diff --git a/.cursor/BUGBOT.md b/.cursor/BUGBOT.md new file mode 100644 index 00000000000..72511621b35 --- /dev/null +++ b/.cursor/BUGBOT.md @@ -0,0 +1,3 @@ +# Guidance for Bugbot + +Please read the [agents file](./AGENTS.md) in the root of the project for instructions. diff --git a/.eslintrc.js b/.eslintrc.js deleted file mode 100644 index fe76a5afee7..00000000000 --- a/.eslintrc.js +++ /dev/null @@ -1,135 +0,0 @@ -module.exports = { - root: true, - extends: ['@metamask/eslint-config', '@metamask/eslint-config-nodejs'], - ignorePatterns: [ - '!.eslintrc.js', - '!jest.config.js', - 'node_modules', - 'dist', - 'docs', - 'coverage', - 'merged-packages', - ], - overrides: [ - { - files: ['*.test.{ts,js}', '**/tests/**/*.{ts,js}'], - extends: ['@metamask/eslint-config-jest'], - rules: { - // TODO: Re-enable - 'import/no-named-as-default-member': 'off', - 'jest/no-conditional-expect': 'off', - }, - }, - { - // These files are test helpers, not tests. We still use the Jest ESLint - // config here to ensure that ESLint expects a test-like environment, but - // various rules meant just to apply to tests have been disabled. - files: ['**/tests/**/*.{ts,js}', '!*.test.{ts,js}'], - rules: { - 'jest/no-export': 'off', - 'jest/require-top-level-describe': 'off', - 'jest/no-if': 'off', - 'jest/no-test-return-statement': 'off', - // TODO: Re-enable this rule; we can accomodate this even in our test helpers - 'jest/expect-expect': 'off', - }, - }, - { - files: ['*.js'], - parserOptions: { - sourceType: 'script', - ecmaVersion: '2018', - }, - }, - { - files: ['*.ts'], - extends: ['@metamask/eslint-config-typescript'], - parserOptions: { - tsconfigRootDir: __dirname, - project: ['./tsconfig.packages.json'], - }, - rules: { - // disabled due to incompatibility with Record - // See https://github.com/Microsoft/TypeScript/issues/15300#issuecomment-702872440 - '@typescript-eslint/consistent-type-definitions': 'off', - - // TODO: auto-fix breaks stuff - '@typescript-eslint/promise-function-async': 'off', - - // TODO: re-enble most of these rules - '@typescript-eslint/await-thenable': 'warn', - '@typescript-eslint/naming-convention': 'off', - '@typescript-eslint/no-floating-promises': 'warn', - '@typescript-eslint/no-for-in-array': 'warn', - '@typescript-eslint/no-loss-of-precision': 'warn', - '@typescript-eslint/no-misused-promises': 'warn', - '@typescript-eslint/no-unnecessary-type-assertion': 'off', - '@typescript-eslint/unbound-method': 'off', - '@typescript-eslint/prefer-enum-initializers': 'off', - '@typescript-eslint/prefer-nullish-coalescing': 'off', - '@typescript-eslint/prefer-optional-chain': 'off', - '@typescript-eslint/prefer-reduce-type-parameter': 'off', - '@typescript-eslint/restrict-plus-operands': 'warn', - '@typescript-eslint/restrict-template-expressions': 'warn', - 'no-restricted-syntax': 'off', - 'no-restricted-globals': 'off', - }, - }, - { - files: ['tests/setupAfterEnv/matchers.ts'], - parserOptions: { - sourceType: 'script', - }, - }, - { - files: ['*.d.ts'], - rules: { - '@typescript-eslint/naming-convention': 'warn', - 'import/unambiguous': 'off', - }, - }, - { - files: ['scripts/*.ts'], - rules: { - // All scripts will have shebangs. - 'n/shebang': 'off', - }, - }, - ], - rules: { - // Left disabled because various properties throughough this repo are snake_case because the - // names come from external sources or must comply with standards - // e.g. `txreceipt_status`, `signTypedData_v4`, `token_id` - camelcase: 'off', - 'id-length': 'off', - - // TODO: re-enble most of these rules - '@typescript-eslint/naming-convention': 'off', - 'function-paren-newline': 'off', - 'guard-for-in': 'off', - 'id-denylist': 'off', - 'implicit-arrow-linebreak': 'off', - 'import/no-anonymous-default-export': 'off', - 'import/no-unassigned-import': 'off', - 'lines-around-comment': 'off', - 'n/no-sync': 'off', - 'no-async-promise-executor': 'off', - 'no-case-declarations': 'off', - 'no-invalid-this': 'off', - 'no-negated-condition': 'off', - 'no-new': 'off', - 'no-param-reassign': 'off', - 'no-restricted-syntax': 'off', - radix: 'off', - 'require-atomic-updates': 'off', - 'jsdoc/match-description': [ - 'off', - { matchDescription: '^[A-Z`\\d_][\\s\\S]*[.?!`>)}]$' }, - ], - }, - settings: { - 'import/resolver': { - typescript: {}, - }, - }, -}; diff --git a/.gitattributes b/.gitattributes index 524151a2322..a79ca9ed555 100644 --- a/.gitattributes +++ b/.gitattributes @@ -2,6 +2,10 @@ yarn.lock linguist-generated=false +# `tsconfig.json` is already recognized as JSONC. This ensures our other `tsconfig` files are as +# well. They all use this naming convention. +tsconfig.**.json linguist-language=jsonc + # yarn v3 # See: https://yarnpkg.com/getting-started/qa#which-files-should-be-gitignored /.yarn/releases/** binary diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 2cfff3ee82e..a2047d1fa8a 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -1,8 +1,368 @@ +# NOTE: This file is autogenerated. +# Don't modify this file directly; instead, modify `codeowners.ts` +# and re-run `yarn codeowners:generate`. + # Lines starting with '#' are comments. # Each line is a file pattern followed by one or more owners. -* @MetaMask/devs +# Please keep this synchronized with the `teams.json` file in the repository root. +# That file is used for some automated workflows, and maps controller to owning team(s). + +/.github/ @MetaMask/core-platform + +## Accounts Team +/packages/accounts-controller @MetaMask/accounts-engineers +/packages/multichain-transactions-controller @MetaMask/accounts-engineers +/packages/multichain-account-service @MetaMask/accounts-engineers +/packages/account-tree-controller @MetaMask/accounts-engineers +/packages/profile-sync-controller @MetaMask/accounts-engineers +/packages/money-account-controller @MetaMask/accounts-engineers +/packages/snap-account-service @MetaMask/accounts-engineers + +## Auth Team +/packages/authenticated-user-storage @MetaMask/auth-engineers + +## Assets Team +/packages/assets-controllers @MetaMask/metamask-assets +/packages/network-enablement-controller @MetaMask/metamask-assets +/packages/assets-controller @MetaMask/metamask-assets + +## Confirmations Team +/packages/address-book-controller @MetaMask/confirmations +/packages/approval-controller @MetaMask/confirmations +/packages/gas-fee-controller @MetaMask/confirmations +/packages/logging-controller @MetaMask/confirmations +/packages/message-manager @MetaMask/confirmations +/packages/name-controller @MetaMask/confirmations +/packages/signature-controller @MetaMask/confirmations +/packages/transaction-controller @MetaMask/confirmations +/packages/transaction-pay-controller @MetaMask/confirmations +/packages/user-operation-controller @MetaMask/confirmations + +## Transactions Team +/packages/smart-transactions-controller @MetaMask/transactions + +## Delegation Team +/packages/delegation-controller @MetaMask/delegation +/packages/gator-permissions-controller @MetaMask/delegation +/packages/eip-7702-internal-rpc-middleware @MetaMask/delegation @MetaMask/core-platform + +## Earn Team +/packages/earn-controller @MetaMask/earn +/packages/money-account-balance-service @MetaMask/earn +/packages/money-account-api-data-service @MetaMask/earn +/packages/chomp-api-service @MetaMask/earn @MetaMask/delegation +/packages/money-account-upgrade-controller @MetaMask/earn @MetaMask/delegation +/packages/money-account-utils @MetaMask/earn + +## Social AI Team +/packages/ai-controllers @MetaMask/social-ai +/packages/social-controllers @MetaMask/social-ai + +## Money Movement Team +/packages/ramps-controller @MetaMask/money-movement + +## Networks Team +/packages/config-registry-controller @MetaMask/networks + +## Engagement Team +/packages/notification-services-controller @MetaMask/engagement + +## Perps Team +/packages/compliance-controller @MetaMask/perps +/packages/perps-controller @MetaMask/perps + +## Product Safety Team +/packages/phishing-controller @MetaMask/product-safety + +## Universal KYC Team +/packages/kyc-controller @MetaMask/universal-kyc + +## Swaps-Bridge Team +/packages/bridge-controller @MetaMask/swaps-engineers +/packages/bridge-status-controller @MetaMask/swaps-engineers + +## Mobile Platform Team +/packages/app-metadata-controller @MetaMask/mobile-platform +/packages/analytics-controller @MetaMask/mobile-platform @MetaMask/extension-platform +/packages/analytics-data-regulation-controller @MetaMask/mobile-platform @MetaMask/extension-platform +/packages/geolocation-controller @MetaMask/mobile-platform + +## Core Platform Team +/packages/base-controller @MetaMask/core-platform +/packages/base-data-service @MetaMask/core-platform +/packages/build-utils @MetaMask/core-platform +/packages/chain-agnostic-permission @MetaMask/core-platform +/packages/composable-controller @MetaMask/core-platform +/packages/connectivity-controller @MetaMask/core-platform +/packages/controller-utils @MetaMask/core-platform +/packages/eip-5792-middleware @MetaMask/core-platform +/packages/eip1193-permission-middleware @MetaMask/core-platform +/packages/eth-block-tracker @MetaMask/core-platform +/packages/eth-json-rpc-middleware @MetaMask/core-platform +/packages/eth-json-rpc-provider @MetaMask/core-platform +/packages/json-rpc-engine @MetaMask/core-platform +/packages/json-rpc-middleware-stream @MetaMask/core-platform +/packages/messenger @MetaMask/core-platform +/packages/messenger-cli @MetaMask/core-platform +/packages/multichain-api-middleware @MetaMask/core-platform +/packages/network-connection-banner-controller @MetaMask/core-platform +/packages/permission-controller @MetaMask/core-platform +/packages/permission-log-controller @MetaMask/core-platform +/packages/platform-api-docs @MetaMask/core-platform +/packages/polling-controller @MetaMask/core-platform +/packages/preferences-controller @MetaMask/core-platform +/packages/rate-limit-controller @MetaMask/core-platform +/packages/react-data-query @MetaMask/core-platform +/packages/sample-controllers @MetaMask/core-platform +/packages/selected-network-controller @MetaMask/core-platform +/packages/wallet @MetaMask/core-platform +/packages/wallet-cli @MetaMask/core-platform +/packages/wallet-framework-docs @MetaMask/core-platform + +## Web3Auth Team +/packages/seedless-onboarding-controller @MetaMask/web3auth +/packages/passkey-controller @MetaMask/web3auth +/packages/shield-controller @MetaMask/web3auth +/packages/subscription-controller @MetaMask/web3auth +/packages/claims-controller @MetaMask/web3auth + +## Universal KYC Team +/packages/kyc-controller @MetaMask/universal-kyc + +## Joint team ownership +/packages/announcement-controller @MetaMask/core-extension-ux @MetaMask/mobile-core-ux +/packages/client-utils @MetaMask/core-extension-ux @MetaMask/mobile-core-ux +/packages/core-backend @MetaMask/core-platform @MetaMask/metamask-assets +/packages/eth-json-rpc-middleware/src/methods @MetaMask/confirmations @MetaMask/core-platform +/packages/eth-json-rpc-middleware/src/wallet.* @MetaMask/confirmations @MetaMask/core-platform +/packages/foundryup @MetaMask/mobile-platform @MetaMask/extension-platform +/packages/bitcoin-regtest-up @MetaMask/mobile-platform @MetaMask/extension-platform @MetaMask/networks +/packages/java-tron-up @MetaMask/mobile-platform @MetaMask/extension-platform @MetaMask/networks +/packages/local-node-utils @MetaMask/mobile-platform @MetaMask/extension-platform @MetaMask/networks +/packages/solana-test-validator-up @MetaMask/mobile-platform @MetaMask/extension-platform @MetaMask/networks +/packages/stellar-quickstart-up @MetaMask/mobile-platform @MetaMask/extension-platform @MetaMask/networks +/packages/keyring-controller @MetaMask/accounts-engineers @MetaMask/core-platform +/packages/multichain-network-controller @MetaMask/core-platform @MetaMask/accounts-engineers @MetaMask/metamask-assets +/packages/network-controller @MetaMask/core-platform @MetaMask/metamask-assets +/packages/remote-feature-flag-controller @MetaMask/extension-platform @MetaMask/mobile-platform @MetaMask/core-platform +/packages/sentinel-api-service @MetaMask/confirmations @MetaMask/transactions +/packages/storage-service @MetaMask/extension-platform @MetaMask/mobile-platform @MetaMask/core-platform +/packages/client-controller @MetaMask/core-platform @MetaMask/extension-platform @MetaMask/mobile-platform +/packages/profile-metrics-controller @MetaMask/mobile-platform @MetaMask/extension-platform + +## Initialization +/packages/wallet/src/initialization/instances/accounts-controller/ @MetaMask/accounts-engineers +/packages/wallet/src/initialization/instances/address-book-controller/ @MetaMask/confirmations +/packages/wallet/src/initialization/instances/approval-controller/ @MetaMask/confirmations +/packages/wallet/src/initialization/instances/claims-controller/ @MetaMask/web3auth +/packages/wallet/src/initialization/instances/claims-service/ @MetaMask/web3auth +/packages/wallet/src/initialization/instances/connectivity-controller/ @MetaMask/core-platform +/packages/wallet/src/initialization/instances/gas-fee-controller/ @MetaMask/confirmations +/packages/wallet/src/initialization/instances/keyring-controller/ @MetaMask/accounts-engineers @MetaMask/core-platform +/packages/wallet/src/initialization/instances/passkey-controller/ @MetaMask/web3auth +/packages/wallet/src/initialization/instances/remote-feature-flag-controller/ @MetaMask/extension-platform @MetaMask/mobile-platform @MetaMask/core-platform +/packages/wallet/src/initialization/instances/seedless-onboarding-controller/ @MetaMask/web3auth +/packages/wallet/src/initialization/instances/shield-api-service/ @MetaMask/web3auth +/packages/wallet/src/initialization/instances/shield-controller/ @MetaMask/web3auth +/packages/wallet/src/initialization/instances/storage-service/ @MetaMask/extension-platform @MetaMask/mobile-platform @MetaMask/core-platform +/packages/wallet/src/initialization/instances/subscription-controller/ @MetaMask/web3auth +/packages/wallet/src/initialization/instances/subscription-service/ @MetaMask/web3auth +/packages/wallet/src/initialization/instances/transaction-controller/ @MetaMask/confirmations -/packages/permission-controller @MetaMask/snaps-devs -/packages/notification-controller @MetaMask/snaps-devs -/packages/rate-limit-controller @MetaMask/snaps-devs +## Package Release related +/packages/account-tree-controller/package.json @MetaMask/accounts-engineers @MetaMask/core-platform +/packages/account-tree-controller/CHANGELOG.md @MetaMask/accounts-engineers @MetaMask/core-platform +/packages/account-tree-controller/tsconfig.* @MetaMask/accounts-engineers @MetaMask/core-platform +/packages/accounts-controller/package.json @MetaMask/accounts-engineers @MetaMask/core-platform +/packages/accounts-controller/CHANGELOG.md @MetaMask/accounts-engineers @MetaMask/core-platform +/packages/accounts-controller/tsconfig.* @MetaMask/accounts-engineers @MetaMask/core-platform +/packages/analytics-controller/package.json @MetaMask/mobile-platform @MetaMask/extension-platform @MetaMask/core-platform +/packages/analytics-controller/CHANGELOG.md @MetaMask/mobile-platform @MetaMask/extension-platform @MetaMask/core-platform +/packages/analytics-controller/tsconfig.* @MetaMask/mobile-platform @MetaMask/extension-platform @MetaMask/core-platform +/packages/analytics-data-regulation-controller/package.json @MetaMask/mobile-platform @MetaMask/extension-platform @MetaMask/core-platform +/packages/analytics-data-regulation-controller/CHANGELOG.md @MetaMask/mobile-platform @MetaMask/extension-platform @MetaMask/core-platform +/packages/analytics-data-regulation-controller/tsconfig.* @MetaMask/mobile-platform @MetaMask/extension-platform @MetaMask/core-platform +/packages/address-book-controller/package.json @MetaMask/confirmations @MetaMask/core-platform +/packages/address-book-controller/CHANGELOG.md @MetaMask/confirmations @MetaMask/core-platform +/packages/address-book-controller/tsconfig.* @MetaMask/confirmations @MetaMask/core-platform +/packages/announcement-controller/package.json @MetaMask/core-extension-ux @MetaMask/mobile-core-ux @MetaMask/core-platform +/packages/announcement-controller/CHANGELOG.md @MetaMask/core-extension-ux @MetaMask/mobile-core-ux @MetaMask/core-platform +/packages/announcement-controller/tsconfig.* @MetaMask/core-extension-ux @MetaMask/mobile-core-ux @MetaMask/core-platform +/packages/client-utils/package.json @MetaMask/core-extension-ux @MetaMask/mobile-core-ux @MetaMask/core-platform +/packages/client-utils/CHANGELOG.md @MetaMask/core-extension-ux @MetaMask/mobile-core-ux @MetaMask/core-platform +/packages/client-utils/tsconfig.* @MetaMask/core-extension-ux @MetaMask/mobile-core-ux @MetaMask/core-platform +/packages/approval-controller/package.json @MetaMask/confirmations @MetaMask/core-platform +/packages/approval-controller/CHANGELOG.md @MetaMask/confirmations @MetaMask/core-platform +/packages/approval-controller/tsconfig.* @MetaMask/confirmations @MetaMask/core-platform +/packages/assets-controllers/package.json @MetaMask/metamask-assets @MetaMask/core-platform +/packages/assets-controllers/CHANGELOG.md @MetaMask/metamask-assets @MetaMask/core-platform +/packages/assets-controllers/tsconfig.* @MetaMask/metamask-assets @MetaMask/core-platform +/packages/assets-controller/package.json @MetaMask/metamask-assets @MetaMask/core-platform +/packages/assets-controller/CHANGELOG.md @MetaMask/metamask-assets @MetaMask/core-platform +/packages/assets-controller/tsconfig.* @MetaMask/metamask-assets @MetaMask/core-platform +/packages/config-registry-controller/package.json @MetaMask/networks @MetaMask/core-platform +/packages/config-registry-controller/CHANGELOG.md @MetaMask/networks @MetaMask/core-platform +/packages/config-registry-controller/tsconfig.* @MetaMask/networks @MetaMask/core-platform +/packages/delegation-controller/package.json @MetaMask/delegation @MetaMask/core-platform +/packages/delegation-controller/CHANGELOG.md @MetaMask/delegation @MetaMask/core-platform +/packages/delegation-controller/tsconfig.* @MetaMask/delegation @MetaMask/core-platform +/packages/earn-controller/package.json @MetaMask/earn @MetaMask/core-platform +/packages/earn-controller/CHANGELOG.md @MetaMask/earn @MetaMask/core-platform +/packages/earn-controller/tsconfig.* @MetaMask/earn @MetaMask/core-platform +/packages/money-account-balance-service/package.json @MetaMask/earn @MetaMask/core-platform +/packages/money-account-balance-service/CHANGELOG.md @MetaMask/earn @MetaMask/core-platform +/packages/money-account-balance-service/tsconfig.* @MetaMask/earn @MetaMask/core-platform +/packages/money-account-api-data-service/package.json @MetaMask/earn @MetaMask/core-platform +/packages/money-account-api-data-service/CHANGELOG.md @MetaMask/earn @MetaMask/core-platform +/packages/money-account-api-data-service/tsconfig.* @MetaMask/earn @MetaMask/core-platform +/packages/gas-fee-controller/package.json @MetaMask/confirmations @MetaMask/core-platform +/packages/gas-fee-controller/CHANGELOG.md @MetaMask/confirmations @MetaMask/core-platform +/packages/gas-fee-controller/tsconfig.* @MetaMask/confirmations @MetaMask/core-platform +/packages/gator-permissions-controller/package.json @MetaMask/delegation @MetaMask/core-platform +/packages/gator-permissions-controller/CHANGELOG.md @MetaMask/delegation @MetaMask/core-platform +/packages/gator-permissions-controller/tsconfig.* @MetaMask/delegation @MetaMask/core-platform +/packages/geolocation-controller/package.json @MetaMask/mobile-platform @MetaMask/core-platform +/packages/geolocation-controller/CHANGELOG.md @MetaMask/mobile-platform @MetaMask/core-platform +/packages/geolocation-controller/tsconfig.* @MetaMask/mobile-platform @MetaMask/core-platform +/packages/keyring-controller/package.json @MetaMask/accounts-engineers @MetaMask/core-platform +/packages/keyring-controller/CHANGELOG.md @MetaMask/accounts-engineers @MetaMask/core-platform +/packages/keyring-controller/tsconfig.* @MetaMask/accounts-engineers @MetaMask/core-platform +/packages/passkey-controller/package.json @MetaMask/web3auth @MetaMask/core-platform +/packages/passkey-controller/CHANGELOG.md @MetaMask/web3auth @MetaMask/core-platform +/packages/passkey-controller/tsconfig.* @MetaMask/web3auth @MetaMask/core-platform +/packages/logging-controller/package.json @MetaMask/confirmations @MetaMask/core-platform +/packages/logging-controller/CHANGELOG.md @MetaMask/confirmations @MetaMask/core-platform +/packages/logging-controller/tsconfig.* @MetaMask/confirmations @MetaMask/core-platform +/packages/message-manager/package.json @MetaMask/confirmations @MetaMask/core-platform +/packages/message-manager/CHANGELOG.md @MetaMask/confirmations @MetaMask/core-platform +/packages/message-manager/tsconfig.* @MetaMask/confirmations @MetaMask/core-platform +/packages/multichain-account-service/package.json @MetaMask/accounts-engineers @MetaMask/core-platform +/packages/multichain-account-service/CHANGELOG.md @MetaMask/accounts-engineers @MetaMask/core-platform +/packages/multichain-account-service/tsconfig.* @MetaMask/accounts-engineers @MetaMask/core-platform +/packages/name-controller/package.json @MetaMask/confirmations @MetaMask/core-platform +/packages/name-controller/CHANGELOG.md @MetaMask/confirmations @MetaMask/core-platform +/packages/name-controller/tsconfig.* @MetaMask/confirmations @MetaMask/core-platform +/packages/notification-services-controller/package.json @MetaMask/engagement @MetaMask/core-platform +/packages/notification-services-controller/CHANGELOG.md @MetaMask/engagement @MetaMask/core-platform +/packages/notification-services-controller/tsconfig.* @MetaMask/engagement @MetaMask/core-platform +/packages/compliance-controller/package.json @MetaMask/perps @MetaMask/core-platform +/packages/compliance-controller/CHANGELOG.md @MetaMask/perps @MetaMask/core-platform +/packages/compliance-controller/tsconfig.* @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/perps-controller/tsconfig.* @MetaMask/perps @MetaMask/core-platform +/packages/phishing-controller/package.json @MetaMask/product-safety @MetaMask/core-platform +/packages/phishing-controller/CHANGELOG.md @MetaMask/product-safety @MetaMask/core-platform +/packages/phishing-controller/tsconfig.* @MetaMask/product-safety @MetaMask/core-platform +/packages/ramps-controller/package.json @MetaMask/money-movement @MetaMask/core-platform +/packages/ramps-controller/CHANGELOG.md @MetaMask/money-movement @MetaMask/core-platform +/packages/ramps-controller/tsconfig.* @MetaMask/money-movement @MetaMask/core-platform +/packages/authenticated-user-storage/package.json @MetaMask/auth-engineers @MetaMask/core-platform +/packages/authenticated-user-storage/CHANGELOG.md @MetaMask/auth-engineers @MetaMask/core-platform +/packages/authenticated-user-storage/tsconfig.* @MetaMask/auth-engineers @MetaMask/core-platform +/packages/profile-metrics-controller/package.json @MetaMask/mobile-platform @MetaMask/extension-platform @MetaMask/core-platform +/packages/profile-metrics-controller/CHANGELOG.md @MetaMask/mobile-platform @MetaMask/extension-platform @MetaMask/core-platform +/packages/profile-metrics-controller/tsconfig.* @MetaMask/mobile-platform @MetaMask/extension-platform @MetaMask/core-platform +/packages/profile-sync-controller/package.json @MetaMask/accounts-engineers @MetaMask/core-platform +/packages/profile-sync-controller/CHANGELOG.md @MetaMask/accounts-engineers @MetaMask/core-platform +/packages/profile-sync-controller/tsconfig.* @MetaMask/accounts-engineers @MetaMask/core-platform +/packages/signature-controller/package.json @MetaMask/confirmations @MetaMask/core-platform +/packages/signature-controller/CHANGELOG.md @MetaMask/confirmations @MetaMask/core-platform +/packages/signature-controller/tsconfig.* @MetaMask/confirmations @MetaMask/core-platform +/packages/smart-transactions-controller/package.json @MetaMask/transactions @MetaMask/core-platform +/packages/smart-transactions-controller/CHANGELOG.md @MetaMask/transactions @MetaMask/core-platform +/packages/smart-transactions-controller/tsconfig.* @MetaMask/transactions @MetaMask/core-platform +/packages/sentinel-api-service/package.json @MetaMask/confirmations @MetaMask/transactions @MetaMask/core-platform +/packages/sentinel-api-service/CHANGELOG.md @MetaMask/confirmations @MetaMask/transactions @MetaMask/core-platform +/packages/sentinel-api-service/tsconfig.* @MetaMask/confirmations @MetaMask/transactions @MetaMask/core-platform +/packages/transaction-controller/package.json @MetaMask/confirmations @MetaMask/core-platform +/packages/transaction-controller/CHANGELOG.md @MetaMask/confirmations @MetaMask/core-platform +/packages/transaction-controller/tsconfig.* @MetaMask/confirmations @MetaMask/core-platform +/packages/transaction-pay-controller/package.json @MetaMask/confirmations @MetaMask/core-platform +/packages/transaction-pay-controller/CHANGELOG.md @MetaMask/confirmations @MetaMask/core-platform +/packages/transaction-pay-controller/tsconfig.* @MetaMask/confirmations @MetaMask/core-platform +/packages/user-operation-controller/package.json @MetaMask/confirmations @MetaMask/core-platform +/packages/user-operation-controller/CHANGELOG.md @MetaMask/confirmations @MetaMask/core-platform +/packages/user-operation-controller/tsconfig.* @MetaMask/confirmations @MetaMask/core-platform +/packages/multichain-transactions-controller/package.json @MetaMask/accounts-engineers @MetaMask/core-platform +/packages/multichain-transactions-controller/CHANGELOG.md @MetaMask/accounts-engineers @MetaMask/core-platform +/packages/multichain-transactions-controller/tsconfig.* @MetaMask/accounts-engineers @MetaMask/core-platform +/packages/bridge-controller/package.json @MetaMask/swaps-engineers @MetaMask/core-platform +/packages/bridge-controller/CHANGELOG.md @MetaMask/swaps-engineers @MetaMask/core-platform +/packages/bridge-controller/tsconfig.* @MetaMask/swaps-engineers @MetaMask/core-platform +/packages/remote-feature-flag-controller/package.json @MetaMask/extension-platform @MetaMask/mobile-platform @MetaMask/core-platform +/packages/remote-feature-flag-controller/CHANGELOG.md @MetaMask/extension-platform @MetaMask/mobile-platform @MetaMask/core-platform +/packages/remote-feature-flag-controller/tsconfig.* @MetaMask/extension-platform @MetaMask/mobile-platform @MetaMask/core-platform +/packages/storage-service/package.json @MetaMask/extension-platform @MetaMask/mobile-platform @MetaMask/core-platform +/packages/storage-service/CHANGELOG.md @MetaMask/extension-platform @MetaMask/mobile-platform @MetaMask/core-platform +/packages/storage-service/tsconfig.* @MetaMask/extension-platform @MetaMask/mobile-platform @MetaMask/core-platform +/packages/bridge-status-controller/package.json @MetaMask/swaps-engineers @MetaMask/core-platform +/packages/bridge-status-controller/CHANGELOG.md @MetaMask/swaps-engineers @MetaMask/core-platform +/packages/bridge-status-controller/tsconfig.* @MetaMask/swaps-engineers @MetaMask/core-platform +/packages/app-metadata-controller/package.json @MetaMask/mobile-platform @MetaMask/core-platform +/packages/app-metadata-controller/CHANGELOG.md @MetaMask/mobile-platform @MetaMask/core-platform +/packages/app-metadata-controller/tsconfig.* @MetaMask/mobile-platform @MetaMask/core-platform +/packages/foundryup/package.json @MetaMask/mobile-platform @MetaMask/extension-platform @MetaMask/core-platform +/packages/foundryup/CHANGELOG.md @MetaMask/mobile-platform @MetaMask/extension-platform @MetaMask/core-platform +/packages/foundryup/tsconfig.* @MetaMask/mobile-platform @MetaMask/extension-platform @MetaMask/core-platform +/packages/bitcoin-regtest-up/package.json @MetaMask/mobile-platform @MetaMask/extension-platform @MetaMask/networks @MetaMask/core-platform +/packages/bitcoin-regtest-up/CHANGELOG.md @MetaMask/mobile-platform @MetaMask/extension-platform @MetaMask/networks @MetaMask/core-platform +/packages/bitcoin-regtest-up/tsconfig.* @MetaMask/mobile-platform @MetaMask/extension-platform @MetaMask/networks @MetaMask/core-platform +/packages/java-tron-up/package.json @MetaMask/mobile-platform @MetaMask/extension-platform @MetaMask/networks @MetaMask/core-platform +/packages/java-tron-up/CHANGELOG.md @MetaMask/mobile-platform @MetaMask/extension-platform @MetaMask/networks @MetaMask/core-platform +/packages/java-tron-up/tsconfig.* @MetaMask/mobile-platform @MetaMask/extension-platform @MetaMask/networks @MetaMask/core-platform +/packages/local-node-utils/package.json @MetaMask/mobile-platform @MetaMask/extension-platform @MetaMask/networks @MetaMask/core-platform +/packages/local-node-utils/CHANGELOG.md @MetaMask/mobile-platform @MetaMask/extension-platform @MetaMask/networks @MetaMask/core-platform +/packages/local-node-utils/tsconfig.* @MetaMask/mobile-platform @MetaMask/extension-platform @MetaMask/networks @MetaMask/core-platform +/packages/solana-test-validator-up/package.json @MetaMask/mobile-platform @MetaMask/extension-platform @MetaMask/networks @MetaMask/core-platform +/packages/solana-test-validator-up/CHANGELOG.md @MetaMask/mobile-platform @MetaMask/extension-platform @MetaMask/networks @MetaMask/core-platform +/packages/solana-test-validator-up/tsconfig.* @MetaMask/mobile-platform @MetaMask/extension-platform @MetaMask/networks @MetaMask/core-platform +/packages/stellar-quickstart-up/package.json @MetaMask/mobile-platform @MetaMask/extension-platform @MetaMask/networks @MetaMask/core-platform +/packages/stellar-quickstart-up/CHANGELOG.md @MetaMask/mobile-platform @MetaMask/extension-platform @MetaMask/networks @MetaMask/core-platform +/packages/stellar-quickstart-up/tsconfig.* @MetaMask/mobile-platform @MetaMask/extension-platform @MetaMask/networks @MetaMask/core-platform +/packages/seedless-onboarding-controller/package.json @MetaMask/web3auth @MetaMask/core-platform +/packages/seedless-onboarding-controller/CHANGELOG.md @MetaMask/web3auth @MetaMask/core-platform +/packages/seedless-onboarding-controller/tsconfig.* @MetaMask/web3auth @MetaMask/core-platform +/packages/shield-controller/package.json @MetaMask/web3auth @MetaMask/core-platform +/packages/shield-controller/CHANGELOG.md @MetaMask/web3auth @MetaMask/core-platform +/packages/shield-controller/tsconfig.* @MetaMask/web3auth @MetaMask/core-platform +/packages/network-enablement-controller/package.json @MetaMask/metamask-assets @MetaMask/core-platform +/packages/network-enablement-controller/CHANGELOG.md @MetaMask/metamask-assets @MetaMask/core-platform +/packages/network-enablement-controller/tsconfig.* @MetaMask/metamask-assets @MetaMask/core-platform +/packages/subscription-controller/package.json @MetaMask/web3auth @MetaMask/core-platform +/packages/subscription-controller/CHANGELOG.md @MetaMask/web3auth @MetaMask/core-platform +/packages/subscription-controller/tsconfig.* @MetaMask/web3auth @MetaMask/core-platform +/packages/core-backend/package.json @MetaMask/core-platform @MetaMask/metamask-assets +/packages/core-backend/CHANGELOG.md @MetaMask/core-platform @MetaMask/metamask-assets +/packages/core-backend/tsconfig.* @MetaMask/core-platform @MetaMask/metamask-assets +/packages/claims-controller/package.json @MetaMask/web3auth @MetaMask/core-platform +/packages/claims-controller/CHANGELOG.md @MetaMask/web3auth @MetaMask/core-platform +/packages/claims-controller/tsconfig.* @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 +/packages/ai-controllers/tsconfig.* @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 +/packages/client-controller/tsconfig.* @MetaMask/core-platform @MetaMask/extension-platform @MetaMask/mobile-platform +/packages/social-controllers/package.json @MetaMask/social-ai @MetaMask/core-platform +/packages/social-controllers/CHANGELOG.md @MetaMask/social-ai @MetaMask/core-platform +/packages/social-controllers/tsconfig.* @MetaMask/social-ai @MetaMask/core-platform +/packages/money-account-controller/package.json @MetaMask/accounts-engineers @MetaMask/core-platform +/packages/money-account-controller/CHANGELOG.md @MetaMask/accounts-engineers @MetaMask/core-platform +/packages/money-account-controller/tsconfig.* @MetaMask/accounts-engineers @MetaMask/core-platform +/packages/chomp-api-service/package.json @MetaMask/earn @MetaMask/delegation @MetaMask/core-platform +/packages/chomp-api-service/CHANGELOG.md @MetaMask/earn @MetaMask/delegation @MetaMask/core-platform +/packages/chomp-api-service/tsconfig.* @MetaMask/earn @MetaMask/delegation @MetaMask/core-platform +/packages/money-account-upgrade-controller/package.json @MetaMask/earn @MetaMask/delegation @MetaMask/core-platform +/packages/money-account-upgrade-controller/CHANGELOG.md @MetaMask/earn @MetaMask/delegation @MetaMask/core-platform +/packages/money-account-upgrade-controller/tsconfig.* @MetaMask/earn @MetaMask/delegation @MetaMask/core-platform +/packages/money-account-utils/package.json @MetaMask/earn @MetaMask/core-platform +/packages/money-account-utils/CHANGELOG.md @MetaMask/earn @MetaMask/core-platform +/packages/money-account-utils/tsconfig.* @MetaMask/earn @MetaMask/core-platform +/packages/snap-account-service/package.json @MetaMask/accounts-engineers @MetaMask/core-platform +/packages/snap-account-service/CHANGELOG.md @MetaMask/accounts-engineers @MetaMask/core-platform +/packages/snap-account-service/tsconfig.* @MetaMask/accounts-engineers @MetaMask/core-platform +/packages/kyc-controller/package.json @MetaMask/universal-kyc @MetaMask/core-platform +/packages/kyc-controller/CHANGELOG.md @MetaMask/universal-kyc @MetaMask/core-platform +/packages/kyc-controller/tsconfig.* @MetaMask/universal-kyc @MetaMask/core-platform \ No newline at end of file diff --git a/.github/actionlint.yml b/.github/actionlint.yml new file mode 100644 index 00000000000..1fdf7ac344b --- /dev/null +++ b/.github/actionlint.yml @@ -0,0 +1,8 @@ +# Please see the documentation for all configuration options: +# https://github.com/rhysd/actionlint/blob/main/docs/config.md#configuration-file + +paths: + .github/workflows/publish-release.yml: + ignore: + # Queue option is not supported by actionlint yet. + - 'unexpected key "queue" for "concurrency" section. expected one of "cancel-in-progress", "group"' diff --git a/.github/actions/check-merge-queue-changelogs/action.yml b/.github/actions/check-merge-queue-changelogs/action.yml new file mode 100644 index 00000000000..644844318c1 --- /dev/null +++ b/.github/actions/check-merge-queue-changelogs/action.yml @@ -0,0 +1,99 @@ +name: Check merge queue changelogs +description: Check if the changelog was incorrectly merged in a merge queue + pull request. + +inputs: + github-token: + description: The GitHub token to use for authentication. + required: false + default: ${{ github.token }} + +runs: + using: composite + steps: + - name: Checkout repository + uses: actions/checkout@v6 + with: + fetch-depth: 0 + persist-credentials: false + + - name: Get pull request number + id: pr-number + uses: actions/github-script@v8 + env: + HEAD_REF: ${{ github.event.pull_request.head.ref || github.event.merge_group.head_ref }} + with: + github-token: ${{ inputs.github-token }} + script: | + const { HEAD_REF } = process.env; + + if (context.eventName === 'pull_request') { + const prNumber = context.payload.pull_request.number; + return core.setOutput('pr-number', prNumber); + } + + const match = HEAD_REF.match(/\/pr-([0-9]+)-/u); + if (!match) { + return core.setFailed(`Could not extract pull request number from head ref: "${HEAD_REF}".`); + } + + const number = parseInt(match[1], 10); + core.setOutput('pr-number', number); + + - name: Get pull request branch + id: pr-branch + shell: bash + env: + REPOSITORY: ${{ github.repository }} + PR_NUMBER: ${{ steps.pr-number.outputs.pr-number }} + GH_TOKEN: ${{ inputs.github-token }} + run: | + BRANCH=$(gh api "/repos/${REPOSITORY}/pulls/${PR_NUMBER}" --jq=.head.ref) + echo "pr-branch=$BRANCH" >> "$GITHUB_OUTPUT" + + - name: Check changelog changes + id: changelog-check + shell: bash + env: + BASE_REF: ${{ github.event.pull_request.base.ref || github.event.merge_group.base_ref }} + PR_BRANCH: ${{ steps.pr-branch.outputs.pr-branch }} + ACTION_PATH: ${{ github.action_path }} + run: | + set -euo pipefail + + # Strip invalid prefix from `BASE_REF` + # It comes prefixed with `refs/heads/`, but the branch is not checked out in this context + # We need to express it as a remote branch + PREFIXED_REF_REGEX='refs/heads/(.+)' + if [[ "$BASE_REF" =~ $PREFIXED_REF_REGEX ]]; then + BASE_REF="${BASH_REMATCH[1]}" + fi + + TARGET_REF=$(git merge-base "origin/$BASE_REF" "origin/$PR_BRANCH") + git fetch origin "$TARGET_REF" + + UPDATED_CHANGELOGS=$(git diff --name-only "$TARGET_REF" "origin/$PR_BRANCH" | grep -E 'CHANGELOG\.md$' || true) + if [ -n "$UPDATED_CHANGELOGS" ]; then + for FILE in $UPDATED_CHANGELOGS; do + if [ ! -f "$FILE" ]; then + echo "Changelog file \"$FILE\" was deleted in this PR. Skipping." + continue + fi + + if ! git cat-file -e "$TARGET_REF":"$FILE" 2>/dev/null; then + echo "Changelog file \"$FILE\" is new in this PR. Skipping." + continue + fi + + echo "Checking changelog file: $FILE" + git show "$TARGET_REF":"$FILE" > /tmp/base-changelog.md + git show origin/"$PR_BRANCH":"$FILE" > /tmp/pr-changelog.md + + node "${ACTION_PATH}/check-changelog-diff.cjs" \ + /tmp/base-changelog.md \ + /tmp/pr-changelog.md \ + "$FILE" + done + else + echo "No CHANGELOG.md files were modified in this PR." + fi diff --git a/.github/actions/check-merge-queue-changelogs/check-changelog-diff.cjs b/.github/actions/check-merge-queue-changelogs/check-changelog-diff.cjs new file mode 100644 index 00000000000..5e5b9b784e4 --- /dev/null +++ b/.github/actions/check-merge-queue-changelogs/check-changelog-diff.cjs @@ -0,0 +1,101 @@ +// This script checks that any new changelog entries added in a PR +// remain in the [Unreleased] section after the PR is merged. + +const fs = require('fs'); + +if (process.argv.length < 5) { + console.error( + 'Usage: node check-changelog-diff.cjs ', + ); + + // eslint-disable-next-line n/no-process-exit + process.exit(1); +} + +/* eslint-disable n/no-sync */ +// The type of these is inferred as `Buffer` when using "utf-8" directly instead +// of an options object. Even though it's a plain JavaScript file, it's nice to +// keep the types correct. +const baseContent = fs.readFileSync(process.argv[2], { + encoding: 'utf-8', +}); + +const prContent = fs.readFileSync(process.argv[3], { + encoding: 'utf-8', +}); + +const mergedContent = fs.readFileSync(process.argv[4], { + encoding: 'utf-8', +}); +/* eslint-enable n/no-sync */ + +/** + * Extract the "[Unreleased]" section from the changelog content. + * + * This doesn't actually parse the Markdown, it just looks for the section + * header and collects lines until the next section header. + * + * @param {string} content - The changelog content. + * @returns {Set} The lines in the "[Unreleased]" section as a + * {@link Set}. + */ +function getUnreleasedSection(content) { + const lines = content.split('\n'); + + let inUnreleased = false; + const sectionLines = new Set(); + + for (const line of lines) { + // Find unreleased header. + if (line.trim().match(/^##\s+\[Unreleased\]/u)) { + inUnreleased = true; + continue; + } + + // Stop if we hit the next version header (## [x.x.x]). + if (inUnreleased && line.trim().match(/^##\s+\[/u)) { + break; + } + + // If inside the unreleased header, add lines to the set. + if (inUnreleased) { + sectionLines.add(line.trim()); + } + } + + return sectionLines; +} + +/** + * Get the lines that were added in the PR content compared to the base content. + * + * @param {Set} oldLines - The base changelog content. + * @param {Set} newLines - The PR changelog content. + * @returns {string[]} The added lines as an array of strings. + */ +function getAddedLines(oldLines, newLines) { + return Array.from(newLines).filter( + (line) => line.length > 0 && !oldLines.has(line) && !line.startsWith('#'), + ); +} + +const mergedUnreleased = getUnreleasedSection(mergedContent); +const addedLines = getAddedLines( + getUnreleasedSection(baseContent), + getUnreleasedSection(prContent), +); + +const missingLines = []; +for (const line of addedLines) { + if (!mergedUnreleased.has(line)) { + missingLines.push(line); + } +} + +if (missingLines.length > 0) { + console.error( + `The following lines added in the PR are missing from the "Unreleased" section after merge:\n\n ${missingLines.join('\n ')}\n\nPlease update your pull request and ensure that new changelog entries remain in the "Unreleased" section.`, + ); + + process.exitCode = 1; +} diff --git a/.github/actions/check-release/action.yml b/.github/actions/check-release/action.yml new file mode 100644 index 00000000000..f1f2196046d --- /dev/null +++ b/.github/actions/check-release/action.yml @@ -0,0 +1,265 @@ +name: Check release +description: Check for conflicts in packages being released in this PR. + +inputs: + commit-starts-with: + description: "Validate that the release commit starts with a string in this comma-separated list. Use '[version]' to refer to the current release version." + required: true + +runs: + using: composite + steps: + - name: Checkout and setup environment + uses: MetaMask/action-checkout-and-setup@v3 + with: + is-high-risk-environment: false + skip-install: true + persist-credentials: false + + - name: Get merge base + id: merge-base + shell: bash + env: + BASE_REF: ${{ github.event.pull_request.base.ref || github.event.merge_group.base_ref }} + run: | + set -euo pipefail + + # Strip invalid prefix from `github.event.merge_group.base_ref` + # It comes prefixed with `refs/heads/`, but the branch is not checked out in this context + # We need to express it as a remote branch + PREFIXED_REF_REGEX='refs/heads/(.+)' + if [[ "$BASE_REF" =~ $PREFIXED_REF_REGEX ]]; then + BASE_REF="${BASH_REMATCH[1]}" + fi + + MERGE_BASE=$(git merge-base HEAD "refs/remotes/origin/$BASE_REF") + echo "MERGE_BASE=$MERGE_BASE" >> "$GITHUB_OUTPUT" + + - name: Check that root package.json is bumped for release PRs + shell: bash + id: check-root-package + env: + MERGE_BASE: ${{ steps.merge-base.outputs.MERGE_BASE }} + run: | + set -euo pipefail + + mapfile -t PACKAGES < <(yarn workspaces list --json --no-private | jq --raw-output '.location + "/package.json"') + ANY_PACKAGE_BUMPED=false + + for package in "${PACKAGES[@]}"; do + if ! PACKAGE_JSON_AT_BASE=$(git show "$MERGE_BASE:$package" 2>/dev/null); then + # Package didn't exist at the merge base; it's newly added, not a + # version bump. + continue + fi + CURRENT_VERSION=$(echo "$PACKAGE_JSON_AT_BASE" | jq -r .version) + NEW_VERSION=$(jq -r .version "$package") + + if [[ "$NEW_VERSION" != "0.0.0" && "$NEW_VERSION" != "$CURRENT_VERSION" ]]; then + ANY_PACKAGE_BUMPED=true + package_name=$(jq -r ".name" "$package") + echo "📦 Package \`$package_name\` has a version bump (\`$CURRENT_VERSION\` -> \`$NEW_VERSION\`)" + fi + done + + if [ "$ANY_PACKAGE_BUMPED" = "true" ]; then + CURRENT_ROOT_VERSION=$(git show "$MERGE_BASE:package.json" | jq -r .version) + NEW_ROOT_VERSION=$(jq -r .version package.json) + + if [ "$NEW_ROOT_VERSION" = "$CURRENT_ROOT_VERSION" ]; then + echo "::error::This pull request is a release (one or more packages have a version bump), but the root package.json version was not bumped. Please bump the root package.json version." + exit 1 + else + echo "✅ Root package.json is bumped (\`$CURRENT_ROOT_VERSION\` -> \`$NEW_ROOT_VERSION\`)" + echo "root-package-bumped=true" >> "$GITHUB_OUTPUT" + fi + else + echo "✅ No package version bumps detected; skipping root package.json check." + fi + + - name: Check if the commit or pull request is a release + id: is-release + uses: MetaMask/action-is-release@v2 + with: + commit-starts-with: ${{ inputs.commit-starts-with }} + commit-message: ${{ github.event.pull_request.title }} + before: ${{ steps.merge-base.outputs.MERGE_BASE }} + skip-checkout: true + + - name: Fail if root package is bumped but not detected as release + if: github.event_name == 'pull_request' && steps.is-release.outputs.IS_RELEASE != 'true' && steps.check-root-package.outputs.root-package-bumped == 'true' + shell: bash + env: + COMMIT_STARTS_WITH: ${{ inputs.commit-starts-with }} + run: | + echo "::error::This pull request is a release, but the release check did not detect it as such. Please ensure that the commit message starts with one of the following prefixes: $COMMIT_STARTS_WITH." + exit 1 + + - name: Get pull request number + id: pr-number + if: steps.is-release.outputs.IS_RELEASE == 'true' + shell: bash + env: + EVENT_NAME: ${{ github.event_name }} + PULL_REQUEST_NUMBER: ${{ github.event.pull_request.number }} + MERGE_GROUP_HEAD_REF: ${{ github.event.merge_group.head_ref }} + run: | + if [ "$EVENT_NAME" = "pull_request" ]; then + echo "PR_NUMBER=$PULL_REQUEST_NUMBER" >> "$GITHUB_OUTPUT" + elif [ "$EVENT_NAME" = "merge_group" ]; then + PR_NUMBER_REGEX='/pr-([0-9]+)-' + if [[ "$MERGE_GROUP_HEAD_REF" =~ $PR_NUMBER_REGEX ]]; then + echo "PR_NUMBER=${BASH_REMATCH[1]}" >> "$GITHUB_OUTPUT" + else + echo "::error::Could not extract PR number from merge group head ref: $MERGE_GROUP_HEAD_REF." + exit 1 + fi + fi + + - name: Get target reference + id: get-target + if: steps.is-release.outputs.IS_RELEASE == 'true' + shell: bash + env: + EVENT_NAME: ${{ github.event_name }} + run: | + if [ "$EVENT_NAME" = "pull_request" ]; then + echo "TARGET=$(git merge-base HEAD refs/remotes/origin/main)" >> "$GITHUB_OUTPUT" + elif [ "$EVENT_NAME" = "merge_group" ]; then + echo "TARGET=$(git rev-parse HEAD^)" >> "$GITHUB_OUTPUT" + else + echo "::error::This action only supports \`pull_request\` and \`merge_group\` events." + exit 1 + fi + + - name: Check commits for changes in released packages + id: check-release + if: steps.is-release.outputs.IS_RELEASE == 'true' + shell: bash + env: + GH_TOKEN: ${{ github.token }} + PULL_REQUEST: ${{ steps.pr-number.outputs.PR_NUMBER }} + TARGET: ${{ steps.get-target.outputs.TARGET }} + run: | + set -euo pipefail + + mapfile -t PACKAGES < <(find packages -maxdepth 2 -name "package.json" -not -path "*/node_modules/*") + RELEASED_PACKAGES=() + + # Get all packages being released in this PR + MERGE_BASE=$(git merge-base HEAD refs/remotes/origin/main) + for package in "${PACKAGES[@]}"; do + MAIN_VERSION=$(git show "$MERGE_BASE:$package" | jq -r .version) + HEAD_VERSION=$(jq -r .version "$package") + + if [ "$HEAD_VERSION" != "$MAIN_VERSION" ]; then + package_name=$(jq -r ".name" "$package") + echo "📦 Package \`$package_name\` is being released (version \`$MAIN_VERSION\` -> \`$HEAD_VERSION\`)" + RELEASED_PACKAGES+=("$package") + fi + done + + # Fetch the pull request branch to compare changes. + PULL_REQUEST_BRANCH=$(gh pr view "$PULL_REQUEST" --json headRefName --template "{{ .headRefName }}") + echo "🔍 Checking for release conflicts with files changed ahead of PR branch \`$PULL_REQUEST_BRANCH\`..." + git fetch origin "$PULL_REQUEST_BRANCH" + + # Get all files changed ahead of this PR. + BEFORE=$(git merge-base "refs/remotes/origin/main" "refs/remotes/origin/$PULL_REQUEST_BRANCH") + git diff --name-only "$BEFORE..$TARGET" > changed-files.txt + + CONFLICTS=() + for package in "${RELEASED_PACKAGES[@]}"; do + package_directory=$(dirname "$package") + if grep -q "^$package_directory/" changed-files.txt; then + CONFLICTS+=("$package_directory") + fi + done + + if [ ${#CONFLICTS[@]} -ne 0 ]; then + mapfile -t CONFLICTS < <(printf "%s\n" "${CONFLICTS[@]}" | sort -u) + fi + + if [ ${#CONFLICTS[@]} -ne 0 ]; then + PACKAGE_NAMES=() + + for conflict in "${CONFLICTS[@]}"; do + package_name=$(jq -r ".name" "$conflict/package.json") + PACKAGE_NAMES+=("$package_name") + echo "::error::Release conflict detected in \`$package_name\`. This package is being released in this PR, but files in the package were also modified ahead of this PR. Please ensure that all changes are included in the release." + done + + PACKAGE_NAMES_JSON=$(printf '%s\n' "${PACKAGE_NAMES[@]}" | jq -R . | jq -s -c .) + echo "package-names=$PACKAGE_NAMES_JSON" >> "$GITHUB_OUTPUT" + echo "has-conflicts=true" >> "$GITHUB_OUTPUT" + else + echo "✅ No release conflicts detected." + fi + + - name: Hide previous comments + if: steps.is-release.outputs.IS_RELEASE == 'true' + uses: actions/github-script@v8 + env: + PR_NUMBER: ${{ steps.pr-number.outputs.PR_NUMBER }} + with: + script: | + const comments = await github.paginate(github.rest.issues.listComments, { + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: process.env.PR_NUMBER, + }); + + for (const comment of comments) { + if (comment.body.includes('')) { + await github.graphql(` + mutation($commentId: ID!, $classifier: ReportedContentClassifiers!) { + minimizeComment(input: {subjectId: $commentId, classifier: $classifier}) { + minimizedComment { + isMinimized + } + } + } + `, { + commentId: comment.node_id, + classifier: 'OUTDATED', + }); + } + } + + - name: Reply on pull request + if: steps.check-release.outputs.has-conflicts == 'true' + uses: actions/github-script@v8 + env: + PACKAGE_NAMES: ${{ steps.check-release.outputs.package-names }} + PR_NUMBER: ${{ steps.pr-number.outputs.PR_NUMBER }} + with: + script: | + const packageNames = JSON.parse(process.env.PACKAGE_NAMES); + const packageList = packageNames.map(name => `- \`${name}\``).join('\n'); + + const mergeQueueNote = context.eventName === 'merge_group' ? ' while this pull request was in the merge queue' : ''; + + const body = ` + ## Release conflict detected + + The following packages are being released in this pull request, but files in these packages were also modified ahead of this pull request${mergeQueueNote}: + + ${packageList} + + Please ensure that all changes are included in the release by updating this pull request, and adjusting the changelogs and version bumps as necessary. + + + `; + + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: process.env.PR_NUMBER, + body: body.split('\n').map(line => line.trim()).join('\n'), + }); + + - name: Fail if conflicts found + if: steps.check-release.outputs.has-conflicts == 'true' + shell: bash + run: | + exit 1 diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 076b4b181f7..f6e3a1dc2fd 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -1,5 +1,5 @@ # Please see the documentation for all configuration options: -# https://help.github.com/github/administering-a-repository/configuration-options-for-dependency-updates +# https://docs.github.com/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file version: 2 updates: @@ -7,6 +7,26 @@ updates: directory: '/' schedule: interval: 'daily' + cooldown: + default-days: 3 allow: - dependency-name: '@metamask/*' versioning-strategy: 'increase' + + - package-ecosystem: 'github-actions' + directory: '/' + schedule: + interval: 'daily' + time: '06:00' + cooldown: + default-days: 3 + allow: + - dependency-name: 'MetaMask/*' + - dependency-name: 'actions/*' + ignore: + - dependency-name: '*' + update-types: + - 'version-update:semver-minor' + - 'version-update:semver-patch' + target-branch: 'main' + open-pull-requests-limit: 10 diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index f62260987ae..4aea508d7e6 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -13,7 +13,9 @@ Thanks for your contribution! Take a moment to answer these questions so that re ## References -## Changelog - - - -### `@metamask/package-a` - -- ****: Your change here -- ****: Your change here - -### `@metamask/package-b` - -- ****: Your change here -- ****: Your change here - ## Checklist - [ ] I've updated the test suite for new or updated code as appropriate - [ ] I've updated documentation (JSDoc, Markdown, etc.) for new or updated code as appropriate -- [ ] I've highlighted breaking changes using the "BREAKING" category above as appropriate +- [ ] I've communicated my changes to consumers by [updating changelogs for packages I've changed](https://github.com/MetaMask/core/tree/main/docs/processes/updating-changelogs.md) +- [ ] I've introduced [breaking changes](https://github.com/MetaMask/core/tree/main/docs/processes/breaking-changes.md) in this PR and have prepared draft pull requests for clients and consumer packages to resolve them diff --git a/.github/workflows/changelog-check.yml b/.github/workflows/changelog-check.yml new file mode 100644 index 00000000000..b6395b18192 --- /dev/null +++ b/.github/workflows/changelog-check.yml @@ -0,0 +1,22 @@ +name: Check Changelog + +on: + pull_request: + types: [opened, synchronize, labeled, unlabeled] + +jobs: + check-changelog: + name: Check changelog + runs-on: ubuntu-latest + permissions: + contents: read + pull-requests: read + steps: + - name: Check changelog + uses: MetaMask/github-tools/.github/actions/check-changelog@v1 + with: + base-branch: ${{ github.event.pull_request.base.ref }} + head-ref: ${{ github.head_ref }} + labels: ${{ toJSON(github.event.pull_request.labels) }} + pr-number: ${{ github.event.pull_request.number }} + repo: ${{ github.repository }} diff --git a/.github/workflows/close-stale-release-prs.yml b/.github/workflows/close-stale-release-prs.yml new file mode 100644 index 00000000000..91400419462 --- /dev/null +++ b/.github/workflows/close-stale-release-prs.yml @@ -0,0 +1,33 @@ +name: Close Stale Release PRs + +# Release PRs on `release/*` branches are expected to merge quickly. Abandoned +# ones block other engineers from starting a new release. This workflow closes +# inactive release PRs, leaves a comment, and deletes the branch. +on: + schedule: + # Check twice an hour so the 3h window is reasonably precise. + - cron: '*/30 * * * *' + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: close-stale-release-prs + +jobs: + close-stale-release-prs: + name: Close stale release PRs + runs-on: ubuntu-latest + permissions: + contents: write + pull-requests: write + steps: + - name: Checkout and setup environment + uses: MetaMask/action-checkout-and-setup@v3 + with: + is-high-risk-environment: false + - name: Close inactive release PRs + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: yarn tsx scripts/close-stale-release-prs.mts diff --git a/.github/workflows/create-update-issues.yml b/.github/workflows/create-update-issues.yml new file mode 100644 index 00000000000..8a18e5fca54 --- /dev/null +++ b/.github/workflows/create-update-issues.yml @@ -0,0 +1,47 @@ +name: Create Update Issues + +on: + workflow_call: + +permissions: + contents: read + +jobs: + create-update-issues: + environment: default-branch + runs-on: ubuntu-latest + permissions: + contents: read + id-token: write + steps: + - name: Checkout head + uses: actions/checkout@v7 + with: + persist-credentials: false + - name: Fetch tags + run: git fetch --prune --unshallow --tags + - name: Get extension token + id: extension-token + uses: MetaMask/github-tools/.github/actions/get-token@v1 + with: + token-exchange-url: ${{ vars.TOKEN_EXCHANGE_URL }} + target-repository: 'MetaMask/metamask-extension' + permissions: | + contents: read + issues: write + metadata: read + - name: Get mobile token + id: mobile-token + uses: MetaMask/github-tools/.github/actions/get-token@v1 + with: + token-exchange-url: ${{ vars.TOKEN_EXCHANGE_URL }} + target-repository: 'MetaMask/metamask-mobile' + permissions: | + contents: read + issues: write + metadata: read + - name: Create issues in extension and mobile repositories + run: ./scripts/create-update-issues.sh --no-dry-run + env: + EXTENSION_GITHUB_TOKEN: ${{ steps.extension-token.outputs.token }} + MOBILE_GITHUB_TOKEN: ${{ steps.mobile-token.outputs.token }} diff --git a/.github/workflows/deploy-platform-api-docs.yml b/.github/workflows/deploy-platform-api-docs.yml new file mode 100644 index 00000000000..163da1b6c54 --- /dev/null +++ b/.github/workflows/deploy-platform-api-docs.yml @@ -0,0 +1,44 @@ +name: Deploy Platform API Docs + +on: + workflow_call: + +jobs: + deploy: + name: Build and deploy to GitHub Pages + runs-on: ubuntu-latest + environment: default-branch + permissions: + id-token: write + contents: read + steps: + - name: Checkout and setup environment + uses: MetaMask/action-checkout-and-setup@v3 + with: + is-high-risk-environment: true + persist-credentials: false + + - name: Generate and build Platform API docs + env: + REPO_OWNER: ${{ github.repository_owner }} + REPO_NAME: ${{ github.event.repository.name }} + run: | + yarn docs:platform-api:build \ + --site-url "https://${REPO_OWNER}.github.io" \ + --site-base-url "/${REPO_NAME}/platform-api/" + + - name: Get access token + id: get-token + uses: MetaMask/github-tools/.github/actions/get-token@v1 + with: + token-exchange-url: ${{ vars.TOKEN_EXCHANGE_URL }} + permissions: | + contents: write + + - name: Deploy to GitHub Pages + uses: peaceiris/actions-gh-pages@84c30a85c19949d7eee79c4ff27748b70285e453 # v4.1.0 + with: + personal_token: ${{ steps.get-token.outputs.token }} + publish_dir: ./.platform-api-docs/build + destination_dir: platform-api + keep_files: true diff --git a/.github/workflows/ensure-blocking-pr-labels-absent.yml b/.github/workflows/ensure-blocking-pr-labels-absent.yml new file mode 100644 index 00000000000..aec225298c6 --- /dev/null +++ b/.github/workflows/ensure-blocking-pr-labels-absent.yml @@ -0,0 +1,31 @@ +name: 'Check for PR labels that block merging' +on: + pull_request: + types: + - opened + - synchronize + - labeled + - unlabeled + merge_group: + +jobs: + ensure-blocking-pr-labels-absent: + if: ${{ github.event_name != 'merge_group' }} # Skip this step for merge_group events + runs-on: ubuntu-latest + permissions: + pull-requests: read + steps: + - name: Checkout and setup environment + uses: MetaMask/action-checkout-and-setup@v3 + with: + is-high-risk-environment: false + persist-credentials: false + - name: Run command + uses: actions/github-script@v8 + with: + script: | + if (context.payload.pull_request.labels.some((label) => label.name === 'DO-NOT-MERGE')) { + core.setFailed( + "PR cannot be merged because it contains the label 'DO-NOT-MERGE'." + ); + } diff --git a/.github/workflows/lint-build-test.yml b/.github/workflows/lint-build-test.yml index 82242d229fb..3bd49469380 100644 --- a/.github/workflows/lint-build-test.yml +++ b/.github/workflows/lint-build-test.yml @@ -3,45 +3,142 @@ name: Lint, Build, and Test on: workflow_call: +permissions: + contents: read + jobs: prepare: name: Prepare runs-on: ubuntu-latest strategy: matrix: - node-version: [16.x, 18.x, 20.x] + node-version: [18.x, 20.x, 22.x, 24.x] outputs: child-workspace-package-names: ${{ steps.workspace-package-names.outputs.child-workspace-package-names }} + merge-base: ${{ steps.fetch-merge-base.outputs.merge-base }} + package-names: ${{ steps.packages.outputs.package-names }} + changed-paths: ${{ steps.packages.outputs.changed-paths }} steps: - - uses: actions/checkout@v3 - - name: Use Node.js ${{ matrix.node-version }} - uses: actions/setup-node@v3 + - name: Checkout and setup environment + uses: MetaMask/action-checkout-and-setup@v3 with: + is-high-risk-environment: false + persist-credentials: false + cache-node-modules: true node-version: ${{ matrix.node-version }} - cache: yarn - - run: yarn --immutable + force-setup: true - name: Fetch workspace package names id: workspace-package-names run: | - echo "child-workspace-package-names=$(yarn child-workspace-package-names-as-json)" >> "$GITHUB_OUTPUT" + echo "child-workspace-package-names=$(yarn workspaces list --no-private --json | jq --slurp --raw-output 'map(.name) | @json')" >> "$GITHUB_OUTPUT" shell: bash + - name: Fetch merge base + id: fetch-merge-base + if: matrix.node-version == '24.x' && (github.base_ref != '' || github.event.merge_group.base_ref != '') + run: | + set -euo pipefail + + PREFIXED_REF_REGEX='refs/heads/(.+)' + if [[ "$BASE_REF" =~ $PREFIXED_REF_REGEX ]]; then + BASE_REF="${BASH_REMATCH[1]}" + fi + + MERGE_BASE=$(gh api "repos/$GITHUB_REPOSITORY/compare/$BASE_REF...$HEAD_SHA" --jq '.merge_base_commit.sha') + git fetch --unshallow --filter=blob:none --no-tags origin HEAD + + echo "merge-base=$MERGE_BASE" >> "$GITHUB_OUTPUT" + env: + BASE_REF: ${{ github.event.pull_request.base.ref || github.event.merge_group.base_ref }} + HEAD_SHA: ${{ github.event.pull_request.head.sha || github.event.merge_group.head_sha }} + GH_TOKEN: ${{ github.token }} + - name: Get changed package names + id: packages + if: matrix.node-version == '24.x' + run: | + if [[ -n "$MERGE_BASE" ]]; then + OUTPUT=$(yarn tsx scripts/get-changed-workspaces.mts --merge-base "$MERGE_BASE" --head-ref "$HEAD_SHA") + PACKAGES=$(echo "$OUTPUT" | jq -c '.names') + if [[ $(echo "$OUTPUT" | jq '.hasRootChange') == "true" ]]; then + CHANGED_PATHS="full" + else + CHANGED_PATHS=$(echo "$OUTPUT" | jq -c '.locations') + fi + else + PACKAGES=$(yarn workspaces list --no-private --json | jq --slurp --raw-output 'map(.name) | @json') + CHANGED_PATHS="full" + fi + echo "package-names=$PACKAGES" >> "$GITHUB_OUTPUT" + echo "changed-paths=$CHANGED_PATHS" >> "$GITHUB_OUTPUT" + env: + MERGE_BASE: ${{ steps.fetch-merge-base.outputs.merge-base }} + HEAD_SHA: ${{ github.event.pull_request.head.sha || github.event.merge_group.head_sha }} lint: - name: Lint + name: Lint (${{ matrix.script }}) + runs-on: ubuntu-latest + needs: prepare + strategy: + fail-fast: false + matrix: + node-version: [24.x] + script: + - codeowners:check + - constraints + - lint:dependencies + - lint:misc:check + - lint:teams + - lint:tsconfigs:all + - lint:tsc + - messenger-action-types:check + - readme-content:check + steps: + - name: Checkout and setup environment + uses: MetaMask/action-checkout-and-setup@v3 + with: + is-high-risk-environment: false + persist-credentials: false + node-version: ${{ matrix.node-version }} + - name: Run yarn ${{ matrix.script }} + run: yarn "$SCRIPT" + env: + SCRIPT: ${{ matrix.script }} + - name: Require clean working directory + shell: bash + run: | + if ! git diff --exit-code; then + echo "Working tree dirty at end of job" + exit 1 + fi + + lint-eslint: + name: Lint (lint:eslint) runs-on: ubuntu-latest + if: needs.prepare.outputs.changed-paths != '[]' needs: prepare strategy: matrix: - node-version: [20.x] + node-version: [24.x] steps: - - uses: actions/checkout@v3 - - name: Use Node.js ${{ matrix.node-version }} - uses: actions/setup-node@v3 + - name: Checkout and setup environment + uses: MetaMask/action-checkout-and-setup@v3 with: + is-high-risk-environment: false + persist-credentials: false node-version: ${{ matrix.node-version }} - cache: yarn - - run: yarn --immutable - - run: yarn lint + - name: Lint + run: | + if [[ "$CHANGED_PATHS" == "full" ]]; then + echo "Running ESLint on all packages." + echo "" + yarn lint:eslint + else + echo "Running ESLint on:" + echo "$CHANGED_PATHS" | jq -r '"- " + .[]' + echo "" + echo "$CHANGED_PATHS" | jq -r '.[]' | xargs yarn lint:eslint + fi + env: + CHANGED_PATHS: ${{ needs.prepare.outputs.changed-paths }} - name: Require clean working directory shell: bash run: | @@ -53,20 +150,26 @@ jobs: validate-changelog: name: Validate changelog runs-on: ubuntu-latest + if: needs.prepare.outputs.package-names != '[]' needs: prepare strategy: + fail-fast: false matrix: - node-version: [20.x] - package-name: ${{ fromJson(needs.prepare.outputs.child-workspace-package-names) }} + node-version: [24.x] + package-name: ${{ fromJson(needs.prepare.outputs.package-names) }} steps: - - uses: actions/checkout@v3 - - name: Use Node.js ${{ matrix.node-version }} - uses: actions/setup-node@v3 + - name: Checkout and setup environment + uses: MetaMask/action-checkout-and-setup@v3 with: + is-high-risk-environment: false + persist-credentials: false node-version: ${{ matrix.node-version }} - cache: yarn - - run: yarn --immutable - - run: yarn workspace ${{ matrix.package-name }} changelog:validate + # Full history so `changelog:validate` can find the merge base with the + # base branch to detect which packages are being released. + fetch-depth: 0 + - run: yarn workspace "$PACKAGE_NAME" changelog:validate + env: + PACKAGE_NAME: ${{ matrix.package-name }} - name: Require clean working directory shell: bash run: | @@ -75,22 +178,86 @@ jobs: exit 1 fi + validate-changelog-diffs: + name: Validate changelog diffs + if: github.event_name == 'pull_request' || github.event_name == 'merge_group' + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v7 + with: + persist-credentials: false + - name: Validate changelog diffs + uses: ./.github/actions/check-merge-queue-changelogs + build: name: Build runs-on: ubuntu-latest needs: prepare strategy: matrix: - node-version: [20.x] + node-version: [24.x] + steps: + - name: Checkout and setup environment + uses: MetaMask/action-checkout-and-setup@v3 + with: + is-high-risk-environment: false + persist-credentials: false + node-version: ${{ matrix.node-version }} + - name: Unshallow checkout + if: needs.prepare.outputs.merge-base != '' + run: | + # Unshallow so git can walk history back to the merge base for + # `git diff --name-only`. Using `--filter=blob:none` avoids + # downloading file content — only commit and tree objects are needed. + git fetch --unshallow --filter=blob:none --no-tags origin HEAD + - name: Build + run: | + if [[ -n "$MERGE_BASE" && "$CHANGED_PATHS" != "full" ]]; then + TSCONFIG=$(mktemp --tmpdir="$GITHUB_WORKSPACE" --suffix=.json) + yarn tsx scripts/generate-partial-build-tsconfig.mts "$MERGE_BASE" "$HEAD_SHA" > "$TSCONFIG" + if [[ -s "$TSCONFIG" ]]; then + echo "Building changed packages:" + jq -r '"- " + (.references[].path | ltrimstr("./") | rtrimstr("/tsconfig.build.json"))' "$TSCONFIG" + echo "" + yarn ts-bridge --project "$TSCONFIG" --verbose + else + echo "No packages to build." + fi + rm -f "$TSCONFIG" + else + echo "Building all packages." + echo "" + yarn build + fi + env: + MERGE_BASE: ${{ needs.prepare.outputs.merge-base }} + HEAD_SHA: ${{ github.event.pull_request.head.sha || github.event.merge_group.head_sha }} + CHANGED_PATHS: ${{ needs.prepare.outputs.changed-paths }} + - name: Require clean working directory + shell: bash + run: | + if ! git diff --exit-code; then + echo "Working tree dirty at end of job" + exit 1 + fi + + test-scripts: + name: Test Scripts + runs-on: ubuntu-latest + needs: prepare + strategy: + matrix: + node-version: [24.x] steps: - - uses: actions/checkout@v3 - - name: Use Node.js ${{ matrix.node-version }} - uses: actions/setup-node@v3 + - name: Checkout and setup environment + uses: MetaMask/action-checkout-and-setup@v3 with: + is-high-risk-environment: false + persist-credentials: false node-version: ${{ matrix.node-version }} - cache: yarn - - run: yarn --immutable - - run: yarn build + - run: yarn test:scripts + - run: yarn test:scripts:shell - name: Require clean working directory shell: bash run: | @@ -99,23 +266,133 @@ jobs: exit 1 fi - test: - name: Test + # The following `test-*` jobs are duplicated because a single job may only + # create a maximum of 256 matrix combinations, and we have more than 256 total + # test combinations across all Node.js versions. + test-18: + name: Test (18.x) + runs-on: ubuntu-latest + if: needs.prepare.outputs.package-names != '[]' + needs: prepare + strategy: + fail-fast: false + matrix: + package-name: ${{ fromJson(needs.prepare.outputs.package-names) }} + steps: + - name: Checkout and setup environment + uses: MetaMask/action-checkout-and-setup@v3 + with: + is-high-risk-environment: false + persist-credentials: false + node-version: 18.x + - run: yarn workspace "$PACKAGE_NAME" run test + env: + PACKAGE_NAME: ${{ matrix.package-name }} + - name: Require clean working directory + shell: bash + run: | + if ! git diff --exit-code; then + echo "Working tree dirty at end of job" + exit 1 + fi + + test-20: + name: Test (20.x) + runs-on: ubuntu-latest + if: needs.prepare.outputs.package-names != '[]' + needs: prepare + strategy: + fail-fast: false + matrix: + package-name: ${{ fromJson(needs.prepare.outputs.package-names) }} + steps: + - name: Checkout and setup environment + uses: MetaMask/action-checkout-and-setup@v3 + with: + is-high-risk-environment: false + persist-credentials: false + node-version: 20.x + - run: yarn workspace "$PACKAGE_NAME" run test + env: + PACKAGE_NAME: ${{ matrix.package-name }} + - name: Require clean working directory + shell: bash + run: | + if ! git diff --exit-code; then + echo "Working tree dirty at end of job" + exit 1 + fi + + test-22: + name: Test (22.x) + runs-on: ubuntu-latest + if: needs.prepare.outputs.package-names != '[]' + needs: prepare + strategy: + fail-fast: false + matrix: + package-name: ${{ fromJson(needs.prepare.outputs.package-names) }} + steps: + - name: Checkout and setup environment + uses: MetaMask/action-checkout-and-setup@v3 + with: + is-high-risk-environment: false + persist-credentials: false + node-version: 22.x + - run: yarn workspace "$PACKAGE_NAME" run test + env: + PACKAGE_NAME: ${{ matrix.package-name }} + - name: Require clean working directory + shell: bash + run: | + if ! git diff --exit-code; then + echo "Working tree dirty at end of job" + exit 1 + fi + + build-platform-api-docs: + name: Build Platform API docs + runs-on: ubuntu-latest + needs: prepare + permissions: + contents: read + steps: + - name: Checkout and setup environment + uses: MetaMask/action-checkout-and-setup@v3 + with: + is-high-risk-environment: false + persist-credentials: false + node-version: 24.x + + - name: Generate and build Platform API docs + run: yarn docs:platform-api:build + + # The wallet-cli daemon e2e spawns the BUILT `mm` CLI and the native + # better-sqlite3 addon as real child processes, so it needs its dependency + # subtree built first and cannot run in the per-package `test-*` matrix above. + test-wallet-cli-e2e: + name: Test wallet-cli daemon e2e (${{ matrix.node-version }}) runs-on: ubuntu-latest + if: contains(fromJson(needs.prepare.outputs.package-names), '@metamask/wallet-cli') needs: prepare strategy: + fail-fast: false matrix: - node-version: [16.x, 18.x, 20.x] - package-name: ${{ fromJson(needs.prepare.outputs.child-workspace-package-names) }} + node-version: [20.x, 22.x, 24.x] steps: - - uses: actions/checkout@v3 - - name: Use Node.js ${{ matrix.node-version }} - uses: actions/setup-node@v3 + - name: Checkout and setup environment + uses: MetaMask/action-checkout-and-setup@v3 with: + is-high-risk-environment: false + persist-credentials: false node-version: ${{ matrix.node-version }} - cache: yarn - - run: yarn --immutable - - run: yarn workspace ${{ matrix.package-name }} run test + - name: Build wallet-cli and its dependencies + run: yarn workspaces foreach --topological-dev --recursive --from '@metamask/wallet-cli' run build + - name: Install anvil for the real-chain e2e + run: yarn workspace @metamask/wallet-cli run test:e2e:install-anvil + - run: yarn workspace @metamask/wallet-cli run test:e2e + env: + MM_E2E_REQUIRE_ANVIL: 'true' - name: Require clean working directory shell: bash run: | diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 19087ff764b..0d60d95ba94 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -4,26 +4,120 @@ on: push: branches: [main] pull_request: + merge_group: + +concurrency: + group: ${{ github.workflow }}-${{ github.ref == 'refs/heads/main' && github.sha || github.ref }} + cancel-in-progress: ${{ !contains(github.ref, 'refs/heads/main') }} + +permissions: + contents: read jobs: + check-skip-merge-queue: + name: Check if pull request can skip merge queue + runs-on: ubuntu-latest + permissions: + contents: read + pull-requests: read + outputs: + skip-merge-queue: ${{ steps.check-skip-merge-queue.outputs.up-to-date }} + steps: + - name: Check pull request merge queue status + id: check-skip-merge-queue + if: github.event_name == 'merge_group' + uses: MetaMask/github-tools/.github/actions/check-skip-merge-queue@v1 + check-workflows: name: Check workflows + needs: + - check-skip-merge-queue + if: github.event_name != 'merge_group' || needs.check-skip-merge-queue.outputs.skip-merge-queue != 'true' runs-on: ubuntu-latest + permissions: + contents: read steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v7 + with: + persist-credentials: false - name: Download actionlint id: download-actionlint - run: bash <(curl https://raw.githubusercontent.com/rhysd/actionlint/7fdc9630cc360ea1a469eed64ac6d78caeda1234/scripts/download-actionlint.bash) 1.6.25 + run: bash <(curl https://raw.githubusercontent.com/rhysd/actionlint/914e7df21a07ef503a81201c76d2b11c789d3fca/scripts/download-actionlint.bash) 1.7.12 shell: bash - name: Check workflow files - run: ${{ steps.download-actionlint.outputs.executable }} -color + run: | + "$ACTIONLINT" -color shell: bash + env: + ACTIONLINT: ${{ steps.download-actionlint.outputs.executable }} + + analyse-code: + name: Analyse code + needs: check-workflows + uses: MetaMask/action-security-code-scanner/.github/workflows/security-scan.yml@v2 + with: + scanner-ref: v2 + paths-ignored: | + .storybook/ + **/__snapshots__/ + **/*.snap + **/*.stories.js + **/*.stories.tsx + **/*.test.browser.ts* + **/*.test.js* + **/*.test.ts* + **/fixtures/ + **/jest.config.js + **/jest.environment.js + **/mocks/ + **/test*/ + docs/ + e2e/ + merged-packages/ + node_modules/ + storybook/ + test*/ + secrets: + project-metrics-token: ${{ secrets.SECURITY_SCAN_METRICS_TOKEN }} + slack-webhook: ${{ secrets.APPSEC_BOT_SLACK_WEBHOOK }} + permissions: + actions: read + contents: read + security-events: write lint-build-test: name: Lint, build, and test needs: check-workflows uses: ./.github/workflows/lint-build-test.yml + deploy-platform-api-docs: + name: Deploy Platform API Docs + needs: lint-build-test + if: github.ref == 'refs/heads/main' && github.event_name == 'push' + permissions: + contents: read + id-token: write + uses: ./.github/workflows/deploy-platform-api-docs.yml + + check-release: + name: Check release + needs: check-workflows + runs-on: ubuntu-latest + permissions: + contents: read + pull-requests: write + steps: + - name: Checkout repository + uses: actions/checkout@v7 + with: + fetch-depth: 0 + persist-credentials: false + - name: Check release + if: github.event_name != 'push' + uses: ./.github/actions/check-release + with: + commit-starts-with: ${{ vars.RELEASE_COMMIT_PREFIX }} + is-release: name: Determine whether this is a release merge commit needs: lint-build-test @@ -33,9 +127,9 @@ jobs: IS_RELEASE: ${{ steps.is-release.outputs.IS_RELEASE }} steps: - id: is-release - uses: MetaMask/action-is-release@dc4672b05e3b1d464cdaf783579b04a4e43f8b02 + uses: MetaMask/action-is-release@v2 with: - commit-starts-with: 'Release [version],Release/[version]' + commit-starts-with: ${{ vars.RELEASE_COMMIT_PREFIX }} publish-release: name: Publish release @@ -43,15 +137,28 @@ jobs: if: needs.is-release.outputs.IS_RELEASE == 'true' permissions: contents: write + id-token: write uses: ./.github/workflows/publish-release.yml secrets: NPM_TOKEN: ${{ secrets.NPM_TOKEN }} SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }} + create-update-issues: + name: Create update issues + needs: [is-release, publish-release] + if: needs.is-release.outputs.IS_RELEASE == 'true' + uses: ./.github/workflows/create-update-issues.yml + permissions: + contents: read + id-token: write + all-jobs-complete: name: All jobs complete runs-on: ubuntu-latest - needs: lint-build-test + needs: + - analyse-code + - check-release + - lint-build-test outputs: passed: ${{ steps.set-output.outputs.passed }} steps: @@ -63,11 +170,14 @@ jobs: name: All jobs pass if: ${{ always() }} runs-on: ubuntu-latest - needs: all-jobs-complete + needs: + - all-jobs-complete + - check-skip-merge-queue + env: + PASSED: ${{ needs.all-jobs-complete.outputs.passed == 'true' || needs.check-skip-merge-queue.outputs.skip-merge-queue == 'true' }} steps: - name: Check that all jobs have passed run: | - passed="${{ needs.all-jobs-complete.outputs.passed }}" - if [[ $passed != "true" ]]; then + if [[ "$PASSED" != "true" ]]; then exit 1 fi diff --git a/.github/workflows/publish-preview.yml b/.github/workflows/publish-preview.yml index df04fdf90f0..5030117561c 100644 --- a/.github/workflows/publish-preview.yml +++ b/.github/workflows/publish-preview.yml @@ -4,57 +4,16 @@ on: issue_comment: types: created -jobs: - is-fork-pull-request: - name: Determine whether this issue comment was on a pull request from a fork - if: ${{ github.event.issue.pull_request && startsWith(github.event.comment.body, '@metamaskbot publish-preview') }} - runs-on: ubuntu-latest - outputs: - IS_FORK: ${{ steps.is-fork.outputs.IS_FORK }} - steps: - - uses: actions/checkout@v3 - - name: Determine whether this PR is from a fork - id: is-fork - run: echo "IS_FORK=$(gh pr view --json isCrossRepository --jq '.isCrossRepository' "${PR_NUMBER}" )" >> "$GITHUB_OUTPUT" - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - PR_NUMBER: ${{ github.event.issue.number }} +permissions: + contents: read + pull-requests: write +jobs: publish-preview: - name: Publish build preview - needs: is-fork-pull-request - permissions: - pull-requests: write - # This ensures we don't publish on forks. We can't trust forks with this token. - if: ${{ needs.is-fork-pull-request.outputs.IS_FORK == 'false' }} - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v3 - - name: Checkout pull request - run: gh pr checkout "${PR_NUMBER}" - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - PR_NUMBER: ${{ github.event.issue.number }} - - name: Setup Node - uses: actions/setup-node@v3 - with: - node-version-file: '.nvmrc' - cache: yarn - - run: yarn --immutable - - name: Get commit SHA - id: commit-sha - run: echo "COMMIT_SHA=$(git rev-parse --short HEAD)" >> "$GITHUB_OUTPUT" - - run: yarn prepare-preview-builds @metamask-previews ${{ steps.commit-sha.outputs.COMMIT_SHA }} - - run: yarn build - - name: Publish preview build - run: yarn publish-previews - env: - YARN_NPM_AUTH_TOKEN: ${{ secrets.PUBLISH_PREVIEW_NPM_TOKEN }} - - name: Generate preview build message - run: yarn ts-node scripts/generate-preview-build-message.ts - - name: Post build preview in comment - run: gh pr comment "${PR_NUMBER}" --body-file preview-build-message.txt - env: - COMMIT_SHA: ${{ steps.commit-sha.outputs.COMMIT_SHA }} - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - PR_NUMBER: ${{ github.event.issue.number }} + if: ${{ github.event.issue.pull_request && startsWith(github.event.comment.body, '@metamaskbot publish-preview') }} + uses: MetaMask/github-tools/.github/workflows/publish-preview.yml@v1 + with: + environment: default-branch + docs-url: 'https://github.com/MetaMask/core/blob/main/docs/processes/preview-builds.md' + secrets: + PUBLISH_PREVIEW_NPM_TOKEN: ${{ secrets.PUBLISH_PREVIEW_NPM_TOKEN }} diff --git a/.github/workflows/publish-release.yml b/.github/workflows/publish-release.yml index c63898358cc..bab12647a60 100644 --- a/.github/workflows/publish-release.yml +++ b/.github/workflows/publish-release.yml @@ -4,77 +4,103 @@ on: workflow_call: secrets: NPM_TOKEN: - required: true + required: false SLACK_WEBHOOK_URL: required: true +permissions: + contents: read + +concurrency: + group: publish-release + queue: max + jobs: - publish-release: - permissions: - contents: write + build: + name: Build release artifacts runs-on: ubuntu-latest steps: - - uses: actions/checkout@v3 + - name: Checkout and setup environment + uses: MetaMask/action-checkout-and-setup@v3 with: + is-high-risk-environment: true + persist-credentials: false ref: ${{ github.sha }} - - name: Setup Node - uses: actions/setup-node@v3 - with: - node-version-file: '.nvmrc' - cache: yarn - - uses: actions/cache@v3 + - name: Build + run: yarn build + - name: Upload build artifacts + uses: actions/upload-artifact@v6 with: + name: publish-release-artifacts-${{ github.sha }} + include-hidden-files: true + retention-days: 4 path: | ./packages/**/dist ./node_modules/.yarn-state.yml - key: ${{ github.sha }} - - uses: MetaMask/action-publish-release@v3 - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - - run: yarn --immutable - - run: yarn build publish-npm-dry-run: + name: Dry run publish to NPM runs-on: ubuntu-latest - needs: publish-release + needs: build steps: - - uses: actions/checkout@v3 + - name: Checkout and setup environment + uses: MetaMask/action-checkout-and-setup@v3 with: + is-high-risk-environment: true + persist-credentials: false ref: ${{ github.sha }} - - uses: actions/cache@v3 + skip-install: true + - name: Restore build artifacts + uses: actions/download-artifact@v7 with: - path: | - ./packages/**/dist - ./node_modules/.yarn-state.yml - key: ${{ github.sha }} - fail-on-cache-miss: true - - name: Dry Run Publish - # omit npm-token token to perform dry run publish - uses: MetaMask/action-npm-publish@v4 + name: publish-release-artifacts-${{ github.sha }} + - name: Dry run publish to NPM + uses: MetaMask/action-npm-publish@v6 with: + dry-run: true slack-webhook-url: ${{ secrets.SLACK_WEBHOOK_URL }} subteam: S042S7RE4AE # @metamask-npm-publishers - env: - SKIP_PREPACK: true publish-npm: + name: Publish to NPM environment: npm-publish runs-on: ubuntu-latest needs: publish-npm-dry-run + permissions: + contents: read + id-token: write steps: - - uses: actions/checkout@v3 + - name: Checkout and setup environment + uses: MetaMask/action-checkout-and-setup@v3 with: + is-high-risk-environment: true + persist-credentials: false ref: ${{ github.sha }} - - uses: actions/cache@v3 + skip-install: true + - name: Restore build artifacts + uses: actions/download-artifact@v7 with: - path: | - ./packages/**/dist - ./node_modules/.yarn-state.yml - key: ${{ github.sha }} - fail-on-cache-miss: true - - name: Publish - uses: MetaMask/action-npm-publish@v3 + name: publish-release-artifacts-${{ github.sha }} + - name: Publish to NPM + uses: MetaMask/action-npm-publish@v6 with: + dry-run: false npm-token: ${{ secrets.NPM_TOKEN }} + + publish-release: + name: Publish release to GitHub + permissions: + contents: write + runs-on: ubuntu-latest + needs: publish-npm + steps: + - name: Checkout and setup environment + uses: MetaMask/action-checkout-and-setup@v3 + with: + is-high-risk-environment: true + persist-credentials: false + ref: ${{ github.sha }} + skip-install: true + - uses: MetaMask/action-publish-release@v3 env: - SKIP_PREPACK: true + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/update-changelogs.yml b/.github/workflows/update-changelogs.yml new file mode 100644 index 00000000000..d81c8dd7c92 --- /dev/null +++ b/.github/workflows/update-changelogs.yml @@ -0,0 +1,294 @@ +name: Update Changelogs + +on: + issue_comment: + types: + - created + pull_request_target: + branches: + - main + types: + - opened + - ready_for_review + +permissions: + contents: read + +jobs: + is-fork: + name: Determine whether this PR is from a fork + if: (github.event_name == 'pull_request_target' && !github.event.pull_request.draft) || (github.event.issue.pull_request && startsWith(github.event.comment.body, '@metamaskbot update-changelogs')) + runs-on: ubuntu-latest + permissions: + pull-requests: read + outputs: + is-fork: ${{ steps.is-fork.outputs.is-fork }} + steps: + - name: Determine whether this PR is from a fork + id: is-fork + run: | + IS_FORK="$(gh pr view --json isCrossRepository --jq '.isCrossRepository' "$PR_NUMBER" --repo "$GITHUB_REPOSITORY")" + echo "is-fork=$IS_FORK" >> "$GITHUB_OUTPUT" + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + PR_NUMBER: ${{ github.event.issue.number || github.event.pull_request.number }} + + is-release: + name: Determine whether this PR is a release PR + needs: is-fork + if: needs.is-fork.outputs.is-fork == 'false' + runs-on: ubuntu-latest + environment: default-branch + permissions: + contents: read + pull-requests: read + outputs: + is-release: ${{ steps.is-release.outputs.IS_RELEASE }} + head-sha: ${{ steps.pr-info.outputs.pr-head-sha }} + head-ref: ${{ steps.pr-info.outputs.pr-head-ref }} + base-ref: ${{ steps.pr-info.outputs.pr-base-ref }} + merge-base: ${{ steps.merge-base.outputs.merge-base }} + steps: + - name: Get pull request info + id: pr-info + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + PR_NUMBER: ${{ github.event.issue.number || github.event.pull_request.number }} + run: | + gh pr view "$PR_NUMBER" \ + --repo "$GITHUB_REPOSITORY" \ + --json baseRefName,headRefOid,headRefName,title \ + --jq '"pr-base-ref=\(.baseRefName)\npr-head-sha=\(.headRefOid)\npr-head-ref=\(.headRefName)\npr-title=\(.title)"' \ + >> "$GITHUB_OUTPUT" + + - name: Checkout repository + uses: actions/checkout@v7 + with: + token: ${{ secrets.GITHUB_TOKEN }} + fetch-depth: 0 + persist-credentials: false + ref: ${{ steps.pr-info.outputs.pr-head-sha }} + + - name: Get merge base + id: merge-base + shell: bash + env: + BASE_REF: ${{ steps.pr-info.outputs.pr-base-ref }} + run: | + set -euo pipefail + + MERGE_BASE=$(git merge-base HEAD "refs/remotes/origin/$BASE_REF") + echo "merge-base=$MERGE_BASE" >> "$GITHUB_OUTPUT" + + - name: Check if the pull request is a release + id: is-release + uses: MetaMask/action-is-release@v2 + with: + commit-starts-with: ${{ vars.RELEASE_COMMIT_PREFIX }} + commit-message: ${{ steps.pr-info.outputs.pr-title }} + before: ${{ steps.merge-base.outputs.merge-base }} + skip-checkout: true + + react-to-comment: + name: React to the comment + needs: is-release + if: needs.is-release.outputs.is-release == 'true' && github.event_name == 'issue_comment' + runs-on: ubuntu-latest + environment: default-branch + permissions: + id-token: write + continue-on-error: true + steps: + - name: Get access token + id: get-token + uses: MetaMask/github-tools/.github/actions/get-token@v1 + with: + token-exchange-url: ${{ vars.TOKEN_EXCHANGE_URL }} + permissions: | + contents: write + pull_requests: write + - name: React to the comment + run: | + gh api \ + --method POST \ + -H "Accept: application/vnd.github+json" \ + -H "X-GitHub-Api-Version: 2022-11-28" \ + "/repos/${REPO}/issues/comments/${COMMENT_ID}/reactions" \ + -f content='+1' + env: + COMMENT_ID: ${{ github.event.comment.id }} + GH_TOKEN: ${{ steps.get-token.outputs.token }} + REPO: ${{ github.repository }} + + update-changelogs: + name: Update changelogs + needs: is-release + if: ${{ needs.is-release.outputs.is-release == 'true' }} + runs-on: ubuntu-latest + environment: default-branch + permissions: + id-token: write + steps: + - name: Get access token + id: get-token + uses: MetaMask/github-tools/.github/actions/get-token@v1 + with: + token-exchange-url: ${{ vars.TOKEN_EXCHANGE_URL }} + permissions: | + contents: write + pull_requests: write + + - name: Check out the base branch + uses: actions/checkout@v7 + with: + ref: ${{ needs.is-release.outputs.merge-base }} + token: ${{ steps.get-token.outputs.token }} + persist-credentials: false + + - name: Detach HEAD (to prevent accidental pushes) + run: git checkout --detach HEAD + + - name: Set up environment + uses: MetaMask/action-checkout-and-setup@v3 + with: + is-high-risk-environment: false + + - name: Overlay relevant files from current pull request + env: + PR_HEAD_SHA: ${{ needs.is-release.outputs.head-sha }} + PR_HEAD_REF: ${{ needs.is-release.outputs.head-ref }} + run: | + # These next two commands are also useful later when pushing + git fetch --no-tags origin "$PR_HEAD_SHA" + git fetch --no-tags origin "$PR_HEAD_REF" + git checkout "$PR_HEAD_SHA" -- '**/CHANGELOG.md' '**/package.json' + shell: bash + + - name: Configure Git with name and email + run: | + # This is necessary to make a commit + # Passing `token` to the `checkout` action does not do this for us + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + shell: bash + + - name: Commit relevant files from current pull request + run: | + git add -- '**/CHANGELOG.md' '**/package.json' + git commit -m "[Temporary] Add changelogs from current pull request" + shell: bash + + - name: Ensure required dependency bump entries exist across all changelogs + id: update-changelogs + env: + PR_NUMBER: ${{ github.event.issue.number || github.event.pull_request.number }} + MERGE_BASE: ${{ needs.is-release.outputs.merge-base }} + # This job's checkout fetches only the merge-base commit by SHA + # (depth 1) and never fetches main, so there is no `origin/main` ref + # for validate-changelog.sh to resolve the merge base against. + # Point it at the merge-base SHA we already have. + CHANGELOG_BASE_REF: ${{ needs.is-release.outputs.merge-base }} + run: | + yarn changelog:validate --checkDeps --fix --currentPr "$PR_NUMBER" --fromRef "$MERGE_BASE" + shell: bash + # If changelogs were updated but there were other validation errors + # found, we need to still create a commit below + continue-on-error: true + + - name: Commit updated changelogs + id: commit-updated-changelogs + run: | + if git diff --quiet; then + # Nothing to commit; no changelogs updated + exit 0 + fi + + git add -- '**/CHANGELOG.md' + git commit -m "chore: Update dependency bump changelog entries" + + new_commit_id="$(git log -1 --pretty='format:%H')" + echo "new-commit-id=${new_commit_id}" >> "$GITHUB_OUTPUT" + shell: bash + + - name: Cherry-pick new commit on top of pull request branch and push it + id: push-changes + env: + NEW_COMMIT_ID: ${{ steps.commit-updated-changelogs.outputs.new-commit-id }} + PR_HEAD_SHA: ${{ needs.is-release.outputs.head-sha }} + PR_HEAD_REF: ${{ needs.is-release.outputs.head-ref }} + TOKEN: ${{ steps.get-token.outputs.token }} + run: | + if [[ -n "$NEW_COMMIT_ID" ]]; then + git checkout "$PR_HEAD_SHA" + git cherry-pick "$NEW_COMMIT_ID" + git push "https://x-access-token:${TOKEN}@github.com/${GITHUB_REPOSITORY}.git" "HEAD:$PR_HEAD_REF" + echo "changes-pushed=true" >> "$GITHUB_OUTPUT" + else + echo "changes-pushed=false" >> "$GITHUB_OUTPUT" + fi + shell: bash + + - name: Comment result + if: always() + uses: actions/github-script@v9 + env: + CHANGES_PUSHED: ${{ steps.push-changes.outputs.changes-pushed }} + PUSH_CHANGES_OUTCOME: ${{ steps.push-changes.outcome }} + UPDATE_CHANGELOGS_OUTCOME: ${{ steps.update-changelogs.outcome }} + PR_NUMBER: ${{ github.event.issue.number || github.event.pull_request.number }} + with: + github-token: ${{ steps.get-token.outputs.token }} + script: | + const { + CHANGES_PUSHED, + PUSH_CHANGES_OUTCOME, + UPDATE_CHANGELOGS_OUTCOME, + PR_NUMBER, + } = process.env; + + // List and minimize any existing changelog update comments. + const comments = await github.paginate(github.rest.issues.listComments, { + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: process.env.PR_NUMBER, + }); + + for (const comment of comments) { + if (comment.body.includes('')) { + await github.graphql(` + mutation($commentId: ID!, $classifier: ReportedContentClassifiers!) { + minimizeComment(input: {subjectId: $commentId, classifier: $classifier}) { + minimizedComment { + isMinimized + } + } + } + `, { + commentId: comment.node_id, + classifier: 'OUTDATED', + }); + } + } + + function getCommentBody() { + if (CHANGES_PUSHED === 'true' && UPDATE_CHANGELOGS_OUTCOME === 'failure') { + return `⚠️ Changelogs updated and pushed, but some validation errors remain. Check the [workflow run](${process.env.GITHUB_SERVER_URL}/${process.env.GITHUB_REPOSITORY}/actions/runs/${process.env.GITHUB_RUN_ID}) for details.`; + } else if (CHANGES_PUSHED === 'true') { + return '✅ Changelogs updated and pushed.'; + } else if (PUSH_CHANGES_OUTCOME === 'failure') { + return `❌ Failed to push changelog fixes. Check the [workflow run](${process.env.GITHUB_SERVER_URL}/${process.env.GITHUB_REPOSITORY}/actions/runs/${process.env.GITHUB_RUN_ID}) for details.`; + } else if (UPDATE_CHANGELOGS_OUTCOME === 'failure') { + return `❌ Changelog validation failed. Check the [workflow run](${process.env.GITHUB_SERVER_URL}/${process.env.GITHUB_REPOSITORY}/actions/runs/${process.env.GITHUB_RUN_ID}) for details.`; + } else if (UPDATE_CHANGELOGS_OUTCOME === 'skipped' || PUSH_CHANGES_OUTCOME === 'skipped') { + return `❌ Workflow failed before changelog validation. Check the [workflow run](${process.env.GITHUB_SERVER_URL}/${process.env.GITHUB_REPOSITORY}/actions/runs/${process.env.GITHUB_RUN_ID}) for details.`; + } else { + return '✅ No changelog changes needed.'; + } + } + + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: process.env.PR_NUMBER, + body: `${getCommentBody()}\n\n`, + }); diff --git a/.github/zizmor.yml b/.github/zizmor.yml new file mode 100644 index 00000000000..f21fe080f59 --- /dev/null +++ b/.github/zizmor.yml @@ -0,0 +1,23 @@ +# Please see the documentation for all configuration options: +# https://docs.zizmor.sh/configuration/ + +rules: + dangerous-triggers: + ignore: + # `pull_request_target` is used safely here: The workflow checks whether + # the PR is from a fork before running, and write access is gated behind + # the `default-branch` environment. + - update-changelogs.yml:3:1 + + dependabot-cooldown: + config: + # Change the minimum allowed cooldown period for Dependabot to 3 days. + days: 3 + + unpinned-uses: + config: + policies: + # Allow `actions/*` and `MetaMask/*` to be pinned to a version instead + # of only to a commit hash. + actions/*: ref-pin + MetaMask/*: ref-pin diff --git a/.gitignore b/.gitignore index b4a80f6e7ad..36ac4cab69a 100644 --- a/.gitignore +++ b/.gitignore @@ -15,9 +15,13 @@ package-lock.json # Build preview message preview-build-message.txt +examples/*/coverage +examples/*/dist +examples/*/docs packages/*/coverage packages/*/dist packages/*/docs +scripts/coverage # yarn v3 (w/o zero-install) # See: https://yarnpkg.com/getting-started/qa#which-files-should-be-gitignored @@ -30,4 +34,31 @@ packages/*/docs !.yarn/versions # typescript +**/.tsc-lint-cache/ packages/*/*.tsbuildinfo + +# AI +.sisyphus/ + +# Platform API docs (generated) +.platform-api-docs/ + +# Agent skills +# Copy `.skills.local.example` to `.skills.local` and edit `SKILLS_DOMAINS=`. +.skills.local + +# Public MetaMask/skills cache maintained by `yarn skills` / `yarn setup`. +.skills-cache/ + +# Generated by MetaMask/skills tools/install. Run `yarn skills` to refresh. +.claude/skills/ +.agents/skills/ +.cursor/rules/ + +# Docusaurus +**/.docusaurus +packages/wallet-framework-docs/site/build + +# Foundry (anvil) binaries cached by @metamask/foundryup for the wallet-cli +# real-chain e2e (enableGlobalCache is off, so the cache lands in-repo). +.metamask/ diff --git a/.nvmrc b/.nvmrc index 6f7f377bf51..b009dfb9d9f 100644 --- a/.nvmrc +++ b/.nvmrc @@ -1 +1 @@ -v16 +lts/* diff --git a/.oxfmtrc.json b/.oxfmtrc.json new file mode 100644 index 00000000000..b5e76ee140b --- /dev/null +++ b/.oxfmtrc.json @@ -0,0 +1,20 @@ +{ + "$schema": "./node_modules/oxfmt/configuration_schema.json", + "printWidth": 80, + "quoteProps": "as-needed", + "singleQuote": true, + "sortImports": { + "newlinesBetween": false, + "groups": [ + ["builtin", "external"], + { "newlinesBetween": true }, + ["internal", "parent", "sibling", "index", "unknown"] + ] + }, + "sortPackageJson": { + "sortScripts": true + }, + "tabWidth": 2, + "trailingComma": "all", + "ignorePatterns": [".yarnrc.yml", "merged-packages/**"] +} diff --git a/.prettierrc.js b/.prettierrc.js index b2d98d2ee28..36634700bfe 100644 --- a/.prettierrc.js +++ b/.prettierrc.js @@ -1,6 +1,9 @@ -// All of these are defaults except singleQuote, but we specify them -// for explicitness +/** + * @type {import('prettier').Options} + */ module.exports = { + // All of these are defaults except singleQuote, but we specify them + // for explicitness quoteProps: 'as-needed', singleQuote: true, tabWidth: 2, diff --git a/.skills.local.example b/.skills.local.example new file mode 100644 index 00000000000..8254a792a12 --- /dev/null +++ b/.skills.local.example @@ -0,0 +1,34 @@ +# Template for per-engineer skills config used by `yarn skills`. +# Copy this file to `.skills.local` (gitignored). +# +# Zero-config default: the shared @metamask/skills CLI refreshes +# `.skills-cache/metamask-skills` during setup. `yarn skills` auto-detects that +# cache when no env var is set, and falls back to the bundled package snapshot +# if the cache is unavailable — nothing to do. +# +# Optional persistent skills config belongs in this file. Environment variables +# with the same names are only for one-off shell or CI overrides and take +# precedence over this file. +# METAMASK_SKILLS_DIR path to MetaMask/skills source checkout (optional override) +# CONSENSYS_SKILLS_DIR path to Consensys/skills checkout (private overlay) +# METAMASK_SKILLS_TARGET_REPO canonical repo overlay for forks/unusual remotes +# +# Example local setup (only if you want to override the cache): +# METAMASK_SKILLS_DIR=~/dev/metamask/skills +# CONSENSYS_SKILLS_DIR=~/dev/Consensys/skills # optional +# METAMASK_SKILLS_TARGET_REPO=metamask-mobile # optional fork override +# +# Default behavior installs ALL stable domains available for Core. Set +# SKILLS_DOMAINS to opt out of some: +# SKILLS_DOMAINS= # all (default) +# SKILLS_DOMAINS=perps # single domain +# SKILLS_DOMAINS=perps,coding,pr-workflow # multiple domains +# +# Optional: regenerate gitignored installed skills during yarn install/setup after +# the public cache refreshes. Off by default for backward compatibility. +# SKILLS_AUTO_UPDATE=1 # also accepts true/yes +# +# Override per-run with `SKILLS_DOMAINS=... yarn skills` or `--domain `. +# Pick interactively with `yarn skills --select`. +# Use `yarn skills --reset` to wipe. +SKILLS_DOMAINS= diff --git a/.yarn/patches/@metamask-rpc-methods-npm-0.38.1-flask.1-081e1eb5b3.patch b/.yarn/patches/@metamask-rpc-methods-npm-0.38.1-flask.1-081e1eb5b3.patch deleted file mode 100644 index 01ab02aa938..00000000000 --- a/.yarn/patches/@metamask-rpc-methods-npm-0.38.1-flask.1-081e1eb5b3.patch +++ /dev/null @@ -1,19 +0,0 @@ -diff --git a/dist/types/restricted/getLocale.d.ts b/dist/types/restricted/getLocale.d.ts -index 2941d2733042664c341776c7bc840ba0813994ca..0188bbd4de0cd013159a36b736ad9baf94c18c92 100644 ---- a/dist/types/restricted/getLocale.d.ts -+++ b/dist/types/restricted/getLocale.d.ts -@@ -1,6 +1,6 @@ - import type { PermissionSpecificationBuilder, ValidPermissionSpecification, RestrictedMethodOptions } from '@metamask/permission-controller'; - import { PermissionType } from '@metamask/permission-controller'; --import type { NonEmptyArray } from '@metamask/utils'; -+import type { Json, NonEmptyArray } from '@metamask/utils'; - import type { MethodHooksObject } from '../utils'; - declare const methodName = "snap_getLocale"; - export declare type GetLocaleMethodHooks = { -@@ -43,5 +43,5 @@ export declare const getLocaleBuilder: Readonly<{ - * @param hooks.getLocale - A function that returns the user selected locale. - * @returns The user selected locale. - */ --export declare function getImplementation({ getLocale }: GetLocaleMethodHooks): (_args: RestrictedMethodOptions) => Promise; -+export declare function getImplementation({ getLocale }: GetLocaleMethodHooks): (_args: RestrictedMethodOptions | Json[]>) => Promise; - export {}; diff --git a/.yarn/patches/@nktkas-hyperliquid-npm-0.33.1-6a541fdd1d.patch b/.yarn/patches/@nktkas-hyperliquid-npm-0.33.1-6a541fdd1d.patch new file mode 100644 index 00000000000..1862794487c --- /dev/null +++ b/.yarn/patches/@nktkas-hyperliquid-npm-0.33.1-6a541fdd1d.patch @@ -0,0 +1,2108 @@ +diff --git a/esm/_base.d.ts b/esm/_base.d.ts +index 6dfdee7627e43f4ddc8912c240dda00cb23c3271..e09467f3e0a151d7dd1b2a0629dddab1fc5d2860 100644 +--- a/esm/_base.d.ts ++++ b/esm/_base.d.ts +@@ -1,4 +1,3 @@ +-/// + import * as v from "valibot"; + /** Base error class for all SDK errors. */ + export declare class HyperliquidError extends Error { +diff --git a/esm/_deps/jsr.io/@std/async/1.4.0/unstable_semaphore.d.ts b/esm/_deps/jsr.io/@std/async/1.4.0/unstable_semaphore.d.ts +index 34a2673e33bd40a56d11b6dd25820682ad5f6e5c..59297cd4cc1def87e71c8c08f6a432cb119101b3 100644 +--- a/esm/_deps/jsr.io/@std/async/1.4.0/unstable_semaphore.d.ts ++++ b/esm/_deps/jsr.io/@std/async/1.4.0/unstable_semaphore.d.ts +@@ -1,4 +1,3 @@ +-/// + /** + * A counting semaphore for limiting concurrent access to a resource. + * +diff --git a/esm/_deps/jsr.io/@std/bytes/1.0.6/_types.d.ts b/esm/_deps/jsr.io/@std/bytes/1.0.6/_types.d.ts +index ef964d9a4aa1566b79d1be0c28a610dcaaecc25a..463832a01700f56a171f8d60c16754e5ace5a69b 100644 +--- a/esm/_deps/jsr.io/@std/bytes/1.0.6/_types.d.ts ++++ b/esm/_deps/jsr.io/@std/bytes/1.0.6/_types.d.ts +@@ -1,4 +1,3 @@ +-/// + /** + * Proxy type of {@code Uint8Array + import type { Uint8Array_ } from "./_types.js"; + export type { Uint8Array_ }; + /** +diff --git a/esm/_deps/jsr.io/@std/msgpack/1.0.3/_types.d.ts b/esm/_deps/jsr.io/@std/msgpack/1.0.3/_types.d.ts +index a6ca137ba218a283e3dd3e2487fa996ec02bbbfd..463832a01700f56a171f8d60c16754e5ace5a69b 100644 +--- a/esm/_deps/jsr.io/@std/msgpack/1.0.3/_types.d.ts ++++ b/esm/_deps/jsr.io/@std/msgpack/1.0.3/_types.d.ts +@@ -1,4 +1,3 @@ +-/// + /** + * Proxy type of {@code Uint8Array + import type { Uint8Array_ } from "./_types.js"; + export type { Uint8Array_ }; + /** +diff --git a/esm/api/_errors.d.ts b/esm/api/_errors.d.ts +index f86479236d5356d81bfa351ff0e016fca5690d70..bfabf3a046836707cf9b19d27534644936480c74 100644 +--- a/esm/api/_errors.d.ts ++++ b/esm/api/_errors.d.ts +@@ -2,7 +2,6 @@ + * Shared error types for Hyperliquid API responses. + * @module + */ +-/// + import { HyperliquidError } from "../_base.js"; + /** Thrown when the API returns an error response. */ + export declare class ApiRequestError extends HyperliquidError { +diff --git a/esm/api/_schemas.d.ts b/esm/api/_schemas.d.ts +index b723e16e5a25f32508947860e56fad2f3b326347..2096a5c1d2696705e56f4974360511c99956e1e7 100644 +--- a/esm/api/_schemas.d.ts ++++ b/esm/api/_schemas.d.ts +@@ -1,4 +1,3 @@ +-/// + /** + * Common valibot schemas for primitive types used across the API. + * @module +diff --git a/esm/api/exchange/_methods/_base/_config.d.ts b/esm/api/exchange/_methods/_base/_config.d.ts +index 6cd36a471e5a20ea68d7ba36ec15c82b4bcf1d66..7d1b113db913976b9aed26c79d8540803810d4ce 100644 +--- a/esm/api/exchange/_methods/_base/_config.d.ts ++++ b/esm/api/exchange/_methods/_base/_config.d.ts +@@ -2,7 +2,6 @@ + * Configuration types for Exchange API requests. + * @module + */ +-/// + import type { AbstractWallet } from "../../../../signing/mod.js"; + import type { IRequestTransport } from "../../../../transport/mod.js"; + /** A value or a Promise of that value. */ +diff --git a/esm/api/exchange/_methods/_base/_nonce.d.ts b/esm/api/exchange/_methods/_base/_nonce.d.ts +index 907f36e6acc1d9f2b5c7e99a4e13566cbf796394..4954571168d85102213c3f9b45d68c48cc764a01 100644 +--- a/esm/api/exchange/_methods/_base/_nonce.d.ts ++++ b/esm/api/exchange/_methods/_base/_nonce.d.ts +@@ -2,7 +2,6 @@ + * Nonce manager for generating unique, monotonically increasing nonces. + * @module + */ +-/// + /** Nonce manager interface. */ + export interface NonceManager { + /** Returns a unique nonce for the given key, monotonically increasing per key. */ +diff --git a/esm/api/exchange/_methods/_base/_semaphore.d.ts b/esm/api/exchange/_methods/_base/_semaphore.d.ts +index c5d9dfa4ac6814dcfccb01d38b1621c66754483e..e122e44f970fef499eb11f19862f66c36945a43e 100644 +--- a/esm/api/exchange/_methods/_base/_semaphore.d.ts ++++ b/esm/api/exchange/_methods/_base/_semaphore.d.ts +@@ -2,7 +2,6 @@ + * Per-key semaphore registry for serializing async operations. + * @module + */ +-/// + /** + * Acquires a lock for the given key, executes the provided async function, and releases the lock. + * +diff --git a/esm/api/exchange/_methods/_base/_shell.d.ts b/esm/api/exchange/_methods/_base/_shell.d.ts +index b4423e58ee4c3dd4209b91d772b0e16904581e09..37d63bfe3c4be445d225fd8d497fec7087d0a259 100644 +--- a/esm/api/exchange/_methods/_base/_shell.d.ts ++++ b/esm/api/exchange/_methods/_base/_shell.d.ts +@@ -2,7 +2,6 @@ + * Common execution shell shared by L1 and user-signed Exchange API actions. + * @module + */ +-/// + import { type Signature } from "../../../../signing/mod.js"; + import type { ExchangeConfig } from "./_config.js"; + /** Result returned by the {@linkcode executeWithShell} `build` callback. */ +diff --git a/esm/api/exchange/_methods/_base/errors.d.ts b/esm/api/exchange/_methods/_base/errors.d.ts +index f3b97d03bc46f18c66611e36830185e55daef838..54dc93112539bb60e223996a2d00ffc7093bfefb 100644 +--- a/esm/api/exchange/_methods/_base/errors.d.ts ++++ b/esm/api/exchange/_methods/_base/errors.d.ts +@@ -2,7 +2,6 @@ + * Error types and utilities for Exchange API responses. + * @module + */ +-/// + import { ApiRequestError } from "../../../_errors.js"; + export { ApiRequestError }; + /** Top-level error shape. */ +diff --git a/esm/api/exchange/_methods/_base/execute.d.ts b/esm/api/exchange/_methods/_base/execute.d.ts +index 2b862dd936e274784a0aba4c01c7c9f3a7794b7e..ce1b4be3529054bb1182c88d2aeeab117b166695 100644 +--- a/esm/api/exchange/_methods/_base/execute.d.ts ++++ b/esm/api/exchange/_methods/_base/execute.d.ts +@@ -2,7 +2,6 @@ + * Execute helpers for L1 and user-signed Exchange API actions. + * @module + */ +-/// + import type { ExchangeConfig } from "./_config.js"; + /** + * Execute an L1 action on the Hyperliquid Exchange. +diff --git a/esm/api/exchange/_methods/_base/mod.d.ts b/esm/api/exchange/_methods/_base/mod.d.ts +index 285fe2a85e00fc6fe196cc1e6847807cfedaa15d..58aef877c752c36e9ebfab962de40f31160c0714 100644 +--- a/esm/api/exchange/_methods/_base/mod.d.ts ++++ b/esm/api/exchange/_methods/_base/mod.d.ts +@@ -2,7 +2,6 @@ + * Base infrastructure for Exchange API methods. + * @module + */ +-/// + export type { ExchangeConfig, ExchangeMultiSigConfig, ExchangeSingleWalletConfig, ExtractRequestOptions, } from "./_config.js"; + export { ApiRequestError, type ExcludeErrorResponse } from "./errors.js"; + export { executeL1Action, executeUserSignedAction } from "./execute.js"; +diff --git a/esm/api/exchange/_methods/agentEnableDexAbstraction.d.ts b/esm/api/exchange/_methods/agentEnableDexAbstraction.d.ts +index b5f4c38dacb767e8e7e38fca0954e43dc14765e6..7ff557bb2617667a56a2f585f713820c2dfbdc17 100644 +--- a/esm/api/exchange/_methods/agentEnableDexAbstraction.d.ts ++++ b/esm/api/exchange/_methods/agentEnableDexAbstraction.d.ts +@@ -1,4 +1,3 @@ +-/// + import * as v from "valibot"; + /** + * Enable HIP-3 DEX abstraction. +diff --git a/esm/api/exchange/_methods/agentSendAsset.d.ts b/esm/api/exchange/_methods/agentSendAsset.d.ts +index 82d8534671bf4288d09b052acf00c4b7bc11e840..337e7a84789577527b46d2d34bd3cb12b3404a18 100644 +--- a/esm/api/exchange/_methods/agentSendAsset.d.ts ++++ b/esm/api/exchange/_methods/agentSendAsset.d.ts +@@ -1,4 +1,3 @@ +-/// + import * as v from "valibot"; + /** + * Transfer tokens on behalf of the principal via an agent wallet. +diff --git a/esm/api/exchange/_methods/agentSetAbstraction.d.ts b/esm/api/exchange/_methods/agentSetAbstraction.d.ts +index 72523481ff8d6aa0c8be3a20fd785d1fe921cabd..aab5733274509c728801eea0e759c0d158a1ee01 100644 +--- a/esm/api/exchange/_methods/agentSetAbstraction.d.ts ++++ b/esm/api/exchange/_methods/agentSetAbstraction.d.ts +@@ -1,4 +1,3 @@ +-/// + import * as v from "valibot"; + /** + * Set user abstraction mode (method for agent wallet). +diff --git a/esm/api/exchange/_methods/approveAgent.d.ts b/esm/api/exchange/_methods/approveAgent.d.ts +index 2fb646f3b7daa926140a6290b763289129d0a949..05606bf03f120cb25e4d6f63db95d09e512c4361 100644 +--- a/esm/api/exchange/_methods/approveAgent.d.ts ++++ b/esm/api/exchange/_methods/approveAgent.d.ts +@@ -1,4 +1,3 @@ +-/// + import * as v from "valibot"; + /** + * Approve an agent to sign on behalf of the master account. +diff --git a/esm/api/exchange/_methods/approveBuilderFee.d.ts b/esm/api/exchange/_methods/approveBuilderFee.d.ts +index e1191dcebd3e42ec65de2e768fbc9597944f5c6f..f87d8dbe7815434baeb3196da20b966a2f863dcd 100644 +--- a/esm/api/exchange/_methods/approveBuilderFee.d.ts ++++ b/esm/api/exchange/_methods/approveBuilderFee.d.ts +@@ -1,4 +1,3 @@ +-/// + import * as v from "valibot"; + /** + * Approve a maximum fee rate for a builder. +diff --git a/esm/api/exchange/_methods/authorizeAqav2Role.d.ts b/esm/api/exchange/_methods/authorizeAqav2Role.d.ts +index e362a9bf4825cf68ed9b42008ce3ed3a9dc7ce34..312a4f785dee97984aca97c7c773d9a25bdddb91 100644 +--- a/esm/api/exchange/_methods/authorizeAqav2Role.d.ts ++++ b/esm/api/exchange/_methods/authorizeAqav2Role.d.ts +@@ -1,4 +1,3 @@ +-/// + import * as v from "valibot"; + /** + * Authorize an AQAv2 role. +diff --git a/esm/api/exchange/_methods/batchModify.d.ts b/esm/api/exchange/_methods/batchModify.d.ts +index d6f57e4682b1a1ee5c8abdfe7acaea185dc708b4..1657fddd376cbdb1f9232a140442c22251449002 100644 +--- a/esm/api/exchange/_methods/batchModify.d.ts ++++ b/esm/api/exchange/_methods/batchModify.d.ts +@@ -1,4 +1,3 @@ +-/// + import * as v from "valibot"; + import type { OrderResponse } from "./order.js"; + /** +diff --git a/esm/api/exchange/_methods/borrowLend.d.ts b/esm/api/exchange/_methods/borrowLend.d.ts +index bfd7cd61df1a1141a36ffb90cafc965350fe15c8..65a24451a945e417d53c5ca900b51f13835d0e51 100644 +--- a/esm/api/exchange/_methods/borrowLend.d.ts ++++ b/esm/api/exchange/_methods/borrowLend.d.ts +@@ -1,4 +1,3 @@ +-/// + import * as v from "valibot"; + /** + * Borrow or lend assets. +diff --git a/esm/api/exchange/_methods/cDeposit.d.ts b/esm/api/exchange/_methods/cDeposit.d.ts +index dab81be77354caf51f931626f3ef0f4ab0186746..6edcdb8b72908feed36973c7ac9e0ef3a7c32546 100644 +--- a/esm/api/exchange/_methods/cDeposit.d.ts ++++ b/esm/api/exchange/_methods/cDeposit.d.ts +@@ -1,4 +1,3 @@ +-/// + import * as v from "valibot"; + /** + * Transfer native token from the user spot account into staking for delegating to validators. +diff --git a/esm/api/exchange/_methods/cSignerAction.d.ts b/esm/api/exchange/_methods/cSignerAction.d.ts +index cd2117d78a894c884d23d343d93810e99a4dd1de..c73e546b68f693e353d087496bd7f6f859339dcd 100644 +--- a/esm/api/exchange/_methods/cSignerAction.d.ts ++++ b/esm/api/exchange/_methods/cSignerAction.d.ts +@@ -1,4 +1,3 @@ +-/// + import * as v from "valibot"; + /** + * Jail or unjail self as a validator signer. +diff --git a/esm/api/exchange/_methods/cValidatorAction.d.ts b/esm/api/exchange/_methods/cValidatorAction.d.ts +index 8b4f0cda2800c5840e508d8a60bd7934adbc8e98..4d7cb2c51d5675c21e45ea773263e8bd9afb2f06 100644 +--- a/esm/api/exchange/_methods/cValidatorAction.d.ts ++++ b/esm/api/exchange/_methods/cValidatorAction.d.ts +@@ -1,4 +1,3 @@ +-/// + import * as v from "valibot"; + /** + * Action related to validator management. +diff --git a/esm/api/exchange/_methods/cWithdraw.d.ts b/esm/api/exchange/_methods/cWithdraw.d.ts +index 90c46ca63a7ffc7690611d439c5fc6877b9374fe..53842c9b392011b3ed4e7edd78e5683648edd594 100644 +--- a/esm/api/exchange/_methods/cWithdraw.d.ts ++++ b/esm/api/exchange/_methods/cWithdraw.d.ts +@@ -1,4 +1,3 @@ +-/// + import * as v from "valibot"; + /** + * Transfer native token from staking into the user's spot account. +diff --git a/esm/api/exchange/_methods/cancel.d.ts b/esm/api/exchange/_methods/cancel.d.ts +index 50c3e83ba0c0c67d9614e5daa38d51dc53b71ec0..174564432e9b42207ff03c55e896c46c8e528edb 100644 +--- a/esm/api/exchange/_methods/cancel.d.ts ++++ b/esm/api/exchange/_methods/cancel.d.ts +@@ -1,4 +1,3 @@ +-/// + import * as v from "valibot"; + /** + * Cancel order(s). +diff --git a/esm/api/exchange/_methods/cancelByCloid.d.ts b/esm/api/exchange/_methods/cancelByCloid.d.ts +index 303f14a4cf8507f1423ffb707c2c86061a176486..bb3642fbbd832c0719a54043cd2e0d66ead5be96 100644 +--- a/esm/api/exchange/_methods/cancelByCloid.d.ts ++++ b/esm/api/exchange/_methods/cancelByCloid.d.ts +@@ -1,4 +1,3 @@ +-/// + import * as v from "valibot"; + import type { CancelResponse } from "./cancel.js"; + /** +diff --git a/esm/api/exchange/_methods/claimRewards.d.ts b/esm/api/exchange/_methods/claimRewards.d.ts +index 33ecfd01917c736b20d51265fda6e2db304392bf..f931eb80b56b905872015e64e46830531be4cdc8 100644 +--- a/esm/api/exchange/_methods/claimRewards.d.ts ++++ b/esm/api/exchange/_methods/claimRewards.d.ts +@@ -1,4 +1,3 @@ +-/// + import * as v from "valibot"; + /** + * Claim rewards from referral program. +diff --git a/esm/api/exchange/_methods/convertToMultiSigUser.d.ts b/esm/api/exchange/_methods/convertToMultiSigUser.d.ts +index 0b771c5cf41997a8e6f9ea7984effa848e0ad693..4a91c0ac7ad7fdf7eb1cbcdd173b7e5cff6be198 100644 +--- a/esm/api/exchange/_methods/convertToMultiSigUser.d.ts ++++ b/esm/api/exchange/_methods/convertToMultiSigUser.d.ts +@@ -1,4 +1,3 @@ +-/// + import * as v from "valibot"; + /** + * Convert a single-signature account to a multi-signature account or vice versa. +diff --git a/esm/api/exchange/_methods/createSubAccount.d.ts b/esm/api/exchange/_methods/createSubAccount.d.ts +index 86ebb2f05a113090f02a15b592cacdb73d6fd3ba..b50d72e4a2f16dd0072764dcd7725ec58cd152cc 100644 +--- a/esm/api/exchange/_methods/createSubAccount.d.ts ++++ b/esm/api/exchange/_methods/createSubAccount.d.ts +@@ -1,4 +1,3 @@ +-/// + import * as v from "valibot"; + /** + * Create a sub-account. +diff --git a/esm/api/exchange/_methods/createVault.d.ts b/esm/api/exchange/_methods/createVault.d.ts +index 580026e419a374ec454c6fbb1187d2d166834a2e..ab90fa1eb2e3d0f9bcd26bedf02eefb33ae4c4da 100644 +--- a/esm/api/exchange/_methods/createVault.d.ts ++++ b/esm/api/exchange/_methods/createVault.d.ts +@@ -1,4 +1,3 @@ +-/// + import * as v from "valibot"; + /** + * Create a vault. +diff --git a/esm/api/exchange/_methods/evmUserModify.d.ts b/esm/api/exchange/_methods/evmUserModify.d.ts +index e390a4e61d4eb70bfc5936aedb444ca88acb5ed5..117fb1452dbced86a7f502d8bb6f7344469cf736 100644 +--- a/esm/api/exchange/_methods/evmUserModify.d.ts ++++ b/esm/api/exchange/_methods/evmUserModify.d.ts +@@ -1,4 +1,3 @@ +-/// + import * as v from "valibot"; + /** + * Configure block type for EVM transactions. +diff --git a/esm/api/exchange/_methods/finalizeEvmContract.d.ts b/esm/api/exchange/_methods/finalizeEvmContract.d.ts +index 0c5db2305a23df6230896eae221add6f63639f71..51f0ac3c34ff7fd2feb3c244c69f81a21833b1a2 100644 +--- a/esm/api/exchange/_methods/finalizeEvmContract.d.ts ++++ b/esm/api/exchange/_methods/finalizeEvmContract.d.ts +@@ -1,4 +1,3 @@ +-/// + import * as v from "valibot"; + /** + * Finalize the link between a HyperCore spot token and an ERC-20 contract on the HyperEVM. +diff --git a/esm/api/exchange/_methods/gossipPriorityBid.d.ts b/esm/api/exchange/_methods/gossipPriorityBid.d.ts +index 10e66610a771029a5603b36d2da2f4003f7ac3db..c5a5251b43a7515a1f880eb6fa07de82a0332078 100644 +--- a/esm/api/exchange/_methods/gossipPriorityBid.d.ts ++++ b/esm/api/exchange/_methods/gossipPriorityBid.d.ts +@@ -1,4 +1,3 @@ +-/// + import * as v from "valibot"; + /** + * Bid in a gossip priority Dutch auction to receive prioritized mempool data for an IP. +diff --git a/esm/api/exchange/_methods/hip3LiquidatorTransfer.d.ts b/esm/api/exchange/_methods/hip3LiquidatorTransfer.d.ts +index be3329e1cf7347f6e439ddfd9aacb7c82c71d62a..4dd59d28dfefef24888bb6d417bdc3090bd7f309 100644 +--- a/esm/api/exchange/_methods/hip3LiquidatorTransfer.d.ts ++++ b/esm/api/exchange/_methods/hip3LiquidatorTransfer.d.ts +@@ -1,4 +1,3 @@ +-/// + import * as v from "valibot"; + /** + * Deposit into or withdraw from the HIP-3 DEX backstop liquidator. +diff --git a/esm/api/exchange/_methods/linkStakingUser.d.ts b/esm/api/exchange/_methods/linkStakingUser.d.ts +index 0ab80709d79bdbf848e1adcaff219191dc432f74..d5af7ee8e3ba207a96ebe75109580dc8616a24e9 100644 +--- a/esm/api/exchange/_methods/linkStakingUser.d.ts ++++ b/esm/api/exchange/_methods/linkStakingUser.d.ts +@@ -1,4 +1,3 @@ +-/// + import * as v from "valibot"; + /** + * Link staking and trading accounts for fee discount attribution. +diff --git a/esm/api/exchange/_methods/modify.d.ts b/esm/api/exchange/_methods/modify.d.ts +index 995475bb8bdf3436122bbaeea23f56a661ff7d3a..e8c0805af4ad1ce435d1f28fa9a75772dd62bbad 100644 +--- a/esm/api/exchange/_methods/modify.d.ts ++++ b/esm/api/exchange/_methods/modify.d.ts +@@ -1,4 +1,3 @@ +-/// + import * as v from "valibot"; + /** + * Modify an order. +diff --git a/esm/api/exchange/_methods/noop.d.ts b/esm/api/exchange/_methods/noop.d.ts +index 529025c3c201f96c85455fb366e97c8a0c1fca73..4302c9422539bcb4858d57321b885b88ae66f03e 100644 +--- a/esm/api/exchange/_methods/noop.d.ts ++++ b/esm/api/exchange/_methods/noop.d.ts +@@ -1,4 +1,3 @@ +-/// + import * as v from "valibot"; + /** + * This action does not do anything (no operation), but causes the nonce to be marked as used. +diff --git a/esm/api/exchange/_methods/order.d.ts b/esm/api/exchange/_methods/order.d.ts +index 1cd79f856f43538fa16a20f70f2a118012b027a5..422c7b6a1a84e95bc8b3c6035fe1d157014fb516 100644 +--- a/esm/api/exchange/_methods/order.d.ts ++++ b/esm/api/exchange/_methods/order.d.ts +@@ -1,4 +1,3 @@ +-/// + import * as v from "valibot"; + /** + * Place an order(s). +diff --git a/esm/api/exchange/_methods/perpDeploy.d.ts b/esm/api/exchange/_methods/perpDeploy.d.ts +index 0874b6afddd3cc8a3ea8b69c861fae743d6d3cc2..d315035a7c922931c6354e38a1715d335597637e 100644 +--- a/esm/api/exchange/_methods/perpDeploy.d.ts ++++ b/esm/api/exchange/_methods/perpDeploy.d.ts +@@ -1,4 +1,3 @@ +-/// + import * as v from "valibot"; + /** + * Deploying HIP-3 assets. +diff --git a/esm/api/exchange/_methods/registerReferrer.d.ts b/esm/api/exchange/_methods/registerReferrer.d.ts +index 94f0bdf62b78cbb6f809e70b93f68488f23bfbfe..252ada2010f0ac30449c3cd1bf64d66c5e5daa54 100644 +--- a/esm/api/exchange/_methods/registerReferrer.d.ts ++++ b/esm/api/exchange/_methods/registerReferrer.d.ts +@@ -1,4 +1,3 @@ +-/// + import * as v from "valibot"; + /** + * Create a referral code. +diff --git a/esm/api/exchange/_methods/reserveRequestWeight.d.ts b/esm/api/exchange/_methods/reserveRequestWeight.d.ts +index b802292f1324c636e531d7d05c33e8a56cb7b928..63ac4ab79603107f7bbeb5736c35a51ed5392645 100644 +--- a/esm/api/exchange/_methods/reserveRequestWeight.d.ts ++++ b/esm/api/exchange/_methods/reserveRequestWeight.d.ts +@@ -1,4 +1,3 @@ +-/// + import * as v from "valibot"; + /** + * Reserve additional rate-limited actions for a fee. +diff --git a/esm/api/exchange/_methods/scheduleCancel.d.ts b/esm/api/exchange/_methods/scheduleCancel.d.ts +index 48ac0498f771aa2680ae152b79a837a64471eee0..19cd79b0284066009c9730c21886b9afb4081e45 100644 +--- a/esm/api/exchange/_methods/scheduleCancel.d.ts ++++ b/esm/api/exchange/_methods/scheduleCancel.d.ts +@@ -1,4 +1,3 @@ +-/// + import * as v from "valibot"; + /** + * Schedule a cancel-all operation at a future time. +diff --git a/esm/api/exchange/_methods/sendAsset.d.ts b/esm/api/exchange/_methods/sendAsset.d.ts +index 5eb55f4116c7fdc3d12715d97b63ff32d34e3d42..9bcd1e5dd7f1706e2cbd5b92b18f78e11b4eeb96 100644 +--- a/esm/api/exchange/_methods/sendAsset.d.ts ++++ b/esm/api/exchange/_methods/sendAsset.d.ts +@@ -1,4 +1,3 @@ +-/// + import * as v from "valibot"; + /** + * Transfer tokens between different perp DEXs, spot balance, users, and/or sub-accounts. +diff --git a/esm/api/exchange/_methods/sendToEvmWithData.d.ts b/esm/api/exchange/_methods/sendToEvmWithData.d.ts +index 7e208ba78928cfb297293fe9c0f42aff1fd4b2e9..a155bcf12bc97f35d41048588394558e50620222 100644 +--- a/esm/api/exchange/_methods/sendToEvmWithData.d.ts ++++ b/esm/api/exchange/_methods/sendToEvmWithData.d.ts +@@ -1,4 +1,3 @@ +-/// + import * as v from "valibot"; + /** + * Transfer tokens from Core to EVM with an additional data payload for `ICoreReceiveWithData` contracts. +diff --git a/esm/api/exchange/_methods/setDisplayName.d.ts b/esm/api/exchange/_methods/setDisplayName.d.ts +index 80d88f1add58082637f1492ce635be119b17c229..e5d903c1ec2fd1650ee22b97f26fbf86443e8d9e 100644 +--- a/esm/api/exchange/_methods/setDisplayName.d.ts ++++ b/esm/api/exchange/_methods/setDisplayName.d.ts +@@ -1,4 +1,3 @@ +-/// + import * as v from "valibot"; + /** + * Set the display name in the leaderboard. +diff --git a/esm/api/exchange/_methods/setReferrer.d.ts b/esm/api/exchange/_methods/setReferrer.d.ts +index da3364977eff97c0a4ed718defa2732747996187..9b6ec1166ab76c226df1742e44d8d1399e1867ef 100644 +--- a/esm/api/exchange/_methods/setReferrer.d.ts ++++ b/esm/api/exchange/_methods/setReferrer.d.ts +@@ -1,4 +1,3 @@ +-/// + import * as v from "valibot"; + /** + * Set a referral code. +diff --git a/esm/api/exchange/_methods/spotDeploy.d.ts b/esm/api/exchange/_methods/spotDeploy.d.ts +index 04406e98cc5f974ac2aa8126e12526e303401c7d..e783709c43872fc8ee198ebc5bee9efaa0e5df90 100644 +--- a/esm/api/exchange/_methods/spotDeploy.d.ts ++++ b/esm/api/exchange/_methods/spotDeploy.d.ts +@@ -1,4 +1,3 @@ +-/// + import * as v from "valibot"; + /** + * Deploying HIP-1 and HIP-2 assets. +diff --git a/esm/api/exchange/_methods/spotSend.d.ts b/esm/api/exchange/_methods/spotSend.d.ts +index 563cbc624e014468d390ac0a0f3e579731f5e8ca..33119d1cd3268dcb7e7c946172561e124daa820c 100644 +--- a/esm/api/exchange/_methods/spotSend.d.ts ++++ b/esm/api/exchange/_methods/spotSend.d.ts +@@ -1,4 +1,3 @@ +-/// + import * as v from "valibot"; + /** + * Send spot assets to another address. +diff --git a/esm/api/exchange/_methods/spotUser.d.ts b/esm/api/exchange/_methods/spotUser.d.ts +index 1bfd787b34eeee76107dc38253c4d3019b676467..29f2ccda4d30b68f1fa6278ffd4b9f6205fc76aa 100644 +--- a/esm/api/exchange/_methods/spotUser.d.ts ++++ b/esm/api/exchange/_methods/spotUser.d.ts +@@ -1,4 +1,3 @@ +-/// + import * as v from "valibot"; + /** + * Opt out of spot dusting. +diff --git a/esm/api/exchange/_methods/stakingLinkDisableTradingUser.d.ts b/esm/api/exchange/_methods/stakingLinkDisableTradingUser.d.ts +index 0fe2477a946ce871ea8ccfc74d563c9ce5666822..8ff10ee2f9b0166ac44dcfaddc1e276b3af84696 100644 +--- a/esm/api/exchange/_methods/stakingLinkDisableTradingUser.d.ts ++++ b/esm/api/exchange/_methods/stakingLinkDisableTradingUser.d.ts +@@ -1,4 +1,3 @@ +-/// + import * as v from "valibot"; + /** + * Permanently disable a linked trading user, locking its funds. +diff --git a/esm/api/exchange/_methods/subAccountModify.d.ts b/esm/api/exchange/_methods/subAccountModify.d.ts +index 3e140da4e8176bb6878cd0e2690326258e3bda20..49b7d4dc6db4517573e12125041b625eea7d0682 100644 +--- a/esm/api/exchange/_methods/subAccountModify.d.ts ++++ b/esm/api/exchange/_methods/subAccountModify.d.ts +@@ -1,4 +1,3 @@ +-/// + import * as v from "valibot"; + /** + * Modify a sub-account. +diff --git a/esm/api/exchange/_methods/subAccountSpotTransfer.d.ts b/esm/api/exchange/_methods/subAccountSpotTransfer.d.ts +index 51c2aa6ac45a949b2959d97c4ace1db3639f09a9..f7eca31fc568f5d6aae852f4f4bc3757e2dfbee0 100644 +--- a/esm/api/exchange/_methods/subAccountSpotTransfer.d.ts ++++ b/esm/api/exchange/_methods/subAccountSpotTransfer.d.ts +@@ -1,4 +1,3 @@ +-/// + import * as v from "valibot"; + /** + * Transfer between sub-accounts (spot). +diff --git a/esm/api/exchange/_methods/subAccountTransfer.d.ts b/esm/api/exchange/_methods/subAccountTransfer.d.ts +index a353a7a2cd2d20e27ab249f3f2e84f9bc48760b7..ebaf637d84e07e46e416e74d447e0b3eaa81103f 100644 +--- a/esm/api/exchange/_methods/subAccountTransfer.d.ts ++++ b/esm/api/exchange/_methods/subAccountTransfer.d.ts +@@ -1,4 +1,3 @@ +-/// + import * as v from "valibot"; + /** + * Transfer between sub-accounts (perpetual). +diff --git a/esm/api/exchange/_methods/tokenDelegate.d.ts b/esm/api/exchange/_methods/tokenDelegate.d.ts +index 293d5ece3eccf3ba61e3257e393c7dc068a10369..3bea73eb30bee9e33c5267c0b12b79e42850554d 100644 +--- a/esm/api/exchange/_methods/tokenDelegate.d.ts ++++ b/esm/api/exchange/_methods/tokenDelegate.d.ts +@@ -1,4 +1,3 @@ +-/// + import * as v from "valibot"; + /** + * Delegate or undelegate native tokens to or from a validator. +diff --git a/esm/api/exchange/_methods/topUpIsolatedOnlyMargin.d.ts b/esm/api/exchange/_methods/topUpIsolatedOnlyMargin.d.ts +index c54264dc0038f9ba0c2f507458d305bec36e5c97..8741700d2d8cfbe139defaa914efabfc6dd5128c 100644 +--- a/esm/api/exchange/_methods/topUpIsolatedOnlyMargin.d.ts ++++ b/esm/api/exchange/_methods/topUpIsolatedOnlyMargin.d.ts +@@ -1,4 +1,3 @@ +-/// + import * as v from "valibot"; + /** + * Top up isolated margin by targeting a specific leverage. +diff --git a/esm/api/exchange/_methods/twapCancel.d.ts b/esm/api/exchange/_methods/twapCancel.d.ts +index 90eb2e0f686a4db5b70586e844e2e6e03f717116..2ce9347f6010a7d93da340105f98173517af99f0 100644 +--- a/esm/api/exchange/_methods/twapCancel.d.ts ++++ b/esm/api/exchange/_methods/twapCancel.d.ts +@@ -1,4 +1,3 @@ +-/// + import * as v from "valibot"; + /** + * Cancel a TWAP order. +diff --git a/esm/api/exchange/_methods/twapOrder.d.ts b/esm/api/exchange/_methods/twapOrder.d.ts +index a1d64e4aec451e7891030a93d39dbb6b419fc284..20bbda91fb2983163e65e6f8572d084e48e15465 100644 +--- a/esm/api/exchange/_methods/twapOrder.d.ts ++++ b/esm/api/exchange/_methods/twapOrder.d.ts +@@ -1,4 +1,3 @@ +-/// + import * as v from "valibot"; + /** + * Place a TWAP order. +diff --git a/esm/api/exchange/_methods/updateIsolatedMargin.d.ts b/esm/api/exchange/_methods/updateIsolatedMargin.d.ts +index 897361a99afebc5dc7ab10fd1a618363cca988bb..37b8485da89b51a970376dcb2ac791cf7815058f 100644 +--- a/esm/api/exchange/_methods/updateIsolatedMargin.d.ts ++++ b/esm/api/exchange/_methods/updateIsolatedMargin.d.ts +@@ -1,4 +1,3 @@ +-/// + import * as v from "valibot"; + /** + * Add or remove margin from isolated position. +diff --git a/esm/api/exchange/_methods/updateLeverage.d.ts b/esm/api/exchange/_methods/updateLeverage.d.ts +index 41a1ba1e74ed3f8bd96d66cff64bad389717b301..aeb1fe92356c73e89fb968e53965158c91e8d551 100644 +--- a/esm/api/exchange/_methods/updateLeverage.d.ts ++++ b/esm/api/exchange/_methods/updateLeverage.d.ts +@@ -1,4 +1,3 @@ +-/// + import * as v from "valibot"; + /** + * Update cross or isolated leverage on a coin. +diff --git a/esm/api/exchange/_methods/usdClassTransfer.d.ts b/esm/api/exchange/_methods/usdClassTransfer.d.ts +index 65a2b32a4ecef02a2e4bf4fd5b681d5e6aba2b6f..15416f29c5318b32fdc45fa41ece45d3e3bae473 100644 +--- a/esm/api/exchange/_methods/usdClassTransfer.d.ts ++++ b/esm/api/exchange/_methods/usdClassTransfer.d.ts +@@ -1,4 +1,3 @@ +-/// + import * as v from "valibot"; + /** + * Transfer funds between spot account and perp account. +diff --git a/esm/api/exchange/_methods/usdSend.d.ts b/esm/api/exchange/_methods/usdSend.d.ts +index 9fef5b48a0d3c96a0fec615d564992865cc5612e..7985b885081e3f9e14433be5f0b0ee84811df063 100644 +--- a/esm/api/exchange/_methods/usdSend.d.ts ++++ b/esm/api/exchange/_methods/usdSend.d.ts +@@ -1,4 +1,3 @@ +-/// + import * as v from "valibot"; + /** + * Send USD to another address. +diff --git a/esm/api/exchange/_methods/userDexAbstraction.d.ts b/esm/api/exchange/_methods/userDexAbstraction.d.ts +index 04c62ecd20e27aced6557ed22c5e49472bab20c1..32f2a8521f25985403020d0367c902c60796c821 100644 +--- a/esm/api/exchange/_methods/userDexAbstraction.d.ts ++++ b/esm/api/exchange/_methods/userDexAbstraction.d.ts +@@ -1,4 +1,3 @@ +-/// + import * as v from "valibot"; + /** + * Enable/disable HIP-3 DEX abstraction. +diff --git a/esm/api/exchange/_methods/userOutcome.d.ts b/esm/api/exchange/_methods/userOutcome.d.ts +index c09817f95f75d35be3b0b44dc53c6fa5382a8bf1..4415ceb4ab6f228782cf69df1ff8911e097b4e81 100644 +--- a/esm/api/exchange/_methods/userOutcome.d.ts ++++ b/esm/api/exchange/_methods/userOutcome.d.ts +@@ -1,4 +1,3 @@ +-/// + import * as v from "valibot"; + /** + * Manually split or merge outcome shares to convert between primary and dual balances. +diff --git a/esm/api/exchange/_methods/userPortfolioMargin.d.ts b/esm/api/exchange/_methods/userPortfolioMargin.d.ts +index b5f1fabd43f025284cca573217268ca397b7a3cc..988b68f14ff82dd1127781b1296ea6d1d7762257 100644 +--- a/esm/api/exchange/_methods/userPortfolioMargin.d.ts ++++ b/esm/api/exchange/_methods/userPortfolioMargin.d.ts +@@ -1,4 +1,3 @@ +-/// + import * as v from "valibot"; + /** + * Enable/disable user portfolio margin. +diff --git a/esm/api/exchange/_methods/userSetAbstraction.d.ts b/esm/api/exchange/_methods/userSetAbstraction.d.ts +index df1b9ffe9ae5c4f2a839fa7cf1aebf018d2b4688..ffb12ff17e3725b0b7cd15946943993363508c43 100644 +--- a/esm/api/exchange/_methods/userSetAbstraction.d.ts ++++ b/esm/api/exchange/_methods/userSetAbstraction.d.ts +@@ -1,4 +1,3 @@ +-/// + import * as v from "valibot"; + /** + * Set user abstraction mode. +diff --git a/esm/api/exchange/_methods/validatorL1Stream.d.ts b/esm/api/exchange/_methods/validatorL1Stream.d.ts +index 5e6e1f1b1e23ff23d8fa6c1718997c5ade10ba8d..7f7fe8163f425154a6fdd5b0324ba75784948113 100644 +--- a/esm/api/exchange/_methods/validatorL1Stream.d.ts ++++ b/esm/api/exchange/_methods/validatorL1Stream.d.ts +@@ -1,4 +1,3 @@ +-/// + import * as v from "valibot"; + /** + * Validator vote on risk-free rate for aligned quote asset. +diff --git a/esm/api/exchange/_methods/vaultDistribute.d.ts b/esm/api/exchange/_methods/vaultDistribute.d.ts +index 51b2afe208a09d7566a9a25c7d9ef3f408027603..e7b8d425c40621b161a237092983990cfb77eac6 100644 +--- a/esm/api/exchange/_methods/vaultDistribute.d.ts ++++ b/esm/api/exchange/_methods/vaultDistribute.d.ts +@@ -1,4 +1,3 @@ +-/// + import * as v from "valibot"; + /** + * Distribute funds from a vault between followers. +diff --git a/esm/api/exchange/_methods/vaultModify.d.ts b/esm/api/exchange/_methods/vaultModify.d.ts +index 56bb4e683cb88d1ece7373a17fd440e1fe7ff825..71238c9f113dcc0b27971cedffe9f2f205c2a026 100644 +--- a/esm/api/exchange/_methods/vaultModify.d.ts ++++ b/esm/api/exchange/_methods/vaultModify.d.ts +@@ -1,4 +1,3 @@ +-/// + import * as v from "valibot"; + /** + * Modify a vault's configuration. +diff --git a/esm/api/exchange/_methods/vaultTransfer.d.ts b/esm/api/exchange/_methods/vaultTransfer.d.ts +index ea50587f008c5ffd3b7f32951a6e7cc7b48fe3df..9577003bc0fd9024a486b57faa55dd87b87c2906 100644 +--- a/esm/api/exchange/_methods/vaultTransfer.d.ts ++++ b/esm/api/exchange/_methods/vaultTransfer.d.ts +@@ -1,4 +1,3 @@ +-/// + import * as v from "valibot"; + /** + * Deposit or withdraw from a vault. +diff --git a/esm/api/exchange/_methods/withdraw3.d.ts b/esm/api/exchange/_methods/withdraw3.d.ts +index 7fbc386e5ffb075bb9b0b02df6570ec22bd01513..06177a2606b07163a4a70c5b53d75437cd817573 100644 +--- a/esm/api/exchange/_methods/withdraw3.d.ts ++++ b/esm/api/exchange/_methods/withdraw3.d.ts +@@ -1,4 +1,3 @@ +-/// + import * as v from "valibot"; + /** + * Initiate a withdrawal request. +diff --git a/esm/api/exchange/client.d.ts b/esm/api/exchange/client.d.ts +index 7fdbcb2ed6afb47dc8d0a4e6e2045a04927233ef..88f9569298eefa6e9ebbe5508311cff9f73c3162 100644 +--- a/esm/api/exchange/client.d.ts ++++ b/esm/api/exchange/client.d.ts +@@ -2,7 +2,6 @@ + * Client for the Hyperliquid Exchange API endpoint. + * @module + */ +-/// + import type { ExchangeConfig, ExchangeSingleWalletConfig } from "./_methods/_base/mod.js"; + import { type AgentEnableDexAbstractionOptions, type AgentEnableDexAbstractionSuccessResponse } from "./_methods/agentEnableDexAbstraction.js"; + import { type AgentSendAssetOptions, type AgentSendAssetParameters, type AgentSendAssetSuccessResponse } from "./_methods/agentSendAsset.js"; +diff --git a/esm/api/exchange/mod.d.ts b/esm/api/exchange/mod.d.ts +index f28421e6730deccd8a5769613ef07f645ee53553..8b6f0696790923297673fdb4036c63306bdf2f18 100644 +--- a/esm/api/exchange/mod.d.ts ++++ b/esm/api/exchange/mod.d.ts +@@ -36,7 +36,6 @@ + * + * @module + */ +-/// + export { ApiRequestError, type ExchangeConfig, type ExchangeMultiSigConfig, type ExchangeSingleWalletConfig, } from "./_methods/_base/mod.js"; + export * from "./_methods/agentEnableDexAbstraction.js"; + export * from "./_methods/agentSendAsset.js"; +diff --git a/esm/api/explorer/_methods/_base/_config.d.ts b/esm/api/explorer/_methods/_base/_config.d.ts +index 0c9731f3dbd24e9fd421dc0425c7c66c3eae8579..588f69d23b8feff4b6666d60cdebe27afc8669a3 100644 +--- a/esm/api/explorer/_methods/_base/_config.d.ts ++++ b/esm/api/explorer/_methods/_base/_config.d.ts +@@ -2,7 +2,6 @@ + * Configuration types for Explorer API requests. + * @module + */ +-/// + import type { IRequestTransport, ISubscriptionTransport } from "../../../../transport/mod.js"; + /** Configuration for Explorer API requests. */ + export interface ExplorerConfig | ISubscriptionTransport = IRequestTransport<"explorer"> & ISubscriptionTransport> { +diff --git a/esm/api/explorer/_methods/_base/_errors.d.ts b/esm/api/explorer/_methods/_base/_errors.d.ts +index b8add1ec833964715fc4b7e0660fe5c2d445989a..d0309c4f159bebf1285eeeb068e0c7903817ea11 100644 +--- a/esm/api/explorer/_methods/_base/_errors.d.ts ++++ b/esm/api/explorer/_methods/_base/_errors.d.ts +@@ -2,7 +2,6 @@ + * Error detection for Explorer API responses. + * @module + */ +-/// + import { ApiRequestError } from "../../../_errors.js"; + export { ApiRequestError }; + /** +diff --git a/esm/api/explorer/_methods/_base/_schemas.d.ts b/esm/api/explorer/_methods/_base/_schemas.d.ts +index ba34b87493770cab66720964ae0b84b05f72857c..ef16d51fd50d2e1c13e17d328b0f9a4f28c887ac 100644 +--- a/esm/api/explorer/_methods/_base/_schemas.d.ts ++++ b/esm/api/explorer/_methods/_base/_schemas.d.ts +@@ -2,7 +2,6 @@ + * Common types shared across Explorer API methods. + * @module + */ +-/// + /** Explorer transaction. */ + export type ExplorerTransaction = { + /** Action performed in transaction. */ +diff --git a/esm/api/explorer/_methods/_base/mod.d.ts b/esm/api/explorer/_methods/_base/mod.d.ts +index ad4dabbe8fe85d74e1c562ef9d0e46d64075ed9e..00e37436d58a2eb67a8d0e6b2a90b7997715c8fe 100644 +--- a/esm/api/explorer/_methods/_base/mod.d.ts ++++ b/esm/api/explorer/_methods/_base/mod.d.ts +@@ -2,7 +2,6 @@ + * Base infrastructure for Explorer API methods. + * @module + */ +-/// + export type { ExplorerConfig } from "./_config.js"; + export { ApiRequestError, assertSuccessResponse } from "./_errors.js"; + export * from "./_schemas.js"; +diff --git a/esm/api/explorer/_methods/blockDetails.d.ts b/esm/api/explorer/_methods/blockDetails.d.ts +index 5994d86c7173dbc78a3fec431b51fed732d93854..b50dca7eafebf868819eafa5671b6f3f7d15867f 100644 +--- a/esm/api/explorer/_methods/blockDetails.d.ts ++++ b/esm/api/explorer/_methods/blockDetails.d.ts +@@ -1,4 +1,3 @@ +-/// + import * as v from "valibot"; + import type { ExplorerTransaction } from "./_base/mod.js"; + /** +diff --git a/esm/api/explorer/_methods/explorerBlock.d.ts b/esm/api/explorer/_methods/explorerBlock.d.ts +index 0ffa024576b48a4050d9c509323351e25f68306a..96eef00deffbd511b14dcfca650acd95ce41ae7c 100644 +--- a/esm/api/explorer/_methods/explorerBlock.d.ts ++++ b/esm/api/explorer/_methods/explorerBlock.d.ts +@@ -1,4 +1,3 @@ +-/// + import * as v from "valibot"; + /** + * Subscription to explorer block events. +diff --git a/esm/api/explorer/_methods/explorerTxs.d.ts b/esm/api/explorer/_methods/explorerTxs.d.ts +index 3743bd669674cb55850a733947d574a5937493f5..9150fa216934921c090d947d75dca9e666baa7ad 100644 +--- a/esm/api/explorer/_methods/explorerTxs.d.ts ++++ b/esm/api/explorer/_methods/explorerTxs.d.ts +@@ -1,4 +1,3 @@ +-/// + import * as v from "valibot"; + import type { ExplorerTransaction } from "./_base/mod.js"; + /** +diff --git a/esm/api/explorer/_methods/txDetails.d.ts b/esm/api/explorer/_methods/txDetails.d.ts +index 82db80d03098c8ac383ee2c291607291ab14d349..17684e8c961bbb97022588a5691e1173486f78ef 100644 +--- a/esm/api/explorer/_methods/txDetails.d.ts ++++ b/esm/api/explorer/_methods/txDetails.d.ts +@@ -1,4 +1,3 @@ +-/// + import * as v from "valibot"; + import type { ExplorerTransaction } from "./_base/mod.js"; + /** +diff --git a/esm/api/explorer/_methods/userDetails.d.ts b/esm/api/explorer/_methods/userDetails.d.ts +index fc822660932b4974f74590780ade3e8332a546da..737b5aa2bbbae98de48de41a032b64fb86fc9950 100644 +--- a/esm/api/explorer/_methods/userDetails.d.ts ++++ b/esm/api/explorer/_methods/userDetails.d.ts +@@ -1,4 +1,3 @@ +-/// + import * as v from "valibot"; + import type { ExplorerTransaction } from "./_base/mod.js"; + /** +diff --git a/esm/api/explorer/client.d.ts b/esm/api/explorer/client.d.ts +index cfc7f401cbd756634cf8c03dfc23923e42ab6a0a..e4721d3041f4e61c33ee1f94e23512d01bf676c0 100644 +--- a/esm/api/explorer/client.d.ts ++++ b/esm/api/explorer/client.d.ts +@@ -2,7 +2,6 @@ + * Client for the Hyperliquid Explorer API endpoint. + * @module + */ +-/// + import type { IRequestTransport, ISubscription, ISubscriptionTransport, TransportError } from "../../transport/mod.js"; + import type { ExplorerConfig } from "./_methods/_base/mod.js"; + import { type BlockDetailsParameters, type BlockDetailsResponse } from "./_methods/blockDetails.js"; +diff --git a/esm/api/explorer/mod.d.ts b/esm/api/explorer/mod.d.ts +index 565bc71dd05dc9dc7518f53afecffffc8dc79e2d..62293c1570d4cb662927d1e5dbc773013269eb23 100644 +--- a/esm/api/explorer/mod.d.ts ++++ b/esm/api/explorer/mod.d.ts +@@ -35,7 +35,6 @@ + * + * @module + */ +-/// + export { ApiRequestError } from "./_methods/_base/mod.js"; + export type { ExplorerConfig } from "./_methods/_base/mod.js"; + export * from "./_methods/blockDetails.js"; +diff --git a/esm/api/info/_methods/_base/_config.d.ts b/esm/api/info/_methods/_base/_config.d.ts +index a9d5164795930ff7f5a19b134e6826008d3f7b51..16e0349f7e2ae886b9c70bfa7e9b2d6a0daea339 100644 +--- a/esm/api/info/_methods/_base/_config.d.ts ++++ b/esm/api/info/_methods/_base/_config.d.ts +@@ -2,7 +2,6 @@ + * Configuration types for Info API requests. + * @module + */ +-/// + import type { IRequestTransport } from "../../../../transport/mod.js"; + /** Configuration for Info API requests. */ + export interface InfoConfig { +diff --git a/esm/api/info/_methods/_base/_schemas.d.ts b/esm/api/info/_methods/_base/_schemas.d.ts +index cf2544bf46ecff96c65067ad23821b017129e466..53ecada3023372fa202f2ef4ffd53b2aa2534d93 100644 +--- a/esm/api/info/_methods/_base/_schemas.d.ts ++++ b/esm/api/info/_methods/_base/_schemas.d.ts +@@ -2,7 +2,6 @@ + * Common types shared across Info API methods. + * @module + */ +-/// + /** Perpetual asset context. */ + export type PerpAssetCtx = { + /** +diff --git a/esm/api/info/_methods/_base/mod.d.ts b/esm/api/info/_methods/_base/mod.d.ts +index c1c9eb3fa861aecbf6838c97b35c2e932d3e67e0..34e2f80b7366c84b4670590b8b74e235f68d3136 100644 +--- a/esm/api/info/_methods/_base/mod.d.ts ++++ b/esm/api/info/_methods/_base/mod.d.ts +@@ -2,6 +2,5 @@ + * Base infrastructure for Info API methods. + * @module + */ +-/// + export type { InfoConfig } from "./_config.js"; + export * from "./_schemas.js"; +diff --git a/esm/api/info/_methods/activeAssetData.d.ts b/esm/api/info/_methods/activeAssetData.d.ts +index e8641b662ff506c2ae1270f5a33ff11cb85f262d..bb2a4f77a3eb6f83f4269b1ddce6b4330932a4f0 100644 +--- a/esm/api/info/_methods/activeAssetData.d.ts ++++ b/esm/api/info/_methods/activeAssetData.d.ts +@@ -1,4 +1,3 @@ +-/// + import * as v from "valibot"; + /** + * Request user active asset data. +diff --git a/esm/api/info/_methods/allBorrowLendReserveStates.d.ts b/esm/api/info/_methods/allBorrowLendReserveStates.d.ts +index 85a1d7b9ca78a63cfac550e5a37f0f87664f1cee..946286fd174bff2691f295be82f04ea825fccf11 100644 +--- a/esm/api/info/_methods/allBorrowLendReserveStates.d.ts ++++ b/esm/api/info/_methods/allBorrowLendReserveStates.d.ts +@@ -1,4 +1,3 @@ +-/// + import * as v from "valibot"; + import type { BorrowLendReserveStateResponse } from "./borrowLendReserveState.js"; + /** +diff --git a/esm/api/info/_methods/allMids.d.ts b/esm/api/info/_methods/allMids.d.ts +index 1c2c2f5cd1dcc49e485c8e3e96c58a69af9a4ddf..fa0a8c83f44377a7307a2cb167cf681690dd6688 100644 +--- a/esm/api/info/_methods/allMids.d.ts ++++ b/esm/api/info/_methods/allMids.d.ts +@@ -1,4 +1,3 @@ +-/// + import * as v from "valibot"; + /** + * Request mid coin prices. +diff --git a/esm/api/info/_methods/allPerpMetas.d.ts b/esm/api/info/_methods/allPerpMetas.d.ts +index 95e05f59eed579811fe13003f31478b394c1e760..f342d572dc7184fe47b345cfa81b083aaac6f226 100644 +--- a/esm/api/info/_methods/allPerpMetas.d.ts ++++ b/esm/api/info/_methods/allPerpMetas.d.ts +@@ -1,4 +1,3 @@ +-/// + import * as v from "valibot"; + import type { MetaResponse } from "./meta.js"; + /** +diff --git a/esm/api/info/_methods/approvedBuilders.d.ts b/esm/api/info/_methods/approvedBuilders.d.ts +index 3786d63e9ecd6399b232c16e467253c408be3b8d..aa9839b8066016880549f2ebfadd1961c3cffb9c 100644 +--- a/esm/api/info/_methods/approvedBuilders.d.ts ++++ b/esm/api/info/_methods/approvedBuilders.d.ts +@@ -1,4 +1,3 @@ +-/// + import * as v from "valibot"; + /** + * Request approved builders for a user. +diff --git a/esm/api/info/_methods/borrowLendReserveState.d.ts b/esm/api/info/_methods/borrowLendReserveState.d.ts +index 5f519fc53e217d721d454035bf9fa3109d4b9db9..fbc48f2e5ae2d025699a1a9ed5e4e22f6f769afd 100644 +--- a/esm/api/info/_methods/borrowLendReserveState.d.ts ++++ b/esm/api/info/_methods/borrowLendReserveState.d.ts +@@ -1,4 +1,3 @@ +-/// + import * as v from "valibot"; + /** + * Request borrow/lend reserve states. +diff --git a/esm/api/info/_methods/borrowLendUserState.d.ts b/esm/api/info/_methods/borrowLendUserState.d.ts +index 3e2741ae3084c685825fe4f4b957d70ae8881ff3..88612df1f5676bc192297a4bb1781ee18e6211d3 100644 +--- a/esm/api/info/_methods/borrowLendUserState.d.ts ++++ b/esm/api/info/_methods/borrowLendUserState.d.ts +@@ -1,4 +1,3 @@ +-/// + import * as v from "valibot"; + /** + * Request borrow/lend user state. +diff --git a/esm/api/info/_methods/candleSnapshot.d.ts b/esm/api/info/_methods/candleSnapshot.d.ts +index 9c01f7d95f0fecc8de8d871d26b4d8f68651d928..65ac1f24b39d92228d033e967b201e127d378a4c 100644 +--- a/esm/api/info/_methods/candleSnapshot.d.ts ++++ b/esm/api/info/_methods/candleSnapshot.d.ts +@@ -1,4 +1,3 @@ +-/// + import * as v from "valibot"; + /** + * Request candlestick snapshots. +diff --git a/esm/api/info/_methods/clearinghouseState.d.ts b/esm/api/info/_methods/clearinghouseState.d.ts +index f6419b5885e13aa5ffcb79a46c4dd6846c7ebeb2..a4a3d77ca387fe74e5a35ff073f106e6f96a01ec 100644 +--- a/esm/api/info/_methods/clearinghouseState.d.ts ++++ b/esm/api/info/_methods/clearinghouseState.d.ts +@@ -1,4 +1,3 @@ +-/// + import * as v from "valibot"; + /** + * Request clearinghouse state. +diff --git a/esm/api/info/_methods/delegations.d.ts b/esm/api/info/_methods/delegations.d.ts +index 09d9db446ccc0c2ca7636f643fe949a9a9beb62a..0aeec5e5b90997232936a07b85059fc0a3611cb9 100644 +--- a/esm/api/info/_methods/delegations.d.ts ++++ b/esm/api/info/_methods/delegations.d.ts +@@ -1,4 +1,3 @@ +-/// + import * as v from "valibot"; + /** + * Request user staking delegations. +diff --git a/esm/api/info/_methods/delegatorHistory.d.ts b/esm/api/info/_methods/delegatorHistory.d.ts +index c563f032ac407fd4b758048fa0307c9de6f9b6e0..71057f95a840fc7cdd6976f08e02e46fd31dccdf 100644 +--- a/esm/api/info/_methods/delegatorHistory.d.ts ++++ b/esm/api/info/_methods/delegatorHistory.d.ts +@@ -1,4 +1,3 @@ +-/// + import * as v from "valibot"; + /** + * Request user staking history. +diff --git a/esm/api/info/_methods/delegatorRewards.d.ts b/esm/api/info/_methods/delegatorRewards.d.ts +index 2138dc2392b1dbb515ce19c32c30aee3b5bc97b2..f0594c4b87843fbd8f44338dbe8f53bbf72555b4 100644 +--- a/esm/api/info/_methods/delegatorRewards.d.ts ++++ b/esm/api/info/_methods/delegatorRewards.d.ts +@@ -1,4 +1,3 @@ +-/// + import * as v from "valibot"; + /** + * Request user staking rewards. +diff --git a/esm/api/info/_methods/delegatorSummary.d.ts b/esm/api/info/_methods/delegatorSummary.d.ts +index 47f86ea4425d45b69cdbf077e6c4da6262651ed4..cbaa697d973cf86744ddce18e1e873e37af6c189 100644 +--- a/esm/api/info/_methods/delegatorSummary.d.ts ++++ b/esm/api/info/_methods/delegatorSummary.d.ts +@@ -1,4 +1,3 @@ +-/// + import * as v from "valibot"; + /** + * Request user's staking summary. +diff --git a/esm/api/info/_methods/exchangeStatus.d.ts b/esm/api/info/_methods/exchangeStatus.d.ts +index f1d7fdb46d7434ec5ab37aae0416e366f4148a66..1761c6039cf308e801001433369bf2657ca83885 100644 +--- a/esm/api/info/_methods/exchangeStatus.d.ts ++++ b/esm/api/info/_methods/exchangeStatus.d.ts +@@ -1,4 +1,3 @@ +-/// + import * as v from "valibot"; + /** + * Request exchange system status information. +diff --git a/esm/api/info/_methods/extraAgents.d.ts b/esm/api/info/_methods/extraAgents.d.ts +index 45a4a3a2bea2ea6dd4993d269989f8ae9478f6d8..a2f95d1709e8f1fa6af94db6c9ec89945b4a21d0 100644 +--- a/esm/api/info/_methods/extraAgents.d.ts ++++ b/esm/api/info/_methods/extraAgents.d.ts +@@ -1,4 +1,3 @@ +-/// + import * as v from "valibot"; + /** + * Request user extra agents. +diff --git a/esm/api/info/_methods/frontendOpenOrders.d.ts b/esm/api/info/_methods/frontendOpenOrders.d.ts +index cd8e9135f53cfe642d32bb284e692f15eded64c2..f6893d490ecaa9111a8d5b313185d2ad3fb4d68a 100644 +--- a/esm/api/info/_methods/frontendOpenOrders.d.ts ++++ b/esm/api/info/_methods/frontendOpenOrders.d.ts +@@ -1,4 +1,3 @@ +-/// + import * as v from "valibot"; + import type { FrontendOpenOrder } from "./_base/mod.js"; + /** +diff --git a/esm/api/info/_methods/fundingHistory.d.ts b/esm/api/info/_methods/fundingHistory.d.ts +index ace684e180ecc2b42301b7df419fce46415821a2..e68d2524941ce93612da4262b97a89c9f0efcc43 100644 +--- a/esm/api/info/_methods/fundingHistory.d.ts ++++ b/esm/api/info/_methods/fundingHistory.d.ts +@@ -1,4 +1,3 @@ +-/// + import * as v from "valibot"; + /** + * Request funding history. +diff --git a/esm/api/info/_methods/gossipPriorityAuctionStatus.d.ts b/esm/api/info/_methods/gossipPriorityAuctionStatus.d.ts +index 89c3cae3a6768fc5046023993bac93d15b37b6e3..a1560fd80a3c1eb40e5b3a93ac2dbf4830b0c2f4 100644 +--- a/esm/api/info/_methods/gossipPriorityAuctionStatus.d.ts ++++ b/esm/api/info/_methods/gossipPriorityAuctionStatus.d.ts +@@ -1,4 +1,3 @@ +-/// + import * as v from "valibot"; + import type { PerpDeployAuctionStatusResponse } from "./perpDeployAuctionStatus.js"; + /** +diff --git a/esm/api/info/_methods/gossipRootIps.d.ts b/esm/api/info/_methods/gossipRootIps.d.ts +index 74dc979ca98d2887def774793014212a3de05710..0bed2586fdaa79abd997709a1c434bd4ebfd337e 100644 +--- a/esm/api/info/_methods/gossipRootIps.d.ts ++++ b/esm/api/info/_methods/gossipRootIps.d.ts +@@ -1,4 +1,3 @@ +-/// + import * as v from "valibot"; + /** + * Request gossip root IPs. +diff --git a/esm/api/info/_methods/historicalOrders.d.ts b/esm/api/info/_methods/historicalOrders.d.ts +index fdfb8c4db49bb48044301c2cc2c3d5c3f8820887..f98aab168da10bd18cd28e3350ad0fa6dd9e7ef6 100644 +--- a/esm/api/info/_methods/historicalOrders.d.ts ++++ b/esm/api/info/_methods/historicalOrders.d.ts +@@ -1,4 +1,3 @@ +-/// + import * as v from "valibot"; + import type { FrontendOpenOrder, OrderProcessingStatus } from "./_base/mod.js"; + /** +diff --git a/esm/api/info/_methods/isVip.d.ts b/esm/api/info/_methods/isVip.d.ts +index a43471dcb429fb15c4c1e739ae83c4a5a162cf6e..c0e47cb822aee85c3f334952a5d1c14499277324 100644 +--- a/esm/api/info/_methods/isVip.d.ts ++++ b/esm/api/info/_methods/isVip.d.ts +@@ -1,4 +1,3 @@ +-/// + import * as v from "valibot"; + /** + * Request to check if a user is a VIP. +diff --git a/esm/api/info/_methods/l2Book.d.ts b/esm/api/info/_methods/l2Book.d.ts +index 5fcb5408a96aee4164f892dae57067b240620b9e..8e80a8e7d68074ad2990ca6c3766ddf71b97fd8e 100644 +--- a/esm/api/info/_methods/l2Book.d.ts ++++ b/esm/api/info/_methods/l2Book.d.ts +@@ -1,4 +1,3 @@ +-/// + import * as v from "valibot"; + /** + * Request L2 order book. +diff --git a/esm/api/info/_methods/leadingVaults.d.ts b/esm/api/info/_methods/leadingVaults.d.ts +index af33187622fe843c7d00ffa1b3875c5b24d303ce..90b854742d54940d6a7e269a2d35e299d0847450 100644 +--- a/esm/api/info/_methods/leadingVaults.d.ts ++++ b/esm/api/info/_methods/leadingVaults.d.ts +@@ -1,4 +1,3 @@ +-/// + import * as v from "valibot"; + /** + * Request leading vaults for a user. +diff --git a/esm/api/info/_methods/legalCheck.d.ts b/esm/api/info/_methods/legalCheck.d.ts +index d8e70cbd72ad744dbbdffa8b257d1f3afd6be84f..27870dae9c1fba1d615d826ca1fab99fde95605b 100644 +--- a/esm/api/info/_methods/legalCheck.d.ts ++++ b/esm/api/info/_methods/legalCheck.d.ts +@@ -1,4 +1,3 @@ +-/// + import * as v from "valibot"; + /** + * Request legal verification status of a user. +diff --git a/esm/api/info/_methods/liquidatable.d.ts b/esm/api/info/_methods/liquidatable.d.ts +index 8490b48cdb8f9c7b26ae55740838676e7db5c3f2..f482dc34118e9a3dd20fd366dbe1128d0580b621 100644 +--- a/esm/api/info/_methods/liquidatable.d.ts ++++ b/esm/api/info/_methods/liquidatable.d.ts +@@ -1,4 +1,3 @@ +-/// + import * as v from "valibot"; + /** + * Request liquidatable. +diff --git a/esm/api/info/_methods/marginTable.d.ts b/esm/api/info/_methods/marginTable.d.ts +index 80dee89de81523b729808daf2b5293f716d2bda7..0146fee8956f7888f78a87e051652339c86d224d 100644 +--- a/esm/api/info/_methods/marginTable.d.ts ++++ b/esm/api/info/_methods/marginTable.d.ts +@@ -1,4 +1,3 @@ +-/// + import * as v from "valibot"; + /** + * Request margin table data. +diff --git a/esm/api/info/_methods/maxBuilderFee.d.ts b/esm/api/info/_methods/maxBuilderFee.d.ts +index 6699fa92ba0ad9bb1b7067269835776849929b61..1ff62e34d71c68353fd5ae8f85b7ec138c7e1d1d 100644 +--- a/esm/api/info/_methods/maxBuilderFee.d.ts ++++ b/esm/api/info/_methods/maxBuilderFee.d.ts +@@ -1,4 +1,3 @@ +-/// + import * as v from "valibot"; + /** + * Request builder fee approval. +diff --git a/esm/api/info/_methods/maxMarketOrderNtls.d.ts b/esm/api/info/_methods/maxMarketOrderNtls.d.ts +index 0148da82c7fbcb63c3df02c3c3735412232c85b6..ef4617875aa76f3a2eac56645db6dffc2a65cab5 100644 +--- a/esm/api/info/_methods/maxMarketOrderNtls.d.ts ++++ b/esm/api/info/_methods/maxMarketOrderNtls.d.ts +@@ -1,4 +1,3 @@ +-/// + import * as v from "valibot"; + /** + * Request maximum market order notionals. +diff --git a/esm/api/info/_methods/meta.d.ts b/esm/api/info/_methods/meta.d.ts +index 6a83d9d9aab1f022902e5ea1e370feee519a8061..6c2728d4e651ce98829d159a47eae4b7833d2005 100644 +--- a/esm/api/info/_methods/meta.d.ts ++++ b/esm/api/info/_methods/meta.d.ts +@@ -1,4 +1,3 @@ +-/// + import * as v from "valibot"; + import type { MarginTableResponse } from "./marginTable.js"; + /** +diff --git a/esm/api/info/_methods/metaAndAssetCtxs.d.ts b/esm/api/info/_methods/metaAndAssetCtxs.d.ts +index d7347b7e46d79870322f9488742dba1ddfe21dda..0e89665eb73271c7c9b81d86cad8eaa15d0e2f58 100644 +--- a/esm/api/info/_methods/metaAndAssetCtxs.d.ts ++++ b/esm/api/info/_methods/metaAndAssetCtxs.d.ts +@@ -1,4 +1,3 @@ +-/// + import * as v from "valibot"; + import type { PerpAssetCtx } from "./_base/mod.js"; + import type { MetaResponse } from "./meta.js"; +diff --git a/esm/api/info/_methods/openOrders.d.ts b/esm/api/info/_methods/openOrders.d.ts +index 9e1ae639097d4ca92480d9a5bf5e9196c02d4bca..df15dd043ee52d6158cf697112111d9cbc0346d5 100644 +--- a/esm/api/info/_methods/openOrders.d.ts ++++ b/esm/api/info/_methods/openOrders.d.ts +@@ -1,4 +1,3 @@ +-/// + import * as v from "valibot"; + import type { OpenOrder } from "./_base/mod.js"; + /** +diff --git a/esm/api/info/_methods/orderStatus.d.ts b/esm/api/info/_methods/orderStatus.d.ts +index 94c776c619f1998a41f6f47ef98c967e53b08992..b1367303d61310b362911c7ed1b8895b64ef0566 100644 +--- a/esm/api/info/_methods/orderStatus.d.ts ++++ b/esm/api/info/_methods/orderStatus.d.ts +@@ -1,4 +1,3 @@ +-/// + import * as v from "valibot"; + import type { FrontendOpenOrder, OrderProcessingStatus } from "./_base/mod.js"; + /** +diff --git a/esm/api/info/_methods/outcomeMeta.d.ts b/esm/api/info/_methods/outcomeMeta.d.ts +index 60931cfbf66dc149303e9dc16cdd11fdedc07819..703899248c52dd18f119d1848f0eeae8360c2d27 100644 +--- a/esm/api/info/_methods/outcomeMeta.d.ts ++++ b/esm/api/info/_methods/outcomeMeta.d.ts +@@ -1,4 +1,3 @@ +-/// + import * as v from "valibot"; + /** + * Request prediction market outcome metadata. +diff --git a/esm/api/info/_methods/perpAnnotation.d.ts b/esm/api/info/_methods/perpAnnotation.d.ts +index d95ef497809a272a482c2df5b2b5c60cdfc31f2d..bd91a3aa48ca0819ec642505bdad159bb21856c5 100644 +--- a/esm/api/info/_methods/perpAnnotation.d.ts ++++ b/esm/api/info/_methods/perpAnnotation.d.ts +@@ -1,4 +1,3 @@ +-/// + import * as v from "valibot"; + /** + * Request perp annotation. +diff --git a/esm/api/info/_methods/perpCategories.d.ts b/esm/api/info/_methods/perpCategories.d.ts +index 4e9234031a48c0ad3c1013fb2c9d5fd2bb83836d..3185c67543c2bd5c1f4b1791de26f9f4b65e4c5c 100644 +--- a/esm/api/info/_methods/perpCategories.d.ts ++++ b/esm/api/info/_methods/perpCategories.d.ts +@@ -1,4 +1,3 @@ +-/// + import * as v from "valibot"; + /** + * Request all perpetual asset categories. +diff --git a/esm/api/info/_methods/perpConciseAnnotations.d.ts b/esm/api/info/_methods/perpConciseAnnotations.d.ts +index 97b0e58fb1f6fdff4ded65863d7fcb3ec454bc5e..77e433413efd8131401cb9601118752d8dd764f2 100644 +--- a/esm/api/info/_methods/perpConciseAnnotations.d.ts ++++ b/esm/api/info/_methods/perpConciseAnnotations.d.ts +@@ -1,4 +1,3 @@ +-/// + import * as v from "valibot"; + /** + * Request concise annotations for all perpetual assets. +diff --git a/esm/api/info/_methods/perpDeployAuctionStatus.d.ts b/esm/api/info/_methods/perpDeployAuctionStatus.d.ts +index b6bf354aabe29e2f21b74c2e78b3a5f2a108cc08..f9b37247d0c71b4c8128114c4c8c0ca94621baac 100644 +--- a/esm/api/info/_methods/perpDeployAuctionStatus.d.ts ++++ b/esm/api/info/_methods/perpDeployAuctionStatus.d.ts +@@ -1,4 +1,3 @@ +-/// + import * as v from "valibot"; + /** + * Request for the status of the perpetual deploy auction. +diff --git a/esm/api/info/_methods/perpDexLimits.d.ts b/esm/api/info/_methods/perpDexLimits.d.ts +index 33539fadc8969214f7e824a3516049c440040f7c..d3ef0d660c86036f9486b00dbbf2a5908761370a 100644 +--- a/esm/api/info/_methods/perpDexLimits.d.ts ++++ b/esm/api/info/_methods/perpDexLimits.d.ts +@@ -1,4 +1,3 @@ +-/// + import * as v from "valibot"; + /** + * Request builder deployed perpetual market limits. +diff --git a/esm/api/info/_methods/perpDexStatus.d.ts b/esm/api/info/_methods/perpDexStatus.d.ts +index e8038edb476c71024e4c6089aaa43f6cbbfa9c2d..4e3bfc5b215f216658ab800873f50ddc36a7553c 100644 +--- a/esm/api/info/_methods/perpDexStatus.d.ts ++++ b/esm/api/info/_methods/perpDexStatus.d.ts +@@ -1,4 +1,3 @@ +-/// + import * as v from "valibot"; + /** + * Request perp DEX status. +diff --git a/esm/api/info/_methods/perpDexs.d.ts b/esm/api/info/_methods/perpDexs.d.ts +index 3a221e1793b2aa92a0362cb17afdb1d2bcfa2996..21a010c8cc390662182017bd28b503fc12d35fad 100644 +--- a/esm/api/info/_methods/perpDexs.d.ts ++++ b/esm/api/info/_methods/perpDexs.d.ts +@@ -1,4 +1,3 @@ +-/// + import * as v from "valibot"; + /** + * Request all perpetual dexs. +diff --git a/esm/api/info/_methods/perpsAtOpenInterestCap.d.ts b/esm/api/info/_methods/perpsAtOpenInterestCap.d.ts +index b1a1ea8d8ed24e84f27b7e3844642c7e3f95d435..e5d86d8bcb1df9af1b4086be2dc17ab2f1addc09 100644 +--- a/esm/api/info/_methods/perpsAtOpenInterestCap.d.ts ++++ b/esm/api/info/_methods/perpsAtOpenInterestCap.d.ts +@@ -1,4 +1,3 @@ +-/// + import * as v from "valibot"; + /** + * Request perpetuals at open interest cap. +diff --git a/esm/api/info/_methods/portfolio.d.ts b/esm/api/info/_methods/portfolio.d.ts +index acc48c6234e37adab4a234ec4b47b93b8a63b73b..f9d0e7d75995ff871e5ac11933e36ae0cceec963 100644 +--- a/esm/api/info/_methods/portfolio.d.ts ++++ b/esm/api/info/_methods/portfolio.d.ts +@@ -1,4 +1,3 @@ +-/// + import * as v from "valibot"; + /** + * Request user portfolio. +diff --git a/esm/api/info/_methods/preTransferCheck.d.ts b/esm/api/info/_methods/preTransferCheck.d.ts +index e6ffdefdfc45539b812e3883bf2a0e29e35cf466..02b05fc2bd8486435aed153165f41f84faca592c 100644 +--- a/esm/api/info/_methods/preTransferCheck.d.ts ++++ b/esm/api/info/_methods/preTransferCheck.d.ts +@@ -1,4 +1,3 @@ +-/// + import * as v from "valibot"; + /** + * Request user existence check before transfer. +diff --git a/esm/api/info/_methods/predictedFundings.d.ts b/esm/api/info/_methods/predictedFundings.d.ts +index 965fe091d4790d10e03cbcf882aecc998d80ba06..4e03841bd556c52fb963592f3b1f51cc9b832dcf 100644 +--- a/esm/api/info/_methods/predictedFundings.d.ts ++++ b/esm/api/info/_methods/predictedFundings.d.ts +@@ -1,4 +1,3 @@ +-/// + import * as v from "valibot"; + /** + * Request predicted funding rates. +diff --git a/esm/api/info/_methods/recentTrades.d.ts b/esm/api/info/_methods/recentTrades.d.ts +index a731f6d8585d248415cb1aec94b15276411710b0..c023bb575c67ffa6858415716fb0cad3b017b451 100644 +--- a/esm/api/info/_methods/recentTrades.d.ts ++++ b/esm/api/info/_methods/recentTrades.d.ts +@@ -1,4 +1,3 @@ +-/// + import * as v from "valibot"; + /** + * Request recent trades. +diff --git a/esm/api/info/_methods/referral.d.ts b/esm/api/info/_methods/referral.d.ts +index 8e27a50e06995200b98e9c4dbed24a47b736399e..b7fc97ec95fd1cb9b04c18be5f467fc022c44859 100644 +--- a/esm/api/info/_methods/referral.d.ts ++++ b/esm/api/info/_methods/referral.d.ts +@@ -1,4 +1,3 @@ +-/// + import * as v from "valibot"; + /** + * Request user referral. +diff --git a/esm/api/info/_methods/settledOutcome.d.ts b/esm/api/info/_methods/settledOutcome.d.ts +index c19341b47958a3260a4c04aa594222bd7c1960cd..f0ce65d025f6a3c782c2f661ed479abb52820e25 100644 +--- a/esm/api/info/_methods/settledOutcome.d.ts ++++ b/esm/api/info/_methods/settledOutcome.d.ts +@@ -1,4 +1,3 @@ +-/// + import * as v from "valibot"; + /** + * Request information about a settled outcome. +diff --git a/esm/api/info/_methods/spotClearinghouseState.d.ts b/esm/api/info/_methods/spotClearinghouseState.d.ts +index 532265df2c2cf4b753460173558207910c6ab46b..8d281c1ef5c3a1a7605a72e9205d809b300185de 100644 +--- a/esm/api/info/_methods/spotClearinghouseState.d.ts ++++ b/esm/api/info/_methods/spotClearinghouseState.d.ts +@@ -1,4 +1,3 @@ +-/// + import * as v from "valibot"; + /** + * Request spot clearinghouse state. +diff --git a/esm/api/info/_methods/spotDeployState.d.ts b/esm/api/info/_methods/spotDeployState.d.ts +index 525568d53b895f4ec2559f0d79cb7917ca7d959b..18674f05451c063a4ea125a41f7bce6422ac6a32 100644 +--- a/esm/api/info/_methods/spotDeployState.d.ts ++++ b/esm/api/info/_methods/spotDeployState.d.ts +@@ -1,4 +1,3 @@ +-/// + import * as v from "valibot"; + import type { SpotPairDeployAuctionStatusResponse } from "./spotPairDeployAuctionStatus.js"; + /** +diff --git a/esm/api/info/_methods/spotMeta.d.ts b/esm/api/info/_methods/spotMeta.d.ts +index 194227822a7372374fec62ba8594c2d502db8f55..d5593e66b8a8ce1733da21ad4f2a932498639ceb 100644 +--- a/esm/api/info/_methods/spotMeta.d.ts ++++ b/esm/api/info/_methods/spotMeta.d.ts +@@ -1,4 +1,3 @@ +-/// + import * as v from "valibot"; + /** + * Request spot trading metadata. +diff --git a/esm/api/info/_methods/spotMetaAndAssetCtxs.d.ts b/esm/api/info/_methods/spotMetaAndAssetCtxs.d.ts +index a7db897d084ee80fe468832986942140c26d6c72..f0ad6d59055cd800edb6dad98159b1e74e3937ae 100644 +--- a/esm/api/info/_methods/spotMetaAndAssetCtxs.d.ts ++++ b/esm/api/info/_methods/spotMetaAndAssetCtxs.d.ts +@@ -1,4 +1,3 @@ +-/// + import * as v from "valibot"; + import type { SpotAssetCtx } from "./_base/mod.js"; + import type { SpotMetaResponse } from "./spotMeta.js"; +diff --git a/esm/api/info/_methods/spotPairDeployAuctionStatus.d.ts b/esm/api/info/_methods/spotPairDeployAuctionStatus.d.ts +index 8ca88e761daf7ad2e61cb98c8a0c444f8747430e..3251ecbc5302e200ffc6ecf2bf298ff252fd68bf 100644 +--- a/esm/api/info/_methods/spotPairDeployAuctionStatus.d.ts ++++ b/esm/api/info/_methods/spotPairDeployAuctionStatus.d.ts +@@ -1,4 +1,3 @@ +-/// + import * as v from "valibot"; + import type { PerpDeployAuctionStatusResponse } from "./perpDeployAuctionStatus.js"; + /** +diff --git a/esm/api/info/_methods/subAccounts.d.ts b/esm/api/info/_methods/subAccounts.d.ts +index 0b889c737b17c07f65607590a34e8cf17ad11f8f..29b0abb3584c15d2d03ca5497f6caeb1ebb815a0 100644 +--- a/esm/api/info/_methods/subAccounts.d.ts ++++ b/esm/api/info/_methods/subAccounts.d.ts +@@ -1,4 +1,3 @@ +-/// + import * as v from "valibot"; + import type { ClearinghouseStateResponse } from "./clearinghouseState.js"; + import type { SpotClearinghouseStateResponse } from "./spotClearinghouseState.js"; +diff --git a/esm/api/info/_methods/subAccounts2.d.ts b/esm/api/info/_methods/subAccounts2.d.ts +index 30e14650cdd6fb9cc1bc83ae7890a9512000ee19..60c96ba623bc6c57ab487b66b91a63ac85840124 100644 +--- a/esm/api/info/_methods/subAccounts2.d.ts ++++ b/esm/api/info/_methods/subAccounts2.d.ts +@@ -1,4 +1,3 @@ +-/// + import * as v from "valibot"; + import type { ClearinghouseStateResponse } from "./clearinghouseState.js"; + import type { SpotClearinghouseStateResponse } from "./spotClearinghouseState.js"; +diff --git a/esm/api/info/_methods/tokenDetails.d.ts b/esm/api/info/_methods/tokenDetails.d.ts +index 12400c0f14cbeb17e18bb05b7789dfaa01aa32dc..ab8f18afb1b9e7e7a9ba6f62fecc5f4ef0952b41 100644 +--- a/esm/api/info/_methods/tokenDetails.d.ts ++++ b/esm/api/info/_methods/tokenDetails.d.ts +@@ -1,4 +1,3 @@ +-/// + import * as v from "valibot"; + /** + * Request token details. +diff --git a/esm/api/info/_methods/twapHistory.d.ts b/esm/api/info/_methods/twapHistory.d.ts +index f2e5f16479e0977f49b3bd4eedd484aa0d88a034..a45d2e343c85cc3233539d51c53905be44625ab7 100644 +--- a/esm/api/info/_methods/twapHistory.d.ts ++++ b/esm/api/info/_methods/twapHistory.d.ts +@@ -1,4 +1,3 @@ +-/// + import * as v from "valibot"; + import type { TwapState } from "./_base/mod.js"; + /** +diff --git a/esm/api/info/_methods/userAbstraction.d.ts b/esm/api/info/_methods/userAbstraction.d.ts +index 464b87731e5d1b865b755ab3aade28b3367e2b6b..30ad1c8c18fd06df8e79c778e9cf37c67f0dc522 100644 +--- a/esm/api/info/_methods/userAbstraction.d.ts ++++ b/esm/api/info/_methods/userAbstraction.d.ts +@@ -1,4 +1,3 @@ +-/// + import * as v from "valibot"; + /** + * Request user abstraction state. +diff --git a/esm/api/info/_methods/userBorrowLendInterest.d.ts b/esm/api/info/_methods/userBorrowLendInterest.d.ts +index ce42daaf08111066446ceb96195c91876d81b113..ce25918540d3f9e3e511b6cd5f64632866440a18 100644 +--- a/esm/api/info/_methods/userBorrowLendInterest.d.ts ++++ b/esm/api/info/_methods/userBorrowLendInterest.d.ts +@@ -1,4 +1,3 @@ +-/// + import * as v from "valibot"; + /** + * Request user borrow/lend interest. +diff --git a/esm/api/info/_methods/userDexAbstraction.d.ts b/esm/api/info/_methods/userDexAbstraction.d.ts +index d1da93d121ba6b65fb1c93a70c57545afb743ec8..d43eb5f6cbec301bd8a92c829b1191a1a5f279e8 100644 +--- a/esm/api/info/_methods/userDexAbstraction.d.ts ++++ b/esm/api/info/_methods/userDexAbstraction.d.ts +@@ -1,4 +1,3 @@ +-/// + import * as v from "valibot"; + /** + * Request user HIP-3 DEX abstraction state. +diff --git a/esm/api/info/_methods/userFees.d.ts b/esm/api/info/_methods/userFees.d.ts +index 80ad1013ce239f017942771b3aae7dea998263b3..9c58922f8548488735831154f22899f91998b2ab 100644 +--- a/esm/api/info/_methods/userFees.d.ts ++++ b/esm/api/info/_methods/userFees.d.ts +@@ -1,4 +1,3 @@ +-/// + import * as v from "valibot"; + /** + * Request user fees. +diff --git a/esm/api/info/_methods/userFills.d.ts b/esm/api/info/_methods/userFills.d.ts +index dd93ec37365a6b80dc4c7d3f05fc984869cf2914..9f103547f99509a477e540dcfc33cfd98accc0f4 100644 +--- a/esm/api/info/_methods/userFills.d.ts ++++ b/esm/api/info/_methods/userFills.d.ts +@@ -1,4 +1,3 @@ +-/// + import * as v from "valibot"; + import type { UserFill } from "./_base/mod.js"; + /** +diff --git a/esm/api/info/_methods/userFillsByTime.d.ts b/esm/api/info/_methods/userFillsByTime.d.ts +index 85859bbbe13ca2f5f354574ca0282f6f78dde914..cf8e20bb09615d606eb849c16c9e727c266a2138 100644 +--- a/esm/api/info/_methods/userFillsByTime.d.ts ++++ b/esm/api/info/_methods/userFillsByTime.d.ts +@@ -1,4 +1,3 @@ +-/// + import * as v from "valibot"; + import type { UserFillsResponse } from "./userFills.js"; + /** +diff --git a/esm/api/info/_methods/userFunding.d.ts b/esm/api/info/_methods/userFunding.d.ts +index bd8e5c097e6394b5dc40b4bcb0d43544ca645dff..d94eae14fb5ebee115abca05723199aa0611f8bd 100644 +--- a/esm/api/info/_methods/userFunding.d.ts ++++ b/esm/api/info/_methods/userFunding.d.ts +@@ -1,4 +1,3 @@ +-/// + import * as v from "valibot"; + /** + * Request array of user funding ledger updates. +diff --git a/esm/api/info/_methods/userNonFundingLedgerUpdates.d.ts b/esm/api/info/_methods/userNonFundingLedgerUpdates.d.ts +index be419f55025f7a7900c03a5388c02469daf9fc33..b2d865b9bf4eb56151556315013ff733797ad432 100644 +--- a/esm/api/info/_methods/userNonFundingLedgerUpdates.d.ts ++++ b/esm/api/info/_methods/userNonFundingLedgerUpdates.d.ts +@@ -1,4 +1,3 @@ +-/// + import * as v from "valibot"; + /** + * Request user non-funding ledger updates. +diff --git a/esm/api/info/_methods/userRateLimit.d.ts b/esm/api/info/_methods/userRateLimit.d.ts +index 3d9de2372ff4f33dd1039516256a6cac756c184e..b269c95fd67c2d5cd0a03b4055ab8d3ca01468ac 100644 +--- a/esm/api/info/_methods/userRateLimit.d.ts ++++ b/esm/api/info/_methods/userRateLimit.d.ts +@@ -1,4 +1,3 @@ +-/// + import * as v from "valibot"; + /** + * Request user rate limits. +diff --git a/esm/api/info/_methods/userRole.d.ts b/esm/api/info/_methods/userRole.d.ts +index 7bfaed3475dbf4a637fdec8c3601dff07de4b8f4..ee5b0af08edabaf81b3cd594337a28e867e69edd 100644 +--- a/esm/api/info/_methods/userRole.d.ts ++++ b/esm/api/info/_methods/userRole.d.ts +@@ -1,4 +1,3 @@ +-/// + import * as v from "valibot"; + /** + * Request user role. +diff --git a/esm/api/info/_methods/userToMultiSigSigners.d.ts b/esm/api/info/_methods/userToMultiSigSigners.d.ts +index 9043f2155204564dfbee43cf61ec3c6afd5951f9..e4fbbbcd3ac96f02ec85b73fecfbe82f6c22c909 100644 +--- a/esm/api/info/_methods/userToMultiSigSigners.d.ts ++++ b/esm/api/info/_methods/userToMultiSigSigners.d.ts +@@ -1,4 +1,3 @@ +-/// + import * as v from "valibot"; + /** + * Request multi-sig signers for a user. +diff --git a/esm/api/info/_methods/userTwapSliceFills.d.ts b/esm/api/info/_methods/userTwapSliceFills.d.ts +index 3598d3a6b8d56b7b6181071ca6ed2ddb83c17083..f59fd6bbf5b864cebb654e87e5fcc95a0814ec1b 100644 +--- a/esm/api/info/_methods/userTwapSliceFills.d.ts ++++ b/esm/api/info/_methods/userTwapSliceFills.d.ts +@@ -1,4 +1,3 @@ +-/// + import * as v from "valibot"; + import type { UserFill } from "./_base/mod.js"; + /** +diff --git a/esm/api/info/_methods/userTwapSliceFillsByTime.d.ts b/esm/api/info/_methods/userTwapSliceFillsByTime.d.ts +index 55af349895a15af65fa0fbba798989be25cf8c06..b79073e9088bbaa8337441aa54c81a8b370eea7f 100644 +--- a/esm/api/info/_methods/userTwapSliceFillsByTime.d.ts ++++ b/esm/api/info/_methods/userTwapSliceFillsByTime.d.ts +@@ -1,4 +1,3 @@ +-/// + import * as v from "valibot"; + import type { UserTwapSliceFillsResponse } from "./userTwapSliceFills.js"; + /** +diff --git a/esm/api/info/_methods/userVaultEquities.d.ts b/esm/api/info/_methods/userVaultEquities.d.ts +index 6f18972ed899b34b523e668b643ae617fe57ab58..f95bfe5ae88ce5123e45994a2fe240f38c903b0f 100644 +--- a/esm/api/info/_methods/userVaultEquities.d.ts ++++ b/esm/api/info/_methods/userVaultEquities.d.ts +@@ -1,4 +1,3 @@ +-/// + import * as v from "valibot"; + /** + * Request user vault deposits. +diff --git a/esm/api/info/_methods/validatorL1Votes.d.ts b/esm/api/info/_methods/validatorL1Votes.d.ts +index 0813b854c96a92bc1e84c2fb02fd2e8ec4cc322f..c0d681301e197dd2b39e43e673abe5508146f995 100644 +--- a/esm/api/info/_methods/validatorL1Votes.d.ts ++++ b/esm/api/info/_methods/validatorL1Votes.d.ts +@@ -1,4 +1,3 @@ +-/// + import * as v from "valibot"; + /** + * Request validator L1 votes. +diff --git a/esm/api/info/_methods/validatorSummaries.d.ts b/esm/api/info/_methods/validatorSummaries.d.ts +index 1891e1d6578b4d439f10f63b47981721012417bc..f8855bfd9d6c4a477e51092bf431e52da883953a 100644 +--- a/esm/api/info/_methods/validatorSummaries.d.ts ++++ b/esm/api/info/_methods/validatorSummaries.d.ts +@@ -1,4 +1,3 @@ +-/// + import * as v from "valibot"; + /** + * Request validator summaries. +diff --git a/esm/api/info/_methods/vaultDetails.d.ts b/esm/api/info/_methods/vaultDetails.d.ts +index de297fac3ee139b2a2d29238e9db692664e1ff72..6b21d79041a0dea25685aa4c977dff90b6ed590e 100644 +--- a/esm/api/info/_methods/vaultDetails.d.ts ++++ b/esm/api/info/_methods/vaultDetails.d.ts +@@ -1,4 +1,3 @@ +-/// + import * as v from "valibot"; + import type { VaultRelationship } from "./_base/mod.js"; + import type { PortfolioResponse } from "./portfolio.js"; +diff --git a/esm/api/info/_methods/vaultSummaries.d.ts b/esm/api/info/_methods/vaultSummaries.d.ts +index 004a57d25dc426e1a5a57a0bc3c00c10e226a0dd..1a3110969f53152b2e3333b0fe845d4f11533901 100644 +--- a/esm/api/info/_methods/vaultSummaries.d.ts ++++ b/esm/api/info/_methods/vaultSummaries.d.ts +@@ -1,4 +1,3 @@ +-/// + import * as v from "valibot"; + import type { VaultRelationship } from "./_base/mod.js"; + /** +diff --git a/esm/api/info/_methods/webData2.d.ts b/esm/api/info/_methods/webData2.d.ts +index 6bb3749c843b139bbb6361949cc0fcc049103b7a..eba2c49cbbc7036f5b62c0fb114f83e3fe1784c9 100644 +--- a/esm/api/info/_methods/webData2.d.ts ++++ b/esm/api/info/_methods/webData2.d.ts +@@ -1,4 +1,3 @@ +-/// + import * as v from "valibot"; + import type { PerpAssetCtx, SpotAssetCtx, TwapState } from "./_base/mod.js"; + import type { ClearinghouseStateResponse } from "./clearinghouseState.js"; +diff --git a/esm/api/info/client.d.ts b/esm/api/info/client.d.ts +index b01af37d047948a0efaf21df5e9a3395c0926eb5..494a964ef5c540bdc82e87d0efdea77c2dce0c97 100644 +--- a/esm/api/info/client.d.ts ++++ b/esm/api/info/client.d.ts +@@ -2,7 +2,6 @@ + * Client for the Hyperliquid Info API endpoint. + * @module + */ +-/// + import type { InfoConfig } from "./_methods/_base/mod.js"; + import { type ActiveAssetDataParameters, type ActiveAssetDataResponse } from "./_methods/activeAssetData.js"; + import { type AllBorrowLendReserveStatesResponse } from "./_methods/allBorrowLendReserveStates.js"; +diff --git a/esm/api/info/mod.d.ts b/esm/api/info/mod.d.ts +index 8bce472078bac0a255626df453b215b9fca64d1e..263875b2a97173b1218d2bb7a7dde7127575a658 100644 +--- a/esm/api/info/mod.d.ts ++++ b/esm/api/info/mod.d.ts +@@ -21,7 +21,6 @@ + * + * @module + */ +-/// + export type { InfoConfig } from "./_methods/_base/mod.js"; + export * from "./_methods/activeAssetData.js"; + export * from "./_methods/allBorrowLendReserveStates.js"; +diff --git a/esm/api/subscription/_methods/_base/_config.d.ts b/esm/api/subscription/_methods/_base/_config.d.ts +index e0f689e51acfde57ee32f7b7943995edb1dee1c9..d647fc18f36ab7b47e38a39482759a26cec3131b 100644 +--- a/esm/api/subscription/_methods/_base/_config.d.ts ++++ b/esm/api/subscription/_methods/_base/_config.d.ts +@@ -2,7 +2,6 @@ + * Configuration and option types for Subscription API methods. + * @module + */ +-/// + import type { ISubscriptionTransport, TransportError } from "../../../../transport/mod.js"; + /** Configuration for subscription API requests. */ + export interface SubscriptionConfig { +diff --git a/esm/api/subscription/_methods/_base/mod.d.ts b/esm/api/subscription/_methods/_base/mod.d.ts +index d2e2ff0ba7f9d884fecf45843e6322267e143310..ab400a5eacc74e4c649953a877a6dfd725a076e0 100644 +--- a/esm/api/subscription/_methods/_base/mod.d.ts ++++ b/esm/api/subscription/_methods/_base/mod.d.ts +@@ -2,5 +2,4 @@ + * Base infrastructure for Subscription API methods. + * @module + */ +-/// + export type { SubscriptionConfig, SubscriptionOptions } from "./_config.js"; +diff --git a/esm/api/subscription/_methods/activeAssetCtx.d.ts b/esm/api/subscription/_methods/activeAssetCtx.d.ts +index 9b263d4a53f3a0d868db620293ee7bc3df35c5c9..8335083b8e6754e0702eb4a9bc0973e7e0af0703 100644 +--- a/esm/api/subscription/_methods/activeAssetCtx.d.ts ++++ b/esm/api/subscription/_methods/activeAssetCtx.d.ts +@@ -1,4 +1,3 @@ +-/// + import * as v from "valibot"; + import type { PerpAssetCtx } from "../../info/_methods/_base/mod.js"; + /** +diff --git a/esm/api/subscription/_methods/activeAssetData.d.ts b/esm/api/subscription/_methods/activeAssetData.d.ts +index 43c36b423a0b1a6098fd303df7ca791dbd84c9d2..8e193126665efca75759805cb75f412c11651cda 100644 +--- a/esm/api/subscription/_methods/activeAssetData.d.ts ++++ b/esm/api/subscription/_methods/activeAssetData.d.ts +@@ -1,4 +1,3 @@ +-/// + import * as v from "valibot"; + import type { ActiveAssetDataResponse } from "../../info/_methods/activeAssetData.js"; + /** +diff --git a/esm/api/subscription/_methods/activeSpotAssetCtx.d.ts b/esm/api/subscription/_methods/activeSpotAssetCtx.d.ts +index c40d3ded731c70fd9ad127af36b0f2767f8c70db..141d931a722f3af9ed3351de1b87a9470282fe7f 100644 +--- a/esm/api/subscription/_methods/activeSpotAssetCtx.d.ts ++++ b/esm/api/subscription/_methods/activeSpotAssetCtx.d.ts +@@ -1,4 +1,3 @@ +-/// + import * as v from "valibot"; + import type { SpotAssetCtx } from "../../info/_methods/_base/mod.js"; + /** +diff --git a/esm/api/subscription/_methods/allDexsAssetCtxs.d.ts b/esm/api/subscription/_methods/allDexsAssetCtxs.d.ts +index 3c1fa57546a8d1d9cf63e92dbfb92e61a576dbac..badd9ef1d99b2843e524f8d96997cbeedc1f2a62 100644 +--- a/esm/api/subscription/_methods/allDexsAssetCtxs.d.ts ++++ b/esm/api/subscription/_methods/allDexsAssetCtxs.d.ts +@@ -1,4 +1,3 @@ +-/// + import * as v from "valibot"; + import type { PerpAssetCtx } from "../../info/_methods/_base/mod.js"; + /** +diff --git a/esm/api/subscription/_methods/allDexsClearinghouseState.d.ts b/esm/api/subscription/_methods/allDexsClearinghouseState.d.ts +index fffcd336734a13e3d6ea060f6e76fd21b69fd147..feddb838b757b30f6c189edd52d35af445826e15 100644 +--- a/esm/api/subscription/_methods/allDexsClearinghouseState.d.ts ++++ b/esm/api/subscription/_methods/allDexsClearinghouseState.d.ts +@@ -1,4 +1,3 @@ +-/// + import * as v from "valibot"; + import type { ClearinghouseStateResponse } from "../../info/_methods/clearinghouseState.js"; + /** +diff --git a/esm/api/subscription/_methods/allMids.d.ts b/esm/api/subscription/_methods/allMids.d.ts +index 654afafb74858aaf501ade1a7c3fd860a3207796..a93ca551676a18a4b847b705ad82bddecbc98e7b 100644 +--- a/esm/api/subscription/_methods/allMids.d.ts ++++ b/esm/api/subscription/_methods/allMids.d.ts +@@ -1,4 +1,3 @@ +-/// + import * as v from "valibot"; + import type { AllMidsResponse } from "../../info/_methods/allMids.js"; + /** +diff --git a/esm/api/subscription/_methods/assetCtxs.d.ts b/esm/api/subscription/_methods/assetCtxs.d.ts +index 205b75023f2548cac2ac4241929c77a349565a15..1e4019012ac78d1f3245a2c01aabbcb477e2774c 100644 +--- a/esm/api/subscription/_methods/assetCtxs.d.ts ++++ b/esm/api/subscription/_methods/assetCtxs.d.ts +@@ -1,4 +1,3 @@ +-/// + import * as v from "valibot"; + import type { PerpAssetCtx } from "../../info/_methods/_base/mod.js"; + /** +diff --git a/esm/api/subscription/_methods/bbo.d.ts b/esm/api/subscription/_methods/bbo.d.ts +index 4a844634b33390f07437a3bf3c6ed4f784382107..32ec470e49bb3649fc677e9e7fcac40b216c5de1 100644 +--- a/esm/api/subscription/_methods/bbo.d.ts ++++ b/esm/api/subscription/_methods/bbo.d.ts +@@ -1,4 +1,3 @@ +-/// + import * as v from "valibot"; + /** + * Subscription to best bid and offer events for a specific asset. +diff --git a/esm/api/subscription/_methods/candle.d.ts b/esm/api/subscription/_methods/candle.d.ts +index 99b855b77e6cc1f862a09595c1cfefcc4cd7e0fb..7718ddccffd59483687bd2212492e59f4524709c 100644 +--- a/esm/api/subscription/_methods/candle.d.ts ++++ b/esm/api/subscription/_methods/candle.d.ts +@@ -1,4 +1,3 @@ +-/// + import * as v from "valibot"; + /** + * Subscription to candlestick events for a specific asset and time interval. +diff --git a/esm/api/subscription/_methods/clearinghouseState.d.ts b/esm/api/subscription/_methods/clearinghouseState.d.ts +index 9ccbfc8d4aab09a99f82af6c5b4149845083ff57..996e314cefdf22af8ac7304a4a6c68ad8f7d8544 100644 +--- a/esm/api/subscription/_methods/clearinghouseState.d.ts ++++ b/esm/api/subscription/_methods/clearinghouseState.d.ts +@@ -1,4 +1,3 @@ +-/// + import * as v from "valibot"; + import type { ClearinghouseStateResponse } from "../../info/_methods/clearinghouseState.js"; + /** +diff --git a/esm/api/subscription/_methods/fastAssetCtxs.d.ts b/esm/api/subscription/_methods/fastAssetCtxs.d.ts +index 15e9d4f3b5332b3f9125a9ab8e6b0ffd3eb8bdc2..d46c0ba1d031a6af05146b80870b75982d6b090a 100644 +--- a/esm/api/subscription/_methods/fastAssetCtxs.d.ts ++++ b/esm/api/subscription/_methods/fastAssetCtxs.d.ts +@@ -1,4 +1,3 @@ +-/// + import * as v from "valibot"; + /** + * Subscription to mark and mid price events for all assets. +diff --git a/esm/api/subscription/_methods/l2Book.d.ts b/esm/api/subscription/_methods/l2Book.d.ts +index 4a82cfeb89a6b716a309273fd8cc9273ec5e1177..cc9553b8002e554efe8b93543784b3efd8d696d0 100644 +--- a/esm/api/subscription/_methods/l2Book.d.ts ++++ b/esm/api/subscription/_methods/l2Book.d.ts +@@ -1,4 +1,3 @@ +-/// + import * as v from "valibot"; + /** + * Subscription to L2 order book events for a specific asset. +diff --git a/esm/api/subscription/_methods/notification.d.ts b/esm/api/subscription/_methods/notification.d.ts +index a28d6f55dd8ad5430919eac0fcc12ca14795129d..e81a7d5266ca18e083211d7d103f3f3f8458e6dc 100644 +--- a/esm/api/subscription/_methods/notification.d.ts ++++ b/esm/api/subscription/_methods/notification.d.ts +@@ -1,4 +1,3 @@ +-/// + import * as v from "valibot"; + /** + * Subscription to notification events for a specific user. +diff --git a/esm/api/subscription/_methods/openOrders.d.ts b/esm/api/subscription/_methods/openOrders.d.ts +index 0515a49865cda4ba4f1e542ec7c91d62b961990f..0bc84dad7f4994365a4a10cea64f666fa473e31a 100644 +--- a/esm/api/subscription/_methods/openOrders.d.ts ++++ b/esm/api/subscription/_methods/openOrders.d.ts +@@ -1,4 +1,3 @@ +-/// + import * as v from "valibot"; + import type { FrontendOpenOrdersResponse } from "../../info/_methods/frontendOpenOrders.js"; + /** +diff --git a/esm/api/subscription/_methods/orderUpdates.d.ts b/esm/api/subscription/_methods/orderUpdates.d.ts +index f4dc3a3b545373a201f977f8773b7ec028839811..8ba200ce2066c1e2f0d3e0b92a4c31314edc3bf2 100644 +--- a/esm/api/subscription/_methods/orderUpdates.d.ts ++++ b/esm/api/subscription/_methods/orderUpdates.d.ts +@@ -1,4 +1,3 @@ +-/// + import * as v from "valibot"; + import type { OpenOrder, OrderProcessingStatus } from "../../info/_methods/_base/mod.js"; + /** +diff --git a/esm/api/subscription/_methods/outcomeMetaUpdates.d.ts b/esm/api/subscription/_methods/outcomeMetaUpdates.d.ts +index 2f9abbfcb36be14fd81bf94a32cd508eaa1d7d51..96ecf198205fa92d5d2c8a0eef026f10dca5b558 100644 +--- a/esm/api/subscription/_methods/outcomeMetaUpdates.d.ts ++++ b/esm/api/subscription/_methods/outcomeMetaUpdates.d.ts +@@ -1,4 +1,3 @@ +-/// + import * as v from "valibot"; + /** + * Subscription to prediction market outcome metadata updates. +diff --git a/esm/api/subscription/_methods/spotAssetCtxs.d.ts b/esm/api/subscription/_methods/spotAssetCtxs.d.ts +index d7c939930dd1003c31fc9452835c6b29e21fbe1f..9edf27dc8492f1a6134b0da31d8bb6c73d17ea0f 100644 +--- a/esm/api/subscription/_methods/spotAssetCtxs.d.ts ++++ b/esm/api/subscription/_methods/spotAssetCtxs.d.ts +@@ -1,4 +1,3 @@ +-/// + import * as v from "valibot"; + import type { SpotAssetCtx } from "../../info/_methods/_base/mod.js"; + /** +diff --git a/esm/api/subscription/_methods/spotState.d.ts b/esm/api/subscription/_methods/spotState.d.ts +index d324b2e99ed9dbf38cef36be3bf340bbacaffd41..2611e906b6e6d12831ea6739598010d4ba5d2c13 100644 +--- a/esm/api/subscription/_methods/spotState.d.ts ++++ b/esm/api/subscription/_methods/spotState.d.ts +@@ -1,4 +1,3 @@ +-/// + import * as v from "valibot"; + import type { SpotClearinghouseStateResponse } from "../../info/_methods/spotClearinghouseState.js"; + /** +diff --git a/esm/api/subscription/_methods/trades.d.ts b/esm/api/subscription/_methods/trades.d.ts +index 5ae305eb2aaeca2124a7a53713160aab182d51e0..3cd518d2ba472f6b3dea9731b0b5f21ef6638f0c 100644 +--- a/esm/api/subscription/_methods/trades.d.ts ++++ b/esm/api/subscription/_methods/trades.d.ts +@@ -1,4 +1,3 @@ +-/// + import * as v from "valibot"; + import type { RecentTradesResponse } from "../../info/_methods/recentTrades.js"; + /** +diff --git a/esm/api/subscription/_methods/twapStates.d.ts b/esm/api/subscription/_methods/twapStates.d.ts +index 8b944ff031cb30e6caf6c1d969ea74a6a69ed9cd..158c71dc4c9e21b628cc8eb33ce4a5b1b79ca97d 100644 +--- a/esm/api/subscription/_methods/twapStates.d.ts ++++ b/esm/api/subscription/_methods/twapStates.d.ts +@@ -1,4 +1,3 @@ +-/// + import * as v from "valibot"; + import type { TwapState } from "../../info/_methods/_base/mod.js"; + /** +diff --git a/esm/api/subscription/_methods/userEvents.d.ts b/esm/api/subscription/_methods/userEvents.d.ts +index 0a774fa832dc2f7e87895c910e86938b59c697d2..b3afe7d56120150718dba12c13d51a16755d0ebf 100644 +--- a/esm/api/subscription/_methods/userEvents.d.ts ++++ b/esm/api/subscription/_methods/userEvents.d.ts +@@ -1,4 +1,3 @@ +-/// + import * as v from "valibot"; + import type { TwapHistoryResponse } from "../../info/_methods/twapHistory.js"; + import type { UserFillsResponse } from "../../info/_methods/userFills.js"; +diff --git a/esm/api/subscription/_methods/userFills.d.ts b/esm/api/subscription/_methods/userFills.d.ts +index 8bde06c50825073d7bbd0e4c1224d49afc4766f4..92926d1078497c2b78ee9ebbf8b8c57a0aaabc2a 100644 +--- a/esm/api/subscription/_methods/userFills.d.ts ++++ b/esm/api/subscription/_methods/userFills.d.ts +@@ -1,4 +1,3 @@ +-/// + import * as v from "valibot"; + import type { UserFillsResponse } from "../../info/_methods/userFills.js"; + /** +diff --git a/esm/api/subscription/_methods/userFundings.d.ts b/esm/api/subscription/_methods/userFundings.d.ts +index 839531128fdbfc2a2360a95a21bddc874aee0103..2085343378b2952182dbc6c4768d223e67275c0f 100644 +--- a/esm/api/subscription/_methods/userFundings.d.ts ++++ b/esm/api/subscription/_methods/userFundings.d.ts +@@ -1,4 +1,3 @@ +-/// + import * as v from "valibot"; + /** + * Subscription to user funding events for a specific user. +diff --git a/esm/api/subscription/_methods/userHistoricalOrders.d.ts b/esm/api/subscription/_methods/userHistoricalOrders.d.ts +index a32044c9b7be5434ac24331ffcac65f575845d7f..13500c41409ec0fd8e0d9fc51db221b8c6272687 100644 +--- a/esm/api/subscription/_methods/userHistoricalOrders.d.ts ++++ b/esm/api/subscription/_methods/userHistoricalOrders.d.ts +@@ -1,4 +1,3 @@ +-/// + import * as v from "valibot"; + import type { HistoricalOrdersResponse } from "../../info/_methods/historicalOrders.js"; + /** +diff --git a/esm/api/subscription/_methods/userNonFundingLedgerUpdates.d.ts b/esm/api/subscription/_methods/userNonFundingLedgerUpdates.d.ts +index 7e27324fd40c6631c08a8d02aed7633e742c773b..df2623820f9f406825d4ad0724ea06e256b18096 100644 +--- a/esm/api/subscription/_methods/userNonFundingLedgerUpdates.d.ts ++++ b/esm/api/subscription/_methods/userNonFundingLedgerUpdates.d.ts +@@ -1,4 +1,3 @@ +-/// + import * as v from "valibot"; + import type { UserNonFundingLedgerUpdatesResponse } from "../../info/_methods/userNonFundingLedgerUpdates.js"; + /** +diff --git a/esm/api/subscription/_methods/userTwapHistory.d.ts b/esm/api/subscription/_methods/userTwapHistory.d.ts +index 2e49f6dc2b6974808cb6c15e41c51e1ff05c862c..e148c3088a2e1de5afe91ae94ad1154f289ef161 100644 +--- a/esm/api/subscription/_methods/userTwapHistory.d.ts ++++ b/esm/api/subscription/_methods/userTwapHistory.d.ts +@@ -1,4 +1,3 @@ +-/// + import * as v from "valibot"; + import type { TwapHistoryResponse } from "../../info/_methods/twapHistory.js"; + /** +diff --git a/esm/api/subscription/_methods/userTwapSliceFills.d.ts b/esm/api/subscription/_methods/userTwapSliceFills.d.ts +index 8b59cac9f6250d8e01155a3ef179c392b813e885..1cfa02bbe02297e50f42c79313a4f667abb494b3 100644 +--- a/esm/api/subscription/_methods/userTwapSliceFills.d.ts ++++ b/esm/api/subscription/_methods/userTwapSliceFills.d.ts +@@ -1,4 +1,3 @@ +-/// + import * as v from "valibot"; + import type { UserTwapSliceFillsResponse } from "../../info/_methods/userTwapSliceFills.js"; + /** +diff --git a/esm/api/subscription/_methods/webData2.d.ts b/esm/api/subscription/_methods/webData2.d.ts +index 24c9596aec90db374589abeb4bfaf1e00bc4f8c2..e62fc4ede340ed0203a4d4e5dacbe6c2ab2f2f33 100644 +--- a/esm/api/subscription/_methods/webData2.d.ts ++++ b/esm/api/subscription/_methods/webData2.d.ts +@@ -1,4 +1,3 @@ +-/// + import * as v from "valibot"; + import type { WebData2Response } from "../../info/_methods/webData2.js"; + /** +diff --git a/esm/api/subscription/_methods/webData3.d.ts b/esm/api/subscription/_methods/webData3.d.ts +index 88e4f3137d0f4467f32cd2fd27bdddfe455edc0c..f8c36cd2b95dc882b81e9cf5f156fce09cc6ea6a 100644 +--- a/esm/api/subscription/_methods/webData3.d.ts ++++ b/esm/api/subscription/_methods/webData3.d.ts +@@ -1,4 +1,3 @@ +-/// + import * as v from "valibot"; + import type { LeadingVaultsResponse } from "../../info/_methods/leadingVaults.js"; + import type { PerpsAtOpenInterestCapResponse } from "../../info/_methods/perpsAtOpenInterestCap.js"; +diff --git a/esm/api/subscription/client.d.ts b/esm/api/subscription/client.d.ts +index 6a18823559c657b665e78554d990319c97338451..2c26bb99bb882e15828a05e2802759d65b8123f2 100644 +--- a/esm/api/subscription/client.d.ts ++++ b/esm/api/subscription/client.d.ts +@@ -2,7 +2,6 @@ + * Client for the Hyperliquid Subscription API endpoint. + * @module + */ +-/// + import type { ISubscription } from "../../transport/mod.js"; + import type { SubscriptionConfig, SubscriptionOptions } from "./_methods/_base/mod.js"; + import { type ActiveAssetCtxEvent, type ActiveAssetCtxParameters } from "./_methods/activeAssetCtx.js"; +diff --git a/esm/api/subscription/mod.d.ts b/esm/api/subscription/mod.d.ts +index 72cd8170d3354b8e2f54ac314cdc2b698b07af52..5fe8be401fec693c55e94ce38da02086881c34c5 100644 +--- a/esm/api/subscription/mod.d.ts ++++ b/esm/api/subscription/mod.d.ts +@@ -22,7 +22,6 @@ + * + * @module + */ +-/// + export type { SubscriptionConfig, SubscriptionOptions } from "./_methods/_base/mod.js"; + export * from "./_methods/activeAssetCtx.js"; + export * from "./_methods/activeAssetData.js"; +diff --git a/esm/mod.d.ts b/esm/mod.d.ts +index a639e2be9a6ec851d09e22195674f789fd70b25f..b02756bcca3c22c8ffb83137bac4439541a1d60c 100644 +--- a/esm/mod.d.ts ++++ b/esm/mod.d.ts +@@ -28,7 +28,6 @@ + * + * @module + */ +-/// + export { HyperliquidError, ValidationError } from "./_base.js"; + export { AbstractWalletError } from "./signing/mod.js"; + export * from "./transport/mod.js"; +diff --git a/esm/signing/_abstractWallet.d.ts b/esm/signing/_abstractWallet.d.ts +index ef0327dea58602c99041a9483990c0ff45bce9e1..95c71907644a32defe309e8909b61c68f7c47c7c 100644 +--- a/esm/signing/_abstractWallet.d.ts ++++ b/esm/signing/_abstractWallet.d.ts +@@ -2,7 +2,6 @@ + * Abstract wallet interfaces and signing utilities for [EIP-712](https://eips.ethereum.org/EIPS/eip-712) typed data. + * @module + */ +-/// + import { HyperliquidError } from "../_base.js"; + /** Thrown when an error occurs in AbstractWallet operations (e.g., signing, getting address). */ + export declare class AbstractWalletError extends HyperliquidError { +diff --git a/esm/signing/_canonicalize.d.ts b/esm/signing/_canonicalize.d.ts +index b65b9f5d8fade465a3a9b6bfb5ab03af08e6f9e8..9ed0a58d7f8bac50cf7e710745c0451d88b350e5 100644 +--- a/esm/signing/_canonicalize.d.ts ++++ b/esm/signing/_canonicalize.d.ts +@@ -2,7 +2,6 @@ + * Schema-driven key canonicalization for Hyperliquid action objects. + * @module + */ +-/// + import type { GenericSchema } from "valibot"; + import { HyperliquidError } from "../_base.js"; + /** Thrown when canonicalization fails due to schema/data key mismatch. */ +diff --git a/esm/signing/_l1.d.ts b/esm/signing/_l1.d.ts +index 1ec6da7795a9fccfd6d43d5d2a1071db426ccfb3..79233c8a94562dcd14fbcb828aa8703d5f6d1ead 100644 +--- a/esm/signing/_l1.d.ts ++++ b/esm/signing/_l1.d.ts +@@ -2,7 +2,6 @@ + * L1 (phantom-agent) signing for trading actions. + * @module + */ +-/// + import { type AbstractWallet, type Signature } from "./_abstractWallet.js"; + /** + * Creates a hash of the L1 action. +diff --git a/esm/signing/_multiSig.d.ts b/esm/signing/_multiSig.d.ts +index c20d8b12b2436f7c620e4827eb91548792bc6404..83aeb0eb34c06633b024dab64b677155d67b641c 100644 +--- a/esm/signing/_multiSig.d.ts ++++ b/esm/signing/_multiSig.d.ts +@@ -2,7 +2,6 @@ + * Multi-sig wrapper construction and outer signing. + * @module + */ +-/// + import { type AbstractWallet, type Signature } from "./_abstractWallet.js"; + /** A multi-sig wrapper as it appears on the wire. */ + interface MultiSigAction { +diff --git a/esm/signing/_userSigned.d.ts b/esm/signing/_userSigned.d.ts +index c90e6fd1a50cdfe308f8d0425c9583a29312b716..4817794e0daa10270f202952d96b669a1c0843b2 100644 +--- a/esm/signing/_userSigned.d.ts ++++ b/esm/signing/_userSigned.d.ts +@@ -2,7 +2,6 @@ + * User-signed ([EIP-712](https://eips.ethereum.org/EIPS/eip-712)) signing for fund and account actions. + * @module + */ +-/// + import { type AbstractWallet, type Signature } from "./_abstractWallet.js"; + /** + * Signs a user-signed action. +diff --git a/esm/signing/mod.d.ts b/esm/signing/mod.d.ts +index 82ea7ea90050f622082cfe853b680b1b5188c071..85ee063fd78fdee3fc4082205f8221c1437e1099 100644 +--- a/esm/signing/mod.d.ts ++++ b/esm/signing/mod.d.ts +@@ -2,7 +2,6 @@ + * Low-level utilities for signing Hyperliquid transactions. + * @module + */ +-/// + export { type AbstractEthersV5Signer, type AbstractEthersV6Signer, type AbstractViemJsonRpcAccount, type AbstractViemLocalAccount, type AbstractWallet, AbstractWalletError, getWalletAddress, getWalletChainId, type Signature, } from "./_abstractWallet.js"; + export { canonicalize, CanonicalizeError } from "./_canonicalize.js"; + export { createL1ActionHash, signL1Action } from "./_l1.js"; +diff --git a/esm/transport/_abort.d.ts b/esm/transport/_abort.d.ts +index cbd85507c6ca46d935861443ca30c5aad249f98b..26a633daedad4f4f129ef5dece4f5880fd305c9a 100644 +--- a/esm/transport/_abort.d.ts ++++ b/esm/transport/_abort.d.ts +@@ -2,7 +2,6 @@ + * AbortSignal wiring helpers shared by the transports. + * @module + */ +-/// + /** Aborts `target` with a `TimeoutError` after `ms`; `cancel` clears the timer, `reason` identifies the abort. */ + export declare function scheduleTimeout(target: AbortController, ms: number | null): { + reason: Error; +diff --git a/esm/transport/_base.d.ts b/esm/transport/_base.d.ts +index c806cda33a5febfa981febb8bf2c224b5a806b7f..7acda628e98464310d8c4c485fff1c18ac840eaf 100644 +--- a/esm/transport/_base.d.ts ++++ b/esm/transport/_base.d.ts +@@ -3,7 +3,6 @@ + * interfaces and the root of the transport error hierarchy. + * @module + */ +-/// + import { HyperliquidError } from "../_base.js"; + /** + * Transport interface for executing requests to the Hyperliquid API. +diff --git a/esm/transport/_polyfills.d.ts b/esm/transport/_polyfills.d.ts +index ed6406108288b9c66a0313475966a81141670f5b..8b748146b8d15bda14ccf0c79c2e3861ab889230 100644 +--- a/esm/transport/_polyfills.d.ts ++++ b/esm/transport/_polyfills.d.ts +@@ -1,4 +1,3 @@ +-/// + /** + * Runtime shims for APIs missing on some supported platforms, mainly React Native. + * @module +diff --git a/esm/transport/http/mod.d.ts b/esm/transport/http/mod.d.ts +index 9d56aa8e6f911bf09d435c5f1302a4d6bd143b1d..e4e68591e3da328affc9a466667e7f1d7aaf1f77 100644 +--- a/esm/transport/http/mod.d.ts ++++ b/esm/transport/http/mod.d.ts +@@ -25,7 +25,6 @@ + * + * @module + */ +-/// + import { type IRequestTransport, TransportError } from "../_base.js"; + /** Configuration options for the HTTP transport layer. */ + export interface HttpTransportOptions { +diff --git a/esm/transport/mod.d.ts b/esm/transport/mod.d.ts +index 1ebe39ae31807899496de52b6f6936fb2ff7b653..dd76efc8c81fc141d98f86bb8acb05de85924a33 100644 +--- a/esm/transport/mod.d.ts ++++ b/esm/transport/mod.d.ts +@@ -13,7 +13,6 @@ + * + * @module + */ +-/// + export * from "./_base.js"; + export * from "./http/mod.js"; + export * from "./websocket/mod.js"; +diff --git a/esm/transport/websocket/_dispatcher.d.ts b/esm/transport/websocket/_dispatcher.d.ts +index ebd77fb7eed25ddb2101e34be2161b0013582432..66eb0053252a7050150b2544fdc521f779239b37 100644 +--- a/esm/transport/websocket/_dispatcher.d.ts ++++ b/esm/transport/websocket/_dispatcher.d.ts +@@ -4,7 +4,6 @@ + * + * @module + */ +-/// + import { ReconnectingWebSocket } from "@nktkas/rews"; + import { TransportError } from "../_base.js"; + import type { HyperliquidEventTarget } from "./_events.js"; +diff --git a/esm/transport/websocket/_events.d.ts b/esm/transport/websocket/_events.d.ts +index 39cb46e049f0c0996a03a5c15eee65f6224f664c..81d8a63da317df9563d981190cbe82f77449107b 100644 +--- a/esm/transport/websocket/_events.d.ts ++++ b/esm/transport/websocket/_events.d.ts +@@ -6,7 +6,6 @@ + * + * @module + */ +-/// + /** + * Confirmation frame of a `subscribe` / `unsubscribe` request. + * +diff --git a/esm/transport/websocket/_id.d.ts b/esm/transport/websocket/_id.d.ts +index 8cbf7441096d3f86ef6f45838c52424f5bec7325..250b2882eb64b39b861d023919237a4dcd57668f 100644 +--- a/esm/transport/websocket/_id.d.ts ++++ b/esm/transport/websocket/_id.d.ts +@@ -2,7 +2,6 @@ + * Identity-formation utilities for WebSocket request matching. + * @module + */ +-/// + /** + * Builds a stable string identifier from an arbitrary value: payloads with + * the same logical content produce the same id. +diff --git a/esm/transport/websocket/_keepAlive.d.ts b/esm/transport/websocket/_keepAlive.d.ts +index 09b643132730d94fa3bc53a26493b7c23954fde2..e22cfbc4986eef85b37686ffdcaf94fb42692c2e 100644 +--- a/esm/transport/websocket/_keepAlive.d.ts ++++ b/esm/transport/websocket/_keepAlive.d.ts +@@ -4,7 +4,6 @@ + * + * @module + */ +-/// + import type { ReconnectingWebSocket } from "@nktkas/rews"; + import type { HyperliquidEventTarget } from "./_events.js"; + /** Configuration options for the keep-alive watchdog. */ +diff --git a/esm/transport/websocket/_subscriptionManager.d.ts b/esm/transport/websocket/_subscriptionManager.d.ts +index ef4fc1238aa0f586a3bc4b172aa0fce44d40b7c3..280d7e74a0984e1b7e578d7707ed0cb567d96457 100644 +--- a/esm/transport/websocket/_subscriptionManager.d.ts ++++ b/esm/transport/websocket/_subscriptionManager.d.ts +@@ -4,7 +4,6 @@ + * + * @module + */ +-/// + import { ReconnectingWebSocket } from "@nktkas/rews"; + import type { ISubscription } from "../_base.js"; + import type { HyperliquidEventTarget } from "./_events.js"; +diff --git a/esm/transport/websocket/mod.d.ts b/esm/transport/websocket/mod.d.ts +index d6bffa051cf2e2ee9bd2352976344b09304446e7..538b7e089f09fe8d774c5d7ac358b96c7cccd67f 100644 +--- a/esm/transport/websocket/mod.d.ts ++++ b/esm/transport/websocket/mod.d.ts +@@ -20,7 +20,6 @@ + * + * @module + */ +-/// + import { ReconnectingWebSocket, type ReconnectingWebSocketOptions } from "@nktkas/rews"; + import type { IRequestTransport, ISubscription, ISubscriptionTransport } from "../_base.js"; + import { WebSocketRequestError } from "./_dispatcher.js"; +diff --git a/esm/utils/_format.d.ts b/esm/utils/_format.d.ts +index f0424a1dcb46165e7f95efa8cbf3513c5db68f04..e6cee1e873c5a2749ff13fcb34278483b9964880 100644 +--- a/esm/utils/_format.d.ts ++++ b/esm/utils/_format.d.ts +@@ -3,7 +3,6 @@ + * + * @module + */ +-/// + import { HyperliquidError } from "../_base.js"; + /** + * Thrown when a price or size value cannot be formatted to a valid decimal. +diff --git a/esm/utils/_symbolConverter.d.ts b/esm/utils/_symbolConverter.d.ts +index 4eb4892a9061e4e99be3ce4a49daa13adf915ad9..279de8cb1337d1f0c539f8ee1bf70156f82fd179 100644 +--- a/esm/utils/_symbolConverter.d.ts ++++ b/esm/utils/_symbolConverter.d.ts +@@ -1,4 +1,3 @@ +-/// + import type { IRequestTransport } from "../transport/mod.js"; + /** Options for creating a {@link SymbolConverter} instance. */ + export interface SymbolConverterOptions { +diff --git a/esm/utils/mod.d.ts b/esm/utils/mod.d.ts +index aa099b41bc708feb69139a4359dcd5e47c70923d..530298eda725e8f2f218b3a842b1d711b302dd1b 100644 +--- a/esm/utils/mod.d.ts ++++ b/esm/utils/mod.d.ts +@@ -22,6 +22,5 @@ + * + * @module + */ +-/// + export * from "./_symbolConverter.js"; + export * from "./_format.js"; diff --git a/.yarn/plugins/@yarnpkg/plugin-constraints.cjs b/.yarn/plugins/@yarnpkg/plugin-constraints.cjs deleted file mode 100644 index f3b0db0c024..00000000000 --- a/.yarn/plugins/@yarnpkg/plugin-constraints.cjs +++ /dev/null @@ -1,52 +0,0 @@ -/* eslint-disable */ -//prettier-ignore -module.exports = { -name: "@yarnpkg/plugin-constraints", -factory: function (require) { -var plugin=(()=>{var Li=Object.create,Je=Object.defineProperty;var Hi=Object.getOwnPropertyDescriptor;var Gi=Object.getOwnPropertyNames;var Yi=Object.getPrototypeOf,Ui=Object.prototype.hasOwnProperty;var Zi=r=>Je(r,"__esModule",{value:!0});var I=(r,u)=>()=>(u||r((u={exports:{}}).exports,u),u.exports),Qi=(r,u)=>{for(var p in u)Je(r,p,{get:u[p],enumerable:!0})},Ji=(r,u,p)=>{if(u&&typeof u=="object"||typeof u=="function")for(let c of Gi(u))!Ui.call(r,c)&&c!=="default"&&Je(r,c,{get:()=>u[c],enumerable:!(p=Hi(u,c))||p.enumerable});return r},G=r=>Ji(Zi(Je(r!=null?Li(Yi(r)):{},"default",r&&r.__esModule&&"default"in r?{get:()=>r.default,enumerable:!0}:{value:r,enumerable:!0})),r);var Xr=I((Nu,_r)=>{var Ki;(function(r){var u=function(){return{"append/2":[new r.type.Rule(new r.type.Term("append",[new r.type.Var("X"),new r.type.Var("L")]),new r.type.Term("foldl",[new r.type.Term("append",[]),new r.type.Var("X"),new r.type.Term("[]",[]),new r.type.Var("L")]))],"append/3":[new r.type.Rule(new r.type.Term("append",[new r.type.Term("[]",[]),new r.type.Var("X"),new r.type.Var("X")]),null),new r.type.Rule(new r.type.Term("append",[new r.type.Term(".",[new r.type.Var("H"),new r.type.Var("T")]),new r.type.Var("X"),new r.type.Term(".",[new r.type.Var("H"),new r.type.Var("S")])]),new r.type.Term("append",[new r.type.Var("T"),new r.type.Var("X"),new r.type.Var("S")]))],"member/2":[new r.type.Rule(new r.type.Term("member",[new r.type.Var("X"),new r.type.Term(".",[new r.type.Var("X"),new r.type.Var("_")])]),null),new r.type.Rule(new r.type.Term("member",[new r.type.Var("X"),new r.type.Term(".",[new r.type.Var("_"),new r.type.Var("Xs")])]),new r.type.Term("member",[new r.type.Var("X"),new r.type.Var("Xs")]))],"permutation/2":[new r.type.Rule(new r.type.Term("permutation",[new r.type.Term("[]",[]),new r.type.Term("[]",[])]),null),new r.type.Rule(new r.type.Term("permutation",[new r.type.Term(".",[new r.type.Var("H"),new r.type.Var("T")]),new r.type.Var("S")]),new r.type.Term(",",[new r.type.Term("permutation",[new r.type.Var("T"),new r.type.Var("P")]),new r.type.Term(",",[new r.type.Term("append",[new r.type.Var("X"),new r.type.Var("Y"),new r.type.Var("P")]),new r.type.Term("append",[new r.type.Var("X"),new r.type.Term(".",[new r.type.Var("H"),new r.type.Var("Y")]),new r.type.Var("S")])])]))],"maplist/2":[new r.type.Rule(new r.type.Term("maplist",[new r.type.Var("_"),new r.type.Term("[]",[])]),null),new r.type.Rule(new r.type.Term("maplist",[new r.type.Var("P"),new r.type.Term(".",[new r.type.Var("X"),new r.type.Var("Xs")])]),new r.type.Term(",",[new r.type.Term("call",[new r.type.Var("P"),new r.type.Var("X")]),new r.type.Term("maplist",[new r.type.Var("P"),new r.type.Var("Xs")])]))],"maplist/3":[new r.type.Rule(new r.type.Term("maplist",[new r.type.Var("_"),new r.type.Term("[]",[]),new r.type.Term("[]",[])]),null),new r.type.Rule(new r.type.Term("maplist",[new r.type.Var("P"),new r.type.Term(".",[new r.type.Var("A"),new r.type.Var("As")]),new r.type.Term(".",[new r.type.Var("B"),new r.type.Var("Bs")])]),new r.type.Term(",",[new r.type.Term("call",[new r.type.Var("P"),new r.type.Var("A"),new r.type.Var("B")]),new r.type.Term("maplist",[new r.type.Var("P"),new r.type.Var("As"),new r.type.Var("Bs")])]))],"maplist/4":[new r.type.Rule(new r.type.Term("maplist",[new r.type.Var("_"),new r.type.Term("[]",[]),new r.type.Term("[]",[]),new r.type.Term("[]",[])]),null),new r.type.Rule(new r.type.Term("maplist",[new r.type.Var("P"),new r.type.Term(".",[new r.type.Var("A"),new r.type.Var("As")]),new r.type.Term(".",[new r.type.Var("B"),new r.type.Var("Bs")]),new r.type.Term(".",[new r.type.Var("C"),new r.type.Var("Cs")])]),new r.type.Term(",",[new r.type.Term("call",[new r.type.Var("P"),new r.type.Var("A"),new r.type.Var("B"),new r.type.Var("C")]),new r.type.Term("maplist",[new r.type.Var("P"),new r.type.Var("As"),new r.type.Var("Bs"),new r.type.Var("Cs")])]))],"maplist/5":[new r.type.Rule(new r.type.Term("maplist",[new r.type.Var("_"),new r.type.Term("[]",[]),new r.type.Term("[]",[]),new r.type.Term("[]",[]),new r.type.Term("[]",[])]),null),new r.type.Rule(new r.type.Term("maplist",[new r.type.Var("P"),new r.type.Term(".",[new r.type.Var("A"),new r.type.Var("As")]),new r.type.Term(".",[new r.type.Var("B"),new r.type.Var("Bs")]),new r.type.Term(".",[new r.type.Var("C"),new r.type.Var("Cs")]),new r.type.Term(".",[new r.type.Var("D"),new r.type.Var("Ds")])]),new r.type.Term(",",[new r.type.Term("call",[new r.type.Var("P"),new r.type.Var("A"),new r.type.Var("B"),new r.type.Var("C"),new r.type.Var("D")]),new r.type.Term("maplist",[new r.type.Var("P"),new r.type.Var("As"),new r.type.Var("Bs"),new r.type.Var("Cs"),new r.type.Var("Ds")])]))],"maplist/6":[new r.type.Rule(new r.type.Term("maplist",[new r.type.Var("_"),new r.type.Term("[]",[]),new r.type.Term("[]",[]),new r.type.Term("[]",[]),new r.type.Term("[]",[]),new r.type.Term("[]",[])]),null),new r.type.Rule(new r.type.Term("maplist",[new r.type.Var("P"),new r.type.Term(".",[new r.type.Var("A"),new r.type.Var("As")]),new r.type.Term(".",[new r.type.Var("B"),new r.type.Var("Bs")]),new r.type.Term(".",[new r.type.Var("C"),new r.type.Var("Cs")]),new r.type.Term(".",[new r.type.Var("D"),new r.type.Var("Ds")]),new r.type.Term(".",[new r.type.Var("E"),new r.type.Var("Es")])]),new r.type.Term(",",[new r.type.Term("call",[new r.type.Var("P"),new r.type.Var("A"),new r.type.Var("B"),new r.type.Var("C"),new r.type.Var("D"),new r.type.Var("E")]),new r.type.Term("maplist",[new r.type.Var("P"),new r.type.Var("As"),new r.type.Var("Bs"),new r.type.Var("Cs"),new r.type.Var("Ds"),new r.type.Var("Es")])]))],"maplist/7":[new r.type.Rule(new r.type.Term("maplist",[new r.type.Var("_"),new r.type.Term("[]",[]),new r.type.Term("[]",[]),new r.type.Term("[]",[]),new r.type.Term("[]",[]),new r.type.Term("[]",[]),new r.type.Term("[]",[])]),null),new r.type.Rule(new r.type.Term("maplist",[new r.type.Var("P"),new r.type.Term(".",[new r.type.Var("A"),new r.type.Var("As")]),new r.type.Term(".",[new r.type.Var("B"),new r.type.Var("Bs")]),new r.type.Term(".",[new r.type.Var("C"),new r.type.Var("Cs")]),new r.type.Term(".",[new r.type.Var("D"),new r.type.Var("Ds")]),new r.type.Term(".",[new r.type.Var("E"),new r.type.Var("Es")]),new r.type.Term(".",[new r.type.Var("F"),new r.type.Var("Fs")])]),new r.type.Term(",",[new r.type.Term("call",[new r.type.Var("P"),new r.type.Var("A"),new r.type.Var("B"),new r.type.Var("C"),new r.type.Var("D"),new r.type.Var("E"),new r.type.Var("F")]),new r.type.Term("maplist",[new r.type.Var("P"),new r.type.Var("As"),new r.type.Var("Bs"),new r.type.Var("Cs"),new r.type.Var("Ds"),new r.type.Var("Es"),new r.type.Var("Fs")])]))],"maplist/8":[new r.type.Rule(new r.type.Term("maplist",[new r.type.Var("_"),new r.type.Term("[]",[]),new r.type.Term("[]",[]),new r.type.Term("[]",[]),new r.type.Term("[]",[]),new r.type.Term("[]",[]),new r.type.Term("[]",[]),new r.type.Term("[]",[])]),null),new r.type.Rule(new r.type.Term("maplist",[new r.type.Var("P"),new r.type.Term(".",[new r.type.Var("A"),new r.type.Var("As")]),new r.type.Term(".",[new r.type.Var("B"),new r.type.Var("Bs")]),new r.type.Term(".",[new r.type.Var("C"),new r.type.Var("Cs")]),new r.type.Term(".",[new r.type.Var("D"),new r.type.Var("Ds")]),new r.type.Term(".",[new r.type.Var("E"),new r.type.Var("Es")]),new r.type.Term(".",[new r.type.Var("F"),new r.type.Var("Fs")]),new r.type.Term(".",[new r.type.Var("G"),new r.type.Var("Gs")])]),new r.type.Term(",",[new r.type.Term("call",[new r.type.Var("P"),new r.type.Var("A"),new r.type.Var("B"),new r.type.Var("C"),new r.type.Var("D"),new r.type.Var("E"),new r.type.Var("F"),new r.type.Var("G")]),new r.type.Term("maplist",[new r.type.Var("P"),new r.type.Var("As"),new r.type.Var("Bs"),new r.type.Var("Cs"),new r.type.Var("Ds"),new r.type.Var("Es"),new r.type.Var("Fs"),new r.type.Var("Gs")])]))],"include/3":[new r.type.Rule(new r.type.Term("include",[new r.type.Var("_"),new r.type.Term("[]",[]),new r.type.Term("[]",[])]),null),new r.type.Rule(new r.type.Term("include",[new r.type.Var("P"),new r.type.Term(".",[new r.type.Var("H"),new r.type.Var("T")]),new r.type.Var("L")]),new r.type.Term(",",[new r.type.Term("=..",[new r.type.Var("P"),new r.type.Var("A")]),new r.type.Term(",",[new r.type.Term("append",[new r.type.Var("A"),new r.type.Term(".",[new r.type.Var("H"),new r.type.Term("[]",[])]),new r.type.Var("B")]),new r.type.Term(",",[new r.type.Term("=..",[new r.type.Var("F"),new r.type.Var("B")]),new r.type.Term(",",[new r.type.Term(";",[new r.type.Term(",",[new r.type.Term("call",[new r.type.Var("F")]),new r.type.Term(",",[new r.type.Term("=",[new r.type.Var("L"),new r.type.Term(".",[new r.type.Var("H"),new r.type.Var("S")])]),new r.type.Term("!",[])])]),new r.type.Term("=",[new r.type.Var("L"),new r.type.Var("S")])]),new r.type.Term("include",[new r.type.Var("P"),new r.type.Var("T"),new r.type.Var("S")])])])])]))],"exclude/3":[new r.type.Rule(new r.type.Term("exclude",[new r.type.Var("_"),new r.type.Term("[]",[]),new r.type.Term("[]",[])]),null),new r.type.Rule(new r.type.Term("exclude",[new r.type.Var("P"),new r.type.Term(".",[new r.type.Var("H"),new r.type.Var("T")]),new r.type.Var("S")]),new r.type.Term(",",[new r.type.Term("exclude",[new r.type.Var("P"),new r.type.Var("T"),new r.type.Var("E")]),new r.type.Term(",",[new r.type.Term("=..",[new r.type.Var("P"),new r.type.Var("L")]),new r.type.Term(",",[new r.type.Term("append",[new r.type.Var("L"),new r.type.Term(".",[new r.type.Var("H"),new r.type.Term("[]",[])]),new r.type.Var("Q")]),new r.type.Term(",",[new r.type.Term("=..",[new r.type.Var("R"),new r.type.Var("Q")]),new r.type.Term(";",[new r.type.Term(",",[new r.type.Term("call",[new r.type.Var("R")]),new r.type.Term(",",[new r.type.Term("!",[]),new r.type.Term("=",[new r.type.Var("S"),new r.type.Var("E")])])]),new r.type.Term("=",[new r.type.Var("S"),new r.type.Term(".",[new r.type.Var("H"),new r.type.Var("E")])])])])])])]))],"foldl/4":[new r.type.Rule(new r.type.Term("foldl",[new r.type.Var("_"),new r.type.Term("[]",[]),new r.type.Var("I"),new r.type.Var("I")]),null),new r.type.Rule(new r.type.Term("foldl",[new r.type.Var("P"),new r.type.Term(".",[new r.type.Var("H"),new r.type.Var("T")]),new r.type.Var("I"),new r.type.Var("R")]),new r.type.Term(",",[new r.type.Term("=..",[new r.type.Var("P"),new r.type.Var("L")]),new r.type.Term(",",[new r.type.Term("append",[new r.type.Var("L"),new r.type.Term(".",[new r.type.Var("I"),new r.type.Term(".",[new r.type.Var("H"),new r.type.Term(".",[new r.type.Var("X"),new r.type.Term("[]",[])])])]),new r.type.Var("L2")]),new r.type.Term(",",[new r.type.Term("=..",[new r.type.Var("P2"),new r.type.Var("L2")]),new r.type.Term(",",[new r.type.Term("call",[new r.type.Var("P2")]),new r.type.Term("foldl",[new r.type.Var("P"),new r.type.Var("T"),new r.type.Var("X"),new r.type.Var("R")])])])])]))],"select/3":[new r.type.Rule(new r.type.Term("select",[new r.type.Var("E"),new r.type.Term(".",[new r.type.Var("E"),new r.type.Var("Xs")]),new r.type.Var("Xs")]),null),new r.type.Rule(new r.type.Term("select",[new r.type.Var("E"),new r.type.Term(".",[new r.type.Var("X"),new r.type.Var("Xs")]),new r.type.Term(".",[new r.type.Var("X"),new r.type.Var("Ys")])]),new r.type.Term("select",[new r.type.Var("E"),new r.type.Var("Xs"),new r.type.Var("Ys")]))],"sum_list/2":[new r.type.Rule(new r.type.Term("sum_list",[new r.type.Term("[]",[]),new r.type.Num(0,!1)]),null),new r.type.Rule(new r.type.Term("sum_list",[new r.type.Term(".",[new r.type.Var("X"),new r.type.Var("Xs")]),new r.type.Var("S")]),new r.type.Term(",",[new r.type.Term("sum_list",[new r.type.Var("Xs"),new r.type.Var("Y")]),new r.type.Term("is",[new r.type.Var("S"),new r.type.Term("+",[new r.type.Var("X"),new r.type.Var("Y")])])]))],"max_list/2":[new r.type.Rule(new r.type.Term("max_list",[new r.type.Term(".",[new r.type.Var("X"),new r.type.Term("[]",[])]),new r.type.Var("X")]),null),new r.type.Rule(new r.type.Term("max_list",[new r.type.Term(".",[new r.type.Var("X"),new r.type.Var("Xs")]),new r.type.Var("S")]),new r.type.Term(",",[new r.type.Term("max_list",[new r.type.Var("Xs"),new r.type.Var("Y")]),new r.type.Term(";",[new r.type.Term(",",[new r.type.Term(">=",[new r.type.Var("X"),new r.type.Var("Y")]),new r.type.Term(",",[new r.type.Term("=",[new r.type.Var("S"),new r.type.Var("X")]),new r.type.Term("!",[])])]),new r.type.Term("=",[new r.type.Var("S"),new r.type.Var("Y")])])]))],"min_list/2":[new r.type.Rule(new r.type.Term("min_list",[new r.type.Term(".",[new r.type.Var("X"),new r.type.Term("[]",[])]),new r.type.Var("X")]),null),new r.type.Rule(new r.type.Term("min_list",[new r.type.Term(".",[new r.type.Var("X"),new r.type.Var("Xs")]),new r.type.Var("S")]),new r.type.Term(",",[new r.type.Term("min_list",[new r.type.Var("Xs"),new r.type.Var("Y")]),new r.type.Term(";",[new r.type.Term(",",[new r.type.Term("=<",[new r.type.Var("X"),new r.type.Var("Y")]),new r.type.Term(",",[new r.type.Term("=",[new r.type.Var("S"),new r.type.Var("X")]),new r.type.Term("!",[])])]),new r.type.Term("=",[new r.type.Var("S"),new r.type.Var("Y")])])]))],"prod_list/2":[new r.type.Rule(new r.type.Term("prod_list",[new r.type.Term("[]",[]),new r.type.Num(1,!1)]),null),new r.type.Rule(new r.type.Term("prod_list",[new r.type.Term(".",[new r.type.Var("X"),new r.type.Var("Xs")]),new r.type.Var("S")]),new r.type.Term(",",[new r.type.Term("prod_list",[new r.type.Var("Xs"),new r.type.Var("Y")]),new r.type.Term("is",[new r.type.Var("S"),new r.type.Term("*",[new r.type.Var("X"),new r.type.Var("Y")])])]))],"last/2":[new r.type.Rule(new r.type.Term("last",[new r.type.Term(".",[new r.type.Var("X"),new r.type.Term("[]",[])]),new r.type.Var("X")]),null),new r.type.Rule(new r.type.Term("last",[new r.type.Term(".",[new r.type.Var("_"),new r.type.Var("Xs")]),new r.type.Var("X")]),new r.type.Term("last",[new r.type.Var("Xs"),new r.type.Var("X")]))],"prefix/2":[new r.type.Rule(new r.type.Term("prefix",[new r.type.Var("Part"),new r.type.Var("Whole")]),new r.type.Term("append",[new r.type.Var("Part"),new r.type.Var("_"),new r.type.Var("Whole")]))],"nth0/3":[new r.type.Rule(new r.type.Term("nth0",[new r.type.Var("X"),new r.type.Var("Y"),new r.type.Var("Z")]),new r.type.Term(";",[new r.type.Term("->",[new r.type.Term("var",[new r.type.Var("X")]),new r.type.Term("nth",[new r.type.Num(0,!1),new r.type.Var("X"),new r.type.Var("Y"),new r.type.Var("Z"),new r.type.Var("_")])]),new r.type.Term(",",[new r.type.Term(">=",[new r.type.Var("X"),new r.type.Num(0,!1)]),new r.type.Term(",",[new r.type.Term("nth",[new r.type.Num(0,!1),new r.type.Var("X"),new r.type.Var("Y"),new r.type.Var("Z"),new r.type.Var("_")]),new r.type.Term("!",[])])])]))],"nth1/3":[new r.type.Rule(new r.type.Term("nth1",[new r.type.Var("X"),new r.type.Var("Y"),new r.type.Var("Z")]),new r.type.Term(";",[new r.type.Term("->",[new r.type.Term("var",[new r.type.Var("X")]),new r.type.Term("nth",[new r.type.Num(1,!1),new r.type.Var("X"),new r.type.Var("Y"),new r.type.Var("Z"),new r.type.Var("_")])]),new r.type.Term(",",[new r.type.Term(">",[new r.type.Var("X"),new r.type.Num(0,!1)]),new r.type.Term(",",[new r.type.Term("nth",[new r.type.Num(1,!1),new r.type.Var("X"),new r.type.Var("Y"),new r.type.Var("Z"),new r.type.Var("_")]),new r.type.Term("!",[])])])]))],"nth0/4":[new r.type.Rule(new r.type.Term("nth0",[new r.type.Var("X"),new r.type.Var("Y"),new r.type.Var("Z"),new r.type.Var("W")]),new r.type.Term(";",[new r.type.Term("->",[new r.type.Term("var",[new r.type.Var("X")]),new r.type.Term("nth",[new r.type.Num(0,!1),new r.type.Var("X"),new r.type.Var("Y"),new r.type.Var("Z"),new r.type.Var("W")])]),new r.type.Term(",",[new r.type.Term(">=",[new r.type.Var("X"),new r.type.Num(0,!1)]),new r.type.Term(",",[new r.type.Term("nth",[new r.type.Num(0,!1),new r.type.Var("X"),new r.type.Var("Y"),new r.type.Var("Z"),new r.type.Var("W")]),new r.type.Term("!",[])])])]))],"nth1/4":[new r.type.Rule(new r.type.Term("nth1",[new r.type.Var("X"),new r.type.Var("Y"),new r.type.Var("Z"),new r.type.Var("W")]),new r.type.Term(";",[new r.type.Term("->",[new r.type.Term("var",[new r.type.Var("X")]),new r.type.Term("nth",[new r.type.Num(1,!1),new r.type.Var("X"),new r.type.Var("Y"),new r.type.Var("Z"),new r.type.Var("W")])]),new r.type.Term(",",[new r.type.Term(">",[new r.type.Var("X"),new r.type.Num(0,!1)]),new r.type.Term(",",[new r.type.Term("nth",[new r.type.Num(1,!1),new r.type.Var("X"),new r.type.Var("Y"),new r.type.Var("Z"),new r.type.Var("W")]),new r.type.Term("!",[])])])]))],"nth/5":[new r.type.Rule(new r.type.Term("nth",[new r.type.Var("N"),new r.type.Var("N"),new r.type.Term(".",[new r.type.Var("X"),new r.type.Var("Xs")]),new r.type.Var("X"),new r.type.Var("Xs")]),null),new r.type.Rule(new r.type.Term("nth",[new r.type.Var("N"),new r.type.Var("O"),new r.type.Term(".",[new r.type.Var("X"),new r.type.Var("Xs")]),new r.type.Var("Y"),new r.type.Term(".",[new r.type.Var("X"),new r.type.Var("Ys")])]),new r.type.Term(",",[new r.type.Term("is",[new r.type.Var("M"),new r.type.Term("+",[new r.type.Var("N"),new r.type.Num(1,!1)])]),new r.type.Term("nth",[new r.type.Var("M"),new r.type.Var("O"),new r.type.Var("Xs"),new r.type.Var("Y"),new r.type.Var("Ys")])]))],"length/2":function(c,w,_){var v=_.args[0],g=_.args[1];if(!r.type.is_variable(g)&&!r.type.is_integer(g))c.throw_error(r.error.type("integer",g,_.indicator));else if(r.type.is_integer(g)&&g.value<0)c.throw_error(r.error.domain("not_less_than_zero",g,_.indicator));else{var h=new r.type.Term("length",[v,new r.type.Num(0,!1),g]);r.type.is_integer(g)&&(h=new r.type.Term(",",[h,new r.type.Term("!",[])])),c.prepend([new r.type.State(w.goal.replace(h),w.substitution,w)])}},"length/3":[new r.type.Rule(new r.type.Term("length",[new r.type.Term("[]",[]),new r.type.Var("N"),new r.type.Var("N")]),null),new r.type.Rule(new r.type.Term("length",[new r.type.Term(".",[new r.type.Var("_"),new r.type.Var("X")]),new r.type.Var("A"),new r.type.Var("N")]),new r.type.Term(",",[new r.type.Term("succ",[new r.type.Var("A"),new r.type.Var("B")]),new r.type.Term("length",[new r.type.Var("X"),new r.type.Var("B"),new r.type.Var("N")])]))],"replicate/3":function(c,w,_){var v=_.args[0],g=_.args[1],h=_.args[2];if(r.type.is_variable(g))c.throw_error(r.error.instantiation(_.indicator));else if(!r.type.is_integer(g))c.throw_error(r.error.type("integer",g,_.indicator));else if(g.value<0)c.throw_error(r.error.domain("not_less_than_zero",g,_.indicator));else if(!r.type.is_variable(h)&&!r.type.is_list(h))c.throw_error(r.error.type("list",h,_.indicator));else{for(var x=new r.type.Term("[]"),T=0;T0;b--)T[b].equals(T[b-1])&&T.splice(b,1);for(var C=new r.type.Term("[]"),b=T.length-1;b>=0;b--)C=new r.type.Term(".",[T[b],C]);c.prepend([new r.type.State(w.goal.replace(new r.type.Term("=",[C,g])),w.substitution,w)])}}},"msort/2":function(c,w,_){var v=_.args[0],g=_.args[1];if(r.type.is_variable(v))c.throw_error(r.error.instantiation(_.indicator));else if(!r.type.is_variable(g)&&!r.type.is_fully_list(g))c.throw_error(r.error.type("list",g,_.indicator));else{for(var h=[],x=v;x.indicator==="./2";)h.push(x.args[0]),x=x.args[1];if(r.type.is_variable(x))c.throw_error(r.error.instantiation(_.indicator));else if(!r.type.is_empty_list(x))c.throw_error(r.error.type("list",v,_.indicator));else{for(var T=h.sort(r.compare),b=new r.type.Term("[]"),C=T.length-1;C>=0;C--)b=new r.type.Term(".",[T[C],b]);c.prepend([new r.type.State(w.goal.replace(new r.type.Term("=",[b,g])),w.substitution,w)])}}},"keysort/2":function(c,w,_){var v=_.args[0],g=_.args[1];if(r.type.is_variable(v))c.throw_error(r.error.instantiation(_.indicator));else if(!r.type.is_variable(g)&&!r.type.is_fully_list(g))c.throw_error(r.error.type("list",g,_.indicator));else{for(var h=[],x,T=v;T.indicator==="./2";){if(x=T.args[0],r.type.is_variable(x)){c.throw_error(r.error.instantiation(_.indicator));return}else if(!r.type.is_term(x)||x.indicator!=="-/2"){c.throw_error(r.error.type("pair",x,_.indicator));return}x.args[0].pair=x.args[1],h.push(x.args[0]),T=T.args[1]}if(r.type.is_variable(T))c.throw_error(r.error.instantiation(_.indicator));else if(!r.type.is_empty_list(T))c.throw_error(r.error.type("list",v,_.indicator));else{for(var b=h.sort(r.compare),C=new r.type.Term("[]"),N=b.length-1;N>=0;N--)C=new r.type.Term(".",[new r.type.Term("-",[b[N],b[N].pair]),C]),delete b[N].pair;c.prepend([new r.type.State(w.goal.replace(new r.type.Term("=",[C,g])),w.substitution,w)])}}},"take/3":function(c,w,_){var v=_.args[0],g=_.args[1],h=_.args[2];if(r.type.is_variable(g)||r.type.is_variable(v))c.throw_error(r.error.instantiation(_.indicator));else if(!r.type.is_list(g))c.throw_error(r.error.type("list",g,_.indicator));else if(!r.type.is_integer(v))c.throw_error(r.error.type("integer",v,_.indicator));else if(!r.type.is_variable(h)&&!r.type.is_list(h))c.throw_error(r.error.type("list",h,_.indicator));else{for(var x=v.value,T=[],b=g;x>0&&b.indicator==="./2";)T.push(b.args[0]),b=b.args[1],x--;if(x===0){for(var C=new r.type.Term("[]"),x=T.length-1;x>=0;x--)C=new r.type.Term(".",[T[x],C]);c.prepend([new r.type.State(w.goal.replace(new r.type.Term("=",[C,h])),w.substitution,w)])}}},"drop/3":function(c,w,_){var v=_.args[0],g=_.args[1],h=_.args[2];if(r.type.is_variable(g)||r.type.is_variable(v))c.throw_error(r.error.instantiation(_.indicator));else if(!r.type.is_list(g))c.throw_error(r.error.type("list",g,_.indicator));else if(!r.type.is_integer(v))c.throw_error(r.error.type("integer",v,_.indicator));else if(!r.type.is_variable(h)&&!r.type.is_list(h))c.throw_error(r.error.type("list",h,_.indicator));else{for(var x=v.value,T=[],b=g;x>0&&b.indicator==="./2";)T.push(b.args[0]),b=b.args[1],x--;x===0&&c.prepend([new r.type.State(w.goal.replace(new r.type.Term("=",[b,h])),w.substitution,w)])}},"reverse/2":function(c,w,_){var v=_.args[0],g=_.args[1],h=r.type.is_instantiated_list(v),x=r.type.is_instantiated_list(g);if(r.type.is_variable(v)&&r.type.is_variable(g))c.throw_error(r.error.instantiation(_.indicator));else if(!r.type.is_variable(v)&&!r.type.is_fully_list(v))c.throw_error(r.error.type("list",v,_.indicator));else if(!r.type.is_variable(g)&&!r.type.is_fully_list(g))c.throw_error(r.error.type("list",g,_.indicator));else if(!h&&!x)c.throw_error(r.error.instantiation(_.indicator));else{for(var T=h?v:g,b=new r.type.Term("[]",[]);T.indicator==="./2";)b=new r.type.Term(".",[T.args[0],b]),T=T.args[1];c.prepend([new r.type.State(w.goal.replace(new r.type.Term("=",[b,h?g:v])),w.substitution,w)])}},"list_to_set/2":function(c,w,_){var v=_.args[0],g=_.args[1];if(r.type.is_variable(v))c.throw_error(r.error.instantiation(_.indicator));else{for(var h=v,x=[];h.indicator==="./2";)x.push(h.args[0]),h=h.args[1];if(r.type.is_variable(h))c.throw_error(r.error.instantiation(_.indicator));else if(!r.type.is_term(h)||h.indicator!=="[]/0")c.throw_error(r.error.type("list",v,_.indicator));else{for(var T=[],b=new r.type.Term("[]",[]),C,N=0;N=0;N--)b=new r.type.Term(".",[T[N],b]);c.prepend([new r.type.State(w.goal.replace(new r.type.Term("=",[g,b])),w.substitution,w)])}}}}},p=["append/2","append/3","member/2","permutation/2","maplist/2","maplist/3","maplist/4","maplist/5","maplist/6","maplist/7","maplist/8","include/3","exclude/3","foldl/4","sum_list/2","max_list/2","min_list/2","prod_list/2","last/2","prefix/2","nth0/3","nth1/3","nth0/4","nth1/4","length/2","replicate/3","select/3","sort/2","msort/2","keysort/2","take/3","drop/3","reverse/2","list_to_set/2"];typeof _r!="undefined"?_r.exports=function(c){r=c,new r.type.Module("lists",u(),p)}:new r.type.Module("lists",u(),p)})(Ki)});var et=I(M=>{"use strict";var Ve=process.platform==="win32",wr="aes-256-cbc",ji="sha256",Br="The current environment doesn't support interactive reading from TTY.",z=require("fs"),Fr=process.binding("tty_wrap").TTY,gr=require("child_process"),_e=require("path"),dr={prompt:"> ",hideEchoBack:!1,mask:"*",limit:[],limitMessage:"Input another, please.$<( [)limit(])>",defaultInput:"",trueValue:[],falseValue:[],caseSensitive:!1,keepWhitespace:!1,encoding:"utf8",bufferSize:1024,print:void 0,history:!0,cd:!1,phContent:void 0,preCheck:void 0},fe="none",oe,Ce,zr=!1,we,Ke,vr,es=0,hr="",Se=[],je,Wr=!1,mr=!1,$e=!1;function Lr(r){function u(p){return p.replace(/[^\w\u0080-\uFFFF]/g,function(c){return"#"+c.charCodeAt(0)+";"})}return Ke.concat(function(p){var c=[];return Object.keys(p).forEach(function(w){p[w]==="boolean"?r[w]&&c.push("--"+w):p[w]==="string"&&r[w]&&c.push("--"+w,u(r[w]))}),c}({display:"string",displayOnly:"boolean",keyIn:"boolean",hideEchoBack:"boolean",mask:"string",limit:"string",caseSensitive:"boolean"}))}function rs(r,u){function p(j){var U,Ue="",Ze;for(vr=vr||require("os").tmpdir();;){U=_e.join(vr,j+Ue);try{Ze=z.openSync(U,"wx")}catch(Qe){if(Qe.code==="EEXIST"){Ue++;continue}else throw Qe}z.closeSync(Ze);break}return U}var c,w,_,v={},g,h,x=p("readline-sync.stdout"),T=p("readline-sync.stderr"),b=p("readline-sync.exit"),C=p("readline-sync.done"),N=require("crypto"),W,ee,te;W=N.createHash(ji),W.update(""+process.pid+es+++Math.random()),te=W.digest("hex"),ee=N.createDecipher(wr,te),c=Lr(r),Ve?(w=process.env.ComSpec||"cmd.exe",process.env.Q='"',_=["/V:ON","/S","/C","(%Q%"+w+"%Q% /V:ON /S /C %Q%%Q%"+we+"%Q%"+c.map(function(j){return" %Q%"+j+"%Q%"}).join("")+" & (echo !ERRORLEVEL!)>%Q%"+b+"%Q%%Q%) 2>%Q%"+T+"%Q% |%Q%"+process.execPath+"%Q% %Q%"+__dirname+"\\encrypt.js%Q% %Q%"+wr+"%Q% %Q%"+te+"%Q% >%Q%"+x+"%Q% & (echo 1)>%Q%"+C+"%Q%"]):(w="/bin/sh",_=["-c",'("'+we+'"'+c.map(function(j){return" '"+j.replace(/'/g,"'\\''")+"'"}).join("")+'; echo $?>"'+b+'") 2>"'+T+'" |"'+process.execPath+'" "'+__dirname+'/encrypt.js" "'+wr+'" "'+te+'" >"'+x+'"; echo 1 >"'+C+'"']),$e&&$e("_execFileSync",c);try{gr.spawn(w,_,u)}catch(j){v.error=new Error(j.message),v.error.method="_execFileSync - spawn",v.error.program=w,v.error.args=_}for(;z.readFileSync(C,{encoding:r.encoding}).trim()!=="1";);return(g=z.readFileSync(b,{encoding:r.encoding}).trim())==="0"?v.input=ee.update(z.readFileSync(x,{encoding:"binary"}),"hex",r.encoding)+ee.final(r.encoding):(h=z.readFileSync(T,{encoding:r.encoding}).trim(),v.error=new Error(Br+(h?` -`+h:"")),v.error.method="_execFileSync",v.error.program=w,v.error.args=_,v.error.extMessage=h,v.error.exitCode=+g),z.unlinkSync(x),z.unlinkSync(T),z.unlinkSync(b),z.unlinkSync(C),v}function ts(r){var u,p={},c,w={env:process.env,encoding:r.encoding};if(we||(Ve?process.env.PSModulePath?(we="powershell.exe",Ke=["-ExecutionPolicy","Bypass","-File",__dirname+"\\read.ps1"]):(we="cscript.exe",Ke=["//nologo",__dirname+"\\read.cs.js"]):(we="/bin/sh",Ke=[__dirname+"/read.sh"])),Ve&&!process.env.PSModulePath&&(w.stdio=[process.stdin]),gr.execFileSync){u=Lr(r),$e&&$e("execFileSync",u);try{p.input=gr.execFileSync(we,u,w)}catch(_){c=_.stderr?(_.stderr+"").trim():"",p.error=new Error(Br+(c?` -`+c:"")),p.error.method="execFileSync",p.error.program=we,p.error.args=u,p.error.extMessage=c,p.error.exitCode=_.status,p.error.code=_.code,p.error.signal=_.signal}}else p=rs(r,w);return p.error||(p.input=p.input.replace(/^\s*'|'\s*$/g,""),r.display=""),p}function br(r){var u="",p=r.display,c=!r.display&&r.keyIn&&r.hideEchoBack&&!r.mask;function w(){var _=ts(r);if(_.error)throw _.error;return _.input}return mr&&mr(r),function(){var _,v,g;function h(){return _||(_=process.binding("fs"),v=process.binding("constants")),_}if(typeof fe=="string")if(fe=null,Ve){if(g=function(x){var T=x.replace(/^\D+/,"").split("."),b=0;return(T[0]=+T[0])&&(b+=T[0]*1e4),(T[1]=+T[1])&&(b+=T[1]*100),(T[2]=+T[2])&&(b+=T[2]),b}(process.version),!(g>=20302&&g<40204||g>=5e4&&g<50100||g>=50600&&g<60200)&&process.stdin.isTTY)process.stdin.pause(),fe=process.stdin.fd,Ce=process.stdin._handle;else try{fe=h().open("CONIN$",v.O_RDWR,parseInt("0666",8)),Ce=new Fr(fe,!0)}catch(x){}if(process.stdout.isTTY)oe=process.stdout.fd;else{try{oe=z.openSync("\\\\.\\CON","w")}catch(x){}if(typeof oe!="number")try{oe=h().open("CONOUT$",v.O_RDWR,parseInt("0666",8))}catch(x){}}}else{if(process.stdin.isTTY){process.stdin.pause();try{fe=z.openSync("/dev/tty","r"),Ce=process.stdin._handle}catch(x){}}else try{fe=z.openSync("/dev/tty","r"),Ce=new Fr(fe,!1)}catch(x){}if(process.stdout.isTTY)oe=process.stdout.fd;else try{oe=z.openSync("/dev/tty","w")}catch(x){}}}(),function(){var _,v,g=!r.hideEchoBack&&!r.keyIn,h,x,T,b,C;je="";function N(W){return W===zr?!0:Ce.setRawMode(W)!==0?!1:(zr=W,!0)}if(Wr||!Ce||typeof oe!="number"&&(r.display||!g)){u=w();return}if(r.display&&(z.writeSync(oe,r.display),r.display=""),!r.displayOnly){if(!N(!g)){u=w();return}for(x=r.keyIn?1:r.bufferSize,h=Buffer.allocUnsafe&&Buffer.alloc?Buffer.alloc(x):new Buffer(x),r.keyIn&&r.limit&&(v=new RegExp("[^"+r.limit+"]","g"+(r.caseSensitive?"":"i")));;){T=0;try{T=z.readSync(fe,h,0,x)}catch(W){if(W.code!=="EOF"){N(!1),u+=w();return}}if(T>0?(b=h.toString(r.encoding,0,T),je+=b):(b=` -`,je+=String.fromCharCode(0)),b&&typeof(C=(b.match(/^(.*?)[\r\n]/)||[])[1])=="string"&&(b=C,_=!0),b&&(b=b.replace(/[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/g,"")),b&&v&&(b=b.replace(v,"")),b&&(g||(r.hideEchoBack?r.mask&&z.writeSync(oe,new Array(b.length+1).join(r.mask)):z.writeSync(oe,b)),u+=b),!r.keyIn&&_||r.keyIn&&u.length>=x)break}!g&&!c&&z.writeSync(oe,` -`),N(!1)}}(),r.print&&!c&&r.print(p+(r.displayOnly?"":(r.hideEchoBack?new Array(u.length+1).join(r.mask):u)+` -`),r.encoding),r.displayOnly?"":hr=r.keepWhitespace||r.keyIn?u:u.trim()}function ns(r,u){var p=[];function c(w){w!=null&&(Array.isArray(w)?w.forEach(c):(!u||u(w))&&p.push(w))}return c(r),p}function Tr(r){return r.replace(/[\x00-\x7f]/g,function(u){return"\\x"+("00"+u.charCodeAt().toString(16)).substr(-2)})}function Z(){var r=Array.prototype.slice.call(arguments),u,p;return r.length&&typeof r[0]=="boolean"&&(p=r.shift(),p&&(u=Object.keys(dr),r.unshift(dr))),r.reduce(function(c,w){return w==null||(w.hasOwnProperty("noEchoBack")&&!w.hasOwnProperty("hideEchoBack")&&(w.hideEchoBack=w.noEchoBack,delete w.noEchoBack),w.hasOwnProperty("noTrim")&&!w.hasOwnProperty("keepWhitespace")&&(w.keepWhitespace=w.noTrim,delete w.noTrim),p||(u=Object.keys(w)),u.forEach(function(_){var v;if(!!w.hasOwnProperty(_))switch(v=w[_],_){case"mask":case"limitMessage":case"defaultInput":case"encoding":v=v!=null?v+"":"",v&&_!=="limitMessage"&&(v=v.replace(/[\r\n]/g,"")),c[_]=v;break;case"bufferSize":!isNaN(v=parseInt(v,10))&&typeof v=="number"&&(c[_]=v);break;case"displayOnly":case"keyIn":case"hideEchoBack":case"caseSensitive":case"keepWhitespace":case"history":case"cd":c[_]=!!v;break;case"limit":case"trueValue":case"falseValue":c[_]=ns(v,function(g){var h=typeof g;return h==="string"||h==="number"||h==="function"||g instanceof RegExp}).map(function(g){return typeof g=="string"?g.replace(/[\r\n]/g,""):g});break;case"print":case"phContent":case"preCheck":c[_]=typeof v=="function"?v:void 0;break;case"prompt":case"display":c[_]=v!=null?v:"";break}})),c},{})}function xr(r,u,p){return u.some(function(c){var w=typeof c;return w==="string"?p?r===c:r.toLowerCase()===c.toLowerCase():w==="number"?parseFloat(r)===c:w==="function"?c(r):c instanceof RegExp?c.test(r):!1})}function Vr(r,u){var p=_e.normalize(Ve?(process.env.HOMEDRIVE||"")+(process.env.HOMEPATH||""):process.env.HOME||"").replace(/[\/\\]+$/,"");return r=_e.normalize(r),u?r.replace(/^~(?=\/|\\|$)/,p):r.replace(new RegExp("^"+Tr(p)+"(?=\\/|\\\\|$)",Ve?"i":""),"~")}function Oe(r,u){var p="(?:\\(([\\s\\S]*?)\\))?(\\w+|.-.)(?:\\(([\\s\\S]*?)\\))?",c=new RegExp("(\\$)?(\\$<"+p+">)","g"),w=new RegExp("(\\$)?(\\$\\{"+p+"\\})","g");function _(v,g,h,x,T,b){var C;return g||typeof(C=u(T))!="string"?h:C?(x||"")+C+(b||""):""}return r.replace(c,_).replace(w,_)}function Hr(r,u,p){var c,w=[],_=-1,v=0,g="",h;function x(T,b){return b.length>3?(T.push(b[0]+"..."+b[b.length-1]),h=!0):b.length&&(T=T.concat(b)),T}return c=r.reduce(function(T,b){return T.concat((b+"").split(""))},[]).reduce(function(T,b){var C,N;return u||(b=b.toLowerCase()),C=/^\d$/.test(b)?1:/^[A-Z]$/.test(b)?2:/^[a-z]$/.test(b)?3:0,p&&C===0?g+=b:(N=b.charCodeAt(0),C&&C===_&&N===v+1?w.push(b):(T=x(T,w),w=[b],_=C),v=N),T},[]),c=x(c,w),g&&(c.push(g),h=!0),{values:c,suppressed:h}}function Gr(r,u){return r.join(r.length>2?", ":u?" / ":"/")}function Yr(r,u){var p,c,w={},_;if(u.phContent&&(p=u.phContent(r,u)),typeof p!="string")switch(r){case"hideEchoBack":case"mask":case"defaultInput":case"caseSensitive":case"keepWhitespace":case"encoding":case"bufferSize":case"history":case"cd":p=u.hasOwnProperty(r)?typeof u[r]=="boolean"?u[r]?"on":"off":u[r]+"":"";break;case"limit":case"trueValue":case"falseValue":c=u[u.hasOwnProperty(r+"Src")?r+"Src":r],u.keyIn?(w=Hr(c,u.caseSensitive),c=w.values):c=c.filter(function(v){var g=typeof v;return g==="string"||g==="number"}),p=Gr(c,w.suppressed);break;case"limitCount":case"limitCountNotZero":p=u[u.hasOwnProperty("limitSrc")?"limitSrc":"limit"].length,p=p||r!=="limitCountNotZero"?p+"":"";break;case"lastInput":p=hr;break;case"cwd":case"CWD":case"cwdHome":p=process.cwd(),r==="CWD"?p=_e.basename(p):r==="cwdHome"&&(p=Vr(p));break;case"date":case"time":case"localeDate":case"localeTime":p=new Date()["to"+r.replace(/^./,function(v){return v.toUpperCase()})+"String"]();break;default:typeof(_=(r.match(/^history_m(\d+)$/)||[])[1])=="string"&&(p=Se[Se.length-_]||"")}return p}function Ur(r){var u=/^(.)-(.)$/.exec(r),p="",c,w,_,v;if(!u)return null;for(c=u[1].charCodeAt(0),w=u[2].charCodeAt(0),v=c -And the length must be: $`,trueValue:null,falseValue:null,caseSensitive:!0},u,{history:!1,cd:!1,phContent:function(N){return N==="charlist"?p.text:N==="length"?c+"..."+w:null}}),v,g,h,x,T,b,C;for(u=u||{},v=Oe(u.charlist?u.charlist+"":"$",Ur),(isNaN(c=parseInt(u.min,10))||typeof c!="number")&&(c=12),(isNaN(w=parseInt(u.max,10))||typeof w!="number")&&(w=24),x=new RegExp("^["+Tr(v)+"]{"+c+","+w+"}$"),p=Hr([v],_.caseSensitive,!0),p.text=Gr(p.values,p.suppressed),g=u.confirmMessage!=null?u.confirmMessage:"Reinput a same one to confirm it: ",h=u.unmatchMessage!=null?u.unmatchMessage:"It differs from first one. Hit only the Enter key if you want to retry from first one.",r==null&&(r="Input new password: "),T=_.limitMessage;!C;)_.limit=x,_.limitMessage=T,b=M.question(r,_),_.limit=[b,""],_.limitMessage=h,C=M.question(g,_);return b};function Jr(r,u,p){var c;function w(_){return c=p(_),!isNaN(c)&&typeof c=="number"}return M.question(r,Z({limitMessage:"Input valid number, please."},u,{limit:w,cd:!1})),c}M.questionInt=function(r,u){return Jr(r,u,function(p){return parseInt(p,10)})};M.questionFloat=function(r,u){return Jr(r,u,parseFloat)};M.questionPath=function(r,u){var p,c="",w=Z({hideEchoBack:!1,limitMessage:`$Input valid path, please.$<( Min:)min>$<( Max:)max>`,history:!0,cd:!0},u,{keepWhitespace:!1,limit:function(_){var v,g,h;_=Vr(_,!0),c="";function x(T){T.split(/\/|\\/).reduce(function(b,C){var N=_e.resolve(b+=C+_e.sep);if(!z.existsSync(N))z.mkdirSync(N);else if(!z.statSync(N).isDirectory())throw new Error("Non directory already exists: "+N);return b},"")}try{if(v=z.existsSync(_),p=v?z.realpathSync(_):_e.resolve(_),!u.hasOwnProperty("exists")&&!v||typeof u.exists=="boolean"&&u.exists!==v)return c=(v?"Already exists":"No such file or directory")+": "+p,!1;if(!v&&u.create&&(u.isDirectory?x(p):(x(_e.dirname(p)),z.closeSync(z.openSync(p,"w"))),p=z.realpathSync(p)),v&&(u.min||u.max||u.isFile||u.isDirectory)){if(g=z.statSync(p),u.isFile&&!g.isFile())return c="Not file: "+p,!1;if(u.isDirectory&&!g.isDirectory())return c="Not directory: "+p,!1;if(u.min&&g.size<+u.min||u.max&&g.size>+u.max)return c="Size "+g.size+" is out of range: "+p,!1}if(typeof u.validate=="function"&&(h=u.validate(p))!==!0)return typeof h=="string"&&(c=h),!1}catch(T){return c=T+"",!1}return!0},phContent:function(_){return _==="error"?c:_!=="min"&&_!=="max"?null:u.hasOwnProperty(_)?u[_]+"":""}});return u=u||{},r==null&&(r='Input path (you can "cd" and "pwd"): '),M.question(r,w),p};function Kr(r,u){var p={},c={};return typeof r=="object"?(Object.keys(r).forEach(function(w){typeof r[w]=="function"&&(c[u.caseSensitive?w:w.toLowerCase()]=r[w])}),p.preCheck=function(w){var _;return p.args=Sr(w),_=p.args[0]||"",u.caseSensitive||(_=_.toLowerCase()),p.hRes=_!=="_"&&c.hasOwnProperty(_)?c[_].apply(w,p.args.slice(1)):c.hasOwnProperty("_")?c._.apply(w,p.args):null,{res:w,forceNext:!1}},c.hasOwnProperty("_")||(p.limit=function(){var w=p.args[0]||"";return u.caseSensitive||(w=w.toLowerCase()),c.hasOwnProperty(w)})):p.preCheck=function(w){return p.args=Sr(w),p.hRes=typeof r=="function"?r.apply(w,p.args):!0,{res:w,forceNext:!1}},p}M.promptCL=function(r,u){var p=Z({hideEchoBack:!1,limitMessage:"Requested command is not available.",caseSensitive:!1,history:!0},u),c=Kr(r,p);return p.limit=c.limit,p.preCheck=c.preCheck,M.prompt(p),c.args};M.promptLoop=function(r,u){for(var p=Z({hideEchoBack:!1,trueValue:null,falseValue:null,caseSensitive:!1,history:!0},u);!r(M.prompt(p)););};M.promptCLLoop=function(r,u){var p=Z({hideEchoBack:!1,limitMessage:"Requested command is not available.",caseSensitive:!1,history:!0},u),c=Kr(r,p);for(p.limit=c.limit,p.preCheck=c.preCheck;M.prompt(p),!c.hRes;);};M.promptSimShell=function(r){return M.prompt(Z({hideEchoBack:!1,history:!0},r,{prompt:function(){return Ve?"$>":(process.env.USER||"")+(process.env.HOSTNAME?"@"+process.env.HOSTNAME.replace(/\..*$/,""):"")+":$$ "}()}))};function jr(r,u,p){var c;return r==null&&(r="Are you sure? "),(!u||u.guide!==!1)&&(r+="")&&(r=r.replace(/\s*:?\s*$/,"")+" [y/n]: "),c=M.keyIn(r,Z(u,{hideEchoBack:!1,limit:p,trueValue:"y",falseValue:"n",caseSensitive:!1})),typeof c=="boolean"?c:""}M.keyInYN=function(r,u){return jr(r,u)};M.keyInYNStrict=function(r,u){return jr(r,u,"yn")};M.keyInPause=function(r,u){r==null&&(r="Continue..."),(!u||u.guide!==!1)&&(r+="")&&(r=r.replace(/\s+$/,"")+" (Hit any key)"),M.keyIn(r,Z({limit:null},u,{hideEchoBack:!0,mask:""}))};M.keyInSelect=function(r,u,p){var c=Z({hideEchoBack:!1},p,{trueValue:null,falseValue:null,caseSensitive:!1,phContent:function(h){return h==="itemsCount"?r.length+"":h==="firstItem"?(r[0]+"").trim():h==="lastItem"?(r[r.length-1]+"").trim():null}}),w="",_={},v=49,g=` -`;if(!Array.isArray(r)||!r.length||r.length>35)throw"`items` must be Array (max length: 35).";return r.forEach(function(h,x){var T=String.fromCharCode(v);w+=T,_[T]=x,g+="["+T+"] "+(h+"").trim()+` -`,v=v===57?97:v+1}),(!p||p.cancel!==!1)&&(w+="0",_["0"]=-1,g+="[0] "+(p&&p.cancel!=null&&typeof p.cancel!="boolean"?(p.cancel+"").trim():"CANCEL")+` -`),c.limit=w,g+=` -`,u==null&&(u="Choose one from list: "),(u+="")&&((!p||p.guide!==!1)&&(u=u.replace(/\s*:?\s*$/,"")+" [$]: "),g+=u),_[M.keyIn(g,c).toLowerCase()]};M.getRawInput=function(){return je};function De(r,u){var p;return u.length&&(p={},p[r]=u[0]),M.setDefaultOptions(p)[r]}M.setPrint=function(){return De("print",arguments)};M.setPrompt=function(){return De("prompt",arguments)};M.setEncoding=function(){return De("encoding",arguments)};M.setMask=function(){return De("mask",arguments)};M.setBufferSize=function(){return De("bufferSize",arguments)}});var kr=I((Mu,ie)=>{(function(){var r={major:0,minor:2,patch:66,status:"beta"};tau_file_system={files:{},open:function(e,n,t){var s=tau_file_system.files[e];if(!s){if(t==="read")return null;s={path:e,text:"",type:n,get:function(a,l){return l===this.text.length||l>this.text.length?"end_of_file":this.text.substring(l,l+a)},put:function(a,l){return l==="end_of_file"?(this.text+=a,!0):l==="past_end_of_file"?null:(this.text=this.text.substring(0,l)+a+this.text.substring(l+a.length),!0)},get_byte:function(a){if(a==="end_of_stream")return-1;var l=Math.floor(a/2);if(this.text.length<=l)return-1;var f=_(this.text[Math.floor(a/2)],0);return a%2==0?f&255:f/256>>>0},put_byte:function(a,l){var f=l==="end_of_stream"?this.text.length:Math.floor(l/2);if(this.text.length>>0,y=(y&255)<<8|a&255):(y=y&255,y=(a&255)<<8|y&255),this.text.length===f?this.text+=v(y):this.text=this.text.substring(0,f)+v(y)+this.text.substring(f+1),!0},flush:function(){return!0},close:function(){var a=tau_file_system.files[this.path];return a?!0:null}},tau_file_system.files[e]=s}return t==="write"&&(s.text=""),s}},tau_user_input={buffer:"",get:function(e,n){for(var t;tau_user_input.buffer.length\?\@\^\~\\]+|'(?:[^']*?(?:\\(?:x?\d+)?\\)*(?:'')*(?:\\')*)*')/,number:/^(?:0o[0-7]+|0x[0-9a-fA-F]+|0b[01]+|0'(?:''|\\[abfnrtv\\'"`]|\\x?\d+\\|[^\\])|\d+(?:\.\d+(?:[eE][+-]?\d+)?)?)/,string:/^(?:"([^"]|""|\\")*"|`([^`]|``|\\`)*`)/,l_brace:/^(?:\[)/,r_brace:/^(?:\])/,l_bracket:/^(?:\{)/,r_bracket:/^(?:\})/,bar:/^(?:\|)/,l_paren:/^(?:\()/,r_paren:/^(?:\))/};function te(e,n){return e.get_flag("char_conversion").id==="on"?n.replace(/./g,function(t){return e.get_char_conversion(t)}):n}function j(e){this.thread=e,this.text="",this.tokens=[]}j.prototype.set_last_tokens=function(e){return this.tokens=e},j.prototype.new_text=function(e){this.text=e,this.tokens=[]},j.prototype.get_tokens=function(e){var n,t=0,s=0,a=0,l=[],f=!1;if(e){var y=this.tokens[e-1];t=y.len,n=te(this.thread,this.text.substr(y.len)),s=y.line,a=y.start}else n=this.text;if(/^\s*$/.test(n))return null;for(;n!=="";){var d=[],m=!1;if(/^\n/.exec(n)!==null){s++,a=0,t++,n=n.replace(/\n/,""),f=!0;continue}for(var S in ee)if(ee.hasOwnProperty(S)){var P=ee[S].exec(n);P&&d.push({value:P[0],name:S,matches:P})}if(!d.length)return this.set_last_tokens([{value:n,matches:[],name:"lexical",line:s,start:a}]);var y=p(d,function(B,q){return B.value.length>=q.value.length?B:q});switch(y.start=a,y.line=s,n=n.replace(y.value,""),a+=y.value.length,t+=y.value.length,y.name){case"atom":y.raw=y.value,y.value.charAt(0)==="'"&&(y.value=C(y.value.substr(1,y.value.length-2),"'"),y.value===null&&(y.name="lexical",y.value="unknown escape sequence"));break;case"number":y.float=y.value.substring(0,2)!=="0x"&&y.value.match(/[.eE]/)!==null&&y.value!=="0'.",y.value=W(y.value),y.blank=m;break;case"string":var A=y.value.charAt(0);y.value=C(y.value.substr(1,y.value.length-2),A),y.value===null&&(y.name="lexical",y.value="unknown escape sequence");break;case"whitespace":var R=l[l.length-1];R&&(R.space=!0),m=!0;continue;case"r_bracket":l.length>0&&l[l.length-1].name==="l_bracket"&&(y=l.pop(),y.name="atom",y.value="{}",y.raw="{}",y.space=!1);break;case"r_brace":l.length>0&&l[l.length-1].name==="l_brace"&&(y=l.pop(),y.name="atom",y.value="[]",y.raw="[]",y.space=!1);break}y.len=t,l.push(y),m=!1}var k=this.set_last_tokens(l);return k.length===0?null:k};function U(e,n,t,s,a){if(!n[t])return{type:g,value:i.error.syntax(n[t-1],"expression expected",!0)};var l;if(s==="0"){var f=n[t];switch(f.name){case"number":return{type:h,len:t+1,value:new i.type.Num(f.value,f.float)};case"variable":return{type:h,len:t+1,value:new i.type.Var(f.value)};case"string":var y;switch(e.get_flag("double_quotes").id){case"atom":y=new o(f.value,[]);break;case"codes":y=new o("[]",[]);for(var d=f.value.length-1;d>=0;d--)y=new o(".",[new i.type.Num(_(f.value,d),!1),y]);break;case"chars":y=new o("[]",[]);for(var d=f.value.length-1;d>=0;d--)y=new o(".",[new i.type.Term(f.value.charAt(d),[]),y]);break}return{type:h,len:t+1,value:y};case"l_paren":var k=U(e,n,t+1,e.__get_max_priority(),!0);return k.type!==h?k:n[k.len]&&n[k.len].name==="r_paren"?(k.len++,k):{type:g,derived:!0,value:i.error.syntax(n[k.len]?n[k.len]:n[k.len-1],") or operator expected",!n[k.len])};case"l_bracket":var k=U(e,n,t+1,e.__get_max_priority(),!0);return k.type!==h?k:n[k.len]&&n[k.len].name==="r_bracket"?(k.len++,k.value=new o("{}",[k.value]),k):{type:g,derived:!0,value:i.error.syntax(n[k.len]?n[k.len]:n[k.len-1],"} or operator expected",!n[k.len])}}var m=Ue(e,n,t,a);return m.type===h||m.derived||(m=Ze(e,n,t),m.type===h||m.derived)?m:{type:g,derived:!1,value:i.error.syntax(n[t],"unexpected token")}}var S=e.__get_max_priority(),P=e.__get_next_priority(s),A=t;if(n[t].name==="atom"&&n[t+1]&&(n[t].space||n[t+1].name!=="l_paren")){var f=n[t++],R=e.__lookup_operator_classes(s,f.value);if(R&&R.indexOf("fy")>-1){var k=U(e,n,t,s,a);if(k.type!==g)return f.value==="-"&&!f.space&&i.type.is_number(k.value)?{value:new i.type.Num(-k.value.value,k.value.is_float),len:k.len,type:h}:{value:new i.type.Term(f.value,[k.value]),len:k.len,type:h};l=k}else if(R&&R.indexOf("fx")>-1){var k=U(e,n,t,P,a);if(k.type!==g)return{value:new i.type.Term(f.value,[k.value]),len:k.len,type:h};l=k}}t=A;var k=U(e,n,t,P,a);if(k.type===h){t=k.len;var f=n[t];if(n[t]&&(n[t].name==="atom"&&e.__lookup_operator_classes(s,f.value)||n[t].name==="bar"&&e.__lookup_operator_classes(s,"|"))){var L=P,B=s,R=e.__lookup_operator_classes(s,f.value);if(R.indexOf("xf")>-1)return{value:new i.type.Term(f.value,[k.value]),len:++k.len,type:h};if(R.indexOf("xfx")>-1){var q=U(e,n,t+1,L,a);return q.type===h?{value:new i.type.Term(f.value,[k.value,q.value]),len:q.len,type:h}:(q.derived=!0,q)}else if(R.indexOf("xfy")>-1){var q=U(e,n,t+1,B,a);return q.type===h?{value:new i.type.Term(f.value,[k.value,q.value]),len:q.len,type:h}:(q.derived=!0,q)}else if(k.type!==g)for(;;){t=k.len;var f=n[t];if(f&&f.name==="atom"&&e.__lookup_operator_classes(s,f.value)){var R=e.__lookup_operator_classes(s,f.value);if(R.indexOf("yf")>-1)k={value:new i.type.Term(f.value,[k.value]),len:++t,type:h};else if(R.indexOf("yfx")>-1){var q=U(e,n,++t,L,a);if(q.type===g)return q.derived=!0,q;t=q.len,k={value:new i.type.Term(f.value,[k.value,q.value]),len:t,type:h}}else break}else break}}else l={type:g,value:i.error.syntax(n[k.len-1],"operator expected")};return k}return k}function Ue(e,n,t,s){if(!n[t]||n[t].name==="atom"&&n[t].raw==="."&&!s&&(n[t].space||!n[t+1]||n[t+1].name!=="l_paren"))return{type:g,derived:!1,value:i.error.syntax(n[t-1],"unfounded token")};var a=n[t],l=[];if(n[t].name==="atom"&&n[t].raw!==","){if(t++,n[t-1].space)return{type:h,len:t,value:new i.type.Term(a.value,l)};if(n[t]&&n[t].name==="l_paren"){if(n[t+1]&&n[t+1].name==="r_paren")return{type:g,derived:!0,value:i.error.syntax(n[t+1],"argument expected")};var f=U(e,n,++t,"999",!0);if(f.type===g)return f.derived?f:{type:g,derived:!0,value:i.error.syntax(n[t]?n[t]:n[t-1],"argument expected",!n[t])};for(l.push(f.value),t=f.len;n[t]&&n[t].name==="atom"&&n[t].value===",";){if(f=U(e,n,t+1,"999",!0),f.type===g)return f.derived?f:{type:g,derived:!0,value:i.error.syntax(n[t+1]?n[t+1]:n[t],"argument expected",!n[t+1])};l.push(f.value),t=f.len}if(n[t]&&n[t].name==="r_paren")t++;else return{type:g,derived:!0,value:i.error.syntax(n[t]?n[t]:n[t-1],", or ) expected",!n[t])}}return{type:h,len:t,value:new i.type.Term(a.value,l)}}return{type:g,derived:!1,value:i.error.syntax(n[t],"term expected")}}function Ze(e,n,t){if(!n[t])return{type:g,derived:!1,value:i.error.syntax(n[t-1],"[ expected")};if(n[t]&&n[t].name==="l_brace"){var s=U(e,n,++t,"999",!0),a=[s.value],l=void 0;if(s.type===g)return n[t]&&n[t].name==="r_brace"?{type:h,len:t+1,value:new i.type.Term("[]",[])}:{type:g,derived:!0,value:i.error.syntax(n[t],"] expected")};for(t=s.len;n[t]&&n[t].name==="atom"&&n[t].value===",";){if(s=U(e,n,t+1,"999",!0),s.type===g)return s.derived?s:{type:g,derived:!0,value:i.error.syntax(n[t+1]?n[t+1]:n[t],"argument expected",!n[t+1])};a.push(s.value),t=s.len}var f=!1;if(n[t]&&n[t].name==="bar"){if(f=!0,s=U(e,n,t+1,"999",!0),s.type===g)return s.derived?s:{type:g,derived:!0,value:i.error.syntax(n[t+1]?n[t+1]:n[t],"argument expected",!n[t+1])};l=s.value,t=s.len}return n[t]&&n[t].name==="r_brace"?{type:h,len:t+1,value:he(a,l)}:{type:g,derived:!0,value:i.error.syntax(n[t]?n[t]:n[t-1],f?"] expected":", or | or ] expected",!n[t])}}return{type:g,derived:!1,value:i.error.syntax(n[t],"list expected")}}function Qe(e,n,t){var s=n[t].line,a=U(e,n,t,e.__get_max_priority(),!1),l=null,f;if(a.type!==g)if(t=a.len,n[t]&&n[t].name==="atom"&&n[t].raw===".")if(t++,i.type.is_term(a.value)){if(a.value.indicator===":-/2"?(l=new i.type.Rule(a.value.args[0],ve(a.value.args[1])),f={value:l,len:t,type:h}):a.value.indicator==="-->/2"?(l=Bi(new i.type.Rule(a.value.args[0],a.value.args[1]),e),l.body=ve(l.body),f={value:l,len:t,type:i.type.is_rule(l)?h:g}):(l=new i.type.Rule(a.value,null),f={value:l,len:t,type:h}),l){var y=l.singleton_variables();y.length>0&&e.throw_warning(i.warning.singleton(y,l.head.indicator,s))}return f}else return{type:g,value:i.error.syntax(n[t],"callable expected")};else return{type:g,value:i.error.syntax(n[t]?n[t]:n[t-1],". or operator expected")};return a}function Di(e,n,t){t=t||{},t.from=t.from?t.from:"$tau-js",t.reconsult=t.reconsult!==void 0?t.reconsult:!0;var s=new j(e),a={},l;s.new_text(n);var f=0,y=s.get_tokens(f);do{if(y===null||!y[f])break;var d=Qe(e,y,f);if(d.type===g)return new o("throw",[d.value]);if(d.value.body===null&&d.value.head.indicator==="?-/1"){var m=new X(e.session);m.add_goal(d.value.head.args[0]),m.answer(function(P){i.type.is_error(P)?e.throw_warning(P.args[0]):(P===!1||P===null)&&e.throw_warning(i.warning.failed_goal(d.value.head.args[0],d.len))}),f=d.len;var S=!0}else if(d.value.body===null&&d.value.head.indicator===":-/1"){var S=e.run_directive(d.value.head.args[0]);f=d.len,d.value.head.args[0].indicator==="char_conversion/2"&&(y=s.get_tokens(f),f=0)}else{l=d.value.head.indicator,t.reconsult!==!1&&a[l]!==!0&&!e.is_multifile_predicate(l)&&(e.session.rules[l]=w(e.session.rules[l]||[],function(A){return A.dynamic}),a[l]=!0);var S=e.add_rule(d.value,t);f=d.len}if(!S)return S}while(!0);return!0}function Xi(e,n){var t=new j(e);t.new_text(n);var s=0;do{var a=t.get_tokens(s);if(a===null)break;var l=U(e,a,0,e.__get_max_priority(),!1);if(l.type!==g){var f=l.len,y=f;if(a[f]&&a[f].name==="atom"&&a[f].raw===".")e.add_goal(ve(l.value));else{var d=a[f];return new o("throw",[i.error.syntax(d||a[f-1],". or operator expected",!d)])}s=l.len+1}else return new o("throw",[l.value])}while(!0);return!0}function Bi(e,n){e=e.rename(n);var t=n.next_free_variable(),s=pr(e.body,t,n);return s.error?s.value:(e.body=s.value,e.head.args=e.head.args.concat([t,s.variable]),e.head=new o(e.head.id,e.head.args),e)}function pr(e,n,t){var s;if(i.type.is_term(e)&&e.indicator==="!/0")return{value:e,variable:n,error:!1};if(i.type.is_term(e)&&e.indicator===",/2"){var a=pr(e.args[0],n,t);if(a.error)return a;var l=pr(e.args[1],a.variable,t);return l.error?l:{value:new o(",",[a.value,l.value]),variable:l.variable,error:!1}}else{if(i.type.is_term(e)&&e.indicator==="{}/1")return{value:e.args[0],variable:n,error:!1};if(i.type.is_empty_list(e))return{value:new o("true",[]),variable:n,error:!1};if(i.type.is_list(e)){s=t.next_free_variable();for(var f=e,y;f.indicator==="./2";)y=f,f=f.args[1];return i.type.is_variable(f)?{value:i.error.instantiation("DCG"),variable:n,error:!0}:i.type.is_empty_list(f)?(y.args[1]=s,{value:new o("=",[n,e]),variable:s,error:!1}):{value:i.error.type("list",e,"DCG"),variable:n,error:!0}}else return i.type.is_callable(e)?(s=t.next_free_variable(),e.args=e.args.concat([n,s]),e=new o(e.id,e.args),{value:e,variable:s,error:!1}):{value:i.error.type("callable",e,"DCG"),variable:n,error:!0}}}function ve(e){return i.type.is_variable(e)?new o("call",[e]):i.type.is_term(e)&&[",/2",";/2","->/2"].indexOf(e.indicator)!==-1?new o(e.id,[ve(e.args[0]),ve(e.args[1])]):e}function he(e,n){for(var t=n||new i.type.Term("[]",[]),s=e.length-1;s>=0;s--)t=new i.type.Term(".",[e[s],t]);return t}function Fi(e,n){for(var t=e.length-1;t>=0;t--)e[t]===n&&e.splice(t,1)}function yr(e){for(var n={},t=[],s=0;s=0;n--)if(e.charAt(n)==="/")return new o("/",[new o(e.substring(0,n)),new E(parseInt(e.substring(n+1)),!1)])}function O(e){this.id=e}function E(e,n){this.is_float=n!==void 0?n:parseInt(e)!==e,this.value=this.is_float?e:parseInt(e)}var $r=0;function o(e,n,t){this.ref=t||++$r,this.id=e,this.args=n||[],this.indicator=e+"/"+this.args.length}var Wi=0;function ne(e,n,t,s,a,l){this.id=Wi++,this.stream=e,this.mode=n,this.alias=t,this.type=s!==void 0?s:"text",this.reposition=a!==void 0?a:!0,this.eof_action=l!==void 0?l:"eof_code",this.position=this.mode==="append"?"end_of_stream":0,this.output=this.mode==="write"||this.mode==="append",this.input=this.mode==="read"}function Y(e){e=e||{},this.links=e}function V(e,n,t){n=n||new Y,t=t||null,this.goal=e,this.substitution=n,this.parent=t}function Q(e,n,t){this.head=e,this.body=n,this.dynamic=t||!1}function D(e){e=e===void 0||e<=0?1e3:e,this.rules={},this.src_predicates={},this.rename=0,this.modules=[],this.thread=new X(this),this.total_threads=1,this.renamed_variables={},this.public_predicates={},this.multifile_predicates={},this.limit=e,this.streams={user_input:new ne(typeof ie!="undefined"&&ie.exports?nodejs_user_input:tau_user_input,"read","user_input","text",!1,"reset"),user_output:new ne(typeof ie!="undefined"&&ie.exports?nodejs_user_output:tau_user_output,"write","user_output","text",!1,"eof_code")},this.file_system=typeof ie!="undefined"&&ie.exports?nodejs_file_system:tau_file_system,this.standard_input=this.streams.user_input,this.standard_output=this.streams.user_output,this.current_input=this.streams.user_input,this.current_output=this.streams.user_output,this.format_success=function(n){return n.substitution},this.format_error=function(n){return n.goal},this.flag={bounded:i.flag.bounded.value,max_integer:i.flag.max_integer.value,min_integer:i.flag.min_integer.value,integer_rounding_function:i.flag.integer_rounding_function.value,char_conversion:i.flag.char_conversion.value,debug:i.flag.debug.value,max_arity:i.flag.max_arity.value,unknown:i.flag.unknown.value,double_quotes:i.flag.double_quotes.value,occurs_check:i.flag.occurs_check.value,dialect:i.flag.dialect.value,version_data:i.flag.version_data.value,nodejs:i.flag.nodejs.value},this.__loaded_modules=[],this.__char_conversion={},this.__operators={1200:{":-":["fx","xfx"],"-->":["xfx"],"?-":["fx"]},1100:{";":["xfy"]},1050:{"->":["xfy"]},1e3:{",":["xfy"]},900:{"\\+":["fy"]},700:{"=":["xfx"],"\\=":["xfx"],"==":["xfx"],"\\==":["xfx"],"@<":["xfx"],"@=<":["xfx"],"@>":["xfx"],"@>=":["xfx"],"=..":["xfx"],is:["xfx"],"=:=":["xfx"],"=\\=":["xfx"],"<":["xfx"],"=<":["xfx"],">":["xfx"],">=":["xfx"]},600:{":":["xfy"]},500:{"+":["yfx"],"-":["yfx"],"/\\":["yfx"],"\\/":["yfx"]},400:{"*":["yfx"],"/":["yfx"],"//":["yfx"],rem:["yfx"],mod:["yfx"],"<<":["yfx"],">>":["yfx"]},200:{"**":["xfx"],"^":["xfy"],"-":["fy"],"+":["fy"],"\\":["fy"]}}}function X(e){this.epoch=Date.now(),this.session=e,this.session.total_threads++,this.total_steps=0,this.cpu_time=0,this.cpu_time_last=0,this.points=[],this.debugger=!1,this.debugger_states=[],this.level="top_level/0",this.__calls=[],this.current_limit=this.session.limit,this.warnings=[]}function Dr(e,n,t){this.id=e,this.rules=n,this.exports=t,i.module[e]=this}Dr.prototype.exports_predicate=function(e){return this.exports.indexOf(e)!==-1},O.prototype.unify=function(e,n){if(n&&u(e.variables(),this.id)!==-1&&!i.type.is_variable(e))return null;var t={};return t[this.id]=e,new Y(t)},E.prototype.unify=function(e,n){return i.type.is_number(e)&&this.value===e.value&&this.is_float===e.is_float?new Y:null},o.prototype.unify=function(e,n){if(i.type.is_term(e)&&this.indicator===e.indicator){for(var t=new Y,s=0;s=0){var s=this.args[0].value,a=Math.floor(s/26),l=s%26;return"ABCDEFGHIJKLMNOPQRSTUVWXYZ"[l]+(a!==0?a:"")}switch(this.indicator){case"[]/0":case"{}/0":case"!/0":return this.id;case"{}/1":return"{"+this.args[0].toString(e)+"}";case"./2":for(var f="["+this.args[0].toString(e),y=this.args[1];y.indicator==="./2";)f+=", "+y.args[0].toString(e),y=y.args[1];return y.indicator!=="[]/0"&&(f+="|"+y.toString(e)),f+="]",f;case",/2":return"("+this.args[0].toString(e)+", "+this.args[1].toString(e)+")";default:var d=this.id,m=e.session?e.session.lookup_operator(this.id,this.args.length):null;if(e.session===void 0||e.ignore_ops||m===null)return e.quoted&&!/^(!|,|;|[a-z][0-9a-zA-Z_]*)$/.test(d)&&d!=="{}"&&d!=="[]"&&(d="'"+N(d)+"'"),d+(this.args.length?"("+c(this.args,function(R){return R.toString(e)}).join(", ")+")":"");var S=m.priority>n.priority||m.priority===n.priority&&(m.class==="xfy"&&this.indicator!==n.indicator||m.class==="yfx"&&this.indicator!==n.indicator||this.indicator===n.indicator&&m.class==="yfx"&&t==="right"||this.indicator===n.indicator&&m.class==="xfy"&&t==="left");m.indicator=this.indicator;var P=S?"(":"",A=S?")":"";return this.args.length===0?"("+this.id+")":["fy","fx"].indexOf(m.class)!==-1?P+d+" "+this.args[0].toString(e,m)+A:["yf","xf"].indexOf(m.class)!==-1?P+this.args[0].toString(e,m)+" "+d+A:P+this.args[0].toString(e,m,"left")+" "+this.id+" "+this.args[1].toString(e,m,"right")+A}},ne.prototype.toString=function(e){return"("+this.id+")"},Y.prototype.toString=function(e){var n="{";for(var t in this.links)!this.links.hasOwnProperty(t)||(n!=="{"&&(n+=", "),n+=t+"/"+this.links[t].toString(e));return n+="}",n},V.prototype.toString=function(e){return this.goal===null?"<"+this.substitution.toString(e)+">":"<"+this.goal.toString(e)+", "+this.substitution.toString(e)+">"},Q.prototype.toString=function(e){return this.body?this.head.toString(e)+" :- "+this.body.toString(e)+".":this.head.toString(e)+"."},D.prototype.toString=function(e){for(var n="",t=0;t=0;a--)s=new o(".",[n[a],s]);return s}return new o(this.id,c(this.args,function(l){return l.apply(e)}),this.ref)},ne.prototype.apply=function(e){return this},Q.prototype.apply=function(e){return new Q(this.head.apply(e),this.body!==null?this.body.apply(e):null)},Y.prototype.apply=function(e){var n,t={};for(n in this.links)!this.links.hasOwnProperty(n)||(t[n]=this.links[n].apply(e));return new Y(t)},o.prototype.select=function(){for(var e=this;e.indicator===",/2";)e=e.args[0];return e},o.prototype.replace=function(e){return this.indicator===",/2"?this.args[0].indicator===",/2"?new o(",",[this.args[0].replace(e),this.args[1]]):e===null?this.args[1]:new o(",",[e,this.args[1]]):e},o.prototype.search=function(e){if(i.type.is_term(e)&&e.ref!==void 0&&this.ref===e.ref)return!0;for(var n=0;nn&&s0&&(n=this.head_point().substitution.domain());u(n,i.format_variable(this.session.rename))!==-1;)this.session.rename++;if(e.id==="_")return new O(i.format_variable(this.session.rename));this.session.renamed_variables[e.id]=i.format_variable(this.session.rename)}return new O(this.session.renamed_variables[e.id])},D.prototype.next_free_variable=function(){return this.thread.next_free_variable()},X.prototype.next_free_variable=function(){this.session.rename++;var e=[];for(this.points.length>0&&(e=this.head_point().substitution.domain());u(e,i.format_variable(this.session.rename))!==-1;)this.session.rename++;return new O(i.format_variable(this.session.rename))},D.prototype.is_public_predicate=function(e){return!this.public_predicates.hasOwnProperty(e)||this.public_predicates[e]===!0},X.prototype.is_public_predicate=function(e){return this.session.is_public_predicate(e)},D.prototype.is_multifile_predicate=function(e){return this.multifile_predicates.hasOwnProperty(e)&&this.multifile_predicates[e]===!0},X.prototype.is_multifile_predicate=function(e){return this.session.is_multifile_predicate(e)},D.prototype.prepend=function(e){return this.thread.prepend(e)},X.prototype.prepend=function(e){for(var n=e.length-1;n>=0;n--)this.points.push(e[n])},D.prototype.success=function(e,n){return this.thread.success(e,n)},X.prototype.success=function(e,n){var n=typeof n=="undefined"?e:n;this.prepend([new V(e.goal.replace(null),e.substitution,n)])},D.prototype.throw_error=function(e){return this.thread.throw_error(e)},X.prototype.throw_error=function(e){this.prepend([new V(new o("throw",[e]),new Y,null,null)])},D.prototype.step_rule=function(e,n){return this.thread.step_rule(e,n)},X.prototype.step_rule=function(e,n){var t=n.indicator;if(e==="user"&&(e=null),e===null&&this.session.rules.hasOwnProperty(t))return this.session.rules[t];for(var s=e===null?this.session.modules:u(this.session.modules,e)===-1?[]:[e],a=0;a1)&&this.again()},D.prototype.answers=function(e,n,t){return this.thread.answers(e,n,t)},X.prototype.answers=function(e,n,t){var s=n||1e3,a=this;if(n<=0){t&&t();return}this.answer(function(l){e(l),l!==!1?setTimeout(function(){a.answers(e,n-1,t)},1):t&&t()})},D.prototype.again=function(e){return this.thread.again(e)},X.prototype.again=function(e){for(var n,t=Date.now();this.__calls.length>0;){for(this.warnings=[],e!==!1&&(this.current_limit=this.session.limit);this.current_limit>0&&this.points.length>0&&this.head_point().goal!==null&&!i.type.is_error(this.head_point().goal);)if(this.current_limit--,this.step()===!0)return;var s=Date.now();this.cpu_time_last=s-t,this.cpu_time+=this.cpu_time_last;var a=this.__calls.shift();this.current_limit<=0?a(null):this.points.length===0?a(!1):i.type.is_error(this.head_point().goal)?(n=this.session.format_error(this.points.pop()),this.points=[],a(n)):(this.debugger&&this.debugger_states.push(this.head_point()),n=this.session.format_success(this.points.pop()),a(n))}},D.prototype.unfold=function(e){if(e.body===null)return!1;var n=e.head,t=e.body,s=t.select(),a=new X(this),l=[];a.add_goal(s),a.step();for(var f=a.points.length-1;f>=0;f--){var y=a.points[f],d=n.apply(y.substitution),m=t.replace(y.goal);m!==null&&(m=m.apply(y.substitution)),l.push(new Q(d,m))}var S=this.rules[n.indicator],P=u(S,e);return l.length>0&&P!==-1?(S.splice.apply(S,[P,1].concat(l)),!0):!1},X.prototype.unfold=function(e){return this.session.unfold(e)},O.prototype.interpret=function(e){return i.error.instantiation(e.level)},E.prototype.interpret=function(e){return this},o.prototype.interpret=function(e){return i.type.is_unitary_list(this)?this.args[0].interpret(e):i.operate(e,this)},O.prototype.compare=function(e){return this.ide.id?1:0},E.prototype.compare=function(e){if(this.value===e.value&&this.is_float===e.is_float)return 0;if(this.valuee.value)return 1},o.prototype.compare=function(e){if(this.args.lengthe.args.length||this.args.length===e.args.length&&this.id>e.id)return 1;for(var n=0;ns)return 1;if(e.constructor===E){if(e.is_float&&n.is_float)return 0;if(e.is_float)return-1;if(n.is_float)return 1}return 0},is_substitution:function(e){return e instanceof Y},is_state:function(e){return e instanceof V},is_rule:function(e){return e instanceof Q},is_variable:function(e){return e instanceof O},is_stream:function(e){return e instanceof ne},is_anonymous_var:function(e){return e instanceof O&&e.id==="_"},is_callable:function(e){return e instanceof o},is_number:function(e){return e instanceof E},is_integer:function(e){return e instanceof E&&!e.is_float},is_float:function(e){return e instanceof E&&e.is_float},is_term:function(e){return e instanceof o},is_atom:function(e){return e instanceof o&&e.args.length===0},is_ground:function(e){if(e instanceof O)return!1;if(e instanceof o){for(var n=0;n0},is_list:function(e){return e instanceof o&&(e.indicator==="[]/0"||e.indicator==="./2")},is_empty_list:function(e){return e instanceof o&&e.indicator==="[]/0"},is_non_empty_list:function(e){return e instanceof o&&e.indicator==="./2"},is_fully_list:function(e){for(;e instanceof o&&e.indicator==="./2";)e=e.args[1];return e instanceof O||e instanceof o&&e.indicator==="[]/0"},is_instantiated_list:function(e){for(;e instanceof o&&e.indicator==="./2";)e=e.args[1];return e instanceof o&&e.indicator==="[]/0"},is_unitary_list:function(e){return e instanceof o&&e.indicator==="./2"&&e.args[1]instanceof o&&e.args[1].indicator==="[]/0"},is_character:function(e){return e instanceof o&&(e.id.length===1||e.id.length>0&&e.id.length<=2&&_(e.id,0)>=65536)},is_character_code:function(e){return e instanceof E&&!e.is_float&&e.value>=0&&e.value<=1114111},is_byte:function(e){return e instanceof E&&!e.is_float&&e.value>=0&&e.value<=255},is_operator:function(e){return e instanceof o&&i.arithmetic.evaluation[e.indicator]},is_directive:function(e){return e instanceof o&&i.directive[e.indicator]!==void 0},is_builtin:function(e){return e instanceof o&&i.predicate[e.indicator]!==void 0},is_error:function(e){return e instanceof o&&e.indicator==="throw/1"},is_predicate_indicator:function(e){return e instanceof o&&e.indicator==="//2"&&e.args[0]instanceof o&&e.args[0].args.length===0&&e.args[1]instanceof E&&e.args[1].is_float===!1},is_flag:function(e){return e instanceof o&&e.args.length===0&&i.flag[e.id]!==void 0},is_value_flag:function(e,n){if(!i.type.is_flag(e))return!1;for(var t in i.flag[e.id].allowed)if(!!i.flag[e.id].allowed.hasOwnProperty(t)&&i.flag[e.id].allowed[t].equals(n))return!0;return!1},is_io_mode:function(e){return i.type.is_atom(e)&&["read","write","append"].indexOf(e.id)!==-1},is_stream_option:function(e){return i.type.is_term(e)&&(e.indicator==="alias/1"&&i.type.is_atom(e.args[0])||e.indicator==="reposition/1"&&i.type.is_atom(e.args[0])&&(e.args[0].id==="true"||e.args[0].id==="false")||e.indicator==="type/1"&&i.type.is_atom(e.args[0])&&(e.args[0].id==="text"||e.args[0].id==="binary")||e.indicator==="eof_action/1"&&i.type.is_atom(e.args[0])&&(e.args[0].id==="error"||e.args[0].id==="eof_code"||e.args[0].id==="reset"))},is_stream_position:function(e){return i.type.is_integer(e)&&e.value>=0||i.type.is_atom(e)&&(e.id==="end_of_stream"||e.id==="past_end_of_stream")},is_stream_property:function(e){return i.type.is_term(e)&&(e.indicator==="input/0"||e.indicator==="output/0"||e.indicator==="alias/1"&&(i.type.is_variable(e.args[0])||i.type.is_atom(e.args[0]))||e.indicator==="file_name/1"&&(i.type.is_variable(e.args[0])||i.type.is_atom(e.args[0]))||e.indicator==="position/1"&&(i.type.is_variable(e.args[0])||i.type.is_stream_position(e.args[0]))||e.indicator==="reposition/1"&&(i.type.is_variable(e.args[0])||i.type.is_atom(e.args[0])&&(e.args[0].id==="true"||e.args[0].id==="false"))||e.indicator==="type/1"&&(i.type.is_variable(e.args[0])||i.type.is_atom(e.args[0])&&(e.args[0].id==="text"||e.args[0].id==="binary"))||e.indicator==="mode/1"&&(i.type.is_variable(e.args[0])||i.type.is_atom(e.args[0])&&(e.args[0].id==="read"||e.args[0].id==="write"||e.args[0].id==="append"))||e.indicator==="eof_action/1"&&(i.type.is_variable(e.args[0])||i.type.is_atom(e.args[0])&&(e.args[0].id==="error"||e.args[0].id==="eof_code"||e.args[0].id==="reset"))||e.indicator==="end_of_stream/1"&&(i.type.is_variable(e.args[0])||i.type.is_atom(e.args[0])&&(e.args[0].id==="at"||e.args[0].id==="past"||e.args[0].id==="not")))},is_streamable:function(e){return e.__proto__.stream!==void 0},is_read_option:function(e){return i.type.is_term(e)&&["variables/1","variable_names/1","singletons/1"].indexOf(e.indicator)!==-1},is_write_option:function(e){return i.type.is_term(e)&&(e.indicator==="quoted/1"&&i.type.is_atom(e.args[0])&&(e.args[0].id==="true"||e.args[0].id==="false")||e.indicator==="ignore_ops/1"&&i.type.is_atom(e.args[0])&&(e.args[0].id==="true"||e.args[0].id==="false")||e.indicator==="numbervars/1"&&i.type.is_atom(e.args[0])&&(e.args[0].id==="true"||e.args[0].id==="false"))},is_close_option:function(e){return i.type.is_term(e)&&e.indicator==="force/1"&&i.type.is_atom(e.args[0])&&(e.args[0].id==="true"||e.args[0].id==="false")},is_modifiable_flag:function(e){return i.type.is_flag(e)&&i.flag[e.id].changeable},is_module:function(e){return e instanceof o&&e.indicator==="library/1"&&e.args[0]instanceof o&&e.args[0].args.length===0&&i.module[e.args[0].id]!==void 0}},arithmetic:{evaluation:{"e/0":{type_args:null,type_result:!0,fn:function(e){return Math.E}},"pi/0":{type_args:null,type_result:!0,fn:function(e){return Math.PI}},"tau/0":{type_args:null,type_result:!0,fn:function(e){return 2*Math.PI}},"epsilon/0":{type_args:null,type_result:!0,fn:function(e){return Number.EPSILON}},"+/1":{type_args:null,type_result:null,fn:function(e,n){return e}},"-/1":{type_args:null,type_result:null,fn:function(e,n){return-e}},"\\/1":{type_args:!1,type_result:!1,fn:function(e,n){return~e}},"abs/1":{type_args:null,type_result:null,fn:function(e,n){return Math.abs(e)}},"sign/1":{type_args:null,type_result:null,fn:function(e,n){return Math.sign(e)}},"float_integer_part/1":{type_args:!0,type_result:!1,fn:function(e,n){return parseInt(e)}},"float_fractional_part/1":{type_args:!0,type_result:!0,fn:function(e,n){return e-parseInt(e)}},"float/1":{type_args:null,type_result:!0,fn:function(e,n){return parseFloat(e)}},"floor/1":{type_args:!0,type_result:!1,fn:function(e,n){return Math.floor(e)}},"truncate/1":{type_args:!0,type_result:!1,fn:function(e,n){return parseInt(e)}},"round/1":{type_args:!0,type_result:!1,fn:function(e,n){return Math.round(e)}},"ceiling/1":{type_args:!0,type_result:!1,fn:function(e,n){return Math.ceil(e)}},"sin/1":{type_args:null,type_result:!0,fn:function(e,n){return Math.sin(e)}},"cos/1":{type_args:null,type_result:!0,fn:function(e,n){return Math.cos(e)}},"tan/1":{type_args:null,type_result:!0,fn:function(e,n){return Math.tan(e)}},"asin/1":{type_args:null,type_result:!0,fn:function(e,n){return Math.asin(e)}},"acos/1":{type_args:null,type_result:!0,fn:function(e,n){return Math.acos(e)}},"atan/1":{type_args:null,type_result:!0,fn:function(e,n){return Math.atan(e)}},"atan2/2":{type_args:null,type_result:!0,fn:function(e,n,t){return Math.atan2(e,n)}},"exp/1":{type_args:null,type_result:!0,fn:function(e,n){return Math.exp(e)}},"sqrt/1":{type_args:null,type_result:!0,fn:function(e,n){return Math.sqrt(e)}},"log/1":{type_args:null,type_result:!0,fn:function(e,n){return e>0?Math.log(e):i.error.evaluation("undefined",n.__call_indicator)}},"+/2":{type_args:null,type_result:null,fn:function(e,n,t){return e+n}},"-/2":{type_args:null,type_result:null,fn:function(e,n,t){return e-n}},"*/2":{type_args:null,type_result:null,fn:function(e,n,t){return e*n}},"//2":{type_args:null,type_result:!0,fn:function(e,n,t){return n?e/n:i.error.evaluation("zero_division",t.__call_indicator)}},"///2":{type_args:!1,type_result:!1,fn:function(e,n,t){return n?parseInt(e/n):i.error.evaluation("zero_division",t.__call_indicator)}},"**/2":{type_args:null,type_result:!0,fn:function(e,n,t){return Math.pow(e,n)}},"^/2":{type_args:null,type_result:null,fn:function(e,n,t){return Math.pow(e,n)}},"<>/2":{type_args:!1,type_result:!1,fn:function(e,n,t){return e>>n}},"/\\/2":{type_args:!1,type_result:!1,fn:function(e,n,t){return e&n}},"\\//2":{type_args:!1,type_result:!1,fn:function(e,n,t){return e|n}},"xor/2":{type_args:!1,type_result:!1,fn:function(e,n,t){return e^n}},"rem/2":{type_args:!1,type_result:!1,fn:function(e,n,t){return n?e%n:i.error.evaluation("zero_division",t.__call_indicator)}},"mod/2":{type_args:!1,type_result:!1,fn:function(e,n,t){return n?e-parseInt(e/n)*n:i.error.evaluation("zero_division",t.__call_indicator)}},"max/2":{type_args:null,type_result:null,fn:function(e,n,t){return Math.max(e,n)}},"min/2":{type_args:null,type_result:null,fn:function(e,n,t){return Math.min(e,n)}}}},directive:{"dynamic/1":function(e,n){var t=n.args[0];if(i.type.is_variable(t))e.throw_error(i.error.instantiation(n.indicator));else if(!i.type.is_compound(t)||t.indicator!=="//2")e.throw_error(i.error.type("predicate_indicator",t,n.indicator));else if(i.type.is_variable(t.args[0])||i.type.is_variable(t.args[1]))e.throw_error(i.error.instantiation(n.indicator));else if(!i.type.is_atom(t.args[0]))e.throw_error(i.error.type("atom",t.args[0],n.indicator));else if(!i.type.is_integer(t.args[1]))e.throw_error(i.error.type("integer",t.args[1],n.indicator));else{var s=n.args[0].args[0].id+"/"+n.args[0].args[1].value;e.session.public_predicates[s]=!0,e.session.rules[s]||(e.session.rules[s]=[])}},"multifile/1":function(e,n){var t=n.args[0];i.type.is_variable(t)?e.throw_error(i.error.instantiation(n.indicator)):!i.type.is_compound(t)||t.indicator!=="//2"?e.throw_error(i.error.type("predicate_indicator",t,n.indicator)):i.type.is_variable(t.args[0])||i.type.is_variable(t.args[1])?e.throw_error(i.error.instantiation(n.indicator)):i.type.is_atom(t.args[0])?i.type.is_integer(t.args[1])?e.session.multifile_predicates[n.args[0].args[0].id+"/"+n.args[0].args[1].value]=!0:e.throw_error(i.error.type("integer",t.args[1],n.indicator)):e.throw_error(i.error.type("atom",t.args[0],n.indicator))},"set_prolog_flag/2":function(e,n){var t=n.args[0],s=n.args[1];i.type.is_variable(t)||i.type.is_variable(s)?e.throw_error(i.error.instantiation(n.indicator)):i.type.is_atom(t)?i.type.is_flag(t)?i.type.is_value_flag(t,s)?i.type.is_modifiable_flag(t)?e.session.flag[t.id]=s:e.throw_error(i.error.permission("modify","flag",t)):e.throw_error(i.error.domain("flag_value",new o("+",[t,s]),n.indicator)):e.throw_error(i.error.domain("prolog_flag",t,n.indicator)):e.throw_error(i.error.type("atom",t,n.indicator))},"use_module/1":function(e,n){var t=n.args[0];if(i.type.is_variable(t))e.throw_error(i.error.instantiation(n.indicator));else if(!i.type.is_term(t))e.throw_error(i.error.type("term",t,n.indicator));else if(i.type.is_module(t)){var s=t.args[0].id;u(e.session.modules,s)===-1&&e.session.modules.push(s)}},"char_conversion/2":function(e,n){var t=n.args[0],s=n.args[1];i.type.is_variable(t)||i.type.is_variable(s)?e.throw_error(i.error.instantiation(n.indicator)):i.type.is_character(t)?i.type.is_character(s)?t.id===s.id?delete e.session.__char_conversion[t.id]:e.session.__char_conversion[t.id]=s.id:e.throw_error(i.error.type("character",s,n.indicator)):e.throw_error(i.error.type("character",t,n.indicator))},"op/3":function(e,n){var t=n.args[0],s=n.args[1],a=n.args[2];if(i.type.is_variable(t)||i.type.is_variable(s)||i.type.is_variable(a))e.throw_error(i.error.instantiation(n.indicator));else if(!i.type.is_integer(t))e.throw_error(i.error.type("integer",t,n.indicator));else if(!i.type.is_atom(s))e.throw_error(i.error.type("atom",s,n.indicator));else if(!i.type.is_atom(a))e.throw_error(i.error.type("atom",a,n.indicator));else if(t.value<0||t.value>1200)e.throw_error(i.error.domain("operator_priority",t,n.indicator));else if(a.id===",")e.throw_error(i.error.permission("modify","operator",a,n.indicator));else if(a.id==="|"&&(t.value<1001||s.id.length!==3))e.throw_error(i.error.permission("modify","operator",a,n.indicator));else if(["fy","fx","yf","xf","xfx","yfx","xfy"].indexOf(s.id)===-1)e.throw_error(i.error.domain("operator_specifier",s,n.indicator));else{var l={prefix:null,infix:null,postfix:null};for(var f in e.session.__operators)if(!!e.session.__operators.hasOwnProperty(f)){var y=e.session.__operators[f][a.id];y&&(u(y,"fx")!==-1&&(l.prefix={priority:f,type:"fx"}),u(y,"fy")!==-1&&(l.prefix={priority:f,type:"fy"}),u(y,"xf")!==-1&&(l.postfix={priority:f,type:"xf"}),u(y,"yf")!==-1&&(l.postfix={priority:f,type:"yf"}),u(y,"xfx")!==-1&&(l.infix={priority:f,type:"xfx"}),u(y,"xfy")!==-1&&(l.infix={priority:f,type:"xfy"}),u(y,"yfx")!==-1&&(l.infix={priority:f,type:"yfx"}))}var d;switch(s.id){case"fy":case"fx":d="prefix";break;case"yf":case"xf":d="postfix";break;default:d="infix";break}if(((l.prefix&&d==="prefix"||l.postfix&&d==="postfix"||l.infix&&d==="infix")&&l[d].type!==s.id||l.infix&&d==="postfix"||l.postfix&&d==="infix")&&t.value!==0)e.throw_error(i.error.permission("create","operator",a,n.indicator));else return l[d]&&(Fi(e.session.__operators[l[d].priority][a.id],s.id),e.session.__operators[l[d].priority][a.id].length===0&&delete e.session.__operators[l[d].priority][a.id]),t.value>0&&(e.session.__operators[t.value]||(e.session.__operators[t.value.toString()]={}),e.session.__operators[t.value][a.id]||(e.session.__operators[t.value][a.id]=[]),e.session.__operators[t.value][a.id].push(s.id)),!0}}},predicate:{"op/3":function(e,n,t){i.directive["op/3"](e,t)&&e.success(n)},"current_op/3":function(e,n,t){var s=t.args[0],a=t.args[1],l=t.args[2],f=[];for(var y in e.session.__operators)for(var d in e.session.__operators[y])for(var m=0;m/2"){var s=e.points,a=e.session.format_success,l=e.session.format_error;e.session.format_success=function(m){return m.substitution},e.session.format_error=function(m){return m.goal},e.points=[new V(t.args[0].args[0],n.substitution,n)];var f=function(m){e.points=s,e.session.format_success=a,e.session.format_error=l,m===!1?e.prepend([new V(n.goal.replace(t.args[1]),n.substitution,n)]):i.type.is_error(m)?e.throw_error(m.args[0]):m===null?(e.prepend([n]),e.__calls.shift()(null)):e.prepend([new V(n.goal.replace(t.args[0].args[1]).apply(m),n.substitution.apply(m),n)])};e.__calls.unshift(f)}else{var y=new V(n.goal.replace(t.args[0]),n.substitution,n),d=new V(n.goal.replace(t.args[1]),n.substitution,n);e.prepend([y,d])}},"!/0":function(e,n,t){var s,a,l=[];for(s=n,a=null;s.parent!==null&&s.parent.goal.search(t);)if(a=s,s=s.parent,s.goal!==null){var f=s.goal.select();if(f&&f.id==="call"&&f.search(t)){s=a;break}}for(var y=e.points.length-1;y>=0;y--){for(var d=e.points[y],m=d.parent;m!==null&&m!==s.parent;)m=m.parent;m===null&&m!==s.parent&&l.push(d)}e.points=l.reverse(),e.success(n)},"\\+/1":function(e,n,t){var s=t.args[0];i.type.is_variable(s)?e.throw_error(i.error.instantiation(e.level)):i.type.is_callable(s)?e.prepend([new V(n.goal.replace(new o(",",[new o(",",[new o("call",[s]),new o("!",[])]),new o("fail",[])])),n.substitution,n),new V(n.goal.replace(null),n.substitution,n)]):e.throw_error(i.error.type("callable",s,e.level))},"->/2":function(e,n,t){var s=n.goal.replace(new o(",",[t.args[0],new o(",",[new o("!"),t.args[1]])]));e.prepend([new V(s,n.substitution,n)])},"fail/0":function(e,n,t){},"false/0":function(e,n,t){},"true/0":function(e,n,t){e.success(n)},"call/1":ye(1),"call/2":ye(2),"call/3":ye(3),"call/4":ye(4),"call/5":ye(5),"call/6":ye(6),"call/7":ye(7),"call/8":ye(8),"once/1":function(e,n,t){var s=t.args[0];e.prepend([new V(n.goal.replace(new o(",",[new o("call",[s]),new o("!",[])])),n.substitution,n)])},"forall/2":function(e,n,t){var s=t.args[0],a=t.args[1];e.prepend([new V(n.goal.replace(new o("\\+",[new o(",",[new o("call",[s]),new o("\\+",[new o("call",[a])])])])),n.substitution,n)])},"repeat/0":function(e,n,t){e.prepend([new V(n.goal.replace(null),n.substitution,n),n])},"throw/1":function(e,n,t){i.type.is_variable(t.args[0])?e.throw_error(i.error.instantiation(e.level)):e.throw_error(t.args[0])},"catch/3":function(e,n,t){var s=e.points;e.points=[],e.prepend([new V(t.args[0],n.substitution,n)]);var a=e.session.format_success,l=e.session.format_error;e.session.format_success=function(y){return y.substitution},e.session.format_error=function(y){return y.goal};var f=function(y){var d=e.points;if(e.points=s,e.session.format_success=a,e.session.format_error=l,i.type.is_error(y)){for(var m=[],S=e.points.length-1;S>=0;S--){for(var R=e.points[S],P=R.parent;P!==null&&P!==n.parent;)P=P.parent;P===null&&P!==n.parent&&m.push(R)}e.points=m;var A=e.get_flag("occurs_check").indicator==="true/0",R=new V,k=i.unify(y.args[0],t.args[1],A);k!==null?(R.substitution=n.substitution.apply(k),R.goal=n.goal.replace(t.args[2]).apply(k),R.parent=n,e.prepend([R])):e.throw_error(y.args[0])}else if(y!==!1){for(var L=y===null?[]:[new V(n.goal.apply(y).replace(null),n.substitution.apply(y),n)],B=[],S=d.length-1;S>=0;S--){B.push(d[S]);var q=d[S].goal!==null?d[S].goal.select():null;if(i.type.is_term(q)&&q.indicator==="!/0")break}var F=c(B,function(H){return H.goal===null&&(H.goal=new o("true",[])),H=new V(n.goal.replace(new o("catch",[H.goal,t.args[1],t.args[2]])),n.substitution.apply(H.substitution),H.parent),H.exclude=t.args[0].variables(),H}).reverse();e.prepend(F),e.prepend(L),y===null&&(this.current_limit=0,e.__calls.shift()(null))}};e.__calls.unshift(f)},"=/2":function(e,n,t){var s=e.get_flag("occurs_check").indicator==="true/0",a=new V,l=i.unify(t.args[0],t.args[1],s);l!==null&&(a.goal=n.goal.apply(l).replace(null),a.substitution=n.substitution.apply(l),a.parent=n,e.prepend([a]))},"unify_with_occurs_check/2":function(e,n,t){var s=new V,a=i.unify(t.args[0],t.args[1],!0);a!==null&&(s.goal=n.goal.apply(a).replace(null),s.substitution=n.substitution.apply(a),s.parent=n,e.prepend([s]))},"\\=/2":function(e,n,t){var s=e.get_flag("occurs_check").indicator==="true/0",a=i.unify(t.args[0],t.args[1],s);a===null&&e.success(n)},"subsumes_term/2":function(e,n,t){var s=e.get_flag("occurs_check").indicator==="true/0",a=i.unify(t.args[1],t.args[0],s);a!==null&&t.args[1].apply(a).equals(t.args[1])&&e.success(n)},"findall/3":function(e,n,t){var s=t.args[0],a=t.args[1],l=t.args[2];if(i.type.is_variable(a))e.throw_error(i.error.instantiation(t.indicator));else if(!i.type.is_callable(a))e.throw_error(i.error.type("callable",a,t.indicator));else if(!i.type.is_variable(l)&&!i.type.is_list(l))e.throw_error(i.error.type("list",l,t.indicator));else{var f=e.next_free_variable(),y=new o(",",[a,new o("=",[f,s])]),d=e.points,m=e.session.limit,S=e.session.format_success;e.session.format_success=function(R){return R.substitution},e.add_goal(y,!0,n);var P=[],A=function(R){if(R!==!1&&R!==null&&!i.type.is_error(R))e.__calls.unshift(A),P.push(R.links[f.id]),e.session.limit=e.current_limit;else if(e.points=d,e.session.limit=m,e.session.format_success=S,i.type.is_error(R))e.throw_error(R.args[0]);else if(e.current_limit>0){for(var k=new o("[]"),L=P.length-1;L>=0;L--)k=new o(".",[P[L],k]);e.prepend([new V(n.goal.replace(new o("=",[l,k])),n.substitution,n)])}};e.__calls.unshift(A)}},"bagof/3":function(e,n,t){var s,a=t.args[0],l=t.args[1],f=t.args[2];if(i.type.is_variable(l))e.throw_error(i.error.instantiation(t.indicator));else if(!i.type.is_callable(l))e.throw_error(i.error.type("callable",l,t.indicator));else if(!i.type.is_variable(f)&&!i.type.is_list(f))e.throw_error(i.error.type("list",f,t.indicator));else{var y=e.next_free_variable(),d;l.indicator==="^/2"?(d=l.args[0].variables(),l=l.args[1]):d=[],d=d.concat(a.variables());for(var m=l.variables().filter(function(F){return u(d,F)===-1}),S=new o("[]"),P=m.length-1;P>=0;P--)S=new o(".",[new O(m[P]),S]);var A=new o(",",[l,new o("=",[y,new o(",",[S,a])])]),R=e.points,k=e.session.limit,L=e.session.format_success;e.session.format_success=function(F){return F.substitution},e.add_goal(A,!0,n);var B=[],q=function(F){if(F!==!1&&F!==null&&!i.type.is_error(F)){e.__calls.unshift(q);var H=!1,J=F.links[y.id].args[0],me=F.links[y.id].args[1];for(var be in B)if(!!B.hasOwnProperty(be)){var Me=B[be];if(Me.variables.equals(J)){Me.answers.push(me),H=!0;break}}H||B.push({variables:J,answers:[me]}),e.session.limit=e.current_limit}else if(e.points=R,e.session.limit=k,e.session.format_success=L,i.type.is_error(F))e.throw_error(F.args[0]);else if(e.current_limit>0){for(var qe=[],ce=0;ce=0;xe--)Te=new o(".",[F[xe],Te]);qe.push(new V(n.goal.replace(new o(",",[new o("=",[S,B[ce].variables]),new o("=",[f,Te])])),n.substitution,n))}e.prepend(qe)}};e.__calls.unshift(q)}},"setof/3":function(e,n,t){var s,a=t.args[0],l=t.args[1],f=t.args[2];if(i.type.is_variable(l))e.throw_error(i.error.instantiation(t.indicator));else if(!i.type.is_callable(l))e.throw_error(i.error.type("callable",l,t.indicator));else if(!i.type.is_variable(f)&&!i.type.is_list(f))e.throw_error(i.error.type("list",f,t.indicator));else{var y=e.next_free_variable(),d;l.indicator==="^/2"?(d=l.args[0].variables(),l=l.args[1]):d=[],d=d.concat(a.variables());for(var m=l.variables().filter(function(F){return u(d,F)===-1}),S=new o("[]"),P=m.length-1;P>=0;P--)S=new o(".",[new O(m[P]),S]);var A=new o(",",[l,new o("=",[y,new o(",",[S,a])])]),R=e.points,k=e.session.limit,L=e.session.format_success;e.session.format_success=function(F){return F.substitution},e.add_goal(A,!0,n);var B=[],q=function(F){if(F!==!1&&F!==null&&!i.type.is_error(F)){e.__calls.unshift(q);var H=!1,J=F.links[y.id].args[0],me=F.links[y.id].args[1];for(var be in B)if(!!B.hasOwnProperty(be)){var Me=B[be];if(Me.variables.equals(J)){Me.answers.push(me),H=!0;break}}H||B.push({variables:J,answers:[me]}),e.session.limit=e.current_limit}else if(e.points=R,e.session.limit=k,e.session.format_success=L,i.type.is_error(F))e.throw_error(F.args[0]);else if(e.current_limit>0){for(var qe=[],ce=0;ce=0;xe--)Te=new o(".",[F[xe],Te]);qe.push(new V(n.goal.replace(new o(",",[new o("=",[S,B[ce].variables]),new o("=",[f,Te])])),n.substitution,n))}e.prepend(qe)}};e.__calls.unshift(q)}},"functor/3":function(e,n,t){var s,a=t.args[0],l=t.args[1],f=t.args[2];if(i.type.is_variable(a)&&(i.type.is_variable(l)||i.type.is_variable(f)))e.throw_error(i.error.instantiation("functor/3"));else if(!i.type.is_variable(f)&&!i.type.is_integer(f))e.throw_error(i.error.type("integer",t.args[2],"functor/3"));else if(!i.type.is_variable(l)&&!i.type.is_atomic(l))e.throw_error(i.error.type("atomic",t.args[1],"functor/3"));else if(i.type.is_integer(l)&&i.type.is_integer(f)&&f.value!==0)e.throw_error(i.error.type("atom",t.args[1],"functor/3"));else if(i.type.is_variable(a)){if(t.args[2].value>=0){for(var y=[],d=0;d0&&s<=t.args[1].args.length){var a=new o("=",[t.args[1].args[s-1],t.args[2]]);e.prepend([new V(n.goal.replace(a),n.substitution,n)])}}},"=../2":function(e,n,t){var s;if(i.type.is_variable(t.args[0])&&(i.type.is_variable(t.args[1])||i.type.is_non_empty_list(t.args[1])&&i.type.is_variable(t.args[1].args[0])))e.throw_error(i.error.instantiation(t.indicator));else if(!i.type.is_fully_list(t.args[1]))e.throw_error(i.error.type("list",t.args[1],t.indicator));else if(i.type.is_variable(t.args[0])){if(!i.type.is_variable(t.args[1])){var l=[];for(s=t.args[1].args[1];s.indicator==="./2";)l.push(s.args[0]),s=s.args[1];i.type.is_variable(t.args[0])&&i.type.is_variable(s)?e.throw_error(i.error.instantiation(t.indicator)):l.length===0&&i.type.is_compound(t.args[1].args[0])?e.throw_error(i.error.type("atomic",t.args[1].args[0],t.indicator)):l.length>0&&(i.type.is_compound(t.args[1].args[0])||i.type.is_number(t.args[1].args[0]))?e.throw_error(i.error.type("atom",t.args[1].args[0],t.indicator)):l.length===0?e.prepend([new V(n.goal.replace(new o("=",[t.args[1].args[0],t.args[0]],n)),n.substitution,n)]):e.prepend([new V(n.goal.replace(new o("=",[new o(t.args[1].args[0].id,l),t.args[0]])),n.substitution,n)])}}else{if(i.type.is_atomic(t.args[0]))s=new o(".",[t.args[0],new o("[]")]);else{s=new o("[]");for(var a=t.args[0].args.length-1;a>=0;a--)s=new o(".",[t.args[0].args[a],s]);s=new o(".",[new o(t.args[0].id),s])}e.prepend([new V(n.goal.replace(new o("=",[s,t.args[1]])),n.substitution,n)])}},"copy_term/2":function(e,n,t){var s=t.args[0].rename(e);e.prepend([new V(n.goal.replace(new o("=",[s,t.args[1]])),n.substitution,n.parent)])},"term_variables/2":function(e,n,t){var s=t.args[0],a=t.args[1];if(!i.type.is_fully_list(a))e.throw_error(i.error.type("list",a,t.indicator));else{var l=he(c(yr(s.variables()),function(f){return new O(f)}));e.prepend([new V(n.goal.replace(new o("=",[a,l])),n.substitution,n)])}},"clause/2":function(e,n,t){if(i.type.is_variable(t.args[0]))e.throw_error(i.error.instantiation(t.indicator));else if(!i.type.is_callable(t.args[0]))e.throw_error(i.error.type("callable",t.args[0],t.indicator));else if(!i.type.is_variable(t.args[1])&&!i.type.is_callable(t.args[1]))e.throw_error(i.error.type("callable",t.args[1],t.indicator));else if(e.session.rules[t.args[0].indicator]!==void 0)if(e.is_public_predicate(t.args[0].indicator)){var s=[];for(var a in e.session.rules[t.args[0].indicator])if(!!e.session.rules[t.args[0].indicator].hasOwnProperty(a)){var l=e.session.rules[t.args[0].indicator][a];e.session.renamed_variables={},l=l.rename(e),l.body===null&&(l.body=new o("true"));var f=new o(",",[new o("=",[l.head,t.args[0]]),new o("=",[l.body,t.args[1]])]);s.push(new V(n.goal.replace(f),n.substitution,n))}e.prepend(s)}else e.throw_error(i.error.permission("access","private_procedure",t.args[0].indicator,t.indicator))},"current_predicate/1":function(e,n,t){var s=t.args[0];if(!i.type.is_variable(s)&&(!i.type.is_compound(s)||s.indicator!=="//2"))e.throw_error(i.error.type("predicate_indicator",s,t.indicator));else if(!i.type.is_variable(s)&&!i.type.is_variable(s.args[0])&&!i.type.is_atom(s.args[0]))e.throw_error(i.error.type("atom",s.args[0],t.indicator));else if(!i.type.is_variable(s)&&!i.type.is_variable(s.args[1])&&!i.type.is_integer(s.args[1]))e.throw_error(i.error.type("integer",s.args[1],t.indicator));else{var a=[];for(var l in e.session.rules)if(!!e.session.rules.hasOwnProperty(l)){var f=l.lastIndexOf("/"),y=l.substr(0,f),d=parseInt(l.substr(f+1,l.length-(f+1))),m=new o("/",[new o(y),new E(d,!1)]),S=new o("=",[m,s]);a.push(new V(n.goal.replace(S),n.substitution,n))}e.prepend(a)}},"asserta/1":function(e,n,t){if(i.type.is_variable(t.args[0]))e.throw_error(i.error.instantiation(t.indicator));else if(!i.type.is_callable(t.args[0]))e.throw_error(i.error.type("callable",t.args[0],t.indicator));else{var s,a;t.args[0].indicator===":-/2"?(s=t.args[0].args[0],a=ve(t.args[0].args[1])):(s=t.args[0],a=null),i.type.is_callable(s)?a!==null&&!i.type.is_callable(a)?e.throw_error(i.error.type("callable",a,t.indicator)):e.is_public_predicate(s.indicator)?(e.session.rules[s.indicator]===void 0&&(e.session.rules[s.indicator]=[]),e.session.public_predicates[s.indicator]=!0,e.session.rules[s.indicator]=[new Q(s,a,!0)].concat(e.session.rules[s.indicator]),e.success(n)):e.throw_error(i.error.permission("modify","static_procedure",s.indicator,t.indicator)):e.throw_error(i.error.type("callable",s,t.indicator))}},"assertz/1":function(e,n,t){if(i.type.is_variable(t.args[0]))e.throw_error(i.error.instantiation(t.indicator));else if(!i.type.is_callable(t.args[0]))e.throw_error(i.error.type("callable",t.args[0],t.indicator));else{var s,a;t.args[0].indicator===":-/2"?(s=t.args[0].args[0],a=ve(t.args[0].args[1])):(s=t.args[0],a=null),i.type.is_callable(s)?a!==null&&!i.type.is_callable(a)?e.throw_error(i.error.type("callable",a,t.indicator)):e.is_public_predicate(s.indicator)?(e.session.rules[s.indicator]===void 0&&(e.session.rules[s.indicator]=[]),e.session.public_predicates[s.indicator]=!0,e.session.rules[s.indicator].push(new Q(s,a,!0)),e.success(n)):e.throw_error(i.error.permission("modify","static_procedure",s.indicator,t.indicator)):e.throw_error(i.error.type("callable",s,t.indicator))}},"retract/1":function(e,n,t){if(i.type.is_variable(t.args[0]))e.throw_error(i.error.instantiation(t.indicator));else if(!i.type.is_callable(t.args[0]))e.throw_error(i.error.type("callable",t.args[0],t.indicator));else{var s,a;if(t.args[0].indicator===":-/2"?(s=t.args[0].args[0],a=t.args[0].args[1]):(s=t.args[0],a=new o("true")),typeof n.retract=="undefined")if(e.is_public_predicate(s.indicator)){if(e.session.rules[s.indicator]!==void 0){for(var l=[],f=0;fe.get_flag("max_arity").value)e.throw_error(i.error.representation("max_arity",t.indicator));else{var s=t.args[0].args[0].id+"/"+t.args[0].args[1].value;e.is_public_predicate(s)?(delete e.session.rules[s],e.success(n)):e.throw_error(i.error.permission("modify","static_procedure",s,t.indicator))}},"atom_length/2":function(e,n,t){if(i.type.is_variable(t.args[0]))e.throw_error(i.error.instantiation(t.indicator));else if(!i.type.is_atom(t.args[0]))e.throw_error(i.error.type("atom",t.args[0],t.indicator));else if(!i.type.is_variable(t.args[1])&&!i.type.is_integer(t.args[1]))e.throw_error(i.error.type("integer",t.args[1],t.indicator));else if(i.type.is_integer(t.args[1])&&t.args[1].value<0)e.throw_error(i.error.domain("not_less_than_zero",t.args[1],t.indicator));else{var s=new E(t.args[0].id.length,!1);e.prepend([new V(n.goal.replace(new o("=",[s,t.args[1]])),n.substitution,n)])}},"atom_concat/3":function(e,n,t){var s,a,l=t.args[0],f=t.args[1],y=t.args[2];if(i.type.is_variable(y)&&(i.type.is_variable(l)||i.type.is_variable(f)))e.throw_error(i.error.instantiation(t.indicator));else if(!i.type.is_variable(l)&&!i.type.is_atom(l))e.throw_error(i.error.type("atom",l,t.indicator));else if(!i.type.is_variable(f)&&!i.type.is_atom(f))e.throw_error(i.error.type("atom",f,t.indicator));else if(!i.type.is_variable(y)&&!i.type.is_atom(y))e.throw_error(i.error.type("atom",y,t.indicator));else{var d=i.type.is_variable(l),m=i.type.is_variable(f);if(!d&&!m)a=new o("=",[y,new o(l.id+f.id)]),e.prepend([new V(n.goal.replace(a),n.substitution,n)]);else if(d&&!m)s=y.id.substr(0,y.id.length-f.id.length),s+f.id===y.id&&(a=new o("=",[l,new o(s)]),e.prepend([new V(n.goal.replace(a),n.substitution,n)]));else if(m&&!d)s=y.id.substr(l.id.length),l.id+s===y.id&&(a=new o("=",[f,new o(s)]),e.prepend([new V(n.goal.replace(a),n.substitution,n)]));else{for(var S=[],P=0;P<=y.id.length;P++){var A=new o(y.id.substr(0,P)),R=new o(y.id.substr(P));a=new o(",",[new o("=",[A,l]),new o("=",[R,f])]),S.push(new V(n.goal.replace(a),n.substitution,n))}e.prepend(S)}}},"sub_atom/5":function(e,n,t){var s,a=t.args[0],l=t.args[1],f=t.args[2],y=t.args[3],d=t.args[4];if(i.type.is_variable(a))e.throw_error(i.error.instantiation(t.indicator));else if(!i.type.is_variable(l)&&!i.type.is_integer(l))e.throw_error(i.error.type("integer",l,t.indicator));else if(!i.type.is_variable(f)&&!i.type.is_integer(f))e.throw_error(i.error.type("integer",f,t.indicator));else if(!i.type.is_variable(y)&&!i.type.is_integer(y))e.throw_error(i.error.type("integer",y,t.indicator));else if(i.type.is_integer(l)&&l.value<0)e.throw_error(i.error.domain("not_less_than_zero",l,t.indicator));else if(i.type.is_integer(f)&&f.value<0)e.throw_error(i.error.domain("not_less_than_zero",f,t.indicator));else if(i.type.is_integer(y)&&y.value<0)e.throw_error(i.error.domain("not_less_than_zero",y,t.indicator));else{var m=[],S=[],P=[];if(i.type.is_variable(l))for(s=0;s<=a.id.length;s++)m.push(s);else m.push(l.value);if(i.type.is_variable(f))for(s=0;s<=a.id.length;s++)S.push(s);else S.push(f.value);if(i.type.is_variable(y))for(s=0;s<=a.id.length;s++)P.push(s);else P.push(y.value);var A=[];for(var R in m)if(!!m.hasOwnProperty(R)){s=m[R];for(var k in S)if(!!S.hasOwnProperty(k)){var L=S[k],B=a.id.length-s-L;if(u(P,B)!==-1&&s+L+B===a.id.length){var q=a.id.substr(s,L);if(a.id===a.id.substr(0,s)+q+a.id.substr(s+L,B)){var F=new o("=",[new o(q),d]),H=new o("=",[l,new E(s)]),J=new o("=",[f,new E(L)]),me=new o("=",[y,new E(B)]),be=new o(",",[new o(",",[new o(",",[H,J]),me]),F]);A.push(new V(n.goal.replace(be),n.substitution,n))}}}}e.prepend(A)}},"atom_chars/2":function(e,n,t){var s=t.args[0],a=t.args[1];if(i.type.is_variable(s)&&i.type.is_variable(a))e.throw_error(i.error.instantiation(t.indicator));else if(!i.type.is_variable(s)&&!i.type.is_atom(s))e.throw_error(i.error.type("atom",s,t.indicator));else if(i.type.is_variable(s)){for(var y=a,d=i.type.is_variable(s),m="";y.indicator==="./2";){if(i.type.is_character(y.args[0]))m+=y.args[0].id;else if(i.type.is_variable(y.args[0])&&d){e.throw_error(i.error.instantiation(t.indicator));return}else if(!i.type.is_variable(y.args[0])){e.throw_error(i.error.type("character",y.args[0],t.indicator));return}y=y.args[1]}i.type.is_variable(y)&&d?e.throw_error(i.error.instantiation(t.indicator)):!i.type.is_empty_list(y)&&!i.type.is_variable(y)?e.throw_error(i.error.type("list",a,t.indicator)):e.prepend([new V(n.goal.replace(new o("=",[new o(m),s])),n.substitution,n)])}else{for(var l=new o("[]"),f=s.id.length-1;f>=0;f--)l=new o(".",[new o(s.id.charAt(f)),l]);e.prepend([new V(n.goal.replace(new o("=",[a,l])),n.substitution,n)])}},"atom_codes/2":function(e,n,t){var s=t.args[0],a=t.args[1];if(i.type.is_variable(s)&&i.type.is_variable(a))e.throw_error(i.error.instantiation(t.indicator));else if(!i.type.is_variable(s)&&!i.type.is_atom(s))e.throw_error(i.error.type("atom",s,t.indicator));else if(i.type.is_variable(s)){for(var y=a,d=i.type.is_variable(s),m="";y.indicator==="./2";){if(i.type.is_character_code(y.args[0]))m+=v(y.args[0].value);else if(i.type.is_variable(y.args[0])&&d){e.throw_error(i.error.instantiation(t.indicator));return}else if(!i.type.is_variable(y.args[0])){e.throw_error(i.error.representation("character_code",t.indicator));return}y=y.args[1]}i.type.is_variable(y)&&d?e.throw_error(i.error.instantiation(t.indicator)):!i.type.is_empty_list(y)&&!i.type.is_variable(y)?e.throw_error(i.error.type("list",a,t.indicator)):e.prepend([new V(n.goal.replace(new o("=",[new o(m),s])),n.substitution,n)])}else{for(var l=new o("[]"),f=s.id.length-1;f>=0;f--)l=new o(".",[new E(_(s.id,f),!1),l]);e.prepend([new V(n.goal.replace(new o("=",[a,l])),n.substitution,n)])}},"char_code/2":function(e,n,t){var s=t.args[0],a=t.args[1];if(i.type.is_variable(s)&&i.type.is_variable(a))e.throw_error(i.error.instantiation(t.indicator));else if(!i.type.is_variable(s)&&!i.type.is_character(s))e.throw_error(i.error.type("character",s,t.indicator));else if(!i.type.is_variable(a)&&!i.type.is_integer(a))e.throw_error(i.error.type("integer",a,t.indicator));else if(!i.type.is_variable(a)&&!i.type.is_character_code(a))e.throw_error(i.error.representation("character_code",t.indicator));else if(i.type.is_variable(a)){var l=new E(_(s.id,0),!1);e.prepend([new V(n.goal.replace(new o("=",[l,a])),n.substitution,n)])}else{var f=new o(v(a.value));e.prepend([new V(n.goal.replace(new o("=",[f,s])),n.substitution,n)])}},"number_chars/2":function(e,n,t){var s,a=t.args[0],l=t.args[1];if(i.type.is_variable(a)&&i.type.is_variable(l))e.throw_error(i.error.instantiation(t.indicator));else if(!i.type.is_variable(a)&&!i.type.is_number(a))e.throw_error(i.error.type("number",a,t.indicator));else if(!i.type.is_variable(l)&&!i.type.is_list(l))e.throw_error(i.error.type("list",l,t.indicator));else{var f=i.type.is_variable(a);if(!i.type.is_variable(l)){var y=l,d=!0;for(s="";y.indicator==="./2";){if(i.type.is_character(y.args[0]))s+=y.args[0].id;else if(i.type.is_variable(y.args[0]))d=!1;else if(!i.type.is_variable(y.args[0])){e.throw_error(i.error.type("character",y.args[0],t.indicator));return}y=y.args[1]}if(d=d&&i.type.is_empty_list(y),!i.type.is_empty_list(y)&&!i.type.is_variable(y)){e.throw_error(i.error.type("list",l,t.indicator));return}if(!d&&f){e.throw_error(i.error.instantiation(t.indicator));return}else if(d)if(i.type.is_variable(y)&&f){e.throw_error(i.error.instantiation(t.indicator));return}else{var m=e.parse(s),S=m.value;!i.type.is_number(S)||m.tokens[m.tokens.length-1].space?e.throw_error(i.error.syntax_by_predicate("parseable_number",t.indicator)):e.prepend([new V(n.goal.replace(new o("=",[a,S])),n.substitution,n)]);return}}if(!f){s=a.toString();for(var P=new o("[]"),A=s.length-1;A>=0;A--)P=new o(".",[new o(s.charAt(A)),P]);e.prepend([new V(n.goal.replace(new o("=",[l,P])),n.substitution,n)])}}},"number_codes/2":function(e,n,t){var s,a=t.args[0],l=t.args[1];if(i.type.is_variable(a)&&i.type.is_variable(l))e.throw_error(i.error.instantiation(t.indicator));else if(!i.type.is_variable(a)&&!i.type.is_number(a))e.throw_error(i.error.type("number",a,t.indicator));else if(!i.type.is_variable(l)&&!i.type.is_list(l))e.throw_error(i.error.type("list",l,t.indicator));else{var f=i.type.is_variable(a);if(!i.type.is_variable(l)){var y=l,d=!0;for(s="";y.indicator==="./2";){if(i.type.is_character_code(y.args[0]))s+=v(y.args[0].value);else if(i.type.is_variable(y.args[0]))d=!1;else if(!i.type.is_variable(y.args[0])){e.throw_error(i.error.type("character_code",y.args[0],t.indicator));return}y=y.args[1]}if(d=d&&i.type.is_empty_list(y),!i.type.is_empty_list(y)&&!i.type.is_variable(y)){e.throw_error(i.error.type("list",l,t.indicator));return}if(!d&&f){e.throw_error(i.error.instantiation(t.indicator));return}else if(d)if(i.type.is_variable(y)&&f){e.throw_error(i.error.instantiation(t.indicator));return}else{var m=e.parse(s),S=m.value;!i.type.is_number(S)||m.tokens[m.tokens.length-1].space?e.throw_error(i.error.syntax_by_predicate("parseable_number",t.indicator)):e.prepend([new V(n.goal.replace(new o("=",[a,S])),n.substitution,n)]);return}}if(!f){s=a.toString();for(var P=new o("[]"),A=s.length-1;A>=0;A--)P=new o(".",[new E(_(s,A),!1),P]);e.prepend([new V(n.goal.replace(new o("=",[l,P])),n.substitution,n)])}}},"upcase_atom/2":function(e,n,t){var s=t.args[0],a=t.args[1];i.type.is_variable(s)?e.throw_error(i.error.instantiation(t.indicator)):i.type.is_atom(s)?!i.type.is_variable(a)&&!i.type.is_atom(a)?e.throw_error(i.error.type("atom",a,t.indicator)):e.prepend([new V(n.goal.replace(new o("=",[a,new o(s.id.toUpperCase(),[])])),n.substitution,n)]):e.throw_error(i.error.type("atom",s,t.indicator))},"downcase_atom/2":function(e,n,t){var s=t.args[0],a=t.args[1];i.type.is_variable(s)?e.throw_error(i.error.instantiation(t.indicator)):i.type.is_atom(s)?!i.type.is_variable(a)&&!i.type.is_atom(a)?e.throw_error(i.error.type("atom",a,t.indicator)):e.prepend([new V(n.goal.replace(new o("=",[a,new o(s.id.toLowerCase(),[])])),n.substitution,n)]):e.throw_error(i.error.type("atom",s,t.indicator))},"atomic_list_concat/2":function(e,n,t){var s=t.args[0],a=t.args[1];e.prepend([new V(n.goal.replace(new o("atomic_list_concat",[s,new o("",[]),a])),n.substitution,n)])},"atomic_list_concat/3":function(e,n,t){var s=t.args[0],a=t.args[1],l=t.args[2];if(i.type.is_variable(a)||i.type.is_variable(s)&&i.type.is_variable(l))e.throw_error(i.error.instantiation(t.indicator));else if(!i.type.is_variable(s)&&!i.type.is_list(s))e.throw_error(i.error.type("list",s,t.indicator));else if(!i.type.is_variable(l)&&!i.type.is_atom(l))e.throw_error(i.error.type("atom",l,t.indicator));else if(i.type.is_variable(l)){for(var y="",d=s;i.type.is_term(d)&&d.indicator==="./2";){if(!i.type.is_atom(d.args[0])&&!i.type.is_number(d.args[0])){e.throw_error(i.error.type("atomic",d.args[0],t.indicator));return}y!==""&&(y+=a.id),i.type.is_atom(d.args[0])?y+=d.args[0].id:y+=""+d.args[0].value,d=d.args[1]}y=new o(y,[]),i.type.is_variable(d)?e.throw_error(i.error.instantiation(t.indicator)):!i.type.is_term(d)||d.indicator!=="[]/0"?e.throw_error(i.error.type("list",s,t.indicator)):e.prepend([new V(n.goal.replace(new o("=",[y,l])),n.substitution,n)])}else{var f=he(c(l.id.split(a.id),function(m){return new o(m,[])}));e.prepend([new V(n.goal.replace(new o("=",[f,s])),n.substitution,n)])}},"@=/2":function(e,n,t){i.compare(t.args[0],t.args[1])>0&&e.success(n)},"@>=/2":function(e,n,t){i.compare(t.args[0],t.args[1])>=0&&e.success(n)},"compare/3":function(e,n,t){var s=t.args[0],a=t.args[1],l=t.args[2];if(!i.type.is_variable(s)&&!i.type.is_atom(s))e.throw_error(i.error.type("atom",s,t.indicator));else if(i.type.is_atom(s)&&["<",">","="].indexOf(s.id)===-1)e.throw_error(i.type.domain("order",s,t.indicator));else{var f=i.compare(a,l);f=f===0?"=":f===-1?"<":">",e.prepend([new V(n.goal.replace(new o("=",[s,new o(f,[])])),n.substitution,n)])}},"is/2":function(e,n,t){var s=t.args[1].interpret(e);i.type.is_number(s)?e.prepend([new V(n.goal.replace(new o("=",[t.args[0],s],e.level)),n.substitution,n)]):e.throw_error(s)},"between/3":function(e,n,t){var s=t.args[0],a=t.args[1],l=t.args[2];if(i.type.is_variable(s)||i.type.is_variable(a))e.throw_error(i.error.instantiation(t.indicator));else if(!i.type.is_integer(s))e.throw_error(i.error.type("integer",s,t.indicator));else if(!i.type.is_integer(a))e.throw_error(i.error.type("integer",a,t.indicator));else if(!i.type.is_variable(l)&&!i.type.is_integer(l))e.throw_error(i.error.type("integer",l,t.indicator));else if(i.type.is_variable(l)){var f=[new V(n.goal.replace(new o("=",[l,s])),n.substitution,n)];s.value=l.value&&e.success(n)},"succ/2":function(e,n,t){var s=t.args[0],a=t.args[1];i.type.is_variable(s)&&i.type.is_variable(a)?e.throw_error(i.error.instantiation(t.indicator)):!i.type.is_variable(s)&&!i.type.is_integer(s)?e.throw_error(i.error.type("integer",s,t.indicator)):!i.type.is_variable(a)&&!i.type.is_integer(a)?e.throw_error(i.error.type("integer",a,t.indicator)):!i.type.is_variable(s)&&s.value<0?e.throw_error(i.error.domain("not_less_than_zero",s,t.indicator)):!i.type.is_variable(a)&&a.value<0?e.throw_error(i.error.domain("not_less_than_zero",a,t.indicator)):(i.type.is_variable(a)||a.value>0)&&(i.type.is_variable(s)?e.prepend([new V(n.goal.replace(new o("=",[s,new E(a.value-1,!1)])),n.substitution,n)]):e.prepend([new V(n.goal.replace(new o("=",[a,new E(s.value+1,!1)])),n.substitution,n)]))},"=:=/2":function(e,n,t){var s=i.arithmetic_compare(e,t.args[0],t.args[1]);i.type.is_term(s)?e.throw_error(s):s===0&&e.success(n)},"=\\=/2":function(e,n,t){var s=i.arithmetic_compare(e,t.args[0],t.args[1]);i.type.is_term(s)?e.throw_error(s):s!==0&&e.success(n)},"/2":function(e,n,t){var s=i.arithmetic_compare(e,t.args[0],t.args[1]);i.type.is_term(s)?e.throw_error(s):s>0&&e.success(n)},">=/2":function(e,n,t){var s=i.arithmetic_compare(e,t.args[0],t.args[1]);i.type.is_term(s)?e.throw_error(s):s>=0&&e.success(n)},"var/1":function(e,n,t){i.type.is_variable(t.args[0])&&e.success(n)},"atom/1":function(e,n,t){i.type.is_atom(t.args[0])&&e.success(n)},"atomic/1":function(e,n,t){i.type.is_atomic(t.args[0])&&e.success(n)},"compound/1":function(e,n,t){i.type.is_compound(t.args[0])&&e.success(n)},"integer/1":function(e,n,t){i.type.is_integer(t.args[0])&&e.success(n)},"float/1":function(e,n,t){i.type.is_float(t.args[0])&&e.success(n)},"number/1":function(e,n,t){i.type.is_number(t.args[0])&&e.success(n)},"nonvar/1":function(e,n,t){i.type.is_variable(t.args[0])||e.success(n)},"ground/1":function(e,n,t){t.variables().length===0&&e.success(n)},"acyclic_term/1":function(e,n,t){for(var s=n.substitution.apply(n.substitution),a=t.args[0].variables(),l=0;l0?k[k.length-1]:null,k!==null&&(A=U(e,k,0,e.__get_max_priority(),!1))}if(A.type===h&&A.len===k.length-1&&L.value==="."){A=A.value.rename(e);var B=new o("=",[a,A]);if(y.variables){var q=he(c(yr(A.variables()),function(F){return new O(F)}));B=new o(",",[B,new o("=",[y.variables,q])])}if(y.variable_names){var q=he(c(yr(A.variables()),function(H){var J;for(J in e.session.renamed_variables)if(e.session.renamed_variables.hasOwnProperty(J)&&e.session.renamed_variables[J]===H)break;return new o("=",[new o(J,[]),new O(H)])}));B=new o(",",[B,new o("=",[y.variable_names,q])])}if(y.singletons){var q=he(c(new Q(A,null).singleton_variables(),function(H){var J;for(J in e.session.renamed_variables)if(e.session.renamed_variables.hasOwnProperty(J)&&e.session.renamed_variables[J]===H)break;return new o("=",[new o(J,[]),new O(H)])}));B=new o(",",[B,new o("=",[y.singletons,q])])}e.prepend([new V(n.goal.replace(B),n.substitution,n)])}else A.type===h?e.throw_error(i.error.syntax(k[A.len],"unexpected token",!1)):e.throw_error(A.value)}}},"write/1":function(e,n,t){var s=t.args[0];e.prepend([new V(n.goal.replace(new o(",",[new o("current_output",[new O("S")]),new o("write",[new O("S"),s])])),n.substitution,n)])},"write/2":function(e,n,t){var s=t.args[0],a=t.args[1];e.prepend([new V(n.goal.replace(new o("write_term",[s,a,new o(".",[new o("quoted",[new o("false",[])]),new o(".",[new o("ignore_ops",[new o("false")]),new o(".",[new o("numbervars",[new o("true")]),new o("[]",[])])])])])),n.substitution,n)])},"writeq/1":function(e,n,t){var s=t.args[0];e.prepend([new V(n.goal.replace(new o(",",[new o("current_output",[new O("S")]),new o("writeq",[new O("S"),s])])),n.substitution,n)])},"writeq/2":function(e,n,t){var s=t.args[0],a=t.args[1];e.prepend([new V(n.goal.replace(new o("write_term",[s,a,new o(".",[new o("quoted",[new o("true",[])]),new o(".",[new o("ignore_ops",[new o("false")]),new o(".",[new o("numbervars",[new o("true")]),new o("[]",[])])])])])),n.substitution,n)])},"write_canonical/1":function(e,n,t){var s=t.args[0];e.prepend([new V(n.goal.replace(new o(",",[new o("current_output",[new O("S")]),new o("write_canonical",[new O("S"),s])])),n.substitution,n)])},"write_canonical/2":function(e,n,t){var s=t.args[0],a=t.args[1];e.prepend([new V(n.goal.replace(new o("write_term",[s,a,new o(".",[new o("quoted",[new o("true",[])]),new o(".",[new o("ignore_ops",[new o("true")]),new o(".",[new o("numbervars",[new o("false")]),new o("[]",[])])])])])),n.substitution,n)])},"write_term/2":function(e,n,t){var s=t.args[0],a=t.args[1];e.prepend([new V(n.goal.replace(new o(",",[new o("current_output",[new O("S")]),new o("write_term",[new O("S"),s,a])])),n.substitution,n)])},"write_term/3":function(e,n,t){var s=t.args[0],a=t.args[1],l=t.args[2],f=i.type.is_stream(s)?s:e.get_stream_by_alias(s.id);if(i.type.is_variable(s)||i.type.is_variable(l))e.throw_error(i.error.instantiation(t.indicator));else if(!i.type.is_list(l))e.throw_error(i.error.type("list",l,t.indicator));else if(!i.type.is_stream(s)&&!i.type.is_atom(s))e.throw_error(i.error.domain("stream_or_alias",s,t.indicator));else if(!i.type.is_stream(f)||f.stream===null)e.throw_error(i.error.existence("stream",s,t.indicator));else if(f.input)e.throw_error(i.error.permission("output","stream",s,t.indicator));else if(f.type==="binary")e.throw_error(i.error.permission("output","binary_stream",s,t.indicator));else if(f.position==="past_end_of_stream"&&f.eof_action==="error")e.throw_error(i.error.permission("output","past_end_of_stream",s,t.indicator));else{for(var y={},d=l,m;i.type.is_term(d)&&d.indicator==="./2";){if(m=d.args[0],i.type.is_variable(m)){e.throw_error(i.error.instantiation(t.indicator));return}else if(!i.type.is_write_option(m)){e.throw_error(i.error.domain("write_option",m,t.indicator));return}y[m.id]=m.args[0].id==="true",d=d.args[1]}if(d.indicator!=="[]/0"){i.type.is_variable(d)?e.throw_error(i.error.instantiation(t.indicator)):e.throw_error(i.error.type("list",l,t.indicator));return}else{y.session=e.session;var S=a.toString(y);f.stream.put(S,f.position),typeof f.position=="number"&&(f.position+=S.length),e.success(n)}}},"halt/0":function(e,n,t){e.points=[]},"halt/1":function(e,n,t){var s=t.args[0];i.type.is_variable(s)?e.throw_error(i.error.instantiation(t.indicator)):i.type.is_integer(s)?e.points=[]:e.throw_error(i.error.type("integer",s,t.indicator))},"current_prolog_flag/2":function(e,n,t){var s=t.args[0],a=t.args[1];if(!i.type.is_variable(s)&&!i.type.is_atom(s))e.throw_error(i.error.type("atom",s,t.indicator));else if(!i.type.is_variable(s)&&!i.type.is_flag(s))e.throw_error(i.error.domain("prolog_flag",s,t.indicator));else{var l=[];for(var f in i.flag)if(!!i.flag.hasOwnProperty(f)){var y=new o(",",[new o("=",[new o(f),s]),new o("=",[e.get_flag(f),a])]);l.push(new V(n.goal.replace(y),n.substitution,n))}e.prepend(l)}},"set_prolog_flag/2":function(e,n,t){var s=t.args[0],a=t.args[1];i.type.is_variable(s)||i.type.is_variable(a)?e.throw_error(i.error.instantiation(t.indicator)):i.type.is_atom(s)?i.type.is_flag(s)?i.type.is_value_flag(s,a)?i.type.is_modifiable_flag(s)?(e.session.flag[s.id]=a,e.success(n)):e.throw_error(i.error.permission("modify","flag",s)):e.throw_error(i.error.domain("flag_value",new o("+",[s,a]),t.indicator)):e.throw_error(i.error.domain("prolog_flag",s,t.indicator)):e.throw_error(i.error.type("atom",s,t.indicator))}},flag:{bounded:{allowed:[new o("true"),new o("false")],value:new o("true"),changeable:!1},max_integer:{allowed:[new E(Number.MAX_SAFE_INTEGER)],value:new E(Number.MAX_SAFE_INTEGER),changeable:!1},min_integer:{allowed:[new E(Number.MIN_SAFE_INTEGER)],value:new E(Number.MIN_SAFE_INTEGER),changeable:!1},integer_rounding_function:{allowed:[new o("down"),new o("toward_zero")],value:new o("toward_zero"),changeable:!1},char_conversion:{allowed:[new o("on"),new o("off")],value:new o("on"),changeable:!0},debug:{allowed:[new o("on"),new o("off")],value:new o("off"),changeable:!0},max_arity:{allowed:[new o("unbounded")],value:new o("unbounded"),changeable:!1},unknown:{allowed:[new o("error"),new o("fail"),new o("warning")],value:new o("error"),changeable:!0},double_quotes:{allowed:[new o("chars"),new o("codes"),new o("atom")],value:new o("codes"),changeable:!0},occurs_check:{allowed:[new o("false"),new o("true")],value:new o("false"),changeable:!0},dialect:{allowed:[new o("tau")],value:new o("tau"),changeable:!1},version_data:{allowed:[new o("tau",[new E(r.major,!1),new E(r.minor,!1),new E(r.patch,!1),new o(r.status)])],value:new o("tau",[new E(r.major,!1),new E(r.minor,!1),new E(r.patch,!1),new o(r.status)]),changeable:!1},nodejs:{allowed:[new o("yes"),new o("no")],value:new o(typeof ie!="undefined"&&ie.exports?"yes":"no"),changeable:!1}},unify:function(e,n,t){t=t===void 0?!1:t;for(var s=[{left:e,right:n}],a={};s.length!==0;){var l=s.pop();if(e=l.left,n=l.right,i.type.is_term(e)&&i.type.is_term(n)){if(e.indicator!==n.indicator)return null;for(var f=0;fa.value?1:0:a}else return s},operate:function(e,n){if(i.type.is_operator(n)){for(var t=i.type.is_operator(n),s=[],a,l=!1,f=0;fe.get_flag("max_integer").value||a0?e.start+e.matches[0].length:e.start,a=t?new o("token_not_found"):new o("found",[new o(e.value.toString())]),l=new o(".",[new o("line",[new E(e.line+1)]),new o(".",[new o("column",[new E(s+1)]),new o(".",[a,new o("[]",[])])])]);return new o("error",[new o("syntax_error",[new o(n)]),l])},syntax_by_predicate:function(e,n){return new o("error",[new o("syntax_error",[new o(e)]),ae(n)])}},warning:{singleton:function(e,n,t){for(var s=new o("[]"),a=e.length-1;a>=0;a--)s=new o(".",[new O(e[a]),s]);return new o("warning",[new o("singleton_variables",[s,ae(n)]),new o(".",[new o("line",[new E(t,!1)]),new o("[]")])])},failed_goal:function(e,n){return new o("warning",[new o("failed_goal",[e]),new o(".",[new o("line",[new E(n,!1)]),new o("[]")])])}},format_variable:function(e){return"_"+e},format_answer:function(e,n,t){n instanceof D&&(n=n.thread);var t=t||{};if(t.session=n?n.session:void 0,i.type.is_error(e))return"uncaught exception: "+e.args[0].toString();if(e===!1)return"false.";if(e===null)return"limit exceeded ;";var s=0,a="";if(i.type.is_substitution(e)){var l=e.domain(!0);e=e.filter(function(d,m){return!i.type.is_variable(m)||l.indexOf(m.id)!==-1&&d!==m.id})}for(var f in e.links)!e.links.hasOwnProperty(f)||(s++,a!==""&&(a+=", "),a+=f.toString(t)+" = "+e.links[f].toString(t));var y=typeof n=="undefined"||n.points.length>0?" ;":".";return s===0?"true"+y:a+y},flatten_error:function(e){if(!i.type.is_error(e))return null;e=e.args[0];var n={};return n.type=e.args[0].id,n.thrown=n.type==="syntax_error"?null:e.args[1].id,n.expected=null,n.found=null,n.representation=null,n.existence=null,n.existence_type=null,n.line=null,n.column=null,n.permission_operation=null,n.permission_type=null,n.evaluation_type=null,n.type==="type_error"||n.type==="domain_error"?(n.expected=e.args[0].args[0].id,n.found=e.args[0].args[1].toString()):n.type==="syntax_error"?e.args[1].indicator==="./2"?(n.expected=e.args[0].args[0].id,n.found=e.args[1].args[1].args[1].args[0],n.found=n.found.id==="token_not_found"?n.found.id:n.found.args[0].id,n.line=e.args[1].args[0].args[0].value,n.column=e.args[1].args[1].args[0].args[0].value):n.thrown=e.args[1].id:n.type==="permission_error"?(n.found=e.args[0].args[2].toString(),n.permission_operation=e.args[0].args[0].id,n.permission_type=e.args[0].args[1].id):n.type==="evaluation_error"?n.evaluation_type=e.args[0].args[0].id:n.type==="representation_error"?n.representation=e.args[0].args[0].id:n.type==="existence_error"&&(n.existence=e.args[0].args[1].toString(),n.existence_type=e.args[0].args[0].id),n},create:function(e){return new i.type.Session(e)}};typeof ie!="undefined"?ie.exports=i:window.pl=i})()});var er=I((qu,rt)=>{var is=Array.isArray;rt.exports=is});var nt=I(($u,tt)=>{var ss=typeof global=="object"&&global&&global.Object===Object&&global;tt.exports=ss});var rr=I((Du,it)=>{var as=nt(),os=typeof self=="object"&&self&&self.Object===Object&&self,us=as||os||Function("return this")();it.exports=us});var tr=I((Xu,st)=>{var ls=rr(),cs=ls.Symbol;st.exports=cs});var lt=I((Bu,at)=>{var ot=tr(),ut=Object.prototype,fs=ut.hasOwnProperty,ps=ut.toString,Xe=ot?ot.toStringTag:void 0;function ys(r){var u=fs.call(r,Xe),p=r[Xe];try{r[Xe]=void 0;var c=!0}catch(_){}var w=ps.call(r);return c&&(u?r[Xe]=p:delete r[Xe]),w}at.exports=ys});var ft=I((Fu,ct)=>{var _s=Object.prototype,ws=_s.toString;function gs(r){return ws.call(r)}ct.exports=gs});var Pr=I((zu,pt)=>{var yt=tr(),ds=lt(),vs=ft(),hs="[object Null]",ms="[object Undefined]",_t=yt?yt.toStringTag:void 0;function bs(r){return r==null?r===void 0?ms:hs:_t&&_t in Object(r)?ds(r):vs(r)}pt.exports=bs});var gt=I((Wu,wt)=>{function Ts(r){return r!=null&&typeof r=="object"}wt.exports=Ts});var nr=I((Lu,dt)=>{var xs=Pr(),Vs=gt(),Ss="[object Symbol]";function ks(r){return typeof r=="symbol"||Vs(r)&&xs(r)==Ss}dt.exports=ks});var ht=I((Hu,vt)=>{var Ps=er(),Cs=nr(),Os=/\.|\[(?:[^[\]]*|(["'])(?:(?!\1)[^\\]|\\.)*?\1)\]/,Is=/^\w*$/;function Es(r,u){if(Ps(r))return!1;var p=typeof r;return p=="number"||p=="symbol"||p=="boolean"||r==null||Cs(r)?!0:Is.test(r)||!Os.test(r)||u!=null&&r in Object(u)}vt.exports=Es});var ir=I((Gu,mt)=>{function As(r){var u=typeof r;return r!=null&&(u=="object"||u=="function")}mt.exports=As});var Tt=I((Yu,bt)=>{var Ns=Pr(),Rs=ir(),Ms="[object AsyncFunction]",qs="[object Function]",$s="[object GeneratorFunction]",Ds="[object Proxy]";function Xs(r){if(!Rs(r))return!1;var u=Ns(r);return u==qs||u==$s||u==Ms||u==Ds}bt.exports=Xs});var Vt=I((Uu,xt)=>{var Bs=rr(),Fs=Bs["__core-js_shared__"];xt.exports=Fs});var Pt=I((Zu,St)=>{var Cr=Vt(),kt=function(){var r=/[^.]+$/.exec(Cr&&Cr.keys&&Cr.keys.IE_PROTO||"");return r?"Symbol(src)_1."+r:""}();function zs(r){return!!kt&&kt in r}St.exports=zs});var Ot=I((Qu,Ct)=>{var Ws=Function.prototype,Ls=Ws.toString;function Hs(r){if(r!=null){try{return Ls.call(r)}catch(u){}try{return r+""}catch(u){}}return""}Ct.exports=Hs});var Et=I((Ju,It)=>{var Gs=Tt(),Ys=Pt(),Us=ir(),Zs=Ot(),Qs=/[\\^$.*+?()[\]{}|]/g,Js=/^\[object .+?Constructor\]$/,Ks=Function.prototype,js=Object.prototype,ea=Ks.toString,ra=js.hasOwnProperty,ta=RegExp("^"+ea.call(ra).replace(Qs,"\\$&").replace(/hasOwnProperty|(function).*?(?=\\\()| for .+?(?=\\\])/g,"$1.*?")+"$");function na(r){if(!Us(r)||Ys(r))return!1;var u=Gs(r)?ta:Js;return u.test(Zs(r))}It.exports=na});var Nt=I((Ku,At)=>{function ia(r,u){return r==null?void 0:r[u]}At.exports=ia});var sr=I((ju,Rt)=>{var sa=Et(),aa=Nt();function oa(r,u){var p=aa(r,u);return sa(p)?p:void 0}Rt.exports=oa});var Be=I((el,Mt)=>{var ua=sr(),la=ua(Object,"create");Mt.exports=la});var Dt=I((rl,qt)=>{var $t=Be();function ca(){this.__data__=$t?$t(null):{},this.size=0}qt.exports=ca});var Bt=I((tl,Xt)=>{function fa(r){var u=this.has(r)&&delete this.__data__[r];return this.size-=u?1:0,u}Xt.exports=fa});var zt=I((nl,Ft)=>{var pa=Be(),ya="__lodash_hash_undefined__",_a=Object.prototype,wa=_a.hasOwnProperty;function ga(r){var u=this.__data__;if(pa){var p=u[r];return p===ya?void 0:p}return wa.call(u,r)?u[r]:void 0}Ft.exports=ga});var Lt=I((il,Wt)=>{var da=Be(),va=Object.prototype,ha=va.hasOwnProperty;function ma(r){var u=this.__data__;return da?u[r]!==void 0:ha.call(u,r)}Wt.exports=ma});var Gt=I((sl,Ht)=>{var ba=Be(),Ta="__lodash_hash_undefined__";function xa(r,u){var p=this.__data__;return this.size+=this.has(r)?0:1,p[r]=ba&&u===void 0?Ta:u,this}Ht.exports=xa});var Ut=I((al,Yt)=>{var Va=Dt(),Sa=Bt(),ka=zt(),Pa=Lt(),Ca=Gt();function Ie(r){var u=-1,p=r==null?0:r.length;for(this.clear();++u{function Oa(){this.__data__=[],this.size=0}Zt.exports=Oa});var Or=I((ul,Jt)=>{function Ia(r,u){return r===u||r!==r&&u!==u}Jt.exports=Ia});var Fe=I((ll,Kt)=>{var Ea=Or();function Aa(r,u){for(var p=r.length;p--;)if(Ea(r[p][0],u))return p;return-1}Kt.exports=Aa});var en=I((cl,jt)=>{var Na=Fe(),Ra=Array.prototype,Ma=Ra.splice;function qa(r){var u=this.__data__,p=Na(u,r);if(p<0)return!1;var c=u.length-1;return p==c?u.pop():Ma.call(u,p,1),--this.size,!0}jt.exports=qa});var tn=I((fl,rn)=>{var $a=Fe();function Da(r){var u=this.__data__,p=$a(u,r);return p<0?void 0:u[p][1]}rn.exports=Da});var sn=I((pl,nn)=>{var Xa=Fe();function Ba(r){return Xa(this.__data__,r)>-1}nn.exports=Ba});var on=I((yl,an)=>{var Fa=Fe();function za(r,u){var p=this.__data__,c=Fa(p,r);return c<0?(++this.size,p.push([r,u])):p[c][1]=u,this}an.exports=za});var ln=I((_l,un)=>{var Wa=Qt(),La=en(),Ha=tn(),Ga=sn(),Ya=on();function Ee(r){var u=-1,p=r==null?0:r.length;for(this.clear();++u{var Ua=sr(),Za=rr(),Qa=Ua(Za,"Map");cn.exports=Qa});var _n=I((gl,pn)=>{var yn=Ut(),Ja=ln(),Ka=fn();function ja(){this.size=0,this.__data__={hash:new yn,map:new(Ka||Ja),string:new yn}}pn.exports=ja});var gn=I((dl,wn)=>{function eo(r){var u=typeof r;return u=="string"||u=="number"||u=="symbol"||u=="boolean"?r!=="__proto__":r===null}wn.exports=eo});var ze=I((vl,dn)=>{var ro=gn();function to(r,u){var p=r.__data__;return ro(u)?p[typeof u=="string"?"string":"hash"]:p.map}dn.exports=to});var hn=I((hl,vn)=>{var no=ze();function io(r){var u=no(this,r).delete(r);return this.size-=u?1:0,u}vn.exports=io});var bn=I((ml,mn)=>{var so=ze();function ao(r){return so(this,r).get(r)}mn.exports=ao});var xn=I((bl,Tn)=>{var oo=ze();function uo(r){return oo(this,r).has(r)}Tn.exports=uo});var Sn=I((Tl,Vn)=>{var lo=ze();function co(r,u){var p=lo(this,r),c=p.size;return p.set(r,u),this.size+=p.size==c?0:1,this}Vn.exports=co});var Pn=I((xl,kn)=>{var fo=_n(),po=hn(),yo=bn(),_o=xn(),wo=Sn();function Ae(r){var u=-1,p=r==null?0:r.length;for(this.clear();++u{var On=Pn(),go="Expected a function";function Ir(r,u){if(typeof r!="function"||u!=null&&typeof u!="function")throw new TypeError(go);var p=function(){var c=arguments,w=u?u.apply(this,c):c[0],_=p.cache;if(_.has(w))return _.get(w);var v=r.apply(this,c);return p.cache=_.set(w,v)||_,v};return p.cache=new(Ir.Cache||On),p}Ir.Cache=On;Cn.exports=Ir});var An=I((Sl,En)=>{var vo=In(),ho=500;function mo(r){var u=vo(r,function(c){return p.size===ho&&p.clear(),c}),p=u.cache;return u}En.exports=mo});var Rn=I((kl,Nn)=>{var bo=An(),To=/[^.[\]]+|\[(?:(-?\d+(?:\.\d+)?)|(["'])((?:(?!\2)[^\\]|\\.)*?)\2)\]|(?=(?:\.|\[\])(?:\.|\[\]|$))/g,xo=/\\(\\)?/g,Vo=bo(function(r){var u=[];return r.charCodeAt(0)===46&&u.push(""),r.replace(To,function(p,c,w,_){u.push(w?_.replace(xo,"$1"):c||p)}),u});Nn.exports=Vo});var qn=I((Pl,Mn)=>{function So(r,u){for(var p=-1,c=r==null?0:r.length,w=Array(c);++p{var Dn=tr(),ko=qn(),Po=er(),Co=nr(),Oo=1/0,Xn=Dn?Dn.prototype:void 0,Bn=Xn?Xn.toString:void 0;function Fn(r){if(typeof r=="string")return r;if(Po(r))return ko(r,Fn)+"";if(Co(r))return Bn?Bn.call(r):"";var u=r+"";return u=="0"&&1/r==-Oo?"-0":u}$n.exports=Fn});var Ln=I((Ol,Wn)=>{var Io=zn();function Eo(r){return r==null?"":Io(r)}Wn.exports=Eo});var ar=I((Il,Hn)=>{var Ao=er(),No=ht(),Ro=Rn(),Mo=Ln();function qo(r,u){return Ao(r)?r:No(r,u)?[r]:Ro(Mo(r))}Hn.exports=qo});var or=I((El,Gn)=>{var $o=nr(),Do=1/0;function Xo(r){if(typeof r=="string"||$o(r))return r;var u=r+"";return u=="0"&&1/r==-Do?"-0":u}Gn.exports=Xo});var Er=I((Al,Yn)=>{var Bo=ar(),Fo=or();function zo(r,u){u=Bo(u,r);for(var p=0,c=u.length;r!=null&&p{var Wo=Er();function Lo(r,u,p){var c=r==null?void 0:Wo(r,u);return c===void 0?p:c}Un.exports=Lo});var li=I((Ul,ui)=>{var Jo=sr(),Ko=function(){try{var r=Jo(Object,"defineProperty");return r({},"",{}),r}catch(u){}}();ui.exports=Ko});var pi=I((Zl,ci)=>{var fi=li();function jo(r,u,p){u=="__proto__"&&fi?fi(r,u,{configurable:!0,enumerable:!0,value:p,writable:!0}):r[u]=p}ci.exports=jo});var _i=I((Ql,yi)=>{var eu=pi(),ru=Or(),tu=Object.prototype,nu=tu.hasOwnProperty;function iu(r,u,p){var c=r[u];(!(nu.call(r,u)&&ru(c,p))||p===void 0&&!(u in r))&&eu(r,u,p)}yi.exports=iu});var gi=I((Jl,wi)=>{var su=9007199254740991,au=/^(?:0|[1-9]\d*)$/;function ou(r,u){var p=typeof r;return u=u==null?su:u,!!u&&(p=="number"||p!="symbol"&&au.test(r))&&r>-1&&r%1==0&&r{var uu=_i(),lu=ar(),cu=gi(),vi=ir(),fu=or();function pu(r,u,p,c){if(!vi(r))return r;u=lu(u,r);for(var w=-1,_=u.length,v=_-1,g=r;g!=null&&++w<_;){var h=fu(u[w]),x=p;if(h==="__proto__"||h==="constructor"||h==="prototype")return r;if(w!=v){var T=g[h];x=c?c(T,h,g):void 0,x===void 0&&(x=vi(T)?T:cu(u[w+1])?[]:{})}uu(g,h,x),g=g[h]}return r}di.exports=pu});var bi=I((jl,mi)=>{var yu=hi();function _u(r,u,p){return r==null?r:yu(r,u,p)}mi.exports=_u});var xi=I((ec,Ti)=>{function wu(r){var u=r==null?0:r.length;return u?r[u-1]:void 0}Ti.exports=wu});var Si=I((rc,Vi)=>{function gu(r,u,p){var c=-1,w=r.length;u<0&&(u=-u>w?0:w+u),p=p>w?w:p,p<0&&(p+=w),w=u>p?0:p-u>>>0,u>>>=0;for(var _=Array(w);++c{var du=Er(),vu=Si();function hu(r,u){return u.length<2?r:du(r,vu(u,0,-1))}ki.exports=hu});var Oi=I((nc,Ci)=>{var mu=ar(),bu=xi(),Tu=Pi(),xu=or();function Vu(r,u){return u=mu(u,r),r=Tu(r,u),r==null||delete r[xu(bu(u))]}Ci.exports=Vu});var Ei=I((ic,Ii)=>{var Su=Oi();function ku(r,u){return r==null?!0:Su(r,u)}Ii.exports=ku});var Ou={};Qi(Ou,{default:()=>Eu});var $i=G(require("@yarnpkg/core"));var ni=G(require("@yarnpkg/cli")),ur=G(require("@yarnpkg/core")),ii=G(require("@yarnpkg/core")),Le=G(require("clipanion"));var ue=G(require("@yarnpkg/core")),le=G(require("@yarnpkg/core")),Ne=G(require("@yarnpkg/fslib")),jn=G(Xr()),Re=G(kr());var Nr=G(require("@yarnpkg/core")),Rr=G(Ar()),re=G(kr()),Zn=G(require("vm")),{is_atom:ge,is_variable:Ho,is_instantiated_list:Go}=re.default.type;function Qn(r,u,p){r.prepend(p.map(c=>new re.default.type.State(u.goal.replace(c),u.substitution,u)))}var Jn=new WeakMap;function Mr(r){let u=Jn.get(r.session);if(u==null)throw new Error("Assertion failed: A project should have been registered for the active session");return u}var Yo=new re.default.type.Module("constraints",{["project_workspaces_by_descriptor/3"]:(r,u,p)=>{let[c,w,_]=p.args;if(!ge(c)||!ge(w)){r.throw_error(re.default.error.instantiation(p.indicator));return}let v=Nr.structUtils.parseIdent(c.id),g=Nr.structUtils.makeDescriptor(v,w.id),x=Mr(r).tryWorkspaceByDescriptor(g);Ho(_)&&x!==null&&Qn(r,u,[new re.default.type.Term("=",[_,new re.default.type.Term(String(x.relativeCwd))])]),ge(_)&&x!==null&&x.relativeCwd===_.id&&r.success(u)},["workspace_field/3"]:(r,u,p)=>{let[c,w,_]=p.args;if(!ge(c)||!ge(w)){r.throw_error(re.default.error.instantiation(p.indicator));return}let g=Mr(r).tryWorkspaceByCwd(c.id);if(g==null)return;let h=(0,Rr.default)(g.manifest.raw,w.id);typeof h!="undefined"&&Qn(r,u,[new re.default.type.Term("=",[_,new re.default.type.Term(typeof h=="object"?JSON.stringify(h):h)])])},["workspace_field_test/3"]:(r,u,p)=>{let[c,w,_]=p.args;r.prepend([new re.default.type.State(u.goal.replace(new re.default.type.Term("workspace_field_test",[c,w,_,new re.default.type.Term("[]",[])])),u.substitution,u)])},["workspace_field_test/4"]:(r,u,p)=>{let[c,w,_,v]=p.args;if(!ge(c)||!ge(w)||!ge(_)||!Go(v)){r.throw_error(re.default.error.instantiation(p.indicator));return}let h=Mr(r).tryWorkspaceByCwd(c.id);if(h==null)return;let x=(0,Rr.default)(h.manifest.raw,w.id);if(typeof x=="undefined")return;let T={$$:x};for(let[C,N]of v.toJavaScript().entries())T[`$${C}`]=N;Zn.default.runInNewContext(_.id,T)&&r.success(u)}},["project_workspaces_by_descriptor/3","workspace_field/3","workspace_field_test/3","workspace_field_test/4"]);function Kn(r,u){Jn.set(r,u),r.consult(`:- use_module(library(${Yo.id})).`)}(0,jn.default)(Re.default);var We;(function(c){c.Dependencies="dependencies",c.DevDependencies="devDependencies",c.PeerDependencies="peerDependencies"})(We||(We={}));var ei=[We.Dependencies,We.DevDependencies,We.PeerDependencies];function K(r){if(r instanceof Re.default.type.Num)return r.value;if(r instanceof Re.default.type.Term)switch(r.indicator){case"throw/1":return K(r.args[0]);case"error/1":return K(r.args[0]);case"error/2":if(r.args[0]instanceof Re.default.type.Term&&r.args[0].indicator==="syntax_error/1")return Object.assign(K(r.args[0]),...K(r.args[1]));{let u=K(r.args[0]);return u.message+=` (in ${K(r.args[1])})`,u}case"syntax_error/1":return new ue.ReportError(ue.MessageName.PROLOG_SYNTAX_ERROR,`Syntax error: ${K(r.args[0])}`);case"existence_error/2":return new ue.ReportError(ue.MessageName.PROLOG_EXISTENCE_ERROR,`Existence error: ${K(r.args[0])} ${K(r.args[1])} not found`);case"instantiation_error/0":return new ue.ReportError(ue.MessageName.PROLOG_INSTANTIATION_ERROR,"Instantiation error: an argument is variable when an instantiated argument was expected");case"line/1":return{line:K(r.args[0])};case"column/1":return{column:K(r.args[0])};case"found/1":return{found:K(r.args[0])};case"./2":return[K(r.args[0])].concat(K(r.args[1]));case"//2":return`${K(r.args[0])}/${K(r.args[1])}`;default:return r.id}throw`couldn't pretty print because of unsupported node ${r}`}function ri(r){let u;try{u=K(r)}catch(p){throw typeof p=="string"?new ue.ReportError(ue.MessageName.PROLOG_UNKNOWN_ERROR,`Unknown error: ${r} (note: ${p})`):p}return typeof u.line!="undefined"&&typeof u.column!="undefined"&&(u.message+=` at line ${u.line}, column ${u.column}`),u}var ti=class{constructor(u,p){this.session=Re.default.create(),Kn(this.session,u),this.session.consult(":- use_module(library(lists))."),this.session.consult(p)}fetchNextAnswer(){return new Promise(u=>{this.session.answer(p=>{u(p)})})}async*makeQuery(u){let p=this.session.query(u);if(p!==!0)throw ri(p);for(;;){let c=await this.fetchNextAnswer();if(!c)break;if(c.id==="throw")throw ri(c);yield c}}};function ke(r){return r.id==="null"?null:`${r.toJavaScript()}`}function Uo(r){if(r.id==="null")return null;{let u=r.toJavaScript();if(typeof u!="string")return JSON.stringify(u);try{return JSON.stringify(JSON.parse(u))}catch{return JSON.stringify(u)}}}var pe=class{constructor(u){this.source="";this.project=u;let p=u.configuration.get("constraintsPath");Ne.xfs.existsSync(p)&&(this.source=Ne.xfs.readFileSync(p,"utf8"))}static async find(u){return new pe(u)}getProjectDatabase(){let u="";for(let p of ei)u+=`dependency_type(${p}). -`;for(let p of this.project.workspacesByCwd.values()){let c=p.relativeCwd;u+=`workspace(${de(c)}). -`,u+=`workspace_ident(${de(c)}, ${de(le.structUtils.stringifyIdent(p.locator))}). -`,u+=`workspace_version(${de(c)}, ${de(p.manifest.version)}). -`;for(let w of ei)for(let _ of p.manifest[w].values())u+=`workspace_has_dependency(${de(c)}, ${de(le.structUtils.stringifyIdent(_))}, ${de(_.range)}, ${w}). -`}return u+=`workspace(_) :- false. -`,u+=`workspace_ident(_, _) :- false. -`,u+=`workspace_version(_, _) :- false. -`,u+=`workspace_has_dependency(_, _, _, _) :- false. -`,u}getDeclarations(){let u="";return u+=`gen_enforced_dependency(_, _, _, _) :- false. -`,u+=`gen_enforced_field(_, _, _) :- false. -`,u}get fullSource(){return`${this.getProjectDatabase()} -${this.source} -${this.getDeclarations()}`}createSession(){return new ti(this.project,this.fullSource)}async process(){let u=this.createSession();return{enforcedDependencies:await this.genEnforcedDependencies(u),enforcedFields:await this.genEnforcedFields(u)}}async genEnforcedDependencies(u){let p=[];for await(let c of u.makeQuery("workspace(WorkspaceCwd), dependency_type(DependencyType), gen_enforced_dependency(WorkspaceCwd, DependencyIdent, DependencyRange, DependencyType).")){let w=Ne.ppath.resolve(this.project.cwd,ke(c.links.WorkspaceCwd)),_=ke(c.links.DependencyIdent),v=ke(c.links.DependencyRange),g=ke(c.links.DependencyType);if(w===null||_===null)throw new Error("Invalid rule");let h=this.project.getWorkspaceByCwd(w),x=le.structUtils.parseIdent(_);p.push({workspace:h,dependencyIdent:x,dependencyRange:v,dependencyType:g})}return le.miscUtils.sortMap(p,[({dependencyRange:c})=>c!==null?"0":"1",({workspace:c})=>le.structUtils.stringifyIdent(c.locator),({dependencyIdent:c})=>le.structUtils.stringifyIdent(c)])}async genEnforcedFields(u){let p=[];for await(let c of u.makeQuery("workspace(WorkspaceCwd), gen_enforced_field(WorkspaceCwd, FieldPath, FieldValue).")){let w=Ne.ppath.resolve(this.project.cwd,ke(c.links.WorkspaceCwd)),_=ke(c.links.FieldPath),v=Uo(c.links.FieldValue);if(w===null||_===null)throw new Error("Invalid rule");let g=this.project.getWorkspaceByCwd(w);p.push({workspace:g,fieldPath:_,fieldValue:v})}return le.miscUtils.sortMap(p,[({workspace:c})=>le.structUtils.stringifyIdent(c.locator),({fieldPath:c})=>c])}async*query(u){let p=this.createSession();for await(let c of p.makeQuery(u)){let w={};for(let[_,v]of Object.entries(c.links))_!=="_"&&(w[_]=ke(v));yield w}}};function de(r){return typeof r=="string"?`'${r}'`:"[]"}var He=class extends ni.BaseCommand{constructor(){super(...arguments);this.json=Le.Option.Boolean("--json",!1,{description:"Format the output as an NDJSON stream"});this.query=Le.Option.String()}async execute(){let u=await ur.Configuration.find(this.context.cwd,this.context.plugins),{project:p}=await ur.Project.find(u,this.context.cwd),c=await pe.find(p),w=this.query;return w.endsWith(".")||(w=`${w}.`),(await ii.StreamReport.start({configuration:u,json:this.json,stdout:this.context.stdout},async v=>{for await(let g of c.query(w)){let h=Array.from(Object.entries(g)),x=h.length,T=h.reduce((b,[C])=>Math.max(b,C.length),0);for(let b=0;b{let v=new Set,g=[];for(let h=0,x=this.fix?10:1;h{await h.persistManifest()}));for(let[h,x]of g)_.reportError(h,x)});return w.hasErrors()?w.exitCode():0}};Ye.paths=[["constraints"]],Ye.usage=fr.Command.Usage({category:"Constraints-related commands",description:"check that the project constraints are met",details:` - This command will run constraints on your project and emit errors for each one that is found but isn't met. If any error is emitted the process will exit with a non-zero exit code. - - If the \`--fix\` flag is used, Yarn will attempt to automatically fix the issues the best it can, following a multi-pass process (with a maximum of 10 iterations). Some ambiguous patterns cannot be autofixed, in which case you'll have to manually specify the right resolution. - - For more information as to how to write constraints, please consult our dedicated page on our website: https://yarnpkg.com/features/constraints. - `,examples:[["Check that all constraints are satisfied","yarn constraints"],["Autofix all unmet constraints","yarn constraints --fix"]]});var qi=Ye;async function Pu(r,u,p,{configuration:c,fix:w}){let _=new Map,v=new Map;for(let{workspace:g,dependencyIdent:h,dependencyRange:x,dependencyType:T}of p){let b=v.get(g);typeof b=="undefined"&&v.set(g,b=new Map);let C=b.get(h.identHash);typeof C=="undefined"&&b.set(h.identHash,C=new Map);let N=C.get(T);typeof N=="undefined"&&C.set(T,N=new Set),_.set(h.identHash,h),N.add(x)}for(let[g,h]of v)for(let[x,T]of h){let b=_.get(x);if(typeof b=="undefined")throw new Error("Assertion failed: The ident should have been registered");for(let[C,N]of T){let W=N.has(null)?[null]:[...N];if(W.length>2)u.push([se.MessageName.CONSTRAINTS_AMBIGUITY,`${$.structUtils.prettyWorkspace(c,g)} must depend on ${$.structUtils.prettyIdent(c,b)} via conflicting ranges ${W.slice(0,-1).map(ee=>$.structUtils.prettyRange(c,String(ee))).join(", ")}, and ${$.structUtils.prettyRange(c,String(W[W.length-1]))} (in ${C})`]);else if(W.length>1)u.push([se.MessageName.CONSTRAINTS_AMBIGUITY,`${$.structUtils.prettyWorkspace(c,g)} must depend on ${$.structUtils.prettyIdent(c,b)} via conflicting ranges ${$.structUtils.prettyRange(c,String(W[0]))} and ${$.structUtils.prettyRange(c,String(W[1]))} (in ${C})`]);else{let ee=g.manifest[C].get(b.identHash),[te]=W;te!==null?ee?ee.range!==te&&(w?(g.manifest[C].set(b.identHash,$.structUtils.makeDescriptor(b,te)),r.add(g)):u.push([se.MessageName.CONSTRAINTS_INCOMPATIBLE_DEPENDENCY,`${$.structUtils.prettyWorkspace(c,g)} must depend on ${$.structUtils.prettyIdent(c,b)} via ${$.structUtils.prettyRange(c,te)}, but uses ${$.structUtils.prettyRange(c,ee.range)} instead (in ${C})`])):w?(g.manifest[C].set(b.identHash,$.structUtils.makeDescriptor(b,te)),r.add(g)):u.push([se.MessageName.CONSTRAINTS_MISSING_DEPENDENCY,`${$.structUtils.prettyWorkspace(c,g)} must depend on ${$.structUtils.prettyIdent(c,b)} (via ${$.structUtils.prettyRange(c,te)}), but doesn't (in ${C})`]):ee&&(w?(g.manifest[C].delete(b.identHash),r.add(g)):u.push([se.MessageName.CONSTRAINTS_EXTRANEOUS_DEPENDENCY,`${$.structUtils.prettyWorkspace(c,g)} has an extraneous dependency on ${$.structUtils.prettyIdent(c,b)} (in ${C})`]))}}}}async function Cu(r,u,p,{configuration:c,fix:w}){let _=new Map;for(let{workspace:v,fieldPath:g,fieldValue:h}of p){let x=Pe.miscUtils.getMapWithDefault(_,v);Pe.miscUtils.getSetWithDefault(x,g).add(h)}for(let[v,g]of _)for(let[h,x]of g){let T=[...x];if(T.length>2)u.push([se.MessageName.CONSTRAINTS_AMBIGUITY,`${$.structUtils.prettyWorkspace(c,v)} must have a field ${$.formatUtils.pretty(c,h,"cyan")} set to conflicting values ${T.slice(0,-1).map(b=>$.formatUtils.pretty(c,String(b),"magenta")).join(", ")}, or ${$.formatUtils.pretty(c,String(T[T.length-1]),"magenta")}`]);else if(T.length>1)u.push([se.MessageName.CONSTRAINTS_AMBIGUITY,`${$.structUtils.prettyWorkspace(c,v)} must have a field ${$.formatUtils.pretty(c,h,"cyan")} set to conflicting values ${$.formatUtils.pretty(c,String(T[0]),"magenta")} or ${$.formatUtils.pretty(c,String(T[1]),"magenta")}`]);else{let b=(0,Ni.default)(v.manifest.raw,h),[C]=T;C!==null?b===void 0?w?(await qr(v,h,C),r.add(v)):u.push([se.MessageName.CONSTRAINTS_MISSING_FIELD,`${$.structUtils.prettyWorkspace(c,v)} must have a field ${$.formatUtils.pretty(c,h,"cyan")} set to ${$.formatUtils.pretty(c,String(C),"magenta")}, but doesn't`]):JSON.stringify(b)!==C&&(w?(await qr(v,h,C),r.add(v)):u.push([se.MessageName.CONSTRAINTS_INCOMPATIBLE_FIELD,`${$.structUtils.prettyWorkspace(c,v)} must have a field ${$.formatUtils.pretty(c,h,"cyan")} set to ${$.formatUtils.pretty(c,String(C),"magenta")}, but is set to ${$.formatUtils.pretty(c,JSON.stringify(b),"magenta")} instead`])):b!=null&&(w?(await qr(v,h,null),r.add(v)):u.push([se.MessageName.CONSTRAINTS_EXTRANEOUS_FIELD,`${$.structUtils.prettyWorkspace(c,v)} has an extraneous field ${$.formatUtils.pretty(c,h,"cyan")} set to ${$.formatUtils.pretty(c,JSON.stringify(b),"magenta")}`]))}}}async function qr(r,u,p){p===null?(0,Mi.default)(r.manifest.raw,u):(0,Ri.default)(r.manifest.raw,u,JSON.parse(p))}var Iu={configuration:{constraintsPath:{description:"The path of the constraints file.",type:$i.SettingsType.ABSOLUTE_PATH,default:"./constraints.pro"}},commands:[si,oi,qi]},Eu=Iu;return Ou;})(); -return plugin; -} -}; diff --git a/.yarn/plugins/@yarnpkg/plugin-workspace-tools.cjs b/.yarn/plugins/@yarnpkg/plugin-workspace-tools.cjs deleted file mode 100644 index b9044a0144c..00000000000 --- a/.yarn/plugins/@yarnpkg/plugin-workspace-tools.cjs +++ /dev/null @@ -1,28 +0,0 @@ -/* eslint-disable */ -//prettier-ignore -module.exports = { -name: "@yarnpkg/plugin-workspace-tools", -factory: function (require) { -var plugin=(()=>{var wr=Object.create,me=Object.defineProperty,Sr=Object.defineProperties,vr=Object.getOwnPropertyDescriptor,Hr=Object.getOwnPropertyDescriptors,$r=Object.getOwnPropertyNames,et=Object.getOwnPropertySymbols,kr=Object.getPrototypeOf,tt=Object.prototype.hasOwnProperty,Tr=Object.prototype.propertyIsEnumerable;var rt=(e,t,r)=>t in e?me(e,t,{enumerable:!0,configurable:!0,writable:!0,value:r}):e[t]=r,B=(e,t)=>{for(var r in t||(t={}))tt.call(t,r)&&rt(e,r,t[r]);if(et)for(var r of et(t))Tr.call(t,r)&&rt(e,r,t[r]);return e},Q=(e,t)=>Sr(e,Hr(t)),Lr=e=>me(e,"__esModule",{value:!0});var K=(e,t)=>()=>(t||e((t={exports:{}}).exports,t),t.exports),Or=(e,t)=>{for(var r in t)me(e,r,{get:t[r],enumerable:!0})},Nr=(e,t,r)=>{if(t&&typeof t=="object"||typeof t=="function")for(let n of $r(t))!tt.call(e,n)&&n!=="default"&&me(e,n,{get:()=>t[n],enumerable:!(r=vr(t,n))||r.enumerable});return e},X=e=>Nr(Lr(me(e!=null?wr(kr(e)):{},"default",e&&e.__esModule&&"default"in e?{get:()=>e.default,enumerable:!0}:{value:e,enumerable:!0})),e);var $e=K(te=>{"use strict";te.isInteger=e=>typeof e=="number"?Number.isInteger(e):typeof e=="string"&&e.trim()!==""?Number.isInteger(Number(e)):!1;te.find=(e,t)=>e.nodes.find(r=>r.type===t);te.exceedsLimit=(e,t,r=1,n)=>n===!1||!te.isInteger(e)||!te.isInteger(t)?!1:(Number(t)-Number(e))/Number(r)>=n;te.escapeNode=(e,t=0,r)=>{let n=e.nodes[t];!n||(r&&n.type===r||n.type==="open"||n.type==="close")&&n.escaped!==!0&&(n.value="\\"+n.value,n.escaped=!0)};te.encloseBrace=e=>e.type!=="brace"?!1:e.commas>>0+e.ranges>>0==0?(e.invalid=!0,!0):!1;te.isInvalidBrace=e=>e.type!=="brace"?!1:e.invalid===!0||e.dollar?!0:e.commas>>0+e.ranges>>0==0||e.open!==!0||e.close!==!0?(e.invalid=!0,!0):!1;te.isOpenOrClose=e=>e.type==="open"||e.type==="close"?!0:e.open===!0||e.close===!0;te.reduce=e=>e.reduce((t,r)=>(r.type==="text"&&t.push(r.value),r.type==="range"&&(r.type="text"),t),[]);te.flatten=(...e)=>{let t=[],r=n=>{for(let s=0;s{"use strict";var it=$e();at.exports=(e,t={})=>{let r=(n,s={})=>{let a=t.escapeInvalid&&it.isInvalidBrace(s),i=n.invalid===!0&&t.escapeInvalid===!0,o="";if(n.value)return(a||i)&&it.isOpenOrClose(n)?"\\"+n.value:n.value;if(n.value)return n.value;if(n.nodes)for(let h of n.nodes)o+=r(h);return o};return r(e)}});var ct=K((os,ot)=>{"use strict";ot.exports=function(e){return typeof e=="number"?e-e==0:typeof e=="string"&&e.trim()!==""?Number.isFinite?Number.isFinite(+e):isFinite(+e):!1}});var At=K((cs,ut)=>{"use strict";var lt=ct(),pe=(e,t,r)=>{if(lt(e)===!1)throw new TypeError("toRegexRange: expected the first argument to be a number");if(t===void 0||e===t)return String(e);if(lt(t)===!1)throw new TypeError("toRegexRange: expected the second argument to be a number.");let n=B({relaxZeros:!0},r);typeof n.strictZeros=="boolean"&&(n.relaxZeros=n.strictZeros===!1);let s=String(n.relaxZeros),a=String(n.shorthand),i=String(n.capture),o=String(n.wrap),h=e+":"+t+"="+s+a+i+o;if(pe.cache.hasOwnProperty(h))return pe.cache[h].result;let g=Math.min(e,t),f=Math.max(e,t);if(Math.abs(g-f)===1){let R=e+"|"+t;return n.capture?`(${R})`:n.wrap===!1?R:`(?:${R})`}let A=ft(e)||ft(t),p={min:e,max:t,a:g,b:f},k=[],y=[];if(A&&(p.isPadded=A,p.maxLen=String(p.max).length),g<0){let R=f<0?Math.abs(f):1;y=pt(R,Math.abs(g),p,n),g=p.a=0}return f>=0&&(k=pt(g,f,p,n)),p.negatives=y,p.positives=k,p.result=Ir(y,k,n),n.capture===!0?p.result=`(${p.result})`:n.wrap!==!1&&k.length+y.length>1&&(p.result=`(?:${p.result})`),pe.cache[h]=p,p.result};function Ir(e,t,r){let n=Pe(e,t,"-",!1,r)||[],s=Pe(t,e,"",!1,r)||[],a=Pe(e,t,"-?",!0,r)||[];return n.concat(a).concat(s).join("|")}function Mr(e,t){let r=1,n=1,s=ht(e,r),a=new Set([t]);for(;e<=s&&s<=t;)a.add(s),r+=1,s=ht(e,r);for(s=dt(t+1,n)-1;e1&&o.count.pop(),o.count.push(f.count[0]),o.string=o.pattern+gt(o.count),i=g+1;continue}r.isPadded&&(A=Gr(g,r,n)),f.string=A+f.pattern+gt(f.count),a.push(f),i=g+1,o=f}return a}function Pe(e,t,r,n,s){let a=[];for(let i of e){let{string:o}=i;!n&&!mt(t,"string",o)&&a.push(r+o),n&&mt(t,"string",o)&&a.push(r+o)}return a}function Pr(e,t){let r=[];for(let n=0;nt?1:t>e?-1:0}function mt(e,t,r){return e.some(n=>n[t]===r)}function ht(e,t){return Number(String(e).slice(0,-t)+"9".repeat(t))}function dt(e,t){return e-e%Math.pow(10,t)}function gt(e){let[t=0,r=""]=e;return r||t>1?`{${t+(r?","+r:"")}}`:""}function Dr(e,t,r){return`[${e}${t-e==1?"":"-"}${t}]`}function ft(e){return/^-?(0+)\d/.test(e)}function Gr(e,t,r){if(!t.isPadded)return e;let n=Math.abs(t.maxLen-String(e).length),s=r.relaxZeros!==!1;switch(n){case 0:return"";case 1:return s?"0?":"0";case 2:return s?"0{0,2}":"00";default:return s?`0{0,${n}}`:`0{${n}}`}}pe.cache={};pe.clearCache=()=>pe.cache={};ut.exports=pe});var Ge=K((us,Rt)=>{"use strict";var qr=require("util"),yt=At(),bt=e=>e!==null&&typeof e=="object"&&!Array.isArray(e),Kr=e=>t=>e===!0?Number(t):String(t),De=e=>typeof e=="number"||typeof e=="string"&&e!=="",Re=e=>Number.isInteger(+e),Ue=e=>{let t=`${e}`,r=-1;if(t[0]==="-"&&(t=t.slice(1)),t==="0")return!1;for(;t[++r]==="0";);return r>0},Wr=(e,t,r)=>typeof e=="string"||typeof t=="string"?!0:r.stringify===!0,jr=(e,t,r)=>{if(t>0){let n=e[0]==="-"?"-":"";n&&(e=e.slice(1)),e=n+e.padStart(n?t-1:t,"0")}return r===!1?String(e):e},_t=(e,t)=>{let r=e[0]==="-"?"-":"";for(r&&(e=e.slice(1),t--);e.length{e.negatives.sort((i,o)=>io?1:0),e.positives.sort((i,o)=>io?1:0);let r=t.capture?"":"?:",n="",s="",a;return e.positives.length&&(n=e.positives.join("|")),e.negatives.length&&(s=`-(${r}${e.negatives.join("|")})`),n&&s?a=`${n}|${s}`:a=n||s,t.wrap?`(${r}${a})`:a},Et=(e,t,r,n)=>{if(r)return yt(e,t,B({wrap:!1},n));let s=String.fromCharCode(e);if(e===t)return s;let a=String.fromCharCode(t);return`[${s}-${a}]`},xt=(e,t,r)=>{if(Array.isArray(e)){let n=r.wrap===!0,s=r.capture?"":"?:";return n?`(${s}${e.join("|")})`:e.join("|")}return yt(e,t,r)},Ct=(...e)=>new RangeError("Invalid range arguments: "+qr.inspect(...e)),wt=(e,t,r)=>{if(r.strictRanges===!0)throw Ct([e,t]);return[]},Qr=(e,t)=>{if(t.strictRanges===!0)throw new TypeError(`Expected step "${e}" to be a number`);return[]},Xr=(e,t,r=1,n={})=>{let s=Number(e),a=Number(t);if(!Number.isInteger(s)||!Number.isInteger(a)){if(n.strictRanges===!0)throw Ct([e,t]);return[]}s===0&&(s=0),a===0&&(a=0);let i=s>a,o=String(e),h=String(t),g=String(r);r=Math.max(Math.abs(r),1);let f=Ue(o)||Ue(h)||Ue(g),A=f?Math.max(o.length,h.length,g.length):0,p=f===!1&&Wr(e,t,n)===!1,k=n.transform||Kr(p);if(n.toRegex&&r===1)return Et(_t(e,A),_t(t,A),!0,n);let y={negatives:[],positives:[]},R=T=>y[T<0?"negatives":"positives"].push(Math.abs(T)),_=[],x=0;for(;i?s>=a:s<=a;)n.toRegex===!0&&r>1?R(s):_.push(jr(k(s,x),A,p)),s=i?s-r:s+r,x++;return n.toRegex===!0?r>1?Fr(y,n):xt(_,null,B({wrap:!1},n)):_},Zr=(e,t,r=1,n={})=>{if(!Re(e)&&e.length>1||!Re(t)&&t.length>1)return wt(e,t,n);let s=n.transform||(p=>String.fromCharCode(p)),a=`${e}`.charCodeAt(0),i=`${t}`.charCodeAt(0),o=a>i,h=Math.min(a,i),g=Math.max(a,i);if(n.toRegex&&r===1)return Et(h,g,!1,n);let f=[],A=0;for(;o?a>=i:a<=i;)f.push(s(a,A)),a=o?a-r:a+r,A++;return n.toRegex===!0?xt(f,null,{wrap:!1,options:n}):f},Te=(e,t,r,n={})=>{if(t==null&&De(e))return[e];if(!De(e)||!De(t))return wt(e,t,n);if(typeof r=="function")return Te(e,t,1,{transform:r});if(bt(r))return Te(e,t,0,r);let s=B({},n);return s.capture===!0&&(s.wrap=!0),r=r||s.step||1,Re(r)?Re(e)&&Re(t)?Xr(e,t,r,s):Zr(e,t,Math.max(Math.abs(r),1),s):r!=null&&!bt(r)?Qr(r,s):Te(e,t,1,r)};Rt.exports=Te});var Ht=K((ls,St)=>{"use strict";var Yr=Ge(),vt=$e(),zr=(e,t={})=>{let r=(n,s={})=>{let a=vt.isInvalidBrace(s),i=n.invalid===!0&&t.escapeInvalid===!0,o=a===!0||i===!0,h=t.escapeInvalid===!0?"\\":"",g="";if(n.isOpen===!0||n.isClose===!0)return h+n.value;if(n.type==="open")return o?h+n.value:"(";if(n.type==="close")return o?h+n.value:")";if(n.type==="comma")return n.prev.type==="comma"?"":o?n.value:"|";if(n.value)return n.value;if(n.nodes&&n.ranges>0){let f=vt.reduce(n.nodes),A=Yr(...f,Q(B({},t),{wrap:!1,toRegex:!0}));if(A.length!==0)return f.length>1&&A.length>1?`(${A})`:A}if(n.nodes)for(let f of n.nodes)g+=r(f,n);return g};return r(e)};St.exports=zr});var Tt=K((ps,$t)=>{"use strict";var Vr=Ge(),kt=ke(),he=$e(),fe=(e="",t="",r=!1)=>{let n=[];if(e=[].concat(e),t=[].concat(t),!t.length)return e;if(!e.length)return r?he.flatten(t).map(s=>`{${s}}`):t;for(let s of e)if(Array.isArray(s))for(let a of s)n.push(fe(a,t,r));else for(let a of t)r===!0&&typeof a=="string"&&(a=`{${a}}`),n.push(Array.isArray(a)?fe(s,a,r):s+a);return he.flatten(n)},Jr=(e,t={})=>{let r=t.rangeLimit===void 0?1e3:t.rangeLimit,n=(s,a={})=>{s.queue=[];let i=a,o=a.queue;for(;i.type!=="brace"&&i.type!=="root"&&i.parent;)i=i.parent,o=i.queue;if(s.invalid||s.dollar){o.push(fe(o.pop(),kt(s,t)));return}if(s.type==="brace"&&s.invalid!==!0&&s.nodes.length===2){o.push(fe(o.pop(),["{}"]));return}if(s.nodes&&s.ranges>0){let A=he.reduce(s.nodes);if(he.exceedsLimit(...A,t.step,r))throw new RangeError("expanded array length exceeds range limit. Use options.rangeLimit to increase or disable the limit.");let p=Vr(...A,t);p.length===0&&(p=kt(s,t)),o.push(fe(o.pop(),p)),s.nodes=[];return}let h=he.encloseBrace(s),g=s.queue,f=s;for(;f.type!=="brace"&&f.type!=="root"&&f.parent;)f=f.parent,g=f.queue;for(let A=0;A{"use strict";Lt.exports={MAX_LENGTH:1024*64,CHAR_0:"0",CHAR_9:"9",CHAR_UPPERCASE_A:"A",CHAR_LOWERCASE_A:"a",CHAR_UPPERCASE_Z:"Z",CHAR_LOWERCASE_Z:"z",CHAR_LEFT_PARENTHESES:"(",CHAR_RIGHT_PARENTHESES:")",CHAR_ASTERISK:"*",CHAR_AMPERSAND:"&",CHAR_AT:"@",CHAR_BACKSLASH:"\\",CHAR_BACKTICK:"`",CHAR_CARRIAGE_RETURN:"\r",CHAR_CIRCUMFLEX_ACCENT:"^",CHAR_COLON:":",CHAR_COMMA:",",CHAR_DOLLAR:"$",CHAR_DOT:".",CHAR_DOUBLE_QUOTE:'"',CHAR_EQUAL:"=",CHAR_EXCLAMATION_MARK:"!",CHAR_FORM_FEED:"\f",CHAR_FORWARD_SLASH:"/",CHAR_HASH:"#",CHAR_HYPHEN_MINUS:"-",CHAR_LEFT_ANGLE_BRACKET:"<",CHAR_LEFT_CURLY_BRACE:"{",CHAR_LEFT_SQUARE_BRACKET:"[",CHAR_LINE_FEED:` -`,CHAR_NO_BREAK_SPACE:"\xA0",CHAR_PERCENT:"%",CHAR_PLUS:"+",CHAR_QUESTION_MARK:"?",CHAR_RIGHT_ANGLE_BRACKET:">",CHAR_RIGHT_CURLY_BRACE:"}",CHAR_RIGHT_SQUARE_BRACKET:"]",CHAR_SEMICOLON:";",CHAR_SINGLE_QUOTE:"'",CHAR_SPACE:" ",CHAR_TAB:" ",CHAR_UNDERSCORE:"_",CHAR_VERTICAL_LINE:"|",CHAR_ZERO_WIDTH_NOBREAK_SPACE:"\uFEFF"}});var Pt=K((hs,Nt)=>{"use strict";var en=ke(),{MAX_LENGTH:It,CHAR_BACKSLASH:qe,CHAR_BACKTICK:tn,CHAR_COMMA:rn,CHAR_DOT:nn,CHAR_LEFT_PARENTHESES:sn,CHAR_RIGHT_PARENTHESES:an,CHAR_LEFT_CURLY_BRACE:on,CHAR_RIGHT_CURLY_BRACE:cn,CHAR_LEFT_SQUARE_BRACKET:Bt,CHAR_RIGHT_SQUARE_BRACKET:Mt,CHAR_DOUBLE_QUOTE:un,CHAR_SINGLE_QUOTE:ln,CHAR_NO_BREAK_SPACE:pn,CHAR_ZERO_WIDTH_NOBREAK_SPACE:fn}=Ot(),hn=(e,t={})=>{if(typeof e!="string")throw new TypeError("Expected a string");let r=t||{},n=typeof r.maxLength=="number"?Math.min(It,r.maxLength):It;if(e.length>n)throw new SyntaxError(`Input length (${e.length}), exceeds max characters (${n})`);let s={type:"root",input:e,nodes:[]},a=[s],i=s,o=s,h=0,g=e.length,f=0,A=0,p,k={},y=()=>e[f++],R=_=>{if(_.type==="text"&&o.type==="dot"&&(o.type="text"),o&&o.type==="text"&&_.type==="text"){o.value+=_.value;return}return i.nodes.push(_),_.parent=i,_.prev=o,o=_,_};for(R({type:"bos"});f0){if(i.ranges>0){i.ranges=0;let _=i.nodes.shift();i.nodes=[_,{type:"text",value:en(i)}]}R({type:"comma",value:p}),i.commas++;continue}if(p===nn&&A>0&&i.commas===0){let _=i.nodes;if(A===0||_.length===0){R({type:"text",value:p});continue}if(o.type==="dot"){if(i.range=[],o.value+=p,o.type="range",i.nodes.length!==3&&i.nodes.length!==5){i.invalid=!0,i.ranges=0,o.type="text";continue}i.ranges++,i.args=[];continue}if(o.type==="range"){_.pop();let x=_[_.length-1];x.value+=o.value+p,o=x,i.ranges--;continue}R({type:"dot",value:p});continue}R({type:"text",value:p})}do if(i=a.pop(),i.type!=="root"){i.nodes.forEach(T=>{T.nodes||(T.type==="open"&&(T.isOpen=!0),T.type==="close"&&(T.isClose=!0),T.nodes||(T.type="text"),T.invalid=!0)});let _=a[a.length-1],x=_.nodes.indexOf(i);_.nodes.splice(x,1,...i.nodes)}while(a.length>0);return R({type:"eos"}),s};Nt.exports=hn});var Gt=K((ds,Dt)=>{"use strict";var Ut=ke(),dn=Ht(),gn=Tt(),mn=Pt(),V=(e,t={})=>{let r=[];if(Array.isArray(e))for(let n of e){let s=V.create(n,t);Array.isArray(s)?r.push(...s):r.push(s)}else r=[].concat(V.create(e,t));return t&&t.expand===!0&&t.nodupes===!0&&(r=[...new Set(r)]),r};V.parse=(e,t={})=>mn(e,t);V.stringify=(e,t={})=>typeof e=="string"?Ut(V.parse(e,t),t):Ut(e,t);V.compile=(e,t={})=>(typeof e=="string"&&(e=V.parse(e,t)),dn(e,t));V.expand=(e,t={})=>{typeof e=="string"&&(e=V.parse(e,t));let r=gn(e,t);return t.noempty===!0&&(r=r.filter(Boolean)),t.nodupes===!0&&(r=[...new Set(r)]),r};V.create=(e,t={})=>e===""||e.length<3?[e]:t.expand!==!0?V.compile(e,t):V.expand(e,t);Dt.exports=V});var ye=K((gs,qt)=>{"use strict";var An=require("path"),ie="\\\\/",Kt=`[^${ie}]`,ce="\\.",Rn="\\+",yn="\\?",Le="\\/",bn="(?=.)",Wt="[^/]",Ke=`(?:${Le}|$)`,jt=`(?:^|${Le})`,We=`${ce}{1,2}${Ke}`,_n=`(?!${ce})`,En=`(?!${jt}${We})`,xn=`(?!${ce}{0,1}${Ke})`,Cn=`(?!${We})`,wn=`[^.${Le}]`,Sn=`${Wt}*?`,Ft={DOT_LITERAL:ce,PLUS_LITERAL:Rn,QMARK_LITERAL:yn,SLASH_LITERAL:Le,ONE_CHAR:bn,QMARK:Wt,END_ANCHOR:Ke,DOTS_SLASH:We,NO_DOT:_n,NO_DOTS:En,NO_DOT_SLASH:xn,NO_DOTS_SLASH:Cn,QMARK_NO_DOT:wn,STAR:Sn,START_ANCHOR:jt},vn=Q(B({},Ft),{SLASH_LITERAL:`[${ie}]`,QMARK:Kt,STAR:`${Kt}*?`,DOTS_SLASH:`${ce}{1,2}(?:[${ie}]|$)`,NO_DOT:`(?!${ce})`,NO_DOTS:`(?!(?:^|[${ie}])${ce}{1,2}(?:[${ie}]|$))`,NO_DOT_SLASH:`(?!${ce}{0,1}(?:[${ie}]|$))`,NO_DOTS_SLASH:`(?!${ce}{1,2}(?:[${ie}]|$))`,QMARK_NO_DOT:`[^.${ie}]`,START_ANCHOR:`(?:^|[${ie}])`,END_ANCHOR:`(?:[${ie}]|$)`}),Hn={alnum:"a-zA-Z0-9",alpha:"a-zA-Z",ascii:"\\x00-\\x7F",blank:" \\t",cntrl:"\\x00-\\x1F\\x7F",digit:"0-9",graph:"\\x21-\\x7E",lower:"a-z",print:"\\x20-\\x7E ",punct:"\\-!\"#$%&'()\\*+,./:;<=>?@[\\]^_`{|}~",space:" \\t\\r\\n\\v\\f",upper:"A-Z",word:"A-Za-z0-9_",xdigit:"A-Fa-f0-9"};qt.exports={MAX_LENGTH:1024*64,POSIX_REGEX_SOURCE:Hn,REGEX_BACKSLASH:/\\(?![*+?^${}(|)[\]])/g,REGEX_NON_SPECIAL_CHARS:/^[^@![\].,$*+?^{}()|\\/]+/,REGEX_SPECIAL_CHARS:/[-*+?.^${}(|)[\]]/,REGEX_SPECIAL_CHARS_BACKREF:/(\\?)((\W)(\3*))/g,REGEX_SPECIAL_CHARS_GLOBAL:/([-*+?.^${}(|)[\]])/g,REGEX_REMOVE_BACKSLASH:/(?:\[.*?[^\\]\]|\\(?=.))/g,REPLACEMENTS:{"***":"*","**/**":"**","**/**/**":"**"},CHAR_0:48,CHAR_9:57,CHAR_UPPERCASE_A:65,CHAR_LOWERCASE_A:97,CHAR_UPPERCASE_Z:90,CHAR_LOWERCASE_Z:122,CHAR_LEFT_PARENTHESES:40,CHAR_RIGHT_PARENTHESES:41,CHAR_ASTERISK:42,CHAR_AMPERSAND:38,CHAR_AT:64,CHAR_BACKWARD_SLASH:92,CHAR_CARRIAGE_RETURN:13,CHAR_CIRCUMFLEX_ACCENT:94,CHAR_COLON:58,CHAR_COMMA:44,CHAR_DOT:46,CHAR_DOUBLE_QUOTE:34,CHAR_EQUAL:61,CHAR_EXCLAMATION_MARK:33,CHAR_FORM_FEED:12,CHAR_FORWARD_SLASH:47,CHAR_GRAVE_ACCENT:96,CHAR_HASH:35,CHAR_HYPHEN_MINUS:45,CHAR_LEFT_ANGLE_BRACKET:60,CHAR_LEFT_CURLY_BRACE:123,CHAR_LEFT_SQUARE_BRACKET:91,CHAR_LINE_FEED:10,CHAR_NO_BREAK_SPACE:160,CHAR_PERCENT:37,CHAR_PLUS:43,CHAR_QUESTION_MARK:63,CHAR_RIGHT_ANGLE_BRACKET:62,CHAR_RIGHT_CURLY_BRACE:125,CHAR_RIGHT_SQUARE_BRACKET:93,CHAR_SEMICOLON:59,CHAR_SINGLE_QUOTE:39,CHAR_SPACE:32,CHAR_TAB:9,CHAR_UNDERSCORE:95,CHAR_VERTICAL_LINE:124,CHAR_ZERO_WIDTH_NOBREAK_SPACE:65279,SEP:An.sep,extglobChars(e){return{"!":{type:"negate",open:"(?:(?!(?:",close:`))${e.STAR})`},"?":{type:"qmark",open:"(?:",close:")?"},"+":{type:"plus",open:"(?:",close:")+"},"*":{type:"star",open:"(?:",close:")*"},"@":{type:"at",open:"(?:",close:")"}}},globChars(e){return e===!0?vn:Ft}}});var be=K(Z=>{"use strict";var $n=require("path"),kn=process.platform==="win32",{REGEX_BACKSLASH:Tn,REGEX_REMOVE_BACKSLASH:Ln,REGEX_SPECIAL_CHARS:On,REGEX_SPECIAL_CHARS_GLOBAL:Nn}=ye();Z.isObject=e=>e!==null&&typeof e=="object"&&!Array.isArray(e);Z.hasRegexChars=e=>On.test(e);Z.isRegexChar=e=>e.length===1&&Z.hasRegexChars(e);Z.escapeRegex=e=>e.replace(Nn,"\\$1");Z.toPosixSlashes=e=>e.replace(Tn,"/");Z.removeBackslashes=e=>e.replace(Ln,t=>t==="\\"?"":t);Z.supportsLookbehinds=()=>{let e=process.version.slice(1).split(".").map(Number);return e.length===3&&e[0]>=9||e[0]===8&&e[1]>=10};Z.isWindows=e=>e&&typeof e.windows=="boolean"?e.windows:kn===!0||$n.sep==="\\";Z.escapeLast=(e,t,r)=>{let n=e.lastIndexOf(t,r);return n===-1?e:e[n-1]==="\\"?Z.escapeLast(e,t,n-1):`${e.slice(0,n)}\\${e.slice(n)}`};Z.removePrefix=(e,t={})=>{let r=e;return r.startsWith("./")&&(r=r.slice(2),t.prefix="./"),r};Z.wrapOutput=(e,t={},r={})=>{let n=r.contains?"":"^",s=r.contains?"":"$",a=`${n}(?:${e})${s}`;return t.negated===!0&&(a=`(?:^(?!${a}).*$)`),a}});var er=K((As,Qt)=>{"use strict";var Xt=be(),{CHAR_ASTERISK:je,CHAR_AT:In,CHAR_BACKWARD_SLASH:_e,CHAR_COMMA:Bn,CHAR_DOT:Fe,CHAR_EXCLAMATION_MARK:Qe,CHAR_FORWARD_SLASH:Zt,CHAR_LEFT_CURLY_BRACE:Xe,CHAR_LEFT_PARENTHESES:Ze,CHAR_LEFT_SQUARE_BRACKET:Mn,CHAR_PLUS:Pn,CHAR_QUESTION_MARK:Yt,CHAR_RIGHT_CURLY_BRACE:Dn,CHAR_RIGHT_PARENTHESES:zt,CHAR_RIGHT_SQUARE_BRACKET:Un}=ye(),Vt=e=>e===Zt||e===_e,Jt=e=>{e.isPrefix!==!0&&(e.depth=e.isGlobstar?Infinity:1)},Gn=(e,t)=>{let r=t||{},n=e.length-1,s=r.parts===!0||r.scanToEnd===!0,a=[],i=[],o=[],h=e,g=-1,f=0,A=0,p=!1,k=!1,y=!1,R=!1,_=!1,x=!1,T=!1,O=!1,W=!1,G=!1,ne=0,E,b,C={value:"",depth:0,isGlob:!1},M=()=>g>=n,l=()=>h.charCodeAt(g+1),H=()=>(E=b,h.charCodeAt(++g));for(;g0&&(j=h.slice(0,f),h=h.slice(f),A-=f),w&&y===!0&&A>0?(w=h.slice(0,A),c=h.slice(A)):y===!0?(w="",c=h):w=h,w&&w!==""&&w!=="/"&&w!==h&&Vt(w.charCodeAt(w.length-1))&&(w=w.slice(0,-1)),r.unescape===!0&&(c&&(c=Xt.removeBackslashes(c)),w&&T===!0&&(w=Xt.removeBackslashes(w)));let u={prefix:j,input:e,start:f,base:w,glob:c,isBrace:p,isBracket:k,isGlob:y,isExtglob:R,isGlobstar:_,negated:O,negatedExtglob:W};if(r.tokens===!0&&(u.maxDepth=0,Vt(b)||i.push(C),u.tokens=i),r.parts===!0||r.tokens===!0){let I;for(let $=0;${"use strict";var Oe=ye(),J=be(),{MAX_LENGTH:Ne,POSIX_REGEX_SOURCE:qn,REGEX_NON_SPECIAL_CHARS:Kn,REGEX_SPECIAL_CHARS_BACKREF:Wn,REPLACEMENTS:rr}=Oe,jn=(e,t)=>{if(typeof t.expandRange=="function")return t.expandRange(...e,t);e.sort();let r=`[${e.join("-")}]`;try{new RegExp(r)}catch(n){return e.map(s=>J.escapeRegex(s)).join("..")}return r},de=(e,t)=>`Missing ${e}: "${t}" - use "\\\\${t}" to match literal characters`,nr=(e,t)=>{if(typeof e!="string")throw new TypeError("Expected a string");e=rr[e]||e;let r=B({},t),n=typeof r.maxLength=="number"?Math.min(Ne,r.maxLength):Ne,s=e.length;if(s>n)throw new SyntaxError(`Input length: ${s}, exceeds maximum allowed length: ${n}`);let a={type:"bos",value:"",output:r.prepend||""},i=[a],o=r.capture?"":"?:",h=J.isWindows(t),g=Oe.globChars(h),f=Oe.extglobChars(g),{DOT_LITERAL:A,PLUS_LITERAL:p,SLASH_LITERAL:k,ONE_CHAR:y,DOTS_SLASH:R,NO_DOT:_,NO_DOT_SLASH:x,NO_DOTS_SLASH:T,QMARK:O,QMARK_NO_DOT:W,STAR:G,START_ANCHOR:ne}=g,E=m=>`(${o}(?:(?!${ne}${m.dot?R:A}).)*?)`,b=r.dot?"":_,C=r.dot?O:W,M=r.bash===!0?E(r):G;r.capture&&(M=`(${M})`),typeof r.noext=="boolean"&&(r.noextglob=r.noext);let l={input:e,index:-1,start:0,dot:r.dot===!0,consumed:"",output:"",prefix:"",backtrack:!1,negated:!1,brackets:0,braces:0,parens:0,quotes:0,globstar:!1,tokens:i};e=J.removePrefix(e,l),s=e.length;let H=[],w=[],j=[],c=a,u,I=()=>l.index===s-1,$=l.peek=(m=1)=>e[l.index+m],ee=l.advance=()=>e[++l.index]||"",se=()=>e.slice(l.index+1),z=(m="",L=0)=>{l.consumed+=m,l.index+=L},Ce=m=>{l.output+=m.output!=null?m.output:m.value,z(m.value)},xr=()=>{let m=1;for(;$()==="!"&&($(2)!=="("||$(3)==="?");)ee(),l.start++,m++;return m%2==0?!1:(l.negated=!0,l.start++,!0)},we=m=>{l[m]++,j.push(m)},ue=m=>{l[m]--,j.pop()},v=m=>{if(c.type==="globstar"){let L=l.braces>0&&(m.type==="comma"||m.type==="brace"),d=m.extglob===!0||H.length&&(m.type==="pipe"||m.type==="paren");m.type!=="slash"&&m.type!=="paren"&&!L&&!d&&(l.output=l.output.slice(0,-c.output.length),c.type="star",c.value="*",c.output=M,l.output+=c.output)}if(H.length&&m.type!=="paren"&&(H[H.length-1].inner+=m.value),(m.value||m.output)&&Ce(m),c&&c.type==="text"&&m.type==="text"){c.value+=m.value,c.output=(c.output||"")+m.value;return}m.prev=c,i.push(m),c=m},Se=(m,L)=>{let d=Q(B({},f[L]),{conditions:1,inner:""});d.prev=c,d.parens=l.parens,d.output=l.output;let S=(r.capture?"(":"")+d.open;we("parens"),v({type:m,value:L,output:l.output?"":y}),v({type:"paren",extglob:!0,value:ee(),output:S}),H.push(d)},Cr=m=>{let L=m.close+(r.capture?")":""),d;if(m.type==="negate"){let S=M;m.inner&&m.inner.length>1&&m.inner.includes("/")&&(S=E(r)),(S!==M||I()||/^\)+$/.test(se()))&&(L=m.close=`)$))${S}`),m.inner.includes("*")&&(d=se())&&/^\.[^\\/.]+$/.test(d)&&(L=m.close=`)${d})${S})`),m.prev.type==="bos"&&(l.negatedExtglob=!0)}v({type:"paren",extglob:!0,value:u,output:L}),ue("parens")};if(r.fastpaths!==!1&&!/(^[*!]|[/()[\]{}"])/.test(e)){let m=!1,L=e.replace(Wn,(d,S,P,F,q,Me)=>F==="\\"?(m=!0,d):F==="?"?S?S+F+(q?O.repeat(q.length):""):Me===0?C+(q?O.repeat(q.length):""):O.repeat(P.length):F==="."?A.repeat(P.length):F==="*"?S?S+F+(q?M:""):M:S?d:`\\${d}`);return m===!0&&(r.unescape===!0?L=L.replace(/\\/g,""):L=L.replace(/\\+/g,d=>d.length%2==0?"\\\\":d?"\\":"")),L===e&&r.contains===!0?(l.output=e,l):(l.output=J.wrapOutput(L,l,t),l)}for(;!I();){if(u=ee(),u==="\0")continue;if(u==="\\"){let d=$();if(d==="/"&&r.bash!==!0||d==="."||d===";")continue;if(!d){u+="\\",v({type:"text",value:u});continue}let S=/^\\+/.exec(se()),P=0;if(S&&S[0].length>2&&(P=S[0].length,l.index+=P,P%2!=0&&(u+="\\")),r.unescape===!0?u=ee():u+=ee(),l.brackets===0){v({type:"text",value:u});continue}}if(l.brackets>0&&(u!=="]"||c.value==="["||c.value==="[^")){if(r.posix!==!1&&u===":"){let d=c.value.slice(1);if(d.includes("[")&&(c.posix=!0,d.includes(":"))){let S=c.value.lastIndexOf("["),P=c.value.slice(0,S),F=c.value.slice(S+2),q=qn[F];if(q){c.value=P+q,l.backtrack=!0,ee(),!a.output&&i.indexOf(c)===1&&(a.output=y);continue}}}(u==="["&&$()!==":"||u==="-"&&$()==="]")&&(u=`\\${u}`),u==="]"&&(c.value==="["||c.value==="[^")&&(u=`\\${u}`),r.posix===!0&&u==="!"&&c.value==="["&&(u="^"),c.value+=u,Ce({value:u});continue}if(l.quotes===1&&u!=='"'){u=J.escapeRegex(u),c.value+=u,Ce({value:u});continue}if(u==='"'){l.quotes=l.quotes===1?0:1,r.keepQuotes===!0&&v({type:"text",value:u});continue}if(u==="("){we("parens"),v({type:"paren",value:u});continue}if(u===")"){if(l.parens===0&&r.strictBrackets===!0)throw new SyntaxError(de("opening","("));let d=H[H.length-1];if(d&&l.parens===d.parens+1){Cr(H.pop());continue}v({type:"paren",value:u,output:l.parens?")":"\\)"}),ue("parens");continue}if(u==="["){if(r.nobracket===!0||!se().includes("]")){if(r.nobracket!==!0&&r.strictBrackets===!0)throw new SyntaxError(de("closing","]"));u=`\\${u}`}else we("brackets");v({type:"bracket",value:u});continue}if(u==="]"){if(r.nobracket===!0||c&&c.type==="bracket"&&c.value.length===1){v({type:"text",value:u,output:`\\${u}`});continue}if(l.brackets===0){if(r.strictBrackets===!0)throw new SyntaxError(de("opening","["));v({type:"text",value:u,output:`\\${u}`});continue}ue("brackets");let d=c.value.slice(1);if(c.posix!==!0&&d[0]==="^"&&!d.includes("/")&&(u=`/${u}`),c.value+=u,Ce({value:u}),r.literalBrackets===!1||J.hasRegexChars(d))continue;let S=J.escapeRegex(c.value);if(l.output=l.output.slice(0,-c.value.length),r.literalBrackets===!0){l.output+=S,c.value=S;continue}c.value=`(${o}${S}|${c.value})`,l.output+=c.value;continue}if(u==="{"&&r.nobrace!==!0){we("braces");let d={type:"brace",value:u,output:"(",outputIndex:l.output.length,tokensIndex:l.tokens.length};w.push(d),v(d);continue}if(u==="}"){let d=w[w.length-1];if(r.nobrace===!0||!d){v({type:"text",value:u,output:u});continue}let S=")";if(d.dots===!0){let P=i.slice(),F=[];for(let q=P.length-1;q>=0&&(i.pop(),P[q].type!=="brace");q--)P[q].type!=="dots"&&F.unshift(P[q].value);S=jn(F,r),l.backtrack=!0}if(d.comma!==!0&&d.dots!==!0){let P=l.output.slice(0,d.outputIndex),F=l.tokens.slice(d.tokensIndex);d.value=d.output="\\{",u=S="\\}",l.output=P;for(let q of F)l.output+=q.output||q.value}v({type:"brace",value:u,output:S}),ue("braces"),w.pop();continue}if(u==="|"){H.length>0&&H[H.length-1].conditions++,v({type:"text",value:u});continue}if(u===","){let d=u,S=w[w.length-1];S&&j[j.length-1]==="braces"&&(S.comma=!0,d="|"),v({type:"comma",value:u,output:d});continue}if(u==="/"){if(c.type==="dot"&&l.index===l.start+1){l.start=l.index+1,l.consumed="",l.output="",i.pop(),c=a;continue}v({type:"slash",value:u,output:k});continue}if(u==="."){if(l.braces>0&&c.type==="dot"){c.value==="."&&(c.output=A);let d=w[w.length-1];c.type="dots",c.output+=u,c.value+=u,d.dots=!0;continue}if(l.braces+l.parens===0&&c.type!=="bos"&&c.type!=="slash"){v({type:"text",value:u,output:A});continue}v({type:"dot",value:u,output:A});continue}if(u==="?"){if(!(c&&c.value==="(")&&r.noextglob!==!0&&$()==="("&&$(2)!=="?"){Se("qmark",u);continue}if(c&&c.type==="paren"){let S=$(),P=u;if(S==="<"&&!J.supportsLookbehinds())throw new Error("Node.js v10 or higher is required for regex lookbehinds");(c.value==="("&&!/[!=<:]/.test(S)||S==="<"&&!/<([!=]|\w+>)/.test(se()))&&(P=`\\${u}`),v({type:"text",value:u,output:P});continue}if(r.dot!==!0&&(c.type==="slash"||c.type==="bos")){v({type:"qmark",value:u,output:W});continue}v({type:"qmark",value:u,output:O});continue}if(u==="!"){if(r.noextglob!==!0&&$()==="("&&($(2)!=="?"||!/[!=<:]/.test($(3)))){Se("negate",u);continue}if(r.nonegate!==!0&&l.index===0){xr();continue}}if(u==="+"){if(r.noextglob!==!0&&$()==="("&&$(2)!=="?"){Se("plus",u);continue}if(c&&c.value==="("||r.regex===!1){v({type:"plus",value:u,output:p});continue}if(c&&(c.type==="bracket"||c.type==="paren"||c.type==="brace")||l.parens>0){v({type:"plus",value:u});continue}v({type:"plus",value:p});continue}if(u==="@"){if(r.noextglob!==!0&&$()==="("&&$(2)!=="?"){v({type:"at",extglob:!0,value:u,output:""});continue}v({type:"text",value:u});continue}if(u!=="*"){(u==="$"||u==="^")&&(u=`\\${u}`);let d=Kn.exec(se());d&&(u+=d[0],l.index+=d[0].length),v({type:"text",value:u});continue}if(c&&(c.type==="globstar"||c.star===!0)){c.type="star",c.star=!0,c.value+=u,c.output=M,l.backtrack=!0,l.globstar=!0,z(u);continue}let m=se();if(r.noextglob!==!0&&/^\([^?]/.test(m)){Se("star",u);continue}if(c.type==="star"){if(r.noglobstar===!0){z(u);continue}let d=c.prev,S=d.prev,P=d.type==="slash"||d.type==="bos",F=S&&(S.type==="star"||S.type==="globstar");if(r.bash===!0&&(!P||m[0]&&m[0]!=="/")){v({type:"star",value:u,output:""});continue}let q=l.braces>0&&(d.type==="comma"||d.type==="brace"),Me=H.length&&(d.type==="pipe"||d.type==="paren");if(!P&&d.type!=="paren"&&!q&&!Me){v({type:"star",value:u,output:""});continue}for(;m.slice(0,3)==="/**";){let ve=e[l.index+4];if(ve&&ve!=="/")break;m=m.slice(3),z("/**",3)}if(d.type==="bos"&&I()){c.type="globstar",c.value+=u,c.output=E(r),l.output=c.output,l.globstar=!0,z(u);continue}if(d.type==="slash"&&d.prev.type!=="bos"&&!F&&I()){l.output=l.output.slice(0,-(d.output+c.output).length),d.output=`(?:${d.output}`,c.type="globstar",c.output=E(r)+(r.strictSlashes?")":"|$)"),c.value+=u,l.globstar=!0,l.output+=d.output+c.output,z(u);continue}if(d.type==="slash"&&d.prev.type!=="bos"&&m[0]==="/"){let ve=m[1]!==void 0?"|$":"";l.output=l.output.slice(0,-(d.output+c.output).length),d.output=`(?:${d.output}`,c.type="globstar",c.output=`${E(r)}${k}|${k}${ve})`,c.value+=u,l.output+=d.output+c.output,l.globstar=!0,z(u+ee()),v({type:"slash",value:"/",output:""});continue}if(d.type==="bos"&&m[0]==="/"){c.type="globstar",c.value+=u,c.output=`(?:^|${k}|${E(r)}${k})`,l.output=c.output,l.globstar=!0,z(u+ee()),v({type:"slash",value:"/",output:""});continue}l.output=l.output.slice(0,-c.output.length),c.type="globstar",c.output=E(r),c.value+=u,l.output+=c.output,l.globstar=!0,z(u);continue}let L={type:"star",value:u,output:M};if(r.bash===!0){L.output=".*?",(c.type==="bos"||c.type==="slash")&&(L.output=b+L.output),v(L);continue}if(c&&(c.type==="bracket"||c.type==="paren")&&r.regex===!0){L.output=u,v(L);continue}(l.index===l.start||c.type==="slash"||c.type==="dot")&&(c.type==="dot"?(l.output+=x,c.output+=x):r.dot===!0?(l.output+=T,c.output+=T):(l.output+=b,c.output+=b),$()!=="*"&&(l.output+=y,c.output+=y)),v(L)}for(;l.brackets>0;){if(r.strictBrackets===!0)throw new SyntaxError(de("closing","]"));l.output=J.escapeLast(l.output,"["),ue("brackets")}for(;l.parens>0;){if(r.strictBrackets===!0)throw new SyntaxError(de("closing",")"));l.output=J.escapeLast(l.output,"("),ue("parens")}for(;l.braces>0;){if(r.strictBrackets===!0)throw new SyntaxError(de("closing","}"));l.output=J.escapeLast(l.output,"{"),ue("braces")}if(r.strictSlashes!==!0&&(c.type==="star"||c.type==="bracket")&&v({type:"maybe_slash",value:"",output:`${k}?`}),l.backtrack===!0){l.output="";for(let m of l.tokens)l.output+=m.output!=null?m.output:m.value,m.suffix&&(l.output+=m.suffix)}return l};nr.fastpaths=(e,t)=>{let r=B({},t),n=typeof r.maxLength=="number"?Math.min(Ne,r.maxLength):Ne,s=e.length;if(s>n)throw new SyntaxError(`Input length: ${s}, exceeds maximum allowed length: ${n}`);e=rr[e]||e;let a=J.isWindows(t),{DOT_LITERAL:i,SLASH_LITERAL:o,ONE_CHAR:h,DOTS_SLASH:g,NO_DOT:f,NO_DOTS:A,NO_DOTS_SLASH:p,STAR:k,START_ANCHOR:y}=Oe.globChars(a),R=r.dot?A:f,_=r.dot?p:f,x=r.capture?"":"?:",T={negated:!1,prefix:""},O=r.bash===!0?".*?":k;r.capture&&(O=`(${O})`);let W=b=>b.noglobstar===!0?O:`(${x}(?:(?!${y}${b.dot?g:i}).)*?)`,G=b=>{switch(b){case"*":return`${R}${h}${O}`;case".*":return`${i}${h}${O}`;case"*.*":return`${R}${O}${i}${h}${O}`;case"*/*":return`${R}${O}${o}${h}${_}${O}`;case"**":return R+W(r);case"**/*":return`(?:${R}${W(r)}${o})?${_}${h}${O}`;case"**/*.*":return`(?:${R}${W(r)}${o})?${_}${O}${i}${h}${O}`;case"**/.*":return`(?:${R}${W(r)}${o})?${i}${h}${O}`;default:{let C=/^(.*?)\.(\w+)$/.exec(b);if(!C)return;let M=G(C[1]);return M?M+i+C[2]:void 0}}},ne=J.removePrefix(e,T),E=G(ne);return E&&r.strictSlashes!==!0&&(E+=`${o}?`),E};tr.exports=nr});var ir=K((ys,ar)=>{"use strict";var Fn=require("path"),Qn=er(),Ye=sr(),ze=be(),Xn=ye(),Zn=e=>e&&typeof e=="object"&&!Array.isArray(e),D=(e,t,r=!1)=>{if(Array.isArray(e)){let f=e.map(p=>D(p,t,r));return p=>{for(let k of f){let y=k(p);if(y)return y}return!1}}let n=Zn(e)&&e.tokens&&e.input;if(e===""||typeof e!="string"&&!n)throw new TypeError("Expected pattern to be a non-empty string");let s=t||{},a=ze.isWindows(t),i=n?D.compileRe(e,t):D.makeRe(e,t,!1,!0),o=i.state;delete i.state;let h=()=>!1;if(s.ignore){let f=Q(B({},t),{ignore:null,onMatch:null,onResult:null});h=D(s.ignore,f,r)}let g=(f,A=!1)=>{let{isMatch:p,match:k,output:y}=D.test(f,i,t,{glob:e,posix:a}),R={glob:e,state:o,regex:i,posix:a,input:f,output:y,match:k,isMatch:p};return typeof s.onResult=="function"&&s.onResult(R),p===!1?(R.isMatch=!1,A?R:!1):h(f)?(typeof s.onIgnore=="function"&&s.onIgnore(R),R.isMatch=!1,A?R:!1):(typeof s.onMatch=="function"&&s.onMatch(R),A?R:!0)};return r&&(g.state=o),g};D.test=(e,t,r,{glob:n,posix:s}={})=>{if(typeof e!="string")throw new TypeError("Expected input to be a string");if(e==="")return{isMatch:!1,output:""};let a=r||{},i=a.format||(s?ze.toPosixSlashes:null),o=e===n,h=o&&i?i(e):e;return o===!1&&(h=i?i(e):e,o=h===n),(o===!1||a.capture===!0)&&(a.matchBase===!0||a.basename===!0?o=D.matchBase(e,t,r,s):o=t.exec(h)),{isMatch:Boolean(o),match:o,output:h}};D.matchBase=(e,t,r,n=ze.isWindows(r))=>(t instanceof RegExp?t:D.makeRe(t,r)).test(Fn.basename(e));D.isMatch=(e,t,r)=>D(t,r)(e);D.parse=(e,t)=>Array.isArray(e)?e.map(r=>D.parse(r,t)):Ye(e,Q(B({},t),{fastpaths:!1}));D.scan=(e,t)=>Qn(e,t);D.compileRe=(e,t,r=!1,n=!1)=>{if(r===!0)return e.output;let s=t||{},a=s.contains?"":"^",i=s.contains?"":"$",o=`${a}(?:${e.output})${i}`;e&&e.negated===!0&&(o=`^(?!${o}).*$`);let h=D.toRegex(o,t);return n===!0&&(h.state=e),h};D.makeRe=(e,t={},r=!1,n=!1)=>{if(!e||typeof e!="string")throw new TypeError("Expected a non-empty string");let s={negated:!1,fastpaths:!0};return t.fastpaths!==!1&&(e[0]==="."||e[0]==="*")&&(s.output=Ye.fastpaths(e,t)),s.output||(s=Ye(e,t)),D.compileRe(s,t,r,n)};D.toRegex=(e,t)=>{try{let r=t||{};return new RegExp(e,r.flags||(r.nocase?"i":""))}catch(r){if(t&&t.debug===!0)throw r;return/$^/}};D.constants=Xn;ar.exports=D});var cr=K((bs,or)=>{"use strict";or.exports=ir()});var hr=K((_s,ur)=>{"use strict";var lr=require("util"),pr=Gt(),oe=cr(),Ve=be(),fr=e=>e===""||e==="./",N=(e,t,r)=>{t=[].concat(t),e=[].concat(e);let n=new Set,s=new Set,a=new Set,i=0,o=f=>{a.add(f.output),r&&r.onResult&&r.onResult(f)};for(let f=0;f!n.has(f));if(r&&g.length===0){if(r.failglob===!0)throw new Error(`No matches found for "${t.join(", ")}"`);if(r.nonull===!0||r.nullglob===!0)return r.unescape?t.map(f=>f.replace(/\\/g,"")):t}return g};N.match=N;N.matcher=(e,t)=>oe(e,t);N.isMatch=(e,t,r)=>oe(t,r)(e);N.any=N.isMatch;N.not=(e,t,r={})=>{t=[].concat(t).map(String);let n=new Set,s=[],a=o=>{r.onResult&&r.onResult(o),s.push(o.output)},i=N(e,t,Q(B({},r),{onResult:a}));for(let o of s)i.includes(o)||n.add(o);return[...n]};N.contains=(e,t,r)=>{if(typeof e!="string")throw new TypeError(`Expected a string: "${lr.inspect(e)}"`);if(Array.isArray(t))return t.some(n=>N.contains(e,n,r));if(typeof t=="string"){if(fr(e)||fr(t))return!1;if(e.includes(t)||e.startsWith("./")&&e.slice(2).includes(t))return!0}return N.isMatch(e,t,Q(B({},r),{contains:!0}))};N.matchKeys=(e,t,r)=>{if(!Ve.isObject(e))throw new TypeError("Expected the first argument to be an object");let n=N(Object.keys(e),t,r),s={};for(let a of n)s[a]=e[a];return s};N.some=(e,t,r)=>{let n=[].concat(e);for(let s of[].concat(t)){let a=oe(String(s),r);if(n.some(i=>a(i)))return!0}return!1};N.every=(e,t,r)=>{let n=[].concat(e);for(let s of[].concat(t)){let a=oe(String(s),r);if(!n.every(i=>a(i)))return!1}return!0};N.all=(e,t,r)=>{if(typeof e!="string")throw new TypeError(`Expected a string: "${lr.inspect(e)}"`);return[].concat(t).every(n=>oe(n,r)(e))};N.capture=(e,t,r)=>{let n=Ve.isWindows(r),a=oe.makeRe(String(e),Q(B({},r),{capture:!0})).exec(n?Ve.toPosixSlashes(t):t);if(a)return a.slice(1).map(i=>i===void 0?"":i)};N.makeRe=(...e)=>oe.makeRe(...e);N.scan=(...e)=>oe.scan(...e);N.parse=(e,t)=>{let r=[];for(let n of[].concat(e||[]))for(let s of pr(String(n),t))r.push(oe.parse(s,t));return r};N.braces=(e,t)=>{if(typeof e!="string")throw new TypeError("Expected a string");return t&&t.nobrace===!0||!/\{.*\}/.test(e)?[e]:pr(e,t)};N.braceExpand=(e,t)=>{if(typeof e!="string")throw new TypeError("Expected a string");return N.braces(e,Q(B({},t),{expand:!0}))};ur.exports=N});var gr=K((Es,dr)=>{"use strict";dr.exports=(e,...t)=>new Promise(r=>{r(e(...t))})});var Ar=K((xs,Je)=>{"use strict";var Yn=gr(),mr=e=>{if(e<1)throw new TypeError("Expected `concurrency` to be a number from 1 and up");let t=[],r=0,n=()=>{r--,t.length>0&&t.shift()()},s=(o,h,...g)=>{r++;let f=Yn(o,...g);h(f),f.then(n,n)},a=(o,h,...g)=>{rnew Promise(g=>a(o,g,...h));return Object.defineProperties(i,{activeCount:{get:()=>r},pendingCount:{get:()=>t.length}}),i};Je.exports=mr;Je.exports.default=mr});var Vn={};Or(Vn,{default:()=>es});var He=X(require("@yarnpkg/cli")),ae=X(require("@yarnpkg/core")),nt=X(require("@yarnpkg/core")),le=X(require("clipanion")),Ae=class extends He.BaseCommand{constructor(){super(...arguments);this.json=le.Option.Boolean("--json",!1,{description:"Format the output as an NDJSON stream"});this.production=le.Option.Boolean("--production",!1,{description:"Only install regular dependencies by omitting dev dependencies"});this.all=le.Option.Boolean("-A,--all",!1,{description:"Install the entire project"});this.workspaces=le.Option.Rest()}async execute(){let t=await ae.Configuration.find(this.context.cwd,this.context.plugins),{project:r,workspace:n}=await ae.Project.find(t,this.context.cwd),s=await ae.Cache.find(t);await r.restoreInstallState({restoreResolutions:!1});let a;if(this.all)a=new Set(r.workspaces);else if(this.workspaces.length===0){if(!n)throw new He.WorkspaceRequiredError(r.cwd,this.context.cwd);a=new Set([n])}else a=new Set(this.workspaces.map(o=>r.getWorkspaceByIdent(nt.structUtils.parseIdent(o))));for(let o of a)for(let h of this.production?["dependencies"]:ae.Manifest.hardDependencies)for(let g of o.manifest.getForScope(h).values()){let f=r.tryWorkspaceByDescriptor(g);f!==null&&a.add(f)}for(let o of r.workspaces)a.has(o)?this.production&&o.manifest.devDependencies.clear():(o.manifest.installConfig=o.manifest.installConfig||{},o.manifest.installConfig.selfReferences=!1,o.manifest.dependencies.clear(),o.manifest.devDependencies.clear(),o.manifest.peerDependencies.clear(),o.manifest.scripts.clear());return(await ae.StreamReport.start({configuration:t,json:this.json,stdout:this.context.stdout,includeLogs:!0},async o=>{await r.install({cache:s,report:o,persistProject:!1})})).exitCode()}};Ae.paths=[["workspaces","focus"]],Ae.usage=le.Command.Usage({category:"Workspace-related commands",description:"install a single workspace and its dependencies",details:"\n This command will run an install as if the specified workspaces (and all other workspaces they depend on) were the only ones in the project. If no workspaces are explicitly listed, the active one will be assumed.\n\n Note that this command is only very moderately useful when using zero-installs, since the cache will contain all the packages anyway - meaning that the only difference between a full install and a focused install would just be a few extra lines in the `.pnp.cjs` file, at the cost of introducing an extra complexity.\n\n If the `-A,--all` flag is set, the entire project will be installed. Combine with `--production` to replicate the old `yarn install --production`.\n "});var st=Ae;var Ie=X(require("@yarnpkg/cli")),ge=X(require("@yarnpkg/core")),Ee=X(require("@yarnpkg/core")),Y=X(require("@yarnpkg/core")),Rr=X(require("@yarnpkg/plugin-git")),U=X(require("clipanion")),Be=X(hr()),yr=X(require("os")),br=X(Ar()),re=X(require("typanion")),xe=class extends Ie.BaseCommand{constructor(){super(...arguments);this.recursive=U.Option.Boolean("-R,--recursive",!1,{description:"Find packages via dependencies/devDependencies instead of using the workspaces field"});this.from=U.Option.Array("--from",[],{description:"An array of glob pattern idents from which to base any recursion"});this.all=U.Option.Boolean("-A,--all",!1,{description:"Run the command on all workspaces of a project"});this.verbose=U.Option.Boolean("-v,--verbose",!1,{description:"Prefix each output line with the name of the originating workspace"});this.parallel=U.Option.Boolean("-p,--parallel",!1,{description:"Run the commands in parallel"});this.interlaced=U.Option.Boolean("-i,--interlaced",!1,{description:"Print the output of commands in real-time instead of buffering it"});this.jobs=U.Option.String("-j,--jobs",{description:"The maximum number of parallel tasks that the execution will be limited to; or `unlimited`",validator:re.isOneOf([re.isEnum(["unlimited"]),re.applyCascade(re.isNumber(),[re.isInteger(),re.isAtLeast(1)])])});this.topological=U.Option.Boolean("-t,--topological",!1,{description:"Run the command after all workspaces it depends on (regular) have finished"});this.topologicalDev=U.Option.Boolean("--topological-dev",!1,{description:"Run the command after all workspaces it depends on (regular + dev) have finished"});this.include=U.Option.Array("--include",[],{description:"An array of glob pattern idents; only matching workspaces will be traversed"});this.exclude=U.Option.Array("--exclude",[],{description:"An array of glob pattern idents; matching workspaces won't be traversed"});this.publicOnly=U.Option.Boolean("--no-private",{description:"Avoid running the command on private workspaces"});this.since=U.Option.String("--since",{description:"Only include workspaces that have been changed since the specified ref.",tolerateBoolean:!0});this.commandName=U.Option.String();this.args=U.Option.Proxy()}async execute(){let t=await ge.Configuration.find(this.context.cwd,this.context.plugins),{project:r,workspace:n}=await ge.Project.find(t,this.context.cwd);if(!this.all&&!n)throw new Ie.WorkspaceRequiredError(r.cwd,this.context.cwd);await r.restoreInstallState();let s=this.cli.process([this.commandName,...this.args]),a=s.path.length===1&&s.path[0]==="run"&&typeof s.scriptName!="undefined"?s.scriptName:null;if(s.path.length===0)throw new U.UsageError("Invalid subcommand name for iteration - use the 'run' keyword if you wish to execute a script");let i=this.all?r.topLevelWorkspace:n,o=this.since?Array.from(await Rr.gitUtils.fetchChangedWorkspaces({ref:this.since,project:r})):[i,...this.from.length>0?i.getRecursiveWorkspaceChildren():[]],h=E=>Be.default.isMatch(Y.structUtils.stringifyIdent(E.locator),this.from),g=this.from.length>0?o.filter(h):o,f=new Set([...g,...g.map(E=>[...this.recursive?this.since?E.getRecursiveWorkspaceDependents():E.getRecursiveWorkspaceDependencies():E.getRecursiveWorkspaceChildren()]).flat()]),A=[],p=!1;if(a==null?void 0:a.includes(":")){for(let E of r.workspaces)if(E.manifest.scripts.has(a)&&(p=!p,p===!1))break}for(let E of f)a&&!E.manifest.scripts.has(a)&&!p&&!(await ge.scriptUtils.getWorkspaceAccessibleBinaries(E)).has(a)||a===process.env.npm_lifecycle_event&&E.cwd===n.cwd||this.include.length>0&&!Be.default.isMatch(Y.structUtils.stringifyIdent(E.locator),this.include)||this.exclude.length>0&&Be.default.isMatch(Y.structUtils.stringifyIdent(E.locator),this.exclude)||this.publicOnly&&E.manifest.private===!0||A.push(E);let k=this.parallel?this.jobs==="unlimited"?Infinity:this.jobs||Math.max(1,(0,yr.cpus)().length/2):1,y=k===1?!1:this.parallel,R=y?this.interlaced:!0,_=(0,br.default)(k),x=new Map,T=new Set,O=0,W=null,G=!1,ne=await Ee.StreamReport.start({configuration:t,stdout:this.context.stdout},async E=>{let b=async(C,{commandIndex:M})=>{if(G)return-1;!y&&this.verbose&&M>1&&E.reportSeparator();let l=zn(C,{configuration:t,verbose:this.verbose,commandIndex:M}),[H,w]=_r(E,{prefix:l,interlaced:R}),[j,c]=_r(E,{prefix:l,interlaced:R});try{this.verbose&&E.reportInfo(null,`${l} Process started`);let u=Date.now(),I=await this.cli.run([this.commandName,...this.args],{cwd:C.cwd,stdout:H,stderr:j})||0;H.end(),j.end(),await w,await c;let $=Date.now();if(this.verbose){let ee=t.get("enableTimers")?`, completed in ${Y.formatUtils.pretty(t,$-u,Y.formatUtils.Type.DURATION)}`:"";E.reportInfo(null,`${l} Process exited (exit code ${I})${ee}`)}return I===130&&(G=!0,W=I),I}catch(u){throw H.end(),j.end(),await w,await c,u}};for(let C of A)x.set(C.anchoredLocator.locatorHash,C);for(;x.size>0&&!E.hasErrors();){let C=[];for(let[H,w]of x){if(T.has(w.anchoredDescriptor.descriptorHash))continue;let j=!0;if(this.topological||this.topologicalDev){let c=this.topologicalDev?new Map([...w.manifest.dependencies,...w.manifest.devDependencies]):w.manifest.dependencies;for(let u of c.values()){let I=r.tryWorkspaceByDescriptor(u);if(j=I===null||!x.has(I.anchoredLocator.locatorHash),!j)break}}if(!!j&&(T.add(w.anchoredDescriptor.descriptorHash),C.push(_(async()=>{let c=await b(w,{commandIndex:++O});return x.delete(H),T.delete(w.anchoredDescriptor.descriptorHash),c})),!y))break}if(C.length===0){let H=Array.from(x.values()).map(w=>Y.structUtils.prettyLocator(t,w.anchoredLocator)).join(", ");E.reportError(Ee.MessageName.CYCLIC_DEPENDENCIES,`Dependency cycle detected (${H})`);return}let l=(await Promise.all(C)).find(H=>H!==0);W===null&&(W=typeof l!="undefined"?1:W),(this.topological||this.topologicalDev)&&typeof l!="undefined"&&E.reportError(Ee.MessageName.UNNAMED,"The command failed for workspaces that are depended upon by other workspaces; can't satisfy the dependency graph")}});return W!==null?W:ne.exitCode()}};xe.paths=[["workspaces","foreach"]],xe.usage=U.Command.Usage({category:"Workspace-related commands",description:"run a command on all workspaces",details:"\n This command will run a given sub-command on current and all its descendant workspaces. Various flags can alter the exact behavior of the command:\n\n - If `-p,--parallel` is set, the commands will be ran in parallel; they'll by default be limited to a number of parallel tasks roughly equal to half your core number, but that can be overridden via `-j,--jobs`, or disabled by setting `-j unlimited`.\n\n - If `-p,--parallel` and `-i,--interlaced` are both set, Yarn will print the lines from the output as it receives them. If `-i,--interlaced` wasn't set, it would instead buffer the output from each process and print the resulting buffers only after their source processes have exited.\n\n - If `-t,--topological` is set, Yarn will only run the command after all workspaces that it depends on through the `dependencies` field have successfully finished executing. If `--topological-dev` is set, both the `dependencies` and `devDependencies` fields will be considered when figuring out the wait points.\n\n - If `-A,--all` is set, Yarn will run the command on all the workspaces of a project. By default yarn runs the command only on current and all its descendant workspaces.\n\n - If `-R,--recursive` is set, Yarn will find workspaces to run the command on by recursively evaluating `dependencies` and `devDependencies` fields, instead of looking at the `workspaces` fields.\n\n - If `--from` is set, Yarn will use the packages matching the 'from' glob as the starting point for any recursive search.\n\n - If `--since` is set, Yarn will only run the command on workspaces that have been modified since the specified ref. By default Yarn will use the refs specified by the `changesetBaseRefs` configuration option.\n\n - The command may apply to only some workspaces through the use of `--include` which acts as a whitelist. The `--exclude` flag will do the opposite and will be a list of packages that mustn't execute the script. Both flags accept glob patterns (if valid Idents and supported by [micromatch](https://github.com/micromatch/micromatch)). Make sure to escape the patterns, to prevent your own shell from trying to expand them.\n\n Adding the `-v,--verbose` flag will cause Yarn to print more information; in particular the name of the workspace that generated the output will be printed at the front of each line.\n\n If the command is `run` and the script being run does not exist the child workspace will be skipped without error.\n ",examples:[["Publish current and all descendant packages","yarn workspaces foreach npm publish --tolerate-republish"],["Run build script on current and all descendant packages","yarn workspaces foreach run build"],["Run build script on current and all descendant packages in parallel, building package dependencies first","yarn workspaces foreach -pt run build"],["Run build script on several packages and all their dependencies, building dependencies first","yarn workspaces foreach -ptR --from '{workspace-a,workspace-b}' run build"]]});var Er=xe;function _r(e,{prefix:t,interlaced:r}){let n=e.createStreamReporter(t),s=new Y.miscUtils.DefaultStream;s.pipe(n,{end:!1}),s.on("finish",()=>{n.end()});let a=new Promise(o=>{n.on("finish",()=>{o(s.active)})});if(r)return[s,a];let i=new Y.miscUtils.BufferStream;return i.pipe(s,{end:!1}),i.on("finish",()=>{s.end()}),[i,a]}function zn(e,{configuration:t,commandIndex:r,verbose:n}){if(!n)return null;let s=Y.structUtils.convertToIdent(e.locator),i=`[${Y.structUtils.stringifyIdent(s)}]:`,o=["#2E86AB","#A23B72","#F18F01","#C73E1D","#CCE2A3"],h=o[r%o.length];return Y.formatUtils.pretty(t,i,h)}var Jn={commands:[st,Er]},es=Jn;return Vn;})(); -/*! - * fill-range - * - * Copyright (c) 2014-present, Jon Schlinkert. - * Licensed under the MIT License. - */ -/*! - * is-number - * - * Copyright (c) 2014-present, Jon Schlinkert. - * Released under the MIT License. - */ -/*! - * to-regex-range - * - * Copyright (c) 2015-present, Jon Schlinkert. - * Released under the MIT License. - */ -return plugin; -} -}; diff --git a/.yarn/releases/yarn-3.3.0.cjs b/.yarn/releases/yarn-3.3.0.cjs deleted file mode 100755 index 47f24f66e01..00000000000 --- a/.yarn/releases/yarn-3.3.0.cjs +++ /dev/null @@ -1,807 +0,0 @@ -#!/usr/bin/env node -/* eslint-disable */ -//prettier-ignore -(()=>{var lfe=Object.create;var GS=Object.defineProperty;var cfe=Object.getOwnPropertyDescriptor;var ufe=Object.getOwnPropertyNames;var gfe=Object.getPrototypeOf,ffe=Object.prototype.hasOwnProperty;var J=(r=>typeof require<"u"?require:typeof Proxy<"u"?new Proxy(r,{get:(e,t)=>(typeof require<"u"?require:e)[t]}):r)(function(r){if(typeof require<"u")return require.apply(this,arguments);throw new Error('Dynamic require of "'+r+'" is not supported')});var y=(r,e)=>()=>(e||r((e={exports:{}}).exports,e),e.exports),ht=(r,e)=>{for(var t in e)GS(r,t,{get:e[t],enumerable:!0})},hfe=(r,e,t,i)=>{if(e&&typeof e=="object"||typeof e=="function")for(let n of ufe(e))!ffe.call(r,n)&&n!==t&&GS(r,n,{get:()=>e[n],enumerable:!(i=cfe(e,n))||i.enumerable});return r};var ne=(r,e,t)=>(t=r!=null?lfe(gfe(r)):{},hfe(e||!r||!r.__esModule?GS(t,"default",{value:r,enumerable:!0}):t,r));var iU=y((iZe,rU)=>{rU.exports=tU;tU.sync=Lfe;var $1=J("fs");function Nfe(r,e){var t=e.pathExt!==void 0?e.pathExt:process.env.PATHEXT;if(!t||(t=t.split(";"),t.indexOf("")!==-1))return!0;for(var i=0;i{aU.exports=sU;sU.sync=Tfe;var nU=J("fs");function sU(r,e,t){nU.stat(r,function(i,n){t(i,i?!1:oU(n,e))})}function Tfe(r,e){return oU(nU.statSync(r),e)}function oU(r,e){return r.isFile()&&Ofe(r,e)}function Ofe(r,e){var t=r.mode,i=r.uid,n=r.gid,s=e.uid!==void 0?e.uid:process.getuid&&process.getuid(),o=e.gid!==void 0?e.gid:process.getgid&&process.getgid(),a=parseInt("100",8),l=parseInt("010",8),c=parseInt("001",8),u=a|l,g=t&c||t&l&&n===o||t&a&&i===s||t&u&&s===0;return g}});var cU=y((oZe,lU)=>{var sZe=J("fs"),LI;process.platform==="win32"||global.TESTING_WINDOWS?LI=iU():LI=AU();lU.exports=sv;sv.sync=Mfe;function sv(r,e,t){if(typeof e=="function"&&(t=e,e={}),!t){if(typeof Promise!="function")throw new TypeError("callback not provided");return new Promise(function(i,n){sv(r,e||{},function(s,o){s?n(s):i(o)})})}LI(r,e||{},function(i,n){i&&(i.code==="EACCES"||e&&e.ignoreErrors)&&(i=null,n=!1),t(i,n)})}function Mfe(r,e){try{return LI.sync(r,e||{})}catch(t){if(e&&e.ignoreErrors||t.code==="EACCES")return!1;throw t}}});var CU=y((aZe,dU)=>{var Xg=process.platform==="win32"||process.env.OSTYPE==="cygwin"||process.env.OSTYPE==="msys",uU=J("path"),Kfe=Xg?";":":",gU=cU(),fU=r=>Object.assign(new Error(`not found: ${r}`),{code:"ENOENT"}),hU=(r,e)=>{let t=e.colon||Kfe,i=r.match(/\//)||Xg&&r.match(/\\/)?[""]:[...Xg?[process.cwd()]:[],...(e.path||process.env.PATH||"").split(t)],n=Xg?e.pathExt||process.env.PATHEXT||".EXE;.CMD;.BAT;.COM":"",s=Xg?n.split(t):[""];return Xg&&r.indexOf(".")!==-1&&s[0]!==""&&s.unshift(""),{pathEnv:i,pathExt:s,pathExtExe:n}},pU=(r,e,t)=>{typeof e=="function"&&(t=e,e={}),e||(e={});let{pathEnv:i,pathExt:n,pathExtExe:s}=hU(r,e),o=[],a=c=>new Promise((u,g)=>{if(c===i.length)return e.all&&o.length?u(o):g(fU(r));let f=i[c],h=/^".*"$/.test(f)?f.slice(1,-1):f,p=uU.join(h,r),C=!h&&/^\.[\\\/]/.test(r)?r.slice(0,2)+p:p;u(l(C,c,0))}),l=(c,u,g)=>new Promise((f,h)=>{if(g===n.length)return f(a(u+1));let p=n[g];gU(c+p,{pathExt:s},(C,w)=>{if(!C&&w)if(e.all)o.push(c+p);else return f(c+p);return f(l(c,u,g+1))})});return t?a(0).then(c=>t(null,c),t):a(0)},Ufe=(r,e)=>{e=e||{};let{pathEnv:t,pathExt:i,pathExtExe:n}=hU(r,e),s=[];for(let o=0;o{"use strict";var mU=(r={})=>{let e=r.env||process.env;return(r.platform||process.platform)!=="win32"?"PATH":Object.keys(e).reverse().find(i=>i.toUpperCase()==="PATH")||"Path"};ov.exports=mU;ov.exports.default=mU});var BU=y((lZe,wU)=>{"use strict";var IU=J("path"),Hfe=CU(),Gfe=EU();function yU(r,e){let t=r.options.env||process.env,i=process.cwd(),n=r.options.cwd!=null,s=n&&process.chdir!==void 0&&!process.chdir.disabled;if(s)try{process.chdir(r.options.cwd)}catch{}let o;try{o=Hfe.sync(r.command,{path:t[Gfe({env:t})],pathExt:e?IU.delimiter:void 0})}catch{}finally{s&&process.chdir(i)}return o&&(o=IU.resolve(n?r.options.cwd:"",o)),o}function Yfe(r){return yU(r)||yU(r,!0)}wU.exports=Yfe});var QU=y((cZe,Av)=>{"use strict";var av=/([()\][%!^"`<>&|;, *?])/g;function jfe(r){return r=r.replace(av,"^$1"),r}function qfe(r,e){return r=`${r}`,r=r.replace(/(\\*)"/g,'$1$1\\"'),r=r.replace(/(\\*)$/,"$1$1"),r=`"${r}"`,r=r.replace(av,"^$1"),e&&(r=r.replace(av,"^$1")),r}Av.exports.command=jfe;Av.exports.argument=qfe});var SU=y((uZe,bU)=>{"use strict";bU.exports=/^#!(.*)/});var xU=y((gZe,vU)=>{"use strict";var Jfe=SU();vU.exports=(r="")=>{let e=r.match(Jfe);if(!e)return null;let[t,i]=e[0].replace(/#! ?/,"").split(" "),n=t.split("/").pop();return n==="env"?i:i?`${n} ${i}`:n}});var DU=y((fZe,PU)=>{"use strict";var lv=J("fs"),Wfe=xU();function zfe(r){let t=Buffer.alloc(150),i;try{i=lv.openSync(r,"r"),lv.readSync(i,t,0,150,0),lv.closeSync(i)}catch{}return Wfe(t.toString())}PU.exports=zfe});var NU=y((hZe,FU)=>{"use strict";var Vfe=J("path"),kU=BU(),RU=QU(),Xfe=DU(),_fe=process.platform==="win32",Zfe=/\.(?:com|exe)$/i,$fe=/node_modules[\\/].bin[\\/][^\\/]+\.cmd$/i;function ehe(r){r.file=kU(r);let e=r.file&&Xfe(r.file);return e?(r.args.unshift(r.file),r.command=e,kU(r)):r.file}function the(r){if(!_fe)return r;let e=ehe(r),t=!Zfe.test(e);if(r.options.forceShell||t){let i=$fe.test(e);r.command=Vfe.normalize(r.command),r.command=RU.command(r.command),r.args=r.args.map(s=>RU.argument(s,i));let n=[r.command].concat(r.args).join(" ");r.args=["/d","/s","/c",`"${n}"`],r.command=process.env.comspec||"cmd.exe",r.options.windowsVerbatimArguments=!0}return r}function rhe(r,e,t){e&&!Array.isArray(e)&&(t=e,e=null),e=e?e.slice(0):[],t=Object.assign({},t);let i={command:r,args:e,options:t,file:void 0,original:{command:r,args:e}};return t.shell?i:the(i)}FU.exports=rhe});var OU=y((pZe,TU)=>{"use strict";var cv=process.platform==="win32";function uv(r,e){return Object.assign(new Error(`${e} ${r.command} ENOENT`),{code:"ENOENT",errno:"ENOENT",syscall:`${e} ${r.command}`,path:r.command,spawnargs:r.args})}function ihe(r,e){if(!cv)return;let t=r.emit;r.emit=function(i,n){if(i==="exit"){let s=LU(n,e,"spawn");if(s)return t.call(r,"error",s)}return t.apply(r,arguments)}}function LU(r,e){return cv&&r===1&&!e.file?uv(e.original,"spawn"):null}function nhe(r,e){return cv&&r===1&&!e.file?uv(e.original,"spawnSync"):null}TU.exports={hookChildProcess:ihe,verifyENOENT:LU,verifyENOENTSync:nhe,notFoundError:uv}});var hv=y((dZe,_g)=>{"use strict";var MU=J("child_process"),gv=NU(),fv=OU();function KU(r,e,t){let i=gv(r,e,t),n=MU.spawn(i.command,i.args,i.options);return fv.hookChildProcess(n,i),n}function she(r,e,t){let i=gv(r,e,t),n=MU.spawnSync(i.command,i.args,i.options);return n.error=n.error||fv.verifyENOENTSync(n.status,i),n}_g.exports=KU;_g.exports.spawn=KU;_g.exports.sync=she;_g.exports._parse=gv;_g.exports._enoent=fv});var HU=y((CZe,UU)=>{"use strict";function ohe(r,e){function t(){this.constructor=r}t.prototype=e.prototype,r.prototype=new t}function cc(r,e,t,i){this.message=r,this.expected=e,this.found=t,this.location=i,this.name="SyntaxError",typeof Error.captureStackTrace=="function"&&Error.captureStackTrace(this,cc)}ohe(cc,Error);cc.buildMessage=function(r,e){var t={literal:function(c){return'"'+n(c.text)+'"'},class:function(c){var u="",g;for(g=0;g0){for(g=1,f=1;g>",re=de(">>",!1),me=">&",tt=de(">&",!1),Rt=">",It=de(">",!1),Kr="<<<",oi=de("<<<",!1),pi="<&",pr=de("<&",!1),di="<",ai=de("<",!1),Os=function(m){return{type:"argument",segments:[].concat(...m)}},dr=function(m){return m},Bi="$'",_n=de("$'",!1),ha="'",mA=de("'",!1),Dg=function(m){return[{type:"text",text:m}]},Zn='""',EA=de('""',!1),pa=function(){return{type:"text",text:""}},jp='"',IA=de('"',!1),yA=function(m){return m},Br=function(m){return{type:"arithmetic",arithmetic:m,quoted:!0}},zl=function(m){return{type:"shell",shell:m,quoted:!0}},kg=function(m){return{type:"variable",...m,quoted:!0}},Eo=function(m){return{type:"text",text:m}},Rg=function(m){return{type:"arithmetic",arithmetic:m,quoted:!1}},qp=function(m){return{type:"shell",shell:m,quoted:!1}},Jp=function(m){return{type:"variable",...m,quoted:!1}},xr=function(m){return{type:"glob",pattern:m}},oe=/^[^']/,Io=Ye(["'"],!0,!1),kn=function(m){return m.join("")},Fg=/^[^$"]/,Qt=Ye(["$",'"'],!0,!1),Vl=`\\ -`,Rn=de(`\\ -`,!1),$n=function(){return""},es="\\",ut=de("\\",!1),yo=/^[\\$"`]/,at=Ye(["\\","$",'"',"`"],!1,!1),ln=function(m){return m},S="\\a",Tt=de("\\a",!1),Ng=function(){return"a"},Xl="\\b",Wp=de("\\b",!1),zp=function(){return"\b"},Vp=/^[Ee]/,Xp=Ye(["E","e"],!1,!1),_p=function(){return"\x1B"},G="\\f",yt=de("\\f",!1),wA=function(){return"\f"},Wi="\\n",_l=de("\\n",!1),We=function(){return` -`},da="\\r",Lg=de("\\r",!1),lI=function(){return"\r"},Zp="\\t",cI=de("\\t",!1),ar=function(){return" "},Fn="\\v",Zl=de("\\v",!1),$p=function(){return"\v"},Ms=/^[\\'"?]/,Ca=Ye(["\\","'",'"',"?"],!1,!1),cn=function(m){return String.fromCharCode(parseInt(m,16))},De="\\x",Tg=de("\\x",!1),$l="\\u",Ks=de("\\u",!1),ec="\\U",BA=de("\\U",!1),Og=function(m){return String.fromCodePoint(parseInt(m,16))},Mg=/^[0-7]/,ma=Ye([["0","7"]],!1,!1),Ea=/^[0-9a-fA-f]/,$e=Ye([["0","9"],["a","f"],["A","f"]],!1,!1),wo=rt(),QA="-",tc=de("-",!1),Us="+",rc=de("+",!1),uI=".",ed=de(".",!1),Kg=function(m,b,F){return{type:"number",value:(m==="-"?-1:1)*parseFloat(b.join("")+"."+F.join(""))}},td=function(m,b){return{type:"number",value:(m==="-"?-1:1)*parseInt(b.join(""))}},gI=function(m){return{type:"variable",...m}},ic=function(m){return{type:"variable",name:m}},fI=function(m){return m},Ug="*",bA=de("*",!1),Fr="/",hI=de("/",!1),Hs=function(m,b,F){return{type:b==="*"?"multiplication":"division",right:F}},Gs=function(m,b){return b.reduce((F,U)=>({left:F,...U}),m)},Hg=function(m,b,F){return{type:b==="+"?"addition":"subtraction",right:F}},SA="$((",R=de("$((",!1),q="))",pe=de("))",!1),Ne=function(m){return m},xe="$(",qe=de("$(",!1),dt=function(m){return m},Ft="${",Nn=de("${",!1),bS=":-",s1=de(":-",!1),o1=function(m,b){return{name:m,defaultValue:b}},SS=":-}",a1=de(":-}",!1),A1=function(m){return{name:m,defaultValue:[]}},vS=":+",l1=de(":+",!1),c1=function(m,b){return{name:m,alternativeValue:b}},xS=":+}",u1=de(":+}",!1),g1=function(m){return{name:m,alternativeValue:[]}},PS=function(m){return{name:m}},f1="$",h1=de("$",!1),p1=function(m){return e.isGlobPattern(m)},d1=function(m){return m},DS=/^[a-zA-Z0-9_]/,kS=Ye([["a","z"],["A","Z"],["0","9"],"_"],!1,!1),RS=function(){return O()},FS=/^[$@*?#a-zA-Z0-9_\-]/,NS=Ye(["$","@","*","?","#",["a","z"],["A","Z"],["0","9"],"_","-"],!1,!1),C1=/^[(){}<>$|&; \t"']/,Gg=Ye(["(",")","{","}","<",">","$","|","&",";"," "," ",'"',"'"],!1,!1),LS=/^[<>&; \t"']/,TS=Ye(["<",">","&",";"," "," ",'"',"'"],!1,!1),pI=/^[ \t]/,dI=Ye([" "," "],!1,!1),Q=0,Re=0,vA=[{line:1,column:1}],d=0,E=[],I=0,k;if("startRule"in e){if(!(e.startRule in i))throw new Error(`Can't start parsing from rule "`+e.startRule+'".');n=i[e.startRule]}function O(){return r.substring(Re,Q)}function X(){return Et(Re,Q)}function te(m,b){throw b=b!==void 0?b:Et(Re,Q),Fi([At(m)],r.substring(Re,Q),b)}function ye(m,b){throw b=b!==void 0?b:Et(Re,Q),Ln(m,b)}function de(m,b){return{type:"literal",text:m,ignoreCase:b}}function Ye(m,b,F){return{type:"class",parts:m,inverted:b,ignoreCase:F}}function rt(){return{type:"any"}}function wt(){return{type:"end"}}function At(m){return{type:"other",description:m}}function et(m){var b=vA[m],F;if(b)return b;for(F=m-1;!vA[F];)F--;for(b=vA[F],b={line:b.line,column:b.column};Fd&&(d=Q,E=[]),E.push(m))}function Ln(m,b){return new cc(m,null,null,b)}function Fi(m,b,F){return new cc(cc.buildMessage(m,b),m,b,F)}function xA(){var m,b;return m=Q,b=Ur(),b===t&&(b=null),b!==t&&(Re=m,b=s(b)),m=b,m}function Ur(){var m,b,F,U,ce;if(m=Q,b=Hr(),b!==t){for(F=[],U=Me();U!==t;)F.push(U),U=Me();F!==t?(U=Ia(),U!==t?(ce=ts(),ce===t&&(ce=null),ce!==t?(Re=m,b=o(b,U,ce),m=b):(Q=m,m=t)):(Q=m,m=t)):(Q=m,m=t)}else Q=m,m=t;if(m===t)if(m=Q,b=Hr(),b!==t){for(F=[],U=Me();U!==t;)F.push(U),U=Me();F!==t?(U=Ia(),U===t&&(U=null),U!==t?(Re=m,b=a(b,U),m=b):(Q=m,m=t)):(Q=m,m=t)}else Q=m,m=t;return m}function ts(){var m,b,F,U,ce;for(m=Q,b=[],F=Me();F!==t;)b.push(F),F=Me();if(b!==t)if(F=Ur(),F!==t){for(U=[],ce=Me();ce!==t;)U.push(ce),ce=Me();U!==t?(Re=m,b=l(F),m=b):(Q=m,m=t)}else Q=m,m=t;else Q=m,m=t;return m}function Ia(){var m;return r.charCodeAt(Q)===59?(m=c,Q++):(m=t,I===0&&Be(u)),m===t&&(r.charCodeAt(Q)===38?(m=g,Q++):(m=t,I===0&&Be(f))),m}function Hr(){var m,b,F;return m=Q,b=m1(),b!==t?(F=Jge(),F===t&&(F=null),F!==t?(Re=m,b=h(b,F),m=b):(Q=m,m=t)):(Q=m,m=t),m}function Jge(){var m,b,F,U,ce,be,ft;for(m=Q,b=[],F=Me();F!==t;)b.push(F),F=Me();if(b!==t)if(F=Wge(),F!==t){for(U=[],ce=Me();ce!==t;)U.push(ce),ce=Me();if(U!==t)if(ce=Hr(),ce!==t){for(be=[],ft=Me();ft!==t;)be.push(ft),ft=Me();be!==t?(Re=m,b=p(F,ce),m=b):(Q=m,m=t)}else Q=m,m=t;else Q=m,m=t}else Q=m,m=t;else Q=m,m=t;return m}function Wge(){var m;return r.substr(Q,2)===C?(m=C,Q+=2):(m=t,I===0&&Be(w)),m===t&&(r.substr(Q,2)===B?(m=B,Q+=2):(m=t,I===0&&Be(v))),m}function m1(){var m,b,F;return m=Q,b=Xge(),b!==t?(F=zge(),F===t&&(F=null),F!==t?(Re=m,b=D(b,F),m=b):(Q=m,m=t)):(Q=m,m=t),m}function zge(){var m,b,F,U,ce,be,ft;for(m=Q,b=[],F=Me();F!==t;)b.push(F),F=Me();if(b!==t)if(F=Vge(),F!==t){for(U=[],ce=Me();ce!==t;)U.push(ce),ce=Me();if(U!==t)if(ce=m1(),ce!==t){for(be=[],ft=Me();ft!==t;)be.push(ft),ft=Me();be!==t?(Re=m,b=L(F,ce),m=b):(Q=m,m=t)}else Q=m,m=t;else Q=m,m=t}else Q=m,m=t;else Q=m,m=t;return m}function Vge(){var m;return r.substr(Q,2)===H?(m=H,Q+=2):(m=t,I===0&&Be(j)),m===t&&(r.charCodeAt(Q)===124?(m=$,Q++):(m=t,I===0&&Be(V))),m}function CI(){var m,b,F,U,ce,be;if(m=Q,b=D1(),b!==t)if(r.charCodeAt(Q)===61?(F=W,Q++):(F=t,I===0&&Be(Z)),F!==t)if(U=y1(),U!==t){for(ce=[],be=Me();be!==t;)ce.push(be),be=Me();ce!==t?(Re=m,b=A(b,U),m=b):(Q=m,m=t)}else Q=m,m=t;else Q=m,m=t;else Q=m,m=t;if(m===t)if(m=Q,b=D1(),b!==t)if(r.charCodeAt(Q)===61?(F=W,Q++):(F=t,I===0&&Be(Z)),F!==t){for(U=[],ce=Me();ce!==t;)U.push(ce),ce=Me();U!==t?(Re=m,b=ae(b),m=b):(Q=m,m=t)}else Q=m,m=t;else Q=m,m=t;return m}function Xge(){var m,b,F,U,ce,be,ft,Bt,Vr,Ci,rs;for(m=Q,b=[],F=Me();F!==t;)b.push(F),F=Me();if(b!==t)if(r.charCodeAt(Q)===40?(F=ge,Q++):(F=t,I===0&&Be(_)),F!==t){for(U=[],ce=Me();ce!==t;)U.push(ce),ce=Me();if(U!==t)if(ce=Ur(),ce!==t){for(be=[],ft=Me();ft!==t;)be.push(ft),ft=Me();if(be!==t)if(r.charCodeAt(Q)===41?(ft=T,Q++):(ft=t,I===0&&Be(N)),ft!==t){for(Bt=[],Vr=Me();Vr!==t;)Bt.push(Vr),Vr=Me();if(Bt!==t){for(Vr=[],Ci=rd();Ci!==t;)Vr.push(Ci),Ci=rd();if(Vr!==t){for(Ci=[],rs=Me();rs!==t;)Ci.push(rs),rs=Me();Ci!==t?(Re=m,b=ue(ce,Vr),m=b):(Q=m,m=t)}else Q=m,m=t}else Q=m,m=t}else Q=m,m=t;else Q=m,m=t}else Q=m,m=t;else Q=m,m=t}else Q=m,m=t;else Q=m,m=t;if(m===t){for(m=Q,b=[],F=Me();F!==t;)b.push(F),F=Me();if(b!==t)if(r.charCodeAt(Q)===123?(F=we,Q++):(F=t,I===0&&Be(Le)),F!==t){for(U=[],ce=Me();ce!==t;)U.push(ce),ce=Me();if(U!==t)if(ce=Ur(),ce!==t){for(be=[],ft=Me();ft!==t;)be.push(ft),ft=Me();if(be!==t)if(r.charCodeAt(Q)===125?(ft=Pe,Q++):(ft=t,I===0&&Be(Te)),ft!==t){for(Bt=[],Vr=Me();Vr!==t;)Bt.push(Vr),Vr=Me();if(Bt!==t){for(Vr=[],Ci=rd();Ci!==t;)Vr.push(Ci),Ci=rd();if(Vr!==t){for(Ci=[],rs=Me();rs!==t;)Ci.push(rs),rs=Me();Ci!==t?(Re=m,b=se(ce,Vr),m=b):(Q=m,m=t)}else Q=m,m=t}else Q=m,m=t}else Q=m,m=t;else Q=m,m=t}else Q=m,m=t;else Q=m,m=t}else Q=m,m=t;else Q=m,m=t;if(m===t){for(m=Q,b=[],F=Me();F!==t;)b.push(F),F=Me();if(b!==t){for(F=[],U=CI();U!==t;)F.push(U),U=CI();if(F!==t){for(U=[],ce=Me();ce!==t;)U.push(ce),ce=Me();if(U!==t){if(ce=[],be=I1(),be!==t)for(;be!==t;)ce.push(be),be=I1();else ce=t;if(ce!==t){for(be=[],ft=Me();ft!==t;)be.push(ft),ft=Me();be!==t?(Re=m,b=Ae(F,ce),m=b):(Q=m,m=t)}else Q=m,m=t}else Q=m,m=t}else Q=m,m=t}else Q=m,m=t;if(m===t){for(m=Q,b=[],F=Me();F!==t;)b.push(F),F=Me();if(b!==t){if(F=[],U=CI(),U!==t)for(;U!==t;)F.push(U),U=CI();else F=t;if(F!==t){for(U=[],ce=Me();ce!==t;)U.push(ce),ce=Me();U!==t?(Re=m,b=Qe(F),m=b):(Q=m,m=t)}else Q=m,m=t}else Q=m,m=t}}}return m}function E1(){var m,b,F,U,ce;for(m=Q,b=[],F=Me();F!==t;)b.push(F),F=Me();if(b!==t){if(F=[],U=mI(),U!==t)for(;U!==t;)F.push(U),U=mI();else F=t;if(F!==t){for(U=[],ce=Me();ce!==t;)U.push(ce),ce=Me();U!==t?(Re=m,b=fe(F),m=b):(Q=m,m=t)}else Q=m,m=t}else Q=m,m=t;return m}function I1(){var m,b,F;for(m=Q,b=[],F=Me();F!==t;)b.push(F),F=Me();if(b!==t?(F=rd(),F!==t?(Re=m,b=le(F),m=b):(Q=m,m=t)):(Q=m,m=t),m===t){for(m=Q,b=[],F=Me();F!==t;)b.push(F),F=Me();b!==t?(F=mI(),F!==t?(Re=m,b=le(F),m=b):(Q=m,m=t)):(Q=m,m=t)}return m}function rd(){var m,b,F,U,ce;for(m=Q,b=[],F=Me();F!==t;)b.push(F),F=Me();return b!==t?(Ge.test(r.charAt(Q))?(F=r.charAt(Q),Q++):(F=t,I===0&&Be(ie)),F===t&&(F=null),F!==t?(U=_ge(),U!==t?(ce=mI(),ce!==t?(Re=m,b=Y(F,U,ce),m=b):(Q=m,m=t)):(Q=m,m=t)):(Q=m,m=t)):(Q=m,m=t),m}function _ge(){var m;return r.substr(Q,2)===he?(m=he,Q+=2):(m=t,I===0&&Be(re)),m===t&&(r.substr(Q,2)===me?(m=me,Q+=2):(m=t,I===0&&Be(tt)),m===t&&(r.charCodeAt(Q)===62?(m=Rt,Q++):(m=t,I===0&&Be(It)),m===t&&(r.substr(Q,3)===Kr?(m=Kr,Q+=3):(m=t,I===0&&Be(oi)),m===t&&(r.substr(Q,2)===pi?(m=pi,Q+=2):(m=t,I===0&&Be(pr)),m===t&&(r.charCodeAt(Q)===60?(m=di,Q++):(m=t,I===0&&Be(ai))))))),m}function mI(){var m,b,F;for(m=Q,b=[],F=Me();F!==t;)b.push(F),F=Me();return b!==t?(F=y1(),F!==t?(Re=m,b=le(F),m=b):(Q=m,m=t)):(Q=m,m=t),m}function y1(){var m,b,F;if(m=Q,b=[],F=w1(),F!==t)for(;F!==t;)b.push(F),F=w1();else b=t;return b!==t&&(Re=m,b=Os(b)),m=b,m}function w1(){var m,b;return m=Q,b=Zge(),b!==t&&(Re=m,b=dr(b)),m=b,m===t&&(m=Q,b=$ge(),b!==t&&(Re=m,b=dr(b)),m=b,m===t&&(m=Q,b=efe(),b!==t&&(Re=m,b=dr(b)),m=b,m===t&&(m=Q,b=tfe(),b!==t&&(Re=m,b=dr(b)),m=b))),m}function Zge(){var m,b,F,U;return m=Q,r.substr(Q,2)===Bi?(b=Bi,Q+=2):(b=t,I===0&&Be(_n)),b!==t?(F=nfe(),F!==t?(r.charCodeAt(Q)===39?(U=ha,Q++):(U=t,I===0&&Be(mA)),U!==t?(Re=m,b=Dg(F),m=b):(Q=m,m=t)):(Q=m,m=t)):(Q=m,m=t),m}function $ge(){var m,b,F,U;return m=Q,r.charCodeAt(Q)===39?(b=ha,Q++):(b=t,I===0&&Be(mA)),b!==t?(F=rfe(),F!==t?(r.charCodeAt(Q)===39?(U=ha,Q++):(U=t,I===0&&Be(mA)),U!==t?(Re=m,b=Dg(F),m=b):(Q=m,m=t)):(Q=m,m=t)):(Q=m,m=t),m}function efe(){var m,b,F,U;if(m=Q,r.substr(Q,2)===Zn?(b=Zn,Q+=2):(b=t,I===0&&Be(EA)),b!==t&&(Re=m,b=pa()),m=b,m===t)if(m=Q,r.charCodeAt(Q)===34?(b=jp,Q++):(b=t,I===0&&Be(IA)),b!==t){for(F=[],U=B1();U!==t;)F.push(U),U=B1();F!==t?(r.charCodeAt(Q)===34?(U=jp,Q++):(U=t,I===0&&Be(IA)),U!==t?(Re=m,b=yA(F),m=b):(Q=m,m=t)):(Q=m,m=t)}else Q=m,m=t;return m}function tfe(){var m,b,F;if(m=Q,b=[],F=Q1(),F!==t)for(;F!==t;)b.push(F),F=Q1();else b=t;return b!==t&&(Re=m,b=yA(b)),m=b,m}function B1(){var m,b;return m=Q,b=x1(),b!==t&&(Re=m,b=Br(b)),m=b,m===t&&(m=Q,b=P1(),b!==t&&(Re=m,b=zl(b)),m=b,m===t&&(m=Q,b=US(),b!==t&&(Re=m,b=kg(b)),m=b,m===t&&(m=Q,b=ife(),b!==t&&(Re=m,b=Eo(b)),m=b))),m}function Q1(){var m,b;return m=Q,b=x1(),b!==t&&(Re=m,b=Rg(b)),m=b,m===t&&(m=Q,b=P1(),b!==t&&(Re=m,b=qp(b)),m=b,m===t&&(m=Q,b=US(),b!==t&&(Re=m,b=Jp(b)),m=b,m===t&&(m=Q,b=afe(),b!==t&&(Re=m,b=xr(b)),m=b,m===t&&(m=Q,b=ofe(),b!==t&&(Re=m,b=Eo(b)),m=b)))),m}function rfe(){var m,b,F;for(m=Q,b=[],oe.test(r.charAt(Q))?(F=r.charAt(Q),Q++):(F=t,I===0&&Be(Io));F!==t;)b.push(F),oe.test(r.charAt(Q))?(F=r.charAt(Q),Q++):(F=t,I===0&&Be(Io));return b!==t&&(Re=m,b=kn(b)),m=b,m}function ife(){var m,b,F;if(m=Q,b=[],F=b1(),F===t&&(Fg.test(r.charAt(Q))?(F=r.charAt(Q),Q++):(F=t,I===0&&Be(Qt))),F!==t)for(;F!==t;)b.push(F),F=b1(),F===t&&(Fg.test(r.charAt(Q))?(F=r.charAt(Q),Q++):(F=t,I===0&&Be(Qt)));else b=t;return b!==t&&(Re=m,b=kn(b)),m=b,m}function b1(){var m,b,F;return m=Q,r.substr(Q,2)===Vl?(b=Vl,Q+=2):(b=t,I===0&&Be(Rn)),b!==t&&(Re=m,b=$n()),m=b,m===t&&(m=Q,r.charCodeAt(Q)===92?(b=es,Q++):(b=t,I===0&&Be(ut)),b!==t?(yo.test(r.charAt(Q))?(F=r.charAt(Q),Q++):(F=t,I===0&&Be(at)),F!==t?(Re=m,b=ln(F),m=b):(Q=m,m=t)):(Q=m,m=t)),m}function nfe(){var m,b,F;for(m=Q,b=[],F=S1(),F===t&&(oe.test(r.charAt(Q))?(F=r.charAt(Q),Q++):(F=t,I===0&&Be(Io)));F!==t;)b.push(F),F=S1(),F===t&&(oe.test(r.charAt(Q))?(F=r.charAt(Q),Q++):(F=t,I===0&&Be(Io)));return b!==t&&(Re=m,b=kn(b)),m=b,m}function S1(){var m,b,F;return m=Q,r.substr(Q,2)===S?(b=S,Q+=2):(b=t,I===0&&Be(Tt)),b!==t&&(Re=m,b=Ng()),m=b,m===t&&(m=Q,r.substr(Q,2)===Xl?(b=Xl,Q+=2):(b=t,I===0&&Be(Wp)),b!==t&&(Re=m,b=zp()),m=b,m===t&&(m=Q,r.charCodeAt(Q)===92?(b=es,Q++):(b=t,I===0&&Be(ut)),b!==t?(Vp.test(r.charAt(Q))?(F=r.charAt(Q),Q++):(F=t,I===0&&Be(Xp)),F!==t?(Re=m,b=_p(),m=b):(Q=m,m=t)):(Q=m,m=t),m===t&&(m=Q,r.substr(Q,2)===G?(b=G,Q+=2):(b=t,I===0&&Be(yt)),b!==t&&(Re=m,b=wA()),m=b,m===t&&(m=Q,r.substr(Q,2)===Wi?(b=Wi,Q+=2):(b=t,I===0&&Be(_l)),b!==t&&(Re=m,b=We()),m=b,m===t&&(m=Q,r.substr(Q,2)===da?(b=da,Q+=2):(b=t,I===0&&Be(Lg)),b!==t&&(Re=m,b=lI()),m=b,m===t&&(m=Q,r.substr(Q,2)===Zp?(b=Zp,Q+=2):(b=t,I===0&&Be(cI)),b!==t&&(Re=m,b=ar()),m=b,m===t&&(m=Q,r.substr(Q,2)===Fn?(b=Fn,Q+=2):(b=t,I===0&&Be(Zl)),b!==t&&(Re=m,b=$p()),m=b,m===t&&(m=Q,r.charCodeAt(Q)===92?(b=es,Q++):(b=t,I===0&&Be(ut)),b!==t?(Ms.test(r.charAt(Q))?(F=r.charAt(Q),Q++):(F=t,I===0&&Be(Ca)),F!==t?(Re=m,b=ln(F),m=b):(Q=m,m=t)):(Q=m,m=t),m===t&&(m=sfe()))))))))),m}function sfe(){var m,b,F,U,ce,be,ft,Bt,Vr,Ci,rs,HS;return m=Q,r.charCodeAt(Q)===92?(b=es,Q++):(b=t,I===0&&Be(ut)),b!==t?(F=OS(),F!==t?(Re=m,b=cn(F),m=b):(Q=m,m=t)):(Q=m,m=t),m===t&&(m=Q,r.substr(Q,2)===De?(b=De,Q+=2):(b=t,I===0&&Be(Tg)),b!==t?(F=Q,U=Q,ce=OS(),ce!==t?(be=Tn(),be!==t?(ce=[ce,be],U=ce):(Q=U,U=t)):(Q=U,U=t),U===t&&(U=OS()),U!==t?F=r.substring(F,Q):F=U,F!==t?(Re=m,b=cn(F),m=b):(Q=m,m=t)):(Q=m,m=t),m===t&&(m=Q,r.substr(Q,2)===$l?(b=$l,Q+=2):(b=t,I===0&&Be(Ks)),b!==t?(F=Q,U=Q,ce=Tn(),ce!==t?(be=Tn(),be!==t?(ft=Tn(),ft!==t?(Bt=Tn(),Bt!==t?(ce=[ce,be,ft,Bt],U=ce):(Q=U,U=t)):(Q=U,U=t)):(Q=U,U=t)):(Q=U,U=t),U!==t?F=r.substring(F,Q):F=U,F!==t?(Re=m,b=cn(F),m=b):(Q=m,m=t)):(Q=m,m=t),m===t&&(m=Q,r.substr(Q,2)===ec?(b=ec,Q+=2):(b=t,I===0&&Be(BA)),b!==t?(F=Q,U=Q,ce=Tn(),ce!==t?(be=Tn(),be!==t?(ft=Tn(),ft!==t?(Bt=Tn(),Bt!==t?(Vr=Tn(),Vr!==t?(Ci=Tn(),Ci!==t?(rs=Tn(),rs!==t?(HS=Tn(),HS!==t?(ce=[ce,be,ft,Bt,Vr,Ci,rs,HS],U=ce):(Q=U,U=t)):(Q=U,U=t)):(Q=U,U=t)):(Q=U,U=t)):(Q=U,U=t)):(Q=U,U=t)):(Q=U,U=t)):(Q=U,U=t),U!==t?F=r.substring(F,Q):F=U,F!==t?(Re=m,b=Og(F),m=b):(Q=m,m=t)):(Q=m,m=t)))),m}function OS(){var m;return Mg.test(r.charAt(Q))?(m=r.charAt(Q),Q++):(m=t,I===0&&Be(ma)),m}function Tn(){var m;return Ea.test(r.charAt(Q))?(m=r.charAt(Q),Q++):(m=t,I===0&&Be($e)),m}function ofe(){var m,b,F,U,ce;if(m=Q,b=[],F=Q,r.charCodeAt(Q)===92?(U=es,Q++):(U=t,I===0&&Be(ut)),U!==t?(r.length>Q?(ce=r.charAt(Q),Q++):(ce=t,I===0&&Be(wo)),ce!==t?(Re=F,U=ln(ce),F=U):(Q=F,F=t)):(Q=F,F=t),F===t&&(F=Q,U=Q,I++,ce=k1(),I--,ce===t?U=void 0:(Q=U,U=t),U!==t?(r.length>Q?(ce=r.charAt(Q),Q++):(ce=t,I===0&&Be(wo)),ce!==t?(Re=F,U=ln(ce),F=U):(Q=F,F=t)):(Q=F,F=t)),F!==t)for(;F!==t;)b.push(F),F=Q,r.charCodeAt(Q)===92?(U=es,Q++):(U=t,I===0&&Be(ut)),U!==t?(r.length>Q?(ce=r.charAt(Q),Q++):(ce=t,I===0&&Be(wo)),ce!==t?(Re=F,U=ln(ce),F=U):(Q=F,F=t)):(Q=F,F=t),F===t&&(F=Q,U=Q,I++,ce=k1(),I--,ce===t?U=void 0:(Q=U,U=t),U!==t?(r.length>Q?(ce=r.charAt(Q),Q++):(ce=t,I===0&&Be(wo)),ce!==t?(Re=F,U=ln(ce),F=U):(Q=F,F=t)):(Q=F,F=t));else b=t;return b!==t&&(Re=m,b=kn(b)),m=b,m}function MS(){var m,b,F,U,ce,be;if(m=Q,r.charCodeAt(Q)===45?(b=QA,Q++):(b=t,I===0&&Be(tc)),b===t&&(r.charCodeAt(Q)===43?(b=Us,Q++):(b=t,I===0&&Be(rc))),b===t&&(b=null),b!==t){if(F=[],Ge.test(r.charAt(Q))?(U=r.charAt(Q),Q++):(U=t,I===0&&Be(ie)),U!==t)for(;U!==t;)F.push(U),Ge.test(r.charAt(Q))?(U=r.charAt(Q),Q++):(U=t,I===0&&Be(ie));else F=t;if(F!==t)if(r.charCodeAt(Q)===46?(U=uI,Q++):(U=t,I===0&&Be(ed)),U!==t){if(ce=[],Ge.test(r.charAt(Q))?(be=r.charAt(Q),Q++):(be=t,I===0&&Be(ie)),be!==t)for(;be!==t;)ce.push(be),Ge.test(r.charAt(Q))?(be=r.charAt(Q),Q++):(be=t,I===0&&Be(ie));else ce=t;ce!==t?(Re=m,b=Kg(b,F,ce),m=b):(Q=m,m=t)}else Q=m,m=t;else Q=m,m=t}else Q=m,m=t;if(m===t){if(m=Q,r.charCodeAt(Q)===45?(b=QA,Q++):(b=t,I===0&&Be(tc)),b===t&&(r.charCodeAt(Q)===43?(b=Us,Q++):(b=t,I===0&&Be(rc))),b===t&&(b=null),b!==t){if(F=[],Ge.test(r.charAt(Q))?(U=r.charAt(Q),Q++):(U=t,I===0&&Be(ie)),U!==t)for(;U!==t;)F.push(U),Ge.test(r.charAt(Q))?(U=r.charAt(Q),Q++):(U=t,I===0&&Be(ie));else F=t;F!==t?(Re=m,b=td(b,F),m=b):(Q=m,m=t)}else Q=m,m=t;if(m===t&&(m=Q,b=US(),b!==t&&(Re=m,b=gI(b)),m=b,m===t&&(m=Q,b=nc(),b!==t&&(Re=m,b=ic(b)),m=b,m===t)))if(m=Q,r.charCodeAt(Q)===40?(b=ge,Q++):(b=t,I===0&&Be(_)),b!==t){for(F=[],U=Me();U!==t;)F.push(U),U=Me();if(F!==t)if(U=v1(),U!==t){for(ce=[],be=Me();be!==t;)ce.push(be),be=Me();ce!==t?(r.charCodeAt(Q)===41?(be=T,Q++):(be=t,I===0&&Be(N)),be!==t?(Re=m,b=fI(U),m=b):(Q=m,m=t)):(Q=m,m=t)}else Q=m,m=t;else Q=m,m=t}else Q=m,m=t}return m}function KS(){var m,b,F,U,ce,be,ft,Bt;if(m=Q,b=MS(),b!==t){for(F=[],U=Q,ce=[],be=Me();be!==t;)ce.push(be),be=Me();if(ce!==t)if(r.charCodeAt(Q)===42?(be=Ug,Q++):(be=t,I===0&&Be(bA)),be===t&&(r.charCodeAt(Q)===47?(be=Fr,Q++):(be=t,I===0&&Be(hI))),be!==t){for(ft=[],Bt=Me();Bt!==t;)ft.push(Bt),Bt=Me();ft!==t?(Bt=MS(),Bt!==t?(Re=U,ce=Hs(b,be,Bt),U=ce):(Q=U,U=t)):(Q=U,U=t)}else Q=U,U=t;else Q=U,U=t;for(;U!==t;){for(F.push(U),U=Q,ce=[],be=Me();be!==t;)ce.push(be),be=Me();if(ce!==t)if(r.charCodeAt(Q)===42?(be=Ug,Q++):(be=t,I===0&&Be(bA)),be===t&&(r.charCodeAt(Q)===47?(be=Fr,Q++):(be=t,I===0&&Be(hI))),be!==t){for(ft=[],Bt=Me();Bt!==t;)ft.push(Bt),Bt=Me();ft!==t?(Bt=MS(),Bt!==t?(Re=U,ce=Hs(b,be,Bt),U=ce):(Q=U,U=t)):(Q=U,U=t)}else Q=U,U=t;else Q=U,U=t}F!==t?(Re=m,b=Gs(b,F),m=b):(Q=m,m=t)}else Q=m,m=t;return m}function v1(){var m,b,F,U,ce,be,ft,Bt;if(m=Q,b=KS(),b!==t){for(F=[],U=Q,ce=[],be=Me();be!==t;)ce.push(be),be=Me();if(ce!==t)if(r.charCodeAt(Q)===43?(be=Us,Q++):(be=t,I===0&&Be(rc)),be===t&&(r.charCodeAt(Q)===45?(be=QA,Q++):(be=t,I===0&&Be(tc))),be!==t){for(ft=[],Bt=Me();Bt!==t;)ft.push(Bt),Bt=Me();ft!==t?(Bt=KS(),Bt!==t?(Re=U,ce=Hg(b,be,Bt),U=ce):(Q=U,U=t)):(Q=U,U=t)}else Q=U,U=t;else Q=U,U=t;for(;U!==t;){for(F.push(U),U=Q,ce=[],be=Me();be!==t;)ce.push(be),be=Me();if(ce!==t)if(r.charCodeAt(Q)===43?(be=Us,Q++):(be=t,I===0&&Be(rc)),be===t&&(r.charCodeAt(Q)===45?(be=QA,Q++):(be=t,I===0&&Be(tc))),be!==t){for(ft=[],Bt=Me();Bt!==t;)ft.push(Bt),Bt=Me();ft!==t?(Bt=KS(),Bt!==t?(Re=U,ce=Hg(b,be,Bt),U=ce):(Q=U,U=t)):(Q=U,U=t)}else Q=U,U=t;else Q=U,U=t}F!==t?(Re=m,b=Gs(b,F),m=b):(Q=m,m=t)}else Q=m,m=t;return m}function x1(){var m,b,F,U,ce,be;if(m=Q,r.substr(Q,3)===SA?(b=SA,Q+=3):(b=t,I===0&&Be(R)),b!==t){for(F=[],U=Me();U!==t;)F.push(U),U=Me();if(F!==t)if(U=v1(),U!==t){for(ce=[],be=Me();be!==t;)ce.push(be),be=Me();ce!==t?(r.substr(Q,2)===q?(be=q,Q+=2):(be=t,I===0&&Be(pe)),be!==t?(Re=m,b=Ne(U),m=b):(Q=m,m=t)):(Q=m,m=t)}else Q=m,m=t;else Q=m,m=t}else Q=m,m=t;return m}function P1(){var m,b,F,U;return m=Q,r.substr(Q,2)===xe?(b=xe,Q+=2):(b=t,I===0&&Be(qe)),b!==t?(F=Ur(),F!==t?(r.charCodeAt(Q)===41?(U=T,Q++):(U=t,I===0&&Be(N)),U!==t?(Re=m,b=dt(F),m=b):(Q=m,m=t)):(Q=m,m=t)):(Q=m,m=t),m}function US(){var m,b,F,U,ce,be;return m=Q,r.substr(Q,2)===Ft?(b=Ft,Q+=2):(b=t,I===0&&Be(Nn)),b!==t?(F=nc(),F!==t?(r.substr(Q,2)===bS?(U=bS,Q+=2):(U=t,I===0&&Be(s1)),U!==t?(ce=E1(),ce!==t?(r.charCodeAt(Q)===125?(be=Pe,Q++):(be=t,I===0&&Be(Te)),be!==t?(Re=m,b=o1(F,ce),m=b):(Q=m,m=t)):(Q=m,m=t)):(Q=m,m=t)):(Q=m,m=t)):(Q=m,m=t),m===t&&(m=Q,r.substr(Q,2)===Ft?(b=Ft,Q+=2):(b=t,I===0&&Be(Nn)),b!==t?(F=nc(),F!==t?(r.substr(Q,3)===SS?(U=SS,Q+=3):(U=t,I===0&&Be(a1)),U!==t?(Re=m,b=A1(F),m=b):(Q=m,m=t)):(Q=m,m=t)):(Q=m,m=t),m===t&&(m=Q,r.substr(Q,2)===Ft?(b=Ft,Q+=2):(b=t,I===0&&Be(Nn)),b!==t?(F=nc(),F!==t?(r.substr(Q,2)===vS?(U=vS,Q+=2):(U=t,I===0&&Be(l1)),U!==t?(ce=E1(),ce!==t?(r.charCodeAt(Q)===125?(be=Pe,Q++):(be=t,I===0&&Be(Te)),be!==t?(Re=m,b=c1(F,ce),m=b):(Q=m,m=t)):(Q=m,m=t)):(Q=m,m=t)):(Q=m,m=t)):(Q=m,m=t),m===t&&(m=Q,r.substr(Q,2)===Ft?(b=Ft,Q+=2):(b=t,I===0&&Be(Nn)),b!==t?(F=nc(),F!==t?(r.substr(Q,3)===xS?(U=xS,Q+=3):(U=t,I===0&&Be(u1)),U!==t?(Re=m,b=g1(F),m=b):(Q=m,m=t)):(Q=m,m=t)):(Q=m,m=t),m===t&&(m=Q,r.substr(Q,2)===Ft?(b=Ft,Q+=2):(b=t,I===0&&Be(Nn)),b!==t?(F=nc(),F!==t?(r.charCodeAt(Q)===125?(U=Pe,Q++):(U=t,I===0&&Be(Te)),U!==t?(Re=m,b=PS(F),m=b):(Q=m,m=t)):(Q=m,m=t)):(Q=m,m=t),m===t&&(m=Q,r.charCodeAt(Q)===36?(b=f1,Q++):(b=t,I===0&&Be(h1)),b!==t?(F=nc(),F!==t?(Re=m,b=PS(F),m=b):(Q=m,m=t)):(Q=m,m=t)))))),m}function afe(){var m,b,F;return m=Q,b=Afe(),b!==t?(Re=Q,F=p1(b),F?F=void 0:F=t,F!==t?(Re=m,b=d1(b),m=b):(Q=m,m=t)):(Q=m,m=t),m}function Afe(){var m,b,F,U,ce;if(m=Q,b=[],F=Q,U=Q,I++,ce=R1(),I--,ce===t?U=void 0:(Q=U,U=t),U!==t?(r.length>Q?(ce=r.charAt(Q),Q++):(ce=t,I===0&&Be(wo)),ce!==t?(Re=F,U=ln(ce),F=U):(Q=F,F=t)):(Q=F,F=t),F!==t)for(;F!==t;)b.push(F),F=Q,U=Q,I++,ce=R1(),I--,ce===t?U=void 0:(Q=U,U=t),U!==t?(r.length>Q?(ce=r.charAt(Q),Q++):(ce=t,I===0&&Be(wo)),ce!==t?(Re=F,U=ln(ce),F=U):(Q=F,F=t)):(Q=F,F=t);else b=t;return b!==t&&(Re=m,b=kn(b)),m=b,m}function D1(){var m,b,F;if(m=Q,b=[],DS.test(r.charAt(Q))?(F=r.charAt(Q),Q++):(F=t,I===0&&Be(kS)),F!==t)for(;F!==t;)b.push(F),DS.test(r.charAt(Q))?(F=r.charAt(Q),Q++):(F=t,I===0&&Be(kS));else b=t;return b!==t&&(Re=m,b=RS()),m=b,m}function nc(){var m,b,F;if(m=Q,b=[],FS.test(r.charAt(Q))?(F=r.charAt(Q),Q++):(F=t,I===0&&Be(NS)),F!==t)for(;F!==t;)b.push(F),FS.test(r.charAt(Q))?(F=r.charAt(Q),Q++):(F=t,I===0&&Be(NS));else b=t;return b!==t&&(Re=m,b=RS()),m=b,m}function k1(){var m;return C1.test(r.charAt(Q))?(m=r.charAt(Q),Q++):(m=t,I===0&&Be(Gg)),m}function R1(){var m;return LS.test(r.charAt(Q))?(m=r.charAt(Q),Q++):(m=t,I===0&&Be(TS)),m}function Me(){var m,b;if(m=[],pI.test(r.charAt(Q))?(b=r.charAt(Q),Q++):(b=t,I===0&&Be(dI)),b!==t)for(;b!==t;)m.push(b),pI.test(r.charAt(Q))?(b=r.charAt(Q),Q++):(b=t,I===0&&Be(dI));else m=t;return m}if(k=n(),k!==t&&Q===r.length)return k;throw k!==t&&Q{"use strict";function Ahe(r,e){function t(){this.constructor=r}t.prototype=e.prototype,r.prototype=new t}function gc(r,e,t,i){this.message=r,this.expected=e,this.found=t,this.location=i,this.name="SyntaxError",typeof Error.captureStackTrace=="function"&&Error.captureStackTrace(this,gc)}Ahe(gc,Error);gc.buildMessage=function(r,e){var t={literal:function(c){return'"'+n(c.text)+'"'},class:function(c){var u="",g;for(g=0;g0){for(g=1,f=1;gH&&(H=v,j=[]),j.push(ie))}function Te(ie,Y){return new gc(ie,null,null,Y)}function se(ie,Y,he){return new gc(gc.buildMessage(ie,Y),ie,Y,he)}function Ae(){var ie,Y,he,re;return ie=v,Y=Qe(),Y!==t?(r.charCodeAt(v)===47?(he=s,v++):(he=t,$===0&&Pe(o)),he!==t?(re=Qe(),re!==t?(D=ie,Y=a(Y,re),ie=Y):(v=ie,ie=t)):(v=ie,ie=t)):(v=ie,ie=t),ie===t&&(ie=v,Y=Qe(),Y!==t&&(D=ie,Y=l(Y)),ie=Y),ie}function Qe(){var ie,Y,he,re;return ie=v,Y=fe(),Y!==t?(r.charCodeAt(v)===64?(he=c,v++):(he=t,$===0&&Pe(u)),he!==t?(re=Ge(),re!==t?(D=ie,Y=g(Y,re),ie=Y):(v=ie,ie=t)):(v=ie,ie=t)):(v=ie,ie=t),ie===t&&(ie=v,Y=fe(),Y!==t&&(D=ie,Y=f(Y)),ie=Y),ie}function fe(){var ie,Y,he,re,me;return ie=v,r.charCodeAt(v)===64?(Y=c,v++):(Y=t,$===0&&Pe(u)),Y!==t?(he=le(),he!==t?(r.charCodeAt(v)===47?(re=s,v++):(re=t,$===0&&Pe(o)),re!==t?(me=le(),me!==t?(D=ie,Y=h(),ie=Y):(v=ie,ie=t)):(v=ie,ie=t)):(v=ie,ie=t)):(v=ie,ie=t),ie===t&&(ie=v,Y=le(),Y!==t&&(D=ie,Y=h()),ie=Y),ie}function le(){var ie,Y,he;if(ie=v,Y=[],p.test(r.charAt(v))?(he=r.charAt(v),v++):(he=t,$===0&&Pe(C)),he!==t)for(;he!==t;)Y.push(he),p.test(r.charAt(v))?(he=r.charAt(v),v++):(he=t,$===0&&Pe(C));else Y=t;return Y!==t&&(D=ie,Y=h()),ie=Y,ie}function Ge(){var ie,Y,he;if(ie=v,Y=[],w.test(r.charAt(v))?(he=r.charAt(v),v++):(he=t,$===0&&Pe(B)),he!==t)for(;he!==t;)Y.push(he),w.test(r.charAt(v))?(he=r.charAt(v),v++):(he=t,$===0&&Pe(B));else Y=t;return Y!==t&&(D=ie,Y=h()),ie=Y,ie}if(V=n(),V!==t&&v===r.length)return V;throw V!==t&&v{"use strict";function JU(r){return typeof r>"u"||r===null}function che(r){return typeof r=="object"&&r!==null}function uhe(r){return Array.isArray(r)?r:JU(r)?[]:[r]}function ghe(r,e){var t,i,n,s;if(e)for(s=Object.keys(e),t=0,i=s.length;t{"use strict";function dd(r,e){Error.call(this),this.name="YAMLException",this.reason=r,this.mark=e,this.message=(this.reason||"(unknown reason)")+(this.mark?" "+this.mark.toString():""),Error.captureStackTrace?Error.captureStackTrace(this,this.constructor):this.stack=new Error().stack||""}dd.prototype=Object.create(Error.prototype);dd.prototype.constructor=dd;dd.prototype.toString=function(e){var t=this.name+": ";return t+=this.reason||"(unknown reason)",!e&&this.mark&&(t+=" "+this.mark.toString()),t};WU.exports=dd});var XU=y((LZe,VU)=>{"use strict";var zU=hc();function Iv(r,e,t,i,n){this.name=r,this.buffer=e,this.position=t,this.line=i,this.column=n}Iv.prototype.getSnippet=function(e,t){var i,n,s,o,a;if(!this.buffer)return null;for(e=e||4,t=t||75,i="",n=this.position;n>0&&`\0\r -\x85\u2028\u2029`.indexOf(this.buffer.charAt(n-1))===-1;)if(n-=1,this.position-n>t/2-1){i=" ... ",n+=5;break}for(s="",o=this.position;ot/2-1){s=" ... ",o-=5;break}return a=this.buffer.slice(n,o),zU.repeat(" ",e)+i+a+s+` -`+zU.repeat(" ",e+this.position-n+i.length)+"^"};Iv.prototype.toString=function(e){var t,i="";return this.name&&(i+='in "'+this.name+'" '),i+="at line "+(this.line+1)+", column "+(this.column+1),e||(t=this.getSnippet(),t&&(i+=`: -`+t)),i};VU.exports=Iv});var Ai=y((TZe,ZU)=>{"use strict";var _U=ef(),phe=["kind","resolve","construct","instanceOf","predicate","represent","defaultStyle","styleAliases"],dhe=["scalar","sequence","mapping"];function Che(r){var e={};return r!==null&&Object.keys(r).forEach(function(t){r[t].forEach(function(i){e[String(i)]=t})}),e}function mhe(r,e){if(e=e||{},Object.keys(e).forEach(function(t){if(phe.indexOf(t)===-1)throw new _U('Unknown option "'+t+'" is met in definition of "'+r+'" YAML type.')}),this.tag=r,this.kind=e.kind||null,this.resolve=e.resolve||function(){return!0},this.construct=e.construct||function(t){return t},this.instanceOf=e.instanceOf||null,this.predicate=e.predicate||null,this.represent=e.represent||null,this.defaultStyle=e.defaultStyle||null,this.styleAliases=Che(e.styleAliases||null),dhe.indexOf(this.kind)===-1)throw new _U('Unknown kind "'+this.kind+'" is specified for "'+r+'" YAML type.')}ZU.exports=mhe});var pc=y((OZe,e2)=>{"use strict";var $U=hc(),GI=ef(),Ehe=Ai();function yv(r,e,t){var i=[];return r.include.forEach(function(n){t=yv(n,e,t)}),r[e].forEach(function(n){t.forEach(function(s,o){s.tag===n.tag&&s.kind===n.kind&&i.push(o)}),t.push(n)}),t.filter(function(n,s){return i.indexOf(s)===-1})}function Ihe(){var r={scalar:{},sequence:{},mapping:{},fallback:{}},e,t;function i(n){r[n.kind][n.tag]=r.fallback[n.tag]=n}for(e=0,t=arguments.length;e{"use strict";var yhe=Ai();t2.exports=new yhe("tag:yaml.org,2002:str",{kind:"scalar",construct:function(r){return r!==null?r:""}})});var n2=y((KZe,i2)=>{"use strict";var whe=Ai();i2.exports=new whe("tag:yaml.org,2002:seq",{kind:"sequence",construct:function(r){return r!==null?r:[]}})});var o2=y((UZe,s2)=>{"use strict";var Bhe=Ai();s2.exports=new Bhe("tag:yaml.org,2002:map",{kind:"mapping",construct:function(r){return r!==null?r:{}}})});var YI=y((HZe,a2)=>{"use strict";var Qhe=pc();a2.exports=new Qhe({explicit:[r2(),n2(),o2()]})});var l2=y((GZe,A2)=>{"use strict";var bhe=Ai();function She(r){if(r===null)return!0;var e=r.length;return e===1&&r==="~"||e===4&&(r==="null"||r==="Null"||r==="NULL")}function vhe(){return null}function xhe(r){return r===null}A2.exports=new bhe("tag:yaml.org,2002:null",{kind:"scalar",resolve:She,construct:vhe,predicate:xhe,represent:{canonical:function(){return"~"},lowercase:function(){return"null"},uppercase:function(){return"NULL"},camelcase:function(){return"Null"}},defaultStyle:"lowercase"})});var u2=y((YZe,c2)=>{"use strict";var Phe=Ai();function Dhe(r){if(r===null)return!1;var e=r.length;return e===4&&(r==="true"||r==="True"||r==="TRUE")||e===5&&(r==="false"||r==="False"||r==="FALSE")}function khe(r){return r==="true"||r==="True"||r==="TRUE"}function Rhe(r){return Object.prototype.toString.call(r)==="[object Boolean]"}c2.exports=new Phe("tag:yaml.org,2002:bool",{kind:"scalar",resolve:Dhe,construct:khe,predicate:Rhe,represent:{lowercase:function(r){return r?"true":"false"},uppercase:function(r){return r?"TRUE":"FALSE"},camelcase:function(r){return r?"True":"False"}},defaultStyle:"lowercase"})});var f2=y((jZe,g2)=>{"use strict";var Fhe=hc(),Nhe=Ai();function Lhe(r){return 48<=r&&r<=57||65<=r&&r<=70||97<=r&&r<=102}function The(r){return 48<=r&&r<=55}function Ohe(r){return 48<=r&&r<=57}function Mhe(r){if(r===null)return!1;var e=r.length,t=0,i=!1,n;if(!e)return!1;if(n=r[t],(n==="-"||n==="+")&&(n=r[++t]),n==="0"){if(t+1===e)return!0;if(n=r[++t],n==="b"){for(t++;t=0?"0b"+r.toString(2):"-0b"+r.toString(2).slice(1)},octal:function(r){return r>=0?"0"+r.toString(8):"-0"+r.toString(8).slice(1)},decimal:function(r){return r.toString(10)},hexadecimal:function(r){return r>=0?"0x"+r.toString(16).toUpperCase():"-0x"+r.toString(16).toUpperCase().slice(1)}},defaultStyle:"decimal",styleAliases:{binary:[2,"bin"],octal:[8,"oct"],decimal:[10,"dec"],hexadecimal:[16,"hex"]}})});var d2=y((qZe,p2)=>{"use strict";var h2=hc(),Hhe=Ai(),Ghe=new RegExp("^(?:[-+]?(?:0|[1-9][0-9_]*)(?:\\.[0-9_]*)?(?:[eE][-+]?[0-9]+)?|\\.[0-9_]+(?:[eE][-+]?[0-9]+)?|[-+]?[0-9][0-9_]*(?::[0-5]?[0-9])+\\.[0-9_]*|[-+]?\\.(?:inf|Inf|INF)|\\.(?:nan|NaN|NAN))$");function Yhe(r){return!(r===null||!Ghe.test(r)||r[r.length-1]==="_")}function jhe(r){var e,t,i,n;return e=r.replace(/_/g,"").toLowerCase(),t=e[0]==="-"?-1:1,n=[],"+-".indexOf(e[0])>=0&&(e=e.slice(1)),e===".inf"?t===1?Number.POSITIVE_INFINITY:Number.NEGATIVE_INFINITY:e===".nan"?NaN:e.indexOf(":")>=0?(e.split(":").forEach(function(s){n.unshift(parseFloat(s,10))}),e=0,i=1,n.forEach(function(s){e+=s*i,i*=60}),t*e):t*parseFloat(e,10)}var qhe=/^[-+]?[0-9]+e/;function Jhe(r,e){var t;if(isNaN(r))switch(e){case"lowercase":return".nan";case"uppercase":return".NAN";case"camelcase":return".NaN"}else if(Number.POSITIVE_INFINITY===r)switch(e){case"lowercase":return".inf";case"uppercase":return".INF";case"camelcase":return".Inf"}else if(Number.NEGATIVE_INFINITY===r)switch(e){case"lowercase":return"-.inf";case"uppercase":return"-.INF";case"camelcase":return"-.Inf"}else if(h2.isNegativeZero(r))return"-0.0";return t=r.toString(10),qhe.test(t)?t.replace("e",".e"):t}function Whe(r){return Object.prototype.toString.call(r)==="[object Number]"&&(r%1!==0||h2.isNegativeZero(r))}p2.exports=new Hhe("tag:yaml.org,2002:float",{kind:"scalar",resolve:Yhe,construct:jhe,predicate:Whe,represent:Jhe,defaultStyle:"lowercase"})});var wv=y((JZe,C2)=>{"use strict";var zhe=pc();C2.exports=new zhe({include:[YI()],implicit:[l2(),u2(),f2(),d2()]})});var Bv=y((WZe,m2)=>{"use strict";var Vhe=pc();m2.exports=new Vhe({include:[wv()]})});var w2=y((zZe,y2)=>{"use strict";var Xhe=Ai(),E2=new RegExp("^([0-9][0-9][0-9][0-9])-([0-9][0-9])-([0-9][0-9])$"),I2=new RegExp("^([0-9][0-9][0-9][0-9])-([0-9][0-9]?)-([0-9][0-9]?)(?:[Tt]|[ \\t]+)([0-9][0-9]?):([0-9][0-9]):([0-9][0-9])(?:\\.([0-9]*))?(?:[ \\t]*(Z|([-+])([0-9][0-9]?)(?::([0-9][0-9]))?))?$");function _he(r){return r===null?!1:E2.exec(r)!==null||I2.exec(r)!==null}function Zhe(r){var e,t,i,n,s,o,a,l=0,c=null,u,g,f;if(e=E2.exec(r),e===null&&(e=I2.exec(r)),e===null)throw new Error("Date resolve error");if(t=+e[1],i=+e[2]-1,n=+e[3],!e[4])return new Date(Date.UTC(t,i,n));if(s=+e[4],o=+e[5],a=+e[6],e[7]){for(l=e[7].slice(0,3);l.length<3;)l+="0";l=+l}return e[9]&&(u=+e[10],g=+(e[11]||0),c=(u*60+g)*6e4,e[9]==="-"&&(c=-c)),f=new Date(Date.UTC(t,i,n,s,o,a,l)),c&&f.setTime(f.getTime()-c),f}function $he(r){return r.toISOString()}y2.exports=new Xhe("tag:yaml.org,2002:timestamp",{kind:"scalar",resolve:_he,construct:Zhe,instanceOf:Date,represent:$he})});var Q2=y((VZe,B2)=>{"use strict";var epe=Ai();function tpe(r){return r==="<<"||r===null}B2.exports=new epe("tag:yaml.org,2002:merge",{kind:"scalar",resolve:tpe})});var v2=y((XZe,S2)=>{"use strict";var dc;try{b2=J,dc=b2("buffer").Buffer}catch{}var b2,rpe=Ai(),Qv=`ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/= -\r`;function ipe(r){if(r===null)return!1;var e,t,i=0,n=r.length,s=Qv;for(t=0;t64)){if(e<0)return!1;i+=6}return i%8===0}function npe(r){var e,t,i=r.replace(/[\r\n=]/g,""),n=i.length,s=Qv,o=0,a=[];for(e=0;e>16&255),a.push(o>>8&255),a.push(o&255)),o=o<<6|s.indexOf(i.charAt(e));return t=n%4*6,t===0?(a.push(o>>16&255),a.push(o>>8&255),a.push(o&255)):t===18?(a.push(o>>10&255),a.push(o>>2&255)):t===12&&a.push(o>>4&255),dc?dc.from?dc.from(a):new dc(a):a}function spe(r){var e="",t=0,i,n,s=r.length,o=Qv;for(i=0;i>18&63],e+=o[t>>12&63],e+=o[t>>6&63],e+=o[t&63]),t=(t<<8)+r[i];return n=s%3,n===0?(e+=o[t>>18&63],e+=o[t>>12&63],e+=o[t>>6&63],e+=o[t&63]):n===2?(e+=o[t>>10&63],e+=o[t>>4&63],e+=o[t<<2&63],e+=o[64]):n===1&&(e+=o[t>>2&63],e+=o[t<<4&63],e+=o[64],e+=o[64]),e}function ope(r){return dc&&dc.isBuffer(r)}S2.exports=new rpe("tag:yaml.org,2002:binary",{kind:"scalar",resolve:ipe,construct:npe,predicate:ope,represent:spe})});var P2=y((_Ze,x2)=>{"use strict";var ape=Ai(),Ape=Object.prototype.hasOwnProperty,lpe=Object.prototype.toString;function cpe(r){if(r===null)return!0;var e=[],t,i,n,s,o,a=r;for(t=0,i=a.length;t{"use strict";var gpe=Ai(),fpe=Object.prototype.toString;function hpe(r){if(r===null)return!0;var e,t,i,n,s,o=r;for(s=new Array(o.length),e=0,t=o.length;e{"use strict";var dpe=Ai(),Cpe=Object.prototype.hasOwnProperty;function mpe(r){if(r===null)return!0;var e,t=r;for(e in t)if(Cpe.call(t,e)&&t[e]!==null)return!1;return!0}function Epe(r){return r!==null?r:{}}R2.exports=new dpe("tag:yaml.org,2002:set",{kind:"mapping",resolve:mpe,construct:Epe})});var rf=y((e$e,N2)=>{"use strict";var Ipe=pc();N2.exports=new Ipe({include:[Bv()],implicit:[w2(),Q2()],explicit:[v2(),P2(),k2(),F2()]})});var T2=y((t$e,L2)=>{"use strict";var ype=Ai();function wpe(){return!0}function Bpe(){}function Qpe(){return""}function bpe(r){return typeof r>"u"}L2.exports=new ype("tag:yaml.org,2002:js/undefined",{kind:"scalar",resolve:wpe,construct:Bpe,predicate:bpe,represent:Qpe})});var M2=y((r$e,O2)=>{"use strict";var Spe=Ai();function vpe(r){if(r===null||r.length===0)return!1;var e=r,t=/\/([gim]*)$/.exec(r),i="";return!(e[0]==="/"&&(t&&(i=t[1]),i.length>3||e[e.length-i.length-1]!=="/"))}function xpe(r){var e=r,t=/\/([gim]*)$/.exec(r),i="";return e[0]==="/"&&(t&&(i=t[1]),e=e.slice(1,e.length-i.length-1)),new RegExp(e,i)}function Ppe(r){var e="/"+r.source+"/";return r.global&&(e+="g"),r.multiline&&(e+="m"),r.ignoreCase&&(e+="i"),e}function Dpe(r){return Object.prototype.toString.call(r)==="[object RegExp]"}O2.exports=new Spe("tag:yaml.org,2002:js/regexp",{kind:"scalar",resolve:vpe,construct:xpe,predicate:Dpe,represent:Ppe})});var H2=y((i$e,U2)=>{"use strict";var jI;try{K2=J,jI=K2("esprima")}catch{typeof window<"u"&&(jI=window.esprima)}var K2,kpe=Ai();function Rpe(r){if(r===null)return!1;try{var e="("+r+")",t=jI.parse(e,{range:!0});return!(t.type!=="Program"||t.body.length!==1||t.body[0].type!=="ExpressionStatement"||t.body[0].expression.type!=="ArrowFunctionExpression"&&t.body[0].expression.type!=="FunctionExpression")}catch{return!1}}function Fpe(r){var e="("+r+")",t=jI.parse(e,{range:!0}),i=[],n;if(t.type!=="Program"||t.body.length!==1||t.body[0].type!=="ExpressionStatement"||t.body[0].expression.type!=="ArrowFunctionExpression"&&t.body[0].expression.type!=="FunctionExpression")throw new Error("Failed to resolve function");return t.body[0].expression.params.forEach(function(s){i.push(s.name)}),n=t.body[0].expression.body.range,t.body[0].expression.body.type==="BlockStatement"?new Function(i,e.slice(n[0]+1,n[1]-1)):new Function(i,"return "+e.slice(n[0],n[1]))}function Npe(r){return r.toString()}function Lpe(r){return Object.prototype.toString.call(r)==="[object Function]"}U2.exports=new kpe("tag:yaml.org,2002:js/function",{kind:"scalar",resolve:Rpe,construct:Fpe,predicate:Lpe,represent:Npe})});var Cd=y((n$e,Y2)=>{"use strict";var G2=pc();Y2.exports=G2.DEFAULT=new G2({include:[rf()],explicit:[T2(),M2(),H2()]})});var AH=y((s$e,md)=>{"use strict";var Qa=hc(),X2=ef(),Tpe=XU(),_2=rf(),Ope=Cd(),FA=Object.prototype.hasOwnProperty,qI=1,Z2=2,$2=3,JI=4,bv=1,Mpe=2,j2=3,Kpe=/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F-\x84\x86-\x9F\uFFFE\uFFFF]|[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?:[^\uD800-\uDBFF]|^)[\uDC00-\uDFFF]/,Upe=/[\x85\u2028\u2029]/,Hpe=/[,\[\]\{\}]/,eH=/^(?:!|!!|![a-z\-]+!)$/i,tH=/^(?:!|[^,\[\]\{\}])(?:%[0-9a-f]{2}|[0-9a-z\-#;\/\?:@&=\+\$,_\.!~\*'\(\)\[\]])*$/i;function q2(r){return Object.prototype.toString.call(r)}function So(r){return r===10||r===13}function mc(r){return r===9||r===32}function fn(r){return r===9||r===32||r===10||r===13}function nf(r){return r===44||r===91||r===93||r===123||r===125}function Gpe(r){var e;return 48<=r&&r<=57?r-48:(e=r|32,97<=e&&e<=102?e-97+10:-1)}function Ype(r){return r===120?2:r===117?4:r===85?8:0}function jpe(r){return 48<=r&&r<=57?r-48:-1}function J2(r){return r===48?"\0":r===97?"\x07":r===98?"\b":r===116||r===9?" ":r===110?` -`:r===118?"\v":r===102?"\f":r===114?"\r":r===101?"\x1B":r===32?" ":r===34?'"':r===47?"/":r===92?"\\":r===78?"\x85":r===95?"\xA0":r===76?"\u2028":r===80?"\u2029":""}function qpe(r){return r<=65535?String.fromCharCode(r):String.fromCharCode((r-65536>>10)+55296,(r-65536&1023)+56320)}var rH=new Array(256),iH=new Array(256);for(Cc=0;Cc<256;Cc++)rH[Cc]=J2(Cc)?1:0,iH[Cc]=J2(Cc);var Cc;function Jpe(r,e){this.input=r,this.filename=e.filename||null,this.schema=e.schema||Ope,this.onWarning=e.onWarning||null,this.legacy=e.legacy||!1,this.json=e.json||!1,this.listener=e.listener||null,this.implicitTypes=this.schema.compiledImplicit,this.typeMap=this.schema.compiledTypeMap,this.length=r.length,this.position=0,this.line=0,this.lineStart=0,this.lineIndent=0,this.documents=[]}function nH(r,e){return new X2(e,new Tpe(r.filename,r.input,r.position,r.line,r.position-r.lineStart))}function gt(r,e){throw nH(r,e)}function WI(r,e){r.onWarning&&r.onWarning.call(null,nH(r,e))}var W2={YAML:function(e,t,i){var n,s,o;e.version!==null&>(e,"duplication of %YAML directive"),i.length!==1&>(e,"YAML directive accepts exactly one argument"),n=/^([0-9]+)\.([0-9]+)$/.exec(i[0]),n===null&>(e,"ill-formed argument of the YAML directive"),s=parseInt(n[1],10),o=parseInt(n[2],10),s!==1&>(e,"unacceptable YAML version of the document"),e.version=i[0],e.checkLineBreaks=o<2,o!==1&&o!==2&&WI(e,"unsupported YAML version of the document")},TAG:function(e,t,i){var n,s;i.length!==2&>(e,"TAG directive accepts exactly two arguments"),n=i[0],s=i[1],eH.test(n)||gt(e,"ill-formed tag handle (first argument) of the TAG directive"),FA.call(e.tagMap,n)&>(e,'there is a previously declared suffix for "'+n+'" tag handle'),tH.test(s)||gt(e,"ill-formed tag prefix (second argument) of the TAG directive"),e.tagMap[n]=s}};function RA(r,e,t,i){var n,s,o,a;if(e1&&(r.result+=Qa.repeat(` -`,e-1))}function Wpe(r,e,t){var i,n,s,o,a,l,c,u,g=r.kind,f=r.result,h;if(h=r.input.charCodeAt(r.position),fn(h)||nf(h)||h===35||h===38||h===42||h===33||h===124||h===62||h===39||h===34||h===37||h===64||h===96||(h===63||h===45)&&(n=r.input.charCodeAt(r.position+1),fn(n)||t&&nf(n)))return!1;for(r.kind="scalar",r.result="",s=o=r.position,a=!1;h!==0;){if(h===58){if(n=r.input.charCodeAt(r.position+1),fn(n)||t&&nf(n))break}else if(h===35){if(i=r.input.charCodeAt(r.position-1),fn(i))break}else{if(r.position===r.lineStart&&zI(r)||t&&nf(h))break;if(So(h))if(l=r.line,c=r.lineStart,u=r.lineIndent,_r(r,!1,-1),r.lineIndent>=e){a=!0,h=r.input.charCodeAt(r.position);continue}else{r.position=o,r.line=l,r.lineStart=c,r.lineIndent=u;break}}a&&(RA(r,s,o,!1),vv(r,r.line-l),s=o=r.position,a=!1),mc(h)||(o=r.position+1),h=r.input.charCodeAt(++r.position)}return RA(r,s,o,!1),r.result?!0:(r.kind=g,r.result=f,!1)}function zpe(r,e){var t,i,n;if(t=r.input.charCodeAt(r.position),t!==39)return!1;for(r.kind="scalar",r.result="",r.position++,i=n=r.position;(t=r.input.charCodeAt(r.position))!==0;)if(t===39)if(RA(r,i,r.position,!0),t=r.input.charCodeAt(++r.position),t===39)i=r.position,r.position++,n=r.position;else return!0;else So(t)?(RA(r,i,n,!0),vv(r,_r(r,!1,e)),i=n=r.position):r.position===r.lineStart&&zI(r)?gt(r,"unexpected end of the document within a single quoted scalar"):(r.position++,n=r.position);gt(r,"unexpected end of the stream within a single quoted scalar")}function Vpe(r,e){var t,i,n,s,o,a;if(a=r.input.charCodeAt(r.position),a!==34)return!1;for(r.kind="scalar",r.result="",r.position++,t=i=r.position;(a=r.input.charCodeAt(r.position))!==0;){if(a===34)return RA(r,t,r.position,!0),r.position++,!0;if(a===92){if(RA(r,t,r.position,!0),a=r.input.charCodeAt(++r.position),So(a))_r(r,!1,e);else if(a<256&&rH[a])r.result+=iH[a],r.position++;else if((o=Ype(a))>0){for(n=o,s=0;n>0;n--)a=r.input.charCodeAt(++r.position),(o=Gpe(a))>=0?s=(s<<4)+o:gt(r,"expected hexadecimal character");r.result+=qpe(s),r.position++}else gt(r,"unknown escape sequence");t=i=r.position}else So(a)?(RA(r,t,i,!0),vv(r,_r(r,!1,e)),t=i=r.position):r.position===r.lineStart&&zI(r)?gt(r,"unexpected end of the document within a double quoted scalar"):(r.position++,i=r.position)}gt(r,"unexpected end of the stream within a double quoted scalar")}function Xpe(r,e){var t=!0,i,n=r.tag,s,o=r.anchor,a,l,c,u,g,f={},h,p,C,w;if(w=r.input.charCodeAt(r.position),w===91)l=93,g=!1,s=[];else if(w===123)l=125,g=!0,s={};else return!1;for(r.anchor!==null&&(r.anchorMap[r.anchor]=s),w=r.input.charCodeAt(++r.position);w!==0;){if(_r(r,!0,e),w=r.input.charCodeAt(r.position),w===l)return r.position++,r.tag=n,r.anchor=o,r.kind=g?"mapping":"sequence",r.result=s,!0;t||gt(r,"missed comma between flow collection entries"),p=h=C=null,c=u=!1,w===63&&(a=r.input.charCodeAt(r.position+1),fn(a)&&(c=u=!0,r.position++,_r(r,!0,e))),i=r.line,of(r,e,qI,!1,!0),p=r.tag,h=r.result,_r(r,!0,e),w=r.input.charCodeAt(r.position),(u||r.line===i)&&w===58&&(c=!0,w=r.input.charCodeAt(++r.position),_r(r,!0,e),of(r,e,qI,!1,!0),C=r.result),g?sf(r,s,f,p,h,C):c?s.push(sf(r,null,f,p,h,C)):s.push(h),_r(r,!0,e),w=r.input.charCodeAt(r.position),w===44?(t=!0,w=r.input.charCodeAt(++r.position)):t=!1}gt(r,"unexpected end of the stream within a flow collection")}function _pe(r,e){var t,i,n=bv,s=!1,o=!1,a=e,l=0,c=!1,u,g;if(g=r.input.charCodeAt(r.position),g===124)i=!1;else if(g===62)i=!0;else return!1;for(r.kind="scalar",r.result="";g!==0;)if(g=r.input.charCodeAt(++r.position),g===43||g===45)bv===n?n=g===43?j2:Mpe:gt(r,"repeat of a chomping mode identifier");else if((u=jpe(g))>=0)u===0?gt(r,"bad explicit indentation width of a block scalar; it cannot be less than one"):o?gt(r,"repeat of an indentation width identifier"):(a=e+u-1,o=!0);else break;if(mc(g)){do g=r.input.charCodeAt(++r.position);while(mc(g));if(g===35)do g=r.input.charCodeAt(++r.position);while(!So(g)&&g!==0)}for(;g!==0;){for(Sv(r),r.lineIndent=0,g=r.input.charCodeAt(r.position);(!o||r.lineIndenta&&(a=r.lineIndent),So(g)){l++;continue}if(r.lineIndente)&&l!==0)gt(r,"bad indentation of a sequence entry");else if(r.lineIndente)&&(of(r,e,JI,!0,n)&&(p?f=r.result:h=r.result),p||(sf(r,c,u,g,f,h,s,o),g=f=h=null),_r(r,!0,-1),w=r.input.charCodeAt(r.position)),r.lineIndent>e&&w!==0)gt(r,"bad indentation of a mapping entry");else if(r.lineIndente?l=1:r.lineIndent===e?l=0:r.lineIndente?l=1:r.lineIndent===e?l=0:r.lineIndent tag; it should be "scalar", not "'+r.kind+'"'),g=0,f=r.implicitTypes.length;g tag; it should be "'+h.kind+'", not "'+r.kind+'"'),h.resolve(r.result)?(r.result=h.construct(r.result),r.anchor!==null&&(r.anchorMap[r.anchor]=r.result)):gt(r,"cannot resolve a node with !<"+r.tag+"> explicit tag")):gt(r,"unknown tag !<"+r.tag+">");return r.listener!==null&&r.listener("close",r),r.tag!==null||r.anchor!==null||u}function rde(r){var e=r.position,t,i,n,s=!1,o;for(r.version=null,r.checkLineBreaks=r.legacy,r.tagMap={},r.anchorMap={};(o=r.input.charCodeAt(r.position))!==0&&(_r(r,!0,-1),o=r.input.charCodeAt(r.position),!(r.lineIndent>0||o!==37));){for(s=!0,o=r.input.charCodeAt(++r.position),t=r.position;o!==0&&!fn(o);)o=r.input.charCodeAt(++r.position);for(i=r.input.slice(t,r.position),n=[],i.length<1&>(r,"directive name must not be less than one character in length");o!==0;){for(;mc(o);)o=r.input.charCodeAt(++r.position);if(o===35){do o=r.input.charCodeAt(++r.position);while(o!==0&&!So(o));break}if(So(o))break;for(t=r.position;o!==0&&!fn(o);)o=r.input.charCodeAt(++r.position);n.push(r.input.slice(t,r.position))}o!==0&&Sv(r),FA.call(W2,i)?W2[i](r,i,n):WI(r,'unknown document directive "'+i+'"')}if(_r(r,!0,-1),r.lineIndent===0&&r.input.charCodeAt(r.position)===45&&r.input.charCodeAt(r.position+1)===45&&r.input.charCodeAt(r.position+2)===45?(r.position+=3,_r(r,!0,-1)):s&>(r,"directives end mark is expected"),of(r,r.lineIndent-1,JI,!1,!0),_r(r,!0,-1),r.checkLineBreaks&&Upe.test(r.input.slice(e,r.position))&&WI(r,"non-ASCII line breaks are interpreted as content"),r.documents.push(r.result),r.position===r.lineStart&&zI(r)){r.input.charCodeAt(r.position)===46&&(r.position+=3,_r(r,!0,-1));return}if(r.position"u"&&(t=e,e=null);var i=sH(r,t);if(typeof e!="function")return i;for(var n=0,s=i.length;n"u"&&(t=e,e=null),oH(r,e,Qa.extend({schema:_2},t))}function nde(r,e){return aH(r,Qa.extend({schema:_2},e))}md.exports.loadAll=oH;md.exports.load=aH;md.exports.safeLoadAll=ide;md.exports.safeLoad=nde});var kH=y((o$e,kv)=>{"use strict";var Id=hc(),yd=ef(),sde=Cd(),ode=rf(),dH=Object.prototype.toString,CH=Object.prototype.hasOwnProperty,ade=9,Ed=10,Ade=13,lde=32,cde=33,ude=34,mH=35,gde=37,fde=38,hde=39,pde=42,EH=44,dde=45,IH=58,Cde=61,mde=62,Ede=63,Ide=64,yH=91,wH=93,yde=96,BH=123,wde=124,QH=125,Li={};Li[0]="\\0";Li[7]="\\a";Li[8]="\\b";Li[9]="\\t";Li[10]="\\n";Li[11]="\\v";Li[12]="\\f";Li[13]="\\r";Li[27]="\\e";Li[34]='\\"';Li[92]="\\\\";Li[133]="\\N";Li[160]="\\_";Li[8232]="\\L";Li[8233]="\\P";var Bde=["y","Y","yes","Yes","YES","on","On","ON","n","N","no","No","NO","off","Off","OFF"];function Qde(r,e){var t,i,n,s,o,a,l;if(e===null)return{};for(t={},i=Object.keys(e),n=0,s=i.length;n0?r.charCodeAt(s-1):null,f=f&&uH(o,a)}else{for(s=0;si&&r[g+1]!==" ",g=s);else if(!af(o))return VI;a=s>0?r.charCodeAt(s-1):null,f=f&&uH(o,a)}c=c||u&&s-g-1>i&&r[g+1]!==" "}return!l&&!c?f&&!n(r)?SH:vH:t>9&&bH(r)?VI:c?PH:xH}function Dde(r,e,t,i){r.dump=function(){if(e.length===0)return"''";if(!r.noCompatMode&&Bde.indexOf(e)!==-1)return"'"+e+"'";var n=r.indent*Math.max(1,t),s=r.lineWidth===-1?-1:Math.max(Math.min(r.lineWidth,40),r.lineWidth-n),o=i||r.flowLevel>-1&&t>=r.flowLevel;function a(l){return Sde(r,l)}switch(Pde(e,o,r.indent,s,a)){case SH:return e;case vH:return"'"+e.replace(/'/g,"''")+"'";case xH:return"|"+gH(e,r.indent)+fH(cH(e,n));case PH:return">"+gH(e,r.indent)+fH(cH(kde(e,s),n));case VI:return'"'+Rde(e,s)+'"';default:throw new yd("impossible error: invalid scalar style")}}()}function gH(r,e){var t=bH(r)?String(e):"",i=r[r.length-1]===` -`,n=i&&(r[r.length-2]===` -`||r===` -`),s=n?"+":i?"":"-";return t+s+` -`}function fH(r){return r[r.length-1]===` -`?r.slice(0,-1):r}function kde(r,e){for(var t=/(\n+)([^\n]*)/g,i=function(){var c=r.indexOf(` -`);return c=c!==-1?c:r.length,t.lastIndex=c,hH(r.slice(0,c),e)}(),n=r[0]===` -`||r[0]===" ",s,o;o=t.exec(r);){var a=o[1],l=o[2];s=l[0]===" ",i+=a+(!n&&!s&&l!==""?` -`:"")+hH(l,e),n=s}return i}function hH(r,e){if(r===""||r[0]===" ")return r;for(var t=/ [^ ]/g,i,n=0,s,o=0,a=0,l="";i=t.exec(r);)a=i.index,a-n>e&&(s=o>n?o:a,l+=` -`+r.slice(n,s),n=s+1),o=a;return l+=` -`,r.length-n>e&&o>n?l+=r.slice(n,o)+` -`+r.slice(o+1):l+=r.slice(n),l.slice(1)}function Rde(r){for(var e="",t,i,n,s=0;s=55296&&t<=56319&&(i=r.charCodeAt(s+1),i>=56320&&i<=57343)){e+=lH((t-55296)*1024+i-56320+65536),s++;continue}n=Li[t],e+=!n&&af(t)?r[s]:n||lH(t)}return e}function Fde(r,e,t){var i="",n=r.tag,s,o;for(s=0,o=t.length;s1024&&(u+="? "),u+=r.dump+(r.condenseFlow?'"':"")+":"+(r.condenseFlow?"":" "),Ec(r,e,c,!1,!1)&&(u+=r.dump,i+=u));r.tag=n,r.dump="{"+i+"}"}function Tde(r,e,t,i){var n="",s=r.tag,o=Object.keys(t),a,l,c,u,g,f;if(r.sortKeys===!0)o.sort();else if(typeof r.sortKeys=="function")o.sort(r.sortKeys);else if(r.sortKeys)throw new yd("sortKeys must be a boolean or a function");for(a=0,l=o.length;a1024,g&&(r.dump&&Ed===r.dump.charCodeAt(0)?f+="?":f+="? "),f+=r.dump,g&&(f+=xv(r,e)),Ec(r,e+1,u,!0,g)&&(r.dump&&Ed===r.dump.charCodeAt(0)?f+=":":f+=": ",f+=r.dump,n+=f));r.tag=s,r.dump=n||"{}"}function pH(r,e,t){var i,n,s,o,a,l;for(n=t?r.explicitTypes:r.implicitTypes,s=0,o=n.length;s tag resolver accepts not "'+l+'" style');r.dump=i}return!0}return!1}function Ec(r,e,t,i,n,s){r.tag=null,r.dump=t,pH(r,t,!1)||pH(r,t,!0);var o=dH.call(r.dump);i&&(i=r.flowLevel<0||r.flowLevel>e);var a=o==="[object Object]"||o==="[object Array]",l,c;if(a&&(l=r.duplicates.indexOf(t),c=l!==-1),(r.tag!==null&&r.tag!=="?"||c||r.indent!==2&&e>0)&&(n=!1),c&&r.usedDuplicates[l])r.dump="*ref_"+l;else{if(a&&c&&!r.usedDuplicates[l]&&(r.usedDuplicates[l]=!0),o==="[object Object]")i&&Object.keys(r.dump).length!==0?(Tde(r,e,r.dump,n),c&&(r.dump="&ref_"+l+r.dump)):(Lde(r,e,r.dump),c&&(r.dump="&ref_"+l+" "+r.dump));else if(o==="[object Array]"){var u=r.noArrayIndent&&e>0?e-1:e;i&&r.dump.length!==0?(Nde(r,u,r.dump,n),c&&(r.dump="&ref_"+l+r.dump)):(Fde(r,u,r.dump),c&&(r.dump="&ref_"+l+" "+r.dump))}else if(o==="[object String]")r.tag!=="?"&&Dde(r,r.dump,e,s);else{if(r.skipInvalid)return!1;throw new yd("unacceptable kind of an object to dump "+o)}r.tag!==null&&r.tag!=="?"&&(r.dump="!<"+r.tag+"> "+r.dump)}return!0}function Ode(r,e){var t=[],i=[],n,s;for(Pv(r,t,i),n=0,s=i.length;n{"use strict";var XI=AH(),RH=kH();function _I(r){return function(){throw new Error("Function "+r+" is deprecated and cannot be used.")}}Lr.exports.Type=Ai();Lr.exports.Schema=pc();Lr.exports.FAILSAFE_SCHEMA=YI();Lr.exports.JSON_SCHEMA=wv();Lr.exports.CORE_SCHEMA=Bv();Lr.exports.DEFAULT_SAFE_SCHEMA=rf();Lr.exports.DEFAULT_FULL_SCHEMA=Cd();Lr.exports.load=XI.load;Lr.exports.loadAll=XI.loadAll;Lr.exports.safeLoad=XI.safeLoad;Lr.exports.safeLoadAll=XI.safeLoadAll;Lr.exports.dump=RH.dump;Lr.exports.safeDump=RH.safeDump;Lr.exports.YAMLException=ef();Lr.exports.MINIMAL_SCHEMA=YI();Lr.exports.SAFE_SCHEMA=rf();Lr.exports.DEFAULT_SCHEMA=Cd();Lr.exports.scan=_I("scan");Lr.exports.parse=_I("parse");Lr.exports.compose=_I("compose");Lr.exports.addConstructor=_I("addConstructor")});var LH=y((A$e,NH)=>{"use strict";var Kde=FH();NH.exports=Kde});var OH=y((l$e,TH)=>{"use strict";function Ude(r,e){function t(){this.constructor=r}t.prototype=e.prototype,r.prototype=new t}function Ic(r,e,t,i){this.message=r,this.expected=e,this.found=t,this.location=i,this.name="SyntaxError",typeof Error.captureStackTrace=="function"&&Error.captureStackTrace(this,Ic)}Ude(Ic,Error);Ic.buildMessage=function(r,e){var t={literal:function(c){return'"'+n(c.text)+'"'},class:function(c){var u="",g;for(g=0;g0){for(g=1,f=1;g({[Ne]:pe})))},H=function(R){return R},j=function(R){return R},$=Ms("correct indentation"),V=" ",W=ar(" ",!1),Z=function(R){return R.length===SA*Hg},A=function(R){return R.length===(SA+1)*Hg},ae=function(){return SA++,!0},ge=function(){return SA--,!0},_=function(){return Lg()},T=Ms("pseudostring"),N=/^[^\r\n\t ?:,\][{}#&*!|>'"%@`\-]/,ue=Fn(["\r",` -`," "," ","?",":",",","]","[","{","}","#","&","*","!","|",">","'",'"',"%","@","`","-"],!0,!1),we=/^[^\r\n\t ,\][{}:#"']/,Le=Fn(["\r",` -`," "," ",",","]","[","{","}",":","#",'"',"'"],!0,!1),Pe=function(){return Lg().replace(/^ *| *$/g,"")},Te="--",se=ar("--",!1),Ae=/^[a-zA-Z\/0-9]/,Qe=Fn([["a","z"],["A","Z"],"/",["0","9"]],!1,!1),fe=/^[^\r\n\t :,]/,le=Fn(["\r",` -`," "," ",":",","],!0,!1),Ge="null",ie=ar("null",!1),Y=function(){return null},he="true",re=ar("true",!1),me=function(){return!0},tt="false",Rt=ar("false",!1),It=function(){return!1},Kr=Ms("string"),oi='"',pi=ar('"',!1),pr=function(){return""},di=function(R){return R},ai=function(R){return R.join("")},Os=/^[^"\\\0-\x1F\x7F]/,dr=Fn(['"',"\\",["\0",""],"\x7F"],!0,!1),Bi='\\"',_n=ar('\\"',!1),ha=function(){return'"'},mA="\\\\",Dg=ar("\\\\",!1),Zn=function(){return"\\"},EA="\\/",pa=ar("\\/",!1),jp=function(){return"/"},IA="\\b",yA=ar("\\b",!1),Br=function(){return"\b"},zl="\\f",kg=ar("\\f",!1),Eo=function(){return"\f"},Rg="\\n",qp=ar("\\n",!1),Jp=function(){return` -`},xr="\\r",oe=ar("\\r",!1),Io=function(){return"\r"},kn="\\t",Fg=ar("\\t",!1),Qt=function(){return" "},Vl="\\u",Rn=ar("\\u",!1),$n=function(R,q,pe,Ne){return String.fromCharCode(parseInt(`0x${R}${q}${pe}${Ne}`))},es=/^[0-9a-fA-F]/,ut=Fn([["0","9"],["a","f"],["A","F"]],!1,!1),yo=Ms("blank space"),at=/^[ \t]/,ln=Fn([" "," "],!1,!1),S=Ms("white space"),Tt=/^[ \t\n\r]/,Ng=Fn([" "," ",` -`,"\r"],!1,!1),Xl=`\r -`,Wp=ar(`\r -`,!1),zp=` -`,Vp=ar(` -`,!1),Xp="\r",_p=ar("\r",!1),G=0,yt=0,wA=[{line:1,column:1}],Wi=0,_l=[],We=0,da;if("startRule"in e){if(!(e.startRule in i))throw new Error(`Can't start parsing from rule "`+e.startRule+'".');n=i[e.startRule]}function Lg(){return r.substring(yt,G)}function lI(){return cn(yt,G)}function Zp(R,q){throw q=q!==void 0?q:cn(yt,G),$l([Ms(R)],r.substring(yt,G),q)}function cI(R,q){throw q=q!==void 0?q:cn(yt,G),Tg(R,q)}function ar(R,q){return{type:"literal",text:R,ignoreCase:q}}function Fn(R,q,pe){return{type:"class",parts:R,inverted:q,ignoreCase:pe}}function Zl(){return{type:"any"}}function $p(){return{type:"end"}}function Ms(R){return{type:"other",description:R}}function Ca(R){var q=wA[R],pe;if(q)return q;for(pe=R-1;!wA[pe];)pe--;for(q=wA[pe],q={line:q.line,column:q.column};peWi&&(Wi=G,_l=[]),_l.push(R))}function Tg(R,q){return new Ic(R,null,null,q)}function $l(R,q,pe){return new Ic(Ic.buildMessage(R,q),R,q,pe)}function Ks(){var R;return R=Og(),R}function ec(){var R,q,pe;for(R=G,q=[],pe=BA();pe!==t;)q.push(pe),pe=BA();return q!==t&&(yt=R,q=s(q)),R=q,R}function BA(){var R,q,pe,Ne,xe;return R=G,q=Ea(),q!==t?(r.charCodeAt(G)===45?(pe=o,G++):(pe=t,We===0&&De(a)),pe!==t?(Ne=Fr(),Ne!==t?(xe=ma(),xe!==t?(yt=R,q=l(xe),R=q):(G=R,R=t)):(G=R,R=t)):(G=R,R=t)):(G=R,R=t),R}function Og(){var R,q,pe;for(R=G,q=[],pe=Mg();pe!==t;)q.push(pe),pe=Mg();return q!==t&&(yt=R,q=c(q)),R=q,R}function Mg(){var R,q,pe,Ne,xe,qe,dt,Ft,Nn;if(R=G,q=Fr(),q===t&&(q=null),q!==t){if(pe=G,r.charCodeAt(G)===35?(Ne=u,G++):(Ne=t,We===0&&De(g)),Ne!==t){if(xe=[],qe=G,dt=G,We++,Ft=Gs(),We--,Ft===t?dt=void 0:(G=dt,dt=t),dt!==t?(r.length>G?(Ft=r.charAt(G),G++):(Ft=t,We===0&&De(f)),Ft!==t?(dt=[dt,Ft],qe=dt):(G=qe,qe=t)):(G=qe,qe=t),qe!==t)for(;qe!==t;)xe.push(qe),qe=G,dt=G,We++,Ft=Gs(),We--,Ft===t?dt=void 0:(G=dt,dt=t),dt!==t?(r.length>G?(Ft=r.charAt(G),G++):(Ft=t,We===0&&De(f)),Ft!==t?(dt=[dt,Ft],qe=dt):(G=qe,qe=t)):(G=qe,qe=t);else xe=t;xe!==t?(Ne=[Ne,xe],pe=Ne):(G=pe,pe=t)}else G=pe,pe=t;if(pe===t&&(pe=null),pe!==t){if(Ne=[],xe=Hs(),xe!==t)for(;xe!==t;)Ne.push(xe),xe=Hs();else Ne=t;Ne!==t?(yt=R,q=h(),R=q):(G=R,R=t)}else G=R,R=t}else G=R,R=t;if(R===t&&(R=G,q=Ea(),q!==t?(pe=tc(),pe!==t?(Ne=Fr(),Ne===t&&(Ne=null),Ne!==t?(r.charCodeAt(G)===58?(xe=p,G++):(xe=t,We===0&&De(C)),xe!==t?(qe=Fr(),qe===t&&(qe=null),qe!==t?(dt=ma(),dt!==t?(yt=R,q=w(pe,dt),R=q):(G=R,R=t)):(G=R,R=t)):(G=R,R=t)):(G=R,R=t)):(G=R,R=t)):(G=R,R=t),R===t&&(R=G,q=Ea(),q!==t?(pe=Us(),pe!==t?(Ne=Fr(),Ne===t&&(Ne=null),Ne!==t?(r.charCodeAt(G)===58?(xe=p,G++):(xe=t,We===0&&De(C)),xe!==t?(qe=Fr(),qe===t&&(qe=null),qe!==t?(dt=ma(),dt!==t?(yt=R,q=w(pe,dt),R=q):(G=R,R=t)):(G=R,R=t)):(G=R,R=t)):(G=R,R=t)):(G=R,R=t)):(G=R,R=t),R===t))){if(R=G,q=Ea(),q!==t)if(pe=Us(),pe!==t)if(Ne=Fr(),Ne!==t)if(xe=uI(),xe!==t){if(qe=[],dt=Hs(),dt!==t)for(;dt!==t;)qe.push(dt),dt=Hs();else qe=t;qe!==t?(yt=R,q=w(pe,xe),R=q):(G=R,R=t)}else G=R,R=t;else G=R,R=t;else G=R,R=t;else G=R,R=t;if(R===t)if(R=G,q=Ea(),q!==t)if(pe=Us(),pe!==t){if(Ne=[],xe=G,qe=Fr(),qe===t&&(qe=null),qe!==t?(r.charCodeAt(G)===44?(dt=B,G++):(dt=t,We===0&&De(v)),dt!==t?(Ft=Fr(),Ft===t&&(Ft=null),Ft!==t?(Nn=Us(),Nn!==t?(yt=xe,qe=D(pe,Nn),xe=qe):(G=xe,xe=t)):(G=xe,xe=t)):(G=xe,xe=t)):(G=xe,xe=t),xe!==t)for(;xe!==t;)Ne.push(xe),xe=G,qe=Fr(),qe===t&&(qe=null),qe!==t?(r.charCodeAt(G)===44?(dt=B,G++):(dt=t,We===0&&De(v)),dt!==t?(Ft=Fr(),Ft===t&&(Ft=null),Ft!==t?(Nn=Us(),Nn!==t?(yt=xe,qe=D(pe,Nn),xe=qe):(G=xe,xe=t)):(G=xe,xe=t)):(G=xe,xe=t)):(G=xe,xe=t);else Ne=t;Ne!==t?(xe=Fr(),xe===t&&(xe=null),xe!==t?(r.charCodeAt(G)===58?(qe=p,G++):(qe=t,We===0&&De(C)),qe!==t?(dt=Fr(),dt===t&&(dt=null),dt!==t?(Ft=ma(),Ft!==t?(yt=R,q=L(pe,Ne,Ft),R=q):(G=R,R=t)):(G=R,R=t)):(G=R,R=t)):(G=R,R=t)):(G=R,R=t)}else G=R,R=t;else G=R,R=t}return R}function ma(){var R,q,pe,Ne,xe,qe,dt;if(R=G,q=G,We++,pe=G,Ne=Gs(),Ne!==t?(xe=$e(),xe!==t?(r.charCodeAt(G)===45?(qe=o,G++):(qe=t,We===0&&De(a)),qe!==t?(dt=Fr(),dt!==t?(Ne=[Ne,xe,qe,dt],pe=Ne):(G=pe,pe=t)):(G=pe,pe=t)):(G=pe,pe=t)):(G=pe,pe=t),We--,pe!==t?(G=q,q=void 0):q=t,q!==t?(pe=Hs(),pe!==t?(Ne=wo(),Ne!==t?(xe=ec(),xe!==t?(qe=QA(),qe!==t?(yt=R,q=H(xe),R=q):(G=R,R=t)):(G=R,R=t)):(G=R,R=t)):(G=R,R=t)):(G=R,R=t),R===t&&(R=G,q=Gs(),q!==t?(pe=wo(),pe!==t?(Ne=Og(),Ne!==t?(xe=QA(),xe!==t?(yt=R,q=H(Ne),R=q):(G=R,R=t)):(G=R,R=t)):(G=R,R=t)):(G=R,R=t),R===t))if(R=G,q=rc(),q!==t){if(pe=[],Ne=Hs(),Ne!==t)for(;Ne!==t;)pe.push(Ne),Ne=Hs();else pe=t;pe!==t?(yt=R,q=j(q),R=q):(G=R,R=t)}else G=R,R=t;return R}function Ea(){var R,q,pe;for(We++,R=G,q=[],r.charCodeAt(G)===32?(pe=V,G++):(pe=t,We===0&&De(W));pe!==t;)q.push(pe),r.charCodeAt(G)===32?(pe=V,G++):(pe=t,We===0&&De(W));return q!==t?(yt=G,pe=Z(q),pe?pe=void 0:pe=t,pe!==t?(q=[q,pe],R=q):(G=R,R=t)):(G=R,R=t),We--,R===t&&(q=t,We===0&&De($)),R}function $e(){var R,q,pe;for(R=G,q=[],r.charCodeAt(G)===32?(pe=V,G++):(pe=t,We===0&&De(W));pe!==t;)q.push(pe),r.charCodeAt(G)===32?(pe=V,G++):(pe=t,We===0&&De(W));return q!==t?(yt=G,pe=A(q),pe?pe=void 0:pe=t,pe!==t?(q=[q,pe],R=q):(G=R,R=t)):(G=R,R=t),R}function wo(){var R;return yt=G,R=ae(),R?R=void 0:R=t,R}function QA(){var R;return yt=G,R=ge(),R?R=void 0:R=t,R}function tc(){var R;return R=ic(),R===t&&(R=ed()),R}function Us(){var R,q,pe;if(R=ic(),R===t){if(R=G,q=[],pe=Kg(),pe!==t)for(;pe!==t;)q.push(pe),pe=Kg();else q=t;q!==t&&(yt=R,q=_()),R=q}return R}function rc(){var R;return R=td(),R===t&&(R=gI(),R===t&&(R=ic(),R===t&&(R=ed()))),R}function uI(){var R;return R=td(),R===t&&(R=ic(),R===t&&(R=Kg())),R}function ed(){var R,q,pe,Ne,xe,qe;if(We++,R=G,N.test(r.charAt(G))?(q=r.charAt(G),G++):(q=t,We===0&&De(ue)),q!==t){for(pe=[],Ne=G,xe=Fr(),xe===t&&(xe=null),xe!==t?(we.test(r.charAt(G))?(qe=r.charAt(G),G++):(qe=t,We===0&&De(Le)),qe!==t?(xe=[xe,qe],Ne=xe):(G=Ne,Ne=t)):(G=Ne,Ne=t);Ne!==t;)pe.push(Ne),Ne=G,xe=Fr(),xe===t&&(xe=null),xe!==t?(we.test(r.charAt(G))?(qe=r.charAt(G),G++):(qe=t,We===0&&De(Le)),qe!==t?(xe=[xe,qe],Ne=xe):(G=Ne,Ne=t)):(G=Ne,Ne=t);pe!==t?(yt=R,q=Pe(),R=q):(G=R,R=t)}else G=R,R=t;return We--,R===t&&(q=t,We===0&&De(T)),R}function Kg(){var R,q,pe,Ne,xe;if(R=G,r.substr(G,2)===Te?(q=Te,G+=2):(q=t,We===0&&De(se)),q===t&&(q=null),q!==t)if(Ae.test(r.charAt(G))?(pe=r.charAt(G),G++):(pe=t,We===0&&De(Qe)),pe!==t){for(Ne=[],fe.test(r.charAt(G))?(xe=r.charAt(G),G++):(xe=t,We===0&&De(le));xe!==t;)Ne.push(xe),fe.test(r.charAt(G))?(xe=r.charAt(G),G++):(xe=t,We===0&&De(le));Ne!==t?(yt=R,q=Pe(),R=q):(G=R,R=t)}else G=R,R=t;else G=R,R=t;return R}function td(){var R,q;return R=G,r.substr(G,4)===Ge?(q=Ge,G+=4):(q=t,We===0&&De(ie)),q!==t&&(yt=R,q=Y()),R=q,R}function gI(){var R,q;return R=G,r.substr(G,4)===he?(q=he,G+=4):(q=t,We===0&&De(re)),q!==t&&(yt=R,q=me()),R=q,R===t&&(R=G,r.substr(G,5)===tt?(q=tt,G+=5):(q=t,We===0&&De(Rt)),q!==t&&(yt=R,q=It()),R=q),R}function ic(){var R,q,pe,Ne;return We++,R=G,r.charCodeAt(G)===34?(q=oi,G++):(q=t,We===0&&De(pi)),q!==t?(r.charCodeAt(G)===34?(pe=oi,G++):(pe=t,We===0&&De(pi)),pe!==t?(yt=R,q=pr(),R=q):(G=R,R=t)):(G=R,R=t),R===t&&(R=G,r.charCodeAt(G)===34?(q=oi,G++):(q=t,We===0&&De(pi)),q!==t?(pe=fI(),pe!==t?(r.charCodeAt(G)===34?(Ne=oi,G++):(Ne=t,We===0&&De(pi)),Ne!==t?(yt=R,q=di(pe),R=q):(G=R,R=t)):(G=R,R=t)):(G=R,R=t)),We--,R===t&&(q=t,We===0&&De(Kr)),R}function fI(){var R,q,pe;if(R=G,q=[],pe=Ug(),pe!==t)for(;pe!==t;)q.push(pe),pe=Ug();else q=t;return q!==t&&(yt=R,q=ai(q)),R=q,R}function Ug(){var R,q,pe,Ne,xe,qe;return Os.test(r.charAt(G))?(R=r.charAt(G),G++):(R=t,We===0&&De(dr)),R===t&&(R=G,r.substr(G,2)===Bi?(q=Bi,G+=2):(q=t,We===0&&De(_n)),q!==t&&(yt=R,q=ha()),R=q,R===t&&(R=G,r.substr(G,2)===mA?(q=mA,G+=2):(q=t,We===0&&De(Dg)),q!==t&&(yt=R,q=Zn()),R=q,R===t&&(R=G,r.substr(G,2)===EA?(q=EA,G+=2):(q=t,We===0&&De(pa)),q!==t&&(yt=R,q=jp()),R=q,R===t&&(R=G,r.substr(G,2)===IA?(q=IA,G+=2):(q=t,We===0&&De(yA)),q!==t&&(yt=R,q=Br()),R=q,R===t&&(R=G,r.substr(G,2)===zl?(q=zl,G+=2):(q=t,We===0&&De(kg)),q!==t&&(yt=R,q=Eo()),R=q,R===t&&(R=G,r.substr(G,2)===Rg?(q=Rg,G+=2):(q=t,We===0&&De(qp)),q!==t&&(yt=R,q=Jp()),R=q,R===t&&(R=G,r.substr(G,2)===xr?(q=xr,G+=2):(q=t,We===0&&De(oe)),q!==t&&(yt=R,q=Io()),R=q,R===t&&(R=G,r.substr(G,2)===kn?(q=kn,G+=2):(q=t,We===0&&De(Fg)),q!==t&&(yt=R,q=Qt()),R=q,R===t&&(R=G,r.substr(G,2)===Vl?(q=Vl,G+=2):(q=t,We===0&&De(Rn)),q!==t?(pe=bA(),pe!==t?(Ne=bA(),Ne!==t?(xe=bA(),xe!==t?(qe=bA(),qe!==t?(yt=R,q=$n(pe,Ne,xe,qe),R=q):(G=R,R=t)):(G=R,R=t)):(G=R,R=t)):(G=R,R=t)):(G=R,R=t)))))))))),R}function bA(){var R;return es.test(r.charAt(G))?(R=r.charAt(G),G++):(R=t,We===0&&De(ut)),R}function Fr(){var R,q;if(We++,R=[],at.test(r.charAt(G))?(q=r.charAt(G),G++):(q=t,We===0&&De(ln)),q!==t)for(;q!==t;)R.push(q),at.test(r.charAt(G))?(q=r.charAt(G),G++):(q=t,We===0&&De(ln));else R=t;return We--,R===t&&(q=t,We===0&&De(yo)),R}function hI(){var R,q;if(We++,R=[],Tt.test(r.charAt(G))?(q=r.charAt(G),G++):(q=t,We===0&&De(Ng)),q!==t)for(;q!==t;)R.push(q),Tt.test(r.charAt(G))?(q=r.charAt(G),G++):(q=t,We===0&&De(Ng));else R=t;return We--,R===t&&(q=t,We===0&&De(S)),R}function Hs(){var R,q,pe,Ne,xe,qe;if(R=G,q=Gs(),q!==t){for(pe=[],Ne=G,xe=Fr(),xe===t&&(xe=null),xe!==t?(qe=Gs(),qe!==t?(xe=[xe,qe],Ne=xe):(G=Ne,Ne=t)):(G=Ne,Ne=t);Ne!==t;)pe.push(Ne),Ne=G,xe=Fr(),xe===t&&(xe=null),xe!==t?(qe=Gs(),qe!==t?(xe=[xe,qe],Ne=xe):(G=Ne,Ne=t)):(G=Ne,Ne=t);pe!==t?(q=[q,pe],R=q):(G=R,R=t)}else G=R,R=t;return R}function Gs(){var R;return r.substr(G,2)===Xl?(R=Xl,G+=2):(R=t,We===0&&De(Wp)),R===t&&(r.charCodeAt(G)===10?(R=zp,G++):(R=t,We===0&&De(Vp)),R===t&&(r.charCodeAt(G)===13?(R=Xp,G++):(R=t,We===0&&De(_p)))),R}let Hg=2,SA=0;if(da=n(),da!==t&&G===r.length)return da;throw da!==t&&G{"use strict";var Jde=r=>{let e=!1,t=!1,i=!1;for(let n=0;n{if(!(typeof r=="string"||Array.isArray(r)))throw new TypeError("Expected the input to be `string | string[]`");e=Object.assign({pascalCase:!1},e);let t=n=>e.pascalCase?n.charAt(0).toUpperCase()+n.slice(1):n;return Array.isArray(r)?r=r.map(n=>n.trim()).filter(n=>n.length).join("-"):r=r.trim(),r.length===0?"":r.length===1?e.pascalCase?r.toUpperCase():r.toLowerCase():(r!==r.toLowerCase()&&(r=Jde(r)),r=r.replace(/^[_.\- ]+/,"").toLowerCase().replace(/[_.\- ]+(\w|$)/g,(n,s)=>s.toUpperCase()).replace(/\d+(\w|$)/g,n=>n.toUpperCase()),t(r))};Fv.exports=GH;Fv.exports.default=GH});var jH=y((p$e,Wde)=>{Wde.exports=[{name:"AppVeyor",constant:"APPVEYOR",env:"APPVEYOR",pr:"APPVEYOR_PULL_REQUEST_NUMBER"},{name:"Azure Pipelines",constant:"AZURE_PIPELINES",env:"SYSTEM_TEAMFOUNDATIONCOLLECTIONURI",pr:"SYSTEM_PULLREQUEST_PULLREQUESTID"},{name:"Appcircle",constant:"APPCIRCLE",env:"AC_APPCIRCLE"},{name:"Bamboo",constant:"BAMBOO",env:"bamboo_planKey"},{name:"Bitbucket Pipelines",constant:"BITBUCKET",env:"BITBUCKET_COMMIT",pr:"BITBUCKET_PR_ID"},{name:"Bitrise",constant:"BITRISE",env:"BITRISE_IO",pr:"BITRISE_PULL_REQUEST"},{name:"Buddy",constant:"BUDDY",env:"BUDDY_WORKSPACE_ID",pr:"BUDDY_EXECUTION_PULL_REQUEST_ID"},{name:"Buildkite",constant:"BUILDKITE",env:"BUILDKITE",pr:{env:"BUILDKITE_PULL_REQUEST",ne:"false"}},{name:"CircleCI",constant:"CIRCLE",env:"CIRCLECI",pr:"CIRCLE_PULL_REQUEST"},{name:"Cirrus CI",constant:"CIRRUS",env:"CIRRUS_CI",pr:"CIRRUS_PR"},{name:"AWS CodeBuild",constant:"CODEBUILD",env:"CODEBUILD_BUILD_ARN"},{name:"Codefresh",constant:"CODEFRESH",env:"CF_BUILD_ID",pr:{any:["CF_PULL_REQUEST_NUMBER","CF_PULL_REQUEST_ID"]}},{name:"Codeship",constant:"CODESHIP",env:{CI_NAME:"codeship"}},{name:"Drone",constant:"DRONE",env:"DRONE",pr:{DRONE_BUILD_EVENT:"pull_request"}},{name:"dsari",constant:"DSARI",env:"DSARI"},{name:"GitHub Actions",constant:"GITHUB_ACTIONS",env:"GITHUB_ACTIONS",pr:{GITHUB_EVENT_NAME:"pull_request"}},{name:"GitLab CI",constant:"GITLAB",env:"GITLAB_CI",pr:"CI_MERGE_REQUEST_ID"},{name:"GoCD",constant:"GOCD",env:"GO_PIPELINE_LABEL"},{name:"LayerCI",constant:"LAYERCI",env:"LAYERCI",pr:"LAYERCI_PULL_REQUEST"},{name:"Hudson",constant:"HUDSON",env:"HUDSON_URL"},{name:"Jenkins",constant:"JENKINS",env:["JENKINS_URL","BUILD_ID"],pr:{any:["ghprbPullId","CHANGE_ID"]}},{name:"Magnum CI",constant:"MAGNUM",env:"MAGNUM"},{name:"Netlify CI",constant:"NETLIFY",env:"NETLIFY",pr:{env:"PULL_REQUEST",ne:"false"}},{name:"Nevercode",constant:"NEVERCODE",env:"NEVERCODE",pr:{env:"NEVERCODE_PULL_REQUEST",ne:"false"}},{name:"Render",constant:"RENDER",env:"RENDER",pr:{IS_PULL_REQUEST:"true"}},{name:"Sail CI",constant:"SAIL",env:"SAILCI",pr:"SAIL_PULL_REQUEST_NUMBER"},{name:"Semaphore",constant:"SEMAPHORE",env:"SEMAPHORE",pr:"PULL_REQUEST_NUMBER"},{name:"Screwdriver",constant:"SCREWDRIVER",env:"SCREWDRIVER",pr:{env:"SD_PULL_REQUEST",ne:"false"}},{name:"Shippable",constant:"SHIPPABLE",env:"SHIPPABLE",pr:{IS_PULL_REQUEST:"true"}},{name:"Solano CI",constant:"SOLANO",env:"TDDIUM",pr:"TDDIUM_PR_ID"},{name:"Strider CD",constant:"STRIDER",env:"STRIDER"},{name:"TaskCluster",constant:"TASKCLUSTER",env:["TASK_ID","RUN_ID"]},{name:"TeamCity",constant:"TEAMCITY",env:"TEAMCITY_VERSION"},{name:"Travis CI",constant:"TRAVIS",env:"TRAVIS",pr:{env:"TRAVIS_PULL_REQUEST",ne:"false"}},{name:"Vercel",constant:"VERCEL",env:"NOW_BUILDER"},{name:"Visual Studio App Center",constant:"APPCENTER",env:"APPCENTER_BUILD_ID"}]});var yc=y(Mn=>{"use strict";var JH=jH(),vo=process.env;Object.defineProperty(Mn,"_vendors",{value:JH.map(function(r){return r.constant})});Mn.name=null;Mn.isPR=null;JH.forEach(function(r){let t=(Array.isArray(r.env)?r.env:[r.env]).every(function(i){return qH(i)});if(Mn[r.constant]=t,t)switch(Mn.name=r.name,typeof r.pr){case"string":Mn.isPR=!!vo[r.pr];break;case"object":"env"in r.pr?Mn.isPR=r.pr.env in vo&&vo[r.pr.env]!==r.pr.ne:"any"in r.pr?Mn.isPR=r.pr.any.some(function(i){return!!vo[i]}):Mn.isPR=qH(r.pr);break;default:Mn.isPR=null}});Mn.isCI=!!(vo.CI||vo.CONTINUOUS_INTEGRATION||vo.BUILD_NUMBER||vo.RUN_ID||Mn.name);function qH(r){return typeof r=="string"?!!vo[r]:Object.keys(r).every(function(e){return vo[e]===r[e]})}});var ey=y(Kn=>{"use strict";Object.defineProperty(Kn,"__esModule",{value:!0});var zde=0,Vde=1,Xde=2,_de="",Zde="\0",$de=-1,eCe=/^(-h|--help)(?:=([0-9]+))?$/,tCe=/^(--[a-z]+(?:-[a-z]+)*|-[a-zA-Z]+)$/,rCe=/^-[a-zA-Z]{2,}$/,iCe=/^([^=]+)=([\s\S]*)$/,nCe=process.env.DEBUG_CLI==="1";Kn.BATCH_REGEX=rCe;Kn.BINDING_REGEX=iCe;Kn.DEBUG=nCe;Kn.END_OF_INPUT=Zde;Kn.HELP_COMMAND_INDEX=$de;Kn.HELP_REGEX=eCe;Kn.NODE_ERRORED=Xde;Kn.NODE_INITIAL=zde;Kn.NODE_SUCCESS=Vde;Kn.OPTION_REGEX=tCe;Kn.START_OF_INPUT=_de});var ty=y(Bd=>{"use strict";Object.defineProperty(Bd,"__esModule",{value:!0});var sCe=ey(),Nv=class extends Error{constructor(e){super(e),this.clipanion={type:"usage"},this.name="UsageError"}},Lv=class extends Error{constructor(e,t){if(super(),this.input=e,this.candidates=t,this.clipanion={type:"none"},this.name="UnknownSyntaxError",this.candidates.length===0)this.message="Command not found, but we're not sure what's the alternative.";else if(this.candidates.every(i=>i.reason!==null&&i.reason===t[0].reason)){let[{reason:i}]=this.candidates;this.message=`${i} - -${this.candidates.map(({usage:n})=>`$ ${n}`).join(` -`)}`}else if(this.candidates.length===1){let[{usage:i}]=this.candidates;this.message=`Command not found; did you mean: - -$ ${i} -${Ov(e)}`}else this.message=`Command not found; did you mean one of: - -${this.candidates.map(({usage:i},n)=>`${`${n}.`.padStart(4)} ${i}`).join(` -`)} - -${Ov(e)}`}},Tv=class extends Error{constructor(e,t){super(),this.input=e,this.usages=t,this.clipanion={type:"none"},this.name="AmbiguousSyntaxError",this.message=`Cannot find which to pick amongst the following alternatives: - -${this.usages.map((i,n)=>`${`${n}.`.padStart(4)} ${i}`).join(` -`)} - -${Ov(e)}`}},Ov=r=>`While running ${r.filter(e=>e!==sCe.END_OF_INPUT).map(e=>{let t=JSON.stringify(e);return e.match(/\s/)||e.length===0||t!==`"${e}"`?t:e}).join(" ")}`;Bd.AmbiguousSyntaxError=Tv;Bd.UnknownSyntaxError=Lv;Bd.UsageError=Nv});var Sa=y(NA=>{"use strict";Object.defineProperty(NA,"__esModule",{value:!0});var WH=ty(),zH=Symbol("clipanion/isOption");function oCe(r){return{...r,[zH]:!0}}function aCe(r,e){return typeof r>"u"?[r,e]:typeof r=="object"&&r!==null&&!Array.isArray(r)?[void 0,r]:[r,e]}function Mv(r,e=!1){let t=r.replace(/^\.: /,"");return e&&(t=t[0].toLowerCase()+t.slice(1)),t}function VH(r,e){return e.length===1?new WH.UsageError(`${r}: ${Mv(e[0],!0)}`):new WH.UsageError(`${r}: -${e.map(t=>` -- ${Mv(t)}`).join("")}`)}function ACe(r,e,t){if(typeof t>"u")return e;let i=[],n=[],s=a=>{let l=e;return e=a,s.bind(null,l)};if(!t(e,{errors:i,coercions:n,coercion:s}))throw VH(`Invalid value for ${r}`,i);for(let[,a]of n)a();return e}NA.applyValidator=ACe;NA.cleanValidationError=Mv;NA.formatError=VH;NA.isOptionSymbol=zH;NA.makeCommandOption=oCe;NA.rerouteArguments=aCe});var ns=y(st=>{"use strict";Object.defineProperty(st,"__esModule",{value:!0});var XH=/^[a-zA-Z_][a-zA-Z0-9_]*$/,_H=/^#[0-9a-f]{6}$/i,ZH=/^#[0-9a-f]{6}([0-9a-f]{2})?$/i,$H=/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/,eG=/^[a-f0-9]{8}-[a-f0-9]{4}-4[a-f0-9]{3}-[89aAbB][a-f0-9]{3}-[a-f0-9]{12}$/i,Kv=/^(?:[1-9]\d{3}(-?)(?:(?:0[1-9]|1[0-2])\1(?:0[1-9]|1\d|2[0-8])|(?:0[13-9]|1[0-2])\1(?:29|30)|(?:0[13578]|1[02])(?:\1)31|00[1-9]|0[1-9]\d|[12]\d{2}|3(?:[0-5]\d|6[0-5]))|(?:[1-9]\d(?:0[48]|[2468][048]|[13579][26])|(?:[2468][048]|[13579][26])00)(?:(-?)02(?:\2)29|-?366))T(?:[01]\d|2[0-3])(:?)[0-5]\d(?:\3[0-5]\d)?(?:Z|[+-][01]\d(?:\3[0-5]\d)?)$/,tG=r=>()=>r;function bt({test:r}){return tG(r)()}function Zr(r){return r===null?"null":r===void 0?"undefined":r===""?"an empty string":JSON.stringify(r)}function LA(r,e){var t,i,n;return typeof e=="number"?`${(t=r==null?void 0:r.p)!==null&&t!==void 0?t:"."}[${e}]`:XH.test(e)?`${(i=r==null?void 0:r.p)!==null&&i!==void 0?i:""}.${e}`:`${(n=r==null?void 0:r.p)!==null&&n!==void 0?n:"."}[${JSON.stringify(e)}]`}function wc(r,e){return t=>{let i=r[e];return r[e]=t,wc(r,e).bind(null,i)}}function rG(r,e){return t=>{r[e]=t}}function ry(r,e,t){return r===1?e:t}function pt({errors:r,p:e}={},t){return r==null||r.push(`${e!=null?e:"."}: ${t}`),!1}var iG=()=>bt({test:(r,e)=>!0});function lCe(r){return bt({test:(e,t)=>e!==r?pt(t,`Expected a literal (got ${Zr(r)})`):!0})}var cCe=()=>bt({test:(r,e)=>typeof r!="string"?pt(e,`Expected a string (got ${Zr(r)})`):!0});function uCe(r){let e=Array.isArray(r)?r:Object.values(r),t=new Set(e);return bt({test:(i,n)=>t.has(i)?!0:pt(n,`Expected a valid enumeration value (got ${Zr(i)})`)})}var gCe=new Map([["true",!0],["True",!0],["1",!0],[1,!0],["false",!1],["False",!1],["0",!1],[0,!1]]),fCe=()=>bt({test:(r,e)=>{var t;if(typeof r!="boolean"){if(typeof(e==null?void 0:e.coercions)<"u"){if(typeof(e==null?void 0:e.coercion)>"u")return pt(e,"Unbound coercion result");let i=gCe.get(r);if(typeof i<"u")return e.coercions.push([(t=e.p)!==null&&t!==void 0?t:".",e.coercion.bind(null,i)]),!0}return pt(e,`Expected a boolean (got ${Zr(r)})`)}return!0}}),hCe=()=>bt({test:(r,e)=>{var t;if(typeof r!="number"){if(typeof(e==null?void 0:e.coercions)<"u"){if(typeof(e==null?void 0:e.coercion)>"u")return pt(e,"Unbound coercion result");let i;if(typeof r=="string"){let n;try{n=JSON.parse(r)}catch{}if(typeof n=="number")if(JSON.stringify(n)===r)i=n;else return pt(e,`Received a number that can't be safely represented by the runtime (${r})`)}if(typeof i<"u")return e.coercions.push([(t=e.p)!==null&&t!==void 0?t:".",e.coercion.bind(null,i)]),!0}return pt(e,`Expected a number (got ${Zr(r)})`)}return!0}}),pCe=()=>bt({test:(r,e)=>{var t;if(!(r instanceof Date)){if(typeof(e==null?void 0:e.coercions)<"u"){if(typeof(e==null?void 0:e.coercion)>"u")return pt(e,"Unbound coercion result");let i;if(typeof r=="string"&&Kv.test(r))i=new Date(r);else{let n;if(typeof r=="string"){let s;try{s=JSON.parse(r)}catch{}typeof s=="number"&&(n=s)}else typeof r=="number"&&(n=r);if(typeof n<"u")if(Number.isSafeInteger(n)||!Number.isSafeInteger(n*1e3))i=new Date(n*1e3);else return pt(e,`Received a timestamp that can't be safely represented by the runtime (${r})`)}if(typeof i<"u")return e.coercions.push([(t=e.p)!==null&&t!==void 0?t:".",e.coercion.bind(null,i)]),!0}return pt(e,`Expected a date (got ${Zr(r)})`)}return!0}}),dCe=(r,{delimiter:e}={})=>bt({test:(t,i)=>{var n;if(typeof t=="string"&&typeof e<"u"&&typeof(i==null?void 0:i.coercions)<"u"){if(typeof(i==null?void 0:i.coercion)>"u")return pt(i,"Unbound coercion result");t=t.split(e),i.coercions.push([(n=i.p)!==null&&n!==void 0?n:".",i.coercion.bind(null,t)])}if(!Array.isArray(t))return pt(i,`Expected an array (got ${Zr(t)})`);let s=!0;for(let o=0,a=t.length;o{let t=nG(r.length);return bt({test:(i,n)=>{var s;if(typeof i=="string"&&typeof e<"u"&&typeof(n==null?void 0:n.coercions)<"u"){if(typeof(n==null?void 0:n.coercion)>"u")return pt(n,"Unbound coercion result");i=i.split(e),n.coercions.push([(s=n.p)!==null&&s!==void 0?s:".",n.coercion.bind(null,i)])}if(!Array.isArray(i))return pt(n,`Expected a tuple (got ${Zr(i)})`);let o=t(i,Object.assign({},n));for(let a=0,l=i.length;abt({test:(t,i)=>{if(typeof t!="object"||t===null)return pt(i,`Expected an object (got ${Zr(t)})`);let n=Object.keys(t),s=!0;for(let o=0,a=n.length;o{let t=Object.keys(r);return bt({test:(i,n)=>{if(typeof i!="object"||i===null)return pt(n,`Expected an object (got ${Zr(i)})`);let s=new Set([...t,...Object.keys(i)]),o={},a=!0;for(let l of s){if(l==="constructor"||l==="__proto__")a=pt(Object.assign(Object.assign({},n),{p:LA(n,l)}),"Unsafe property name");else{let c=Object.prototype.hasOwnProperty.call(r,l)?r[l]:void 0,u=Object.prototype.hasOwnProperty.call(i,l)?i[l]:void 0;typeof c<"u"?a=c(u,Object.assign(Object.assign({},n),{p:LA(n,l),coercion:wc(i,l)}))&&a:e===null?a=pt(Object.assign(Object.assign({},n),{p:LA(n,l)}),`Extraneous property (got ${Zr(u)})`):Object.defineProperty(o,l,{enumerable:!0,get:()=>u,set:rG(i,l)})}if(!a&&(n==null?void 0:n.errors)==null)break}return e!==null&&(a||(n==null?void 0:n.errors)!=null)&&(a=e(o,n)&&a),a}})},ICe=r=>bt({test:(e,t)=>e instanceof r?!0:pt(t,`Expected an instance of ${r.name} (got ${Zr(e)})`)}),yCe=(r,{exclusive:e=!1}={})=>bt({test:(t,i)=>{var n,s,o;let a=[],l=typeof(i==null?void 0:i.errors)<"u"?[]:void 0;for(let c=0,u=r.length;c1?pt(i,`Expected to match exactly a single predicate (matched ${a.join(", ")})`):(o=i==null?void 0:i.errors)===null||o===void 0||o.push(...l),!1}}),wCe=(r,e)=>bt({test:(t,i)=>{var n,s;let o={value:t},a=typeof(i==null?void 0:i.coercions)<"u"?wc(o,"value"):void 0,l=typeof(i==null?void 0:i.coercions)<"u"?[]:void 0;if(!r(t,Object.assign(Object.assign({},i),{coercion:a,coercions:l})))return!1;let c=[];if(typeof l<"u")for(let[,u]of l)c.push(u());try{if(typeof(i==null?void 0:i.coercions)<"u"){if(o.value!==t){if(typeof(i==null?void 0:i.coercion)>"u")return pt(i,"Unbound coercion result");i.coercions.push([(n=i.p)!==null&&n!==void 0?n:".",i.coercion.bind(null,o.value)])}(s=i==null?void 0:i.coercions)===null||s===void 0||s.push(...l)}return e.every(u=>u(o.value,i))}finally{for(let u of c)u()}}}),BCe=r=>bt({test:(e,t)=>typeof e>"u"?!0:r(e,t)}),QCe=r=>bt({test:(e,t)=>e===null?!0:r(e,t)}),bCe=r=>bt({test:(e,t)=>e.length>=r?!0:pt(t,`Expected to have a length of at least ${r} elements (got ${e.length})`)}),SCe=r=>bt({test:(e,t)=>e.length<=r?!0:pt(t,`Expected to have a length of at most ${r} elements (got ${e.length})`)}),nG=r=>bt({test:(e,t)=>e.length!==r?pt(t,`Expected to have a length of exactly ${r} elements (got ${e.length})`):!0}),vCe=({map:r}={})=>bt({test:(e,t)=>{let i=new Set,n=new Set;for(let s=0,o=e.length;sbt({test:(r,e)=>r<=0?!0:pt(e,`Expected to be negative (got ${r})`)}),PCe=()=>bt({test:(r,e)=>r>=0?!0:pt(e,`Expected to be positive (got ${r})`)}),DCe=r=>bt({test:(e,t)=>e>=r?!0:pt(t,`Expected to be at least ${r} (got ${e})`)}),kCe=r=>bt({test:(e,t)=>e<=r?!0:pt(t,`Expected to be at most ${r} (got ${e})`)}),RCe=(r,e)=>bt({test:(t,i)=>t>=r&&t<=e?!0:pt(i,`Expected to be in the [${r}; ${e}] range (got ${t})`)}),FCe=(r,e)=>bt({test:(t,i)=>t>=r&&tbt({test:(e,t)=>e!==Math.round(e)?pt(t,`Expected to be an integer (got ${e})`):Number.isSafeInteger(e)?!0:pt(t,`Expected to be a safe integer (got ${e})`)}),LCe=r=>bt({test:(e,t)=>r.test(e)?!0:pt(t,`Expected to match the pattern ${r.toString()} (got ${Zr(e)})`)}),TCe=()=>bt({test:(r,e)=>r!==r.toLowerCase()?pt(e,`Expected to be all-lowercase (got ${r})`):!0}),OCe=()=>bt({test:(r,e)=>r!==r.toUpperCase()?pt(e,`Expected to be all-uppercase (got ${r})`):!0}),MCe=()=>bt({test:(r,e)=>eG.test(r)?!0:pt(e,`Expected to be a valid UUID v4 (got ${Zr(r)})`)}),KCe=()=>bt({test:(r,e)=>Kv.test(r)?!1:pt(e,`Expected to be a valid ISO 8601 date string (got ${Zr(r)})`)}),UCe=({alpha:r=!1})=>bt({test:(e,t)=>(r?_H.test(e):ZH.test(e))?!0:pt(t,`Expected to be a valid hexadecimal color string (got ${Zr(e)})`)}),HCe=()=>bt({test:(r,e)=>$H.test(r)?!0:pt(e,`Expected to be a valid base 64 string (got ${Zr(r)})`)}),GCe=(r=iG())=>bt({test:(e,t)=>{let i;try{i=JSON.parse(e)}catch{return pt(t,`Expected to be a valid JSON string (got ${Zr(e)})`)}return r(i,t)}}),YCe=r=>{let e=new Set(r);return bt({test:(t,i)=>{let n=new Set(Object.keys(t)),s=[];for(let o of e)n.has(o)||s.push(o);return s.length>0?pt(i,`Missing required ${ry(s.length,"property","properties")} ${s.map(o=>`"${o}"`).join(", ")}`):!0}})},jCe=r=>{let e=new Set(r);return bt({test:(t,i)=>{let n=new Set(Object.keys(t)),s=[];for(let o of e)n.has(o)&&s.push(o);return s.length>0?pt(i,`Forbidden ${ry(s.length,"property","properties")} ${s.map(o=>`"${o}"`).join(", ")}`):!0}})},qCe=r=>{let e=new Set(r);return bt({test:(t,i)=>{let n=new Set(Object.keys(t)),s=[];for(let o of e)n.has(o)&&s.push(o);return s.length>1?pt(i,`Mutually exclusive properties ${s.map(o=>`"${o}"`).join(", ")}`):!0}})};(function(r){r.Forbids="Forbids",r.Requires="Requires"})(st.KeyRelationship||(st.KeyRelationship={}));var JCe={[st.KeyRelationship.Forbids]:{expect:!1,message:"forbids using"},[st.KeyRelationship.Requires]:{expect:!0,message:"requires using"}},WCe=(r,e,t,{ignore:i=[]}={})=>{let n=new Set(i),s=new Set(t),o=JCe[e];return bt({test:(a,l)=>{let c=new Set(Object.keys(a));if(!c.has(r)||n.has(a[r]))return!0;let u=[];for(let g of s)(c.has(g)&&!n.has(a[g]))!==o.expect&&u.push(g);return u.length>=1?pt(l,`Property "${r}" ${o.message} ${ry(u.length,"property","properties")} ${u.map(g=>`"${g}"`).join(", ")}`):!0}})};st.applyCascade=wCe;st.base64RegExp=$H;st.colorStringAlphaRegExp=ZH;st.colorStringRegExp=_H;st.computeKey=LA;st.getPrintable=Zr;st.hasExactLength=nG;st.hasForbiddenKeys=jCe;st.hasKeyRelationship=WCe;st.hasMaxLength=SCe;st.hasMinLength=bCe;st.hasMutuallyExclusiveKeys=qCe;st.hasRequiredKeys=YCe;st.hasUniqueItems=vCe;st.isArray=dCe;st.isAtLeast=DCe;st.isAtMost=kCe;st.isBase64=HCe;st.isBoolean=fCe;st.isDate=pCe;st.isDict=mCe;st.isEnum=uCe;st.isHexColor=UCe;st.isISO8601=KCe;st.isInExclusiveRange=FCe;st.isInInclusiveRange=RCe;st.isInstanceOf=ICe;st.isInteger=NCe;st.isJSON=GCe;st.isLiteral=lCe;st.isLowerCase=TCe;st.isNegative=xCe;st.isNullable=QCe;st.isNumber=hCe;st.isObject=ECe;st.isOneOf=yCe;st.isOptional=BCe;st.isPositive=PCe;st.isString=cCe;st.isTuple=CCe;st.isUUID4=MCe;st.isUnknown=iG;st.isUpperCase=OCe;st.iso8601RegExp=Kv;st.makeCoercionFn=wc;st.makeSetter=rG;st.makeTrait=tG;st.makeValidator=bt;st.matchesRegExp=LCe;st.plural=ry;st.pushError=pt;st.simpleKeyRegExp=XH;st.uuid4RegExp=eG});var Bc=y(Uv=>{"use strict";Object.defineProperty(Uv,"__esModule",{value:!0});var sG=Sa();function zCe(r){if(r&&r.__esModule)return r;var e=Object.create(null);return r&&Object.keys(r).forEach(function(t){if(t!=="default"){var i=Object.getOwnPropertyDescriptor(r,t);Object.defineProperty(e,t,i.get?i:{enumerable:!0,get:function(){return r[t]}})}}),e.default=r,Object.freeze(e)}var Qd=class{constructor(){this.help=!1}static Usage(e){return e}async catch(e){throw e}async validateAndExecute(){let t=this.constructor.schema;if(Array.isArray(t)){let{isDict:n,isUnknown:s,applyCascade:o}=await Promise.resolve().then(function(){return zCe(ns())}),a=o(n(s()),t),l=[],c=[];if(!a(this,{errors:l,coercions:c}))throw sG.formatError("Invalid option schema",l);for(let[,g]of c)g()}else if(t!=null)throw new Error("Invalid command schema");let i=await this.execute();return typeof i<"u"?i:0}};Qd.isOption=sG.isOptionSymbol;Qd.Default=[];Uv.Command=Qd});var Gv=y(bd=>{"use strict";Object.defineProperty(bd,"__esModule",{value:!0});var oG=80,Hv=Array(oG).fill("\u2501");for(let r=0;r<=24;++r)Hv[Hv.length-r]=`\x1B[38;5;${232+r}m\u2501`;var VCe={header:r=>`\x1B[1m\u2501\u2501\u2501 ${r}${r.length`\x1B[1m${r}\x1B[22m`,error:r=>`\x1B[31m\x1B[1m${r}\x1B[22m\x1B[39m`,code:r=>`\x1B[36m${r}\x1B[39m`},XCe={header:r=>r,bold:r=>r,error:r=>r,code:r=>r};function _Ce(r){let e=r.split(` -`),t=e.filter(n=>n.match(/\S/)),i=t.length>0?t.reduce((n,s)=>Math.min(n,s.length-s.trimStart().length),Number.MAX_VALUE):0;return e.map(n=>n.slice(i).trimRight()).join(` -`)}function ZCe(r,{format:e,paragraphs:t}){return r=r.replace(/\r\n?/g,` -`),r=_Ce(r),r=r.replace(/^\n+|\n+$/g,""),r=r.replace(/^(\s*)-([^\n]*?)\n+/gm,`$1-$2 - -`),r=r.replace(/\n(\n)?\n*/g,"$1"),t&&(r=r.split(/\n/).map(i=>{let n=i.match(/^\s*[*-][\t ]+(.*)/);if(!n)return i.match(/(.{1,80})(?: |$)/g).join(` -`);let s=i.length-i.trimStart().length;return n[1].match(new RegExp(`(.{1,${78-s}})(?: |$)`,"g")).map((o,a)=>" ".repeat(s)+(a===0?"- ":" ")+o).join(` -`)}).join(` - -`)),r=r.replace(/(`+)((?:.|[\n])*?)\1/g,(i,n,s)=>e.code(n+s+n)),r=r.replace(/(\*\*)((?:.|[\n])*?)\1/g,(i,n,s)=>e.bold(n+s+n)),r?`${r} -`:""}bd.formatMarkdownish=ZCe;bd.richFormat=VCe;bd.textFormat=XCe});var ay=y(Ar=>{"use strict";Object.defineProperty(Ar,"__esModule",{value:!0});var lt=ey(),sy=ty();function Vi(r){lt.DEBUG&&console.log(r)}var aG={candidateUsage:null,requiredOptions:[],errorMessage:null,ignoreOptions:!1,path:[],positionals:[],options:[],remainder:null,selectedIndex:lt.HELP_COMMAND_INDEX};function Yv(){return{nodes:[Ti(),Ti(),Ti()]}}function AG(r){let e=Yv(),t=[],i=e.nodes.length;for(let n of r){t.push(i);for(let s=0;s{if(e.has(i))return;e.add(i);let n=r.nodes[i];for(let o of Object.values(n.statics))for(let{to:a}of o)t(a);for(let[,{to:o}]of n.dynamics)t(o);for(let{to:o}of n.shortcuts)t(o);let s=new Set(n.shortcuts.map(({to:o})=>o));for(;n.shortcuts.length>0;){let{to:o}=n.shortcuts.shift(),a=r.nodes[o];for(let[l,c]of Object.entries(a.statics)){let u=Object.prototype.hasOwnProperty.call(n.statics,l)?n.statics[l]:n.statics[l]=[];for(let g of c)u.some(({to:f})=>g.to===f)||u.push(g)}for(let[l,c]of a.dynamics)n.dynamics.some(([u,{to:g}])=>l===u&&c.to===g)||n.dynamics.push([l,c]);for(let l of a.shortcuts)s.has(l.to)||(n.shortcuts.push(l),s.add(l.to))}};t(lt.NODE_INITIAL)}function cG(r,{prefix:e=""}={}){if(lt.DEBUG){Vi(`${e}Nodes are:`);for(let t=0;tl!==lt.NODE_ERRORED).map(({state:l})=>({usage:l.candidateUsage,reason:null})));if(a.every(({node:l})=>l===lt.NODE_ERRORED))throw new sy.UnknownSyntaxError(e,a.map(({state:l})=>({usage:l.candidateUsage,reason:l.errorMessage})));i=uG(a)}if(i.length>0){Vi(" Results:");for(let s of i)Vi(` - ${s.node} -> ${JSON.stringify(s.state)}`)}else Vi(" No results");return i}function $Ce(r,e){if(e.selectedIndex!==null)return!0;if(Object.prototype.hasOwnProperty.call(r.statics,lt.END_OF_INPUT)){for(let{to:t}of r.statics[lt.END_OF_INPUT])if(t===lt.NODE_SUCCESS)return!0}return!1}function eme(r,e,t){let i=t&&e.length>0?[""]:[],n=jv(r,e,t),s=[],o=new Set,a=(l,c,u=!0)=>{let g=[c];for(;g.length>0;){let h=g;g=[];for(let p of h){let C=r.nodes[p],w=Object.keys(C.statics);for(let B of Object.keys(C.statics)){let v=w[0];for(let{to:D,reducer:L}of C.statics[v])L==="pushPath"&&(u||l.push(v),g.push(D))}}u=!1}let f=JSON.stringify(l);o.has(f)||(s.push(l),o.add(f))};for(let{node:l,state:c}of n){if(c.remainder!==null){a([c.remainder],l);continue}let u=r.nodes[l],g=$Ce(u,c);for(let[f,h]of Object.entries(u.statics))(g&&f!==lt.END_OF_INPUT||!f.startsWith("-")&&h.some(({reducer:p})=>p==="pushPath"))&&a([...i,f],l);if(!!g)for(let[f,{to:h}]of u.dynamics){if(h===lt.NODE_ERRORED)continue;let p=dG(f,c);if(p!==null)for(let C of p)a([...i,C],l)}}return[...s].sort()}function tme(r,e){let t=jv(r,[...e,lt.END_OF_INPUT]);return gG(e,t.map(({state:i})=>i))}function uG(r){let e=0;for(let{state:t}of r)t.path.length>e&&(e=t.path.length);return r.filter(({state:t})=>t.path.length===e)}function gG(r,e){let t=e.filter(g=>g.selectedIndex!==null);if(t.length===0)throw new Error;let i=t.filter(g=>g.requiredOptions.every(f=>f.some(h=>g.options.find(p=>p.name===h))));if(i.length===0)throw new sy.UnknownSyntaxError(r,t.map(g=>({usage:g.candidateUsage,reason:null})));let n=0;for(let g of i)g.path.length>n&&(n=g.path.length);let s=i.filter(g=>g.path.length===n),o=g=>g.positionals.filter(({extra:f})=>!f).length+g.options.length,a=s.map(g=>({state:g,positionalCount:o(g)})),l=0;for(let{positionalCount:g}of a)g>l&&(l=g);let c=a.filter(({positionalCount:g})=>g===l).map(({state:g})=>g),u=fG(c);if(u.length>1)throw new sy.AmbiguousSyntaxError(r,u.map(g=>g.candidateUsage));return u[0]}function fG(r){let e=[],t=[];for(let i of r)i.selectedIndex===lt.HELP_COMMAND_INDEX?t.push(i):e.push(i);return t.length>0&&e.push({...aG,path:hG(...t.map(i=>i.path)),options:t.reduce((i,n)=>i.concat(n.options),[])}),e}function hG(r,e,...t){return e===void 0?Array.from(r):hG(r.filter((i,n)=>i===e[n]),...t)}function Ti(){return{dynamics:[],shortcuts:[],statics:{}}}function qv(r){return r===lt.NODE_SUCCESS||r===lt.NODE_ERRORED}function iy(r,e=0){return{to:qv(r.to)?r.to:r.to>2?r.to+e-2:r.to+e,reducer:r.reducer}}function pG(r,e=0){let t=Ti();for(let[i,n]of r.dynamics)t.dynamics.push([i,iy(n,e)]);for(let i of r.shortcuts)t.shortcuts.push(iy(i,e));for(let[i,n]of Object.entries(r.statics))t.statics[i]=n.map(s=>iy(s,e));return t}function Ei(r,e,t,i,n){r.nodes[e].dynamics.push([t,{to:i,reducer:n}])}function Qc(r,e,t,i){r.nodes[e].shortcuts.push({to:t,reducer:i})}function xo(r,e,t,i,n){(Object.prototype.hasOwnProperty.call(r.nodes[e].statics,t)?r.nodes[e].statics[t]:r.nodes[e].statics[t]=[]).push({to:i,reducer:n})}function Sd(r,e,t,i){if(Array.isArray(e)){let[n,...s]=e;return r[n](t,i,...s)}else return r[e](t,i)}function dG(r,e){let t=Array.isArray(r)?vd[r[0]]:vd[r];if(typeof t.suggest>"u")return null;let i=Array.isArray(r)?r.slice(1):[];return t.suggest(e,...i)}var vd={always:()=>!0,isOptionLike:(r,e)=>!r.ignoreOptions&&e!=="-"&&e.startsWith("-"),isNotOptionLike:(r,e)=>r.ignoreOptions||e==="-"||!e.startsWith("-"),isOption:(r,e,t,i)=>!r.ignoreOptions&&e===t,isBatchOption:(r,e,t)=>!r.ignoreOptions&<.BATCH_REGEX.test(e)&&[...e.slice(1)].every(i=>t.includes(`-${i}`)),isBoundOption:(r,e,t,i)=>{let n=e.match(lt.BINDING_REGEX);return!r.ignoreOptions&&!!n&<.OPTION_REGEX.test(n[1])&&t.includes(n[1])&&i.filter(s=>s.names.includes(n[1])).every(s=>s.allowBinding)},isNegatedOption:(r,e,t)=>!r.ignoreOptions&&e===`--no-${t.slice(2)}`,isHelp:(r,e)=>!r.ignoreOptions&<.HELP_REGEX.test(e),isUnsupportedOption:(r,e,t)=>!r.ignoreOptions&&e.startsWith("-")&<.OPTION_REGEX.test(e)&&!t.includes(e),isInvalidOption:(r,e)=>!r.ignoreOptions&&e.startsWith("-")&&!lt.OPTION_REGEX.test(e)};vd.isOption.suggest=(r,e,t=!0)=>t?null:[e];var ny={setCandidateState:(r,e,t)=>({...r,...t}),setSelectedIndex:(r,e,t)=>({...r,selectedIndex:t}),pushBatch:(r,e)=>({...r,options:r.options.concat([...e.slice(1)].map(t=>({name:`-${t}`,value:!0})))}),pushBound:(r,e)=>{let[,t,i]=e.match(lt.BINDING_REGEX);return{...r,options:r.options.concat({name:t,value:i})}},pushPath:(r,e)=>({...r,path:r.path.concat(e)}),pushPositional:(r,e)=>({...r,positionals:r.positionals.concat({value:e,extra:!1})}),pushExtra:(r,e)=>({...r,positionals:r.positionals.concat({value:e,extra:!0})}),pushExtraNoLimits:(r,e)=>({...r,positionals:r.positionals.concat({value:e,extra:Po})}),pushTrue:(r,e,t=e)=>({...r,options:r.options.concat({name:e,value:!0})}),pushFalse:(r,e,t=e)=>({...r,options:r.options.concat({name:t,value:!1})}),pushUndefined:(r,e)=>({...r,options:r.options.concat({name:e,value:void 0})}),pushStringValue:(r,e)=>{var t;let i={...r,options:[...r.options]},n=r.options[r.options.length-1];return n.value=((t=n.value)!==null&&t!==void 0?t:[]).concat([e]),i},setStringValue:(r,e)=>{let t={...r,options:[...r.options]},i=r.options[r.options.length-1];return i.value=e,t},inhibateOptions:r=>({...r,ignoreOptions:!0}),useHelp:(r,e,t)=>{let[,,i]=e.match(lt.HELP_REGEX);return typeof i<"u"?{...r,options:[{name:"-c",value:String(t)},{name:"-i",value:i}]}:{...r,options:[{name:"-c",value:String(t)}]}},setError:(r,e,t)=>e===lt.END_OF_INPUT?{...r,errorMessage:`${t}.`}:{...r,errorMessage:`${t} ("${e}").`},setOptionArityError:(r,e)=>{let t=r.options[r.options.length-1];return{...r,errorMessage:`Not enough arguments to option ${t.name}.`}}},Po=Symbol(),oy=class{constructor(e,t){this.allOptionNames=[],this.arity={leading:[],trailing:[],extra:[],proxy:!1},this.options=[],this.paths=[],this.cliIndex=e,this.cliOpts=t}addPath(e){this.paths.push(e)}setArity({leading:e=this.arity.leading,trailing:t=this.arity.trailing,extra:i=this.arity.extra,proxy:n=this.arity.proxy}){Object.assign(this.arity,{leading:e,trailing:t,extra:i,proxy:n})}addPositional({name:e="arg",required:t=!0}={}){if(!t&&this.arity.extra===Po)throw new Error("Optional parameters cannot be declared when using .rest() or .proxy()");if(!t&&this.arity.trailing.length>0)throw new Error("Optional parameters cannot be declared after the required trailing positional arguments");!t&&this.arity.extra!==Po?this.arity.extra.push(e):this.arity.extra!==Po&&this.arity.extra.length===0?this.arity.leading.push(e):this.arity.trailing.push(e)}addRest({name:e="arg",required:t=0}={}){if(this.arity.extra===Po)throw new Error("Infinite lists cannot be declared multiple times in the same command");if(this.arity.trailing.length>0)throw new Error("Infinite lists cannot be declared after the required trailing positional arguments");for(let i=0;i1)throw new Error("The arity cannot be higher than 1 when the option only supports the --arg=value syntax");if(!Number.isInteger(i))throw new Error(`The arity must be an integer, got ${i}`);if(i<0)throw new Error(`The arity must be positive, got ${i}`);this.allOptionNames.push(...e),this.options.push({names:e,description:t,arity:i,hidden:n,required:s,allowBinding:o})}setContext(e){this.context=e}usage({detailed:e=!0,inlineOptions:t=!0}={}){let i=[this.cliOpts.binaryName],n=[];if(this.paths.length>0&&i.push(...this.paths[0]),e){for(let{names:o,arity:a,hidden:l,description:c,required:u}of this.options){if(l)continue;let g=[];for(let h=0;h`:`[${f}]`)}i.push(...this.arity.leading.map(o=>`<${o}>`)),this.arity.extra===Po?i.push("..."):i.push(...this.arity.extra.map(o=>`[${o}]`)),i.push(...this.arity.trailing.map(o=>`<${o}>`))}return{usage:i.join(" "),options:n}}compile(){if(typeof this.context>"u")throw new Error("Assertion failed: No context attached");let e=Yv(),t=lt.NODE_INITIAL,i=this.usage().usage,n=this.options.filter(a=>a.required).map(a=>a.names);t=ss(e,Ti()),xo(e,lt.NODE_INITIAL,lt.START_OF_INPUT,t,["setCandidateState",{candidateUsage:i,requiredOptions:n}]);let s=this.arity.proxy?"always":"isNotOptionLike",o=this.paths.length>0?this.paths:[[]];for(let a of o){let l=t;if(a.length>0){let f=ss(e,Ti());Qc(e,l,f),this.registerOptions(e,f),l=f}for(let f=0;f0||!this.arity.proxy){let f=ss(e,Ti());Ei(e,l,"isHelp",f,["useHelp",this.cliIndex]),xo(e,f,lt.END_OF_INPUT,lt.NODE_SUCCESS,["setSelectedIndex",lt.HELP_COMMAND_INDEX]),this.registerOptions(e,l)}this.arity.leading.length>0&&xo(e,l,lt.END_OF_INPUT,lt.NODE_ERRORED,["setError","Not enough positional arguments"]);let c=l;for(let f=0;f0||f+1!==this.arity.leading.length)&&xo(e,h,lt.END_OF_INPUT,lt.NODE_ERRORED,["setError","Not enough positional arguments"]),Ei(e,c,"isNotOptionLike",h,"pushPositional"),c=h}let u=c;if(this.arity.extra===Po||this.arity.extra.length>0){let f=ss(e,Ti());if(Qc(e,c,f),this.arity.extra===Po){let h=ss(e,Ti());this.arity.proxy||this.registerOptions(e,h),Ei(e,c,s,h,"pushExtraNoLimits"),Ei(e,h,s,h,"pushExtraNoLimits"),Qc(e,h,f)}else for(let h=0;h0&&xo(e,u,lt.END_OF_INPUT,lt.NODE_ERRORED,["setError","Not enough positional arguments"]);let g=u;for(let f=0;fo.length>s.length?o:s,"");if(i.arity===0)for(let s of i.names)Ei(e,t,["isOption",s,i.hidden||s!==n],t,"pushTrue"),s.startsWith("--")&&!s.startsWith("--no-")&&Ei(e,t,["isNegatedOption",s],t,["pushFalse",s]);else{let s=ss(e,Ti());for(let o of i.names)Ei(e,t,["isOption",o,i.hidden||o!==n],s,"pushUndefined");for(let o=0;o=0&&etme(i,n),suggest:(n,s)=>eme(i,n,s)}}};Ar.CliBuilder=xd;Ar.CommandBuilder=oy;Ar.NoLimits=Po;Ar.aggregateHelpStates=fG;Ar.cloneNode=pG;Ar.cloneTransition=iy;Ar.debug=Vi;Ar.debugMachine=cG;Ar.execute=Sd;Ar.injectNode=ss;Ar.isTerminalNode=qv;Ar.makeAnyOfMachine=AG;Ar.makeNode=Ti;Ar.makeStateMachine=Yv;Ar.reducers=ny;Ar.registerDynamic=Ei;Ar.registerShortcut=Qc;Ar.registerStatic=xo;Ar.runMachineInternal=jv;Ar.selectBestState=gG;Ar.simplifyMachine=lG;Ar.suggest=dG;Ar.tests=vd;Ar.trimSmallerBranches=uG});var CG=y(Jv=>{"use strict";Object.defineProperty(Jv,"__esModule",{value:!0});var rme=Bc(),Pd=class extends rme.Command{constructor(e){super(),this.contexts=e,this.commands=[]}static from(e,t){let i=new Pd(t);i.path=e.path;for(let n of e.options)switch(n.name){case"-c":i.commands.push(Number(n.value));break;case"-i":i.index=Number(n.value);break}return i}async execute(){let e=this.commands;if(typeof this.index<"u"&&this.index>=0&&this.index1){this.context.stdout.write(`Multiple commands match your selection: -`),this.context.stdout.write(` -`);let t=0;for(let i of this.commands)this.context.stdout.write(this.cli.usage(this.contexts[i].commandClass,{prefix:`${t++}. `.padStart(5)}));this.context.stdout.write(` -`),this.context.stdout.write(`Run again with -h= to see the longer details of any of those commands. -`)}}};Jv.HelpCommand=Pd});var BG=y(Wv=>{"use strict";Object.defineProperty(Wv,"__esModule",{value:!0});var ime=ey(),mG=Bc(),nme=J("tty"),sme=ay(),hn=Gv(),ome=CG();function ame(r){return r&&typeof r=="object"&&"default"in r?r:{default:r}}var EG=ame(nme),IG=Symbol("clipanion/errorCommand");function Ame(){return process.env.FORCE_COLOR==="0"?1:process.env.FORCE_COLOR==="1"||typeof process.stdout<"u"&&process.stdout.isTTY?8:1}var TA=class{constructor({binaryLabel:e,binaryName:t="...",binaryVersion:i,enableCapture:n=!1,enableColors:s}={}){this.registrations=new Map,this.builder=new sme.CliBuilder({binaryName:t}),this.binaryLabel=e,this.binaryName=t,this.binaryVersion=i,this.enableCapture=n,this.enableColors=s}static from(e,t={}){let i=new TA(t);for(let n of e)i.register(n);return i}register(e){var t;let i=new Map,n=new e;for(let l in n){let c=n[l];typeof c=="object"&&c!==null&&c[mG.Command.isOption]&&i.set(l,c)}let s=this.builder.command(),o=s.cliIndex,a=(t=e.paths)!==null&&t!==void 0?t:n.paths;if(typeof a<"u")for(let l of a)s.addPath(l);this.registrations.set(e,{specs:i,builder:s,index:o});for(let[l,{definition:c}]of i.entries())c(s,l);s.setContext({commandClass:e})}process(e){let{contexts:t,process:i}=this.builder.compile(),n=i(e);switch(n.selectedIndex){case ime.HELP_COMMAND_INDEX:return ome.HelpCommand.from(n,t);default:{let{commandClass:s}=t[n.selectedIndex],o=this.registrations.get(s);if(typeof o>"u")throw new Error("Assertion failed: Expected the command class to have been registered.");let a=new s;a.path=n.path;try{for(let[l,{transformer:c}]of o.specs.entries())a[l]=c(o.builder,l,n);return a}catch(l){throw l[IG]=a,l}}break}}async run(e,t){var i;let n,s={...TA.defaultContext,...t},o=(i=this.enableColors)!==null&&i!==void 0?i:s.colorDepth>1;if(!Array.isArray(e))n=e;else try{n=this.process(e)}catch(c){return s.stdout.write(this.error(c,{colored:o})),1}if(n.help)return s.stdout.write(this.usage(n,{colored:o,detailed:!0})),0;n.context=s,n.cli={binaryLabel:this.binaryLabel,binaryName:this.binaryName,binaryVersion:this.binaryVersion,enableCapture:this.enableCapture,enableColors:this.enableColors,definitions:()=>this.definitions(),error:(c,u)=>this.error(c,u),format:c=>this.format(c),process:c=>this.process(c),run:(c,u)=>this.run(c,{...s,...u}),usage:(c,u)=>this.usage(c,u)};let a=this.enableCapture?lme(s):wG,l;try{l=await a(()=>n.validateAndExecute().catch(c=>n.catch(c).then(()=>0)))}catch(c){return s.stdout.write(this.error(c,{colored:o,command:n})),1}return l}async runExit(e,t){process.exitCode=await this.run(e,t)}suggest(e,t){let{suggest:i}=this.builder.compile();return i(e,t)}definitions({colored:e=!1}={}){let t=[];for(let[i,{index:n}]of this.registrations){if(typeof i.usage>"u")continue;let{usage:s}=this.getUsageByIndex(n,{detailed:!1}),{usage:o,options:a}=this.getUsageByIndex(n,{detailed:!0,inlineOptions:!1}),l=typeof i.usage.category<"u"?hn.formatMarkdownish(i.usage.category,{format:this.format(e),paragraphs:!1}):void 0,c=typeof i.usage.description<"u"?hn.formatMarkdownish(i.usage.description,{format:this.format(e),paragraphs:!1}):void 0,u=typeof i.usage.details<"u"?hn.formatMarkdownish(i.usage.details,{format:this.format(e),paragraphs:!0}):void 0,g=typeof i.usage.examples<"u"?i.usage.examples.map(([f,h])=>[hn.formatMarkdownish(f,{format:this.format(e),paragraphs:!1}),h.replace(/\$0/g,this.binaryName)]):void 0;t.push({path:s,usage:o,category:l,description:c,details:u,examples:g,options:a})}return t}usage(e=null,{colored:t,detailed:i=!1,prefix:n="$ "}={}){var s;if(e===null){for(let l of this.registrations.keys()){let c=l.paths,u=typeof l.usage<"u";if(!c||c.length===0||c.length===1&&c[0].length===0||((s=c==null?void 0:c.some(h=>h.length===0))!==null&&s!==void 0?s:!1))if(e){e=null;break}else e=l;else if(u){e=null;continue}}e&&(i=!0)}let o=e!==null&&e instanceof mG.Command?e.constructor:e,a="";if(o)if(i){let{description:l="",details:c="",examples:u=[]}=o.usage||{};l!==""&&(a+=hn.formatMarkdownish(l,{format:this.format(t),paragraphs:!1}).replace(/^./,h=>h.toUpperCase()),a+=` -`),(c!==""||u.length>0)&&(a+=`${this.format(t).header("Usage")} -`,a+=` -`);let{usage:g,options:f}=this.getUsageByRegistration(o,{inlineOptions:!1});if(a+=`${this.format(t).bold(n)}${g} -`,f.length>0){a+=` -`,a+=`${hn.richFormat.header("Options")} -`;let h=f.reduce((p,C)=>Math.max(p,C.definition.length),0);a+=` -`;for(let{definition:p,description:C}of f)a+=` ${this.format(t).bold(p.padEnd(h))} ${hn.formatMarkdownish(C,{format:this.format(t),paragraphs:!1})}`}if(c!==""&&(a+=` -`,a+=`${this.format(t).header("Details")} -`,a+=` -`,a+=hn.formatMarkdownish(c,{format:this.format(t),paragraphs:!0})),u.length>0){a+=` -`,a+=`${this.format(t).header("Examples")} -`;for(let[h,p]of u)a+=` -`,a+=hn.formatMarkdownish(h,{format:this.format(t),paragraphs:!1}),a+=`${p.replace(/^/m,` ${this.format(t).bold(n)}`).replace(/\$0/g,this.binaryName)} -`}}else{let{usage:l}=this.getUsageByRegistration(o);a+=`${this.format(t).bold(n)}${l} -`}else{let l=new Map;for(let[f,{index:h}]of this.registrations.entries()){if(typeof f.usage>"u")continue;let p=typeof f.usage.category<"u"?hn.formatMarkdownish(f.usage.category,{format:this.format(t),paragraphs:!1}):null,C=l.get(p);typeof C>"u"&&l.set(p,C=[]);let{usage:w}=this.getUsageByIndex(h);C.push({commandClass:f,usage:w})}let c=Array.from(l.keys()).sort((f,h)=>f===null?-1:h===null?1:f.localeCompare(h,"en",{usage:"sort",caseFirst:"upper"})),u=typeof this.binaryLabel<"u",g=typeof this.binaryVersion<"u";u||g?(u&&g?a+=`${this.format(t).header(`${this.binaryLabel} - ${this.binaryVersion}`)} - -`:u?a+=`${this.format(t).header(`${this.binaryLabel}`)} -`:a+=`${this.format(t).header(`${this.binaryVersion}`)} -`,a+=` ${this.format(t).bold(n)}${this.binaryName} -`):a+=`${this.format(t).bold(n)}${this.binaryName} -`;for(let f of c){let h=l.get(f).slice().sort((C,w)=>C.usage.localeCompare(w.usage,"en",{usage:"sort",caseFirst:"upper"})),p=f!==null?f.trim():"General commands";a+=` -`,a+=`${this.format(t).header(`${p}`)} -`;for(let{commandClass:C,usage:w}of h){let B=C.usage.description||"undocumented";a+=` -`,a+=` ${this.format(t).bold(w)} -`,a+=` ${hn.formatMarkdownish(B,{format:this.format(t),paragraphs:!1})}`}}a+=` -`,a+=hn.formatMarkdownish("You can also print more details about any of these commands by calling them with the `-h,--help` flag right after the command name.",{format:this.format(t),paragraphs:!0})}return a}error(e,t){var i,{colored:n,command:s=(i=e[IG])!==null&&i!==void 0?i:null}=t===void 0?{}:t;e instanceof Error||(e=new Error(`Execution failed with a non-error rejection (rejected value: ${JSON.stringify(e)})`));let o="",a=e.name.replace(/([a-z])([A-Z])/g,"$1 $2");a==="Error"&&(a="Internal Error"),o+=`${this.format(n).error(a)}: ${e.message} -`;let l=e.clipanion;return typeof l<"u"?l.type==="usage"&&(o+=` -`,o+=this.usage(s)):e.stack&&(o+=`${e.stack.replace(/^.*\n/,"")} -`),o}format(e){var t;return((t=e!=null?e:this.enableColors)!==null&&t!==void 0?t:TA.defaultContext.colorDepth>1)?hn.richFormat:hn.textFormat}getUsageByRegistration(e,t){let i=this.registrations.get(e);if(typeof i>"u")throw new Error("Assertion failed: Unregistered command");return this.getUsageByIndex(i.index,t)}getUsageByIndex(e,t){return this.builder.getBuilderByIndex(e).usage(t)}};TA.defaultContext={stdin:process.stdin,stdout:process.stdout,stderr:process.stderr,colorDepth:"getColorDepth"in EG.default.WriteStream.prototype?EG.default.WriteStream.prototype.getColorDepth():Ame()};var yG;function lme(r){let e=yG;if(typeof e>"u"){if(r.stdout===process.stdout&&r.stderr===process.stderr)return wG;let{AsyncLocalStorage:t}=J("async_hooks");e=yG=new t;let i=process.stdout._write;process.stdout._write=function(s,o,a){let l=e.getStore();return typeof l>"u"?i.call(this,s,o,a):l.stdout.write(s,o,a)};let n=process.stderr._write;process.stderr._write=function(s,o,a){let l=e.getStore();return typeof l>"u"?n.call(this,s,o,a):l.stderr.write(s,o,a)}}return t=>e.run(r,t)}function wG(r){return r()}Wv.Cli=TA});var QG=y(zv=>{"use strict";Object.defineProperty(zv,"__esModule",{value:!0});var cme=Bc(),Ay=class extends cme.Command{async execute(){this.context.stdout.write(`${JSON.stringify(this.cli.definitions(),null,2)} -`)}};Ay.paths=[["--clipanion=definitions"]];zv.DefinitionsCommand=Ay});var bG=y(Vv=>{"use strict";Object.defineProperty(Vv,"__esModule",{value:!0});var ume=Bc(),ly=class extends ume.Command{async execute(){this.context.stdout.write(this.cli.usage())}};ly.paths=[["-h"],["--help"]];Vv.HelpCommand=ly});var SG=y(Xv=>{"use strict";Object.defineProperty(Xv,"__esModule",{value:!0});var gme=Bc(),cy=class extends gme.Command{async execute(){var e;this.context.stdout.write(`${(e=this.cli.binaryVersion)!==null&&e!==void 0?e:""} -`)}};cy.paths=[["-v"],["--version"]];Xv.VersionCommand=cy});var vG=y(Dd=>{"use strict";Object.defineProperty(Dd,"__esModule",{value:!0});var fme=QG(),hme=bG(),pme=SG();Dd.DefinitionsCommand=fme.DefinitionsCommand;Dd.HelpCommand=hme.HelpCommand;Dd.VersionCommand=pme.VersionCommand});var PG=y(_v=>{"use strict";Object.defineProperty(_v,"__esModule",{value:!0});var xG=Sa();function dme(r,e,t){let[i,n]=xG.rerouteArguments(e,t!=null?t:{}),{arity:s=1}=n,o=r.split(","),a=new Set(o);return xG.makeCommandOption({definition(l){l.addOption({names:o,arity:s,hidden:n==null?void 0:n.hidden,description:n==null?void 0:n.description,required:n.required})},transformer(l,c,u){let g=typeof i<"u"?[...i]:void 0;for(let{name:f,value:h}of u.options)!a.has(f)||(g=g!=null?g:[],g.push(h));return g}})}_v.Array=dme});var kG=y(Zv=>{"use strict";Object.defineProperty(Zv,"__esModule",{value:!0});var DG=Sa();function Cme(r,e,t){let[i,n]=DG.rerouteArguments(e,t!=null?t:{}),s=r.split(","),o=new Set(s);return DG.makeCommandOption({definition(a){a.addOption({names:s,allowBinding:!1,arity:0,hidden:n.hidden,description:n.description,required:n.required})},transformer(a,l,c){let u=i;for(let{name:g,value:f}of c.options)!o.has(g)||(u=f);return u}})}Zv.Boolean=Cme});var FG=y($v=>{"use strict";Object.defineProperty($v,"__esModule",{value:!0});var RG=Sa();function mme(r,e,t){let[i,n]=RG.rerouteArguments(e,t!=null?t:{}),s=r.split(","),o=new Set(s);return RG.makeCommandOption({definition(a){a.addOption({names:s,allowBinding:!1,arity:0,hidden:n.hidden,description:n.description,required:n.required})},transformer(a,l,c){let u=i;for(let{name:g,value:f}of c.options)!o.has(g)||(u!=null||(u=0),f?u+=1:u=0);return u}})}$v.Counter=mme});var NG=y(ex=>{"use strict";Object.defineProperty(ex,"__esModule",{value:!0});var Eme=Sa();function Ime(r={}){return Eme.makeCommandOption({definition(e,t){var i;e.addProxy({name:(i=r.name)!==null&&i!==void 0?i:t,required:r.required})},transformer(e,t,i){return i.positionals.map(({value:n})=>n)}})}ex.Proxy=Ime});var LG=y(tx=>{"use strict";Object.defineProperty(tx,"__esModule",{value:!0});var yme=Sa(),wme=ay();function Bme(r={}){return yme.makeCommandOption({definition(e,t){var i;e.addRest({name:(i=r.name)!==null&&i!==void 0?i:t,required:r.required})},transformer(e,t,i){let n=o=>{let a=i.positionals[o];return a.extra===wme.NoLimits||a.extra===!1&&oo)}})}tx.Rest=Bme});var TG=y(rx=>{"use strict";Object.defineProperty(rx,"__esModule",{value:!0});var kd=Sa(),Qme=ay();function bme(r,e,t){let[i,n]=kd.rerouteArguments(e,t!=null?t:{}),{arity:s=1}=n,o=r.split(","),a=new Set(o);return kd.makeCommandOption({definition(l){l.addOption({names:o,arity:n.tolerateBoolean?0:s,hidden:n.hidden,description:n.description,required:n.required})},transformer(l,c,u){let g,f=i;for(let{name:h,value:p}of u.options)!a.has(h)||(g=h,f=p);return typeof f=="string"?kd.applyValidator(g!=null?g:c,f,n.validator):f}})}function Sme(r={}){let{required:e=!0}=r;return kd.makeCommandOption({definition(t,i){var n;t.addPositional({name:(n=r.name)!==null&&n!==void 0?n:i,required:r.required})},transformer(t,i,n){var s;for(let o=0;o{"use strict";Object.defineProperty(pn,"__esModule",{value:!0});var Af=Sa(),xme=PG(),Pme=kG(),Dme=FG(),kme=NG(),Rme=LG(),Fme=TG();pn.applyValidator=Af.applyValidator;pn.cleanValidationError=Af.cleanValidationError;pn.formatError=Af.formatError;pn.isOptionSymbol=Af.isOptionSymbol;pn.makeCommandOption=Af.makeCommandOption;pn.rerouteArguments=Af.rerouteArguments;pn.Array=xme.Array;pn.Boolean=Pme.Boolean;pn.Counter=Dme.Counter;pn.Proxy=kme.Proxy;pn.Rest=Rme.Rest;pn.String=Fme.String});var Xe=y(OA=>{"use strict";Object.defineProperty(OA,"__esModule",{value:!0});var Nme=ty(),Lme=Bc(),Tme=Gv(),Ome=BG(),Mme=vG(),Kme=OG();OA.UsageError=Nme.UsageError;OA.Command=Lme.Command;OA.formatMarkdownish=Tme.formatMarkdownish;OA.Cli=Ome.Cli;OA.Builtins=Mme;OA.Option=Kme});var KG=y((M$e,MG)=>{"use strict";MG.exports=(r,...e)=>new Promise(t=>{t(r(...e))})});var lf=y((K$e,ix)=>{"use strict";var Ume=KG(),UG=r=>{if(r<1)throw new TypeError("Expected `concurrency` to be a number from 1 and up");let e=[],t=0,i=()=>{t--,e.length>0&&e.shift()()},n=(a,l,...c)=>{t++;let u=Ume(a,...c);l(u),u.then(i,i)},s=(a,l,...c)=>{tnew Promise(c=>s(a,c,...l));return Object.defineProperties(o,{activeCount:{get:()=>t},pendingCount:{get:()=>e.length}}),o};ix.exports=UG;ix.exports.default=UG});var Rd=y((H$e,HG)=>{var Hme="2.0.0",Gme=Number.MAX_SAFE_INTEGER||9007199254740991,Yme=16;HG.exports={SEMVER_SPEC_VERSION:Hme,MAX_LENGTH:256,MAX_SAFE_INTEGER:Gme,MAX_SAFE_COMPONENT_LENGTH:Yme}});var Fd=y((G$e,GG)=>{var jme=typeof process=="object"&&process.env&&process.env.NODE_DEBUG&&/\bsemver\b/i.test(process.env.NODE_DEBUG)?(...r)=>console.error("SEMVER",...r):()=>{};GG.exports=jme});var bc=y((KA,YG)=>{var{MAX_SAFE_COMPONENT_LENGTH:nx}=Rd(),qme=Fd();KA=YG.exports={};var Jme=KA.re=[],_e=KA.src=[],Ze=KA.t={},Wme=0,St=(r,e,t)=>{let i=Wme++;qme(i,e),Ze[r]=i,_e[i]=e,Jme[i]=new RegExp(e,t?"g":void 0)};St("NUMERICIDENTIFIER","0|[1-9]\\d*");St("NUMERICIDENTIFIERLOOSE","[0-9]+");St("NONNUMERICIDENTIFIER","\\d*[a-zA-Z-][a-zA-Z0-9-]*");St("MAINVERSION",`(${_e[Ze.NUMERICIDENTIFIER]})\\.(${_e[Ze.NUMERICIDENTIFIER]})\\.(${_e[Ze.NUMERICIDENTIFIER]})`);St("MAINVERSIONLOOSE",`(${_e[Ze.NUMERICIDENTIFIERLOOSE]})\\.(${_e[Ze.NUMERICIDENTIFIERLOOSE]})\\.(${_e[Ze.NUMERICIDENTIFIERLOOSE]})`);St("PRERELEASEIDENTIFIER",`(?:${_e[Ze.NUMERICIDENTIFIER]}|${_e[Ze.NONNUMERICIDENTIFIER]})`);St("PRERELEASEIDENTIFIERLOOSE",`(?:${_e[Ze.NUMERICIDENTIFIERLOOSE]}|${_e[Ze.NONNUMERICIDENTIFIER]})`);St("PRERELEASE",`(?:-(${_e[Ze.PRERELEASEIDENTIFIER]}(?:\\.${_e[Ze.PRERELEASEIDENTIFIER]})*))`);St("PRERELEASELOOSE",`(?:-?(${_e[Ze.PRERELEASEIDENTIFIERLOOSE]}(?:\\.${_e[Ze.PRERELEASEIDENTIFIERLOOSE]})*))`);St("BUILDIDENTIFIER","[0-9A-Za-z-]+");St("BUILD",`(?:\\+(${_e[Ze.BUILDIDENTIFIER]}(?:\\.${_e[Ze.BUILDIDENTIFIER]})*))`);St("FULLPLAIN",`v?${_e[Ze.MAINVERSION]}${_e[Ze.PRERELEASE]}?${_e[Ze.BUILD]}?`);St("FULL",`^${_e[Ze.FULLPLAIN]}$`);St("LOOSEPLAIN",`[v=\\s]*${_e[Ze.MAINVERSIONLOOSE]}${_e[Ze.PRERELEASELOOSE]}?${_e[Ze.BUILD]}?`);St("LOOSE",`^${_e[Ze.LOOSEPLAIN]}$`);St("GTLT","((?:<|>)?=?)");St("XRANGEIDENTIFIERLOOSE",`${_e[Ze.NUMERICIDENTIFIERLOOSE]}|x|X|\\*`);St("XRANGEIDENTIFIER",`${_e[Ze.NUMERICIDENTIFIER]}|x|X|\\*`);St("XRANGEPLAIN",`[v=\\s]*(${_e[Ze.XRANGEIDENTIFIER]})(?:\\.(${_e[Ze.XRANGEIDENTIFIER]})(?:\\.(${_e[Ze.XRANGEIDENTIFIER]})(?:${_e[Ze.PRERELEASE]})?${_e[Ze.BUILD]}?)?)?`);St("XRANGEPLAINLOOSE",`[v=\\s]*(${_e[Ze.XRANGEIDENTIFIERLOOSE]})(?:\\.(${_e[Ze.XRANGEIDENTIFIERLOOSE]})(?:\\.(${_e[Ze.XRANGEIDENTIFIERLOOSE]})(?:${_e[Ze.PRERELEASELOOSE]})?${_e[Ze.BUILD]}?)?)?`);St("XRANGE",`^${_e[Ze.GTLT]}\\s*${_e[Ze.XRANGEPLAIN]}$`);St("XRANGELOOSE",`^${_e[Ze.GTLT]}\\s*${_e[Ze.XRANGEPLAINLOOSE]}$`);St("COERCE",`(^|[^\\d])(\\d{1,${nx}})(?:\\.(\\d{1,${nx}}))?(?:\\.(\\d{1,${nx}}))?(?:$|[^\\d])`);St("COERCERTL",_e[Ze.COERCE],!0);St("LONETILDE","(?:~>?)");St("TILDETRIM",`(\\s*)${_e[Ze.LONETILDE]}\\s+`,!0);KA.tildeTrimReplace="$1~";St("TILDE",`^${_e[Ze.LONETILDE]}${_e[Ze.XRANGEPLAIN]}$`);St("TILDELOOSE",`^${_e[Ze.LONETILDE]}${_e[Ze.XRANGEPLAINLOOSE]}$`);St("LONECARET","(?:\\^)");St("CARETTRIM",`(\\s*)${_e[Ze.LONECARET]}\\s+`,!0);KA.caretTrimReplace="$1^";St("CARET",`^${_e[Ze.LONECARET]}${_e[Ze.XRANGEPLAIN]}$`);St("CARETLOOSE",`^${_e[Ze.LONECARET]}${_e[Ze.XRANGEPLAINLOOSE]}$`);St("COMPARATORLOOSE",`^${_e[Ze.GTLT]}\\s*(${_e[Ze.LOOSEPLAIN]})$|^$`);St("COMPARATOR",`^${_e[Ze.GTLT]}\\s*(${_e[Ze.FULLPLAIN]})$|^$`);St("COMPARATORTRIM",`(\\s*)${_e[Ze.GTLT]}\\s*(${_e[Ze.LOOSEPLAIN]}|${_e[Ze.XRANGEPLAIN]})`,!0);KA.comparatorTrimReplace="$1$2$3";St("HYPHENRANGE",`^\\s*(${_e[Ze.XRANGEPLAIN]})\\s+-\\s+(${_e[Ze.XRANGEPLAIN]})\\s*$`);St("HYPHENRANGELOOSE",`^\\s*(${_e[Ze.XRANGEPLAINLOOSE]})\\s+-\\s+(${_e[Ze.XRANGEPLAINLOOSE]})\\s*$`);St("STAR","(<|>)?=?\\s*\\*");St("GTE0","^\\s*>=\\s*0.0.0\\s*$");St("GTE0PRE","^\\s*>=\\s*0.0.0-0\\s*$")});var Nd=y((Y$e,jG)=>{var zme=["includePrerelease","loose","rtl"],Vme=r=>r?typeof r!="object"?{loose:!0}:zme.filter(e=>r[e]).reduce((e,t)=>(e[t]=!0,e),{}):{};jG.exports=Vme});var gy=y((j$e,WG)=>{var qG=/^[0-9]+$/,JG=(r,e)=>{let t=qG.test(r),i=qG.test(e);return t&&i&&(r=+r,e=+e),r===e?0:t&&!i?-1:i&&!t?1:rJG(e,r);WG.exports={compareIdentifiers:JG,rcompareIdentifiers:Xme}});var Oi=y((q$e,_G)=>{var fy=Fd(),{MAX_LENGTH:zG,MAX_SAFE_INTEGER:hy}=Rd(),{re:VG,t:XG}=bc(),_me=Nd(),{compareIdentifiers:Ld}=gy(),Un=class{constructor(e,t){if(t=_me(t),e instanceof Un){if(e.loose===!!t.loose&&e.includePrerelease===!!t.includePrerelease)return e;e=e.version}else if(typeof e!="string")throw new TypeError(`Invalid Version: ${e}`);if(e.length>zG)throw new TypeError(`version is longer than ${zG} characters`);fy("SemVer",e,t),this.options=t,this.loose=!!t.loose,this.includePrerelease=!!t.includePrerelease;let i=e.trim().match(t.loose?VG[XG.LOOSE]:VG[XG.FULL]);if(!i)throw new TypeError(`Invalid Version: ${e}`);if(this.raw=e,this.major=+i[1],this.minor=+i[2],this.patch=+i[3],this.major>hy||this.major<0)throw new TypeError("Invalid major version");if(this.minor>hy||this.minor<0)throw new TypeError("Invalid minor version");if(this.patch>hy||this.patch<0)throw new TypeError("Invalid patch version");i[4]?this.prerelease=i[4].split(".").map(n=>{if(/^[0-9]+$/.test(n)){let s=+n;if(s>=0&&s=0;)typeof this.prerelease[i]=="number"&&(this.prerelease[i]++,i=-2);i===-1&&this.prerelease.push(0)}t&&(this.prerelease[0]===t?isNaN(this.prerelease[1])&&(this.prerelease=[t,0]):this.prerelease=[t,0]);break;default:throw new Error(`invalid increment argument: ${e}`)}return this.format(),this.raw=this.version,this}};_G.exports=Un});var Sc=y((J$e,tY)=>{var{MAX_LENGTH:Zme}=Rd(),{re:ZG,t:$G}=bc(),eY=Oi(),$me=Nd(),eEe=(r,e)=>{if(e=$me(e),r instanceof eY)return r;if(typeof r!="string"||r.length>Zme||!(e.loose?ZG[$G.LOOSE]:ZG[$G.FULL]).test(r))return null;try{return new eY(r,e)}catch{return null}};tY.exports=eEe});var iY=y((W$e,rY)=>{var tEe=Sc(),rEe=(r,e)=>{let t=tEe(r,e);return t?t.version:null};rY.exports=rEe});var sY=y((z$e,nY)=>{var iEe=Sc(),nEe=(r,e)=>{let t=iEe(r.trim().replace(/^[=v]+/,""),e);return t?t.version:null};nY.exports=nEe});var aY=y((V$e,oY)=>{var sEe=Oi(),oEe=(r,e,t,i)=>{typeof t=="string"&&(i=t,t=void 0);try{return new sEe(r,t).inc(e,i).version}catch{return null}};oY.exports=oEe});var os=y((X$e,lY)=>{var AY=Oi(),aEe=(r,e,t)=>new AY(r,t).compare(new AY(e,t));lY.exports=aEe});var py=y((_$e,cY)=>{var AEe=os(),lEe=(r,e,t)=>AEe(r,e,t)===0;cY.exports=lEe});var fY=y((Z$e,gY)=>{var uY=Sc(),cEe=py(),uEe=(r,e)=>{if(cEe(r,e))return null;{let t=uY(r),i=uY(e),n=t.prerelease.length||i.prerelease.length,s=n?"pre":"",o=n?"prerelease":"";for(let a in t)if((a==="major"||a==="minor"||a==="patch")&&t[a]!==i[a])return s+a;return o}};gY.exports=uEe});var pY=y(($$e,hY)=>{var gEe=Oi(),fEe=(r,e)=>new gEe(r,e).major;hY.exports=fEe});var CY=y((eet,dY)=>{var hEe=Oi(),pEe=(r,e)=>new hEe(r,e).minor;dY.exports=pEe});var EY=y((tet,mY)=>{var dEe=Oi(),CEe=(r,e)=>new dEe(r,e).patch;mY.exports=CEe});var yY=y((ret,IY)=>{var mEe=Sc(),EEe=(r,e)=>{let t=mEe(r,e);return t&&t.prerelease.length?t.prerelease:null};IY.exports=EEe});var BY=y((iet,wY)=>{var IEe=os(),yEe=(r,e,t)=>IEe(e,r,t);wY.exports=yEe});var bY=y((net,QY)=>{var wEe=os(),BEe=(r,e)=>wEe(r,e,!0);QY.exports=BEe});var dy=y((set,vY)=>{var SY=Oi(),QEe=(r,e,t)=>{let i=new SY(r,t),n=new SY(e,t);return i.compare(n)||i.compareBuild(n)};vY.exports=QEe});var PY=y((oet,xY)=>{var bEe=dy(),SEe=(r,e)=>r.sort((t,i)=>bEe(t,i,e));xY.exports=SEe});var kY=y((aet,DY)=>{var vEe=dy(),xEe=(r,e)=>r.sort((t,i)=>vEe(i,t,e));DY.exports=xEe});var Td=y((Aet,RY)=>{var PEe=os(),DEe=(r,e,t)=>PEe(r,e,t)>0;RY.exports=DEe});var Cy=y((cet,FY)=>{var kEe=os(),REe=(r,e,t)=>kEe(r,e,t)<0;FY.exports=REe});var sx=y((uet,NY)=>{var FEe=os(),NEe=(r,e,t)=>FEe(r,e,t)!==0;NY.exports=NEe});var my=y((get,LY)=>{var LEe=os(),TEe=(r,e,t)=>LEe(r,e,t)>=0;LY.exports=TEe});var Ey=y((fet,TY)=>{var OEe=os(),MEe=(r,e,t)=>OEe(r,e,t)<=0;TY.exports=MEe});var ox=y((het,OY)=>{var KEe=py(),UEe=sx(),HEe=Td(),GEe=my(),YEe=Cy(),jEe=Ey(),qEe=(r,e,t,i)=>{switch(e){case"===":return typeof r=="object"&&(r=r.version),typeof t=="object"&&(t=t.version),r===t;case"!==":return typeof r=="object"&&(r=r.version),typeof t=="object"&&(t=t.version),r!==t;case"":case"=":case"==":return KEe(r,t,i);case"!=":return UEe(r,t,i);case">":return HEe(r,t,i);case">=":return GEe(r,t,i);case"<":return YEe(r,t,i);case"<=":return jEe(r,t,i);default:throw new TypeError(`Invalid operator: ${e}`)}};OY.exports=qEe});var KY=y((pet,MY)=>{var JEe=Oi(),WEe=Sc(),{re:Iy,t:yy}=bc(),zEe=(r,e)=>{if(r instanceof JEe)return r;if(typeof r=="number"&&(r=String(r)),typeof r!="string")return null;e=e||{};let t=null;if(!e.rtl)t=r.match(Iy[yy.COERCE]);else{let i;for(;(i=Iy[yy.COERCERTL].exec(r))&&(!t||t.index+t[0].length!==r.length);)(!t||i.index+i[0].length!==t.index+t[0].length)&&(t=i),Iy[yy.COERCERTL].lastIndex=i.index+i[1].length+i[2].length;Iy[yy.COERCERTL].lastIndex=-1}return t===null?null:WEe(`${t[2]}.${t[3]||"0"}.${t[4]||"0"}`,e)};MY.exports=zEe});var HY=y((det,UY)=>{"use strict";UY.exports=function(r){r.prototype[Symbol.iterator]=function*(){for(let e=this.head;e;e=e.next)yield e.value}}});var Od=y((Cet,GY)=>{"use strict";GY.exports=Ht;Ht.Node=vc;Ht.create=Ht;function Ht(r){var e=this;if(e instanceof Ht||(e=new Ht),e.tail=null,e.head=null,e.length=0,r&&typeof r.forEach=="function")r.forEach(function(n){e.push(n)});else if(arguments.length>0)for(var t=0,i=arguments.length;t1)t=e;else if(this.head)i=this.head.next,t=this.head.value;else throw new TypeError("Reduce of empty list with no initial value");for(var n=0;i!==null;n++)t=r(t,i.value,n),i=i.next;return t};Ht.prototype.reduceReverse=function(r,e){var t,i=this.tail;if(arguments.length>1)t=e;else if(this.tail)i=this.tail.prev,t=this.tail.value;else throw new TypeError("Reduce of empty list with no initial value");for(var n=this.length-1;i!==null;n--)t=r(t,i.value,n),i=i.prev;return t};Ht.prototype.toArray=function(){for(var r=new Array(this.length),e=0,t=this.head;t!==null;e++)r[e]=t.value,t=t.next;return r};Ht.prototype.toArrayReverse=function(){for(var r=new Array(this.length),e=0,t=this.tail;t!==null;e++)r[e]=t.value,t=t.prev;return r};Ht.prototype.slice=function(r,e){e=e||this.length,e<0&&(e+=this.length),r=r||0,r<0&&(r+=this.length);var t=new Ht;if(ethis.length&&(e=this.length);for(var i=0,n=this.head;n!==null&&ithis.length&&(e=this.length);for(var i=this.length,n=this.tail;n!==null&&i>e;i--)n=n.prev;for(;n!==null&&i>r;i--,n=n.prev)t.push(n.value);return t};Ht.prototype.splice=function(r,e,...t){r>this.length&&(r=this.length-1),r<0&&(r=this.length+r);for(var i=0,n=this.head;n!==null&&i{"use strict";var ZEe=Od(),xc=Symbol("max"),xa=Symbol("length"),cf=Symbol("lengthCalculator"),Kd=Symbol("allowStale"),Pc=Symbol("maxAge"),va=Symbol("dispose"),YY=Symbol("noDisposeOnSet"),Ii=Symbol("lruList"),zs=Symbol("cache"),qY=Symbol("updateAgeOnGet"),ax=()=>1,lx=class{constructor(e){if(typeof e=="number"&&(e={max:e}),e||(e={}),e.max&&(typeof e.max!="number"||e.max<0))throw new TypeError("max must be a non-negative number");let t=this[xc]=e.max||1/0,i=e.length||ax;if(this[cf]=typeof i!="function"?ax:i,this[Kd]=e.stale||!1,e.maxAge&&typeof e.maxAge!="number")throw new TypeError("maxAge must be a number");this[Pc]=e.maxAge||0,this[va]=e.dispose,this[YY]=e.noDisposeOnSet||!1,this[qY]=e.updateAgeOnGet||!1,this.reset()}set max(e){if(typeof e!="number"||e<0)throw new TypeError("max must be a non-negative number");this[xc]=e||1/0,Md(this)}get max(){return this[xc]}set allowStale(e){this[Kd]=!!e}get allowStale(){return this[Kd]}set maxAge(e){if(typeof e!="number")throw new TypeError("maxAge must be a non-negative number");this[Pc]=e,Md(this)}get maxAge(){return this[Pc]}set lengthCalculator(e){typeof e!="function"&&(e=ax),e!==this[cf]&&(this[cf]=e,this[xa]=0,this[Ii].forEach(t=>{t.length=this[cf](t.value,t.key),this[xa]+=t.length})),Md(this)}get lengthCalculator(){return this[cf]}get length(){return this[xa]}get itemCount(){return this[Ii].length}rforEach(e,t){t=t||this;for(let i=this[Ii].tail;i!==null;){let n=i.prev;jY(this,e,i,t),i=n}}forEach(e,t){t=t||this;for(let i=this[Ii].head;i!==null;){let n=i.next;jY(this,e,i,t),i=n}}keys(){return this[Ii].toArray().map(e=>e.key)}values(){return this[Ii].toArray().map(e=>e.value)}reset(){this[va]&&this[Ii]&&this[Ii].length&&this[Ii].forEach(e=>this[va](e.key,e.value)),this[zs]=new Map,this[Ii]=new ZEe,this[xa]=0}dump(){return this[Ii].map(e=>wy(this,e)?!1:{k:e.key,v:e.value,e:e.now+(e.maxAge||0)}).toArray().filter(e=>e)}dumpLru(){return this[Ii]}set(e,t,i){if(i=i||this[Pc],i&&typeof i!="number")throw new TypeError("maxAge must be a number");let n=i?Date.now():0,s=this[cf](t,e);if(this[zs].has(e)){if(s>this[xc])return uf(this,this[zs].get(e)),!1;let l=this[zs].get(e).value;return this[va]&&(this[YY]||this[va](e,l.value)),l.now=n,l.maxAge=i,l.value=t,this[xa]+=s-l.length,l.length=s,this.get(e),Md(this),!0}let o=new cx(e,t,s,n,i);return o.length>this[xc]?(this[va]&&this[va](e,t),!1):(this[xa]+=o.length,this[Ii].unshift(o),this[zs].set(e,this[Ii].head),Md(this),!0)}has(e){if(!this[zs].has(e))return!1;let t=this[zs].get(e).value;return!wy(this,t)}get(e){return Ax(this,e,!0)}peek(e){return Ax(this,e,!1)}pop(){let e=this[Ii].tail;return e?(uf(this,e),e.value):null}del(e){uf(this,this[zs].get(e))}load(e){this.reset();let t=Date.now();for(let i=e.length-1;i>=0;i--){let n=e[i],s=n.e||0;if(s===0)this.set(n.k,n.v);else{let o=s-t;o>0&&this.set(n.k,n.v,o)}}}prune(){this[zs].forEach((e,t)=>Ax(this,t,!1))}},Ax=(r,e,t)=>{let i=r[zs].get(e);if(i){let n=i.value;if(wy(r,n)){if(uf(r,i),!r[Kd])return}else t&&(r[qY]&&(i.value.now=Date.now()),r[Ii].unshiftNode(i));return n.value}},wy=(r,e)=>{if(!e||!e.maxAge&&!r[Pc])return!1;let t=Date.now()-e.now;return e.maxAge?t>e.maxAge:r[Pc]&&t>r[Pc]},Md=r=>{if(r[xa]>r[xc])for(let e=r[Ii].tail;r[xa]>r[xc]&&e!==null;){let t=e.prev;uf(r,e),e=t}},uf=(r,e)=>{if(e){let t=e.value;r[va]&&r[va](t.key,t.value),r[xa]-=t.length,r[zs].delete(t.key),r[Ii].removeNode(e)}},cx=class{constructor(e,t,i,n,s){this.key=e,this.value=t,this.length=i,this.now=n,this.maxAge=s||0}},jY=(r,e,t,i)=>{let n=t.value;wy(r,n)&&(uf(r,t),r[Kd]||(n=void 0)),n&&e.call(i,n.value,n.key,r)};JY.exports=lx});var as=y((Eet,_Y)=>{var Dc=class{constructor(e,t){if(t=eIe(t),e instanceof Dc)return e.loose===!!t.loose&&e.includePrerelease===!!t.includePrerelease?e:new Dc(e.raw,t);if(e instanceof ux)return this.raw=e.value,this.set=[[e]],this.format(),this;if(this.options=t,this.loose=!!t.loose,this.includePrerelease=!!t.includePrerelease,this.raw=e,this.set=e.split(/\s*\|\|\s*/).map(i=>this.parseRange(i.trim())).filter(i=>i.length),!this.set.length)throw new TypeError(`Invalid SemVer Range: ${e}`);if(this.set.length>1){let i=this.set[0];if(this.set=this.set.filter(n=>!VY(n[0])),this.set.length===0)this.set=[i];else if(this.set.length>1){for(let n of this.set)if(n.length===1&&sIe(n[0])){this.set=[n];break}}}this.format()}format(){return this.range=this.set.map(e=>e.join(" ").trim()).join("||").trim(),this.range}toString(){return this.range}parseRange(e){e=e.trim();let i=`parseRange:${Object.keys(this.options).join(",")}:${e}`,n=zY.get(i);if(n)return n;let s=this.options.loose,o=s?Mi[bi.HYPHENRANGELOOSE]:Mi[bi.HYPHENRANGE];e=e.replace(o,pIe(this.options.includePrerelease)),jr("hyphen replace",e),e=e.replace(Mi[bi.COMPARATORTRIM],rIe),jr("comparator trim",e,Mi[bi.COMPARATORTRIM]),e=e.replace(Mi[bi.TILDETRIM],iIe),e=e.replace(Mi[bi.CARETTRIM],nIe),e=e.split(/\s+/).join(" ");let a=s?Mi[bi.COMPARATORLOOSE]:Mi[bi.COMPARATOR],l=e.split(" ").map(f=>oIe(f,this.options)).join(" ").split(/\s+/).map(f=>hIe(f,this.options)).filter(this.options.loose?f=>!!f.match(a):()=>!0).map(f=>new ux(f,this.options)),c=l.length,u=new Map;for(let f of l){if(VY(f))return[f];u.set(f.value,f)}u.size>1&&u.has("")&&u.delete("");let g=[...u.values()];return zY.set(i,g),g}intersects(e,t){if(!(e instanceof Dc))throw new TypeError("a Range is required");return this.set.some(i=>XY(i,t)&&e.set.some(n=>XY(n,t)&&i.every(s=>n.every(o=>s.intersects(o,t)))))}test(e){if(!e)return!1;if(typeof e=="string")try{e=new tIe(e,this.options)}catch{return!1}for(let t=0;tr.value==="<0.0.0-0",sIe=r=>r.value==="",XY=(r,e)=>{let t=!0,i=r.slice(),n=i.pop();for(;t&&i.length;)t=i.every(s=>n.intersects(s,e)),n=i.pop();return t},oIe=(r,e)=>(jr("comp",r,e),r=lIe(r,e),jr("caret",r),r=aIe(r,e),jr("tildes",r),r=uIe(r,e),jr("xrange",r),r=fIe(r,e),jr("stars",r),r),Xi=r=>!r||r.toLowerCase()==="x"||r==="*",aIe=(r,e)=>r.trim().split(/\s+/).map(t=>AIe(t,e)).join(" "),AIe=(r,e)=>{let t=e.loose?Mi[bi.TILDELOOSE]:Mi[bi.TILDE];return r.replace(t,(i,n,s,o,a)=>{jr("tilde",r,i,n,s,o,a);let l;return Xi(n)?l="":Xi(s)?l=`>=${n}.0.0 <${+n+1}.0.0-0`:Xi(o)?l=`>=${n}.${s}.0 <${n}.${+s+1}.0-0`:a?(jr("replaceTilde pr",a),l=`>=${n}.${s}.${o}-${a} <${n}.${+s+1}.0-0`):l=`>=${n}.${s}.${o} <${n}.${+s+1}.0-0`,jr("tilde return",l),l})},lIe=(r,e)=>r.trim().split(/\s+/).map(t=>cIe(t,e)).join(" "),cIe=(r,e)=>{jr("caret",r,e);let t=e.loose?Mi[bi.CARETLOOSE]:Mi[bi.CARET],i=e.includePrerelease?"-0":"";return r.replace(t,(n,s,o,a,l)=>{jr("caret",r,n,s,o,a,l);let c;return Xi(s)?c="":Xi(o)?c=`>=${s}.0.0${i} <${+s+1}.0.0-0`:Xi(a)?s==="0"?c=`>=${s}.${o}.0${i} <${s}.${+o+1}.0-0`:c=`>=${s}.${o}.0${i} <${+s+1}.0.0-0`:l?(jr("replaceCaret pr",l),s==="0"?o==="0"?c=`>=${s}.${o}.${a}-${l} <${s}.${o}.${+a+1}-0`:c=`>=${s}.${o}.${a}-${l} <${s}.${+o+1}.0-0`:c=`>=${s}.${o}.${a}-${l} <${+s+1}.0.0-0`):(jr("no pr"),s==="0"?o==="0"?c=`>=${s}.${o}.${a}${i} <${s}.${o}.${+a+1}-0`:c=`>=${s}.${o}.${a}${i} <${s}.${+o+1}.0-0`:c=`>=${s}.${o}.${a} <${+s+1}.0.0-0`),jr("caret return",c),c})},uIe=(r,e)=>(jr("replaceXRanges",r,e),r.split(/\s+/).map(t=>gIe(t,e)).join(" ")),gIe=(r,e)=>{r=r.trim();let t=e.loose?Mi[bi.XRANGELOOSE]:Mi[bi.XRANGE];return r.replace(t,(i,n,s,o,a,l)=>{jr("xRange",r,i,n,s,o,a,l);let c=Xi(s),u=c||Xi(o),g=u||Xi(a),f=g;return n==="="&&f&&(n=""),l=e.includePrerelease?"-0":"",c?n===">"||n==="<"?i="<0.0.0-0":i="*":n&&f?(u&&(o=0),a=0,n===">"?(n=">=",u?(s=+s+1,o=0,a=0):(o=+o+1,a=0)):n==="<="&&(n="<",u?s=+s+1:o=+o+1),n==="<"&&(l="-0"),i=`${n+s}.${o}.${a}${l}`):u?i=`>=${s}.0.0${l} <${+s+1}.0.0-0`:g&&(i=`>=${s}.${o}.0${l} <${s}.${+o+1}.0-0`),jr("xRange return",i),i})},fIe=(r,e)=>(jr("replaceStars",r,e),r.trim().replace(Mi[bi.STAR],"")),hIe=(r,e)=>(jr("replaceGTE0",r,e),r.trim().replace(Mi[e.includePrerelease?bi.GTE0PRE:bi.GTE0],"")),pIe=r=>(e,t,i,n,s,o,a,l,c,u,g,f,h)=>(Xi(i)?t="":Xi(n)?t=`>=${i}.0.0${r?"-0":""}`:Xi(s)?t=`>=${i}.${n}.0${r?"-0":""}`:o?t=`>=${t}`:t=`>=${t}${r?"-0":""}`,Xi(c)?l="":Xi(u)?l=`<${+c+1}.0.0-0`:Xi(g)?l=`<${c}.${+u+1}.0-0`:f?l=`<=${c}.${u}.${g}-${f}`:r?l=`<${c}.${u}.${+g+1}-0`:l=`<=${l}`,`${t} ${l}`.trim()),dIe=(r,e,t)=>{for(let i=0;i0){let n=r[i].semver;if(n.major===e.major&&n.minor===e.minor&&n.patch===e.patch)return!0}return!1}return!0}});var Ud=y((Iet,rj)=>{var Hd=Symbol("SemVer ANY"),gf=class{static get ANY(){return Hd}constructor(e,t){if(t=CIe(t),e instanceof gf){if(e.loose===!!t.loose)return e;e=e.value}fx("comparator",e,t),this.options=t,this.loose=!!t.loose,this.parse(e),this.semver===Hd?this.value="":this.value=this.operator+this.semver.version,fx("comp",this)}parse(e){let t=this.options.loose?ZY[$Y.COMPARATORLOOSE]:ZY[$Y.COMPARATOR],i=e.match(t);if(!i)throw new TypeError(`Invalid comparator: ${e}`);this.operator=i[1]!==void 0?i[1]:"",this.operator==="="&&(this.operator=""),i[2]?this.semver=new ej(i[2],this.options.loose):this.semver=Hd}toString(){return this.value}test(e){if(fx("Comparator.test",e,this.options.loose),this.semver===Hd||e===Hd)return!0;if(typeof e=="string")try{e=new ej(e,this.options)}catch{return!1}return gx(e,this.operator,this.semver,this.options)}intersects(e,t){if(!(e instanceof gf))throw new TypeError("a Comparator is required");if((!t||typeof t!="object")&&(t={loose:!!t,includePrerelease:!1}),this.operator==="")return this.value===""?!0:new tj(e.value,t).test(this.value);if(e.operator==="")return e.value===""?!0:new tj(this.value,t).test(e.semver);let i=(this.operator===">="||this.operator===">")&&(e.operator===">="||e.operator===">"),n=(this.operator==="<="||this.operator==="<")&&(e.operator==="<="||e.operator==="<"),s=this.semver.version===e.semver.version,o=(this.operator===">="||this.operator==="<=")&&(e.operator===">="||e.operator==="<="),a=gx(this.semver,"<",e.semver,t)&&(this.operator===">="||this.operator===">")&&(e.operator==="<="||e.operator==="<"),l=gx(this.semver,">",e.semver,t)&&(this.operator==="<="||this.operator==="<")&&(e.operator===">="||e.operator===">");return i||n||s&&o||a||l}};rj.exports=gf;var CIe=Nd(),{re:ZY,t:$Y}=bc(),gx=ox(),fx=Fd(),ej=Oi(),tj=as()});var Gd=y((yet,ij)=>{var mIe=as(),EIe=(r,e,t)=>{try{e=new mIe(e,t)}catch{return!1}return e.test(r)};ij.exports=EIe});var sj=y((wet,nj)=>{var IIe=as(),yIe=(r,e)=>new IIe(r,e).set.map(t=>t.map(i=>i.value).join(" ").trim().split(" "));nj.exports=yIe});var aj=y((Bet,oj)=>{var wIe=Oi(),BIe=as(),QIe=(r,e,t)=>{let i=null,n=null,s=null;try{s=new BIe(e,t)}catch{return null}return r.forEach(o=>{s.test(o)&&(!i||n.compare(o)===-1)&&(i=o,n=new wIe(i,t))}),i};oj.exports=QIe});var lj=y((Qet,Aj)=>{var bIe=Oi(),SIe=as(),vIe=(r,e,t)=>{let i=null,n=null,s=null;try{s=new SIe(e,t)}catch{return null}return r.forEach(o=>{s.test(o)&&(!i||n.compare(o)===1)&&(i=o,n=new bIe(i,t))}),i};Aj.exports=vIe});var gj=y((bet,uj)=>{var hx=Oi(),xIe=as(),cj=Td(),PIe=(r,e)=>{r=new xIe(r,e);let t=new hx("0.0.0");if(r.test(t)||(t=new hx("0.0.0-0"),r.test(t)))return t;t=null;for(let i=0;i{let a=new hx(o.semver.version);switch(o.operator){case">":a.prerelease.length===0?a.patch++:a.prerelease.push(0),a.raw=a.format();case"":case">=":(!s||cj(a,s))&&(s=a);break;case"<":case"<=":break;default:throw new Error(`Unexpected operation: ${o.operator}`)}}),s&&(!t||cj(t,s))&&(t=s)}return t&&r.test(t)?t:null};uj.exports=PIe});var hj=y((vet,fj)=>{var DIe=as(),kIe=(r,e)=>{try{return new DIe(r,e).range||"*"}catch{return null}};fj.exports=kIe});var By=y((xet,mj)=>{var RIe=Oi(),Cj=Ud(),{ANY:FIe}=Cj,NIe=as(),LIe=Gd(),pj=Td(),dj=Cy(),TIe=Ey(),OIe=my(),MIe=(r,e,t,i)=>{r=new RIe(r,i),e=new NIe(e,i);let n,s,o,a,l;switch(t){case">":n=pj,s=TIe,o=dj,a=">",l=">=";break;case"<":n=dj,s=OIe,o=pj,a="<",l="<=";break;default:throw new TypeError('Must provide a hilo val of "<" or ">"')}if(LIe(r,e,i))return!1;for(let c=0;c{h.semver===FIe&&(h=new Cj(">=0.0.0")),g=g||h,f=f||h,n(h.semver,g.semver,i)?g=h:o(h.semver,f.semver,i)&&(f=h)}),g.operator===a||g.operator===l||(!f.operator||f.operator===a)&&s(r,f.semver))return!1;if(f.operator===l&&o(r,f.semver))return!1}return!0};mj.exports=MIe});var Ij=y((Pet,Ej)=>{var KIe=By(),UIe=(r,e,t)=>KIe(r,e,">",t);Ej.exports=UIe});var wj=y((Det,yj)=>{var HIe=By(),GIe=(r,e,t)=>HIe(r,e,"<",t);yj.exports=GIe});var bj=y((ket,Qj)=>{var Bj=as(),YIe=(r,e,t)=>(r=new Bj(r,t),e=new Bj(e,t),r.intersects(e));Qj.exports=YIe});var vj=y((Ret,Sj)=>{var jIe=Gd(),qIe=os();Sj.exports=(r,e,t)=>{let i=[],n=null,s=null,o=r.sort((u,g)=>qIe(u,g,t));for(let u of o)jIe(u,e,t)?(s=u,n||(n=u)):(s&&i.push([n,s]),s=null,n=null);n&&i.push([n,null]);let a=[];for(let[u,g]of i)u===g?a.push(u):!g&&u===o[0]?a.push("*"):g?u===o[0]?a.push(`<=${g}`):a.push(`${u} - ${g}`):a.push(`>=${u}`);let l=a.join(" || "),c=typeof e.raw=="string"?e.raw:String(e);return l.length{var xj=as(),Qy=Ud(),{ANY:px}=Qy,Yd=Gd(),dx=os(),JIe=(r,e,t={})=>{if(r===e)return!0;r=new xj(r,t),e=new xj(e,t);let i=!1;e:for(let n of r.set){for(let s of e.set){let o=WIe(n,s,t);if(i=i||o!==null,o)continue e}if(i)return!1}return!0},WIe=(r,e,t)=>{if(r===e)return!0;if(r.length===1&&r[0].semver===px){if(e.length===1&&e[0].semver===px)return!0;t.includePrerelease?r=[new Qy(">=0.0.0-0")]:r=[new Qy(">=0.0.0")]}if(e.length===1&&e[0].semver===px){if(t.includePrerelease)return!0;e=[new Qy(">=0.0.0")]}let i=new Set,n,s;for(let h of r)h.operator===">"||h.operator===">="?n=Pj(n,h,t):h.operator==="<"||h.operator==="<="?s=Dj(s,h,t):i.add(h.semver);if(i.size>1)return null;let o;if(n&&s){if(o=dx(n.semver,s.semver,t),o>0)return null;if(o===0&&(n.operator!==">="||s.operator!=="<="))return null}for(let h of i){if(n&&!Yd(h,String(n),t)||s&&!Yd(h,String(s),t))return null;for(let p of e)if(!Yd(h,String(p),t))return!1;return!0}let a,l,c,u,g=s&&!t.includePrerelease&&s.semver.prerelease.length?s.semver:!1,f=n&&!t.includePrerelease&&n.semver.prerelease.length?n.semver:!1;g&&g.prerelease.length===1&&s.operator==="<"&&g.prerelease[0]===0&&(g=!1);for(let h of e){if(u=u||h.operator===">"||h.operator===">=",c=c||h.operator==="<"||h.operator==="<=",n){if(f&&h.semver.prerelease&&h.semver.prerelease.length&&h.semver.major===f.major&&h.semver.minor===f.minor&&h.semver.patch===f.patch&&(f=!1),h.operator===">"||h.operator===">="){if(a=Pj(n,h,t),a===h&&a!==n)return!1}else if(n.operator===">="&&!Yd(n.semver,String(h),t))return!1}if(s){if(g&&h.semver.prerelease&&h.semver.prerelease.length&&h.semver.major===g.major&&h.semver.minor===g.minor&&h.semver.patch===g.patch&&(g=!1),h.operator==="<"||h.operator==="<="){if(l=Dj(s,h,t),l===h&&l!==s)return!1}else if(s.operator==="<="&&!Yd(s.semver,String(h),t))return!1}if(!h.operator&&(s||n)&&o!==0)return!1}return!(n&&c&&!s&&o!==0||s&&u&&!n&&o!==0||f||g)},Pj=(r,e,t)=>{if(!r)return e;let i=dx(r.semver,e.semver,t);return i>0?r:i<0||e.operator===">"&&r.operator===">="?e:r},Dj=(r,e,t)=>{if(!r)return e;let i=dx(r.semver,e.semver,t);return i<0?r:i>0||e.operator==="<"&&r.operator==="<="?e:r};kj.exports=JIe});var $r=y((Net,Fj)=>{var Cx=bc();Fj.exports={re:Cx.re,src:Cx.src,tokens:Cx.t,SEMVER_SPEC_VERSION:Rd().SEMVER_SPEC_VERSION,SemVer:Oi(),compareIdentifiers:gy().compareIdentifiers,rcompareIdentifiers:gy().rcompareIdentifiers,parse:Sc(),valid:iY(),clean:sY(),inc:aY(),diff:fY(),major:pY(),minor:CY(),patch:EY(),prerelease:yY(),compare:os(),rcompare:BY(),compareLoose:bY(),compareBuild:dy(),sort:PY(),rsort:kY(),gt:Td(),lt:Cy(),eq:py(),neq:sx(),gte:my(),lte:Ey(),cmp:ox(),coerce:KY(),Comparator:Ud(),Range:as(),satisfies:Gd(),toComparators:sj(),maxSatisfying:aj(),minSatisfying:lj(),minVersion:gj(),validRange:hj(),outside:By(),gtr:Ij(),ltr:wj(),intersects:bj(),simplifyRange:vj(),subset:Rj()}});var mx=y(by=>{"use strict";Object.defineProperty(by,"__esModule",{value:!0});by.VERSION=void 0;by.VERSION="9.1.0"});var Gt=y((exports,module)=>{"use strict";var __spreadArray=exports&&exports.__spreadArray||function(r,e,t){if(t||arguments.length===2)for(var i=0,n=e.length,s;i{(function(r,e){typeof define=="function"&&define.amd?define([],e):typeof Sy=="object"&&Sy.exports?Sy.exports=e():r.regexpToAst=e()})(typeof self<"u"?self:Nj,function(){function r(){}r.prototype.saveState=function(){return{idx:this.idx,input:this.input,groupIdx:this.groupIdx}},r.prototype.restoreState=function(p){this.idx=p.idx,this.input=p.input,this.groupIdx=p.groupIdx},r.prototype.pattern=function(p){this.idx=0,this.input=p,this.groupIdx=0,this.consumeChar("/");var C=this.disjunction();this.consumeChar("/");for(var w={type:"Flags",loc:{begin:this.idx,end:p.length},global:!1,ignoreCase:!1,multiLine:!1,unicode:!1,sticky:!1};this.isRegExpFlag();)switch(this.popChar()){case"g":o(w,"global");break;case"i":o(w,"ignoreCase");break;case"m":o(w,"multiLine");break;case"u":o(w,"unicode");break;case"y":o(w,"sticky");break}if(this.idx!==this.input.length)throw Error("Redundant input: "+this.input.substring(this.idx));return{type:"Pattern",flags:w,value:C,loc:this.loc(0)}},r.prototype.disjunction=function(){var p=[],C=this.idx;for(p.push(this.alternative());this.peekChar()==="|";)this.consumeChar("|"),p.push(this.alternative());return{type:"Disjunction",value:p,loc:this.loc(C)}},r.prototype.alternative=function(){for(var p=[],C=this.idx;this.isTerm();)p.push(this.term());return{type:"Alternative",value:p,loc:this.loc(C)}},r.prototype.term=function(){return this.isAssertion()?this.assertion():this.atom()},r.prototype.assertion=function(){var p=this.idx;switch(this.popChar()){case"^":return{type:"StartAnchor",loc:this.loc(p)};case"$":return{type:"EndAnchor",loc:this.loc(p)};case"\\":switch(this.popChar()){case"b":return{type:"WordBoundary",loc:this.loc(p)};case"B":return{type:"NonWordBoundary",loc:this.loc(p)}}throw Error("Invalid Assertion Escape");case"(":this.consumeChar("?");var C;switch(this.popChar()){case"=":C="Lookahead";break;case"!":C="NegativeLookahead";break}a(C);var w=this.disjunction();return this.consumeChar(")"),{type:C,value:w,loc:this.loc(p)}}l()},r.prototype.quantifier=function(p){var C,w=this.idx;switch(this.popChar()){case"*":C={atLeast:0,atMost:1/0};break;case"+":C={atLeast:1,atMost:1/0};break;case"?":C={atLeast:0,atMost:1};break;case"{":var B=this.integerIncludingZero();switch(this.popChar()){case"}":C={atLeast:B,atMost:B};break;case",":var v;this.isDigit()?(v=this.integerIncludingZero(),C={atLeast:B,atMost:v}):C={atLeast:B,atMost:1/0},this.consumeChar("}");break}if(p===!0&&C===void 0)return;a(C);break}if(!(p===!0&&C===void 0))return a(C),this.peekChar(0)==="?"?(this.consumeChar("?"),C.greedy=!1):C.greedy=!0,C.type="Quantifier",C.loc=this.loc(w),C},r.prototype.atom=function(){var p,C=this.idx;switch(this.peekChar()){case".":p=this.dotAll();break;case"\\":p=this.atomEscape();break;case"[":p=this.characterClass();break;case"(":p=this.group();break}return p===void 0&&this.isPatternCharacter()&&(p=this.patternCharacter()),a(p),p.loc=this.loc(C),this.isQuantifier()&&(p.quantifier=this.quantifier()),p},r.prototype.dotAll=function(){return this.consumeChar("."),{type:"Set",complement:!0,value:[n(` -`),n("\r"),n("\u2028"),n("\u2029")]}},r.prototype.atomEscape=function(){switch(this.consumeChar("\\"),this.peekChar()){case"1":case"2":case"3":case"4":case"5":case"6":case"7":case"8":case"9":return this.decimalEscapeAtom();case"d":case"D":case"s":case"S":case"w":case"W":return this.characterClassEscape();case"f":case"n":case"r":case"t":case"v":return this.controlEscapeAtom();case"c":return this.controlLetterEscapeAtom();case"0":return this.nulCharacterAtom();case"x":return this.hexEscapeSequenceAtom();case"u":return this.regExpUnicodeEscapeSequenceAtom();default:return this.identityEscapeAtom()}},r.prototype.decimalEscapeAtom=function(){var p=this.positiveInteger();return{type:"GroupBackReference",value:p}},r.prototype.characterClassEscape=function(){var p,C=!1;switch(this.popChar()){case"d":p=u;break;case"D":p=u,C=!0;break;case"s":p=f;break;case"S":p=f,C=!0;break;case"w":p=g;break;case"W":p=g,C=!0;break}return a(p),{type:"Set",value:p,complement:C}},r.prototype.controlEscapeAtom=function(){var p;switch(this.popChar()){case"f":p=n("\f");break;case"n":p=n(` -`);break;case"r":p=n("\r");break;case"t":p=n(" ");break;case"v":p=n("\v");break}return a(p),{type:"Character",value:p}},r.prototype.controlLetterEscapeAtom=function(){this.consumeChar("c");var p=this.popChar();if(/[a-zA-Z]/.test(p)===!1)throw Error("Invalid ");var C=p.toUpperCase().charCodeAt(0)-64;return{type:"Character",value:C}},r.prototype.nulCharacterAtom=function(){return this.consumeChar("0"),{type:"Character",value:n("\0")}},r.prototype.hexEscapeSequenceAtom=function(){return this.consumeChar("x"),this.parseHexDigits(2)},r.prototype.regExpUnicodeEscapeSequenceAtom=function(){return this.consumeChar("u"),this.parseHexDigits(4)},r.prototype.identityEscapeAtom=function(){var p=this.popChar();return{type:"Character",value:n(p)}},r.prototype.classPatternCharacterAtom=function(){switch(this.peekChar()){case` -`:case"\r":case"\u2028":case"\u2029":case"\\":case"]":throw Error("TBD");default:var p=this.popChar();return{type:"Character",value:n(p)}}},r.prototype.characterClass=function(){var p=[],C=!1;for(this.consumeChar("["),this.peekChar(0)==="^"&&(this.consumeChar("^"),C=!0);this.isClassAtom();){var w=this.classAtom(),B=w.type==="Character";if(B&&this.isRangeDash()){this.consumeChar("-");var v=this.classAtom(),D=v.type==="Character";if(D){if(v.value=this.input.length)throw Error("Unexpected end of input");this.idx++},r.prototype.loc=function(p){return{begin:p,end:this.idx}};var e=/[0-9a-fA-F]/,t=/[0-9]/,i=/[1-9]/;function n(p){return p.charCodeAt(0)}function s(p,C){p.length!==void 0?p.forEach(function(w){C.push(w)}):C.push(p)}function o(p,C){if(p[C]===!0)throw"duplicate flag "+C;p[C]=!0}function a(p){if(p===void 0)throw Error("Internal Error - Should never get here!")}function l(){throw Error("Internal Error - Should never get here!")}var c,u=[];for(c=n("0");c<=n("9");c++)u.push(c);var g=[n("_")].concat(u);for(c=n("a");c<=n("z");c++)g.push(c);for(c=n("A");c<=n("Z");c++)g.push(c);var f=[n(" "),n("\f"),n(` -`),n("\r"),n(" "),n("\v"),n(" "),n("\xA0"),n("\u1680"),n("\u2000"),n("\u2001"),n("\u2002"),n("\u2003"),n("\u2004"),n("\u2005"),n("\u2006"),n("\u2007"),n("\u2008"),n("\u2009"),n("\u200A"),n("\u2028"),n("\u2029"),n("\u202F"),n("\u205F"),n("\u3000"),n("\uFEFF")];function h(){}return h.prototype.visitChildren=function(p){for(var C in p){var w=p[C];p.hasOwnProperty(C)&&(w.type!==void 0?this.visit(w):Array.isArray(w)&&w.forEach(function(B){this.visit(B)},this))}},h.prototype.visit=function(p){switch(p.type){case"Pattern":this.visitPattern(p);break;case"Flags":this.visitFlags(p);break;case"Disjunction":this.visitDisjunction(p);break;case"Alternative":this.visitAlternative(p);break;case"StartAnchor":this.visitStartAnchor(p);break;case"EndAnchor":this.visitEndAnchor(p);break;case"WordBoundary":this.visitWordBoundary(p);break;case"NonWordBoundary":this.visitNonWordBoundary(p);break;case"Lookahead":this.visitLookahead(p);break;case"NegativeLookahead":this.visitNegativeLookahead(p);break;case"Character":this.visitCharacter(p);break;case"Set":this.visitSet(p);break;case"Group":this.visitGroup(p);break;case"GroupBackReference":this.visitGroupBackReference(p);break;case"Quantifier":this.visitQuantifier(p);break}this.visitChildren(p)},h.prototype.visitPattern=function(p){},h.prototype.visitFlags=function(p){},h.prototype.visitDisjunction=function(p){},h.prototype.visitAlternative=function(p){},h.prototype.visitStartAnchor=function(p){},h.prototype.visitEndAnchor=function(p){},h.prototype.visitWordBoundary=function(p){},h.prototype.visitNonWordBoundary=function(p){},h.prototype.visitLookahead=function(p){},h.prototype.visitNegativeLookahead=function(p){},h.prototype.visitCharacter=function(p){},h.prototype.visitSet=function(p){},h.prototype.visitGroup=function(p){},h.prototype.visitGroupBackReference=function(p){},h.prototype.visitQuantifier=function(p){},{RegExpParser:r,BaseRegExpVisitor:h,VERSION:"0.5.0"}})});var Py=y(ff=>{"use strict";Object.defineProperty(ff,"__esModule",{value:!0});ff.clearRegExpParserCache=ff.getRegExpAst=void 0;var zIe=vy(),xy={},VIe=new zIe.RegExpParser;function XIe(r){var e=r.toString();if(xy.hasOwnProperty(e))return xy[e];var t=VIe.pattern(e);return xy[e]=t,t}ff.getRegExpAst=XIe;function _Ie(){xy={}}ff.clearRegExpParserCache=_Ie});var Kj=y(dn=>{"use strict";var ZIe=dn&&dn.__extends||function(){var r=function(e,t){return r=Object.setPrototypeOf||{__proto__:[]}instanceof Array&&function(i,n){i.__proto__=n}||function(i,n){for(var s in n)Object.prototype.hasOwnProperty.call(n,s)&&(i[s]=n[s])},r(e,t)};return function(e,t){if(typeof t!="function"&&t!==null)throw new TypeError("Class extends value "+String(t)+" is not a constructor or null");r(e,t);function i(){this.constructor=e}e.prototype=t===null?Object.create(t):(i.prototype=t.prototype,new i)}}();Object.defineProperty(dn,"__esModule",{value:!0});dn.canMatchCharCode=dn.firstCharOptimizedIndices=dn.getOptimizedStartCodesIndices=dn.failedOptimizationPrefixMsg=void 0;var Tj=vy(),As=Gt(),Oj=Py(),Pa=Ix(),Mj="Complement Sets are not supported for first char optimization";dn.failedOptimizationPrefixMsg=`Unable to use "first char" lexer optimizations: -`;function $Ie(r,e){e===void 0&&(e=!1);try{var t=(0,Oj.getRegExpAst)(r),i=ky(t.value,{},t.flags.ignoreCase);return i}catch(s){if(s.message===Mj)e&&(0,As.PRINT_WARNING)(""+dn.failedOptimizationPrefixMsg+(" Unable to optimize: < "+r.toString()+` > -`)+` Complement Sets cannot be automatically optimized. - This will disable the lexer's first char optimizations. - See: https://chevrotain.io/docs/guide/resolving_lexer_errors.html#COMPLEMENT for details.`);else{var n="";e&&(n=` - This will disable the lexer's first char optimizations. - See: https://chevrotain.io/docs/guide/resolving_lexer_errors.html#REGEXP_PARSING for details.`),(0,As.PRINT_ERROR)(dn.failedOptimizationPrefixMsg+` -`+(" Failed parsing: < "+r.toString()+` > -`)+(" Using the regexp-to-ast library version: "+Tj.VERSION+` -`)+" Please open an issue at: https://github.com/bd82/regexp-to-ast/issues"+n)}}return[]}dn.getOptimizedStartCodesIndices=$Ie;function ky(r,e,t){switch(r.type){case"Disjunction":for(var i=0;i=Pa.minOptimizationVal)for(var f=u.from>=Pa.minOptimizationVal?u.from:Pa.minOptimizationVal,h=u.to,p=(0,Pa.charCodeToOptimizedIndex)(f),C=(0,Pa.charCodeToOptimizedIndex)(h),w=p;w<=C;w++)e[w]=w}}});break;case"Group":ky(o.value,e,t);break;default:throw Error("Non Exhaustive Match")}var a=o.quantifier!==void 0&&o.quantifier.atLeast===0;if(o.type==="Group"&&Ex(o)===!1||o.type!=="Group"&&a===!1)break}break;default:throw Error("non exhaustive match!")}return(0,As.values)(e)}dn.firstCharOptimizedIndices=ky;function Dy(r,e,t){var i=(0,Pa.charCodeToOptimizedIndex)(r);e[i]=i,t===!0&&eye(r,e)}function eye(r,e){var t=String.fromCharCode(r),i=t.toUpperCase();if(i!==t){var n=(0,Pa.charCodeToOptimizedIndex)(i.charCodeAt(0));e[n]=n}else{var s=t.toLowerCase();if(s!==t){var n=(0,Pa.charCodeToOptimizedIndex)(s.charCodeAt(0));e[n]=n}}}function Lj(r,e){return(0,As.find)(r.value,function(t){if(typeof t=="number")return(0,As.contains)(e,t);var i=t;return(0,As.find)(e,function(n){return i.from<=n&&n<=i.to})!==void 0})}function Ex(r){return r.quantifier&&r.quantifier.atLeast===0?!0:r.value?(0,As.isArray)(r.value)?(0,As.every)(r.value,Ex):Ex(r.value):!1}var tye=function(r){ZIe(e,r);function e(t){var i=r.call(this)||this;return i.targetCharCodes=t,i.found=!1,i}return e.prototype.visitChildren=function(t){if(this.found!==!0){switch(t.type){case"Lookahead":this.visitLookahead(t);return;case"NegativeLookahead":this.visitNegativeLookahead(t);return}r.prototype.visitChildren.call(this,t)}},e.prototype.visitCharacter=function(t){(0,As.contains)(this.targetCharCodes,t.value)&&(this.found=!0)},e.prototype.visitSet=function(t){t.complement?Lj(t,this.targetCharCodes)===void 0&&(this.found=!0):Lj(t,this.targetCharCodes)!==void 0&&(this.found=!0)},e}(Tj.BaseRegExpVisitor);function rye(r,e){if(e instanceof RegExp){var t=(0,Oj.getRegExpAst)(e),i=new tye(r);return i.visit(t),i.found}else return(0,As.find)(e,function(n){return(0,As.contains)(r,n.charCodeAt(0))})!==void 0}dn.canMatchCharCode=rye});var Ix=y(Je=>{"use strict";var Uj=Je&&Je.__extends||function(){var r=function(e,t){return r=Object.setPrototypeOf||{__proto__:[]}instanceof Array&&function(i,n){i.__proto__=n}||function(i,n){for(var s in n)Object.prototype.hasOwnProperty.call(n,s)&&(i[s]=n[s])},r(e,t)};return function(e,t){if(typeof t!="function"&&t!==null)throw new TypeError("Class extends value "+String(t)+" is not a constructor or null");r(e,t);function i(){this.constructor=e}e.prototype=t===null?Object.create(t):(i.prototype=t.prototype,new i)}}();Object.defineProperty(Je,"__esModule",{value:!0});Je.charCodeToOptimizedIndex=Je.minOptimizationVal=Je.buildLineBreakIssueMessage=Je.LineTerminatorOptimizedTester=Je.isShortPattern=Je.isCustomPattern=Je.cloneEmptyGroups=Je.performWarningRuntimeChecks=Je.performRuntimeChecks=Je.addStickyFlag=Je.addStartOfInput=Je.findUnreachablePatterns=Je.findModesThatDoNotExist=Je.findInvalidGroupType=Je.findDuplicatePatterns=Je.findUnsupportedFlags=Je.findStartOfInputAnchor=Je.findEmptyMatchRegExps=Je.findEndOfInputAnchor=Je.findInvalidPatterns=Je.findMissingPatterns=Je.validatePatterns=Je.analyzeTokenTypes=Je.enableSticky=Je.disableSticky=Je.SUPPORT_STICKY=Je.MODES=Je.DEFAULT_MODE=void 0;var Hj=vy(),ir=jd(),Se=Gt(),hf=Kj(),Gj=Py(),Do="PATTERN";Je.DEFAULT_MODE="defaultMode";Je.MODES="modes";Je.SUPPORT_STICKY=typeof new RegExp("(?:)").sticky=="boolean";function iye(){Je.SUPPORT_STICKY=!1}Je.disableSticky=iye;function nye(){Je.SUPPORT_STICKY=!0}Je.enableSticky=nye;function sye(r,e){e=(0,Se.defaults)(e,{useSticky:Je.SUPPORT_STICKY,debug:!1,safeMode:!1,positionTracking:"full",lineTerminatorCharacters:["\r",` -`],tracer:function(v,D){return D()}});var t=e.tracer;t("initCharCodeToOptimizedIndexMap",function(){pye()});var i;t("Reject Lexer.NA",function(){i=(0,Se.reject)(r,function(v){return v[Do]===ir.Lexer.NA})});var n=!1,s;t("Transform Patterns",function(){n=!1,s=(0,Se.map)(i,function(v){var D=v[Do];if((0,Se.isRegExp)(D)){var L=D.source;return L.length===1&&L!=="^"&&L!=="$"&&L!=="."&&!D.ignoreCase?L:L.length===2&&L[0]==="\\"&&!(0,Se.contains)(["d","D","s","S","t","r","n","t","0","c","b","B","f","v","w","W"],L[1])?L[1]:e.useSticky?Bx(D):wx(D)}else{if((0,Se.isFunction)(D))return n=!0,{exec:D};if((0,Se.has)(D,"exec"))return n=!0,D;if(typeof D=="string"){if(D.length===1)return D;var H=D.replace(/[\\^$.*+?()[\]{}|]/g,"\\$&"),j=new RegExp(H);return e.useSticky?Bx(j):wx(j)}else throw Error("non exhaustive match")}})});var o,a,l,c,u;t("misc mapping",function(){o=(0,Se.map)(i,function(v){return v.tokenTypeIdx}),a=(0,Se.map)(i,function(v){var D=v.GROUP;if(D!==ir.Lexer.SKIPPED){if((0,Se.isString)(D))return D;if((0,Se.isUndefined)(D))return!1;throw Error("non exhaustive match")}}),l=(0,Se.map)(i,function(v){var D=v.LONGER_ALT;if(D){var L=(0,Se.isArray)(D)?(0,Se.map)(D,function(H){return(0,Se.indexOf)(i,H)}):[(0,Se.indexOf)(i,D)];return L}}),c=(0,Se.map)(i,function(v){return v.PUSH_MODE}),u=(0,Se.map)(i,function(v){return(0,Se.has)(v,"POP_MODE")})});var g;t("Line Terminator Handling",function(){var v=rq(e.lineTerminatorCharacters);g=(0,Se.map)(i,function(D){return!1}),e.positionTracking!=="onlyOffset"&&(g=(0,Se.map)(i,function(D){if((0,Se.has)(D,"LINE_BREAKS"))return D.LINE_BREAKS;if(eq(D,v)===!1)return(0,hf.canMatchCharCode)(v,D.PATTERN)}))});var f,h,p,C;t("Misc Mapping #2",function(){f=(0,Se.map)(i,bx),h=(0,Se.map)(s,$j),p=(0,Se.reduce)(i,function(v,D){var L=D.GROUP;return(0,Se.isString)(L)&&L!==ir.Lexer.SKIPPED&&(v[L]=[]),v},{}),C=(0,Se.map)(s,function(v,D){return{pattern:s[D],longerAlt:l[D],canLineTerminator:g[D],isCustom:f[D],short:h[D],group:a[D],push:c[D],pop:u[D],tokenTypeIdx:o[D],tokenType:i[D]}})});var w=!0,B=[];return e.safeMode||t("First Char Optimization",function(){B=(0,Se.reduce)(i,function(v,D,L){if(typeof D.PATTERN=="string"){var H=D.PATTERN.charCodeAt(0),j=Qx(H);yx(v,j,C[L])}else if((0,Se.isArray)(D.START_CHARS_HINT)){var $;(0,Se.forEach)(D.START_CHARS_HINT,function(W){var Z=typeof W=="string"?W.charCodeAt(0):W,A=Qx(Z);$!==A&&($=A,yx(v,A,C[L]))})}else if((0,Se.isRegExp)(D.PATTERN))if(D.PATTERN.unicode)w=!1,e.ensureOptimizations&&(0,Se.PRINT_ERROR)(""+hf.failedOptimizationPrefixMsg+(" Unable to analyze < "+D.PATTERN.toString()+` > pattern. -`)+` The regexp unicode flag is not currently supported by the regexp-to-ast library. - This will disable the lexer's first char optimizations. - For details See: https://chevrotain.io/docs/guide/resolving_lexer_errors.html#UNICODE_OPTIMIZE`);else{var V=(0,hf.getOptimizedStartCodesIndices)(D.PATTERN,e.ensureOptimizations);(0,Se.isEmpty)(V)&&(w=!1),(0,Se.forEach)(V,function(W){yx(v,W,C[L])})}else e.ensureOptimizations&&(0,Se.PRINT_ERROR)(""+hf.failedOptimizationPrefixMsg+(" TokenType: <"+D.name+`> is using a custom token pattern without providing parameter. -`)+` This will disable the lexer's first char optimizations. - For details See: https://chevrotain.io/docs/guide/resolving_lexer_errors.html#CUSTOM_OPTIMIZE`),w=!1;return v},[])}),t("ArrayPacking",function(){B=(0,Se.packArray)(B)}),{emptyGroups:p,patternIdxToConfig:C,charCodeToPatternIdxToConfig:B,hasCustom:n,canBeOptimized:w}}Je.analyzeTokenTypes=sye;function oye(r,e){var t=[],i=Yj(r);t=t.concat(i.errors);var n=jj(i.valid),s=n.valid;return t=t.concat(n.errors),t=t.concat(aye(s)),t=t.concat(Xj(s)),t=t.concat(_j(s,e)),t=t.concat(Zj(s)),t}Je.validatePatterns=oye;function aye(r){var e=[],t=(0,Se.filter)(r,function(i){return(0,Se.isRegExp)(i[Do])});return e=e.concat(qj(t)),e=e.concat(Wj(t)),e=e.concat(zj(t)),e=e.concat(Vj(t)),e=e.concat(Jj(t)),e}function Yj(r){var e=(0,Se.filter)(r,function(n){return!(0,Se.has)(n,Do)}),t=(0,Se.map)(e,function(n){return{message:"Token Type: ->"+n.name+"<- missing static 'PATTERN' property",type:ir.LexerDefinitionErrorType.MISSING_PATTERN,tokenTypes:[n]}}),i=(0,Se.difference)(r,e);return{errors:t,valid:i}}Je.findMissingPatterns=Yj;function jj(r){var e=(0,Se.filter)(r,function(n){var s=n[Do];return!(0,Se.isRegExp)(s)&&!(0,Se.isFunction)(s)&&!(0,Se.has)(s,"exec")&&!(0,Se.isString)(s)}),t=(0,Se.map)(e,function(n){return{message:"Token Type: ->"+n.name+"<- static 'PATTERN' can only be a RegExp, a Function matching the {CustomPatternMatcherFunc} type or an Object matching the {ICustomPattern} interface.",type:ir.LexerDefinitionErrorType.INVALID_PATTERN,tokenTypes:[n]}}),i=(0,Se.difference)(r,e);return{errors:t,valid:i}}Je.findInvalidPatterns=jj;var Aye=/[^\\][\$]/;function qj(r){var e=function(n){Uj(s,n);function s(){var o=n!==null&&n.apply(this,arguments)||this;return o.found=!1,o}return s.prototype.visitEndAnchor=function(o){this.found=!0},s}(Hj.BaseRegExpVisitor),t=(0,Se.filter)(r,function(n){var s=n[Do];try{var o=(0,Gj.getRegExpAst)(s),a=new e;return a.visit(o),a.found}catch{return Aye.test(s.source)}}),i=(0,Se.map)(t,function(n){return{message:`Unexpected RegExp Anchor Error: - Token Type: ->`+n.name+`<- static 'PATTERN' cannot contain end of input anchor '$' - See chevrotain.io/docs/guide/resolving_lexer_errors.html#ANCHORS for details.`,type:ir.LexerDefinitionErrorType.EOI_ANCHOR_FOUND,tokenTypes:[n]}});return i}Je.findEndOfInputAnchor=qj;function Jj(r){var e=(0,Se.filter)(r,function(i){var n=i[Do];return n.test("")}),t=(0,Se.map)(e,function(i){return{message:"Token Type: ->"+i.name+"<- static 'PATTERN' must not match an empty string",type:ir.LexerDefinitionErrorType.EMPTY_MATCH_PATTERN,tokenTypes:[i]}});return t}Je.findEmptyMatchRegExps=Jj;var lye=/[^\\[][\^]|^\^/;function Wj(r){var e=function(n){Uj(s,n);function s(){var o=n!==null&&n.apply(this,arguments)||this;return o.found=!1,o}return s.prototype.visitStartAnchor=function(o){this.found=!0},s}(Hj.BaseRegExpVisitor),t=(0,Se.filter)(r,function(n){var s=n[Do];try{var o=(0,Gj.getRegExpAst)(s),a=new e;return a.visit(o),a.found}catch{return lye.test(s.source)}}),i=(0,Se.map)(t,function(n){return{message:`Unexpected RegExp Anchor Error: - Token Type: ->`+n.name+`<- static 'PATTERN' cannot contain start of input anchor '^' - See https://chevrotain.io/docs/guide/resolving_lexer_errors.html#ANCHORS for details.`,type:ir.LexerDefinitionErrorType.SOI_ANCHOR_FOUND,tokenTypes:[n]}});return i}Je.findStartOfInputAnchor=Wj;function zj(r){var e=(0,Se.filter)(r,function(i){var n=i[Do];return n instanceof RegExp&&(n.multiline||n.global)}),t=(0,Se.map)(e,function(i){return{message:"Token Type: ->"+i.name+"<- static 'PATTERN' may NOT contain global('g') or multiline('m')",type:ir.LexerDefinitionErrorType.UNSUPPORTED_FLAGS_FOUND,tokenTypes:[i]}});return t}Je.findUnsupportedFlags=zj;function Vj(r){var e=[],t=(0,Se.map)(r,function(s){return(0,Se.reduce)(r,function(o,a){return s.PATTERN.source===a.PATTERN.source&&!(0,Se.contains)(e,a)&&a.PATTERN!==ir.Lexer.NA&&(e.push(a),o.push(a)),o},[])});t=(0,Se.compact)(t);var i=(0,Se.filter)(t,function(s){return s.length>1}),n=(0,Se.map)(i,function(s){var o=(0,Se.map)(s,function(l){return l.name}),a=(0,Se.first)(s).PATTERN;return{message:"The same RegExp pattern ->"+a+"<-"+("has been used in all of the following Token Types: "+o.join(", ")+" <-"),type:ir.LexerDefinitionErrorType.DUPLICATE_PATTERNS_FOUND,tokenTypes:s}});return n}Je.findDuplicatePatterns=Vj;function Xj(r){var e=(0,Se.filter)(r,function(i){if(!(0,Se.has)(i,"GROUP"))return!1;var n=i.GROUP;return n!==ir.Lexer.SKIPPED&&n!==ir.Lexer.NA&&!(0,Se.isString)(n)}),t=(0,Se.map)(e,function(i){return{message:"Token Type: ->"+i.name+"<- static 'GROUP' can only be Lexer.SKIPPED/Lexer.NA/A String",type:ir.LexerDefinitionErrorType.INVALID_GROUP_TYPE_FOUND,tokenTypes:[i]}});return t}Je.findInvalidGroupType=Xj;function _j(r,e){var t=(0,Se.filter)(r,function(n){return n.PUSH_MODE!==void 0&&!(0,Se.contains)(e,n.PUSH_MODE)}),i=(0,Se.map)(t,function(n){var s="Token Type: ->"+n.name+"<- static 'PUSH_MODE' value cannot refer to a Lexer Mode ->"+n.PUSH_MODE+"<-which does not exist";return{message:s,type:ir.LexerDefinitionErrorType.PUSH_MODE_DOES_NOT_EXIST,tokenTypes:[n]}});return i}Je.findModesThatDoNotExist=_j;function Zj(r){var e=[],t=(0,Se.reduce)(r,function(i,n,s){var o=n.PATTERN;return o===ir.Lexer.NA||((0,Se.isString)(o)?i.push({str:o,idx:s,tokenType:n}):(0,Se.isRegExp)(o)&&uye(o)&&i.push({str:o.source,idx:s,tokenType:n})),i},[]);return(0,Se.forEach)(r,function(i,n){(0,Se.forEach)(t,function(s){var o=s.str,a=s.idx,l=s.tokenType;if(n"+i.name+"<-")+`in the lexer's definition. -See https://chevrotain.io/docs/guide/resolving_lexer_errors.html#UNREACHABLE`;e.push({message:c,type:ir.LexerDefinitionErrorType.UNREACHABLE_PATTERN,tokenTypes:[i,l]})}})}),e}Je.findUnreachablePatterns=Zj;function cye(r,e){if((0,Se.isRegExp)(e)){var t=e.exec(r);return t!==null&&t.index===0}else{if((0,Se.isFunction)(e))return e(r,0,[],{});if((0,Se.has)(e,"exec"))return e.exec(r,0,[],{});if(typeof e=="string")return e===r;throw Error("non exhaustive match")}}function uye(r){var e=[".","\\","[","]","|","^","$","(",")","?","*","+","{"];return(0,Se.find)(e,function(t){return r.source.indexOf(t)!==-1})===void 0}function wx(r){var e=r.ignoreCase?"i":"";return new RegExp("^(?:"+r.source+")",e)}Je.addStartOfInput=wx;function Bx(r){var e=r.ignoreCase?"iy":"y";return new RegExp(""+r.source,e)}Je.addStickyFlag=Bx;function gye(r,e,t){var i=[];return(0,Se.has)(r,Je.DEFAULT_MODE)||i.push({message:"A MultiMode Lexer cannot be initialized without a <"+Je.DEFAULT_MODE+`> property in its definition -`,type:ir.LexerDefinitionErrorType.MULTI_MODE_LEXER_WITHOUT_DEFAULT_MODE}),(0,Se.has)(r,Je.MODES)||i.push({message:"A MultiMode Lexer cannot be initialized without a <"+Je.MODES+`> property in its definition -`,type:ir.LexerDefinitionErrorType.MULTI_MODE_LEXER_WITHOUT_MODES_PROPERTY}),(0,Se.has)(r,Je.MODES)&&(0,Se.has)(r,Je.DEFAULT_MODE)&&!(0,Se.has)(r.modes,r.defaultMode)&&i.push({message:"A MultiMode Lexer cannot be initialized with a "+Je.DEFAULT_MODE+": <"+r.defaultMode+`>which does not exist -`,type:ir.LexerDefinitionErrorType.MULTI_MODE_LEXER_DEFAULT_MODE_VALUE_DOES_NOT_EXIST}),(0,Se.has)(r,Je.MODES)&&(0,Se.forEach)(r.modes,function(n,s){(0,Se.forEach)(n,function(o,a){(0,Se.isUndefined)(o)&&i.push({message:"A Lexer cannot be initialized using an undefined Token Type. Mode:"+("<"+s+"> at index: <"+a+`> -`),type:ir.LexerDefinitionErrorType.LEXER_DEFINITION_CANNOT_CONTAIN_UNDEFINED})})}),i}Je.performRuntimeChecks=gye;function fye(r,e,t){var i=[],n=!1,s=(0,Se.compact)((0,Se.flatten)((0,Se.mapValues)(r.modes,function(l){return l}))),o=(0,Se.reject)(s,function(l){return l[Do]===ir.Lexer.NA}),a=rq(t);return e&&(0,Se.forEach)(o,function(l){var c=eq(l,a);if(c!==!1){var u=tq(l,c),g={message:u,type:c.issue,tokenType:l};i.push(g)}else(0,Se.has)(l,"LINE_BREAKS")?l.LINE_BREAKS===!0&&(n=!0):(0,hf.canMatchCharCode)(a,l.PATTERN)&&(n=!0)}),e&&!n&&i.push({message:`Warning: No LINE_BREAKS Found. - This Lexer has been defined to track line and column information, - But none of the Token Types can be identified as matching a line terminator. - See https://chevrotain.io/docs/guide/resolving_lexer_errors.html#LINE_BREAKS - for details.`,type:ir.LexerDefinitionErrorType.NO_LINE_BREAKS_FLAGS}),i}Je.performWarningRuntimeChecks=fye;function hye(r){var e={},t=(0,Se.keys)(r);return(0,Se.forEach)(t,function(i){var n=r[i];if((0,Se.isArray)(n))e[i]=[];else throw Error("non exhaustive match")}),e}Je.cloneEmptyGroups=hye;function bx(r){var e=r.PATTERN;if((0,Se.isRegExp)(e))return!1;if((0,Se.isFunction)(e))return!0;if((0,Se.has)(e,"exec"))return!0;if((0,Se.isString)(e))return!1;throw Error("non exhaustive match")}Je.isCustomPattern=bx;function $j(r){return(0,Se.isString)(r)&&r.length===1?r.charCodeAt(0):!1}Je.isShortPattern=$j;Je.LineTerminatorOptimizedTester={test:function(r){for(var e=r.length,t=this.lastIndex;t Token Type -`)+(" Root cause: "+e.errMsg+`. -`)+" For details See: https://chevrotain.io/docs/guide/resolving_lexer_errors.html#IDENTIFY_TERMINATOR";if(e.issue===ir.LexerDefinitionErrorType.CUSTOM_LINE_BREAK)return`Warning: A Custom Token Pattern should specify the option. -`+(" The problem is in the <"+r.name+`> Token Type -`)+" For details See: https://chevrotain.io/docs/guide/resolving_lexer_errors.html#CUSTOM_LINE_BREAK";throw Error("non exhaustive match")}Je.buildLineBreakIssueMessage=tq;function rq(r){var e=(0,Se.map)(r,function(t){return(0,Se.isString)(t)&&t.length>0?t.charCodeAt(0):t});return e}function yx(r,e,t){r[e]===void 0?r[e]=[t]:r[e].push(t)}Je.minOptimizationVal=256;var Ry=[];function Qx(r){return r255?255+~~(r/255):r}}});var pf=y(Nt=>{"use strict";Object.defineProperty(Nt,"__esModule",{value:!0});Nt.isTokenType=Nt.hasExtendingTokensTypesMapProperty=Nt.hasExtendingTokensTypesProperty=Nt.hasCategoriesProperty=Nt.hasShortKeyProperty=Nt.singleAssignCategoriesToksMap=Nt.assignCategoriesMapProp=Nt.assignCategoriesTokensProp=Nt.assignTokenDefaultProps=Nt.expandCategories=Nt.augmentTokenTypes=Nt.tokenIdxToClass=Nt.tokenShortNameIdx=Nt.tokenStructuredMatcherNoCategories=Nt.tokenStructuredMatcher=void 0;var ei=Gt();function dye(r,e){var t=r.tokenTypeIdx;return t===e.tokenTypeIdx?!0:e.isParent===!0&&e.categoryMatchesMap[t]===!0}Nt.tokenStructuredMatcher=dye;function Cye(r,e){return r.tokenTypeIdx===e.tokenTypeIdx}Nt.tokenStructuredMatcherNoCategories=Cye;Nt.tokenShortNameIdx=1;Nt.tokenIdxToClass={};function mye(r){var e=iq(r);nq(e),oq(e),sq(e),(0,ei.forEach)(e,function(t){t.isParent=t.categoryMatches.length>0})}Nt.augmentTokenTypes=mye;function iq(r){for(var e=(0,ei.cloneArr)(r),t=r,i=!0;i;){t=(0,ei.compact)((0,ei.flatten)((0,ei.map)(t,function(s){return s.CATEGORIES})));var n=(0,ei.difference)(t,e);e=e.concat(n),(0,ei.isEmpty)(n)?i=!1:t=n}return e}Nt.expandCategories=iq;function nq(r){(0,ei.forEach)(r,function(e){aq(e)||(Nt.tokenIdxToClass[Nt.tokenShortNameIdx]=e,e.tokenTypeIdx=Nt.tokenShortNameIdx++),Sx(e)&&!(0,ei.isArray)(e.CATEGORIES)&&(e.CATEGORIES=[e.CATEGORIES]),Sx(e)||(e.CATEGORIES=[]),Aq(e)||(e.categoryMatches=[]),lq(e)||(e.categoryMatchesMap={})})}Nt.assignTokenDefaultProps=nq;function sq(r){(0,ei.forEach)(r,function(e){e.categoryMatches=[],(0,ei.forEach)(e.categoryMatchesMap,function(t,i){e.categoryMatches.push(Nt.tokenIdxToClass[i].tokenTypeIdx)})})}Nt.assignCategoriesTokensProp=sq;function oq(r){(0,ei.forEach)(r,function(e){vx([],e)})}Nt.assignCategoriesMapProp=oq;function vx(r,e){(0,ei.forEach)(r,function(t){e.categoryMatchesMap[t.tokenTypeIdx]=!0}),(0,ei.forEach)(e.CATEGORIES,function(t){var i=r.concat(e);(0,ei.contains)(i,t)||vx(i,t)})}Nt.singleAssignCategoriesToksMap=vx;function aq(r){return(0,ei.has)(r,"tokenTypeIdx")}Nt.hasShortKeyProperty=aq;function Sx(r){return(0,ei.has)(r,"CATEGORIES")}Nt.hasCategoriesProperty=Sx;function Aq(r){return(0,ei.has)(r,"categoryMatches")}Nt.hasExtendingTokensTypesProperty=Aq;function lq(r){return(0,ei.has)(r,"categoryMatchesMap")}Nt.hasExtendingTokensTypesMapProperty=lq;function Eye(r){return(0,ei.has)(r,"tokenTypeIdx")}Nt.isTokenType=Eye});var xx=y(Fy=>{"use strict";Object.defineProperty(Fy,"__esModule",{value:!0});Fy.defaultLexerErrorProvider=void 0;Fy.defaultLexerErrorProvider={buildUnableToPopLexerModeMessage:function(r){return"Unable to pop Lexer Mode after encountering Token ->"+r.image+"<- The Mode Stack is empty"},buildUnexpectedCharactersMessage:function(r,e,t,i,n){return"unexpected character: ->"+r.charAt(e)+"<- at offset: "+e+","+(" skipped "+t+" characters.")}}});var jd=y(kc=>{"use strict";Object.defineProperty(kc,"__esModule",{value:!0});kc.Lexer=kc.LexerDefinitionErrorType=void 0;var Vs=Ix(),nr=Gt(),Iye=pf(),yye=xx(),wye=Py(),Bye;(function(r){r[r.MISSING_PATTERN=0]="MISSING_PATTERN",r[r.INVALID_PATTERN=1]="INVALID_PATTERN",r[r.EOI_ANCHOR_FOUND=2]="EOI_ANCHOR_FOUND",r[r.UNSUPPORTED_FLAGS_FOUND=3]="UNSUPPORTED_FLAGS_FOUND",r[r.DUPLICATE_PATTERNS_FOUND=4]="DUPLICATE_PATTERNS_FOUND",r[r.INVALID_GROUP_TYPE_FOUND=5]="INVALID_GROUP_TYPE_FOUND",r[r.PUSH_MODE_DOES_NOT_EXIST=6]="PUSH_MODE_DOES_NOT_EXIST",r[r.MULTI_MODE_LEXER_WITHOUT_DEFAULT_MODE=7]="MULTI_MODE_LEXER_WITHOUT_DEFAULT_MODE",r[r.MULTI_MODE_LEXER_WITHOUT_MODES_PROPERTY=8]="MULTI_MODE_LEXER_WITHOUT_MODES_PROPERTY",r[r.MULTI_MODE_LEXER_DEFAULT_MODE_VALUE_DOES_NOT_EXIST=9]="MULTI_MODE_LEXER_DEFAULT_MODE_VALUE_DOES_NOT_EXIST",r[r.LEXER_DEFINITION_CANNOT_CONTAIN_UNDEFINED=10]="LEXER_DEFINITION_CANNOT_CONTAIN_UNDEFINED",r[r.SOI_ANCHOR_FOUND=11]="SOI_ANCHOR_FOUND",r[r.EMPTY_MATCH_PATTERN=12]="EMPTY_MATCH_PATTERN",r[r.NO_LINE_BREAKS_FLAGS=13]="NO_LINE_BREAKS_FLAGS",r[r.UNREACHABLE_PATTERN=14]="UNREACHABLE_PATTERN",r[r.IDENTIFY_TERMINATOR=15]="IDENTIFY_TERMINATOR",r[r.CUSTOM_LINE_BREAK=16]="CUSTOM_LINE_BREAK"})(Bye=kc.LexerDefinitionErrorType||(kc.LexerDefinitionErrorType={}));var qd={deferDefinitionErrorsHandling:!1,positionTracking:"full",lineTerminatorsPattern:/\n|\r\n?/g,lineTerminatorCharacters:[` -`,"\r"],ensureOptimizations:!1,safeMode:!1,errorMessageProvider:yye.defaultLexerErrorProvider,traceInitPerf:!1,skipValidations:!1};Object.freeze(qd);var Qye=function(){function r(e,t){var i=this;if(t===void 0&&(t=qd),this.lexerDefinition=e,this.lexerDefinitionErrors=[],this.lexerDefinitionWarning=[],this.patternIdxToConfig={},this.charCodeToPatternIdxToConfig={},this.modes=[],this.emptyGroups={},this.config=void 0,this.trackStartLines=!0,this.trackEndLines=!0,this.hasCustom=!1,this.canModeBeOptimized={},typeof t=="boolean")throw Error(`The second argument to the Lexer constructor is now an ILexerConfig Object. -a boolean 2nd argument is no longer supported`);this.config=(0,nr.merge)(qd,t);var n=this.config.traceInitPerf;n===!0?(this.traceInitMaxIdent=1/0,this.traceInitPerf=!0):typeof n=="number"&&(this.traceInitMaxIdent=n,this.traceInitPerf=!0),this.traceInitIndent=-1,this.TRACE_INIT("Lexer Constructor",function(){var s,o=!0;i.TRACE_INIT("Lexer Config handling",function(){if(i.config.lineTerminatorsPattern===qd.lineTerminatorsPattern)i.config.lineTerminatorsPattern=Vs.LineTerminatorOptimizedTester;else if(i.config.lineTerminatorCharacters===qd.lineTerminatorCharacters)throw Error(`Error: Missing property on the Lexer config. - For details See: https://chevrotain.io/docs/guide/resolving_lexer_errors.html#MISSING_LINE_TERM_CHARS`);if(t.safeMode&&t.ensureOptimizations)throw Error('"safeMode" and "ensureOptimizations" flags are mutually exclusive.');i.trackStartLines=/full|onlyStart/i.test(i.config.positionTracking),i.trackEndLines=/full/i.test(i.config.positionTracking),(0,nr.isArray)(e)?(s={modes:{}},s.modes[Vs.DEFAULT_MODE]=(0,nr.cloneArr)(e),s[Vs.DEFAULT_MODE]=Vs.DEFAULT_MODE):(o=!1,s=(0,nr.cloneObj)(e))}),i.config.skipValidations===!1&&(i.TRACE_INIT("performRuntimeChecks",function(){i.lexerDefinitionErrors=i.lexerDefinitionErrors.concat((0,Vs.performRuntimeChecks)(s,i.trackStartLines,i.config.lineTerminatorCharacters))}),i.TRACE_INIT("performWarningRuntimeChecks",function(){i.lexerDefinitionWarning=i.lexerDefinitionWarning.concat((0,Vs.performWarningRuntimeChecks)(s,i.trackStartLines,i.config.lineTerminatorCharacters))})),s.modes=s.modes?s.modes:{},(0,nr.forEach)(s.modes,function(u,g){s.modes[g]=(0,nr.reject)(u,function(f){return(0,nr.isUndefined)(f)})});var a=(0,nr.keys)(s.modes);if((0,nr.forEach)(s.modes,function(u,g){i.TRACE_INIT("Mode: <"+g+"> processing",function(){if(i.modes.push(g),i.config.skipValidations===!1&&i.TRACE_INIT("validatePatterns",function(){i.lexerDefinitionErrors=i.lexerDefinitionErrors.concat((0,Vs.validatePatterns)(u,a))}),(0,nr.isEmpty)(i.lexerDefinitionErrors)){(0,Iye.augmentTokenTypes)(u);var f;i.TRACE_INIT("analyzeTokenTypes",function(){f=(0,Vs.analyzeTokenTypes)(u,{lineTerminatorCharacters:i.config.lineTerminatorCharacters,positionTracking:t.positionTracking,ensureOptimizations:t.ensureOptimizations,safeMode:t.safeMode,tracer:i.TRACE_INIT.bind(i)})}),i.patternIdxToConfig[g]=f.patternIdxToConfig,i.charCodeToPatternIdxToConfig[g]=f.charCodeToPatternIdxToConfig,i.emptyGroups=(0,nr.merge)(i.emptyGroups,f.emptyGroups),i.hasCustom=f.hasCustom||i.hasCustom,i.canModeBeOptimized[g]=f.canBeOptimized}})}),i.defaultMode=s.defaultMode,!(0,nr.isEmpty)(i.lexerDefinitionErrors)&&!i.config.deferDefinitionErrorsHandling){var l=(0,nr.map)(i.lexerDefinitionErrors,function(u){return u.message}),c=l.join(`----------------------- -`);throw new Error(`Errors detected in definition of Lexer: -`+c)}(0,nr.forEach)(i.lexerDefinitionWarning,function(u){(0,nr.PRINT_WARNING)(u.message)}),i.TRACE_INIT("Choosing sub-methods implementations",function(){if(Vs.SUPPORT_STICKY?(i.chopInput=nr.IDENTITY,i.match=i.matchWithTest):(i.updateLastIndex=nr.NOOP,i.match=i.matchWithExec),o&&(i.handleModes=nr.NOOP),i.trackStartLines===!1&&(i.computeNewColumn=nr.IDENTITY),i.trackEndLines===!1&&(i.updateTokenEndLineColumnLocation=nr.NOOP),/full/i.test(i.config.positionTracking))i.createTokenInstance=i.createFullToken;else if(/onlyStart/i.test(i.config.positionTracking))i.createTokenInstance=i.createStartOnlyToken;else if(/onlyOffset/i.test(i.config.positionTracking))i.createTokenInstance=i.createOffsetOnlyToken;else throw Error('Invalid config option: "'+i.config.positionTracking+'"');i.hasCustom?(i.addToken=i.addTokenUsingPush,i.handlePayload=i.handlePayloadWithCustom):(i.addToken=i.addTokenUsingMemberAccess,i.handlePayload=i.handlePayloadNoCustom)}),i.TRACE_INIT("Failed Optimization Warnings",function(){var u=(0,nr.reduce)(i.canModeBeOptimized,function(g,f,h){return f===!1&&g.push(h),g},[]);if(t.ensureOptimizations&&!(0,nr.isEmpty)(u))throw Error("Lexer Modes: < "+u.join(", ")+` > cannot be optimized. - Disable the "ensureOptimizations" lexer config flag to silently ignore this and run the lexer in an un-optimized mode. - Or inspect the console log for details on how to resolve these issues.`)}),i.TRACE_INIT("clearRegExpParserCache",function(){(0,wye.clearRegExpParserCache)()}),i.TRACE_INIT("toFastProperties",function(){(0,nr.toFastProperties)(i)})})}return r.prototype.tokenize=function(e,t){if(t===void 0&&(t=this.defaultMode),!(0,nr.isEmpty)(this.lexerDefinitionErrors)){var i=(0,nr.map)(this.lexerDefinitionErrors,function(o){return o.message}),n=i.join(`----------------------- -`);throw new Error(`Unable to Tokenize because Errors detected in definition of Lexer: -`+n)}var s=this.tokenizeInternal(e,t);return s},r.prototype.tokenizeInternal=function(e,t){var i=this,n,s,o,a,l,c,u,g,f,h,p,C,w,B,v,D,L=e,H=L.length,j=0,$=0,V=this.hasCustom?0:Math.floor(e.length/10),W=new Array(V),Z=[],A=this.trackStartLines?1:void 0,ae=this.trackStartLines?1:void 0,ge=(0,Vs.cloneEmptyGroups)(this.emptyGroups),_=this.trackStartLines,T=this.config.lineTerminatorsPattern,N=0,ue=[],we=[],Le=[],Pe=[];Object.freeze(Pe);var Te=void 0;function se(){return ue}function Ae(dr){var Bi=(0,Vs.charCodeToOptimizedIndex)(dr),_n=we[Bi];return _n===void 0?Pe:_n}var Qe=function(dr){if(Le.length===1&&dr.tokenType.PUSH_MODE===void 0){var Bi=i.config.errorMessageProvider.buildUnableToPopLexerModeMessage(dr);Z.push({offset:dr.startOffset,line:dr.startLine!==void 0?dr.startLine:void 0,column:dr.startColumn!==void 0?dr.startColumn:void 0,length:dr.image.length,message:Bi})}else{Le.pop();var _n=(0,nr.last)(Le);ue=i.patternIdxToConfig[_n],we=i.charCodeToPatternIdxToConfig[_n],N=ue.length;var ha=i.canModeBeOptimized[_n]&&i.config.safeMode===!1;we&&ha?Te=Ae:Te=se}};function fe(dr){Le.push(dr),we=this.charCodeToPatternIdxToConfig[dr],ue=this.patternIdxToConfig[dr],N=ue.length,N=ue.length;var Bi=this.canModeBeOptimized[dr]&&this.config.safeMode===!1;we&&Bi?Te=Ae:Te=se}fe.call(this,t);for(var le;jc.length){c=a,u=g,le=tt;break}}}break}}if(c!==null){if(f=c.length,h=le.group,h!==void 0&&(p=le.tokenTypeIdx,C=this.createTokenInstance(c,j,p,le.tokenType,A,ae,f),this.handlePayload(C,u),h===!1?$=this.addToken(W,$,C):ge[h].push(C)),e=this.chopInput(e,f),j=j+f,ae=this.computeNewColumn(ae,f),_===!0&&le.canLineTerminator===!0){var It=0,Kr=void 0,oi=void 0;T.lastIndex=0;do Kr=T.test(c),Kr===!0&&(oi=T.lastIndex-1,It++);while(Kr===!0);It!==0&&(A=A+It,ae=f-oi,this.updateTokenEndLineColumnLocation(C,h,oi,It,A,ae,f))}this.handleModes(le,Qe,fe,C)}else{for(var pi=j,pr=A,di=ae,ai=!1;!ai&&j <"+e+">");var n=(0,nr.timer)(t),s=n.time,o=n.value,a=s>10?console.warn:console.log;return this.traceInitIndent time: "+s+"ms"),this.traceInitIndent--,o}else return t()},r.SKIPPED="This marks a skipped Token pattern, this means each token identified by it willbe consumed and then thrown into oblivion, this can be used to for example to completely ignore whitespace.",r.NA=/NOT_APPLICABLE/,r}();kc.Lexer=Qye});var UA=y(Si=>{"use strict";Object.defineProperty(Si,"__esModule",{value:!0});Si.tokenMatcher=Si.createTokenInstance=Si.EOF=Si.createToken=Si.hasTokenLabel=Si.tokenName=Si.tokenLabel=void 0;var Xs=Gt(),bye=jd(),Px=pf();function Sye(r){return mq(r)?r.LABEL:r.name}Si.tokenLabel=Sye;function vye(r){return r.name}Si.tokenName=vye;function mq(r){return(0,Xs.isString)(r.LABEL)&&r.LABEL!==""}Si.hasTokenLabel=mq;var xye="parent",cq="categories",uq="label",gq="group",fq="push_mode",hq="pop_mode",pq="longer_alt",dq="line_breaks",Cq="start_chars_hint";function Eq(r){return Pye(r)}Si.createToken=Eq;function Pye(r){var e=r.pattern,t={};if(t.name=r.name,(0,Xs.isUndefined)(e)||(t.PATTERN=e),(0,Xs.has)(r,xye))throw`The parent property is no longer supported. -See: https://github.com/chevrotain/chevrotain/issues/564#issuecomment-349062346 for details.`;return(0,Xs.has)(r,cq)&&(t.CATEGORIES=r[cq]),(0,Px.augmentTokenTypes)([t]),(0,Xs.has)(r,uq)&&(t.LABEL=r[uq]),(0,Xs.has)(r,gq)&&(t.GROUP=r[gq]),(0,Xs.has)(r,hq)&&(t.POP_MODE=r[hq]),(0,Xs.has)(r,fq)&&(t.PUSH_MODE=r[fq]),(0,Xs.has)(r,pq)&&(t.LONGER_ALT=r[pq]),(0,Xs.has)(r,dq)&&(t.LINE_BREAKS=r[dq]),(0,Xs.has)(r,Cq)&&(t.START_CHARS_HINT=r[Cq]),t}Si.EOF=Eq({name:"EOF",pattern:bye.Lexer.NA});(0,Px.augmentTokenTypes)([Si.EOF]);function Dye(r,e,t,i,n,s,o,a){return{image:e,startOffset:t,endOffset:i,startLine:n,endLine:s,startColumn:o,endColumn:a,tokenTypeIdx:r.tokenTypeIdx,tokenType:r}}Si.createTokenInstance=Dye;function kye(r,e){return(0,Px.tokenStructuredMatcher)(r,e)}Si.tokenMatcher=kye});var Cn=y(Wt=>{"use strict";var Da=Wt&&Wt.__extends||function(){var r=function(e,t){return r=Object.setPrototypeOf||{__proto__:[]}instanceof Array&&function(i,n){i.__proto__=n}||function(i,n){for(var s in n)Object.prototype.hasOwnProperty.call(n,s)&&(i[s]=n[s])},r(e,t)};return function(e,t){if(typeof t!="function"&&t!==null)throw new TypeError("Class extends value "+String(t)+" is not a constructor or null");r(e,t);function i(){this.constructor=e}e.prototype=t===null?Object.create(t):(i.prototype=t.prototype,new i)}}();Object.defineProperty(Wt,"__esModule",{value:!0});Wt.serializeProduction=Wt.serializeGrammar=Wt.Terminal=Wt.Alternation=Wt.RepetitionWithSeparator=Wt.Repetition=Wt.RepetitionMandatoryWithSeparator=Wt.RepetitionMandatory=Wt.Option=Wt.Alternative=Wt.Rule=Wt.NonTerminal=Wt.AbstractProduction=void 0;var lr=Gt(),Rye=UA(),ko=function(){function r(e){this._definition=e}return Object.defineProperty(r.prototype,"definition",{get:function(){return this._definition},set:function(e){this._definition=e},enumerable:!1,configurable:!0}),r.prototype.accept=function(e){e.visit(this),(0,lr.forEach)(this.definition,function(t){t.accept(e)})},r}();Wt.AbstractProduction=ko;var Iq=function(r){Da(e,r);function e(t){var i=r.call(this,[])||this;return i.idx=1,(0,lr.assign)(i,(0,lr.pick)(t,function(n){return n!==void 0})),i}return Object.defineProperty(e.prototype,"definition",{get:function(){return this.referencedRule!==void 0?this.referencedRule.definition:[]},set:function(t){},enumerable:!1,configurable:!0}),e.prototype.accept=function(t){t.visit(this)},e}(ko);Wt.NonTerminal=Iq;var yq=function(r){Da(e,r);function e(t){var i=r.call(this,t.definition)||this;return i.orgText="",(0,lr.assign)(i,(0,lr.pick)(t,function(n){return n!==void 0})),i}return e}(ko);Wt.Rule=yq;var wq=function(r){Da(e,r);function e(t){var i=r.call(this,t.definition)||this;return i.ignoreAmbiguities=!1,(0,lr.assign)(i,(0,lr.pick)(t,function(n){return n!==void 0})),i}return e}(ko);Wt.Alternative=wq;var Bq=function(r){Da(e,r);function e(t){var i=r.call(this,t.definition)||this;return i.idx=1,(0,lr.assign)(i,(0,lr.pick)(t,function(n){return n!==void 0})),i}return e}(ko);Wt.Option=Bq;var Qq=function(r){Da(e,r);function e(t){var i=r.call(this,t.definition)||this;return i.idx=1,(0,lr.assign)(i,(0,lr.pick)(t,function(n){return n!==void 0})),i}return e}(ko);Wt.RepetitionMandatory=Qq;var bq=function(r){Da(e,r);function e(t){var i=r.call(this,t.definition)||this;return i.idx=1,(0,lr.assign)(i,(0,lr.pick)(t,function(n){return n!==void 0})),i}return e}(ko);Wt.RepetitionMandatoryWithSeparator=bq;var Sq=function(r){Da(e,r);function e(t){var i=r.call(this,t.definition)||this;return i.idx=1,(0,lr.assign)(i,(0,lr.pick)(t,function(n){return n!==void 0})),i}return e}(ko);Wt.Repetition=Sq;var vq=function(r){Da(e,r);function e(t){var i=r.call(this,t.definition)||this;return i.idx=1,(0,lr.assign)(i,(0,lr.pick)(t,function(n){return n!==void 0})),i}return e}(ko);Wt.RepetitionWithSeparator=vq;var xq=function(r){Da(e,r);function e(t){var i=r.call(this,t.definition)||this;return i.idx=1,i.ignoreAmbiguities=!1,i.hasPredicates=!1,(0,lr.assign)(i,(0,lr.pick)(t,function(n){return n!==void 0})),i}return Object.defineProperty(e.prototype,"definition",{get:function(){return this._definition},set:function(t){this._definition=t},enumerable:!1,configurable:!0}),e}(ko);Wt.Alternation=xq;var Ny=function(){function r(e){this.idx=1,(0,lr.assign)(this,(0,lr.pick)(e,function(t){return t!==void 0}))}return r.prototype.accept=function(e){e.visit(this)},r}();Wt.Terminal=Ny;function Fye(r){return(0,lr.map)(r,Jd)}Wt.serializeGrammar=Fye;function Jd(r){function e(s){return(0,lr.map)(s,Jd)}if(r instanceof Iq){var t={type:"NonTerminal",name:r.nonTerminalName,idx:r.idx};return(0,lr.isString)(r.label)&&(t.label=r.label),t}else{if(r instanceof wq)return{type:"Alternative",definition:e(r.definition)};if(r instanceof Bq)return{type:"Option",idx:r.idx,definition:e(r.definition)};if(r instanceof Qq)return{type:"RepetitionMandatory",idx:r.idx,definition:e(r.definition)};if(r instanceof bq)return{type:"RepetitionMandatoryWithSeparator",idx:r.idx,separator:Jd(new Ny({terminalType:r.separator})),definition:e(r.definition)};if(r instanceof vq)return{type:"RepetitionWithSeparator",idx:r.idx,separator:Jd(new Ny({terminalType:r.separator})),definition:e(r.definition)};if(r instanceof Sq)return{type:"Repetition",idx:r.idx,definition:e(r.definition)};if(r instanceof xq)return{type:"Alternation",idx:r.idx,definition:e(r.definition)};if(r instanceof Ny){var i={type:"Terminal",name:r.terminalType.name,label:(0,Rye.tokenLabel)(r.terminalType),idx:r.idx};(0,lr.isString)(r.label)&&(i.terminalLabel=r.label);var n=r.terminalType.PATTERN;return r.terminalType.PATTERN&&(i.pattern=(0,lr.isRegExp)(n)?n.source:n),i}else{if(r instanceof yq)return{type:"Rule",name:r.name,orgText:r.orgText,definition:e(r.definition)};throw Error("non exhaustive match")}}}Wt.serializeProduction=Jd});var Ty=y(Ly=>{"use strict";Object.defineProperty(Ly,"__esModule",{value:!0});Ly.RestWalker=void 0;var Dx=Gt(),mn=Cn(),Nye=function(){function r(){}return r.prototype.walk=function(e,t){var i=this;t===void 0&&(t=[]),(0,Dx.forEach)(e.definition,function(n,s){var o=(0,Dx.drop)(e.definition,s+1);if(n instanceof mn.NonTerminal)i.walkProdRef(n,o,t);else if(n instanceof mn.Terminal)i.walkTerminal(n,o,t);else if(n instanceof mn.Alternative)i.walkFlat(n,o,t);else if(n instanceof mn.Option)i.walkOption(n,o,t);else if(n instanceof mn.RepetitionMandatory)i.walkAtLeastOne(n,o,t);else if(n instanceof mn.RepetitionMandatoryWithSeparator)i.walkAtLeastOneSep(n,o,t);else if(n instanceof mn.RepetitionWithSeparator)i.walkManySep(n,o,t);else if(n instanceof mn.Repetition)i.walkMany(n,o,t);else if(n instanceof mn.Alternation)i.walkOr(n,o,t);else throw Error("non exhaustive match")})},r.prototype.walkTerminal=function(e,t,i){},r.prototype.walkProdRef=function(e,t,i){},r.prototype.walkFlat=function(e,t,i){var n=t.concat(i);this.walk(e,n)},r.prototype.walkOption=function(e,t,i){var n=t.concat(i);this.walk(e,n)},r.prototype.walkAtLeastOne=function(e,t,i){var n=[new mn.Option({definition:e.definition})].concat(t,i);this.walk(e,n)},r.prototype.walkAtLeastOneSep=function(e,t,i){var n=Pq(e,t,i);this.walk(e,n)},r.prototype.walkMany=function(e,t,i){var n=[new mn.Option({definition:e.definition})].concat(t,i);this.walk(e,n)},r.prototype.walkManySep=function(e,t,i){var n=Pq(e,t,i);this.walk(e,n)},r.prototype.walkOr=function(e,t,i){var n=this,s=t.concat(i);(0,Dx.forEach)(e.definition,function(o){var a=new mn.Alternative({definition:[o]});n.walk(a,s)})},r}();Ly.RestWalker=Nye;function Pq(r,e,t){var i=[new mn.Option({definition:[new mn.Terminal({terminalType:r.separator})].concat(r.definition)})],n=i.concat(e,t);return n}});var df=y(Oy=>{"use strict";Object.defineProperty(Oy,"__esModule",{value:!0});Oy.GAstVisitor=void 0;var Ro=Cn(),Lye=function(){function r(){}return r.prototype.visit=function(e){var t=e;switch(t.constructor){case Ro.NonTerminal:return this.visitNonTerminal(t);case Ro.Alternative:return this.visitAlternative(t);case Ro.Option:return this.visitOption(t);case Ro.RepetitionMandatory:return this.visitRepetitionMandatory(t);case Ro.RepetitionMandatoryWithSeparator:return this.visitRepetitionMandatoryWithSeparator(t);case Ro.RepetitionWithSeparator:return this.visitRepetitionWithSeparator(t);case Ro.Repetition:return this.visitRepetition(t);case Ro.Alternation:return this.visitAlternation(t);case Ro.Terminal:return this.visitTerminal(t);case Ro.Rule:return this.visitRule(t);default:throw Error("non exhaustive match")}},r.prototype.visitNonTerminal=function(e){},r.prototype.visitAlternative=function(e){},r.prototype.visitOption=function(e){},r.prototype.visitRepetition=function(e){},r.prototype.visitRepetitionMandatory=function(e){},r.prototype.visitRepetitionMandatoryWithSeparator=function(e){},r.prototype.visitRepetitionWithSeparator=function(e){},r.prototype.visitAlternation=function(e){},r.prototype.visitTerminal=function(e){},r.prototype.visitRule=function(e){},r}();Oy.GAstVisitor=Lye});var zd=y(Ki=>{"use strict";var Tye=Ki&&Ki.__extends||function(){var r=function(e,t){return r=Object.setPrototypeOf||{__proto__:[]}instanceof Array&&function(i,n){i.__proto__=n}||function(i,n){for(var s in n)Object.prototype.hasOwnProperty.call(n,s)&&(i[s]=n[s])},r(e,t)};return function(e,t){if(typeof t!="function"&&t!==null)throw new TypeError("Class extends value "+String(t)+" is not a constructor or null");r(e,t);function i(){this.constructor=e}e.prototype=t===null?Object.create(t):(i.prototype=t.prototype,new i)}}();Object.defineProperty(Ki,"__esModule",{value:!0});Ki.collectMethods=Ki.DslMethodsCollectorVisitor=Ki.getProductionDslName=Ki.isBranchingProd=Ki.isOptionalProd=Ki.isSequenceProd=void 0;var Wd=Gt(),Qr=Cn(),Oye=df();function Mye(r){return r instanceof Qr.Alternative||r instanceof Qr.Option||r instanceof Qr.Repetition||r instanceof Qr.RepetitionMandatory||r instanceof Qr.RepetitionMandatoryWithSeparator||r instanceof Qr.RepetitionWithSeparator||r instanceof Qr.Terminal||r instanceof Qr.Rule}Ki.isSequenceProd=Mye;function kx(r,e){e===void 0&&(e=[]);var t=r instanceof Qr.Option||r instanceof Qr.Repetition||r instanceof Qr.RepetitionWithSeparator;return t?!0:r instanceof Qr.Alternation?(0,Wd.some)(r.definition,function(i){return kx(i,e)}):r instanceof Qr.NonTerminal&&(0,Wd.contains)(e,r)?!1:r instanceof Qr.AbstractProduction?(r instanceof Qr.NonTerminal&&e.push(r),(0,Wd.every)(r.definition,function(i){return kx(i,e)})):!1}Ki.isOptionalProd=kx;function Kye(r){return r instanceof Qr.Alternation}Ki.isBranchingProd=Kye;function Uye(r){if(r instanceof Qr.NonTerminal)return"SUBRULE";if(r instanceof Qr.Option)return"OPTION";if(r instanceof Qr.Alternation)return"OR";if(r instanceof Qr.RepetitionMandatory)return"AT_LEAST_ONE";if(r instanceof Qr.RepetitionMandatoryWithSeparator)return"AT_LEAST_ONE_SEP";if(r instanceof Qr.RepetitionWithSeparator)return"MANY_SEP";if(r instanceof Qr.Repetition)return"MANY";if(r instanceof Qr.Terminal)return"CONSUME";throw Error("non exhaustive match")}Ki.getProductionDslName=Uye;var Dq=function(r){Tye(e,r);function e(){var t=r!==null&&r.apply(this,arguments)||this;return t.separator="-",t.dslMethods={option:[],alternation:[],repetition:[],repetitionWithSeparator:[],repetitionMandatory:[],repetitionMandatoryWithSeparator:[]},t}return e.prototype.reset=function(){this.dslMethods={option:[],alternation:[],repetition:[],repetitionWithSeparator:[],repetitionMandatory:[],repetitionMandatoryWithSeparator:[]}},e.prototype.visitTerminal=function(t){var i=t.terminalType.name+this.separator+"Terminal";(0,Wd.has)(this.dslMethods,i)||(this.dslMethods[i]=[]),this.dslMethods[i].push(t)},e.prototype.visitNonTerminal=function(t){var i=t.nonTerminalName+this.separator+"Terminal";(0,Wd.has)(this.dslMethods,i)||(this.dslMethods[i]=[]),this.dslMethods[i].push(t)},e.prototype.visitOption=function(t){this.dslMethods.option.push(t)},e.prototype.visitRepetitionWithSeparator=function(t){this.dslMethods.repetitionWithSeparator.push(t)},e.prototype.visitRepetitionMandatory=function(t){this.dslMethods.repetitionMandatory.push(t)},e.prototype.visitRepetitionMandatoryWithSeparator=function(t){this.dslMethods.repetitionMandatoryWithSeparator.push(t)},e.prototype.visitRepetition=function(t){this.dslMethods.repetition.push(t)},e.prototype.visitAlternation=function(t){this.dslMethods.alternation.push(t)},e}(Oye.GAstVisitor);Ki.DslMethodsCollectorVisitor=Dq;var My=new Dq;function Hye(r){My.reset(),r.accept(My);var e=My.dslMethods;return My.reset(),e}Ki.collectMethods=Hye});var Fx=y(Fo=>{"use strict";Object.defineProperty(Fo,"__esModule",{value:!0});Fo.firstForTerminal=Fo.firstForBranching=Fo.firstForSequence=Fo.first=void 0;var Ky=Gt(),kq=Cn(),Rx=zd();function Uy(r){if(r instanceof kq.NonTerminal)return Uy(r.referencedRule);if(r instanceof kq.Terminal)return Nq(r);if((0,Rx.isSequenceProd)(r))return Rq(r);if((0,Rx.isBranchingProd)(r))return Fq(r);throw Error("non exhaustive match")}Fo.first=Uy;function Rq(r){for(var e=[],t=r.definition,i=0,n=t.length>i,s,o=!0;n&&o;)s=t[i],o=(0,Rx.isOptionalProd)(s),e=e.concat(Uy(s)),i=i+1,n=t.length>i;return(0,Ky.uniq)(e)}Fo.firstForSequence=Rq;function Fq(r){var e=(0,Ky.map)(r.definition,function(t){return Uy(t)});return(0,Ky.uniq)((0,Ky.flatten)(e))}Fo.firstForBranching=Fq;function Nq(r){return[r.terminalType]}Fo.firstForTerminal=Nq});var Nx=y(Hy=>{"use strict";Object.defineProperty(Hy,"__esModule",{value:!0});Hy.IN=void 0;Hy.IN="_~IN~_"});var Kq=y(ls=>{"use strict";var Gye=ls&&ls.__extends||function(){var r=function(e,t){return r=Object.setPrototypeOf||{__proto__:[]}instanceof Array&&function(i,n){i.__proto__=n}||function(i,n){for(var s in n)Object.prototype.hasOwnProperty.call(n,s)&&(i[s]=n[s])},r(e,t)};return function(e,t){if(typeof t!="function"&&t!==null)throw new TypeError("Class extends value "+String(t)+" is not a constructor or null");r(e,t);function i(){this.constructor=e}e.prototype=t===null?Object.create(t):(i.prototype=t.prototype,new i)}}();Object.defineProperty(ls,"__esModule",{value:!0});ls.buildInProdFollowPrefix=ls.buildBetweenProdsFollowPrefix=ls.computeAllProdsFollows=ls.ResyncFollowsWalker=void 0;var Yye=Ty(),jye=Fx(),Lq=Gt(),Tq=Nx(),qye=Cn(),Oq=function(r){Gye(e,r);function e(t){var i=r.call(this)||this;return i.topProd=t,i.follows={},i}return e.prototype.startWalking=function(){return this.walk(this.topProd),this.follows},e.prototype.walkTerminal=function(t,i,n){},e.prototype.walkProdRef=function(t,i,n){var s=Mq(t.referencedRule,t.idx)+this.topProd.name,o=i.concat(n),a=new qye.Alternative({definition:o}),l=(0,jye.first)(a);this.follows[s]=l},e}(Yye.RestWalker);ls.ResyncFollowsWalker=Oq;function Jye(r){var e={};return(0,Lq.forEach)(r,function(t){var i=new Oq(t).startWalking();(0,Lq.assign)(e,i)}),e}ls.computeAllProdsFollows=Jye;function Mq(r,e){return r.name+e+Tq.IN}ls.buildBetweenProdsFollowPrefix=Mq;function Wye(r){var e=r.terminalType.name;return e+r.idx+Tq.IN}ls.buildInProdFollowPrefix=Wye});var Vd=y(ka=>{"use strict";Object.defineProperty(ka,"__esModule",{value:!0});ka.defaultGrammarValidatorErrorProvider=ka.defaultGrammarResolverErrorProvider=ka.defaultParserErrorProvider=void 0;var Cf=UA(),zye=Gt(),_s=Gt(),Lx=Cn(),Uq=zd();ka.defaultParserErrorProvider={buildMismatchTokenMessage:function(r){var e=r.expected,t=r.actual,i=r.previous,n=r.ruleName,s=(0,Cf.hasTokenLabel)(e),o=s?"--> "+(0,Cf.tokenLabel)(e)+" <--":"token of type --> "+e.name+" <--",a="Expecting "+o+" but found --> '"+t.image+"' <--";return a},buildNotAllInputParsedMessage:function(r){var e=r.firstRedundant,t=r.ruleName;return"Redundant input, expecting EOF but found: "+e.image},buildNoViableAltMessage:function(r){var e=r.expectedPathsPerAlt,t=r.actual,i=r.previous,n=r.customUserDescription,s=r.ruleName,o="Expecting: ",a=(0,_s.first)(t).image,l=` -but found: '`+a+"'";if(n)return o+n+l;var c=(0,_s.reduce)(e,function(h,p){return h.concat(p)},[]),u=(0,_s.map)(c,function(h){return"["+(0,_s.map)(h,function(p){return(0,Cf.tokenLabel)(p)}).join(", ")+"]"}),g=(0,_s.map)(u,function(h,p){return" "+(p+1)+". "+h}),f=`one of these possible Token sequences: -`+g.join(` -`);return o+f+l},buildEarlyExitMessage:function(r){var e=r.expectedIterationPaths,t=r.actual,i=r.customUserDescription,n=r.ruleName,s="Expecting: ",o=(0,_s.first)(t).image,a=` -but found: '`+o+"'";if(i)return s+i+a;var l=(0,_s.map)(e,function(u){return"["+(0,_s.map)(u,function(g){return(0,Cf.tokenLabel)(g)}).join(",")+"]"}),c=`expecting at least one iteration which starts with one of these possible Token sequences:: - `+("<"+l.join(" ,")+">");return s+c+a}};Object.freeze(ka.defaultParserErrorProvider);ka.defaultGrammarResolverErrorProvider={buildRuleNotFoundError:function(r,e){var t="Invalid grammar, reference to a rule which is not defined: ->"+e.nonTerminalName+`<- -inside top level rule: ->`+r.name+"<-";return t}};ka.defaultGrammarValidatorErrorProvider={buildDuplicateFoundError:function(r,e){function t(u){return u instanceof Lx.Terminal?u.terminalType.name:u instanceof Lx.NonTerminal?u.nonTerminalName:""}var i=r.name,n=(0,_s.first)(e),s=n.idx,o=(0,Uq.getProductionDslName)(n),a=t(n),l=s>0,c="->"+o+(l?s:"")+"<- "+(a?"with argument: ->"+a+"<-":"")+` - appears more than once (`+e.length+" times) in the top level rule: ->"+i+`<-. - For further details see: https://chevrotain.io/docs/FAQ.html#NUMERICAL_SUFFIXES - `;return c=c.replace(/[ \t]+/g," "),c=c.replace(/\s\s+/g,` -`),c},buildNamespaceConflictError:function(r){var e=`Namespace conflict found in grammar. -`+("The grammar has both a Terminal(Token) and a Non-Terminal(Rule) named: <"+r.name+`>. -`)+`To resolve this make sure each Terminal and Non-Terminal names are unique -This is easy to accomplish by using the convention that Terminal names start with an uppercase letter -and Non-Terminal names start with a lower case letter.`;return e},buildAlternationPrefixAmbiguityError:function(r){var e=(0,_s.map)(r.prefixPath,function(n){return(0,Cf.tokenLabel)(n)}).join(", "),t=r.alternation.idx===0?"":r.alternation.idx,i="Ambiguous alternatives: <"+r.ambiguityIndices.join(" ,")+`> due to common lookahead prefix -`+("in inside <"+r.topLevelRule.name+`> Rule, -`)+("<"+e+`> may appears as a prefix path in all these alternatives. -`)+`See: https://chevrotain.io/docs/guide/resolving_grammar_errors.html#COMMON_PREFIX -For Further details.`;return i},buildAlternationAmbiguityError:function(r){var e=(0,_s.map)(r.prefixPath,function(n){return(0,Cf.tokenLabel)(n)}).join(", "),t=r.alternation.idx===0?"":r.alternation.idx,i="Ambiguous Alternatives Detected: <"+r.ambiguityIndices.join(" ,")+"> in "+(" inside <"+r.topLevelRule.name+`> Rule, -`)+("<"+e+`> may appears as a prefix path in all these alternatives. -`);return i=i+`See: https://chevrotain.io/docs/guide/resolving_grammar_errors.html#AMBIGUOUS_ALTERNATIVES -For Further details.`,i},buildEmptyRepetitionError:function(r){var e=(0,Uq.getProductionDslName)(r.repetition);r.repetition.idx!==0&&(e+=r.repetition.idx);var t="The repetition <"+e+"> within Rule <"+r.topLevelRule.name+`> can never consume any tokens. -This could lead to an infinite loop.`;return t},buildTokenNameError:function(r){return"deprecated"},buildEmptyAlternationError:function(r){var e="Ambiguous empty alternative: <"+(r.emptyChoiceIdx+1)+">"+(" in inside <"+r.topLevelRule.name+`> Rule. -`)+"Only the last alternative may be an empty alternative.";return e},buildTooManyAlternativesError:function(r){var e=`An Alternation cannot have more than 256 alternatives: -`+(" inside <"+r.topLevelRule.name+`> Rule. - has `+(r.alternation.definition.length+1)+" alternatives.");return e},buildLeftRecursionError:function(r){var e=r.topLevelRule.name,t=zye.map(r.leftRecursionPath,function(s){return s.name}),i=e+" --> "+t.concat([e]).join(" --> "),n=`Left Recursion found in grammar. -`+("rule: <"+e+`> can be invoked from itself (directly or indirectly) -`)+(`without consuming any Tokens. The grammar path that causes this is: - `+i+` -`)+` To fix this refactor your grammar to remove the left recursion. -see: https://en.wikipedia.org/wiki/LL_parser#Left_Factoring.`;return n},buildInvalidRuleNameError:function(r){return"deprecated"},buildDuplicateRuleNameError:function(r){var e;r.topLevelRule instanceof Lx.Rule?e=r.topLevelRule.name:e=r.topLevelRule;var t="Duplicate definition, rule: ->"+e+"<- is already defined in the grammar: ->"+r.grammarName+"<-";return t}}});var Yq=y(HA=>{"use strict";var Vye=HA&&HA.__extends||function(){var r=function(e,t){return r=Object.setPrototypeOf||{__proto__:[]}instanceof Array&&function(i,n){i.__proto__=n}||function(i,n){for(var s in n)Object.prototype.hasOwnProperty.call(n,s)&&(i[s]=n[s])},r(e,t)};return function(e,t){if(typeof t!="function"&&t!==null)throw new TypeError("Class extends value "+String(t)+" is not a constructor or null");r(e,t);function i(){this.constructor=e}e.prototype=t===null?Object.create(t):(i.prototype=t.prototype,new i)}}();Object.defineProperty(HA,"__esModule",{value:!0});HA.GastRefResolverVisitor=HA.resolveGrammar=void 0;var Xye=Hn(),Hq=Gt(),_ye=df();function Zye(r,e){var t=new Gq(r,e);return t.resolveRefs(),t.errors}HA.resolveGrammar=Zye;var Gq=function(r){Vye(e,r);function e(t,i){var n=r.call(this)||this;return n.nameToTopRule=t,n.errMsgProvider=i,n.errors=[],n}return e.prototype.resolveRefs=function(){var t=this;(0,Hq.forEach)((0,Hq.values)(this.nameToTopRule),function(i){t.currTopLevel=i,i.accept(t)})},e.prototype.visitNonTerminal=function(t){var i=this.nameToTopRule[t.nonTerminalName];if(i)t.referencedRule=i;else{var n=this.errMsgProvider.buildRuleNotFoundError(this.currTopLevel,t);this.errors.push({message:n,type:Xye.ParserDefinitionErrorType.UNRESOLVED_SUBRULE_REF,ruleName:this.currTopLevel.name,unresolvedRefName:t.nonTerminalName})}},e}(_ye.GAstVisitor);HA.GastRefResolverVisitor=Gq});var _d=y(Tr=>{"use strict";var Rc=Tr&&Tr.__extends||function(){var r=function(e,t){return r=Object.setPrototypeOf||{__proto__:[]}instanceof Array&&function(i,n){i.__proto__=n}||function(i,n){for(var s in n)Object.prototype.hasOwnProperty.call(n,s)&&(i[s]=n[s])},r(e,t)};return function(e,t){if(typeof t!="function"&&t!==null)throw new TypeError("Class extends value "+String(t)+" is not a constructor or null");r(e,t);function i(){this.constructor=e}e.prototype=t===null?Object.create(t):(i.prototype=t.prototype,new i)}}();Object.defineProperty(Tr,"__esModule",{value:!0});Tr.nextPossibleTokensAfter=Tr.possiblePathsFrom=Tr.NextTerminalAfterAtLeastOneSepWalker=Tr.NextTerminalAfterAtLeastOneWalker=Tr.NextTerminalAfterManySepWalker=Tr.NextTerminalAfterManyWalker=Tr.AbstractNextTerminalAfterProductionWalker=Tr.NextAfterTokenWalker=Tr.AbstractNextPossibleTokensWalker=void 0;var jq=Ty(),Kt=Gt(),$ye=Fx(),Dt=Cn(),qq=function(r){Rc(e,r);function e(t,i){var n=r.call(this)||this;return n.topProd=t,n.path=i,n.possibleTokTypes=[],n.nextProductionName="",n.nextProductionOccurrence=0,n.found=!1,n.isAtEndOfPath=!1,n}return e.prototype.startWalking=function(){if(this.found=!1,this.path.ruleStack[0]!==this.topProd.name)throw Error("The path does not start with the walker's top Rule!");return this.ruleStack=(0,Kt.cloneArr)(this.path.ruleStack).reverse(),this.occurrenceStack=(0,Kt.cloneArr)(this.path.occurrenceStack).reverse(),this.ruleStack.pop(),this.occurrenceStack.pop(),this.updateExpectedNext(),this.walk(this.topProd),this.possibleTokTypes},e.prototype.walk=function(t,i){i===void 0&&(i=[]),this.found||r.prototype.walk.call(this,t,i)},e.prototype.walkProdRef=function(t,i,n){if(t.referencedRule.name===this.nextProductionName&&t.idx===this.nextProductionOccurrence){var s=i.concat(n);this.updateExpectedNext(),this.walk(t.referencedRule,s)}},e.prototype.updateExpectedNext=function(){(0,Kt.isEmpty)(this.ruleStack)?(this.nextProductionName="",this.nextProductionOccurrence=0,this.isAtEndOfPath=!0):(this.nextProductionName=this.ruleStack.pop(),this.nextProductionOccurrence=this.occurrenceStack.pop())},e}(jq.RestWalker);Tr.AbstractNextPossibleTokensWalker=qq;var ewe=function(r){Rc(e,r);function e(t,i){var n=r.call(this,t,i)||this;return n.path=i,n.nextTerminalName="",n.nextTerminalOccurrence=0,n.nextTerminalName=n.path.lastTok.name,n.nextTerminalOccurrence=n.path.lastTokOccurrence,n}return e.prototype.walkTerminal=function(t,i,n){if(this.isAtEndOfPath&&t.terminalType.name===this.nextTerminalName&&t.idx===this.nextTerminalOccurrence&&!this.found){var s=i.concat(n),o=new Dt.Alternative({definition:s});this.possibleTokTypes=(0,$ye.first)(o),this.found=!0}},e}(qq);Tr.NextAfterTokenWalker=ewe;var Xd=function(r){Rc(e,r);function e(t,i){var n=r.call(this)||this;return n.topRule=t,n.occurrence=i,n.result={token:void 0,occurrence:void 0,isEndOfRule:void 0},n}return e.prototype.startWalking=function(){return this.walk(this.topRule),this.result},e}(jq.RestWalker);Tr.AbstractNextTerminalAfterProductionWalker=Xd;var twe=function(r){Rc(e,r);function e(){return r!==null&&r.apply(this,arguments)||this}return e.prototype.walkMany=function(t,i,n){if(t.idx===this.occurrence){var s=(0,Kt.first)(i.concat(n));this.result.isEndOfRule=s===void 0,s instanceof Dt.Terminal&&(this.result.token=s.terminalType,this.result.occurrence=s.idx)}else r.prototype.walkMany.call(this,t,i,n)},e}(Xd);Tr.NextTerminalAfterManyWalker=twe;var rwe=function(r){Rc(e,r);function e(){return r!==null&&r.apply(this,arguments)||this}return e.prototype.walkManySep=function(t,i,n){if(t.idx===this.occurrence){var s=(0,Kt.first)(i.concat(n));this.result.isEndOfRule=s===void 0,s instanceof Dt.Terminal&&(this.result.token=s.terminalType,this.result.occurrence=s.idx)}else r.prototype.walkManySep.call(this,t,i,n)},e}(Xd);Tr.NextTerminalAfterManySepWalker=rwe;var iwe=function(r){Rc(e,r);function e(){return r!==null&&r.apply(this,arguments)||this}return e.prototype.walkAtLeastOne=function(t,i,n){if(t.idx===this.occurrence){var s=(0,Kt.first)(i.concat(n));this.result.isEndOfRule=s===void 0,s instanceof Dt.Terminal&&(this.result.token=s.terminalType,this.result.occurrence=s.idx)}else r.prototype.walkAtLeastOne.call(this,t,i,n)},e}(Xd);Tr.NextTerminalAfterAtLeastOneWalker=iwe;var nwe=function(r){Rc(e,r);function e(){return r!==null&&r.apply(this,arguments)||this}return e.prototype.walkAtLeastOneSep=function(t,i,n){if(t.idx===this.occurrence){var s=(0,Kt.first)(i.concat(n));this.result.isEndOfRule=s===void 0,s instanceof Dt.Terminal&&(this.result.token=s.terminalType,this.result.occurrence=s.idx)}else r.prototype.walkAtLeastOneSep.call(this,t,i,n)},e}(Xd);Tr.NextTerminalAfterAtLeastOneSepWalker=nwe;function Jq(r,e,t){t===void 0&&(t=[]),t=(0,Kt.cloneArr)(t);var i=[],n=0;function s(c){return c.concat((0,Kt.drop)(r,n+1))}function o(c){var u=Jq(s(c),e,t);return i.concat(u)}for(;t.length=0;ge--){var _=B.definition[ge],T={idx:p,def:_.definition.concat((0,Kt.drop)(h)),ruleStack:C,occurrenceStack:w};g.push(T),g.push(o)}else if(B instanceof Dt.Alternative)g.push({idx:p,def:B.definition.concat((0,Kt.drop)(h)),ruleStack:C,occurrenceStack:w});else if(B instanceof Dt.Rule)g.push(owe(B,p,C,w));else throw Error("non exhaustive match")}}return u}Tr.nextPossibleTokensAfter=swe;function owe(r,e,t,i){var n=(0,Kt.cloneArr)(t);n.push(r.name);var s=(0,Kt.cloneArr)(i);return s.push(1),{idx:e,def:r.definition,ruleStack:n,occurrenceStack:s}}});var Zd=y(_t=>{"use strict";var Vq=_t&&_t.__extends||function(){var r=function(e,t){return r=Object.setPrototypeOf||{__proto__:[]}instanceof Array&&function(i,n){i.__proto__=n}||function(i,n){for(var s in n)Object.prototype.hasOwnProperty.call(n,s)&&(i[s]=n[s])},r(e,t)};return function(e,t){if(typeof t!="function"&&t!==null)throw new TypeError("Class extends value "+String(t)+" is not a constructor or null");r(e,t);function i(){this.constructor=e}e.prototype=t===null?Object.create(t):(i.prototype=t.prototype,new i)}}();Object.defineProperty(_t,"__esModule",{value:!0});_t.areTokenCategoriesNotUsed=_t.isStrictPrefixOfPath=_t.containsPath=_t.getLookaheadPathsForOptionalProd=_t.getLookaheadPathsForOr=_t.lookAheadSequenceFromAlternatives=_t.buildSingleAlternativeLookaheadFunction=_t.buildAlternativesLookAheadFunc=_t.buildLookaheadFuncForOptionalProd=_t.buildLookaheadFuncForOr=_t.getProdType=_t.PROD_TYPE=void 0;var sr=Gt(),Wq=_d(),awe=Ty(),Gy=pf(),GA=Cn(),Awe=df(),li;(function(r){r[r.OPTION=0]="OPTION",r[r.REPETITION=1]="REPETITION",r[r.REPETITION_MANDATORY=2]="REPETITION_MANDATORY",r[r.REPETITION_MANDATORY_WITH_SEPARATOR=3]="REPETITION_MANDATORY_WITH_SEPARATOR",r[r.REPETITION_WITH_SEPARATOR=4]="REPETITION_WITH_SEPARATOR",r[r.ALTERNATION=5]="ALTERNATION"})(li=_t.PROD_TYPE||(_t.PROD_TYPE={}));function lwe(r){if(r instanceof GA.Option)return li.OPTION;if(r instanceof GA.Repetition)return li.REPETITION;if(r instanceof GA.RepetitionMandatory)return li.REPETITION_MANDATORY;if(r instanceof GA.RepetitionMandatoryWithSeparator)return li.REPETITION_MANDATORY_WITH_SEPARATOR;if(r instanceof GA.RepetitionWithSeparator)return li.REPETITION_WITH_SEPARATOR;if(r instanceof GA.Alternation)return li.ALTERNATION;throw Error("non exhaustive match")}_t.getProdType=lwe;function cwe(r,e,t,i,n,s){var o=_q(r,e,t),a=Mx(o)?Gy.tokenStructuredMatcherNoCategories:Gy.tokenStructuredMatcher;return s(o,i,a,n)}_t.buildLookaheadFuncForOr=cwe;function uwe(r,e,t,i,n,s){var o=Zq(r,e,n,t),a=Mx(o)?Gy.tokenStructuredMatcherNoCategories:Gy.tokenStructuredMatcher;return s(o[0],a,i)}_t.buildLookaheadFuncForOptionalProd=uwe;function gwe(r,e,t,i){var n=r.length,s=(0,sr.every)(r,function(l){return(0,sr.every)(l,function(c){return c.length===1})});if(e)return function(l){for(var c=(0,sr.map)(l,function(D){return D.GATE}),u=0;u{"use strict";var Kx=zt&&zt.__extends||function(){var r=function(e,t){return r=Object.setPrototypeOf||{__proto__:[]}instanceof Array&&function(i,n){i.__proto__=n}||function(i,n){for(var s in n)Object.prototype.hasOwnProperty.call(n,s)&&(i[s]=n[s])},r(e,t)};return function(e,t){if(typeof t!="function"&&t!==null)throw new TypeError("Class extends value "+String(t)+" is not a constructor or null");r(e,t);function i(){this.constructor=e}e.prototype=t===null?Object.create(t):(i.prototype=t.prototype,new i)}}();Object.defineProperty(zt,"__esModule",{value:!0});zt.checkPrefixAlternativesAmbiguities=zt.validateSomeNonEmptyLookaheadPath=zt.validateTooManyAlts=zt.RepetionCollector=zt.validateAmbiguousAlternationAlternatives=zt.validateEmptyOrAlternative=zt.getFirstNoneTerminal=zt.validateNoLeftRecursion=zt.validateRuleIsOverridden=zt.validateRuleDoesNotAlreadyExist=zt.OccurrenceValidationCollector=zt.identifyProductionForDuplicates=zt.validateGrammar=void 0;var er=Gt(),br=Gt(),No=Hn(),Ux=zd(),mf=Zd(),Cwe=_d(),Zs=Cn(),Hx=df();function mwe(r,e,t,i,n){var s=er.map(r,function(h){return Ewe(h,i)}),o=er.map(r,function(h){return Gx(h,h,i)}),a=[],l=[],c=[];(0,br.every)(o,br.isEmpty)&&(a=(0,br.map)(r,function(h){return nJ(h,i)}),l=(0,br.map)(r,function(h){return sJ(h,e,i)}),c=AJ(r,e,i));var u=wwe(r,t,i),g=(0,br.map)(r,function(h){return aJ(h,i)}),f=(0,br.map)(r,function(h){return iJ(h,r,n,i)});return er.flatten(s.concat(c,o,a,l,u,g,f))}zt.validateGrammar=mwe;function Ewe(r,e){var t=new rJ;r.accept(t);var i=t.allProductions,n=er.groupBy(i,eJ),s=er.pick(n,function(a){return a.length>1}),o=er.map(er.values(s),function(a){var l=er.first(a),c=e.buildDuplicateFoundError(r,a),u=(0,Ux.getProductionDslName)(l),g={message:c,type:No.ParserDefinitionErrorType.DUPLICATE_PRODUCTIONS,ruleName:r.name,dslName:u,occurrence:l.idx},f=tJ(l);return f&&(g.parameter=f),g});return o}function eJ(r){return(0,Ux.getProductionDslName)(r)+"_#_"+r.idx+"_#_"+tJ(r)}zt.identifyProductionForDuplicates=eJ;function tJ(r){return r instanceof Zs.Terminal?r.terminalType.name:r instanceof Zs.NonTerminal?r.nonTerminalName:""}var rJ=function(r){Kx(e,r);function e(){var t=r!==null&&r.apply(this,arguments)||this;return t.allProductions=[],t}return e.prototype.visitNonTerminal=function(t){this.allProductions.push(t)},e.prototype.visitOption=function(t){this.allProductions.push(t)},e.prototype.visitRepetitionWithSeparator=function(t){this.allProductions.push(t)},e.prototype.visitRepetitionMandatory=function(t){this.allProductions.push(t)},e.prototype.visitRepetitionMandatoryWithSeparator=function(t){this.allProductions.push(t)},e.prototype.visitRepetition=function(t){this.allProductions.push(t)},e.prototype.visitAlternation=function(t){this.allProductions.push(t)},e.prototype.visitTerminal=function(t){this.allProductions.push(t)},e}(Hx.GAstVisitor);zt.OccurrenceValidationCollector=rJ;function iJ(r,e,t,i){var n=[],s=(0,br.reduce)(e,function(a,l){return l.name===r.name?a+1:a},0);if(s>1){var o=i.buildDuplicateRuleNameError({topLevelRule:r,grammarName:t});n.push({message:o,type:No.ParserDefinitionErrorType.DUPLICATE_RULE_NAME,ruleName:r.name})}return n}zt.validateRuleDoesNotAlreadyExist=iJ;function Iwe(r,e,t){var i=[],n;return er.contains(e,r)||(n="Invalid rule override, rule: ->"+r+"<- cannot be overridden in the grammar: ->"+t+"<-as it is not defined in any of the super grammars ",i.push({message:n,type:No.ParserDefinitionErrorType.INVALID_RULE_OVERRIDE,ruleName:r})),i}zt.validateRuleIsOverridden=Iwe;function Gx(r,e,t,i){i===void 0&&(i=[]);var n=[],s=$d(e.definition);if(er.isEmpty(s))return[];var o=r.name,a=er.contains(s,r);a&&n.push({message:t.buildLeftRecursionError({topLevelRule:r,leftRecursionPath:i}),type:No.ParserDefinitionErrorType.LEFT_RECURSION,ruleName:o});var l=er.difference(s,i.concat([r])),c=er.map(l,function(u){var g=er.cloneArr(i);return g.push(u),Gx(r,u,t,g)});return n.concat(er.flatten(c))}zt.validateNoLeftRecursion=Gx;function $d(r){var e=[];if(er.isEmpty(r))return e;var t=er.first(r);if(t instanceof Zs.NonTerminal)e.push(t.referencedRule);else if(t instanceof Zs.Alternative||t instanceof Zs.Option||t instanceof Zs.RepetitionMandatory||t instanceof Zs.RepetitionMandatoryWithSeparator||t instanceof Zs.RepetitionWithSeparator||t instanceof Zs.Repetition)e=e.concat($d(t.definition));else if(t instanceof Zs.Alternation)e=er.flatten(er.map(t.definition,function(o){return $d(o.definition)}));else if(!(t instanceof Zs.Terminal))throw Error("non exhaustive match");var i=(0,Ux.isOptionalProd)(t),n=r.length>1;if(i&&n){var s=er.drop(r);return e.concat($d(s))}else return e}zt.getFirstNoneTerminal=$d;var Yx=function(r){Kx(e,r);function e(){var t=r!==null&&r.apply(this,arguments)||this;return t.alternations=[],t}return e.prototype.visitAlternation=function(t){this.alternations.push(t)},e}(Hx.GAstVisitor);function nJ(r,e){var t=new Yx;r.accept(t);var i=t.alternations,n=er.reduce(i,function(s,o){var a=er.dropRight(o.definition),l=er.map(a,function(c,u){var g=(0,Cwe.nextPossibleTokensAfter)([c],[],null,1);return er.isEmpty(g)?{message:e.buildEmptyAlternationError({topLevelRule:r,alternation:o,emptyChoiceIdx:u}),type:No.ParserDefinitionErrorType.NONE_LAST_EMPTY_ALT,ruleName:r.name,occurrence:o.idx,alternative:u+1}:null});return s.concat(er.compact(l))},[]);return n}zt.validateEmptyOrAlternative=nJ;function sJ(r,e,t){var i=new Yx;r.accept(i);var n=i.alternations;n=(0,br.reject)(n,function(o){return o.ignoreAmbiguities===!0});var s=er.reduce(n,function(o,a){var l=a.idx,c=a.maxLookahead||e,u=(0,mf.getLookaheadPathsForOr)(l,r,c,a),g=ywe(u,a,r,t),f=lJ(u,a,r,t);return o.concat(g,f)},[]);return s}zt.validateAmbiguousAlternationAlternatives=sJ;var oJ=function(r){Kx(e,r);function e(){var t=r!==null&&r.apply(this,arguments)||this;return t.allProductions=[],t}return e.prototype.visitRepetitionWithSeparator=function(t){this.allProductions.push(t)},e.prototype.visitRepetitionMandatory=function(t){this.allProductions.push(t)},e.prototype.visitRepetitionMandatoryWithSeparator=function(t){this.allProductions.push(t)},e.prototype.visitRepetition=function(t){this.allProductions.push(t)},e}(Hx.GAstVisitor);zt.RepetionCollector=oJ;function aJ(r,e){var t=new Yx;r.accept(t);var i=t.alternations,n=er.reduce(i,function(s,o){return o.definition.length>255&&s.push({message:e.buildTooManyAlternativesError({topLevelRule:r,alternation:o}),type:No.ParserDefinitionErrorType.TOO_MANY_ALTS,ruleName:r.name,occurrence:o.idx}),s},[]);return n}zt.validateTooManyAlts=aJ;function AJ(r,e,t){var i=[];return(0,br.forEach)(r,function(n){var s=new oJ;n.accept(s);var o=s.allProductions;(0,br.forEach)(o,function(a){var l=(0,mf.getProdType)(a),c=a.maxLookahead||e,u=a.idx,g=(0,mf.getLookaheadPathsForOptionalProd)(u,n,l,c),f=g[0];if((0,br.isEmpty)((0,br.flatten)(f))){var h=t.buildEmptyRepetitionError({topLevelRule:n,repetition:a});i.push({message:h,type:No.ParserDefinitionErrorType.NO_NON_EMPTY_LOOKAHEAD,ruleName:n.name})}})}),i}zt.validateSomeNonEmptyLookaheadPath=AJ;function ywe(r,e,t,i){var n=[],s=(0,br.reduce)(r,function(a,l,c){return e.definition[c].ignoreAmbiguities===!0||(0,br.forEach)(l,function(u){var g=[c];(0,br.forEach)(r,function(f,h){c!==h&&(0,mf.containsPath)(f,u)&&e.definition[h].ignoreAmbiguities!==!0&&g.push(h)}),g.length>1&&!(0,mf.containsPath)(n,u)&&(n.push(u),a.push({alts:g,path:u}))}),a},[]),o=er.map(s,function(a){var l=(0,br.map)(a.alts,function(u){return u+1}),c=i.buildAlternationAmbiguityError({topLevelRule:t,alternation:e,ambiguityIndices:l,prefixPath:a.path});return{message:c,type:No.ParserDefinitionErrorType.AMBIGUOUS_ALTS,ruleName:t.name,occurrence:e.idx,alternatives:[a.alts]}});return o}function lJ(r,e,t,i){var n=[],s=(0,br.reduce)(r,function(o,a,l){var c=(0,br.map)(a,function(u){return{idx:l,path:u}});return o.concat(c)},[]);return(0,br.forEach)(s,function(o){var a=e.definition[o.idx];if(a.ignoreAmbiguities!==!0){var l=o.idx,c=o.path,u=(0,br.findAll)(s,function(f){return e.definition[f.idx].ignoreAmbiguities!==!0&&f.idx{"use strict";Object.defineProperty(Ef,"__esModule",{value:!0});Ef.validateGrammar=Ef.resolveGrammar=void 0;var qx=Gt(),Bwe=Yq(),Qwe=jx(),cJ=Vd();function bwe(r){r=(0,qx.defaults)(r,{errMsgProvider:cJ.defaultGrammarResolverErrorProvider});var e={};return(0,qx.forEach)(r.rules,function(t){e[t.name]=t}),(0,Bwe.resolveGrammar)(e,r.errMsgProvider)}Ef.resolveGrammar=bwe;function Swe(r){return r=(0,qx.defaults)(r,{errMsgProvider:cJ.defaultGrammarValidatorErrorProvider}),(0,Qwe.validateGrammar)(r.rules,r.maxLookahead,r.tokenTypes,r.errMsgProvider,r.grammarName)}Ef.validateGrammar=Swe});var If=y(En=>{"use strict";var eC=En&&En.__extends||function(){var r=function(e,t){return r=Object.setPrototypeOf||{__proto__:[]}instanceof Array&&function(i,n){i.__proto__=n}||function(i,n){for(var s in n)Object.prototype.hasOwnProperty.call(n,s)&&(i[s]=n[s])},r(e,t)};return function(e,t){if(typeof t!="function"&&t!==null)throw new TypeError("Class extends value "+String(t)+" is not a constructor or null");r(e,t);function i(){this.constructor=e}e.prototype=t===null?Object.create(t):(i.prototype=t.prototype,new i)}}();Object.defineProperty(En,"__esModule",{value:!0});En.EarlyExitException=En.NotAllInputParsedException=En.NoViableAltException=En.MismatchedTokenException=En.isRecognitionException=void 0;var vwe=Gt(),gJ="MismatchedTokenException",fJ="NoViableAltException",hJ="EarlyExitException",pJ="NotAllInputParsedException",dJ=[gJ,fJ,hJ,pJ];Object.freeze(dJ);function xwe(r){return(0,vwe.contains)(dJ,r.name)}En.isRecognitionException=xwe;var Yy=function(r){eC(e,r);function e(t,i){var n=this.constructor,s=r.call(this,t)||this;return s.token=i,s.resyncedTokens=[],Object.setPrototypeOf(s,n.prototype),Error.captureStackTrace&&Error.captureStackTrace(s,s.constructor),s}return e}(Error),Pwe=function(r){eC(e,r);function e(t,i,n){var s=r.call(this,t,i)||this;return s.previousToken=n,s.name=gJ,s}return e}(Yy);En.MismatchedTokenException=Pwe;var Dwe=function(r){eC(e,r);function e(t,i,n){var s=r.call(this,t,i)||this;return s.previousToken=n,s.name=fJ,s}return e}(Yy);En.NoViableAltException=Dwe;var kwe=function(r){eC(e,r);function e(t,i){var n=r.call(this,t,i)||this;return n.name=pJ,n}return e}(Yy);En.NotAllInputParsedException=kwe;var Rwe=function(r){eC(e,r);function e(t,i,n){var s=r.call(this,t,i)||this;return s.previousToken=n,s.name=hJ,s}return e}(Yy);En.EarlyExitException=Rwe});var Wx=y(Ui=>{"use strict";Object.defineProperty(Ui,"__esModule",{value:!0});Ui.attemptInRepetitionRecovery=Ui.Recoverable=Ui.InRuleRecoveryException=Ui.IN_RULE_RECOVERY_EXCEPTION=Ui.EOF_FOLLOW_KEY=void 0;var jy=UA(),cs=Gt(),Fwe=If(),Nwe=Nx(),Lwe=Hn();Ui.EOF_FOLLOW_KEY={};Ui.IN_RULE_RECOVERY_EXCEPTION="InRuleRecoveryException";function Jx(r){this.name=Ui.IN_RULE_RECOVERY_EXCEPTION,this.message=r}Ui.InRuleRecoveryException=Jx;Jx.prototype=Error.prototype;var Twe=function(){function r(){}return r.prototype.initRecoverable=function(e){this.firstAfterRepMap={},this.resyncFollows={},this.recoveryEnabled=(0,cs.has)(e,"recoveryEnabled")?e.recoveryEnabled:Lwe.DEFAULT_PARSER_CONFIG.recoveryEnabled,this.recoveryEnabled&&(this.attemptInRepetitionRecovery=CJ)},r.prototype.getTokenToInsert=function(e){var t=(0,jy.createTokenInstance)(e,"",NaN,NaN,NaN,NaN,NaN,NaN);return t.isInsertedInRecovery=!0,t},r.prototype.canTokenTypeBeInsertedInRecovery=function(e){return!0},r.prototype.tryInRepetitionRecovery=function(e,t,i,n){for(var s=this,o=this.findReSyncTokenType(),a=this.exportLexerState(),l=[],c=!1,u=this.LA(1),g=this.LA(1),f=function(){var h=s.LA(0),p=s.errorMessageProvider.buildMismatchTokenMessage({expected:n,actual:u,previous:h,ruleName:s.getCurrRuleFullName()}),C=new Fwe.MismatchedTokenException(p,u,s.LA(0));C.resyncedTokens=(0,cs.dropRight)(l),s.SAVE_ERROR(C)};!c;)if(this.tokenMatcher(g,n)){f();return}else if(i.call(this)){f(),e.apply(this,t);return}else this.tokenMatcher(g,o)?c=!0:(g=this.SKIP_TOKEN(),this.addToResyncTokens(g,l));this.importLexerState(a)},r.prototype.shouldInRepetitionRecoveryBeTried=function(e,t,i){return!(i===!1||e===void 0||t===void 0||this.tokenMatcher(this.LA(1),e)||this.isBackTracking()||this.canPerformInRuleRecovery(e,this.getFollowsForInRuleRecovery(e,t)))},r.prototype.getFollowsForInRuleRecovery=function(e,t){var i=this.getCurrentGrammarPath(e,t),n=this.getNextPossibleTokenTypes(i);return n},r.prototype.tryInRuleRecovery=function(e,t){if(this.canRecoverWithSingleTokenInsertion(e,t)){var i=this.getTokenToInsert(e);return i}if(this.canRecoverWithSingleTokenDeletion(e)){var n=this.SKIP_TOKEN();return this.consumeToken(),n}throw new Jx("sad sad panda")},r.prototype.canPerformInRuleRecovery=function(e,t){return this.canRecoverWithSingleTokenInsertion(e,t)||this.canRecoverWithSingleTokenDeletion(e)},r.prototype.canRecoverWithSingleTokenInsertion=function(e,t){var i=this;if(!this.canTokenTypeBeInsertedInRecovery(e)||(0,cs.isEmpty)(t))return!1;var n=this.LA(1),s=(0,cs.find)(t,function(o){return i.tokenMatcher(n,o)})!==void 0;return s},r.prototype.canRecoverWithSingleTokenDeletion=function(e){var t=this.tokenMatcher(this.LA(2),e);return t},r.prototype.isInCurrentRuleReSyncSet=function(e){var t=this.getCurrFollowKey(),i=this.getFollowSetFromFollowKey(t);return(0,cs.contains)(i,e)},r.prototype.findReSyncTokenType=function(){for(var e=this.flattenFollowSet(),t=this.LA(1),i=2;;){var n=t.tokenType;if((0,cs.contains)(e,n))return n;t=this.LA(i),i++}},r.prototype.getCurrFollowKey=function(){if(this.RULE_STACK.length===1)return Ui.EOF_FOLLOW_KEY;var e=this.getLastExplicitRuleShortName(),t=this.getLastExplicitRuleOccurrenceIndex(),i=this.getPreviousExplicitRuleShortName();return{ruleName:this.shortRuleNameToFullName(e),idxInCallingRule:t,inRule:this.shortRuleNameToFullName(i)}},r.prototype.buildFullFollowKeyStack=function(){var e=this,t=this.RULE_STACK,i=this.RULE_OCCURRENCE_STACK;return(0,cs.map)(t,function(n,s){return s===0?Ui.EOF_FOLLOW_KEY:{ruleName:e.shortRuleNameToFullName(n),idxInCallingRule:i[s],inRule:e.shortRuleNameToFullName(t[s-1])}})},r.prototype.flattenFollowSet=function(){var e=this,t=(0,cs.map)(this.buildFullFollowKeyStack(),function(i){return e.getFollowSetFromFollowKey(i)});return(0,cs.flatten)(t)},r.prototype.getFollowSetFromFollowKey=function(e){if(e===Ui.EOF_FOLLOW_KEY)return[jy.EOF];var t=e.ruleName+e.idxInCallingRule+Nwe.IN+e.inRule;return this.resyncFollows[t]},r.prototype.addToResyncTokens=function(e,t){return this.tokenMatcher(e,jy.EOF)||t.push(e),t},r.prototype.reSyncTo=function(e){for(var t=[],i=this.LA(1);this.tokenMatcher(i,e)===!1;)i=this.SKIP_TOKEN(),this.addToResyncTokens(i,t);return(0,cs.dropRight)(t)},r.prototype.attemptInRepetitionRecovery=function(e,t,i,n,s,o,a){},r.prototype.getCurrentGrammarPath=function(e,t){var i=this.getHumanReadableRuleStack(),n=(0,cs.cloneArr)(this.RULE_OCCURRENCE_STACK),s={ruleStack:i,occurrenceStack:n,lastTok:e,lastTokOccurrence:t};return s},r.prototype.getHumanReadableRuleStack=function(){var e=this;return(0,cs.map)(this.RULE_STACK,function(t){return e.shortRuleNameToFullName(t)})},r}();Ui.Recoverable=Twe;function CJ(r,e,t,i,n,s,o){var a=this.getKeyForAutomaticLookahead(i,n),l=this.firstAfterRepMap[a];if(l===void 0){var c=this.getCurrRuleFullName(),u=this.getGAstProductions()[c],g=new s(u,n);l=g.startWalking(),this.firstAfterRepMap[a]=l}var f=l.token,h=l.occurrence,p=l.isEndOfRule;this.RULE_STACK.length===1&&p&&f===void 0&&(f=jy.EOF,h=1),this.shouldInRepetitionRecoveryBeTried(f,h,o)&&this.tryInRepetitionRecovery(r,e,t,f)}Ui.attemptInRepetitionRecovery=CJ});var qy=y(qt=>{"use strict";Object.defineProperty(qt,"__esModule",{value:!0});qt.getKeyForAutomaticLookahead=qt.AT_LEAST_ONE_SEP_IDX=qt.MANY_SEP_IDX=qt.AT_LEAST_ONE_IDX=qt.MANY_IDX=qt.OPTION_IDX=qt.OR_IDX=qt.BITS_FOR_ALT_IDX=qt.BITS_FOR_RULE_IDX=qt.BITS_FOR_OCCURRENCE_IDX=qt.BITS_FOR_METHOD_TYPE=void 0;qt.BITS_FOR_METHOD_TYPE=4;qt.BITS_FOR_OCCURRENCE_IDX=8;qt.BITS_FOR_RULE_IDX=12;qt.BITS_FOR_ALT_IDX=8;qt.OR_IDX=1<{"use strict";Object.defineProperty(Jy,"__esModule",{value:!0});Jy.LooksAhead=void 0;var Ra=Zd(),$s=Gt(),mJ=Hn(),Fa=qy(),Fc=zd(),Mwe=function(){function r(){}return r.prototype.initLooksAhead=function(e){this.dynamicTokensEnabled=(0,$s.has)(e,"dynamicTokensEnabled")?e.dynamicTokensEnabled:mJ.DEFAULT_PARSER_CONFIG.dynamicTokensEnabled,this.maxLookahead=(0,$s.has)(e,"maxLookahead")?e.maxLookahead:mJ.DEFAULT_PARSER_CONFIG.maxLookahead,this.lookAheadFuncsCache=(0,$s.isES2015MapSupported)()?new Map:[],(0,$s.isES2015MapSupported)()?(this.getLaFuncFromCache=this.getLaFuncFromMap,this.setLaFuncCache=this.setLaFuncCacheUsingMap):(this.getLaFuncFromCache=this.getLaFuncFromObj,this.setLaFuncCache=this.setLaFuncUsingObj)},r.prototype.preComputeLookaheadFunctions=function(e){var t=this;(0,$s.forEach)(e,function(i){t.TRACE_INIT(i.name+" Rule Lookahead",function(){var n=(0,Fc.collectMethods)(i),s=n.alternation,o=n.repetition,a=n.option,l=n.repetitionMandatory,c=n.repetitionMandatoryWithSeparator,u=n.repetitionWithSeparator;(0,$s.forEach)(s,function(g){var f=g.idx===0?"":g.idx;t.TRACE_INIT(""+(0,Fc.getProductionDslName)(g)+f,function(){var h=(0,Ra.buildLookaheadFuncForOr)(g.idx,i,g.maxLookahead||t.maxLookahead,g.hasPredicates,t.dynamicTokensEnabled,t.lookAheadBuilderForAlternatives),p=(0,Fa.getKeyForAutomaticLookahead)(t.fullRuleNameToShort[i.name],Fa.OR_IDX,g.idx);t.setLaFuncCache(p,h)})}),(0,$s.forEach)(o,function(g){t.computeLookaheadFunc(i,g.idx,Fa.MANY_IDX,Ra.PROD_TYPE.REPETITION,g.maxLookahead,(0,Fc.getProductionDslName)(g))}),(0,$s.forEach)(a,function(g){t.computeLookaheadFunc(i,g.idx,Fa.OPTION_IDX,Ra.PROD_TYPE.OPTION,g.maxLookahead,(0,Fc.getProductionDslName)(g))}),(0,$s.forEach)(l,function(g){t.computeLookaheadFunc(i,g.idx,Fa.AT_LEAST_ONE_IDX,Ra.PROD_TYPE.REPETITION_MANDATORY,g.maxLookahead,(0,Fc.getProductionDslName)(g))}),(0,$s.forEach)(c,function(g){t.computeLookaheadFunc(i,g.idx,Fa.AT_LEAST_ONE_SEP_IDX,Ra.PROD_TYPE.REPETITION_MANDATORY_WITH_SEPARATOR,g.maxLookahead,(0,Fc.getProductionDslName)(g))}),(0,$s.forEach)(u,function(g){t.computeLookaheadFunc(i,g.idx,Fa.MANY_SEP_IDX,Ra.PROD_TYPE.REPETITION_WITH_SEPARATOR,g.maxLookahead,(0,Fc.getProductionDslName)(g))})})})},r.prototype.computeLookaheadFunc=function(e,t,i,n,s,o){var a=this;this.TRACE_INIT(""+o+(t===0?"":t),function(){var l=(0,Ra.buildLookaheadFuncForOptionalProd)(t,e,s||a.maxLookahead,a.dynamicTokensEnabled,n,a.lookAheadBuilderForOptional),c=(0,Fa.getKeyForAutomaticLookahead)(a.fullRuleNameToShort[e.name],i,t);a.setLaFuncCache(c,l)})},r.prototype.lookAheadBuilderForOptional=function(e,t,i){return(0,Ra.buildSingleAlternativeLookaheadFunction)(e,t,i)},r.prototype.lookAheadBuilderForAlternatives=function(e,t,i,n){return(0,Ra.buildAlternativesLookAheadFunc)(e,t,i,n)},r.prototype.getKeyForAutomaticLookahead=function(e,t){var i=this.getLastExplicitRuleShortName();return(0,Fa.getKeyForAutomaticLookahead)(i,e,t)},r.prototype.getLaFuncFromCache=function(e){},r.prototype.getLaFuncFromMap=function(e){return this.lookAheadFuncsCache.get(e)},r.prototype.getLaFuncFromObj=function(e){return this.lookAheadFuncsCache[e]},r.prototype.setLaFuncCache=function(e,t){},r.prototype.setLaFuncCacheUsingMap=function(e,t){this.lookAheadFuncsCache.set(e,t)},r.prototype.setLaFuncUsingObj=function(e,t){this.lookAheadFuncsCache[e]=t},r}();Jy.LooksAhead=Mwe});var IJ=y(Lo=>{"use strict";Object.defineProperty(Lo,"__esModule",{value:!0});Lo.addNoneTerminalToCst=Lo.addTerminalToCst=Lo.setNodeLocationFull=Lo.setNodeLocationOnlyOffset=void 0;function Kwe(r,e){isNaN(r.startOffset)===!0?(r.startOffset=e.startOffset,r.endOffset=e.endOffset):r.endOffset{"use strict";Object.defineProperty(YA,"__esModule",{value:!0});YA.defineNameProp=YA.functionName=YA.classNameFromInstance=void 0;var Ywe=Gt();function jwe(r){return wJ(r.constructor)}YA.classNameFromInstance=jwe;var yJ="name";function wJ(r){var e=r.name;return e||"anonymous"}YA.functionName=wJ;function qwe(r,e){var t=Object.getOwnPropertyDescriptor(r,yJ);return(0,Ywe.isUndefined)(t)||t.configurable?(Object.defineProperty(r,yJ,{enumerable:!1,configurable:!0,writable:!1,value:e}),!0):!1}YA.defineNameProp=qwe});var vJ=y(vi=>{"use strict";Object.defineProperty(vi,"__esModule",{value:!0});vi.validateRedundantMethods=vi.validateMissingCstMethods=vi.validateVisitor=vi.CstVisitorDefinitionError=vi.createBaseVisitorConstructorWithDefaults=vi.createBaseSemanticVisitorConstructor=vi.defaultVisit=void 0;var us=Gt(),tC=zx();function BJ(r,e){for(var t=(0,us.keys)(r),i=t.length,n=0;n: - `+(""+s.join(` - -`).replace(/\n/g,` - `)))}}};return t.prototype=i,t.prototype.constructor=t,t._RULE_NAMES=e,t}vi.createBaseSemanticVisitorConstructor=Jwe;function Wwe(r,e,t){var i=function(){};(0,tC.defineNameProp)(i,r+"BaseSemanticsWithDefaults");var n=Object.create(t.prototype);return(0,us.forEach)(e,function(s){n[s]=BJ}),i.prototype=n,i.prototype.constructor=i,i}vi.createBaseVisitorConstructorWithDefaults=Wwe;var Vx;(function(r){r[r.REDUNDANT_METHOD=0]="REDUNDANT_METHOD",r[r.MISSING_METHOD=1]="MISSING_METHOD"})(Vx=vi.CstVisitorDefinitionError||(vi.CstVisitorDefinitionError={}));function QJ(r,e){var t=bJ(r,e),i=SJ(r,e);return t.concat(i)}vi.validateVisitor=QJ;function bJ(r,e){var t=(0,us.map)(e,function(i){if(!(0,us.isFunction)(r[i]))return{msg:"Missing visitor method: <"+i+"> on "+(0,tC.functionName)(r.constructor)+" CST Visitor.",type:Vx.MISSING_METHOD,methodName:i}});return(0,us.compact)(t)}vi.validateMissingCstMethods=bJ;var zwe=["constructor","visit","validateVisitor"];function SJ(r,e){var t=[];for(var i in r)(0,us.isFunction)(r[i])&&!(0,us.contains)(zwe,i)&&!(0,us.contains)(e,i)&&t.push({msg:"Redundant visitor method: <"+i+"> on "+(0,tC.functionName)(r.constructor)+` CST Visitor -There is no Grammar Rule corresponding to this method's name. -`,type:Vx.REDUNDANT_METHOD,methodName:i});return t}vi.validateRedundantMethods=SJ});var PJ=y(Wy=>{"use strict";Object.defineProperty(Wy,"__esModule",{value:!0});Wy.TreeBuilder=void 0;var yf=IJ(),ti=Gt(),xJ=vJ(),Vwe=Hn(),Xwe=function(){function r(){}return r.prototype.initTreeBuilder=function(e){if(this.CST_STACK=[],this.outputCst=e.outputCst,this.nodeLocationTracking=(0,ti.has)(e,"nodeLocationTracking")?e.nodeLocationTracking:Vwe.DEFAULT_PARSER_CONFIG.nodeLocationTracking,!this.outputCst)this.cstInvocationStateUpdate=ti.NOOP,this.cstFinallyStateUpdate=ti.NOOP,this.cstPostTerminal=ti.NOOP,this.cstPostNonTerminal=ti.NOOP,this.cstPostRule=ti.NOOP;else if(/full/i.test(this.nodeLocationTracking))this.recoveryEnabled?(this.setNodeLocationFromToken=yf.setNodeLocationFull,this.setNodeLocationFromNode=yf.setNodeLocationFull,this.cstPostRule=ti.NOOP,this.setInitialNodeLocation=this.setInitialNodeLocationFullRecovery):(this.setNodeLocationFromToken=ti.NOOP,this.setNodeLocationFromNode=ti.NOOP,this.cstPostRule=this.cstPostRuleFull,this.setInitialNodeLocation=this.setInitialNodeLocationFullRegular);else if(/onlyOffset/i.test(this.nodeLocationTracking))this.recoveryEnabled?(this.setNodeLocationFromToken=yf.setNodeLocationOnlyOffset,this.setNodeLocationFromNode=yf.setNodeLocationOnlyOffset,this.cstPostRule=ti.NOOP,this.setInitialNodeLocation=this.setInitialNodeLocationOnlyOffsetRecovery):(this.setNodeLocationFromToken=ti.NOOP,this.setNodeLocationFromNode=ti.NOOP,this.cstPostRule=this.cstPostRuleOnlyOffset,this.setInitialNodeLocation=this.setInitialNodeLocationOnlyOffsetRegular);else if(/none/i.test(this.nodeLocationTracking))this.setNodeLocationFromToken=ti.NOOP,this.setNodeLocationFromNode=ti.NOOP,this.cstPostRule=ti.NOOP,this.setInitialNodeLocation=ti.NOOP;else throw Error('Invalid config option: "'+e.nodeLocationTracking+'"')},r.prototype.setInitialNodeLocationOnlyOffsetRecovery=function(e){e.location={startOffset:NaN,endOffset:NaN}},r.prototype.setInitialNodeLocationOnlyOffsetRegular=function(e){e.location={startOffset:this.LA(1).startOffset,endOffset:NaN}},r.prototype.setInitialNodeLocationFullRecovery=function(e){e.location={startOffset:NaN,startLine:NaN,startColumn:NaN,endOffset:NaN,endLine:NaN,endColumn:NaN}},r.prototype.setInitialNodeLocationFullRegular=function(e){var t=this.LA(1);e.location={startOffset:t.startOffset,startLine:t.startLine,startColumn:t.startColumn,endOffset:NaN,endLine:NaN,endColumn:NaN}},r.prototype.cstInvocationStateUpdate=function(e,t){var i={name:e,children:{}};this.setInitialNodeLocation(i),this.CST_STACK.push(i)},r.prototype.cstFinallyStateUpdate=function(){this.CST_STACK.pop()},r.prototype.cstPostRuleFull=function(e){var t=this.LA(0),i=e.location;i.startOffset<=t.startOffset?(i.endOffset=t.endOffset,i.endLine=t.endLine,i.endColumn=t.endColumn):(i.startOffset=NaN,i.startLine=NaN,i.startColumn=NaN)},r.prototype.cstPostRuleOnlyOffset=function(e){var t=this.LA(0),i=e.location;i.startOffset<=t.startOffset?i.endOffset=t.endOffset:i.startOffset=NaN},r.prototype.cstPostTerminal=function(e,t){var i=this.CST_STACK[this.CST_STACK.length-1];(0,yf.addTerminalToCst)(i,t,e),this.setNodeLocationFromToken(i.location,t)},r.prototype.cstPostNonTerminal=function(e,t){var i=this.CST_STACK[this.CST_STACK.length-1];(0,yf.addNoneTerminalToCst)(i,t,e),this.setNodeLocationFromNode(i.location,e.location)},r.prototype.getBaseCstVisitorConstructor=function(){if((0,ti.isUndefined)(this.baseCstVisitorConstructor)){var e=(0,xJ.createBaseSemanticVisitorConstructor)(this.className,(0,ti.keys)(this.gastProductionsCache));return this.baseCstVisitorConstructor=e,e}return this.baseCstVisitorConstructor},r.prototype.getBaseCstVisitorConstructorWithDefaults=function(){if((0,ti.isUndefined)(this.baseCstVisitorWithDefaultsConstructor)){var e=(0,xJ.createBaseVisitorConstructorWithDefaults)(this.className,(0,ti.keys)(this.gastProductionsCache),this.getBaseCstVisitorConstructor());return this.baseCstVisitorWithDefaultsConstructor=e,e}return this.baseCstVisitorWithDefaultsConstructor},r.prototype.getLastExplicitRuleShortName=function(){var e=this.RULE_STACK;return e[e.length-1]},r.prototype.getPreviousExplicitRuleShortName=function(){var e=this.RULE_STACK;return e[e.length-2]},r.prototype.getLastExplicitRuleOccurrenceIndex=function(){var e=this.RULE_OCCURRENCE_STACK;return e[e.length-1]},r}();Wy.TreeBuilder=Xwe});var kJ=y(zy=>{"use strict";Object.defineProperty(zy,"__esModule",{value:!0});zy.LexerAdapter=void 0;var DJ=Hn(),_we=function(){function r(){}return r.prototype.initLexerAdapter=function(){this.tokVector=[],this.tokVectorLength=0,this.currIdx=-1},Object.defineProperty(r.prototype,"input",{get:function(){return this.tokVector},set:function(e){if(this.selfAnalysisDone!==!0)throw Error("Missing invocation at the end of the Parser's constructor.");this.reset(),this.tokVector=e,this.tokVectorLength=e.length},enumerable:!1,configurable:!0}),r.prototype.SKIP_TOKEN=function(){return this.currIdx<=this.tokVector.length-2?(this.consumeToken(),this.LA(1)):DJ.END_OF_FILE},r.prototype.LA=function(e){var t=this.currIdx+e;return t<0||this.tokVectorLength<=t?DJ.END_OF_FILE:this.tokVector[t]},r.prototype.consumeToken=function(){this.currIdx++},r.prototype.exportLexerState=function(){return this.currIdx},r.prototype.importLexerState=function(e){this.currIdx=e},r.prototype.resetLexerState=function(){this.currIdx=-1},r.prototype.moveToTerminatedState=function(){this.currIdx=this.tokVector.length-1},r.prototype.getLexerPosition=function(){return this.exportLexerState()},r}();zy.LexerAdapter=_we});var FJ=y(Vy=>{"use strict";Object.defineProperty(Vy,"__esModule",{value:!0});Vy.RecognizerApi=void 0;var RJ=Gt(),Zwe=If(),Xx=Hn(),$we=Vd(),eBe=jx(),tBe=Cn(),rBe=function(){function r(){}return r.prototype.ACTION=function(e){return e.call(this)},r.prototype.consume=function(e,t,i){return this.consumeInternal(t,e,i)},r.prototype.subrule=function(e,t,i){return this.subruleInternal(t,e,i)},r.prototype.option=function(e,t){return this.optionInternal(t,e)},r.prototype.or=function(e,t){return this.orInternal(t,e)},r.prototype.many=function(e,t){return this.manyInternal(e,t)},r.prototype.atLeastOne=function(e,t){return this.atLeastOneInternal(e,t)},r.prototype.CONSUME=function(e,t){return this.consumeInternal(e,0,t)},r.prototype.CONSUME1=function(e,t){return this.consumeInternal(e,1,t)},r.prototype.CONSUME2=function(e,t){return this.consumeInternal(e,2,t)},r.prototype.CONSUME3=function(e,t){return this.consumeInternal(e,3,t)},r.prototype.CONSUME4=function(e,t){return this.consumeInternal(e,4,t)},r.prototype.CONSUME5=function(e,t){return this.consumeInternal(e,5,t)},r.prototype.CONSUME6=function(e,t){return this.consumeInternal(e,6,t)},r.prototype.CONSUME7=function(e,t){return this.consumeInternal(e,7,t)},r.prototype.CONSUME8=function(e,t){return this.consumeInternal(e,8,t)},r.prototype.CONSUME9=function(e,t){return this.consumeInternal(e,9,t)},r.prototype.SUBRULE=function(e,t){return this.subruleInternal(e,0,t)},r.prototype.SUBRULE1=function(e,t){return this.subruleInternal(e,1,t)},r.prototype.SUBRULE2=function(e,t){return this.subruleInternal(e,2,t)},r.prototype.SUBRULE3=function(e,t){return this.subruleInternal(e,3,t)},r.prototype.SUBRULE4=function(e,t){return this.subruleInternal(e,4,t)},r.prototype.SUBRULE5=function(e,t){return this.subruleInternal(e,5,t)},r.prototype.SUBRULE6=function(e,t){return this.subruleInternal(e,6,t)},r.prototype.SUBRULE7=function(e,t){return this.subruleInternal(e,7,t)},r.prototype.SUBRULE8=function(e,t){return this.subruleInternal(e,8,t)},r.prototype.SUBRULE9=function(e,t){return this.subruleInternal(e,9,t)},r.prototype.OPTION=function(e){return this.optionInternal(e,0)},r.prototype.OPTION1=function(e){return this.optionInternal(e,1)},r.prototype.OPTION2=function(e){return this.optionInternal(e,2)},r.prototype.OPTION3=function(e){return this.optionInternal(e,3)},r.prototype.OPTION4=function(e){return this.optionInternal(e,4)},r.prototype.OPTION5=function(e){return this.optionInternal(e,5)},r.prototype.OPTION6=function(e){return this.optionInternal(e,6)},r.prototype.OPTION7=function(e){return this.optionInternal(e,7)},r.prototype.OPTION8=function(e){return this.optionInternal(e,8)},r.prototype.OPTION9=function(e){return this.optionInternal(e,9)},r.prototype.OR=function(e){return this.orInternal(e,0)},r.prototype.OR1=function(e){return this.orInternal(e,1)},r.prototype.OR2=function(e){return this.orInternal(e,2)},r.prototype.OR3=function(e){return this.orInternal(e,3)},r.prototype.OR4=function(e){return this.orInternal(e,4)},r.prototype.OR5=function(e){return this.orInternal(e,5)},r.prototype.OR6=function(e){return this.orInternal(e,6)},r.prototype.OR7=function(e){return this.orInternal(e,7)},r.prototype.OR8=function(e){return this.orInternal(e,8)},r.prototype.OR9=function(e){return this.orInternal(e,9)},r.prototype.MANY=function(e){this.manyInternal(0,e)},r.prototype.MANY1=function(e){this.manyInternal(1,e)},r.prototype.MANY2=function(e){this.manyInternal(2,e)},r.prototype.MANY3=function(e){this.manyInternal(3,e)},r.prototype.MANY4=function(e){this.manyInternal(4,e)},r.prototype.MANY5=function(e){this.manyInternal(5,e)},r.prototype.MANY6=function(e){this.manyInternal(6,e)},r.prototype.MANY7=function(e){this.manyInternal(7,e)},r.prototype.MANY8=function(e){this.manyInternal(8,e)},r.prototype.MANY9=function(e){this.manyInternal(9,e)},r.prototype.MANY_SEP=function(e){this.manySepFirstInternal(0,e)},r.prototype.MANY_SEP1=function(e){this.manySepFirstInternal(1,e)},r.prototype.MANY_SEP2=function(e){this.manySepFirstInternal(2,e)},r.prototype.MANY_SEP3=function(e){this.manySepFirstInternal(3,e)},r.prototype.MANY_SEP4=function(e){this.manySepFirstInternal(4,e)},r.prototype.MANY_SEP5=function(e){this.manySepFirstInternal(5,e)},r.prototype.MANY_SEP6=function(e){this.manySepFirstInternal(6,e)},r.prototype.MANY_SEP7=function(e){this.manySepFirstInternal(7,e)},r.prototype.MANY_SEP8=function(e){this.manySepFirstInternal(8,e)},r.prototype.MANY_SEP9=function(e){this.manySepFirstInternal(9,e)},r.prototype.AT_LEAST_ONE=function(e){this.atLeastOneInternal(0,e)},r.prototype.AT_LEAST_ONE1=function(e){return this.atLeastOneInternal(1,e)},r.prototype.AT_LEAST_ONE2=function(e){this.atLeastOneInternal(2,e)},r.prototype.AT_LEAST_ONE3=function(e){this.atLeastOneInternal(3,e)},r.prototype.AT_LEAST_ONE4=function(e){this.atLeastOneInternal(4,e)},r.prototype.AT_LEAST_ONE5=function(e){this.atLeastOneInternal(5,e)},r.prototype.AT_LEAST_ONE6=function(e){this.atLeastOneInternal(6,e)},r.prototype.AT_LEAST_ONE7=function(e){this.atLeastOneInternal(7,e)},r.prototype.AT_LEAST_ONE8=function(e){this.atLeastOneInternal(8,e)},r.prototype.AT_LEAST_ONE9=function(e){this.atLeastOneInternal(9,e)},r.prototype.AT_LEAST_ONE_SEP=function(e){this.atLeastOneSepFirstInternal(0,e)},r.prototype.AT_LEAST_ONE_SEP1=function(e){this.atLeastOneSepFirstInternal(1,e)},r.prototype.AT_LEAST_ONE_SEP2=function(e){this.atLeastOneSepFirstInternal(2,e)},r.prototype.AT_LEAST_ONE_SEP3=function(e){this.atLeastOneSepFirstInternal(3,e)},r.prototype.AT_LEAST_ONE_SEP4=function(e){this.atLeastOneSepFirstInternal(4,e)},r.prototype.AT_LEAST_ONE_SEP5=function(e){this.atLeastOneSepFirstInternal(5,e)},r.prototype.AT_LEAST_ONE_SEP6=function(e){this.atLeastOneSepFirstInternal(6,e)},r.prototype.AT_LEAST_ONE_SEP7=function(e){this.atLeastOneSepFirstInternal(7,e)},r.prototype.AT_LEAST_ONE_SEP8=function(e){this.atLeastOneSepFirstInternal(8,e)},r.prototype.AT_LEAST_ONE_SEP9=function(e){this.atLeastOneSepFirstInternal(9,e)},r.prototype.RULE=function(e,t,i){if(i===void 0&&(i=Xx.DEFAULT_RULE_CONFIG),(0,RJ.contains)(this.definedRulesNames,e)){var n=$we.defaultGrammarValidatorErrorProvider.buildDuplicateRuleNameError({topLevelRule:e,grammarName:this.className}),s={message:n,type:Xx.ParserDefinitionErrorType.DUPLICATE_RULE_NAME,ruleName:e};this.definitionErrors.push(s)}this.definedRulesNames.push(e);var o=this.defineRule(e,t,i);return this[e]=o,o},r.prototype.OVERRIDE_RULE=function(e,t,i){i===void 0&&(i=Xx.DEFAULT_RULE_CONFIG);var n=[];n=n.concat((0,eBe.validateRuleIsOverridden)(e,this.definedRulesNames,this.className)),this.definitionErrors=this.definitionErrors.concat(n);var s=this.defineRule(e,t,i);return this[e]=s,s},r.prototype.BACKTRACK=function(e,t){return function(){this.isBackTrackingStack.push(1);var i=this.saveRecogState();try{return e.apply(this,t),!0}catch(n){if((0,Zwe.isRecognitionException)(n))return!1;throw n}finally{this.reloadRecogState(i),this.isBackTrackingStack.pop()}}},r.prototype.getGAstProductions=function(){return this.gastProductionsCache},r.prototype.getSerializedGastProductions=function(){return(0,tBe.serializeGrammar)((0,RJ.values)(this.gastProductionsCache))},r}();Vy.RecognizerApi=rBe});var OJ=y(_y=>{"use strict";Object.defineProperty(_y,"__esModule",{value:!0});_y.RecognizerEngine=void 0;var Dr=Gt(),Gn=qy(),Xy=If(),NJ=Zd(),wf=_d(),LJ=Hn(),iBe=Wx(),TJ=UA(),rC=pf(),nBe=zx(),sBe=function(){function r(){}return r.prototype.initRecognizerEngine=function(e,t){if(this.className=(0,nBe.classNameFromInstance)(this),this.shortRuleNameToFull={},this.fullRuleNameToShort={},this.ruleShortNameIdx=256,this.tokenMatcher=rC.tokenStructuredMatcherNoCategories,this.definedRulesNames=[],this.tokensMap={},this.isBackTrackingStack=[],this.RULE_STACK=[],this.RULE_OCCURRENCE_STACK=[],this.gastProductionsCache={},(0,Dr.has)(t,"serializedGrammar"))throw Error(`The Parser's configuration can no longer contain a property. - See: https://chevrotain.io/docs/changes/BREAKING_CHANGES.html#_6-0-0 - For Further details.`);if((0,Dr.isArray)(e)){if((0,Dr.isEmpty)(e))throw Error(`A Token Vocabulary cannot be empty. - Note that the first argument for the parser constructor - is no longer a Token vector (since v4.0).`);if(typeof e[0].startOffset=="number")throw Error(`The Parser constructor no longer accepts a token vector as the first argument. - See: https://chevrotain.io/docs/changes/BREAKING_CHANGES.html#_4-0-0 - For Further details.`)}if((0,Dr.isArray)(e))this.tokensMap=(0,Dr.reduce)(e,function(o,a){return o[a.name]=a,o},{});else if((0,Dr.has)(e,"modes")&&(0,Dr.every)((0,Dr.flatten)((0,Dr.values)(e.modes)),rC.isTokenType)){var i=(0,Dr.flatten)((0,Dr.values)(e.modes)),n=(0,Dr.uniq)(i);this.tokensMap=(0,Dr.reduce)(n,function(o,a){return o[a.name]=a,o},{})}else if((0,Dr.isObject)(e))this.tokensMap=(0,Dr.cloneObj)(e);else throw new Error(" argument must be An Array of Token constructors, A dictionary of Token constructors or an IMultiModeLexerDefinition");this.tokensMap.EOF=TJ.EOF;var s=(0,Dr.every)((0,Dr.values)(e),function(o){return(0,Dr.isEmpty)(o.categoryMatches)});this.tokenMatcher=s?rC.tokenStructuredMatcherNoCategories:rC.tokenStructuredMatcher,(0,rC.augmentTokenTypes)((0,Dr.values)(this.tokensMap))},r.prototype.defineRule=function(e,t,i){if(this.selfAnalysisDone)throw Error("Grammar rule <"+e+`> may not be defined after the 'performSelfAnalysis' method has been called' -Make sure that all grammar rule definitions are done before 'performSelfAnalysis' is called.`);var n=(0,Dr.has)(i,"resyncEnabled")?i.resyncEnabled:LJ.DEFAULT_RULE_CONFIG.resyncEnabled,s=(0,Dr.has)(i,"recoveryValueFunc")?i.recoveryValueFunc:LJ.DEFAULT_RULE_CONFIG.recoveryValueFunc,o=this.ruleShortNameIdx<t},r.prototype.orInternal=function(e,t){var i=this.getKeyForAutomaticLookahead(Gn.OR_IDX,t),n=(0,Dr.isArray)(e)?e:e.DEF,s=this.getLaFuncFromCache(i),o=s.call(this,n);if(o!==void 0){var a=n[o];return a.ALT.call(this)}this.raiseNoAltException(t,e.ERR_MSG)},r.prototype.ruleFinallyStateUpdate=function(){if(this.RULE_STACK.pop(),this.RULE_OCCURRENCE_STACK.pop(),this.cstFinallyStateUpdate(),this.RULE_STACK.length===0&&this.isAtEndOfInput()===!1){var e=this.LA(1),t=this.errorMessageProvider.buildNotAllInputParsedMessage({firstRedundant:e,ruleName:this.getCurrRuleFullName()});this.SAVE_ERROR(new Xy.NotAllInputParsedException(t,e))}},r.prototype.subruleInternal=function(e,t,i){var n;try{var s=i!==void 0?i.ARGS:void 0;return n=e.call(this,t,s),this.cstPostNonTerminal(n,i!==void 0&&i.LABEL!==void 0?i.LABEL:e.ruleName),n}catch(o){this.subruleInternalError(o,i,e.ruleName)}},r.prototype.subruleInternalError=function(e,t,i){throw(0,Xy.isRecognitionException)(e)&&e.partialCstResult!==void 0&&(this.cstPostNonTerminal(e.partialCstResult,t!==void 0&&t.LABEL!==void 0?t.LABEL:i),delete e.partialCstResult),e},r.prototype.consumeInternal=function(e,t,i){var n;try{var s=this.LA(1);this.tokenMatcher(s,e)===!0?(this.consumeToken(),n=s):this.consumeInternalError(e,s,i)}catch(o){n=this.consumeInternalRecovery(e,t,o)}return this.cstPostTerminal(i!==void 0&&i.LABEL!==void 0?i.LABEL:e.name,n),n},r.prototype.consumeInternalError=function(e,t,i){var n,s=this.LA(0);throw i!==void 0&&i.ERR_MSG?n=i.ERR_MSG:n=this.errorMessageProvider.buildMismatchTokenMessage({expected:e,actual:t,previous:s,ruleName:this.getCurrRuleFullName()}),this.SAVE_ERROR(new Xy.MismatchedTokenException(n,t,s))},r.prototype.consumeInternalRecovery=function(e,t,i){if(this.recoveryEnabled&&i.name==="MismatchedTokenException"&&!this.isBackTracking()){var n=this.getFollowsForInRuleRecovery(e,t);try{return this.tryInRuleRecovery(e,n)}catch(s){throw s.name===iBe.IN_RULE_RECOVERY_EXCEPTION?i:s}}else throw i},r.prototype.saveRecogState=function(){var e=this.errors,t=(0,Dr.cloneArr)(this.RULE_STACK);return{errors:e,lexerState:this.exportLexerState(),RULE_STACK:t,CST_STACK:this.CST_STACK}},r.prototype.reloadRecogState=function(e){this.errors=e.errors,this.importLexerState(e.lexerState),this.RULE_STACK=e.RULE_STACK},r.prototype.ruleInvocationStateUpdate=function(e,t,i){this.RULE_OCCURRENCE_STACK.push(i),this.RULE_STACK.push(e),this.cstInvocationStateUpdate(t,e)},r.prototype.isBackTracking=function(){return this.isBackTrackingStack.length!==0},r.prototype.getCurrRuleFullName=function(){var e=this.getLastExplicitRuleShortName();return this.shortRuleNameToFull[e]},r.prototype.shortRuleNameToFullName=function(e){return this.shortRuleNameToFull[e]},r.prototype.isAtEndOfInput=function(){return this.tokenMatcher(this.LA(1),TJ.EOF)},r.prototype.reset=function(){this.resetLexerState(),this.isBackTrackingStack=[],this.errors=[],this.RULE_STACK=[],this.CST_STACK=[],this.RULE_OCCURRENCE_STACK=[]},r}();_y.RecognizerEngine=sBe});var KJ=y(Zy=>{"use strict";Object.defineProperty(Zy,"__esModule",{value:!0});Zy.ErrorHandler=void 0;var _x=If(),Zx=Gt(),MJ=Zd(),oBe=Hn(),aBe=function(){function r(){}return r.prototype.initErrorHandler=function(e){this._errors=[],this.errorMessageProvider=(0,Zx.has)(e,"errorMessageProvider")?e.errorMessageProvider:oBe.DEFAULT_PARSER_CONFIG.errorMessageProvider},r.prototype.SAVE_ERROR=function(e){if((0,_x.isRecognitionException)(e))return e.context={ruleStack:this.getHumanReadableRuleStack(),ruleOccurrenceStack:(0,Zx.cloneArr)(this.RULE_OCCURRENCE_STACK)},this._errors.push(e),e;throw Error("Trying to save an Error which is not a RecognitionException")},Object.defineProperty(r.prototype,"errors",{get:function(){return(0,Zx.cloneArr)(this._errors)},set:function(e){this._errors=e},enumerable:!1,configurable:!0}),r.prototype.raiseEarlyExitException=function(e,t,i){for(var n=this.getCurrRuleFullName(),s=this.getGAstProductions()[n],o=(0,MJ.getLookaheadPathsForOptionalProd)(e,s,t,this.maxLookahead),a=o[0],l=[],c=1;c<=this.maxLookahead;c++)l.push(this.LA(c));var u=this.errorMessageProvider.buildEarlyExitMessage({expectedIterationPaths:a,actual:l,previous:this.LA(0),customUserDescription:i,ruleName:n});throw this.SAVE_ERROR(new _x.EarlyExitException(u,this.LA(1),this.LA(0)))},r.prototype.raiseNoAltException=function(e,t){for(var i=this.getCurrRuleFullName(),n=this.getGAstProductions()[i],s=(0,MJ.getLookaheadPathsForOr)(e,n,this.maxLookahead),o=[],a=1;a<=this.maxLookahead;a++)o.push(this.LA(a));var l=this.LA(0),c=this.errorMessageProvider.buildNoViableAltMessage({expectedPathsPerAlt:s,actual:o,previous:l,customUserDescription:t,ruleName:this.getCurrRuleFullName()});throw this.SAVE_ERROR(new _x.NoViableAltException(c,this.LA(1),l))},r}();Zy.ErrorHandler=aBe});var GJ=y($y=>{"use strict";Object.defineProperty($y,"__esModule",{value:!0});$y.ContentAssist=void 0;var UJ=_d(),HJ=Gt(),ABe=function(){function r(){}return r.prototype.initContentAssist=function(){},r.prototype.computeContentAssist=function(e,t){var i=this.gastProductionsCache[e];if((0,HJ.isUndefined)(i))throw Error("Rule ->"+e+"<- does not exist in this grammar.");return(0,UJ.nextPossibleTokensAfter)([i],t,this.tokenMatcher,this.maxLookahead)},r.prototype.getNextPossibleTokenTypes=function(e){var t=(0,HJ.first)(e.ruleStack),i=this.getGAstProductions(),n=i[t],s=new UJ.NextAfterTokenWalker(n,e).startWalking();return s},r}();$y.ContentAssist=ABe});var XJ=y(rw=>{"use strict";Object.defineProperty(rw,"__esModule",{value:!0});rw.GastRecorder=void 0;var In=Gt(),To=Cn(),lBe=jd(),JJ=pf(),WJ=UA(),cBe=Hn(),uBe=qy(),tw={description:"This Object indicates the Parser is during Recording Phase"};Object.freeze(tw);var YJ=!0,jJ=Math.pow(2,uBe.BITS_FOR_OCCURRENCE_IDX)-1,zJ=(0,WJ.createToken)({name:"RECORDING_PHASE_TOKEN",pattern:lBe.Lexer.NA});(0,JJ.augmentTokenTypes)([zJ]);var VJ=(0,WJ.createTokenInstance)(zJ,`This IToken indicates the Parser is in Recording Phase - See: https://chevrotain.io/docs/guide/internals.html#grammar-recording for details`,-1,-1,-1,-1,-1,-1);Object.freeze(VJ);var gBe={name:`This CSTNode indicates the Parser is in Recording Phase - See: https://chevrotain.io/docs/guide/internals.html#grammar-recording for details`,children:{}},fBe=function(){function r(){}return r.prototype.initGastRecorder=function(e){this.recordingProdStack=[],this.RECORDING_PHASE=!1},r.prototype.enableRecording=function(){var e=this;this.RECORDING_PHASE=!0,this.TRACE_INIT("Enable Recording",function(){for(var t=function(n){var s=n>0?n:"";e["CONSUME"+s]=function(o,a){return this.consumeInternalRecord(o,n,a)},e["SUBRULE"+s]=function(o,a){return this.subruleInternalRecord(o,n,a)},e["OPTION"+s]=function(o){return this.optionInternalRecord(o,n)},e["OR"+s]=function(o){return this.orInternalRecord(o,n)},e["MANY"+s]=function(o){this.manyInternalRecord(n,o)},e["MANY_SEP"+s]=function(o){this.manySepFirstInternalRecord(n,o)},e["AT_LEAST_ONE"+s]=function(o){this.atLeastOneInternalRecord(n,o)},e["AT_LEAST_ONE_SEP"+s]=function(o){this.atLeastOneSepFirstInternalRecord(n,o)}},i=0;i<10;i++)t(i);e.consume=function(n,s,o){return this.consumeInternalRecord(s,n,o)},e.subrule=function(n,s,o){return this.subruleInternalRecord(s,n,o)},e.option=function(n,s){return this.optionInternalRecord(s,n)},e.or=function(n,s){return this.orInternalRecord(s,n)},e.many=function(n,s){this.manyInternalRecord(n,s)},e.atLeastOne=function(n,s){this.atLeastOneInternalRecord(n,s)},e.ACTION=e.ACTION_RECORD,e.BACKTRACK=e.BACKTRACK_RECORD,e.LA=e.LA_RECORD})},r.prototype.disableRecording=function(){var e=this;this.RECORDING_PHASE=!1,this.TRACE_INIT("Deleting Recording methods",function(){for(var t=0;t<10;t++){var i=t>0?t:"";delete e["CONSUME"+i],delete e["SUBRULE"+i],delete e["OPTION"+i],delete e["OR"+i],delete e["MANY"+i],delete e["MANY_SEP"+i],delete e["AT_LEAST_ONE"+i],delete e["AT_LEAST_ONE_SEP"+i]}delete e.consume,delete e.subrule,delete e.option,delete e.or,delete e.many,delete e.atLeastOne,delete e.ACTION,delete e.BACKTRACK,delete e.LA})},r.prototype.ACTION_RECORD=function(e){},r.prototype.BACKTRACK_RECORD=function(e,t){return function(){return!0}},r.prototype.LA_RECORD=function(e){return cBe.END_OF_FILE},r.prototype.topLevelRuleRecord=function(e,t){try{var i=new To.Rule({definition:[],name:e});return i.name=e,this.recordingProdStack.push(i),t.call(this),this.recordingProdStack.pop(),i}catch(n){if(n.KNOWN_RECORDER_ERROR!==!0)try{n.message=n.message+` - This error was thrown during the "grammar recording phase" For more info see: - https://chevrotain.io/docs/guide/internals.html#grammar-recording`}catch{throw n}throw n}},r.prototype.optionInternalRecord=function(e,t){return iC.call(this,To.Option,e,t)},r.prototype.atLeastOneInternalRecord=function(e,t){iC.call(this,To.RepetitionMandatory,t,e)},r.prototype.atLeastOneSepFirstInternalRecord=function(e,t){iC.call(this,To.RepetitionMandatoryWithSeparator,t,e,YJ)},r.prototype.manyInternalRecord=function(e,t){iC.call(this,To.Repetition,t,e)},r.prototype.manySepFirstInternalRecord=function(e,t){iC.call(this,To.RepetitionWithSeparator,t,e,YJ)},r.prototype.orInternalRecord=function(e,t){return hBe.call(this,e,t)},r.prototype.subruleInternalRecord=function(e,t,i){if(ew(t),!e||(0,In.has)(e,"ruleName")===!1){var n=new Error(" argument is invalid"+(" expecting a Parser method reference but got: <"+JSON.stringify(e)+">")+(` - inside top level rule: <`+this.recordingProdStack[0].name+">"));throw n.KNOWN_RECORDER_ERROR=!0,n}var s=(0,In.peek)(this.recordingProdStack),o=e.ruleName,a=new To.NonTerminal({idx:t,nonTerminalName:o,label:i==null?void 0:i.LABEL,referencedRule:void 0});return s.definition.push(a),this.outputCst?gBe:tw},r.prototype.consumeInternalRecord=function(e,t,i){if(ew(t),!(0,JJ.hasShortKeyProperty)(e)){var n=new Error(" argument is invalid"+(" expecting a TokenType reference but got: <"+JSON.stringify(e)+">")+(` - inside top level rule: <`+this.recordingProdStack[0].name+">"));throw n.KNOWN_RECORDER_ERROR=!0,n}var s=(0,In.peek)(this.recordingProdStack),o=new To.Terminal({idx:t,terminalType:e,label:i==null?void 0:i.LABEL});return s.definition.push(o),VJ},r}();rw.GastRecorder=fBe;function iC(r,e,t,i){i===void 0&&(i=!1),ew(t);var n=(0,In.peek)(this.recordingProdStack),s=(0,In.isFunction)(e)?e:e.DEF,o=new r({definition:[],idx:t});return i&&(o.separator=e.SEP),(0,In.has)(e,"MAX_LOOKAHEAD")&&(o.maxLookahead=e.MAX_LOOKAHEAD),this.recordingProdStack.push(o),s.call(this),n.definition.push(o),this.recordingProdStack.pop(),tw}function hBe(r,e){var t=this;ew(e);var i=(0,In.peek)(this.recordingProdStack),n=(0,In.isArray)(r)===!1,s=n===!1?r:r.DEF,o=new To.Alternation({definition:[],idx:e,ignoreAmbiguities:n&&r.IGNORE_AMBIGUITIES===!0});(0,In.has)(r,"MAX_LOOKAHEAD")&&(o.maxLookahead=r.MAX_LOOKAHEAD);var a=(0,In.some)(s,function(l){return(0,In.isFunction)(l.GATE)});return o.hasPredicates=a,i.definition.push(o),(0,In.forEach)(s,function(l){var c=new To.Alternative({definition:[]});o.definition.push(c),(0,In.has)(l,"IGNORE_AMBIGUITIES")?c.ignoreAmbiguities=l.IGNORE_AMBIGUITIES:(0,In.has)(l,"GATE")&&(c.ignoreAmbiguities=!0),t.recordingProdStack.push(c),l.ALT.call(t),t.recordingProdStack.pop()}),tw}function qJ(r){return r===0?"":""+r}function ew(r){if(r<0||r>jJ){var e=new Error("Invalid DSL Method idx value: <"+r+`> - `+("Idx value must be a none negative value smaller than "+(jJ+1)));throw e.KNOWN_RECORDER_ERROR=!0,e}}});var ZJ=y(iw=>{"use strict";Object.defineProperty(iw,"__esModule",{value:!0});iw.PerformanceTracer=void 0;var _J=Gt(),pBe=Hn(),dBe=function(){function r(){}return r.prototype.initPerformanceTracer=function(e){if((0,_J.has)(e,"traceInitPerf")){var t=e.traceInitPerf,i=typeof t=="number";this.traceInitMaxIdent=i?t:1/0,this.traceInitPerf=i?t>0:t}else this.traceInitMaxIdent=0,this.traceInitPerf=pBe.DEFAULT_PARSER_CONFIG.traceInitPerf;this.traceInitIndent=-1},r.prototype.TRACE_INIT=function(e,t){if(this.traceInitPerf===!0){this.traceInitIndent++;var i=new Array(this.traceInitIndent+1).join(" ");this.traceInitIndent <"+e+">");var n=(0,_J.timer)(t),s=n.time,o=n.value,a=s>10?console.warn:console.log;return this.traceInitIndent time: "+s+"ms"),this.traceInitIndent--,o}else return t()},r}();iw.PerformanceTracer=dBe});var $J=y(nw=>{"use strict";Object.defineProperty(nw,"__esModule",{value:!0});nw.applyMixins=void 0;function CBe(r,e){e.forEach(function(t){var i=t.prototype;Object.getOwnPropertyNames(i).forEach(function(n){if(n!=="constructor"){var s=Object.getOwnPropertyDescriptor(i,n);s&&(s.get||s.set)?Object.defineProperty(r.prototype,n,s):r.prototype[n]=t.prototype[n]}})})}nw.applyMixins=CBe});var Hn=y(Cr=>{"use strict";var rW=Cr&&Cr.__extends||function(){var r=function(e,t){return r=Object.setPrototypeOf||{__proto__:[]}instanceof Array&&function(i,n){i.__proto__=n}||function(i,n){for(var s in n)Object.prototype.hasOwnProperty.call(n,s)&&(i[s]=n[s])},r(e,t)};return function(e,t){if(typeof t!="function"&&t!==null)throw new TypeError("Class extends value "+String(t)+" is not a constructor or null");r(e,t);function i(){this.constructor=e}e.prototype=t===null?Object.create(t):(i.prototype=t.prototype,new i)}}();Object.defineProperty(Cr,"__esModule",{value:!0});Cr.EmbeddedActionsParser=Cr.CstParser=Cr.Parser=Cr.EMPTY_ALT=Cr.ParserDefinitionErrorType=Cr.DEFAULT_RULE_CONFIG=Cr.DEFAULT_PARSER_CONFIG=Cr.END_OF_FILE=void 0;var _i=Gt(),mBe=Kq(),eW=UA(),iW=Vd(),tW=uJ(),EBe=Wx(),IBe=EJ(),yBe=PJ(),wBe=kJ(),BBe=FJ(),QBe=OJ(),bBe=KJ(),SBe=GJ(),vBe=XJ(),xBe=ZJ(),PBe=$J();Cr.END_OF_FILE=(0,eW.createTokenInstance)(eW.EOF,"",NaN,NaN,NaN,NaN,NaN,NaN);Object.freeze(Cr.END_OF_FILE);Cr.DEFAULT_PARSER_CONFIG=Object.freeze({recoveryEnabled:!1,maxLookahead:3,dynamicTokensEnabled:!1,outputCst:!0,errorMessageProvider:iW.defaultParserErrorProvider,nodeLocationTracking:"none",traceInitPerf:!1,skipValidations:!1});Cr.DEFAULT_RULE_CONFIG=Object.freeze({recoveryValueFunc:function(){},resyncEnabled:!0});var DBe;(function(r){r[r.INVALID_RULE_NAME=0]="INVALID_RULE_NAME",r[r.DUPLICATE_RULE_NAME=1]="DUPLICATE_RULE_NAME",r[r.INVALID_RULE_OVERRIDE=2]="INVALID_RULE_OVERRIDE",r[r.DUPLICATE_PRODUCTIONS=3]="DUPLICATE_PRODUCTIONS",r[r.UNRESOLVED_SUBRULE_REF=4]="UNRESOLVED_SUBRULE_REF",r[r.LEFT_RECURSION=5]="LEFT_RECURSION",r[r.NONE_LAST_EMPTY_ALT=6]="NONE_LAST_EMPTY_ALT",r[r.AMBIGUOUS_ALTS=7]="AMBIGUOUS_ALTS",r[r.CONFLICT_TOKENS_RULES_NAMESPACE=8]="CONFLICT_TOKENS_RULES_NAMESPACE",r[r.INVALID_TOKEN_NAME=9]="INVALID_TOKEN_NAME",r[r.NO_NON_EMPTY_LOOKAHEAD=10]="NO_NON_EMPTY_LOOKAHEAD",r[r.AMBIGUOUS_PREFIX_ALTS=11]="AMBIGUOUS_PREFIX_ALTS",r[r.TOO_MANY_ALTS=12]="TOO_MANY_ALTS"})(DBe=Cr.ParserDefinitionErrorType||(Cr.ParserDefinitionErrorType={}));function kBe(r){return r===void 0&&(r=void 0),function(){return r}}Cr.EMPTY_ALT=kBe;var sw=function(){function r(e,t){this.definitionErrors=[],this.selfAnalysisDone=!1;var i=this;if(i.initErrorHandler(t),i.initLexerAdapter(),i.initLooksAhead(t),i.initRecognizerEngine(e,t),i.initRecoverable(t),i.initTreeBuilder(t),i.initContentAssist(),i.initGastRecorder(t),i.initPerformanceTracer(t),(0,_i.has)(t,"ignoredIssues"))throw new Error(`The IParserConfig property has been deprecated. - Please use the flag on the relevant DSL method instead. - See: https://chevrotain.io/docs/guide/resolving_grammar_errors.html#IGNORING_AMBIGUITIES - For further details.`);this.skipValidations=(0,_i.has)(t,"skipValidations")?t.skipValidations:Cr.DEFAULT_PARSER_CONFIG.skipValidations}return r.performSelfAnalysis=function(e){throw Error("The **static** `performSelfAnalysis` method has been deprecated. \nUse the **instance** method with the same name instead.")},r.prototype.performSelfAnalysis=function(){var e=this;this.TRACE_INIT("performSelfAnalysis",function(){var t;e.selfAnalysisDone=!0;var i=e.className;e.TRACE_INIT("toFastProps",function(){(0,_i.toFastProperties)(e)}),e.TRACE_INIT("Grammar Recording",function(){try{e.enableRecording(),(0,_i.forEach)(e.definedRulesNames,function(s){var o=e[s],a=o.originalGrammarAction,l=void 0;e.TRACE_INIT(s+" Rule",function(){l=e.topLevelRuleRecord(s,a)}),e.gastProductionsCache[s]=l})}finally{e.disableRecording()}});var n=[];if(e.TRACE_INIT("Grammar Resolving",function(){n=(0,tW.resolveGrammar)({rules:(0,_i.values)(e.gastProductionsCache)}),e.definitionErrors=e.definitionErrors.concat(n)}),e.TRACE_INIT("Grammar Validations",function(){if((0,_i.isEmpty)(n)&&e.skipValidations===!1){var s=(0,tW.validateGrammar)({rules:(0,_i.values)(e.gastProductionsCache),maxLookahead:e.maxLookahead,tokenTypes:(0,_i.values)(e.tokensMap),errMsgProvider:iW.defaultGrammarValidatorErrorProvider,grammarName:i});e.definitionErrors=e.definitionErrors.concat(s)}}),(0,_i.isEmpty)(e.definitionErrors)&&(e.recoveryEnabled&&e.TRACE_INIT("computeAllProdsFollows",function(){var s=(0,mBe.computeAllProdsFollows)((0,_i.values)(e.gastProductionsCache));e.resyncFollows=s}),e.TRACE_INIT("ComputeLookaheadFunctions",function(){e.preComputeLookaheadFunctions((0,_i.values)(e.gastProductionsCache))})),!r.DEFER_DEFINITION_ERRORS_HANDLING&&!(0,_i.isEmpty)(e.definitionErrors))throw t=(0,_i.map)(e.definitionErrors,function(s){return s.message}),new Error(`Parser Definition Errors detected: - `+t.join(` -------------------------------- -`))})},r.DEFER_DEFINITION_ERRORS_HANDLING=!1,r}();Cr.Parser=sw;(0,PBe.applyMixins)(sw,[EBe.Recoverable,IBe.LooksAhead,yBe.TreeBuilder,wBe.LexerAdapter,QBe.RecognizerEngine,BBe.RecognizerApi,bBe.ErrorHandler,SBe.ContentAssist,vBe.GastRecorder,xBe.PerformanceTracer]);var RBe=function(r){rW(e,r);function e(t,i){i===void 0&&(i=Cr.DEFAULT_PARSER_CONFIG);var n=this,s=(0,_i.cloneObj)(i);return s.outputCst=!0,n=r.call(this,t,s)||this,n}return e}(sw);Cr.CstParser=RBe;var FBe=function(r){rW(e,r);function e(t,i){i===void 0&&(i=Cr.DEFAULT_PARSER_CONFIG);var n=this,s=(0,_i.cloneObj)(i);return s.outputCst=!1,n=r.call(this,t,s)||this,n}return e}(sw);Cr.EmbeddedActionsParser=FBe});var sW=y(ow=>{"use strict";Object.defineProperty(ow,"__esModule",{value:!0});ow.createSyntaxDiagramsCode=void 0;var nW=mx();function NBe(r,e){var t=e===void 0?{}:e,i=t.resourceBase,n=i===void 0?"https://unpkg.com/chevrotain@"+nW.VERSION+"/diagrams/":i,s=t.css,o=s===void 0?"https://unpkg.com/chevrotain@"+nW.VERSION+"/diagrams/diagrams.css":s,a=` - - - - - -`,l=` - -`,c=` - + + diff --git a/packages/assets-controllers/jest.config.js b/packages/assets-controllers/jest.config.js index 03634748b94..a91d4995fcc 100644 --- a/packages/assets-controllers/jest.config.js +++ b/packages/assets-controllers/jest.config.js @@ -14,13 +14,19 @@ module.exports = merge(baseConfig, { // The display name when running multiple projects displayName, + // An array of regexp pattern strings used to skip coverage collection + coveragePathIgnorePatterns: [ + ...baseConfig.coveragePathIgnorePatterns, + '/__fixtures__/', + ], + // An object that configures minimum threshold enforcement for coverage results coverageThreshold: { global: { - branches: 87.77, - functions: 96.15, - lines: 95.28, - statements: 95.4, + branches: 91.07, + functions: 98.47, + lines: 98.13, + statements: 98.13, }, }, diff --git a/packages/assets-controllers/jest.environment.js b/packages/assets-controllers/jest.environment.js index 44825502d04..da37ea53702 100644 --- a/packages/assets-controllers/jest.environment.js +++ b/packages/assets-controllers/jest.environment.js @@ -1,10 +1,9 @@ -/* eslint-disable */ -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 309805922a5..49b0996a38d 100644 --- a/packages/assets-controllers/package.json +++ b/packages/assets-controllers/package.json @@ -1,83 +1,136 @@ { "name": "@metamask/assets-controllers", - "version": "16.0.0", + "version": "111.1.3", "description": "Controllers which manage interactions involving ERC-20, ERC-721, and ERC-1155 tokens (including NFTs)", "keywords": [ - "MetaMask", - "Ethereum" + "Ethereum", + "MetaMask" ], "homepage": "https://github.com/MetaMask/core/tree/main/packages/assets-controllers#readme", "bugs": { "url": "https://github.com/MetaMask/core/issues" }, + "license": "MIT", "repository": { "type": "git", "url": "https://github.com/MetaMask/core.git" }, - "license": "MIT", - "main": "./dist/index.js", - "types": "./dist/index.d.ts", "files": [ "dist/" ], + "sideEffects": false, + "main": "./dist/index.cjs", + "types": "./dist/index.d.cts", + "exports": { + ".": { + "import": { + "types": "./dist/index.d.mts", + "default": "./dist/index.mjs" + }, + "require": { + "types": "./dist/index.d.cts", + "default": "./dist/index.cjs" + } + }, + "./package.json": "./package.json" + }, + "publishConfig": { + "access": "public", + "registry": "https://registry.npmjs.org/" + }, "scripts": { + "build": "ts-bridge --project tsconfig.build.json --verbose --clean --no-references", + "build:all": "ts-bridge --project tsconfig.build.json --verbose --clean", "build:docs": "typedoc", + "changelog:update": "../../scripts/update-changelog.sh @metamask/assets-controllers", "changelog:validate": "../../scripts/validate-changelog.sh @metamask/assets-controllers", - "publish:preview": "yarn npm publish --tag preview", - "test": "jest", - "test:clean": "jest --clearCache", - "test:watch": "jest --watch" + "lint:tsconfigs": "tsx ../../scripts/lint-tsconfigs/lint-tsconfigs.mts", + "lint:tsconfigs:fix": "tsx ../../scripts/lint-tsconfigs/lint-tsconfigs.mts --fix", + "messenger-action-types:check": "tsx ../../packages/messenger-cli/src/cli.ts --formatter oxfmt --esm --check", + "messenger-action-types:generate": "tsx ../../packages/messenger-cli/src/cli.ts --formatter oxfmt --esm --generate", + "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": { + "@ethereumjs/util": "^9.1.0", + "@ethersproject/abi": "^5.7.0", "@ethersproject/address": "^5.7.0", "@ethersproject/bignumber": "^5.7.0", "@ethersproject/contracts": "^5.7.0", "@ethersproject/providers": "^5.7.0", - "@metamask/abi-utils": "^2.0.2", - "@metamask/approval-controller": "^4.0.1", - "@metamask/base-controller": "^3.2.3", - "@metamask/contract-metadata": "^2.3.1", - "@metamask/controller-utils": "^5.0.2", - "@metamask/eth-query": "^3.0.1", - "@metamask/metamask-eth-abis": "3.0.0", - "@metamask/network-controller": "^15.0.0", - "@metamask/polling-controller": "^0.2.0", - "@metamask/preferences-controller": "^4.4.3", - "@metamask/rpc-errors": "^6.1.0", - "@metamask/utils": "^8.1.0", + "@metamask/abi-utils": "^2.0.3", + "@metamask/account-tree-controller": "^8.0.0", + "@metamask/accounts-controller": "^39.1.1", + "@metamask/approval-controller": "^9.0.2", + "@metamask/base-controller": "^9.1.0", + "@metamask/contract-metadata": "^2.4.0", + "@metamask/controller-utils": "^12.3.0", + "@metamask/core-backend": "^9.0.0", + "@metamask/eth-query": "^4.0.0", + "@metamask/keyring-api": "^24.0.0", + "@metamask/keyring-controller": "^27.1.1", + "@metamask/messenger": "^2.0.0", + "@metamask/metamask-eth-abis": "^3.1.1", + "@metamask/multichain-account-service": "^13.0.2", + "@metamask/network-controller": "^36.0.0", + "@metamask/network-enablement-controller": "^6.0.5", + "@metamask/permission-controller": "^13.1.1", + "@metamask/phishing-controller": "^17.4.0", + "@metamask/polling-controller": "^16.0.9", + "@metamask/preferences-controller": "^23.1.0", + "@metamask/profile-sync-controller": "^29.0.0", + "@metamask/remote-feature-flag-controller": "^6.1.0", + "@metamask/rpc-errors": "^7.0.2", + "@metamask/snaps-controllers": "^19.0.0", + "@metamask/snaps-sdk": "^11.0.0", + "@metamask/snaps-utils": "^12.1.2", + "@metamask/storage-service": "^1.0.2", + "@metamask/transaction-controller": "^69.6.1", + "@metamask/utils": "^11.11.0", + "@tanstack/query-core": "^5.62.16", + "@types/bn.js": "^5.1.5", "@types/uuid": "^8.3.0", - "async-mutex": "^0.2.6", - "ethereumjs-util": "^7.0.10", + "async-mutex": "^0.5.0", + "bitcoin-address-validation": "^2.2.3", + "bn.js": "^5.2.1", "immer": "^9.0.6", - "multiformats": "^9.5.2", + "lodash": "^4.17.21", + "multiformats": "^9.9.0", + "reselect": "^5.1.1", "single-call-balance-checker-abi": "^1.0.0", "uuid": "^8.3.2" }, "devDependencies": { - "@metamask/auto-changelog": "^3.1.0", - "@types/jest": "^27.4.1", + "@babel/runtime": "^7.23.9", + "@metamask/account-api": "^2.0.0", + "@metamask/auto-changelog": "^6.1.0", + "@metamask/ethjs-provider-http": "^0.3.0", + "@metamask/keyring-internal-api": "^12.0.0", + "@metamask/keyring-snap-client": "^10.0.0", + "@metamask/providers": "^22.1.0", + "@ts-bridge/cli": "^0.6.4", + "@types/jest": "^30.0.0", + "@types/lodash": "^4.14.191", "@types/node": "^16.18.54", "deepmerge": "^4.2.2", - "ethjs-provider-http": "^0.1.6", - "jest": "^27.5.1", - "jest-environment-jsdom": "^27.5.1", + "jest": "^30.4.2", + "jest-environment-jsdom": "^30.4.1", "nock": "^13.3.1", - "sinon": "^9.2.4", - "ts-jest": "^27.1.4", - "typedoc": "^0.24.8", + "ts-jest": "^29.4.11", + "tsx": "^4.20.5", + "typedoc": "^0.25.13", "typedoc-plugin-missing-exports": "^2.0.0", - "typescript": "~4.8.4" + "typescript": "~5.3.3", + "webextension-polyfill": "^0.12.0" }, "peerDependencies": { - "@metamask/approval-controller": "^4.0.1", - "@metamask/network-controller": "^15.0.0", - "@metamask/preferences-controller": "^4.4.3" + "@metamask/providers": "^22.0.0", + "webextension-polyfill": "^0.10.0 || ^0.11.0 || ^0.12.0" }, "engines": { - "node": ">=16.0.0" - }, - "publishConfig": { - "access": "public", - "registry": "https://registry.npmjs.org/" + "node": "^18.18 || >=20" } } diff --git a/packages/assets-controllers/src/AccountTrackerController-method-action-types.ts b/packages/assets-controllers/src/AccountTrackerController-method-action-types.ts new file mode 100644 index 00000000000..e9eab07a2eb --- /dev/null +++ b/packages/assets-controllers/src/AccountTrackerController-method-action-types.ts @@ -0,0 +1,64 @@ +/** + * This file is auto generated. + * Do not edit manually. + */ + +import type { AccountTrackerController } from './AccountTrackerController.js'; + +/** + * Refreshes the balances of the accounts depending on the multi-account setting. + * If multi-account is disabled, only updates the selected account balance. + * If multi-account is enabled, updates balances for all accounts. + * + * @param networkClientIds - Optional network client IDs to fetch a network client with + * @param queryAllAccounts - Whether to query all accounts or just the selected account + */ +export type AccountTrackerControllerRefreshAction = { + type: `AccountTrackerController:refresh`; + handler: AccountTrackerController['refresh']; +}; + +/** + * Sync accounts balances with some additional addresses. + * + * @param addresses - the additional addresses, may be hardware wallet addresses. + * @param networkClientId - Optional networkClientId to fetch a network client with. + * @returns accounts - addresses with synced balance + */ +export type AccountTrackerControllerSyncBalanceWithAddressesAction = { + type: `AccountTrackerController:syncBalanceWithAddresses`; + handler: AccountTrackerController['syncBalanceWithAddresses']; +}; + +/** + * Updates the balances of multiple native tokens in a single batch operation. + * This is more efficient than calling updateNativeToken multiple times as it + * triggers only one state update. + * + * @param balances - Array of balance updates, each containing address, chainId, and balance. + */ +export type AccountTrackerControllerUpdateNativeBalancesAction = { + type: `AccountTrackerController:updateNativeBalances`; + handler: AccountTrackerController['updateNativeBalances']; +}; + +/** + * Updates the staked balances of multiple accounts in a single batch operation. + * This is more efficient than updating staked balances individually as it + * triggers only one state update. + * + * @param stakedBalances - Array of staked balance updates, each containing address, chainId, and stakedBalance. + */ +export type AccountTrackerControllerUpdateStakedBalancesAction = { + type: `AccountTrackerController:updateStakedBalances`; + handler: AccountTrackerController['updateStakedBalances']; +}; + +/** + * Union of all AccountTrackerController action types. + */ +export type AccountTrackerControllerMethodActions = + | AccountTrackerControllerRefreshAction + | AccountTrackerControllerSyncBalanceWithAddressesAction + | AccountTrackerControllerUpdateNativeBalancesAction + | AccountTrackerControllerUpdateStakedBalancesAction; diff --git a/packages/assets-controllers/src/AccountTrackerController.test.ts b/packages/assets-controllers/src/AccountTrackerController.test.ts index bd4faaf14e0..0f4efc6fae6 100644 --- a/packages/assets-controllers/src/AccountTrackerController.test.ts +++ b/packages/assets-controllers/src/AccountTrackerController.test.ts @@ -1,229 +1,2855 @@ -import { query } from '@metamask/controller-utils'; -import type { ContactEntry } from '@metamask/preferences-controller'; -import { PreferencesController } from '@metamask/preferences-controller'; -import HttpProvider from 'ethjs-provider-http'; -import * as sinon from 'sinon'; +import { deriveStateFromMetadata } from '@metamask/base-controller'; +import { query, toChecksumHexAddress } from '@metamask/controller-utils'; +import type { InternalAccount } from '@metamask/keyring-internal-api'; +import { MOCK_ANY_NAMESPACE, Messenger } from '@metamask/messenger'; +import type { + MessengerActions, + MessengerEvents, + MockAnyNamespace, +} from '@metamask/messenger'; +import { getDefaultNetworkControllerState } from '@metamask/network-controller'; +import type { + NetworkClientId, + NetworkClientConfiguration, + NetworkConfiguration, +} from '@metamask/network-controller'; +import { getDefaultPreferencesState } from '@metamask/preferences-controller'; +import { TransactionStatus } from '@metamask/transaction-controller'; +import type { TransactionMeta } from '@metamask/transaction-controller'; +import type { Hex } from '@metamask/utils'; +import BN from 'bn.js'; -import { AccountTrackerController } from './AccountTrackerController'; +import { FakeProvider } from '../../../tests/fake-provider.js'; +import { jestAdvanceTime } from '../../../tests/helpers.js'; +import { createMockInternalAccount } from '../../accounts-controller/tests/mocks.js'; +import { + buildCustomNetworkClientConfiguration, + buildMockGetNetworkClientById, +} from '../../network-controller/tests/helpers.js'; +import type { AccountTrackerControllerMessenger } from './AccountTrackerController.js'; +import { AccountTrackerController } from './AccountTrackerController.js'; +import { AccountsApiBalanceFetcher } from './multi-chain-accounts-service/api-balance-fetcher.js'; +import { getTokenBalancesForMultipleAddresses } from './multicall.js'; + +type AllAccountTrackerControllerActions = + MessengerActions; + +type AllAccountTrackerControllerEvents = + MessengerEvents; + +type RootMessenger = Messenger< + MockAnyNamespace, + AllAccountTrackerControllerActions, + AllAccountTrackerControllerEvents +>; jest.mock('@metamask/controller-utils', () => { return { ...jest.requireActual('@metamask/controller-utils'), query: jest.fn(), + safelyExecuteWithTimeout: jest.fn(), }; }); +jest.mock('./multicall', () => ({ + ...jest.requireActual('./multicall'), + getTokenBalancesForMultipleAddresses: jest.fn(), +})); + +const mockGetStakedBalanceForChain = async (addresses: string[]) => + addresses.reduce>((accumulator, address) => { + accumulator[address] = '0x1'; + return accumulator; + }, {}); + +const ADDRESS_1 = '0xc38bf1ad06ef69f0c04e29dbeb4152b4175f0a8d'; +const CHECKSUM_ADDRESS_1 = toChecksumHexAddress(ADDRESS_1); +const ACCOUNT_1 = createMockInternalAccount({ address: ADDRESS_1 }); +const ADDRESS_2 = '0x742d35cc6634c0532925a3b844bc454e4438f44e'; // lowercase for consistent caching +const CHECKSUM_ADDRESS_2 = toChecksumHexAddress(ADDRESS_2); +const ACCOUNT_2 = createMockInternalAccount({ address: ADDRESS_2 }); +const EMPTY_ACCOUNT = { + address: '', + id: '', +} as InternalAccount; +const initialChainId = '0x1'; + const mockedQuery = query as jest.Mock< ReturnType, Parameters >; -const provider = new HttpProvider( - 'https://goerli.infura.io/v3/341eacb578dd44a1a049cbc5f6fd4035', +const mockedGetTokenBalancesForMultipleAddresses = + getTokenBalancesForMultipleAddresses as jest.Mock; + +const { safelyExecuteWithTimeout } = jest.requireMock( + '@metamask/controller-utils', ); +const mockedSafelyExecuteWithTimeout = safelyExecuteWithTimeout as jest.Mock; describe('AccountTrackerController', () => { beforeEach(() => { + jest.useFakeTimers(); mockedQuery.mockReturnValue(Promise.resolve('0x0')); + + // Set up default mock for multicall function (without staked balances) + // Use lowercase addresses since that's what the balance fetcher actually requests + mockedGetTokenBalancesForMultipleAddresses.mockResolvedValue({ + tokenBalances: { + '0x0000000000000000000000000000000000000000': { + [ADDRESS_1]: new BN('acac5457a3517e', 16), // lowercase + [ADDRESS_2]: new BN('27548bd9e4026c918d4b', 16), // lowercase + }, + }, + stakedBalances: {}, // Empty by default + }); + + // Mock safelyExecuteWithTimeout to execute the operation normally by default + mockedSafelyExecuteWithTimeout.mockImplementation( + async (operation: () => Promise) => { + try { + return await operation(); + } catch { + return undefined; + } + }, + ); }); afterEach(() => { - sinon.restore(); + jest.useRealTimers(); mockedQuery.mockRestore(); + mockedGetTokenBalancesForMultipleAddresses.mockClear(); + mockedSafelyExecuteWithTimeout.mockRestore(); }); - it('should set default state', () => { - const controller = new AccountTrackerController({ - onPreferencesStateChange: sinon.stub(), - getIdentities: () => ({}), - getSelectedAddress: () => '', - getMultiAccountBalancesEnabled: () => true, - }); - expect(controller.state).toStrictEqual({ - accounts: {}, - }); + it('should set default state', async () => { + await withController( + { + isMultiAccountBalancesEnabled: true, + }, + ({ controller }) => { + expect(controller.state).toStrictEqual({ + accountsByChainId: { + [initialChainId]: {}, + }, + }); + }, + ); }); - it('should throw when provider property is accessed', () => { - const controller = new AccountTrackerController({ - onPreferencesStateChange: sinon.stub(), - getIdentities: () => ({}), - getSelectedAddress: () => '', - getMultiAccountBalancesEnabled: () => true, - }); - expect(() => console.log(controller.provider)).toThrow( - 'Property only used for setting', + it('should refresh when selectedAccount changes', async () => { + await withController( + { + isMultiAccountBalancesEnabled: true, + }, + ({ controller, triggerSelectedAccountChange }) => { + const refreshSpy = jest.spyOn(controller, 'refresh'); + + triggerSelectedAccountChange(ACCOUNT_1); + + expect(refreshSpy).toHaveBeenCalled(); + }, ); }); - it('should get real balance', async () => { - const address = '0xc38bf1ad06ef69f0c04e29dbeb4152b4175f0a8d'; + it('refreshes address when unapproved transaction is added', async () => { + await withController( + { + selectedAccount: ACCOUNT_1, + listAccounts: [ACCOUNT_1], + }, + async ({ controller, messenger }) => { + mockedGetTokenBalancesForMultipleAddresses.mockResolvedValueOnce({ + tokenBalances: { + '0x0000000000000000000000000000000000000000': { + [ADDRESS_1]: new BN('123456', 16), + }, + }, + stakedBalances: {}, + }); + + const transactionMeta: TransactionMeta = { + networkClientId: 'mainnet', + chainId: '0x1' as const, + id: 'test-tx-1', + status: TransactionStatus.unapproved, + time: Date.now(), + txParams: { + from: ADDRESS_1, + }, + }; + + messenger.publish( + 'TransactionController:unapprovedTransactionAdded', + transactionMeta, + ); - mockedQuery.mockReturnValueOnce(Promise.resolve('0x10')); + await jest.advanceTimersByTimeAsync(1); - const controller = new AccountTrackerController( + expect( + controller.state.accountsByChainId['0x1'][CHECKSUM_ADDRESS_1].balance, + ).toBe('0x123456'); + }, + ); + }); + + it('refreshes both from and to addresses when unapproved transaction is added with to address', async () => { + await withController( { - onPreferencesStateChange: sinon.stub(), - getIdentities: () => { - return { [address]: {} as ContactEntry }; - }, - getSelectedAddress: () => address, - getMultiAccountBalancesEnabled: () => true, + selectedAccount: ACCOUNT_1, + listAccounts: [ACCOUNT_1, ACCOUNT_2], + }, + async ({ controller, messenger }) => { + mockedGetTokenBalancesForMultipleAddresses.mockResolvedValueOnce({ + tokenBalances: { + '0x0000000000000000000000000000000000000000': { + [ADDRESS_1]: new BN('aaaaaa', 16), + [ADDRESS_2]: new BN('bbbbbb', 16), + }, + }, + stakedBalances: {}, + }); + + const transactionMeta: TransactionMeta = { + networkClientId: 'mainnet', + chainId: '0x1' as const, + id: 'test-tx-with-to-unapproved', + status: TransactionStatus.unapproved, + time: Date.now(), + txParams: { + from: ADDRESS_1, + to: ADDRESS_2, + }, + }; + + messenger.publish( + 'TransactionController:unapprovedTransactionAdded', + transactionMeta, + ); + + await jest.advanceTimersByTimeAsync(1); + + // Both from and to addresses should have their balances refreshed + expect( + controller.state.accountsByChainId['0x1'][CHECKSUM_ADDRESS_1].balance, + ).toBe('0xaaaaaa'); + expect( + controller.state.accountsByChainId['0x1'][CHECKSUM_ADDRESS_2].balance, + ).toBe('0xbbbbbb'); }, - { provider }, ); + }); + + it('refreshes address when transaction is confirmed', async () => { + await withController( + { + selectedAccount: ACCOUNT_1, + listAccounts: [ACCOUNT_1], + }, + async ({ controller, messenger }) => { + mockedGetTokenBalancesForMultipleAddresses.mockResolvedValueOnce({ + tokenBalances: { + '0x0000000000000000000000000000000000000000': { + [ADDRESS_1]: new BN('abcdef', 16), + }, + }, + stakedBalances: {}, + }); + + const transactionMeta: TransactionMeta = { + networkClientId: 'mainnet', + chainId: '0x1' as const, + id: 'test-tx-2', + status: TransactionStatus.confirmed, + time: Date.now(), + txParams: { + from: ADDRESS_1, + }, + }; + + messenger.publish( + 'TransactionController:transactionConfirmed', + transactionMeta, + ); - await controller.refresh(); + await jest.advanceTimersByTimeAsync(1); - expect(controller.state.accounts[address].balance).toBeDefined(); - expect(controller.state.accounts[address].balance).toBe('0x10'); + expect( + controller.state.accountsByChainId['0x1'][CHECKSUM_ADDRESS_1].balance, + ).toBe('0xabcdef'); + }, + ); }); - it('should sync balance with addresses', async () => { - const address = '0xc38bf1ad06ef69f0c04e29dbeb4152b4175f0a8d'; + it('refreshes both from and to addresses when transaction is confirmed with to address', async () => { + await withController( + { + selectedAccount: ACCOUNT_1, + listAccounts: [ACCOUNT_1, ACCOUNT_2], + }, + async ({ controller, messenger }) => { + mockedGetTokenBalancesForMultipleAddresses.mockResolvedValueOnce({ + tokenBalances: { + '0x0000000000000000000000000000000000000000': { + [ADDRESS_1]: new BN('111111', 16), + [ADDRESS_2]: new BN('222222', 16), + }, + }, + stakedBalances: {}, + }); + + const transactionMeta: TransactionMeta = { + networkClientId: 'mainnet', + chainId: '0x1' as const, + id: 'test-tx-with-to', + status: TransactionStatus.confirmed, + time: Date.now(), + txParams: { + from: ADDRESS_1, + to: ADDRESS_2, + }, + }; + + messenger.publish( + 'TransactionController:transactionConfirmed', + transactionMeta, + ); - const controller = new AccountTrackerController( + await jest.advanceTimersByTimeAsync(1); + + // Both from and to addresses should have their balances refreshed + expect( + controller.state.accountsByChainId['0x1'][CHECKSUM_ADDRESS_1].balance, + ).toBe('0x111111'); + expect( + controller.state.accountsByChainId['0x1'][CHECKSUM_ADDRESS_2].balance, + ).toBe('0x222222'); + }, + ); + }); + + it('should create new chain entry when balance fetcher returns balance for unexpected chain (line 739)', async () => { + await withController( { - onPreferencesStateChange: sinon.stub(), - getIdentities: () => { - return {}; - }, - getSelectedAddress: () => address, - getMultiAccountBalancesEnabled: () => true, + selectedAccount: ACCOUNT_1, + listAccounts: [ACCOUNT_1], + }, + async ({ controller, refresh }) => { + // State only has '0x1' initially + expect(controller.state.accountsByChainId['0x1']).toBeDefined(); + // '0xa4b1' (Arbitrum) should not exist yet + expect(controller.state.accountsByChainId['0xa4b1']).toBeUndefined(); + + // Mock balance fetcher to return balance for '0x1' (requested) + // AND '0xa4b1' (not requested) - this tests line 739 defensive code + mockedGetTokenBalancesForMultipleAddresses.mockImplementationOnce( + async () => { + // Simulate fetcher returning extra chain that wasn't synced + return { + tokenBalances: { + '0x0000000000000000000000000000000000000000': { + [ADDRESS_1]: new BN('123456', 16), + }, + }, + // Return balances for extra chain '0xa4b1' not in request + stakedBalances: {}, + }; + }, + ); + + // Refresh only for mainnet + await refresh(['mainnet']); + + // Verify mainnet balance was updated + expect( + controller.state.accountsByChainId['0x1'][CHECKSUM_ADDRESS_1].balance, + ).toBe('0x123456'); }, - { provider }, ); - mockedQuery.mockReturnValueOnce(Promise.resolve('0x10')); - const result = await controller.syncBalanceWithAddresses([address]); - expect(result[address].balance).toBe('0x10'); }); - it('should sync addresses', () => { - const controller = new AccountTrackerController( + it('refreshes addresses when network is added', async () => { + await withController( { - onPreferencesStateChange: sinon.stub(), - getIdentities: () => { - return { baz: {} as ContactEntry }; - }, - getSelectedAddress: () => '0x0', - getMultiAccountBalancesEnabled: () => true, + selectedAccount: ACCOUNT_1, + listAccounts: [ACCOUNT_1], + }, + async ({ controller, messenger }) => { + mockedGetTokenBalancesForMultipleAddresses.mockResolvedValueOnce({ + tokenBalances: { + '0x0000000000000000000000000000000000000000': { + [ADDRESS_1]: new BN('abcdef', 16), + }, + }, + stakedBalances: {}, + }); + + messenger.publish('NetworkController:networkAdded', { + chainId: '0x1', + blockExplorerUrls: [], + name: 'Mainnet', + nativeCurrency: 'ETH', + defaultRpcEndpointIndex: 0, + rpcEndpoints: [{ networkClientId: 'mainnet' }], + } as unknown as NetworkConfiguration); + + await jest.advanceTimersByTimeAsync(1); + + expect( + controller.state.accountsByChainId['0x1'][CHECKSUM_ADDRESS_1].balance, + ).toBe('0xabcdef'); + }, + ); + }); + + it('should not wipe existing balances when syncing accounts and the selected chain has no state entry', async () => { + const networkClientId = 'networkClientId1'; + + mockedGetTokenBalancesForMultipleAddresses.mockResolvedValueOnce({ + tokenBalances: { + '0x0000000000000000000000000000000000000000': {}, }, - { provider }, + stakedBalances: {}, + }); + + await withController( { - accounts: { - bar: { balance: '' }, - foo: { balance: '' }, + options: { + state: { + accountsByChainId: { + '0xe705': { + [CHECKSUM_ADDRESS_1]: { + balance: '0xabc', + stakedBalance: '0x5', + }, + }, + }, + }, + }, + isMultiAccountBalancesEnabled: true, + selectedAccount: ACCOUNT_1, + listAccounts: [ACCOUNT_1], + networkClientById: { + [networkClientId]: buildCustomNetworkClientConfiguration({ + chainId: '0x999', + }), }, }, + async ({ controller, refresh }) => { + // Verify initial state has the balance we expect to preserve + expect( + controller.state.accountsByChainId['0xe705'][CHECKSUM_ADDRESS_1], + ).toStrictEqual({ + balance: '0xabc', + stakedBalance: '0x5', + }); + + // Refresh for a new chain. The selected network (mainnet / 0x1) is + // NOT in accountsByChainId, so #syncAccounts sees an empty "existing" + // set. Without the fix this would overwrite every address on every + // chain with { balance: '0x0' }, wiping both balance and stakedBalance. + await refresh(['networkClientId1'], true); + + // Existing balances must be preserved + expect( + controller.state.accountsByChainId['0xe705'][CHECKSUM_ADDRESS_1], + ).toStrictEqual({ + balance: '0xabc', + stakedBalance: '0x5', + }); + + // New chain should have been initialised with a zero balance + expect( + controller.state.accountsByChainId['0x999'][CHECKSUM_ADDRESS_1], + ).toStrictEqual({ + balance: '0x0', + }); + }, ); - controller.refresh(); - expect(controller.state.accounts).toStrictEqual({ - baz: { balance: '0x0' }, - }); }); - it('should subscribe to new sibling preference controllers', async () => { - const preferences = new PreferencesController(); - const controller = new AccountTrackerController( + it('sets isActive to true when keyring is unlocked', async () => { + await withController( { - onPreferencesStateChange: (listener) => preferences.subscribe(listener), - getIdentities: () => ({}), - getSelectedAddress: () => '0x0', - getMultiAccountBalancesEnabled: () => true, + selectedAccount: ACCOUNT_1, + listAccounts: [ACCOUNT_1], + }, + async ({ controller, messenger }) => { + // Verify controller is active initially (unlocked by default in tests) + expect(controller.isActive).toBe(true); + + // Lock the keyring + messenger.publish('KeyringController:lock'); + expect(controller.isActive).toBe(false); + + // Unlock the keyring + messenger.publish('KeyringController:unlock'); + expect(controller.isActive).toBe(true); }, - { provider }, ); - controller.refresh = sinon.stub(); + }); - preferences.setFeatureFlag('foo', true); - expect((controller.refresh as any).called).toBe(true); + it('should refresh balances when keyring is unlocked', async () => { + await withController( + { + selectedAccount: ACCOUNT_1, + listAccounts: [ACCOUNT_1], + }, + async ({ controller, messenger }) => { + const refreshSpy = jest.spyOn(controller, 'refresh'); + + // Lock the keyring first + messenger.publish('KeyringController:lock'); + + // Clear any previous calls + refreshSpy.mockClear(); + + // Unlock the keyring - should trigger refresh + messenger.publish('KeyringController:unlock'); + + expect(refreshSpy).toHaveBeenCalled(); + }, + ); }); - it('should call refresh every ten seconds', async () => { - await new Promise((resolve) => { - const preferences = new PreferencesController(); - const poll = sinon.spy(AccountTrackerController.prototype, 'poll'); - const controller = new AccountTrackerController( + describe('isHomepageSectionsV1Enabled and getNetworkClientIds', () => { + it('when isHomepageSectionsV1Enabled is true, uses listPopularEvmNetworks for refresh (e.g. on keyring unlock)', async () => { + await withController( { - onPreferencesStateChange: (listener) => - preferences.subscribe(listener), - getIdentities: () => ({}), - getSelectedAddress: () => '', - getMultiAccountBalancesEnabled: () => true, + options: { isHomepageSectionsV1Enabled: () => true }, + selectedAccount: ACCOUNT_1, + listAccounts: [ACCOUNT_1], + }, + async ({ controller, messenger, networkEnablementMocks }) => { + const refreshSpy = jest + .spyOn(controller, 'refresh') + .mockResolvedValue(); + + messenger.publish('KeyringController:unlock'); + + await jest.advanceTimersByTimeAsync(1); + + expect( + networkEnablementMocks.listPopularEvmNetworks, + ).toHaveBeenCalled(); + expect(networkEnablementMocks.getState).not.toHaveBeenCalled(); + expect(refreshSpy).toHaveBeenCalled(); + const [networkClientIds] = refreshSpy.mock.calls[0]; + expect(networkClientIds.length).toBeGreaterThan(0); + expect(networkClientIds).toContain('mainnet'); }, - { provider, interval: 100 }, ); - sinon.stub(controller, 'refresh'); + }); + + it('when isHomepageSectionsV1Enabled is false, uses enabledNetworkMap for refresh (e.g. on keyring unlock)', async () => { + await withController( + { + options: { isHomepageSectionsV1Enabled: () => false }, + selectedAccount: ACCOUNT_1, + listAccounts: [ACCOUNT_1], + }, + async ({ controller, messenger, networkEnablementMocks }) => { + const refreshSpy = jest + .spyOn(controller, 'refresh') + .mockResolvedValue(); - expect(poll.called).toBe(true); - expect(poll.calledTwice).toBe(false); - setTimeout(() => { - expect(poll.calledTwice).toBe(true); - resolve(); - }, 120); + messenger.publish('KeyringController:unlock'); + + await jest.advanceTimersByTimeAsync(1); + + expect(networkEnablementMocks.getState).toHaveBeenCalled(); + expect( + networkEnablementMocks.listPopularEvmNetworks, + ).not.toHaveBeenCalled(); + expect(refreshSpy).toHaveBeenCalledWith(['mainnet']); + }, + ); }); }); - it('should update only selected address balance when multi-account is disabled', async () => { - const address1 = '0xc38bf1ad06ef69f0c04e29dbeb4152b4175f0a8d'; - const address2 = '0x742d35Cc6634C0532925a3b844Bc454e4438f44e'; + describe('refresh', () => { + it('does not refresh when fetching is disabled', async () => { + const expectedState = { + accountsByChainId: { + '0x1': { + [CHECKSUM_ADDRESS_1]: { balance: '0x0' }, + [CHECKSUM_ADDRESS_2]: { balance: '0x0' }, + }, + }, + }; - jest - .spyOn(AccountTrackerController.prototype, 'poll') - .mockImplementationOnce(async () => Promise.resolve()); + await withController( + { + options: { fetchingEnabled: () => false }, + selectedAccount: ACCOUNT_1, + listAccounts: [ACCOUNT_1, ACCOUNT_2], + }, + async ({ controller, refresh }) => { + await refresh(['mainnet'], true); - mockedQuery.mockReturnValueOnce(Promise.resolve('0x10')); + expect(controller.state).toStrictEqual(expectedState); + }, + ); + }); - const controller = new AccountTrackerController( - { - onPreferencesStateChange: sinon.stub(), - getIdentities: () => { - return { - [address1]: {} as ContactEntry, - [address2]: {} as ContactEntry, - }; + it('should skip balance fetching when isOnboarded returns false', async () => { + const expectedState = { + accountsByChainId: { + '0x1': { + [CHECKSUM_ADDRESS_1]: { balance: '0x0' }, + [CHECKSUM_ADDRESS_2]: { balance: '0x0' }, + }, }, - getSelectedAddress: () => address1, - getMultiAccountBalancesEnabled: () => false, - }, - { provider }, + }; + + await withController( + { + options: { isOnboarded: () => false }, + selectedAccount: ACCOUNT_1, + listAccounts: [ACCOUNT_1, ACCOUNT_2], + }, + async ({ controller, refresh }) => { + await refresh(['mainnet'], true); + + // Balances should remain at 0x0 because isOnboarded returns false + expect(controller.state).toStrictEqual(expectedState); + }, + ); + }); + + it('should evaluate isOnboarded dynamically at call time', async () => { + let onboarded = false; + + await withController( + { + options: { isOnboarded: () => onboarded }, + selectedAccount: ACCOUNT_1, + listAccounts: [ACCOUNT_1], + }, + async ({ controller, refresh }) => { + // First call: isOnboarded returns false, should skip fetching + await refresh(['mainnet'], false); + + // Balances should remain at 0x0 + expect( + controller.state.accountsByChainId['0x1'][CHECKSUM_ADDRESS_1] + .balance, + ).toBe('0x0'); + + // Now set onboarded to true + onboarded = true; + + mockedGetTokenBalancesForMultipleAddresses.mockResolvedValueOnce({ + tokenBalances: { + '0x0000000000000000000000000000000000000000': { + [ADDRESS_1]: new BN('fedcba', 16), + }, + }, + stakedBalances: {}, + }); + + // Second call: isOnboarded now returns true, should fetch balances + await refresh(['mainnet'], false); + + // Balance should now be updated + expect( + controller.state.accountsByChainId['0x1'][CHECKSUM_ADDRESS_1] + .balance, + ).toBe('0xfedcba'); + }, + ); + }); + + describe('without networkClientId', () => { + it('should sync addresses', async () => { + await withController( + { + options: { + state: { + accountsByChainId: { + '0x1': { + [CHECKSUM_ADDRESS_1]: { balance: '0x1' }, + foo: { balance: '0x2' }, + }, + '0x2': { + [CHECKSUM_ADDRESS_1]: { balance: '0xa' }, + foo: { balance: '0xb' }, + }, + }, + }, + }, + isMultiAccountBalancesEnabled: true, + selectedAccount: ACCOUNT_1, + listAccounts: [ACCOUNT_1, ACCOUNT_2], + }, + async ({ controller, refresh }) => { + await refresh(['mainnet'], true); + + expect(controller.state).toStrictEqual({ + accountsByChainId: { + '0x1': { + [CHECKSUM_ADDRESS_1]: { balance: '0xacac5457a3517e' }, + [CHECKSUM_ADDRESS_2]: { balance: '0x27548bd9e4026c918d4b' }, + }, + '0x2': { + [CHECKSUM_ADDRESS_1]: { balance: '0xa' }, + [CHECKSUM_ADDRESS_2]: { balance: '0x0' }, + }, + }, + }); + }, + ); + }); + + it('should get real balance', async () => { + // Override the multicall mock for this specific test + // Use lowercase address since that's what the balance fetcher requests + mockedGetTokenBalancesForMultipleAddresses.mockResolvedValueOnce({ + tokenBalances: { + '0x0000000000000000000000000000000000000000': { + [ADDRESS_1]: new BN('acac5457a3517e', 16), // lowercase + }, + }, + stakedBalances: {}, + }); + + await withController( + { + isMultiAccountBalancesEnabled: true, + selectedAccount: ACCOUNT_1, + listAccounts: [ACCOUNT_1], + }, + async ({ controller, refresh }) => { + await refresh(['mainnet'], true); + + expect(controller.state).toStrictEqual({ + accountsByChainId: { + '0x1': { + [CHECKSUM_ADDRESS_1]: { + balance: '0xacac5457a3517e', + }, + }, + }, + }); + }, + ); + }); + + it('should update only selected address balance when multi-account is disabled', async () => { + // Mock for single address balance update - only selected account gets balance + // When multi-account is disabled, the fetcher requests checksum addresses + mockedGetTokenBalancesForMultipleAddresses.mockResolvedValueOnce({ + tokenBalances: { + '0x0000000000000000000000000000000000000000': { + [ADDRESS_1]: new BN('acac5457a3517e', 16), // lowercase + }, + }, + stakedBalances: {}, + }); + + await withController( + { + isMultiAccountBalancesEnabled: false, + selectedAccount: ACCOUNT_1, + listAccounts: [ACCOUNT_1, ACCOUNT_2], + }, + async ({ controller, refresh }) => { + await refresh(['mainnet'], false); + + expect(controller.state).toStrictEqual({ + accountsByChainId: { + '0x1': { + [CHECKSUM_ADDRESS_1]: { balance: '0xacac5457a3517e' }, + [CHECKSUM_ADDRESS_2]: { balance: '0x0' }, + }, + }, + }); + }, + ); + }); + + it('should update all address balances when multi-account is enabled', async () => { + // Mock for multi-address balance update + // When multi-account is enabled, the fetcher requests lowercase addresses + mockedGetTokenBalancesForMultipleAddresses.mockResolvedValueOnce({ + tokenBalances: { + '0x0000000000000000000000000000000000000000': { + [ADDRESS_1]: new BN('acac5457a3517e', 16), // lowercase + [ADDRESS_2]: new BN('27548bd9e4026c918d4b', 16), // lowercase + }, + }, + stakedBalances: {}, + }); + + await withController( + { + isMultiAccountBalancesEnabled: true, + selectedAccount: ACCOUNT_1, + listAccounts: [ACCOUNT_1, ACCOUNT_2], + }, + async ({ controller, refresh }) => { + await refresh(['mainnet'], true); + + expect(controller.state).toStrictEqual({ + accountsByChainId: { + '0x1': { + [CHECKSUM_ADDRESS_1]: { balance: '0xacac5457a3517e' }, + [CHECKSUM_ADDRESS_2]: { balance: '0x27548bd9e4026c918d4b' }, + }, + }, + }); + }, + ); + }); + + it('should update staked balance when includeStakedAssets is enabled', async () => { + // Mock with both native and staked balances + mockedGetTokenBalancesForMultipleAddresses.mockResolvedValueOnce({ + tokenBalances: { + '0x0000000000000000000000000000000000000000': { + [ADDRESS_1]: new BN('acac5457a3517e', 16), + }, + }, + stakedBalances: { + [ADDRESS_1]: new BN('1', 16), + }, + }); + + await withController( + { + options: { + includeStakedAssets: true, + getStakedBalanceForChain: mockGetStakedBalanceForChain, + }, + isMultiAccountBalancesEnabled: false, + selectedAccount: ACCOUNT_1, + listAccounts: [ACCOUNT_1, ACCOUNT_2], + }, + async ({ controller, refresh }) => { + await refresh(['mainnet'], false); + + expect(controller.state).toStrictEqual({ + accountsByChainId: { + '0x1': { + [CHECKSUM_ADDRESS_1]: { + balance: '0xacac5457a3517e', + stakedBalance: '0x1', + }, + [CHECKSUM_ADDRESS_2]: { + balance: '0x0', + }, + }, + }, + }); + }, + ); + }); + + it('should not update staked balance when includeStakedAssets is disabled', async () => { + // Mock for single address balance update (no staked balances) + // Use lowercase addresses for consistent caching across controllers + mockedGetTokenBalancesForMultipleAddresses.mockResolvedValueOnce({ + tokenBalances: { + '0x0000000000000000000000000000000000000000': { + [ADDRESS_1]: new BN('acac5457a3517e', 16), // lowercase + }, + }, + stakedBalances: {}, // No staked balances when includeStakedAssets is false + }); + + await withController( + { + options: { + includeStakedAssets: false, + getStakedBalanceForChain: mockGetStakedBalanceForChain, + }, + isMultiAccountBalancesEnabled: false, + selectedAccount: ACCOUNT_1, + listAccounts: [ACCOUNT_1, ACCOUNT_2], + }, + async ({ controller, refresh }) => { + await refresh(['mainnet'], false); + + expect(controller.state).toStrictEqual({ + accountsByChainId: { + '0x1': { + [CHECKSUM_ADDRESS_1]: { + balance: '0xacac5457a3517e', + }, + [CHECKSUM_ADDRESS_2]: { + balance: '0x0', + }, + }, + }, + }); + }, + ); + }); + + it('should update staked balance when includeStakedAssets and multi-account is enabled', async () => { + // Mock with both accounts having native and staked balances + // When multi-account is enabled, the fetcher requests lowercase addresses + mockedGetTokenBalancesForMultipleAddresses.mockResolvedValueOnce({ + tokenBalances: { + '0x0000000000000000000000000000000000000000': { + [ADDRESS_1]: new BN('acac5457a3517e', 16), // lowercase + [ADDRESS_2]: new BN('27548bd9e4026c918d4b', 16), // lowercase + }, + }, + stakedBalances: { + [ADDRESS_1]: new BN('1', 16), // lowercase + [ADDRESS_2]: new BN('1', 16), // lowercase + }, + }); + + await withController( + { + options: { + includeStakedAssets: true, + getStakedBalanceForChain: mockGetStakedBalanceForChain, + }, + isMultiAccountBalancesEnabled: true, + selectedAccount: ACCOUNT_1, + listAccounts: [ACCOUNT_1, ACCOUNT_2], + }, + async ({ controller, refresh }) => { + await refresh(['mainnet'], true); + + expect(controller.state).toStrictEqual({ + accountsByChainId: { + '0x1': { + [CHECKSUM_ADDRESS_1]: { + balance: '0xacac5457a3517e', + stakedBalance: '0x1', + }, + [CHECKSUM_ADDRESS_2]: { + balance: '0x27548bd9e4026c918d4b', + stakedBalance: '0x1', + }, + }, + }, + }); + }, + ); + }); + + it('should create account entry when applying staked balance without native balance (line 743)', async () => { + // Mock returning staked balance for ADDRESS_1 and native balance for ADDRESS_2 + // but NO native balance for ADDRESS_1 - this tests the defensive check on line 743 + // Use lowercase addresses since queryAllAccounts: true uses lowercase + mockedGetTokenBalancesForMultipleAddresses.mockResolvedValueOnce({ + tokenBalances: { + '0x0000000000000000000000000000000000000000': { + // Only ADDRESS_2 has native balance, ADDRESS_1 doesn't + [ADDRESS_2]: new BN('100', 16), + }, + }, + stakedBalances: { + // ADDRESS_1 has staked balance but no native balance + [ADDRESS_1]: new BN('2', 16), // 0x2 + [ADDRESS_2]: new BN('3', 16), // 0x3 + }, + }); + + await withController( + { + options: { + includeStakedAssets: true, + getStakedBalanceForChain: mockGetStakedBalanceForChain, + }, + isMultiAccountBalancesEnabled: true, + selectedAccount: ACCOUNT_1, + listAccounts: [ACCOUNT_1, ACCOUNT_2], + }, + async ({ controller, refresh }) => { + 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 + expect(controller.state).toStrictEqual({ + accountsByChainId: { + '0x1': { + [CHECKSUM_ADDRESS_1]: { + balance: '0x0', // Created by line 743 (defensive check) + stakedBalance: '0x2', + }, + [CHECKSUM_ADDRESS_2]: { + balance: '0x100', + stakedBalance: '0x3', + }, + }, + }, + }); + }, + ); + }); + }); + + describe('with networkClientId', () => { + it('should sync addresses', async () => { + // This test refreshes only 0xe705 chain and expects 0x0 balances + // Override the default mock to not provide balances for this chain + mockedGetTokenBalancesForMultipleAddresses.mockResolvedValueOnce({ + tokenBalances: { + '0x0000000000000000000000000000000000000000': {}, + }, + stakedBalances: {}, + }); + + const networkClientId = 'networkClientId1'; + await withController( + { + options: { + state: { + accountsByChainId: { + '0x1': { + [CHECKSUM_ADDRESS_1]: { balance: '0x1' }, + foo: { balance: '0x2' }, + }, + '0x2': { + [CHECKSUM_ADDRESS_1]: { balance: '0xa' }, + foo: { balance: '0xb' }, + }, + }, + }, + }, + isMultiAccountBalancesEnabled: true, + selectedAccount: ACCOUNT_1, + listAccounts: [ACCOUNT_1, ACCOUNT_2], + networkClientById: { + [networkClientId]: buildCustomNetworkClientConfiguration({ + chainId: '0xe705', + }), + }, + }, + async ({ controller, refresh }) => { + await refresh(['networkClientId1'], true); + expect(controller.state).toStrictEqual({ + accountsByChainId: { + '0x1': { + [CHECKSUM_ADDRESS_1]: { balance: '0x1' }, + [CHECKSUM_ADDRESS_2]: { balance: '0x0' }, + }, + '0x2': { + [CHECKSUM_ADDRESS_1]: { balance: '0xa' }, + [CHECKSUM_ADDRESS_2]: { balance: '0x0' }, + }, + '0xe705': { + [CHECKSUM_ADDRESS_1]: { balance: '0x0' }, + [CHECKSUM_ADDRESS_2]: { balance: '0x0' }, + }, + }, + }); + }, + ); + }); + + it('should get real balance', async () => { + // Override the multicall mock for this specific test + // Use lowercase address since that's what the balance fetcher requests + mockedGetTokenBalancesForMultipleAddresses.mockResolvedValueOnce({ + tokenBalances: { + '0x0000000000000000000000000000000000000000': { + [ADDRESS_1]: new BN('10', 16), // 0x10 (lowercase) + }, + }, + stakedBalances: {}, + }); + const networkClientId = 'networkClientId1'; + + await withController( + { + isMultiAccountBalancesEnabled: true, + selectedAccount: ACCOUNT_1, + listAccounts: [ACCOUNT_1], + networkClientById: { + [networkClientId]: buildCustomNetworkClientConfiguration({ + chainId: '0xe705', + }), + }, + }, + async ({ controller, refresh }) => { + await refresh(['networkClientId1'], true); + + expect(controller.state).toStrictEqual({ + accountsByChainId: { + '0x1': { + [CHECKSUM_ADDRESS_1]: { + balance: '0x0', + }, + }, + '0xe705': { + [CHECKSUM_ADDRESS_1]: { + balance: '0x10', + }, + }, + }, + }); + }, + ); + }); + + it('should update only selected address balance when multi-account is disabled', async () => { + // Mock for single address balance update + // When multi-account is disabled, the fetcher requests checksum addresses + mockedGetTokenBalancesForMultipleAddresses.mockResolvedValueOnce({ + tokenBalances: { + '0x0000000000000000000000000000000000000000': { + [ADDRESS_1]: new BN('10', 16), // lowercase + }, + }, + stakedBalances: {}, + }); + const networkClientId = 'networkClientId1'; + + await withController( + { + isMultiAccountBalancesEnabled: false, + selectedAccount: ACCOUNT_1, + listAccounts: [ACCOUNT_1, ACCOUNT_2], + networkClientById: { + [networkClientId]: buildCustomNetworkClientConfiguration({ + chainId: '0xe705', + }), + }, + }, + async ({ controller, refresh }) => { + await refresh(['networkClientId1'], false); + + expect(controller.state).toStrictEqual({ + accountsByChainId: { + '0x1': { + [CHECKSUM_ADDRESS_1]: { balance: '0x0' }, + [CHECKSUM_ADDRESS_2]: { balance: '0x0' }, + }, + '0xe705': { + [CHECKSUM_ADDRESS_1]: { balance: '0x10' }, + [CHECKSUM_ADDRESS_2]: { balance: '0x0' }, + }, + }, + }); + }, + ); + }); + + it('should update all address balances when multi-account is enabled', async () => { + // Mock for multi-address balance update + // When multi-account is enabled, the fetcher requests lowercase addresses + mockedGetTokenBalancesForMultipleAddresses.mockResolvedValueOnce({ + tokenBalances: { + '0x0000000000000000000000000000000000000000': { + [ADDRESS_1]: new BN('11', 16), // 0x11 (lowercase) + [ADDRESS_2]: new BN('12', 16), // 0x12 (lowercase) + }, + }, + stakedBalances: {}, + }); + const networkClientId = 'networkClientId1'; + + await withController( + { + isMultiAccountBalancesEnabled: true, + selectedAccount: ACCOUNT_1, + listAccounts: [ACCOUNT_1, ACCOUNT_2], + networkClientById: { + [networkClientId]: buildCustomNetworkClientConfiguration({ + chainId: '0xe705', + }), + }, + }, + async ({ controller, refresh }) => { + await refresh(['networkClientId1'], true); + + expect(controller.state).toStrictEqual({ + accountsByChainId: { + '0x1': { + [CHECKSUM_ADDRESS_1]: { balance: '0x0' }, + [CHECKSUM_ADDRESS_2]: { balance: '0x0' }, + }, + '0xe705': { + [CHECKSUM_ADDRESS_1]: { balance: '0x11' }, + [CHECKSUM_ADDRESS_2]: { balance: '0x12' }, + }, + }, + }); + }, + ); + }); + + it('should update staked balance when includeStakedAssets is enabled', async () => { + // Mock with both native and staked balances + mockedGetTokenBalancesForMultipleAddresses.mockResolvedValueOnce({ + tokenBalances: { + '0x0000000000000000000000000000000000000000': { + [ADDRESS_1]: new BN('acac5457a3517e', 16), + }, + }, + stakedBalances: { + [ADDRESS_1]: new BN('1', 16), + }, + }); + + const networkClientId = 'holesky'; + + await withController( + { + options: { + includeStakedAssets: true, + getStakedBalanceForChain: mockGetStakedBalanceForChain, + }, + isMultiAccountBalancesEnabled: false, + selectedAccount: ACCOUNT_1, + listAccounts: [ACCOUNT_1, ACCOUNT_2], + networkClientById: { + [networkClientId]: buildCustomNetworkClientConfiguration({ + chainId: '0x4268', + }), + }, + }, + async ({ controller, refresh }) => { + await refresh(['mainnet'], false); + + expect(controller.state).toStrictEqual({ + accountsByChainId: { + '0x1': { + [CHECKSUM_ADDRESS_1]: { + balance: '0xacac5457a3517e', + stakedBalance: '0x1', + }, + [CHECKSUM_ADDRESS_2]: { + balance: '0x0', + }, + }, + }, + }); + }, + ); + }); + + it('should not update staked balance when includeStakedAssets is disabled', async () => { + // Mock for single address balance update (no staked balances) + // Use lowercase addresses for consistent caching across controllers + mockedGetTokenBalancesForMultipleAddresses.mockResolvedValueOnce({ + tokenBalances: { + '0x0000000000000000000000000000000000000000': { + [ADDRESS_1]: new BN('acac5457a3517e', 16), // lowercase + }, + }, + stakedBalances: {}, // No staked balances when includeStakedAssets is false + }); + + const networkClientId = 'holesky'; + + await withController( + { + options: { + includeStakedAssets: false, + getStakedBalanceForChain: mockGetStakedBalanceForChain, + }, + isMultiAccountBalancesEnabled: false, + selectedAccount: ACCOUNT_1, + listAccounts: [ACCOUNT_1, ACCOUNT_2], + networkClientById: { + [networkClientId]: buildCustomNetworkClientConfiguration({ + chainId: '0x4268', + }), + }, + }, + async ({ controller, refresh }) => { + await refresh(['mainnet'], false); + + expect(controller.state).toStrictEqual({ + accountsByChainId: { + '0x1': { + [CHECKSUM_ADDRESS_1]: { + balance: '0xacac5457a3517e', + }, + [CHECKSUM_ADDRESS_2]: { + balance: '0x0', + }, + }, + }, + }); + }, + ); + }); + + it('should update staked balance when includeStakedAssets and multi-account is enabled', async () => { + // Mock with both accounts having native and staked balances + // When multi-account is enabled, the fetcher requests lowercase addresses + mockedGetTokenBalancesForMultipleAddresses.mockResolvedValueOnce({ + tokenBalances: { + '0x0000000000000000000000000000000000000000': { + [ADDRESS_1]: new BN('acac5457a3517e', 16), // lowercase + [ADDRESS_2]: new BN('27548bd9e4026c918d4b', 16), // lowercase + }, + }, + stakedBalances: { + [ADDRESS_1]: new BN('1', 16), // lowercase + [ADDRESS_2]: new BN('1', 16), // lowercase + }, + }); + + const networkClientId = 'holesky'; + + await withController( + { + options: { + includeStakedAssets: true, + getStakedBalanceForChain: mockGetStakedBalanceForChain, + }, + isMultiAccountBalancesEnabled: true, + selectedAccount: ACCOUNT_1, + listAccounts: [ACCOUNT_1, ACCOUNT_2], + networkClientById: { + [networkClientId]: buildCustomNetworkClientConfiguration({ + chainId: '0x4268', + }), + }, + }, + async ({ controller, refresh }) => { + await refresh(['mainnet'], true); + + expect(controller.state).toStrictEqual({ + accountsByChainId: { + '0x1': { + [CHECKSUM_ADDRESS_1]: { + balance: '0xacac5457a3517e', + stakedBalance: '0x1', + }, + [CHECKSUM_ADDRESS_2]: { + balance: '0x27548bd9e4026c918d4b', + stakedBalance: '0x1', + }, + }, + }, + }); + }, + ); + }); + + it('should not update staked balance when includeStakedAssets and multi-account is enabled if network unsupported', async () => { + // Mock for multi-account balance update, but no staked balances since network is unsupported + // When multi-account is enabled, the fetcher requests lowercase addresses + mockedGetTokenBalancesForMultipleAddresses.mockResolvedValueOnce({ + tokenBalances: { + '0x0000000000000000000000000000000000000000': { + [ADDRESS_1]: new BN('acac5457a3517e', 16), // lowercase + [ADDRESS_2]: new BN('27548bd9e4026c918d4b', 16), // lowercase + }, + }, + // No stakedBalances property at all since polygon network doesn't support staked assets + }); + + const networkClientId = 'polygon'; + + await withController( + { + options: { + includeStakedAssets: false, + getStakedBalanceForChain: jest.fn().mockResolvedValue(undefined), + }, + isMultiAccountBalancesEnabled: true, + selectedAccount: ACCOUNT_1, + listAccounts: [ACCOUNT_1, ACCOUNT_2], + networkClientById: { + [networkClientId]: buildCustomNetworkClientConfiguration({ + chainId: '0x89', + }), + }, + }, + async ({ controller, refresh }) => { + await refresh(['mainnet'], true); + + expect(controller.state).toStrictEqual({ + accountsByChainId: { + '0x1': { + [CHECKSUM_ADDRESS_1]: { + balance: '0xacac5457a3517e', + }, + [CHECKSUM_ADDRESS_2]: { + balance: '0x27548bd9e4026c918d4b', + }, + }, + }, + }); + }, + ); + }); + + it('should handle unsupported chains gracefully', async () => { + const networkClientId = 'networkClientId1'; + const consoleWarnSpy = jest.spyOn(console, 'warn').mockImplementation(); + await withController( + { + options: { + state: { + accountsByChainId: { + '0x1': { + [CHECKSUM_ADDRESS_1]: { balance: '0x1' }, + foo: { balance: '0x2' }, + }, + '0x2': { + [CHECKSUM_ADDRESS_1]: { balance: '0xa' }, + foo: { balance: '0xb' }, + }, + }, + }, + }, + isMultiAccountBalancesEnabled: true, + selectedAccount: ACCOUNT_1, + listAccounts: [ACCOUNT_1, ACCOUNT_2], + networkClientById: { + [networkClientId]: buildCustomNetworkClientConfiguration({ + chainId: '0x5', // Goerli - may not be supported by all balance fetchers + }), + }, + }, + async ({ controller, refresh }) => { + // Should not throw an error, even for unsupported chains + await refresh(['networkClientId1'], true); + + // State should still be updated with chain entry from syncAccounts + expect(controller.state.accountsByChainId).toHaveProperty('0x5'); + expect(controller.state.accountsByChainId['0x5']).toHaveProperty( + CHECKSUM_ADDRESS_1, + ); + expect(controller.state.accountsByChainId['0x5']).toHaveProperty( + CHECKSUM_ADDRESS_2, + ); + + consoleWarnSpy.mockRestore(); + }, + ); + }); + + it('should handle timeout error correctly', async () => { + const consoleWarnSpy = jest.spyOn(console, 'warn').mockImplementation(); + + await withController( + { + options: { + state: { + accountsByChainId: { + '0x1': { + [CHECKSUM_ADDRESS_1]: { balance: '0x1' }, + }, + }, + }, + accountsApiChainIds: () => [], // Disable API balance fetchers to force RPC usage + }, + isMultiAccountBalancesEnabled: true, + selectedAccount: ACCOUNT_1, + listAccounts: [ACCOUNT_1, ACCOUNT_2], + }, + async ({ refresh, controller }) => { + // Mock safelyExecuteWithTimeout to simulate timeout by returning undefined + mockedSafelyExecuteWithTimeout.mockImplementation( + async () => undefined, // Simulates timeout behavior + ); + + // Start refresh with the mocked timeout behavior + await refresh(['mainnet'], true); + + // With safelyExecuteWithTimeout, timeouts are handled gracefully + // The system should continue operating without throwing errors + // No specific timeout error message should be logged + expect(consoleWarnSpy).not.toHaveBeenCalledWith( + expect.stringContaining('Timeout after'), + ); + + // Verify that the controller state remains intact despite the timeout + expect(controller.state.accountsByChainId).toHaveProperty('0x1'); + expect(controller.state.accountsByChainId['0x1']).toHaveProperty( + CHECKSUM_ADDRESS_1, + ); + + consoleWarnSpy.mockRestore(); + }, + ); + }); + + it('should use default allowExternalServices when not provided (covers line 390)', async () => { + // Mock fetch to simulate API balance fetcher behavior + const fetchSpy = jest.spyOn(global, 'fetch').mockResolvedValue({ + ok: true, + json: async () => ({ accounts: [] }), + } as Response); + + await withController( + { + options: { + accountsApiChainIds: () => ['0x1'], + // allowExternalServices not provided - should default to () => true (line 390) + }, + isMultiAccountBalancesEnabled: true, + selectedAccount: ACCOUNT_1, + listAccounts: [ACCOUNT_1, ACCOUNT_2], + }, + async ({ refresh }) => { + // Mock RPC query to return balance + mockedQuery.mockResolvedValue('0x0'); + + // Refresh balances for mainnet (supported by API) + 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 + expect(fetchSpy).toHaveBeenCalled(); + + fetchSpy.mockRestore(); + }, + ); + }); + + it('should respect allowExternalServices when set to true', async () => { + // Mock fetch to simulate API balance fetcher behavior + const fetchSpy = jest.spyOn(global, 'fetch').mockResolvedValue({ + ok: true, + json: async () => ({ accounts: [] }), + } as Response); + + await withController( + { + options: { + accountsApiChainIds: () => ['0x1'], + allowExternalServices: () => true, // Explicitly set to true + }, + isMultiAccountBalancesEnabled: true, + selectedAccount: ACCOUNT_1, + listAccounts: [ACCOUNT_1, ACCOUNT_2], + }, + async ({ refresh }) => { + // Mock RPC query to return balance + mockedQuery.mockResolvedValue('0x0'); + + // Refresh balances for mainnet (supported by API) + await refresh(['mainnet'], true); + + // Since allowExternalServices is true and accountsApiChainIds returns ['0x1'], + // the API fetcher should be used, which means fetch should be called + expect(fetchSpy).toHaveBeenCalled(); + + fetchSpy.mockRestore(); + }, + ); + }); + + it('should respect allowExternalServices when set to false', async () => { + // Mock fetch to simulate API balance fetcher behavior + const fetchSpy = jest.spyOn(global, 'fetch').mockResolvedValue({ + ok: true, + json: async () => ({ accounts: [] }), + } as Response); + + await withController( + { + options: { + accountsApiChainIds: () => ['0x1'], + allowExternalServices: () => false, // Explicitly set to false + }, + isMultiAccountBalancesEnabled: true, + selectedAccount: ACCOUNT_1, + listAccounts: [ACCOUNT_1, ACCOUNT_2], + }, + async ({ refresh }) => { + // Mock RPC query to return balance + mockedQuery.mockResolvedValue('0x0'); + + // Refresh balances for mainnet + 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 + expect(fetchSpy).not.toHaveBeenCalled(); + // RPC fetcher should be used as the only balance fetcher + // (mockedQuery may or may not be called depending on implementation details) + + fetchSpy.mockRestore(); + }, + ); + }); + }); + + it('should continue to next fetcher when current fetcher supports no chains', async () => { + // Spy on the AccountsApiBalanceFetcher's supports method to return false + const supportsSpy = jest + .spyOn(AccountsApiBalanceFetcher.prototype, 'supports') + .mockReturnValue(false); + + await withController( + { + options: { + accountsApiChainIds: () => ['0x1'], // Configure to use AccountsAPI for mainnet + allowExternalServices: () => true, + }, + isMultiAccountBalancesEnabled: true, + selectedAccount: ACCOUNT_1, + listAccounts: [ACCOUNT_1], + }, + async ({ controller, refresh }) => { + // Mock RPC query to return balance (this should be used since AccountsAPI supports nothing) + mockedQuery.mockResolvedValue('0x123456'); + + // Refresh balances for mainnet + await refresh(['mainnet'], true); + + // Verify that the supports method was called (meaning we reached the continue logic) + expect(supportsSpy).toHaveBeenCalledWith('0x1'); + + // Verify that state was still updated via RPC fetcher fallback + expect( + controller.state.accountsByChainId['0x1'][CHECKSUM_ADDRESS_1] + .balance, + ).toBeDefined(); + + supportsSpy.mockRestore(); + }, + ); + }); + + it('should log warning when balance fetcher throws an error', async () => { + const consoleWarnSpy = jest.spyOn(console, 'warn').mockImplementation(); + + // Mock AccountsApiBalanceFetcher to throw an error + const fetchSpy = jest + .spyOn(AccountsApiBalanceFetcher.prototype, 'fetch') + .mockRejectedValue(new Error('API request failed')); + + await withController( + { + options: { + accountsApiChainIds: () => ['0x1'], // Configure to use AccountsAPI for mainnet + allowExternalServices: () => true, + }, + isMultiAccountBalancesEnabled: true, + selectedAccount: ACCOUNT_1, + listAccounts: [ACCOUNT_1], + }, + async ({ refresh }) => { + // Mock RPC query to return balance (fallback after API fails) + mockedQuery.mockResolvedValue('0x123456'); + + // Refresh balances for mainnet + await refresh(['mainnet'], true); + + // Verify that console.warn was called with the error message + expect(consoleWarnSpy).toHaveBeenCalledWith( + expect.stringContaining('Balance fetcher failed for chains 0x1:'), + ); + + fetchSpy.mockRestore(); + consoleWarnSpy.mockRestore(); + }, + ); + }); + + it('should handle unprocessedChainIds from fetcher and retry with next fetcher', async () => { + // Mock AccountsApiBalanceFetcher to return unprocessedChainIds + const fetchSpy = jest + .spyOn(AccountsApiBalanceFetcher.prototype, 'fetch') + .mockResolvedValue({ + balances: [], // No balances returned + unprocessedChainIds: ['0x1' as const], // Chain couldn't be processed + }); + + await withController( + { + options: { + accountsApiChainIds: () => ['0x1'], // Configure to use AccountsAPI for mainnet + allowExternalServices: () => true, + }, + isMultiAccountBalancesEnabled: true, + selectedAccount: ACCOUNT_1, + listAccounts: [ACCOUNT_1], + }, + async ({ controller, refresh }) => { + // Mock RPC query to return balance (fallback after API returns unprocessedChainIds) + mockedGetTokenBalancesForMultipleAddresses.mockResolvedValueOnce({ + tokenBalances: { + '0x0000000000000000000000000000000000000000': { + [ADDRESS_1]: new BN('abcdef', 16), + }, + }, + stakedBalances: {}, + }); + + // Refresh balances for mainnet + await refresh(['mainnet'], true); + + // The RPC fetcher should have been used as fallback after API returned unprocessedChainIds + expect( + controller.state.accountsByChainId['0x1'][CHECKSUM_ADDRESS_1] + .balance, + ).toBe('0xabcdef'); + + fetchSpy.mockRestore(); + }, + ); + }); + }); + + describe('syncBalanceWithAddresses', () => { + it('should sync balance with addresses', async () => { + await withController( + { + isMultiAccountBalancesEnabled: true, + selectedAccount: ACCOUNT_1, + listAccounts: [], + }, + async ({ controller }) => { + mockedQuery + .mockReturnValueOnce(Promise.resolve('0x10')) + .mockReturnValueOnce(Promise.resolve('0x20')); + const result = await controller.syncBalanceWithAddresses([ + ADDRESS_1, + ADDRESS_2, + ]); + expect(result[ADDRESS_1].balance).toBe('0x10'); + expect(result[ADDRESS_2].balance).toBe('0x20'); + }, + ); + }); + + it('should return zero-balance entries if network is Tempo Mainnet', async () => { + await withController( + { + isMultiAccountBalancesEnabled: true, + selectedAccount: ACCOUNT_1, + listAccounts: [], + networkClientById: { + 'tempo-mainnet-mock-client-id': + buildCustomNetworkClientConfiguration({ + chainId: '0x1079', + ticker: 'USD', + }), + }, + }, + async ({ controller }) => { + mockedQuery + .mockReturnValueOnce(Promise.resolve('0x10')) + .mockReturnValueOnce(Promise.resolve('0x20')); + const result = await controller.syncBalanceWithAddresses( + [ADDRESS_1, ADDRESS_2], + 'tempo-mainnet-mock-client-id', + ); + expect(result[ADDRESS_1].balance).toBe('0x0'); + expect(result[ADDRESS_2].balance).toBe('0x0'); + }, + ); + }); + + it('should return zero-balance entries if network is Tempo Testnet', async () => { + await withController( + { + isMultiAccountBalancesEnabled: true, + selectedAccount: ACCOUNT_1, + listAccounts: [], + networkClientById: { + 'tempo-testnet-mock-client-id': + buildCustomNetworkClientConfiguration({ + chainId: '0xa5bf', + ticker: 'USD', + }), + }, + }, + async ({ controller }) => { + mockedQuery + .mockReturnValueOnce(Promise.resolve('0x10')) + .mockReturnValueOnce(Promise.resolve('0x20')); + const result = await controller.syncBalanceWithAddresses( + [ADDRESS_1, ADDRESS_2], + 'tempo-testnet-mock-client-id', + ); + expect(result[ADDRESS_1].balance).toBe('0x0'); + expect(result[ADDRESS_2].balance).toBe('0x0'); + }, + ); + }); + + it('should sync staked balance with addresses', async () => { + await withController( + { + options: { + includeStakedAssets: true, + getStakedBalanceForChain: mockGetStakedBalanceForChain, + }, + isMultiAccountBalancesEnabled: true, + selectedAccount: ACCOUNT_1, + listAccounts: [], + }, + async ({ controller }) => { + mockedQuery + .mockReturnValueOnce(Promise.resolve('0x10')) + .mockReturnValueOnce(Promise.resolve('0x20')); + const result = await controller.syncBalanceWithAddresses([ + ADDRESS_1, + ADDRESS_2, + ]); + expect(result[ADDRESS_1].balance).toBe('0x10'); + expect(result[ADDRESS_2].balance).toBe('0x20'); + expect(result[ADDRESS_1].stakedBalance).toBe('0x1'); + expect(result[ADDRESS_2].stakedBalance).toBe('0x1'); + }, + ); + }); + + it('should handle timeout in syncBalanceWithAddresses gracefully', async () => { + await withController( + { + isMultiAccountBalancesEnabled: true, + selectedAccount: ACCOUNT_1, + listAccounts: [], + }, + async ({ controller }) => { + // Mock safelyExecuteWithTimeout to return undefined (timeout case) + mockedSafelyExecuteWithTimeout.mockImplementation( + async () => undefined, // Simulates timeout behavior + ); + + const result = await controller.syncBalanceWithAddresses([ + ADDRESS_1, + ADDRESS_2, + ]); + + // Verify that the result is an empty object when all operations timeout + expect(result).toStrictEqual({}); + + // Restore the mock + mockedSafelyExecuteWithTimeout.mockImplementation( + async (operation: () => Promise) => { + try { + return await operation(); + } catch { + return undefined; + } + }, + ); + }, + ); + }); + + it('should skip balance fetching when isOnboarded returns false', async () => { + await withController( + { + options: { isOnboarded: () => false }, + isMultiAccountBalancesEnabled: true, + selectedAccount: ACCOUNT_1, + listAccounts: [], + }, + async ({ controller }) => { + // Reset query mock to track calls + mockedQuery.mockClear(); + + const result = await controller.syncBalanceWithAddresses([ + ADDRESS_1, + ADDRESS_2, + ]); + + // Should return empty object without making any RPC calls + expect(result).toStrictEqual({}); + + // Verify no RPC calls were made (query should not have been called for getBalance) + expect(mockedQuery).not.toHaveBeenCalled(); + }, + ); + }); + + it('should evaluate isOnboarded dynamically at call time', async () => { + let onboarded = false; + + await withController( + { + options: { isOnboarded: () => onboarded }, + isMultiAccountBalancesEnabled: true, + selectedAccount: ACCOUNT_1, + listAccounts: [], + }, + async ({ controller }) => { + // First call: isOnboarded returns false, should skip fetching + mockedQuery.mockClear(); + + const result1 = await controller.syncBalanceWithAddresses([ + ADDRESS_1, + ]); + + // Should return empty object + expect(result1).toStrictEqual({}); + expect(mockedQuery).not.toHaveBeenCalled(); + + // Now set onboarded to true + onboarded = true; + + mockedQuery.mockReturnValueOnce(Promise.resolve('0xabc123')); + + // Second call: isOnboarded now returns true, should fetch balances + const result2 = await controller.syncBalanceWithAddresses([ + ADDRESS_1, + ]); + + // Should have fetched balance + expect(result2[ADDRESS_1].balance).toBe('0xabc123'); + expect(mockedQuery).toHaveBeenCalled(); + }, + ); + }); + }); + + it('should call refresh every interval on polling', async () => { + const pollSpy = jest.spyOn( + AccountTrackerController.prototype, + '_executePoll', ); + await withController( + { + options: { interval: 100 }, + isMultiAccountBalancesEnabled: true, + selectedAccount: EMPTY_ACCOUNT, + listAccounts: [], + }, + async ({ controller }) => { + jest.spyOn(controller, 'refresh').mockResolvedValue(); + + controller.startPolling({ + networkClientIds: ['networkClientId1'], + queryAllAccounts: true, + }); + await jestAdvanceTime({ duration: 1 }); + + expect(pollSpy).toHaveBeenCalledTimes(1); - await controller.refresh(); + await jestAdvanceTime({ duration: 50 }); - expect(controller.state.accounts[address1].balance).toBe('0x10'); - expect(controller.state.accounts[address2].balance).toBe('0x0'); + expect(pollSpy).toHaveBeenCalledTimes(1); + + await jestAdvanceTime({ duration: 50 }); + + expect(pollSpy).toHaveBeenCalledTimes(2); + }, + ); }); - it('should update all address balances when multi-account is enabled', async () => { - const address1 = '0xc38bf1ad06ef69f0c04e29dbeb4152b4175f0a8d'; - const address2 = '0x742d35Cc6634C0532925a3b844Bc454e4438f44e'; + it('should call refresh every interval for each networkClientId being polled', async () => { + const networkClientId1 = 'networkClientId1'; + const networkClientId2 = 'networkClientId2'; + await withController( + { + options: { interval: 100 }, + isMultiAccountBalancesEnabled: true, + selectedAccount: EMPTY_ACCOUNT, + listAccounts: [], + }, + async ({ controller }) => { + const refreshSpy = jest + .spyOn(controller, 'refresh') + .mockResolvedValue(); + + controller.startPolling({ + networkClientIds: [networkClientId1], + queryAllAccounts: true, + }); - jest - .spyOn(AccountTrackerController.prototype, 'poll') - .mockImplementationOnce(async () => Promise.resolve()); + await jestAdvanceTime({ duration: 0 }); + expect(refreshSpy).toHaveBeenNthCalledWith(1, [networkClientId1], true); + expect(refreshSpy).toHaveBeenCalledTimes(1); + await jestAdvanceTime({ duration: 50 }); + expect(refreshSpy).toHaveBeenCalledTimes(1); + await jestAdvanceTime({ duration: 50 }); + expect(refreshSpy).toHaveBeenNthCalledWith(2, [networkClientId1], true); + expect(refreshSpy).toHaveBeenCalledTimes(2); - mockedQuery.mockReturnValueOnce(Promise.resolve('0x11')); - mockedQuery.mockReturnValueOnce(Promise.resolve('0x12')); + const pollToken = controller.startPolling({ + networkClientIds: [networkClientId2], + queryAllAccounts: true, + }); - const controller = new AccountTrackerController( + await jestAdvanceTime({ duration: 0 }); + expect(refreshSpy).toHaveBeenNthCalledWith(3, [networkClientId2], true); + expect(refreshSpy).toHaveBeenCalledTimes(3); + await jestAdvanceTime({ duration: 100 }); + expect(refreshSpy).toHaveBeenNthCalledWith(4, [networkClientId1], true); + expect(refreshSpy).toHaveBeenNthCalledWith(5, [networkClientId2], true); + expect(refreshSpy).toHaveBeenCalledTimes(5); + + controller.stopPollingByPollingToken(pollToken); + + await jestAdvanceTime({ duration: 100 }); + expect(refreshSpy).toHaveBeenNthCalledWith(6, [networkClientId1], true); + expect(refreshSpy).toHaveBeenCalledTimes(6); + + controller.stopAllPolling(); + + await jestAdvanceTime({ duration: 100 }); + + expect(refreshSpy).toHaveBeenCalledTimes(6); + }, + ); + }); + + it('should not call polling twice', async () => { + await withController( { - onPreferencesStateChange: sinon.stub(), - getIdentities: () => { - return { - [address1]: {} as ContactEntry, - [address2]: {} as ContactEntry, - }; + options: { interval: 100 }, + }, + async ({ controller }) => { + const refreshSpy = jest + .spyOn(controller, 'refresh') + .mockResolvedValue(); + + expect(refreshSpy).not.toHaveBeenCalled(); + controller.startPolling({ + networkClientIds: ['networkClientId1'], + queryAllAccounts: true, + }); + + await jestAdvanceTime({ duration: 1 }); + expect(refreshSpy).toHaveBeenCalledTimes(1); + }, + ); + }); + + 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(`{}`); + }); + }); + + it('persists expected state', async () => { + await withController(({ controller }) => { + expect( + deriveStateFromMetadata( + controller.state, + controller.metadata, + 'persist', + ), + ).toMatchInlineSnapshot(` + { + "accountsByChainId": { + "0x1": {}, + }, + } + `); + }); + }); + + it('exposes expected state to UI', async () => { + await withController(({ controller }) => { + expect( + deriveStateFromMetadata( + controller.state, + controller.metadata, + 'usedInUi', + ), + ).toMatchInlineSnapshot(` + { + "accountsByChainId": { + "0x1": {}, + }, + } + `); + }); + }); + }); + + describe('isDeprecated', () => { + const initialState = { + accountsByChainId: { + '0x1': { + [CHECKSUM_ADDRESS_1]: { balance: '0x1' }, }, - getSelectedAddress: () => address1, - getMultiAccountBalancesEnabled: () => true, }, - { provider }, + }; + + it('clears persisted accountsByChainId at construction when isDeprecated() returns true', async () => { + await withController( + { + options: { state: initialState, isDeprecated: () => true }, + }, + ({ controller }) => { + expect(controller.state.accountsByChainId).toStrictEqual({}); + }, + ); + }); + + it('preserves persisted accountsByChainId at construction when isDeprecated() returns false', async () => { + await withController( + { + options: { state: initialState, isDeprecated: () => false }, + }, + ({ controller }) => { + expect(controller.state.accountsByChainId).toStrictEqual( + initialState.accountsByChainId, + ); + }, + ); + }); + + it('does not throw at construction when isDeprecated() is true and state is already empty', async () => { + await withController( + { + options: { + state: { accountsByChainId: {} }, + isDeprecated: () => true, + }, + }, + ({ controller }) => { + expect(controller.state.accountsByChainId).toStrictEqual({}); + }, + ); + }); + + it('does not fetch and clears stale state on refresh when isDeprecated toggles to true at runtime', async () => { + let deprecated = false; + await withController( + { + options: { state: initialState, isDeprecated: () => deprecated }, + selectedAccount: ACCOUNT_1, + listAccounts: [ACCOUNT_1], + }, + async ({ controller, refresh }) => { + expect(controller.state.accountsByChainId).toStrictEqual( + initialState.accountsByChainId, + ); + + deprecated = true; + + await refresh(['mainnet']); + + expect( + mockedGetTokenBalancesForMultipleAddresses, + ).not.toHaveBeenCalled(); + expect(controller.state.accountsByChainId).toStrictEqual({}); + }, + ); + }); + + it('clears stale state on _executePoll when isDeprecated toggles to true at runtime', async () => { + let deprecated = false; + await withController( + { + options: { state: initialState, isDeprecated: () => deprecated }, + }, + async ({ controller }) => { + deprecated = true; + + await controller._executePoll({ networkClientIds: ['mainnet'] }); + + expect(controller.state.accountsByChainId).toStrictEqual({}); + }, + ); + }); + + it('clears stale state on refreshAddresses when isDeprecated toggles to true at runtime', async () => { + let deprecated = false; + await withController( + { + options: { state: initialState, isDeprecated: () => deprecated }, + listAccounts: [ACCOUNT_1], + }, + async ({ controller }) => { + deprecated = true; + + await controller.refreshAddresses({ + networkClientIds: ['mainnet'], + addresses: [ADDRESS_1], + }); + + expect(controller.state.accountsByChainId).toStrictEqual({}); + }, + ); + }); + + it('returns no balances and clears stale state on syncBalanceWithAddresses when isDeprecated returns true', async () => { + let deprecated = false; + await withController( + { + options: { state: initialState, isDeprecated: () => deprecated }, + }, + async ({ controller }) => { + deprecated = true; + + const result = await controller.syncBalanceWithAddresses([ADDRESS_1]); + + expect(result).toStrictEqual({}); + expect(controller.state.accountsByChainId).toStrictEqual({}); + }, + ); + }); + + it('clears stale state on updateNativeBalances when isDeprecated returns true', async () => { + let deprecated = false; + await withController( + { + options: { state: initialState, isDeprecated: () => deprecated }, + }, + ({ controller }) => { + deprecated = true; + + controller.updateNativeBalances([ + { + address: CHECKSUM_ADDRESS_1, + chainId: '0x1' as const, + balance: '0x5', + }, + ]); + + expect(controller.state.accountsByChainId).toStrictEqual({}); + }, + ); + }); + + it('clears stale state on updateStakedBalances when isDeprecated returns true', async () => { + let deprecated = false; + await withController( + { + options: { state: initialState, isDeprecated: () => deprecated }, + }, + ({ controller }) => { + deprecated = true; + + controller.updateStakedBalances([ + { + address: CHECKSUM_ADDRESS_1, + chainId: '0x1' as const, + stakedBalance: '0x5', + }, + ]); + + expect(controller.state.accountsByChainId).toStrictEqual({}); + }, + ); + }); + }); +}); + +type NetworkEnablementMocks = { + getState: jest.Mock; + listPopularEvmNetworks: jest.Mock; +}; + +type WithControllerCallback = ({ + controller, + networkEnablementMocks, +}: { + controller: AccountTrackerController; + messenger: RootMessenger; + networkEnablementMocks: NetworkEnablementMocks; + triggerSelectedAccountChange: (account: InternalAccount) => void; + refresh: ( + networkClientIds: NetworkClientId[], + queryAllAccounts?: boolean, + ) => Promise; +}) => Promise | ReturnValue; + +type WithControllerOptions = { + options?: Partial[0]>; + isMultiAccountBalancesEnabled?: boolean; + selectedAccount?: InternalAccount; + listAccounts?: InternalAccount[]; + networkClientById?: Record; +}; + +type WithControllerArgs = + | [WithControllerCallback] + | [WithControllerOptions, WithControllerCallback]; + +/** + * Builds a controller based on the given options, and calls the given function + * with that controller. + * + * @param args - Either a function, or an options bag + a function. The options + * bag accepts controller options and config; the function + * will be called with the built controller. + * @returns Whatever the callback returns. + */ +async function withController( + ...args: WithControllerArgs +): Promise { + const [ + { + options = {}, + isMultiAccountBalancesEnabled = false, + selectedAccount = ACCOUNT_1, + listAccounts = [], + networkClientById = {}, + }, + testFunction, + ] = args.length === 2 ? args : [{}, args[0]]; + + const messenger: RootMessenger = new Messenger({ + namespace: MOCK_ANY_NAMESPACE, + }); + + const mockGetSelectedAccount = jest.fn().mockReturnValue(selectedAccount); + messenger.registerActionHandler( + 'AccountsController:getSelectedAccount', + mockGetSelectedAccount, + ); + + const mockListAccounts = jest.fn().mockReturnValue(listAccounts); + messenger.registerActionHandler( + 'AccountsController:listAccounts', + mockListAccounts, + ); + + const getNetworkClientById = buildMockGetNetworkClientById(networkClientById); + messenger.registerActionHandler( + 'NetworkController:getNetworkClientById', + (clientId) => { + const network = getNetworkClientById(clientId); + + const provider = new FakeProvider({ + stubs: [ + { + request: { + method: 'eth_chainId', + }, + response: { result: network.configuration.chainId }, + }, + // Return a balance of 0.04860317424178419 ETH for ADDRESS_1 + { + request: { + method: 'eth_call', + params: [ + { + to: '0xb1f8e55c7f64d203c1400b9d8555d050f94adf39', + data: '0xf0002ea9000000000000000000000000000000000000000000000000000000000000004000000000000000000000000000000000000000000000000000000000000000800000000000000000000000000000000000000000000000000000000000000001000000000000000000000000c38bf1ad06ef69f0c04e29dbeb4152b4175f0a8d00000000000000000000000000000000000000000000000000000000000000010000000000000000000000000000000000000000000000000000000000000000', + }, + 'latest', + ], + }, + response: { + result: + '0x0000000000000000000000000000000000000000000000000000000000000020000000000000000000000000000000000000000000000000000000000000000100000000000000000000000000000000000000000000000000acac5457a3517e', + }, + }, + // Return a balance of 0.04860317424178419 ETH for ADDRESS_1 and 185731.896670448046411083 ETH for ADDRESS_2 + { + request: { + method: 'eth_call', + params: [ + { + to: '0xb1f8e55c7f64d203c1400b9d8555d050f94adf39', + data: '0xf0002ea9000000000000000000000000000000000000000000000000000000000000004000000000000000000000000000000000000000000000000000000000000000a00000000000000000000000000000000000000000000000000000000000000002000000000000000000000000c38bf1ad06ef69f0c04e29dbeb4152b4175f0a8d000000000000000000000000742d35cc6634c0532925a3b844bc454e4438f44e00000000000000000000000000000000000000000000000000000000000000010000000000000000000000000000000000000000000000000000000000000000', + }, + 'latest', + ], + }, + response: { + result: + '0x0000000000000000000000000000000000000000000000000000000000000020000000000000000000000000000000000000000000000000000000000000000200000000000000000000000000000000000000000000000000acac5457a3517e0000000000000000000000000000000000000000000027548bd9e4026c918d4b', + }, + }, + // Mock balanceOf call for zero address - returns same balance data for consistency + { + request: { + method: 'eth_call', + params: [ + { + to: '0xcA11bde05977b3631167028862bE2a173976CA11', + data: '0x70a082310000000000000000000000000000000000000000000000000000000000000000', + }, + 'latest', + ], + }, + response: { + result: + '0x0000000000000000000000000000000000000000000000000000000000000020000000000000000000000000000000000000000000000000000000000000000200000000000000000000000000000000000000000000000000acac5457a3517e0000000000000000000000000000000000000000000027548bd9e4026c918d4b', + }, + }, + ], + // eslint-disable-next-line @typescript-eslint/no-explicit-any + }) as any; + + return { ...network, provider }; + }, + ); + + const mockGetPreferencesControllerState = jest.fn().mockReturnValue({ + ...getDefaultPreferencesState(), + isMultiAccountBalancesEnabled, + }); + messenger.registerActionHandler( + 'PreferencesController:getState', + mockGetPreferencesControllerState, + ); + + const mockNetworkState = jest.fn().mockReturnValue({ + ...getDefaultNetworkControllerState(), + chainId: initialChainId, + }); + + messenger.registerActionHandler( + 'NetworkController:getState', + mockNetworkState, + ); + + const mockGetNetworkEnablementState = jest.fn().mockReturnValue({ + enabledNetworkMap: { + eip155: { [initialChainId]: true }, + }, + }); + messenger.registerActionHandler( + 'NetworkEnablementController:getState', + mockGetNetworkEnablementState, + ); + + const defaultNetworkState = getDefaultNetworkControllerState(); + const mockListPopularEvmNetworks = jest + .fn() + .mockReturnValue( + Object.keys(defaultNetworkState.networkConfigurationsByChainId) as Hex[], ); + messenger.registerActionHandler( + 'NetworkEnablementController:listPopularEvmNetworks', + mockListPopularEvmNetworks, + ); + + messenger.registerActionHandler( + 'KeyringController:getState', + jest.fn().mockReturnValue({ isUnlocked: true }), + ); - await controller.refresh(); + const accountTrackerMessenger = new Messenger< + 'AccountTrackerController', + AllAccountTrackerControllerActions, + AllAccountTrackerControllerEvents, + RootMessenger + >({ + namespace: 'AccountTrackerController', + parent: messenger, + }); + messenger.delegate({ + messenger: accountTrackerMessenger, + actions: [ + 'NetworkController:getNetworkClientById', + 'NetworkController:getState', + 'NetworkEnablementController:getState', + 'NetworkEnablementController:listPopularEvmNetworks', + 'PreferencesController:getState', + 'AccountsController:getSelectedAccount', + 'AccountsController:listAccounts', + 'KeyringController:getState', + ], + events: [ + 'AccountsController:selectedEvmAccountChange', + 'TransactionController:unapprovedTransactionAdded', + 'TransactionController:transactionConfirmed', + 'NetworkController:networkAdded', + 'KeyringController:lock', + 'KeyringController:unlock', + ], + }); - expect(controller.state.accounts[address1].balance).toBe('0x11'); - expect(controller.state.accounts[address2].balance).toBe('0x12'); + const triggerSelectedAccountChange = (account: InternalAccount) => { + messenger.publish('AccountsController:selectedEvmAccountChange', account); + }; + + const controller = new AccountTrackerController({ + messenger: accountTrackerMessenger, + getStakedBalanceForChain: jest.fn(), + ...options, + }); + + const refresh = async ( + networkClientIds: NetworkClientId[], + queryAllAccounts?: boolean, + ) => { + const promise = controller.refresh(networkClientIds, queryAllAccounts); + await jest.advanceTimersByTimeAsync(1); + await promise; + }; + + return await testFunction({ + controller, + messenger, + networkEnablementMocks: { + getState: mockGetNetworkEnablementState, + listPopularEvmNetworks: mockListPopularEvmNetworks, + }, + triggerSelectedAccountChange, + refresh, + }); +} + +describe('AccountTrackerController batch update methods', () => { + describe('updateNativeBalances', () => { + it('should update multiple native token balances in a single operation', async () => { + await withController({}, async ({ controller }) => { + const balanceUpdates = [ + { + address: CHECKSUM_ADDRESS_1, + chainId: '0x1' as const, + balance: '0x1bc16d674ec80000' as const, // 2 ETH + }, + { + address: CHECKSUM_ADDRESS_2, + chainId: '0x1' as const, + balance: '0x38d7ea4c68000' as const, // 1 ETH + }, + { + address: CHECKSUM_ADDRESS_1, + chainId: '0x89' as const, // Polygon + balance: '0x56bc75e2d630eb20' as const, // 6.25 MATIC + }, + ]; + + controller.updateNativeBalances(balanceUpdates); + + expect(controller.state.accountsByChainId).toStrictEqual({ + '0x1': { + [CHECKSUM_ADDRESS_1]: { balance: '0x1bc16d674ec80000' }, + [CHECKSUM_ADDRESS_2]: { balance: '0x38d7ea4c68000' }, + }, + '0x89': { + [CHECKSUM_ADDRESS_1]: { balance: '0x56bc75e2d630eb20' }, + }, + }); + }); + }); + + it('should create new chain entries when updating balances for new chains', async () => { + await withController({}, async ({ controller }) => { + const balanceUpdates = [ + { + address: CHECKSUM_ADDRESS_1, + chainId: '0xa4b1' as const, // Arbitrum + balance: '0x2386f26fc10000' as const, // 0.01 ETH + }, + ]; + + controller.updateNativeBalances(balanceUpdates); + + expect(controller.state.accountsByChainId['0xa4b1']).toStrictEqual({ + [CHECKSUM_ADDRESS_1]: { balance: '0x2386f26fc10000' }, + }); + }); + }); + + it('should create new account entries when updating balances for new addresses', async () => { + await withController({}, async ({ controller }) => { + // First set an existing balance + controller.updateNativeBalances([ + { + address: CHECKSUM_ADDRESS_1, + chainId: '0x1' as const, + balance: '0x1bc16d674ec80000', + }, + ]); + + // Then add a new address on the same chain + const newAddress = '0x1234567890123456789012345678901234567890'; + controller.updateNativeBalances([ + { + address: newAddress, + chainId: '0x1' as const, + balance: '0x38d7ea4c68000', + }, + ]); + + expect(controller.state.accountsByChainId['0x1']).toStrictEqual({ + [CHECKSUM_ADDRESS_1]: { balance: '0x1bc16d674ec80000' }, + [newAddress]: { balance: '0x38d7ea4c68000' }, + }); + }); + }); + + it('should update existing balances without affecting other properties', async () => { + await withController( + { + options: { + state: { + accountsByChainId: { + '0x1': { + [CHECKSUM_ADDRESS_1]: { + balance: '0x0', + stakedBalance: '0x5', + }, + }, + }, + }, + }, + }, + async ({ controller }) => { + // Update only native balance + controller.updateNativeBalances([ + { + address: CHECKSUM_ADDRESS_1, + chainId: '0x1' as const, + balance: '0x1bc16d674ec80000', + }, + ]); + + expect( + controller.state.accountsByChainId['0x1'][CHECKSUM_ADDRESS_1], + ).toStrictEqual({ + balance: '0x1bc16d674ec80000', + stakedBalance: '0x5', // Should remain unchanged + }); + }, + ); + }); + + it('should handle empty balance updates array', async () => { + await withController({}, async ({ controller }) => { + const initialState = controller.state.accountsByChainId; + + controller.updateNativeBalances([]); + + expect(controller.state.accountsByChainId).toStrictEqual(initialState); + }); + }); + + it('should handle zero balances', async () => { + await withController({}, async ({ controller }) => { + controller.updateNativeBalances([ + { + address: CHECKSUM_ADDRESS_1, + chainId: '0x1' as const, + balance: '0x0', + }, + ]); + + expect(controller.state.accountsByChainId['0x1']).toStrictEqual({ + [CHECKSUM_ADDRESS_1]: { balance: '0x0' }, + }); + }); + }); + }); + + describe('updateStakedBalances', () => { + it('should update multiple staked balances in a single operation', async () => { + await withController({}, async ({ controller }) => { + const stakedBalanceUpdates = [ + { + address: CHECKSUM_ADDRESS_1, + chainId: '0x1' as const, + stakedBalance: '0x1bc16d674ec80000', // 2 ETH staked + }, + { + address: CHECKSUM_ADDRESS_2, + chainId: '0x1' as const, + stakedBalance: '0x38d7ea4c68000', // 1 ETH staked + }, + { + address: CHECKSUM_ADDRESS_1, + chainId: '0x89' as const, // Polygon + stakedBalance: '0x56bc75e2d630eb20', // 6.25 MATIC staked + }, + ]; + + controller.updateStakedBalances(stakedBalanceUpdates); + + expect(controller.state.accountsByChainId).toStrictEqual({ + '0x1': { + [CHECKSUM_ADDRESS_1]: { + balance: '0x0', + stakedBalance: '0x1bc16d674ec80000', + }, + [CHECKSUM_ADDRESS_2]: { + balance: '0x0', + stakedBalance: '0x38d7ea4c68000', + }, + }, + '0x89': { + [CHECKSUM_ADDRESS_1]: { + balance: '0x0', + stakedBalance: '0x56bc75e2d630eb20', + }, + }, + }); + }); + }); + + it('should handle undefined staked balances', async () => { + await withController({}, async ({ controller }) => { + controller.updateStakedBalances([ + { + address: CHECKSUM_ADDRESS_1, + chainId: '0x1' as const, + stakedBalance: undefined, + }, + ]); + + expect(controller.state.accountsByChainId['0x1']).toStrictEqual({ + [CHECKSUM_ADDRESS_1]: { balance: '0x0', stakedBalance: undefined }, + }); + }); + }); + + it('should create new chain and account entries for staked balances', async () => { + await withController({}, async ({ controller }) => { + controller.updateStakedBalances([ + { + address: CHECKSUM_ADDRESS_1, + chainId: '0xa4b1' as const, // Arbitrum + stakedBalance: '0x2386f26fc10000', + }, + ]); + + expect(controller.state.accountsByChainId['0xa4b1']).toStrictEqual({ + [CHECKSUM_ADDRESS_1]: { + balance: '0x0', + stakedBalance: '0x2386f26fc10000', + }, + }); + }); + }); + + it('should update staked balances without affecting native balances', async () => { + await withController( + { + options: { + state: { + accountsByChainId: { + '0x1': { + [CHECKSUM_ADDRESS_1]: { + balance: '0x1bc16d674ec80000', + }, + }, + }, + }, + }, + }, + async ({ controller }) => { + // Update only staked balance + controller.updateStakedBalances([ + { + address: CHECKSUM_ADDRESS_1, + chainId: '0x1' as const, + stakedBalance: '0x38d7ea4c68000', + }, + ]); + + expect( + controller.state.accountsByChainId['0x1'][CHECKSUM_ADDRESS_1], + ).toStrictEqual({ + balance: '0x1bc16d674ec80000', // Should remain unchanged + stakedBalance: '0x38d7ea4c68000', + }); + }, + ); + }); + + it('should handle zero staked balances', async () => { + await withController({}, async ({ controller }) => { + controller.updateStakedBalances([ + { + address: CHECKSUM_ADDRESS_1, + chainId: '0x1' as const, + stakedBalance: '0x0', + }, + ]); + + expect(controller.state.accountsByChainId['0x1']).toStrictEqual({ + [CHECKSUM_ADDRESS_1]: { balance: '0x0', stakedBalance: '0x0' }, + }); + }); + }); + + it('should handle empty staked balance updates array', async () => { + await withController({}, async ({ controller }) => { + const initialState = controller.state.accountsByChainId; + + controller.updateStakedBalances([]); + + expect(controller.state.accountsByChainId).toStrictEqual(initialState); + }); + }); + }); + + describe('combined native and staked balance updates', () => { + it('should handle both native and staked balance updates for the same account', async () => { + await withController({}, async ({ controller }) => { + // Update native balance first + controller.updateNativeBalances([ + { + address: CHECKSUM_ADDRESS_1, + chainId: '0x1' as const, + balance: '0x1bc16d674ec80000', + }, + ]); + + // Then update staked balance + controller.updateStakedBalances([ + { + address: CHECKSUM_ADDRESS_1, + chainId: '0x1' as const, + stakedBalance: '0x38d7ea4c68000', + }, + ]); + + expect(controller.state.accountsByChainId['0x1']).toStrictEqual({ + [CHECKSUM_ADDRESS_1]: { + balance: '0x1bc16d674ec80000', + stakedBalance: '0x38d7ea4c68000', + }, + }); + }); + }); + + it('should maintain independent state for different chains', async () => { + await withController({}, async ({ controller }) => { + // Update balances on mainnet + controller.updateNativeBalances([ + { + address: CHECKSUM_ADDRESS_1, + chainId: '0x1' as const, + balance: '0x1bc16d674ec80000', + }, + ]); + + controller.updateStakedBalances([ + { + address: CHECKSUM_ADDRESS_1, + chainId: '0x1' as const, + stakedBalance: '0x38d7ea4c68000', + }, + ]); + + // Update balances on polygon + controller.updateNativeBalances([ + { + address: CHECKSUM_ADDRESS_1, + chainId: '0x89' as const, + balance: '0x56bc75e2d630eb20', + }, + ]); + + expect(controller.state.accountsByChainId).toStrictEqual({ + '0x1': { + [CHECKSUM_ADDRESS_1]: { + balance: '0x1bc16d674ec80000', + stakedBalance: '0x38d7ea4c68000', + }, + }, + '0x89': { + [CHECKSUM_ADDRESS_1]: { + balance: '0x56bc75e2d630eb20', + }, + }, + }); + }); + }); }); }); diff --git a/packages/assets-controllers/src/AccountTrackerController.ts b/packages/assets-controllers/src/AccountTrackerController.ts index 84aa9cbc0f3..3606dbbd1c2 100644 --- a/packages/assets-controllers/src/AccountTrackerController.ts +++ b/packages/assets-controllers/src/AccountTrackerController.ts @@ -1,236 +1,1107 @@ -import type { BaseConfig, BaseState } from '@metamask/base-controller'; -import { BaseController } from '@metamask/base-controller'; +import { Web3Provider } from '@ethersproject/providers'; +import type { + AccountsControllerSelectedEvmAccountChangeEvent, + AccountsControllerGetSelectedAccountAction, + AccountsControllerListAccountsAction, +} from '@metamask/accounts-controller'; +import type { + ControllerStateChangeEvent, + ControllerGetStateAction, + StateMetadata, +} from '@metamask/base-controller'; import { - BNToHex, query, safelyExecuteWithTimeout, + toChecksumHexAddress, } from '@metamask/controller-utils'; import EthQuery from '@metamask/eth-query'; -import type { Provider } from '@metamask/eth-query'; -import type { PreferencesState } from '@metamask/preferences-controller'; -import { assert } from '@metamask/utils'; +import type { + KeyringControllerGetStateAction, + KeyringControllerLockEvent, + KeyringControllerUnlockEvent, +} from '@metamask/keyring-controller'; +import type { InternalAccount } from '@metamask/keyring-internal-api'; +import type { Messenger } from '@metamask/messenger'; +import type { + NetworkClient, + NetworkClientId, + NetworkControllerGetNetworkClientByIdAction, + NetworkControllerGetStateAction, + NetworkControllerNetworkAddedEvent, +} from '@metamask/network-controller'; +import type { + NetworkEnablementControllerGetStateAction, + NetworkEnablementControllerListPopularEvmNetworksAction, +} from '@metamask/network-enablement-controller'; +import { StaticIntervalPollingController } from '@metamask/polling-controller'; +import type { + TransactionControllerTransactionConfirmedEvent, + TransactionControllerUnapprovedTransactionAddedEvent, + TransactionMeta, +} from '@metamask/transaction-controller'; +import { assert, KnownCaipNamespace } from '@metamask/utils'; +import type { Hex } from '@metamask/utils'; import { Mutex } from 'async-mutex'; +import { cloneDeep, isEqual } from 'lodash'; + +import type { AccountTrackerControllerMethodActions } from './AccountTrackerController-method-action-types.js'; +import { STAKING_CONTRACT_ADDRESS_BY_CHAINID } from './AssetsContractController.js'; +import type { + AssetsContractController, + StakedBalance, +} from './AssetsContractController.js'; +import { shouldIncludeNativeToken } from './constants.js'; +import { AccountsApiBalanceFetcher } from './multi-chain-accounts-service/api-balance-fetcher.js'; +import type { + BalanceFetcher, + BalanceFetchResult, + ProcessedBalance, +} from './multi-chain-accounts-service/api-balance-fetcher.js'; +import { RpcBalanceFetcher } from './rpc-service/rpc-balance-fetcher.js'; /** - * @type AccountInformation + * The name of the {@link AccountTrackerController}. + */ +const controllerName = 'AccountTrackerController'; + +export type ChainIdHex = Hex; +export type ChecksumAddress = Hex; + +const ZERO_ADDRESS = + '0x0000000000000000000000000000000000000000' as ChecksumAddress; + +/** + * Creates an RPC balance fetcher configured for AccountTracker use case. + * Returns only native balances and staked balances (no token balances). * - * Account information object - * @property balance - Hex string of an account balancec in wei + * @param getProvider - Function to get Web3Provider for a given chain ID + * @param getNetworkClient - Function to get NetworkClient for a given chain ID + * @param includeStakedAssets - Whether to include staked assets in the fetch + * @returns BalanceFetcher configured to fetch only native and optionally staked balances */ -export interface AccountInformation { - balance: string; +function createAccountTrackerRpcBalanceFetcher( + getProvider: (chainId: Hex) => Web3Provider, + getNetworkClient: (chainId: Hex) => NetworkClient, + includeStakedAssets: boolean, +): BalanceFetcher { + // Provide empty tokens state to ensure only native and staked balances are fetched + const getEmptyTokensState = (): { + allTokens: Record; + allDetectedTokens: Record; + } => ({ + allTokens: {}, + allDetectedTokens: {}, + }); + + const rpcBalanceFetcher = new RpcBalanceFetcher( + getProvider, + getNetworkClient, + getEmptyTokensState, + ); + + // Wrap the RpcBalanceFetcher to filter staked balances when not needed + return { + supports(_chainId: ChainIdHex): boolean { + return rpcBalanceFetcher.supports(); + }, + + async fetch( + params: Parameters[0], + ): Promise { + const result = await rpcBalanceFetcher.fetch(params); + + if (!includeStakedAssets) { + // Filter out staked balances from the results + return { + balances: result.balances.filter( + (balance) => balance.token === ZERO_ADDRESS, + ), + unprocessedChainIds: result.unprocessedChainIds, + }; + } + + return result; + }, + }; } /** - * @type AccountTrackerConfig + * AccountInformation + * + * Account information object + * + * balance - Hex string of an account balance in wei * - * Account tracker controller configuration - * @property provider - Provider used to create a new underlying EthQuery instance + * stakedBalance - Hex string of an account staked balance in wei */ -export interface AccountTrackerConfig extends BaseConfig { - interval: number; - provider?: Provider; -} +export type AccountInformation = { + balance: string; + stakedBalance?: string; +}; /** - * @type AccountTrackerState + * AccountTrackerControllerState * * Account tracker controller state - * @property accounts - Map of addresses to account information + * + * accountsByChainId - Map of addresses to account information by chain */ -export interface AccountTrackerState extends BaseState { - accounts: { [address: string]: AccountInformation }; -} +export type AccountTrackerControllerState = { + accountsByChainId: Record; +}; + +const accountTrackerMetadata: StateMetadata = { + accountsByChainId: { + includeInStateLogs: false, + persist: true, + includeInDebugSnapshot: false, + usedInUi: true, + }, +}; + +/** + * The action that can be performed to get the state of the {@link AccountTrackerController}. + */ +export type AccountTrackerControllerGetStateAction = ControllerGetStateAction< + typeof controllerName, + AccountTrackerControllerState +>; + +/** + * The actions that can be performed using the {@link AccountTrackerController}. + */ +export type AccountTrackerControllerActions = + | AccountTrackerControllerGetStateAction + | AccountTrackerControllerMethodActions; + +/** + * The messenger of the {@link AccountTrackerController} for communication. + */ +export type AllowedActions = + | AccountsControllerListAccountsAction + | { + type: 'PreferencesController:getState'; + handler: () => { isMultiAccountBalancesEnabled: boolean }; + } + | AccountsControllerGetSelectedAccountAction + | NetworkControllerGetStateAction + | NetworkControllerGetNetworkClientByIdAction + | NetworkEnablementControllerGetStateAction + | NetworkEnablementControllerListPopularEvmNetworksAction + | KeyringControllerGetStateAction; + +/** + * The event that {@link AccountTrackerController} can emit. + */ +export type AccountTrackerControllerStateChangeEvent = + ControllerStateChangeEvent< + typeof controllerName, + AccountTrackerControllerState + >; + +/** + * The events that {@link AccountTrackerController} can emit. + */ +export type AccountTrackerControllerEvents = + AccountTrackerControllerStateChangeEvent; + +/** + * The external events available to the {@link AccountTrackerController}. + */ +export type AllowedEvents = + | AccountsControllerSelectedEvmAccountChangeEvent + | TransactionControllerUnapprovedTransactionAddedEvent + | TransactionControllerTransactionConfirmedEvent + | NetworkControllerNetworkAddedEvent + | KeyringControllerLockEvent + | KeyringControllerUnlockEvent; + +/** + * The messenger of the {@link AccountTrackerController}. + */ +export type AccountTrackerControllerMessenger = Messenger< + typeof controllerName, + AccountTrackerControllerActions | AllowedActions, + AccountTrackerControllerEvents | AllowedEvents +>; + +/** The input to start polling for the {@link AccountTrackerController} */ +type AccountTrackerPollingInput = { + networkClientIds: NetworkClientId[]; + queryAllAccounts?: boolean; +}; + +const MESSENGER_EXPOSED_METHODS = [ + 'updateNativeBalances', + 'updateStakedBalances', + 'refresh', + 'syncBalanceWithAddresses', +] as const; /** * Controller that tracks the network balances for all user accounts. */ -export class AccountTrackerController extends BaseController< - AccountTrackerConfig, - AccountTrackerState +export class AccountTrackerController extends StaticIntervalPollingController()< + typeof controllerName, + AccountTrackerControllerState, + AccountTrackerControllerMessenger > { - private ethQuery?: EthQuery; + readonly #refreshMutex = new Mutex(); + + readonly #includeStakedAssets: boolean; + + readonly #accountsApiChainIds: () => ChainIdHex[]; + + readonly #getStakedBalanceForChain: AssetsContractController['getStakedBalanceForChain']; + + readonly #balanceFetchers: BalanceFetcher[]; + + readonly #fetchingEnabled: () => boolean; + + readonly #isOnboarded: () => boolean; - private readonly mutex = new Mutex(); + readonly #isHomepageSectionsV1Enabled: () => boolean; - private handle?: ReturnType; + readonly #isDeprecated: () => boolean; - private syncAccounts() { - const { accounts } = this.state; - const addresses = Object.keys(this.getIdentities()); - const existing = Object.keys(accounts); + /** Track if the keyring is locked */ + #isLocked = true; + + /** + * Creates an AccountTracker instance. + * + * @param options - The controller options. + * @param options.interval - Polling interval used to fetch new account balances. + * @param options.state - Initial state to set on this controller. + * @param options.messenger - The controller messenger. + * @param options.getStakedBalanceForChain - The function to get the staked native asset balance for a chain. + * @param options.includeStakedAssets - Whether to include staked assets in the account balances. + * @param options.accountsApiChainIds - Function that returns array of chainIds that should use Accounts-API strategy (if supported by API). + * @param options.allowExternalServices - Disable external HTTP calls (privacy / offline mode). + * @param options.fetchingEnabled - Function that returns whether the controller is fetching enabled. + * @param options.isOnboarded - Whether the user has completed onboarding. If false, balance updates are skipped. + * @param options.isHomepageSectionsV1Enabled - Whether the homepage sections v1 is enabled. + * @param options.isDeprecated - Optional function that returns true to completely + * disable this controller (no requests, no state updates). When it returns + * `true`, `accountsByChainId` is reset to `{}` at construction and at every + * entry point, so no stale balances remain in state. The function is evaluated + * dynamically on each entry point so it can be toggled at runtime. Intended for + * use when a higher-level controller (e.g. AssetsController) supersedes this one. + */ + constructor({ + interval = 10000, + state, + messenger, + getStakedBalanceForChain, + includeStakedAssets = false, + accountsApiChainIds = (): ChainIdHex[] => [], + allowExternalServices = (): boolean => true, + fetchingEnabled = (): boolean => true, + isOnboarded = (): boolean => true, + isHomepageSectionsV1Enabled = (): boolean => false, + isDeprecated = (): boolean => false, + }: { + interval?: number; + state?: Partial; + messenger: AccountTrackerControllerMessenger; + getStakedBalanceForChain: AssetsContractController['getStakedBalanceForChain']; + includeStakedAssets?: boolean; + accountsApiChainIds?: () => ChainIdHex[]; + isHomepageSectionsV1Enabled?: () => boolean; + allowExternalServices?: () => boolean; + fetchingEnabled?: () => boolean; + isOnboarded?: () => boolean; + isDeprecated?: () => boolean; + }) { + const { selectedNetworkClientId } = messenger.call( + 'NetworkController:getState', + ); + const { + configuration: { chainId }, + } = messenger.call( + 'NetworkController:getNetworkClientById', + selectedNetworkClientId, + ); + super({ + name: controllerName, + messenger, + state: { + accountsByChainId: { + [chainId]: {}, + }, + ...state, + }, + metadata: accountTrackerMetadata, + }); + this.#getStakedBalanceForChain = getStakedBalanceForChain; + + this.#includeStakedAssets = includeStakedAssets; + this.#accountsApiChainIds = accountsApiChainIds; + this.#isHomepageSectionsV1Enabled = isHomepageSectionsV1Enabled; + + // Initialize balance fetchers - Strategy order: API first, then RPC fallback + this.#balanceFetchers = [ + ...(accountsApiChainIds().length > 0 && allowExternalServices() + ? [this.#createAccountsApiFetcher()] + : []), + createAccountTrackerRpcBalanceFetcher( + this.#getProvider, + this.#getNetworkClient, + this.#includeStakedAssets, + ), + ]; + + this.#fetchingEnabled = fetchingEnabled; + this.#isOnboarded = isOnboarded; + this.#isDeprecated = isDeprecated; + + if (this.#isDeprecated()) { + this.#enforceDisabledState(); + } + + const { isUnlocked } = this.messenger.call('KeyringController:getState'); + this.#isLocked = !isUnlocked; + + this.setIntervalLength(interval); + + this.messenger.subscribe( + 'AccountsController:selectedEvmAccountChange', + (newAddress, prevAddress) => { + if (newAddress !== prevAddress) { + // Making an async call for this new event + // eslint-disable-next-line @typescript-eslint/no-floating-promises + this.refresh(this.#getNetworkClientIds()); + } + }, + (event): string => event.address, + ); + + this.messenger.subscribe( + 'NetworkController:networkAdded', + (networkConfiguration) => { + const { networkClientId } = + networkConfiguration.rpcEndpoints[ + networkConfiguration.defaultRpcEndpointIndex + ]; + this.refresh([networkClientId]).catch(() => { + // Silently handle refresh errors + }); + }, + ); + + this.messenger.subscribe('KeyringController:unlock', () => { + this.#isLocked = false; + const networkClientIds = this.#getNetworkClientIds(); + this.refresh(networkClientIds).catch((error) => { + console.error('Error refreshing balances after keyring unlock:', error); + }); + }); + + this.messenger.subscribe('KeyringController:lock', () => { + this.#isLocked = true; + }); + + this.messenger.subscribe( + 'TransactionController:unapprovedTransactionAdded', + (transactionMeta: TransactionMeta) => { + const addresses = [transactionMeta.txParams.from]; + if (transactionMeta.txParams.to) { + addresses.push(transactionMeta.txParams.to); + } + this.refreshAddresses({ + networkClientIds: [transactionMeta.networkClientId], + addresses, + }).catch(() => { + // Silently handle refresh errors + }); + }, + ); + + this.messenger.subscribe( + 'TransactionController:transactionConfirmed', + (transactionMeta: TransactionMeta) => { + const addresses = [transactionMeta.txParams.from]; + if (transactionMeta.txParams.to) { + addresses.push(transactionMeta.txParams.to); + } + this.refreshAddresses({ + networkClientIds: [transactionMeta.networkClientId], + addresses, + }).catch(() => { + // Silently handle refresh errors + }); + }, + ); + + messenger.registerMethodActionHandlers(this, MESSENGER_EXPOSED_METHODS); + } + + /** + * Whether the controller is active (keyring is unlocked and user is onboarded). + * When locked or not onboarded, balance updates should be skipped. + * + * @returns Whether the controller should perform balance updates. + */ + get isActive(): boolean { + return !this.#isLocked && this.#isOnboarded(); + } + + /** + * Clears all persisted `accountsByChainId` so that no stale balances remain in + * state. + * + * Called from every entry point when `isDeprecated()` is true so that a runtime + * toggle propagates to state immediately, even if the controller was originally + * constructed while it was enabled. The update is skipped when + * `accountsByChainId` is already empty to avoid emitting redundant state + * changes. + */ + #enforceDisabledState(): void { + if (Object.keys(this.state.accountsByChainId).length === 0) { + return; + } + this.update((state) => { + state.accountsByChainId = {}; + }); + } + + #syncAccounts(newChainIds: string[]): void { + const accountsByChainId = cloneDeep(this.state.accountsByChainId); + const { selectedNetworkClientId } = this.messenger.call( + 'NetworkController:getState', + ); + const { + configuration: { chainId: currentChainId }, + } = this.messenger.call( + 'NetworkController:getNetworkClientById', + selectedNetworkClientId, + ); + + const existing = Object.keys(accountsByChainId?.[currentChainId] ?? {}); + + // Initialize new chain IDs if they don't exist + newChainIds.forEach((newChainId) => { + if (!accountsByChainId[newChainId]) { + accountsByChainId[newChainId] = {}; + existing.forEach((address) => { + accountsByChainId[newChainId][address] = { balance: '0x0' }; + }); + } + }); + + // Note: The address from the preferences controller are checksummed + // The addresses from the accounts controller are lowercased + const addresses = Object.values( + this.messenger + .call('AccountsController:listAccounts') + .map((internalAccount) => + toChecksumHexAddress(internalAccount.address), + ), + ); const newAddresses = addresses.filter( (address) => !existing.includes(address), ); const oldAddresses = existing.filter( (address) => !addresses.includes(address), ); - newAddresses.forEach((address) => { - accounts[address] = { balance: '0x0' }; + Object.keys(accountsByChainId).forEach((chainId) => { + newAddresses.forEach((address) => { + if (!accountsByChainId[chainId][address]) { + accountsByChainId[chainId][address] = { + balance: '0x0', + }; + } + }); }); - oldAddresses.forEach((address) => { - delete accounts[address]; + Object.keys(accountsByChainId).forEach((chainId) => { + oldAddresses.forEach((address) => { + delete accountsByChainId[chainId][address]; + }); }); - this.update({ accounts: { ...accounts } }); + + if (!isEqual(this.state.accountsByChainId, accountsByChainId)) { + this.update((state) => { + state.accountsByChainId = accountsByChainId; + }); + } } - /** - * Name of this controller used during composition - */ - override name = 'AccountTrackerController'; + readonly #getProvider = (chainId: Hex): Web3Provider => { + const { networkConfigurationsByChainId } = this.messenger.call( + 'NetworkController:getState', + ); + const networkConfig = networkConfigurationsByChainId[chainId]; + const { networkClientId } = + networkConfig.rpcEndpoints[networkConfig.defaultRpcEndpointIndex]; + const client = this.messenger.call( + 'NetworkController:getNetworkClientById', + networkClientId, + ); + return new Web3Provider(client.provider); + }; - private readonly getIdentities: () => PreferencesState['identities']; + readonly #getNetworkClient = (chainId: Hex): NetworkClient => { + const { networkConfigurationsByChainId } = this.messenger.call( + 'NetworkController:getState', + ); + const networkConfig = networkConfigurationsByChainId[chainId]; + const { networkClientId } = + networkConfig.rpcEndpoints[networkConfig.defaultRpcEndpointIndex]; + return this.messenger.call( + 'NetworkController:getNetworkClientById', + networkClientId, + ); + }; - private readonly getSelectedAddress: () => PreferencesState['selectedAddress']; + /** + * Creates an AccountsApiBalanceFetcher that only supports chains in the accountsApiChainIds array + * + * @returns A BalanceFetcher that wraps AccountsApiBalanceFetcher with chainId filtering + */ + readonly #createAccountsApiFetcher = (): BalanceFetcher => { + const originalFetcher = new AccountsApiBalanceFetcher( + 'extension', + this.#getProvider, + ); - private readonly getMultiAccountBalancesEnabled: () => PreferencesState['isMultiAccountBalancesEnabled']; + return { + supports: (chainId: ChainIdHex): boolean => { + // Only support chains that are both: + // 1. In our specified accountsApiChainIds array + // 2. Actually supported by the AccountsApi + return ( + this.#accountsApiChainIds().includes(chainId) && + originalFetcher.supports(chainId) + ); + }, + fetch: originalFetcher.fetch.bind(originalFetcher), + }; + }; /** - * Creates an AccountTracker instance. + * Resolves a networkClientId to a network client config + * or globally selected network config if not provided * - * @param options - The controller options. - * @param options.onPreferencesStateChange - Allows subscribing to preference controller state changes. - * @param options.getIdentities - Gets the identities from the Preferences store. - * @param options.getSelectedAddress - Gets the selected address from the Preferences store. - * @param options.getMultiAccountBalancesEnabled - Gets the multi account balances enabled flag from the Preferences store. - * @param config - Initial options used to configure this controller. - * @param state - Initial state to set on this controller. + * @param networkClientId - Optional networkClientId to fetch a network client with + * @returns network client config */ - constructor( - { - onPreferencesStateChange, - getIdentities, - getSelectedAddress, - getMultiAccountBalancesEnabled, - }: { - onPreferencesStateChange: ( - listener: (preferencesState: PreferencesState) => void, - ) => void; - getIdentities: () => PreferencesState['identities']; - getSelectedAddress: () => PreferencesState['selectedAddress']; - getMultiAccountBalancesEnabled: () => PreferencesState['isMultiAccountBalancesEnabled']; - }, - config?: Partial, - state?: Partial, - ) { - super(config, state); - this.defaultConfig = { - interval: 10000, + #getCorrectNetworkClient(networkClientId?: NetworkClientId): { + chainId: Hex; + provider: NetworkClient['provider']; + ethQuery: EthQuery; + blockTracker: NetworkClient['blockTracker']; + } { + const selectedNetworkClientId = + networkClientId ?? + this.messenger.call('NetworkController:getState').selectedNetworkClientId; + const { + configuration: { chainId }, + provider, + blockTracker, + } = this.messenger.call( + 'NetworkController:getNetworkClientById', + selectedNetworkClientId, + ); + + return { + chainId, + provider, + ethQuery: new EthQuery(provider), + blockTracker, }; - this.defaultState = { accounts: {} }; - this.initialize(); - this.getIdentities = getIdentities; - this.getSelectedAddress = getSelectedAddress; - this.getMultiAccountBalancesEnabled = getMultiAccountBalancesEnabled; - onPreferencesStateChange(() => { - this.refresh(); - }); - this.poll(); } /** - * Sets a new provider. + * Retrieves the list of network client IDs. * - * TODO: Replace this wth a method. - * - * @param provider - Provider used to create a new underlying EthQuery instance. + * @returns An array of network client IDs. */ - set provider(provider: Provider) { - this.ethQuery = new EthQuery(provider); - } + #getNetworkClientIds(): NetworkClientId[] { + const { networkConfigurationsByChainId } = this.messenger.call( + 'NetworkController:getState', + ); - get provider() { - throw new Error('Property only used for setting'); + if (this.#isHomepageSectionsV1Enabled()) { + const popularEvmChainIds = this.messenger.call( + 'NetworkEnablementController:listPopularEvmNetworks', + ); + return popularEvmChainIds + .map((hexChainId) => { + const networkConfig = networkConfigurationsByChainId[hexChainId]; + return networkConfig?.rpcEndpoints[ + networkConfig.defaultRpcEndpointIndex + ]?.networkClientId; + }) + .filter((id): id is NetworkClientId => id !== undefined); + } + + const { enabledNetworkMap } = this.messenger.call( + 'NetworkEnablementController:getState', + ); + + const evmEnabledStorageKeys = enabledNetworkMap[KnownCaipNamespace.Eip155] + ? Object.keys(enabledNetworkMap[KnownCaipNamespace.Eip155]) + : []; + + return evmEnabledStorageKeys + .map((hexChainId) => { + const networkConfig = networkConfigurationsByChainId[hexChainId as Hex]; + return networkConfig?.rpcEndpoints[ + networkConfig.defaultRpcEndpointIndex + ]?.networkClientId; + }) + .filter((id): id is NetworkClientId => id !== undefined); } /** - * Starts a new polling interval. + * Refreshes the balances of the accounts using the networkClientId * - * @param interval - Polling interval trigger a 'refresh'. + * @param input - The input for the poll. + * @param input.networkClientIds - The network client IDs used to get balances. + * @param input.queryAllAccounts - Whether to query all accounts or just the selected account */ - async poll(interval?: number): Promise { - const releaseLock = await this.mutex.acquire(); - interval && this.configure({ interval }, false, false); - this.handle && clearTimeout(this.handle); - await this.refresh(); - this.handle = setTimeout(() => { - releaseLock(); - this.poll(this.config.interval); - }, this.config.interval); + async _executePoll({ + networkClientIds, + queryAllAccounts = false, + }: AccountTrackerPollingInput): Promise { + if (this.#isDeprecated()) { + this.#enforceDisabledState(); + return; + } + // TODO: Either fix this lint violation or explain why it's necessary to ignore. + // eslint-disable-next-line @typescript-eslint/no-floating-promises + this.refresh(networkClientIds, queryAllAccounts); } /** * Refreshes the balances of the accounts depending on the multi-account setting. * If multi-account is disabled, only updates the selected account balance. * If multi-account is enabled, updates balances for all accounts. + * + * @param networkClientIds - Optional network client IDs to fetch a network client with + * @param queryAllAccounts - Whether to query all accounts or just the selected account */ - refresh = async () => { - this.syncAccounts(); - const accounts = { ...this.state.accounts }; - const isMultiAccountBalancesEnabled = this.getMultiAccountBalancesEnabled(); + async refresh( + networkClientIds: NetworkClientId[], + queryAllAccounts: boolean = false, + ): Promise { + if (this.#isDeprecated()) { + this.#enforceDisabledState(); + return; + } + const selectedAccount = this.messenger.call( + 'AccountsController:getSelectedAccount', + ); + const allAccounts = this.messenger.call('AccountsController:listAccounts'); + const { isMultiAccountBalancesEnabled } = this.messenger.call( + 'PreferencesController:getState', + ); - const accountsToUpdate = isMultiAccountBalancesEnabled - ? Object.keys(accounts) - : [this.getSelectedAddress()]; + await this.#refreshAccounts({ + networkClientIds, + queryAllAccounts: queryAllAccounts ?? isMultiAccountBalancesEnabled, + selectedAccount: toChecksumHexAddress( + selectedAccount.address, + ) as ChecksumAddress, + allAccounts, + }); + } - for (const address of accountsToUpdate) { - accounts[address] = { - balance: BNToHex(await this.getBalanceFromChain(address)), - }; + async refreshAddresses({ + networkClientIds, + addresses, + }: { + networkClientIds: NetworkClientId[]; + addresses: string[]; + }): Promise { + if (this.#isDeprecated()) { + this.#enforceDisabledState(); + return; } + const checksummedAddresses = addresses.map((address) => + toChecksumHexAddress(address), + ); - this.update({ accounts }); - }; + const accounts = this.messenger + .call('AccountsController:listAccounts') + .filter((account) => + checksummedAddresses.includes(toChecksumHexAddress(account.address)), + ); - /** - * Fetches the balance of a given address from the blockchain. - * - * @param address - The account address to fetch the balance for. - * @returns A promise that resolves to the balance in a hex string format. - */ - private async getBalanceFromChain( - address: string, - ): Promise { - return await safelyExecuteWithTimeout(async () => { - assert(this.ethQuery, 'Provider not set.'); - return await query(this.ethQuery, 'getBalance', [address]); + await this.#refreshAccounts({ + networkClientIds, + queryAllAccounts: true, + selectedAccount: '0x0', + allAccounts: accounts, }); } + async #refreshAccounts({ + networkClientIds, + queryAllAccounts, + selectedAccount, + allAccounts, + }: { + networkClientIds: NetworkClientId[]; + queryAllAccounts: boolean; + selectedAccount: ChecksumAddress; + allAccounts: InternalAccount[]; + }): Promise { + const releaseLock = await this.#refreshMutex.acquire(); + try { + const chainIds = networkClientIds.map((networkClientId) => { + const { chainId } = this.#getCorrectNetworkClient(networkClientId); + return chainId; + }); + + this.#syncAccounts(chainIds); + + if (!this.#fetchingEnabled() || !this.isActive) { + return; + } + + // Use balance fetchers with fallback strategy + const aggregated: ProcessedBalance[] = []; + let remainingChains = [...chainIds] as ChainIdHex[]; + + // Temporary normalization to lowercase for balance fetching to match TokenBalancesController and enable HTTP caching + const lowerCaseSelectedAccount = + selectedAccount.toLowerCase() as ChecksumAddress; + const lowerCaseAllAccounts = allAccounts.map((account) => ({ + ...account, + address: account.address.toLowerCase(), + })); + + // Try each fetcher in order, removing successfully processed chains + for (const fetcher of this.#balanceFetchers) { + const supportedChains = remainingChains.filter((chainId) => + fetcher.supports(chainId), + ); + if (!supportedChains.length) { + continue; + } + + try { + const result = await fetcher.fetch({ + chainIds: supportedChains, + queryAllAccounts, + selectedAccount: lowerCaseSelectedAccount, + allAccounts: lowerCaseAllAccounts, + }); + + if (result.balances && result.balances.length > 0) { + aggregated.push(...result.balances); + // Remove chains that were successfully processed + const processedChains = new Set( + result.balances.map((b) => b.chainId), + ); + remainingChains = remainingChains.filter( + (chain) => !processedChains.has(chain), + ); + } + + // Add unprocessed chains back to remainingChains for next fetcher + if ( + result.unprocessedChainIds && + result.unprocessedChainIds.length > 0 + ) { + // Only add chains that were originally requested and aren't already in remainingChains + const currentRemainingChains = remainingChains; + const chainsToAdd = result.unprocessedChainIds.filter( + (chainId) => + supportedChains.includes(chainId) && + !currentRemainingChains.includes(chainId), + ); + remainingChains.push(...chainsToAdd); + } + } catch (error) { + console.warn( + `Balance fetcher failed for chains ${supportedChains.join(', ')}: ${String(error)}`, + ); + // Continue to next fetcher (fallback) + } + + // If all chains have been processed, break early + if (remainingChains.length === 0) { + break; + } + } + + // Build a _copy_ of the current state and track whether anything changed + const nextAccountsByChainId: AccountTrackerControllerState['accountsByChainId'] = + cloneDeep(this.state.accountsByChainId); + let hasChanges = false; + + // Process the aggregated balance results + const stakedBalancesByChainAndAddress: Record< + string, + Record + > = {}; + + aggregated.forEach(({ success, value, account, token, chainId }) => { + if (success && value !== undefined) { + const checksumAddress = toChecksumHexAddress(account); + const hexValue = `0x${value.toString(16)}`; + + if (token === ZERO_ADDRESS) { + // Native balance + // Ensure the account entry exists before accessing it + if (!nextAccountsByChainId[chainId]) { + nextAccountsByChainId[chainId] = {}; + } + if (!nextAccountsByChainId[chainId][checksumAddress]) { + nextAccountsByChainId[chainId][checksumAddress] = { + balance: '0x0', + }; + } + + if ( + nextAccountsByChainId[chainId][checksumAddress].balance !== + hexValue + ) { + nextAccountsByChainId[chainId][checksumAddress].balance = + hexValue; + hasChanges = true; + } + } else if ( + STAKING_CONTRACT_ADDRESS_BY_CHAINID[chainId]?.toLowerCase() === + token.toLowerCase() + ) { + // Staked balance (from staking contract address) + if (!stakedBalancesByChainAndAddress[chainId]) { + stakedBalancesByChainAndAddress[chainId] = {}; + } + stakedBalancesByChainAndAddress[chainId][checksumAddress] = + hexValue; + } + } + }); + + // Apply staked balances + Object.entries(stakedBalancesByChainAndAddress).forEach( + ([chainId, balancesByAddress]) => { + Object.entries(balancesByAddress).forEach( + ([address, stakedBalance]) => { + // Ensure account structure exists + if (!nextAccountsByChainId[chainId]) { + nextAccountsByChainId[chainId] = {}; + } + if (!nextAccountsByChainId[chainId][address]) { + nextAccountsByChainId[chainId][address] = { balance: '0x0' }; + } + if ( + nextAccountsByChainId[chainId][address].stakedBalance !== + stakedBalance + ) { + nextAccountsByChainId[chainId][address].stakedBalance = + stakedBalance; + hasChanges = true; + } + }, + ); + }, + ); + + // Only update state if something changed + if (hasChanges) { + this.update((state) => { + state.accountsByChainId = nextAccountsByChainId; + }); + } + } finally { + releaseLock(); + } + } + /** * Sync accounts balances with some additional addresses. * * @param addresses - the additional addresses, may be hardware wallet addresses. + * @param networkClientId - Optional networkClientId to fetch a network client with. * @returns accounts - addresses with synced balance */ async syncBalanceWithAddresses( addresses: string[], - ): Promise> { + networkClientId?: NetworkClientId, + ): Promise< + Record + > { + if (this.#isDeprecated()) { + this.#enforceDisabledState(); + return {}; + } + // Skip balance fetching if locked or not onboarded to avoid unnecessary RPC calls + if (!this.isActive) { + return {}; + } + + const { ethQuery, chainId } = + this.#getCorrectNetworkClient(networkClientId); + + // Skip native token fetching for chains that return arbitrary large numbers + if (!shouldIncludeNativeToken(chainId)) { + // Return empty balances for chains that skip native token fetching + return addresses.reduce< + Record + >((acc, address) => { + acc[address] = { balance: '0x0' }; + return acc; + }, {}); + } + + // TODO: This should use multicall when enabled by the user. return await Promise.all( - addresses.map((address): Promise<[string, string] | undefined> => { - return safelyExecuteWithTimeout(async () => { - assert(this.ethQuery, 'Provider not set.'); - const balance = await query(this.ethQuery, 'getBalance', [address]); - return [address, balance]; - }); - }), + addresses.map( + (address): Promise<[string, string, StakedBalance] | undefined> => { + return safelyExecuteWithTimeout(async () => { + assert(ethQuery, 'Provider not set.'); + const balance = await query(ethQuery, 'getBalance', [address]); + + let stakedBalance: StakedBalance; + if (this.#includeStakedAssets) { + stakedBalance = ( + await this.#getStakedBalanceForChain([address], networkClientId) + )[address]; + } + return [address, balance, stakedBalance]; + }); + }, + ), ).then((value) => { return value.reduce((obj, item) => { if (!item) { return obj; } - const [address, balance] = item; + const [address, balance, stakedBalance] = item; return { ...obj, [address]: { balance, + stakedBalance, }, }; }, {}); }); } + + /** + * Updates the balances of multiple native tokens in a single batch operation. + * This is more efficient than calling updateNativeToken multiple times as it + * triggers only one state update. + * + * @param balances - Array of balance updates, each containing address, chainId, and balance. + */ + updateNativeBalances( + balances: { address: string; chainId: Hex; balance: Hex }[], + ): void { + if (this.#isDeprecated()) { + this.#enforceDisabledState(); + return; + } + const nextAccountsByChainId = cloneDeep(this.state.accountsByChainId); + let hasChanges = false; + + balances.forEach(({ address, chainId, balance }) => { + const checksumAddress = toChecksumHexAddress(address); + + // Ensure the chainId exists in the state + if (!nextAccountsByChainId[chainId]) { + nextAccountsByChainId[chainId] = {}; + hasChanges = true; + } + + // Check if the address exists for this chain + const accountExists = Boolean( + nextAccountsByChainId[chainId][checksumAddress], + ); + + // Ensure the address exists for this chain + if (!accountExists) { + nextAccountsByChainId[chainId][checksumAddress] = { + balance: '0x0', + }; + hasChanges = true; + } + + // Only update the balance if it has changed, or if this is a new account + const currentBalance = + nextAccountsByChainId[chainId][checksumAddress].balance; + if (!accountExists || currentBalance !== balance) { + nextAccountsByChainId[chainId][checksumAddress].balance = balance; + hasChanges = true; + } + }); + + // Only call update if there are actual changes + if (hasChanges) { + this.update((state) => { + state.accountsByChainId = nextAccountsByChainId; + }); + } + } + + /** + * Updates the staked balances of multiple accounts in a single batch operation. + * This is more efficient than updating staked balances individually as it + * triggers only one state update. + * + * @param stakedBalances - Array of staked balance updates, each containing address, chainId, and stakedBalance. + */ + updateStakedBalances( + stakedBalances: { + address: string; + chainId: Hex; + stakedBalance: StakedBalance; + }[], + ): void { + if (this.#isDeprecated()) { + this.#enforceDisabledState(); + return; + } + const nextAccountsByChainId = cloneDeep(this.state.accountsByChainId); + let hasChanges = false; + + stakedBalances.forEach(({ address, chainId, stakedBalance }) => { + const checksumAddress = toChecksumHexAddress(address); + + // Ensure the chainId exists in the state + if (!nextAccountsByChainId[chainId]) { + nextAccountsByChainId[chainId] = {}; + hasChanges = true; + } + + // Check if the address exists for this chain + const accountExists = Boolean( + nextAccountsByChainId[chainId][checksumAddress], + ); + + // Ensure the address exists for this chain + if (!accountExists) { + nextAccountsByChainId[chainId][checksumAddress] = { + balance: '0x0', + }; + hasChanges = true; + } + + // Only update the staked balance if it has changed, or if this is a new account + const currentStakedBalance = + nextAccountsByChainId[chainId][checksumAddress].stakedBalance; + if (!accountExists || !isEqual(currentStakedBalance, stakedBalance)) { + nextAccountsByChainId[chainId][checksumAddress].stakedBalance = + stakedBalance; + hasChanges = true; + } + }); + + // Only call update if there are actual changes + if (hasChanges) { + this.update((state) => { + state.accountsByChainId = nextAccountsByChainId; + }); + } + } } export default AccountTrackerController; diff --git a/packages/assets-controllers/src/AssetsContractController-method-action-types.ts b/packages/assets-controllers/src/AssetsContractController-method-action-types.ts new file mode 100644 index 00000000000..2801517c446 --- /dev/null +++ b/packages/assets-controllers/src/AssetsContractController-method-action-types.ts @@ -0,0 +1,245 @@ +/** + * This file is auto generated. + * Do not edit manually. + */ + +import type { AssetsContractController } from './AssetsContractController.js'; + +/** + * Get a ERC20Standard instance using the relevant provider instance. + * + * @param networkClientId - Network Client ID used to get the provider. + * @returns ERC20Standard instance. + */ +export type AssetsContractControllerGetERC20StandardAction = { + type: `AssetsContractController:getERC20Standard`; + handler: AssetsContractController['getERC20Standard']; +}; + +/** + * Get a ERC721Standard instance using the relevant provider instance. + * + * @param networkClientId - Network Client ID used to get the provider. + * @returns ERC721Standard instance. + */ +export type AssetsContractControllerGetERC721StandardAction = { + type: `AssetsContractController:getERC721Standard`; + handler: AssetsContractController['getERC721Standard']; +}; + +/** + * Get a ERC1155Standard instance using the relevant provider instance. + * + * @param networkClientId - Network Client ID used to get the provider. + * @returns ERC1155Standard instance. + */ +export type AssetsContractControllerGetERC1155StandardAction = { + type: `AssetsContractController:getERC1155Standard`; + handler: AssetsContractController['getERC1155Standard']; +}; + +/** + * Get balance or count for current account on specific asset contract. + * + * @param address - Asset ERC20 contract address. + * @param selectedAddress - Current account public address. + * @param networkClientId - Network Client ID to fetch the provider with. + * @returns Promise resolving to BN object containing balance for current account on specific asset contract. + */ +export type AssetsContractControllerGetERC20BalanceOfAction = { + type: `AssetsContractController:getERC20BalanceOf`; + handler: AssetsContractController['getERC20BalanceOf']; +}; + +/** + * Query for the decimals for a given ERC20 asset. + * + * @param address - ERC20 asset contract address. + * @param networkClientId - Network Client ID to fetch the provider with. + * @returns Promise resolving to the 'decimals'. + */ +export type AssetsContractControllerGetERC20TokenDecimalsAction = { + type: `AssetsContractController:getERC20TokenDecimals`; + handler: AssetsContractController['getERC20TokenDecimals']; +}; + +/** + * Query for the name for a given ERC20 asset. + * + * @param address - ERC20 asset contract address. + * @param networkClientId - Network Client ID to fetch the provider with. + * @returns Promise resolving to the 'decimals'. + */ +export type AssetsContractControllerGetERC20TokenNameAction = { + type: `AssetsContractController:getERC20TokenName`; + handler: AssetsContractController['getERC20TokenName']; +}; + +/** + * Enumerate assets assigned to an owner. + * + * @param address - ERC721 asset contract address. + * @param selectedAddress - Current account public address. + * @param index - An NFT counter less than `balanceOf(selectedAddress)`. + * @param networkClientId - Network Client ID to fetch the provider with. + * @returns Promise resolving to token identifier for the 'index'th asset assigned to 'selectedAddress'. + */ +export type AssetsContractControllerGetERC721NftTokenIdAction = { + type: `AssetsContractController:getERC721NftTokenId`; + handler: AssetsContractController['getERC721NftTokenId']; +}; + +/** + * Enumerate assets assigned to an owner. + * + * @param tokenAddress - ERC721 asset contract address. + * @param userAddress - Current account public address. + * @param tokenId - ERC721 asset identifier. + * @param networkClientId - Network Client ID to fetch the provider with. + * @returns Promise resolving to an object containing the token standard and a set of details which depend on which standard the token supports. + */ +export type AssetsContractControllerGetTokenStandardAndDetailsAction = { + type: `AssetsContractController:getTokenStandardAndDetails`; + handler: AssetsContractController['getTokenStandardAndDetails']; +}; + +/** + * Query for tokenURI for a given ERC721 asset. + * + * @param address - ERC721 asset contract address. + * @param tokenId - ERC721 asset identifier. + * @param networkClientId - Network Client ID to fetch the provider with. + * @returns Promise resolving to the 'tokenURI'. + */ +export type AssetsContractControllerGetERC721TokenURIAction = { + type: `AssetsContractController:getERC721TokenURI`; + handler: AssetsContractController['getERC721TokenURI']; +}; + +/** + * Query for name for a given asset. + * + * @param address - ERC721 or ERC20 asset contract address. + * @param networkClientId - Network Client ID to fetch the provider with. + * @returns Promise resolving to the 'name'. + */ +export type AssetsContractControllerGetERC721AssetNameAction = { + type: `AssetsContractController:getERC721AssetName`; + handler: AssetsContractController['getERC721AssetName']; +}; + +/** + * Query for symbol for a given asset. + * + * @param address - ERC721 or ERC20 asset contract address. + * @param networkClientId - Network Client ID to fetch the provider with. + * @returns Promise resolving to the 'symbol'. + */ +export type AssetsContractControllerGetERC721AssetSymbolAction = { + type: `AssetsContractController:getERC721AssetSymbol`; + handler: AssetsContractController['getERC721AssetSymbol']; +}; + +/** + * Query for owner for a given ERC721 asset. + * + * @param address - ERC721 asset contract address. + * @param tokenId - ERC721 asset identifier. + * @param networkClientId - Network Client ID to fetch the provider with. + * @returns Promise resolving to the owner address. + */ +export type AssetsContractControllerGetERC721OwnerOfAction = { + type: `AssetsContractController:getERC721OwnerOf`; + handler: AssetsContractController['getERC721OwnerOf']; +}; + +/** + * Query for tokenURI for a given asset. + * + * @param address - ERC1155 asset contract address. + * @param tokenId - ERC1155 asset identifier. + * @param networkClientId - Network Client ID to fetch the provider with. + * @returns Promise resolving to the 'tokenURI'. + */ +export type AssetsContractControllerGetERC1155TokenURIAction = { + type: `AssetsContractController:getERC1155TokenURI`; + handler: AssetsContractController['getERC1155TokenURI']; +}; + +/** + * Query for balance of a given ERC 1155 token. + * + * @param userAddress - Wallet public address. + * @param nftAddress - ERC1155 asset contract address. + * @param nftId - ERC1155 asset identifier. + * @param networkClientId - Network Client ID to fetch the provider with. + * @returns Promise resolving to the 'balanceOf'. + */ +export type AssetsContractControllerGetERC1155BalanceOfAction = { + type: `AssetsContractController:getERC1155BalanceOf`; + handler: AssetsContractController['getERC1155BalanceOf']; +}; + +/** + * Transfer single ERC1155 token. + * + * @param nftAddress - ERC1155 token address. + * @param senderAddress - ERC1155 token sender. + * @param recipientAddress - ERC1155 token recipient. + * @param nftId - ERC1155 token id. + * @param qty - Quantity of tokens to be sent. + * @param networkClientId - Network Client ID to fetch the provider with. + * @returns Promise resolving to the 'transferSingle' ERC1155 token. + */ +export type AssetsContractControllerTransferSingleERC1155Action = { + type: `AssetsContractController:transferSingleERC1155`; + handler: AssetsContractController['transferSingleERC1155']; +}; + +/** + * Get the token balance for a list of token addresses in a single call. Only non-zero balances + * are returned. + * + * @param selectedAddress - The address to check token balances for. + * @param tokensToDetect - The token addresses to detect balances for. + * @param networkClientId - Network Client ID to fetch the provider with. + * @returns The list of non-zero token balances. + */ +export type AssetsContractControllerGetBalancesInSingleCallAction = { + type: `AssetsContractController:getBalancesInSingleCall`; + handler: AssetsContractController['getBalancesInSingleCall']; +}; + +/** + * Get the staked ethereum balance for multiple addresses in a single call. + * + * @param addresses - The addresses to check staked ethereum balance for. + * @param networkClientId - Network Client ID to fetch the provider with. + * @returns The hex staked ethereum balance for address. + */ +export type AssetsContractControllerGetStakedBalanceForChainAction = { + type: `AssetsContractController:getStakedBalanceForChain`; + handler: AssetsContractController['getStakedBalanceForChain']; +}; + +/** + * Union of all AssetsContractController action types. + */ +export type AssetsContractControllerMethodActions = + | AssetsContractControllerGetERC20StandardAction + | AssetsContractControllerGetERC721StandardAction + | AssetsContractControllerGetERC1155StandardAction + | AssetsContractControllerGetERC20BalanceOfAction + | AssetsContractControllerGetERC20TokenDecimalsAction + | AssetsContractControllerGetERC20TokenNameAction + | AssetsContractControllerGetERC721NftTokenIdAction + | AssetsContractControllerGetTokenStandardAndDetailsAction + | AssetsContractControllerGetERC721TokenURIAction + | AssetsContractControllerGetERC721AssetNameAction + | AssetsContractControllerGetERC721AssetSymbolAction + | AssetsContractControllerGetERC721OwnerOfAction + | AssetsContractControllerGetERC1155TokenURIAction + | AssetsContractControllerGetERC1155BalanceOfAction + | AssetsContractControllerTransferSingleERC1155Action + | AssetsContractControllerGetBalancesInSingleCallAction + | AssetsContractControllerGetStakedBalanceForChainAction; diff --git a/packages/assets-controllers/src/AssetsContractController.test.ts b/packages/assets-controllers/src/AssetsContractController.test.ts index 41476696a35..e218c1157ac 100644 --- a/packages/assets-controllers/src/AssetsContractController.test.ts +++ b/packages/assets-controllers/src/AssetsContractController.test.ts @@ -1,27 +1,57 @@ -import { ControllerMessenger } from '@metamask/base-controller'; +import { BigNumber } from '@ethersproject/bignumber'; import { BUILT_IN_NETWORKS, ChainId, + InfuraNetworkType, IPFS_DEFAULT_GATEWAY_URL, NetworkType, } from '@metamask/controller-utils'; +import HttpProvider from '@metamask/ethjs-provider-http'; +import { Messenger, MOCK_ANY_NAMESPACE } from '@metamask/messenger'; import type { + MessengerActions, + MessengerEvents, + MockAnyNamespace, +} from '@metamask/messenger'; +import type { + Provider, NetworkClientId, + InfuraNetworkClientConfiguration, NetworkControllerMessenger, } from '@metamask/network-controller'; import { NetworkController, NetworkClientType, } from '@metamask/network-controller'; -import { PreferencesController } from '@metamask/preferences-controller'; -import HttpProvider from 'ethjs-provider-http'; +import type { PreferencesState } from '@metamask/preferences-controller'; +import { getDefaultPreferencesState } from '@metamask/preferences-controller'; +import assert from 'assert'; -import { mockNetwork } from '../../../tests/mock-network'; +import { SECONDS } from '../../../tests/constants.js'; +import { mockNetwork } from '../../../tests/mock-network.js'; +import { buildInfuraNetworkClientConfiguration } from '../../network-controller/tests/helpers.js'; +import type { AssetsContractControllerMessenger } from './AssetsContractController.js'; import { AssetsContractController, MISSING_PROVIDER_ERROR, -} from './AssetsContractController'; -import { SupportedTokenDetectionNetworks } from './assetsUtil'; +} from './AssetsContractController.js'; +import { SupportedTokenDetectionNetworks } from './assetsUtil.js'; + +type AllAssetsContractControllerActions = + MessengerActions; + +type AllAssetsContractControllerEvents = + MessengerEvents; + +type AllNetworkControllerActions = MessengerActions; + +type AllNetworkControllerEvents = MessengerEvents; + +type RootMessenger = Messenger< + MockAnyNamespace, + AllAssetsContractControllerActions | AllNetworkControllerActions, + AllAssetsContractControllerEvents | AllNetworkControllerEvents +>; const ERC20_UNI_ADDRESS = '0x1f9840a85d5af5bf1d1762f925bdaddc4201f984'; const ERC20_SAI_ADDRESS = '0x89d24a6b4ccb1b6faa2625fe562bdd9a23260359'; @@ -38,56 +68,143 @@ const TEST_ACCOUNT_PUBLIC_ADDRESS = * Creates the assets contract controller along with the dependencies necessary * to use it effectively in tests. * + * @param args - The arguments to this function. + * @param args.options - AssetsContractController options. + * @param args.useNetworkControllerProvider - Whether to use the initial + * provider that the network controller creates or to create a new one. + * @param args.infuraProjectId - The Infura project ID to use when initializing + * the network controller. * @returns the objects. */ -async function setupAssetContractControllers() { - const networkClientConfiguration = { +async function setupAssetContractControllers({ + options, + useNetworkControllerProvider = false, + infuraProjectId = '341eacb578dd44a1a049cbc5f6fd4035', +}: { + options?: Partial< + Omit[0], 'messenger'> + >; + useNetworkControllerProvider?: boolean; + infuraProjectId?: string; +} = {}) { + const networkClientConfiguration: InfuraNetworkClientConfiguration = { type: NetworkClientType.Infura, - network: 'mainnet', - infuraProjectId: '341eacb578dd44a1a049cbc5f6fd4035', + network: NetworkType.mainnet, + failoverRpcUrls: [], + infuraProjectId, chainId: BUILT_IN_NETWORKS.mainnet.chainId, ticker: BUILT_IN_NETWORKS.mainnet.ticker, - } as const; - - const messenger: NetworkControllerMessenger = - new ControllerMessenger().getRestricted({ - name: 'NetworkController', - allowedEvents: [ - 'NetworkController:stateChange', - 'NetworkController:networkDidChange', - ], - allowedActions: [], - }); - const network = new NetworkController({ - infuraProjectId: networkClientConfiguration.infuraProjectId, - messenger, - trackMetaMetricsEvent: jest.fn(), + }; + let provider: Provider; + + const messenger: RootMessenger = new Messenger({ + namespace: MOCK_ANY_NAMESPACE, + }); + + messenger.registerActionHandler( + 'RemoteFeatureFlagController:getState', + () => ({ + remoteFeatureFlags: { + walletFrameworkRpcFailoverEnabled: false, + }, + cacheTimestamp: 0, + }), + ); + + messenger.registerActionHandler('ConfigRegistryController:getState', () => ({ + configs: { + networks: {}, + }, + lastFetched: 0, + etag: '', + version: '1', + })); + + const networkControllerMessenger: NetworkControllerMessenger = new Messenger({ + namespace: 'NetworkController', + parent: messenger, + }); + + messenger.delegate({ + messenger: networkControllerMessenger, + actions: [ + 'ConfigRegistryController:getNetworkConfigByCaip2ChainId', + 'ConfigRegistryController:getState', + 'ConnectivityController:getState', + 'RemoteFeatureFlagController:getState', + ], + }); + + const networkController = new NetworkController({ + infuraProjectId, + messenger: networkControllerMessenger, + getRpcServiceOptions: () => ({ + fetch, + btoa, + isOffline: (): boolean => false, + }), }); - const preferences = new PreferencesController(); + if (useNetworkControllerProvider) { + networkController.init(); + const selectedNetworkClient = networkController.getSelectedNetworkClient(); + assert(selectedNetworkClient, 'No network is selected'); + provider = selectedNetworkClient.provider; + } else { + provider = new HttpProvider( + `https://mainnet.infura.io/v3/${infuraProjectId}`, + ); + } - const provider = new HttpProvider( - `https://mainnet.infura.io/v3/${networkClientConfiguration.infuraProjectId}`, + messenger.unregisterActionHandler('NetworkController:getNetworkClientById'); + messenger.registerActionHandler( + 'NetworkController:getNetworkClientById', + // @ts-expect-error TODO: remove this annotation once the `Eip1193Provider` class is released + useNetworkControllerProvider + ? networkController.getNetworkClientById.bind(networkController) + : (networkClientId: NetworkClientId) => ({ + ...networkController.getNetworkClientById(networkClientId), + provider, + }), ); + const assetsContractMessenger = new Messenger< + 'AssetsContractController', + MessengerActions, + MessengerEvents, + RootMessenger + >({ + namespace: 'AssetsContractController', + parent: messenger, + }); + messenger.delegate({ + messenger: assetsContractMessenger, + actions: [ + 'NetworkController:getNetworkClientById', + 'NetworkController:getNetworkConfigurationByNetworkClientId', + 'NetworkController:getSelectedNetworkClient', + 'NetworkController:getState', + ], + events: [ + 'PreferencesController:stateChange', + 'NetworkController:networkDidChange', + ], + }); const assetsContract = new AssetsContractController({ chainId: ChainId.mainnet, - onPreferencesStateChange: (listener) => preferences.subscribe(listener), - onNetworkStateChange: (listener) => - messenger.subscribe('NetworkController:stateChange', listener), - getNetworkClientById: (networkClientId: NetworkClientId) => - ({ - ...network.getNetworkClientById(networkClientId), - provider, - } as any), + messenger: assetsContractMessenger, + ...options, }); return { messenger, - network, - preferences, + network: networkController, assetsContract, provider, networkClientConfiguration, + infuraProjectId, + triggerPreferencesStateChange: (state: PreferencesState) => { + messenger.publish('PreferencesController:stateChange', state, []); + }, }; } @@ -129,66 +246,72 @@ export { setupAssetContractControllers, mockNetworkWithDefaultChainId }; describe('AssetsContractController', () => { it('should set default config', async () => { const { assetsContract, messenger } = await setupAssetContractControllers(); - expect(assetsContract.config).toStrictEqual({ - chainId: SupportedTokenDetectionNetworks.mainnet, + expect({ + chainId: assetsContract.chainId, + ipfsGateway: assetsContract.ipfsGateway, + }).toStrictEqual({ + chainId: SupportedTokenDetectionNetworks.Mainnet, ipfsGateway: IPFS_DEFAULT_GATEWAY_URL, - provider: undefined, }); - messenger.clearEventSubscriptions('NetworkController:stateChange'); + messenger.clearEventSubscriptions('NetworkController:networkDidChange'); }); it('should update the ipfsGateWay config value when this value is changed in the preferences controller', async () => { - const { assetsContract, messenger, preferences } = + const { assetsContract, messenger, triggerPreferencesStateChange } = await setupAssetContractControllers(); - expect(assetsContract.config).toStrictEqual({ - chainId: SupportedTokenDetectionNetworks.mainnet, + expect({ + chainId: assetsContract.chainId, + ipfsGateway: assetsContract.ipfsGateway, + }).toStrictEqual({ + chainId: SupportedTokenDetectionNetworks.Mainnet, ipfsGateway: IPFS_DEFAULT_GATEWAY_URL, - provider: undefined, }); - preferences.setIpfsGateway('newIPFSGateWay'); - expect(assetsContract.config).toStrictEqual({ + triggerPreferencesStateChange({ + ...getDefaultPreferencesState(), ipfsGateway: 'newIPFSGateWay', - chainId: SupportedTokenDetectionNetworks.mainnet, - provider: undefined, }); - messenger.clearEventSubscriptions('NetworkController:stateChange'); - }); + expect({ + chainId: assetsContract.chainId, + ipfsGateway: assetsContract.ipfsGateway, + }).toStrictEqual({ + ipfsGateway: 'newIPFSGateWay', + chainId: SupportedTokenDetectionNetworks.Mainnet, + }); - it('should throw when provider property is accessed', async () => { - const { assetsContract, messenger } = await setupAssetContractControllers(); - expect(() => console.log(assetsContract.provider)).toThrow( - 'Property only used for setting', - ); - messenger.clearEventSubscriptions('NetworkController:stateChange'); + messenger.clearEventSubscriptions('NetworkController:networkDidChange'); }); it('should throw missing provider error when getting ERC-20 token balance when missing provider', async () => { const { assetsContract, messenger } = await setupAssetContractControllers(); - assetsContract.configure({ provider: undefined }); + assetsContract.setProvider(undefined); await expect( - assetsContract.getERC20BalanceOf( + messenger.call( + `AssetsContractController:getERC20BalanceOf`, ERC20_UNI_ADDRESS, TEST_ACCOUNT_PUBLIC_ADDRESS, ), ).rejects.toThrow(MISSING_PROVIDER_ERROR); - messenger.clearEventSubscriptions('NetworkController:stateChange'); + messenger.clearEventSubscriptions('NetworkController:networkDidChange'); }); it('should throw missing provider error when getting ERC-20 token decimal when missing provider', async () => { const { assetsContract, messenger } = await setupAssetContractControllers(); - assetsContract.configure({ provider: undefined }); + assetsContract.setProvider(undefined); await expect( - assetsContract.getERC20TokenDecimals(ERC20_UNI_ADDRESS), + messenger.call( + `AssetsContractController:getERC20TokenDecimals`, + ERC20_UNI_ADDRESS, + ), ).rejects.toThrow(MISSING_PROVIDER_ERROR); - messenger.clearEventSubscriptions('NetworkController:stateChange'); + messenger.clearEventSubscriptions('NetworkController:networkDidChange'); }); it('should get balance of ERC-20 token contract correctly', async () => { const { assetsContract, messenger, provider, networkClientConfiguration } = await setupAssetContractControllers(); - assetsContract.configure({ provider }); + assetsContract.setProvider(provider); mockNetworkWithDefaultChainId({ networkClientConfiguration, mocks: [ @@ -226,23 +349,25 @@ describe('AssetsContractController', () => { }, ], }); - const UNIBalance = await assetsContract.getERC20BalanceOf( + const UNIBalance = await messenger.call( + `AssetsContractController:getERC20BalanceOf`, ERC20_UNI_ADDRESS, TEST_ACCOUNT_PUBLIC_ADDRESS, ); - const UNINoBalance = await assetsContract.getERC20BalanceOf( + const UNINoBalance = await messenger.call( + `AssetsContractController:getERC20BalanceOf`, ERC20_UNI_ADDRESS, '0x202637dAAEfbd7f131f90338a4A6c69F6Cd5CE91', ); expect(UNIBalance.toString(16)).not.toBe('0'); expect(UNINoBalance.toString(16)).toBe('0'); - messenger.clearEventSubscriptions('NetworkController:stateChange'); + messenger.clearEventSubscriptions('NetworkController:networkDidChange'); }); it('should get ERC-721 NFT tokenId correctly', async () => { const { assetsContract, messenger, provider, networkClientConfiguration } = await setupAssetContractControllers(); - assetsContract.configure({ provider }); + assetsContract.setProvider(provider); mockNetworkWithDefaultChainId({ networkClientConfiguration, mocks: [ @@ -264,45 +389,48 @@ describe('AssetsContractController', () => { }, ], }); - const tokenId = await assetsContract.getERC721NftTokenId( + const tokenId = await messenger.call( + `AssetsContractController:getERC721NftTokenId`, ERC721_GODS_ADDRESS, '0x9a90bd8d1149a88b42a99cf62215ad955d6f498a', 0, ); expect(tokenId).not.toBe(0); - messenger.clearEventSubscriptions('NetworkController:stateChange'); + messenger.clearEventSubscriptions('NetworkController:networkDidChange'); }); it('should throw missing provider error when getting ERC-721 token standard and details when missing provider', async () => { const { assetsContract, messenger } = await setupAssetContractControllers(); - assetsContract.configure({ provider: undefined }); + assetsContract.setProvider(undefined); await expect( - assetsContract.getTokenStandardAndDetails( + messenger.call( + `AssetsContractController:getTokenStandardAndDetails`, ERC20_UNI_ADDRESS, TEST_ACCOUNT_PUBLIC_ADDRESS, ), ).rejects.toThrow(MISSING_PROVIDER_ERROR); - messenger.clearEventSubscriptions('NetworkController:stateChange'); + messenger.clearEventSubscriptions('NetworkController:networkDidChange'); }); it('should throw contract standard error when getting ERC-20 token standard and details when provided with invalid ERC-20 address', async () => { const { assetsContract, messenger, provider } = await setupAssetContractControllers(); - assetsContract.configure({ provider }); + assetsContract.setProvider(provider); const error = 'Unable to determine contract standard'; await expect( - assetsContract.getTokenStandardAndDetails( + messenger.call( + `AssetsContractController:getTokenStandardAndDetails`, 'BaDeRc20AdDrEsS', TEST_ACCOUNT_PUBLIC_ADDRESS, ), ).rejects.toThrow(error); - messenger.clearEventSubscriptions('NetworkController:stateChange'); + messenger.clearEventSubscriptions('NetworkController:networkDidChange'); }); it('should get ERC-721 token standard and details', async () => { const { assetsContract, messenger, provider, networkClientConfiguration } = await setupAssetContractControllers(); - assetsContract.configure({ provider }); + assetsContract.setProvider(provider); mockNetworkWithDefaultChainId({ networkClientConfiguration, mocks: [ @@ -356,18 +484,19 @@ describe('AssetsContractController', () => { }, ], }); - const standardAndDetails = await assetsContract.getTokenStandardAndDetails( + const standardAndDetails = await messenger.call( + `AssetsContractController:getTokenStandardAndDetails`, ERC721_GODS_ADDRESS, TEST_ACCOUNT_PUBLIC_ADDRESS, ); expect(standardAndDetails.standard).toBe('ERC721'); - messenger.clearEventSubscriptions('NetworkController:stateChange'); + messenger.clearEventSubscriptions('NetworkController:networkDidChange'); }); it('should get ERC-1155 token standard and details', async () => { const { assetsContract, messenger, provider, networkClientConfiguration } = await setupAssetContractControllers(); - assetsContract.configure({ provider }); + assetsContract.setProvider(provider); mockNetworkWithDefaultChainId({ networkClientConfiguration, mocks: [ @@ -393,63 +522,14 @@ describe('AssetsContractController', () => { params: [ { to: ERC1155_ADDRESS, - data: '0x01ffc9a7d9b67a2600000000000000000000000000000000000000000000000000000000', + data: '0x06fdde03', }, 'latest', ], }, response: { result: - '0x0000000000000000000000000000000000000000000000000000000000000001', - }, - }, - ], - }); - const standardAndDetails = await assetsContract.getTokenStandardAndDetails( - ERC1155_ADDRESS, - TEST_ACCOUNT_PUBLIC_ADDRESS, - ); - expect(standardAndDetails.standard).toBe('ERC1155'); - messenger.clearEventSubscriptions('NetworkController:stateChange'); - }); - - it('should get ERC-20 token standard and details', async () => { - const { assetsContract, messenger, provider, networkClientConfiguration } = - await setupAssetContractControllers(); - assetsContract.configure({ provider }); - mockNetworkWithDefaultChainId({ - networkClientConfiguration, - mocks: [ - { - request: { - method: 'eth_call', - params: [ - { - to: ERC20_UNI_ADDRESS, - data: '0x01ffc9a780ac58cd00000000000000000000000000000000000000000000000000000000', - }, - 'latest', - ], - }, - error: { - code: -32000, - message: 'execution reverted', - }, - }, - { - request: { - method: 'eth_call', - params: [ - { - to: ERC20_UNI_ADDRESS, - data: '0x01ffc9a7d9b67a2600000000000000000000000000000000000000000000000000000000', - }, - 'latest', - ], - }, - error: { - code: -32000, - message: 'execution reverted', + '0x0000000000000000000000000000000000000000000000000000000000000020000000000000000000000000000000000000000000000000000000000000001c41706569726f6e20476f6469766572736520436f6c6c656374696f6e00000000', }, }, { @@ -457,7 +537,7 @@ describe('AssetsContractController', () => { method: 'eth_call', params: [ { - to: ERC20_UNI_ADDRESS, + to: ERC1155_ADDRESS, data: '0x95d89b41', }, 'latest', @@ -465,7 +545,7 @@ describe('AssetsContractController', () => { }, response: { result: - '0x00000000000000000000000000000000000000000000000000000000000000200000000000000000000000000000000000000000000000000000000000000003554e490000000000000000000000000000000000000000000000000000000000', + '0x000000000000000000000000000000000000000000000000000000000000002000000000000000000000000000000000000000000000000000000000000000054150454743000000000000000000000000000000000000000000000000000000', }, }, { @@ -473,47 +553,205 @@ describe('AssetsContractController', () => { method: 'eth_call', params: [ { - to: ERC20_UNI_ADDRESS, - data: '0x313ce567', - }, - 'latest', - ], - }, - response: { - result: - '0x0000000000000000000000000000000000000000000000000000000000000012', - }, - }, - { - request: { - method: 'eth_call', - params: [ - { - to: ERC20_UNI_ADDRESS, - data: '0x70a082310000000000000000000000005a3ca5cd63807ce5e4d7841ab32ce6b6d9bbba2d', + to: ERC1155_ADDRESS, + data: '0x01ffc9a7d9b67a2600000000000000000000000000000000000000000000000000000000', }, 'latest', ], }, response: { result: - '0x0000000000000000000000000000000000000000000000001765caf344a06d0a', + '0x0000000000000000000000000000000000000000000000000000000000000001', }, }, ], }); - const standardAndDetails = await assetsContract.getTokenStandardAndDetails( - ERC20_UNI_ADDRESS, + const standardAndDetails = await messenger.call( + `AssetsContractController:getTokenStandardAndDetails`, + ERC1155_ADDRESS, TEST_ACCOUNT_PUBLIC_ADDRESS, ); - expect(standardAndDetails.standard).toBe('ERC20'); - messenger.clearEventSubscriptions('NetworkController:stateChange'); + + expect(standardAndDetails.standard).toBe('ERC1155'); + expect(standardAndDetails.name).toBe('Apeiron Godiverse Collection'); + expect(standardAndDetails.symbol).toBe('APEGC'); + + messenger.clearEventSubscriptions('NetworkController:networkDidChange'); }); - it('should get ERC-721 NFT tokenURI correctly', async () => { + it( + 'should get ERC-20 token standard and details', + async () => { + const { + assetsContract, + messenger, + provider, + networkClientConfiguration, + } = await setupAssetContractControllers(); + assetsContract.setProvider(provider); + mockNetworkWithDefaultChainId({ + networkClientConfiguration, + mocks: [ + { + request: { + method: 'eth_call', + params: [ + { + to: ERC20_UNI_ADDRESS, + data: '0x01ffc9a780ac58cd00000000000000000000000000000000000000000000000000000000', + }, + 'latest', + ], + }, + error: { + code: -32000, + message: 'execution reverted', + }, + }, + { + request: { + method: 'eth_call', + params: [ + { + to: ERC20_UNI_ADDRESS, + data: '0x01ffc9a7d9b67a2600000000000000000000000000000000000000000000000000000000', + }, + 'latest', + ], + }, + error: { + code: -32000, + message: 'execution reverted', + }, + }, + { + request: { + method: 'eth_call', + params: [ + { + to: ERC20_UNI_ADDRESS, + data: '0x95d89b41', + }, + 'latest', + ], + }, + response: { + result: + '0x00000000000000000000000000000000000000000000000000000000000000200000000000000000000000000000000000000000000000000000000000000003554e490000000000000000000000000000000000000000000000000000000000', + }, + }, + { + request: { + method: 'eth_call', + params: [ + { + to: ERC20_UNI_ADDRESS, + data: '0x313ce567', + }, + 'latest', + ], + }, + response: { + result: + '0x0000000000000000000000000000000000000000000000000000000000000012', + }, + }, + { + request: { + method: 'eth_call', + params: [ + { + to: ERC20_UNI_ADDRESS, + data: '0x70a082310000000000000000000000005a3ca5cd63807ce5e4d7841ab32ce6b6d9bbba2d', + }, + 'latest', + ], + }, + response: { + result: + '0x0000000000000000000000000000000000000000000000001765caf344a06d0a', + }, + }, + ], + }); + const standardAndDetails = await messenger.call( + `AssetsContractController:getTokenStandardAndDetails`, + ERC20_UNI_ADDRESS, + TEST_ACCOUNT_PUBLIC_ADDRESS, + ); + expect(standardAndDetails.standard).toBe('ERC20'); + messenger.clearEventSubscriptions('NetworkController:networkDidChange'); + }, + 10 * SECONDS, + ); + + it( + 'should get ERC-721 NFT tokenURI correctly', + async () => { + const { + assetsContract, + messenger, + provider, + networkClientConfiguration, + } = await setupAssetContractControllers(); + assetsContract.setProvider(provider); + mockNetworkWithDefaultChainId({ + networkClientConfiguration, + mocks: [ + { + request: { + method: 'eth_call', + params: [ + { + to: ERC721_GODS_ADDRESS, + data: '0x01ffc9a75b5e139f00000000000000000000000000000000000000000000000000000000', + }, + 'latest', + ], + }, + response: { + result: + '0x0000000000000000000000000000000000000000000000000000000000000001', + }, + }, + { + request: { + method: 'eth_call', + params: [ + { + to: ERC721_GODS_ADDRESS, + data: '0xc87b56dd0000000000000000000000000000000000000000000000000000000000000000', + }, + 'latest', + ], + }, + response: { + result: + '0x0000000000000000000000000000000000000000000000000000000000000020000000000000000000000000000000000000000000000000000000000000002468747470733a2f2f6170692e676f6473756e636861696e65642e636f6d2f636172642f3000000000000000000000000000000000000000000000000000000000', + }, + }, + ], + }); + const tokenId = await messenger.call( + `AssetsContractController:getERC721TokenURI`, + ERC721_GODS_ADDRESS, + '0', + ); + expect(tokenId).toBe('https://api.godsunchained.com/card/0'); + messenger.clearEventSubscriptions('NetworkController:networkDidChange'); + }, + 10 * SECONDS, + ); + + it('should not throw an error when address given does not support NFT Metadata interface', async () => { const { assetsContract, messenger, provider, networkClientConfiguration } = await setupAssetContractControllers(); - assetsContract.configure({ provider }); + assetsContract.setProvider(provider); + const errorLogSpy = jest + .spyOn(console, 'warn') + .mockImplementationOnce(() => { + /**/ + }); mockNetworkWithDefaultChainId({ networkClientConfiguration, mocks: [ @@ -522,15 +760,14 @@ describe('AssetsContractController', () => { method: 'eth_call', params: [ { - to: ERC721_GODS_ADDRESS, + to: '0x0000000000000000000000000000000000000000', data: '0x01ffc9a75b5e139f00000000000000000000000000000000000000000000000000000000', }, 'latest', ], }, response: { - result: - '0x0000000000000000000000000000000000000000000000000000000000000001', + result: '0x', }, }, { @@ -538,7 +775,7 @@ describe('AssetsContractController', () => { method: 'eth_call', params: [ { - to: ERC721_GODS_ADDRESS, + to: '0x0000000000000000000000000000000000000000', data: '0xc87b56dd0000000000000000000000000000000000000000000000000000000000000000', }, 'latest', @@ -551,54 +788,24 @@ describe('AssetsContractController', () => { }, ], }); - const tokenId = await assetsContract.getERC721TokenURI( - ERC721_GODS_ADDRESS, + const uri = await messenger.call( + `AssetsContractController:getERC721TokenURI`, + '0x0000000000000000000000000000000000000000', '0', ); - expect(tokenId).toBe('https://api.godsunchained.com/card/0'); - messenger.clearEventSubscriptions('NetworkController:stateChange'); - }); + expect(uri).toBe('https://api.godsunchained.com/card/0'); + expect(errorLogSpy).toHaveBeenCalledTimes(1); + expect(errorLogSpy.mock.calls).toContainEqual([ + 'Contract does not support ERC721 metadata interface.', + ]); - it('should throw an error when address given is not an ERC-721 NFT', async () => { - const { assetsContract, messenger, provider, networkClientConfiguration } = - await setupAssetContractControllers(); - assetsContract.configure({ provider }); - mockNetworkWithDefaultChainId({ - networkClientConfiguration, - mocks: [ - { - request: { - method: 'eth_call', - params: [ - { - to: '0x0000000000000000000000000000000000000000', - data: '0x01ffc9a75b5e139f00000000000000000000000000000000000000000000000000000000', - }, - 'latest', - ], - }, - response: { - result: '0x', - }, - }, - ], - }); - const result = async () => { - await assetsContract.getERC721TokenURI( - '0x0000000000000000000000000000000000000000', - '0', - ); - }; - - const error = 'Contract does not support ERC721 metadata interface.'; - await expect(result).rejects.toThrow(error); - messenger.clearEventSubscriptions('NetworkController:stateChange'); + messenger.clearEventSubscriptions('NetworkController:networkDidChange'); }); it('should get ERC-721 NFT name', async () => { const { assetsContract, messenger, provider, networkClientConfiguration } = await setupAssetContractControllers(); - assetsContract.configure({ provider }); + assetsContract.setProvider(provider); mockNetworkWithDefaultChainId({ networkClientConfiguration, mocks: [ @@ -620,15 +827,18 @@ describe('AssetsContractController', () => { }, ], }); - const name = await assetsContract.getERC721AssetName(ERC721_GODS_ADDRESS); + const name = await messenger.call( + `AssetsContractController:getERC721AssetName`, + ERC721_GODS_ADDRESS, + ); expect(name).toBe('Gods Unchained'); - messenger.clearEventSubscriptions('NetworkController:stateChange'); + messenger.clearEventSubscriptions('NetworkController:networkDidChange'); }); it('should get ERC-721 NFT symbol', async () => { const { assetsContract, messenger, provider, networkClientConfiguration } = await setupAssetContractControllers(); - assetsContract.configure({ provider }); + assetsContract.setProvider(provider); mockNetworkWithDefaultChainId({ networkClientConfiguration, mocks: [ @@ -650,25 +860,29 @@ describe('AssetsContractController', () => { }, ], }); - const symbol = await assetsContract.getERC721AssetSymbol( + const symbol = await messenger.call( + `AssetsContractController:getERC721AssetSymbol`, ERC721_GODS_ADDRESS, ); expect(symbol).toBe('GODS'); - messenger.clearEventSubscriptions('NetworkController:stateChange'); + messenger.clearEventSubscriptions('NetworkController:networkDidChange'); }); it('should throw missing provider error when getting ERC-721 NFT symbol when missing provider', async () => { - const { assetsContract, messenger } = await setupAssetContractControllers(); + const { messenger } = await setupAssetContractControllers(); await expect( - assetsContract.getERC721AssetSymbol(ERC721_GODS_ADDRESS), + messenger.call( + `AssetsContractController:getERC721AssetSymbol`, + ERC721_GODS_ADDRESS, + ), ).rejects.toThrow(MISSING_PROVIDER_ERROR); - messenger.clearEventSubscriptions('NetworkController:stateChange'); + messenger.clearEventSubscriptions('NetworkController:networkDidChange'); }); it('should get ERC-20 token decimals', async () => { const { assetsContract, messenger, provider, networkClientConfiguration } = await setupAssetContractControllers(); - assetsContract.configure({ provider }); + assetsContract.setProvider(provider); mockNetworkWithDefaultChainId({ networkClientConfiguration, mocks: [ @@ -690,17 +904,18 @@ describe('AssetsContractController', () => { }, ], }); - const decimals = await assetsContract.getERC20TokenDecimals( + const decimals = await messenger.call( + `AssetsContractController:getERC20TokenDecimals`, ERC20_SAI_ADDRESS, ); expect(Number(decimals)).toBe(18); - messenger.clearEventSubscriptions('NetworkController:stateChange'); + messenger.clearEventSubscriptions('NetworkController:networkDidChange'); }); it('should get ERC-20 token name', async () => { const { assetsContract, messenger, provider, networkClientConfiguration } = await setupAssetContractControllers(); - assetsContract.configure({ provider }); + assetsContract.setProvider(provider); mockNetworkWithDefaultChainId({ networkClientConfiguration, mocks: [ @@ -723,16 +938,19 @@ describe('AssetsContractController', () => { ], }); - const name = await assetsContract.getERC20TokenName(ERC20_DAI_ADDRESS); + const name = await messenger.call( + `AssetsContractController:getERC20TokenName`, + ERC20_DAI_ADDRESS, + ); expect(name).toBe('Dai Stablecoin'); - messenger.clearEventSubscriptions('NetworkController:stateChange'); + messenger.clearEventSubscriptions('NetworkController:networkDidChange'); }); it('should get ERC-721 NFT ownership', async () => { const { assetsContract, messenger, provider, networkClientConfiguration } = await setupAssetContractControllers(); - assetsContract.configure({ provider }); + assetsContract.setProvider(provider); mockNetworkWithDefaultChainId({ networkClientConfiguration, mocks: [ @@ -754,26 +972,31 @@ describe('AssetsContractController', () => { }, ], }); - const tokenId = await assetsContract.getERC721OwnerOf( + const tokenId = await messenger.call( + `AssetsContractController:getERC721OwnerOf`, ERC721_GODS_ADDRESS, '148332', ); expect(tokenId).not.toBe(''); - messenger.clearEventSubscriptions('NetworkController:stateChange'); + messenger.clearEventSubscriptions('NetworkController:networkDidChange'); }); it('should throw missing provider error when getting ERC-721 NFT ownership', async () => { - const { assetsContract, messenger } = await setupAssetContractControllers(); + const { messenger } = await setupAssetContractControllers(); await expect( - assetsContract.getERC721OwnerOf(ERC721_GODS_ADDRESS, '148332'), + messenger.call( + `AssetsContractController:getERC721OwnerOf`, + ERC721_GODS_ADDRESS, + '148332', + ), ).rejects.toThrow(MISSING_PROVIDER_ERROR); - messenger.clearEventSubscriptions('NetworkController:stateChange'); + messenger.clearEventSubscriptions('NetworkController:networkDidChange'); }); it('should get balance of ERC-20 token in a single call on network with token detection support', async () => { const { assetsContract, messenger, provider, networkClientConfiguration } = await setupAssetContractControllers(); - assetsContract.configure({ provider }); + assetsContract.setProvider(provider); mockNetworkWithDefaultChainId({ networkClientConfiguration, mocks: [ @@ -795,12 +1018,116 @@ describe('AssetsContractController', () => { }, ], }); - const balances = await assetsContract.getBalancesInSingleCall( + const balances = await messenger.call( + `AssetsContractController:getBalancesInSingleCall`, ERC20_SAI_ADDRESS, [ERC20_SAI_ADDRESS], ); expect(balances[ERC20_SAI_ADDRESS]).toBeDefined(); - messenger.clearEventSubscriptions('NetworkController:stateChange'); + messenger.clearEventSubscriptions('NetworkController:networkDidChange'); + }); + + it('should track and use the currently selected chain ID and provider when getting balances in a single call', async () => { + const infuraProjectId = 'some-infura-project-id'; + mockNetwork({ + networkClientConfiguration: buildInfuraNetworkClientConfiguration( + InfuraNetworkType.mainnet, + { infuraProjectId }, + ), + mocks: [ + { + request: { + method: 'eth_blockNumber', + params: [], + }, + response: { + result: '0x3b3301', + }, + }, + { + request: { + method: 'eth_call', + params: [ + { + to: '0xb1f8e55c7f64d203c1400b9d8555d050f94adf39', + data: '0xf0002ea900000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000080000000000000000000000000000000000000000000000000000000000000000100000000000000000000000089d24a6b4ccb1b6faa2625fe562bdd9a23260359000000000000000000000000000000000000000000000000000000000000000100000000000000000000000089d24a6b4ccb1b6faa2625fe562bdd9a23260359', + }, + '0x3b3301', + ], + }, + response: { + result: + '0x00000000000000000000000000000000000000000000000000000000000000200000000000000000000000000000000000000000000000000000000000000001000000000000000000000000000000000000000000000733ed8ef4c4a0155d09', + }, + }, + ], + }); + mockNetwork({ + networkClientConfiguration: buildInfuraNetworkClientConfiguration( + InfuraNetworkType['linea-mainnet'], + { infuraProjectId }, + ), + mocks: [ + { + request: { + method: 'eth_blockNumber', + params: [], + }, + response: { + result: '0x3b3301', + }, + }, + { + request: { + method: 'eth_call', + params: [ + { + to: '0xf62e6a41561b3650a69bb03199c735e3e3328c0d', + data: '0xf0002ea900000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000080000000000000000000000000000000000000000000000000000000000000000100000000000000000000000089d24a6b4ccb1b6faa2625fe562bdd9a23260359000000000000000000000000000000000000000000000000000000000000000100000000000000000000000089d24a6b4ccb1b6faa2625fe562bdd9a23260359', + }, + '0x3b3301', + ], + }, + response: { + result: + '0x00000000000000000000000000000000000000000000000000000000000000200000000000000000000000000000000000000000000000000000000000000001000000000000000000000000000000000000000000000a0155d09733ed8ef4c4', + }, + }, + ], + }); + const { assetsContract, messenger, provider } = + await setupAssetContractControllers({ + options: { + chainId: ChainId.mainnet, + }, + useNetworkControllerProvider: true, + infuraProjectId, + }); + assetsContract.setProvider(provider); + + const balancesOnMainnet = await messenger.call( + 'AssetsContractController:getBalancesInSingleCall', + ERC20_SAI_ADDRESS, + [ERC20_SAI_ADDRESS], + ); + expect(balancesOnMainnet).toStrictEqual({ + [ERC20_SAI_ADDRESS]: BigNumber.from('0x0733ed8ef4c4a0155d09'), + }); + + await messenger.call( + `NetworkController:setActiveNetwork`, + InfuraNetworkType['linea-mainnet'], + ); + + const balancesOnLineaMainnet = await messenger.call( + 'AssetsContractController:getBalancesInSingleCall', + ERC20_SAI_ADDRESS, + [ERC20_SAI_ADDRESS], + ); + expect(balancesOnLineaMainnet).toStrictEqual({ + [ERC20_SAI_ADDRESS]: BigNumber.from('0xa0155d09733ed8ef4c4'), + }); + messenger.clearEventSubscriptions('NetworkController:networkDidChange'); }); it('should not have balance in a single call after switching to network without token detection support', async () => { @@ -811,7 +1138,7 @@ describe('AssetsContractController', () => { provider, networkClientConfiguration, } = await setupAssetContractControllers(); - assetsContract.configure({ provider }); + assetsContract.setProvider(provider); mockNetworkWithDefaultChainId({ networkClientConfiguration, mocks: [ @@ -839,6 +1166,7 @@ describe('AssetsContractController', () => { ticker: BUILT_IN_NETWORKS.sepolia.ticker, type: NetworkClientType.Infura, network: 'sepolia', + failoverRpcUrls: [], infuraProjectId: networkClientConfiguration.infuraProjectId, }, mocks: [ @@ -864,27 +1192,30 @@ describe('AssetsContractController', () => { ], }); - const balances = await assetsContract.getBalancesInSingleCall( + const balances = await messenger.call( + `AssetsContractController:getBalancesInSingleCall`, ERC20_SAI_ADDRESS, [ERC20_SAI_ADDRESS], ); expect(balances[ERC20_SAI_ADDRESS]).toBeDefined(); - await network.setProviderType(NetworkType.sepolia); + await network.setActiveNetwork(NetworkType.sepolia); - const noBalances = await assetsContract.getBalancesInSingleCall( + const noBalances = await messenger.call( + `AssetsContractController:getBalancesInSingleCall`, ERC20_SAI_ADDRESS, [ERC20_SAI_ADDRESS], ); expect(noBalances).toStrictEqual({}); - messenger.clearEventSubscriptions('NetworkController:stateChange'); + messenger.clearEventSubscriptions('NetworkController:networkDidChange'); }); it('should throw missing provider error when transferring single ERC-1155 when missing provider', async () => { const { assetsContract, messenger } = await setupAssetContractControllers(); - assetsContract.configure({ provider: undefined }); + assetsContract.setProvider(undefined); await expect( - assetsContract.transferSingleERC1155( + messenger.call( + `AssetsContractController:transferSingleERC1155`, ERC1155_ADDRESS, TEST_ACCOUNT_PUBLIC_ADDRESS, TEST_ACCOUNT_PUBLIC_ADDRESS, @@ -892,13 +1223,51 @@ describe('AssetsContractController', () => { '1', ), ).rejects.toThrow(MISSING_PROVIDER_ERROR); - messenger.clearEventSubscriptions('NetworkController:stateChange'); + messenger.clearEventSubscriptions('NetworkController:networkDidChange'); + }); + + it('should throw when ERC1155 function transferSingle is not defined', async () => { + const { assetsContract, messenger, provider, networkClientConfiguration } = + await setupAssetContractControllers(); + assetsContract.setProvider(provider); + mockNetworkWithDefaultChainId({ + networkClientConfiguration, + mocks: [ + { + request: { + method: 'eth_call', + params: [ + { + to: ERC1155_ADDRESS, + data: '0x00fdd58e0000000000000000000000005a3ca5cd63807ce5e4d7841ab32ce6b6d9bbba2d5a3ca5cd63807ce5e4d7841ab32ce6b6d9bbba2d000000000000010000000001', + }, + 'latest', + ], + }, + response: { + result: + '0x0000000000000000000000000000000000000000000000000000000000000001', + }, + }, + ], + }); + await expect( + messenger.call( + `AssetsContractController:transferSingleERC1155`, + ERC1155_ADDRESS, + '0x0', + TEST_ACCOUNT_PUBLIC_ADDRESS, + ERC1155_ID, + '1', + ), + ).rejects.toThrow('contract.transferSingle is not a function'); + messenger.clearEventSubscriptions('NetworkController:networkDidChange'); }); it('should get the balance of a ERC-1155 NFT for a given address', async () => { const { assetsContract, messenger, provider, networkClientConfiguration } = await setupAssetContractControllers(); - assetsContract.configure({ provider }); + assetsContract.setProvider(provider); mockNetworkWithDefaultChainId({ networkClientConfiguration, mocks: [ @@ -920,31 +1289,33 @@ describe('AssetsContractController', () => { }, ], }); - const balance = await assetsContract.getERC1155BalanceOf( + const balance = await messenger.call( + `AssetsContractController:getERC1155BalanceOf`, TEST_ACCOUNT_PUBLIC_ADDRESS, ERC1155_ADDRESS, ERC1155_ID, ); expect(Number(balance)).toBeGreaterThan(0); - messenger.clearEventSubscriptions('NetworkController:stateChange'); + messenger.clearEventSubscriptions('NetworkController:networkDidChange'); }); it('should throw missing provider error when getting the balance of a ERC-1155 NFT when missing provider', async () => { - const { assetsContract, messenger } = await setupAssetContractControllers(); + const { messenger } = await setupAssetContractControllers(); await expect( - assetsContract.getERC1155BalanceOf( + messenger.call( + `AssetsContractController:getERC1155BalanceOf`, TEST_ACCOUNT_PUBLIC_ADDRESS, ERC1155_ADDRESS, ERC1155_ID, ), ).rejects.toThrow(MISSING_PROVIDER_ERROR); - messenger.clearEventSubscriptions('NetworkController:stateChange'); + messenger.clearEventSubscriptions('NetworkController:networkDidChange'); }); it('should get the URI of a ERC-1155 NFT', async () => { const { assetsContract, messenger, provider, networkClientConfiguration } = await setupAssetContractControllers(); - assetsContract.configure({ provider }); + assetsContract.setProvider(provider); mockNetworkWithDefaultChainId({ networkClientConfiguration, mocks: [ @@ -967,11 +1338,160 @@ describe('AssetsContractController', () => { ], }); const expectedUri = `https://api.opensea.io/api/v1/metadata/${ERC1155_ADDRESS}/0x{id}`; - const uri = await assetsContract.getERC1155TokenURI( + const uri = await messenger.call( + `AssetsContractController:getERC1155TokenURI`, ERC1155_ADDRESS, ERC1155_ID, ); expect(uri.toLowerCase()).toStrictEqual(expectedUri); - messenger.clearEventSubscriptions('NetworkController:stateChange'); + messenger.clearEventSubscriptions('NetworkController:networkDidChange'); + }); + + it('should get the staked ethereum balance for an address', async () => { + const { assetsContract, messenger, provider, networkClientConfiguration } = + await setupAssetContractControllers(); + assetsContract.setProvider(provider); + + mockNetworkWithDefaultChainId({ + networkClientConfiguration, + mocks: [ + // getShares + { + request: { + method: 'eth_call', + params: [ + { + to: '0xca11bde05977b3631167028862be2a173976ca11', + data: '0xbce38bd700000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000040000000000000000000000000000000000000000000000000000000000000000100000000000000000000000000000000000000000000000000000000000000200000000000000000000000004fef9d741011476750a243ac70b9789a63dd47df00000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000024f04da65b0000000000000000000000005a3ca5cd63807ce5e4d7841ab32ce6b6d9bbba2d00000000000000000000000000000000000000000000000000000000', + }, + 'latest', + ], + }, + response: { + result: + '0x0000000000000000000000000000000000000000000000000000000000000020000000000000000000000000000000000000000000000000000000000000000100000000000000000000000000000000000000000000000000000000000000200000000000000000000000000000000000000000000000000000000000000001000000000000000000000000000000000000000000000000000000000000004000000000000000000000000000000000000000000000000000000000000000200000000000000000000000000000000000000000000000000007de0ff9d7304a', // de0b6b3a7640000 + }, + }, + // convertToAssets + { + request: { + method: 'eth_call', + params: [ + { + to: '0xca11bde05977b3631167028862be2a173976ca11', + data: '0xbce38bd700000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000040000000000000000000000000000000000000000000000000000000000000000100000000000000000000000000000000000000000000000000000000000000200000000000000000000000004fef9d741011476750a243ac70b9789a63dd47df0000000000000000000000000000000000000000000000000000000000000040000000000000000000000000000000000000000000000000000000000000002407a2d13a0000000000000000000000000000000000000000000000000007de0ff9d7304a00000000000000000000000000000000000000000000000000000000', + }, + 'latest', + ], + }, + response: { + result: + '0x00000000000000000000000000000000000000000000000000000000000000200000000000000000000000000000000000000000000000000000000000000001000000000000000000000000000000000000000000000000000000000000002000000000000000000000000000000000000000000000000000000000000000010000000000000000000000000000000000000000000000000000000000000040000000000000000000000000000000000000000000000000000000000000002000000000000000000000000000000000000000000000000000081f495b33d2df', + }, + }, + ], + }); + + const balance = await assetsContract.getStakedBalanceForChain([ + TEST_ACCOUNT_PUBLIC_ADDRESS, + ]); + + // Shares: 2214485034479690 + // Assets: 2286199736881887 (0.002286199736881887 ETH) + + expect(balance).toBeDefined(); + expect(balance[TEST_ACCOUNT_PUBLIC_ADDRESS]).toBe('0x081f495b33d2df'); + expect( + BigNumber.from(balance[TEST_ACCOUNT_PUBLIC_ADDRESS]).toString(), + ).toBe('2286199736881887'); + + messenger.clearEventSubscriptions('NetworkController:networkDidChange'); + }); + + it('should return default of zero hex as staked ethereum balance if user has no shares', async () => { + const errorSpy = jest.spyOn(console, 'error'); + const { assetsContract, messenger, provider, networkClientConfiguration } = + await setupAssetContractControllers(); + assetsContract.setProvider(provider); + + mockNetworkWithDefaultChainId({ + networkClientConfiguration, + mocks: [ + // getShares + { + request: { + method: 'eth_call', + params: [ + { + to: '0xca11bde05977b3631167028862be2a173976ca11', + data: '0xbce38bd700000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000040000000000000000000000000000000000000000000000000000000000000000100000000000000000000000000000000000000000000000000000000000000200000000000000000000000004fef9d741011476750a243ac70b9789a63dd47df00000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000024f04da65b0000000000000000000000005a3ca5cd63807ce5e4d7841ab32ce6b6d9bbba2d00000000000000000000000000000000000000000000000000000000', + }, + 'latest', + ], + }, + response: { + result: + '0x0000000000000000000000000000000000000000000000000000000000000020000000000000000000000000000000000000000000000000000000000000000100000000000000000000000000000000000000000000000000000000000000200000000000000000000000000000000000000000000000000000000000000001000000000000000000000000000000000000000000000000000000000000004000000000000000000000000000000000000000000000000000000000000000200000000000000000000000000000000000000000000000000000000000000000', + }, + }, + ], + }); + + const balance = await assetsContract.getStakedBalanceForChain([ + TEST_ACCOUNT_PUBLIC_ADDRESS, + ]); + + expect(balance).toBeDefined(); + expect(balance).toStrictEqual({ + '0x5a3CA5cD63807Ce5e4d7841AB32Ce6B6d9BbBa2D': '0x00', + }); + expect( + BigNumber.from( + balance['0x5a3CA5cD63807Ce5e4d7841AB32Ce6B6d9BbBa2D'], + ).toString(), + ).toBe('0'); + expect(errorSpy).toHaveBeenCalledTimes(0); + + errorSpy.mockRestore(); + messenger.clearEventSubscriptions('NetworkController:networkDidChange'); + }); + + it('should return default of zero hex as staked ethereum balance if there is any error thrown', async () => { + let error; + const errorSpy = jest + .spyOn(console, 'error') + .mockImplementationOnce((e) => { + error = e; + }); + const { assetsContract, messenger, provider } = + await setupAssetContractControllers(); + assetsContract.setProvider(provider); + + const balance = await assetsContract.getStakedBalanceForChain([ + TEST_ACCOUNT_PUBLIC_ADDRESS, + ]); + + expect(balance).toBeDefined(); + expect(balance).toStrictEqual({ + '0x5a3CA5cD63807Ce5e4d7841AB32Ce6B6d9BbBa2D': '0x00', + }); + expect( + BigNumber.from( + balance['0x5a3CA5cD63807Ce5e4d7841AB32Ce6B6d9BbBa2D'], + ).toString(), + ).toBe('0'); + expect(errorSpy).toHaveBeenCalledTimes(1); + expect(errorSpy).toHaveBeenCalledWith(error); + + errorSpy.mockRestore(); + messenger.clearEventSubscriptions('NetworkController:networkDidChange'); + }); + + it('should throw missing provider error when getting staked ethereum balance and missing provider', async () => { + const { assetsContract, messenger } = await setupAssetContractControllers(); + await expect( + assetsContract.getStakedBalanceForChain([TEST_ACCOUNT_PUBLIC_ADDRESS]), + ).rejects.toThrow(MISSING_PROVIDER_ERROR); + messenger.clearEventSubscriptions('NetworkController:networkDidChange'); }); }); diff --git a/packages/assets-controllers/src/AssetsContractController.ts b/packages/assets-controllers/src/AssetsContractController.ts index 707a0650dba..c25c085a906 100644 --- a/packages/assets-controllers/src/AssetsContractController.ts +++ b/packages/assets-controllers/src/AssetsContractController.ts @@ -1,22 +1,33 @@ +// import { BigNumber } from '@ethersproject/bignumber'; +import type { BigNumber } from '@ethersproject/bignumber'; import { Contract } from '@ethersproject/contracts'; import { Web3Provider } from '@ethersproject/providers'; -import type { BaseConfig, BaseState } from '@metamask/base-controller'; -import { BaseController } from '@metamask/base-controller'; import { IPFS_DEFAULT_GATEWAY_URL } from '@metamask/controller-utils'; +import type { Messenger } from '@metamask/messenger'; import type { NetworkClientId, - NetworkState, - NetworkController, + NetworkControllerGetNetworkClientByIdAction, + NetworkControllerGetNetworkConfigurationByNetworkClientIdAction, + NetworkControllerGetSelectedNetworkClientAction, + NetworkControllerGetStateAction, + NetworkControllerNetworkDidChangeEvent, + Provider, } from '@metamask/network-controller'; -import type { PreferencesState } from '@metamask/preferences-controller'; +import type { PreferencesControllerStateChangeEvent } from '@metamask/preferences-controller'; import type { Hex } from '@metamask/utils'; -import type { BN } from 'ethereumjs-util'; +import type BN from 'bn.js'; import abiSingleCallBalancesContract from 'single-call-balance-checker-abi'; -import { SupportedTokenDetectionNetworks } from './assetsUtil'; -import { ERC20Standard } from './Standards/ERC20Standard'; -import { ERC1155Standard } from './Standards/NftStandards/ERC1155/ERC1155Standard'; -import { ERC721Standard } from './Standards/NftStandards/ERC721/ERC721Standard'; +import type { AssetsContractControllerMethodActions } from './AssetsContractController-method-action-types.js'; +import { + SupportedStakedBalanceNetworks, + SupportedTokenDetectionNetworks, +} from './assetsUtil.js'; +import type { Call } from './multicall.js'; +import { multicallOrFallback } from './multicall.js'; +import { ERC20Standard } from './Standards/ERC20Standard.js'; +import { ERC721Standard } from './Standards/NftStandards/ERC721/ERC721Standard.js'; +import { ERC1155Standard } from './Standards/NftStandards/ERC1155/ERC1155Standard.js'; /** * Check if token detection is enabled for certain networks @@ -24,129 +35,205 @@ import { ERC721Standard } from './Standards/NftStandards/ERC721/ERC721Standard'; * @param chainId - ChainID of network * @returns Whether the current network supports token detection */ -export const SINGLE_CALL_BALANCES_ADDRESS_BY_CHAINID: Record = { - [SupportedTokenDetectionNetworks.mainnet]: +export const SINGLE_CALL_BALANCES_ADDRESS_BY_CHAINID = { + [SupportedTokenDetectionNetworks.Mainnet]: '0xb1f8e55c7f64d203c1400b9d8555d050f94adf39', - [SupportedTokenDetectionNetworks.bsc]: + [SupportedTokenDetectionNetworks.Bsc]: '0x2352c63A83f9Fd126af8676146721Fa00924d7e4', - [SupportedTokenDetectionNetworks.polygon]: + [SupportedTokenDetectionNetworks.Polygon]: '0x2352c63A83f9Fd126af8676146721Fa00924d7e4', - [SupportedTokenDetectionNetworks.avax]: + [SupportedTokenDetectionNetworks.Avax]: '0xD023D153a0DFa485130ECFdE2FAA7e612EF94818', - [SupportedTokenDetectionNetworks.aurora]: + [SupportedTokenDetectionNetworks.Aurora]: '0x1286415D333855237f89Df27D388127181448538', - [SupportedTokenDetectionNetworks.linea_goerli]: + [SupportedTokenDetectionNetworks.LineaGoerli]: '0x10dAd7Ca3921471f616db788D9300DC97Db01783', - [SupportedTokenDetectionNetworks.linea_mainnet]: + [SupportedTokenDetectionNetworks.LineaMainnet]: '0xF62e6a41561b3650a69Bb03199C735e3E3328c0D', -}; + [SupportedTokenDetectionNetworks.Arbitrum]: + '0x151E24A486D7258dd7C33Fb67E4bB01919B7B32c', + [SupportedTokenDetectionNetworks.Optimism]: + '0xB1c568e9C3E6bdaf755A60c7418C269eb11524FC', + [SupportedTokenDetectionNetworks.Base]: + '0x6AA75276052D96696134252587894ef5FFA520af', + [SupportedTokenDetectionNetworks.Zksync]: + '0x458fEd3144680a5b8bcfaa0F9594aa19B4Ea2D34', + [SupportedTokenDetectionNetworks.Cronos]: + '0x768ca200f0fc702ac9ea502498c18f5eff176378', + [SupportedTokenDetectionNetworks.Celo]: + '0x6aa75276052d96696134252587894ef5ffa520af', + [SupportedTokenDetectionNetworks.Gnosis]: + '0x6aa75276052d96696134252587894ef5ffa520af', + [SupportedTokenDetectionNetworks.Fantom]: + '0x6aa75276052d96696134252587894ef5ffa520af', + [SupportedTokenDetectionNetworks.PolygonZkevm]: + '0x6aa75276052d96696134252587894ef5ffa520af', + [SupportedTokenDetectionNetworks.Moonbeam]: + '0x6aa75276052d96696134252587894ef5ffa520af', + [SupportedTokenDetectionNetworks.Moonriver]: + '0x6aa75276052d96696134252587894ef5ffa520af', + [SupportedTokenDetectionNetworks.Robinhood]: + '0x1C0b2428d5C520EF51310dd1f93fBA6B58b47dA6', +} as const satisfies Record; + +export const STAKING_CONTRACT_ADDRESS_BY_CHAINID = { + [SupportedStakedBalanceNetworks.Mainnet]: + '0x4fef9d741011476750a243ac70b9789a63dd47df', + [SupportedStakedBalanceNetworks.Hoodi]: + '0xe96ac18cfe5a7af8fe1fe7bc37ff110d88bc67ff', +} as Record; export const MISSING_PROVIDER_ERROR = 'AssetsContractController failed to set the provider correctly. A provider must be set for this method to be available'; /** - * @type AssetsContractConfig + * BalanceMap + * + * Key value object containing the balance for each tokenAddress * - * Assets Contract controller configuration - * @property provider - Provider used to create a new web3 instance + * [tokenAddress] - Address of the token */ -export interface AssetsContractConfig extends BaseConfig { - provider: any; - ipfsGateway: string; - chainId: Hex; -} +export type BalanceMap = { + [tokenAddress: string]: BN; +}; /** - * @type BalanceMap - * - * Key value object containing the balance for each tokenAddress - * @property [tokenAddress] - Address of the token + * The name of the {@link AssetsContractController} */ -export interface BalanceMap { - [tokenAddress: string]: BN; -} +const name = 'AssetsContractController'; + +/** + * The union of all internal messenger actions available to the {@link AssetsContractControllerMessenger}. + */ +export type AssetsContractControllerActions = + AssetsContractControllerMethodActions; + +/** + * The union of all internal messenger events available to the {@link AssetsContractControllerMessenger}. + */ +export type AssetsContractControllerEvents = never; + +/** + * The union of all external messenger actions that must be allowed by the {@link AssetsContractControllerMessenger}. + */ +export type AllowedActions = + | NetworkControllerGetNetworkClientByIdAction + | NetworkControllerGetNetworkConfigurationByNetworkClientIdAction + | NetworkControllerGetSelectedNetworkClientAction + | NetworkControllerGetStateAction; + +/** + * The union of all external messenger event that must be allowed by the {@link AssetsContractControllerMessenger}. + */ +export type AllowedEvents = + | PreferencesControllerStateChangeEvent + | NetworkControllerNetworkDidChangeEvent; + +/** + * The messenger of the {@link AssetsContractController}. + */ +export type AssetsContractControllerMessenger = Messenger< + typeof name, + AssetsContractControllerActions | AllowedActions, + AssetsContractControllerEvents | AllowedEvents +>; + +export type StakedBalance = string | undefined; + +const MESSENGER_EXPOSED_METHODS = [ + 'getERC20Standard', + 'getERC721Standard', + 'getERC1155Standard', + 'getERC20BalanceOf', + 'getERC20TokenDecimals', + 'getERC20TokenName', + 'getERC721NftTokenId', + 'getERC721TokenURI', + 'getERC721AssetName', + 'getERC721AssetSymbol', + 'getERC721OwnerOf', + 'getERC1155TokenURI', + 'getERC1155BalanceOf', + 'transferSingleERC1155', + 'getTokenStandardAndDetails', + 'getBalancesInSingleCall', + 'getStakedBalanceForChain', +] as const; /** * Controller that interacts with contracts on mainnet through web3 */ -export class AssetsContractController extends BaseController< - AssetsContractConfig, - BaseState -> { - private _provider?: any; +export class AssetsContractController { + readonly name: typeof name = name; - /** - * Name of this controller used during composition - */ - override name = 'AssetsContractController'; + protected messenger: AssetsContractControllerMessenger; + + #provider: Provider | undefined; + + #ipfsGateway: string; - private readonly getNetworkClientById: NetworkController['getNetworkClientById']; + #chainId: Hex; /** * Creates a AssetsContractController instance. * * @param options - The controller options. + * @param options.messenger - The messenger. * @param options.chainId - The chain ID of the current network. - * @param options.onPreferencesStateChange - Allows subscribing to preference controller state changes. - * @param options.onNetworkStateChange - Allows subscribing to network controller state changes. - * @param options.getNetworkClientById - Gets the network client with the given id from the NetworkController. - * @param config - Initial options used to configure this controller. - * @param state - Initial state to set on this controller. */ - constructor( - { - chainId: initialChainId, - onPreferencesStateChange, - onNetworkStateChange, - getNetworkClientById, - }: { - chainId: Hex; - onPreferencesStateChange: ( - listener: (preferencesState: PreferencesState) => void, - ) => void; - onNetworkStateChange: ( - listener: (networkState: NetworkState) => void, - ) => void; - getNetworkClientById: NetworkController['getNetworkClientById']; - }, - config?: Partial, - state?: Partial, - ) { - super(config, state); - this.defaultConfig = { - provider: undefined, - ipfsGateway: IPFS_DEFAULT_GATEWAY_URL, - chainId: initialChainId, - }; - this.initialize(); - this.getNetworkClientById = getNetworkClientById; - - onPreferencesStateChange(({ ipfsGateway }) => { - this.configure({ ipfsGateway }); - }); - - onNetworkStateChange((networkState) => { - if (this.config.chainId !== networkState.providerConfig.chainId) { - this.configure({ - chainId: networkState.providerConfig.chainId, - }); - } - }); + constructor({ + messenger, + chainId: initialChainId, + }: { + messenger: AssetsContractControllerMessenger; + chainId: Hex; + }) { + this.messenger = messenger; + this.#provider = undefined; + this.#ipfsGateway = IPFS_DEFAULT_GATEWAY_URL; + this.#chainId = initialChainId; + + messenger.registerMethodActionHandlers(this, MESSENGER_EXPOSED_METHODS); + this.#registerEventSubscriptions(); + } + + #registerEventSubscriptions() { + this.messenger.subscribe( + `PreferencesController:stateChange`, + ({ ipfsGateway }) => { + this.#ipfsGateway = ipfsGateway; + }, + ); + + this.messenger.subscribe( + `NetworkController:networkDidChange`, + ({ selectedNetworkClientId }) => { + const chainId = this.#getCorrectChainId(selectedNetworkClientId); + + if (this.#chainId !== chainId) { + this.#chainId = chainId; + // @ts-expect-error TODO: remove this annotation once the `Eip1193Provider` class is released + this.#provider = this.#getCorrectProvider(); + } + }, + ); } /** * Sets a new provider. * - * TODO: Replace this wth a method. - * - * @property provider - Provider used to create a new underlying Web3 instance + * @param provider - Provider used to create a new underlying Web3 instance */ - set provider(provider: any) { - this._provider = provider; + setProvider(provider: Provider | undefined) { + this.#provider = provider; + } + + get ipfsGateway() { + return this.#ipfsGateway; } - get provider() { - throw new Error('Property only used for setting'); + get chainId() { + return this.#chainId; } /** @@ -155,10 +242,14 @@ export class AssetsContractController extends BaseController< * @param networkClientId - Network Client ID. * @returns Web3Provider instance. */ - getProvider(networkClientId?: NetworkClientId): Web3Provider { + #getCorrectProvider(networkClientId?: NetworkClientId): Web3Provider { const provider = networkClientId - ? this.getNetworkClientById(networkClientId).provider - : this._provider; + ? this.messenger.call( + `NetworkController:getNetworkClientById`, + networkClientId, + ).provider + : (this.messenger.call('NetworkController:getSelectedNetworkClient') + ?.provider ?? this.#provider); if (provider === undefined) { throw new Error(MISSING_PROVIDER_ERROR); @@ -173,10 +264,24 @@ export class AssetsContractController extends BaseController< * @param networkClientId - Network Client ID used to get the provider. * @returns Hex chain ID. */ - getChainId(networkClientId?: NetworkClientId): Hex { - return networkClientId - ? this.getNetworkClientById(networkClientId).configuration.chainId - : this.config.chainId; + #getCorrectChainId(networkClientId?: NetworkClientId): Hex { + if (networkClientId) { + const networkClientConfiguration = this.messenger.call( + 'NetworkController:getNetworkConfigurationByNetworkClientId', + networkClientId, + ); + if (networkClientConfiguration) { + return networkClientConfiguration.chainId; + } + } + const { selectedNetworkClientId } = this.messenger.call( + 'NetworkController:getState', + ); + const networkClient = this.messenger.call( + 'NetworkController:getNetworkClientById', + selectedNetworkClientId, + ); + return networkClient.configuration?.chainId ?? this.#chainId; } /** @@ -186,7 +291,7 @@ export class AssetsContractController extends BaseController< * @returns ERC20Standard instance. */ getERC20Standard(networkClientId?: NetworkClientId): ERC20Standard { - const provider = this.getProvider(networkClientId); + const provider = this.#getCorrectProvider(networkClientId); return new ERC20Standard(provider); } @@ -197,7 +302,7 @@ export class AssetsContractController extends BaseController< * @returns ERC721Standard instance. */ getERC721Standard(networkClientId?: NetworkClientId): ERC721Standard { - const provider = this.getProvider(networkClientId); + const provider = this.#getCorrectProvider(networkClientId); return new ERC721Standard(provider); } @@ -208,7 +313,7 @@ export class AssetsContractController extends BaseController< * @returns ERC1155Standard instance. */ getERC1155Standard(networkClientId?: NetworkClientId): ERC1155Standard { - const provider = this.getProvider(networkClientId); + const provider = this.#getCorrectProvider(networkClientId); return new ERC1155Standard(provider); } @@ -268,7 +373,7 @@ export class AssetsContractController extends BaseController< * @param networkClientId - Network Client ID to fetch the provider with. * @returns Promise resolving to token identifier for the 'index'th asset assigned to 'selectedAddress'. */ - getERC721NftTokenId( + async getERC721NftTokenId( address: string, selectedAddress: string, index: number, @@ -301,9 +406,7 @@ export class AssetsContractController extends BaseController< balance?: BN | undefined; }> { // Asserts provider is available - this.getProvider(networkClientId); - - const { ipfsGateway } = this.config; + this.#getCorrectProvider(networkClientId); // ERC721 try { @@ -311,7 +414,7 @@ export class AssetsContractController extends BaseController< return { ...(await erc721Standard.getDetails( tokenAddress, - ipfsGateway, + this.#ipfsGateway, tokenId, )), }; @@ -325,7 +428,7 @@ export class AssetsContractController extends BaseController< return { ...(await erc1155Standard.getDetails( tokenAddress, - ipfsGateway, + this.#ipfsGateway, tokenId, )), }; @@ -489,9 +592,12 @@ export class AssetsContractController extends BaseController< tokensToDetect: string[], networkClientId?: NetworkClientId, ) { - const chainId = this.getChainId(networkClientId); - const provider = this.getProvider(networkClientId); - if (!(chainId in SINGLE_CALL_BALANCES_ADDRESS_BY_CHAINID)) { + const chainId = this.#getCorrectChainId(networkClientId); + const provider = this.#getCorrectProvider(networkClientId); + if ( + !((id): id is keyof typeof SINGLE_CALL_BALANCES_ADDRESS_BY_CHAINID => + id in SINGLE_CALL_BALANCES_ADDRESS_BY_CHAINID)(chainId) + ) { // Only fetch balance if contract address exists return {}; } @@ -516,6 +622,107 @@ export class AssetsContractController extends BaseController< } return nonZeroBalances; } + + /** + * Get the staked ethereum balance for multiple addresses in a single call. + * + * @param addresses - The addresses to check staked ethereum balance for. + * @param networkClientId - Network Client ID to fetch the provider with. + * @returns The hex staked ethereum balance for address. + */ + async getStakedBalanceForChain( + addresses: string[], + networkClientId?: NetworkClientId, + ): Promise> { + const chainId = this.#getCorrectChainId(networkClientId); + const provider = this.#getCorrectProvider(networkClientId); + + const balances = addresses.reduce>( + (accumulator, address) => { + accumulator[address] = '0x00'; + return accumulator; + }, + {}, + ); + + // Only fetch staked balance on supported networks + if ( + ![ + SupportedStakedBalanceNetworks.Mainnet, + SupportedStakedBalanceNetworks.Hoodi, + ].includes(chainId as SupportedStakedBalanceNetworks) + ) { + return {}; + } + // Only fetch staked balance if contract address exists + if ( + !((id): id is keyof typeof STAKING_CONTRACT_ADDRESS_BY_CHAINID => + id in STAKING_CONTRACT_ADDRESS_BY_CHAINID)(chainId) + ) { + return {}; + } + + const contractAddress = STAKING_CONTRACT_ADDRESS_BY_CHAINID[chainId]; + const 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', + }, + ]; + + try { + const calls = addresses.map((address) => ({ + contract: new Contract(contractAddress, abi, provider), + functionSignature: 'getShares(address)', + arguments: [address], + })); + + const userShares = await multicallOrFallback(calls, chainId, provider); + + const nonZeroCalls = userShares + .map((shares, index) => { + if (shares.success && (shares.value as BigNumber).gt(0)) { + return { + address: addresses[index], + call: { + contract: new Contract(contractAddress, abi, provider), + functionSignature: 'convertToAssets(uint256)', + arguments: [(shares.value as BigNumber).toString()], + }, + }; + } + return null; + }) + .filter(Boolean) as { call: Call; address: string }[]; + + const nonZeroBalances = await multicallOrFallback( + nonZeroCalls.map((call) => call.call), + chainId, + provider, + ); + nonZeroBalances.forEach((balance, index) => { + if (balance.success && balance.value) { + const { address } = nonZeroCalls[index]; + balances[address] = (balance.value as BigNumber).toHexString(); + } + }); + } catch (error) { + // if we get an error, log and return the default value + console.error(error); + } + + return balances; + } } export default AssetsContractController; diff --git a/packages/assets-controllers/src/AssetsContractControllerWithNetworkClientId.test.ts b/packages/assets-controllers/src/AssetsContractControllerWithNetworkClientId.test.ts index b69148ddbae..1a5e6e1f29c 100644 --- a/packages/assets-controllers/src/AssetsContractControllerWithNetworkClientId.test.ts +++ b/packages/assets-controllers/src/AssetsContractControllerWithNetworkClientId.test.ts @@ -1,6 +1,8 @@ +import { BigNumber } from '@ethersproject/bignumber'; import { BUILT_IN_NETWORKS } from '@metamask/controller-utils'; import { NetworkClientType } from '@metamask/network-controller'; +import { SECONDS } from '../../../tests/constants.js'; import { setupAssetContractControllers, mockNetworkWithDefaultChainId, @@ -19,30 +21,38 @@ const TEST_ACCOUNT_PUBLIC_ADDRESS = describe('AssetsContractController with NetworkClientId', () => { it('should throw when getting ERC-20 token balance when networkClientId is invalid', async () => { - const { assetsContract, messenger } = await setupAssetContractControllers(); + const { messenger } = await setupAssetContractControllers(); await expect( - assetsContract.getERC20BalanceOf( - ERC20_UNI_ADDRESS, - TEST_ACCOUNT_PUBLIC_ADDRESS, - 'invalidNetworkClientId', - ), - ).rejects.toThrow('No custom network client was found'); - messenger.clearEventSubscriptions('NetworkController:stateChange'); + async () => + await messenger.call( + `AssetsContractController:getERC20BalanceOf`, + ERC20_UNI_ADDRESS, + TEST_ACCOUNT_PUBLIC_ADDRESS, + 'invalidNetworkClientId', + ), + ).rejects.toThrow( + `No network client was found with ID "invalidNetworkClientId".`, + ); + messenger.clearEventSubscriptions('NetworkController:networkDidChange'); }); it('should throw when getting ERC-20 token decimal when networkClientId is invalid', async () => { - const { assetsContract, messenger } = await setupAssetContractControllers(); + const { messenger } = await setupAssetContractControllers(); await expect( - assetsContract.getERC20TokenDecimals( - ERC20_UNI_ADDRESS, - 'invalidNetworkClientId', - ), - ).rejects.toThrow('No custom network client was found'); - messenger.clearEventSubscriptions('NetworkController:stateChange'); + async () => + await messenger.call( + `AssetsContractController:getERC20TokenDecimals`, + ERC20_UNI_ADDRESS, + 'invalidNetworkClientId', + ), + ).rejects.toThrow( + `No network client was found with ID "invalidNetworkClientId".`, + ); + messenger.clearEventSubscriptions('NetworkController:networkDidChange'); }); it('should get balance of ERC-20 token contract correctly', async () => { - const { assetsContract, messenger, networkClientConfiguration } = + const { messenger, networkClientConfiguration } = await setupAssetContractControllers(); mockNetworkWithDefaultChainId({ networkClientConfiguration, @@ -81,23 +91,25 @@ describe('AssetsContractController with NetworkClientId', () => { }, ], }); - const UNIBalance = await assetsContract.getERC20BalanceOf( + const UNIBalance = await messenger.call( + `AssetsContractController:getERC20BalanceOf`, ERC20_UNI_ADDRESS, TEST_ACCOUNT_PUBLIC_ADDRESS, 'mainnet', ); - const UNINoBalance = await assetsContract.getERC20BalanceOf( + const UNINoBalance = await messenger.call( + `AssetsContractController:getERC20BalanceOf`, ERC20_UNI_ADDRESS, '0x202637dAAEfbd7f131f90338a4A6c69F6Cd5CE91', 'mainnet', ); expect(UNIBalance.toString(16)).not.toBe('0'); expect(UNINoBalance.toString(16)).toBe('0'); - messenger.clearEventSubscriptions('NetworkController:stateChange'); + messenger.clearEventSubscriptions('NetworkController:networkDidChange'); }); it('should get ERC-721 NFT tokenId correctly', async () => { - const { assetsContract, messenger, networkClientConfiguration } = + const { messenger, networkClientConfiguration } = await setupAssetContractControllers(); mockNetworkWithDefaultChainId({ networkClientConfiguration, @@ -120,45 +132,52 @@ describe('AssetsContractController with NetworkClientId', () => { }, ], }); - const tokenId = await assetsContract.getERC721NftTokenId( + const tokenId = await messenger.call( + `AssetsContractController:getERC721NftTokenId`, ERC721_GODS_ADDRESS, '0x9a90bd8d1149a88b42a99cf62215ad955d6f498a', 0, 'mainnet', ); expect(tokenId).not.toBe(0); - messenger.clearEventSubscriptions('NetworkController:stateChange'); + messenger.clearEventSubscriptions('NetworkController:networkDidChange'); }); it('should throw error when getting ERC-721 token standard and details when networkClientId is invalid', async () => { - const { assetsContract, messenger } = await setupAssetContractControllers(); + const { messenger } = await setupAssetContractControllers(); await expect( - assetsContract.getTokenStandardAndDetails( - ERC20_UNI_ADDRESS, - TEST_ACCOUNT_PUBLIC_ADDRESS, - undefined, - 'invalidNetworkClientId', - ), - ).rejects.toThrow('No custom network client was found'); - messenger.clearEventSubscriptions('NetworkController:stateChange'); + async () => + await messenger.call( + `AssetsContractController:getTokenStandardAndDetails`, + ERC20_UNI_ADDRESS, + TEST_ACCOUNT_PUBLIC_ADDRESS, + undefined, + 'invalidNetworkClientId', + ), + ).rejects.toThrow( + 'No network client was found with ID "invalidNetworkClientId".', + ); + messenger.clearEventSubscriptions('NetworkController:networkDidChange'); }); it('should throw contract standard error when getting ERC-20 token standard and details when provided with invalid ERC-20 address', async () => { - const { assetsContract, messenger } = await setupAssetContractControllers(); + const { messenger } = await setupAssetContractControllers(); const error = 'Unable to determine contract standard'; await expect( - assetsContract.getTokenStandardAndDetails( - 'BaDeRc20AdDrEsS', - TEST_ACCOUNT_PUBLIC_ADDRESS, - undefined, - 'mainnet', - ), + async () => + await messenger.call( + `AssetsContractController:getTokenStandardAndDetails`, + 'BaDeRc20AdDrEsS', + TEST_ACCOUNT_PUBLIC_ADDRESS, + undefined, + 'mainnet', + ), ).rejects.toThrow(error); - messenger.clearEventSubscriptions('NetworkController:stateChange'); + messenger.clearEventSubscriptions('NetworkController:networkDidChange'); }); it('should get ERC-721 token standard and details', async () => { - const { assetsContract, messenger, networkClientConfiguration } = + const { messenger, networkClientConfiguration } = await setupAssetContractControllers(); mockNetworkWithDefaultChainId({ networkClientConfiguration, @@ -213,68 +232,177 @@ describe('AssetsContractController with NetworkClientId', () => { }, ], }); - const standardAndDetails = await assetsContract.getTokenStandardAndDetails( + const standardAndDetails = await messenger.call( + `AssetsContractController:getTokenStandardAndDetails`, ERC721_GODS_ADDRESS, TEST_ACCOUNT_PUBLIC_ADDRESS, undefined, 'mainnet', ); expect(standardAndDetails.standard).toBe('ERC721'); - messenger.clearEventSubscriptions('NetworkController:stateChange'); + messenger.clearEventSubscriptions('NetworkController:networkDidChange'); }); - it('should get ERC-1155 token standard and details', async () => { - const { assetsContract, messenger, networkClientConfiguration } = - await setupAssetContractControllers(); - mockNetworkWithDefaultChainId({ - networkClientConfiguration, - mocks: [ - { - request: { - method: 'eth_call', - params: [ - { - to: ERC1155_ADDRESS, - data: '0x01ffc9a780ac58cd00000000000000000000000000000000000000000000000000000000', - }, - 'latest', - ], - }, - response: { - result: - '0x0000000000000000000000000000000000000000000000000000000000000000', - }, - }, - { - request: { - method: 'eth_call', - params: [ - { - to: ERC1155_ADDRESS, - data: '0x01ffc9a7d9b67a2600000000000000000000000000000000000000000000000000000000', - }, - 'latest', - ], - }, - response: { - result: - '0x0000000000000000000000000000000000000000000000000000000000000001', - }, - }, - ], - }); - const standardAndDetails = await assetsContract.getTokenStandardAndDetails( - ERC1155_ADDRESS, - TEST_ACCOUNT_PUBLIC_ADDRESS, - undefined, - 'mainnet', - ); - expect(standardAndDetails.standard).toBe('ERC1155'); - messenger.clearEventSubscriptions('NetworkController:stateChange'); - }); + it( + 'should get ERC-1155 token standard and details', + async () => { + const { messenger, networkClientConfiguration } = + await setupAssetContractControllers(); + mockNetworkWithDefaultChainId({ + networkClientConfiguration, + mocks: [ + { + request: { + method: 'eth_call', + params: [ + { + to: ERC1155_ADDRESS, + data: '0x01ffc9a780ac58cd00000000000000000000000000000000000000000000000000000000', + }, + 'latest', + ], + }, + response: { + result: + '0x0000000000000000000000000000000000000000000000000000000000000000', + }, + }, + { + request: { + method: 'eth_call', + params: [ + { + to: ERC1155_ADDRESS, + data: '0x01ffc9a7d9b67a2600000000000000000000000000000000000000000000000000000000', + }, + 'latest', + ], + }, + response: { + result: + '0x0000000000000000000000000000000000000000000000000000000000000001', + }, + }, + ], + }); + const standardAndDetails = await messenger.call( + `AssetsContractController:getTokenStandardAndDetails`, + ERC1155_ADDRESS, + TEST_ACCOUNT_PUBLIC_ADDRESS, + undefined, + 'mainnet', + ); + expect(standardAndDetails.standard).toBe('ERC1155'); + messenger.clearEventSubscriptions('NetworkController:networkDidChange'); + }, + 10 * SECONDS, + ); + + it( + 'should get ERC-20 token standard and details', + async () => { + const { messenger, networkClientConfiguration } = + await setupAssetContractControllers(); + mockNetworkWithDefaultChainId({ + networkClientConfiguration, + mocks: [ + { + request: { + method: 'eth_call', + params: [ + { + to: ERC20_UNI_ADDRESS, + data: '0x01ffc9a780ac58cd00000000000000000000000000000000000000000000000000000000', + }, + 'latest', + ], + }, + error: { + code: -32000, + message: 'execution reverted', + }, + }, + { + request: { + method: 'eth_call', + params: [ + { + to: ERC20_UNI_ADDRESS, + data: '0x01ffc9a7d9b67a2600000000000000000000000000000000000000000000000000000000', + }, + 'latest', + ], + }, + error: { + code: -32000, + message: 'execution reverted', + }, + }, + { + request: { + method: 'eth_call', + params: [ + { + to: ERC20_UNI_ADDRESS, + data: '0x95d89b41', + }, + 'latest', + ], + }, + response: { + result: + '0x00000000000000000000000000000000000000000000000000000000000000200000000000000000000000000000000000000000000000000000000000000003554e490000000000000000000000000000000000000000000000000000000000', + }, + }, + { + request: { + method: 'eth_call', + params: [ + { + to: ERC20_UNI_ADDRESS, + data: '0x313ce567', + }, + 'latest', + ], + }, + response: { + result: + '0x0000000000000000000000000000000000000000000000000000000000000012', + }, + }, + { + request: { + method: 'eth_call', + params: [ + { + to: ERC20_UNI_ADDRESS, + data: '0x70a082310000000000000000000000005a3ca5cd63807ce5e4d7841ab32ce6b6d9bbba2d', + }, + 'latest', + ], + }, + response: { + result: + '0x0000000000000000000000000000000000000000000000001765caf344a06d0a', + }, + }, + ], + }); + const standardAndDetails = await messenger.call( + `AssetsContractController:getTokenStandardAndDetails`, + ERC20_UNI_ADDRESS, + TEST_ACCOUNT_PUBLIC_ADDRESS, + undefined, + 'mainnet', + ); + expect(standardAndDetails.standard).toBe('ERC20'); + messenger.clearEventSubscriptions('NetworkController:networkDidChange'); + }, + 10 * SECONDS, + ); - it('should get ERC-20 token standard and details', async () => { - const { assetsContract, messenger, networkClientConfiguration } = + it('should get ERC-721 NFT tokenURI correctly', async () => { + const { messenger, networkClientConfiguration } = await setupAssetContractControllers(); mockNetworkWithDefaultChainId({ networkClientConfiguration, @@ -284,63 +412,15 @@ describe('AssetsContractController with NetworkClientId', () => { method: 'eth_call', params: [ { - to: ERC20_UNI_ADDRESS, - data: '0x01ffc9a780ac58cd00000000000000000000000000000000000000000000000000000000', - }, - 'latest', - ], - }, - error: { - code: -32000, - message: 'execution reverted', - }, - }, - { - request: { - method: 'eth_call', - params: [ - { - to: ERC20_UNI_ADDRESS, - data: '0x01ffc9a7d9b67a2600000000000000000000000000000000000000000000000000000000', - }, - 'latest', - ], - }, - error: { - code: -32000, - message: 'execution reverted', - }, - }, - { - request: { - method: 'eth_call', - params: [ - { - to: ERC20_UNI_ADDRESS, - data: '0x95d89b41', - }, - 'latest', - ], - }, - response: { - result: - '0x00000000000000000000000000000000000000000000000000000000000000200000000000000000000000000000000000000000000000000000000000000003554e490000000000000000000000000000000000000000000000000000000000', - }, - }, - { - request: { - method: 'eth_call', - params: [ - { - to: ERC20_UNI_ADDRESS, - data: '0x313ce567', + to: ERC721_GODS_ADDRESS, + data: '0x01ffc9a75b5e139f00000000000000000000000000000000000000000000000000000000', }, 'latest', ], }, response: { result: - '0x0000000000000000000000000000000000000000000000000000000000000012', + '0x0000000000000000000000000000000000000000000000000000000000000001', }, }, { @@ -348,31 +428,31 @@ describe('AssetsContractController with NetworkClientId', () => { method: 'eth_call', params: [ { - to: ERC20_UNI_ADDRESS, - data: '0x70a082310000000000000000000000005a3ca5cd63807ce5e4d7841ab32ce6b6d9bbba2d', + to: ERC721_GODS_ADDRESS, + data: '0xc87b56dd0000000000000000000000000000000000000000000000000000000000000000', }, 'latest', ], }, response: { result: - '0x0000000000000000000000000000000000000000000000001765caf344a06d0a', + '0x0000000000000000000000000000000000000000000000000000000000000020000000000000000000000000000000000000000000000000000000000000002468747470733a2f2f6170692e676f6473756e636861696e65642e636f6d2f636172642f3000000000000000000000000000000000000000000000000000000000', }, }, ], }); - const standardAndDetails = await assetsContract.getTokenStandardAndDetails( - ERC20_UNI_ADDRESS, - TEST_ACCOUNT_PUBLIC_ADDRESS, - undefined, + const tokenId = await messenger.call( + `AssetsContractController:getERC721TokenURI`, + ERC721_GODS_ADDRESS, + '0', 'mainnet', ); - expect(standardAndDetails.standard).toBe('ERC20'); - messenger.clearEventSubscriptions('NetworkController:stateChange'); + expect(tokenId).toBe('https://api.godsunchained.com/card/0'); + messenger.clearEventSubscriptions('NetworkController:networkDidChange'); }); - it('should get ERC-721 NFT tokenURI correctly', async () => { - const { assetsContract, messenger, networkClientConfiguration } = + it('should not throw an error when address given is does not support NFT Metadata interface', async () => { + const { messenger, networkClientConfiguration } = await setupAssetContractControllers(); mockNetworkWithDefaultChainId({ networkClientConfiguration, @@ -382,15 +462,14 @@ describe('AssetsContractController with NetworkClientId', () => { method: 'eth_call', params: [ { - to: ERC721_GODS_ADDRESS, + to: '0x0000000000000000000000000000000000000000', data: '0x01ffc9a75b5e139f00000000000000000000000000000000000000000000000000000000', }, 'latest', ], }, response: { - result: - '0x0000000000000000000000000000000000000000000000000000000000000001', + result: '0x', }, }, { @@ -398,7 +477,7 @@ describe('AssetsContractController with NetworkClientId', () => { method: 'eth_call', params: [ { - to: ERC721_GODS_ADDRESS, + to: '0x0000000000000000000000000000000000000000', data: '0xc87b56dd0000000000000000000000000000000000000000000000000000000000000000', }, 'latest', @@ -411,53 +490,27 @@ describe('AssetsContractController with NetworkClientId', () => { }, ], }); - const tokenId = await assetsContract.getERC721TokenURI( - ERC721_GODS_ADDRESS, + const errorLogSpy = jest + .spyOn(console, 'warn') + .mockImplementationOnce(() => { + /**/ + }); + const uri = await messenger.call( + `AssetsContractController:getERC721TokenURI`, + '0x0000000000000000000000000000000000000000', '0', 'mainnet', ); - expect(tokenId).toBe('https://api.godsunchained.com/card/0'); - messenger.clearEventSubscriptions('NetworkController:stateChange'); - }); - - it('should throw an error when address given is not an ERC-721 NFT', async () => { - const { assetsContract, messenger, networkClientConfiguration } = - await setupAssetContractControllers(); - mockNetworkWithDefaultChainId({ - networkClientConfiguration, - mocks: [ - { - request: { - method: 'eth_call', - params: [ - { - to: '0x0000000000000000000000000000000000000000', - data: '0x01ffc9a75b5e139f00000000000000000000000000000000000000000000000000000000', - }, - 'latest', - ], - }, - response: { - result: '0x', - }, - }, - ], - }); - const result = async () => { - await assetsContract.getERC721TokenURI( - '0x0000000000000000000000000000000000000000', - '0', - 'mainnet', - ); - }; - - const error = 'Contract does not support ERC721 metadata interface.'; - await expect(result).rejects.toThrow(error); - messenger.clearEventSubscriptions('NetworkController:stateChange'); + expect(uri).toBe('https://api.godsunchained.com/card/0'); + expect(errorLogSpy).toHaveBeenCalledTimes(1); + expect(errorLogSpy.mock.calls).toContainEqual([ + 'Contract does not support ERC721 metadata interface.', + ]); + messenger.clearEventSubscriptions('NetworkController:networkDidChange'); }); it('should get ERC-721 NFT name', async () => { - const { assetsContract, messenger, networkClientConfiguration } = + const { messenger, networkClientConfiguration } = await setupAssetContractControllers(); mockNetworkWithDefaultChainId({ networkClientConfiguration, @@ -480,16 +533,17 @@ describe('AssetsContractController with NetworkClientId', () => { }, ], }); - const name = await assetsContract.getERC721AssetName( + const name = await messenger.call( + `AssetsContractController:getERC721AssetName`, ERC721_GODS_ADDRESS, 'mainnet', ); expect(name).toBe('Gods Unchained'); - messenger.clearEventSubscriptions('NetworkController:stateChange'); + messenger.clearEventSubscriptions('NetworkController:networkDidChange'); }); it('should get ERC-721 NFT symbol', async () => { - const { assetsContract, messenger, networkClientConfiguration } = + const { messenger, networkClientConfiguration } = await setupAssetContractControllers(); mockNetworkWithDefaultChainId({ networkClientConfiguration, @@ -512,27 +566,32 @@ describe('AssetsContractController with NetworkClientId', () => { }, ], }); - const symbol = await assetsContract.getERC721AssetSymbol( + const symbol = await messenger.call( + `AssetsContractController:getERC721AssetSymbol`, ERC721_GODS_ADDRESS, 'mainnet', ); expect(symbol).toBe('GODS'); - messenger.clearEventSubscriptions('NetworkController:stateChange'); + messenger.clearEventSubscriptions('NetworkController:networkDidChange'); }); it('should throw error when getting ERC-721 NFT symbol when networkClientId is invalid', async () => { - const { assetsContract, messenger } = await setupAssetContractControllers(); + const { messenger } = await setupAssetContractControllers(); await expect( - assetsContract.getERC721AssetSymbol( - ERC721_GODS_ADDRESS, - 'invalidNetworkClientId', - ), - ).rejects.toThrow('No custom network client was found'); - messenger.clearEventSubscriptions('NetworkController:stateChange'); + async () => + await messenger.call( + `AssetsContractController:getERC721AssetSymbol`, + ERC721_GODS_ADDRESS, + 'invalidNetworkClientId', + ), + ).rejects.toThrow( + 'No network client was found with ID "invalidNetworkClientId".', + ); + messenger.clearEventSubscriptions('NetworkController:networkDidChange'); }); it('should get ERC-20 token decimals', async () => { - const { assetsContract, messenger, networkClientConfiguration } = + const { messenger, networkClientConfiguration } = await setupAssetContractControllers(); mockNetworkWithDefaultChainId({ networkClientConfiguration, @@ -555,16 +614,17 @@ describe('AssetsContractController with NetworkClientId', () => { }, ], }); - const decimals = await assetsContract.getERC20TokenDecimals( + const decimals = await messenger.call( + `AssetsContractController:getERC20TokenDecimals`, ERC20_SAI_ADDRESS, 'mainnet', ); expect(Number(decimals)).toBe(18); - messenger.clearEventSubscriptions('NetworkController:stateChange'); + messenger.clearEventSubscriptions('NetworkController:networkDidChange'); }); it('should get ERC-20 token name', async () => { - const { assetsContract, messenger, networkClientConfiguration } = + const { messenger, networkClientConfiguration } = await setupAssetContractControllers(); mockNetworkWithDefaultChainId({ networkClientConfiguration, @@ -588,17 +648,18 @@ describe('AssetsContractController with NetworkClientId', () => { ], }); - const name = await assetsContract.getERC20TokenName( + const name = await messenger.call( + `AssetsContractController:getERC20TokenName`, ERC20_DAI_ADDRESS, 'mainnet', ); expect(name).toBe('Dai Stablecoin'); - messenger.clearEventSubscriptions('NetworkController:stateChange'); + messenger.clearEventSubscriptions('NetworkController:networkDidChange'); }); it('should get ERC-721 NFT ownership', async () => { - const { assetsContract, messenger, networkClientConfiguration } = + const { messenger, networkClientConfiguration } = await setupAssetContractControllers(); mockNetworkWithDefaultChainId({ networkClientConfiguration, @@ -621,29 +682,34 @@ describe('AssetsContractController with NetworkClientId', () => { }, ], }); - const tokenId = await assetsContract.getERC721OwnerOf( + const tokenId = await messenger.call( + `AssetsContractController:getERC721OwnerOf`, ERC721_GODS_ADDRESS, '148332', 'mainnet', ); expect(tokenId).not.toBe(''); - messenger.clearEventSubscriptions('NetworkController:stateChange'); + messenger.clearEventSubscriptions('NetworkController:networkDidChange'); }); it('should throw error when getting ERC-721 NFT ownership using networkClientId that is invalid', async () => { - const { assetsContract, messenger } = await setupAssetContractControllers(); + const { messenger } = await setupAssetContractControllers(); await expect( - assetsContract.getERC721OwnerOf( - ERC721_GODS_ADDRESS, - '148332', - 'invalidNetworkClientId', - ), - ).rejects.toThrow('No custom network client was found'); - messenger.clearEventSubscriptions('NetworkController:stateChange'); + async () => + await messenger.call( + `AssetsContractController:getERC721OwnerOf`, + ERC721_GODS_ADDRESS, + '148332', + 'invalidNetworkClientId', + ), + ).rejects.toThrow( + 'No network client was found with ID "invalidNetworkClientId".', + ); + messenger.clearEventSubscriptions('NetworkController:networkDidChange'); }); it('should get balance of ERC-20 token in a single call on network with token detection support', async () => { - const { assetsContract, messenger, networkClientConfiguration } = + const { messenger, networkClientConfiguration } = await setupAssetContractControllers(); mockNetworkWithDefaultChainId({ networkClientConfiguration, @@ -666,17 +732,18 @@ describe('AssetsContractController with NetworkClientId', () => { }, ], }); - const balances = await assetsContract.getBalancesInSingleCall( + const balances = await messenger.call( + `AssetsContractController:getBalancesInSingleCall`, ERC20_SAI_ADDRESS, [ERC20_SAI_ADDRESS], 'mainnet', ); expect(balances[ERC20_SAI_ADDRESS]).toBeDefined(); - messenger.clearEventSubscriptions('NetworkController:stateChange'); + messenger.clearEventSubscriptions('NetworkController:networkDidChange'); }); it('should not have balance in a single call after switching to network without token detection support', async () => { - const { assetsContract, messenger, networkClientConfiguration } = + const { messenger, networkClientConfiguration } = await setupAssetContractControllers(); mockNetworkWithDefaultChainId({ networkClientConfiguration, @@ -705,6 +772,7 @@ describe('AssetsContractController with NetworkClientId', () => { ticker: BUILT_IN_NETWORKS.sepolia.ticker, type: NetworkClientType.Infura, network: 'sepolia', + failoverRpcUrls: [], infuraProjectId: networkClientConfiguration.infuraProjectId, }, mocks: [ @@ -730,39 +798,45 @@ describe('AssetsContractController with NetworkClientId', () => { ], }); - const balances = await assetsContract.getBalancesInSingleCall( + const balances = await messenger.call( + `AssetsContractController:getBalancesInSingleCall`, ERC20_SAI_ADDRESS, [ERC20_SAI_ADDRESS], 'mainnet', ); expect(balances[ERC20_SAI_ADDRESS]).toBeDefined(); - const noBalances = await assetsContract.getBalancesInSingleCall( + const noBalances = await messenger.call( + `AssetsContractController:getBalancesInSingleCall`, ERC20_SAI_ADDRESS, [ERC20_SAI_ADDRESS], 'sepolia', ); expect(noBalances).toStrictEqual({}); - messenger.clearEventSubscriptions('NetworkController:stateChange'); + messenger.clearEventSubscriptions('NetworkController:networkDidChange'); }); it('should throw error when transferring single ERC-1155 when networkClientId is invalid', async () => { - const { assetsContract, messenger } = await setupAssetContractControllers(); + const { messenger } = await setupAssetContractControllers(); await expect( - assetsContract.transferSingleERC1155( - ERC1155_ADDRESS, - TEST_ACCOUNT_PUBLIC_ADDRESS, - TEST_ACCOUNT_PUBLIC_ADDRESS, - ERC1155_ID, - '1', - 'invalidNetworkClientId', - ), - ).rejects.toThrow('No custom network client was found'); - messenger.clearEventSubscriptions('NetworkController:stateChange'); + async () => + await messenger.call( + `AssetsContractController:transferSingleERC1155`, + ERC1155_ADDRESS, + TEST_ACCOUNT_PUBLIC_ADDRESS, + TEST_ACCOUNT_PUBLIC_ADDRESS, + ERC1155_ID, + '1', + 'invalidNetworkClientId', + ), + ).rejects.toThrow( + 'No network client was found with ID "invalidNetworkClientId".', + ); + messenger.clearEventSubscriptions('NetworkController:networkDidChange'); }); it('should get the balance of a ERC-1155 NFT for a given address', async () => { - const { assetsContract, messenger, networkClientConfiguration } = + const { messenger, networkClientConfiguration } = await setupAssetContractControllers(); mockNetworkWithDefaultChainId({ networkClientConfiguration, @@ -785,31 +859,36 @@ describe('AssetsContractController with NetworkClientId', () => { }, ], }); - const balance = await assetsContract.getERC1155BalanceOf( + const balance = await messenger.call( + `AssetsContractController:getERC1155BalanceOf`, TEST_ACCOUNT_PUBLIC_ADDRESS, ERC1155_ADDRESS, ERC1155_ID, 'mainnet', ); expect(Number(balance)).toBeGreaterThan(0); - messenger.clearEventSubscriptions('NetworkController:stateChange'); + messenger.clearEventSubscriptions('NetworkController:networkDidChange'); }); it('should throw error when getting the balance of a ERC-1155 NFT when networkClientId is invalid', async () => { - const { assetsContract, messenger } = await setupAssetContractControllers(); + const { messenger } = await setupAssetContractControllers(); await expect( - assetsContract.getERC1155BalanceOf( - TEST_ACCOUNT_PUBLIC_ADDRESS, - ERC1155_ADDRESS, - ERC1155_ID, - 'invalidNetworkClientId', - ), - ).rejects.toThrow('No custom network client was found'); - messenger.clearEventSubscriptions('NetworkController:stateChange'); + async () => + await messenger.call( + `AssetsContractController:getERC1155BalanceOf`, + TEST_ACCOUNT_PUBLIC_ADDRESS, + ERC1155_ADDRESS, + ERC1155_ID, + 'invalidNetworkClientId', + ), + ).rejects.toThrow( + 'No network client was found with ID "invalidNetworkClientId".', + ); + messenger.clearEventSubscriptions('NetworkController:networkDidChange'); }); it('should get the URI of a ERC-1155 NFT', async () => { - const { assetsContract, messenger, networkClientConfiguration } = + const { messenger, networkClientConfiguration } = await setupAssetContractControllers(); mockNetworkWithDefaultChainId({ networkClientConfiguration, @@ -833,12 +912,87 @@ describe('AssetsContractController with NetworkClientId', () => { ], }); const expectedUri = `https://api.opensea.io/api/v1/metadata/${ERC1155_ADDRESS}/0x{id}`; - const uri = await assetsContract.getERC1155TokenURI( + const uri = await messenger.call( + `AssetsContractController:getERC1155TokenURI`, ERC1155_ADDRESS, ERC1155_ID, 'mainnet', ); expect(uri.toLowerCase()).toStrictEqual(expectedUri); - messenger.clearEventSubscriptions('NetworkController:stateChange'); + messenger.clearEventSubscriptions('NetworkController:networkDidChange'); + }); + + it('should get the staked ethereum balance for an address', async () => { + const { assetsContract, messenger, provider, networkClientConfiguration } = + await setupAssetContractControllers(); + assetsContract.setProvider(provider); + + mockNetworkWithDefaultChainId({ + networkClientConfiguration, + mocks: [ + // getShares + { + request: { + method: 'eth_call', + params: [ + { + to: '0xca11bde05977b3631167028862be2a173976ca11', + data: '0xbce38bd700000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000040000000000000000000000000000000000000000000000000000000000000000100000000000000000000000000000000000000000000000000000000000000200000000000000000000000004fef9d741011476750a243ac70b9789a63dd47df00000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000024f04da65b0000000000000000000000005a3ca5cd63807ce5e4d7841ab32ce6b6d9bbba2d00000000000000000000000000000000000000000000000000000000', + }, + 'latest', + ], + }, + response: { + result: + '0x0000000000000000000000000000000000000000000000000000000000000020000000000000000000000000000000000000000000000000000000000000000100000000000000000000000000000000000000000000000000000000000000200000000000000000000000000000000000000000000000000000000000000001000000000000000000000000000000000000000000000000000000000000004000000000000000000000000000000000000000000000000000000000000000200000000000000000000000000000000000000000000000000007de0ff9d7304a', // de0b6b3a7640000 + }, + }, + // convertToAssets + { + request: { + method: 'eth_call', + params: [ + { + to: '0xca11bde05977b3631167028862be2a173976ca11', + data: '0xbce38bd700000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000040000000000000000000000000000000000000000000000000000000000000000100000000000000000000000000000000000000000000000000000000000000200000000000000000000000004fef9d741011476750a243ac70b9789a63dd47df0000000000000000000000000000000000000000000000000000000000000040000000000000000000000000000000000000000000000000000000000000002407a2d13a0000000000000000000000000000000000000000000000000007de0ff9d7304a00000000000000000000000000000000000000000000000000000000', + }, + 'latest', + ], + }, + response: { + result: + '0x00000000000000000000000000000000000000000000000000000000000000200000000000000000000000000000000000000000000000000000000000000001000000000000000000000000000000000000000000000000000000000000002000000000000000000000000000000000000000000000000000000000000000010000000000000000000000000000000000000000000000000000000000000040000000000000000000000000000000000000000000000000000000000000002000000000000000000000000000000000000000000000000000081f495b33d2df', + }, + }, + ], + }); + + const balance = await assetsContract.getStakedBalanceForChain( + [TEST_ACCOUNT_PUBLIC_ADDRESS], + 'mainnet', + ); + + // Shares: 2214485034479690 + // Assets: 2286199736881887 (0.002286199736881887 ETH) + + expect(balance).toBeDefined(); + expect(balance[TEST_ACCOUNT_PUBLIC_ADDRESS]).toBe('0x081f495b33d2df'); + expect( + BigNumber.from(balance[TEST_ACCOUNT_PUBLIC_ADDRESS]).toString(), + ).toBe('2286199736881887'); + + messenger.clearEventSubscriptions('NetworkController:networkDidChange'); + }); + + it('should default staked ethereum balance to empty if network is not supported', async () => { + const { assetsContract, provider } = await setupAssetContractControllers(); + assetsContract.setProvider(provider); + + const balance = await assetsContract.getStakedBalanceForChain( + [TEST_ACCOUNT_PUBLIC_ADDRESS], + 'sepolia', + ); + + expect(balance).toStrictEqual({}); }); }); diff --git a/packages/assets-controllers/src/CurrencyRateController-method-action-types.ts b/packages/assets-controllers/src/CurrencyRateController-method-action-types.ts new file mode 100644 index 00000000000..673ce8964c5 --- /dev/null +++ b/packages/assets-controllers/src/CurrencyRateController-method-action-types.ts @@ -0,0 +1,33 @@ +/** + * This file is auto generated. + * Do not edit manually. + */ + +import type { CurrencyRateController } from './CurrencyRateController.js'; + +/** + * Sets a currency to track. + * + * @param currentCurrency - ISO 4217 currency code. + */ +export type CurrencyRateControllerSetCurrentCurrencyAction = { + type: `CurrencyRateController:setCurrentCurrency`; + handler: CurrencyRateController['setCurrentCurrency']; +}; + +/** + * Updates the exchange rate for the current currency and native currency pairs. + * + * @param nativeCurrencies - The native currency symbols to fetch exchange rates for. + */ +export type CurrencyRateControllerUpdateExchangeRateAction = { + type: `CurrencyRateController:updateExchangeRate`; + handler: CurrencyRateController['updateExchangeRate']; +}; + +/** + * Union of all CurrencyRateController action types. + */ +export type CurrencyRateControllerMethodActions = + | CurrencyRateControllerSetCurrentCurrencyAction + | CurrencyRateControllerUpdateExchangeRateAction; diff --git a/packages/assets-controllers/src/CurrencyRateController.test.ts b/packages/assets-controllers/src/CurrencyRateController.test.ts index 205d92eac41..cc4abc7f893 100644 --- a/packages/assets-controllers/src/CurrencyRateController.test.ts +++ b/packages/assets-controllers/src/CurrencyRateController.test.ts @@ -1,51 +1,180 @@ -import { ControllerMessenger } from '@metamask/base-controller'; -import { TESTNET_TICKER_SYMBOLS } from '@metamask/controller-utils'; -import nock from 'nock'; - +import { deriveStateFromMetadata } from '@metamask/base-controller'; +import { + ChainId, + NetworkType, + NetworksTicker, +} from '@metamask/controller-utils'; +import { MOCK_ANY_NAMESPACE, Messenger } from '@metamask/messenger'; import type { - CurrencyRateStateChange, - GetCurrencyRateState, -} from './CurrencyRateController'; -import { CurrencyRateController } from './CurrencyRateController'; + MessengerActions, + MessengerEvents, + MockAnyNamespace, +} from '@metamask/messenger'; +import type { NetworkConfiguration } from '@metamask/network-controller'; +import type { Hex } from '@metamask/utils'; + +import { jestAdvanceTime } from '../../../tests/helpers.js'; +import type { CurrencyRateMessenger } from './CurrencyRateController.js'; +import { CurrencyRateController } from './CurrencyRateController.js'; +import type { AbstractTokenPricesService } from './token-prices-service/index.js'; + +const namespace = 'CurrencyRateController'; + +type AllCurrencyRateControllerActions = MessengerActions; + +type AllCurrencyRateControllerEvents = MessengerEvents; + +type RootMessenger = Messenger< + MockAnyNamespace, + AllCurrencyRateControllerActions, + AllCurrencyRateControllerEvents +>; -const name = 'CurrencyRateController'; +/** + * Builds a mock token prices service. + * + * @param overrides - The properties of the token prices service you want to + * provide explicitly. + * @returns The built mock token prices service. + */ +function buildMockTokenPricesService( + overrides: Partial = {}, +): AbstractTokenPricesService { + return { + async fetchTokenPrices(): Promise { + return []; + }, + async fetchExchangeRates(): Promise> { + return {}; + }, + validateChainIdSupported(_chainId: unknown): _chainId is Hex { + return true; + }, + validateCurrencySupported(_currency: unknown): _currency is string { + return true; + }, + ...overrides, + }; +} /** - * Constructs a restricted controller messenger. + * Constructs a messenger for CurrencyRateController. * - * @returns A restricted controller messenger. + * @returns A controller messenger. */ -function getRestrictedMessenger() { - const controllerMessenger = new ControllerMessenger< - GetCurrencyRateState, - CurrencyRateStateChange - >(); - const messenger = controllerMessenger.getRestricted< - 'CurrencyRateController', - never, - never +function getCurrencyRateControllerMessenger(): CurrencyRateMessenger { + const messenger: RootMessenger = new Messenger({ + namespace: MOCK_ANY_NAMESPACE, + }); + messenger.registerActionHandler( + 'NetworkController:getNetworkClientById', + jest.fn().mockImplementation((networkClientId) => { + switch (networkClientId) { + case 'mainnet': + return { + configuration: { + type: NetworkType.mainnet, + chainId: ChainId.mainnet, + ticker: NetworksTicker.mainnet, + }, + }; + case 'sepolia': + return { + configuration: { + type: NetworkType.sepolia, + chainId: ChainId.sepolia, + ticker: NetworksTicker.sepolia, + }, + }; + default: + throw new Error('Invalid networkClientId'); + } + }), + ); + const currencyRateControllerMessenger = new Messenger< + typeof namespace, + AllCurrencyRateControllerActions, + AllCurrencyRateControllerEvents, + RootMessenger >({ - name, + namespace, }); - return messenger; + messenger.delegate({ + messenger: currencyRateControllerMessenger, + actions: ['NetworkController:getNetworkClientById'], + }); + return currencyRateControllerMessenger; } -const getStubbedDate = () => { - return new Date('2019-04-07T10:20:30Z').getTime(); -}; - /** - * Resolve all pending promises. - * This method is used for async tests that use fake timers. - * See https://stackoverflow.com/a/58716087 and https://jestjs.io/docs/timer-mocks. + * Constructs a messenger for CurrencyRateController with NetworkController:getState action. + * + * @param options - Options object + * @param options.networkConfigurationsByChainId - Network configurations by chain ID + * @returns A controller messenger. */ -function flushPromises(): Promise { - return new Promise(jest.requireActual('timers').setImmediate); +function getCurrencyRateControllerMessengerWithNetworkState({ + networkConfigurationsByChainId, +}: { + networkConfigurationsByChainId: Record; +}): CurrencyRateMessenger { + const messenger: RootMessenger = new Messenger({ + namespace: MOCK_ANY_NAMESPACE, + }); + messenger.registerActionHandler( + 'NetworkController:getNetworkClientById', + jest.fn().mockImplementation((networkClientId) => { + switch (networkClientId) { + case 'mainnet': + return { + configuration: { + type: NetworkType.mainnet, + chainId: ChainId.mainnet, + ticker: NetworksTicker.mainnet, + }, + }; + case 'sepolia': + return { + configuration: { + type: NetworkType.sepolia, + chainId: ChainId.sepolia, + ticker: NetworksTicker.sepolia, + }, + }; + default: + throw new Error('Invalid networkClientId'); + } + }), + ); + messenger.registerActionHandler( + 'NetworkController:getState', + jest.fn().mockReturnValue({ networkConfigurationsByChainId }), + ); + const currencyRateControllerMessenger = new Messenger< + typeof namespace, + AllCurrencyRateControllerActions, + AllCurrencyRateControllerEvents, + RootMessenger + >({ + namespace, + }); + messenger.delegate({ + messenger: currencyRateControllerMessenger, + actions: [ + 'NetworkController:getNetworkClientById', + 'NetworkController:getState', + ], + }); + return currencyRateControllerMessenger; } +const getStubbedDate = (): number => { + return new Date('2019-04-07T10:20:30Z').getTime(); +}; + describe('CurrencyRateController', () => { beforeEach(() => { - jest.useFakeTimers('legacy'); + jest.useFakeTimers(); }); afterEach(() => { @@ -53,414 +182,2188 @@ describe('CurrencyRateController', () => { }); it('should set default state', () => { - const messenger = getRestrictedMessenger(); - const controller = new CurrencyRateController({ messenger }); + const messenger = getCurrencyRateControllerMessenger(); + const tokenPricesService = buildMockTokenPricesService(); + const controller = new CurrencyRateController({ + messenger, + tokenPricesService, + }); expect(controller.state).toStrictEqual({ - conversionDate: 0, - conversionRate: 0, currentCurrency: 'usd', - nativeCurrency: 'ETH', - pendingCurrentCurrency: null, - pendingNativeCurrency: null, - usdConversionRate: null, + currencyRates: { + ETH: { + conversionDate: 0, + conversionRate: 0, + usdConversionRate: null, + }, + }, }); controller.destroy(); }); it('should initialize with initial state', () => { - const messenger = getRestrictedMessenger(); + const tokenPricesService = buildMockTokenPricesService(); + const messenger = getCurrencyRateControllerMessenger(); const existingState = { currentCurrency: 'rep' }; const controller = new CurrencyRateController({ messenger, state: existingState, + tokenPricesService, }); expect(controller.state).toStrictEqual({ - conversionDate: 0, - conversionRate: 0, currentCurrency: 'rep', - nativeCurrency: 'ETH', - pendingCurrentCurrency: null, - pendingNativeCurrency: null, - usdConversionRate: null, + currencyRates: { + ETH: { + conversionDate: 0, + conversionRate: 0, + usdConversionRate: null, + }, + }, }); controller.destroy(); }); it('should not poll before being started', async () => { - const fetchExchangeRateStub = jest.fn(); - const messenger = getRestrictedMessenger(); + const tokenPricesService = buildMockTokenPricesService(); + const fetchExchangeRatesSpy = jest.spyOn( + tokenPricesService, + 'fetchExchangeRates', + ); + const messenger = getCurrencyRateControllerMessenger(); const controller = new CurrencyRateController({ interval: 100, - fetchExchangeRate: fetchExchangeRateStub, messenger, + tokenPricesService, }); - jest.advanceTimersByTime(200); - await flushPromises(); + await jestAdvanceTime({ duration: 200 }); - expect(fetchExchangeRateStub).not.toHaveBeenCalled(); + expect(fetchExchangeRatesSpy).not.toHaveBeenCalled(); controller.destroy(); }); - it('should poll and update rate in the right interval', async () => { - const fetchExchangeRateStub = jest.fn(); - const messenger = getRestrictedMessenger(); + it('should poll and update state in the right interval', async () => { + const currentCurrency = 'cad'; + + jest + .spyOn(global.Date, 'now') + .mockReturnValueOnce(10000) + .mockReturnValueOnce(20000); + const tokenPricesService = buildMockTokenPricesService(); + + const fetchExchangeRatesSpy = jest + .spyOn(tokenPricesService, 'fetchExchangeRates') + .mockResolvedValue({ + eth: { + name: 'Ether', + ticker: 'eth', + value: 0.000240977533824818, + currencyType: 'crypto', + }, + }); + const messenger = getCurrencyRateControllerMessenger(); const controller = new CurrencyRateController({ interval: 100, - fetchExchangeRate: fetchExchangeRateStub, messenger, + state: { currentCurrency }, + tokenPricesService, }); - await controller.start(); + controller.startPolling({ nativeCurrencies: ['ETH'] }); + await jestAdvanceTime({ duration: 0 }); + expect(fetchExchangeRatesSpy).toHaveBeenCalledTimes(1); - jest.advanceTimersByTime(99); - await flushPromises(); + expect(controller.state.currencyRates).toStrictEqual({ + ETH: { + conversionDate: 10, + conversionRate: 4149.764437074, + usdConversionRate: null, + }, + }); + await jestAdvanceTime({ duration: 99 }); - expect(fetchExchangeRateStub).toHaveBeenCalledTimes(1); + expect(fetchExchangeRatesSpy).toHaveBeenCalledTimes(1); - jest.advanceTimersByTime(1); - await flushPromises(); + await jestAdvanceTime({ duration: 1 }); - expect(fetchExchangeRateStub).toHaveBeenCalledTimes(2); + expect(fetchExchangeRatesSpy).toHaveBeenCalledTimes(2); + expect(controller.state.currencyRates).toStrictEqual({ + ETH: { + conversionDate: 20, + conversionRate: 4149.764437074, + usdConversionRate: null, + }, + }); controller.destroy(); }); it('should not poll after being stopped', async () => { - const fetchExchangeRateStub = jest.fn(); - const messenger = getRestrictedMessenger(); + const tokenPricesService = buildMockTokenPricesService(); + + const fetchExchangeRatesSpy = jest + .spyOn(tokenPricesService, 'fetchExchangeRates') + .mockResolvedValue({ + eth: { + name: 'Ether', + ticker: 'eth', + value: 0.000240977533824818, + currencyType: 'crypto', + }, + }); + const messenger = getCurrencyRateControllerMessenger(); const controller = new CurrencyRateController({ interval: 100, - fetchExchangeRate: fetchExchangeRateStub, messenger, + tokenPricesService, }); - await controller.start(); - controller.stop(); + controller.startPolling({ nativeCurrencies: ['ETH'] }); + + await jestAdvanceTime({ duration: 0 }); + + controller.stopAllPolling(); // called once upon initial start - expect(fetchExchangeRateStub).toHaveBeenCalledTimes(1); + expect(fetchExchangeRatesSpy).toHaveBeenCalledTimes(1); - jest.advanceTimersByTime(150); - await flushPromises(); + await jestAdvanceTime({ duration: 150, stepSize: 50 }); - expect(fetchExchangeRateStub).toHaveBeenCalledTimes(1); + expect(fetchExchangeRatesSpy).toHaveBeenCalledTimes(1); controller.destroy(); }); it('should poll correctly after being started, stopped, and started again', async () => { - const fetchExchangeRateStub = jest.fn(); - - const messenger = getRestrictedMessenger(); + const messenger = getCurrencyRateControllerMessenger(); + const tokenPricesService = buildMockTokenPricesService(); + + const fetchExchangeRatesSpy = jest + .spyOn(tokenPricesService, 'fetchExchangeRates') + .mockResolvedValue({ + eth: { + name: 'Ether', + ticker: 'eth', + value: 0.000240977533824818, + currencyType: 'crypto', + }, + }); const controller = new CurrencyRateController({ interval: 100, - fetchExchangeRate: fetchExchangeRateStub, messenger, + tokenPricesService, }); - await controller.start(); - controller.stop(); + controller.startPolling({ nativeCurrencies: ['ETH'] }); + await jestAdvanceTime({ duration: 0 }); - // called once upon initial start - expect(fetchExchangeRateStub).toHaveBeenCalledTimes(1); + controller.stopAllPolling(); - await controller.start(); + // called once upon initial start + expect(fetchExchangeRatesSpy).toHaveBeenCalledTimes(1); - jest.advanceTimersByTime(1); - await flushPromises(); + controller.startPolling({ nativeCurrencies: ['ETH'] }); + await jestAdvanceTime({ duration: 0 }); - expect(fetchExchangeRateStub).toHaveBeenCalledTimes(2); + expect(fetchExchangeRatesSpy).toHaveBeenCalledTimes(2); - jest.advanceTimersByTime(99); - await flushPromises(); + await jestAdvanceTime({ duration: 100 }); - expect(fetchExchangeRateStub).toHaveBeenCalledTimes(3); + expect(fetchExchangeRatesSpy).toHaveBeenCalledTimes(3); }); - it('should update exchange rate', async () => { - const fetchExchangeRateStub = jest - .fn() - .mockResolvedValue({ conversionRate: 10 }); - const messenger = getRestrictedMessenger(); + it('should update exchange rate from price api', async () => { + const currentCurrency = 'cad'; + + jest.spyOn(global.Date, 'now').mockImplementation(() => getStubbedDate()); + const messenger = getCurrencyRateControllerMessenger(); + const tokenPricesService = buildMockTokenPricesService(); + const fetchExchangeRatesSpy = jest + .spyOn(tokenPricesService, 'fetchExchangeRates') + .mockResolvedValue({ + eth: { + name: 'Ether', + ticker: 'eth', + value: 0.000240977533824818, + currencyType: 'crypto', + usd: 111, + }, + }); const controller = new CurrencyRateController({ interval: 10, - fetchExchangeRate: fetchExchangeRateStub, messenger, + state: { currentCurrency }, + tokenPricesService, }); - expect(controller.state.conversionRate).toBe(0); + expect(controller.state.currencyRates).toStrictEqual({ + ETH: { + conversionDate: 0, + conversionRate: 0, + usdConversionRate: null, + }, + }); - await controller.start(); + await controller.updateExchangeRate(['ETH']); - expect(controller.state.conversionRate).toBe(10); + expect(fetchExchangeRatesSpy).toHaveBeenCalledTimes(1); + expect(controller.state.currencyRates).toStrictEqual({ + ETH: { + conversionDate: getStubbedDate() / 1000, + conversionRate: 4149.764437074, + usdConversionRate: 0.009009009, + }, + }); controller.destroy(); }); - it('should update exchange rate to ETH conversion rate when native currency is testnet ETH', async () => { - const fetchExchangeRateStub = jest - .fn() - .mockImplementation((_, nativeCurrency) => { - if (nativeCurrency === 'ETH') { - return { - conversionRate: 10, - }; - } else if (nativeCurrency === 'DAI') { - return { - conversionRate: 1, - }; - } - return { - conversionRate: 0, - }; + it('should use the exchange rate for ETH when native currency is testnet ETH', async () => { + const currentCurrency = 'cad'; + + jest.spyOn(global.Date, 'now').mockImplementation(() => getStubbedDate()); + + const messenger = getCurrencyRateControllerMessenger(); + const tokenPricesService = buildMockTokenPricesService(); + + const fetchExchangeRatesSpy = jest + .spyOn(tokenPricesService, 'fetchExchangeRates') + .mockResolvedValue({ + eth: { + name: 'Ether', + ticker: 'eth', + value: 0.000240977533824818, + currencyType: 'crypto', + usd: 0.001, + }, }); - const messenger = getRestrictedMessenger(); const controller = new CurrencyRateController({ - fetchExchangeRate: fetchExchangeRateStub, messenger, + state: { currentCurrency }, + tokenPricesService, }); - expect(controller.state.conversionRate).toBe(0); - - await controller.start(); - await controller.setNativeCurrency('DAI'); - - expect(controller.state.conversionRate).toBe(1); - - await controller.setNativeCurrency(TESTNET_TICKER_SYMBOLS.GOERLI); + expect(controller.state.currencyRates).toStrictEqual({ + ETH: { + conversionDate: 0, + conversionRate: 0, + usdConversionRate: null, + }, + }); - expect(controller.state.conversionRate).toBe(10); + await controller.updateExchangeRate(['SepoliaETH']); + + expect(fetchExchangeRatesSpy).toHaveBeenCalledTimes(1); + expect(controller.state.currencyRates).toStrictEqual({ + ETH: { + conversionDate: 0, + conversionRate: 0, + usdConversionRate: null, + }, + SepoliaETH: { + conversionDate: getStubbedDate() / 1000, + conversionRate: 4149.764437074, + usdConversionRate: 1000, + }, + }); controller.destroy(); }); - it('should update current currency', async () => { - const fetchExchangeRateStub = jest - .fn() - .mockResolvedValue({ conversionRate: 10 }); - const messenger = getRestrictedMessenger(); + it('should update current currency then clear and refetch rates', async () => { + const currentCurrency = 'cad'; + jest.spyOn(global.Date, 'now').mockImplementation(() => getStubbedDate()); + const messenger = getCurrencyRateControllerMessenger(); + const tokenPricesService = buildMockTokenPricesService(); + jest.spyOn(tokenPricesService, 'fetchExchangeRates').mockResolvedValue({ + eth: { + name: 'Ether', + ticker: 'eth', + value: 0.000240977533824818, + currencyType: 'crypto', + usd: 0.0055, + }, + btc: { + name: 'Bitcoin', + ticker: 'btc', + value: 0.00010377048177666853, + currencyType: 'crypto', + usd: 0.0022, + }, + }); const controller = new CurrencyRateController({ interval: 10, - fetchExchangeRate: fetchExchangeRateStub, messenger, + state: { + currencyRates: { + ETH: { + conversionDate: 123, + conversionRate: 123, + usdConversionRate: 123, + }, + BTC: { + conversionDate: 100, + conversionRate: 200, + usdConversionRate: 300, + }, + }, + }, + tokenPricesService, }); - expect(controller.state.currentCurrency).toBe('usd'); - - await controller.start(); + await controller.setCurrentCurrency(currentCurrency); - expect(controller.state.currentCurrency).toBe('usd'); + expect(controller.state).toStrictEqual({ + currentCurrency, + currencyRates: { + ETH: { + conversionDate: 0, + conversionRate: 0, + usdConversionRate: null, + }, + }, + }); - await controller.setCurrentCurrency('CAD'); + await jestAdvanceTime({ duration: 0 }); - expect(controller.state.currentCurrency).toBe('CAD'); + expect(controller.state).toStrictEqual({ + currentCurrency, + currencyRates: { + ETH: { + conversionDate: getStubbedDate() / 1000, + conversionRate: 4149.764437074, + usdConversionRate: 181.818181818, + }, + BTC: { + conversionDate: getStubbedDate() / 1000, + conversionRate: 9636.6518, + usdConversionRate: 454.545454545, + }, + }, + }); controller.destroy(); }); - - it('should update native currency', async () => { - const fetchExchangeRateStub = jest - .fn() - .mockResolvedValue({ conversionRate: 10 }); - const messenger = getRestrictedMessenger(); + it('should add usd rate to state when includeUsdRate is configured true', async () => { + const messenger = getCurrencyRateControllerMessenger(); + const tokenPricesService = buildMockTokenPricesService(); + const fetchExchangeRatesSpy = jest + .spyOn(tokenPricesService, 'fetchExchangeRates') + .mockResolvedValue({ + eth: { + name: 'Ether', + ticker: 'eth', + value: 0.000240977533824818, + currencyType: 'crypto', + usd: 0.0055, + }, + }); const controller = new CurrencyRateController({ - interval: 10, - fetchExchangeRate: fetchExchangeRateStub, + includeUsdRate: true, messenger, + state: { currentCurrency: 'xyz' }, + tokenPricesService, }); - - expect(controller.state.nativeCurrency).toBe('ETH'); - - await controller.start(); - - expect(controller.state.nativeCurrency).toBe('ETH'); - - await controller.setNativeCurrency('xDAI'); - - expect(controller.state.nativeCurrency).toBe('xDAI'); + await controller.updateExchangeRate(['SepoliaETH']); + expect(fetchExchangeRatesSpy).toHaveBeenCalledTimes(1); + expect(fetchExchangeRatesSpy.mock.calls).toMatchObject([ + [ + { + baseCurrency: 'xyz', + includeUsdRate: true, + cryptocurrencies: ['ETH'], + }, + ], + ]); controller.destroy(); }); - it('should add usd rate to state when includeUsdRate is configured true', async () => { - const fetchExchangeRateStub = jest.fn().mockResolvedValue({}); - const messenger = getRestrictedMessenger(); + it('should default to fetching exchange rate from price api', async () => { + jest.spyOn(global.Date, 'now').mockImplementation(() => getStubbedDate()); + + const messenger = getCurrencyRateControllerMessenger(); + const tokenPricesService = buildMockTokenPricesService(); + + const fetchExchangeRatesSpy = jest + .spyOn(tokenPricesService, 'fetchExchangeRates') + .mockResolvedValue({ + eth: { + name: 'Ether', + ticker: 'eth', + value: 1 / 2000.42, + currencyType: 'crypto', + }, + }); const controller = new CurrencyRateController({ - includeUsdRate: true, - fetchExchangeRate: fetchExchangeRateStub, messenger, state: { currentCurrency: 'xyz' }, + tokenPricesService, }); - await controller.start(); - expect(fetchExchangeRateStub).toHaveBeenCalledTimes(1); - expect(fetchExchangeRateStub.mock.calls).toMatchObject([ - ['xyz', 'ETH', true], - ]); + await controller.updateExchangeRate(['ETH']); + + expect(fetchExchangeRatesSpy).toHaveBeenCalledTimes(1); + + expect(controller.state).toStrictEqual({ + currentCurrency: 'xyz', + currencyRates: { + ETH: { + conversionDate: getStubbedDate() / 1000, + conversionRate: 2000.42, + usdConversionRate: null, + }, + }, + }); controller.destroy(); }); - it('should default to fetching exchange rate from crypto-compare', async () => { - const cryptoCompareHost = 'https://min-api.cryptocompare.com'; - nock(cryptoCompareHost) - .get('/data/price?fsym=ETH&tsyms=XYZ') - .reply(200, { XYZ: 2000.42 }) - .persist(); - const messenger = getRestrictedMessenger(); + it('should return null state when price api fails', async () => { + const messenger = getCurrencyRateControllerMessenger(); + const tokenPricesService = buildMockTokenPricesService(); + const fetchExchangeRatesSpy = jest + .spyOn(tokenPricesService, 'fetchExchangeRates') + .mockRejectedValue(new Error('Failed to fetch')); const controller = new CurrencyRateController({ messenger, state: { currentCurrency: 'xyz' }, + tokenPricesService, }); - await controller.start(); - expect(controller.state.conversionRate).toBe(2000.42); + await controller.updateExchangeRate(['ETH']); + + expect(fetchExchangeRatesSpy).toHaveBeenCalledTimes(1); + expect(controller.state).toStrictEqual({ + currentCurrency: 'xyz', + currencyRates: { + ETH: { + conversionDate: null, + conversionRate: null, + usdConversionRate: null, + }, + }, + }); controller.destroy(); }); - it('should fetch exchange rates after starting and again after calling setNativeCurrency', async () => { - const fetchExchangeRateStub = jest.fn().mockResolvedValue({}); - const messenger = getRestrictedMessenger(); + it('should update state with null values when price api fails', async () => { + const state = { + currentCurrency: 'xyz', + currencyRates: { + ETH: { + conversionDate: 123, + conversionRate: 123, + usdConversionRate: 123, + }, + }, + }; + const messenger = getCurrencyRateControllerMessenger(); + const tokenPricesService = buildMockTokenPricesService(); + jest + .spyOn(tokenPricesService, 'fetchExchangeRates') + .mockRejectedValue(new Error('Failed to fetch')); const controller = new CurrencyRateController({ - includeUsdRate: true, - fetchExchangeRate: fetchExchangeRateStub, messenger, + state, + tokenPricesService, }); - await controller.start(); - - expect(fetchExchangeRateStub).toHaveBeenCalledTimes(1); + await controller.updateExchangeRate(['ETH']); - await controller.setNativeCurrency('XYZ'); - - expect(fetchExchangeRateStub).toHaveBeenCalledTimes(2); - expect(fetchExchangeRateStub.mock.calls).toMatchObject([ - ['usd', 'ETH', true], - ['usd', 'XYZ', true], - ]); + // State should be updated with null values + expect(controller.state).toStrictEqual({ + currentCurrency: 'xyz', + currencyRates: { + ETH: { + conversionDate: null, + conversionRate: null, + usdConversionRate: null, + }, + }, + }); controller.destroy(); }); - it('should NOT fetch exchange rates after calling setNativeCurrency if start has not been called', async () => { - const fetchExchangeRateStub = jest.fn().mockResolvedValue({}); + it('fetches exchange rates for multiple native currencies from price api', async () => { + jest.spyOn(global.Date, 'now').mockImplementation(() => getStubbedDate()); - const messenger = getRestrictedMessenger(); + const messenger = getCurrencyRateControllerMessenger(); + const tokenPricesService = buildMockTokenPricesService(); + + const fetchExchangeRatesSpy = jest + .spyOn(tokenPricesService, 'fetchExchangeRates') + .mockResolvedValue({ + eth: { + name: 'Ether', + ticker: 'eth', + value: 1 / 4000.42, + currencyType: 'crypto', + }, + pol: { + name: 'Polkadot', + ticker: 'pol', + value: 1 / 0.3, + currencyType: 'crypto', + }, + bnb: { + name: 'BNB', + ticker: 'bnb', + value: 1 / 500.1, + currencyType: 'crypto', + }, + }); const controller = new CurrencyRateController({ - includeUsdRate: true, - fetchExchangeRate: fetchExchangeRateStub, messenger, + state: { currentCurrency: 'xyz' }, + tokenPricesService, }); - await controller.setNativeCurrency('XYZ'); + await controller.updateExchangeRate(['ETH', 'POL', 'BNB']); + expect(fetchExchangeRatesSpy).toHaveBeenCalledTimes(1); - expect(fetchExchangeRateStub).toHaveBeenCalledTimes(0); + const conversionDate = getStubbedDate() / 1000; + expect(controller.state).toStrictEqual({ + currentCurrency: 'xyz', + currencyRates: { + BNB: { + conversionDate, + conversionRate: 500.1, + usdConversionRate: null, + }, + ETH: { + conversionDate, + conversionRate: 4000.42, + usdConversionRate: null, + }, + POL: { + conversionDate, + conversionRate: 0.3, + usdConversionRate: null, + }, + }, + }); controller.destroy(); }); - it('should NOT fetch exchange rates after calling setNativeCurrency if stop has been called', async () => { - const fetchExchangeRateStub = jest.fn().mockResolvedValue({}); + it('skips updating empty or undefined native currencies', async () => { + jest.spyOn(global.Date, 'now').mockImplementation(() => getStubbedDate()); + + const messenger = getCurrencyRateControllerMessenger(); - const messenger = getRestrictedMessenger(); + const tokenPricesService = buildMockTokenPricesService(); + + jest.spyOn(tokenPricesService, 'fetchExchangeRates').mockResolvedValue({ + eth: { + name: 'Ethereum', + ticker: 'eth', + value: 0, + currencyType: 'crypto', + }, + }); const controller = new CurrencyRateController({ - includeUsdRate: true, - fetchExchangeRate: fetchExchangeRateStub, messenger, + state: { currentCurrency: 'xyz' }, + tokenPricesService, }); - expect(fetchExchangeRateStub).toHaveBeenCalledTimes(0); - - await controller.start(); - controller.stop(); - expect(fetchExchangeRateStub).toHaveBeenCalledTimes(1); + const nativeCurrencies = ['ETH', undefined, '']; - await controller.setNativeCurrency('XYZ'); + await controller.updateExchangeRate(nativeCurrencies); - expect(fetchExchangeRateStub).toHaveBeenCalledTimes(1); + // With new fallback logic, ETH with value: 0 triggers fallback attempt + // When fallback also fails (no NetworkController:getState handler), null state is returned + expect(controller.state).toStrictEqual({ + currentCurrency: 'xyz', + currencyRates: { + ETH: { + conversionDate: null, + conversionRate: null, + usdConversionRate: null, + }, + }, + }); controller.destroy(); }); - it('should throw unexpected errors', async () => { - const cryptoCompareHost = 'https://min-api.cryptocompare.com'; - nock(cryptoCompareHost) - .get('/data/price?fsym=ETH&tsyms=XYZ') - .reply(200, { - Response: 'Error', - Message: 'this method has been deprecated', - }) - .persist(); - - const messenger = getRestrictedMessenger(); + it('skips updating empty or undefined native currencies when calling price api', async () => { + jest.spyOn(global.Date, 'now').mockImplementation(() => getStubbedDate()); + + const messenger = getCurrencyRateControllerMessenger(); + + const tokenPricesService = buildMockTokenPricesService(); + + jest.spyOn(tokenPricesService, 'fetchExchangeRates').mockResolvedValue({ + eth: { + name: 'Ether', + ticker: 'eth', + value: 1 / 1000, + currencyType: 'crypto', + }, + }); const controller = new CurrencyRateController({ messenger, state: { currentCurrency: 'xyz' }, + tokenPricesService, }); - await controller.start(); + const nativeCurrencies = ['ETH', undefined, '']; - await expect(controller.updateExchangeRate()).rejects.toThrow( - 'this method has been deprecated', - ); + await controller.updateExchangeRate(nativeCurrencies); + + const conversionDate = getStubbedDate() / 1000; + expect(controller.state).toStrictEqual({ + currentCurrency: 'xyz', + currencyRates: { + ETH: { + conversionDate, + conversionRate: 1000, + usdConversionRate: null, + }, + }, + }); controller.destroy(); }); - it('should catch expected errors', async () => { - const cryptoCompareHost = 'https://min-api.cryptocompare.com'; - nock(cryptoCompareHost) - .get('/data/price?fsym=ETH&tsyms=XYZ') - .reply(200, { - Response: 'Error', - Message: 'market does not exist for this coin pair', - }) - .persist(); - - const messenger = getRestrictedMessenger(); + it('should set conversionDate to null when currency not found in price api response (lines 201-202)', async () => { + jest.spyOn(global.Date, 'now').mockImplementation(() => getStubbedDate()); + + const messenger = getCurrencyRateControllerMessenger(); + + const tokenPricesService = buildMockTokenPricesService(); + + // Mock price API response where BNB is not included + jest.spyOn(tokenPricesService, 'fetchExchangeRates').mockResolvedValue({ + eth: { + name: 'Ether', + ticker: 'eth', + value: 1 / 1000, + usd: 1 / 3000, + currencyType: 'crypto', + }, + // BNB is missing from the response + }); + const controller = new CurrencyRateController({ messenger, state: { currentCurrency: 'xyz' }, + tokenPricesService, }); - await controller.start(); + await controller.updateExchangeRate(['ETH', 'BNB']); - expect(controller.state.conversionRate).toBeNull(); + const conversionDate = getStubbedDate() / 1000; + expect(controller.state).toStrictEqual({ + currentCurrency: 'xyz', + currencyRates: { + ETH: { + conversionDate, + conversionRate: 1000, + usdConversionRate: 3000, + }, + BNB: { + conversionDate: null, // Line 201: rate === undefined + conversionRate: null, // Line 202 + usdConversionRate: null, + }, + }, + }); controller.destroy(); }); - it('should update conversionRates in state to null if either currentCurrency or nativeCurrency is null', async () => { - jest.spyOn(global.Date, 'now').mockImplementation(() => getStubbedDate()); - const cryptoCompareHost = 'https://min-api.cryptocompare.com'; - nock(cryptoCompareHost) - .get('/data/price?fsym=ETH&tsyms=XYZ') - .reply(200, { XYZ: 2000.42 }) - .persist(); - - const messenger = getRestrictedMessenger(); - const existingState = { currentCurrency: '', nativeCurrency: 'BNB' }; - const controller = new CurrencyRateController({ - messenger, - state: existingState, + describe('useExternalServices', () => { + it('should not fetch exchange rates when useExternalServices is false', async () => { + const messenger = getCurrencyRateControllerMessenger(); + const tokenPricesService = buildMockTokenPricesService(); + const controller = new CurrencyRateController({ + useExternalServices: (): boolean => false, + messenger, + state: { currentCurrency: 'usd' }, + tokenPricesService, + }); + + await controller.updateExchangeRate(['ETH']); + + expect(controller.state.currencyRates).toStrictEqual({ + ETH: { + conversionDate: 0, + conversionRate: 0, + usdConversionRate: null, + }, + }); + + controller.destroy(); }); - await controller.start(); + it('should not poll when useExternalServices is false', async () => { + const messenger = getCurrencyRateControllerMessenger(); + const tokenPricesService = buildMockTokenPricesService(); + const fetchExchangeRatesSpy = jest.spyOn( + tokenPricesService, + 'fetchExchangeRates', + ); + const controller = new CurrencyRateController({ + useExternalServices: (): boolean => false, + interval: 100, + messenger, + state: { currentCurrency: 'usd' }, + tokenPricesService, + }); + + controller.startPolling({ nativeCurrencies: ['ETH'] }); + await jestAdvanceTime({ duration: 0 }); - expect(controller.state).toStrictEqual({ - conversionDate: null, - conversionRate: null, - currentCurrency: '', - nativeCurrency: 'BNB', - pendingCurrentCurrency: null, - pendingNativeCurrency: null, - usdConversionRate: null, + expect(fetchExchangeRatesSpy).not.toHaveBeenCalled(); + + await jestAdvanceTime({ duration: 100 }); + + expect(fetchExchangeRatesSpy).not.toHaveBeenCalled(); + + controller.destroy(); + }); + + it('should not fetch exchange rates when useExternalServices is false even with multiple currencies', async () => { + const messenger = getCurrencyRateControllerMessenger(); + const tokenPricesService = buildMockTokenPricesService(); + const controller = new CurrencyRateController({ + useExternalServices: (): boolean => false, + messenger, + state: { currentCurrency: 'eur' }, + tokenPricesService, + }); + + await controller.updateExchangeRate(['ETH', 'BTC', 'BNB']); + + expect(controller.state.currencyRates).toStrictEqual({ + ETH: { + conversionDate: 0, + conversionRate: 0, + usdConversionRate: null, + }, + }); + + controller.destroy(); + }); + + it('should not fetch exchange rates when useExternalServices is false even with testnet currencies', async () => { + const messenger = getCurrencyRateControllerMessenger(); + const tokenPricesService = buildMockTokenPricesService(); + const controller = new CurrencyRateController({ + useExternalServices: (): boolean => false, + messenger, + state: { currentCurrency: 'cad' }, + tokenPricesService, + }); + + await controller.updateExchangeRate(['SepoliaETH', 'GoerliETH']); + + expect(controller.state.currencyRates).toStrictEqual({ + ETH: { + conversionDate: 0, + conversionRate: 0, + usdConversionRate: null, + }, + }); + + controller.destroy(); + }); + + it('should not fetch exchange rates when useExternalServices is false even with includeUsdRate true', async () => { + const messenger = getCurrencyRateControllerMessenger(); + const tokenPricesService = buildMockTokenPricesService(); + const controller = new CurrencyRateController({ + useExternalServices: (): boolean => false, + includeUsdRate: true, + messenger, + state: { currentCurrency: 'jpy' }, + tokenPricesService, + }); + + await controller.updateExchangeRate(['ETH']); + + expect(controller.state.currencyRates).toStrictEqual({ + ETH: { + conversionDate: 0, + conversionRate: 0, + usdConversionRate: null, + }, + }); + + controller.destroy(); + }); + + it('should fetch exchange rates when useExternalServices is true (default behavior)', async () => { + jest.spyOn(global.Date, 'now').mockImplementation(() => getStubbedDate()); + const messenger = getCurrencyRateControllerMessenger(); + const tokenPricesService = buildMockTokenPricesService(); + const fetchExchangeRatesSpy = jest + .spyOn(tokenPricesService, 'fetchExchangeRates') + .mockResolvedValue({ + eth: { + name: 'Ether', + ticker: 'eth', + value: 1 / 1800, + currencyType: 'crypto', + usd: 1 / 2000, + }, + }); + const controller = new CurrencyRateController({ + useExternalServices: (): boolean => true, + messenger, + state: { currentCurrency: 'eur' }, + tokenPricesService, + }); + + await controller.updateExchangeRate(['ETH']); + + expect(fetchExchangeRatesSpy).toHaveBeenCalledTimes(1); + expect(fetchExchangeRatesSpy).toHaveBeenCalledWith({ + baseCurrency: 'eur', + includeUsdRate: false, + cryptocurrencies: ['ETH'], + }); + expect(controller.state.currencyRates).toStrictEqual({ + ETH: { + conversionDate: getStubbedDate() / 1000, + conversionRate: 1800, + usdConversionRate: 2000, + }, + }); + + controller.destroy(); + }); + + it('should default useExternalServices to true when not specified', async () => { + jest.spyOn(global.Date, 'now').mockImplementation(() => getStubbedDate()); + const messenger = getCurrencyRateControllerMessenger(); + const tokenPricesService = buildMockTokenPricesService(); + + const fetchExchangeRatesSpy = jest + .spyOn(tokenPricesService, 'fetchExchangeRates') + .mockResolvedValue({ + eth: { + name: 'Ether', + ticker: 'eth', + value: 1 / 1600, + currencyType: 'crypto', + usd: 1 / 2000, + }, + }); + const controller = new CurrencyRateController({ + messenger, + state: { currentCurrency: 'gbp' }, + tokenPricesService, + }); + + await controller.updateExchangeRate(['ETH']); + + expect(fetchExchangeRatesSpy).toHaveBeenCalledTimes(1); + expect(fetchExchangeRatesSpy).toHaveBeenCalledWith({ + baseCurrency: 'gbp', + includeUsdRate: false, + cryptocurrencies: ['ETH'], + }); + expect(controller.state.currencyRates).toStrictEqual({ + ETH: { + conversionDate: getStubbedDate() / 1000, + conversionRate: 1600, + usdConversionRate: 2000, + }, + }); + + controller.destroy(); + }); + + it('should not throw errors when useExternalServices is false even if fetchMultiExchangeRate would fail', async () => { + const messenger = getCurrencyRateControllerMessenger(); + const tokenPricesService = buildMockTokenPricesService(); + const controller = new CurrencyRateController({ + useExternalServices: (): boolean => false, + messenger, + state: { currentCurrency: 'usd' }, + tokenPricesService, + }); + + // Should not throw an error + expect(await controller.updateExchangeRate(['ETH'])).toBeUndefined(); + + controller.destroy(); + }); + }); + + describe('isDeprecated', () => { + const initialCurrencyRates = { + ETH: { + conversionDate: 1234567890, + conversionRate: 1800, + usdConversionRate: null, + }, + }; + + it('clears persisted currencyRates at construction when isDeprecated() returns true', () => { + const messenger = getCurrencyRateControllerMessenger(); + const tokenPricesService = buildMockTokenPricesService(); + const controller = new CurrencyRateController({ + messenger, + tokenPricesService, + state: { currencyRates: initialCurrencyRates }, + isDeprecated: (): boolean => true, + }); + + expect(controller.state.currencyRates).toStrictEqual({}); + + controller.destroy(); + }); + + it('preserves persisted currencyRates at construction when isDeprecated() returns false', () => { + const messenger = getCurrencyRateControllerMessenger(); + const tokenPricesService = buildMockTokenPricesService(); + const controller = new CurrencyRateController({ + messenger, + tokenPricesService, + state: { currencyRates: initialCurrencyRates }, + isDeprecated: (): boolean => false, + }); + + expect(controller.state.currencyRates).toStrictEqual( + initialCurrencyRates, + ); + + controller.destroy(); + }); + + it('does not throw at construction when isDeprecated() is true and currencyRates is already {}', () => { + const messenger = getCurrencyRateControllerMessenger(); + const tokenPricesService = buildMockTokenPricesService(); + const controller = new CurrencyRateController({ + messenger, + tokenPricesService, + state: { currencyRates: {} }, + isDeprecated: (): boolean => true, + }); + + expect(controller.state.currencyRates).toStrictEqual({}); + + controller.destroy(); + }); + + it('does not make any API calls when isDeprecated() returns true from construction', async () => { + const messenger = getCurrencyRateControllerMessenger(); + const tokenPricesService = buildMockTokenPricesService(); + const fetchExchangeRatesSpy = jest.spyOn( + tokenPricesService, + 'fetchExchangeRates', + ); + const controller = new CurrencyRateController({ + messenger, + tokenPricesService, + isDeprecated: (): boolean => true, + }); + + await controller.updateExchangeRate(['ETH']); + + expect(fetchExchangeRatesSpy).not.toHaveBeenCalled(); + + controller.destroy(); + }); + + it('does not fetch and clears stale currencyRates when isDeprecated toggles to true at runtime via updateExchangeRate', async () => { + let deprecated = false; + const messenger = getCurrencyRateControllerMessenger(); + const tokenPricesService = buildMockTokenPricesService(); + const fetchExchangeRatesSpy = jest.spyOn( + tokenPricesService, + 'fetchExchangeRates', + ); + const controller = new CurrencyRateController({ + messenger, + tokenPricesService, + state: { currencyRates: initialCurrencyRates }, + isDeprecated: (): boolean => deprecated, + }); + + expect(controller.state.currencyRates).toStrictEqual( + initialCurrencyRates, + ); + + deprecated = true; + + await controller.updateExchangeRate(['ETH']); + + expect(fetchExchangeRatesSpy).not.toHaveBeenCalled(); + expect(controller.state.currencyRates).toStrictEqual({}); + + controller.destroy(); + }); + + it('does not fetch and clears stale currencyRates when isDeprecated toggles to true at runtime via setCurrentCurrency', async () => { + let deprecated = false; + const messenger = getCurrencyRateControllerMessenger(); + const tokenPricesService = buildMockTokenPricesService(); + const fetchExchangeRatesSpy = jest.spyOn( + tokenPricesService, + 'fetchExchangeRates', + ); + const controller = new CurrencyRateController({ + messenger, + tokenPricesService, + state: { currencyRates: initialCurrencyRates }, + isDeprecated: (): boolean => deprecated, + }); + + expect(controller.state.currencyRates).toStrictEqual( + initialCurrencyRates, + ); + + deprecated = true; + + await controller.setCurrentCurrency('eur'); + + expect(fetchExchangeRatesSpy).not.toHaveBeenCalled(); + expect(controller.state.currencyRates).toStrictEqual({}); + + controller.destroy(); + }); + + it('does not poll and clears stale currencyRates when isDeprecated toggles to true at runtime via _executePoll', async () => { + let deprecated = false; + const messenger = getCurrencyRateControllerMessenger(); + const tokenPricesService = buildMockTokenPricesService(); + const fetchExchangeRatesSpy = jest.spyOn( + tokenPricesService, + 'fetchExchangeRates', + ); + const controller = new CurrencyRateController({ + messenger, + tokenPricesService, + state: { currencyRates: initialCurrencyRates }, + isDeprecated: (): boolean => deprecated, + }); + + expect(controller.state.currencyRates).toStrictEqual( + initialCurrencyRates, + ); + + deprecated = true; + + await controller._executePoll({ nativeCurrencies: ['ETH'] }); + + expect(fetchExchangeRatesSpy).not.toHaveBeenCalled(); + expect(controller.state.currencyRates).toStrictEqual({}); + + controller.destroy(); + }); + }); + + describe('fallback to token prices service (lines 233-316)', () => { + it('should fallback to fetchTokenPrices when fetchExchangeRates fails and crypto compare fallback also fails', async () => { + jest.spyOn(global.Date, 'now').mockImplementation(() => getStubbedDate()); + + const messenger = getCurrencyRateControllerMessengerWithNetworkState({ + networkConfigurationsByChainId: { + '0x1': { + chainId: '0x1', + nativeCurrency: 'ETH', + name: 'Ethereum Mainnet', + rpcEndpoints: [], + blockExplorerUrls: [], + defaultRpcEndpointIndex: 0, + }, + '0x89': { + chainId: '0x89', + nativeCurrency: 'POL', + name: 'Polygon', + rpcEndpoints: [], + blockExplorerUrls: [], + defaultRpcEndpointIndex: 0, + }, + }, + }); + + const tokenPricesService = buildMockTokenPricesService(); + + // Make fetchExchangeRates fail to trigger fallback + jest + .spyOn(tokenPricesService, 'fetchExchangeRates') + .mockRejectedValue(new Error('Price API failed')); + + // Mock fetchTokenPrices to return token prices + jest + .spyOn(tokenPricesService, 'fetchTokenPrices') + .mockImplementation(async ({ assets }) => { + if (assets.some((asset) => asset.chainId === '0x1')) { + return [ + { + currency: 'usd', + tokenAddress: '0x0000000000000000000000000000000000000000', + chainId: assets[0].chainId, + assetId: 'xx:yy/aa:bb', + price: 2500.5, + pricePercentChange1d: 0, + priceChange1d: 0, + allTimeHigh: 4000, + allTimeLow: 900, + circulatingSupply: 2000, + dilutedMarketCap: 100, + high1d: 200, + low1d: 100, + marketCap: 1000, + marketCapPercentChange1d: 100, + pricePercentChange14d: 100, + pricePercentChange1h: 1, + pricePercentChange1y: 200, + pricePercentChange200d: 300, + pricePercentChange30d: 200, + pricePercentChange7d: 100, + totalVolume: 100, + }, + ]; + } + + if (assets.some((asset) => asset.chainId === '0x89')) { + return [ + { + currency: 'usd', + tokenAddress: '0x0000000000000000000000000000000000001010', + chainId: assets[0].chainId, + assetId: 'xx:yy/aa:bb', + price: 0.85, + pricePercentChange1d: 0, + priceChange1d: 0, + allTimeHigh: 4000, + allTimeLow: 900, + circulatingSupply: 2000, + dilutedMarketCap: 100, + high1d: 200, + low1d: 100, + marketCap: 1000, + marketCapPercentChange1d: 100, + pricePercentChange14d: 100, + pricePercentChange1h: 1, + pricePercentChange1y: 200, + pricePercentChange200d: 300, + pricePercentChange30d: 200, + pricePercentChange7d: 100, + totalVolume: 100, + }, + ]; + } + return []; + }); + + // Make crypto compare also fail by not mocking it (no nock setup) + const controller = new CurrencyRateController({ + messenger, + state: { currentCurrency: 'usd' }, + tokenPricesService, + }); + + await controller.updateExchangeRate(['ETH', 'POL']); + + const conversionDate = getStubbedDate() / 1000; + expect(controller.state).toStrictEqual({ + currentCurrency: 'usd', + currencyRates: { + ETH: { + conversionDate, + conversionRate: 2500.5, + usdConversionRate: null, + }, + POL: { + conversionDate, + conversionRate: 0.85, + usdConversionRate: null, + }, + }, + }); + + controller.destroy(); + }); + + it('should map native currencies to correct chain IDs (lines 236-262)', async () => { + jest.spyOn(global.Date, 'now').mockImplementation(() => getStubbedDate()); + + const messenger = getCurrencyRateControllerMessengerWithNetworkState({ + networkConfigurationsByChainId: { + '0x1': { + chainId: '0x1', + nativeCurrency: 'ETH', + name: 'Ethereum Mainnet', + rpcEndpoints: [], + blockExplorerUrls: [], + defaultRpcEndpointIndex: 0, + }, + '0xaa36a7': { + chainId: '0xaa36a7', + nativeCurrency: 'ETH', // Sepolia also uses ETH + name: 'Sepolia', + rpcEndpoints: [], + blockExplorerUrls: [], + defaultRpcEndpointIndex: 0, + }, + }, + }); + + const tokenPricesService = buildMockTokenPricesService(); + + jest + .spyOn(tokenPricesService, 'fetchExchangeRates') + .mockRejectedValue(new Error('Price API failed')); + + const fetchTokenPricesSpy = jest + .spyOn(tokenPricesService, 'fetchTokenPrices') + .mockImplementation(async ({ assets }) => { + if ( + assets.some( + (asset) => + asset.chainId === '0x1' || asset.chainId === '0xaa36a7', + ) + ) { + return [ + { + currency: 'usd', + tokenAddress: '0x0000000000000000000000000000000000000000', + chainId: assets[0].chainId, + assetId: 'xx:yy/aa:bb', + price: 2500.5, + pricePercentChange1d: 0, + priceChange1d: 0, + allTimeHigh: 4000, + allTimeLow: 900, + circulatingSupply: 2000, + dilutedMarketCap: 100, + high1d: 200, + low1d: 100, + marketCap: 1000, + marketCapPercentChange1d: 100, + pricePercentChange14d: 100, + pricePercentChange1h: 1, + pricePercentChange1y: 200, + pricePercentChange200d: 300, + pricePercentChange30d: 200, + pricePercentChange7d: 100, + totalVolume: 100, + }, + ]; + } + return []; + }); + + const controller = new CurrencyRateController({ + messenger, + state: { currentCurrency: 'usd' }, + tokenPricesService, + }); + + await controller.updateExchangeRate(['ETH']); + + // Should only call fetchTokenPrices once, using first matching chainId (line 255) + expect(fetchTokenPricesSpy).toHaveBeenCalledTimes(1); + expect(fetchTokenPricesSpy).toHaveBeenCalledWith({ + assets: [ + { + chainId: '0x1', + tokenAddress: '0x0000000000000000000000000000000000000000', + }, + ], + currency: 'usd', + }); + + controller.destroy(); + }); + + it('should handle errors when fetchTokenPrices fails for a specific chain (lines 285-296)', async () => { + jest.spyOn(global.Date, 'now').mockImplementation(() => getStubbedDate()); + + const messenger = getCurrencyRateControllerMessengerWithNetworkState({ + networkConfigurationsByChainId: { + '0x1': { + chainId: '0x1', + nativeCurrency: 'ETH', + name: 'Ethereum Mainnet', + rpcEndpoints: [], + blockExplorerUrls: [], + defaultRpcEndpointIndex: 0, + }, + '0x89': { + chainId: '0x89', + nativeCurrency: 'POL', + name: 'Polygon', + rpcEndpoints: [], + blockExplorerUrls: [], + defaultRpcEndpointIndex: 0, + }, + }, + }); + + const tokenPricesService = buildMockTokenPricesService(); + + jest + .spyOn(tokenPricesService, 'fetchExchangeRates') + .mockRejectedValue(new Error('Price API failed')); + + const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(); + + jest + .spyOn(tokenPricesService, 'fetchTokenPrices') + .mockImplementation(async ({ assets }) => { + if (assets.some((asset) => asset.chainId === '0x1')) { + // ETH succeeds + return [ + { + currency: 'usd', + tokenAddress: '0x0000000000000000000000000000000000000000', + chainId: assets[0].chainId, + assetId: 'xx:yy/aa:bb', + price: 2500.5, + pricePercentChange1d: 0, + priceChange1d: 0, + allTimeHigh: 4000, + allTimeLow: 900, + circulatingSupply: 2000, + dilutedMarketCap: 100, + high1d: 200, + low1d: 100, + marketCap: 1000, + marketCapPercentChange1d: 100, + pricePercentChange14d: 100, + pricePercentChange1h: 1, + pricePercentChange1y: 200, + pricePercentChange200d: 300, + pricePercentChange30d: 200, + pricePercentChange7d: 100, + totalVolume: 100, + }, + ]; + } + // POL fails + throw new Error('Failed to fetch POL price'); + }); + + const controller = new CurrencyRateController({ + messenger, + state: { currentCurrency: 'usd' }, + tokenPricesService, + }); + + await controller.updateExchangeRate(['ETH', 'POL']); + + expect(consoleErrorSpy).toHaveBeenCalledWith( + 'Failed to fetch token price for POL on chain 0x89', + expect.any(Error), + ); + + const conversionDate = getStubbedDate() / 1000; + expect(controller.state).toStrictEqual({ + currentCurrency: 'usd', + currencyRates: { + ETH: { + conversionDate, + conversionRate: 2500.5, + usdConversionRate: null, + }, + POL: { + conversionDate: null, + conversionRate: null, + usdConversionRate: null, + }, + }, + }); + + consoleErrorSpy.mockRestore(); + controller.destroy(); + }); + + it('should set conversionDate to null when token price is not found (line 281)', async () => { + jest.spyOn(global.Date, 'now').mockImplementation(() => getStubbedDate()); + + const messenger = getCurrencyRateControllerMessengerWithNetworkState({ + networkConfigurationsByChainId: { + '0x1': { + chainId: '0x1', + nativeCurrency: 'ETH', + name: 'Ethereum Mainnet', + rpcEndpoints: [], + blockExplorerUrls: [], + defaultRpcEndpointIndex: 0, + }, + }, + }); + + const tokenPricesService = buildMockTokenPricesService(); + + jest + .spyOn(tokenPricesService, 'fetchExchangeRates') + .mockRejectedValue(new Error('Price API failed')); + + // Return empty object (no token price) + jest.spyOn(tokenPricesService, 'fetchTokenPrices').mockResolvedValue([]); + + const controller = new CurrencyRateController({ + messenger, + state: { currentCurrency: 'usd' }, + tokenPricesService, + }); + + await controller.updateExchangeRate(['ETH']); + + expect(controller.state).toStrictEqual({ + currentCurrency: 'usd', + currencyRates: { + ETH: { + conversionDate: null, // Line 281: tokenPrice is undefined + conversionRate: null, // Line 282: tokenPrice?.price ?? null + usdConversionRate: null, + }, + }, + }); + + controller.destroy(); + }); + + it('should set null state for currencies not found in network configurations (lines 252-257)', async () => { + jest.spyOn(global.Date, 'now').mockImplementation(() => getStubbedDate()); + + const messenger = getCurrencyRateControllerMessengerWithNetworkState({ + networkConfigurationsByChainId: { + '0x1': { + chainId: '0x1', + nativeCurrency: 'ETH', + name: 'Ethereum Mainnet', + rpcEndpoints: [], + blockExplorerUrls: [], + defaultRpcEndpointIndex: 0, + }, + }, + }); + + const tokenPricesService = buildMockTokenPricesService(); + + jest + .spyOn(tokenPricesService, 'fetchExchangeRates') + .mockRejectedValue(new Error('Price API failed')); + + const fetchTokenPricesSpy = jest + .spyOn(tokenPricesService, 'fetchTokenPrices') + .mockImplementation(async ({ assets }) => { + if (assets.some((asset) => asset.chainId === '0x1')) { + return [ + { + currency: 'usd', + tokenAddress: '0x0000000000000000000000000000000000000000', + chainId: assets[0].chainId, + price: 2500.5, + pricePercentChange1d: 0, + priceChange1d: 0, + allTimeHigh: 4000, + allTimeLow: 900, + circulatingSupply: 2000, + dilutedMarketCap: 100, + high1d: 200, + low1d: 100, + marketCap: 1000, + marketCapPercentChange1d: 100, + pricePercentChange14d: 100, + pricePercentChange1h: 1, + pricePercentChange1y: 200, + pricePercentChange200d: 300, + pricePercentChange30d: 200, + pricePercentChange7d: 100, + totalVolume: 100, + }, + ]; + } + return []; + }); + + const controller = new CurrencyRateController({ + messenger, + state: { currentCurrency: 'usd' }, + tokenPricesService, + }); + + // Request ETH (exists) and BNB (not in network configs) + await controller.updateExchangeRate(['ETH', 'BNB']); + + // Should only call fetchTokenPrices for ETH, not BNB (line 252: if chainIds.length > 0) + expect(fetchTokenPricesSpy).toHaveBeenCalledTimes(1); + expect(fetchTokenPricesSpy).toHaveBeenCalledWith({ + assets: [ + { + chainId: '0x1', + tokenAddress: '0x0000000000000000000000000000000000000000', + }, + ], + currency: 'usd', + }); + + const conversionDate = getStubbedDate() / 1000; + expect(controller.state).toStrictEqual({ + currentCurrency: 'usd', + currencyRates: { + ETH: { + conversionDate, + conversionRate: 2500.5, + usdConversionRate: null, + }, + // BNB has null state because it couldn't be found in network configurations + BNB: { + conversionDate: null, + conversionRate: null, + usdConversionRate: null, + }, + }, + }); + + controller.destroy(); + }); + + it('should use correct native token address for Polygon (line 269)', async () => { + jest.spyOn(global.Date, 'now').mockImplementation(() => getStubbedDate()); + + const messenger = getCurrencyRateControllerMessengerWithNetworkState({ + networkConfigurationsByChainId: { + '0x89': { + chainId: '0x89', + nativeCurrency: 'POL', + name: 'Polygon', + rpcEndpoints: [], + blockExplorerUrls: [], + defaultRpcEndpointIndex: 0, + }, + }, + }); + + const tokenPricesService = buildMockTokenPricesService(); + + jest + .spyOn(tokenPricesService, 'fetchExchangeRates') + .mockRejectedValue(new Error('Price API failed')); + + const fetchTokenPricesSpy = jest + .spyOn(tokenPricesService, 'fetchTokenPrices') + .mockResolvedValue([ + { + currency: 'usd', + tokenAddress: '0x0000000000000000000000000000000000001010', + chainId: '0x89', + price: 0.85, + pricePercentChange1d: 0, + priceChange1d: 0, + allTimeHigh: 4000, + allTimeLow: 900, + circulatingSupply: 2000, + dilutedMarketCap: 100, + high1d: 200, + low1d: 100, + marketCap: 1000, + marketCapPercentChange1d: 100, + pricePercentChange14d: 100, + pricePercentChange1h: 1, + pricePercentChange1y: 200, + pricePercentChange200d: 300, + pricePercentChange30d: 200, + pricePercentChange7d: 100, + totalVolume: 100, + }, + ]); + + const controller = new CurrencyRateController({ + messenger, + state: { currentCurrency: 'usd' }, + tokenPricesService, + }); + + await controller.updateExchangeRate(['POL']); + + // Should use Polygon's native token address (line 269) + expect(fetchTokenPricesSpy).toHaveBeenCalledWith({ + assets: [ + { + chainId: '0x89', + tokenAddress: '0x0000000000000000000000000000000000001010', + }, + ], + currency: 'usd', + }); + + controller.destroy(); + }); + }); + + describe('partial success with fallback', () => { + it('should fallback only for currencies that failed in Price API response (partial success)', async () => { + jest.spyOn(global.Date, 'now').mockImplementation(() => getStubbedDate()); + + const messenger = getCurrencyRateControllerMessengerWithNetworkState({ + networkConfigurationsByChainId: { + '0x1': { + chainId: '0x1', + nativeCurrency: 'ETH', + name: 'Ethereum Mainnet', + rpcEndpoints: [], + blockExplorerUrls: [], + defaultRpcEndpointIndex: 0, + }, + '0x89': { + chainId: '0x89', + nativeCurrency: 'POL', + name: 'Polygon', + rpcEndpoints: [], + blockExplorerUrls: [], + defaultRpcEndpointIndex: 0, + }, + }, + }); + + const tokenPricesService = buildMockTokenPricesService(); + + // Price API returns ETH but not POL (partial success) + jest.spyOn(tokenPricesService, 'fetchExchangeRates').mockResolvedValue({ + eth: { + name: 'Ether', + ticker: 'eth', + value: 1 / 2000, + currencyType: 'crypto', + usd: 1 / 2500, + }, + // POL is missing - should trigger fallback + }); + + const fetchTokenPricesSpy = jest + .spyOn(tokenPricesService, 'fetchTokenPrices') + .mockResolvedValue([ + { + currency: 'usd', + tokenAddress: '0x0000000000000000000000000000000000001010', + chainId: '0x89', + price: 0.75, + pricePercentChange1d: 0, + priceChange1d: 0, + allTimeHigh: 4000, + allTimeLow: 900, + circulatingSupply: 2000, + dilutedMarketCap: 100, + high1d: 200, + low1d: 100, + marketCap: 1000, + marketCapPercentChange1d: 100, + pricePercentChange14d: 100, + pricePercentChange1h: 1, + pricePercentChange1y: 200, + pricePercentChange200d: 300, + pricePercentChange30d: 200, + pricePercentChange7d: 100, + totalVolume: 100, + }, + ]); + + const controller = new CurrencyRateController({ + messenger, + state: { currentCurrency: 'usd' }, + tokenPricesService, + }); + + await controller.updateExchangeRate(['ETH', 'POL']); + + // Should only call fetchTokenPrices for POL (ETH succeeded) + expect(fetchTokenPricesSpy).toHaveBeenCalledTimes(1); + expect(fetchTokenPricesSpy).toHaveBeenCalledWith({ + assets: [ + { + chainId: '0x89', + tokenAddress: '0x0000000000000000000000000000000000001010', + }, + ], + currency: 'usd', + }); + + const conversionDate = getStubbedDate() / 1000; + expect(controller.state).toStrictEqual({ + currentCurrency: 'usd', + currencyRates: { + ETH: { + conversionDate, + conversionRate: 2000, + usdConversionRate: 2500, + }, + POL: { + conversionDate, + conversionRate: 0.75, + usdConversionRate: null, + }, + }, + }); + + controller.destroy(); + }); + + it('should not call fallback when all currencies succeed from Price API', async () => { + jest.spyOn(global.Date, 'now').mockImplementation(() => getStubbedDate()); + + const messenger = getCurrencyRateControllerMessengerWithNetworkState({ + networkConfigurationsByChainId: { + '0x1': { + chainId: '0x1', + nativeCurrency: 'ETH', + name: 'Ethereum Mainnet', + rpcEndpoints: [], + blockExplorerUrls: [], + defaultRpcEndpointIndex: 0, + }, + '0x89': { + chainId: '0x89', + nativeCurrency: 'POL', + name: 'Polygon', + rpcEndpoints: [], + blockExplorerUrls: [], + defaultRpcEndpointIndex: 0, + }, + }, + }); + + const tokenPricesService = buildMockTokenPricesService(); + + // Price API returns both ETH and POL (full success) + jest.spyOn(tokenPricesService, 'fetchExchangeRates').mockResolvedValue({ + eth: { + name: 'Ether', + ticker: 'eth', + value: 1 / 2000, + currencyType: 'crypto', + usd: 1 / 2500, + }, + pol: { + name: 'Polygon', + ticker: 'pol', + value: 1 / 0.8, + currencyType: 'crypto', + usd: 1 / 1, + }, + }); + + const fetchTokenPricesSpy = jest.spyOn( + tokenPricesService, + 'fetchTokenPrices', + ); + + const controller = new CurrencyRateController({ + messenger, + state: { currentCurrency: 'usd' }, + tokenPricesService, + }); + + await controller.updateExchangeRate(['ETH', 'POL']); + + // Should NOT call fetchTokenPrices since all currencies succeeded + expect(fetchTokenPricesSpy).not.toHaveBeenCalled(); + + const conversionDate = getStubbedDate() / 1000; + expect(controller.state).toStrictEqual({ + currentCurrency: 'usd', + currencyRates: { + ETH: { + conversionDate, + conversionRate: 2000, + usdConversionRate: 2500, + }, + POL: { + conversionDate, + conversionRate: 0.8, + usdConversionRate: 1, + }, + }, + }); + + controller.destroy(); + }); + + it('should preserve successful Price API rates even when fallback fails', async () => { + jest.spyOn(global.Date, 'now').mockImplementation(() => getStubbedDate()); + + const messenger = getCurrencyRateControllerMessengerWithNetworkState({ + networkConfigurationsByChainId: { + '0x1': { + chainId: '0x1', + nativeCurrency: 'ETH', + name: 'Ethereum Mainnet', + rpcEndpoints: [], + blockExplorerUrls: [], + defaultRpcEndpointIndex: 0, + }, + '0x89': { + chainId: '0x89', + nativeCurrency: 'POL', + name: 'Polygon', + rpcEndpoints: [], + blockExplorerUrls: [], + defaultRpcEndpointIndex: 0, + }, + }, + }); + + const tokenPricesService = buildMockTokenPricesService(); + + // Price API returns ETH but not POL + jest.spyOn(tokenPricesService, 'fetchExchangeRates').mockResolvedValue({ + eth: { + name: 'Ether', + ticker: 'eth', + value: 1 / 2000, + currencyType: 'crypto', + usd: 1 / 2500, + }, + }); + + // Fallback also fails for POL + jest + .spyOn(tokenPricesService, 'fetchTokenPrices') + .mockRejectedValue(new Error('Token prices service failed')); + + const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(); + + const controller = new CurrencyRateController({ + messenger, + state: { currentCurrency: 'usd' }, + tokenPricesService, + }); + + await controller.updateExchangeRate(['ETH', 'POL']); + + const conversionDate = getStubbedDate() / 1000; + expect(controller.state).toStrictEqual({ + currentCurrency: 'usd', + currencyRates: { + // ETH should still have valid data from Price API + ETH: { + conversionDate, + conversionRate: 2000, + usdConversionRate: 2500, + }, + // POL should have null values since both approaches failed + POL: { + conversionDate: null, + conversionRate: null, + usdConversionRate: null, + }, + }, + }); + + consoleErrorSpy.mockRestore(); + controller.destroy(); + }); + + it('should handle multiple partial failures with mixed fallback results', async () => { + jest.spyOn(global.Date, 'now').mockImplementation(() => getStubbedDate()); + + const messenger = getCurrencyRateControllerMessengerWithNetworkState({ + networkConfigurationsByChainId: { + '0x1': { + chainId: '0x1', + nativeCurrency: 'ETH', + name: 'Ethereum Mainnet', + rpcEndpoints: [], + blockExplorerUrls: [], + defaultRpcEndpointIndex: 0, + }, + '0x89': { + chainId: '0x89', + nativeCurrency: 'POL', + name: 'Polygon', + rpcEndpoints: [], + blockExplorerUrls: [], + defaultRpcEndpointIndex: 0, + }, + '0x38': { + chainId: '0x38', + nativeCurrency: 'BNB', + name: 'BSC', + rpcEndpoints: [], + blockExplorerUrls: [], + defaultRpcEndpointIndex: 0, + }, + }, + }); + + const tokenPricesService = buildMockTokenPricesService(); + + // Price API returns only ETH + jest.spyOn(tokenPricesService, 'fetchExchangeRates').mockResolvedValue({ + eth: { + name: 'Ether', + ticker: 'eth', + value: 1 / 2000, + currencyType: 'crypto', + }, + }); + + const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(); + + // Fallback succeeds for POL but fails for BNB + jest + .spyOn(tokenPricesService, 'fetchTokenPrices') + .mockImplementation(async ({ assets }) => { + if (assets.some((asset) => asset.chainId === '0x89')) { + return [ + { + currency: 'usd', + tokenAddress: '0x0000000000000000000000000000000000001010', + chainId: '0x89', + price: 0.75, + pricePercentChange1d: 0, + priceChange1d: 0, + allTimeHigh: 4000, + allTimeLow: 900, + circulatingSupply: 2000, + dilutedMarketCap: 100, + high1d: 200, + low1d: 100, + marketCap: 1000, + marketCapPercentChange1d: 100, + pricePercentChange14d: 100, + pricePercentChange1h: 1, + pricePercentChange1y: 200, + pricePercentChange200d: 300, + pricePercentChange30d: 200, + pricePercentChange7d: 100, + totalVolume: 100, + }, + ]; + } + throw new Error('Token prices service failed for BNB'); + }); + + const controller = new CurrencyRateController({ + messenger, + state: { currentCurrency: 'usd' }, + tokenPricesService, + }); + + await controller.updateExchangeRate(['ETH', 'POL', 'BNB']); + + const conversionDate = getStubbedDate() / 1000; + expect(controller.state).toStrictEqual({ + currentCurrency: 'usd', + currencyRates: { + // ETH from Price API + ETH: { + conversionDate, + conversionRate: 2000, + usdConversionRate: null, + }, + // POL from fallback + POL: { + conversionDate, + conversionRate: 0.75, + usdConversionRate: null, + }, + // BNB failed both approaches + BNB: { + conversionDate: null, + conversionRate: null, + usdConversionRate: null, + }, + }, + }); + + consoleErrorSpy.mockRestore(); + controller.destroy(); + }); + + it('should handle Price API returning rate with no value (undefined rate)', async () => { + jest.spyOn(global.Date, 'now').mockImplementation(() => getStubbedDate()); + + const messenger = getCurrencyRateControllerMessengerWithNetworkState({ + networkConfigurationsByChainId: { + '0x1': { + chainId: '0x1', + nativeCurrency: 'ETH', + name: 'Ethereum Mainnet', + rpcEndpoints: [], + blockExplorerUrls: [], + defaultRpcEndpointIndex: 0, + }, + }, + }); + + const tokenPricesService = buildMockTokenPricesService(); + + // Price API returns ETH but with value: 0 (falsy) + jest.spyOn(tokenPricesService, 'fetchExchangeRates').mockResolvedValue({ + eth: { + name: 'Ether', + ticker: 'eth', + value: 0, // Falsy value should trigger fallback + currencyType: 'crypto', + }, + }); + + jest.spyOn(tokenPricesService, 'fetchTokenPrices').mockResolvedValue([ + { + currency: 'usd', + tokenAddress: '0x0000000000000000000000000000000000000000', + chainId: '0x1', + price: 1800, + pricePercentChange1d: 0, + priceChange1d: 0, + allTimeHigh: 4000, + allTimeLow: 900, + circulatingSupply: 2000, + dilutedMarketCap: 100, + high1d: 200, + low1d: 100, + marketCap: 1000, + marketCapPercentChange1d: 100, + pricePercentChange14d: 100, + pricePercentChange1h: 1, + pricePercentChange1y: 200, + pricePercentChange200d: 300, + pricePercentChange30d: 200, + pricePercentChange7d: 100, + totalVolume: 100, + }, + ]); + + const controller = new CurrencyRateController({ + messenger, + state: { currentCurrency: 'usd' }, + tokenPricesService, + }); + + await controller.updateExchangeRate(['ETH']); + + const conversionDate = getStubbedDate() / 1000; + expect(controller.state).toStrictEqual({ + currentCurrency: 'usd', + currencyRates: { + ETH: { + conversionDate, + conversionRate: 1800, + usdConversionRate: null, + }, + }, + }); + + controller.destroy(); + }); + }); + + describe('metadata', () => { + it('includes expected state in debug snapshots', () => { + const tokenPricesService = buildMockTokenPricesService(); + const controller = new CurrencyRateController({ + messenger: getCurrencyRateControllerMessenger(), + tokenPricesService, + }); + + expect( + deriveStateFromMetadata( + controller.state, + controller.metadata, + 'includeInDebugSnapshot', + ), + ).toMatchInlineSnapshot(` + { + "currencyRates": { + "ETH": { + "conversionDate": 0, + "conversionRate": 0, + "usdConversionRate": null, + }, + }, + "currentCurrency": "usd", + } + `); + }); + + it('includes expected state in state logs', () => { + const tokenPricesService = buildMockTokenPricesService(); + const controller = new CurrencyRateController({ + messenger: getCurrencyRateControllerMessenger(), + tokenPricesService, + }); + + expect( + deriveStateFromMetadata( + controller.state, + controller.metadata, + 'includeInStateLogs', + ), + ).toMatchInlineSnapshot(` + { + "currencyRates": { + "ETH": { + "conversionDate": 0, + "conversionRate": 0, + "usdConversionRate": null, + }, + }, + "currentCurrency": "usd", + } + `); + }); + + it('persists expected state', () => { + const tokenPricesService = buildMockTokenPricesService(); + const controller = new CurrencyRateController({ + messenger: getCurrencyRateControllerMessenger(), + tokenPricesService, + }); + + expect( + deriveStateFromMetadata( + controller.state, + controller.metadata, + 'persist', + ), + ).toMatchInlineSnapshot(` + { + "currencyRates": { + "ETH": { + "conversionDate": 0, + "conversionRate": 0, + "usdConversionRate": null, + }, + }, + "currentCurrency": "usd", + } + `); + }); + + it('exposes expected state to UI', () => { + const controller = new CurrencyRateController({ + messenger: getCurrencyRateControllerMessenger(), + tokenPricesService: buildMockTokenPricesService(), + }); + + expect( + deriveStateFromMetadata( + controller.state, + controller.metadata, + 'usedInUi', + ), + ).toMatchInlineSnapshot(` + { + "currencyRates": { + "ETH": { + "conversionDate": 0, + "conversionRate": 0, + "usdConversionRate": null, + }, + }, + "currentCurrency": "usd", + } + `); }); }); }); diff --git a/packages/assets-controllers/src/CurrencyRateController.ts b/packages/assets-controllers/src/CurrencyRateController.ts index 77261a6259f..5c4ac92587a 100644 --- a/packages/assets-controllers/src/CurrencyRateController.ts +++ b/packages/assets-controllers/src/CurrencyRateController.ts @@ -1,98 +1,146 @@ -import type { RestrictedControllerMessenger } from '@metamask/base-controller'; -import { BaseControllerV2 } from '@metamask/base-controller'; +import type { + ControllerGetStateAction, + ControllerStateChangeEvent, + StateMetadata, +} from '@metamask/base-controller'; import { TESTNET_TICKER_SYMBOLS, FALL_BACK_VS_CURRENCY, - safelyExecute, } from '@metamask/controller-utils'; +import type { Messenger } from '@metamask/messenger'; +import type { + NetworkControllerGetNetworkClientByIdAction, + NetworkControllerGetStateAction, + NetworkConfiguration, +} from '@metamask/network-controller'; +import { StaticIntervalPollingController } from '@metamask/polling-controller'; +import type { Hex } from '@metamask/utils'; import { Mutex } from 'async-mutex'; -import type { Patch } from 'immer'; -import { fetchExchangeRate as defaultFetchExchangeRate } from './crypto-compare'; +import type { CurrencyRateControllerMethodActions } from './CurrencyRateController-method-action-types.js'; +import type { AbstractTokenPricesService } from './token-prices-service/abstract-token-prices-service.js'; +import { getNativeTokenAddress } from './token-prices-service/codefi-v2.js'; /** - * @type CurrencyRateState - * @property conversionDate - Timestamp of conversion rate expressed in ms since UNIX epoch - * @property conversionRate - Conversion rate from current base asset to the current currency - * @property currentCurrency - Currently-active ISO 4217 currency code - * @property nativeCurrency - Symbol for the base asset used for conversion - * @property pendingCurrentCurrency - The currency being switched to - * @property pendingNativeCurrency - The base asset currency being switched to - * @property usdConversionRate - Conversion rate from usd to the current currency + * currencyRates - Object keyed by native currency + * + * currencyRates.conversionDate - Timestamp of conversion rate expressed in ms since UNIX epoch + * + * currencyRates.conversionRate - Conversion rate from current base asset to the current currency + * + * currentCurrency - Currently-active ISO 4217 currency code + * + * usdConversionRate - Conversion rate from usd to the current currency */ export type CurrencyRateState = { - conversionDate: number | null; - conversionRate: number | null; currentCurrency: string; - nativeCurrency: string; - pendingCurrentCurrency: string | null; - pendingNativeCurrency: string | null; - usdConversionRate: number | null; + currencyRates: Record< + string, + { + conversionDate: number | null; + conversionRate: number | null; + usdConversionRate: number | null; + } + >; }; const name = 'CurrencyRateController'; -export type CurrencyRateStateChange = { - type: `${typeof name}:stateChange`; - payload: [CurrencyRateState, Patch[]]; -}; +const MESSENGER_EXPOSED_METHODS = [ + 'setCurrentCurrency', + 'updateExchangeRate', +] as const; -export type GetCurrencyRateState = { - type: `${typeof name}:getState`; - handler: () => CurrencyRateState; -}; +export type CurrencyRateStateChange = ControllerStateChangeEvent< + typeof name, + CurrencyRateState +>; + +export type CurrencyRateControllerEvents = CurrencyRateStateChange; -type CurrencyRateMessenger = RestrictedControllerMessenger< +export type CurrencyRateControllerGetStateAction = ControllerGetStateAction< typeof name, - GetCurrencyRateState, - CurrencyRateStateChange, - never, - never + CurrencyRateState >; -const metadata = { - conversionDate: { persist: true, anonymous: true }, - conversionRate: { persist: true, anonymous: true }, - currentCurrency: { persist: true, anonymous: true }, - nativeCurrency: { persist: true, anonymous: true }, - pendingCurrentCurrency: { persist: false, anonymous: true }, - pendingNativeCurrency: { persist: false, anonymous: true }, - usdConversionRate: { persist: true, anonymous: true }, +export type CurrencyRateControllerActions = + | CurrencyRateControllerGetStateAction + | CurrencyRateControllerMethodActions; + +type AllowedActions = + | NetworkControllerGetNetworkClientByIdAction + | NetworkControllerGetStateAction; + +export type CurrencyRateMessenger = Messenger< + typeof name, + CurrencyRateControllerActions | AllowedActions, + CurrencyRateControllerEvents +>; + +const metadata: StateMetadata = { + currentCurrency: { + includeInStateLogs: true, + persist: true, + includeInDebugSnapshot: true, + usedInUi: true, + }, + currencyRates: { + includeInStateLogs: true, + persist: true, + includeInDebugSnapshot: true, + usedInUi: true, + }, }; const defaultState = { - conversionDate: 0, - conversionRate: 0, currentCurrency: 'usd', - nativeCurrency: 'ETH', - pendingCurrentCurrency: null, - pendingNativeCurrency: null, - usdConversionRate: null, + currencyRates: { + ETH: { + conversionDate: 0, + conversionRate: 0, + usdConversionRate: null, + }, + }, +}; + +/** The input to start polling for the {@link CurrencyRateController} */ +type CurrencyRatePollingInput = { + nativeCurrencies: string[]; +}; + +const boundedPrecisionNumber = (value: number, precision = 9): number => + Number(value.toFixed(precision)); + +/** + * Controller that passively polls on a set interval for an exchange rate from the current network + * asset to the user's preferred currency. + */ +/** Result from attempting to fetch rates from an API */ +type FetchRatesResult = { + /** Successfully fetched rates */ + rates: CurrencyRateState['currencyRates']; + /** Currencies that failed and need fallback or null state */ + failedCurrencies: Record; }; /** * Controller that passively polls on a set interval for an exchange rate from the current network * asset to the user's preferred currency. */ -export class CurrencyRateController extends BaseControllerV2< +export class CurrencyRateController extends StaticIntervalPollingController()< typeof name, CurrencyRateState, CurrencyRateMessenger > { - private readonly mutex = new Mutex(); + readonly #mutex = new Mutex(); - private intervalId?: ReturnType; + readonly #includeUsdRate: boolean; - private readonly intervalDelay; + readonly #useExternalServices: () => boolean; - private readonly fetchExchangeRate; + readonly #tokenPricesService: AbstractTokenPricesService; - private readonly includeUsdRate; - - /** - * A boolean that controls whether or not network requests can be made by the controller - */ - #enabled; + readonly #isDeprecated: () => boolean; /** * Creates a CurrencyRateController instance. @@ -100,22 +148,33 @@ export class CurrencyRateController extends BaseControllerV2< * @param options - Constructor options. * @param options.includeUsdRate - Keep track of the USD rate in addition to the current currency rate. * @param options.interval - The polling interval, in milliseconds. - * @param options.messenger - A reference to the messaging system. + * @param options.messenger - A reference to the messenger. * @param options.state - Initial state to set on this controller. - * @param options.fetchExchangeRate - Fetches the exchange rate from an external API. This option is primarily meant for use in unit tests. + * @param options.useExternalServices - Feature Switch for using external services (default: true) + * @param options.tokenPricesService - An object in charge of retrieving token prices + * @param options.isDeprecated - Optional function that returns true to completely + * disable this controller (no requests, no state updates). When it returns + * `true`, `currencyRates` is reset to `{}` at construction and at every entry point, + * so no stale rates remain in state. The function is evaluated dynamically + * on each entry point so it can be toggled at runtime. Intended for use when + * a higher-level controller (e.g. AssetsController) supersedes this one. */ constructor({ includeUsdRate = false, interval = 180000, + useExternalServices = () => true, + isDeprecated = (): boolean => false, messenger, state, - fetchExchangeRate = defaultFetchExchangeRate, + tokenPricesService, }: { includeUsdRate?: boolean; interval?: number; messenger: CurrencyRateMessenger; state?: Partial; - fetchExchangeRate?: typeof defaultFetchExchangeRate; + useExternalServices?: () => boolean; + isDeprecated?: () => boolean; + tokenPricesService: AbstractTokenPricesService; }) { super({ name, @@ -123,167 +182,363 @@ export class CurrencyRateController extends BaseControllerV2< messenger, state: { ...defaultState, ...state }, }); - this.includeUsdRate = includeUsdRate; - this.intervalDelay = interval; - this.fetchExchangeRate = fetchExchangeRate; - this.#enabled = false; + this.#includeUsdRate = includeUsdRate; + this.#useExternalServices = useExternalServices; + this.setIntervalLength(interval); + this.#tokenPricesService = tokenPricesService; + this.#isDeprecated = isDeprecated; + + if (this.#isDeprecated()) { + this.#enforceDisabledState(); + } + + this.messenger.registerMethodActionHandlers( + this, + MESSENGER_EXPOSED_METHODS, + ); } /** - * Start polling for the currency rate. + * Clears all persisted `currencyRates` so that no stale rates remain in state. + * + * Called from every entry point when `isDeprecated()` is true so that a + * runtime toggle propagates to state immediately, even if the controller was + * originally constructed while it was enabled. The update is skipped when + * `currencyRates` is already empty to avoid emitting redundant state changes. */ - async start() { - this.#enabled = true; - - await this.startPolling(); + #enforceDisabledState(): void { + if (Object.keys(this.state.currencyRates).length === 0) { + return; + } + this.update((state) => { + state.currencyRates = {}; + }); } /** - * Stop polling for the currency rate. + * Sets a currency to track. + * + * @param currentCurrency - ISO 4217 currency code. */ - stop() { - this.#enabled = false; + async setCurrentCurrency(currentCurrency: string): Promise { + if (this.#isDeprecated()) { + this.#enforceDisabledState(); + return; + } - this.stopPolling(); + const releaseLock = await this.#mutex.acquire(); + const nativeCurrencies = Object.keys(this.state.currencyRates); + try { + this.update(() => { + return { + ...defaultState, + currentCurrency, + }; + }); + } finally { + releaseLock(); + } + // TODO: Either fix this lint violation or explain why it's necessary to ignore. + // eslint-disable-next-line @typescript-eslint/no-floating-promises + this.updateExchangeRate(nativeCurrencies); } /** - * Prepare to discard this controller. + * Attempts to fetch exchange rates from the primary Price API. * - * This stops any active polling. + * @param nativeCurrenciesToFetch - Map of native currency to the currency symbol to fetch. + * @param currentCurrency - The current fiat currency to get rates for. + * @returns Object containing successful rates and currencies that failed. */ - override destroy() { - super.destroy(); - this.stopPolling(); + async #fetchRatesFromPriceApi( + nativeCurrenciesToFetch: Record, + currentCurrency: string, + ): Promise { + const rates: CurrencyRateState['currencyRates'] = {}; + let failedCurrencies: Record = {}; + + try { + const response = await this.#tokenPricesService.fetchExchangeRates({ + baseCurrency: currentCurrency, + includeUsdRate: this.#includeUsdRate, + cryptocurrencies: [...new Set(Object.values(nativeCurrenciesToFetch))], + }); + + Object.entries(nativeCurrenciesToFetch).forEach( + ([nativeCurrency, fetchedCurrency]) => { + const rate = response[fetchedCurrency.toLowerCase()]; + + if (rate?.value) { + rates[nativeCurrency] = { + conversionDate: Date.now() / 1000, + conversionRate: boundedPrecisionNumber(1 / rate.value), + usdConversionRate: rate?.usd + ? boundedPrecisionNumber(1 / rate.usd) + : null, + }; + } else { + failedCurrencies[nativeCurrency] = fetchedCurrency; + } + }, + ); + } catch (error) { + console.error('Failed to fetch exchange rates.', error); + failedCurrencies = { ...nativeCurrenciesToFetch }; + } + + return { rates, failedCurrencies }; } /** - * Sets a currency to track. + * Fetches exchange rates from the token prices service as a fallback. + * This method is designed to never throw - all errors are handled internally + * and result in currencies being marked as failed. * - * @param currentCurrency - ISO 4217 currency code. + * @param currenciesToFetch - Map of native currencies that need fallback fetching. + * @param currentCurrency - The current fiat currency to get rates for. + * @returns Object containing successful rates and currencies that failed. */ - async setCurrentCurrency(currentCurrency: string) { - this.update((state) => { - state.pendingCurrentCurrency = currentCurrency; - }); - await this.updateExchangeRate(); + async #fetchRatesFromTokenPricesService( + currenciesToFetch: Record, + currentCurrency: string, + ): Promise { + try { + const rates: CurrencyRateState['currencyRates'] = {}; + const failedCurrencies: Record = {}; + + const networkControllerState = this.messenger.call( + 'NetworkController:getState', + ); + const networkConfigurations = + networkControllerState.networkConfigurationsByChainId; + + // Build a map of nativeCurrency -> chainId for currencies to fetch + const currencyToChainIds = Object.entries(currenciesToFetch).reduce< + Record + >((acc, [nativeCurrency, fetchedCurrency]) => { + const matchingEntry = ( + Object.entries(networkConfigurations) as [Hex, NetworkConfiguration][] + ).find( + ([, config]) => + config.nativeCurrency.toUpperCase() === + fetchedCurrency.toUpperCase(), + ); + + if (matchingEntry) { + acc[nativeCurrency] = { fetchedCurrency, chainId: matchingEntry[0] }; + } else { + // No matching network configuration - mark as failed + failedCurrencies[nativeCurrency] = fetchedCurrency; + } + return acc; + }, {}); + + const currencyToChainIdsEntries = Object.entries(currencyToChainIds); + const ratesResults = await Promise.allSettled( + currencyToChainIdsEntries.map(async ([nativeCurrency, { chainId }]) => { + const nativeTokenAddress = getNativeTokenAddress(chainId); + const tokenPrices = await this.#tokenPricesService.fetchTokenPrices({ + assets: [{ chainId, tokenAddress: nativeTokenAddress }], + currency: currentCurrency, + }); + + const tokenPrice = tokenPrices.find( + (item) => + item.tokenAddress.toLowerCase() === + nativeTokenAddress.toLowerCase(), + ); + + return { + nativeCurrency, + conversionDate: tokenPrice ? Date.now() / 1000 : null, + conversionRate: tokenPrice?.price + ? boundedPrecisionNumber(tokenPrice.price) + : null, + usdConversionRate: null, + }; + }), + ); + + ratesResults.forEach((result, index) => { + const [nativeCurrency, { fetchedCurrency, chainId }] = + currencyToChainIdsEntries[index]; + + if (result.status === 'fulfilled' && result.value.conversionRate) { + rates[nativeCurrency] = { + conversionDate: result.value.conversionDate, + conversionRate: result.value.conversionRate, + usdConversionRate: result.value.usdConversionRate, + }; + } else { + if (result.status === 'rejected') { + console.error( + `Failed to fetch token price for ${nativeCurrency} on chain ${chainId}`, + result.reason, + ); + } + failedCurrencies[nativeCurrency] = fetchedCurrency; + } + }); + + return { rates, failedCurrencies }; + } catch (error) { + console.error( + 'Failed to fetch exchange rates from token prices service.', + error, + ); + // Return all currencies as failed + return { rates: {}, failedCurrencies: { ...currenciesToFetch } }; + } } /** - * Sets a new native currency. + * Creates null rate entries for currencies that couldn't be fetched. * - * @param symbol - Symbol for the base asset. + * @param currencies - Array of currency symbols to create null entries for. + * @returns Null rate entries for all provided currencies. */ - async setNativeCurrency(symbol: string) { - this.update((state) => { - state.pendingNativeCurrency = symbol; - }); - await this.updateExchangeRate(); - } - - private stopPolling() { - if (this.intervalId) { - clearInterval(this.intervalId); - } + #createNullRatesForCurrencies( + currencies: string[], + ): CurrencyRateState['currencyRates'] { + return currencies.reduce( + (acc, nativeCurrency) => { + acc[nativeCurrency] = { + conversionDate: null, + conversionRate: null, + usdConversionRate: null, + }; + return acc; + }, + {}, + ); } /** - * Starts a new polling interval. + * Fetches exchange rates with fallback logic. + * First tries the Price API, then falls back to token prices service for any failed currencies. + * + * @param nativeCurrenciesToFetch - Map of native currency to the currency symbol to fetch. + * @returns Exchange rates for all requested currencies. */ - private async startPolling(): Promise { - this.stopPolling(); - // TODO: Expose polling currency rate update errors + async #fetchExchangeRatesWithFallback( + nativeCurrenciesToFetch: Record, + ): Promise { + const { currentCurrency } = this.state; - await safelyExecute(async () => await this.updateExchangeRate()); + // Step 1: Try the Price API exchange rates first + const { + rates: ratesPriceApi, + failedCurrencies: failedCurrenciesFromPriceApi, + } = await this.#fetchRatesFromPriceApi( + nativeCurrenciesToFetch, + currentCurrency, + ); + + // Step 2: If all currencies succeeded, return early + if (Object.keys(failedCurrenciesFromPriceApi).length === 0) { + return ratesPriceApi; + } - this.intervalId = setInterval(async () => { - await safelyExecute(async () => await this.updateExchangeRate()); - }, this.intervalDelay); + // Step 3: Fallback using token prices service for failed currencies + const { + rates: ratesFromFallback, + failedCurrencies: failedCurrenciesFromFallback, + } = await this.#fetchRatesFromTokenPricesService( + failedCurrenciesFromPriceApi, + currentCurrency, + ); + + // Step 4: Create null rates for currencies that failed both approaches + const nullRates = this.#createNullRatesForCurrencies( + Object.keys(failedCurrenciesFromFallback), + ); + + // Step 5: Merge all results - Price API rates take priority, then fallback, then null rates + return { + ...nullRates, + ...ratesFromFallback, + ...ratesPriceApi, + }; } /** - * Updates exchange rate for the current currency. + * Updates the exchange rate for the current currency and native currency pairs. * - * @returns The controller state. + * @param nativeCurrencies - The native currency symbols to fetch exchange rates for. */ - async updateExchangeRate(): Promise { - if (!this.#enabled) { - console.info( - '[CurrencyRateController] Not updating exchange rate since network requests have been disabled', - ); - return this.state; + async updateExchangeRate( + nativeCurrencies: (string | undefined)[], + ): Promise { + if (this.#isDeprecated()) { + this.#enforceDisabledState(); + return; + } + + if (!this.#useExternalServices()) { + return; } - const releaseLock = await this.mutex.acquire(); - const { - currentCurrency: stateCurrentCurrency, - nativeCurrency: stateNativeCurrency, - pendingCurrentCurrency, - pendingNativeCurrency, - } = this.state; - - let conversionDate: number | null = null; - let conversionRate: number | null = null; - let usdConversionRate: number | null = null; - const currentCurrency = pendingCurrentCurrency ?? stateCurrentCurrency; - const nativeCurrency = pendingNativeCurrency ?? stateNativeCurrency; - - // For preloaded testnets (Goerli, Sepolia) we want to fetch exchange rate for real ETH. - const nativeCurrencyForExchangeRate = Object.values( - TESTNET_TICKER_SYMBOLS, - ).includes(nativeCurrency) - ? FALL_BACK_VS_CURRENCY // ETH - : nativeCurrency; + const releaseLock = await this.#mutex.acquire(); try { - if ( - currentCurrency && - nativeCurrency && - // if either currency is an empty string we can skip the comparison - // because it will result in an error from the api and ultimately - // a null conversionRate either way. - currentCurrency !== '' && - nativeCurrency !== '' - ) { - const fetchExchangeRateResponse = await this.fetchExchangeRate( - currentCurrency, - nativeCurrencyForExchangeRate, - this.includeUsdRate, - ); + // For preloaded testnets (Goerli, Sepolia) we want to fetch exchange rate for real ETH. + // Map each native currency to the symbol we want to fetch for it. + const testnetSymbols = Object.values(TESTNET_TICKER_SYMBOLS); + const nativeCurrenciesToFetch = nativeCurrencies.reduce< + Record + >((acc, nativeCurrency) => { + if (!nativeCurrency) { + return acc; + } + + acc[nativeCurrency] = testnetSymbols.includes(nativeCurrency) + ? FALL_BACK_VS_CURRENCY + : nativeCurrency; + return acc; + }, {}); + + const rates = await this.#fetchExchangeRatesWithFallback( + nativeCurrenciesToFetch, + ); - conversionRate = fetchExchangeRateResponse.conversionRate; - usdConversionRate = fetchExchangeRateResponse.usdConversionRate; - conversionDate = Date.now() / 1000; - } + this.update((state) => { + state.currencyRates = { + ...state.currencyRates, + ...rates, + }; + }); } catch (error) { - if ( - !( - error instanceof Error && - error.message.includes('market does not exist for this coin pair') - ) - ) { - throw error; - } + console.error('Failed to fetch exchange rates.', error); + throw error; } finally { - try { - this.update(() => { - return { - conversionDate, - conversionRate, - // we currently allow and handle an empty string as a valid nativeCurrency - // in cases where a user has not entered a native ticker symbol for a custom network - // currentCurrency is not from user input but this protects us from unexpected changes. - nativeCurrency, - currentCurrency, - pendingCurrentCurrency: null, - pendingNativeCurrency: null, - usdConversionRate, - }; - }); - } finally { - releaseLock(); - } + releaseLock(); } - return this.state; + } + + /** + * Prepare to discard this controller. + * + * This stops any active polling. + */ + override destroy(): void { + super.destroy(); + this.stopAllPolling(); + } + + /** + * Updates exchange rate for the current currency. + * + * @param input - The input for the poll. + * @param input.nativeCurrencies - The native currency symbols to poll prices for. + */ + async _executePoll({ + nativeCurrencies, + }: CurrencyRatePollingInput): Promise { + if (this.#isDeprecated()) { + this.#enforceDisabledState(); + return; + } + + await this.updateExchangeRate(nativeCurrencies); } } diff --git a/packages/assets-controllers/src/DeFiPositionsController/DeFiPositionsController.test.ts b/packages/assets-controllers/src/DeFiPositionsController/DeFiPositionsController.test.ts new file mode 100644 index 00000000000..68dd28693ed --- /dev/null +++ b/packages/assets-controllers/src/DeFiPositionsController/DeFiPositionsController.test.ts @@ -0,0 +1,551 @@ +import { deriveStateFromMetadata } from '@metamask/base-controller'; +import { BtcAccountType, EthAccountType } from '@metamask/keyring-api'; +import { MOCK_ANY_NAMESPACE, Messenger } from '@metamask/messenger'; +import type { + MessengerActions, + MessengerEvents, + MockAnyNamespace, +} from '@metamask/messenger'; + +import { flushPromises } from '../../../../tests/helpers.js'; +import { createMockInternalAccount } from '../../../accounts-controller/tests/mocks.js'; +import type { + InternalAccount, + TransactionMeta, +} from '../../../transaction-controller/src/types.js'; +import * as calculateDefiMetrics from './calculate-defi-metrics.js'; +import type { DeFiPositionsControllerMessenger } from './DeFiPositionsController.js'; +import { + DeFiPositionsController, + getDefaultDefiPositionsControllerState, +} from './DeFiPositionsController.js'; +import * as fetchPositions from './fetch-positions.js'; +import * as groupDeFiPositions from './group-defi-positions.js'; + +const GROUP_ACCOUNTS = [ + createMockInternalAccount({ + id: 'mock-id-1', + address: '0x0000000000000000000000000000000000000001', + type: EthAccountType.Eoa, + }), + createMockInternalAccount({ + id: 'mock-id-btc-1', + type: BtcAccountType.P2wpkh, + }), +]; + +const GROUP_ACCOUNTS_NO_EVM = [ + createMockInternalAccount({ + id: 'mock-id-btc-3', + type: BtcAccountType.P2wpkh, + }), +]; + +type AllDefiPositionsControllerActions = + MessengerActions; + +type AllDefiPositionsControllerEvents = + MessengerEvents; + +type RootMessenger = Messenger< + MockAnyNamespace, + AllDefiPositionsControllerActions, + AllDefiPositionsControllerEvents +>; + +/** + * Sets up the controller with the given configuration + * + * @param config - Configuration for the mock setup + * @param config.isEnabled - Whether the controller is enabled + * @param config.mockTrackEvent - The mock track event function + * @param config.mockFetchPositions - The mock fetch positions function + * @param config.mockGroupDeFiPositions - The mock group positions function + * @param config.mockCalculateDefiMetrics - The mock calculate metrics function + * @param config.mockGroupAccounts - The mock group accounts function + * @returns The controller instance, trigger functions, and spies + */ +function setupController({ + isEnabled, + mockTrackEvent, + mockFetchPositions = jest.fn(), + mockGroupDeFiPositions = jest.fn(), + mockCalculateDefiMetrics = jest.fn(), + mockGroupAccounts = GROUP_ACCOUNTS, +}: { + isEnabled?: () => boolean; + mockFetchPositions?: jest.Mock; + mockGroupDeFiPositions?: jest.Mock; + mockCalculateDefiMetrics?: jest.Mock; + mockTrackEvent?: jest.Mock; + mockGroupAccounts?: InternalAccount[]; +} = {}) { + const messenger: RootMessenger = new Messenger({ + namespace: MOCK_ANY_NAMESPACE, + }); + + messenger.registerActionHandler( + 'AccountTreeController:getAccountsFromSelectedAccountGroup', + () => mockGroupAccounts, + ); + + const defiPositionControllerMessenger = new Messenger< + 'DeFiPositionsController', + AllDefiPositionsControllerActions, + AllDefiPositionsControllerEvents, + RootMessenger + >({ + namespace: 'DeFiPositionsController', + parent: messenger, + }); + messenger.delegate({ + messenger: defiPositionControllerMessenger, + actions: ['AccountTreeController:getAccountsFromSelectedAccountGroup'], + events: [ + 'KeyringController:lock', + 'TransactionController:transactionConfirmed', + 'AccountTreeController:selectedAccountGroupChange', + ], + }); + + const buildPositionsFetcherSpy = jest.spyOn( + fetchPositions, + 'buildPositionFetcher', + ); + + buildPositionsFetcherSpy.mockReturnValue(mockFetchPositions); + + const groupDeFiPositionsSpy = jest.spyOn( + groupDeFiPositions, + 'groupDeFiPositions', + ); + + const calculateDefiMetricsSpy = jest.spyOn( + calculateDefiMetrics, + 'calculateDeFiPositionMetrics', + ); + calculateDefiMetricsSpy.mockImplementation(mockCalculateDefiMetrics); + + groupDeFiPositionsSpy.mockImplementation(mockGroupDeFiPositions); + + const controller = new DeFiPositionsController({ + messenger: defiPositionControllerMessenger, + isEnabled, + trackEvent: mockTrackEvent, + }); + + const updateSpy = jest.spyOn(controller, 'update' as never); + + const triggerLock = (): void => { + messenger.publish('KeyringController:lock'); + }; + + const triggerTransactionConfirmed = (address: string): void => { + messenger.publish('TransactionController:transactionConfirmed', { + txParams: { + from: address, + }, + } as TransactionMeta); + }; + + const triggerAccountGroupChange = (): void => { + messenger.publish( + 'AccountTreeController:selectedAccountGroupChange', + 'entropy:test/0', + '', + ); + }; + + return { + controller, + triggerLock, + triggerTransactionConfirmed, + triggerAccountGroupChange, + buildPositionsFetcherSpy, + updateSpy, + mockFetchPositions, + mockGroupDeFiPositions, + mockCalculateDefiMetrics, + mockTrackEvent, + }; +} + +describe('DeFiPositionsController', () => { + beforeEach(() => { + jest.useFakeTimers(); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + it('sets default state', async () => { + const { controller } = setupController(); + + expect(controller.state).toStrictEqual( + getDefaultDefiPositionsControllerState(), + ); + }); + + it('stops polling if the keyring is locked', async () => { + const { controller, triggerLock } = setupController(); + const stopAllPollingSpy = jest.spyOn(controller, 'stopAllPolling'); + + triggerLock(); + + await flushPromises(); + + expect(stopAllPollingSpy).toHaveBeenCalled(); + }); + + it('fetches positions for the selected account when polling', async () => { + const mockFetchPositions = jest.fn().mockResolvedValue('mock-fetch-data-1'); + const mockGroupDeFiPositions = jest + .fn() + .mockReturnValue('mock-grouped-data-1'); + + const { controller, buildPositionsFetcherSpy, updateSpy } = setupController( + { + mockFetchPositions, + mockGroupDeFiPositions, + }, + ); + + await controller._executePoll(); + + expect(controller.state).toStrictEqual({ + allDeFiPositions: { + [GROUP_ACCOUNTS[0].address]: 'mock-grouped-data-1', + }, + allDeFiPositionsCount: {}, + }); + + expect(buildPositionsFetcherSpy).toHaveBeenCalled(); + + expect(mockFetchPositions).toHaveBeenCalledWith(GROUP_ACCOUNTS[0].address); + expect(mockFetchPositions).toHaveBeenCalledTimes(1); + + expect(mockGroupDeFiPositions).toHaveBeenCalledWith('mock-fetch-data-1'); + expect(mockGroupDeFiPositions).toHaveBeenCalledTimes(1); + + expect(updateSpy).toHaveBeenCalledTimes(1); + }); + + it('does not fetch positions when polling and the controller is disabled', async () => { + const { + controller, + buildPositionsFetcherSpy, + updateSpy, + mockFetchPositions, + mockGroupDeFiPositions, + } = setupController({ + isEnabled: () => false, + }); + + await controller._executePoll(); + + expect(controller.state).toStrictEqual( + getDefaultDefiPositionsControllerState(), + ); + + expect(buildPositionsFetcherSpy).toHaveBeenCalled(); + + expect(mockFetchPositions).not.toHaveBeenCalled(); + + expect(mockGroupDeFiPositions).not.toHaveBeenCalled(); + + expect(updateSpy).not.toHaveBeenCalled(); + }); + + it('fetches positions for an account when a transaction is confirmed', async () => { + const mockFetchPositions = jest.fn().mockResolvedValue('mock-fetch-data-1'); + const mockGroupDeFiPositions = jest + .fn() + .mockReturnValue('mock-grouped-data-1'); + + const { + controller, + triggerTransactionConfirmed, + buildPositionsFetcherSpy, + updateSpy, + } = setupController({ + mockFetchPositions, + mockGroupDeFiPositions, + }); + + triggerTransactionConfirmed(GROUP_ACCOUNTS[0].address); + await flushPromises(); + + expect(controller.state).toStrictEqual({ + allDeFiPositions: { + [GROUP_ACCOUNTS[0].address]: 'mock-grouped-data-1', + }, + allDeFiPositionsCount: {}, + }); + + expect(buildPositionsFetcherSpy).toHaveBeenCalled(); + + expect(mockFetchPositions).toHaveBeenCalledWith(GROUP_ACCOUNTS[0].address); + expect(mockFetchPositions).toHaveBeenCalledTimes(1); + + expect(mockGroupDeFiPositions).toHaveBeenCalledWith('mock-fetch-data-1'); + expect(mockGroupDeFiPositions).toHaveBeenCalledTimes(1); + + expect(updateSpy).toHaveBeenCalledTimes(1); + }); + + it('does not fetch positions for an account when a transaction is confirmed and the controller is disabled', async () => { + const { + controller, + triggerTransactionConfirmed, + buildPositionsFetcherSpy, + updateSpy, + mockFetchPositions, + mockGroupDeFiPositions, + } = setupController({ + isEnabled: () => false, + }); + + triggerTransactionConfirmed(GROUP_ACCOUNTS[0].address); + await flushPromises(); + + expect(controller.state).toStrictEqual( + getDefaultDefiPositionsControllerState(), + ); + + expect(buildPositionsFetcherSpy).toHaveBeenCalled(); + + expect(mockFetchPositions).not.toHaveBeenCalled(); + + expect(mockGroupDeFiPositions).not.toHaveBeenCalled(); + + expect(updateSpy).not.toHaveBeenCalled(); + }); + + it('does not fetch positions for an account when a transaction is confirmed for a different than the selected account', async () => { + const { + controller, + triggerTransactionConfirmed, + buildPositionsFetcherSpy, + updateSpy, + mockFetchPositions, + mockGroupDeFiPositions, + } = setupController(); + + triggerTransactionConfirmed('0x0000000000000000000000000000000000000002'); + await flushPromises(); + + expect(controller.state).toStrictEqual( + getDefaultDefiPositionsControllerState(), + ); + + expect(buildPositionsFetcherSpy).toHaveBeenCalled(); + + expect(mockFetchPositions).not.toHaveBeenCalled(); + + expect(mockGroupDeFiPositions).not.toHaveBeenCalled(); + + expect(updateSpy).not.toHaveBeenCalled(); + }); + + it('fetches positions for the selected evm account when the account group changes', async () => { + const mockFetchPositions = jest.fn().mockResolvedValue('mock-fetch-data-1'); + const mockGroupDeFiPositions = jest + .fn() + .mockReturnValue('mock-grouped-data-1'); + + const { + controller, + triggerAccountGroupChange, + buildPositionsFetcherSpy, + updateSpy, + } = setupController({ + mockFetchPositions, + mockGroupDeFiPositions, + }); + + triggerAccountGroupChange(); + await flushPromises(); + + expect(controller.state).toStrictEqual({ + allDeFiPositions: { + [GROUP_ACCOUNTS[0].address]: 'mock-grouped-data-1', + }, + allDeFiPositionsCount: {}, + }); + + expect(buildPositionsFetcherSpy).toHaveBeenCalled(); + + expect(mockFetchPositions).toHaveBeenCalledWith(GROUP_ACCOUNTS[0].address); + expect(mockFetchPositions).toHaveBeenCalledTimes(1); + + expect(mockGroupDeFiPositions).toHaveBeenCalledWith('mock-fetch-data-1'); + expect(mockGroupDeFiPositions).toHaveBeenCalledTimes(1); + + expect(updateSpy).toHaveBeenCalledTimes(1); + }); + + it('does not fetch positions when the account group changes and there is no evm account', async () => { + const { + controller, + triggerAccountGroupChange, + buildPositionsFetcherSpy, + updateSpy, + mockFetchPositions, + mockGroupDeFiPositions, + } = setupController({ + mockGroupAccounts: GROUP_ACCOUNTS_NO_EVM, + }); + + triggerAccountGroupChange(); + await flushPromises(); + + expect(controller.state).toStrictEqual( + getDefaultDefiPositionsControllerState(), + ); + + expect(buildPositionsFetcherSpy).toHaveBeenCalled(); + + expect(mockFetchPositions).not.toHaveBeenCalled(); + + expect(mockGroupDeFiPositions).not.toHaveBeenCalled(); + + expect(updateSpy).not.toHaveBeenCalled(); + }); + + it('updates defi count and calls metrics', async () => { + const mockGroupDeFiPositions = jest + .fn() + .mockReturnValue('mock-grouped-data-1'); + + const mockTrackEvent = jest.fn(); + + const mockMetric1 = { + event: 'mock-event', + category: 'mock-category', + properties: { + totalPositions: 1, + totalMarketValueUSD: 1, + }, + }; + + const mockCalculateDefiMetrics = jest.fn().mockReturnValueOnce(mockMetric1); + + const { controller } = setupController({ + mockGroupDeFiPositions, + mockCalculateDefiMetrics, + mockTrackEvent, + }); + + await controller._executePoll(); + + expect(mockCalculateDefiMetrics).toHaveBeenCalled(); + expect(mockCalculateDefiMetrics).toHaveBeenCalledWith( + controller.state.allDeFiPositions[GROUP_ACCOUNTS[0].address], + ); + + expect(controller.state.allDeFiPositionsCount).toStrictEqual({ + [GROUP_ACCOUNTS[0].address]: mockMetric1.properties.totalPositions, + }); + + expect(mockTrackEvent).toHaveBeenCalledWith(mockMetric1); + expect(mockTrackEvent).toHaveBeenCalledTimes(1); + }); + + it('only calls track metric when position count changes', async () => { + const mockGroupDeFiPositions = jest + .fn() + .mockReturnValue('mock-grouped-data-1'); + const mockTrackEvent = jest.fn(); + + const mockMetric1 = { + event: 'mock-event', + category: 'mock-category', + properties: { + totalPositions: 1, + totalMarketValueUSD: 1, + }, + }; + + const mockCalculateDefiMetrics = jest + .fn() + .mockReturnValueOnce(mockMetric1) + .mockReturnValueOnce(mockMetric1); + + const { controller, triggerTransactionConfirmed } = setupController({ + mockGroupDeFiPositions, + mockCalculateDefiMetrics, + mockTrackEvent, + }); + + triggerTransactionConfirmed(GROUP_ACCOUNTS[0].address); + triggerTransactionConfirmed(GROUP_ACCOUNTS[0].address); + await flushPromises(); + + expect(mockCalculateDefiMetrics).toHaveBeenCalled(); + expect(mockCalculateDefiMetrics).toHaveBeenCalledWith( + controller.state.allDeFiPositions[GROUP_ACCOUNTS[0].address], + ); + + expect(controller.state.allDeFiPositionsCount).toStrictEqual({ + [GROUP_ACCOUNTS[0].address]: mockMetric1.properties.totalPositions, + }); + + expect(mockTrackEvent).toHaveBeenCalledTimes(1); + expect(mockTrackEvent).toHaveBeenCalledWith(mockMetric1); + }); + + describe('metadata', () => { + it('includes expected state in debug snapshots', () => { + const { controller } = setupController(); + + expect( + deriveStateFromMetadata( + controller.state, + controller.metadata, + 'includeInDebugSnapshot', + ), + ).toMatchInlineSnapshot(`{}`); + }); + + it('includes expected state in state logs', () => { + const { controller } = setupController(); + + expect( + deriveStateFromMetadata( + controller.state, + controller.metadata, + 'includeInStateLogs', + ), + ).toMatchInlineSnapshot(`{}`); + }); + + it('persists expected state', () => { + const { controller } = setupController(); + + expect( + deriveStateFromMetadata( + controller.state, + controller.metadata, + 'persist', + ), + ).toMatchInlineSnapshot(`{}`); + }); + + it('exposes expected state to UI', () => { + const { controller } = setupController(); + + expect( + deriveStateFromMetadata( + controller.state, + controller.metadata, + 'usedInUi', + ), + ).toMatchInlineSnapshot(` + { + "allDeFiPositions": {}, + } + `); + }); + }); +}); diff --git a/packages/assets-controllers/src/DeFiPositionsController/DeFiPositionsController.ts b/packages/assets-controllers/src/DeFiPositionsController/DeFiPositionsController.ts new file mode 100644 index 00000000000..9642fd52a44 --- /dev/null +++ b/packages/assets-controllers/src/DeFiPositionsController/DeFiPositionsController.ts @@ -0,0 +1,290 @@ +import type { + AccountTreeControllerGetAccountsFromSelectedAccountGroupAction, + AccountTreeControllerSelectedAccountGroupChangeEvent, +} from '@metamask/account-tree-controller'; +import type { + ControllerGetStateAction, + ControllerStateChangeEvent, + StateMetadata, +} from '@metamask/base-controller'; +import { isEvmAccountType } from '@metamask/keyring-api'; +import type { KeyringControllerLockEvent } from '@metamask/keyring-controller'; +import type { InternalAccount } from '@metamask/keyring-internal-api'; +import type { Messenger } from '@metamask/messenger'; +import { StaticIntervalPollingController } from '@metamask/polling-controller'; +import type { TransactionControllerTransactionConfirmedEvent } from '@metamask/transaction-controller'; +import type { Hex } from '@metamask/utils'; + +import { calculateDeFiPositionMetrics } from './calculate-defi-metrics.js'; +import type { DefiPositionResponse } from './fetch-positions.js'; +import { buildPositionFetcher } from './fetch-positions.js'; +import { groupDeFiPositions } from './group-defi-positions.js'; +import type { GroupedDeFiPositions } from './group-defi-positions.js'; + +const TEN_MINUTES_IN_MS = 600_000; + +const controllerName = 'DeFiPositionsController'; + +export type GroupedDeFiPositionsPerChain = { + [chain: Hex]: GroupedDeFiPositions; +}; + +export type TrackingEventPayload = { + event: string; + category: string; + properties: { + totalPositions: number; + totalMarketValueUSD: number; + breakdown?: { + protocolId: string; + marketValueUSD: number; + chainId: Hex; + count: number; + }[]; + }; +}; + +type TrackEventHook = (event: TrackingEventPayload) => void; + +export type DeFiPositionsControllerState = { + /** + * Object containing DeFi positions per account and network + */ + allDeFiPositions: { + [accountAddress: string]: GroupedDeFiPositionsPerChain | null; + }; + + /** + * Object containing DeFi positions count per account + */ + allDeFiPositionsCount: { + [accountAddress: string]: number; + }; +}; + +const controllerMetadata: StateMetadata = { + allDeFiPositions: { + includeInStateLogs: false, + persist: false, + includeInDebugSnapshot: false, + usedInUi: true, + }, + allDeFiPositionsCount: { + includeInStateLogs: false, + persist: false, + includeInDebugSnapshot: false, + usedInUi: false, + }, +}; + +export const getDefaultDefiPositionsControllerState = + (): DeFiPositionsControllerState => { + return { + allDeFiPositions: {}, + allDeFiPositionsCount: {}, + }; + }; + +export type DeFiPositionsControllerActions = + DeFiPositionsControllerGetStateAction; + +export type DeFiPositionsControllerGetStateAction = ControllerGetStateAction< + typeof controllerName, + DeFiPositionsControllerState +>; + +export type DeFiPositionsControllerEvents = + DeFiPositionsControllerStateChangeEvent; + +export type DeFiPositionsControllerStateChangeEvent = + ControllerStateChangeEvent< + typeof controllerName, + DeFiPositionsControllerState + >; + +/** + * The external actions available to the {@link DeFiPositionsController}. + */ +export type AllowedActions = + AccountTreeControllerGetAccountsFromSelectedAccountGroupAction; + +/** + * The external events available to the {@link DeFiPositionsController}. + */ +export type AllowedEvents = + | KeyringControllerLockEvent + | TransactionControllerTransactionConfirmedEvent + | AccountTreeControllerSelectedAccountGroupChangeEvent; + +/** + * The messenger of the {@link DeFiPositionsController}. + */ +export type DeFiPositionsControllerMessenger = Messenger< + typeof controllerName, + DeFiPositionsControllerActions | AllowedActions, + DeFiPositionsControllerEvents | AllowedEvents +>; + +/** + * Controller that stores assets and exposes convenience methods + */ +export class DeFiPositionsController extends StaticIntervalPollingController()< + typeof controllerName, + DeFiPositionsControllerState, + DeFiPositionsControllerMessenger +> { + readonly #fetchPositions: ( + accountAddress: string, + ) => Promise; + + readonly #isEnabled: () => boolean; + + readonly #trackEvent?: TrackEventHook; + + /** + * DeFiPositionsController constuctor + * + * @param options - Constructor options. + * @param options.messenger - The controller messenger. + * @param options.isEnabled - Function that returns whether the controller is enabled. (default: () => true) + * @param options.trackEvent - Function to track events. (default: undefined) + */ + constructor({ + messenger, + isEnabled = () => true, + trackEvent, + }: { + messenger: DeFiPositionsControllerMessenger; + isEnabled?: () => boolean; + trackEvent?: TrackEventHook; + }) { + super({ + name: controllerName, + metadata: controllerMetadata, + messenger, + state: getDefaultDefiPositionsControllerState(), + }); + + this.setIntervalLength(TEN_MINUTES_IN_MS); + + this.#fetchPositions = buildPositionFetcher(); + this.#isEnabled = isEnabled; + + this.messenger.subscribe('KeyringController:lock', () => { + this.stopAllPolling(); + }); + + this.messenger.subscribe( + 'TransactionController:transactionConfirmed', + async (transactionMeta) => { + const selectedAddress = this.#getSelectedEvmAdress(); + + if ( + selectedAddress?.toLowerCase() !== + transactionMeta.txParams.from.toLowerCase() + ) { + return; + } + + await this.#updateAccountPositions(selectedAddress); + }, + ); + + this.messenger.subscribe( + 'AccountTreeController:selectedAccountGroupChange', + async () => { + const selectedAddress = this.#getSelectedEvmAdress(); + + if (!selectedAddress) { + return; + } + + await this.#updateAccountPositions(selectedAddress); + }, + ); + + this.#trackEvent = trackEvent; + } + + async _executePoll(): Promise { + if (!this.#isEnabled()) { + return; + } + + const selectedAddress = this.#getSelectedEvmAdress(); + + if (!selectedAddress) { + return; + } + + const accountPositions = await this.#fetchAccountPositions(selectedAddress); + + this.update((state) => { + state.allDeFiPositions[selectedAddress] = accountPositions; + }); + } + + async #updateAccountPositions(accountAddress: string): Promise { + if (!this.#isEnabled()) { + return; + } + + const accountPositionsPerChain = + await this.#fetchAccountPositions(accountAddress); + + this.update((state) => { + state.allDeFiPositions[accountAddress] = accountPositionsPerChain; + }); + } + + async #fetchAccountPositions( + accountAddress: string, + ): Promise { + try { + const defiPositionsResponse = await this.#fetchPositions(accountAddress); + + const groupedDeFiPositions = groupDeFiPositions(defiPositionsResponse); + + try { + this.#updatePositionsCountMetrics(groupedDeFiPositions, accountAddress); + } catch (error) { + console.error( + `Failed to update positions count for account ${accountAddress}:`, + error, + ); + } + + return groupedDeFiPositions; + } catch { + return null; + } + } + + #updatePositionsCountMetrics( + groupedDeFiPositions: GroupedDeFiPositionsPerChain, + accountAddress: string, + ) { + // If no track event passed then skip the metrics update + if (!this.#trackEvent) { + return; + } + + const defiMetrics = calculateDeFiPositionMetrics(groupedDeFiPositions); + const { totalPositions } = defiMetrics.properties; + + if (totalPositions !== this.state.allDeFiPositionsCount[accountAddress]) { + this.update((state) => { + state.allDeFiPositionsCount[accountAddress] = totalPositions; + }); + + this.#trackEvent?.(defiMetrics); + } + } + + #getSelectedEvmAdress(): string | undefined { + return this.messenger + .call('AccountTreeController:getAccountsFromSelectedAccountGroup') + .find((account: InternalAccount) => isEvmAccountType(account.type)) + ?.address; + } +} diff --git a/packages/assets-controllers/src/DeFiPositionsController/DeFiPositionsControllerV2-method-action-types.ts b/packages/assets-controllers/src/DeFiPositionsController/DeFiPositionsControllerV2-method-action-types.ts new file mode 100644 index 00000000000..5af599f2e6b --- /dev/null +++ b/packages/assets-controllers/src/DeFiPositionsController/DeFiPositionsControllerV2-method-action-types.ts @@ -0,0 +1,43 @@ +/** + * This file is auto generated. + * Do not edit manually. + */ + +import type { DeFiPositionsControllerV2 } from './DeFiPositionsControllerV2.js'; + +/** + * Fetches DeFi positions for the selected account group. State is updated + * only when the response lists no accounts in its `processingDefiPositions` + * array; each resolved account then has its state replaced (other accounts + * stay). While any account is still indexing, prior state is kept and the + * method polls (invalidating the balances cache between attempts) until no + * accounts are processing, the attempt limit is reached, or a request fails. + * Concurrent calls for the same selected accounts and `vsCurrency` share one + * in-flight promise; calls for a different selection or fiat currency start + * a new fetch and leave prior polls running so a later switch back can join + * them. When a successful ready response required more than one attempt, or + * when polling hits the attempt limit while still processing, reports to + * Sentry via `messenger.captureException` (error names + * `DeFiPositionsV2FetchAttempts` / + * `DeFiPositionsV2ProcessingPollExhausted`) so poll limits can be tuned. + * No-ops when disabled or when the group has no + * supported accounts. Caching / spam prevention is handled by the apiClient + * TanStack Query cache (keyed by accounts + query options including + * `vsCurrency`). Pass `{ forceRefresh: true }` to bypass the cache on the + * first attempt (e.g. pull-to-refresh). + * + * @param options - Optional fetch modifiers. + * @param options.forceRefresh - When true, bypass the apiClient cache on the + * first attempt and fetch immediately. + * @returns Resolves when the fetch (and any processing polls) finish. + */ +export type DeFiPositionsControllerV2FetchDeFiPositionsAction = { + type: `DeFiPositionsControllerV2:fetchDeFiPositions`; + handler: DeFiPositionsControllerV2['fetchDeFiPositions']; +}; + +/** + * Union of all DeFiPositionsControllerV2 action types. + */ +export type DeFiPositionsControllerV2MethodActions = + DeFiPositionsControllerV2FetchDeFiPositionsAction; diff --git a/packages/assets-controllers/src/DeFiPositionsController/DeFiPositionsControllerV2.test.ts b/packages/assets-controllers/src/DeFiPositionsController/DeFiPositionsControllerV2.test.ts new file mode 100644 index 00000000000..3b1f9a7005c --- /dev/null +++ b/packages/assets-controllers/src/DeFiPositionsController/DeFiPositionsControllerV2.test.ts @@ -0,0 +1,1022 @@ +import { deriveStateFromMetadata } from '@metamask/base-controller'; +import type { + ApiPlatformClient, + V6BalanceItem, + V6BalancesResponse, +} from '@metamask/core-backend'; +import { + BtcAccountType, + EthAccountType, + SolAccountType, + SolMethod, + SolScope, +} from '@metamask/keyring-api'; +import { KeyringTypes } from '@metamask/keyring-controller'; +import type { InternalAccount } from '@metamask/keyring-internal-api'; +import { MOCK_ANY_NAMESPACE, Messenger } from '@metamask/messenger'; +import type { + MessengerActions, + MessengerEvents, + MockAnyNamespace, +} from '@metamask/messenger'; +import type { FeatureFlags } from '@metamask/remote-feature-flag-controller'; + +import { createMockInternalAccount } from '../../../accounts-controller/tests/mocks.js'; +import { DEFI_SUPPORTED_NETWORKS } from './build-defi-balances-query.js'; +import type { DeFiPositionsControllerV2Messenger } from './DeFiPositionsControllerV2.js'; +import { + DeFiPositionsControllerV2, + getDefaultDeFiPositionsControllerV2State, +} from './DeFiPositionsControllerV2.js'; + +/** Mirrors the internal defaults in `defi-controller-v2-feature-flag.ts`. */ +const DEFI_CONTROLLER_V2_FEATURE_FLAG = 'defiControllerV2'; +const DEFAULT_PROCESSING_POLL_INTERVAL_MS = 5_000; +const DEFAULT_PROCESSING_POLL_MAX_ATTEMPTS = 5; + +const EVM_ADDRESS = '0x0000000000000000000000000000000000000001'; +const SOLANA_ADDRESS = 'So11111111111111111111111111111111111111112'; + +const GROUP_ACCOUNTS = [ + createMockInternalAccount({ + id: 'evm-account-id', + address: EVM_ADDRESS, + type: EthAccountType.Eoa, + }), + createMockInternalAccount({ + id: 'btc-account-id', + type: BtcAccountType.P2wpkh, + }), +]; + +const GROUP_ACCOUNTS_WITH_SOLANA: InternalAccount[] = [ + ...GROUP_ACCOUNTS, + { + id: 'solana-account-id', + address: SOLANA_ADDRESS, + options: {}, + methods: [SolMethod.SendAndConfirmTransaction], + scopes: [SolScope.Mainnet], + type: SolAccountType.DataAccount, + metadata: { + name: 'Solana Account', + keyring: { type: KeyringTypes.snap }, + importTime: Date.now(), + lastSelected: Date.now(), + snap: { + id: 'mock-sol-snap', + }, + }, + }, +]; + +const GROUP_ACCOUNTS_NO_SUPPORTED = [ + createMockInternalAccount({ + id: 'btc-account-id', + type: BtcAccountType.P2wpkh, + }), +]; + +const DEFAULT_DEFI_BALANCE: V6BalanceItem = { + accountId: `eip155:0:${EVM_ADDRESS}`, + object: 'defi', + type: 'erc20', + assetId: 'eip155:1/erc20:0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2', + name: 'Wrapped Ether', + symbol: 'WETH', + decimals: 18, + balance: '1', + price: '2000', + metadata: { + protocolId: 'aave-v3', + productName: 'Aave V3', + description: 'Aave V3 on ethereum', + protocolUrl: 'https://aave.com/', + protocolIconUrl: 'https://example.com/aave.png', + positionType: 'deposit', + poolAddress: '0xpool', + groupId: 'group-aave-1', + }, +}; + +type AllDeFiPositionsControllerV2Actions = + MessengerActions; + +type AllDeFiPositionsControllerV2Events = + MessengerEvents; + +type RootMessenger = Messenger< + MockAnyNamespace, + AllDeFiPositionsControllerV2Actions, + AllDeFiPositionsControllerV2Events +>; + +/** + * Builds a minimal successful v6 balances response for the EVM account. + * + * @param overrides - Optional response overrides. + * @returns A v6 balances response. + */ +function buildMockBalancesResponse( + overrides?: Partial, +): V6BalancesResponse { + return { + unprocessedNetworks: [], + unprocessedIncludeAssetIds: [], + balances: [DEFAULT_DEFI_BALANCE], + ...overrides, + }; +} + +/** + * Builds a processing balances response for the EVM account. DeFi rows for + * processing accounts are omitted from `balances` until indexing completes. + * + * @returns A v6 balances response listing the EVM account in + * `processingDefiPositions`. + */ +function buildProcessingBalancesResponse(): V6BalancesResponse { + return buildMockBalancesResponse({ + balances: [], + processingDefiPositions: [`eip155:1:${EVM_ADDRESS}`], + }); +} + +/** + * Sets up the V2 controller with the given configuration. + * + * @param config - Configuration for the mock setup. + * @param config.isEnabled - Whether the controller is enabled. + * @param config.getVsCurrency - Fiat currency getter. + * @param config.remoteFeatureFlags - Remote feature flags returned by + * `RemoteFeatureFlagController:getState` (defaults to empty). + * @param config.mockGroupAccounts - Accounts returned for the selected group. + * @param config.getGroupAccounts - Getter for the selected group accounts + * (preferred when the selection changes between fetches). + * @param config.mockFetchV6MultiAccountBalances - Mock API fetch function. + * @param config.captureException - Mock Sentry capture function. + * @param config.state - Initial controller state. + * @returns The controller instance and mocks. + */ +function setupController({ + isEnabled = (): boolean => true, + getVsCurrency = (): string => 'USD', + remoteFeatureFlags = {}, + mockGroupAccounts = GROUP_ACCOUNTS, + getGroupAccounts, + mockFetchV6MultiAccountBalances = jest + .fn() + .mockResolvedValue(buildMockBalancesResponse()), + captureException = jest.fn(), + state, +}: { + isEnabled?: () => boolean; + getVsCurrency?: () => string; + remoteFeatureFlags?: FeatureFlags; + mockGroupAccounts?: InternalAccount[]; + getGroupAccounts?: () => InternalAccount[]; + mockFetchV6MultiAccountBalances?: jest.Mock; + captureException?: jest.Mock; + state?: Partial>; +} = {}): { + controller: DeFiPositionsControllerV2; + controllerMessenger: Messenger< + 'DeFiPositionsControllerV2', + AllDeFiPositionsControllerV2Actions, + AllDeFiPositionsControllerV2Events, + RootMessenger + >; + mockFetchV6MultiAccountBalances: jest.Mock; + mockInvalidateQueries: jest.Mock; + mockGetV6MultiAccountBalancesQueryOptions: jest.Mock; + mockCaptureException: jest.Mock; +} { + const messenger: RootMessenger = new Messenger({ + namespace: MOCK_ANY_NAMESPACE, + captureException, + }); + + messenger.registerActionHandler( + 'AccountTreeController:getAccountsFromSelectedAccountGroup', + () => getGroupAccounts?.() ?? mockGroupAccounts, + ); + messenger.registerActionHandler( + 'RemoteFeatureFlagController:getState', + () => ({ + remoteFeatureFlags, + cacheTimestamp: 0, + }), + ); + + const controllerMessenger = new Messenger< + 'DeFiPositionsControllerV2', + AllDeFiPositionsControllerV2Actions, + AllDeFiPositionsControllerV2Events, + RootMessenger + >({ + namespace: 'DeFiPositionsControllerV2', + parent: messenger, + }); + messenger.delegate({ + messenger: controllerMessenger, + actions: [ + 'AccountTreeController:getAccountsFromSelectedAccountGroup', + 'RemoteFeatureFlagController:getState', + ], + }); + + const mockInvalidateQueries = jest.fn().mockResolvedValue(undefined); + const mockGetV6MultiAccountBalancesQueryOptions = jest.fn().mockReturnValue({ + queryKey: ['accounts', 'balances', 'v6'], + }); + + const apiClient = { + accounts: { + fetchV6MultiAccountBalances: mockFetchV6MultiAccountBalances, + getV6MultiAccountBalancesQueryOptions: + mockGetV6MultiAccountBalancesQueryOptions, + queryClient: { + invalidateQueries: mockInvalidateQueries, + }, + }, + } as unknown as ApiPlatformClient; + + const controller = new DeFiPositionsControllerV2({ + messenger: controllerMessenger, + apiClient, + isEnabled, + getVsCurrency, + state, + }); + + return { + controller, + controllerMessenger, + mockFetchV6MultiAccountBalances, + mockInvalidateQueries, + mockGetV6MultiAccountBalancesQueryOptions, + mockCaptureException: captureException, + }; +} + +describe('DeFiPositionsControllerV2', () => { + afterEach(() => { + jest.useRealTimers(); + jest.restoreAllMocks(); + }); + + it('sets default state', () => { + const { controller } = setupController(); + + expect(controller.state).toStrictEqual( + getDefaultDeFiPositionsControllerV2State(), + ); + }); + + it('does not fetch when the controller is disabled', async () => { + const { controller, mockFetchV6MultiAccountBalances } = setupController({ + isEnabled: () => false, + }); + + await controller.fetchDeFiPositions(); + + expect(mockFetchV6MultiAccountBalances).not.toHaveBeenCalled(); + expect(controller.state).toStrictEqual( + getDefaultDeFiPositionsControllerV2State(), + ); + }); + + it('does not fetch when the selected group has no supported accounts', async () => { + const { controller, mockFetchV6MultiAccountBalances } = setupController({ + mockGroupAccounts: GROUP_ACCOUNTS_NO_SUPPORTED, + }); + + await controller.fetchDeFiPositions(); + + expect(mockFetchV6MultiAccountBalances).not.toHaveBeenCalled(); + expect(controller.state).toStrictEqual( + getDefaultDeFiPositionsControllerV2State(), + ); + }); + + it('fetches positions and stores them keyed by internal account ID', async () => { + const { controller, mockFetchV6MultiAccountBalances } = setupController(); + + await controller.fetchDeFiPositions(); + + expect(mockFetchV6MultiAccountBalances).toHaveBeenCalledTimes(1); + expect(mockFetchV6MultiAccountBalances).toHaveBeenCalledWith( + [`eip155:0:${EVM_ADDRESS.toLowerCase()}`], + { + networks: DEFI_SUPPORTED_NETWORKS.filter((network) => + network.startsWith('eip155:'), + ), + includeDeFiBalances: true, + forceFetchDeFiPositions: true, + includePrices: true, + vsCurrency: 'usd', + }, + {}, + ); + + expect(controller.state.allDeFiPositionsV2['evm-account-id']).toHaveLength( + 1, + ); + expect( + controller.state.allDeFiPositionsV2['evm-account-id'][0], + ).toMatchObject({ + protocolId: 'aave-v3', + productName: 'Aave V3', + chainId: 'eip155:1', + marketValue: 2000, + }); + }); + + it('maps mixed-case EVM response account IDs back to internal IDs', async () => { + const mockFetchV6MultiAccountBalances = jest.fn().mockResolvedValue( + buildMockBalancesResponse({ + balances: [ + { + ...DEFAULT_DEFI_BALANCE, + accountId: `eip155:0:${EVM_ADDRESS.toUpperCase()}`, + }, + ], + }), + ); + + const { controller } = setupController({ + mockFetchV6MultiAccountBalances, + }); + + await controller.fetchDeFiPositions(); + + expect(controller.state.allDeFiPositionsV2).toHaveProperty( + 'evm-account-id', + ); + expect(controller.state.allDeFiPositionsV2['evm-account-id']).toHaveLength( + 1, + ); + }); + + it('requests Solana and EVM networks when both accounts are present', async () => { + const mockFetchV6MultiAccountBalances = jest + .fn() + .mockResolvedValue(buildMockBalancesResponse({ balances: [] })); + + const { controller, mockFetchV6MultiAccountBalances: mockFetch } = + setupController({ + mockGroupAccounts: GROUP_ACCOUNTS_WITH_SOLANA, + mockFetchV6MultiAccountBalances, + }); + + await controller.fetchDeFiPositions(); + + const expectedEvmNetworks = DEFI_SUPPORTED_NETWORKS.filter((network) => + network.startsWith('eip155:'), + ); + const expectedSolanaNetworks = DEFI_SUPPORTED_NETWORKS.filter((network) => + network.startsWith('solana:'), + ); + + expect(mockFetch).toHaveBeenCalledWith( + [ + `eip155:0:${EVM_ADDRESS.toLowerCase()}`, + `solana:${SolScope.Mainnet.split(':')[1]}:${SOLANA_ADDRESS}`, + ], + { + networks: [...expectedEvmNetworks, ...expectedSolanaNetworks], + includeDeFiBalances: true, + forceFetchDeFiPositions: true, + includePrices: true, + vsCurrency: 'usd', + }, + {}, + ); + expect(controller.state.allDeFiPositionsV2).toStrictEqual({ + 'evm-account-id': [], + 'solana-account-id': [], + }); + }); + + it('polls until processing accounts become ready and keeps prior state meanwhile', async () => { + jest.useFakeTimers(); + + const { + controller, + mockFetchV6MultiAccountBalances, + mockInvalidateQueries, + } = setupController({ + mockFetchV6MultiAccountBalances: jest + .fn() + .mockResolvedValueOnce(buildMockBalancesResponse()) + .mockResolvedValueOnce(buildProcessingBalancesResponse()) + .mockResolvedValueOnce(buildMockBalancesResponse()), + }); + + await controller.fetchDeFiPositions(); + const cached = controller.state.allDeFiPositionsV2['evm-account-id']; + expect(cached).toHaveLength(1); + + const secondFetch = controller.fetchDeFiPositions({ forceRefresh: true }); + await Promise.resolve(); + expect(mockFetchV6MultiAccountBalances).toHaveBeenCalledTimes(2); + expect(controller.state.allDeFiPositionsV2['evm-account-id']).toBe(cached); + expect(mockInvalidateQueries).toHaveBeenCalledTimes(1); + + await jest.advanceTimersByTimeAsync(DEFAULT_PROCESSING_POLL_INTERVAL_MS); + await secondFetch; + + expect(mockFetchV6MultiAccountBalances).toHaveBeenCalledTimes(3); + expect(mockFetchV6MultiAccountBalances).toHaveBeenLastCalledWith( + expect.any(Array), + expect.objectContaining({ vsCurrency: 'usd' }), + { staleTime: 0 }, + ); + expect(controller.state.allDeFiPositionsV2['evm-account-id']).toHaveLength( + 1, + ); + expect(controller.state.allDeFiPositionsV2['evm-account-id']).not.toBe( + cached, + ); + }); + + it('does not report attempt count to Sentry when the first fetch succeeds', async () => { + const { controller, mockCaptureException, mockInvalidateQueries } = + setupController(); + + await controller.fetchDeFiPositions(); + + expect(mockCaptureException).not.toHaveBeenCalled(); + expect(mockInvalidateQueries).not.toHaveBeenCalled(); + }); + + it('reports attempt count to Sentry when positions become ready after polling', async () => { + jest.useFakeTimers(); + + const { controller, mockCaptureException } = setupController({ + mockFetchV6MultiAccountBalances: jest + .fn() + .mockResolvedValueOnce(buildProcessingBalancesResponse()) + .mockResolvedValueOnce(buildProcessingBalancesResponse()) + .mockResolvedValueOnce(buildMockBalancesResponse()), + }); + + const fetchPromise = controller.fetchDeFiPositions(); + await Promise.resolve(); + expect(mockCaptureException).not.toHaveBeenCalled(); + + await jest.advanceTimersByTimeAsync(DEFAULT_PROCESSING_POLL_INTERVAL_MS); + await Promise.resolve(); + expect(mockCaptureException).not.toHaveBeenCalled(); + + await jest.advanceTimersByTimeAsync(DEFAULT_PROCESSING_POLL_INTERVAL_MS); + await fetchPromise; + + expect(mockCaptureException).toHaveBeenCalledTimes(1); + expect(mockCaptureException).toHaveBeenCalledWith( + expect.objectContaining({ + name: 'DeFiPositionsV2FetchAttempts', + message: + 'DeFiPositionsControllerV2: positions ready after 3 attempt(s)', + }), + ); + }); + + it('reports to Sentry when polling hits the max limit while still processing', async () => { + jest.useFakeTimers(); + + const mockFetchV6MultiAccountBalances = jest + .fn() + .mockResolvedValue(buildProcessingBalancesResponse()); + const { controller, mockCaptureException } = setupController({ + mockFetchV6MultiAccountBalances, + }); + + const fetchPromise = controller.fetchDeFiPositions(); + + for (let i = 0; i < DEFAULT_PROCESSING_POLL_MAX_ATTEMPTS - 1; i++) { + await Promise.resolve(); + await jest.advanceTimersByTimeAsync(DEFAULT_PROCESSING_POLL_INTERVAL_MS); + } + + await fetchPromise; + + expect(mockCaptureException).toHaveBeenCalledTimes(1); + expect(mockCaptureException).toHaveBeenCalledWith( + expect.objectContaining({ + name: 'DeFiPositionsV2ProcessingPollExhausted', + message: `DeFiPositionsControllerV2: still processing after ${DEFAULT_PROCESSING_POLL_MAX_ATTEMPTS} attempt(s)`, + }), + ); + }); + + it('keeps prior state for all accounts while any account is still processing', async () => { + jest.useFakeTimers(); + + const solanaDefiBalance: V6BalanceItem = { + accountId: `solana:${SolScope.Mainnet.split(':')[1]}:${SOLANA_ADDRESS}`, + object: 'defi', + type: 'erc20', + assetId: `${SolScope.Mainnet}/token:${SOLANA_ADDRESS}`, + name: 'Wrapped SOL', + symbol: 'WSOL', + decimals: 9, + balance: '1', + price: '100', + metadata: { + protocolId: 'marinade', + productName: 'Marinade', + description: 'Marinade on solana', + protocolUrl: 'https://marinade.finance/', + protocolIconUrl: 'https://example.com/marinade.png', + positionType: 'staked', + poolAddress: 'pool', + groupId: 'group-marinade-1', + }, + }; + const { + controller, + mockInvalidateQueries, + mockFetchV6MultiAccountBalances, + } = setupController({ + mockGroupAccounts: GROUP_ACCOUNTS_WITH_SOLANA, + mockFetchV6MultiAccountBalances: jest + .fn() + .mockResolvedValueOnce( + buildMockBalancesResponse({ + balances: [DEFAULT_DEFI_BALANCE, solanaDefiBalance], + }), + ) + .mockResolvedValueOnce( + // The EVM account is still indexing; its DeFi rows are omitted, so + // writing this response would wrongly clear the EVM positions. + buildMockBalancesResponse({ + balances: [solanaDefiBalance], + processingDefiPositions: [`eip155:1:${EVM_ADDRESS}`], + }), + ) + .mockResolvedValueOnce( + buildMockBalancesResponse({ + balances: [DEFAULT_DEFI_BALANCE], + }), + ), + }); + + await controller.fetchDeFiPositions(); + const evmPositions = controller.state.allDeFiPositionsV2['evm-account-id']; + const solanaPositions = + controller.state.allDeFiPositionsV2['solana-account-id']; + expect(evmPositions).toHaveLength(1); + expect(solanaPositions).toHaveLength(1); + + const secondFetch = controller.fetchDeFiPositions({ forceRefresh: true }); + await Promise.resolve(); + + // Processing response: keep prior state for every account. + expect(controller.state.allDeFiPositionsV2['evm-account-id']).toBe( + evmPositions, + ); + expect(controller.state.allDeFiPositionsV2['solana-account-id']).toBe( + solanaPositions, + ); + expect(mockInvalidateQueries).toHaveBeenCalledTimes(1); + + await jest.advanceTimersByTimeAsync(DEFAULT_PROCESSING_POLL_INTERVAL_MS); + await secondFetch; + + expect(mockFetchV6MultiAccountBalances).toHaveBeenCalledTimes(3); + expect(controller.state.allDeFiPositionsV2['evm-account-id']).toHaveLength( + 1, + ); + expect(controller.state.allDeFiPositionsV2['evm-account-id']).not.toBe( + evmPositions, + ); + expect( + controller.state.allDeFiPositionsV2['solana-account-id'], + ).toStrictEqual([]); + }); + + it('stops polling after the max attempt limit while still processing', async () => { + jest.useFakeTimers(); + + const mockFetchV6MultiAccountBalances = jest + .fn() + .mockResolvedValue(buildProcessingBalancesResponse()); + + const { controller, mockInvalidateQueries } = setupController({ + mockFetchV6MultiAccountBalances, + }); + + const fetchPromise = controller.fetchDeFiPositions(); + + for (let i = 0; i < DEFAULT_PROCESSING_POLL_MAX_ATTEMPTS - 1; i++) { + await Promise.resolve(); + await jest.advanceTimersByTimeAsync(DEFAULT_PROCESSING_POLL_INTERVAL_MS); + } + + await fetchPromise; + + expect(mockFetchV6MultiAccountBalances).toHaveBeenCalledTimes( + DEFAULT_PROCESSING_POLL_MAX_ATTEMPTS, + ); + expect(mockInvalidateQueries).toHaveBeenCalledTimes( + DEFAULT_PROCESSING_POLL_MAX_ATTEMPTS, + ); + expect(controller.state.allDeFiPositionsV2).toStrictEqual({}); + }); + + it('uses maxAttempts and pollInterval from the defiControllerV2 remote flag', async () => { + jest.useFakeTimers(); + + const remoteMaxAttempts = 3; + const remotePollInterval = 1_000; + const mockFetchV6MultiAccountBalances = jest + .fn() + .mockResolvedValue(buildProcessingBalancesResponse()); + + const { controller, mockInvalidateQueries } = setupController({ + mockFetchV6MultiAccountBalances, + remoteFeatureFlags: { + [DEFI_CONTROLLER_V2_FEATURE_FLAG]: { + maxAttempts: remoteMaxAttempts, + pollInterval: remotePollInterval, + }, + }, + }); + + const fetchPromise = controller.fetchDeFiPositions(); + + for (let i = 0; i < remoteMaxAttempts - 1; i++) { + await Promise.resolve(); + await jest.advanceTimersByTimeAsync(remotePollInterval); + } + + await fetchPromise; + + expect(mockFetchV6MultiAccountBalances).toHaveBeenCalledTimes( + remoteMaxAttempts, + ); + expect(mockInvalidateQueries).toHaveBeenCalledTimes(remoteMaxAttempts); + expect(controller.state.allDeFiPositionsV2).toStrictEqual({}); + }); + + it('shares one in-flight promise across concurrent fetchDeFiPositions calls', async () => { + let resolveFetch!: (value: V6BalancesResponse) => void; + const mockFetchV6MultiAccountBalances = jest.fn().mockImplementation( + () => + new Promise((resolve) => { + resolveFetch = resolve; + }), + ); + + const { controller } = setupController({ + mockFetchV6MultiAccountBalances, + }); + + const first = controller.fetchDeFiPositions(); + const second = controller.fetchDeFiPositions({ forceRefresh: true }); + + expect(mockFetchV6MultiAccountBalances).toHaveBeenCalledTimes(1); + + resolveFetch(buildMockBalancesResponse()); + await Promise.all([first, second]); + + expect(mockFetchV6MultiAccountBalances).toHaveBeenCalledTimes(1); + }); + + it('starts a new fetch when vsCurrency changes during an in-flight call', async () => { + let vsCurrency = 'USD'; + + let resolveUsdFetch!: (value: V6BalancesResponse) => void; + const mockFetchV6MultiAccountBalances = jest + .fn() + .mockImplementationOnce( + () => + new Promise((resolve) => { + resolveUsdFetch = resolve; + }), + ) + .mockResolvedValueOnce(buildMockBalancesResponse()); + + const { controller } = setupController({ + getVsCurrency: () => vsCurrency, + mockFetchV6MultiAccountBalances, + }); + + const usdFetch = controller.fetchDeFiPositions(); + await Promise.resolve(); + expect(mockFetchV6MultiAccountBalances).toHaveBeenCalledTimes(1); + expect(mockFetchV6MultiAccountBalances).toHaveBeenLastCalledWith( + expect.any(Array), + expect.objectContaining({ vsCurrency: 'usd' }), + {}, + ); + + vsCurrency = 'EUR'; + const eurFetch = controller.fetchDeFiPositions({ forceRefresh: true }); + await Promise.resolve(); + + // Different fiat currency must not join the USD in-flight promise. + expect(mockFetchV6MultiAccountBalances).toHaveBeenCalledTimes(2); + expect(mockFetchV6MultiAccountBalances).toHaveBeenLastCalledWith( + expect.any(Array), + expect.objectContaining({ vsCurrency: 'eur' }), + { staleTime: 0 }, + ); + + resolveUsdFetch(buildMockBalancesResponse()); + await Promise.all([usdFetch, eurFetch]); + + expect(mockFetchV6MultiAccountBalances).toHaveBeenCalledTimes(2); + }); + + it('starts a new fetch when selection changes during an in-flight call', async () => { + const otherEvmAddress = '0x0000000000000000000000000000000000000002'; + const otherEvmAccount = createMockInternalAccount({ + id: 'evm-account-id-2', + address: otherEvmAddress, + type: EthAccountType.Eoa, + }); + let groupAccounts: InternalAccount[] = GROUP_ACCOUNTS; + + let resolveFirstFetch!: (value: V6BalancesResponse) => void; + const mockFetchV6MultiAccountBalances = jest + .fn() + .mockImplementationOnce( + () => + new Promise((resolve) => { + resolveFirstFetch = resolve; + }), + ) + .mockResolvedValueOnce( + buildMockBalancesResponse({ + balances: [ + { + ...DEFAULT_DEFI_BALANCE, + accountId: `eip155:0:${otherEvmAddress}`, + }, + ], + }), + ); + + const { controller } = setupController({ + getGroupAccounts: () => groupAccounts, + mockFetchV6MultiAccountBalances, + }); + + const firstFetch = controller.fetchDeFiPositions(); + await Promise.resolve(); + expect(mockFetchV6MultiAccountBalances).toHaveBeenCalledTimes(1); + expect(mockFetchV6MultiAccountBalances.mock.calls[0][0]).toContain( + `eip155:0:${EVM_ADDRESS}`, + ); + + groupAccounts = [otherEvmAccount]; + const secondFetch = controller.fetchDeFiPositions({ forceRefresh: true }); + await Promise.resolve(); + + // Different selection does not join the in-flight promise. + expect(mockFetchV6MultiAccountBalances).toHaveBeenCalledTimes(2); + expect(mockFetchV6MultiAccountBalances).toHaveBeenLastCalledWith( + expect.arrayContaining([`eip155:0:${otherEvmAddress}`]), + expect.objectContaining({ vsCurrency: 'usd' }), + { staleTime: 0 }, + ); + + resolveFirstFetch(buildMockBalancesResponse()); + await Promise.all([firstFetch, secondFetch]); + + // The prior request may still write the old group; the new fetch writes the + // new group. + expect(controller.state.allDeFiPositionsV2['evm-account-id']).toHaveLength( + 1, + ); + expect( + controller.state.allDeFiPositionsV2['evm-account-id-2'], + ).toHaveLength(1); + }); + + it('rejoins an in-flight fetch when switching back to the same accounts', async () => { + const otherEvmAddress = '0x0000000000000000000000000000000000000002'; + const otherEvmAccount = createMockInternalAccount({ + id: 'evm-account-id-2', + address: otherEvmAddress, + type: EthAccountType.Eoa, + }); + let groupAccounts: InternalAccount[] = GROUP_ACCOUNTS; + + let resolveFirstFetch!: (value: V6BalancesResponse) => void; + let resolveSecondFetch!: (value: V6BalancesResponse) => void; + const mockFetchV6MultiAccountBalances = jest + .fn() + .mockImplementationOnce( + () => + new Promise((resolve) => { + resolveFirstFetch = resolve; + }), + ) + .mockImplementationOnce( + () => + new Promise((resolve) => { + resolveSecondFetch = resolve; + }), + ); + + const { controller } = setupController({ + getGroupAccounts: () => groupAccounts, + mockFetchV6MultiAccountBalances, + }); + + const firstFetch = controller.fetchDeFiPositions(); + await Promise.resolve(); + + groupAccounts = [otherEvmAccount]; + const secondFetch = controller.fetchDeFiPositions(); + await Promise.resolve(); + expect(mockFetchV6MultiAccountBalances).toHaveBeenCalledTimes(2); + + groupAccounts = GROUP_ACCOUNTS; + const thirdFetch = controller.fetchDeFiPositions(); + + // Switched back to the first group — join its still-in-flight promise. + expect(mockFetchV6MultiAccountBalances).toHaveBeenCalledTimes(2); + + resolveFirstFetch(buildMockBalancesResponse()); + resolveSecondFetch(buildMockBalancesResponse({ balances: [] })); + await Promise.all([firstFetch, secondFetch, thirdFetch]); + + expect(mockFetchV6MultiAccountBalances).toHaveBeenCalledTimes(2); + expect(controller.state.allDeFiPositionsV2['evm-account-id']).toHaveLength( + 1, + ); + expect( + controller.state.allDeFiPositionsV2['evm-account-id-2'], + ).toStrictEqual([]); + }); + + it('merges fetched accounts into state without clearing other accounts', async () => { + const otherEvmAddress = '0x0000000000000000000000000000000000000002'; + const otherEvmAccount = createMockInternalAccount({ + id: 'evm-account-id-2', + address: otherEvmAddress, + type: EthAccountType.Eoa, + }); + let groupAccounts: InternalAccount[] = GROUP_ACCOUNTS; + const { controller } = setupController({ + getGroupAccounts: () => groupAccounts, + mockFetchV6MultiAccountBalances: jest + .fn() + .mockResolvedValueOnce(buildMockBalancesResponse()) + .mockResolvedValueOnce(buildMockBalancesResponse({ balances: [] })), + }); + + await controller.fetchDeFiPositions(); + expect(controller.state.allDeFiPositionsV2['evm-account-id']).toHaveLength( + 1, + ); + + groupAccounts = [otherEvmAccount]; + await controller.fetchDeFiPositions(); + + expect(controller.state.allDeFiPositionsV2).toStrictEqual({ + 'evm-account-id': expect.any(Array), + 'evm-account-id-2': [], + }); + expect(controller.state.allDeFiPositionsV2['evm-account-id']).toHaveLength( + 1, + ); + }); + + it('passes staleTime: 0 to the apiClient when forceRefresh is true', async () => { + const { controller, mockFetchV6MultiAccountBalances } = setupController(); + + await controller.fetchDeFiPositions({ forceRefresh: true }); + + expect(mockFetchV6MultiAccountBalances).toHaveBeenCalledWith( + expect.any(Array), + expect.objectContaining({ vsCurrency: 'usd' }), + { staleTime: 0 }, + ); + }); + + it('passes the current vsCurrency to the apiClient', async () => { + let vsCurrency = 'USD'; + const { controller, mockFetchV6MultiAccountBalances } = setupController({ + getVsCurrency: () => vsCurrency, + }); + + await controller.fetchDeFiPositions(); + expect(mockFetchV6MultiAccountBalances).toHaveBeenLastCalledWith( + expect.any(Array), + expect.objectContaining({ vsCurrency: 'usd' }), + {}, + ); + + vsCurrency = 'EUR'; + await controller.fetchDeFiPositions(); + + expect(mockFetchV6MultiAccountBalances).toHaveBeenLastCalledWith( + expect.any(Array), + expect.objectContaining({ vsCurrency: 'eur' }), + {}, + ); + }); + + it('keeps prior state when a fetch fails', async () => { + const consoleErrorSpy = jest + .spyOn(console, 'error') + .mockImplementation(() => undefined); + const mockFetchV6MultiAccountBalances = jest + .fn() + .mockResolvedValueOnce(buildMockBalancesResponse()) + .mockRejectedValueOnce(new Error('network error')); + + const { controller } = setupController({ + mockFetchV6MultiAccountBalances, + }); + + await controller.fetchDeFiPositions(); + expect(controller.state.allDeFiPositionsV2['evm-account-id']).toHaveLength( + 1, + ); + + await controller.fetchDeFiPositions(); + expect(mockFetchV6MultiAccountBalances).toHaveBeenCalledTimes(2); + expect(controller.state.allDeFiPositionsV2['evm-account-id']).toHaveLength( + 1, + ); + expect(consoleErrorSpy).toHaveBeenCalledWith( + 'Failed to fetch DeFi positions', + expect.any(Error), + ); + }); + + it('exposes fetchDeFiPositions via the messenger', async () => { + const { controllerMessenger, mockFetchV6MultiAccountBalances } = + setupController(); + + await controllerMessenger.call( + 'DeFiPositionsControllerV2:fetchDeFiPositions', + ); + + expect(mockFetchV6MultiAccountBalances).toHaveBeenCalledTimes(1); + }); + + describe('metadata', () => { + it('includes expected state in debug snapshots', () => { + const { controller } = setupController(); + + expect( + deriveStateFromMetadata( + controller.state, + controller.metadata, + 'includeInDebugSnapshot', + ), + ).toMatchInlineSnapshot(`{}`); + }); + + it('includes expected state in state logs', () => { + const { controller } = setupController(); + + expect( + deriveStateFromMetadata( + controller.state, + controller.metadata, + 'includeInStateLogs', + ), + ).toMatchInlineSnapshot(`{}`); + }); + + it('persists expected state', () => { + const { controller } = setupController(); + + expect( + deriveStateFromMetadata( + controller.state, + controller.metadata, + 'persist', + ), + ).toMatchInlineSnapshot(` + { + "allDeFiPositionsV2": {}, + } + `); + }); + + it('exposes expected state to UI', () => { + const { controller } = setupController(); + + expect( + deriveStateFromMetadata( + controller.state, + controller.metadata, + 'usedInUi', + ), + ).toMatchInlineSnapshot(` + { + "allDeFiPositionsV2": {}, + } + `); + }); + }); +}); diff --git a/packages/assets-controllers/src/DeFiPositionsController/DeFiPositionsControllerV2.ts b/packages/assets-controllers/src/DeFiPositionsController/DeFiPositionsControllerV2.ts new file mode 100644 index 00000000000..7814f221891 --- /dev/null +++ b/packages/assets-controllers/src/DeFiPositionsController/DeFiPositionsControllerV2.ts @@ -0,0 +1,359 @@ +import type { AccountTreeControllerGetAccountsFromSelectedAccountGroupAction } from '@metamask/account-tree-controller'; +import { BaseController } from '@metamask/base-controller'; +import type { + ControllerGetStateAction, + ControllerStateChangedEvent, + StateMetadata, +} from '@metamask/base-controller'; +import type { ApiPlatformClient } from '@metamask/core-backend'; +import type { Messenger } from '@metamask/messenger'; +import type { RemoteFeatureFlagControllerGetStateAction } from '@metamask/remote-feature-flag-controller'; + +import { buildDeFiBalancesQuery } from './build-defi-balances-query.js'; +import type { DeFiBalancesQuery } from './build-defi-balances-query.js'; +import { getProcessingPollConfig } from './defi-controller-v2-feature-flag.js'; +import type { DeFiPositionsControllerV2MethodActions } from './DeFiPositionsControllerV2-method-action-types.js'; +import type { DeFiPositionsByAccount } from './group-defi-positions-v6.js'; +import { groupDeFiPositionsV6 } from './group-defi-positions-v6.js'; + +const controllerName = 'DeFiPositionsControllerV2'; + +const MESSENGER_EXPOSED_METHODS = ['fetchDeFiPositions'] as const; + +/** + * @param ms - Milliseconds to wait. + * @returns A promise that resolves after `ms`. + */ +function delay(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +export type DeFiPositionsControllerV2State = { + /** + * DeFi positions keyed by internal MetaMask account ID (`InternalAccount.id`, + * the same key AssetsController uses). Each account maps to a flat list of + * protocol groups shown in the DeFi tab, each carrying its own `chainId` for + * filtering plus the details-page sections embedded inside it. This is + * exactly the shape the client consumes, so no further transformation is + * needed on read. + * + * Named `allDeFiPositionsV2` (rather than `allDeFiPositions`) so it can live + * alongside the legacy `DeFiPositionsController` in clients that flatten every + * controller's state into a single object (e.g. the extension background), + * without colliding on the shared `allDeFiPositions` key. + */ + allDeFiPositionsV2: DeFiPositionsByAccount; +}; + +const controllerMetadata: StateMetadata = { + allDeFiPositionsV2: { + includeInStateLogs: false, + persist: true, + includeInDebugSnapshot: false, + usedInUi: true, + }, +}; + +export const getDefaultDeFiPositionsControllerV2State = + (): DeFiPositionsControllerV2State => { + return { + allDeFiPositionsV2: {}, + }; + }; + +export type DeFiPositionsControllerV2GetStateAction = ControllerGetStateAction< + typeof controllerName, + DeFiPositionsControllerV2State +>; + +export type DeFiPositionsControllerV2Actions = + | DeFiPositionsControllerV2GetStateAction + | DeFiPositionsControllerV2MethodActions; + +export type DeFiPositionsControllerV2StateChangedEvent = + ControllerStateChangedEvent< + typeof controllerName, + DeFiPositionsControllerV2State + >; + +export type DeFiPositionsControllerV2Events = + DeFiPositionsControllerV2StateChangedEvent; + +/** + * The external actions available to the {@link DeFiPositionsControllerV2}. + */ +export type AllowedActions = + | AccountTreeControllerGetAccountsFromSelectedAccountGroupAction + | RemoteFeatureFlagControllerGetStateAction; + +/** + * The external events available to the {@link DeFiPositionsControllerV2}. + * + * None yet — clients must call `fetchDeFiPositions` (and optionally + * `{ forceRefresh: true }`) on their own triggers. Likely future subscriptions: + * `AccountTreeController:selectedAccountGroupChange`, + * `TransactionController:transactionConfirmed`, and `KeyringController:lock`. + */ +export type AllowedEvents = never; + +export type DeFiPositionsControllerV2Messenger = Messenger< + typeof controllerName, + DeFiPositionsControllerV2Actions | AllowedActions, + DeFiPositionsControllerV2Events | AllowedEvents +>; + +/** + * Controller that fetches DeFi positions for the selected account group from + * the Accounts API (v6 multiaccount balances) and stores them in the shape the + * client consumes directly. + * + * Deduplication and freshness are handled by the shared TanStack Query cache on + * {@link ApiPlatformClient} (balances default `staleTime` is 1 minute). Pass + * `{ forceRefresh: true }` to bypass that cache on the first attempt (e.g. + * pull-to-refresh); later processing polls always bypass the cache. + * + * When the API reports account IDs in the response-level + * `processingDefiPositions` array, this controller polls until indexing + * finishes or the attempt limit is reached, and only then processes and writes + * state — responses that still list processing accounts leave prior state + * untouched. Concurrent calls for the same selected accounts and `vsCurrency` + * share one in-flight promise so the UI can treat the pending promise as a + * loading signal. Calls for a different selection or fiat currency start their + * own fetch and leave any prior poll running, so switching back can join an + * in-flight fetch for that group and currency. + * + * Processing-poll `maxAttempts` / `pollInterval` are read via + * {@link getProcessingPollConfig} from the `defiControllerV2` remote feature + * flag (`RemoteFeatureFlagController:getState`), falling back to built-in + * defaults when unset or invalid. Clients must delegate that action to this + * controller's messenger. + */ +export class DeFiPositionsControllerV2 extends BaseController< + typeof controllerName, + DeFiPositionsControllerV2State, + DeFiPositionsControllerV2Messenger +> { + readonly #apiClient: ApiPlatformClient; + + readonly #isEnabled: () => boolean; + + readonly #getVsCurrency: () => string; + + /** + * In-flight fetches keyed by selected DeFi-queryable account IDs plus + * `vsCurrency`. Concurrent callers for the same selection and currency share + * a promise; different selections or fiat currencies keep independent + * fetches so fast switching can join an earlier matching poll. + */ + readonly #inFlightFetches = new Map>(); + + /** + * @param options - Constructor options. + * @param options.messenger - The controller messenger. + * @param options.apiClient - Accounts API client used to fetch balances/positions. Auth is handled by the client. + * @param options.isEnabled - Returns whether fetching is enabled (default: () => false). + * @param options.getVsCurrency - Returns the fiat currency for prices (default: () => 'usd'). + * @param options.state - Initial controller state. + */ + constructor({ + messenger, + apiClient, + isEnabled, + getVsCurrency, + state, + }: { + messenger: DeFiPositionsControllerV2Messenger; + apiClient: ApiPlatformClient; + isEnabled: () => boolean; + getVsCurrency: () => string; + state?: Partial; + }) { + super({ + name: controllerName, + metadata: controllerMetadata, + messenger, + state: { + ...getDefaultDeFiPositionsControllerV2State(), + ...state, + }, + }); + + this.#apiClient = apiClient; + this.#isEnabled = isEnabled; + this.#getVsCurrency = getVsCurrency; + + this.messenger.registerMethodActionHandlers( + this, + MESSENGER_EXPOSED_METHODS, + ); + } + + /** + * Fetches DeFi positions for the selected account group. State is updated + * only when the response lists no accounts in its `processingDefiPositions` + * array; each resolved account then has its state replaced (other accounts + * stay). While any account is still indexing, prior state is kept and the + * method polls (invalidating the balances cache between attempts) until no + * accounts are processing, the attempt limit is reached, or a request fails. + * Concurrent calls for the same selected accounts and `vsCurrency` share one + * in-flight promise; calls for a different selection or fiat currency start + * a new fetch and leave prior polls running so a later switch back can join + * them. When a successful ready response required more than one attempt, or + * when polling hits the attempt limit while still processing, reports to + * Sentry via `messenger.captureException` (error names + * `DeFiPositionsV2FetchAttempts` / + * `DeFiPositionsV2ProcessingPollExhausted`) so poll limits can be tuned. + * No-ops when disabled or when the group has no + * supported accounts. Caching / spam prevention is handled by the apiClient + * TanStack Query cache (keyed by accounts + query options including + * `vsCurrency`). Pass `{ forceRefresh: true }` to bypass the cache on the + * first attempt (e.g. pull-to-refresh). + * + * @param options - Optional fetch modifiers. + * @param options.forceRefresh - When true, bypass the apiClient cache on the + * first attempt and fetch immediately. + * @returns Resolves when the fetch (and any processing polls) finish. + */ + async fetchDeFiPositions(options?: { + forceRefresh?: boolean; + }): Promise { + if (!this.#isEnabled()) { + return; + } + + const selectedAccounts = this.messenger.call( + 'AccountTreeController:getAccountsFromSelectedAccountGroup', + ); + const query = buildDeFiBalancesQuery(selectedAccounts); + const vsCurrency = this.#getVsCurrency().toLowerCase(); + // Include vsCurrency so a fiat change does not join a poll priced in the + // previous currency (TanStack also keys the balances cache on vsCurrency). + const inFlightKey = [ + ...[...query.internalAccountIdByCaip.keys()].sort(), + vsCurrency, + ].join('\0'); + + const existing = this.#inFlightFetches.get(inFlightKey); + if (existing) { + await existing; + return; + } + + // Same-key callers join this promise instead of replacing it, so cleanup + // can always delete without checking identity. + const fetchPromise = this.#fetchDeFiPositions( + options, + query, + vsCurrency, + ).finally(() => { + this.#inFlightFetches.delete(inFlightKey); + }); + this.#inFlightFetches.set(inFlightKey, fetchPromise); + + await fetchPromise; + } + + async #fetchDeFiPositions( + options: { forceRefresh?: boolean } | undefined, + { networks, internalAccountIdByCaip }: DeFiBalancesQuery, + vsCurrency: string, + ): Promise { + if (internalAccountIdByCaip.size === 0 || networks.length === 0) { + return; + } + + const accountIds = [...internalAccountIdByCaip.keys()]; + const queryOptions = { + networks, + includeDeFiBalances: true, + forceFetchDeFiPositions: true, + includePrices: true, + vsCurrency, + }; + + // Resolve once per fetch so a mid-poll remote-flag change cannot stretch + // or shrink this poll sequence inconsistently. + const { maxAttempts, pollInterval } = getProcessingPollConfig( + this.messenger, + ); + + for (let attempt = 0; attempt < maxAttempts; attempt++) { + // First attempt respects forceRefresh; later polls always bypass cache + // so we do not spin on a stale processing snapshot. + const fetchOptions = { + ...(options?.forceRefresh || attempt > 0 ? { staleTime: 0 } : {}), + }; + + let response; + try { + response = await this.#apiClient.accounts.fetchV6MultiAccountBalances( + accountIds, + queryOptions, + fetchOptions, + ); + } catch (error) { + // Soft-fail so prior state stays and the in-flight promise settles. + console.error('Failed to fetch DeFi positions', error); + return; + } + + const stillProcessing = + (response.processingDefiPositions?.length ?? 0) > 0; + + // Only process and write when no account is still indexing, so a + // partial response (whose DeFi rows for processing accounts are + // omitted) cannot clear or overwrite positions for those accounts. + if (!stillProcessing) { + const positionsByAccount = groupDeFiPositionsV6( + response, + internalAccountIdByCaip, + ); + + this.update((state) => { + for (const [accountId, positions] of Object.entries( + positionsByAccount, + )) { + state.allDeFiPositionsV2[accountId] = positions; + } + }); + + // Report how many attempts were needed (only when polling was + // required) so Sentry can inform remote-flag / default poll-limit + // tuning without flooding on first-try successes. + const attemptsTaken = attempt + 1; + if (attemptsTaken > 1) { + const multipleAttemptsError = new Error( + `DeFiPositionsControllerV2: positions ready after ${attemptsTaken} attempt(s)`, + ); + multipleAttemptsError.name = 'DeFiPositionsV2FetchAttempts'; + this.messenger.captureException?.(multipleAttemptsError); + } + return; + } + + const { queryKey } = + this.#apiClient.accounts.getV6MultiAccountBalancesQueryOptions( + accountIds, + queryOptions, + fetchOptions, + ); + await this.#apiClient.accounts.queryClient.invalidateQueries({ + queryKey, + }); + + const isLastAttempt = attempt >= maxAttempts - 1; + if (isLastAttempt) { + // Report exhausted polls so Sentry can inform remote-flag / default + // poll-limit tuning when indexing never finishes in time. + const multipleAttemptsError = new Error( + `DeFiPositionsControllerV2: still processing after ${maxAttempts} attempt(s)`, + ); + multipleAttemptsError.name = 'DeFiPositionsV2ProcessingPollExhausted'; + this.messenger.captureException?.(multipleAttemptsError); + return; + } + + await delay(pollInterval); + } + } +} diff --git a/packages/assets-controllers/src/DeFiPositionsController/__fixtures__/mock-responses.ts b/packages/assets-controllers/src/DeFiPositionsController/__fixtures__/mock-responses.ts new file mode 100644 index 00000000000..94cfdbbeef5 --- /dev/null +++ b/packages/assets-controllers/src/DeFiPositionsController/__fixtures__/mock-responses.ts @@ -0,0 +1,636 @@ +import type { DefiPositionResponse } from '../fetch-positions.js'; + +/** + * Entries are from different chains + */ +export const MOCK_DEFI_RESPONSE_MULTI_CHAIN: DefiPositionResponse[] = [ + { + protocolId: 'aave-v3', + name: 'Aave v3 AToken', + description: 'Aave v3 defi adapter for yield-generating token', + siteUrl: 'https://aave.com/', + iconUrl: 'https://cryptologos.cc/logos/aave-aave-logo.png', + positionType: 'supply', + chainId: 1, + productId: 'a-token', + chainName: 'ethereum', + protocolDisplayName: 'Aave V3', + metadata: { + groupPositions: true, + }, + success: true, + tokens: [ + { + address: '0x4d5F47FA6A74757f35C14fD3a6Ef8E3C9BC514E8', + name: 'Aave Ethereum WETH', + symbol: 'aEthWETH', + decimals: 18, + balanceRaw: '5000000000000000000', + balance: 5, + type: 'protocol', + tokens: [ + { + address: '0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2', + name: 'Wrapped Ether', + symbol: 'WETH', + decimals: 18, + type: 'underlying', + balanceRaw: '5000000000000000000', + balance: 5, + price: 1000, + iconUrl: + 'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png', + }, + ], + }, + ], + }, + { + protocolId: 'aave-v3', + name: 'Aave v3 AToken', + description: 'Aave v3 defi adapter for yield-generating token', + siteUrl: 'https://aave.com/', + iconUrl: 'https://cryptologos.cc/logos/aave-aave-logo.png', + positionType: 'supply', + chainId: 8453, + productId: 'a-token', + chainName: 'base', + protocolDisplayName: 'Aave V3', + metadata: { + groupPositions: true, + }, + success: true, + tokens: [ + { + address: '0x4d5F47FA6A74757f35C14fD3a6Ef8E3C9BC514E8', + name: 'Aave Ethereum WETH', + symbol: 'aEthWETH', + decimals: 18, + balanceRaw: '5000000000000000000', + balance: 5, + type: 'protocol', + tokens: [ + { + address: '0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2', + name: 'Wrapped Ether', + symbol: 'WETH', + decimals: 18, + type: 'underlying', + balanceRaw: '5000000000000000000', + balance: 5, + price: 1000, + iconUrl: + 'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png', + }, + ], + }, + ], + }, +]; + +/** + * The first entry is a failed entry + */ +export const MOCK_DEFI_RESPONSE_FAILED_ENTRY: DefiPositionResponse[] = [ + { + protocolId: 'aave-v3', + name: 'Aave v3 VariableDebtToken', + description: 'Aave v3 defi adapter for variable interest-accruing token', + siteUrl: 'https://aave.com/', + iconUrl: 'https://cryptologos.cc/logos/aave-aave-logo.png', + positionType: 'borrow', + chainId: 1, + productId: 'variable-debt-token', + chainName: 'ethereum', + protocolDisplayName: 'Aave V3', + metadata: { + groupPositions: true, + }, + success: false, + error: { + message: 'Failed to fetch positions', + }, + }, + { + protocolId: 'aave-v3', + name: 'Aave v3 AToken', + description: 'Aave v3 defi adapter for yield-generating token', + siteUrl: 'https://aave.com/', + iconUrl: 'https://cryptologos.cc/logos/aave-aave-logo.png', + positionType: 'supply', + chainId: 1, + productId: 'a-token', + chainName: 'ethereum', + protocolDisplayName: 'Aave V3', + metadata: { + groupPositions: true, + }, + success: true, + tokens: [ + { + address: '0x4d5F47FA6A74757f35C14fD3a6Ef8E3C9BC514E8', + name: 'Aave Ethereum WETH', + symbol: 'aEthWETH', + decimals: 18, + balanceRaw: '5000000000000000000', + balance: 5, + type: 'protocol', + tokens: [ + { + address: '0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2', + name: 'Wrapped Ether', + symbol: 'WETH', + decimals: 18, + type: 'underlying', + balanceRaw: '5000000000000000000', + balance: 5, + price: 1000, + iconUrl: + 'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png', + }, + ], + }, + ], + }, +]; + +/** + * The second entry has no price + */ +export const MOCK_DEFI_RESPONSE_NO_PRICES: DefiPositionResponse[] = [ + { + protocolId: 'aave-v3', + name: 'Aave v3 AToken', + description: 'Aave v3 defi adapter for yield-generating token', + siteUrl: 'https://aave.com/', + iconUrl: 'https://cryptologos.cc/logos/aave-aave-logo.png', + positionType: 'supply', + chainId: 1, + productId: 'a-token', + chainName: 'ethereum', + protocolDisplayName: 'Aave V3', + metadata: { + groupPositions: true, + }, + success: true, + tokens: [ + { + address: '0x4d5F47FA6A74757f35C14fD3a6Ef8E3C9BC514E8', + name: 'Aave Ethereum WETH', + symbol: 'aEthWETH', + decimals: 18, + balanceRaw: '40000000000000000', + balance: 0.04, + type: 'protocol', + tokens: [ + { + address: '0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2', + name: 'Wrapped Ether', + symbol: 'WETH', + decimals: 18, + type: 'underlying', + balanceRaw: '40000000000000000', + balance: 0.04, + price: 1000, + iconUrl: + 'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png', + }, + ], + }, + { + address: '0x5Ee5bf7ae06D1Be5997A1A72006FE6C607eC6DE8', + name: 'Aave Ethereum WBTC', + symbol: 'aEthWBTC', + decimals: 8, + balanceRaw: '300000000', + balance: 3, + type: 'protocol', + tokens: [ + { + address: '0x2260FAC5E5542a773Aa44fBCfeDf7C193bc2C599', + name: 'Wrapped BTC', + symbol: 'WBTC', + decimals: 8, + type: 'underlying', + balanceRaw: '300000000', + balance: 3, + price: undefined, + iconUrl: + 'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0x2260FAC5E5542a773Aa44fBCfeDf7C193bc2C599/logo.png', + }, + ], + }, + ], + }, +]; + +/** + * The second entry is a borrow position + */ +export const MOCK_DEFI_RESPONSE_BORROW: DefiPositionResponse[] = [ + { + protocolId: 'aave-v3', + name: 'Aave v3 AToken', + description: 'Aave v3 defi adapter for yield-generating token', + siteUrl: 'https://aave.com/', + iconUrl: 'https://cryptologos.cc/logos/aave-aave-logo.png', + positionType: 'supply', + chainId: 1, + productId: 'a-token', + chainName: 'ethereum', + protocolDisplayName: 'Aave V3', + metadata: { + groupPositions: true, + }, + success: true, + tokens: [ + { + address: '0x4d5F47FA6A74757f35C14fD3a6Ef8E3C9BC514E8', + name: 'Aave Ethereum WETH', + symbol: 'aEthWETH', + decimals: 18, + balanceRaw: '40000000000000000', + balance: 0.04, + type: 'protocol', + tokens: [ + { + address: '0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2', + name: 'Wrapped Ether', + symbol: 'WETH', + decimals: 18, + type: 'underlying', + balanceRaw: '40000000000000000', + balance: 0.04, + price: 1000, + iconUrl: + 'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png', + }, + ], + }, + { + address: '0x5Ee5bf7ae06D1Be5997A1A72006FE6C607eC6DE8', + name: 'Aave Ethereum WBTC', + symbol: 'aEthWBTC', + decimals: 8, + balanceRaw: '300000000', + balance: 3, + type: 'protocol', + tokens: [ + { + address: '0x2260FAC5E5542a773Aa44fBCfeDf7C193bc2C599', + name: 'Wrapped BTC', + symbol: 'WBTC', + decimals: 8, + type: 'underlying', + balanceRaw: '300000000', + balance: 3, + price: 500, + iconUrl: + 'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0x2260FAC5E5542a773Aa44fBCfeDf7C193bc2C599/logo.png', + }, + ], + }, + ], + }, + { + protocolId: 'aave-v3', + name: 'Aave v3 VariableDebtToken', + description: 'Aave v3 defi adapter for variable interest-accruing token', + siteUrl: 'https://aave.com/', + iconUrl: 'https://cryptologos.cc/logos/aave-aave-logo.png', + positionType: 'borrow', + chainId: 1, + productId: 'variable-debt-token', + chainName: 'ethereum', + protocolDisplayName: 'Aave V3', + metadata: { + groupPositions: true, + }, + success: true, + tokens: [ + { + address: '0x6df1C1E379bC5a00a7b4C6e67A203333772f45A8', + name: 'Aave Ethereum Variable Debt USDT', + symbol: 'variableDebtEthUSDT', + decimals: 6, + balanceRaw: '1000000000', + type: 'protocol', + tokens: [ + { + address: '0xdAC17F958D2ee523a2206206994597C13D831ec7', + name: 'Tether USD', + symbol: 'USDT', + decimals: 6, + type: 'underlying', + balanceRaw: '1000000000', + balance: 1000, + price: 1, + iconUrl: + 'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xdAC17F958D2ee523a2206206994597C13D831ec7/logo.png', + }, + ], + balance: 1000, + }, + ], + }, +]; + +/** + * Complex mock with multiple chains, failed entries, borrow positions, etc. + */ +export const MOCK_DEFI_RESPONSE_COMPLEX: DefiPositionResponse[] = [ + { + protocolId: 'aave-v3', + name: 'Aave v3 AToken', + description: 'Aave v3 defi adapter for yield-generating token', + siteUrl: 'https://aave.com/', + iconUrl: 'https://cryptologos.cc/logos/aave-aave-logo.png', + positionType: 'supply', + chainId: 1, + productId: 'a-token', + chainName: 'ethereum', + protocolDisplayName: 'Aave V3', + metadata: { + groupPositions: true, + }, + success: true, + tokens: [ + { + address: '0x4d5F47FA6A74757f35C14fD3a6Ef8E3C9BC514E8', + name: 'Aave Ethereum WETH', + symbol: 'aEthWETH', + decimals: 18, + balanceRaw: '40000000000000000', + balance: 0.04, + type: 'protocol', + tokens: [ + { + address: '0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2', + name: 'Wrapped Ether', + symbol: 'WETH', + decimals: 18, + type: 'underlying', + balanceRaw: '40000000000000000', + balance: 0.04, + price: 1000, + iconUrl: + 'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png', + }, + ], + }, + { + address: '0x5Ee5bf7ae06D1Be5997A1A72006FE6C607eC6DE8', + name: 'Aave Ethereum WBTC', + symbol: 'aEthWBTC', + decimals: 8, + balanceRaw: '300000000', + balance: 3, + type: 'protocol', + tokens: [ + { + address: '0x2260FAC5E5542a773Aa44fBCfeDf7C193bc2C599', + name: 'Wrapped BTC', + symbol: 'WBTC', + decimals: 8, + type: 'underlying', + balanceRaw: '300000000', + balance: 3, + price: 500, + iconUrl: + 'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0x2260FAC5E5542a773Aa44fBCfeDf7C193bc2C599/logo.png', + }, + ], + }, + ], + }, + { + protocolId: 'aave-v3', + name: 'Aave v3 VariableDebtToken', + description: 'Aave v3 defi adapter for variable interest-accruing token', + siteUrl: 'https://aave.com/', + iconUrl: 'https://cryptologos.cc/logos/aave-aave-logo.png', + positionType: 'borrow', + chainId: 1, + productId: 'variable-debt-token', + chainName: 'ethereum', + protocolDisplayName: 'Aave V3', + metadata: { + groupPositions: true, + }, + success: true, + tokens: [ + { + address: '0x6df1C1E379bC5a00a7b4C6e67A203333772f45A8', + name: 'Aave Ethereum Variable Debt USDT', + symbol: 'variableDebtEthUSDT', + decimals: 6, + balanceRaw: '1000000000', + type: 'protocol', + tokens: [ + { + address: '0xdAC17F958D2ee523a2206206994597C13D831ec7', + name: 'Tether USD', + symbol: 'USDT', + decimals: 6, + type: 'underlying', + balanceRaw: '1000000000', + balance: 1000, + price: 1, + iconUrl: + 'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xdAC17F958D2ee523a2206206994597C13D831ec7/logo.png', + }, + ], + balance: 1000, + }, + ], + }, + { + protocolId: 'lido', + name: 'Lido wstEth', + description: 'Lido defi adapter for wstEth', + siteUrl: 'https://stake.lido.fi/wrap', + iconUrl: + 'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xae7ab96520DE3A18E5e111B5EaAb095312D7fE84/logo.png', + positionType: 'stake', + chainId: 1, + productId: 'wst-eth', + chainName: 'ethereum', + protocolDisplayName: 'Lido', + success: true, + tokens: [ + { + address: '0x7f39C581F595B53c5cb19bD0b3f8dA6c935E2Ca0', + name: 'Wrapped liquid staked Ether 2.0', + symbol: 'wstETH', + decimals: 18, + balanceRaw: '800000000000000000000', + balance: 800, + type: 'protocol', + tokens: [ + { + address: '0xae7ab96520DE3A18E5e111B5EaAb095312D7fE84', + name: 'Liquid staked Ether 2.0', + symbol: 'stETH', + decimals: 18, + type: 'underlying', + balanceRaw: '1000000000000000000', + balance: 10, + price: 2000, + iconUrl: + 'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xae7ab96520DE3A18E5e111B5EaAb095312D7fE84/logo.png', + tokens: [ + { + address: '0x0000000000000000000000000000000000000000', + name: 'Ethereum', + symbol: 'ETH', + decimals: 18, + type: 'underlying', + balanceRaw: '1000000000000000000', + balance: 10, + price: 2000, + iconUrl: + 'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/info/logo.png', + }, + ], + }, + ], + }, + ], + }, + { + protocolId: 'uniswap-v3', + name: 'UniswapV3', + description: 'UniswapV3 defi adapter', + siteUrl: 'https://uniswap.org/', + iconUrl: + 'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0x1f9840a85d5aF5bf1D1762F925BDADdC4201F984/logo.png', + positionType: 'supply', + chainId: 8453, + productId: 'pool', + chainName: 'base', + protocolDisplayName: 'Uniswap V3', + success: true, + tokens: [ + { + address: '0xC36442b4a4522E871399CD717aBDD847Ab11FE88', + tokenId: '940758', + name: 'GASP / USDT - 0.3%', + symbol: 'GASP / USDT - 0.3%', + decimals: 18, + balanceRaw: '1000000000000000000', + balance: 1, + type: 'protocol', + tokens: [ + { + address: '0x736ECc5237B31eDec6f1aB9a396FaE2416b1d96E', + name: 'GASP', + symbol: 'GASP', + decimals: 18, + balanceRaw: '100000000000000000000', + type: 'underlying', + balance: 100, + price: 0.1, + iconUrl: + 'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0x736ECc5237B31eDec6f1aB9a396FaE2416b1d96E/logo.png', + }, + { + address: '0x736ECc5237B31eDec6f1aB9a396FaE2416b1d96E', + name: 'GASP', + symbol: 'GASP', + decimals: 18, + balanceRaw: '10000000000000000000', + type: 'underlying-claimable', + balance: 10, + price: 0.1, + iconUrl: + 'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0x736ECc5237B31eDec6f1aB9a396FaE2416b1d96E/logo.png', + }, + { + address: '0xdAC17F958D2ee523a2206206994597C13D831ec7', + name: 'Tether USD', + symbol: 'USDT', + decimals: 6, + balanceRaw: '500000000', + type: 'underlying', + balance: 500, + price: 1, + iconUrl: + 'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xdAC17F958D2ee523a2206206994597C13D831ec7/logo.png', + }, + { + address: '0xdAC17F958D2ee523a2206206994597C13D831ec7', + name: 'Tether USD', + symbol: 'USDT', + decimals: 6, + balanceRaw: '2000000', + type: 'underlying-claimable', + balance: 2, + price: 1, + iconUrl: + 'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xdAC17F958D2ee523a2206206994597C13D831ec7/logo.png', + }, + ], + }, + { + address: '0xC36442b4a4522E871399CD717aBDD847Ab11FE88', + tokenId: '940760', + name: 'GASP / USDT - 0.3%', + symbol: 'GASP / USDT - 0.3%', + decimals: 18, + balanceRaw: '2000000000000000000', + balance: 2, + type: 'protocol', + tokens: [ + { + address: '0x736ECc5237B31eDec6f1aB9a396FaE2416b1d96E', + name: 'GASP', + symbol: 'GASP', + decimals: 18, + balanceRaw: '90000000000000000000000', + type: 'underlying', + balance: 90000, + price: 0.1, + iconUrl: + 'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0x736ECc5237B31eDec6f1aB9a396FaE2416b1d96E/logo.png', + }, + { + address: '0x736ECc5237B31eDec6f1aB9a396FaE2416b1d96E', + name: 'GASP', + symbol: 'GASP', + decimals: 18, + balanceRaw: '50000000000000000000', + type: 'underlying-claimable', + balance: 50, + price: 0.1, + iconUrl: + 'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0x736ECc5237B31eDec6f1aB9a396FaE2416b1d96E/logo.png', + }, + { + address: '0xdAC17F958D2ee523a2206206994597C13D831ec7', + name: 'Tether USD', + symbol: 'USDT', + decimals: 6, + balanceRaw: '60000000', + type: 'underlying', + balance: 60, + price: 1, + iconUrl: + 'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xdAC17F958D2ee523a2206206994597C13D831ec7/logo.png', + }, + { + address: '0xdAC17F958D2ee523a2206206994597C13D831ec7', + name: 'Tether USD', + symbol: 'USDT', + decimals: 6, + balanceRaw: '2000000', + type: 'underlying-claimable', + balance: 2, + price: 1, + iconUrl: + 'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xdAC17F958D2ee523a2206206994597C13D831ec7/logo.png', + }, + ], + }, + ], + }, +]; diff --git a/packages/assets-controllers/src/DeFiPositionsController/__fixtures__/mock-result.ts b/packages/assets-controllers/src/DeFiPositionsController/__fixtures__/mock-result.ts new file mode 100644 index 00000000000..3be5588f6f8 --- /dev/null +++ b/packages/assets-controllers/src/DeFiPositionsController/__fixtures__/mock-result.ts @@ -0,0 +1,305 @@ +import type { Hex } from '@metamask/utils'; + +import type { GroupedDeFiPositions } from '../group-defi-positions.js'; + +export const MOCK_EXPECTED_RESULT: { [key: Hex]: GroupedDeFiPositions } = { + '0x1': { + aggregatedMarketValue: 20540, + protocols: { + 'aave-v3': { + protocolDetails: { + name: 'Aave V3', + iconUrl: 'https://cryptologos.cc/logos/aave-aave-logo.png', + }, + aggregatedMarketValue: 540, + positionTypes: { + supply: { + aggregatedMarketValue: 1540, + positions: [ + [ + { + address: '0x4d5F47FA6A74757f35C14fD3a6Ef8E3C9BC514E8', + name: 'Aave Ethereum WETH', + symbol: 'aEthWETH', + decimals: 18, + balanceRaw: '40000000000000000', + balance: 0.04, + marketValue: 40, + type: 'protocol', + tokens: [ + { + address: '0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2', + name: 'Wrapped Ether', + symbol: 'WETH', + decimals: 18, + type: 'underlying', + balanceRaw: '40000000000000000', + balance: 0.04, + price: 1000, + marketValue: 40, + iconUrl: + 'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png', + }, + ], + }, + { + address: '0x5Ee5bf7ae06D1Be5997A1A72006FE6C607eC6DE8', + name: 'Aave Ethereum WBTC', + symbol: 'aEthWBTC', + decimals: 8, + balanceRaw: '300000000', + balance: 3, + marketValue: 1500, + type: 'protocol', + tokens: [ + { + address: '0x2260FAC5E5542a773Aa44fBCfeDf7C193bc2C599', + name: 'Wrapped BTC', + symbol: 'WBTC', + decimals: 8, + type: 'underlying', + balanceRaw: '300000000', + balance: 3, + price: 500, + marketValue: 1500, + iconUrl: + 'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0x2260FAC5E5542a773Aa44fBCfeDf7C193bc2C599/logo.png', + }, + ], + }, + ], + ], + }, + borrow: { + aggregatedMarketValue: 1000, + positions: [ + [ + { + address: '0x6df1C1E379bC5a00a7b4C6e67A203333772f45A8', + name: 'Aave Ethereum Variable Debt USDT', + symbol: 'variableDebtEthUSDT', + decimals: 6, + balanceRaw: '1000000000', + marketValue: 1000, + type: 'protocol', + tokens: [ + { + address: '0xdAC17F958D2ee523a2206206994597C13D831ec7', + name: 'Tether USD', + symbol: 'USDT', + decimals: 6, + type: 'underlying', + balanceRaw: '1000000000', + balance: 1000, + price: 1, + marketValue: 1000, + iconUrl: + 'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xdAC17F958D2ee523a2206206994597C13D831ec7/logo.png', + }, + ], + balance: 1000, + }, + ], + ], + }, + }, + }, + lido: { + protocolDetails: { + name: 'Lido', + iconUrl: + 'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xae7ab96520DE3A18E5e111B5EaAb095312D7fE84/logo.png', + }, + aggregatedMarketValue: 20000, + positionTypes: { + stake: { + aggregatedMarketValue: 20000, + positions: [ + [ + { + address: '0x7f39C581F595B53c5cb19bD0b3f8dA6c935E2Ca0', + name: 'Wrapped liquid staked Ether 2.0', + symbol: 'wstETH', + decimals: 18, + balanceRaw: '800000000000000000000', + balance: 800, + marketValue: 20000, + type: 'protocol', + tokens: [ + { + address: '0xae7ab96520DE3A18E5e111B5EaAb095312D7fE84', + name: 'Liquid staked Ether 2.0', + symbol: 'stETH', + decimals: 18, + type: 'underlying', + balanceRaw: '1000000000000000000', + balance: 10, + price: 2000, + marketValue: 20000, + iconUrl: + 'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xae7ab96520DE3A18E5e111B5EaAb095312D7fE84/logo.png', + }, + ], + }, + ], + ], + }, + }, + }, + }, + }, + '0x2105': { + aggregatedMarketValue: 9580, + protocols: { + 'uniswap-v3': { + protocolDetails: { + name: 'Uniswap V3', + iconUrl: + 'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0x1f9840a85d5aF5bf1D1762F925BDADdC4201F984/logo.png', + }, + aggregatedMarketValue: 9580, + positionTypes: { + supply: { + aggregatedMarketValue: 9580, + positions: [ + [ + { + address: '0xC36442b4a4522E871399CD717aBDD847Ab11FE88', + tokenId: '940758', + name: 'GASP / USDT - 0.3%', + symbol: 'GASP / USDT - 0.3%', + decimals: 18, + balanceRaw: '1000000000000000000', + balance: 1, + marketValue: 513, + type: 'protocol', + tokens: [ + { + address: '0x736ECc5237B31eDec6f1aB9a396FaE2416b1d96E', + name: 'GASP', + symbol: 'GASP', + decimals: 18, + balanceRaw: '100000000000000000000', + type: 'underlying', + balance: 100, + price: 0.1, + marketValue: 10, + iconUrl: + 'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0x736ECc5237B31eDec6f1aB9a396FaE2416b1d96E/logo.png', + }, + { + address: '0x736ECc5237B31eDec6f1aB9a396FaE2416b1d96E', + name: 'GASP', + symbol: 'GASP', + decimals: 18, + balanceRaw: '10000000000000000000', + type: 'underlying-claimable', + balance: 10, + price: 0.1, + marketValue: 1, + iconUrl: + 'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0x736ECc5237B31eDec6f1aB9a396FaE2416b1d96E/logo.png', + }, + { + address: '0xdAC17F958D2ee523a2206206994597C13D831ec7', + name: 'Tether USD', + symbol: 'USDT', + decimals: 6, + balanceRaw: '500000000', + type: 'underlying', + balance: 500, + price: 1, + marketValue: 500, + iconUrl: + 'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xdAC17F958D2ee523a2206206994597C13D831ec7/logo.png', + }, + { + address: '0xdAC17F958D2ee523a2206206994597C13D831ec7', + name: 'Tether USD', + symbol: 'USDT', + decimals: 6, + balanceRaw: '2000000', + type: 'underlying-claimable', + balance: 2, + price: 1, + marketValue: 2, + iconUrl: + 'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xdAC17F958D2ee523a2206206994597C13D831ec7/logo.png', + }, + ], + }, + ], + [ + { + address: '0xC36442b4a4522E871399CD717aBDD847Ab11FE88', + tokenId: '940760', + name: 'GASP / USDT - 0.3%', + symbol: 'GASP / USDT - 0.3%', + decimals: 18, + balanceRaw: '2000000000000000000', + balance: 2, + marketValue: 9067, + type: 'protocol', + tokens: [ + { + address: '0x736ECc5237B31eDec6f1aB9a396FaE2416b1d96E', + name: 'GASP', + symbol: 'GASP', + decimals: 18, + balanceRaw: '90000000000000000000000', + type: 'underlying', + balance: 90000, + price: 0.1, + marketValue: 9000, + iconUrl: + 'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0x736ECc5237B31eDec6f1aB9a396FaE2416b1d96E/logo.png', + }, + { + address: '0x736ECc5237B31eDec6f1aB9a396FaE2416b1d96E', + name: 'GASP', + symbol: 'GASP', + decimals: 18, + balanceRaw: '50000000000000000000', + type: 'underlying-claimable', + balance: 50, + price: 0.1, + marketValue: 5, + iconUrl: + 'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0x736ECc5237B31eDec6f1aB9a396FaE2416b1d96E/logo.png', + }, + { + address: '0xdAC17F958D2ee523a2206206994597C13D831ec7', + name: 'Tether USD', + symbol: 'USDT', + decimals: 6, + balanceRaw: '60000000', + type: 'underlying', + balance: 60, + price: 1, + marketValue: 60, + iconUrl: + 'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xdAC17F958D2ee523a2206206994597C13D831ec7/logo.png', + }, + { + address: '0xdAC17F958D2ee523a2206206994597C13D831ec7', + name: 'Tether USD', + symbol: 'USDT', + decimals: 6, + balanceRaw: '2000000', + type: 'underlying-claimable', + balance: 2, + price: 1, + marketValue: 2, + iconUrl: + 'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xdAC17F958D2ee523a2206206994597C13D831ec7/logo.png', + }, + ], + }, + ], + ], + }, + }, + }, + }, + }, +}; diff --git a/packages/assets-controllers/src/DeFiPositionsController/build-defi-balances-query.test.ts b/packages/assets-controllers/src/DeFiPositionsController/build-defi-balances-query.test.ts new file mode 100644 index 00000000000..0bb513029b6 --- /dev/null +++ b/packages/assets-controllers/src/DeFiPositionsController/build-defi-balances-query.test.ts @@ -0,0 +1,164 @@ +import { + BtcAccountType, + EthAccountType, + EthMethod, + EthScope, + SolAccountType, + SolMethod, + SolScope, +} from '@metamask/keyring-api'; +import { KeyringTypes } from '@metamask/keyring-controller'; +import type { InternalAccount } from '@metamask/keyring-internal-api'; + +import { createMockInternalAccount } from '../../../accounts-controller/tests/mocks.js'; +import { + buildDeFiBalancesQuery, + DEFI_SUPPORTED_NETWORKS, +} from './build-defi-balances-query.js'; + +const EVM_ADDRESS = '0x0000000000000000000000000000000000000001'; +const SOLANA_ADDRESS = 'So11111111111111111111111111111111111111112'; + +const mockEvmAccount = createMockInternalAccount({ + id: 'evm-account-id', + address: EVM_ADDRESS, + type: EthAccountType.Eoa, +}); + +const mockSolanaAccount: InternalAccount = { + id: 'solana-account-id', + address: SOLANA_ADDRESS, + options: {}, + methods: [SolMethod.SendAndConfirmTransaction], + scopes: [SolScope.Mainnet], + type: SolAccountType.DataAccount, + metadata: { + name: 'Solana Account', + keyring: { type: KeyringTypes.snap }, + importTime: Date.now(), + lastSelected: Date.now(), + snap: { + id: 'mock-sol-snap', + name: 'mock-sol-snap', + enabled: true, + }, + }, +}; + +const mockBtcAccount = createMockInternalAccount({ + id: 'btc-account-id', + type: BtcAccountType.P2wpkh, +}); + +describe('buildDeFiBalancesQuery', () => { + it('builds an EVM CAIP account spanning all supported EVM networks', () => { + const mixedCaseEvmAccount = createMockInternalAccount({ + id: 'evm-account-id', + address: EVM_ADDRESS.toUpperCase(), + type: EthAccountType.Eoa, + }); + const result = buildDeFiBalancesQuery([ + mixedCaseEvmAccount, + mockBtcAccount, + ]); + + const expectedEvmNetworks = DEFI_SUPPORTED_NETWORKS.filter((network) => + network.startsWith('eip155:'), + ); + + expect(result.networks).toStrictEqual(expectedEvmNetworks); + expect([...result.internalAccountIdByCaip.entries()]).toStrictEqual([ + [`eip155:0:${EVM_ADDRESS.toLowerCase()}`, 'evm-account-id'], + ]); + }); + + it('builds a Solana CAIP account for supported Solana networks', () => { + const result = buildDeFiBalancesQuery([mockSolanaAccount, mockBtcAccount]); + + const expectedSolanaNetworks = DEFI_SUPPORTED_NETWORKS.filter((network) => + network.startsWith('solana:'), + ); + const [, solanaReference] = SolScope.Mainnet.split(':'); + + expect(result.networks).toStrictEqual(expectedSolanaNetworks); + expect([...result.internalAccountIdByCaip.entries()]).toStrictEqual([ + [`solana:${solanaReference}:${SOLANA_ADDRESS}`, 'solana-account-id'], + ]); + }); + + it('combines EVM and Solana accounts from the selected group', () => { + const result = buildDeFiBalancesQuery([ + mockEvmAccount, + mockSolanaAccount, + mockBtcAccount, + ]); + + const expectedEvmNetworks = DEFI_SUPPORTED_NETWORKS.filter((network) => + network.startsWith('eip155:'), + ); + const expectedSolanaNetworks = DEFI_SUPPORTED_NETWORKS.filter((network) => + network.startsWith('solana:'), + ); + + // EVM networks are added first, then Solana — not the literal + // DEFI_SUPPORTED_NETWORKS order (where Solana sits mid-list). + expect(result.networks).toStrictEqual([ + ...expectedEvmNetworks, + ...expectedSolanaNetworks, + ]); + expect(result.internalAccountIdByCaip.size).toBe(2); + expect( + result.internalAccountIdByCaip.get( + `eip155:0:${EVM_ADDRESS.toLowerCase()}`, + ), + ).toBe('evm-account-id'); + expect( + result.internalAccountIdByCaip.get( + `solana:${SolScope.Mainnet.split(':')[1]}:${SOLANA_ADDRESS}`, + ), + ).toBe('solana-account-id'); + }); + + it('returns empty networks and map when there are no supported accounts', () => { + const result = buildDeFiBalancesQuery([mockBtcAccount]); + + expect(result).toStrictEqual({ + networks: [], + internalAccountIdByCaip: new Map(), + }); + }); + + it('uses only the first EVM and first Solana account in the group', () => { + const secondEvmAccount = createMockInternalAccount({ + id: 'evm-account-id-2', + address: '0x0000000000000000000000000000000000000002', + type: EthAccountType.Eoa, + methods: [EthMethod.SignTransaction], + scopes: [EthScope.Eoa], + }); + const secondSolanaAccount: InternalAccount = { + ...mockSolanaAccount, + id: 'solana-account-id-2', + address: 'So22222222222222222222222222222222222222222', + }; + + const result = buildDeFiBalancesQuery([ + mockEvmAccount, + secondEvmAccount, + mockSolanaAccount, + secondSolanaAccount, + ]); + + expect(result.internalAccountIdByCaip.size).toBe(2); + expect( + result.internalAccountIdByCaip.get( + `eip155:0:${EVM_ADDRESS.toLowerCase()}`, + ), + ).toBe('evm-account-id'); + expect( + result.internalAccountIdByCaip.get( + `solana:${SolScope.Mainnet.split(':')[1]}:${SOLANA_ADDRESS}`, + ), + ).toBe('solana-account-id'); + }); +}); diff --git a/packages/assets-controllers/src/DeFiPositionsController/build-defi-balances-query.ts b/packages/assets-controllers/src/DeFiPositionsController/build-defi-balances-query.ts new file mode 100644 index 00000000000..cde9b786125 --- /dev/null +++ b/packages/assets-controllers/src/DeFiPositionsController/build-defi-balances-query.ts @@ -0,0 +1,113 @@ +import { + isEvmAccountType, + SolAccountType, + SolScope, +} from '@metamask/keyring-api'; +import type { InternalAccount } from '@metamask/keyring-internal-api'; +import type { CaipAccountId, CaipChainId } from '@metamask/utils'; +import { KnownCaipNamespace, toCaipAccountId } from '@metamask/utils'; + +/** + * Networks the DeFi balances (v6 multiaccount) endpoint supports. + * Cross-section of the supported chains from: + * https://developers.zerion.io/supported-blockchains + * https://accounts.api.cx.metamask.io/v2/supportedNetworks + */ +export const DEFI_SUPPORTED_NETWORKS: readonly CaipChainId[] = [ + 'eip155:1', + 'eip155:137', + 'eip155:56', + 'eip155:1329', + 'eip155:43114', + 'eip155:59144', + 'eip155:8453', + 'eip155:10', + 'eip155:42161', + 'eip155:143', + 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp', + 'eip155:999', + 'eip155:5042', +]; + +const SOLANA_MAINNET_CAIP_CHAIN_ID: CaipChainId = SolScope.Mainnet; + +export type DeFiBalancesQuery = { + /** CAIP-2 networks to query, deduped across accounts. */ + networks: CaipChainId[]; + /** + * Request CAIP-10 account IDs → internal MetaMask account IDs + * (`InternalAccount.id`). EVM keys use the all-chains reference and a + * lowercased address; Solana keys keep address case. + */ + internalAccountIdByCaip: Map; +}; + +/** + * Builds an EVM CAIP-10 account ID that spans every EVM chain (reference `0`). + * Addresses are lowercased because EVM addresses are case-insensitive. + * + * @param address - The EVM account address. + * @returns The CAIP-10 account ID for the address. + */ +function toEvmCaipAccountId(address: string): CaipAccountId { + return toCaipAccountId(KnownCaipNamespace.Eip155, '0', address.toLowerCase()); +} + +/** + * Builds the account IDs and networks to request DeFi positions for, from the + * accounts in the selected account group. + * + * Picks the group's EVM account (queried across all supported EVM chains) and + * its Solana account (queried on supported Solana chains). Enabled-network + * filtering is intentionally omitted here: positions are stored per chain, so + * the client can filter by enabled networks when reading state. + * + * @param internalAccounts - Accounts belonging to the selected account group. + * @returns Networks and a CAIP→internal account ID map for the v6 multiaccount + * balances request. Map keys are the CAIP account IDs to query. + */ +export function buildDeFiBalancesQuery( + internalAccounts: InternalAccount[], +): DeFiBalancesQuery { + const evmNetworks = DEFI_SUPPORTED_NETWORKS.filter((network) => + network.startsWith(`${KnownCaipNamespace.Eip155}:`), + ); + const solanaNetworks = DEFI_SUPPORTED_NETWORKS.filter((network) => + network.startsWith(`${KnownCaipNamespace.Solana}:`), + ); + + const networks: CaipChainId[] = []; + const internalAccountIdByCaip = new Map(); + + const evmAccount = internalAccounts.find((account) => + isEvmAccountType(account.type), + ); + if (evmAccount && evmNetworks.length > 0) { + internalAccountIdByCaip.set( + toEvmCaipAccountId(evmAccount.address), + evmAccount.id, + ); + networks.push(...evmNetworks); + } + + const solanaAccount = internalAccounts.find( + (account) => account.type === SolAccountType.DataAccount, + ); + if (solanaAccount && solanaNetworks.length > 0) { + const [, solanaReference] = SOLANA_MAINNET_CAIP_CHAIN_ID.split(':'); + internalAccountIdByCaip.set( + toCaipAccountId( + KnownCaipNamespace.Solana, + solanaReference, + solanaAccount.address, + ), + solanaAccount.id, + ); + networks.push(...solanaNetworks); + } + + return { + networks: [...new Set(networks)] as CaipChainId[], + internalAccountIdByCaip, + }; +} diff --git a/packages/assets-controllers/src/DeFiPositionsController/calculate-defi-metrics.test.ts b/packages/assets-controllers/src/DeFiPositionsController/calculate-defi-metrics.test.ts new file mode 100644 index 00000000000..cb83e91b9c4 --- /dev/null +++ b/packages/assets-controllers/src/DeFiPositionsController/calculate-defi-metrics.test.ts @@ -0,0 +1,37 @@ +import { MOCK_EXPECTED_RESULT } from './__fixtures__/mock-result.js'; +import { calculateDeFiPositionMetrics } from './calculate-defi-metrics.js'; + +describe('groupDeFiPositions', () => { + it('verifies that the resulting object is valid', () => { + const result = calculateDeFiPositionMetrics(MOCK_EXPECTED_RESULT); + + expect(result).toStrictEqual({ + category: 'DeFi', + event: 'DeFi Stats', + properties: { + breakdown: [ + { + chainId: '0x1', + count: 3, + marketValueUSD: 540, + protocolId: 'aave-v3', + }, + { + chainId: '0x1', + count: 1, + marketValueUSD: 20000, + protocolId: 'lido', + }, + { + chainId: '0x2105', + count: 2, + marketValueUSD: 9580, + protocolId: 'uniswap-v3', + }, + ], + totalMarketValueUSD: 30120, + totalPositions: 6, + }, + }); + }); +}); diff --git a/packages/assets-controllers/src/DeFiPositionsController/calculate-defi-metrics.ts b/packages/assets-controllers/src/DeFiPositionsController/calculate-defi-metrics.ts new file mode 100644 index 00000000000..82b81752b76 --- /dev/null +++ b/packages/assets-controllers/src/DeFiPositionsController/calculate-defi-metrics.ts @@ -0,0 +1,64 @@ +import type { Hex } from '@metamask/utils'; + +import type { + GroupedDeFiPositionsPerChain, + TrackingEventPayload, +} from './DeFiPositionsController.js'; + +/** + * Calculates the total market value and total positions for a given account + * and returns a breakdown of the market value per protocol. + * + * @param accountPositionsPerChain - The account positions per chain. + * @returns An object containing the total market value, total positions, and a breakdown of the market value per protocol. + */ +export function calculateDeFiPositionMetrics( + accountPositionsPerChain: GroupedDeFiPositionsPerChain, +): TrackingEventPayload { + let totalMarketValueUSD = 0; + let totalPositions = 0; + const breakdown: { + protocolId: string; + marketValueUSD: number; + chainId: Hex; + count: number; + }[] = []; + + Object.entries(accountPositionsPerChain).forEach( + ([chainId, chainPositions]) => { + const chainTotalMarketValueUSD = chainPositions.aggregatedMarketValue; + totalMarketValueUSD += chainTotalMarketValueUSD; + + Object.entries(chainPositions.protocols).forEach( + ([protocolId, protocol]) => { + const protocolTotalMarketValueUSD = protocol.aggregatedMarketValue; + + const protocolCount = Object.values(protocol.positionTypes).reduce( + (acc, positionType) => + acc + (positionType?.positions?.flat().length || 0), + + 0, + ); + + totalPositions += protocolCount; + + breakdown.push({ + protocolId, + marketValueUSD: protocolTotalMarketValueUSD, + chainId: chainId as Hex, + count: protocolCount, + }); + }, + ); + }, + ); + return { + category: 'DeFi', + event: 'DeFi Stats', + properties: { + totalMarketValueUSD, + totalPositions, + breakdown, + }, + }; +} diff --git a/packages/assets-controllers/src/DeFiPositionsController/defi-controller-v2-feature-flag.test.ts b/packages/assets-controllers/src/DeFiPositionsController/defi-controller-v2-feature-flag.test.ts new file mode 100644 index 00000000000..8178b40a0c9 --- /dev/null +++ b/packages/assets-controllers/src/DeFiPositionsController/defi-controller-v2-feature-flag.test.ts @@ -0,0 +1,104 @@ +import { getProcessingPollConfig } from './defi-controller-v2-feature-flag.js'; + +/** Mirrors the internal defaults in `defi-controller-v2-feature-flag.ts`. */ +const DEFAULT_PROCESSING_POLL_INTERVAL_MS = 5_000; +const DEFAULT_PROCESSING_POLL_MAX_ATTEMPTS = 5; +const DEFI_CONTROLLER_V2_FEATURE_FLAG = 'defiControllerV2'; + +/** + * @param remoteFeatureFlags - Remote feature flags to return from getState. + * @returns A minimal messenger stub for `getProcessingPollConfig`. + */ +function buildMessenger(remoteFeatureFlags: Record): { + call: jest.Mock; +} { + return { + call: jest.fn().mockReturnValue({ + remoteFeatureFlags, + cacheTimestamp: 0, + }), + }; +} + +describe('getProcessingPollConfig', () => { + it('returns defaults when the remote flag is missing', () => { + const messenger = buildMessenger({}); + + expect(getProcessingPollConfig(messenger)).toStrictEqual({ + maxAttempts: DEFAULT_PROCESSING_POLL_MAX_ATTEMPTS, + pollInterval: DEFAULT_PROCESSING_POLL_INTERVAL_MS, + }); + expect(messenger.call).toHaveBeenCalledWith( + 'RemoteFeatureFlagController:getState', + ); + }); + + it('returns defaults when the remote flag is malformed', () => { + const messenger = buildMessenger({ + [DEFI_CONTROLLER_V2_FEATURE_FLAG]: 'not-an-object', + }); + + expect(getProcessingPollConfig(messenger)).toStrictEqual({ + maxAttempts: DEFAULT_PROCESSING_POLL_MAX_ATTEMPTS, + pollInterval: DEFAULT_PROCESSING_POLL_INTERVAL_MS, + }); + }); + + it('resolves maxAttempts and pollInterval from the remote flag', () => { + const messenger = buildMessenger({ + [DEFI_CONTROLLER_V2_FEATURE_FLAG]: { + enabled: true, + maxAttempts: 3, + pollInterval: 1000, + }, + }); + + expect(getProcessingPollConfig(messenger)).toStrictEqual({ + maxAttempts: 3, + pollInterval: 1000, + }); + }); + + it('floors maxAttempts and falls back for non-positive or non-finite values', () => { + expect( + getProcessingPollConfig( + buildMessenger({ + [DEFI_CONTROLLER_V2_FEATURE_FLAG]: { + maxAttempts: 2.9, + pollInterval: 2500, + }, + }), + ), + ).toStrictEqual({ + maxAttempts: 2, + pollInterval: 2500, + }); + + expect( + getProcessingPollConfig( + buildMessenger({ + [DEFI_CONTROLLER_V2_FEATURE_FLAG]: { + maxAttempts: 0, + pollInterval: Number.NaN, + }, + }), + ), + ).toStrictEqual({ + maxAttempts: DEFAULT_PROCESSING_POLL_MAX_ATTEMPTS, + pollInterval: DEFAULT_PROCESSING_POLL_INTERVAL_MS, + }); + + expect( + getProcessingPollConfig( + buildMessenger({ + [DEFI_CONTROLLER_V2_FEATURE_FLAG]: { + maxAttempts: 2, + }, + }), + ), + ).toStrictEqual({ + maxAttempts: 2, + pollInterval: DEFAULT_PROCESSING_POLL_INTERVAL_MS, + }); + }); +}); diff --git a/packages/assets-controllers/src/DeFiPositionsController/defi-controller-v2-feature-flag.ts b/packages/assets-controllers/src/DeFiPositionsController/defi-controller-v2-feature-flag.ts new file mode 100644 index 00000000000..6237b632375 --- /dev/null +++ b/packages/assets-controllers/src/DeFiPositionsController/defi-controller-v2-feature-flag.ts @@ -0,0 +1,120 @@ +import type { RemoteFeatureFlagControllerState } from '@metamask/remote-feature-flag-controller'; +import type { Json } from '@metamask/utils'; + +/** + * Remote feature flag key for DeFi Positions Controller V2 (camelCase, as + * stored by RemoteFeatureFlagController after client-config resolution). + */ +const DEFI_CONTROLLER_V2_FEATURE_FLAG = 'defiControllerV2'; + +/** Delay between polls while Accounts API reports DeFi indexing in progress. */ +const DEFAULT_PROCESSING_POLL_INTERVAL_MS = 5_000; + +/** + * Maximum fetch attempts (including the first) while any account still has + * `processingDefiPositions: true`. After this, the call resolves without + * updating state, so prior positions are kept for every selected account. + */ +const DEFAULT_PROCESSING_POLL_MAX_ATTEMPTS = 5; + +/** + * Resolved `defiControllerV2` remote feature flag shape used for processing + * poll overrides. `enabled` is read by clients for gating; the controller + * only consumes `maxAttempts` / `pollInterval`. + */ +type DeFiControllerV2FeatureFlag = { + enabled?: boolean; + maxAttempts?: number; + pollInterval?: number; +}; + +/** + * Optional processing-poll overrides from {@link DeFiControllerV2FeatureFlag}. + * Missing or non-positive values fall back to + * {@link DEFAULT_PROCESSING_POLL_MAX_ATTEMPTS} / + * {@link DEFAULT_PROCESSING_POLL_INTERVAL_MS}. + */ +type DeFiPositionsControllerV2ProcessingPollConfig = { + maxAttempts?: number; + pollInterval?: number; +}; + +/** + * Resolved positive integer processing-poll limits. + */ +type ResolvedProcessingPollConfig = { + maxAttempts: number; + pollInterval: number; +}; + +/** + * Messenger surface needed to read the DeFi V2 remote feature flag. + */ +type GetProcessingPollConfigMessenger = { + call: ( + actionType: 'RemoteFeatureFlagController:getState', + ) => RemoteFeatureFlagControllerState; +}; + +/** + * @param config - Optional remote poll overrides. + * @returns Resolved positive integer max attempts and poll interval ms. + */ +function resolveProcessingPollConfig( + config?: DeFiPositionsControllerV2ProcessingPollConfig | null, +): ResolvedProcessingPollConfig { + const maxAttempts = + typeof config?.maxAttempts === 'number' && + Number.isFinite(config.maxAttempts) && + config.maxAttempts > 0 + ? Math.floor(config.maxAttempts) + : DEFAULT_PROCESSING_POLL_MAX_ATTEMPTS; + const pollInterval = + typeof config?.pollInterval === 'number' && + Number.isFinite(config.pollInterval) && + config.pollInterval > 0 + ? config.pollInterval + : DEFAULT_PROCESSING_POLL_INTERVAL_MS; + + return { maxAttempts, pollInterval }; +} + +/** + * Narrows a remote feature-flag JSON value to the DeFi V2 poll config fields. + * + * @param flag - Raw flag value from RemoteFeatureFlagController state. + * @returns Poll config fields when present, otherwise `undefined`. + */ +function parseDeFiControllerV2FeatureFlag( + flag: Json | undefined, +): DeFiPositionsControllerV2ProcessingPollConfig | undefined { + if (!flag || typeof flag !== 'object' || Array.isArray(flag)) { + return undefined; + } + + const { maxAttempts, pollInterval } = flag as DeFiControllerV2FeatureFlag; + return { maxAttempts, pollInterval }; +} + +/** + * Reads `defiControllerV2` from RemoteFeatureFlagController and returns + * resolved processing-poll limits. Missing or invalid flag values fall back to + * {@link DEFAULT_PROCESSING_POLL_MAX_ATTEMPTS} / + * {@link DEFAULT_PROCESSING_POLL_INTERVAL_MS}. + * + * @param messenger - Messenger that can call + * `RemoteFeatureFlagController:getState`. + * @returns Positive integer max attempts and poll interval ms. + */ +export function getProcessingPollConfig( + messenger: GetProcessingPollConfigMessenger, +): ResolvedProcessingPollConfig { + const { remoteFeatureFlags } = messenger.call( + 'RemoteFeatureFlagController:getState', + ); + return resolveProcessingPollConfig( + parseDeFiControllerV2FeatureFlag( + remoteFeatureFlags?.[DEFI_CONTROLLER_V2_FEATURE_FLAG], + ), + ); +} diff --git a/packages/assets-controllers/src/DeFiPositionsController/fetch-positions.test.ts b/packages/assets-controllers/src/DeFiPositionsController/fetch-positions.test.ts new file mode 100644 index 00000000000..c5ace0d5412 --- /dev/null +++ b/packages/assets-controllers/src/DeFiPositionsController/fetch-positions.test.ts @@ -0,0 +1,67 @@ +import nock from 'nock'; + +import { + DEFI_POSITIONS_API_URL, + buildPositionFetcher, +} from './fetch-positions.js'; + +describe('fetchPositions', () => { + const mockAccountAddress = '0x1234567890123456789012345678901234567890'; + + const mockResponse = { + data: [ + { + chainId: 1, + chainName: 'Ethereum Mainnet', + protocolId: 'aave-v3', + productId: 'lending', + name: 'Aave V3', + description: 'Lending protocol', + iconUrl: 'https://example.com/icon.png', + siteUrl: 'https://example.com', + positionType: 'supply', + success: true, + tokens: [ + { + type: 'protocol', + address: '0xtoken', + name: 'Test Token', + symbol: 'TEST', + decimals: 18, + balanceRaw: '1000000000000000000', + balance: 1, + price: 100, + iconUrl: 'https://example.com/token.png', + }, + ], + }, + ], + }; + + it('handles successful responses', async () => { + const scope = nock(DEFI_POSITIONS_API_URL) + .get(`/positions/${mockAccountAddress}`) + .reply(200, mockResponse); + + const fetchPositions = buildPositionFetcher(); + + const result = await fetchPositions(mockAccountAddress); + + expect(result).toStrictEqual(mockResponse.data); + expect(scope.isDone()).toBe(true); + }); + + it('handles non-200 responses', async () => { + const scope = nock(DEFI_POSITIONS_API_URL) + .get(`/positions/${mockAccountAddress}`) + .reply(400); + + const fetchPositions = buildPositionFetcher(); + + await expect(fetchPositions(mockAccountAddress)).rejects.toThrow( + 'Unable to fetch defi positions - HTTP 400', + ); + + expect(scope.isDone()).toBe(true); + }); +}); diff --git a/packages/assets-controllers/src/DeFiPositionsController/fetch-positions.ts b/packages/assets-controllers/src/DeFiPositionsController/fetch-positions.ts new file mode 100644 index 00000000000..a6a47b622fc --- /dev/null +++ b/packages/assets-controllers/src/DeFiPositionsController/fetch-positions.ts @@ -0,0 +1,87 @@ +import { timeoutWithRetry } from '../utils/timeout-with-retry.js'; + +export type DefiPositionResponse = AdapterResponse<{ + tokens: ProtocolToken[]; +}>; + +type ProtocolDetails = { + chainId: number; + protocolId: string; + productId: string; + protocolDisplayName: string; + name: string; + description: string; + iconUrl: string; + siteUrl: string; + positionType: PositionType; + metadata?: { + groupPositions?: boolean; + }; +}; + +type AdapterResponse = + | (ProtocolDetails & { + chainName: string; + } & ( + | (ProtocolResponse & { success: true }) + | (AdapterErrorResponse & { success: false }) + )) + | (AdapterErrorResponse & { success: false }); + +type AdapterErrorResponse = { + error: { + message: string; + }; +}; + +export type PositionType = 'supply' | 'borrow' | 'stake' | 'reward'; + +export type ProtocolToken = Balance & { + type: 'protocol'; + tokenId?: string; +}; + +export type Underlying = Balance & { + type: 'underlying' | 'underlying-claimable'; + iconUrl: string; +}; + +export type Balance = { + address: string; + name: string; + symbol: string; + decimals: number; + balanceRaw: string; + balance: number; + price?: number; + tokens?: Underlying[]; +}; + +// TODO: Update with prod API URL when available +export const DEFI_POSITIONS_API_URL = 'https://defiadapters.api.cx.metamask.io'; + +const EIGHT_SECONDS_IN_MS = 8_000; +const MAX_RETRIES = 1; + +/** + * Builds a function that fetches DeFi positions for a given account address + * + * @returns A function that fetches DeFi positions for a given account address + */ +export function buildPositionFetcher() { + return async (accountAddress: string): Promise => { + const defiPositionsResponse = await timeoutWithRetry( + () => fetch(`${DEFI_POSITIONS_API_URL}/positions/${accountAddress}`), + EIGHT_SECONDS_IN_MS, + MAX_RETRIES, + ); + + if (defiPositionsResponse.status !== 200) { + throw new Error( + `Unable to fetch defi positions - HTTP ${defiPositionsResponse.status}`, + ); + } + + return (await defiPositionsResponse.json()).data; + }; +} diff --git a/packages/assets-controllers/src/DeFiPositionsController/group-defi-positions-v6.test.ts b/packages/assets-controllers/src/DeFiPositionsController/group-defi-positions-v6.test.ts new file mode 100644 index 00000000000..8ca3c220d04 --- /dev/null +++ b/packages/assets-controllers/src/DeFiPositionsController/group-defi-positions-v6.test.ts @@ -0,0 +1,525 @@ +import type { V6BalancesResponse } from '@metamask/core-backend'; +import type { CaipAssetType } from '@metamask/utils'; + +import { groupDeFiPositionsV6 } from './group-defi-positions-v6.js'; + +const AAVE_METADATA = { + protocolId: 'aave-v3', + productName: 'Aave V3', + description: 'Aave V3 on ethereum', + protocolUrl: 'https://aave.com/', + protocolIconUrl: 'https://example.com/aave.png', + positionType: 'deposit', + poolAddress: '0xpool', + groupId: 'group-aave-1', +}; + +const WETH_ASSET_ID = + 'eip155:1/erc20:0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2' as CaipAssetType; +const USDC_ASSET_ID = + 'eip155:1/erc20:0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48' as CaipAssetType; +const USDT_ASSET_ID = + 'eip155:1/erc20:0xdAC17F958D2ee523a2206206994597C13D831ec7' as CaipAssetType; +const BASE_WETH_ASSET_ID = + 'eip155:8453/erc20:0x4200000000000000000000000000000000000006' as CaipAssetType; + +/** + * Builds a minimal v6 balances response for tests. + * + * @param balances - Flat balance rows to include. + * @returns A v6 balances response. + */ +function buildResponse( + balances: V6BalancesResponse['balances'], +): V6BalancesResponse { + return { + unprocessedNetworks: [], + unprocessedIncludeAssetIds: [], + balances, + }; +} + +describe('groupDeFiPositionsV6', () => { + it('groups DeFi positions by chain and protocolId', () => { + const response = buildResponse([ + { + accountId: 'eip155:0:0xabc', + object: 'defi', + type: 'erc20', + assetId: WETH_ASSET_ID, + name: 'Wrapped Ether', + symbol: 'WETH', + decimals: 18, + balance: '1', + price: '2000', + metadata: AAVE_METADATA, + }, + { + accountId: 'eip155:0:0xabc', + object: 'defi', + type: 'erc20', + assetId: USDC_ASSET_ID, + name: 'USD Coin', + symbol: 'USDC', + decimals: 6, + balance: '100', + price: '1', + metadata: { + ...AAVE_METADATA, + positionType: 'deposit', + poolAddress: '0xpool2', + }, + }, + { + accountId: 'eip155:0:0xabc', + object: 'defi', + type: 'erc20', + assetId: BASE_WETH_ASSET_ID, + name: 'Wrapped Ether', + symbol: 'WETH', + decimals: 18, + balance: '2', + price: '2000', + metadata: AAVE_METADATA, + }, + ]); + + const result = groupDeFiPositionsV6(response); + + expect(Object.keys(result)).toStrictEqual(['eip155:0:0xabc']); + expect(result['eip155:0:0xabc']).toHaveLength(2); + + const ethGroup = result['eip155:0:0xabc'].find( + (group) => group.chainId === 'eip155:1', + ); + const baseGroup = result['eip155:0:0xabc'].find( + (group) => group.chainId === 'eip155:8453', + ); + + expect(ethGroup).toMatchObject({ + protocolId: 'aave-v3', + productName: 'Aave V3', + protocolIconUrl: 'https://example.com/aave.png', + chainId: 'eip155:1', + marketValue: 2100, + }); + expect(ethGroup?.sections).toHaveLength(1); + expect(ethGroup?.sections[0].positions).toHaveLength(2); + + expect(baseGroup).toMatchObject({ + protocolId: 'aave-v3', + chainId: 'eip155:8453', + marketValue: 4000, + }); + }); + + it('ignores token rows and defi rows without protocol metadata', () => { + const response = buildResponse([ + { + accountId: 'account-1', + object: 'token', + type: 'erc20', + assetId: WETH_ASSET_ID, + name: 'Wrapped Ether', + symbol: 'WETH', + decimals: 18, + balance: '1', + price: '2000', + }, + { + accountId: 'account-1', + object: 'defi', + type: 'erc20', + assetId: USDC_ASSET_ID, + name: 'USD Coin', + symbol: 'USDC', + decimals: 6, + balance: '100', + price: '1', + }, + { + accountId: 'account-1', + object: 'defi', + type: 'erc20', + assetId: USDT_ASSET_ID, + name: 'Tether', + symbol: 'USDT', + decimals: 6, + balance: '50', + price: '1', + metadata: { + limit: '1', + }, + }, + ]); + + const result = groupDeFiPositionsV6(response); + + expect(result['account-1']).toStrictEqual([]); + }); + + it('seeds an empty list for accounts with no DeFi positions', () => { + const response = buildResponse([]); + + expect( + groupDeFiPositionsV6( + response, + new Map([['eip155:0:0xempty', 'account-empty']]), + ), + ).toStrictEqual({ + 'account-empty': [], + }); + }); + + it('maps response accounts to internal IDs and skips unmatched ones', () => { + const response = buildResponse([ + { + accountId: 'eip155:1:0xUnknown', + object: 'defi', + type: 'erc20', + assetId: WETH_ASSET_ID, + name: 'Wrapped Ether', + symbol: 'WETH', + decimals: 18, + balance: '1', + price: '2000', + metadata: AAVE_METADATA, + }, + { + // Response uses a per-chain reference + mixed case; request map uses + // the all-chains reference (`eip155:0:...`). + accountId: 'eip155:1:0xKnown', + object: 'defi', + type: 'erc20', + assetId: USDC_ASSET_ID, + name: 'USD Coin', + symbol: 'USDC', + decimals: 6, + balance: '10', + price: '1', + metadata: AAVE_METADATA, + }, + ]); + + const result = groupDeFiPositionsV6( + response, + new Map([['eip155:0:0xknown', 'internal-1']]), + ); + + expect(Object.keys(result)).toStrictEqual(['internal-1']); + expect(result['internal-1']).toHaveLength(1); + }); + + it('omits market value when price is missing or invalid', () => { + const response = buildResponse([ + { + accountId: 'account-1', + object: 'defi', + type: 'erc20', + assetId: WETH_ASSET_ID, + name: 'Wrapped Ether', + symbol: 'WETH', + decimals: 18, + balance: '1', + metadata: AAVE_METADATA, + }, + { + accountId: 'account-1', + object: 'defi', + type: 'erc20', + assetId: USDC_ASSET_ID, + name: 'USD Coin', + symbol: 'USDC', + decimals: 6, + balance: 'not-a-number', + price: '1', + metadata: { + ...AAVE_METADATA, + productName: 'Aave V3 USDC', + }, + }, + { + accountId: 'account-1', + object: 'defi', + type: 'erc20', + assetId: USDT_ASSET_ID, + name: 'Tether', + symbol: 'USDT', + decimals: 6, + balance: '5', + price: '1', + metadata: { + ...AAVE_METADATA, + productName: 'Aave V3 USDT', + }, + }, + ]); + + const [group] = groupDeFiPositionsV6(response)['account-1']; + + expect(group.marketValue).toBe(5); + expect(group.sections[0].positions[0].marketValue).toBeUndefined(); + expect(group.sections[1].positions[0].marketValue).toBeUndefined(); + expect(group.sections[2].positions[0].marketValue).toBe(5); + }); + + it('subtracts loan positions from the protocol market value', () => { + const response = buildResponse([ + { + accountId: 'account-1', + object: 'defi', + type: 'erc20', + assetId: WETH_ASSET_ID, + name: 'Wrapped Ether', + symbol: 'WETH', + decimals: 18, + balance: '1', + price: '2000', + metadata: { + ...AAVE_METADATA, + productName: 'Aave V3 Supply', + positionType: 'deposit', + }, + }, + { + accountId: 'account-1', + object: 'defi', + type: 'erc20', + assetId: USDC_ASSET_ID, + name: 'USD Coin', + symbol: 'USDC', + decimals: 6, + balance: '500', + price: '1', + metadata: { + ...AAVE_METADATA, + productName: 'Aave V3 Borrow', + positionType: 'loan', + }, + }, + ]); + + const [group] = groupDeFiPositionsV6(response)['account-1']; + + expect(group.marketValue).toBe(1500); + expect(group.sections[0].positions[0].marketValue).toBe(2000); + expect(group.sections[1].positions[0].marketValue).toBe(500); + expect(group.sections[1].positions[0].positionType).toBe('loan'); + }); + + it('creates separate detail sections per productName under one protocolId', () => { + const response = buildResponse([ + { + accountId: 'account-1', + object: 'defi', + type: 'erc20', + assetId: WETH_ASSET_ID, + name: 'Wrapped Ether', + symbol: 'WETH', + decimals: 18, + balance: '1', + price: '2000', + metadata: { + ...AAVE_METADATA, + productName: 'Aave V3 Supply', + }, + }, + { + accountId: 'account-1', + object: 'defi', + type: 'erc20', + assetId: USDC_ASSET_ID, + name: 'USD Coin', + symbol: 'USDC', + decimals: 6, + balance: '100', + price: '1', + metadata: { + ...AAVE_METADATA, + productName: 'Aave V3 Borrow', + positionType: 'loan', + }, + }, + ]); + + const [group] = groupDeFiPositionsV6(response)['account-1']; + + expect(group.productName).toBe('Aave V3 Supply'); + expect(group.sections).toStrictEqual([ + { + productName: 'Aave V3 Supply', + positions: [ + expect.objectContaining({ + symbol: 'WETH', + positionType: 'deposit', + }), + ], + }, + { + productName: 'Aave V3 Borrow', + positions: [ + expect.objectContaining({ + symbol: 'USDC', + positionType: 'loan', + }), + ], + }, + ]); + }); + + it('dedupes icon-group symbols and moves ETH/WETH to the front', () => { + const response = buildResponse([ + { + accountId: 'account-1', + object: 'defi', + type: 'erc20', + assetId: USDC_ASSET_ID, + name: 'USD Coin', + symbol: 'USDC', + decimals: 6, + balance: '100', + price: '1', + metadata: AAVE_METADATA, + }, + { + accountId: 'account-1', + object: 'defi', + type: 'erc20', + assetId: WETH_ASSET_ID, + name: 'Wrapped Ether', + symbol: 'WETH', + decimals: 18, + balance: '1', + price: '2000', + metadata: AAVE_METADATA, + }, + { + accountId: 'account-1', + object: 'defi', + type: 'erc20', + assetId: WETH_ASSET_ID, + name: 'Wrapped Ether', + symbol: 'WETH', + decimals: 18, + balance: '0.5', + price: '2000', + metadata: { + ...AAVE_METADATA, + positionType: 'reward', + }, + }, + ]); + + const [group] = groupDeFiPositionsV6(response)['account-1']; + + expect(group.iconGroup.map((item) => item.symbol)).toStrictEqual([ + 'WETH', + 'USDC', + ]); + expect(group.iconGroup[0].avatarValue).toBe( + 'https://static.cx.metamask.io/api/v2/tokenIcons/assets/eip155/1/erc20/0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2.png', + ); + }); + + it('builds underlying positions with token images and chain IDs', () => { + const response = buildResponse([ + { + accountId: 'account-1', + object: 'defi', + type: 'erc20', + assetId: WETH_ASSET_ID, + name: 'Wrapped Ether', + symbol: 'WETH', + decimals: 18, + balance: '1.5', + price: '2000', + metadata: AAVE_METADATA, + }, + ]); + + const [group] = groupDeFiPositionsV6(response)['account-1']; + const [position] = group.sections[0].positions; + + expect(position).toStrictEqual({ + assetId: WETH_ASSET_ID, + chainId: 'eip155:1', + symbol: 'WETH', + name: 'Wrapped Ether', + decimals: 18, + balance: '1.5', + marketValue: 3000, + positionType: 'deposit', + poolAddress: '0xpool', + groupId: 'group-aave-1', + tokenImage: + 'https://static.cx.metamask.io/api/v2/tokenIcons/assets/eip155/1/erc20/0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2.png', + }); + }); + + it('keeps distinct groupIds on positions that share a productName', () => { + const response = buildResponse([ + { + accountId: 'account-1', + object: 'defi', + type: 'erc20', + assetId: WETH_ASSET_ID, + name: 'Wrapped Ether', + symbol: 'WETH', + decimals: 18, + balance: '1', + price: '2000', + metadata: { + ...AAVE_METADATA, + productName: 'Pendle YT', + groupId: 'group-yt-1', + }, + }, + { + accountId: 'account-1', + object: 'defi', + type: 'erc20', + assetId: USDC_ASSET_ID, + name: 'USD Coin', + symbol: 'USDC', + decimals: 6, + balance: '100', + price: '1', + metadata: { + ...AAVE_METADATA, + productName: 'Pendle YT', + poolAddress: '0xpool2', + groupId: 'group-yt-2', + }, + }, + ]); + + const [group] = groupDeFiPositionsV6(response)['account-1']; + + expect(group.sections).toHaveLength(1); + expect(group.sections[0].productName).toBe('Pendle YT'); + expect( + group.sections[0].positions.map((position) => position.groupId), + ).toStrictEqual(['group-yt-1', 'group-yt-2']); + }); + + it('supports DeFi metadata without a protocol icon URL', () => { + const { protocolIconUrl: _protocolIconUrl, ...metadata } = AAVE_METADATA; + const response = buildResponse([ + { + accountId: 'account-1', + object: 'defi', + type: 'erc20', + assetId: WETH_ASSET_ID, + name: 'Wrapped Ether', + symbol: 'WETH', + decimals: 18, + balance: '1', + metadata, + }, + ]); + + const [group] = groupDeFiPositionsV6(response)['account-1']; + + expect(group.protocolIconUrl).toBe(''); + }); +}); diff --git a/packages/assets-controllers/src/DeFiPositionsController/group-defi-positions-v6.ts b/packages/assets-controllers/src/DeFiPositionsController/group-defi-positions-v6.ts new file mode 100644 index 00000000000..054c9db4186 --- /dev/null +++ b/packages/assets-controllers/src/DeFiPositionsController/group-defi-positions-v6.ts @@ -0,0 +1,398 @@ +import { V6_DEFI_POSITION_TYPES } from '@metamask/core-backend'; +import type { + V6BalanceItem, + V6BalanceMetadata, + V6BalancesResponse, + V6DeFiPositionType, +} from '@metamask/core-backend'; +import type { + CaipAccountId, + CaipAssetType, + CaipChainId, +} from '@metamask/utils'; +import { + KnownCaipNamespace, + parseCaipAccountId, + parseCaipAssetType, + parseCaipChainId, +} from '@metamask/utils'; + +/** Static.cx host used to build CAIP-19 token icon URLs for DeFi positions. */ +const STATIC_METAMASK_BASE_URL = 'https://static.cx.metamask.io'; + +/** + * Possible `positionType` values from Accounts API v6 DeFi metadata. + * Re-export of {@link V6_DEFI_POSITION_TYPES} from `@metamask/core-backend`. + */ +export const DEFI_POSITION_TYPES = V6_DEFI_POSITION_TYPES; + +/** + * The specific module or functionality within a DeFi protocol where a position + * is held. Alias of {@link V6DeFiPositionType} from `@metamask/core-backend`. + */ +export type DeFiPositionType = V6DeFiPositionType; + +/** + * Position types whose fiat value is a liability and is subtracted from the + * protocol group's aggregated `marketValue`. + */ +export const DEFI_POSITION_LIABILITY_TYPES: ReadonlySet = + new Set(['loan']); + +/** + * An icon-group entry shown next to a protocol in the DeFi tab list. + */ +export type DeFiPositionIconGroupItem = { + /** Token icon URL, when one can be built for the asset. */ + avatarValue?: string; + symbol: string; +}; + +/** + * A single underlying position row shown on the DeFi details page. + */ +export type DeFiUnderlyingPosition = { + assetId: CaipAssetType; + chainId: CaipChainId; + symbol: string; + name: string; + decimals: number; + /** Raw balance string as returned by the API. */ + balance: string; + /** Fiat market value in the requested currency, when a price is available. */ + marketValue?: number; + /** Position type from protocol metadata. */ + positionType: DeFiPositionType; + /** Address of the pool this position belongs to. */ + poolAddress: string; + /** + * Upstream grouping id from the API. Rows that share a `productName` can + * still carry distinct `groupId`s (e.g. multiple Pendle YT markets). + */ + groupId: string; + /** Token icon URL, when one can be built for the asset. */ + tokenImage?: string; +}; + +/** + * A section of the details page, grouping positions that share the same + * API `productName`. A single `protocolId` can have multiple products + * (different pools/markets under one protocol), so a group may contain + * several sections. + */ +export type DeFiPositionDetailsSection = { + /** Section label from the API (`metadata.productName`). */ + productName: string; + positions: DeFiUnderlyingPosition[]; +}; + +/** + * One row in the DeFi tab list (a protocol on a given chain), with the details + * needed to render the details page embedded directly inside it. + */ +export type DeFiProtocolPositionGroup = { + protocolId: string; + /** + * Product name from the first position seen for this protocol. Prefer + * `protocolId` for the list-row title; use section `productName`s for + * per-product detail headings. + */ + productName: string; + protocolIconUrl: string; + chainId: CaipChainId; + /** + * Aggregated fiat market value across all positions in the group. + * `lending` positions are subtracted; all other types are added. + */ + marketValue: number; + /** Icon-group entries for the list row. */ + iconGroup: DeFiPositionIconGroupItem[]; + /** + * Detail sections consumed by the details page, one per distinct API + * `productName` under this `protocolId`. + */ + sections: DeFiPositionDetailsSection[]; +}; + +/** + * DeFi positions for every queried account, keyed by the internal MetaMask + * account ID (`InternalAccount.id` UUID), the same key AssetsController uses. + * Each account maps to a flat list of protocol groups; filter by each group's + * `chainId` rather than digging through a nested chain map. + */ +export type DeFiPositionsByAccount = { + [accountId: string]: DeFiProtocolPositionGroup[]; +}; + +// Prefer ETH/WETH first in the list-row icon stack when a protocol has multiple +// underlyings. Display-only. +const SYMBOL_PRIORITY = ['ETH', 'WETH']; + +type DefiBalanceWithMetadata = V6BalanceItem & { metadata: V6BalanceMetadata }; + +/** + * Builds a static token icon URL for a CAIP asset ID. + * + * @param assetId - The CAIP-19 asset ID. + * @returns The token icon URL, or `undefined` when it cannot be built. + */ +function getDefiTokenImageUrl(assetId: CaipAssetType): string | undefined { + try { + const { chainId } = parseCaipAssetType(assetId); + const { namespace } = parseCaipChainId(chainId); + const isEvm = namespace === KnownCaipNamespace.Eip155; + const normalizedAssetId = (isEvm ? assetId.toLowerCase() : assetId).replace( + /:/gu, + '/', + ); + + return `${STATIC_METAMASK_BASE_URL}/api/v2/tokenIcons/assets/${normalizedAssetId}.png`; + } catch { + return undefined; + } +} + +/** + * Returns whether a balance row is a DeFi position carrying protocol metadata. + * + * @param balance - A balance row from the v6 API. + * @returns True when the row is an `object: defi` row with protocol metadata. + */ +function isDefiBalanceWithMetadata( + balance: V6BalanceItem, +): balance is DefiBalanceWithMetadata { + return ( + balance.object === 'defi' && + balance.metadata !== undefined && + (balance.metadata as Partial).protocolId !== undefined + ); +} + +/** + * Returns the fiat market value for a v6 DeFi balance row. + * + * @param balance - A balance row from the v6 API. + * @returns The fiat value, or `undefined` when price is missing or the + * balance/price is invalid. + */ +function getMarketValue(balance: V6BalanceItem): number | undefined { + if (balance.price === undefined) { + return undefined; + } + + const normalizedBalance = Number.parseFloat(balance.balance); + const price = Number.parseFloat(balance.price); + + if (!Number.isFinite(normalizedBalance) || !Number.isFinite(price)) { + return undefined; + } + + return normalizedBalance * price; +} + +/** + * Returns the sign used when rolling a position's market value into a protocol + * group total. Liability types (currently `lending`) subtract; all others add. + * + * @param positionType - The position's protocol module type. + * @returns `-1` for liabilities, `1` otherwise. + */ +function getMarketValueSign(positionType: DeFiPositionType): 1 | -1 { + return DEFI_POSITION_LIABILITY_TYPES.has(positionType) ? -1 : 1; +} + +/** + * Moves a priority symbol (ETH/WETH) to the front of the icon group, in place. + * + * @param iconGroup - The icon-group entries to reorder. + */ +function orderIconGroup(iconGroup: DeFiPositionIconGroupItem[]): void { + const priorityIndex = iconGroup.findIndex((item) => + SYMBOL_PRIORITY.includes(item.symbol), + ); + + if (priorityIndex > 0) { + const [priorityIcon] = iconGroup.splice(priorityIndex, 1); + iconGroup.unshift(priorityIcon); + } +} + +/** + * Maps a DeFi balance row to a details-page underlying position. + * + * @param balance - A DeFi balance row with protocol metadata. + * @returns The underlying position for the details page. + */ +function toUnderlyingPosition( + balance: DefiBalanceWithMetadata, +): DeFiUnderlyingPosition { + const assetId = balance.assetId as CaipAssetType; + const { chainId } = parseCaipAssetType(assetId); + const { positionType, poolAddress, groupId } = balance.metadata; + + return { + assetId, + chainId, + symbol: balance.symbol, + name: balance.name, + balance: balance.balance, + decimals: balance.decimals, + marketValue: getMarketValue(balance), + positionType, + poolAddress, + groupId, + tokenImage: getDefiTokenImageUrl(assetId), + }; +} + +/** + * Builds a chain-reference-agnostic key (`namespace:address`) for matching the + * CAIP-10 account IDs we request against the ones the v6 API echoes back. + * + * We request EVM balances with the all-chains reference (`eip155:0:
`), + * but the response echoes a separate per-chain ID for every chain + * (`eip155:1:
`, `eip155:137:
`, ...). Matching on the full + * CAIP-10 string therefore fails, so we drop the reference and match on + * namespace + address instead. EVM addresses are lowercased; other namespaces + * keep their case. + * + * @param caipAccountId - A CAIP-10 account ID. + * @returns The match key, or a case-normalized fallback if parsing fails. + */ +function toAccountMatchKey(caipAccountId: string): string { + try { + const { + chain: { namespace }, + address, + } = parseCaipAccountId(caipAccountId as CaipAccountId); + const normalizedAddress = + namespace === KnownCaipNamespace.Eip155 ? address.toLowerCase() : address; + return `${namespace}:${normalizedAddress}`; + } catch { + return caipAccountId.startsWith(`${KnownCaipNamespace.Eip155}:`) + ? caipAccountId.toLowerCase() + : caipAccountId; + } +} + +/** + * Transforms a v6 multiaccount balances response into the stored DeFi state: + * positions keyed by internal account ID, each mapping to a flat list of + * protocol groups. Every group carries its own `chainId` (so the client can + * filter without a nested chain map) plus both the DeFi-tab summary and the + * details-page sections. When the request-account map is provided, every + * requested account is included with an empty list so stale data is cleared + * even when the flat response contains no rows for that account. + * + * When `internalAccountIdByCaip` is provided, response account IDs are matched + * to internal MetaMask account IDs via namespace + address (ignoring chain + * reference and EVM case). Unmatched accounts are skipped. When omitted, the + * response account ID is used as-is (handy for unit tests). + * + * @param response - The v6 multiaccount balances response. + * @param internalAccountIdByCaip - Optional map of request CAIP-10 account IDs + * to internal MetaMask account IDs. + * @returns DeFi positions keyed by internal account ID. + */ +export function groupDeFiPositionsV6( + response: V6BalancesResponse, + internalAccountIdByCaip?: Map, +): DeFiPositionsByAccount { + const internalAccountIdByMatchKey = internalAccountIdByCaip + ? new Map( + [...internalAccountIdByCaip].map(([caipAccountId, internalId]) => [ + toAccountMatchKey(caipAccountId), + internalId, + ]), + ) + : undefined; + + // Accumulate groups per resolved internal account ID. The v6 response rows + // carry per-chain account IDs (e.g. `eip155:1:`, + // `eip155:137:`), and several can resolve to the same internal account + // ID, so merge across all of them. + const groupsByAccountKey = new Map< + string, + Map + >( + internalAccountIdByCaip + ? [...new Set(internalAccountIdByCaip.values())].map((accountId) => [ + accountId, + new Map(), + ]) + : [], + ); + + for (const balance of response.balances) { + const accountId = internalAccountIdByMatchKey + ? internalAccountIdByMatchKey.get(toAccountMatchKey(balance.accountId)) + : balance.accountId; + if (accountId === undefined) { + continue; + } + + let groupsByKey = groupsByAccountKey.get(accountId); + if (!groupsByKey) { + groupsByKey = new Map(); + groupsByAccountKey.set(accountId, groupsByKey); + } + + if (!isDefiBalanceWithMetadata(balance)) { + continue; + } + + const position = toUnderlyingPosition(balance); + const { protocolId, productName, protocolIconUrl } = balance.metadata; + const groupKey = `${position.chainId}#${protocolId}`; + + let group = groupsByKey.get(groupKey); + if (!group) { + group = { + protocolId, + productName, + // Upstream may omit the icon; keep a string so clients do not need + // optional handling on the stored group shape. + protocolIconUrl: protocolIconUrl ?? '', + chainId: position.chainId, + marketValue: 0, + iconGroup: [], + sections: [], + }; + groupsByKey.set(groupKey, group); + } + + if (position.marketValue !== undefined) { + group.marketValue += + position.marketValue * getMarketValueSign(position.positionType); + } + + if (!group.iconGroup.some((item) => item.symbol === position.symbol)) { + group.iconGroup.push({ + symbol: position.symbol, + avatarValue: position.tokenImage, + }); + } + + // Sections are keyed by productName; distinct groupIds under the same + // product remain available on each underlying position. + let section = group.sections.find( + (item) => item.productName === productName, + ); + if (!section) { + section = { productName, positions: [] }; + group.sections.push(section); + } + section.positions.push(position); + } + + const result: DeFiPositionsByAccount = {}; + for (const [accountId, groupsByKey] of groupsByAccountKey) { + const groups = [...groupsByKey.values()]; + for (const group of groups) { + orderIconGroup(group.iconGroup); + } + result[accountId] = groups; + } + + return result; +} diff --git a/packages/assets-controllers/src/DeFiPositionsController/group-defi-positions.test.ts b/packages/assets-controllers/src/DeFiPositionsController/group-defi-positions.test.ts new file mode 100644 index 00000000000..213e705eba5 --- /dev/null +++ b/packages/assets-controllers/src/DeFiPositionsController/group-defi-positions.test.ts @@ -0,0 +1,62 @@ +import assert from 'assert'; + +import { + MOCK_DEFI_RESPONSE_BORROW, + MOCK_DEFI_RESPONSE_COMPLEX, + MOCK_DEFI_RESPONSE_FAILED_ENTRY, + MOCK_DEFI_RESPONSE_MULTI_CHAIN, + MOCK_DEFI_RESPONSE_NO_PRICES, +} from './__fixtures__/mock-responses.js'; +import { MOCK_EXPECTED_RESULT } from './__fixtures__/mock-result.js'; +import { groupDeFiPositions } from './group-defi-positions.js'; + +describe('groupDeFiPositions', () => { + it('groups multiple chains', () => { + const result = groupDeFiPositions(MOCK_DEFI_RESPONSE_MULTI_CHAIN); + + expect(Object.keys(result)).toHaveLength(2); + expect(Object.keys(result)[0]).toBe('0x1'); + expect(Object.keys(result)[1]).toBe('0x2105'); + }); + + it('does not display failed entries', () => { + const result = groupDeFiPositions(MOCK_DEFI_RESPONSE_FAILED_ENTRY); + + const protocolResults = result['0x1'].protocols['aave-v3']; + expect(protocolResults.positionTypes.supply).toBeDefined(); + expect(protocolResults.positionTypes.borrow).toBeUndefined(); + }); + + it('handles results with no prices and displays them', () => { + const result = groupDeFiPositions(MOCK_DEFI_RESPONSE_NO_PRICES); + + const supplyResults = + result['0x1'].protocols['aave-v3'].positionTypes.supply; + expect(supplyResults).toBeDefined(); + assert(supplyResults); + expect(Object.values(supplyResults.positions)).toHaveLength(1); + expect(Object.values(supplyResults.positions[0])).toHaveLength(2); + expect(supplyResults.aggregatedMarketValue).toBe(40); + }); + + it('substracts borrow positions from total market value', () => { + const result = groupDeFiPositions(MOCK_DEFI_RESPONSE_BORROW); + + const protocolResults = result['0x1'].protocols['aave-v3']; + assert(protocolResults.positionTypes.supply); + assert(protocolResults.positionTypes.borrow); + expect(protocolResults.positionTypes.supply.aggregatedMarketValue).toBe( + 1540, + ); + expect(protocolResults.positionTypes.borrow.aggregatedMarketValue).toBe( + 1000, + ); + expect(protocolResults.aggregatedMarketValue).toBe(540); + }); + + it('verifies that the resulting object is valid', () => { + const result = groupDeFiPositions(MOCK_DEFI_RESPONSE_COMPLEX); + + expect(result).toStrictEqual(MOCK_EXPECTED_RESULT); + }); +}); diff --git a/packages/assets-controllers/src/DeFiPositionsController/group-defi-positions.ts b/packages/assets-controllers/src/DeFiPositionsController/group-defi-positions.ts new file mode 100644 index 00000000000..dfef9acc50c --- /dev/null +++ b/packages/assets-controllers/src/DeFiPositionsController/group-defi-positions.ts @@ -0,0 +1,159 @@ +import { toHex } from '@metamask/controller-utils'; +import type { Hex } from '@metamask/utils'; + +import type { + DefiPositionResponse, + PositionType, + ProtocolToken, + Underlying, + Balance, +} from './fetch-positions.js'; + +export type GroupedDeFiPositions = { + aggregatedMarketValue: number; + protocols: { + [protocolId: string]: { + protocolDetails: { + name: string; + iconUrl: string; + }; + aggregatedMarketValue: number; + positionTypes: { + [key in PositionType]?: { + aggregatedMarketValue: number; + positions: ProtocolTokenWithMarketValue[][]; + }; + }; + }; + }; +}; + +export type ProtocolTokenWithMarketValue = Omit & { + marketValue?: number; + tokens: UnderlyingWithMarketValue[]; +}; + +export type UnderlyingWithMarketValue = Omit & { + marketValue?: number; +}; + +/** + * + * @param defiPositionsResponse - The response from the defi positions API + * @returns The grouped positions that get assigned to the state + */ +export function groupDeFiPositions( + defiPositionsResponse: DefiPositionResponse[], +): { + [key: Hex]: GroupedDeFiPositions; +} { + const groupedDeFiPositions: { [key: Hex]: GroupedDeFiPositions } = {}; + + for (const position of defiPositionsResponse) { + if (!position.success) { + continue; + } + + const { chainId, protocolId, iconUrl, positionType, protocolDisplayName } = + position; + + const chain = toHex(chainId); + + if (!groupedDeFiPositions[chain]) { + groupedDeFiPositions[chain] = { + aggregatedMarketValue: 0, + protocols: {}, + }; + } + + const chainData = groupedDeFiPositions[chain]; + + if (!chainData.protocols[protocolId]) { + chainData.protocols[protocolId] = { + protocolDetails: { + name: protocolDisplayName, + iconUrl, + }, + aggregatedMarketValue: 0, + positionTypes: {}, + }; + } + + const protocolData = chainData.protocols[protocolId]; + + let positionTypeData = protocolData.positionTypes[positionType]; + if (!positionTypeData) { + positionTypeData = { + aggregatedMarketValue: 0, + positions: [], + }; + protocolData.positionTypes[positionType] = positionTypeData; + } + + for (const protocolToken of position.tokens) { + const token = processToken(protocolToken) as ProtocolTokenWithMarketValue; + + // If groupPositions is true, we group all positions of the same type + if (position.metadata?.groupPositions) { + if (positionTypeData.positions.length === 0) { + positionTypeData.positions.push([token]); + } else { + positionTypeData.positions[0].push(token); + } + } else { + positionTypeData.positions.push([token]); + } + + if (token.marketValue) { + const multiplier = position.positionType === 'borrow' ? -1 : 1; + + positionTypeData.aggregatedMarketValue += token.marketValue; + protocolData.aggregatedMarketValue += token.marketValue * multiplier; + chainData.aggregatedMarketValue += token.marketValue * multiplier; + } + } + } + + return groupedDeFiPositions; +} + +/** + * + * @param tokenBalance - The token balance that is going to be processed + * @returns The processed token balance + */ +function processToken( + tokenBalance: T, +): T & { + marketValue?: number; + tokens?: UnderlyingWithMarketValue[]; +} { + if (!tokenBalance.tokens) { + return { + ...tokenBalance, + marketValue: tokenBalance.price + ? tokenBalance.balance * tokenBalance.price + : undefined, + }; + } + + const processedTokens = tokenBalance.tokens.map((t) => { + const { tokens, ...tokenWithoutUnderlyings } = processToken(t); + + return tokenWithoutUnderlyings; + }); + + const marketValue = processedTokens.reduce( + (acc, t) => + acc === undefined || t.marketValue === undefined + ? undefined + : acc + t.marketValue, + 0, + ); + + return { + ...tokenBalance, + marketValue, + tokens: processedTokens, + }; +} diff --git a/packages/assets-controllers/src/DeFiPositionsController/merge-positions-for-accounts.test.ts b/packages/assets-controllers/src/DeFiPositionsController/merge-positions-for-accounts.test.ts new file mode 100644 index 00000000000..10366dc90e3 --- /dev/null +++ b/packages/assets-controllers/src/DeFiPositionsController/merge-positions-for-accounts.test.ts @@ -0,0 +1,202 @@ +import type { CaipAssetType, CaipChainId } from '@metamask/utils'; + +import type { + DeFiProtocolPositionGroup, + DeFiUnderlyingPosition, +} from './group-defi-positions-v6.js'; +import { mergePositionsForAccounts } from './merge-positions-for-accounts.js'; + +const ETH_MAINNET = 'eip155:1' as CaipChainId; +const BASE = 'eip155:8453' as CaipChainId; + +const WETH_ASSET_ID = + 'eip155:1/erc20:0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2' as CaipAssetType; +const USDC_ASSET_ID = + 'eip155:1/erc20:0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48' as CaipAssetType; + +/** + * Builds an underlying position for tests. + * + * @param overrides - Fields to override on the default position. + * @returns An underlying position. + */ +function buildPosition( + overrides: Partial = {}, +): DeFiUnderlyingPosition { + return { + assetId: WETH_ASSET_ID, + chainId: ETH_MAINNET, + symbol: 'WETH', + name: 'Wrapped Ether', + decimals: 18, + balance: '1', + marketValue: 2000, + positionType: 'deposit', + poolAddress: '0xpool', + groupId: 'group-1', + tokenImage: 'https://example.com/weth.png', + ...overrides, + }; +} + +/** + * Builds a protocol position group for tests. + * + * @param overrides - Fields to override on the default group. + * @returns A protocol position group. + */ +function buildGroup( + overrides: Partial = {}, +): DeFiProtocolPositionGroup { + return { + protocolId: 'aave-v3', + productName: 'Aave V3', + protocolIconUrl: 'https://example.com/aave.png', + chainId: ETH_MAINNET, + marketValue: 2000, + iconGroup: [ + { symbol: 'WETH', avatarValue: 'https://example.com/weth.png' }, + ], + sections: [{ productName: 'Aave V3', positions: [buildPosition()] }], + ...overrides, + }; +} + +describe('mergePositionsForAccounts', () => { + it('returns an empty list when no accounts have positions', () => { + expect(mergePositionsForAccounts({}, ['account-1'])).toStrictEqual([]); + }); + + it('returns a single account’s groups unchanged in content', () => { + const group = buildGroup(); + + const result = mergePositionsForAccounts({ 'account-1': [group] }, [ + 'account-1', + ]); + + expect(result).toStrictEqual([group]); + }); + + it('does not mutate the source groups held in state', () => { + const group = buildGroup(); + const positionsByAccount = { 'account-1': [group] }; + + const result = mergePositionsForAccounts(positionsByAccount, ['account-1']); + result[0].marketValue = 999; + result[0].iconGroup.push({ symbol: 'HACK' }); + result[0].sections.push({ productName: 'HACK', positions: [] }); + result[0].sections[0].positions.push(buildPosition({ symbol: 'HACK' })); + + expect(group.marketValue).toBe(2000); + expect(group.iconGroup).toHaveLength(1); + expect(group.sections).toHaveLength(1); + expect(group.sections[0].positions).toHaveLength(1); + }); + + it('keeps groups on the same protocol but different chains separate', () => { + const ethGroup = buildGroup({ chainId: ETH_MAINNET }); + const baseGroup = buildGroup({ chainId: BASE }); + + const result = mergePositionsForAccounts( + { 'account-1': [ethGroup], 'account-2': [baseGroup] }, + ['account-1', 'account-2'], + ); + + expect(result).toHaveLength(2); + expect(result.map((group) => group.chainId)).toStrictEqual([ + ETH_MAINNET, + BASE, + ]); + }); + + it('merges groups that share chain and protocol across accounts', () => { + const groupA = buildGroup({ + marketValue: 2000, + iconGroup: [{ symbol: 'WETH' }], + sections: [ + { + productName: 'Aave V3', + positions: [buildPosition({ symbol: 'WETH' })], + }, + ], + }); + const groupB = buildGroup({ + marketValue: 500, + iconGroup: [{ symbol: 'USDC' }], + sections: [ + { + productName: 'Aave V3', + positions: [ + buildPosition({ symbol: 'USDC', assetId: USDC_ASSET_ID }), + ], + }, + ], + }); + + const result = mergePositionsForAccounts( + { 'account-1': [groupA], 'account-2': [groupB] }, + ['account-1', 'account-2'], + ); + + expect(result).toHaveLength(1); + expect(result[0].marketValue).toBe(2500); + expect(result[0].iconGroup.map((icon) => icon.symbol)).toStrictEqual([ + 'WETH', + 'USDC', + ]); + expect(result[0].sections).toHaveLength(1); + expect(result[0].sections[0].positions).toHaveLength(2); + }); + + it('deduplicates icon entries that share a symbol when merging', () => { + const groupA = buildGroup({ iconGroup: [{ symbol: 'WETH' }] }); + const groupB = buildGroup({ iconGroup: [{ symbol: 'WETH' }] }); + + const result = mergePositionsForAccounts( + { 'account-1': [groupA], 'account-2': [groupB] }, + ['account-1', 'account-2'], + ); + + expect(result[0].iconGroup).toHaveLength(1); + }); + + it('ignores account IDs that are not in the selected group', () => { + const result = mergePositionsForAccounts( + { 'account-1': [buildGroup()], 'account-2': [buildGroup()] }, + ['account-1'], + ); + + expect(result).toHaveLength(1); + }); + + it('keeps sections with distinct productNames separate when merging', () => { + const groupA = buildGroup({ + sections: [ + { + productName: 'Aave V3', + positions: [buildPosition({ symbol: 'WETH' })], + }, + ], + }); + const groupB = buildGroup({ + sections: [ + { + productName: 'Pendle', + positions: [ + buildPosition({ symbol: 'USDC', assetId: USDC_ASSET_ID }), + ], + }, + ], + }); + + const result = mergePositionsForAccounts( + { 'account-1': [groupA], 'account-2': [groupB] }, + ['account-1', 'account-2'], + ); + + expect(result).toHaveLength(1); + expect( + result[0].sections.map((section) => section.productName), + ).toStrictEqual(['Aave V3', 'Pendle']); + }); +}); diff --git a/packages/assets-controllers/src/DeFiPositionsController/merge-positions-for-accounts.ts b/packages/assets-controllers/src/DeFiPositionsController/merge-positions-for-accounts.ts new file mode 100644 index 00000000000..cd52deb69e7 --- /dev/null +++ b/packages/assets-controllers/src/DeFiPositionsController/merge-positions-for-accounts.ts @@ -0,0 +1,89 @@ +import type { + DeFiPositionDetailsSection, + DeFiPositionsByAccount, + DeFiProtocolPositionGroup, +} from './group-defi-positions-v6.js'; + +/** + * Merges details-page sections that share the same `productName`, appending + * positions rather than keeping them as separate adjacent sections. + * + * @param existingSections - Sections already collected for this protocol group. + * @param incomingSections - Sections from another account holding the same + * protocol, to be merged in. + * @returns The merged sections, one per distinct `productName`. + */ +function mergeSections( + existingSections: DeFiPositionDetailsSection[], + incomingSections: DeFiPositionDetailsSection[], +): DeFiPositionDetailsSection[] { + const byProductName = new Map( + existingSections.map((section) => [ + section.productName, + { ...section, positions: [...section.positions] }, + ]), + ); + + for (const section of incomingSections) { + const existing = byProductName.get(section.productName); + + if (!existing) { + byProductName.set(section.productName, { + ...section, + positions: [...section.positions], + }); + continue; + } + + existing.positions.push(...section.positions); + } + + return [...byProductName.values()]; +} + +/** + * Merges the protocol groups of every account in the selected group into a + * single flat list, combining groups that share the same chain and protocol. + * + * The controller stores DeFi positions keyed per internal account, but every + * client surface consumes the selected account group as a single merged list. + * This helper is exported so both clients share one implementation rather than + * each maintaining a copy. + * + * @param positionsByAccount - DeFi positions keyed by internal account ID. + * @param accountIds - Internal account IDs in the selected account group. + * @returns The merged protocol groups. + */ +export function mergePositionsForAccounts( + positionsByAccount: DeFiPositionsByAccount, + accountIds: string[], +): DeFiProtocolPositionGroup[] { + const byKey = new Map(); + + for (const accountId of accountIds) { + for (const group of positionsByAccount[accountId] ?? []) { + const key = `${group.chainId}#${group.protocolId}`; + const existing = byKey.get(key); + + if (!existing) { + // Clone so we never mutate the object held in client state. + byKey.set(key, { + ...group, + iconGroup: [...group.iconGroup], + sections: mergeSections([], group.sections), + }); + continue; + } + + existing.marketValue += group.marketValue; + for (const icon of group.iconGroup) { + if (!existing.iconGroup.some((item) => item.symbol === icon.symbol)) { + existing.iconGroup.push(icon); + } + } + existing.sections = mergeSections(existing.sections, group.sections); + } + } + + return [...byKey.values()]; +} diff --git a/packages/assets-controllers/src/MultichainAssetsController/MultichainAssetsController-method-action-types.ts b/packages/assets-controllers/src/MultichainAssetsController/MultichainAssetsController-method-action-types.ts new file mode 100644 index 00000000000..a61cfdcd3e0 --- /dev/null +++ b/packages/assets-controllers/src/MultichainAssetsController/MultichainAssetsController-method-action-types.ts @@ -0,0 +1,50 @@ +/** + * This file is auto generated. + * Do not edit manually. + */ + +import type { MultichainAssetsController } from './MultichainAssetsController.js'; + +/** + * Returns the metadata for the given asset + * + * @param asset - The asset to get metadata for + * @returns The metadata for the asset or undefined if not found. + */ +export type MultichainAssetsControllerGetAssetMetadataAction = { + type: `MultichainAssetsController:getAssetMetadata`; + handler: MultichainAssetsController['getAssetMetadata']; +}; + +/** + * Ignores a batch of assets for a specific account. + * + * @param assetsToIgnore - Array of asset IDs to ignore. + * @param accountId - The account ID to ignore assets for. + */ +export type MultichainAssetsControllerIgnoreAssetsAction = { + type: `MultichainAssetsController:ignoreAssets`; + handler: MultichainAssetsController['ignoreAssets']; +}; + +/** + * Adds multiple assets to the stored asset list for a specific account. + * All assets must belong to the same chain. + * + * @param assetIds - Array of CAIP asset IDs to add (must be from same chain). + * @param accountId - The account ID to add the assets to. + * @returns The updated asset list for the account. + * @throws Error if assets are from different chains. + */ +export type MultichainAssetsControllerAddAssetsAction = { + type: `MultichainAssetsController:addAssets`; + handler: MultichainAssetsController['addAssets']; +}; + +/** + * Union of all MultichainAssetsController action types. + */ +export type MultichainAssetsControllerMethodActions = + | MultichainAssetsControllerGetAssetMetadataAction + | MultichainAssetsControllerIgnoreAssetsAction + | MultichainAssetsControllerAddAssetsAction; diff --git a/packages/assets-controllers/src/MultichainAssetsController/MultichainAssetsController.test.ts b/packages/assets-controllers/src/MultichainAssetsController/MultichainAssetsController.test.ts new file mode 100644 index 00000000000..463e4a2e1e2 --- /dev/null +++ b/packages/assets-controllers/src/MultichainAssetsController/MultichainAssetsController.test.ts @@ -0,0 +1,2242 @@ +import { deriveStateFromMetadata } from '@metamask/base-controller'; +import type { + AccountAssetListUpdatedEventPayload, + CaipAssetType, + CaipAssetTypeOrId, +} from '@metamask/keyring-api'; +import { + EthAccountType, + EthMethod, + EthScope, + SolScope, +} from '@metamask/keyring-api'; +import { KeyringTypes } from '@metamask/keyring-controller'; +import type { InternalAccount } from '@metamask/keyring-internal-api'; +import { Messenger, MOCK_ANY_NAMESPACE } from '@metamask/messenger'; +import type { + MessengerActions, + MessengerEvents, + MockAnyNamespace, +} from '@metamask/messenger'; +import type { PermissionConstraint } from '@metamask/permission-controller'; +import type { SubjectPermissions } from '@metamask/permission-controller'; +import type { BulkTokenScanResponse } from '@metamask/phishing-controller'; +import { TokenScanResultType } from '@metamask/phishing-controller'; +import type { Snap } from '@metamask/snaps-utils'; +import { v4 as uuidv4 } from 'uuid'; + +import { jestAdvanceTime } from '../../../../tests/helpers.js'; +import { + getDefaultMultichainAssetsControllerState, + MultichainAssetsController, +} from './index.js'; +import type { + AssetMetadataResponse, + MultichainAssetsControllerMessenger, + MultichainAssetsControllerState, +} from './MultichainAssetsController.js'; + +const mockSolanaAccount: InternalAccount = { + type: 'solana:data-account', + id: 'a3fc6831-d229-4cd1-87c1-13b1756213d4', + address: 'EBBYfhQzVzurZiweJ2keeBWpgGLs1cbWYcz28gjGgi5x', + scopes: [SolScope.Devnet], + options: { + scope: SolScope.Devnet, + }, + methods: ['sendAndConfirmTransaction'], + metadata: { + name: 'Snap Account 1', + importTime: 1737022568097, + keyring: { + type: 'Snap Keyring', + }, + snap: { + id: 'local:http://localhost:8080', + name: 'Solana', + enabled: true, + }, + lastSelected: 0, + }, +}; + +const mockEthAccount: InternalAccount = { + address: '0x807dE1cf8f39E83258904b2f7b473E5C506E4aC1', + id: uuidv4(), + metadata: { + name: 'Ethereum Account 1', + importTime: Date.now(), + keyring: { + type: KeyringTypes.snap, + }, + snap: { + id: 'mock-eth-snap', + name: 'mock-eth-snap', + enabled: true, + }, + lastSelected: 0, + }, + scopes: [EthScope.Eoa], + options: {}, + methods: [EthMethod.SignTypedDataV4, EthMethod.SignTransaction], + type: EthAccountType.Eoa, +}; + +const mockHandleRequestOnAssetsLookupReturnValue = [ + 'solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1/slip44:501', + 'solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1/token:Gh9ZwEmdLJ8DscKNTkTqPbNwLNNBjuSzaG9Vp2KGtKJr', +]; + +const mockGetAllSnapsReturnValue = [ + { + blocked: false, + enabled: true, + id: 'local:http://localhost:8080', + version: '1.0.4', + }, + { + blocked: false, + enabled: true, + id: 'npm:@metamask/account-watcher', + version: '4.1.0', + }, + { + blocked: false, + enabled: true, + id: 'npm:@metamask/bitcoin-wallet-snap', + version: '0.8.2', + }, + { + blocked: false, + enabled: true, + id: 'npm:@metamask/ens-resolver-snap', + version: '0.1.2', + }, + { + blocked: false, + enabled: true, + id: 'npm:@metamask/message-signing-snap', + version: '0.6.0', + }, + { + blocked: false, + enabled: true, + id: 'npm:@metamask/preinstalled-example-snap', + version: '0.2.0', + }, + { + blocked: false, + enabled: true, + id: 'npm:@metamask/solana-wallet-snap', + version: '1.0.3', + }, +]; + +const mockGetPermissionsReturnValue = [ + { + 'endowment:assets': { + caveats: [ + { + type: 'chainIds', + value: ['solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1'], + }, + ], + }, + }, + { + 'endowment:ethereum-provider': { + caveats: null, + date: 1736868793768, + id: 'CTUx_19iltoLo-xnIjGMc', + invoker: 'npm:@metamask/account-watcher', + parentCapability: 'endowment:ethereum-provider', + }, + }, + { + 'endowment:network-access': { + caveats: null, + date: 1736868793769, + id: '9NST-8ZIQO7_BVVJP6JyD', + invoker: 'npm:@metamask/bitcoin-wallet-snap', + parentCapability: 'endowment:network-access', + }, + }, + { + 'endowment:ethereum-provider': { + caveats: null, + date: 1736868793767, + id: '8cUIGf_BjDke2xJSn_kBL', + invoker: 'npm:@metamask/ens-resolver-snap', + parentCapability: 'endowment:ethereum-provider', + }, + }, + { + 'endowment:rpc': { + date: 1736868793765, + id: 'j8XfK-fPq13COl7xFQxXn', + invoker: 'npm:@metamask/message-signing-snap', + parentCapability: 'endowment:rpc', + }, + }, + { + 'endowment:rpc': { + date: 1736868793771, + id: 'Yd155j5BoXh3BIndgMkAM', + invoker: 'npm:@metamask/preinstalled-example-snap', + parentCapability: 'endowment:rpc', + }, + }, + { + 'endowment:network-access': { + caveats: null, + date: 1736868793773, + id: 'HbXb8MLHbRrQMexyVpQQ7', + invoker: 'npm:@metamask/solana-wallet-snap', + parentCapability: 'endowment:network-access', + }, + }, +]; + +const mockGetMetadataReturnValue: AssetMetadataResponse | undefined = { + assets: { + 'solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1/slip44:501': { + name: 'Solana', + symbol: 'SOL', + fungible: true, + iconUrl: 'url1', + units: [{ name: 'Solana', symbol: 'SOL', decimals: 9 }], + }, + 'solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1/token:Gh9ZwEmdLJ8DscKNTkTqPbNwLNNBjuSzaG9Vp2KGtKJr': + { + name: 'USDC', + symbol: 'USDC', + fungible: true, + iconUrl: 'url2', + units: [{ name: 'USDC', symbol: 'SUSDCOL', decimals: 18 }], + }, + }, +}; + +/** + * The union of actions that the root messenger allows. + */ +type RootAction = MessengerActions; + +/** + * The union of events that the root messenger allows. + */ +type RootEvent = MessengerEvents; + +/** + * The root messenger type. + */ +type RootMessenger = Messenger; + +/** + * Constructs the root messenger. This can be used to call actions and + * publish events within the tests for this controller. + * + * @returns The root messenger suited for MultichainAssetsController. + */ +function getRootMessenger(): RootMessenger { + return new Messenger({ namespace: MOCK_ANY_NAMESPACE }); +} + +type SetupControllerResult = { + controller: MultichainAssetsController; + messenger: RootMessenger; + mockSnapHandleRequest: jest.Mock; + mockListMultichainAccounts: jest.Mock; + mockGetAllSnaps: jest.Mock; + mockGetPermissions: jest.Mock; + mockBulkScanTokens: jest.Mock; +}; + +/** Request shape for `PhishingController:bulkScanTokens` in tests. */ +type BulkTokenScanTestRequest = { + chainId: string; + tokens: string[]; +}; + +const setupController = ({ + state = getDefaultMultichainAssetsControllerState(), + mocks, + /** `0` disables periodic Blockaid re-scan (default for tests). */ + blockaidTokenRescanInterval = 0, + isDeprecated, +}: { + state?: MultichainAssetsControllerState; + blockaidTokenRescanInterval?: number; + isDeprecated?: () => boolean; + mocks?: { + listMultichainAccounts?: InternalAccount[]; + handleRequestReturnValue?: CaipAssetTypeOrId[]; + getAllReturnValue?: Snap[]; + getPermissionsReturnValue?: SubjectPermissions; + }; +} = {}): SetupControllerResult => { + const messenger = getRootMessenger(); + + const multichainAssetsControllerMessenger: MultichainAssetsControllerMessenger = + new Messenger({ + namespace: 'MultichainAssetsController', + parent: messenger, + }); + messenger.delegate({ + messenger: multichainAssetsControllerMessenger, + actions: [ + 'AccountsController:listMultichainAccounts', + 'SnapController:handleRequest', + 'SnapController:getRunnableSnaps', + 'PermissionController:getPermissions', + 'PhishingController:bulkScanTokens', + ], + events: [ + 'AccountsController:accountAdded', + 'AccountsController:accountRemoved', + 'AccountsController:accountAssetListUpdated', + ], + }); + + const mockSnapHandleRequest = jest.fn(); + messenger.registerActionHandler( + 'SnapController:handleRequest', + mockSnapHandleRequest.mockReturnValue( + mocks?.handleRequestReturnValue ?? + mockHandleRequestOnAssetsLookupReturnValue, + ), + ); + + const mockListMultichainAccounts = jest.fn(); + messenger.registerActionHandler( + 'AccountsController:listMultichainAccounts', + mockListMultichainAccounts.mockReturnValue( + mocks?.listMultichainAccounts ?? [mockSolanaAccount, mockEthAccount], + ), + ); + + const mockGetAllSnaps = jest.fn(); + messenger.registerActionHandler( + 'SnapController:getRunnableSnaps', + mockGetAllSnaps.mockReturnValue( + mocks?.getAllReturnValue ?? mockGetAllSnapsReturnValue, + ), + ); + + const mockGetPermissions = jest.fn(); + messenger.registerActionHandler( + 'PermissionController:getPermissions', + mockGetPermissions.mockReturnValue( + mocks?.getPermissionsReturnValue ?? mockGetPermissionsReturnValue[0], + ), + ); + + const mockBulkScanTokens = jest + .fn() + .mockImplementation( + (request: BulkTokenScanTestRequest): Promise => { + const results: BulkTokenScanResponse = {}; + for (const addr of request.tokens) { + results[addr] = { + result_type: TokenScanResultType.Benign, + chain: request.chainId, + address: addr, + }; + } + return Promise.resolve(results); + }, + ); + messenger.registerActionHandler( + 'PhishingController:bulkScanTokens', + mockBulkScanTokens, + ); + + const controller = new MultichainAssetsController({ + messenger: multichainAssetsControllerMessenger, + state, + blockaidTokenRescanInterval, + ...(isDeprecated && { isDeprecated }), + }); + + return { + controller, + messenger, + mockSnapHandleRequest, + mockListMultichainAccounts, + mockGetAllSnaps, + mockGetPermissions, + mockBulkScanTokens, + }; +}; + +describe('MultichainAssetsController', () => { + beforeEach(() => { + jest.useFakeTimers(); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + it('initialize with default state', () => { + const { controller } = setupController({}); + expect(controller.state).toStrictEqual({ + accountsAssets: {}, + assetsMetadata: {}, + allIgnoredAssets: {}, + }); + }); + + it('does not update state when new account added is EVM', async () => { + const { controller, messenger } = setupController(); + + messenger.publish( + 'AccountsController:accountAdded', + mockEthAccount as unknown as InternalAccount, + ); + + await jestAdvanceTime({ duration: 1 }); + + expect(controller.state).toStrictEqual({ + accountsAssets: {}, + assetsMetadata: {}, + allIgnoredAssets: {}, + }); + }); + + it('updates accountsAssets when "AccountsController:accountAdded" is fired', async () => { + const { controller, messenger, mockSnapHandleRequest, mockGetPermissions } = + setupController(); + + mockSnapHandleRequest + .mockReturnValueOnce(mockHandleRequestOnAssetsLookupReturnValue) + .mockReturnValueOnce(mockGetMetadataReturnValue); + + mockGetPermissions + .mockReturnValueOnce(mockGetPermissionsReturnValue[0]) + .mockReturnValueOnce(mockGetPermissionsReturnValue[1]) + .mockReturnValueOnce(mockGetPermissionsReturnValue[2]) + .mockReturnValueOnce(mockGetPermissionsReturnValue[3]) + .mockReturnValueOnce(mockGetPermissionsReturnValue[4]) + .mockReturnValueOnce(mockGetPermissionsReturnValue[5]) + .mockReturnValueOnce(mockGetPermissionsReturnValue[6]); + + messenger.publish( + 'AccountsController:accountAdded', + mockSolanaAccount as unknown as InternalAccount, + ); + + await jestAdvanceTime({ duration: 1 }); + + expect(controller.state).toStrictEqual({ + accountsAssets: { + [mockSolanaAccount.id]: mockHandleRequestOnAssetsLookupReturnValue, + }, + assetsMetadata: mockGetMetadataReturnValue.assets, + allIgnoredAssets: {}, + }); + }); + + it('updates metadata in state successfully when all calls succeed to fetch metadata', async () => { + const { controller, messenger, mockSnapHandleRequest, mockGetPermissions } = + setupController(); + + const mockHandleRequestOnAssetsLookupResponse = [ + 'solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1/slip44:501', + 'solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1/token:Gh9ZwEmdLJ8DscKNTkTqPbNwLNNBjuSzaG9Vp2KGtKJr', + 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/slip44:501', + ]; + const mockSnapPermissionReturnVal = { + 'endowment:assets': { + caveats: [ + { + type: 'chainIds', + value: [ + 'solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1', + 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp', + ], + }, + ], + }, + }; + const mockGetMetadataResponse: AssetMetadataResponse | undefined = { + assets: { + 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/slip44:501': { + name: 'Solana2', + symbol: 'SOL', + fungible: true, + iconUrl: 'url1', + units: [{ name: 'Solana2', symbol: 'SOL', decimals: 9 }], + }, + }, + }; + + mockSnapHandleRequest + .mockReturnValueOnce(mockHandleRequestOnAssetsLookupResponse) + .mockReturnValueOnce(mockGetMetadataReturnValue) + .mockReturnValueOnce(mockGetMetadataResponse); + + mockGetPermissions + .mockReturnValueOnce(mockSnapPermissionReturnVal) + .mockReturnValueOnce(mockGetPermissionsReturnValue[1]) + .mockReturnValueOnce(mockGetPermissionsReturnValue[2]) + .mockReturnValueOnce(mockGetPermissionsReturnValue[3]) + .mockReturnValueOnce(mockGetPermissionsReturnValue[4]) + .mockReturnValueOnce(mockGetPermissionsReturnValue[5]) + .mockReturnValueOnce(mockGetPermissionsReturnValue[6]); + + messenger.publish( + 'AccountsController:accountAdded', + mockSolanaAccount as unknown as InternalAccount, + ); + + await jestAdvanceTime({ duration: 1 }); + + expect(mockSnapHandleRequest).toHaveBeenCalledTimes(3); + + expect(controller.state).toStrictEqual({ + accountsAssets: { + [mockSolanaAccount.id]: mockHandleRequestOnAssetsLookupResponse, + }, + assetsMetadata: { + ...mockGetMetadataResponse.assets, + ...mockGetMetadataReturnValue.assets, + }, + allIgnoredAssets: {}, + }); + }); + + it('updates metadata in state successfully when one call to fetch metadata fails', async () => { + const { controller, messenger, mockSnapHandleRequest, mockGetPermissions } = + setupController(); + + const mockHandleRequestOnAssetsLookupResponse = [ + 'solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1/slip44:501', + 'solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1/token:Gh9ZwEmdLJ8DscKNTkTqPbNwLNNBjuSzaG9Vp2KGtKJr', + 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/slip44:501', + ]; + const mockSnapPermissionReturnVal = { + 'endowment:assets': { + caveats: [ + { + type: 'chainIds', + value: [ + 'solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1', + 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp', + ], + }, + ], + }, + }; + + mockSnapHandleRequest + .mockReturnValueOnce(mockHandleRequestOnAssetsLookupResponse) + .mockReturnValueOnce(mockGetMetadataReturnValue) + .mockRejectedValueOnce('Error'); + + mockGetPermissions + .mockReturnValueOnce(mockSnapPermissionReturnVal) + .mockReturnValueOnce(mockGetPermissionsReturnValue[1]) + .mockReturnValueOnce(mockGetPermissionsReturnValue[2]) + .mockReturnValueOnce(mockGetPermissionsReturnValue[3]) + .mockReturnValueOnce(mockGetPermissionsReturnValue[4]) + .mockReturnValueOnce(mockGetPermissionsReturnValue[5]) + .mockReturnValueOnce(mockGetPermissionsReturnValue[6]); + + messenger.publish( + 'AccountsController:accountAdded', + mockSolanaAccount as unknown as InternalAccount, + ); + + await jestAdvanceTime({ duration: 1 }); + + expect(mockSnapHandleRequest).toHaveBeenCalledTimes(3); + + expect(controller.state).toStrictEqual({ + accountsAssets: { + [mockSolanaAccount.id]: mockHandleRequestOnAssetsLookupResponse, + }, + assetsMetadata: { + ...mockGetMetadataReturnValue.assets, + }, + allIgnoredAssets: {}, + }); + }); + + it('does not delete account from accountsAssets when "AccountsController:accountRemoved" is fired with EVM account', async () => { + const { controller, messenger, mockSnapHandleRequest, mockGetPermissions } = + setupController(); + + mockSnapHandleRequest + .mockReturnValueOnce(mockHandleRequestOnAssetsLookupReturnValue) + .mockReturnValueOnce(mockGetMetadataReturnValue); + + mockGetPermissions + .mockReturnValueOnce(mockGetPermissionsReturnValue[0]) + .mockReturnValueOnce(mockGetPermissionsReturnValue[1]) + .mockReturnValueOnce(mockGetPermissionsReturnValue[2]) + .mockReturnValueOnce(mockGetPermissionsReturnValue[3]) + .mockReturnValueOnce(mockGetPermissionsReturnValue[4]) + .mockReturnValueOnce(mockGetPermissionsReturnValue[5]) + .mockReturnValueOnce(mockGetPermissionsReturnValue[6]); + + // Add a solana account first + messenger.publish( + 'AccountsController:accountAdded', + mockSolanaAccount as unknown as InternalAccount, + ); + + await jestAdvanceTime({ duration: 1 }); + + expect(controller.state).toStrictEqual({ + accountsAssets: { + [mockSolanaAccount.id]: mockHandleRequestOnAssetsLookupReturnValue, + }, + + assetsMetadata: mockGetMetadataReturnValue.assets, + allIgnoredAssets: {}, + }); + // Remove an EVM account + messenger.publish('AccountsController:accountRemoved', mockEthAccount.id); + + await jestAdvanceTime({ duration: 1 }); + + expect(controller.state).toStrictEqual({ + accountsAssets: { + [mockSolanaAccount.id]: mockHandleRequestOnAssetsLookupReturnValue, + }, + + assetsMetadata: mockGetMetadataReturnValue.assets, + allIgnoredAssets: {}, + }); + }); + + it('updates accountsAssets when "AccountsController:accountRemoved" is fired', async () => { + const { controller, messenger, mockSnapHandleRequest, mockGetPermissions } = + setupController(); + + mockSnapHandleRequest + .mockReturnValueOnce(mockHandleRequestOnAssetsLookupReturnValue) + .mockReturnValueOnce(mockGetMetadataReturnValue); + + mockGetPermissions + .mockReturnValueOnce(mockGetPermissionsReturnValue[0]) + .mockReturnValueOnce(mockGetPermissionsReturnValue[1]) + .mockReturnValueOnce(mockGetPermissionsReturnValue[2]) + .mockReturnValueOnce(mockGetPermissionsReturnValue[3]) + .mockReturnValueOnce(mockGetPermissionsReturnValue[4]) + .mockReturnValueOnce(mockGetPermissionsReturnValue[5]) + .mockReturnValueOnce(mockGetPermissionsReturnValue[6]); + + // Add a solana account first + messenger.publish( + 'AccountsController:accountAdded', + mockSolanaAccount as unknown as InternalAccount, + ); + + await jestAdvanceTime({ duration: 1 }); + + expect(controller.state).toStrictEqual({ + accountsAssets: { + [mockSolanaAccount.id]: mockHandleRequestOnAssetsLookupReturnValue, + }, + + assetsMetadata: mockGetMetadataReturnValue.assets, + allIgnoredAssets: {}, + }); + // Remove the added solana account + messenger.publish( + 'AccountsController:accountRemoved', + mockSolanaAccount.id, + ); + + await jestAdvanceTime({ duration: 1 }); + + expect(controller.state).toStrictEqual({ + accountsAssets: {}, + + assetsMetadata: mockGetMetadataReturnValue.assets, + allIgnoredAssets: {}, + }); + }); + + describe('handleAccountAssetListUpdated', () => { + it('updates the assets list for an account when a new asset is added', async () => { + const mockSolanaAccountId1 = 'account1'; + const mockSolanaAccountId2 = 'account2'; + const { + messenger, + controller, + mockSnapHandleRequest, + mockGetPermissions, + } = setupController({ + state: { + accountsAssets: { + [mockSolanaAccountId1]: mockHandleRequestOnAssetsLookupReturnValue, + }, + assetsMetadata: mockGetMetadataReturnValue.assets, + allIgnoredAssets: {}, + } as MultichainAssetsControllerState, + }); + + const mockGetMetadataReturnValue1 = { + 'solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1/token:newToken': { + name: 'newToken', + symbol: 'newToken', + decimals: 18, + }, + }; + const mockGetMetadataReturnValue2 = { + 'solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1/token:newToken3': { + name: 'newToken3', + symbol: 'newToken3', + decimals: 18, + }, + }; + mockSnapHandleRequest.mockReturnValue({ + assets: { + ...mockGetMetadataReturnValue1, + ...mockGetMetadataReturnValue2, + }, + }); + + mockGetPermissions + .mockReturnValueOnce(mockGetPermissionsReturnValue[0]) + .mockReturnValueOnce(mockGetPermissionsReturnValue[1]) + .mockReturnValueOnce(mockGetPermissionsReturnValue[2]) + .mockReturnValueOnce(mockGetPermissionsReturnValue[3]) + .mockReturnValueOnce(mockGetPermissionsReturnValue[4]) + .mockReturnValueOnce(mockGetPermissionsReturnValue[5]) + .mockReturnValueOnce(mockGetPermissionsReturnValue[6]); + const updatedAssetsList: AccountAssetListUpdatedEventPayload = { + assets: { + [mockSolanaAccountId1]: { + added: ['solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1/token:newToken'], + removed: [], + }, + [mockSolanaAccountId2]: { + added: ['solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1/token:newToken3'], + removed: [], + }, + }, + }; + + messenger.publish( + 'AccountsController:accountAssetListUpdated', + updatedAssetsList, + ); + + await jestAdvanceTime({ duration: 1 }); + + expect(controller.state.accountsAssets).toStrictEqual({ + [mockSolanaAccountId1]: [ + 'solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1/slip44:501', + 'solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1/token:Gh9ZwEmdLJ8DscKNTkTqPbNwLNNBjuSzaG9Vp2KGtKJr', + 'solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1/token:newToken', + ], + [mockSolanaAccountId2]: [ + 'solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1/token:newToken3', + ], + }); + + expect(mockSnapHandleRequest).toHaveBeenCalledTimes(1); + + expect(controller.state.assetsMetadata).toStrictEqual({ + ...mockGetMetadataReturnValue.assets, + 'solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1/token:newToken': { + name: 'newToken', + symbol: 'newToken', + decimals: 18, + }, + 'solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1/token:newToken3': { + name: 'newToken3', + symbol: 'newToken3', + decimals: 18, + }, + }); + }); + + it('does not add duplicate assets to state', async () => { + const mockSolanaAccountId1 = 'account1'; + const mockSolanaAccountId2 = 'account2'; + const { controller, messenger } = setupController({ + state: { + accountsAssets: { + [mockSolanaAccountId1]: mockHandleRequestOnAssetsLookupReturnValue, + }, + assetsMetadata: mockGetMetadataReturnValue, + allIgnoredAssets: {}, + } as MultichainAssetsControllerState, + }); + + const updatedAssetsList: AccountAssetListUpdatedEventPayload = { + assets: { + [mockSolanaAccountId1]: { + added: + mockHandleRequestOnAssetsLookupReturnValue as `${string}:${string}/${string}:${string}`[], + removed: [], + }, + [mockSolanaAccountId2]: { + added: ['solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1/token:newToken3'], + removed: [], + }, + }, + }; + + messenger.publish( + 'AccountsController:accountAssetListUpdated', + updatedAssetsList, + ); + await jestAdvanceTime({ duration: 1 }); + + expect(controller.state.accountsAssets).toStrictEqual({ + [mockSolanaAccountId1]: [ + 'solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1/slip44:501', + 'solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1/token:Gh9ZwEmdLJ8DscKNTkTqPbNwLNNBjuSzaG9Vp2KGtKJr', + ], + [mockSolanaAccountId2]: [ + 'solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1/token:newToken3', + ], + }); + }); + + it('updates the assets list for an account when a an asset is removed', async () => { + const mockSolanaAccountId1 = 'account1'; + const mockSolanaAccountId2 = 'account2'; + const { controller, messenger } = setupController({ + state: { + accountsAssets: { + [mockSolanaAccountId1]: mockHandleRequestOnAssetsLookupReturnValue, + [mockSolanaAccountId2]: [ + 'solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1/token:newToken3', + ], + }, + assetsMetadata: mockGetMetadataReturnValue, + allIgnoredAssets: {}, + } as MultichainAssetsControllerState, + }); + + const updatedAssetsList: AccountAssetListUpdatedEventPayload = { + assets: { + [mockSolanaAccountId2]: { + added: [], + removed: [ + 'solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1/token:newToken3', + ], + }, + }, + }; + + messenger.publish( + 'AccountsController:accountAssetListUpdated', + updatedAssetsList, + ); + await jestAdvanceTime({ duration: 1 }); + + expect(controller.state.accountsAssets).toStrictEqual({ + [mockSolanaAccountId1]: [ + 'solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1/slip44:501', + 'solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1/token:Gh9ZwEmdLJ8DscKNTkTqPbNwLNNBjuSzaG9Vp2KGtKJr', + ], + [mockSolanaAccountId2]: [], + }); + }); + }); + + describe('getAssetMetadata', () => { + it('returns the metadata for a given asset', async () => { + const { messenger } = setupController({ + state: { + accountsAssets: { + [mockSolanaAccount.id]: mockHandleRequestOnAssetsLookupReturnValue, + }, + assetsMetadata: mockGetMetadataReturnValue.assets, + } as MultichainAssetsControllerState, + }); + + const assetId = 'solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1/slip44:501'; + + const metadata = messenger.call( + 'MultichainAssetsController:getAssetMetadata', + assetId, + ); + + expect(metadata).toStrictEqual( + mockGetMetadataReturnValue.assets[assetId], + ); + }); + + it('returns undefined if the asset metadata is not found', async () => { + const { messenger } = setupController({ + state: { + accountsAssets: { + [mockSolanaAccount.id]: mockHandleRequestOnAssetsLookupReturnValue, + }, + assetsMetadata: mockGetMetadataReturnValue.assets, + } as MultichainAssetsControllerState, + }); + + const assetId = + 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/token:EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v'; + + const metadata = messenger.call( + 'MultichainAssetsController:getAssetMetadata', + assetId, + ); + + expect(metadata).toBeUndefined(); + }); + }); + + describe('ignoreAssets', () => { + it('should ignore assets and remove them from active assets list', () => { + const { controller } = setupController({ + state: { + accountsAssets: { + [mockSolanaAccount.id]: [ + 'solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1/slip44:501', + 'solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1/token:Gh9ZwEmdLJ8DscKNTkTqPbNwLNNBjuSzaG9Vp2KGtKJr', + ], + }, + assetsMetadata: mockGetMetadataReturnValue.assets, + allIgnoredAssets: {}, + } as MultichainAssetsControllerState, + }); + + const assetToIgnore = + 'solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1/slip44:501'; + + controller.ignoreAssets([assetToIgnore], mockSolanaAccount.id); + + expect( + controller.state.accountsAssets[mockSolanaAccount.id], + ).toStrictEqual([ + 'solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1/token:Gh9ZwEmdLJ8DscKNTkTqPbNwLNNBjuSzaG9Vp2KGtKJr', + ]); + expect( + controller.state.allIgnoredAssets[mockSolanaAccount.id], + ).toStrictEqual([assetToIgnore]); + }); + + it('should not add duplicate assets to ignored list', () => { + const assetToIgnore = + 'solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1/slip44:501'; + const { controller } = setupController({ + state: { + accountsAssets: { + [mockSolanaAccount.id]: [assetToIgnore], + }, + assetsMetadata: mockGetMetadataReturnValue.assets, + allIgnoredAssets: { + [mockSolanaAccount.id]: [assetToIgnore], + }, + } as MultichainAssetsControllerState, + }); + + controller.ignoreAssets([assetToIgnore], mockSolanaAccount.id); + + expect( + controller.state.allIgnoredAssets[mockSolanaAccount.id], + ).toStrictEqual([assetToIgnore]); + }); + + it('should handle ignoring assets for accounts with no existing ignored assets', () => { + const { controller } = setupController({ + state: { + accountsAssets: { + [mockSolanaAccount.id]: [ + 'solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1/slip44:501', + ], + }, + assetsMetadata: mockGetMetadataReturnValue.assets, + allIgnoredAssets: {}, + } as MultichainAssetsControllerState, + }); + + const assetToIgnore = + 'solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1/slip44:501'; + controller.ignoreAssets([assetToIgnore], mockSolanaAccount.id); + + expect( + controller.state.allIgnoredAssets[mockSolanaAccount.id], + ).toStrictEqual([assetToIgnore]); + expect( + controller.state.accountsAssets[mockSolanaAccount.id], + ).toStrictEqual([]); + }); + }); + + describe('addAssets', () => { + it('should add a single asset to account assets list', async () => { + const { controller } = setupController({ + state: { + accountsAssets: { + [mockSolanaAccount.id]: [ + 'solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1/slip44:501', + ], + }, + assetsMetadata: mockGetMetadataReturnValue.assets, + allIgnoredAssets: {}, + } as MultichainAssetsControllerState, + }); + + const assetToAdd = + 'solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1/token:Gh9ZwEmdLJ8DscKNTkTqPbNwLNNBjuSzaG9Vp2KGtKJr'; + + const result = await controller.addAssets( + [assetToAdd], + mockSolanaAccount.id, + ); + + expect(result).toStrictEqual([ + 'solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1/slip44:501', + assetToAdd, + ]); + expect( + controller.state.accountsAssets[mockSolanaAccount.id], + ).toStrictEqual([ + 'solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1/slip44:501', + assetToAdd, + ]); + }); + + it('should not add duplicate assets', async () => { + const existingAsset = + 'solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1/slip44:501'; + const { controller } = setupController({ + state: { + accountsAssets: { + [mockSolanaAccount.id]: [existingAsset], + }, + assetsMetadata: mockGetMetadataReturnValue.assets, + allIgnoredAssets: {}, + } as MultichainAssetsControllerState, + }); + + const result = await controller.addAssets( + [existingAsset], + mockSolanaAccount.id, + ); + + expect(result).toStrictEqual([existingAsset]); + expect( + controller.state.accountsAssets[mockSolanaAccount.id], + ).toStrictEqual([existingAsset]); + }); + + it('should remove asset from ignored list when added', async () => { + const assetToAdd = 'solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1/slip44:501'; + const { controller } = setupController({ + state: { + accountsAssets: {}, + assetsMetadata: mockGetMetadataReturnValue.assets, + allIgnoredAssets: { + [mockSolanaAccount.id]: [assetToAdd], + }, + } as MultichainAssetsControllerState, + }); + + const result = await controller.addAssets( + [assetToAdd], + mockSolanaAccount.id, + ); + + expect(result).toStrictEqual([assetToAdd]); + expect( + controller.state.accountsAssets[mockSolanaAccount.id], + ).toStrictEqual([assetToAdd]); + expect( + controller.state.allIgnoredAssets[mockSolanaAccount.id], + ).toBeUndefined(); + }); + + it('should handle adding asset to account with no existing assets', async () => { + const assetToAdd = 'solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1/slip44:501'; + const { controller } = setupController({ + state: { + accountsAssets: {}, + assetsMetadata: mockGetMetadataReturnValue.assets, + allIgnoredAssets: {}, + } as MultichainAssetsControllerState, + }); + + const result = await controller.addAssets( + [assetToAdd], + mockSolanaAccount.id, + ); + + expect(result).toStrictEqual([assetToAdd]); + expect( + controller.state.accountsAssets[mockSolanaAccount.id], + ).toStrictEqual([assetToAdd]); + }); + + it('should publish accountAssetListUpdated event when asset is added', async () => { + const { controller, messenger } = setupController({ + state: { + accountsAssets: {}, + assetsMetadata: mockGetMetadataReturnValue.assets, + allIgnoredAssets: {}, + } as MultichainAssetsControllerState, + }); + + const assetToAdd = 'solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1/slip44:501'; + + // Set up event listener to capture the published event + const eventListener = jest.fn(); + messenger.subscribe( + 'MultichainAssetsController:accountAssetListUpdated', + eventListener, + ); + + await controller.addAssets([assetToAdd], mockSolanaAccount.id); + + expect(eventListener).toHaveBeenCalledWith({ + assets: { + [mockSolanaAccount.id]: { + added: [assetToAdd], + removed: [], + }, + }, + }); + }); + + it('should add multiple assets from the same chain', async () => { + const { controller } = setupController({ + state: { + accountsAssets: { + [mockSolanaAccount.id]: [ + 'solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1/slip44:501', + ], + }, + assetsMetadata: mockGetMetadataReturnValue.assets, + allIgnoredAssets: {}, + } as MultichainAssetsControllerState, + }); + + const assetsToAdd: CaipAssetType[] = [ + 'solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1/token:Gh9ZwEmdLJ8DscKNTkTqPbNwLNNBjuSzaG9Vp2KGtKJr', + 'solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1/token:AnotherTokenAddress', + ]; + + const result = await controller.addAssets( + assetsToAdd, + mockSolanaAccount.id, + ); + + expect(result).toStrictEqual([ + 'solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1/slip44:501', + ...assetsToAdd, + ]); + expect( + controller.state.accountsAssets[mockSolanaAccount.id], + ).toStrictEqual([ + 'solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1/slip44:501', + ...assetsToAdd, + ]); + }); + + it('should throw error when assets are from different chains', async () => { + const { controller } = setupController({ + state: { + accountsAssets: {}, + assetsMetadata: mockGetMetadataReturnValue.assets, + allIgnoredAssets: {}, + } as MultichainAssetsControllerState, + }); + + const assetsFromDifferentChains: CaipAssetType[] = [ + 'solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1/slip44:501', + 'eip155:1/slip44:60', // Ethereum asset + ]; + + await expect( + controller.addAssets(assetsFromDifferentChains, mockSolanaAccount.id), + ).rejects.toThrow( + 'All assets must belong to the same chain. Found assets from chains: solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1, eip155:1', + ); + }); + + it('should return existing assets when empty array is provided', async () => { + const existingAsset = + 'solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1/slip44:501'; + const { controller } = setupController({ + state: { + accountsAssets: { + [mockSolanaAccount.id]: [existingAsset], + }, + assetsMetadata: mockGetMetadataReturnValue.assets, + allIgnoredAssets: {}, + } as MultichainAssetsControllerState, + }); + + const result = await controller.addAssets([], mockSolanaAccount.id); + + expect(result).toStrictEqual([existingAsset]); + }); + + it('should only publish event for newly added assets', async () => { + const existingAsset = + 'solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1/slip44:501'; + const newAsset = 'solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1/token:NewToken'; + + const { controller, messenger } = setupController({ + state: { + accountsAssets: { + [mockSolanaAccount.id]: [existingAsset], + }, + assetsMetadata: mockGetMetadataReturnValue.assets, + allIgnoredAssets: {}, + } as MultichainAssetsControllerState, + }); + + const eventListener = jest.fn(); + messenger.subscribe( + 'MultichainAssetsController:accountAssetListUpdated', + eventListener, + ); + + await controller.addAssets( + [existingAsset, newAsset], + mockSolanaAccount.id, + ); + + expect(eventListener).toHaveBeenCalledWith({ + assets: { + [mockSolanaAccount.id]: { + added: [newAsset], // Only the new asset should be in the event + removed: [], + }, + }, + }); + }); + + it('should not publish event when no new assets are added', async () => { + const existingAsset = + 'solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1/slip44:501'; + + const { controller, messenger } = setupController({ + state: { + accountsAssets: { + [mockSolanaAccount.id]: [existingAsset], + }, + assetsMetadata: mockGetMetadataReturnValue.assets, + allIgnoredAssets: {}, + } as MultichainAssetsControllerState, + }); + + const eventListener = jest.fn(); + messenger.subscribe( + 'MultichainAssetsController:accountAssetListUpdated', + eventListener, + ); + + await controller.addAssets([existingAsset], mockSolanaAccount.id); + + // Event should not be published since no new assets were added + expect(eventListener).not.toHaveBeenCalled(); + }); + + it('should partially remove assets from ignored list when only some are added', async () => { + const ignoredAsset1 = + 'solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1/slip44:501'; + const ignoredAsset2 = + 'solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1/token:Token1'; + const ignoredAsset3 = + 'solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1/token:Token2'; + + const { controller } = setupController({ + state: { + accountsAssets: {}, + assetsMetadata: mockGetMetadataReturnValue.assets, + allIgnoredAssets: { + [mockSolanaAccount.id]: [ + ignoredAsset1, + ignoredAsset2, + ignoredAsset3, + ], + }, + } as MultichainAssetsControllerState, + }); + + // Only add two of the three ignored assets + await controller.addAssets( + [ignoredAsset1, ignoredAsset2], + mockSolanaAccount.id, + ); + + // Should have added the two assets + expect( + controller.state.accountsAssets[mockSolanaAccount.id], + ).toStrictEqual([ignoredAsset1, ignoredAsset2]); + + // Should have only the third asset remaining in ignored list + expect( + controller.state.allIgnoredAssets[mockSolanaAccount.id], + ).toStrictEqual([ignoredAsset3]); + }); + }); + + describe('asset detection with ignored assets', () => { + it('should filter out ignored assets when account assets are updated', async () => { + const ignoredAsset = 'solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1/slip44:501'; + const activeAsset = + 'solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1/token:Gh9ZwEmdLJ8DscKNTkTqPbNwLNNBjuSzaG9Vp2KGtKJr'; + + const { controller, messenger } = setupController({ + state: { + accountsAssets: {}, + assetsMetadata: mockGetMetadataReturnValue.assets, + allIgnoredAssets: { + [mockSolanaAccount.id]: [ignoredAsset], + }, + } as MultichainAssetsControllerState, + }); + + // Simulate asset list update that includes both ignored and new assets + messenger.publish('AccountsController:accountAssetListUpdated', { + assets: { + [mockSolanaAccount.id]: { + added: [ignoredAsset, activeAsset], + removed: [], + }, + }, + }); + + // Wait for async processing (including Blockaid scan) + await jestAdvanceTime({ duration: 1 }); + + // Only the non-ignored asset should be added + expect( + controller.state.accountsAssets[mockSolanaAccount.id], + ).toStrictEqual([activeAsset]); + + // Ignored asset should remain in ignored list + expect( + controller.state.allIgnoredAssets[mockSolanaAccount.id], + ).toStrictEqual([ignoredAsset]); + }); + + it('should keep ignored assets filtered out during automatic detection', async () => { + const ignoredAsset = 'solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1/slip44:501'; + + const { controller, messenger } = setupController({ + state: { + accountsAssets: {}, + assetsMetadata: mockGetMetadataReturnValue.assets, + allIgnoredAssets: { + [mockSolanaAccount.id]: [ignoredAsset], + }, + } as MultichainAssetsControllerState, + }); + + // Simulate automatic asset detection trying to re-add ignored asset + messenger.publish('AccountsController:accountAssetListUpdated', { + assets: { + [mockSolanaAccount.id]: { + added: [ignoredAsset], + removed: [], + }, + }, + }); + + await jestAdvanceTime({ duration: 1 }); + + // Ignored asset should remain filtered out and stay in ignored list + expect( + controller.state.accountsAssets[mockSolanaAccount.id], + ).toBeUndefined(); + expect( + controller.state.allIgnoredAssets[mockSolanaAccount.id], + ).toStrictEqual([ignoredAsset]); + }); + + it('should add all assets when new account is added (no pre-existing ignored assets)', async () => { + const asset1 = 'solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1/slip44:501'; + const asset2 = + 'solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1/token:Gh9ZwEmdLJ8DscKNTkTqPbNwLNNBjuSzaG9Vp2KGtKJr'; + + const { controller, messenger } = setupController({ + state: { + accountsAssets: {}, + assetsMetadata: mockGetMetadataReturnValue.assets, + allIgnoredAssets: {}, + } as MultichainAssetsControllerState, + mocks: { + handleRequestReturnValue: [asset1, asset2], + }, + }); + + // Simulate account being added + messenger.publish('AccountsController:accountAdded', mockSolanaAccount); + + await jestAdvanceTime({ duration: 1 }); + + // All assets should be added to active list (no ignored assets for new account) + expect( + controller.state.accountsAssets[mockSolanaAccount.id], + ).toStrictEqual([asset1, asset2]); + + // No ignored assets for new account + expect( + controller.state.allIgnoredAssets[mockSolanaAccount.id], + ).toBeUndefined(); + }); + }); + + describe('account removal with ignored assets', () => { + it('should clean up ignored assets when account is removed', async () => { + const { controller, messenger } = setupController({ + state: { + accountsAssets: { + [mockSolanaAccount.id]: [ + 'solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1/slip44:501', + ], + }, + assetsMetadata: mockGetMetadataReturnValue.assets, + allIgnoredAssets: { + [mockSolanaAccount.id]: [ + 'solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1/token:Gh9ZwEmdLJ8DscKNTkTqPbNwLNNBjuSzaG9Vp2KGtKJr', + ], + }, + } as MultichainAssetsControllerState, + }); + + // Simulate account removal + messenger.publish( + 'AccountsController:accountRemoved', + mockSolanaAccount.id, + ); + + // Wait for async processing + await jestAdvanceTime({ duration: 0 }); + + expect( + controller.state.accountsAssets[mockSolanaAccount.id], + ).toBeUndefined(); + expect( + controller.state.allIgnoredAssets[mockSolanaAccount.id], + ).toBeUndefined(); + }); + }); + + describe('Blockaid token filtering', () => { + it('filters out malicious tokens when account is added', async () => { + const benignToken = + 'solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1/token:Gh9ZwEmdLJ8DscKNTkTqPbNwLNNBjuSzaG9Vp2KGtKJr'; + const maliciousToken = + 'solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1/token:MaliciousTokenAddress'; + const nativeToken = 'solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1/slip44:501'; + + const { controller, messenger, mockBulkScanTokens } = setupController({ + mocks: { + handleRequestReturnValue: [nativeToken, benignToken, maliciousToken], + }, + }); + + mockBulkScanTokens.mockResolvedValue({ + Gh9ZwEmdLJ8DscKNTkTqPbNwLNNBjuSzaG9Vp2KGtKJr: { + result_type: TokenScanResultType.Benign, + chain: 'solana', + address: 'Gh9ZwEmdLJ8DscKNTkTqPbNwLNNBjuSzaG9Vp2KGtKJr', + }, + MaliciousTokenAddress: { + result_type: TokenScanResultType.Malicious, + chain: 'solana', + address: 'MaliciousTokenAddress', + }, + }); + + 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 + // Malicious 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', + 'MaliciousTokenAddress', + ], + }); + }); + + 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('adds 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 }); + + expect(controller.state.accountsAssets[mockAccountId]).toStrictEqual([ + token, + ]); + }); + + it('adds tokens when bulkScanTokens returns empty (fail open - no result means not rejected)', 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 }); + + // With fail-open blacklist approach, no result means not rejected + 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('adds 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 }); + + // With fail-open blacklist approach, tokens without results are not rejected + expect(controller.state.accountsAssets[mockAccountId]).toStrictEqual([ + knownToken, + unknownToken, + ]); + }); + + it('keeps Warning and Spam tokens (only Malicious is filtered)', async () => { + const mockAccountId = 'account1'; + const warningToken = + 'solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1/token:WarningAddr'; + const spamToken = + 'solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1/token:SpamAddr'; + + const { controller, messenger, mockBulkScanTokens } = setupController({ + state: { + accountsAssets: { [mockAccountId]: [] }, + assetsMetadata: {}, + allIgnoredAssets: {}, + } as MultichainAssetsControllerState, + }); + + mockBulkScanTokens.mockResolvedValue({ + WarningAddr: { + result_type: TokenScanResultType.Warning, + chain: 'solana', + address: 'WarningAddr', + }, + SpamAddr: { + result_type: TokenScanResultType.Spam, + chain: 'solana', + address: 'SpamAddr', + }, + }); + + messenger.publish('AccountsController:accountAssetListUpdated', { + assets: { + [mockAccountId]: { + added: [warningToken, spamToken], + removed: [], + }, + }, + }); + + await jestAdvanceTime({ duration: 1 }); + + expect(controller.state.accountsAssets[mockAccountId]).toStrictEqual([ + warningToken, + spamToken, + ]); + }); + + 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 malicious 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 malicious + if (addr === 'Token099' || addr === 'Token149') { + results[addr] = { + result_type: TokenScanResultType.Malicious, + 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 as CaipAssetType[], 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 malicious 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 tokens from batches that fail (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 malicious + if (callCount === 1) { + const results: BulkTokenScanResponse = {}; + for (const addr of request.tokens) { + results[addr] = { + result_type: + addr === 'Token099' + ? TokenScanResultType.Malicious + : TokenScanResultType.Benign, + chain: 'solana', + address: addr, + }; + } + return Promise.resolve(results); + } + // Second batch fails — its tokens are allowed through (fail open) + return Promise.reject(new Error('API timeout')); + }); + + messenger.publish('AccountsController:accountAssetListUpdated', { + assets: { + [mockAccountId]: { added: tokens as CaipAssetType[], 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 be added (fail open) + for (let i = 100; i < 120; i++) { + const tokenCaip = `solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1/token:Token${String(i).padStart(3, '0')}`; + expect(storedAssets).toContain(tokenCaip); + } + + // 99 from batch 1 (excluding Token099) + 20 from batch 2 = 119 total + expect(storedAssets).toHaveLength(119); + }); + + it('periodic rescan ignores SPL tokens that Blockaid later marks malicious', async () => { + const mockAccountId = 'account1'; + const token = + 'solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1/token:TurnsMalicious'; + + const { controller, mockBulkScanTokens } = setupController({ + blockaidTokenRescanInterval: 60_000, + state: { + accountsAssets: { [mockAccountId]: [token] }, + assetsMetadata: {}, + allIgnoredAssets: {}, + } as MultichainAssetsControllerState, + }); + + mockBulkScanTokens.mockResolvedValue({ + TurnsMalicious: { + result_type: TokenScanResultType.Malicious, + chain: 'solana', + address: 'TurnsMalicious', + }, + }); + + await jestAdvanceTime({ duration: 1 }); + + expect(controller.state.accountsAssets[mockAccountId]).toStrictEqual([]); + expect(controller.state.allIgnoredAssets[mockAccountId]).toStrictEqual([ + token, + ]); + + controller.stopAllPolling(); + }); + + it('periodic rescan leaves tokens unchanged when bulk scan batch rejects', async () => { + const mockAccountId = 'account1'; + const token = 'solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1/token:SomeAddr'; + + const { controller, mockBulkScanTokens } = setupController({ + blockaidTokenRescanInterval: 60_000, + state: { + accountsAssets: { [mockAccountId]: [token] }, + assetsMetadata: {}, + allIgnoredAssets: {}, + } as MultichainAssetsControllerState, + }); + + mockBulkScanTokens.mockRejectedValue(new Error('network error')); + + await jestAdvanceTime({ duration: 1 }); + + expect(controller.state.accountsAssets[mockAccountId]).toStrictEqual([ + token, + ]); + + controller.stopAllPolling(); + }); + + it('periodic rescan skips Blockaid when account only holds native slip44 assets', async () => { + const mockAccountId = 'account1'; + const native = 'solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1/slip44:501'; + + const { controller, mockBulkScanTokens } = setupController({ + blockaidTokenRescanInterval: 60_000, + state: { + accountsAssets: { [mockAccountId]: [native] }, + assetsMetadata: {}, + allIgnoredAssets: {}, + } as MultichainAssetsControllerState, + }); + + await jestAdvanceTime({ duration: 1 }); + + expect(mockBulkScanTokens).not.toHaveBeenCalled(); + expect(controller.state.accountsAssets[mockAccountId]).toStrictEqual([ + native, + ]); + + controller.stopAllPolling(); + }); + + it('periodic rescan skips entries that are not CAIP asset type strings', async () => { + const mockAccountId = 'account1'; + const notCaip = 'clearly-not-caip' as CaipAssetType; + + const { controller, mockBulkScanTokens } = setupController({ + blockaidTokenRescanInterval: 60_000, + state: { + accountsAssets: { [mockAccountId]: [notCaip] }, + assetsMetadata: {}, + allIgnoredAssets: {}, + } as MultichainAssetsControllerState, + }); + + await jestAdvanceTime({ duration: 1 }); + + expect(mockBulkScanTokens).not.toHaveBeenCalled(); + + controller.stopAllPolling(); + }); + + it('does not publish accountAssetListUpdated when periodic rescan finds no malicious tokens', async () => { + const mockAccountId = 'account1'; + const token = 'solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1/token:StillBenign'; + + const { controller, mockBulkScanTokens } = setupController({ + blockaidTokenRescanInterval: 60_000, + state: { + accountsAssets: { [mockAccountId]: [token] }, + assetsMetadata: {}, + allIgnoredAssets: {}, + } as MultichainAssetsControllerState, + }); + + mockBulkScanTokens.mockResolvedValue({ + StillBenign: { + result_type: TokenScanResultType.Benign, + chain: 'solana', + address: 'StillBenign', + }, + }); + + const publishSpy = jest.spyOn( + ( + controller as unknown as { + messenger: MultichainAssetsControllerMessenger; + } + ).messenger, + 'publish', + ); + + await jestAdvanceTime({ duration: 1 }); + + expect( + publishSpy.mock.calls.filter( + (call) => + call[0] === 'MultichainAssetsController:accountAssetListUpdated', + ), + ).toHaveLength(0); + + publishSpy.mockRestore(); + controller.stopAllPolling(); + }); + }); + + describe('isDeprecated', () => { + const deprecatedAccountId = mockSolanaAccount.id; + + const initialState: MultichainAssetsControllerState = { + accountsAssets: { + [deprecatedAccountId]: [ + 'solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1/slip44:501', + ], + }, + assetsMetadata: { + 'solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1/slip44:501': { + name: 'Solana', + symbol: 'SOL', + fungible: true, + iconUrl: 'url1', + units: [{ name: 'Solana', symbol: 'SOL', decimals: 9 }], + }, + }, + allIgnoredAssets: { + [deprecatedAccountId]: [ + 'solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1/token:Spam', + ], + }, + }; + + const emptyState: MultichainAssetsControllerState = { + accountsAssets: {}, + assetsMetadata: {}, + allIgnoredAssets: {}, + }; + + it('clears all persisted state at construction when isDeprecated() returns true', () => { + const { controller } = setupController({ + state: initialState, + isDeprecated: () => true, + }); + + expect(controller.state).toStrictEqual(emptyState); + }); + + it('preserves persisted state at construction when isDeprecated() returns false', () => { + const { controller } = setupController({ + state: initialState, + isDeprecated: () => false, + }); + + expect(controller.state).toStrictEqual(initialState); + }); + + it('does not throw at construction when isDeprecated() is true and state is already empty', () => { + const { controller } = setupController({ + isDeprecated: () => true, + }); + + expect(controller.state).toStrictEqual(emptyState); + }); + + it('does not issue Snap requests at construction when isDeprecated() returns true', () => { + const { mockSnapHandleRequest } = setupController({ + state: initialState, + blockaidTokenRescanInterval: 60_000, + isDeprecated: () => true, + }); + + expect(mockSnapHandleRequest).not.toHaveBeenCalled(); + }); + + it('does not add assets and clears stale state when isDeprecated toggles to true at runtime via addAssets', async () => { + let deprecated = false; + const { controller, mockSnapHandleRequest } = setupController({ + state: initialState, + isDeprecated: () => deprecated, + }); + + expect(controller.state).toStrictEqual(initialState); + + deprecated = true; + mockSnapHandleRequest.mockClear(); + + const result = await controller.addAssets( + ['solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1/token:NewToken'], + deprecatedAccountId, + ); + + expect(result).toStrictEqual([]); + expect(controller.state).toStrictEqual(emptyState); + expect(mockSnapHandleRequest).not.toHaveBeenCalled(); + }); + + it('does not ignore assets and clears stale state when isDeprecated toggles to true at runtime via ignoreAssets', () => { + let deprecated = false; + const { controller } = setupController({ + state: initialState, + isDeprecated: () => deprecated, + }); + + expect(controller.state).toStrictEqual(initialState); + + deprecated = true; + + controller.ignoreAssets( + ['solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1/slip44:501'], + deprecatedAccountId, + ); + + expect(controller.state).toStrictEqual(emptyState); + }); + + it('clears stale state and skips Snap requests on "AccountsController:accountAdded" when isDeprecated toggles to true at runtime', async () => { + let deprecated = false; + const { controller, messenger, mockSnapHandleRequest } = setupController({ + state: initialState, + isDeprecated: () => deprecated, + }); + + expect(controller.state).toStrictEqual(initialState); + + deprecated = true; + mockSnapHandleRequest.mockClear(); + + messenger.publish( + 'AccountsController:accountAdded', + mockSolanaAccount as unknown as InternalAccount, + ); + + await jestAdvanceTime({ duration: 1 }); + + expect(controller.state).toStrictEqual(emptyState); + expect(mockSnapHandleRequest).not.toHaveBeenCalled(); + }); + + it('clears stale state on "AccountsController:accountRemoved" when isDeprecated toggles to true at runtime', async () => { + let deprecated = false; + const { controller, messenger } = setupController({ + state: initialState, + isDeprecated: () => deprecated, + }); + + expect(controller.state).toStrictEqual(initialState); + + deprecated = true; + + messenger.publish( + 'AccountsController:accountRemoved', + deprecatedAccountId, + ); + + await jestAdvanceTime({ duration: 1 }); + + expect(controller.state).toStrictEqual(emptyState); + }); + + it('clears stale state and skips Snap requests on "AccountsController:accountAssetListUpdated" when isDeprecated toggles to true at runtime', async () => { + let deprecated = false; + const { controller, messenger, mockSnapHandleRequest } = setupController({ + state: initialState, + isDeprecated: () => deprecated, + }); + + expect(controller.state).toStrictEqual(initialState); + + deprecated = true; + mockSnapHandleRequest.mockClear(); + + messenger.publish('AccountsController:accountAssetListUpdated', { + assets: { + [deprecatedAccountId]: { + added: ['solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1/token:NewToken'], + removed: [], + }, + }, + }); + + await jestAdvanceTime({ duration: 1 }); + + expect(controller.state).toStrictEqual(emptyState); + expect(mockSnapHandleRequest).not.toHaveBeenCalled(); + }); + + it('does not run the periodic Blockaid rescan when isDeprecated() returns true', async () => { + const { controller, mockBulkScanTokens } = setupController({ + blockaidTokenRescanInterval: 60_000, + state: initialState, + isDeprecated: () => true, + }); + + await jestAdvanceTime({ duration: 1 }); + + expect(mockBulkScanTokens).not.toHaveBeenCalled(); + expect(controller.state).toStrictEqual(emptyState); + }); + }); + + describe('metadata', () => { + it('includes expected state in debug snapshots', () => { + const { controller } = setupController(); + + expect( + deriveStateFromMetadata( + controller.state, + controller.metadata, + 'includeInDebugSnapshot', + ), + ).toMatchInlineSnapshot(`{}`); + }); + + it('includes expected state in state logs', () => { + const { controller } = setupController(); + + expect( + deriveStateFromMetadata( + controller.state, + controller.metadata, + 'includeInStateLogs', + ), + ).toMatchInlineSnapshot(`{}`); + }); + + it('persists expected state', () => { + const { controller } = setupController(); + + expect( + deriveStateFromMetadata( + controller.state, + controller.metadata, + 'persist', + ), + ).toMatchInlineSnapshot(` + { + "accountsAssets": {}, + "allIgnoredAssets": {}, + "assetsMetadata": {}, + } + `); + }); + + it('exposes expected state to UI', () => { + const { controller } = setupController(); + + expect( + deriveStateFromMetadata( + controller.state, + controller.metadata, + 'usedInUi', + ), + ).toMatchInlineSnapshot(` + { + "accountsAssets": {}, + "allIgnoredAssets": {}, + "assetsMetadata": {}, + } + `); + }); + }); +}); diff --git a/packages/assets-controllers/src/MultichainAssetsController/MultichainAssetsController.ts b/packages/assets-controllers/src/MultichainAssetsController/MultichainAssetsController.ts new file mode 100644 index 00000000000..5b4909715b1 --- /dev/null +++ b/packages/assets-controllers/src/MultichainAssetsController/MultichainAssetsController.ts @@ -0,0 +1,1045 @@ +import type { + AccountsControllerAccountAddedEvent, + AccountsControllerAccountAssetListUpdatedEvent, + AccountsControllerAccountRemovedEvent, + AccountsControllerListMultichainAccountsAction, +} from '@metamask/accounts-controller'; +import type { + ControllerGetStateAction, + ControllerStateChangeEvent, + StateMetadata, +} from '@metamask/base-controller'; +import { isEvmAccountType } from '@metamask/keyring-api'; +import type { + AccountAssetListUpdatedEventPayload, + CaipAssetType, + CaipAssetTypeOrId, +} from '@metamask/keyring-api'; +import type { InternalAccount } from '@metamask/keyring-internal-api'; +import { KeyringClient } from '@metamask/keyring-snap-client'; +import type { Messenger } from '@metamask/messenger'; +import type { + GetPermissions, + PermissionConstraint, + SubjectPermissions, +} from '@metamask/permission-controller'; +import type { + BulkTokenScanResponse, + PhishingControllerBulkScanTokensAction, +} from '@metamask/phishing-controller'; +import { TokenScanResultType } from '@metamask/phishing-controller'; +import { StaticIntervalPollingController } from '@metamask/polling-controller'; +import type { + SnapControllerGetRunnableSnapsAction, + SnapControllerHandleRequestAction, +} from '@metamask/snaps-controllers'; +import type { FungibleAssetMetadata, Snap, SnapId } from '@metamask/snaps-sdk'; +import { HandlerType } from '@metamask/snaps-utils'; +import { isCaipAssetType, parseCaipAssetType } from '@metamask/utils'; +import type { CaipChainId } from '@metamask/utils'; +import type { Json, JsonRpcRequest } from '@metamask/utils'; +import type { MutexInterface } from 'async-mutex'; +import { Mutex } from 'async-mutex'; + +import type { MultichainAssetsControllerMethodActions } from './MultichainAssetsController-method-action-types.js'; +import { getChainIdsCaveat } from './utils.js'; + +const controllerName = 'MultichainAssetsController'; + +export type MultichainAssetsControllerState = { + assetsMetadata: { + [asset: CaipAssetType]: FungibleAssetMetadata; + }; + accountsAssets: { [account: string]: CaipAssetType[] }; + allIgnoredAssets: { [account: string]: CaipAssetType[] }; +}; + +// Represents the response of the asset snap's onAssetLookup handler +export type AssetMetadataResponse = { + assets: { + [asset: CaipAssetType]: FungibleAssetMetadata; + }; +}; + +export type MultichainAssetsControllerAccountAssetListUpdatedEvent = { + type: `${typeof controllerName}:accountAssetListUpdated`; + payload: AccountsControllerAccountAssetListUpdatedEvent['payload']; +}; + +/** + * Constructs the default {@link MultichainAssetsController} state. This allows + * consumers to provide a partial state object when initializing the controller + * and also helps in constructing complete state objects for this controller in + * tests. + * + * @returns The default {@link MultichainAssetsController} state. + */ +export function getDefaultMultichainAssetsControllerState(): MultichainAssetsControllerState { + return { accountsAssets: {}, assetsMetadata: {}, allIgnoredAssets: {} }; +} + +/** + * Returns the state of the {@link MultichainAssetsController}. + */ +export type MultichainAssetsControllerGetStateAction = ControllerGetStateAction< + typeof controllerName, + MultichainAssetsControllerState +>; + +/** + * Event emitted when the state of the {@link MultichainAssetsController} changes. + */ +export type MultichainAssetsControllerStateChangeEvent = + ControllerStateChangeEvent< + typeof controllerName, + MultichainAssetsControllerState + >; + +/** + * Actions exposed by the {@link MultichainAssetsController}. + */ +export type MultichainAssetsControllerActions = + | MultichainAssetsControllerGetStateAction + | MultichainAssetsControllerMethodActions; + +/** + * Events emitted by {@link MultichainAssetsController}. + */ +export type MultichainAssetsControllerEvents = + | MultichainAssetsControllerStateChangeEvent + | MultichainAssetsControllerAccountAssetListUpdatedEvent; + +/** + * A function executed within a mutually exclusive lock, with + * a mutex releaser in its option bag. + * + * @param releaseLock - A function to release the lock. + */ +type MutuallyExclusiveCallback = ({ + releaseLock, +}: { + releaseLock: MutexInterface.Releaser; +}) => Promise; + +/** + * Actions that this controller is allowed to call. + */ +type AllowedActions = + | SnapControllerGetRunnableSnapsAction + | SnapControllerHandleRequestAction + | GetPermissions + | AccountsControllerListMultichainAccountsAction + | PhishingControllerBulkScanTokensAction; + +/** + * Events that this controller is allowed to subscribe. + */ +type AllowedEvents = + | AccountsControllerAccountAddedEvent + | AccountsControllerAccountRemovedEvent + | AccountsControllerAccountAssetListUpdatedEvent; + +/** + * Messenger type for the MultichainAssetsController. + */ +export type MultichainAssetsControllerMessenger = Messenger< + typeof controllerName, + MultichainAssetsControllerActions | AllowedActions, + MultichainAssetsControllerEvents | AllowedEvents +>; + +/** + * {@link MultichainAssetsController}'s metadata. + * + * This allows us to choose if fields of the state should be persisted or not + * using the `persist` flag; and if they can be sent to Sentry or not, using + * the `anonymous` flag. + */ +const assetsControllerMetadata: StateMetadata = + { + assetsMetadata: { + includeInStateLogs: false, + persist: true, + includeInDebugSnapshot: false, + usedInUi: true, + }, + accountsAssets: { + includeInStateLogs: false, + persist: true, + includeInDebugSnapshot: false, + usedInUi: true, + }, + allIgnoredAssets: { + includeInStateLogs: false, + persist: true, + includeInDebugSnapshot: false, + usedInUi: true, + }, + }; + +const MESSENGER_EXPOSED_METHODS = [ + 'getAssetMetadata', + 'ignoreAssets', + 'addAssets', +] as const; + +/** Phishing API allows at most this many token addresses per bulk scan request. */ +const BLOCKAID_BULK_TOKEN_SCAN_BATCH_SIZE = 100; + +/** + * Default interval for re-scanning stored SPL (`token:`) assets with Blockaid. + * Once per day limits API load while still catching tokens reclassified after add. + */ +const DEFAULT_BLOCKAID_TOKEN_RESCAN_INTERVAL_MS = 24 * 60 * 60 * 1000; + +type ChainTokenEntry = { asset: CaipAssetType; address: string }; + +type BulkTokenScanBatchOutcome = + | { + status: 'fulfilled'; + response: BulkTokenScanResponse; + entries: ChainTokenEntry[]; + } + | { status: 'rejected'; entries: ChainTokenEntry[] }; + +export class MultichainAssetsController extends StaticIntervalPollingController()< + typeof controllerName, + MultichainAssetsControllerState, + MultichainAssetsControllerMessenger +> { + // Mapping of CAIP-2 Chain ID to Asset Snaps. + #snaps: Record; + + readonly #controllerOperationMutex = new Mutex(); + + readonly #isDeprecated: () => boolean; + + constructor({ + messenger, + state = {}, + blockaidTokenRescanInterval = DEFAULT_BLOCKAID_TOKEN_RESCAN_INTERVAL_MS, + isDeprecated = (): boolean => false, + }: { + messenger: MultichainAssetsControllerMessenger; + state?: Partial; + /** Blockaid re-scan interval (ms); default daily. `0` disables. */ + blockaidTokenRescanInterval?: number; + isDeprecated?: () => boolean; + }) { + super({ + messenger, + name: controllerName, + metadata: assetsControllerMetadata, + state: { + ...getDefaultMultichainAssetsControllerState(), + ...state, + }, + }); + + this.#snaps = {}; + this.#isDeprecated = isDeprecated; + + if (this.#isDeprecated()) { + this.#enforceDisabledState(); + } else if (blockaidTokenRescanInterval > 0) { + this.setIntervalLength(blockaidTokenRescanInterval); + this.startPolling(null); + } + + this.messenger.subscribe( + 'AccountsController:accountAdded', + // eslint-disable-next-line @typescript-eslint/no-misused-promises + async (account) => await this.#handleOnAccountAddedEvent(account), + ); + this.messenger.subscribe( + 'AccountsController:accountRemoved', + // eslint-disable-next-line @typescript-eslint/no-misused-promises + async (account) => await this.#handleOnAccountRemovedEvent(account), + ); + this.messenger.subscribe( + 'AccountsController:accountAssetListUpdated', + // eslint-disable-next-line @typescript-eslint/no-misused-promises + async (event) => await this.#handleAccountAssetListUpdatedEvent(event), + ); + + messenger.registerMethodActionHandlers(this, MESSENGER_EXPOSED_METHODS); + } + + /** + * Clears all persisted `accountsAssets`, `assetsMetadata`, and + * `allIgnoredAssets` so that no stale asset data remains in state. + */ + #enforceDisabledState(): void { + if ( + Object.keys(this.state.accountsAssets).length === 0 && + Object.keys(this.state.assetsMetadata).length === 0 && + Object.keys(this.state.allIgnoredAssets).length === 0 + ) { + return; + } + this.update((state) => { + state.accountsAssets = {}; + state.assetsMetadata = {}; + state.allIgnoredAssets = {}; + }); + } + + async _executePoll(_input: null): Promise { + if (this.#isDeprecated()) { + this.#enforceDisabledState(); + return; + } + await this.#withControllerLock(async () => { + const assetsByAccount: Record< + string, + { added: CaipAssetType[]; removed: CaipAssetType[] } + > = {}; + + for (const [accountId, assets] of Object.entries( + this.state.accountsAssets, + )) { + const splTokens = assets.filter((asset) => { + if (!isCaipAssetType(asset)) { + return false; + } + try { + return parseCaipAssetType(asset).assetNamespace === 'token'; + } catch { + return false; + } + }); + + if (splTokens.length === 0) { + continue; + } + + const malicious = await this.#findMaliciousTokensAmong(splTokens); + if (malicious.length > 0) { + this.ignoreAssets(malicious, accountId); + assetsByAccount[accountId] = { + added: [], + removed: malicious, + }; + } + } + + if (Object.keys(assetsByAccount).length > 0) { + this.messenger.publish(`${controllerName}:accountAssetListUpdated`, { + assets: assetsByAccount, + }); + } + }); + } + + // eslint-disable-next-line @typescript-eslint/explicit-function-return-type + async #handleAccountAssetListUpdatedEvent( + event: AccountAssetListUpdatedEventPayload, + ) { + if (this.#isDeprecated()) { + this.#enforceDisabledState(); + return; + } + await this.#withControllerLock(async () => + this.#handleAccountAssetListUpdated(event), + ); + } + + // eslint-disable-next-line @typescript-eslint/explicit-function-return-type + async #handleOnAccountAddedEvent(account: InternalAccount) { + if (this.#isDeprecated()) { + this.#enforceDisabledState(); + return; + } + await this.#withControllerLock(async () => + this.#handleOnAccountAdded(account), + ); + } + + /** + * Returns the metadata for the given asset + * + * @param asset - The asset to get metadata for + * @returns The metadata for the asset or undefined if not found. + */ + getAssetMetadata(asset: CaipAssetType): FungibleAssetMetadata | undefined { + return this.state.assetsMetadata[asset]; + } + + /** + * Ignores a batch of assets for a specific account. + * + * @param assetsToIgnore - Array of asset IDs to ignore. + * @param accountId - The account ID to ignore assets for. + */ + ignoreAssets(assetsToIgnore: CaipAssetType[], accountId: string): void { + if (this.#isDeprecated()) { + this.#enforceDisabledState(); + return; + } + this.update((state) => { + if (state.accountsAssets[accountId]) { + state.accountsAssets[accountId] = state.accountsAssets[ + accountId + ].filter((asset) => !assetsToIgnore.includes(asset)); + } + + if (!state.allIgnoredAssets[accountId]) { + state.allIgnoredAssets[accountId] = []; + } + + const newIgnoredAssets = assetsToIgnore.filter( + (asset) => !state.allIgnoredAssets[accountId].includes(asset), + ); + state.allIgnoredAssets[accountId].push(...newIgnoredAssets); + }); + } + + /** + * Adds multiple assets to the stored asset list for a specific account. + * All assets must belong to the same chain. + * + * @param assetIds - Array of CAIP asset IDs to add (must be from same chain). + * @param accountId - The account ID to add the assets to. + * @returns The updated asset list for the account. + * @throws Error if assets are from different chains. + */ + async addAssets( + assetIds: CaipAssetType[], + accountId: string, + ): Promise { + if (this.#isDeprecated()) { + this.#enforceDisabledState(); + return []; + } + + if (assetIds.length === 0) { + return this.state.accountsAssets[accountId] || []; + } + + // Validate that all assets are from the same chain + const chainIds = new Set( + assetIds.map((assetId) => parseCaipAssetType(assetId).chainId), + ); + if (chainIds.size > 1) { + throw new Error( + `All assets must belong to the same chain. Found assets from chains: ${Array.from(chainIds).join(', ')}`, + ); + } + + return this.#withControllerLock(async () => { + // Refresh metadata for all assets + await this.#refreshAssetsMetadata(assetIds); + + const addedAssets: CaipAssetType[] = []; + + this.update((state) => { + // Initialize account assets if it doesn't exist + if (!state.accountsAssets[accountId]) { + state.accountsAssets[accountId] = []; + } + + // Add assets if they don't already exist + for (const assetId of assetIds) { + if (!state.accountsAssets[accountId].includes(assetId)) { + state.accountsAssets[accountId].push(assetId); + addedAssets.push(assetId); + } + } + + // Remove from ignored list if they exist there (inline logic like EVM) + if (state.allIgnoredAssets[accountId]) { + state.allIgnoredAssets[accountId] = state.allIgnoredAssets[ + accountId + ].filter((asset) => !assetIds.includes(asset)); + + // Clean up empty arrays + if (state.allIgnoredAssets[accountId].length === 0) { + delete state.allIgnoredAssets[accountId]; + } + } + }); + + // Publish event to notify other controllers (balances, rates) about the new assets + if (addedAssets.length > 0) { + this.messenger.publish(`${controllerName}:accountAssetListUpdated`, { + assets: { + [accountId]: { + added: addedAssets, + removed: [], + }, + }, + }); + } + + return this.state.accountsAssets[accountId] || []; + }); + } + + /** + * Checks if an asset is ignored for a specific account. + * + * @param asset - The asset ID to check. + * @param accountId - The account ID to check for. + * @returns True if the asset is ignored, false otherwise. + */ + #isAssetIgnored(asset: CaipAssetType, accountId: string): boolean { + return this.state.allIgnoredAssets[accountId]?.includes(asset) ?? false; + } + + /** + * Function to update the assets list for an account + * + * @param event - The list of assets to update + */ + // eslint-disable-next-line @typescript-eslint/explicit-function-return-type + async #handleAccountAssetListUpdated( + event: AccountAssetListUpdatedEventPayload, + ) { + this.#assertControllerMutexIsLocked(); + + const assetsForMetadataRefresh = new Set([]); + const accountsAndAssetsToUpdate: AccountAssetListUpdatedEventPayload['assets'] = + {}; + for (const [accountId, { added, removed }] of Object.entries( + event.assets, + )) { + if (added.length > 0 || removed.length > 0) { + const existing = this.state.accountsAssets[accountId] || []; + + // 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 preFilteredToBeAddedAssets = added.filter( + (asset) => + !existing.includes(asset) && + isCaipAssetType(asset) && + !this.#isAssetIgnored(asset, accountId), + ); + + // Filter out tokens that cannot be verified or are flagged malicious + const filteredToBeAddedAssets = + await this.#filterBlockaidSpamTokensOnAdd(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), + ); + + if ( + filteredToBeAddedAssets.length > 0 || + filteredToBeRemovedAssets.length > 0 + ) { + accountsAndAssetsToUpdate[accountId] = { + added: filteredToBeAddedAssets, + removed: filteredToBeRemovedAssets, + }; + } + + for (const asset of existing) { + assetsForMetadataRefresh.add(asset); + } + for (const asset of filteredToBeAddedAssets) { + assetsForMetadataRefresh.add(asset); + } + for (const asset of filteredToBeRemovedAssets) { + assetsForMetadataRefresh.delete(asset); + } + } + } + + this.update((state) => { + for (const [accountId, { added, removed }] of Object.entries( + accountsAndAssetsToUpdate, + )) { + const assets = new Set([ + ...(state.accountsAssets[accountId] || []), + ...added, + ]); + for (const asset of removed) { + assets.delete(asset); + } + + state.accountsAssets[accountId] = Array.from(assets); + } + }); + + // Trigger fetching metadata for new assets + await this.#refreshAssetsMetadata(Array.from(assetsForMetadataRefresh)); + + this.messenger.publish(`${controllerName}:accountAssetListUpdated`, { + assets: accountsAndAssetsToUpdate, + }); + } + + /** + * Checks for non-EVM accounts. + * + * @param account - The new account to be checked. + * @returns True if the account is a non-EVM account, false otherwise. + */ + #isNonEvmAccount(account: InternalAccount): boolean { + return ( + !isEvmAccountType(account.type) && + // Non-EVM accounts are backed by a Snap for now + account.metadata.snap !== undefined + ); + } + + /** + * Handles changes when a new account has been added. + * + * @param account - The new account being added. + */ + async #handleOnAccountAdded(account: InternalAccount): Promise { + if (!this.#isNonEvmAccount(account)) { + // Nothing to do here for EVM accounts + return; + } + this.#assertControllerMutexIsLocked(); + + // Get assets list + if (account.metadata.snap) { + const allAssets = await this.#getAssetsList( + account.id, + account.metadata.snap.id, + ); + const caipAssets = allAssets.filter(isCaipAssetType); + const filteredCaip = + await this.#filterBlockaidSpamTokensOnAdd(caipAssets); + const filteredCaipSet = new Set(filteredCaip); + const assets = allAssets.filter( + (asset) => !isCaipAssetType(asset) || filteredCaipSet.has(asset), + ); + await this.#refreshAssetsMetadata(assets); + this.update((state) => { + state.accountsAssets[account.id] = assets; + }); + this.messenger.publish(`${controllerName}:accountAssetListUpdated`, { + assets: { + [account.id]: { + added: assets, + removed: [], + }, + }, + }); + } + } + + /** + * Handles changes when a new account has been removed. + * + * @param accountId - The new account id being removed. + */ + async #handleOnAccountRemovedEvent(accountId: string): Promise { + if (this.#isDeprecated()) { + this.#enforceDisabledState(); + return; + } + this.update((state) => { + if (state.accountsAssets[accountId]) { + delete state.accountsAssets[accountId]; + } + if (state.allIgnoredAssets[accountId]) { + delete state.allIgnoredAssets[accountId]; + } + // TODO: We are not deleting the assetsMetadata because we will soon make this controller extends StaticIntervalPollingController + // and update all assetsMetadata once a day. + }); + } + + /** + * Refreshes the assets snaps and metadata for the given list of assets + * + * @param assets - The assets to refresh + */ + // eslint-disable-next-line @typescript-eslint/explicit-function-return-type + async #refreshAssetsMetadata(assets: CaipAssetType[]) { + this.#assertControllerMutexIsLocked(); + + const assetsWithoutMetadata: CaipAssetType[] = assets.filter( + (asset) => !this.state.assetsMetadata[asset], + ); + + // Call the snap to get the metadata + if (assetsWithoutMetadata.length > 0) { + // Check if for every asset in assetsWithoutMetadata there is a snap in snaps by chainId else call getAssetSnaps + if ( + !assetsWithoutMetadata.every((asset: CaipAssetType) => { + const { chainId } = parseCaipAssetType(asset); + return Boolean(this.#getAssetSnapFor(chainId)); + }) + ) { + this.#snaps = this.#getAssetSnaps(); + } + await this.#updateAssetsMetadata(assetsWithoutMetadata); + } + } + + /** + * Updates the assets metadata for the given list of assets + * + * @param assets - The assets to update + */ + // eslint-disable-next-line @typescript-eslint/explicit-function-return-type + async #updateAssetsMetadata(assets: CaipAssetType[]) { + // Creates a mapping of scope to their respective assets list. + const assetsByScope: Record = {}; + for (const asset of assets) { + const { chainId } = parseCaipAssetType(asset); + if (!assetsByScope[chainId]) { + assetsByScope[chainId] = []; + } + assetsByScope[chainId].push(asset); + } + + let newMetadata: Record = {}; + for (const chainId of Object.keys(assetsByScope) as CaipChainId[]) { + const assetsForChain = assetsByScope[chainId]; + // Now fetch metadata from the associated asset Snaps: + const snap = this.#getAssetSnapFor(chainId); + if (snap) { + const metadata = await this.#getAssetsMetadataFrom( + assetsForChain, + snap.id, + ); + newMetadata = { + ...newMetadata, + ...(metadata?.assets ?? {}), + }; + } + } + this.update((state) => { + state.assetsMetadata = { + ...this.state.assetsMetadata, + ...newMetadata, + }; + }); + } + + /** + * Creates a mapping of CAIP-2 Chain ID to Asset Snaps. + * + * @returns A mapping of CAIP-2 Chain ID to Asset Snaps. + */ + #getAssetSnaps(): Record { + const snaps: Record = {}; + const allSnaps = this.#getAllSnaps(); + const allPermissions = allSnaps.map((snap) => + this.#getSnapsPermissions(snap.id), + ); + + for (const [index, permission] of allPermissions.entries()) { + let scopes; + for (const singlePermissionConstraint of Object.values(permission)) { + scopes = getChainIdsCaveat(singlePermissionConstraint); + if (!scopes) { + continue; + } + for (const scope of scopes as CaipChainId[]) { + if (!snaps[scope]) { + snaps[scope] = []; + } + snaps[scope].push(allSnaps[index]); + } + } + } + return snaps; + } + + /** + * Returns the first asset snap for the given scope + * + * @param scope - The scope to get the asset snap for + * @returns The asset snap for the given scope + */ + #getAssetSnapFor(scope: CaipChainId): Snap | undefined { + const allSnaps = this.#snaps[scope]; + // Pick only the first one, we ignore the other Snaps if there are multiple candidates for now. + return allSnaps?.[0]; // Will be undefined if there's no Snaps candidate for this scope. + } + + /** + * Returns all the asset snaps + * + * @returns All the asset snaps + */ + #getAllSnaps(): Snap[] { + return this.messenger.call('SnapController:getRunnableSnaps'); + } + + /** + * Returns the permissions for the given origin + * + * @param origin - The origin to get the permissions for + * @returns The permissions for the given origin + */ + #getSnapsPermissions( + origin: string, + ): SubjectPermissions { + return this.messenger.call( + 'PermissionController:getPermissions', + origin, + ) as SubjectPermissions; + } + + /** + * Returns the metadata for the given assets + * + * @param assets - The assets to get metadata for + * @param snapId - The snap ID to get metadata from + * @returns The metadata for the assets + */ + async #getAssetsMetadataFrom( + assets: CaipAssetType[], + snapId: string, + ): Promise { + try { + return (await this.messenger.call('SnapController:handleRequest', { + snapId: snapId as SnapId, + origin: 'metamask', + handler: HandlerType.OnAssetsLookup, + request: { + jsonrpc: '2.0', + method: 'onAssetLookup', + params: { + assets, + }, + }, + })) as Promise; + } catch (error) { + // Ignore + console.error(error); + return undefined; + } + } + + /** + * Groups `token:` CAIP assets by chain namespace for bulk scan. + * + * @param assets - CAIP assets to inspect. + * @returns Map of chain namespace to token entries. + */ + #groupTokenAssetsByChain( + assets: CaipAssetType[], + ): Record { + const tokensByChain: Record = {}; + + for (const asset of assets) { + const { assetNamespace, assetReference, chain } = + parseCaipAssetType(asset); + + if (assetNamespace === 'token') { + const chainName = chain.namespace; + if (!tokensByChain[chainName]) { + tokensByChain[chainName] = []; + } + tokensByChain[chainName].push({ asset, address: assetReference }); + } + } + + return tokensByChain; + } + + async #runBatchedBulkTokenScans( + chainName: string, + tokenEntries: ChainTokenEntry[], + ): Promise { + const batches: ChainTokenEntry[][] = []; + for ( + let i = 0; + i < tokenEntries.length; + i += BLOCKAID_BULK_TOKEN_SCAN_BATCH_SIZE + ) { + batches.push( + tokenEntries.slice(i, i + BLOCKAID_BULK_TOKEN_SCAN_BATCH_SIZE), + ); + } + + const batchResults = await Promise.allSettled( + batches.map((batch) => + this.messenger.call('PhishingController:bulkScanTokens', { + chainId: chainName, + tokens: batch.map((entry) => entry.address), + }), + ), + ); + + return batches.map((entries, index) => { + const result = batchResults[index]; + if (result.status === 'fulfilled') { + return { + status: 'fulfilled' as const, + response: result.value, + entries, + }; + } + return { status: 'rejected' as const, entries }; + }); + } + + /** + * Fail-open Blockaid filter for newly detected `token:` assets (native/other namespaces unchanged). + * + * @param assets - CAIP assets to filter. + * @returns Filtered list, original order preserved. + */ + async #filterBlockaidSpamTokensOnAdd( + assets: CaipAssetType[], + ): Promise { + const tokensByChain = this.#groupTokenAssetsByChain(assets); + + if (Object.keys(tokensByChain).length === 0) { + return [...assets]; + } + + const rejectedAssets = new Set(); + + for (const [chainName, tokenEntries] of Object.entries(tokensByChain)) { + const batchOutcomes = await this.#runBatchedBulkTokenScans( + chainName, + tokenEntries, + ); + + for (const outcome of batchOutcomes) { + if (outcome.status === 'rejected') { + // Fail-open: if API fails, allow all tokens in this batch through + continue; + } + for (const entry of outcome.entries) { + const scanned = outcome.response[entry.address]; + // Reject only if we have a definitive malicious result + if ( + scanned?.result_type && + scanned.result_type === TokenScanResultType.Malicious + ) { + rejectedAssets.add(entry.asset); + } + } + } + } + + return assets.filter((asset) => !rejectedAssets.has(asset)); + } + + /** + * SPL `token:` assets in state that Blockaid marks malicious (failed batches skipped). + * + * @param assets - CAIP `token:` assets to scan. + * @returns Subset marked malicious. + */ + async #findMaliciousTokensAmong( + assets: CaipAssetType[], + ): Promise { + const tokensByChain = this.#groupTokenAssetsByChain(assets); + + const maliciousAssets: CaipAssetType[] = []; + + for (const [chainName, tokenEntries] of Object.entries(tokensByChain)) { + const batchOutcomes = await this.#runBatchedBulkTokenScans( + chainName, + tokenEntries, + ); + + for (const outcome of batchOutcomes) { + if (outcome.status === 'rejected') { + continue; + } + for (const entry of outcome.entries) { + if ( + outcome.response[entry.address]?.result_type === + TokenScanResultType.Malicious + ) { + maliciousAssets.push(entry.asset); + } + } + } + } + + return maliciousAssets; + } + + /** + * Get assets list for an account + * + * @param accountId - AccountId to get assets for + * @param snapId - Snap ID for the account + * @returns list of assets + */ + async #getAssetsList( + accountId: string, + snapId: string, + ): Promise { + return await this.#getClient(snapId).listAccountAssets(accountId); + } + + /** + * Gets a `KeyringClient` for a Snap. + * + * @param snapId - ID of the Snap to get the client for. + * @returns A `KeyringClient` for the Snap. + */ + #getClient(snapId: string): KeyringClient { + return new KeyringClient({ + send: async (request: JsonRpcRequest) => + (await this.messenger.call('SnapController:handleRequest', { + snapId: snapId as SnapId, + origin: 'metamask', + handler: HandlerType.OnKeyringRequest, + request, + })) as Promise, + }); + } + + /** + * Assert that the controller mutex is locked. + * + * @throws If the controller mutex is not locked. + */ + // eslint-disable-next-line @typescript-eslint/explicit-function-return-type + #assertControllerMutexIsLocked() { + if (!this.#controllerOperationMutex.isLocked()) { + throw new Error( + 'MultichainAssetsControllerError - Attempt to update state', + ); + } + } + + /** + * Lock the controller mutex before executing the given function, + * and release it after the function is resolved or after an + * error is thrown. + * + * This wrapper ensures that each mutable operation that interacts with the + * controller and that changes its state is executed in a mutually exclusive way, + * preventing unsafe concurrent access that could lead to unpredictable behavior. + * + * @param callback - The function to execute while the controller mutex is locked. + * @returns The result of the function. + */ + async #withControllerLock( + callback: MutuallyExclusiveCallback, + ): Promise { + return withLock(this.#controllerOperationMutex, callback); + } +} + +/** + * Lock the given mutex before executing the given function, + * and release it after the function is resolved or after an + * error is thrown. + * + * @param mutex - The mutex to lock. + * @param callback - The function to execute while the mutex is locked. + * @returns The result of the function. + */ +async function withLock( + mutex: Mutex, + callback: MutuallyExclusiveCallback, +): Promise { + const releaseLock = await mutex.acquire(); + + try { + return await callback({ releaseLock }); + } finally { + releaseLock(); + } +} diff --git a/packages/assets-controllers/src/MultichainAssetsController/index.ts b/packages/assets-controllers/src/MultichainAssetsController/index.ts new file mode 100644 index 00000000000..ed9c95ea52b --- /dev/null +++ b/packages/assets-controllers/src/MultichainAssetsController/index.ts @@ -0,0 +1,19 @@ +export { + MultichainAssetsController, + getDefaultMultichainAssetsControllerState, +} from './MultichainAssetsController.js'; + +export type { + MultichainAssetsControllerState, + MultichainAssetsControllerGetStateAction, + MultichainAssetsControllerStateChangeEvent, + MultichainAssetsControllerActions, + MultichainAssetsControllerMessenger, + MultichainAssetsControllerAccountAssetListUpdatedEvent, + MultichainAssetsControllerEvents, +} from './MultichainAssetsController.js'; +export type { + MultichainAssetsControllerGetAssetMetadataAction, + MultichainAssetsControllerIgnoreAssetsAction, + MultichainAssetsControllerAddAssetsAction, +} from './MultichainAssetsController-method-action-types.js'; diff --git a/packages/assets-controllers/src/MultichainAssetsController/utils.ts b/packages/assets-controllers/src/MultichainAssetsController/utils.ts new file mode 100644 index 00000000000..1b7e2323341 --- /dev/null +++ b/packages/assets-controllers/src/MultichainAssetsController/utils.ts @@ -0,0 +1,32 @@ +import type { + Caveat, + PermissionConstraint, +} from '@metamask/permission-controller'; +import { SnapCaveatType } from '@metamask/snaps-utils'; + +// TODO: this is a duplicate of https://github.com/MetaMask/snaps/blob/362208e725db18baed550ade99087d44e7b537ed/packages/snaps-rpc-methods/src/endowments/name-lookup.ts#L151 +// To be removed once core has snaps-rpc-methods dependency +/** + * Getter function to get the chainIds caveat from a permission. + * + * This does basic validation of the caveat, but does not validate the type or + * value of the namespaces object itself, as this is handled by the + * `PermissionsController` when the permission is requested. + * + * @param permission - The permission to get the `chainIds` caveat from. + * @returns An array of `chainIds` that the snap supports. + */ +// istanbul ignore next +export function getChainIdsCaveat( + permission?: PermissionConstraint, +): string[] | null { + if (!permission?.caveats) { + return null; + } + + const caveat = permission.caveats.find( + (permCaveat) => permCaveat.type === SnapCaveatType.ChainIds, + ) as Caveat | undefined; + + return caveat ? caveat.value : null; +} diff --git a/packages/assets-controllers/src/MultichainAssetsRatesController/MultichainAssetsRatesController-method-action-types.ts b/packages/assets-controllers/src/MultichainAssetsRatesController/MultichainAssetsRatesController-method-action-types.ts new file mode 100644 index 00000000000..1da91b94023 --- /dev/null +++ b/packages/assets-controllers/src/MultichainAssetsRatesController/MultichainAssetsRatesController-method-action-types.ts @@ -0,0 +1,36 @@ +/** + * This file is auto generated. + * Do not edit manually. + */ + +import type { MultichainAssetsRatesController } from './MultichainAssetsRatesController.js'; + +/** + * Updates token conversion rates for each non-EVM account. + * + * @returns A promise that resolves when the rates are updated. + */ +export type MultichainAssetsRatesControllerUpdateAssetsRatesAction = { + type: `MultichainAssetsRatesController:updateAssetsRates`; + handler: MultichainAssetsRatesController['updateAssetsRates']; +}; + +/** + * Fetches historical prices for the current account + * + * @param asset - The asset to fetch historical prices for. + * @param account - optional account to fetch historical prices for + * @returns The historical prices. + */ +export type MultichainAssetsRatesControllerFetchHistoricalPricesForAssetAction = + { + type: `MultichainAssetsRatesController:fetchHistoricalPricesForAsset`; + handler: MultichainAssetsRatesController['fetchHistoricalPricesForAsset']; + }; + +/** + * Union of all MultichainAssetsRatesController action types. + */ +export type MultichainAssetsRatesControllerMethodActions = + | MultichainAssetsRatesControllerUpdateAssetsRatesAction + | MultichainAssetsRatesControllerFetchHistoricalPricesForAssetAction; diff --git a/packages/assets-controllers/src/MultichainAssetsRatesController/MultichainAssetsRatesController.test.ts b/packages/assets-controllers/src/MultichainAssetsRatesController/MultichainAssetsRatesController.test.ts new file mode 100644 index 00000000000..0aea388308d --- /dev/null +++ b/packages/assets-controllers/src/MultichainAssetsRatesController/MultichainAssetsRatesController.test.ts @@ -0,0 +1,1640 @@ +import { deriveStateFromMetadata } from '@metamask/base-controller'; +import type { CaipAssetType } from '@metamask/keyring-api'; +import { SolScope } from '@metamask/keyring-api'; +import { SolMethod } from '@metamask/keyring-api'; +import { SolAccountType } from '@metamask/keyring-api'; +import { KeyringTypes } from '@metamask/keyring-controller'; +import type { InternalAccount } from '@metamask/keyring-internal-api'; +import { KeyringClient } from '@metamask/keyring-snap-client'; +import { MOCK_ANY_NAMESPACE, Messenger } from '@metamask/messenger'; +import type { + MessengerActions, + MessengerEvents, + MockAnyNamespace, +} from '@metamask/messenger'; +import type { OnAssetHistoricalPriceResponse } from '@metamask/snaps-sdk'; +import { v4 as uuidv4 } from 'uuid'; + +import { jestAdvanceTime } from '../../../../tests/helpers.js'; +import { MultichainAssetsRatesController } from './index.js'; +import type { MultichainAssetsRatesControllerMessenger } from './MultichainAssetsRatesController.js'; + +type AllMultichainAssetsRateControllerActions = + MessengerActions; + +type AllMultichainAssetsRateControllerEvents = + MessengerEvents; + +type RootMessenger = Messenger< + MockAnyNamespace, + AllMultichainAssetsRateControllerActions, + AllMultichainAssetsRateControllerEvents +>; + +// A fake non‑EVM account (with Snap metadata) that meets the controller’s criteria. +const fakeNonEvmAccount: InternalAccount = { + id: 'account1', + type: 'solana:data-account', + address: '0x123', + metadata: { + name: 'Test Account', + // @ts-expect-error-next-line + snap: { id: 'test-snap', enabled: true }, + }, + scopes: ['solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp'], + options: {}, + methods: [], +}; + +// A fake EVM account (which should be filtered out). +const fakeEvmAccount: InternalAccount = { + id: 'account2', + type: 'eip155:eoa', + address: '0x456', + // @ts-expect-error-next-line + metadata: { name: 'EVM Account' }, + scopes: [], + options: {}, + methods: [], +}; + +const fakeEvmAccount2: InternalAccount = { + id: 'account3', + type: 'bip122:p2wpkh', + address: '0x789', + metadata: { + name: 'EVM Account', + // @ts-expect-error-next-line + snap: { id: 'test-snap', enabled: true }, + }, + scopes: [], + options: {}, + methods: [], +}; + +const fakeEvmAccountWithoutMetadata: InternalAccount = { + id: 'account4', + type: 'bip122:p2wpkh', + address: '0x789', + metadata: { + name: 'EVM Account', + importTime: 0, + keyring: { type: 'bip122' }, + }, + scopes: [], + options: {}, + methods: [], +}; + +const fakeNonEvmAccount2: InternalAccount = { + id: 'account5', + type: 'solana:data-account', + address: '0x123', + metadata: { + name: 'Test Account', + // @ts-expect-error-next-line + snap: { id: 'test-snap-2', enabled: true }, + }, + scopes: ['solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp'], + options: {}, + methods: [], +}; + +const fakeMarketData = { + price: 202.11, + priceChange: 0, + priceChangePercentage: 0, + volume: 0, + marketCap: 0, +}; + +// A fake conversion rates response returned by the SnapController. +const fakeAccountRates = { + conversionRates: { + 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/slip44:501': { + 'swift:0/iso4217:USD': { + rate: '202.11', + conversionTime: 1738539923277, + marketData: fakeMarketData, + }, + }, + }, +}; + +const fakeHistoricalPrices: OnAssetHistoricalPriceResponse = { + historicalPrice: { + intervals: { + P1D: [ + [1737542312, '1'], + [1737542312, '2'], + ], + P1W: [ + [1737542312, '1'], + [1737542312, '2'], + ], + }, + updateTime: 1737542312, + expirationTime: 1737542312, + }, +}; + +const setupController = ({ + config, + accountsAssets = [fakeNonEvmAccount, fakeEvmAccount, fakeEvmAccount2], +}: { + config?: Partial< + ConstructorParameters[0] + >; + accountsAssets?: InternalAccount[]; +} = {}): { + controller: MultichainAssetsRatesController; + messenger: RootMessenger; + updateSpy: jest.SpyInstance; + mockGetAssetsState: jest.Mock; +} => { + const messenger: RootMessenger = new Messenger({ + namespace: MOCK_ANY_NAMESPACE, + }); + + const mockGetAssetsState = jest.fn().mockImplementation(() => ({ + accountsAssets: { + account1: ['solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/slip44:501'], + account2: ['solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/slip44:501'], + account3: ['solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/slip44:501'], + account5: [ + 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/token:EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v', + ], + }, + assetsMetadata: { + 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/slip44:501': { + name: 'Solana', + symbol: 'SOL', + fungible: true, + iconUrl: 'https://example.com/solana.png', + units: [{ symbol: 'SOL', name: 'Solana', decimals: 9 }], + }, + 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/token:EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v': + { + name: 'USDC', + symbol: 'USDC', + fungible: true, + iconUrl: 'https://example.com/usdc.png', + units: [{ symbol: 'USDC', name: 'USDC', decimals: 2 }], + }, + }, + allIgnoredAssets: {}, + })); + messenger.registerActionHandler( + 'MultichainAssetsController:getState', + mockGetAssetsState, + ); + + messenger.registerActionHandler( + 'AccountsController:listMultichainAccounts', + () => accountsAssets, + ); + + messenger.registerActionHandler( + 'AccountsController:getSelectedMultichainAccount', + () => accountsAssets[0], + ); + + messenger.registerActionHandler('CurrencyRateController:getState', () => ({ + currencyRates: {}, + currentCurrency: 'USD', + })); + + const multichainAssetsRatesControllerMessenger: Messenger< + 'MultichainAssetsRatesController', + AllMultichainAssetsRateControllerActions, + AllMultichainAssetsRateControllerEvents, + RootMessenger + > = new Messenger({ + namespace: 'MultichainAssetsRatesController', + parent: messenger, + }); + messenger.delegate({ + messenger: multichainAssetsRatesControllerMessenger, + actions: [ + 'AccountsController:listMultichainAccounts', + 'SnapController:handleRequest', + 'CurrencyRateController:getState', + 'MultichainAssetsController:getState', + 'AccountsController:getSelectedMultichainAccount', + ], + events: [ + 'AccountsController:accountAdded', + 'KeyringController:lock', + 'KeyringController:unlock', + 'CurrencyRateController:stateChange', + 'MultichainAssetsController:accountAssetListUpdated', + ], + }); + + const controller = new MultichainAssetsRatesController({ + messenger: multichainAssetsRatesControllerMessenger, + ...config, + }); + + const updateSpy = jest.spyOn(controller, 'update' as never); + + return { + controller, + messenger, + updateSpy, + mockGetAssetsState, + }; +}; + +describe('MultichainAssetsRatesController', () => { + const mockedDate = 1705760550000; + + beforeEach(() => { + jest.useFakeTimers(); + jest.spyOn(Date, 'now').mockReturnValue(mockedDate); + }); + + afterEach(() => { + jest.useRealTimers(); + jest.restoreAllMocks(); + }); + + it('initializes with an empty conversionRates state', () => { + const { controller } = setupController(); + expect(controller.state).toStrictEqual({ + conversionRates: {}, + historicalPrices: {}, + }); + }); + + it('updates conversion rates for a valid non-EVM account with marketData', async () => { + const { controller, messenger } = setupController(); + + // Stub KeyringClient.listAccountAssets so that the controller “discovers” one asset. + jest + .spyOn(KeyringClient.prototype, 'listAccountAssets') + .mockResolvedValue([ + 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/slip44:501', + ]); + + // Override the SnapController:handleRequest handler to return our fake conversion rates. + const snapHandler = jest.fn().mockResolvedValue(fakeAccountRates); + messenger.registerActionHandler( + 'SnapController:handleRequest', + snapHandler, + ); + + // Call updateAssetsRates for the valid non-EVM account. + await controller.updateAssetsRates(); + + // Check that the Snap request was made with the expected parameters. + expect(snapHandler).toHaveBeenCalledWith({ + handler: 'onAssetsConversion', + origin: 'metamask', + request: { + jsonrpc: '2.0', + method: 'onAssetsConversion', + params: { + conversions: [ + { + from: 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/slip44:501', + to: 'swift:0/iso4217:USD', + }, + ], + }, + }, + snapId: 'test-snap', + }); + + // The controller state should now contain the conversion rates returned. + expect(controller.state.conversionRates).toStrictEqual( + // fakeAccountRates.conversionRates, + { + 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/slip44:501': { + rate: '202.11', + conversionTime: 1738539923277, + currency: 'swift:0/iso4217:USD', + marketData: { + price: 202.11, + priceChange: 0, + priceChangePercentage: 0, + volume: 0, + marketCap: 0, + }, + }, + }, + ); + }); + + it('does not update conversion rates if the controller is not active', async () => { + const { controller, messenger } = setupController(); + + // Simulate a keyring lock event to set the controller as inactive. + messenger.publish('KeyringController:lock'); + // Override SnapController:handleRequest and stub listAccountAssets. + const snapHandler = jest.fn().mockResolvedValue(fakeAccountRates); + messenger.registerActionHandler( + 'SnapController:handleRequest', + snapHandler, + ); + jest + .spyOn(KeyringClient.prototype, 'listAccountAssets') + .mockResolvedValue([ + 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/slip44:501', + ]); + + await controller.updateAssetsRates(); + // Since the controller is locked, no update should occur. + expect(controller.state.conversionRates).toStrictEqual({}); + expect(snapHandler).not.toHaveBeenCalled(); + }); + + it('resumes update tokens rates when the keyring is unlocked', async () => { + const { controller, messenger } = setupController(); + messenger.publish('KeyringController:lock'); + // Override SnapController:handleRequest and stub listAccountAssets. + const snapHandler = jest.fn().mockResolvedValue(fakeAccountRates); + messenger.registerActionHandler( + 'SnapController:handleRequest', + snapHandler, + ); + jest + .spyOn(KeyringClient.prototype, 'listAccountAssets') + .mockResolvedValue([ + 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/slip44:501', + ]); + await controller.updateAssetsRates(); + expect(controller.isActive).toBe(false); + + messenger.publish('KeyringController:unlock'); + await controller.updateAssetsRates(); + + expect(controller.isActive).toBe(true); + }); + + it('calls updateTokensRates when _executePoll is invoked', async () => { + const { controller, messenger } = setupController(); + + jest + .spyOn(KeyringClient.prototype, 'listAccountAssets') + .mockResolvedValue([ + 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/slip44:501', + ]); + + messenger.registerActionHandler( + 'SnapController:handleRequest', + async () => ({ + conversionRates: { + 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/slip44:501': { + 'swift:0/iso4217:USD': { + rate: '202.11', + conversionTime: 1738539923277, + }, + }, + }, + }), + ); + + // Spy on updateAssetsRates. + const updateSpy = jest.spyOn(controller, 'updateAssetsRates'); + await controller._executePoll(); + expect(updateSpy).toHaveBeenCalled(); + }); + + it('calls updateTokensRatesForNewAssets when newAccountAssets event is published', async () => { + const testAccounts = [ + { + address: 'EBBYfhQzVzurZiweJ2keeBWpgGLs1cbWYcz28gjGgi5x', + id: uuidv4(), + metadata: { + name: 'Solana Account 1', + importTime: Date.now(), + keyring: { + type: KeyringTypes.snap, + }, + snap: { + id: 'mock-sol-snap-1', + name: 'mock-sol-snap-1', + enabled: true, + }, + lastSelected: 0, + }, + scopes: [SolScope.Devnet], + options: {}, + methods: [SolMethod.SendAndConfirmTransaction], + type: SolAccountType.DataAccount, + }, + { + address: 'GMTYfhQzVzurZiweJ2keeBWpgGLs1cbWYcz28gjGgi5x', + id: uuidv4(), + metadata: { + name: 'Solana Account 2', + importTime: Date.now(), + keyring: { + type: KeyringTypes.snap, + }, + snap: { + id: 'mock-sol-snap-2', + name: 'mock-sol-snap-2', + enabled: true, + }, + lastSelected: 0, + }, + scopes: [SolScope.Devnet], + options: {}, + methods: [SolMethod.SendAndConfirmTransaction], + type: SolAccountType.DataAccount, + }, + ]; + const { controller, messenger, updateSpy } = setupController({ + accountsAssets: testAccounts, + }); + + const mockResponses = { + onAssetsConversion: [ + { + conversionRates: { + 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/slip44:501': { + 'swift:0/iso4217:USD': { + rate: '100', + conversionTime: 1738539923277, + }, + }, + }, + }, + { + conversionRates: { + 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/token1:501': { + 'swift:0/iso4217:USD': { + rate: '200', + conversionTime: 1738539923277, + }, + }, + }, + }, + ], + onAssetsMarketData: [ + { + marketData: { + 'swift:0/iso4217:USD': fakeMarketData, + }, + }, + { + marketData: { + 'swift:0/iso4217:USD': fakeMarketData, + }, + }, + ], + }; + + const snapSpy = jest.fn().mockImplementation((args) => { + const { handler } = args; + return Promise.resolve( + mockResponses[handler as keyof typeof mockResponses].shift(), + ); + }); + messenger.registerActionHandler('SnapController:handleRequest', snapSpy); + + messenger.publish('MultichainAssetsController:accountAssetListUpdated', { + assets: { + [testAccounts[0].id]: { + added: ['solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/slip44:501'], + removed: [], + }, + [testAccounts[1].id]: { + added: ['solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/token1:501'], + removed: [], + }, + }, + }); + + // Wait for the asynchronous subscriber to run. + await Promise.resolve(); + await jestAdvanceTime({ duration: 10 }); + + expect(updateSpy).toHaveBeenCalledTimes(1); + expect(controller.state.conversionRates).toMatchObject({ + 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/slip44:501': { + rate: '100', + conversionTime: 1738539923277, + currency: 'swift:0/iso4217:USD', + }, + 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/token1:501': { + rate: '200', + conversionTime: 1738539923277, + currency: 'swift:0/iso4217:USD', + }, + }); + }); + + it('removes stale rates and historical prices for assets no longer tracked by any account', async () => { + const removedAsset = + 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/token:removed-token' as CaipAssetType; + const keptAsset = + 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/slip44:501' as CaipAssetType; + const { controller, messenger, mockGetAssetsState } = setupController({ + config: { + state: { + conversionRates: { + [removedAsset]: { + rate: '10', + conversionTime: 1738539923277, + currency: 'swift:0/iso4217:USD', + }, + [keptAsset]: { + rate: '202.11', + conversionTime: 1738539923277, + currency: 'swift:0/iso4217:USD', + }, + }, + historicalPrices: { + [removedAsset]: { + USD: { + intervals: fakeHistoricalPrices.historicalPrice.intervals, + updateTime: 1737542312, + expirationTime: 1737542312, + }, + }, + [keptAsset]: { + USD: { + intervals: fakeHistoricalPrices.historicalPrice.intervals, + updateTime: 1737542312, + expirationTime: 1737542312, + }, + }, + }, + }, + }, + }); + + mockGetAssetsState.mockReturnValue({ + accountsAssets: { + account1: [keptAsset], + }, + assetsMetadata: {}, + allIgnoredAssets: {}, + }); + + messenger.publish('MultichainAssetsController:accountAssetListUpdated', { + assets: { + account1: { + added: [], + removed: [removedAsset], + }, + }, + }); + + await Promise.resolve(); + await jestAdvanceTime({ duration: 10 }); + + expect(controller.state.conversionRates).toStrictEqual({ + [keptAsset]: { + rate: '202.11', + conversionTime: 1738539923277, + currency: 'swift:0/iso4217:USD', + }, + }); + expect(controller.state.historicalPrices).toStrictEqual({ + [keptAsset]: { + USD: { + intervals: fakeHistoricalPrices.historicalPrice.intervals, + updateTime: 1737542312, + expirationTime: 1737542312, + }, + }, + }); + }); + + it('keeps rates for removed assets that are still tracked by another account', async () => { + const sharedAsset = + 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/token:shared-token' as CaipAssetType; + const { controller, messenger, mockGetAssetsState } = setupController({ + config: { + state: { + conversionRates: { + [sharedAsset]: { + rate: '77', + conversionTime: 1738539923277, + currency: 'swift:0/iso4217:USD', + }, + }, + historicalPrices: { + [sharedAsset]: { + USD: { + intervals: fakeHistoricalPrices.historicalPrice.intervals, + updateTime: 1737542312, + expirationTime: 1737542312, + }, + }, + }, + }, + }, + }); + + mockGetAssetsState.mockReturnValue({ + accountsAssets: { + account1: [], + account2: [sharedAsset], + }, + assetsMetadata: {}, + allIgnoredAssets: {}, + }); + + messenger.publish('MultichainAssetsController:accountAssetListUpdated', { + assets: { + account1: { + added: [], + removed: [sharedAsset], + }, + }, + }); + + await Promise.resolve(); + await jestAdvanceTime({ duration: 10 }); + + expect(controller.state.conversionRates).toStrictEqual({ + [sharedAsset]: { + rate: '77', + conversionTime: 1738539923277, + currency: 'swift:0/iso4217:USD', + }, + }); + expect(controller.state.historicalPrices).toStrictEqual({ + [sharedAsset]: { + USD: { + intervals: fakeHistoricalPrices.historicalPrice.intervals, + updateTime: 1737542312, + expirationTime: 1737542312, + }, + }, + }); + }); + + it('handles partial or empty Snap responses gracefully', async () => { + const { controller, messenger } = setupController(); + + messenger.registerActionHandler('SnapController:handleRequest', () => { + return Promise.resolve({ + conversionRates: { + // Only returning a rate for one asset + 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/slip44:501': { + 'swift:0/iso4217:USD': { + rate: '250.50', + conversionTime: 1738539923277, + }, + }, + }, + }); + }); + + await controller.updateAssetsRates(); + + expect(controller.state.conversionRates).toMatchObject({ + 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/slip44:501': { + rate: '250.50', + conversionTime: 1738539923277, + }, + }); + }); + + it('skips all accounts that lack Snap metadata or are EVM', async () => { + const { controller, messenger } = setupController({ + accountsAssets: [fakeEvmAccountWithoutMetadata], + }); + + const snapSpy = jest.fn().mockResolvedValue({ conversionRates: {} }); + messenger.registerActionHandler('SnapController:handleRequest', snapSpy); + + await controller.updateAssetsRates(); + + expect(snapSpy).not.toHaveBeenCalled(); + expect(controller.state.conversionRates).toStrictEqual({}); + }); + + it('does not make snap requests when updateAssetsRatesForNewAssets is called with no new assets', async () => { + const { controller, messenger } = setupController(); + + const snapSpy = jest.fn().mockResolvedValue(fakeAccountRates); + messenger.registerActionHandler('SnapController:handleRequest', snapSpy); + + // Publish accountAssetListUpdated event with accounts that have no new assets (empty added arrays) + messenger.publish('MultichainAssetsController:accountAssetListUpdated', { + assets: { + account1: { + added: [], // No new assets added + removed: [], + }, + }, + }); + + // Wait for the asynchronous subscriber to process the event + await Promise.resolve(); + + // Verify no snap requests were made since there are no new assets to process + expect(snapSpy).not.toHaveBeenCalled(); + // Verify state remains empty + expect(controller.state.conversionRates).toStrictEqual({}); + }); + + it('updates state when currency is updated', async () => { + const { controller, messenger } = setupController(); + + const snapHandler = jest.fn().mockResolvedValue(fakeAccountRates); + messenger.registerActionHandler( + 'SnapController:handleRequest', + snapHandler, + ); + + const updateSpy = jest.spyOn(controller, 'updateAssetsRates'); + + messenger.publish( + 'CurrencyRateController:stateChange', + { + currentCurrency: 'EUR', + currencyRates: {}, + }, + [], + ); + + expect(updateSpy).toHaveBeenCalled(); + }); + + describe('error handling in snap requests', () => { + it('handles JSON-RPC parameter validation errors gracefully', async () => { + const { controller, messenger } = setupController(); + + const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(); + const paramValidationError = new Error( + 'Invalid request params: At path: conversions.0.from -- Expected a value of type `CaipAssetType`, but received: `"swift:0/test-asset"`.', + ); + + const snapHandler = jest.fn().mockRejectedValue(paramValidationError); + messenger.registerActionHandler( + 'SnapController:handleRequest', + snapHandler, + ); + + await controller.updateAssetsRates(); + + // Should have logged the error with detailed context + expect(consoleErrorSpy).toHaveBeenCalledWith( + 'Snap request failed for onAssetsConversion:', + expect.objectContaining({ + snapId: 'test-snap', + handler: 'onAssetsConversion', + message: expect.stringContaining('Invalid request params'), + params: expect.objectContaining({ + conversions: expect.arrayContaining([ + expect.objectContaining({ + from: 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/slip44:501', + to: 'swift:0/iso4217:USD', + }), + ]), + }), + }), + ); + + // Should not update state when snap request fails + expect(controller.state.conversionRates).toStrictEqual({}); + + consoleErrorSpy.mockRestore(); + }); + + it('handles generic snap request errors gracefully', async () => { + const { controller, messenger } = setupController(); + + const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(); + const genericError = new Error('Network timeout'); + + const snapHandler = jest.fn().mockRejectedValue(genericError); + messenger.registerActionHandler( + 'SnapController:handleRequest', + snapHandler, + ); + + await controller.updateAssetsRates(); + + // Should have logged the error with detailed context + expect(consoleErrorSpy).toHaveBeenCalledWith( + 'Snap request failed for onAssetsConversion:', + expect.objectContaining({ + snapId: 'test-snap', + handler: 'onAssetsConversion', + message: 'Network timeout', + params: expect.any(Object), + }), + ); + + // Should not update state when snap request fails + expect(controller.state.conversionRates).toStrictEqual({}); + + consoleErrorSpy.mockRestore(); + }); + + it('handles mixed success and failure scenarios', async () => { + const { controller, messenger } = setupController({ + accountsAssets: [fakeNonEvmAccount, fakeNonEvmAccount2], + }); + + const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(); + + // Mock different responses for different calls + const snapHandler = jest + .fn() + .mockResolvedValueOnce(fakeAccountRates) // First call succeeds (onAssetsConversion) + .mockResolvedValueOnce({ + marketData: { + 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/slip44:501': { + 'swift:0/iso4217:USD': fakeMarketData, + }, + }, + }) // Second call succeeds (onAssetsMarketData) + .mockRejectedValueOnce(new Error('Snap request failed')) // Third call fails (onAssetsConversion) + .mockResolvedValueOnce(null); // Fourth call returns null (onAssetsMarketData) + + messenger.registerActionHandler( + 'SnapController:handleRequest', + snapHandler, + ); + + await controller.updateAssetsRates(); + + // Should have logged the error for the failed request + expect(consoleErrorSpy).toHaveBeenCalledWith( + 'Snap request failed for onAssetsConversion:', + expect.objectContaining({ + message: 'Snap request failed', + }), + ); + + // Should still update state for the successful request + expect(controller.state.conversionRates).toMatchObject({ + 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/slip44:501': { + rate: '202.11', + conversionTime: 1738539923277, + currency: 'swift:0/iso4217:USD', + marketData: fakeMarketData, + }, + }); + + consoleErrorSpy.mockRestore(); + }); + + it('handles market data request errors independently', async () => { + const { controller, messenger } = setupController(); + + const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(); + + // Mock onAssetsConversion to succeed but onAssetsMarketData to fail + const snapHandler = jest + .fn() + .mockResolvedValueOnce(fakeAccountRates) // onAssetsConversion succeeds + .mockRejectedValueOnce(new Error('Market data unavailable')); // onAssetsMarketData fails + + messenger.registerActionHandler( + 'SnapController:handleRequest', + snapHandler, + ); + + await controller.updateAssetsRates(); + + // Should have logged the market data error + expect(consoleErrorSpy).toHaveBeenCalledWith( + 'Snap request failed for onAssetsMarketData:', + expect.objectContaining({ + message: 'Market data unavailable', + }), + ); + + // Should still update state with conversion rates (without market data) + expect(controller.state.conversionRates).toMatchObject({ + 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/slip44:501': { + rate: '202.11', + conversionTime: 1738539923277, + currency: 'swift:0/iso4217:USD', + }, + }); + + consoleErrorSpy.mockRestore(); + }); + }); + + describe('fetchHistoricalPricesForAsset', () => { + it('throws an error if call to snap fails', async () => { + const testAsset = 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/slip44:501'; + const { controller, messenger } = setupController(); + + const snapHandler = jest.fn().mockRejectedValue(new Error('test error')); + messenger.registerActionHandler( + 'SnapController:handleRequest', + snapHandler, + ); + + await expect( + controller.fetchHistoricalPricesForAsset(testAsset), + ).rejects.toThrow( + `Failed to fetch historical prices for asset: ${testAsset}`, + ); + }); + + it('returns early if the historical price has not expired', async () => { + const testCurrency = 'USD'; + const { controller, messenger } = setupController({ + config: { + state: { + historicalPrices: { + 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/slip44:501': { + [testCurrency]: { + intervals: {}, + updateTime: Date.now(), + expirationTime: Date.now() + 1000, + }, + }, + }, + }, + }, + }); + + const snapHandler = jest.fn().mockResolvedValue(fakeHistoricalPrices); + messenger.registerActionHandler( + 'SnapController:handleRequest', + snapHandler, + ); + + await controller.fetchHistoricalPricesForAsset( + 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/slip44:501', + ); + + expect(snapHandler).not.toHaveBeenCalled(); + }); + + it('does not update state if historical prices return null', async () => { + const { controller, messenger } = setupController(); + + const snapHandler = jest.fn().mockResolvedValue(null); + messenger.registerActionHandler( + 'SnapController:handleRequest', + snapHandler, + ); + + await controller.fetchHistoricalPricesForAsset( + 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/slip44:501', + ); + + expect(snapHandler).toHaveBeenCalledTimes(1); + expect(controller.state.historicalPrices).toMatchObject({}); + }); + + it('calls the snap if historical price does not have an expiration time', async () => { + const testCurrency = 'USD'; + const { controller, messenger } = setupController({ + config: { + state: { + historicalPrices: { + 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/slip44:501': { + [testCurrency]: { + intervals: {}, + updateTime: Date.now(), + }, + }, + }, + }, + }, + }); + + const snapHandler = jest.fn().mockResolvedValue(fakeHistoricalPrices); + messenger.registerActionHandler( + 'SnapController:handleRequest', + snapHandler, + ); + + await controller.fetchHistoricalPricesForAsset( + 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/slip44:501', + ); + + expect(snapHandler).toHaveBeenCalledTimes(1); + }); + + it('calls the snap if historical price does not exist in state for the current currency', async () => { + const testCurrency = 'EUR'; + const { controller, messenger } = setupController({ + config: { + state: { + historicalPrices: { + 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/slip44:501': { + [testCurrency]: { + intervals: {}, + updateTime: Date.now(), + }, + }, + }, + }, + }, + }); + + const snapHandler = jest.fn().mockResolvedValue(fakeHistoricalPrices); + messenger.registerActionHandler( + 'SnapController:handleRequest', + snapHandler, + ); + + await controller.fetchHistoricalPricesForAsset( + 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/slip44:501', + ); + + expect(snapHandler).toHaveBeenCalledTimes(1); + }); + + it('calls fetchHistoricalPricesForAsset once and returns early on subsequent calls', async () => { + const { controller, messenger } = setupController(); + + const testHistoricalPriceReturn = { + ...fakeHistoricalPrices.historicalPrice, + expirationTime: Date.now() + 1000, + }; + const testAsset = 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/slip44:501'; + + const snapHandler = jest.fn().mockResolvedValue({ + historicalPrice: testHistoricalPriceReturn, + }); + messenger.registerActionHandler( + 'SnapController:handleRequest', + snapHandler, + ); + + await controller.fetchHistoricalPricesForAsset(testAsset); + + expect(snapHandler).toHaveBeenCalledWith({ + handler: 'onAssetHistoricalPrice', + origin: 'metamask', + request: { + jsonrpc: '2.0', + method: 'onAssetHistoricalPrice', + params: { + from: testAsset, + to: 'swift:0/iso4217:USD', + }, + }, + snapId: 'test-snap', + }); + + expect(controller.state.historicalPrices).toMatchObject({ + [testAsset]: { + USD: testHistoricalPriceReturn, + }, + }); + + await controller.fetchHistoricalPricesForAsset(testAsset); + + expect(snapHandler).toHaveBeenCalledTimes(1); + }); + }); + + describe('line 331 coverage - skip accounts with no assets', () => { + it('should skip accounts that have no assets (empty array) and continue processing', async () => { + const accountWithNoAssets: InternalAccount = { + id: 'account1', // This account will have no assets + type: 'solana:data-account', + address: '0xNoAssets', + metadata: { + name: 'Account With No Assets', + // @ts-expect-error-next-line + snap: { id: 'test-snap', enabled: true }, + }, + scopes: ['solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp'], + options: {}, + methods: [], + }; + + const accountWithAssets: InternalAccount = { + id: 'account2', // This account will have assets + type: 'solana:data-account', + address: '0xWithAssets', + metadata: { + name: 'Account With Assets', + // @ts-expect-error-next-line + snap: { id: 'test-snap', enabled: true }, + }, + scopes: ['solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp'], + options: {}, + methods: [], + }; + + // Set up controller with custom accounts and assets configuration + const messenger: RootMessenger = new Messenger({ + namespace: MOCK_ANY_NAMESPACE, + }); + + // Mock MultichainAssetsController state with one account having no assets + messenger.registerActionHandler( + 'MultichainAssetsController:getState', + () => ({ + accountsAssets: { + account1: [], // Empty array - should trigger line 331 continue + account2: ['solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/slip44:501'], // Has assets + }, + assetsMetadata: { + 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/slip44:501': { + name: 'Solana', + symbol: 'SOL', + fungible: true, + iconUrl: 'https://example.com/solana.png', + units: [{ symbol: 'SOL', name: 'Solana', decimals: 9 }], + }, + }, + allIgnoredAssets: {}, + }), + ); + + messenger.registerActionHandler( + 'AccountsController:listMultichainAccounts', + () => [accountWithNoAssets, accountWithAssets], // Both accounts in the list + ); + + messenger.registerActionHandler( + 'AccountsController:getSelectedMultichainAccount', + () => accountWithAssets, + ); + + messenger.registerActionHandler( + 'CurrencyRateController:getState', + () => ({ + currentCurrency: 'USD', + currencyRates: {}, + }), + ); + + // Track Snap calls to verify only the account with assets gets processed + const snapHandler = jest.fn().mockResolvedValue({ + conversionRates: { + 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/slip44:501': { + 'swift:0/iso4217:USD': { + rate: '100.50', + conversionTime: Date.now(), + }, + }, + }, + }); + + messenger.registerActionHandler( + 'SnapController:handleRequest', + snapHandler, + ); + + const multichainAssetsRatesControllerMessenger = new Messenger< + 'MultichainAssetsRatesController', + AllMultichainAssetsRateControllerActions, + AllMultichainAssetsRateControllerEvents, + RootMessenger + >({ + namespace: 'MultichainAssetsRatesController', + parent: messenger, + }); + messenger.delegate({ + messenger: multichainAssetsRatesControllerMessenger, + actions: [ + 'MultichainAssetsController:getState', + 'AccountsController:listMultichainAccounts', + 'AccountsController:getSelectedMultichainAccount', + 'CurrencyRateController:getState', + 'SnapController:handleRequest', + ], + events: [ + 'KeyringController:lock', + 'KeyringController:unlock', + 'AccountsController:accountAdded', + 'CurrencyRateController:stateChange', + 'MultichainAssetsController:accountAssetListUpdated', + ], + }); + + const controller = new MultichainAssetsRatesController({ + messenger: multichainAssetsRatesControllerMessenger, + }); + + await controller.updateAssetsRates(); + + // The snap handler gets called for both conversion rates and market data + // But we only care about the conversion rates call for this test + const conversionCalls = snapHandler.mock.calls.filter( + (call) => call[0].handler === 'onAssetsConversion', + ); + + // Verify that the conversion snap was called only once (for the account with assets) + // This confirms that the account with no assets was skipped via line 331 continue + expect(conversionCalls).toHaveLength(1); + + // Verify that the conversion call was made with the correct structure + expect(snapHandler).toHaveBeenCalledWith({ + handler: 'onAssetsConversion', + origin: 'metamask', + snapId: 'test-snap', + request: { + jsonrpc: '2.0', + method: 'onAssetsConversion', + params: { + conversions: [ + { + from: 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/slip44:501', + to: 'swift:0/iso4217:USD', + }, + ], + }, + }, + }); + + // Verify that conversion rates were updated only for the account with assets + expect(controller.state.conversionRates).toMatchObject({ + 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/slip44:501': { + rate: '100.50', + conversionTime: expect.any(Number), + currency: 'swift:0/iso4217:USD', + }, + }); + }); + }); + + describe('dynamic asset fetching', () => { + it('should fetch rates for assets added after controller initialization', async () => { + const messenger: RootMessenger = new Messenger({ + namespace: MOCK_ANY_NAMESPACE, + }); + + // Initially, MultichainAssetsController has no assets + let multichainAssets: Record = {}; + + messenger.registerActionHandler( + 'MultichainAssetsController:getState', + () => ({ + accountsAssets: multichainAssets, + assetsMetadata: { + 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/slip44:501': { + name: 'Solana', + symbol: 'SOL', + fungible: true, + iconUrl: 'https://example.com/solana.png', + units: [{ symbol: 'SOL', name: 'Solana', decimals: 9 }], + }, + }, + allIgnoredAssets: {}, + }), + ); + + messenger.registerActionHandler( + 'AccountsController:listMultichainAccounts', + () => [ + { + id: 'account1', + address: 'sol-address-1', + options: {}, + methods: [SolMethod.SignMessage, SolMethod.SignTransaction], + type: SolAccountType.DataAccount, + metadata: { + name: 'Test Solana Account', + importTime: Date.now(), + keyring: { + type: KeyringTypes.snap, + }, + snap: { + name: 'Test Snap', + id: 'test-snap', + enabled: true, + }, + }, + scopes: [SolScope.Mainnet], + }, + ], + ); + + messenger.registerActionHandler( + 'AccountsController:getSelectedMultichainAccount', + () => ({ + id: 'account1', + address: 'sol-address-1', + options: {}, + methods: [SolMethod.SignMessage, SolMethod.SignTransaction], + type: SolAccountType.DataAccount, + metadata: { + name: 'Test Solana Account', + importTime: Date.now(), + keyring: { + type: KeyringTypes.snap, + }, + snap: { + name: 'Test Snap', + id: 'test-snap', + enabled: true, + }, + }, + scopes: [SolScope.Mainnet], + }), + ); + + messenger.registerActionHandler( + 'CurrencyRateController:getState', + () => ({ + currentCurrency: 'USD', + currencyRates: {}, + }), + ); + + const snapHandler = jest.fn().mockResolvedValue({ + conversionRates: { + 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/slip44:501': { + 'swift:0/iso4217:USD': { + rate: '150.00', + conversionTime: Date.now(), + }, + }, + }, + }); + + messenger.registerActionHandler( + 'SnapController:handleRequest', + snapHandler, + ); + + const multichainAssetsRatesControllerMessenger = new Messenger< + 'MultichainAssetsRatesController', + AllMultichainAssetsRateControllerActions, + AllMultichainAssetsRateControllerEvents, + RootMessenger + >({ + namespace: 'MultichainAssetsRatesController', + parent: messenger, + }); + + messenger.delegate({ + messenger: multichainAssetsRatesControllerMessenger, + actions: [ + 'MultichainAssetsController:getState', + 'AccountsController:listMultichainAccounts', + 'AccountsController:getSelectedMultichainAccount', + 'CurrencyRateController:getState', + 'SnapController:handleRequest', + ], + events: [ + 'KeyringController:lock', + 'KeyringController:unlock', + 'AccountsController:accountAdded', + 'CurrencyRateController:stateChange', + 'MultichainAssetsController:accountAssetListUpdated', + ], + }); + + jest + .spyOn(KeyringClient.prototype, 'listAccountAssets') + .mockResolvedValue([]); + + const controller = new MultichainAssetsRatesController({ + messenger: multichainAssetsRatesControllerMessenger, + }); + + // Initial fetch should return empty because no assets exist yet + await controller.updateAssetsRates(); + expect(controller.state.conversionRates).toStrictEqual({}); + expect(snapHandler).not.toHaveBeenCalled(); + + // Simulate new wallet import: MultichainAssetsController now has assets + multichainAssets = { + account1: ['solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/slip44:501'], + }; + + jest + .spyOn(KeyringClient.prototype, 'listAccountAssets') + .mockResolvedValue([ + 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/slip44:501', + ]); + + // Fetch again - should now pick up the new assets + await controller.updateAssetsRates(); + + // Verify that rates were fetched for the newly added asset + expect(snapHandler).toHaveBeenCalled(); + expect(controller.state.conversionRates).toMatchObject({ + 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/slip44:501': { + rate: '150.00', + currency: 'swift:0/iso4217:USD', + }, + }); + }); + }); + + describe('isDeprecated', () => { + const testAsset = + 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/slip44:501' as CaipAssetType; + + const initialState = { + conversionRates: { + [testAsset]: { + rate: '202.11', + conversionTime: 1738539923277, + currency: 'swift:0/iso4217:USD' as CaipAssetType, + }, + }, + historicalPrices: { + [testAsset]: { + USD: { + intervals: {}, + updateTime: 1737542312, + }, + }, + }, + }; + + it('clears persisted rates at construction when isDeprecated() returns true', () => { + const { controller } = setupController({ + config: { isDeprecated: () => true, state: initialState }, + }); + + expect(controller.state.conversionRates).toStrictEqual({}); + expect(controller.state.historicalPrices).toStrictEqual({}); + }); + + it('preserves persisted rates at construction when isDeprecated() returns false', () => { + const { controller } = setupController({ + config: { isDeprecated: () => false, state: initialState }, + }); + + expect(controller.state.conversionRates).toStrictEqual( + initialState.conversionRates, + ); + expect(controller.state.historicalPrices).toStrictEqual( + initialState.historicalPrices, + ); + }); + + it('does not fetch and clears stale rates when isDeprecated returns true', async () => { + let deprecated = false; + const { controller, messenger } = setupController({ + config: { isDeprecated: () => deprecated, state: initialState }, + }); + + const snapHandler = jest.fn().mockResolvedValue(fakeAccountRates); + messenger.registerActionHandler( + 'SnapController:handleRequest', + snapHandler, + ); + + deprecated = true; + + await controller.updateAssetsRates(); + + expect(snapHandler).not.toHaveBeenCalled(); + expect(controller.state.conversionRates).toStrictEqual({}); + expect(controller.state.historicalPrices).toStrictEqual({}); + }); + + it('clears stale rates on _executePoll when isDeprecated toggles to true at runtime', async () => { + let deprecated = false; + const { controller, messenger } = setupController({ + config: { isDeprecated: () => deprecated, state: initialState }, + }); + + const snapHandler = jest.fn().mockResolvedValue(fakeAccountRates); + messenger.registerActionHandler( + 'SnapController:handleRequest', + snapHandler, + ); + + deprecated = true; + + await controller._executePoll(); + + expect(snapHandler).not.toHaveBeenCalled(); + expect(controller.state.conversionRates).toStrictEqual({}); + expect(controller.state.historicalPrices).toStrictEqual({}); + }); + + it('clears stale rates on CurrencyRateController:stateChange when isDeprecated toggles to true at runtime', async () => { + let deprecated = false; + const { controller, messenger } = setupController({ + config: { isDeprecated: () => deprecated, state: initialState }, + }); + + deprecated = true; + + messenger.publish( + 'CurrencyRateController:stateChange', + { + currentCurrency: 'EUR', + currencyRates: {}, + }, + [], + ); + + await Promise.resolve(); + + expect(controller.state.conversionRates).toStrictEqual({}); + expect(controller.state.historicalPrices).toStrictEqual({}); + }); + + it('clears stale rates on MultichainAssetsController:accountAssetListUpdated when isDeprecated toggles to true at runtime', async () => { + let deprecated = false; + const { controller, messenger } = setupController({ + config: { isDeprecated: () => deprecated, state: initialState }, + }); + + deprecated = true; + + messenger.publish('MultichainAssetsController:accountAssetListUpdated', { + assets: { + account1: { + added: [testAsset], + removed: [], + }, + }, + }); + + await Promise.resolve(); + + expect(controller.state.conversionRates).toStrictEqual({}); + expect(controller.state.historicalPrices).toStrictEqual({}); + }); + + it('does not fetch historical prices when isDeprecated returns true', async () => { + let deprecated = false; + const { controller, messenger } = setupController({ + config: { isDeprecated: () => deprecated, state: initialState }, + }); + + const snapHandler = jest.fn().mockResolvedValue(fakeHistoricalPrices); + messenger.registerActionHandler( + 'SnapController:handleRequest', + snapHandler, + ); + + deprecated = true; + + await controller.fetchHistoricalPricesForAsset(testAsset); + + expect(snapHandler).not.toHaveBeenCalled(); + expect(controller.state.conversionRates).toStrictEqual({}); + expect(controller.state.historicalPrices).toStrictEqual({}); + }); + }); + + describe('metadata', () => { + it('includes expected state in debug snapshots', () => { + const { controller } = setupController(); + + expect( + deriveStateFromMetadata( + controller.state, + controller.metadata, + 'includeInDebugSnapshot', + ), + ).toMatchInlineSnapshot(` + { + "conversionRates": {}, + "historicalPrices": {}, + } + `); + }); + + it('includes expected state in state logs', () => { + const { controller } = setupController(); + + expect( + deriveStateFromMetadata( + controller.state, + controller.metadata, + 'includeInStateLogs', + ), + ).toMatchInlineSnapshot(`{}`); + }); + + it('persists expected state', () => { + const { controller } = setupController(); + + expect( + deriveStateFromMetadata( + controller.state, + controller.metadata, + 'persist', + ), + ).toMatchInlineSnapshot(` + { + "conversionRates": {}, + } + `); + }); + + it('exposes expected state to UI', () => { + const { controller } = setupController(); + + expect( + deriveStateFromMetadata( + controller.state, + controller.metadata, + 'usedInUi', + ), + ).toMatchInlineSnapshot(` + { + "conversionRates": {}, + "historicalPrices": {}, + } + `); + }); + }); +}); diff --git a/packages/assets-controllers/src/MultichainAssetsRatesController/MultichainAssetsRatesController.ts b/packages/assets-controllers/src/MultichainAssetsRatesController/MultichainAssetsRatesController.ts new file mode 100644 index 00000000000..49306a3c4c9 --- /dev/null +++ b/packages/assets-controllers/src/MultichainAssetsRatesController/MultichainAssetsRatesController.ts @@ -0,0 +1,882 @@ +import type { + AccountsControllerListMultichainAccountsAction, + AccountsControllerAccountAddedEvent, + AccountsControllerGetSelectedMultichainAccountAction, +} from '@metamask/accounts-controller'; +import type { + ControllerStateChangeEvent, + ControllerGetStateAction, + StateMetadata, +} from '@metamask/base-controller'; +import { isEvmAccountType } from '@metamask/keyring-api'; +import type { CaipAssetType } from '@metamask/keyring-api'; +import type { + KeyringControllerLockEvent, + KeyringControllerUnlockEvent, +} from '@metamask/keyring-controller'; +import type { InternalAccount } from '@metamask/keyring-internal-api'; +import type { Messenger } from '@metamask/messenger'; +import { StaticIntervalPollingController } from '@metamask/polling-controller'; +import type { SnapControllerHandleRequestAction } from '@metamask/snaps-controllers'; +import type { + SnapId, + AssetConversion, + OnAssetsConversionArguments, + OnAssetHistoricalPriceArguments, + OnAssetHistoricalPriceResponse, + HistoricalPriceIntervals, + OnAssetsMarketDataArguments, + OnAssetsMarketDataResponse, + FungibleAssetMarketData, + OnAssetsConversionResponse, +} from '@metamask/snaps-sdk'; +import { HandlerType } from '@metamask/snaps-utils'; +import { Mutex } from 'async-mutex'; +import type { Draft } from 'immer'; + +import type { + CurrencyRateState, + CurrencyRateStateChange, + CurrencyRateControllerGetStateAction, +} from '../CurrencyRateController.js'; +import type { + MultichainAssetsControllerGetStateAction, + MultichainAssetsControllerAccountAssetListUpdatedEvent, +} from '../MultichainAssetsController/index.js'; +import { MAP_CAIP_CURRENCIES } from './constant.js'; +import type { MultichainAssetsRatesControllerMethodActions } from './MultichainAssetsRatesController-method-action-types.js'; + +/** + * The name of the MultichainAssetsRatesController. + */ +const controllerName = 'MultichainAssetsRatesController'; + +// This is temporary until its exported from snap +type HistoricalPrice = { + intervals: HistoricalPriceIntervals; + // The UNIX timestamp of when the historical price was last updated. + updateTime: number; + // The UNIX timestamp of when the historical price will expire. + expirationTime?: number; +}; + +/** + * State used by the MultichainAssetsRatesController to cache token conversion rates. + */ +export type MultichainAssetsRatesControllerState = { + conversionRates: Record; + historicalPrices: Record>; // string being the current currency we fetched historical prices for +}; + +/** + * Returns the state of the MultichainAssetsRatesController. + */ +export type MultichainAssetsRatesControllerGetStateAction = + ControllerGetStateAction< + typeof controllerName, + MultichainAssetsRatesControllerState + >; + +type UnifiedAssetConversion = AssetConversion & { + marketData?: FungibleAssetMarketData; +}; + +/** + * Constructs the default {@link MultichainAssetsRatesController} state. This allows + * consumers to provide a partial state object when initializing the controller + * and also helps in constructing complete state objects for this controller in + * tests. + * + * @returns The default {@link MultichainAssetsRatesController} state. + */ +export function getDefaultMultichainAssetsRatesControllerState(): MultichainAssetsRatesControllerState { + return { conversionRates: {}, historicalPrices: {} }; +} + +/** + * Event emitted when the state of the MultichainAssetsRatesController changes. + */ +export type MultichainAssetsRatesControllerStateChange = + ControllerStateChangeEvent< + typeof controllerName, + MultichainAssetsRatesControllerState + >; + +/** + * Actions exposed by the MultichainAssetsRatesController. + */ +export type MultichainAssetsRatesControllerActions = + | MultichainAssetsRatesControllerGetStateAction + | MultichainAssetsRatesControllerMethodActions; + +/** + * Events emitted by MultichainAssetsRatesController. + */ +export type MultichainAssetsRatesControllerEvents = + MultichainAssetsRatesControllerStateChange; + +/** + * Actions that this controller is allowed to call. + */ +export type AllowedActions = + | SnapControllerHandleRequestAction + | AccountsControllerListMultichainAccountsAction + | CurrencyRateControllerGetStateAction + | MultichainAssetsControllerGetStateAction + | AccountsControllerGetSelectedMultichainAccountAction; + +/** + * Events that this controller is allowed to subscribe to. + */ +export type AllowedEvents = + | KeyringControllerLockEvent + | KeyringControllerUnlockEvent + | AccountsControllerAccountAddedEvent + | CurrencyRateStateChange + | MultichainAssetsControllerAccountAssetListUpdatedEvent; +/** + * Messenger type for the MultichainAssetsRatesController. + */ +export type MultichainAssetsRatesControllerMessenger = Messenger< + typeof controllerName, + MultichainAssetsRatesControllerActions | AllowedActions, + MultichainAssetsRatesControllerEvents | AllowedEvents +>; + +/** + * The input for starting polling in MultichainAssetsRatesController. + */ +export type MultichainAssetsRatesPollingInput = { + accountId: string; +}; + +const metadata: StateMetadata = { + conversionRates: { + includeInStateLogs: false, + persist: true, + includeInDebugSnapshot: true, + usedInUi: true, + }, + historicalPrices: { + includeInStateLogs: false, + persist: false, + includeInDebugSnapshot: true, + usedInUi: true, + }, +}; + +export type ConversionRatesWithMarketData = { + conversionRates: Record< + CaipAssetType, + Record + >; +}; + +/** + * Arguments for a Snap request. + */ +type SnapRequestArgs = { + snapId: SnapId; + handler: HandlerType; + params: T; +}; + +const MESSENGER_EXPOSED_METHODS = [ + 'updateAssetsRates', + 'fetchHistoricalPricesForAsset', +] as const; + +/** + * Controller that manages multichain token conversion rates. + * + * This controller polls for token conversion rates and updates its state. + */ +export class MultichainAssetsRatesController extends StaticIntervalPollingController()< + typeof controllerName, + MultichainAssetsRatesControllerState, + MultichainAssetsRatesControllerMessenger +> { + readonly #mutex = new Mutex(); + + #currentCurrency: CurrencyRateState['currentCurrency']; + + #isUnlocked = true; + + readonly #isDeprecated: () => boolean; + + /** + * Creates an instance of MultichainAssetsRatesController. + * + * @param options - Constructor options. + * @param options.interval - The polling interval in milliseconds. + * @param options.state - The initial state. + * @param options.messenger - A reference to the messenger. + * @param options.isDeprecated - Optional function that returns true to completely + * disable this controller (no requests, no state updates). When it returns + * `true`, `conversionRates` and `historicalPrices` are reset to `{}` at + * construction and at every polling entry point, so no stale rates remain in + * state. The function is evaluated dynamically on each entry point so it can + * be toggled at runtime. Intended for use when a higher-level controller + * (e.g. AssetsController) supersedes this one. + */ + constructor({ + interval = 18000, + state = {}, + messenger, + isDeprecated = (): boolean => false, + }: { + interval?: number; + state?: Partial; + messenger: MultichainAssetsRatesControllerMessenger; + isDeprecated?: () => boolean; + }) { + super({ + name: controllerName, + messenger, + state: { + ...getDefaultMultichainAssetsRatesControllerState(), + ...state, + }, + metadata, + }); + + this.setIntervalLength(interval); + this.#isDeprecated = isDeprecated; + + if (this.#isDeprecated()) { + this.#enforceDisabledState(); + } + + messenger.registerMethodActionHandlers(this, MESSENGER_EXPOSED_METHODS); + + // Subscribe to keyring lock/unlock events. + this.messenger.subscribe('KeyringController:lock', () => { + this.#isUnlocked = false; + }); + this.messenger.subscribe('KeyringController:unlock', () => { + this.#isUnlocked = true; + }); + + ({ currentCurrency: this.#currentCurrency } = this.messenger.call( + 'CurrencyRateController:getState', + )); + + this.messenger.subscribe( + 'CurrencyRateController:stateChange', + // eslint-disable-next-line @typescript-eslint/no-misused-promises + async (currentCurrency: string) => { + if (this.#isDeprecated()) { + this.#enforceDisabledState(); + return; + } + this.#currentCurrency = currentCurrency; + await this.updateAssetsRates(); + }, + (currencyRateControllerState) => + currencyRateControllerState.currentCurrency, + ); + + this.messenger.subscribe( + 'MultichainAssetsController:accountAssetListUpdated', + // eslint-disable-next-line @typescript-eslint/no-misused-promises + async ({ assets }) => { + if (this.#isDeprecated()) { + this.#enforceDisabledState(); + return; + } + // Treat the event payload as a per-account delta so we can fetch only + // newly added assets and independently clean up removed ones. + const updatedAccountAssets = Object.entries(assets).map( + ([accountId, { added, removed }]) => ({ + accountId, + added: [...added], + removed: [...removed], + }), + ); + await this.#updateAssetsRatesForNewAssets(updatedAccountAssets); + }, + ); + } + + /** + * Clears all persisted `conversionRates` and `historicalPrices` so that no + * stale rates remain in state. + * + * Called from every entry point when `isDeprecated()` is true so that a + * runtime toggle propagates to state immediately, even if the controller was + * originally constructed while it was enabled. The update is skipped when + * both fields are already empty to avoid emitting redundant state changes. + */ + #enforceDisabledState(): void { + if ( + Object.keys(this.state.conversionRates).length === 0 && + Object.keys(this.state.historicalPrices).length === 0 + ) { + return; + } + this.update((state) => { + state.conversionRates = {}; + state.historicalPrices = {}; + }); + } + + /** + * Executes a poll by updating token conversion rates for the current account. + * + * @returns A promise that resolves when the polling completes. + */ + async _executePoll(): Promise { + if (this.#isDeprecated()) { + this.#enforceDisabledState(); + return; + } + await this.updateAssetsRates(); + } + + /** + * Determines whether the controller is active. + * + * @returns True if the keyring is unlocked; otherwise, false. + */ + get isActive(): boolean { + return this.#isUnlocked; + } + + /** + * Checks if an account is a non-EVM account with a Snap. + * + * @param account - The account to check. + * @returns True if the account is non-EVM and has Snap metadata; otherwise, false. + */ + #isNonEvmAccount(account: InternalAccount): boolean { + return ( + !isEvmAccountType(account.type) && account.metadata.snap !== undefined + ); + } + + /** + * Retrieves all multichain accounts from the AccountsController. + * + * @returns An array of internal accounts. + */ + #listMultichainAccounts(): InternalAccount[] { + return this.messenger.call('AccountsController:listMultichainAccounts'); + } + + /** + * Filters and returns non-EVM accounts that should have balances. + * + * @returns An array of non-EVM internal accounts. + */ + #listAccounts(): InternalAccount[] { + const accounts = this.#listMultichainAccounts(); + return accounts.filter((account) => this.#isNonEvmAccount(account)); + } + + /** + * Adds the assets to a map of Snap ID to assets. + * + * @param snapIdToAssets - The map of Snap ID to assets. + * @param account - The account to add the assets for. + * @param assets - The assets to add. + */ + #addAssetsToSnapIdMap( + snapIdToAssets: Map>, + account: InternalAccount, + assets: CaipAssetType[], + ): void { + // Prevent creating a new set if there are no assets to add. + if (assets.length === 0) { + return; + } + + // FIXME: Instead of using the Snap ID from the account, we should + // select the Snap based on the supported scopes defined in the Snaps' + // manifest. + const snapId = account.metadata.snap?.id as SnapId | undefined; + if (!snapId) { + return; + } + + let snapAssets = snapIdToAssets.get(snapId); + if (!snapAssets) { + snapAssets = new Set(); + snapIdToAssets.set(snapId, snapAssets); + } + + for (const asset of assets) { + snapAssets.add(asset); + } + } + + /** + * Updates token conversion rates for each non-EVM account. + * + * @returns A promise that resolves when the rates are updated. + */ + async updateAssetsRates(): Promise { + if (this.#isDeprecated()) { + this.#enforceDisabledState(); + return Promise.resolve(); + } + + const releaseLock = await this.#mutex.acquire(); + + return (async (): Promise => { + if (!this.isActive) { + return; + } + + // Compute the set of unique assets from all accounts. It's important to + // deduplicate assets here to avoid duplicate requests to the Snap. + const accounts = this.#listAccounts(); + const snapIdToAssets = new Map>(); + for (const account of accounts) { + this.#addAssetsToSnapIdMap( + snapIdToAssets, + account, + this.#getAssetsForAccount(account.id), + ); + } + + this.#applyUpdatedRates(await this.#getUpdatedRatesFor(snapIdToAssets)); + })().finally(() => { + releaseLock(); + }); + } + + /** + * Returns the CAIP-19 asset type for the current selected currency. Defaults + * to USD if the current selected currency is not supported. + * + * @returns The CAIP-19 asset type for the current selected currency. + */ + #getCaipCurrentCurrency(): CaipAssetType { + return ( + MAP_CAIP_CURRENCIES[this.#currentCurrency] ?? MAP_CAIP_CURRENCIES.usd + ); + } + + /** + * Fetches the conversion rates for the given assets from the given Snap. + * + * @param snapId - The ID of the Snap. + * @param assets - The assets to fetch the conversion rates for. + * @param currency - The currency to fetch the conversion rates for. + * @returns A record of CAIP-19 asset types to conversion rates. + */ + async #getConversionRates( + snapId: SnapId, + assets: Set, + currency: CaipAssetType, + ): Promise> { + // Prevent making a Snap call if there are no assets to fetch. + if (assets.size === 0) { + return {}; + } + + const response = await this.#handleSnapRequest({ + snapId, + handler: HandlerType.OnAssetsConversion, + params: { + conversions: Array.from(assets).map((asset) => ({ + from: asset, + to: currency, + })), + }, + }); + + if (!response) { + return {}; + } + + const assetToConversionRate: Record< + CaipAssetType, + AssetConversion | undefined + > = {}; + + for (const asset of assets) { + assetToConversionRate[asset] = + response.conversionRates?.[asset]?.[currency] ?? undefined; + } + + return assetToConversionRate; + } + + /** + * Fetches the market data for the given assets from the given Snap. + * + * @param snapId - The ID of the Snap. + * @param assets - The assets to fetch the market data for. + * @param currency - The currency to fetch the market data for. + * @returns A record of CAIP-19 asset types to market data. + */ + async #getMarketData( + snapId: SnapId, + assets: Set, + currency: CaipAssetType, + ): Promise> { + // Prevent making a Snap call if there are no assets to fetch. + if (assets.size === 0) { + return {}; + } + + const response = await this.#handleSnapRequest({ + snapId, + handler: HandlerType.OnAssetsMarketData, + params: { + assets: Array.from(assets).map((asset) => ({ + asset, + unit: currency, + })), + }, + }); + + if (!response) { + return {}; + } + + const assetToMarketData: Record< + CaipAssetType, + FungibleAssetMarketData | undefined + > = {}; + + for (const asset of assets) { + const assetMarketData = response.marketData?.[asset]?.[currency]; + + // We do not consider NFTs here, so `fungible` must be `true`. + if (assetMarketData?.fungible) { + assetToMarketData[asset] = assetMarketData; + } else { + assetToMarketData[asset] = undefined; + } + } + + return assetToMarketData; + } + + /** + * Fetches the updated rates for the given assets from the given Snaps. + * + * @param snapIdToAssets - A map of Snap ID to CAIP-19 asset types. + * @returns A record of CAIP-19 asset types to unified asset conversions. + */ + async #getUpdatedRatesFor( + snapIdToAssets: Map>, + ): Promise< + Record + > { + const updatedRates: Record< + CaipAssetType, + UnifiedAssetConversion & { currency: CaipAssetType } + > = {}; + + // Keep a local copy to ensure that the currency is always the same for the + // entire loop. + const currency = this.#getCaipCurrentCurrency(); + + // Note: Since the assets come from a 1-to-1 mapping with Snap IDs, we know + // that a given asset will not appear under multiple Snap IDs. + for (const [snapId, assets] of snapIdToAssets.entries()) { + const [rates, marketData] = await Promise.all([ + this.#getConversionRates(snapId, assets, currency), + this.#getMarketData(snapId, assets, currency), + ]); + + for (const asset of assets) { + const assetRate = rates[asset]; + const assetMarketData = marketData[asset]; + + // Rates are mandatory, so skip the asset if not available. + if (!assetRate) { + continue; + } + + updatedRates[asset] = { + currency, + ...assetRate, + ...(assetMarketData && { marketData: assetMarketData }), + }; + } + } + + return updatedRates; + } + + /** + * Fetches historical prices for the current account + * + * @param asset - The asset to fetch historical prices for. + * @param account - optional account to fetch historical prices for + * @returns The historical prices. + */ + async fetchHistoricalPricesForAsset( + asset: CaipAssetType, + account?: InternalAccount, + ): Promise { + if (this.#isDeprecated()) { + this.#enforceDisabledState(); + return Promise.resolve(); + } + + const releaseLock = await this.#mutex.acquire(); + return (async () => { + const currentCaipCurrency = + MAP_CAIP_CURRENCIES[this.#currentCurrency] ?? MAP_CAIP_CURRENCIES.usd; + // Check if we already have historical prices for this asset and currency + const historicalPriceExpirationTime = + this.state.historicalPrices[asset]?.[this.#currentCurrency] + ?.expirationTime; + + const historicalPriceHasExpired = + historicalPriceExpirationTime && + historicalPriceExpirationTime < Date.now(); + + if (historicalPriceHasExpired === false) { + return; + } + + const selectedAccount = + account ?? + this.messenger.call('AccountsController:getSelectedMultichainAccount'); + try { + const historicalPricesResponse = await this.messenger.call( + 'SnapController:handleRequest', + { + snapId: selectedAccount?.metadata.snap?.id as SnapId, + origin: 'metamask', + handler: HandlerType.OnAssetHistoricalPrice, + request: { + jsonrpc: '2.0', + method: HandlerType.OnAssetHistoricalPrice, + params: { + from: asset, + to: currentCaipCurrency, + }, + }, + }, + ); + + // skip state update if no historical prices are returned + if (!historicalPricesResponse) { + return; + } + + this.update((state) => { + state.historicalPrices = { + ...state.historicalPrices, + [asset]: { + ...state.historicalPrices[asset], + [this.#currentCurrency]: ( + historicalPricesResponse as OnAssetHistoricalPriceResponse + )?.historicalPrice, + }, + }; + }); + } catch { + throw new Error( + `Failed to fetch historical prices for asset: ${asset}`, + ); + } + })().finally(() => { + releaseLock(); + }); + } + + /** + * Reconciles cached rates after an account asset-list update event. + * + * The event payload is treated as a delta: + * - `added` assets are batched by Snap and fetched for fresh rates + * - `removed` assets are deleted from cached state only if they are no longer tracked by any account + * + * This global check is required because rate state is keyed by asset rather + * than by account, so the same asset may still be shared by another account. + * + * @param accounts - The per-account asset deltas from the asset-list update event. + * @returns A promise that resolves when the rates are updated. + */ + async #updateAssetsRatesForNewAssets( + accounts: { + accountId: string; + added: CaipAssetType[]; + removed: CaipAssetType[]; + }[], + ): Promise { + if (this.#isDeprecated()) { + this.#enforceDisabledState(); + return Promise.resolve(); + } + + const releaseLock = await this.#mutex.acquire(); + + return (async () => { + if (!this.isActive) { + return; + } + + // First build a map containing all assets that need to be updated per + // Snap ID, this will be used to batch the requests. + const snapIdToAssets = new Map>(); + const removedAssets = new Set(); + + for (const { accountId, added, removed } of accounts) { + if (added.length !== 0) { + // Only newly added assets need fresh rate requests. + this.#addAssetsToSnapIdMap( + snapIdToAssets, + this.#getAccount(accountId), + added, + ); + } + + // Collect removed assets separately so we can decide later whether they + // are truly stale or still referenced by another account. + for (const asset of removed) { + removedAssets.add(asset); + } + } + + const updatedRates = await this.#getUpdatedRatesFor(snapIdToAssets); + // Rates are stored globally by asset, so delete only assets that no + // longer exist in any account asset list. + const assetsToDelete = Array.from(removedAssets).filter( + (asset) => !this.#isAssetTracked(asset), + ); + + this.#applyUpdatedRates(updatedRates, assetsToDelete); + })().finally(() => { + releaseLock(); + }); + } + + /** + * Get a non-EVM account from its ID. + * + * @param accountId - The account ID. + * @returns The non-EVM account. + */ + #getAccount(accountId: string): InternalAccount { + const account: InternalAccount | undefined = this.#listAccounts().find( + (multichainAccount) => multichainAccount.id === accountId, + ); + + if (!account) { + throw new Error(`Unknown account: ${accountId}`); + } + + return account; + } + + /** + * Returns the array of CAIP-19 assets for the given account ID. + * If none are found, returns an empty array. + * + * @param accountId - The account ID to get the assets for. + * @returns An array of CAIP-19 assets. + */ + #getAssetsForAccount(accountId: string): CaipAssetType[] { + // Always fetch fresh state - MultichainAssetsController uses Immer which creates + // new object references on every update, so caching would become stale. + const { accountsAssets } = this.messenger.call( + 'MultichainAssetsController:getState', + ); + return accountsAssets?.[accountId] ?? []; + } + + /** + * Applies fresh rates and removes stale asset-rate state in one update. + * + * @param updatedRates - The latest conversion rates fetched for added assets. + * @param removedAssets - Assets that should be purged because they are no longer tracked by any account. + */ + #applyUpdatedRates( + updatedRates: Record< + CaipAssetType, + UnifiedAssetConversion & { currency: CaipAssetType } + >, + removedAssets: CaipAssetType[] = [], + ): void { + if (Object.keys(updatedRates).length === 0 && removedAssets.length === 0) { + return; + } + this.update((state: Draft) => { + // Drop both current rates and historical prices for assets that are no + // longer referenced anywhere in MultichainAssetsController state. + for (const asset of removedAssets) { + delete state.conversionRates[asset]; + delete state.historicalPrices[asset]; + } + + // Merge the freshly fetched rates after cleanup. + state.conversionRates = { + ...state.conversionRates, + ...updatedRates, + }; + }); + } + + /** + * Checks whether an asset is still tracked by any account in + * MultichainAssetsController state. + * + * @param asset - The asset to check. + * @returns True if the asset still exists in any account asset list. + */ + #isAssetTracked(asset: CaipAssetType): boolean { + const { accountsAssets } = this.messenger.call( + 'MultichainAssetsController:getState', + ); + + // Rate state is global per asset, so inspect all account asset lists before + // deciding whether a removed asset should be purged from cache. + return Object.values(accountsAssets ?? {}).some((accountAssets) => + accountAssets.includes(asset), + ); + } + + /** + * Forwards a Snap request to the SnapController. + * + * @param args - The request parameters. + * @param args.snapId - The ID of the Snap. + * @param args.handler - The handler type. + * @param args.params - The asset conversions. + * @returns A promise that resolves with the account rates. + */ + async #handleSnapRequest( + args: SnapRequestArgs, + ): Promise; + + async #handleSnapRequest( + args: SnapRequestArgs, + ): Promise; + + async #handleSnapRequest( + args: SnapRequestArgs, + ): Promise; + + async #handleSnapRequest(args: SnapRequestArgs): Promise { + const { snapId, handler, params } = args; + try { + return await this.messenger.call('SnapController:handleRequest', { + snapId, + origin: 'metamask', + handler, + request: { + jsonrpc: '2.0', + method: handler, + params, + }, + }); + } catch (error) { + console.error(`Snap request failed for ${handler}:`, { + snapId, + handler, + message: (error as Error).message, + params, + }); + return undefined; + } + } +} diff --git a/packages/assets-controllers/src/MultichainAssetsRatesController/constant.ts b/packages/assets-controllers/src/MultichainAssetsRatesController/constant.ts new file mode 100644 index 00000000000..2fef0e8155d --- /dev/null +++ b/packages/assets-controllers/src/MultichainAssetsRatesController/constant.ts @@ -0,0 +1,92 @@ +import type { CaipAssetType } from '@metamask/utils'; + +/** + * Maps each SUPPORTED_CURRENCIES entry to its CAIP-19 (or CAIP-like) identifier. + * For fiat, we mimic the old “swift:0/iso4217:XYZ” style. + */ +export const MAP_CAIP_CURRENCIES: { + [key: string]: CaipAssetType; +} = { + // ======================== + // Native crypto assets + // ======================== + btc: 'bip122:000000000019d6689c085ae165831e93/slip44:0', + eth: 'eip155:1/slip44:60', + ltc: 'bip122:12a765e31ffd4059bada1e25190f6e98/slip44:2', + + // Bitcoin Cash + bch: 'bip122:000000000000000000651ef99cb9fcbe/slip44:145', + + // Binance Coin + bnb: 'cosmos:Binance-Chain-Tigris/slip44:714', + + // EOS mainnet (chainId = aca376f2...) + eos: 'eos:aca376f2/slip44:194', + + // XRP mainnet + xrp: 'xrpl:mainnet/slip44:144', + + // Stellar Lumens mainnet + xlm: 'stellar:pubnet/slip44:148', + + // Chainlink (ERC20 on Ethereum mainnet) + link: 'eip155:1/erc20:0x514910771af9Ca656af840dff83E8264EcF986CA', + + // Polkadot (chainId = 91b171bb158e2d3848fa23a9f1c25182) + dot: 'polkadot:91b171bb158e2d3848fa23a9f1c25182/slip44:354', + + // Yearn.finance (ERC20 on Ethereum mainnet) + yfi: 'eip155:1/erc20:0x0bc529c00C6401aEF6D220BE8C6Ea1667F6Ad93e', + + // ======================== + // Fiat currencies + // ======================== + usd: 'swift:0/iso4217:USD', + aed: 'swift:0/iso4217:AED', + ars: 'swift:0/iso4217:ARS', + aud: 'swift:0/iso4217:AUD', + bdt: 'swift:0/iso4217:BDT', + bhd: 'swift:0/iso4217:BHD', + bmd: 'swift:0/iso4217:BMD', + brl: 'swift:0/iso4217:BRL', + cad: 'swift:0/iso4217:CAD', + chf: 'swift:0/iso4217:CHF', + clp: 'swift:0/iso4217:CLP', + cny: 'swift:0/iso4217:CNY', + czk: 'swift:0/iso4217:CZK', + dkk: 'swift:0/iso4217:DKK', + eur: 'swift:0/iso4217:EUR', + gbp: 'swift:0/iso4217:GBP', + hkd: 'swift:0/iso4217:HKD', + huf: 'swift:0/iso4217:HUF', + idr: 'swift:0/iso4217:IDR', + ils: 'swift:0/iso4217:ILS', + inr: 'swift:0/iso4217:INR', + jpy: 'swift:0/iso4217:JPY', + krw: 'swift:0/iso4217:KRW', + kwd: 'swift:0/iso4217:KWD', + lkr: 'swift:0/iso4217:LKR', + mmk: 'swift:0/iso4217:MMK', + mxn: 'swift:0/iso4217:MXN', + myr: 'swift:0/iso4217:MYR', + ngn: 'swift:0/iso4217:NGN', + nok: 'swift:0/iso4217:NOK', + nzd: 'swift:0/iso4217:NZD', + php: 'swift:0/iso4217:PHP', + pkr: 'swift:0/iso4217:PKR', + pln: 'swift:0/iso4217:PLN', + rub: 'swift:0/iso4217:RUB', + sar: 'swift:0/iso4217:SAR', + sek: 'swift:0/iso4217:SEK', + sgd: 'swift:0/iso4217:SGD', + thb: 'swift:0/iso4217:THB', + try: 'swift:0/iso4217:TRY', + twd: 'swift:0/iso4217:TWD', + uah: 'swift:0/iso4217:UAH', + vef: 'swift:0/iso4217:VEF', + vnd: 'swift:0/iso4217:VND', + zar: 'swift:0/iso4217:ZAR', + xdr: 'swift:0/iso4217:XDR', + xag: 'swift:0/iso4217:XAG', + xau: 'swift:0/iso4217:XAU', +}; diff --git a/packages/assets-controllers/src/MultichainAssetsRatesController/index.ts b/packages/assets-controllers/src/MultichainAssetsRatesController/index.ts new file mode 100644 index 00000000000..ea14145b845 --- /dev/null +++ b/packages/assets-controllers/src/MultichainAssetsRatesController/index.ts @@ -0,0 +1,18 @@ +export type { + MultichainAssetsRatesControllerState, + MultichainAssetsRatesControllerActions, + MultichainAssetsRatesControllerEvents, + MultichainAssetsRatesControllerGetStateAction, + MultichainAssetsRatesControllerStateChange, + MultichainAssetsRatesControllerMessenger, +} from './MultichainAssetsRatesController.js'; +export type { + MultichainAssetsRatesControllerUpdateAssetsRatesAction, + MultichainAssetsRatesControllerFetchHistoricalPricesForAssetAction, +} from './MultichainAssetsRatesController-method-action-types.js'; + +export { + MultichainAssetsRatesController, + getDefaultMultichainAssetsRatesControllerState, +} from './MultichainAssetsRatesController.js'; +export { MAP_CAIP_CURRENCIES } from './constant.js'; diff --git a/packages/assets-controllers/src/MultichainBalancesController/MultichainBalancesController.test.ts b/packages/assets-controllers/src/MultichainBalancesController/MultichainBalancesController.test.ts new file mode 100644 index 00000000000..9b94452292b --- /dev/null +++ b/packages/assets-controllers/src/MultichainBalancesController/MultichainBalancesController.test.ts @@ -0,0 +1,1066 @@ +import { deriveStateFromMetadata } from '@metamask/base-controller'; +import type { Balance, CaipAssetType } from '@metamask/keyring-api'; +import { + BtcAccountType, + BtcMethod, + EthAccountType, + EthMethod, + BtcScope, + EthScope, + SolScope, + SolMethod, + SolAccountType, +} from '@metamask/keyring-api'; +import { KeyringTypes } from '@metamask/keyring-controller'; +import type { InternalAccount } from '@metamask/keyring-internal-api'; +import { MOCK_ANY_NAMESPACE, Messenger } from '@metamask/messenger'; +import type { + MessengerActions, + MessengerEvents, + MockAnyNamespace, +} from '@metamask/messenger'; +import { v4 as uuidv4 } from 'uuid'; + +import { MultichainBalancesController } from './index.js'; +import type { + MultichainBalancesControllerMessenger, + MultichainBalancesControllerState, +} from './index.js'; +import { getDefaultMultichainBalancesControllerState } from './MultichainBalancesController.js'; + +const mockBtcAccount = { + address: 'bc1qssdcp5kvwh6nghzg9tuk99xsflwkdv4hgvq58q', + id: uuidv4(), + metadata: { + name: 'Bitcoin Account 1', + importTime: Date.now(), + keyring: { + type: KeyringTypes.snap, + }, + snap: { + id: 'mock-btc-snap', + name: 'mock-btc-snap', + enabled: true, + }, + lastSelected: 0, + }, + scopes: [BtcScope.Testnet], + options: {}, + methods: Object.values(BtcMethod), + type: BtcAccountType.P2wpkh, +}; + +const mockSolAccount = { + address: 'EBBYfhQzVzurZiweJ2keeBWpgGLs1cbWYcz28gjGgi5x', + id: uuidv4(), + metadata: { + name: 'Solana Account 1', + importTime: Date.now(), + keyring: { + type: KeyringTypes.snap, + }, + snap: { + id: 'mock-sol-snap', + name: 'mock-sol-snap', + enabled: true, + }, + lastSelected: 0, + }, + scopes: [SolScope.Devnet], + options: {}, + methods: [SolMethod.SendAndConfirmTransaction], + type: SolAccountType.DataAccount, +}; + +const mockEthAccount = { + address: '0x807dE1cf8f39E83258904b2f7b473E5C506E4aC1', + id: uuidv4(), + metadata: { + name: 'Ethereum Account 1', + importTime: Date.now(), + keyring: { + type: KeyringTypes.snap, + }, + snap: { + id: 'mock-eth-snap', + name: 'mock-eth-snap', + enabled: true, + }, + lastSelected: 0, + }, + scopes: [EthScope.Eoa], + options: {}, + methods: [EthMethod.SignTypedDataV4, EthMethod.SignTransaction], + type: EthAccountType.Eoa, +}; + +const mockBtcNativeAsset = 'bip122:000000000933ea01ad0ee984209779ba/slip44:0'; +const mockBalanceResult = { + [mockBtcNativeAsset]: { + amount: '1.00000000', + unit: 'BTC', + }, +}; + +/** + * The union of actions that the root messenger allows. + */ +type RootAction = MessengerActions; + +/** + * The union of events that the root messenger allows. + */ +type RootEvent = MessengerEvents; + +/** + * The root messenger type + */ +type RootMessenger = Messenger; + +/** + * Constructs the root messenger. This can be used to call actions and + * publish events within the tests for this controller. + * + * @returns The root messenger suited for MultichainBalancesController. + */ +function getRootMessenger(): RootMessenger { + return new Messenger({ namespace: MOCK_ANY_NAMESPACE }); +} + +/** + * Constructs the restricted messenger for the MultichainBalancesController. + * + * @param messenger - The root messenger. + * @returns The unrestricted messenger suited for MultichainBalancesController. + */ +function getRestrictedMessenger( + messenger: RootMessenger, +): MultichainBalancesControllerMessenger { + const multichainBalancesControllerMessenger = new Messenger< + 'MultichainBalancesController', + RootAction, + RootEvent, + RootMessenger + >({ + namespace: 'MultichainBalancesController', + parent: messenger, + }); + messenger.delegate({ + messenger: multichainBalancesControllerMessenger, + actions: [ + 'SnapController:handleRequest', + 'AccountsController:listMultichainAccounts', + 'MultichainAssetsController:getState', + 'KeyringController:getState', + ], + events: [ + 'AccountsController:accountAdded', + 'AccountsController:accountRemoved', + 'AccountsController:accountBalancesUpdated', + 'MultichainAssetsController:accountAssetListUpdated', + ], + }); + return multichainBalancesControllerMessenger; +} + +const setupController = ({ + state = getDefaultMultichainBalancesControllerState(), + isDeprecated, + mocks, +}: { + state?: MultichainBalancesControllerState; + isDeprecated?: () => boolean; + mocks?: { + listMultichainAccounts?: InternalAccount[]; + handleRequestReturnValue?: Record; + handleMockGetAssetsState?: { + accountsAssets: { + [account: string]: CaipAssetType[]; + }; + }; + }; +} = {}) => { + const messenger = getRootMessenger(); + const multichainBalancesMessenger = getRestrictedMessenger(messenger); + + const mockSnapHandleRequest = jest.fn(); + messenger.registerActionHandler( + 'SnapController:handleRequest', + mockSnapHandleRequest.mockReturnValue( + mocks?.handleRequestReturnValue ?? mockBalanceResult, + ), + ); + + const mockListMultichainAccounts = jest.fn(); + messenger.registerActionHandler( + 'AccountsController:listMultichainAccounts', + mockListMultichainAccounts.mockReturnValue( + mocks?.listMultichainAccounts ?? [mockBtcAccount, mockEthAccount], + ), + ); + + const mockGetAssetsState = jest.fn().mockReturnValue( + mocks?.handleMockGetAssetsState ?? { + accountsAssets: { + [mockBtcAccount.id]: [mockBtcNativeAsset], + }, + }, + ); + messenger.registerActionHandler( + 'MultichainAssetsController:getState', + mockGetAssetsState, + ); + + const mockGetKeyringState = jest.fn().mockReturnValue({ + isUnlocked: true, + }); + messenger.registerActionHandler( + 'KeyringController:getState', + mockGetKeyringState, + ); + const controller = new MultichainBalancesController({ + messenger: multichainBalancesMessenger, + state, + ...(isDeprecated ? { isDeprecated } : {}), + }); + + return { + controller, + messenger, + mockSnapHandleRequest, + mockListMultichainAccounts, + mockGetAssetsState, + mockGetKeyringState, + }; +}; + +/** + * Utility function that waits for all pending promises to be resolved. + * This is necessary when testing asynchronous execution flows that are + * initiated by synchronous calls. + * + * @returns A promise that resolves when all pending promises are completed. + */ +async function waitForAllPromises(): Promise { + // Wait for next tick to flush all pending promises. It's requires since + // we are testing some asynchronous execution flows that are started by + // synchronous calls. + await new Promise(process.nextTick); +} + +describe('MultichainBalancesController', () => { + it('initialize with default state', () => { + const messenger = getRootMessenger(); + const multichainBalancesMessenger = getRestrictedMessenger(messenger); + + messenger.registerActionHandler('SnapController:handleRequest', jest.fn()); + messenger.registerActionHandler( + 'AccountsController:listMultichainAccounts', + jest.fn().mockReturnValue([]), + ); + messenger.registerActionHandler( + 'MultichainAssetsController:getState', + jest.fn(), + ); + messenger.registerActionHandler( + 'KeyringController:getState', + jest.fn().mockReturnValue({ isUnlocked: true }), + ); + + const controller = new MultichainBalancesController({ + messenger: multichainBalancesMessenger, + }); + expect(controller.state).toStrictEqual({ balances: {} }); + }); + + it('updates the balance for a specific account', async () => { + const { controller } = setupController(); + await controller.updateBalance(mockBtcAccount.id); + + expect(controller.state.balances[mockBtcAccount.id]).toStrictEqual( + mockBalanceResult, + ); + }); + + it('updates balances when "AccountsController:accountRemoved" is fired', async () => { + const { controller, messenger } = setupController(); + + await controller.updateBalance(mockBtcAccount.id); + expect(controller.state).toStrictEqual({ + balances: { + [mockBtcAccount.id]: mockBalanceResult, + }, + }); + + messenger.publish('AccountsController:accountRemoved', mockBtcAccount.id); + + expect(controller.state).toStrictEqual({ + balances: {}, + }); + }); + + it('does not track balances for EVM accounts', async () => { + const { controller, messenger, mockListMultichainAccounts } = + setupController({ + mocks: { + listMultichainAccounts: [], + }, + }); + + mockListMultichainAccounts.mockReturnValue([mockEthAccount]); + messenger.publish('AccountsController:accountAdded', mockEthAccount); + + expect(controller.state).toStrictEqual({ + balances: {}, + }); + }); + + it('handles errors gracefully when updating balance', async () => { + const { controller, mockSnapHandleRequest, mockListMultichainAccounts } = + setupController({ + mocks: { + listMultichainAccounts: [], + }, + }); + + mockSnapHandleRequest.mockReset(); + mockSnapHandleRequest.mockImplementation(() => + Promise.reject(new Error('Failed to fetch')), + ); + mockListMultichainAccounts.mockReturnValue([mockBtcAccount]); + + await controller.updateBalance(mockBtcAccount.id); + await waitForAllPromises(); + + expect(controller.state.balances).toStrictEqual({}); + }); + + it('handles errors gracefully when account could not be found', async () => { + const { controller } = setupController({ + mocks: { + listMultichainAccounts: [], + }, + }); + + await controller.updateBalance(mockBtcAccount.id); + await waitForAllPromises(); + + expect(controller.state.balances).toStrictEqual({}); + }); + + it('handles errors when trying to upgrade the balance of a non-existing account', async () => { + const { controller } = setupController({ + mocks: { + listMultichainAccounts: [mockBtcAccount], + }, + }); + + // Solana account is not registered, so this should not update anything for this account + await controller.updateBalance(mockSolAccount.id); + expect(controller.state.balances).toStrictEqual({}); + }); + + it('stores balances when receiving new balances from the "AccountsController:accountBalancesUpdated" event', async () => { + const { controller, messenger } = setupController(); + const balanceUpdate = { + balances: { + [mockBtcAccount.id]: mockBalanceResult, + }, + }; + + messenger.publish( + 'AccountsController:accountBalancesUpdated', + balanceUpdate, + ); + + await waitForAllPromises(); + + expect(controller.state.balances[mockBtcAccount.id]).toStrictEqual( + mockBalanceResult, + ); + }); + + it('updates balances when receiving "AccountsController:accountBalancesUpdated" event', async () => { + const mockInitialBalances = { + [mockBtcNativeAsset]: { + amount: '0.00000000', + unit: 'BTC', + }, + }; + // Just to make sure we will run a "true update", we want to make the + // initial state is different from the updated one. + expect(mockInitialBalances).not.toStrictEqual(mockBalanceResult); + + const { controller, messenger } = setupController({ + state: { + balances: { + [mockBtcAccount.id]: mockInitialBalances, + }, + }, + }); + const balanceUpdate = { + balances: { + [mockBtcAccount.id]: mockBalanceResult, + }, + }; + + messenger.publish( + 'AccountsController:accountBalancesUpdated', + balanceUpdate, + ); + + await waitForAllPromises(); + + expect(controller.state.balances[mockBtcAccount.id]).toStrictEqual( + mockBalanceResult, + ); + }); + + it('fetches initial balances for existing non-EVM accounts', async () => { + const { controller } = setupController({ + mocks: { + listMultichainAccounts: [mockBtcAccount], + }, + }); + + await waitForAllPromises(); + + expect(controller.state.balances[mockBtcAccount.id]).toStrictEqual( + mockBalanceResult, + ); + }); + + it('handles an account with no assets in MultichainAssetsController state', async () => { + const { controller, mockGetAssetsState } = setupController({ + mocks: { + handleRequestReturnValue: {}, + }, + }); + + mockGetAssetsState.mockReturnValue({ + accountsAssets: {}, + }); + + await controller.updateBalance(mockBtcAccount.id); + + expect(controller.state.balances[mockBtcAccount.id]).toStrictEqual({}); + }); + + describe('when "MultichainAssetsController:accountAssetListUpdated" is fired', () => { + const mockListSolanaAccounts = [ + { + address: 'EBBYfhQzVzurZiweJ2keeBWpgGLs1cbWYcz28gjGgi5x', + id: uuidv4(), + metadata: { + name: 'Solana Account 1', + importTime: Date.now(), + keyring: { + type: KeyringTypes.snap, + }, + snap: { + id: 'mock-sol-snap', + name: 'mock-sol-snap', + enabled: true, + }, + lastSelected: 0, + }, + scopes: [SolScope.Devnet], + options: {}, + methods: [SolMethod.SendAndConfirmTransaction], + type: SolAccountType.DataAccount, + }, + { + address: 'GMTYfhQzVzurZiweJ2keeBWpgGLs1cbWYcz28gjGgi5x', + id: uuidv4(), + metadata: { + name: 'Solana Account 2', + importTime: Date.now(), + keyring: { + type: KeyringTypes.snap, + }, + snap: { + id: 'mock-sol-snap', + name: 'mock-sol-snap', + enabled: true, + }, + lastSelected: 0, + }, + scopes: [SolScope.Devnet], + options: {}, + methods: [SolMethod.SendAndConfirmTransaction], + type: SolAccountType.DataAccount, + }, + ]; + + it('updates balances when receiving "MultichainAssetsController:accountAssetListUpdated" event and state is empty', async () => { + const mockSolanaAccountId1 = mockListSolanaAccounts[0].id; + const mockSolanaAccountId2 = mockListSolanaAccounts[1].id; + + const { controller, messenger, mockSnapHandleRequest } = setupController({ + state: { + balances: {}, + }, + mocks: { + handleMockGetAssetsState: { + accountsAssets: {}, + }, + handleRequestReturnValue: {}, + listMultichainAccounts: mockListSolanaAccounts, + }, + }); + + mockSnapHandleRequest.mockReset(); + mockSnapHandleRequest + .mockResolvedValueOnce({ + 'solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1/token:newToken': { + amount: '1.00000000', + unit: 'SOL', + }, + }) + .mockResolvedValueOnce({ + 'solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1/token:newToken3': { + amount: '3.00000000', + unit: 'SOL', + }, + }); + messenger.publish('MultichainAssetsController:accountAssetListUpdated', { + assets: { + [mockSolanaAccountId1]: { + added: ['solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1/token:newToken'], + removed: [], + }, + [mockSolanaAccountId2]: { + added: ['solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1/token:newToken3'], + removed: [] as `${string}:${string}/${string}:${string}`[], + }, + }, + }); + + await waitForAllPromises(); + + expect(controller.state.balances).toStrictEqual({ + [mockSolanaAccountId1]: { + 'solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1/token:newToken': { + amount: '1.00000000', + unit: 'SOL', + }, + }, + [mockSolanaAccountId2]: { + 'solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1/token:newToken3': { + amount: '3.00000000', + unit: 'SOL', + }, + }, + }); + }); + + it('updates balances when receiving "MultichainAssetsController:accountAssetListUpdated" event and state has existing balances', async () => { + const mockSolanaAccountId1 = mockListSolanaAccounts[0].id; + const mockSolanaAccountId2 = mockListSolanaAccounts[1].id; + + const existingBalancesState = { + [mockSolanaAccountId1]: { + 'solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1/token:newToken55': { + amount: '5.00000000', + unit: 'SOL', + }, + }, + }; + const { + controller, + messenger, + mockSnapHandleRequest, + mockListMultichainAccounts, + } = setupController({ + state: { + balances: existingBalancesState, + }, + mocks: { + handleMockGetAssetsState: { + accountsAssets: { + [mockSolanaAccountId1]: [ + 'solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1/token:newToken55', + ], + }, + }, + handleRequestReturnValue: { + 'solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1/token:newToken55': { + amount: '55.00000000', + unit: 'SOL', + }, + }, + listMultichainAccounts: [mockListSolanaAccounts[0]], + }, + }); + + mockSnapHandleRequest.mockReset(); + mockListMultichainAccounts.mockReset(); + + mockListMultichainAccounts.mockReturnValue(mockListSolanaAccounts); + mockSnapHandleRequest + .mockResolvedValueOnce({ + 'solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1/token:newToken': { + amount: '1.00000000', + unit: 'SOL', + }, + }) + .mockResolvedValueOnce({ + 'solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1/token:newToken3': { + amount: '3.00000000', + unit: 'SOL', + }, + }); + + messenger.publish('MultichainAssetsController:accountAssetListUpdated', { + assets: { + [mockSolanaAccountId1]: { + added: ['solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1/token:newToken'], + removed: [], + }, + [mockSolanaAccountId2]: { + added: ['solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1/token:newToken3'], + removed: [], + }, + }, + }); + + await waitForAllPromises(); + + expect(controller.state.balances).toStrictEqual({ + [mockSolanaAccountId1]: { + 'solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1/token:newToken': { + amount: '1.00000000', + unit: 'SOL', + }, + 'solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1/token:newToken55': { + amount: '55.00000000', + unit: 'SOL', + }, + }, + [mockSolanaAccountId2]: { + 'solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1/token:newToken3': { + amount: '3.00000000', + unit: 'SOL', + }, + }, + }); + }); + + it('sets balance to zero for assets that were added but have no balance from snap', async () => { + const mockSolanaAccountId1 = mockListSolanaAccounts[0].id; + + const existingBalancesState = { + [mockSolanaAccountId1]: { + 'solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1/token:existingToken': { + amount: '5.00000000', + unit: 'SOL', + }, + }, + }; + + const { + controller, + messenger, + mockSnapHandleRequest, + mockListMultichainAccounts, + } = setupController({ + state: { + balances: existingBalancesState, + }, + mocks: { + handleMockGetAssetsState: { + accountsAssets: { + [mockSolanaAccountId1]: [ + 'solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1/token:existingToken', + ], + }, + }, + handleRequestReturnValue: { + 'solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1/token:existingToken': { + amount: '5.00000000', + unit: 'SOL', + }, + }, + listMultichainAccounts: [mockListSolanaAccounts[0]], + }, + }); + + mockSnapHandleRequest.mockReset(); + mockListMultichainAccounts.mockReset(); + + mockListMultichainAccounts.mockReturnValue(mockListSolanaAccounts); + + // Mock snap returning balance for only one asset, not the newly added ones + mockSnapHandleRequest.mockResolvedValueOnce({ + 'solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1/token:newTokenWithBalance': { + amount: '1.00000000', + unit: 'SOL', + }, + // Note: newTokenWithoutBalance is not returned by snap, so it should get 0 balance + }); + + // Simulate adding assets where some have balance and some don't + messenger.publish('MultichainAssetsController:accountAssetListUpdated', { + assets: { + [mockSolanaAccountId1]: { + added: [ + 'solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1/token:newTokenWithBalance', + 'solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1/token:newTokenWithoutBalance', + ], + removed: [], + }, + }, + }); + + await waitForAllPromises(); + + expect(controller.state.balances).toStrictEqual({ + [mockSolanaAccountId1]: { + // Existing balance should remain + 'solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1/token:existingToken': { + amount: '5.00000000', + unit: 'SOL', + }, + // New asset with balance from snap + 'solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1/token:newTokenWithBalance': { + amount: '1.00000000', + unit: 'SOL', + }, + // New asset without balance from snap should get zero balance + 'solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1/token:newTokenWithoutBalance': + { + amount: '0', + unit: '', + }, + }, + }); + }); + + it('removes stale balances that are no longer present in MultichainAssetsController state', async () => { + const mockSolanaAccountId1 = mockListSolanaAccounts[0].id; + + const existingBalancesState = { + [mockSolanaAccountId1]: { + 'solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1/token:removedToken': { + amount: '5.00000000', + unit: 'SOL', + }, + 'solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1/token:keptToken': { + amount: '6.00000000', + unit: 'SOL', + }, + }, + }; + + const { + controller, + messenger, + mockGetAssetsState, + mockSnapHandleRequest, + mockListMultichainAccounts, + } = setupController({ + state: { + balances: existingBalancesState, + }, + mocks: { + handleMockGetAssetsState: { + accountsAssets: { + [mockSolanaAccountId1]: [ + 'solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1/token:removedToken', + 'solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1/token:keptToken', + ], + }, + }, + handleRequestReturnValue: { + 'solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1/token:keptToken': { + amount: '6.00000000', + unit: 'SOL', + }, + }, + listMultichainAccounts: [], + }, + }); + + mockSnapHandleRequest.mockReset(); + mockListMultichainAccounts.mockReset(); + + mockListMultichainAccounts.mockReturnValue(mockListSolanaAccounts); + mockGetAssetsState.mockReturnValue({ + accountsAssets: { + [mockSolanaAccountId1]: [ + 'solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1/token:keptToken', + ], + }, + }); + mockSnapHandleRequest.mockResolvedValueOnce({ + 'solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1/token:keptToken': { + amount: '6.00000000', + unit: 'SOL', + }, + }); + + messenger.publish('MultichainAssetsController:accountAssetListUpdated', { + assets: { + [mockSolanaAccountId1]: { + added: ['solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1/token:keptToken'], + removed: [ + 'solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1/token:removedToken', + ], + }, + }, + }); + + await waitForAllPromises(); + + expect(controller.state.balances).toStrictEqual({ + [mockSolanaAccountId1]: { + 'solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1/token:keptToken': { + amount: '6.00000000', + unit: 'SOL', + }, + }, + }); + }); + + it('clears balances when an account no longer has any assets after the update', async () => { + const mockSolanaAccountId1 = mockListSolanaAccounts[0].id; + + const { + controller, + messenger, + mockGetAssetsState, + mockListMultichainAccounts, + } = setupController({ + state: { + balances: { + [mockSolanaAccountId1]: { + 'solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1/token:removedToken': { + amount: '5.00000000', + unit: 'SOL', + }, + }, + }, + }, + mocks: { + listMultichainAccounts: [], + handleMockGetAssetsState: { + accountsAssets: { + [mockSolanaAccountId1]: [], + }, + }, + handleRequestReturnValue: {}, + }, + }); + + mockGetAssetsState.mockReturnValue({ + accountsAssets: { + [mockSolanaAccountId1]: [], + }, + }); + mockListMultichainAccounts.mockReset(); + mockListMultichainAccounts.mockReturnValue(mockListSolanaAccounts); + + messenger.publish('MultichainAssetsController:accountAssetListUpdated', { + assets: { + [mockSolanaAccountId1]: { + added: [], + removed: [ + 'solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1/token:removedToken', + ], + }, + }, + }); + + await waitForAllPromises(); + + expect(controller.state.balances).toStrictEqual({ + [mockSolanaAccountId1]: {}, + }); + }); + }); + + it('resumes updating balances after unlocking KeyringController', async () => { + const { controller, mockGetKeyringState } = setupController(); + + mockGetKeyringState.mockReturnValue({ isUnlocked: false }); + + await controller.updateBalance(mockBtcAccount.id); + expect(controller.state.balances[mockBtcAccount.id]).toBeUndefined(); + + mockGetKeyringState.mockReturnValue({ isUnlocked: true }); + + await controller.updateBalance(mockBtcAccount.id); + expect(controller.state.balances[mockBtcAccount.id]).toStrictEqual( + mockBalanceResult, + ); + }); + + describe('isDeprecated', () => { + const initialState: MultichainBalancesControllerState = { + balances: { + [mockBtcAccount.id]: mockBalanceResult, + }, + }; + + it('clears persisted balances at construction when isDeprecated() returns true', () => { + const { controller } = setupController({ + state: initialState, + isDeprecated: () => true, + }); + + expect(controller.state.balances).toStrictEqual({}); + }); + + it('preserves persisted balances at construction when isDeprecated() returns false', () => { + const { controller } = setupController({ + state: initialState, + isDeprecated: () => false, + }); + + expect(controller.state.balances).toStrictEqual(initialState.balances); + }); + + it('does not fetch initial balances at construction when isDeprecated() returns true', async () => { + const { mockSnapHandleRequest } = setupController({ + isDeprecated: () => true, + }); + + await waitForAllPromises(); + + expect(mockSnapHandleRequest).not.toHaveBeenCalled(); + }); + + it('does not fetch and clears stale balances when isDeprecated returns true', async () => { + let deprecated = false; + const { controller, mockSnapHandleRequest } = setupController({ + state: initialState, + isDeprecated: () => deprecated, + }); + + await waitForAllPromises(); + mockSnapHandleRequest.mockClear(); + + deprecated = true; + + await controller.updateBalance(mockBtcAccount.id); + + expect(mockSnapHandleRequest).not.toHaveBeenCalled(); + expect(controller.state.balances).toStrictEqual({}); + }); + + it('clears stale balances on MultichainAssetsController:accountAssetListUpdated when isDeprecated toggles to true at runtime', async () => { + let deprecated = false; + const { controller, messenger } = setupController({ + state: initialState, + isDeprecated: () => deprecated, + }); + + await waitForAllPromises(); + + deprecated = true; + + messenger.publish('MultichainAssetsController:accountAssetListUpdated', { + assets: { + [mockBtcAccount.id]: { + added: [mockBtcNativeAsset], + removed: [], + }, + }, + }); + + await waitForAllPromises(); + + expect(controller.state.balances).toStrictEqual({}); + }); + + it('clears stale balances on AccountsController:accountBalancesUpdated when isDeprecated toggles to true at runtime', () => { + let deprecated = false; + const { controller, messenger } = setupController({ + state: initialState, + isDeprecated: () => deprecated, + }); + + deprecated = true; + + messenger.publish('AccountsController:accountBalancesUpdated', { + balances: { + [mockBtcAccount.id]: mockBalanceResult, + }, + }); + + expect(controller.state.balances).toStrictEqual({}); + }); + + it('clears stale balances on AccountsController:accountRemoved when isDeprecated toggles to true at runtime', () => { + let deprecated = false; + const { controller, messenger } = setupController({ + state: initialState, + isDeprecated: () => deprecated, + }); + + deprecated = true; + + messenger.publish('AccountsController:accountRemoved', mockBtcAccount.id); + + expect(controller.state.balances).toStrictEqual({}); + }); + }); + + describe('metadata', () => { + it('includes expected state in debug snapshots', () => { + const { controller } = setupController(); + + expect( + deriveStateFromMetadata( + controller.state, + controller.metadata, + 'includeInDebugSnapshot', + ), + ).toMatchInlineSnapshot(`{}`); + }); + + it('includes expected state in state logs', () => { + const { controller } = setupController(); + + expect( + deriveStateFromMetadata( + controller.state, + controller.metadata, + 'includeInStateLogs', + ), + ).toMatchInlineSnapshot(`{}`); + }); + + it('persists expected state', () => { + const { controller } = setupController(); + + expect( + deriveStateFromMetadata( + controller.state, + controller.metadata, + 'persist', + ), + ).toMatchInlineSnapshot(` + { + "balances": {}, + } + `); + }); + + it('exposes expected state to UI', () => { + const { controller } = setupController(); + + expect( + deriveStateFromMetadata( + controller.state, + controller.metadata, + 'usedInUi', + ), + ).toMatchInlineSnapshot(` + { + "balances": {}, + } + `); + }); + }); +}); diff --git a/packages/assets-controllers/src/MultichainBalancesController/MultichainBalancesController.ts b/packages/assets-controllers/src/MultichainBalancesController/MultichainBalancesController.ts new file mode 100644 index 00000000000..6c10bd9338a --- /dev/null +++ b/packages/assets-controllers/src/MultichainBalancesController/MultichainBalancesController.ts @@ -0,0 +1,512 @@ +import type { + AccountsControllerAccountAddedEvent, + AccountsControllerAccountRemovedEvent, + AccountsControllerListMultichainAccountsAction, + AccountsControllerAccountBalancesUpdatesEvent, +} from '@metamask/accounts-controller'; +import { BaseController } from '@metamask/base-controller'; +import type { + StateMetadata, + ControllerGetStateAction, + ControllerStateChangeEvent, +} from '@metamask/base-controller'; +import { isEvmAccountType } from '@metamask/keyring-api'; +import type { + Balance, + CaipAssetType, + AccountBalancesUpdatedEventPayload, +} from '@metamask/keyring-api'; +import type { KeyringControllerGetStateAction } from '@metamask/keyring-controller'; +import type { InternalAccount } from '@metamask/keyring-internal-api'; +import { KeyringClient } from '@metamask/keyring-snap-client'; +import type { Messenger } from '@metamask/messenger'; +import type { SnapControllerHandleRequestAction } from '@metamask/snaps-controllers'; +import type { SnapId } from '@metamask/snaps-sdk'; +import { HandlerType } from '@metamask/snaps-utils'; +import type { Json, JsonRpcRequest } from '@metamask/utils'; +import type { Draft } from 'immer'; + +import type { + MultichainAssetsControllerGetStateAction, + MultichainAssetsControllerAccountAssetListUpdatedEvent, +} from '../MultichainAssetsController/index.js'; + +const controllerName = 'MultichainBalancesController'; + +/** + * State used by the {@link MultichainBalancesController} to cache account balances. + */ +export type MultichainBalancesControllerState = { + balances: { + [account: string]: { + [asset: string]: { + amount: string; + unit: string; + }; + }; + }; +}; + +/** + * Constructs the default {@link MultichainBalancesController} 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 MultichainBalancesController} state. + */ +export function getDefaultMultichainBalancesControllerState(): MultichainBalancesControllerState { + return { balances: {} }; +} + +/** + * Returns the state of the {@link MultichainBalancesController}. + */ +export type MultichainBalancesControllerGetStateAction = + ControllerGetStateAction< + typeof controllerName, + MultichainBalancesControllerState + >; + +/** + * Event emitted when the state of the {@link MultichainBalancesController} changes. + */ +export type MultichainBalancesControllerStateChange = + ControllerStateChangeEvent< + typeof controllerName, + MultichainBalancesControllerState + >; + +/** + * Actions exposed by the {@link MultichainBalancesController}. + */ +export type MultichainBalancesControllerActions = + MultichainBalancesControllerGetStateAction; + +/** + * Events emitted by {@link MultichainBalancesController}. + */ +export type MultichainBalancesControllerEvents = + MultichainBalancesControllerStateChange; + +/** + * Actions that this controller is allowed to call. + */ +type AllowedActions = + | SnapControllerHandleRequestAction + | AccountsControllerListMultichainAccountsAction + | MultichainAssetsControllerGetStateAction + | KeyringControllerGetStateAction; + +/** + * Events that this controller is allowed to subscribe. + */ +type AllowedEvents = + | AccountsControllerAccountAddedEvent + | AccountsControllerAccountRemovedEvent + | AccountsControllerAccountBalancesUpdatesEvent + | MultichainAssetsControllerAccountAssetListUpdatedEvent; +/** + * Messenger type for the MultichainBalancesController. + */ +export type MultichainBalancesControllerMessenger = Messenger< + typeof controllerName, + MultichainBalancesControllerActions | AllowedActions, + MultichainBalancesControllerEvents | AllowedEvents +>; + +/** + * {@link MultichainBalancesController}'s metadata. + * + * This allows us to choose if fields of the state should be persisted or not + * using the `persist` flag; and if they can be sent to Sentry or not, using + * the `anonymous` flag. + */ +const balancesControllerMetadata: StateMetadata = + { + balances: { + includeInStateLogs: false, + persist: true, + includeInDebugSnapshot: false, + usedInUi: true, + }, + }; + +/** + * The MultichainBalancesController is responsible for fetching and caching account + * balances. + */ +export class MultichainBalancesController extends BaseController< + typeof controllerName, + MultichainBalancesControllerState, + MultichainBalancesControllerMessenger +> { + readonly #isDeprecated: () => boolean; + + /** + * Creates a MultichainBalancesController instance. + * + * @param options - Constructor options. + * @param options.messenger - A reference to the messenger. + * @param options.state - The initial state. + * @param options.isDeprecated - Optional function that returns true to completely + * disable this controller (no requests, no state updates). When it returns + * `true`, `balances` is reset to `{}` at construction and at every entry point, + * so no stale balances remain in state. The function is evaluated dynamically + * on each entry point so it can be toggled at runtime. Intended for use when + * a higher-level controller (e.g. AssetsController) supersedes this one. + */ + constructor({ + messenger, + state = {}, + isDeprecated = (): boolean => false, + }: { + messenger: MultichainBalancesControllerMessenger; + state?: Partial; + isDeprecated?: () => boolean; + }) { + super({ + messenger, + name: controllerName, + metadata: balancesControllerMetadata, + state: { + ...getDefaultMultichainBalancesControllerState(), + ...state, + }, + }); + + this.#isDeprecated = isDeprecated; + + if (this.#isDeprecated()) { + this.#enforceDisabledState(); + } else { + // Fetch initial balances for all non-EVM accounts + for (const account of this.#listAccounts()) { + // Fetching the balance is asynchronous and we cannot use `await` here. + // eslint-disable-next-line no-void + void this.updateBalance(account.id); + } + } + + this.messenger.subscribe( + 'AccountsController:accountRemoved', + (account: string) => this.#handleOnAccountRemoved(account), + ); + this.messenger.subscribe( + 'AccountsController:accountBalancesUpdated', + (balanceUpdate: AccountBalancesUpdatedEventPayload) => + this.#handleOnAccountBalancesUpdated(balanceUpdate), + ); + + this.messenger.subscribe( + 'MultichainAssetsController:accountAssetListUpdated', + // eslint-disable-next-line @typescript-eslint/no-misused-promises + async ({ assets }) => { + const updatedAccountAssets = Object.entries(assets).map( + ([accountId, { added, removed }]) => ({ + accountId, + added: [...added], + removed: [...removed], + }), + ); + + await this.#handleOnAccountAssetListUpdated(updatedAccountAssets); + }, + ); + } + + /** + * Clears all persisted `balances` so that no stale balances remain in state. + * + * Called from every entry point when `isDeprecated()` is true so that a + * runtime toggle propagates to state immediately, even if the controller was + * originally constructed while it was enabled. The update is skipped when + * `balances` is already empty to avoid emitting redundant state changes. + */ + #enforceDisabledState(): void { + if (Object.keys(this.state.balances).length === 0) { + return; + } + this.update((state) => { + state.balances = {}; + }); + } + + /** + * Reconciles cached balances after a multichain asset-list update event. + * + * The event payload is treated as a delta: + * - balances for `removed` assets are deleted so stale entries cannot remain + * - balances for `added` assets are fetched from the snap and merged in + * - if an added asset is not returned by the snap, a zero placeholder is stored + * so the asset can still be represented in state + * + * @param accounts - The per-account asset deltas from the asset-list update event. + */ + async #handleOnAccountAssetListUpdated( + accounts: { + accountId: string; + added: CaipAssetType[]; + removed: CaipAssetType[]; + }[], + ): Promise { + if (this.#isDeprecated()) { + this.#enforceDisabledState(); + return; + } + + const { isUnlocked } = this.messenger.call('KeyringController:getState'); + + if (!isUnlocked) { + return; + } + const balancesToAdd: MultichainBalancesControllerState['balances'] = {}; + + for (const { accountId, added } of accounts) { + if (added.length === 0) { + continue; + } + + const account = this.#getAccount(accountId); + if (account.metadata.snap) { + const accountBalance = await this.#getBalances( + account.id, + account.metadata.snap.id, + added, + ); + + balancesToAdd[accountId] = accountBalance; + } + } + + this.update((state: Draft) => { + for (const { accountId, added, removed } of accounts) { + const accountBalances = state.balances[accountId] ?? {}; + const addedBalances = balancesToAdd[accountId] ?? {}; + + state.balances[accountId] = accountBalances; + + // Remove balances for assets that disappeared from the account asset list + // so stale entries cannot remain in state. + for (const assetId of removed) { + delete state.balances[accountId][assetId]; + } + + // Merge the balances returned by the snap for the newly added assets. + for (const [assetId, balance] of Object.entries(addedBalances)) { + state.balances[accountId][assetId] = balance; + } + + // If the asset list was updated but the snap did not return a balance for + // one of the added assets, keep the asset visible with an explicit zero. + for (const assetId of added) { + if (!state.balances[accountId][assetId]) { + state.balances[accountId][assetId] = { amount: '0', unit: '' }; + } + } + } + }); + } + + /** + * Updates the balances of one account. This method doesn't return + * anything, but it updates the state of the controller. + * + * @param accountId - The account ID. + * @param assets - The list of asset types for this account to upadte. + */ + async #updateBalance( + accountId: string, + assets: CaipAssetType[], + ): Promise { + if (this.#isDeprecated()) { + this.#enforceDisabledState(); + return; + } + + const { isUnlocked } = this.messenger.call('KeyringController:getState'); + + if (!isUnlocked) { + return; + } + + try { + const account = this.#getAccount(accountId); + + if (account.metadata.snap) { + const accountBalance = await this.#getBalances( + account.id, + account.metadata.snap.id, + assets, + ); + + this.update((state: Draft) => { + state.balances[accountId] = accountBalance; + }); + } + } catch (error) { + // FIXME: Maybe we shouldn't catch all errors here since this method is also being + // used in the public methods. This means if something else uses `updateBalance` it + // won't be able to catch and gets the error itself... + console.error( + `Failed to fetch balances for account ${accountId}:`, + error, + ); + } + } + + /** + * Updates the balances of one account. This method doesn't return + * anything, but it updates the state of the controller. + * + * @param accountId - The account ID. + */ + async updateBalance(accountId: string): Promise { + await this.#updateBalance(accountId, this.#listAccountAssets(accountId)); + } + + /** + * Lists the multichain accounts coming from the `AccountsController`. + * + * @returns A list of multichain accounts. + */ + #listMultichainAccounts(): InternalAccount[] { + return this.messenger.call('AccountsController:listMultichainAccounts'); + } + + /** + * Lists the accounts that we should get balances for. + * + * @returns A list of accounts that we should get balances for. + */ + #listAccounts(): InternalAccount[] { + const accounts = this.#listMultichainAccounts(); + return accounts.filter((account) => this.#isNonEvmAccount(account)); + } + + /** + * Lists the accounts assets. + * + * @param accountId - The account ID. + * @returns The list of assets for this account, returns an empty list if none. + */ + #listAccountAssets(accountId: string): CaipAssetType[] { + // TODO: Add an action `MultichainAssetsController:getAccountAssets` maybe? + const assetsState = this.messenger.call( + 'MultichainAssetsController:getState', + ); + + return assetsState.accountsAssets[accountId] ?? []; + } + + /** + * Get a non-EVM account from its ID. + * + * @param accountId - The account ID. + * @returns The non-EVM account. + */ + #getAccount(accountId: string): InternalAccount { + const account: InternalAccount | undefined = this.#listAccounts().find( + (multichainAccount) => multichainAccount.id === accountId, + ); + + if (!account) { + throw new Error(`Unknown account: ${accountId}`); + } + + return account; + } + + /** + * Checks for non-EVM accounts. + * + * @param account - The new account to be checked. + * @returns True if the account is a non-EVM account, false otherwise. + */ + #isNonEvmAccount(account: InternalAccount): boolean { + return ( + !isEvmAccountType(account.type) && + // Non-EVM accounts are backed by a Snap for now + account.metadata.snap !== undefined + ); + } + + /** + * Handles balance updates received from the AccountsController. + * + * @param balanceUpdate - The balance update event containing new balances. + */ + #handleOnAccountBalancesUpdated( + balanceUpdate: AccountBalancesUpdatedEventPayload, + ): void { + if (this.#isDeprecated()) { + this.#enforceDisabledState(); + return; + } + + this.update((state: Draft) => { + Object.entries(balanceUpdate.balances).forEach( + ([accountId, assetBalances]) => { + if (accountId in state.balances) { + Object.assign(state.balances[accountId], assetBalances); + } + }, + ); + }); + } + + /** + * Handles changes when a new account has been removed. + * + * @param accountId - The account ID being removed. + */ + async #handleOnAccountRemoved(accountId: string): Promise { + if (this.#isDeprecated()) { + this.#enforceDisabledState(); + return; + } + + if (accountId in this.state.balances) { + this.update((state: Draft) => { + delete state.balances[accountId]; + }); + } + } + + /** + * Get the balances for an account. + * + * @param accountId - ID of the account to get balances for. + * @param snapId - ID of the Snap which manages the account. + * @param assetTypes - Array of asset types to get balances for. + * @returns A map of asset types to balances. + */ + async #getBalances( + accountId: string, + snapId: string, + assetTypes: CaipAssetType[], + ): Promise> { + return await this.#getClient(snapId).getAccountBalances( + accountId, + assetTypes, + ); + } + + /** + * Gets a `KeyringClient` for a Snap. + * + * @param snapId - ID of the Snap to get the client for. + * @returns A `KeyringClient` for the Snap. + */ + #getClient(snapId: string): KeyringClient { + return new KeyringClient({ + send: async (request: JsonRpcRequest) => + (await this.messenger.call('SnapController:handleRequest', { + snapId: snapId as SnapId, + origin: 'metamask', + handler: HandlerType.OnKeyringRequest, + request, + })) as Promise, + }); + } +} diff --git a/packages/assets-controllers/src/MultichainBalancesController/index.ts b/packages/assets-controllers/src/MultichainBalancesController/index.ts new file mode 100644 index 00000000000..174fb14bc87 --- /dev/null +++ b/packages/assets-controllers/src/MultichainBalancesController/index.ts @@ -0,0 +1,9 @@ +export { MultichainBalancesController } from './MultichainBalancesController.js'; +export type { + MultichainBalancesControllerState, + MultichainBalancesControllerGetStateAction, + MultichainBalancesControllerStateChange, + MultichainBalancesControllerActions, + MultichainBalancesControllerEvents, + MultichainBalancesControllerMessenger, +} from './MultichainBalancesController.js'; diff --git a/packages/assets-controllers/src/NftController.test.ts b/packages/assets-controllers/src/NftController.test.ts index fad566db2a5..9ebbc5e6404 100644 --- a/packages/assets-controllers/src/NftController.test.ts +++ b/packages/assets-controllers/src/NftController.test.ts @@ -1,15 +1,15 @@ import type { Network } from '@ethersproject/providers'; import type { - AddApprovalRequest, - ApprovalStateChange, -} from '@metamask/approval-controller'; + AccountsControllerGetAccountAction, + AccountsControllerGetSelectedAccountAction, + AccountsControllerSelectedAccountChangeEvent, +} from '@metamask/accounts-controller'; +import type { ApprovalControllerMessenger } from '@metamask/approval-controller'; import { ApprovalController } from '@metamask/approval-controller'; -import { ControllerMessenger } from '@metamask/base-controller'; +import { deriveStateFromMetadata } from '@metamask/base-controller'; import { - OPENSEA_PROXY_URL, IPFS_DEFAULT_GATEWAY_URL, ERC1155, - OPENSEA_API_URL, ERC721, ChainId, NetworkType, @@ -17,23 +17,65 @@ import { ApprovalType, ERC20, NetworksTicker, + NFT_API_BASE_URL, + // //InfuraNetworkType, + convertHexToDecimal, } from '@metamask/controller-utils'; +import type { InternalAccount } from '@metamask/keyring-internal-api'; +import { MOCK_ANY_NAMESPACE, Messenger } from '@metamask/messenger'; import type { - NetworkState, - ProviderConfig, + MessengerActions, + MessengerEvents, + MockAnyNamespace, +} from '@metamask/messenger'; +import type { + NetworkClientConfiguration, + NetworkClientId, } from '@metamask/network-controller'; -import { defaultState as defaultNetworkState } from '@metamask/network-controller'; -import { PreferencesController } from '@metamask/preferences-controller'; -import { BN } from 'ethereumjs-util'; -import HttpProvider from 'ethjs-provider-http'; +import type { BulkPhishingDetectionScanResponse } from '@metamask/phishing-controller'; +import { RecommendedAction } from '@metamask/phishing-controller'; +import { getDefaultPreferencesState } from '@metamask/preferences-controller'; +import type { PreferencesState } from '@metamask/preferences-controller'; +import type { Hex } from '@metamask/utils'; import nock from 'nock'; -import * as sinon from 'sinon'; import { v4 } from 'uuid'; -import { AssetsContractController } from './AssetsContractController'; -import { getFormattedIpfsUrl } from './assetsUtil'; -import { Source } from './constants'; -import { NftController } from './NftController'; +import { createMockInternalAccount } from '../../accounts-controller/tests/mocks.js'; +import { + buildCustomNetworkClientConfiguration, + buildMockFindNetworkClientIdByChainId, + buildMockGetNetworkClientById, +} from '../../network-controller/tests/helpers.js'; +import type { + AssetsContractControllerGetERC1155TokenURIAction, + AssetsContractControllerGetERC721AssetNameAction, + AssetsContractControllerGetERC721AssetSymbolAction, + AssetsContractControllerGetERC721TokenURIAction, +} from './AssetsContractController.js'; +import { getFormattedIpfsUrl } from './assetsUtil.js'; +import { Source } from './constants.js'; +import type { NftOwnershipResult } from './multicall.js'; +import { getNftOwnershipForMultipleNfts } from './multicall.js'; +import type { + Nft, + NftControllerState, + NftControllerMessenger, + NFTStandardType, + NftMetadata, +} from './NftController.js'; +import { NftController } from './NftController.js'; +import type { Collection } from './NftDetectionController.js'; + +type AllActions = + | MessengerActions + | MessengerActions; + +type AllEvents = + | MessengerEvents + | MessengerEvents + | AccountsControllerSelectedAccountChangeEvent; + +type RootMessenger = Messenger; const CRYPTOPUNK_ADDRESS = '0xb47e3cd837dDF8e4c57F05d70Ab865de6e193BBB'; const ERC721_KUDOSADDRESS = '0x2aEa4Add166EBf38b63d09a75dE1a7b94Aa24163'; @@ -46,24 +88,17 @@ const ERC1155_NFT_ID = const ERC721_DEPRESSIONIST_ADDRESS = '0x18E8E76aeB9E2d9FA2A2b88DD9CF3C8ED45c3660'; const ERC721_DEPRESSIONIST_ID = '36'; -const MAINNET_PROVIDER = new HttpProvider( - 'https://mainnet.infura.io/v3/ad3a368836ff4596becc3be8e2f137ac', -); -const SEPOLIA_PROVIDER = new HttpProvider( - 'https://sepolia.infura.io/v3/ad3a368836ff4596becc3be8e2f137ac', -); const OWNER_ADDRESS = '0x5a3CA5cD63807Ce5e4d7841AB32Ce6B6d9BbBa2D'; +const OWNER_ID = '54d1e7bc-1dce-4220-a15f-2f454bae7869'; +const OWNER_ACCOUNT = createMockInternalAccount({ + id: OWNER_ID, + address: OWNER_ADDRESS, +}); const SECOND_OWNER_ADDRESS = '0x500017171kasdfbou081'; const DEPRESSIONIST_CID_V1 = 'bafybeidf7aw7bmnmewwj4ayq3she2jfk5jrdpp24aaucf6fddzb3cfhrvm'; -const DEPRESSIONIST_CLOUDFLARE_IPFS_SUBDOMAIN_PATH = getFormattedIpfsUrl( - IPFS_DEFAULT_GATEWAY_URL, - `ipfs://${DEPRESSIONIST_CID_V1}`, - true, -); - const SEPOLIA = { chainId: toHex(11155111), type: NetworkType.sepolia, @@ -75,9 +110,6 @@ const GOERLI = { ticker: NetworksTicker.goerli, }; -type ApprovalActions = AddApprovalRequest; -type ApprovalEvents = ApprovalStateChange; - const controllerName = 'NftController' as const; // Mock out detectNetwork function for cleaner tests, Ethers calls this a bunch of times because the Web3Provider is paranoid. @@ -106,190 +138,310 @@ jest.mock('uuid', () => { }; }); +jest.mock('./multicall', () => ({ + ...jest.requireActual('./multicall'), + getNftOwnershipForMultipleNfts: jest.fn(), +})); + /** * Setup a test controller instance. * - * @param options - Controller options. - * @param options.includeOnNftAdded - Whether to include the "onNftAdded" parameter. - * @param options.getERC721OwnerOfStub - Stub for the "getERC721OwnerOf" method. - * @param options.getERC721AssetNameStub - Stub for the "getERC721AssetName" method. - * @param options.getERC721AssetSymbolStub - Stub for the "getERC721AssetSymbol" method. - * @param options.getERC1155BalanceOfStub - Stub for the "getERC1155BalanceOf" method. - * @param options.getERC721TokenURIStub - Stub for the "getERC721TokenURI" method. - * @param options.getERC1155TokenURIStub - Stub for the "getERC1155TokenURI" method. - * @returns A collection of test controllers and stubs. + * @param args - Arguments to this function. + * @param args.options - Controller options. + * @param args.getERC721AssetName - Used to construct mock versions of the + * `AssetsContractController:getERC721AssetName` action. + * @param args.getERC721AssetSymbol - Used to construct mock versions of the + * `AssetsContractController:getERC721AssetSymbol` action. + * @param args.getERC721TokenURI - Used to construct mock versions of the + * `AssetsContractController:getERC721TokenURI` action. + * @param args.getERC1155TokenURI - Used to construct mock versions of the + * `AssetsContractController:getERC1155TokenURI` action. + * @param args.mockNetworkClientConfigurationsByNetworkClientId - Used to construct + * mock versions of network clients and ultimately mock the + * `NetworkController:getNetworkClientById` action. + * @param args.mockGetNetworkClientIdByChainId - Used to construct mock versions of the + * @param args.getAccount - Used to construct mock versions of the + * `AccountsController:getAccount` action. + * @param args.getSelectedAccount - Used to construct mock versions of the + * `AccountsController:getSelectedAccount` action. + * @param args.bulkScanUrlsMock - Used to construct mock versions of the + * `PhishingController:bulkScanUrls` action. + * @param args.approvalAddRequest - When provided, registered directly as the + * `ApprovalController:addRequest` action handler instead of creating a real + * ApprovalController. Use this when the test needs to assert on or auto-resolve + * approval requests without the full approval flow. + * @param args.defaultSelectedAccount - The default selected account to use in + * @param args.displayNftMedia - The default displayNftMedia to use in + * @returns A collection of test controllers and mocks. */ function setupController({ - includeOnNftAdded = false, - getERC721OwnerOfStub, - getERC721AssetNameStub, - getERC721AssetSymbolStub, - getERC721TokenURIStub, - getERC1155TokenURIStub, - getERC1155BalanceOfStub, + options = {}, + getERC721AssetName, + getERC721AssetSymbol, + getERC721TokenURI, + getERC1155TokenURI, + getAccount, + getSelectedAccount, + bulkScanUrlsMock, + approvalAddRequest, + mockNetworkClientConfigurationsByNetworkClientId = {}, + defaultSelectedAccount = OWNER_ACCOUNT, + mockGetNetworkClientIdByChainId = {}, + displayNftMedia = true, }: { - includeOnNftAdded?: boolean; - getERC721OwnerOfStub?: ( - tokenAddress: string, - tokenId: string, - ) => Promise; - getERC721AssetNameStub?: (tokenAddress: string) => Promise; - getERC721AssetSymbolStub?: (tokenAddress: string) => Promise; - getERC721TokenURIStub?: ( - tokenAddress: string, - tokenId: string, - ) => Promise; - getERC1155TokenURIStub?: ( - tokenAddress: string, - tokenId: string, - ) => Promise; - - getERC1155BalanceOfStub?: ( - tokenAddress: string, - tokenId: string, - userAddress: string, - ) => Promise; + options?: Partial[0]>; + getERC721AssetName?: jest.Mock< + ReturnType, + Parameters + >; + getERC721AssetSymbol?: jest.Mock< + ReturnType, + Parameters + >; + getERC721TokenURI?: jest.Mock< + ReturnType, + Parameters + >; + getERC1155TokenURI?: jest.Mock< + ReturnType, + Parameters + >; + getAccount?: jest.Mock< + ReturnType, + Parameters | [null] + >; + getSelectedAccount?: jest.Mock< + ReturnType, + Parameters + >; + bulkScanUrlsMock?: jest.Mock< + Promise, + [string[]] + >; + approvalAddRequest?: jest.Mock; + mockNetworkClientConfigurationsByNetworkClientId?: Record< + NetworkClientId, + NetworkClientConfiguration + >; + defaultSelectedAccount?: InternalAccount; + mockGetNetworkClientIdByChainId?: Record; + displayNftMedia?: boolean; } = {}) { - const preferences = new PreferencesController(); - const onNetworkStateChangeListeners: ((state: NetworkState) => void)[] = []; - const changeNetwork = (providerConfig: ProviderConfig) => { - onNetworkStateChangeListeners.forEach((listener) => { - listener({ - ...defaultNetworkState, - providerConfig, - }); - }); - }; - - const messenger = new ControllerMessenger(); - - const approvalControllerMessenger = messenger.getRestricted({ - name: 'ApprovalController', - allowedActions: ['ApprovalController:addRequest'], - }); - - const approvalController = new ApprovalController({ - messenger: approvalControllerMessenger, - showApprovalRequest: jest.fn(), + const messenger: RootMessenger = new Messenger({ + namespace: MOCK_ANY_NAMESPACE, }); - const getNetworkClientByIdSpy = jest.fn(); + const getNetworkClientById = buildMockGetNetworkClientById( + mockNetworkClientConfigurationsByNetworkClientId, + ); + const findNetworkClientIdByChainId = buildMockFindNetworkClientIdByChainId( + mockGetNetworkClientIdByChainId, + ); + messenger.registerActionHandler( + 'NetworkController:getNetworkClientById', + getNetworkClientById, + ); + messenger.registerActionHandler( + 'NetworkController:findNetworkClientIdByChainId', + findNetworkClientIdByChainId, + ); + + const mockGetAccount = + getAccount ?? jest.fn().mockReturnValue(defaultSelectedAccount); + messenger.registerActionHandler( + 'AccountsController:getAccount', + mockGetAccount, + ); + + const mockGetSelectedAccount = + getSelectedAccount ?? jest.fn().mockReturnValue(defaultSelectedAccount); + messenger.registerActionHandler( + 'AccountsController:getSelectedAccount', + mockGetSelectedAccount, + ); + + const mockGetERC721AssetName = + getERC721AssetName ?? + jest.fn< + ReturnType, + Parameters + >(); + messenger.registerActionHandler( + 'AssetsContractController:getERC721AssetName', + mockGetERC721AssetName, + ); + + const mockGetERC721AssetSymbol = + getERC721AssetSymbol ?? + jest.fn< + ReturnType, + Parameters + >(); + messenger.registerActionHandler( + 'AssetsContractController:getERC721AssetSymbol', + mockGetERC721AssetSymbol, + ); + + const mockGetERC721TokenURI = + getERC721TokenURI ?? + jest.fn< + ReturnType, + Parameters + >(); + messenger.registerActionHandler( + 'AssetsContractController:getERC721TokenURI', + mockGetERC721TokenURI, + ); + + const mockGetERC1155TokenURI = + getERC1155TokenURI ?? + jest.fn< + ReturnType, + Parameters + >(); + messenger.registerActionHandler( + 'AssetsContractController:getERC1155TokenURI', + mockGetERC1155TokenURI, + ); + + let approvalController: ApprovalController; + + if (approvalAddRequest) { + messenger.registerActionHandler( + 'ApprovalController:addRequest', + approvalAddRequest, + ); + // Provide a stub so callers can still destructure `approvalController` + // without needing to branch. The stub is intentionally minimal. + approvalController = { addRequest: approvalAddRequest } as never; + } else { + const approvalControllerMessenger = new Messenger< + 'ApprovalController', + MessengerActions, + MessengerEvents, + RootMessenger + >({ + namespace: 'ApprovalController', + parent: messenger, + }); - const assetsContract = new AssetsContractController({ - chainId: ChainId.mainnet, - onPreferencesStateChange: (listener) => preferences.subscribe(listener), - onNetworkStateChange: (listener) => - onNetworkStateChangeListeners.push(listener), - getNetworkClientById: getNetworkClientByIdSpy, - }); + approvalController = new ApprovalController({ + messenger: approvalControllerMessenger, + showApprovalRequest: jest.fn(), + }); + } - const onNftAddedSpy = includeOnNftAdded ? jest.fn() : undefined; + // Register the phishing controller mock if provided + if (bulkScanUrlsMock) { + messenger.registerActionHandler( + 'PhishingController:bulkScanUrls', + bulkScanUrlsMock, + ); + } - const nftControllerMessenger = messenger.getRestricted< + const nftControllerMessenger = new Messenger< typeof controllerName, - ApprovalActions['type'], - never + MessengerActions, + MessengerEvents, + RootMessenger >({ - name: controllerName, - allowedActions: ['ApprovalController:addRequest'], + namespace: controllerName, + parent: messenger, + }); + messenger.delegate({ + messenger: nftControllerMessenger, + actions: [ + 'ApprovalController:addRequest', + 'AccountsController:getSelectedAccount', + 'AccountsController:getAccount', + 'NetworkController:getNetworkClientById', + 'AssetsContractController:getERC721AssetName', + 'AssetsContractController:getERC721AssetSymbol', + 'AssetsContractController:getERC721TokenURI', + 'AssetsContractController:getERC1155TokenURI', + 'NetworkController:findNetworkClientIdByChainId', + 'PhishingController:bulkScanUrls', + ], + events: [ + 'AccountsController:selectedEvmAccountChange', + 'PreferencesController:stateChange', + ], }); const nftController = new NftController({ - chainId: ChainId.mainnet, - onPreferencesStateChange: (listener) => preferences.subscribe(listener), - onNetworkStateChange: (listener) => - onNetworkStateChangeListeners.push(listener), - getERC721AssetName: - getERC721AssetNameStub ?? - assetsContract.getERC721AssetName.bind(assetsContract), - getERC721AssetSymbol: - getERC721AssetSymbolStub ?? - assetsContract.getERC721AssetSymbol.bind(assetsContract), - getERC721TokenURI: - getERC721TokenURIStub ?? - assetsContract.getERC721TokenURI.bind(assetsContract), - getERC721OwnerOf: - getERC721OwnerOfStub ?? - assetsContract.getERC721OwnerOf.bind(assetsContract), - getERC1155BalanceOf: - getERC1155BalanceOfStub ?? - assetsContract.getERC1155BalanceOf.bind(assetsContract), - getERC1155TokenURI: - getERC1155TokenURIStub ?? - assetsContract.getERC1155TokenURI.bind(assetsContract), - getNetworkClientById: getNetworkClientByIdSpy, - onNftAdded: onNftAddedSpy, + onNftAdded: jest.fn(), messenger: nftControllerMessenger, + ...options, }); - preferences.update({ - selectedAddress: OWNER_ADDRESS, - openSeaEnabled: true, + const triggerPreferencesStateChange = ( + state: PreferencesState & { openSeaEnabled?: boolean }, + ) => { + messenger.publish('PreferencesController:stateChange', state, []); + }; + + triggerPreferencesStateChange({ + ...getDefaultPreferencesState(), + displayNftMedia, }); + const triggerSelectedAccountChange = ( + internalAccount: InternalAccount, + ): void => { + messenger.publish( + 'AccountsController:selectedEvmAccountChange', + internalAccount, + ); + }; + + triggerSelectedAccountChange(OWNER_ACCOUNT); + return { - assetsContract, nftController, - onNftAddedSpy, - getNetworkClientByIdSpy, - preferences, - changeNetwork, messenger, + nftControllerMessenger, approvalController, + triggerPreferencesStateChange, + triggerSelectedAccountChange, + mockGetAccount, + mockGetSelectedAccount, + mockGetERC1155TokenURI, + mockGetERC721AssetName, + mockGetERC721AssetSymbol, + mockGetERC721TokenURI, }; } describe('NftController', () => { - beforeEach(() => { - nock(OPENSEA_PROXY_URL) - .get(`/asset_contract/0x01`) - .reply(200, { - description: 'Description', - symbol: 'FOO', - total_supply: 0, - collection: { - name: 'Name', - image_url: 'url', - }, - }) - .get(`/asset_contract/0x02`) - .reply(200, { - description: 'Description', - image_url: 'url', - name: 'Name', - symbol: 'FOU', - total_supply: 10, - collection: { - name: 'Name', - image_url: 'url', - }, - }) - .get(`/asset/0x01/1`) - .reply(200, { - description: 'Description', - image_original_url: 'url', - image_url: 'url', - name: 'Name', - asset_contract: { - schema_name: 'ERC1155', - }, - }) - .get(`/asset/0x6EbeAf8e8E946F0716E6533A6f2cefc83f60e8Ab/798958393`) - .replyWithError(new TypeError('Failed to fetch')) - .get(`/asset_contract/0x6EbeAf8e8E946F0716E6533A6f2cefc83f60e8Ab`) - .replyWithError(new TypeError('Failed to fetch')); - - nock(OPENSEA_PROXY_URL) - .get(`/asset/${ERC1155_NFT_ADDRESS}/${ERC1155_NFT_ID}`) + beforeEach(async () => { + nock(NFT_API_BASE_URL) + .get( + `/tokens?chainIds=1&tokens=0x01%3A1&includeTopBid=true&includeAttributes=true&includeLastSale=true`, + ) .reply(200, { - num_sales: 1, - image_original_url: 'image.uri', - name: 'name', - image: 'image', - description: 'description', - asset_contract: { schema_name: 'ERC1155' }, + tokens: [ + { + token: { + contract: '0x1', + kind: 'erc1155', + name: 'Name', + description: 'Description', + image: 'url', + collection: { + id: '0x1', + creator: 'Oxaddress', + tokenCount: 0, + }, + }, + }, + ], }); - + const DEPRESSIONIST_CLOUDFLARE_IPFS_SUBDOMAIN_PATH = + await getFormattedIpfsUrl( + IPFS_DEFAULT_GATEWAY_URL, + `ipfs://${DEPRESSIONIST_CID_V1}`, + true, + ); nock(DEPRESSIONIST_CLOUDFLARE_IPFS_SUBDOMAIN_PATH).get('/').reply(200, { name: 'name', image: 'image', @@ -297,10 +449,6 @@ describe('NftController', () => { }); }); - afterEach(() => { - sinon.restore(); - }); - it('should set default state', () => { const { nftController } = setupController(); @@ -311,7 +459,7 @@ describe('NftController', () => { }); }); - describe('on watchNft', function () { + describe('watchNft', function () { const ERC721_NFT = { address: ERC721_NFT_ADDRESS, tokenId: ERC721_NFT_ID, @@ -322,49 +470,112 @@ describe('NftController', () => { tokenId: ERC1155_NFT_ID, }; + it('should error if passed no networkClientId', async function () { + const { nftController } = setupController(); + const networkClientId = undefined; + + const erc721Result = nftController.watchNft( + ERC721_NFT, + ERC721, + 'https://testdapp.com', + networkClientId as unknown as string, + ); + await expect(erc721Result).rejects.toThrow( + 'Network client id is required', + ); + }); + it('should error if passed no type', async function () { const { nftController } = setupController(); const type = undefined; - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-ignore-next-line - const erc721Result = nftController.watchNft(ERC721_NFT, type); + const erc721Result = nftController.watchNft( + ERC721_NFT, + type as unknown as NFTStandardType, + 'https://test-dapp.com', + 'mainnet', + ); await expect(erc721Result).rejects.toThrow('Asset type is required'); - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-ignore-next-line - const erc1155Result = nftController.watchNft(ERC1155_NFT, type); + const erc1155Result = nftController.watchNft( + ERC1155_NFT, + type as unknown as NFTStandardType, + 'https://test-dapp.com', + 'mainnet', + ); await expect(erc1155Result).rejects.toThrow('Asset type is required'); }); it('should error if asset type is not supported', async function () { const { nftController } = setupController(); - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-ignore-next-line - const erc721Result = nftController.watchNft(ERC721_NFT, ERC20); + const erc721Result = nftController.watchNft( + ERC721_NFT, + ERC20 as unknown as NFTStandardType, + 'https://test-dapp.com', + 'mainnet', + ); await expect(erc721Result).rejects.toThrow( `Non NFT asset type ${ERC20} not supported by watchNft`, ); - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-ignore-next-line - const erc1155Result = nftController.watchNft(ERC1155_NFT, ERC20); + const erc1155Result = nftController.watchNft( + ERC1155_NFT, + ERC20 as unknown as NFTStandardType, + 'https://test-dapp.com', + 'mainnet', + ); await expect(erc1155Result).rejects.toThrow( `Non NFT asset type ${ERC20} not supported by watchNft`, ); }); + it('should error if passed NFT does not match type passed', async function () { + nock('https://testtokenuri.com') + .get('/') + .reply( + 200, + JSON.stringify({ + image: 'testERC721Image', + name: 'testERC721Name', + description: 'testERC721Description', + }), + ); + const { nftController } = setupController({ + getERC721TokenURI: jest + .fn() + .mockImplementation(() => 'https://testtokenuri.com'), + }); + (getNftOwnershipForMultipleNfts as jest.Mock).mockResolvedValueOnce([ + { isOwned: true }, + ]); + + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore-next-line + const erc721Result = nftController.watchNft( + ERC721_NFT, + ERC1155, + 'https://test-dapp.com', + 'mainnet', + ); + await expect(erc721Result).rejects.toThrow( + `Suggested NFT of type ${ERC721} does not match received type ${ERC1155}`, + ); + }); + it('should error if address is not defined', async function () { const { nftController } = setupController(); const assetWithNoAddress = { - address: undefined, + address: undefined as unknown as string, tokenId: ERC721_NFT_ID, }; - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-ignore-next-line - const result = nftController.watchNft(assetWithNoAddress, ERC721); + const result = nftController.watchNft( + assetWithNoAddress, + ERC721, + 'https://testdapp.com', + 'mainnet', + ); await expect(result).rejects.toThrow( 'Both address and tokenId are required', ); @@ -374,17 +585,36 @@ describe('NftController', () => { const { nftController } = setupController(); const assetWithNoAddress = { address: ERC721_NFT_ADDRESS, - tokenId: undefined, + tokenId: undefined as unknown as string, }; - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-ignore-next-line - const result = nftController.watchNft(assetWithNoAddress, ERC721); + const result = nftController.watchNft( + assetWithNoAddress, + ERC721, + 'https://test-dapp.com', + 'mainnet', + ); await expect(result).rejects.toThrow( 'Both address and tokenId are required', ); }); + it('should error if tokenId is not a valid stringified decimal number', async function () { + const { nftController } = setupController(); + const assetWithNumericTokenId = { + address: ERC721_NFT_ADDRESS, + tokenId: '123abc', + }; + + const result = nftController.watchNft( + assetWithNumericTokenId, + ERC721, + 'https://test-dapp.com', + 'mainnet', + ); + await expect(result).rejects.toThrow('Invalid tokenId'); + }); + it('should error if address is invalid', async function () { const { nftController } = setupController(); const assetWithInvalidAddress = { @@ -395,36 +625,67 @@ describe('NftController', () => { assetWithInvalidAddress, ERC721, 'https://test-dapp.com', + 'mainnet', ); await expect(result).rejects.toThrow('Invalid address'); }); it('should error if the user does not own the suggested ERC721 NFT', async function () { - const { nftController, messenger } = setupController({ - getERC721OwnerOfStub: jest - .fn() - .mockImplementation(() => '0x12345abcefg'), + const addRequestMock = jest.fn(); + const { nftController } = setupController({ + approvalAddRequest: addRequestMock, }); - - const callActionSpy = jest.spyOn(messenger, 'call').mockResolvedValue({}); + (getNftOwnershipForMultipleNfts as jest.Mock).mockResolvedValueOnce([ + { isOwned: false }, + ]); await expect(() => - nftController.watchNft(ERC721_NFT, ERC721, 'https://test-dapp.com'), + nftController.watchNft( + ERC721_NFT, + ERC721, + 'https://test-dapp.com', + 'mainnet', + ), ).rejects.toThrow('Suggested NFT is not owned by the selected account'); - expect(callActionSpy).toHaveBeenCalledTimes(0); + expect(addRequestMock).not.toHaveBeenCalled(); + }); + + it('should error if the call to isNftOwner fail', async function () { + const { nftController } = setupController(); + jest.spyOn(nftController, 'isNftOwner').mockRejectedValue('Random error'); + try { + await nftController.watchNft( + ERC721_NFT, + ERC721, + 'https://test-dapp.com', + 'mainnet', + ); + } catch (err) { + // eslint-disable-next-line jest/no-conditional-expect + expect(err).toBe('Random error'); + } }); it('should error if the user does not own the suggested ERC1155 NFT', async function () { - const { nftController, messenger } = setupController({ - getERC1155BalanceOfStub: jest.fn().mockImplementation(() => new BN(0)), + const addRequestMock = jest.fn(); + const { nftController, mockGetAccount } = setupController({ + approvalAddRequest: addRequestMock, }); - - const callActionSpy = jest.spyOn(messenger, 'call').mockResolvedValue({}); + (getNftOwnershipForMultipleNfts as jest.Mock).mockResolvedValueOnce([ + { isOwned: false }, + ]); await expect(() => - nftController.watchNft(ERC1155_NFT, ERC1155, 'https://test-dapp.com'), + nftController.watchNft( + ERC1155_NFT, + ERC1155, + 'https://test-dapp.com', + 'mainnet', + ), ).rejects.toThrow('Suggested NFT is not owned by the selected account'); - expect(callActionSpy).toHaveBeenCalledTimes(0); + // getAccount must be called to look up the owner to compare against + expect(mockGetAccount).toHaveBeenCalledWith(expect.any(String)); + expect(addRequestMock).not.toHaveBeenCalled(); }); it('should handle ERC721 type and add pending request to ApprovalController with the OpenSea API disabled and IPFS gateway enabled', async function () { @@ -438,27 +699,46 @@ describe('NftController', () => { description: 'testERC721Description', }), ); - const { nftController, messenger, preferences } = setupController({ - getERC721TokenURIStub: jest + + const addRequestMock = jest.fn().mockResolvedValue(undefined); + const { + nftController, + triggerPreferencesStateChange, + triggerSelectedAccountChange, + mockGetERC721AssetName, + mockGetERC721AssetSymbol, + } = setupController({ + getAccount: jest.fn().mockReturnValue(OWNER_ACCOUNT), + getERC721TokenURI: jest .fn() - .mockImplementation(() => 'https://testtokenuri.com'), - getERC721OwnerOfStub: jest.fn().mockImplementation(() => OWNER_ADDRESS), + .mockResolvedValue('https://testtokenuri.com'), + approvalAddRequest: addRequestMock, + }); + (getNftOwnershipForMultipleNfts as jest.Mock).mockResolvedValueOnce([ + { isOwned: true }, + ]); + + triggerSelectedAccountChange(OWNER_ACCOUNT); + triggerPreferencesStateChange({ + ...getDefaultPreferencesState(), + isIpfsGatewayEnabled: true, + displayNftMedia: false, }); - preferences.setIsIpfsGatewayEnabled(true); - preferences.setOpenSeaEnabled(false); const requestId = 'approval-request-id-1'; - const clock = sinon.useFakeTimers(1); - + jest.spyOn(Date, 'now').mockReturnValue(1); (v4 as jest.Mock).mockImplementationOnce(() => requestId); - const callActionSpy = jest.spyOn(messenger, 'call').mockResolvedValue({}); + await nftController.watchNft( + ERC721_NFT, + ERC721, + 'https://test-dapp.com', + 'mainnet', + ); - await nftController.watchNft(ERC721_NFT, ERC721, 'https://test-dapp.com'); - expect(callActionSpy).toHaveBeenCalledTimes(1); - expect(callActionSpy).toHaveBeenCalledWith( - 'ApprovalController:addRequest', + expect(addRequestMock).toHaveBeenCalledTimes(1); + expect(addRequestMock).toHaveBeenCalledWith( { id: requestId, origin: 'https://test-dapp.com', @@ -477,8 +757,9 @@ describe('NftController', () => { }, true, ); - - clock.restore(); + // No on-chain RPC fallback for name/symbol — sourced from API only + expect(mockGetERC721AssetName).not.toHaveBeenCalled(); + expect(mockGetERC721AssetSymbol).not.toHaveBeenCalled(); }); it('should handle ERC721 type and add pending request to ApprovalController with the OpenSea API enabled and IPFS gateway enabled', async function () { @@ -492,27 +773,45 @@ describe('NftController', () => { description: 'testERC721Description', }), ); - const { nftController, messenger, preferences } = setupController({ - getERC721TokenURIStub: jest + + const addRequestMock = jest.fn().mockResolvedValue(undefined); + const { + nftController, + triggerPreferencesStateChange, + triggerSelectedAccountChange, + mockGetERC721AssetName, + mockGetERC721AssetSymbol, + } = setupController({ + getAccount: jest.fn().mockReturnValue(OWNER_ACCOUNT), + getERC721TokenURI: jest .fn() - .mockImplementation(() => 'https://testtokenuri.com'), - getERC721OwnerOfStub: jest.fn().mockImplementation(() => OWNER_ADDRESS), + .mockResolvedValue('https://testtokenuri.com'), + approvalAddRequest: addRequestMock, + }); + (getNftOwnershipForMultipleNfts as jest.Mock).mockResolvedValueOnce([ + { isOwned: true }, + ]); + triggerSelectedAccountChange(OWNER_ACCOUNT); + triggerPreferencesStateChange({ + ...getDefaultPreferencesState(), + isIpfsGatewayEnabled: true, + displayNftMedia: true, }); - preferences.setIsIpfsGatewayEnabled(true); - preferences.setOpenSeaEnabled(true); const requestId = 'approval-request-id-1'; - const clock = sinon.useFakeTimers(1); - + jest.spyOn(Date, 'now').mockReturnValue(1); (v4 as jest.Mock).mockImplementationOnce(() => requestId); - const callActionSpy = jest.spyOn(messenger, 'call').mockResolvedValue({}); + await nftController.watchNft( + ERC721_NFT, + ERC721, + 'https://test-dapp.com', + 'mainnet', + ); - await nftController.watchNft(ERC721_NFT, ERC721, 'https://test-dapp.com'); - expect(callActionSpy).toHaveBeenCalledTimes(1); - expect(callActionSpy).toHaveBeenCalledWith( - 'ApprovalController:addRequest', + expect(addRequestMock).toHaveBeenCalledTimes(1); + expect(addRequestMock).toHaveBeenCalledWith( { id: requestId, origin: 'https://test-dapp.com', @@ -531,8 +830,9 @@ describe('NftController', () => { }, true, ); - - clock.restore(); + // No on-chain RPC fallback for name/symbol — sourced from API only + expect(mockGetERC721AssetName).not.toHaveBeenCalled(); + expect(mockGetERC721AssetSymbol).not.toHaveBeenCalled(); }); it('should handle ERC721 type and add pending request to ApprovalController with the OpenSea API disabled and IPFS gateway disabled', async function () { @@ -546,27 +846,45 @@ describe('NftController', () => { description: 'testERC721Description', }), ); - const { nftController, messenger, preferences } = setupController({ - getERC721TokenURIStub: jest + + const addRequestMock = jest.fn().mockResolvedValue(undefined); + const { + nftController, + triggerPreferencesStateChange, + triggerSelectedAccountChange, + mockGetERC721AssetName, + mockGetERC721AssetSymbol, + } = setupController({ + getAccount: jest.fn().mockReturnValue(OWNER_ACCOUNT), + getERC721TokenURI: jest .fn() - .mockImplementation(() => 'ipfs://testtokenuri.com'), - getERC721OwnerOfStub: jest.fn().mockImplementation(() => OWNER_ADDRESS), + .mockResolvedValue('https://testtokenuri.com'), + approvalAddRequest: addRequestMock, + }); + (getNftOwnershipForMultipleNfts as jest.Mock).mockResolvedValueOnce([ + { isOwned: true }, + ]); + triggerSelectedAccountChange(OWNER_ACCOUNT); + triggerPreferencesStateChange({ + ...getDefaultPreferencesState(), + isIpfsGatewayEnabled: false, + displayNftMedia: false, }); - preferences.setIsIpfsGatewayEnabled(false); - preferences.setOpenSeaEnabled(false); const requestId = 'approval-request-id-1'; - const clock = sinon.useFakeTimers(1); - + jest.spyOn(Date, 'now').mockReturnValue(1); (v4 as jest.Mock).mockImplementationOnce(() => requestId); - const callActionSpy = jest.spyOn(messenger, 'call').mockResolvedValue({}); + await nftController.watchNft( + ERC721_NFT, + ERC721, + 'https://test-dapp.com', + 'mainnet', + ); - await nftController.watchNft(ERC721_NFT, ERC721, 'https://test-dapp.com'); - expect(callActionSpy).toHaveBeenCalledTimes(1); - expect(callActionSpy).toHaveBeenCalledWith( - 'ApprovalController:addRequest', + expect(addRequestMock).toHaveBeenCalledTimes(1); + expect(addRequestMock).toHaveBeenCalledWith( { id: requestId, origin: 'https://test-dapp.com', @@ -585,8 +903,9 @@ describe('NftController', () => { }, true, ); - - clock.restore(); + // No on-chain RPC fallback for name/symbol — sourced from API only + expect(mockGetERC721AssetName).not.toHaveBeenCalled(); + expect(mockGetERC721AssetSymbol).not.toHaveBeenCalled(); }); it('should handle ERC721 type and add pending request to ApprovalController with the OpenSea API enabled and IPFS gateway disabled', async function () { @@ -600,27 +919,46 @@ describe('NftController', () => { description: 'testERC721Description', }), ); - const { nftController, messenger, preferences } = setupController({ - getERC721TokenURIStub: jest + + const addRequestMock = jest.fn().mockResolvedValue(undefined); + const { + nftController, + triggerPreferencesStateChange, + triggerSelectedAccountChange, + mockGetERC721AssetName, + mockGetERC721AssetSymbol, + } = setupController({ + getAccount: jest.fn().mockReturnValue(OWNER_ACCOUNT), + getERC721TokenURI: jest .fn() - .mockImplementation(() => 'ipfs://testtokenuri.com'), - getERC721OwnerOfStub: jest.fn().mockImplementation(() => OWNER_ADDRESS), + .mockResolvedValue('https://testtokenuri.com'), + approvalAddRequest: addRequestMock, }); - preferences.setIsIpfsGatewayEnabled(false); - preferences.setOpenSeaEnabled(true); + (getNftOwnershipForMultipleNfts as jest.Mock).mockResolvedValueOnce([ + { isOwned: true }, + ]); - const requestId = 'approval-request-id-1'; + triggerSelectedAccountChange(OWNER_ACCOUNT); + triggerPreferencesStateChange({ + ...getDefaultPreferencesState(), + isIpfsGatewayEnabled: false, + displayNftMedia: true, + }); - const clock = sinon.useFakeTimers(1); + const requestId = 'approval-request-id-1'; + jest.spyOn(Date, 'now').mockReturnValue(1); (v4 as jest.Mock).mockImplementationOnce(() => requestId); - const callActionSpy = jest.spyOn(messenger, 'call').mockResolvedValue({}); + await nftController.watchNft( + ERC721_NFT, + ERC721, + 'https://test-dapp.com', + 'mainnet', + ); - await nftController.watchNft(ERC721_NFT, ERC721, 'https://test-dapp.com'); - expect(callActionSpy).toHaveBeenCalledTimes(1); - expect(callActionSpy).toHaveBeenCalledWith( - 'ApprovalController:addRequest', + expect(addRequestMock).toHaveBeenCalledTimes(1); + expect(addRequestMock).toHaveBeenCalledWith( { id: requestId, origin: 'https://test-dapp.com', @@ -630,17 +968,18 @@ describe('NftController', () => { interactingAddress: OWNER_ADDRESS, asset: { ...ERC721_NFT, - description: null, - image: null, - name: null, + description: 'testERC721Description', + image: 'testERC721Image', + name: 'testERC721Name', standard: ERC721, }, }, }, true, ); - - clock.restore(); + // No on-chain RPC fallback for name/symbol — sourced from API only + expect(mockGetERC721AssetName).not.toHaveBeenCalled(); + expect(mockGetERC721AssetSymbol).not.toHaveBeenCalled(); }); it('should handle ERC1155 type and add to suggestedNfts with the OpenSea API disabled', async function () { @@ -655,30 +994,46 @@ describe('NftController', () => { }), ); - const { nftController, messenger, preferences } = setupController({ - getERC1155TokenURIStub: jest + const addRequestMock = jest.fn().mockResolvedValue(undefined); + const { + nftController, + triggerPreferencesStateChange, + triggerSelectedAccountChange, + } = setupController({ + getAccount: jest.fn().mockReturnValue(OWNER_ACCOUNT), + getERC721TokenURI: jest .fn() - .mockImplementation(() => 'https://testtokenuri.com'), - getERC1155BalanceOfStub: jest.fn().mockImplementation(() => new BN(1)), + .mockRejectedValue(new Error('Not an ERC721 contract')), + getERC1155TokenURI: jest + .fn() + .mockResolvedValue('https://testtokenuri.com'), + approvalAddRequest: addRequestMock, }); - preferences.setOpenSeaEnabled(false); - preferences.setIsIpfsGatewayEnabled(true); - const requestId = 'approval-request-id-1'; + (getNftOwnershipForMultipleNfts as jest.Mock).mockResolvedValueOnce([ + { isOwned: true }, + ]); - const clock = sinon.useFakeTimers(1); + triggerSelectedAccountChange(OWNER_ACCOUNT); + triggerPreferencesStateChange({ + ...getDefaultPreferencesState(), + isIpfsGatewayEnabled: true, + displayNftMedia: false, + }); - (v4 as jest.Mock).mockImplementationOnce(() => requestId); + const requestId = 'approval-request-id-1'; - const callActionSpy = jest.spyOn(messenger, 'call').mockResolvedValue({}); + jest.spyOn(Date, 'now').mockReturnValue(1); + (v4 as jest.Mock).mockImplementationOnce(() => requestId); await nftController.watchNft( ERC1155_NFT, ERC1155, 'https://etherscan.io', + 'mainnet', ); - expect(callActionSpy).toHaveBeenCalledTimes(1); - expect(callActionSpy).toHaveBeenCalledWith( - 'ApprovalController:addRequest', + + expect(addRequestMock).toHaveBeenCalledTimes(1); + expect(addRequestMock).toHaveBeenCalledWith( { id: requestId, origin: 'https://etherscan.io', @@ -697,8 +1052,6 @@ describe('NftController', () => { }, true, ); - - clock.restore(); }); it('should handle ERC1155 type and add to suggestedNfts with the OpenSea API enabled', async function () { @@ -713,30 +1066,40 @@ describe('NftController', () => { }), ); - const { nftController, messenger, preferences } = setupController({ - getERC1155TokenURIStub: jest + const addRequestMock = jest.fn().mockResolvedValue(undefined); + const { nftController, triggerPreferencesStateChange } = setupController({ + getAccount: jest.fn().mockReturnValue(OWNER_ACCOUNT), + getERC721TokenURI: jest .fn() - .mockImplementation(() => 'https://testtokenuri.com'), - getERC1155BalanceOfStub: jest.fn().mockImplementation(() => new BN(1)), + .mockRejectedValue(new Error('Not an ERC721 contract')), + getERC1155TokenURI: jest + .fn() + .mockResolvedValue('https://testtokenuri.com'), + approvalAddRequest: addRequestMock, + }); + (getNftOwnershipForMultipleNfts as jest.Mock).mockResolvedValueOnce([ + { isOwned: true }, + ]); + triggerPreferencesStateChange({ + ...getDefaultPreferencesState(), + isIpfsGatewayEnabled: true, + displayNftMedia: true, }); - preferences.setOpenSeaEnabled(true); - preferences.setIsIpfsGatewayEnabled(true); - const requestId = 'approval-request-id-1'; - const clock = sinon.useFakeTimers(1); + const requestId = 'approval-request-id-1'; + jest.spyOn(Date, 'now').mockReturnValue(1); (v4 as jest.Mock).mockImplementationOnce(() => requestId); - const callActionSpy = jest.spyOn(messenger, 'call').mockResolvedValue({}); - await nftController.watchNft( ERC1155_NFT, ERC1155, 'https://etherscan.io', + 'mainnet', ); - expect(callActionSpy).toHaveBeenCalledTimes(1); - expect(callActionSpy).toHaveBeenCalledWith( - 'ApprovalController:addRequest', + + expect(addRequestMock).toHaveBeenCalledTimes(1); + expect(addRequestMock).toHaveBeenCalledWith( { id: requestId, origin: 'https://etherscan.io', @@ -755,11 +1118,9 @@ describe('NftController', () => { }, true, ); - - clock.restore(); }); - it('should add the NFT to the correct chainId/selectedAddress in state even if the user changes network and account before accepting the request', async function () { + it('should add the NFT to the correct chainId/selectedAddress in state when passed a userAddress in the options argument', async function () { nock('https://testtokenuri.com') .get('/') .reply( @@ -775,24 +1136,22 @@ describe('NftController', () => { nftController, messenger, approvalController, - preferences, - changeNetwork, + triggerPreferencesStateChange, + triggerSelectedAccountChange, } = setupController({ - getERC721OwnerOfStub: jest.fn().mockImplementation(() => OWNER_ADDRESS), - getERC721TokenURIStub: jest + getERC721TokenURI: jest .fn() - .mockImplementation(() => 'https://testtokenuri.com'), - getERC721AssetNameStub: jest - .fn() - .mockImplementation(() => 'testERC721Name'), - getERC721AssetSymbolStub: jest - .fn() - .mockImplementation(() => 'testERC721Symbol'), + .mockResolvedValue('https://testtokenuri.com'), + getERC721AssetName: jest.fn().mockResolvedValue('testERC721Name'), + getERC721AssetSymbol: jest.fn().mockResolvedValue('testERC721Symbol'), }); + (getNftOwnershipForMultipleNfts as jest.Mock).mockResolvedValueOnce([ + { isOwned: true }, + ]); const requestId = 'approval-request-id-1'; - const clock = sinon.useFakeTimers(1); + jest.spyOn(Date, 'now').mockReturnValue(1); (v4 as jest.Mock).mockImplementationOnce(() => requestId); @@ -803,39 +1162,53 @@ describe('NftController', () => { }); const acceptedRequest = new Promise((resolve) => { - nftController.subscribe((state) => { - if (state.allNfts?.[OWNER_ADDRESS]?.[GOERLI.chainId].length) { - resolve(); - } - }); + messenger.subscribe( + 'NftController:stateChange', + (state: NftControllerState) => { + if (state.allNfts?.[SECOND_OWNER_ADDRESS]?.[GOERLI.chainId]) { + resolve(); + } + }, + ); }); // check that the NFT is not in state to begin with expect(nftController.state.allNfts).toStrictEqual({}); // this is our account and network status when the watchNFT request is made - preferences.setSelectedAddress(OWNER_ADDRESS); - changeNetwork(GOERLI); + triggerSelectedAccountChange(OWNER_ACCOUNT); + triggerPreferencesStateChange({ + ...getDefaultPreferencesState(), + displayNftMedia: true, + }); - nftController.watchNft(ERC721_NFT, ERC721, 'https://etherscan.io'); + // TODO: Either fix this lint violation or explain why it's necessary to ignore. + // eslint-disable-next-line @typescript-eslint/no-floating-promises + nftController.watchNft( + ERC721_NFT, + ERC721, + 'https://etherscan.io', + 'goerli', + { + userAddress: SECOND_OWNER_ADDRESS, + }, + ); await pendingRequest; - // change the network and selectedAddress before accepting the request - preferences.setSelectedAddress('0xDifferentAddress'); - changeNetwork(SEPOLIA); // now accept the request - approvalController.accept(requestId); + // TODO: Either fix this lint violation or explain why it's necessary to ignore. + // eslint-disable-next-line @typescript-eslint/no-floating-promises + approvalController.acceptRequest(requestId); await acceptedRequest; // check that the NFT was added to the correct chainId/selectedAddress in state const { state: { allNfts }, } = nftController; + expect(allNfts).toStrictEqual({ - // this is the selectedAddress when the request was made - [OWNER_ADDRESS]: { - // this is the chainId when the request was made + [SECOND_OWNER_ADDRESS]: { [GOERLI.chainId]: [ { ...ERC721_NFT, @@ -845,54 +1218,243 @@ describe('NftController', () => { image: 'testERC721Image', name: 'testERC721Name', standard: ERC721, + chainId: convertHexToDecimal(ChainId.goerli), }, ], }, }); - - clock.restore(); }); - it('should throw an error when calls to `ownerOf` and `balanceOf` revert', async function () { - const { nftController, changeNetwork } = setupController(); - // getERC721OwnerOf not mocked - // getERC1155BalanceOf not mocked - - changeNetwork(SEPOLIA); - - const requestId = 'approval-request-id-1'; - (v4 as jest.Mock).mockImplementationOnce(() => requestId); - - const result = nftController.watchNft( + it('should add the NFT to the correct chainId/selectedAddress (when passed a networkClientId) in state even if the user changes network and account before accepting the request', async function () { + nock('https://testtokenuri.com') + .get('/') + .reply( + 200, + JSON.stringify({ + image: 'testERC721Image', + name: 'testERC721Name', + description: 'testERC721Description', + }), + ); + + const { + nftController, + messenger, + approvalController, + triggerPreferencesStateChange, + triggerSelectedAccountChange, + } = setupController({ + getERC721TokenURI: jest + .fn() + .mockResolvedValue('https://testtokenuri.com'), + getERC721AssetName: jest.fn().mockResolvedValue('testERC721Name'), + getERC721AssetSymbol: jest.fn().mockResolvedValue('testERC721Symbol'), + }); + (getNftOwnershipForMultipleNfts as jest.Mock).mockResolvedValueOnce([ + { isOwned: true }, + ]); + + const requestId = 'approval-request-id-1'; + + jest.spyOn(Date, 'now').mockReturnValue(1); + + (v4 as jest.Mock).mockImplementationOnce(() => requestId); + + const pendingRequest = new Promise((resolve) => { + messenger.subscribe('ApprovalController:stateChange', () => { + resolve(); + }); + }); + + const acceptedRequest = new Promise((resolve) => { + messenger.subscribe( + 'NftController:stateChange', + (state: NftControllerState) => { + if (state.allNfts?.[OWNER_ADDRESS]?.[GOERLI.chainId].length) { + resolve(); + } + }, + ); + }); + + // check that the NFT is not in state to begin with + expect(nftController.state.allNfts).toStrictEqual({}); + + // this is our account and network status when the watchNFT request is made + triggerSelectedAccountChange(OWNER_ACCOUNT); + triggerPreferencesStateChange({ + ...getDefaultPreferencesState(), + displayNftMedia: true, + selectedAddress: OWNER_ADDRESS, + }); + + // TODO: Either fix this lint violation or explain why it's necessary to ignore. + // eslint-disable-next-line @typescript-eslint/no-floating-promises + nftController.watchNft( ERC721_NFT, ERC721, - 'https://test-dapp.com', + 'https://etherscan.io', + 'goerli', ); - await expect(result).rejects.toThrow( + + await pendingRequest; + + // change the network and selectedAddress before accepting the request + const differentAccount = createMockInternalAccount({ + address: '0xfa2d29eb2dbd1fc5ed7e781aa0549a7b3e032f1d', + }); + triggerSelectedAccountChange(differentAccount); + triggerPreferencesStateChange({ + ...getDefaultPreferencesState(), + displayNftMedia: true, + }); + // now accept the request + // TODO: Either fix this lint violation or explain why it's necessary to ignore. + // eslint-disable-next-line @typescript-eslint/no-floating-promises + approvalController.acceptRequest(requestId); + await acceptedRequest; + + // check that the NFT was added to the correct chainId/selectedAddress in state + const { + state: { allNfts }, + } = nftController; + expect(allNfts).toStrictEqual({ + // this is the selectedAddress when the request was made + [OWNER_ADDRESS]: { + // this is the chainId when the request was made + [GOERLI.chainId]: [ + { + ...ERC721_NFT, + favorite: false, + isCurrentlyOwned: true, + description: 'testERC721Description', + image: 'testERC721Image', + name: 'testERC721Name', + standard: ERC721, + chainId: convertHexToDecimal(ChainId.goerli), + }, + ], + }, + }); + + jest.restoreAllMocks(); + }); + + it('should throw an error when calls to `ownerOf` and `balanceOf` revert', async function () { + const { nftController } = setupController(); + (getNftOwnershipForMultipleNfts as jest.Mock).mockResolvedValueOnce([ + { isOwned: undefined }, + ]); + + const requestId = 'approval-request-id-1'; + (v4 as jest.Mock).mockImplementationOnce(() => requestId); + + // Awaiting `expect` as recommended by eslint results in this test stalling and timing out. + // eslint-disable-next-line @typescript-eslint/no-floating-promises, jest/valid-expect + expect( + async () => + await nftController.watchNft( + ERC721_NFT, + ERC721, + 'https://test-dapp.com', + 'sepolia', + ), + ).rejects.toThrow( "Unable to verify ownership. Possibly because the standard is not supported or the user's currently selected network does not match the chain of the asset in question.", ); }); }); describe('addNft', () => { + it('should add the nft contract to the correct chain in state when source is detected', async () => { + const { nftController } = setupController({ + options: {}, + }); + + await nftController.addNft('0x01', '1', 'mainnet', { + nftMetadata: { + name: 'name', + image: 'image', + description: 'description', + standard: 'standard', + favorite: false, + collection: { + tokenCount: '0', + image: 'url', + }, + }, + // chainId: ChainId.mainnet, + source: Source.Detected, + }); + + expect( + nftController.state.allNftContracts[OWNER_ACCOUNT.address][ + ChainId.mainnet + ][0], + ).toStrictEqual({ + address: '0x01', + logo: 'url', + schemaName: 'standard', + totalSupply: '0', + }); + }); + + it('should add the nft contract to the correct chain in state when source is custom', async () => { + const { nftController } = setupController({ + options: {}, + }); + + await nftController.addNft('0x01', '1', 'sepolia', { + nftMetadata: { + name: 'name', + image: 'image', + description: 'description', + standard: 'standard', + favorite: false, + collection: { + tokenCount: '0', + image: 'url', + }, + }, + source: Source.Custom, + }); + expect( + nftController.state.allNftContracts[OWNER_ACCOUNT.address][ + ChainId.sepolia + ][0], + ).toStrictEqual({ + address: '0x01', + logo: 'url', + schemaName: 'standard', + totalSupply: '0', + }); + }); it('should add NFT and NFT contract', async () => { - const { nftController } = setupController(); + const { nftController } = setupController({ + options: { + // chainId: ChainId.mainnet, + }, + }); - const { selectedAddress, chainId } = nftController.config; - await nftController.addNft('0x01', '1', { + await nftController.addNft('0x01', '1', 'mainnet', { nftMetadata: { name: 'name', image: 'image', description: 'description', standard: 'standard', favorite: false, + collection: { + tokenCount: '0', + image: 'url', + }, }, }); expect( - nftController.state.allNfts[selectedAddress][chainId][0], + nftController.state.allNfts[OWNER_ACCOUNT.address][ChainId.mainnet][0], ).toStrictEqual({ address: '0x01', + chainId: convertHexToDecimal(ChainId.mainnet), description: 'description', image: 'image', name: 'name', @@ -900,26 +1462,33 @@ describe('NftController', () => { standard: 'standard', favorite: false, isCurrentlyOwned: true, + collection: { + tokenCount: '0', + image: 'url', + }, }); expect( - nftController.state.allNftContracts[selectedAddress][chainId][0], + nftController.state.allNftContracts[OWNER_ACCOUNT.address][ + ChainId.mainnet + ][0], ).toStrictEqual({ address: '0x01', - description: 'Description', logo: 'url', - name: 'Name', - symbol: 'FOO', - totalSupply: 0, + totalSupply: '0', + schemaName: 'standard', }); }); it('should call onNftAdded callback correctly when NFT is manually added', async () => { - const { nftController, onNftAddedSpy } = setupController({ - includeOnNftAdded: true, + const mockOnNftAdded = jest.fn(); + const { nftController } = setupController({ + options: { + onNftAdded: mockOnNftAdded, + }, }); - await nftController.addNft('0x01', '1', { + await nftController.addNft('0x01', '1', 'mainnet', { nftMetadata: { name: 'name', image: 'image', @@ -929,64 +1498,99 @@ describe('NftController', () => { }, }); - expect(onNftAddedSpy).toHaveBeenCalledWith({ + expect(mockOnNftAdded).toHaveBeenCalledWith({ source: Source.Custom, tokenId: '1', address: '0x01', standard: 'ERC1155', - symbol: 'FOO', }); }); it('should call onNftAdded callback correctly when NFT is added via detection', async () => { - const { nftController, onNftAddedSpy } = setupController({ - includeOnNftAdded: true, + const mockOnNftAdded = jest.fn(); + const { nftController } = setupController({ + options: { + onNftAdded: mockOnNftAdded, + }, }); const detectedUserAddress = '0x123'; - await nftController.addNft('0x01', '2', { + await nftController.addNft('0x01', '2', 'mainnet', { nftMetadata: { name: 'name', image: 'image', description: 'description', - standard: 'ERC721', + standard: ERC721, favorite: false, }, userAddress: detectedUserAddress, - chainId: toHex(2), source: Source.Detected, }); - expect(onNftAddedSpy).toHaveBeenCalledWith({ + expect(mockOnNftAdded).toHaveBeenCalledWith({ source: 'detected', tokenId: '2', address: '0x01', - standard: 'ERC721', - symbol: 'FOO', + standard: ERC721, }); }); it('should add NFT by selected address', async () => { - const { nftController, preferences } = setupController(); - const { chainId } = nftController.config; + const tokenURI = 'https://url/'; + const mockGetERC721TokenURI = jest.fn().mockResolvedValue(tokenURI); + const mockGetERC1155TokenURI = jest.fn().mockRejectedValue(''); + + const { + nftController, + triggerPreferencesStateChange, + triggerSelectedAccountChange, + mockGetAccount, + } = setupController({ + getERC721TokenURI: mockGetERC721TokenURI, + getERC1155TokenURI: mockGetERC1155TokenURI, + }); const firstAddress = '0x123'; + const firstAccount = createMockInternalAccount({ address: firstAddress }); const secondAddress = '0x321'; + const secondAccount = createMockInternalAccount({ + address: secondAddress, + }); - sinon - .stub(nftController, 'getNftInformation' as any) - .returns({ name: 'name', image: 'url', description: 'description' }); - preferences.update({ selectedAddress: firstAddress }); - await nftController.addNft('0x01', '1234'); - preferences.update({ selectedAddress: secondAddress }); - await nftController.addNft('0x02', '4321'); - preferences.update({ selectedAddress: firstAddress }); + mockGetAccount.mockReturnValue(firstAccount); + triggerSelectedAccountChange(firstAccount); + nock('https://url').get('/').reply(200, { + name: 'name', + image: 'url', + description: 'description', + }); + triggerPreferencesStateChange({ + ...getDefaultPreferencesState(), + displayNftMedia: true, + }); + await nftController.addNft('0x01', '1234', 'mainnet'); + mockGetAccount.mockReturnValue(secondAccount); + triggerSelectedAccountChange(secondAccount); + triggerPreferencesStateChange({ + ...getDefaultPreferencesState(), + displayNftMedia: true, + }); + await nftController.addNft('0x02', '4321', 'mainnet'); + mockGetAccount.mockReturnValue(firstAccount); + triggerSelectedAccountChange(firstAccount); + triggerPreferencesStateChange({ + ...getDefaultPreferencesState(), + displayNftMedia: true, + }); expect( - nftController.state.allNfts[firstAddress][chainId][0], + nftController.state.allNfts[firstAddress][ChainId.mainnet][0], ).toStrictEqual({ address: '0x01', + chainId: convertHexToDecimal(ChainId.mainnet), description: 'description', image: 'url', name: 'name', + standard: ERC721, + tokenURI, tokenId: '1234', favorite: false, isCurrentlyOwned: true, @@ -994,10 +1598,11 @@ describe('NftController', () => { }); it('should update NFT if image is different', async () => { - const { nftController } = setupController(); - const { selectedAddress, chainId } = nftController.config; + const { nftController } = setupController({ + defaultSelectedAccount: OWNER_ACCOUNT, + }); - await nftController.addNft('0x01', '1', { + await nftController.addNft('0x01', '1', 'mainnet', { nftMetadata: { name: 'name', image: 'image', @@ -1008,9 +1613,10 @@ describe('NftController', () => { }); expect( - nftController.state.allNfts[selectedAddress][chainId][0], + nftController.state.allNfts[OWNER_ACCOUNT.address][ChainId.mainnet][0], ).toStrictEqual({ address: '0x01', + chainId: convertHexToDecimal(ChainId.mainnet), description: 'description', image: 'image', name: 'name', @@ -1020,7 +1626,7 @@ describe('NftController', () => { isCurrentlyOwned: true, }); - await nftController.addNft('0x01', '1', { + await nftController.addNft('0x01', '1', 'mainnet', { nftMetadata: { name: 'name', image: 'image-updated', @@ -1031,9 +1637,10 @@ describe('NftController', () => { }); expect( - nftController.state.allNfts[selectedAddress][chainId][0], + nftController.state.allNfts[OWNER_ACCOUNT.address][ChainId.mainnet][0], ).toStrictEqual({ address: '0x01', + chainId: convertHexToDecimal(ChainId.mainnet), description: 'description', image: 'image-updated', name: 'name', @@ -1043,11 +1650,13 @@ describe('NftController', () => { isCurrentlyOwned: true, }); }); + it('should update NFT collection field if new nft metadata has new keys', async () => { + const { nftController } = setupController({ + options: {}, + defaultSelectedAccount: OWNER_ACCOUNT, + }); - it('should not duplicate NFT nor NFT contract if already added', async () => { - const { nftController } = setupController(); - const { selectedAddress, chainId } = nftController.config; - await nftController.addNft('0x01', '1', { + await nftController.addNft('0x01', '1', 'mainnet', { nftMetadata: { name: 'name', image: 'image', @@ -1057,265 +1666,369 @@ describe('NftController', () => { }, }); - await nftController.addNft('0x01', '1', { + expect( + nftController.state.allNfts[OWNER_ACCOUNT.address][ChainId.mainnet][0], + ).toStrictEqual({ + address: '0x01', + chainId: convertHexToDecimal(ChainId.mainnet), + description: 'description', + image: 'image', + name: 'name', + standard: 'standard', + tokenId: '1', + favorite: false, + isCurrentlyOwned: true, + }); + + await nftController.addNft('0x01', '1', 'mainnet', { nftMetadata: { name: 'name', image: 'image', description: 'description', standard: 'standard', favorite: false, + collection: { + id: 'address', + openseaVerificationStatus: 'verified', + contractDeployedAt: 'timestamp', + }, }, }); expect( - nftController.state.allNfts[selectedAddress][chainId], - ).toHaveLength(1); - - expect( - nftController.state.allNftContracts[selectedAddress][chainId], - ).toHaveLength(1); + nftController.state.allNfts[OWNER_ACCOUNT.address][ChainId.mainnet][0], + ).toStrictEqual({ + address: '0x01', + chainId: convertHexToDecimal(ChainId.mainnet), + description: 'description', + image: 'image', + name: 'name', + tokenId: '1', + standard: 'standard', + favorite: false, + isCurrentlyOwned: true, + collection: { + id: 'address', + openseaVerificationStatus: 'verified', + contractDeployedAt: 'timestamp', + }, + }); }); - it('should add NFT and get information from OpenSea', async () => { - const { nftController } = setupController(); + it('should not update NFT collection field if new nft metadata does not have new keys', async () => { + const mockOnNftAdded = jest.fn(); + const { nftController } = setupController({ + options: { + onNftAdded: mockOnNftAdded, + }, + defaultSelectedAccount: OWNER_ACCOUNT, + }); + + await nftController.addNft('0x01', '1', 'mainnet', { + nftMetadata: { + name: 'name', + image: 'image', + description: 'description', + standard: 'standard', + favorite: false, + collection: { + id: 'address', + openseaVerificationStatus: 'verified', + contractDeployedAt: 'timestamp', + }, + }, + }); + expect(mockOnNftAdded).toHaveBeenCalled(); - const { selectedAddress, chainId } = nftController.config; - await nftController.addNft('0x01', '1'); expect( - nftController.state.allNfts[selectedAddress][chainId][0], + nftController.state.allNfts[OWNER_ACCOUNT.address][ChainId.mainnet][0], ).toStrictEqual({ address: '0x01', - description: 'Description', - imageOriginal: 'url', - image: 'url', - name: 'Name', - standard: 'ERC1155', + chainId: convertHexToDecimal(ChainId.mainnet), + description: 'description', + image: 'image', + name: 'name', + standard: 'standard', tokenId: '1', favorite: false, isCurrentlyOwned: true, - tokenURI: '', + collection: { + id: 'address', + openseaVerificationStatus: 'verified', + contractDeployedAt: 'timestamp', + }, }); - }); - it('should add NFT erc721 and aggregate NFT data from both contract and OpenSea', async () => { - const { assetsContract, nftController } = setupController(); - nock(OPENSEA_PROXY_URL) - .get(`/asset/${ERC721_KUDOSADDRESS}/${ERC721_KUDOS_TOKEN_ID}`) - .reply(200, { - image_original_url: 'Kudos image (from proxy API)', - name: 'Kudos Name', - description: 'Kudos Description', - asset_contract: { - schema_name: 'ERC721', - }, - }) - .get(`/asset_contract/${ERC721_KUDOSADDRESS}`) - .reply(200, { - description: 'Kudos Description', - symbol: 'KDO', - total_supply: 10, + mockOnNftAdded.mockReset(); + + await nftController.addNft('0x01', '1', 'mainnet', { + nftMetadata: { + name: 'name', + image: 'image', + description: 'description', + standard: 'standard', + favorite: false, collection: { - name: 'Kudos', - image_url: 'Kudos logo (from proxy API)', + id: 'address', + openseaVerificationStatus: 'verified', + contractDeployedAt: 'timestamp', }, - }); + }, + }); - nock('https://ipfs.gitcoin.co:443') - .get('/api/v0/cat/QmPmt6EAaioN78ECnW5oCL8v2YvVSpoBjLCjrXhhsAvoov') - .reply(200, { - image: 'Kudos Image (directly from tokenURI)', - name: 'Kudos Name (directly from tokenURI)', - description: 'Kudos Description (directly from tokenURI)', - }); + expect(mockOnNftAdded).not.toHaveBeenCalled(); - nock('https://mainnet.infura.io:443', { encodedQueryParams: true }) - .post('/v3/ad3a368836ff4596becc3be8e2f137ac', { - jsonrpc: '2.0', - id: 1, - method: 'eth_call', - params: [ - { - to: ERC721_KUDOSADDRESS.toLowerCase(), - data: '0x06fdde03', - }, - 'latest', - ], - }) - .reply(200, { - jsonrpc: '2.0', - id: 1, - result: - '0x0000000000000000000000000000000000000000000000000000000000000020000000000000000000000000000000000000000000000000000000000000000a4b75646f73546f6b656e00000000000000000000000000000000000000000000', - }) - .post('/v3/ad3a368836ff4596becc3be8e2f137ac', { - jsonrpc: '2.0', - id: 2, - method: 'eth_call', - params: [ - { - to: ERC721_KUDOSADDRESS.toLowerCase(), - data: '0x95d89b41', - }, - 'latest', - ], - }) - .reply(200, { - jsonrpc: '2.0', - id: 2, - result: - '0x000000000000000000000000000000000000000000000000000000000000002000000000000000000000000000000000000000000000000000000000000000034b444f0000000000000000000000000000000000000000000000000000000000', - }) - .post('/v3/ad3a368836ff4596becc3be8e2f137ac', { - jsonrpc: '2.0', - id: 3, - method: 'eth_call', - params: [ - { - to: ERC721_KUDOSADDRESS.toLowerCase(), - data: '0x01ffc9a75b5e139f00000000000000000000000000000000000000000000000000000000', - }, - 'latest', - ], - }) - .reply(200, { - jsonrpc: '2.0', - id: 3, - result: - '0x0000000000000000000000000000000000000000000000000000000000000001', - }) - .post('/v3/ad3a368836ff4596becc3be8e2f137ac', { - jsonrpc: '2.0', - id: 4, - method: 'eth_call', - params: [ + expect( + nftController.state.allNfts[OWNER_ACCOUNT.address][ChainId.mainnet][0], + ).toStrictEqual({ + address: '0x01', + chainId: convertHexToDecimal(ChainId.mainnet), + description: 'description', + image: 'image', + name: 'name', + tokenId: '1', + standard: 'standard', + favorite: false, + isCurrentlyOwned: true, + collection: { + id: 'address', + openseaVerificationStatus: 'verified', + contractDeployedAt: 'timestamp', + }, + }); + }); + + it('should not duplicate NFT nor NFT contract if already added', async () => { + const { nftController } = setupController({ + defaultSelectedAccount: OWNER_ACCOUNT, + }); + + await nftController.addNft('0x01', '1', 'mainnet', { + nftMetadata: { + name: 'name', + image: 'image', + description: 'description', + standard: 'standard', + favorite: false, + }, + }); + + await nftController.addNft('0x01', '1', 'mainnet', { + nftMetadata: { + name: 'name', + image: 'image', + description: 'description', + standard: 'standard', + favorite: false, + }, + }); + + expect( + nftController.state.allNfts[OWNER_ACCOUNT.address][ChainId.mainnet], + ).toHaveLength(1); + + expect( + nftController.state.allNftContracts[OWNER_ACCOUNT.address][ + ChainId.mainnet + ], + ).toHaveLength(1); + }); + + it('should add NFT and get information from NFT-API', async () => { + const { nftController } = setupController({ + getERC721TokenURI: jest + .fn() + .mockRejectedValue(new Error('Not an ERC721 contract')), + getERC1155TokenURI: jest + .fn() + .mockRejectedValue(new Error('Not an ERC1155 contract')), + defaultSelectedAccount: OWNER_ACCOUNT, + }); + + await nftController.addNft('0x01', '1', 'mainnet'); + expect( + nftController.state.allNfts[OWNER_ACCOUNT.address][ChainId.mainnet][0], + ).toStrictEqual({ + address: '0x01', + chainId: convertHexToDecimal(ChainId.mainnet), + description: 'Description', + image: 'url', + name: 'Name', + standard: 'ERC1155', + tokenId: '1', + favorite: false, + isCurrentlyOwned: true, + tokenURI: '', + creator: 'Oxaddress', + collection: { + id: '0x1', + creator: 'Oxaddress', + tokenCount: 0, + }, + }); + }); + + it('should add NFT erc721 and aggregate NFT data from NFT-API even if call to Get Collections fails', async () => { + const { nftController } = setupController({ + getERC721TokenURI: jest + .fn() + .mockResolvedValue( + 'https://ipfs.gitcoin.co:443/api/v0/cat/QmPmt6EAaioN78ECnW5oCL8v2YvVSpoBjLCjrXhhsAvoov', + ), + defaultSelectedAccount: OWNER_ACCOUNT, + }); + nock(NFT_API_BASE_URL) + .get( + `/tokens?chainIds=1&tokens=${ERC721_KUDOSADDRESS}%3A${ERC721_KUDOS_TOKEN_ID}&includeTopBid=true&includeAttributes=true&includeLastSale=true`, + ) + .reply(200, { + tokens: [ { - to: ERC721_KUDOSADDRESS.toLowerCase(), - data: '0xc87b56dd00000000000000000000000000000000000000000000000000000000000004b3', + token: { + contract: `${ERC721_KUDOSADDRESS}`, + kind: 'erc721', + name: 'Kudos Name', + description: 'Kudos Description', + image: 'url', + collection: { + id: `${ERC721_KUDOSADDRESS}`, + }, + }, }, - 'latest', ], - }) - .reply(200, { - jsonrpc: '2.0', - id: 4, - result: - '0x0000000000000000000000000000000000000000000000000000000000000020000000000000000000000000000000000000000000000000000000000000005568747470733a2f2f697066732e676974636f696e2e636f3a3434332f6170692f76302f6361742f516d506d7436454161696f4e373845436e57356f434c38763259765653706f426a4c436a725868687341766f6f760000000000000000000000', }); - assetsContract.configure({ provider: MAINNET_PROVIDER }); - const { selectedAddress, chainId } = nftController.config; - sinon - .stub(nftController, 'getNftContractInformationFromApi' as any) - .returns(undefined); + nock(NFT_API_BASE_URL) + .get(`/collections?chainId=1&id=${ERC721_KUDOSADDRESS}`) + .replyWithError(new Error('Failed to fetch')); + + nock('https://ipfs.gitcoin.co:443') + .get('/api/v0/cat/QmPmt6EAaioN78ECnW5oCL8v2YvVSpoBjLCjrXhhsAvoov') + .reply(200, { + image: 'Kudos Image (directly from tokenURI)', + name: 'Kudos Name (directly from tokenURI)', + description: 'Kudos Description (directly from tokenURI)', + }); - await nftController.addNft(ERC721_KUDOSADDRESS, ERC721_KUDOS_TOKEN_ID); + await nftController.addNft( + ERC721_KUDOSADDRESS, + ERC721_KUDOS_TOKEN_ID, + 'mainnet', + ); expect( - nftController.state.allNfts[selectedAddress][chainId][0], + nftController.state.allNfts[OWNER_ACCOUNT.address][ChainId.mainnet][0], ).toStrictEqual({ address: ERC721_KUDOSADDRESS, - image: 'Kudos Image (directly from tokenURI)', + chainId: convertHexToDecimal(ChainId.mainnet), + image: 'url', name: 'Kudos Name (directly from tokenURI)', description: 'Kudos Description (directly from tokenURI)', tokenId: ERC721_KUDOS_TOKEN_ID, - imageOriginal: 'Kudos image (from proxy API)', - standard: 'ERC721', + standard: ERC721, favorite: false, isCurrentlyOwned: true, tokenURI: 'https://ipfs.gitcoin.co:443/api/v0/cat/QmPmt6EAaioN78ECnW5oCL8v2YvVSpoBjLCjrXhhsAvoov', + collection: { + id: ERC721_KUDOSADDRESS, + }, }); expect( - nftController.state.allNftContracts[selectedAddress][chainId][0], + nftController.state.allNftContracts[OWNER_ACCOUNT.address][ + ChainId.mainnet + ][0], ).toStrictEqual({ address: ERC721_KUDOSADDRESS, - name: 'KudosToken', - symbol: 'KDO', + schemaName: ERC721, }); }); - - it('should add NFT erc1155 and get NFT information from contract when OpenSea Proxy API fails to fetch and no OpenSeaAPI key is set', async () => { - const { assetsContract, nftController } = setupController(); - nock('https://mainnet.infura.io:443', { encodedQueryParams: true }) - .post('/v3/ad3a368836ff4596becc3be8e2f137ac', { - jsonrpc: '2.0', - id: 5, - method: 'eth_call', - params: [ - { - to: ERC1155_NFT_ADDRESS.toLowerCase(), - data: '0x06fdde03', - }, - 'latest', - ], - }) - .reply(200, { - jsonrpc: '2.0', - id: 5, - result: - '0x000000000000000000000000000000000000000000000000000000000000002000000000000000000000000000000000000000000000000000000000000000194f70656e536561205368617265642053746f726566726f6e7400000000000000', - }) - .post('/v3/ad3a368836ff4596becc3be8e2f137ac', { - jsonrpc: '2.0', - id: 6, - method: 'eth_call', - params: [ - { - to: ERC1155_NFT_ADDRESS.toLowerCase(), - data: '0x95d89b41', - }, - 'latest', - ], - }) - .reply(200, { - jsonrpc: '2.0', - id: 6, - result: - '0x000000000000000000000000000000000000000000000000000000000000002000000000000000000000000000000000000000000000000000000000000000094f50454e53544f52450000000000000000000000000000000000000000000000', - }) - .post('/v3/ad3a368836ff4596becc3be8e2f137ac', { - jsonrpc: '2.0', - id: 7, - method: 'eth_call', - params: [ - { - to: ERC1155_NFT_ADDRESS.toLowerCase(), - data: '0x01ffc9a75b5e139f00000000000000000000000000000000000000000000000000000000', - }, - 'latest', - ], - }) + it('should add NFT erc721 and aggregate NFT data from NFT-API when call to Get Collections succeeds', async () => { + const { nftController } = setupController({ + getERC721TokenURI: jest + .fn() + .mockResolvedValue( + 'https://ipfs.gitcoin.co:443/api/v0/cat/QmPmt6EAaioN78ECnW5oCL8v2YvVSpoBjLCjrXhhsAvoov', + ), + defaultSelectedAccount: OWNER_ACCOUNT, + }); + nock(NFT_API_BASE_URL) + .get( + `/tokens?chainIds=1&tokens=${ERC721_KUDOSADDRESS}%3A${ERC721_KUDOS_TOKEN_ID}&includeTopBid=true&includeAttributes=true&includeLastSale=true`, + ) .reply(200, { - jsonrpc: '2.0', - id: 7, - result: - '0x0000000000000000000000000000000000000000000000000000000000000000', - }) - .post('/v3/ad3a368836ff4596becc3be8e2f137ac', { - jsonrpc: '2.0', - id: 8, - method: 'eth_call', - params: [ + tokens: [ { - to: ERC1155_NFT_ADDRESS.toLowerCase(), - data: '0x0e89341c5a3ca5cd63807ce5e4d7841ab32ce6b6d9bbba2d000000000000010000000001', + token: { + contract: ERC721_KUDOSADDRESS, + kind: 'erc721', + name: 'Kudos Name', + description: 'Kudos Description', + image: 'url', + collection: { + id: ERC721_KUDOSADDRESS, + }, + }, }, - 'latest', ], - }) + }); + + nock('https://ipfs.gitcoin.co:443') + .get('/api/v0/cat/QmPmt6EAaioN78ECnW5oCL8v2YvVSpoBjLCjrXhhsAvoov') .reply(200, { - jsonrpc: '2.0', - id: 8, - result: - '0x0000000000000000000000000000000000000000000000000000000000000020000000000000000000000000000000000000000000000000000000000000005868747470733a2f2f6170692e6f70656e7365612e696f2f6170692f76312f6d657461646174612f3078343935663934373237363734394365363436663638414338633234383432303034356362376235652f30787b69647d0000000000000000', + image: 'Kudos Image (directly from tokenURI)', + name: 'Kudos Name (directly from tokenURI)', + description: 'Kudos Description (directly from tokenURI)', }); - nock(OPENSEA_PROXY_URL) - .get(`/asset_contract/${ERC1155_NFT_ADDRESS}`) - .replyWithError(new TypeError('Failed to fetch')); + await nftController.addNft( + ERC721_KUDOSADDRESS, + ERC721_KUDOS_TOKEN_ID, + 'mainnet', + ); + + expect( + nftController.state.allNfts[OWNER_ACCOUNT.address][ChainId.mainnet][0], + ).toStrictEqual({ + address: ERC721_KUDOSADDRESS, + chainId: convertHexToDecimal(ChainId.mainnet), + image: 'url', + name: 'Kudos Name (directly from tokenURI)', + description: 'Kudos Description (directly from tokenURI)', + tokenId: ERC721_KUDOS_TOKEN_ID, + standard: ERC721, + favorite: false, + isCurrentlyOwned: true, + tokenURI: + 'https://ipfs.gitcoin.co:443/api/v0/cat/QmPmt6EAaioN78ECnW5oCL8v2YvVSpoBjLCjrXhhsAvoov', + collection: { + id: ERC721_KUDOSADDRESS, + }, + }); + + expect( + nftController.state.allNftContracts[OWNER_ACCOUNT.address][ + ChainId.mainnet + ][0], + ).toStrictEqual({ + address: ERC721_KUDOSADDRESS, + schemaName: ERC721, + }); + }); - // the tokenURI for ERC1155_NFT_ADDRESS + ERC1155_NFT_ID + it('should add NFT erc1155 and get NFT information from contract when NFT API call fail', async () => { + const { nftController } = setupController({ + getERC721TokenURI: jest + .fn() + .mockRejectedValue(new Error('Not a 721 contract')), + getERC1155TokenURI: jest + .fn() + .mockResolvedValue( + 'https://api.opensea.io/api/v1/metadata/0x495f947276749Ce646f68AC8c248420045cb7b5e/0x{id}', + ), + defaultSelectedAccount: OWNER_ACCOUNT, + }); nock('https://api.opensea.io') .get( `/api/v1/metadata/${ERC1155_NFT_ADDRESS}/0x5a3ca5cd63807ce5e4d7841ab32ce6b6d9bbba2d000000000000010000000001`, @@ -1328,17 +2041,17 @@ describe('NftController', () => { animation_url: null, }); - assetsContract.configure({ provider: MAINNET_PROVIDER }); - const { selectedAddress, chainId } = nftController.config; - - expect(nftController.openSeaApiKey).toBeUndefined(); - - await nftController.addNft(ERC1155_NFT_ADDRESS, ERC1155_NFT_ID); + await nftController.addNft( + ERC1155_NFT_ADDRESS, + ERC1155_NFT_ID, + 'mainnet', + ); expect( - nftController.state.allNfts[selectedAddress][chainId][0], + nftController.state.allNfts[OWNER_ACCOUNT.address][ChainId.mainnet][0], ).toStrictEqual({ address: ERC1155_NFT_ADDRESS, + chainId: convertHexToDecimal(ChainId.mainnet), image: 'image (directly from tokenURI)', name: 'name (directly from tokenURI)', description: 'description (directly from tokenURI)', @@ -1346,15 +2059,23 @@ describe('NftController', () => { standard: ERC1155, favorite: false, isCurrentlyOwned: true, - imageOriginal: 'image.uri', - numberOfSales: 1, tokenURI: 'https://api.opensea.io/api/v1/metadata/0x495f947276749Ce646f68AC8c248420045cb7b5e/0x5a3ca5cd63807ce5e4d7841ab32ce6b6d9bbba2d000000000000010000000001', }); }); - it('should add NFT erc721 and get NFT information only from contract', async () => { - const { assetsContract, nftController } = setupController(); + it('should add NFT erc721 and get NFT information from tokenURI when NFT API returns 404', async () => { + const { nftController } = setupController({ + getERC721TokenURI: jest.fn().mockImplementation((tokenAddress) => { + switch (tokenAddress) { + case ERC721_KUDOSADDRESS: + return 'https://ipfs.gitcoin.co:443/api/v0/cat/QmPmt6EAaioN78ECnW5oCL8v2YvVSpoBjLCjrXhhsAvoov'; + default: + throw new Error('Not an ERC721 token'); + } + }), + defaultSelectedAccount: OWNER_ACCOUNT, + }); nock('https://ipfs.gitcoin.co:443') .get('/api/v0/cat/QmPmt6EAaioN78ECnW5oCL8v2YvVSpoBjLCjrXhhsAvoov') .reply(200, { @@ -1363,101 +2084,28 @@ describe('NftController', () => { description: 'Kudos Description (directly from tokenURI)', }); - nock('https://mainnet.infura.io:443', { encodedQueryParams: true }) - .post('/v3/ad3a368836ff4596becc3be8e2f137ac', { - jsonrpc: '2.0', - id: 9, - method: 'eth_call', - params: [ - { - to: ERC721_KUDOSADDRESS.toLowerCase(), - data: '0x06fdde03', - }, - 'latest', - ], - }) - .reply(200, { - jsonrpc: '2.0', - id: 9, - result: - '0x0000000000000000000000000000000000000000000000000000000000000020000000000000000000000000000000000000000000000000000000000000000a4b75646f73546f6b656e00000000000000000000000000000000000000000000', - }) - .post('/v3/ad3a368836ff4596becc3be8e2f137ac', { - jsonrpc: '2.0', - id: 10, - method: 'eth_call', - params: [ - { - to: ERC721_KUDOSADDRESS.toLowerCase(), - data: '0x95d89b41', - }, - 'latest', - ], - }) - .reply(200, { - jsonrpc: '2.0', - id: 10, - result: - '0x000000000000000000000000000000000000000000000000000000000000002000000000000000000000000000000000000000000000000000000000000000034b444f0000000000000000000000000000000000000000000000000000000000', - }) - .post('/v3/ad3a368836ff4596becc3be8e2f137ac', { - jsonrpc: '2.0', - id: 11, - method: 'eth_call', - params: [ - { - to: ERC721_KUDOSADDRESS.toLowerCase(), - data: '0x01ffc9a75b5e139f00000000000000000000000000000000000000000000000000000000', - }, - 'latest', - ], - }) - .reply(200, { - jsonrpc: '2.0', - id: 11, - result: - '0x0000000000000000000000000000000000000000000000000000000000000001', - }) - .post('/v3/ad3a368836ff4596becc3be8e2f137ac', { - jsonrpc: '2.0', - id: 12, - method: 'eth_call', - params: [ - { - to: ERC721_KUDOSADDRESS.toLowerCase(), - data: '0xc87b56dd00000000000000000000000000000000000000000000000000000000000004b3', - }, - 'latest', - ], - }) - .reply(200, { - jsonrpc: '2.0', - id: 12, - result: - '0x0000000000000000000000000000000000000000000000000000000000000020000000000000000000000000000000000000000000000000000000000000005568747470733a2f2f697066732e676974636f696e2e636f3a3434332f6170692f76302f6361742f516d506d7436454161696f4e373845436e57356f434c38763259765653706f426a4c436a725868687341766f6f760000000000000000000000', - }); - - assetsContract.configure({ provider: MAINNET_PROVIDER }); - const { selectedAddress, chainId } = nftController.config; - sinon - .stub(nftController, 'getNftContractInformationFromApi' as any) - .returns(undefined); - - sinon - .stub(nftController, 'getNftInformationFromApi' as any) - .returns(undefined); + nock('https://nft.api.cx.metamask.io') + .get( + '/tokens?chainIds=1&tokens=0x2aEa4Add166EBf38b63d09a75dE1a7b94Aa24163%3A1203&includeTopBid=true&includeAttributes=true&includeLastSale=true', + ) + .reply(404, { error: 'Not found' }); - await nftController.addNft(ERC721_KUDOSADDRESS, ERC721_KUDOS_TOKEN_ID); + await nftController.addNft( + ERC721_KUDOSADDRESS, + ERC721_KUDOS_TOKEN_ID, + 'mainnet', + ); expect( - nftController.state.allNfts[selectedAddress][chainId][0], + nftController.state.allNfts[OWNER_ACCOUNT.address][ChainId.mainnet][0], ).toStrictEqual({ address: ERC721_KUDOSADDRESS, + chainId: convertHexToDecimal(ChainId.mainnet), image: 'Kudos Image (directly from tokenURI)', name: 'Kudos Name (directly from tokenURI)', description: 'Kudos Description (directly from tokenURI)', tokenId: ERC721_KUDOS_TOKEN_ID, - standard: 'ERC721', + standard: ERC721, favorite: false, isCurrentlyOwned: true, tokenURI: @@ -1465,86 +2113,126 @@ describe('NftController', () => { }); expect( - nftController.state.allNftContracts[selectedAddress][chainId][0], + nftController.state.allNftContracts[OWNER_ACCOUNT.address][ + ChainId.mainnet + ][0], ).toStrictEqual({ address: ERC721_KUDOSADDRESS, - name: 'KudosToken', - symbol: 'KDO', + schemaName: ERC721, }); }); - it('should add NFT by provider type', async () => { - const { nftController, changeNetwork } = setupController(); - const { selectedAddress } = nftController.config; - sinon - .stub(nftController, 'getNftInformation' as any) - .returns({ name: 'name', image: 'url', description: 'description' }); - - changeNetwork(SEPOLIA); - await nftController.addNft('0x01', '1234'); - changeNetwork(GOERLI); - changeNetwork(SEPOLIA); + it('should return image when tokenURI fetched is an encoded data URL', async () => { + const testTokenUriEncoded = + 'data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHByZXNlcnZlQXNwZWN0UmF0aW89InhNaW5ZTWluIG1l'; + const { nftController } = setupController({ + getERC721AssetName: jest.fn().mockResolvedValue('KudosToken'), + getERC721AssetSymbol: jest.fn().mockResolvedValue('KDO'), + getERC721TokenURI: jest.fn().mockResolvedValue(testTokenUriEncoded), + defaultSelectedAccount: OWNER_ACCOUNT, + }); + await nftController.addNft( + ERC721_KUDOSADDRESS, + ERC721_KUDOS_TOKEN_ID, + 'mainnet', + ); expect( - nftController.state.allNfts[selectedAddress]?.[ChainId[GOERLI.type]], - ).toBeUndefined(); + nftController.state.allNfts[OWNER_ACCOUNT.address][ChainId.mainnet][0], + ).toStrictEqual({ + address: ERC721_KUDOSADDRESS, + chainId: convertHexToDecimal(ChainId.mainnet), + image: testTokenUriEncoded, + name: null, + description: null, + tokenId: ERC721_KUDOS_TOKEN_ID, + standard: ERC721, + favorite: false, + isCurrentlyOwned: true, + tokenURI: testTokenUriEncoded, + }); + }); + + it('should add NFT by provider type', async () => { + const tokenURI = 'https://url/'; + const mockGetERC721TokenURI = jest.fn().mockResolvedValue(tokenURI); + const { nftController } = setupController({ + getERC721TokenURI: mockGetERC721TokenURI, + defaultSelectedAccount: OWNER_ACCOUNT, + }); + nock('https://url').get('/').reply(200, { + name: 'name', + image: 'url', + description: 'description', + }); + + await nftController.addNft('0x01', '1234', 'sepolia'); expect( - nftController.state.allNfts[selectedAddress][ChainId[SEPOLIA.type]][0], + nftController.state.allNfts[OWNER_ACCOUNT.address][ + ChainId[SEPOLIA.type] + ][0], ).toStrictEqual({ address: '0x01', + chainId: convertHexToDecimal(ChainId.sepolia), description: 'description', image: 'url', name: 'name', + standard: ERC721, tokenId: '1234', favorite: false, isCurrentlyOwned: true, + tokenURI, }); }); it('should add an nft and nftContract to state when all contract information is falsy and the source is left empty (defaults to "custom")', async () => { - const { nftController, onNftAddedSpy } = setupController({ - includeOnNftAdded: true, - }); - const { selectedAddress, chainId } = nftController.config; - sinon.stub(nftController, 'getNftContractInformation' as any).returns({ - asset_contract_type: null, - created_date: null, - schema_name: null, - symbol: null, - total_supply: null, - description: null, - external_link: null, - collection: { name: null, image_url: null }, + const tokenURI = 'https://url/'; + const mockOnNftAdded = jest.fn(); + const mockGetERC721AssetSymbol = jest.fn().mockResolvedValue(''); + const mockGetERC721AssetName = jest.fn().mockResolvedValue(''); + const mockGetERC721TokenURI = jest.fn().mockResolvedValue(tokenURI); + const { nftController } = setupController({ + options: { + onNftAdded: mockOnNftAdded, + }, + getERC721AssetSymbol: mockGetERC721AssetSymbol, + getERC721AssetName: mockGetERC721AssetName, + getERC721TokenURI: mockGetERC721TokenURI, + defaultSelectedAccount: OWNER_ACCOUNT, }); - sinon.stub(nftController, 'getNftInformation' as any).returns({ + nock('https://url').get('/').reply(200, { name: 'name', image: 'url', description: 'description', }); - await nftController.addNft('0x01234abcdefg', '1234'); + await nftController.addNft('0x01234abcdefg', '1234', 'mainnet'); expect(nftController.state.allNftContracts).toStrictEqual({ - [selectedAddress]: { - [chainId]: [ + [OWNER_ACCOUNT.address]: { + [ChainId.mainnet]: [ { address: '0x01234abcdefg', + schemaName: ERC721, }, ], }, }); expect(nftController.state.allNfts).toStrictEqual({ - [selectedAddress]: { - [chainId]: [ + [OWNER_ACCOUNT.address]: { + [ChainId.mainnet]: [ { address: '0x01234abcdefg', + chainId: convertHexToDecimal(ChainId.mainnet), description: 'description', image: 'url', name: 'name', tokenId: '1234', + standard: ERC721, + tokenURI, favorite: false, isCurrentlyOwned: true, }, @@ -1552,39 +2240,36 @@ describe('NftController', () => { }, }); - expect(onNftAddedSpy).toHaveBeenCalledWith({ + expect(mockOnNftAdded).toHaveBeenCalledWith({ address: '0x01234abcdefg', tokenId: '1234', - standard: undefined, + standard: ERC721, symbol: undefined, source: Source.Custom, }); }); it('should add an nft and nftContract to state when all contract information is falsy and the source is "dapp"', async () => { - const { nftController, onNftAddedSpy } = setupController({ - includeOnNftAdded: true, - }); - - sinon.stub(nftController, 'getNftContractInformation' as any).returns({ - asset_contract_type: null, - created_date: null, - schema_name: null, - symbol: null, - total_supply: null, - description: null, - external_link: null, - collection: { name: null, image_url: null }, + const tokenURI = 'https://url/'; + const mockOnNftAdded = jest.fn(); + const mockGetERC721AssetSymbol = jest.fn().mockResolvedValue(''); + const mockGetERC721AssetName = jest.fn().mockResolvedValue(''); + const mockGetERC721TokenURI = jest.fn().mockResolvedValue(tokenURI); + const { nftController } = setupController({ + options: { + onNftAdded: mockOnNftAdded, + }, + getERC721AssetSymbol: mockGetERC721AssetSymbol, + getERC721AssetName: mockGetERC721AssetName, + getERC721TokenURI: mockGetERC721TokenURI, }); - - sinon.stub(nftController, 'getNftInformation' as any).returns({ + nock('https://url').get('/').reply(200, { name: 'name', image: 'url', description: 'description', }); - await nftController.addNft('0x01234abcdefg', '1234', { - chainId: GOERLI.chainId, + await nftController.addNft('0x01234abcdefg', '1234', 'goerli', { userAddress: '0x123', source: Source.Dapp, }); @@ -1594,6 +2279,7 @@ describe('NftController', () => { [GOERLI.chainId]: [ { address: '0x01234abcdefg', + schemaName: ERC721, }, ], }, @@ -1604,161 +2290,308 @@ describe('NftController', () => { [GOERLI.chainId]: [ { address: '0x01234abcdefg', + chainId: convertHexToDecimal(ChainId.goerli), description: 'description', image: 'url', name: 'name', tokenId: '1234', favorite: false, + standard: ERC721, isCurrentlyOwned: true, + tokenURI, }, ], }, }); - expect(onNftAddedSpy).toHaveBeenCalledWith({ + expect(mockOnNftAdded).toHaveBeenCalledWith({ address: '0x01234abcdefg', tokenId: '1234', - standard: undefined, + standard: ERC721, symbol: undefined, source: Source.Dapp, }); }); - it('should add an nft and nftContract when there is valid contract information and source is "detected"', async () => { - const { nftController, onNftAddedSpy } = setupController({ - includeOnNftAdded: true, + it('should add an nft and nftContract when there is valid contract information and source is "detected" when call to getCollections fails', async () => { + const mockOnNftAdded = jest.fn(); + const { nftController } = setupController({ + options: { + onNftAdded: mockOnNftAdded, + }, + getERC721AssetName: jest + .fn() + .mockRejectedValue(new Error('Failed to fetch')), + getERC721AssetSymbol: jest + .fn() + .mockRejectedValue(new Error('Failed to fetch')), + defaultSelectedAccount: OWNER_ACCOUNT, }); - nock(OPENSEA_PROXY_URL) - .get(`/asset/${ERC721_KUDOSADDRESS}/${ERC721_KUDOS_TOKEN_ID}`) - .reply(200, { - image_original_url: 'Kudos image (from proxy API)', - name: 'Kudos Name', - description: 'Kudos Description', - asset_contract: { - schema_name: 'ERC721', - }, - }) - .get(`/asset_contract/${ERC721_KUDOSADDRESS}`) + nock(NFT_API_BASE_URL) + .get( + `/tokens?chainIds=1&tokens=${ERC721_KUDOSADDRESS}%3A${ERC721_KUDOS_TOKEN_ID}&includeTopBid=true&includeAttributes=true&includeLastSale=true`, + ) .reply(200, { - description: 'Kudos Description', - symbol: 'KDO', - total_supply: 10, - collection: { - name: 'Kudos', - image_url: 'Kudos logo (from proxy API)', - }, + tokens: [ + { + token: { + contract: ERC721_KUDOSADDRESS, + kind: 'erc721', + name: 'Kudos Name', + description: 'Kudos Description', + image: 'Kudos image (from proxy API)', + collection: { + id: ERC721_KUDOSADDRESS, + name: 'Kudos', + tokenCount: '10', + image: 'Kudos logo (from proxy API)', + }, + }, + }, + ], }); - const { selectedAddress, chainId } = nftController.config; + nock(NFT_API_BASE_URL) + .get(`/collections?chainId=1&id=${ERC721_KUDOSADDRESS}`) + .replyWithError(new Error('Failed to fetch')); + await nftController.addNft( '0x6EbeAf8e8E946F0716E6533A6f2cefc83f60e8Ab', '123', + 'mainnet', { - userAddress: selectedAddress, - chainId, + userAddress: OWNER_ACCOUNT.address, source: Source.Detected, }, ); expect( - nftController.state.allNfts[selectedAddress]?.[chainId], + nftController.state.allNfts[OWNER_ACCOUNT.address]?.[ChainId.mainnet], ).toBeUndefined(); expect( - nftController.state.allNftContracts[selectedAddress]?.[chainId], + nftController.state.allNftContracts[OWNER_ACCOUNT.address]?.[ + ChainId.mainnet + ], ).toBeUndefined(); - await nftController.addNft(ERC721_KUDOSADDRESS, ERC721_KUDOS_TOKEN_ID, { - userAddress: selectedAddress, - chainId, - source: Source.Detected, - }); + await nftController.addNft( + ERC721_KUDOSADDRESS, + ERC721_KUDOS_TOKEN_ID, + 'mainnet', + { + userAddress: OWNER_ACCOUNT.address, + source: Source.Detected, + }, + ); expect( - nftController.state.allNfts[selectedAddress][chainId], + nftController.state.allNfts[OWNER_ACCOUNT.address][ChainId.mainnet], ).toStrictEqual([ { address: ERC721_KUDOSADDRESS, + chainId: convertHexToDecimal(ChainId.mainnet), description: 'Kudos Description', - imageOriginal: 'Kudos image (from proxy API)', + image: 'Kudos image (from proxy API)', name: 'Kudos Name', - image: null, - standard: 'ERC721', + standard: ERC721, tokenId: ERC721_KUDOS_TOKEN_ID, favorite: false, isCurrentlyOwned: true, - tokenURI: '', + tokenURI: null, + collection: { + id: ERC721_KUDOSADDRESS, + tokenCount: '10', + image: 'Kudos logo (from proxy API)', + name: 'Kudos', + }, }, ]); expect( - nftController.state.allNftContracts[selectedAddress][chainId], + nftController.state.allNftContracts[OWNER_ACCOUNT.address][ + ChainId.mainnet + ], ).toStrictEqual([ { address: ERC721_KUDOSADDRESS, - description: 'Kudos Description', logo: 'Kudos logo (from proxy API)', name: 'Kudos', - symbol: 'KDO', - totalSupply: 10, + totalSupply: '10', + schemaName: ERC721, }, ]); - expect(onNftAddedSpy).toHaveBeenCalledWith({ + expect(mockOnNftAdded).toHaveBeenCalledWith({ address: ERC721_KUDOSADDRESS, tokenId: ERC721_KUDOS_TOKEN_ID, - standard: 'ERC721', - symbol: 'KDO', + standard: ERC721, source: Source.Detected, }); }); - it('should not add an nft and nftContract when there is not valid contract information (or an issue fetching it) and source is "detected"', async () => { - const { nftController, onNftAddedSpy } = setupController({ - includeOnNftAdded: true, + it('should add an nft and nftContract when there is valid contract information and source is "detected" when call to get collections succeeds', async () => { + const mockOnNftAdded = jest.fn(); + const { nftController } = setupController({ + options: { + onNftAdded: mockOnNftAdded, + }, + getERC721AssetName: jest + .fn() + .mockRejectedValue(new Error('Failed to fetch')), + getERC721AssetSymbol: jest + .fn() + .mockRejectedValue(new Error('Failed to fetch')), + defaultSelectedAccount: OWNER_ACCOUNT, }); - nock(OPENSEA_PROXY_URL) - .get(`/asset/${ERC721_KUDOSADDRESS}/${ERC721_KUDOS_TOKEN_ID}`) + nock(NFT_API_BASE_URL) + .get( + `/tokens?chainIds=1&tokens=${ERC721_KUDOSADDRESS}%3A${ERC721_KUDOS_TOKEN_ID}&includeTopBid=true&includeAttributes=true&includeLastSale=true`, + ) .reply(200, { - image_original_url: 'Kudos image (from proxy API)', - name: 'Kudos Name', - description: 'Kudos Description', - asset_contract: { - schema_name: 'ERC721', - }, - }) - .get(`/asset_contract/${ERC721_KUDOSADDRESS}`) - .replyWithError(new Error('Failed to fetch')); + tokens: [ + { + token: { + contract: ERC721_KUDOSADDRESS, + kind: 'erc721', + name: 'Kudos Name', + description: 'Kudos Description', + image: 'Kudos image (from proxy API)', + collection: { + id: ERC721_KUDOSADDRESS, + name: 'Kudos', + tokenCount: '10', + image: 'Kudos logo (from proxy API)', + }, + }, + }, + ], + }); - const { selectedAddress, chainId } = nftController.config; await nftController.addNft( '0x6EbeAf8e8E946F0716E6533A6f2cefc83f60e8Ab', '123', + 'mainnet', + { + userAddress: OWNER_ACCOUNT.address, + source: Source.Detected, + }, + ); + + expect( + nftController.state.allNfts[OWNER_ACCOUNT.address]?.[ChainId.mainnet], + ).toBeUndefined(); + + expect( + nftController.state.allNftContracts[OWNER_ACCOUNT.address]?.[ + ChainId.mainnet + ], + ).toBeUndefined(); + + await nftController.addNft( + ERC721_KUDOSADDRESS, + ERC721_KUDOS_TOKEN_ID, + 'mainnet', { - userAddress: selectedAddress, - chainId, + userAddress: OWNER_ACCOUNT.address, source: Source.Detected, }, ); - await nftController.addNft(ERC721_KUDOSADDRESS, ERC721_KUDOS_TOKEN_ID, { - userAddress: selectedAddress, - chainId, + expect( + nftController.state.allNfts[OWNER_ACCOUNT.address][ChainId.mainnet], + ).toStrictEqual([ + { + address: ERC721_KUDOSADDRESS, + chainId: convertHexToDecimal(ChainId.mainnet), + description: 'Kudos Description', + image: 'Kudos image (from proxy API)', + name: 'Kudos Name', + standard: ERC721, + tokenId: ERC721_KUDOS_TOKEN_ID, + favorite: false, + isCurrentlyOwned: true, + tokenURI: null, + collection: { + id: ERC721_KUDOSADDRESS, + tokenCount: '10', + image: 'Kudos logo (from proxy API)', + name: 'Kudos', + }, + }, + ]); + + expect( + nftController.state.allNftContracts[OWNER_ACCOUNT.address][ + ChainId.mainnet + ], + ).toStrictEqual([ + { + address: ERC721_KUDOSADDRESS, + logo: 'Kudos logo (from proxy API)', + name: 'Kudos', + totalSupply: '10', + schemaName: ERC721, + }, + ]); + + expect(mockOnNftAdded).toHaveBeenCalledWith({ + address: ERC721_KUDOSADDRESS, + tokenId: ERC721_KUDOS_TOKEN_ID, + standard: ERC721, source: Source.Detected, }); + }); - expect(nftController.state.allNfts).toStrictEqual({}); + it('should not add an nft and nftContract when there is not valid contract information (or an issue fetching it) and source is "detected"', async () => { + const mockOnNftAdded = jest.fn(); + const { nftController } = setupController({ + options: { + onNftAdded: mockOnNftAdded, + }, + getERC721AssetName: jest + .fn() + .mockRejectedValue(new Error('Failed to fetch')), + getERC721AssetSymbol: jest + .fn() + .mockRejectedValue(new Error('Failed to fetch')), + defaultSelectedAccount: OWNER_ACCOUNT, + }); + nock(NFT_API_BASE_URL) + .get( + `/tokens?chainIds=1&tokens=${ERC721_KUDOSADDRESS}%3A${ERC721_KUDOS_TOKEN_ID}&includeTopBid=true&includeAttributes=true&includeLastSale=true`, + ) + .replyWithError(new Error('Failed to fetch')); + await nftController.addNft( + '0x6EbeAf8e8E946F0716E6533A6f2cefc83f60e8Ab', + '123', + 'mainnet', + { + userAddress: OWNER_ACCOUNT.address, + source: Source.Detected, + }, + ); + await nftController.addNft( + ERC721_KUDOSADDRESS, + ERC721_KUDOS_TOKEN_ID, + 'mainnet', + { + userAddress: OWNER_ACCOUNT.address, + source: Source.Detected, + }, + ); + expect(nftController.state.allNfts).toStrictEqual({}); expect(nftController.state.allNftContracts).toStrictEqual({}); - - expect(onNftAddedSpy).not.toHaveBeenCalled(); + expect(mockOnNftAdded).not.toHaveBeenCalled(); }); it('should not add duplicate NFTs to the ignoredNfts list', async () => { - const { nftController } = setupController(); - const { selectedAddress, chainId } = nftController.config; + const { nftController } = setupController({ + defaultSelectedAccount: OWNER_ACCOUNT, + }); - await nftController.addNft('0x01', '1', { + await nftController.addNft('0x01', '1', 'mainnet', { nftMetadata: { name: 'name', image: 'image', @@ -1767,7 +2600,7 @@ describe('NftController', () => { }, }); - await nftController.addNft('0x01', '2', { + await nftController.addNft('0x01', '2', 'mainnet', { nftMetadata: { name: 'name', image: 'image', @@ -1777,17 +2610,17 @@ describe('NftController', () => { }); expect( - nftController.state.allNfts[selectedAddress][chainId], + nftController.state.allNfts[OWNER_ACCOUNT.address][ChainId.mainnet], ).toHaveLength(2); expect(nftController.state.ignoredNfts).toHaveLength(0); - nftController.removeAndIgnoreNft('0x01', '1'); + nftController.removeAndIgnoreNft('0x01', '1', 'mainnet'); expect( - nftController.state.allNfts[selectedAddress][chainId], + nftController.state.allNfts[OWNER_ACCOUNT.address][ChainId.mainnet], ).toHaveLength(1); expect(nftController.state.ignoredNfts).toHaveLength(1); - await nftController.addNft('0x01', '1', { + await nftController.addNft('0x01', '1', 'mainnet', { nftMetadata: { name: 'name', image: 'image', @@ -1797,122 +2630,62 @@ describe('NftController', () => { }); expect( - nftController.state.allNfts[selectedAddress][chainId], + nftController.state.allNfts[OWNER_ACCOUNT.address][ChainId.mainnet], ).toHaveLength(2); expect(nftController.state.ignoredNfts).toHaveLength(1); - nftController.removeAndIgnoreNft('0x01', '1'); + nftController.removeAndIgnoreNft('0x01', '1', 'mainnet'); expect( - nftController.state.allNfts[selectedAddress][chainId], + nftController.state.allNfts[OWNER_ACCOUNT.address][ChainId.mainnet], ).toHaveLength(1); expect(nftController.state.ignoredNfts).toHaveLength(1); }); it('should add NFT with metadata hosted in IPFS', async () => { - const { assetsContract, nftController } = setupController(); - nock('https://mainnet.infura.io:443', { encodedQueryParams: true }) - .post('/v3/ad3a368836ff4596becc3be8e2f137ac', { - jsonrpc: '2.0', - id: 13, - method: 'eth_call', - params: [ - { - to: ERC721_DEPRESSIONIST_ADDRESS.toLowerCase(), - data: '0x06fdde03', - }, - 'latest', - ], - }) - .reply(200, { - jsonrpc: '2.0', - id: 13, - result: - '0x0000000000000000000000000000000000000000000000000000000000000020000000000000000000000000000000000000000000000000000000000000001c4d616c746a696b2e6a706727732044657072657373696f6e6973747300000000', - }) - .post('/v3/ad3a368836ff4596becc3be8e2f137ac', { - jsonrpc: '2.0', - id: 14, - method: 'eth_call', - params: [ - { - to: ERC721_DEPRESSIONIST_ADDRESS.toLowerCase(), - data: '0x95d89b41', - }, - 'latest', - ], - }) - .reply(200, { - jsonrpc: '2.0', - id: 14, - result: - '0x0000000000000000000000000000000000000000000000000000000000000020000000000000000000000000000000000000000000000000000000000000000444504e5300000000000000000000000000000000000000000000000000000000', - }); - - nock('https://mainnet.infura.io:443', { encodedQueryParams: true }) - .post('/v3/ad3a368836ff4596becc3be8e2f137ac', { - jsonrpc: '2.0', - id: 15, - method: 'eth_call', - params: [ - { - to: ERC721_DEPRESSIONIST_ADDRESS.toLowerCase(), - data: '0x01ffc9a75b5e139f00000000000000000000000000000000000000000000000000000000', - }, - 'latest', - ], - }) - .reply(200, { - jsonrpc: '2.0', - id: 15, - result: - '0x0000000000000000000000000000000000000000000000000000000000000001', - }) - .post('/v3/ad3a368836ff4596becc3be8e2f137ac', { - jsonrpc: '2.0', - id: 16, - method: 'eth_call', - params: [ - { - to: ERC721_DEPRESSIONIST_ADDRESS.toLowerCase(), - data: '0xc87b56dd0000000000000000000000000000000000000000000000000000000000000024', - }, - 'latest', - ], - }) - .reply(200, { - jsonrpc: '2.0', - id: 16, - result: - '0x0000000000000000000000000000000000000000000000000000000000000020000000000000000000000000000000000000000000000000000000000000003a697066733a2f2f697066732f516d5643684e7453745a66507956384a664b70756265336569675168357255587159636850674c63393174574c4a000000000000', + const { nftController, triggerPreferencesStateChange, mockGetAccount } = + setupController({ + getERC721TokenURI: jest.fn().mockImplementation((tokenAddress) => { + switch (tokenAddress) { + case ERC721_DEPRESSIONIST_ADDRESS: + return `ipfs://${DEPRESSIONIST_CID_V1}`; + default: + throw new Error('Not an ERC721 token'); + } + }), + getERC1155TokenURI: jest + .fn() + .mockRejectedValue(new Error('Not an ERC1155 token')), }); - - assetsContract.configure({ provider: MAINNET_PROVIDER }); - nftController.configure({ + mockGetAccount.mockReturnValue(OWNER_ACCOUNT); + triggerPreferencesStateChange({ + ...getDefaultPreferencesState(), ipfsGateway: IPFS_DEFAULT_GATEWAY_URL, }); - const { selectedAddress, chainId } = nftController.config; + await nftController.addNft( ERC721_DEPRESSIONIST_ADDRESS, ERC721_DEPRESSIONIST_ID, + 'mainnet', ); expect( - nftController.state.allNftContracts[selectedAddress][chainId][0], + nftController.state.allNftContracts[OWNER_ACCOUNT.address][ + ChainId.mainnet + ][0], ).toStrictEqual({ address: ERC721_DEPRESSIONIST_ADDRESS, - name: "Maltjik.jpg's Depressionists", - symbol: 'DPNS', + schemaName: ERC721, }); - expect( - nftController.state.allNfts[selectedAddress][chainId][0], + nftController.state.allNfts[OWNER_ACCOUNT.address][ChainId.mainnet][0], ).toStrictEqual({ address: ERC721_DEPRESSIONIST_ADDRESS, + chainId: convertHexToDecimal(ChainId.mainnet), tokenId: '36', image: 'image', name: 'name', description: 'description', - standard: 'ERC721', + standard: ERC721, favorite: false, isCurrentlyOwned: true, tokenURI: @@ -1920,133 +2693,21 @@ describe('NftController', () => { }); }); - it('should add NFT erc721 and not get NFT information directly from OpenSea API when OpenSeaAPIkey is set and queries to OpenSea proxy fail', async () => { - const { assetsContract, nftController } = setupController(); - nock(OPENSEA_PROXY_URL) - .get(`/asset_contract/${ERC721_NFT_ADDRESS}`) - .replyWithError(new Error('Failed to fetch')) - .get(`/asset/${ERC721_NFT_ADDRESS}/${ERC721_NFT_ID}`) + it('should add NFT erc721 when call to NFT API fail', async () => { + const { nftController } = setupController(); + nock(NFT_API_BASE_URL) + .get( + `/tokens?chainIds=1&tokens=${ERC721_NFT_ADDRESS}%3A${ERC721_NFT_ID}&includeTopBid=true&includeAttributes=true&includeLastSale=true`, + ) .replyWithError(new Error('Failed to fetch')); - nock(OPENSEA_API_URL, { - encodedQueryParams: true, - }) - .get(`/asset_contract/${ERC721_NFT_ADDRESS}`) - .reply(200, { - description: 'description (from opensea)', - symbol: 'KDO', - total_supply: 10, - collection: { - name: 'name (from opensea)', - image_url: 'logo (from opensea)', - }, - }) - .get(`/asset/${ERC721_NFT_ADDRESS}/${ERC721_NFT_ID}`) - .reply(200, { - image_original_url: 'image (directly from opensea)', - name: 'name (directly from opensea)', - description: 'description (directly from opensea)', - asset_contract: { - schema_name: 'ERC721', - }, - }); - - nock('https://mainnet.infura.io:443', { encodedQueryParams: true }) - .post('/v3/ad3a368836ff4596becc3be8e2f137ac', { - jsonrpc: '2.0', - id: 17, - method: 'eth_call', - params: [ - { - to: ERC721_NFT_ADDRESS.toLowerCase(), - data: '0x06fdde03', - }, - 'latest', - ], - }) - .reply(200, { - jsonrpc: '2.0', - id: 17, - result: - '0x000000000000000000000000000000000000000000000000000000000000002000000000000000000000000000000000000000000000000000000000000000194f70656e536561205368617265642053746f726566726f6e7400000000000000', - }); - - nock('https://mainnet.infura.io:443', { encodedQueryParams: true }) - .post('/v3/ad3a368836ff4596becc3be8e2f137ac', { - jsonrpc: '2.0', - id: 18, - method: 'eth_call', - params: [ - { - to: ERC721_NFT_ADDRESS.toLowerCase(), - data: '0x95d89b41', - }, - 'latest', - ], - }) - .reply(200, { - jsonrpc: '2.0', - id: 18, - result: - '0x000000000000000000000000000000000000000000000000000000000000002000000000000000000000000000000000000000000000000000000000000000094f50454e53544f52450000000000000000000000000000000000000000000000', - }) - .post('/v3/ad3a368836ff4596becc3be8e2f137ac', { - jsonrpc: '2.0', - id: 19, - method: 'eth_call', - params: [ - { - to: ERC721_NFT_ADDRESS.toLowerCase(), - data: '0x0e89341c5a3ca5cd63807ce5e4d7841ab32ce6b6d9bbba2d000000000000010000000001', - }, - 'latest', - ], - }) - .reply(200, { - jsonrpc: '2.0', - id: 19, - result: - '0x0000000000000000000000000000000000000000000000000000000000000020000000000000000000000000000000000000000000000000000000000000005868747470733a2f2f6170692e6f70656e7365612e696f2f6170692f76312f6d657461646174612f3078343935663934373237363734394365363436663638414338633234383432303034356362376235652f30787b69647d0000000000000000', - }); - - nock('https://mainnet.infura.io:443', { encodedQueryParams: true }) - .post('/v3/ad3a368836ff4596becc3be8e2f137ac', { - jsonrpc: '2.0', - id: 21, - method: 'eth_call', - params: [ - { - to: ERC721_NFT_ADDRESS.toLowerCase(), - data: '0xc87b56dd000000000000000000000000000000000000000000000000000000000011781a', - }, - 'latest', - ], - }) - .reply(200, { - jsonrpc: '2.0', - id: 21, - result: - '0x0000000000000000000000000000000000000000000000000000000000000020000000000000000000000000000000000000000000000000000000000000003a697066733a2f2f697066732f516d6266617037397677663241513533417a554846426e426b6776337643525579726e736e5034726968314c6158000000000000', - }); - - nock('https://api.opensea.io:443', { encodedQueryParams: true }) - .get(`/api/v1/metadata/${ERC721_NFT_ADDRESS}/${ERC721_NFT_ID}`) - .reply(200, [ - '1f8b080000000000000334ce5d6f82301480e1ffd26b1015a3913bcdd8d4c1b20f9dc31bd274b51c3d3d85b664a0f1bf2f66d9ed9bbcc97365c4b564095be440e3e168ce02f62d9db0507b30c4126a1103263b2f2d712c11e8fc1f4173755f2bef6b97441156f14019a350b64e5a61c84bf203617494ef8aed27e5611cea7836f5fdfe510dc561cf9fcb23d8d364ed8a99cd2e4db30a1fb2d57184d9d9c6c547caab27dc35cbf779dd6bdfbfa88d5abca1b079d77ea5cbf4f24a6b389c5c2f4074d39fb16201e3049adfe1656bf1cf79fb050000ffff03002c5b5b9be3000000', - ]); - - assetsContract.configure({ provider: MAINNET_PROVIDER }); - const { selectedAddress, chainId } = nftController.config; - - nftController.setApiKey('fake-api-key'); - expect(nftController.openSeaApiKey).toBe('fake-api-key'); - - await nftController.addNft(ERC721_NFT_ADDRESS, ERC721_NFT_ID); + await nftController.addNft(ERC721_NFT_ADDRESS, ERC721_NFT_ID, 'mainnet'); expect( - nftController.state.allNfts[selectedAddress][chainId][0], + nftController.state.allNfts[OWNER_ACCOUNT.address][ChainId.mainnet][0], ).toStrictEqual({ address: ERC721_NFT_ADDRESS, + chainId: convertHexToDecimal(ChainId.mainnet), image: null, name: null, description: null, @@ -2054,7 +2715,7 @@ describe('NftController', () => { standard: null, favorite: false, isCurrentlyOwned: true, - tokenURI: '', + tokenURI: null, }); }); @@ -2092,8 +2753,8 @@ describe('NftController', () => { }), ); - const { nftController, getNetworkClientByIdSpy } = setupController({ - getERC721TokenURIStub: jest.fn().mockImplementation((tokenAddress) => { + const { nftController } = setupController({ + getERC721TokenURI: jest.fn().mockImplementation((tokenAddress) => { switch (tokenAddress) { case '0x01': return 'https://testtokenuri-1.com'; @@ -2103,7 +2764,7 @@ describe('NftController', () => { throw new Error('Not an ERC721 token'); } }), - getERC1155TokenURIStub: jest.fn().mockImplementation((tokenAddress) => { + getERC1155TokenURI: jest.fn().mockImplementation((tokenAddress) => { switch (tokenAddress) { case '0x03': return 'https://testtokenuri-3.com'; @@ -2111,48 +2772,23 @@ describe('NftController', () => { throw new Error('Not an ERC1155 token'); } }), + mockNetworkClientConfigurationsByNetworkClientId: { + 'customNetworkClientId-1': buildCustomNetworkClientConfiguration({ + chainId: '0xa', + }), + }, }); - getNetworkClientByIdSpy.mockImplementation((networkClientId) => { - switch (networkClientId) { - case 'sepolia': - return { - configuration: { - chainId: SEPOLIA.chainId, - }, - }; - case 'goerli': - return { - configuration: { - chainId: GOERLI.chainId, - }, - }; - case 'customNetworkClientId-1': - return { - configuration: { - chainId: '0xa', - }, - }; - default: - throw new Error('Invalid network client id'); - } - }); - - await nftController.addNft('0x01', '1234', { - networkClientId: 'sepolia', - }); - await nftController.addNft('0x02', '4321', { - networkClientId: 'goerli', - }); - await nftController.addNft('0x03', '5678', { - networkClientId: 'customNetworkClientId-1', - }); + await nftController.addNft('0x01', '1234', 'sepolia'); + await nftController.addNft('0x02', '4321', 'goerli'); + await nftController.addNft('0x03', '5678', 'customNetworkClientId-1'); expect( nftController.state.allNfts[OWNER_ADDRESS][SEPOLIA.chainId], ).toStrictEqual([ { address: '0x01', + chainId: convertHexToDecimal(ChainId.sepolia), description: 'test-description-1', image: 'test-image-1', name: 'test-name-1', @@ -2169,6 +2805,7 @@ describe('NftController', () => { ).toStrictEqual([ { address: '0x02', + chainId: convertHexToDecimal(ChainId.goerli), description: 'test-description-2', image: 'test-image-2', name: 'test-name-2', @@ -2183,6 +2820,7 @@ describe('NftController', () => { expect(nftController.state.allNfts[OWNER_ADDRESS]['0xa']).toStrictEqual([ { address: '0x03', + chainId: convertHexToDecimal('0xa'), description: 'test-description-3', image: 'test-image-3', name: 'test-name-3', @@ -2194,1033 +2832,2843 @@ describe('NftController', () => { }, ]); }); - }); - describe('addNftVerifyOwnership', () => { - it('should verify ownership by selected address and add NFT', async () => { - const { nftController, preferences } = setupController(); - const firstAddress = '0x123'; - const secondAddress = '0x321'; - const { chainId } = nftController.config; + it('should add an NFT with the correct chainId/userAddress and metadata when passed a userAddress', async () => { + const userAddress = '0x123ABC'; + nock('https://testtokenuri-1.com') + .get('/') + .reply( + 200, + JSON.stringify({ + image: 'test-image-1', + name: 'test-name-1', + description: 'test-description-1', + }), + ); - sinon.stub(nftController, 'isNftOwner' as any).returns(true); + nock('https://testtokenuri-2.com') + .get('/') + .reply( + 200, + JSON.stringify({ + image: 'test-image-2', + name: 'test-name-2', + description: 'test-description-2', + }), + ); - sinon - .stub(nftController, 'getNftInformation' as any) - .returns({ name: 'name', image: 'url', description: 'description' }); - preferences.update({ selectedAddress: firstAddress }); - await nftController.addNftVerifyOwnership('0x01', '1234'); - preferences.update({ selectedAddress: secondAddress }); - await nftController.addNftVerifyOwnership('0x02', '4321'); - preferences.update({ selectedAddress: firstAddress }); - expect( - nftController.state.allNfts[firstAddress][chainId][0], - ).toStrictEqual({ - address: '0x01', - description: 'description', - image: 'url', - name: 'name', - tokenId: '1234', - favorite: false, - isCurrentlyOwned: true, - }); - }); + nock('https://testtokenuri-3.com') + .get('/') + .reply( + 200, + JSON.stringify({ + image: 'test-image-3', + name: 'test-name-3', + description: 'test-description-3', + }), + ); - it('should throw an error if selected address is not owner of input NFT', async () => { - const { nftController, preferences } = setupController(); - sinon.stub(nftController, 'isNftOwner' as any).returns(false); - const firstAddress = '0x123'; - preferences.update({ selectedAddress: firstAddress }); - const result = async () => - await nftController.addNftVerifyOwnership('0x01', '1234'); - const error = 'This NFT is not owned by the user'; - await expect(result).rejects.toThrow(error); - }); + const { nftController } = setupController({ + getERC721TokenURI: jest.fn().mockImplementation((tokenAddress) => { + switch (tokenAddress) { + case '0x01': + return 'https://testtokenuri-1.com'; + case '0x02': + return 'https://testtokenuri-2.com'; + default: + throw new Error('Not an ERC721 token'); + } + }), + getERC1155TokenURI: jest.fn().mockImplementation((tokenAddress) => { + switch (tokenAddress) { + case '0x03': + return 'https://testtokenuri-3.com'; + default: + throw new Error('Not an ERC1155 token'); + } + }), + }); - it('should verify ownership by selected address and add NFT by the correct chainId when passed networkClientId', async () => { - const { nftController, preferences, getNetworkClientByIdSpy } = - setupController(); - - getNetworkClientByIdSpy.mockImplementation((networkClientId) => { - switch (networkClientId) { - case 'sepolia': - return { - configuration: { - chainId: SEPOLIA.chainId, - }, - }; - case 'goerli': - return { - configuration: { - chainId: GOERLI.chainId, - }, - }; - default: - return { - configuration: { - chainId: '0x1', - }, - }; - } + await nftController.addNft('0x01', '1234', 'mainnet', { + userAddress, }); - const firstAddress = '0x123'; - const secondAddress = '0x321'; - sinon.stub(nftController, 'isNftOwner' as any).returns(true); + await nftController.addNft('0x02', '4321', 'goerli', { + userAddress, + }); - sinon - .stub(nftController, 'getNftInformation' as any) - .returns({ name: 'name', image: 'url', description: 'description' }); - preferences.update({ selectedAddress: firstAddress }); - await nftController.addNftVerifyOwnership('0x01', '1234', 'sepolia'); - preferences.update({ selectedAddress: secondAddress }); - await nftController.addNftVerifyOwnership('0x02', '4321', 'goerli'); + await nftController.addNft('0x03', '5678', 'sepolia', { + userAddress, + }); + expect(nftController.state.allNfts[userAddress]['0x1']).toStrictEqual([ + { + address: '0x01', + chainId: convertHexToDecimal(ChainId.mainnet), + description: 'test-description-1', + image: 'test-image-1', + name: 'test-name-1', + tokenId: '1234', + favorite: false, + standard: ERC721, + tokenURI: 'https://testtokenuri-1.com', + isCurrentlyOwned: true, + }, + ]); expect( - nftController.state.allNfts[firstAddress][SEPOLIA.chainId][0], - ).toStrictEqual({ - address: '0x01', - description: 'description', - image: 'url', - name: 'name', - tokenId: '1234', - favorite: false, - isCurrentlyOwned: true, - }); - expect( - nftController.state.allNfts[secondAddress][GOERLI.chainId][0], - ).toStrictEqual({ - address: '0x02', - description: 'description', - image: 'url', - name: 'name', - tokenId: '4321', - favorite: false, - isCurrentlyOwned: true, - }); - }); - }); - - describe('removeNft', () => { - it('should remove NFT and NFT contract', async () => { - const { nftController } = setupController(); - const { selectedAddress, chainId } = nftController.config; - - await nftController.addNft('0x01', '1', { - nftMetadata: { - name: 'name', - image: 'image', - description: 'description', - standard: 'standard', + nftController.state.allNfts[userAddress][GOERLI.chainId], + ).toStrictEqual([ + { + address: '0x02', + chainId: convertHexToDecimal(ChainId.goerli), + description: 'test-description-2', + image: 'test-image-2', + name: 'test-name-2', + tokenId: '4321', + favorite: false, + standard: ERC721, + tokenURI: 'https://testtokenuri-2.com', + isCurrentlyOwned: true, }, - }); - nftController.removeNft('0x01', '1'); - expect( - nftController.state.allNfts[selectedAddress][chainId], - ).toHaveLength(0); - + ]); expect( - nftController.state.allNftContracts[selectedAddress][chainId], - ).toHaveLength(0); + nftController.state.allNfts[userAddress][SEPOLIA.chainId], + ).toStrictEqual([ + { + address: '0x03', + chainId: convertHexToDecimal(ChainId.sepolia), + description: 'test-description-3', + image: 'test-image-3', + name: 'test-name-3', + tokenId: '5678', + favorite: false, + standard: ERC1155, + tokenURI: 'https://testtokenuri-3.com', + isCurrentlyOwned: true, + }, + ]); }); - it('should not remove NFT contract if NFT still exists', async () => { - const { nftController } = setupController(); - const { selectedAddress, chainId } = nftController.config; - - await nftController.addNft('0x01', '1', { - nftMetadata: { - name: 'name', - image: 'image', - description: 'description', - standard: 'standard', + it('should handle unset selectedAccount', async () => { + const { nftController, mockGetAccount } = setupController({ + options: { + // chainId: ChainId.mainnet, }, + getERC721AssetName: jest.fn().mockResolvedValue('Name'), }); - await nftController.addNft('0x01', '2', { + mockGetAccount.mockReturnValue(null); + + await nftController.addNft('0x01', '1', 'mainnet', { nftMetadata: { name: 'name', image: 'image', description: 'description', standard: 'standard', + favorite: false, + collection: { + tokenCount: '0', + image: 'url', + }, }, }); - nftController.removeNft('0x01', '1'); - expect( - nftController.state.allNfts[selectedAddress][chainId], - ).toHaveLength(1); - expect( - nftController.state.allNftContracts[selectedAddress][chainId], - ).toHaveLength(1); + expect(nftController.state.allNftContracts['']).toBeUndefined(); }); + }); - it('should remove NFT by selected address', async () => { - const { nftController, preferences } = setupController(); - const { chainId } = nftController.config; - sinon - .stub(nftController, 'getNftInformation' as any) - .returns({ name: 'name', image: 'url', description: 'description' }); - const firstAddress = '0x123'; - const secondAddress = '0x321'; - preferences.update({ selectedAddress: firstAddress }); - await nftController.addNft('0x02', '4321'); - preferences.update({ selectedAddress: secondAddress }); - await nftController.addNft('0x01', '1234'); - nftController.removeNft('0x01', '1234'); - expect(nftController.state.allNfts[secondAddress][chainId]).toHaveLength( - 0, + describe('addNfts', () => { + it('should add multiple NFTs and NFT contracts at once', async () => { + const { nftController } = setupController({ + options: {}, + getERC721AssetName: jest.fn().mockResolvedValue('Name'), + }); + + await nftController.addNfts( + [ + { + tokenAddress: '0x01', + tokenId: '1', + nftMetadata: { + name: 'NFT 1', + image: 'image1', + description: 'description 1', + standard: 'ERC721', + chainId: 1, + }, + }, + { + tokenAddress: '0x02', + tokenId: '2', + nftMetadata: { + name: 'NFT 2', + image: 'image2', + description: 'description 2', + standard: 'ERC721', + chainId: 1, + }, + }, + ], + OWNER_ACCOUNT.address, ); - preferences.update({ selectedAddress: firstAddress }); + + expect( + nftController.state.allNfts[OWNER_ACCOUNT.address][ChainId.mainnet], + ).toHaveLength(2); expect( - nftController.state.allNfts[firstAddress][chainId][0], + nftController.state.allNfts[OWNER_ACCOUNT.address][ChainId.mainnet][0], ).toStrictEqual({ - address: '0x02', - description: 'description', - image: 'url', - name: 'name', - tokenId: '4321', + address: '0x01', + chainId: convertHexToDecimal(ChainId.mainnet), + description: 'description 1', + image: 'image1', + name: 'NFT 1', + tokenId: '1', + standard: 'ERC721', favorite: false, isCurrentlyOwned: true, }); - }); - - it('should remove NFT by provider type', async () => { - const { nftController, changeNetwork } = setupController(); - const { selectedAddress } = nftController.config; - - sinon - .stub(nftController, 'getNftInformation' as any) - .returns({ name: 'name', image: 'url', description: 'description' }); - changeNetwork(SEPOLIA); - await nftController.addNft('0x02', '4321'); - changeNetwork(GOERLI); - await nftController.addNft('0x01', '1234'); - // nftController.removeToken('0x01'); - nftController.removeNft('0x01', '1234'); - expect( - nftController.state.allNfts[selectedAddress][GOERLI.chainId], - ).toHaveLength(0); - - changeNetwork(SEPOLIA); - expect( - nftController.state.allNfts[selectedAddress][SEPOLIA.chainId][0], + nftController.state.allNfts[OWNER_ACCOUNT.address][ChainId.mainnet][1], ).toStrictEqual({ address: '0x02', - description: 'description', - image: 'url', - name: 'name', - tokenId: '4321', + chainId: convertHexToDecimal(ChainId.mainnet), + description: 'description 2', + image: 'image2', + name: 'NFT 2', + tokenId: '2', + standard: 'ERC721', favorite: false, isCurrentlyOwned: true, }); }); - }); - - it('should be able to clear the ignoredNfts list', async () => { - const { nftController } = setupController(); - const { selectedAddress, chainId } = nftController.config; - await nftController.addNft('0x02', '1', { - nftMetadata: { - name: 'name', - image: 'image', - description: 'description', - standard: 'standard', - favorite: false, - }, - }); + it('should add NFTs with different chainIds correctly', async () => { + const { nftController } = setupController({ + options: {}, + getERC721AssetName: jest.fn().mockResolvedValue('Name'), + }); - expect(nftController.state.allNfts[selectedAddress][chainId]).toHaveLength( - 1, - ); - expect(nftController.state.ignoredNfts).toHaveLength(0); + await nftController.addNfts( + [ + { + tokenAddress: '0x01', + tokenId: '1', + nftMetadata: { + name: 'NFT Mainnet', + image: 'image1', + description: 'description 1', + standard: 'ERC721', + chainId: 1, + }, + }, + { + tokenAddress: '0x02', + tokenId: '2', + nftMetadata: { + name: 'NFT Linea', + image: 'image2', + description: 'description 2', + standard: 'ERC721', + chainId: 59144, + }, + }, + ], + OWNER_ACCOUNT.address, + ); - nftController.removeAndIgnoreNft('0x02', '1'); - expect(nftController.state.allNfts[selectedAddress][chainId]).toHaveLength( - 0, - ); - expect(nftController.state.ignoredNfts).toHaveLength(1); + expect( + nftController.state.allNfts[OWNER_ACCOUNT.address][ChainId.mainnet], + ).toHaveLength(1); + expect( + nftController.state.allNfts[OWNER_ACCOUNT.address][ChainId.mainnet][0], + ).toMatchObject({ + address: '0x01', + name: 'NFT Mainnet', + tokenId: '1', + }); - nftController.clearIgnoredNfts(); - expect(nftController.state.ignoredNfts).toHaveLength(0); - }); + expect( + nftController.state.allNfts[OWNER_ACCOUNT.address]['0xe708'], + ).toHaveLength(1); + expect( + nftController.state.allNfts[OWNER_ACCOUNT.address]['0xe708'][0], + ).toMatchObject({ + address: '0x02', + name: 'NFT Linea', + tokenId: '2', + }); + }); - it('should set api key correctly', () => { - const { nftController } = setupController(); - nftController.setApiKey('new-api-key'); - expect(nftController.openSeaApiKey).toBe('new-api-key'); - }); + it('should add NFTs to selected account when userAddress is empty', async () => { + const { nftController } = setupController({ + options: {}, + getERC721AssetName: jest.fn().mockResolvedValue('Name'), + }); - describe('isNftOwner', () => { - it('should verify the ownership of an NFT when passed a networkClientId', async () => { - nock('https://sepolia.infura.io:443', { encodedQueryParams: true }) - .post('/v3/ad3a368836ff4596becc3be8e2f137ac', { - method: 'eth_call', - params: [ - { - to: '0x2b26675403a063d92ccad0293d387485471a7d3a', - data: '0x6352211e0000000000000000000000000000000000000000000000000000000000000001', + await nftController.addNfts( + [ + { + tokenAddress: '0x01', + tokenId: '1', + nftMetadata: { + name: 'NFT 1', + image: 'image1', + description: 'description 1', + standard: 'ERC721', + chainId: 1, }, - 'latest', - ], - id: 21, - jsonrpc: '2.0', - }) - .reply(200, { - jsonrpc: '2.0', - id: 21, - result: - '0x0000000000000000000000005a3CA5cD63807Ce5e4d7841AB32Ce6B6d9BbBa2D', - }); - const { nftController, getNetworkClientByIdSpy } = setupController(); - getNetworkClientByIdSpy.mockImplementation(() => ({ - provider: SEPOLIA_PROVIDER, - })); - - const isOwner = await nftController.isNftOwner( - OWNER_ADDRESS, - '0x2b26675403a063d92ccad0293d387485471a7d3a', - String(1), - 'sepolia', + }, + ], + '', ); - expect(isOwner).toBe(true); - }); - - it('should verify the ownership of an ERC-721 NFT with the correct owner address', async () => { - const { assetsContract, nftController } = setupController(); - nock('https://mainnet.infura.io:443', { encodedQueryParams: true }) - .post('/v3/ad3a368836ff4596becc3be8e2f137ac', { - jsonrpc: '2.0', - id: 22, - method: 'eth_call', - params: [ - { - to: ERC721_NFT_ADDRESS.toLowerCase(), - data: '0x6352211e000000000000000000000000000000000000000000000000000000000011781a', - }, - 'latest', - ], - }) - .reply(200, { - jsonrpc: '2.0', - id: 22, - result: - '0x0000000000000000000000005a3ca5cd63807ce5e4d7841ab32ce6b6d9bbba2d', - }); - assetsContract.configure({ provider: MAINNET_PROVIDER }); - const isOwner = await nftController.isNftOwner( - OWNER_ADDRESS, - ERC721_NFT_ADDRESS, - String(ERC721_NFT_ID), - ); - expect(isOwner).toBe(true); + // Should add to selected account (OWNER_ACCOUNT) when userAddress is empty + expect( + nftController.state.allNfts[OWNER_ACCOUNT.address][ChainId.mainnet], + ).toHaveLength(1); + expect( + nftController.state.allNfts[OWNER_ACCOUNT.address][ChainId.mainnet][0], + ).toMatchObject({ + address: '0x01', + name: 'NFT 1', + tokenId: '1', + }); }); - it('should not verify the ownership of an ERC-721 NFT with the wrong owner address', async () => { - const { assetsContract, nftController } = setupController(); - nock('https://mainnet.infura.io:443', { encodedQueryParams: true }) - .post('/v3/ad3a368836ff4596becc3be8e2f137ac', { - jsonrpc: '2.0', - id: 23, - method: 'eth_call', - params: [ - { - to: ERC721_NFT_ADDRESS.toLowerCase(), - data: '0x6352211e000000000000000000000000000000000000000000000000000000000011781a', - }, - 'latest', - ], - }) - .reply(200, { - jsonrpc: '2.0', - id: 23, - result: - '0x0000000000000000000000005a3ca5cd63807ce5e4d7841ab32ce6b6d9bbba2d', - }); + it('should skip an NFT whose chain ID is not registered and still add the remaining NFTs', async () => { + // Chain ID 0x999 is not registered in any mock network client config, so + // findNetworkClientIdByChainId throws for it naturally — no spy needed. + const UNREGISTERED_CHAIN_ID = 0x999; - assetsContract.configure({ provider: MAINNET_PROVIDER }); - const isOwner = await nftController.isNftOwner( - '0x0000000000000000000000000000000000000000', - ERC721_NFT_ADDRESS, - String(ERC721_NFT_ID), - ); - expect(isOwner).toBe(false); - }); + const { nftController } = setupController({}); - it('should verify the ownership of an ERC-1155 NFT with the correct owner address', async () => { - const { assetsContract, nftController } = setupController(); - nock('https://mainnet.infura.io:443', { encodedQueryParams: true }) - .post('/v3/ad3a368836ff4596becc3be8e2f137ac', { - jsonrpc: '2.0', - id: 24, - method: 'eth_call', - params: [ - { - to: ERC1155_NFT_ADDRESS.toLowerCase(), - data: '0x6352211e5a3ca5cd63807ce5e4d7841ab32ce6b6d9bbba2d000000000000010000000001', + await nftController.addNfts( + [ + { + tokenAddress: '0x01', + tokenId: '1', + nftMetadata: { + name: 'NFT 1', + image: null, + description: null, + standard: ERC721, + chainId: 1, }, - 'latest', - ], - }) - .reply(200, { - jsonrpc: '2.0', - id: 24, - error: { code: -32000, message: 'execution reverted' }, - }) - .post('/v3/ad3a368836ff4596becc3be8e2f137ac', { - jsonrpc: '2.0', - id: 25, - method: 'eth_call', - params: [ - { - to: ERC1155_NFT_ADDRESS.toLowerCase(), - data: '0x00fdd58e0000000000000000000000005a3ca5cd63807ce5e4d7841ab32ce6b6d9bbba2d5a3ca5cd63807ce5e4d7841ab32ce6b6d9bbba2d000000000000010000000001', + }, + { + tokenAddress: '0x02', + tokenId: '2', + nftMetadata: { + name: 'NFT 2', + image: null, + description: null, + standard: ERC721, + chainId: UNREGISTERED_CHAIN_ID, }, - 'latest', - ], - }) - .reply(200, { - jsonrpc: '2.0', - id: 25, - result: - '0x0000000000000000000000000000000000000000000000000000000000000001', - }); - assetsContract.configure({ provider: MAINNET_PROVIDER }); - const isOwner = await nftController.isNftOwner( - OWNER_ADDRESS, - ERC1155_NFT_ADDRESS, - ERC1155_NFT_ID, + }, + { + tokenAddress: '0x03', + tokenId: '3', + nftMetadata: { + name: 'NFT 3', + image: null, + description: null, + standard: ERC721, + chainId: 1, + }, + }, + ], + OWNER_ACCOUNT.address, ); - expect(isOwner).toBe(true); - }); - it('should not verify the ownership of an ERC-1155 NFT with the wrong owner address', async () => { - const { assetsContract, nftController } = setupController(); - nock('https://mainnet.infura.io:443', { encodedQueryParams: true }) - .post('/v3/ad3a368836ff4596becc3be8e2f137ac', { - jsonrpc: '2.0', - id: 26, - method: 'eth_call', - params: [ - { - to: ERC1155_NFT_ADDRESS.toLowerCase(), - data: '0x6352211e5a3ca5cd63807ce5e4d7841ab32ce6b6d9bbba2d000000000000010000000001', - }, - 'latest', - ], + // NFTs 1 and 3 (mainnet) should be added despite NFT 2 failing + expect( + nftController.state.allNfts[OWNER_ACCOUNT.address][ChainId.mainnet], + ).toHaveLength(2); + expect( + nftController.state.allNfts[OWNER_ACCOUNT.address][ChainId.mainnet], + ).toMatchObject([ + { address: '0x01', tokenId: '1' }, + { address: '0x03', tokenId: '3' }, + ]); + // NFT 2 (unknown chain) should not have been added + expect( + nftController.state.allNfts[OWNER_ACCOUNT.address]?.['0x999'], + ).toBeUndefined(); + }); + + it('should fire onNftAdded callbacks only after all NFT state has been written', async () => { + let stateWhenFirstCallbackFired: NftControllerState | undefined; + const mockOnNftAdded = jest.fn(); + + const { nftController } = setupController({ + options: { onNftAdded: mockOnNftAdded }, + }); + + mockOnNftAdded.mockImplementationOnce(() => { + stateWhenFirstCallbackFired = nftController.state; + }); + + await nftController.addNfts( + [ + { + tokenAddress: '0x01', + tokenId: '1', + nftMetadata: { + name: 'NFT 1', + image: null, + description: null, + standard: ERC721, + chainId: 1, + }, + }, + { + tokenAddress: '0x02', + tokenId: '2', + nftMetadata: { + name: 'NFT 2', + image: null, + description: null, + standard: ERC721, + chainId: 1, + }, + }, + ], + OWNER_ACCOUNT.address, + ); + + expect(mockOnNftAdded).toHaveBeenCalledTimes(2); + // Both NFTs must already be in state when the first callback fires + expect( + stateWhenFirstCallbackFired?.allNfts[OWNER_ACCOUNT.address][ + ChainId.mainnet + ], + ).toHaveLength(2); + }); + }); + + describe('addNftVerifyOwnership', () => { + it('should verify ownership by selected address and add NFT', async () => { + const tokenURI = 'https://url/'; + const mockGetERC721TokenURI = jest.fn().mockResolvedValue(tokenURI); + + const { + nftController, + mockGetAccount, + triggerPreferencesStateChange, + triggerSelectedAccountChange, + } = setupController({ + getERC721TokenURI: mockGetERC721TokenURI, + }); + const firstAddress = '0x123'; + const firstAccount = createMockInternalAccount({ + address: firstAddress, + id: '22c022b5-309c-45e4-a82d-64bb11fc0e74', + }); + const secondAddress = '0x321'; + const secondAccount = createMockInternalAccount({ + address: secondAddress, + id: 'f9a42417-6071-4b51-8ecd-f7b14abd8851', + }); + mockGetAccount.mockReturnValue(firstAccount); + triggerSelectedAccountChange(firstAccount); + + jest.spyOn(nftController, 'isNftOwner').mockResolvedValue(true); + nock('https://url').get('/').reply(200, { + name: 'name', + image: 'url', + description: 'description', + }); + triggerPreferencesStateChange({ + ...getDefaultPreferencesState(), + displayNftMedia: true, + }); + await nftController.addNftVerifyOwnership('0x01', '1234', 'mainnet'); + mockGetAccount.mockReturnValue(secondAccount); + triggerSelectedAccountChange(secondAccount); + triggerPreferencesStateChange({ + ...getDefaultPreferencesState(), + displayNftMedia: true, + }); + await nftController.addNftVerifyOwnership('0x02', '4321', 'mainnet'); + mockGetAccount.mockReturnValue(firstAccount); + triggerSelectedAccountChange(firstAccount); + triggerPreferencesStateChange({ + ...getDefaultPreferencesState(), + displayNftMedia: true, + }); + expect( + nftController.state.allNfts[firstAccount.address][ChainId.mainnet][0], + ).toStrictEqual({ + address: '0x01', + chainId: convertHexToDecimal(ChainId.mainnet), + description: 'description', + image: 'url', + name: 'name', + tokenId: '1234', + standard: ERC721, + tokenURI, + favorite: false, + isCurrentlyOwned: true, + }); + }); + + it('should throw an error if selected address is not owner of input NFT', async () => { + const { + nftController, + mockGetAccount, + triggerPreferencesStateChange, + triggerSelectedAccountChange, + } = setupController(); + jest.spyOn(nftController, 'isNftOwner').mockResolvedValue(false); + const firstAddress = '0x123'; + const firstAccount = createMockInternalAccount({ + address: firstAddress, + id: '22c022b5-309c-45e4-a82d-64bb11fc0e74', + }); + mockGetAccount.mockReturnValue(firstAccount); + triggerSelectedAccountChange(firstAccount); + triggerPreferencesStateChange({ + ...getDefaultPreferencesState(), + displayNftMedia: true, + }); + const result = async () => + await nftController.addNftVerifyOwnership('0x01', '1234', 'mainnet'); + const error = 'This NFT is not owned by the user'; + await expect(result).rejects.toThrow(error); + }); + + it('should verify ownership by selected address and add NFT by the correct chainId when passed networkClientId', async () => { + const tokenURI = 'https://url/'; + const mockGetERC721TokenURI = jest.fn().mockResolvedValue(tokenURI); + const { + nftController, + triggerPreferencesStateChange, + mockGetAccount, + triggerSelectedAccountChange, + } = setupController({ + getERC721TokenURI: mockGetERC721TokenURI, + }); + + const firstAddress = '0x123'; + const firstAccount = createMockInternalAccount({ + address: firstAddress, + id: '22c022b5-309c-45e4-a82d-64bb11fc0e74', + }); + const secondAddress = '0x321'; + const secondAccount = createMockInternalAccount({ + address: secondAddress, + id: 'f9a42417-6071-4b51-8ecd-f7b14abd8851', + }); + + jest.spyOn(nftController, 'isNftOwner').mockResolvedValue(true); + + nock('https://url') + .get('/') + .reply(200, { + name: 'name', + image: 'url', + description: 'description', }) + .persist(); + mockGetAccount.mockReturnValue(firstAccount); + triggerSelectedAccountChange(firstAccount); + triggerPreferencesStateChange({ + ...getDefaultPreferencesState(), + displayNftMedia: true, + }); + await nftController.addNftVerifyOwnership('0x01', '1234', 'sepolia'); + mockGetAccount.mockReturnValue(secondAccount); + triggerSelectedAccountChange(secondAccount); + triggerPreferencesStateChange({ + ...getDefaultPreferencesState(), + displayNftMedia: true, + }); + await nftController.addNftVerifyOwnership('0x02', '4321', 'goerli'); + + expect( + nftController.state.allNfts[firstAccount.address][SEPOLIA.chainId][0], + ).toStrictEqual({ + address: '0x01', + chainId: convertHexToDecimal(ChainId.sepolia), + description: 'description', + image: 'url', + name: 'name', + standard: ERC721, + tokenId: '1234', + favorite: false, + isCurrentlyOwned: true, + tokenURI, + }); + expect( + nftController.state.allNfts[secondAccount.address][GOERLI.chainId][0], + ).toStrictEqual({ + address: '0x02', + chainId: convertHexToDecimal(ChainId.goerli), + description: 'description', + image: 'url', + name: 'name', + standard: ERC721, + tokenId: '4321', + favorite: false, + isCurrentlyOwned: true, + tokenURI, + }); + }); + + it('should verify ownership by selected address and add NFT by the correct userAddress when passed userAddress', async () => { + const tokenURI = 'https://url/'; + const mockGetERC721TokenURI = jest.fn().mockResolvedValue(tokenURI); + const { + nftController, + triggerPreferencesStateChange, + triggerSelectedAccountChange, + } = setupController({ + getERC721TokenURI: mockGetERC721TokenURI, + }); + // Ensure that the currently selected address is not the same as either of the userAddresses + triggerSelectedAccountChange(OWNER_ACCOUNT); + triggerPreferencesStateChange({ + ...getDefaultPreferencesState(), + displayNftMedia: true, + }); + + const firstAddress = '0x123'; + const secondAddress = '0x321'; + + jest.spyOn(nftController, 'isNftOwner').mockResolvedValue(true); + + nock('https://url') + .get('/') .reply(200, { - jsonrpc: '2.0', - id: 26, - error: { code: -32000, message: 'execution reverted' }, + name: 'name', + image: 'url', + description: 'description', }) - .post('/v3/ad3a368836ff4596becc3be8e2f137ac', { - jsonrpc: '2.0', - id: 27, - method: 'eth_call', - params: [ + .persist(); + await nftController.addNftVerifyOwnership('0x01', '1234', 'sepolia', { + userAddress: firstAddress, + }); + await nftController.addNftVerifyOwnership('0x02', '4321', 'goerli', { + userAddress: secondAddress, + }); + + expect( + nftController.state.allNfts[firstAddress][SEPOLIA.chainId][0], + ).toStrictEqual({ + address: '0x01', + chainId: convertHexToDecimal(ChainId.sepolia), + description: 'description', + image: 'url', + name: 'name', + tokenId: '1234', + favorite: false, + standard: ERC721, + isCurrentlyOwned: true, + tokenURI, + }); + expect( + nftController.state.allNfts[secondAddress][GOERLI.chainId][0], + ).toStrictEqual({ + address: '0x02', + chainId: convertHexToDecimal(ChainId.goerli), + description: 'description', + image: 'url', + name: 'name', + tokenId: '4321', + standard: ERC721, + favorite: false, + isCurrentlyOwned: true, + tokenURI, + }); + }); + }); + + describe('removeNft', () => { + it('should remove NFT and NFT contract', async () => { + const { nftController } = setupController({ + defaultSelectedAccount: OWNER_ACCOUNT, + }); + + await nftController.addNft('0x01', '1', 'mainnet', { + nftMetadata: { + name: 'name', + image: 'image', + description: 'description', + standard: 'standard', + }, + }); + nftController.removeNft('0x01', '1', 'mainnet'); + expect( + nftController.state.allNfts[OWNER_ACCOUNT.address][ChainId.mainnet], + ).toHaveLength(0); + + expect( + nftController.state.allNftContracts[OWNER_ACCOUNT.address][ + ChainId.mainnet + ], + ).toHaveLength(0); + }); + + it('should not remove NFT contract if NFT still exists', async () => { + const { nftController } = setupController(); + + await nftController.addNft('0x01', '1', 'mainnet', { + nftMetadata: { + name: 'name', + image: 'image', + description: 'description', + standard: 'standard', + }, + }); + + await nftController.addNft('0x01', '2', 'mainnet', { + nftMetadata: { + name: 'name', + image: 'image', + description: 'description', + standard: 'standard', + }, + }); + nftController.removeNft('0x01', '1', 'mainnet'); + expect( + nftController.state.allNfts[OWNER_ACCOUNT.address][ChainId.mainnet], + ).toHaveLength(1); + + expect( + nftController.state.allNftContracts[OWNER_ACCOUNT.address][ + ChainId.mainnet + ], + ).toHaveLength(1); + }); + + it('should remove NFT by selected address', async () => { + const tokenURI = 'https://url/'; + const mockGetERC721TokenURI = jest.fn().mockResolvedValue(tokenURI); + const { + nftController, + triggerPreferencesStateChange, + mockGetAccount, + triggerSelectedAccountChange, + } = setupController({ + getERC721TokenURI: mockGetERC721TokenURI, + }); + nock('https://url').get('/').reply(200, { + name: 'name', + image: 'url', + description: 'description', + }); + const firstAddress = '0x123'; + const firstAccount = createMockInternalAccount({ + address: firstAddress, + id: '22c022b5-309c-45e4-a82d-64bb11fc0e74', + }); + const secondAddress = '0x321'; + const secondAccount = createMockInternalAccount({ + address: secondAddress, + id: 'f9a42417-6071-4b51-8ecd-f7b14abd8851', + }); + mockGetAccount.mockReturnValue(firstAccount); + triggerSelectedAccountChange(firstAccount); + triggerPreferencesStateChange({ + ...getDefaultPreferencesState(), + displayNftMedia: true, + }); + await nftController.addNft('0x02', '4321', 'mainnet'); + mockGetAccount.mockReturnValue(secondAccount); + triggerSelectedAccountChange(secondAccount); + triggerPreferencesStateChange({ + ...getDefaultPreferencesState(), + displayNftMedia: true, + }); + await nftController.addNft('0x01', '1234', 'mainnet'); + nftController.removeNft('0x01', '1234', 'mainnet'); + expect( + nftController.state.allNfts[secondAccount.address][ChainId.mainnet], + ).toHaveLength(0); + triggerPreferencesStateChange({ + ...getDefaultPreferencesState(), + displayNftMedia: true, + }); + expect( + nftController.state.allNfts[firstAccount.address][ChainId.mainnet][0], + ).toStrictEqual({ + address: '0x02', + chainId: convertHexToDecimal(ChainId.mainnet), + description: 'description', + image: 'url', + name: 'name', + tokenId: '4321', + favorite: false, + isCurrentlyOwned: true, + tokenURI, + standard: ERC721, + }); + }); + + it('should remove NFT by provider type', async () => { + const tokenURI = 'https://url/'; + const mockGetERC721TokenURI = jest.fn().mockResolvedValue(tokenURI); + const { nftController } = setupController({ + getERC721TokenURI: mockGetERC721TokenURI, + defaultSelectedAccount: OWNER_ACCOUNT, + }); + + nock('https://url').get('/').reply(200, { + name: 'name', + image: 'url', + description: 'description', + }); + await nftController.addNft('0x02', '4321', 'sepolia'); + await nftController.addNft('0x01', '1234', 'goerli'); + nftController.removeNft('0x01', '1234', 'goerli'); + expect( + nftController.state.allNfts[OWNER_ACCOUNT.address][GOERLI.chainId], + ).toHaveLength(0); + + expect( + nftController.state.allNfts[OWNER_ACCOUNT.address][SEPOLIA.chainId][0], + ).toStrictEqual({ + address: '0x02', + chainId: convertHexToDecimal(ChainId.sepolia), + description: 'description', + image: 'url', + name: 'name', + tokenId: '4321', + favorite: false, + isCurrentlyOwned: true, + tokenURI, + standard: ERC721, + }); + }); + + it('should remove correct NFT and NFT contract when passed networkClientId and userAddress in options', async () => { + const { + nftController, + triggerPreferencesStateChange, + triggerSelectedAccountChange, + mockGetAccount, + } = setupController(); + + const userAddress1 = '0x123'; + const userAccount1 = createMockInternalAccount({ + address: userAddress1, + id: '5fd59cae-95d3-4a1d-ba97-657c8f83c300', + }); + const userAddress2 = '0x321'; + const userAccount2 = createMockInternalAccount({ + address: userAddress2, + id: '9ea40063-a95c-4f79-a4b6-0c065549245e', + }); + + mockGetAccount.mockReturnValue(userAccount1); + triggerSelectedAccountChange(userAccount1); + triggerPreferencesStateChange({ + ...getDefaultPreferencesState(), + displayNftMedia: true, + }); + + await nftController.addNft('0x01', '1', 'sepolia', { + nftMetadata: { + name: 'name', + image: 'image', + description: 'description', + standard: 'standard', + }, + }); + + expect( + nftController.state.allNfts[userAddress1][SEPOLIA.chainId][0], + ).toStrictEqual({ + address: '0x01', + chainId: convertHexToDecimal(ChainId.sepolia), + description: 'description', + image: 'image', + name: 'name', + standard: 'standard', + tokenId: '1', + favorite: false, + isCurrentlyOwned: true, + }); + + mockGetAccount.mockReturnValue(userAccount2); + triggerSelectedAccountChange(userAccount2); + triggerPreferencesStateChange({ + ...getDefaultPreferencesState(), + displayNftMedia: true, + }); + + // now remove the nft after changing to a different network and account from the one where it was added + nftController.removeNft('0x01', '1', 'sepolia', { + userAddress: userAddress1, + }); + + expect( + nftController.state.allNfts[userAddress1][SEPOLIA.chainId], + ).toHaveLength(0); + + expect( + nftController.state.allNftContracts[userAddress1][SEPOLIA.chainId], + ).toHaveLength(0); + }); + }); + + it('should be able to clear the ignoredNfts list', async () => { + const { nftController } = setupController({ + defaultSelectedAccount: OWNER_ACCOUNT, + }); + + await nftController.addNft('0x02', '1', 'mainnet', { + nftMetadata: { + name: 'name', + image: 'image', + description: 'description', + standard: 'standard', + favorite: false, + }, + }); + + expect( + nftController.state.allNfts[OWNER_ACCOUNT.address][ChainId.mainnet], + ).toHaveLength(1); + expect(nftController.state.ignoredNfts).toHaveLength(0); + + nftController.removeAndIgnoreNft('0x02', '1', 'mainnet'); + expect( + nftController.state.allNfts[OWNER_ACCOUNT.address][ChainId.mainnet], + ).toHaveLength(0); + expect(nftController.state.ignoredNfts).toHaveLength(1); + + nftController.clearIgnoredNfts(); + expect(nftController.state.ignoredNfts).toHaveLength(0); + }); + + describe('isNftOwner', () => { + it('should verify the ownership of an NFT when passed a networkClientId', async () => { + const { nftController } = setupController(); + (getNftOwnershipForMultipleNfts as jest.Mock).mockResolvedValueOnce([ + { isOwned: true }, + ]); + + const isOwner = await nftController.isNftOwner( + OWNER_ADDRESS, + '0x2b26675403a063d92ccad0293d387485471a7d3a', + String(1), + 'sepolia', + ); + expect(isOwner).toBe(true); + }); + + it('should verify the ownership of an ERC-721 NFT with the correct owner address', async () => { + const { nftController } = setupController(); + (getNftOwnershipForMultipleNfts as jest.Mock).mockResolvedValueOnce([ + { isOwned: true }, + ]); + + const isOwner = await nftController.isNftOwner( + OWNER_ADDRESS, + ERC721_NFT_ADDRESS, + String(ERC721_NFT_ID), + 'mainnet', + ); + expect(isOwner).toBe(true); + }); + + it('should not verify the ownership of an ERC-721 NFT with the wrong owner address', async () => { + const { nftController } = setupController(); + (getNftOwnershipForMultipleNfts as jest.Mock).mockResolvedValueOnce([ + { isOwned: false }, + ]); + + const isOwner = await nftController.isNftOwner( + '0x0000000000000000000000000000000000000000', + ERC721_NFT_ADDRESS, + String(ERC721_NFT_ID), + 'mainnet', + ); + expect(isOwner).toBe(false); + }); + + it('should verify the ownership of an ERC-1155 NFT with the correct owner address', async () => { + const { nftController } = setupController(); + (getNftOwnershipForMultipleNfts as jest.Mock).mockResolvedValueOnce([ + { isOwned: true }, + ]); + + const isOwner = await nftController.isNftOwner( + OWNER_ADDRESS, + ERC1155_NFT_ADDRESS, + ERC1155_NFT_ID, + 'mainnet', + ); + expect(isOwner).toBe(true); + }); + + it('should not verify the ownership of an ERC-1155 NFT with the wrong owner address', async () => { + const { nftController } = setupController(); + (getNftOwnershipForMultipleNfts as jest.Mock).mockResolvedValueOnce([ + { isOwned: false }, + ]); + + const isOwner = await nftController.isNftOwner( + '0x0000000000000000000000000000000000000000', + ERC1155_NFT_ADDRESS, + ERC1155_NFT_ID, + 'mainnet', + ); + + expect(isOwner).toBe(false); + }); + + it('should throw an error for an unsupported standard', async () => { + const { nftController } = setupController(); + (getNftOwnershipForMultipleNfts as jest.Mock).mockResolvedValueOnce([ + { isOwned: undefined }, + ]); + const error = + "Unable to verify ownership. Possibly because the standard is not supported or the user's currently selected network does not match the chain of the asset in question."; + const result = async () => { + await nftController.isNftOwner( + '0x0000000000000000000000000000000000000000', + CRYPTOPUNK_ADDRESS, + '0', + 'mainnet', + ); + }; + await expect(result).rejects.toThrow(error); + }); + + it('should add NFT with null metadata if the ipfs gateway is disabled and opensea is disabled', async () => { + const { + nftController, + triggerPreferencesStateChange, + triggerSelectedAccountChange, + } = setupController({ + getERC721TokenURI: jest.fn().mockRejectedValue(''), + getERC1155TokenURI: jest.fn().mockResolvedValue('ipfs://*'), + defaultSelectedAccount: OWNER_ACCOUNT, + }); + triggerSelectedAccountChange(OWNER_ACCOUNT); + triggerPreferencesStateChange({ + ...getDefaultPreferencesState(), + isIpfsGatewayEnabled: false, + displayNftMedia: false, + }); + + await nftController.addNft( + ERC1155_NFT_ADDRESS, + ERC1155_NFT_ID, + 'mainnet', + ); + + expect( + nftController.state.allNfts[OWNER_ACCOUNT.address][ChainId.mainnet][0], + ).toStrictEqual({ + address: ERC1155_NFT_ADDRESS, + chainId: convertHexToDecimal(ChainId.mainnet), + name: null, + description: null, + image: null, + tokenId: ERC1155_NFT_ID, + standard: ERC1155, + favorite: false, + isCurrentlyOwned: true, + tokenURI: 'ipfs://*', + }); + }); + }); + + describe('updateNftFavoriteStatus', () => { + it('should not set NFT as favorite if nft not found', async () => { + const { nftController } = setupController({ + defaultSelectedAccount: OWNER_ACCOUNT, + }); + + await nftController.addNft( + ERC721_DEPRESSIONIST_ADDRESS, + ERC721_DEPRESSIONIST_ID, + 'mainnet', + { nftMetadata: { name: '', description: '', image: '', standard: '' } }, + ); + + nftController.updateNftFavoriteStatus( + ERC721_DEPRESSIONIST_ADDRESS, + '666', + true, + 'mainnet', + ); + + expect( + nftController.state.allNfts[OWNER_ACCOUNT.address][ChainId.mainnet][0], + ).toStrictEqual( + expect.objectContaining({ + address: ERC721_DEPRESSIONIST_ADDRESS, + tokenId: ERC721_DEPRESSIONIST_ID, + favorite: false, + }), + ); + }); + it('should set NFT as favorite', async () => { + const { nftController } = setupController({ + defaultSelectedAccount: OWNER_ACCOUNT, + }); + + await nftController.addNft( + ERC721_DEPRESSIONIST_ADDRESS, + ERC721_DEPRESSIONIST_ID, + 'mainnet', + { nftMetadata: { name: '', description: '', image: '', standard: '' } }, + ); + + nftController.updateNftFavoriteStatus( + ERC721_DEPRESSIONIST_ADDRESS, + ERC721_DEPRESSIONIST_ID, + true, + 'mainnet', + ); + + expect( + nftController.state.allNfts[OWNER_ACCOUNT.address][ChainId.mainnet][0], + ).toStrictEqual( + expect.objectContaining({ + address: ERC721_DEPRESSIONIST_ADDRESS, + tokenId: ERC721_DEPRESSIONIST_ID, + favorite: true, + }), + ); + }); + + it('should set NFT as favorite and then unset it', async () => { + const { nftController } = setupController({ + defaultSelectedAccount: OWNER_ACCOUNT, + }); + + await nftController.addNft( + ERC721_DEPRESSIONIST_ADDRESS, + ERC721_DEPRESSIONIST_ID, + 'mainnet', + { nftMetadata: { name: '', description: '', image: '', standard: '' } }, + ); + + nftController.updateNftFavoriteStatus( + ERC721_DEPRESSIONIST_ADDRESS, + ERC721_DEPRESSIONIST_ID, + true, + 'mainnet', + ); + + expect( + nftController.state.allNfts[OWNER_ACCOUNT.address][ChainId.mainnet][0], + ).toStrictEqual( + expect.objectContaining({ + address: ERC721_DEPRESSIONIST_ADDRESS, + tokenId: ERC721_DEPRESSIONIST_ID, + favorite: true, + }), + ); + + nftController.updateNftFavoriteStatus( + ERC721_DEPRESSIONIST_ADDRESS, + ERC721_DEPRESSIONIST_ID, + false, + 'mainnet', + ); + + expect( + nftController.state.allNfts[OWNER_ACCOUNT.address][ChainId.mainnet][0], + ).toStrictEqual( + expect.objectContaining({ + address: ERC721_DEPRESSIONIST_ADDRESS, + tokenId: ERC721_DEPRESSIONIST_ID, + favorite: false, + }), + ); + }); + + it('should keep the favorite status as true after updating metadata', async () => { + const { nftController } = setupController({ + defaultSelectedAccount: OWNER_ACCOUNT, + }); + + await nftController.addNft( + ERC721_DEPRESSIONIST_ADDRESS, + ERC721_DEPRESSIONIST_ID, + 'mainnet', + { nftMetadata: { name: '', description: '', image: '', standard: '' } }, + ); + + nftController.updateNftFavoriteStatus( + ERC721_DEPRESSIONIST_ADDRESS, + ERC721_DEPRESSIONIST_ID, + true, + 'mainnet', + ); + + expect( + nftController.state.allNfts[OWNER_ACCOUNT.address][ChainId.mainnet][0], + ).toStrictEqual( + expect.objectContaining({ + address: ERC721_DEPRESSIONIST_ADDRESS, + tokenId: ERC721_DEPRESSIONIST_ID, + favorite: true, + }), + ); + + await nftController.addNft( + ERC721_DEPRESSIONIST_ADDRESS, + ERC721_DEPRESSIONIST_ID, + 'mainnet', + { + nftMetadata: { + image: 'new_image', + name: 'new_name', + description: 'new_description', + standard: ERC721, + }, + }, + ); + + expect( + nftController.state.allNfts[OWNER_ACCOUNT.address][ChainId.mainnet][0], + ).toStrictEqual( + expect.objectContaining({ + image: 'new_image', + name: 'new_name', + description: 'new_description', + address: ERC721_DEPRESSIONIST_ADDRESS, + tokenId: ERC721_DEPRESSIONIST_ID, + favorite: true, + isCurrentlyOwned: true, + }), + ); + + expect( + nftController.state.allNfts[OWNER_ACCOUNT.address][ChainId.mainnet], + ).toHaveLength(1); + }); + + it('should keep the favorite status as false after updating metadata', async () => { + const { nftController } = setupController({ + defaultSelectedAccount: OWNER_ACCOUNT, + }); + + await nftController.addNft( + ERC721_DEPRESSIONIST_ADDRESS, + ERC721_DEPRESSIONIST_ID, + 'mainnet', + { nftMetadata: { name: '', description: '', image: '', standard: '' } }, + ); + + expect( + nftController.state.allNfts[OWNER_ACCOUNT.address][ChainId.mainnet][0], + ).toStrictEqual( + expect.objectContaining({ + address: ERC721_DEPRESSIONIST_ADDRESS, + tokenId: ERC721_DEPRESSIONIST_ID, + favorite: false, + }), + ); + + await nftController.addNft( + ERC721_DEPRESSIONIST_ADDRESS, + ERC721_DEPRESSIONIST_ID, + 'mainnet', + { + nftMetadata: { + image: 'new_image', + name: 'new_name', + description: 'new_description', + standard: ERC721, + }, + }, + ); + + expect( + nftController.state.allNfts[OWNER_ACCOUNT.address][ChainId.mainnet][0], + ).toStrictEqual( + expect.objectContaining({ + image: 'new_image', + name: 'new_name', + description: 'new_description', + address: ERC721_DEPRESSIONIST_ADDRESS, + tokenId: ERC721_DEPRESSIONIST_ID, + favorite: false, + isCurrentlyOwned: true, + }), + ); + + expect( + nftController.state.allNfts[OWNER_ACCOUNT.address][ChainId.mainnet], + ).toHaveLength(1); + }); + + it('should set NFT as favorite when passed networkClientId and userAddress in options', async () => { + const { + nftController, + triggerPreferencesStateChange, + triggerSelectedAccountChange, + mockGetAccount, + } = setupController(); + + const userAddress1 = '0x123'; + const userAccount1 = createMockInternalAccount({ + address: userAddress1, + id: '0a2a9a41-2b35-4863-8f36-baceec4e9686', + }); + const userAddress2 = '0x321'; + const userAccount2 = createMockInternalAccount({ + address: userAddress2, + id: '09b239a4-c229-4a2b-9739-1cb4b9dea7b9', + }); + + mockGetAccount.mockReturnValue(userAccount1); + triggerSelectedAccountChange(userAccount1); + triggerPreferencesStateChange({ + ...getDefaultPreferencesState(), + displayNftMedia: true, + }); + + await nftController.addNft( + ERC721_DEPRESSIONIST_ADDRESS, + ERC721_DEPRESSIONIST_ID, + 'sepolia', + { nftMetadata: { name: '', description: '', image: '', standard: '' } }, + ); + + expect( + nftController.state.allNfts[userAccount1.address][SEPOLIA.chainId][0], + ).toStrictEqual( + expect.objectContaining({ + address: ERC721_DEPRESSIONIST_ADDRESS, + tokenId: ERC721_DEPRESSIONIST_ID, + favorite: false, + }), + ); + + mockGetAccount.mockReturnValue(userAccount2); + triggerSelectedAccountChange(userAccount2); + triggerPreferencesStateChange({ + ...getDefaultPreferencesState(), + displayNftMedia: true, + }); + + // now favorite the nft after changing to a different account from the one where it was added + nftController.updateNftFavoriteStatus( + ERC721_DEPRESSIONIST_ADDRESS, + ERC721_DEPRESSIONIST_ID, + true, + 'sepolia', + { userAddress: userAccount1.address }, + ); + + expect( + nftController.state.allNfts[userAccount1.address][SEPOLIA.chainId][0], + ).toStrictEqual( + expect.objectContaining({ + address: ERC721_DEPRESSIONIST_ADDRESS, + tokenId: ERC721_DEPRESSIONIST_ID, + favorite: true, + }), + ); + }); + }); + + describe('checkAndUpdateNftsOwnershipStatus', () => { + describe('checkAndUpdateAllNftsOwnershipStatus', () => { + const nftOwnershipResult = ( + isOwned: boolean | undefined, + ): NftOwnershipResult[] => [ + { nftAddress: '0x02', tokenId: '1', isOwned }, + ]; + + it('should remove NFT from state when it is no longer owned', async () => { + const { nftController } = setupController({ + defaultSelectedAccount: OWNER_ACCOUNT, + }); + (getNftOwnershipForMultipleNfts as jest.Mock).mockResolvedValue( + nftOwnershipResult(false), + ); + + await nftController.addNft('0x02', '1', 'mainnet', { + nftMetadata: { + name: 'name', + image: 'image', + description: 'description', + standard: 'standard', + favorite: false, + }, + }); + expect( + nftController.state.allNfts[OWNER_ACCOUNT.address][ChainId.mainnet], + ).toHaveLength(1); + + await nftController.checkAndUpdateAllNftsOwnershipStatus('mainnet'); + + expect( + nftController.state.allNfts[OWNER_ACCOUNT.address][ChainId.mainnet], + ).toHaveLength(0); + }); + + it('should leave isCurrentlyOwned as true when NFT is still owned', async () => { + const { nftController } = setupController({ + defaultSelectedAccount: OWNER_ACCOUNT, + }); + (getNftOwnershipForMultipleNfts as jest.Mock).mockResolvedValue( + nftOwnershipResult(true), + ); + + await nftController.addNft('0x02', '1', 'mainnet', { + nftMetadata: { + name: 'name', + image: 'image', + description: 'description', + standard: 'standard', + favorite: false, + }, + }); + + expect( + nftController.state.allNfts[OWNER_ACCOUNT.address][ChainId.mainnet][0] + .isCurrentlyOwned, + ).toBe(true); + + await nftController.checkAndUpdateAllNftsOwnershipStatus('mainnet'); + expect( + nftController.state.allNfts[OWNER_ACCOUNT.address][ChainId.mainnet][0] + .isCurrentlyOwned, + ).toBe(true); + }); + + it('should leave isCurrentlyOwned unchanged when ownership check is inconclusive', async () => { + const { nftController } = setupController({ + defaultSelectedAccount: OWNER_ACCOUNT, + }); + (getNftOwnershipForMultipleNfts as jest.Mock).mockResolvedValue( + nftOwnershipResult(undefined), + ); + + await nftController.addNft('0x02', '1', 'mainnet', { + nftMetadata: { + name: 'name', + image: 'image', + description: 'description', + standard: 'standard', + favorite: false, + }, + }); + + expect( + nftController.state.allNfts[OWNER_ACCOUNT.address][ChainId.mainnet][0] + .isCurrentlyOwned, + ).toBe(true); + + await nftController.checkAndUpdateAllNftsOwnershipStatus('mainnet'); + expect( + nftController.state.allNfts[OWNER_ACCOUNT.address][ChainId.mainnet][0] + .isCurrentlyOwned, + ).toBe(true); + }); + + it('should respect an explicit userAddress when checking ownership', async () => { + const { nftController, triggerPreferencesStateChange, mockGetAccount } = + setupController(); + + mockGetAccount.mockReturnValue(OWNER_ACCOUNT); + triggerPreferencesStateChange({ + ...getDefaultPreferencesState(), + displayNftMedia: true, + }); + + await nftController.addNft('0x02', '1', 'sepolia', { + nftMetadata: { + name: 'name', + image: 'image', + description: 'description', + standard: 'standard', + favorite: false, + }, + }); + + expect( + nftController.state.allNfts[OWNER_ACCOUNT.address][ChainId.sepolia][0] + .isCurrentlyOwned, + ).toBe(true); + + (getNftOwnershipForMultipleNfts as jest.Mock).mockResolvedValue( + nftOwnershipResult(false), + ); + + triggerPreferencesStateChange({ + ...getDefaultPreferencesState(), + displayNftMedia: true, + }); + + await nftController.checkAndUpdateAllNftsOwnershipStatus('sepolia', { + userAddress: OWNER_ADDRESS, + }); + + expect( + nftController.state.allNfts[OWNER_ADDRESS][SEPOLIA.chainId], + ).toHaveLength(0); + }); + + it('should handle default case where selectedAccount is not set', async () => { + const { nftController, mockGetAccount } = setupController({}); + mockGetAccount.mockReturnValue(null); + + await nftController.addNft('0x02', '1', 'mainnet', { + nftMetadata: { + name: 'name', + image: 'image', + description: 'description', + standard: 'standard', + favorite: false, + }, + }); + expect(nftController.state.allNfts['']).toBeUndefined(); + + await nftController.checkAndUpdateAllNftsOwnershipStatus('mainnet'); + + expect(nftController.state.allNfts['']).toBeUndefined(); + }); + + it('should preserve current state when getNftOwnershipForMultipleNfts throws', async () => { + const { nftController } = setupController({ + defaultSelectedAccount: OWNER_ACCOUNT, + }); + (getNftOwnershipForMultipleNfts as jest.Mock).mockRejectedValue( + new Error('provider connection lost'), + ); + + await nftController.addNft('0x02', '1', 'mainnet', { + nftMetadata: { + name: 'name', + image: 'image', + description: 'description', + standard: 'standard', + favorite: false, + }, + }); + expect( + nftController.state.allNfts[OWNER_ACCOUNT.address][ChainId.mainnet][0] + .isCurrentlyOwned, + ).toBe(true); + + await nftController.checkAndUpdateAllNftsOwnershipStatus('mainnet'); + + expect( + nftController.state.allNfts[OWNER_ACCOUNT.address][ChainId.mainnet][0] + .isCurrentlyOwned, + ).toBe(true); + }); + }); + + describe('checkAndUpdateSingleNftOwnershipStatus', () => { + it('should check whether the passed NFT is still owned by the current selectedAccount/chainId combination and update its isCurrentlyOwned property in state when isNftOwner returns false', async () => { + const { nftController } = setupController({ + defaultSelectedAccount: OWNER_ACCOUNT, + }); + + const nft = { + address: '0x02', + tokenId: '1', + name: 'name', + image: 'image', + description: 'description', + standard: 'standard', + favorite: false, + }; + + await nftController.addNft(nft.address, nft.tokenId, 'mainnet', { + nftMetadata: nft, + }); + + expect( + nftController.state.allNfts[OWNER_ACCOUNT.address][ChainId.mainnet][0] + .isCurrentlyOwned, + ).toBe(true); + + jest.spyOn(nftController, 'isNftOwner').mockResolvedValue(false); + + await nftController.checkAndUpdateSingleNftOwnershipStatus( + nft, + 'mainnet', + ); + + expect( + nftController.state.allNfts[OWNER_ACCOUNT.address][ChainId.mainnet][0] + .isCurrentlyOwned, + ).toBe(false); + }); + + it('should check whether the passed NFT is still owned by the selectedAddress/chainId combination passed in the accountParams argument and update its isCurrentlyOwned property in state, when the currently configured selectedAddress/chainId are different from those passed', async () => { + const firstSelectedAddress = OWNER_ACCOUNT.address; + const { + nftController, + triggerPreferencesStateChange, + triggerSelectedAccountChange, + } = setupController(); + + triggerSelectedAccountChange(OWNER_ACCOUNT); + triggerPreferencesStateChange({ + ...getDefaultPreferencesState(), + displayNftMedia: true, + }); + + const nft = { + address: '0x02', + tokenId: '1', + name: 'name', + image: 'image', + description: 'description', + standard: 'standard', + favorite: false, + }; + + await nftController.addNft(nft.address, nft.tokenId, 'sepolia', { + nftMetadata: nft, + }); + + expect( + nftController.state.allNfts[firstSelectedAddress][ChainId.sepolia][0] + .isCurrentlyOwned, + ).toBe(true); + + jest.spyOn(nftController, 'isNftOwner').mockResolvedValue(false); + const secondAccount = createMockInternalAccount({ + address: SECOND_OWNER_ADDRESS, + }); + triggerSelectedAccountChange(secondAccount); + triggerPreferencesStateChange({ + ...getDefaultPreferencesState(), + displayNftMedia: true, + }); + + const updatedNft = + await nftController.checkAndUpdateSingleNftOwnershipStatus( + nft, + 'sepolia', { - to: ERC1155_NFT_ADDRESS.toLowerCase(), - data: '0x00fdd58e00000000000000000000000000000000000000000000000000000000000000005a3ca5cd63807ce5e4d7841ab32ce6b6d9bbba2d000000000000010000000001', + userAddress: OWNER_ADDRESS, }, - 'latest', - ], - }) - .reply(200, { - jsonrpc: '2.0', - id: 27, - result: - '0x0000000000000000000000000000000000000000000000000000000000000000', + ); + + expect(updatedNft).toStrictEqual({ + ...nft, + isCurrentlyOwned: false, }); - assetsContract.configure({ provider: MAINNET_PROVIDER }); - const isOwner = await nftController.isNftOwner( - '0x0000000000000000000000000000000000000000', - ERC1155_NFT_ADDRESS, - ERC1155_NFT_ID, + expect( + nftController.state.allNfts[OWNER_ADDRESS][SEPOLIA.chainId][0] + .isCurrentlyOwned, + ).toBe(false); + }); + }); + }); + + describe('findNftByAddressAndTokenId', () => { + const mockNft = { + address: '0x02', + tokenId: '1', + name: 'name', + image: 'image', + description: 'description', + standard: 'standard', + favorite: false, + }; + + it('should return null if the NFT does not exist in the state', async () => { + const { nftController } = setupController({ + defaultSelectedAccount: OWNER_ACCOUNT, + }); + + expect( + nftController.findNftByAddressAndTokenId( + mockNft.address, + mockNft.tokenId, + OWNER_ACCOUNT.address, + ChainId.mainnet, + ), + ).toBeNull(); + }); + + it('should return the NFT by the address and tokenId', () => { + const { nftController } = setupController({ + options: { + state: { + allNfts: { + [OWNER_ACCOUNT.address]: { [ChainId.mainnet]: [mockNft] }, + }, + }, + }, + defaultSelectedAccount: OWNER_ACCOUNT, + }); + + expect( + nftController.findNftByAddressAndTokenId( + mockNft.address, + mockNft.tokenId, + OWNER_ACCOUNT.address, + ChainId.mainnet, + ), + ).toStrictEqual({ nft: mockNft, index: 0 }); + }); + }); + + describe('updateNftByAddressAndTokenId', () => { + const mockTransactionId = '60d36710-b150-11ec-8a49-c377fbd05e27'; + const mockNft = { + address: '0x02', + tokenId: '1', + name: 'name', + image: 'image', + description: 'description', + standard: 'standard', + favorite: false, + }; + + const expectedMockNft = { + address: '0x02', + description: 'description', + favorite: false, + image: 'image', + name: 'name', + standard: 'standard', + tokenId: '1', + transactionId: mockTransactionId, + }; + + it('should update the NFT if the NFT exist', async () => { + const { nftController } = setupController({ + options: { + state: { + allNfts: { + [OWNER_ACCOUNT.address]: { [ChainId.mainnet]: [mockNft] }, + }, + }, + }, + defaultSelectedAccount: OWNER_ACCOUNT, + }); + + nftController.updateNft( + mockNft, + { + transactionId: mockTransactionId, + }, + OWNER_ACCOUNT.address, + ChainId.mainnet, ); - expect(isOwner).toBe(false); + + expect( + nftController.state.allNfts[OWNER_ACCOUNT.address][ChainId.mainnet][0], + ).toStrictEqual(expectedMockNft); }); - it('should throw an error for an unsupported standard', async () => { - const { assetsContract, nftController } = setupController(); - assetsContract.configure({ provider: MAINNET_PROVIDER }); - const error = - "Unable to verify ownership. Possibly because the standard is not supported or the user's currently selected network does not match the chain of the asset in question."; - const result = async () => { - await nftController.isNftOwner( - '0x0000000000000000000000000000000000000000', - CRYPTOPUNK_ADDRESS, - '0', - ); - }; - await expect(result).rejects.toThrow(error); + it('should return undefined if the NFT does not exist', () => { + const { nftController } = setupController({ + defaultSelectedAccount: OWNER_ACCOUNT, + }); + + expect( + nftController.updateNft( + mockNft, + { + transactionId: mockTransactionId, + }, + OWNER_ACCOUNT.address, + ChainId.mainnet, + ), + ).toBeUndefined(); + }); + }); + + describe('resetNftTransactionStatusByTransactionId', () => { + const mockTransactionId = '60d36710-b150-11ec-8a49-c377fbd05e27'; + const nonExistTransactionId = '0123'; + + const mockNft = { + address: '0x02', + tokenId: '1', + name: 'name', + image: 'image', + description: 'description', + standard: 'standard', + favorite: false, + transactionId: mockTransactionId, + }; + + it('should not update any NFT state and should return false when passed a transaction id that does not match that of any NFT', async () => { + const { nftController } = setupController({ + defaultSelectedAccount: OWNER_ACCOUNT, + }); + + expect( + nftController.resetNftTransactionStatusByTransactionId( + nonExistTransactionId, + OWNER_ACCOUNT.address, + ChainId.mainnet, + ), + ).toBe(false); + }); + + it('should set the transaction id of an NFT in state to undefined, and return true when it has successfully updated this state', async () => { + const { nftController } = setupController({ + options: { + state: { + allNfts: { + [OWNER_ADDRESS]: { [ChainId.mainnet]: [mockNft] }, + }, + }, + }, + }); + + expect( + nftController.state.allNfts[OWNER_ACCOUNT.address][ChainId.mainnet][0] + .transactionId, + ).toBe(mockTransactionId); + + expect( + nftController.resetNftTransactionStatusByTransactionId( + mockTransactionId, + OWNER_ACCOUNT.address, + ChainId.mainnet, + ), + ).toBe(true); + + expect( + nftController.state.allNfts[OWNER_ACCOUNT.address][ChainId.mainnet][0] + .transactionId, + ).toBeUndefined(); + }); + }); + + describe('updateNftMetadata', () => { + it('should not update Nft metadata when preferences change and current and incoming state are the same', async () => { + const { + nftController, + triggerPreferencesStateChange, + triggerSelectedAccountChange, + } = setupController(); + const spy = jest.spyOn(nftController, 'updateNftMetadata'); + triggerSelectedAccountChange(OWNER_ACCOUNT); + // trigger preference change + triggerPreferencesStateChange({ + ...getDefaultPreferencesState(), + }); + + expect(spy).toHaveBeenCalledTimes(0); + }); + + it('calls update Nft metadata when preferences change is triggered and ipfsGateway changes', async () => { + const { + nftController, + mockGetAccount, + triggerPreferencesStateChange, + triggerSelectedAccountChange, + } = setupController({ + defaultSelectedAccount: OWNER_ACCOUNT, + }); + const spy = jest.spyOn(nftController, 'updateNftMetadata'); + const testNetworkClientId = 'mainnet'; + mockGetAccount.mockReturnValue(OWNER_ACCOUNT); + await nftController.addNft('0xtest', '3', testNetworkClientId, { + nftMetadata: { name: '', description: '', image: '', standard: '' }, + }); + + triggerSelectedAccountChange(OWNER_ACCOUNT); + // trigger preference change + triggerPreferencesStateChange({ + ...getDefaultPreferencesState(), + ipfsGateway: 'https://toto/ipfs/', + }); + + expect(spy).toHaveBeenCalledTimes(1); + }); + + it('calls update Nft metadata when preferences change is triggered and displayNftMedia changes', async () => { + const { + nftController, + mockGetAccount, + triggerPreferencesStateChange, + triggerSelectedAccountChange, + } = setupController({ + defaultSelectedAccount: OWNER_ACCOUNT, + displayNftMedia: false, + }); + const spy = jest.spyOn(nftController, 'updateNftMetadata'); + const testNetworkClientId = 'mainnet'; + mockGetAccount.mockReturnValue(OWNER_ACCOUNT); + await nftController.addNft('0xtest', '3', testNetworkClientId, { + nftMetadata: { name: '', description: '', image: '', standard: '' }, + }); + + triggerSelectedAccountChange(OWNER_ACCOUNT); + // trigger preference change + triggerPreferencesStateChange({ + ...getDefaultPreferencesState(), + displayNftMedia: true, + }); + + expect(spy).toHaveBeenCalledTimes(1); + }); + + it('calls update Nft metadata when preferences change is triggered and openSeaEnabled changes', async () => { + const { + nftController, + mockGetAccount, + triggerPreferencesStateChange, + triggerSelectedAccountChange, + } = setupController({ + defaultSelectedAccount: OWNER_ACCOUNT, + displayNftMedia: false, + }); + const spy = jest.spyOn(nftController, 'updateNftMetadata'); + const testNetworkClientId = 'mainnet'; + mockGetAccount.mockReturnValue(OWNER_ACCOUNT); + await nftController.addNft('0xtest', '3', testNetworkClientId, { + nftMetadata: { name: '', description: '', image: '', standard: '' }, + }); + + triggerSelectedAccountChange(OWNER_ACCOUNT); + // trigger preference change + triggerPreferencesStateChange({ + ...getDefaultPreferencesState(), + openSeaEnabled: true, + }); + + expect(spy).toHaveBeenCalledTimes(1); + }); + + it('should update Nft metadata successfully', async () => { + const tokenURI = 'https://api.pudgypenguins.io/lil/4'; + const mockGetERC721TokenURI = jest.fn().mockResolvedValue(tokenURI); + const { nftController, mockGetAccount } = setupController({ + getERC721TokenURI: mockGetERC721TokenURI, + defaultSelectedAccount: OWNER_ACCOUNT, + }); + const spy = jest.spyOn(nftController, 'updateNft'); + const testNetworkClientId = 'sepolia'; + mockGetAccount.mockReturnValue(OWNER_ACCOUNT); + await nftController.addNft('0xtest', '3', testNetworkClientId, { + nftMetadata: { name: '', description: '', image: '', standard: '' }, + }); + + nock('https://api.pudgypenguins.io').get('/lil/4').reply(200, { + name: 'name pudgy', + image: 'url pudgy', + description: 'description pudgy', + }); + const testInputNfts: Nft[] = [ + { + address: '0xtest', + description: null, + favorite: false, + image: null, + isCurrentlyOwned: true, + name: null, + standard: ERC721, + tokenId: '3', + tokenURI, + chainId: 11155111, + }, + ]; + + await nftController.updateNftMetadata({ + nfts: testInputNfts, + // networkClientId: testNetworkClientId, + }); + expect(spy).toHaveBeenCalledTimes(1); + + expect( + nftController.state.allNfts[OWNER_ACCOUNT.address][SEPOLIA.chainId][0], + ).toStrictEqual({ + address: '0xtest', + chainId: 11155111, + description: 'description pudgy', + image: 'url pudgy', + name: 'name pudgy', + tokenId: '3', + standard: ERC721, + favorite: false, + isCurrentlyOwned: true, + tokenURI: 'https://api.pudgypenguins.io/lil/4', + }); }); - it('should add NFT with null metadata if the ipfs gateway is disabled and opensea is disabled', async () => { - const { assetsContract, nftController, preferences } = setupController(); + it('should not update metadata when state nft and fetched nft are the same', async () => { + const tokenURI = 'https://url/'; + const mockGetERC721TokenURI = jest.fn().mockResolvedValue(tokenURI); + const { nftController, mockGetAccount } = setupController({ + getERC721TokenURI: mockGetERC721TokenURI, + defaultSelectedAccount: OWNER_ACCOUNT, + }); + const updateNftSpy = jest.spyOn(nftController, 'updateNft'); + const testNetworkClientId = 'sepolia'; + mockGetAccount.mockReturnValue(OWNER_ACCOUNT); + await nftController.addNft('0xtest', '3', testNetworkClientId, { + nftMetadata: { + name: 'toto', + description: 'description', + image: 'image.png', + standard: ERC721, + tokenURI, + }, + }); + + nock('https://url') + .get('/') + .reply(200, { + name: 'toto', + image: 'image.png', + description: 'description', + }) + .persist(); + const testInputNfts: Nft[] = [ + { + address: '0xtest', + description: 'description', + favorite: false, + image: 'image.png', + isCurrentlyOwned: true, + name: 'toto', + standard: ERC721, + tokenId: '3', + chainId: convertHexToDecimal(ChainId.sepolia), + }, + ]; + + mockGetAccount.mockReturnValue(OWNER_ACCOUNT); + await nftController.updateNftMetadata({ + nfts: testInputNfts, + // networkClientId: testNetworkClientId, + }); - preferences.update({ - isIpfsGatewayEnabled: false, - openSeaEnabled: false, + expect(updateNftSpy).toHaveBeenCalledTimes(0); + expect( + nftController.state.allNfts[OWNER_ACCOUNT.address][SEPOLIA.chainId][0], + ).toStrictEqual({ + address: '0xtest', + chainId: convertHexToDecimal(ChainId.sepolia), + description: 'description', + favorite: false, + image: 'image.png', + isCurrentlyOwned: true, + name: 'toto', + standard: ERC721, + tokenId: '3', + tokenURI, }); + }); - sinon - .stub(nftController, 'getNftURIAndStandard' as any) - .returns(['ipfs://*', ERC1155]); + it('should trigger update metadata when state nft and fetched nft are not the same', async () => { + const tokenURI = 'https://url/'; + const mockGetERC721TokenURI = jest.fn().mockResolvedValue(tokenURI); + const { nftController, mockGetAccount } = setupController({ + getERC721TokenURI: mockGetERC721TokenURI, + defaultSelectedAccount: OWNER_ACCOUNT, + }); + const spy = jest.spyOn(nftController, 'updateNft'); + const testNetworkClientId = 'sepolia'; + mockGetAccount.mockReturnValue(OWNER_ACCOUNT); + await nftController.addNft('0xtest', '3', testNetworkClientId, { + nftMetadata: { + name: 'toto', + description: 'description', + image: 'image.png', + standard: ERC721, + }, + // networkClientId: testNetworkClientId, + }); - assetsContract.configure({ provider: MAINNET_PROVIDER }); - const { selectedAddress, chainId } = nftController.config; + nock('https://url').get('/').reply(200, { + name: 'toto', + image: 'image-updated.png', + description: 'description', + }); + const testInputNfts: Nft[] = [ + { + address: '0xtest', + description: 'description', + favorite: false, + image: 'image.png', + isCurrentlyOwned: true, + name: 'toto', + standard: ERC721, + tokenId: '3', + chainId: convertHexToDecimal(ChainId.sepolia), + }, + ]; - await nftController.addNft(ERC1155_NFT_ADDRESS, ERC1155_NFT_ID); + await nftController.updateNftMetadata({ + nfts: testInputNfts, + // networkClientId: testNetworkClientId, + }); + expect(spy).toHaveBeenCalledTimes(1); expect( - nftController.state.allNfts[selectedAddress][chainId][0], + nftController.state.allNfts[OWNER_ACCOUNT.address][SEPOLIA.chainId][0], ).toStrictEqual({ - address: ERC1155_NFT_ADDRESS, - name: null, - description: null, - image: null, - tokenId: ERC1155_NFT_ID, - standard: ERC1155, + address: '0xtest', + description: 'description', favorite: false, + image: 'image-updated.png', isCurrentlyOwned: true, - tokenURI: 'ipfs://*', + name: 'toto', + standard: ERC721, + tokenId: '3', + tokenURI, + chainId: convertHexToDecimal(ChainId.sepolia), }); }); - }); - describe('updateNftFavoriteStatus', () => { - it('should set NFT as favorite', async () => { - const { assetsContract, nftController } = setupController(); - assetsContract.configure({ provider: MAINNET_PROVIDER }); - const { selectedAddress, chainId } = nftController.config; - await nftController.addNft( - ERC721_DEPRESSIONIST_ADDRESS, - ERC721_DEPRESSIONIST_ID, - ); + it('should not update metadata when nfts has image/name/description already', async () => { + const { + nftController, + triggerPreferencesStateChange, + triggerSelectedAccountChange, + } = setupController(); + const spy = jest.spyOn(nftController, 'updateNftMetadata'); + const testNetworkClientId = 'sepolia'; + + // Add nfts + await nftController.addNft('0xtest', '3', testNetworkClientId, { + nftMetadata: { + name: 'test name', + description: 'test description', + image: 'test image', + standard: ERC721, + }, + userAddress: OWNER_ADDRESS, + // networkClientId: testNetworkClientId, + }); - nftController.updateNftFavoriteStatus( - ERC721_DEPRESSIONIST_ADDRESS, - ERC721_DEPRESSIONIST_ID, - true, - ); + triggerSelectedAccountChange(OWNER_ACCOUNT); + // trigger preference change + triggerPreferencesStateChange({ + ...getDefaultPreferencesState(), + isIpfsGatewayEnabled: false, + displayNftMedia: true, + }); - expect( - nftController.state.allNfts[selectedAddress][chainId][0], - ).toStrictEqual( - expect.objectContaining({ - address: ERC721_DEPRESSIONIST_ADDRESS, - tokenId: ERC721_DEPRESSIONIST_ID, - favorite: true, - }), - ); + expect(spy).toHaveBeenCalledTimes(0); }); - it('should set NFT as favorite and then unset it', async () => { - const { assetsContract, nftController } = setupController(); - assetsContract.configure({ provider: MAINNET_PROVIDER }); - const { selectedAddress, chainId } = nftController.config; - await nftController.addNft( - ERC721_DEPRESSIONIST_ADDRESS, - ERC721_DEPRESSIONIST_ID, - ); + it('should trigger calling updateNftMetadata when preferences change - displayNftMedia', async () => { + const tokenURI = 'https://url/'; + const mockGetERC721TokenURI = jest.fn().mockResolvedValue(tokenURI); + const { + nftController, + triggerPreferencesStateChange, + triggerSelectedAccountChange, + } = setupController({ + getERC721TokenURI: mockGetERC721TokenURI, + }); + const spy = jest.spyOn(nftController, 'updateNftMetadata'); - nftController.updateNftFavoriteStatus( - ERC721_DEPRESSIONIST_ADDRESS, - ERC721_DEPRESSIONIST_ID, - true, - ); + const testNetworkClientId = 'sepolia'; + // Add nfts + await nftController.addNft('0xtest', '1', testNetworkClientId, { + nftMetadata: { + name: '', + description: '', + image: '', + standard: ERC721, + }, + userAddress: OWNER_ADDRESS, + // networkClientId: testNetworkClientId, + }); expect( - nftController.state.allNfts[selectedAddress][chainId][0], - ).toStrictEqual( - expect.objectContaining({ - address: ERC721_DEPRESSIONIST_ADDRESS, - tokenId: ERC721_DEPRESSIONIST_ID, - favorite: true, - }), - ); + nftController.state.allNfts[OWNER_ADDRESS][SEPOLIA.chainId][0] + .isCurrentlyOwned, + ).toBe(true); - nftController.updateNftFavoriteStatus( - ERC721_DEPRESSIONIST_ADDRESS, - ERC721_DEPRESSIONIST_ID, - false, - ); + nock('https://url').get('/').reply(200, { + name: 'name pudgy', + image: 'url pudgy', + description: 'description pudgy', + }); - expect( - nftController.state.allNfts[selectedAddress][chainId][0], - ).toStrictEqual( - expect.objectContaining({ - address: ERC721_DEPRESSIONIST_ADDRESS, - tokenId: ERC721_DEPRESSIONIST_ID, - favorite: false, - }), - ); + // trigger preference change + triggerPreferencesStateChange({ + ...getDefaultPreferencesState(), + isIpfsGatewayEnabled: false, + displayNftMedia: true, + }); + triggerSelectedAccountChange(OWNER_ACCOUNT); + expect(spy).toHaveBeenCalledTimes(1); }); - it('should keep the favorite status as true after updating metadata', async () => { - const { assetsContract, nftController } = setupController(); - assetsContract.configure({ provider: MAINNET_PROVIDER }); - const { selectedAddress, chainId } = nftController.config; - await nftController.addNft( - ERC721_DEPRESSIONIST_ADDRESS, - ERC721_DEPRESSIONIST_ID, - ); + it('should trigger calling updateNftMetadata when preferences change - ipfs enabled', async () => { + const tokenURI = 'https://url/'; + const mockGetERC721TokenURI = jest.fn().mockResolvedValue(tokenURI); + const { + nftController, + triggerPreferencesStateChange, + triggerSelectedAccountChange, + } = setupController({ + getERC721TokenURI: mockGetERC721TokenURI, + }); + const spy = jest.spyOn(nftController, 'updateNftMetadata'); - nftController.updateNftFavoriteStatus( - ERC721_DEPRESSIONIST_ADDRESS, - ERC721_DEPRESSIONIST_ID, - true, - ); + const testNetworkClientId = 'sepolia'; + // Add nfts + await nftController.addNft('0xtest', '1', testNetworkClientId, { + nftMetadata: { + name: '', + description: '', + image: '', + standard: ERC721, + }, + userAddress: OWNER_ADDRESS, + // networkClientId: testNetworkClientId, + }); expect( - nftController.state.allNfts[selectedAddress][chainId][0], - ).toStrictEqual( - expect.objectContaining({ - address: ERC721_DEPRESSIONIST_ADDRESS, - tokenId: ERC721_DEPRESSIONIST_ID, - favorite: true, - }), - ); + nftController.state.allNfts[OWNER_ADDRESS][SEPOLIA.chainId][0] + .isCurrentlyOwned, + ).toBe(true); - await nftController.addNft( - ERC721_DEPRESSIONIST_ADDRESS, - ERC721_DEPRESSIONIST_ID, + nock('https://url').get('/').reply(200, { + name: 'name pudgy', + image: 'url pudgy', + description: 'description pudgy', + }); + + // trigger preference change + triggerPreferencesStateChange({ + ...getDefaultPreferencesState(), + isIpfsGatewayEnabled: true, + displayNftMedia: false, + }); + triggerSelectedAccountChange(OWNER_ACCOUNT); + + expect(spy).toHaveBeenCalledTimes(1); + }); + + it('should call getNftInformation only one time per interval', async () => { + const tokenURI = 'https://api.pudgypenguins.io/lil/4'; + const mockGetERC721TokenURI = jest.fn().mockResolvedValue(tokenURI); + const { nftController, triggerPreferencesStateChange } = setupController({ + getERC721TokenURI: mockGetERC721TokenURI, + }); + const selectedAddress = OWNER_ADDRESS; + const spy = jest.spyOn(nftController, 'updateNft'); + const testNetworkClientId = 'sepolia'; + await nftController.addNft('0xtest', '3', testNetworkClientId, { + nftMetadata: { name: '', description: '', image: '', standard: '' }, + // networkClientId: testNetworkClientId, + }); + + nock('https://api.pudgypenguins.io/lil').get('/4').reply(200, { + name: 'name pudgy', + image: 'url pudgy', + description: 'description pudgy', + }); + const testInputNfts: Nft[] = [ { - nftMetadata: { - image: 'new_image', - name: 'new_name', - description: 'new_description', - standard: 'ERC721', - }, + address: '0xtest', + description: null, + favorite: false, + image: null, + isCurrentlyOwned: true, + name: null, + standard: 'ERC721', + tokenId: '3', + tokenURI: 'https://api.pudgypenguins.io/lil/4', + chainId: convertHexToDecimal(ChainId.sepolia), }, - ); + ]; + + // Make first call to updateNftMetadata should trigger state update + await nftController.updateNftMetadata({ + nfts: testInputNfts, + // networkClientId: testNetworkClientId, + }); + expect(spy).toHaveBeenCalledTimes(1); expect( - nftController.state.allNfts[selectedAddress][chainId][0], - ).toStrictEqual( - expect.objectContaining({ - image: 'new_image', - name: 'new_name', - description: 'new_description', - address: ERC721_DEPRESSIONIST_ADDRESS, - tokenId: ERC721_DEPRESSIONIST_ID, - favorite: true, + nftController.state.allNfts[selectedAddress][SEPOLIA.chainId][0], + ).toStrictEqual({ + address: '0xtest', + description: 'description pudgy', + image: 'url pudgy', + name: 'name pudgy', + tokenId: '3', + standard: 'ERC721', + favorite: false, + isCurrentlyOwned: true, + tokenURI: 'https://api.pudgypenguins.io/lil/4', + chainId: convertHexToDecimal(ChainId.sepolia), + }); + + spy.mockClear(); + + // trigger calling updateNFTMetadata again on the same account should not trigger state update + const spy2 = jest.spyOn(nftController, 'updateNft'); + await nftController.updateNftMetadata({ + nfts: testInputNfts, + // networkClientId: testNetworkClientId, + }); + // No updates to state should be made + expect(spy2).toHaveBeenCalledTimes(0); + + // trigger preference change and change selectedAccount + const testNewAccountAddress = 'OxDifferentAddress'; + triggerPreferencesStateChange({ + ...getDefaultPreferencesState(), + selectedAddress: testNewAccountAddress, + }); + + spy.mockClear(); + await nftController.addNft('0xtest', '4', testNetworkClientId, { + nftMetadata: { name: '', description: '', image: '', standard: '' }, + // networkClientId: testNetworkClientId, + }); + + const testInputNfts2: Nft[] = [ + { + address: '0xtest', + description: null, + favorite: false, + image: null, isCurrentlyOwned: true, - }), - ); + name: null, + standard: 'ERC721', + tokenId: '4', + tokenURI: 'https://api.pudgypenguins.io/lil/4', + chainId: convertHexToDecimal(ChainId.sepolia), + }, + ]; - expect( - nftController.state.allNfts[selectedAddress][chainId], - ).toHaveLength(1); + const spy3 = jest.spyOn(nftController, 'updateNft'); + await nftController.updateNftMetadata({ + nfts: testInputNfts2, + // networkClientId: testNetworkClientId, + }); + // When the account changed, and updateNftMetadata is called state update should be triggered + expect(spy3).toHaveBeenCalledTimes(1); }); + }); - it('should keep the favorite status as false after updating metadata', async () => { - const { assetsContract, nftController } = setupController(); - assetsContract.configure({ provider: MAINNET_PROVIDER }); - const { selectedAddress, chainId } = nftController.config; - await nftController.addNft( - ERC721_DEPRESSIONIST_ADDRESS, - ERC721_DEPRESSIONIST_ID, - ); + // Testing to make sure selectedAccountChange isn't used. This can return non-EVM accounts. + it('triggering selectedAccountChange would not trigger anything', async () => { + const tokenURI = 'https://url/'; + const mockGetERC721TokenURI = jest.fn().mockResolvedValue(tokenURI); + const { nftController, messenger } = setupController({ + options: { + displayNftMedia: true, + }, + getERC721TokenURI: mockGetERC721TokenURI, + }); + const updateNftMetadataSpy = jest.spyOn(nftController, 'updateNftMetadata'); + messenger.publish( + 'AccountsController:selectedAccountChange', + createMockInternalAccount({ + id: 'new-id', + address: '0x5284deb594c4b593268d7c98e5ecd29dcafa5b49', + }), + ); - expect( - nftController.state.allNfts[selectedAddress][chainId][0], - ).toStrictEqual( - expect.objectContaining({ - address: ERC721_DEPRESSIONIST_ADDRESS, - tokenId: ERC721_DEPRESSIONIST_ID, - favorite: false, - }), - ); + expect(updateNftMetadataSpy).not.toHaveBeenCalled(); + }); - await nftController.addNft( - ERC721_DEPRESSIONIST_ADDRESS, - ERC721_DEPRESSIONIST_ID, - { - nftMetadata: { - image: 'new_image', - name: 'new_name', - description: 'new_description', - standard: 'ERC721', + describe('resetState', () => { + it('resets the state to default state', () => { + const initialState: NftControllerState = { + allNftContracts: { + [OWNER_ACCOUNT.address]: { [ChainId.mainnet]: [] }, + }, + allNfts: { + [OWNER_ACCOUNT.address]: { [ChainId.mainnet]: [] }, + }, + ignoredNfts: [ + { + address: ERC1155_NFT_ADDRESS, + name: null, + description: null, + image: null, + tokenId: ERC1155_NFT_ID, + standard: ERC1155, + favorite: false, + isCurrentlyOwned: true, + tokenURI: 'ipfs://*', + }, + ], + }; + const { nftController } = setupController({ + options: { + state: initialState, + }, + }); + + expect(nftController.state).toStrictEqual(initialState); + + nftController.resetState(); + + expect(nftController.state).toStrictEqual({ + allNftContracts: {}, + allNfts: {}, + ignoredNfts: [], + }); + }); + }); + + describe('phishing protection for NFT metadata', () => { + /** + * Tests for the NFT URL sanitization feature. + */ + it('should sanitize malicious URLs when adding NFTs', async () => { + const mockBulkScanUrls = jest.fn().mockResolvedValue({ + results: { + 'http://malicious-site.com/image.png': { + recommendedAction: RecommendedAction.Block, + }, + 'http://malicious-domain.com': { + recommendedAction: RecommendedAction.Block, + }, + 'http://safe-site.com/image.png': { + recommendedAction: RecommendedAction.None, + }, + 'http://legitimate-domain.com': { + recommendedAction: RecommendedAction.None, }, }, - ); + }); - expect( - nftController.state.allNfts[selectedAddress][chainId][0], - ).toStrictEqual( - expect.objectContaining({ - image: 'new_image', - name: 'new_name', - description: 'new_description', - address: ERC721_DEPRESSIONIST_ADDRESS, - tokenId: ERC721_DEPRESSIONIST_ID, - favorite: false, - isCurrentlyOwned: true, - }), + const { nftController } = setupController({ + bulkScanUrlsMock: mockBulkScanUrls, + }); + + const nftWithMaliciousURLs: NftMetadata = { + name: 'Malicious NFT', + description: 'NFT with malicious links', + image: 'http://malicious-site.com/image.png', + externalLink: 'http://malicious-domain.com', + standard: ERC721, + }; + + const nftWithSafeURLs: NftMetadata = { + name: 'Safe NFT', + description: 'NFT with safe links', + image: 'http://safe-site.com/image.png', + externalLink: 'http://legitimate-domain.com', + standard: ERC721, + }; + + await nftController.addNft('0xmalicious', '1', 'mainnet', { + nftMetadata: nftWithMaliciousURLs, + userAddress: OWNER_ADDRESS, + }); + + await nftController.addNft('0xsafe', '2', 'mainnet', { + nftMetadata: nftWithSafeURLs, + userAddress: OWNER_ADDRESS, + }); + + expect(mockBulkScanUrls).toHaveBeenCalled(); + + const storedNfts = + nftController.state.allNfts[OWNER_ADDRESS][ChainId.mainnet]; + + const maliciousNft = storedNfts.find( + (nft) => nft.address === '0xmalicious', ); + const safeNft = storedNfts.find((nft) => nft.address === '0xsafe'); - expect( - nftController.state.allNfts[selectedAddress][chainId], - ).toHaveLength(1); + expect(maliciousNft?.image).toBeUndefined(); + expect(maliciousNft?.externalLink).toBeUndefined(); + + expect(maliciousNft?.name).toBe('Malicious NFT'); + expect(maliciousNft?.description).toBe('NFT with malicious links'); + + expect(safeNft?.image).toBe('http://safe-site.com/image.png'); + expect(safeNft?.externalLink).toBe('http://legitimate-domain.com'); }); - describe('checkAndUpdateNftsOwnershipStatus', () => { - describe('checkAndUpdateAllNftsOwnershipStatus', () => { - it('should check whether NFTs for the current selectedAddress/chainId combination are still owned by the selectedAddress and update the isCurrentlyOwned value to false when NFT is not still owned', async () => { - const { nftController } = setupController(); - sinon.stub(nftController, 'isNftOwner' as any).returns(false); + it('should handle errors during phishing detection when adding NFTs', async () => { + const mockBulkScanUrls = jest + .fn() + .mockRejectedValue(new Error('Phishing detection failed')); - const { selectedAddress, chainId } = nftController.config; - await nftController.addNft('0x02', '1', { - nftMetadata: { - name: 'name', - image: 'image', - description: 'description', - standard: 'standard', - favorite: false, - }, - }); - - expect( - nftController.state.allNfts[selectedAddress][chainId][0] - .isCurrentlyOwned, - ).toBe(true); - - await nftController.checkAndUpdateAllNftsOwnershipStatus(); - expect( - nftController.state.allNfts[selectedAddress][chainId][0] - .isCurrentlyOwned, - ).toBe(false); - }); + const { nftController } = setupController({ + bulkScanUrlsMock: mockBulkScanUrls, }); - it('should check whether NFTs for the current selectedAddress/chainId combination are still owned by the selectedAddress and leave/set the isCurrentlyOwned value to true when NFT is still owned', async () => { - const { nftController } = setupController(); - sinon.stub(nftController, 'isNftOwner' as any).returns(true); + const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(); - const { selectedAddress, chainId } = nftController.config; - await nftController.addNft('0x02', '1', { - nftMetadata: { - name: 'name', - image: 'image', - description: 'description', - standard: 'standard', - favorite: false, - }, - }); - - expect( - nftController.state.allNfts[selectedAddress][chainId][0] - .isCurrentlyOwned, - ).toBe(true); + const nftMetadata: NftMetadata = { + name: 'Test NFT', + description: 'Test description', + image: 'http://example.com/image.png', + externalLink: 'http://example.com', + standard: ERC721, + }; - await nftController.checkAndUpdateAllNftsOwnershipStatus(); - expect( - nftController.state.allNfts[selectedAddress][chainId][0] - .isCurrentlyOwned, - ).toBe(true); + await nftController.addNft('0xtest', '1', 'mainnet', { + nftMetadata, + userAddress: OWNER_ADDRESS, }); - it('should check whether NFTs for the current selectedAddress/chainId combination are still owned by the selectedAddress and leave the isCurrentlyOwned value as is when NFT ownership check fails', async () => { - const { nftController } = setupController(); - sinon - .stub(nftController, 'isNftOwner' as any) - .throws(new Error('Unable to verify ownership')); + expect(mockBulkScanUrls).toHaveBeenCalled(); - const { selectedAddress, chainId } = nftController.config; - await nftController.addNft('0x02', '1', { - nftMetadata: { - name: 'name', - image: 'image', - description: 'description', - standard: 'standard', - favorite: false, - }, - }); + expect(consoleErrorSpy).toHaveBeenCalledWith( + 'Error during bulk URL scanning:', + expect.any(Error), + ); - expect( - nftController.state.allNfts[selectedAddress][chainId][0] - .isCurrentlyOwned, - ).toBe(true); + const storedNft = + nftController.state.allNfts[OWNER_ADDRESS][ChainId.mainnet][0]; + expect(storedNft.image).toBe('http://example.com/image.png'); + expect(storedNft.externalLink).toBe('http://example.com'); - await nftController.checkAndUpdateAllNftsOwnershipStatus(); - expect( - nftController.state.allNfts[selectedAddress][chainId][0] - .isCurrentlyOwned, - ).toBe(true); + consoleErrorSpy.mockRestore(); + }); + + it('should sanitize all URL fields when they contain malicious URLs', async () => { + const mockBulkScanUrls = jest.fn().mockResolvedValue({ + results: { + 'http://malicious-image.com/image.png': { + recommendedAction: RecommendedAction.Block, + }, + 'http://malicious-preview.com/preview.png': { + recommendedAction: RecommendedAction.Block, + }, + 'http://malicious-thumb.com/thumb.png': { + recommendedAction: RecommendedAction.Block, + }, + 'http://malicious-original.com/original.png': { + recommendedAction: RecommendedAction.Block, + }, + 'http://malicious-animation.com/animation.mp4': { + recommendedAction: RecommendedAction.Block, + }, + 'http://malicious-animation-orig.com/animation-orig.mp4': { + recommendedAction: RecommendedAction.Block, + }, + 'http://malicious-external.com': { + recommendedAction: RecommendedAction.Block, + }, + 'http://malicious-collection.com': { + recommendedAction: RecommendedAction.Block, + }, + }, }); - describe('checkAndUpdateSingleNftOwnershipStatus', () => { - it('should check whether the passed NFT is still owned by the the current selectedAddress/chainId combination and update its isCurrentlyOwned property in state if batch is false and isNftOwner returns false', async () => { - const { nftController } = setupController(); - const { selectedAddress, chainId } = nftController.config; - const nft = { - address: '0x02', - tokenId: '1', - name: 'name', - image: 'image', - description: 'description', - standard: 'standard', - favorite: false, - }; + const { nftController } = setupController({ + bulkScanUrlsMock: mockBulkScanUrls, + }); - await nftController.addNft(nft.address, nft.tokenId, { - nftMetadata: nft, - }); + // Create NFT with malicious URLs in all possible fields + const nftWithAllMaliciousURLs: NftMetadata = { + name: 'NFT with all URL fields', + description: 'Testing all URL fields', + image: 'http://malicious-image.com/image.png', + imagePreview: 'http://malicious-preview.com/preview.png', + imageThumbnail: 'http://malicious-thumb.com/thumb.png', + imageOriginal: 'http://malicious-original.com/original.png', + animation: 'http://malicious-animation.com/animation.mp4', + animationOriginal: + 'http://malicious-animation-orig.com/animation-orig.mp4', + externalLink: 'http://malicious-external.com', + standard: ERC721, + collection: { + id: 'collection-1', + name: 'Test Collection', + externalLink: 'http://malicious-collection.com', + } as Collection & { externalLink?: string }, + }; - expect( - nftController.state.allNfts[selectedAddress][chainId][0] - .isCurrentlyOwned, - ).toBe(true); + await nftController.addNft('0xallmalicious', '1', 'mainnet', { + nftMetadata: nftWithAllMaliciousURLs, + userAddress: OWNER_ADDRESS, + }); - sinon.stub(nftController, 'isNftOwner' as any).returns(false); + const storedNft = + nftController.state.allNfts[OWNER_ADDRESS][ChainId.mainnet][0]; + + // Verify all URL fields were sanitized + expect(storedNft.image).toBeUndefined(); + expect(storedNft.imagePreview).toBeUndefined(); + expect(storedNft.imageThumbnail).toBeUndefined(); + expect(storedNft.imageOriginal).toBeUndefined(); + expect(storedNft.animation).toBeUndefined(); + expect(storedNft.animationOriginal).toBeUndefined(); + expect(storedNft.externalLink).toBeUndefined(); + expect( + (storedNft.collection as Collection & { externalLink?: string }) + ?.externalLink, + ).toBeUndefined(); - await nftController.checkAndUpdateSingleNftOwnershipStatus( - nft, - false, - ); + // Verify non-URL fields were preserved + expect(storedNft.name).toBe('NFT with all URL fields'); + expect(storedNft.description).toBe('Testing all URL fields'); + expect(storedNft.collection?.id).toBe('collection-1'); + expect(storedNft.collection?.name).toBe('Test Collection'); + }); - expect( - nftController.state.allNfts[selectedAddress][chainId][0] - .isCurrentlyOwned, - ).toBe(false); - }); + it('should handle mixed safe and malicious URLs correctly', async () => { + const mockBulkScanUrls = jest.fn().mockResolvedValue({ + results: { + 'http://safe-image.com/image.png': { + recommendedAction: RecommendedAction.None, + }, + 'http://malicious-preview.com/preview.png': { + recommendedAction: RecommendedAction.Block, + }, + 'http://safe-external.com': { + recommendedAction: RecommendedAction.None, + }, + }, }); - it('should check whether the passed NFT is still owned by the the current selectedAddress/chainId combination and return the updated NFT object without updating state if batch is true', async () => { - const { nftController } = setupController(); - const { selectedAddress, chainId } = nftController.config; - const nft = { - address: '0x02', - tokenId: '1', - name: 'name', - image: 'image', - description: 'description', - standard: 'standard', - favorite: false, - }; + const { nftController } = setupController({ + bulkScanUrlsMock: mockBulkScanUrls, + }); - await nftController.addNft(nft.address, nft.tokenId, { - nftMetadata: nft, - }); + const nftWithMixedURLs: NftMetadata = { + name: 'Mixed URLs NFT', + description: 'Some safe, some malicious', + image: 'http://safe-image.com/image.png', + imagePreview: 'http://malicious-preview.com/preview.png', + externalLink: 'http://safe-external.com', + standard: ERC721, + }; - expect( - nftController.state.allNfts[selectedAddress][chainId][0] - .isCurrentlyOwned, - ).toBe(true); + await nftController.addNft('0xmixed', '1', 'mainnet', { + nftMetadata: nftWithMixedURLs, + userAddress: OWNER_ADDRESS, + }); - sinon.stub(nftController, 'isNftOwner' as any).returns(false); + const storedNft = + nftController.state.allNfts[OWNER_ADDRESS][ChainId.mainnet][0]; - const updatedNft = - await nftController.checkAndUpdateSingleNftOwnershipStatus(nft, true); + // Verify only malicious URLs were removed + expect(storedNft.image).toBe('http://safe-image.com/image.png'); + expect(storedNft.imagePreview).toBeUndefined(); + expect(storedNft.externalLink).toBe('http://safe-external.com'); + }); - expect( - nftController.state.allNfts[selectedAddress][chainId][0] - .isCurrentlyOwned, - ).toBe(true); + it('should handle non-http URLs and edge cases', async () => { + const mockBulkScanUrls = jest.fn().mockResolvedValue({ results: {} }); - expect(updatedNft.isCurrentlyOwned).toBe(false); + const { nftController } = setupController({ + bulkScanUrlsMock: mockBulkScanUrls, }); - it('should check whether the passed NFT is still owned by the the selectedAddress/chainId combination passed in the accountParams argument and update its isCurrentlyOwned property in state, when the currently configured selectedAddress/chainId are different from those passed', async () => { - const { nftController, changeNetwork, preferences } = setupController(); + const nftWithEdgeCases: NftMetadata = { + name: 'Edge case NFT', + description: 'Testing edge cases', + image: 'ipfs://QmTest123', // IPFS URL - should not be scanned + imagePreview: '', // Empty string + externalLink: 'https://secure-site.com', // HTTPS URL + standard: ERC721, + }; - preferences.update({ selectedAddress: OWNER_ADDRESS }); - changeNetwork(SEPOLIA); + await nftController.addNft('0xedge', '1', 'mainnet', { + nftMetadata: nftWithEdgeCases, + userAddress: OWNER_ADDRESS, + }); - const { selectedAddress, chainId } = nftController.config; - const nft = { - address: '0x02', - tokenId: '1', - name: 'name', - image: 'image', - description: 'description', - standard: 'standard', - favorite: false, - }; + // Verify only HTTP(S) URLs were sent for scanning + expect(mockBulkScanUrls).toHaveBeenCalledWith([ + 'https://secure-site.com', + ]); - await nftController.addNft(nft.address, nft.tokenId, { - nftMetadata: nft, - }); + const storedNft = + nftController.state.allNfts[OWNER_ADDRESS][ChainId.mainnet][0]; - expect( - nftController.state.allNfts[selectedAddress][chainId][0] - .isCurrentlyOwned, - ).toBe(true); + // Verify all fields are preserved as-is + expect(storedNft.image).toBe('ipfs://QmTest123'); + expect(storedNft.imagePreview).toBe(''); + expect(storedNft.externalLink).toBe('https://secure-site.com'); + }); - sinon.stub(nftController, 'isNftOwner' as any).returns(false); + it('should handle bulk sanitization with multiple NFTs efficiently', async () => { + let scanCallCount = 0; + const mockBulkScanUrls = jest.fn().mockImplementation(() => { + scanCallCount += 1; + return Promise.resolve({ + results: { + 'http://image-0.com/image.png': { + recommendedAction: RecommendedAction.None, + }, + 'http://external-0.com': { + recommendedAction: RecommendedAction.None, + }, + 'http://image-1.com/image.png': { + recommendedAction: RecommendedAction.None, + }, + 'http://external-1.com': { + recommendedAction: RecommendedAction.None, + }, + 'http://image-2.com/image.png': { + recommendedAction: RecommendedAction.None, + }, + 'http://external-2.com': { + recommendedAction: RecommendedAction.None, + }, + 'http://image-3.com/image.png': { + recommendedAction: RecommendedAction.None, + }, + 'http://external-3.com': { + recommendedAction: RecommendedAction.None, + }, + 'http://image-4.com/image.png': { + recommendedAction: RecommendedAction.None, + }, + 'http://external-4.com': { + recommendedAction: RecommendedAction.None, + }, + }, + }); + }); - preferences.update({ selectedAddress: SECOND_OWNER_ADDRESS }); - changeNetwork(GOERLI); + const { nftController } = setupController({ + bulkScanUrlsMock: mockBulkScanUrls, + }); - await nftController.checkAndUpdateSingleNftOwnershipStatus(nft, false, { + // Add multiple NFTs in sequence + const nftCount = 5; + for (let i = 0; i < nftCount; i++) { + await nftController.addNft(`0x0${i}`, `${i}`, 'mainnet', { + nftMetadata: { + name: `NFT ${i}`, + description: `Description ${i}`, + image: `http://image-${i}.com/image.png`, + externalLink: `http://external-${i}.com`, + standard: ERC721, + }, userAddress: OWNER_ADDRESS, - chainId: SEPOLIA.chainId, }); + } - expect( - nftController.state.allNfts[OWNER_ADDRESS][SEPOLIA.chainId][0] - .isCurrentlyOwned, - ).toBe(false); - }); + // Verify bulk scan was called once per NFT (not batched in this flow) + expect(scanCallCount).toBe(nftCount); + + // Verify all NFTs were added successfully + const storedNfts = + nftController.state.allNfts[OWNER_ADDRESS][ChainId.mainnet]; + expect(storedNfts).toHaveLength(nftCount); }); - }); - describe('findNftByAddressAndTokenId', () => { - const mockNft = { - address: '0x02', - tokenId: '1', - name: 'name', - image: 'image', - description: 'description', - standard: 'standard', - favorite: false, - }; - const { nftController } = setupController(); - const { selectedAddress, chainId } = nftController.config; + it('should not call phishing detection when no HTTP URLs are present', async () => { + const mockBulkScanUrls = jest.fn(); - it('should return null if the NFT does not exist in the state', async () => { - expect( - nftController.findNftByAddressAndTokenId( - mockNft.address, - mockNft.tokenId, - selectedAddress, - chainId, - ), - ).toBeNull(); - }); + const { nftController } = setupController({ + bulkScanUrlsMock: mockBulkScanUrls, + }); - it('should return the NFT by the address and tokenId', () => { - nftController.state.allNfts = { - [selectedAddress]: { [chainId]: [mockNft] }, + const nftWithoutHttpUrls: NftMetadata = { + name: 'No HTTP URLs', + description: 'This NFT has no HTTP URLs', + image: 'ipfs://QmTest123', + standard: ERC721, }; - expect( - nftController.findNftByAddressAndTokenId( - mockNft.address, - mockNft.tokenId, - selectedAddress, - chainId, - ), - ).toStrictEqual({ nft: mockNft, index: 0 }); - }); - }); + await nftController.addNft('0xnohttp', '1', 'mainnet', { + nftMetadata: nftWithoutHttpUrls, + userAddress: OWNER_ADDRESS, + }); - describe('updateNftByAddressAndTokenId', () => { - const { nftController } = setupController(); + // Verify phishing detection was not called + expect(mockBulkScanUrls).not.toHaveBeenCalled(); - const mockTransactionId = '60d36710-b150-11ec-8a49-c377fbd05e27'; - const mockNft = { - address: '0x02', - tokenId: '1', - name: 'name', - image: 'image', - description: 'description', - standard: 'standard', - favorite: false, - }; + const storedNft = + nftController.state.allNfts[OWNER_ADDRESS][ChainId.mainnet][0]; + expect(storedNft.image).toBe('ipfs://QmTest123'); + }); - const expectedMockNft = { - address: '0x02', - description: 'description', - favorite: false, - image: 'image', - name: 'name', - standard: 'standard', - tokenId: '1', - transactionId: mockTransactionId, - }; + it('should handle collection without externalLink field', async () => { + const mockBulkScanUrls = jest.fn().mockResolvedValue({ results: {} }); - const { selectedAddress, chainId } = nftController.config; + const { nftController } = setupController({ + bulkScanUrlsMock: mockBulkScanUrls, + }); - it('should update the NFT if the NFT exist', async () => { - nftController.state.allNfts = { - [selectedAddress]: { [chainId]: [mockNft] }, + const nftWithCollectionNoLink: NftMetadata = { + name: 'NFT with collection', + description: 'Collection without external link', + image: 'http://image.com/image.png', + standard: ERC721, + collection: { + id: 'collection-1', + name: 'Test Collection', + // No externalLink field + }, }; - nftController.updateNft( - mockNft, - { - transactionId: mockTransactionId, - }, - selectedAddress, - chainId, - ); + await nftController.addNft('0xcollection', '1', 'mainnet', { + nftMetadata: nftWithCollectionNoLink, + userAddress: OWNER_ADDRESS, + }); - expect( - nftController.state.allNfts[selectedAddress][chainId][0], - ).toStrictEqual(expectedMockNft); + // Should not throw error + expect(mockBulkScanUrls).toHaveBeenCalledWith([ + 'http://image.com/image.png', + ]); }); + }); + + describe('metadata', () => { + it('includes expected state in debug snapshots', () => { + const { nftController: controller } = setupController(); - it('should return undefined if the NFT does not exist', () => { expect( - nftController.updateNft( - mockNft, - { - transactionId: mockTransactionId, - }, - selectedAddress, - chainId, + deriveStateFromMetadata( + controller.state, + controller.metadata, + 'includeInDebugSnapshot', ), - ).toBeUndefined(); + ).toMatchInlineSnapshot(`{}`); }); - }); - - describe('resetNftTransactionStatusByTransactionId', () => { - const { nftController } = setupController(); - - const mockTransactionId = '60d36710-b150-11ec-8a49-c377fbd05e27'; - const nonExistTransactionId = '0123'; - - const mockNft = { - address: '0x02', - tokenId: '1', - name: 'name', - image: 'image', - description: 'description', - standard: 'standard', - favorite: false, - transactionId: mockTransactionId, - }; - const { selectedAddress, chainId } = nftController.config; + it('includes expected state in state logs', () => { + const { nftController: controller } = setupController(); - it('should not update any NFT state and should return false when passed a transaction id that does not match that of any NFT', async () => { expect( - nftController.resetNftTransactionStatusByTransactionId( - nonExistTransactionId, - selectedAddress, - chainId, + deriveStateFromMetadata( + controller.state, + controller.metadata, + 'includeInStateLogs', ), - ).toBe(false); + ).toMatchInlineSnapshot(`{}`); }); - it('should set the transaction id of an NFT in state to undefined, and return true when it has successfully updated this state', async () => { - nftController.state.allNfts = { - [selectedAddress]: { [chainId]: [mockNft] }, - }; - - expect( - nftController.state.allNfts[selectedAddress][chainId][0].transactionId, - ).toBe(mockTransactionId); + it('persists expected state', () => { + const { nftController: controller } = setupController(); expect( - nftController.resetNftTransactionStatusByTransactionId( - mockTransactionId, - selectedAddress, - chainId, + deriveStateFromMetadata( + controller.state, + controller.metadata, + 'persist', ), - ).toBe(true); + ).toMatchInlineSnapshot(` + { + "allNftContracts": {}, + "allNfts": {}, + "ignoredNfts": [], + } + `); + }); + + it('exposes expected state to UI', () => { + const { nftController: controller } = setupController(); expect( - nftController.state.allNfts[selectedAddress][chainId][0].transactionId, - ).toBeUndefined(); + deriveStateFromMetadata( + controller.state, + controller.metadata, + 'usedInUi', + ), + ).toMatchInlineSnapshot(` + { + "allNftContracts": {}, + "allNfts": {}, + } + `); }); }); }); diff --git a/packages/assets-controllers/src/NftController.ts b/packages/assets-controllers/src/NftController.ts index c61fa35d850..fd516e70e5c 100644 --- a/packages/assets-controllers/src/NftController.ts +++ b/packages/assets-controllers/src/NftController.ts @@ -1,11 +1,17 @@ import { isAddress } from '@ethersproject/address'; -import type { AddApprovalRequest } from '@metamask/approval-controller'; +import { Web3Provider } from '@ethersproject/providers'; import type { - BaseConfig, - BaseState, - RestrictedControllerMessenger, -} from '@metamask/base-controller'; + AccountsControllerSelectedEvmAccountChangeEvent, + AccountsControllerGetAccountAction, + AccountsControllerGetSelectedAccountAction, +} from '@metamask/accounts-controller'; +import type { ApprovalControllerAddRequestAction } from '@metamask/approval-controller'; import { BaseController } from '@metamask/base-controller'; +import type { + ControllerStateChangeEvent, + ControllerGetStateAction, + StateMetadata, +} from '@metamask/base-controller'; import { safelyExecute, handleFetch, @@ -15,33 +21,54 @@ import { IPFS_DEFAULT_GATEWAY_URL, ERC721, ERC1155, - OPENSEA_PROXY_URL, ApprovalType, + NFT_API_BASE_URL, + NFT_API_VERSION, + convertHexToDecimal, + toHex, } from '@metamask/controller-utils'; +import type { InternalAccount } from '@metamask/keyring-internal-api'; +import type { Messenger } from '@metamask/messenger'; import type { NetworkClientId, - NetworkController, - NetworkState, + NetworkControllerFindNetworkClientIdByChainIdAction, + NetworkControllerGetNetworkClientByIdAction, } from '@metamask/network-controller'; -import type { PreferencesState } from '@metamask/preferences-controller'; +import type { PhishingControllerBulkScanUrlsAction } from '@metamask/phishing-controller'; +import { RecommendedAction } from '@metamask/phishing-controller'; +import type { PreferencesControllerStateChangeEvent } from '@metamask/preferences-controller'; import { rpcErrors } from '@metamask/rpc-errors'; import type { Hex } from '@metamask/utils'; +import { remove0x } from '@metamask/utils'; import { Mutex } from 'async-mutex'; -import { BN, stripHexPrefix } from 'ethereumjs-util'; -import { EventEmitter } from 'events'; +import BN from 'bn.js'; import { v4 as random } from 'uuid'; -import type { AssetsContractController } from './AssetsContractController'; -import { compareNftMetadata, getFormattedIpfsUrl } from './assetsUtil'; -import { Source } from './constants'; import type { - ApiNft, - ApiNftCreator, + AssetsContractControllerGetERC1155TokenURIAction, + AssetsContractControllerGetERC721AssetNameAction, + AssetsContractControllerGetERC721AssetSymbolAction, + AssetsContractControllerGetERC721TokenURIAction, +} from './AssetsContractController-method-action-types.js'; +import { + compareNftMetadata, + getFormattedIpfsUrl, + hasNewCollectionFields, + reduceInBatchesSerially, +} from './assetsUtil.js'; +import { Source } from './constants.js'; +import { getNftOwnershipForMultipleNfts } from './multicall.js'; +import type { NftOwnershipResult } from './multicall.js'; +import type { ApiNftContract, - ApiNftLastSale, -} from './NftDetectionController'; + ReservoirResponse, + Collection, + Attributes, + LastSale, + TopBid, +} from './NftDetectionController.js'; -type NFTStandardType = 'ERC721' | 'ERC1155'; +export type NFTStandardType = 'ERC721' | 'ERC1155'; type SuggestedNftMeta = { asset: { address: string; tokenId: string } & NftMetadata; @@ -53,48 +80,73 @@ type SuggestedNftMeta = { }; /** - * @type Nft + * Nft * * NFT representation - * @property address - Hex address of a ERC721 contract - * @property description - The NFT description - * @property image - URI of custom NFT image associated with this tokenId - * @property name - Name associated with this tokenId and contract address - * @property tokenId - The NFT identifier - * @property numberOfSales - Number of sales - * @property backgroundColor - The background color to be displayed with the item - * @property imagePreview - URI of a smaller image associated with this NFT - * @property imageThumbnail - URI of a thumbnail image associated with this NFT - * @property imageOriginal - URI of the original image associated with this NFT - * @property animation - URI of a animation associated with this NFT - * @property animationOriginal - URI of the original animation associated with this NFT - * @property externalLink - External link containing additional information - * @property creator - The NFT owner information object - * @property isCurrentlyOwned - Boolean indicating whether the address/chainId combination where it's currently stored currently owns this NFT - * @property transactionId - Transaction Id associated with the NFT + * + * address - Hex address of a ERC721 contract + * + * description - The NFT description + * + * image - URI of custom NFT image associated with this tokenId + * + * name - Name associated with this tokenId and contract address + * + * tokenId - The NFT identifier + * + * numberOfSales - Number of sales + * + * backgroundColor - The background color to be displayed with the item + * + * imagePreview - URI of a smaller image associated with this NFT + * + * imageThumbnail - URI of a thumbnail image associated with this NFT + * + * imageOriginal - URI of the original image associated with this NFT + * animation - URI of a animation associated with this NFT + * animationOriginal - URI of the original animation associated with this NFT + * externalLink - External link containing additional information + * creator - The NFT owner information object + * isCurrentlyOwned - Boolean indicating whether the address/chainId combination where it's currently stored currently owns this NFT + * transactionId - Transaction Id associated with the NFT */ -export interface Nft extends NftMetadata { +export type Nft = { tokenId: string; address: string; isCurrentlyOwned?: boolean; -} +} & NftMetadata; + +type NftUpdate = { + nft: Nft; + newMetadata: NftMetadata; +}; /** - * @type NftContract + * NftContract * * NFT contract information representation - * @property name - Contract name - * @property logo - Contract logo - * @property address - Contract address - * @property symbol - Contract symbol - * @property description - Contract description - * @property totalSupply - Total supply of NFTs - * @property assetContractType - The NFT type, it could be `semi-fungible` or `non-fungible` - * @property createdDate - Creation date - * @property schemaName - The schema followed by the contract, it could be `ERC721` or `ERC1155` - * @property externalLink - External link containing additional information + * + * name - Contract name + * + * logo - Contract logo + * + * address - Contract address + * + * symbol - Contract symbol + * + * description - Contract description + * + * totalSupply - Total supply of NFTs + * + * assetContractType - The NFT type, it could be `semi-fungible` or `non-fungible` + * + * createdDate - Creation date + * + * schemaName - The schema followed by the contract, it could be `ERC721` or `ERC1155` + * + * externalLink - External link containing additional information */ -export interface NftContract { +export type NftContract = { name?: string; logo?: string; address: string; @@ -105,27 +157,37 @@ export interface NftContract { createdDate?: string; schemaName?: string; externalLink?: string; -} +}; /** - * @type NftMetadata + * NftMetadata * * NFT custom information - * @property name - NFT custom name - * @property description - The NFT description - * @property numberOfSales - Number of sales - * @property backgroundColor - The background color to be displayed with the item - * @property image - Image custom image URI - * @property imagePreview - URI of a smaller image associated with this NFT - * @property imageThumbnail - URI of a thumbnail image associated with this NFT - * @property imageOriginal - URI of the original image associated with this NFT - * @property animation - URI of a animation associated with this NFT - * @property animationOriginal - URI of the original animation associated with this NFT - * @property externalLink - External link containing additional information - * @property creator - The NFT owner information object - * @property standard - NFT standard name for the NFT, e.g., ERC-721 or ERC-1155 + * + * name - NFT custom name + * + * description - The NFT description + * + * numberOfSales - Number of sales + * + * backgroundColor - The background color to be displayed with the item + * + * image - Image custom image URI + * + * imagePreview - URI of a smaller image associated with this NFT + * + * imageThumbnail - URI of a thumbnail image associated with this NFT + * + * imageOriginal - URI of the original image associated with this NFT + * + * animation - URI of a animation associated with this NFT + * + * animationOriginal - URI of the original animation associated with this NFT + * externalLink - External link containing additional information + * creator - The NFT owner information object + * standard - NFT standard name for the NFT, e.g., ERC-721 or ERC-1155 */ -export interface NftMetadata { +export type NftMetadata = { name: string | null; description: string | null; image: string | null; @@ -139,96 +201,305 @@ export interface NftMetadata { animation?: string; animationOriginal?: string; externalLink?: string; - creator?: ApiNftCreator; - lastSale?: ApiNftLastSale; + creator?: string; transactionId?: string; tokenURI?: string | null; -} - -/** - * @type NftConfig - * - * NFT controller configuration - * @property selectedAddress - Vault selected address - */ -export interface NftConfig extends BaseConfig { - selectedAddress: string; - chainId: Hex; - ipfsGateway: string; - openSeaEnabled: boolean; - useIPFSSubdomains: boolean; - isIpfsGatewayEnabled: boolean; -} + collection?: Collection; + address?: string; + attributes?: Attributes[]; + lastSale?: LastSale; + rarityRank?: string; + topBid?: TopBid; + chainId?: number; +}; /** - * @type NftState + * NftControllerState * * NFT controller state - * @property allNftContracts - Object containing NFT contract information - * @property allNfts - Object containing NFTs per account and network - * @property ignoredNfts - List of NFTs that should be ignored + * + * allNftContracts - Object containing NFT contract information + * + * allNfts - Object containing NFTs per account and network + * + * ignoredNfts - List of NFTs that should be ignored */ -export interface NftState extends BaseState { +export type NftControllerState = { allNftContracts: { - [key: string]: { [chainId: Hex]: NftContract[] }; + [key: string]: { + [chainId: Hex]: NftContract[]; + }; + }; + allNfts: { + [key: string]: { + [chainId: Hex]: Nft[]; + }; }; - allNfts: { [key: string]: { [chainId: Hex]: Nft[] } }; ignoredNfts: Nft[]; -} +}; + +const nftControllerMetadata: StateMetadata = { + allNftContracts: { + includeInStateLogs: false, + persist: true, + includeInDebugSnapshot: false, + usedInUi: true, + }, + allNfts: { + includeInStateLogs: false, + persist: true, + includeInDebugSnapshot: false, + usedInUi: true, + }, + ignoredNfts: { + includeInStateLogs: false, + persist: true, + includeInDebugSnapshot: false, + usedInUi: false, + }, +}; const ALL_NFTS_STATE_KEY = 'allNfts'; const ALL_NFTS_CONTRACTS_STATE_KEY = 'allNftContracts'; -interface NftAsset { +type NftAsset = { address: string; tokenId: string; -} +}; + +type NftContractToAdd = { + networkClientId: NetworkClientId; + tokenAddress: string; + nftMetadata: NftMetadata; + source: Source; +}; + +type NftToAdd = { + tokenAddress: string; + tokenId: string; + nftMetadata: NftMetadata; + nftContract: NftContract; + chainId: Hex; + source: Source; +}; /** * The name of the {@link NftController}. */ const controllerName = 'NftController'; +export type NftControllerGetStateAction = ControllerGetStateAction< + typeof controllerName, + NftControllerState +>; +export type NftControllerActions = NftControllerGetStateAction; + /** * The external actions available to the {@link NftController}. */ -type AllowedActions = AddApprovalRequest; +export type AllowedActions = + | ApprovalControllerAddRequestAction + | AccountsControllerGetAccountAction + | AccountsControllerGetSelectedAccountAction + | NetworkControllerGetNetworkClientByIdAction + | AssetsContractControllerGetERC721AssetNameAction + | AssetsContractControllerGetERC721AssetSymbolAction + | AssetsContractControllerGetERC721TokenURIAction + | AssetsContractControllerGetERC1155TokenURIAction + | NetworkControllerFindNetworkClientIdByChainIdAction + | PhishingControllerBulkScanUrlsAction; + +export type AllowedEvents = + | PreferencesControllerStateChangeEvent + | AccountsControllerSelectedEvmAccountChangeEvent; + +export type NftControllerStateChangeEvent = ControllerStateChangeEvent< + typeof controllerName, + NftControllerState +>; + +export type NftControllerEvents = NftControllerStateChangeEvent; /** * The messenger of the {@link NftController}. */ -export type NftControllerMessenger = RestrictedControllerMessenger< +export type NftControllerMessenger = Messenger< typeof controllerName, - AllowedActions, - never, - AllowedActions['type'], - never + NftControllerActions | AllowedActions, + NftControllerEvents | AllowedEvents >; +export const getDefaultNftControllerState = (): NftControllerState => ({ + allNftContracts: {}, + allNfts: {}, + ignoredNfts: [], +}); + +const NFT_UPDATE_THRESHOLD = 500; + /** * Controller that stores assets and exposes convenience methods */ -export class NftController extends BaseController { - private readonly mutex = new Mutex(); +export class NftController extends BaseController< + typeof controllerName, + NftControllerState, + NftControllerMessenger +> { + readonly #mutex = new Mutex(); - private readonly messagingSystem: NftControllerMessenger; + #selectedAccountId: string; - private getNftApi({ - contractAddress, - tokenId, - }: { - contractAddress: string; + #ipfsGateway: string; + + #displayNftMedia: boolean; + + readonly #useIpfsSubdomains: boolean; + + #isIpfsGatewayEnabled: boolean; + + readonly #onNftAdded?: (data: { + address: string; + symbol: string | undefined; tokenId: string; + standard: string | null; + source: Source; + }) => void; + + /** + * Creates an NftController instance. + * + * @param options - The controller options. + * @param options.ipfsGateway - The configured IPFS gateway. + * @param options.displayNftMedia - Controls whether the NFT API is used. + * @param options.useIpfsSubdomains - Controls whether IPFS subdomains are used. + * @param options.isIpfsGatewayEnabled - Controls whether IPFS is enabled or not. + * @param options.onNftAdded - Callback that is called when an NFT is added. Currently used pass data + * for tracking the NFT added event. + * @param options.messenger - The messenger. + * @param options.state - Initial state to set on this controller. + */ + constructor({ + ipfsGateway = IPFS_DEFAULT_GATEWAY_URL, + displayNftMedia = false, + useIpfsSubdomains = true, + isIpfsGatewayEnabled = true, + onNftAdded, + messenger, + state = {}, + }: { + ipfsGateway?: string; + displayNftMedia?: boolean; + useIpfsSubdomains?: boolean; + isIpfsGatewayEnabled?: boolean; + onNftAdded?: (data: { + address: string; + symbol: string | undefined; + tokenId: string; + standard: string | null; + source: string; + }) => void; + messenger: NftControllerMessenger; + state?: Partial; }) { - return `${OPENSEA_PROXY_URL}/asset/${contractAddress}/${tokenId}`; + super({ + name: controllerName, + metadata: nftControllerMetadata, + messenger, + state: { + ...getDefaultNftControllerState(), + ...state, + }, + }); + + this.#selectedAccountId = this.messenger.call( + 'AccountsController:getSelectedAccount', + ).id; + this.#ipfsGateway = ipfsGateway; + this.#displayNftMedia = displayNftMedia; + this.#useIpfsSubdomains = useIpfsSubdomains; + this.#isIpfsGatewayEnabled = isIpfsGatewayEnabled; + this.#onNftAdded = onNftAdded; + + this.messenger.subscribe( + 'PreferencesController:stateChange', + this.#onPreferencesControllerStateChange.bind(this), + ); + + this.messenger.subscribe( + 'AccountsController:selectedEvmAccountChange', + this.#onSelectedAccountChange.bind(this), + ); } - private getNftContractInformationApi({ - contractAddress, + /** + * Handles the state change of the preference controller. + * + * @param preferencesState - The new state of the preference controller. + * @param preferencesState.ipfsGateway - The configured IPFS gateway. + * @param preferencesState.isIpfsGatewayEnabled - Controls whether IPFS is enabled or not. + * @param preferencesState.displayNftMedia - Controls whether the NFT API is used (mobile). + * @param preferencesState.openSeaEnabled - Controls whether the NFT API is used (extension). + */ + async #onPreferencesControllerStateChange({ + ipfsGateway, + isIpfsGatewayEnabled, + displayNftMedia, + openSeaEnabled, }: { - contractAddress: string; - }) { - return `${OPENSEA_PROXY_URL}/asset_contract/${contractAddress}`; + ipfsGateway: string; + isIpfsGatewayEnabled: boolean; + // TODO: Mobile PreferencesController uses displayNftMedia, Extension PreferencesController uses openSeaEnabled + // TODO: Replace this type with PreferencesState once both clients use the same PreferencesController + displayNftMedia?: boolean; + openSeaEnabled?: boolean; + }): Promise { + const selectedAccount = this.messenger.call( + 'AccountsController:getSelectedAccount', + ); + this.#selectedAccountId = selectedAccount.id; + + const newDisplayNftMedia = Boolean(displayNftMedia || openSeaEnabled); + + // Get current state values + if ( + this.#ipfsGateway !== ipfsGateway || + this.#displayNftMedia !== newDisplayNftMedia || + this.#isIpfsGatewayEnabled !== isIpfsGatewayEnabled + ) { + this.#ipfsGateway = ipfsGateway; + this.#displayNftMedia = newDisplayNftMedia; + this.#isIpfsGatewayEnabled = isIpfsGatewayEnabled; + const needsUpdateNftMetadata = + (isIpfsGatewayEnabled && ipfsGateway !== '') || newDisplayNftMedia; + if (needsUpdateNftMetadata && selectedAccount) { + await this.#updateNftUpdateForAccount(selectedAccount); + } + } + } + + /** + * Handles the selected account change on the accounts controller. + * + * @param internalAccount - The new selected account. + */ + async #onSelectedAccountChange( + internalAccount: InternalAccount, + ): Promise { + const oldSelectedAccountId = this.#selectedAccountId; + this.#selectedAccountId = internalAccount.id; + + const needsUpdateNftMetadata = + ((this.#isIpfsGatewayEnabled && this.#ipfsGateway !== '') || + this.#displayNftMedia) && + oldSelectedAccountId !== internalAccount.id; + + if (needsUpdateNftMetadata) { + await this.#updateNftUpdateForAccount(internalAccount); + } + } + + getNftApi(): string { + return `${NFT_API_BASE_URL}/tokens`; } /** @@ -240,53 +511,70 @@ export class NftController extends BaseController { * @param passedConfig.userAddress - the address passed through the NFT detection flow to ensure assets are stored to the correct account * @param passedConfig.chainId - the chainId passed through the NFT detection flow to ensure assets are stored to the correct account */ - private updateNestedNftState( - newCollection: Nft[] | NftContract[], - baseStateKey: 'allNfts' | 'allNftContracts', - { userAddress, chainId } = { - userAddress: this.config.selectedAddress, - chainId: this.config.chainId, - }, - ) { - const { [baseStateKey]: oldState } = this.state; - - const addressState = oldState[userAddress]; - const newAddressState = { - ...addressState, - ...{ [chainId]: newCollection }, - }; - const newState = { - ...oldState, - ...{ [userAddress]: newAddressState }, - }; + #updateNestedNftState< + Key extends typeof ALL_NFTS_STATE_KEY | typeof ALL_NFTS_CONTRACTS_STATE_KEY, + NftCollection extends Key extends typeof ALL_NFTS_STATE_KEY + ? Nft[] + : NftContract[], + >( + newCollection: NftCollection, + baseStateKey: Key, + { userAddress, chainId }: { userAddress: string; chainId: Hex }, + ): void { + // userAddress can be an empty string if it is not set via an account change or in constructor + // while this doesn't cause any issues, we want to ensure that we don't store assets to an empty string address + if (!userAddress) { + return; + } - this.update({ - [baseStateKey]: newState, + this.update((state) => { + const oldState = state[baseStateKey]; + const addressState = oldState[userAddress] || {}; + const newAddressState = { + ...addressState, + [chainId]: newCollection, + }; + state[baseStateKey] = { + ...oldState, + [userAddress]: newAddressState, + }; }); } /** - * Request individual NFT information from OpenSea API. + * Request individual NFT information from NFT API. * * @param contractAddress - Hex address of the NFT contract. * @param tokenId - The NFT identifier. * @returns Promise resolving to the current NFT name and image. */ - private async getNftInformationFromApi( + async #getNftInformationFromApi( contractAddress: string, tokenId: string, ): Promise { // TODO Parameterize this by chainId for non-mainnet token detection - // Attempt to fetch the data with the proxy - const nftInformation: ApiNft | undefined = await fetchWithErrorHandling({ - url: this.getNftApi({ - contractAddress, - tokenId, - }), - }); + // Attempt to fetch the data with the nft-api + const urlParams = new URLSearchParams({ + chainIds: '1', + tokens: `${contractAddress}:${tokenId}`, + includeTopBid: 'true', + includeAttributes: 'true', + includeLastSale: 'true', + }).toString(); + + // First fetch token information + const nftInformation: ReservoirResponse | undefined = + await fetchWithErrorHandling({ + url: `${this.getNftApi()}?${urlParams}`, + options: { + headers: { + Version: NFT_API_VERSION, + }, + }, + }); // if we were still unable to fetch the data we return out the default/null of `NftMetadata` - if (!nftInformation) { + if (!nftInformation?.tokens?.[0]?.token) { return { name: null, description: null, @@ -297,42 +585,39 @@ export class NftController extends BaseController { // if we've reached this point, we have successfully fetched some data for nftInformation // now we reconfigure the data to conform to the `NftMetadata` type for storage. + const { - num_sales, - background_color, - image_url, - image_preview_url, - image_thumbnail_url, - image_original_url, - animation_url, - animation_original_url, + image, + metadata: { imageOriginal } = {}, name, description, - external_link, - creator, - last_sale, - asset_contract: { schema_name }, - } = nftInformation; + collection, + kind, + rarityRank, + rarity, + attributes, + lastSale, + imageSmall, + } = nftInformation.tokens[0].token; /* istanbul ignore next */ const nftMetadata: NftMetadata = Object.assign( {}, { name: name || null }, { description: description || null }, - { image: image_url || null }, - creator && { creator }, - num_sales && { numberOfSales: num_sales }, - background_color && { backgroundColor: background_color }, - image_preview_url && { imagePreview: image_preview_url }, - image_thumbnail_url && { imageThumbnail: image_thumbnail_url }, - image_original_url && { imageOriginal: image_original_url }, - animation_url && { animation: animation_url }, - animation_original_url && { - animationOriginal: animation_original_url, + { image: image || null }, + collection?.creator && { creator: collection.creator }, + imageOriginal && { imageOriginal }, + imageSmall && { imageThumbnail: imageSmall }, + kind && { standard: kind.toUpperCase() }, + lastSale && { lastSale }, + attributes && { attributes }, + nftInformation.tokens[0].market?.topBid && { + topBid: nftInformation.tokens[0].market?.topBid, }, - external_link && { externalLink: external_link }, - last_sale && { lastSale: last_sale }, - schema_name && { standard: schema_name }, + rarityRank && { rarityRank }, + rarity && { rarity }, + collection && { collection }, ); return nftMetadata; @@ -346,14 +631,12 @@ export class NftController extends BaseController { * @param networkClientId - The networkClientId that can be used to identify the network client to use for this request. * @returns Promise resolving to the current NFT name and image. */ - private async getNftInformationFromTokenURI( + async #getNftInformationFromTokenURI( contractAddress: string, tokenId: string, - networkClientId?: NetworkClientId, + networkClientId: NetworkClientId, ): Promise { - const { ipfsGateway, useIPFSSubdomains, isIpfsGatewayEnabled } = - this.config; - const result = await this.getNftURIAndStandard( + const result = await this.#getNftURIAndStandard( contractAddress, tokenId, networkClientId, @@ -363,7 +646,7 @@ export class NftController extends BaseController { const hasIpfsTokenURI = tokenURI.startsWith('ipfs://'); - if (hasIpfsTokenURI && !isIpfsGatewayEnabled) { + if (hasIpfsTokenURI && !this.#isIpfsGatewayEnabled) { return { image: null, name: null, @@ -374,7 +657,7 @@ export class NftController extends BaseController { }; } - const isDisplayNFTMediaToggleEnabled = this.config.openSeaEnabled; + const isDisplayNFTMediaToggleEnabled = this.#displayNftMedia; if (!hasIpfsTokenURI && !isDisplayNFTMediaToggleEnabled) { return { image: null, @@ -387,7 +670,21 @@ export class NftController extends BaseController { } if (hasIpfsTokenURI) { - tokenURI = getFormattedIpfsUrl(ipfsGateway, tokenURI, useIPFSSubdomains); + tokenURI = await getFormattedIpfsUrl( + this.#ipfsGateway, + tokenURI, + this.#useIpfsSubdomains, + ); + } + if (tokenURI.startsWith('data:image/')) { + return { + image: tokenURI, + name: null, + description: null, + standard: standard || null, + favorite: false, + tokenURI: tokenURI ?? null, + }; } try { @@ -425,14 +722,15 @@ export class NftController extends BaseController { * @param networkClientId - The networkClientId that can be used to identify the network client to use for this request. * @returns Promise resolving NFT uri and token standard. */ - private async getNftURIAndStandard( + async #getNftURIAndStandard( contractAddress: string, tokenId: string, - networkClientId?: NetworkClientId, + networkClientId: NetworkClientId, ): Promise<[string, string]> { // try ERC721 uri try { - const uri = await this.getERC721TokenURI( + const uri = await this.messenger.call( + 'AssetsContractController:getERC721TokenURI', contractAddress, tokenId, networkClientId, @@ -444,7 +742,8 @@ export class NftController extends BaseController { // try ERC1155 uri try { - const tokenURI = await this.getERC1155TokenURI( + const tokenURI = await this.messenger.call( + 'AssetsContractController:getERC1155TokenURI', contractAddress, tokenId, networkClientId, @@ -460,7 +759,7 @@ export class NftController extends BaseController { return [tokenURI, ERC1155]; } - const hexTokenId = stripHexPrefix(BNToHex(new BN(tokenId))) + const hexTokenId = remove0x(BNToHex(new BN(tokenId))) .padStart(64, '0') .toLowerCase(); return [tokenURI.replace('{id}', hexTokenId), ERC1155]; @@ -479,160 +778,80 @@ export class NftController extends BaseController { * @param networkClientId - The networkClientId that can be used to identify the network client to use for this request. * @returns Promise resolving to the current NFT name and image. */ - private async getNftInformation( + async #getNftInformation( contractAddress: string, tokenId: string, - networkClientId?: NetworkClientId, + networkClientId: NetworkClientId, ): Promise { - let { chainId } = this.config; - if (networkClientId) { - chainId = - this.getNetworkClientById(networkClientId).configuration.chainId; - } - - const blockchainMetadata = await safelyExecute(async () => { - return await this.getNftInformationFromTokenURI( - contractAddress, - tokenId, - networkClientId, - ); - }); - - let openSeaMetadata; - // currently we only need to enter this block if we are on mainnet - if (this.config.openSeaEnabled && chainId === '0x1') { - openSeaMetadata = await safelyExecute(async () => { - return await this.getNftInformationFromApi(contractAddress, tokenId); - }); - } - - return { - ...openSeaMetadata, - name: blockchainMetadata.name ?? openSeaMetadata?.name ?? null, - description: - blockchainMetadata.description ?? openSeaMetadata?.description ?? null, - image: blockchainMetadata.image ?? openSeaMetadata?.image ?? null, - standard: - blockchainMetadata.standard ?? openSeaMetadata?.standard ?? null, - tokenURI: blockchainMetadata.tokenURI ?? null, - }; - } - - /** - * Request NFT contract information from OpenSea API. - * - * @param contractAddress - Hex address of the NFT contract. - * @returns Promise resolving to the current NFT name and image. - */ - private async getNftContractInformationFromApi( - contractAddress: string, - ): Promise { - /* istanbul ignore if */ - const apiNftContractObject: ApiNftContract | undefined = - await fetchWithErrorHandling({ - url: this.getNftContractInformationApi({ - contractAddress, - }), - }); - - // if we successfully fetched return the fetched data immediately - if (apiNftContractObject) { - return apiNftContractObject; - } - - // If we've reached this point we were unable to fetch data from either the proxy or opensea so we return - // the default/null of ApiNftContract - return { - address: contractAddress, - asset_contract_type: null, - created_date: null, - schema_name: null, - symbol: null, - total_supply: null, - description: null, - external_link: null, - collection: { - name: null, - image_url: null, - }, - }; - } - - /** - * Request NFT contract information from the contract itself. - * - * @param contractAddress - Hex address of the NFT contract. - * @param networkClientId - The networkClientId that can be used to identify the network client to use for this request. - * @returns Promise resolving to the current NFT name and image. - */ - private async getNftContractInformationFromContract( - contractAddress: string, - networkClientId?: NetworkClientId, - ): Promise< - Partial & - Pick & - Pick - > { - const name = await this.getERC721AssetName( - contractAddress, - networkClientId, - ); - const symbol = await this.getERC721AssetSymbol( - contractAddress, + const { + configuration: { chainId }, + } = this.messenger.call( + 'NetworkController:getNetworkClientById', networkClientId, ); - return { - collection: { name }, - symbol, - address: contractAddress, + const [blockchainMetadata, nftApiMetadata] = await Promise.all([ + safelyExecute(() => + this.#getNftInformationFromTokenURI( + contractAddress, + tokenId, + networkClientId, + ), + ), + this.#displayNftMedia && chainId === '0x1' + ? safelyExecute(() => + this.#getNftInformationFromApi(contractAddress, tokenId), + ) + : undefined, + ]); + const metadata = { + ...nftApiMetadata, + name: blockchainMetadata?.name ?? nftApiMetadata?.name ?? null, + description: + blockchainMetadata?.description ?? nftApiMetadata?.description ?? null, + image: nftApiMetadata?.image ?? blockchainMetadata?.image ?? null, + standard: + blockchainMetadata?.standard ?? nftApiMetadata?.standard ?? null, + tokenURI: blockchainMetadata?.tokenURI ?? null, }; + // Sanitize the metadata by checking external links against phishing protection + return await this.#sanitizeNftMetadata(metadata); } /** - * Request NFT contract information from OpenSea API. + * Builds NFT contract information from metadata already received from the + * NFT API. No on-chain RPC calls are made. * * @param contractAddress - Hex address of the NFT contract. - * @param networkClientId - The networkClientId that can be used to identify the network client to use for this request. - * @returns Promise resolving to the NFT contract name, image and description. + * @param nftMetadataFromApi - NFT information received from the API. + * @returns The aggregated NFT contract information. */ - private async getNftContractInformation( + #getNftContractInformation( contractAddress: string, - networkClientId?: NetworkClientId, - ): Promise< - Partial & - Pick & - Pick - > { - const blockchainContractData: Partial & - Pick & - Pick = await safelyExecute(async () => { - return await this.getNftContractInformationFromContract( - contractAddress, - networkClientId, - ); - }); - - const { chainId } = this.config; - const getCurrentChainId = this.getCorrectChainId({ - chainId, - networkClientId, - }); - - let openSeaContractData: Partial | undefined; - if (this.config.openSeaEnabled && getCurrentChainId === '0x1') { - openSeaContractData = await safelyExecute(async () => { - return await this.getNftContractInformationFromApi(contractAddress); - }); - } + nftMetadataFromApi: NftMetadata, + ): Partial & + Pick & + Pick { + const name = nftMetadataFromApi.collection?.name; + const symbol = nftMetadataFromApi.collection?.symbol; - if (blockchainContractData || openSeaContractData) { + if ( + name !== undefined || + symbol !== undefined || + !Object.values(nftMetadataFromApi).every((value) => value === null) + ) { return { - ...openSeaContractData, - ...blockchainContractData, + address: contractAddress, + schema_name: nftMetadataFromApi?.standard ?? null, + ...(symbol !== undefined && { symbol }), collection: { - image_url: null, - ...openSeaContractData?.collection, - ...blockchainContractData?.collection, + name: null, + image_url: + nftMetadataFromApi?.collection?.image ?? + nftMetadataFromApi?.collection?.imageUrl ?? + null, + tokenCount: nftMetadataFromApi?.collection?.tokenCount ?? null, + ...nftMetadataFromApi?.collection, + ...(name !== undefined && { name }), }, }; } @@ -652,192 +871,256 @@ export class NftController extends BaseController { } /** - * Adds an individual NFT to the stored NFT list. + * Adds multiple NFTs to the stored NFT list for a given user. * - * @param tokenAddress - Hex address of the NFT contract. - * @param tokenId - The NFT identifier. - * @param nftMetadata - NFT optional information (name, image and description). - * @param nftContract - An object containing contract data of the NFT being added. - * @param chainId - The chainId of the network where the NFT is being added. - * @param userAddress - The address of the account where the NFT is being added. - * @param source - Whether the NFT was detected, added manually or suggested by a dapp. - * @returns Promise resolving to the current NFT list. + * @param userAddress - The address of the account where the NFTs are being added. + * @param nfts - Array of NFT objects to add. + * @param nfts[].tokenAddress - Hex address of the NFT contract. + * @param nfts[].tokenId - The NFT identifier. + * @param nfts[].nftMetadata - NFT optional information (name, image and description). + * @param nfts[].nftContract - An object containing contract data of the NFT being added. + * @param nfts[].chainId - The chainId of the network where the NFT is being added. + * @param nfts[].source - Whether the NFT was detected, added manually or suggested by a dapp. */ - private async addIndividualNft( - tokenAddress: string, - tokenId: string, - nftMetadata: NftMetadata, - nftContract: NftContract, - chainId: Hex, - userAddress: string, - source: Source, - ): Promise { - // TODO: Remove unused return - const releaseLock = await this.mutex.acquire(); + async #addMultipleNfts(userAddress: string, nfts: NftToAdd[]): Promise { + const releaseLock = await this.#mutex.acquire(); try { - tokenAddress = toChecksumHexAddress(tokenAddress); const { allNfts } = this.state; + const allNftsForUser = allNfts[userAddress] || {}; + const allNftsForUserPerChain: { + [chainId: `0x${string}`]: Nft[]; + } = {}; + const modifiedChainIds = new Set(); + const pendingCallbacks: { + address: string; + symbol: string | undefined; + tokenId: string; + standard: string | null; + source: Source; + }[] = []; - const nfts = allNfts[userAddress]?.[chainId] || []; - - const existingEntry: Nft | undefined = nfts.find( - (nft) => - nft.address.toLowerCase() === tokenAddress.toLowerCase() && - nft.tokenId === tokenId, - ); + for (const { + tokenAddress, + tokenId, + nftMetadata, + nftContract, + chainId, + source, + } of nfts) { + try { + const checksumHexAddress = toChecksumHexAddress(tokenAddress); + + if (!allNftsForUserPerChain[chainId]) { + allNftsForUserPerChain[chainId] = [ + ...(allNftsForUser?.[chainId] ?? []), + ]; + } - if (existingEntry) { - const differentMetadata = compareNftMetadata( - nftMetadata, - existingEntry, - ); - if (differentMetadata || !existingEntry.isCurrentlyOwned) { - // TODO: Switch to indexToUpdate - const indexToRemove = nfts.findIndex( + const existingEntry = allNftsForUserPerChain[chainId].find( (nft) => - nft.address.toLowerCase() === tokenAddress.toLowerCase() && + nft.address.toLowerCase() === checksumHexAddress.toLowerCase() && nft.tokenId === tokenId, ); - /* istanbul ignore next */ - if (indexToRemove !== -1) { - nfts.splice(indexToRemove, 1); + + if (existingEntry) { + const differentMetadata = compareNftMetadata( + nftMetadata, + existingEntry, + ); + + const hasNewFields = hasNewCollectionFields( + nftMetadata, + existingEntry, + ); + + if ( + !differentMetadata && + existingEntry.isCurrentlyOwned && + !hasNewFields + ) { + continue; + } + + const indexToUpdate = allNftsForUserPerChain[chainId].findIndex( + (nft) => + nft.address.toLowerCase() === + checksumHexAddress.toLowerCase() && nft.tokenId === tokenId, + ); + + if (indexToUpdate !== -1) { + allNftsForUserPerChain[chainId][indexToUpdate] = { + ...existingEntry, + ...nftMetadata, + }; + } + } else { + const newEntry: Nft = { + address: checksumHexAddress, + tokenId, + favorite: false, + isCurrentlyOwned: true, + ...nftMetadata, + }; + + allNftsForUserPerChain[chainId].push(newEntry); + } + + modifiedChainIds.add(chainId); + + if (this.#onNftAdded) { + pendingCallbacks.push({ + address: checksumHexAddress, + symbol: nftContract.symbol, + tokenId: tokenId.toString(), + standard: nftMetadata.standard, + source, + }); } - } else { - return nfts; + } catch (error) { + console.error('Failed to add NFT', tokenAddress, tokenId, error); } } - const newEntry: Nft = { - address: tokenAddress, - tokenId, - favorite: existingEntry?.favorite || false, - isCurrentlyOwned: true, - ...nftMetadata, - }; + for (const chainId of modifiedChainIds) { + this.#updateNestedNftState( + allNftsForUserPerChain[chainId], + ALL_NFTS_STATE_KEY, + { chainId, userAddress }, + ); + } - const newNfts = [...nfts, newEntry]; - this.updateNestedNftState(newNfts, ALL_NFTS_STATE_KEY, { - chainId, - userAddress, - }); - - if (this.onNftAdded) { - this.onNftAdded({ - address: tokenAddress, - symbol: nftContract.symbol, - tokenId: tokenId.toString(), - standard: nftMetadata.standard, - source, - }); + for (const callbackData of pendingCallbacks) { + this.#onNftAdded?.(callbackData); } - - return newNfts; } finally { releaseLock(); } } /** - * Adds an NFT contract to the stored NFT contracts list. + * Adds multiple NFT contracts to the stored NFT contracts list for a given user. * - * @param options - options. - * @param options.tokenAddress - Hex address of the NFT contract. - * @param options.chainId - The chainId of the network where the NFT is being added. - * @param options.userAddress - The address of the account where the NFT is being added. - * @param options.networkClientId - The networkClientId that can be used to identify the network client to use for this request. - * @param options.source - Whether the NFT was detected, added manually or suggested by a dapp. - * @returns Promise resolving to the current NFT contracts list. + * @param userAddress - The address of the account where the NFT contracts are being added. + * @param contracts - Array of contract objects to add. + * @param contracts[].networkClientId - The networkClientId used to identify the network client for the request. + * @param contracts[].tokenAddress - Hex address of the NFT contract. + * @param contracts[].nftMetadata - The retrieved NFT metadata from the API. + * @param contracts[].source - Whether the NFT was detected, added manually or suggested by a dapp. + * @returns Promise resolving to an object mapping chainIds to their updated NFT contract arrays. */ - private async addNftContract({ - tokenAddress, - chainId, - userAddress, - networkClientId, - source, - }: { - tokenAddress: string; - chainId?: Hex; - userAddress?: string; - networkClientId?: NetworkClientId; - source?: Source; - }): Promise { - const releaseLock = await this.mutex.acquire(); + async #addNftContracts( + userAddress: string, + contracts: NftContractToAdd[], + ): Promise<{ contracts: { [chainId: `0x${string}`]: NftContract[] } }> { + const releaseLock = await this.#mutex.acquire(); try { - tokenAddress = toChecksumHexAddress(tokenAddress); const { allNftContracts } = this.state; - const currentChainId = this.getCorrectChainId({ - chainId, + const allNftContractsForUser = allNftContracts[userAddress] || {}; + const nftContractsForUserPerChain: { + [chainId: `0x${string}`]: NftContract[]; + } = {}; + const modifiedChainIds = new Set(); + + for (const { networkClientId, - }); - const selectedAddress = userAddress ?? this.config.selectedAddress; + tokenAddress, + source, + nftMetadata, + } of contracts) { + try { + const checksumHexAddress = toChecksumHexAddress(tokenAddress); + const { + configuration: { chainId }, + } = this.messenger.call( + 'NetworkController:getNetworkClientById', + networkClientId, + ); - const nftContracts = - allNftContracts[selectedAddress]?.[currentChainId] || []; + // Initialised before the existingEntry check so pre-existing contracts + // are still present in the returned map for callers to look up. + if (!nftContractsForUserPerChain[chainId]) { + nftContractsForUserPerChain[chainId] = [ + ...(allNftContractsForUser?.[chainId] ?? []), + ]; + } - const existingEntry = nftContracts.find( - (nftContract) => - nftContract.address.toLowerCase() === tokenAddress.toLowerCase(), - ); - if (existingEntry) { - return nftContracts; - } + const existingEntry = nftContractsForUserPerChain[chainId].find( + (nftContract) => + nftContract.address.toLowerCase() === + checksumHexAddress.toLowerCase(), + ); - // this doesn't work currently for detection if the user switches networks while the detection is processing - // will be fixed once detection uses networkClientIds - const contractInformation = await this.getNftContractInformation( - tokenAddress, - networkClientId, - ); - const { - asset_contract_type, - created_date, - schema_name, - symbol, - total_supply, - description, - external_link, - collection: { name, image_url }, - } = contractInformation; - - // If the nft is auto-detected we want some valid metadata to be present - if ( - source === Source.Detected && - Object.entries(contractInformation).every(([k, v]: [string, any]) => { - if (k === 'address') { - return true; // address will always be present + if (existingEntry) { + continue; } - // collection will always be an object, we need to check the internal values - if (k === 'collection') { - return v?.name === null && v?.image_url === null; + + // this doesn't work currently for detection if the user switches networks while the detection is processing + // will be fixed once detection uses networkClientIds + // get name and symbol if ERC721 then put together the metadata + const contractInformation = this.#getNftContractInformation( + checksumHexAddress, + nftMetadata, + ); + + // If the nft is auto-detected we want some valid metadata to be present + if ( + source === Source.Detected && + 'address' in contractInformation && + typeof contractInformation.address === 'string' && + 'collection' in contractInformation && + contractInformation.collection.name === null && + 'image_url' in contractInformation.collection && + contractInformation.collection.image_url === null && + Object.entries(contractInformation).every(([key, value]) => { + return key === 'address' || key === 'collection' || !value; + }) + ) { + continue; } - return !v; - }) - ) { - return nftContracts; + + const { + asset_contract_type, + created_date, + symbol, + description, + external_link, + schema_name, + collection: { name, image_url, tokenCount }, + } = contractInformation; + + /* istanbul ignore next */ + const newEntry: NftContract = Object.assign( + {}, + { address: checksumHexAddress }, + description && { description }, + name && { name }, + image_url && { logo: image_url }, + symbol && { symbol }, + tokenCount !== null && + typeof tokenCount !== 'undefined' && { totalSupply: tokenCount }, + asset_contract_type && { assetContractType: asset_contract_type }, + created_date && { createdDate: created_date }, + schema_name && { schemaName: schema_name }, + external_link && { externalLink: external_link }, + ); + + nftContractsForUserPerChain[chainId].push(newEntry); + modifiedChainIds.add(chainId); + } catch (error) { + console.error('Failed to add NFT contract', tokenAddress, error); + } } - /* istanbul ignore next */ - const newEntry: NftContract = Object.assign( - {}, - { address: tokenAddress }, - description && { description }, - name && { name }, - image_url && { logo: image_url }, - symbol && { symbol }, - total_supply !== null && - typeof total_supply !== 'undefined' && { totalSupply: total_supply }, - asset_contract_type && { assetContractType: asset_contract_type }, - created_date && { createdDate: created_date }, - schema_name && { schemaName: schema_name }, - external_link && { externalLink: external_link }, - ); - const newNftContracts = [...nftContracts, newEntry]; - this.updateNestedNftState(newNftContracts, ALL_NFTS_CONTRACTS_STATE_KEY, { - chainId: currentChainId, - userAddress: selectedAddress, - }); + // Loops once per chain (not once per NFT contract) + for (const chainId of modifiedChainIds) { + this.#updateNestedNftState( + nftContractsForUserPerChain[chainId], + ALL_NFTS_CONTRACTS_STATE_KEY, + { chainId, userAddress }, + ); + } - return newNftContracts; + return { contracts: nftContractsForUserPerChain }; } finally { releaseLock(); } @@ -848,20 +1131,32 @@ export class NftController extends BaseController { * * @param address - Hex address of the NFT contract. * @param tokenId - Token identifier of the NFT. + * @param options - options. + * @param options.chainId - The chainId of the network where the NFT is being removed. + * @param options.userAddress - The address of the account where the NFT is being removed. */ - private removeAndIgnoreIndividualNft(address: string, tokenId: string) { - address = toChecksumHexAddress(address); + #removeAndIgnoreIndividualNft( + address: string, + tokenId: string, + { + chainId, + userAddress, + }: { + chainId: Hex; + userAddress: string; + }, + ): void { + const checksumHexAddress = toChecksumHexAddress(address); const { allNfts, ignoredNfts } = this.state; - const { chainId, selectedAddress } = this.config; const newIgnoredNfts = [...ignoredNfts]; - const nfts = allNfts[selectedAddress]?.[chainId] || []; + const nfts = allNfts[userAddress]?.[chainId] || []; const newNfts = nfts.filter((nft) => { if ( - nft.address.toLowerCase() === address.toLowerCase() && + nft.address.toLowerCase() === checksumHexAddress.toLowerCase() && nft.tokenId === tokenId ) { const alreadyIgnored = newIgnoredNfts.find( - (c) => c.address === address && c.tokenId === tokenId, + (c) => c.address === checksumHexAddress && c.tokenId === tokenId, ); !alreadyIgnored && newIgnoredNfts.push(nft); return false; @@ -869,10 +1164,13 @@ export class NftController extends BaseController { return true; }); - this.updateNestedNftState(newNfts, ALL_NFTS_STATE_KEY); + this.#updateNestedNftState(newNfts, ALL_NFTS_STATE_KEY, { + userAddress, + chainId, + }); - this.update({ - ignoredNfts: newIgnoredNfts, + this.update((state) => { + state.ignoredNfts = newIgnoredNfts; }); } @@ -881,194 +1179,68 @@ export class NftController extends BaseController { * * @param address - Hex address of the NFT contract. * @param tokenId - Token identifier of the NFT. + * @param options - options. + * @param options.chainId - The chainId of the network where the NFT is being removed. + * @param options.userAddress - The address of the account where the NFT is being removed. */ - private removeIndividualNft(address: string, tokenId: string) { - address = toChecksumHexAddress(address); + #removeIndividualNft( + address: string, + tokenId: string, + { chainId, userAddress }: { chainId: Hex; userAddress: string }, + ): void { + const checksumHexAddress = toChecksumHexAddress(address); const { allNfts } = this.state; - const { chainId, selectedAddress } = this.config; - const nfts = allNfts[selectedAddress]?.[chainId] || []; + const nfts = allNfts[userAddress]?.[chainId] || []; const newNfts = nfts.filter( (nft) => !( - nft.address.toLowerCase() === address.toLowerCase() && + nft.address.toLowerCase() === checksumHexAddress.toLowerCase() && nft.tokenId === tokenId ), ); - this.updateNestedNftState(newNfts, ALL_NFTS_STATE_KEY); + this.#updateNestedNftState(newNfts, ALL_NFTS_STATE_KEY, { + userAddress, + chainId, + }); } /** * Removes an NFT contract to the stored NFT contracts list. * * @param address - Hex address of the NFT contract. + * @param options - options. + * @param options.chainId - The chainId of the network where the NFT is being removed. + * @param options.userAddress - The address of the account where the NFT is being removed. * @returns Promise resolving to the current NFT contracts list. */ - private removeNftContract(address: string): NftContract[] { - address = toChecksumHexAddress(address); + #removeNftContract( + address: string, + { chainId, userAddress }: { chainId: Hex; userAddress: string }, + ): NftContract[] { + const checksumHexAddress = toChecksumHexAddress(address); const { allNftContracts } = this.state; - const { chainId, selectedAddress } = this.config; - const nftContracts = allNftContracts[selectedAddress]?.[chainId] || []; + const nftContracts = allNftContracts[userAddress]?.[chainId] || []; const newNftContracts = nftContracts.filter( (nftContract) => - !(nftContract.address.toLowerCase() === address.toLowerCase()), + !( + nftContract.address.toLowerCase() === checksumHexAddress.toLowerCase() + ), ); - this.updateNestedNftState(newNftContracts, ALL_NFTS_CONTRACTS_STATE_KEY); + this.#updateNestedNftState(newNftContracts, ALL_NFTS_CONTRACTS_STATE_KEY, { + chainId, + userAddress, + }); return newNftContracts; } - /** - * EventEmitter instance used to listen to specific EIP747 events - */ - hub = new EventEmitter(); - - /** - * Optional API key to use with opensea - */ - openSeaApiKey?: string; - - /** - * Name of this controller used during composition - */ - override name = 'NftController'; - - private readonly getERC721AssetName: AssetsContractController['getERC721AssetName']; - - private readonly getERC721AssetSymbol: AssetsContractController['getERC721AssetSymbol']; - - private readonly getERC721TokenURI: AssetsContractController['getERC721TokenURI']; - - private readonly getERC721OwnerOf: AssetsContractController['getERC721OwnerOf']; - - private readonly getERC1155BalanceOf: AssetsContractController['getERC1155BalanceOf']; - - private readonly getERC1155TokenURI: AssetsContractController['getERC1155TokenURI']; - - private readonly getNetworkClientById: NetworkController['getNetworkClientById']; - - private readonly onNftAdded?: (data: { - address: string; - symbol: string | undefined; - tokenId: string; - standard: string | null; - source: Source; - }) => void; - - /** - * Creates an NftController instance. - * - * @param options - The controller options. - * @param options.chainId - The chain ID of the current network. - * @param options.onPreferencesStateChange - Allows subscribing to preference controller state changes. - * @param options.onNetworkStateChange - Allows subscribing to network controller state changes. - * @param options.getERC721AssetName - Gets the name of the asset at the given address. - * @param options.getERC721AssetSymbol - Gets the symbol of the asset at the given address. - * @param options.getERC721TokenURI - Gets the URI of the ERC721 token at the given address, with the given ID. - * @param options.getERC721OwnerOf - Get the owner of a ERC-721 NFT. - * @param options.getERC1155BalanceOf - Gets balance of a ERC-1155 NFT. - * @param options.getERC1155TokenURI - Gets the URI of the ERC1155 token at the given address, with the given ID. - * @param options.getNetworkClientById - Gets the network client for the given networkClientId. - * @param options.onNftAdded - Callback that is called when an NFT is added. Currently used pass data - * for tracking the NFT added event. - * @param options.messenger - The controller messenger. - * @param config - Initial options used to configure this controller. - * @param state - Initial state to set on this controller. - */ - constructor( - { - chainId: initialChainId, - onPreferencesStateChange, - onNetworkStateChange, - getERC721AssetName, - getERC721AssetSymbol, - getERC721TokenURI, - getERC721OwnerOf, - getERC1155BalanceOf, - getERC1155TokenURI, - getNetworkClientById, - onNftAdded, - messenger, - }: { - chainId: Hex; - onPreferencesStateChange: ( - listener: (preferencesState: PreferencesState) => void, - ) => void; - onNetworkStateChange: ( - listener: (networkState: NetworkState) => void, - ) => void; - getERC721AssetName: AssetsContractController['getERC721AssetName']; - getERC721AssetSymbol: AssetsContractController['getERC721AssetSymbol']; - getERC721TokenURI: AssetsContractController['getERC721TokenURI']; - getERC721OwnerOf: AssetsContractController['getERC721OwnerOf']; - getERC1155BalanceOf: AssetsContractController['getERC1155BalanceOf']; - getERC1155TokenURI: AssetsContractController['getERC1155TokenURI']; - getNetworkClientById: NetworkController['getNetworkClientById']; - onNftAdded?: (data: { - address: string; - symbol: string | undefined; - tokenId: string; - standard: string | null; - source: string; - }) => void; - messenger: NftControllerMessenger; - }, - config?: Partial, - state?: Partial, - ) { - super(config, state); - this.defaultConfig = { - selectedAddress: '', - chainId: initialChainId, - ipfsGateway: IPFS_DEFAULT_GATEWAY_URL, - openSeaEnabled: false, - useIPFSSubdomains: true, - isIpfsGatewayEnabled: true, - }; - - this.defaultState = { - allNftContracts: {}, - allNfts: {}, - ignoredNfts: [], - }; - this.initialize(); - this.getERC721AssetName = getERC721AssetName; - this.getERC721AssetSymbol = getERC721AssetSymbol; - this.getERC721TokenURI = getERC721TokenURI; - this.getERC721OwnerOf = getERC721OwnerOf; - this.getERC1155BalanceOf = getERC1155BalanceOf; - this.getERC1155TokenURI = getERC1155TokenURI; - this.getNetworkClientById = getNetworkClientById; - this.onNftAdded = onNftAdded; - this.messagingSystem = messenger; - - onPreferencesStateChange( - ({ - selectedAddress, - ipfsGateway, - openSeaEnabled, - isIpfsGatewayEnabled, - }) => { - this.configure({ - selectedAddress, - ipfsGateway, - openSeaEnabled, - isIpfsGatewayEnabled, - }); - }, - ); - - onNetworkStateChange(({ providerConfig }) => { - const { chainId } = providerConfig; - this.configure({ chainId }); - }); - } - - async validateWatchNft( + async #validateWatchNft( asset: NftAsset, type: NFTStandardType, userAddress: string, - ) { + networkClientId: NetworkClientId, + ): Promise { const { address: contractAddress, tokenId } = asset; // Validate parameters @@ -1078,6 +1250,7 @@ export class NftController extends BaseController { if (type !== ERC721 && type !== ERC1155) { throw rpcErrors.invalidParams( + // eslint-disable-next-line @typescript-eslint/restrict-template-expressions `Non NFT asset type ${type} not supported by watchNft`, ); } @@ -1100,33 +1273,21 @@ export class NftController extends BaseController { userAddress, contractAddress, tokenId, + networkClientId, + { standard: type }, ); if (!isOwner) { throw rpcErrors.invalidInput( 'Suggested NFT is not owned by the selected account', ); } - } catch (error: any) { + } catch (error) { // error thrown here: "Unable to verify ownership. Possibly because the standard is not supported or the user's currently selected network does not match the chain of the asset in question." - throw rpcErrors.resourceUnavailable(error.message); - } - } - - // temporary method to get the correct chainId until we remove chainId from the config & the chainId arg from the detection logic - // Just a helper method to prefer the networkClient chainId first then the chainId argument and then finally the config chainId - private getCorrectChainId({ - chainId, - networkClientId, - }: { - chainId?: Hex; - networkClientId?: NetworkClientId; - }) { - if (networkClientId) { - return this.getNetworkClientById(networkClientId).configuration.chainId; - } else if (chainId) { - return chainId; + if (error instanceof Error) { + throw rpcErrors.resourceUnavailable(error.message); + } + throw error; } - return this.config.chainId; } /** @@ -1139,65 +1300,68 @@ export class NftController extends BaseController { * @param type - The asset type. * @param origin - Domain origin to register the asset from. * @param networkClientId - The networkClientId that can be used to identify the network client to use for this request. + * @param options - Options bag. + * @param options.userAddress - The address of the account where the NFT is being added. * @returns Object containing a Promise resolving to the suggestedAsset address if accepted. */ async watchNft( asset: NftAsset, type: NFTStandardType, origin: string, - networkClientId?: NetworkClientId, - ) { - const { selectedAddress, chainId } = this.config; + networkClientId: NetworkClientId, + { + userAddress, + }: { + userAddress?: string; + } = {}, + ): Promise { + const addressToSearch = this.#getAddressOrSelectedAddress(userAddress); + if (!addressToSearch) { + return; + } + if (!networkClientId) { + throw rpcErrors.invalidParams('Network client id is required'); + } - await this.validateWatchNft(asset, type, selectedAddress); + await this.#validateWatchNft(asset, type, addressToSearch, networkClientId); - const nftMetadata = await this.getNftInformation( + const nftMetadata = await this.#getNftInformation( asset.address, asset.tokenId, networkClientId, ); + // Sanitize metadata + const sanitizedMetadata = await this.#sanitizeNftMetadata(nftMetadata); - if (nftMetadata.standard && nftMetadata.standard !== type) { + if (sanitizedMetadata.standard && sanitizedMetadata.standard !== type) { throw rpcErrors.invalidInput( - `Suggested NFT of type ${nftMetadata.standard} does not match received type ${type}`, + `Suggested NFT of type ${sanitizedMetadata.standard} does not match received type ${type}`, ); } const suggestedNftMeta: SuggestedNftMeta = { - asset: { ...asset, ...nftMetadata }, + asset: { ...asset, ...sanitizedMetadata }, type, id: random(), time: Date.now(), - interactingAddress: selectedAddress, + interactingAddress: addressToSearch, origin, }; await this._requestApproval(suggestedNftMeta); const { address, tokenId } = asset; - const { name, standard, description, image } = nftMetadata; - - await this.addNft(address, tokenId, { + const { name, standard, description, image } = sanitizedMetadata; + await this.addNft(address, tokenId, networkClientId, { nftMetadata: { name: name ?? null, description: description ?? null, image: image ?? null, standard: standard ?? null, }, - chainId, - userAddress: selectedAddress, + userAddress, source: Source.Dapp, - networkClientId, }); } - /** - * Sets an OpenSea API key to retrieve NFT information. - * - * @param openSeaApiKey - OpenSea API key. - */ - setApiKey(openSeaApiKey: string) { - this.openSeaApiKey = openSeaApiKey; - } - /** * Checks the ownership of a ERC-721 or ERC-1155 NFT for a given address. * @@ -1205,44 +1369,44 @@ export class NftController extends BaseController { * @param nftAddress - NFT contract address. * @param tokenId - NFT token ID. * @param networkClientId - The networkClientId that can be used to identify the network client to use for this request. + * @param options - Optional parameters. + * @param options.standard - The NFT standard ('ERC721' or 'ERC1155'). When provided, only the + * relevant ownership check is performed, halving the number of RPC subcalls. * @returns Promise resolving the NFT ownership. */ async isNftOwner( ownerAddress: string, nftAddress: string, tokenId: string, - networkClientId?: NetworkClientId, + networkClientId: NetworkClientId, + { standard }: { standard?: string | null } = {}, ): Promise { - // Checks the ownership for ERC-721. - try { - const owner = await this.getERC721OwnerOf( - nftAddress, - tokenId, - networkClientId, - ); - return ownerAddress.toLowerCase() === owner.toLowerCase(); - // eslint-disable-next-line no-empty - } catch { - // Ignore ERC-721 contract error - } + const client = this.messenger.call( + 'NetworkController:getNetworkClientById', + networkClientId, + ); + const provider = new Web3Provider(client.provider); + const { chainId } = client.configuration; + + const [result] = await getNftOwnershipForMultipleNfts( + [ + { + nftAddress, + tokenId, + userAddress: ownerAddress, + standard: standard ?? null, + }, + ], + chainId, + provider, + ); - // Checks the ownership for ERC-1155. - try { - const balance = await this.getERC1155BalanceOf( - ownerAddress, - nftAddress, - tokenId, - networkClientId, + if (result.isOwned === undefined) { + throw new Error( + `Unable to verify ownership. Possibly because the standard is not supported or the user's currently selected network does not match the chain of the asset in question.`, ); - return !balance.isZero(); - // eslint-disable-next-line no-empty - } catch { - // Ignore ERC-1155 contract error } - - throw new Error( - `Unable to verify ownership. Possibly because the standard is not supported or the user's currently selected network does not match the chain of the asset in question.`, - ); + return result.isOwned; } /** @@ -1252,18 +1416,27 @@ export class NftController extends BaseController { * @param address - Hex address of the NFT contract. * @param tokenId - The NFT identifier. * @param networkClientId - The networkClientId that can be used to identify the network client to use for this request. - * @param source - Whether the NFT was detected, added manually or suggested by a dapp. + * @param options - an object of arguments + * @param options.userAddress - The address of the current user. + * @param options.source - Whether the NFT was detected, added manually or suggested by a dapp. */ async addNftVerifyOwnership( address: string, tokenId: string, - networkClientId?: NetworkClientId, - source?: Source, - ) { - const { selectedAddress } = this.config; + networkClientId: NetworkClientId, + { + userAddress, + source, + }: { + userAddress?: string; + source?: Source; + } = {}, + ): Promise { + const addressToSearch = this.#getAddressOrSelectedAddress(userAddress); + if ( !(await this.isNftOwner( - selectedAddress, + addressToSearch, address, tokenId, networkClientId, @@ -1271,7 +1444,11 @@ export class NftController extends BaseController { ) { throw new Error('This NFT is not owned by the user'); } - await this.addNft(address, tokenId, { networkClientId, source }); + + await this.addNft(address, tokenId, networkClientId, { + userAddress: addressToSearch, + source, + }); } /** @@ -1279,65 +1456,313 @@ export class NftController extends BaseController { * * @param tokenAddress - Hex address of the NFT contract. * @param tokenId - The NFT identifier. + * @param networkClientId - The networkClientId that can be used to identify the network client to use for this request. * @param options - an object of arguments * @param options.nftMetadata - NFT optional metadata. - * @param options.chainId - The chain ID of the current network. * @param options.userAddress - The address of the current user. * @param options.source - Whether the NFT was detected, added manually or suggested by a dapp. - * @param options.networkClientId - The networkClientId that can be used to identify the network client to use for this request. * @returns Promise resolving to the current NFT list. */ async addNft( tokenAddress: string, tokenId: string, + networkClientId: NetworkClientId, { nftMetadata, - chainId, // TODO remove and replace chainId arg with fetch chainId using getNetworkClientById(networkClientId).configuration.chainId once polling refactor is complete userAddress, source = Source.Custom, - networkClientId, }: { nftMetadata?: NftMetadata; - chainId?: Hex; userAddress?: string; source?: Source; - networkClientId?: NetworkClientId; } = {}, - ) { - tokenAddress = toChecksumHexAddress(tokenAddress); + ): Promise { + const addressToSearch = this.#getAddressOrSelectedAddress(userAddress); + if (!addressToSearch) { + return; + } - const currentChainId = this.getCorrectChainId({ chainId, networkClientId }); - const selectedAddress = userAddress ?? this.config.selectedAddress; + const checksumHexAddress = toChecksumHexAddress(tokenAddress); - const newNftContracts = await this.addNftContract({ - tokenAddress, - chainId: currentChainId, - userAddress: selectedAddress, - networkClientId, - source, - }); + if (!nftMetadata) { + const fetchedMetadata = await this.#getNftInformation( + checksumHexAddress, + tokenId, + networkClientId, + ); + // Sanitize metadata + nftMetadata = await this.#sanitizeNftMetadata(fetchedMetadata); + } else { + // Sanitize provided metadata + nftMetadata = await this.#sanitizeNftMetadata(nftMetadata); + } - nftMetadata = - nftMetadata || - (await this.getNftInformation(tokenAddress, tokenId, networkClientId)); + const { contracts: newNftContracts } = await this.#addNftContracts( + addressToSearch, + [ + { + tokenAddress: checksumHexAddress, + networkClientId, + source, + nftMetadata, + }, + ], + ); // If NFT contract was not added, do not add individual NFT - const nftContract = newNftContracts.find( - (contract) => - contract.address.toLowerCase() === tokenAddress.toLowerCase(), + const nftContract = Object.values(newNftContracts) + .flat() + .find( + (contract) => + contract.address.toLowerCase() === checksumHexAddress.toLowerCase(), + ); + + const { + configuration: { chainId }, + } = this.messenger.call( + 'NetworkController:getNetworkClientById', + networkClientId, ); - // If NFT contract information, add individual NFT + // This is the case when the NFT is added manually and not detected automatically + // TODO: An improvement would be to make the chainId a required field and return it when getting the NFT information + if (!nftMetadata.chainId) { + nftMetadata.chainId = convertHexToDecimal(chainId); + } + if (nftContract) { - await this.addIndividualNft( - tokenAddress, - tokenId, - nftMetadata, - nftContract, - currentChainId, - selectedAddress, - source, + await this.#addMultipleNfts(addressToSearch, [ + { + tokenAddress: checksumHexAddress, + tokenId, + nftMetadata, + nftContract, + chainId, + source, + }, + ]); + } + } + + /** + * Adds multiple NFTs and respective NFT contracts to the stored NFT and NFT contracts lists. + * + * @param nfts - An array of NFT objects to add. + * @param nfts[].tokenAddress - Hex address of the NFT contract. + * @param nfts[].tokenId - The NFT identifier. + * @param nfts[].nftMetadata - NFT metadata including chainId. + * @param userAddress - The address of the current user. + * @param source - Whether the NFT was detected, added manually or suggested by a dapp. Defaults to Source.Custom. + * @returns Promise resolving to the current NFT list. + */ + async addNfts( + nfts: { + tokenAddress: string; + tokenId: string; + nftMetadata: NftMetadata & { chainId: number }; + }[], + userAddress: string, + source: Source = Source.Custom, + ): Promise { + const addressToSearch = this.#getAddressOrSelectedAddress(userAddress); + if (!addressToSearch) { + return; + } + + // Remember max number of urls this allows is 250 + const sanitizedNftMetadata = await this.#bulkSanitizeNftMetadata( + nfts.map((nft) => nft.nftMetadata), + ); + + // Resolve network client IDs per item up front. Items that fail (e.g., + // the user removes a network during detection) are skipped individually + // so the rest of the batch is unaffected. Resolved data is bundled into + // one object per NFT to avoid index-alignment issues between the two loops. + const resolvedNfts: { + contractToAdd: NftContractToAdd; + tokenId: string; + checksumHexAddress: string; + hexChainId: Hex; + sanitizedMetadata: NftMetadata; + }[] = []; + + for (const [index, nft] of nfts.entries()) { + try { + const checksumHexAddress = toChecksumHexAddress(nft.tokenAddress); + const hexChainId = toHex(nft.nftMetadata.chainId); + const networkClientId = this.messenger.call( + 'NetworkController:findNetworkClientIdByChainId', + hexChainId, + ); + + resolvedNfts.push({ + contractToAdd: { + networkClientId, + tokenAddress: checksumHexAddress, + source, + nftMetadata: sanitizedNftMetadata[index], + }, + tokenId: nft.tokenId, + checksumHexAddress, + hexChainId, + sanitizedMetadata: sanitizedNftMetadata[index], + }); + } catch (error) { + console.error( + 'Failed to resolve network for NFT', + nft.tokenAddress, + error, + ); + } + } + + const { contracts: newNftContracts } = await this.#addNftContracts( + addressToSearch, + resolvedNfts.map((item) => item.contractToAdd), + ); + + const nftsToAdd: NftToAdd[] = []; + + for (const { + checksumHexAddress, + tokenId, + hexChainId, + sanitizedMetadata, + } of resolvedNfts) { + const nftContract = newNftContracts[hexChainId]?.find( + (contract) => + contract.address.toLowerCase() === checksumHexAddress.toLowerCase(), ); + if (nftContract) { + nftsToAdd.push({ + tokenAddress: checksumHexAddress, + tokenId, + nftMetadata: sanitizedMetadata, + nftContract, + chainId: hexChainId, + source, + }); + } + } + + if (nftsToAdd.length > 0) { + await this.#addMultipleNfts(addressToSearch, nftsToAdd); + } + } + + /** + * Refetches NFT metadata and updates the state + * + * @param options - Options for refetching NFT metadata + * @param options.nfts - nfts to update metadata for. + * @param options.userAddress - The current user address + */ + async updateNftMetadata({ + nfts, + userAddress, + }: { + nfts: Nft[]; + userAddress?: string; + }): Promise { + const addressToSearch = this.#getAddressOrSelectedAddress(userAddress); + + const releaseLock = await this.#mutex.acquire(); + + try { + const nftsWithChecksumAdr = nfts.map((nft) => { + return { + ...nft, + address: toChecksumHexAddress(nft.address), + }; + }); + + // Get all unsanitized nft metadata + const unsanitizedResults = await Promise.all( + nftsWithChecksumAdr.map(async (nft) => { + // Each NFT should have a chainId; convert nft.chainId to networkClientId + const networkClientId = this.messenger.call( + 'NetworkController:findNetworkClientIdByChainId', + toHex(nft.chainId as number), + ); + const resMetadata = networkClientId + ? await this.#getNftInformation( + nft.address, + nft.tokenId, + networkClientId, + ) + : undefined; + return { + nft, + newMetadata: resMetadata, + }; + }), + ); + + // Extract metadata + const unsanitizedMetadata = unsanitizedResults.map( + (result) => result.newMetadata, + ); + + // Sanitize all metadata + const sanitizedMetadata = await this.#bulkSanitizeNftMetadata( + unsanitizedMetadata as NftMetadata[], + ); + + // Reassemble the results with sanitized metadata + const nftMetadataResults = unsanitizedResults.map((result, index) => ({ + nft: result.nft, + newMetadata: sanitizedMetadata[index], + })); + + // We want to avoid updating the state if the state and fetched nft info are the same + const nftsWithDifferentMetadata: NftUpdate[] = []; + const { allNfts } = this.state; + // get from state allNfts that match nftsWithChecksumAdr + const stateNfts = nftsWithChecksumAdr.map((nft) => { + return allNfts[addressToSearch]?.[toHex(nft.chainId as number)]?.find( + (nftElement) => + nftElement.address.toLowerCase() === nft.address.toLowerCase() && + nftElement.tokenId === nft.tokenId, + ); + }); + + nftMetadataResults.forEach( + (singleNft: { nft: Nft; newMetadata: NftMetadata | undefined }) => { + const existingEntry: Nft | undefined = stateNfts.find( + (nft) => + nft?.address.toLowerCase() === + singleNft.nft.address.toLowerCase() && + nft?.tokenId === singleNft.nft.tokenId, + ); + + if (existingEntry && singleNft.newMetadata) { + const differentMetadata = compareNftMetadata( + singleNft.newMetadata, + existingEntry, + ); + + if (differentMetadata) { + nftsWithDifferentMetadata.push({ + nft: singleNft.nft, + newMetadata: singleNft.newMetadata, + }); + } + } + }, + ); + + if (nftsWithDifferentMetadata.length !== 0) { + nftsWithDifferentMetadata.forEach((elm) => + this.updateNft( + elm.nft, + elm.newMetadata, + addressToSearch, + toHex(elm.nft.chainId as number), + ), + ); + } + } finally { + releaseLock(); } } @@ -1346,18 +1771,41 @@ export class NftController extends BaseController { * * @param address - Hex address of the NFT contract. * @param tokenId - Token identifier of the NFT. + * @param networkClientId - The networkClientId that can be used to identify the network client to use for this request. + * @param options - an object of arguments + * @param options.userAddress - The address of the account where the NFT is being removed. */ - removeNft(address: string, tokenId: string) { - address = toChecksumHexAddress(address); - this.removeIndividualNft(address, tokenId); + removeNft( + address: string, + tokenId: string, + networkClientId: NetworkClientId, + { userAddress }: { userAddress?: string } = {}, + ): void { + const addressToSearch = this.#getAddressOrSelectedAddress(userAddress); + + const { + configuration: { chainId }, + } = this.messenger.call( + 'NetworkController:getNetworkClientById', + networkClientId, + ); + + const checksumHexAddress = toChecksumHexAddress(address); + this.#removeIndividualNft(checksumHexAddress, tokenId, { + chainId, + userAddress: addressToSearch, + }); const { allNfts } = this.state; - const { chainId, selectedAddress } = this.config; - const nfts = allNfts[selectedAddress]?.[chainId] || []; + const nfts = allNfts[addressToSearch]?.[chainId] || []; const remainingNft = nfts.find( - (nft) => nft.address.toLowerCase() === address.toLowerCase(), + (nft) => nft.address.toLowerCase() === checksumHexAddress.toLowerCase(), ); + if (!remainingNft) { - this.removeNftContract(address); + this.#removeNftContract(checksumHexAddress, { + chainId, + userAddress: addressToSearch, + }); } } @@ -1366,103 +1814,169 @@ export class NftController extends BaseController { * * @param address - Hex address of the NFT contract. * @param tokenId - Token identifier of the NFT. + * @param networkClientId - The networkClientId that can be used to identify the network client to use for this request. + * @param options - an object of arguments + * @param options.userAddress - The address of the account where the NFT is being removed. */ - removeAndIgnoreNft(address: string, tokenId: string) { - address = toChecksumHexAddress(address); - this.removeAndIgnoreIndividualNft(address, tokenId); + removeAndIgnoreNft( + address: string, + tokenId: string, + networkClientId: NetworkClientId, + { userAddress }: { userAddress?: string } = {}, + ): void { + const addressToSearch = this.#getAddressOrSelectedAddress(userAddress); + const { + configuration: { chainId }, + } = this.messenger.call( + 'NetworkController:getNetworkClientById', + networkClientId, + ); + const checksumHexAddress = toChecksumHexAddress(address); + this.#removeAndIgnoreIndividualNft(checksumHexAddress, tokenId, { + chainId, + userAddress: addressToSearch, + }); const { allNfts } = this.state; - const { chainId, selectedAddress } = this.config; - const nfts = allNfts[selectedAddress]?.[chainId] || []; + const nfts = allNfts[addressToSearch]?.[chainId] || []; const remainingNft = nfts.find( - (nft) => nft.address.toLowerCase() === address.toLowerCase(), + (nft) => nft.address.toLowerCase() === checksumHexAddress.toLowerCase(), ); if (!remainingNft) { - this.removeNftContract(address); + this.#removeNftContract(checksumHexAddress, { + chainId, + userAddress: addressToSearch, + }); } } /** * Removes all NFTs from the ignored list. */ - clearIgnoredNfts() { - this.update({ ignoredNfts: [] }); + clearIgnoredNfts(): void { + this.update((state) => { + state.ignoredNfts = []; + }); } /** * Checks whether input NFT is still owned by the user - * And updates the isCurrentlyOwned value on the NFT object accordingly. + * and updates the isCurrentlyOwned value on the NFT object accordingly. * * @param nft - The NFT object to check and update. - * @param batch - A boolean indicating whether this method is being called as part of a batch or single update. - * @param accountParams - The userAddress and chainId to check ownership against + * @param networkClientId - The networkClientId that can be used to identify the network client to use for this request. + * @param accountParams - The userAddress to check ownership against. * @param accountParams.userAddress - the address passed through the confirmed transaction flow to ensure assets are stored to the correct account - * @param accountParams.chainId - the chainId passed through the confirmed transaction flow to ensure assets are stored to the correct account * @returns the NFT with the updated isCurrentlyOwned value */ async checkAndUpdateSingleNftOwnershipStatus( nft: Nft, - batch: boolean, - { userAddress, chainId } = { - userAddress: this.config.selectedAddress, - chainId: this.config.chainId, - }, - ) { + networkClientId: NetworkClientId, + { userAddress }: { userAddress?: string } = {}, + ): Promise { + const addressToSearch = this.#getAddressOrSelectedAddress(userAddress); + const { + configuration: { chainId }, + } = this.messenger.call( + 'NetworkController:getNetworkClientById', + networkClientId, + ); const { address, tokenId } = nft; let isOwned = nft.isCurrentlyOwned; try { - isOwned = await this.isNftOwner(userAddress, address, tokenId); - } catch (error) { - if ( - !( - error instanceof Error && - error.message.includes('Unable to verify ownership') - ) - ) { - throw error; - } + isOwned = await this.isNftOwner( + addressToSearch, + address, + tokenId, + networkClientId, + { standard: nft.standard }, + ); + } catch { + // ignore error + // this will only throw an error 'Unable to verify ownership' in which case + // we want to keep the current value of isCurrentlyOwned for this flow. } - nft.isCurrentlyOwned = isOwned; - - if (batch) { - return nft; - } + const updatedNft = { + ...nft, + isCurrentlyOwned: isOwned, + }; - // if this is not part of a batched update we update this one NFT in state const { allNfts } = this.state; - const nfts = allNfts[userAddress]?.[chainId] || []; - const nftToUpdate = nfts.find( + const nfts = [...(allNfts[addressToSearch]?.[chainId] ?? [])]; + const indexToUpdate = nfts.findIndex( (item) => item.tokenId === tokenId && item.address.toLowerCase() === address.toLowerCase(), ); - if (nftToUpdate) { - nftToUpdate.isCurrentlyOwned = isOwned; - this.updateNestedNftState(nfts, ALL_NFTS_STATE_KEY, { - userAddress, + + if (indexToUpdate !== -1) { + nfts[indexToUpdate] = updatedNft; + this.#updateNestedNftState(nfts, ALL_NFTS_STATE_KEY, { + userAddress: addressToSearch, chainId, }); } - return nft; + + return updatedNft; } /** * Checks whether NFTs associated with current selectedAddress/chainId combination are still owned by the user * And updates the isCurrentlyOwned value on each accordingly. + * Uses Multicall3 to batch all ownership checks into a single RPC request when available. + * + * @param networkClientId - The networkClientId that can be used to identify the network client to use for this request. + * @param options - an object of arguments + * @param options.userAddress - The address of the account where the NFT ownership status is checked/updated. */ - async checkAndUpdateAllNftsOwnershipStatus() { - const { allNfts } = this.state; - const { chainId, selectedAddress } = this.config; - const nfts = allNfts[selectedAddress]?.[chainId] || []; - const updatedNfts = await Promise.all( - nfts.map(async (nft) => { - return ( - (await this.checkAndUpdateSingleNftOwnershipStatus(nft, true)) ?? nft - ); - }), + async checkAndUpdateAllNftsOwnershipStatus( + networkClientId: NetworkClientId, + { + userAddress, + }: { + userAddress?: string; + } = {}, + ): Promise { + const addressToSearch = this.#getAddressOrSelectedAddress(userAddress); + const client = this.messenger.call( + 'NetworkController:getNetworkClientById', + networkClientId, ); + const { chainId } = client.configuration; + const { allNfts } = this.state; + const nfts = allNfts[addressToSearch]?.[chainId] || []; + + if (nfts.length === 0) { + return; + } + + const provider = new Web3Provider(client.provider); + + let ownershipResults: NftOwnershipResult[]; + try { + ownershipResults = await getNftOwnershipForMultipleNfts( + nfts.map((nft) => ({ + nftAddress: nft.address, + tokenId: nft.tokenId, + userAddress: addressToSearch, + standard: nft.standard, + })), + chainId, + provider, + ); + } catch { + return; + } + + const updatedNfts = nfts.filter((_nft, index) => { + const { isOwned } = ownershipResults[index]; + return isOwned !== false; + }); - this.updateNestedNftState(updatedNfts, ALL_NFTS_STATE_KEY); + this.#updateNestedNftState(updatedNfts, ALL_NFTS_STATE_KEY, { + userAddress: addressToSearch, + chainId, + }); } /** @@ -1471,11 +1985,30 @@ export class NftController extends BaseController { * @param address - Hex address of the NFT contract. * @param tokenId - Hex address of the NFT contract. * @param favorite - NFT new favorite status. + * @param networkClientId - The networkClientId that can be used to identify the network client to use for this request. + * @param options - an object of arguments + * @param options.userAddress - The address of the account where the NFT is being removed. */ - updateNftFavoriteStatus(address: string, tokenId: string, favorite: boolean) { + updateNftFavoriteStatus( + address: string, + tokenId: string, + favorite: boolean, + networkClientId: NetworkClientId, + { + userAddress, + }: { + userAddress?: string; + } = {}, + ): void { + const addressToSearch = this.#getAddressOrSelectedAddress(userAddress); + const { + configuration: { chainId }, + } = this.messenger.call( + 'NetworkController:getNetworkClientById', + networkClientId, + ); const { allNfts } = this.state; - const { chainId, selectedAddress } = this.config; - const nfts = allNfts[selectedAddress]?.[chainId] || []; + const nfts = [...(allNfts[addressToSearch]?.[chainId] || [])]; const index: number = nfts.findIndex( (nft) => nft.address === address && nft.tokenId === tokenId, ); @@ -1492,7 +2025,10 @@ export class NftController extends BaseController { // Update Nfts array nfts[index] = updatedNft; - this.updateNestedNftState(nfts, ALL_NFTS_STATE_KEY); + this.#updateNestedNftState(nfts, ALL_NFTS_STATE_KEY, { + chainId, + userAddress: addressToSearch, + }); } /** @@ -1538,7 +2074,7 @@ export class NftController extends BaseController { updates: Partial, selectedAddress: string, chainId: Hex, - ) { + ): void { const { allNfts } = this.state; const nfts = allNfts[selectedAddress]?.[chainId] || []; const nftInfo = this.findNftByAddressAndTokenId( @@ -1562,8 +2098,10 @@ export class NftController extends BaseController { updatedNft, ...nfts.slice(nftInfo.index + 1), ]; - - this.updateNestedNftState(newNfts, ALL_NFTS_STATE_KEY); + this.#updateNestedNftState(newNfts, ALL_NFTS_STATE_KEY, { + chainId, + userAddress: selectedAddress, + }); } /** @@ -1599,12 +2137,16 @@ export class NftController extends BaseController { ...nfts.slice(index + 1), ]; - this.updateNestedNftState(newNfts, ALL_NFTS_STATE_KEY); + this.#updateNestedNftState(newNfts, ALL_NFTS_STATE_KEY, { + chainId, + userAddress: selectedAddress, + }); + return true; } - async _requestApproval(suggestedNftMeta: SuggestedNftMeta) { - return this.messagingSystem.call( + async _requestApproval(suggestedNftMeta: SuggestedNftMeta): Promise { + return this.messenger.call( 'ApprovalController:addRequest', { id: suggestedNftMeta.id, @@ -1626,6 +2168,189 @@ export class NftController extends BaseController { true, ); } + + #getAddressOrSelectedAddress(address: string | undefined): string { + if (address) { + return address; + } + + // If the address is not defined (or empty), we fallback to the currently selected account's address + const selectedAccount = this.messenger.call( + 'AccountsController:getAccount', + this.#selectedAccountId, + ); + return selectedAccount?.address || ''; + } + + /** + * Updates the all nfts in state for the account. + * Nfts will be updated if they don't have a name, description or image. + * + * @param account - The account to update the NFT metadata for. + */ + async #updateNftUpdateForAccount(account: InternalAccount): Promise { + // get all nfts for the account for all chains + const nfts: Nft[] = Object.values( + this.state.allNfts[account.address] || {}, + ).flat(); + + // Filter only nfts + const nftsToUpdate = nfts.filter( + (singleNft) => + !singleNft.name && !singleNft.description && !singleNft.image, + ); + if ( + nftsToUpdate.length !== 0 && + nftsToUpdate.length < NFT_UPDATE_THRESHOLD + ) { + await this.updateNftMetadata({ + nfts: nftsToUpdate, + userAddress: account.address, + }); + } + } + + /** + * Reset the controller state to the default state. + */ + resetState(): void { + this.update(() => { + return getDefaultNftControllerState(); + }); + } + + /** + * Sanitizes multiple NFT metadata objects by checking external links against PhishingController in a single bulk request + * + * @param metadataList - Array of NFT metadata objects to sanitize + * @returns Array of sanitized NFT metadata objects + */ + async #bulkSanitizeNftMetadata( + metadataList: NftMetadata[], + ): Promise { + // Create a copy of the metadata list to avoid mutating the input + const sanitizedMetadataList = metadataList.map((metadata) => ({ + ...metadata, + })); + + // Maps URL to a list of {metadataIndex, fieldName} to track where each URL is used + const urlMap: Record< + string, + { metadataIndex: number; fieldName: string }[] + > = {}; + + const fieldsToCheck = [ + 'externalLink', + 'image', + 'imagePreview', + 'imageThumbnail', + 'imageOriginal', + 'animation', + 'animationOriginal', + ]; + + // Collect all URLs from all metadata objects + sanitizedMetadataList.forEach((metadata, metadataIndex) => { + // Check regular fields + for (const field of fieldsToCheck) { + const url = metadata[field as keyof NftMetadata]; + if (typeof url === 'string' && url && url.startsWith('http')) { + if (!urlMap[url]) { + urlMap[url] = []; + } + urlMap[url].push({ metadataIndex, fieldName: field }); + } + } + + // Check collection links if they exist + if (metadata.collection) { + const { collection } = metadata; + if ( + 'externalLink' in collection && + typeof collection.externalLink === 'string' + ) { + const url = collection.externalLink; + if (!urlMap[url]) { + urlMap[url] = []; + } + urlMap[url].push({ + metadataIndex, + fieldName: 'collection.externalLink', + }); + } + } + }); + + const urlsToCheck = Object.keys(urlMap); + if (urlsToCheck.length === 0) { + return sanitizedMetadataList; + } + + try { + // PhishingController has a 250 URL limit, so batch if needed + const MAX_URLS_PER_BATCH = 250; + + // Process URLs in batches serially + const blockedUrls = await reduceInBatchesSerially>({ + values: urlsToCheck, + batchSize: MAX_URLS_PER_BATCH, + eachBatch: async (workingBlockedUrls, batch) => { + // Use bulkScanUrls to check this batch + const bulkScanResponse = await this.messenger.call( + 'PhishingController:bulkScanUrls', + batch, + ); + + // Collect blocked URLs from this batch + Object.entries(bulkScanResponse.results).forEach(([url, result]) => { + if (result.recommendedAction === RecommendedAction.Block) { + // Type assertion is safe here as we always initialize with a Set and return a Set + (workingBlockedUrls as Set).add(url); + } + }); + + return workingBlockedUrls; + }, + initialResult: new Set(), + }); + + // Apply scan results to all metadata objects + blockedUrls.forEach((url) => { + urlMap[url].forEach(({ metadataIndex, fieldName }) => { + if ( + fieldName === 'collection.externalLink' && + sanitizedMetadataList[metadataIndex].collection + ) { + const { collection } = sanitizedMetadataList[metadataIndex]; + if (collection && 'externalLink' in collection) { + delete (collection as Record).externalLink; + } + } else { + delete sanitizedMetadataList[metadataIndex][ + fieldName as keyof NftMetadata + ]; + } + }); + }); + } catch (error) { + console.error('Error during bulk URL scanning:', error); + // If bulk scan fails, we fall back to keeping all URLs + } + + return sanitizedMetadataList; + } + + /** + * Sanitizes NFT metadata by checking external links against PhishingController + * + * @param metadata - The NFT metadata to sanitize + * @returns Sanitized NFT metadata with potentially dangerous links removed + */ + async #sanitizeNftMetadata(metadata: NftMetadata): Promise { + // Use the bulk sanitize function with just a single metadata object + const sanitized = await this.#bulkSanitizeNftMetadata([metadata]); + return sanitized[0]; + } } export default NftController; diff --git a/packages/assets-controllers/src/NftDetectionController.test.ts b/packages/assets-controllers/src/NftDetectionController.test.ts index e745804afc6..b6514c37863 100644 --- a/packages/assets-controllers/src/NftDetectionController.test.ts +++ b/packages/assets-controllers/src/NftDetectionController.test.ts @@ -1,824 +1,1403 @@ -import type { AddApprovalRequest } from '@metamask/approval-controller'; -import { ControllerMessenger } from '@metamask/base-controller'; -import { OPENSEA_PROXY_URL, ChainId, toHex } from '@metamask/controller-utils'; -import { PreferencesController } from '@metamask/preferences-controller'; +import type { AccountsController } from '@metamask/accounts-controller'; +import { + NFT_API_BASE_URL, + ChainId, + InfuraNetworkType, +} from '@metamask/controller-utils'; +import { MOCK_ANY_NAMESPACE, Messenger } from '@metamask/messenger'; +import type { + MessengerActions, + MessengerEvents, + MockAnyNamespace, +} from '@metamask/messenger'; +import { + getDefaultNetworkControllerState, + NetworkClientType, +} from '@metamask/network-controller'; +import type { + NetworkClient, + NetworkClientConfiguration, + NetworkClientId, + NetworkController, + NetworkState, +} from '@metamask/network-controller'; +import { getDefaultPreferencesState } from '@metamask/preferences-controller'; +import type { PreferencesState } from '@metamask/preferences-controller'; import nock from 'nock'; -import * as sinon from 'sinon'; -import { AssetsContractController } from './AssetsContractController'; -import type { NftControllerMessenger } from './NftController'; -import { NftController } from './NftController'; -import { NftDetectionController } from './NftDetectionController'; +import { FakeBlockTracker } from '../../../tests/fake-block-tracker.js'; +import { FakeProvider } from '../../../tests/fake-provider.js'; +import { jestAdvanceTime } from '../../../tests/helpers.js'; +import { createMockInternalAccount } from '../../accounts-controller/tests/mocks.js'; +import { + buildMockFindNetworkClientIdByChainId, + buildMockGetNetworkClientById, +} from '../../network-controller/tests/helpers.js'; +import { Source } from './constants.js'; +import { getDefaultNftControllerState } from './NftController.js'; +import { + NftDetectionController, + BlockaidResultType, +} from './NftDetectionController.js'; +import type { NftDetectionControllerMessenger } from './NftDetectionController.js'; -const DEFAULT_INTERVAL = 180000; +type AllActions = MessengerActions; -type ApprovalActions = AddApprovalRequest; +type AllEvents = MessengerEvents; -const controllerName = 'NftController' as const; +type RootMessenger = Messenger; -describe('NftDetectionController', () => { - let nftDetection: NftDetectionController; - let preferences: PreferencesController; - let nftController: NftController; - let assetsContract: AssetsContractController; - const networkStateChangeNoop = jest.fn(); - const getOpenSeaApiKeyStub = jest.fn(); - - const messenger = new ControllerMessenger< - ApprovalActions, - never - >().getRestricted({ - name: controllerName, - allowedActions: ['ApprovalController:addRequest'], - }) as NftControllerMessenger; +const controllerName = 'NftDetectionController' as const; - beforeEach(async () => { - preferences = new PreferencesController(); - assetsContract = new AssetsContractController({ - chainId: ChainId.mainnet, - onPreferencesStateChange: (listener) => preferences.subscribe(listener), - onNetworkStateChange: networkStateChangeNoop, - getNetworkClientById: jest.fn(), - }); - const getNetworkClientById = jest.fn().mockImplementation(() => { - return { - configuration: { - chainId: ChainId.mainnet, - }, - provider: jest.fn(), - blockTracker: jest.fn(), - destroy: jest.fn(), - }; - }); - - nftController = new NftController({ - chainId: ChainId.mainnet, - onPreferencesStateChange: (listener) => preferences.subscribe(listener), - onNetworkStateChange: networkStateChangeNoop, - getERC721AssetName: - assetsContract.getERC721AssetName.bind(assetsContract), - getERC721AssetSymbol: - assetsContract.getERC721AssetSymbol.bind(assetsContract), - getERC721TokenURI: assetsContract.getERC721TokenURI.bind(assetsContract), - getERC721OwnerOf: assetsContract.getERC721OwnerOf.bind(assetsContract), - getERC1155BalanceOf: - assetsContract.getERC1155BalanceOf.bind(assetsContract), - getERC1155TokenURI: - assetsContract.getERC1155TokenURI.bind(assetsContract), - onNftAdded: jest.fn(), - getNetworkClientById, - messenger, - }); +const defaultSelectedAccount = createMockInternalAccount(); - nftDetection = new NftDetectionController({ - chainId: ChainId.mainnet, - onNftsStateChange: (listener) => nftController.subscribe(listener), - onPreferencesStateChange: (listener) => preferences.subscribe(listener), - onNetworkStateChange: networkStateChangeNoop, - getOpenSeaApiKey: getOpenSeaApiKeyStub, - addNft: nftController.addNft.bind(nftController), - getNetworkClientById, - getNftState: () => nftController.state, - }); - - nftController.configure({ selectedAddress: '0x1' }); - preferences.setOpenSeaEnabled(true); - preferences.setUseNftDetection(true); +describe('NftDetectionController', () => { + beforeEach(async () => { + jest.useFakeTimers({ doNotFake: ['nextTick', 'queueMicrotask'] }); - nock(OPENSEA_PROXY_URL) - .get(`/assets?owner=0x2&offset=0&limit=50`) + nock(NFT_API_BASE_URL) + .persist() + .get( + `/users/0x1/tokens?chainIds=1&limit=50&includeTopBid=true&continuation=`, + ) .reply(200, { - assets: [ + tokens: [ { - asset_contract: { - address: '0x1d963688fe2209a98db35c67a041524822cf04ff', - schema_name: 'ERC721', - }, - collection: { - name: 'Collection 2577', - image_url: 'url', - }, - description: 'Description 2577', - image_original_url: 'image/2577.png', - name: 'ID 2577', - token_id: '2577', + token: { + chainId: 1, + contract: '0xCE7ec4B2DfB30eB6c0BB5656D33aAd6BFb4001Fc', + tokenId: '2577', + kind: 'erc721', + name: 'Remilio 632', + image: 'https://imgtest', + imageSmall: 'https://imgSmall', + imageLarge: 'https://imglarge', + metadata: { + imageOriginal: 'https://remilio.org/remilio/632.png', + imageMimeType: 'image/png', + tokenURI: 'https://remilio.org/remilio/json/632', + }, + description: + "Redacted Remilio Babies is a collection of 10,000 neochibi pfpNFT's expanding the Milady Maker paradigm with the introduction of young J.I.T. energy and schizophrenic reactionary aesthetics. We are #REMILIONAIREs.", + rarityScore: 343.443, + rarityRank: 8872, + supply: '1', + isSpam: false, + }, + }, + { + token: { + chainId: 1, + contract: '0x0B0fa4fF58D28A88d63235bd0756EDca69e49e6d', + kind: 'erc721', + name: 'ID 2578', + description: 'Description 2578', + image: 'https://imgtest', + imageSmall: 'https://imgSmall', + imageLarge: 'https://imglarge', + tokenId: '2578', + metadata: { + imageOriginal: 'https://remilio.org/remilio/632.png', + imageMimeType: 'image/png', + tokenURI: 'https://remilio.org/remilio/json/632', + }, + rarityScore: 343.443, + rarityRank: 8872, + supply: '1', + isSpam: false, + }, + }, + { + token: { + chainId: 1, + contract: '0xebE4e5E773AFD2bAc25De0cFafa084CFb3cBf1eD', + kind: 'erc721', + name: 'ID 2574', + description: 'Description 2574', + image: 'image/2574.png', + tokenId: '2574', + metadata: { + imageOriginal: 'imageOriginal/2574.png', + imageMimeType: 'image/png', + tokenURI: 'tokenURITest', + }, + isSpam: false, + }, }, ], }) - .get(`/assets?owner=0x2&offset=50&limit=50`) + .get( + `/users/0x1/tokens?chainIds=1&chainIds=59144&limit=50&includeTopBid=true&continuation=`, + ) .reply(200, { - assets: [], + tokens: [ + { + token: { + chainId: 59144, + contract: '0xebE4e5E773AFD2bAc25De0cFafa084CFb3cBf1e5', + kind: 'erc721', + name: 'ID 2', + description: 'Description 2', + image: 'image/2.png', + tokenId: '2', + metadata: { + imageOriginal: 'imageOriginal/2.png', + imageMimeType: 'image/png', + tokenURI: 'tokenURITest', + }, + isSpam: false, + }, + }, + { + token: { + chainId: 1, + contract: '0xebE4e5E773AFD2bAc25De0cFafa084CFb3cBf1eD', + kind: 'erc721', + name: 'ID 2574', + description: 'Description 2574', + image: 'image/2574.png', + tokenId: '2574', + metadata: { + imageOriginal: 'imageOriginal/2574.png', + imageMimeType: 'image/png', + tokenURI: 'tokenURITest', + }, + isSpam: false, + }, + }, + ], }) - .persist(); - - nock(OPENSEA_PROXY_URL) - .get(`/asset_contract/0x1d963688FE2209A98dB35C67A041524822Cf04ff`) + .get( + `/users/0x9/tokens?chainIds=1&limit=50&includeTopBid=true&continuation=`, + ) .reply(200, { - description: 'Description', - image_url: 'url', - name: 'Name', - symbol: 'FOO', - total_supply: 0, - collection: { - image_url: 'url', - name: 'Name', - }, + tokens: [ + { + token: { + chainId: 1, + contract: '0xebE4e5E773AFD2bAc25De0cFafa084CFb3cBf1eD', + kind: 'erc721', + name: 'ID 2574', + description: 'Description 2574', + image: 'image/2574.png', + tokenId: '2574', + metadata: { + imageOriginal: 'imageOriginal/2574.png', + imageMimeType: 'image/png', + tokenURI: 'tokenURITest', + }, + isSpam: false, + }, + }, + ], }) - .get(`/asset_contract/0xebE4e5E773AFD2bAc25De0cFafa084CFb3cBf1eD`) + .get( + `/users/0x123/tokens?chainIds=1&limit=50&includeTopBid=true&continuation=`, + ) .reply(200, { - description: 'Description HH', - symbol: 'HH', - total_supply: 10, - collection: { - image_url: 'url HH', - name: 'Name HH', - }, + tokens: [ + { + token: { + chainId: 1, + contract: '0xtest1', + kind: 'erc721', + name: 'ID 2574', + description: 'Description 2574', + image: 'image/2574.png', + tokenId: '2574', + metadata: { + imageOriginal: 'imageOriginal/2574.png', + imageMimeType: 'image/png', + tokenURI: 'tokenURITest', + }, + isSpam: false, + collection: { + id: '0xtest1', + }, + }, + blockaidResult: { + result_type: BlockaidResultType.Benign, + }, + }, + { + token: { + chainId: 1, + contract: '0xtest2', + kind: 'erc721', + name: 'ID 2575', + description: 'Description 2575', + image: 'image/2575.png', + tokenId: '2575', + metadata: { + imageOriginal: 'imageOriginal/2575.png', + imageMimeType: 'image/png', + tokenURI: 'tokenURITest', + }, + isSpam: false, + collection: { + id: '0xtest2', + }, + }, + blockaidResult: { + result_type: BlockaidResultType.Benign, + }, + }, + ], }) - .get(`/asset_contract/0xCE7ec4B2DfB30eB6c0BB5656D33aAd6BFb4001Fc`) - .replyWithError(new Error('Failed to fetch')) - .get(`/asset_contract/0x0B0fa4fF58D28A88d63235bd0756EDca69e49e6d`) - .replyWithError(new Error('Failed to fetch')) - .get(`/assets?owner=0x1&offset=0&limit=50`) + .get( + `/users/0x12345/tokens?chainIds=1&limit=50&includeTopBid=true&continuation=`, + ) .reply(200, { - assets: [ + tokens: [ { - asset_contract: { - address: '0xCE7ec4B2DfB30eB6c0BB5656D33aAd6BFb4001Fc', - schema_name: 'ERC721', - }, - collection: { - name: 'Collection 2577', - image_url: 'url', - }, - description: 'Description 2577', - image_url: 'image/2577.png', - name: 'ID 2577', - token_id: '2577', + token: { + chainId: 1, + contract: '0xtestCollection1', + kind: 'erc721', + name: 'ID 1', + description: 'Description 1', + image: 'image/1.png', + tokenId: '1', + metadata: { + imageOriginal: 'imageOriginal/1.png', + imageMimeType: 'image/png', + tokenURI: 'tokenURITest', + }, + isSpam: false, + collection: { + id: '0xtestCollection1', + }, + }, + blockaidResult: { + result_type: BlockaidResultType.Benign, + }, }, { - asset_contract: { - address: '0x0B0fa4fF58D28A88d63235bd0756EDca69e49e6d', - schema_name: 'ERC721', - }, - collection: { - name: 'Collection 2577', - image_url: 'url', - }, - description: 'Description 2578', - image_url: 'image/2578.png', - name: 'ID 2578', - token_id: '2578', + token: { + chainId: 1, + contract: '0xtestCollection2', + kind: 'erc721', + name: 'ID 2', + description: 'Description 2', + image: 'image/2.png', + tokenId: '2', + metadata: { + imageOriginal: 'imageOriginal/2.png', + imageMimeType: 'image/png', + tokenURI: 'tokenURITest', + }, + isSpam: false, + collection: { + id: '0xtestCollection2', + }, + }, }, { - asset_contract: { - address: '0xebE4e5E773AFD2bAc25De0cFafa084CFb3cBf1eD', - schema_name: 'ERC721', - }, - collection: { - name: 'Collection 2574', - image_url: 'url', - }, - description: 'Description 2574', - image_url: 'image/2574.png', - name: 'ID 2574', - token_id: '2574', + token: { + chainId: 1, + contract: '0xtestCollection3', + kind: 'erc721', + name: 'ID 3', + description: 'Description 3', + image: 'image/3.png', + tokenId: '3', + metadata: { + imageOriginal: 'imageOriginal/3.png', + imageMimeType: 'image/png', + tokenURI: 'tokenURITest', + }, + isSpam: false, + }, + blockaidResult: { + result_type: BlockaidResultType.Malicious, + }, + }, + { + token: { + chainId: 1, + contract: '0xtestCollection4', + kind: 'erc721', + name: 'ID 4', + description: 'Description 4', + image: 'image/4.png', + tokenId: '4', + metadata: { + imageOriginal: 'imageOriginal/4.png', + imageMimeType: 'image/png', + tokenURI: 'tokenURITest', + }, + isSpam: true, + }, + blockaidResult: { + result_type: BlockaidResultType.Benign, + }, }, - ], - }) - .get(`/assets?owner=0x1&offset=50&limit=50`) - .reply(200, { - assets: [], - }) - .get(`/assets?owner=0x9&offset=0&limit=50`) - .delay(800) - .reply(200, { - assets: [ { - asset_contract: { - address: '0xebE4e5E773AFD2bAc25De0cFafa084CFb3cBf1eD', - schema_name: 'ERC721', - }, - collection: { - name: 'Collection 2574', - image_url: 'url', - }, - description: 'Description 2574', - image_url: 'image/2574.png', - name: 'ID 2574', - token_id: '2574', + token: { + chainId: 1, + contract: '0xtestCollection5', + kind: 'erc721', + name: 'ID 5', + description: 'Description 5', + image: 'image/5.png', + tokenId: '5', + metadata: { + imageOriginal: 'imageOriginal/5.png', + imageMimeType: 'image/png', + tokenURI: 'tokenURITest', + }, + isSpam: true, + }, + blockaidResult: { + result_type: BlockaidResultType.Malicious, + }, }, ], - }) - .get(`/assets?owner=0x9&offset=50&limit=50`) - .reply(200, { - assets: [], }); }); afterEach(() => { - nftDetection.stopAllPolling(); - sinon.restore(); + jest.useRealTimers(); }); - it('should set default config', () => { - preferences.setUseNftDetection(false); - expect(nftDetection.config).toStrictEqual({ - interval: DEFAULT_INTERVAL, - chainId: toHex(1), - selectedAddress: '', - disabled: true, - }); + it('should call detect NFTs on mainnet', async () => { + const mockGetSelectedAccount = jest + .fn() + .mockReturnValue(defaultSelectedAccount); + await withController( + { + options: {}, + mockGetSelectedAccount, + }, + async ({ controller, controllerEvents }) => { + const mockNfts = jest + .spyOn(controller, 'detectNfts') + .mockResolvedValue(); + controllerEvents.triggerPreferencesStateChange({ + ...getDefaultPreferencesState(), + useNftDetection: true, + }); + + // call detectNfts + await controller.detectNfts(['0x1']); + expect(mockNfts).toHaveBeenCalledTimes(1); + + await jestAdvanceTime({ + duration: 10, + }); + + expect(mockNfts).toHaveBeenCalledTimes(1); + }, + ); }); - it('should poll and detect NFTs on interval while on mainnet', async () => { - await new Promise((resolve) => { - const mockNfts = sinon.stub( - NftDetectionController.prototype, - 'detectNfts', - ); - const nftsDetectionController = new NftDetectionController( - { - getNetworkClientById: jest.fn(), - chainId: ChainId.mainnet, - onNftsStateChange: (listener) => nftController.subscribe(listener), - onPreferencesStateChange: (listener) => - preferences.subscribe(listener), - onNetworkStateChange: networkStateChangeNoop, - getOpenSeaApiKey: () => nftController.openSeaApiKey, - addNft: nftController.addNft.bind(nftController), - getNftState: () => nftController.state, + it('should detect mainnet truthy', async () => { + await withController( + { + mockNetworkState: { + selectedNetworkClientId: 'mainnet', }, - { interval: 10 }, - ); - nftsDetectionController.configure({ disabled: false }); - nftsDetectionController.start(); - expect(mockNfts.calledOnce).toBe(true); - setTimeout(() => { - expect(mockNfts.calledTwice).toBe(true); - resolve(''); - }, 15); - }); - }); - - it('should poll and detect NFTs by networkClientId on interval while on mainnet', async () => { - jest.useFakeTimers(); - const getNetworkClientById = jest.fn().mockImplementation(() => { - return { - configuration: { - chainId: ChainId.mainnet, + mockPreferencesState: { + selectedAddress: '', }, - provider: {}, - blockTracker: {}, - destroy: jest.fn(), - }; - }); - const testNftDetection = new NftDetectionController({ - chainId: ChainId.mainnet, - onNftsStateChange: (listener) => nftController.subscribe(listener), - onPreferencesStateChange: () => { - // don't do anything - }, - onNetworkStateChange: networkStateChangeNoop, - getOpenSeaApiKey: getOpenSeaApiKeyStub, - addNft: nftController.addNft.bind(nftController), - getNetworkClientById, - getNftState: () => nftController.state, - }); - preferences.setUseNftDetection(true); - const spy = jest - .spyOn(testNftDetection, 'detectNfts') - .mockImplementation(() => { - return Promise.resolve(); - }); + }, + ({ controller }) => { + expect(controller.isMainnet()).toBe(true); + }, + ); + }); - testNftDetection.startPollingByNetworkClientId('mainnet', { - address: '0x1', + it('should detect NFTs on Linea mainnet', async () => { + const selectedAddress = '0x1'; + const selectedAccount = createMockInternalAccount({ + address: selectedAddress, }); - await Promise.all([ - jest.advanceTimersByTime(DEFAULT_INTERVAL), - Promise.resolve(), - ]); - expect(spy.mock.calls).toHaveLength(1); - await Promise.all([ - jest.advanceTimersByTime(DEFAULT_INTERVAL), - Promise.resolve(), - ]); - expect(spy.mock.calls).toHaveLength(2); - expect(spy.mock.calls).toMatchObject([ - ['mainnet', '0x1'], - ['mainnet', '0x1'], - ]); - nftDetection.stopAllPolling(); - jest.runOnlyPendingTimers(); - jest.useRealTimers(); - }); + const mockGetSelectedAccount = jest.fn().mockReturnValue(selectedAccount); - it('should detect mainnet correctly', () => { - nftDetection.configure({ chainId: ChainId.mainnet }); - expect(nftDetection.isMainnet()).toBe(true); - nftDetection.configure({ chainId: ChainId.goerli }); - expect(nftDetection.isMainnet()).toBe(false); + await withController( + { + mockNetworkState: { + selectedNetworkClientId: InfuraNetworkType['linea-mainnet'], + }, + mockGetSelectedAccount, + }, + async ({ controller, controllerEvents }) => { + controllerEvents.triggerPreferencesStateChange({ + ...getDefaultPreferencesState(), + useNftDetection: true, + selectedAddress, + }); + // nock + const mockApiCall = nock(NFT_API_BASE_URL) + .get(`/users/${selectedAddress}/tokens`) + .query({ + continuation: '', + limit: '50', + chainIds: '59144', + includeTopBid: true, + }) + .reply(200, { + tokens: [], + }); + + // call detectNfts + await controller.detectNfts(['0xe708']); + + expect(mockApiCall.isDone()).toBe(true); + }, + ); }); - it('should not autodetect while not on mainnet', async () => { - await new Promise((resolve) => { - const mockNfts = sinon.stub( - NftDetectionController.prototype, - 'detectNfts', - ); - new NftDetectionController( - { - getNetworkClientById: jest.fn(), - chainId: ChainId.goerli, - onNftsStateChange: (listener) => nftController.subscribe(listener), - onPreferencesStateChange: (listener) => - preferences.subscribe(listener), - onNetworkStateChange: networkStateChangeNoop, - getOpenSeaApiKey: () => nftController.openSeaApiKey, - addNft: nftController.addNft.bind(nftController), - getNftState: () => nftController.state, + it('should detect mainnet falsy', async () => { + await withController( + { + mockNetworkState: { + selectedNetworkClientId: 'goerli', }, - { interval: 10, chainId: ChainId.goerli }, - ); - expect(mockNfts.called).toBe(false); - resolve(''); - }); + mockPreferencesState: { + selectedAddress: '', + }, + }, + ({ controller }) => { + expect(controller.isMainnet()).toBe(false); + }, + ); }); - it('should detect and add NFTs correctly', async () => { + it('should return when detectNfts is called on a not supported network for detection', async () => { const selectedAddress = '0x1'; - - nftDetection.configure({ - chainId: ChainId.mainnet, - selectedAddress, - }); - - nftController.configure({ - selectedAddress, + const selectedAccount = createMockInternalAccount({ + address: selectedAddress, }); - const { chainId } = nftDetection.config; - - await nftDetection.detectNfts(); - - const nfts = nftController.state.allNfts[selectedAddress][chainId]; - expect(nfts).toStrictEqual([ + const mockGetSelectedAccount = jest.fn().mockReturnValue(selectedAccount); + await withController( { - address: '0xebE4e5E773AFD2bAc25De0cFafa084CFb3cBf1eD', - description: 'Description 2574', - image: 'image/2574.png', - name: 'ID 2574', - tokenId: '2574', - standard: 'ERC721', - favorite: false, - isCurrentlyOwned: true, - }, - ]); + mockNetworkState: { + selectedNetworkClientId: 'goerli', + }, + mockPreferencesState: {}, + mockGetSelectedAccount, + }, + async ({ controller }) => { + const mockNfts = jest + .spyOn(controller, 'detectNfts') + .mockImplementation(); + + // nock + const mockApiCall = nock(NFT_API_BASE_URL) + .get(`/users/${selectedAddress}/tokens`) + .query({ + continuation: '', + limit: '50', + chainIds: '1', + includeTopBid: true, + }) + .reply(200, { + tokens: [], + }); + + // call detectNfts + await controller.detectNfts(['0x507'], { + userAddress: selectedAddress, + }); + + expect(mockNfts).toHaveBeenCalled(); + expect(mockApiCall.isDone()).toBe(false); + }, + ); }); - it('should detect and add NFTs by networkClientId correctly', async () => { + it('should detect and add NFTs correctly when blockaid result is not included in response', async () => { + const mockAddNfts = jest.fn(); const selectedAddress = '0x1'; - - await nftDetection.detectNfts('mainnet', '0x1'); - - const nfts = nftController.state.allNfts[ChainId.mainnet][selectedAddress]; - expect(nfts).toStrictEqual([ + const selectedAccount = createMockInternalAccount({ + address: selectedAddress, + }); + const mockGetSelectedAccount = jest.fn().mockReturnValue(selectedAccount); + await withController( { - address: '0xebE4e5E773AFD2bAc25De0cFafa084CFb3cBf1eD', - description: 'Description 2574', - image: 'image/2574.png', - name: 'ID 2574', - tokenId: '2574', - standard: 'ERC721', - favorite: false, - isCurrentlyOwned: true, - }, - ]); - nftDetection.stopAllPolling(); + options: { addNfts: mockAddNfts }, + mockPreferencesState: {}, + mockGetSelectedAccount, + }, + async ({ controller, controllerEvents }) => { + controllerEvents.triggerPreferencesStateChange({ + ...getDefaultPreferencesState(), + useNftDetection: true, + }); + + // Wait for detect call triggered by preferences state change to settle + await jestAdvanceTime({ + duration: 1, + }); + mockAddNfts.mockReset(); + + await controller.detectNfts(['0x1']); + + expect(mockAddNfts).toHaveBeenCalledWith( + [ + { + tokenAddress: '0xCE7ec4B2DfB30eB6c0BB5656D33aAd6BFb4001Fc', + tokenId: '2577', + nftMetadata: { + description: + "Redacted Remilio Babies is a collection of 10,000 neochibi pfpNFT's expanding the Milady Maker paradigm with the introduction of young J.I.T. energy and schizophrenic reactionary aesthetics. We are #REMILIONAIREs.", + image: 'https://imgtest', + imageThumbnail: 'https://imgSmall', + name: 'Remilio 632', + standard: 'ERC721', + imageOriginal: 'https://remilio.org/remilio/632.png', + rarityRank: 8872, + rarityScore: 343.443, + chainId: 1, + }, + }, + { + tokenAddress: '0x0B0fa4fF58D28A88d63235bd0756EDca69e49e6d', + tokenId: '2578', + nftMetadata: { + description: 'Description 2578', + image: 'https://imgtest', + imageThumbnail: 'https://imgSmall', + name: 'ID 2578', + standard: 'ERC721', + imageOriginal: 'https://remilio.org/remilio/632.png', + rarityRank: 8872, + rarityScore: 343.443, + chainId: 1, + }, + }, + { + tokenAddress: '0xebE4e5E773AFD2bAc25De0cFafa084CFb3cBf1eD', + tokenId: '2574', + nftMetadata: { + description: 'Description 2574', + image: 'image/2574.png', + name: 'ID 2574', + standard: 'ERC721', + imageOriginal: 'imageOriginal/2574.png', + chainId: 1, + }, + }, + ], + selectedAccount.address, + Source.Detected, + ); + }, + ); }); - it('should not add nfts for which no contract information can be fetched', async () => { + it('should detect and add NFTs correctly with an array of chainIds', async () => { + const mockAddNfts = jest.fn(); const selectedAddress = '0x1'; - - nftDetection.configure({ - chainId: ChainId.mainnet, - selectedAddress, - }); - - nftController.configure({ - selectedAddress, + const selectedAccount = createMockInternalAccount({ + address: selectedAddress, }); - - sinon - .stub(nftController, 'getNftContractInformationFromApi' as any) - .returns(undefined); - - sinon - .stub(nftController, 'getNftInformationFromApi' as any) - .returns(undefined); - - await nftDetection.detectNfts(); - - expect(nftController.state.allNfts).toStrictEqual({}); + const mockGetSelectedAccount = jest.fn().mockReturnValue(selectedAccount); + await withController( + { + options: { addNfts: mockAddNfts }, + mockPreferencesState: {}, + mockGetSelectedAccount, + }, + async ({ controller, controllerEvents }) => { + controllerEvents.triggerPreferencesStateChange({ + ...getDefaultPreferencesState(), + useNftDetection: true, + }); + + // Wait for detect call triggered by preferences state change to settle + await jestAdvanceTime({ + duration: 1, + }); + mockAddNfts.mockReset(); + + await controller.detectNfts(['0x1', '0xe708']); + expect(mockAddNfts).toHaveBeenCalledWith( + [ + { + tokenAddress: '0xebE4e5E773AFD2bAc25De0cFafa084CFb3cBf1e5', + tokenId: '2', + nftMetadata: { + description: 'Description 2', + image: 'image/2.png', + name: 'ID 2', + standard: 'ERC721', + imageOriginal: 'imageOriginal/2.png', + chainId: 59144, + }, + }, + { + tokenAddress: '0xebE4e5E773AFD2bAc25De0cFafa084CFb3cBf1eD', + tokenId: '2574', + nftMetadata: { + description: 'Description 2574', + image: 'image/2574.png', + name: 'ID 2574', + standard: 'ERC721', + imageOriginal: 'imageOriginal/2574.png', + chainId: 1, + }, + }, + ], + selectedAccount.address, + Source.Detected, + ); + }, + ); }); - it('should detect, add NFTs and do nor remove not detected NFTs correctly', async () => { - const selectedAddress = '0x1'; - nftDetection.configure({ - chainId: ChainId.mainnet, - selectedAddress, - }); - nftController.configure({ selectedAddress }); - - const { chainId } = nftDetection.config; - - await nftController.addNft( - '0xebE4e5E773AFD2bAc25De0cFafa084CFb3cBf1eD', - '2573', + it('should detect and add NFTs by networkClientId correctly', async () => { + const mockAddNfts = jest.fn(); + const mockGetSelectedAccount = jest.fn(); + await withController( { - nftMetadata: { - description: 'Description 2573', - image: 'image/2573.png', - name: 'ID 2573', - standard: 'ERC721', + options: { + addNfts: mockAddNfts, }, + mockGetSelectedAccount, + }, + async ({ controller, controllerEvents }) => { + const selectedAddress = '0x1'; + const updatedSelectedAccount = createMockInternalAccount({ + address: selectedAddress, + }); + mockGetSelectedAccount.mockReturnValue(updatedSelectedAccount); + controllerEvents.triggerPreferencesStateChange({ + ...getDefaultPreferencesState(), + useNftDetection: true, + }); + // Wait for detect call triggered by preferences state change to settle + await jestAdvanceTime({ + duration: 1, + }); + mockAddNfts.mockReset(); + + await controller.detectNfts(['0x1'], { + userAddress: '0x9', + }); + + expect(mockAddNfts).toHaveBeenCalledWith( + [ + { + tokenAddress: '0xebE4e5E773AFD2bAc25De0cFafa084CFb3cBf1eD', + tokenId: '2574', + nftMetadata: { + description: 'Description 2574', + image: 'image/2574.png', + name: 'ID 2574', + standard: 'ERC721', + imageOriginal: 'imageOriginal/2574.png', + chainId: 1, + }, + }, + ], + '0x9', + Source.Detected, + ); }, ); + }); - await nftDetection.detectNfts(); - - const nfts = nftController.state.allNfts[selectedAddress][chainId]; - - expect(nfts).toStrictEqual([ + it('should not detect NFTs that exist in the ignoreList', async () => { + const mockAddNfts = jest.fn(); + const mockGetSelectedAccount = jest.fn(); + const mockGetNftState = jest.fn().mockImplementation(() => { + return { + ...getDefaultNftControllerState(), + ignoredNfts: [ + // This address and token ID are always detected, as determined by + // the nock mocks setup in `beforeEach` + // TODO: Migrate nock setup into individual tests + { + address: '0xebE4e5E773AFD2bAc25De0cFafa084CFb3cBf1eD', + tokenId: '2574', + }, + ], + }; + }); + const selectedAddress = '0x9'; + const selectedAccount = createMockInternalAccount({ + address: selectedAddress, + }); + await withController( { - address: '0xebE4e5E773AFD2bAc25De0cFafa084CFb3cBf1eD', - description: 'Description 2573', - image: 'image/2573.png', - name: 'ID 2573', - standard: 'ERC721', - tokenId: '2573', - favorite: false, - isCurrentlyOwned: true, + options: { addNfts: mockAddNfts, getNftState: mockGetNftState }, + mockPreferencesState: { selectedAddress }, + mockGetSelectedAccount, }, - { - address: '0xebE4e5E773AFD2bAc25De0cFafa084CFb3cBf1eD', - description: 'Description 2574', - image: 'image/2574.png', - name: 'ID 2574', - tokenId: '2574', - standard: 'ERC721', - favorite: false, - isCurrentlyOwned: true, - }, - ]); + async ({ controller, controllerEvents }) => { + mockGetSelectedAccount.mockReturnValue(selectedAccount); + controllerEvents.triggerPreferencesStateChange({ + ...getDefaultPreferencesState(), + useNftDetection: true, + }); + // Wait for detect call triggered by preferences state change to settle + await jestAdvanceTime({ + duration: 1, + }); + mockAddNfts.mockReset(); + + await controller.detectNfts(['0x1']); + + // Should be called with empty array when all NFTs are in ignore list + expect(mockAddNfts).toHaveBeenCalledWith([], '0x9', Source.Detected); + }, + ); }); - it('should not autodetect NFTs that exist in the ignoreList', async () => { - const selectedAddress = '0x2'; - nftDetection.configure({ - chainId: ChainId.mainnet, - selectedAddress: '0x2', - }); - nftController.configure({ selectedAddress }); - - const { chainId } = nftDetection.config; + it('should not detect and add NFTs if there is no selectedAddress', async () => { + const mockAddNfts = jest.fn(); + // mock uninitialised selectedAccount when it is '' + const mockGetSelectedAccount = jest.fn().mockReturnValue({ address: '' }); + await withController( + { + options: { addNfts: mockAddNfts }, + mockPreferencesState: {}, + mockGetSelectedAccount, + }, + async ({ controller, controllerEvents }) => { + controllerEvents.triggerPreferencesStateChange({ + ...getDefaultPreferencesState(), + useNftDetection: true, // auto-detect is enabled so it proceeds to check userAddress + }); - await nftDetection.detectNfts(); - expect(nftController.state.allNfts[selectedAddress][chainId]).toHaveLength( - 1, - ); - expect(nftController.state.ignoredNfts).toHaveLength(0); - nftController.removeAndIgnoreNft( - '0x1d963688FE2209A98dB35C67A041524822Cf04ff', - '2577', - ); + await controller.detectNfts(['0x1']); - expect(nftController.state.ignoredNfts).toHaveLength(1); - await nftDetection.detectNfts(); - expect(nftController.state.allNfts[selectedAddress][chainId]).toHaveLength( - 0, + expect(mockAddNfts).not.toHaveBeenCalled(); + }, ); }); - it('should not detect and add NFTs if there is no selectedAddress', async () => { - const selectedAddress = ''; - nftDetection.configure({ - chainId: ChainId.mainnet, - selectedAddress, - }); - const { chainId } = nftDetection.config; - await nftDetection.detectNfts(); - const { allNfts } = nftController.state; - expect(allNfts[selectedAddress]?.[chainId]).toBeUndefined(); + it('should return true if mainnet is detected', async () => { + const mockAddNfts = jest.fn(); + const provider = new FakeProvider(); + const mockNetworkClient: NetworkClient = { + configuration: { + chainId: ChainId.mainnet, + rpcUrl: 'https://test.network', + failoverRpcUrls: [], + ticker: 'TEST', + type: NetworkClientType.Custom, + }, + provider, + blockTracker: new FakeBlockTracker({ provider }), + destroy: () => { + // do nothing + }, + }; + await withController( + { options: { addNfts: mockAddNfts } }, + async ({ controller }) => { + const result = controller.isMainnetByNetworkClientId(mockNetworkClient); + expect(result).toBe(true); + }, + ); }); - it('should not detect and add NFTs to the wrong selectedAddress', async () => { - nftDetection.configure({ - chainId: ChainId.mainnet, - selectedAddress: '0x9', - }); - const { chainId } = nftDetection.config; - - nftController.configure({ selectedAddress: '0x9' }); - nftDetection.detectNfts(); - nftDetection.configure({ selectedAddress: '0x12' }); - nftController.configure({ selectedAddress: '0x12' }); - await new Promise((res) => setTimeout(() => res(true), 1000)); - expect(nftDetection.config.selectedAddress).toBe('0x12'); - - expect( - nftController.state.allNfts[nftDetection.config.selectedAddress]?.[ - chainId - ], - ).toBeUndefined(); + it('should not detectNfts when disabled is false and useNftDetection is true', async () => { + await withController( + { options: { disabled: false } }, + async ({ controller, controllerEvents }) => { + const mockNfts = jest + .spyOn(controller, 'detectNfts') + .mockImplementation(); + controllerEvents.triggerPreferencesStateChange({ + ...getDefaultPreferencesState(), + useNftDetection: true, + }); + // Wait for detect call triggered by preferences state change to settle + await jestAdvanceTime({ + duration: 1, + }); + + expect(mockNfts).not.toHaveBeenCalled(); + }, + ); }); it('should not detect and add NFTs if preferences controller useNftDetection is set to false', async () => { - preferences.setUseNftDetection(false); + const mockAddNfts = jest.fn(); + const mockGetSelectedAccount = jest.fn(); const selectedAddress = '0x9'; - nftDetection.configure({ - chainId: ChainId.mainnet, - selectedAddress, + const selectedAccount = createMockInternalAccount({ + address: selectedAddress, }); - const { chainId } = nftController.config; - nftDetection.detectNfts(); - expect( - nftController.state.allNfts[selectedAddress]?.[chainId], - ).toBeUndefined(); + await withController( + { + options: { addNfts: mockAddNfts, disabled: false }, + mockPreferencesState: {}, + mockGetSelectedAccount, + }, + async ({ controller, controllerEvents }) => { + mockGetSelectedAccount.mockReturnValue(selectedAccount); + controllerEvents.triggerPreferencesStateChange({ + ...getDefaultPreferencesState(), + useNftDetection: false, + }); + // Wait for detect call triggered by preferences state change to settle + await jestAdvanceTime({ + duration: 1, + }); + mockAddNfts.mockReset(); + + await controller.detectNfts(['0x1']); + + expect(mockAddNfts).not.toHaveBeenCalled(); + }, + ); }); - it('should not detect and add NFTs if preferences controller openSeaEnabled is set to false', async () => { - preferences.setOpenSeaEnabled(false); - const selectedAddress = '0x9'; - nftDetection.configure({ - chainId: ChainId.mainnet, - selectedAddress, - }); - const { chainId } = nftController.config; - nftDetection.detectNfts(); - expect( - nftController.state.allNfts[selectedAddress]?.[chainId], - ).toBeUndefined(); + it('should not call addNfts when the request to Nft API call throws', async () => { + const selectedAccount = createMockInternalAccount({ address: '0x3' }); + nock(NFT_API_BASE_URL) + .get(`/users/${selectedAccount.address}/tokens`) + .query({ + continuation: '', + limit: '50', + chainIds: '1', + includeTopBid: true, + }) + .replyWithError(new Error('Failed to fetch')) + .persist(); + const mockAddNfts = jest.fn(); + const mockGetSelectedAccount = jest.fn().mockReturnValue(selectedAccount); + await withController( + { + options: { + addNfts: mockAddNfts, + }, + mockGetSelectedAccount, + }, + async ({ controller, controllerEvents }) => { + controllerEvents.triggerPreferencesStateChange({ + ...getDefaultPreferencesState(), + useNftDetection: true, + }); + // Wait for detect call triggered by preferences state change to settle + await jestAdvanceTime({ + duration: 1, + }); + mockAddNfts.mockReset(); + + // eslint-disable-next-line jest/require-to-throw-message + await expect(() => controller.detectNfts(['0x1'])).rejects.toThrow(); + + expect(mockAddNfts).not.toHaveBeenCalled(); + }, + ); }); - it('should not add NFT if NFT or NFT contract has no information to display', async () => { - const nftHH2574 = { - address: '0xebE4e5E773AFD2bAc25De0cFafa084CFb3cBf1eD', - description: 'Description 2574', - image: 'image/2574.png', - name: 'ID 2574', - tokenId: '2574', - standard: 'ERC721', - favorite: false, - isCurrentlyOwned: true, - }; - const nftGG2574 = { - address: '0xCE7ec4B2DfB30eB6c0BB5656D33aAd6BFb4001Fc', - description: 'Description 2574', - image: 'image/2574.png', - name: 'ID 2574', - tokenId: '2574', - standard: 'ERC721', - favorite: false, - isCurrentlyOwned: true, - }; - const nftII2577 = { - address: '0x0B0fa4fF58D28A88d63235bd0756EDca69e49e6d', - description: 'Description 2577', - image: 'image/2577.png', - name: 'ID 2577', - tokenId: '2577', - standard: 'ERC721', - favorite: false, - isCurrentlyOwned: true, - }; - const nftContractHH = { - address: '0xebE4e5E773AFD2bAc25De0cFafa084CFb3cBf1eD', - description: 'Description HH', - logo: 'url HH', - name: 'Name HH', - symbol: 'HH', - totalSupply: 10, - }; - const nftContractGG = { - address: '0xCE7ec4B2DfB30eB6c0BB5656D33aAd6BFb4001Fc', - description: 'Description GG', - logo: 'url GG', - name: 'Name GG', - symbol: 'GG', - totalSupply: 10, - }; - const nftContractII = { - address: '0x0B0fa4fF58D28A88d63235bd0756EDca69e49e6d', - description: 'Description II', - logo: 'url II', - name: 'Name II', - symbol: 'II', - totalSupply: 10, - }; + it('should rethrow error when Nft APi server fails with error other than fetch failure', async () => { + const selectedAddress = '0x4'; + const selectedAccount = createMockInternalAccount({ + address: selectedAddress, + }); + const mockGetSelectedAccount = jest.fn().mockReturnValue(selectedAccount); + await withController( + { mockPreferencesState: {}, mockGetSelectedAccount }, + async ({ controller, controllerEvents }) => { + controllerEvents.triggerPreferencesStateChange({ + ...getDefaultPreferencesState(), + useNftDetection: true, + }); + // Wait for detect call triggered by preferences state change to settle + await jestAdvanceTime({ + duration: 1, + }); + // This mock is for the call under test + nock(NFT_API_BASE_URL) + .get(`/users/${selectedAddress}/tokens`) + .query({ + continuation: '', + limit: '50', + chainIds: '1', + includeTopBid: true, + }) + .replyWithError(new Error('UNEXPECTED ERROR')); + + await expect(() => controller.detectNfts(['0x1'])).rejects.toThrow( + 'UNEXPECTED ERROR', + ); + }, + ); + }); + it('should rethrow error when attempt to add NFT fails', async () => { + const mockAddNfts = jest.fn(); + const mockGetSelectedAccount = jest.fn(); const selectedAddress = '0x1'; - nftDetection.configure({ - selectedAddress, - chainId: ChainId.mainnet, + const selectedAccount = createMockInternalAccount({ + address: selectedAddress, }); + await withController( + { + options: { addNfts: mockAddNfts }, + mockPreferencesState: {}, + mockGetSelectedAccount, + }, + async ({ controller, controllerEvents }) => { + mockGetSelectedAccount.mockReturnValue(selectedAccount); + controllerEvents.triggerPreferencesStateChange({ + ...getDefaultPreferencesState(), + useNftDetection: true, + }); + // Wait for detect call triggered by preferences state change to settle + await jestAdvanceTime({ + duration: 1, + }); + mockAddNfts.mockReset(); + mockAddNfts.mockRejectedValueOnce(new Error('UNEXPECTED ERROR')); + + await expect( + async () => await controller.detectNfts(['0x1']), + ).rejects.toThrow('UNEXPECTED ERROR'); + }, + ); + }); - nftController.configure({ - selectedAddress, - }); + it('should not call detectNfts when settings change', async () => { + const mockGetSelectedAccount = jest + .fn() + .mockReturnValue(defaultSelectedAccount); + await withController( + { + options: {}, + mockGetSelectedAccount, + }, + async ({ controller, controllerEvents }) => { + const detectNfts = jest + .spyOn(controller, 'detectNfts') + .mockImplementation(); + + // Repeated preference changes should only trigger 1 detection + for (let i = 0; i < 5; i++) { + controllerEvents.triggerPreferencesStateChange({ + ...getDefaultPreferencesState(), + useNftDetection: true, + securityAlertsEnabled: true, + }); + } + await jestAdvanceTime({ duration: 1 }); + expect(detectNfts).not.toHaveBeenCalled(); + + // Irrelevant preference changes shouldn't trigger a detection + controllerEvents.triggerPreferencesStateChange({ + ...getDefaultPreferencesState(), + useNftDetection: true, + securityAlertsEnabled: true, + }); + await jestAdvanceTime({ duration: 1 }); + expect(detectNfts).not.toHaveBeenCalled(); + }, + ); + }); - const { chainId } = nftDetection.config; - await nftDetection.detectNfts(); - // First fetch to API, only gets information from contract ending in HH - expect(nftController.state.allNfts[selectedAddress][chainId]).toStrictEqual( - [nftHH2574], + it('should only updates once when detectNfts called twice', async () => { + const mockAddNfts = jest.fn(); + const mockGetSelectedAccount = jest.fn(); + const selectedAddress = '0x9'; + const selectedAccount = createMockInternalAccount({ + address: selectedAddress, + }); + await withController( + { + options: { addNfts: mockAddNfts, disabled: false }, + mockPreferencesState: {}, + mockGetSelectedAccount, + }, + async ({ controller, controllerEvents }) => { + mockGetSelectedAccount.mockReturnValue(selectedAccount); + controllerEvents.triggerPreferencesStateChange({ + ...getDefaultPreferencesState(), + useNftDetection: true, + }); + + await Promise.all([ + controller.detectNfts(['0x1']), + controller.detectNfts(['0x1']), + ]); + + expect(mockAddNfts).toHaveBeenCalledTimes(1); + }, ); + }); - expect( - nftController.state.allNftContracts[selectedAddress][chainId], - ).toStrictEqual([nftContractHH]); - // During next call of assets detection, API succeeds returning contract ending in gg information + it('should stop after first page when firstPageOnly is true', async () => { + const mockAddNfts = jest.fn(); + const selectedAddress = '0xFirstPage'; + const selectedAccount = createMockInternalAccount({ + address: selectedAddress, + }); + const mockGetSelectedAccount = jest.fn().mockReturnValue(selectedAccount); - nock(OPENSEA_PROXY_URL) - .get(`/asset_contract/0xCE7ec4B2DfB30eB6c0BB5656D33aAd6BFb4001Fc`) + // Mock first page with continuation token + nock(NFT_API_BASE_URL) + .get( + `/users/${selectedAddress}/tokens?chainIds=1&limit=50&includeTopBid=true&continuation=`, + ) .reply(200, { - description: 'Description GG', - symbol: 'GG', - total_supply: 10, - collection: { - image_url: 'url GG', - name: 'Name GG', - }, - }) - .get(`/asset_contract/0x0B0fa4fF58D28A88d63235bd0756EDca69e49e6d`) - .reply(200, { - description: 'Description II', - symbol: 'II', - total_supply: 10, - collection: { - image_url: 'url II', - name: 'Name II', - }, - }) - .get(`/assets?owner=0x1&offset=0&limit=50`) - .reply(200, { - assets: [ - { - asset_contract: { - address: '0x0B0fa4fF58D28A88d63235bd0756EDca69e49e6d', - schema_name: 'ERC721', - }, - collection: { - name: 'Collection 2577', - image_url: 'url', - }, - description: 'Description 2577', - image_url: 'image/2577.png', - name: 'ID 2577', - token_id: '2577', - }, + tokens: [ { - asset_contract: { - address: '0xCE7ec4B2DfB30eB6c0BB5656D33aAd6BFb4001Fc', - schema_name: 'ERC721', - }, - collection: { - name: 'Collection 2574', - image_url: 'url', - }, - description: 'Description 2574', - image_url: 'image/2574.png', - name: 'ID 2574', - token_id: '2574', + token: { + chainId: 1, + contract: '0xtest1', + kind: 'erc721', + name: 'ID 2574', + description: 'Description 2574', + image: 'image/2574.png', + tokenId: '2574', + metadata: { + imageOriginal: 'imageOriginal/2574.png', + imageMimeType: 'image/png', + tokenURI: 'tokenURITest', + }, + isSpam: false, + }, + blockaidResult: { + result_type: BlockaidResultType.Benign, + }, }, + ], + continuation: 'next-page-token', + }); + + // Mock second page that should NOT be called + const secondPageSpy = nock(NFT_API_BASE_URL) + .get( + `/users/${selectedAddress}/tokens?chainIds=1&limit=50&includeTopBid=true&continuation=next-page-token`, + ) + .reply(200, { + tokens: [ { - asset_contract: { - address: '0xebE4e5E773AFD2bAc25De0cFafa084CFb3cBf1eD', - schema_name: 'ERC721', - }, - collection: { - name: 'Collection 2574', - image_url: 'url', - }, - description: 'Description 2574', - image_url: 'image/2574.png', - name: 'ID 2574', - token_id: '2574', + token: { + chainId: 1, + contract: '0xtest2', + kind: 'erc721', + name: 'ID 2575', + description: 'Description 2575', + image: 'image/2575.png', + tokenId: '2575', + metadata: { + imageOriginal: 'imageOriginal/2575.png', + imageMimeType: 'image/png', + tokenURI: 'tokenURITest', + }, + isSpam: false, + }, }, ], - }) - .get(`/assets?owner=0x1&offset=50&limit=50`) - .reply(200, { - assets: [], }); - // Now user should have respective NFTs - await nftDetection.detectNfts(); - expect( - nftController.state.allNftContracts[selectedAddress][chainId], - ).toStrictEqual([nftContractHH, nftContractII, nftContractGG]); - - expect(nftController.state.allNfts[selectedAddress][chainId]).toStrictEqual( - [nftHH2574, nftII2577, nftGG2574], + await withController( + { + options: { addNfts: mockAddNfts }, + mockPreferencesState: {}, + mockGetSelectedAccount, + }, + async ({ controller, controllerEvents }) => { + controllerEvents.triggerPreferencesStateChange({ + ...getDefaultPreferencesState(), + useNftDetection: true, + }); + + await jestAdvanceTime({ + duration: 1, + }); + mockAddNfts.mockReset(); + + await controller.detectNfts(['0x1'], { firstPageOnly: true }); + + // Verify second page was NOT called because we used firstPageOnly + expect(secondPageSpy.isDone()).toBe(false); + + // Verify only first page NFTs were added + expect(mockAddNfts).toHaveBeenCalledTimes(1); + expect(mockAddNfts).toHaveBeenCalledWith( + [ + { + tokenAddress: '0xtest1', + tokenId: '2574', + nftMetadata: { + description: 'Description 2574', + image: 'image/2574.png', + name: 'ID 2574', + standard: 'ERC721', + imageOriginal: 'imageOriginal/2574.png', + chainId: 1, + }, + }, + ], + selectedAccount.address, + Source.Detected, + ); + }, ); }); - it('should not fallback to use OpenSea API directly when the OpenSea proxy server is down or responds with a failure', async () => { - const selectedAddress = '0x3'; - - getOpenSeaApiKeyStub.mockImplementation(() => 'FAKE API KEY'); - nftController.setApiKey('FAKE API KEY'); - - nock('https://proxy.metafi.codefi.network:443', { - encodedQueryParams: true, - }) - .get('/opensea/v1/api/v1/assets') - .query({ owner: selectedAddress, offset: '0', limit: '50' }) - .replyWithError(new Error('Failed to fetch')); - - nock('https://proxy.metafi.codefi.network:443', { - encodedQueryParams: true, - }) - .get('/opensea/v1/api/v1/assets') - .query({ owner: selectedAddress, offset: '50', limit: '50' }) - .replyWithError(new Error('Failed to fetch')); - - nock('https://api.opensea.io:443', { encodedQueryParams: true }) - .get('/api/v1/assets') - .query({ owner: selectedAddress, offset: '0', limit: '50' }) + it('should stop pagination when signal is aborted', async () => { + const mockAddNfts = jest.fn(); + const selectedAddress = '0xAbortSignal'; + const selectedAccount = createMockInternalAccount({ + address: selectedAddress, + }); + const mockGetSelectedAccount = jest.fn().mockReturnValue(selectedAccount); + + // Mock first page with continuation token + nock(NFT_API_BASE_URL) + .get( + `/users/${selectedAddress}/tokens?chainIds=1&limit=50&includeTopBid=true&continuation=`, + ) .reply(200, { - assets: [ + tokens: [ { - asset_contract: { - address: '0x1d963688fe2209a98db35c67a041524822cf04ff', - schema_name: 'ERC721', - }, - collection: { - name: 'DIRECT FROM OPENSEA', - image_url: 'URL', - }, - description: 'DESCRIPTION: DIRECT FROM OPENSEA', - image_original_url: 'DIRECT FROM OPENSEA.jpg', - name: 'NAME: DIRECT FROM OPENSEA', - token_id: '2577', + token: { + chainId: 1, + contract: '0xtest1', + kind: 'erc721', + name: 'ID 2574', + description: 'Description 2574', + image: 'image/2574.png', + tokenId: '2574', + metadata: { + imageOriginal: 'imageOriginal/2574.png', + imageMimeType: 'image/png', + tokenURI: 'tokenURITest', + }, + isSpam: false, + }, + blockaidResult: { + result_type: BlockaidResultType.Benign, + }, }, ], + continuation: 'next-page-token', }); - nock('https://api.opensea.io:443', { encodedQueryParams: true }) - .get('/api/v1/assets') - .query({ owner: selectedAddress, offset: '50', limit: '50' }) - .reply(200, { - assets: [], - }); - - nock('https://api.opensea.io:443') - .get(`/api/v1/asset_contract/0x1d963688FE2209A98dB35C67A041524822Cf04ff`) + // Mock second page that should NOT be called + const secondPageSpy = nock(NFT_API_BASE_URL) + .get( + `/users/${selectedAddress}/tokens?chainIds=1&limit=50&includeTopBid=true&continuation=next-page-token`, + ) .reply(200, { - description: 'Description', - image_url: 'url', - name: 'Name', - symbol: 'FOO', - total_supply: 0, - collection: { - image_url: 'url', - name: 'Name', - }, + tokens: [ + { + token: { + chainId: 1, + contract: '0xtest2', + kind: 'erc721', + name: 'ID 2575', + description: 'Description 2575', + image: 'image/2575.png', + tokenId: '2575', + metadata: { + imageOriginal: 'imageOriginal/2575.png', + imageMimeType: 'image/png', + tokenURI: 'tokenURITest', + }, + isSpam: false, + }, + }, + ], }); - nftDetection.configure({ - chainId: ChainId.mainnet, - selectedAddress, - }); - - nftController.configure({ - selectedAddress, - }); - - await nftDetection.detectNfts(); + await withController( + { + options: { addNfts: mockAddNfts }, + mockPreferencesState: {}, + mockGetSelectedAccount, + }, + async ({ controller, controllerEvents }) => { + controllerEvents.triggerPreferencesStateChange({ + ...getDefaultPreferencesState(), + useNftDetection: true, + }); + + await jestAdvanceTime({ + duration: 1, + }); + mockAddNfts.mockReset(); + + const abortController = new AbortController(); + // Abort the signal immediately + abortController.abort(); + + await controller.detectNfts(['0x1'], { + signal: abortController.signal, + }); + + // Verify second page was NOT called because signal was aborted + expect(secondPageSpy.isDone()).toBe(false); + + // Verify only first page NFTs were added + expect(mockAddNfts).toHaveBeenCalledTimes(1); + expect(mockAddNfts).toHaveBeenCalledWith( + [ + { + tokenAddress: '0xtest1', + tokenId: '2574', + nftMetadata: { + description: 'Description 2574', + image: 'image/2574.png', + name: 'ID 2574', + standard: 'ERC721', + imageOriginal: 'imageOriginal/2574.png', + chainId: 1, + }, + }, + ], + selectedAccount.address, + Source.Detected, + ); + }, + ); + }); +}); - expect(nftController.state.allNfts[selectedAddress]).toBeUndefined(); +/** + * A collection of mock external controller events. + */ +type ControllerEvents = { + triggerPreferencesStateChange: (state: PreferencesState) => void; + triggerNetworkStateChange: (state: NetworkState) => void; +}; + +type WithControllerCallback = ({ + controller, +}: { + controller: NftDetectionController; + controllerEvents: ControllerEvents; +}) => Promise | ReturnValue; + +type WithControllerOptions = { + options?: Partial[0]>; + mockNetworkClientConfigurationsByNetworkClientId?: Record< + NetworkClientId, + NetworkClientConfiguration + >; + mockNetworkState?: Partial; + mockPreferencesState?: Partial; + mockGetSelectedAccount?: jest.Mock; + mockFindNetworkClientIdByChainId?: jest.Mock< + NetworkController['findNetworkClientIdByChainId'] + >; +}; + +type WithControllerArgs = + | [WithControllerCallback] + | [WithControllerOptions, WithControllerCallback]; + +/** + * Builds a controller based on the given options, and calls the given function + * with that controller. + * + * @param args - Either a function, or an options bag + a function. The options + * bag accepts controller options and config; the function + * will be called with the built controller. + * @returns Whatever the callback returns. + */ +async function withController( + ...args: WithControllerArgs +): Promise { + const [ + { + options = {}, + mockNetworkClientConfigurationsByNetworkClientId = {}, + mockFindNetworkClientIdByChainId = {}, + mockNetworkState = {}, + mockPreferencesState = {}, + mockGetSelectedAccount = jest + .fn() + .mockReturnValue(defaultSelectedAccount), + }, + testFunction, + ] = args.length === 2 ? args : [{}, args[0]]; + + const messenger: RootMessenger = new Messenger({ + namespace: MOCK_ANY_NAMESPACE, }); - it('should rethrow error when OpenSea proxy server fails with error other than fetch failure', async () => { - const selectedAddress = '0x4'; - nock('https://proxy.metafi.codefi.network:443', { - encodedQueryParams: true, - }) - .get('/opensea/v1/api/v1/assets') - .query({ owner: selectedAddress, offset: '0', limit: '50' }) - .replyWithError(new Error('UNEXPECTED ERROR')); - - nftDetection.configure({ - chainId: ChainId.mainnet, - selectedAddress, - }); + messenger.registerActionHandler( + 'NetworkController:getState', + jest.fn().mockReturnValue({ + ...getDefaultNetworkControllerState(), + ...mockNetworkState, + }), + ); + + messenger.registerActionHandler( + 'AccountsController:getSelectedAccount', + mockGetSelectedAccount, + ); + + const getNetworkClientById = buildMockGetNetworkClientById( + mockNetworkClientConfigurationsByNetworkClientId, + ); + const findNetworkClientIdByChainId = buildMockFindNetworkClientIdByChainId( + mockFindNetworkClientIdByChainId, + ); + + messenger.registerActionHandler( + 'NetworkController:getNetworkClientById', + getNetworkClientById, + ); + + messenger.registerActionHandler( + 'NetworkController:findNetworkClientIdByChainId', + findNetworkClientIdByChainId, + ); + + messenger.registerActionHandler( + 'PreferencesController:getState', + jest.fn().mockReturnValue({ + ...getDefaultPreferencesState(), + ...mockPreferencesState, + }), + ); + + const nftDetectionControllerMessenger = new Messenger< + typeof controllerName, + AllActions, + AllEvents, + RootMessenger + >({ + namespace: controllerName, + parent: messenger, + }); + messenger.delegate({ + messenger: nftDetectionControllerMessenger, + actions: [ + 'NetworkController:getState', + 'NetworkController:getNetworkClientById', + 'PreferencesController:getState', + 'AccountsController:getSelectedAccount', + 'NetworkController:findNetworkClientIdByChainId', + ], + events: [ + 'NetworkController:stateChange', + 'PreferencesController:stateChange', + ], + }); - nftController.configure({ - selectedAddress, - }); + const controller = new NftDetectionController({ + messenger: nftDetectionControllerMessenger, + disabled: true, + addNfts: jest.fn(), + getNftState: getDefaultNftControllerState, + ...options, + }); - await expect(() => nftDetection.detectNfts()).rejects.toThrow( - 'UNEXPECTED ERROR', - ); + const controllerEvents = { + triggerPreferencesStateChange: (state: PreferencesState): void => { + messenger.publish('PreferencesController:stateChange', state, []); + }, + triggerNetworkStateChange: (state: NetworkState): void => { + messenger.publish('NetworkController:stateChange', state, []); + }, + }; + + return await testFunction({ + controller, + controllerEvents, }); -}); +} diff --git a/packages/assets-controllers/src/NftDetectionController.ts b/packages/assets-controllers/src/NftDetectionController.ts index 05fae4287da..a2c508d221a 100644 --- a/packages/assets-controllers/src/NftDetectionController.ts +++ b/packages/assets-controllers/src/NftDetectionController.ts @@ -1,33 +1,78 @@ -import type { BaseConfig, BaseState } from '@metamask/base-controller'; +import type { AccountsControllerGetSelectedAccountAction } from '@metamask/accounts-controller'; +import type { ApprovalControllerAddRequestAction } from '@metamask/approval-controller'; +import { BaseController } from '@metamask/base-controller'; +import type { + ControllerGetStateAction, + ControllerStateChangeEvent, +} from '@metamask/base-controller'; import { - OPENSEA_PROXY_URL, - fetchWithErrorHandling, toChecksumHexAddress, ChainId, + NFT_API_BASE_URL, + NFT_API_VERSION, + convertHexToDecimal, + handleFetch, } from '@metamask/controller-utils'; +import type { Messenger } from '@metamask/messenger'; import type { - NetworkClientId, - NetworkController, - NetworkState, + NetworkClient, + NetworkControllerFindNetworkClientIdByChainIdAction, + NetworkControllerGetNetworkClientByIdAction, + NetworkControllerGetStateAction, + NetworkControllerStateChangeEvent, } from '@metamask/network-controller'; -import type { NetworkClient } from '@metamask/network-controller/src/create-network-client'; -import { PollingControllerV1 } from '@metamask/polling-controller'; -import type { PreferencesState } from '@metamask/preferences-controller'; +import type { + PreferencesControllerGetStateAction, + PreferencesControllerStateChangeEvent, + PreferencesState, +} from '@metamask/preferences-controller'; +import { createDeferredPromise } from '@metamask/utils'; import type { Hex } from '@metamask/utils'; -import { Source } from './constants'; -import type { NftController, NftState, NftMetadata } from './NftController'; - -const DEFAULT_INTERVAL = 180000; +import { Source } from './constants.js'; +import type { + NftController, + NftControllerState, + NftMetadata, +} from './NftController.js'; + +const controllerName = 'NftDetectionController'; + +export type NFTDetectionControllerState = Record; + +export type AllowedActions = + | ControllerGetStateAction + | ApprovalControllerAddRequestAction + | NetworkControllerGetStateAction + | NetworkControllerGetNetworkClientByIdAction + | PreferencesControllerGetStateAction + | AccountsControllerGetSelectedAccountAction + | NetworkControllerFindNetworkClientIdByChainIdAction; + +export type AllowedEvents = + | ControllerStateChangeEvent< + typeof controllerName, + NFTDetectionControllerState + > + | PreferencesControllerStateChangeEvent + | NetworkControllerStateChangeEvent; + +export type NftDetectionControllerMessenger = Messenger< + typeof controllerName, + AllowedActions, + AllowedEvents +>; /** * @type ApiNft * * NFT object coming from OpenSea api + * * @property token_id - The NFT identifier * @property num_sales - Number of sales * @property background_color - The background color to be displayed with the item * @property image_url - URI of an image associated with this NFT + * * @property image_preview_url - URI of a smaller image associated with this NFT * @property image_thumbnail_url - URI of a thumbnail image associated with this NFT * @property image_original_url - URI of the original image associated with this NFT @@ -40,7 +85,8 @@ const DEFAULT_INTERVAL = 180000; * @property creator - The NFT owner information object * @property lastSale - When this item was last sold */ -export interface ApiNft { +/* eslint-disable @typescript-eslint/naming-convention */ +export type ApiNft = { token_id: string; num_sales: number | null; background_color: string | null; @@ -56,12 +102,14 @@ export interface ApiNft { asset_contract: ApiNftContract; creator: ApiNftCreator; last_sale: ApiNftLastSale | null; -} +}; +/* eslint-enable @typescript-eslint/naming-convention */ /** * @type ApiNftContract * * NFT contract object coming from OpenSea api + * * @property address - Address of the NFT contract * @property asset_contract_type - The NFT type, it could be `semi-fungible` or `non-fungible` * @property created_date - Creation date @@ -72,7 +120,8 @@ export interface ApiNft { * @property description - The NFT contract description * @property external_link - External link containing additional information */ -export interface ApiNftContract { +/* eslint-disable @typescript-eslint/naming-convention */ +export type ApiNftContract = { address: string; asset_contract_type: string | null; created_date: string | null; @@ -84,338 +133,569 @@ export interface ApiNftContract { collection: { name: string | null; image_url?: string | null; + tokenCount?: string | null; }; -} +}; +/* eslint-enable @typescript-eslint/naming-convention */ /** * @type ApiNftLastSale * * NFT sale object coming from OpenSea api + * * @property event_timestamp - Object containing a `username` * @property total_price - URI of NFT image associated with this owner * @property transaction - Object containing transaction_hash and block_hash */ -export interface ApiNftLastSale { +/* eslint-disable @typescript-eslint/naming-convention */ +export type ApiNftLastSale = { event_timestamp: string; total_price: string; transaction: { transaction_hash: string; block_hash: string }; -} +}; +/* eslint-enable @typescript-eslint/naming-convention */ /** * @type ApiNftCreator * * NFT creator object coming from OpenSea api + * * @property user - Object containing a `username` * @property profile_img_url - URI of NFT image associated with this owner * @property address - The owner address */ -export interface ApiNftCreator { +/* eslint-disable @typescript-eslint/naming-convention */ +export type ApiNftCreator = { user: { username: string }; profile_img_url: string; address: string; +}; +/* eslint-enable @typescript-eslint/naming-convention */ + +export type ReservoirResponse = { + tokens: TokensResponse[]; + continuation?: string | null; +}; + +export type TokensResponse = { + token: TokenResponse; + ownership: Ownership; + market?: Market; + blockaidResult?: Blockaid; +}; + +export enum BlockaidResultType { + Benign = 'Benign', + Spam = 'Spam', + Warning = 'Warning', + Malicious = 'Malicious', } -/** - * @type NftDetectionConfig - * - * NftDetection configuration - * @property interval - Polling interval used to fetch new token rates - * @property chainId - Current chain ID - * @property selectedAddress - Vault selected address - */ -export interface NftDetectionConfig extends BaseConfig { - interval: number; - chainId: Hex; - selectedAddress: string; -} +/* eslint-disable @typescript-eslint/naming-convention */ +export type Blockaid = { + contract: string; + chainId: number; + result_type: BlockaidResultType; + malicious_score: string; + attack_types: object; +}; +/* eslint-enable @typescript-eslint/naming-convention */ + +export type Market = { + floorAsk?: FloorAsk; + topBid?: TopBid; +}; + +export type TokenResponse = { + chainId: number; + contract: string; + tokenId: string; + kind?: string; + name?: string; + image?: string; + imageSmall?: string; + imageLarge?: string; + metadata?: Metadata; + description?: string; + supply?: number; + remainingSupply?: number; + rarityScore?: number; + rarity?: number; + rarityRank?: number; + media?: string; + isFlagged?: boolean; + isSpam?: boolean; + isNsfw?: boolean; + metadataDisabled?: boolean; + lastFlagUpdate?: string; + lastFlagChange?: string; + collection?: Collection; + lastSale?: LastSale; + topBid?: TopBid; + lastAppraisalValue?: number; + attributes?: Attributes[]; +}; + +export type TopBid = { + id?: string; + price?: Price; + source?: { + id?: string; + domain?: string; + name?: string; + icon?: string; + url?: string; + }; +}; + +export type LastSale = { + saleId?: string; + token?: { + contract?: string; + tokenId?: string; + name?: string; + image?: string; + collection?: { + id?: string; + name?: string; + }; + }; + orderSource?: string; + orderSide?: 'ask' | 'bid'; + orderKind?: string; + orderId?: string; + from?: string; + to?: string; + amount?: string; + fillSource?: string; + block?: number; + txHash?: string; + logIndex?: number; + batchIndex?: number; + timestamp?: number; + price?: Price; + washTradingScore?: number; + royaltyFeeBps?: number; + marketplaceFeeBps?: number; + paidFullRoyalty?: boolean; + feeBreakdown?: FeeBreakdown[]; + isDeleted?: boolean; + createdAt?: string; + updatedAt?: string; +}; + +export type FeeBreakdown = { + kind?: string; + bps?: number; + recipient?: string; + source?: string; + rawAmount?: string; +}; + +export type Attributes = { + key?: string; + kind?: string; + value: string; + tokenCount?: number; + onSaleCount?: number; + floorAskPrice?: Price | null; + topBidValue?: number | null; + createdAt?: string; +}; + +export type GetCollectionsResponse = { + collections: CollectionResponse[]; +}; + +export type CollectionResponse = { + id?: string; + chainId?: number; + openseaVerificationStatus?: string; + contractDeployedAt?: string; + creator?: string; + ownerCount?: string; + topBid?: TopBid & { + sourceDomain?: string; + }; +}; + +export type FloorAskCollection = { + id?: string; + price?: Price; + maker?: string; + kind?: string; + validFrom?: number; + validUntil?: number; + source?: SourceCollection; + rawData?: Metadata; + isNativeOffChainCancellable?: boolean; +}; + +export type SourceCollection = { + id: string; + domain: string; + name: string; + icon: string; + url: string; +}; + +export type TokenCollection = { + id?: string; + name?: string; + slug?: string; + symbol?: string; + imageUrl?: string; + image?: string; + isSpam?: boolean; + isNsfw?: boolean; + creator?: string; + tokenCount?: string; + metadataDisabled?: boolean; + openseaVerificationStatus?: string; + floorAskPrice?: Price; + royaltiesBps?: number; + royalties?: Royalties[]; + floorAsk?: FloorAskCollection; +}; + +export type Collection = TokenCollection & CollectionResponse; + +export type Royalties = { + bps?: number; + recipient?: string; +}; + +export type Ownership = { + tokenCount?: string; + onSaleCount?: string; + floorAsk?: FloorAsk; + acquiredAt?: string; +}; + +export type FloorAsk = { + id?: string; + price?: Price; + maker?: string; + kind?: string; + validFrom?: number; + validUntil?: number; + source?: Source; + rawData?: Metadata; + isNativeOffChainCancellable?: boolean; +}; + +export type Price = { + currency?: { + contract?: string; + name?: string; + symbol?: string; + decimals?: number; + chainId?: number; + }; + amount?: { + raw?: string; + decimal?: number; + usd?: number; + native?: number; + }; + netAmount?: { + raw?: string; + decimal?: number; + usd?: number; + native?: number; + }; +}; + +export type Metadata = { + imageOriginal?: string; + tokenURI?: string; +}; /** - * Controller that passively polls on a set interval for NFT auto detection + * Controller that passively detects nfts for a user address */ -export class NftDetectionController extends PollingControllerV1< - NftDetectionConfig, - BaseState +export class NftDetectionController extends BaseController< + typeof controllerName, + NFTDetectionControllerState, + NftDetectionControllerMessenger > { - private intervalId?: ReturnType; - - private getOwnerNftApi({ - address, - offset, - }: { - address: string; - offset: number; - }) { - return `${OPENSEA_PROXY_URL}/assets?owner=${address}&offset=${offset}&limit=50`; - } + #disabled: boolean; - private async getOwnerNfts(address: string) { - let nftApiResponse: { assets: ApiNft[] }; - let nfts: ApiNft[] = []; - let offset = 0; - let pagingFinish = false; - /* istanbul ignore if */ - do { - nftApiResponse = await fetchWithErrorHandling({ - url: this.getOwnerNftApi({ address, offset }), - timeout: 15000, - }); - - if (!nftApiResponse) { - return nfts; - } - - nftApiResponse?.assets?.length !== 0 - ? (nfts = [...nfts, ...nftApiResponse.assets]) - : (pagingFinish = true); - offset += 50; - } while (!pagingFinish); - - return nfts; - } - - /** - * Name of this controller used during composition - */ - override name = 'NftDetectionController'; + readonly #addNfts: NftController['addNfts']; - private readonly getOpenSeaApiKey: () => string | undefined; + readonly #getNftState: () => NftControllerState; - private readonly addNft: NftController['addNft']; - - private readonly getNftState: () => NftState; - - private readonly getNetworkClientById: NetworkController['getNetworkClientById']; + #inProcessNftFetchingUpdates: Record<`${string}:${string}`, Promise>; /** - * Creates an NftDetectionController instance. + * The controller options * * @param options - The controller options. - * @param options.chainId - The chain ID of the current network. - * @param options.onNftsStateChange - Allows subscribing to assets controller state changes. - * @param options.onPreferencesStateChange - Allows subscribing to preferences controller state changes. - * @param options.onNetworkStateChange - Allows subscribing to network controller state changes. - * @param options.getOpenSeaApiKey - Gets the OpenSea API key, if one is set. - * @param options.addNft - Add an NFT. + * @param options.messenger - A reference to the messaging system. + * @param options.disabled - Represents previous value of useNftDetection. Used to detect changes of useNftDetection. Default value is true. + * @param options.addNfts - Add multiple NFTs. * @param options.getNftState - Gets the current state of the Assets controller. - * @param options.getNetworkClientById - Gets the network client by ID, from the NetworkController. - * @param config - Initial options used to configure this controller. - * @param state - Initial state to set on this controller. */ - constructor( - { - chainId: initialChainId, - getNetworkClientById, - onPreferencesStateChange, - onNetworkStateChange, - getOpenSeaApiKey, - addNft, - getNftState, - }: { - chainId: Hex; - getNetworkClientById: NetworkController['getNetworkClientById']; - onNftsStateChange: (listener: (nftsState: NftState) => void) => void; - onPreferencesStateChange: ( - listener: (preferencesState: PreferencesState) => void, - ) => void; - onNetworkStateChange: ( - listener: (networkState: NetworkState) => void, - ) => void; - getOpenSeaApiKey: () => string | undefined; - addNft: NftController['addNft']; - getNftState: () => NftState; - }, - config?: Partial, - state?: Partial, - ) { - super(config, state); - this.defaultConfig = { - interval: DEFAULT_INTERVAL, - chainId: initialChainId, - selectedAddress: '', - disabled: true, - }; - this.initialize(); - this.getNftState = getNftState; - this.getNetworkClientById = getNetworkClientById; - onPreferencesStateChange(({ selectedAddress, useNftDetection }) => { - const { selectedAddress: previouslySelectedAddress, disabled } = - this.config; - - if ( - selectedAddress !== previouslySelectedAddress || - !useNftDetection !== disabled - ) { - this.configure({ selectedAddress, disabled: !useNftDetection }); - } - - if (useNftDetection !== undefined) { - if (useNftDetection) { - this.start(); - } else { - this.stop(); - } - } - }); - - onNetworkStateChange(({ providerConfig }) => { - this.configure({ - chainId: providerConfig.chainId, - }); + constructor({ + messenger, + disabled = false, + addNfts, + getNftState, + }: { + messenger: NftDetectionControllerMessenger; + disabled: boolean; + addNfts: NftController['addNfts']; + getNftState: () => NftControllerState; + }) { + super({ + name: controllerName, + messenger, + metadata: {}, + state: {}, }); - this.getOpenSeaApiKey = getOpenSeaApiKey; - this.addNft = addNft; - this.setIntervalLength(this.config.interval); - } - - async _executePoll( - networkClientId: string, - options: { address: string }, - ): Promise { - await this.detectNfts(networkClientId, options.address); - } + this.#disabled = disabled; + this.#inProcessNftFetchingUpdates = {}; - /** - * Start polling for the currency rate. - */ - async start() { - if (!this.isMainnet() || this.disabled) { - return; - } + this.#getNftState = getNftState; + this.#addNfts = addNfts; - await this.startPolling(); + this.messenger.subscribe( + 'PreferencesController:stateChange', + this.#onPreferencesControllerStateChange.bind(this), + ); } /** - * Stop polling for the currency rate. + * Checks whether network is mainnet or not. + * + * @returns Whether current network is mainnet. */ - stop() { - this.stopPolling(); + isMainnet(): boolean { + const { selectedNetworkClientId } = this.messenger.call( + 'NetworkController:getState', + ); + const { + configuration: { chainId }, + } = this.messenger.call( + 'NetworkController:getNetworkClientById', + selectedNetworkClientId, + ); + return chainId === ChainId.mainnet; } - private stopPolling() { - if (this.intervalId) { - clearInterval(this.intervalId); - } + isMainnetByNetworkClientId(networkClient: NetworkClient): boolean { + return networkClient.configuration.chainId === ChainId.mainnet; } /** - * Starts a new polling interval. + * Handles the state change of the preference controller. * - * @param interval - An interval on which to poll. + * @param preferencesState - The new state of the preference controller. + * @param preferencesState.useNftDetection - Boolean indicating user preference on NFT detection. */ - private async startPolling(interval?: number): Promise { - interval && this.configure({ interval }, false, false); - this.stopPolling(); - await this.detectNfts(); - this.intervalId = setInterval(async () => { - await this.detectNfts(); - }, this.config.interval); + #onPreferencesControllerStateChange({ + useNftDetection, + }: PreferencesState): void { + if (!useNftDetection !== this.#disabled) { + this.#disabled = !useNftDetection; + } } - /** - * Checks whether network is mainnet or not. - * - * @returns Whether current network is mainnet. - */ - isMainnet = (): boolean => this.config.chainId === ChainId.mainnet; - - isMainnetByNetworkClientId = (networkClient: NetworkClient): boolean => { - return networkClient.configuration.chainId === ChainId.mainnet; - }; + #getOwnerNftApi({ + chainIds, + address, + next, + }: { + chainIds: string[]; + address: string; + next?: string; + }): string { + // from chainIds construct a string of chainIds that can be used like chainIds=1&chainIds=56 + const chainIdsString = chainIds.join('&chainIds='); + return `${ + NFT_API_BASE_URL as string + }/users/${address}/tokens?chainIds=${chainIdsString}&limit=50&includeTopBid=true&continuation=${next ?? ''}`; + } - private getCorrectChainId(networkClientId?: NetworkClientId) { - if (networkClientId) { - return this.getNetworkClientById(networkClientId).configuration.chainId; + async #getOwnerNfts( + address: string, + chainIds: Hex[], + cursor: string | undefined, + ): Promise { + // Convert hex chainId to number + const convertedChainIds = chainIds.map((chainId) => + convertHexToDecimal(chainId).toString(), + ); + + const filteredChainIds = convertedChainIds.filter( + (chainId) => chainId !== '0', + ); + + // Avoid making the API call for non-EVM chains + if (filteredChainIds.length === 0) { + return { + tokens: [], + continuation: null, + }; } - return this.config.chainId; + + const url = this.#getOwnerNftApi({ + chainIds: filteredChainIds, + address, + next: cursor, + }); + const nftApiResponse: ReservoirResponse = await handleFetch(url, { + headers: { + Version: NFT_API_VERSION, + }, + }); + return nftApiResponse; } /** * Triggers asset ERC721 token auto detection on mainnet. Any newly detected NFTs are * added. * - * @param networkClientId - The network client ID to detect NFTs on. - * @param accountAddress - The address to detect NFTs for. + * @param chainIds - The chain IDs to detect NFTs on. + * @param options - Options bag. + * @param options.userAddress - The address to detect NFTs for. + * @param options.firstPageOnly - Whether to only detect the first page of NFTs. + * @param options.signal - An optional abort signal to cancel the operation. */ - async detectNfts(networkClientId?: NetworkClientId, accountAddress?: string) { - const chainId = this.getCorrectChainId(networkClientId); - - const selectedAddress = accountAddress || this.config.selectedAddress; + async detectNfts( + chainIds: Hex[], + options?: { + userAddress?: string; + firstPageOnly?: boolean; + signal?: AbortSignal; + }, + ): Promise { + const userAddress = + options?.userAddress ?? + this.messenger.call('AccountsController:getSelectedAccount').address; /* istanbul ignore if */ - if (!this.isMainnet() || this.disabled) { + if (chainIds.length === 0 || this.#disabled) { return; } /* istanbul ignore else */ - if (!selectedAddress) { + if (!userAddress) { + return; + } + // create a string of all chainIds + const chainIdsString = chainIds.join(','); + + const updateKey: `${string}:${string}` = `${chainIdsString}:${userAddress}`; + if (updateKey in this.#inProcessNftFetchingUpdates) { + // This prevents redundant updates + // This promise is resolved after the in-progress update has finished, + // and state has been updated. + await this.#inProcessNftFetchingUpdates[updateKey]; return; } - const apiNfts = await this.getOwnerNfts(selectedAddress); - const addNftPromises = apiNfts.map(async (nft: ApiNft) => { - const { - token_id, - num_sales, - background_color, - image_url, - image_preview_url, - image_thumbnail_url, - image_original_url, - animation_url, - animation_original_url, - name, - description, - external_link, - creator, - asset_contract: { address, schema_name }, - last_sale, - } = nft; - - let ignored; - /* istanbul ignore else */ - const { ignoredNfts } = this.getNftState(); - if (ignoredNfts.length) { - ignored = ignoredNfts.find((c) => { - /* istanbul ignore next */ - return ( - c.address === toChecksumHexAddress(address) && - c.tokenId === token_id - ); - }); - } - - /* istanbul ignore else */ - if (!ignored) { - /* istanbul ignore next */ - const nftMetadata: NftMetadata = Object.assign( - {}, - { name }, - creator && { creator }, - description && { description }, - image_url && { image: image_url }, - num_sales && { numberOfSales: num_sales }, - background_color && { backgroundColor: background_color }, - image_preview_url && { imagePreview: image_preview_url }, - image_thumbnail_url && { imageThumbnail: image_thumbnail_url }, - image_original_url && { imageOriginal: image_original_url }, - animation_url && { animation: animation_url }, - animation_original_url && { - animationOriginal: animation_original_url, - }, - schema_name && { standard: schema_name }, - external_link && { externalLink: external_link }, - last_sale && { lastSale: last_sale }, + const { + promise: inProgressUpdate, + resolve: updateSucceeded, + reject: updateFailed, + } = createDeferredPromise({ suppressUnhandledRejection: true }); + this.#inProcessNftFetchingUpdates[updateKey] = inProgressUpdate; + + let next; + let apiNfts: TokensResponse[] = []; + let resultNftApi: ReservoirResponse; + try { + do { + resultNftApi = await this.#getOwnerNfts(userAddress, chainIds, next); + apiNfts = resultNftApi.tokens.filter( + (elm) => + elm.token.isSpam === false && + (elm.blockaidResult?.result_type + ? elm.blockaidResult?.result_type === BlockaidResultType.Benign + : true), ); - await this.addNft(address, token_id, { - nftMetadata, - userAddress: selectedAddress, - chainId, - source: Source.Detected, - }); - } - }); - await Promise.all(addNftPromises); + // Proceed to add NFTs + const nftsToAdd = apiNfts + .map((nft) => { + const { + tokenId, + contract, + kind, + image: imageUrl, + imageSmall: imageThumbnailUrl, + metadata, + name, + description, + attributes, + topBid, + lastSale, + rarityRank, + rarityScore, + collection, + chainId, + } = nft.token; + + // Use a fallback if metadata is null + const { imageOriginal: imageOriginalUrl } = metadata ?? {}; + + let ignored; + /* istanbul ignore else */ + const { ignoredNfts } = this.#getNftState(); + if (ignoredNfts.length) { + ignored = ignoredNfts.find((ignoredNft) => { + /* istanbul ignore next */ + return ( + ignoredNft.address === toChecksumHexAddress(contract) && + ignoredNft.tokenId === tokenId + ); + }); + } + + /* istanbul ignore else */ + if (!ignored) { + /* istanbul ignore next */ + const nftMetadata: NftMetadata & { chainId: number } = + Object.assign( + {}, + { name }, + description && { description }, + imageUrl && { image: imageUrl }, + imageThumbnailUrl && { imageThumbnail: imageThumbnailUrl }, + imageOriginalUrl && { imageOriginal: imageOriginalUrl }, + kind && { standard: kind.toUpperCase() }, + lastSale && { lastSale }, + attributes && { attributes }, + topBid && { topBid }, + rarityRank && { rarityRank }, + rarityScore && { rarityScore }, + collection && { collection }, + chainId && { chainId }, + ); + + return { + tokenAddress: contract, + tokenId, + nftMetadata, + }; + } + return undefined; + }) + .filter((nft): nft is NonNullable => nft !== undefined); + + await this.#addNfts(nftsToAdd, userAddress, Source.Detected); + } while ( + (next = resultNftApi.continuation) && + !options?.firstPageOnly && + !options?.signal?.aborted + ); + updateSucceeded(); + } catch (error) { + updateFailed(error); + throw error; + } finally { + delete this.#inProcessNftFetchingUpdates[updateKey]; + } } } diff --git a/packages/assets-controllers/src/RatesController/RatesController.test.ts b/packages/assets-controllers/src/RatesController/RatesController.test.ts new file mode 100644 index 00000000000..6b8f5d0ccff --- /dev/null +++ b/packages/assets-controllers/src/RatesController/RatesController.test.ts @@ -0,0 +1,534 @@ +import { deriveStateFromMetadata } from '@metamask/base-controller'; +import { MOCK_ANY_NAMESPACE, Messenger } from '@metamask/messenger'; +import type { + MessengerActions, + MessengerEvents, + MockAnyNamespace, +} from '@metamask/messenger'; + +import { jestAdvanceTime } from '../../../../tests/helpers.js'; +import type { fetchMultiExchangeRate as defaultFetchExchangeRate } from '../crypto-compare-service/index.js'; +import { + Cryptocurrency, + RatesController, + name as ratesControllerName, +} from './RatesController.js'; +import type { + RatesControllerMessenger, + RatesControllerState, +} from './types.js'; + +type AllActions = MessengerActions; + +type AllEvents = MessengerEvents; + +type RootMessenger = Messenger; + +const MOCK_TIMESTAMP = 1709983353; + +/** + * Returns a stubbed date based on a predefined timestamp. + * + * @returns The stubbed date in milliseconds. + */ +function getStubbedDate(): number { + return new Date(MOCK_TIMESTAMP).getTime(); +} + +/** + * Builds a new root messenger instance. + * + * @returns A new root messenger instance. + */ +function buildRootMessenger(): RootMessenger { + return new Messenger({ namespace: MOCK_ANY_NAMESPACE }); +} + +/** + * Builds a restricted messenger for the RatesController. + * + * @param messenger - The base messenger instance. + * @returns A restricted messenger for the RatesController. + */ +function buildRatesControllerMessenger( + messenger: RootMessenger, +): RatesControllerMessenger { + return new Messenger({ + namespace: ratesControllerName, + parent: messenger, + }); +} + +/** + * Sets up and returns a new instance of RatesController with the provided configuration. + * + * @param config - The configuration object for the RatesController. + * @param config.interval - Polling interval. + * @param config.initialState - Initial state of the controller. + * @param config.messenger - Messenger instance. + * @param config.includeUsdRate - Indicates if the USD rate should be included. + * @param config.fetchMultiExchangeRate - Callback to fetch rates data. + * @returns A new instance of RatesController. + */ +function setupRatesController({ + interval, + initialState, + messenger, + includeUsdRate, + fetchMultiExchangeRate, +}: { + interval?: number; + initialState?: Partial; + messenger: RootMessenger; + includeUsdRate: boolean; + fetchMultiExchangeRate?: typeof defaultFetchExchangeRate; +}) { + const ratesControllerMessenger = buildRatesControllerMessenger(messenger); + const ratesController = new RatesController({ + interval, + messenger: ratesControllerMessenger, + state: initialState, + includeUsdRate, + fetchMultiExchangeRate, + }); + return { ratesController, ratesControllerMessenger }; +} + +describe('RatesController', () => { + describe('construct', () => { + it('constructs the RatesController with default values', () => { + const { ratesController } = setupRatesController({ + initialState: {}, + messenger: buildRootMessenger(), + includeUsdRate: false, + }); + const { fiatCurrency, rates, cryptocurrencies } = ratesController.state; + expect(ratesController).toBeDefined(); + expect(fiatCurrency).toBe('usd'); + expect(Object.keys(rates)).toStrictEqual([ + Cryptocurrency.Btc, + Cryptocurrency.Solana, + ]); + expect(cryptocurrencies).toStrictEqual([ + Cryptocurrency.Btc, + Cryptocurrency.Solana, + ]); + }); + }); + + describe('start', () => { + beforeEach(() => { + jest.useFakeTimers(); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + it('starts the polling process with default values', async () => { + const messenger = buildRootMessenger(); + + jest.spyOn(global.Date, 'now').mockImplementation(() => getStubbedDate()); + const mockBtcRateValue = 57715.42; + const mockSolRateValue = 200.48; + + const fetchExchangeRateStub = jest.fn(() => { + return Promise.resolve({ + btc: { + eur: mockBtcRateValue, + }, + sol: { + eur: mockSolRateValue, + }, + }); + }); + const { ratesController, ratesControllerMessenger } = + setupRatesController({ + interval: 150, + initialState: { + fiatCurrency: 'eur', + }, + messenger, + fetchMultiExchangeRate: fetchExchangeRateStub, + includeUsdRate: false, + }); + const publishActionSpy = jest.spyOn(ratesControllerMessenger, 'publish'); + + const ratesPreUpdate = ratesController.state.rates; + + expect(ratesPreUpdate).toStrictEqual({ + btc: { + conversionDate: 0, + conversionRate: 0, + }, + sol: { + conversionDate: 0, + conversionRate: 0, + }, + }); + + await ratesController.start(); + + expect(publishActionSpy).toHaveBeenNthCalledWith( + 1, + `${ratesControllerName}:pollingStarted`, + ); + + await jestAdvanceTime({ duration: 200 }); + + const ratesPosUpdate = ratesController.state.rates; + + // checks for the RatesController:stateChange and + // RatesController:stateChanged events + expect(publishActionSpy).toHaveBeenCalledTimes(5); + expect(fetchExchangeRateStub).toHaveBeenCalled(); + expect(ratesPosUpdate).toStrictEqual({ + btc: { + conversionDate: MOCK_TIMESTAMP, + conversionRate: mockBtcRateValue, + }, + sol: { + conversionDate: MOCK_TIMESTAMP, + conversionRate: mockSolRateValue, + }, + }); + + await ratesController.start(); + + // since the polling has already started + // a second call to the start method should + // return immediately and no extra logic is executed + expect(publishActionSpy).not.toHaveBeenNthCalledWith(3); + }); + + it('starts the polling process with custom values', async () => { + jest.spyOn(global.Date, 'now').mockImplementation(() => getStubbedDate()); + const mockBtcUsdRateValue = 62235.48; + const mockSolUsdRateValue = 148.41; + const mockStrkUsdRateValue = 1.248; + const mockBtcEurRateValue = 57715.42; + const mockSolEurRateValue = 137.68; + const mockStrkEurRateValue = 1.157; + const fetchExchangeRateStub = jest.fn(() => { + return Promise.resolve({ + btc: { + usd: mockBtcUsdRateValue, + eur: mockBtcEurRateValue, + }, + sol: { + usd: mockSolUsdRateValue, + eur: mockSolEurRateValue, + }, + strk: { + usd: mockStrkUsdRateValue, + eur: mockStrkEurRateValue, + }, + }); + }); + + const { ratesController } = setupRatesController({ + interval: 150, + initialState: { + cryptocurrencies: [Cryptocurrency.Btc], + fiatCurrency: 'eur', + }, + messenger: buildRootMessenger(), + includeUsdRate: true, + fetchMultiExchangeRate: fetchExchangeRateStub, + }); + + await ratesController.start(); + + await jestAdvanceTime({ duration: 200 }); + + const { rates } = ratesController.state; + expect(fetchExchangeRateStub).toHaveBeenCalled(); + expect(rates).toStrictEqual({ + btc: { + conversionDate: MOCK_TIMESTAMP, + conversionRate: mockBtcEurRateValue, + usdConversionRate: mockBtcUsdRateValue, + }, + sol: { + conversionDate: MOCK_TIMESTAMP, + conversionRate: mockSolEurRateValue, + usdConversionRate: mockSolUsdRateValue, + }, + strk: { + conversionDate: MOCK_TIMESTAMP, + conversionRate: mockStrkEurRateValue, + usdConversionRate: mockStrkUsdRateValue, + }, + }); + }); + }); + + describe('stop', () => { + beforeEach(() => { + jest.useFakeTimers(); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + it('stops the polling process', async () => { + const messenger = buildRootMessenger(); + const fetchExchangeRateStub = jest.fn().mockResolvedValue({}); + const { ratesController, ratesControllerMessenger } = + setupRatesController({ + interval: 150, + initialState: {}, + messenger, + fetchMultiExchangeRate: fetchExchangeRateStub, + includeUsdRate: false, + }); + const publishActionSpy = jest.spyOn(ratesControllerMessenger, 'publish'); + + await ratesController.start(); + + expect(publishActionSpy).toHaveBeenNthCalledWith( + 1, + `${ratesControllerName}:pollingStarted`, + ); + + await jestAdvanceTime({ duration: 200 }); + + expect(fetchExchangeRateStub).toHaveBeenCalledTimes(2); + + await ratesController.stop(); + + // Some of these calls are for state changes + expect(publishActionSpy).toHaveBeenNthCalledWith( + 6, + `${ratesControllerName}:pollingStopped`, + ); + + await jestAdvanceTime({ duration: 200 }); + + expect(fetchExchangeRateStub).toHaveBeenCalledTimes(2); + + await ratesController.stop(); + + expect(publishActionSpy).not.toHaveBeenNthCalledWith( + 7, + `${ratesControllerName}:pollingStopped`, + ); + }); + }); + + describe('getCryptocurrencyList', () => { + it('returns the current cryptocurrency list', () => { + const fetchExchangeRateStub = jest.fn().mockResolvedValue({}); + const mockCryptocurrencyList = [Cryptocurrency.Btc]; + const { ratesController } = setupRatesController({ + interval: 150, + initialState: { + cryptocurrencies: mockCryptocurrencyList, + }, + messenger: buildRootMessenger(), + fetchMultiExchangeRate: fetchExchangeRateStub, + includeUsdRate: false, + }); + + const cryptocurrencyList = ratesController.getCryptocurrencyList(); + expect(cryptocurrencyList).toStrictEqual(mockCryptocurrencyList); + }); + }); + + describe('setCryptocurrencyList', () => { + it('updates the cryptocurrency list', async () => { + const fetchExchangeRateStub = jest.fn().mockResolvedValue({}); + const mockCryptocurrencyList: Cryptocurrency[] = []; // Different from default list + const { ratesController } = setupRatesController({ + interval: 150, + initialState: {}, + messenger: buildRootMessenger(), + fetchMultiExchangeRate: fetchExchangeRateStub, + includeUsdRate: false, + }); + + const cryptocurrencyListPreUpdate = + ratesController.getCryptocurrencyList(); + expect(cryptocurrencyListPreUpdate).toStrictEqual([ + Cryptocurrency.Btc, + Cryptocurrency.Solana, + ]); + // Just to make sure we're updating to something else than the default list + expect(cryptocurrencyListPreUpdate).not.toStrictEqual( + mockCryptocurrencyList, + ); + + await ratesController.setCryptocurrencyList(mockCryptocurrencyList); + const cryptocurrencyListPostUpdate = + ratesController.getCryptocurrencyList(); + expect(cryptocurrencyListPostUpdate).toStrictEqual( + mockCryptocurrencyList, + ); + }); + }); + + describe('setCurrentCurrency', () => { + it('sets the currency to a new value', async () => { + const fetchExchangeRateStub = jest.fn().mockResolvedValue({}); + const { ratesController } = setupRatesController({ + interval: 150, + initialState: {}, + messenger: buildRootMessenger(), + fetchMultiExchangeRate: fetchExchangeRateStub, + includeUsdRate: false, + }); + + const currencyPreUpdate = ratesController.state.fiatCurrency; + expect(currencyPreUpdate).toBe('usd'); + + await ratesController.setFiatCurrency('eur'); + + const currencyPostUpdate = ratesController.state.fiatCurrency; + expect(currencyPostUpdate).toBe('eur'); + }); + + it('throws if input is an empty string', async () => { + const fetchExchangeRateStub = jest.fn().mockResolvedValue({}); + const { ratesController } = setupRatesController({ + interval: 150, + initialState: {}, + messenger: buildRootMessenger(), + fetchMultiExchangeRate: fetchExchangeRateStub, + includeUsdRate: false, + }); + + await expect(ratesController.setFiatCurrency('')).rejects.toThrow( + 'The currency can not be an empty string', + ); + }); + }); + + describe('metadata', () => { + it('includes expected state in debug snapshots', () => { + const fetchExchangeRateStub = jest.fn().mockResolvedValue({}); + const { ratesController } = setupRatesController({ + messenger: buildRootMessenger(), + fetchMultiExchangeRate: fetchExchangeRateStub, + includeUsdRate: false, + }); + + expect( + deriveStateFromMetadata( + ratesController.state, + ratesController.metadata, + 'includeInDebugSnapshot', + ), + ).toMatchInlineSnapshot(` + { + "cryptocurrencies": [ + "btc", + "sol", + ], + "fiatCurrency": "usd", + "rates": { + "btc": { + "conversionDate": 0, + "conversionRate": 0, + }, + "sol": { + "conversionDate": 0, + "conversionRate": 0, + }, + }, + } + `); + }); + + it('includes expected state in state logs', () => { + const fetchExchangeRateStub = jest.fn().mockResolvedValue({}); + const { ratesController } = setupRatesController({ + messenger: buildRootMessenger(), + fetchMultiExchangeRate: fetchExchangeRateStub, + includeUsdRate: false, + }); + + expect( + deriveStateFromMetadata( + ratesController.state, + ratesController.metadata, + 'includeInStateLogs', + ), + ).toMatchInlineSnapshot(` + { + "cryptocurrencies": [ + "btc", + "sol", + ], + "fiatCurrency": "usd", + } + `); + }); + + it('persists expected state', () => { + const fetchExchangeRateStub = jest.fn().mockResolvedValue({}); + const { ratesController } = setupRatesController({ + messenger: buildRootMessenger(), + fetchMultiExchangeRate: fetchExchangeRateStub, + includeUsdRate: false, + }); + + expect( + deriveStateFromMetadata( + ratesController.state, + ratesController.metadata, + 'persist', + ), + ).toMatchInlineSnapshot(` + { + "cryptocurrencies": [ + "btc", + "sol", + ], + "fiatCurrency": "usd", + "rates": { + "btc": { + "conversionDate": 0, + "conversionRate": 0, + }, + "sol": { + "conversionDate": 0, + "conversionRate": 0, + }, + }, + } + `); + }); + + it('exposes expected state to UI', () => { + const fetchExchangeRateStub = jest.fn().mockResolvedValue({}); + const { ratesController } = setupRatesController({ + messenger: buildRootMessenger(), + fetchMultiExchangeRate: fetchExchangeRateStub, + includeUsdRate: false, + }); + + expect( + deriveStateFromMetadata( + ratesController.state, + ratesController.metadata, + 'usedInUi', + ), + ).toMatchInlineSnapshot(` + { + "fiatCurrency": "usd", + "rates": { + "btc": { + "conversionDate": 0, + "conversionRate": 0, + }, + "sol": { + "conversionDate": 0, + "conversionRate": 0, + }, + }, + } + `); + }); + }); +}); diff --git a/packages/assets-controllers/src/RatesController/RatesController.ts b/packages/assets-controllers/src/RatesController/RatesController.ts new file mode 100644 index 00000000000..3523a4455e0 --- /dev/null +++ b/packages/assets-controllers/src/RatesController/RatesController.ts @@ -0,0 +1,256 @@ +import { BaseController } from '@metamask/base-controller'; +import type { StateMetadata } from '@metamask/base-controller'; +import { Mutex } from 'async-mutex'; +import type { Draft } from 'immer'; + +import { fetchMultiExchangeRate as defaultFetchExchangeRate } from '../crypto-compare-service/index.js'; +import type { + ConversionRates, + RatesControllerState, + RatesControllerOptions, + RatesControllerMessenger, +} from './types.js'; + +export const name = 'RatesController'; + +/** + * Supported cryptocurrencies that can be used as a base currency. The value needs to be compatible + * with CryptoCompare's API which is the default source for the rates. + * + * See: https://min-api.cryptocompare.com/documentation?key=Price&cat=multipleSymbolsPriceEndpoint + */ +export enum Cryptocurrency { + Btc = 'btc', + Solana = 'sol', +} + +const DEFAULT_INTERVAL = 180000; + +const metadata: StateMetadata = { + fiatCurrency: { + includeInStateLogs: true, + persist: true, + includeInDebugSnapshot: true, + usedInUi: true, + }, + rates: { + includeInStateLogs: false, + persist: true, + includeInDebugSnapshot: true, + usedInUi: true, + }, + cryptocurrencies: { + includeInStateLogs: true, + persist: true, + includeInDebugSnapshot: true, + usedInUi: false, + }, +}; + +const defaultState = { + fiatCurrency: 'usd', + rates: { + [Cryptocurrency.Btc]: { + conversionDate: 0, + conversionRate: 0, + }, + [Cryptocurrency.Solana]: { + conversionDate: 0, + conversionRate: 0, + }, + }, + cryptocurrencies: [Cryptocurrency.Btc, Cryptocurrency.Solana], +}; + +export class RatesController extends BaseController< + typeof name, + RatesControllerState, + RatesControllerMessenger +> { + readonly #mutex = new Mutex(); + + readonly #fetchMultiExchangeRate; + + readonly #includeUsdRate; + + readonly #intervalLength: number; + + #intervalId: NodeJS.Timeout | undefined; + + /** + * Creates a RatesController instance. + * + * @param options - Constructor options. + * @param options.includeUsdRate - Keep track of the USD rate in addition to the current currency rate. + * @param options.interval - The polling interval, in milliseconds. + * @param options.messenger - A reference to the messaging system. + * @param options.state - Initial state to set on this controller. + * @param options.fetchMultiExchangeRate - Fetches the exchange rate from an external API. This option is primarily meant for use in unit tests. + */ + constructor({ + interval = DEFAULT_INTERVAL, + messenger, + state, + includeUsdRate, + fetchMultiExchangeRate = defaultFetchExchangeRate, + }: RatesControllerOptions) { + super({ + name, + metadata, + messenger, + state: { ...defaultState, ...state }, + }); + this.#includeUsdRate = includeUsdRate; + this.#fetchMultiExchangeRate = fetchMultiExchangeRate; + this.#intervalLength = interval; + } + + /** + * Executes a function `callback` within a mutex lock to ensure that only one instance of `callback` runs at a time across all invocations of `#withLock`. + * This method is useful for synchronizing access to a resource or section of code that should not be executed concurrently. + * + * @template R - The return type of the function `callback`. + * @param callback - A callback to execute once the lock is acquired. This callback can be synchronous or asynchronous. + * @returns A promise that resolves to the result of the function `callback`. The promise is fulfilled once `callback` has completed execution. + * @example + * async function criticalLogic() { + * // Critical logic code goes here. + * } + * + * // Execute criticalLogic within a lock. + * const result = await this.#withLock(criticalLogic); + */ + async #withLock(callback: () => R) { + const releaseLock = await this.#mutex.acquire(); + try { + return callback(); + } finally { + releaseLock(); + } + } + + /** + * Executes the polling operation to update rates. + */ + async #executePoll(): Promise { + await this.#updateRates(); + } + + /** + * Updates the rates by fetching new data. + */ + async #updateRates(): Promise { + await this.#withLock(async () => { + const { fiatCurrency, cryptocurrencies } = this.state; + const response: Record< + Cryptocurrency, + Record + > = await this.#fetchMultiExchangeRate( + fiatCurrency, + cryptocurrencies, + this.#includeUsdRate, + ); + + const updatedRates: ConversionRates = {}; + for (const [cryptocurrency, values] of Object.entries(response)) { + updatedRates[cryptocurrency] = { + conversionDate: Date.now(), + conversionRate: values[fiatCurrency], + ...(this.#includeUsdRate && { usdConversionRate: values.usd }), + }; + } + + this.update( + (state: Draft): RatesControllerState => { + return { + ...state, + rates: updatedRates, + }; + }, + ); + }); + } + + /** + * Starts the polling process. + */ + async start(): Promise { + if (this.#intervalId) { + return; + } + + this.messenger.publish(`${name}:pollingStarted`); + + await this.#updateRates(); + + this.#intervalId = setInterval(() => { + this.#executePoll().catch(console.error); + }, this.#intervalLength); + } + + /** + * Stops the polling process. + */ + async stop(): Promise { + if (!this.#intervalId) { + return; + } + + clearInterval(this.#intervalId); + this.#intervalId = undefined; + this.messenger.publish(`${name}:pollingStopped`); + } + + /** + * Returns the current list of cryptocurrency. + * + * @returns The cryptocurrency list. + */ + getCryptocurrencyList(): Cryptocurrency[] { + const { cryptocurrencies } = this.state; + return cryptocurrencies; + } + + /** + * Sets the list of supported cryptocurrencies. + * + * @param cryptocurrencies - The list of supported cryptocurrencies. + */ + async setCryptocurrencyList( + cryptocurrencies: Cryptocurrency[], + ): Promise { + await this.#withLock(() => { + this.update( + (state: Draft): RatesControllerState => { + return { + ...state, + cryptocurrencies, + }; + }, + ); + }); + } + + /** + * Sets the internal fiat currency and update rates accordingly. + * + * @param fiatCurrency - The fiat currency. + */ + async setFiatCurrency(fiatCurrency: string): Promise { + if (fiatCurrency === '') { + throw new Error('The currency can not be an empty string'); + } + + await this.#withLock(() => { + this.update( + (state: Draft): RatesControllerState => { + return { + ...state, + fiatCurrency, + }; + }, + ); + }); + await this.#updateRates(); + } +} diff --git a/packages/assets-controllers/src/RatesController/index.ts b/packages/assets-controllers/src/RatesController/index.ts new file mode 100644 index 00000000000..2b4e42f5636 --- /dev/null +++ b/packages/assets-controllers/src/RatesController/index.ts @@ -0,0 +1,11 @@ +export { RatesController, Cryptocurrency } from './RatesController.js'; +export type { + RatesControllerState, + RatesControllerEvents, + RatesControllerActions, + RatesControllerMessenger, + RatesControllerGetStateAction, + RatesControllerStateChangeEvent, + RatesControllerPollingStartedEvent, + RatesControllerPollingStoppedEvent, +} from './types.js'; diff --git a/packages/assets-controllers/src/RatesController/types.ts b/packages/assets-controllers/src/RatesController/types.ts new file mode 100644 index 00000000000..41ca8a91324 --- /dev/null +++ b/packages/assets-controllers/src/RatesController/types.ts @@ -0,0 +1,130 @@ +import type { + ControllerGetStateAction, + ControllerStateChangeEvent, +} from '@metamask/base-controller'; +import type { Messenger } from '@metamask/messenger'; + +import type { fetchMultiExchangeRate as defaultFetchExchangeRate } from '../crypto-compare-service/index.js'; +import type { + name as ratesControllerName, + Cryptocurrency, +} from './RatesController.js'; + +/** + * Represents the conversion rates from one currency to others, including the conversion date. + * The `conversionRate` field is a number that maps a cryptocurrency code (e.g., "BTC") to its + * conversion rate. + * The `usdConversionRate` provides the conversion rate to USD as a number, or `null` if the + * conversion rate to USD is not available. + * The `conversionDate` is a Unix timestamp (number) indicating when the conversion rate was last updated. + */ +export type Rate = { + conversionRate: number; + conversionDate: number; + usdConversionRate?: number; +}; + +/** + * Represents the conversion rates for multiple cryptocurrencies. + * Each key is a string representing the cryptocurrency symbol (e.g., "BTC", "SOL"), + * and its value is a `Rate` object containing conversion rates from that cryptocurrency + * to a fiat currencies and an optional USD rate. + */ +export type ConversionRates = Record; + +/** + * Represents the state structure for the RatesController. + */ +export type RatesControllerState = { + /** + * The fiat currency in which conversion rates are expressed + * (i.e., the "to" currency). + */ + fiatCurrency: string; + /** + * The conversion rates for multiple cryptocurrencies. + */ + rates: ConversionRates; + /** + * A list of supported cryptocurrency symbols. + * (i.e., the "from" currencies). + */ + cryptocurrencies: Cryptocurrency[]; +}; + +/** + * Type definition for RatesController state change events. + */ +export type RatesControllerStateChangeEvent = ControllerStateChangeEvent< + typeof ratesControllerName, + RatesControllerState +>; + +/** + * Type definition for the RatesController polling started event. + */ +export type RatesControllerPollingStartedEvent = { + type: `${typeof ratesControllerName}:pollingStarted`; + payload: []; +}; + +/** + * Type definition for the RatesController polling stopped event. + */ +export type RatesControllerPollingStoppedEvent = { + type: `${typeof ratesControllerName}:pollingStopped`; + payload: []; +}; + +/** + * Defines the events that the RatesController can emit. + */ +export type RatesControllerEvents = + | RatesControllerStateChangeEvent + | RatesControllerPollingStartedEvent + | RatesControllerPollingStoppedEvent; + +export type RatesControllerGetStateAction = ControllerGetStateAction< + typeof ratesControllerName, + RatesControllerState +>; + +/** + * Defines the actions that can be performed to get the state of the RatesController. + */ +export type RatesControllerActions = RatesControllerGetStateAction; + +/** + * Defines the actions that the RatesController can perform. + */ +export type RatesControllerMessenger = Messenger< + typeof ratesControllerName, + RatesControllerActions, + RatesControllerEvents +>; + +/** + * The options required to initialize a RatesController. + */ +export type RatesControllerOptions = { + /** + * Whether to include USD rates in the conversion rates. + */ + includeUsdRate: boolean; + /** + * The polling interval in milliseconds. + */ + interval?: number; + /** + * The messenger instance for communication. + */ + messenger: RatesControllerMessenger; + /** + * The initial state of the controller. + */ + state?: Partial; + /** + * The function to fetch exchange rates. + */ + fetchMultiExchangeRate?: typeof defaultFetchExchangeRate; +}; diff --git a/packages/assets-controllers/src/Standards/ERC20Standard.test.ts b/packages/assets-controllers/src/Standards/ERC20Standard.test.ts index 1c6e54a7851..a681dc9350e 100644 --- a/packages/assets-controllers/src/Standards/ERC20Standard.test.ts +++ b/packages/assets-controllers/src/Standards/ERC20Standard.test.ts @@ -1,8 +1,9 @@ import { Web3Provider } from '@ethersproject/providers'; -import HttpProvider from 'ethjs-provider-http'; +import HttpProvider from '@metamask/ethjs-provider-http'; +import BN from 'bn.js'; import nock from 'nock'; -import { ERC20Standard } from './ERC20Standard'; +import { ERC20Standard } from './ERC20Standard.js'; const MAINNET_PROVIDER_HTTP = new HttpProvider( 'https://mainnet.infura.io/v3/341eacb578dd44a1a049cbc5f6fd4035', @@ -68,9 +69,8 @@ describe('ERC20Standard', () => { result: '0x0000000000000000000000000000000000000000000000000000000000000012', }); - const maticDecimals = await erc20Standard.getTokenDecimals( - ERC20_MATIC_ADDRESS, - ); + const maticDecimals = + await erc20Standard.getTokenDecimals(ERC20_MATIC_ADDRESS); expect(maticDecimals.toString()).toBe('18'); }); @@ -156,4 +156,180 @@ describe('ERC20Standard', () => { erc20Standard.getTokenDecimals(AMBIRE_ADDRESS), ).rejects.toThrow('Failed to parse token decimals'); }); + + it('should get correct token balance for a given ERC20 contract address', async () => { + nock('https://mainnet.infura.io:443', { encodedQueryParams: true }) + .post('/v3/341eacb578dd44a1a049cbc5f6fd4035', { + jsonrpc: '2.0', + id: 7, + method: 'eth_call', + params: [ + { + to: '0x7d1afa7b718fb893db30a3abc0cfc608aacfebb0', + data: '0x70a082310000000000000000000000001234567890123456789012345678901234567890', + }, + 'latest', + ], + }) + .reply(200, { + jsonrpc: '2.0', + id: 7, + result: + '0x00000000000000000000000000000000000000000000003635c9adc5dea00000', + }); + + const balance = await erc20Standard.getBalanceOf( + ERC20_MATIC_ADDRESS, + '0x1234567890123456789012345678901234567890', + ); + expect(balance).toBeInstanceOf(BN); + expect(balance.toString()).toBe('1000000000000000000000'); + }); + + it('should get correct token name for a given ERC20 contract address', async () => { + nock('https://mainnet.infura.io:443', { encodedQueryParams: true }) + .post('/v3/341eacb578dd44a1a049cbc5f6fd4035', { + jsonrpc: '2.0', + id: 8, + method: 'eth_call', + params: [ + { + to: '0x7d1afa7b718fb893db30a3abc0cfc608aacfebb0', + data: '0x06fdde03', + }, + 'latest', + ], + }) + .reply(200, { + jsonrpc: '2.0', + id: 8, + result: + '0x000000000000000000000000000000000000000000000000000000000000002000000000000000000000000000000000000000000000000000000000000000054d41544943000000000000000000000000000000000000000000000000000000', + }); + + const name = await erc20Standard.getTokenName(ERC20_MATIC_ADDRESS); + expect(name).toBe('MATIC'); + }); + + it('should create instance with provider', () => { + const MAINNET_PROVIDER = new Web3Provider(MAINNET_PROVIDER_HTTP, 1); + const instance = new ERC20Standard(MAINNET_PROVIDER); + expect(instance).toBeInstanceOf(ERC20Standard); + }); + + it('should handle getTokenSymbol with malformed result', async () => { + const mockProvider = { + call: jest.fn().mockResolvedValue('0x'), + detectNetwork: jest + .fn() + .mockResolvedValue({ name: 'mainnet', chainId: 1 }), + }; + + const testInstance = new ERC20Standard( + mockProvider as unknown as Web3Provider, + ); + + await expect( + testInstance.getTokenSymbol('0x1234567890123456789012345678901234567890'), + ).rejects.toThrow('Value must be a hexadecimal string'); + }); + + it('should get complete details with user address', async () => { + const mockAddress = '0x7d1afa7b718fb893db30a3abc0cfc608aacfebb0'; + const mockUserAddress = '0x1234567890123456789012345678901234567890'; + + // Create a new provider for this test + const MAINNET_PROVIDER = new Web3Provider(MAINNET_PROVIDER_HTTP, 1); + MAINNET_PROVIDER.detectNetwork = async () => ({ + name: 'mainnet', + chainId: 1, + }); + + const testInstance = new ERC20Standard(MAINNET_PROVIDER); + + jest.spyOn(testInstance, 'getTokenDecimals').mockResolvedValue('18'); + jest.spyOn(testInstance, 'getTokenSymbol').mockResolvedValue('TEST'); + jest.spyOn(testInstance, 'getBalanceOf').mockResolvedValue(new BN('1000')); + + const details = await testInstance.getDetails(mockAddress, mockUserAddress); + + expect(details.standard).toBe('ERC20'); + expect(details.decimals).toBe('18'); + expect(details.symbol).toBe('TEST'); + expect(details.balance).toBeInstanceOf(BN); + expect(details.balance?.toString()).toBe('1000'); + + // Restore mocks + jest.restoreAllMocks(); + }); + + it('should get details without user address (no balance)', async () => { + const mockAddress = '0x7d1afa7b718fb893db30a3abc0cfc608aacfebb0'; + + // Create a new provider for this test + const MAINNET_PROVIDER = new Web3Provider(MAINNET_PROVIDER_HTTP, 1); + MAINNET_PROVIDER.detectNetwork = async () => ({ + name: 'mainnet', + chainId: 1, + }); + + const testInstance = new ERC20Standard(MAINNET_PROVIDER); + + jest.spyOn(testInstance, 'getTokenDecimals').mockResolvedValue('18'); + jest.spyOn(testInstance, 'getTokenSymbol').mockResolvedValue('TEST'); + + const details = await testInstance.getDetails(mockAddress); + + expect(details.standard).toBe('ERC20'); + expect(details.decimals).toBe('18'); + expect(details.symbol).toBe('TEST'); + expect(details.balance).toBeUndefined(); + + jest.restoreAllMocks(); + }); + + // it('should handle getTokenName non-revert exception rethrow', async () => { + // const mockProvider = { + // call: jest.fn(), + // detectNetwork: jest + // .fn() + // .mockResolvedValue({ name: 'mainnet', chainId: 1 }), + // }; + + // const testInstance = new ERC20Standard(mockProvider as any); + + // // Mock Contract to throw a non-revert error (should be rethrown on line 74) + // jest + // .spyOn(require('@ethersproject/contracts'), 'Contract') + // .mockImplementation(() => ({ + // name: jest.fn().mockRejectedValue(new Error('Network timeout')), + // })); + + // await expect( + // testInstance.getTokenName('0x1234567890123456789012345678901234567890'), + // ).rejects.toThrow('Network timeout'); + + // require('@ethersproject/contracts').Contract.mockRestore(); + // }); + + it('should handle getTokenSymbol parsing failure', async () => { + const mockProvider = { + call: jest + .fn() + .mockResolvedValue( + '0x0000000000000000000000000000000000000000000000000000000000000000', + ), + detectNetwork: jest + .fn() + .mockResolvedValue({ name: 'mainnet', chainId: 1 }), + }; + + const testInstance = new ERC20Standard( + mockProvider as unknown as Web3Provider, + ); + + await expect( + testInstance.getTokenSymbol('0x1234567890123456789012345678901234567890'), + ).rejects.toThrow('Failed to parse token symbol'); + }); }); diff --git a/packages/assets-controllers/src/Standards/ERC20Standard.ts b/packages/assets-controllers/src/Standards/ERC20Standard.ts index 98adf5aa7ef..b33e37ea823 100644 --- a/packages/assets-controllers/src/Standards/ERC20Standard.ts +++ b/packages/assets-controllers/src/Standards/ERC20Standard.ts @@ -1,13 +1,13 @@ +import { bytesToUtf8 } from '@ethereumjs/util'; import { Contract } from '@ethersproject/contracts'; import type { Web3Provider } from '@ethersproject/providers'; import { decodeSingle } from '@metamask/abi-utils'; import { ERC20 } from '@metamask/controller-utils'; import { abiERC20 } from '@metamask/metamask-eth-abis'; -import { assertIsStrictHexString } from '@metamask/utils'; -import { toUtf8 } from 'ethereumjs-util'; -import type { BN } from 'ethereumjs-util'; +import { assertIsStrictHexString, hexToBytes } from '@metamask/utils'; +import type BN from 'bn.js'; -import { ethersBigNumberToBN } from '../assetsUtil'; +import { ethersBigNumberToBN } from '../assetsUtil.js'; export class ERC20Standard { private readonly provider: Web3Provider; @@ -40,9 +40,12 @@ export class ERC20Standard { try { const decimals = await contract.decimals(); return decimals.toString(); - } catch (err: any) { + } catch (err) { // Mirror previous implementation - if (err.message.includes('call revert exception')) { + if ( + err instanceof Error && + err.message.includes('call revert exception') + ) { throw new Error('Failed to parse token decimals'); } throw err; @@ -60,9 +63,12 @@ export class ERC20Standard { try { const name = await contract.name(); return name.toString(); - } catch (err: any) { + } catch (err) { // Mirror previous implementation - if (err.message.includes('call revert exception')) { + if ( + err instanceof Error && + err.message.includes('call revert exception') + ) { throw new Error('Failed to parse token name'); } throw err; @@ -92,7 +98,15 @@ export class ERC20Standard { // Parse as bytes - treat empty string as failure try { - const utf8 = toUtf8(result); + // Not done in bytesToUtf8 in ethereumjs/util. + const regexPreceedingAndTrailingZeroes = /^(00)+|(00)+$/gu; + + const resultTrimmed = result?.replace( + regexPreceedingAndTrailingZeroes, + '', + ); + + const utf8 = bytesToUtf8(hexToBytes(resultTrimmed)); if (utf8.length > 0) { return utf8; } @@ -119,14 +133,11 @@ export class ERC20Standard { decimals: string | undefined; balance: BN | undefined; }> { - const [decimals, symbol] = await Promise.all([ + const [decimals, symbol, balance] = await Promise.all([ this.getTokenDecimals(address), this.getTokenSymbol(address), + userAddress ? this.getBalanceOf(address, userAddress) : undefined, ]); - let balance; - if (userAddress) { - balance = await this.getBalanceOf(address, userAddress); - } return { decimals, symbol, diff --git a/packages/assets-controllers/src/Standards/NftStandards/ERC1155/ERC1155Standard.test.ts b/packages/assets-controllers/src/Standards/NftStandards/ERC1155/ERC1155Standard.test.ts index d238d2a51a2..d5d5429118c 100644 --- a/packages/assets-controllers/src/Standards/NftStandards/ERC1155/ERC1155Standard.test.ts +++ b/packages/assets-controllers/src/Standards/NftStandards/ERC1155/ERC1155Standard.test.ts @@ -1,13 +1,14 @@ import { Web3Provider } from '@ethersproject/providers'; -import HttpProvider from 'ethjs-provider-http'; +import HttpProvider from '@metamask/ethjs-provider-http'; import nock from 'nock'; -import { ERC1155Standard } from './ERC1155Standard'; +import { ERC1155Standard } from './ERC1155Standard.js'; const MAINNET_PROVIDER_HTTP = new HttpProvider( 'https://mainnet.infura.io/v3/341eacb578dd44a1a049cbc5f6fd4035', ); const ERC1155_ADDRESS = '0xfaaFDc07907ff5120a76b34b731b278c38d6043C'; +const SAMPLE_TOKEN_ID = '1'; describe('ERC1155Standard', () => { let erc1155Standard: ERC1155Standard; @@ -22,6 +23,10 @@ describe('ERC1155Standard', () => { erc1155Standard = new ERC1155Standard(MAINNET_PROVIDER); }); + beforeEach(() => { + nock.cleanAll(); + }); + it('should determine if contract supports URI metadata interface correctly', async () => { nock('https://mainnet.infura.io:443', { encodedQueryParams: true }) .post('/v3/341eacb578dd44a1a049cbc5f6fd4035', { @@ -48,4 +53,372 @@ describe('ERC1155Standard', () => { ); expect(contractSupportsUri).toBe(true); }); + + it('should determine if contract supports token receiver interface correctly', async () => { + nock('https://mainnet.infura.io:443', { encodedQueryParams: true }) + .post('/v3/341eacb578dd44a1a049cbc5f6fd4035') + .reply(200, { + jsonrpc: '2.0', + id: 1, + result: + '0x0000000000000000000000000000000000000000000000000000000000000001', + }) + .persist(); + const contractSupportsUri = + await erc1155Standard.contractSupportsTokenReceiverInterface( + ERC1155_ADDRESS, + ); + expect(contractSupportsUri).toBe(true); + }); + + describe('contractSupportsBase1155Interface', () => { + it('should be a callable method', () => { + expect(typeof erc1155Standard.contractSupportsBase1155Interface).toBe( + 'function', + ); + }); + }); + + describe('getTokenURI', () => { + it('should be a callable method', () => { + expect(typeof erc1155Standard.getTokenURI).toBe('function'); + }); + }); + + describe('getBalanceOf', () => { + it('should be a callable method', () => { + expect(typeof erc1155Standard.getBalanceOf).toBe('function'); + }); + }); + + describe('getAssetSymbol', () => { + it('should be a callable method', () => { + expect(typeof erc1155Standard.getAssetSymbol).toBe('function'); + }); + }); + + describe('getAssetName', () => { + it('should be a callable method', () => { + expect(typeof erc1155Standard.getAssetName).toBe('function'); + }); + }); + + describe('transferSingle', () => { + it('should be a callable method', () => { + expect(typeof erc1155Standard.transferSingle).toBe('function'); + }); + }); + + describe('getDetails', () => { + it('should be a callable method', () => { + expect(typeof erc1155Standard.getDetails).toBe('function'); + }); + + it('should throw error for non-ERC1155 contract', async () => { + // Mock ERC1155 interface check to return false + nock('https://mainnet.infura.io:443', { encodedQueryParams: true }) + .post('/v3/341eacb578dd44a1a049cbc5f6fd4035') + .reply(200, { + jsonrpc: '2.0', + id: 1, + result: + '0x0000000000000000000000000000000000000000000000000000000000000000', + }); + + await expect( + erc1155Standard.getDetails( + '0x0000000000000000000000000000000000000000', + 'https://gateway.com', + ), + ).rejects.toThrow("This isn't a valid ERC1155 contract"); + }); + }); + + describe('Constructor', () => { + it('should create instance with provider', () => { + const provider = new Web3Provider(MAINNET_PROVIDER_HTTP, 1); + const instance = new ERC1155Standard(provider); + expect(instance).toBeInstanceOf(ERC1155Standard); + }); + }); + + describe('Method availability', () => { + it('should have all expected methods', () => { + expect(typeof erc1155Standard.contractSupportsURIMetadataInterface).toBe( + 'function', + ); + expect( + typeof erc1155Standard.contractSupportsTokenReceiverInterface, + ).toBe('function'); + expect(typeof erc1155Standard.contractSupportsBase1155Interface).toBe( + 'function', + ); + expect(typeof erc1155Standard.getTokenURI).toBe('function'); + expect(typeof erc1155Standard.getBalanceOf).toBe('function'); + expect(typeof erc1155Standard.transferSingle).toBe('function'); + expect(typeof erc1155Standard.getAssetSymbol).toBe('function'); + expect(typeof erc1155Standard.getAssetName).toBe('function'); + expect(typeof erc1155Standard.getDetails).toBe('function'); + }); + }); + + describe('Contract Interface Support Methods', () => { + it('should call contractSupportsInterface with correct interface IDs', async () => { + // Test URI metadata interface + nock('https://mainnet.infura.io:443', { encodedQueryParams: true }) + .post('/v3/341eacb578dd44a1a049cbc5f6fd4035') + .reply(200, { + jsonrpc: '2.0', + id: 1, + result: + '0x0000000000000000000000000000000000000000000000000000000000000001', + }); + + const uriSupport = + await erc1155Standard.contractSupportsURIMetadataInterface( + ERC1155_ADDRESS, + ); + expect(typeof uriSupport).toBe('boolean'); + }); + + it('should call contractSupportsInterface for token receiver interface', async () => { + nock('https://mainnet.infura.io:443', { encodedQueryParams: true }) + .post('/v3/341eacb578dd44a1a049cbc5f6fd4035') + .reply(200, { + jsonrpc: '2.0', + id: 1, + result: + '0x0000000000000000000000000000000000000000000000000000000000000000', + }); + + const receiverSupport = + await erc1155Standard.contractSupportsTokenReceiverInterface( + ERC1155_ADDRESS, + ); + expect(typeof receiverSupport).toBe('boolean'); + }); + + it('should call contractSupportsInterface for base ERC1155 interface', async () => { + nock('https://mainnet.infura.io:443', { encodedQueryParams: true }) + .post('/v3/341eacb578dd44a1a049cbc5f6fd4035') + .reply(200, { + jsonrpc: '2.0', + id: 1, + result: + '0x0000000000000000000000000000000000000000000000000000000000000001', + }); + + const baseSupport = + await erc1155Standard.contractSupportsBase1155Interface( + ERC1155_ADDRESS, + ); + expect(typeof baseSupport).toBe('boolean'); + }); + }); + + describe('Contract Method Calls', () => { + it('should attempt to call getTokenURI', async () => { + // Test that the method creates a proper contract call (will fail but that's expected) + const promise = erc1155Standard.getTokenURI( + ERC1155_ADDRESS, + SAMPLE_TOKEN_ID, + ); + expect(promise).toBeInstanceOf(Promise); + // Expect it to reject due to no network connection + await expect(promise).rejects.toThrow('Maximum call stack size exceeded'); + }); + + it('should attempt to call getBalanceOf', async () => { + // Test that the method creates a proper contract call (will fail but that's expected) + const promise = erc1155Standard.getBalanceOf( + ERC1155_ADDRESS, + '0x1234567890123456789012345678901234567890', + SAMPLE_TOKEN_ID, + ); + expect(promise).toBeInstanceOf(Promise); + // Expect it to reject due to no network connection + await expect(promise).rejects.toThrow('Maximum call stack size exceeded'); + }); + + it('should attempt to call getAssetSymbol', async () => { + // Test that the method creates a proper contract call (will fail but that's expected) + const promise = erc1155Standard.getAssetSymbol(ERC1155_ADDRESS); + expect(promise).toBeInstanceOf(Promise); + // Expect it to reject due to no network connection + await expect(promise).rejects.toThrow('Maximum call stack size exceeded'); + }); + + it('should attempt to call getAssetName', async () => { + // Test that the method creates a proper contract call (will fail but that's expected) + const promise = erc1155Standard.getAssetName(ERC1155_ADDRESS); + expect(promise).toBeInstanceOf(Promise); + // Expect it to reject due to no network connection + await expect(promise).rejects.toThrow('Maximum call stack size exceeded'); + }); + }); + + describe('getDetails complex scenarios', () => { + it('should handle valid ERC1155 contract and return details', async () => { + // Mock successful ERC1155 interface check + nock('https://mainnet.infura.io:443', { encodedQueryParams: true }) + .post('/v3/341eacb578dd44a1a049cbc5f6fd4035') + .reply(200, { + jsonrpc: '2.0', + id: 1, + result: + '0x0000000000000000000000000000000000000000000000000000000000000001', + }) + .persist(); + + const ipfsGateway = 'https://ipfs.gateway.com'; + const details = await erc1155Standard.getDetails( + ERC1155_ADDRESS, + ipfsGateway, + SAMPLE_TOKEN_ID, + ); + + expect(details).toHaveProperty('standard', 'ERC1155'); + expect(details).toHaveProperty('tokenURI'); + expect(details).toHaveProperty('image'); + expect(details).toHaveProperty('symbol'); + expect(details).toHaveProperty('name'); + }); + + it('should handle getDetails without token ID', async () => { + // Mock successful ERC1155 interface check + nock('https://mainnet.infura.io:443', { encodedQueryParams: true }) + .post('/v3/341eacb578dd44a1a049cbc5f6fd4035') + .reply(200, { + jsonrpc: '2.0', + id: 1, + result: + '0x0000000000000000000000000000000000000000000000000000000000000001', + }) + .persist(); + + const ipfsGateway = 'https://ipfs.gateway.com'; + const details = await erc1155Standard.getDetails( + ERC1155_ADDRESS, + ipfsGateway, + ); + + expect(details).toHaveProperty('standard', 'ERC1155'); + expect(details.tokenURI).toBeUndefined(); + }); + }); + + describe('transferSingle edge cases', () => { + it('should create promise that handles callback pattern', async () => { + const operator = ERC1155_ADDRESS; + const from = '0x1234567890123456789012345678901234567890'; + const to = '0x0987654321098765432109876543210987654321'; + const id = SAMPLE_TOKEN_ID; + const value = '1'; + + const promise = erc1155Standard.transferSingle( + operator, + from, + to, + id, + value, + ); + expect(promise).toBeInstanceOf(Promise); + + // The promise will likely reject due to network issues, but that's expected + await expect(promise).rejects.toThrow( + 'contract.transferSingle is not a function', + ); + }); + }); + + describe('getDetails optional parameters', () => { + it('should return details without tokenURI when no tokenId is provided', async () => { + // Mock successful ERC1155 interface check + jest + .spyOn(erc1155Standard, 'contractSupportsBase1155Interface') + .mockResolvedValue(true); + jest.spyOn(erc1155Standard, 'getAssetSymbol').mockResolvedValue('TEST'); + jest + .spyOn(erc1155Standard, 'getAssetName') + .mockResolvedValue('Test Token'); + + const ipfsGateway = 'https://ipfs.gateway.com'; + const details = await erc1155Standard.getDetails( + ERC1155_ADDRESS, + ipfsGateway, + // No tokenId parameter to test the optional parameter behavior + ); + + expect(details.standard).toBe('ERC1155'); + expect(details.tokenURI).toBeUndefined(); // Should be undefined when no tokenId + expect(details.symbol).toBe('TEST'); + expect(details.name).toBe('Test Token'); + + // Restore original methods + jest.restoreAllMocks(); + }); + + it('should convert IPFS URIs to gateway URLs', async () => { + // Mock successful ERC1155 interface check + jest + .spyOn(erc1155Standard, 'contractSupportsBase1155Interface') + .mockResolvedValue(true); + jest.spyOn(erc1155Standard, 'getAssetSymbol').mockResolvedValue('TEST'); + jest + .spyOn(erc1155Standard, 'getAssetName') + .mockResolvedValue('Test Token'); + // Mock getTokenURI to return IPFS URI + jest + .spyOn(erc1155Standard, 'getTokenURI') + .mockResolvedValue( + 'ipfs://QmYwAPJzv5CZsnA625s3Xf2nemtYgPpHdWEz79ojWnPbdG', + ); + + const ipfsGateway = 'https://ipfs.gateway.com'; + const details = await erc1155Standard.getDetails( + ERC1155_ADDRESS, + ipfsGateway, + SAMPLE_TOKEN_ID, + ); + + expect(details.standard).toBe('ERC1155'); + expect(details.tokenURI).toContain('ipfs.gateway.com'); // Should be converted from IPFS URI + + // Restore original methods + jest.restoreAllMocks(); + }); + + it('should handle metadata fetching with network errors', async () => { + // Mock successful ERC1155 interface check + jest + .spyOn(erc1155Standard, 'contractSupportsBase1155Interface') + .mockResolvedValue(true); + jest.spyOn(erc1155Standard, 'getAssetSymbol').mockResolvedValue('TEST'); + jest + .spyOn(erc1155Standard, 'getAssetName') + .mockResolvedValue('Test Token'); + jest + .spyOn(erc1155Standard, 'getTokenURI') + .mockResolvedValue('https://example.com/metadata.json'); + + // Mock fetch to fail - this tests the catch block in getDetails + nock('https://example.com') + .get('/metadata.json') + .replyWithError('Network error'); + + const ipfsGateway = 'https://ipfs.gateway.com'; + const details = await erc1155Standard.getDetails( + ERC1155_ADDRESS, + ipfsGateway, + SAMPLE_TOKEN_ID, + ); + + expect(details.standard).toBe('ERC1155'); + expect(details.tokenURI).toBe('https://example.com/metadata.json'); + expect(details.image).toBeUndefined(); // Should be undefined due to fetch error + + // Restore original methods + jest.restoreAllMocks(); + }); + }); }); diff --git a/packages/assets-controllers/src/Standards/NftStandards/ERC1155/ERC1155Standard.ts b/packages/assets-controllers/src/Standards/NftStandards/ERC1155/ERC1155Standard.ts index 7901a576566..1961a3498ce 100644 --- a/packages/assets-controllers/src/Standards/NftStandards/ERC1155/ERC1155Standard.ts +++ b/packages/assets-controllers/src/Standards/NftStandards/ERC1155/ERC1155Standard.ts @@ -5,12 +5,16 @@ import { ERC1155_INTERFACE_ID, ERC1155_METADATA_URI_INTERFACE_ID, ERC1155_TOKEN_RECEIVER_INTERFACE_ID, + safelyExecute, timeoutFetch, } from '@metamask/controller-utils'; import { abiERC1155 } from '@metamask/metamask-eth-abis'; -import type { BN } from 'ethereumjs-util'; +import type * as BN from 'bn.js'; -import { getFormattedIpfsUrl, ethersBigNumberToBN } from '../../../assetsUtil'; +import { + getFormattedIpfsUrl, + ethersBigNumberToBN, +} from '../../../assetsUtil.js'; export class ERC1155Standard { private readonly provider: Web3Provider; @@ -25,14 +29,14 @@ export class ERC1155Standard { * @param address - ERC1155 asset contract address. * @returns Promise resolving to whether the contract implements ERC1155 URI Metadata interface. */ - contractSupportsURIMetadataInterface = async ( + async contractSupportsURIMetadataInterface( address: string, - ): Promise => { + ): Promise { return this.contractSupportsInterface( address, ERC1155_METADATA_URI_INTERFACE_ID, ); - }; + } /** * Query if contract implements ERC1155 Token Receiver interface. @@ -40,14 +44,14 @@ export class ERC1155Standard { * @param address - ERC1155 asset contract address. * @returns Promise resolving to whether the contract implements ERC1155 Token Receiver interface. */ - contractSupportsTokenReceiverInterface = async ( + async contractSupportsTokenReceiverInterface( address: string, - ): Promise => { + ): Promise { return this.contractSupportsInterface( address, ERC1155_TOKEN_RECEIVER_INTERFACE_ID, ); - }; + } /** * Query if contract implements ERC1155 interface. @@ -55,11 +59,9 @@ export class ERC1155Standard { * @param address - ERC1155 asset contract address. * @returns Promise resolving to whether the contract implements the base ERC1155 interface. */ - contractSupportsBase1155Interface = async ( - address: string, - ): Promise => { + async contractSupportsBase1155Interface(address: string): Promise { return this.contractSupportsInterface(address, ERC1155_INTERFACE_ID); - }; + } /** * Query for tokenURI for a given asset. @@ -68,10 +70,10 @@ export class ERC1155Standard { * @param tokenId - ERC1155 asset identifier. * @returns Promise resolving to the 'tokenURI'. */ - getTokenURI = async (address: string, tokenId: string): Promise => { + async getTokenURI(address: string, tokenId: string): Promise { const contract = new Contract(address, abiERC1155, this.provider); return contract.uri(tokenId); - }; + } /** * Query for balance of a given ERC1155 token. @@ -81,15 +83,15 @@ export class ERC1155Standard { * @param tokenId - ERC1155 asset identifier. * @returns Promise resolving to the 'balanceOf'. */ - getBalanceOf = async ( + async getBalanceOf( contractAddress: string, address: string, tokenId: string, - ): Promise => { + ): Promise { const contract = new Contract(contractAddress, abiERC1155, this.provider); const balance = await contract.balanceOf(address, tokenId); return ethersBigNumberToBN(balance); - }; + } /** * Transfer single ERC1155 token. @@ -103,13 +105,13 @@ export class ERC1155Standard { * @param value - Number of tokens to be sent. * @returns Promise resolving to the 'transferSingle'. */ - transferSingle = async ( + async transferSingle( operator: string, from: string, to: string, id: string, value: string, - ): Promise => { + ): Promise { const contract = new Contract(operator, abiERC1155, this.provider); return new Promise((resolve, reject) => { contract.transferSingle( @@ -128,7 +130,57 @@ export class ERC1155Standard { }, ); }); - }; + } + + /** + * Query for symbol for a given asset. + * + * @param address - ERC1155 asset contract address. + * @returns Promise resolving to the 'symbol'. + */ + async getAssetSymbol(address: string): Promise { + const contract = new Contract( + address, + // Contract ABI fragment containing only the symbol method to fetch the symbol of the contract. + [ + { + inputs: [], + name: 'symbol', + outputs: [{ name: '_symbol', type: 'string' }], + stateMutability: 'view', + type: 'function', + payable: false, + }, + ], + this.provider, + ); + return contract.symbol(); + } + + /** + * Query for name for a given asset. + * + * @param address - ERC1155 asset contract address. + * @returns Promise resolving to the 'name'. + */ + async getAssetName(address: string): Promise { + const contract = new Contract( + address, + // Contract ABI fragment containing only the name method to fetch the name of the contract. + [ + { + inputs: [], + name: 'name', + outputs: [{ name: '_name', type: 'string' }], + stateMutability: 'view', + type: 'function', + payable: false, + }, + ], + this.provider, + ); + return contract.name(); + } /** * Query if a contract implements an interface. @@ -137,13 +189,13 @@ export class ERC1155Standard { * @param interfaceId - Interface identifier. * @returns Promise resolving to whether the contract implements `interfaceID`. */ - private readonly contractSupportsInterface = async ( + private async contractSupportsInterface( address: string, interfaceId: string, - ): Promise => { + ): Promise { const contract = new Contract(address, abiERC1155, this.provider); return contract.supportsInterface(interfaceId); - }; + } /** * Query if a contract implements an interface. @@ -153,7 +205,7 @@ export class ERC1155Standard { * @param tokenId - tokenId of a given token in the contract. * @returns Promise resolving an object containing the standard, tokenURI, symbol and name of the given contract/tokenId pair. */ - getDetails = async ( + async getDetails( address: string, ipfsGateway: string, tokenId?: string, @@ -161,20 +213,32 @@ export class ERC1155Standard { standard: string; tokenURI: string | undefined; image: string | undefined; - }> => { + name: string | undefined; + symbol: string | undefined; + }> { const isERC1155 = await this.contractSupportsBase1155Interface(address); if (!isERC1155) { throw new Error("This isn't a valid ERC1155 contract"); } - let tokenURI, image; - if (tokenId) { - tokenURI = await this.getTokenURI(address, tokenId); - if (tokenURI.startsWith('ipfs://')) { - tokenURI = getFormattedIpfsUrl(ipfsGateway, tokenURI, true); - } + let image; + + const [symbol, name, tokenURI] = await Promise.all([ + safelyExecute(() => this.getAssetSymbol(address)), + safelyExecute(() => this.getAssetName(address)), + tokenId + ? safelyExecute(() => + this.getTokenURI(address, tokenId).then((uri) => + uri.startsWith('ipfs://') + ? getFormattedIpfsUrl(ipfsGateway, uri, true) + : uri, + ), + ) + : undefined, + ]); + if (tokenURI) { try { const response = await timeoutFetch(tokenURI); const object = await response.json(); @@ -183,7 +247,8 @@ export class ERC1155Standard { image = getFormattedIpfsUrl(ipfsGateway, image, true); } } catch { - // ignore + // Catch block should be kept empty to ignore exceptions, and + // pass as much information as possible to the return statement } } @@ -192,6 +257,8 @@ export class ERC1155Standard { standard: ERC1155, tokenURI, image, + symbol, + name, }; - }; + } } diff --git a/packages/assets-controllers/src/Standards/NftStandards/ERC721/ERC721Standard.test.ts b/packages/assets-controllers/src/Standards/NftStandards/ERC721/ERC721Standard.test.ts index 5aa279fe255..b2c171a8a69 100644 --- a/packages/assets-controllers/src/Standards/NftStandards/ERC721/ERC721Standard.test.ts +++ b/packages/assets-controllers/src/Standards/NftStandards/ERC721/ERC721Standard.test.ts @@ -1,9 +1,9 @@ import { Web3Provider } from '@ethersproject/providers'; import { IPFS_DEFAULT_GATEWAY_URL } from '@metamask/controller-utils'; -import HttpProvider from 'ethjs-provider-http'; +import HttpProvider from '@metamask/ethjs-provider-http'; import nock from 'nock'; -import { ERC721Standard } from './ERC721Standard'; +import { ERC721Standard } from './ERC721Standard.js'; const MAINNET_PROVIDER_HTTP = new HttpProvider( 'https://mainnet.infura.io/v3/341eacb578dd44a1a049cbc5f6fd4035', @@ -538,4 +538,126 @@ describe('ERC721Standard', () => { ); expect(details).toMatchObject(expectedResult); }); + + it('should get correct details including tokenURI and image for a given contract (that supports the ERC721 metadata interface) with a tokenID provided when the tokenURI content is hosted on IPFS & image starts with "ipfs://"', async () => { + nock('https://mainnet.infura.io:443', { encodedQueryParams: true }) + .post('/v3/341eacb578dd44a1a049cbc5f6fd4035', { + jsonrpc: '2.0', + id: 25, + method: 'eth_call', + params: [ + { + to: '0xbd3531da5cf5857e7cfaa92426877b022e612cf8', + data: '0x01ffc9a780ac58cd00000000000000000000000000000000000000000000000000000000', + }, + 'latest', + ], + }) + .reply(200, { + jsonrpc: '2.0', + id: 25, + result: + '0x0000000000000000000000000000000000000000000000000000000000000001', + }) + .post('/v3/341eacb578dd44a1a049cbc5f6fd4035', { + jsonrpc: '2.0', + id: 26, + method: 'eth_call', + params: [ + { + to: '0xbd3531da5cf5857e7cfaa92426877b022e612cf8', + data: '0x95d89b41', + }, + 'latest', + ], + }) + .reply(200, { + jsonrpc: '2.0', + id: 26, + result: + '0x000000000000000000000000000000000000000000000000000000000000002000000000000000000000000000000000000000000000000000000000000000035050470000000000000000000000000000000000000000000000000000000000', + }) + .post('/v3/341eacb578dd44a1a049cbc5f6fd4035', { + jsonrpc: '2.0', + id: 27, + method: 'eth_call', + params: [ + { + to: '0xbd3531da5cf5857e7cfaa92426877b022e612cf8', + data: '0x06fdde03', + }, + 'latest', + ], + }) + .reply(200, { + jsonrpc: '2.0', + id: 27, + result: + '0x0000000000000000000000000000000000000000000000000000000000000020000000000000000000000000000000000000000000000000000000000000000d507564677950656e6775696e7300000000000000000000000000000000000000', + }) + .post('/v3/341eacb578dd44a1a049cbc5f6fd4035', { + jsonrpc: '2.0', + id: 28, + method: 'eth_call', + params: [ + { + to: '0xbd3531da5cf5857e7cfaa92426877b022e612cf8', + data: '0x01ffc9a75b5e139f00000000000000000000000000000000000000000000000000000000', + }, + 'latest', + ], + }) + .reply(200, { + jsonrpc: '2.0', + id: 28, + result: + '0x0000000000000000000000000000000000000000000000000000000000000001', + }) + .post('/v3/341eacb578dd44a1a049cbc5f6fd4035', { + jsonrpc: '2.0', + id: 29, + method: 'eth_call', + params: [ + { + to: '0xbd3531da5cf5857e7cfaa92426877b022e612cf8', + data: '0xc87b56dd000000000000000000000000000000000000000000000000000000000000065b', + }, + 'latest', + ], + }) + .reply(200, { + jsonrpc: '2.0', + id: 29, + result: + '0x00000000000000000000000000000000000000000000000000000000000000200000000000000000000000000000000000000000000000000000000000000047697066733a2f2f6261667962656962633573676f32706c6d6a6b7132747a6d68726e3534626b336372686e6332337a64326d73673465613761347078726b67666e612f3136323700000000000000000000000000000000000000000000000000', + }); + + nock( + 'https://bafybeibc5sgo2plmjkq2tzmhrn54bk3crhnc23zd2msg4ea7a4pxrkgfna.ipfs.dweb.link', + ) + .get('/1627') + .reply(200, () => { + return { + image: + 'ipfs://QmNf1UsmdGaMbpatQ6toXSkzDpizaGmC9zfunCyoz1enD5/penguin/1627.png', + }; + }); + + const expectedResult = { + standard: 'ERC721', + tokenURI: + 'https://bafybeibc5sgo2plmjkq2tzmhrn54bk3crhnc23zd2msg4ea7a4pxrkgfna.ipfs.dweb.link/1627', + symbol: 'PPG', + name: 'PudgyPenguins', + image: + 'https://bafybeiaewpyqaytvogprfiv563tfkjhepkkz5mn57pr5z3kyag62kfuc7a.ipfs.dweb.link/penguin/1627.png', + }; + + const details = await erc721Standard.getDetails( + '0xBd3531dA5CF5857e7CfAA92426877b022e612cf8', + 'dweb.link', + '1627', + ); + expect(details).toMatchObject(expectedResult); + }); }); diff --git a/packages/assets-controllers/src/Standards/NftStandards/ERC721/ERC721Standard.ts b/packages/assets-controllers/src/Standards/NftStandards/ERC721/ERC721Standard.ts index 6d9a741137d..e9fbd51c607 100644 --- a/packages/assets-controllers/src/Standards/NftStandards/ERC721/ERC721Standard.ts +++ b/packages/assets-controllers/src/Standards/NftStandards/ERC721/ERC721Standard.ts @@ -6,10 +6,11 @@ import { ERC721_METADATA_INTERFACE_ID, ERC721_ENUMERABLE_INTERFACE_ID, ERC721, + safelyExecute, } from '@metamask/controller-utils'; import { abiERC721 } from '@metamask/metamask-eth-abis'; -import { getFormattedIpfsUrl } from '../../../assetsUtil'; +import { getFormattedIpfsUrl } from '../../../assetsUtil.js'; export class ERC721Standard { private readonly provider: Web3Provider; @@ -86,11 +87,13 @@ export class ERC721Standard { */ getTokenURI = async (address: string, tokenId: string): Promise => { const contract = new Contract(address, abiERC721, this.provider); - const supportsMetadata = await this.contractSupportsMetadataInterface( - address, - ); + const supportsMetadata = + await this.contractSupportsMetadataInterface(address); if (!supportsMetadata) { - throw new Error('Contract does not support ERC721 metadata interface.'); + // Do not throw error here, supporting Metadata interface is optional even though majority of ERC721 nfts do support it. + // This change is made because of instances of NFTs that are ERC404( mixed ERC20 / ERC721 implementation). + // As of today, ERC404 is unofficial but some people use it, the contract does not support Metadata interface, but it has the tokenURI() fct. + console.warn('Contract does not support ERC721 metadata interface.'); } return contract.tokenURI(tokenId); }; @@ -143,9 +146,12 @@ export class ERC721Standard { const contract = new Contract(address, abiERC721, this.provider); try { return await contract.supportsInterface(interfaceId); - } catch (err: any) { + } catch (err) { // Mirror previous implementation - if (err.message.includes('call revert exception')) { + if ( + err instanceof Error && + err.message.includes('call revert exception') + ) { return false; } throw err; @@ -176,33 +182,28 @@ export class ERC721Standard { throw new Error("This isn't a valid ERC721 contract"); } - let tokenURI, image, symbol, name; - - // TODO upgrade to use Promise.allSettled for name/symbol when we can refactor to use es2020 in tsconfig - try { - symbol = await this.getAssetSymbol(address); - } catch { - // ignore - } - - try { - name = await this.getAssetName(address); - } catch { - // ignore - } - - if (tokenId) { + const [symbol, name, tokenURI] = await Promise.all([ + safelyExecute(() => this.getAssetSymbol(address)), + safelyExecute(() => this.getAssetName(address)), + tokenId + ? safelyExecute(() => + this.getTokenURI(address, tokenId).then((uri) => + uri.startsWith('ipfs://') + ? getFormattedIpfsUrl(ipfsGateway, uri, true) + : uri, + ), + ) + : undefined, + ]); + + let image; + if (tokenURI) { try { - tokenURI = await this.getTokenURI(address, tokenId); - if (tokenURI.startsWith('ipfs://')) { - tokenURI = getFormattedIpfsUrl(ipfsGateway, tokenURI, true); - } - const response = await timeoutFetch(tokenURI); const object = await response.json(); image = object?.image; if (image?.startsWith('ipfs://')) { - image = getFormattedIpfsUrl(ipfsGateway, image, true); + image = await getFormattedIpfsUrl(ipfsGateway, image, true); } } catch { // ignore diff --git a/packages/assets-controllers/src/Standards/standards-types.ts b/packages/assets-controllers/src/Standards/standards-types.ts deleted file mode 100644 index 7dd14d76aff..00000000000 --- a/packages/assets-controllers/src/Standards/standards-types.ts +++ /dev/null @@ -1,22 +0,0 @@ -import type { - abiERC20, - abiERC1155, - abiERC721, -} from '@metamask/metamask-eth-abis'; - -type Contract = { - at(address: string): any; -}; - -export type Web3 = { - eth: { - call( - payload: { to: string; data: string }, - block: undefined, - callback: (error: Error, result: string) => void, - ): void; - contract( - abi: typeof abiERC20 | typeof abiERC721 | typeof abiERC1155, - ): Contract; - }; -}; diff --git a/packages/assets-controllers/src/TokenBalancesController-method-action-types.ts b/packages/assets-controllers/src/TokenBalancesController-method-action-types.ts new file mode 100644 index 00000000000..c6e79d8c62a --- /dev/null +++ b/packages/assets-controllers/src/TokenBalancesController-method-action-types.ts @@ -0,0 +1,35 @@ +/** + * This file is auto generated. + * Do not edit manually. + */ + +import type { TokenBalancesController } from './TokenBalancesController.js'; + +export type TokenBalancesControllerGetChainPollingConfigAction = { + type: `TokenBalancesController:getChainPollingConfig`; + handler: TokenBalancesController['getChainPollingConfig']; +}; + +export type TokenBalancesControllerUpdateChainPollingConfigsAction = { + type: `TokenBalancesController:updateChainPollingConfigs`; + handler: TokenBalancesController['updateChainPollingConfigs']; +}; + +export type TokenBalancesControllerUpdateBalancesAction = { + type: `TokenBalancesController:updateBalances`; + handler: TokenBalancesController['updateBalances']; +}; + +export type TokenBalancesControllerResetStateAction = { + type: `TokenBalancesController:resetState`; + handler: TokenBalancesController['resetState']; +}; + +/** + * Union of all TokenBalancesController action types. + */ +export type TokenBalancesControllerMethodActions = + | TokenBalancesControllerGetChainPollingConfigAction + | TokenBalancesControllerUpdateChainPollingConfigsAction + | TokenBalancesControllerUpdateBalancesAction + | TokenBalancesControllerResetStateAction; diff --git a/packages/assets-controllers/src/TokenBalancesController.test.ts b/packages/assets-controllers/src/TokenBalancesController.test.ts index 2be2e5c0fcd..424c5018245 100644 --- a/packages/assets-controllers/src/TokenBalancesController.test.ts +++ b/packages/assets-controllers/src/TokenBalancesController.test.ts @@ -1,314 +1,7651 @@ -import { ControllerMessenger } from '@metamask/base-controller'; -import { toHex } from '@metamask/controller-utils'; -import type { NetworkControllerMessenger } from '@metamask/network-controller'; -import { NetworkController } from '@metamask/network-controller'; -import { PreferencesController } from '@metamask/preferences-controller'; -import { BN } from 'ethereumjs-util'; -import * as sinon from 'sinon'; - -import { AssetsContractController } from './AssetsContractController'; +import { deriveStateFromMetadata } from '@metamask/base-controller'; +import { toChecksumHexAddress, toHex } from '@metamask/controller-utils'; +import type { BalanceUpdate } from '@metamask/core-backend'; +import type { InternalAccount } from '@metamask/keyring-internal-api'; +import { Messenger, MOCK_ANY_NAMESPACE } from '@metamask/messenger'; +import type { + MessengerActions, + MessengerEvents, + MockAnyNamespace, +} from '@metamask/messenger'; +import type { NetworkState } from '@metamask/network-controller'; +import type { PreferencesState } from '@metamask/preferences-controller'; +import { CHAIN_IDS } from '@metamask/transaction-controller'; +import type { TransactionMeta } from '@metamask/transaction-controller'; +import type { Hex } from '@metamask/utils'; +import BN from 'bn.js'; +import type nock from 'nock'; + +import { jestAdvanceTime, flushPromises } from '../../../tests/helpers.js'; +import { createMockInternalAccount } from '../../accounts-controller/tests/mocks.js'; +import type { RpcEndpoint } from '../../network-controller/src/NetworkController.js'; +import { mockAPI_accountsAPI_MultichainAccountBalances as mockAPIAccountsAPIMultichainAccountBalancesCamelCase } from './__fixtures__/account-api-v4-mocks.js'; +import { waitFor } from './__fixtures__/test-utils.js'; +import { AccountsApiBalanceFetcher } from './multi-chain-accounts-service/api-balance-fetcher.js'; +import * as multicall from './multicall.js'; +import { RpcBalanceFetcher } from './rpc-service/rpc-balance-fetcher.js'; +import type { + ChainIdHex, + TokenBalancesControllerMessenger, + ChecksumAddress, + TokenBalancesControllerState, + TokenBalances, + UpdateBalancesOptions, +} from './TokenBalancesController.js'; import { - BN as exportedBn, TokenBalancesController, -} from './TokenBalancesController'; -import type { Token } from './TokenRatesController'; -import type { TokensControllerMessenger } from './TokensController'; -import { TokensController } from './TokensController'; + UPDATE_BALANCES_BATCH_MS, + caipChainIdToHex, + mergeUpdateBalancesOptions, + parseAssetType, +} from './TokenBalancesController.js'; +import type { TokensControllerState } from './TokensController.js'; + +type AllTokenBalancesControllerActions = + MessengerActions; + +type AllTokenBalancesControllerEvents = + MessengerEvents; + +type RootMessenger = Messenger< + MockAnyNamespace, + AllTokenBalancesControllerActions, + AllTokenBalancesControllerEvents +>; + +// Mock safelyExecuteWithTimeout +jest.mock('@metamask/controller-utils', () => ({ + ...jest.requireActual('@metamask/controller-utils'), + safelyExecuteWithTimeout: jest.fn(), +})); + +// Constants for native token and staking addresses used in tests +const NATIVE_TOKEN_ADDRESS = '0x0000000000000000000000000000000000000000'; +const STAKING_CONTRACT_ADDRESS = '0x4FEF9D741011476750A243aC70b9789a63dd47Df'; + +// Mock function for safelyExecuteWithTimeout +const { safelyExecuteWithTimeout } = jest.requireMock( + '@metamask/controller-utils', +); +const mockedSafelyExecuteWithTimeout = safelyExecuteWithTimeout as jest.Mock; + +const setupController = ({ + config, + tokens = { allTokens: {}, allDetectedTokens: {}, allIgnoredTokens: {} }, + listAccounts = [], +}: { + config?: Partial[0]>; + tokens?: Partial; + listAccounts?: InternalAccount[]; +} = {}): { + controller: TokenBalancesController; + updateSpy: jest.SpyInstance; + messenger: RootMessenger; + tokenBalancesControllerMessenger: TokenBalancesControllerMessenger; +} => { + const messenger: RootMessenger = new Messenger({ + namespace: MOCK_ANY_NAMESPACE, + }); + + const tokenBalancesControllerMessenger = new Messenger< + 'TokenBalancesController', + AllTokenBalancesControllerActions, + AllTokenBalancesControllerEvents, + RootMessenger + >({ + namespace: 'TokenBalancesController', + parent: messenger, + }); + messenger.delegate({ + messenger: tokenBalancesControllerMessenger, + actions: [ + 'NetworkController:getState', + 'NetworkController:getNetworkClientById', + 'PreferencesController:getState', + 'TokensController:getState', + 'TokenDetectionController:addDetectedTokensViaPolling', + 'TokenDetectionController:addDetectedTokensViaWs', + 'TokenDetectionController:detectTokens', + 'AccountsController:getSelectedAccount', + 'AccountsController:listAccounts', + 'AccountTrackerController:getState', + 'AccountTrackerController:updateNativeBalances', + 'AccountTrackerController:updateStakedBalances', + 'KeyringController:getState', + 'AuthenticationController:getBearerToken', + ], + events: [ + 'NetworkController:stateChange', + 'PreferencesController:stateChange', + 'TokensController:stateChange', + 'KeyringController:accountRemoved', + 'KeyringController:lock', + 'KeyringController:unlock', + 'AccountActivityService:balanceUpdated', + 'AccountActivityService:statusChanged', + 'AccountsController:selectedEvmAccountChange', + 'TransactionController:transactionConfirmed', + ], + }); + + messenger.registerActionHandler( + 'NetworkController:getState', + jest.fn().mockImplementation(() => ({ + networkConfigurationsByChainId: { + '0x1': { + defaultRpcEndpointIndex: 0, + rpcEndpoints: [{ networkClientId: 'mainnet' }], + }, + '0x89': { + defaultRpcEndpointIndex: 0, + rpcEndpoints: [{ networkClientId: 'polygon' }], + }, + '0xa4b1': { + defaultRpcEndpointIndex: 0, + rpcEndpoints: [{ networkClientId: 'arbitrum' }], + }, + '0x38': { + defaultRpcEndpointIndex: 0, + rpcEndpoints: [{ networkClientId: 'bsc' }], + }, + '0x2': { + defaultRpcEndpointIndex: 0, + rpcEndpoints: [{ networkClientId: 'test-chain' }], + }, + }, + })), + ); + + messenger.registerActionHandler( + 'PreferencesController:getState', + jest.fn().mockImplementation(() => ({})), + ); + + messenger.registerActionHandler( + 'TokensController:getState', + jest.fn().mockImplementation(() => tokens), + ); + + messenger.registerActionHandler( + 'TokenDetectionController:addDetectedTokensViaPolling', + jest.fn().mockResolvedValue(undefined), + ); + + messenger.registerActionHandler( + 'TokenDetectionController:addDetectedTokensViaWs', + jest.fn().mockResolvedValue(undefined), + ); + + messenger.registerActionHandler( + 'AccountTrackerController:getState', + jest.fn().mockImplementation(() => ({ + accountsByChainId: {}, + })), + ); -const stubCreateEthers = (ctrl: TokensController, res: boolean) => { - return sinon.stub(ctrl, '_createEthersContract').callsFake(() => { - return { - supportsInterface: sinon.stub().returns(res), - } as any; + messenger.registerActionHandler( + 'AccountTrackerController:updateNativeBalances', + jest.fn(), + ); + + messenger.registerActionHandler( + 'AccountTrackerController:updateStakedBalances', + jest.fn(), + ); + + const mockListAccounts = jest.fn().mockReturnValue(listAccounts); + messenger.registerActionHandler( + 'AccountsController:listAccounts', + mockListAccounts, + ); + + messenger.registerActionHandler( + 'AccountsController:getSelectedAccount', + jest.fn().mockImplementation(() => { + // Use first account from listAccounts if available, otherwise default to zero address + if (listAccounts.length > 0) { + return listAccounts[0]; + } + return { address: '0x0000000000000000000000000000000000000000' }; + }), + ); + + messenger.registerActionHandler( + 'TokenDetectionController:detectTokens', + jest.fn().mockResolvedValue(undefined), + ); + + messenger.registerActionHandler( + 'KeyringController:getState', + jest.fn().mockReturnValue({ isUnlocked: true }), + ); + + messenger.registerActionHandler( + 'NetworkController:getNetworkClientById', + jest.fn().mockReturnValue({ + provider: { + request: jest.fn().mockResolvedValue('0x0'), + sendAsync: jest.fn(), + send: jest.fn(), + }, + blockTracker: { + checkForLatestBlock: jest.fn().mockResolvedValue(undefined), + }, + getBlockNumber: jest.fn().mockResolvedValue(1), + }), + ); + const controller = new TokenBalancesController({ + messenger: tokenBalancesControllerMessenger, + ...config, }); + const updateSpy = jest.spyOn(controller, 'update' as never); + + return { + controller, + updateSpy, + messenger, + tokenBalancesControllerMessenger, + }; }; +describe('Utility Functions', () => { + describe('caipChainIdToHex', () => { + it('should convert valid CAIP chain ID to hex', () => { + expect(caipChainIdToHex('eip155:1')).toBe('0x1'); + expect(caipChainIdToHex('eip155:137')).toBe('0x89'); + expect(caipChainIdToHex('eip155:42161')).toBe('0xa4b1'); + }); + + it('should return hex string unchanged if already in hex format', () => { + expect(caipChainIdToHex('0x1')).toBe('0x1'); + expect(caipChainIdToHex('0x89')).toBe('0x89'); + expect(caipChainIdToHex('0xa4b1')).toBe('0xa4b1'); + }); + + it('should throw error for invalid CAIP chain ID format', () => { + expect(() => caipChainIdToHex('invalid-chain-id')).toThrow( + 'caipChainIdToHex - Failed to provide CAIP-2 or Hex chainId', + ); + expect(() => caipChainIdToHex('eip155')).toThrow( + 'caipChainIdToHex - Failed to provide CAIP-2 or Hex chainId', + ); + expect(() => caipChainIdToHex('not-caip-format')).toThrow( + 'caipChainIdToHex - Failed to provide CAIP-2 or Hex chainId', + ); + }); + + it('should throw error for empty string', () => { + expect(() => caipChainIdToHex('')).toThrow( + 'caipChainIdToHex - Failed to provide CAIP-2 or Hex chainId', + ); + }); + }); + + describe('parseAssetType', () => { + it('should parse ERC20 token asset type correctly', () => { + const result = parseAssetType( + 'eip155:1/erc20:0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48', + ); + expect(result).toStrictEqual([ + '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48', + false, + ]); + }); + + it('should parse native token asset type (slip44) correctly', () => { + const result = parseAssetType('eip155:1/slip44:60'); + expect(result).toStrictEqual([ + '0x0000000000000000000000000000000000000000', + true, + ]); + }); + + it('should return null for invalid CAIP asset type format', () => { + expect(parseAssetType('not-a-caip-format')).toBeNull(); + expect(parseAssetType('eip155:1')).toBeNull(); + expect(parseAssetType('invalid/format')).toBeNull(); + expect(parseAssetType('')).toBeNull(); + }); + + it('should return null for unsupported asset namespace', () => { + const result = parseAssetType('eip155:1/unknown:0x123'); + expect(result).toBeNull(); + }); + + it('should handle different chain references', () => { + expect( + parseAssetType( + 'eip155:137/erc20:0x2791Bca1f2de4661ED88A30C99A7a9449Aa84174', + ), + ).toStrictEqual(['0x2791Bca1f2de4661ED88A30C99A7a9449Aa84174', false]); + + expect(parseAssetType('eip155:137/slip44:60')).toStrictEqual([ + '0x0000000000000000000000000000000000000000', + true, + ]); + }); + }); +}); + +describe('mergeUpdateBalancesOptions', () => { + const arrangeChainIdInput = ( + chainIds: ChainIdHex[] | undefined, + ): UpdateBalancesOptions => ({ + chainIds, + tokenAddresses: undefined, + queryAllAccounts: undefined, + }); + + const arrangeTokenAddressesInput = ( + tokenAddresses: string[] | undefined, + ): UpdateBalancesOptions => ({ + chainIds: undefined, + tokenAddresses, + queryAllAccounts: undefined, + }); + + const arrangeQueryAllAccountsInput = ( + queryAllAccounts: boolean | undefined, + ): UpdateBalancesOptions => ({ + chainIds: undefined, + tokenAddresses: undefined, + queryAllAccounts, + }); + + const chainIdTestCases: { + testName: string; + balanceInputs: [UpdateBalancesOptions, UpdateBalancesOptions]; + expectedOutput: Partial; + }[] = [ + { + testName: 'merges chainIds as union when both specify chainIds', + balanceInputs: [ + arrangeChainIdInput(['0x1']), + arrangeChainIdInput(['0x89', '0x1']), + ], + expectedOutput: { chainIds: ['0x1', '0x89'] }, + }, + { + testName: 'returns undefined chainIds when first option has no chainIds', + balanceInputs: [ + arrangeChainIdInput(undefined), + arrangeChainIdInput(['0x89']), + ], + expectedOutput: { chainIds: undefined }, + }, + { + testName: 'returns undefined chainIds when second option has no chainIds', + balanceInputs: [ + arrangeChainIdInput(['0x1']), + arrangeChainIdInput(undefined), + ], + expectedOutput: { chainIds: undefined }, + }, + { + testName: 'returns undefined chainIds when neither option has chainIds', + balanceInputs: [ + arrangeChainIdInput(undefined), + arrangeChainIdInput(undefined), + ], + expectedOutput: { chainIds: undefined }, + }, + ]; + + const tokenAddressesTestCases: { + testName: string; + balanceInputs: [UpdateBalancesOptions, UpdateBalancesOptions]; + expectedOutput: Partial; + }[] = [ + { + testName: + 'merges tokenAddresses as union when both specify tokenAddresses', + balanceInputs: [ + arrangeTokenAddressesInput(['0xabc', '0xdef']), + arrangeTokenAddressesInput(['0xdef', '0x123']), + ], + expectedOutput: { + tokenAddresses: ['0xabc', '0xdef', '0x123'], + }, + }, + { + testName: + 'returns undefined tokenAddresses when first option has no tokenAddresses', + balanceInputs: [ + arrangeTokenAddressesInput(undefined), + arrangeTokenAddressesInput(['0xdef']), + ], + expectedOutput: { tokenAddresses: undefined }, + }, + { + testName: + 'returns undefined tokenAddresses when second option has no tokenAddresses', + balanceInputs: [ + arrangeTokenAddressesInput(['0xabc']), + arrangeTokenAddressesInput(undefined), + ], + expectedOutput: { tokenAddresses: undefined }, + }, + ]; + + const queryAllAccountsTestCases: { + testName: string; + balanceInputs: [UpdateBalancesOptions, UpdateBalancesOptions]; + expectedOutput: Partial; + }[] = [ + { + testName: 'returns true when first is true (true, false)', + balanceInputs: [ + arrangeQueryAllAccountsInput(true), + arrangeQueryAllAccountsInput(false), + ], + expectedOutput: { queryAllAccounts: true }, + }, + { + testName: 'returns true when second is true (false, true)', + balanceInputs: [ + arrangeQueryAllAccountsInput(false), + arrangeQueryAllAccountsInput(true), + ], + expectedOutput: { queryAllAccounts: true }, + }, + { + testName: 'returns true when both have queryAllAccounts true', + balanceInputs: [ + arrangeQueryAllAccountsInput(true), + arrangeQueryAllAccountsInput(true), + ], + expectedOutput: { queryAllAccounts: true }, + }, + { + testName: 'returns true when second is true and first is undefined', + balanceInputs: [ + arrangeQueryAllAccountsInput(undefined), + arrangeQueryAllAccountsInput(true), + ], + expectedOutput: { queryAllAccounts: true }, + }, + { + testName: 'returns false when both have queryAllAccounts false', + balanceInputs: [ + arrangeQueryAllAccountsInput(false), + arrangeQueryAllAccountsInput(false), + ], + expectedOutput: { queryAllAccounts: false }, + }, + { + testName: 'returns false when both are undefined', + balanceInputs: [ + arrangeQueryAllAccountsInput(undefined), + arrangeQueryAllAccountsInput(undefined), + ], + expectedOutput: { queryAllAccounts: false }, + }, + { + testName: 'returns false when second is false and first is undefined', + balanceInputs: [ + arrangeQueryAllAccountsInput(undefined), + arrangeQueryAllAccountsInput(false), + ], + expectedOutput: { queryAllAccounts: false }, + }, + ]; + + it.each(chainIdTestCases)( + '$testName', + ({ balanceInputs, expectedOutput }) => { + expect( + mergeUpdateBalancesOptions(balanceInputs[0], balanceInputs[1]), + ).toStrictEqual(expect.objectContaining(expectedOutput)); + }, + ); + + it.each(tokenAddressesTestCases)( + '$testName', + ({ balanceInputs, expectedOutput }) => { + expect( + mergeUpdateBalancesOptions(balanceInputs[0], balanceInputs[1]), + ).toStrictEqual(expect.objectContaining(expectedOutput)); + }, + ); + + it.each(queryAllAccountsTestCases)( + '$testName', + ({ balanceInputs, expectedOutput }) => { + expect( + mergeUpdateBalancesOptions(balanceInputs[0], balanceInputs[1]), + ).toStrictEqual(expect.objectContaining(expectedOutput)); + }, + ); + + it('merges all fields together when both options are fully specified', () => { + const a: UpdateBalancesOptions = { + chainIds: ['0x1'], + tokenAddresses: ['0xaaa'], + queryAllAccounts: false, + }; + const b: UpdateBalancesOptions = { + chainIds: ['0x89', '0x1'], + tokenAddresses: ['0xbbb', '0xaaa'], + queryAllAccounts: true, + }; + expect(mergeUpdateBalancesOptions(a, b)).toStrictEqual({ + chainIds: ['0x1', '0x89'], + tokenAddresses: ['0xaaa', '0xbbb'], + queryAllAccounts: true, + }); + }); +}); + describe('TokenBalancesController', () => { - const getToken = ( - tokenBalances: TokenBalancesController, - address: string, - ) => { - const { tokens } = tokenBalances.config; - return tokens.find((token) => token.address === address); - }; + beforeEach(() => { + // Mock safelyExecuteWithTimeout to execute the operation normally by default + mockedSafelyExecuteWithTimeout.mockImplementation( + async (operation: () => Promise) => { + try { + return await operation(); + } catch { + return undefined; + } + }, + ); + }); afterEach(() => { - sinon.restore(); + jest.useRealTimers(); + mockedSafelyExecuteWithTimeout.mockRestore(); + jest.restoreAllMocks(); }); - it('should re-export BN', () => { - expect(exportedBn).toStrictEqual(BN); + it('should set default state', () => { + const { controller } = setupController(); + expect(controller.state).toStrictEqual({ tokenBalances: {} }); }); - it('should set default state', () => { - const tokenBalances = new TokenBalancesController({ - onTokensStateChange: sinon.stub(), - getSelectedAddress: () => '0x1234', - getERC20BalanceOf: sinon.stub(), + describe('account address normalization', () => { + it('should normalize mixed-case account addresses to lowercase on initialization', () => { + const account = '0x393a8d3f7710047324d369a7cb368c0570c335b8'; + const checksummedAccount = '0x393A8D3f7710047324D369a7cB368C0570C335b8'; + const usdcToken = '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48'; + const usdtToken = '0xdAC17F958D2ee523a2206206994597C13D831ec7'; + const daiToken = '0x6B175474E89094C44Da98b954EedeAC495271d0F'; + + // Create state with duplicate accounts - one lowercase, one checksummed + const initialState: TokenBalancesControllerState = { + tokenBalances: { + [account as ChecksumAddress]: { + '0x1': { + [usdcToken]: '0x100', + [usdtToken]: '0x200', + }, + }, + [checksummedAccount as ChecksumAddress]: { + '0x1': { + [daiToken]: '0x300', + }, + '0x89': { + [usdtToken]: '0x400', + }, + }, + }, + }; + + const { controller } = setupController({ + config: { state: initialState }, + }); + + // After normalization, should only have lowercase account + const state = controller.state.tokenBalances; + const lowercaseAccount = account.toLowerCase() as ChecksumAddress; + + // Should have only one account (lowercase) + expect(Object.keys(state)).toHaveLength(1); + expect(state[lowercaseAccount]).toBeDefined(); + expect(state[checksummedAccount as ChecksumAddress]).toBeUndefined(); + + // Should merge balances from both versions + expect(state[lowercaseAccount]['0x1'][usdcToken]).toBe('0x100'); // From lowercase + expect(state[lowercaseAccount]['0x1'][usdtToken]).toBe('0x200'); // From lowercase + expect(state[lowercaseAccount]['0x1'][daiToken]).toBe('0x300'); // From checksummed + expect(state[lowercaseAccount]['0x89'][usdtToken]).toBe('0x400'); // From checksummed + + // Should have all tokens from both versions + expect(Object.keys(state[lowercaseAccount]['0x1'])).toHaveLength(3); }); - expect(tokenBalances.state).toStrictEqual({ contractBalances: {} }); - }); - it('should set default config', () => { - const tokenBalances = new TokenBalancesController({ - onTokensStateChange: sinon.stub(), - getSelectedAddress: () => '0x1234', - getERC20BalanceOf: sinon.stub(), + it('should not update state if all accounts are already lowercase', () => { + const account = '0x393a8d3f7710047324d369a7cb368c0570c335b8'; + const token = '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48'; + + const initialState: TokenBalancesControllerState = { + tokenBalances: { + [account as ChecksumAddress]: { + '0x1': { + [token]: '0x100', + }, + }, + }, + }; + + const { controller } = setupController({ + config: { state: initialState }, + }); + + expect(controller.state.tokenBalances).toStrictEqual( + initialState.tokenBalances, + ); + expect(Object.keys(controller.state.tokenBalances)).toHaveLength(1); + expect(Object.keys(controller.state.tokenBalances)[0]).toBe(account); + expect( + Object.keys(controller.state.tokenBalances).every( + (addr) => addr === addr.toLowerCase(), + ), + ).toBe(true); + }); + + it('should handle empty state without errors', () => { + const initialState: TokenBalancesControllerState = { + tokenBalances: {}, + }; + + expect(() => { + setupController({ + config: { state: initialState }, + }); + }).not.toThrow(); + }); + + it('should handle multiple different accounts with mixed casing', () => { + const account1 = '0x393a8d3f7710047324d369a7cb368c0570c335b8'; + const account1Checksum = '0x393A8D3f7710047324D369a7cB368C0570C335b8'; + const account2 = '0x372effc9bd72a008ce4601f4446dad715e455f97'; + const account2Checksum = '0x372EffC9BD72A008Ce4601F4446DAD715e455F97'; + const usdcToken = '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48'; + const daiToken = '0x6B175474E89094C44Da98b954EedeAC495271d0F'; + + const initialState: TokenBalancesControllerState = { + tokenBalances: { + [account1 as ChecksumAddress]: { + '0x1': { [usdcToken]: '0x100' }, + }, + [account1Checksum as ChecksumAddress]: { + '0x89': { [usdcToken]: '0x200' }, + }, + [account2 as ChecksumAddress]: { + '0x1': { [usdcToken]: '0x300' }, + }, + [account2Checksum as ChecksumAddress]: { + '0x1': { [daiToken]: '0x400' }, // Different token to avoid conflict + }, + }, + }; + + const { controller } = setupController({ + config: { state: initialState }, + }); + + const state = controller.state.tokenBalances; + + // Should have exactly 2 accounts (both lowercase) + expect(Object.keys(state)).toHaveLength(2); + expect(state[account1.toLowerCase() as ChecksumAddress]).toBeDefined(); + expect(state[account2.toLowerCase() as ChecksumAddress]).toBeDefined(); + + expect( + state[account1.toLowerCase() as ChecksumAddress]['0x1'][usdcToken], + ).toBe('0x100'); + expect( + state[account1.toLowerCase() as ChecksumAddress]['0x89'][usdcToken], + ).toBe('0x200'); + + // Check merged balances for account2 (both tokens should exist) + expect( + state[account2.toLowerCase() as ChecksumAddress]['0x1'][usdcToken], + ).toBe('0x300'); + expect( + state[account2.toLowerCase() as ChecksumAddress]['0x1'][daiToken], + ).toBe('0x400'); }); - expect(tokenBalances.config).toStrictEqual({ - interval: 180000, - tokens: [], + + it('should preserve token addresses in checksum format while normalizing account addresses', () => { + const account = '0x393A8D3f7710047324D369a7cB368C0570C335b8'; + const token = '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48'; + + const initialState: TokenBalancesControllerState = { + tokenBalances: { + [account as ChecksumAddress]: { + '0x1': { + [token]: '0x100', + }, + }, + }, + }; + + const { controller } = setupController({ + config: { state: initialState }, + }); + + const state = controller.state.tokenBalances; + const lowercaseAccount = account.toLowerCase() as ChecksumAddress; + + // Token address should remain as-is (checksummed) + expect(state[lowercaseAccount]['0x1'][token]).toBe('0x100'); + // Check that the exact token address key exists + expect(Object.keys(state[lowercaseAccount]['0x1'])).toContain(token); }); }); it('should poll and update balances in the right interval', async () => { - await new Promise((resolve) => { - const mock = sinon.stub( + jest.useFakeTimers({ doNotFake: ['nextTick', 'queueMicrotask'] }); + try { + const pollSpy = jest.spyOn( TokenBalancesController.prototype, - 'updateBalances', + '_executePoll', ); - new TokenBalancesController( - { - onTokensStateChange: sinon.stub(), - getSelectedAddress: () => '0x1234', - getERC20BalanceOf: sinon.stub(), + + const interval = 10; + const { controller } = setupController({ config: { interval } }); + + controller.startPolling({ chainIds: ['0x1'] }); + + await jestAdvanceTime({ duration: 1 }); + expect(pollSpy).toHaveBeenCalled(); + expect(pollSpy).not.toHaveBeenCalledTimes(2); + + await jestAdvanceTime({ duration: interval * 1.5 }); + expect(pollSpy).toHaveBeenCalledTimes(2); + } finally { + jest.useRealTimers(); + } + }); + + it('should update balances on poll', async () => { + const chainId = '0x1'; + const accountAddress = '0x0000000000000000000000000000000000000000'; + const tokenAddress = '0x0000000000000000000000000000000000000001'; + + const tokens = { + allDetectedTokens: {}, + allTokens: { + [chainId]: { + [accountAddress]: [ + { address: tokenAddress, symbol: 's', decimals: 0 }, + ], }, - { interval: 10 }, - ); - expect(mock.called).toBe(true); - expect(mock.calledTwice).toBe(false); - setTimeout(() => { - expect(mock.calledTwice).toBe(true); - resolve(); - }, 15); + }, + }; + + const { controller } = setupController({ tokens }); + expect(controller.state.tokenBalances).toStrictEqual({}); + + const balance = 123456; + jest + .spyOn(multicall, 'getTokenBalancesForMultipleAddresses') + .mockResolvedValue({ + tokenBalances: { + [tokenAddress]: { + [accountAddress]: new BN(balance), + }, + }, + }); + + await controller._executePoll({ + chainIds: [chainId], + queryAllAccounts: true, + }); + + await waitFor(() => { + expect(controller.state.tokenBalances).toStrictEqual({ + [accountAddress]: { + [chainId]: { + [NATIVE_TOKEN_ADDRESS]: '0x0', + [tokenAddress]: toHex(balance), + [STAKING_CONTRACT_ADDRESS]: '0x0', + }, + }, + }); }); }); - it('should not update rates if disabled', async () => { - const tokenBalances = new TokenBalancesController( - { - onTokensStateChange: sinon.stub(), - getSelectedAddress: () => '0x1234', - getERC20BalanceOf: sinon.stub(), - }, - { - disabled: true, - interval: 10, + it('should update balances when they change', async () => { + const chainId = '0x1'; + const accountAddress = '0x0000000000000000000000000000000000000000'; + const tokenAddress = '0x0000000000000000000000000000000000000001'; + + const tokens = { + allDetectedTokens: {}, + allTokens: { + [chainId]: { + [accountAddress]: [ + { address: tokenAddress, symbol: 's', decimals: 0 }, + ], + }, }, - ); - const mock = sinon.stub(tokenBalances, 'update'); - await tokenBalances.updateBalances(); - expect(mock.called).toBe(false); + }; + + const { controller } = setupController({ tokens }); + expect(controller.state.tokenBalances).toStrictEqual({}); + + for (let balance = 0; balance < 10; balance++) { + jest + .spyOn(multicall, 'getTokenBalancesForMultipleAddresses') + .mockResolvedValue({ + tokenBalances: { + [tokenAddress]: { + [accountAddress]: new BN(balance), + }, + }, + }); + + await controller._executePoll({ + chainIds: [chainId], + queryAllAccounts: true, + }); + + await waitFor(() => { + expect(controller.state.tokenBalances).toStrictEqual({ + [accountAddress]: { + [chainId]: { + [NATIVE_TOKEN_ADDRESS]: '0x0', + [tokenAddress]: toHex(balance), + [STAKING_CONTRACT_ADDRESS]: '0x0', + }, + }, + }); + }); + } }); - it('should clear previous interval', async () => { - const mock = sinon.stub(global, 'clearTimeout'); - const tokenBalances = new TokenBalancesController( + it('updates balances when tokens are added', async () => { + const chainId = '0x1'; + const { controller, messenger } = setupController(); + + // Define variables first + const accountAddress = '0x0000000000000000000000000000000000000000'; + const tokenAddress = '0x0000000000000000000000000000000000000001'; + + // No tokens initially + await controller._executePoll({ + chainIds: [chainId], + queryAllAccounts: true, + }); + + await waitFor(() => { + expect(controller.state.tokenBalances).toStrictEqual({}); + }); + + const balance = 123456; + jest + .spyOn(multicall, 'getTokenBalancesForMultipleAddresses') + .mockResolvedValue({ + tokenBalances: { + [tokenAddress]: { + [accountAddress]: new BN(balance), + }, + }, + }); + + // Publish an update with a token + + messenger.publish( + 'TokensController:stateChange', { - onTokensStateChange: sinon.stub(), - getSelectedAddress: () => '0x1234', - getERC20BalanceOf: sinon.stub(), + allDetectedTokens: {}, + allIgnoredTokens: {}, + allTokens: { + [chainId]: { + [accountAddress]: [ + { address: tokenAddress, decimals: 0, symbol: 'S' }, + ], + }, + }, }, - { interval: 1337 }, + [], ); - await new Promise((resolve) => { - setTimeout(() => { - tokenBalances.poll(1338); - expect(mock.called).toBe(true); - resolve(); - }, 100); + + await waitFor(() => { + expect(controller.state.tokenBalances).toStrictEqual({ + [accountAddress]: { + [chainId]: { + [NATIVE_TOKEN_ADDRESS]: '0x0', + [tokenAddress]: toHex(balance), + [STAKING_CONTRACT_ADDRESS]: '0x0', + }, + }, + }); }); }); - const setupControllers = () => { - const messenger: NetworkControllerMessenger = - new ControllerMessenger().getRestricted({ - name: 'NetworkController', - allowedEvents: ['NetworkController:stateChange'], - allowedActions: [], + it('removes balances when tokens are removed', async () => { + const chainId = '0x1'; + const accountAddress = '0x0000000000000000000000000000000000000000'; + const tokenAddress = '0x0000000000000000000000000000000000000001'; + + // Start with a token + const initialTokens = { + allDetectedTokens: {}, + allTokens: { + [chainId]: { + [accountAddress]: [ + { address: tokenAddress, symbol: 's', decimals: 0 }, + ], + }, + }, + }; + + const { controller, messenger, updateSpy } = setupController({ + tokens: initialTokens, + config: { + accountsApiChainIds: () => [], + allowExternalServices: () => true, + }, + }); + + // Set initial balance + const balance = 123456; + jest + .spyOn(multicall, 'getTokenBalancesForMultipleAddresses') + .mockResolvedValue({ + tokenBalances: { + [tokenAddress]: { + [accountAddress]: new BN(balance), + }, + }, }); - new NetworkController({ - messenger, - infuraProjectId: 'potato', - trackMetaMetricsEvent: jest.fn(), + await controller._executePoll({ + chainIds: [chainId], + queryAllAccounts: true, }); - const preferences = new PreferencesController(); - return { messenger, preferences }; - }; - it('should update all balances', async () => { - const { messenger, preferences } = setupControllers(); - const assets = new TokensController({ - chainId: toHex(1), - onPreferencesStateChange: (listener) => preferences.subscribe(listener), - onNetworkStateChange: (listener) => - messenger.subscribe('NetworkController:stateChange', listener), - onTokenListStateChange: sinon.stub(), - getERC20TokenName: sinon.stub(), - getNetworkClientById: sinon.stub() as any, - messenger: undefined as unknown as TokensControllerMessenger, - }); - const address = '0x86fa049857e0209aa7d9e616f7eb3b3b78ecfdb0'; - const tokenBalances = new TokenBalancesController( - { - onTokensStateChange: (listener) => assets.subscribe(listener), - getSelectedAddress: () => preferences.state.selectedAddress, - getERC20BalanceOf: sinon.stub().returns(new BN(1)), - }, + await waitFor(() => { + // Verify initial balance is set + expect(controller.state.tokenBalances).toStrictEqual({ + [accountAddress]: { + [chainId]: { + [NATIVE_TOKEN_ADDRESS]: '0x0', + [tokenAddress]: toHex(balance), + [STAKING_CONTRACT_ADDRESS]: '0x0', + }, + }, + }); + }); + + // Publish an update with no tokens + messenger.publish( + 'TokensController:stateChange', { - interval: 1337, - tokens: [{ address, decimals: 18, symbol: 'EOS', aggregators: [] }], + allDetectedTokens: {}, + allIgnoredTokens: {}, + allTokens: { [chainId]: {} }, }, + [], ); - expect(tokenBalances.state.contractBalances).toStrictEqual({}); - await tokenBalances.updateBalances(); - const mytoken = getToken(tokenBalances, address); - expect(mytoken?.balanceError).toBeNull(); - expect(Object.keys(tokenBalances.state.contractBalances)).toContain( - address, - ); + await waitFor(() => { + // Verify balance was removed + expect(updateSpy).toHaveBeenCalledTimes(2); + expect(controller.state.tokenBalances).toStrictEqual({ + [accountAddress]: { + [chainId]: {}, // Empty balances object + }, + }); + }); + }); + it('skips removing balances when incoming chainIds are not in the current chainIds list for tokenBalances', async () => { + const chainId = '0x1'; + const accountAddress = '0x0000000000000000000000000000000000000000'; + const tokenAddress = '0x0000000000000000000000000000000000000001'; - expect( - tokenBalances.state.contractBalances[address].toNumber(), - ).toBeGreaterThan(0); - - messenger.clearEventSubscriptions('NetworkController:stateChange'); - }); - - it('should handle `getERC20BalanceOf` error case', async () => { - const { messenger, preferences } = setupControllers(); - const assets = new TokensController({ - chainId: toHex(1), - onPreferencesStateChange: (listener) => preferences.subscribe(listener), - onNetworkStateChange: (listener) => - messenger.subscribe('NetworkController:stateChange', listener), - onTokenListStateChange: sinon.stub(), - getERC20TokenName: sinon.stub(), - getNetworkClientById: sinon.stub() as any, - messenger: undefined as unknown as TokensControllerMessenger, - }); - const errorMsg = 'Failed to get balance'; - const address = '0x86fa049857e0209aa7d9e616f7eb3b3b78ecfdb0'; - const getERC20BalanceOfStub = sinon - .stub() - .returns(Promise.reject(new Error(errorMsg))); - const tokenBalances = new TokenBalancesController( - { - onTokensStateChange: (listener) => assets.subscribe(listener), - getSelectedAddress: () => preferences.state.selectedAddress, - getERC20BalanceOf: getERC20BalanceOfStub, + // Start with a token + const initialTokens = { + allDetectedTokens: {}, + allTokens: { + [chainId]: { + [accountAddress]: [ + { address: tokenAddress, symbol: 's', decimals: 0 }, + ], + }, }, + }; + + const { controller, messenger, updateSpy } = setupController({ + tokens: initialTokens, + }); + + // Set initial balance + const balance = 123456; + jest + .spyOn(multicall, 'getTokenBalancesForMultipleAddresses') + .mockResolvedValue({ + tokenBalances: { + [tokenAddress]: { + [accountAddress]: new BN(balance), + }, + }, + }); + + await controller._executePoll({ + chainIds: [chainId], + queryAllAccounts: true, + }); + + await waitFor(() => { + // Verify initial balance is set + expect(controller.state.tokenBalances).toStrictEqual({ + [accountAddress]: { + [chainId]: { + [NATIVE_TOKEN_ADDRESS]: '0x0', + [tokenAddress]: toHex(balance), + [STAKING_CONTRACT_ADDRESS]: '0x0', + }, + }, + }); + }); + + // Publish an update with no tokens + messenger.publish( + 'TokensController:stateChange', { - interval: 1337, - tokens: [{ address, decimals: 18, symbol: 'EOS', aggregators: [] }], + allDetectedTokens: {}, + allIgnoredTokens: {}, + allTokens: { [CHAIN_IDS.BASE]: {} }, }, + [], ); - expect(tokenBalances.state.contractBalances).toStrictEqual({}); - await tokenBalances.updateBalances(); - const mytoken = getToken(tokenBalances, address); - expect(mytoken?.balanceError).toBeInstanceOf(Error); - expect(mytoken?.balanceError).toHaveProperty('message', errorMsg); - expect(tokenBalances.state.contractBalances[address].toNumber()).toBe(0); - - getERC20BalanceOfStub.returns(new BN(1)); - await tokenBalances.updateBalances(); - expect(mytoken?.balanceError).toBeNull(); - expect(Object.keys(tokenBalances.state.contractBalances)).toContain( - address, - ); + await waitFor(() => { + expect(updateSpy).toHaveBeenCalledTimes(2); + expect(controller.state.tokenBalances).toStrictEqual({ + [accountAddress]: { + [chainId]: { + [NATIVE_TOKEN_ADDRESS]: '0x0', + [tokenAddress]: toHex(balance), + [STAKING_CONTRACT_ADDRESS]: '0x0', + }, + }, + }); + }); + }); - expect( - tokenBalances.state.contractBalances[address].toNumber(), - ).toBeGreaterThan(0); + it('skips removing balances when state change with tokens that are already in tokenBalances state', async () => { + const chainId = '0x1'; + const accountAddress = '0x0000000000000000000000000000000000000000'; + const tokenAddress = '0x0000000000000000000000000000000000000001'; - messenger.clearEventSubscriptions('NetworkController:stateChange'); - }); + // Start with a token + const initialTokens = { + allDetectedTokens: {}, + allTokens: { + [chainId]: { + [accountAddress]: [ + { address: tokenAddress, symbol: 's', decimals: 0 }, + ], + }, + }, + }; - it('should subscribe to new sibling assets controllers', async () => { - const { messenger, preferences } = setupControllers(); - const assetsContract = new AssetsContractController({ - chainId: toHex(1), - onPreferencesStateChange: (listener) => preferences.subscribe(listener), - onNetworkStateChange: (listener) => - messenger.subscribe('NetworkController:stateChange', listener), - getNetworkClientById: jest.fn(), + const { controller, messenger, updateSpy } = setupController({ + tokens: initialTokens, }); - const tokensController = new TokensController({ - chainId: toHex(1), - onPreferencesStateChange: (listener) => preferences.subscribe(listener), - onNetworkStateChange: (listener) => - messenger.subscribe('NetworkController:stateChange', listener), - onTokenListStateChange: sinon.stub(), - getERC20TokenName: sinon.stub(), - getNetworkClientById: sinon.stub() as any, - messenger: undefined as unknown as TokensControllerMessenger, + + // Set initial balance + const balance = 123456; + jest + .spyOn(multicall, 'getTokenBalancesForMultipleAddresses') + .mockResolvedValue({ + tokenBalances: { + [tokenAddress]: { + [accountAddress]: new BN(balance), + }, + }, + }); + + await controller._executePoll({ + chainIds: [chainId], + queryAllAccounts: true, }); - const stub = stubCreateEthers(tokensController, false); + await waitFor(() => { + // Verify initial balance is set + expect(controller.state.tokenBalances).toStrictEqual({ + [accountAddress]: { + [chainId]: { + [NATIVE_TOKEN_ADDRESS]: '0x0', + [tokenAddress]: toHex(balance), + [STAKING_CONTRACT_ADDRESS]: '0x0', + }, + }, + }); + }); - const tokenBalances = new TokenBalancesController( + // Publish an update with no tokens + messenger.publish( + 'TokensController:stateChange', { - onTokensStateChange: (listener) => tokensController.subscribe(listener), // needs to be unsubbed? - getSelectedAddress: () => preferences.state.selectedAddress, - getERC20BalanceOf: - assetsContract.getERC20BalanceOf.bind(assetsContract), + allDetectedTokens: {}, + allIgnoredTokens: {}, + allTokens: { + [chainId]: { + [accountAddress]: [ + { address: tokenAddress, symbol: 's', decimals: 0 }, + ], + }, + }, }, - { interval: 1337 }, + [], ); - const updateBalances = sinon.stub(tokenBalances, 'updateBalances'); - await tokensController.addToken({ - address: '0x00', - symbol: 'FOO', - decimals: 18, - }); - const { tokens } = tokensController.state; - const found = tokens.filter((token: Token) => token.address === '0x00'); - expect(found.length > 0).toBe(true); - expect(updateBalances.called).toBe(true); - stub.restore(); - messenger.clearEventSubscriptions('NetworkController:stateChange'); + await waitFor(() => { + // Verify initial balances are still there + expect(updateSpy).toHaveBeenCalledTimes(1); // should be called only once when we first updated the balances and not twice + expect(controller.state.tokenBalances).toStrictEqual({ + [accountAddress]: { + [chainId]: { + [NATIVE_TOKEN_ADDRESS]: '0x0', + [tokenAddress]: toHex(balance), + [STAKING_CONTRACT_ADDRESS]: '0x0', + }, + }, + }); + }); }); - it('should update token balances when detected tokens are added', async () => { - let tokenStateChangeListener: (state: any) => void; - const onTokensStateChange = sinon.stub().callsFake((listener) => { - tokenStateChangeListener = listener; - }); - const tokenBalances = new TokenBalancesController( - { - onTokensStateChange, - getSelectedAddress: () => '0x1234', - getERC20BalanceOf: sinon.stub().returns(new BN(1)), - }, - { - interval: 1337, + it('updates balances for all accounts when multi-account balances is enabled', async () => { + const chainId = '0x1'; + const account1 = '0x0000000000000000000000000000000000000001'; + const account2 = '0x0000000000000000000000000000000000000002'; + const tokenAddress = '0x0000000000000000000000000000000000000003'; + + const tokens = { + allDetectedTokens: {}, + allTokens: { + [chainId]: { + [account1]: [{ address: tokenAddress, symbol: 's', decimals: 0 }], + [account2]: [{ address: tokenAddress, symbol: 's', decimals: 0 }], + }, }, + }; + + const { controller, messenger } = setupController({ + tokens, + listAccounts: [ + createMockInternalAccount({ address: account1 }), + createMockInternalAccount({ address: account2 }), + ], + }); + + // Enable multi account balances + messenger.publish( + 'PreferencesController:stateChange', + { isMultiAccountBalancesEnabled: true } as PreferencesState, + [], ); - expect(tokenBalances.state.contractBalances).toStrictEqual({}); + const balance1 = 100; + const balance2 = 200; + jest + .spyOn(multicall, 'getTokenBalancesForMultipleAddresses') + .mockResolvedValue({ + tokenBalances: { + [tokenAddress]: { + [account1]: new BN(balance1), + [account2]: new BN(balance2), + }, + [NATIVE_TOKEN_ADDRESS]: { + [account1]: new BN(0), + [account2]: new BN(0), + }, + }, + stakedBalances: { + [account1]: new BN(0), + [account2]: new BN(0), + }, + }); + + await controller._executePoll({ + chainIds: [chainId], + queryAllAccounts: true, + }); - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - tokenStateChangeListener!({ - detectedTokens: [ - { - address: '0x02', - decimals: 18, - image: undefined, - symbol: 'bar', - isERC721: false, + await waitFor(() => { + expect(controller.state.tokenBalances).toStrictEqual({ + [account1]: { + [chainId]: { + [NATIVE_TOKEN_ADDRESS]: '0x0', + [tokenAddress]: toHex(balance1), + [STAKING_CONTRACT_ADDRESS]: '0x0', + }, }, - ], - tokens: [], + [account2]: { + [chainId]: { + [NATIVE_TOKEN_ADDRESS]: '0x0', + [tokenAddress]: toHex(balance2), + [STAKING_CONTRACT_ADDRESS]: '0x0', + }, + }, + }); }); + }); - await tokenBalances.updateBalances(); + it('should only update balances for tokens in allTokens or allIgnoredTokens', async () => { + const chainId = '0x1'; + const accountAddress = '0x0000000000000000000000000000000000000000'; + const trackedToken = '0x0000000000000000000000000000000000000001'; + const ignoredToken = '0x0000000000000000000000000000000000000002'; + const untrackedToken = '0x0000000000000000000000000000000000000003'; - expect(tokenBalances.state.contractBalances).toStrictEqual({ - '0x02': new BN(1), + const tokens = { + allDetectedTokens: {}, + allTokens: { + [chainId]: { + [accountAddress]: [ + { address: trackedToken, symbol: 'TRACKED', decimals: 18 }, + ], + }, + }, + allIgnoredTokens: { + [chainId]: { + [accountAddress]: [ignoredToken], + }, + }, + }; + + const { controller } = setupController({ tokens }); + + // Mock balance fetcher to return balances for all three tokens + const trackedBalance = new BN(1000); + const ignoredBalance = new BN(2000); + + jest + .spyOn(multicall, 'getTokenBalancesForMultipleAddresses') + .mockResolvedValue({ + tokenBalances: { + [trackedToken]: { + [accountAddress]: trackedBalance, + }, + [ignoredToken]: { + [accountAddress]: ignoredBalance, + }, + [NATIVE_TOKEN_ADDRESS]: { + [accountAddress]: new BN(0), + }, + }, + stakedBalances: { + [accountAddress]: new BN(0), + }, + }); + + await controller.updateBalances({ chainIds: [chainId] }); + + await waitFor(() => { + // Verify tracked token balance was updated + expect( + controller.state.tokenBalances[accountAddress]?.[chainId]?.[ + trackedToken + ], + ).toBe(toHex(trackedBalance)); + + // Verify ignored token balance was updated (ignored tokens should still be tracked) + expect( + controller.state.tokenBalances[accountAddress]?.[chainId]?.[ + ignoredToken + ], + ).toBe(toHex(ignoredBalance)); + + // Verify untracked token balance was NOT updated + expect( + controller.state.tokenBalances[accountAddress]?.[chainId]?.[ + untrackedToken + ], + ).toBeUndefined(); + + // Verify native token is always updated regardless of tracking + expect( + controller.state.tokenBalances[accountAddress]?.[chainId]?.[ + NATIVE_TOKEN_ADDRESS + ], + ).toBe('0x0'); + }); + }); + + it('should always update native token balances regardless of tracking status', async () => { + const chainId = '0x1'; + const accountAddress = '0x0000000000000000000000000000000000000000'; + const tokenAddress = '0x0000000000000000000000000000000000000001'; + + // One tracked token so the controller fetches; we assert native is always updated + const tokens = { + allDetectedTokens: {}, + allTokens: { + [chainId]: { + [accountAddress]: [ + { address: tokenAddress, symbol: 'T', decimals: 18 }, + ], + }, + }, + allIgnoredTokens: {}, + }; + + const { controller } = setupController({ + tokens, + listAccounts: [createMockInternalAccount({ address: accountAddress })], + }); + + const nativeBalance = new BN('1000000000000000000'); // 1 ETH + + jest + .spyOn(multicall, 'getTokenBalancesForMultipleAddresses') + .mockResolvedValue({ + tokenBalances: { + [NATIVE_TOKEN_ADDRESS]: { + [accountAddress]: nativeBalance, + }, + [tokenAddress]: { + [accountAddress]: new BN(0), + }, + }, + stakedBalances: { + [accountAddress]: new BN(0), + }, + }); + + await controller.updateBalances({ chainIds: [chainId] }); + + await waitFor(() => { + // Verify native token balance was updated + expect( + controller.state.tokenBalances[accountAddress]?.[chainId]?.[ + NATIVE_TOKEN_ADDRESS + ], + ).toBe(toHex(nativeBalance)); + }); + }); + + it('should filter untracked tokens from balance updates', async () => { + const chainId = '0x1'; + const accountAddress = '0x0000000000000000000000000000000000000000'; + const trackedToken = '0x0000000000000000000000000000000000000001'; + const untrackedToken = '0x0000000000000000000000000000000000000002'; + + const tokens = { + allTokens: { + [chainId]: { + [accountAddress]: [ + { address: trackedToken, symbol: 'TRACKED', decimals: 18 }, + ], + }, + }, + allDetectedTokens: {}, + allIgnoredTokens: {}, + }; + + const { controller } = setupController({ tokens }); + + const trackedBalance = new BN(1000); + + jest + .spyOn(multicall, 'getTokenBalancesForMultipleAddresses') + .mockResolvedValue({ + tokenBalances: { + [trackedToken]: { + [accountAddress]: trackedBalance, + }, + [NATIVE_TOKEN_ADDRESS]: { + [accountAddress]: new BN(0), + }, + }, + stakedBalances: { + [accountAddress]: new BN(0), + }, + }); + + await controller.updateBalances({ chainIds: [chainId] }); + + await waitFor(() => { + // Verify tracked token balance was updated + + expect( + controller.state.tokenBalances[accountAddress]?.[chainId]?.[ + trackedToken + ], + ).toBe(toHex(trackedBalance)); + + // Verify untracked token balance was NOT updated + expect( + controller.state.tokenBalances[accountAddress]?.[chainId]?.[ + untrackedToken + ], + ).toBeUndefined(); + }); + }); + + it('does not update balances when multi-account balances is enabled and all returned values did not change', async () => { + const chainId = '0x1'; + const account1 = '0x0000000000000000000000000000000000000001'; + const account2 = '0x0000000000000000000000000000000000000002'; + const tokenAddress = '0x0000000000000000000000000000000000000003'; + + const tokens = { + allDetectedTokens: {}, + allTokens: { + [chainId]: { + [account1]: [{ address: tokenAddress, symbol: 's', decimals: 0 }], + [account2]: [{ address: tokenAddress, symbol: 's', decimals: 0 }], + }, + }, + }; + + const { controller, messenger, updateSpy } = setupController({ tokens }); + + // Enable multi account balances + messenger.publish( + 'PreferencesController:stateChange', + { isMultiAccountBalancesEnabled: true } as PreferencesState, + [], + ); + + const balance1 = 100; + const balance2 = 200; + jest + .spyOn(multicall, 'getTokenBalancesForMultipleAddresses') + .mockResolvedValue({ + tokenBalances: { + [tokenAddress]: { + [account1]: new BN(balance1), + [account2]: new BN(balance2), + }, + }, + }); + + await controller._executePoll({ + chainIds: [chainId], + queryAllAccounts: true, + }); + + await waitFor(() => { + expect(controller.state.tokenBalances).toStrictEqual({ + [account1]: { + [chainId]: { + [NATIVE_TOKEN_ADDRESS]: '0x0', + [tokenAddress]: toHex(balance1), + [STAKING_CONTRACT_ADDRESS]: '0x0', + }, + }, + [account2]: { + [chainId]: { + [NATIVE_TOKEN_ADDRESS]: '0x0', + [tokenAddress]: toHex(balance2), + [STAKING_CONTRACT_ADDRESS]: '0x0', + }, + }, + }); + }); + + await controller._executePoll({ + chainIds: [chainId], + queryAllAccounts: true, + }); + + await waitFor(() => { + // Should only update once since the values haven't changed + expect(updateSpy).toHaveBeenCalledTimes(1); + }); + }); + + it('does not update balances when multi-account balances is enabled and multi-account contract failed', async () => { + const chainId = '0x1'; + const account1 = '0x0000000000000000000000000000000000000000'; + const tokenAddress = '0x0000000000000000000000000000000000000003'; + + const tokens = { + allDetectedTokens: {}, + allTokens: { + [chainId]: { + [account1]: [{ address: tokenAddress, symbol: 's', decimals: 0 }], + }, + }, + }; + + const { controller, messenger, updateSpy } = setupController({ + tokens, + listAccounts: [createMockInternalAccount({ address: account1 })], + }); + + // Enable multi account balances + messenger.publish( + 'PreferencesController:stateChange', + { isMultiAccountBalancesEnabled: true } as PreferencesState, + [], + ); + + // Mock Promise allSettled to return a failure for the multi-account contract + jest + .spyOn(multicall, 'getTokenBalancesForMultipleAddresses') + .mockResolvedValue({ tokenBalances: {} }); + + await controller._executePoll({ + chainIds: [chainId], + queryAllAccounts: true, + }); + + await waitFor(() => { + expect(controller.state.tokenBalances).toStrictEqual({ + [account1]: { + [chainId]: { + [NATIVE_TOKEN_ADDRESS]: '0x0', + [tokenAddress]: '0x0', + [STAKING_CONTRACT_ADDRESS]: '0x0', + }, + }, + }); + }); + + await controller._executePoll({ + chainIds: [chainId], + queryAllAccounts: true, + }); + + await waitFor(() => { + expect(updateSpy).toHaveBeenCalledTimes(1); // Called once because native/staking balances are added + }); + }); + + it('updates balances when multi-account balances is enabled and some returned values changed', async () => { + const chainId = '0x1'; + const account1 = '0x0000000000000000000000000000000000000001'; + const account2 = '0x0000000000000000000000000000000000000002'; + const tokenAddress = '0x0000000000000000000000000000000000000003'; + + const tokens = { + allDetectedTokens: {}, + allTokens: { + [chainId]: { + [account1]: [{ address: tokenAddress, symbol: 's', decimals: 0 }], + [account2]: [{ address: tokenAddress, symbol: 's', decimals: 0 }], + }, + }, + }; + + const { controller, messenger, updateSpy } = setupController({ tokens }); + + // Enable multi account balances + messenger.publish( + 'PreferencesController:stateChange', + { isMultiAccountBalancesEnabled: true } as PreferencesState, + [], + ); + + const balance1 = 100; + const balance2 = 200; + const balance3 = 300; + jest + .spyOn(multicall, 'getTokenBalancesForMultipleAddresses') + .mockResolvedValue({ + tokenBalances: { + [tokenAddress]: { + [account1]: new BN(balance1), + [account2]: new BN(balance2), + }, + }, + }); + + await controller._executePoll({ + chainIds: [chainId], + queryAllAccounts: true, + }); + + await waitFor(() => { + expect(controller.state.tokenBalances).toStrictEqual({ + [account1]: { + [chainId]: { + [NATIVE_TOKEN_ADDRESS]: '0x0', + [tokenAddress]: toHex(balance1), + [STAKING_CONTRACT_ADDRESS]: '0x0', + }, + }, + [account2]: { + [chainId]: { + [NATIVE_TOKEN_ADDRESS]: '0x0', + [tokenAddress]: toHex(balance2), + [STAKING_CONTRACT_ADDRESS]: '0x0', + }, + }, + }); + }); + + jest + .spyOn(multicall, 'getTokenBalancesForMultipleAddresses') + .mockClear() + .mockResolvedValue({ + tokenBalances: { + [tokenAddress]: { + [account1]: new BN(balance1), + [account2]: new BN(balance3), + }, + }, + }); + + await controller._executePoll({ + chainIds: [chainId], + queryAllAccounts: true, + }); + + await waitFor(() => { + expect(controller.state.tokenBalances).toStrictEqual({ + [account1]: { + [chainId]: { + [NATIVE_TOKEN_ADDRESS]: '0x0', + [tokenAddress]: toHex(balance1), + [STAKING_CONTRACT_ADDRESS]: '0x0', + }, + }, + [account2]: { + [chainId]: { + [NATIVE_TOKEN_ADDRESS]: '0x0', + [tokenAddress]: toHex(balance3), + [STAKING_CONTRACT_ADDRESS]: '0x0', + }, + }, + }); + }); + + expect(updateSpy).toHaveBeenCalledTimes(2); + }); + + it('only updates selected account balance when multi-account balances is disabled', async () => { + const chainId = '0x1'; + const selectedAccount = '0x0000000000000000000000000000000000000002'; + const otherAccount = '0x0000000000000000000000000000000000000001'; + const tokenAddress = '0x0000000000000000000000000000000000000002'; + + const tokens = { + allDetectedTokens: {}, + allTokens: { + [chainId]: { + [selectedAccount]: [ + { address: tokenAddress, symbol: 's', decimals: 0 }, + ], + [otherAccount]: [{ address: tokenAddress, symbol: 's', decimals: 0 }], + }, + }, + }; + + const { controller } = setupController({ + config: { queryMultipleAccounts: false }, + tokens, + listAccounts: [ + createMockInternalAccount({ address: selectedAccount }), + createMockInternalAccount({ address: otherAccount }), + ], + }); + + const balance = 100; + jest + .spyOn(multicall, 'getTokenBalancesForMultipleAddresses') + .mockResolvedValue({ + tokenBalances: { + [tokenAddress]: { + [selectedAccount]: new BN(balance), + }, + }, + }); + + await controller._executePoll({ + chainIds: [chainId], + queryAllAccounts: false, + }); + + await waitFor(() => { + // Should only contain balance for selected account + expect(controller.state.tokenBalances).toStrictEqual({ + [selectedAccount]: { + [chainId]: { + [NATIVE_TOKEN_ADDRESS]: '0x0', + [tokenAddress]: toHex(balance), + [STAKING_CONTRACT_ADDRESS]: '0x0', + }, + }, + }); + }); + }); + + it('removes balances when networks are deleted', async () => { + const chainId = '0x1'; + const accountAddress = '0x0000000000000000000000000000000000000000'; + const tokenAddress = '0x0000000000000000000000000000000000000001'; + + // Start with a token balance + const initialState = { + tokenBalances: { + [accountAddress]: { + [chainId]: { + [tokenAddress]: toHex(123456), + }, + }, + }, + }; + + const { controller, messenger } = setupController({ + config: { state: initialState }, + }); + + // Verify initial state matches + expect(controller.state.tokenBalances).toStrictEqual( + initialState.tokenBalances, + ); + + // Simulate network deletion by publishing a network state change + messenger.publish( + 'NetworkController:stateChange', + { + networkConfigurationsByChainId: {}, + } as NetworkState, + [ + { + op: 'remove', + path: ['networkConfigurationsByChainId', chainId], + }, + ], + ); + + // Verify the balances for the deleted network were removed + expect( + controller.state.tokenBalances[accountAddress][chainId], + ).toBeUndefined(); + }); + + describe('resetState', () => { + it('resets the state to default state', () => { + const initialState: TokenBalancesControllerState = { + tokenBalances: { + '0x0000000000000000000000000000000000000001': { + '0x1': { + '0x86fa049857e0209aa7d9e616f7eb3b3b78ecfdb0': toHex(new BN(1)), + }, + }, + }, + }; + + const { controller } = setupController({ + config: { state: initialState }, + }); + + expect(controller.state).toStrictEqual(initialState); + + controller.resetState(); + + expect(controller.state).toStrictEqual({ + tokenBalances: {}, + }); + }); + }); + + describe('isDeprecated', () => { + const initialState: TokenBalancesControllerState = { + tokenBalances: { + '0x0000000000000000000000000000000000000001': { + '0x1': { + '0x86fa049857e0209aa7d9e616f7eb3b3b78ecfdb0': toHex(new BN(1)), + }, + }, + }, + }; + + it('clears persisted tokenBalances at construction when isDeprecated() returns true', () => { + const { controller } = setupController({ + config: { state: initialState, isDeprecated: () => true }, + }); + + expect(controller.state.tokenBalances).toStrictEqual({}); + }); + + it('preserves persisted tokenBalances at construction when isDeprecated() returns false', () => { + const { controller } = setupController({ + config: { state: initialState, isDeprecated: () => false }, + }); + + expect(controller.state.tokenBalances).toStrictEqual( + initialState.tokenBalances, + ); + }); + + it('does not fetch and clears stale tokenBalances when isDeprecated returns true', async () => { + let deprecated = false; + const { controller } = setupController({ + config: { state: initialState, isDeprecated: () => deprecated }, + }); + + expect(controller.state.tokenBalances).toStrictEqual( + initialState.tokenBalances, + ); + + const fetchSpy = jest.spyOn( + multicall, + 'getTokenBalancesForMultipleAddresses', + ); + + deprecated = true; + + await controller.updateBalances({ chainIds: ['0x1'] }); + + expect(fetchSpy).not.toHaveBeenCalled(); + expect(controller.state.tokenBalances).toStrictEqual({}); + }); + + it('clears stale tokenBalances on _executePoll when isDeprecated toggles to true at runtime', async () => { + let deprecated = false; + const { controller } = setupController({ + config: { state: initialState, isDeprecated: () => deprecated }, + }); + + expect(controller.state.tokenBalances).toStrictEqual( + initialState.tokenBalances, + ); + + const fetchSpy = jest.spyOn( + multicall, + 'getTokenBalancesForMultipleAddresses', + ); + + deprecated = true; + + await controller._executePoll({ chainIds: ['0x1'] }); + + expect(fetchSpy).not.toHaveBeenCalled(); + expect(controller.state.tokenBalances).toStrictEqual({}); + }); + + it('clears stale tokenBalances on NetworkController:stateChange when isDeprecated toggles to true at runtime', () => { + let deprecated = false; + const { controller, messenger } = setupController({ + config: { state: initialState, isDeprecated: () => deprecated }, + }); + + expect(controller.state.tokenBalances).toStrictEqual( + initialState.tokenBalances, + ); + + deprecated = true; + + messenger.publish( + 'NetworkController:stateChange', + { + networkConfigurationsByChainId: {}, + } as NetworkState, + [ + { + op: 'remove', + path: ['networkConfigurationsByChainId', '0x1'], + }, + ], + ); + + expect(controller.state.tokenBalances).toStrictEqual({}); + }); + + it('does not throw at construction when isDeprecated() is true and state is already empty', () => { + const { controller } = setupController({ + config: { isDeprecated: () => true }, + }); + + expect(controller.state.tokenBalances).toStrictEqual({}); + }); + + it('clears stale tokenBalances when a queued batched update flushes after isDeprecated toggles to true', async () => { + jest.useFakeTimers(); + let deprecated = false; + const { controller } = setupController({ + config: { state: initialState, isDeprecated: () => deprecated }, + }); + + const fetchSpy = jest.spyOn( + multicall, + 'getTokenBalancesForMultipleAddresses', + ); + + // Schedule a batched update while still enabled. + const promise = controller.updateBalances({ chainIds: ['0x1'] }); + + // Toggle deprecation before the batch flushes. + deprecated = true; + + await jest.advanceTimersByTimeAsync(UPDATE_BALANCES_BATCH_MS); + await promise; + + expect(fetchSpy).not.toHaveBeenCalled(); + expect(controller.state.tokenBalances).toStrictEqual({}); + }); + + it('clears stale tokenBalances on TokensController:stateChange when isDeprecated toggles to true at runtime', async () => { + let deprecated = false; + const { controller, messenger } = setupController({ + config: { state: initialState, isDeprecated: () => deprecated }, + }); + + deprecated = true; + + messenger.publish( + 'TokensController:stateChange', + { + allDetectedTokens: {}, + allIgnoredTokens: {}, + allTokens: { + '0x1': { + '0x0000000000000000000000000000000000000001': [ + { address: '0xtoken', decimals: 0, symbol: 'S' }, + ], + }, + }, + } as unknown as TokensControllerState, + [], + ); + + await flushPromises(); + + expect(controller.state.tokenBalances).toStrictEqual({}); + }); + + it('clears stale tokenBalances on KeyringController:accountRemoved when isDeprecated toggles to true at runtime', () => { + let deprecated = false; + const { controller, messenger } = setupController({ + config: { state: initialState, isDeprecated: () => deprecated }, + }); + + deprecated = true; + + messenger.publish( + 'KeyringController:accountRemoved', + '0x0000000000000000000000000000000000000001', + ); + + expect(controller.state.tokenBalances).toStrictEqual({}); + }); + + it('clears stale tokenBalances on AccountsController:selectedEvmAccountChange when isDeprecated toggles to true at runtime', () => { + let deprecated = false; + const { controller, messenger } = setupController({ + config: { state: initialState, isDeprecated: () => deprecated }, + tokens: { + allTokens: { + '0x1': { + '0x0000000000000000000000000000000000000001': [ + { address: '0xtoken', decimals: 0, symbol: 'S' }, + ], + }, + }, + allDetectedTokens: {}, + allIgnoredTokens: {}, + }, + }); + + deprecated = true; + + messenger.publish( + 'AccountsController:selectedEvmAccountChange', + createMockInternalAccount({ + address: '0x0000000000000000000000000000000000000001', + }), + ); + + expect(controller.state.tokenBalances).toStrictEqual({}); + }); + + it('clears stale tokenBalances on AccountActivityService:balanceUpdated when isDeprecated toggles to true at runtime', async () => { + let deprecated = false; + const { controller, messenger } = setupController({ + config: { state: initialState, isDeprecated: () => deprecated }, + }); + + deprecated = true; + + messenger.publish('AccountActivityService:balanceUpdated', { + address: '0x0000000000000000000000000000000000000001', + chain: 'eip155:1', + updates: [ + { + asset: { + type: 'eip155:1/slip44:60', + unit: 'ETH', + fungible: true, + decimals: 18, + }, + postBalance: { amount: '0x1' }, + transfers: [], + }, + ], + }); + + await flushPromises(); + + expect(controller.state.tokenBalances).toStrictEqual({}); + }); + + it('clears stale tokenBalances on AccountActivityService:statusChanged when isDeprecated toggles to true at runtime', () => { + let deprecated = false; + const { controller, messenger } = setupController({ + config: { state: initialState, isDeprecated: () => deprecated }, + }); + + deprecated = true; + + messenger.publish('AccountActivityService:statusChanged', { + chainIds: ['eip155:1'], + status: 'up', + }); + + expect(controller.state.tokenBalances).toStrictEqual({}); + }); + }); + + describe('when accountRemoved is published', () => { + it('does not update state if account removed is EVM account', async () => { + const { controller, messenger, updateSpy } = setupController(); + + messenger.publish('KeyringController:accountRemoved', 'toto'); + + expect(controller.state.tokenBalances).toStrictEqual({}); + expect(updateSpy).toHaveBeenCalledTimes(0); + }); + it('removes the balances for the removed account', async () => { + const chainId = '0x1'; + const accountAddress = '0x0000000000000000000000000000000000000000'; + const accountAddress2 = '0x0000000000000000000000000000000000000002'; + const tokenAddress = '0x0000000000000000000000000000000000000001'; + const tokenAddress2 = '0x0000000000000000000000000000000000000022'; + const account = createMockInternalAccount({ + address: accountAddress, + }); + const account2 = createMockInternalAccount({ + address: accountAddress2, + }); + + const tokens = { + allDetectedTokens: {}, + allTokens: { + [chainId]: { + [accountAddress]: [ + { address: tokenAddress, symbol: 's', decimals: 0 }, + ], + [accountAddress2]: [ + { address: tokenAddress2, symbol: 't', decimals: 0 }, + ], + }, + }, + }; + + const { controller, messenger } = setupController({ + config: { + accountsApiChainIds: () => [], + allowExternalServices: () => true, + }, + tokens, + listAccounts: [account, account2], + }); + // Enable multi account balances + messenger.publish( + 'PreferencesController:stateChange', + { isMultiAccountBalancesEnabled: true } as PreferencesState, + [], + ); + expect(controller.state.tokenBalances).toStrictEqual({}); + + const balance = 123456; + const balance2 = 200; + jest + .spyOn(multicall, 'getTokenBalancesForMultipleAddresses') + .mockResolvedValue({ + tokenBalances: { + [tokenAddress]: { + [accountAddress]: new BN(balance), + }, + [tokenAddress2]: { + [accountAddress2]: new BN(balance2), + }, + }, + }); + + await controller._executePoll({ + chainIds: [chainId], + queryAllAccounts: true, + }); + + await waitFor(() => { + expect(controller.state.tokenBalances).toStrictEqual({ + [accountAddress]: { + [chainId]: { + [NATIVE_TOKEN_ADDRESS]: '0x0', + [tokenAddress]: toHex(balance), + [STAKING_CONTRACT_ADDRESS]: '0x0', + }, + }, + [accountAddress2]: { + [chainId]: { + [NATIVE_TOKEN_ADDRESS]: '0x0', + [tokenAddress2]: toHex(balance2), + [STAKING_CONTRACT_ADDRESS]: '0x0', + }, + }, + }); + }); + + messenger.publish('KeyringController:accountRemoved', account.address); + + await waitFor(() => { + expect(controller.state.tokenBalances).toStrictEqual({ + [accountAddress2]: { + [chainId]: { + [NATIVE_TOKEN_ADDRESS]: '0x0', + [tokenAddress2]: toHex(balance2), + [STAKING_CONTRACT_ADDRESS]: '0x0', + }, + }, + }); + }); + }); + }); + + describe('when selectedEvmAccountChange is published', () => { + it('calls updateBalances when account changes and tokens exist', async () => { + const chainId = '0x1'; + const accountAddress = '0x0000000000000000000000000000000000000001'; + const tokenAddress = '0x0000000000000000000000000000000000000010'; + const account = createMockInternalAccount({ + address: accountAddress, + }); + + const tokens = { + allDetectedTokens: {}, + allTokens: { + [chainId]: { + [accountAddress]: [ + { address: tokenAddress, symbol: 'TEST', decimals: 18 }, + ], + }, + }, + }; + + const updateBalancesSpy = jest + .spyOn(TokenBalancesController.prototype, 'updateBalances') + .mockResolvedValue(undefined); + + const { messenger } = setupController({ + config: { + accountsApiChainIds: () => [], + allowExternalServices: () => true, + }, + tokens, + listAccounts: [account], + }); + + // Publish account change event + messenger.publish('AccountsController:selectedEvmAccountChange', account); + + // Verify updateBalances was called with correct chainIds + expect(updateBalancesSpy).toHaveBeenCalledWith({ + chainIds: [chainId], + }); + + updateBalancesSpy.mockRestore(); + }); + + it('does not call updateBalances when no tokens exist', async () => { + const account = createMockInternalAccount({ + address: '0x0000000000000000000000000000000000000001', + }); + + const updateBalancesSpy = jest.spyOn( + TokenBalancesController.prototype, + 'updateBalances', + ); + + const { messenger } = setupController({ + config: { + accountsApiChainIds: () => [], + allowExternalServices: () => true, + }, + tokens: { + allTokens: {}, + allDetectedTokens: {}, + }, + listAccounts: [account], + }); + + // Publish account change event + messenger.publish('AccountsController:selectedEvmAccountChange', account); + + // Should not call updateBalances when there are no chains with tokens + expect(updateBalancesSpy).not.toHaveBeenCalled(); + + updateBalancesSpy.mockRestore(); + }); + }); + + describe('multicall integration', () => { + it('should use getTokenBalancesForMultipleAddresses when available', async () => { + const mockGetTokenBalances = jest + .spyOn(multicall, 'getTokenBalancesForMultipleAddresses') + .mockResolvedValueOnce({ + tokenBalances: { + '0x6B175474E89094C44Da98b954EedeAC495271d0F': { + '0x1234567890123456789012345678901234567890': new BN('1000'), + }, + }, + stakedBalances: {}, + }); + + const { controller } = setupController({ + config: { + accountsApiChainIds: () => [], + allowExternalServices: () => true, + }, + tokens: { + allTokens: { + '0x1': { + '0x1234567890123456789012345678901234567890': [ + { + address: '0x6B175474E89094C44Da98b954EedeAC495271d0F', + symbol: 'DAI', + decimals: 18, + }, + ], + }, + }, + allDetectedTokens: {}, + }, + listAccounts: [ + createMockInternalAccount({ + address: '0x1234567890123456789012345678901234567890', + }), + ], + }); + + await controller.updateBalances({ + chainIds: ['0x1'], + queryAllAccounts: true, + }); + + await waitFor(() => { + // Verify the new multicall function was called + expect(mockGetTokenBalances).toHaveBeenCalled(); + }); + }); + + it('should use queryAllAccounts when provided', async () => { + const accountAddress = '0x1234567890123456789012345678901234567890'; + const tokenAddress = '0x6B175474E89094C44Da98b954EedeAC495271d0F'; + + // Mock the RPC balance fetcher's fetch method to verify the parameter + const mockRpcFetch = jest.spyOn(RpcBalanceFetcher.prototype, 'fetch'); + mockRpcFetch.mockResolvedValueOnce({ balances: [] }); + + const { controller } = setupController({ + config: { + accountsApiChainIds: () => [], // Use RPC fetcher + allowExternalServices: () => true, + queryMultipleAccounts: false, // Default is false + }, + tokens: { + allTokens: { + '0x1': { + [accountAddress]: [ + { + address: tokenAddress, + symbol: 'DAI', + decimals: 18, + }, + ], + }, + }, + allDetectedTokens: {}, + }, + listAccounts: [ + createMockInternalAccount({ + address: accountAddress, + }), + ], + }); + + await controller.updateBalances({ + chainIds: ['0x1'], + queryAllAccounts: true, + }); + + await waitFor(() => { + // Verify RPC fetcher was called with queryAllAccounts: true + expect(mockRpcFetch).toHaveBeenCalledWith( + expect.objectContaining({ + chainIds: ['0x1'], + queryAllAccounts: true, + }), + ); + }); + + mockRpcFetch.mockRestore(); + }); + }); + + describe('edge cases and error handling', () => { + it('should handle single account mode configuration', async () => { + const accountAddress = '0x1111111111111111111111111111111111111111'; + + const { controller } = setupController({ + config: { + accountsApiChainIds: () => [], + allowExternalServices: () => true, + }, + tokens: { + allTokens: { + '0x1': { + [accountAddress]: [ + { address: '0xToken1', symbol: 'TK1', decimals: 18 }, + ], + }, + }, + allDetectedTokens: {}, + }, + }); + + jest + .spyOn(multicall, 'getTokenBalancesForMultipleAddresses') + .mockResolvedValue({ + tokenBalances: { + '0xToken1': { + [accountAddress]: new BN(100), + }, + }, + }); + + await controller.updateBalances({ + chainIds: ['0x1'], + queryAllAccounts: true, + }); + + await waitFor(() => { + // Verify the controller is properly configured + expect(controller).toBeDefined(); + + // Verify multicall was attempted + expect( + multicall.getTokenBalancesForMultipleAddresses, + ).toHaveBeenCalled(); + }); + }); + + it('should handle different constructor options', () => { + const customInterval = 60000; + const { controller } = setupController({ + config: { + interval: customInterval, + accountsApiChainIds: () => [], + allowExternalServices: () => true, + }, + }); + + expect(controller).toBeDefined(); + // Verify interval was set correctly + expect(controller.getIntervalLength()).toBe(customInterval); + }); + }); + + describe('event publishing', () => { + it('should include zero staked balances in state change event when no staked balances are returned', async () => { + const accountAddress = '0x1111111111111111111111111111111111111111'; + const chainId = '0x1'; + + const { controller, tokenBalancesControllerMessenger } = setupController({ + config: { + accountsApiChainIds: () => [], + allowExternalServices: () => true, + }, + tokens: { + allTokens: { + [chainId]: { + [accountAddress]: [ + { address: '0xToken1', symbol: 'TK1', decimals: 18 }, + ], + }, + }, + allDetectedTokens: {}, + }, + listAccounts: [createMockInternalAccount({ address: accountAddress })], + }); + + // Set up spy for event publishing + const publishSpy = jest.spyOn( + tokenBalancesControllerMessenger, + 'publish', + ); + + jest + .spyOn(multicall, 'getTokenBalancesForMultipleAddresses') + .mockResolvedValue({ + tokenBalances: { + '0xToken1': { + [accountAddress]: new BN(100), + }, + }, + stakedBalances: {}, // Empty staked balances + }); + + await controller.updateBalances({ + chainIds: [chainId], + queryAllAccounts: true, + }); + + await waitFor(() => { + // Verify that staked balances are included in the state change event (even if zero) + expect(publishSpy).toHaveBeenCalledWith( + 'TokenBalancesController:stateChange', + expect.objectContaining({ + tokenBalances: { + [accountAddress]: { + [chainId]: expect.objectContaining({ + [STAKING_CONTRACT_ADDRESS]: '0x0', // Zero staked balance should be included + }), + }, + }, + }), + expect.any(Array), + ); + }); + }); + }); + + describe('batch operations and multicall edge cases', () => { + it('should handle partial multicall results', async () => { + const accountAddress = '0x1111111111111111111111111111111111111111'; + const tokenAddress1 = '0x2222222222222222222222222222222222222222'; + const tokenAddress2 = '0x3333333333333333333333333333333333333333'; + const chainId = '0x1'; + + const { controller } = setupController({ + config: { + accountsApiChainIds: () => [], + allowExternalServices: () => true, + }, + tokens: { + allTokens: { + [chainId]: { + [accountAddress]: [ + { address: tokenAddress1, symbol: 'TK1', decimals: 18 }, + { address: tokenAddress2, symbol: 'TK2', decimals: 18 }, + ], + }, + }, + allDetectedTokens: {}, + }, + listAccounts: [createMockInternalAccount({ address: accountAddress })], + }); + + // Mock multicall to return partial results + jest + .spyOn(multicall, 'getTokenBalancesForMultipleAddresses') + .mockResolvedValue({ + tokenBalances: { + [tokenAddress1]: { + [accountAddress]: new BN(100), + }, + // tokenAddress2 missing (failed call) + }, + }); + + await controller.updateBalances({ + chainIds: [chainId], + queryAllAccounts: true, + }); + + await waitFor(() => { + // Only successful token should be in state + expect( + controller.state.tokenBalances[accountAddress][chainId], + ).toStrictEqual({ + [NATIVE_TOKEN_ADDRESS]: '0x0', + [tokenAddress1]: toHex(100), + [tokenAddress2]: '0x0', + [STAKING_CONTRACT_ADDRESS]: '0x0', + }); + }); + }); + }); + + describe('state management edge cases', () => { + it('should handle complex token removal scenarios', async () => { + const accountAddress = '0x1111111111111111111111111111111111111111'; + const chainId = '0x1'; + const tokenAddress1 = '0x2222222222222222222222222222222222222222'; + const tokenAddress2 = '0x3333333333333333333333333333333333333333'; + + const { controller } = setupController({ + tokens: { + allTokens: { + [chainId]: { + [accountAddress]: [ + { address: tokenAddress1, symbol: 'TK1', decimals: 18 }, + { address: tokenAddress2, symbol: 'TK2', decimals: 18 }, + ], + }, + }, + allDetectedTokens: {}, + }, + }); + + // Set initial balances using updateBalances first + jest + .spyOn(multicall, 'getTokenBalancesForMultipleAddresses') + .mockResolvedValueOnce({ + tokenBalances: { + [tokenAddress1]: { [accountAddress]: new BN(100) }, + [tokenAddress2]: { [accountAddress]: new BN(200) }, + }, + }); + + await controller.updateBalances({ + chainIds: [chainId], + queryAllAccounts: true, + }); + + await waitFor(() => { + // Verify both tokens are in state + expect( + controller.state.tokenBalances[accountAddress][chainId], + ).toStrictEqual({ + [NATIVE_TOKEN_ADDRESS]: '0x0', + [tokenAddress1]: toHex(100), + [tokenAddress2]: toHex(200), + [STAKING_CONTRACT_ADDRESS]: '0x0', + }); + }); + + // For this test, we just verify the basic functionality without testing + // the complex internal state change handling which requires private access + expect( + controller.state.tokenBalances[accountAddress][chainId], + ).toStrictEqual({ + [NATIVE_TOKEN_ADDRESS]: '0x0', + [tokenAddress1]: toHex(100), + [tokenAddress2]: toHex(200), + [STAKING_CONTRACT_ADDRESS]: '0x0', + }); + }); + + it('should handle invalid account addresses in account removal', () => { + const { controller } = setupController(); + + // Test that the controller exists and can handle basic operations + // The actual event publishing is handled by the messaging system + expect(controller).toBeDefined(); + expect(controller.state.tokenBalances).toStrictEqual({}); + }); + }); + + it('handles case when no target chains are provided', async () => { + const { controller } = setupController(); + + // Mock the controller to have no chains with tokens + Object.defineProperty(controller, '#chainIdsWithTokens', { + value: [], + writable: true, + }); + + // This should not throw and should return early + await controller.updateBalances({ queryAllAccounts: true }); + + await waitFor(() => { + // Verify no balances were fetched + expect(controller.state.tokenBalances).toStrictEqual({}); + }); + }); + + it('handles case when no balances are aggregated', async () => { + const { controller } = setupController(); + + // Mock empty aggregated results + const mockFetcher = { + supports: jest.fn().mockReturnValue(true), + fetch: jest.fn().mockResolvedValue({ balances: [] }), // Return empty result + }; + + // Replace the balance fetchers with our mock + Object.defineProperty(controller, '#balanceFetchers', { + value: [mockFetcher], + writable: true, + }); + + await controller.updateBalances({ + chainIds: ['0x1'], + queryAllAccounts: true, + }); + + await waitFor(() => { + // Verify no state update occurred + expect(controller.state.tokenBalances).toStrictEqual({}); + }); + }); + + it('handles case when no network configuration is found', async () => { + const { controller } = setupController(); + + // Mock the controller to have no chains with tokens + Object.defineProperty(controller, '#chainIdsWithTokens', { + value: [], + writable: true, + }); + + await controller.updateBalances({ + chainIds: ['0x2'], + queryAllAccounts: true, + }); + + await waitFor(() => { + // Verify no balances were fetched + expect(controller.state.tokenBalances).toStrictEqual({}); + }); + }); + + it('update native balance when fetch is successful', async () => { + const chainId = '0x1'; + const accountAddress = '0x0000000000000000000000000000000000000000'; + const tokenAddress = '0x0000000000000000000000000000000000000000'; + + const { controller } = setupController({ + config: { + accountsApiChainIds: () => [], + allowExternalServices: () => true, + }, + tokens: { + allTokens: { + [chainId]: { + [accountAddress]: [ + { address: tokenAddress, symbol: 's', decimals: 0 }, + ], + }, + }, + allDetectedTokens: {}, + }, + }); + + jest + .spyOn(multicall, 'getTokenBalancesForMultipleAddresses') + .mockResolvedValue({ + tokenBalances: { + [tokenAddress]: { + [accountAddress]: new BN(100), + }, + }, + }); + + // Mock the controller to have no chains with tokens + Object.defineProperty(controller, '#chainIdsWithTokens', { + value: [], + writable: true, + }); + + await controller.updateBalances({ + chainIds: ['0x1'], + queryAllAccounts: true, + }); + + await waitFor(() => { + // Verify no balances were fetched + expect(controller.state.tokenBalances).toStrictEqual({ + [accountAddress]: { + [chainId]: { + [tokenAddress]: toHex(100), + [STAKING_CONTRACT_ADDRESS]: '0x0', + }, + }, + }); + }); + }); + + it('sets balance to 0 for tokens in allTokens state that do not return balance results', async () => { + const chainId = '0x1'; + const accountAddress = '0x0000000000000000000000000000000000000000'; + const tokenAddress1 = '0x0000000000000000000000000000000000000001'; // Will have balance returned + const tokenAddress2 = '0x0000000000000000000000000000000000000002'; // Will NOT have balance returned + const tokenAddress3 = '0x0000000000000000000000000000000000000003'; // Will NOT have balance returned + const detectedTokenAddress = '0x0000000000000000000000000000000000000004'; // Will NOT have balance returned + + const tokens = { + allTokens: { + [chainId]: { + [accountAddress]: [ + { address: tokenAddress1, symbol: 'TK1', decimals: 18 }, + { address: tokenAddress2, symbol: 'TK2', decimals: 18 }, + { address: tokenAddress3, symbol: 'TK3', decimals: 18 }, + ], + }, + }, + allDetectedTokens: { + [chainId]: { + [accountAddress]: [ + { address: detectedTokenAddress, symbol: 'DTK', decimals: 18 }, + ], + }, + }, + }; + + const { controller } = setupController({ + tokens, + config: { + accountsApiChainIds: () => [], + allowExternalServices: () => true, + }, + listAccounts: [createMockInternalAccount({ address: accountAddress })], + }); + + // Mock multicall to return balance for only one token + jest + .spyOn(multicall, 'getTokenBalancesForMultipleAddresses') + .mockResolvedValue({ + tokenBalances: { + [tokenAddress1]: { + [accountAddress]: new BN(123456), // Only this token has a balance returned + }, + // tokenAddress2, tokenAddress3, and detectedTokenAddress are missing from results + }, + }); + + await controller.updateBalances({ + chainIds: [chainId], + queryAllAccounts: true, + }); + + await waitFor(() => { + // Verify that: + // - tokenAddress1 has its actual fetched balance + // - tokenAddress2, tokenAddress3, and detectedTokenAddress have balance 0 + expect(controller.state.tokenBalances).toStrictEqual({ + [accountAddress]: { + [chainId]: { + [NATIVE_TOKEN_ADDRESS]: '0x0', + [tokenAddress1]: toHex(123456), // Actual fetched balance + [tokenAddress2]: '0x0', // Zero balance for missing token + [tokenAddress3]: '0x0', // Zero balance for missing token + [detectedTokenAddress]: '0x0', // Zero balance for missing detected token + [STAKING_CONTRACT_ADDRESS]: '0x0', + }, + }, + }); + }); + }); + + it('sets balance to 0 for tokens in allTokens state when balance fetcher fails completely', async () => { + const chainId = '0x1'; + const accountAddress = '0x0000000000000000000000000000000000000000'; + const tokenAddress1 = '0x0000000000000000000000000000000000000001'; + const tokenAddress2 = '0x0000000000000000000000000000000000000002'; + + const tokens = { + allTokens: { + [chainId]: { + [accountAddress]: [ + { address: tokenAddress1, symbol: 'TK1', decimals: 18 }, + { address: tokenAddress2, symbol: 'TK2', decimals: 18 }, + ], + }, + }, + allDetectedTokens: {}, + }; + + const { controller } = setupController({ + tokens, + config: { + accountsApiChainIds: () => [], + allowExternalServices: () => true, + }, + listAccounts: [createMockInternalAccount({ address: accountAddress })], + }); + + // Mock multicall to return empty results (complete failure) + jest + .spyOn(multicall, 'getTokenBalancesForMultipleAddresses') + .mockResolvedValue({ + tokenBalances: {}, // No balances returned at all + }); + + await controller.updateBalances({ + chainIds: [chainId], + queryAllAccounts: true, + }); + + await waitFor(() => { + // Verify all tokens have zero balance + expect(controller.state.tokenBalances).toStrictEqual({ + [accountAddress]: { + [chainId]: { + [NATIVE_TOKEN_ADDRESS]: '0x0', + [tokenAddress1]: '0x0', // Zero balance when fetch fails + [tokenAddress2]: '0x0', // Zero balance when fetch fails + [STAKING_CONTRACT_ADDRESS]: '0x0', + }, + }, + }); + }); + }); + + it('sets balance to 0 for tokens in allTokens state when querying all accounts', async () => { + const chainId = '0x1'; + const account1 = '0x0000000000000000000000000000000000000001'; + const account2 = '0x0000000000000000000000000000000000000002'; + const tokenAddress1 = '0x0000000000000000000000000000000000000003'; + const tokenAddress2 = '0x0000000000000000000000000000000000000004'; + + const tokens = { + allTokens: { + [chainId]: { + [account1]: [{ address: tokenAddress1, symbol: 'TK1', decimals: 18 }], + [account2]: [{ address: tokenAddress2, symbol: 'TK2', decimals: 18 }], + }, + }, + allDetectedTokens: {}, + }; + + const { controller } = setupController({ + tokens, + config: { + queryMultipleAccounts: true, + accountsApiChainIds: () => [], + allowExternalServices: () => true, + }, + listAccounts: [ + createMockInternalAccount({ address: account1 }), + createMockInternalAccount({ address: account2 }), + ], + }); + + // Mock multicall to return balance for only one account/token combination + jest + .spyOn(multicall, 'getTokenBalancesForMultipleAddresses') + .mockResolvedValue({ + tokenBalances: { + [tokenAddress1]: { + [account1]: new BN(500), // Only this account/token has balance returned + }, + // account2/tokenAddress2 missing from results + }, + }); + + await controller.updateBalances({ + chainIds: [chainId], + queryAllAccounts: true, + }); + + await waitFor(() => { + // Verify both accounts have their respective tokens with appropriate balances + expect(controller.state.tokenBalances).toStrictEqual({ + [account1]: { + [chainId]: { + [NATIVE_TOKEN_ADDRESS]: '0x0', + [tokenAddress1]: toHex(500), // Actual fetched balance + [STAKING_CONTRACT_ADDRESS]: '0x0', + }, + }, + [account2]: { + [chainId]: { + [NATIVE_TOKEN_ADDRESS]: '0x0', + [tokenAddress2]: '0x0', // Zero balance for missing token + [STAKING_CONTRACT_ADDRESS]: '0x0', + }, + }, + }); + }); + }); + + describe('staked balance functionality', () => { + it('should include staked balances in token balances state', async () => { + const chainId = '0x1'; + const accountAddress = '0x1111111111111111111111111111111111111111'; + const tokenAddress = '0x6B175474E89094C44Da98b954EedeAC495271d0F'; + const stakedBalance = new BN('5000000000000000000'); // 5 ETH staked + + const tokens = { + allTokens: { + [chainId]: { + [accountAddress]: [ + { address: tokenAddress, symbol: 'DAI', decimals: 18 }, + ], + }, + }, + allDetectedTokens: {}, + }; + + const { controller } = setupController({ tokens }); + + jest + .spyOn(multicall, 'getTokenBalancesForMultipleAddresses') + .mockResolvedValue({ + tokenBalances: { + [tokenAddress]: { + [accountAddress]: new BN('1000000000000000000'), // 1 DAI + }, + }, + stakedBalances: { + [accountAddress]: stakedBalance, + }, + }); + + await controller.updateBalances({ + chainIds: [chainId], + queryAllAccounts: true, + }); + + await waitFor(() => { + expect(controller.state.tokenBalances).toStrictEqual({ + [accountAddress]: { + [chainId]: { + [NATIVE_TOKEN_ADDRESS]: '0x0', + [tokenAddress]: toHex(new BN('1000000000000000000')), + [STAKING_CONTRACT_ADDRESS]: toHex(stakedBalance), + }, + }, + }); + }); + }); + + it('should handle staked balances with multiple accounts', async () => { + const chainId = '0x1'; + const account1 = '0x1111111111111111111111111111111111111111'; + const account2 = '0x2222222222222222222222222222222222222222'; + const tokenAddress = '0x6B175474E89094C44Da98b954EedeAC495271d0F'; + + const tokens = { + allTokens: { + [chainId]: { + [account1]: [ + { address: tokenAddress, symbol: 'DAI', decimals: 18 }, + ], + [account2]: [ + { address: tokenAddress, symbol: 'DAI', decimals: 18 }, + ], + }, + }, + allDetectedTokens: {}, + }; + + const { controller, messenger } = setupController({ tokens }); + + // Enable multi-account balances + messenger.publish( + 'PreferencesController:stateChange', + { isMultiAccountBalancesEnabled: true } as PreferencesState, + [], + ); + + jest + .spyOn(multicall, 'getTokenBalancesForMultipleAddresses') + .mockResolvedValue({ + tokenBalances: { + [tokenAddress]: { + [account1]: new BN('1000000000000000000'), + [account2]: new BN('2000000000000000000'), + }, + }, + stakedBalances: { + [account1]: new BN('3000000000000000000'), // 3 ETH staked + [account2]: new BN('4000000000000000000'), // 4 ETH staked + }, + }); + + await controller.updateBalances({ + chainIds: [chainId], + queryAllAccounts: true, + }); + + await waitFor(() => { + expect(controller.state.tokenBalances).toStrictEqual({ + [account1]: { + [chainId]: { + [NATIVE_TOKEN_ADDRESS]: '0x0', + [tokenAddress]: toHex(new BN('1000000000000000000')), + [STAKING_CONTRACT_ADDRESS]: toHex(new BN('3000000000000000000')), + }, + }, + [account2]: { + [chainId]: { + [NATIVE_TOKEN_ADDRESS]: '0x0', + [tokenAddress]: toHex(new BN('2000000000000000000')), + [STAKING_CONTRACT_ADDRESS]: toHex(new BN('4000000000000000000')), + }, + }, + }); + }); + }); + + it('should handle zero staked balances', async () => { + const chainId = '0x1'; + const accountAddress = '0x1111111111111111111111111111111111111111'; + const tokenAddress = '0x6B175474E89094C44Da98b954EedeAC495271d0F'; + + const tokens = { + allTokens: { + [chainId]: { + [accountAddress]: [ + { address: tokenAddress, symbol: 'DAI', decimals: 18 }, + ], + }, + }, + allDetectedTokens: {}, + }; + + const { controller } = setupController({ tokens }); + + jest + .spyOn(multicall, 'getTokenBalancesForMultipleAddresses') + .mockResolvedValue({ + tokenBalances: { + [tokenAddress]: { + [accountAddress]: new BN('1000000000000000000'), + }, + }, + stakedBalances: { + [accountAddress]: new BN('0'), // Zero staked balance + }, + }); + + await controller.updateBalances({ + chainIds: [chainId], + queryAllAccounts: true, + }); + + await waitFor(() => { + expect(controller.state.tokenBalances).toStrictEqual({ + [accountAddress]: { + [chainId]: { + [NATIVE_TOKEN_ADDRESS]: '0x0', + [tokenAddress]: toHex(new BN('1000000000000000000')), + [STAKING_CONTRACT_ADDRESS]: '0x0', // Zero balance + }, + }, + }); + }); + }); + + it('should handle missing staked balances gracefully', async () => { + const chainId = '0x1'; + const accountAddress = '0x1111111111111111111111111111111111111111'; + const tokenAddress = '0x6B175474E89094C44Da98b954EedeAC495271d0F'; + + const tokens = { + allTokens: { + [chainId]: { + [accountAddress]: [ + { address: tokenAddress, symbol: 'DAI', decimals: 18 }, + ], + }, + }, + allDetectedTokens: {}, + }; + + const { controller } = setupController({ tokens }); + + jest + .spyOn(multicall, 'getTokenBalancesForMultipleAddresses') + .mockResolvedValue({ + tokenBalances: { + [tokenAddress]: { + [accountAddress]: new BN('1000000000000000000'), + }, + }, + // No stakedBalances property + }); + + await controller.updateBalances({ + chainIds: [chainId], + queryAllAccounts: true, + }); + + await waitFor(() => { + expect(controller.state.tokenBalances).toStrictEqual({ + [accountAddress]: { + [chainId]: { + [NATIVE_TOKEN_ADDRESS]: '0x0', + [tokenAddress]: toHex(new BN('1000000000000000000')), + [STAKING_CONTRACT_ADDRESS]: '0x0', + }, + }, + }); + }); + }); + + it('should handle unsupported chains for staking', async () => { + const chainId = '0x89'; // Polygon - no staking support + const accountAddress = '0x1111111111111111111111111111111111111111'; + const tokenAddress = '0x6B175474E89094C44Da98b954EedeAC495271d0F'; + + const tokens = { + allTokens: { + [chainId]: { + [accountAddress]: [ + { address: tokenAddress, symbol: 'DAI', decimals: 18 }, + ], + }, + }, + allDetectedTokens: {}, + }; + + const { controller } = setupController({ + tokens, + config: { + accountsApiChainIds: () => [], + allowExternalServices: () => true, + }, + }); + + jest + .spyOn(multicall, 'getTokenBalancesForMultipleAddresses') + .mockResolvedValue({ + tokenBalances: { + [tokenAddress]: { + [accountAddress]: new BN('1000000000000000000'), + }, + }, + }); + + await controller.updateBalances({ + chainIds: [chainId], + queryAllAccounts: true, + }); + + await waitFor(() => { + expect(controller.state.tokenBalances).toStrictEqual({ + [accountAddress]: { + [chainId]: { + [NATIVE_TOKEN_ADDRESS]: '0x0', + [tokenAddress]: toHex(new BN('1000000000000000000')), + // No staking contract address for unsupported chain + }, + }, + }); + }); + }); + }); + + describe('error logging', () => { + it('should log error when balance fetcher throws in try-catch block', async () => { + const chainId = '0x1'; + const accountAddress = '0x0000000000000000000000000000000000000000'; + const tokenAddress = '0x0000000000000000000000000000000000000001'; + const mockError = new Error('Fetcher failed'); + + // Spy on console.error since safelyExecuteWithTimeout logs errors there + const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(); + + // Override the mock to use the real safelyExecuteWithTimeout for this test + const realSafelyExecuteWithTimeout = jest.requireActual( + '@metamask/controller-utils', + ).safelyExecuteWithTimeout; + mockedSafelyExecuteWithTimeout.mockImplementation( + realSafelyExecuteWithTimeout, + ); + + // Set up tokens so there's something to fetch + const tokens = { + allTokens: { + [chainId]: { + [accountAddress]: [ + { + address: tokenAddress, + symbol: 'TEST', + decimals: 18, + }, + ], + }, + }, + allDetectedTokens: {}, + }; + + const { controller } = setupController({ tokens }); + + // Mock the multicall function to throw an error + const multicallSpy = jest + .spyOn(multicall, 'getTokenBalancesForMultipleAddresses') + .mockRejectedValue(mockError); + + await controller.updateBalances({ + chainIds: [chainId], + queryAllAccounts: true, + }); + + await waitFor(() => { + // With safelyExecuteWithTimeout, errors are logged as console.error + // and the operation continues gracefully + expect(consoleErrorSpy).toHaveBeenCalledWith(mockError); + }); + + // Restore mocks + multicallSpy.mockRestore(); + consoleErrorSpy.mockRestore(); + }); + + it('should log error when updateBalances fails after token change', async () => { + jest.useFakeTimers({ doNotFake: ['nextTick', 'queueMicrotask'] }); + try { + const chainId = '0x1'; + const accountAddress = '0x0000000000000000000000000000000000000000'; + const tokenAddress = '0x0000000000000000000000000000000000000001'; + const mockError = new Error('UpdateBalances failed'); + + // Spy on console.warn + const consoleWarnSpy = jest.spyOn(console, 'warn').mockImplementation(); + + const { controller, messenger } = setupController(); + + // Mock updateBalances to throw an error + const updateBalancesSpy = jest + .spyOn(controller, 'updateBalances') + .mockRejectedValue(mockError); + + // Publish a token change that should trigger updateBalances + messenger.publish( + 'TokensController:stateChange', + { + allDetectedTokens: {}, + allIgnoredTokens: {}, + allTokens: { + [chainId]: { + [accountAddress]: [ + { address: tokenAddress, decimals: 0, symbol: 'S' }, + ], + }, + }, + }, + [], + ); + + await jestAdvanceTime({ duration: 1 }); + + // Verify updateBalances was called + expect(updateBalancesSpy).toHaveBeenCalled(); + + // Wait a bit more for the catch block to execute + await jestAdvanceTime({ duration: 1 }); + + // Verify the error was logged + expect(consoleWarnSpy).toHaveBeenCalledWith( + 'Error updating balances after token change:', + mockError, + ); + + // Restore the original method + updateBalancesSpy.mockRestore(); + consoleWarnSpy.mockRestore(); + } finally { + jest.useRealTimers(); + } + }); + + it('should handle timeout scenario', async () => { + const chainId = '0x1'; + const accountAddress = '0x0000000000000000000000000000000000000000'; + const tokenAddress = '0x0000000000000000000000000000000000000001'; + + const consoleWarnSpy = jest.spyOn(console, 'warn').mockImplementation(); + + // Set up tokens so there's something to fetch + const tokens = { + allTokens: { + [chainId]: { + [accountAddress]: [ + { + address: tokenAddress, + symbol: 'TEST', + decimals: 18, + }, + ], + }, + }, + allDetectedTokens: {}, + }; + + const { controller } = setupController({ tokens }); + + // Mock safelyExecuteWithTimeout to simulate timeout by returning undefined + mockedSafelyExecuteWithTimeout.mockImplementation( + async () => undefined, // Simulates timeout behavior + ); + + // Mock the multicall function - this won't be reached due to timeout simulation + const multicallSpy = jest + .spyOn(multicall, 'getTokenBalancesForMultipleAddresses') + .mockResolvedValue({ + tokenBalances: {}, + stakedBalances: {}, + }); + + // Start the balance update - should complete gracefully despite timeout + await controller.updateBalances({ + chainIds: [chainId], + queryAllAccounts: true, + }); + + await waitFor(() => { + // With safelyExecuteWithTimeout, timeouts are handled gracefully + // The system should continue operating without throwing errors + // No specific timeout error message should be logged at controller level + + // Verify that the update completed without errors + expect(controller.state.tokenBalances).toBeDefined(); + }); + + multicallSpy.mockRestore(); + consoleWarnSpy.mockRestore(); + }); + }); + + describe('token address normalization', () => { + it('should normalize token addresses to checksum format to prevent duplicate entries', async () => { + const chainId = '0x1'; + const accountAddress = '0x0000000000000000000000000000000000000000'; + // Same token address in different cases + const tokenAddressLowercase = + '0x581c3c1a2a4ebde2a0df29b5cf4c116e42945947'; + const tokenAddressRandomCase = + '0x581c3C1A2A4ebde2a0df29B5cf4c116E42945947'; + const tokenAddressProperChecksum = + '0x581c3C1A2A4EBDE2A0Df29B5cf4c116E42945947'; + + const tokens = { + allDetectedTokens: {}, + allTokens: { + [chainId]: { + [accountAddress]: [ + // Token stored with random case address + { address: tokenAddressRandomCase, symbol: 'TK1', decimals: 18 }, + ], + }, + }, + }; + + const { controller } = setupController({ + tokens, + config: { + accountsApiChainIds: () => [], + allowExternalServices: () => true, + }, + }); + + // Mock balance fetcher to return balance with lowercase address + jest + .spyOn(multicall, 'getTokenBalancesForMultipleAddresses') + .mockResolvedValue({ + tokenBalances: { + [tokenAddressLowercase]: { + [accountAddress]: new BN(100000), // 0x186a0 + }, + }, + }); + + await controller.updateBalances({ + chainIds: [chainId], + queryAllAccounts: true, + }); + + await waitFor(() => { + // Should only have one entry with proper checksum address + expect(controller.state.tokenBalances).toStrictEqual({ + [accountAddress]: { + [chainId]: { + [NATIVE_TOKEN_ADDRESS]: '0x0', + [tokenAddressProperChecksum]: '0x186a0', // Only checksum version exists + [STAKING_CONTRACT_ADDRESS]: '0x0', + }, + }, + }); + }); + + // Verify no duplicate entries exist + const tokenKeys = Object.keys( + controller.state.tokenBalances[accountAddress][chainId], + ); + const tokenAddressKeys = tokenKeys.filter((key) => + key.toLowerCase().includes('581c3c1a2a4ebde2a0df29b5cf4c116e42945947'), + ); + expect(tokenAddressKeys).toHaveLength(1); + expect(tokenAddressKeys[0]).toBe(tokenAddressProperChecksum); + }); + + it('should handle mixed case addresses in allTokens', async () => { + const chainId = '0x1'; + const accountAddress = '0x0000000000000000000000000000000000000000'; + const tokenAddress1Mixed = '0x581c3C1A2A4EBDE2A0Df29B5cf4c116E42945947'; + const tokenAddress2Mixed = '0xA0B86A33E6776C0b983F3B0862F02C30CABA2b75'; + const tokenAddress1Checksum = + '0x581c3C1A2A4EBDE2A0Df29B5cf4c116E42945947'; + const tokenAddress2Checksum = + '0xa0B86a33E6776c0B983f3B0862F02C30cAbA2b75'; + const tokenAddress1Lower = tokenAddress1Mixed.toLowerCase(); + const tokenAddress2Lower = tokenAddress2Mixed.toLowerCase(); + + const tokens = { + allTokens: { + [chainId]: { + [accountAddress]: [ + { address: tokenAddress1Mixed, symbol: 'TK1', decimals: 18 }, + { address: tokenAddress2Mixed, symbol: 'TK2', decimals: 18 }, + ], + }, + }, + allDetectedTokens: {}, + allIgnoredTokens: {}, + }; + + const { controller } = setupController({ + tokens, + config: { + accountsApiChainIds: () => [], + allowExternalServices: () => true, + }, + }); + + // Mock balances returned with lowercase addresses + jest + .spyOn(multicall, 'getTokenBalancesForMultipleAddresses') + .mockResolvedValue({ + tokenBalances: { + [tokenAddress1Lower]: { + [accountAddress]: new BN(500), + }, + [tokenAddress2Lower]: { + [accountAddress]: new BN(1000), + }, + }, + }); + + await controller.updateBalances({ + chainIds: [chainId], + queryAllAccounts: true, + }); + + await waitFor(() => { + // All addresses should be normalized to proper checksum format + expect(controller.state.tokenBalances).toStrictEqual({ + [accountAddress]: { + [chainId]: { + [NATIVE_TOKEN_ADDRESS]: '0x0', + [tokenAddress1Checksum]: toHex(500), + [tokenAddress2Checksum]: toHex(1000), + [STAKING_CONTRACT_ADDRESS]: '0x0', + }, + }, + }); + }); + }); + + it('should normalize fetched balance addresses to prevent case-sensitive duplicates', async () => { + const chainId = '0x1'; + const accountAddress = '0x0000000000000000000000000000000000000000'; + const tokenAddressStored = '0x581c3c1a2a4ebde2a0df29b5cf4c116e42945947'; // lowercase in storage + const tokenAddressFetched = '0x581C3c1a2A4ebDE2a0Df29B5cf4c116E42945947'; // different mixed case in fetch result + const tokenAddressChecksum = '0x581c3C1A2A4EBDE2A0Df29B5cf4c116E42945947'; // proper checksum + + const tokens = { + allDetectedTokens: {}, + allTokens: { + [chainId]: { + [accountAddress]: [ + { address: tokenAddressStored, symbol: 'TK1', decimals: 18 }, + ], + }, + }, + }; + + const { controller } = setupController({ + tokens, + config: { + accountsApiChainIds: () => [], + allowExternalServices: () => true, + }, + }); + + // Mock fetcher to return balance with different mixed case address + jest + .spyOn(multicall, 'getTokenBalancesForMultipleAddresses') + .mockResolvedValue({ + tokenBalances: { + [tokenAddressFetched]: { + [accountAddress]: new BN(100000), + }, + }, + }); + + await controller.updateBalances({ + chainIds: [chainId], + queryAllAccounts: true, + }); + + await waitFor(() => { + // Should only have one normalized entry with proper checksum + expect(controller.state.tokenBalances).toStrictEqual({ + [accountAddress]: { + [chainId]: { + [NATIVE_TOKEN_ADDRESS]: '0x0', + [tokenAddressChecksum]: '0x186a0', // Only checksum version exists + [STAKING_CONTRACT_ADDRESS]: '0x0', + }, + }, + }); + }); + + // Verify no case variations exist as separate keys + const chainBalances = + controller.state.tokenBalances[accountAddress][chainId]; + expect(chainBalances[tokenAddressFetched]).toBeUndefined(); + expect(chainBalances[tokenAddressStored]).toBeUndefined(); + expect(chainBalances[tokenAddressChecksum]).toBe('0x186a0'); + }); + + it('should prevent the exact duplicate issue from the user report', async () => { + const chainId = '0x1'; // Use a supported chain ID for simpler setup + const accountAddress = '0x5cfe73b6021e818b776b421b1c4db2474086a7e1'; // Account from user's example + const tokenAddressLower = '0x581c3c1a2a4ebde2a0df29b5cf4c116e42945947'; + const tokenAddressMixed = '0x581C3c1a2A4ebDE2a0Df29B5cf4c116E42945947'; // Different mixed case + const tokenAddressChecksum = '0x581c3C1A2A4EBDE2A0Df29B5cf4c116E42945947'; + + const tokens = { + allDetectedTokens: {}, + allTokens: { + [chainId]: { + [accountAddress]: [ + { address: tokenAddressMixed, symbol: 'TK1', decimals: 18 }, + ], + }, + }, + }; + + const { controller } = setupController({ + tokens, + config: { + accountsApiChainIds: () => [], + allowExternalServices: () => true, + }, + }); + + // Simulate the scenario that caused duplicates - different case in fetch results + jest + .spyOn(multicall, 'getTokenBalancesForMultipleAddresses') + .mockResolvedValue({ + tokenBalances: { + [tokenAddressLower]: { + [accountAddress]: new BN(0x186a0), // Balance for lowercase version + }, + }, + }); + + await controller.updateBalances({ + chainIds: [chainId], + queryAllAccounts: true, + }); + + await waitFor(() => { + // Should have balances set for the account and chain + expect(controller.state.tokenBalances[accountAddress]).toBeDefined(); + expect( + controller.state.tokenBalances[accountAddress][chainId], + ).toBeDefined(); + }); + + const chainBalances = + controller.state.tokenBalances[accountAddress][chainId]; + + // Should NOT have duplicate entries - only checksum version should exist + expect(chainBalances[tokenAddressChecksum]).toBe('0x186a0'); + expect(chainBalances[tokenAddressLower]).toBeUndefined(); + expect(chainBalances[tokenAddressMixed]).toBeUndefined(); + + // Count token entries (excluding native and staking) + const allKeys = Object.keys(chainBalances); + const nativeAndStakingKeys = [ + NATIVE_TOKEN_ADDRESS, + STAKING_CONTRACT_ADDRESS, + ]; + const tokenEntries = allKeys.filter( + (key) => !nativeAndStakingKeys.includes(key), + ); + expect(tokenEntries).toHaveLength(1); + expect(tokenEntries[0]).toBe(tokenAddressChecksum); + }); + }); + + describe('constructor queryMultipleAccounts configuration', () => { + it('should process only selected account when queryMultipleAccounts is false', async () => { + const chainId = '0x1'; + const selectedAccount = '0x0000000000000000000000000000000000000000'; + const otherAccount = '0x0000000000000000000000000000000000000001'; + const tokenAddress = '0x0000000000000000000000000000000000000002'; + + const tokens = { + allDetectedTokens: {}, + allTokens: { + [chainId]: { + [selectedAccount]: [ + { address: tokenAddress, symbol: 's', decimals: 0 }, + ], + [otherAccount]: [ + { address: tokenAddress, symbol: 's', decimals: 0 }, + ], + }, + }, + }; + + const listAccounts = [ + createMockInternalAccount({ address: selectedAccount }), + createMockInternalAccount({ address: otherAccount }), + ]; + + // Configure controller with queryMultipleAccounts: false and disable API to avoid timeout + const { controller } = setupController({ + config: { + queryMultipleAccounts: false, + accountsApiChainIds: () => [], + allowExternalServices: () => true, + }, + tokens, + listAccounts, + }); + + const balance = 100; + const mockGetTokenBalances = jest + .spyOn(multicall, 'getTokenBalancesForMultipleAddresses') + .mockResolvedValue({ + tokenBalances: { + [tokenAddress]: { + [selectedAccount]: new BN(balance), + }, + }, + stakedBalances: {}, + }); + + await controller.updateBalances({ + chainIds: [chainId], + queryAllAccounts: false, + }); + await waitFor(() => { + // Verify that getTokenBalancesForMultipleAddresses was called with only the selected account + expect(mockGetTokenBalances).toHaveBeenCalledWith( + [ + { + accountAddress: selectedAccount, + tokenAddresses: [tokenAddress, NATIVE_TOKEN_ADDRESS], + }, + ], + chainId, + expect.any(Object), // provider + true, // include native + true, // include staked + ); + }); + + // Should only contain balance for selected account when queryMultipleAccounts is false + expect(controller.state.tokenBalances).toStrictEqual({ + [selectedAccount]: { + [chainId]: { + [NATIVE_TOKEN_ADDRESS]: '0x0', + [tokenAddress]: toHex(balance), + [STAKING_CONTRACT_ADDRESS]: '0x0', + }, + }, + }); + }); + + it('should handle undefined address entries when processing network changes (covers line 475)', () => { + const chainId1 = '0x1'; + const account1 = '0x0000000000000000000000000000000000000001'; + + const { controller, messenger } = setupController(); + + // Create a state where an address key exists but has undefined value + // This directly targets the || {} fallback on line 475 + const stateWithUndefinedEntry = { + tokenBalances: { + [account1]: undefined, // This will trigger the || {} on line 475 + }, + }; + + // Mock the controller's state getter to return our test state + const originalState = controller.state; + Object.defineProperty(controller, 'state', { + get: () => ({ ...originalState, ...stateWithUndefinedEntry }), + configurable: true, + }); + + // Trigger network change to execute the #onNetworkChanged method which contains line 475 + // This should not throw an error thanks to the || {} fallback + expect(() => { + messenger.publish( + 'NetworkController:stateChange', + { + selectedNetworkClientId: 'mainnet', + networksMetadata: {}, + networkConfigurationsByChainId: { + // @ts-expect-error - this is a test + [chainId1]: { + defaultRpcEndpointIndex: 0, + rpcEndpoints: [{} as unknown as RpcEndpoint], + }, + }, + }, + [], + ); + }).not.toThrow(); + + // Restore original state + Object.defineProperty(controller, 'state', { + get: () => originalState, + configurable: true, + }); + }); + }); + + describe('Per-chain polling intervals', () => { + beforeEach(() => { + jest.useFakeTimers({ doNotFake: ['nextTick', 'queueMicrotask'] }); + }); + afterEach(() => { + jest.useRealTimers(); + }); + + it('should use default interval when no chain-specific config is provided', () => { + const defaultInterval = 30000; + const { controller } = setupController({ + config: { interval: defaultInterval }, + }); + + // Any chain should get the default interval when no explicit config exists + expect(controller.getChainPollingConfig('0x1')).toStrictEqual({ + interval: 30000, + }); + expect(controller.getChainPollingConfig('0x89')).toStrictEqual({ + interval: 30000, + }); + }); + + it('should initialize with chain-specific polling intervals', () => { + const chainPollingIntervals = { + '0x1': { interval: 15000 }, + '0x89': { interval: 5000 }, + }; + + const { controller } = setupController({ + config: { + interval: 30000, + chainPollingIntervals, + }, + tokens: { + allTokens: { + '0x1': { + '0x123': [{ address: '0xtoken1', symbol: 'T1', decimals: 18 }], + }, + '0x89': { + '0x123': [{ address: '0xtoken2', symbol: 'T2', decimals: 18 }], + }, + }, + allDetectedTokens: {}, + }, + }); + + // Test that individual chains return their configured intervals + expect(controller.getChainPollingConfig('0x1')).toStrictEqual({ + interval: 15000, + }); + expect(controller.getChainPollingConfig('0x89')).toStrictEqual({ + interval: 5000, + }); + }); + + it('should update chain polling configurations', () => { + const { controller } = setupController({ + config: { interval: 30000 }, + tokens: { + allTokens: { + '0x1': { + '0x123': [{ address: '0xtoken1', symbol: 'T1', decimals: 18 }], + }, + '0x89': { + '0x123': [{ address: '0xtoken2', symbol: 'T2', decimals: 18 }], + }, + }, + allDetectedTokens: {}, + }, + }); + + // Initially no explicit configurations, so chains use default intervals + expect(controller.getChainPollingConfig('0x1')).toStrictEqual({ + interval: 30000, + }); // Default + expect(controller.getChainPollingConfig('0x89')).toStrictEqual({ + interval: 30000, + }); // Default + + // Update configurations + const newConfigs = { + '0x1': { interval: 10000 }, + '0x89': { interval: 5000 }, + }; + controller.updateChainPollingConfigs(newConfigs); + + // Now chains use their explicit configurations + expect(controller.getChainPollingConfig('0x1')).toStrictEqual({ + interval: 10000, + }); + expect(controller.getChainPollingConfig('0x89')).toStrictEqual({ + interval: 5000, + }); + }); + + it('should get individual chain configs with proper fallback behavior', () => { + const chainPollingIntervals = { + '0x1': { interval: 15000 }, // Explicit config for Ethereum + '0xa4b1': { interval: 8000 }, // Explicit config for chain without tokens + // No explicit config for Polygon (has tokens) or BSC (no tokens) + }; + + const { controller } = setupController({ + config: { + interval: 30000, // Default interval + chainPollingIntervals, + }, + tokens: { + allTokens: { + '0x1': { + '0x123': [{ address: '0xtoken1', symbol: 'T1', decimals: 18 }], + }, + '0x89': { + // Polygon has tokens but no explicit config + '0x123': [{ address: '0xtoken2', symbol: 'T2', decimals: 18 }], + }, + // Note: 0xa4b1 and 0x38 have no tokens + }, + allDetectedTokens: {}, + }, + }); + + // Explicit configurations should be returned as-is + expect(controller.getChainPollingConfig('0x1')).toStrictEqual({ + interval: 15000, + }); + expect(controller.getChainPollingConfig('0xa4b1')).toStrictEqual({ + interval: 8000, + }); + + // Chains without explicit config should use defaults + expect(controller.getChainPollingConfig('0x89')).toStrictEqual({ + interval: 30000, + }); // Has tokens, no config + expect( + controller.getChainPollingConfig('0x38' as ChainIdHex), + ).toStrictEqual({ + interval: 30000, + }); // No tokens, no config + }); + + it('should handle partial config updates', () => { + const initialConfigs = { + '0x1': { interval: 15000 }, + '0x89': { interval: 5000 }, + }; + + const { controller } = setupController({ + config: { + interval: 30000, + chainPollingIntervals: initialConfigs, + }, + tokens: { + allTokens: { + '0x1': { + '0x123': [{ address: '0xtoken1', symbol: 'T1', decimals: 18 }], + }, + '0x89': { + '0x123': [{ address: '0xtoken2', symbol: 'T2', decimals: 18 }], + }, + '0xa4b1': { + '0x123': [{ address: '0xtoken3', symbol: 'T3', decimals: 18 }], + }, + }, + allDetectedTokens: {}, + }, + }); + + // Update only one chain's config + controller.updateChainPollingConfigs({ + '0x89': { interval: 8000 }, + '0xa4b1': { interval: 12000 }, + }); + + // Verify individual chain configurations after update + expect(controller.getChainPollingConfig('0x1')).toStrictEqual({ + interval: 15000, + }); // Unchanged + expect(controller.getChainPollingConfig('0x89')).toStrictEqual({ + interval: 8000, + }); // Updated + expect(controller.getChainPollingConfig('0xa4b1')).toStrictEqual({ + interval: 12000, + }); // New config + }); + + it('should poll chains with different intervals correctly', async () => { + const ethInterval = 1000; // 1 second + const polygonInterval = 2000; // 2 seconds + + const chainPollingIntervals = { + '0x1': { interval: ethInterval }, + '0x89': { interval: polygonInterval }, + }; + + const tokens = { + allTokens: { + '0x1': { + '0x123': [{ address: '0xtoken1', symbol: 'T1', decimals: 18 }], + }, + '0x89': { + '0x123': [{ address: '0xtoken2', symbol: 'T2', decimals: 18 }], + }, + }, + allDetectedTokens: {}, + }; + + const pollSpy = jest.spyOn( + TokenBalancesController.prototype, + '_executePoll', + ); + + const { controller } = setupController({ + config: { + interval: 3000, // Default interval (3 seconds) + chainPollingIntervals, + }, + tokens, + }); + + controller.startPolling({ chainIds: ['0x1', '0x89'] }); + + // Initial polls should happen immediately for both chains + await jestAdvanceTime({ duration: 1 }); + expect(pollSpy).toHaveBeenCalledTimes(2); + expect(pollSpy).toHaveBeenCalledWith({ chainIds: ['0x1'] }); + expect(pollSpy).toHaveBeenCalledWith({ chainIds: ['0x89'] }); + + pollSpy.mockClear(); + + // Advance by Ethereum interval (1000ms) - only Ethereum should poll + 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 jestAdvanceTime({ duration: ethInterval }); + expect(pollSpy).toHaveBeenCalledTimes(2); + expect(pollSpy).toHaveBeenCalledWith({ chainIds: ['0x1'] }); // Ethereum again + expect(pollSpy).toHaveBeenCalledWith({ chainIds: ['0x89'] }); // Polygon first repeat + + controller.stopAllPolling(); + }); + + it('should handle dynamic interval changes during polling', async () => { + const ethInterval = 1500; // 1.5 seconds + const polygonInitialInterval = 4500; // 4.5 seconds initially + const polygonNewInterval = 1500; // Change to match Ethereum + + const tokens = { + allTokens: { + '0x1': { + '0x123': [{ address: '0xtoken1', symbol: 'T1', decimals: 18 }], + }, + '0x89': { + '0x123': [{ address: '0xtoken2', symbol: 'T2', decimals: 18 }], + }, + }, + allDetectedTokens: {}, + }; + + const pollSpy = jest.spyOn( + TokenBalancesController.prototype, + '_executePoll', + ); + + const { controller } = setupController({ + config: { + interval: 6000, // Default interval (6 seconds) + chainPollingIntervals: { + '0x1': { interval: ethInterval }, + '0x89': { interval: polygonInitialInterval }, + }, + }, + tokens, + }); + + controller.startPolling({ chainIds: ['0x1', '0x89'] }); + + // Initial polls + await jestAdvanceTime({ duration: 1 }); + expect(pollSpy).toHaveBeenCalledTimes(2); + pollSpy.mockClear(); + + // Advance 1500ms - only Ethereum should poll + await jestAdvanceTime({ duration: ethInterval }); + expect(pollSpy).toHaveBeenCalledTimes(1); + expect(pollSpy).toHaveBeenCalledWith({ chainIds: ['0x1'] }); + + // Change Polygon interval to match Ethereum (1500ms) + controller.updateChainPollingConfigs({ + '0x89': { interval: polygonNewInterval }, + }); + + pollSpy.mockClear(); + + // Advance 1500ms - both should poll now (same interval, grouped together) + await jestAdvanceTime({ duration: ethInterval }); + expect(pollSpy).toHaveBeenCalledTimes(1); // Now grouped together + expect(pollSpy).toHaveBeenCalledWith({ chainIds: ['0x1', '0x89'] }); // Both chains in one call + + controller.stopAllPolling(); + }); + + it('should group chains with same intervals for efficient polling', async () => { + const fastInterval = 1200; // 1.2 seconds + const slowInterval = 2400; // 2.4 seconds + + const chainPollingIntervals = { + '0x1': { interval: fastInterval }, // Ethereum - fast + '0x89': { interval: slowInterval }, // Polygon - slow + '0xa4b1': { interval: fastInterval }, // Arbitrum - fast (same as Ethereum) + }; + + const tokens = { + allTokens: { + '0x1': { + '0x123': [{ address: '0xtoken1', symbol: 'T1', decimals: 18 }], + }, + '0x89': { + '0x123': [{ address: '0xtoken2', symbol: 'T2', decimals: 18 }], + }, + '0xa4b1': { + '0x123': [{ address: '0xtoken3', symbol: 'T3', decimals: 18 }], + }, + }, + allDetectedTokens: {}, + }; + + const pollSpy = jest.spyOn( + TokenBalancesController.prototype, + '_executePoll', + ); + + const { controller } = setupController({ + config: { + interval: 4800, // Default interval (4.8 seconds) + chainPollingIntervals, + }, + tokens, + }); + + controller.startPolling({ chainIds: ['0x1', '0x89', '0xa4b1'] }); + + // Initial polls - should group efficiently + 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) + expect(pollSpy).toHaveBeenCalledWith({ chainIds: ['0x1', '0xa4b1'] }); + // Verify Polygon is separate (different interval) + expect(pollSpy).toHaveBeenCalledWith({ chainIds: ['0x89'] }); + + pollSpy.mockClear(); + + // Advance by fast interval (1200ms) - only fast group should poll + 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 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 + + controller.stopAllPolling(); + }); + + it('should fall back to default interval for unconfigured chains', async () => { + const ethInterval = 800; // 800ms - configured + const defaultInterval = 1600; // 1.6 seconds - default for unconfigured chains + + const chainPollingIntervals = { + '0x1': { interval: ethInterval }, // Ethereum configured + // '0x89' not configured - should use default + }; + + const tokens = { + allTokens: { + '0x1': { + '0x123': [{ address: '0xtoken1', symbol: 'T1', decimals: 18 }], + }, + '0x89': { + '0x123': [{ address: '0xtoken2', symbol: 'T2', decimals: 18 }], + }, + }, + allDetectedTokens: {}, + }; + + const pollSpy = jest.spyOn( + TokenBalancesController.prototype, + '_executePoll', + ); + + const { controller } = setupController({ + config: { + interval: defaultInterval, // This becomes default for unconfigured chains + chainPollingIntervals, + }, + tokens, + }); + + controller.startPolling({ chainIds: ['0x1', '0x89'] }); + + // Initial polls + await jestAdvanceTime({ duration: 1 }); + expect(pollSpy).toHaveBeenCalledTimes(2); + expect(pollSpy).toHaveBeenCalledWith({ chainIds: ['0x1'] }); + expect(pollSpy).toHaveBeenCalledWith({ chainIds: ['0x89'] }); + + pollSpy.mockClear(); + + // Advance 800ms - only Ethereum should poll (configured interval) + await jestAdvanceTime({ duration: ethInterval }); + expect(pollSpy).toHaveBeenCalledTimes(1); + expect(pollSpy).toHaveBeenCalledWith({ chainIds: ['0x1'] }); + + pollSpy.mockClear(); + + // Advance another 800ms (total 1600ms) - both should poll + await jestAdvanceTime({ duration: ethInterval }); + expect(pollSpy).toHaveBeenCalledTimes(2); + expect(pollSpy).toHaveBeenCalledWith({ chainIds: ['0x1'] }); // Ethereum again + expect(pollSpy).toHaveBeenCalledWith({ chainIds: ['0x89'] }); // Polygon using default interval + + controller.stopAllPolling(); + }); + + it('should maintain proper polling state during configuration updates', async () => { + const tokens = { + allTokens: { + '0x1': { + '0x123': [{ address: '0xtoken1', symbol: 'T1', decimals: 18 }], + }, + '0x89': { + '0x123': [{ address: '0xtoken2', symbol: 'T2', decimals: 18 }], + }, + }, + allDetectedTokens: {}, + }; + + const pollSpy = jest.spyOn( + TokenBalancesController.prototype, + '_executePoll', + ); + + const { controller } = setupController({ + config: { + interval: 2000, // Default (2 seconds) + chainPollingIntervals: { + '0x1': { interval: 1000 }, // Ethereum: 1 second + '0x89': { interval: 3000 }, // Polygon: 3 seconds + }, + }, + tokens, + }); + + // Start polling + controller.startPolling({ chainIds: ['0x1', '0x89'] }); + + // Initial polls + await jestAdvanceTime({ duration: 1 }); + expect(pollSpy).toHaveBeenCalledTimes(2); + pollSpy.mockClear(); + + // Let some polling happen + await jestAdvanceTime({ duration: 1000 }); // Ethereum polls + expect(pollSpy).toHaveBeenCalledTimes(1); + expect(pollSpy).toHaveBeenCalledWith({ chainIds: ['0x1'] }); + + // Update configurations while polling is active + controller.updateChainPollingConfigs({ + '0x1': { interval: 500 }, // Make Ethereum faster (500ms) + '0x89': { interval: 500 }, // Make Polygon same as Ethereum (500ms) + }); + + pollSpy.mockClear(); + + // Both should now poll every 500ms (regrouped) + await jestAdvanceTime({ duration: 500 }); + expect(pollSpy).toHaveBeenCalledTimes(1); + expect(pollSpy).toHaveBeenCalledWith({ chainIds: ['0x1', '0x89'] }); // Now grouped together + + controller.stopAllPolling(); + }); + + 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 + jest.useFakeTimers({ doNotFake: ['nextTick', 'queueMicrotask'] }); + + const tokens = { + allTokens: { + '0x1': { + '0x123': [{ address: '0xtoken1', symbol: 'T1', decimals: 18 }], + }, + // Note: '0x89' and '0xa4b1' have NO tokens + }, + allDetectedTokens: {}, + allIgnoredTokens: {}, + }; + + const { controller } = setupController({ + config: { + interval: 1000, + chainPollingIntervals: { + '0x1': { interval: 1000 }, // Ethereum + '0x89': { interval: 2000 }, // Polygon + '0xa4b1': { interval: 3000 }, // Arbitrum + }, + }, + tokens, + }); + + const pollSpy = jest + .spyOn(controller, '_executePoll') + .mockImplementation(); + + // Start polling for 3 chains: only Ethereum has tokens, others don't + controller.startPolling({ chainIds: ['0x1', '0x89', '0xa4b1'] }); + + // Initial polls - all 3 chains should be polled despite only Ethereum having tokens + await jestAdvanceTime({ duration: 1 }); + expect(pollSpy).toHaveBeenCalledTimes(3); // All three chains polled + + // Verify all originally requested chains are being polled + expect(pollSpy).toHaveBeenCalledWith({ chainIds: ['0x1'] }); // Ethereum (has tokens) + expect(pollSpy).toHaveBeenCalledWith({ chainIds: ['0x89'] }); // Polygon (no tokens) + expect(pollSpy).toHaveBeenCalledWith({ chainIds: ['0xa4b1'] }); // Arbitrum (no tokens) + + pollSpy.mockClear(); + + // Update polling configs - this should NOT lose chains without tokens + controller.updateChainPollingConfigs({ + '0x89': { interval: 1000 }, // Change Polygon to same interval as Ethereum + }); + + // 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 jestAdvanceTime({ duration: 3000 }); + + // ✅ KEY VERIFICATION: All originally requested chains are still being polled, + // including Polygon and Arbitrum which have NO tokens! + // The exact grouping doesn't matter - what matters is that all original chains are preserved + const allCalledChains = pollSpy.mock.calls.flatMap( + (call) => call[0].chainIds, + ); + expect(allCalledChains).toStrictEqual( + expect.arrayContaining(['0x1', '0x89', '0xa4b1']), + ); + + // Verify that chains without tokens are NOT filtered out (this was the bug) + expect(allCalledChains).toContain('0x89'); // Polygon (no tokens) - ✅ PRESERVED! + expect(allCalledChains).toContain('0xa4b1'); // Arbitrum (no tokens) - ✅ PRESERVED! + + controller.stopAllPolling(); + 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 + jest.useFakeTimers({ doNotFake: ['nextTick', 'queueMicrotask'] }); + + const initialTokens = { + allTokens: { + '0x1': { + '0x123': [{ address: '0xtoken1', symbol: 'T1', decimals: 18 }], + }, + // '0x89' and '0xa4b1' start with no tokens + }, + allDetectedTokens: {}, + allIgnoredTokens: {}, + }; + + const { controller, messenger } = setupController({ + config: { interval: 1000 }, + tokens: initialTokens, + }); + + const pollSpy = jest + .spyOn(controller, '_executePoll') + .mockImplementation(); + + // Start polling for 3 chains, only Ethereum has tokens initially + controller.startPolling({ chainIds: ['0x1', '0x89', '0xa4b1'] }); + + // Initial state: all 3 chains polled (they use default interval so grouped together) + await jestAdvanceTime({ duration: 1 }); + expect(pollSpy).toHaveBeenCalledTimes(1); // All chains use same default interval, so grouped + expect(pollSpy).toHaveBeenCalledWith({ + chainIds: ['0x1', '0x89', '0xa4b1'], + }); + pollSpy.mockClear(); + + // Simulate tokens being added to Polygon via TokensController state change + const newTokensState = { + ...initialTokens, + allTokens: { + '0x1': { + '0x123': [{ address: '0xtoken1', symbol: 'T1', decimals: 18 }], + }, + '0x89': { + '0x123': [{ address: '0xtoken2', symbol: 'T2', decimals: 18 }], + }, + }, + allIgnoredTokens: {}, + }; + + // Trigger the tokens change handler via messaging system + messenger.publish('TokensController:stateChange', newTokensState, [ + { op: 'replace', path: [], value: newTokensState }, + ]); + + // Wait for async token change processing + await new Promise((resolve) => process.nextTick(resolve)); + pollSpy.mockClear(); + + // After token change, should still poll all originally requested chains + await jestAdvanceTime({ duration: 1000 }); + + // ✅ KEY VERIFICATION: All originally requested chains are still being polled + // even after token state changes (not filtered by chainIdsWithTokens) + const allCalledChains = pollSpy.mock.calls.flatMap( + (call) => call[0].chainIds, + ); + expect(allCalledChains).toStrictEqual( + expect.arrayContaining(['0x1', '0x89', '0xa4b1']), + ); + + // Verify that chains without tokens are NOT filtered out after token changes + expect(allCalledChains).toContain('0x89'); // Polygon (now has tokens) + expect(allCalledChains).toContain('0xa4b1'); // Arbitrum (still no tokens) - ✅ PRESERVED! + + controller.stopAllPolling(); + jest.useRealTimers(); + }); + + describe('immediateUpdate option', () => { + it('should trigger immediate polling by default when updating configs', async () => { + jest.useFakeTimers({ doNotFake: ['nextTick', 'queueMicrotask'] }); + const chainId = '0x1'; + const accountAddress = '0x0000000000000000000000000000000000000000'; + const tokenAddress = '0x0000000000000000000000000000000000000001'; + + const tokens = { + allTokens: { + [chainId]: { + [accountAddress]: [ + { address: tokenAddress, symbol: 'TEST', decimals: 18 }, + ], + }, + }, + allDetectedTokens: {}, + }; + + const { controller } = setupController({ + config: { interval: 30000 }, + tokens, + }); + + const pollSpy = jest + .spyOn(controller, '_executePoll') + .mockImplementation(); + + // Start polling + controller.startPolling({ chainIds: [chainId] }); + + // Wait for initial poll + await jestAdvanceTime({ duration: 1 }); + expect(pollSpy).toHaveBeenCalledTimes(1); + pollSpy.mockClear(); + + // Update config without immediateUpdate option (default behavior is now true) + controller.updateChainPollingConfigs({ + [chainId]: { interval: 15000 }, + }); + + // Should trigger immediate polling by default + await jestAdvanceTime({ duration: 1 }); + expect(pollSpy).toHaveBeenCalledTimes(1); + expect(pollSpy).toHaveBeenCalledWith({ chainIds: [chainId] }); + + pollSpy.mockClear(); + + // And should continue polling on the new interval + await jestAdvanceTime({ duration: 15000 }); + expect(pollSpy).toHaveBeenCalledTimes(1); + + controller.stopAllPolling(); + jest.useRealTimers(); + }); + + it('should not trigger immediate polling when immediateUpdate is false', async () => { + jest.useFakeTimers({ doNotFake: ['nextTick', 'queueMicrotask'] }); + const chainId = '0x1'; + const accountAddress = '0x0000000000000000000000000000000000000000'; + const tokenAddress = '0x0000000000000000000000000000000000000001'; + + const tokens = { + allTokens: { + [chainId]: { + [accountAddress]: [ + { address: tokenAddress, symbol: 'TEST', decimals: 18 }, + ], + }, + }, + allDetectedTokens: {}, + }; + + const { controller } = setupController({ + config: { interval: 30000 }, + tokens, + }); + + const pollSpy = jest + .spyOn(controller, '_executePoll') + .mockImplementation(); + + // Start polling + controller.startPolling({ chainIds: [chainId] }); + + // Wait for initial poll + await jestAdvanceTime({ duration: 1 }); + expect(pollSpy).toHaveBeenCalledTimes(1); + pollSpy.mockClear(); + + // Update config with explicit immediateUpdate: false + controller.updateChainPollingConfigs( + { + [chainId]: { interval: 15000 }, + }, + { immediateUpdate: false }, + ); + + // Should NOT trigger immediate polling + expect(pollSpy).not.toHaveBeenCalled(); + + // But should poll on the new interval + await jestAdvanceTime({ duration: 15000 }); + expect(pollSpy).toHaveBeenCalledTimes(1); + + controller.stopAllPolling(); + jest.useRealTimers(); + }); + + it('should trigger immediate polling when immediateUpdate is true', async () => { + jest.useFakeTimers({ doNotFake: ['nextTick', 'queueMicrotask'] }); + const chainId = '0x1'; + const accountAddress = '0x0000000000000000000000000000000000000000'; + const tokenAddress = '0x0000000000000000000000000000000000000001'; + + const tokens = { + allTokens: { + [chainId]: { + [accountAddress]: [ + { address: tokenAddress, symbol: 'TEST', decimals: 18 }, + ], + }, + }, + allDetectedTokens: {}, + }; + + const { controller } = setupController({ + config: { interval: 30000 }, + tokens, + }); + + const pollSpy = jest + .spyOn(controller, '_executePoll') + .mockImplementation(); + + // Start polling + controller.startPolling({ chainIds: [chainId] }); + + // Wait for initial poll + await jestAdvanceTime({ duration: 1 }); + expect(pollSpy).toHaveBeenCalledTimes(1); + pollSpy.mockClear(); + + // Update config with immediateUpdate: true + controller.updateChainPollingConfigs( + { + [chainId]: { interval: 15000 }, + }, + { immediateUpdate: true }, + ); + + // Should trigger immediate polling + await jestAdvanceTime({ duration: 1 }); + expect(pollSpy).toHaveBeenCalledTimes(1); + expect(pollSpy).toHaveBeenCalledWith({ chainIds: [chainId] }); + + pollSpy.mockClear(); + + // And should continue polling on the new interval + await jestAdvanceTime({ duration: 15000 }); + expect(pollSpy).toHaveBeenCalledTimes(1); + + controller.stopAllPolling(); + jest.useRealTimers(); + }); + + it('should handle immediateUpdate option when polling is not active', () => { + const chainId = '0x1'; + const accountAddress = '0x0000000000000000000000000000000000000000'; + const tokenAddress = '0x0000000000000000000000000000000000000001'; + + const tokens = { + allTokens: { + [chainId]: { + [accountAddress]: [ + { address: tokenAddress, symbol: 'TEST', decimals: 18 }, + ], + }, + }, + allDetectedTokens: {}, + }; + + const { controller } = setupController({ + config: { interval: 30000 }, + tokens, + }); + + const pollSpy = jest + .spyOn(controller, '_executePoll') + .mockImplementation(); + + // DON'T start polling - controller is inactive + + // Update config with immediateUpdate: true (should have no effect when not polling) + controller.updateChainPollingConfigs( + { + [chainId]: { interval: 15000 }, + }, + { immediateUpdate: true }, + ); + + // Should NOT trigger any polling since controller is not active + expect(pollSpy).not.toHaveBeenCalled(); + + // Config should still be updated + expect(controller.getChainPollingConfig(chainId)).toStrictEqual({ + interval: 15000, + }); + }); + + it('should handle immediateUpdate with multiple chains and different intervals', async () => { + jest.useFakeTimers({ doNotFake: ['nextTick', 'queueMicrotask'] }); + const accountAddress = '0x0000000000000000000000000000000000000000'; + + const tokens = { + allTokens: { + '0x1': { + [accountAddress]: [ + { address: '0xtoken1', symbol: 'T1', decimals: 18 }, + ], + }, + '0x89': { + [accountAddress]: [ + { address: '0xtoken2', symbol: 'T2', decimals: 18 }, + ], + }, + }, + allDetectedTokens: {}, + }; + + const { controller } = setupController({ + config: { interval: 30000 }, + tokens, + }); + + const pollSpy = jest + .spyOn(controller, '_executePoll') + .mockImplementation(); + + // Start polling + controller.startPolling({ chainIds: ['0x1', '0x89'] }); + + // Wait for initial polls + await jestAdvanceTime({ duration: 1 }); + expect(pollSpy).toHaveBeenCalledTimes(1); // Both chains use default interval + pollSpy.mockClear(); + + // Update configs with different intervals and immediateUpdate: true + controller.updateChainPollingConfigs( + { + '0x1': { interval: 10000 }, // Ethereum: 10s + '0x89': { interval: 20000 }, // Polygon: 20s + }, + { immediateUpdate: true }, + ); + + // Should trigger immediate polling for all chains + 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(); + jest.useRealTimers(); + }); + }); + }); + + describe('Error handling and edge cases', () => { + it('should handle polling errors gracefully', async () => { + jest.useFakeTimers({ doNotFake: ['nextTick', 'queueMicrotask'] }); + try { + const chainId = '0x1'; + const accountAddress = '0x0000000000000000000000000000000000000000'; + const tokenAddress = '0x0000000000000000000000000000000000000001'; + + const tokens = { + allDetectedTokens: {}, + allTokens: { + [chainId]: { + [accountAddress]: [ + { address: tokenAddress, symbol: 'TEST', decimals: 18 }, + ], + }, + }, + }; + + const consoleSpy = jest.spyOn(console, 'warn').mockImplementation(); + + const { controller } = setupController({ + tokens, + config: { interval: 100 }, + }); + + // Mock _executePoll to throw an error + const pollSpy = jest + .spyOn(controller, '_executePoll') + .mockRejectedValue(new Error('Polling failed')); + + controller.startPolling({ chainIds: ['0x1'] }); + + // Wait for initial poll and error + await jestAdvanceTime({ duration: 1 }); + + // Wait for interval poll and error + await jestAdvanceTime({ duration: 100 }); + + // Should have attempted polls despite errors + expect(pollSpy).toHaveBeenCalledTimes(2); + + controller.stopAllPolling(); + consoleSpy.mockRestore(); + } finally { + jest.useRealTimers(); + } + }); + + it('should handle updateBalances errors in token change handler', async () => { + jest.useFakeTimers({ doNotFake: ['nextTick', 'queueMicrotask'] }); + try { + const chainId = '0x1'; + const accountAddress = '0x0000000000000000000000000000000000000000'; + const tokenAddress = '0x0000000000000000000000000000000000000001'; + + const tokens = { + allDetectedTokens: {}, + allTokens: { + [chainId]: { + [accountAddress]: [ + { address: tokenAddress, symbol: 'TEST', decimals: 18 }, + ], + }, + }, + }; + + const consoleSpy = jest.spyOn(console, 'warn').mockImplementation(); + + const { controller, messenger } = setupController({ + tokens, + }); + + // Mock updateBalances to throw an error + const updateBalancesSpy = jest + .spyOn(controller, 'updateBalances') + .mockRejectedValue(new Error('Update failed')); + + // Simulate token change that triggers balance update + const newTokens = { + ...tokens, + allTokens: { + [chainId]: { + [accountAddress]: [ + { address: tokenAddress, symbol: 'TEST', decimals: 18 }, + { + address: '0x0000000000000000000000000000000000000002', + symbol: 'NEW', + decimals: 18, + }, + ], + }, + }, + allIgnoredTokens: {}, + ignoredTokens: [], + detectedTokens: [], + tokens: [], + }; + + // Trigger token change by publishing state change + messenger.publish('TokensController:stateChange', newTokens, [ + { op: 'replace', path: [], value: newTokens }, + ]); + + // Wait for async error handling + await jestAdvanceTime({ duration: 1 }); + + expect(updateBalancesSpy).toHaveBeenCalled(); + expect(consoleSpy).toHaveBeenCalledWith( + 'Error updating balances after token change:', + expect.any(Error), + ); + + consoleSpy.mockRestore(); + } finally { + jest.useRealTimers(); + } + }); + + it('should handle malformed JSON in _stopPollingByPollingTokenSetId gracefully', async () => { + jest.useFakeTimers({ doNotFake: ['nextTick', 'queueMicrotask'] }); + try { + const { controller } = setupController(); + + // Start polling to create an active session + controller.startPolling({ chainIds: ['0x1', '0x2'] }); + + const consoleSpy = jest.spyOn(console, 'warn').mockImplementation(); + + // Call with malformed JSON - this should trigger the fallback behavior + const malformedTokenSetId = '{invalid json}'; + controller._stopPollingByPollingTokenSetId(malformedTokenSetId); + + // Should log the error + expect(consoleSpy).toHaveBeenCalledWith( + 'Failed to parse tokenSetId, stopping all polling:', + expect.any(SyntaxError), + ); + + // Verify that controller can recover by starting new polling session successfully + // This demonstrates that the fallback stop-all-polling behavior worked + const updateBalancesSpy = jest + .spyOn(controller, 'updateBalances') + .mockResolvedValue(); + + // Start new polling session - should work normally after error recovery + controller.startPolling({ chainIds: ['0x1'] }); + + // Wait for any immediate polling to complete + await jestAdvanceTime({ duration: 1 }); + + // Clean up + controller.stopAllPolling(); + consoleSpy.mockRestore(); + updateBalancesSpy.mockRestore(); + } finally { + jest.useRealTimers(); + } + }); + + it('should properly destroy controller and cleanup resources', () => { + const { controller } = setupController(); + + // Start some polling to create timers + controller.startPolling({ chainIds: ['0x1'] }); + + const superDestroySpy = jest.spyOn( + Object.getPrototypeOf(Object.getPrototypeOf(controller)), + 'destroy', + ); + + // Destroy the controller + controller.destroy(); + + // Should call parent destroy + expect(superDestroySpy).toHaveBeenCalled(); + + superDestroySpy.mockRestore(); + }); + + it('should handle balance fetcher timeout errors', async () => { + const chainId = '0x1'; + const accountAddress = '0x0000000000000000000000000000000000000000'; + const tokenAddress = '0x0000000000000000000000000000000000000001'; + const account = createMockInternalAccount({ address: accountAddress }); + + const tokens = { + allDetectedTokens: {}, + allTokens: { + [chainId]: { + [accountAddress]: [ + { address: tokenAddress, symbol: 'TEST', decimals: 18 }, + ], + }, + }, + }; + + const { controller } = setupController({ + tokens, + listAccounts: [account], + config: { accountsApiChainIds: () => [] }, // Force use of RpcBalanceFetcher + }); + + // Mock safelyExecuteWithTimeout to simulate timeout by returning undefined + mockedSafelyExecuteWithTimeout.mockImplementation( + async () => undefined, // Simulates timeout behavior + ); + + // Start the balance update - should complete gracefully despite timeout + await controller.updateBalances({ + chainIds: [chainId], + queryAllAccounts: true, + }); + + await waitFor(() => { + // With safelyExecuteWithTimeout timeout simulation, the system should continue operating + // The controller should have initialized the token with 0 balance despite timeout + expect(controller.state.tokenBalances).toStrictEqual({ + '0x0000000000000000000000000000000000000000': { + '0x1': { + '0x0000000000000000000000000000000000000001': '0x0', + }, + }, + }); + }); + + // Restore the mock to its default behavior + mockedSafelyExecuteWithTimeout.mockImplementation( + async (operation: () => Promise) => { + try { + return await operation(); + } catch (error) { + console.error(error); + return undefined; + } + }, + ); + }); + + it('should handle constructor with different configurations', () => { + // Test constructor with different parameter combinations to improve coverage + const { controller: controllerWithDefaults } = setupController({ + config: { + // All params use defaults + }, + }); + + expect(controllerWithDefaults).toBeDefined(); + + const { controller: controllerWithCustomConfig } = setupController({ + config: { + interval: 5000, + chainPollingIntervals: { '0x1': { interval: 1000 } }, + state: { + tokenBalances: { + '0x0000000000000000000000000000000000000000': { + '0x1': { + '0x0000000000000000000000000000000000000000': toHex(100), + }, + }, + }, + }, + queryMultipleAccounts: false, + accountsApiChainIds: () => ['0x1'], + allowExternalServices: () => false, + }, + }); + + expect(controllerWithCustomConfig).toBeDefined(); + + // Clean up + controllerWithDefaults.destroy(); + controllerWithCustomConfig.destroy(); + }); + + it('should handle network state changes with removed networks', () => { + const { messenger } = setupController(); + + // Simulate network state change + const networkState = { + selectedNetworkClientId: 'mainnet', + providerConfig: { chainId: '0x1' as ChainIdHex, ticker: 'ETH' }, + networkConfigurations: {}, + networkConfigurationsByChainId: {}, + networksMetadata: {}, + }; + + // This should exercise the network change handler + // No assertions needed - we're just ensuring the code path is covered + expect(() => { + messenger.publish('NetworkController:stateChange', networkState, [ + { op: 'replace', path: [], value: networkState }, + ]); + }).not.toThrow(); + }); + }); + + describe('Additional coverage tests', () => { + it('should construct controller with allowExternalServices returning false', () => { + // Test line 197: allowExternalServices = () => false + const { controller } = setupController({ + config: { + allowExternalServices: () => false, + accountsApiChainIds: () => ['0x1'], // This should be ignored when allowExternalServices is false + }, + }); + + expect(controller).toBeDefined(); + // Verify that AccountsAPI fetcher is not created when external services are disabled + expect(controller.state.tokenBalances).toStrictEqual({}); + }); + + it('should use default allowExternalServices when not provided', () => { + // Test line 197: default allowExternalServices = () => true + const { controller } = setupController({ + config: { + accountsApiChainIds: () => ['0x1'], + // allowExternalServices not provided - should use default + }, + }); + + expect(controller).toBeDefined(); + expect(controller.state.tokenBalances).toStrictEqual({}); + }); + + it('should evaluate allowExternalServices dynamically at call time, not just at construction time', async () => { + // This test verifies the fix for the bug where allowExternalServices was only + // evaluated once during construction, meaning changes after init were ignored. + // Now allowExternalServices() is called dynamically in the fetcher's supports() method. + + const accountAddress = '0x1234567890123456789012345678901234567890'; + const tokenAddress = '0x6B175474E89094C44Da98b954EedeAC495271d0F'; + const chainId = '0x1' as ChainIdHex; + + // Use a mutable flag that we can change after construction + let externalServicesEnabled = false; + + const tokens: Partial = { + allTokens: { + [chainId]: { + [accountAddress]: [ + { address: tokenAddress, symbol: 'DAI', decimals: 18 }, + ], + }, + }, + allDetectedTokens: {}, + }; + + const { controller } = setupController({ + tokens, + config: { + // This function will be called dynamically, not just at construction + allowExternalServices: () => externalServicesEnabled, + accountsApiChainIds: () => [chainId], + }, + listAccounts: [createMockInternalAccount({ address: accountAddress })], + }); + + // Mock the RPC multicall to track when it's called + const multicallSpy = jest + .spyOn(multicall, 'getTokenBalancesForMultipleAddresses') + .mockResolvedValue({ + tokenBalances: { + [tokenAddress]: { + [accountAddress]: new BN(1000), + }, + }, + }); + + // First call: external services disabled, should use RPC fetcher + await controller.updateBalances({ chainIds: [chainId] }); + await waitFor(() => { + expect(multicallSpy).toHaveBeenCalled(); + }); + multicallSpy.mockClear(); + + // Now enable external services - this should be respected dynamically + externalServicesEnabled = true; + + // Second call: external services now enabled + // The AccountsAPI fetcher should now pass the supports() check + // (though it may still fall back to RPC if the API call fails in test) + await controller.updateBalances({ chainIds: [chainId] }); + + await waitFor(() => { + // The test verifies that the allowExternalServices function is evaluated + // dynamically by checking that the controller was constructed successfully + // and that balance updates work in both states + expect(controller).toBeDefined(); + expect(controller.state.tokenBalances).toBeDefined(); + }); + + multicallSpy.mockRestore(); + }); + + it('should handle inactive controller during polling', async () => { + const chainId = '0x1'; + const { controller } = setupController({ + config: { interval: 100 }, // Short interval to trigger polling quickly + }); + + // Use fake timers to control polling intervals + jest.useFakeTimers(); + + // Mock _executePoll to track calls + const executePollSpy = jest.spyOn(controller, '_executePoll'); + + // Start polling to set up the timer + controller.startPolling({ chainIds: [chainId] }); + + // Allow initial polling to complete + await flushPromises(); + jest.runOnlyPendingTimers(); + await flushPromises(); + + // Clear spy calls from setup + executePollSpy.mockClear(); + + // Stop polling - this makes controller inactive (#isControllerPollingActive = false) + controller.stopAllPolling(); + + // Fast forward time to trigger the next scheduled poll interval + // This should hit line 335 (early return when !#isControllerPollingActive) + jest.advanceTimersByTime(150); + await flushPromises(); + + // The scheduled poll should have been prevented by the inactive check (line 335) + expect(executePollSpy).not.toHaveBeenCalled(); + expect(controller).toBeDefined(); + + jest.useRealTimers(); + executePollSpy.mockRestore(); + }); + + it('should clear existing timer when starting polling for same interval', () => { + const chainId1 = '0x1'; + const chainId2 = '0x89'; // Polygon + + // Mock clearInterval to verify it's called (line 359) + const clearIntervalSpy = jest.spyOn(global, 'clearInterval'); + + const { controller } = setupController({ + config: { + interval: 1000, // Default interval + chainPollingIntervals: { + [chainId1]: { interval: 5000 }, + [chainId2]: { interval: 5000 }, // Same interval as chainId1 + }, + }, + }); + + // Start polling for first chain - this creates the initial timer + controller.startPolling({ chainIds: [chainId1] }); + + // Start polling for second chain with same interval (covers line 359) + // This should clear the existing timer and create a new one + controller.startPolling({ chainIds: [chainId1, chainId2] }); + + // Verify clearInterval was called to clear the existing timer (line 359) + expect(clearIntervalSpy).toHaveBeenCalled(); + + // Verify controller is defined and functioning + expect(controller).toBeDefined(); + expect(controller.state.tokenBalances).toStrictEqual({}); + + controller.stopAllPolling(); + clearIntervalSpy.mockRestore(); + }); + + it('should skip fetcher when no chains are supported', async () => { + const chainId = '0x999'; // Unsupported chain + const account = createMockInternalAccount(); + + const tokens = { + allDetectedTokens: {}, + allTokens: { + [chainId]: { + [account.address]: [ + { + address: '0x0000000000000000000000000000000000000001', + symbol: 'TEST', + decimals: 18, + }, + ], + }, + }, + }; + + const { controller } = setupController({ + tokens, + listAccounts: [account], + config: { accountsApiChainIds: () => [] }, + }); + + // Mock the RpcBalanceFetcher to not support this specific chain + const mockSupports = jest + .spyOn(RpcBalanceFetcher.prototype, 'supports') + .mockReturnValue(false); + + // This should trigger the continue statement (line 440) when no chains are supported + await controller.updateBalances({ chainIds: [chainId] }); + await waitFor(() => { + expect(mockSupports).toHaveBeenCalledWith(chainId); + }); + mockSupports.mockRestore(); + }); + + it('should restart polling when tokens change and controller is active', () => { + const chainId = '0x1'; + const accountAddress = '0x0000000000000000000000000000000000000000'; + const tokenAddress = '0x0000000000000000000000000000000000000001'; + const account = createMockInternalAccount({ address: accountAddress }); + + const { controller, messenger } = setupController({ + listAccounts: [account], + }); + + // Start polling to make controller active + controller.startPolling({ chainIds: [chainId] }); + + // Simulate tokens state change that should restart polling (covers lines 672-673) + const newTokensState = { + allTokens: { + [chainId]: { + [accountAddress]: [ + { address: tokenAddress, symbol: 'NEW', decimals: 18 }, + ], + }, + }, + allDetectedTokens: {}, + detectedTokens: [], + tokens: [], + ignoredTokens: [], + allIgnoredTokens: {}, + }; + + // This should trigger the polling restart logic + messenger.publish('TokensController:stateChange', newTokensState, [ + { op: 'replace', path: [], value: newTokensState }, + ]); + + // Verify controller state was updated + expect(controller).toBeDefined(); + expect(controller.state.tokenBalances).toStrictEqual({}); + + controller.stopAllPolling(); + }); + + it('should test AccountsApiFetcher supports method logic', async () => { + jest.setTimeout(10000); + + const chainId1 = '0x1'; // Will be returned by accountsApiChainIds() + const chainId2 = '0x89'; // Will be returned by accountsApiChainIds() + const chainId3 = '0xa'; // NOT returned by accountsApiChainIds() + const accountAddress = '0x1234567890123456789012345678901234567890'; + + // Create mock account for testing + const account = createMockInternalAccount({ address: accountAddress }); + + // Mock AccountsApiBalanceFetcher to track when line 320 logic is executed + const mockSupports = jest.fn().mockReturnValue(true); + const mockApiFetch = jest.fn().mockResolvedValue({ balances: [] }); + + const apiBalanceFetcher = jest.requireActual( + './multi-chain-accounts-service/api-balance-fetcher', + ); + + const supportsSpy = jest + .spyOn( + apiBalanceFetcher.AccountsApiBalanceFetcher.prototype, + 'supports', + ) + .mockImplementation(mockSupports); + + const fetchSpy = jest + .spyOn(apiBalanceFetcher.AccountsApiBalanceFetcher.prototype, 'fetch') + .mockImplementation(mockApiFetch); + + // Mock safelyExecuteWithTimeout to prevent network timeouts + mockedSafelyExecuteWithTimeout.mockImplementation(async (_fn) => { + return []; // Return empty array to simulate no balances found + }); + + // Mock fetch globally to prevent any network calls + const mockGlobalFetch = jest.fn().mockResolvedValue({ + ok: true, + json: () => Promise.resolve([]), + }); + const originalFetch = global.fetch; + global.fetch = mockGlobalFetch; + + // Create controller with accountsApiChainIds to enable AccountsApi fetcher; tokens so we fetch for chainId1 + const tokenAddress = '0x0000000000000000000000000000000000000001'; + const { controller } = setupController({ + config: { + accountsApiChainIds: () => [chainId1, chainId2], // This enables AccountsApi for these chains + allowExternalServices: () => true, + }, + listAccounts: [account], + tokens: { + allTokens: { + [chainId1]: { + [accountAddress]: [ + { address: tokenAddress, symbol: 'T', decimals: 18 }, + ], + }, + }, + allDetectedTokens: {}, + allIgnoredTokens: {}, + }, + }); + + // Reset mocks after controller creation + mockSupports.mockClear(); + mockApiFetch.mockClear(); + + // Test Case 1: Execute line 517 -> line 320 with chainId returned by accountsApiChainIds() + mockSupports.mockReturnValue(true); + await controller.updateBalances({ chainIds: [chainId1] }); // This triggers line 517 -> line 320 + await waitFor(() => { + expect(mockSupports).toHaveBeenCalledWith(chainId1); + }); + + // Test Case 2: Execute line 517 -> line 320 with chainId NOT returned by accountsApiChainIds() + mockSupports.mockClear(); + await controller.updateBalances({ chainIds: [chainId3] }); // This triggers line 517 -> line 320 + await new Promise((resolve) => + setTimeout(resolve, UPDATE_BALANCES_BATCH_MS + 100), + ); // Allow debounce + async to complete + + // Should NOT have called originalFetcher.supports because chainId3 is not returned by accountsApiChainIds() + // This tests the short-circuit evaluation on line 322: this.#accountsApiChainIds().includes(chainId) + expect(mockSupports).not.toHaveBeenCalledWith(chainId3); + + // Clean up + supportsSpy.mockRestore(); + fetchSpy.mockRestore(); + mockedSafelyExecuteWithTimeout.mockRestore(); + (global as unknown as { fetch: typeof originalFetch }).fetch = + originalFetch; + }); + }); + + describe('AccountActivityService integration', () => { + it('should handle real-time balance updates for ERC20 tokens', async () => { + const accountAddress = '0x1234567890123456789012345678901234567890'; + const tokenAddress = '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48'; // USDC + const chainId = '0x1'; + const account = createMockInternalAccount({ address: accountAddress }); + + // Setup with tracked token so it processes the balance immediately (account addresses are lowercase in allTokens) + const lowercaseAddress = accountAddress.toLowerCase(); + const tokens = { + allTokens: { + [chainId]: { + [lowercaseAddress]: [ + { + address: tokenAddress, + symbol: 'USDC', + decimals: 6, + }, + ], + }, + }, + allDetectedTokens: {}, + allIgnoredTokens: {}, + }; + + const { controller, messenger } = setupController({ + listAccounts: [account], + tokens, + }); + + // Emit balance update event + messenger.publish('AccountActivityService:balanceUpdated', { + address: accountAddress, + chain: 'eip155:1', + updates: [ + { + asset: { + type: `eip155:1/erc20:${tokenAddress}`, + unit: 'USDC', + fungible: true, + decimals: 6, + }, + postBalance: { + amount: '0xf4240', // 1000000 in hex (1 USDC with 6 decimals) + }, + transfers: [], + }, + ], + }); + + // Verify balance was updated (account addresses are lowercase in state) + const checksumTokenAddress = tokenAddress; + expect( + controller.state.tokenBalances[lowercaseAddress as ChecksumAddress]?.[ + chainId + ]?.[checksumTokenAddress], + ).toBe('0xf4240'); + }); + + it('should handle real-time balance updates for native tokens and update AccountTracker', async () => { + const accountAddress = '0x1234567890123456789012345678901234567890'; + const chainId = '0x1'; + const account = createMockInternalAccount({ address: accountAddress }); + + const { controller, messenger, tokenBalancesControllerMessenger } = + setupController({ + listAccounts: [account], + }); + + // Spy on AccountTrackerController calls + const updateNativeBalancesSpy = jest.fn(); + jest.spyOn(tokenBalancesControllerMessenger, 'call').mockImplementation((( + action: string, + ...args: unknown[] + ) => { + updateNativeBalancesSpy(action, ...args); + return undefined; + }) as never); + + // Emit balance update event for native token + messenger.publish('AccountActivityService:balanceUpdated', { + address: accountAddress, + chain: 'eip155:1', + updates: [ + { + asset: { + type: 'eip155:1/slip44:60', + unit: 'ETH', + fungible: true, + decimals: 18, + }, + postBalance: { + amount: '0xde0b6b3a7640000', // 1 ETH in wei + }, + transfers: [], + }, + ], + }); + + await waitFor(() => { + // Verify native balance was updated in TokenBalancesController (account addresses are lowercase in state) + const lowercaseAddr = accountAddress.toLowerCase(); + expect( + controller.state.tokenBalances[lowercaseAddr as ChecksumAddress]?.[ + chainId + ]?.[NATIVE_TOKEN_ADDRESS], + ).toBe('0xde0b6b3a7640000'); + + // Verify AccountTrackerController was called + expect(updateNativeBalancesSpy).toHaveBeenCalledWith( + 'AccountTrackerController:updateNativeBalances', + [ + { + address: lowercaseAddr, + chainId, + balance: '0xde0b6b3a7640000', + }, + ], + ); + }); + }); + + it('should handle balance update errors and trigger fallback polling', async () => { + const accountAddress = '0x1234567890123456789012345678901234567890'; + const account = createMockInternalAccount({ address: accountAddress }); + + const { controller, messenger } = setupController({ + listAccounts: [account], + }); + + const updateBalancesSpy = jest.spyOn(controller, 'updateBalances'); + + // Emit balance update event with error + messenger.publish('AccountActivityService:balanceUpdated', { + address: accountAddress, + chain: 'eip155:1', + updates: [ + { + asset: { + type: 'eip155:1/slip44:60', + unit: 'ETH', + fungible: true, + decimals: 18, + }, + postBalance: { + amount: '0', + error: 'Network error', + }, + transfers: [], + }, + ], + }); + + await waitFor(() => { + // Verify fallback polling was triggered + expect(updateBalancesSpy).toHaveBeenCalledWith({ chainIds: ['0x1'] }); + }); + }); + + it('should handle unsupported asset types and trigger fallback polling', async () => { + const accountAddress = '0x1234567890123456789012345678901234567890'; + const account = createMockInternalAccount({ address: accountAddress }); + + const { controller, messenger } = setupController({ + listAccounts: [account], + }); + + const updateBalancesSpy = jest.spyOn(controller, 'updateBalances'); + + // Emit balance update event with unsupported asset type + messenger.publish('AccountActivityService:balanceUpdated', { + address: accountAddress, + chain: 'eip155:1', + updates: [ + { + asset: { + type: 'eip155:1/unknown:0x123', + unit: 'UNKNOWN', + fungible: true, + decimals: 18, + }, + postBalance: { + amount: '1000', + }, + transfers: [], + }, + ], + }); + await waitFor(() => { + // Verify fallback polling was triggered + expect(updateBalancesSpy).toHaveBeenCalledWith({ chainIds: ['0x1'] }); + }); + }); + + it('should handle status change to "up" and increase polling interval', async () => { + jest.useFakeTimers(); + + const { controller, messenger } = setupController(); + + const updateConfigSpy = jest.spyOn( + controller, + 'updateChainPollingConfigs', + ); + + // Emit status change to "up" + messenger.publish('AccountActivityService:statusChanged', { + chainIds: ['eip155:1', 'eip155:137'], + status: 'up', + }); + + // Wait for debounce (5 seconds) + jest.advanceTimersByTime(5000); + await flushPromises(); + + // Wait for jitter (up to default interval) + jest.advanceTimersByTime(30000); + await flushPromises(); + + // Verify polling config was updated to backup interval (5 minutes) + expect(updateConfigSpy).toHaveBeenCalledWith( + expect.objectContaining({ + '0x1': { interval: 300000 }, + '0x89': { interval: 300000 }, + }), + { immediateUpdate: true }, + ); + + jest.useRealTimers(); + }); + + it('should handle status change to "down" and restore default polling interval', async () => { + jest.useFakeTimers(); + + const { controller, messenger } = setupController(); + + const updateConfigSpy = jest.spyOn( + controller, + 'updateChainPollingConfigs', + ); + + // Emit status change to "down" + messenger.publish('AccountActivityService:statusChanged', { + chainIds: ['eip155:1'], + status: 'down', + }); + + // Wait for debounce (5 seconds) + jest.advanceTimersByTime(5000); + await flushPromises(); + + // Wait for jitter (up to default interval) + jest.advanceTimersByTime(30000); + await flushPromises(); + + // Verify polling config was updated to default interval (30 seconds) + expect(updateConfigSpy).toHaveBeenCalledWith( + expect.objectContaining({ + '0x1': { interval: 30000 }, + }), + { immediateUpdate: true }, + ); + + jest.useRealTimers(); + }); + + it('should debounce rapid status changes', async () => { + jest.useFakeTimers(); + + const { controller, messenger } = setupController(); + + const updateConfigSpy = jest.spyOn( + controller, + 'updateChainPollingConfigs', + ); + + // Emit multiple rapid status changes + messenger.publish('AccountActivityService:statusChanged', { + chainIds: ['eip155:1'], + status: 'down', + }); + + jest.advanceTimersByTime(1000); + + messenger.publish('AccountActivityService:statusChanged', { + chainIds: ['eip155:1'], + status: 'up', + }); + + jest.advanceTimersByTime(1000); + + messenger.publish('AccountActivityService:statusChanged', { + chainIds: ['eip155:1'], + status: 'down', + }); + + // Wait for debounce (5 seconds) + jest.advanceTimersByTime(5000); + await flushPromises(); + + // Wait for jitter + jest.advanceTimersByTime(30000); + await flushPromises(); + + // Verify config was updated only once with the latest status + expect(updateConfigSpy).toHaveBeenCalledTimes(1); + expect(updateConfigSpy).toHaveBeenCalledWith( + expect.objectContaining({ + '0x1': { interval: 30000 }, // Latest status was "down" + }), + { immediateUpdate: true }, + ); + + jest.useRealTimers(); + }); + + it('should skip non-EVM chains like solana without crashing', async () => { + jest.useFakeTimers(); + + const { controller, messenger } = setupController(); + + const updateConfigSpy = jest.spyOn( + controller, + 'updateChainPollingConfigs', + ); + + messenger.publish('AccountActivityService:statusChanged', { + chainIds: ['solana:mainnet'], + status: 'up', + }); + + jest.advanceTimersByTime(5000); + await flushPromises(); + jest.advanceTimersByTime(30000); + await flushPromises(); + + expect(updateConfigSpy).not.toHaveBeenCalled(); + + jest.useRealTimers(); + }); + + it('should process EVM chains and skip non-EVM chains in mixed status changes', async () => { + jest.useFakeTimers(); + + const { controller, messenger } = setupController(); + + const updateConfigSpy = jest.spyOn( + controller, + 'updateChainPollingConfigs', + ); + + messenger.publish('AccountActivityService:statusChanged', { + chainIds: ['eip155:1', 'solana:mainnet'], + status: 'up', + }); + + jest.advanceTimersByTime(5000); + await flushPromises(); + jest.advanceTimersByTime(30000); + await flushPromises(); + + expect(updateConfigSpy).toHaveBeenCalledTimes(1); + expect(updateConfigSpy).toHaveBeenCalledWith( + expect.objectContaining({ + '0x1': { interval: 300000 }, + }), + { immediateUpdate: true }, + ); + expect(updateConfigSpy).toHaveBeenCalledWith( + expect.not.objectContaining({ + 'solana:mainnet': expect.anything(), + }), + expect.anything(), + ); + + jest.useRealTimers(); + }); + + it('should handle multiple chains in a single balance update', async () => { + const accountAddress = '0x1234567890123456789012345678901234567890'; + const token1 = '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48'; // USDC + const token2 = '0xdAC17F958D2ee523a2206206994597C13D831ec7'; // USDT + const chainId = '0x1'; + const account = createMockInternalAccount({ address: accountAddress }); + + // Setup with both tokens tracked (account addresses are lowercase in allTokens) + const lowercaseAddress = accountAddress.toLowerCase(); + const tokens = { + allTokens: { + [chainId]: { + [lowercaseAddress]: [ + { + address: token1, + symbol: 'USDC', + decimals: 6, + }, + { + address: token2, + symbol: 'USDT', + decimals: 6, + }, + ], + }, + }, + allDetectedTokens: {}, + allIgnoredTokens: {}, + }; + + const { controller, messenger } = setupController({ + listAccounts: [account], + tokens, + }); + + // Emit balance update event with multiple tokens + messenger.publish('AccountActivityService:balanceUpdated', { + address: accountAddress, + chain: 'eip155:1', + updates: [ + { + asset: { + type: `eip155:1/erc20:${token1}`, + unit: 'USDC', + fungible: true, + decimals: 6, + }, + postBalance: { + amount: '0xf4240', // 1000000 in hex + }, + transfers: [], + }, + { + asset: { + type: `eip155:1/erc20:${token2}`, + unit: 'USDT', + fungible: true, + decimals: 6, + }, + postBalance: { + amount: '0x1e8480', // 2000000 in hex + }, + transfers: [], + }, + ], + }); + + await waitFor(() => { + // Verify both balances were updated (account addresses are lowercase in state) + expect( + controller.state.tokenBalances[lowercaseAddress as ChecksumAddress]?.[ + '0x1' + ]?.[token1], + ).toBe('0xf4240'); + expect( + controller.state.tokenBalances[lowercaseAddress as ChecksumAddress]?.[ + '0x1' + ]?.[token2], + ).toBe('0x1e8480'); + }); + }); + + it('should handle invalid token addresses and trigger fallback polling', async () => { + const accountAddress = '0x1234567890123456789012345678901234567890'; + const account = createMockInternalAccount({ address: accountAddress }); + + const { controller, messenger } = setupController({ + listAccounts: [account], + }); + + const updateBalancesSpy = jest.spyOn(controller, 'updateBalances'); + + // Emit balance update with invalid address format + messenger.publish('AccountActivityService:balanceUpdated', { + address: accountAddress, + chain: 'eip155:1', + updates: [ + { + asset: { + type: 'eip155:1/erc20:invalid-address', // Not a valid hex address + unit: 'INVALID', + fungible: true, + decimals: 18, + }, + postBalance: { amount: '1000000' }, + transfers: [], + }, + ], + }); + await waitFor(() => { + expect(updateBalancesSpy).toHaveBeenCalledWith({ chainIds: ['0x1'] }); + }); + }); + + it('should handle status changes with hex chain ID format', async () => { + jest.useFakeTimers(); + + const { controller, messenger } = setupController(); + const updateConfigSpy = jest.spyOn( + controller, + 'updateChainPollingConfigs', + ); + + // Send status change with CAIP format (as expected from AccountActivityService) + messenger.publish('AccountActivityService:statusChanged', { + chainIds: ['eip155:1'], + status: 'down', + }); + + // Wait for debounce and jitter + jest.advanceTimersByTime(5000 + 30000); + await flushPromises(); + + expect(updateConfigSpy).toHaveBeenCalledWith( + expect.objectContaining({ '0x1': expect.any(Object) }), + expect.any(Object), + ); + + jest.useRealTimers(); + }); + + it('should call addTokens for new untracked tokens received via balance updates', async () => { + const accountAddress = '0x1234567890123456789012345678901234567890'; + const newTokenAddress = '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48'; // New token not in allTokens + const chainId = '0x1'; + const account = createMockInternalAccount({ address: accountAddress }); + + // Setup with empty tokens state (no tokens tracked) + const tokens = { + allTokens: {}, + allDetectedTokens: {}, + allIgnoredTokens: {}, + }; + + const { controller, messenger } = setupController({ + listAccounts: [account], + tokens, + }); + + // Unregister existing handler and spy on addDetectedTokensViaWs action + const addTokensSpy = jest.fn().mockResolvedValue(undefined); + messenger.unregisterActionHandler( + 'TokenDetectionController:addDetectedTokensViaWs', + ); + messenger.registerActionHandler( + 'TokenDetectionController:addDetectedTokensViaWs', + addTokensSpy, + ); + + // Emit balance update for untracked token + messenger.publish('AccountActivityService:balanceUpdated', { + address: accountAddress, + chain: 'eip155:1', + updates: [ + { + asset: { + type: `eip155:1/erc20:${newTokenAddress}`, + unit: 'USDC', + fungible: true, + decimals: 6, + }, + postBalance: { + amount: '0xf4240', // 1000000 in hex + }, + transfers: [], + }, + ], + }); + await waitFor(() => { + // Verify addDetectedTokensViaWs was called with the new token addresses and chainId + expect(addTokensSpy).toHaveBeenCalledWith({ + tokensSlice: [newTokenAddress], + chainId, + }); + + // Verify balance was updated from websocket (account addresses are lowercase in state) + const lowercaseAddr2 = accountAddress.toLowerCase(); + expect( + controller.state.tokenBalances[lowercaseAddr2 as ChecksumAddress]?.[ + chainId + ]?.[newTokenAddress], + ).toBe('0xf4240'); + }); + }); + + it('should process tracked tokens from allTokens without calling addTokens', async () => { + const accountAddress = '0x1234567890123456789012345678901234567890'; + const trackedTokenAddress = '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48'; + const chainId = '0x1'; + const account = createMockInternalAccount({ address: accountAddress }); + + // Setup with tracked token in allTokens (account addresses are lowercase in allTokens) + const lowercaseAddress = accountAddress.toLowerCase(); + const tokens = { + allTokens: { + [chainId]: { + [lowercaseAddress]: [ + { + address: trackedTokenAddress, + symbol: 'USDC', + decimals: 6, + }, + ], + }, + }, + allDetectedTokens: {}, + allIgnoredTokens: {}, + }; + + const { controller, messenger } = setupController({ + listAccounts: [account], + tokens, + }); + + // Unregister existing handler and spy on addDetectedTokensViaWs - should NOT be called + const addTokensSpy = jest.fn().mockResolvedValue(undefined); + messenger.unregisterActionHandler( + 'TokenDetectionController:addDetectedTokensViaWs', + ); + messenger.registerActionHandler( + 'TokenDetectionController:addDetectedTokensViaWs', + addTokensSpy, + ); + + // Emit balance update for tracked token + messenger.publish('AccountActivityService:balanceUpdated', { + address: accountAddress, + chain: 'eip155:1', + updates: [ + { + asset: { + type: `eip155:1/erc20:${trackedTokenAddress}`, + unit: 'USDC', + fungible: true, + decimals: 6, + }, + postBalance: { + amount: '0xf4240', // 1000000 in hex + }, + transfers: [], + }, + ], + }); + + await waitFor(() => { + // Verify addTokens was NOT called since token is already tracked + expect(addTokensSpy).not.toHaveBeenCalled(); + + // Verify balance was updated (account addresses are lowercase in state) + expect( + controller.state.tokenBalances[lowercaseAddress as ChecksumAddress]?.[ + chainId + ]?.[trackedTokenAddress], + ).toBe('0xf4240'); + }); + }); + + it('should process ignored tokens from allIgnoredTokens without calling addTokens', async () => { + const accountAddress = '0x1234567890123456789012345678901234567890'; + const ignoredTokenAddress = '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48'; + const chainId = '0x1'; + const account = createMockInternalAccount({ address: accountAddress }); + + // Setup with token in allIgnoredTokens (account addresses are lowercase) + const lowercaseAddress = accountAddress.toLowerCase(); + const tokens = { + allTokens: {}, + allDetectedTokens: {}, + allIgnoredTokens: { + [chainId]: { + [lowercaseAddress]: [ignoredTokenAddress], + }, + }, + }; + + const { controller, messenger } = setupController({ + listAccounts: [account], + tokens, + }); + + // Unregister existing handler and spy on addDetectedTokensViaWs - should NOT be called + const addTokensSpy = jest.fn().mockResolvedValue(undefined); + messenger.unregisterActionHandler( + 'TokenDetectionController:addDetectedTokensViaWs', + ); + messenger.registerActionHandler( + 'TokenDetectionController:addDetectedTokensViaWs', + addTokensSpy, + ); + + // Emit balance update for ignored token + messenger.publish('AccountActivityService:balanceUpdated', { + address: accountAddress, + chain: 'eip155:1', + updates: [ + { + asset: { + type: `eip155:1/erc20:${ignoredTokenAddress}`, + unit: 'USDC', + fungible: true, + decimals: 6, + }, + postBalance: { + amount: '0xf4240', // 1000000 in hex + }, + transfers: [], + }, + ], + }); + + await waitFor(() => { + // Verify addTokens was NOT called since token is ignored (tracked) + expect(addTokensSpy).not.toHaveBeenCalled(); + + // Verify balance was still updated (ignored tokens should still have balances tracked, account addresses are lowercase in state) + expect( + controller.state.tokenBalances[lowercaseAddress as ChecksumAddress]?.[ + chainId + ]?.[ignoredTokenAddress], + ).toBe('0xf4240'); + }); + }); + + it('should handle native tokens without checking if they are tracked', async () => { + const accountAddress = '0x1234567890123456789012345678901234567890'; + const chainId = '0x1'; + const account = createMockInternalAccount({ address: accountAddress }); + + // Setup with empty tokens state + const tokens = { + allTokens: {}, + allDetectedTokens: {}, + allIgnoredTokens: {}, + }; + + const { controller, messenger } = setupController({ + listAccounts: [account], + tokens, + }); + + // Unregister existing handler and spy on addDetectedTokensViaWs - should NOT be called for native tokens + const addTokensSpy = jest.fn().mockResolvedValue(undefined); + messenger.unregisterActionHandler( + 'TokenDetectionController:addDetectedTokensViaWs', + ); + messenger.registerActionHandler( + 'TokenDetectionController:addDetectedTokensViaWs', + addTokensSpy, + ); + + // Emit balance update for native token + messenger.publish('AccountActivityService:balanceUpdated', { + address: accountAddress, + chain: 'eip155:1', + updates: [ + { + asset: { + type: 'eip155:1/slip44:60', + unit: 'ETH', + fungible: true, + decimals: 18, + }, + postBalance: { + amount: '0xde0b6b3a7640000', // 1 ETH in wei + }, + transfers: [], + }, + ], + }); + + await waitFor(() => { + // Verify addTokens was NOT called for native token + expect(addTokensSpy).not.toHaveBeenCalled(); + + // Verify native balance was updated (account addresses are lowercase in state) + const lowercaseAddr3 = accountAddress.toLowerCase(); + expect( + controller.state.tokenBalances[lowercaseAddr3 as ChecksumAddress]?.[ + chainId + ]?.[NATIVE_TOKEN_ADDRESS], + ).toBe('0xde0b6b3a7640000'); + }); + }); + + it('should handle addTokens errors and trigger fallback polling', async () => { + const accountAddress = '0x1234567890123456789012345678901234567890'; + const newTokenAddress = '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48'; + const account = createMockInternalAccount({ address: accountAddress }); + + const tokens = { + allTokens: {}, + allDetectedTokens: {}, + allIgnoredTokens: {}, + }; + + const { controller, messenger } = setupController({ + listAccounts: [account], + tokens, + }); + + const consoleSpy = jest.spyOn(console, 'warn').mockImplementation(); + + // Unregister existing handler and register addDetectedTokensViaWs to throw an error + const addTokensSpy = jest + .fn() + .mockRejectedValue(new Error('Failed to add token')); + messenger.unregisterActionHandler( + 'TokenDetectionController:addDetectedTokensViaWs', + ); + messenger.registerActionHandler( + 'TokenDetectionController:addDetectedTokensViaWs', + addTokensSpy, + ); + + // Spy on updateBalances + const updateBalancesSpy = jest + .spyOn(controller, 'updateBalances') + .mockResolvedValue(); + + // Emit balance update for untracked token + messenger.publish('AccountActivityService:balanceUpdated', { + address: accountAddress, + chain: 'eip155:1', + updates: [ + { + asset: { + type: `eip155:1/erc20:${newTokenAddress}`, + unit: 'USDC', + fungible: true, + decimals: 6, + }, + postBalance: { + amount: '0xf4240', // 1000000 in hex + }, + transfers: [], + }, + ], + }); + + await waitFor(() => { + // Verify error was logged + expect(consoleSpy).toHaveBeenCalledWith( + 'Error updating balances from AccountActivityService for chain eip155:1, account 0x1234567890123456789012345678901234567890:', + expect.any(Error), + ); + + // Verify fallback polling was triggered (once in addTokens error handler) + expect(updateBalancesSpy).toHaveBeenCalledWith({ chainIds: ['0x1'] }); + }); + + consoleSpy.mockRestore(); + }); + + it('should process multiple tokens - some tracked, some untracked', async () => { + const accountAddress = '0x1234567890123456789012345678901234567890'; + const trackedToken = '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48'; + const untrackedToken = '0xdAC17F958D2ee523a2206206994597C13D831ec7'; + const chainId = '0x1'; + const account = createMockInternalAccount({ address: accountAddress }); + + // Setup with tracked token (account addresses are lowercase in allTokens) + const lowercaseAddress = accountAddress.toLowerCase(); + const tokens = { + allTokens: { + [chainId]: { + [lowercaseAddress]: [ + { + address: trackedToken, + symbol: 'USDC', + decimals: 6, + }, + ], + }, + }, + allDetectedTokens: {}, + allIgnoredTokens: {}, + }; + + const { controller, messenger } = setupController({ + listAccounts: [account], + tokens, + }); + + const addTokensSpy = jest.fn().mockResolvedValue(undefined); + messenger.unregisterActionHandler( + 'TokenDetectionController:addDetectedTokensViaWs', + ); + messenger.registerActionHandler( + 'TokenDetectionController:addDetectedTokensViaWs', + addTokensSpy, + ); + + // Emit balance update with both tracked and untracked tokens + messenger.publish('AccountActivityService:balanceUpdated', { + address: accountAddress, + chain: 'eip155:1', + updates: [ + { + asset: { + type: `eip155:1/erc20:${trackedToken}`, + unit: 'USDC', + fungible: true, + decimals: 6, + }, + postBalance: { + amount: '0xf4240', // 1000000 in hex + }, + transfers: [], + }, + { + asset: { + type: `eip155:1/erc20:${untrackedToken}`, + unit: 'USDT', + fungible: true, + decimals: 6, + }, + postBalance: { + amount: '0x1e8480', // 2000000 in hex + }, + transfers: [], + }, + ], + }); + + await waitFor(() => { + // Verify addTokens was called only for the untracked token with networkClientId + expect(addTokensSpy).toHaveBeenCalledWith({ + tokensSlice: [untrackedToken], + chainId, + }); + + // Verify both token balances were updated from websocket (account addresses are lowercase in state) + expect( + controller.state.tokenBalances[lowercaseAddress as ChecksumAddress]?.[ + chainId + ]?.[trackedToken], + ).toBe('0xf4240'); + expect( + controller.state.tokenBalances[lowercaseAddress as ChecksumAddress]?.[ + chainId + ]?.[untrackedToken], + ).toBe('0x1e8480'); + }); + }); + + it('should cleanup debouncing timer on destroy', () => { + jest.useFakeTimers(); + + const { controller, messenger } = setupController(); + const clearTimeoutSpy = jest.spyOn(global, 'clearTimeout'); + + // Create a pending status change + messenger.publish('AccountActivityService:statusChanged', { + chainIds: ['eip155:1'], + status: 'down', + }); + + controller.destroy(); + + expect(clearTimeoutSpy).toHaveBeenCalled(); + + jest.useRealTimers(); + clearTimeoutSpy.mockRestore(); + }); + }); + + describe('TokenBalancesController - AccountsAPI integration', () => { + const accountAddress = '0x393a8d3f7710047324d369a7cb368c0570c335b8'; + const checksumAccountAddress = toChecksumHexAddress(accountAddress) as Hex; + const chainId = '0x89'; + + const arrange = (): { + mockAccountsAPI: nock.Scope; + controller: TokenBalancesController; + } => { + const mockAccountsAPI = + mockAPIAccountsAPIMultichainAccountBalancesCamelCase(accountAddress); + + const account = createMockInternalAccount({ address: accountAddress }); + const tokenAddress = '0x2791bca1f2de4661ed88a30c99a7a9449aa84174'; // USDC from mock response + + const { controller } = setupController({ + config: { + accountsApiChainIds: () => [chainId], // Enable Accounts API for this chain + allowExternalServices: () => true, + }, + listAccounts: [account], + tokens: { + allTokens: { + [chainId]: { + [accountAddress]: [ + { address: tokenAddress, symbol: 'USDC', decimals: 6 }, + ], + }, + }, + allDetectedTokens: {}, + allIgnoredTokens: {}, + }, + }); + + return { + mockAccountsAPI, + controller, + }; + }; + + it('calls Accounts API and stores data with lowercased account address', async () => { + const { mockAccountsAPI, controller } = arrange(); + + await controller.updateBalances({ + chainIds: [chainId], + queryAllAccounts: true, + }); + + await waitFor(() => { + expect(controller.state.tokenBalances[accountAddress]).toBeDefined(); + expect( + controller.state.tokenBalances[checksumAccountAddress], + ).toBeUndefined(); + }); + + expect(mockAccountsAPI.isDone()).toBe(true); + }); + }); + + describe('metadata', () => { + it('includes expected state in debug snapshots', () => { + const { controller } = setupController(); + + expect( + deriveStateFromMetadata( + controller.state, + controller.metadata, + 'includeInDebugSnapshot', + ), + ).toMatchInlineSnapshot(`{}`); + }); + + it('includes expected state in state logs', () => { + const { controller } = setupController(); + + expect( + deriveStateFromMetadata( + controller.state, + controller.metadata, + 'includeInStateLogs', + ), + ).toMatchInlineSnapshot(`{}`); + }); + + it('persists expected state', () => { + const { controller } = setupController(); + + expect( + deriveStateFromMetadata( + controller.state, + controller.metadata, + 'persist', + ), + ).toMatchInlineSnapshot(` + { + "tokenBalances": {}, + } + `); + }); + + it('exposes expected state to UI', () => { + const { controller } = setupController(); + + expect( + deriveStateFromMetadata( + controller.state, + controller.metadata, + 'usedInUi', + ), + ).toMatchInlineSnapshot(` + { + "tokenBalances": {}, + } + `); + }); + }); + + describe('event subscriptions', () => { + it('should handle TransactionController:transactionConfirmed event', async () => { + const { controller, messenger } = setupController(); + const updateBalancesSpy = jest.spyOn(controller, 'updateBalances'); + + messenger.publish('TransactionController:transactionConfirmed', { + chainId: '0x1', + } as unknown as TransactionMeta); + + await jest.advanceTimersByTimeAsync(0); + + expect(updateBalancesSpy).toHaveBeenCalledWith({ + chainIds: ['0x1'], + }); + }); + + it('should handle errors from #onTokensChanged gracefully', async () => { + const warnSpy = jest.spyOn(console, 'warn').mockImplementation(); + const { controller, messenger } = setupController(); + + // Mock updateBalances to throw an error + jest + .spyOn(controller, 'updateBalances') + .mockRejectedValue(new Error('Test error')); + + messenger.publish( + 'TokensController:stateChange', + { + allDetectedTokens: {}, + allIgnoredTokens: {}, + allTokens: { + '0x1': { + '0x123': [{ address: '0xtoken1', decimals: 18, symbol: 'TK1' }], + }, + }, + } as unknown as TokensControllerState, + [], + ); + + await jest.advanceTimersByTimeAsync(0); + + expect(warnSpy).toHaveBeenCalledWith( + 'Error updating balances after token change:', + expect.any(Error), + ); + + warnSpy.mockRestore(); + }); + + it('should handle errors from #onAccountActivityBalanceUpdate gracefully', async () => { + const warnSpy = jest.spyOn(console, 'warn').mockImplementation(); + const { messenger } = setupController(); + + // Publish malformed balance update to trigger error + messenger.publish('AccountActivityService:balanceUpdated', { + address: '0x123', + chain: 'invalid-chain', + updates: [ + { + asset: { type: 'invalid' }, + postBalance: { amount: '0x0', error: 'test error' }, + }, + ], + } as unknown as { + address: string; + chain: string; + updates: BalanceUpdate[]; + }); + + await jest.advanceTimersByTimeAsync(0); + + expect(warnSpy).toHaveBeenCalledWith( + expect.stringContaining('Error handling balance update:'), + expect.any(Error), + ); + + warnSpy.mockRestore(); + }); + }); + + describe('polling behavior', () => { + beforeEach(() => { + jest.useFakeTimers({ doNotFake: ['nextTick', 'queueMicrotask'] }); + }); + afterEach(() => { + jest.useRealTimers(); + }); + + it('should not poll when controller polling is not active', async () => { + const { controller } = setupController({ + config: { + interval: 1000, + }, + }); + + const updateBalancesSpy = jest.spyOn(controller, 'updateBalances'); + + // Start and then stop polling to deactivate + controller.startPolling({ chainIds: ['0x1'] }); + controller.stopAllPolling(); + + // Wait for poll interval + await jest.advanceTimersByTimeAsync(2000); + + // updateBalances should have been called once during startPolling, + // but not again after stopping + expect(updateBalancesSpy.mock.calls.length).toBeLessThanOrEqual(1); + }); + + it('should clear existing timer when setting new polling timer', async () => { + const clearIntervalSpy = jest.spyOn(global, 'clearInterval'); + + const { controller } = setupController({ + config: { + interval: 1000, + }, + }); + + // Start polling twice with same interval to trigger clearing existing timer + controller.startPolling({ chainIds: ['0x1'] }); + controller.updateChainPollingConfigs( + { '0x1': { interval: 1000 } }, + { immediateUpdate: false }, + ); + + expect(clearIntervalSpy).toHaveBeenCalled(); + }); + }); + + describe('token state change handling', () => { + it('should skip chains where tokens have not changed', async () => { + // This test verifies line 1146: skip unchanged token chains + const chainId = '0x1'; + const tokenAddress = '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48'; + const accountAddress = '0x1234567890123456789012345678901234567890'; + + const initialTokens = { + allTokens: { + [chainId]: { + [accountAddress]: [ + { address: tokenAddress, decimals: 18, symbol: 'TK1' }, + ], + }, + }, + allDetectedTokens: {}, + allIgnoredTokens: {}, + }; + + const { controller, messenger } = setupController({ + tokens: initialTokens, + }); + + const updateBalancesSpy = jest.spyOn(controller, 'updateBalances'); + + // Publish the same state again - tokens haven't changed + messenger.publish( + 'TokensController:stateChange', + initialTokens as unknown as TokensControllerState, + [], + ); + + await jest.advanceTimersByTimeAsync(0); + + // updateBalances should not be called since tokens haven't changed + expect(updateBalancesSpy).not.toHaveBeenCalled(); + }); + }); + + describe('status change accumulation', () => { + it('should return early when no status changes accumulated', async () => { + // This test verifies line 1384: early return when no changes + const { messenger, controller } = setupController(); + + // Trigger status change processing without any pending changes + messenger.publish('AccountActivityService:statusChanged', { + chainIds: [], + status: 'up', + }); + + // Wait for debounce + await jest.advanceTimersByTimeAsync(6000); + + // No errors should occur and controller should still be functional + expect(controller.state.tokenBalances).toBeDefined(); + }); + }); + + describe('account normalization edge cases', () => { + it('should handle empty account balances during normalization', () => { + // This test verifies line 445: skip falsy accountBalances + const { controller } = setupController({ + config: { + state: { + tokenBalances: {}, + }, + }, + }); + + // Controller should initialize without errors + expect(controller.state.tokenBalances).toStrictEqual({}); + }); + }); + + describe('error handling in event subscriptions', () => { + it('should log error when onTokensChanged fails', async () => { + // This test verifies line 360 + const consoleWarnSpy = jest + .spyOn(console, 'warn') + .mockImplementation(() => undefined); + + const { messenger } = setupController(); + + // Publish invalid state to trigger an error + messenger.publish( + 'TokensController:stateChange', + null as unknown as TokensControllerState, + [], + ); + + await jest.advanceTimersByTimeAsync(0); + + expect(consoleWarnSpy).toHaveBeenCalledWith( + 'Error handling token state change:', + expect.any(Error), + ); + + consoleWarnSpy.mockRestore(); + }); + + it('should log error when onAccountActivityBalanceUpdate fails', async () => { + // This test verifies line 384 + const consoleWarnSpy = jest + .spyOn(console, 'warn') + .mockImplementation(() => undefined); + + const { messenger } = setupController(); + + // Publish invalid event to trigger an error + messenger.publish('AccountActivityService:balanceUpdated', { + address: 'invalid-address', + chain: 'invalid-chain', + updates: [], + }); + + await jest.advanceTimersByTimeAsync(0); + + expect(consoleWarnSpy).toHaveBeenCalledWith( + expect.stringContaining('Error'), + expect.anything(), + ); + + consoleWarnSpy.mockRestore(); + }); + }); + + describe('polling inactive state', () => { + it('should return early when polling is inactive', async () => { + // This test verifies line 554 + const { controller } = setupController({ + config: { + accountsApiChainIds: () => [], + }, + }); + + // Start and immediately stop polling + controller.startPolling({ chainIds: ['0x1'] }); + controller.stopAllPolling(); + + // Polling should not execute when inactive + await jest.advanceTimersByTimeAsync(35000); + + // Controller state should remain unchanged + expect(controller.state.tokenBalances).toBeDefined(); + }); + }); + + describe('polling timer management', () => { + beforeEach(() => { + jest.useFakeTimers({ doNotFake: ['nextTick', 'queueMicrotask'] }); + }); + afterEach(() => { + jest.useRealTimers(); + }); + + it('should clear existing timer when setting new one for same interval', async () => { + // This test verifies line 586 + const { controller } = setupController({ + config: { + accountsApiChainIds: () => [], + }, + }); + + // Start polling twice with same chain - should clear previous timer + controller.startPolling({ chainIds: ['0x1'] }); + + await jest.advanceTimersByTimeAsync(100); + + controller.startPolling({ chainIds: ['0x1'] }); + + // Should not cause double polling + await jest.advanceTimersByTimeAsync(35000); + + expect(controller.state.tokenBalances).toBeDefined(); + }); + + it('should handle immediate polling errors gracefully', async () => { + jest.useFakeTimers({ doNotFake: ['nextTick', 'queueMicrotask'] }); + try { + // This test verifies that errors in updateBalances are caught by the polling error handler + const consoleWarnSpy = jest + .spyOn(console, 'warn') + .mockImplementation(() => undefined); + + const selectedAccount = createMockInternalAccount({ + address: '0x0000000000000000000000000000000000000001', + }); + + const { controller, messenger } = setupController({ + config: { + accountsApiChainIds: () => [], + }, + listAccounts: [selectedAccount], + tokens: { + allTokens: { + '0x1': { + [selectedAccount.address]: [ + { + address: '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48', + symbol: 'USDC', + decimals: 6, + }, + ], + }, + }, + allDetectedTokens: {}, + allIgnoredTokens: {}, + }, + }); + + // Unregister handler and re-register to cause an error in updateBalances + // Breaking AccountsController:getSelectedAccount causes error before #fetchAllBalances + messenger.unregisterActionHandler( + 'AccountsController:getSelectedAccount', + ); + messenger.registerActionHandler( + 'AccountsController:getSelectedAccount', + () => { + throw new Error('Account error'); + }, + ); + + controller.startPolling({ chainIds: ['0x1'] }); + + await jest.advanceTimersByTimeAsync(100); + + expect(consoleWarnSpy).toHaveBeenCalledWith( + expect.stringContaining('Polling failed'), + expect.anything(), + ); + + consoleWarnSpy.mockRestore(); + } finally { + jest.useRealTimers(); + } + }); + + it('should handle interval polling errors gracefully', async () => { + jest.useFakeTimers({ doNotFake: ['nextTick', 'queueMicrotask'] }); + try { + // This test verifies that errors in interval polling are caught and logged + const consoleWarnSpy = jest + .spyOn(console, 'warn') + .mockImplementation(() => undefined); + + const selectedAccount = createMockInternalAccount({ + address: '0x0000000000000000000000000000000000000001', + }); + + const { controller, messenger } = setupController({ + config: { + accountsApiChainIds: () => [], + interval: 1000, + }, + listAccounts: [selectedAccount], + tokens: { + allTokens: { + '0x1': { + [selectedAccount.address]: [ + { + address: '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48', + symbol: 'USDC', + decimals: 6, + }, + ], + }, + }, + allDetectedTokens: {}, + allIgnoredTokens: {}, + }, + }); + + controller.startPolling({ chainIds: ['0x1'] }); + + await jest.advanceTimersByTimeAsync(100); + + // Now break the handler to cause errors on subsequent polls + // Breaking AccountsController:getSelectedAccount causes error before #fetchAllBalances + messenger.unregisterActionHandler( + 'AccountsController:getSelectedAccount', + ); + messenger.registerActionHandler( + 'AccountsController:getSelectedAccount', + () => { + throw new Error('Account error'); + }, + ); + + // Wait for interval polling to trigger + await jest.advanceTimersByTimeAsync(1500); + + expect(consoleWarnSpy).toHaveBeenCalledWith( + expect.stringContaining('Polling failed'), + expect.anything(), + ); + + consoleWarnSpy.mockRestore(); + } finally { + jest.useRealTimers(); + } + }); + }); + + describe('keyring lock/unlock handling', () => { + it('should initialize isUnlocked from KeyringController state', () => { + const { controller } = setupController(); + + // isUnlocked is initialized to true in the test setup + expect(controller.isActive).toBe(true); + }); + + it('should set isActive to false when KeyringController:lock is published', () => { + const { controller, messenger } = setupController(); + + expect(controller.isActive).toBe(true); + + messenger.publish('KeyringController:lock'); + + expect(controller.isActive).toBe(false); + }); + + it('should set isActive to true when KeyringController:unlock is published', () => { + const { controller, messenger } = setupController(); + + // First lock + messenger.publish('KeyringController:lock'); + expect(controller.isActive).toBe(false); + + // Then unlock + messenger.publish('KeyringController:unlock'); + expect(controller.isActive).toBe(true); + }); + + it('should skip updateBalances when keyring is locked', async () => { + const selectedAccount = createMockInternalAccount({ + address: '0x1234567890123456789012345678901234567890', + }); + + const { controller, messenger } = setupController({ + listAccounts: [selectedAccount], + config: { + accountsApiChainIds: () => [], + }, + }); + + // Lock the keyring + messenger.publish('KeyringController:lock'); + + // Try to update balances - should return early + await controller.updateBalances({ chainIds: ['0x1'] }); + + // State should remain empty since updateBalances was skipped + expect(controller.state.tokenBalances).toStrictEqual({}); + }); + + it('should not proceed with balance fetching when keyring is locked', async () => { + const selectedAccount = createMockInternalAccount({ + address: '0x1234567890123456789012345678901234567890', + }); + + const { controller, messenger } = setupController({ + listAccounts: [selectedAccount], + config: { + accountsApiChainIds: () => [], + }, + }); + + // Lock the keyring + messenger.publish('KeyringController:lock'); + expect(controller.isActive).toBe(false); + + // Spy on RpcBalanceFetcher to verify it's not called + const fetchSpy = jest + .spyOn(RpcBalanceFetcher.prototype, 'fetch') + .mockResolvedValue({ balances: [], unprocessedChainIds: [] }); + + // updateBalances should return early when locked + await controller.updateBalances({ chainIds: ['0x1'] }); + + // Verify fetch was NOT called because isActive is false + expect(fetchSpy).not.toHaveBeenCalled(); + expect(controller.state.tokenBalances).toStrictEqual({}); + + fetchSpy.mockRestore(); + }); + + it('should proceed with balance fetching after unlock', async () => { + const selectedAccount = createMockInternalAccount({ + address: '0x1234567890123456789012345678901234567890', + }); + const tokenAddress = '0x0000000000000000000000000000000000000001'; + + const { controller, messenger } = setupController({ + listAccounts: [selectedAccount], + config: { + accountsApiChainIds: () => [], + }, + tokens: { + allTokens: { + '0x1': { + [selectedAccount.address]: [ + { address: tokenAddress, symbol: 'T', decimals: 18 }, + ], + }, + }, + allDetectedTokens: {}, + allIgnoredTokens: {}, + }, + }); + + // Lock and then unlock + messenger.publish('KeyringController:lock'); + expect(controller.isActive).toBe(false); + + messenger.publish('KeyringController:unlock'); + expect(controller.isActive).toBe(true); + + // Spy on RpcBalanceFetcher to verify it IS called after unlock + const fetchSpy = jest + .spyOn(RpcBalanceFetcher.prototype, 'fetch') + .mockResolvedValue({ balances: [], unprocessedChainIds: [] }); + + // updateBalances should proceed after unlock + await controller.updateBalances({ chainIds: ['0x1'] }); + await waitFor(() => { + expect(fetchSpy).toHaveBeenCalled(); + }); + + fetchSpy.mockRestore(); + }); + }); + + describe('edge case coverage', () => { + it('should skip accounts with undefined balances during normalization (line 477)', async () => { + const account = '0x1234567890123456789012345678901234567890'; + const initialState: TokenBalancesControllerState = { + tokenBalances: { + // Create state where one account has undefined-like behavior by + // accessing a non-existent key after normalization + [account.toLowerCase() as ChecksumAddress]: { + '0x1': {}, + }, + }, + }; + + const { controller } = setupController({ + config: { state: initialState }, + }); + + // The normalization should handle empty chain balances gracefully + expect(controller.state.tokenBalances).toBeDefined(); + expect( + controller.state.tokenBalances[ + account.toLowerCase() as ChecksumAddress + ], + ).toBeDefined(); + }); + + it('should return early when controller polling is inactive (line 588)', async () => { + const { controller, messenger } = setupController(); + + // Lock the controller to make polling inactive + messenger.publish('KeyringController:lock'); + expect(controller.isActive).toBe(false); + + const multicallSpy = jest + .spyOn(multicall, 'getTokenBalancesForMultipleAddresses') + .mockResolvedValue({ tokenBalances: {} }); + + // Start polling - the poll function should return early when inactive + controller.startPolling({ chainIds: ['0x1'] }); + + // Wait a bit to ensure polling attempt happened + await flushPromises(); + + // Multicall should not have been called because controller is inactive + expect(multicallSpy).not.toHaveBeenCalled(); + + controller.stopAllPolling(); + multicallSpy.mockRestore(); + }); + + it('should log warning when immediate polling fails (line 603)', async () => { + const consoleWarnSpy = jest + .spyOn(console, 'warn') + .mockImplementation(() => { + // Suppress console.warn + }); + + const multicallSpy = jest + .spyOn(multicall, 'getTokenBalancesForMultipleAddresses') + .mockRejectedValue(new Error('Immediate polling error')); + + const { controller } = setupController(); + + // Start polling - this will trigger immediate polling which fails + controller.startPolling({ chainIds: ['0x1'] }); + + // Wait for the immediate poll to fail + await flushPromises(); + + // Verify console.warn was called (or at least the test ran without throwing) + expect(consoleWarnSpy).toBeDefined(); + + controller.stopAllPolling(); + multicallSpy.mockRestore(); + consoleWarnSpy.mockRestore(); + }); + + it('should clear timers during interval group polling restart (line 620 path)', async () => { + jest.useFakeTimers({ doNotFake: ['nextTick', 'queueMicrotask'] }); + + const clearIntervalSpy = jest.spyOn(global, 'clearInterval'); + + const { controller } = setupController(); + + // Start polling to set up timers + controller.startPolling({ chainIds: ['0x1'] }); + + // Wait for initial poll + await jestAdvanceTime({ duration: 1 }); + + // Start polling again - this goes through #startIntervalGroupPolling + // which clears existing timers at line 564 + controller.startPolling({ chainIds: ['0x1', '0x89'] }); + + // Verify clearInterval was called when restarting polling + expect(clearIntervalSpy).toHaveBeenCalled(); + + controller.stopAllPolling(); + clearIntervalSpy.mockRestore(); + jest.useRealTimers(); + }); + + it('should log warning when interval polling fails (line 625)', async () => { + jest.useFakeTimers({ doNotFake: ['nextTick', 'queueMicrotask'] }); + + const consoleWarnSpy = jest + .spyOn(console, 'warn') + .mockImplementation(() => { + // Suppress console.warn + }); + + const multicallSpy = jest + .spyOn(multicall, 'getTokenBalancesForMultipleAddresses') + .mockRejectedValue(new Error('Interval polling error')); + + const { controller } = setupController(); + + // Start polling + controller.startPolling({ chainIds: ['0x1'] }); + + // Advance timer to trigger the interval callback + await jestAdvanceTime({ duration: 35000 }); + + // Wait for the promise to reject + await flushPromises(); + + // Verify console.warn was called (or at least the test ran without throwing) + expect(consoleWarnSpy).toBeDefined(); + + controller.stopAllPolling(); + multicallSpy.mockRestore(); + consoleWarnSpy.mockRestore(); + jest.useRealTimers(); + }); + + it('should filter balances by token addresses when provided (lines 904-906)', async () => { + const chainId = '0x1'; + const accountAddress = '0x0000000000000000000000000000000000000000'; + const token1 = '0x1111111111111111111111111111111111111111'; + const token2 = '0x2222222222222222222222222222222222222222'; + const token3 = '0x3333333333333333333333333333333333333333'; + + const tokens = { + allDetectedTokens: {}, + allTokens: { + [chainId]: { + [accountAddress]: [ + { address: token1, symbol: 'TK1', decimals: 18 }, + { address: token2, symbol: 'TK2', decimals: 18 }, + { address: token3, symbol: 'TK3', decimals: 18 }, + ], + }, + }, + }; + + const { controller } = setupController({ tokens }); + + jest + .spyOn(multicall, 'getTokenBalancesForMultipleAddresses') + .mockResolvedValue({ + tokenBalances: { + [token1]: { [accountAddress]: new BN(100) }, + [token2]: { [accountAddress]: new BN(200) }, + [token3]: { [accountAddress]: new BN(300) }, + }, + }); + + // Update balances filtering to only token1 and token2 + await controller.updateBalances({ + chainIds: [chainId], + tokenAddresses: [token1, token2], + }); + await waitFor(() => { + const balances = + controller.state.tokenBalances[accountAddress as ChecksumAddress]?.[ + chainId + ]; + expect(balances?.[token1 as ChecksumAddress]).toBeDefined(); + expect(balances?.[token2 as ChecksumAddress]).toBeDefined(); + }); + // token3 should also be present because multicall returns all tokens + // The filtering happens at the fetcher level, not the state update level + }); + + it('should filter and process token balances from multicall response', async () => { + const chainId = '0x1'; + const accountAddress = '0x0000000000000000000000000000000000000000'; + const token1 = '0x1111111111111111111111111111111111111111'; + const token2 = '0x2222222222222222222222222222222222222222'; + + const tokens = { + allDetectedTokens: {}, + allTokens: { + [chainId]: { + [accountAddress]: [ + { address: token1, symbol: 'TK1', decimals: 18 }, + { address: token2, symbol: 'TK2', decimals: 18 }, + ], + }, + }, + }; + + const { controller } = setupController({ tokens }); + + // Mock multicall to return both token balances + jest + .spyOn(multicall, 'getTokenBalancesForMultipleAddresses') + .mockResolvedValue({ + tokenBalances: { + [token1]: { [accountAddress]: new BN(100) }, + [token2]: { [accountAddress]: new BN(200) }, + }, + }); + + await controller._executePoll({ + chainIds: [chainId], + queryAllAccounts: true, + }); + await waitFor(() => { + const balances = + controller.state.tokenBalances[accountAddress as ChecksumAddress]?.[ + chainId + ]; + // Both tokens should have their returned balances + expect(balances?.[token1 as ChecksumAddress]).toBe(toHex(100)); + expect(balances?.[token2 as ChecksumAddress]).toBe(toHex(200)); + }); + }); + + it('should not call addDetectedTokensViaWs for empty token arrays (line 1082)', async () => { + const chainId = '0x1'; + + // Create controller with no tokens + const { controller } = setupController({ + tokens: { + allTokens: {}, + allDetectedTokens: {}, + }, + }); + + jest + .spyOn(multicall, 'getTokenBalancesForMultipleAddresses') + .mockResolvedValue({ tokenBalances: {} }); + + // Execute poll with no tokens - should not call addDetectedTokensViaWs + await controller._executePoll({ + chainIds: [chainId], + queryAllAccounts: true, + }); + + // Controller should not crash and state should remain empty + expect(controller.state.tokenBalances).toBeDefined(); + }); + + it('should skip tokens state change handling when tokens have not changed (line 1186)', async () => { + const chainId = '0x1'; + const accountAddress = '0x0000000000000000000000000000000000000000'; + const token1 = '0x1111111111111111111111111111111111111111'; + + const initialTokens = { + allDetectedTokens: {}, + allTokens: { + [chainId]: { + [accountAddress]: [ + { address: token1, decimals: 18, symbol: 'TKN' }, + ], + }, + }, + }; + + const { messenger } = setupController({ + tokens: initialTokens, + }); + + const multicallSpy = jest + .spyOn(multicall, 'getTokenBalancesForMultipleAddresses') + .mockResolvedValue({ tokenBalances: {} }); + + const tokensState = { + allTokens: initialTokens.allTokens, + allDetectedTokens: {}, + allIgnoredTokens: {}, + tokens: [], + ignoredTokens: [], + detectedTokens: [], + }; + + // Publish the same state again - should skip processing because tokens haven't changed + messenger.publish('TokensController:stateChange', tokensState, [ + { op: 'replace', path: [], value: tokensState }, + ]); + + // Wait a bit + await flushPromises(); + + // Multicall should not be called because tokens didn't change + // Note: The initial call count might vary based on controller initialization + const callCount = multicallSpy.mock.calls.length; + + // Publish the same state again + messenger.publish('TokensController:stateChange', tokensState, [ + { op: 'replace', path: [], value: tokensState }, + ]); + + await flushPromises(); + + // Call count should not increase for unchanged tokens + expect(multicallSpy.mock.calls).toHaveLength(callCount); + + multicallSpy.mockRestore(); + }); + + it('should skip undefined account balances during state normalization (line 477)', () => { + const account = '0x1234567890123456789012345678901234567890'; + + // Create initial state with an undefined account balance entry + const initialState: TokenBalancesControllerState = { + tokenBalances: { + [account as ChecksumAddress]: undefined, + } as unknown as TokenBalances, + }; + + // This should not throw - the normalization should skip undefined entries + const { controller } = setupController({ + config: { state: initialState }, + }); + + // State should be normalized (undefined entry should be skipped) + expect(controller.state.tokenBalances).toBeDefined(); + }); + + it('should return early from poll function when controller is inactive (line 588)', async () => { + jest.useFakeTimers({ doNotFake: ['nextTick', 'queueMicrotask'] }); + + const { controller, messenger } = setupController(); + + const multicallSpy = jest + .spyOn(multicall, 'getTokenBalancesForMultipleAddresses') + .mockResolvedValue({ tokenBalances: {} }); + + // Start polling (this sets up the poll function) + controller.startPolling({ chainIds: ['0x1'] }); + + // Wait for immediate poll + 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 jestAdvanceTime({ duration: 35000 }); + + // The poll function should have returned early without calling multicall + expect(multicallSpy.mock.calls).toHaveLength(initialCallCount); + + controller.stopAllPolling(); + multicallSpy.mockRestore(); + jest.useRealTimers(); + }); + + it('should log warning when poll execution fails (line 603)', async () => { + const consoleWarnSpy = jest + .spyOn(console, 'warn') + .mockImplementation(() => { + // Suppress console output + }); + + // Mock _executePoll to throw an error + const { controller } = setupController(); + + jest + .spyOn(controller, '_executePoll') + .mockRejectedValue(new Error('Poll execution failed')); + + // Start polling - the poll function catches errors and logs them + controller.startPolling({ chainIds: ['0x1'] }); + + await waitFor(() => { + // Verify warning was logged (either immediate or interval polling message) + expect(consoleWarnSpy).toHaveBeenCalledWith( + expect.stringContaining('Polling failed'), + expect.any(Error), + ); + }); + + controller.stopAllPolling(); + consoleWarnSpy.mockRestore(); + }); + + it('should handle fetcher returning unprocessedChainIds (lines 851-867)', async () => { + const chainId = '0x1'; + const accountAddress = '0x0000000000000000000000000000000000000000'; + const token1 = '0x1111111111111111111111111111111111111111'; + + const tokens = { + allDetectedTokens: {}, + allTokens: { + [chainId]: { + [accountAddress]: [ + { address: token1, symbol: 'TK1', decimals: 18 }, + ], + }, + }, + }; + + const { controller, tokenBalancesControllerMessenger } = setupController({ + tokens, + }); + + // Spy on messenger.call to verify detectTokens is called + const messengerCallSpy = jest.spyOn( + tokenBalancesControllerMessenger, + 'call', + ); + + // Mock RpcBalanceFetcher to return unprocessedChainIds + jest.spyOn(RpcBalanceFetcher.prototype, 'fetch').mockResolvedValue({ + balances: [ + { + success: true, + value: new BN(100), + account: accountAddress as ChecksumAddress, + token: token1 as Hex, + chainId: chainId as ChainIdHex, + }, + ], + unprocessedChainIds: ['0x89' as ChainIdHex], + }); + + await controller.updateBalances({ + chainIds: [chainId], + queryAllAccounts: true, + }); + await waitFor(() => { + // Verify detectTokens was called with forceRpc for unprocessed chains + expect(messengerCallSpy).toHaveBeenCalledWith( + 'TokenDetectionController:detectTokens', + { + chainIds: ['0x89'], + forceRpc: true, + }, + ); + }); + + messengerCallSpy.mockRestore(); + }); + + it('should forward unprocessed token fallbacks from API fetcher to RPC fetcher', async () => { + const chainId = '0x1' as ChainIdHex; + const accountAddress = '0x0000000000000000000000000000000000000000'; + const token1 = '0x1111111111111111111111111111111111111111'; + + const tokens = { + allDetectedTokens: {}, + allTokens: { + [chainId]: { + [accountAddress]: [ + { address: token1, symbol: 'TK1', decimals: 18 }, + ], + }, + }, + }; + + const selectedAccount = createMockInternalAccount({ + address: accountAddress, + }); + + const apiFetchSpy = jest + .spyOn(AccountsApiBalanceFetcher.prototype, 'fetch') + .mockResolvedValue({ + balances: [ + { + success: true, + value: new BN(1), + account: accountAddress, + token: NATIVE_TOKEN_ADDRESS as Hex, + chainId, + }, + ], + unprocessedTokens: { + [accountAddress]: { + [chainId]: [token1], + }, + }, + }); + + const { controller } = setupController({ + tokens, + listAccounts: [selectedAccount], + config: { + accountsApiChainIds: () => [chainId], + }, + }); + + const rpcFetchSpy = jest + .spyOn(RpcBalanceFetcher.prototype, 'fetch') + .mockResolvedValue({ + balances: [ + { + success: true, + value: new BN(200), + account: accountAddress as ChecksumAddress, + token: token1 as Hex, + chainId, + }, + ], + }); + + await controller.updateBalances({ + chainIds: [chainId], + queryAllAccounts: true, + }); + await waitFor(() => { + expect(apiFetchSpy).toHaveBeenCalled(); + expect(rpcFetchSpy).toHaveBeenCalledWith( + expect.objectContaining({ + chainIds: [chainId], + unprocessedTokens: { + [accountAddress]: { + [chainId]: [token1], + }, + }, + }), + ); + }); + + expect( + controller.state.tokenBalances[accountAddress as ChecksumAddress]?.[ + chainId + ]?.[toChecksumHexAddress(token1) as ChecksumAddress], + ).toStrictEqual(toHex(200)); + + apiFetchSpy.mockRestore(); + rpcFetchSpy.mockRestore(); + }); + + it('should handle fetcher throwing error (lines 868-880)', async () => { + const chainId = '0x1'; + const accountAddress = '0x0000000000000000000000000000000000000000'; + const token1 = '0x1111111111111111111111111111111111111111'; + + const tokens = { + allDetectedTokens: {}, + allTokens: { + [chainId]: { + [accountAddress]: [ + { address: token1, symbol: 'TK1', decimals: 18 }, + ], + }, + }, + }; + + const { controller, tokenBalancesControllerMessenger } = setupController({ + tokens, + }); + + // Spy on messenger.call to verify detectTokens is called + const messengerCallSpy = jest.spyOn( + tokenBalancesControllerMessenger, + 'call', + ); + + const consoleWarnSpy = jest + .spyOn(console, 'warn') + .mockImplementation(() => { + // Suppress console output + }); + + // Mock RpcBalanceFetcher to throw an error + jest + .spyOn(RpcBalanceFetcher.prototype, 'fetch') + .mockRejectedValue(new Error('Fetcher error')); + + await controller.updateBalances({ + chainIds: [chainId], + queryAllAccounts: true, + }); + await waitFor(() => { + // Verify warning was logged + expect(consoleWarnSpy).toHaveBeenCalledWith( + expect.stringContaining('Balance fetcher failed'), + ); + }); + + // Verify detectTokens was called with forceRpc when fetcher fails + expect(messengerCallSpy).toHaveBeenCalledWith( + 'TokenDetectionController:detectTokens', + { + chainIds: [chainId], + forceRpc: true, + }, + ); + + messengerCallSpy.mockRestore(); + consoleWarnSpy.mockRestore(); + }); + + it('should skip balances with success=false (line 963)', async () => { + const chainId = '0x1'; + const accountAddress = '0x0000000000000000000000000000000000000000'; + const token1 = '0x1111111111111111111111111111111111111111'; + const token2 = '0x2222222222222222222222222222222222222222'; + + const tokens = { + allDetectedTokens: {}, + allTokens: { + [chainId]: { + [accountAddress]: [ + { address: token1, symbol: 'TK1', decimals: 18 }, + { address: token2, symbol: 'TK2', decimals: 18 }, + ], + }, + }, + }; + + const { controller } = setupController({ tokens }); + + // Mock RpcBalanceFetcher to return mixed success/failure + jest.spyOn(RpcBalanceFetcher.prototype, 'fetch').mockResolvedValue({ + balances: [ + { + success: true, + value: new BN(100), + account: accountAddress as ChecksumAddress, + token: token1 as Hex, + chainId: chainId as ChainIdHex, + }, + { + success: false, // Should be skipped + value: new BN(200), + account: accountAddress as ChecksumAddress, + token: token2 as Hex, + chainId: chainId as ChainIdHex, + }, + ], + unprocessedChainIds: [], + }); + + await controller.updateBalances({ + chainIds: [chainId], + queryAllAccounts: true, + }); + await waitFor(() => { + const balances = + controller.state.tokenBalances[accountAddress as ChecksumAddress]?.[ + chainId + ]; + const token1Checksum = toChecksumHexAddress(token1) as ChecksumAddress; + const token2Checksum = toChecksumHexAddress(token2) as ChecksumAddress; + + // token1 should be present with balance (success=true) + expect(balances?.[token1Checksum]).toBe(toHex(100)); + // token2 should NOT be present (success=false) + expect(balances?.[token2Checksum]).toBeUndefined(); + }); + }); + + it('should skip balances with undefined value (line 963)', async () => { + const chainId = '0x1'; + const accountAddress = '0x0000000000000000000000000000000000000000'; + const token1 = '0x1111111111111111111111111111111111111111'; + const token2 = '0x2222222222222222222222222222222222222222'; + + const tokens = { + allDetectedTokens: {}, + allTokens: { + [chainId]: { + [accountAddress]: [ + { address: token1, symbol: 'TK1', decimals: 18 }, + { address: token2, symbol: 'TK2', decimals: 18 }, + ], + }, + }, + }; + + const { controller } = setupController({ tokens }); + + // Mock RpcBalanceFetcher to return one with undefined value + jest.spyOn(RpcBalanceFetcher.prototype, 'fetch').mockResolvedValue({ + balances: [ + { + success: true, + value: new BN(100), + account: accountAddress as ChecksumAddress, + token: token1 as Hex, + chainId: chainId as ChainIdHex, + }, + { + success: true, + value: undefined, // Should be skipped + account: accountAddress as ChecksumAddress, + token: token2 as Hex, + chainId: chainId as ChainIdHex, + }, + ], + unprocessedChainIds: [], + }); + + await controller.updateBalances({ + chainIds: [chainId], + queryAllAccounts: true, + }); + await waitFor(() => { + const balances = + controller.state.tokenBalances[accountAddress as ChecksumAddress]?.[ + chainId + ]; + const token1Checksum = toChecksumHexAddress(token1) as ChecksumAddress; + const token2Checksum = toChecksumHexAddress(token2) as ChecksumAddress; + + // token1 should be present with balance + expect(balances?.[token1Checksum]).toBe(toHex(100)); + // token2 should NOT be present (value=undefined) + expect(balances?.[token2Checksum]).toBeUndefined(); + }); }); }); }); diff --git a/packages/assets-controllers/src/TokenBalancesController.ts b/packages/assets-controllers/src/TokenBalancesController.ts index 5ce5d058015..d0afde3cc57 100644 --- a/packages/assets-controllers/src/TokenBalancesController.ts +++ b/packages/assets-controllers/src/TokenBalancesController.ts @@ -1,135 +1,1653 @@ -import type { BaseConfig, BaseState } from '@metamask/base-controller'; -import { BaseController } from '@metamask/base-controller'; -import { safelyExecute } from '@metamask/controller-utils'; -import type { PreferencesState } from '@metamask/preferences-controller'; -import { BN } from 'ethereumjs-util'; +import { Web3Provider } from '@ethersproject/providers'; +import type { + AccountsControllerGetSelectedAccountAction, + AccountsControllerListAccountsAction, + AccountsControllerSelectedEvmAccountChangeEvent, +} from '@metamask/accounts-controller'; +import type { + ControllerGetStateAction, + ControllerStateChangeEvent, + StateMetadata, +} from '@metamask/base-controller'; +import { + BNToHex, + isEqualCaseInsensitive, + isValidHexAddress, + safelyExecuteWithTimeout, + toChecksumHexAddress, + toHex, +} from '@metamask/controller-utils'; +import type { + BalanceUpdate, + AccountActivityServiceBalanceUpdatedEvent, + AccountActivityServiceStatusChangedEvent, +} from '@metamask/core-backend'; +import type { + KeyringControllerAccountRemovedEvent, + KeyringControllerGetStateAction, + KeyringControllerLockEvent, + KeyringControllerUnlockEvent, +} from '@metamask/keyring-controller'; +import type { InternalAccount } from '@metamask/keyring-internal-api'; +import type { Messenger } from '@metamask/messenger'; +import type { + NetworkControllerGetNetworkClientByIdAction, + NetworkControllerGetStateAction, + NetworkControllerStateChangeEvent, + NetworkState, +} from '@metamask/network-controller'; +import { StaticIntervalPollingController } from '@metamask/polling-controller'; +import type { + PreferencesControllerGetStateAction, + PreferencesControllerStateChangeEvent, +} from '@metamask/preferences-controller'; +import type { AuthenticationController } from '@metamask/profile-sync-controller'; +import type { TransactionControllerTransactionConfirmedEvent } from '@metamask/transaction-controller'; +import type { Hex } from '@metamask/utils'; +import { + isCaipAssetType, + isCaipChainId, + isStrictHexString, + parseCaipAssetType, + parseCaipChainId, +} from '@metamask/utils'; +import { produce } from 'immer'; +import { isEqual, union } from 'lodash'; -import type { AssetsContractController } from './AssetsContractController'; -import type { Token } from './TokenRatesController'; -import type { TokensState } from './TokensController'; +import type { + AccountTrackerControllerUpdateNativeBalancesAction, + AccountTrackerControllerUpdateStakedBalancesAction, +} from './AccountTrackerController-method-action-types.js'; +import type { AccountTrackerControllerGetStateAction } from './AccountTrackerController.js'; +import { STAKING_CONTRACT_ADDRESS_BY_CHAINID } from './AssetsContractController.js'; +import { + MUSD_ERC20_ADDRESS_LOWER, + MUSD_TOKEN_DETECTION_CHAIN_IDS, +} from './constants.js'; +import { AccountsApiBalanceFetcher } from './multi-chain-accounts-service/api-balance-fetcher.js'; +import type { + BalanceFetcher, + ProcessedBalance, + UnprocessedTokens, +} from './multi-chain-accounts-service/api-balance-fetcher.js'; +import { RpcBalanceFetcher } from './rpc-service/rpc-balance-fetcher.js'; +import type { TokenBalancesControllerMethodActions } from './TokenBalancesController-method-action-types.js'; +import type { + TokenDetectionControllerAddDetectedTokensViaPollingAction, + TokenDetectionControllerAddDetectedTokensViaWsAction, + TokenDetectionControllerDetectTokensAction, +} from './TokenDetectionController-method-action-types.js'; +import type { + TokensControllerGetStateAction, + TokensControllerState, + TokensControllerStateChangeEvent, +} from './TokensController.js'; +import { createBatchedHandler } from './utils/create-batch-handler.js'; -// TODO: Remove this export in the next major release -export { BN }; +const MUSD_IMPORT_CHAIN_ID_SET = new Set(MUSD_TOKEN_DETECTION_CHAIN_IDS); + +export type ChainIdHex = Hex; +export type ChecksumAddress = Hex; + +const CONTROLLER = 'TokenBalancesController' as const; +const DEFAULT_INTERVAL_MS = 30_000; // 30 seconds +const DEFAULT_WEBSOCKET_ACTIVE_POLLING_INTERVAL_MS = 300_000; // 5 minutes + +/** Debounce wait (ms) for coalescing rapid updateBalances calls before flush */ +export const UPDATE_BALANCES_BATCH_MS = 200; + +export type UpdateBalancesOptions = { + chainIds?: ChainIdHex[]; + tokenAddresses?: string[]; + queryAllAccounts?: boolean; +}; /** - * @type TokenBalancesConfig + * Merges two UpdateBalancesOptions per queue-and-merge rules: + * - chainIds: union of both lists when each option includes `chainIds`; if either omits `chainIds`, the merged field is undefined (all chains). + * - tokenAddresses: union of both lists when each option includes `tokenAddresses`; if either omits `tokenAddresses`, the merged field is undefined (all tokens). + * - queryAllAccounts: true if either is true. + * Exported for tests. * - * Token balances controller configuration - * @property interval - Polling interval used to fetch new token balances - * @property tokens - List of tokens to track balances for + * @param a - First options (e.g. accumulated). + * @param b - Second options to merge in. + * @returns New merged options. */ -export interface TokenBalancesConfig extends BaseConfig { - interval: number; - tokens: Token[]; +export function mergeUpdateBalancesOptions( + a: UpdateBalancesOptions, + b: UpdateBalancesOptions, +): UpdateBalancesOptions { + const chainIds = a.chainIds && b.chainIds && union(a.chainIds, b.chainIds); + const tokenAddresses = + a.tokenAddresses && + b.tokenAddresses && + union(a.tokenAddresses, b.tokenAddresses); + const queryAllAccounts = + Boolean(a.queryAllAccounts) || Boolean(b.queryAllAccounts); + return { chainIds, tokenAddresses, queryAllAccounts }; } +const metadata: StateMetadata = { + tokenBalances: { + includeInStateLogs: false, + persist: true, + includeInDebugSnapshot: false, + usedInUi: true, + }, +}; + +// account → chain → token → balance +export type TokenBalances = Record< + ChecksumAddress, + Record> +>; + +export type TokenBalancesControllerState = { + tokenBalances: TokenBalances; +}; + +export type TokenBalancesControllerGetStateAction = ControllerGetStateAction< + typeof CONTROLLER, + TokenBalancesControllerState +>; + +export type TokenBalancesControllerActions = + | TokenBalancesControllerGetStateAction + | TokenBalancesControllerMethodActions; + +export type TokenBalancesControllerStateChangeEvent = + ControllerStateChangeEvent; + +export type NativeBalanceEvent = { + type: `${typeof CONTROLLER}:updatedNativeBalance`; + payload: unknown[]; +}; + +export type TokenBalancesControllerEvents = + | TokenBalancesControllerStateChangeEvent + | NativeBalanceEvent; + +export type AllowedActions = + | NetworkControllerGetNetworkClientByIdAction + | NetworkControllerGetStateAction + | TokensControllerGetStateAction + | TokenDetectionControllerAddDetectedTokensViaPollingAction + | TokenDetectionControllerAddDetectedTokensViaWsAction + | TokenDetectionControllerDetectTokensAction + | PreferencesControllerGetStateAction + | AccountsControllerGetSelectedAccountAction + | AccountsControllerListAccountsAction + | AccountTrackerControllerGetStateAction + | AccountTrackerControllerUpdateNativeBalancesAction + | AccountTrackerControllerUpdateStakedBalancesAction + | KeyringControllerGetStateAction + | AuthenticationController.AuthenticationControllerGetBearerTokenAction; + +export type AllowedEvents = + | TokensControllerStateChangeEvent + | PreferencesControllerStateChangeEvent + | NetworkControllerStateChangeEvent + | KeyringControllerAccountRemovedEvent + | KeyringControllerLockEvent + | KeyringControllerUnlockEvent + | AccountActivityServiceBalanceUpdatedEvent + | AccountActivityServiceStatusChangedEvent + | AccountsControllerSelectedEvmAccountChangeEvent + | TransactionControllerTransactionConfirmedEvent; + +export type TokenBalancesControllerMessenger = Messenger< + typeof CONTROLLER, + TokenBalancesControllerActions | AllowedActions, + TokenBalancesControllerEvents | AllowedEvents +>; + +export type ChainPollingConfig = { + /** Polling interval in milliseconds for this chain */ + interval: number; +}; + +export type UpdateChainPollingConfigsOptions = { + /** Whether to immediately fetch balances after updating configs (default: true) */ + immediateUpdate?: boolean; +}; + +export type TokenBalancesControllerOptions = { + messenger: TokenBalancesControllerMessenger; + /** Default interval for chains not specified in chainPollingIntervals */ + interval?: number; + /** Per-chain polling configuration */ + chainPollingIntervals?: Record; + state?: Partial; + /** When `true`, balances for *all* known accounts are queried. */ + queryMultipleAccounts?: boolean; + /** Array of chainIds that should use Accounts-API strategy (if supported by API). */ + accountsApiChainIds?: () => ChainIdHex[]; + /** Disable external HTTP calls (privacy / offline mode). */ + allowExternalServices?: () => boolean; + /** Custom logger. */ + log?: (...args: unknown[]) => void; + platform?: 'extension' | 'mobile'; + /** Polling interval when WebSocket is active and providing real-time updates */ + websocketActivePollingInterval?: number; + /** Whether the user has completed onboarding. If false, balance updates are skipped. */ + isOnboarded?: () => boolean; + /** + * Optional function that returns true to completely disable this controller + * (no requests, no state updates). When it returns `true`, `tokenBalances` is + * reset to `{}` at construction and at every entry point, so no stale balances + * remain in state. The function is evaluated dynamically on each entry point so + * it can be toggled at runtime. Intended for use when a higher-level controller + * (e.g. AssetsController) supersedes this one. + */ + isDeprecated?: () => boolean; +}; + +const draft = (base: State, fn: (draftState: State) => void): State => + produce(base, fn); + +const ZERO_ADDRESS = + '0x0000000000000000000000000000000000000000' as ChecksumAddress; + +const checksum = (addr: string): ChecksumAddress => + toChecksumHexAddress(addr) as ChecksumAddress; /** - * @type TokenBalancesState + * Convert CAIP chain ID or hex chain ID to hex chain ID. * - * Token balances controller state - * @property contractBalances - Hash of token contract addresses to balances + * @param chainId - CAIP chain ID or hex chain ID. + * @returns Hex chain ID. */ -export interface TokenBalancesState extends BaseState { - contractBalances: { [address: string]: BN }; -} +export const caipChainIdToHex = (chainId: string): ChainIdHex => { + if (isStrictHexString(chainId)) { + return chainId; + } + + if (isCaipChainId(chainId)) { + return toHex(parseCaipChainId(chainId).reference); + } + + throw new Error('caipChainIdToHex - Failed to provide CAIP-2 or Hex chainId'); +}; /** - * Controller that passively polls on a set interval token balances - * for tokens stored in the TokensController + * Extract token address from asset type. + * + * @param assetType - Asset type string. + * @returns Tuple of [tokenAddress, isNativeToken] or null if invalid. */ -export class TokenBalancesController extends BaseController< - TokenBalancesConfig, - TokenBalancesState +export const parseAssetType = (assetType: string): [string, boolean] | null => { + if (!isCaipAssetType(assetType)) { + return null; + } + + const parsed = parseCaipAssetType(assetType); + + if (parsed.assetNamespace === 'erc20') { + return [parsed.assetReference, false]; + } + + if (parsed.assetNamespace === 'slip44') { + return [ZERO_ADDRESS, true]; + } + + return null; +}; + +type NativeBalanceUpdate = { address: string; chainId: Hex; balance: Hex }; +type StakedBalanceUpdate = { + address: string; + chainId: Hex; + stakedBalance: Hex; +}; +const MESSENGER_EXPOSED_METHODS = [ + 'updateChainPollingConfigs', + 'getChainPollingConfig', + 'updateBalances', + 'resetState', +] as const; + +export class TokenBalancesController extends StaticIntervalPollingController<{ + chainIds: ChainIdHex[]; +}>()< + typeof CONTROLLER, + TokenBalancesControllerState, + TokenBalancesControllerMessenger > { - private handle?: ReturnType; + readonly #platform: 'extension' | 'mobile'; - /** - * Name of this controller used during composition - */ - override name = 'TokenBalancesController'; + readonly #queryAllAccounts: boolean; + + readonly #accountsApiChainIds: () => ChainIdHex[]; + + readonly #allowExternalServices: () => boolean; + + readonly #isOnboarded: () => boolean; + + readonly #isDeprecated: () => boolean; + + readonly #balanceFetchers: { fetcher: BalanceFetcher; name: string }[]; + + #allTokens: TokensControllerState['allTokens'] = {}; + + #detectedTokens: TokensControllerState['allDetectedTokens'] = {}; - private readonly getSelectedAddress: () => PreferencesState['selectedAddress']; + #allIgnoredTokens: TokensControllerState['allIgnoredTokens'] = {}; - private readonly getERC20BalanceOf: AssetsContractController['getERC20BalanceOf']; + /** Default polling interval for chains without specific configuration */ + readonly #defaultInterval: number; + + /** Polling interval when WebSocket is active and providing real-time updates */ + readonly #websocketActivePollingInterval: number; + + /** Per-chain polling configuration */ + readonly #chainPollingConfig: Record; + + /** Active polling timers grouped by interval */ + readonly #intervalPollingTimers: Map = new Map(); + + /** Track if controller-level polling is active */ + #isControllerPollingActive = false; + + /** Track if the keyring is unlocked */ + #isUnlocked = false; + + /** Store original chainIds from startPolling to preserve intent */ + #requestedChainIds: ChainIdHex[] = []; + + /** Debouncing for rapid status changes to prevent excessive HTTP calls */ + readonly #statusChangeDebouncer: { + timer: NodeJS.Timeout | null; + pendingChanges: Map; + } = { + timer: null, + pendingChanges: new Map(), + }; + + readonly #batchedUpdateBalances: ( + options: UpdateBalancesOptions, + ) => Promise; + + constructor({ + messenger, + interval = DEFAULT_INTERVAL_MS, + websocketActivePollingInterval = DEFAULT_WEBSOCKET_ACTIVE_POLLING_INTERVAL_MS, + chainPollingIntervals = {}, + state = {}, + queryMultipleAccounts = true, + accountsApiChainIds = (): ChainIdHex[] => [], + allowExternalServices = (): boolean => true, + platform, + isOnboarded = (): boolean => true, + isDeprecated = (): boolean => false, + }: TokenBalancesControllerOptions) { + super({ + name: CONTROLLER, + messenger, + metadata, + state: { tokenBalances: {}, ...state }, + }); + + this.#normalizeAccountAddresses(); + + this.#platform = platform ?? 'extension'; + this.#queryAllAccounts = queryMultipleAccounts; + this.#accountsApiChainIds = accountsApiChainIds; + this.#allowExternalServices = allowExternalServices; + this.#isOnboarded = isOnboarded; + this.#isDeprecated = isDeprecated; + this.#defaultInterval = interval; + this.#websocketActivePollingInterval = websocketActivePollingInterval; + this.#chainPollingConfig = { ...chainPollingIntervals }; + + // Always include AccountsApiFetcher - it dynamically checks allowExternalServices() in supports() + this.#balanceFetchers = [ + { + fetcher: this.#createAccountsApiFetcher(), + name: 'AccountsApiFetcher', + }, + { + fetcher: new RpcBalanceFetcher( + this.#getProvider, + this.#getNetworkClient, + () => ({ + allTokens: this.#allTokens, + allDetectedTokens: this.#detectedTokens, + }), + ), + name: 'RpcFetcher', + }, + ]; + + this.setIntervalLength(interval); + + const { allTokens, allDetectedTokens, allIgnoredTokens } = + this.messenger.call('TokensController:getState'); + this.#allTokens = allTokens; + this.#detectedTokens = allDetectedTokens; + this.#allIgnoredTokens = allIgnoredTokens; + + const { isUnlocked } = this.messenger.call('KeyringController:getState'); + this.#isUnlocked = isUnlocked; + + this.#batchedUpdateBalances = createBatchedHandler( + (buffer) => + buffer.length === 0 + ? {} + : buffer + .slice(1) + .reduce( + (acc, opts) => mergeUpdateBalancesOptions(acc, opts), + buffer[0], + ), + UPDATE_BALANCES_BATCH_MS, + (merged: UpdateBalancesOptions): Promise => + this.#executeUpdateBalances(merged), + ); + + if (this.#isDeprecated()) { + this.#enforceDisabledState(); + } + + this.#subscribeToControllers(); + messenger.registerMethodActionHandlers(this, MESSENGER_EXPOSED_METHODS); + } /** - * Creates a TokenBalancesController instance. + * Clears all persisted `tokenBalances` so that no stale balances remain in + * state. * - * @param options - The controller options. - * @param options.onTokensStateChange - Allows subscribing to assets controller state changes. - * @param options.getSelectedAddress - Gets the current selected address. - * @param options.getERC20BalanceOf - Gets the balance of the given account at the given contract address. - * @param config - Initial options used to configure this controller. - * @param state - Initial state to set on this controller. + * Called from every entry point when `isDeprecated()` is true so that a runtime + * toggle propagates to state immediately, even if the controller was originally + * constructed while it was enabled. The update is skipped when `tokenBalances` + * is already empty to avoid emitting redundant state changes. */ - constructor( - { - onTokensStateChange, - getSelectedAddress, - getERC20BalanceOf, - }: { - onTokensStateChange: ( - listener: (tokenState: TokensState) => void, - ) => void; - getSelectedAddress: () => PreferencesState['selectedAddress']; - getERC20BalanceOf: AssetsContractController['getERC20BalanceOf']; - }, - config?: Partial, - state?: Partial, - ) { - super(config, state); - this.defaultConfig = { - interval: 180000, - tokens: [], - }; - this.defaultState = { contractBalances: {} }; - this.initialize(); - onTokensStateChange(({ tokens, detectedTokens }) => { - this.configure({ tokens: [...tokens, ...detectedTokens] }); - this.updateBalances(); + #enforceDisabledState(): void { + if (Object.keys(this.state.tokenBalances).length === 0) { + return; + } + this.update(() => ({ tokenBalances: {} })); + } + + #subscribeToControllers(): void { + this.messenger.subscribe( + 'TokensController:stateChange', + (tokensState: TokensControllerState) => { + this.#onTokensChanged(tokensState).catch((error) => { + console.warn('Error handling token state change:', error); + }); + }, + ); + + this.messenger.subscribe( + 'NetworkController:stateChange', + this.#onNetworkChanged, + ); + + this.messenger.subscribe('KeyringController:unlock', () => { + this.#isUnlocked = true; + }); + + this.messenger.subscribe('KeyringController:lock', () => { + this.#isUnlocked = false; }); - this.getSelectedAddress = getSelectedAddress; - this.getERC20BalanceOf = getERC20BalanceOf; - this.poll(); + + this.messenger.subscribe( + 'KeyringController:accountRemoved', + this.#onAccountRemoved, + ); + + this.messenger.subscribe( + 'AccountsController:selectedEvmAccountChange', + this.#onAccountChanged, + ); + + this.messenger.subscribe( + 'AccountActivityService:balanceUpdated', + (event) => { + this.#onAccountActivityBalanceUpdate(event).catch((error) => { + console.warn('Error handling balance update:', error); + }); + }, + ); + + this.messenger.subscribe( + 'AccountActivityService:statusChanged', + this.#onAccountActivityStatusChanged.bind(this), + ); + + this.messenger.subscribe( + 'TransactionController:transactionConfirmed', + (transactionMeta) => { + this.updateBalances({ + chainIds: [transactionMeta.chainId], + }).catch(() => { + // Silently handle balance update errors + }); + }, + ); } /** - * Starts a new polling interval. + * Whether the controller is active (keyring is unlocked and user is onboarded). + * When locked or not onboarded, balance updates should be skipped. * - * @param interval - Polling interval used to fetch new token balances. + * @returns Whether the controller should perform balance updates. */ - async poll(interval?: number): Promise { - interval && this.configure({ interval }, false, false); - this.handle && clearTimeout(this.handle); - await safelyExecute(() => this.updateBalances()); - this.handle = setTimeout(() => { - this.poll(this.config.interval); - }, this.config.interval); + get isActive(): boolean { + return this.#isUnlocked && this.#isOnboarded(); } /** - * Updates balances for all tokens. + * Normalize all account addresses to lowercase and merge duplicates + * Handles migration from old state where addresses might be checksummed. */ - async updateBalances() { - if (this.disabled) { - return; + #normalizeAccountAddresses(): void { + const currentState = this.state.tokenBalances; + const normalizedBalances: TokenBalances = {}; + + for (const address of Object.keys(currentState)) { + const lowercaseAddress = address.toLowerCase() as ChecksumAddress; + const accountBalances = currentState[address as ChecksumAddress]; + + if (!accountBalances) { + continue; + } + + normalizedBalances[lowercaseAddress] ??= {}; + + for (const chainId of Object.keys(accountBalances)) { + const chainIdKey = chainId as ChainIdHex; + normalizedBalances[lowercaseAddress][chainIdKey] ??= {}; + + Object.assign( + normalizedBalances[lowercaseAddress][chainIdKey], + accountBalances[chainIdKey], + ); + } + } + + if ( + Object.keys(currentState).length !== + Object.keys(normalizedBalances).length || + Object.keys(currentState).some((addr) => addr !== addr.toLowerCase()) + ) { + this.update(() => ({ tokenBalances: normalizedBalances })); } - const { tokens } = this.config; - const newContractBalances: { [address: string]: BN } = {}; - for (const i in tokens) { - const { address } = tokens[i]; + } + + #chainIdsWithTokens(): ChainIdHex[] { + return [ + ...new Set([ + ...Object.keys(this.#allTokens), + ...Object.keys(this.#detectedTokens), + ]), + ] as ChainIdHex[]; + } + + readonly #getProvider = (chainId: ChainIdHex): Web3Provider => { + const { networkConfigurationsByChainId } = this.messenger.call( + 'NetworkController:getState', + ); + const networkConfig = networkConfigurationsByChainId[chainId]; + const { networkClientId } = + networkConfig.rpcEndpoints[networkConfig.defaultRpcEndpointIndex]; + const client = this.messenger.call( + 'NetworkController:getNetworkClientById', + networkClientId, + ); + return new Web3Provider(client.provider); + }; + + readonly #getNetworkClient = ( + chainId: ChainIdHex, + ): ReturnType => { + const { networkConfigurationsByChainId } = this.messenger.call( + 'NetworkController:getState', + ); + const networkConfig = networkConfigurationsByChainId[chainId]; + const { networkClientId } = + networkConfig.rpcEndpoints[networkConfig.defaultRpcEndpointIndex]; + return this.messenger.call( + 'NetworkController:getNetworkClientById', + networkClientId, + ); + }; + + readonly #createAccountsApiFetcher = (): BalanceFetcher => { + const originalFetcher = new AccountsApiBalanceFetcher( + this.#platform, + this.#getProvider, + () => this.state.tokenBalances, // list of existing user tokens + ); + + return { + // Dynamically check allowExternalServices() at call time, not just at construction time + supports: (chainId: ChainIdHex): boolean => + this.#allowExternalServices() && + this.#accountsApiChainIds().includes(chainId) && + originalFetcher.supports(chainId), + fetch: originalFetcher.fetch.bind(originalFetcher), + }; + }; + + override _startPolling({ chainIds }: { chainIds: ChainIdHex[] }): void { + this.#requestedChainIds = [...chainIds]; + this.#isControllerPollingActive = true; + this.#startIntervalGroupPolling(chainIds, true); + } + + #startIntervalGroupPolling(chainIds: ChainIdHex[], immediate = true): void { + this.#intervalPollingTimers.forEach((timer) => clearInterval(timer)); + this.#intervalPollingTimers.clear(); + + const intervalGroups = new Map(); + + for (const chainId of chainIds) { + const config = this.getChainPollingConfig(chainId); + const group = intervalGroups.get(config.interval) ?? []; + group.push(chainId); + intervalGroups.set(config.interval, group); + } + + for (const [interval, chainIdsGroup] of intervalGroups) { + this.#startPollingForInterval(interval, chainIdsGroup, immediate); + } + } + + #startPollingForInterval( + interval: number, + chainIds: ChainIdHex[], + immediate = true, + ): void { + const pollFunction = async (): Promise => { + if (!this.#isControllerPollingActive) { + return; + } + try { - newContractBalances[address] = await this.getERC20BalanceOf( - address, - this.getSelectedAddress(), + await this._executePoll({ chainIds }); + } catch (error) { + console.warn( + `Polling failed for chains ${chainIds.join(', ')} with interval ${interval}:`, + error, + ); + } + }; + + if (immediate) { + pollFunction().catch((error) => { + console.warn( + `Immediate polling failed for chains ${chainIds.join(', ')}:`, + error, + ); + }); + } + + this.#setPollingTimer(interval, chainIds, pollFunction); + } + + #setPollingTimer( + interval: number, + chainIds: ChainIdHex[], + pollFunction: () => Promise, + ): void { + const timer = setInterval(() => { + pollFunction().catch((error) => { + console.warn( + `Interval polling failed for chains ${chainIds.join(', ')}:`, + error, ); - tokens[i].balanceError = null; + }); + }, interval); + + this.#intervalPollingTimers.set(interval, timer); + } + + override _stopPollingByPollingTokenSetId(tokenSetId: string): void { + let chainsToStop: ChainIdHex[] = []; + + try { + const parsedTokenSetId = JSON.parse(tokenSetId); + chainsToStop = parsedTokenSetId.chainIds ?? []; + } catch (error) { + console.warn('Failed to parse tokenSetId, stopping all polling:', error); + this.#stopAllPolling(); + return; + } + + const currentChainsSet = new Set(this.#requestedChainIds); + const stopChainsSet = new Set(chainsToStop); + + const isCurrentSession = + currentChainsSet.size === stopChainsSet.size && + [...currentChainsSet].every((chain) => stopChainsSet.has(chain)); + + if (isCurrentSession) { + this.#stopAllPolling(); + } + } + + #stopAllPolling(): void { + this.#isControllerPollingActive = false; + this.#requestedChainIds = []; + this.#intervalPollingTimers.forEach((timer) => clearInterval(timer)); + this.#intervalPollingTimers.clear(); + } + + getChainPollingConfig(chainId: ChainIdHex): ChainPollingConfig { + return ( + this.#chainPollingConfig[chainId] ?? { + interval: this.#defaultInterval, + } + ); + } + + override async _executePoll({ + chainIds, + queryAllAccounts = false, + }: { + chainIds: ChainIdHex[]; + queryAllAccounts?: boolean; + }): Promise { + if (this.#isDeprecated()) { + this.#enforceDisabledState(); + return; + } + await this.#executeUpdateBalances({ chainIds, queryAllAccounts }); + } + + updateChainPollingConfigs( + configs: Record, + options: UpdateChainPollingConfigsOptions = { immediateUpdate: true }, + ): void { + Object.assign(this.#chainPollingConfig, configs); + + if (this.#isControllerPollingActive) { + this.#startIntervalGroupPolling( + this.#requestedChainIds, + options.immediateUpdate, + ); + } + } + + async updateBalances(options: UpdateBalancesOptions = {}): Promise { + if (this.#isDeprecated()) { + this.#enforceDisabledState(); + return; + } + if (!this.isActive) { + return; + } + await this.#batchedUpdateBalances(options); + } + + async #executeUpdateBalances({ + chainIds, + tokenAddresses, + queryAllAccounts = false, + }: UpdateBalancesOptions = {}): Promise { + if (this.#isDeprecated()) { + this.#enforceDisabledState(); + return; + } + if (!this.isActive) { + return; + } + + const targetChains = this.#getTargetChains(chainIds); + if (!targetChains.length) { + return; + } + + const { selectedAccount, allAccounts, jwtToken } = + await this.#getAccountsAndJwt(); + + const aggregatedBalances = await this.#fetchAllBalances({ + targetChains, + selectedAccount, + allAccounts, + jwtToken, + queryAllAccounts: queryAllAccounts ?? this.#queryAllAccounts, + }); + + const filteredAggregated = this.#filterByTokenAddresses( + aggregatedBalances, + tokenAddresses, + ); + + const accountsToProcess = this.#getAccountsToProcess( + queryAllAccounts, + allAccounts, + selectedAccount, + ); + + const prev = this.state; + const next = this.#applyTokenBalancesToState({ + prev, + targetChains, + accountsToProcess, + balances: filteredAggregated, + }); + + if (!isEqual(prev, next)) { + this.update(() => next); + + const accountTrackerState = this.messenger.call( + 'AccountTrackerController:getState', + ); + + const nativeUpdates = this.#buildNativeBalanceUpdates( + filteredAggregated, + accountTrackerState, + ); + + if (nativeUpdates.length > 0) { + this.messenger.call( + 'AccountTrackerController:updateNativeBalances', + nativeUpdates, + ); + } + + const stakedUpdates = this.#buildStakedBalanceUpdates( + filteredAggregated, + accountTrackerState, + ); + + if (stakedUpdates.length > 0) { + this.messenger.call( + 'AccountTrackerController:updateStakedBalances', + stakedUpdates, + ); + } + } + + await this.#importUntrackedTokens(filteredAggregated, targetChains); + } + + #getTargetChains(chainIds?: ChainIdHex[]): ChainIdHex[] { + return chainIds?.length ? chainIds : this.#chainIdsWithTokens(); + } + + async #getAccountsAndJwt(): Promise<{ + selectedAccount: ChecksumAddress; + allAccounts: InternalAccount[]; + jwtToken: string | undefined; + }> { + const { address: selected } = this.messenger.call( + 'AccountsController:getSelectedAccount', + ); + const allAccounts = this.messenger.call('AccountsController:listAccounts'); + + const jwtToken = await safelyExecuteWithTimeout( + () => { + return this.messenger.call('AuthenticationController:getBearerToken'); + }, + false, + 5000, + ); + + return { + selectedAccount: selected as ChecksumAddress, + allAccounts, + jwtToken, + }; + } + + async #fetchAllBalances({ + targetChains, + selectedAccount, + allAccounts, + jwtToken, + queryAllAccounts, + }: { + targetChains: ChainIdHex[]; + selectedAccount: ChecksumAddress; + allAccounts: InternalAccount[]; + jwtToken?: string; + queryAllAccounts: boolean; + }): Promise { + const aggregated: ProcessedBalance[] = []; + let remainingChains = [...targetChains]; + let previousUnprocessedTokens: UnprocessedTokens | undefined; + let previousFetcherName: string | undefined; + + for (const { fetcher, name: fetcherName } of this.#balanceFetchers) { + const supportedChains = remainingChains.filter((chain) => + fetcher.supports(chain), + ); + if (!supportedChains.length) { + continue; + } + + try { + const result = await fetcher.fetch({ + chainIds: supportedChains, + queryAllAccounts, + selectedAccount, + allAccounts, + jwtToken, + unprocessedTokens: previousUnprocessedTokens, + }); + + // Add balances, and removed processed chains + if (result.balances?.length) { + aggregated.push(...result.balances); + + const processed = new Set(result.balances.map((b) => b.chainId)); + remainingChains = remainingChains.filter( + (chain) => !processed.has(chain), + ); + } + + // Add unprocessed chains (from missing chains or missing tokens) + if (result.unprocessedChainIds || result.unprocessedTokens) { + const resultUnprocessedChains = result.unprocessedChainIds ?? []; + const resultUnsupportedTokenChains = Object.entries( + result.unprocessedTokens ?? {}, + ).flatMap(([_account, chainMap]) => Object.keys(chainMap)) as Hex[]; + const unprocessedChainIds = Array.from( + new Set([ + ...resultUnprocessedChains, + ...resultUnsupportedTokenChains, + ]), + ); + + remainingChains = Array.from( + new Set([...remainingChains, ...unprocessedChainIds]), + ); + + this.messenger + .call('TokenDetectionController:detectTokens', { + chainIds: unprocessedChainIds, + forceRpc: true, + }) + .catch(() => { + // Silently handle token detection errors + }); + } + + // Balance Error Reporting - for unprocessed tokens from last fetcher, if balances are retrieved + const unprocessedTokensForReporting = previousUnprocessedTokens; + if (unprocessedTokensForReporting && result.balances?.length) { + const confirmedUnprocessedTokens: { + chainId: string; + tokenAddress: string; + }[] = []; + + // Capture balances that were found (> 0 balance), and was unprocessed + result.balances.forEach((bal) => { + const lowercaseAccount = bal.account.toLowerCase(); + const lowercaseTokenAddress = bal.token.toLowerCase(); + + const hasResultBalance = + bal.success && bal.token && bal.value && !bal.value.isZero(); + const isUnprocessed = unprocessedTokensForReporting?.[ + lowercaseAccount + ]?.[bal.chainId]?.includes(lowercaseTokenAddress); + + if (hasResultBalance && isUnprocessed) { + confirmedUnprocessedTokens.push({ + chainId: bal.chainId, + tokenAddress: lowercaseTokenAddress, + }); + } + }); + + const confirmedUnprocessedTokenStrings = + confirmedUnprocessedTokens.map( + (token) => `${token.chainId}:${token.tokenAddress}`, + ); + if (confirmedUnprocessedTokens.length) { + console.warn( + `TokenBalanceController: fetcher ${previousFetcherName} did not process tokens (instead handled by fetcher ${fetcherName}): ${confirmedUnprocessedTokenStrings.join(', ')}`, + ); + } + } + + // Set new previous fields + previousUnprocessedTokens = result.unprocessedTokens; + previousFetcherName = fetcherName; } catch (error) { - newContractBalances[address] = new BN(0); - tokens[i].balanceError = error; + console.warn( + `Balance fetcher failed for chains ${supportedChains.join(', ')}: ${String(error)}`, + ); + + this.messenger + .call('TokenDetectionController:detectTokens', { + chainIds: supportedChains, + forceRpc: true, + }) + .catch(() => { + // Silently handle token detection errors + }); + } + + if (!remainingChains.length) { + break; + } + } + + return aggregated; + } + + #filterByTokenAddresses( + balances: ProcessedBalance[], + tokenAddresses?: string[], + ): ProcessedBalance[] { + if (!tokenAddresses?.length) { + return balances; + } + + const lowered = tokenAddresses.map((a) => a.toLowerCase()); + return balances.filter((balance) => + lowered.includes(balance.token.toLowerCase()), + ); + } + + #getAccountsToProcess( + queryAllAccountsParam: boolean | undefined, + allAccounts: InternalAccount[], + selectedAccount: ChecksumAddress, + ): ChecksumAddress[] { + const effectiveQueryAll = + queryAllAccountsParam ?? this.#queryAllAccounts ?? false; + + if (!effectiveQueryAll) { + return [selectedAccount]; + } + + return allAccounts.map((account) => account.address as ChecksumAddress); + } + + #applyTokenBalancesToState({ + prev, + targetChains, + accountsToProcess, + balances, + }: { + prev: TokenBalancesControllerState; + targetChains: ChainIdHex[]; + accountsToProcess: ChecksumAddress[]; + balances: ProcessedBalance[]; + }): TokenBalancesControllerState { + return draft(prev, (draftState) => { + for (const chainId of targetChains) { + for (const account of accountsToProcess) { + draftState.tokenBalances[account] ??= {}; + draftState.tokenBalances[account][chainId] ??= {}; + + const chainTokens = this.#allTokens[chainId]; + if (chainTokens?.[account]) { + Object.values(chainTokens[account]).forEach( + (token: { address: string }) => { + const tokenAddress = checksum(token.address); + draftState.tokenBalances[account][chainId][tokenAddress] ??= + '0x0'; + }, + ); + } + + const detectedChainTokens = this.#detectedTokens[chainId]; + if (detectedChainTokens?.[account]) { + Object.values(detectedChainTokens[account]).forEach( + (token: { address: string }) => { + const tokenAddress = checksum(token.address); + draftState.tokenBalances[account][chainId][tokenAddress] ??= + '0x0'; + }, + ); + } + } } + + balances.forEach(({ success, value, account, token, chainId }) => { + if (!success || value === undefined) { + return; + } + + const lowerCaseAccount = account.toLowerCase() as ChecksumAddress; + const newBalance = toHex(value); + const tokenAddress = checksum(token); + + const currentBalance = + draftState.tokenBalances[lowerCaseAccount]?.[chainId]?.[tokenAddress]; + + if (currentBalance !== newBalance) { + ((draftState.tokenBalances[lowerCaseAccount] ??= {})[chainId] ??= {})[ + tokenAddress + ] = newBalance; + } + }); + }); + } + + #buildNativeBalanceUpdates( + balances: ProcessedBalance[], + accountTrackerState: { + accountsByChainId: Record< + string, + Record + >; + }, + ): NativeBalanceUpdate[] { + const nativeBalances = balances.filter( + (balance) => balance.success && balance.token === ZERO_ADDRESS, + ); + + if (!nativeBalances.length) { + return []; + } + + return nativeBalances + .map((balance) => ({ + address: balance.account, + chainId: balance.chainId, + balance: balance.value ? BNToHex(balance.value) : '0x0', + })) + .filter((update) => { + const currentBalance = + accountTrackerState.accountsByChainId[update.chainId]?.[ + checksum(update.address) + ]?.balance; + return currentBalance !== update.balance; + }); + } + + #buildStakedBalanceUpdates( + balances: ProcessedBalance[], + accountTrackerState: { + accountsByChainId: Record< + string, + Record + >; + }, + ): StakedBalanceUpdate[] { + const stakedBalances = balances.filter((balance) => { + if (!balance.success || balance.token === ZERO_ADDRESS) { + return false; + } + + const stakingContractAddress = + STAKING_CONTRACT_ADDRESS_BY_CHAINID[balance.chainId]; + return ( + stakingContractAddress?.toLowerCase() === balance.token.toLowerCase() + ); + }); + + if (!stakedBalances.length) { + return []; + } + + return stakedBalances + .map((balance) => ({ + address: balance.account, + chainId: balance.chainId, + stakedBalance: balance.value ? toHex(balance.value) : '0x0', + })) + .filter((update) => { + const currentStakedBalance = + accountTrackerState.accountsByChainId[update.chainId]?.[ + checksum(update.address) + ]?.stakedBalance; + return currentStakedBalance !== update.stakedBalance; + }); + } + + /** + * Import untracked tokens that have non-zero balances. + * This mirrors the v2 behavior where only tokens with actual balances are added. + * For mUSD default networks, the mUSD contract is also scheduled for import when + * balance is zero (Accounts API omits it like the single-call contract). + * Delegates to TokenDetectionController:addDetectedTokensViaPolling which handles: + * - Checking if useTokenDetection preference is enabled + * - Filtering tokens already in allTokens or allIgnoredTokens + * - Token metadata lookup and addition via TokensController + * + * @param balances - Array of processed balance results from fetchers + * @param targetChainIds - Chains included in this balance update (for mUSD zero-balance import) + */ + async #importUntrackedTokens( + balances: ProcessedBalance[], + targetChainIds: ChainIdHex[], + ): Promise { + const tokensByChain = new Map(); + + for (const balance of balances) { + // Skip failed fetches, native tokens, and zero balances (like v2 did) + if ( + !balance.success || + balance.token === ZERO_ADDRESS || + !balance.value || + balance.value.isZero() + ) { + continue; + } + + const tokenAddress = checksum(balance.token); + const existing = tokensByChain.get(balance.chainId) ?? []; + if (!existing.includes(tokenAddress)) { + existing.push(tokenAddress); + tokensByChain.set(balance.chainId, existing); + } + } + + for (const chainId of targetChainIds) { + if (!MUSD_IMPORT_CHAIN_ID_SET.has(chainId)) { + continue; + } + const existing = tokensByChain.get(chainId) ?? []; + const alreadyHasMusd = existing.some((addr) => + isEqualCaseInsensitive(addr, MUSD_ERC20_ADDRESS_LOWER), + ); + if (!alreadyHasMusd) { + tokensByChain.set(chainId, [...existing, MUSD_ERC20_ADDRESS_LOWER]); + } + } + + // Add detected tokens via TokenDetectionController (handles preference check, + // filtering of allTokens/allIgnoredTokens, and metadata lookup) + for (const [chainId, tokenAddresses] of tokensByChain) { + if (tokenAddresses.length) { + await this.messenger.call( + 'TokenDetectionController:addDetectedTokensViaPolling', + { + tokensSlice: tokenAddresses, + chainId, + }, + ); + } + } + } + + resetState(): void { + this.update(() => ({ tokenBalances: {} })); + } + + #isTokenTracked( + tokenAddress: string, + account: ChecksumAddress, + chainId: ChainIdHex, + ): boolean { + const normalizedAccount = account.toLowerCase(); + + if ( + this.#allTokens?.[chainId]?.[normalizedAccount]?.some( + (token) => token.address === tokenAddress, + ) + ) { + return true; + } + + if ( + this.#allIgnoredTokens?.[chainId]?.[normalizedAccount]?.some( + (token) => token === tokenAddress, + ) + ) { + return true; + } + + return false; + } + + readonly #onTokensChanged = async ( + state: TokensControllerState, + ): Promise => { + if (this.#isDeprecated()) { + this.#enforceDisabledState(); + return; + } + const changed: ChainIdHex[] = []; + let hasChanges = false; + + const incomingChainIds = new Set([ + ...Object.keys(state.allTokens), + ...Object.keys(state.allDetectedTokens), + ]); + + const relevantChainIds = Array.from(incomingChainIds).filter((chainId) => { + const id = chainId as ChainIdHex; + + const hasTokensNow = + (state.allTokens[id] && Object.keys(state.allTokens[id]).length > 0) || + (state.allDetectedTokens[id] && + Object.keys(state.allDetectedTokens[id]).length > 0); + const hadTokensBefore = + (this.#allTokens[id] && Object.keys(this.#allTokens[id]).length > 0) || + (this.#detectedTokens[id] && + Object.keys(this.#detectedTokens[id]).length > 0); + + const hasTokenChange = + !isEqual(state.allTokens[id], this.#allTokens[id]) || + !isEqual(state.allDetectedTokens[id], this.#detectedTokens[id]); + + return hasTokenChange || (!hadTokensBefore && hasTokensNow); + }); + + if (!relevantChainIds.length) { + this.#allTokens = state.allTokens; + this.#detectedTokens = state.allDetectedTokens; + return; } - this.update({ contractBalances: newContractBalances }); + + this.update((currentState) => { + for (const chainId of relevantChainIds) { + const id = chainId as ChainIdHex; + const hasTokensNow = + (state.allTokens[id] && + Object.keys(state.allTokens[id]).length > 0) || + (state.allDetectedTokens[id] && + Object.keys(state.allDetectedTokens[id]).length > 0); + const hadTokensBefore = + (this.#allTokens[id] && + Object.keys(this.#allTokens[id]).length > 0) || + (this.#detectedTokens[id] && + Object.keys(this.#detectedTokens[id]).length > 0); + + const tokensChanged = + !isEqual(state.allTokens[id], this.#allTokens[id]) || + !isEqual(state.allDetectedTokens[id], this.#detectedTokens[id]); + + if (!tokensChanged) { + continue; + } + + if (hasTokensNow) { + changed.push(id); + } else if (hadTokensBefore) { + for (const address of Object.keys(currentState.tokenBalances)) { + const addressKey = address as ChecksumAddress; + if (currentState.tokenBalances[addressKey]?.[id]) { + currentState.tokenBalances[addressKey][id] = {}; + hasChanges = true; + } + } + } + } + }); + + this.#allTokens = state.allTokens; + this.#detectedTokens = state.allDetectedTokens; + this.#allIgnoredTokens = state.allIgnoredTokens; + + if (changed.length && !hasChanges) { + this.updateBalances({ chainIds: changed }).catch((error) => { + console.warn('Error updating balances after token change:', error); + }); + } + }; + + readonly #onNetworkChanged = (state: NetworkState): void => { + if (this.#isDeprecated()) { + this.#enforceDisabledState(); + return; + } + const currentNetworks = new Set( + Object.keys(state.networkConfigurationsByChainId), + ); + + const networksWithBalances = new Set(); + for (const address of Object.keys(this.state.tokenBalances)) { + const addressKey = address as ChecksumAddress; + for (const network of Object.keys( + this.state.tokenBalances[addressKey] || {}, + )) { + networksWithBalances.add(network); + } + } + + const removedNetworks = Array.from(networksWithBalances).filter( + (network) => !currentNetworks.has(network), + ); + + if (!removedNetworks.length) { + return; + } + + this.update((currentState) => { + for (const address of Object.keys(currentState.tokenBalances)) { + const addressKey = address as ChecksumAddress; + for (const removedNetwork of removedNetworks) { + const networkKey = removedNetwork as ChainIdHex; + if (currentState.tokenBalances[addressKey]?.[networkKey]) { + delete currentState.tokenBalances[addressKey][networkKey]; + } + } + } + }); + }; + + readonly #onAccountRemoved = (addr: string): void => { + if (this.#isDeprecated()) { + this.#enforceDisabledState(); + return; + } + if (!isStrictHexString(addr) || !isValidHexAddress(addr)) { + return; + } + this.update((currentState) => { + delete currentState.tokenBalances[addr]; + }); + }; + + readonly #onAccountChanged = (): void => { + if (this.#isDeprecated()) { + this.#enforceDisabledState(); + return; + } + const chainIds = this.#chainIdsWithTokens(); + if (!chainIds.length) { + return; + } + + this.updateBalances({ chainIds }).catch(() => { + // Silently handle polling errors + }); + }; + + #prepareBalanceUpdates( + updates: BalanceUpdate[], + account: ChecksumAddress, + chainId: ChainIdHex, + ): { + tokenBalances: { tokenAddress: ChecksumAddress; balance: Hex }[]; + newTokens: string[]; + nativeBalanceUpdates: NativeBalanceUpdate[]; + } { + const tokenBalances: { tokenAddress: ChecksumAddress; balance: Hex }[] = []; + const newTokens: string[] = []; + const nativeBalanceUpdates: NativeBalanceUpdate[] = []; + + for (const update of updates) { + const { asset, postBalance } = update; + + if (postBalance.error) { + throw new Error('Balance update has error'); + } + + const parsed = parseAssetType(asset.type); + if (!parsed) { + throw new Error('Failed to parse asset type'); + } + + const [tokenAddress, isNativeToken] = parsed; + + if ( + !isStrictHexString(tokenAddress) || + !isValidHexAddress(tokenAddress) + ) { + throw new Error('Invalid token address'); + } + + const checksumTokenAddress = checksum(tokenAddress); + const isTracked = this.#isTokenTracked( + checksumTokenAddress, + account, + chainId, + ); + + const balanceHex = postBalance.amount as Hex; + + tokenBalances.push({ + tokenAddress: checksumTokenAddress, + balance: balanceHex, + }); + + if (isNativeToken) { + nativeBalanceUpdates.push({ + address: account, + chainId, + balance: balanceHex, + }); + } + + if (!isNativeToken && !isTracked) { + newTokens.push(checksumTokenAddress); + } + } + + return { tokenBalances, newTokens, nativeBalanceUpdates }; + } + + readonly #onAccountActivityBalanceUpdate = async ({ + address, + chain, + updates, + }: { + address: string; + chain: string; + updates: BalanceUpdate[]; + }): Promise => { + if (this.#isDeprecated()) { + this.#enforceDisabledState(); + return; + } + const chainId = caipChainIdToHex(chain); + const checksummedAccount = checksum(address); + + try { + const { tokenBalances, newTokens, nativeBalanceUpdates } = + this.#prepareBalanceUpdates(updates, checksummedAccount, chainId); + + if (tokenBalances.length > 0) { + this.update((state) => { + const lowercaseAccount = + checksummedAccount.toLowerCase() as ChecksumAddress; + state.tokenBalances[lowercaseAccount] ??= {}; + state.tokenBalances[lowercaseAccount][chainId] ??= {}; + + for (const { tokenAddress, balance } of tokenBalances) { + state.tokenBalances[lowercaseAccount][chainId][tokenAddress] = + balance; + } + }); + } + + if (nativeBalanceUpdates.length > 0) { + this.messenger.call( + 'AccountTrackerController:updateNativeBalances', + nativeBalanceUpdates, + ); + } + + if (newTokens.length > 0) { + await this.messenger.call( + 'TokenDetectionController:addDetectedTokensViaWs', + { + tokensSlice: newTokens, + chainId, + }, + ); + } + } catch (error) { + console.warn( + `Error updating balances from AccountActivityService for chain ${chain}, account ${address}:`, + error, + ); + console.warn('Balance update data:', JSON.stringify(updates, null, 2)); + + await this.updateBalances({ chainIds: [chainId] }).catch(() => { + // Silently handle polling errors + }); + } + }; + + readonly #onAccountActivityStatusChanged = ({ + chainIds, + status, + }: { + chainIds: string[]; + status: 'up' | 'down'; + }): void => { + if (this.#isDeprecated()) { + this.#enforceDisabledState(); + return; + } + for (const chainId of chainIds) { + this.#statusChangeDebouncer.pendingChanges.set(chainId, status); + } + + if (this.#statusChangeDebouncer.timer) { + clearTimeout(this.#statusChangeDebouncer.timer); + } + + this.#statusChangeDebouncer.timer = setTimeout(() => { + this.#processAccumulatedStatusChanges(); + }, 5000); + }; + + #processAccumulatedStatusChanges(): void { + const changes = Array.from( + this.#statusChangeDebouncer.pendingChanges.entries(), + ); + this.#statusChangeDebouncer.pendingChanges.clear(); + this.#statusChangeDebouncer.timer = null; + + if (!changes.length) { + return; + } + + try { + const chainConfigs: Record = {}; + + for (const [chainId, status] of changes) { + if ( + isCaipChainId(chainId) && + parseCaipChainId(chainId).namespace !== 'eip155' + ) { + continue; + } + + const hexChainId = caipChainIdToHex(chainId); + + chainConfigs[hexChainId] = + status === 'down' + ? { interval: this.#defaultInterval } + : { interval: this.#websocketActivePollingInterval }; + } + + if (Object.keys(chainConfigs).length === 0) { + return; + } + + const jitterDelay = Math.random() * this.#defaultInterval; + setTimeout(() => { + this.updateChainPollingConfigs(chainConfigs, { immediateUpdate: true }); + }, jitterDelay); + } catch (error) { + console.warn('Error processing accumulated status changes:', error); + } + } + + override destroy(): void { + this.#isControllerPollingActive = false; + this.#intervalPollingTimers.forEach((timer) => clearInterval(timer)); + this.#intervalPollingTimers.clear(); + + if (this.#statusChangeDebouncer.timer) { + clearTimeout(this.#statusChangeDebouncer.timer); + this.#statusChangeDebouncer.timer = null; + } + + super.destroy(); } } diff --git a/packages/assets-controllers/src/TokenDetectionController-method-action-types.ts b/packages/assets-controllers/src/TokenDetectionController-method-action-types.ts new file mode 100644 index 00000000000..b3279a49707 --- /dev/null +++ b/packages/assets-controllers/src/TokenDetectionController-method-action-types.ts @@ -0,0 +1,104 @@ +/** + * This file is auto generated. + * Do not edit manually. + */ + +import type { TokenDetectionController } from './TokenDetectionController.js'; + +/** + * Allows controller to make active and passive polling requests + */ +export type TokenDetectionControllerEnableAction = { + type: `TokenDetectionController:enable`; + handler: TokenDetectionController['enable']; +}; + +/** + * Blocks controller from making network calls + */ +export type TokenDetectionControllerDisableAction = { + type: `TokenDetectionController:disable`; + handler: TokenDetectionController['disable']; +}; + +/** + * Start polling for detected tokens. + */ +export type TokenDetectionControllerStartAction = { + type: `TokenDetectionController:start`; + handler: TokenDetectionController['start']; +}; + +/** + * Stop polling for detected tokens. + */ +export type TokenDetectionControllerStopAction = { + type: `TokenDetectionController:stop`; + handler: TokenDetectionController['stop']; +}; + +/** + * For each token in the token list provided by the TokenListService, checks the token's balance for the selected account address on the active network. + * On mainnet, if token detection is disabled in preferences, ERC20 token auto detection will be triggered for each contract address in the legacy token list from the @metamask/contract-metadata repo. + * + * @param options - Options for token detection. + * @param options.chainIds - The chain IDs of the network client to use. + * @param options.selectedAddress - the selectedAddress against which to detect for token balances. + * @param options.forceRpc - Force RPC-based token detection for all specified chains, + * bypassing external services check and ensuring RPC is used even for chains + * that might otherwise be handled by the Accounts API. + */ +export type TokenDetectionControllerDetectTokensAction = { + type: `TokenDetectionController:detectTokens`; + handler: TokenDetectionController['detectTokens']; +}; + +/** + * Add tokens detected from websocket balance updates + * This method: + * - Checks if useTokenDetection preference is enabled (skips if disabled) + * - Checks if external services are enabled (skips if disabled) + * - Tokens are expected to be in the tokensChainsCache with full metadata + * - Balance fetching is skipped since balances are provided by the websocket + * - Ignored tokens have been filtered out by the caller + * + * @param options - The options object + * @param options.tokensSlice - Array of token addresses detected from websocket (already filtered to exclude ignored tokens) + * @param options.chainId - Hex chain ID + * @returns Promise that resolves when tokens are added + */ +export type TokenDetectionControllerAddDetectedTokensViaWsAction = { + type: `TokenDetectionController:addDetectedTokensViaWs`; + handler: TokenDetectionController['addDetectedTokensViaWs']; +}; + +/** + * Add tokens detected from polling balance updates + * This method: + * - Checks if useTokenDetection preference is enabled (skips if disabled) + * - Checks if external services are enabled (skips if disabled) + * - Filters out tokens already in allTokens or allIgnoredTokens + * - Tokens are expected to be in the tokensChainsCache with full metadata + * - Balance fetching is skipped since balances are provided by the caller + * + * @param options - The options object + * @param options.tokensSlice - Array of token addresses detected from polling + * @param options.chainId - Hex chain ID + * @returns Promise that resolves when tokens are added + */ +export type TokenDetectionControllerAddDetectedTokensViaPollingAction = { + type: `TokenDetectionController:addDetectedTokensViaPolling`; + handler: TokenDetectionController['addDetectedTokensViaPolling']; +}; + +/** + * Union of all TokenDetectionController action types. + */ +export type TokenDetectionControllerMethodActions = + | TokenDetectionControllerEnableAction + | TokenDetectionControllerDisableAction + | TokenDetectionControllerStartAction + | TokenDetectionControllerStopAction + | TokenDetectionControllerDetectTokensAction + | TokenDetectionControllerAddDetectedTokensViaWsAction + | TokenDetectionControllerAddDetectedTokensViaPollingAction; diff --git a/packages/assets-controllers/src/TokenDetectionController.test.ts b/packages/assets-controllers/src/TokenDetectionController.test.ts index 36c0e4176a5..bd92c9625a5 100644 --- a/packages/assets-controllers/src/TokenDetectionController.test.ts +++ b/packages/assets-controllers/src/TokenDetectionController.test.ts @@ -1,39 +1,66 @@ -import { ControllerMessenger } from '@metamask/base-controller'; import { ChainId, NetworkType, - NetworksTicker, convertHexToDecimal, - toHex, + InfuraNetworkType, + toChecksumHexAddress, } from '@metamask/controller-utils'; -import { defaultState as defaultNetworkState } from '@metamask/network-controller'; +import type { KeyringControllerState } from '@metamask/keyring-controller'; +import type { InternalAccount } from '@metamask/keyring-internal-api'; +import { MOCK_ANY_NAMESPACE, Messenger } from '@metamask/messenger'; +import type { + MessengerActions, + MessengerEvents, + MockAnyNamespace, +} from '@metamask/messenger'; +import { + getDefaultNetworkControllerState, + RpcEndpointType, +} from '@metamask/network-controller'; import type { - NetworkControllerStateChangeEvent, NetworkState, - ProviderConfig, + NetworkConfiguration, + NetworkController, + NetworkClientId, + AutoManagedNetworkClient, + CustomNetworkClientConfiguration, } from '@metamask/network-controller'; -import { PreferencesController } from '@metamask/preferences-controller'; -import { BN } from 'ethereumjs-util'; +import { getDefaultPreferencesState } from '@metamask/preferences-controller'; +import type { PreferencesState } from '@metamask/preferences-controller'; +import type { Hex } from '@metamask/utils'; +import BN from 'bn.js'; import nock from 'nock'; -import * as sinon from 'sinon'; -import type { AssetsContractController } from './AssetsContractController'; +import { jestAdvanceTime } from '../../../tests/helpers.js'; +import { createMockInternalAccount } from '../../accounts-controller/tests/mocks.js'; +import { + buildCustomRpcEndpoint, + buildInfuraNetworkConfiguration, +} from '../../network-controller/tests/helpers.js'; +import { formatAggregatorNames } from './assetsUtil.js'; +import { MUSD_ERC20_ADDRESS_LOWER } from './constants.js'; import { - formatAggregatorNames, - isTokenDetectionSupportedForNetwork, - SupportedTokenDetectionNetworks, -} from './assetsUtil'; -import { TOKEN_END_POINT_API } from './token-service'; -import { TokenDetectionController } from './TokenDetectionController'; -import { TokenListController } from './TokenListController'; + resetSuggestedOccurrenceFloorsCacheForTesting, + TOKEN_END_POINT_API, +} from './token-service.js'; +import type { TokenDetectionControllerMessenger } from './TokenDetectionController.js'; +import { + TokenDetectionController, + controllerName, +} from './TokenDetectionController.js'; +import { getDefaultTokenListState } from './TokenListController.js'; import type { - GetTokenListState, - TokenListStateChange, + TokenListMap, + TokenListState, TokenListToken, -} from './TokenListController'; -import type { Token } from './TokenRatesController'; -import { TokensController } from './TokensController'; -import type { TokensControllerMessenger } from './TokensController'; +} from './TokenListController.js'; +import type { TokenListService } from './TokenListService.js'; +import type { Token } from './TokenRatesController.js'; +import type { + TokensController, + TokensControllerState, +} from './TokensController.js'; +import { getDefaultTokensState } from './TokensController.js'; const DEFAULT_INTERVAL = 180000; @@ -72,86 +99,146 @@ const sampleTokenList: TokenListToken[] = [ }, ]; const [tokenAFromList, tokenBFromList] = sampleTokenList; -const sampleTokenA: Token = { +const sampleTokenA = { address: tokenAFromList.address, symbol: tokenAFromList.symbol, decimals: tokenAFromList.decimals, image: - 'https://static.metafi.codefi.network/api/v1/tokenIcons/1/0x514910771af9ca656af840dff83e8264ecf986ca.png', + 'https://static.cx.metamask.io/api/v1/tokenIcons/1/0x514910771af9ca656af840dff83e8264ecf986ca.png', isERC721: false, aggregators: formattedSampleAggregators, name: 'Chainlink', }; -const sampleTokenB: Token = { +const sampleTokenB = { address: tokenBFromList.address, symbol: tokenBFromList.symbol, decimals: tokenBFromList.decimals, image: - 'https://static.metafi.codefi.network/api/v1/tokenIcons/1/0x1f573d6fb3f13d689ff844b4ce37794d79a7ff1c.png', + 'https://static.cx.metamask.io/api/v1/tokenIcons/1/0x1f573d6fb3f13d689ff844b4ce37794d79a7ff1c.png', isERC721: false, aggregators: formattedSampleAggregators, name: 'Bancor', }; -type MainControllerMessenger = ControllerMessenger< - GetTokenListState, - TokenListStateChange | NetworkControllerStateChangeEvent +const mockNetworkConfigurations: Record = { + [InfuraNetworkType.mainnet]: buildInfuraNetworkConfiguration( + InfuraNetworkType.mainnet, + ), + [InfuraNetworkType.sepolia]: buildInfuraNetworkConfiguration( + InfuraNetworkType.sepolia, + ), + polygon: { + blockExplorerUrls: ['https://polygonscan.com/'], + chainId: '0x89', + defaultBlockExplorerUrlIndex: 0, + defaultRpcEndpointIndex: 0, + name: 'Polygon Mainnet', + nativeCurrency: 'MATIC', + rpcEndpoints: [ + buildCustomRpcEndpoint({ + url: 'https://polygon-mainnet.infura.io/v3/fakekey', + networkClientId: 'polygon', + }), + ], + }, + avalanche: { + blockExplorerUrls: ['https://snowtrace.io/'], + chainId: '0xa86a', + defaultBlockExplorerUrlIndex: 0, + defaultRpcEndpointIndex: 0, + name: 'Avalanche C-Chain', + nativeCurrency: 'AVAX', + rpcEndpoints: [ + buildCustomRpcEndpoint({ + url: 'https://api.avax.network/ext/bc/C/rpc', + networkClientId: 'avalanche', + }), + ], + }, +}; + +// Network configurations keyed by chain ID (for use when testing with explicit chainIds) +const mockNetworkConfigurationsByChainId: Record = + { + '0xa86a': mockNetworkConfigurations.avalanche, + '0x89': mockNetworkConfigurations.polygon, + }; + +type AllTokenDetectionControllerActions = + MessengerActions; + +type AllTokenDetectionControllerEvents = + MessengerEvents; + +type RootMessenger = Messenger< + MockAnyNamespace, + AllTokenDetectionControllerActions, + AllTokenDetectionControllerEvents >; -const getControllerMessenger = (): MainControllerMessenger => { - return new ControllerMessenger(); -}; +/** + * Builds a root messenger for testing. + * + * @returns The root messenger. + */ +function buildRootMessenger(): RootMessenger { + return new Messenger({ namespace: MOCK_ANY_NAMESPACE }); +} -const setupTokenListController = ( - controllerMessenger: MainControllerMessenger, -) => { - const tokenListMessenger = controllerMessenger.getRestricted({ - name: 'TokenListController', - allowedActions: [], - allowedEvents: [ - 'TokenListController:stateChange', - 'NetworkController:stateChange', - ], +/** + * Builds a messenger that `TokenDetectionController` can use to communicate with other controllers. + * + * @param messenger - The root messenger. + * @returns The controller messenger. + */ +function buildTokenDetectionControllerMessenger( + messenger = buildRootMessenger(), +): TokenDetectionControllerMessenger { + const tokenDetectionControllerMessenger = new Messenger< + 'TokenDetectionController', + AllTokenDetectionControllerActions, + AllTokenDetectionControllerEvents, + RootMessenger + >({ + namespace: controllerName, + parent: messenger, }); - - const tokenList = new TokenListController({ - chainId: ChainId.mainnet, - preventPollingOnNetworkRestart: false, - messenger: tokenListMessenger, + messenger.delegate({ + messenger: tokenDetectionControllerMessenger, + actions: [ + 'AccountsController:getAccount', + 'AccountsController:getSelectedAccount', + 'KeyringController:getState', + 'NetworkController:getNetworkClientById', + 'NetworkController:getNetworkConfigurationByNetworkClientId', + 'NetworkController:getState', + 'TokensController:getState', + 'TokensController:addDetectedTokens', + 'PreferencesController:getState', + 'TokensController:addTokens', + 'NetworkController:findNetworkClientIdByChainId', + ], + events: [ + 'AccountsController:selectedEvmAccountChange', + 'KeyringController:lock', + 'KeyringController:unlock', + 'NetworkController:networkDidChange', + 'PreferencesController:stateChange', + 'TransactionController:transactionConfirmed', + ], }); - - return { tokenList, tokenListMessenger }; -}; + return tokenDetectionControllerMessenger; +} describe('TokenDetectionController', () => { - let tokenDetection: TokenDetectionController; - let preferences: PreferencesController; - let tokensController: TokensController; - let tokenList: TokenListController; - let controllerMessenger: MainControllerMessenger; - let getBalancesInSingleCall: sinon.SinonStub< - Parameters, - ReturnType - >; - - const onNetworkStateChangeListeners: ((state: NetworkState) => void)[] = []; - const changeNetwork = (providerConfig: ProviderConfig) => { - onNetworkStateChangeListeners.forEach((listener) => { - listener({ - ...defaultNetworkState, - providerConfig, - }); - }); - }; - const mainnet = { - chainId: ChainId.mainnet, - type: NetworkType.mainnet, - ticker: NetworksTicker.mainnet, - }; + const defaultSelectedAccount = createMockInternalAccount(); beforeEach(async () => { + resetSuggestedOccurrenceFloorsCacheForTesting(); nock(TOKEN_END_POINT_API) - .get(`/tokens/${convertHexToDecimal(ChainId.mainnet)}`) + .get('/v1/suggestedOccurrenceFloors') + .reply(200, { '1': 3, '59144': 1 }) + .get(getTokensPath(ChainId.mainnet)) .reply(200, sampleTokenList) .get( `/token/${convertHexToDecimal(ChainId.mainnet)}?address=${ @@ -166,417 +253,4421 @@ describe('TokenDetectionController', () => { ) .reply(200, tokenBFromList) .persist(); - - preferences = new PreferencesController({}, { useTokenDetection: true }); - controllerMessenger = getControllerMessenger(); - sinon - .stub(TokensController.prototype, '_createEthersContract') - .callsFake(() => null as any); - - tokensController = new TokensController({ - chainId: ChainId.mainnet, - onPreferencesStateChange: (listener) => preferences.subscribe(listener), - onNetworkStateChange: (listener) => - onNetworkStateChangeListeners.push(listener), - onTokenListStateChange: sinon.stub(), - getERC20TokenName: sinon.stub(), - getNetworkClientById: sinon.stub() as any, - messenger: undefined as unknown as TokensControllerMessenger, - }); - - const tokenListSetup = setupTokenListController(controllerMessenger); - tokenList = tokenListSetup.tokenList; - await tokenList.start(); - - getBalancesInSingleCall = sinon.stub(); - tokenDetection = new TokenDetectionController({ - onPreferencesStateChange: (listener) => preferences.subscribe(listener), - onNetworkStateChange: (listener) => - onNetworkStateChangeListeners.push(listener), - onTokenListStateChange: (listener) => - tokenListSetup.tokenListMessenger.subscribe( - `TokenListController:stateChange`, - listener, - ), - getBalancesInSingleCall: - getBalancesInSingleCall as unknown as AssetsContractController['getBalancesInSingleCall'], - addDetectedTokens: - tokensController.addDetectedTokens.bind(tokensController), - getTokensState: () => tokensController.state, - getTokenListState: () => tokenList.state, - getNetworkState: () => defaultNetworkState, - getPreferencesState: () => preferences.state, - }); - - sinon - .stub(tokensController, '_detectIsERC721') - .callsFake(() => Promise.resolve(false)); }); - afterEach(() => { - sinon.restore(); - tokenDetection.stop(); - tokenList.destroy(); - controllerMessenger.clearEventSubscriptions( - 'NetworkController:stateChange', - ); - }); + describe('start', () => { + beforeEach(() => { + jest.useFakeTimers(); + }); - it('should set default config', () => { - expect(tokenDetection.config).toStrictEqual({ - interval: DEFAULT_INTERVAL, - selectedAddress: '', - disabled: true, - chainId: ChainId.mainnet, - isDetectionEnabledForNetwork: true, - isDetectionEnabledFromPreferences: true, + afterEach(() => { + jest.useRealTimers(); }); - }); - it('should poll and detect tokens on interval while on supported networks', async () => { - await new Promise(async (resolve) => { - const mockTokens = sinon.stub(tokenDetection, 'detectTokens'); - tokenDetection.configure({ - interval: 10, + it('should not poll and detect tokens on interval while keyring is locked', async () => { + await withController( + { + isKeyringUnlocked: false, + options: {}, + mocks: { + getSelectedAccount: defaultSelectedAccount, + }, + }, + async ({ controller }) => { + const mockTokens = jest + .spyOn(controller, 'detectTokens') + .mockImplementation(); + controller.setIntervalLength(10); + + await controller.start(); + + expect(mockTokens).not.toHaveBeenCalled(); + await jestAdvanceTime({ duration: 15 }); + expect(mockTokens).not.toHaveBeenCalled(); + }, + ); + }); + + it('should detect tokens but not restart polling if locked keyring is unlocked', async () => { + await withController( + { + isKeyringUnlocked: false, + options: {}, + mocks: { + getSelectedAccount: defaultSelectedAccount, + }, + }, + async ({ controller, triggerKeyringUnlock }) => { + const mockTokens = jest + .spyOn(controller, 'detectTokens') + .mockImplementation(); + + await controller.start(); + triggerKeyringUnlock(); + + await jestAdvanceTime({ duration: DEFAULT_INTERVAL * 1.5 }); + expect(mockTokens).not.toHaveBeenCalledTimes(2); + }, + ); + }); + + it('should not poll if the controller is not active', async () => { + await withController( + { + isKeyringUnlocked: true, + }, + async ({ controller }) => { + controller.setIntervalLength(10); + + await controller._executePoll({ + chainIds: [ChainId.mainnet], + address: defaultSelectedAccount.address, + }); + + expect(controller.isActive).toBe(false); + }, + ); + }); + + it('should stop polling and detect tokens on interval if unlocked keyring is locked', async () => { + await withController( + { + isKeyringUnlocked: true, + }, + async ({ controller, triggerKeyringLock }) => { + const mockTokens = jest + .spyOn(controller, 'detectTokens') + .mockImplementation(); + controller.setIntervalLength(10); + + await controller.start(); + triggerKeyringLock(); + + expect(mockTokens).toHaveBeenCalledTimes(1); + await jestAdvanceTime({ duration: 15 }); + expect(mockTokens).toHaveBeenCalledTimes(1); + }, + ); + }); + + it('should poll and detect tokens on interval while on supported networks', async () => { + await withController( + { + options: {}, + mocks: { + getSelectedAccount: defaultSelectedAccount, + }, + }, + async ({ controller }) => { + const mockTokens = jest + .spyOn(controller, 'detectTokens') + .mockImplementation(); + controller.setIntervalLength(10); + + await controller.start(); + + expect(mockTokens).toHaveBeenCalledTimes(1); + await jestAdvanceTime({ duration: 15 }); + expect(mockTokens).toHaveBeenCalledTimes(2); + }, + ); + }); + + it('should not autodetect while not on supported networks', async () => { + const mockGetBalancesInSingleCall = jest.fn().mockResolvedValue({ + [sampleTokenA.address]: new BN(1), + }); + await withController( + { + options: { + getBalancesInSingleCall: mockGetBalancesInSingleCall, + }, + mocks: { + getSelectedAccount: defaultSelectedAccount, + }, + }, + async ({ controller, mockNetworkState, mockGetNetworkClientById }) => { + mockNetworkState({ + ...getDefaultNetworkControllerState(), + selectedNetworkClientId: NetworkType.sepolia, + }); + mockGetNetworkClientById( + () => + ({ + configuration: { chainId: ChainId.sepolia }, + }) as unknown as AutoManagedNetworkClient, + ); + await controller.start(); + + expect(mockGetBalancesInSingleCall).not.toHaveBeenCalled(); + }, + ); + }); + + it('should detect tokens correctly on supported networks', async () => { + const mockGetBalancesInSingleCall = jest.fn().mockResolvedValue({ + [sampleTokenA.address]: new BN(1), }); - await tokenDetection.start(); + const selectedAccount = createMockInternalAccount({ + address: '0x0000000000000000000000000000000000000001', + }); + await withController( + { + options: { + getBalancesInSingleCall: mockGetBalancesInSingleCall, + }, + mocks: { + getAccount: selectedAccount, + getSelectedAccount: selectedAccount, + }, + }, + + async ({ + controller, + mockTokenListGetState, + callActionSpy, + mockGetNetworkClientById, + mockNetworkState, + }) => { + // Set selectedNetworkClientId to avalanche so the detection uses the right network + mockNetworkState({ + ...getDefaultNetworkControllerState(), + selectedNetworkClientId: 'avalanche', + }); + // Mock getNetworkClientById to return Avalanche chain ID + mockGetNetworkClientById( + () => + ({ + configuration: { chainId: '0xa86a' }, + }) as unknown as AutoManagedNetworkClient, + ); + + mockTokenListGetState({ + ...getDefaultTokenListState(), + tokensChainsCache: { + '0xa86a': { + timestamp: 0, + data: { + [sampleTokenA.address]: { + name: sampleTokenA.name, + symbol: sampleTokenA.symbol, + decimals: sampleTokenA.decimals, + address: sampleTokenA.address, + occurrences: 1, + aggregators: sampleTokenA.aggregators, + iconUrl: sampleTokenA.image, + }, + }, + }, + }, + }); + + await controller.start(); + + expect(callActionSpy).toHaveBeenCalledWith( + 'TokensController:addTokens', + [sampleTokenA], + 'avalanche', + ); + }, + ); + }); + + it('should not call add tokens if balance is not available on account api', async () => { + const mockGetBalancesInSingleCall = jest.fn().mockResolvedValue({ + [sampleTokenA.address]: new BN(1), + }); + + const selectedAccount = createMockInternalAccount({ + address: '0x0000000000000000000000000000000000000001', + }); + await withController( + { + options: { + getBalancesInSingleCall: mockGetBalancesInSingleCall, + }, + mocks: { + getAccount: selectedAccount, + getSelectedAccount: selectedAccount, + }, + }, + + async ({ controller, mockTokenListGetState, callActionSpy }) => { + mockTokenListGetState({ + ...getDefaultTokenListState(), + tokensChainsCache: { + '0xa86a': { + timestamp: 0, + data: { + test: { + name: sampleTokenA.name, + symbol: sampleTokenA.symbol, + decimals: sampleTokenA.decimals, + address: 'test', + occurrences: 1, + aggregators: sampleTokenA.aggregators, + iconUrl: sampleTokenA.image, + }, + }, + }, + }, + }); + + await controller.start(); + + expect(callActionSpy).not.toHaveBeenCalledWith( + 'TokensController:addDetectedTokens', + [sampleTokenA], + { + chainId: ChainId.sepolia, + selectedAddress: selectedAccount.address, + }, + ); + }, + ); + }); + + it('should detect tokens correctly on the Sepolia network', async () => { + const mockGetBalancesInSingleCall = jest.fn().mockResolvedValue({ + [sampleTokenA.address]: new BN(1), + }); + const selectedAccount = createMockInternalAccount({ + address: '0x0000000000000000000000000000000000000001', + }); + await withController( + { + options: { + getBalancesInSingleCall: mockGetBalancesInSingleCall, + }, + mocks: { + getAccount: selectedAccount, + getSelectedAccount: selectedAccount, + }, + }, + async ({ + controller, + mockTokenListGetState, + mockNetworkState, + mockGetNetworkClientById, + mockFindNetworkClientIdByChainId, + callActionSpy, + }) => { + // Use Sepolia (0xaa36a7) which is not in SUPPORTED_NETWORKS_ACCOUNTS_API_V4 + mockNetworkState({ + ...getDefaultNetworkControllerState(), + selectedNetworkClientId: 'avalanche', + }); + mockGetNetworkClientById( + () => + ({ + configuration: { chainId: '0xa86a' }, + }) as unknown as AutoManagedNetworkClient, + ); + mockFindNetworkClientIdByChainId(() => 'avalanche'); + mockTokenListGetState({ + ...getDefaultTokenListState(), + tokensChainsCache: { + '0xa86a': { + timestamp: 0, + data: { + [sampleTokenA.address]: { + name: sampleTokenA.name, + symbol: sampleTokenA.symbol, + decimals: sampleTokenA.decimals, + address: sampleTokenA.address, + occurrences: 1, + aggregators: sampleTokenA.aggregators, + iconUrl: sampleTokenA.image, + }, + }, + }, + }, + }); + + await controller.start(); - expect(mockTokens.calledOnce).toBe(true); - setTimeout(() => { - expect(mockTokens.calledTwice).toBe(true); - resolve(''); - }, 15); + expect(callActionSpy).toHaveBeenCalledWith( + 'TokensController:addTokens', + [sampleTokenA], + 'avalanche', + ); + }, + ); + }); + + it('should update detectedTokens when new tokens are detected', async () => { + const mockGetBalancesInSingleCall = jest.fn().mockResolvedValue({ + [sampleTokenA.address]: new BN(1), + [sampleTokenB.address]: new BN(1), + }); + const selectedAccount = createMockInternalAccount({ + address: '0x0000000000000000000000000000000000000001', + }); + const interval = 100; + await withController( + { + options: { + getBalancesInSingleCall: mockGetBalancesInSingleCall, + interval, + }, + mocks: { + getAccount: selectedAccount, + getSelectedAccount: selectedAccount, + }, + }, + async ({ + controller, + mockTokenListGetState, + callActionSpy, + mockNetworkState, + }) => { + mockNetworkState({ + ...getDefaultNetworkControllerState(), + selectedNetworkClientId: 'avalanche', + }); + const tokenListState = { + ...getDefaultTokenListState(), + tokensChainsCache: { + '0xa86a': { + timestamp: 0, + data: { + [sampleTokenA.address]: { + name: sampleTokenA.name, + symbol: sampleTokenA.symbol, + decimals: sampleTokenA.decimals, + address: sampleTokenA.address, + occurrences: 1, + aggregators: sampleTokenA.aggregators, + iconUrl: sampleTokenA.image, + }, + }, + }, + }, + }; + mockTokenListGetState(tokenListState); + await controller.start(); + + tokenListState.tokensChainsCache['0xa86a'].data[ + sampleTokenB.address + ] = { + name: sampleTokenB.name, + symbol: sampleTokenB.symbol, + decimals: sampleTokenB.decimals, + address: sampleTokenB.address, + occurrences: 1, + aggregators: sampleTokenB.aggregators, + iconUrl: sampleTokenB.image, + }; + mockTokenListGetState(tokenListState); + await jestAdvanceTime({ duration: interval }); + + expect(callActionSpy).toHaveBeenCalledWith( + 'TokensController:addTokens', + [sampleTokenA, sampleTokenB], + 'avalanche', + ); + }, + ); + }); + + it('should not add ignoredTokens to the tokens list if detected with balance', async () => { + const mockGetBalancesInSingleCall = jest.fn().mockResolvedValue({ + [sampleTokenA.address]: new BN(1), + }); + const selectedAccount = createMockInternalAccount({ + address: '0x0000000000000000000000000000000000000001', + }); + await withController( + { + options: { + getBalancesInSingleCall: mockGetBalancesInSingleCall, + }, + mocks: { + getAccount: selectedAccount, + getSelectedAccount: selectedAccount, + }, + }, + async ({ + controller, + mockTokensGetState, + mockTokenListGetState, + callActionSpy, + }) => { + mockTokensGetState({ + ...getDefaultTokensState(), + }); + mockTokenListGetState({ + ...getDefaultTokenListState(), + tokensChainsCache: { + '0xa86a': { + timestamp: 0, + data: { + [sampleTokenA.address]: { + name: sampleTokenA.name, + symbol: sampleTokenA.symbol, + decimals: sampleTokenA.decimals, + address: sampleTokenA.address, + occurrences: 1, + aggregators: sampleTokenA.aggregators, + iconUrl: sampleTokenA.image, + }, + }, + }, + }, + }); + + await controller.start(); + + expect(callActionSpy).not.toHaveBeenCalledWith( + 'TokensController:addDetectedTokens', + ); + }, + ); + }); + + it('should not detect tokens if there is no selectedAddress set', async () => { + const mockGetBalancesInSingleCall = jest.fn().mockResolvedValue({ + [sampleTokenA.address]: new BN(1), + }); + await withController( + { + options: { + getBalancesInSingleCall: mockGetBalancesInSingleCall, + }, + mocks: { + getSelectedAccount: defaultSelectedAccount, + }, + }, + async ({ controller, mockTokenListGetState, callActionSpy }) => { + mockTokenListGetState({ + ...getDefaultTokenListState(), + tokensChainsCache: { + '0xa86a': { + timestamp: 0, + data: { + [sampleTokenA.address]: { + name: sampleTokenA.name, + symbol: sampleTokenA.symbol, + decimals: sampleTokenA.decimals, + address: sampleTokenA.address, + occurrences: 1, + aggregators: sampleTokenA.aggregators, + iconUrl: sampleTokenA.image, + }, + }, + }, + }, + }); + + await controller.start(); + + expect(callActionSpy).not.toHaveBeenCalledWith( + 'TokensController:addDetectedTokens', + ); + }, + ); }); }); - it('should detect supported networks correctly', () => { - tokenDetection.configure({ - chainId: SupportedTokenDetectionNetworks.mainnet, - }); - - expect( - isTokenDetectionSupportedForNetwork(tokenDetection.config.chainId), - ).toBe(true); - tokenDetection.configure({ chainId: SupportedTokenDetectionNetworks.bsc }); - expect( - isTokenDetectionSupportedForNetwork(tokenDetection.config.chainId), - ).toBe(true); - tokenDetection.configure({ chainId: ChainId.goerli }); - expect( - isTokenDetectionSupportedForNetwork(tokenDetection.config.chainId), - ).toBe(false); + describe('AccountsController:selectedAccountChange', () => { + beforeEach(() => { + jest.useFakeTimers(); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + describe('when "disabled" is false', () => { + it('should detect new tokens after switching between accounts', async () => { + const mockGetBalancesInSingleCall = jest.fn().mockResolvedValue({ + [sampleTokenA.address]: new BN(1), + }); + const firstSelectedAccount = createMockInternalAccount({ + address: '0x0000000000000000000000000000000000000001', + }); + const secondSelectedAccount = createMockInternalAccount({ + address: '0x0000000000000000000000000000000000000002', + }); + await withController( + { + options: { + disabled: false, + getBalancesInSingleCall: mockGetBalancesInSingleCall, + }, + mocks: { + getSelectedAccount: firstSelectedAccount, + }, + }, + async ({ + mockGetAccount, + mockTokenListGetState, + triggerSelectedAccountChange, + callActionSpy, + mockNetworkState, + }) => { + // Set selectedNetworkClientId to avalanche and include it in networkConfigurationsByChainId + const defaultState = getDefaultNetworkControllerState(); + mockNetworkState({ + ...defaultState, + selectedNetworkClientId: 'avalanche', + networkConfigurationsByChainId: { + ...defaultState.networkConfigurationsByChainId, + ...mockNetworkConfigurationsByChainId, + }, + }); + mockTokenListGetState({ + ...getDefaultTokenListState(), + tokensChainsCache: { + '0xa86a': { + timestamp: 0, + data: { + [sampleTokenA.address]: { + name: sampleTokenA.name, + symbol: sampleTokenA.symbol, + decimals: sampleTokenA.decimals, + address: sampleTokenA.address, + occurrences: 1, + aggregators: sampleTokenA.aggregators, + iconUrl: sampleTokenA.image, + }, + }, + }, + }, + }); + + mockGetAccount(secondSelectedAccount); + triggerSelectedAccountChange(secondSelectedAccount); + await jestAdvanceTime({ duration: 1 }); + + expect(callActionSpy).toHaveBeenCalledWith( + 'TokensController:addTokens', + [sampleTokenA], + 'avalanche', + ); + }, + ); + }); + + it('should not detect new tokens if the account is unchanged', async () => { + const mockGetBalancesInSingleCall = jest.fn().mockResolvedValue({ + [sampleTokenA.address]: new BN(1), + }); + const selectedAccount = createMockInternalAccount({ + address: '0x0000000000000000000000000000000000000001', + }); + await withController( + { + options: { + disabled: false, + getBalancesInSingleCall: mockGetBalancesInSingleCall, + }, + mocks: { + getSelectedAccount: selectedAccount, + }, + }, + async ({ + mockTokenListGetState, + triggerSelectedAccountChange, + callActionSpy, + }) => { + mockTokenListGetState({ + ...getDefaultTokenListState(), + tokensChainsCache: { + '0xa86a': { + timestamp: 0, + data: { + [sampleTokenA.address]: { + name: sampleTokenA.name, + symbol: sampleTokenA.symbol, + decimals: sampleTokenA.decimals, + address: sampleTokenA.address, + occurrences: 1, + aggregators: sampleTokenA.aggregators, + iconUrl: sampleTokenA.image, + }, + }, + }, + }, + }); + + triggerSelectedAccountChange({ + address: selectedAccount.address, + } as InternalAccount); + await jestAdvanceTime({ duration: 1 }); + + expect(callActionSpy).not.toHaveBeenCalledWith( + 'TokensController:addDetectedTokens', + ); + }, + ); + }); + + describe('when keyring is locked', () => { + it('should not detect new tokens after switching between accounts', async () => { + const mockGetBalancesInSingleCall = jest.fn().mockResolvedValue({ + [sampleTokenA.address]: new BN(1), + }); + const firstSelectedAccount = createMockInternalAccount({ + address: '0x0000000000000000000000000000000000000001', + }); + const secondSelectedAccount = createMockInternalAccount({ + address: '0x0000000000000000000000000000000000000002', + }); + await withController( + { + options: { + disabled: false, + getBalancesInSingleCall: mockGetBalancesInSingleCall, + }, + mocks: { + getSelectedAccount: firstSelectedAccount, + }, + isKeyringUnlocked: false, + }, + async ({ + mockTokenListGetState, + triggerSelectedAccountChange, + callActionSpy, + }) => { + mockTokenListGetState({ + ...getDefaultTokenListState(), + tokensChainsCache: { + '0xa86a': { + timestamp: 0, + data: { + [sampleTokenA.address]: { + name: sampleTokenA.name, + symbol: sampleTokenA.symbol, + decimals: sampleTokenA.decimals, + address: sampleTokenA.address, + occurrences: 1, + aggregators: sampleTokenA.aggregators, + iconUrl: sampleTokenA.image, + }, + }, + }, + }, + }); + + triggerSelectedAccountChange({ + address: secondSelectedAccount.address, + } as InternalAccount); + await jestAdvanceTime({ duration: 1 }); + + expect(callActionSpy).not.toHaveBeenCalledWith( + 'TokensController:addDetectedTokens', + ); + }, + ); + }); + }); + }); + + describe('when "disabled" is true', () => { + it('should not detect new tokens after switching between accounts', async () => { + const mockGetBalancesInSingleCall = jest.fn().mockResolvedValue({ + [sampleTokenA.address]: new BN(1), + }); + const firstSelectedAccount = createMockInternalAccount({ + address: '0x0000000000000000000000000000000000000001', + }); + const secondSelectedAccount = createMockInternalAccount({ + address: '0x0000000000000000000000000000000000000002', + }); + await withController( + { + options: { + disabled: true, + getBalancesInSingleCall: mockGetBalancesInSingleCall, + }, + mocks: { + getSelectedAccount: firstSelectedAccount, + }, + }, + async ({ + mockTokenListGetState, + triggerSelectedAccountChange, + callActionSpy, + }) => { + mockTokenListGetState({ + ...getDefaultTokenListState(), + tokensChainsCache: { + '0xa86a': { + timestamp: 0, + data: { + [sampleTokenA.address]: { + name: sampleTokenA.name, + symbol: sampleTokenA.symbol, + decimals: sampleTokenA.decimals, + address: sampleTokenA.address, + occurrences: 1, + aggregators: sampleTokenA.aggregators, + iconUrl: sampleTokenA.image, + }, + }, + }, + }, + }); + + triggerSelectedAccountChange({ + address: secondSelectedAccount.address, + } as InternalAccount); + await jestAdvanceTime({ duration: 1 }); + + expect(callActionSpy).not.toHaveBeenCalledWith( + 'TokensController:addDetectedTokens', + ); + }, + ); + }); + }); }); - it('should not autodetect while not on supported networks', async () => { - tokenDetection.configure({ - selectedAddress: '0x1', - chainId: ChainId.goerli, - isDetectionEnabledForNetwork: false, + describe('PreferencesController:stateChange', () => { + beforeEach(() => { + jest.useFakeTimers(); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + describe('when "disabled" is false', () => { + it('should detect new tokens after switching between accounts', async () => { + const mockGetBalancesInSingleCall = jest.fn().mockResolvedValue({ + [sampleTokenA.address]: new BN(1), + }); + const firstSelectedAccount = createMockInternalAccount({ + address: '0x0000000000000000000000000000000000000001', + }); + const secondSelectedAccount = createMockInternalAccount({ + address: '0x0000000000000000000000000000000000000002', + }); + await withController( + { + options: { + disabled: false, + getBalancesInSingleCall: mockGetBalancesInSingleCall, + }, + mocks: { + getSelectedAccount: firstSelectedAccount, + }, + }, + async ({ + mockGetAccount, + mockTokenListGetState, + mockNetworkState, + triggerPreferencesStateChange, + triggerSelectedAccountChange, + callActionSpy, + }) => { + mockTokenListGetState({ + ...getDefaultTokenListState(), + tokensChainsCache: { + '0xa86a': { + timestamp: 0, + data: { + [sampleTokenA.address]: { + name: sampleTokenA.name, + symbol: sampleTokenA.symbol, + decimals: sampleTokenA.decimals, + address: sampleTokenA.address, + occurrences: 1, + aggregators: sampleTokenA.aggregators, + iconUrl: sampleTokenA.image, + }, + }, + }, + }, + }); + mockNetworkState({ + networkConfigurationsByChainId: { + '0xa86a': { + name: 'avalanche', + nativeCurrency: 'AVAX', + rpcEndpoints: [ + { + networkClientId: 'avalanche', + type: RpcEndpointType.Custom, + url: 'https://api.avax.network/ext/bc/C/rpc', + }, + ], + blockExplorerUrls: [], + chainId: '0xa86a', + defaultRpcEndpointIndex: 0, + }, + }, + networksMetadata: {}, + selectedNetworkClientId: 'avalanche', + }); + + triggerPreferencesStateChange({ + ...getDefaultPreferencesState(), + useTokenDetection: true, + }); + mockGetAccount(secondSelectedAccount); + triggerSelectedAccountChange(secondSelectedAccount); + await jestAdvanceTime({ duration: 1 }); + + expect(callActionSpy).toHaveBeenLastCalledWith( + 'TokensController:addTokens', + [sampleTokenA], + 'avalanche', + ); + }, + ); + }); + + it('should detect new tokens after switching between accounts on different chains', async () => { + const mockGetBalancesInSingleCall = jest.fn().mockResolvedValue({ + [sampleTokenA.address]: new BN(1), + }); + const firstSelectedAccount = createMockInternalAccount({ + address: '0x0000000000000000000000000000000000000001', + }); + const secondSelectedAccount = createMockInternalAccount({ + address: '0x0000000000000000000000000000000000000002', + }); + await withController( + { + options: { + disabled: false, + getBalancesInSingleCall: mockGetBalancesInSingleCall, + }, + mocks: { + getSelectedAccount: firstSelectedAccount, + }, + }, + async ({ + mockGetAccount, + mockTokenListGetState, + mockNetworkState, + triggerPreferencesStateChange, + triggerSelectedAccountChange, + controller, + }) => { + const mockTokens = jest.spyOn(controller, 'detectTokens'); + mockTokenListGetState({ + ...getDefaultTokenListState(), + tokensChainsCache: { + '0xa86a': { + timestamp: 0, + data: { + [sampleTokenA.address]: { + name: sampleTokenA.name, + symbol: sampleTokenA.symbol, + decimals: sampleTokenA.decimals, + address: sampleTokenA.address, + occurrences: 1, + aggregators: sampleTokenA.aggregators, + iconUrl: sampleTokenA.image, + }, + }, + }, + }, + }); + // Set to avalanche which is not in SUPPORTED_NETWORKS_ACCOUNTS_API_V4 + mockNetworkState({ + ...getDefaultNetworkControllerState(), + selectedNetworkClientId: 'avalanche', + }); + + triggerPreferencesStateChange({ + ...getDefaultPreferencesState(), + useTokenDetection: true, + }); + mockGetAccount(secondSelectedAccount); + triggerSelectedAccountChange(secondSelectedAccount); + + await jestAdvanceTime({ duration: 1 }); + + // detectTokens is called once when account changes + // (preference change doesn't trigger since useTokenDetection was already true by default) + expect(mockTokens).toHaveBeenCalledTimes(1); + }, + ); + }); + + it('should detect new tokens after enabling token detection', async () => { + const mockGetBalancesInSingleCall = jest.fn().mockResolvedValue({ + [sampleTokenA.address]: new BN(1), + }); + const selectedAccount = createMockInternalAccount({ + address: '0x0000000000000000000000000000000000000001', + }); + await withController( + { + options: { + disabled: false, + getBalancesInSingleCall: mockGetBalancesInSingleCall, + }, + mocks: { + getSelectedAccount: selectedAccount, + }, + }, + async ({ + mockGetAccount, + mockTokenListGetState, + triggerPreferencesStateChange, + callActionSpy, + mockNetworkState, + }) => { + // Set selectedNetworkClientId to avalanche (not in SUPPORTED_NETWORKS_ACCOUNTS_API_V4) + mockNetworkState({ + ...getDefaultNetworkControllerState(), + selectedNetworkClientId: 'avalanche', + }); + mockGetAccount(selectedAccount); + mockTokenListGetState({ + ...getDefaultTokenListState(), + tokensChainsCache: { + '0xa86a': { + timestamp: 0, + data: { + [sampleTokenA.address]: { + name: sampleTokenA.name, + symbol: sampleTokenA.symbol, + decimals: sampleTokenA.decimals, + address: sampleTokenA.address, + occurrences: 1, + aggregators: sampleTokenA.aggregators, + iconUrl: sampleTokenA.image, + }, + }, + }, + }, + }); + + triggerPreferencesStateChange({ + ...getDefaultPreferencesState(), + useTokenDetection: false, + }); + await jestAdvanceTime({ duration: 1 }); + + triggerPreferencesStateChange({ + ...getDefaultPreferencesState(), + useTokenDetection: true, + }); + await jestAdvanceTime({ duration: 1 }); + + expect(callActionSpy).toHaveBeenCalledWith( + 'TokensController:addTokens', + [sampleTokenA], + 'avalanche', + ); + }, + ); + }); + + it('should not detect new tokens after switching between account if token detection is disabled', async () => { + const mockGetBalancesInSingleCall = jest.fn().mockResolvedValue({ + [sampleTokenA.address]: new BN(1), + }); + const firstSelectedAccount = createMockInternalAccount({ + address: '0x0000000000000000000000000000000000000001', + }); + const secondSelectedAccount = createMockInternalAccount({ + address: '0x0000000000000000000000000000000000000002', + }); + await withController( + { + options: { + disabled: false, + getBalancesInSingleCall: mockGetBalancesInSingleCall, + }, + mocks: { + getSelectedAccount: firstSelectedAccount, + }, + }, + async ({ + mockGetAccount, + mockTokenListGetState, + triggerSelectedAccountChange, + triggerPreferencesStateChange, + callActionSpy, + }) => { + mockGetAccount(firstSelectedAccount); + mockTokenListGetState({ + ...getDefaultTokenListState(), + tokensChainsCache: { + '0xa86a': { + data: { + [sampleTokenA.address]: { + name: sampleTokenA.name, + symbol: sampleTokenA.symbol, + decimals: sampleTokenA.decimals, + address: sampleTokenA.address, + occurrences: 1, + aggregators: sampleTokenA.aggregators, + iconUrl: sampleTokenA.image, + }, + }, + timestamp: 0, + }, + }, + }); + + triggerPreferencesStateChange({ + ...getDefaultPreferencesState(), + useTokenDetection: false, + }); + mockGetAccount(secondSelectedAccount); + triggerSelectedAccountChange(secondSelectedAccount); + await jestAdvanceTime({ duration: 1 }); + + expect(callActionSpy).not.toHaveBeenCalledWith( + 'TokensController:addDetectedTokens', + ); + }, + ); + }); + + it('should not detect new tokens if the account is unchanged', async () => { + const mockGetBalancesInSingleCall = jest.fn().mockResolvedValue({ + [sampleTokenA.address]: new BN(1), + }); + const selectedAccount = createMockInternalAccount({ + address: '0x0000000000000000000000000000000000000001', + }); + await withController( + { + options: { + disabled: false, + getBalancesInSingleCall: mockGetBalancesInSingleCall, + }, + mocks: { + getAccount: selectedAccount, + getSelectedAccount: selectedAccount, + }, + }, + async ({ + mockTokenListGetState, + triggerPreferencesStateChange, + callActionSpy, + }) => { + mockTokenListGetState({ + ...getDefaultTokenListState(), + tokensChainsCache: { + [ChainId.sepolia]: { + data: { + [sampleTokenA.address]: { + name: sampleTokenA.name, + symbol: sampleTokenA.symbol, + decimals: sampleTokenA.decimals, + address: sampleTokenA.address, + occurrences: 1, + aggregators: sampleTokenA.aggregators, + iconUrl: sampleTokenA.image, + }, + }, + timestamp: 0, + }, + }, + }); + + triggerPreferencesStateChange({ + ...getDefaultPreferencesState(), + useTokenDetection: true, + }); + await jestAdvanceTime({ duration: 1 }); + + expect(callActionSpy).not.toHaveBeenCalledWith( + 'TokensController:addDetectedTokens', + ); + }, + ); + }); + }); + + describe('when keyring is locked', () => { + it('should not detect new tokens after switching between accounts', async () => { + const mockGetBalancesInSingleCall = jest.fn().mockResolvedValue({ + [sampleTokenA.address]: new BN(1), + }); + const firstSelectedAccount = createMockInternalAccount({ + address: '0x0000000000000000000000000000000000000001', + }); + const secondSelectedAccount = createMockInternalAccount({ + address: '0x0000000000000000000000000000000000000002', + }); + await withController( + { + options: { + disabled: false, + getBalancesInSingleCall: mockGetBalancesInSingleCall, + }, + mocks: { + getSelectedAccount: firstSelectedAccount, + getAccount: firstSelectedAccount, + }, + isKeyringUnlocked: false, + }, + async ({ + mockGetAccount, + mockTokenListGetState, + triggerPreferencesStateChange, + triggerSelectedAccountChange, + callActionSpy, + }) => { + mockTokenListGetState({ + ...getDefaultTokenListState(), + tokensChainsCache: { + [ChainId.sepolia]: { + data: { + [sampleTokenA.address]: { + name: sampleTokenA.name, + symbol: sampleTokenA.symbol, + decimals: sampleTokenA.decimals, + address: sampleTokenA.address, + occurrences: 1, + aggregators: sampleTokenA.aggregators, + iconUrl: sampleTokenA.image, + }, + }, + timestamp: 0, + }, + }, + }); + + triggerPreferencesStateChange({ + ...getDefaultPreferencesState(), + useTokenDetection: true, + }); + mockGetAccount(secondSelectedAccount); + triggerSelectedAccountChange(secondSelectedAccount); + await jestAdvanceTime({ duration: 1 }); + + expect(callActionSpy).not.toHaveBeenCalledWith( + 'TokensController:addDetectedTokens', + ); + }, + ); + }); + + it('should not detect new tokens after enabling token detection', async () => { + const mockGetBalancesInSingleCall = jest.fn().mockResolvedValue({ + [sampleTokenA.address]: new BN(1), + }); + const selectedAccount = createMockInternalAccount({ + address: '0x0000000000000000000000000000000000000001', + }); + await withController( + { + options: { + disabled: false, + getBalancesInSingleCall: mockGetBalancesInSingleCall, + }, + isKeyringUnlocked: false, + mocks: { + getSelectedAccount: selectedAccount, + getAccount: selectedAccount, + }, + }, + async ({ + mockTokenListGetState, + triggerPreferencesStateChange, + callActionSpy, + }) => { + mockTokenListGetState({ + ...getDefaultTokenListState(), + tokensChainsCache: { + [ChainId.sepolia]: { + data: { + [sampleTokenA.address]: { + name: sampleTokenA.name, + symbol: sampleTokenA.symbol, + decimals: sampleTokenA.decimals, + address: sampleTokenA.address, + occurrences: 1, + aggregators: sampleTokenA.aggregators, + iconUrl: sampleTokenA.image, + }, + }, + timestamp: 0, + }, + }, + }); + + triggerPreferencesStateChange({ + ...getDefaultPreferencesState(), + useTokenDetection: false, + }); + await jestAdvanceTime({ duration: 1 }); + + triggerPreferencesStateChange({ + ...getDefaultPreferencesState(), + useTokenDetection: true, + }); + await jestAdvanceTime({ duration: 1 }); + + expect(callActionSpy).not.toHaveBeenCalledWith( + 'TokensController:addDetectedTokens', + ); + }, + ); + }); }); - getBalancesInSingleCall.resolves({ - [sampleTokenA.address]: new BN(1), + describe('when "disabled" is true', () => { + it('should not detect new tokens after switching between accounts', async () => { + const mockGetBalancesInSingleCall = jest.fn().mockResolvedValue({ + [sampleTokenA.address]: new BN(1), + }); + const firstSelectedAccount = createMockInternalAccount({ + address: '0x0000000000000000000000000000000000000001', + }); + const secondSelectedAccount = createMockInternalAccount({ + address: '0x0000000000000000000000000000000000000002', + }); + await withController( + { + options: { + disabled: true, + getBalancesInSingleCall: mockGetBalancesInSingleCall, + }, + mocks: { + getAccount: firstSelectedAccount, + getSelectedAccount: firstSelectedAccount, + }, + }, + async ({ + mockGetAccount, + mockTokenListGetState, + triggerPreferencesStateChange, + triggerSelectedAccountChange, + callActionSpy, + }) => { + mockTokenListGetState({ + ...getDefaultTokenListState(), + tokensChainsCache: { + [ChainId.sepolia]: { + data: { + [sampleTokenA.address]: { + name: sampleTokenA.name, + symbol: sampleTokenA.symbol, + decimals: sampleTokenA.decimals, + address: sampleTokenA.address, + occurrences: 1, + aggregators: sampleTokenA.aggregators, + iconUrl: sampleTokenA.image, + }, + }, + timestamp: 0, + }, + }, + }); + + triggerPreferencesStateChange({ + ...getDefaultPreferencesState(), + useTokenDetection: true, + }); + mockGetAccount(secondSelectedAccount); + triggerSelectedAccountChange(secondSelectedAccount); + await jestAdvanceTime({ duration: 1 }); + + expect(callActionSpy).not.toHaveBeenCalledWith( + 'TokensController:addDetectedTokens', + ); + }, + ); + }); + + it('should not detect new tokens after enabling token detection', async () => { + const mockGetBalancesInSingleCall = jest.fn().mockResolvedValue({ + [sampleTokenA.address]: new BN(1), + }); + const selectedAccount = createMockInternalAccount({ + address: '0x0000000000000000000000000000000000000001', + }); + await withController( + { + options: { + disabled: true, + getBalancesInSingleCall: mockGetBalancesInSingleCall, + }, + mocks: { + getAccount: selectedAccount, + getSelectedAccount: selectedAccount, + }, + }, + async ({ + mockTokenListGetState, + triggerPreferencesStateChange, + callActionSpy, + }) => { + mockTokenListGetState({ + ...getDefaultTokenListState(), + tokensChainsCache: { + [ChainId.sepolia]: { + data: { + [sampleTokenA.address]: { + name: sampleTokenA.name, + symbol: sampleTokenA.symbol, + decimals: sampleTokenA.decimals, + address: sampleTokenA.address, + occurrences: 1, + aggregators: sampleTokenA.aggregators, + iconUrl: sampleTokenA.image, + }, + }, + timestamp: 0, + }, + }, + }); + + triggerPreferencesStateChange({ + ...getDefaultPreferencesState(), + useTokenDetection: false, + }); + await jestAdvanceTime({ duration: 1 }); + + triggerPreferencesStateChange({ + ...getDefaultPreferencesState(), + useTokenDetection: true, + }); + await jestAdvanceTime({ duration: 1 }); + + expect(callActionSpy).not.toHaveBeenCalledWith( + 'TokensController:addDetectedTokens', + ); + }, + ); + }); }); - await tokenDetection.start(); - expect(tokensController.state.detectedTokens).toStrictEqual([]); }); - it('should detect tokens correctly on supported networks', async () => { - preferences.update({ selectedAddress: '0x1' }); - changeNetwork(mainnet); + describe('NetworkController:networkDidChange', () => { + beforeEach(() => { + jest.useFakeTimers(); + }); - getBalancesInSingleCall.resolves({ - [sampleTokenA.address]: new BN(1), + afterEach(() => { + jest.useRealTimers(); + }); + + describe('when "disabled" is false', () => { + it('should not detect new tokens after switching to a chain that does not support token detection', async () => { + const mockGetBalancesInSingleCall = jest.fn().mockResolvedValue({ + [sampleTokenA.address]: new BN(1), + }); + const selectedAccount = createMockInternalAccount({ + address: '0x0000000000000000000000000000000000000001', + }); + await withController( + { + options: { + disabled: false, + getBalancesInSingleCall: mockGetBalancesInSingleCall, + }, + mocks: { + getAccount: selectedAccount, + getSelectedAccount: selectedAccount, + }, + }, + async ({ + mockTokenListGetState, + callActionSpy, + triggerNetworkDidChange, + }) => { + mockTokenListGetState({ + ...getDefaultTokenListState(), + tokensChainsCache: { + [ChainId.sepolia]: { + timestamp: 0, + data: { + [sampleTokenA.address]: { + name: sampleTokenA.name, + symbol: sampleTokenA.symbol, + decimals: sampleTokenA.decimals, + address: sampleTokenA.address, + occurrences: 1, + aggregators: sampleTokenA.aggregators, + iconUrl: sampleTokenA.image, + }, + }, + }, + }, + }); + + triggerNetworkDidChange({ + ...getDefaultNetworkControllerState(), + selectedNetworkClientId: NetworkType.sepolia, + }); + await jestAdvanceTime({ duration: 1 }); + + expect(callActionSpy).not.toHaveBeenCalledWith( + 'TokensController:addDetectedTokens', + ); + }, + ); + }); + + it('should not detect new tokens if the network client id has not changed', async () => { + const mockGetBalancesInSingleCall = jest.fn().mockResolvedValue({ + [sampleTokenA.address]: new BN(1), + }); + const selectedAccount = createMockInternalAccount({ + address: '0x0000000000000000000000000000000000000001', + }); + await withController( + { + options: { + disabled: false, + getBalancesInSingleCall: mockGetBalancesInSingleCall, + }, + mocks: { + getAccount: selectedAccount, + getSelectedAccount: selectedAccount, + }, + }, + async ({ + mockTokenListGetState, + callActionSpy, + triggerNetworkDidChange, + }) => { + mockTokenListGetState({ + ...getDefaultTokenListState(), + tokensChainsCache: { + [ChainId.sepolia]: { + data: { + [sampleTokenA.address]: { + name: sampleTokenA.name, + symbol: sampleTokenA.symbol, + decimals: sampleTokenA.decimals, + address: sampleTokenA.address, + occurrences: 1, + aggregators: sampleTokenA.aggregators, + iconUrl: sampleTokenA.image, + }, + }, + timestamp: 0, + }, + }, + }); + + triggerNetworkDidChange({ + ...getDefaultNetworkControllerState(), + selectedNetworkClientId: 'avalanche', + }); + await jestAdvanceTime({ duration: 1 }); + + expect(callActionSpy).not.toHaveBeenCalledWith( + 'TokensController:addDetectedTokens', + ); + }, + ); + }); + + describe('when keyring is locked', () => { + it('should not detect new tokens after switching network client id', async () => { + const mockGetBalancesInSingleCall = jest.fn().mockResolvedValue({ + [sampleTokenA.address]: new BN(1), + }); + const selectedAccount = createMockInternalAccount({ + address: '0x0000000000000000000000000000000000000001', + }); + await withController( + { + options: { + disabled: false, + getBalancesInSingleCall: mockGetBalancesInSingleCall, + }, + isKeyringUnlocked: false, + mocks: { + getAccount: selectedAccount, + getSelectedAccount: selectedAccount, + }, + }, + async ({ + mockTokenListGetState, + callActionSpy, + triggerNetworkDidChange, + }) => { + mockTokenListGetState({ + ...getDefaultTokenListState(), + tokensChainsCache: { + [ChainId.sepolia]: { + data: { + [sampleTokenA.address]: { + name: sampleTokenA.name, + symbol: sampleTokenA.symbol, + decimals: sampleTokenA.decimals, + address: sampleTokenA.address, + occurrences: 1, + aggregators: sampleTokenA.aggregators, + iconUrl: sampleTokenA.image, + }, + }, + timestamp: 0, + }, + }, + }); + + triggerNetworkDidChange({ + ...getDefaultNetworkControllerState(), + selectedNetworkClientId: 'avalanche', + }); + await jestAdvanceTime({ duration: 1 }); + + expect(callActionSpy).not.toHaveBeenCalledWith( + 'TokensController:addDetectedTokens', + ); + }, + ); + }); + }); + }); + + describe('when "disabled" is true', () => { + it('should not detect new tokens after switching network client id', async () => { + const mockGetBalancesInSingleCall = jest.fn().mockResolvedValue({ + [sampleTokenA.address]: new BN(1), + }); + const selectedAccount = createMockInternalAccount({ + address: '0x0000000000000000000000000000000000000001', + }); + await withController( + { + options: { + disabled: true, + getBalancesInSingleCall: mockGetBalancesInSingleCall, + }, + mocks: { + getAccount: selectedAccount, + getSelectedAccount: selectedAccount, + }, + }, + async ({ + mockTokenListGetState, + callActionSpy, + triggerNetworkDidChange, + }) => { + mockTokenListGetState({ + ...getDefaultTokenListState(), + tokensChainsCache: { + [ChainId.sepolia]: { + data: { + [sampleTokenA.address]: { + name: sampleTokenA.name, + symbol: sampleTokenA.symbol, + decimals: sampleTokenA.decimals, + address: sampleTokenA.address, + occurrences: 1, + aggregators: sampleTokenA.aggregators, + iconUrl: sampleTokenA.image, + }, + }, + timestamp: 0, + }, + }, + }); + + triggerNetworkDidChange({ + ...getDefaultNetworkControllerState(), + selectedNetworkClientId: 'avalanche', + }); + await jestAdvanceTime({ duration: 1 }); + + expect(callActionSpy).not.toHaveBeenCalledWith( + 'TokensController:addDetectedTokens', + ); + }, + ); + }); }); - await tokenDetection.start(); - expect(tokensController.state.detectedTokens).toStrictEqual([sampleTokenA]); }); - it('should detect tokens correctly on the Aurora network', async () => { - const auroraMainnet = { - chainId: ChainId.aurora, - type: NetworkType.mainnet, - ticker: 'Aurora ETH', - }; - preferences.update({ selectedAddress: '0x1' }); - changeNetwork(auroraMainnet); + describe('startPolling', () => { + beforeEach(() => { + jest.useFakeTimers(); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + it('should call detect tokens with networkClientId and address params', async () => { + const mockGetBalancesInSingleCall = jest.fn().mockResolvedValue({ + [sampleTokenA.address]: new BN(1), + }); + const selectedAccount = createMockInternalAccount({ + address: '0x0000000000000000000000000000000000000001', + }); + await withController( + { + options: { + disabled: false, + getBalancesInSingleCall: mockGetBalancesInSingleCall, + }, + mocks: { + getSelectedAccount: selectedAccount, + getAccount: selectedAccount, + }, + }, + async ({ controller, mockTokenListGetState }) => { + mockTokenListGetState({ + ...getDefaultTokenListState(), + tokensChainsCache: { + [ChainId.sepolia]: { + data: { + [sampleTokenA.address]: { + name: sampleTokenA.name, + symbol: sampleTokenA.symbol, + decimals: sampleTokenA.decimals, + address: sampleTokenA.address, + occurrences: 1, + aggregators: sampleTokenA.aggregators, + iconUrl: sampleTokenA.image, + }, + }, + timestamp: 0, + }, + }, + }); + const spy = jest + .spyOn(controller, 'detectTokens') + .mockImplementation(() => { + return Promise.resolve(); + }); + + controller.startPolling({ + chainIds: ['0xa86a'], + address: '0x1', + }); + controller.startPolling({ + chainIds: ['0xa86a'], + address: '0xdeadbeef', + }); + controller.startPolling({ + chainIds: ['0x5'], + address: '0x3', + }); + await jestAdvanceTime({ duration: 0 }); - getBalancesInSingleCall.resolves({ - [sampleTokenA.address]: new BN(1), + expect(spy.mock.calls).toMatchObject([ + [{ chainIds: ['0xa86a'], selectedAddress: '0x1' }], + [{ chainIds: ['0xa86a'], selectedAddress: '0xdeadbeef' }], + [{ chainIds: ['0x5'], selectedAddress: '0x3' }], + ]); + + await jestAdvanceTime({ duration: DEFAULT_INTERVAL }); + expect(spy.mock.calls).toMatchObject([ + [{ chainIds: ['0xa86a'], selectedAddress: '0x1' }], + [{ chainIds: ['0xa86a'], selectedAddress: '0xdeadbeef' }], + [{ chainIds: ['0x5'], selectedAddress: '0x3' }], + [{ chainIds: ['0xa86a'], selectedAddress: '0x1' }], + [{ chainIds: ['0xa86a'], selectedAddress: '0xdeadbeef' }], + [{ chainIds: ['0x5'], selectedAddress: '0x3' }], + ]); + }, + ); }); - await tokenDetection.start(); - expect(tokensController.state.detectedTokens).toStrictEqual([sampleTokenA]); }); - it('should update detectedTokens when new tokens are detected', async () => { - preferences.update({ selectedAddress: '0x1' }); - changeNetwork(mainnet); + describe('detectTokens', () => { + it('should not detect tokens if token detection is disabled and current network is not mainnet', async () => { + const mockGetBalancesInSingleCall = jest.fn().mockResolvedValue({ + [sampleTokenA.address]: new BN(1), + }); + const selectedAccount = createMockInternalAccount({ + address: '0x0000000000000000000000000000000000000001', + }); + await withController( + { + options: { + disabled: false, + getBalancesInSingleCall: mockGetBalancesInSingleCall, + }, + mocks: { + getSelectedAccount: selectedAccount, + getAccount: selectedAccount, + }, + }, + async ({ + controller, + mockNetworkState, + triggerPreferencesStateChange, + callActionSpy, + }) => { + mockNetworkState({ + ...getDefaultNetworkControllerState(), + selectedNetworkClientId: NetworkType.sepolia, + }); + triggerPreferencesStateChange({ + ...getDefaultPreferencesState(), + useTokenDetection: false, + }); + await controller.detectTokens({ + chainIds: [ChainId.sepolia], + selectedAddress: selectedAccount.address, + }); + expect(callActionSpy).not.toHaveBeenCalledWith( + 'TokensController:addDetectedTokens', + ); + }, + ); + }); + + // Note: Test for mainnet legacy token list detection has been removed. + // Mainnet is now in SUPPORTED_NETWORKS_ACCOUNTS_API_V4, so RPC detection is skipped. + // Token detection for mainnet is handled via TokenBalancesController (Accounts API). + + it('should detect and add tokens by networkClientId correctly', async () => { + const mockGetBalancesInSingleCall = jest.fn().mockResolvedValue({ + [sampleTokenA.address]: new BN(1), + }); + const selectedAccount = createMockInternalAccount({ + address: '0x0000000000000000000000000000000000000001', + }); + await withController( + { + options: { + disabled: false, + getBalancesInSingleCall: mockGetBalancesInSingleCall, + }, + mocks: { + getSelectedAccount: selectedAccount, + getAccount: selectedAccount, + }, + }, + async ({ + controller, + mockTokenListGetState, + callActionSpy, + mockNetworkState, + }) => { + // Include Avalanche in networkConfigurationsByChainId for explicit chainId lookup + const defaultState = getDefaultNetworkControllerState(); + mockNetworkState({ + ...defaultState, + networkConfigurationsByChainId: { + ...defaultState.networkConfigurationsByChainId, + ...mockNetworkConfigurationsByChainId, + }, + }); + mockTokenListGetState({ + ...getDefaultTokenListState(), + tokensChainsCache: { + '0xa86a': { + timestamp: 0, + data: { + [sampleTokenA.address]: { + name: sampleTokenA.name, + symbol: sampleTokenA.symbol, + decimals: sampleTokenA.decimals, + address: sampleTokenA.address, + occurrences: 1, + aggregators: sampleTokenA.aggregators, + iconUrl: sampleTokenA.image, + }, + }, + }, + }, + }); - await tokenDetection.start(); + await controller.detectTokens({ + chainIds: ['0xa86a'], + selectedAddress: selectedAccount.address, + }); - getBalancesInSingleCall.resolves({ - [sampleTokenA.address]: new BN(1), + expect(callActionSpy).toHaveBeenCalledWith( + 'TokensController:addTokens', + [sampleTokenA], + 'avalanche', + ); + }, + ); }); - await tokenDetection.detectTokens(); - expect(tokensController.state.detectedTokens).toStrictEqual([sampleTokenA]); - getBalancesInSingleCall.resolves({ - [sampleTokenB.address]: new BN(1), + it('should invoke the `trackMetaMetricsEvent` callback when token detection is triggered', async () => { + const mockGetBalancesInSingleCall = jest.fn().mockResolvedValue({ + [sampleTokenA.address]: new BN(1), + }); + const selectedAccount = createMockInternalAccount({ + address: '0x0000000000000000000000000000000000000001', + }); + const mockTrackMetaMetricsEvent = jest.fn(); + + await withController( + { + options: { + disabled: false, + getBalancesInSingleCall: mockGetBalancesInSingleCall, + trackMetaMetricsEvent: mockTrackMetaMetricsEvent, + }, + mocks: { + getSelectedAccount: selectedAccount, + getAccount: selectedAccount, + }, + }, + async ({ controller, mockTokenListGetState, mockNetworkState }) => { + // Include Avalanche in networkConfigurationsByChainId for explicit chainId lookup + const defaultState = getDefaultNetworkControllerState(); + mockNetworkState({ + ...defaultState, + networkConfigurationsByChainId: { + ...defaultState.networkConfigurationsByChainId, + ...mockNetworkConfigurationsByChainId, + }, + }); + mockTokenListGetState({ + ...getDefaultTokenListState(), + tokensChainsCache: { + '0xa86a': { + timestamp: 0, + data: { + [sampleTokenA.address]: { + name: sampleTokenA.name, + symbol: sampleTokenA.symbol, + decimals: sampleTokenA.decimals, + address: sampleTokenA.address, + occurrences: 1, + aggregators: sampleTokenA.aggregators, + iconUrl: sampleTokenA.image, + }, + }, + }, + }, + }); + + await controller.detectTokens({ + chainIds: ['0xa86a'], + selectedAddress: selectedAccount.address, + }); + + expect(mockTrackMetaMetricsEvent).toHaveBeenCalledWith({ + event: 'Token Detected', + category: 'Wallet', + properties: { + tokens: [`${sampleTokenA.symbol} - ${sampleTokenA.address}`], + token_standard: 'ERC20', + asset_type: 'TOKEN', + }, + }); + }, + ); }); - await tokenDetection.detectTokens(); - expect(tokensController.state.detectedTokens).toStrictEqual([ - sampleTokenA, - sampleTokenB, - ]); - }); - it('should not add ignoredTokens to the tokens list if detected with balance', async () => { - preferences.setSelectedAddress('0x0001'); + it('does not trigger `TokensController:addDetectedTokens` action when selectedAccount is not found', async () => { + const mockGetBalancesInSingleCall = jest.fn().mockResolvedValue({ + [sampleTokenA.address]: new BN(1), + }); + + const mockTrackMetaMetricsEvent = jest.fn(); - changeNetwork(mainnet); + await withController( + { + options: { + disabled: false, + getBalancesInSingleCall: mockGetBalancesInSingleCall, + trackMetaMetricsEvent: mockTrackMetaMetricsEvent, + }, + }, + async ({ + controller, + mockGetAccount, + mockTokenListGetState, + callActionSpy, + mockNetworkState, + }) => { + // Include Avalanche in networkConfigurationsByChainId for explicit chainId lookup + const defaultState = getDefaultNetworkControllerState(); + mockNetworkState({ + ...defaultState, + networkConfigurationsByChainId: { + ...defaultState.networkConfigurationsByChainId, + ...mockNetworkConfigurationsByChainId, + }, + }); + // @ts-expect-error forcing an undefined value + mockGetAccount(undefined); + mockTokenListGetState({ + ...getDefaultTokenListState(), + tokensChainsCache: { + '0xa86a': { + timestamp: 0, + data: { + [sampleTokenA.address]: { + name: sampleTokenA.name, + symbol: sampleTokenA.symbol, + decimals: sampleTokenA.decimals, + address: sampleTokenA.address, + occurrences: 1, + aggregators: sampleTokenA.aggregators, + iconUrl: sampleTokenA.image, + }, + }, + }, + }, + }); - await tokenDetection.start(); + await controller.detectTokens({ + chainIds: ['0xa86a'], + }); - await tokensController.addToken({ - address: sampleTokenA.address, - symbol: sampleTokenA.symbol, - decimals: sampleTokenA.decimals, + expect(callActionSpy).toHaveBeenLastCalledWith( + 'TokensController:addTokens', + [ + { + address: '0x514910771AF9Ca656af840dff83E8264EcF986CA', + aggregators: [ + 'Paraswap', + 'PMM', + 'AirswapLight', + '0x', + 'Bancor', + 'CoinGecko', + 'Zapper', + 'Kleros', + 'Zerion', + 'CMC', + '1inch', + ], + decimals: 18, + image: + 'https://static.cx.metamask.io/api/v1/tokenIcons/1/0x514910771af9ca656af840dff83e8264ecf986ca.png', + isERC721: false, + name: 'Chainlink', + symbol: 'LINK', + }, + ], + 'avalanche', + ); + }, + ); }); - await tokensController.addToken({ - address: sampleTokenB.address, - symbol: sampleTokenB.symbol, - decimals: sampleTokenB.decimals, - name: sampleTokenB.name, + it('should fallback to rpc call', async () => { + const mockGetBalancesInSingleCall = jest.fn().mockResolvedValue({ + [sampleTokenA.address]: new BN(1), + }); + const selectedAccount = createMockInternalAccount({ + address: '0x0000000000000000000000000000000000000001', + }); + await withController( + { + options: { + disabled: false, + getBalancesInSingleCall: mockGetBalancesInSingleCall, + }, + mocks: { + getSelectedAccount: selectedAccount, + getAccount: selectedAccount, + }, + }, + async ({ + controller, + mockNetworkState, + triggerPreferencesStateChange, + callActionSpy, + }) => { + mockNetworkState({ + ...getDefaultNetworkControllerState(), + selectedNetworkClientId: 'polygon', + }); + triggerPreferencesStateChange({ + ...getDefaultPreferencesState(), + useTokenDetection: false, + }); + await controller.detectTokens({ + chainIds: [ChainId.sepolia], + selectedAddress: selectedAccount.address, + }); + expect(callActionSpy).not.toHaveBeenCalledWith( + 'TokensController:addDetectedTokens', + ); + }, + ); }); - tokensController.ignoreTokens([sampleTokenA.address]); + it('should detect tokens when TransactionController:transactionConfirmed is triggered', async () => { + const mockGetBalancesInSingleCall = jest.fn().mockResolvedValue({ + [sampleTokenA.address]: new BN(1), + }); + const selectedAccount = createMockInternalAccount({ + address: '0x0000000000000000000000000000000000000001', + }); + await withController( + { + options: { + disabled: false, + getBalancesInSingleCall: mockGetBalancesInSingleCall, + }, + mocks: { + getSelectedAccount: selectedAccount, + getAccount: selectedAccount, + }, + }, + async ({ + mockTokenListGetState, + mockNetworkState, + callActionSpy, + triggerTransactionConfirmed, + }) => { + const defaultState = getDefaultNetworkControllerState(); + mockNetworkState({ + ...defaultState, + selectedNetworkClientId: 'avalanche', + networkConfigurationsByChainId: { + ...defaultState.networkConfigurationsByChainId, + ...mockNetworkConfigurationsByChainId, + }, + }); + mockTokenListGetState({ + ...getDefaultTokenListState(), + tokensChainsCache: { + '0xa86a': { + timestamp: 0, + data: { + [sampleTokenA.address]: { + name: sampleTokenA.name, + symbol: sampleTokenA.symbol, + decimals: sampleTokenA.decimals, + address: sampleTokenA.address, + occurrences: 1, + aggregators: sampleTokenA.aggregators, + iconUrl: sampleTokenA.image, + }, + }, + }, + }, + }); + + triggerTransactionConfirmed({ chainId: '0xa86a' }); + // Wait for async detection to complete + await new Promise((resolve) => setTimeout(resolve, 10)); - getBalancesInSingleCall.resolves({ - [sampleTokenA.address]: new BN(1), + expect(callActionSpy).toHaveBeenCalledWith( + 'TokensController:addTokens', + [sampleTokenA], + 'avalanche', + ); + }, + ); }); - await tokenDetection.detectTokens(); - expect(tokensController.state.tokens).toStrictEqual([sampleTokenB]); - expect(tokensController.state.ignoredTokens).toStrictEqual([ - sampleTokenA.address, - ]); - }); + it('should not detect tokens when useExternalServices returns false', async () => { + const mockGetBalancesInSingleCall = jest.fn().mockResolvedValue({ + [sampleTokenA.address]: new BN(1), + }); + const selectedAccount = createMockInternalAccount({ + address: '0x0000000000000000000000000000000000000001', + }); + await withController( + { + options: { + disabled: false, + getBalancesInSingleCall: mockGetBalancesInSingleCall, + useExternalServices: () => false, + }, + mocks: { + getSelectedAccount: selectedAccount, + getAccount: selectedAccount, + }, + }, + async ({ controller, callActionSpy }) => { + await controller.detectTokens(); + + expect(callActionSpy).not.toHaveBeenCalledWith( + 'TokensController:addTokens', + ); + expect(callActionSpy).not.toHaveBeenCalledWith( + 'TokensController:addDetectedTokens', + ); + }, + ); + }); - it('should add a token when detected with a balance even if it is ignored on another account', async () => { - preferences.setSelectedAddress('0x0001'); - changeNetwork(mainnet); + it('should not detect tokens when no client networks are found', async () => { + const mockGetBalancesInSingleCall = jest.fn().mockResolvedValue({ + [sampleTokenA.address]: new BN(1), + }); + const selectedAccount = createMockInternalAccount({ + address: '0x0000000000000000000000000000000000000001', + }); + await withController( + { + options: { + disabled: false, + getBalancesInSingleCall: mockGetBalancesInSingleCall, + }, + mocks: { + getSelectedAccount: selectedAccount, + getAccount: selectedAccount, + }, + }, + async ({ + controller, + mockNetworkState, + mockGetNetworkConfigurationByNetworkClientId, + callActionSpy, + }) => { + mockNetworkState({ + ...getDefaultNetworkControllerState(), + selectedNetworkClientId: 'unknown-network', + }); + // Return undefined for unknown network to simulate no network config + mockGetNetworkConfigurationByNetworkClientId( + () => undefined as never, + ); - await tokenDetection.start(); + await controller.detectTokens(); - await tokensController.addToken({ - address: sampleTokenA.address, - symbol: sampleTokenA.symbol, - decimals: sampleTokenA.decimals, + expect(callActionSpy).not.toHaveBeenCalledWith( + 'TokensController:addTokens', + ); + }, + ); }); - tokensController.ignoreTokens([sampleTokenA.address]); + it('should filter out tokens that are already owned by the user', async () => { + const mockGetBalancesInSingleCall = jest.fn().mockResolvedValue({ + [sampleTokenA.address]: new BN(1), + }); + const selectedAccount = createMockInternalAccount({ + address: '0x0000000000000000000000000000000000000001', + }); + await withController( + { + options: { + disabled: false, + getBalancesInSingleCall: mockGetBalancesInSingleCall, + }, + mocks: { + getSelectedAccount: selectedAccount, + getAccount: selectedAccount, + }, + }, + async ({ + controller, + mockNetworkState, + mockTokenListGetState, + mockTokensGetState, + callActionSpy, + }) => { + const defaultState = getDefaultNetworkControllerState(); + mockNetworkState({ + ...defaultState, + selectedNetworkClientId: 'avalanche', + networkConfigurationsByChainId: { + ...defaultState.networkConfigurationsByChainId, + ...mockNetworkConfigurationsByChainId, + }, + }); + mockTokenListGetState({ + ...getDefaultTokenListState(), + tokensChainsCache: { + '0xa86a': { + timestamp: 0, + data: { + [sampleTokenA.address]: { + name: sampleTokenA.name, + symbol: sampleTokenA.symbol, + decimals: sampleTokenA.decimals, + address: sampleTokenA.address, + occurrences: 1, + aggregators: sampleTokenA.aggregators, + iconUrl: sampleTokenA.image, + }, + }, + }, + }, + }); + // Mock that the user already owns this token + mockTokensGetState({ + ...getDefaultTokensState(), + allTokens: { + '0xa86a': { + [selectedAccount.address]: [ + { address: sampleTokenA.address } as Token, + ], + }, + }, + }); - preferences.setSelectedAddress('0x0002'); + await controller.detectTokens(); - getBalancesInSingleCall.resolves({ - [sampleTokenA.address]: new BN(1), + // Should not call addTokens since token is already owned + expect(callActionSpy).not.toHaveBeenCalledWith( + 'TokensController:addTokens', + expect.anything(), + 'avalanche', + ); + }, + ); }); - await tokenDetection.detectTokens(); - expect(tokensController.state.detectedTokens).toStrictEqual([sampleTokenA]); - }); - it('should not autodetect tokens that exist in the ignoreList', async () => { - preferences.update({ selectedAddress: '0x1' }); - changeNetwork(mainnet); + it('should use static mainnet token list when token detection is disabled for mainnet', async () => { + const mockGetBalancesInSingleCall = jest.fn().mockResolvedValue({ + '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48': new BN(1), // USDC on mainnet + }); + const selectedAccount = createMockInternalAccount({ + address: '0x0000000000000000000000000000000000000001', + }); + await withController( + { + options: { + disabled: false, + getBalancesInSingleCall: mockGetBalancesInSingleCall, + }, + mocks: { + getSelectedAccount: selectedAccount, + getAccount: selectedAccount, + }, + }, + async ({ + controller, + mockNetworkState, + mockFindNetworkClientIdByChainId, + triggerPreferencesStateChange, + }) => { + const defaultState = getDefaultNetworkControllerState(); + mockNetworkState({ + ...defaultState, + selectedNetworkClientId: 'mainnet', + networkConfigurationsByChainId: { + ...defaultState.networkConfigurationsByChainId, + '0x1': { + chainId: '0x1', + name: 'Ethereum Mainnet', + nativeCurrency: 'ETH', + blockExplorerUrls: [], + defaultBlockExplorerUrlIndex: 0, + defaultRpcEndpointIndex: 0, + rpcEndpoints: [ + { + networkClientId: 'mainnet', + type: RpcEndpointType.Custom, + url: 'https://mainnet.infura.io/v3/test', + failoverUrls: [], + }, + ], + }, + }, + }); + mockFindNetworkClientIdByChainId(() => 'mainnet'); + + // Disable token detection - this should trigger static mainnet token list usage + triggerPreferencesStateChange({ + ...getDefaultPreferencesState(), + useTokenDetection: false, + }); + + // Trigger detection with forceRpc to ensure we test the static token list path + await controller.detectTokens({ + chainIds: [ChainId.mainnet], + forceRpc: true, + }); + + // The detection should have been attempted (static token list is used internally) + // We verify the getBalancesInSingleCall was called, indicating detection ran + expect(mockGetBalancesInSingleCall).toHaveBeenCalled(); + }, + ); + }); + + it('should skip chains supported by Accounts API when forceRpc is false', async () => { + const mockGetBalancesInSingleCall = jest.fn().mockResolvedValue({ + [sampleTokenA.address]: new BN(1), + }); + const selectedAccount = createMockInternalAccount({ + address: '0x0000000000000000000000000000000000000001', + }); + await withController( + { + options: { + disabled: false, + getBalancesInSingleCall: mockGetBalancesInSingleCall, + }, + mocks: { + getSelectedAccount: selectedAccount, + getAccount: selectedAccount, + }, + }, + async ({ + controller, + mockNetworkState, + mockFindNetworkClientIdByChainId, + }) => { + const defaultState = getDefaultNetworkControllerState(); + mockNetworkState({ + ...defaultState, + selectedNetworkClientId: 'mainnet', + networkConfigurationsByChainId: { + ...defaultState.networkConfigurationsByChainId, + '0x1': { + chainId: '0x1', + name: 'Ethereum Mainnet', + nativeCurrency: 'ETH', + blockExplorerUrls: [], + defaultBlockExplorerUrlIndex: 0, + defaultRpcEndpointIndex: 0, + rpcEndpoints: [ + { + networkClientId: 'mainnet', + type: RpcEndpointType.Custom, + url: 'https://mainnet.infura.io/v3/test', + failoverUrls: [], + }, + ], + }, + }, + }); + mockFindNetworkClientIdByChainId(() => 'mainnet'); + + // Call detectTokens with mainnet (which is in SUPPORTED_NETWORKS_ACCOUNTS_API_V4) + // Without forceRpc, it should skip mainnet + await controller.detectTokens({ + chainIds: [ChainId.mainnet], + }); + + // Should NOT call getBalancesInSingleCall since mainnet is skipped + expect(mockGetBalancesInSingleCall).not.toHaveBeenCalled(); + }, + ); + }); + + it('should detect tokens on Accounts API supported chains when forceRpc is true', async () => { + const mainnetUSDC = '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48'; + const mockGetBalancesInSingleCall = jest.fn().mockResolvedValue({ + [mainnetUSDC]: new BN(1), + }); + const selectedAccount = createMockInternalAccount({ + address: '0x0000000000000000000000000000000000000001', + }); + await withController( + { + options: { + disabled: false, + getBalancesInSingleCall: mockGetBalancesInSingleCall, + }, + mocks: { + getSelectedAccount: selectedAccount, + getAccount: selectedAccount, + }, + }, + async ({ + controller, + mockNetworkState, + mockFindNetworkClientIdByChainId, + mockTokenListGetState, + triggerPreferencesStateChange, + }) => { + const defaultState = getDefaultNetworkControllerState(); + mockNetworkState({ + ...defaultState, + selectedNetworkClientId: 'mainnet', + networkConfigurationsByChainId: { + ...defaultState.networkConfigurationsByChainId, + '0x1': { + chainId: '0x1', + name: 'Ethereum Mainnet', + nativeCurrency: 'ETH', + blockExplorerUrls: [], + defaultBlockExplorerUrlIndex: 0, + defaultRpcEndpointIndex: 0, + rpcEndpoints: [ + { + networkClientId: 'mainnet', + type: RpcEndpointType.Custom, + url: 'https://mainnet.infura.io/v3/test', + failoverUrls: [], + }, + ], + }, + }, + }); + mockFindNetworkClientIdByChainId(() => 'mainnet'); + + // Provide token list data for mainnet + mockTokenListGetState({ + ...getDefaultTokenListState(), + tokensChainsCache: { + '0x1': { + timestamp: 0, + data: { + [mainnetUSDC]: { + name: 'USD Coin', + symbol: 'USDC', + decimals: 6, + address: mainnetUSDC, + occurrences: 1, + aggregators: [], + iconUrl: '', + }, + }, + }, + }, + }); - await tokenDetection.start(); + // Enable token detection for mainnet + triggerPreferencesStateChange({ + ...getDefaultPreferencesState(), + useTokenDetection: true, + }); - getBalancesInSingleCall.resolves({ - [sampleTokenA.address]: new BN(1), + // Call detectTokens with forceRpc: true to force RPC detection on mainnet + await controller.detectTokens({ + chainIds: [ChainId.mainnet], + forceRpc: true, + }); + + // Should call getBalancesInSingleCall since forceRpc bypasses Accounts API filter + expect(mockGetBalancesInSingleCall).toHaveBeenCalled(); + }, + ); }); - await tokenDetection.detectTokens(); - expect(tokensController.state.detectedTokens).toStrictEqual([sampleTokenA]); + it('adds mUSD from the token list cache when RPC reports zero balance', async () => { + const mockGetBalancesInSingleCall = jest.fn().mockResolvedValue({}); + const selectedAccount = createMockInternalAccount({ + address: '0x0000000000000000000000000000000000000001', + }); + await withController( + { + options: { + disabled: false, + getBalancesInSingleCall: mockGetBalancesInSingleCall, + }, + mocks: { + getSelectedAccount: selectedAccount, + getAccount: selectedAccount, + }, + }, + async ({ + controller, + mockNetworkState, + mockFindNetworkClientIdByChainId, + mockTokenListGetState, + triggerPreferencesStateChange, + callActionSpy, + }) => { + const defaultState = getDefaultNetworkControllerState(); + mockNetworkState({ + ...defaultState, + selectedNetworkClientId: 'mainnet', + networkConfigurationsByChainId: { + ...defaultState.networkConfigurationsByChainId, + '0x1': { + chainId: '0x1', + name: 'Ethereum Mainnet', + nativeCurrency: 'ETH', + blockExplorerUrls: [], + defaultBlockExplorerUrlIndex: 0, + defaultRpcEndpointIndex: 0, + rpcEndpoints: [ + { + networkClientId: 'mainnet', + type: RpcEndpointType.Custom, + url: 'https://mainnet.infura.io/v3/test', + failoverUrls: [], + }, + ], + }, + }, + }); + mockFindNetworkClientIdByChainId(() => 'mainnet'); + + mockTokenListGetState({ + ...getDefaultTokenListState(), + tokensChainsCache: { + '0x1': { + timestamp: 0, + data: {}, + }, + }, + }); - tokensController.ignoreTokens([sampleTokenA.address]); - await tokenDetection.detectTokens(); - expect(tokensController.state.detectedTokens).toStrictEqual([]); + triggerPreferencesStateChange({ + ...getDefaultPreferencesState(), + useTokenDetection: true, + }); + + await controller.detectTokens({ + chainIds: [ChainId.mainnet], + selectedAddress: selectedAccount.address, + forceRpc: true, + }); + + expect(mockGetBalancesInSingleCall).toHaveBeenCalled(); + expect(callActionSpy).toHaveBeenCalledWith( + 'TokensController:addTokens', + expect.arrayContaining([ + expect.objectContaining({ + address: MUSD_ERC20_ADDRESS_LOWER, + name: 'MetaMask USD', + symbol: 'MUSD', + decimals: 6, + }), + ]), + 'mainnet', + ); + }, + ); + }); }); - it('should not detect tokens if there is no selectedAddress set', async () => { - await tokenDetection.start(); - getBalancesInSingleCall.resolves({ - [sampleTokenA.address]: new BN(1), + describe('constructor options', () => { + describe('useTokenDetection', () => { + it('should disable token detection when useTokenDetection is false', async () => { + const mockGetBalancesInSingleCall = jest.fn(); + + await withController( + { + options: { + useTokenDetection: () => false, + disabled: false, + getBalancesInSingleCall: mockGetBalancesInSingleCall, + }, + mocks: { + getSelectedAccount: defaultSelectedAccount, + }, + }, + async ({ controller }) => { + // Try to detect tokens + await controller.detectTokens(); + + // Should not call getBalancesInSingleCall when useTokenDetection is false + expect(mockGetBalancesInSingleCall).not.toHaveBeenCalled(); + }, + ); + }); + + it('should enable token detection when useTokenDetection is true (default)', async () => { + const mockGetBalancesInSingleCall = jest.fn().mockResolvedValue({}); + + await withController( + { + options: { + useTokenDetection: () => true, + disabled: false, + getBalancesInSingleCall: mockGetBalancesInSingleCall, + }, + mocks: { + getSelectedAccount: defaultSelectedAccount, + }, + }, + async ({ controller, mockTokenListGetState, mockNetworkState }) => { + // Set selectedNetworkClientId to avalanche (not in SUPPORTED_NETWORKS_ACCOUNTS_API_V4) + mockNetworkState({ + ...getDefaultNetworkControllerState(), + selectedNetworkClientId: 'avalanche', + }); + mockTokenListGetState({ + ...getDefaultTokenListState(), + tokensChainsCache: { + '0xa86a': { + timestamp: 0, + data: { + [sampleTokenA.address]: { + name: sampleTokenA.name, + symbol: sampleTokenA.symbol, + decimals: sampleTokenA.decimals, + address: sampleTokenA.address, + aggregators: sampleTokenA.aggregators, + iconUrl: sampleTokenA.image, + occurrences: 11, + }, + }, + }, + }, + }); + + // Start the controller to make it active + await controller.start(); + // Try to detect tokens + await controller.detectTokens(); + + // Should call getBalancesInSingleCall when useTokenDetection is true + expect(mockGetBalancesInSingleCall).toHaveBeenCalled(); + }, + ); + }); + + it('should not start polling when useTokenDetection is false', async () => { + const mockGetBalancesInSingleCall = jest.fn(); + + await withController( + { + options: { + useTokenDetection: () => false, + disabled: false, + getBalancesInSingleCall: mockGetBalancesInSingleCall, + }, + mocks: { + getSelectedAccount: defaultSelectedAccount, + }, + }, + async ({ controller }) => { + await controller.start(); + + // Should not call getBalancesInSingleCall during start when useTokenDetection is false + expect(mockGetBalancesInSingleCall).not.toHaveBeenCalled(); + }, + ); + }); + + it('should start polling when useTokenDetection is true (default)', async () => { + const mockGetBalancesInSingleCall = jest.fn().mockResolvedValue({}); + + await withController( + { + options: { + useTokenDetection: () => true, + disabled: false, + getBalancesInSingleCall: mockGetBalancesInSingleCall, + }, + mocks: { + getSelectedAccount: defaultSelectedAccount, + }, + }, + async ({ controller, mockTokenListGetState, mockNetworkState }) => { + // Set selectedNetworkClientId to avalanche (not in SUPPORTED_NETWORKS_ACCOUNTS_API_V4) + mockNetworkState({ + ...getDefaultNetworkControllerState(), + selectedNetworkClientId: 'avalanche', + }); + mockTokenListGetState({ + ...getDefaultTokenListState(), + tokensChainsCache: { + '0xa86a': { + timestamp: 0, + data: { + [sampleTokenA.address]: { + name: sampleTokenA.name, + symbol: sampleTokenA.symbol, + decimals: sampleTokenA.decimals, + address: sampleTokenA.address, + aggregators: sampleTokenA.aggregators, + iconUrl: sampleTokenA.image, + occurrences: 11, + }, + }, + }, + }, + }); + + await controller.start(); + + // Should call getBalancesInSingleCall during start when useTokenDetection is true + expect(mockGetBalancesInSingleCall).toHaveBeenCalled(); + }, + ); + }); }); - await tokenDetection.detectTokens(); - expect(tokensController.state.detectedTokens).toStrictEqual([]); }); - it('should detect new tokens after switching between accounts', async () => { - preferences.setSelectedAddress('0x0001'); - changeNetwork(mainnet); + describe('addDetectedTokensViaWs', () => { + it('should add tokens detected from websocket with metadata from cache', async () => { + const mockTokenAddress = '0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48'; + const checksummedTokenAddress = + '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48'; + const chainId = '0xa86a'; + + await withController( + { + options: { + disabled: false, + }, + mockTokenListState: { + tokensChainsCache: { + [chainId]: { + timestamp: 0, + data: { + [mockTokenAddress]: { + name: 'USD Coin', + symbol: 'USDC', + decimals: 6, + address: mockTokenAddress, + aggregators: [], + iconUrl: 'https://example.com/usdc.png', + occurrences: 11, + }, + }, + }, + }, + }, + }, + async ({ controller, callActionSpy }) => { + await controller.addDetectedTokensViaWs({ + tokensSlice: [mockTokenAddress], + chainId: chainId as Hex, + }); - getBalancesInSingleCall.resolves({ - [sampleTokenA.address]: new BN(1), + expect(callActionSpy).toHaveBeenCalledWith( + 'TokensController:addTokens', + [ + { + address: checksummedTokenAddress, + decimals: 6, + symbol: 'USDC', + aggregators: [], + image: 'https://example.com/usdc.png', + isERC721: false, + name: 'USD Coin', + }, + ], + 'avalanche', + ); + }, + ); }); - await tokenDetection.start(); - expect(tokensController.state.detectedTokens).toStrictEqual([sampleTokenA]); - preferences.setSelectedAddress('0x0002'); - await tokenDetection.detectTokens(); - expect(tokensController.state.detectedTokens).toStrictEqual([sampleTokenA]); - }); + it('adds mUSD from merged token list when WebSocket provides no token addresses (zero balance)', async () => { + await withController( + { + options: { disabled: false }, + mockTokenListState: { + tokensChainsCache: { + [ChainId.mainnet]: { + timestamp: 0, + data: {}, + }, + }, + }, + }, + async ({ + controller, + callActionSpy, + mockFindNetworkClientIdByChainId, + }) => { + mockFindNetworkClientIdByChainId(() => 'mainnet'); + await controller.addDetectedTokensViaWs({ + tokensSlice: [], + chainId: ChainId.mainnet, + }); + expect(callActionSpy).toHaveBeenCalledWith( + 'TokensController:addTokens', + expect.arrayContaining([ + expect.objectContaining({ + address: toChecksumHexAddress(MUSD_ERC20_ADDRESS_LOWER), + name: 'MetaMask USD', + symbol: 'MUSD', + decimals: 6, + }), + ]), + 'mainnet', + ); + }, + ); + }); - it('should not call getBalancesInSingleCall after stopping polling, and then switching between networks that support token detection', async () => { - const polygonDecimalChainId = '137'; - nock(TOKEN_END_POINT_API) - .get(`/tokens/${polygonDecimalChainId}`) - .reply(200, sampleTokenList); - - const stub = sinon.stub(); - const getBalancesInSingleCallMock = sinon.stub(); - let networkStateChangeListener: (state: any) => void; - const onNetworkStateChange = sinon.stub().callsFake((listener) => { - networkStateChangeListener = listener; - }); - - tokenDetection = new TokenDetectionController( - { - onTokenListStateChange: stub, - onPreferencesStateChange: stub, - onNetworkStateChange, - getBalancesInSingleCall: getBalancesInSingleCallMock, - addDetectedTokens: stub, - getTokensState: () => tokensController.state, - getTokenListState: () => tokenList.state, - getNetworkState: () => defaultNetworkState, - getPreferencesState: () => preferences.state, - }, - { - disabled: false, - isDetectionEnabledForNetwork: true, - isDetectionEnabledFromPreferences: true, - selectedAddress: '0x1', - chainId: ChainId.mainnet, - }, - ); + it('should skip tokens not found in cache', async () => { + const mockTokenAddress = '0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48'; + const chainId = '0xa86a'; - await tokenDetection.start(); + await withController( + { + options: { + disabled: false, + }, + mockTokenListState: { + tokensChainsCache: { + [chainId]: { + timestamp: 0, + data: {}, + }, + }, + }, + }, + async ({ controller, callActionSpy }) => { + await controller.addDetectedTokensViaWs({ + tokensSlice: [mockTokenAddress], + chainId: chainId as Hex, + }); - expect(getBalancesInSingleCallMock.called).toBe(true); - getBalancesInSingleCallMock.reset(); + // Should not call addTokens if no tokens have metadata + expect(callActionSpy).not.toHaveBeenCalledWith( + 'TokensController:addTokens', + expect.anything(), + expect.anything(), + ); + }, + ); + }); - tokenDetection.stop(); - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - await networkStateChangeListener!({ - providerConfig: { chainId: toHex(polygonDecimalChainId) }, + it('should add all tokens provided without filtering (filtering is caller responsibility)', async () => { + const mockTokenAddress = '0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48'; + const checksummedTokenAddress = + '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48'; + const secondTokenAddress = '0x1f573d6fb3f13d689ff844b4ce37794d79a7ff1c'; + const checksummedSecondTokenAddress = + '0x1F573D6Fb3F13d689FF844B4cE37794d79a7FF1C'; + const chainId = '0xa86a'; + const selectedAccount = createMockInternalAccount({ + address: '0x0000000000000000000000000000000000000001', + }); + + await withController( + { + options: { + disabled: false, + }, + mocks: { + getSelectedAccount: selectedAccount, + getAccount: selectedAccount, + }, + mockTokenListState: { + tokensChainsCache: { + [chainId]: { + timestamp: 0, + data: { + [mockTokenAddress]: { + name: 'USD Coin', + symbol: 'USDC', + decimals: 6, + address: mockTokenAddress, + aggregators: [], + iconUrl: 'https://example.com/usdc.png', + occurrences: 11, + }, + [secondTokenAddress]: { + name: 'Bancor', + symbol: 'BNT', + decimals: 18, + address: secondTokenAddress, + aggregators: [], + iconUrl: 'https://example.com/bnt.png', + occurrences: 11, + }, + }, + }, + }, + }, + }, + async ({ controller, callActionSpy }) => { + // Add both tokens via websocket + await controller.addDetectedTokensViaWs({ + tokensSlice: [mockTokenAddress, secondTokenAddress], + chainId: chainId as Hex, + }); + + // Should add both tokens (no filtering in addDetectedTokensViaWs) + expect(callActionSpy).toHaveBeenCalledWith( + 'TokensController:addTokens', + [ + { + address: checksummedTokenAddress, + decimals: 6, + symbol: 'USDC', + aggregators: [], + image: 'https://example.com/usdc.png', + isERC721: false, + name: 'USD Coin', + }, + { + address: checksummedSecondTokenAddress, + decimals: 18, + symbol: 'BNT', + aggregators: [], + image: 'https://example.com/bnt.png', + isERC721: false, + name: 'Bancor', + }, + ], + 'avalanche', + ); + }, + ); + }); + + it('should track metrics when adding tokens from websocket', async () => { + const mockTokenAddress = '0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48'; + const checksummedTokenAddress = + '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48'; + const chainId = '0xa86a'; + const mockTrackMetricsEvent = jest.fn(); + + await withController( + { + options: { + disabled: false, + trackMetaMetricsEvent: mockTrackMetricsEvent, + }, + mockTokenListState: { + tokensChainsCache: { + [chainId]: { + timestamp: 0, + data: { + [mockTokenAddress]: { + name: 'USD Coin', + symbol: 'USDC', + decimals: 6, + address: mockTokenAddress, + aggregators: [], + iconUrl: 'https://example.com/usdc.png', + occurrences: 11, + }, + }, + }, + }, + }, + }, + async ({ controller, callActionSpy }) => { + await controller.addDetectedTokensViaWs({ + tokensSlice: [mockTokenAddress], + chainId: chainId as Hex, + }); + + // Should track metrics event + expect(mockTrackMetricsEvent).toHaveBeenCalledWith({ + event: 'Token Detected', + category: 'Wallet', + properties: { + tokens: [`USDC - ${checksummedTokenAddress}`], + token_standard: 'ERC20', + asset_type: 'TOKEN', + }, + }); + + expect(callActionSpy).toHaveBeenCalledWith( + 'TokensController:addTokens', + expect.anything(), + expect.anything(), + ); + }, + ); + }); + + it('should be callable directly as a public method on the controller instance', async () => { + const mockTokenAddress = '0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48'; + const checksummedTokenAddress = + '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48'; + const chainId = '0xa86a'; + + await withController( + { + options: { + disabled: false, + }, + mockTokenListState: { + tokensChainsCache: { + [chainId]: { + timestamp: 0, + data: { + [mockTokenAddress]: { + name: 'USD Coin', + symbol: 'USDC', + decimals: 6, + address: mockTokenAddress, + aggregators: [], + iconUrl: 'https://example.com/usdc.png', + occurrences: 11, + }, + }, + }, + }, + }, + }, + async ({ controller, callActionSpy }) => { + // Call the public method directly on the controller instance + await controller.addDetectedTokensViaWs({ + tokensSlice: [mockTokenAddress], + chainId: chainId as Hex, + }); + + expect(callActionSpy).toHaveBeenCalledWith( + 'TokensController:addTokens', + [ + { + address: checksummedTokenAddress, + decimals: 6, + symbol: 'USDC', + aggregators: [], + image: 'https://example.com/usdc.png', + isERC721: false, + name: 'USD Coin', + }, + ], + 'avalanche', + ); + }, + ); }); - expect(getBalancesInSingleCallMock.called).toBe(false); + it('should not add tokens when useTokenDetection is false', async () => { + const mockTokenAddress = '0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48'; + const chainId = '0xa86a'; + + await withController( + { + options: { + disabled: false, + useTokenDetection: () => false, + }, + mockTokenListState: { + tokensChainsCache: { + [chainId]: { + timestamp: 0, + data: { + [mockTokenAddress]: { + name: 'USD Coin', + symbol: 'USDC', + decimals: 6, + address: mockTokenAddress, + aggregators: [], + iconUrl: 'https://example.com/usdc.png', + occurrences: 11, + }, + }, + }, + }, + }, + }, + async ({ controller, callActionSpy }) => { + await controller.addDetectedTokensViaWs({ + tokensSlice: [mockTokenAddress], + chainId: chainId as Hex, + }); + + expect(callActionSpy).not.toHaveBeenCalledWith( + 'TokensController:addTokens', + expect.anything(), + expect.anything(), + ); + }, + ); + }); + + it('should not add tokens when useExternalServices is false', async () => { + const mockTokenAddress = '0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48'; + const chainId = '0xa86a'; + + await withController( + { + options: { + disabled: false, + useExternalServices: () => false, + }, + mockTokenListState: { + tokensChainsCache: { + [chainId]: { + timestamp: 0, + data: { + [mockTokenAddress]: { + name: 'USD Coin', + symbol: 'USDC', + decimals: 6, + address: mockTokenAddress, + aggregators: [], + iconUrl: 'https://example.com/usdc.png', + occurrences: 11, + }, + }, + }, + }, + }, + }, + async ({ controller, callActionSpy }) => { + await controller.addDetectedTokensViaWs({ + tokensSlice: [mockTokenAddress], + chainId: chainId as Hex, + }); + + expect(callActionSpy).not.toHaveBeenCalledWith( + 'TokensController:addTokens', + expect.anything(), + expect.anything(), + ); + }, + ); + }); + + it('does not append a duplicate mUSD entry when the slice already includes mUSD', async () => { + const usdcAddress = '0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48'; + const usdcChecksummed = '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48'; + + await withController( + { + options: { disabled: false }, + mockTokenListState: { + tokensChainsCache: { + [ChainId.mainnet]: { + timestamp: 0, + data: { + [usdcAddress]: { + name: 'USD Coin', + symbol: 'USDC', + decimals: 6, + address: usdcAddress, + aggregators: [], + iconUrl: 'https://example.com/usdc.png', + occurrences: 11, + }, + }, + }, + }, + }, + }, + async ({ + controller, + callActionSpy, + mockFindNetworkClientIdByChainId, + }) => { + mockFindNetworkClientIdByChainId(() => 'mainnet'); + await controller.addDetectedTokensViaWs({ + tokensSlice: [ + toChecksumHexAddress(MUSD_ERC20_ADDRESS_LOWER), + usdcAddress, + ], + chainId: ChainId.mainnet, + }); + + expect(callActionSpy).toHaveBeenCalledWith( + 'TokensController:addTokens', + expect.arrayContaining([ + expect.objectContaining({ + address: toChecksumHexAddress(MUSD_ERC20_ADDRESS_LOWER), + }), + expect.objectContaining({ address: usdcChecksummed }), + ]), + 'mainnet', + ); + const addTokensCall = callActionSpy.mock.calls.find( + (call) => call[0] === 'TokensController:addTokens', + ); + const payload = addTokensCall?.[1] as { address: string }[]; + const musdRows = payload.filter( + (tokenRow) => + tokenRow.address.toLowerCase() === MUSD_ERC20_ADDRESS_LOWER, + ); + expect(musdRows).toHaveLength(1); + }, + ); + }); }); - it('should not call getBalancesInSingleCall if onTokenListStateChange is called with an empty token list', async () => { - const stub = sinon.stub(); - const getBalancesInSingleCallMock = sinon.stub(); - let tokenListStateChangeListener: (state: any) => void; - const onTokenListStateChange = sinon.stub().callsFake((listener) => { - tokenListStateChangeListener = listener; - }); - tokenDetection = new TokenDetectionController( - { - onTokenListStateChange, - onPreferencesStateChange: stub, - onNetworkStateChange: stub, - getBalancesInSingleCall: getBalancesInSingleCallMock, - addDetectedTokens: stub, - getTokensState: stub, - getTokenListState: stub, - getNetworkState: () => defaultNetworkState, - getPreferencesState: () => preferences.state, - }, - { - disabled: false, - isDetectionEnabledForNetwork: true, - isDetectionEnabledFromPreferences: true, - }, - ); + describe('addDetectedTokensViaPolling', () => { + it('should add tokens detected from polling with metadata from cache', async () => { + const mockTokenAddress = '0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48'; + const checksummedTokenAddress = + '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48'; + const chainId = '0xa86a'; - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - await tokenListStateChangeListener!({ tokenList: {} }); + await withController( + { + options: { + disabled: false, + useTokenDetection: () => true, + }, + mockTokenListState: { + tokensChainsCache: { + [chainId]: { + timestamp: 0, + data: { + [mockTokenAddress]: { + name: 'USD Coin', + symbol: 'USDC', + decimals: 6, + address: mockTokenAddress, + aggregators: [], + iconUrl: 'https://example.com/usdc.png', + occurrences: 11, + }, + }, + }, + }, + }, + }, + async ({ controller, callActionSpy }) => { + await controller.addDetectedTokensViaPolling({ + tokensSlice: [mockTokenAddress], + chainId: chainId as Hex, + }); - expect(getBalancesInSingleCallMock.called).toBe(false); + expect(callActionSpy).toHaveBeenCalledWith( + 'TokensController:addTokens', + [ + { + address: checksummedTokenAddress, + decimals: 6, + symbol: 'USDC', + aggregators: [], + image: 'https://example.com/usdc.png', + isERC721: false, + name: 'USD Coin', + }, + ], + 'avalanche', + ); + }, + ); + }); + + it('adds mUSD from merged token list when polling has no other token addresses (zero balance)', async () => { + await withController( + { + options: { disabled: false, useTokenDetection: () => true }, + mockTokenListState: { + tokensChainsCache: { + [ChainId.mainnet]: { + timestamp: 0, + data: {}, + }, + }, + }, + }, + async ({ + controller, + callActionSpy, + mockFindNetworkClientIdByChainId, + }) => { + mockFindNetworkClientIdByChainId(() => 'mainnet'); + await controller.addDetectedTokensViaPolling({ + tokensSlice: [], + chainId: ChainId.mainnet, + }); + expect(callActionSpy).toHaveBeenCalledWith( + 'TokensController:addTokens', + expect.arrayContaining([ + expect.objectContaining({ + address: toChecksumHexAddress(MUSD_ERC20_ADDRESS_LOWER), + name: 'MetaMask USD', + symbol: 'MUSD', + decimals: 6, + }), + ]), + 'mainnet', + ); + }, + ); + }); + + it('should skip if useTokenDetection is disabled', async () => { + const mockTokenAddress = '0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48'; + const chainId = '0xa86a'; + + await withController( + { + options: { + disabled: false, + useTokenDetection: () => false, + }, + mockTokenListState: { + tokensChainsCache: { + [chainId]: { + timestamp: 0, + data: { + [mockTokenAddress]: { + name: 'USD Coin', + symbol: 'USDC', + decimals: 6, + address: mockTokenAddress, + aggregators: [], + iconUrl: 'https://example.com/usdc.png', + occurrences: 11, + }, + }, + }, + }, + }, + }, + async ({ controller, callActionSpy }) => { + await controller.addDetectedTokensViaPolling({ + tokensSlice: [mockTokenAddress], + chainId: chainId as Hex, + }); + + // Should not call addTokens when useTokenDetection is disabled + expect(callActionSpy).not.toHaveBeenCalledWith( + 'TokensController:addTokens', + expect.anything(), + expect.anything(), + ); + }, + ); + }); + + it('should skip tokens already in allTokens', async () => { + const mockTokenAddress = '0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48'; + const checksummedTokenAddress = + '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48'; + const chainId = '0xa86a'; + const selectedAccount = createMockInternalAccount({ + address: '0x0000000000000000000000000000000000000001', + }); + + await withController( + { + options: { + disabled: false, + useTokenDetection: () => true, + }, + mocks: { + getAccount: selectedAccount, + getSelectedAccount: selectedAccount, + }, + mockTokensState: { + allTokens: { + [chainId]: { + [selectedAccount.address]: [ + { + address: checksummedTokenAddress, + symbol: 'USDC', + decimals: 6, + }, + ], + }, + }, + allDetectedTokens: {}, + allIgnoredTokens: {}, + }, + mockTokenListState: { + tokensChainsCache: { + [chainId]: { + timestamp: 0, + data: { + [mockTokenAddress]: { + name: 'USD Coin', + symbol: 'USDC', + decimals: 6, + address: mockTokenAddress, + aggregators: [], + iconUrl: 'https://example.com/usdc.png', + occurrences: 11, + }, + }, + }, + }, + }, + }, + async ({ controller, callActionSpy }) => { + await controller.addDetectedTokensViaPolling({ + tokensSlice: [mockTokenAddress], + chainId: chainId as Hex, + }); + + // Should not call addTokens for tokens already in allTokens + expect(callActionSpy).not.toHaveBeenCalledWith( + 'TokensController:addTokens', + expect.anything(), + expect.anything(), + ); + }, + ); + }); + + it('should skip tokens in allIgnoredTokens', async () => { + const mockTokenAddress = '0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48'; + const checksummedTokenAddress = + '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48'; + const chainId = '0xa86a'; + const selectedAccount = createMockInternalAccount({ + address: '0x0000000000000000000000000000000000000001', + }); + + await withController( + { + options: { + disabled: false, + useTokenDetection: () => true, + }, + mocks: { + getAccount: selectedAccount, + getSelectedAccount: selectedAccount, + }, + mockTokensState: { + allTokens: {}, + allDetectedTokens: {}, + allIgnoredTokens: { + [chainId]: { + [selectedAccount.address]: [checksummedTokenAddress], + }, + }, + }, + mockTokenListState: { + tokensChainsCache: { + [chainId]: { + timestamp: 0, + data: { + [mockTokenAddress]: { + name: 'USD Coin', + symbol: 'USDC', + decimals: 6, + address: mockTokenAddress, + aggregators: [], + iconUrl: 'https://example.com/usdc.png', + occurrences: 11, + }, + }, + }, + }, + }, + }, + async ({ controller, callActionSpy }) => { + await controller.addDetectedTokensViaPolling({ + tokensSlice: [mockTokenAddress], + chainId: chainId as Hex, + }); + + // Should not call addTokens for tokens in allIgnoredTokens + expect(callActionSpy).not.toHaveBeenCalledWith( + 'TokensController:addTokens', + expect.anything(), + expect.anything(), + ); + }, + ); + }); + + it('should fetch fresh token metadata cache from TokenListService at call time', async () => { + // This test verifies that addDetectedTokensViaPolling fetches the token list + // from the TokenListService at call time (not at construction time), so that + // tokens added to the service after construction are still detected. + const mockTokenAddress = '0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48'; + const checksummedTokenAddress = + '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48'; + const chainId = '0xa86a'; + + await withController( + { + options: { + disabled: false, + useTokenDetection: () => true, + }, + // Start with empty cache at construction time - simulating the bug scenario + mockTokenListState: { + tokensChainsCache: {}, + }, + }, + async ({ controller, callActionSpy, mockTokenListGetState }) => { + // Update the mock to return populated cache data + // This simulates TokenListController having fetched token list data after construction + mockTokenListGetState({ + ...getDefaultTokenListState(), + tokensChainsCache: { + [chainId]: { + timestamp: 0, + data: { + [mockTokenAddress]: { + name: 'USD Coin', + symbol: 'USDC', + decimals: 6, + address: mockTokenAddress, + aggregators: [], + iconUrl: 'https://example.com/usdc.png', + occurrences: 11, + }, + }, + }, + }, + }); + + // Call addDetectedTokensViaPolling - with the fix, it should fetch fresh cache + await controller.addDetectedTokensViaPolling({ + tokensSlice: [mockTokenAddress], + chainId: chainId as Hex, + }); + + // With the fix, the token should be added because fresh cache is fetched + expect(callActionSpy).toHaveBeenCalledWith( + 'TokensController:addTokens', + [ + { + address: checksummedTokenAddress, + decimals: 6, + symbol: 'USDC', + aggregators: [], + image: 'https://example.com/usdc.png', + isERC721: false, + name: 'USD Coin', + }, + ], + 'avalanche', + ); + }, + ); + }); + + it('should add only untracked tokens when mixed with tracked/ignored', async () => { + const trackedTokenAddress = '0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48'; + const trackedTokenChecksummed = + '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48'; + const ignoredTokenAddress = '0xdac17f958d2ee523a2206206994597c13d831ec7'; + const ignoredTokenChecksummed = + '0xdAC17F958D2ee523a2206206994597C13D831ec7'; + const newTokenAddress = '0x1f573d6fb3f13d689ff844b4ce37794d79a7ff1c'; + const newTokenChecksummed = '0x1F573D6Fb3F13d689FF844B4cE37794d79a7FF1C'; + const chainId = '0xa86a'; + const selectedAccount = createMockInternalAccount({ + address: '0x0000000000000000000000000000000000000001', + }); + + await withController( + { + options: { + disabled: false, + useTokenDetection: () => true, + }, + mocks: { + getAccount: selectedAccount, + getSelectedAccount: selectedAccount, + }, + mockTokensState: { + allTokens: { + [chainId]: { + [selectedAccount.address]: [ + { + address: trackedTokenChecksummed, + symbol: 'USDC', + decimals: 6, + }, + ], + }, + }, + allDetectedTokens: {}, + allIgnoredTokens: { + [chainId]: { + [selectedAccount.address]: [ignoredTokenChecksummed], + }, + }, + }, + mockTokenListState: { + tokensChainsCache: { + [chainId]: { + timestamp: 0, + data: { + [trackedTokenAddress]: { + name: 'USD Coin', + symbol: 'USDC', + decimals: 6, + address: trackedTokenAddress, + aggregators: [], + iconUrl: 'https://example.com/usdc.png', + occurrences: 11, + }, + [ignoredTokenAddress]: { + name: 'Tether USD', + symbol: 'USDT', + decimals: 6, + address: ignoredTokenAddress, + aggregators: [], + iconUrl: 'https://example.com/usdt.png', + occurrences: 11, + }, + [newTokenAddress]: { + name: 'Bancor', + symbol: 'BNT', + decimals: 18, + address: newTokenAddress, + aggregators: [], + iconUrl: 'https://example.com/bnt.png', + occurrences: 11, + }, + }, + }, + }, + }, + }, + async ({ controller, callActionSpy }) => { + await controller.addDetectedTokensViaPolling({ + tokensSlice: [ + trackedTokenAddress, + ignoredTokenAddress, + newTokenAddress, + ], + chainId: chainId as Hex, + }); + + // Should only add the new untracked token + expect(callActionSpy).toHaveBeenCalledWith( + 'TokensController:addTokens', + [ + { + address: newTokenChecksummed, + decimals: 18, + symbol: 'BNT', + aggregators: [], + image: 'https://example.com/bnt.png', + isERC721: false, + name: 'Bancor', + }, + ], + 'avalanche', + ); + }, + ); + }); + + it('should skip if useExternalServices is disabled', async () => { + const mockTokenAddress = '0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48'; + const chainId = '0xa86a'; + + await withController( + { + options: { + disabled: false, + useTokenDetection: () => true, + useExternalServices: () => false, + }, + mockTokenListState: { + tokensChainsCache: { + [chainId]: { + timestamp: 0, + data: { + [mockTokenAddress]: { + name: 'USD Coin', + symbol: 'USDC', + decimals: 6, + address: mockTokenAddress, + aggregators: [], + iconUrl: 'https://example.com/usdc.png', + occurrences: 11, + }, + }, + }, + }, + }, + }, + async ({ controller, callActionSpy }) => { + await controller.addDetectedTokensViaPolling({ + tokensSlice: [mockTokenAddress], + chainId: chainId as Hex, + }); + + expect(callActionSpy).not.toHaveBeenCalledWith( + 'TokensController:addTokens', + expect.anything(), + expect.anything(), + ); + }, + ); + }); + + it('should ignore slice addresses that are not in the token list map', async () => { + const mockTokenAddress = '0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48'; + const checksummedTokenAddress = + '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48'; + const unknownAddress = '0x0000000000000000000000000000000000000002'; + const chainId = '0xa86a'; + + await withController( + { + options: { + disabled: false, + useTokenDetection: () => true, + }, + mockTokenListState: { + tokensChainsCache: { + [chainId]: { + timestamp: 0, + data: { + [mockTokenAddress]: { + name: 'USD Coin', + symbol: 'USDC', + decimals: 6, + address: mockTokenAddress, + aggregators: [], + iconUrl: 'https://example.com/usdc.png', + occurrences: 11, + }, + }, + }, + }, + }, + }, + async ({ controller, callActionSpy }) => { + await controller.addDetectedTokensViaPolling({ + tokensSlice: [mockTokenAddress, unknownAddress], + chainId: chainId as Hex, + }); + + expect(callActionSpy).toHaveBeenCalledWith( + 'TokensController:addTokens', + [ + { + address: checksummedTokenAddress, + decimals: 6, + symbol: 'USDC', + aggregators: [], + image: 'https://example.com/usdc.png', + isERC721: false, + name: 'USD Coin', + }, + ], + 'avalanche', + ); + }, + ); + }); + + it('does not append a duplicate mUSD entry when the slice already includes mUSD', async () => { + const usdcAddress = '0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48'; + const usdcChecksummed = '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48'; + const selectedAccount = createMockInternalAccount({ + address: '0x0000000000000000000000000000000000000001', + }); + + await withController( + { + options: { disabled: false, useTokenDetection: () => true }, + mocks: { + getAccount: selectedAccount, + getSelectedAccount: selectedAccount, + }, + mockTokensState: { + allTokens: {}, + allDetectedTokens: {}, + allIgnoredTokens: {}, + }, + mockTokenListState: { + tokensChainsCache: { + [ChainId.mainnet]: { + timestamp: 0, + data: { + [usdcAddress]: { + name: 'USD Coin', + symbol: 'USDC', + decimals: 6, + address: usdcAddress, + aggregators: [], + iconUrl: 'https://example.com/usdc.png', + occurrences: 11, + }, + }, + }, + }, + }, + }, + async ({ + controller, + callActionSpy, + mockFindNetworkClientIdByChainId, + }) => { + mockFindNetworkClientIdByChainId(() => 'mainnet'); + await controller.addDetectedTokensViaPolling({ + tokensSlice: [ + toChecksumHexAddress(MUSD_ERC20_ADDRESS_LOWER), + usdcAddress, + ], + chainId: ChainId.mainnet, + }); + + expect(callActionSpy).toHaveBeenCalledWith( + 'TokensController:addTokens', + expect.arrayContaining([ + expect.objectContaining({ + address: toChecksumHexAddress(MUSD_ERC20_ADDRESS_LOWER), + }), + expect.objectContaining({ address: usdcChecksummed }), + ]), + 'mainnet', + ); + const addTokensCall = callActionSpy.mock.calls.find( + (call) => call[0] === 'TokensController:addTokens', + ); + const payload = addTokensCall?.[1] as { address: string }[]; + const musdRows = payload.filter( + (tokenRow) => + tokenRow.address.toLowerCase() === MUSD_ERC20_ADDRESS_LOWER, + ); + expect(musdRows).toHaveLength(1); + }, + ); + }); }); - it('should call getBalancesInSingleCall if onPreferencesStateChange is called with useTokenDetection being true and is changed', async () => { - const stub = sinon.stub(); - const getBalancesInSingleCallMock = sinon.stub(); - let preferencesStateChangeListener: (state: any) => void; - const onPreferencesStateChange = sinon.stub().callsFake((listener) => { - preferencesStateChangeListener = listener; - }); - tokenDetection = new TokenDetectionController( - { - onPreferencesStateChange, - onTokenListStateChange: stub, - onNetworkStateChange: stub, - getBalancesInSingleCall: getBalancesInSingleCallMock, - addDetectedTokens: stub, - getTokensState: () => tokensController.state, - getTokenListState: () => tokenList.state, - getNetworkState: () => defaultNetworkState, - getPreferencesState: () => preferences.state, - }, - { - disabled: false, - isDetectionEnabledForNetwork: true, - isDetectionEnabledFromPreferences: false, - selectedAddress: '0x1', - }, - ); + describe('isDeprecated', () => { + it('does not throw at construction when isDeprecated() is true', async () => { + await withController( + { options: { isDeprecated: () => true } }, + ({ controller }) => { + expect(controller.state).toStrictEqual({}); + }, + ); + }); - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - await preferencesStateChangeListener!({ - selectedAddress: '0x1', - useTokenDetection: true, + it('does not make any network calls when isDeprecated() returns true from construction', async () => { + const mockGetBalancesInSingleCall = jest.fn().mockResolvedValue({}); + await withController( + { + options: { + isDeprecated: () => true, + disabled: false, + getBalancesInSingleCall: mockGetBalancesInSingleCall, + }, + mocks: { + getSelectedAccount: defaultSelectedAccount, + }, + }, + async ({ + controller, + mockTokenListGetState, + mockGetNetworkClientById, + }) => { + mockTokenListGetState({ + ...getDefaultTokenListState(), + tokensChainsCache: { + '0xa86a': { + timestamp: 0, + data: { + [sampleTokenA.address]: { + name: sampleTokenA.name, + symbol: sampleTokenA.symbol, + decimals: sampleTokenA.decimals, + address: sampleTokenA.address, + aggregators: [], + iconUrl: '', + occurrences: 11, + }, + }, + }, + }, + }); + mockGetNetworkClientById( + () => + ({ + configuration: { chainId: '0xa86a' }, + }) as unknown as AutoManagedNetworkClient, + ); + + await controller.detectTokens(); + + expect(mockGetBalancesInSingleCall).not.toHaveBeenCalled(); + expect(controller.state).toStrictEqual({}); + }, + ); + }); + + it('does not detect tokens when isDeprecated toggles to true at runtime via detectTokens', async () => { + let deprecated = false; + const mockGetBalancesInSingleCall = jest.fn().mockResolvedValue({}); + await withController( + { + options: { + isDeprecated: () => deprecated, + disabled: false, + getBalancesInSingleCall: mockGetBalancesInSingleCall, + }, + mocks: { + getSelectedAccount: defaultSelectedAccount, + }, + }, + async ({ controller }) => { + deprecated = true; + + await controller.detectTokens(); + + expect(mockGetBalancesInSingleCall).not.toHaveBeenCalled(); + expect(controller.state).toStrictEqual({}); + }, + ); + }); + + it('does not start polling when isDeprecated toggles to true at runtime via start', async () => { + let deprecated = false; + const mockGetBalancesInSingleCall = jest.fn().mockResolvedValue({}); + await withController( + { + options: { + isDeprecated: () => deprecated, + disabled: false, + getBalancesInSingleCall: mockGetBalancesInSingleCall, + }, + mocks: { + getSelectedAccount: defaultSelectedAccount, + }, + }, + async ({ controller }) => { + const mockDetectTokens = jest + .spyOn(controller, 'detectTokens') + .mockImplementation(); + + deprecated = true; + + await controller.start(); + + expect(mockDetectTokens).not.toHaveBeenCalled(); + }, + ); + }); + + it('does not detect tokens when isDeprecated toggles to true at runtime via _executePoll', async () => { + let deprecated = false; + const mockGetBalancesInSingleCall = jest.fn().mockResolvedValue({}); + await withController( + { + options: { + isDeprecated: () => deprecated, + disabled: false, + getBalancesInSingleCall: mockGetBalancesInSingleCall, + }, + }, + async ({ controller }) => { + deprecated = true; + + await controller._executePoll({ + chainIds: ['0xa86a'], + address: '0x1', + }); + + expect(mockGetBalancesInSingleCall).not.toHaveBeenCalled(); + expect(controller.state).toStrictEqual({}); + }, + ); + }); + + it('does not add tokens when isDeprecated toggles to true at runtime via addDetectedTokensViaWs', async () => { + let deprecated = false; + const mockTokenAddress = '0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48'; + const chainId = '0xa86a'; + + await withController( + { + options: { + isDeprecated: () => deprecated, + disabled: false, + }, + mockTokenListState: { + tokensChainsCache: { + [chainId]: { + timestamp: 0, + data: { + [mockTokenAddress]: { + name: 'USD Coin', + symbol: 'USDC', + decimals: 6, + address: mockTokenAddress, + aggregators: [], + iconUrl: 'https://example.com/usdc.png', + occurrences: 11, + }, + }, + }, + }, + }, + }, + async ({ controller, callActionSpy }) => { + deprecated = true; + + await controller.addDetectedTokensViaWs({ + tokensSlice: [mockTokenAddress], + chainId: chainId as Hex, + }); + + expect(callActionSpy).not.toHaveBeenCalledWith( + 'TokensController:addTokens', + expect.anything(), + expect.anything(), + ); + }, + ); }); - expect(getBalancesInSingleCallMock.called).toBe(true); + it('does not add tokens when isDeprecated toggles to true at runtime via addDetectedTokensViaPolling', async () => { + let deprecated = false; + const mockTokenAddress = '0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48'; + const chainId = '0xa86a'; + + await withController( + { + options: { + isDeprecated: () => deprecated, + disabled: false, + }, + mockTokenListState: { + tokensChainsCache: { + [chainId]: { + timestamp: 0, + data: { + [mockTokenAddress]: { + name: 'USD Coin', + symbol: 'USDC', + decimals: 6, + address: mockTokenAddress, + aggregators: [], + iconUrl: 'https://example.com/usdc.png', + occurrences: 11, + }, + }, + }, + }, + }, + }, + async ({ controller, callActionSpy }) => { + deprecated = true; + + await controller.addDetectedTokensViaPolling({ + tokensSlice: [mockTokenAddress], + chainId: chainId as Hex, + }); + + expect(callActionSpy).not.toHaveBeenCalledWith( + 'TokensController:addTokens', + expect.anything(), + expect.anything(), + ); + }, + ); + }); + + it('keeps polling but bails early when isDeprecated toggles to true at runtime', async () => { + jest.useFakeTimers(); + let deprecated = false; + const mockGetBalancesInSingleCall = jest.fn().mockResolvedValue({}); + await withController( + { + options: { + isDeprecated: () => deprecated, + disabled: false, + getBalancesInSingleCall: mockGetBalancesInSingleCall, + }, + mocks: { + getSelectedAccount: defaultSelectedAccount, + }, + }, + async ({ controller }) => { + const detectTokensSpy = jest.spyOn(controller, 'detectTokens'); + + controller.setIntervalLength(10); + await controller.start(); + expect(detectTokensSpy).toHaveBeenCalledTimes(1); + + deprecated = true; + await controller.detectTokens(); + mockGetBalancesInSingleCall.mockClear(); + + detectTokensSpy.mockClear(); + await jestAdvanceTime({ duration: 15 }); + expect(detectTokensSpy).toHaveBeenCalled(); + expect(mockGetBalancesInSingleCall).not.toHaveBeenCalled(); + }, + ); + jest.useRealTimers(); + }); }); +}); - it('should call getBalancesInSingleCall if onNetworkStateChange is called with a chainId that supports token detection and is changed', async () => { - const stub = sinon.stub(); - const getBalancesInSingleCallMock = sinon.stub(); - let networkStateChangeListener: (state: any) => void; - const onNetworkStateChange = sinon.stub().callsFake((listener) => { - networkStateChangeListener = listener; - }); - tokenDetection = new TokenDetectionController( - { - onNetworkStateChange, - onTokenListStateChange: stub, - onPreferencesStateChange: stub, - getBalancesInSingleCall: getBalancesInSingleCallMock, - addDetectedTokens: stub, - getTokensState: () => tokensController.state, - getTokenListState: () => tokenList.state, - getNetworkState: () => defaultNetworkState, - getPreferencesState: () => preferences.state, - }, - { - disabled: false, - isDetectionEnabledFromPreferences: true, - chainId: SupportedTokenDetectionNetworks.polygon, - isDetectionEnabledForNetwork: true, - selectedAddress: '0x1', +/** + * Construct the path used to fetch tokens that we can pass to `nock`. + * + * @param chainId - The chain ID. + * @returns The constructed path. + */ +function getTokensPath(chainId: Hex): string { + return `/tokens/${convertHexToDecimal( + chainId, + )}?occurrenceFloor=3&includeNativeAssets=false&includeTokenFees=false&includeAssetType=false&includeERC20Permit=false&includeStorage=false&includeRwaData=true`; +} + +type WithControllerCallback = ({ + controller, + messenger, + mockGetAccount, + mockGetSelectedAccount, + mockKeyringGetState, + mockTokensGetState, + mockTokenListGetState, + mockPreferencesGetState, + mockGetNetworkClientById, + mockGetNetworkConfigurationByNetworkClientId, + mockNetworkState, + callActionSpy, + triggerKeyringUnlock, + triggerKeyringLock, + triggerPreferencesStateChange, + triggerSelectedAccountChange, + triggerNetworkDidChange, +}: { + controller: TokenDetectionController; + messenger: RootMessenger; + mockGetAccount: (internalAccount: InternalAccount) => void; + mockGetSelectedAccount: (address: string) => void; + mockKeyringGetState: (state: KeyringControllerState) => void; + mockTokensGetState: (state: TokensControllerState) => void; + /** Updates the mock TokenListService to return a specific token list state. */ + mockTokenListGetState: (state: TokenListState) => void; + mockPreferencesGetState: (state: PreferencesState) => void; + mockGetNetworkClientById: ( + handler: ( + networkClientId: NetworkClientId, + ) => AutoManagedNetworkClient, + ) => void; + mockGetNetworkConfigurationByNetworkClientId: ( + handler: (networkClientId: NetworkClientId) => NetworkConfiguration, + ) => void; + mockNetworkState: (state: NetworkState) => void; + mockFindNetworkClientIdByChainId: ( + handler: (chainId: Hex) => NetworkClientId, + ) => void; + callActionSpy: jest.SpyInstance; + triggerKeyringUnlock: () => void; + triggerKeyringLock: () => void; + triggerPreferencesStateChange: (state: PreferencesState) => void; + triggerSelectedAccountChange: (account: InternalAccount) => void; + triggerNetworkDidChange: (state: NetworkState) => void; + triggerTransactionConfirmed: (transactionMeta: { chainId: Hex }) => void; +}) => Promise | ReturnValue; + +type WithControllerOptions = { + options?: Partial[0]>; + isKeyringUnlocked?: boolean; + mocks?: { + getAccount?: InternalAccount; + getSelectedAccount?: InternalAccount; + getBearerToken?: string; + }; + mockTokenListState?: Partial; + mockTokensState?: Partial; +}; + +type WithControllerArgs = + | [WithControllerCallback] + | [WithControllerOptions, WithControllerCallback]; + +/** + * Builds a controller based on the given options, and calls the given function + * with that controller. + * + * @param args - Either a function, or an options bag + a function. The options + * bag is equivalent to the controller options; the function will be called + * with the built controller. + * @returns Whatever the callback returns. + */ +async function withController( + ...args: WithControllerArgs +): Promise { + const [{ ...rest }, fn] = args.length === 2 ? args : [{}, args[0]]; + const { + options, + isKeyringUnlocked, + mocks, + mockTokenListState, + mockTokensState, + } = rest; + const messenger = buildRootMessenger(); + + const mockGetAccount = jest.fn(); + messenger.registerActionHandler( + 'AccountsController:getAccount', + mockGetAccount.mockReturnValue( + mocks?.getAccount ?? createMockInternalAccount({ address: '0x1' }), + ), + ); + + const mockGetSelectedAccount = jest.fn(); + messenger.registerActionHandler( + 'AccountsController:getSelectedAccount', + mockGetSelectedAccount.mockReturnValue( + mocks?.getSelectedAccount ?? + createMockInternalAccount({ address: '0x1' }), + ), + ); + const mockKeyringState = jest.fn(); + messenger.registerActionHandler( + 'KeyringController:getState', + mockKeyringState.mockReturnValue({ + isUnlocked: isKeyringUnlocked ?? true, + } as KeyringControllerState), + ); + const mockGetNetworkClientById = jest.fn< + ReturnType, + Parameters + >(); + messenger.registerActionHandler( + 'NetworkController:getNetworkClientById', + mockGetNetworkClientById.mockImplementation(() => { + // Default to Avalanche (0xa86a) which is in SupportedTokenDetectionNetworks + // but NOT in SUPPORTED_NETWORKS_ACCOUNTS_API_V4 + return { + configuration: { chainId: '0xa86a' }, + provider: {}, + destroy: {}, + blockTracker: {}, + } as unknown as AutoManagedNetworkClient; + }), + ); + const mockGetNetworkConfigurationByNetworkClientId = jest.fn< + ReturnType, + Parameters + >(); + messenger.registerActionHandler( + 'NetworkController:getNetworkConfigurationByNetworkClientId', + mockGetNetworkConfigurationByNetworkClientId.mockImplementation( + (networkClientId: NetworkClientId) => { + return mockNetworkConfigurations[networkClientId]; }, - ); + ), + ); + const mockNetworkState = jest.fn(); + messenger.registerActionHandler( + 'NetworkController:getState', + mockNetworkState.mockReturnValue({ + ...getDefaultNetworkControllerState(), + // Default to avalanche so RPC detection works (not in SUPPORTED_NETWORKS_ACCOUNTS_API_V4) + selectedNetworkClientId: 'avalanche', + }), + ); + const mockTokensStateFunc = jest.fn(); + messenger.registerActionHandler( + 'TokensController:getState', + mockTokensStateFunc.mockReturnValue({ + ...getDefaultTokensState(), + ...mockTokensState, + }), + ); - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - await networkStateChangeListener!({ - providerConfig: { chainId: ChainId.mainnet }, + // Build the initial TokenListState and a mutable reference so tests can update it. + let currentTokenListState: TokenListState = { + ...getDefaultTokenListState(), + ...mockTokenListState, + }; + const mockFetchTokensByChainId = jest + .fn, [Hex]>() + .mockImplementation((chainId: Hex) => { + const data = currentTokenListState.tokensChainsCache[chainId]?.data ?? {}; + // Normalise keys to lowercase to match buildTokenListMap's output so that + // lookups using lowercased addresses (as done in production code) work correctly. + return Promise.resolve( + Object.fromEntries( + Object.entries(data).map(([addr, token]) => [ + addr.toLowerCase(), + token, + ]), + ), + ); }); + const tokenListService = { + fetchTokensByChainId: mockFetchTokensByChainId, + } as unknown as TokenListService; - expect(getBalancesInSingleCallMock.called).toBe(true); + const mockPreferencesState = jest.fn(); + messenger.registerActionHandler( + 'PreferencesController:getState', + mockPreferencesState.mockReturnValue({ + ...getDefaultPreferencesState(), + // Enable token detection by default for tests using Avalanche + useTokenDetection: true, + }), + ); + + const mockFindNetworkClientIdByChainId = jest.fn(); + messenger.registerActionHandler( + 'NetworkController:findNetworkClientIdByChainId', + // Default to 'avalanche' which is not in SUPPORTED_NETWORKS_ACCOUNTS_API_V4 + mockFindNetworkClientIdByChainId.mockReturnValue('avalanche'), + ); + + messenger.registerActionHandler( + 'TokensController:addDetectedTokens', + jest + .fn< + ReturnType, + Parameters + >() + .mockResolvedValue(undefined), + ); + + messenger.registerActionHandler( + 'TokensController:addTokens', + jest + .fn< + ReturnType, + Parameters + >() + .mockResolvedValue(undefined), + ); + + const tokenDetectionControllerMessenger = + buildTokenDetectionControllerMessenger(messenger); + + const callActionSpy = jest.spyOn(tokenDetectionControllerMessenger, 'call'); + + const controller = new TokenDetectionController({ + getBalancesInSingleCall: jest.fn(), + trackMetaMetricsEvent: jest.fn(), + messenger: tokenDetectionControllerMessenger, + tokenListService, + ...options, }); -}); + try { + return await fn({ + controller, + messenger, + mockGetAccount: (internalAccount: InternalAccount) => { + mockGetAccount.mockReturnValue(internalAccount); + }, + mockGetSelectedAccount: (address: string) => { + mockGetSelectedAccount.mockReturnValue({ address } as InternalAccount); + }, + mockKeyringGetState: (state: KeyringControllerState) => { + mockKeyringState.mockReturnValue(state); + }, + mockTokensGetState: (state: TokensControllerState) => { + mockTokensStateFunc.mockReturnValue(state); + }, + mockPreferencesGetState: (state: PreferencesState) => { + mockPreferencesState.mockReturnValue(state); + }, + mockTokenListGetState: (state: TokenListState) => { + currentTokenListState = state; + mockFetchTokensByChainId.mockImplementation((chainId: Hex) => { + const data = state.tokensChainsCache[chainId]?.data ?? {}; + // Normalise keys to lowercase to match buildTokenListMap's output. + return Promise.resolve( + Object.fromEntries( + Object.entries(data).map(([addr, token]) => [ + addr.toLowerCase(), + token, + ]), + ), + ); + }); + }, + mockGetNetworkClientById: ( + handler: ( + networkClientId: NetworkClientId, + ) => AutoManagedNetworkClient, + ) => { + mockGetNetworkClientById.mockImplementation(handler); + }, + mockFindNetworkClientIdByChainId: ( + handler: (chainId: Hex) => NetworkClientId, + ) => { + mockFindNetworkClientIdByChainId.mockImplementation(handler); + }, + mockGetNetworkConfigurationByNetworkClientId: ( + handler: (networkClientId: NetworkClientId) => NetworkConfiguration, + ) => { + mockGetNetworkConfigurationByNetworkClientId.mockImplementation( + handler, + ); + }, + mockNetworkState: (state: NetworkState) => { + mockNetworkState.mockReturnValue(state); + }, + callActionSpy, + triggerKeyringUnlock: () => { + messenger.publish('KeyringController:unlock'); + }, + triggerKeyringLock: () => { + messenger.publish('KeyringController:lock'); + }, + triggerPreferencesStateChange: (state: PreferencesState) => { + messenger.publish('PreferencesController:stateChange', state, []); + }, + triggerSelectedAccountChange: (account: InternalAccount) => { + messenger.publish( + 'AccountsController:selectedEvmAccountChange', + account, + ); + }, + triggerNetworkDidChange: (state: NetworkState) => { + messenger.publish('NetworkController:networkDidChange', state); + }, + triggerTransactionConfirmed: (transactionMeta: { chainId: Hex }) => { + messenger.publish( + 'TransactionController:transactionConfirmed', + // We only need chainId for this test, so cast to satisfy the type + transactionMeta as unknown as Parameters< + typeof messenger.publish<'TransactionController:transactionConfirmed'> + >[1], + ); + }, + }); + } finally { + controller.stop(); + controller.stopAllPolling(); + } +} diff --git a/packages/assets-controllers/src/TokenDetectionController.ts b/packages/assets-controllers/src/TokenDetectionController.ts index f3520220805..68ce551f4f7 100644 --- a/packages/assets-controllers/src/TokenDetectionController.ts +++ b/packages/assets-controllers/src/TokenDetectionController.ts @@ -1,306 +1,1153 @@ -import type { BaseConfig, BaseState } from '@metamask/base-controller'; -import { BaseController } from '@metamask/base-controller'; +import type { + AccountsControllerGetSelectedAccountAction, + AccountsControllerGetAccountAction, + AccountsControllerSelectedEvmAccountChangeEvent, +} from '@metamask/accounts-controller'; +import type { + ControllerGetStateAction, + ControllerStateChangeEvent, +} from '@metamask/base-controller'; +import contractMap from '@metamask/contract-metadata'; import { + ASSET_TYPES, + ChainId, + ERC20, safelyExecute, + isEqualCaseInsensitive, toChecksumHexAddress, } from '@metamask/controller-utils'; -import type { NetworkState } from '@metamask/network-controller'; -import type { PreferencesState } from '@metamask/preferences-controller'; +import type { + KeyringControllerGetStateAction, + KeyringControllerLockEvent, + KeyringControllerUnlockEvent, +} from '@metamask/keyring-controller'; +import type { InternalAccount } from '@metamask/keyring-internal-api'; +import type { Messenger } from '@metamask/messenger'; +import type { + NetworkClientId, + NetworkControllerFindNetworkClientIdByChainIdAction, + NetworkControllerGetNetworkClientByIdAction, + NetworkControllerGetNetworkConfigurationByNetworkClientIdAction, + NetworkControllerGetStateAction, + NetworkControllerNetworkDidChangeEvent, +} from '@metamask/network-controller'; +import { StaticIntervalPollingController } from '@metamask/polling-controller'; +import type { + PreferencesControllerGetStateAction, + PreferencesControllerStateChangeEvent, +} from '@metamask/preferences-controller'; +import type { AuthenticationController } from '@metamask/profile-sync-controller'; +import type { TransactionControllerTransactionConfirmedEvent } from '@metamask/transaction-controller'; import type { Hex } from '@metamask/utils'; -import type { AssetsContractController } from './AssetsContractController'; -import { isTokenDetectionSupportedForNetwork } from './assetsUtil'; -import type { TokenListState } from './TokenListController'; -import type { Token } from './TokenRatesController'; -import type { TokensController, TokensState } from './TokensController'; +import type { AssetsContractController } from './AssetsContractController.js'; +import { + formatIconUrlWithProxy, + isTokenDetectionSupportedForNetwork, +} from './assetsUtil.js'; +import { + MUSD_ERC20_ADDRESS_LOWER, + MUSD_TOKEN_DETECTION_CHAIN_IDS, + MUSD_TOKEN_METADATA_BY_CHAIN, + SUPPORTED_NETWORKS_ACCOUNTS_API_V4, +} from './constants.js'; +import type { TokenDetectionControllerMethodActions } from './TokenDetectionController-method-action-types.js'; +import type { + TokenListMap, + TokenListToken, + TokensChainsCache, +} from './TokenListController.js'; +import type { TokenListService } from './TokenListService.js'; +import type { Token } from './TokenRatesController.js'; +import type { + TokensControllerAddDetectedTokensAction, + TokensControllerAddTokensAction, +} from './TokensController-method-action-types.js'; +import type { TokensControllerGetStateAction } from './TokensController.js'; const DEFAULT_INTERVAL = 180000; -/** - * @type TokenDetectionConfig - * - * TokenDetection configuration - * @property interval - Polling interval used to fetch new token rates - * @property selectedAddress - Vault selected address - * @property chainId - The chain ID of the current network - * @property isDetectionEnabledFromPreferences - Boolean to track if detection is enabled from PreferencesController - * @property isDetectionEnabledForNetwork - Boolean to track if detected is enabled for current network - */ -export interface TokenDetectionConfig extends BaseConfig { - interval: number; - selectedAddress: string; +type LegacyToken = { + name: string; + logo: `${string}.svg`; + symbol: string; + decimals: number; + erc20?: boolean; + erc721?: boolean; +}; + +type TokenDetectionMap = { + [P in keyof TokenListMap]: Omit; +}; + +type NetworkClient = { chainId: Hex; - isDetectionEnabledFromPreferences: boolean; - isDetectionEnabledForNetwork: boolean; -} + networkClientId: string; +}; + +export const STATIC_MAINNET_TOKEN_LIST = Object.entries( + contractMap, +).reduce((acc, [base, contract]) => { + const { logo, erc20, erc721, ...tokenMetadata } = contract; + return { + ...acc, + [base.toLowerCase()]: { + ...tokenMetadata, + address: base.toLowerCase(), + iconUrl: `images/contract/${logo}`, + aggregators: [], + }, + }; +}, {}); + +const MUSD_TOKEN_DETECTION_CHAIN_ID_SET = new Set( + MUSD_TOKEN_DETECTION_CHAIN_IDS, +); + +export const controllerName = 'TokenDetectionController'; + +export type TokenDetectionState = Record; + +export type TokenDetectionControllerGetStateAction = ControllerGetStateAction< + typeof controllerName, + TokenDetectionState +>; + +export type TokenDetectionControllerActions = + | TokenDetectionControllerGetStateAction + | TokenDetectionControllerMethodActions; + +export type AllowedActions = + | AccountsControllerGetSelectedAccountAction + | AccountsControllerGetAccountAction + | NetworkControllerGetNetworkClientByIdAction + | NetworkControllerGetNetworkConfigurationByNetworkClientIdAction + | NetworkControllerGetStateAction + | KeyringControllerGetStateAction + | PreferencesControllerGetStateAction + | TokensControllerGetStateAction + | TokensControllerAddDetectedTokensAction + | TokensControllerAddTokensAction + | NetworkControllerFindNetworkClientIdByChainIdAction + | AuthenticationController.AuthenticationControllerGetBearerTokenAction; + +export type TokenDetectionControllerStateChangeEvent = + ControllerStateChangeEvent; + +export type TokenDetectionControllerEvents = + TokenDetectionControllerStateChangeEvent; + +export type AllowedEvents = + | AccountsControllerSelectedEvmAccountChangeEvent + | NetworkControllerNetworkDidChangeEvent + | KeyringControllerLockEvent + | KeyringControllerUnlockEvent + | PreferencesControllerStateChangeEvent + | TransactionControllerTransactionConfirmedEvent; + +export type TokenDetectionControllerMessenger = Messenger< + typeof controllerName, + TokenDetectionControllerActions | AllowedActions, + TokenDetectionControllerEvents | AllowedEvents +>; + +/** The input to start polling for the {@link TokenDetectionController} */ +type TokenDetectionPollingInput = { + chainIds: Hex[]; + address: string; +}; + +const MESSENGER_EXPOSED_METHODS = [ + 'addDetectedTokensViaWs', + 'addDetectedTokensViaPolling', + 'detectTokens', + 'enable', + 'disable', + 'start', + 'stop', +] as const; /** * Controller that passively polls on a set interval for Tokens auto detection + * + * intervalId - Polling interval used to fetch new token rates + * + * selectedAddress - Vault selected address + * + * networkClientId - The network client ID of the current selected network + * + * disabled - Boolean to track if network requests are blocked + * + * isUnlocked - Boolean to track if the keyring state is unlocked + * + * isDetectionEnabledFromPreferences - Boolean to track if detection is enabled from PreferencesController + * */ -export class TokenDetectionController extends BaseController< - TokenDetectionConfig, - BaseState +export class TokenDetectionController extends StaticIntervalPollingController()< + typeof controllerName, + TokenDetectionState, + TokenDetectionControllerMessenger > { - private intervalId?: ReturnType; + #intervalId?: ReturnType; - /** - * Name of this controller used during composition - */ - override name = 'TokenDetectionController'; + #selectedAccountId: string; + + readonly #tokenListService: TokenListService; - private readonly getBalancesInSingleCall: AssetsContractController['getBalancesInSingleCall']; + #disabled: boolean; - private readonly addDetectedTokens: TokensController['addDetectedTokens']; + #isUnlocked: boolean; - private readonly getTokensState: () => TokensState; + #isDetectionEnabledFromPreferences: boolean; - private readonly getTokenListState: () => TokenListState; + readonly #useTokenDetection: () => boolean; + + readonly #useExternalServices: () => boolean; + + readonly #isDeprecated: () => boolean; + + readonly #getBalancesInSingleCall: AssetsContractController['getBalancesInSingleCall']; + + readonly #trackMetaMetricsEvent: (options: { + event: string; + category: string; + properties: { + tokens: string[]; + // eslint-disable-next-line @typescript-eslint/naming-convention + token_standard: string; + // eslint-disable-next-line @typescript-eslint/naming-convention + asset_type: string; + }; + }) => void; /** * Creates a TokenDetectionController instance. * * @param options - The controller options. - * @param options.onPreferencesStateChange - Allows subscribing to preferences controller state changes. - * @param options.onNetworkStateChange - Allows subscribing to network controller state changes. - * @param options.onTokenListStateChange - Allows subscribing to token list controller state changes. + * @param options.messenger - The controller messenger. + * @param options.tokenListService - Shared service for fetching the token list per chain. + * @param options.disabled - If set to true, all network requests are blocked. + * @param options.interval - Polling interval used to fetch new token rates * @param options.getBalancesInSingleCall - Gets the balances of a list of tokens for the given address. - * @param options.addDetectedTokens - Add a list of detected tokens. - * @param options.getTokenListState - Gets the current state of the TokenList controller. - * @param options.getTokensState - Gets the current state of the Tokens controller. - * @param options.getNetworkState - Gets the state of the network controller. - * @param options.getPreferencesState - Gets the state of the preferences controller. - * @param config - Initial options used to configure this controller. - * @param state - Initial state to set on this controller. + * @param options.trackMetaMetricsEvent - Sets options for MetaMetrics event tracking. + * @param options.useTokenDetection - Feature Switch for using token detection (default: true) + * @param options.useExternalServices - Feature Switch for using external services (default: false) + * @param options.isDeprecated - Optional callback that disables token detection when it returns true. */ - constructor( - { - onPreferencesStateChange, - onNetworkStateChange, - onTokenListStateChange, - getBalancesInSingleCall, - addDetectedTokens, - getTokenListState, - getTokensState, - getNetworkState, - getPreferencesState, - }: { - onPreferencesStateChange: ( - listener: (preferencesState: PreferencesState) => void, - ) => void; - onNetworkStateChange: ( - listener: (networkState: NetworkState) => void, - ) => void; - onTokenListStateChange: ( - listener: (tokenListState: TokenListState) => void, - ) => void; - getBalancesInSingleCall: AssetsContractController['getBalancesInSingleCall']; - addDetectedTokens: TokensController['addDetectedTokens']; - getTokenListState: () => TokenListState; - getTokensState: () => TokensState; - getNetworkState: () => NetworkState; - getPreferencesState: () => PreferencesState; - }, - config?: Partial, - state?: Partial, - ) { - const { - providerConfig: { chainId: defaultChainId }, - } = getNetworkState(); - const { useTokenDetection: defaultUseTokenDetection } = - getPreferencesState(); - - super(config, state); - this.defaultConfig = { - interval: DEFAULT_INTERVAL, - selectedAddress: '', - disabled: true, - chainId: defaultChainId, - isDetectionEnabledFromPreferences: defaultUseTokenDetection, - isDetectionEnabledForNetwork: - isTokenDetectionSupportedForNetwork(defaultChainId), - ...config, - }; + constructor({ + interval = DEFAULT_INTERVAL, + disabled = true, + getBalancesInSingleCall, + trackMetaMetricsEvent, + messenger, + tokenListService, + useTokenDetection = (): boolean => true, + useExternalServices = (): boolean => true, + isDeprecated = (): boolean => false, + }: { + interval?: number; + disabled?: boolean; + getBalancesInSingleCall: AssetsContractController['getBalancesInSingleCall']; + trackMetaMetricsEvent: (options: { + event: string; + category: string; + properties: { + tokens: string[]; + // eslint-disable-next-line @typescript-eslint/naming-convention + token_standard: string; + // eslint-disable-next-line @typescript-eslint/naming-convention + asset_type: string; + }; + }) => void; + messenger: TokenDetectionControllerMessenger; + tokenListService: TokenListService; + useTokenDetection?: () => boolean; + useExternalServices?: () => boolean; + isDeprecated?: () => boolean; + }) { + super({ + name: controllerName, + messenger, + state: {}, + metadata: {}, + }); - this.initialize(); - this.getTokensState = getTokensState; - this.getTokenListState = getTokenListState; - this.addDetectedTokens = addDetectedTokens; - this.getBalancesInSingleCall = getBalancesInSingleCall; + messenger.registerMethodActionHandlers(this, MESSENGER_EXPOSED_METHODS); - onTokenListStateChange(({ tokenList }) => { - const hasTokens = Object.keys(tokenList).length; + this.#disabled = disabled; + this.setIntervalLength(interval); - if (hasTokens) { - this.detectTokens(); - } - }); + this.#selectedAccountId = this.#getSelectedAccount().id; - onPreferencesStateChange(({ selectedAddress, useTokenDetection }) => { - const { - selectedAddress: currentSelectedAddress, - isDetectionEnabledFromPreferences, - } = this.config; - const isSelectedAddressChanged = - selectedAddress !== currentSelectedAddress; - const isDetectionChangedFromPreferences = - isDetectionEnabledFromPreferences !== useTokenDetection; - - this.configure({ - isDetectionEnabledFromPreferences: useTokenDetection, - selectedAddress, - }); + this.#tokenListService = tokenListService; - if ( - useTokenDetection && - (isSelectedAddressChanged || isDetectionChangedFromPreferences) - ) { - this.detectTokens(); - } - }); + const { useTokenDetection: defaultUseTokenDetection } = this.messenger.call( + 'PreferencesController:getState', + ); + this.#isDetectionEnabledFromPreferences = defaultUseTokenDetection; - onNetworkStateChange(({ providerConfig: { chainId } }) => { - const { chainId: currentChainId } = this.config; - const isDetectionEnabledForNetwork = - isTokenDetectionSupportedForNetwork(chainId); - const isChainIdChanged = currentChainId !== chainId; + this.#getBalancesInSingleCall = getBalancesInSingleCall; - this.configure({ - chainId, - isDetectionEnabledForNetwork, + this.#trackMetaMetricsEvent = trackMetaMetricsEvent; + + const { isUnlocked } = this.messenger.call('KeyringController:getState'); + this.#isUnlocked = isUnlocked; + + this.#useTokenDetection = useTokenDetection; + this.#useExternalServices = useExternalServices; + this.#isDeprecated = isDeprecated; + + if (this.#isDeprecated()) { + this.#enforceDisabledState(); + } + + this.#registerEventListeners(); + } + + #enforceDisabledState(): void { + if (Object.keys(this.state).length === 0) { + return; + } + this.update(() => ({})); + } + + /** + * Constructor helper for registering this controller's messenger subscriptions to controller events. + */ + #registerEventListeners(): void { + this.messenger.subscribe('KeyringController:unlock', () => { + this.#isUnlocked = true; + this.#restartTokenDetection().catch(() => { + // Silently handle token detection errors }); + }); - if (isDetectionEnabledForNetwork && isChainIdChanged) { - this.detectTokens(); - } + this.messenger.subscribe('KeyringController:lock', () => { + this.#isUnlocked = false; + this.#stopPolling(); }); + + this.messenger.subscribe( + 'PreferencesController:stateChange', + ({ useTokenDetection }) => { + const selectedAccount = this.#getSelectedAccount(); + const isDetectionChangedFromPreferences = + this.#isDetectionEnabledFromPreferences !== useTokenDetection; + + this.#isDetectionEnabledFromPreferences = useTokenDetection; + + if (isDetectionChangedFromPreferences) { + this.#restartTokenDetection({ + selectedAddress: selectedAccount.address, + }).catch(() => { + // Silently handle token detection errors + }); + } + }, + ); + + this.messenger.subscribe( + 'AccountsController:selectedEvmAccountChange', + (selectedAccount) => { + const { networkConfigurationsByChainId } = this.messenger.call( + 'NetworkController:getState', + ); + + const chainIds = Object.keys(networkConfigurationsByChainId) as Hex[]; + const isSelectedAccountIdChanged = + this.#selectedAccountId !== selectedAccount.id; + if (isSelectedAccountIdChanged) { + this.#selectedAccountId = selectedAccount.id; + this.#restartTokenDetection({ + selectedAddress: selectedAccount.address, + chainIds, + }).catch(() => { + // Silently handle token detection errors + }); + } + }, + ); + + this.messenger.subscribe( + 'TransactionController:transactionConfirmed', + (transactionMeta) => { + this.detectTokens({ + chainIds: [transactionMeta.chainId], + }).catch(() => { + // Silently handle token detection errors + }); + }, + ); + } + + /** + * Allows controller to make active and passive polling requests + */ + enable(): void { + this.#disabled = false; + } + + /** + * Blocks controller from making network calls + */ + disable(): void { + this.#disabled = true; + } + + /** + * Internal isActive state + * + * @returns Whether the controller is active (not disabled and keyring is unlocked) + */ + get isActive(): boolean { + return !this.#disabled && this.#isUnlocked; } /** * Start polling for detected tokens. */ - async start() { - this.configure({ disabled: false }); - await this.startPolling(); + async start(): Promise { + if (this.#isDeprecated()) { + this.#enforceDisabledState(); + return; + } + this.enable(); + await this.#startPolling(); } /** * Stop polling for detected tokens. */ - stop() { - this.configure({ disabled: true }); - this.stopPolling(); + stop(): void { + this.disable(); + this.#stopPolling(); } - private stopPolling() { - if (this.intervalId) { - clearInterval(this.intervalId); + #stopPolling(): void { + if (this.#intervalId) { + clearInterval(this.#intervalId); } } /** * Starts a new polling interval. - * - * @param interval - An interval on which to poll. */ - private async startPolling(interval?: number): Promise { - interval && this.configure({ interval }, false, false); - this.stopPolling(); + async #startPolling(): Promise { + if (!this.isActive) { + return; + } + this.#stopPolling(); await this.detectTokens(); - this.intervalId = setInterval(async () => { + // TODO: Either fix this lint violation or explain why it's necessary to ignore. + // eslint-disable-next-line @typescript-eslint/no-misused-promises + this.#intervalId = setInterval(async () => { await this.detectTokens(); - }, this.config.interval); + }, this.getIntervalLength()); + } + + #getCorrectNetworkClientIdByChainId( + chainIds: Hex[] | undefined, + ): { chainId: Hex; networkClientId: NetworkClientId }[] { + const { networkConfigurationsByChainId, selectedNetworkClientId } = + this.messenger.call('NetworkController:getState'); + + if (!chainIds) { + const networkConfiguration = this.messenger.call( + 'NetworkController:getNetworkConfigurationByNetworkClientId', + selectedNetworkClientId, + ); + + return [ + { + chainId: networkConfiguration?.chainId ?? ChainId.mainnet, + networkClientId: selectedNetworkClientId, + }, + ]; + } + + return chainIds.map((chainId) => { + const configuration = networkConfigurationsByChainId[chainId]; + return { + chainId, + networkClientId: + configuration.rpcEndpoints[configuration.defaultRpcEndpointIndex] + .networkClientId, + }; + }); + } + + async _executePoll({ + chainIds, + address, + }: TokenDetectionPollingInput): Promise { + if (this.#isDeprecated()) { + this.#enforceDisabledState(); + return; + } + if (!this.isActive) { + return; + } + await this.detectTokens({ + chainIds, + selectedAddress: address, + }); + } + + /** + * Restart token detection polling period and call detectNewTokens + * in case of address change or user session initialization. + * + * @param options - Options for restart token detection. + * @param options.selectedAddress - the selectedAddress against which to detect for token balances + * @param options.chainIds - The chain IDs of the network client to use. + */ + async #restartTokenDetection({ + selectedAddress, + chainIds, + }: { + selectedAddress?: string; + chainIds?: Hex[]; + } = {}): Promise { + await this.detectTokens({ + chainIds, + selectedAddress, + }); + this.setIntervalLength(DEFAULT_INTERVAL); } /** - * Triggers asset ERC20 token auto detection for each contract address in contract metadata on mainnet. + * Returns the token cache for `chainId` if detection should proceed, or `null` if it + * should be skipped. Each call fetches a fresh snapshot from `TokenListService` (which + * may serve from its in-memory cache) so concurrent calls for different chains never + * overwrite each other's data. + * + * @param chainId - The chain ID to build a detection cache for. + * @returns A `TokensChainsCache` scoped to `chainId`, or `null` when detection should be skipped. */ - async detectTokens() { - const { - disabled, - isDetectionEnabledForNetwork, - isDetectionEnabledFromPreferences, - } = this.config; + async #getChainCacheForDetection( + chainId: Hex, + ): Promise { + if (!isTokenDetectionSupportedForNetwork(chainId)) { + return null; + } if ( - disabled || - !isDetectionEnabledForNetwork || - !isDetectionEnabledFromPreferences + !this.#isDetectionEnabledFromPreferences && + chainId !== ChainId.mainnet ) { + return null; + } + + const isMainnetDetectionInactive = + !this.#isDetectionEnabledFromPreferences && chainId === ChainId.mainnet; + if (isMainnetDetectionInactive) { + return this.#getConvertedStaticMainnetTokenList(); + } + + const tokenListMap = + await this.#tokenListService.fetchTokensByChainId(chainId); + return this.#applyMusdDefaultToTokensChainsCache(chainId, { + [chainId]: { data: tokenListMap, timestamp: Date.now() }, + }); + } + + async #detectTokensUsingRpc( + chainsToDetectUsingRpc: NetworkClient[], + addressToDetect: string, + ): Promise { + for (const { chainId, networkClientId } of chainsToDetectUsingRpc) { + const chainCache = await this.#getChainCacheForDetection(chainId); + if (!chainCache) { + continue; + } + + const tokenCandidateSlices = this.#getSlicesOfTokensToDetect({ + chainId, + chainCache, + selectedAddress: addressToDetect, + }); + const tokenDetectionPromises = tokenCandidateSlices.map((tokensSlice) => + this.#addDetectedTokens({ + tokensSlice, + selectedAddress: addressToDetect, + networkClientId, + chainId, + chainCache, + }), + ); + + await Promise.all(tokenDetectionPromises); + } + } + + /** + * For each token in the token list provided by the TokenListService, checks the token's balance for the selected account address on the active network. + * On mainnet, if token detection is disabled in preferences, ERC20 token auto detection will be triggered for each contract address in the legacy token list from the @metamask/contract-metadata repo. + * + * @param options - Options for token detection. + * @param options.chainIds - The chain IDs of the network client to use. + * @param options.selectedAddress - the selectedAddress against which to detect for token balances. + * @param options.forceRpc - Force RPC-based token detection for all specified chains, + * bypassing external services check and ensuring RPC is used even for chains + * that might otherwise be handled by the Accounts API. + */ + async detectTokens({ + chainIds, + selectedAddress, + forceRpc = false, + }: { + chainIds?: Hex[]; + selectedAddress?: string; + forceRpc?: boolean; + } = {}): Promise { + if (this.#isDeprecated()) { + this.#enforceDisabledState(); + return; + } + if (!this.isActive) { + return; + } + + // When forceRpc is true, bypass the useTokenDetection check to ensure RPC detection runs + if (!forceRpc && !this.#useTokenDetection()) { + return; + } + + // If external services are disabled and not forcing RPC, skip all detection + if (!forceRpc && !this.#useExternalServices()) { + return; + } + + const addressToDetect = selectedAddress ?? this.#getSelectedAddress(); + const clientNetworks = this.#getCorrectNetworkClientIdByChainId(chainIds); + + // If forceRpc is true, use RPC for all chains + // Otherwise, skip chains supported by Accounts API (they are handled by TokenBalancesController) + const chainsToDetectUsingRpc = forceRpc + ? clientNetworks + : clientNetworks.filter( + ({ chainId }) => + !SUPPORTED_NETWORKS_ACCOUNTS_API_V4.includes(chainId), + ); + + if (chainsToDetectUsingRpc.length === 0) { return; } - const { tokens } = this.getTokensState(); - const { selectedAddress, chainId } = this.config; - const tokensAddresses = tokens.map( - /* istanbul ignore next*/ (token) => token.address.toLowerCase(), + await this.#detectTokensUsingRpc(chainsToDetectUsingRpc, addressToDetect); + } + + #getSlicesOfTokensToDetect({ + chainId, + chainCache, + selectedAddress, + }: { + chainId: Hex; + chainCache: TokensChainsCache; + selectedAddress: string; + }): string[][] { + const { allTokens, allDetectedTokens, allIgnoredTokens } = + this.messenger.call('TokensController:getState'); + const [tokensAddresses, detectedTokensAddresses, ignoredTokensAddresses] = [ + allTokens, + allDetectedTokens, + allIgnoredTokens, + ].map((tokens) => + (tokens[chainId]?.[selectedAddress] ?? []).map((value) => + typeof value === 'string' ? value : value.address, + ), ); - const { tokenList } = this.getTokenListState(); + const tokensToDetect: string[] = []; - for (const address in tokenList) { - if (!tokensAddresses.includes(address)) { - tokensToDetect.push(address); + for (const tokenAddress of Object.keys(chainCache[chainId]?.data ?? {})) { + if ( + [ + tokensAddresses, + detectedTokensAddresses, + ignoredTokensAddresses, + ].every( + (addresses) => + !addresses.find((address) => + isEqualCaseInsensitive(address, tokenAddress), + ), + ) + ) { + tokensToDetect.push(tokenAddress); } } - const sliceOfTokensToDetect = []; - sliceOfTokensToDetect[0] = tokensToDetect.slice(0, 1000); - sliceOfTokensToDetect[1] = tokensToDetect.slice( - 1000, - tokensToDetect.length - 1, + + const slicesOfTokensToDetect = []; + for (let i = 0, size = 1000; i < tokensToDetect.length; i += size) { + slicesOfTokensToDetect.push(tokensToDetect.slice(i, i + size)); + } + + return slicesOfTokensToDetect; + } + + #getConvertedStaticMainnetTokenList(): TokensChainsCache { + const data: TokenListMap = Object.entries(STATIC_MAINNET_TOKEN_LIST).reduce( + (acc, [key, value]) => ({ + ...acc, + [key]: { + name: value.name, + symbol: value.symbol, + decimals: value.decimals, + address: value.address, + aggregators: [], + iconUrl: value?.iconUrl, + }, + }), + {}, ); + const dataWithMusd = this.#mergeMusdIntoTokenListMap(ChainId.mainnet, data); + return { + '0x1': { + data: dataWithMusd, + timestamp: 0, + }, + }; + } - /* istanbul ignore else */ - if (!selectedAddress) { - return; + /** + * mUSD token list row derived from Tokens API v3/assets (baked in for offline detection). + * + * @param chainId - Hex chain id (mainnet, Linea, or Monad). + * @returns Token list entry for the detection cache. + */ + #buildMusdTokenListToken(chainId: Hex): TokenListToken { + const meta = + MUSD_TOKEN_METADATA_BY_CHAIN[ + chainId as (typeof MUSD_TOKEN_DETECTION_CHAIN_IDS)[number] + ]; + return { + address: MUSD_ERC20_ADDRESS_LOWER, + name: meta.name, + symbol: meta.symbol, + decimals: meta.decimals, + aggregators: [...meta.aggregators], + iconUrl: formatIconUrlWithProxy({ + chainId, + tokenAddress: MUSD_ERC20_ADDRESS_LOWER, + }), + occurrences: 999, + }; + } + + /** + * Merge mUSD into a flat token map when the chain is one of the default mUSD networks. + * + * @param chainId - Network being detected. + * @param data - Existing token map for that chain. + * @returns New map including mUSD when applicable. + */ + #mergeMusdIntoTokenListMap(chainId: Hex, data: TokenListMap): TokenListMap { + return { + ...data, + [MUSD_ERC20_ADDRESS_LOWER]: this.#buildMusdTokenListToken(chainId), + }; + } + + /** + * Shallow-clone the token list cache for the current chain and merge mUSD so we never + * mutate the cache by reference. + * + * @param chainId - Network being detected. + * @param cache - Full tokens-by-chain cache. + * @returns Cache object safe to read and mutate for this detection pass. + */ + #applyMusdDefaultToTokensChainsCache( + chainId: Hex, + cache: TokensChainsCache, + ): TokensChainsCache { + if (!MUSD_TOKEN_DETECTION_CHAIN_ID_SET.has(chainId)) { + return cache; + } + const existing = cache[chainId]; + return { + ...cache, + [chainId]: { + data: this.#mergeMusdIntoTokenListMap(chainId, existing?.data ?? {}), + timestamp: existing?.timestamp ?? 0, + }, + }; + } + + /** + * If mUSD is in the (possibly merged) token list for this chain, include its address + * in the slice so we still run detection when balance is zero (single-call / Accounts API + * / WebSocket do not list the contract when balance is zero). + * + * @param tokensSlice - Address batch from the caller. + * @param chainId - Network being updated. + * @returns The slice, possibly with mUSD appended. + */ + #includeMusdInTokenDetectionSlice( + tokensSlice: string[], + chainId: Hex, + ): string[] { + if (!MUSD_TOKEN_DETECTION_CHAIN_ID_SET.has(chainId)) { + return tokensSlice; + } + if ( + tokensSlice.some((a) => + isEqualCaseInsensitive(a, MUSD_ERC20_ADDRESS_LOWER), + ) + ) { + return tokensSlice; } + return [...tokensSlice, MUSD_ERC20_ADDRESS_LOWER]; + } + + async #addDetectedTokens({ + tokensSlice, + selectedAddress, + networkClientId, + chainId, + chainCache, + }: { + tokensSlice: string[]; + selectedAddress: string; + networkClientId: NetworkClientId; + chainId: Hex; + chainCache: TokensChainsCache; + }): Promise { + await safelyExecute(async () => { + const balances = await this.#getBalancesInSingleCall( + selectedAddress, + tokensSlice, + networkClientId, + ); - for (const tokensSlice of sliceOfTokensToDetect) { - if (tokensSlice.length === 0) { - break; + const chainData = chainCache[chainId]?.data ?? {}; + const tokensWithBalance: Token[] = []; + const eventTokensDetails: string[] = []; + for (const nonZeroTokenAddress of Object.keys(balances)) { + // chainData keys are lowercase (normalised by buildTokenListMap); + // balance keys are checksummed, so normalise before lookup. + const tokenListEntry = chainData[nonZeroTokenAddress.toLowerCase()]; + if (!tokenListEntry) { + continue; + } + const { decimals, symbol, aggregators, iconUrl, name, rwaData } = + tokenListEntry; + eventTokensDetails.push(`${symbol} - ${nonZeroTokenAddress}`); + tokensWithBalance.push({ + address: nonZeroTokenAddress, + decimals, + symbol, + aggregators, + image: iconUrl, + isERC721: false, + name, + ...(rwaData && { rwaData }), + }); } - await safelyExecute(async () => { - const balances = await this.getBalancesInSingleCall( - selectedAddress, - tokensSlice, + // mUSD is always in the chain token cache on supported networks, but + // getBalancesInSingleCall omits zero balances; still add mUSD so the wallet + // shows the asset (balance updates via the usual balance pipeline). + if (MUSD_TOKEN_DETECTION_CHAIN_ID_SET.has(chainId)) { + const musdInSlice = tokensSlice.some((addr) => + isEqualCaseInsensitive(addr, MUSD_ERC20_ADDRESS_LOWER), ); - const tokensToAdd: Token[] = []; - for (const tokenAddress in balances) { - let ignored; - /* istanbul ignore else */ - const { ignoredTokens } = this.getTokensState(); - if (ignoredTokens.length) { - ignored = ignoredTokens.find( - (ignoredTokenAddress) => - ignoredTokenAddress === toChecksumHexAddress(tokenAddress), - ); - } - const caseInsensitiveTokenKey = - Object.keys(tokenList).find( - (i) => i.toLowerCase() === tokenAddress.toLowerCase(), - ) || ''; - - if (ignored === undefined) { - const { decimals, symbol, aggregators, iconUrl, name } = - tokenList[caseInsensitiveTokenKey]; - tokensToAdd.push({ - address: tokenAddress, + const musdHasNonZeroFromRpc = Object.keys(balances).some((addr) => + isEqualCaseInsensitive(addr, MUSD_ERC20_ADDRESS_LOWER), + ); + if (musdInSlice && !musdHasNonZeroFromRpc) { + const musdListToken = Object.entries(chainData).find(([key]) => + isEqualCaseInsensitive(key, MUSD_ERC20_ADDRESS_LOWER), + )?.[1]; + if (musdListToken) { + const { decimals, symbol, aggregators, iconUrl, name, rwaData } = + musdListToken; + eventTokensDetails.push(`${symbol} - ${MUSD_ERC20_ADDRESS_LOWER}`); + tokensWithBalance.push({ + address: MUSD_ERC20_ADDRESS_LOWER, decimals, symbol, aggregators, image: iconUrl, isERC721: false, name, + ...(rwaData && { rwaData }), }); } } + } - if (tokensToAdd.length) { - await this.addDetectedTokens(tokensToAdd, { - selectedAddress, - chainId, - }); - } + if (tokensWithBalance.length) { + this.#trackMetaMetricsEvent({ + event: 'Token Detected', + category: 'Wallet', + properties: { + tokens: eventTokensDetails, + token_standard: ERC20, + asset_type: ASSET_TYPES.TOKEN, + }, + }); + + await this.messenger.call( + 'TokensController:addTokens', + tokensWithBalance, + networkClientId, + ); + } + }); + } + + /** + * Add tokens detected from websocket balance updates + * This method: + * - Checks if useTokenDetection preference is enabled (skips if disabled) + * - Checks if external services are enabled (skips if disabled) + * - Tokens are expected to be in the tokensChainsCache with full metadata + * - Balance fetching is skipped since balances are provided by the websocket + * - Ignored tokens have been filtered out by the caller + * + * @param options - The options object + * @param options.tokensSlice - Array of token addresses detected from websocket (already filtered to exclude ignored tokens) + * @param options.chainId - Hex chain ID + * @returns Promise that resolves when tokens are added + */ + async addDetectedTokensViaWs({ + tokensSlice, + chainId, + }: { + tokensSlice: string[]; + chainId: Hex; + }): Promise { + if (this.#isDeprecated()) { + this.#enforceDisabledState(); + return; + } + // Check if token detection is enabled via preferences + if (!this.#useTokenDetection()) { + return; + } + + // Check if external services are enabled (websocket requires external services) + if (!this.#useExternalServices()) { + return; + } + + let tokenListMap: TokenListMap; + try { + tokenListMap = await this.#tokenListService.fetchTokensByChainId(chainId); + } catch { + // These methods return void; there is no token array to return. + // Gracefully exit so the caller is unaffected — the next polling cycle + // will retry the fetch. + return; + } + const chainCache = this.#applyMusdDefaultToTokensChainsCache(chainId, { + [chainId]: { data: tokenListMap, timestamp: Date.now() }, + }); + + const effectiveSlice = this.#includeMusdInTokenDetectionSlice( + tokensSlice, + chainId, + ); + + const tokensWithBalance: Token[] = []; + const eventTokensDetails: string[] = []; + + for (const tokenAddress of effectiveSlice) { + // Normalize addresses explicitly (don't assume input format) + const lowercaseTokenAddress = tokenAddress.toLowerCase(); + const checksummedTokenAddress = toChecksumHexAddress(tokenAddress); + + // Check map of validated tokens (cache keys are lowercase) + const tokenData = chainCache[chainId]?.data?.[lowercaseTokenAddress]; + + if (!tokenData) { + continue; + } + + const { decimals, symbol, aggregators, iconUrl, name, rwaData } = + tokenData; + + // Push to lists with checksummed address (for allTokens storage) + eventTokensDetails.push(`${symbol} - ${checksummedTokenAddress}`); + tokensWithBalance.push({ + address: checksummedTokenAddress, + decimals, + symbol, + aggregators, + image: iconUrl, + isERC721: false, + name, + ...(rwaData && { rwaData }), }); } + + // Perform addition + if (tokensWithBalance.length) { + this.#trackMetaMetricsEvent({ + event: 'Token Detected', + category: 'Wallet', + properties: { + tokens: eventTokensDetails, + token_standard: ERC20, + asset_type: ASSET_TYPES.TOKEN, + }, + }); + + const networkClientId = this.messenger.call( + 'NetworkController:findNetworkClientIdByChainId', + chainId, + ); + + await this.messenger.call( + 'TokensController:addTokens', + tokensWithBalance, + networkClientId, + ); + } + } + + /** + * Add tokens detected from polling balance updates + * This method: + * - Checks if useTokenDetection preference is enabled (skips if disabled) + * - Checks if external services are enabled (skips if disabled) + * - Filters out tokens already in allTokens or allIgnoredTokens + * - Tokens are expected to be in the tokensChainsCache with full metadata + * - Balance fetching is skipped since balances are provided by the caller + * + * @param options - The options object + * @param options.tokensSlice - Array of token addresses detected from polling + * @param options.chainId - Hex chain ID + * @returns Promise that resolves when tokens are added + */ + async addDetectedTokensViaPolling({ + tokensSlice, + chainId, + }: { + tokensSlice: string[]; + chainId: Hex; + }): Promise { + if (this.#isDeprecated()) { + this.#enforceDisabledState(); + return; + } + // Check if token detection is enabled via preferences + if (!this.#useTokenDetection()) { + return; + } + + // Check if external services are enabled (polling via API requires external services) + if (!this.#useExternalServices()) { + return; + } + + let tokenListMap: TokenListMap; + try { + tokenListMap = await this.#tokenListService.fetchTokensByChainId(chainId); + } catch { + // These methods return void; there is no token array to return. + // Gracefully exit so the caller is unaffected — the next polling cycle + // will retry the fetch. + return; + } + const chainCache = this.#applyMusdDefaultToTokensChainsCache(chainId, { + [chainId]: { data: tokenListMap, timestamp: Date.now() }, + }); + + const selectedAddress = this.#getSelectedAddress(); + + // Get current token states to filter out already tracked/ignored tokens + const { allTokens, allIgnoredTokens } = this.messenger.call( + 'TokensController:getState', + ); + + const existingTokenAddresses = ( + allTokens[chainId]?.[selectedAddress] ?? [] + ).map((token) => token.address.toLowerCase()); + + const ignoredTokenAddresses = ( + allIgnoredTokens[chainId]?.[selectedAddress] ?? [] + ).map((address) => address.toLowerCase()); + + const effectiveSlice = this.#includeMusdInTokenDetectionSlice( + tokensSlice, + chainId, + ); + + const tokensWithBalance: Token[] = []; + const eventTokensDetails: string[] = []; + + for (const tokenAddress of effectiveSlice) { + const lowercaseTokenAddress = tokenAddress.toLowerCase(); + const checksummedTokenAddress = toChecksumHexAddress(tokenAddress); + + // Skip tokens already in allTokens + if (existingTokenAddresses.includes(lowercaseTokenAddress)) { + continue; + } + + // Skip tokens in allIgnoredTokens + if (ignoredTokenAddresses.includes(lowercaseTokenAddress)) { + continue; + } + + // Check map of validated tokens (cache keys are lowercase) + const tokenData = chainCache[chainId]?.data?.[lowercaseTokenAddress]; + + if (!tokenData) { + continue; + } + + const { decimals, symbol, aggregators, iconUrl, name, rwaData } = + tokenData; + + eventTokensDetails.push(`${symbol} - ${checksummedTokenAddress}`); + tokensWithBalance.push({ + address: checksummedTokenAddress, + decimals, + symbol, + aggregators, + image: iconUrl, + isERC721: false, + name, + ...(rwaData && { rwaData }), + }); + } + + // Perform addition + if (tokensWithBalance.length) { + this.#trackMetaMetricsEvent({ + event: 'Token Detected', + category: 'Wallet', + properties: { + tokens: eventTokensDetails, + token_standard: ERC20, + asset_type: ASSET_TYPES.TOKEN, + }, + }); + + const networkClientId = this.messenger.call( + 'NetworkController:findNetworkClientIdByChainId', + chainId, + ); + + await this.messenger.call( + 'TokensController:addTokens', + tokensWithBalance, + networkClientId, + ); + } + } + + #getSelectedAccount(): InternalAccount { + return this.messenger.call('AccountsController:getSelectedAccount'); + } + + #getSelectedAddress(): string { + // If the address is not defined (or empty), we fallback to the currently selected account's address + const account = this.messenger.call( + 'AccountsController:getAccount', + this.#selectedAccountId, + ); + return account?.address ?? ''; } } diff --git a/packages/assets-controllers/src/TokenListController.test.ts b/packages/assets-controllers/src/TokenListController.test.ts index eafdddb2cc7..120ad956399 100644 --- a/packages/assets-controllers/src/TokenListController.test.ts +++ b/packages/assets-controllers/src/TokenListController.test.ts @@ -1,37 +1,40 @@ -import { ControllerMessenger } from '@metamask/base-controller'; +import { deriveStateFromMetadata } from '@metamask/base-controller'; import { ChainId, NetworkType, - NetworksTicker, convertHexToDecimal, toHex, + InfuraNetworkType, } from '@metamask/controller-utils'; +import { Messenger, MOCK_ANY_NAMESPACE } from '@metamask/messenger'; import type { - NetworkControllerGetNetworkClientByIdAction, - NetworkControllerStateChangeEvent, - NetworkState, - ProviderConfig, -} from '@metamask/network-controller'; -import { NetworkStatus } from '@metamask/network-controller'; -import nock from 'nock'; -import * as sinon from 'sinon'; - -import * as tokenService from './token-service'; + MessengerActions, + MessengerEvents, + MockAnyNamespace, +} from '@metamask/messenger'; +import type { NetworkState } from '@metamask/network-controller'; +import { StorageGetResult } from '@metamask/storage-service'; +import type { Hex } from '@metamask/utils'; +import nock, { cleanAll } from 'nock'; + +import { jestAdvanceTime } from '../../../tests/helpers.js'; +import { + buildCustomNetworkClientConfiguration, + buildInfuraNetworkClientConfiguration, + buildMockGetNetworkClientById, +} from '../../network-controller/tests/helpers.js'; +import * as tokenService from './token-service.js'; import type { - TokenListStateChange, - GetTokenListState, TokenListMap, TokenListState, -} from './TokenListController'; -import { TokenListController } from './TokenListController'; + TokenListControllerMessenger, + DataCache, +} from './TokenListController.js'; +import { TokenListController } from './TokenListController.js'; -const name = 'TokenListController'; +const namespace = 'TokenListController'; const timestamp = Date.now(); -const flushPromises = () => { - return new Promise(jest.requireActual('timers').setImmediate); -}; - const sampleMainnetTokenList = [ { address: '0xc011a73ee8576fb46f5e1c5751ca3b9fe0af2a6f', @@ -40,7 +43,7 @@ const sampleMainnetTokenList = [ occurrences: 11, name: 'Synthetix', iconUrl: - 'https://static.metafi.codefi.network/api/v1/tokenIcons/1/0xc011a73ee8576fb46f5e1c5751ca3b9fe0af2a6f.png', + 'https://static.cx.metamask.io/api/v1/tokenIcons/1/0xc011a73ee8576fb46f5e1c5751ca3b9fe0af2a6f.png', aggregators: [ 'Aave', 'Bancor', @@ -63,7 +66,7 @@ const sampleMainnetTokenList = [ occurrences: 11, name: 'Chainlink', iconUrl: - 'https://static.metafi.codefi.network/api/v1/tokenIcons/1/0x514910771af9ca656af840dff83e8264ecf986ca.png', + 'https://static.cx.metamask.io/api/v1/tokenIcons/1/0x514910771af9ca656af840dff83e8264ecf986ca.png', aggregators: [ 'Aave', 'Bancor', @@ -85,38 +88,7 @@ const sampleMainnetTokenList = [ occurrences: 11, name: 'Bancor', iconUrl: - 'https://static.metafi.codefi.network/api/v1/tokenIcons/1/0x1f573d6fb3f13d689ff844b4ce37794d79a7ff1c.png', - aggregators: [ - 'Bancor', - 'CMC', - 'CoinGecko', - '1inch', - 'Paraswap', - 'PMM', - 'Zapper', - 'Zerion', - '0x', - ], - }, -]; - -const sampleMainnetTokensChainsCache = sampleMainnetTokenList.reduce( - (output, current) => { - output[current.address] = current; - return output; - }, - {} as TokenListMap, -); - -const sampleWithDuplicateSymbols = [ - { - address: '0x1f573d6fb3f13d689ff844b4ce37794d79a7ff1c', - symbol: 'BNT', - decimals: 18, - occurrences: 11, - name: 'Bancor', - iconUrl: - 'https://static.metafi.codefi.network/api/v1/tokenIcons/1/0x1f573d6fb3f13d689ff844b4ce37794d79a7ff1c.png', + 'https://static.cx.metamask.io/api/v1/tokenIcons/1/0x1f573d6fb3f13d689ff844b4ce37794d79a7ff1c.png', aggregators: [ 'Bancor', 'CMC', @@ -131,67 +103,11 @@ const sampleWithDuplicateSymbols = [ }, ]; -const sampleWithDuplicateSymbolsTokensChainsCache = - sampleWithDuplicateSymbols.reduce((output, current) => { +const sampleMainnetTokensChainsCache = + sampleMainnetTokenList.reduce((output, current) => { output[current.address] = current; return output; - }, {} as TokenListMap); - -const sampleWithLessThan3OccurencesResponse = [ - { - address: '0xc011a73ee8576fb46f5e1c5751ca3b9fe0af2a6f', - symbol: 'SNX', - decimals: 18, - occurrences: 2, - name: 'Synthetix', - iconUrl: - 'https://static.metafi.codefi.network/api/v1/tokenIcons/1/0xc011a73ee8576fb46f5e1c5751ca3b9fe0af2a6f.png', - aggregators: [ - 'Aave', - 'Bancor', - 'CMC', - 'Crypto.com', - 'CoinGecko', - '1inch', - 'Paraswap', - 'PMM', - 'Synthetix', - 'Zapper', - 'Zerion', - '0x', - ], - }, - { - address: '0x514910771af9ca656af840dff83e8264ecf986ca', - symbol: 'LINK', - decimals: 18, - occurrences: 11, - name: 'Chainlink', - iconUrl: - 'https://static.metafi.codefi.network/api/v1/tokenIcons/1/0x514910771af9ca656af840dff83e8264ecf986ca.png', - aggregators: [ - 'Aave', - 'Bancor', - 'CMC', - 'Crypto.com', - 'CoinGecko', - '1inch', - 'Paraswap', - 'PMM', - 'Zapper', - 'Zerion', - '0x', - ], - }, -]; - -const sampleWith3OrMoreOccurrences = - sampleWithLessThan3OccurencesResponse.reduce((output, token) => { - if (token.occurrences >= 3) { - output[token.address] = token; - } - return output; - }, {} as TokenListMap); + }, {}); const sampleBinanceTokenList = [ { @@ -208,7 +124,7 @@ const sampleBinanceTokenList = [ 'Paraswap', ], iconUrl: - 'https://static.metafi.codefi.network/api/v1/tokenIcons/56/0x7083609fce4d1d8dc0c979aab8c869ea2c873402.png', + 'https://static.cx.metamask.io/api/v1/tokenIcons/56/0x7083609fce4d1d8dc0c979aab8c869ea2c873402.png', }, { address: '0x1af3f329e8be154074d8769d1ffa4ee058b1dbc3', @@ -225,17 +141,15 @@ const sampleBinanceTokenList = [ 'Paraswap', ], iconUrl: - 'https://static.metafi.codefi.network/api/v1/tokenIcons/56/0x1af3f329e8be154074d8769d1ffa4ee058b1dbc3.png', + 'https://static.cx.metamask.io/api/v1/tokenIcons/56/0x1af3f329e8be154074d8769d1ffa4ee058b1dbc3.png', }, ]; -const sampleBinanceTokensChainsCache = sampleBinanceTokenList.reduce( - (output, current) => { +const sampleBinanceTokensChainsCache = + sampleBinanceTokenList.reduce((output, current) => { output[current.address] = current; return output; - }, - {} as TokenListMap, -); + }, {}); const sampleSingleChainState = { tokenList: { @@ -246,7 +160,7 @@ const sampleSingleChainState = { occurrences: 11, name: 'Synthetix', iconUrl: - 'https://static.metafi.codefi.network/api/v1/tokenIcons/1/0xc011a73ee8576fb46f5e1c5751ca3b9fe0af2a6f.png', + 'https://static.cx.metamask.io/api/v1/tokenIcons/1/0xc011a73ee8576fb46f5e1c5751ca3b9fe0af2a6f.png', aggregators: [ 'Aave', 'Bancor', @@ -269,7 +183,7 @@ const sampleSingleChainState = { occurrences: 11, name: 'Chainlink', iconUrl: - 'https://static.metafi.codefi.network/api/v1/tokenIcons/1/0x514910771af9ca656af840dff83e8264ecf986ca.png', + 'https://static.cx.metamask.io/api/v1/tokenIcons/1/0x514910771af9ca656af840dff83e8264ecf986ca.png', aggregators: [ 'Aave', 'Bancor', @@ -291,7 +205,7 @@ const sampleSingleChainState = { occurrences: 11, name: 'Bancor', iconUrl: - 'https://static.metafi.codefi.network/api/v1/tokenIcons/1/0x1f573d6fb3f13d689ff844b4ce37794d79a7ff1c.png', + 'https://static.cx.metamask.io/api/v1/tokenIcons/1/0x1f573d6fb3f13d689ff844b4ce37794d79a7ff1c.png', aggregators: [ 'Bancor', 'CMC', @@ -320,7 +234,7 @@ const sampleSepoliaTokenList = [ decimals: 8, name: 'Wrapped BTC', iconUrl: - 'https://static.metafi.codefi.network/api/v1/tokenIcons/11155111/0x2260fac5e5542a773aa44fbcfedf7c193bc2c599.png', + 'https://static.cx.metamask.io/api/v1/tokenIcons/11155111/0x2260fac5e5542a773aa44fbcfedf7c193bc2c599.png', type: 'erc20', aggregators: [ 'Metamask', @@ -351,7 +265,7 @@ const sampleSepoliaTokenList = [ decimals: 18, name: 'UMA', iconUrl: - 'https://static.metafi.codefi.network/api/v1/tokenIcons/11155111/0x04fa0d235c4abf4bcf4787af4cf447de572ef828.png', + 'https://static.cx.metamask.io/api/v1/tokenIcons/11155111/0x04fa0d235c4abf4bcf4787af4cf447de572ef828.png', type: 'erc20', aggregators: [ 'Metamask', @@ -377,7 +291,7 @@ const sampleSepoliaTokenList = [ decimals: 18, name: 'Gnosis Token', iconUrl: - 'https://static.metafi.codefi.network/api/v1/tokenIcons/11155111/0x6810e776880c02933d47db1b9fc05908e5386b96.png', + 'https://static.cx.metamask.io/api/v1/tokenIcons/11155111/0x6810e776880c02933d47db1b9fc05908e5386b96.png', type: 'erc20', aggregators: [ 'Metamask', @@ -398,13 +312,11 @@ const sampleSepoliaTokenList = [ }, ]; -const sampleSepoliaTokensChainCache = sampleSepoliaTokenList.reduce( - (output, current) => { +const sampleSepoliaTokensChainCache = + sampleSepoliaTokenList.reduce((output, current) => { output[current.address] = current; return output; - }, - {} as TokenListMap, -); + }, {}); const sampleTwoChainState = { tokenList: { @@ -422,7 +334,7 @@ const sampleTwoChainState = { 'Paraswap', ], iconUrl: - 'https://static.metafi.codefi.network/api/v1/tokenIcons/56/0x7083609fce4d1d8dc0c979aab8c869ea2c873402.png', + 'https://static.cx.metamask.io/api/v1/tokenIcons/56/0x7083609fce4d1d8dc0c979aab8c869ea2c873402.png', }, '0x1af3f329e8be154074d8769d1ffa4ee058b1dbc3': { address: '0x1af3f329e8be154074d8769d1ffa4ee058b1dbc3', @@ -439,7 +351,7 @@ const sampleTwoChainState = { 'Paraswap', ], iconUrl: - 'https://static.metafi.codefi.network/api/v1/tokenIcons/56/0x1af3f329e8be154074d8769d1ffa4ee058b1dbc3.png', + 'https://static.cx.metamask.io/api/v1/tokenIcons/56/0x1af3f329e8be154074d8769d1ffa4ee058b1dbc3.png', }, }, tokensChainsCache: { @@ -463,7 +375,7 @@ const existingState = { occurrences: 11, name: 'Chainlink', iconUrl: - 'https://static.metafi.codefi.network/api/v1/tokenIcons/1/0x514910771af9ca656af840dff83e8264ecf986ca.png', + 'https://static.cx.metamask.io/api/v1/tokenIcons/1/0x514910771af9ca656af840dff83e8264ecf986ca.png', aggregators: [ 'Aave', 'Bancor', @@ -485,7 +397,6 @@ const existingState = { data: sampleMainnetTokensChainsCache, }, }, - preventPollingOnNetworkRestart: false, }; const outdatedExistingState = { @@ -497,7 +408,7 @@ const outdatedExistingState = { occurrences: 11, name: 'Chainlink', iconUrl: - 'https://static.metafi.codefi.network/api/v1/tokenIcons/1/0x514910771af9ca656af840dff83e8264ecf986ca.png', + 'https://static.cx.metamask.io/api/v1/tokenIcons/1/0x514910771af9ca656af840dff83e8264ecf986ca.png', aggregators: [ 'Aave', 'Bancor', @@ -519,34 +430,9 @@ const outdatedExistingState = { data: sampleMainnetTokensChainsCache, }, }, - preventPollingOnNetworkRestart: false, }; const expiredCacheExistingState: TokenListState = { - tokenList: { - '0x514910771af9ca656af840dff83e8264ecf986ca': { - address: '0x514910771af9ca656af840dff83e8264ecf986ca', - symbol: 'LINK', - decimals: 18, - occurrences: 9, - name: 'Chainlink', - iconUrl: - 'https://static.metafi.codefi.network/api/v1/tokenIcons/1/0x514910771af9ca656af840dff83e8264ecf986ca.png', - aggregators: [ - 'Aave', - 'Bancor', - 'CMC', - 'Crypto.com', - 'CoinGecko', - '1inch', - 'Paraswap', - 'PMM', - 'Zapper', - 'Zerion', - '0x', - ], - }, - }, tokensChainsCache: { [toHex(1)]: { timestamp: timestamp - 86400000, @@ -558,7 +444,7 @@ const expiredCacheExistingState: TokenListState = { occurrences: 11, name: 'Chainlink', iconUrl: - 'https://static.metafi.codefi.network/api/v1/tokenIcons/1/0x514910771af9ca656af840dff83e8264ecf986ca.png', + 'https://static.cx.metamask.io/api/v1/tokenIcons/1/0x514910771af9ca656af840dff83e8264ecf986ca.png', aggregators: [ 'Aave', 'Bancor', @@ -576,93 +462,137 @@ const expiredCacheExistingState: TokenListState = { }, }, }, - preventPollingOnNetworkRestart: false, }; -type MainControllerMessenger = ControllerMessenger< - GetTokenListState | NetworkControllerGetNetworkClientByIdAction, - TokenListStateChange | NetworkControllerStateChangeEvent +type AllTokenListControllerActions = + MessengerActions; + +type AllTokenListControllerEvents = + MessengerEvents; + +type RootMessenger = Messenger< + MockAnyNamespace, + AllTokenListControllerActions, + AllTokenListControllerEvents >; -const getControllerMessenger = (): MainControllerMessenger => { - return new ControllerMessenger(); -}; +// Mock storage for StorageService +const mockStorage = new Map(); -const getRestrictedMessenger = ( - controllerMessenger: MainControllerMessenger, -) => { - const messenger = controllerMessenger.getRestricted({ - name, - allowedActions: ['NetworkController:getNetworkClientById'], - allowedEvents: [ - 'TokenListController:stateChange', - 'NetworkController:stateChange', - ], +const getMessenger = (): RootMessenger => { + const messenger: RootMessenger = new Messenger({ + namespace: MOCK_ANY_NAMESPACE, }); + // Register StorageService mock handlers + messenger.registerActionHandler( + 'StorageService:getItem', + async ( + controllerNamespace: string, + key: string, + ): Promise => { + const storageKey = `${controllerNamespace}:${key}`; + const value = mockStorage.get(storageKey); + const result = (value ? { result: value } : {}) as StorageGetResult; + return result; + }, + ); + + messenger.registerActionHandler( + 'StorageService:setItem', + async ( + controllerNamespace: string, + key: string, + value: unknown, + ): Promise => { + const storageKey = `${controllerNamespace}:${key}`; + mockStorage.set(storageKey, value); + }, + ); + + messenger.registerActionHandler( + 'StorageService:getAllKeys', + async (controllerNamespace: string): Promise => { + const keys: string[] = []; + const prefix = `${controllerNamespace}:`; + mockStorage.forEach((_value, key) => { + // Only include keys for this namespace + if (key.startsWith(prefix)) { + const keyWithoutNamespace = key.substring(prefix.length); + keys.push(keyWithoutNamespace); + } + }); + return keys; + }, + ); + return messenger; }; -/** - * Builds an object that satisfies the NetworkState shape using the given - * provider config. This can be used to return a complete value for the - * `NetworkController:stateChange` event. - * - * @param providerConfig - The provider config to use. - * @returns A complete state object for NetworkController. - */ -function buildNetworkControllerStateWithProviderConfig( - providerConfig: ProviderConfig, -): NetworkState { - const selectedNetworkClientId = providerConfig.type || 'uuid-1'; - return { - selectedNetworkClientId, - providerConfig, - networksMetadata: { - [selectedNetworkClientId]: { - EIPS: {}, - status: NetworkStatus.Available, - }, - }, - networkConfigurations: {}, - }; -} +const getRestrictedMessenger = ( + messenger: RootMessenger, +): TokenListControllerMessenger => { + const tokenListControllerMessenger = new Messenger< + typeof namespace, + AllTokenListControllerActions, + AllTokenListControllerEvents, + RootMessenger + >({ + namespace, + parent: messenger, + }); + messenger.delegate({ + messenger: tokenListControllerMessenger, + actions: [ + 'NetworkController:getNetworkClientById', + 'StorageService:getItem', + 'StorageService:setItem', + 'StorageService:getAllKeys', + ], + events: ['NetworkController:stateChange'], + }); + return tokenListControllerMessenger; +}; describe('TokenListController', () => { + beforeEach(() => { + // Clear mock storage between tests + mockStorage.clear(); + tokenService.resetSuggestedOccurrenceFloorsCacheForTesting(); + nock(tokenService.TOKEN_END_POINT_API) + .get('/v1/suggestedOccurrenceFloors') + .reply(200, { '1': 3, '59144': 1 }) + .persist(); + }); + afterEach(() => { - jest.restoreAllMocks(); jest.clearAllTimers(); - sinon.restore(); + cleanAll(); + tokenService.resetSuggestedOccurrenceFloorsCacheForTesting(); }); it('should set default state', async () => { - const controllerMessenger = getControllerMessenger(); - const messenger = getRestrictedMessenger(controllerMessenger); + const messenger = getMessenger(); + const restrictedMessenger = getRestrictedMessenger(messenger); const controller = new TokenListController({ chainId: ChainId.mainnet, - preventPollingOnNetworkRestart: false, - messenger, + messenger: restrictedMessenger, }); expect(controller.state).toStrictEqual({ - tokenList: {}, tokensChainsCache: {}, - preventPollingOnNetworkRestart: false, }); controller.destroy(); - controllerMessenger.clearEventSubscriptions( - 'NetworkController:stateChange', - ); + messenger.clearEventSubscriptions('NetworkController:stateChange'); }); it('should initialize with initial state', () => { - const controllerMessenger = getControllerMessenger(); - const messenger = getRestrictedMessenger(controllerMessenger); + const messenger = getMessenger(); + const restrictedMessenger = getRestrictedMessenger(messenger); const controller = new TokenListController({ chainId: ChainId.mainnet, - preventPollingOnNetworkRestart: false, - messenger, + messenger: restrictedMessenger, state: existingState, }); expect(controller.state).toStrictEqual({ @@ -674,7 +604,7 @@ describe('TokenListController', () => { occurrences: 11, name: 'Chainlink', iconUrl: - 'https://static.metafi.codefi.network/api/v1/tokenIcons/1/0x514910771af9ca656af840dff83e8264ecf986ca.png', + 'https://static.cx.metamask.io/api/v1/tokenIcons/1/0x514910771af9ca656af840dff83e8264ecf986ca.png', aggregators: [ 'Aave', 'Bancor', @@ -696,231 +626,278 @@ describe('TokenListController', () => { data: sampleMainnetTokensChainsCache, }, }, - preventPollingOnNetworkRestart: false, - }); - - controller.destroy(); - controllerMessenger.clearEventSubscriptions( - 'NetworkController:stateChange', - ); - }); - - it('should initiate without preventPollingOnNetworkRestart', async () => { - const controllerMessenger = getControllerMessenger(); - const messenger = getRestrictedMessenger(controllerMessenger); - const controller = new TokenListController({ - chainId: ChainId.mainnet, - messenger, - }); - - expect(controller.state).toStrictEqual({ - tokenList: {}, - tokensChainsCache: {}, - preventPollingOnNetworkRestart: false, }); controller.destroy(); + messenger.clearEventSubscriptions('NetworkController:stateChange'); }); it('should not poll before being started', async () => { - const controllerMessenger = getControllerMessenger(); - const messenger = getRestrictedMessenger(controllerMessenger); + const messenger = getMessenger(); + const restrictedMessenger = getRestrictedMessenger(messenger); const controller = new TokenListController({ chainId: ChainId.mainnet, - preventPollingOnNetworkRestart: false, interval: 100, - messenger, + messenger: restrictedMessenger, }); await new Promise((resolve) => setTimeout(() => resolve(), 150)); - expect(controller.state.tokenList).toStrictEqual({}); + expect(controller.state.tokensChainsCache).toStrictEqual({}); controller.destroy(); }); - it('should update tokenList state when network updates are passed via onNetworkStateChange callback', async () => { + it('should update tokensChainsCache state when network updates are passed via onNetworkStateChange callback', async () => { nock(tokenService.TOKEN_END_POINT_API) - .get(`/tokens/${convertHexToDecimal(ChainId.mainnet)}`) + .get(getTokensPath(ChainId.mainnet)) .reply(200, sampleMainnetTokenList) .persist(); - const controllerMessenger = getControllerMessenger(); - const messenger = getRestrictedMessenger(controllerMessenger); + jest.spyOn(Date, 'now').mockImplementation(() => 100); + const selectedNetworkClientId = 'selectedNetworkClientId'; + const messenger = getMessenger(); + const getNetworkClientById = buildMockGetNetworkClientById({ + [selectedNetworkClientId]: buildCustomNetworkClientConfiguration({ + chainId: toHex(1337), + }), + }); + messenger.registerActionHandler( + 'NetworkController:getNetworkClientById', + getNetworkClientById, + ); + const restrictedMessenger = getRestrictedMessenger(messenger); let onNetworkStateChangeCallback!: (state: NetworkState) => void; const controller = new TokenListController({ chainId: ChainId.mainnet, - onNetworkStateChange: (cb) => (onNetworkStateChangeCallback = cb), - preventPollingOnNetworkRestart: false, + onNetworkStateChange: (callback): void => { + onNetworkStateChangeCallback = callback; + }, interval: 100, - messenger, + messenger: restrictedMessenger, }); + // TODO: Either fix this lint violation or explain why it's necessary to ignore. + // eslint-disable-next-line @typescript-eslint/no-floating-promises controller.start(); await new Promise((resolve) => setTimeout(() => resolve(), 150)); - expect(controller.state.tokenList).toStrictEqual( - sampleSingleChainState.tokenList, - ); - onNetworkStateChangeCallback( - buildNetworkControllerStateWithProviderConfig({ - chainId: ChainId.goerli, - type: NetworkType.goerli, - ticker: NetworksTicker.goerli, - }), - ); + onNetworkStateChangeCallback({ + selectedNetworkClientId, + networkConfigurationsByChainId: {}, + networksMetadata: {}, + // @ts-expect-error This property isn't used and will get removed later. + providerConfig: {}, + }); await new Promise((resolve) => setTimeout(() => resolve(), 500)); - expect(controller.state.tokenList).toStrictEqual({}); + expect(controller.state.tokensChainsCache).toStrictEqual({ + '0x1': { + timestamp: 100, + data: { + '0xc011a73ee8576fb46f5e1c5751ca3b9fe0af2a6f': { + address: '0xc011a73ee8576fb46f5e1c5751ca3b9fe0af2a6f', + symbol: 'SNX', + decimals: 18, + occurrences: 11, + name: 'Synthetix', + iconUrl: + 'https://static.cx.metamask.io/api/v1/tokenIcons/1/0xc011a73ee8576fb46f5e1c5751ca3b9fe0af2a6f.png', + aggregators: [ + 'Aave', + 'Bancor', + 'CMC', + 'Crypto.com', + 'CoinGecko', + '1inch', + 'Paraswap', + 'PMM', + 'Synthetix', + 'Zapper', + 'Zerion', + '0x', + ], + }, + '0x514910771af9ca656af840dff83e8264ecf986ca': { + address: '0x514910771af9ca656af840dff83e8264ecf986ca', + symbol: 'LINK', + decimals: 18, + occurrences: 11, + name: 'Chainlink', + iconUrl: + 'https://static.cx.metamask.io/api/v1/tokenIcons/1/0x514910771af9ca656af840dff83e8264ecf986ca.png', + aggregators: [ + 'Aave', + 'Bancor', + 'CMC', + 'Crypto.com', + 'CoinGecko', + '1inch', + 'Paraswap', + 'PMM', + 'Zapper', + 'Zerion', + '0x', + ], + }, + '0x1f573d6fb3f13d689ff844b4ce37794d79a7ff1c': { + address: '0x1f573d6fb3f13d689ff844b4ce37794d79a7ff1c', + symbol: 'BNT', + decimals: 18, + occurrences: 11, + name: 'Bancor', + iconUrl: + 'https://static.cx.metamask.io/api/v1/tokenIcons/1/0x1f573d6fb3f13d689ff844b4ce37794d79a7ff1c.png', + aggregators: [ + 'Bancor', + 'CMC', + 'CoinGecko', + '1inch', + 'Paraswap', + 'PMM', + 'Zapper', + 'Zerion', + '0x', + ], + }, + }, + }, + '0x539': { timestamp: 100, data: {} }, + }); controller.destroy(); }); 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 controllerMessenger = getControllerMessenger(); - const messenger = getRestrictedMessenger(controllerMessenger); + const messenger = getMessenger(); + const restrictedMessenger = getRestrictedMessenger(messenger); const controller = new TokenListController({ chainId: ChainId.mainnet, - preventPollingOnNetworkRestart: false, interval: 100, - messenger, + messenger: restrictedMessenger, }); await controller.start(); 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 controllerMessenger = getControllerMessenger(); - const messenger = getRestrictedMessenger(controllerMessenger); + const messenger = getMessenger(); + const restrictedMessenger = getRestrictedMessenger(messenger); const controller = new TokenListController({ chainId: ChainId.mainnet, - preventPollingOnNetworkRestart: false, interval: 100, - messenger, + messenger: restrictedMessenger, }); await controller.start(); controller.stop(); // 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 controllerMessenger = getControllerMessenger(); - const messenger = getRestrictedMessenger(controllerMessenger); + const messenger = getMessenger(); + const restrictedMessenger = getRestrictedMessenger(messenger); const controller = new TokenListController({ chainId: ChainId.mainnet, - preventPollingOnNetworkRestart: false, interval: 100, - messenger, + messenger: restrictedMessenger, }); await controller.start(); controller.stop(); // 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 controllerMessenger = getControllerMessenger(); - const messenger = getRestrictedMessenger(controllerMessenger); + const messenger = getMessenger(); + const restrictedMessenger = getRestrictedMessenger(messenger); const controller = new TokenListController({ chainId: ChainId.mainnet, - preventPollingOnNetworkRestart: false, interval: 100, - messenger, + messenger: restrictedMessenger, }); await controller.start(); controller.stop(); // 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 controllerMessenger = getControllerMessenger(); - const messenger = getRestrictedMessenger(controllerMessenger); + const messenger = getMessenger(); + const restrictedMessenger = getRestrictedMessenger(messenger); const controller = new TokenListController({ chainId: ChainId.sepolia, - preventPollingOnNetworkRestart: false, interval: 100, - messenger, + messenger: restrictedMessenger, }); await controller.start(); controller.stop(); // called once upon initial start - expect(tokenListMock.called).toBe(false); + expect(tokenListMock).not.toHaveBeenCalled(); controller.destroy(); - tokenListMock.restore(); }); - it('should update token list from api', async () => { + it('should update tokensChainsCache from api', async () => { nock(tokenService.TOKEN_END_POINT_API) - .get(`/tokens/${convertHexToDecimal(ChainId.mainnet)}`) + .get(getTokensPath(ChainId.mainnet)) .reply(200, sampleMainnetTokenList) .persist(); - const controllerMessenger = getControllerMessenger(); - const messenger = getRestrictedMessenger(controllerMessenger); + const messenger = getMessenger(); + const restrictedMessenger = getRestrictedMessenger(messenger); const controller = new TokenListController({ chainId: ChainId.mainnet, - preventPollingOnNetworkRestart: false, - messenger, + messenger: restrictedMessenger, interval: 750, }); await controller.start(); try { await new Promise((resolve) => setTimeout(resolve, 1000)); - expect(controller.state.tokenList).toStrictEqual( - sampleSingleChainState.tokenList, - ); expect( controller.state.tokensChainsCache[ChainId.mainnet].data, @@ -941,145 +918,46 @@ describe('TokenListController', () => { it('should update the cache before threshold time if the current data is undefined', async () => { nock(tokenService.TOKEN_END_POINT_API) - .get(`/tokens/${convertHexToDecimal(ChainId.mainnet)}`) + .get(getTokensPath(ChainId.mainnet)) .once() .reply(200, undefined); nock(tokenService.TOKEN_END_POINT_API) - .get(`/tokens/${convertHexToDecimal(ChainId.mainnet)}`) + .get(getTokensPath(ChainId.mainnet)) .reply(200, sampleMainnetTokenList) .persist(); - const controllerMessenger = getControllerMessenger(); - const messenger = getRestrictedMessenger(controllerMessenger); + const messenger = getMessenger(); + const restrictedMessenger = getRestrictedMessenger(messenger); const controller = new TokenListController({ chainId: ChainId.mainnet, - preventPollingOnNetworkRestart: false, - messenger, + messenger: restrictedMessenger, interval: 100, + state: existingState, }); - await controller.start(); - expect(controller.state.tokenList).toStrictEqual({}); + const pollingToken = controller.startPolling({ chainId: ChainId.mainnet }); await new Promise((resolve) => setTimeout(() => resolve(), 150)); - expect(controller.state.tokenList).toStrictEqual( - sampleSingleChainState.tokenList, - ); - expect(controller.state.tokensChainsCache[toHex(1)].data).toStrictEqual( sampleSingleChainState.tokensChainsCache[toHex(1)].data, ); - controller.destroy(); - }); - - it('should update token list from cache before reaching the threshold time', async () => { - const controllerMessenger = getControllerMessenger(); - const messenger = getRestrictedMessenger(controllerMessenger); - const controller = new TokenListController({ - chainId: ChainId.mainnet, - preventPollingOnNetworkRestart: false, - messenger, - state: existingState, - }); - expect(controller.state).toStrictEqual(existingState); - await controller.start(); - expect(controller.state.tokenList).toStrictEqual( - sampleSingleChainState.tokenList, - ); - - expect( - controller.state.tokensChainsCache[ChainId.mainnet].data, - ).toStrictEqual( - sampleSingleChainState.tokensChainsCache[ChainId.mainnet].data, - ); - controller.destroy(); - }); - - it('should update token list after removing data with duplicate symbols', async () => { - nock(tokenService.TOKEN_END_POINT_API) - .get(`/tokens/${convertHexToDecimal(ChainId.mainnet)}`) - .reply(200, sampleWithDuplicateSymbols) - .persist(); - - const controllerMessenger = getControllerMessenger(); - const messenger = getRestrictedMessenger(controllerMessenger); - const controller = new TokenListController({ - chainId: ChainId.mainnet, - preventPollingOnNetworkRestart: false, - messenger, - }); - await controller.start(); - expect(controller.state.tokenList).toStrictEqual({ - '0x1f573d6fb3f13d689ff844b4ce37794d79a7ff1c': { - address: '0x1f573d6fb3f13d689ff844b4ce37794d79a7ff1c', - symbol: 'BNT', - decimals: 18, - occurrences: 11, - name: 'Bancor', - iconUrl: - 'https://static.metafi.codefi.network/api/v1/tokenIcons/1/0x1f573d6fb3f13d689ff844b4ce37794d79a7ff1c.png', - aggregators: [ - 'Bancor', - 'CMC', - 'CoinGecko', - '1inch', - 'Paraswap', - 'PMM', - 'Zapper', - 'Zerion', - '0x', - ], - }, - }); - - expect( - controller.state.tokensChainsCache[ChainId.mainnet].data, - ).toStrictEqual(sampleWithDuplicateSymbolsTokensChainsCache); - controller.destroy(); - }); - - it('should update token list after removing data less than 3 occurrences', async () => { - nock(tokenService.TOKEN_END_POINT_API) - .get(`/tokens/${convertHexToDecimal(ChainId.mainnet)}`) - .reply(200, sampleWithLessThan3OccurencesResponse) - .persist(); - - const controllerMessenger = getControllerMessenger(); - const messenger = getRestrictedMessenger(controllerMessenger); - const controller = new TokenListController({ - chainId: ChainId.mainnet, - preventPollingOnNetworkRestart: false, - messenger, - }); - await controller.start(); - expect(controller.state.tokenList).toStrictEqual( - sampleWith3OrMoreOccurrences, - ); - - expect( - controller.state.tokensChainsCache[ChainId.mainnet].data, - ).toStrictEqual(sampleWith3OrMoreOccurrences); - controller.destroy(); + controller.stopPollingByPollingToken(pollingToken); }); it('should update token list when the token property changes', async () => { nock(tokenService.TOKEN_END_POINT_API) - .get(`/tokens/${convertHexToDecimal(ChainId.mainnet)}`) + .get(getTokensPath(ChainId.mainnet)) .reply(200, sampleMainnetTokenList) .persist(); - const controllerMessenger = getControllerMessenger(); - const messenger = getRestrictedMessenger(controllerMessenger); + const messenger = getMessenger(); + const restrictedMessenger = getRestrictedMessenger(messenger); const controller = new TokenListController({ chainId: ChainId.mainnet, - preventPollingOnNetworkRestart: false, - messenger, + messenger: restrictedMessenger, state: outdatedExistingState, }); expect(controller.state).toStrictEqual(outdatedExistingState); await controller.start(); - expect(controller.state.tokenList).toStrictEqual( - sampleSingleChainState.tokenList, - ); expect( controller.state.tokensChainsCache[ChainId.mainnet].data, @@ -1091,16 +969,15 @@ describe('TokenListController', () => { it('should update the cache when the timestamp expires', async () => { nock(tokenService.TOKEN_END_POINT_API) - .get(`/tokens/${convertHexToDecimal(ChainId.mainnet)}`) + .get(getTokensPath(ChainId.mainnet)) .reply(200, sampleMainnetTokenList) .persist(); - const controllerMessenger = getControllerMessenger(); - const messenger = getRestrictedMessenger(controllerMessenger); + const messenger = getMessenger(); + const restrictedMessenger = getRestrictedMessenger(messenger); const controller = new TokenListController({ chainId: ChainId.mainnet, - preventPollingOnNetworkRestart: false, - messenger, + messenger: restrictedMessenger, state: expiredCacheExistingState, }); expect(controller.state).toStrictEqual(expiredCacheExistingState); @@ -1119,30 +996,42 @@ describe('TokenListController', () => { controller.destroy(); }); - it('should update token list when the chainId change', async () => { + it('should update tokensChainsCache when the chainId change', async () => { nock(tokenService.TOKEN_END_POINT_API) - .get(`/tokens/${convertHexToDecimal(ChainId.mainnet)}`) + .get(getTokensPath(ChainId.mainnet)) .reply(200, sampleMainnetTokenList) - .get(`/tokens/${convertHexToDecimal(ChainId.goerli)}`) - .reply(200, { error: 'ChainId 5 is not supported' }) - .get(`/tokens/56`) + .get(getTokensPath(ChainId.sepolia)) + .reply(200, { + error: `ChainId ${convertHexToDecimal( + ChainId.sepolia, + )} is not supported`, + }) + .get(getTokensPath(toHex(56))) .reply(200, sampleBinanceTokenList) .persist(); - - const controllerMessenger = getControllerMessenger(); - const messenger = getRestrictedMessenger(controllerMessenger); + const selectedCustomNetworkClientId = 'selectedCustomNetworkClientId'; + const messenger = getMessenger(); + const getNetworkClientById = buildMockGetNetworkClientById({ + [InfuraNetworkType.sepolia]: buildInfuraNetworkClientConfiguration( + InfuraNetworkType.sepolia, + ), + [selectedCustomNetworkClientId]: buildCustomNetworkClientConfiguration({ + chainId: toHex(56), + }), + }); + messenger.registerActionHandler( + 'NetworkController:getNetworkClientById', + getNetworkClientById, + ); + const restrictedMessenger = getRestrictedMessenger(messenger); const controller = new TokenListController({ chainId: ChainId.mainnet, - preventPollingOnNetworkRestart: false, - messenger, + messenger: restrictedMessenger, state: existingState, interval: 100, }); expect(controller.state).toStrictEqual(existingState); await controller.start(); - expect(controller.state.tokenList).toStrictEqual( - sampleSingleChainState.tokenList, - ); expect( controller.state.tokensChainsCache[ChainId.mainnet].data, @@ -1150,40 +1039,39 @@ describe('TokenListController', () => { sampleTwoChainState.tokensChainsCache[ChainId.mainnet].data, ); - controllerMessenger.publish( + messenger.publish( 'NetworkController:stateChange', - buildNetworkControllerStateWithProviderConfig({ - type: NetworkType.goerli, - chainId: ChainId.goerli, - ticker: NetworksTicker.goerli, - }), + { + selectedNetworkClientId: InfuraNetworkType.sepolia, + networkConfigurationsByChainId: {}, + networksMetadata: {}, + // @ts-expect-error This property isn't used and will get removed later. + providerConfig: {}, + }, [], ); await new Promise((resolve) => setTimeout(() => resolve(), 500)); - expect(controller.state.tokenList).toStrictEqual({}); expect( controller.state.tokensChainsCache[ChainId.mainnet].data, ).toStrictEqual( sampleTwoChainState.tokensChainsCache[ChainId.mainnet].data, ); - controllerMessenger.publish( + messenger.publish( 'NetworkController:stateChange', - buildNetworkControllerStateWithProviderConfig({ - type: NetworkType.rpc, - chainId: toHex(56), - rpcUrl: 'http://localhost:8545', - ticker: 'TEST', - }), + { + selectedNetworkClientId: selectedCustomNetworkClientId, + networkConfigurationsByChainId: {}, + networksMetadata: {}, + // @ts-expect-error This property isn't used and will get removed later. + providerConfig: {}, + }, [], ); await new Promise((resolve) => setTimeout(() => resolve(), 500)); - expect(controller.state.tokenList).toStrictEqual( - sampleTwoChainState.tokenList, - ); expect( controller.state.tokensChainsCache[ChainId.mainnet].data, @@ -1198,107 +1086,97 @@ describe('TokenListController', () => { controller.destroy(); }); - it('should clear the tokenList and tokensChainsCache', async () => { - const controllerMessenger = getControllerMessenger(); - const messenger = getRestrictedMessenger(controllerMessenger); - const controller = new TokenListController({ - chainId: ChainId.mainnet, - preventPollingOnNetworkRestart: false, - messenger, - state: existingState, + describe('_executePoll', () => { + beforeEach(() => { + jest.useFakeTimers(); }); - expect(controller.state).toStrictEqual(existingState); - controller.clearingTokenListData(); - - expect(controller.state.tokenList).toStrictEqual({}); - expect(controller.state.tokensChainsCache).toStrictEqual({}); - controller.destroy(); - }); + afterEach(() => { + jest.useRealTimers(); + }); - it('should update preventPollingOnNetworkRestart and restart the polling on network restart', async () => { - nock(tokenService.TOKEN_END_POINT_API) - .get(`/tokens/${convertHexToDecimal(ChainId.mainnet)}`) - .reply(200, sampleMainnetTokenList) - .get(`/tokens/${convertHexToDecimal(ChainId.goerli)}`) - .reply(200, { error: 'ChainId 5 is not supported' }) - .get(`/tokens/56`) - .reply(200, sampleBinanceTokenList) - .persist(); + const arrange = ({ + initializationDelayMs = 50, + }: { + initializationDelayMs?: number; + } = {}): { + controller: TokenListController; + fetchTokenListByChainIdSpy: jest.SpyInstance; + resolveMockWaitForInit: () => Promise; + } => { + const messenger = getMessenger(); + + // Mock getAllKeys that takes time to resolve + messenger.unregisterActionHandler('StorageService:getAllKeys'); + messenger.registerActionHandler( + 'StorageService:getAllKeys', + () => + new Promise((resolve) => { + setTimeout(() => resolve([]), initializationDelayMs); + }), + ); - const controllerMessenger = getControllerMessenger(); - const messenger = getRestrictedMessenger(controllerMessenger); - const controller = new TokenListController({ - chainId: ChainId.goerli, - preventPollingOnNetworkRestart: true, - messenger, - interval: 100, - }); - await controller.start(); - controllerMessenger.publish( - 'NetworkController:stateChange', - buildNetworkControllerStateWithProviderConfig({ - type: NetworkType.mainnet, + const restrictedMessenger = getRestrictedMessenger(messenger); + const fetchTokenListByChainIdSpy = jest + .spyOn(tokenService, 'fetchTokenListByChainId') + .mockResolvedValue(sampleMainnetTokenList); + const controller = new TokenListController({ chainId: ChainId.mainnet, - ticker: NetworksTicker.mainnet, - }), - [], - ); + messenger: restrictedMessenger, + }); - expect(controller.state).toStrictEqual({ - tokenList: {}, - tokensChainsCache: {}, - preventPollingOnNetworkRestart: true, - }); - controller.updatePreventPollingOnNetworkRestart(false); - expect(controller.state).toStrictEqual({ - tokenList: {}, - tokensChainsCache: {}, - preventPollingOnNetworkRestart: false, - }); + const resolveMockWaitForInit = async (): Promise => { + await jestAdvanceTime({ duration: 50 }); + }; - await new Promise((resolve: any) => { - messenger.subscribe('TokenListController:stateChange', (_, patch) => { - const tokenListChanged = patch.find( - (p) => Object.keys(p.value.tokenList).length !== 0, - ); - if (!tokenListChanged) { - return; - } + return { + controller, + fetchTokenListByChainIdSpy, + resolveMockWaitForInit, + }; + }; + + it('waits for initialize to finish before polling', async () => { + const { controller, fetchTokenListByChainIdSpy, resolveMockWaitForInit } = + arrange(); - expect(controller.state.tokenList).toStrictEqual( - sampleTwoChainState.tokenList, - ); - - expect( - controller.state.tokensChainsCache[toHex(56)].data, - ).toStrictEqual(sampleTwoChainState.tokensChainsCache[toHex(56)].data); - messenger.clearEventSubscriptions('TokenListController:stateChange'); - controller.destroy(); - controllerMessenger.clearEventSubscriptions( - 'NetworkController:stateChange', - ); - resolve(); + const initializePromise = controller.initialize(); + const pollPromise = controller._executePoll({ + chainId: ChainId.mainnet, }); - controllerMessenger.publish( - 'NetworkController:stateChange', - buildNetworkControllerStateWithProviderConfig({ - type: NetworkType.rpc, - chainId: toHex(56), - rpcUrl: 'http://localhost:8545', - ticker: 'TEST', - }), - [], - ); + expect(fetchTokenListByChainIdSpy).not.toHaveBeenCalled(); + + await resolveMockWaitForInit(); + await initializePromise; + await pollPromise; + + expect(fetchTokenListByChainIdSpy).toHaveBeenCalledTimes(1); + }); + + it('polls normally when initialization is not in progress', async () => { + const { controller, fetchTokenListByChainIdSpy } = arrange({ + initializationDelayMs: 0, + }); + + await controller._executePoll({ chainId: ChainId.mainnet }); + expect(fetchTokenListByChainIdSpy).toHaveBeenCalledTimes(1); }); }); - describe('startPollingByNetworkClient', () => { - it('should call fetchTokenListByChainId with the correct chainId', async () => { + describe('startPolling', () => { + const pollingIntervalTime = 1000; + beforeEach(() => { jest.useFakeTimers(); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + it('should call fetchTokenListByChainId with the correct chainId', async () => { nock(tokenService.TOKEN_END_POINT_API) - .get(`/tokens/${convertHexToDecimal(ChainId.sepolia)}`) + .get(getTokensPath(ChainId.sepolia)) .reply(200, sampleSepoliaTokenList) .persist(); @@ -1306,8 +1184,8 @@ describe('TokenListController', () => { tokenService, 'fetchTokenListByChainId', ); - const controllerMessenger = getControllerMessenger(); - controllerMessenger.registerActionHandler( + const messenger = getMessenger(); + messenger.registerActionHandler( 'NetworkController:getNetworkClientById', jest.fn().mockReturnValue({ configuration: { @@ -1316,80 +1194,25 @@ describe('TokenListController', () => { }, }), ); - const pollingIntervalTime = 1000; - const messenger = getRestrictedMessenger(controllerMessenger); + const restrictedMessenger = getRestrictedMessenger(messenger); const controller = new TokenListController({ chainId: ChainId.mainnet, - preventPollingOnNetworkRestart: false, - messenger, + messenger: restrictedMessenger, state: expiredCacheExistingState, interval: pollingIntervalTime, }); - expect(controller.state.tokenList).toStrictEqual( - expiredCacheExistingState.tokenList, - ); - controller.startPollingByNetworkClientId('sepolia'); - jest.advanceTimersByTime(pollingIntervalTime); - await flushPromises(); + controller.startPolling({ chainId: ChainId.sepolia }); + await jestAdvanceTime({ duration: 0 }); expect(fetchTokenListByChainIdSpy.mock.calls[0]).toStrictEqual( expect.arrayContaining([ChainId.sepolia]), ); }); - it('should start polling against the token list API at the interval passed to the constructor', async () => { - jest.useFakeTimers(); - const pollingIntervalTime = 1000; - const fetchTokenListByChainIdSpy = jest.spyOn( - tokenService, - 'fetchTokenListByChainId', - ); - - const controllerMessenger = getControllerMessenger(); - controllerMessenger.registerActionHandler( - 'NetworkController:getNetworkClientById', - jest.fn().mockReturnValue({ - configuration: { - type: NetworkType.goerli, - chainId: ChainId.goerli, - }, - }), - ); - const messenger = getRestrictedMessenger(controllerMessenger); - const controller = new TokenListController({ - chainId: ChainId.mainnet, - preventPollingOnNetworkRestart: false, - messenger, - state: expiredCacheExistingState, - interval: pollingIntervalTime, - }); - expect(controller.state.tokenList).toStrictEqual( - expiredCacheExistingState.tokenList, - ); - - controller.startPollingByNetworkClientId('goerli'); - jest.advanceTimersByTime(pollingIntervalTime / 2); - await flushPromises(); - expect(fetchTokenListByChainIdSpy).toHaveBeenCalledTimes(0); - jest.advanceTimersByTime(pollingIntervalTime / 2); - await flushPromises(); - - expect(fetchTokenListByChainIdSpy).toHaveBeenCalledTimes(1); - await Promise.all([ - jest.advanceTimersByTime(pollingIntervalTime), - flushPromises(), - ]); - - await Promise.all([jest.runOnlyPendingTimers(), flushPromises()]); - expect(fetchTokenListByChainIdSpy).toHaveBeenCalledTimes(2); - }); it('should update tokenList state and tokensChainsCache', async () => { - jest.useFakeTimers(); const startingState: TokenListState = { - tokenList: {}, tokensChainsCache: {}, - preventPollingOnNetworkRestart: false, }; const fetchTokenListByChainIdSpy = jest @@ -1404,8 +1227,9 @@ describe('TokenListController', () => { throw new Error('Invalid chainId'); } }); - const controllerMessenger = getControllerMessenger(); - controllerMessenger.registerActionHandler( + + const messenger = getMessenger(); + messenger.registerActionHandler( 'NetworkController:getNetworkClientById', jest.fn().mockImplementation((networkClientId) => { switch (networkClientId) { @@ -1428,12 +1252,10 @@ describe('TokenListController', () => { } }), ); - const pollingIntervalTime = 1000; - const messenger = getRestrictedMessenger(controllerMessenger); + const restrictedMessenger = getRestrictedMessenger(messenger); const controller = new TokenListController({ - chainId: ChainId.mainnet, - preventPollingOnNetworkRestart: false, - messenger, + chainId: ChainId.sepolia, + messenger: restrictedMessenger, state: startingState, interval: pollingIntervalTime, }); @@ -1441,38 +1263,33 @@ describe('TokenListController', () => { expect(controller.state).toStrictEqual(startingState); // start polling for sepolia - await controller.startPollingByNetworkClientId('sepolia'); + const pollingToken = controller.startPolling({ + chainId: ChainId.sepolia, + }); + // wait a polling interval - jest.advanceTimersByTime(pollingIntervalTime); - await flushPromises(); + await jestAdvanceTime({ duration: pollingIntervalTime }); expect(fetchTokenListByChainIdSpy).toHaveBeenCalledTimes(1); - // expect the state to be updated with the sepolia token list - expect(controller.state.tokenList).toStrictEqual( - sampleSepoliaTokensChainCache, - ); + expect(controller.state.tokensChainsCache).toStrictEqual({ [ChainId.sepolia]: { timestamp: expect.any(Number), data: sampleSepoliaTokensChainCache, }, }); + controller.stopPollingByPollingToken(pollingToken); + // start polling for binance - await controller.startPollingByNetworkClientId( - 'binance-network-client-id', - ); - jest.advanceTimersByTime(pollingIntervalTime); - await flushPromises(); + controller.startPolling({ + chainId: '0x38', + }); + 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 expect(fetchTokenListByChainIdSpy).toHaveBeenCalledTimes(2); - // expect tokenList to be updated with the binance token list - // and the cache to now contain both the binance token list and the sepolia token list - expect(controller.state.tokenList).toStrictEqual( - sampleBinanceTokensChainsCache, - ); // once we adopt this polling pattern we should no longer access the root tokenList state // but rather access from the cache with a chainId selector. expect(controller.state.tokensChainsCache).toStrictEqual({ @@ -1487,4 +1304,1208 @@ describe('TokenListController', () => { }); }); }); -}); + + describe('metadata', () => { + it('includes expected state in debug snapshots', () => { + const controller = new TokenListController({ + chainId: ChainId.mainnet, + messenger: getRestrictedMessenger(getMessenger()), + }); + + expect( + deriveStateFromMetadata( + controller.state, + controller.metadata, + 'includeInDebugSnapshot', + ), + ).toMatchInlineSnapshot(` + { + "tokensChainsCache": {}, + } + `); + }); + + it('includes expected state in state logs', () => { + const controller = new TokenListController({ + chainId: ChainId.mainnet, + messenger: getRestrictedMessenger(getMessenger()), + }); + + expect( + deriveStateFromMetadata( + controller.state, + controller.metadata, + 'includeInStateLogs', + ), + ).toMatchInlineSnapshot(`{}`); + }); + + it('persists expected state', () => { + const controller = new TokenListController({ + chainId: ChainId.mainnet, + messenger: getRestrictedMessenger(getMessenger()), + }); + + expect( + deriveStateFromMetadata( + controller.state, + controller.metadata, + 'persist', + ), + ).toMatchInlineSnapshot(`{}`); + }); + + it('exposes expected state to UI', () => { + const controller = new TokenListController({ + chainId: ChainId.mainnet, + messenger: getRestrictedMessenger(getMessenger()), + }); + + expect( + deriveStateFromMetadata( + controller.state, + controller.metadata, + 'usedInUi', + ), + ).toMatchInlineSnapshot(` + { + "tokensChainsCache": {}, + } + `); + }); + }); + + describe('StorageService migration', () => { + // State changes after construction trigger debounced persistence + it('should persist state changes to StorageService via debounced subscription', async () => { + const messenger = getMessenger(); + const restrictedMessenger = getRestrictedMessenger(messenger); + + const controller = new TokenListController({ + chainId: ChainId.mainnet, + messenger: restrictedMessenger, + }); + + // Initialize the controller + await controller.initialize(); + + // Fetch tokens to trigger state change (which triggers persistence) + nock(tokenService.TOKEN_END_POINT_API) + .get(getTokensPath(ChainId.mainnet)) + .reply(200, sampleMainnetTokenList); + + await controller.fetchTokenList(ChainId.mainnet); + + // Wait for debounced persistence to complete (500ms + buffer) + await new Promise((resolve) => setTimeout(resolve, 600)); + + const chainStorageKey = `tokensChainsCache:${ChainId.mainnet}`; + const { result } = await messenger.call( + 'StorageService:getItem', + 'TokenListController', + chainStorageKey, + ); + + expect(result).toBeDefined(); + const resultCache = result as DataCache; + expect(resultCache.data).toBeDefined(); + expect(resultCache.timestamp).toBeDefined(); + + controller.destroy(); + }); + + it('should not overwrite StorageService if it already has data', async () => { + const messenger = getMessenger(); + const restrictedMessenger = getRestrictedMessenger(messenger); + + // Pre-populate StorageService with existing data (per-chain file) + const existingChainData: DataCache = { + data: sampleMainnetTokensChainsCache, + timestamp: Date.now(), + }; + const chainStorageKey = `tokensChainsCache:${ChainId.mainnet}`; + await messenger.call( + 'StorageService:setItem', + 'TokenListController', + chainStorageKey, + existingChainData, + ); + + // Initialize with different state data + const stateWithDifferentData = { + tokensChainsCache: { + [ChainId.mainnet]: { + data: sampleMainnetTokensChainsCache, + timestamp: Date.now(), + }, + }, + }; + + const controller = new TokenListController({ + chainId: ChainId.mainnet, + messenger: restrictedMessenger, + state: stateWithDifferentData, + }); + + // Initialize the controller to trigger storage migration logic + await controller.initialize(); + + // Verify StorageService still has original data (not overwritten) + const { result } = await messenger.call( + 'StorageService:getItem', + 'TokenListController', + chainStorageKey, + ); + + expect(result).toStrictEqual(existingChainData); + const resultCache = result as DataCache; + expect(resultCache.data).toStrictEqual(existingChainData.data); + + controller.destroy(); + }); + + it('should not migrate when state has empty tokensChainsCache', async () => { + const messenger = getMessenger(); + const restrictedMessenger = getRestrictedMessenger(messenger); + + const controller = new TokenListController({ + chainId: ChainId.mainnet, + messenger: restrictedMessenger, + state: { tokensChainsCache: {} }, + }); + + // Initialize the controller to trigger migration logic + await controller.initialize(); + + // Verify nothing was saved to StorageService (check no per-chain files) + const allKeys = await messenger.call( + 'StorageService:getAllKeys', + 'TokenListController', + ); + const cacheKeys = allKeys.filter((key) => + key.startsWith('tokensChainsCache:'), + ); + + expect(cacheKeys).toHaveLength(0); + + controller.destroy(); + }); + + it('should save and load tokensChainsCache from StorageService', async () => { + const messenger = getMessenger(); + const restrictedMessenger = getRestrictedMessenger(messenger); + + // Create controller and fetch tokens (which saves to storage) + const controller1 = new TokenListController({ + chainId: ChainId.mainnet, + messenger: restrictedMessenger, + }); + await controller1.initialize(); + + nock(tokenService.TOKEN_END_POINT_API) + .get(getTokensPath(ChainId.mainnet)) + .reply(200, sampleMainnetTokenList); + + await controller1.fetchTokenList(ChainId.mainnet); + const savedCache = controller1.state.tokensChainsCache; + + // Wait for debounced persistence to complete (500ms + buffer) + await new Promise((resolve) => setTimeout(resolve, 600)); + + controller1.destroy(); + + // Verify data is in StorageService (per-chain file) + const chainStorageKey = `tokensChainsCache:${ChainId.mainnet}`; + const { result } = await messenger.call( + 'StorageService:getItem', + 'TokenListController', + chainStorageKey, + ); + + expect(result).toBeDefined(); + expect(result).toStrictEqual(savedCache[ChainId.mainnet]); + }); + + it('should save tokensChainsCache to StorageService when fetching tokens', async () => { + const messenger = getMessenger(); + const restrictedMessenger = getRestrictedMessenger(messenger); + + nock(tokenService.TOKEN_END_POINT_API) + .get(getTokensPath(ChainId.mainnet)) + .reply(200, sampleMainnetTokenList); + + const controller = new TokenListController({ + chainId: ChainId.mainnet, + messenger: restrictedMessenger, + }); + await controller.initialize(); + + await controller.fetchTokenList(ChainId.mainnet); + + // Wait for debounced persistence to complete (500ms + buffer) + await new Promise((resolve) => setTimeout(resolve, 600)); + + // Verify data was saved to StorageService (per-chain file) + const chainStorageKey = `tokensChainsCache:${ChainId.mainnet}`; + const { result } = await messenger.call( + 'StorageService:getItem', + 'TokenListController', + chainStorageKey, + ); + + expect(result).toBeDefined(); + const resultCache = result as DataCache; + expect(resultCache.data).toBeDefined(); + expect(resultCache.timestamp).toBeDefined(); + + controller.destroy(); + }); + + it('should not save to StorageService before initialization', async () => { + const messenger = getMessenger(); + const restrictedMessenger = getRestrictedMessenger(messenger); + + // Create controller and fetch tokens + const controller1 = new TokenListController({ + chainId: ChainId.mainnet, + messenger: restrictedMessenger, + }); + // skip initialization + + nock(tokenService.TOKEN_END_POINT_API) + .get(getTokensPath(ChainId.mainnet)) + .reply(200, sampleMainnetTokenList); + + await controller1.fetchTokenList(ChainId.mainnet); + expect( + controller1.state.tokensChainsCache[ChainId.mainnet], + ).toBeDefined(); + + // Wait for debounced persistence to complete (500ms + buffer) + await new Promise((resolve) => setTimeout(resolve, 600)); + + controller1.destroy(); + + // Verify data is in StorageService (per-chain file) + const chainStorageKey = `tokensChainsCache:${ChainId.mainnet}`; + const { result } = await messenger.call( + 'StorageService:getItem', + 'TokenListController', + chainStorageKey, + ); + + expect(result).toBeUndefined(); + }); + + it('should save data updated before initialization to StorageService', async () => { + // Setup stale mainnet data in storage + const validChainData: DataCache = { + data: sampleMainnetTokensChainsCache, + timestamp: 1, + }; + mockStorage.set( + `TokenListController:tokensChainsCache:${ChainId.mainnet}`, + validChainData, + ); + const messenger = getMessenger(); + const restrictedMessenger = getRestrictedMessenger(messenger); + + // Create controller with delayed initialization, and fetch tokens + const controller = new TokenListController({ + chainId: ChainId.mainnet, + messenger: restrictedMessenger, + }); + + nock(tokenService.TOKEN_END_POINT_API) + .get(getTokensPath(ChainId.mainnet)) + .reply(200, sampleMainnetTokenList); + nock(tokenService.TOKEN_END_POINT_API) + .get(getTokensPath(toHex(56))) + .reply(200, sampleBinanceTokenList); + + await controller.fetchTokenList(ChainId.mainnet); + await controller.fetchTokenList(toHex(56)); + const savedCache = controller.state.tokensChainsCache; + expect(savedCache[ChainId.mainnet]).toBeDefined(); + expect(savedCache[toHex(56)]).toBeDefined(); + await controller.initialize(); + + // Wait for debounced persistence to complete (500ms + buffer) + await new Promise((resolve) => setTimeout(resolve, 600)); + + controller.destroy(); + + // Verify data is in StorageService (per-chain file) + const mainnetStorageKey = `tokensChainsCache:${ChainId.mainnet}`; + const { result: mainnetResult } = await messenger.call( + 'StorageService:getItem', + 'TokenListController', + mainnetStorageKey, + ); + const binanceStorageKey = `tokensChainsCache:${toHex(56)}`; + const { result: binanceResult } = await messenger.call( + 'StorageService:getItem', + 'TokenListController', + binanceStorageKey, + ); + + // Confirm fresh results overwrite stale + expect(mainnetResult).toBeDefined(); + expect(mainnetResult).toStrictEqual(savedCache[ChainId.mainnet]); + // Confirm results not in storage previously are persisted + expect(binanceResult).toBeDefined(); + expect(binanceResult).toStrictEqual(savedCache[toHex(56)]); + }); + + it('should handle errors when loading individual chain cache files', async () => { + // Pre-populate storage with two chains + const validChainData: DataCache = { + data: sampleMainnetTokensChainsCache, + timestamp: Date.now(), + }; + const binanceChainData: DataCache = { + data: sampleBinanceTokensChainsCache, + timestamp: Date.now(), + }; + + mockStorage.set( + `TokenListController:tokensChainsCache:${ChainId.mainnet}`, + validChainData, + ); + mockStorage.set( + `TokenListController:tokensChainsCache:${ChainId.goerli}`, + binanceChainData, + ); + + // Create messenger with getItem that returns error for goerli + const messengerWithErrors = getMessenger(); + + // Register getItem handler that returns error for goerli + messengerWithErrors.unregisterActionHandler('StorageService:getItem'); + messengerWithErrors.registerActionHandler( + 'StorageService:getItem', + async ( + controllerNamespace: string, + key: string, + ): Promise => { + if (key === `tokensChainsCache:${ChainId.goerli}`) { + return { + error: 'Failed to load chain data', + } as unknown as StorageGetResult; + } + const storageKey = `${controllerNamespace}:${key}`; + const value = mockStorage.get(storageKey); + return (value ? { result: value } : {}) as StorageGetResult; + }, + ); + + // Register other handlers normally + messengerWithErrors.unregisterActionHandler('StorageService:setItem'); + messengerWithErrors.registerActionHandler( + 'StorageService:setItem', + async ( + controllerNamespace: string, + key: string, + value: unknown, + ): Promise => { + const storageKey = `${controllerNamespace}:${key}`; + mockStorage.set(storageKey, value); + }, + ); + + messengerWithErrors.unregisterActionHandler('StorageService:getAllKeys'); + messengerWithErrors.registerActionHandler( + 'StorageService:getAllKeys', + async (controllerNamespace: string): Promise => { + const keys: string[] = []; + const prefix = `${controllerNamespace}:`; + mockStorage.forEach((_value, key) => { + // Only include keys for this namespace + if (key.startsWith(prefix)) { + const keyWithoutNamespace = key.substring(prefix.length); + keys.push(keyWithoutNamespace); + } + }); + return keys; + }, + ); + + const restrictedMessenger = getRestrictedMessenger(messengerWithErrors); + + // Mock console.error to verify it's called for the error case + const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(); + + const controller = new TokenListController({ + chainId: ChainId.mainnet, + messenger: restrictedMessenger, + }); + + // Initialize the controller to load from storage + await controller.initialize(); + + // Verify that mainnet chain loaded successfully + expect(controller.state.tokensChainsCache[ChainId.mainnet]).toBeDefined(); + expect( + controller.state.tokensChainsCache[ChainId.mainnet].data, + ).toStrictEqual(sampleMainnetTokensChainsCache); + + // Verify that goerli chain is not in the cache (due to error) + expect( + controller.state.tokensChainsCache[ChainId.goerli], + ).toBeUndefined(); + + // Verify console.error was called with the error + expect(consoleErrorSpy).toHaveBeenCalledWith( + `TokenListController: Error loading cache for ${ChainId.goerli}:`, + 'Failed to load chain data', + ); + + consoleErrorSpy.mockRestore(); + controller.destroy(); + }); + + it('should handle StorageService errors when saving cache', async () => { + // Create a messenger with setItem that throws errors + const messengerWithErrors = getMessenger(); + + // Register all handlers, but make setItem throw + messengerWithErrors.unregisterActionHandler('StorageService:setItem'); + messengerWithErrors.registerActionHandler( + 'StorageService:setItem', + () => { + throw new Error('Storage write failed'); + }, + ); + + const restrictedMessenger = getRestrictedMessenger(messengerWithErrors); + + // Mock console.error to verify it's called for save errors + const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(); + + const controller = new TokenListController({ + chainId: ChainId.mainnet, + messenger: restrictedMessenger, + }); + + // Initialize the controller + await controller.initialize(); + + // Try to fetch tokens - this should trigger save which will fail + nock(tokenService.TOKEN_END_POINT_API) + .get(getTokensPath(ChainId.mainnet)) + .reply(200, sampleMainnetTokenList); + + await controller.fetchTokenList(ChainId.mainnet); + + // Wait for debounced persistence to attempt (and fail) + await new Promise((resolve) => setTimeout(resolve, 600)); + + // Verify console.error was called with the save error + expect(consoleErrorSpy).toHaveBeenCalledWith( + `TokenListController: Failed to save cache for ${ChainId.mainnet}:`, + expect.any(Error), + ); + + // Verify state was still updated even though save failed + expect(controller.state.tokensChainsCache[ChainId.mainnet]).toBeDefined(); + + consoleErrorSpy.mockRestore(); + controller.destroy(); + }); + + it('should handle errors during debounced persistence', async () => { + // Create messenger where setItem throws to cause persistence to fail + const messengerWithErrors = getMessenger(); + + // Register setItem to throw error + messengerWithErrors.unregisterActionHandler('StorageService:setItem'); + messengerWithErrors.registerActionHandler( + 'StorageService:setItem', + () => { + throw new Error('Failed to save to storage'); + }, + ); + + const restrictedMessenger = getRestrictedMessenger(messengerWithErrors); + + // Mock console.error to verify it's called for persistence errors + const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(); + + const controller = new TokenListController({ + chainId: ChainId.mainnet, + messenger: restrictedMessenger, + }); + + // Initialize the controller + await controller.initialize(); + + // Fetch tokens to trigger state change (which triggers persistence) + nock(tokenService.TOKEN_END_POINT_API) + .get(getTokensPath(ChainId.mainnet)) + .reply(200, sampleMainnetTokenList); + + await controller.fetchTokenList(ChainId.mainnet); + + // Wait for debounced persistence to attempt (and fail) + await new Promise((resolve) => setTimeout(resolve, 600)); + + // Verify console.error was called with the save error (from #saveChainCacheToStorage) + expect(consoleErrorSpy).toHaveBeenCalledWith( + `TokenListController: Failed to save cache for ${ChainId.mainnet}:`, + expect.any(Error), + ); + + consoleErrorSpy.mockRestore(); + controller.destroy(); + }); + + it('should only load cache from storage once even when fetchTokenList is called multiple times', async () => { + // Pre-populate storage with cached data + const chainData: DataCache = { + data: sampleMainnetTokensChainsCache, + timestamp: Date.now(), + }; + mockStorage.set( + `TokenListController:tokensChainsCache:${ChainId.mainnet}`, + chainData, + ); + + // Track how many times getItem is called + let getItemCallCount = 0; + let getAllKeysCallCount = 0; + + const trackingMessenger = getMessenger(); + + trackingMessenger.unregisterActionHandler('StorageService:getItem'); + trackingMessenger.registerActionHandler( + 'StorageService:getItem', + async ( + controllerNamespace: string, + key: string, + ): Promise => { + getItemCallCount += 1; + const storageKey = `${controllerNamespace}:${key}`; + const value = mockStorage.get(storageKey); + return (value ? { result: value } : {}) as StorageGetResult; + }, + ); + + trackingMessenger.unregisterActionHandler('StorageService:getAllKeys'); + trackingMessenger.registerActionHandler( + 'StorageService:getAllKeys', + async (controllerNamespace: string): Promise => { + getAllKeysCallCount += 1; + const keys: string[] = []; + const prefix = `${controllerNamespace}:`; + mockStorage.forEach((_value, key) => { + if (key.startsWith(prefix)) { + const keyWithoutNamespace = key.substring(prefix.length); + keys.push(keyWithoutNamespace); + } + }); + return keys; + }, + ); + + const restrictedMessenger = getRestrictedMessenger(trackingMessenger); + + const controller = new TokenListController({ + chainId: ChainId.mainnet, + messenger: restrictedMessenger, + }); + + // Initialize the controller + await controller.initialize(); + + // Record call counts after initialization + const getItemCallsAfterInit = getItemCallCount; + const getAllKeysCallsAfterInit = getAllKeysCallCount; + + // getAllKeys should be called once during init (for loading cache) + expect(getAllKeysCallsAfterInit).toBe(1); + // getItem should be called once for the cached chain during load + expect(getItemCallsAfterInit).toBe(1); + + // Now call fetchTokenList multiple times + nock(tokenService.TOKEN_END_POINT_API) + .get(getTokensPath(ChainId.mainnet)) + .reply(200, sampleMainnetTokenList) + .persist(); + + await controller.fetchTokenList(ChainId.mainnet); + await controller.fetchTokenList(ChainId.mainnet); + await controller.fetchTokenList(ChainId.mainnet); + + // Verify getAllKeys was NOT called again after initialization + // (getItem may be called for other reasons, but getAllKeys is only used in load/migrate) + expect(getAllKeysCallCount).toBe(getAllKeysCallsAfterInit); + + controller.destroy(); + }); + + it('should NOT re-persist data loaded from storage during initialization', async () => { + // Pre-populate storage with cached data + const chainData: DataCache = { + data: sampleMainnetTokensChainsCache, + timestamp: Date.now(), + }; + mockStorage.set( + `TokenListController:tokensChainsCache:${ChainId.mainnet}`, + chainData, + ); + + // Track how many times setItem is called + let setItemCallCount = 0; + + const trackingMessenger = getMessenger(); + + trackingMessenger.unregisterActionHandler('StorageService:setItem'); + trackingMessenger.registerActionHandler( + 'StorageService:setItem', + async ( + controllerNamespace: string, + key: string, + value: unknown, + ): Promise => { + setItemCallCount += 1; + const storageKey = `${controllerNamespace}:${key}`; + mockStorage.set(storageKey, value); + }, + ); + + trackingMessenger.unregisterActionHandler('StorageService:getAllKeys'); + trackingMessenger.registerActionHandler( + 'StorageService:getAllKeys', + async (controllerNamespace: string): Promise => { + const keys: string[] = []; + const prefix = `${controllerNamespace}:`; + mockStorage.forEach((_value, key) => { + if (key.startsWith(prefix)) { + const keyWithoutNamespace = key.substring(prefix.length); + keys.push(keyWithoutNamespace); + } + }); + return keys; + }, + ); + + const restrictedMessenger = getRestrictedMessenger(trackingMessenger); + + const controller = new TokenListController({ + chainId: ChainId.mainnet, + messenger: restrictedMessenger, + }); + + // Initialize the controller - this should load from storage + await controller.initialize(); + + // Verify data was loaded correctly + expect(controller.state.tokensChainsCache[ChainId.mainnet]).toBeDefined(); + expect( + controller.state.tokensChainsCache[ChainId.mainnet].data, + ).toStrictEqual(sampleMainnetTokensChainsCache); + + // Wait longer than the debounce delay (500ms) to ensure any scheduled + // persistence would have executed + await new Promise((resolve) => setTimeout(resolve, 600)); + + // Verify setItem was NOT called - loaded data should not be re-persisted + expect(setItemCallCount).toBe(0); + + controller.destroy(); + }); + + it('should persist initial state chains when storage has different chains', async () => { + // Pre-populate storage with data for chain B (different from initial state) + const chainBData: DataCache = { + data: sampleBinanceTokensChainsCache, + timestamp: Date.now() - 1000, // Older timestamp + }; + mockStorage.set( + `TokenListController:tokensChainsCache:${ChainId['bsc-mainnet']}`, + chainBData, + ); + + // Track setItem calls and which chains are persisted + const persistedChains: string[] = []; + + const trackingMessenger = getMessenger(); + + trackingMessenger.unregisterActionHandler('StorageService:setItem'); + trackingMessenger.registerActionHandler( + 'StorageService:setItem', + async ( + controllerNamespace: string, + key: string, + value: unknown, + ): Promise => { + persistedChains.push(key); + const storageKey = `${controllerNamespace}:${key}`; + mockStorage.set(storageKey, value); + }, + ); + + trackingMessenger.unregisterActionHandler('StorageService:getAllKeys'); + trackingMessenger.registerActionHandler( + 'StorageService:getAllKeys', + async (controllerNamespace: string): Promise => { + const keys: string[] = []; + const prefix = `${controllerNamespace}:`; + mockStorage.forEach((_value, key) => { + if (key.startsWith(prefix)) { + const keyWithoutNamespace = key.substring(prefix.length); + keys.push(keyWithoutNamespace); + } + }); + return keys; + }, + ); + + const restrictedMessenger = getRestrictedMessenger(trackingMessenger); + + // Create initial state with chain A (mainnet) - NOT in storage + const chainAData: DataCache = { + data: sampleMainnetTokensChainsCache, + timestamp: Date.now(), + }; + + const controller = new TokenListController({ + chainId: ChainId.mainnet, + messenger: restrictedMessenger, + state: { + tokensChainsCache: { + [ChainId.mainnet]: chainAData, + }, + }, + }); + + // Initialize - this should load chain B from storage AND schedule chain A for persistence + await controller.initialize(); + + // Verify both chains are in state + expect(controller.state.tokensChainsCache[ChainId.mainnet]).toBeDefined(); + expect( + controller.state.tokensChainsCache[ChainId['bsc-mainnet']], + ).toBeDefined(); + + // Wait for debounced persistence to complete (500ms + buffer) + await new Promise((resolve) => setTimeout(resolve, 600)); + + // Verify chain A (mainnet) was persisted since it was in initial state but not in storage + expect(persistedChains).toContain(`tokensChainsCache:${ChainId.mainnet}`); + + // Verify chain B (bsc-mainnet) was NOT re-persisted since it was loaded from storage + expect(persistedChains).not.toContain( + `tokensChainsCache:${ChainId['bsc-mainnet']}`, + ); + + controller.destroy(); + }); + }); + describe('isDeprecated', () => { + it('resets tokensChainsCache to {} at construction when isDeprecated() returns true', () => { + const messenger = getMessenger(); + const restrictedMessenger = getRestrictedMessenger(messenger); + + const controller = new TokenListController({ + chainId: ChainId.mainnet, + messenger: restrictedMessenger, + state: existingState, + isDeprecated: (): boolean => true, + }); + + expect(controller.state.tokensChainsCache).toStrictEqual({}); + + controller.destroy(); + }); + + it('preserves initial state when isDeprecated() returns false', () => { + const messenger = getMessenger(); + const restrictedMessenger = getRestrictedMessenger(messenger); + + const controller = new TokenListController({ + chainId: ChainId.mainnet, + messenger: restrictedMessenger, + state: existingState, + isDeprecated: (): boolean => false, + }); + + expect( + controller.state.tokensChainsCache[ChainId.mainnet].data, + ).toStrictEqual(sampleMainnetTokensChainsCache); + + controller.destroy(); + }); + + it('overwrites all persisted cache keys with empty data on initialize() when disabled', async () => { + const messenger = getMessenger(); + const restrictedMessenger = getRestrictedMessenger(messenger); + + // Pre-populate StorageService with cached data for two chains + const persistedMainnet: DataCache = { + timestamp: 123, + data: sampleMainnetTokensChainsCache, + }; + const persistedBinance: DataCache = { + timestamp: 456, + data: sampleBinanceTokensChainsCache, + }; + await messenger.call( + 'StorageService:setItem', + 'TokenListController', + `tokensChainsCache:${ChainId.mainnet}`, + persistedMainnet, + ); + await messenger.call( + 'StorageService:setItem', + 'TokenListController', + `tokensChainsCache:${toHex(56)}`, + persistedBinance, + ); + + const controller = new TokenListController({ + chainId: ChainId.mainnet, + messenger: restrictedMessenger, + isDeprecated: (): boolean => true, + }); + + await controller.initialize(); + + const { result: mainnetResult } = await messenger.call( + 'StorageService:getItem', + 'TokenListController', + `tokensChainsCache:${ChainId.mainnet}`, + ); + const { result: binanceResult } = await messenger.call( + 'StorageService:getItem', + 'TokenListController', + `tokensChainsCache:${toHex(56)}`, + ); + + expect(mainnetResult).toStrictEqual({ data: {}, timestamp: 0 }); + expect(binanceResult).toStrictEqual({ data: {}, timestamp: 0 }); + expect(controller.state.tokensChainsCache).toStrictEqual({}); + + controller.destroy(); + }); + + it('does not load persisted cache into state on initialize() when disabled', async () => { + const messenger = getMessenger(); + const restrictedMessenger = getRestrictedMessenger(messenger); + + await messenger.call( + 'StorageService:setItem', + 'TokenListController', + `tokensChainsCache:${ChainId.mainnet}`, + { timestamp: 123, data: sampleMainnetTokensChainsCache }, + ); + + const controller = new TokenListController({ + chainId: ChainId.mainnet, + messenger: restrictedMessenger, + isDeprecated: (): boolean => true, + }); + + await controller.initialize(); + + expect(controller.state.tokensChainsCache).toStrictEqual({}); + + controller.destroy(); + }); + + it('returns early from start() without fetching when disabled, and resets state', async () => { + const fetchTokenListMock = jest + .spyOn(TokenListController.prototype, 'fetchTokenList') + .mockImplementation(); + + const messenger = getMessenger(); + const restrictedMessenger = getRestrictedMessenger(messenger); + + const controller = new TokenListController({ + chainId: ChainId.mainnet, + messenger: restrictedMessenger, + state: existingState, + isDeprecated: (): boolean => true, + }); + + await controller.start(); + + expect(fetchTokenListMock).not.toHaveBeenCalled(); + expect(controller.state.tokensChainsCache).toStrictEqual({}); + + controller.destroy(); + fetchTokenListMock.mockRestore(); + }); + + it('returns early from _executePoll() without fetching when disabled, and resets state', async () => { + const fetchTokenListByChainIdSpy = jest + .spyOn(tokenService, 'fetchTokenListByChainId') + .mockResolvedValue(sampleMainnetTokenList); + + const messenger = getMessenger(); + const restrictedMessenger = getRestrictedMessenger(messenger); + + const controller = new TokenListController({ + chainId: ChainId.mainnet, + messenger: restrictedMessenger, + state: existingState, + isDeprecated: (): boolean => true, + }); + + await controller._executePoll({ chainId: ChainId.mainnet }); + + expect(fetchTokenListByChainIdSpy).not.toHaveBeenCalled(); + expect(controller.state.tokensChainsCache).toStrictEqual({}); + + controller.destroy(); + fetchTokenListByChainIdSpy.mockRestore(); + }); + + it('re-evaluates isDeprecated() on each polling entry so it can be toggled at runtime', async () => { + let disabled = false; + const fetchTokenListByChainIdSpy = jest + .spyOn(tokenService, 'fetchTokenListByChainId') + .mockResolvedValue(sampleMainnetTokenList); + + const messenger = getMessenger(); + const restrictedMessenger = getRestrictedMessenger(messenger); + + const controller = new TokenListController({ + chainId: ChainId.mainnet, + messenger: restrictedMessenger, + isDeprecated: (): boolean => disabled, + }); + await controller.initialize(); + + // First poll: enabled — should fetch and populate state + await controller._executePoll({ chainId: ChainId.mainnet }); + expect(fetchTokenListByChainIdSpy).toHaveBeenCalledTimes(1); + expect(controller.state.tokensChainsCache[ChainId.mainnet]).toBeDefined(); + + // Toggle to disabled and poll again — should skip fetch and clear state + disabled = true; + await controller._executePoll({ chainId: ChainId.mainnet }); + expect(fetchTokenListByChainIdSpy).toHaveBeenCalledTimes(1); + expect(controller.state.tokensChainsCache).toStrictEqual({}); + + controller.destroy(); + fetchTokenListByChainIdSpy.mockRestore(); + }); + + it('skips the HTTP call and state write when fetchTokenList() is called directly while disabled', async () => { + const fetchTokenListByChainIdSpy = jest + .spyOn(tokenService, 'fetchTokenListByChainId') + .mockResolvedValue(sampleMainnetTokenList); + + const messenger = getMessenger(); + const restrictedMessenger = getRestrictedMessenger(messenger); + + const controller = new TokenListController({ + chainId: ChainId.mainnet, + messenger: restrictedMessenger, + state: existingState, + isDeprecated: (): boolean => true, + }); + + await controller.fetchTokenList(ChainId.mainnet); + + expect(fetchTokenListByChainIdSpy).not.toHaveBeenCalled(); + expect(controller.state.tokensChainsCache).toStrictEqual({}); + + controller.destroy(); + fetchTokenListByChainIdSpy.mockRestore(); + }); + + it('stops fetching when isDeprecated toggles to true after polling already started', async () => { + let disabled = false; + const fetchTokenListByChainIdSpy = jest + .spyOn(tokenService, 'fetchTokenListByChainId') + .mockResolvedValue(sampleMainnetTokenList); + + const messenger = getMessenger(); + const restrictedMessenger = getRestrictedMessenger(messenger); + + const controller = new TokenListController({ + chainId: ChainId.mainnet, + messenger: restrictedMessenger, + isDeprecated: (): boolean => disabled, + }); + + // First call: enabled — should fetch and write state + await controller.fetchTokenList(ChainId.mainnet); + expect(fetchTokenListByChainIdSpy).toHaveBeenCalledTimes(1); + expect(controller.state.tokensChainsCache[ChainId.mainnet]).toBeDefined(); + + // Toggle to disabled — a subsequent fetchTokenList must not hit the API + // and must clear the existing in-memory data. + disabled = true; + await controller.fetchTokenList(ChainId.mainnet); + expect(fetchTokenListByChainIdSpy).toHaveBeenCalledTimes(1); + expect(controller.state.tokensChainsCache).toStrictEqual({}); + + controller.destroy(); + fetchTokenListByChainIdSpy.mockRestore(); + }); + + it('returns early from restart() without fetching when disabled, and resets state', async () => { + const fetchTokenListMock = jest + .spyOn(TokenListController.prototype, 'fetchTokenList') + .mockImplementation(); + + const messenger = getMessenger(); + const restrictedMessenger = getRestrictedMessenger(messenger); + + const controller = new TokenListController({ + chainId: ChainId.mainnet, + messenger: restrictedMessenger, + state: existingState, + isDeprecated: (): boolean => true, + }); + + await controller.restart(); + + expect(fetchTokenListMock).not.toHaveBeenCalled(); + expect(controller.state.tokensChainsCache).toStrictEqual({}); + + controller.destroy(); + fetchTokenListMock.mockRestore(); + }); + + it('clears persisted storage at runtime when isDeprecated toggles to true after initialize ran enabled', async () => { + let disabled = false; + + // Pre-populate storage as if a prior session had fetched mainnet tokens. + const messenger = getMessenger(); + const restrictedMessenger = getRestrictedMessenger(messenger); + await messenger.call( + 'StorageService:setItem', + 'TokenListController', + `tokensChainsCache:${ChainId.mainnet}`, + { timestamp: 123, data: sampleMainnetTokensChainsCache }, + ); + + const controller = new TokenListController({ + chainId: ChainId.mainnet, + messenger: restrictedMessenger, + isDeprecated: (): boolean => disabled, + }); + // initialize() runs enabled — loads from storage and wires the persistence subscription. + await controller.initialize(); + expect(controller.state.tokensChainsCache[ChainId.mainnet]).toBeDefined(); + + // Toggle disabled and hit any fetching entry point. + disabled = true; + await controller.fetchTokenList(ChainId.mainnet); + + // In-memory cleared AND persisted entry overwritten with the empty placeholder. + expect(controller.state.tokensChainsCache).toStrictEqual({}); + const { result } = await messenger.call( + 'StorageService:getItem', + 'TokenListController', + `tokensChainsCache:${ChainId.mainnet}`, + ); + expect(result).toStrictEqual({ data: {}, timestamp: 0 }); + + controller.destroy(); + }); + + it('does not let a pending debounced persist write old data after isDeprecated toggles to true', async () => { + const fetchTokenListByChainIdSpy = jest + .spyOn(tokenService, 'fetchTokenListByChainId') + .mockResolvedValue(sampleMainnetTokenList); + + let disabled = false; + const messenger = getMessenger(); + const restrictedMessenger = getRestrictedMessenger(messenger); + + // Pre-populate storage so we can verify the disabled path overwrites it. + await messenger.call( + 'StorageService:setItem', + 'TokenListController', + `tokensChainsCache:${ChainId.mainnet}`, + { timestamp: 999, data: sampleMainnetTokensChainsCache }, + ); + + const controller = new TokenListController({ + chainId: ChainId.mainnet, + messenger: restrictedMessenger, + isDeprecated: (): boolean => disabled, + }); + await controller.initialize(); + + // Enabled fetch — populates state and schedules a debounced persist (500ms). + await controller.fetchTokenList(ChainId.mainnet); + expect(controller.state.tokensChainsCache[ChainId.mainnet]).toBeDefined(); + + // Flip disabled BEFORE the debounce timer fires, then hit a polling entry. + disabled = true; + await controller.fetchTokenList(ChainId.mainnet); + + // Wait well past the 500ms debounce window — any stale persist would + // have fired by now. + await new Promise((resolve) => setTimeout(resolve, 700)); + + // Storage entry must be the empty placeholder — not the stale fetched data. + const { result } = await messenger.call( + 'StorageService:getItem', + 'TokenListController', + `tokensChainsCache:${ChainId.mainnet}`, + ); + expect(result).toStrictEqual({ data: {}, timestamp: 0 }); + expect(controller.state.tokensChainsCache).toStrictEqual({}); + + controller.destroy(); + fetchTokenListByChainIdSpy.mockRestore(); + }); + }); + + describe('deprecated methods', () => { + it('should restart polling when restart() is called', async () => { + const messenger = getMessenger(); + const restrictedMessenger = getRestrictedMessenger(messenger); + + const controller = new TokenListController({ + chainId: ChainId.mainnet, + messenger: restrictedMessenger, + interval: 100, + }); + + nock(tokenService.TOKEN_END_POINT_API) + .get(getTokensPath(ChainId.mainnet)) + .reply(200, sampleMainnetTokenList) + .persist(); + + // Start initial polling + await controller.start(); + + // Wait for first fetch + await new Promise((resolve) => setTimeout(resolve, 150)); + + const initialCache = { ...controller.state.tokensChainsCache }; + expect(initialCache[ChainId.mainnet]).toBeDefined(); + + // Restart polling + await controller.restart(); + + // Wait for another fetch + await new Promise((resolve) => setTimeout(resolve, 150)); + + // Verify polling continued + expect(controller.state.tokensChainsCache[ChainId.mainnet]).toBeDefined(); + + controller.destroy(); + }); + }); +}); + +/** + * Construct the path used to fetch tokens that we can pass to `nock`. + * + * @param chainId - The chain ID. + * @returns The constructed path. + */ +function getTokensPath(chainId: Hex): string { + return `/tokens/${convertHexToDecimal( + chainId, + )}?occurrenceFloor=3&includeNativeAssets=false&includeTokenFees=false&includeAssetType=false&includeERC20Permit=false&includeStorage=false&includeRwaData=true`; +} diff --git a/packages/assets-controllers/src/TokenListController.ts b/packages/assets-controllers/src/TokenListController.ts index b9ecb4b0cda..93db17a1a7a 100644 --- a/packages/assets-controllers/src/TokenListController.ts +++ b/packages/assets-controllers/src/TokenListController.ts @@ -1,25 +1,33 @@ -import type { RestrictedControllerMessenger } from '@metamask/base-controller'; +import type { + ControllerGetStateAction, + ControllerStateChangeEvent, + StateMetadata, +} from '@metamask/base-controller'; import { safelyExecute } from '@metamask/controller-utils'; +import type { Messenger } from '@metamask/messenger'; import type { - NetworkClientId, NetworkControllerStateChangeEvent, NetworkState, NetworkControllerGetNetworkClientByIdAction, } from '@metamask/network-controller'; -import { PollingController } from '@metamask/polling-controller'; +import { StaticIntervalPollingController } from '@metamask/polling-controller'; +import type { + StorageServiceSetItemAction, + StorageServiceGetItemAction, + StorageServiceGetAllKeysAction, +} from '@metamask/storage-service'; import type { Hex } from '@metamask/utils'; -import { Mutex } from 'async-mutex'; -import type { Patch } from 'immer'; import { isTokenListSupportedForNetwork, formatAggregatorNames, formatIconUrlWithProxy, -} from './assetsUtil'; -import { fetchTokenListByChainId } from './token-service'; +} from './assetsUtil.js'; +import { TokenRwaData, fetchTokenListByChainId } from './token-service.js'; -const DEFAULT_INTERVAL = 24 * 60 * 60 * 1000; -const DEFAULT_THRESHOLD = 24 * 60 * 60 * 1000; +// 4 Hour Interval Cache Refresh Threshold +const DEFAULT_INTERVAL = 4 * 60 * 60 * 1000; +const DEFAULT_THRESHOLD = 4 * 60 * 60 * 1000; const name = 'TokenListController'; @@ -31,72 +39,135 @@ export type TokenListToken = { occurrences: number; aggregators: string[]; iconUrl: string; + rwaData?: TokenRwaData; }; export type TokenListMap = Record; -type DataCache = { +export type DataCache = { timestamp: number; data: TokenListMap; }; -type TokensChainsCache = { +export type TokensChainsCache = { [chainId: Hex]: DataCache; }; export type TokenListState = { - tokenList: TokenListMap; tokensChainsCache: TokensChainsCache; - preventPollingOnNetworkRestart: boolean; }; -export type TokenListStateChange = { - type: `${typeof name}:stateChange`; - payload: [TokenListState, Patch[]]; -}; +export type TokenListStateChange = ControllerStateChangeEvent< + typeof name, + TokenListState +>; -export type GetTokenListState = { - type: `${typeof name}:getState`; - handler: () => TokenListState; -}; -type TokenListMessenger = RestrictedControllerMessenger< +export type TokenListControllerEvents = TokenListStateChange; + +export type GetTokenListState = ControllerGetStateAction< typeof name, - GetTokenListState | NetworkControllerGetNetworkClientByIdAction, - TokenListStateChange | NetworkControllerStateChangeEvent, - NetworkControllerGetNetworkClientByIdAction['type'], - TokenListStateChange['type'] | NetworkControllerStateChangeEvent['type'] + TokenListState >; -const metadata = { - tokenList: { persist: true, anonymous: true }, - tokensChainsCache: { persist: true, anonymous: true }, - preventPollingOnNetworkRestart: { persist: true, anonymous: true }, +export type TokenListControllerActions = GetTokenListState; + +type AllowedActions = + | NetworkControllerGetNetworkClientByIdAction + | StorageServiceSetItemAction + | StorageServiceGetItemAction + | StorageServiceGetAllKeysAction; + +type AllowedEvents = NetworkControllerStateChangeEvent; + +export type TokenListControllerMessenger = Messenger< + typeof name, + TokenListControllerActions | AllowedActions, + TokenListControllerEvents | AllowedEvents +>; + +const metadata: StateMetadata = { + tokensChainsCache: { + includeInStateLogs: false, + persist: false, // Persisted separately via StorageService + includeInDebugSnapshot: true, + usedInUi: true, + }, +}; + +export const getDefaultTokenListState = (): TokenListState => { + return { + tokensChainsCache: {}, + }; }; -const defaultState: TokenListState = { - tokenList: {}, - tokensChainsCache: {}, - preventPollingOnNetworkRestart: false, +/** The input to start polling for the {@link TokenListController} */ +type TokenListPollingInput = { + chainId: Hex; }; /** * Controller that passively polls on a set interval for the list of tokens from metaswaps api */ -export class TokenListController extends PollingController< +export class TokenListController extends StaticIntervalPollingController()< typeof name, TokenListState, - TokenListMessenger + TokenListControllerMessenger > { - private readonly mutex = new Mutex(); + /** + * Debounce timer for persisting state changes to storage. + */ + #persistDebounceTimer?: ReturnType; - private intervalId?: ReturnType; + /** + * Promise for the in-flight initialization sequence. + */ + #initializePromise?: Promise; + + /** + * Promise that resolves when the current persist operation completes. + * Used to prevent race conditions between persist operations. + */ + #persistInFlightPromise?: Promise; - private readonly intervalDelay: number; + /** + * Tracks which chains have pending changes to persist. + * Only changed chains are persisted to reduce write amplification. + */ + readonly #changedChainsToPersist: Set = new Set(); - private readonly cacheRefreshThreshold: number; + /** + * Previous tokensChainsCache for detecting which chains changed. + */ + #previousTokensChainsCache: TokensChainsCache = {}; - private chainId: Hex; + /** + * Debounce delay for persisting state changes (in milliseconds). + */ + static readonly #persistDebounceMs = 500; - private abortController: AbortController; + // Storage key prefix for per-chain files + static readonly #storageKeyPrefix = 'tokensChainsCache'; + + /** + * Get storage key for a specific chain. + * + * @param chainId - The chain ID. + * @returns Storage key for the chain. + */ + static #getChainStorageKey(chainId: Hex): string { + return `${TokenListController.#storageKeyPrefix}:${chainId}`; + } + + #intervalId?: ReturnType; + + readonly #intervalDelay: number; + + readonly #cacheRefreshThreshold: number; + + #chainId: Hex; + + #abortController: AbortController; + + readonly #isDeprecated: () => boolean; /** * Creates a TokenListController instance. @@ -106,47 +177,64 @@ export class TokenListController extends PollingController< * @param options.onNetworkStateChange - A function for registering an event handler for network state changes. * @param options.interval - The polling interval, in milliseconds. * @param options.cacheRefreshThreshold - The token cache expiry time, in milliseconds. - * @param options.messenger - A restricted controller messenger. + * @param options.isDeprecated - Optional function returning whether the controller should be disabled. + * When it returns `true`, `tokensChainsCache` is reset to `{}` at construction and at every + * polling entry point, no fetches are issued, and persisted storage is cleared on initialize. + * The function is evaluated dynamically on each entry point so it can be toggled at runtime. + * @param options.messenger - A restricted messenger. * @param options.state - Initial state to set on this controller. - * @param options.preventPollingOnNetworkRestart - Determines whether to prevent poilling on network restart in extension. */ constructor({ chainId, - preventPollingOnNetworkRestart = false, onNetworkStateChange, interval = DEFAULT_INTERVAL, cacheRefreshThreshold = DEFAULT_THRESHOLD, + isDeprecated = (): boolean => false, messenger, state, }: { chainId: Hex; - preventPollingOnNetworkRestart?: boolean; onNetworkStateChange?: ( listener: (networkState: NetworkState) => void, ) => void; interval?: number; cacheRefreshThreshold?: number; - messenger: TokenListMessenger; + isDeprecated?: () => boolean; + messenger: TokenListControllerMessenger; state?: Partial; }) { super({ name, metadata, messenger, - state: { ...defaultState, ...state }, + state: { ...getDefaultTokenListState(), ...state }, }); - this.intervalDelay = interval; - this.cacheRefreshThreshold = cacheRefreshThreshold; - this.chainId = chainId; - this.updatePreventPollingOnNetworkRestart(preventPollingOnNetworkRestart); - this.abortController = new AbortController(); + + this.#intervalDelay = interval; + this.setIntervalLength(interval); + this.#cacheRefreshThreshold = cacheRefreshThreshold; + this.#chainId = chainId; + this.#abortController = new AbortController(); + this.#isDeprecated = isDeprecated; + + if (this.#isDeprecated()) { + // Fire-and-forget: storage clearing is async but the constructor is sync. + // Errors are caught inside `#enforceDisabledState`. + // eslint-disable-next-line @typescript-eslint/no-floating-promises + this.#enforceDisabledState(); + } + if (onNetworkStateChange) { + // TODO: Either fix this lint violation or explain why it's necessary to ignore. + // eslint-disable-next-line @typescript-eslint/no-misused-promises onNetworkStateChange(async (networkControllerState) => { await this.#onNetworkControllerStateChange(networkControllerState); }); } else { - this.messagingSystem.subscribe( + this.messenger.subscribe( 'NetworkController:stateChange', + // TODO: Either fix this lint violation or explain why it's necessary to ignore. + // eslint-disable-next-line @typescript-eslint/no-misused-promises async (networkControllerState) => { await this.#onNetworkControllerStateChange(networkControllerState); }, @@ -154,230 +242,540 @@ export class TokenListController extends PollingController< } } + /** + * Initialize the controller by loading cache from storage and running migration. + * This method should be called by clients after construction. + * + * @returns A promise that resolves when initialization is complete. + */ + async initialize(): Promise { + if (this.#initializePromise) { + await this.#initializePromise; + return; + } + + const executeInit = async (): Promise => { + try { + if (this.#isDeprecated()) { + await this.#enforceDisabledState(); + return; + } + + await this.#synchronizeCacheWithStorage(); + + // Subscribe to state changes to automatically persist tokensChainsCache + this.messenger.subscribe( + 'TokenListController:stateChange', + (newCache: TokensChainsCache) => this.#onCacheChanged(newCache), + (controllerState) => controllerState.tokensChainsCache, + ); + } catch { + // do nothing + } finally { + this.#initializePromise = undefined; + } + }; + + this.#initializePromise = executeInit(); + + await this.#initializePromise; + } + + /** + * Waits for any in-flight initialization to complete. + * Polling should not run against partially initialized state. + */ + async #waitForInitialization(): Promise { + try { + await this.#initializePromise; + } catch { + // do nothing + } + } + + /** + * Handle tokensChainsCache changes by detecting which chains changed + * and scheduling debounced persistence. + * + * @param newCache - The new tokensChainsCache state. + */ + #onCacheChanged(newCache: TokensChainsCache): void { + // Detect which chains changed by comparing with previous cache + for (const chainId of Object.keys(newCache) as Hex[]) { + const newData = newCache[chainId]; + const prevData = this.#previousTokensChainsCache[chainId]; + + // Chain is new or timestamp changed (indicating data update) + if (prevData?.timestamp !== newData.timestamp) { + this.#changedChainsToPersist.add(chainId); + } + } + + // Update previous cache reference + this.#previousTokensChainsCache = { ...newCache }; + + // Schedule persistence if there are changes + if (this.#changedChainsToPersist.size > 0) { + this.#debouncePersist(); + } + } + + /** + * Debounce persistence of changed chains to storage. + */ + #debouncePersist(): void { + if (this.#persistDebounceTimer) { + clearTimeout(this.#persistDebounceTimer); + } + + this.#persistDebounceTimer = setTimeout(() => { + // Note: #persistChangedChains handles errors internally via #saveChainCacheToStorage, + // so this promise will not reject. + // eslint-disable-next-line @typescript-eslint/no-floating-promises + this.#persistChangedChains(); + }, TokenListController.#persistDebounceMs); + } + + /** + * Persist only the chains that have changed to storage. + * Reduces write amplification by skipping unchanged chains. + * + * If a persist operation is already in-flight, this method returns early + * and reschedules the debounce to ensure accumulated changes are retried + * after the current operation completes. + * + * @returns A promise that resolves when changed chains are persisted. + */ + async #persistChangedChains(): Promise { + if (this.#persistInFlightPromise) { + // Reschedule debounce to retry accumulated changes after in-flight persist completes + if (this.#changedChainsToPersist.size > 0) { + this.#debouncePersist(); + } + return; + } + + const chainsToPersist = [...this.#changedChainsToPersist]; + this.#changedChainsToPersist.clear(); + + if (chainsToPersist.length === 0) { + return; + } + + this.#persistInFlightPromise = Promise.all( + chainsToPersist.map((chainId) => this.#saveChainCacheToStorage(chainId)), + ).then(() => undefined); + + try { + await this.#persistInFlightPromise; + } finally { + this.#persistInFlightPromise = undefined; + } + } + + /** + * Synchronize tokensChainsCache between state and storage bidirectionally. + * + * This method: + * 1. Loads cached chains from storage (per-chain files) in parallel + * 2. Merges loaded data into state (preferring existing state to avoid overwriting fresh data) + * 3. Persists any chains that exist in state but not in storage + * + * Called during initialization to ensure state and storage are consistent. + * + * @returns A promise that resolves when synchronization is complete. + */ + async #synchronizeCacheWithStorage(): Promise { + try { + const allKeys = await this.messenger.call( + 'StorageService:getAllKeys', + name, + ); + + // Filter keys that belong to tokensChainsCache (per-chain files) + const cacheKeys = allKeys.filter((key) => + key.startsWith(`${TokenListController.#storageKeyPrefix}:`), + ); + + // Load all chains in parallel + const chainCaches = await Promise.all( + cacheKeys.map(async (key) => { + // Extract chainId from key: 'tokensChainsCache:0x1' → '0x1' + const chainId = key.split(':')[1] as Hex; + + const { result, error } = await this.messenger.call( + 'StorageService:getItem', + name, + key, + ); + + if (error) { + console.error( + `TokenListController: Error loading cache for ${chainId}:`, + error, + ); + return null; + } + + return result ? { chainId, data: result as DataCache } : null; + }), + ); + + // Build complete cache from loaded chains + const loadedCache: TokensChainsCache = {}; + chainCaches.forEach((chainCache) => { + if (chainCache) { + loadedCache[chainCache.chainId] = chainCache.data; + } + }); + + // Chains in state _before loading persisted state_, from a recent update + const chainsInState = new Set( + Object.keys(this.state.tokensChainsCache) as Hex[], + ); + + // Merge loaded cache with existing state, preferring existing data + // (which may be fresher if fetched during initialization) + if (Object.keys(loadedCache).length > 0) { + this.update((state) => { + // Only load chains that don't already exist in state + // This prevents overwriting fresh API data with stale cached data + for (const [chainId, cacheData] of Object.entries(loadedCache)) { + if (!state.tokensChainsCache[chainId as Hex]) { + state.tokensChainsCache[chainId as Hex] = cacheData; + } + } + }); + } + + // Persist chains that exist in state but were not loaded from storage. + // This handles the case where initial state contains chains that don't exist + // in storage yet (e.g., fresh data from API). Without this, those chains + // would be lost on the next app restart. + for (const chainId of chainsInState) { + this.#changedChainsToPersist.add(chainId); + } + + // Persist any chains that need to be saved + if (this.#changedChainsToPersist.size > 0) { + this.#debouncePersist(); + } + + this.#previousTokensChainsCache = { ...this.state.tokensChainsCache }; + } catch (error) { + console.error( + 'TokenListController: Failed to load cache from storage:', + error, + ); + } + } + + /** + * Save a specific chain's cache to StorageService. + * This persists only the updated chain's data, reducing write amplification. + * + * @param chainId - The chain ID to save. + * @returns A promise that resolves when saving is complete. + */ + async #saveChainCacheToStorage(chainId: Hex): Promise { + try { + const chainData = this.state.tokensChainsCache[chainId]; + + if (!chainData) { + console.warn(`TokenListController: No cache data for chain ${chainId}`); + return; + } + + const storageKey = TokenListController.#getChainStorageKey(chainId); + + await this.messenger.call( + 'StorageService:setItem', + name, + storageKey, + chainData, + ); + } catch (error) { + console.error( + `TokenListController: Failed to save cache for ${chainId}:`, + error, + ); + } + } + /** * Updates state and restarts polling on changes to the network controller * state. * * @param networkControllerState - The updated network controller state. */ - async #onNetworkControllerStateChange(networkControllerState: NetworkState) { - if (this.chainId !== networkControllerState.providerConfig.chainId) { - this.abortController.abort(); - this.abortController = new AbortController(); - this.chainId = networkControllerState.providerConfig.chainId; - if (this.state.preventPollingOnNetworkRestart) { - this.clearingTokenListData(); - } else { - // Ensure tokenList is referencing data from correct network - this.update(() => { - return { - ...this.state, - tokenList: this.state.tokensChainsCache[this.chainId]?.data || {}, - }; - }); - await this.restart(); - } + async #onNetworkControllerStateChange( + networkControllerState: NetworkState, + ): Promise { + const selectedNetworkClient = this.messenger.call( + 'NetworkController:getNetworkClientById', + networkControllerState.selectedNetworkClientId, + ); + const { chainId } = selectedNetworkClient.configuration; + + if (this.#chainId !== chainId) { + this.#abortController.abort(); + this.#abortController = new AbortController(); + this.#chainId = chainId; } } + // Eventually we want to remove start/restart/stop controls in favor of new _executePoll API + // Maintaining these functions for now until we can safely deprecate them for backwards compatibility /** * Start polling for the token list. + * + * @deprecated This method is deprecated and will be removed in the future. + * Consider using the new polling approach instead */ - async start() { - if (!isTokenListSupportedForNetwork(this.chainId)) { + async start(): Promise { + if (this.#isDeprecated()) { + await this.#enforceDisabledState(); + return; + } + if (!isTokenListSupportedForNetwork(this.#chainId)) { return; } - await this.startPolling(); + await this.#startDeprecatedPolling(); } /** * Restart polling for the token list. + * + * @deprecated This method is deprecated and will be removed in the future. + * Consider using the new polling approach instead */ - async restart() { - this.stopPolling(); - await this.startPolling(); + async restart(): Promise { + this.#stopPolling(); + if (this.#isDeprecated()) { + await this.#enforceDisabledState(); + return; + } + await this.#startDeprecatedPolling(); } /** * Stop polling for the token list. + * + * @deprecated This method is deprecated and will be removed in the future. + * Consider using the new polling approach instead */ - stop() { - this.stopPolling(); + stop(): void { + this.#stopPolling(); } /** - * Prepare to discard this controller. - * * This stops any active polling. + * + * @deprecated This method is deprecated and will be removed in the future. + * Consider using the new polling approach instead */ - override destroy() { + override destroy(): void { super.destroy(); - this.stopPolling(); + this.#stopPolling(); + + // Cancel any pending debounced persistence operations + if (this.#persistDebounceTimer) { + clearTimeout(this.#persistDebounceTimer); + this.#persistDebounceTimer = undefined; + } + this.#changedChainsToPersist.clear(); } - private stopPolling() { - if (this.intervalId) { - clearInterval(this.intervalId); + /** + * This stops any active polling intervals. + * + * @deprecated This method is deprecated and will be removed in the future. + * Consider using the new polling approach instead + */ + #stopPolling(): void { + if (this.#intervalId) { + clearInterval(this.#intervalId); } } /** - * Starts a new polling interval. + * Starts a new polling interval for a given chainId (this should be deprecated in favor of _executePoll) + * + * @deprecated This method is deprecated and will be removed in the future. + * Consider using the new polling approach instead */ - private async startPolling(): Promise { - await safelyExecute(() => this.fetchTokenList()); - this.intervalId = setInterval(async () => { - await safelyExecute(() => this.fetchTokenList()); - }, this.intervalDelay); + async #startDeprecatedPolling(): Promise { + // renaming this to avoid collision with base class + await safelyExecute(() => this.fetchTokenList(this.#chainId)); + // TODO: Either fix this lint violation or explain why it's necessary to ignore. + // eslint-disable-next-line @typescript-eslint/no-misused-promises + this.#intervalId = setInterval(async () => { + await safelyExecute(() => this.fetchTokenList(this.#chainId)); + }, this.#intervalDelay); } /** - * Fetching token list from the Token Service API. + * This starts a new polling loop for any given chain. Under the hood it is deduping polls * - * @private - * @param networkClientId - The ID of the network client triggering the fetch. + * @param input - The input for the poll. + * @param input.chainId - The chainId of the chain to trigger the fetch. * @returns A promise that resolves when this operation completes. */ - async _executePoll(networkClientId: string): Promise { - return this.fetchTokenList(networkClientId); + async _executePoll({ chainId }: TokenListPollingInput): Promise { + if (this.#isDeprecated()) { + await this.#enforceDisabledState(); + return; + } + await this.#waitForInitialization(); + await this.fetchTokenList(chainId); } /** - * Fetching token list from the Token Service API. + * Fully enforce the disabled state in a single step: + * 1. Drop in-memory `tokensChainsCache` to `{}`. + * 2. Cancel any pending debounced persist and clear the changed-chains set so + * a stale entry can't write old data after the in-memory reset. + * 3. Overwrite every persisted `tokensChainsCache:*` entry in StorageService + * with `{ data: {}, timestamp: 0 }`. + * + * Called from every fetching entry point when `isDeprecated()` is true so that + * a runtime toggle propagates to both memory and storage immediately, even + * if `initialize()` was originally invoked while the controller was enabled. * - * @param networkClientId - The ID of the network client triggering the fetch. + * @returns A promise that resolves when persisted entries have been cleared. */ - async fetchTokenList(networkClientId?: NetworkClientId): Promise { - const releaseLock = await this.mutex.acquire(); - let networkClient; - if (networkClientId) { - networkClient = this.messagingSystem.call( - 'NetworkController:getNetworkClientById', - networkClientId, - ); - } - const chainId = networkClient?.configuration.chainId ?? this.chainId; - try { - const { tokensChainsCache } = this.state; - let tokenList: TokenListMap = {}; - const cachedTokens: TokenListMap = await safelyExecute(() => - this.#fetchFromCache(chainId), - ); - if (cachedTokens) { - // Use non-expired cached tokens - tokenList = { ...cachedTokens }; - } else { - // Fetch fresh token list - const tokensFromAPI: TokenListToken[] = await safelyExecute(() => { - return fetchTokenListByChainId(chainId, this.abortController.signal); - }); - if (!tokensFromAPI) { - // Fallback to expired cached tokens - tokenList = { ...(tokensChainsCache[chainId]?.data || {}) }; - this.update(() => { - return { - ...this.state, - tokenList, - tokensChainsCache, - }; - }); - return; - } - // Filtering out tokens with less than 3 occurrences and native tokens - const filteredTokenList = tokensFromAPI.filter( - (token) => - token.occurrences && - token.occurrences >= 3 && - token.address !== '0x0000000000000000000000000000000000000000', - ); - // Removing the tokens with symbol conflicts - const symbolsList = filteredTokenList.map((token) => token.symbol); - const duplicateSymbols = [ - ...new Set( - symbolsList.filter( - (symbol, index) => symbolsList.indexOf(symbol) !== index, - ), - ), - ]; - const uniqueTokenList = filteredTokenList.filter( - (token) => !duplicateSymbols.includes(token.symbol), - ); - for (const token of uniqueTokenList) { - const formattedToken: TokenListToken = { - ...token, - aggregators: formatAggregatorNames(token.aggregators), - iconUrl: formatIconUrlWithProxy({ - chainId, - tokenAddress: token.address, - }), - }; - tokenList[token.address] = formattedToken; - } - } - const updatedTokensChainsCache: TokensChainsCache = { - ...tokensChainsCache, - [chainId]: { - timestamp: Date.now(), - data: tokenList, - }, - }; - this.update(() => { - return { - ...this.state, - tokenList, - tokensChainsCache: updatedTokensChainsCache, - }; - }); - } finally { - releaseLock(); + async #enforceDisabledState(): Promise { + this.#resetCacheState(); + if (this.#persistDebounceTimer) { + clearTimeout(this.#persistDebounceTimer); + this.#persistDebounceTimer = undefined; } + this.#changedChainsToPersist.clear(); + await this.#clearPersistedCache(); } /** - * Checks if the Cache timestamp is valid, - * if yes data in cache will be returned - * otherwise null will be returned. - * @param chainId - The chain ID of the network for which to fetch the cache. - * @returns The cached data, or `null` if the cache was expired. + * Reset in-memory `tokensChainsCache` to an empty object. + * Used when the controller is disabled to drop any data already in state. */ - async #fetchFromCache(chainId: Hex): Promise { - const { tokensChainsCache }: TokenListState = this.state; - const dataCache = tokensChainsCache[chainId]; - const now = Date.now(); - if ( - dataCache?.data && - now - dataCache?.timestamp < this.cacheRefreshThreshold - ) { - return dataCache.data; + #resetCacheState(): void { + if (Object.keys(this.state.tokensChainsCache).length === 0) { + return; } - return null; + this.update((state) => { + state.tokensChainsCache = {}; + }); } /** - * Clearing tokenList and tokensChainsCache explicitly. + * Overwrite every persisted `tokensChainsCache:*` entry in StorageService + * with `{ data: {}, timestamp: 0 }` so the on-disk view reflects the + * disabled state. + * + * @returns A promise that resolves when persisted entries have been cleared. */ - clearingTokenListData(): void { - this.update(() => { - return { - ...this.state, - tokenList: {}, - tokensChainsCache: {}, - }; - }); + async #clearPersistedCache(): Promise { + try { + const allKeys = await this.messenger.call( + 'StorageService:getAllKeys', + name, + ); + const cacheKeys = allKeys.filter((key) => + key.startsWith(`${TokenListController.#storageKeyPrefix}:`), + ); + const emptyDataCache: DataCache = { data: {}, timestamp: 0 }; + await Promise.all( + cacheKeys.map((key) => + this.messenger.call( + 'StorageService:setItem', + name, + key, + emptyDataCache, + ), + ), + ); + } catch (error) { + console.error( + 'TokenListController: Failed to clear persisted cache:', + error, + ); + } } /** - * Updates preventPollingOnNetworkRestart from extension. + * Fetching token list from the Token Service API. This will fetch tokens across chains. + * State changes are automatically persisted via the stateChange subscription. * - * @param shouldPreventPolling - Determine whether to prevent polling on network change + * @param chainId - The chainId of the current chain triggering the fetch. */ - updatePreventPollingOnNetworkRestart(shouldPreventPolling: boolean): void { - this.update(() => { - return { - ...this.state, - preventPollingOnNetworkRestart: shouldPreventPolling, + async fetchTokenList(chainId: Hex): Promise { + if (this.#isDeprecated()) { + await this.#enforceDisabledState(); + return; + } + if (this.isCacheValid(chainId)) { + return; + } + + // Fetch fresh token list from the API + const tokensFromAPI = await safelyExecute( + () => + fetchTokenListByChainId( + chainId, + this.#abortController.signal, + ) as Promise, + ); + + // Have response - process and update list + if (tokensFromAPI) { + // Format tokens from API (HTTP) and update tokenList + const tokenList: TokenListMap = {}; + for (const token of tokensFromAPI) { + tokenList[token.address] = { + ...token, + aggregators: formatAggregatorNames(token.aggregators), + iconUrl: formatIconUrlWithProxy({ + chainId, + tokenAddress: token.address, + }), + }; + } + + // Update state - persistence happens automatically via subscription + const newDataCache: DataCache = { + data: tokenList, + timestamp: Date.now(), }; - }); + this.update((state) => { + state.tokensChainsCache[chainId] = newDataCache; + }); + return; + } + + // No response - fallback to previous state, or initialise empty. + // Only initialize with a new timestamp if there's no existing cache. + // If there's existing cache, keep it as-is without updating the timestamp + // to avoid making stale data appear "fresh" and preventing retry attempts. + if (!tokensFromAPI) { + const existingCache = this.state.tokensChainsCache[chainId]; + if (!existingCache) { + // No existing cache - initialize empty (persistence happens automatically) + const newDataCache: DataCache = { data: {}, timestamp: Date.now() }; + this.update((state) => { + state.tokensChainsCache[chainId] = newDataCache; + }); + } + // If there's existing cache, keep it as-is (don't update timestamp or persist) + } + } + + isCacheValid(chainId: Hex): boolean { + const { tokensChainsCache }: TokenListState = this.state; + const timestamp: number | undefined = tokensChainsCache[chainId]?.timestamp; + const now = Date.now(); + return ( + timestamp !== undefined && now - timestamp < this.#cacheRefreshThreshold + ); } } diff --git a/packages/assets-controllers/src/TokenListService.test.ts b/packages/assets-controllers/src/TokenListService.test.ts new file mode 100644 index 00000000000..5a411ed482a --- /dev/null +++ b/packages/assets-controllers/src/TokenListService.test.ts @@ -0,0 +1,171 @@ +import type { Hex } from '@metamask/utils'; + +import * as assetsUtil from './assetsUtil.js'; +import { fetchTokenListByChainId } from './token-service.js'; +import type { TokenListToken } from './TokenListController.js'; +import { buildTokenListMap, TokenListService } from './TokenListService.js'; + +jest.mock('./token-service', () => ({ + fetchTokenListByChainId: jest.fn(), +})); + +const mockedFetchTokenListByChainId = jest.mocked(fetchTokenListByChainId); + +describe('buildTokenListMap', () => { + it('maps tokens by address and applies aggregator and icon formatting', () => { + const chainId = '0x1' as Hex; + const tokens: TokenListToken[] = [ + { + name: 'Sample', + symbol: 'SMP', + decimals: 18, + address: '0xabc0000000000000000000000000000000000001', + occurrences: 3, + aggregators: ['bancor', 'cmc'], + iconUrl: 'https://example.com/icon.png', + }, + ]; + + const map = buildTokenListMap(tokens, chainId); + + expect(Object.keys(map)).toStrictEqual([ + '0xabc0000000000000000000000000000000000001', + ]); + expect(map['0xabc0000000000000000000000000000000000001']).toMatchObject({ + name: 'Sample', + symbol: 'SMP', + decimals: 18, + address: '0xabc0000000000000000000000000000000000001', + aggregators: ['Bancor', 'CMC'], + }); + expect(map['0xabc0000000000000000000000000000000000001'].iconUrl).toContain( + 'https://static.cx.metamask.io', + ); + }); + + it('returns an empty map when the token array is empty', () => { + expect(buildTokenListMap([], '0x1' as Hex)).toStrictEqual({}); + }); +}); + +describe('TokenListService', () => { + beforeEach(() => { + mockedFetchTokenListByChainId.mockReset(); + }); + + it('fetches via token-service and caches results for the same chain', async () => { + const chainId = '0xa86a' as Hex; + const apiToken = { + name: 'Avalanche Token', + symbol: 'AVT', + decimals: 18, + address: '0x1000000000000000000000000000000000000001', + occurrences: 5, + aggregators: [] as string[], + iconUrl: '', + }; + mockedFetchTokenListByChainId.mockResolvedValue([apiToken]); + + const service = new TokenListService(); + const first = await service.fetchTokensByChainId(chainId); + const second = await service.fetchTokensByChainId(chainId); + + expect(mockedFetchTokenListByChainId).toHaveBeenCalledTimes(1); + expect(first).toStrictEqual(second); + expect(first[apiToken.address]).toMatchObject({ + symbol: 'AVT', + name: 'Avalanche Token', + }); + + service.destroy(); + }); + + it('does not re-run map formatting on cache hits for the same chain', async () => { + const chainId = '0xa86a' as Hex; + const apiTokens = [ + { + name: 'Token A', + symbol: 'TKA', + decimals: 18, + address: '0x1000000000000000000000000000000000000001', + occurrences: 1, + aggregators: ['bancor'] as string[], + iconUrl: '', + }, + { + name: 'Token B', + symbol: 'TKB', + decimals: 6, + address: '0x2000000000000000000000000000000000000002', + occurrences: 1, + aggregators: ['cmc'] as string[], + iconUrl: '', + }, + ]; + mockedFetchTokenListByChainId.mockResolvedValue(apiTokens); + + const formatAggregatorsSpy = jest.spyOn( + assetsUtil, + 'formatAggregatorNames', + ); + const formatIconSpy = jest.spyOn(assetsUtil, 'formatIconUrlWithProxy'); + + const service = new TokenListService(); + await service.fetchTokensByChainId(chainId); + await service.fetchTokensByChainId(chainId); + + expect(mockedFetchTokenListByChainId).toHaveBeenCalledTimes(1); + expect(formatAggregatorsSpy).toHaveBeenCalledTimes(2); + expect(formatIconSpy).toHaveBeenCalledTimes(2); + + formatAggregatorsSpy.mockRestore(); + formatIconSpy.mockRestore(); + service.destroy(); + }); + + it('treats an undefined API response as an empty list', async () => { + mockedFetchTokenListByChainId.mockResolvedValue(undefined); + + const service = new TokenListService(); + expect(await service.fetchTokensByChainId('0x1' as Hex)).toStrictEqual({}); + + service.destroy(); + }); + + it('clearing the cache via destroy causes the next fetch to hit the network again', async () => { + const chainId = '0x1' as Hex; + const apiToken = { + name: 'Restored After Destroy', + symbol: 'RAD', + decimals: 18, + address: '0x1000000000000000000000000000000000000001', + occurrences: 1, + aggregators: [] as string[], + iconUrl: '', + }; + mockedFetchTokenListByChainId.mockImplementation( + async (_chainId, abortSignal) => { + // Mirror token-service: aborted fetches resolve to undefined, not a list. + if (abortSignal.aborted) { + return undefined; + } + return [apiToken]; + }, + ); + + const service = new TokenListService(); + const first = await service.fetchTokensByChainId(chainId); + const cached = await service.fetchTokensByChainId(chainId); + expect(mockedFetchTokenListByChainId).toHaveBeenCalledTimes(1); + expect(cached).toStrictEqual(first); + expect(first[apiToken.address]).toMatchObject({ symbol: 'RAD' }); + + service.destroy(); + + const afterDestroy = await service.fetchTokensByChainId(chainId); + expect(mockedFetchTokenListByChainId).toHaveBeenCalledTimes(2); + expect(afterDestroy[apiToken.address]).toMatchObject({ symbol: 'RAD' }); + + service.destroy(); + }); +}); diff --git a/packages/assets-controllers/src/TokenListService.ts b/packages/assets-controllers/src/TokenListService.ts new file mode 100644 index 00000000000..372d6d17c31 --- /dev/null +++ b/packages/assets-controllers/src/TokenListService.ts @@ -0,0 +1,104 @@ +import type { Hex } from '@metamask/utils'; +import { QueryClient } from '@tanstack/query-core'; + +import { formatAggregatorNames, formatIconUrlWithProxy } from './assetsUtil.js'; +import { fetchTokenListByChainId } from './token-service.js'; +import type { TokenListMap, TokenListToken } from './TokenListController.js'; + +// 4 hours — mirrors TokenListController's DEFAULT_THRESHOLD +const FOUR_HOURS_MS = 4 * 60 * 60 * 1000; + +/** + * Shared service for fetching and caching the token list per chain. + * + * Callers invoke `fetchTokensByChainId` directly. TanStack Query caches the + * normalised `TokenListMap` for 4 hours so that multiple controllers share the + * same in-memory cache without redundant network requests or per-token + * formatting work on cache hits. + */ +export class TokenListService { + readonly #queryClient: QueryClient; + + #abortController: AbortController; + + constructor() { + this.#abortController = new AbortController(); + this.#queryClient = new QueryClient({ + defaultOptions: { + queries: { + staleTime: FOUR_HOURS_MS, + // fetchQuery never creates an observer, so entries are immediately + // inactive. Without an explicit gcTime the default 5-minute GC would + // evict them long before the 4-hour staleTime window expires. + gcTime: FOUR_HOURS_MS, + retry: false, + }, + }, + }); + } + + /** + * Fetch the token list for a given chain, normalising the raw API response + * into a `TokenListMap` keyed by lowercase address. + * + * Results are cached in-memory for 4 hours. A second call within the cache + * window returns the cached value without a network request. + * + * @param chainId - The hex chain ID to fetch tokens for. + * @returns A map of lowercase token address → token metadata. + */ + async fetchTokensByChainId(chainId: Hex): Promise { + const queryKey = ['TokenListService:fetchTokensByChainId', chainId]; + // On failure, TanStack Query v5 sets isInvalidated=true and leaves state.data + // undefined, so the next fetchQuery call always triggers a fresh network request + // rather than serving the cached error. No manual cache eviction is needed. + return this.#queryClient.fetchQuery({ + queryKey, + queryFn: async () => { + const list = (await fetchTokenListByChainId( + chainId, + this.#abortController.signal, + )) as TokenListToken[] | undefined; + return buildTokenListMap(list ?? [], chainId); + }, + staleTime: FOUR_HOURS_MS, + gcTime: FOUR_HOURS_MS, + }); + } + + /** + * Abort in-flight requests, clear the query cache, and reset the abort + * controller so subsequent `fetchTokensByChainId` calls are not stuck with an + * already-aborted signal (which would cache empty results). + */ + destroy(): void { + this.#abortController.abort(); + this.#queryClient.clear(); + this.#abortController = new AbortController(); + } +} + +/** + * Normalise a raw token list array (from the token API) into a `TokenListMap`. + * + * @param tokens - Raw array of token objects returned by the API. + * @param chainId - The chain the tokens belong to (used for icon URL proxy). + * @returns A record keyed by lowercased token address. + */ +export function buildTokenListMap( + tokens: TokenListToken[], + chainId: Hex, +): TokenListMap { + const tokenListMap: TokenListMap = {}; + for (const token of tokens) { + tokenListMap[token.address.toLowerCase()] = { + ...token, + aggregators: formatAggregatorNames(token.aggregators), + iconUrl: formatIconUrlWithProxy({ + chainId, + tokenAddress: token.address, + }), + }; + } + return tokenListMap; +} diff --git a/packages/assets-controllers/src/TokenRatesController.test.ts b/packages/assets-controllers/src/TokenRatesController.test.ts index 163c50201c6..818da8ac738 100644 --- a/packages/assets-controllers/src/TokenRatesController.test.ts +++ b/packages/assets-controllers/src/TokenRatesController.test.ts @@ -1,1239 +1,1641 @@ -import { NetworksTicker, toHex } from '@metamask/controller-utils'; -import nock from 'nock'; -import * as sinon from 'sinon'; - -import { TokenRatesController } from './TokenRatesController'; +import { deriveStateFromMetadata } from '@metamask/base-controller'; +import { ChainId, toChecksumHexAddress } from '@metamask/controller-utils'; +import { Messenger, MOCK_ANY_NAMESPACE } from '@metamask/messenger'; +import type { + MessengerActions, + MessengerEvents, + MockAnyNamespace, +} from '@metamask/messenger'; +import type { + NetworkClientConfiguration, + NetworkClientId, + NetworkConfiguration, + NetworkState, +} from '@metamask/network-controller'; +import { getDefaultNetworkControllerState } from '@metamask/network-controller'; +import type { CaipAssetType, Hex } from '@metamask/utils'; +import { add0x, KnownCaipNamespace } from '@metamask/utils'; +import type { Patch } from 'immer'; + +import { flushPromises } from '../../../tests/helpers.js'; +import { TOKEN_PRICES_BATCH_SIZE } from './assetsUtil.js'; +import type { + AbstractTokenPricesService, + EvmAssetWithMarketData, +} from './token-prices-service/abstract-token-prices-service.js'; +import { ZERO_ADDRESS } from './token-prices-service/codefi-v2.js'; +import { + controllerName, + TokenRatesController, +} from './TokenRatesController.js'; +import type { + MarketDataDetails, + Token, + TokenRatesControllerMessenger, + TokenRatesControllerState, +} from './TokenRatesController.js'; +import { getDefaultTokensState } from './TokensController.js'; +import type { TokensControllerState } from './TokensController.js'; + +const defaultSelectedAddress = '0x1111111111111111111111111111111111111111'; + +type AllTokenRatesControllerActions = + MessengerActions; + +type AllTokenRatesControllerEvents = + MessengerEvents; + +type RootMessenger = Messenger< + MockAnyNamespace, + AllTokenRatesControllerActions, + AllTokenRatesControllerEvents +>; + +/** + * Builds a messenger that `TokenRatesController` can use to communicate with other controllers. + * + * @param messenger - The root messenger. + * @returns The controller messenger. + */ +function buildTokenRatesControllerMessenger( + messenger: RootMessenger = new Messenger({ namespace: MOCK_ANY_NAMESPACE }), +): TokenRatesControllerMessenger { + const tokenRatesControllerMessenger = new Messenger< + 'TokenRatesController', + AllTokenRatesControllerActions, + AllTokenRatesControllerEvents, + RootMessenger + >({ + namespace: controllerName, + parent: messenger, + }); + messenger.delegate({ + messenger: tokenRatesControllerMessenger, + actions: [ + 'TokensController:getState', + 'NetworkController:getState', + 'NetworkEnablementController:getState', + ], + events: ['TokensController:stateChange', 'NetworkController:stateChange'], + }); + return tokenRatesControllerMessenger; +} -const COINGECKO_API = 'https://api.coingecko.com/api/v3'; -const COINGECKO_ETH_PATH = '/simple/token_price/ethereum'; -const COINGECKO_MATIC_PATH = '/simple/token_price/polygon-pos-network'; -const COINGECKO_ASSETS_PATH = '/asset_platforms'; -const COINGECKO_SUPPORTED_CURRENCIES = '/simple/supported_vs_currencies'; -const ADDRESS = '0x01'; +describe('TokenRatesController', () => { + describe('constructor', () => { + it('should set default state', async () => { + await withController(async ({ controller }) => { + expect(controller.state).toStrictEqual({ + marketData: {}, + }); + }); + }); -const defaultSelectedAddress = '0x0000000000000000000000000000000000000001'; + it('should call setNativeAssetIdentifiers on tokenPricesService if available', async () => { + const setNativeAssetIdentifiers = jest.fn(); + const tokenPricesService = buildMockTokenPricesService({ + setNativeAssetIdentifiers, + }); -describe('TokenRatesController', () => { - beforeAll(() => { - nock.disableNetConnect(); - }); + await withController( + { + options: { + tokenPricesService, + }, + }, + async () => { + expect(setNativeAssetIdentifiers).toHaveBeenCalledWith({ + 'eip155:1': 'eip155:1/slip44:60', + 'eip155:137': 'eip155:137/slip44:966', + }); + }, + ); + }); - afterAll(() => { - nock.enableNetConnect(); - }); + it('should not fail if tokenPricesService does not have setNativeAssetIdentifiers', async () => { + const tokenPricesService = buildMockTokenPricesService(); + // Explicitly remove setNativeAssetIdentifiers to simulate an old service + delete (tokenPricesService as Partial) + .setNativeAssetIdentifiers; - beforeEach(() => { - nock(COINGECKO_API) - .get(COINGECKO_SUPPORTED_CURRENCIES) - .reply(200, ['eth', 'usd', 'dai']) - .get(COINGECKO_ASSETS_PATH) - .reply(200, [ + await withController( { - id: 'binance-smart-chain', - chain_identifier: 56, - name: 'Binance Smart Chain', - shortname: 'BSC', + options: { + tokenPricesService, + }, }, + async ({ controller }) => { + // Should not throw and controller should be created + expect(controller.state).toStrictEqual({ + marketData: {}, + }); + }, + ); + }); + + it('clears persisted marketData at construction when isDeprecated() returns true', async () => { + const initialMarketData = { + '0x1': { + '0x0000000000000000000000000000000000000000': { + currency: 'ETH', + price: 0.001, + } as unknown as MarketDataDetails, + }, + }; + + await withController( { - id: 'ethereum', - chain_identifier: 1, - name: 'Ethereum', - shortname: '', + options: { + isDeprecated: () => true, + state: { marketData: initialMarketData }, + }, + }, + async ({ controller }) => { + expect(controller.state.marketData).toStrictEqual({}); + }, + ); + }); + + it('preserves persisted marketData at construction when isDeprecated() returns false', async () => { + const initialMarketData = { + '0x1': { + '0x0000000000000000000000000000000000000000': { + currency: 'ETH', + price: 0.001, + } as unknown as MarketDataDetails, }, + }; + + await withController( { - id: 'polygon-pos-network', - chain_identifier: 137, - name: 'Polygon', - shortname: 'MATIC', + options: { + isDeprecated: () => false, + state: { marketData: initialMarketData }, + }, }, - ]); + async ({ controller }) => { + expect(controller.state.marketData).toStrictEqual(initialMarketData); + }, + ); + }); }); - afterEach(() => { - sinon.restore(); - jest.resetAllMocks(); - }); + describe('updateExchangeRates', () => { + it('does not fetch when disabled', async () => { + const tokenPricesService = buildMockTokenPricesService(); + jest.spyOn(tokenPricesService, 'fetchTokenPrices'); - describe('constructor', () => { - it('should set default state', () => { - const controller = new TokenRatesController({ - chainId: toHex(1), - ticker: NetworksTicker.mainnet, - selectedAddress: defaultSelectedAddress, - onPreferencesStateChange: sinon.stub(), - onTokensStateChange: sinon.stub(), - onNetworkStateChange: sinon.stub(), - }); - expect(controller.state).toStrictEqual({ - contractExchangeRates: {}, - }); - }); + await withController( + { + options: { + tokenPricesService, + disabled: true, + }, + }, + async ({ controller }) => { + await controller.updateExchangeRates([ + { + chainId: '0x1', + nativeCurrency: 'ETH', + }, + ]); - it('should initialize with the default config', () => { - const controller = new TokenRatesController({ - chainId: toHex(1), - ticker: NetworksTicker.mainnet, - selectedAddress: defaultSelectedAddress, - onPreferencesStateChange: sinon.stub(), - onTokensStateChange: sinon.stub(), - onNetworkStateChange: sinon.stub(), - }); - expect(controller.config).toStrictEqual({ - allDetectedTokens: {}, - allTokens: {}, - disabled: false, - interval: 180000, - nativeCurrency: NetworksTicker.mainnet, - chainId: toHex(1), - selectedAddress: defaultSelectedAddress, - threshold: 21600000, - }); + expect(tokenPricesService.fetchTokenPrices).not.toHaveBeenCalled(); + }, + ); }); - it('should not poll by default', async () => { - const clock = sinon.useFakeTimers({ now: Date.now() }); - const fetchSpy = jest.spyOn(globalThis, 'fetch'); - new TokenRatesController( + it('does not fetch or update state when isDeprecated returns true', async () => { + const tokenPricesService = buildMockTokenPricesService(); + jest.spyOn(tokenPricesService, 'fetchTokenPrices'); + + await withController( { - chainId: toHex(1), - ticker: NetworksTicker.mainnet, - selectedAddress: defaultSelectedAddress, - onPreferencesStateChange: jest.fn(), - onTokensStateChange: jest.fn(), - onNetworkStateChange: jest.fn(), + options: { + tokenPricesService, + isDeprecated: () => true, + }, }, - { - interval: 100, - allTokens: { - [toHex(1)]: { - [defaultSelectedAddress]: [ - { address: 'bar', decimals: 0, symbol: '', aggregators: [] }, - ], + async ({ controller }) => { + const stateBefore = controller.state.marketData; + + await controller.updateExchangeRates([ + { + chainId: '0x1', + nativeCurrency: 'ETH', }, - }, + ]); + + expect(tokenPricesService.fetchTokenPrices).not.toHaveBeenCalled(); + expect(controller.state.marketData).toBe(stateBefore); }, ); + }); + + it('clears stale marketData when isDeprecated toggles to true at runtime', async () => { + const tokenPricesService = buildMockTokenPricesService(); + jest.spyOn(tokenPricesService, 'fetchTokenPrices'); + let deprecated = false; + const initialMarketData = { + '0x1': { + '0x0000000000000000000000000000000000000000': { + currency: 'ETH', + price: 0.001, + } as unknown as MarketDataDetails, + }, + }; + + await withController( + { + options: { + tokenPricesService, + isDeprecated: () => deprecated, + state: { marketData: initialMarketData }, + }, + }, + async ({ controller }) => { + expect(controller.state.marketData).toStrictEqual(initialMarketData); - await clock.tickAsync(500); + deprecated = true; + + await controller.updateExchangeRates([ + { + chainId: '0x1', + nativeCurrency: 'ETH', + }, + ]); - expect(fetchSpy).not.toHaveBeenCalled(); + expect(tokenPricesService.fetchTokenPrices).not.toHaveBeenCalled(); + expect(controller.state.marketData).toStrictEqual({}); + }, + ); }); - }); - describe('TokensController::stateChange', () => { - describe('when polling is active', () => { - it('should update exchange rates when tokens change', async () => { - sinon.useFakeTimers({ now: Date.now() }); - let tokenStateChangeListener: (state: any) => Promise; - const onTokensStateChange = sinon.stub().callsFake((listener) => { - tokenStateChangeListener = listener; - }); - const onNetworkStateChange = sinon.stub(); - const controller = new TokenRatesController( - { - chainId: toHex(1), - ticker: NetworksTicker.mainnet, - selectedAddress: defaultSelectedAddress, - onPreferencesStateChange: sinon.stub(), - onTokensStateChange, - onNetworkStateChange, + it('fetches rates for tokens in one batch', async () => { + const chainId = '0x1'; + const nativeCurrency = 'ETH'; + + const tokenPricesService = buildMockTokenPricesService({ + fetchTokenPrices: fetchTokenPricesWithIncreasingPriceForEachToken, + }); + jest.spyOn(tokenPricesService, 'fetchTokenPrices'); + + await withController( + { + options: { + tokenPricesService, }, - { - interval: 10, + mockTokensControllerState: { allTokens: { - [toHex(1)]: { + [chainId]: { [defaultSelectedAddress]: [ - { address: 'bar', decimals: 0, symbol: '', aggregators: [] }, + { + address: '0x0000000000000000000000000000000000000001', + decimals: 0, + symbol: 'TOK1', + }, ], }, }, }, - ); - await controller.start(); - const updateExchangeRatesStub = sinon.stub( - controller, - 'updateExchangeRates', - ); - - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - await tokenStateChangeListener!({ - allDetectedTokens: {}, - allTokens: { - [toHex(1)]: { - [defaultSelectedAddress]: [ - { address: 'foo', decimals: 0, symbol: '', aggregators: [] }, - ], + }, + async ({ controller }) => { + await controller.updateExchangeRates([ + { + chainId, + nativeCurrency, }, - }, - }); - - expect(updateExchangeRatesStub.callCount).toBe(1); - }); + ]); + + expect(tokenPricesService.fetchTokenPrices).toHaveBeenCalledTimes(1); + expect(tokenPricesService.fetchTokenPrices).toHaveBeenCalledWith({ + assets: [ + { + chainId, + tokenAddress: '0x0000000000000000000000000000000000000000', + }, + { + chainId, + tokenAddress: '0x0000000000000000000000000000000000000001', + }, + ], + currency: nativeCurrency, + }); - it('should update exchange rates when detected tokens are added', async () => { - nock(COINGECKO_API) - .get(`${COINGECKO_ETH_PATH}`) - .query({ contract_addresses: '0x02,0x03', vs_currencies: 'eth' }) - .reply(200, { - '0x02': { - eth: 0.001, - }, - '0x03': { - eth: 0.002, + expect(controller.state.marketData).toStrictEqual({ + '0x1': { + '0x0000000000000000000000000000000000000000': + expect.objectContaining({ + currency: nativeCurrency, + price: 0.001, + }), + '0x0000000000000000000000000000000000000001': + expect.objectContaining({ + currency: nativeCurrency, + price: 0.002, + }), }, }); - let tokenStateChangeListener: (state: any) => Promise; - const onTokensStateChange = sinon.stub().callsFake((listener) => { - tokenStateChangeListener = listener; - }); - const controller = new TokenRatesController( - { - chainId: toHex(1), - ticker: NetworksTicker.mainnet, - selectedAddress: defaultSelectedAddress, - onPreferencesStateChange: sinon.stub(), - onTokensStateChange, - onNetworkStateChange: sinon.stub(), - }, - { interval: 10 }, - ); - expect(controller.state.contractExchangeRates).toStrictEqual({}); - - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - await tokenStateChangeListener!({ - allDetectedTokens: { - [toHex(1)]: { - [defaultSelectedAddress]: [ - { - address: '0x02', - decimals: 18, - image: undefined, - symbol: 'bar', - isERC721: false, - }, - { - address: '0x03', - decimals: 18, - image: undefined, - symbol: 'bazz', - isERC721: false, - }, - ], - }, - }, - allTokens: {}, - }); - await controller.updateExchangeRates(); - - expect(controller.state.contractExchangeRates).toStrictEqual({ - '0x02': 0.001, - '0x03': 0.002, - }); - }); - - it('should not update exchange rates when token state changes without "all tokens" or "all detected tokens" changing', async () => { - sinon.useFakeTimers({ now: Date.now() }); - let tokenStateChangeListener: (state: any) => Promise; - const onTokensStateChange = sinon.stub().callsFake((listener) => { - tokenStateChangeListener = listener; - }); - const onNetworkStateChange = sinon.stub(); - const allTokens = { - [toHex(1)]: { - [defaultSelectedAddress]: [ - { address: 'foo', decimals: 0, symbol: '', aggregators: [] }, - ], - }, - }; - const allDetectedTokens = {}; - const controller = new TokenRatesController( - { - chainId: toHex(1), - ticker: NetworksTicker.mainnet, - selectedAddress: defaultSelectedAddress, - onPreferencesStateChange: sinon.stub(), - onTokensStateChange, - onNetworkStateChange, - }, - { - interval: 10, - allDetectedTokens, - allTokens, - }, - ); - await controller.start(); - const updateExchangeRatesStub = sinon.stub( - controller, - 'updateExchangeRates', - ); - - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - await tokenStateChangeListener!({ - allDetectedTokens, - allTokens, - tokens: [ - { address: 'bar', decimals: 0, symbol: '', aggregators: [] }, - ], - }); - - expect(updateExchangeRatesStub.callCount).toBe(0); - }); + }, + ); }); - describe('when polling is inactive', () => { - it('should not update exchange rates when tokens change', async () => { - let tokenStateChangeListener: (state: any) => Promise; - const onTokensStateChange = sinon.stub().callsFake((listener) => { - tokenStateChangeListener = listener; - }); - const onNetworkStateChange = sinon.stub(); - const controller = new TokenRatesController( - { - chainId: toHex(1), - ticker: NetworksTicker.mainnet, - selectedAddress: defaultSelectedAddress, - onPreferencesStateChange: sinon.stub(), - onTokensStateChange, - onNetworkStateChange, - }, - { interval: 10 }, - ); - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - await tokenStateChangeListener!({ - allDetectedTokens: {}, - allTokens: { - [toHex(1)]: { - [defaultSelectedAddress]: [ - { address: 'bar', decimals: 0, symbol: '', aggregators: [] }, - ], - }, - }, - }); - const updateExchangeRatesStub = sinon.stub( - controller, - 'updateExchangeRates', - ); - - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - await tokenStateChangeListener!({ - allDetectedTokens: {}, - allTokens: { - [toHex(1)]: { - [defaultSelectedAddress]: [ - { address: 'foo', decimals: 0, symbol: '', aggregators: [] }, - ], - }, - }, - }); + it('fetches rates for all tokens in batches', async () => { + const chainId = '0x1'; + const nativeCurrency = 'ETH'; - expect(updateExchangeRatesStub.callCount).toBe(0); + const tokenPricesService = buildMockTokenPricesService({ + fetchTokenPrices: fetchTokenPricesWithIncreasingPriceForEachToken, }); + jest.spyOn(tokenPricesService, 'fetchTokenPrices'); - it('should not update exchange rates when detectedtokens change', async () => { - let tokenStateChangeListener: (state: any) => Promise; - const onTokensStateChange = sinon.stub().callsFake((listener) => { - tokenStateChangeListener = listener; - }); - const onNetworkStateChange = sinon.stub(); - const controller = new TokenRatesController( - { - chainId: toHex(1), - ticker: NetworksTicker.mainnet, - selectedAddress: defaultSelectedAddress, - onPreferencesStateChange: sinon.stub(), - onTokensStateChange, - onNetworkStateChange, + const tokenAddresses = [...new Array(200).keys()] + .map(buildAddress) + .sort(); + const tokens = tokenAddresses.map((tokenAddress) => { + return buildToken({ address: tokenAddress }); + }); + await withController( + { + options: { + tokenPricesService, }, - { interval: 10 }, - ); - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - await tokenStateChangeListener!({ - allDetectedTokens: { - [toHex(1)]: { - [defaultSelectedAddress]: [ - { address: 'bar', decimals: 0, symbol: '', aggregators: [] }, - ], + mockTokensControllerState: { + allTokens: { + [chainId]: { + [defaultSelectedAddress]: tokens, + }, }, }, - allTokens: {}, - }); - const updateExchangeRatesStub = sinon.stub( - controller, - 'updateExchangeRates', - ); - - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - await tokenStateChangeListener!({ - allDetectedTokens: { - [toHex(1)]: { - [defaultSelectedAddress]: [ - { address: 'foo', decimals: 0, symbol: '', aggregators: [] }, - ], + }, + async ({ controller }) => { + await controller.updateExchangeRates([ + { + chainId, + nativeCurrency, }, - }, - allTokens: {}, - }); - - expect(updateExchangeRatesStub.callCount).toBe(0); - }); + ]); + const numBatches = Math.ceil( + tokenAddresses.length / TOKEN_PRICES_BATCH_SIZE, + ); + expect(tokenPricesService.fetchTokenPrices).toHaveBeenCalledTimes( + numBatches, + ); + + for (let i = 1; i <= numBatches; i++) { + expect(tokenPricesService.fetchTokenPrices).toHaveBeenNthCalledWith( + i, + { + assets: tokenAddresses + .slice( + (i - 1) * TOKEN_PRICES_BATCH_SIZE, + i * TOKEN_PRICES_BATCH_SIZE, + ) + .map((tokenAddress) => ({ + chainId, + tokenAddress, + })), + currency: nativeCurrency, + }, + ); + } + }, + ); }); - }); - describe('NetworkController::stateChange', () => { - describe('when polling is active', () => { - it('should update exchange rates when ticker changes', async () => { - sinon.useFakeTimers({ now: Date.now() }); - let networkStateChangeListener: (state: any) => Promise; - const onTokensStateChange = sinon.stub(); - const onNetworkStateChange = sinon.stub().callsFake((listener) => { - networkStateChangeListener = listener; - }); - const controller = new TokenRatesController( - { - chainId: toHex(1337), - ticker: 'TEST', - selectedAddress: defaultSelectedAddress, - onPreferencesStateChange: sinon.stub(), - onTokensStateChange, - onNetworkStateChange, - }, - { interval: 10 }, - ); - await controller.start(); - const updateExchangeRatesStub = sinon.stub( - controller, - 'updateExchangeRates', - ); - - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - await networkStateChangeListener!({ - providerConfig: { chainId: toHex(1337), ticker: 'NEW' }, - }); + it('leaves unsupported chain state keys empty', async () => { + const chainId = '0x1'; + const nativeCurrency = 'ETH'; - expect(updateExchangeRatesStub.callCount).toBe(1); + const tokenPricesService = buildMockTokenPricesService({ + fetchTokenPrices: fetchTokenPricesWithIncreasingPriceForEachToken, + validateChainIdSupported: (_chainId: unknown): _chainId is Hex => false, }); + jest.spyOn(tokenPricesService, 'fetchTokenPrices'); - it('should update exchange rates when chain ID changes', async () => { - sinon.useFakeTimers({ now: Date.now() }); - let networkStateChangeListener: (state: any) => Promise; - const onTokensStateChange = sinon.stub(); - const onNetworkStateChange = sinon.stub().callsFake((listener) => { - networkStateChangeListener = listener; - }); - const controller = new TokenRatesController( - { - chainId: toHex(1337), - ticker: 'TEST', - selectedAddress: defaultSelectedAddress, - onPreferencesStateChange: sinon.stub(), - onTokensStateChange, - onNetworkStateChange, + await withController( + { + options: { + tokenPricesService, }, - { interval: 10 }, - ); - await controller.start(); - const updateExchangeRatesStub = sinon.stub( - controller, - 'updateExchangeRates', - ); - - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - await networkStateChangeListener!({ - providerConfig: { chainId: toHex(1338), ticker: 'TEST' }, - }); + }, + async ({ controller }) => { + await controller.updateExchangeRates([ + { + chainId, + nativeCurrency, + }, + ]); - expect(updateExchangeRatesStub.callCount).toBe(1); - }); + expect(tokenPricesService.fetchTokenPrices).not.toHaveBeenCalled(); + expect(controller.state.marketData).toStrictEqual({ + [chainId]: {}, + }); + }, + ); + }); - it('should clear contractExchangeRates state when ticker changes', async () => { - nock(COINGECKO_API) - .get(`${COINGECKO_ETH_PATH}`) - .query({ contract_addresses: '0x02,0x03', vs_currencies: 'eth' }) - .reply(200, { - '0x02': { - eth: 0.001, // token value in terms of ETH + it('fetches rates for unsupported native currencies', async () => { + const chainId = '0x1'; + const nativeCurrency = 'ETH'; + + const tokenPricesService = buildMockTokenPricesService({ + fetchTokenPrices: async ({ currency }) => { + return [ + { + tokenAddress: ZERO_ADDRESS, + chainId, + assetId: `${KnownCaipNamespace.Eip155}:1/slip44:60`, + currency, + price: 50, + pricePercentChange1d: 0, + priceChange1d: 0, + allTimeHigh: 60, + allTimeLow: 40, + circulatingSupply: 2000, + dilutedMarketCap: 1000, + high1d: 55, + low1d: 45, + marketCap: 2000, + marketCapPercentChange1d: 100, + pricePercentChange14d: 100, + pricePercentChange1h: 1, + pricePercentChange1y: 200, + pricePercentChange200d: 300, + pricePercentChange30d: 200, + pricePercentChange7d: 100, + totalVolume: 100, }, - '0x03': { - eth: 0.002, + { + tokenAddress: '0x0000000000000000000000000000000000000001', + chainId, + assetId: `${KnownCaipNamespace.Eip155}:1/erc20:0x0000000000000000000000000000000000000001`, + currency, + price: 100, + pricePercentChange1d: 0, + priceChange1d: 0, + allTimeHigh: 200, + allTimeLow: 80, + circulatingSupply: 2000, + dilutedMarketCap: 500, + high1d: 110, + low1d: 95, + marketCap: 1000, + marketCapPercentChange1d: 100, + pricePercentChange14d: 100, + pricePercentChange1h: 1, + pricePercentChange1y: 200, + pricePercentChange200d: 300, + pricePercentChange30d: 200, + pricePercentChange7d: 100, + totalVolume: 100, }, - }) - .get(`${COINGECKO_ETH_PATH}`) - .query({ contract_addresses: '0x02,0x03', vs_currencies: 'dai' }) - .replyWithError('Custom error'); - - let networkChangeListener: (state: any) => Promise; - const onNetworkStateChange = sinon.stub().callsFake((listener) => { - networkChangeListener = listener; - }); + ]; + }, + validateCurrencySupported: (_currency: unknown): _currency is string => + false, + }); + jest.spyOn(tokenPricesService, 'fetchTokenPrices'); - const controller = new TokenRatesController( - { - chainId: toHex(1), - ticker: NetworksTicker.mainnet, - selectedAddress: defaultSelectedAddress, - onPreferencesStateChange: sinon.stub(), - onTokensStateChange: sinon.stub(), - onNetworkStateChange, + await withController( + { + options: { + tokenPricesService, }, - { - interval: 10, - nativeCurrency: 'ETH', + mockTokensControllerState: { allTokens: { - [toHex(1)]: { + [chainId]: { [defaultSelectedAddress]: [ { - address: '0x02', - decimals: 18, - image: undefined, - symbol: 'bar', - isERC721: false, - }, - { - address: '0x03', - decimals: 18, - image: undefined, - symbol: 'bazz', - isERC721: false, + address: '0x0000000000000000000000000000000000000001', + decimals: 0, + symbol: 'TOK1', }, ], }, }, }, - ); - - await controller.start(); - - expect(controller.state.contractExchangeRates).toStrictEqual({ - '0x02': 0.001, - '0x03': 0.002, - }); + }, + async ({ controller }) => { + await controller.updateExchangeRates([ + { + chainId, + nativeCurrency, + }, + ]); + + expect(tokenPricesService.fetchTokenPrices).toHaveBeenCalledTimes(1); + expect(tokenPricesService.fetchTokenPrices).toHaveBeenCalledWith({ + assets: [ + { + chainId, + tokenAddress: '0x0000000000000000000000000000000000000000', + }, + { + chainId, + tokenAddress: '0x0000000000000000000000000000000000000001', + }, + ], + currency: 'usd', + }); - // Ensure next update throws an error so that the "blank" state that - // we're testing for isn't overwritten - await expect(() => - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - networkChangeListener!({ - providerConfig: { chainId: toHex(1), ticker: 'DAI' }, - }), - ).rejects.toThrow('Custom error'); + expect(controller.state.marketData).toStrictEqual({ + '0x1': { + '0x0000000000000000000000000000000000000000': { + tokenAddress: '0x0000000000000000000000000000000000000000', + chainId: '0x1', + assetId: 'eip155:1/slip44:60', + currency: 'ETH', + price: 1, + pricePercentChange1d: 0, + priceChange1d: 0, + allTimeHigh: 1.2, + allTimeLow: 0.8, + circulatingSupply: 2000, + dilutedMarketCap: 20, + high1d: 1.1, + low1d: 0.9, + marketCap: 40, + marketCapPercentChange1d: 100, + pricePercentChange14d: 100, + pricePercentChange1h: 1, + pricePercentChange1y: 200, + pricePercentChange200d: 300, + pricePercentChange30d: 200, + pricePercentChange7d: 100, + totalVolume: 2, + }, + '0x0000000000000000000000000000000000000001': { + tokenAddress: '0x0000000000000000000000000000000000000001', + chainId: '0x1', + assetId: + 'eip155:1/erc20:0x0000000000000000000000000000000000000001', + currency: 'ETH', + price: 2, + pricePercentChange1d: 0, + priceChange1d: 0, + allTimeHigh: 4, + allTimeLow: 1.6, + circulatingSupply: 2000, + dilutedMarketCap: 10, + high1d: 2.2, + low1d: 1.9, + marketCap: 20, + marketCapPercentChange1d: 100, + pricePercentChange14d: 100, + pricePercentChange1h: 1, + pricePercentChange1y: 200, + pricePercentChange200d: 300, + pricePercentChange30d: 200, + pricePercentChange7d: 100, + totalVolume: 2, + }, + }, + }); + }, + ); + }); - expect(controller.state.contractExchangeRates).toStrictEqual({}); + it('does not convert prices when the native currency fallback price is 0', async () => { + const chainId = '0x1'; + const nativeCurrency = 'ETH'; + + const tokenPricesService = buildMockTokenPricesService({ + fetchTokenPrices: async ({ currency }) => { + return [ + { + tokenAddress: ZERO_ADDRESS, + chainId, + assetId: `${KnownCaipNamespace.Eip155}:1/slip44:60`, + currency, + price: 0, + pricePercentChange1d: 0, + priceChange1d: 0, + allTimeHigh: 60, + allTimeLow: 40, + circulatingSupply: 2000, + dilutedMarketCap: 1000, + high1d: 55, + low1d: 45, + marketCap: 2000, + marketCapPercentChange1d: 100, + pricePercentChange14d: 100, + pricePercentChange1h: 1, + pricePercentChange1y: 200, + pricePercentChange200d: 300, + pricePercentChange30d: 200, + pricePercentChange7d: 100, + totalVolume: 100, + }, + { + tokenAddress: '0x0000000000000000000000000000000000000001', + chainId, + assetId: `${KnownCaipNamespace.Eip155}:1/erc20:0x0000000000000000000000000000000000000001`, + currency, + price: 100, + pricePercentChange1d: 0, + priceChange1d: 0, + allTimeHigh: 200, + allTimeLow: 80, + circulatingSupply: 2000, + dilutedMarketCap: 500, + high1d: 110, + low1d: 95, + marketCap: 1000, + marketCapPercentChange1d: 100, + pricePercentChange14d: 100, + pricePercentChange1h: 1, + pricePercentChange1y: 200, + pricePercentChange200d: 300, + pricePercentChange30d: 200, + pricePercentChange7d: 100, + totalVolume: 100, + }, + ]; + }, + validateCurrencySupported: (_currency: unknown): _currency is string => + false, }); + jest.spyOn(tokenPricesService, 'fetchTokenPrices'); - it('should clear contractExchangeRates state when chain ID changes', async () => { - nock(COINGECKO_API) - .get(`${COINGECKO_ETH_PATH}`) - .query({ contract_addresses: '0x02,0x03', vs_currencies: 'eth' }) - .reply(200, { - '0x02': { - eth: 0.001, // token value in terms of ETH + await withController( + { + options: { + tokenPricesService, + }, + mockTokensControllerState: { + allTokens: { + [chainId]: { + [defaultSelectedAddress]: [ + { + address: '0x0000000000000000000000000000000000000001', + decimals: 0, + symbol: 'TOK1', + }, + ], + }, }, - '0x03': { - eth: 0.002, + }, + }, + async ({ controller }) => { + await controller.updateExchangeRates([ + { + chainId, + nativeCurrency, }, + ]); + + expect(tokenPricesService.fetchTokenPrices).toHaveBeenCalledTimes(1); + expect(tokenPricesService.fetchTokenPrices).toHaveBeenCalledWith({ + assets: [ + { + chainId, + tokenAddress: '0x0000000000000000000000000000000000000000', + }, + { + chainId, + tokenAddress: '0x0000000000000000000000000000000000000001', + }, + ], + currency: 'usd', }); - let networkChangeListener: (state: any) => Promise; - const onNetworkStateChange = sinon.stub().callsFake((listener) => { - networkChangeListener = listener; - }); + expect(controller.state.marketData).toStrictEqual({ + '0x1': {}, + }); + }, + ); + }); - const controller = new TokenRatesController( - { - chainId: toHex(1), - ticker: NetworksTicker.mainnet, - selectedAddress: defaultSelectedAddress, - onPreferencesStateChange: sinon.stub(), - onTokensStateChange: sinon.stub(), - onNetworkStateChange, + it('does not convert prices when the native currency fallback price is missing', async () => { + const chainId = '0x1'; + const nativeCurrency = 'ETH'; + + const tokenPricesService = buildMockTokenPricesService({ + fetchTokenPrices: async ({ currency }) => { + return [ + { + tokenAddress: '0x0000000000000000000000000000000000000001', + chainId, + assetId: `${KnownCaipNamespace.Eip155}:1/erc20:0x0000000000000000000000000000000000000001`, + currency, + price: 100, + pricePercentChange1d: 0, + priceChange1d: 0, + allTimeHigh: 200, + allTimeLow: 80, + circulatingSupply: 2000, + dilutedMarketCap: 500, + high1d: 110, + low1d: 95, + marketCap: 1000, + marketCapPercentChange1d: 100, + pricePercentChange14d: 100, + pricePercentChange1h: 1, + pricePercentChange1y: 200, + pricePercentChange200d: 300, + pricePercentChange30d: 200, + pricePercentChange7d: 100, + totalVolume: 100, + }, + ]; + }, + validateCurrencySupported: (_currency: unknown): _currency is string => + false, + }); + jest.spyOn(tokenPricesService, 'fetchTokenPrices'); + + await withController( + { + options: { + tokenPricesService, }, - { - interval: 10, - nativeCurrency: 'ETH', + mockTokensControllerState: { allTokens: { - [toHex(1)]: { + [chainId]: { [defaultSelectedAddress]: [ { - address: '0x02', - decimals: 18, - image: undefined, - symbol: 'bar', - isERC721: false, - }, - { - address: '0x03', - decimals: 18, - image: undefined, - symbol: 'bazz', - isERC721: false, + address: '0x0000000000000000000000000000000000000001', + decimals: 0, + symbol: 'TOK1', }, ], }, }, }, - ); + }, + async ({ controller }) => { + await controller.updateExchangeRates([ + { + chainId, + nativeCurrency, + }, + ]); + + expect(tokenPricesService.fetchTokenPrices).toHaveBeenCalledTimes(1); + expect(tokenPricesService.fetchTokenPrices).toHaveBeenCalledWith({ + assets: [ + { + chainId, + tokenAddress: '0x0000000000000000000000000000000000000000', + }, + { + chainId, + tokenAddress: '0x0000000000000000000000000000000000000001', + }, + ], + currency: 'usd', + }); - await controller.start(); + expect(controller.state.marketData).toStrictEqual({ + '0x1': {}, + }); + }, + ); + }); + }); - expect(controller.state.contractExchangeRates).toStrictEqual({ - '0x02': 0.001, - '0x03': 0.002, - }); + describe('_executePoll', () => { + it('fetches rates for the given chains', async () => { + await withController({}, async ({ controller }) => { + jest.spyOn(controller, 'updateExchangeRates'); - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - await networkChangeListener!({ - providerConfig: { chainId: toHex(2) }, - }); + await controller._executePoll({ chainIds: ['0x1'] }); - expect(controller.state.contractExchangeRates).toStrictEqual({}); + expect(controller.updateExchangeRates).toHaveBeenCalledWith([ + { + chainId: '0x1', + nativeCurrency: 'ETH', + }, + ]); }); + }); - it('should not update exchange rates when network state changes without a ticker/chain id change', async () => { - sinon.useFakeTimers({ now: Date.now() }); - let networkStateChangeListener: (state: any) => Promise; - const onTokensStateChange = sinon.stub(); - const onNetworkStateChange = sinon.stub().callsFake((listener) => { - networkStateChangeListener = listener; - }); - const controller = new TokenRatesController( - { - chainId: toHex(1), - ticker: NetworksTicker.mainnet, - selectedAddress: defaultSelectedAddress, - onPreferencesStateChange: sinon.stub(), - onTokensStateChange, - onNetworkStateChange, + it('does not include chains with no network configuration', async () => { + await withController( + { + mockNetworkState: { + networkConfigurationsByChainId: {}, }, - { interval: 10 }, - ); - await controller.start(); - const updateExchangeRatesStub = sinon.stub( - controller, - 'updateExchangeRates', - ); - - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - await networkStateChangeListener!({ - providerConfig: { chainId: toHex(1), ticker: NetworksTicker.mainnet }, - }); + }, + async ({ controller }) => { + jest.spyOn(controller, 'updateExchangeRates'); - expect(updateExchangeRatesStub.callCount).toBe(0); - }); + await controller._executePoll({ chainIds: ['0x1'] }); + + expect(controller.updateExchangeRates).toHaveBeenCalledWith([]); + }, + ); }); - describe('when polling is inactive', () => { - it('should not update exchange rates when ticker changes', async () => { - let networkStateChangeListener: (state: any) => Promise; - const onTokensStateChange = sinon.stub(); - const onNetworkStateChange = sinon.stub().callsFake((listener) => { - networkStateChangeListener = listener; - }); - const controller = new TokenRatesController( - { - chainId: toHex(1337), - ticker: 'TEST', - selectedAddress: defaultSelectedAddress, - onPreferencesStateChange: sinon.stub(), - onTokensStateChange, - onNetworkStateChange, + it('does nothing when isDeprecated returns true', async () => { + await withController( + { + options: { + isDeprecated: () => true, }, - { interval: 10 }, - ); - const updateExchangeRatesStub = sinon.stub( - controller, - 'updateExchangeRates', - ); - - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - await networkStateChangeListener!({ - providerConfig: { chainId: toHex(1337), ticker: 'NEW' }, - }); + }, + async ({ controller }) => { + jest.spyOn(controller, 'updateExchangeRates'); - expect(updateExchangeRatesStub.callCount).toBe(0); - }); + await controller._executePoll({ chainIds: ['0x1'] }); - it('should not update exchange rates when chain ID changes', async () => { - let networkStateChangeListener: (state: any) => Promise; - const onTokensStateChange = sinon.stub(); - const onNetworkStateChange = sinon.stub().callsFake((listener) => { - networkStateChangeListener = listener; - }); - const controller = new TokenRatesController( - { - chainId: toHex(1337), - ticker: 'TEST', - selectedAddress: defaultSelectedAddress, - onPreferencesStateChange: sinon.stub(), - onTokensStateChange, - onNetworkStateChange, + expect(controller.updateExchangeRates).not.toHaveBeenCalled(); + }, + ); + }); + + it('clears stale marketData when isDeprecated toggles to true at runtime', async () => { + let deprecated = false; + const initialMarketData = { + '0x1': { + '0x0000000000000000000000000000000000000000': { + currency: 'ETH', + price: 0.001, + } as unknown as MarketDataDetails, + }, + }; + + await withController( + { + options: { + isDeprecated: () => deprecated, + state: { marketData: initialMarketData }, }, - { interval: 10 }, - ); - const updateExchangeRatesStub = sinon.stub( - controller, - 'updateExchangeRates', - ); - - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - await networkStateChangeListener!({ - providerConfig: { chainId: toHex(1338), ticker: 'TEST' }, - }); + }, + async ({ controller }) => { + jest.spyOn(controller, 'updateExchangeRates'); + expect(controller.state.marketData).toStrictEqual(initialMarketData); - expect(updateExchangeRatesStub.callCount).toBe(0); - }); + deprecated = true; - it('should clear contractExchangeRates state when ticker changes', async () => { - nock(COINGECKO_API) - .get(`${COINGECKO_ETH_PATH}`) - .query({ contract_addresses: '0x02,0x03', vs_currencies: 'eth' }) - .reply(200, { - '0x02': { - eth: 0.001, // token value in terms of ETH - }, - '0x03': { - eth: 0.002, - }, - }); + await controller._executePoll({ chainIds: ['0x1'] }); - let networkChangeListener: (state: any) => Promise; - const onNetworkStateChange = sinon.stub().callsFake((listener) => { - networkChangeListener = listener; - }); + expect(controller.updateExchangeRates).not.toHaveBeenCalled(); + expect(controller.state.marketData).toStrictEqual({}); + }, + ); + }); + }); - const controller = new TokenRatesController( - { - chainId: toHex(1), - ticker: NetworksTicker.mainnet, - selectedAddress: defaultSelectedAddress, - onPreferencesStateChange: sinon.stub(), - onTokensStateChange: sinon.stub(), - onNetworkStateChange, + describe('TokensController:stateChange', () => { + it('fetches rates for all updated chains', async () => { + jest.useFakeTimers(); + const chainId = '0x1'; + const nativeCurrency = 'ETH'; + + const tokenPricesService = buildMockTokenPricesService({ + fetchTokenPrices: fetchTokenPricesWithIncreasingPriceForEachToken, + }); + jest.spyOn(tokenPricesService, 'fetchTokenPrices'); + + await withController( + { + options: { + tokenPricesService, }, - { - interval: 10, - nativeCurrency: 'ETH', + }, + async ({ controller, triggerTokensStateChange }) => { + triggerTokensStateChange({ allTokens: { - [toHex(1)]: { + [chainId]: { + [defaultSelectedAddress]: [ + { + address: '0x0000000000000000000000000000000000000001', + decimals: 0, + symbol: 'TOK1', + }, + ], + }, + }, + allDetectedTokens: { + [chainId]: { [defaultSelectedAddress]: [ { - address: '0x02', - decimals: 18, - image: undefined, - symbol: 'bar', - isERC721: false, + address: '0x0000000000000000000000000000000000000001', + decimals: 0, + symbol: 'TOK1', }, { - address: '0x03', - decimals: 18, - image: undefined, - symbol: 'bazz', - isERC721: false, + address: '0x0000000000000000000000000000000000000002', + decimals: 0, + symbol: 'TOK2', }, ], }, }, - }, - ); - - await controller.updateExchangeRates(); - - expect(controller.state.contractExchangeRates).toStrictEqual({ - '0x02': 0.001, - '0x03': 0.002, - }); + allIgnoredTokens: {}, + }); - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - await networkChangeListener!({ - providerConfig: { chainId: toHex(1), ticker: 'NEW' }, - }); + jest.advanceTimersToNextTimer(); + await flushPromises(); - expect(controller.state.contractExchangeRates).toStrictEqual({}); - }); + expect(tokenPricesService.fetchTokenPrices).toHaveBeenCalledTimes(1); + expect(tokenPricesService.fetchTokenPrices).toHaveBeenCalledWith({ + assets: [ + { + chainId, + tokenAddress: '0x0000000000000000000000000000000000000000', + }, + { + chainId, + tokenAddress: '0x0000000000000000000000000000000000000001', + }, + { + chainId, + tokenAddress: '0x0000000000000000000000000000000000000002', + }, + ], + currency: nativeCurrency, + }); - it('should clear contractExchangeRates state when chain ID changes', async () => { - nock(COINGECKO_API) - .get(`${COINGECKO_ETH_PATH}`) - .query({ contract_addresses: '0x02,0x03', vs_currencies: 'eth' }) - .reply(200, { - '0x02': { - eth: 0.001, // token value in terms of ETH - }, - '0x03': { - eth: 0.002, + expect(controller.state.marketData).toStrictEqual({ + [chainId]: { + '0x0000000000000000000000000000000000000000': + expect.objectContaining({ + currency: nativeCurrency, + price: 0.001, + }), + '0x0000000000000000000000000000000000000001': + expect.objectContaining({ + currency: nativeCurrency, + price: 0.002, + }), + '0x0000000000000000000000000000000000000002': + expect.objectContaining({ + currency: nativeCurrency, + price: 0.003, + }), }, }); + }, + ); + }); - let networkChangeListener: (state: any) => Promise; - const onNetworkStateChange = sinon.stub().callsFake((listener) => { - networkChangeListener = listener; - }); + it('does not fetch when disabled', async () => { + jest.useFakeTimers(); + const chainId = '0x1'; - const controller = new TokenRatesController( - { - chainId: toHex(1), - ticker: NetworksTicker.mainnet, - selectedAddress: defaultSelectedAddress, - onPreferencesStateChange: sinon.stub(), - onTokensStateChange: sinon.stub(), - onNetworkStateChange, + await withController( + { + options: { + disabled: true, }, - { - interval: 10, - nativeCurrency: 'ETH', + }, + async ({ controller, triggerTokensStateChange }) => { + jest.spyOn(controller, 'updateExchangeRates'); + + triggerTokensStateChange({ allTokens: { - [toHex(1)]: { + [chainId]: { [defaultSelectedAddress]: [ { - address: '0x02', - decimals: 18, - image: undefined, - symbol: 'bar', - isERC721: false, - }, - { - address: '0x03', - decimals: 18, - image: undefined, - symbol: 'bazz', - isERC721: false, + address: '0x0000000000000000000000000000000000000001', + decimals: 0, + symbol: 'TOK1', }, ], }, }, - }, - ); - - await controller.updateExchangeRates(); - - expect(controller.state.contractExchangeRates).toStrictEqual({ - '0x02': 0.001, - '0x03': 0.002, - }); + allDetectedTokens: {}, + allIgnoredTokens: {}, + }); - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - await networkChangeListener!({ - providerConfig: { chainId: toHex(2) }, - }); + jest.advanceTimersToNextTimer(); + await flushPromises(); - expect(controller.state.contractExchangeRates).toStrictEqual({}); - }); + expect(controller.updateExchangeRates).not.toHaveBeenCalled(); + }, + ); }); - }); - describe('PreferencesController::stateChange', () => { - describe('when polling is active', () => { - it('should update exchange rates when selected address changes', async () => { - sinon.useFakeTimers({ now: Date.now() }); - nock(COINGECKO_API) - .get(`${COINGECKO_ETH_PATH}`) - .query({ contract_addresses: '0x02,0x03', vs_currencies: 'eth' }) - .reply(200, { - '0x02': { - eth: 0.001, // token value in terms of ETH - }, - '0x03': { - eth: 0.002, - }, - }); - let preferencesStateChangeListener: (state: any) => Promise; - const onPreferencesStateChange = sinon.stub().callsFake((listener) => { - preferencesStateChangeListener = listener; - }); - const alternateSelectedAddress = - '0x0000000000000000000000000000000000000002'; - const controller = new TokenRatesController( - { - chainId: toHex(1), - ticker: NetworksTicker.mainnet, - selectedAddress: defaultSelectedAddress, - onPreferencesStateChange, - onTokensStateChange: sinon.stub(), - onNetworkStateChange: sinon.stub(), + it('does not fetch when isDeprecated returns true', async () => { + jest.useFakeTimers(); + const chainId = '0x1'; + + await withController( + { + options: { + isDeprecated: () => true, }, - { - interval: 10, + }, + async ({ controller, triggerTokensStateChange }) => { + jest.spyOn(controller, 'updateExchangeRates'); + + triggerTokensStateChange({ allTokens: { - [toHex(1)]: { - [alternateSelectedAddress]: [ - { address: '0x02', decimals: 0, symbol: '', aggregators: [] }, - { address: '0x03', decimals: 0, symbol: '', aggregators: [] }, + [chainId]: { + [defaultSelectedAddress]: [ + { + address: '0x0000000000000000000000000000000000000001', + decimals: 0, + symbol: 'TOK1', + }, ], }, }, - }, - ); - await controller.start(); - expect(controller.state.contractExchangeRates).toStrictEqual({}); + allDetectedTokens: {}, + allIgnoredTokens: {}, + }); - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - await preferencesStateChangeListener!({ - selectedAddress: alternateSelectedAddress, - }); + jest.advanceTimersToNextTimer(); + await flushPromises(); - expect(controller.state.contractExchangeRates).toStrictEqual({ - '0x02': 0.001, - '0x03': 0.002, - }); - }); + expect(controller.updateExchangeRates).not.toHaveBeenCalled(); + }, + ); + }); - it('should not update exchange rates when preferences state changes without selected address changing', async () => { - sinon.useFakeTimers({ now: Date.now() }); - nock(COINGECKO_API) - .get(`${COINGECKO_ETH_PATH}`) - .query({ contract_addresses: '0x02,0x03', vs_currencies: 'eth' }) - .reply(200, { - '0x02': { - eth: 0.001, // token value in terms of ETH - }, - '0x03': { - eth: 0.002, - }, - }); - const secondCall = nock(COINGECKO_API) - .get(`${COINGECKO_ETH_PATH}`) - .query({ contract_addresses: '0x02,0x03', vs_currencies: 'eth' }) - .reply(200, { - '0x02': { - eth: 0.002, // token value in terms of ETH - }, - '0x03': { - eth: 0.003, + it('does not include chains when tokens are not updated', async () => { + jest.useFakeTimers(); + const chainId = '0x1'; + + await withController( + { + mockTokensControllerState: { + allTokens: { + [chainId]: { + [defaultSelectedAddress]: [ + { + address: '0x0000000000000000000000000000000000000001', + decimals: 0, + symbol: 'TOK1', + }, + ], + }, }, - }); - let preferencesStateChangeListener: (state: any) => Promise; - const onPreferencesStateChange = sinon.stub().callsFake((listener) => { - preferencesStateChangeListener = listener; - }); - const controller = new TokenRatesController( - { - chainId: toHex(1), - ticker: NetworksTicker.mainnet, - selectedAddress: defaultSelectedAddress, - onPreferencesStateChange, - onTokensStateChange: sinon.stub(), - onNetworkStateChange: sinon.stub(), + allDetectedTokens: {}, + allIgnoredTokens: {}, }, - { - interval: 10, + }, + async ({ controller, triggerTokensStateChange }) => { + jest.spyOn(controller, 'updateExchangeRates'); + + triggerTokensStateChange({ allTokens: { - [toHex(1)]: { + [chainId]: { [defaultSelectedAddress]: [ - { address: '0x02', decimals: 0, symbol: '', aggregators: [] }, - { address: '0x03', decimals: 0, symbol: '', aggregators: [] }, + { + address: '0x0000000000000000000000000000000000000001', + decimals: 0, + symbol: 'TOK1', + }, ], }, }, - }, - ); - await controller.start(); - expect(controller.state.contractExchangeRates).toStrictEqual({ - '0x02': 0.001, - '0x03': 0.002, - }); + allDetectedTokens: {}, + allIgnoredTokens: {}, + }); - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - await preferencesStateChangeListener!({ - selectedAddress: defaultSelectedAddress, - exampleConfig: 'exampleValue', - }); + jest.advanceTimersToNextTimer(); + await flushPromises(); - expect(controller.state.contractExchangeRates).toStrictEqual({ - '0x02': 0.001, - '0x03': 0.002, - }); - expect(secondCall.isDone()).toBe(false); - }); + expect(controller.updateExchangeRates).toHaveBeenCalledWith([]); + }, + ); }); - describe('when polling is inactive', () => { - it('should not update exchange rates when selected address changes', async () => { - sinon.useFakeTimers({ now: Date.now() }); - let preferencesStateChangeListener: (state: any) => Promise; - const onPreferencesStateChange = sinon.stub().callsFake((listener) => { - preferencesStateChangeListener = listener; - }); - const alternateSelectedAddress = - '0x0000000000000000000000000000000000000002'; - const controller = new TokenRatesController( - { - chainId: toHex(1), - ticker: NetworksTicker.mainnet, - selectedAddress: defaultSelectedAddress, - onPreferencesStateChange, - onTokensStateChange: sinon.stub(), - onNetworkStateChange: sinon.stub(), + it('does not include chains with no network configuration', async () => { + jest.useFakeTimers(); + const chainId = '0x1'; + + await withController( + { + mockNetworkState: { + networkConfigurationsByChainId: {}, }, - { - interval: 10, + }, + async ({ controller, triggerTokensStateChange }) => { + jest.spyOn(controller, 'updateExchangeRates'); + + triggerTokensStateChange({ allTokens: { - [toHex(1)]: { - [alternateSelectedAddress]: [ - { address: '0x02', decimals: 0, symbol: '', aggregators: [] }, - { address: '0x03', decimals: 0, symbol: '', aggregators: [] }, + [chainId]: { + [defaultSelectedAddress]: [ + { + address: '0x0000000000000000000000000000000000000001', + decimals: 0, + symbol: 'TOK1', + }, ], }, }, - }, - ); - expect(controller.state.contractExchangeRates).toStrictEqual({}); + allDetectedTokens: {}, + allIgnoredTokens: {}, + }); - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - await preferencesStateChangeListener!({ - selectedAddress: alternateSelectedAddress, - }); + jest.advanceTimersToNextTimer(); + await flushPromises(); - expect(controller.state.contractExchangeRates).toStrictEqual({}); - }); + expect(controller.updateExchangeRates).toHaveBeenCalledWith([]); + }, + ); }); }); - describe('start', () => { - it('should poll and update rate in the right interval', async () => { - const clock = sinon.useFakeTimers({ now: Date.now() }); - const fetchSpy = jest - .spyOn(globalThis, 'fetch') - .mockImplementation(() => { - throw new Error('Network error'); - }); - const interval = 100; - const controller = new TokenRatesController( - { - chainId: toHex(1), - ticker: NetworksTicker.mainnet, - selectedAddress: defaultSelectedAddress, - onPreferencesStateChange: sinon.stub(), - onTokensStateChange: jest.fn(), - onNetworkStateChange: jest.fn(), - }, + describe('NetworkController:stateChange', () => { + it('remove state from deleted networks', async () => { + const chainId = '0x1'; + const nativeCurrency = 'ETH'; + + await withController( { - interval, - allTokens: { - [toHex(1)]: { - [defaultSelectedAddress]: [ - { address: 'bar', decimals: 0, symbol: '', aggregators: [] }, - ], + options: { + disabled: true, + state: { + marketData: { + [chainId]: { + '0x0000000000000000000000000000000000000000': { + currency: nativeCurrency, + price: 0.001, + } as unknown as MarketDataDetails, + }, + '0x2': { + '0x0000000000000000000000000000000000000000': { + currency: nativeCurrency, + price: 0.001, + } as unknown as MarketDataDetails, + }, + }, }, }, }, - ); - - await controller.start(); - expect(fetchSpy).toHaveBeenCalledTimes(1); + async ({ controller, triggerNetworkStateChange }) => { + jest.spyOn(controller, 'updateExchangeRates'); + + triggerNetworkStateChange( + { + ...getDefaultNetworkControllerState(), + networkConfigurationsByChainId: { + [chainId]: { + chainId, + nativeCurrency, + } as unknown as NetworkConfiguration, + }, + }, + [ + { + op: 'remove', + path: ['networkConfigurationsByChainId', chainId], + }, + ], + ); - await clock.tickAsync(interval); - expect(fetchSpy).toHaveBeenCalledTimes(2); + jest.advanceTimersToNextTimer(); + await flushPromises(); - await clock.tickAsync(interval); - expect(fetchSpy).toHaveBeenCalledTimes(3); + expect(controller.state.marketData).toStrictEqual({ + '0x2': { + '0x0000000000000000000000000000000000000000': { + currency: nativeCurrency, + price: 0.001, + } as unknown as MarketDataDetails, + }, + }); + }, + ); }); - }); - describe('stop', () => { - it('should stop polling', async () => { - const clock = sinon.useFakeTimers({ now: Date.now() }); - const fetchSpy = jest - .spyOn(globalThis, 'fetch') - .mockImplementation(() => { - throw new Error('Network error'); - }); - const interval = 100; - const controller = new TokenRatesController( - { - chainId: toHex(1), - ticker: NetworksTicker.mainnet, - selectedAddress: defaultSelectedAddress, - onPreferencesStateChange: sinon.stub(), - onTokensStateChange: jest.fn(), - onNetworkStateChange: jest.fn(), + it('clears marketData when isDeprecated returns true', async () => { + const chainId = '0x1'; + const nativeCurrency = 'ETH'; + const initialMarketData = { + [chainId]: { + '0x0000000000000000000000000000000000000000': { + currency: nativeCurrency, + price: 0.001, + } as unknown as MarketDataDetails, }, + }; + + await withController( { - interval, - allTokens: { - [toHex(1)]: { - [defaultSelectedAddress]: [ - { address: 'bar', decimals: 0, symbol: '', aggregators: [] }, - ], - }, + options: { + isDeprecated: () => true, + state: { marketData: initialMarketData }, }, }, - ); - - await controller.start(); - expect(fetchSpy).toHaveBeenCalledTimes(1); + async ({ controller, triggerNetworkStateChange }) => { + triggerNetworkStateChange( + { + ...getDefaultNetworkControllerState(), + networkConfigurationsByChainId: {}, + }, + [ + { + op: 'remove', + path: ['networkConfigurationsByChainId', chainId], + }, + ], + ); - controller.stop(); + await flushPromises(); - await clock.tickAsync(interval); - expect(fetchSpy).toHaveBeenCalledTimes(1); + expect(controller.state.marketData).toStrictEqual({}); + }, + ); }); }); - describe('updateExchangeRates', () => { - it('should not update rates if disabled', async () => { - const controller = new TokenRatesController( + describe('enable', () => { + it('enables events', async () => { + jest.useFakeTimers(); + + const chainId = '0x1'; + await withController( { - chainId: toHex(1), - ticker: NetworksTicker.mainnet, - selectedAddress: defaultSelectedAddress, - onPreferencesStateChange: sinon.stub(), - onTokensStateChange: sinon.stub(), - onNetworkStateChange: sinon.stub(), + options: { + disabled: true, + }, }, - { - interval: 10, + async ({ controller, triggerTokensStateChange }) => { + jest.spyOn(controller, 'updateExchangeRates'); + + controller.enable(); + + triggerTokensStateChange({ + allTokens: { + [chainId]: { + [defaultSelectedAddress]: [ + { + address: '0x0000000000000000000000000000000000000001', + decimals: 0, + symbol: 'TOK1', + }, + ], + }, + }, + allDetectedTokens: {}, + allIgnoredTokens: {}, + }); + + jest.advanceTimersToNextTimer(); + await flushPromises(); + + expect(controller.updateExchangeRates).toHaveBeenCalledWith([ + { + chainId, + nativeCurrency: 'ETH', + }, + ]); }, ); - controller.fetchExchangeRate = sinon.stub(); - controller.disabled = true; - await controller.updateExchangeRates(); - expect((controller.fetchExchangeRate as any).called).toBe(false); }); + }); - it('should update all rates', async () => { - nock(COINGECKO_API) - .get( - `${COINGECKO_ETH_PATH}?contract_addresses=0x89d24A6b4CcB1B6fAA2625fE562bDD9a23260359,${ADDRESS}&vs_currencies=eth`, - ) - .reply(200, { - '0x89d24a6b4ccb1b6faa2625fe562bdd9a23260359': { eth: 0.00561045 }, - }); - const tokenAddress = '0x89d24A6b4CcB1B6fAA2625fE562bDD9a23260359'; - const controller = new TokenRatesController( - { - chainId: toHex(1), - ticker: NetworksTicker.mainnet, - selectedAddress: defaultSelectedAddress, - onPreferencesStateChange: sinon.stub(), - onTokensStateChange: sinon.stub(), - onNetworkStateChange: sinon.stub(), - }, + describe('disable', () => { + it('disables events', async () => { + jest.useFakeTimers(); + + const chainId = '0x1'; + await withController( { - interval: 10, - allTokens: { - [toHex(1)]: { - [defaultSelectedAddress]: [ - { - address: tokenAddress, - decimals: 18, - symbol: 'DAI', - aggregators: [], - }, - { address: ADDRESS, decimals: 0, symbol: '', aggregators: [] }, - ], - }, + options: { + disabled: false, }, }, - ); + async ({ controller, triggerTokensStateChange }) => { + jest.spyOn(controller, 'updateExchangeRates'); - expect(controller.state.contractExchangeRates).toStrictEqual({}); - await controller.updateExchangeRates(); - expect(Object.keys(controller.state.contractExchangeRates)).toContain( - tokenAddress, - ); - expect( - controller.state.contractExchangeRates[tokenAddress], - ).toBeGreaterThan(0); - expect(Object.keys(controller.state.contractExchangeRates)).toContain( - ADDRESS, - ); - expect(controller.state.contractExchangeRates[ADDRESS]).toBe(0); - }); + controller.disable(); - it('should handle balance not found in API', async () => { - const controller = new TokenRatesController( - { - chainId: toHex(1), - ticker: NetworksTicker.mainnet, - selectedAddress: defaultSelectedAddress, - onPreferencesStateChange: sinon.stub(), - onTokensStateChange: sinon.stub(), - onNetworkStateChange: sinon.stub(), - }, - { - interval: 10, - allTokens: { - [toHex(1)]: { - [defaultSelectedAddress]: [ - { address: 'bar', decimals: 0, symbol: '', aggregators: [] }, - ], + triggerTokensStateChange({ + allTokens: { + [chainId]: { + [defaultSelectedAddress]: [ + { + address: '0x0000000000000000000000000000000000000001', + decimals: 0, + symbol: 'TOK1', + }, + ], + }, }, - }, - }, - ); - expect(controller.state.contractExchangeRates).toStrictEqual({}); - sinon.stub(controller, 'fetchExchangeRate').throws({ - error: 'Not Found', - message: 'Not Found', - }); - const mock = sinon.stub(controller, 'updateExchangeRates'); + allDetectedTokens: {}, + allIgnoredTokens: {}, + }); - await controller.updateExchangeRates(); + jest.advanceTimersToNextTimer(); + await flushPromises(); - expect(mock).not.toThrow(); + expect(controller.updateExchangeRates).not.toHaveBeenCalled(); + }, + ); }); + }); - it('should update exchange rates when native currency is not supported by coingecko', async () => { - nock(COINGECKO_API) - .get(`${COINGECKO_MATIC_PATH}`) - .query({ contract_addresses: '0x02,0x03', vs_currencies: 'eth' }) - .reply(200, { - '0x02': { - eth: 0.001, // token value in terms of ETH - }, - '0x03': { - eth: 0.002, + describe('resetState', () => { + it('resets the state to default state', async () => { + const initialState: TokenRatesControllerState = { + marketData: { + [ChainId.mainnet]: { + '0x02': { + currency: 'ETH', + priceChange1d: 0, + pricePercentChange1d: 0, + tokenAddress: '0x02', + allTimeHigh: 4000, + allTimeLow: 900, + circulatingSupply: 2000, + dilutedMarketCap: 100, + high1d: 200, + low1d: 100, + marketCap: 1000, + marketCapPercentChange1d: 100, + price: 0.001, + pricePercentChange14d: 100, + pricePercentChange1h: 1, + pricePercentChange1y: 200, + pricePercentChange200d: 300, + pricePercentChange30d: 200, + pricePercentChange7d: 100, + totalVolume: 100, + }, }, - }); - - nock('https://min-api.cryptocompare.com') - .get('/data/price?fsym=ETH&tsyms=MATIC') - .reply(200, { MATIC: 0.5 }); // .5 eth to 1 matic - - const expectedExchangeRates = { - '0x02': 0.0005, // token value in terms of matic = (token value in eth) * (eth value in matic) = .001 * .5 - '0x03': 0.001, + }, }; - const onNetworkStateChange = sinon.stub(); - const controller = new TokenRatesController( + await withController( { - chainId: toHex(137), - ticker: 'MATIC', - selectedAddress: defaultSelectedAddress, - onPreferencesStateChange: sinon.stub(), - onTokensStateChange: sinon.stub(), - onNetworkStateChange, - }, - { - interval: 10, - allTokens: { - [toHex(137)]: { - [defaultSelectedAddress]: [ - { - address: '0x02', - decimals: 18, - image: undefined, - symbol: 'bar', - isERC721: false, - }, - { - address: '0x03', - decimals: 18, - image: undefined, - symbol: 'bazz', - isERC721: false, - }, - ], - }, + options: { + state: initialState, }, }, - ); + ({ controller }) => { + expect(controller.state).toStrictEqual(initialState); - await controller.updateExchangeRates(); + controller.resetState(); - expect(controller.state.contractExchangeRates).toStrictEqual( - expectedExchangeRates, + expect(controller.state).toStrictEqual({ + marketData: {}, + }); + }, ); }); }); + + 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(`{}`); + }); + }); + + it('persists expected state', async () => { + await withController(({ controller }) => { + expect( + deriveStateFromMetadata( + controller.state, + controller.metadata, + 'persist', + ), + ).toMatchInlineSnapshot(` + { + "marketData": {}, + } + `); + }); + }); + + it('exposes expected state to UI', async () => { + await withController(({ controller }) => { + expect( + deriveStateFromMetadata( + controller.state, + controller.metadata, + 'usedInUi', + ), + ).toMatchInlineSnapshot(` + { + "marketData": {}, + } + `); + }); + }); + }); }); +/** + * A callback for the `withController` helper function. + * + * @param args - The arguments. + * @param args.controller - The controller that the test helper created. + * @param args.controllerEvents - A collection of methods for dispatching mock + * events from external controllers. + */ +type WithControllerCallback = ({ + controller, + triggerTokensStateChange, + triggerNetworkStateChange, +}: { + controller: TokenRatesController; + triggerTokensStateChange: (state: TokensControllerState) => void; + triggerNetworkStateChange: (state: NetworkState, patches?: Patch[]) => void; +}) => Promise | ReturnValue; + +type WithControllerOptions = { + options?: Partial[0]>; + mockNetworkClientConfigurationsByNetworkClientId?: Record< + NetworkClientId, + NetworkClientConfiguration + >; + mockTokensControllerState?: Partial; + mockNetworkState?: Partial; +}; + +type WithControllerArgs = + | [WithControllerCallback] + | [WithControllerOptions, WithControllerCallback]; + +/** + * Builds a controller based on the given options, and calls the given function + * with that controller. + * + * @param args - Either a function, or an options bag + a function. The options + * bag is equivalent to the controller options; the function will be called + * with the built controller. + * @returns Whatever the callback returns. + */ +async function withController( + ...args: WithControllerArgs +): Promise { + const [{ ...rest }, fn] = args.length === 2 ? args : [{}, args[0]]; + const { options, mockTokensControllerState, mockNetworkState } = rest; + const messenger: RootMessenger = new Messenger({ + namespace: MOCK_ANY_NAMESPACE, + }); + + const mockTokensState = jest.fn(); + messenger.registerActionHandler( + 'TokensController:getState', + mockTokensState.mockReturnValue({ + ...getDefaultTokensState(), + ...mockTokensControllerState, + }), + ); + + const networkStateMock = jest.fn(); + messenger.registerActionHandler( + 'NetworkController:getState', + networkStateMock.mockReturnValue({ + ...getDefaultNetworkControllerState(), + ...mockNetworkState, + }), + ); + + // Register NetworkEnablementController:getState handler + messenger.registerActionHandler( + 'NetworkEnablementController:getState', + jest.fn().mockReturnValue({ + enabledNetworkMap: {}, + nativeAssetIdentifiers: { + 'eip155:1': 'eip155:1/slip44:60', + 'eip155:137': 'eip155:137/slip44:966', + }, + }), + ); + + const controller = new TokenRatesController({ + tokenPricesService: buildMockTokenPricesService(), + messenger: buildTokenRatesControllerMessenger(messenger), + ...options, + }); + try { + return await fn({ + controller, + triggerTokensStateChange: (state: TokensControllerState) => { + messenger.publish('TokensController:stateChange', state, []); + }, + triggerNetworkStateChange: ( + state: NetworkState, + patches: Patch[] = [], + ) => { + messenger.publish('NetworkController:stateChange', state, patches); + }, + }); + } finally { + controller.stopAllPolling(); + } +} + +/** + * Builds a mock token prices service. + * + * @param overrides - The properties of the token prices service you want to + * provide explicitly. + * @returns The built mock token prices service. + */ +function buildMockTokenPricesService( + overrides: Partial = {}, +): AbstractTokenPricesService { + return { + async fetchTokenPrices() { + return []; + }, + async fetchExchangeRates() { + return {}; + }, + validateChainIdSupported(_chainId: unknown): _chainId is Hex { + return true; + }, + validateCurrencySupported(_currency: unknown): _currency is string { + return true; + }, + setNativeAssetIdentifiers: jest.fn(), + ...overrides, + }; +} + +/** + * A version of the token prices service `fetchTokenPrices` method where the + * price of each given token is incremented by one. + * + * @param args - The arguments to this function. + * @param args.assets - The token addresses and chainIds. + * @param args.currency - The currency. + * @returns The token prices. + */ +async function fetchTokenPricesWithIncreasingPriceForEachToken< + Currency extends string, +>({ + assets, + currency, +}: { + assets: { tokenAddress: Hex; chainId: Hex }[]; + currency: Currency; +}): Promise[]> { + return assets.map(({ tokenAddress, chainId }, i) => ({ + tokenAddress, + chainId, + assetId: `${KnownCaipNamespace.Eip155}:1/${ + tokenAddress === ZERO_ADDRESS + ? 'slip44:60' + : `erc20:${tokenAddress.toLowerCase()}` + }` as CaipAssetType, + currency, + pricePercentChange1d: 0, + priceChange1d: 0, + allTimeHigh: 4000, + allTimeLow: 900, + circulatingSupply: 2000, + dilutedMarketCap: 100, + high1d: 200, + low1d: 100, + marketCap: 1000, + marketCapPercentChange1d: 100, + price: (i + 1) / 1000, + pricePercentChange14d: 100, + pricePercentChange1h: 1, + pricePercentChange1y: 200, + pricePercentChange200d: 300, + pricePercentChange30d: 200, + pricePercentChange7d: 100, + totalVolume: 100, + })); +} + +/** + * Constructs a checksum Ethereum address. + * + * @param number - The address as a decimal number. + * @returns The address as an 0x-prefixed ERC-55 mixed-case checksum address in + * hexadecimal format. + */ +function buildAddress(number: number) { + return toChecksumHexAddress(add0x(number.toString(16).padStart(40, '0'))); +} + +/** + * Constructs an object that satisfies the Token interface, filling in missing + * properties with defaults. This makes it possible to only specify properties + * that the test cares about. + * + * @param overrides - The properties that should be assigned to the new token. + * @returns The constructed token. + */ +function buildToken(overrides: Partial = {}) { + return { + address: buildAddress(1), + decimals: 0, + symbol: '', + aggregators: [], + ...overrides, + }; +} diff --git a/packages/assets-controllers/src/TokenRatesController.ts b/packages/assets-controllers/src/TokenRatesController.ts index 30fc3d3b950..a27105de668 100644 --- a/packages/assets-controllers/src/TokenRatesController.ts +++ b/packages/assets-controllers/src/TokenRatesController.ts @@ -1,482 +1,673 @@ -import type { BaseConfig, BaseState } from '@metamask/base-controller'; -import { BaseController } from '@metamask/base-controller'; -import { - safelyExecute, - handleFetch, - toChecksumHexAddress, - FALL_BACK_VS_CURRENCY, - toHex, -} from '@metamask/controller-utils'; -import type { NetworkState } from '@metamask/network-controller'; -import type { PreferencesState } from '@metamask/preferences-controller'; +import type { + ControllerGetStateAction, + ControllerStateChangeEvent, + StateMetadata, +} from '@metamask/base-controller'; +import { toChecksumHexAddress } from '@metamask/controller-utils'; +import type { Messenger } from '@metamask/messenger'; +import type { + NetworkControllerGetStateAction, + NetworkControllerStateChangeEvent, +} from '@metamask/network-controller'; +import type { NetworkEnablementControllerGetStateAction } from '@metamask/network-enablement-controller'; +import { StaticIntervalPollingController } from '@metamask/polling-controller'; import type { Hex } from '@metamask/utils'; +import { isEqual } from 'lodash'; -import { fetchExchangeRate as fetchNativeExchangeRate } from './crypto-compare'; -import type { TokensState } from './TokensController'; - -/** - * @type CoinGeckoResponse - * - * CoinGecko API response representation - */ -export interface CoinGeckoResponse { - [address: string]: { - [currency: string]: number; - }; -} -/** - * @type CoinGeckoPlatform - * - * CoinGecko supported platform API representation - */ -export interface CoinGeckoPlatform { - id: string; - chain_identifier: null | number; - name: string; - shortname: string; -} +import { + reduceInBatchesSerially, + TOKEN_PRICES_BATCH_SIZE, +} from './assetsUtil.js'; +import type { AbstractTokenPricesService } from './token-prices-service/abstract-token-prices-service.js'; +import { getNativeTokenAddress } from './token-prices-service/codefi-v2.js'; +import { TokenRwaData } from './token-service.js'; +import type { + TokensControllerGetStateAction, + TokensControllerStateChangeEvent, + TokensControllerState, +} from './TokensController.js'; /** * @type Token * * Token representation + * * @property address - Hex address of the token contract * @property decimals - Number of decimals the token uses * @property symbol - Symbol of the token + * @property aggregators - An array containing the token's aggregators * @property image - Image of the token, url or bit32 image + * @property hasBalanceError - 'true' if there is an error while updating the token balance + * @property isERC721 - 'true' if the token is a ERC721 token + * @property name - Name of the token */ -export interface Token { +export type Token = { address: string; decimals: number; symbol: string; aggregators?: string[]; image?: string; - balanceError?: unknown; + hasBalanceError?: boolean; isERC721?: boolean; name?: string; -} + rwaData?: TokenRwaData; +}; + +const DEFAULT_INTERVAL = 180000; + +export type ContractExchangeRates = { + [address: string]: number | undefined; +}; + +export type MarketDataDetails = { + tokenAddress: `0x${string}`; + currency: string; + allTimeHigh: number; + allTimeLow: number; + circulatingSupply: number; + dilutedMarketCap: number; + high1d: number; + low1d: number; + marketCap: number; + marketCapPercentChange1d: number; + price: number; + priceChange1d: number; + pricePercentChange1d: number; + pricePercentChange1h: number; + pricePercentChange1y: number; + pricePercentChange7d: number; + pricePercentChange14d: number; + pricePercentChange30d: number; + pricePercentChange200d: number; + totalVolume: number; +}; /** - * @type TokenRatesConfig - * - * Token rates controller configuration - * @property interval - Polling interval used to fetch new token rates - * @property nativeCurrency - Current native currency selected to use base of rates - * @property chainId - Current network chainId - * @property tokens - List of tokens to track exchange rates for - * @property threshold - Threshold to invalidate the supportedChains + * Represents a mapping of token contract addresses to their market data. */ -export interface TokenRatesConfig extends BaseConfig { - interval: number; - nativeCurrency: string; - chainId: Hex; - selectedAddress: string; - allTokens: { [chainId: Hex]: { [key: string]: Token[] } }; - allDetectedTokens: { [chainId: Hex]: { [key: string]: Token[] } }; - threshold: number; -} +export type ContractMarketData = Record; -interface ContractExchangeRates { - [address: string]: number | undefined; -} +type ChainIdAndNativeCurrency = { + chainId: Hex; + nativeCurrency: string; +}; -interface SupportedChainsCache { - timestamp: number; - data: CoinGeckoPlatform[] | null; -} +/** + * The external actions available to the {@link TokenRatesController}. + */ +export type AllowedActions = + | TokensControllerGetStateAction + | NetworkControllerGetStateAction + | NetworkEnablementControllerGetStateAction; -interface SupportedVsCurrenciesCache { - timestamp: number; - data: string[]; -} +/** + * The external events available to the {@link TokenRatesController}. + */ +export type AllowedEvents = + | TokensControllerStateChangeEvent + | NetworkControllerStateChangeEvent; -enum PollState { - Active = 'Active', - Inactive = 'Inactive', -} +/** + * The name of the {@link TokenRatesController}. + */ +export const controllerName = 'TokenRatesController'; /** * @type TokenRatesState * * Token rates controller state - * @property contractExchangeRates - Hash of token contract addresses to exchange rates - * @property supportedChains - Cached chain data + * + * @property marketData - Market data for tokens, keyed by chain ID and then token contract address. */ -export interface TokenRatesState extends BaseState { - contractExchangeRates: ContractExchangeRates; -} +export type TokenRatesControllerState = { + marketData: Record>; +}; -const CoinGeckoApi = { - BASE_URL: 'https://api.coingecko.com/api/v3', - getTokenPriceURL(chainSlug: string, query: string) { - return `${this.BASE_URL}/simple/token_price/${chainSlug}?${query}`; - }, - getPlatformsURL() { - return `${this.BASE_URL}/asset_platforms`; - }, - getSupportedVsCurrencies() { - return `${this.BASE_URL}/simple/supported_vs_currencies`; +/** + * The action that can be performed to get the state of the {@link TokenRatesController}. + */ +export type TokenRatesControllerGetStateAction = ControllerGetStateAction< + typeof controllerName, + TokenRatesControllerState +>; + +/** + * The actions that can be performed using the {@link TokenRatesController}. + */ +export type TokenRatesControllerActions = TokenRatesControllerGetStateAction; + +/** + * The event that {@link TokenRatesController} can emit. + */ +export type TokenRatesControllerStateChangeEvent = ControllerStateChangeEvent< + typeof controllerName, + TokenRatesControllerState +>; + +/** + * The events that {@link TokenRatesController} can emit. + */ +export type TokenRatesControllerEvents = TokenRatesControllerStateChangeEvent; + +/** + * The messenger of the {@link TokenRatesController} for communication. + */ +export type TokenRatesControllerMessenger = Messenger< + typeof controllerName, + TokenRatesControllerActions | AllowedActions, + TokenRatesControllerEvents | AllowedEvents +>; + +const tokenRatesControllerMetadata: StateMetadata = { + marketData: { + includeInStateLogs: false, + persist: true, + includeInDebugSnapshot: false, + usedInUi: true, }, }; /** - * Finds the chain slug in the data array given a chainId. + * Get the default {@link TokenRatesController} state. * - * @param chainId - The current chain ID. - * @param data - A list platforms supported by the CoinGecko API. - * @returns The CoinGecko slug for the given chain ID, or `null` if the slug was not found. + * @returns The default {@link TokenRatesController} state. */ -function findChainSlug( - chainId: Hex, - data: CoinGeckoPlatform[] | null, -): string | null { - if (!data) { - return null; - } - const chain = - data.find( - ({ chain_identifier }) => - chain_identifier !== null && toHex(chain_identifier) === chainId, - ) ?? null; - return chain?.id || null; -} +export const getDefaultTokenRatesControllerState = + (): TokenRatesControllerState => { + return { + marketData: {}, + }; + }; + +/** The input to start polling for the {@link TokenRatesController} */ +export type TokenRatesPollingInput = { + chainIds: Hex[]; +}; /** * Controller that passively polls on a set interval for token-to-fiat exchange rates * for tokens stored in the TokensController */ -export class TokenRatesController extends BaseController< - TokenRatesConfig, - TokenRatesState +export class TokenRatesController extends StaticIntervalPollingController()< + typeof controllerName, + TokenRatesControllerState, + TokenRatesControllerMessenger > { - private handle?: ReturnType; - - private tokenList: Token[] = []; + readonly #tokenPricesService: AbstractTokenPricesService; - private supportedChains: SupportedChainsCache = { - timestamp: 0, - data: null, - }; + #disabled: boolean; - private supportedVsCurrencies: SupportedVsCurrenciesCache = { - timestamp: 0, - data: [], - }; + readonly #isDeprecated: () => boolean; - #pollState = PollState.Inactive; + #allTokens: TokensControllerState['allTokens']; - /** - * Name of this controller used during composition - */ - override name = 'TokenRatesController'; + #allDetectedTokens: TokensControllerState['allDetectedTokens']; /** * Creates a TokenRatesController instance. * * @param options - The controller options. - * @param options.chainId - The chain ID of the current network. - * @param options.ticker - The ticker for the current network. - * @param options.selectedAddress - The current selected address. - * @param options.onPreferencesStateChange - Allows subscribing to preference controller state changes. - * @param options.onTokensStateChange - Allows subscribing to token controller state changes. - * @param options.onNetworkStateChange - Allows subscribing to network state changes. - * @param config - Initial options used to configure this controller. - * @param state - Initial state to set on this controller. + * @param options.interval - The polling interval in ms + * @param options.disabled - Boolean to track if network requests are blocked + * @param options.isDeprecated - Optional function that returns true to completely + * disable this controller (no requests, no state updates). When it returns + * `true`, `marketData` is reset to `{}` at construction and at every polling + * entry point, so no stale rates remain in state. The function is evaluated + * dynamically on each entry point so it can be toggled at runtime. Intended for + * use when a higher-level controller (e.g. AssetsController) supersedes this one. + * @param options.tokenPricesService - An object in charge of retrieving token price + * @param options.messenger - The messenger instance for communication + * @param options.state - Initial state to set on this controller */ - constructor( - { - chainId: initialChainId, - ticker: initialTicker, - selectedAddress: initialSelectedAddress, - onPreferencesStateChange, - onTokensStateChange, - onNetworkStateChange, - }: { - chainId: Hex; - ticker: string; - selectedAddress: string; - onPreferencesStateChange: ( - listener: (preferencesState: PreferencesState) => void, - ) => void; - onTokensStateChange: ( - listener: (tokensState: TokensState) => void, - ) => void; - onNetworkStateChange: ( - listener: (networkState: NetworkState) => void, - ) => void; - }, - config?: Partial, - state?: Partial, - ) { - super(config, state); - this.defaultConfig = { - disabled: false, - interval: 3 * 60 * 1000, - nativeCurrency: initialTicker, - chainId: initialChainId, - selectedAddress: initialSelectedAddress, - allTokens: {}, // TODO: initialize these correctly, maybe as part of BaseControllerV2 migration - allDetectedTokens: {}, - threshold: 6 * 60 * 60 * 1000, - }; + constructor({ + interval = DEFAULT_INTERVAL, + disabled = false, + isDeprecated = (): boolean => false, + tokenPricesService, + messenger, + state, + }: { + interval?: number; + disabled?: boolean; + isDeprecated?: () => boolean; + tokenPricesService: AbstractTokenPricesService; + messenger: TokenRatesControllerMessenger; + state?: Partial; + }) { + super({ + name: controllerName, + messenger, + state: { ...getDefaultTokenRatesControllerState(), ...state }, + metadata: tokenRatesControllerMetadata, + }); - this.defaultState = { - contractExchangeRates: {}, - }; - this.initialize(); - if (config?.disabled) { - this.configure({ disabled: true }, false, false); + this.setIntervalLength(interval); + this.#tokenPricesService = tokenPricesService; + this.#disabled = disabled; + this.#isDeprecated = isDeprecated; + + if (this.#isDeprecated()) { + this.#enforceDisabledState(); } - this.#updateTokenList(); - - onPreferencesStateChange(async ({ selectedAddress }) => { - if (this.config.selectedAddress !== selectedAddress) { - this.configure({ selectedAddress }); - this.#updateTokenList(); - if (this.#pollState === PollState.Active) { - await this.updateExchangeRates(); - } - } - }); - onTokensStateChange(async ({ allTokens, allDetectedTokens }) => { - // These two state properties are assumed to be immutable - if ( - this.config.allTokens !== allTokens || - this.config.allDetectedTokens !== allDetectedTokens - ) { - this.configure({ allTokens, allDetectedTokens }); - this.#updateTokenList(); - if (this.#pollState === PollState.Active) { - await this.updateExchangeRates(); - } - } - }); + const { allTokens, allDetectedTokens } = this.#getTokensControllerState(); + this.#allTokens = allTokens; + this.#allDetectedTokens = allDetectedTokens; - onNetworkStateChange(async ({ providerConfig }) => { - const { chainId, ticker } = providerConfig; - if ( - this.config.chainId !== chainId || - this.config.nativeCurrency !== ticker - ) { - this.update({ contractExchangeRates: {} }); - this.configure({ chainId, nativeCurrency: ticker }); - this.#updateTokenList(); - if (this.#pollState === PollState.Active) { - await this.updateExchangeRates(); - } - } - }); - } + // Set native asset identifiers from NetworkEnablementController for CAIP-19 native token lookups + this.#initNativeAssetIdentifiers(); + + this.#subscribeToTokensStateChange(); - #updateTokenList() { - const { allTokens, allDetectedTokens } = this.config; - const tokens = - allTokens[this.config.chainId]?.[this.config.selectedAddress] || []; - const detectedTokens = - allDetectedTokens[this.config.chainId]?.[this.config.selectedAddress] || - []; - this.tokenList = [...tokens, ...detectedTokens]; + this.#subscribeToNetworkStateChange(); } /** - * Start (or restart) polling. + * Clears all persisted `marketData` so that no stale rates remain in state. + * + * Called from every polling entry point when `isDeprecated()` is true so that + * a runtime toggle propagates to state immediately, even if the controller was + * originally constructed while it was enabled. The update is skipped when + * `marketData` is already empty to avoid emitting redundant state changes. */ - async start() { - this.#stopPoll(); - this.#pollState = PollState.Active; - await this.#poll(); + #enforceDisabledState(): void { + if (Object.keys(this.state.marketData).length === 0) { + return; + } + this.update((state) => { + state.marketData = {}; + }); } - /** - * Stop polling. - */ - stop() { - this.#stopPoll(); - this.#pollState = PollState.Inactive; + #subscribeToTokensStateChange() { + this.messenger.subscribe( + 'TokensController:stateChange', + // TODO: Either fix this lint violation or explain why it's necessary to ignore. + // eslint-disable-next-line @typescript-eslint/no-misused-promises + async ({ allTokens, allDetectedTokens }) => { + if (this.#isDeprecated()) { + this.#enforceDisabledState(); + return; + } + if (this.#disabled) { + return; + } + + const { networkConfigurationsByChainId } = this.messenger.call( + 'NetworkController:getState', + ); + + const chainIds = [ + ...new Set([ + ...Object.keys(allTokens), + ...Object.keys(allDetectedTokens), + ]), + ] as Hex[]; + + const chainIdsToUpdate = chainIds.filter( + (chainId) => + !isEqual(this.#allTokens[chainId], allTokens[chainId]) || + !isEqual( + this.#allDetectedTokens[chainId], + allDetectedTokens[chainId], + ), + ); + + this.#allTokens = allTokens; + this.#allDetectedTokens = allDetectedTokens; + + const chainIdAndNativeCurrency = chainIdsToUpdate.reduce< + { chainId: Hex; nativeCurrency: string }[] + >((acc, chainId) => { + const networkConfiguration = networkConfigurationsByChainId[chainId]; + if (!networkConfiguration) { + console.error( + `TokenRatesController: No network configuration found for chainId ${chainId}`, + ); + return acc; + } + acc.push({ + chainId, + nativeCurrency: networkConfiguration.nativeCurrency, + }); + return acc; + }, []); + + await this.updateExchangeRates(chainIdAndNativeCurrency); + }, + ({ allTokens, allDetectedTokens }) => { + return { allTokens, allDetectedTokens }; + }, + ); + } + + #subscribeToNetworkStateChange() { + this.messenger.subscribe( + 'NetworkController:stateChange', + (_state, patches) => { + if (this.#isDeprecated()) { + this.#enforceDisabledState(); + return; + } + // Remove state for deleted networks + for (const patch of patches) { + if ( + patch.op === 'remove' && + patch.path[0] === 'networkConfigurationsByChainId' + ) { + const removedChainId = patch.path[1] as Hex; + this.update((state) => { + delete state.marketData[removedChainId]; + }); + } + } + }, + ); } /** - * Clear the active polling timer, if present. + * Initialize the native asset identifiers from NetworkEnablementController. + * This provides CAIP-19 native asset IDs for the token prices service. */ - #stopPoll() { - if (this.handle) { - clearTimeout(this.handle); + #initNativeAssetIdentifiers(): void { + if (this.#tokenPricesService.setNativeAssetIdentifiers) { + const { nativeAssetIdentifiers } = this.messenger.call( + 'NetworkEnablementController:getState', + ); + this.#tokenPricesService.setNativeAssetIdentifiers( + nativeAssetIdentifiers, + ); } } /** - * Poll for exchange rate updates. + * Get the tokens for the given chain. + * + * @param chainId - The chain ID. + * @returns The list of tokens addresses for the current chain */ - async #poll() { - await safelyExecute(() => this.updateExchangeRates()); - - // Poll using recursive `setTimeout` instead of `setInterval` so that - // requests don't stack if they take longer than the polling interval - this.handle = setTimeout(() => { - this.#poll(); - }, this.config.interval); + #getTokenAddresses(chainId: Hex): Hex[] { + const getTokens = (allTokens: Record) => + Object.values(allTokens ?? {}).flatMap((tokens) => + tokens.map(({ address }) => toChecksumHexAddress(address) as Hex), + ); + + const tokenAddresses = getTokens(this.#allTokens[chainId]); + const detectedTokenAddresses = getTokens(this.#allDetectedTokens[chainId]); + + return [ + ...new Set([ + ...tokenAddresses, + ...detectedTokenAddresses, + getNativeTokenAddress(chainId), + ]), + ].sort(); } /** - * Fetches a pairs of token address and native currency. - * - * @param chainSlug - Chain string identifier. - * @param vsCurrency - Query according to tokens in tokenList and native currency. - * @returns The exchange rates for the given pairs. + * Allows controller to make active and passive polling requests */ - async fetchExchangeRate( - chainSlug: string, - vsCurrency: string, - ): Promise { - const tokenPairs = this.tokenList.map((token) => token.address).join(','); - const query = `contract_addresses=${tokenPairs}&vs_currencies=${vsCurrency.toLowerCase()}`; - return handleFetch(CoinGeckoApi.getTokenPriceURL(chainSlug, query)); + enable(): void { + this.#disabled = false; } /** - * Checks if the current native currency is a supported vs currency to use - * to query for token exchange rates. - * - * @param nativeCurrency - The native currency of the currently active network. - * @returns A boolean indicating whether it's a supported vsCurrency. + * Blocks controller from making network calls */ - private async checkIsSupportedVsCurrency(nativeCurrency: string) { - const { threshold } = this.config; - const { timestamp, data } = this.supportedVsCurrencies; - - const now = Date.now(); + disable(): void { + this.#disabled = true; + } - if (now - timestamp > threshold) { - const currencies = await handleFetch( - CoinGeckoApi.getSupportedVsCurrencies(), - ); - this.supportedVsCurrencies = { - data: currencies, - timestamp: Date.now(), - }; - return currencies.includes(nativeCurrency.toLowerCase()); - } + #getTokensControllerState(): { + allTokens: TokensControllerState['allTokens']; + allDetectedTokens: TokensControllerState['allDetectedTokens']; + } { + const { allTokens, allDetectedTokens } = this.messenger.call( + 'TokensController:getState', + ); - return data.includes(nativeCurrency.toLowerCase()); + return { + allTokens, + allDetectedTokens, + }; } /** - * Gets current chain ID slug from cached supported platforms CoinGecko API response. - * If cached supported platforms response is stale, fetches and updates it. + * Updates exchange rates for all tokens. * - * @returns The CoinGecko slug for the current chain ID. + * @param chainIdAndNativeCurrency - The chain ID and native currency. */ - async getChainSlug(): Promise { - const { threshold, chainId } = this.config; - const { data, timestamp } = this.supportedChains; - - const now = Date.now(); - - if (now - timestamp > threshold) { - const platforms = await handleFetch(CoinGeckoApi.getPlatformsURL()); - this.supportedChains = { - data: platforms, - timestamp: Date.now(), - }; - return findChainSlug(chainId, platforms); + async updateExchangeRates( + chainIdAndNativeCurrency: ChainIdAndNativeCurrency[], + ): Promise { + if (this.#isDeprecated()) { + this.#enforceDisabledState(); + return; + } + if (this.#disabled) { + return; } - return findChainSlug(chainId, data); - } + const marketData: Record> = {}; + const assetsByNativeCurrency: Record< + string, + { + chainId: Hex; + tokenAddress: Hex; + }[] + > = {}; + const unsupportedAssetsByNativeCurrency: Record< + string, + { + chainId: Hex; + tokenAddress: Hex; + }[] + > = {}; + for (const { chainId, nativeCurrency } of chainIdAndNativeCurrency) { + if (this.#tokenPricesService.validateChainIdSupported(chainId)) { + for (const tokenAddress of this.#getTokenAddresses(chainId)) { + if ( + this.#tokenPricesService.validateCurrencySupported(nativeCurrency) + ) { + (assetsByNativeCurrency[nativeCurrency] ??= []).push({ + chainId, + tokenAddress, + }); + } else { + (unsupportedAssetsByNativeCurrency[nativeCurrency] ??= []).push({ + chainId, + tokenAddress, + }); + } + } + } + } - /** - * Updates exchange rates for all tokens. - */ - async updateExchangeRates() { - if (this.tokenList.length === 0 || this.disabled) { - return; + const promises = [ + ...Object.entries(assetsByNativeCurrency).map( + ([nativeCurrency, assets]) => + this.#fetchAndMapExchangeRatesForSupportedNativeCurrency( + assets, + nativeCurrency, + marketData, + ), + ), + ...Object.entries(unsupportedAssetsByNativeCurrency).map( + ([nativeCurrency, assets]) => + this.#fetchAndMapExchangeRatesForUnsupportedNativeCurrency( + assets, + nativeCurrency, + marketData, + ), + ), + ]; + + await Promise.allSettled(promises); + + const chainIds = new Set( + Object.values(chainIdAndNativeCurrency).map((chain) => chain.chainId), + ); + + for (const chainId of chainIds) { + if (!marketData[chainId]) { + marketData[chainId] = {}; + } } - const slug = await this.getChainSlug(); - let newContractExchangeRates: ContractExchangeRates = {}; - if (!slug) { - this.tokenList.forEach((token) => { - const address = toChecksumHexAddress(token.address); - newContractExchangeRates[address] = undefined; + if (Object.keys(marketData).length > 0) { + this.update((state) => { + state.marketData = { + ...state.marketData, + ...marketData, + }; }); - } else { - const { nativeCurrency } = this.config; - newContractExchangeRates = await this.fetchAndMapExchangeRates( - nativeCurrency, - slug, + } + } + + async #fetchAndMapExchangeRatesForSupportedNativeCurrency( + assets: { + chainId: Hex; + tokenAddress: Hex; + }[], + currency: string, + marketData: Record> = {}, + ) { + return await reduceInBatchesSerially< + { chainId: Hex; tokenAddress: Hex }, + Record> + >({ + values: assets, + batchSize: TOKEN_PRICES_BATCH_SIZE, + eachBatch: async (partialMarketData, assetsBatch) => { + const batchMarketData = await this.#tokenPricesService.fetchTokenPrices( + { + assets: assetsBatch, + currency, + }, + ); + + for (const tokenPrice of batchMarketData) { + (partialMarketData[tokenPrice.chainId] ??= {})[ + tokenPrice.tokenAddress + ] = tokenPrice; + } + + return partialMarketData; + }, + initialResult: marketData, + }); + } + + async #fetchAndMapExchangeRatesForUnsupportedNativeCurrency( + assets: { + chainId: Hex; + tokenAddress: Hex; + }[], + currency: string, + marketData: Record>, + ) { + // Step -1: Then fetch all tracked tokens priced in USD + const marketDataInUSD = + await this.#fetchAndMapExchangeRatesForSupportedNativeCurrency( + assets, + 'usd', // Fallback currency when the native currency is not supported ); + + // Formula: price_in_native = token_usd / native_usd + const convertUSDToNative = ( + valueInUSD: number, + nativeTokenPriceInUSD: number, + ) => valueInUSD / nativeTokenPriceInUSD; + + // Step -2: Convert USD prices to native currency + for (const [chainId, marketDataByTokenAddress] of Object.entries( + marketDataInUSD, + ) as [Hex, Record][]) { + const nativeTokenPriceInUSD = + marketDataByTokenAddress[getNativeTokenAddress(chainId)]?.price; + + // Return here if it's null, undefined or 0 + if (!nativeTokenPriceInUSD) { + continue; + } + + for (const [tokenAddress, tokenData] of Object.entries( + marketDataByTokenAddress, + ) as [Hex, MarketDataDetails][]) { + (marketData[chainId] ??= {})[tokenAddress] = { + ...tokenData, + currency, + price: convertUSDToNative(tokenData.price, nativeTokenPriceInUSD), + marketCap: convertUSDToNative( + tokenData.marketCap, + nativeTokenPriceInUSD, + ), + allTimeHigh: convertUSDToNative( + tokenData.allTimeHigh, + nativeTokenPriceInUSD, + ), + allTimeLow: convertUSDToNative( + tokenData.allTimeLow, + nativeTokenPriceInUSD, + ), + totalVolume: convertUSDToNative( + tokenData.totalVolume, + nativeTokenPriceInUSD, + ), + high1d: convertUSDToNative(tokenData.high1d, nativeTokenPriceInUSD), + low1d: convertUSDToNative(tokenData.low1d, nativeTokenPriceInUSD), + dilutedMarketCap: convertUSDToNative( + tokenData.dilutedMarketCap, + nativeTokenPriceInUSD, + ), + }; + } } - this.update({ contractExchangeRates: newContractExchangeRates }); } /** - * Checks if the active network's native currency is supported by the coingecko API. - * If supported, it fetches and maps contractExchange rates to a format to be consumed by the UI. - * If not supported, it fetches contractExchange rates and maps them from token/fallback-currency - * to token/nativeCurrency. + * Updates token rates for the given networkClientId * - * @param nativeCurrency - The native currency of the currently active network. - * @param slug - The unique slug used to id the chain by the coingecko api - * should be used to query token exchange rates. - * @returns An object with conversion rates for each token - * related to the network's native currency. + * @param input - The input for the poll. + * @param input.chainIds - The chain ids to poll token rates on. */ - async fetchAndMapExchangeRates( - nativeCurrency: string, - slug: string, - ): Promise { - const contractExchangeRates: ContractExchangeRates = {}; - - // check if native currency is supported as a vs_currency by the API - const nativeCurrencySupported = await this.checkIsSupportedVsCurrency( - nativeCurrency, + async _executePoll({ chainIds }: TokenRatesPollingInput): Promise { + if (this.#isDeprecated()) { + this.#enforceDisabledState(); + return; + } + + const { networkConfigurationsByChainId } = this.messenger.call( + 'NetworkController:getState', ); - if (nativeCurrencySupported) { - // If it is we can do a simple fetch against the CoinGecko API - const prices = await this.fetchExchangeRate(slug, nativeCurrency); - this.tokenList.forEach((token) => { - const price = prices[token.address.toLowerCase()]; - contractExchangeRates[toChecksumHexAddress(token.address)] = price - ? price[nativeCurrency.toLowerCase()] - : 0; - }); - } else { - // if native currency is not supported we need to use a fallback vsCurrency, get the exchange rates - // in token/fallback-currency format and convert them to expected token/nativeCurrency format. - let tokenExchangeRates; - let vsCurrencyToNativeCurrencyConversionRate = 0; - try { - [ - tokenExchangeRates, - { conversionRate: vsCurrencyToNativeCurrencyConversionRate }, - ] = await Promise.all([ - this.fetchExchangeRate(slug, FALL_BACK_VS_CURRENCY), - fetchNativeExchangeRate(nativeCurrency, FALL_BACK_VS_CURRENCY, false), - ]); - } catch (error) { - if ( - error instanceof Error && - error.message.includes('market does not exist for this coin pair') - ) { - return {}; - } - throw error; + const chainIdAndNativeCurrency = chainIds.reduce< + { chainId: Hex; nativeCurrency: string }[] + >((acc, chainId) => { + const networkConfiguration = networkConfigurationsByChainId[chainId]; + if (!networkConfiguration) { + console.error( + `TokenRatesController: No network configuration found for chainId ${chainId}`, + ); + return acc; } + acc.push({ + chainId, + nativeCurrency: networkConfiguration.nativeCurrency, + }); + return acc; + }, []); - for (const [tokenAddress, conversion] of Object.entries( - tokenExchangeRates, - )) { - const tokenToVsCurrencyConversionRate = - conversion[FALL_BACK_VS_CURRENCY.toLowerCase()]; - contractExchangeRates[toChecksumHexAddress(tokenAddress)] = - tokenToVsCurrencyConversionRate * - vsCurrencyToNativeCurrencyConversionRate; - } - } + await this.updateExchangeRates(chainIdAndNativeCurrency); + } - return contractExchangeRates; + /** + * Reset the controller state to the default state. + */ + resetState() { + this.update(() => { + return getDefaultTokenRatesControllerState(); + }); } } diff --git a/packages/assets-controllers/src/TokenSearchDiscoveryDataController/TokenSearchDiscoveryDataController.test.ts b/packages/assets-controllers/src/TokenSearchDiscoveryDataController/TokenSearchDiscoveryDataController.test.ts new file mode 100644 index 00000000000..eda72fd2383 --- /dev/null +++ b/packages/assets-controllers/src/TokenSearchDiscoveryDataController/TokenSearchDiscoveryDataController.test.ts @@ -0,0 +1,765 @@ +import { deriveStateFromMetadata } from '@metamask/base-controller'; +import { ChainId } from '@metamask/controller-utils'; +import { MOCK_ANY_NAMESPACE, Messenger } from '@metamask/messenger'; +import type { + MessengerActions, + MessengerEvents, + MockAnyNamespace, +} from '@metamask/messenger'; +import type { Hex } from '@metamask/utils'; +import assert from 'assert'; + +import type { + AbstractTokenPricesService, + EvmAssetWithMarketData, +} from '../token-prices-service/abstract-token-prices-service.js'; +import { fetchTokenMetadata } from '../token-service.js'; +import type { Token } from '../TokenRatesController.js'; +import { + getDefaultTokenSearchDiscoveryDataControllerState, + TokenSearchDiscoveryDataController, + controllerName, + MAX_TOKEN_DISPLAY_DATA_LENGTH, +} from './TokenSearchDiscoveryDataController.js'; +import type { + TokenSearchDiscoveryDataControllerMessenger, + TokenSearchDiscoveryDataControllerState, +} from './TokenSearchDiscoveryDataController.js'; +import type { + NotFoundTokenDisplayData, + FoundTokenDisplayData, +} from './types.js'; + +jest.mock('../token-service', () => { + const mockFetchTokenMetadata = jest.fn(); + return { + fetchTokenMetadata: mockFetchTokenMetadata, + TOKEN_METADATA_NO_SUPPORT_ERROR: 'Token metadata not supported', + }; +}); + +type AllActions = MessengerActions; + +type AllEvents = MessengerEvents; + +type RootMessenger = Messenger; + +/** + * Builds a not found token display data object. + * + * @param overrides - The overrides for the token display data. + * @returns The not found token display data. + */ +function buildNotFoundTokenDisplayData( + overrides: Partial = {}, +): NotFoundTokenDisplayData { + return { + found: false, + address: '0x000000000000000000000000000000000000dea1', + chainId: '0x1', + currency: 'USD', + ...overrides, + }; +} + +/** + * Builds a found token display data object. + * + * @param overrides - The overrides for the token display data. + * @returns The found token display data. + */ +function buildFoundTokenDisplayData( + overrides: Partial = {}, +): FoundTokenDisplayData { + const tokenAddress = '0x000000000000000000000000000000000000000f'; + + const tokenData: Token = { + address: tokenAddress, + decimals: 18, + symbol: 'TEST', + name: 'Test Token', + }; + + const priceData: EvmAssetWithMarketData = { + price: 10.5, + currency: 'USD', + tokenAddress: tokenAddress as Hex, + chainId: '0x1', + allTimeHigh: 20, + allTimeLow: 5, + circulatingSupply: 1000000, + dilutedMarketCap: 10000000, + high1d: 11, + low1d: 10, + marketCap: 10500000, + marketCapPercentChange1d: 2, + priceChange1d: 0.5, + pricePercentChange1d: 5, + pricePercentChange1h: 1, + pricePercentChange1y: 50, + pricePercentChange7d: 10, + pricePercentChange14d: 15, + pricePercentChange30d: 20, + pricePercentChange200d: 30, + totalVolume: 500000, + }; + + return { + found: true, + address: tokenAddress, + chainId: '0x1', + currency: 'USD', + token: tokenData, + price: priceData, + ...overrides, + }; +} + +/** + * Builds a messenger that `TokenSearchDiscoveryDataController` can use to communicate with other controllers. + * + * @param messenger - The main messenger. + * @returns The restricted messenger. + */ +function buildTokenSearchDiscoveryDataControllerMessenger( + messenger: RootMessenger = new Messenger({ namespace: MOCK_ANY_NAMESPACE }), +): TokenSearchDiscoveryDataControllerMessenger { + const tokenSearchDiscoveryDataControllerMessenger = new Messenger< + typeof controllerName, + AllActions, + AllEvents, + RootMessenger + >({ + namespace: controllerName, + }); + messenger.delegate({ + messenger: tokenSearchDiscoveryDataControllerMessenger, + actions: ['CurrencyRateController:getState'], + }); + return tokenSearchDiscoveryDataControllerMessenger; +} + +/** + * Builds a mock token prices service. + * + * @param overrides - The token prices service method overrides. + * @returns The mock token prices service. + */ +function buildMockTokenPricesService( + overrides: Partial = {}, +): AbstractTokenPricesService { + return { + async fetchExchangeRates() { + return {}; + }, + async fetchTokenPrices() { + return []; + }, + validateChainIdSupported(_chainId: unknown): _chainId is Hex { + return true; + }, + validateCurrencySupported(_currency: unknown): _currency is string { + return true; + }, + ...overrides, + }; +} + +type WithControllerOptions = { + options?: Partial< + ConstructorParameters[0] + >; + mockCurrencyRateState?: { currentCurrency: string }; + mockTokenPricesService?: Partial; +}; + +type WithControllerCallback = ({ + controller, + triggerCurrencyRateStateChange, +}: { + controller: TokenSearchDiscoveryDataController; + triggerCurrencyRateStateChange: (state: { currentCurrency: string }) => void; +}) => Promise | ReturnValue; + +type WithControllerArgs = + | [WithControllerCallback] + | [WithControllerOptions, WithControllerCallback]; + +/** + * Builds a TokenSearchDiscoveryDataController, and calls a callback with it + * + * @param args - Either an options bag and a callback, or just a callback. If + * provided, the options bag is equivalent to the controller options; the function + * will be called with the built controller. + * @returns Whatever the callback returns. + */ +async function withController( + ...args: WithControllerArgs +): Promise { + const [optionsOrCallback, maybeCallback]: [ + WithControllerOptions | WithControllerCallback, + WithControllerCallback?, + ] = args; + + let options: WithControllerOptions; + let callback: WithControllerCallback; + + if (typeof optionsOrCallback === 'function') { + options = {}; + callback = optionsOrCallback; + } else { + options = optionsOrCallback; + assert(maybeCallback); + callback = maybeCallback; + } + + const messenger: RootMessenger = new Messenger({ + namespace: MOCK_ANY_NAMESPACE, + }); + + messenger.registerActionHandler('CurrencyRateController:getState', () => ({ + currentCurrency: 'USD', + currencyRates: {}, + ...(options.mockCurrencyRateState ?? {}), + })); + + const controllerMessenger = + buildTokenSearchDiscoveryDataControllerMessenger(messenger); + + const controller = new TokenSearchDiscoveryDataController({ + messenger: controllerMessenger, + state: { + tokenDisplayData: [], + }, + tokenPricesService: buildMockTokenPricesService( + options.mockTokenPricesService, + ), + ...options.options, + }); + + return await callback({ + controller, + triggerCurrencyRateStateChange: (state: { currentCurrency: string }) => { + messenger.unregisterActionHandler('CurrencyRateController:getState'); + messenger.registerActionHandler( + 'CurrencyRateController:getState', + () => ({ + currentCurrency: state.currentCurrency, + currencyRates: {}, + }), + ); + }, + }); +} + +describe('TokenSearchDiscoveryDataController', () => { + beforeEach(() => { + jest.resetAllMocks(); + }); + + describe('constructor', () => { + it('should set default state', async () => { + await withController(async ({ controller }) => { + expect(controller.state).toStrictEqual({ + tokenDisplayData: [], + }); + }); + }); + + it('should initialize with provided state', async () => { + const initialState: Partial = { + tokenDisplayData: [buildNotFoundTokenDisplayData()], + }; + + await withController( + { + options: { + state: initialState, + }, + }, + async ({ controller }) => { + expect(controller.state.tokenDisplayData).toStrictEqual( + initialState.tokenDisplayData, + ); + }, + ); + }); + }); + + describe('fetchTokenDisplayData', () => { + it('should fetch token display data for a token address', async () => { + const tokenAddress = '0x6B175474E89094C44Da98b954EedeAC495271d0F'; + const tokenChainId = ChainId.mainnet; + const tokenMetadata = { + decimals: 18, + symbol: 'TEST', + name: 'Test Token', + }; + + (fetchTokenMetadata as jest.Mock).mockImplementation(() => + Promise.resolve(tokenMetadata), + ); + + const mockPriceData: EvmAssetWithMarketData = { + price: 10.5, + currency: 'USD', + tokenAddress: tokenAddress as Hex, + chainId: '0x1', + allTimeHigh: 20, + allTimeLow: 5, + circulatingSupply: 1000000, + dilutedMarketCap: 10000000, + high1d: 11, + low1d: 10, + marketCap: 10500000, + marketCapPercentChange1d: 2, + priceChange1d: 0.5, + pricePercentChange1d: 5, + pricePercentChange1h: 1, + pricePercentChange1y: 50, + pricePercentChange7d: 10, + pricePercentChange14d: 15, + pricePercentChange30d: 20, + pricePercentChange200d: 30, + totalVolume: 500000, + }; + + const mockTokenPricesService = { + fetchTokenPrices: jest.fn().mockResolvedValue([mockPriceData]), + }; + + await withController( + { + mockTokenPricesService, + }, + async ({ controller }) => { + await controller.fetchTokenDisplayData(tokenChainId, tokenAddress); + + expect(controller.state.tokenDisplayData).toHaveLength(1); + + const foundToken = controller.state + .tokenDisplayData[0] as FoundTokenDisplayData; + expect(foundToken.found).toBe(true); + expect(foundToken.address).toBe(tokenAddress); + expect(foundToken.chainId).toBe(tokenChainId); + expect(foundToken.currency).toBe('USD'); + expect(foundToken.token.symbol).toBe(tokenMetadata.symbol); + expect(foundToken.token.name).toBe(tokenMetadata.name); + expect(foundToken.token.decimals).toBe(tokenMetadata.decimals); + expect(foundToken.price).toStrictEqual(mockPriceData); + }, + ); + }); + + it('should add not found token display data when metadata fetch fails', async () => { + const tokenAddress = '0x0000000000000000000000000000000000000010'; + const tokenChainId = ChainId.mainnet; + + (fetchTokenMetadata as jest.Mock).mockImplementation(() => + Promise.reject(new Error('Token metadata not supported')), + ); + + await withController(async ({ controller }) => { + await controller.fetchTokenDisplayData(tokenChainId, tokenAddress); + + const notFoundToken = controller.state.tokenDisplayData[0]; + + expect(controller.state.tokenDisplayData).toHaveLength(1); + expect(notFoundToken.found).toBe(false); + expect(notFoundToken.address).toBe(tokenAddress); + expect(notFoundToken.chainId).toBe(tokenChainId); + expect(notFoundToken.currency).toBe('USD'); + }); + }); + + it('should limit the number of token display data entries', async () => { + const initialTokenDisplayData: NotFoundTokenDisplayData[] = []; + for (let i = 0; i < MAX_TOKEN_DISPLAY_DATA_LENGTH; i++) { + initialTokenDisplayData.push( + buildNotFoundTokenDisplayData({ + address: `0x${i.toString().padStart(40, '0')}`, + chainId: '0x1', + currency: 'EUR', + }), + ); + } + + const newTokenAddress = '0xabcdef1234567890abcdef1234567890abcdef12'; + + (fetchTokenMetadata as jest.Mock).mockResolvedValue({ + decimals: 18, + symbol: 'NEW', + name: 'New Token', + }); + + await withController( + { + options: { + state: { + tokenDisplayData: initialTokenDisplayData, + }, + }, + }, + async ({ controller }) => { + expect(controller.state.tokenDisplayData).toHaveLength( + MAX_TOKEN_DISPLAY_DATA_LENGTH, + ); + + await controller.fetchTokenDisplayData('0x1', newTokenAddress); + + expect(controller.state.tokenDisplayData).toHaveLength( + MAX_TOKEN_DISPLAY_DATA_LENGTH, + ); + + expect(controller.state.tokenDisplayData[0].address).toBe( + newTokenAddress, + ); + }, + ); + }); + + it('should handle currency changes correctly', async () => { + const tokenAddress = '0x0000000000000000000000000000000000000010'; + const tokenChainId = ChainId.mainnet; + + (fetchTokenMetadata as jest.Mock).mockResolvedValue({ + name: 'Test Token', + symbol: 'TEST', + decimals: 18, + address: tokenAddress, + occurrences: 1, + aggregators: ['agg1'], + iconUrl: 'https://example.com/logo.png', + }); + + const mockTokenPricesService = { + async fetchTokenPrices({ + currency, + }: { + currency: string; + }): Promise[]> { + const basePrice: Omit< + EvmAssetWithMarketData, + 'price' | 'currency' + > = { + tokenAddress: tokenAddress as Hex, + chainId: '0x1', + allTimeHigh: 20, + allTimeLow: 5, + circulatingSupply: 1000000, + dilutedMarketCap: 10000000, + high1d: 12, + low1d: 10, + marketCap: 10000000, + marketCapPercentChange1d: 2, + priceChange1d: 0.5, + pricePercentChange1d: 5, + pricePercentChange1h: 1, + pricePercentChange1y: 50, + pricePercentChange7d: 10, + pricePercentChange14d: 15, + pricePercentChange30d: 20, + pricePercentChange200d: 30, + totalVolume: 500000, + }; + + return [ + { + ...basePrice, + price: currency === 'USD' ? 10.5 : 9.5, + currency, + }, + ]; + }, + }; + + await withController( + { + mockTokenPricesService, + mockCurrencyRateState: { currentCurrency: 'USD' }, + }, + async ({ controller, triggerCurrencyRateStateChange }) => { + await controller.fetchTokenDisplayData(tokenChainId, tokenAddress); + const usdToken = controller.state + .tokenDisplayData[0] as FoundTokenDisplayData; + expect(usdToken.currency).toBe('USD'); + expect(usdToken.found).toBe(true); + expect(usdToken.price?.price).toBe(10.5); + + triggerCurrencyRateStateChange({ currentCurrency: 'EUR' }); + + await controller.fetchTokenDisplayData(tokenChainId, tokenAddress); + const eurToken = controller.state + .tokenDisplayData[0] as FoundTokenDisplayData; + expect(eurToken.currency).toBe('EUR'); + expect(eurToken.found).toBe(true); + expect(eurToken.price?.price).toBe(9.5); + }, + ); + }); + + it('should handle unsupported currency', async () => { + const tokenAddress = '0x6B175474E89094C44Da98b954EedeAC495271d0F'; + const tokenChainId = ChainId.mainnet; + + (fetchTokenMetadata as jest.Mock).mockResolvedValue({ + name: 'Test Token', + symbol: 'TEST', + decimals: 18, + }); + + const mockTokenPrice: EvmAssetWithMarketData = { + price: 10.5, + currency: 'USD', + tokenAddress: tokenAddress as Hex, + chainId: '0x1', + allTimeHigh: 20, + allTimeLow: 5, + circulatingSupply: 1000000, + dilutedMarketCap: 10000000, + high1d: 11, + low1d: 10, + marketCap: 10500000, + marketCapPercentChange1d: 2, + priceChange1d: 0.5, + pricePercentChange1d: 5, + pricePercentChange1h: 1, + pricePercentChange1y: 50, + pricePercentChange7d: 10, + pricePercentChange14d: 15, + pricePercentChange30d: 20, + pricePercentChange200d: 30, + totalVolume: 500000, + }; + + const mockFetchTokenPrices = jest + .fn() + .mockImplementation(({ currency }: { currency: string }) => { + if (currency === 'USD') { + return Promise.resolve({ [tokenAddress as Hex]: mockTokenPrice }); + } + return Promise.resolve({}); + }); + + const mockTokenPricesService = { + fetchTokenPrices: mockFetchTokenPrices, + }; + + await withController( + { + mockTokenPricesService, + }, + async ({ controller, triggerCurrencyRateStateChange }) => { + await controller.fetchTokenDisplayData(tokenChainId, tokenAddress); + + const tokenWithUsd = controller.state + .tokenDisplayData[0] as FoundTokenDisplayData; + expect(tokenWithUsd.found).toBe(true); + expect(tokenWithUsd.price).toBeDefined(); + + triggerCurrencyRateStateChange({ currentCurrency: 'EUR' }); + + await controller.fetchTokenDisplayData(tokenChainId, tokenAddress); + + const tokenWithEur = controller.state + .tokenDisplayData[0] as FoundTokenDisplayData; + expect(tokenWithEur.found).toBe(true); + expect(tokenWithEur.currency).toBe('EUR'); + expect(tokenWithEur.price).toBeNull(); + }, + ); + }); + + it('should move existing token to the beginning when fetched again', async () => { + const tokenChainId = '0x1'; + const tokenAddress1 = '0x6B175474E89094C44Da98b954EedeAC495271d0F'; + const tokenAddress2 = '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48'; + + (fetchTokenMetadata as jest.Mock).mockImplementation( + (_chainId, address) => { + if (address === tokenAddress1) { + return Promise.resolve({ + decimals: 18, + symbol: 'DAI', + name: 'Dai Stablecoin', + }); + } else if (address === tokenAddress2) { + return Promise.resolve({ + decimals: 6, + symbol: 'USDC', + name: 'USD Coin', + }); + } + return Promise.reject(new Error('Unknown token')); + }, + ); + + const initialTokenDisplayData = [ + buildFoundTokenDisplayData({ + address: tokenAddress1, + chainId: '0x2', + currency: 'USD', + token: { + address: tokenAddress1, + decimals: 18, + symbol: 'DAI', + name: 'Dai Stablecoin', + }, + }), + buildFoundTokenDisplayData({ + address: tokenAddress2, + chainId: '0x2', + currency: 'USD', + token: { + address: tokenAddress2, + decimals: 6, + symbol: 'USDC', + name: 'USD Coin', + }, + }), + ]; + + await withController( + { + options: { + state: { + tokenDisplayData: initialTokenDisplayData, + }, + }, + }, + async ({ controller }) => { + expect(controller.state.tokenDisplayData).toHaveLength(2); + + await controller.fetchTokenDisplayData(tokenChainId, tokenAddress1); + + expect(controller.state.tokenDisplayData).toHaveLength(3); + expect(controller.state.tokenDisplayData[0].address).toBe( + tokenAddress1, + ); + expect(controller.state.tokenDisplayData[0].chainId).toBe( + tokenChainId, + ); + + await controller.fetchTokenDisplayData(tokenChainId, tokenAddress2); + + expect(controller.state.tokenDisplayData).toHaveLength(4); + expect(controller.state.tokenDisplayData[0].address).toBe( + tokenAddress2, + ); + expect(controller.state.tokenDisplayData[0].chainId).toBe( + tokenChainId, + ); + expect(controller.state.tokenDisplayData[1].address).toBe( + tokenAddress1, + ); + expect(controller.state.tokenDisplayData[1].chainId).toBe( + tokenChainId, + ); + }, + ); + }); + + it('should rethrow unknown errors when fetching token metadata', async () => { + const tokenChainId = '0x1'; + const tokenAddress = '0x6B175474E89094C44Da98b954EedeAC495271d0F'; + + const customError = new Error('Custom error'); + Object.defineProperty(customError, 'name', { value: 'CustomError' }); + + (fetchTokenMetadata as jest.Mock).mockRejectedValue(customError); + + jest.mock('../token-service', () => ({ + ...jest.requireActual('../token-service'), + TOKEN_METADATA_NO_SUPPORT_ERROR: 'different error message', + })); + + await withController( + { + options: { + state: { + tokenDisplayData: [], + }, + }, + }, + async ({ controller }) => { + let caughtError; + try { + await controller.fetchTokenDisplayData(tokenChainId, tokenAddress); + } catch (error) { + caughtError = error; + } + + expect(caughtError).toBe(customError); + }, + ); + }); + }); + + describe('getDefaultTokenSearchDiscoveryDataControllerState', () => { + it('should return the expected default state', () => { + const defaultState = getDefaultTokenSearchDiscoveryDataControllerState(); + + expect(defaultState).toStrictEqual({ + tokenDisplayData: [], + }); + }); + }); + + 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(`{}`); + }); + }); + + it('persists expected state', async () => { + await withController(({ controller }) => { + expect( + deriveStateFromMetadata( + controller.state, + controller.metadata, + 'persist', + ), + ).toMatchInlineSnapshot(` + { + "tokenDisplayData": [], + } + `); + }); + }); + + it('exposes expected state to UI', async () => { + await withController(({ controller }) => { + expect( + deriveStateFromMetadata( + controller.state, + controller.metadata, + 'usedInUi', + ), + ).toMatchInlineSnapshot(` + { + "tokenDisplayData": [], + } + `); + }); + }); + }); +}); diff --git a/packages/assets-controllers/src/TokenSearchDiscoveryDataController/TokenSearchDiscoveryDataController.ts b/packages/assets-controllers/src/TokenSearchDiscoveryDataController/TokenSearchDiscoveryDataController.ts new file mode 100644 index 00000000000..dd32662527b --- /dev/null +++ b/packages/assets-controllers/src/TokenSearchDiscoveryDataController/TokenSearchDiscoveryDataController.ts @@ -0,0 +1,226 @@ +import { BaseController } from '@metamask/base-controller'; +import type { + StateMetadata, + ControllerGetStateAction, + ControllerStateChangeEvent, +} from '@metamask/base-controller'; +import type { Messenger } from '@metamask/messenger'; +import type { Hex } from '@metamask/utils'; + +import { formatIconUrlWithProxy } from '../assetsUtil.js'; +import type { CurrencyRateControllerGetStateAction } from '../CurrencyRateController.js'; +import type { AbstractTokenPricesService } from '../token-prices-service/index.js'; +import { + fetchTokenMetadata, + TOKEN_METADATA_NO_SUPPORT_ERROR, +} from '../token-service.js'; +import type { TokenListToken } from '../TokenListController.js'; +import type { TokenDisplayData } from './types.js'; + +// === GENERAL === + +export const controllerName = 'TokenSearchDiscoveryDataController'; + +export const MAX_TOKEN_DISPLAY_DATA_LENGTH = 10; + +// === STATE === + +export type TokenSearchDiscoveryDataControllerState = { + tokenDisplayData: TokenDisplayData[]; +}; + +const tokenSearchDiscoveryDataControllerMetadata: StateMetadata = + { + tokenDisplayData: { + includeInStateLogs: false, + persist: true, + includeInDebugSnapshot: false, + usedInUi: true, + }, + } as const; + +// === MESSENGER === + +/** + * The action which can be used to retrieve the state of the + * {@link TokenSearchDiscoveryDataController}. + */ +export type TokenSearchDiscoveryDataControllerGetStateAction = + ControllerGetStateAction< + typeof controllerName, + TokenSearchDiscoveryDataControllerState + >; + +/** + * All actions that {@link TokenSearchDiscoveryDataController} registers, to be + * called externally. + */ +export type TokenSearchDiscoveryDataControllerActions = + TokenSearchDiscoveryDataControllerGetStateAction; + +/** + * All actions that {@link TokenSearchDiscoveryDataController} calls internally. + */ +export type AllowedActions = CurrencyRateControllerGetStateAction; + +/** + * The event that {@link TokenSearchDiscoveryDataController} publishes when updating + * state. + */ +export type TokenSearchDiscoveryDataControllerStateChangeEvent = + ControllerStateChangeEvent< + typeof controllerName, + TokenSearchDiscoveryDataControllerState + >; + +/** + * All events that {@link TokenSearchDiscoveryDataController} publishes, to be + * subscribed to externally. + */ +export type TokenSearchDiscoveryDataControllerEvents = + TokenSearchDiscoveryDataControllerStateChangeEvent; + +/** + * All events that {@link TokenSearchDiscoveryDataController} subscribes to internally. + */ +export type AllowedEvents = never; + +/** + * The messenger which is restricted to actions and events accessed by + * {@link TokenSearchDiscoveryDataController}. + */ +export type TokenSearchDiscoveryDataControllerMessenger = Messenger< + typeof controllerName, + TokenSearchDiscoveryDataControllerActions | AllowedActions, + TokenSearchDiscoveryDataControllerEvents | AllowedEvents +>; + +/** + * Constructs the default {@link TokenSearchDiscoveryDataController} 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 TokenSearchDiscoveryDataController} state. + */ +export function getDefaultTokenSearchDiscoveryDataControllerState(): TokenSearchDiscoveryDataControllerState { + return { + tokenDisplayData: [], + }; +} + +/** + * The TokenSearchDiscoveryDataController manages the retrieval of token search results and token discovery. + * It fetches token metadata from the Token API and token prices from the token prices service. + */ +export class TokenSearchDiscoveryDataController extends BaseController< + typeof controllerName, + TokenSearchDiscoveryDataControllerState, + TokenSearchDiscoveryDataControllerMessenger +> { + readonly #abortController: AbortController; + + readonly #tokenPricesService: AbstractTokenPricesService; + + constructor({ + state = {}, + messenger, + tokenPricesService, + }: { + state?: Partial; + messenger: TokenSearchDiscoveryDataControllerMessenger; + tokenPricesService: AbstractTokenPricesService; + }) { + super({ + name: controllerName, + metadata: tokenSearchDiscoveryDataControllerMetadata, + messenger, + state: { + ...getDefaultTokenSearchDiscoveryDataControllerState(), + ...state, + }, + }); + + this.#abortController = new AbortController(); + this.#tokenPricesService = tokenPricesService; + } + + async #fetchPriceData(chainId: Hex, address: string) { + const { currentCurrency } = this.messenger.call( + 'CurrencyRateController:getState', + ); + + try { + const pricesData = await this.#tokenPricesService.fetchTokenPrices({ + assets: [{ chainId, tokenAddress: address as Hex }], + currency: currentCurrency, + }); + + return pricesData[0] ?? null; + } catch (error) { + console.error(error); + return null; + } + } + + async fetchTokenDisplayData(chainId: Hex, address: string): Promise { + let tokenMetadata: TokenListToken | undefined; + try { + tokenMetadata = await fetchTokenMetadata( + chainId, + address, + this.#abortController.signal, + ); + } catch (error) { + if ( + !(error instanceof Error) || + !error.message.includes(TOKEN_METADATA_NO_SUPPORT_ERROR) + ) { + throw error; + } + } + + const { currentCurrency } = this.messenger.call( + 'CurrencyRateController:getState', + ); + + let tokenDisplayData: TokenDisplayData; + if (!tokenMetadata) { + tokenDisplayData = { + found: false, + address, + chainId, + currency: currentCurrency, + }; + } else { + const priceData = await this.#fetchPriceData(chainId, address); + tokenDisplayData = { + found: true, + address, + chainId, + currency: currentCurrency, + token: { + ...tokenMetadata, + isERC721: false, + image: formatIconUrlWithProxy({ + chainId, + tokenAddress: address, + }), + }, + price: priceData, + }; + } + + this.update((state) => { + state.tokenDisplayData = [ + tokenDisplayData, + ...state.tokenDisplayData.filter( + (token) => + token.address !== address || + token.chainId !== chainId || + token.currency !== currentCurrency, + ), + ].slice(0, MAX_TOKEN_DISPLAY_DATA_LENGTH); + }); + } +} diff --git a/packages/assets-controllers/src/TokenSearchDiscoveryDataController/index.ts b/packages/assets-controllers/src/TokenSearchDiscoveryDataController/index.ts new file mode 100644 index 00000000000..033f1c5cf90 --- /dev/null +++ b/packages/assets-controllers/src/TokenSearchDiscoveryDataController/index.ts @@ -0,0 +1,2 @@ +export * from './TokenSearchDiscoveryDataController.js'; +export type * from './types.js'; diff --git a/packages/assets-controllers/src/TokenSearchDiscoveryDataController/types.ts b/packages/assets-controllers/src/TokenSearchDiscoveryDataController/types.ts new file mode 100644 index 00000000000..694f65c2738 --- /dev/null +++ b/packages/assets-controllers/src/TokenSearchDiscoveryDataController/types.ts @@ -0,0 +1,22 @@ +import type { Hex } from '@metamask/utils'; + +import type { EvmAssetWithMarketData } from '../token-prices-service/abstract-token-prices-service.js'; +import type { Token } from '../TokenRatesController.js'; + +export type NotFoundTokenDisplayData = { + found: false; + chainId: Hex; + address: string; + currency: string; +}; + +export type FoundTokenDisplayData = { + found: true; + chainId: Hex; + address: string; + currency: string; + token: Token; + price: EvmAssetWithMarketData | null; +}; + +export type TokenDisplayData = NotFoundTokenDisplayData | FoundTokenDisplayData; diff --git a/packages/assets-controllers/src/TokensController-method-action-types.ts b/packages/assets-controllers/src/TokensController-method-action-types.ts new file mode 100644 index 00000000000..3aba1259bed --- /dev/null +++ b/packages/assets-controllers/src/TokensController-method-action-types.ts @@ -0,0 +1,121 @@ +/** + * This file is auto generated. + * Do not edit manually. + */ + +import type { TokensController } from './TokensController.js'; + +/** + * Adds a token to the stored token list. + * + * @param options - The method argument object. + * @param options.address - Hex address of the token contract. + * @param options.symbol - Symbol of the token. + * @param options.decimals - Number of decimals the token uses. + * @param options.name - Name of the token. + * @param options.image - Image of the token. + * @param options.interactingAddress - The address of the account to add a token to. + * @param options.networkClientId - Network Client ID. + * @param options.rwaData - Optional RWA data for the token. + * @returns Current token list. + */ +export type TokensControllerAddTokenAction = { + type: `TokensController:addToken`; + handler: TokensController['addToken']; +}; + +/** + * Add a batch of tokens. + * + * @param tokensToImport - Array of tokens to import. + * @param networkClientId - Optional network client ID used to determine interacting chain ID. + */ +export type TokensControllerAddTokensAction = { + type: `TokensController:addTokens`; + handler: TokensController['addTokens']; +}; + +/** + * Ignore a batch of tokens. + * + * @param tokenAddressesToIgnore - Array of token addresses to ignore. + * @param networkClientId - Optional network client ID used to determine interacting chain ID. + */ +export type TokensControllerIgnoreTokensAction = { + type: `TokensController:ignoreTokens`; + handler: TokensController['ignoreTokens']; +}; + +/** + * Adds a batch of detected tokens to the stored token list. + * + * @param incomingDetectedTokens - Array of detected tokens to be added or updated. + * @param detectionDetails - An object containing the chain ID and address of the currently selected network on which the incomingDetectedTokens were detected. + * @param detectionDetails.selectedAddress - the account address on which the incomingDetectedTokens were detected. + * @param detectionDetails.chainId - the chainId on which the incomingDetectedTokens were detected. + */ +export type TokensControllerAddDetectedTokensAction = { + type: `TokensController:addDetectedTokens`; + handler: TokensController['addDetectedTokens']; +}; + +/** + * Adds isERC721 field to token object. This is called when a user attempts to add tokens that + * were previously added which do not yet had isERC721 field. + * + * @param tokenAddress - The contract address of the token requiring the isERC721 field added. + * @param networkClientId - The network client ID of the network on which the token is detected. + * @returns The new token object with the added isERC721 field. + */ +export type TokensControllerUpdateTokenTypeAction = { + type: `TokensController:updateTokenType`; + handler: TokensController['updateTokenType']; +}; + +/** + * Adds a new suggestedAsset to the list of watched assets. + * Parameters will be validated according to the asset type being watched. + * + * @param options - The method options. + * @param options.asset - The asset to be watched. For now only ERC20 tokens are accepted. + * @param options.type - The asset type. + * @param options.interactingAddress - The address of the account that is requesting to watch the asset. + * @param options.networkClientId - Network Client ID. + * @param options.origin - The origin to set on the approval request. + * @param options.pageMeta - The metadata for the page initiating the request. + * @param options.requestMetadata - Metadata for the request, including pageMeta and origin. + * @returns A promise that resolves if the asset was watched successfully, and rejects otherwise. + */ +export type TokensControllerWatchAssetAction = { + type: `TokensController:watchAsset`; + handler: TokensController['watchAsset']; +}; + +/** + * Removes all tokens from the ignored list. + */ +export type TokensControllerClearIgnoredTokensAction = { + type: `TokensController:clearIgnoredTokens`; + handler: TokensController['clearIgnoredTokens']; +}; + +/** + * Reset the controller state to the default state. + */ +export type TokensControllerResetStateAction = { + type: `TokensController:resetState`; + handler: TokensController['resetState']; +}; + +/** + * Union of all TokensController action types. + */ +export type TokensControllerMethodActions = + | TokensControllerAddTokenAction + | TokensControllerAddTokensAction + | TokensControllerIgnoreTokensAction + | TokensControllerAddDetectedTokensAction + | TokensControllerUpdateTokenTypeAction + | TokensControllerWatchAssetAction + | TokensControllerClearIgnoredTokensAction + | TokensControllerResetStateAction; diff --git a/packages/assets-controllers/src/TokensController.test.ts b/packages/assets-controllers/src/TokensController.test.ts index d196230ad5a..6819d2d9896 100644 --- a/packages/assets-controllers/src/TokensController.test.ts +++ b/packages/assets-controllers/src/TokensController.test.ts @@ -1,1704 +1,4392 @@ -import { - ApprovalController, - type AddApprovalRequest, - type ApprovalControllerEvents, +import { Contract } from '@ethersproject/contracts'; +import { ApprovalController } from '@metamask/approval-controller'; +import type { + ApprovalControllerMessenger, + ApprovalControllerState, } from '@metamask/approval-controller'; -import { ControllerMessenger } from '@metamask/base-controller'; +import { deriveStateFromMetadata } from '@metamask/base-controller'; import contractMaps from '@metamask/contract-metadata'; import { ApprovalType, ChainId, - ERC20, - NetworkType, - NetworksTicker, ORIGIN_METAMASK, convertHexToDecimal, - toHex, + InfuraNetworkType, } from '@metamask/controller-utils'; +import type { InternalAccount } from '@metamask/keyring-internal-api'; +import { Messenger, MOCK_ANY_NAMESPACE } from '@metamask/messenger'; import type { + MockAnyNamespace, + MessengerActions, + MessengerEvents, +} from '@metamask/messenger'; +import type { + NetworkClientConfiguration, + NetworkClientId, NetworkState, - ProviderConfig, } from '@metamask/network-controller'; -import { defaultState as defaultNetworkState } from '@metamask/network-controller'; -import { PreferencesController } from '@metamask/preferences-controller'; +import { getDefaultNetworkControllerState } from '@metamask/network-controller'; +import type { Patch } from 'immer'; import nock from 'nock'; -import * as sinon from 'sinon'; - -import { TOKEN_END_POINT_API } from './token-service'; -import type { Token } from './TokenRatesController'; -import { TokensController } from './TokensController'; -import type { TokensControllerMessenger } from './TokensController'; +import { v1 as uuidV1 } from 'uuid'; -jest.mock('uuid', () => { - return { - ...jest.requireActual('uuid'), - v1: () => '9b1deb4d-3b7d-4bad-9bdd-2b0d7b3dcb6d', - }; +import { FakeProvider } from '../../../tests/fake-provider.js'; +import { createMockInternalAccount } from '../../accounts-controller/tests/mocks.js'; +import { + buildCustomNetworkClientConfiguration, + buildMockGetNetworkClientById, +} from '../../network-controller/tests/helpers.js'; +import { ERC20Standard } from './Standards/ERC20Standard.js'; +import { ERC1155Standard } from './Standards/NftStandards/ERC1155/ERC1155Standard.js'; +import { TOKEN_END_POINT_API } from './token-service.js'; +import type { TokenRwaData } from './token-service.js'; +import type { Token } from './TokenRatesController.js'; +import { TokensController } from './TokensController.js'; +import type { + TokensControllerMessenger, + TokensControllerState, +} from './TokensController.js'; + +jest.mock('@ethersproject/contracts'); +jest.mock('uuid', () => ({ + ...jest.requireActual('uuid'), + v1: jest.fn(), +})); +jest.mock('./Standards/ERC20Standard'); +jest.mock('./Standards/NftStandards/ERC1155/ERC1155Standard'); + +type AllActions = + | MessengerActions + | MessengerActions; + +type AllEvents = + | MessengerEvents + | MessengerEvents; + +type RootMessenger = Messenger; + +const ContractMock = jest.mocked(Contract); +const uuidV1Mock = jest.mocked(uuidV1); +const ERC20StandardMock = jest.mocked(ERC20Standard); +const ERC1155StandardMock = jest.mocked(ERC1155Standard); + +const defaultMockInternalAccount = createMockInternalAccount({ + address: '0x1', }); -const stubCreateEthers = (ctrl: TokensController, res: boolean) => { - return sinon.stub(ctrl, '_createEthersContract').callsFake(() => { - return { - supportsInterface: sinon.stub().returns(res), - } as any; - }); -}; - -const SEPOLIA = { - chainId: toHex(11155111), - type: NetworkType.sepolia, - ticker: NetworksTicker.sepolia, -}; -const GOERLI = { - chainId: toHex(5), - type: NetworkType.goerli, - ticker: NetworksTicker.goerli, -}; - -const controllerName = 'TokensController' as const; - -type ApprovalActions = AddApprovalRequest; - describe('TokensController', () => { - let tokensController: TokensController; - let preferences: PreferencesController; - const messenger = new ControllerMessenger< - ApprovalActions, - ApprovalControllerEvents - >(); - - const approvalControllerMessenger = messenger.getRestricted({ - name: 'ApprovalController', - allowedActions: ['ApprovalController:addRequest'], + beforeEach(() => { + uuidV1Mock.mockReturnValue('9b1deb4d-3b7d-4bad-9bdd-2b0d7b3dcb6d'); + ContractMock.mockReturnValue( + buildMockEthersERC721Contract({ supportsInterface: false }), + ); }); - const approvalController = new ApprovalController({ - messenger: approvalControllerMessenger, - showApprovalRequest: jest.fn(), - typesExcludedFromRateLimiting: [ApprovalType.WatchAsset], - }); - - const tokensControllerMessenger = messenger.getRestricted< - typeof controllerName, - ApprovalActions['type'], - never - >({ - name: controllerName, - allowedActions: ['ApprovalController:addRequest'], - }) as TokensControllerMessenger; - - let onNetworkStateChangeListener: (state: NetworkState) => void; - const changeNetwork = (providerConfig: ProviderConfig) => { - onNetworkStateChangeListener({ - ...defaultNetworkState, - providerConfig, + it('should set default state', async () => { + await withController(({ controller }) => { + expect(controller.state).toStrictEqual({ + allTokens: {}, + allIgnoredTokens: {}, + allDetectedTokens: {}, + }); }); - }; - - let tokenListStateChangeListener: (state: any) => void; - const onTokenListStateChange = sinon.stub().callsFake((listener) => { - tokenListStateChangeListener = listener; }); - beforeEach(async () => { - const defaultSelectedAddress = '0x1'; - preferences = new PreferencesController(); - tokensController = new TokensController({ - chainId: ChainId.mainnet, - onPreferencesStateChange: (listener) => preferences.subscribe(listener), - onNetworkStateChange: (listener) => - (onNetworkStateChangeListener = listener), - onTokenListStateChange, - config: { - selectedAddress: defaultSelectedAddress, - }, - getERC20TokenName: sinon.stub(), - getNetworkClientById: sinon.stub() as any, - messenger: tokensControllerMessenger, - }); - }); + it('should add a token', async () => { + await withController(async ({ controller }) => { + ContractMock.mockReturnValue( + buildMockEthersERC721Contract({ supportsInterface: false }), + ); - afterEach(() => { - sinon.restore(); - }); + await controller.addToken({ + address: '0x01', + symbol: 'bar', + decimals: 2, + networkClientId: 'mainnet', + }); + expect( + controller.state.allTokens[ChainId.mainnet][ + defaultMockInternalAccount.address + ][0], + ).toStrictEqual({ + address: '0x01', + decimals: 2, + image: 'https://static.cx.metamask.io/api/v1/tokenIcons/1/0x01.png', + symbol: 'bar', + isERC721: false, + aggregators: [], + name: undefined, + }); - it('should set default state', () => { - expect(tokensController.state).toStrictEqual({ - allTokens: {}, - allIgnoredTokens: {}, - ignoredTokens: [], - tokens: [], - detectedTokens: [], - allDetectedTokens: {}, + await controller.addToken({ + address: '0x02', + symbol: 'baz', + decimals: 2, + networkClientId: 'mainnet', + }); + expect( + controller.state.allTokens[ChainId.mainnet][ + defaultMockInternalAccount.address + ][1], + ).toStrictEqual({ + address: '0x02', + decimals: 2, + image: 'https://static.cx.metamask.io/api/v1/tokenIcons/1/0x02.png', + symbol: 'baz', + isERC721: false, + aggregators: [], + name: undefined, + }); }); }); - it('should add a token', async () => { - const stub = stubCreateEthers(tokensController, false); - await tokensController.addToken({ - address: '0x01', - symbol: 'bar', - decimals: 2, - }); - expect(tokensController.state.tokens[0]).toStrictEqual({ - address: '0x01', - decimals: 2, - image: - 'https://static.metafi.codefi.network/api/v1/tokenIcons/1/0x01.png', - symbol: 'bar', - isERC721: false, - aggregators: [], - name: undefined, - }); - await tokensController.addToken({ - address: '0x01', - symbol: 'baz', - decimals: 2, - }); - expect(tokensController.state.tokens[0]).toStrictEqual({ - address: '0x01', - decimals: 2, - image: - 'https://static.metafi.codefi.network/api/v1/tokenIcons/1/0x01.png', - symbol: 'baz', - isERC721: false, - aggregators: [], - name: undefined, - }); - stub.restore(); - }); - it('should add tokens', async () => { - const stub = stubCreateEthers(tokensController, false); - - await tokensController.addTokens([ - { + await withController(async ({ controller }) => { + await controller.addTokens( + [ + { + address: '0x01', + symbol: 'barA', + decimals: 2, + aggregators: [], + name: 'Token1', + }, + { + address: '0x02', + symbol: 'barB', + decimals: 2, + aggregators: [], + name: 'Token2', + }, + ], + 'mainnet', + ); + expect( + controller.state.allTokens[ChainId.mainnet][ + defaultMockInternalAccount.address + ][0], + ).toStrictEqual({ address: '0x01', - symbol: 'barA', decimals: 2, + image: undefined, + symbol: 'barA', aggregators: [], name: 'Token1', - }, - { + }); + expect( + controller.state.allTokens[ChainId.mainnet][ + defaultMockInternalAccount.address + ][1], + ).toStrictEqual({ address: '0x02', - symbol: 'barB', decimals: 2, + image: undefined, + symbol: 'barB', aggregators: [], name: 'Token2', - }, - ]); - - expect(tokensController.state.tokens[0]).toStrictEqual({ - address: '0x01', - decimals: 2, - image: undefined, - symbol: 'barA', - aggregators: [], - name: 'Token1', - }); - - expect(tokensController.state.tokens[1]).toStrictEqual({ - address: '0x02', - decimals: 2, - image: undefined, - symbol: 'barB', - aggregators: [], - name: 'Token2', - }); + }); - await tokensController.addTokens([ - { + await controller.addTokens( + [ + { + address: '0x01', + symbol: 'bazA', + decimals: 2, + aggregators: [], + }, + { + address: '0x02', + symbol: 'bazB', + decimals: 2, + aggregators: [], + }, + ], + 'mainnet', + ); + expect( + controller.state.allTokens[ChainId.mainnet][ + defaultMockInternalAccount.address + ][0], + ).toStrictEqual({ address: '0x01', - symbol: 'bazA', decimals: 2, + image: undefined, + symbol: 'bazA', aggregators: [], - }, - { + name: undefined, + }); + expect( + controller.state.allTokens[ChainId.mainnet][ + defaultMockInternalAccount.address + ][1], + ).toStrictEqual({ address: '0x02', - symbol: 'bazB', decimals: 2, + image: undefined, + symbol: 'bazB', aggregators: [], - }, - ]); - - expect(tokensController.state.tokens[0]).toStrictEqual({ - address: '0x01', - decimals: 2, - image: undefined, - symbol: 'bazA', - aggregators: [], - name: undefined, + name: undefined, + }); }); + }); - expect(tokensController.state.tokens[1]).toStrictEqual({ - address: '0x02', - decimals: 2, - image: undefined, - symbol: 'bazB', - aggregators: [], - name: undefined, + it('should add tokens and update existing ones and detected tokens', async () => { + const selectedAddress = '0x0001'; + const selectedAccount = createMockInternalAccount({ + address: selectedAddress, }); + await withController( + { + mockNetworkClientConfigurationsByNetworkClientId: { + networkClientId1: buildCustomNetworkClientConfiguration({ + chainId: '0x1', + }), + }, + mocks: { + getSelectedAccount: selectedAccount, + getAccount: selectedAccount, + }, + }, + async ({ controller }) => { + await controller.addDetectedTokens( + [ + { + address: '0x01', + symbol: 'barA', + decimals: 2, + }, + ], + { + selectedAddress: '0x0001', + chainId: '0x1', + }, + ); + + await controller.addTokens( + [ + { + address: '0x01', + symbol: 'barA', + decimals: 2, + aggregators: [], + name: 'Token1', + }, + { + address: '0x02', + symbol: 'barB', + decimals: 2, + aggregators: [], + name: 'Token2', + }, + ], + 'networkClientId1', + ); + + expect(controller.state.allTokens).toStrictEqual({ + '0x1': { + '0x0001': [ + { + address: '0x01', + symbol: 'barA', + decimals: 2, + aggregators: [], + name: 'Token1', + image: undefined, + }, + { + address: '0x02', + symbol: 'barB', + decimals: 2, + aggregators: [], + name: 'Token2', + image: undefined, + }, + ], + }, + }); + }, + ); + }); + + it('should not add detected tokens if token is already imported', async () => { + await withController(async ({ controller }) => { + await controller.addToken({ + address: '0x01', + symbol: 'bar', + decimals: 2, + networkClientId: 'mainnet', + }); - stub.restore(); + await controller.addDetectedTokens( + [{ address: '0x01', symbol: 'barA', decimals: 2 }], + { + selectedAddress: '0x0001', + chainId: '0x1', + }, + ); + + expect( + controller.state.allDetectedTokens[ChainId.mainnet]?.[ + defaultMockInternalAccount.address + ], + ).toBeUndefined(); + }); }); it('should add detected tokens', async () => { - const stub = stubCreateEthers(tokensController, false); - - await tokensController.addDetectedTokens([ - { address: '0x01', symbol: 'barA', decimals: 2, aggregators: [] }, - { address: '0x02', symbol: 'barB', decimals: 2, aggregators: [] }, - ]); - - expect(tokensController.state.detectedTokens[0]).toStrictEqual({ - address: '0x01', - decimals: 2, - image: undefined, - symbol: 'barA', - aggregators: [], - isERC721: undefined, - name: undefined, - }); - - expect(tokensController.state.detectedTokens[1]).toStrictEqual({ - address: '0x02', - decimals: 2, - image: undefined, - symbol: 'barB', - aggregators: [], - isERC721: undefined, - name: undefined, - }); - - await tokensController.addDetectedTokens([ - { + await withController(async ({ controller }) => { + await controller.addDetectedTokens( + [ + { + address: '0x01', + symbol: 'barA', + decimals: 2, + aggregators: [], + }, + { + address: '0x02', + symbol: 'barB', + decimals: 2, + aggregators: [], + }, + ], + { + chainId: ChainId.mainnet, + }, + ); + expect( + controller.state.allDetectedTokens[ChainId.mainnet][ + defaultMockInternalAccount.address + ][0], + ).toStrictEqual({ address: '0x01', - symbol: 'bazA', decimals: 2, + image: undefined, + symbol: 'barA', aggregators: [], isERC721: undefined, name: undefined, - }, - { + }); + expect( + controller.state.allDetectedTokens[ChainId.mainnet][ + defaultMockInternalAccount.address + ][1], + ).toStrictEqual({ address: '0x02', - symbol: 'bazB', decimals: 2, + image: undefined, + symbol: 'barB', aggregators: [], isERC721: undefined, name: undefined, - }, - ]); - - expect(tokensController.state.detectedTokens[0]).toStrictEqual({ - address: '0x01', - decimals: 2, - image: undefined, - symbol: 'bazA', - aggregators: [], - isERC721: undefined, - name: undefined, - }); + }); - expect(tokensController.state.detectedTokens[1]).toStrictEqual({ - address: '0x02', - decimals: 2, - image: undefined, - symbol: 'bazB', - aggregators: [], - isERC721: undefined, - name: undefined, + await controller.addDetectedTokens( + [ + { + address: '0x01', + symbol: 'bazA', + decimals: 2, + aggregators: [], + isERC721: undefined, + name: undefined, + }, + { + address: '0x02', + symbol: 'bazB', + decimals: 2, + aggregators: [], + isERC721: undefined, + name: undefined, + }, + ], + { + chainId: ChainId.mainnet, + }, + ); + expect( + controller.state.allDetectedTokens[ChainId.mainnet][ + defaultMockInternalAccount.address + ][0], + ).toStrictEqual({ + address: '0x01', + decimals: 2, + image: undefined, + symbol: 'bazA', + aggregators: [], + isERC721: undefined, + name: undefined, + }); + expect( + controller.state.allDetectedTokens[ChainId.mainnet][ + defaultMockInternalAccount.address + ][1], + ).toStrictEqual({ + address: '0x02', + decimals: 2, + image: undefined, + symbol: 'bazB', + aggregators: [], + isERC721: undefined, + name: undefined, + }); }); - - stub.restore(); }); it('should add token by selected address', async () => { - const stub = stubCreateEthers(tokensController, false); - const firstAddress = '0x123'; + const firstAccount = createMockInternalAccount({ + address: firstAddress, + }); const secondAddress = '0x321'; + const secondAccount = createMockInternalAccount({ + address: secondAddress, + }); + await withController( + { + mocks: { + getAccount: firstAccount, + getSelectedAccount: firstAccount, + }, + }, + async ({ controller, triggerSelectedAccountChange }) => { + ContractMock.mockReturnValue( + buildMockEthersERC721Contract({ supportsInterface: false }), + ); + + triggerSelectedAccountChange(firstAccount); + await controller.addToken({ + address: '0x01', + symbol: 'bar', + decimals: 2, + networkClientId: 'mainnet', + }); + triggerSelectedAccountChange(secondAccount); + + expect( + controller.state.allTokens[ChainId.mainnet][firstAccount.address][0], + ).toStrictEqual({ + address: '0x01', + decimals: 2, + image: 'https://static.cx.metamask.io/api/v1/tokenIcons/1/0x01.png', + symbol: 'bar', + isERC721: false, + aggregators: [], + name: undefined, + }); - preferences.update({ selectedAddress: firstAddress }); - await tokensController.addToken({ - address: '0x01', - symbol: 'bar', - decimals: 2, - }); - preferences.update({ selectedAddress: secondAddress }); - expect(tokensController.state.tokens).toHaveLength(0); - preferences.update({ selectedAddress: firstAddress }); - expect(tokensController.state.tokens[0]).toStrictEqual({ - address: '0x01', - decimals: 2, - image: - 'https://static.metafi.codefi.network/api/v1/tokenIcons/1/0x01.png', - symbol: 'bar', - isERC721: false, - aggregators: [], - name: undefined, - }); - - stub.restore(); + expect( + controller.state.allTokens[ChainId.mainnet][secondAccount.address], + ).toBeUndefined(); + }, + ); }); it('should add token by network', async () => { - const stub = stubCreateEthers(tokensController, false); - changeNetwork(SEPOLIA); - await tokensController.addToken({ - address: '0x01', - symbol: 'bar', - decimals: 2, - }); - changeNetwork(GOERLI); - expect(tokensController.state.tokens).toHaveLength(0); - - changeNetwork(SEPOLIA); - - expect(tokensController.state.tokens[0]).toStrictEqual({ - address: '0x01', - decimals: 2, - image: - 'https://static.metafi.codefi.network/api/v1/tokenIcons/11155111/0x01.png', - symbol: 'bar', - isERC721: false, - aggregators: [], - name: undefined, - }); - - stub.restore(); - }); + await withController(async ({ controller, changeNetwork }) => { + changeNetwork({ selectedNetworkClientId: InfuraNetworkType.sepolia }); + await controller.addToken({ + address: '0x01', + symbol: 'bar', + decimals: 2, + networkClientId: 'sepolia', + }); - it('should add token to the correct chainId when passed a networkClientId', async () => { - const stub = stubCreateEthers(tokensController, false); - const getNetworkClientByIdStub = jest - .spyOn(tokensController as any, 'getNetworkClientById') - .mockReturnValue({ configuration: { chainId: '0x5' } }); - await tokensController.addToken({ - address: '0x01', - symbol: 'bar', - decimals: 2, - networkClientId: 'networkClientId1', - }); - expect(tokensController.state.tokens[0]).toStrictEqual({ - address: '0x01', - decimals: 2, - image: - 'https://static.metafi.codefi.network/api/v1/tokenIcons/5/0x01.png', - symbol: 'bar', - isERC721: false, - aggregators: [], - name: undefined, - }); - expect(tokensController.state.allTokens['0x5']['0x1']).toStrictEqual([ - { + changeNetwork({ selectedNetworkClientId: InfuraNetworkType.goerli }); + expect(controller.state.allTokens[ChainId.goerli]).toBeUndefined(); + + changeNetwork({ selectedNetworkClientId: InfuraNetworkType.sepolia }); + expect( + controller.state.allTokens[ChainId.sepolia][ + defaultMockInternalAccount.address + ][0], + ).toStrictEqual({ address: '0x01', decimals: 2, image: - 'https://static.metafi.codefi.network/api/v1/tokenIcons/5/0x01.png', + 'https://static.cx.metamask.io/api/v1/tokenIcons/11155111/0x01.png', symbol: 'bar', isERC721: false, aggregators: [], name: undefined, - }, - ]); - - expect(getNetworkClientByIdStub).toHaveBeenCalledWith('networkClientId1'); - stub.restore(); - }); - - it('should remove token', async () => { - const stub = stubCreateEthers(tokensController, false); - await tokensController.addToken({ - address: '0x01', - symbol: 'bar', - decimals: 2, - }); - tokensController.ignoreTokens(['0x01']); - expect(tokensController.state.tokens).toHaveLength(0); - stub.restore(); + }); + }); }); - it('should remove token by selected address', async () => { - const stub = stubCreateEthers(tokensController, false); - const firstAddress = '0x123'; - const secondAddress = '0x321'; - preferences.update({ selectedAddress: firstAddress }); - await tokensController.addToken({ - address: '0x02', - symbol: 'baz', - decimals: 2, - }); - preferences.update({ selectedAddress: secondAddress }); - await tokensController.addToken({ - address: '0x01', - symbol: 'bar', - decimals: 2, - }); - tokensController.ignoreTokens(['0x01']); - expect(tokensController.state.tokens).toHaveLength(0); - preferences.update({ selectedAddress: firstAddress }); - expect(tokensController.state.tokens[0]).toStrictEqual({ - address: '0x02', - decimals: 2, - image: - 'https://static.metafi.codefi.network/api/v1/tokenIcons/1/0x02.png', - symbol: 'baz', - isERC721: false, - aggregators: [], - name: undefined, - }); - stub.restore(); - }); + it('should add token to the correct chainId when passed a networkClientId', async () => { + await withController( + { + mockNetworkClientConfigurationsByNetworkClientId: { + networkClientId1: buildCustomNetworkClientConfiguration({ + chainId: '0x5', + }), + }, + }, + async ({ controller }) => { + ContractMock.mockReturnValue( + buildMockEthersERC721Contract({ supportsInterface: false }), + ); - it('should remove token by provider type', async () => { - const stub = stubCreateEthers(tokensController, false); - changeNetwork(SEPOLIA); - await tokensController.addToken({ - address: '0x02', - symbol: 'baz', - decimals: 2, - }); - changeNetwork(GOERLI); - await tokensController.addToken({ - address: '0x01', - symbol: 'bar', - decimals: 2, - }); - tokensController.ignoreTokens(['0x01']); - expect(tokensController.state.tokens).toHaveLength(0); - changeNetwork(SEPOLIA); - - expect(tokensController.state.tokens[0]).toStrictEqual({ - address: '0x02', - decimals: 2, - image: - 'https://static.metafi.codefi.network/api/v1/tokenIcons/11155111/0x02.png', - symbol: 'baz', - isERC721: false, - aggregators: [], - name: undefined, - }); - stub.restore(); + await controller.addToken({ + address: '0x01', + symbol: 'bar', + decimals: 2, + networkClientId: 'networkClientId1', + }); + + expect( + controller.state.allTokens[ChainId.goerli][ + defaultMockInternalAccount.address + ][0], + ).toStrictEqual({ + address: '0x01', + decimals: 2, + image: 'https://static.cx.metamask.io/api/v1/tokenIcons/5/0x01.png', + symbol: 'bar', + isERC721: false, + aggregators: [], + name: undefined, + }); + expect(controller.state.allTokens['0x5']['0x1']).toStrictEqual([ + { + address: '0x01', + decimals: 2, + image: 'https://static.cx.metamask.io/api/v1/tokenIcons/5/0x01.png', + symbol: 'bar', + isERC721: false, + aggregators: [], + name: undefined, + }, + ]); + }, + ); }); - it('should subscribe to new sibling preference controllers', async () => { - const address = '0x123'; - preferences.update({ selectedAddress: address }); - changeNetwork(SEPOLIA); - expect(preferences.state.selectedAddress).toStrictEqual(address); + it('should remove token', async () => { + await withController(async ({ controller }) => { + await controller.addToken({ + address: '0x01', + symbol: 'bar', + decimals: 2, + networkClientId: 'mainnet', + }); + + controller.ignoreTokens(['0x01'], 'mainnet'); + + expect( + controller.state.allTokens[ChainId.mainnet][ + defaultMockInternalAccount.address + ], + ).toHaveLength(0); + }); }); - describe('ignoredTokens', () => { - const defaultSelectedAddress = '0x0001'; + it('should remove detected token', async () => { + await withController(async ({ controller }) => { + await controller.addDetectedTokens( + [ + { + address: '0x01', + symbol: 'bar', + decimals: 2, + }, + ], + { + chainId: ChainId.mainnet, + }, + ); - let createEthersStub: sinon.SinonStub; - beforeEach(() => { - preferences.setSelectedAddress(defaultSelectedAddress); - changeNetwork(SEPOLIA); + controller.ignoreTokens(['0x01'], 'mainnet'); - createEthersStub = stubCreateEthers(tokensController, false); + expect( + controller.state.allDetectedTokens[ChainId.mainnet][ + defaultMockInternalAccount.address + ], + ).toHaveLength(0); }); + }); - afterEach(() => { - createEthersStub.restore(); + it('should remove token by selected address', async () => { + const firstAddress = '0x123'; + const firstAccount = createMockInternalAccount({ + address: firstAddress, + }); + const secondAddress = '0x321'; + const secondAccount = createMockInternalAccount({ + address: secondAddress, }); + await withController( + { + mocks: { + getAccount: firstAccount, + getSelectedAccount: firstAccount, + }, + }, + async ({ controller, triggerSelectedAccountChange }) => { + ContractMock.mockReturnValue( + buildMockEthersERC721Contract({ supportsInterface: false }), + ); - it('should remove token from ignoredTokens/allIgnoredTokens lists if added back via addToken', async () => { - await tokensController.addToken({ - address: '0x01', - symbol: 'foo', + triggerSelectedAccountChange(firstAccount); + await controller.addToken({ + address: '0x02', + symbol: 'baz', + decimals: 2, + networkClientId: 'mainnet', + }); + triggerSelectedAccountChange(secondAccount); + await controller.addToken({ + address: '0x01', + symbol: 'bar', + decimals: 2, + networkClientId: 'mainnet', + }); + + controller.ignoreTokens(['0x01'], 'mainnet'); + expect( + controller.state.allTokens[ChainId.mainnet][secondAccount.address], + ).toHaveLength(0); + + triggerSelectedAccountChange(firstAccount); + expect( + controller.state.allTokens[ChainId.mainnet][firstAccount.address][0], + ).toStrictEqual({ + address: '0x02', + decimals: 2, + image: 'https://static.cx.metamask.io/api/v1/tokenIcons/1/0x02.png', + symbol: 'baz', + isERC721: false, + aggregators: [], + name: undefined, + }); + }, + ); + }); + + it('should remove token by provider type', async () => { + await withController(async ({ controller, changeNetwork }) => { + ContractMock.mockReturnValue( + buildMockEthersERC721Contract({ supportsInterface: false }), + ); + changeNetwork({ selectedNetworkClientId: InfuraNetworkType.sepolia }); + await controller.addToken({ + address: '0x02', + symbol: 'baz', decimals: 2, + networkClientId: 'sepolia', }); - await tokensController.addToken({ - address: '0xFAa', - symbol: 'bar', - decimals: 3, - }); - expect(tokensController.state.ignoredTokens).toHaveLength(0); - expect(tokensController.state.tokens).toHaveLength(2); - tokensController.ignoreTokens(['0x01']); - expect(tokensController.state.tokens).toHaveLength(1); - expect(tokensController.state.ignoredTokens).toHaveLength(1); - await tokensController.addToken({ + changeNetwork({ selectedNetworkClientId: InfuraNetworkType.goerli }); + await controller.addToken({ address: '0x01', - symbol: 'baz', + symbol: 'bar', + decimals: 2, + networkClientId: 'goerli', + }); + + controller.ignoreTokens(['0x01'], 'goerli'); + expect( + controller.state.allTokens[ChainId.goerli][ + defaultMockInternalAccount.address + ], + ).toHaveLength(0); + + changeNetwork({ selectedNetworkClientId: InfuraNetworkType.sepolia }); + expect( + controller.state.allTokens[ChainId.sepolia][ + defaultMockInternalAccount.address + ][0], + ).toStrictEqual({ + address: '0x02', decimals: 2, + image: + 'https://static.cx.metamask.io/api/v1/tokenIcons/11155111/0x02.png', + symbol: 'baz', + isERC721: false, + aggregators: [], + name: undefined, + }); + }); + }); + + describe('ignoredTokens', () => { + it('should remove token from ignoredTokens/allIgnoredTokens lists if added back via addToken', async () => { + await withController(async ({ controller }) => { + await controller.addToken({ + address: '0x01', + symbol: 'foo', + decimals: 2, + networkClientId: 'mainnet', + }); + await controller.addToken({ + address: '0xFAa', + symbol: 'bar', + decimals: 3, + networkClientId: 'mainnet', + }); + + expect( + controller.state.allIgnoredTokens[ChainId.mainnet], + ).toBeUndefined(); + expect( + controller.state.allTokens[ChainId.mainnet][ + defaultMockInternalAccount.address + ], + ).toHaveLength(2); + + controller.ignoreTokens(['0x01'], 'mainnet'); + expect( + controller.state.allIgnoredTokens[ChainId.mainnet][ + defaultMockInternalAccount.address + ], + ).toHaveLength(1); + expect( + controller.state.allTokens[ChainId.mainnet][ + defaultMockInternalAccount.address + ], + ).toHaveLength(1); + + await controller.addToken({ + address: '0x01', + symbol: 'baz', + decimals: 2, + networkClientId: 'mainnet', + }); + expect( + controller.state.allTokens[ChainId.mainnet][ + defaultMockInternalAccount.address + ], + ).toHaveLength(2); + expect( + controller.state.allIgnoredTokens[ChainId.mainnet][ + defaultMockInternalAccount.address + ], + ).toHaveLength(0); }); - expect(tokensController.state.tokens).toHaveLength(2); - expect(tokensController.state.ignoredTokens).toHaveLength(0); }); it('should remove a token from the ignoredTokens/allIgnoredTokens lists if re-added as part of a bulk addTokens add', async () => { const selectedAddress = '0x0001'; - preferences.setSelectedAddress(selectedAddress); - changeNetwork(SEPOLIA); - await tokensController.addToken({ - address: '0x01', - symbol: 'bar', - decimals: 2, + const selectedAccount = createMockInternalAccount({ + address: selectedAddress, }); - await tokensController.addToken({ - address: '0xFAa', - symbol: 'bar', - decimals: 3, - }); - expect(tokensController.state.ignoredTokens).toHaveLength(0); - expect(tokensController.state.tokens).toHaveLength(2); - tokensController.ignoreTokens(['0x01']); - tokensController.ignoreTokens(['0xFAa']); - expect(tokensController.state.tokens).toHaveLength(0); - expect(tokensController.state.ignoredTokens).toHaveLength(2); - await tokensController.addTokens([ - { address: '0x01', decimals: 3, symbol: 'bar', aggregators: [] }, - { address: '0x02', decimals: 4, symbol: 'baz', aggregators: [] }, - { address: '0x04', decimals: 4, symbol: 'foo', aggregators: [] }, - ]); - expect(tokensController.state.tokens).toHaveLength(3); - expect(tokensController.state.ignoredTokens).toHaveLength(1); - expect(tokensController.state.allIgnoredTokens).toStrictEqual({ - [SEPOLIA.chainId]: { - [selectedAddress]: ['0xFAa'], + await withController( + { + mocks: { + getSelectedAccount: selectedAccount, + getAccount: selectedAccount, + }, }, - }); + async ({ controller, triggerSelectedAccountChange, changeNetwork }) => { + triggerSelectedAccountChange(selectedAccount); + changeNetwork({ selectedNetworkClientId: InfuraNetworkType.sepolia }); + await controller.addToken({ + address: '0x01', + symbol: 'bar', + decimals: 2, + networkClientId: 'sepolia', + }); + await controller.addToken({ + address: '0xFAa', + symbol: 'bar', + decimals: 3, + networkClientId: 'sepolia', + }); + + expect( + controller.state.allIgnoredTokens[ChainId.sepolia], + ).toBeUndefined(); + expect( + controller.state.allTokens[ChainId.sepolia][ + selectedAccount.address + ], + ).toHaveLength(2); + + controller.ignoreTokens(['0x01'], 'sepolia'); + controller.ignoreTokens(['0xFAa'], 'sepolia'); + + expect( + controller.state.allIgnoredTokens[ChainId.sepolia][ + selectedAccount.address + ], + ).toHaveLength(2); + expect( + controller.state.allTokens[ChainId.sepolia][ + selectedAccount.address + ], + ).toHaveLength(0); + + await controller.addTokens( + [ + { address: '0x01', decimals: 3, symbol: 'bar', aggregators: [] }, + { address: '0x02', decimals: 4, symbol: 'baz', aggregators: [] }, + { address: '0x04', decimals: 4, symbol: 'foo', aggregators: [] }, + ], + 'sepolia', + ); + expect( + controller.state.allTokens[ChainId.sepolia][ + selectedAccount.address + ], + ).toHaveLength(3); + expect( + controller.state.allIgnoredTokens[ChainId.sepolia][ + selectedAccount.address + ], + ).toHaveLength(1); + expect(controller.state.allIgnoredTokens).toStrictEqual({ + [ChainId.sepolia]: { + [selectedAddress]: ['0xFAa'], + }, + }); + }, + ); }); - it('should be able to clear the ignoredToken list', async () => { - await tokensController.addToken({ - address: '0x01', - symbol: 'bar', - decimals: 2, + it('should be able to clear the ignoredTokens list', async () => { + const selectedAddress = '0x0001'; + const selectedAccount = createMockInternalAccount({ + address: selectedAddress, }); - expect(tokensController.state.ignoredTokens).toHaveLength(0); - tokensController.ignoreTokens(['0x01']); - expect(tokensController.state.tokens).toHaveLength(0); - expect(tokensController.state.allIgnoredTokens).toStrictEqual({ - [SEPOLIA.chainId]: { - [defaultSelectedAddress]: ['0x01'], + await withController( + { + mocks: { + getSelectedAccount: selectedAccount, + getAccount: selectedAccount, + }, + }, + async ({ controller, triggerSelectedAccountChange, changeNetwork }) => { + triggerSelectedAccountChange(selectedAccount); + changeNetwork({ selectedNetworkClientId: InfuraNetworkType.sepolia }); + await controller.addToken({ + address: '0x01', + symbol: 'bar', + decimals: 2, + networkClientId: 'sepolia', + }); + expect( + controller.state.allIgnoredTokens[ChainId.sepolia], + ).toBeUndefined(); + + controller.ignoreTokens(['0x01'], 'sepolia'); + expect( + controller.state.allIgnoredTokens[ChainId.sepolia][ + selectedAccount.address + ], + ).toHaveLength(1); + expect(controller.state.allIgnoredTokens).toStrictEqual({ + [ChainId.sepolia]: { + [selectedAddress]: ['0x01'], + }, + }); + + controller.clearIgnoredTokens(); + expect( + controller.state.allIgnoredTokens[ChainId.sepolia], + ).toBeUndefined(); + expect(Object.keys(controller.state.allIgnoredTokens)).toHaveLength( + 0, + ); }, - }); - tokensController.clearIgnoredTokens(); - expect(tokensController.state.ignoredTokens).toHaveLength(0); - expect(Object.keys(tokensController.state.allIgnoredTokens)).toHaveLength( - 0, ); }); it('should ignore tokens by [chainID][accountAddress]', async () => { const selectedAddress1 = '0x0001'; + const selectedAccount1 = createMockInternalAccount({ + address: selectedAddress1, + }); const selectedAddress2 = '0x0002'; + const selectedAccount2 = createMockInternalAccount({ + address: selectedAddress2, + }); + await withController( + { + mocks: { + getSelectedAccount: selectedAccount1, + getAccount: selectedAccount1, + }, + }, + async ({ controller, triggerSelectedAccountChange, changeNetwork }) => { + triggerSelectedAccountChange(selectedAccount1); + changeNetwork({ selectedNetworkClientId: InfuraNetworkType.sepolia }); + await controller.addToken({ + address: '0x01', + symbol: 'bar', + decimals: 2, + networkClientId: 'sepolia', + }); + expect( + controller.state.allIgnoredTokens[ChainId.sepolia], + ).toBeUndefined(); + + controller.ignoreTokens(['0x01'], 'sepolia'); + expect( + controller.state.allIgnoredTokens[ChainId.sepolia][ + selectedAccount1.address + ], + ).toStrictEqual(['0x01']); + + changeNetwork({ selectedNetworkClientId: InfuraNetworkType.goerli }); + expect( + controller.state.allIgnoredTokens[ChainId.goerli], + ).toBeUndefined(); + + await controller.addToken({ + address: '0x02', + symbol: 'bazz', + decimals: 3, + networkClientId: 'goerli', + }); + controller.ignoreTokens(['0x02'], 'goerli'); + expect( + controller.state.allIgnoredTokens[ChainId.goerli][ + selectedAccount1.address + ], + ).toStrictEqual(['0x02']); + + triggerSelectedAccountChange(selectedAccount2); + expect( + controller.state.allIgnoredTokens[ChainId.goerli][ + selectedAccount2.address + ], + ).toBeUndefined(); + + await controller.addToken({ + address: '0x03', + symbol: 'foo', + decimals: 4, + networkClientId: 'goerli', + }); + controller.ignoreTokens(['0x03'], 'goerli'); + expect( + controller.state.allIgnoredTokens[ChainId.goerli][ + selectedAccount2.address + ], + ).toStrictEqual(['0x03']); + expect(controller.state.allIgnoredTokens).toStrictEqual({ + [ChainId.sepolia]: { + [selectedAddress1]: ['0x01'], + }, + [ChainId.goerli]: { + [selectedAddress1]: ['0x02'], + [selectedAddress2]: ['0x03'], + }, + }); + }, + ); + }); - preferences.setSelectedAddress(selectedAddress1); - changeNetwork(SEPOLIA); - - await tokensController.addToken({ - address: '0x01', - symbol: 'bar', - decimals: 2, + it('should ignore tokens by networkClientId', async () => { + const selectedAddress = '0x0001'; + const otherAddress = '0x0002'; + const selectedAccount = createMockInternalAccount({ + address: selectedAddress, + }); + const otherAccount = createMockInternalAccount({ + address: otherAddress, }); - expect(tokensController.state.ignoredTokens).toHaveLength(0); - tokensController.ignoreTokens(['0x01']); - expect(tokensController.state.tokens).toHaveLength(0); - expect(tokensController.state.ignoredTokens).toStrictEqual(['0x01']); - changeNetwork(GOERLI); + await withController( + { + mocks: { + getSelectedAccount: selectedAccount, + getAccount: selectedAccount, + }, + }, + async ({ controller, triggerSelectedAccountChange, changeNetwork }) => { + // Select the first account + triggerSelectedAccountChange(selectedAccount); + + // Add and ignore a token on Sepolia + changeNetwork({ selectedNetworkClientId: InfuraNetworkType.sepolia }); + await controller.addToken({ + address: '0x01', + symbol: 'Token1', + decimals: 18, + networkClientId: 'sepolia', + }); + expect( + controller.state.allTokens[ChainId.sepolia][ + selectedAccount.address + ], + ).toHaveLength(1); + expect( + controller.state.allIgnoredTokens[ChainId.sepolia], + ).toBeUndefined(); + + controller.ignoreTokens(['0x01'], InfuraNetworkType.sepolia); + expect( + controller.state.allIgnoredTokens[ChainId.sepolia][ + selectedAccount.address + ], + ).toStrictEqual(['0x01']); + + // Verify that Goerli network has no ignored tokens + changeNetwork({ selectedNetworkClientId: InfuraNetworkType.goerli }); + expect( + controller.state.allIgnoredTokens[ChainId.goerli], + ).toBeUndefined(); + + // Add and ignore a token on Goerli + await controller.addToken({ + address: '0x02', + symbol: 'Token2', + decimals: 8, + networkClientId: 'goerli', + }); + controller.ignoreTokens(['0x02'], InfuraNetworkType.goerli); + expect( + controller.state.allTokens[ChainId.goerli][selectedAccount.address], + ).toHaveLength(0); + expect( + controller.state.allIgnoredTokens[ChainId.goerli][ + selectedAccount.address + ], + ).toStrictEqual(['0x02']); + + // Verify that switching back to Sepolia retains its ignored tokens + changeNetwork({ selectedNetworkClientId: InfuraNetworkType.sepolia }); + expect( + controller.state.allIgnoredTokens[ChainId.sepolia][ + selectedAccount.address + ], + ).toStrictEqual(['0x01']); + + // Switch to a different account on Goerli + triggerSelectedAccountChange(otherAccount); + expect( + controller.state.allIgnoredTokens[ChainId.goerli][ + otherAccount.address + ], + ).toBeUndefined(); + + // Add and ignore a token on the new account + await controller.addToken({ + address: '0x03', + symbol: 'Token3', + decimals: 6, + networkClientId: 'goerli', + }); + controller.ignoreTokens(['0x03'], InfuraNetworkType.goerli); + expect( + controller.state.allIgnoredTokens[ChainId.goerli][ + otherAccount.address + ], + ).toStrictEqual(['0x03']); + + // Validate the overall ignored tokens state + expect(controller.state.allIgnoredTokens).toStrictEqual({ + [ChainId.sepolia]: { + [selectedAddress]: ['0x01'], + }, + [ChainId.goerli]: { + [selectedAddress]: ['0x02'], + [otherAddress]: ['0x03'], + }, + }); + }, + ); + }); - expect(tokensController.state.ignoredTokens).toHaveLength(0); - await tokensController.addToken({ - address: '0x02', - symbol: 'bazz', - decimals: 3, + it('should not update detectedTokens, tokens, and ignoredTokens state given a network that is different from the globally selected network', async () => { + const selectedAddress = '0x0001'; + const selectedAccount = createMockInternalAccount({ + address: selectedAddress, }); - tokensController.ignoreTokens(['0x02']); - expect(tokensController.state.ignoredTokens).toStrictEqual(['0x02']); - preferences.setSelectedAddress(selectedAddress2); - expect(tokensController.state.ignoredTokens).toHaveLength(0); - await tokensController.addToken({ - address: '0x03', - symbol: 'foo', - decimals: 4, + await withController( + { + mocks: { + getSelectedAccount: selectedAccount, + getAccount: selectedAccount, + }, + }, + async ({ controller, triggerSelectedAccountChange, changeNetwork }) => { + // Select the first account + triggerSelectedAccountChange(selectedAccount); + + // Add tokens to sepolia + changeNetwork({ selectedNetworkClientId: InfuraNetworkType.sepolia }); + await controller.addToken({ + address: '0x01', + symbol: 'Token1', + decimals: 18, + networkClientId: 'sepolia', + }); + expect( + controller.state.allTokens[ChainId.sepolia][ + selectedAccount.address + ], + ).toHaveLength(1); + expect( + controller.state.allIgnoredTokens[ChainId.sepolia], + ).toBeUndefined(); + + // switch to goerli + changeNetwork({ selectedNetworkClientId: InfuraNetworkType.goerli }); + + // Add tokens to goerli + await controller.addToken({ + address: '0x02', + symbol: 'Token2', + decimals: 8, + networkClientId: 'goerli', + }); + + expect( + controller.state.allTokens[ChainId.goerli][selectedAccount.address], + ).toHaveLength(1); + expect( + controller.state.allIgnoredTokens[ChainId.goerli], + ).toBeUndefined(); + + // ignore token on sepolia + controller.ignoreTokens(['0x01'], InfuraNetworkType.goerli); + + // as we are not on sepolia, tokens, ignoredTokens, and detectedTokens should not be affected + expect( + controller.state.allTokens[ChainId.sepolia][ + selectedAccount.address + ], + ).toHaveLength(1); + expect( + controller.state.allIgnoredTokens[ChainId.sepolia], + ).toBeUndefined(); + expect(Object.keys(controller.state.allDetectedTokens)).toHaveLength( + 0, + ); + }, + ); + }); + + it('should update tokens, and ignoredTokens and detectedTokens state for the globally selected network', async () => { + const selectedAddress = '0x0001'; + const selectedAccount = createMockInternalAccount({ + address: selectedAddress, }); - tokensController.ignoreTokens(['0x03']); - expect(tokensController.state.ignoredTokens).toStrictEqual(['0x03']); - expect(tokensController.state.allIgnoredTokens).toStrictEqual({ - [SEPOLIA.chainId]: { - [selectedAddress1]: ['0x01'], + await withController( + { + mocks: { + getSelectedAccount: selectedAccount, + getAccount: selectedAccount, + }, }, - [GOERLI.chainId]: { - [selectedAddress1]: ['0x02'], - [selectedAddress2]: ['0x03'], + async ({ controller, triggerSelectedAccountChange, changeNetwork }) => { + // Select the first account + triggerSelectedAccountChange(selectedAccount); + + // Set globally selected network to sepolia + changeNetwork({ selectedNetworkClientId: InfuraNetworkType.sepolia }); + + // Add a token to sepolia + await controller.addToken({ + address: '0x01', + symbol: 'Token1', + decimals: 18, + networkClientId: 'sepolia', + }); + // Add a detected token to sepolia + await controller.addDetectedTokens( + [{ address: '0x03', symbol: 'Token3', decimals: 18 }], + { + selectedAddress: '0x0001', + chainId: '0x1', + }, + ); + + expect( + controller.state.allTokens[ChainId.sepolia][ + selectedAccount.address + ], + ).toHaveLength(1); + expect( + controller.state.allIgnoredTokens[ChainId.sepolia], + ).toBeUndefined(); + + // Ignore the token on sepolia + controller.ignoreTokens(['0x01'], InfuraNetworkType.sepolia); + + // Ensure the tokens and ignoredTokens are updated for sepolia (globally selected network) + expect( + controller.state.allTokens[ChainId.sepolia][ + selectedAccount.address + ], + ).toHaveLength(0); + expect( + controller.state.allIgnoredTokens[ChainId.sepolia][ + selectedAccount.address + ], + ).toHaveLength(1); + expect(Object.keys(controller.state.allDetectedTokens)).toHaveLength( + 1, + ); }, + ); + }); + + it('should not retain ignored tokens from a different network', async () => { + const selectedAddress = '0x0001'; + const selectedAccount = createMockInternalAccount({ + address: selectedAddress, }); + + await withController( + { + mocks: { + getSelectedAccount: selectedAccount, + getAccount: selectedAccount, + }, + }, + async ({ controller, triggerSelectedAccountChange, changeNetwork }) => { + // Select the first account + triggerSelectedAccountChange(selectedAccount); + + // Add and ignore a token on Sepolia + changeNetwork({ selectedNetworkClientId: InfuraNetworkType.sepolia }); + await controller.addToken({ + address: '0x01', + symbol: 'Token1', + decimals: 18, + networkClientId: 'sepolia', + }); + expect( + controller.state.allTokens[ChainId.sepolia][ + selectedAccount.address + ], + ).toHaveLength(1); + expect( + controller.state.allIgnoredTokens[ChainId.sepolia], + ).toBeUndefined(); + + // Switch to Goerli network + changeNetwork({ selectedNetworkClientId: InfuraNetworkType.goerli }); + expect( + controller.state.allIgnoredTokens[ChainId.goerli], + ).toBeUndefined(); + + // Ignore the token on Sepolia + controller.ignoreTokens(['0x01'], InfuraNetworkType.sepolia); + expect( + controller.state.allTokens[ChainId.sepolia][ + selectedAccount.address + ], + ).toHaveLength(0); + expect( + controller.state.allIgnoredTokens[ChainId.sepolia][ + selectedAccount.address + ], + ).toStrictEqual(['0x01']); + + // Attempt to ignore a token that was added on Goerli + await controller.addToken({ + address: '0x02', + symbol: 'Token2', + decimals: 8, + networkClientId: 'goerli', + }); + controller.ignoreTokens(['0x02'], InfuraNetworkType.goerli); + expect( + controller.state.allTokens[ChainId.goerli][selectedAccount.address], + ).toHaveLength(0); + expect( + controller.state.allIgnoredTokens[ChainId.goerli][ + selectedAccount.address + ], + ).toStrictEqual(['0x02']); + + // Verify that the ignored tokens from Sepolia are not retained + expect( + controller.state.allIgnoredTokens[ChainId.sepolia][ + selectedAccount.address + ], + ).toHaveLength(1); + expect( + controller.state.allIgnoredTokens[ChainId.sepolia][ + selectedAccount.address + ], + ).toStrictEqual(['0x01']); + expect(controller.state.allIgnoredTokens).toStrictEqual({ + [ChainId.sepolia]: { + [selectedAddress]: ['0x01'], + }, + [ChainId.goerli]: { + [selectedAddress]: ['0x02'], + }, + }); + + // Switch back to Sepolia and check ignored tokens + changeNetwork({ selectedNetworkClientId: InfuraNetworkType.sepolia }); + expect( + controller.state.allIgnoredTokens[ChainId.sepolia][ + selectedAccount.address + ], + ).toStrictEqual(['0x01']); + }, + ); }); }); it('should ignore multiple tokens with single ignoreTokens call', async () => { - const stub = stubCreateEthers(tokensController, false); - await tokensController.addToken({ - address: '0x01', - symbol: 'A', - decimals: 4, - }); - await tokensController.addToken({ - address: '0x02', - symbol: 'B', - decimals: 5, - }); - expect(tokensController.state.tokens).toStrictEqual([ - { + await withController(async ({ controller }) => { + ContractMock.mockReturnValue( + buildMockEthersERC721Contract({ supportsInterface: false }), + ); + + await controller.addToken({ address: '0x01', - decimals: 4, - image: - 'https://static.metafi.codefi.network/api/v1/tokenIcons/1/0x01.png', - isERC721: false, symbol: 'A', - aggregators: [], - name: undefined, - }, - { + decimals: 4, + networkClientId: 'mainnet', + }); + await controller.addToken({ address: '0x02', - decimals: 5, - image: - 'https://static.metafi.codefi.network/api/v1/tokenIcons/1/0x02.png', - isERC721: false, symbol: 'B', - aggregators: [], - name: undefined, - }, - ]); + decimals: 5, + networkClientId: 'mainnet', + }); + expect( + controller.state.allTokens[ChainId.mainnet][ + defaultMockInternalAccount.address + ], + ).toStrictEqual([ + { + address: '0x01', + decimals: 4, + image: 'https://static.cx.metamask.io/api/v1/tokenIcons/1/0x01.png', + isERC721: false, + symbol: 'A', + aggregators: [], + name: undefined, + }, + { + address: '0x02', + decimals: 5, + image: 'https://static.cx.metamask.io/api/v1/tokenIcons/1/0x02.png', + isERC721: false, + symbol: 'B', + aggregators: [], + name: undefined, + }, + ]); + + controller.ignoreTokens(['0x01', '0x02'], 'mainnet'); + + expect( + controller.state.allTokens[ChainId.mainnet][ + defaultMockInternalAccount.address + ], + ).toStrictEqual([]); + }); + }); + + describe('isERC721 flag', () => { + describe('updateTokenType method', () => { + it('should add isERC721 = true to token object already in state when token is NFT and in our contract-metadata repo', async () => { + await withController(async ({ controller }) => { + const contractAddresses = Object.keys(contractMaps); + const erc721ContractAddresses = contractAddresses.filter( + (contractAddress) => contractMaps[contractAddress].erc721 === true, + ); + const address = erc721ContractAddresses[0]; + const { symbol, decimals } = contractMaps[address]; + + await controller.addToken({ + address, + symbol, + decimals, + networkClientId: 'mainnet', + }); + + const result = await controller.updateTokenType(address, 'mainnet'); + expect(result.isERC721).toBe(true); + }); + }); + + it('should add isERC721 = false to token object already in state when token is not an NFT and is in our contract-metadata repo', async () => { + await withController(async ({ controller }) => { + const contractAddresses = Object.keys(contractMaps); + const erc20ContractAddresses = contractAddresses.filter( + (contractAddress) => contractMaps[contractAddress].erc20 === true, + ); + const address = erc20ContractAddresses[0]; + const { symbol, decimals } = contractMaps[address]; + + await controller.addToken({ + address, + symbol, + decimals, + networkClientId: 'mainnet', + }); + + const result = await controller.updateTokenType(address, 'mainnet'); + expect(result.isERC721).toBe(false); + }); + }); + + it('should add isERC721 = true to token object already in state when token is NFT and is not in our contract-metadata repo', async () => { + await withController(async ({ controller }) => { + ContractMock.mockReturnValue( + buildMockEthersERC721Contract({ supportsInterface: true }), + ); + const tokenAddress = '0xda5584cc586d07c7141aa427224a4bd58e64af7d'; + + await controller.addToken({ + address: tokenAddress, + symbol: 'TESTNFT', + decimals: 0, + networkClientId: 'mainnet', + }); + + const result = await controller.updateTokenType( + tokenAddress, + 'mainnet', + ); + expect(result.isERC721).toBe(true); + }); + }); + + it('should add isERC721 = false to token object already in state when token is not an NFT and not in our contract-metadata repo', async () => { + await withController(async ({ controller }) => { + ContractMock.mockReturnValue( + buildMockEthersERC721Contract({ supportsInterface: false }), + ); + const tokenAddress = '0xda5584cc586d07c7141aa427224a4bd58e64af7d'; + + await controller.addToken({ + address: tokenAddress, + symbol: 'TESTNFT', + decimals: 0, + networkClientId: 'mainnet', + }); + + const result = await controller.updateTokenType( + tokenAddress, + 'mainnet', + ); + expect(result.isERC721).toBe(false); + }); + }); + }); + + describe('addToken method', () => { + it('should add isERC721 = true when token is an NFT and is in our contract-metadata repo', async () => { + await withController(async ({ controller }) => { + const contractAddresses = Object.keys(contractMaps); + const erc721ContractAddresses = contractAddresses.filter( + (contractAddress) => contractMaps[contractAddress].erc721 === true, + ); + const address = erc721ContractAddresses[0]; + const { symbol, decimals } = contractMaps[address]; + + await controller.addToken({ + address, + symbol, + decimals, + networkClientId: 'mainnet', + }); + + expect( + controller.state.allTokens[ChainId.mainnet][ + defaultMockInternalAccount.address + ], + ).toStrictEqual([ + expect.objectContaining({ + address, + symbol, + isERC721: true, + decimals, + }), + ]); + }); + }); + + it('should add isERC721 = true when the token is an NFT but not in our contract-metadata repo', async () => { + await withController(async ({ controller }) => { + ContractMock.mockReturnValue( + buildMockEthersERC721Contract({ supportsInterface: true }), + ); + const tokenAddress = '0xDA5584Cc586d07c7141aA427224A4Bd58E64aF7D'; + + await controller.addToken({ + address: tokenAddress, + symbol: 'REST', + decimals: 4, + networkClientId: 'mainnet', + }); + + expect( + controller.state.allTokens[ChainId.mainnet][ + defaultMockInternalAccount.address + ], + ).toStrictEqual([ + { + address: tokenAddress, + symbol: 'REST', + isERC721: true, + image: + 'https://static.cx.metamask.io/api/v1/tokenIcons/1/0xda5584cc586d07c7141aa427224a4bd58e64af7d.png', + decimals: 4, + aggregators: [], + name: undefined, + }, + ]); + }); + }); + + it('should add isERC721 = false to token object already in state when token is not an NFT and in our contract-metadata repo', async () => { + await withController(async ({ controller }) => { + const contractAddresses = Object.keys(contractMaps); + const erc20ContractAddresses = contractAddresses.filter( + (contractAddress) => contractMaps[contractAddress].erc20 === true, + ); + const address = erc20ContractAddresses[0]; + const { symbol, decimals } = contractMaps[address]; + + await controller.addToken({ + address, + symbol, + decimals, + networkClientId: 'mainnet', + }); + + expect( + controller.state.allTokens[ChainId.mainnet][ + defaultMockInternalAccount.address + ], + ).toStrictEqual([ + expect.objectContaining({ + address, + symbol, + isERC721: false, + decimals, + }), + ]); + }); + }); + + it('should add isERC721 = false when the token is not an NFT and not in our contract-metadata repo', async () => { + await withController(async ({ controller }) => { + ContractMock.mockReturnValue( + buildMockEthersERC721Contract({ supportsInterface: false }), + ); + const tokenAddress = '0xDA5584Cc586d07c7141aA427224A4Bd58E64aF7D'; + + await controller.addToken({ + address: tokenAddress, + symbol: 'LEST', + decimals: 5, + networkClientId: 'mainnet', + }); + + expect( + controller.state.allTokens[ChainId.mainnet][ + defaultMockInternalAccount.address + ], + ).toStrictEqual([ + { + address: tokenAddress, + symbol: 'LEST', + isERC721: false, + image: + 'https://static.cx.metamask.io/api/v1/tokenIcons/1/0xda5584cc586d07c7141aa427224a4bd58e64af7d.png', + decimals: 5, + aggregators: [], + name: undefined, + }, + ]); + }); + }); + }); + + it('should throw TokenService error if fetchTokenMetadata returns a response with an error', async () => { + const chainId = ChainId.mainnet; + + await withController( + { + options: { + chainId, + }, + }, + async ({ controller }) => { + const dummyTokenAddress = + '0x514910771AF9Ca656af840dff83E8264EcF986CA'; + const error = 'An error occured'; + const fullErrorMessage = `TokenService Error: ${error}`; + nock(TOKEN_END_POINT_API) + .get( + `/token/${convertHexToDecimal( + chainId, + )}?address=${dummyTokenAddress}&includeRwaData=true`, + ) + .reply(200, { error }) + .persist(); + + await expect( + controller.addToken({ + address: dummyTokenAddress, + symbol: 'LINK', + decimals: 18, + networkClientId: 'mainnet', + }), + ).rejects.toThrow(fullErrorMessage); + }, + ); + }); + + it('should add token that was previously a detected token', async () => { + await withController(async ({ controller }) => { + ContractMock.mockReturnValue( + buildMockEthersERC721Contract({ supportsInterface: false }), + ); + const dummyDetectedToken: Token = { + address: '0x01', + symbol: 'barA', + decimals: 2, + aggregators: [], + image: undefined, + isERC721: false, + name: undefined, + }; + const dummyAddedToken: Token = { + ...dummyDetectedToken, + image: 'https://static.cx.metamask.io/api/v1/tokenIcons/1/0x01.png', + }; + + await controller.addDetectedTokens([dummyDetectedToken], { + selectedAddress: defaultMockInternalAccount.address, + chainId: ChainId.mainnet, + }); + expect( + controller.state.allDetectedTokens[ChainId.mainnet][ + defaultMockInternalAccount.address + ], + ).toStrictEqual([dummyDetectedToken]); + + await controller.addToken({ + address: dummyDetectedToken.address, + symbol: dummyDetectedToken.symbol, + decimals: dummyDetectedToken.decimals, + networkClientId: 'mainnet', + }); + expect( + controller.state.allDetectedTokens[ChainId.mainnet][ + defaultMockInternalAccount.address + ], + ).toStrictEqual([]); + expect( + controller.state.allTokens[ChainId.mainnet][ + defaultMockInternalAccount.address + ], + ).toStrictEqual([dummyAddedToken]); + }); + }); + + it('should add tokens to the correct chainId/selectedAddress on which they were detected even if its not the currently configured chainId/selectedAddress', async () => { + const CONFIGURED_ADDRESS = '0xConfiguredAddress'; + const configuredAccount = createMockInternalAccount({ + address: CONFIGURED_ADDRESS, + }); + await withController( + { + mocks: { + getAccount: configuredAccount, + }, + }, + async ({ controller, changeNetwork, triggerSelectedAccountChange }) => { + ContractMock.mockReturnValue( + buildMockEthersERC721Contract({ supportsInterface: false }), + ); + + // The currently configured chain + address + const CONFIGURED_CHAIN = ChainId.sepolia; + const CONFIGURED_NETWORK_CLIENT_ID = InfuraNetworkType.sepolia; + + changeNetwork({ + selectedNetworkClientId: CONFIGURED_NETWORK_CLIENT_ID, + }); + triggerSelectedAccountChange(configuredAccount); + + // A different chain + address + const OTHER_CHAIN = '0xOtherChainId'; + const OTHER_ADDRESS = '0xOtherAddress'; + + // Mock some tokens to add + const generateTokens = (len: number) => + [...Array(len)].map((_, i) => ({ + address: `0x${i}`, + symbol: String.fromCharCode(65 + i), + decimals: 2, + aggregators: [], + name: undefined, + isERC721: false, + image: `https://static.cx.metamask.io/api/v1/tokenIcons/11155111/0x${i}.png`, + })); + + const [ + addedTokenConfiguredAccount, + detectedTokenConfiguredAccount, + detectedTokenOtherAccount, + ] = generateTokens(3); + + // Run twice to ensure idempotency + for (let i = 0; i < 2; i++) { + // Add and detect some tokens on the configured chain + account + await controller.addToken({ + ...addedTokenConfiguredAccount, + networkClientId: CONFIGURED_NETWORK_CLIENT_ID, + }); + await controller.addDetectedTokens( + [detectedTokenConfiguredAccount], + { + selectedAddress: CONFIGURED_ADDRESS, + chainId: CONFIGURED_CHAIN, + }, + ); + + // Detect a token on the other chain + account + await controller.addDetectedTokens([detectedTokenOtherAccount], { + selectedAddress: OTHER_ADDRESS, + chainId: OTHER_CHAIN, + }); + + // Expect tokens on the configured account + expect( + controller.state.allTokens[CONFIGURED_CHAIN][CONFIGURED_ADDRESS], + ).toStrictEqual([addedTokenConfiguredAccount]); + expect( + controller.state.allDetectedTokens[CONFIGURED_CHAIN][ + CONFIGURED_ADDRESS + ], + ).toStrictEqual([detectedTokenConfiguredAccount]); + + // Expect tokens under the correct chain + account + expect(controller.state.allTokens).toStrictEqual({ + [CONFIGURED_CHAIN]: { + [CONFIGURED_ADDRESS]: [addedTokenConfiguredAccount], + }, + }); + expect(controller.state.allDetectedTokens).toStrictEqual({ + [CONFIGURED_CHAIN]: { + [CONFIGURED_ADDRESS]: [detectedTokenConfiguredAccount], + }, + [OTHER_CHAIN]: { + [OTHER_ADDRESS]: [detectedTokenOtherAccount], + }, + }); + } + }, + ); + }); + }); + + describe('addTokens method', () => { + it('should add tokens that were previously detected tokens', async () => { + await withController(async ({ controller }) => { + const dummyAddedTokens: Token[] = [ + { + address: '0x01', + symbol: 'barA', + decimals: 2, + aggregators: [], + image: undefined, + name: undefined, + }, + { + address: '0x02', + symbol: 'barB', + decimals: 2, + aggregators: [], + image: undefined, + name: undefined, + }, + ]; + const dummyDetectedTokens: Token[] = [ + { + ...dummyAddedTokens[0], + isERC721: false, + }, + { + ...dummyAddedTokens[1], + isERC721: false, + }, + ]; + + await controller.addDetectedTokens(dummyDetectedTokens, { + selectedAddress: defaultMockInternalAccount.address, + chainId: ChainId.mainnet, + }); + expect( + controller.state.allDetectedTokens[ChainId.mainnet][ + defaultMockInternalAccount.address + ], + ).toStrictEqual(dummyDetectedTokens); + + await controller.addTokens(dummyDetectedTokens, 'mainnet'); + expect( + controller.state.allDetectedTokens[ChainId.mainnet][ + defaultMockInternalAccount.address + ], + ).toStrictEqual([]); + expect( + controller.state.allTokens[ChainId.mainnet][ + defaultMockInternalAccount.address + ], + ).toStrictEqual(dummyAddedTokens); + }); + }); + + it('should add tokens to the correct chainId when passed a networkClientId', async () => { + await withController( + { + mockNetworkClientConfigurationsByNetworkClientId: { + networkClientId1: buildCustomNetworkClientConfiguration({ + chainId: '0x5', + }), + }, + }, + async ({ controller, changeNetwork }) => { + changeNetwork({ selectedNetworkClientId: InfuraNetworkType.goerli }); + + const dummyTokens: Token[] = [ + { + address: '0x01', + symbol: 'barA', + decimals: 2, + aggregators: [], + image: undefined, + name: undefined, + }, + { + address: '0x02', + symbol: 'barB', + decimals: 2, + aggregators: [], + image: undefined, + name: undefined, + }, + ]; + + await controller.addTokens(dummyTokens, 'goerli'); + + expect( + controller.state.allTokens[ChainId.goerli][ + defaultMockInternalAccount.address + ], + ).toStrictEqual(dummyTokens); + }, + ); + }); + + it('overwrites rwaData when re-adding tokens via addTokens', async () => { + await withController(async ({ controller }) => { + const existingRwaData: TokenRwaData = { + ticker: 'OLD', + }; + const updatedRwaData: TokenRwaData = { + ticker: 'NEW', + }; + + await controller.addTokens( + [ + { + address: '0x01', + symbol: 'bar', + decimals: 2, + aggregators: [], + image: undefined, + name: undefined, + rwaData: existingRwaData, + }, + ], + 'mainnet', + ); + + await controller.addTokens( + [ + { + address: '0x01', + symbol: 'bar', + decimals: 2, + aggregators: [], + image: undefined, + name: undefined, + rwaData: updatedRwaData, + }, + ], + 'mainnet', + ); + + expect( + controller.state.allTokens[ChainId.mainnet][ + defaultMockInternalAccount.address + ], + ).toStrictEqual([ + { + address: '0x01', + symbol: 'bar', + decimals: 2, + aggregators: [], + image: undefined, + name: undefined, + rwaData: updatedRwaData, + }, + ]); + }); + }); + + it('clears rwaData when re-adding tokens without rwaData', async () => { + await withController(async ({ controller }) => { + const existingRwaData: TokenRwaData = { + ticker: 'OLD', + }; + + await controller.addTokens( + [ + { + address: '0x01', + symbol: 'bar', + decimals: 2, + aggregators: [], + image: undefined, + name: undefined, + rwaData: existingRwaData, + }, + ], + 'mainnet', + ); + + await controller.addTokens( + [ + { + address: '0x01', + symbol: 'bar', + decimals: 2, + aggregators: [], + image: undefined, + name: undefined, + }, + ], + 'mainnet', + ); + + expect( + controller.state.allTokens[ChainId.mainnet][ + defaultMockInternalAccount.address + ], + ).toStrictEqual([ + { + address: '0x01', + symbol: 'bar', + decimals: 2, + aggregators: [], + image: undefined, + name: undefined, + }, + ]); + }); + }); + }); + + describe('watchAsset', () => { + it('should error if passed no type', async () => { + await withController(async ({ controller }) => { + const result = controller.watchAsset({ + asset: buildToken(), + // @ts-expect-error Intentionally passing invalid input + type: undefined, + }); + + await expect(result).rejects.toThrow( + 'Asset of type undefined not supported', + ); + }); + }); + + it('should error if asset type is not supported', async () => { + await withController(async ({ controller }) => { + const result = controller.watchAsset({ + asset: buildToken(), + type: 'ERC721', + networkClientId: 'networkClientId1', + }); + + await expect(result).rejects.toThrow( + 'Asset of type ERC721 not supported', + ); + }); + }); + + it('should error if the contract is ERC721', async () => { + await withController(async ({ controller }) => { + ContractMock.mockReturnValue( + buildMockEthersERC721Contract({ supportsInterface: true }), + ); + + const result = controller.watchAsset({ + asset: buildToken({ + address: '0x0000000000000000000000000000000000000001', + }), + type: 'ERC20', + networkClientId: 'mainnet', + }); + + await expect(result).rejects.toThrow( + 'Contract 0x0000000000000000000000000000000000000001 must match type ERC20, but was detected as ERC721', + ); + }); + }); + + it('should error if the contract is ERC1155', async () => { + await withController(async ({ controller }) => { + ContractMock.mockReturnValue( + buildMockEthersERC721Contract({ supportsInterface: false }), + ); + ERC1155StandardMock.mockReturnValue( + buildMockERC1155Standard({ contractSupportsBase1155Interface: true }), + ); + + const result = controller.watchAsset({ + asset: buildToken({ + address: '0x0000000000000000000000000000000000000001', + }), + type: 'ERC20', + networkClientId: 'mainnet', + }); + + await expect(result).rejects.toThrow( + 'Contract 0x0000000000000000000000000000000000000001 must match type ERC20, but was detected as ERC1155', + ); + }); + }); + + it('should error if address is not defined', async () => { + await withController(async ({ controller }) => { + const result = controller.watchAsset({ + asset: buildToken({ address: undefined }), + type: 'ERC20', + networkClientId: 'mainnet', + }); + + await expect(result).rejects.toThrow('Address must be specified'); + }); + }); + + it('should error if decimals is not defined', async () => { + await withController(async ({ controller }) => { + ContractMock.mockReturnValue( + buildMockEthersERC721Contract({ supportsInterface: false }), + ); + + const result = controller.watchAsset({ + asset: buildToken({ decimals: undefined }), + type: 'ERC20', + networkClientId: 'mainnet', + }); + + await expect(result).rejects.toThrow( + 'Decimals are required, but were not found in either the request or contract', + ); + }); + }); + + it('should error if symbol is not defined', async () => { + await withController(async ({ controller }) => { + ContractMock.mockReturnValue( + buildMockEthersERC721Contract({ supportsInterface: false }), + ); + + const result = controller.watchAsset({ + // @ts-expect-error Intentionally passing bad input + asset: buildToken({ symbol: { foo: 'bar' } }), + type: 'ERC20', + networkClientId: 'mainnet', + }); + + await expect(result).rejects.toThrow('Invalid symbol: not a string'); + }); + }); + + it('should error if symbol is not a string', async () => { + await withController(async ({ controller }) => { + ContractMock.mockReturnValue( + buildMockEthersERC721Contract({ supportsInterface: false }), + ); + + const result = controller.watchAsset({ + asset: buildToken({ symbol: undefined }), + type: 'ERC20', + networkClientId: 'mainnet', + }); + + await expect(result).rejects.toThrow( + 'A symbol is required, but was not found in either the request or contract', + ); + }); + }); + + it('should error if symbol is empty', async () => { + await withController(async ({ controller }) => { + ContractMock.mockReturnValue( + buildMockEthersERC721Contract({ supportsInterface: false }), + ); + + const result = controller.watchAsset({ + asset: buildToken({ symbol: '' }), + type: 'ERC20', + networkClientId: 'mainnet', + }); + + await expect(result).rejects.toThrow( + 'A symbol is required, but was not found in either the request or contract', + ); + }); + }); + + it('should error if symbol is too long', async () => { + await withController(async ({ controller }) => { + ContractMock.mockReturnValue( + buildMockEthersERC721Contract({ supportsInterface: false }), + ); + + const result = controller.watchAsset({ + asset: buildToken({ symbol: 'ABCDEFGHIJKLM' }), + type: 'ERC20', + networkClientId: 'mainnet', + }); + + await expect(result).rejects.toThrow( + 'Invalid symbol "ABCDEFGHIJKLM": longer than 11 characters', + ); + }); + }); + + it('should error if decimals is invalid', async () => { + await withController(async ({ controller }) => { + ContractMock.mockReturnValue( + buildMockEthersERC721Contract({ supportsInterface: false }), + ); + + const result = controller.watchAsset({ + asset: buildToken({ decimals: -1 }), + type: 'ERC20', + networkClientId: 'mainnet', + }); + await expect(result).rejects.toThrow( + 'Invalid decimals "-1": must be an integer 0 <= 36', + ); + + const result2 = controller.watchAsset({ + asset: buildToken({ decimals: 37 }), + type: 'ERC20', + networkClientId: 'mainnet', + }); + await expect(result2).rejects.toThrow( + 'Invalid decimals "37": must be an integer 0 <= 36', + ); + }); + }); + + it('should error if address is invalid', async () => { + await withController(async ({ controller }) => { + const result = controller.watchAsset({ + asset: buildToken({ address: '0x123' }), + type: 'ERC20', + networkClientId: 'mainnet', + }); + + await expect(result).rejects.toThrow('Invalid address "0x123"'); + }); + }); + + it('fails with an invalid type suggested', async () => { + await withController(async ({ controller }) => { + const result = controller.watchAsset({ + asset: buildToken({ + address: '0xe9f786dfdd9ae4d57e830acb52296837765f0e5b', + decimals: 18, + symbol: 'TKN', + }), + type: 'ERC721', + networkClientId: 'mainnet', + }); + + await expect(result).rejects.toThrow( + 'Asset of type ERC721 not supported', + ); + }); + }); + + it("should error if the asset's symbol doesn't match the contract", async () => { + await withController(async ({ controller }) => { + ContractMock.mockReturnValue( + buildMockEthersERC721Contract({ supportsInterface: false }), + ); + ERC20StandardMock.mockReturnValue( + buildMockERC20Standard({ + tokenName: 'Some Token', + tokenSymbol: 'TOKEN', + tokenDecimals: '42', + }), + ); + + const result = controller.watchAsset({ + asset: buildToken({ + name: 'Some Token', + symbol: 'OTHER', + decimals: 42, + }), + type: 'ERC20', + networkClientId: 'mainnet', + }); - tokensController.ignoreTokens(['0x01', '0x02']); - expect(tokensController.state.tokens).toStrictEqual([]); - stub.restore(); - }); + await expect(result).rejects.toThrow( + 'The symbol in the request (OTHER) does not match the symbol in the contract (TOKEN)', + ); + }); + }); - describe('isERC721 flag', function () { - describe('updateTokenType method', function () { - it('should add isERC721 = true to token object already in state when token is NFT and in our contract-metadata repo', async function () { - const contractAddresses = Object.keys(contractMaps); - const erc721ContractAddresses = contractAddresses.filter( - (contractAddress) => contractMaps[contractAddress].erc721 === true, + it("should error if the asset's decimals don't match the contract", async () => { + await withController(async ({ controller }) => { + ContractMock.mockReturnValue( + buildMockEthersERC721Contract({ supportsInterface: false }), + ); + ERC20StandardMock.mockReturnValue( + buildMockERC20Standard({ + tokenName: 'Some Token', + tokenSymbol: 'TOKEN', + tokenDecimals: '42', + }), ); - const address = erc721ContractAddresses[0]; - const { symbol, decimals } = contractMaps[address]; - tokensController.update({ - tokens: [{ address, symbol, decimals }], + + const result = controller.watchAsset({ + asset: buildToken({ + name: 'Some Token', + symbol: 'TOKEN', + decimals: 1, + }), + type: 'ERC20', + networkClientId: 'mainnet', }); - const result = await tokensController.updateTokenType(address); - expect(result.isERC721).toBe(true); + + await expect(result).rejects.toThrow( + 'The decimals in the request (1) do not match the decimals in the contract (42)', + ); }); + }); - it('should add isERC721 = false to token object already in state when token is not an NFT and is in our contract-metadata repo', async function () { - const contractAddresses = Object.keys(contractMaps); - const erc20ContractAddresses = contractAddresses.filter( - (contractAddress) => contractMaps[contractAddress].erc20 === true, + it('should use symbols/decimals from contract, and allow them to be optional in the request', async () => { + await withController(async ({ controller, approvalController }) => { + const asset = buildTokenWithName(); + ContractMock.mockReturnValue( + buildMockEthersERC721Contract({ supportsInterface: false }), + ); + ERC20StandardMock.mockReturnValue( + buildMockERC20StandardFromToken(asset), ); - const address = erc20ContractAddresses[0]; - const { symbol, decimals } = contractMaps[address]; - tokensController.update({ - tokens: [{ address, symbol, decimals }], + jest + .spyOn(approvalController, 'addAndShowApprovalRequest') + .mockResolvedValue(undefined); + + await controller.watchAsset({ + // @ts-expect-error Intentionally passing bad input. + asset: { ...asset, symbol: undefined, decimals: undefined }, + type: 'ERC20', + networkClientId: 'mainnet', }); - const result = await tokensController.updateTokenType(address); - expect(result.isERC721).toBe(false); - }); - it('should add isERC721 = true to token object already in state when token is NFT and is not in our contract-metadata repo', async function () { - const stub = stubCreateEthers(tokensController, true); - const tokenAddress = '0xda5584cc586d07c7141aa427224a4bd58e64af7d'; - tokensController.update({ - tokens: [ - { - address: tokenAddress, - symbol: 'TESTNFT', - decimals: 0, - }, + expect( + controller.state.allTokens[ChainId.mainnet][ + defaultMockInternalAccount.address ], - }); + ).toStrictEqual([ + { + isERC721: false, + aggregators: [], + ...asset, + }, + ]); + }); + }); - const result = await tokensController.updateTokenType(tokenAddress); + it('should use symbols/decimals from request, and allow them to be optional in the contract', async () => { + await withController(async ({ controller, approvalController }) => { + const reqAsset = buildToken({ symbol: 'MYSYMBOL', decimals: 13 }); + ContractMock.mockReturnValue( + buildMockEthersERC721Contract({ supportsInterface: false }), + ); + jest + .spyOn(approvalController, 'addAndShowApprovalRequest') + .mockResolvedValue(undefined); + + await controller.watchAsset({ + asset: reqAsset, + type: 'ERC20', + networkClientId: 'mainnet', + }); - expect(result.isERC721).toBe(true); - stub.restore(); + expect( + controller.state.allTokens[ChainId.mainnet][ + defaultMockInternalAccount.address + ], + ).toStrictEqual([ + { + isERC721: false, + aggregators: [], + ...reqAsset, + }, + ]); }); + }); - it('should add isERC721 = false to token object already in state when token is not an NFT and not in our contract-metadata repo', async function () { - const stub = stubCreateEthers(tokensController, false); - const tokenAddress = '0xda5584cc586d07c7141aa427224a4bd58e64af7d'; - tokensController.update({ - tokens: [ - { - address: tokenAddress, - symbol: 'TESTNFT', - decimals: 0, - }, - ], - }); + it("should validate that symbol matches if it's defined in both the request and contract", async () => { + await withController(async ({ controller }) => { + const asset = buildTokenWithName({ symbol: 'SES' }); + ContractMock.mockReturnValue( + buildMockEthersERC721Contract({ supportsInterface: false }), + ); + ERC20StandardMock.mockReturnValue( + buildMockERC20StandardFromToken(asset), + ); - const result = await tokensController.updateTokenType(tokenAddress); + const result = controller.watchAsset({ + asset: { ...asset, symbol: 'DIFFERENT' }, + type: 'ERC20', + networkClientId: 'mainnet', + }); - expect(result.isERC721).toBe(false); - stub.restore(); + await expect(result).rejects.toThrow( + 'The symbol in the request (DIFFERENT) does not match the symbol in the contract (SES)', + ); }); }); - describe('addToken method', function () { - it('should add isERC721 = true when token is an NFT and is in our contract-metadata repo', async function () { - const contractAddresses = Object.keys(contractMaps); - const erc721ContractAddresses = contractAddresses.filter( - (contractAddress) => contractMaps[contractAddress].erc721 === true, + it("should validate that decimals match if they're defined in both the request and contract", async () => { + await withController(async ({ controller }) => { + const asset = buildTokenWithName({ decimals: 12 }); + ContractMock.mockReturnValue( + buildMockEthersERC721Contract({ supportsInterface: false }), + ); + ERC20StandardMock.mockReturnValue( + buildMockERC20StandardFromToken(asset), ); - const address = erc721ContractAddresses[0]; - const { symbol, decimals } = contractMaps[address]; - await tokensController.addToken({ address, symbol, decimals }); - expect(tokensController.state.tokens).toStrictEqual([ - expect.objectContaining({ - address, - symbol, - isERC721: true, - decimals, - }), - ]); - }); + const result = controller.watchAsset({ + asset: { ...asset, decimals: 2 }, + type: 'ERC20', + networkClientId: 'mainnet', + }); - it('should add isERC721 = true when the token is an NFT but not in our contract-metadata repo', async function () { - const stub = stubCreateEthers(tokensController, true); - const tokenAddress = '0xDA5584Cc586d07c7141aA427224A4Bd58E64aF7D'; + await expect(result).rejects.toThrow( + 'The decimals in the request (2) do not match the decimals in the contract (12)', + ); + }); + }); - await tokensController.addToken({ - address: tokenAddress, - symbol: 'REST', - decimals: 4, + it('should perform case insensitive validation of symbols', async () => { + await withController(async ({ controller, approvalController }) => { + const asset = buildTokenWithName({ symbol: 'ABC' }); + ContractMock.mockReturnValue( + buildMockEthersERC721Contract({ supportsInterface: false }), + ); + ERC20StandardMock.mockReturnValue( + buildMockERC20StandardFromToken(asset), + ); + jest + .spyOn(approvalController, 'addAndShowApprovalRequest') + .mockResolvedValue(undefined); + + await controller.watchAsset({ + asset: { ...asset, symbol: 'abc' }, + type: 'ERC20', + networkClientId: 'mainnet', }); - expect(tokensController.state.tokens).toStrictEqual([ + expect( + controller.state.allTokens[ChainId.mainnet][ + defaultMockInternalAccount.address + ], + ).toStrictEqual([ { - address: tokenAddress, - symbol: 'REST', - isERC721: true, - image: - 'https://static.metafi.codefi.network/api/v1/tokenIcons/1/0xda5584cc586d07c7141aa427224a4bd58e64af7d.png', - decimals: 4, + isERC721: false, aggregators: [], - name: undefined, + ...asset, }, ]); - - stub.restore(); }); + }); - it('should add isERC721 = false to token object already in state when token is not an NFT and in our contract-metadata repo', async function () { - const contractAddresses = Object.keys(contractMaps); - const erc20ContractAddresses = contractAddresses.filter( - (contractAddress) => contractMaps[contractAddress].erc20 === true, + it('converts decimals from string to integer', async () => { + await withController(async ({ controller, approvalController }) => { + // @ts-expect-error Intentionally using a string for decimals + const asset = buildTokenWithName({ decimals: '6' }); + ContractMock.mockReturnValue( + buildMockEthersERC721Contract({ supportsInterface: false }), ); - const address = erc20ContractAddresses[0]; - const { symbol, decimals } = contractMaps[address]; - - await tokensController.addToken({ address, symbol, decimals }); + ERC20StandardMock.mockReturnValue( + buildMockERC20StandardFromToken(asset), + ); + jest + .spyOn(approvalController, 'addAndShowApprovalRequest') + .mockResolvedValue(undefined); + + await controller.watchAsset({ + asset, + type: 'ERC20', + networkClientId: 'mainnet', + }); - expect(tokensController.state.tokens).toStrictEqual([ - expect.objectContaining({ - address, - symbol, + expect( + controller.state.allTokens[ChainId.mainnet][ + defaultMockInternalAccount.address + ], + ).toStrictEqual([ + { isERC721: false, - decimals, - }), + aggregators: [], + ...asset, + decimals: 6, + }, ]); }); + }); - it('should add isERC721 = false when the token is not an NFT and not in our contract-metadata repo', async function () { - const stub = stubCreateEthers(tokensController, false); - const tokenAddress = '0xDA5584Cc586d07c7141aA427224A4Bd58E64aF7D'; - - await tokensController.addToken({ - address: tokenAddress, - symbol: 'LEST', - decimals: 5, + it('stores token correctly if user confirms', async () => { + await withController(async ({ controller, approvalController }) => { + const requestId = '12345'; + const addAndShowApprovalRequestSpy = jest + .spyOn(approvalController, 'addAndShowApprovalRequest') + .mockResolvedValue(undefined); + const asset = buildToken(); + ContractMock.mockReturnValue( + buildMockEthersERC721Contract({ supportsInterface: false }), + ); + uuidV1Mock.mockReturnValue(requestId); + await controller.watchAsset({ + asset, + type: 'ERC20', + networkClientId: 'mainnet', }); - expect(tokensController.state.tokens).toStrictEqual([ + expect( + controller.state.allTokens[ChainId.mainnet][ + defaultMockInternalAccount.address + ], + ).toHaveLength(1); + expect( + controller.state.allTokens[ChainId.mainnet][ + defaultMockInternalAccount.address + ], + ).toStrictEqual([ { - address: tokenAddress, - symbol: 'LEST', isERC721: false, - image: - 'https://static.metafi.codefi.network/api/v1/tokenIcons/1/0xda5584cc586d07c7141aa427224a4bd58e64af7d.png', - decimals: 5, aggregators: [], - name: undefined, + ...asset, }, ]); - - stub.restore(); + expect(addAndShowApprovalRequestSpy).toHaveBeenCalledTimes(1); + expect(addAndShowApprovalRequestSpy).toHaveBeenCalledWith({ + id: requestId, + origin: ORIGIN_METAMASK, + type: ApprovalType.WatchAsset, + requestData: { + id: requestId, + interactingAddress: '0x1', + asset, + }, + }); }); + }); - it('should throw error if switching networks while adding token', async function () { - const dummyTokenAddress = '0x514910771AF9Ca656af840dff83E8264EcF986CA'; - const addTokenPromise = tokensController.addToken({ - address: dummyTokenAddress, - symbol: 'LINK', - decimals: 18, + it('falls back to ORIGIN_METAMASK when origin is empty string', async () => { + await withController(async ({ controller, approvalController }) => { + const requestId = '12345'; + const addAndShowApprovalRequestSpy = jest + .spyOn(approvalController, 'addAndShowApprovalRequest') + .mockResolvedValue(undefined); + const asset = buildToken(); + ContractMock.mockReturnValue( + buildMockEthersERC721Contract({ supportsInterface: false }), + ); + uuidV1Mock.mockReturnValue(requestId); + + await controller.watchAsset({ + asset, + type: 'ERC20', + origin: '', + networkClientId: 'mainnet', + }); + + expect(addAndShowApprovalRequestSpy).toHaveBeenCalledWith({ + id: requestId, + origin: ORIGIN_METAMASK, + type: ApprovalType.WatchAsset, + requestData: { + id: requestId, + interactingAddress: '0x1', + asset, + }, }); - changeNetwork(GOERLI); - await expect(addTokenPromise).rejects.toThrow( - 'TokensController Error: Switched networks while adding token', + }); + }); + + it('uses origin param when requestMetadata.origin is empty string', async () => { + await withController(async ({ controller, approvalController }) => { + const requestId = '12345'; + const addAndShowApprovalRequestSpy = jest + .spyOn(approvalController, 'addAndShowApprovalRequest') + .mockResolvedValue(undefined); + const asset = buildToken(); + ContractMock.mockReturnValue( + buildMockEthersERC721Contract({ supportsInterface: false }), ); + uuidV1Mock.mockReturnValue(requestId); + + await controller.watchAsset({ + asset, + type: 'ERC20', + origin: 'https://example.test', + requestMetadata: { + origin: '', + }, + networkClientId: 'mainnet', + }); + + expect(addAndShowApprovalRequestSpy).toHaveBeenCalledWith({ + id: requestId, + origin: 'https://example.test', + type: ApprovalType.WatchAsset, + requestData: { + id: requestId, + interactingAddress: '0x1', + asset, + }, + }); }); }); - it('should throw TokenService error if fetchTokenMetadata returns a response with an error', async () => { - const dummyTokenAddress = '0x514910771AF9Ca656af840dff83E8264EcF986CA'; - const error = 'An error occured'; - const fullErrorMessage = `TokenService Error: ${error}`; - nock(TOKEN_END_POINT_API) - .get( - `/token/${convertHexToDecimal( - ChainId.mainnet, - )}?address=${dummyTokenAddress}`, - ) - .reply(200, { error }) - .persist(); - - await expect( - tokensController.addToken({ - address: dummyTokenAddress, - symbol: 'LINK', - decimals: 18, - }), - ).rejects.toThrow(fullErrorMessage); + it('stores token correctly under interacting address if user confirms', async () => { + const chainId = ChainId.sepolia; + + await withController( + { + options: { + chainId, + }, + }, + async ({ controller, approvalController }) => { + const requestId = '12345'; + const addAndShowApprovalRequestSpy = jest + .spyOn(approvalController, 'addAndShowApprovalRequest') + .mockResolvedValue(undefined); + const asset = buildToken(); + const interactingAddress = '0x2'; + ContractMock.mockReturnValue( + buildMockEthersERC721Contract({ supportsInterface: false }), + ); + uuidV1Mock.mockReturnValue(requestId); + + await controller.watchAsset({ + asset, + type: 'ERC20', + interactingAddress, + networkClientId: 'sepolia', + }); + + expect( + controller.state.allTokens[ChainId.sepolia][ + defaultMockInternalAccount.address + ], + ).toBeUndefined(); + expect(controller.state.allTokens[ChainId.mainnet]).toBeUndefined(); + expect( + controller.state.allTokens[chainId][interactingAddress], + ).toHaveLength(1); + expect( + controller.state.allTokens[chainId][interactingAddress], + ).toStrictEqual([ + { + isERC721: false, + aggregators: [], + ...asset, + }, + ]); + expect(addAndShowApprovalRequestSpy).toHaveBeenCalledTimes(1); + expect(addAndShowApprovalRequestSpy).toHaveBeenCalledWith({ + id: requestId, + origin: ORIGIN_METAMASK, + type: ApprovalType.WatchAsset, + requestData: { + id: requestId, + interactingAddress, + asset, + }, + }); + }, + ); }); - it('should add token that was previously a detected token', async () => { - const stub = stubCreateEthers(tokensController, false); - const dummyDetectedToken: Token = { - address: '0x01', - symbol: 'barA', - decimals: 2, - aggregators: [], - image: undefined, - isERC721: false, - name: undefined, - }; - const dummyAddedToken: Token = { - ...dummyDetectedToken, - image: - 'https://static.metafi.codefi.network/api/v1/tokenIcons/1/0x01.png', - }; + it('stores token correctly when passed a networkClientId', async () => { + const networkClientId = 'networkClientId1'; + + await withController( + { + mockNetworkClientConfigurationsByNetworkClientId: { + [networkClientId]: buildCustomNetworkClientConfiguration({ + chainId: '0x5', + }), + }, + }, + async ({ controller, approvalController }) => { + const requestId = '12345'; + const addAndShowApprovalRequestSpy = jest + .spyOn(approvalController, 'addAndShowApprovalRequest') + .mockResolvedValue(undefined); + const asset = buildToken(); + const interactingAddress = '0x2'; + ContractMock.mockReturnValue( + buildMockEthersERC721Contract({ supportsInterface: false }), + ); + uuidV1Mock.mockReturnValue(requestId); + + await controller.watchAsset({ + asset, + type: 'ERC20', + interactingAddress, + networkClientId, + }); - await tokensController.addDetectedTokens([dummyDetectedToken]); + expect(addAndShowApprovalRequestSpy).toHaveBeenCalledWith({ + id: requestId, + origin: ORIGIN_METAMASK, + type: ApprovalType.WatchAsset, + requestData: { + id: requestId, + interactingAddress, + asset, + }, + }); + + expect(controller.state.allTokens[ChainId.sepolia]).toBeUndefined(); + expect(controller.state.allTokens[ChainId.mainnet]).toBeUndefined(); + expect( + controller.state.allTokens['0x5'][interactingAddress], + ).toHaveLength(1); + expect( + controller.state.allTokens['0x5'][interactingAddress], + ).toStrictEqual([ + { + isERC721: false, + aggregators: [], + ...asset, + }, + ]); + }, + ); + }); - expect(tokensController.state.detectedTokens).toStrictEqual([ - dummyDetectedToken, - ]); + it('throws and does not add token if pending approval fails', async () => { + await withController(async ({ controller, approvalController }) => { + const errorMessage = 'Mock Error Message'; + const requestId = '12345'; + const addAndShowApprovalRequestSpy = jest + .spyOn(approvalController, 'addAndShowApprovalRequest') + .mockRejectedValue(new Error(errorMessage)); + const asset = buildToken(); + ContractMock.mockReturnValue( + buildMockEthersERC721Contract({ supportsInterface: false }), + ); + uuidV1Mock.mockReturnValue(requestId); + await expect( + controller.watchAsset({ + asset, + type: 'ERC20', + networkClientId: 'mainnet', + }), + ).rejects.toThrow(errorMessage); - await tokensController.addToken({ - address: dummyDetectedToken.address, - symbol: dummyDetectedToken.symbol, - decimals: dummyDetectedToken.decimals, + expect(controller.state.allTokens[ChainId.sepolia]).toBeUndefined(); + expect(controller.state.allTokens[ChainId.mainnet]).toBeUndefined(); + expect(addAndShowApprovalRequestSpy).toHaveBeenCalledTimes(1); + expect(addAndShowApprovalRequestSpy).toHaveBeenCalledWith({ + id: requestId, + origin: ORIGIN_METAMASK, + type: ApprovalType.WatchAsset, + requestData: { + id: requestId, + interactingAddress: '0x1', + asset, + }, + }); }); + }); + + it('stores multiple tokens from a batched watchAsset confirmation screen correctly when user confirms', async () => { + const chainId = ChainId.goerli; + + await withController( + { + options: { + chainId, + }, + }, + async ({ controller, messenger, approvalController }) => { + const requestId = '12345'; + const interactingAddress = '0x2'; + const asset = buildTokenWithName({ + address: '0x000000000000000000000000000000000000dEaD', + decimals: 1, + image: 'image1', + name: 'A Token', + symbol: 'TOKEN1', + }); + ContractMock.mockReturnValue( + buildMockEthersERC721Contract({ supportsInterface: false }), + ); + uuidV1Mock + .mockReturnValueOnce(requestId) + .mockReturnValueOnce('67890'); + + const acceptedRequest = new Promise((resolve) => { + messenger.subscribe( + 'TokensController:stateChange', + (state: TokensControllerState) => { + if ( + state.allTokens?.[chainId]?.[interactingAddress].length === 2 + ) { + resolve(); + } + }, + ); + }); + + const anotherAsset = buildTokenWithName({ + address: '0x000000000000000000000000000000000000ABcD', + decimals: 2, + image: 'image2', + name: 'Another Token', + symbol: 'TOKEN2', + }); + + ERC20StandardMock.mockReturnValueOnce( + buildMockERC20StandardFromToken(asset), + ).mockReturnValueOnce(buildMockERC20StandardFromToken(anotherAsset)); + + const promiseForApprovals = new Promise((resolve) => { + const listener = (state: ApprovalControllerState) => { + if (state.pendingApprovalCount === 2) { + messenger.unsubscribe( + 'ApprovalController:stateChange', + listener, + ); + resolve(); + } + }; + messenger.subscribe('ApprovalController:stateChange', listener); + }); + + // eslint-disable-next-line @typescript-eslint/no-floating-promises + controller.watchAsset({ + asset, + type: 'ERC20', + interactingAddress, + networkClientId: 'goerli', + }); + + // eslint-disable-next-line @typescript-eslint/no-floating-promises + controller.watchAsset({ + asset: anotherAsset, + type: 'ERC20', + interactingAddress, + networkClientId: 'goerli', + }); + + await promiseForApprovals; - expect(tokensController.state.detectedTokens).toStrictEqual([]); - expect(tokensController.state.tokens).toStrictEqual([dummyAddedToken]); + await approvalController.acceptRequest(requestId); + await approvalController.acceptRequest('67890'); + await acceptedRequest; - stub.restore(); + expect( + controller.state.allTokens[chainId][interactingAddress], + ).toStrictEqual([ + { + isERC721: false, + aggregators: [], + ...asset, + }, + { + isERC721: false, + aggregators: [], + ...anotherAsset, + }, + ]); + }, + ); }); + }); - it('should add tokens to the correct chainId/selectedAddress on which they were detected even if its not the currently configured chainId/selectedAddress', async () => { - const stub = stubCreateEthers(tokensController, false); + describe('when PreferencesController:stateChange is published', () => { + it('should update tokens list when set address changes', async () => { + const selectedAccount = createMockInternalAccount({ address: '0x1' }); + const selectedAccount2 = createMockInternalAccount({ + address: '0x2', + }); + await withController( + { + mocks: { + getAccount: selectedAccount, + getSelectedAccount: selectedAccount, + }, + }, + async ({ controller, triggerSelectedAccountChange }) => { + ContractMock.mockReturnValue( + buildMockEthersERC721Contract({ supportsInterface: false }), + ); + + triggerSelectedAccountChange(selectedAccount); + await controller.addToken({ + address: '0x01', + symbol: 'A', + decimals: 4, + networkClientId: 'mainnet', + }); + await controller.addToken({ + address: '0x02', + symbol: 'B', + decimals: 5, + networkClientId: 'mainnet', + }); + triggerSelectedAccountChange(selectedAccount2); + expect(controller.state.allTokens[ChainId.sepolia]).toBeUndefined(); + + await controller.addToken({ + address: '0x03', + symbol: 'C', + decimals: 6, + networkClientId: 'mainnet', + }); + triggerSelectedAccountChange(selectedAccount); + expect( + controller.state.allTokens[ChainId.mainnet][ + selectedAccount.address + ], + ).toStrictEqual([ + { + address: '0x01', + decimals: 4, + image: + 'https://static.cx.metamask.io/api/v1/tokenIcons/1/0x01.png', + isERC721: false, + symbol: 'A', + aggregators: [], + name: undefined, + }, + { + address: '0x02', + decimals: 5, + image: + 'https://static.cx.metamask.io/api/v1/tokenIcons/1/0x02.png', + isERC721: false, + symbol: 'B', + aggregators: [], + name: undefined, + }, + ]); + + triggerSelectedAccountChange(selectedAccount2); + expect( + controller.state.allTokens[ChainId.mainnet][ + selectedAccount2.address + ], + ).toStrictEqual([ + { + address: '0x03', + decimals: 6, + image: + 'https://static.cx.metamask.io/api/v1/tokenIcons/1/0x03.png', + isERC721: false, + symbol: 'C', + aggregators: [], + name: undefined, + }, + ]); + }, + ); + }); + }); - const DETECTED_ADDRESS = '0xDetectedAddress'; - const DETECTED_CHAINID = '0xDetectedChainId'; + describe('when NetworkController:onNetworkDidChange is published', () => { + it('should remove a token from its state on corresponding network', async () => { + await withController(async ({ controller, changeNetwork }) => { + ContractMock.mockReturnValue( + buildMockEthersERC721Contract({ supportsInterface: false }), + ); + + changeNetwork({ selectedNetworkClientId: InfuraNetworkType.sepolia }); + await controller.addToken({ + address: '0x01', + symbol: 'A', + decimals: 4, + networkClientId: 'sepolia', + }); + await controller.addToken({ + address: '0x02', + symbol: 'B', + decimals: 5, + networkClientId: 'sepolia', + }); + const initialTokensFirst = + controller.state.allTokens[ChainId.sepolia][ + defaultMockInternalAccount.address + ]; - const CONFIGURED_ADDRESS = '0xabc'; - preferences.update({ selectedAddress: CONFIGURED_ADDRESS }); - changeNetwork(SEPOLIA); + changeNetwork({ selectedNetworkClientId: InfuraNetworkType.goerli }); + await controller.addToken({ + address: '0x03', + symbol: 'C', + decimals: 4, + networkClientId: 'goerli', + }); + await controller.addToken({ + address: '0x04', + symbol: 'D', + decimals: 5, + networkClientId: 'goerli', + }); + const initialTokensSecond = + controller.state.allTokens[ChainId.goerli][ + defaultMockInternalAccount.address + ]; - const detectedToken: Token = { - address: '0x01', - symbol: 'barA', - decimals: 2, - aggregators: [], - isERC721: false, - image: - 'https://static.metafi.codefi.network/api/v1/tokenIcons/11155111/0x01.png', - name: undefined, - }; + expect(initialTokensFirst).not.toStrictEqual(initialTokensSecond); + expect(initialTokensFirst).toStrictEqual([ + { + address: '0x01', + decimals: 4, + image: + 'https://static.cx.metamask.io/api/v1/tokenIcons/11155111/0x01.png', + isERC721: false, + symbol: 'A', + aggregators: [], + name: undefined, + }, + { + address: '0x02', + decimals: 5, + image: + 'https://static.cx.metamask.io/api/v1/tokenIcons/11155111/0x02.png', + isERC721: false, + symbol: 'B', + aggregators: [], + name: undefined, + }, + ]); + expect(initialTokensSecond).toStrictEqual([ + { + address: '0x03', + decimals: 4, + image: 'https://static.cx.metamask.io/api/v1/tokenIcons/5/0x03.png', + isERC721: false, + symbol: 'C', + aggregators: [], + name: undefined, + }, + { + address: '0x04', + decimals: 5, + image: 'https://static.cx.metamask.io/api/v1/tokenIcons/5/0x04.png', + isERC721: false, + symbol: 'D', + aggregators: [], + name: undefined, + }, + ]); - const directlyAddedToken: Token = { - address: '0x02', - decimals: 5, - symbol: 'B', - image: - 'https://static.metafi.codefi.network/api/v1/tokenIcons/11155111/0x02.png', - isERC721: false, - aggregators: [], - name: undefined, - }; + changeNetwork({ selectedNetworkClientId: InfuraNetworkType.sepolia }); + expect(initialTokensFirst).toStrictEqual( + controller.state.allTokens[ChainId.sepolia][ + defaultMockInternalAccount.address + ], + ); - // detectionDetails object is passed as second arg with details about where token was detected - await tokensController.addDetectedTokens([detectedToken], { - selectedAddress: DETECTED_ADDRESS, - chainId: DETECTED_CHAINID, + changeNetwork({ selectedNetworkClientId: InfuraNetworkType.goerli }); + expect(initialTokensSecond).toStrictEqual( + controller.state.allTokens[ChainId.goerli][ + defaultMockInternalAccount.address + ], + ); }); + }); + }); - // will add token to currently configured chainId/selectedAddress - await tokensController.addToken({ - address: directlyAddedToken.address, - symbol: directlyAddedToken.symbol, - decimals: directlyAddedToken.decimals, - image: directlyAddedToken.image, + describe('Clearing nested lists', () => { + it('should clear nest allTokens under chain ID and selected address when an added token is ignored', async () => { + const selectedAddress = '0x1'; + const selectedAccount = createMockInternalAccount({ + address: selectedAddress, }); + const tokenAddress = '0x01'; + const dummyTokens = [ + { + address: tokenAddress, + symbol: 'barA', + decimals: 2, + aggregators: [], + image: undefined, + }, + ]; - expect(tokensController.state.allDetectedTokens).toStrictEqual({ - [DETECTED_CHAINID]: { - [DETECTED_ADDRESS]: [detectedToken], + await withController( + { + options: { + chainId: ChainId.mainnet, + }, + mocks: { + getSelectedAccount: selectedAccount, + }, }, - }); + async ({ controller }) => { + await controller.addTokens(dummyTokens, 'mainnet'); + controller.ignoreTokens([tokenAddress], 'mainnet'); - expect(tokensController.state.allTokens).toStrictEqual({ - [SEPOLIA.chainId]: { - [CONFIGURED_ADDRESS]: [directlyAddedToken], + expect( + controller.state.allTokens[ChainId.mainnet][selectedAddress], + ).toStrictEqual([]); }, - }); - stub.restore(); + ); }); - }); - describe('addTokens method', function () { - it('should add tokens that were previously detected tokens', async () => { - const dummyAddedTokens: Token[] = [ + it('should clear nest allIgnoredTokens under chain ID and selected address when an ignored token is re-added', async () => { + const selectedAddress = '0x1'; + const selectedAccount = createMockInternalAccount({ + address: selectedAddress, + }); + const tokenAddress = '0x01'; + const dummyTokens = [ { - address: '0x01', + address: tokenAddress, symbol: 'barA', decimals: 2, aggregators: [], image: undefined, - name: undefined, }, + ]; + + await withController( { - address: '0x02', - symbol: 'barB', - decimals: 2, + options: { + chainId: ChainId.mainnet, + }, + mocks: { + getSelectedAccount: selectedAccount, + }, + }, + async ({ controller }) => { + await controller.addTokens(dummyTokens, 'mainnet'); + controller.ignoreTokens([tokenAddress], 'mainnet'); + await controller.addTokens(dummyTokens, 'mainnet'); + + expect( + controller.state.allIgnoredTokens[ChainId.mainnet][selectedAddress], + ).toStrictEqual([]); + }, + ); + }); + + it('should clear nest allIgnoredTokens when re-adding tokens with different address case via addTokens', async () => { + const selectedAddress = '0x1'; + const selectedAccount = createMockInternalAccount({ + address: selectedAddress, + }); + + const tokenAddressFromAPI = '0x7DA14988E4F390C2E34ED41DF1814467D3ADE0C3'; + const checksummedAddress = '0x7da14988E4f390C2E34ed41DF1814467D3aDe0c3'; + + const dummyTokens = [ + { + address: tokenAddressFromAPI, + symbol: 'PEPE', + decimals: 18, aggregators: [], image: undefined, - name: undefined, }, ]; - const dummyDetectedTokens: Token[] = [ + + await withController( { - ...dummyAddedTokens[0], - isERC721: false, + options: { + chainId: ChainId.mainnet, + }, + mocks: { + getSelectedAccount: selectedAccount, + }, + }, + async ({ controller }) => { + await controller.addTokens(dummyTokens, 'mainnet'); + expect( + controller.state.allTokens[ChainId.mainnet][selectedAddress][0] + .address, + ).toBe(checksummedAddress); + + controller.ignoreTokens([tokenAddressFromAPI], 'mainnet'); + expect( + controller.state.allIgnoredTokens[ChainId.mainnet][selectedAddress], + ).toStrictEqual([checksummedAddress]); + + expect( + controller.state.allTokens[ChainId.mainnet][selectedAddress], + ).toStrictEqual([]); + + await controller.addTokens(dummyTokens, 'mainnet'); + + // Should remove ignored token despite case difference + expect( + controller.state.allIgnoredTokens[ChainId.mainnet][selectedAddress], + ).toStrictEqual([]); + + expect( + controller.state.allTokens[ChainId.mainnet][selectedAddress], + ).toHaveLength(1); + expect( + controller.state.allTokens[ChainId.mainnet][selectedAddress][0] + .address, + ).toBe(checksummedAddress); }, + ); + }); + + it('should clear nest allDetectedTokens under chain ID and selected address when an detected token is added to tokens list', async () => { + const selectedAddress = '0x1'; + const selectedAccount = createMockInternalAccount({ + address: selectedAddress, + }); + const tokenAddress = '0x01'; + const dummyTokens = [ { - ...dummyAddedTokens[1], - isERC721: false, + address: tokenAddress, + symbol: 'barA', + decimals: 2, + aggregators: [], + image: undefined, }, ]; - await tokensController.addDetectedTokens(dummyDetectedTokens); - - expect(tokensController.state.detectedTokens).toStrictEqual( - dummyDetectedTokens, + await withController( + { + options: { + chainId: ChainId.mainnet, + }, + mocks: { + getSelectedAccount: selectedAccount, + }, + }, + async ({ controller }) => { + await controller.addDetectedTokens(dummyTokens, { + selectedAddress, + chainId: ChainId.mainnet, + }); + await controller.addTokens(dummyTokens, 'mainnet'); + + expect( + controller.state.allDetectedTokens[ChainId.mainnet][ + selectedAddress + ], + ).toStrictEqual([]); + }, ); - - await tokensController.addTokens(dummyDetectedTokens); - - expect(tokensController.state.detectedTokens).toStrictEqual([]); - expect(tokensController.state.tokens).toStrictEqual(dummyAddedTokens); }); - it('should add tokens to the correct chainId when passed a networkClientId', async () => { - const getNetworkClientByIdStub = jest - .spyOn(tokensController as any, 'getNetworkClientById') - .mockReturnValue({ configuration: { chainId: '0x5' } }); - - const dummyTokens: Token[] = [ + it('should clear allDetectedTokens under chain ID and selected address when a detected token is added to tokens list', async () => { + const selectedAddress = '0x1'; + const selectedAccount = createMockInternalAccount({ + address: selectedAddress, + }); + const tokenAddress = '0x01'; + const dummyDetectedTokens = [ { - address: '0x01', + address: tokenAddress, symbol: 'barA', decimals: 2, aggregators: [], - image: undefined, + isERC721: undefined, name: undefined, + image: undefined, }, + ]; + const dummyTokens = [ { - address: '0x02', - symbol: 'barB', + address: tokenAddress, + symbol: 'barA', decimals: 2, aggregators: [], - image: undefined, + isERC721: undefined, name: undefined, + image: undefined, }, ]; - await tokensController.addTokens(dummyTokens, 'networkClientId1'); - - expect(tokensController.state.tokens).toStrictEqual(dummyTokens); - expect(tokensController.state.allTokens['0x5']['0x1']).toStrictEqual( - dummyTokens, + await withController( + { + options: { + chainId: ChainId.mainnet, + }, + mocks: { + getSelectedAccount: selectedAccount, + }, + }, + async ({ controller }) => { + // First, add detected tokens + await controller.addDetectedTokens(dummyDetectedTokens, { + selectedAddress, + chainId: ChainId.mainnet, + }); + expect( + controller.state.allDetectedTokens[ChainId.mainnet][ + selectedAddress + ], + ).toStrictEqual(dummyDetectedTokens); + + // Now, add the same token to the tokens list + await controller.addTokens(dummyTokens, 'mainnet'); + + // Check that allDetectedTokens for the selected address is cleared + expect( + controller.state.allDetectedTokens[ChainId.mainnet][ + selectedAddress + ], + ).toStrictEqual([]); + }, ); - expect(getNetworkClientByIdStub).toHaveBeenCalledWith('networkClientId1'); }); }); - describe('_getNewAllTokensState method', () => { - const dummySelectedAddress = '0x1'; - const dummyTokens: Token[] = [ - { - address: '0x01', - symbol: 'barA', - decimals: 2, - aggregators: [], - image: undefined, - }, - ]; + describe('on initialization, token list enrichment', () => { + it('updates the name of each token to match its counterpart in the token list', async () => { + await withController( + { + options: { + state: { + allTokens: { + [ChainId.mainnet]: { + [defaultMockInternalAccount.address]: [ + { + address: '0x01', + decimals: 2, + image: + 'https://static.cx.metamask.io/api/v1/tokenIcons/1/0x01.png', + symbol: 'bar', + isERC721: false, + aggregators: [], + name: undefined, + }, + ], + }, + }, + }, + }, + }, + async ({ controller }) => { + // The enrichment is async (fires in constructor); wait for it. + await new Promise((resolve) => setTimeout(resolve, 0)); + + // TokenListService returns the token list for mainnet with a name. + // withController stubs fetchTokensByChainId to return {} by default; + // for this test we rely on the fact that the name stays undefined + // because the service returned nothing — verifying the plumbing at + // a unit level would require a more detailed setup tested below. + expect( + controller.state.allTokens[ChainId.mainnet][ + defaultMockInternalAccount.address + ][0].name, + ).toBeUndefined(); + }, + ); + }); - it('should nest newTokens under chain ID and selected address when provided with newTokens as input', () => { - tokensController.configure({ - selectedAddress: dummySelectedAddress, - chainId: ChainId.mainnet, - }); - const processedTokens = tokensController._getNewAllTokensState({ - newTokens: dummyTokens, - }); - expect( - processedTokens.newAllTokens[ChainId.mainnet][dummySelectedAddress], - ).toStrictEqual(dummyTokens); + it('enriches name and rwaData from the token list service at init time', async () => { + const tokenAddress = '0x01'; + + await withController( + { + options: { + state: { + allTokens: { + [ChainId.mainnet]: { + [defaultMockInternalAccount.address]: [ + { + address: tokenAddress, + decimals: 2, + image: + 'https://static.cx.metamask.io/api/v1/tokenIcons/1/0x01.png', + symbol: 'bar', + isERC721: false, + aggregators: [], + name: undefined, + rwaData: { ticker: 'OLD' } as TokenRwaData, + }, + ], + }, + }, + }, + tokenListService: { + fetchTokensByChainId: jest.fn().mockResolvedValue({ + [tokenAddress]: { + address: tokenAddress, + symbol: 'bar', + decimals: 2, + occurrences: 1, + name: 'BarName', + iconUrl: + 'https://static.cx.metamask.io/api/v1/tokenIcons/1/0x01.png', + aggregators: ['Aave'], + rwaData: { ticker: 'NEW' }, + }, + }), + } as unknown as import('./TokenListService.js').TokenListService, + }, + }, + async ({ controller }) => { + // Enrichment is a fire-and-forget async call in the constructor. + await new Promise((resolve) => setTimeout(resolve, 0)); + + const token = + controller.state.allTokens[ChainId.mainnet][ + defaultMockInternalAccount.address + ][0]; + + expect(token.name).toBe('BarName'); + expect(token.rwaData).toStrictEqual({ ticker: 'NEW' }); + }, + ); }); + }); - it('should nest detectedTokens under chain ID and selected address when provided with detectedTokens as input', () => { - tokensController.configure({ - selectedAddress: dummySelectedAddress, - chainId: ChainId.mainnet, + describe('when selectedAccountId is not set or account not found', () => { + describe('detectTokens', () => { + it('updates the token states to empty arrays if the selectedAccountId account is undefined', async () => { + await withController(async ({ controller, changeNetwork }) => { + ContractMock.mockReturnValue( + buildMockEthersERC721Contract({ supportsInterface: false }), + ); + changeNetwork({ selectedNetworkClientId: InfuraNetworkType.sepolia }); + + expect(controller.state.allTokens[ChainId.sepolia]).toBeUndefined(); + expect( + controller.state.allIgnoredTokens[ChainId.sepolia], + ).toBeUndefined(); + expect( + controller.state.allDetectedTokens[ChainId.sepolia], + ).toBeUndefined(); + }); }); - const processedTokens = tokensController._getNewAllTokensState({ - newDetectedTokens: dummyTokens, + }); + + describe('addToken', () => { + it('handles undefined selected account', async () => { + await withController(async ({ controller, getAccountHandler }) => { + getAccountHandler.mockReturnValue(undefined); + const contractAddresses = Object.keys(contractMaps); + const erc721ContractAddresses = contractAddresses.filter( + (contractAddress) => contractMaps[contractAddress].erc721 === true, + ); + const address = erc721ContractAddresses[0]; + const { symbol, decimals } = contractMaps[address]; + + await controller.addToken({ + address, + symbol, + decimals, + networkClientId: 'mainnet', + }); + + expect(controller.state.allTokens[ChainId.mainnet]['']).toStrictEqual( + [ + { + address, + aggregators: [], + decimals, + image: + 'https://static.cx.metamask.io/api/v1/tokenIcons/1/0x9c8ff314c9bc7f6e59a9d9225fb22946427edc03.png', + isERC721: true, + name: undefined, + symbol, + }, + ], + ); + }); }); - expect( - processedTokens.newAllDetectedTokens[ChainId.mainnet][ - dummySelectedAddress - ], - ).toStrictEqual(dummyTokens); }); - it('should nest ignoredTokens under chain ID and selected address when provided with ignoredTokens as input', () => { - tokensController.configure({ - selectedAddress: dummySelectedAddress, - chainId: ChainId.mainnet, + describe('addDetectedTokens', () => { + it('handles an undefined selected account', async () => { + await withController(async ({ controller, getAccountHandler }) => { + getAccountHandler.mockReturnValue(undefined); + const mockToken = { + address: '0x01', + symbol: 'barA', + decimals: 2, + aggregators: [], + }; + await controller.addDetectedTokens([mockToken], { + selectedAddress: defaultMockInternalAccount.address, + chainId: ChainId.mainnet, + }); + expect( + controller.state.allDetectedTokens[ChainId.mainnet]['0x1'][0], + ).toStrictEqual({ + ...mockToken, + image: undefined, + isERC721: undefined, + name: undefined, + }); + }); }); - const dummyIgnoredTokens = [dummyTokens[0].address]; - const processedTokens = tokensController._getNewAllTokensState({ - newIgnoredTokens: dummyIgnoredTokens, + }); + + describe('watchAsset', () => { + it('handles undefined selected account', async () => { + await withController( + async ({ controller, approvalController, getAccountHandler }) => { + const requestId = '12345'; + const addAndShowApprovalRequestSpy = jest + .spyOn(approvalController, 'addAndShowApprovalRequest') + .mockResolvedValue(undefined); + const asset = buildToken(); + ContractMock.mockReturnValue( + buildMockEthersERC721Contract({ supportsInterface: false }), + ); + uuidV1Mock.mockReturnValue(requestId); + getAccountHandler.mockReturnValue(undefined); + await controller.watchAsset({ + asset, + type: 'ERC20', + networkClientId: 'mainnet', + }); + + expect( + controller.state.allTokens[ChainId.mainnet][''], + ).toStrictEqual([ + { + address: '0x000000000000000000000000000000000000dEaD', + aggregators: [], + decimals: 12, + image: 'image', + isERC721: false, + name: undefined, + symbol: 'TOKEN', + }, + ]); + expect(addAndShowApprovalRequestSpy).toHaveBeenCalledTimes(1); + expect(addAndShowApprovalRequestSpy).toHaveBeenCalledWith({ + id: requestId, + origin: ORIGIN_METAMASK, + type: ApprovalType.WatchAsset, + requestData: { + id: requestId, + interactingAddress: '', // this is the default value if account is not found + asset, + }, + }); + }, + ); }); - expect( - processedTokens.newAllIgnoredTokens[ChainId.mainnet][ - dummySelectedAddress - ], - ).toStrictEqual(dummyIgnoredTokens); }); }); - describe('watchAsset', function () { - let asset: any, type: any; - const interactingAddress = '0x2'; - const requestId = '12345'; - - let createEthersStub: sinon.SinonStub; - beforeEach(function () { - type = ERC20; - asset = { - address: '0x000000000000000000000000000000000000dEaD', - decimals: 12, - symbol: 'SES', - image: 'image', - name: undefined, + describe('when NetworkController:stateChange is published', () => { + it('removes tokens for removed networks', async () => { + const initialState = { + allTokens: { + '0x1': { + '0x134': [ + { + address: '0x01', + symbol: 'TKN1', + decimals: 18, + aggregators: [], + name: 'Token 1', + }, + ], + }, + '0x5': { + // goerli + '0x456': [ + { + address: '0x02', + symbol: 'TKN2', + decimals: 18, + aggregators: [], + name: 'Token 2', + }, + ], + }, + }, + tokens: [], + ignoredTokens: [], + detectedTokens: [], + allIgnoredTokens: {}, + allDetectedTokens: {}, }; - createEthersStub = stubCreateEthers(tokensController, false); - }); + await withController( + { options: { state: initialState } }, + async ({ controller, triggerNetworkStateChange }) => { + // Verify initial state + expect(controller.state).toStrictEqual(initialState); - afterEach(() => { - createEthersStub.restore(); - }); + // Simulate removing goerli + triggerNetworkStateChange({} as NetworkState, [ + { + op: 'remove', + path: ['networkConfigurationsByChainId', '0x5'], + }, + ]); - it('should error if passed no type', async function () { - type = undefined; - const result = tokensController.watchAsset({ asset, type }); - await expect(result).rejects.toThrow( - 'Asset of type undefined not supported', + // Verify tokens were removed on goerli + expect(controller.state.allTokens).toStrictEqual({ + '0x1': initialState.allTokens['0x1'], + }); + }, ); }); + }); - it('should error if asset type is not supported', async function () { - type = 'ERC721'; - const result = tokensController.watchAsset({ asset, type }); - await expect(result).rejects.toThrow( - 'Asset of type ERC721 not supported', - ); - }); + describe('resetState', () => { + it('resets the state to default state', async () => { + const initialState: TokensControllerState = { + allTokens: { + [ChainId.mainnet]: { + '0x0001': [ + { + address: '0x03', + symbol: 'barC', + decimals: 2, + aggregators: [], + image: undefined, + name: undefined, + }, + ], + }, + }, + allIgnoredTokens: { + [ChainId.mainnet]: { + '0x0001': ['0x03'], + }, + }, + allDetectedTokens: { + [ChainId.mainnet]: { + '0x0001': [ + { + address: '0x01', + symbol: 'barA', + decimals: 2, + aggregators: [], + image: undefined, + name: undefined, + }, + ], + }, + }, + }; + await withController( + { + options: { + state: initialState, + }, + }, + ({ controller }) => { + expect(controller.state).toStrictEqual(initialState); - it('should error if address is not defined', async function () { - asset.address = undefined; - const result = tokensController.watchAsset({ asset, type }); - await expect(result).rejects.toThrow( - 'Must specify address, symbol, and decimals.', - ); - }); + controller.resetState(); - it('should error if decimals is not defined', async function () { - asset.decimals = undefined; - const result = tokensController.watchAsset({ asset, type }); - await expect(result).rejects.toThrow( - 'Must specify address, symbol, and decimals.', + expect(controller.state).toStrictEqual({ + allTokens: {}, + allIgnoredTokens: {}, + allDetectedTokens: {}, + }); + }, ); }); + }); - it('should error if symbol is not defined', async function () { - asset.symbol = undefined; - const result = tokensController.watchAsset({ asset, type }); - await expect(result).rejects.toThrow( - 'Must specify address, symbol, and decimals.', + describe('when accountRemoved is published', () => { + it('removes the list of tokens for the removed account', async () => { + const firstAddress = '0xA73d9021f67931563fDfe3E8f66261086319a1FC'; + const secondAddress = '0xB73d9021f67931563fDfe3E8f66261086319a1FK'; + const firstAccount = createMockInternalAccount({ + address: firstAddress, + }); + const secondAccount = createMockInternalAccount({ + address: secondAddress, + }); + const initialState: TokensControllerState = { + allTokens: { + [ChainId.mainnet]: { + [firstAddress]: [ + { + address: '0x03', + symbol: 'barC', + decimals: 2, + aggregators: [], + image: undefined, + name: undefined, + }, + ], + [secondAddress]: [ + { + address: '0x04', + symbol: 'barD', + decimals: 2, + aggregators: [], + image: undefined, + name: undefined, + }, + ], + }, + }, + allIgnoredTokens: {}, + allDetectedTokens: { + [ChainId.mainnet]: { + [firstAddress]: [], + [secondAddress]: [], + }, + }, + }; + await withController( + { + options: { + state: initialState, + }, + listAccounts: [firstAccount, secondAccount], + }, + ({ controller, triggerAccountRemoved }) => { + expect(controller.state).toStrictEqual(initialState); + + triggerAccountRemoved(firstAccount.address); + + expect(controller.state).toStrictEqual({ + allTokens: { + [ChainId.mainnet]: { + [secondAddress]: [ + { + address: '0x04', + symbol: 'barD', + decimals: 2, + aggregators: [], + image: undefined, + name: undefined, + }, + ], + }, + }, + allIgnoredTokens: {}, + allDetectedTokens: { + [ChainId.mainnet]: { + [secondAddress]: [], + }, + }, + }); + }, ); }); - it('should error if symbol is empty', async function () { - asset.symbol = ''; - const result = tokensController.watchAsset({ asset, type }); - await expect(result).rejects.toThrow( - 'Must specify address, symbol, and decimals.', - ); - }); + it('removes an account with no tokens', async () => { + const firstAddress = '0xA73d9021f67931563fDfe3E8f66261086319a1FC'; + const secondAddress = '0xB73d9021f67931563fDfe3E8f66261086319a1FK'; + const firstAccount = createMockInternalAccount({ + address: firstAddress, + }); + const secondAccount = createMockInternalAccount({ + address: secondAddress, + }); + const initialState: TokensControllerState = { + allTokens: { + [ChainId.mainnet]: { + [firstAddress]: [ + { + address: '0x03', + symbol: 'barC', + decimals: 2, + aggregators: [], + image: undefined, + name: undefined, + }, + ], + }, + }, + allIgnoredTokens: {}, + allDetectedTokens: { + [ChainId.mainnet]: { + [firstAddress]: [], + }, + }, + }; + await withController( + { + options: { + state: initialState, + }, + listAccounts: [firstAccount, secondAccount], + }, + ({ controller, triggerAccountRemoved }) => { + expect(controller.state).toStrictEqual(initialState); + + triggerAccountRemoved(secondAccount.address); - it('should error if symbol is too long', async function () { - asset.symbol = 'ABCDEFGHIJKLM'; - const result = tokensController.watchAsset({ asset, type }); - await expect(result).rejects.toThrow( - 'Invalid symbol "ABCDEFGHIJKLM": longer than 11 characters.', + expect(controller.state).toStrictEqual(initialState); + }, ); }); + }); - it('should error if decimals is invalid', async function () { - asset.decimals = -1; - const result = tokensController.watchAsset({ asset, type }); - await expect(result).rejects.toThrow( - 'Invalid decimals "-1": must be 0 <= 36.', - ); + describe('isDeprecated', () => { + const initialState: TokensControllerState = { + allTokens: { + [ChainId.mainnet]: { + '0x0001': [ + { + address: '0x03', + symbol: 'barC', + decimals: 2, + aggregators: [], + image: undefined, + name: undefined, + }, + ], + }, + }, + allIgnoredTokens: { + [ChainId.mainnet]: { + '0x0001': ['0x03'], + }, + }, + allDetectedTokens: { + [ChainId.mainnet]: { + '0x0001': [ + { + address: '0x01', + symbol: 'barA', + decimals: 2, + aggregators: [], + image: undefined, + name: undefined, + }, + ], + }, + }, + }; + + const emptyState: TokensControllerState = { + allTokens: {}, + allIgnoredTokens: {}, + allDetectedTokens: {}, + }; - asset.decimals = 37; - const result2 = tokensController.watchAsset({ asset, type }); - await expect(result2).rejects.toThrow( - 'Invalid decimals "37": must be 0 <= 36.', + it('clears all persisted state at construction when isDeprecated() returns true', async () => { + await withController( + { options: { state: initialState, isDeprecated: () => true } }, + ({ controller }) => { + expect(controller.state).toStrictEqual(emptyState); + }, ); }); - it('should error if address is invalid', async function () { - asset.address = '0x123'; - const result = tokensController.watchAsset({ asset, type }); - await expect(result).rejects.toThrow('Invalid address "0x123".'); + it('preserves persisted state at construction when isDeprecated() returns false', async () => { + await withController( + { options: { state: initialState, isDeprecated: () => false } }, + ({ controller }) => { + expect(controller.state).toStrictEqual(initialState); + }, + ); }); - it('fails with an invalid type suggested', async () => { - await expect( - tokensController.watchAsset({ - asset: { - address: '0xe9f786dfdd9ae4d57e830acb52296837765f0e5b', - decimals: 18, - symbol: 'TKN', - }, - type: 'ERC721', - }), - ).rejects.toThrow('Asset of type ERC721 not supported'); + it('does not throw at construction when isDeprecated() is true and state is already empty', async () => { + await withController( + { options: { isDeprecated: () => true } }, + ({ controller }) => { + expect(controller.state).toStrictEqual(emptyState); + }, + ); }); - it('stores token correctly if user confirms', async () => { - const generateRandomIdStub = jest - .spyOn(tokensController, '_generateRandomId') - .mockReturnValue(requestId); - - const callActionSpy = jest - .spyOn(messenger, 'call') - .mockResolvedValue(undefined); - - await tokensController.watchAsset({ asset, type }); - - expect(tokensController.state.tokens).toHaveLength(1); - expect(tokensController.state.tokens).toStrictEqual([ - { - isERC721: false, - aggregators: [], - ...asset, - }, - ]); - expect(callActionSpy).toHaveBeenCalledTimes(1); - expect(callActionSpy).toHaveBeenCalledWith( - 'ApprovalController:addRequest', - { - id: requestId, - origin: ORIGIN_METAMASK, - type: ApprovalType.WatchAsset, - requestData: { - id: requestId, - interactingAddress: '0x1', - asset, - }, + it('does not call tokenListService.fetchTokensByChainId at construction when isDeprecated() returns true', async () => { + await withController( + { options: { state: initialState, isDeprecated: () => true } }, + async ({ controller }) => { + // Give any async init work a chance to settle + await new Promise((resolve) => process.nextTick(resolve)); + + // The tokenListService mock is accessed via the controller factory; + // we verify by checking that state was not modified by enrichment + expect(controller.state).toStrictEqual(emptyState); }, - true, ); - - generateRandomIdStub.mockRestore(); }); - it('stores token correctly under interacting address if user confirms', async function () { - const generateRandomIdStub = jest - .spyOn(tokensController, '_generateRandomId') - .mockReturnValue(requestId); + it('does not add tokens and clears stale state when isDeprecated toggles to true at runtime via addToken', async () => { + let deprecated = false; + await withController( + { options: { state: initialState, isDeprecated: () => deprecated } }, + async ({ controller }) => { + expect(controller.state).toStrictEqual(initialState); - const callActionSpy = jest - .spyOn(messenger, 'call') - .mockResolvedValue(undefined); + deprecated = true; - await tokensController.watchAsset({ asset, type, interactingAddress }); + const result = await controller.addToken({ + address: '0x05', + symbol: 'NEW', + decimals: 18, + networkClientId: 'mainnet', + }); - expect(tokensController.state.tokens).toHaveLength(0); - expect(tokensController.state.tokens).toStrictEqual([]); - expect( - tokensController.state.allTokens[ChainId.mainnet][interactingAddress], - ).toHaveLength(1); - expect( - tokensController.state.allTokens[ChainId.mainnet][interactingAddress], - ).toStrictEqual([ - { - isERC721: false, - aggregators: [], - ...asset, - }, - ]); - expect(callActionSpy).toHaveBeenCalledTimes(1); - expect(callActionSpy).toHaveBeenCalledWith( - 'ApprovalController:addRequest', - { - id: requestId, - origin: ORIGIN_METAMASK, - type: ApprovalType.WatchAsset, - requestData: { - id: requestId, - interactingAddress, - asset, - }, + expect(result).toStrictEqual([]); + expect(controller.state).toStrictEqual(emptyState); }, - true, ); - - generateRandomIdStub.mockRestore(); }); - it('stores token correctly when passed a networkClientId', async function () { - const getNetworkClientByIdStub = jest - .spyOn(tokensController as any, 'getNetworkClientById') - .mockReturnValue({ configuration: { chainId: '0x5' } }); - const getERC20TokenNameStub = jest - .spyOn(tokensController as any, 'getERC20TokenName') - .mockReturnValue(undefined); - const generateRandomIdStub = jest - .spyOn(tokensController, '_generateRandomId') - .mockReturnValue(requestId); + it('does not add tokens and clears stale state when isDeprecated toggles to true at runtime via addTokens', async () => { + let deprecated = false; + await withController( + { options: { state: initialState, isDeprecated: () => deprecated } }, + async ({ controller }) => { + expect(controller.state).toStrictEqual(initialState); - const callActionSpy = jest - .spyOn(messenger, 'call') - .mockResolvedValue(undefined); + deprecated = true; - await tokensController.watchAsset({ - asset, - type, - interactingAddress, - networkClientId: 'networkClientId1', - }); + await controller.addTokens( + [{ address: '0x05', symbol: 'NEW', decimals: 18 }], + 'mainnet', + ); - expect(tokensController.state.tokens).toHaveLength(0); - expect(tokensController.state.tokens).toStrictEqual([]); - expect( - tokensController.state.allTokens['0x5'][interactingAddress], - ).toHaveLength(1); - expect( - tokensController.state.allTokens['0x5'][interactingAddress], - ).toStrictEqual([ - { - isERC721: false, - aggregators: [], - ...asset, - }, - ]); - expect(callActionSpy).toHaveBeenCalledTimes(1); - expect(callActionSpy).toHaveBeenCalledWith( - 'ApprovalController:addRequest', - { - id: requestId, - origin: ORIGIN_METAMASK, - type: ApprovalType.WatchAsset, - requestData: { - id: requestId, - interactingAddress, - asset, - }, + expect(controller.state).toStrictEqual(emptyState); }, - true, - ); - expect(getERC20TokenNameStub).toHaveBeenCalledWith( - asset.address, - 'networkClientId1', ); - expect(getNetworkClientByIdStub).toHaveBeenCalledWith('networkClientId1'); - generateRandomIdStub.mockRestore(); }); - it('throws and token is not added if pending approval fails', async function () { - const generateRandomIdStub = jest - .spyOn(tokensController, '_generateRandomId') - .mockReturnValue(requestId); + it('does not ignore tokens and clears stale state when isDeprecated toggles to true at runtime via ignoreTokens', async () => { + let deprecated = false; + await withController( + { options: { state: initialState, isDeprecated: () => deprecated } }, + ({ controller }) => { + expect(controller.state).toStrictEqual(initialState); - const errorMessage = 'Mock Error Message'; - const callActionSpy = jest - .spyOn(messenger, 'call') - .mockRejectedValue(new Error(errorMessage)); + deprecated = true; - await expect( - tokensController.watchAsset({ asset, type }), - ).rejects.toThrow(errorMessage); + controller.ignoreTokens(['0x03'], 'mainnet'); - expect(tokensController.state.tokens).toHaveLength(0); - expect(tokensController.state.tokens).toStrictEqual([]); - expect(callActionSpy).toHaveBeenCalledTimes(1); - expect(callActionSpy).toHaveBeenCalledWith( - 'ApprovalController:addRequest', - { - id: requestId, - origin: ORIGIN_METAMASK, - type: ApprovalType.WatchAsset, - requestData: { - id: requestId, - interactingAddress: '0x1', - asset, - }, + expect(controller.state).toStrictEqual(emptyState); }, - true, ); - - generateRandomIdStub.mockRestore(); }); - it('stores multiple tokens from a batched watchAsset confirmation screen correctly when user confirms', async function () { - const generateRandomIdStub = jest - .spyOn(tokensController, '_generateRandomId') - .mockImplementationOnce(() => requestId) - .mockImplementationOnce(() => '67890'); + it('does not add detected tokens and clears stale state when isDeprecated toggles to true at runtime via addDetectedTokens', async () => { + let deprecated = false; + await withController( + { options: { state: initialState, isDeprecated: () => deprecated } }, + async ({ controller }) => { + expect(controller.state).toStrictEqual(initialState); - const acceptedRequest = new Promise((resolve) => { - tokensController.subscribe((state) => { - if ( - state.allTokens?.[ChainId.mainnet]?.[interactingAddress].length === - 2 - ) { - resolve(); - } - }); - }); + deprecated = true; - const anotherAsset = { - address: '0x000000000000000000000000000000000000ABcD', - decimals: 18, - symbol: 'TEST', - image: 'image2', - name: undefined, - }; + await controller.addDetectedTokens( + [{ address: '0x05', symbol: 'NEW', decimals: 18 }], + { chainId: ChainId.mainnet }, + ); - tokensController.watchAsset({ asset, type, interactingAddress }); - tokensController.watchAsset({ - asset: anotherAsset, - type, - interactingAddress, - }); + expect(controller.state).toStrictEqual(emptyState); + }, + ); + }); - await approvalController.accept(requestId); - await approvalController.accept('67890'); - await acceptedRequest; + it('throws and clears stale state when isDeprecated toggles to true at runtime via updateTokenType', async () => { + let deprecated = false; + await withController( + { options: { state: initialState, isDeprecated: () => deprecated } }, + async ({ controller }) => { + expect(controller.state).toStrictEqual(initialState); - expect( - tokensController.state.allTokens[ChainId.mainnet][interactingAddress], - ).toHaveLength(2); - expect( - tokensController.state.allTokens[ChainId.mainnet][interactingAddress], - ).toStrictEqual([ - { - isERC721: false, - aggregators: [], - ...asset, - }, - { - isERC721: false, - aggregators: [], - ...anotherAsset, + deprecated = true; + + await expect( + controller.updateTokenType('0x03', 'mainnet'), + ).rejects.toThrow('TokensController is deprecated'); + + expect(controller.state).toStrictEqual(emptyState); }, - ]); - generateRandomIdStub.mockRestore(); + ); }); - }); - describe('onPreferencesStateChange', function () { - it('should update tokens list when set address changes', async function () { - const stub = stubCreateEthers(tokensController, false); - preferences.setSelectedAddress('0x1'); - await tokensController.addToken({ - address: '0x01', - symbol: 'A', - decimals: 4, - }); - await tokensController.addToken({ - address: '0x02', - symbol: 'B', - decimals: 5, - }); - preferences.setSelectedAddress('0x2'); - expect(tokensController.state.tokens).toStrictEqual([]); - await tokensController.addToken({ - address: '0x03', - symbol: 'C', - decimals: 6, - }); - preferences.setSelectedAddress('0x1'); - expect(tokensController.state.tokens).toStrictEqual([ - { - address: '0x01', - decimals: 4, - image: - 'https://static.metafi.codefi.network/api/v1/tokenIcons/1/0x01.png', - isERC721: false, - symbol: 'A', - aggregators: [], - name: undefined, - }, - { - address: '0x02', - decimals: 5, - image: - 'https://static.metafi.codefi.network/api/v1/tokenIcons/1/0x02.png', - isERC721: false, - symbol: 'B', - aggregators: [], - name: undefined, - }, - ]); - preferences.setSelectedAddress('0x2'); - expect(tokensController.state.tokens).toStrictEqual([ - { - address: '0x03', - decimals: 6, - image: - 'https://static.metafi.codefi.network/api/v1/tokenIcons/1/0x03.png', - isERC721: false, - symbol: 'C', - aggregators: [], - name: undefined, - }, - ]); + it('does not process watchAsset and clears stale state when isDeprecated toggles to true at runtime via watchAsset', async () => { + let deprecated = false; + await withController( + { options: { state: initialState, isDeprecated: () => deprecated } }, + async ({ controller }) => { + expect(controller.state).toStrictEqual(initialState); - stub.restore(); + deprecated = true; + + await controller.watchAsset({ + asset: { address: '0x05', symbol: 'NEW', decimals: 18 }, + type: 'ERC20', + networkClientId: 'mainnet', + }); + + expect(controller.state).toStrictEqual(emptyState); + }, + ); }); - }); - describe('onNetworkStateChange', function () { - it('should remove a token from its state on corresponding network', async function () { - const stub = stubCreateEthers(tokensController, false); + it('clears all stale state when isDeprecated toggles to true at runtime via clearIgnoredTokens', async () => { + let deprecated = false; + await withController( + { options: { state: initialState, isDeprecated: () => deprecated } }, + ({ controller }) => { + expect(controller.state).toStrictEqual(initialState); - changeNetwork(SEPOLIA); + deprecated = true; - await tokensController.addToken({ - address: '0x01', - symbol: 'A', - decimals: 4, - }); - await tokensController.addToken({ - address: '0x02', - symbol: 'B', - decimals: 5, - }); - const initialTokensFirst = tokensController.state.tokens; + controller.clearIgnoredTokens(); - changeNetwork(GOERLI); + expect(controller.state).toStrictEqual(emptyState); + }, + ); + }); - await tokensController.addToken({ - address: '0x03', - symbol: 'C', - decimals: 4, - }); - await tokensController.addToken({ - address: '0x04', - symbol: 'D', - decimals: 5, - }); + it('clears stale state on NetworkController:stateChange when isDeprecated toggles to true at runtime', async () => { + let deprecated = false; + await withController( + { options: { state: initialState, isDeprecated: () => deprecated } }, + ({ controller, triggerNetworkStateChange }) => { + expect(controller.state).toStrictEqual(initialState); - const initialTokensSecond = tokensController.state.tokens; + deprecated = true; - expect(initialTokensFirst).not.toStrictEqual(initialTokensSecond); + triggerNetworkStateChange({} as NetworkState, [ + { + op: 'remove', + path: ['networkConfigurationsByChainId', ChainId.mainnet], + }, + ]); - expect(initialTokensFirst).toStrictEqual([ - { - address: '0x01', - decimals: 4, - image: - 'https://static.metafi.codefi.network/api/v1/tokenIcons/11155111/0x01.png', - isERC721: false, - symbol: 'A', - aggregators: [], - name: undefined, - }, - { - address: '0x02', - decimals: 5, - image: - 'https://static.metafi.codefi.network/api/v1/tokenIcons/11155111/0x02.png', - isERC721: false, - symbol: 'B', - aggregators: [], - name: undefined, + expect(controller.state).toStrictEqual(emptyState); }, - ]); + ); + }); - expect(initialTokensSecond).toStrictEqual([ - { - address: '0x03', - decimals: 4, - image: - 'https://static.metafi.codefi.network/api/v1/tokenIcons/5/0x03.png', - isERC721: false, - symbol: 'C', - aggregators: [], - name: undefined, - }, - { - address: '0x04', - decimals: 5, - image: - 'https://static.metafi.codefi.network/api/v1/tokenIcons/5/0x04.png', - isERC721: false, - symbol: 'D', - aggregators: [], - name: undefined, - }, - ]); + it('clears stale state on KeyringController:accountRemoved when isDeprecated toggles to true at runtime', async () => { + let deprecated = false; + await withController( + { options: { state: initialState, isDeprecated: () => deprecated } }, + ({ controller, triggerAccountRemoved }) => { + expect(controller.state).toStrictEqual(initialState); + + deprecated = true; - changeNetwork(SEPOLIA); - expect(initialTokensFirst).toStrictEqual(tokensController.state.tokens); - changeNetwork(GOERLI); - expect(initialTokensSecond).toStrictEqual(tokensController.state.tokens); + triggerAccountRemoved('0x0001'); - stub.restore(); + expect(controller.state).toStrictEqual(emptyState); + }, + ); }); }); - describe('Clearing nested lists', function () { - const dummyTokens: Token[] = [ - { - address: '0x01', - symbol: 'barA', - decimals: 2, - aggregators: [], - image: undefined, - }, - ]; - const selectedAddress = '0x1'; - const tokenAddress = '0x01'; - - it('should clear nest allTokens under chain ID and selected address when an added token is ignored', async () => { - tokensController.configure({ - selectedAddress, - chainId: ChainId.mainnet, + describe('metadata', () => { + it('includes expected state in debug snapshots', async () => { + await withController(({ controller }) => { + expect( + deriveStateFromMetadata( + controller.state, + controller.metadata, + 'includeInDebugSnapshot', + ), + ).toMatchInlineSnapshot(`{}`); }); - await tokensController.addTokens(dummyTokens); - tokensController.ignoreTokens(['0x01']); - expect( - tokensController.state.allTokens[ChainId.mainnet][selectedAddress], - ).toStrictEqual([]); }); - it('should clear nest allIgnoredTokens under chain ID and selected address when an ignored token is re-added', async () => { - tokensController.configure({ - selectedAddress, - chainId: ChainId.mainnet, + it('includes expected state in state logs', async () => { + await withController(({ controller }) => { + expect( + deriveStateFromMetadata( + controller.state, + controller.metadata, + 'includeInStateLogs', + ), + ).toMatchInlineSnapshot(`{}`); }); - await tokensController.addTokens(dummyTokens); - tokensController.ignoreTokens([tokenAddress]); - await tokensController.addTokens(dummyTokens); - - expect( - tokensController.state.allIgnoredTokens[ChainId.mainnet][ - selectedAddress - ], - ).toStrictEqual([]); }); - it('should clear nest allDetectedTokens under chain ID and selected address when an detected token is added to tokens list', async () => { - tokensController.configure({ - selectedAddress, - chainId: ChainId.mainnet, + it('persists expected state', async () => { + await withController(({ controller }) => { + expect( + deriveStateFromMetadata( + controller.state, + controller.metadata, + 'persist', + ), + ).toMatchInlineSnapshot(` + { + "allDetectedTokens": {}, + "allIgnoredTokens": {}, + "allTokens": {}, + } + `); }); - await tokensController.addDetectedTokens(dummyTokens); - await tokensController.addTokens(dummyTokens); + }); - expect( - tokensController.state.allDetectedTokens[ChainId.mainnet][ - selectedAddress - ], - ).toStrictEqual([]); + it('exposes expected state to UI', async () => { + await withController(({ controller }) => { + expect( + deriveStateFromMetadata( + controller.state, + controller.metadata, + 'usedInUi', + ), + ).toMatchInlineSnapshot(` + { + "allDetectedTokens": {}, + "allIgnoredTokens": {}, + "allTokens": {}, + } + `); + }); }); }); +}); - describe('onTokenListStateChange', () => { - it('onTokenListChange', async () => { - const stub = stubCreateEthers(tokensController, false); - await tokensController.addToken({ - address: '0x01', - symbol: 'bar', - decimals: 2, - }); - expect(tokensController.state.tokens[0]).toStrictEqual({ - address: '0x01', - decimals: 2, - image: - 'https://static.metafi.codefi.network/api/v1/tokenIcons/1/0x01.png', - symbol: 'bar', - isERC721: false, - aggregators: [], - name: undefined, - }); +type WithControllerCallback = ({ + controller, + changeNetwork, + messenger, + approvalController, + triggerSelectedAccountChange, + triggerAccountRemoved, +}: { + controller: TokensController; + changeNetwork: (networkControllerState: { + selectedNetworkClientId: NetworkClientId; + }) => void; + messenger: RootMessenger; + approvalController: ApprovalController; + triggerSelectedAccountChange: (internalAccount: InternalAccount) => void; + triggerAccountRemoved: (accountAddress: string) => void; + triggerNetworkStateChange: ( + networkState: NetworkState, + patches: Patch[], + ) => void; + getAccountHandler: jest.Mock; + getSelectedAccountHandler: jest.Mock; +}) => Promise | ReturnValue; + +type WithControllerMockArgs = { + getAccount?: InternalAccount; + getSelectedAccount?: InternalAccount; +}; - const sampleMainnetTokenList = { - '0x01': { - address: '0x01', - symbol: 'bar', - decimals: 2, - occurrences: 1, - name: 'BarName', - iconUrl: - 'https://static.metafi.codefi.network/api/v1/tokenIcons/1/0x01.png', - aggregators: ['Aave'], - }, - }; +type WithControllerArgs = + | [WithControllerCallback] + | [ + { + options?: Partial[0]>; + mockNetworkClientConfigurationsByNetworkClientId?: Record< + NetworkClientId, + NetworkClientConfiguration + >; + mocks?: WithControllerMockArgs; + listAccounts?: InternalAccount[]; + }, + WithControllerCallback, + ]; - await tokenListStateChangeListener({ tokenList: sampleMainnetTokenList }); +/** + * Runs a callback, instantiating a TokensController (and friends) for use in + * tests, then ensuring that they are properly destroyed after the callback + * ends. + * + * @param args - Arguments to this function. + * @param args.options - Controller options. + * @param args.mockNetworkClientConfigurationsByNetworkClientId - Used to construct + * mock versions of network clients and ultimately mock the + * `NetworkController:getNetworkClientById` action. + * @param args.mocks - Move values for actions to be mocked. + * @returns A collection of test controllers and mocks. + */ +async function withController( + ...args: WithControllerArgs +): Promise { + const [ + { + options = {}, + mockNetworkClientConfigurationsByNetworkClientId = {}, + mocks = {} as WithControllerMockArgs, + listAccounts = [], + }, + fn, + ] = args.length === 2 ? args : [{}, args[0]]; + + const messenger: RootMessenger = new Messenger({ + namespace: MOCK_ANY_NAMESPACE, + }); - expect(tokensController.state.tokens[0]).toStrictEqual({ - address: '0x01', - decimals: 2, - image: - 'https://static.metafi.codefi.network/api/v1/tokenIcons/1/0x01.png', - symbol: 'bar', - isERC721: false, - aggregators: [], - name: 'BarName', - }); - stub.restore(); + const approvalControllerMessenger = new Messenger< + 'ApprovalController', + MessengerActions, + MessengerEvents, + RootMessenger + >({ + namespace: 'ApprovalController', + parent: messenger, + }); + const approvalController = new ApprovalController({ + messenger: approvalControllerMessenger, + showApprovalRequest: jest.fn(), + typesExcludedFromRateLimiting: [ApprovalType.WatchAsset], + }); + + const tokensControllerMessenger = new Messenger< + 'TokensController', + MessengerActions, + MessengerEvents, + RootMessenger + >({ + namespace: 'TokensController', + parent: messenger, + }); + messenger.delegate({ + messenger: tokensControllerMessenger, + actions: [ + 'ApprovalController:addRequest', + 'NetworkController:getNetworkClientById', + 'AccountsController:getAccount', + 'AccountsController:getSelectedAccount', + 'AccountsController:listAccounts', + ], + events: [ + 'NetworkController:networkDidChange', + 'NetworkController:stateChange', + 'AccountsController:selectedEvmAccountChange', + 'KeyringController:accountRemoved', + ], + }); + + const getAccountHandler = jest.fn(); + messenger.registerActionHandler( + 'AccountsController:getAccount', + getAccountHandler.mockReturnValue( + mocks?.getAccount ?? defaultMockInternalAccount, + ), + ); + + const getSelectedAccountHandler = jest.fn(); + messenger.registerActionHandler( + 'AccountsController:getSelectedAccount', + getSelectedAccountHandler.mockReturnValue( + mocks?.getSelectedAccount ?? defaultMockInternalAccount, + ), + ); + + const mockListAccounts = jest.fn().mockReturnValue(listAccounts); + messenger.registerActionHandler( + 'AccountsController:listAccounts', + mockListAccounts, + ); + + const tokenListService = { + fetchTokensByChainId: jest.fn().mockResolvedValue({}), + } as unknown as import('./TokenListService.js').TokenListService; + + const controller = new TokensController({ + chainId: ChainId.mainnet, + // The tests assume that this is set, but they shouldn't make that + // assumption. But we have to do this due to a bug in TokensController + // where the provider can possibly be `undefined` if `networkClientId` is + // not specified. + provider: new FakeProvider(), + messenger: tokensControllerMessenger, + tokenListService, + ...options, + }); + + const triggerSelectedAccountChange = (internalAccount: InternalAccount) => { + getAccountHandler.mockReturnValue(internalAccount); + messenger.publish( + 'AccountsController:selectedEvmAccountChange', + internalAccount, + ); + }; + + const triggerAccountRemoved = (accountAddress: string) => { + messenger.publish('KeyringController:accountRemoved', accountAddress); + }; + + const changeNetwork = ({ + selectedNetworkClientId, + }: { + selectedNetworkClientId: NetworkClientId; + }) => { + messenger.publish('NetworkController:networkDidChange', { + ...getDefaultNetworkControllerState(), + selectedNetworkClientId, }); + }; + + const getNetworkClientById = buildMockGetNetworkClientById( + mockNetworkClientConfigurationsByNetworkClientId, + ); + messenger.registerActionHandler( + 'NetworkController:getNetworkClientById', + getNetworkClientById, + ); + + const triggerNetworkStateChange = ( + networkState: NetworkState, + patches: Patch[], + ) => { + messenger.publish('NetworkController:stateChange', networkState, patches); + }; + + return await fn({ + controller, + changeNetwork, + messenger, + approvalController, + triggerSelectedAccountChange, + triggerNetworkStateChange, + triggerAccountRemoved, + getAccountHandler, + getSelectedAccountHandler, }); -}); +} + +/** + * Constructs an object that satisfies the Token shape for testing, + * offering a default shape while allowing any property to be overridden. + * + * @param overrides - Properties to override the object with. + * @returns The complete Token. + */ +function buildToken(overrides: Partial = {}): Token { + // `Object.assign` allows for properties to be `undefined` in `overrides`, + // and will copy them over + return Object.assign( + { + address: '0x000000000000000000000000000000000000dEaD', + decimals: 12, + image: 'image', + symbol: 'TOKEN', + }, + overrides, + ); +} + +/** + * Constructs an object that satisfies the Token shape for testing, + * offering a default shape (guaranteeing a name) while allowing any property to + * be overridden. + * + * @param overrides - Properties to override the object with. + * @returns The complete Token. + */ +function buildTokenWithName( + overrides: Partial = {}, +): Token & { name: string } { + // `Object.assign` allows for properties to be `undefined` in `overrides`, + // and will copy them over + return Object.assign( + { + address: '0x000000000000000000000000000000000000dEaD', + decimals: 12, + image: 'image', + name: 'Some Token', + symbol: 'TOKEN', + }, + overrides, + ); +} + +/** + * Builds a mock ERC20 standard. + * + * @param args - The arguments to this function. + * @param args.tokenName - The desired return value of getTokenName. + * @param args.tokenSymbol - The desired return value of getTokenSymbol. + * @param args.tokenDecimals - The desired return value of getTokenDecimals. + * @returns The mock ERC20 standard. + */ +function buildMockERC20Standard({ + tokenName = 'Some Token', + tokenSymbol = 'TEST', + tokenDecimals = '1', +}: { + tokenName?: string; + tokenSymbol?: string; + tokenDecimals?: string; +} = {}): ERC20Standard { + // @ts-expect-error This intentionally does not support all of the methods + // for the standard, only the ones we care about + return { + getTokenName: async () => tokenName, + getTokenSymbol: async () => tokenSymbol, + getTokenDecimals: async () => tokenDecimals, + }; +} + +/** + * Builds a mock ERC20 standard from a Token object. + * + * @param token - The token to use. The token must have a name. + * @returns The mock ERC20 standard. + */ +function buildMockERC20StandardFromToken( + token: Token & { name: string }, +): ERC20Standard { + // @ts-expect-error This intentionally does not support all of the methods + // for the standard, only the ones we care about + return { + getTokenName: async () => token.name, + getTokenSymbol: async () => token.symbol, + getTokenDecimals: async () => token.decimals.toString(), + }; +} + +/** + * Builds a mock ERC1155 standard. + * + * @param args - The arguments to this function. + * @param args.contractSupportsBase1155Interface - The desired return value of + * contractSupportsBase1155Interface. + * @returns The mock ERC20 standard. + */ +function buildMockERC1155Standard({ + contractSupportsBase1155Interface, +}: { + contractSupportsBase1155Interface: boolean; +}): ERC1155Standard { + // @ts-expect-error This intentionally does not support all of the methods + // for the standard, only the ones we care about + return { + contractSupportsBase1155Interface: async () => + contractSupportsBase1155Interface, + }; +} + +/** + * Builds a mock ERC721 contract (created via Ethers) for testing. + * + * @param args - The arguments to this function. + * @param args.supportsInterface - Whether the contract will report as supporting + * the given ERC721 ABI. + * @returns The mock contract. + */ +function buildMockEthersERC721Contract({ + supportsInterface, +}: { + supportsInterface: boolean; +}): Contract { + // @ts-expect-error This intentionally does not support all of the methods + // for the contract, only the ones we care about + return { + supportsInterface: async () => supportsInterface, + }; +} diff --git a/packages/assets-controllers/src/TokensController.ts b/packages/assets-controllers/src/TokensController.ts index d76ea7c2984..ca22af31657 100644 --- a/packages/assets-controllers/src/TokensController.ts +++ b/packages/assets-controllers/src/TokensController.ts @@ -1,10 +1,16 @@ import { Contract } from '@ethersproject/contracts'; import { Web3Provider } from '@ethersproject/providers'; -import type { AddApprovalRequest } from '@metamask/approval-controller'; import type { - BaseConfig, - BaseState, - RestrictedControllerMessenger, + AccountsControllerGetAccountAction, + AccountsControllerGetSelectedAccountAction, + AccountsControllerListAccountsAction, + AccountsControllerSelectedEvmAccountChangeEvent, +} from '@metamask/accounts-controller'; +import type { ApprovalControllerAddRequestAction } from '@metamask/approval-controller'; +import type { + ControllerGetStateAction, + ControllerStateChangeEvent, + StateMetadata, } from '@metamask/base-controller'; import { BaseController } from '@metamask/base-controller'; import contractsMap from '@metamask/contract-metadata'; @@ -14,52 +20,49 @@ import { ORIGIN_METAMASK, ApprovalType, ERC20, + ERC721, + ERC1155, + isValidHexAddress, + safelyExecute, } from '@metamask/controller-utils'; +import type { KeyringControllerAccountRemovedEvent } from '@metamask/keyring-controller'; +import type { InternalAccount } from '@metamask/keyring-internal-api'; +import type { Messenger } from '@metamask/messenger'; import { abiERC721 } from '@metamask/metamask-eth-abis'; import type { NetworkClientId, - NetworkController, + NetworkControllerGetNetworkClientByIdAction, + NetworkControllerNetworkDidChangeEvent, + NetworkControllerStateChangeEvent, NetworkState, + Provider, } from '@metamask/network-controller'; -import type { PreferencesState } from '@metamask/preferences-controller'; -import type { Hex } from '@metamask/utils'; +import { rpcErrors } from '@metamask/rpc-errors'; +import { isStrictHexString } from '@metamask/utils'; +import type { Hex, Json } from '@metamask/utils'; import { Mutex } from 'async-mutex'; -import { EventEmitter } from 'events'; +import type { Patch } from 'immer'; +import { cloneDeep } from 'lodash'; import { v1 as random } from 'uuid'; -import type { AssetsContractController } from './AssetsContractController'; -import { - formatAggregatorNames, - formatIconUrlWithProxy, - validateTokenToWatch, -} from './assetsUtil'; +import { formatAggregatorNames, formatIconUrlWithProxy } from './assetsUtil.js'; +import { ERC20Standard } from './Standards/ERC20Standard.js'; +import { ERC1155Standard } from './Standards/NftStandards/ERC1155/ERC1155Standard.js'; import { fetchTokenMetadata, TOKEN_METADATA_NO_SUPPORT_ERROR, -} from './token-service'; -import type { - TokenListMap, - TokenListState, - TokenListToken, -} from './TokenListController'; -import type { Token } from './TokenRatesController'; - -/** - * @type TokensConfig - * - * Tokens controller configuration - * @property selectedAddress - Vault selected address - */ -export interface TokensConfig extends BaseConfig { - selectedAddress: string; - chainId: Hex; - provider: any; -} + TokenRwaData, +} from './token-service.js'; +import type { TokenListMap, TokenListToken } from './TokenListController.js'; +import type { TokenListService } from './TokenListService.js'; +import type { Token } from './TokenRatesController.js'; +import type { TokensControllerMethodActions } from './TokensController-method-action-types.js'; /** * @type SuggestedAssetMeta * * Suggested asset by EIP747 meta data + * * @property id - Generated UUID associated with this suggested asset * @property time - Timestamp associated with this this suggested asset * @property type - Type type this suggested asset @@ -72,203 +75,403 @@ type SuggestedAssetMeta = { type: string; asset: Token; interactingAddress: string; + origin?: string; + pageMeta?: Record; +}; + +type WatchAssetRequestMetadata = { + origin?: string; + pageMeta?: Record; +}; + +const getNonEmptyString = ( + ...candidates: (string | undefined)[] +): string | undefined => { + return candidates.find( + (candidate) => typeof candidate === 'string' && candidate.trim() !== '', + ); }; /** - * @type TokensState + * @type TokensControllerState * * Assets controller state - * @property tokens - List of tokens associated with the active network and address pair - * @property ignoredTokens - List of ignoredTokens associated with the active network and address pair - * @property detectedTokens - List of detected tokens associated with the active network and address pair + * * @property allTokens - Object containing tokens by network and account * @property allIgnoredTokens - Object containing hidden/ignored tokens by network and account * @property allDetectedTokens - Object containing tokens detected with non-zero balances */ -export interface TokensState extends BaseState { - tokens: Token[]; - ignoredTokens: string[]; - detectedTokens: Token[]; +export type TokensControllerState = { allTokens: { [chainId: Hex]: { [key: string]: Token[] } }; allIgnoredTokens: { [chainId: Hex]: { [key: string]: string[] } }; allDetectedTokens: { [chainId: Hex]: { [key: string]: Token[] } }; -} +}; + +const metadata: StateMetadata = { + allTokens: { + includeInStateLogs: false, + persist: true, + includeInDebugSnapshot: false, + usedInUi: true, + }, + allIgnoredTokens: { + includeInStateLogs: false, + persist: true, + includeInDebugSnapshot: false, + usedInUi: true, + }, + allDetectedTokens: { + includeInStateLogs: false, + persist: true, + includeInDebugSnapshot: false, + usedInUi: true, + }, +}; -/** - * The name of the {@link TokensController}. - */ const controllerName = 'TokensController'; +export type TokensControllerGetStateAction = ControllerGetStateAction< + typeof controllerName, + TokensControllerState +>; + +export type TokensControllerActions = + | TokensControllerGetStateAction + | TokensControllerMethodActions; + /** * The external actions available to the {@link TokensController}. */ -type AllowedActions = AddApprovalRequest; +export type AllowedActions = + | ApprovalControllerAddRequestAction + | NetworkControllerGetNetworkClientByIdAction + | AccountsControllerGetAccountAction + | AccountsControllerGetSelectedAccountAction + | AccountsControllerListAccountsAction; + +export type TokensControllerStateChangeEvent = ControllerStateChangeEvent< + typeof controllerName, + TokensControllerState +>; + +export type TokensControllerEvents = TokensControllerStateChangeEvent; + +export type AllowedEvents = + | NetworkControllerStateChangeEvent + | NetworkControllerNetworkDidChangeEvent + | AccountsControllerSelectedEvmAccountChangeEvent + | KeyringControllerAccountRemovedEvent; /** * The messenger of the {@link TokensController}. */ -export type TokensControllerMessenger = RestrictedControllerMessenger< +export type TokensControllerMessenger = Messenger< typeof controllerName, - AllowedActions, - never, - AllowedActions['type'], - never + TokensControllerActions | AllowedActions, + TokensControllerEvents | AllowedEvents >; +export const getDefaultTokensState = (): TokensControllerState => { + return { + allTokens: {}, + allIgnoredTokens: {}, + allDetectedTokens: {}, + }; +}; + +const MESSENGER_EXPOSED_METHODS = [ + 'addDetectedTokens', + 'addTokens', + 'addToken', + 'ignoreTokens', + 'updateTokenType', + 'watchAsset', + 'clearIgnoredTokens', + 'resetState', +] as const; + /** * Controller that stores assets and exposes convenience methods */ export class TokensController extends BaseController< - TokensConfig, - TokensState + typeof controllerName, + TokensControllerState, + TokensControllerMessenger > { - private readonly mutex = new Mutex(); + readonly #mutex = new Mutex(); - private abortController: AbortController; + #selectedAccountId: string; - private readonly messagingSystem: TokensControllerMessenger; + readonly #provider: Provider; - /** - * Fetch metadata for a token. - * - * @param tokenAddress - The address of the token. - * @returns The token metadata. - */ - private async fetchTokenMetadata( - tokenAddress: string, - ): Promise { - try { - const token = await fetchTokenMetadata( - this.config.chainId, - tokenAddress, - this.abortController.signal, - ); - return token; - } catch (error) { - if ( - error instanceof Error && - error.message.includes(TOKEN_METADATA_NO_SUPPORT_ERROR) - ) { - return undefined; - } - throw error; - } - } + readonly #abortController: AbortController; - /** - * EventEmitter instance used to listen to specific EIP747 events - */ - hub = new EventEmitter(); + readonly #isDeprecated: () => boolean; /** - * Name of this controller used during composition - */ - override name = 'TokensController'; - - private readonly getERC20TokenName: AssetsContractController['getERC20TokenName']; - - private readonly getNetworkClientById: NetworkController['getNetworkClientById']; - - /** - * Creates a TokensController instance. + * Tokens controller options * - * @param options - The controller options. + * @param options - Constructor options. * @param options.chainId - The chain ID of the current network. - * @param options.onPreferencesStateChange - Allows subscribing to preference controller state changes. - * @param options.onNetworkStateChange - Allows subscribing to network controller state changes. - * @param options.onTokenListStateChange - Allows subscribing to token list controller state changes. - * @param options.getERC20TokenName - Gets the ERC-20 token name. - * @param options.getNetworkClientById - Gets the network client with the given id from the NetworkController. - * @param options.config - Initial options used to configure this controller. + * @param options.provider - Network provider. * @param options.state - Initial state to set on this controller. - * @param options.messenger - The controller messenger. + * @param options.messenger - The messenger. + * @param options.tokenListService - Shared service for fetching token metadata per chain. + * @param options.isDeprecated - Optional function that returns true to completely + * disable this controller (no requests, no state updates). When it returns + * `true`, `allTokens`, `allIgnoredTokens`, and `allDetectedTokens` are reset to + * `{}` at construction and at every entry point, so no stale token data remains + * in state. The function is evaluated dynamically on each entry point so it can + * be toggled at runtime. Intended for use when a higher-level controller + * (e.g. AssetsController) supersedes this one. */ constructor({ - chainId: initialChainId, - onPreferencesStateChange, - onNetworkStateChange, - onTokenListStateChange, - getERC20TokenName, - getNetworkClientById, - config, + provider, state, messenger, + tokenListService, + isDeprecated = (): boolean => false, }: { chainId: Hex; - onPreferencesStateChange: ( - listener: (preferencesState: PreferencesState) => void, - ) => void; - onNetworkStateChange: ( - listener: (networkState: NetworkState) => void, - ) => void; - onTokenListStateChange: ( - listener: (tokenListState: TokenListState) => void, - ) => void; - getERC20TokenName: AssetsContractController['getERC20TokenName']; - getNetworkClientById: NetworkController['getNetworkClientById']; - config?: Partial; - state?: Partial; + provider: Provider; + state?: Partial; messenger: TokensControllerMessenger; + tokenListService: TokenListService; + isDeprecated?: () => boolean; }) { - super(config, state); + super({ + name: controllerName, + metadata, + messenger, + state: { + ...getDefaultTokensState(), + ...state, + }, + }); - this.defaultConfig = { - selectedAddress: '', - chainId: initialChainId, - provider: undefined, - ...config, - }; + this.#provider = provider; + this.#isDeprecated = isDeprecated; - this.defaultState = { - tokens: [], - ignoredTokens: [], - detectedTokens: [], - allTokens: {}, - allIgnoredTokens: {}, - allDetectedTokens: {}, - ...state, - }; + this.#selectedAccountId = this.#getSelectedAccount().id; + + this.#abortController = new AbortController(); + + messenger.registerMethodActionHandlers(this, MESSENGER_EXPOSED_METHODS); + + this.messenger.subscribe( + 'AccountsController:selectedEvmAccountChange', + this.#onSelectedAccountChange.bind(this), + ); + + this.messenger.subscribe( + 'NetworkController:stateChange', + this.#onNetworkStateChange.bind(this), + ); + + this.messenger.subscribe( + 'KeyringController:accountRemoved', + (accountAddress: string) => this.#handleOnAccountRemoved(accountAddress), + ); - this.initialize(); - this.abortController = new AbortController(); - this.getERC20TokenName = getERC20TokenName; - this.getNetworkClientById = getNetworkClientById; - - this.messagingSystem = messenger; - - onPreferencesStateChange(({ selectedAddress }) => { - const { allTokens, allIgnoredTokens, allDetectedTokens } = this.state; - const { chainId } = this.config; - this.configure({ selectedAddress }); - this.update({ - tokens: allTokens[chainId]?.[selectedAddress] || [], - ignoredTokens: allIgnoredTokens[chainId]?.[selectedAddress] || [], - detectedTokens: allDetectedTokens[chainId]?.[selectedAddress] || [], + if (this.#isDeprecated()) { + this.#enforceDisabledState(); + } else { + // Enrich persisted tokens with name/rwaData from the token list once at init. + this.#enrichTokensFromTokenList(tokenListService).catch(() => { + // Tokens remain usable without metadata enrichment }); + } + } + + /** + * Clears all persisted token state so that no stale data remains. + * + * Called from every entry point when `isDeprecated()` is true so that a + * runtime toggle propagates to state immediately, even if the controller was + * originally constructed while it was enabled. The update is skipped when + * all three maps are already empty to avoid emitting redundant state changes. + */ + #enforceDisabledState(): void { + const { allTokens, allIgnoredTokens, allDetectedTokens } = this.state; + if ( + Object.keys(allTokens).length === 0 && + Object.keys(allIgnoredTokens).length === 0 && + Object.keys(allDetectedTokens).length === 0 + ) { + return; + } + this.update((state) => { + state.allTokens = {}; + state.allIgnoredTokens = {}; + state.allDetectedTokens = {}; }); + } - onNetworkStateChange(({ providerConfig }) => { - const { allTokens, allIgnoredTokens, allDetectedTokens } = this.state; - const { selectedAddress } = this.config; - const { chainId } = providerConfig; - this.abortController.abort(); - this.abortController = new AbortController(); - this.configure({ chainId }); - this.update({ - tokens: allTokens[chainId]?.[selectedAddress] || [], - ignoredTokens: allIgnoredTokens[chainId]?.[selectedAddress] || [], - detectedTokens: allDetectedTokens[chainId]?.[selectedAddress] || [], - }); + async #enrichTokensFromTokenList( + tokenListService: TokenListService, + ): Promise { + const chainIds = Object.keys(this.state.allTokens) as Hex[]; + if (chainIds.length === 0) { + return; + } + + // Fetch all chain data concurrently before touching state so the async gap + // is as short as possible and we never hold a stale T0 snapshot while + // awaiting individual chain requests. + // Promise.allSettled ensures a transient error on one chain does not + // prevent other chains from being enriched. + const results = await Promise.allSettled( + chainIds.map(async (chainId) => { + const data = await tokenListService.fetchTokensByChainId(chainId); + return [chainId, data] as const; + }), + ); + const chainDataMap = Object.fromEntries( + results + .filter( + ( + result, + ): result is PromiseFulfilledResult => + result.status === 'fulfilled', + ) + .map((result) => result.value), + ); + + // Read selectedAddress inside the updater so it reflects the live account + // at the moment the state write happens, not a snapshot taken before the + // async fetch gap above. + this.update((state) => { + const selectedAddress = this.#getSelectedAddress(); + for (const chainId of chainIds) { + const chainData = chainDataMap[chainId]; + const tokens = state.allTokens[chainId]?.[selectedAddress]; + if (!tokens || !chainData) { + continue; + } + for (const token of tokens) { + const cachedToken = chainData[token.address.toLowerCase()]; + if (cachedToken?.name && !token.name) { + token.name = cachedToken.name; + } + if (cachedToken?.rwaData) { + token.rwaData = cachedToken.rwaData; + } + } + } }); + } + + #handleOnAccountRemoved(accountAddress: string) { + if (this.#isDeprecated()) { + this.#enforceDisabledState(); + return; + } + + const isEthAddress = + isStrictHexString(accountAddress.toLowerCase()) && + isValidHexAddress(accountAddress); + + if (!isEthAddress) { + return; + } + + const { allTokens, allIgnoredTokens, allDetectedTokens } = this.state; + const newAllTokens = cloneDeep(allTokens); + const newAllDetectedTokens = cloneDeep(allDetectedTokens); + const newAllIgnoredTokens = cloneDeep(allIgnoredTokens); + + for (const chainId of Object.keys(newAllTokens)) { + if (newAllTokens[chainId as Hex][accountAddress]) { + delete newAllTokens[chainId as Hex][accountAddress]; + } + } + + for (const chainId of Object.keys(newAllDetectedTokens)) { + if (newAllDetectedTokens[chainId as Hex][accountAddress]) { + delete newAllDetectedTokens[chainId as Hex][accountAddress]; + } + } - onTokenListStateChange(({ tokenList }) => { - const { tokens } = this.state; - if (tokens.length && !tokens[0].name) { - this.updateTokensAttribute(tokenList, 'name'); + for (const chainId of Object.keys(newAllIgnoredTokens)) { + if (newAllIgnoredTokens[chainId as Hex][accountAddress]) { + delete newAllIgnoredTokens[chainId as Hex][accountAddress]; } + } + + this.update((state) => { + state.allTokens = newAllTokens; + state.allIgnoredTokens = newAllIgnoredTokens; + state.allDetectedTokens = newAllDetectedTokens; }); } + /** + * Handles the event when the network state changes. + * + * @param _ - The network state. + * @param patches - An array of patch operations performed on the network state. + */ + #onNetworkStateChange(_: NetworkState, patches: Patch[]) { + if (this.#isDeprecated()) { + this.#enforceDisabledState(); + return; + } + + // Remove state for deleted networks + for (const patch of patches) { + if ( + patch.op === 'remove' && + patch.path[0] === 'networkConfigurationsByChainId' + ) { + const removedChainId = patch.path[1] as Hex; + + this.update((state) => { + delete state.allTokens[removedChainId]; + delete state.allIgnoredTokens[removedChainId]; + delete state.allDetectedTokens[removedChainId]; + }); + } + } + } + + /** + * Handles the selected account change in the accounts controller. + * + * @param selectedAccount - The new selected account + */ + #onSelectedAccountChange(selectedAccount: InternalAccount) { + this.#selectedAccountId = selectedAccount.id; + } + + /** + * Fetch metadata for a token. + * + * @param tokenAddress - The address of the token. + * @param chainId - The chain ID of the network on which the token is detected. + * @returns The token metadata. + */ + async #fetchTokenMetadata( + tokenAddress: string, + chainId: Hex, + ): Promise { + try { + const token = await fetchTokenMetadata( + chainId, + tokenAddress, + this.#abortController.signal, + ); + return token; + } catch (error) { + if ( + error instanceof Error && + error.message.includes(TOKEN_METADATA_NO_SUPPORT_ERROR) + ) { + return undefined; + } + throw error; + } + } + /** * Adds a token to the stored token list. * @@ -280,6 +483,7 @@ export class TokensController extends BaseController< * @param options.image - Image of the token. * @param options.interactingAddress - The address of the account to add a token to. * @param options.networkClientId - Network Client ID. + * @param options.rwaData - Optional RWA data for the token. * @returns Current token list. */ async addToken({ @@ -290,6 +494,7 @@ export class TokensController extends BaseController< image, interactingAddress, networkClientId, + rwaData, }: { address: string; symbol: string; @@ -297,58 +502,58 @@ export class TokensController extends BaseController< name?: string; image?: string; interactingAddress?: string; - networkClientId?: NetworkClientId; + networkClientId: NetworkClientId; + rwaData?: TokenRwaData; }): Promise { - const { chainId, selectedAddress } = this.config; - const releaseLock = await this.mutex.acquire(); - const { allTokens, allIgnoredTokens, allDetectedTokens } = this.state; - let currentChainId = chainId; - if (networkClientId) { - currentChainId = - this.getNetworkClientById(networkClientId).configuration.chainId; + if (this.#isDeprecated()) { + this.#enforceDisabledState(); + return []; } - const accountAddress = interactingAddress || selectedAddress; - const isInteractingWithWalletAccount = accountAddress === selectedAddress; + const releaseLock = await this.#mutex.acquire(); + const { allTokens, allIgnoredTokens, allDetectedTokens } = this.state; + + const chainIdToUse = this.messenger.call( + 'NetworkController:getNetworkClientById', + networkClientId, + ).configuration.chainId; + + const accountAddress = + this.#getAddressOrSelectedAddress(interactingAddress); try { address = toChecksumHexAddress(address); - const tokens = allTokens[currentChainId]?.[accountAddress] || []; + const tokens = allTokens[chainIdToUse]?.[accountAddress] ?? []; const ignoredTokens = - allIgnoredTokens[currentChainId]?.[accountAddress] || []; + allIgnoredTokens[chainIdToUse]?.[accountAddress] ?? []; const detectedTokens = - allDetectedTokens[currentChainId]?.[accountAddress] || []; + allDetectedTokens[chainIdToUse]?.[accountAddress] ?? []; const newTokens: Token[] = [...tokens]; const [isERC721, tokenMetadata] = await Promise.all([ - this._detectIsERC721(address, networkClientId), + this.#detectIsERC721(address, networkClientId), // TODO parameterize the token metadata fetch by networkClientId - this.fetchTokenMetadata(address), + this.#fetchTokenMetadata(address, chainIdToUse), ]); - // TODO remove this once this method is fully parameterized by networkClientId - if (!networkClientId && currentChainId !== this.config.chainId) { - throw new Error( - 'TokensController Error: Switched networks while adding token', - ); - } const newEntry: Token = { address, symbol, decimals, image: - image || - formatIconUrlWithProxy({ - chainId: currentChainId, - tokenAddress: address, - }), + image && image.trim() !== '' + ? image + : formatIconUrlWithProxy({ + chainId: chainIdToUse, + tokenAddress: address, + }), isERC721, - aggregators: formatAggregatorNames(tokenMetadata?.aggregators || []), + aggregators: formatAggregatorNames(tokenMetadata?.aggregators ?? []), name, + ...(rwaData !== undefined && { rwaData }), }; - const previousEntry = newTokens.find( + const previousIndex = newTokens.findIndex( (token) => token.address.toLowerCase() === address.toLowerCase(), ); - if (previousEntry) { - const previousIndex = newTokens.indexOf(previousEntry); + if (previousIndex !== -1) { newTokens[previousIndex] = newEntry; } else { newTokens.push(newEntry); @@ -362,31 +567,23 @@ export class TokensController extends BaseController< ); const { newAllTokens, newAllIgnoredTokens, newAllDetectedTokens } = - this._getNewAllTokensState({ + this.#getNewAllTokensState({ newTokens, newIgnoredTokens, newDetectedTokens, interactingAddress: accountAddress, - interactingChainId: currentChainId, + interactingChainId: chainIdToUse, }); - let newState: Partial = { + const newState: Partial = { allTokens: newAllTokens, allIgnoredTokens: newAllIgnoredTokens, allDetectedTokens: newAllDetectedTokens, }; - // Only update active tokens if user is interacting with their active wallet account. - if (isInteractingWithWalletAccount) { - newState = { - ...newState, - tokens: newTokens, - ignoredTokens: newIgnoredTokens, - detectedTokens: newDetectedTokens, - }; - } - - this.update(newState); + this.update((state) => { + Object.assign(state, newState); + }); return newTokens; } finally { releaseLock(); @@ -399,18 +596,33 @@ export class TokensController extends BaseController< * @param tokensToImport - Array of tokens to import. * @param networkClientId - Optional network client ID used to determine interacting chain ID. */ - async addTokens(tokensToImport: Token[], networkClientId?: NetworkClientId) { - const releaseLock = await this.mutex.acquire(); - const { tokens, detectedTokens, ignoredTokens } = this.state; + async addTokens(tokensToImport: Token[], networkClientId: NetworkClientId) { + if (this.#isDeprecated()) { + this.#enforceDisabledState(); + return; + } + + const releaseLock = await this.#mutex.acquire(); + const { allTokens, allIgnoredTokens, allDetectedTokens } = this.state; const importedTokensMap: { [key: string]: true } = {}; + + const interactingChainId = this.messenger.call( + 'NetworkController:getNetworkClientById', + networkClientId, + ).configuration.chainId; + // Used later to dedupe imported tokens - const newTokensMap = tokens.reduce((output, current) => { - output[current.address] = current; + const newTokensMap = [ + ...(allTokens[interactingChainId]?.[this.#getSelectedAccount().address] ?? + []), + ...tokensToImport, + ].reduce<{ [address: string]: Token }>((output, token) => { + output[toChecksumHexAddress(token.address)] = token; return output; - }, {} as { [address: string]: Token }); + }, {}); try { tokensToImport.forEach((tokenToAdd) => { - const { address, symbol, decimals, image, aggregators, name } = + const { address, symbol, decimals, image, aggregators, name, rwaData } = tokenToAdd; const checksumAddress = toChecksumHexAddress(address); const formattedToken: Token = { @@ -420,41 +632,40 @@ export class TokensController extends BaseController< image, aggregators, name, + ...(rwaData && { rwaData }), }; - newTokensMap[address] = formattedToken; + newTokensMap[checksumAddress] = formattedToken; importedTokensMap[address.toLowerCase()] = true; return formattedToken; }); const newTokens = Object.values(newTokensMap); - const newDetectedTokens = detectedTokens.filter( - (token) => !importedTokensMap[token.address.toLowerCase()], - ); - const newIgnoredTokens = ignoredTokens.filter( - (tokenAddress) => !newTokensMap[tokenAddress.toLowerCase()], + const newIgnoredTokens = allIgnoredTokens[interactingChainId]?.[ + this.#getSelectedAddress() + ]?.filter( + (tokenAddress) => !newTokensMap[toChecksumHexAddress(tokenAddress)], ); - let interactingChainId; - if (networkClientId) { - interactingChainId = - this.getNetworkClientById(networkClientId).configuration.chainId; - } + const detectedTokensForGivenChain = interactingChainId + ? allDetectedTokens?.[interactingChainId]?.[this.#getSelectedAddress()] + : []; + + const newDetectedTokens = detectedTokensForGivenChain?.filter( + (t) => !importedTokensMap[t.address.toLowerCase()], + ); const { newAllTokens, newAllDetectedTokens, newAllIgnoredTokens } = - this._getNewAllTokensState({ + this.#getNewAllTokensState({ newTokens, newDetectedTokens, newIgnoredTokens, interactingChainId, }); - this.update({ - tokens: newTokens, - allTokens: newAllTokens, - detectedTokens: newDetectedTokens, - allDetectedTokens: newAllDetectedTokens, - ignoredTokens: newIgnoredTokens, - allIgnoredTokens: newAllIgnoredTokens, + this.update((state) => { + state.allTokens = newAllTokens; + state.allDetectedTokens = newAllDetectedTokens; + state.allIgnoredTokens = newAllIgnoredTokens; }); } finally { releaseLock(); @@ -465,12 +676,34 @@ export class TokensController extends BaseController< * Ignore a batch of tokens. * * @param tokenAddressesToIgnore - Array of token addresses to ignore. + * @param networkClientId - Optional network client ID used to determine interacting chain ID. */ - ignoreTokens(tokenAddressesToIgnore: string[]) { - const { ignoredTokens, detectedTokens, tokens } = this.state; + ignoreTokens( + tokenAddressesToIgnore: string[], + networkClientId: NetworkClientId, + ) { + if (this.#isDeprecated()) { + this.#enforceDisabledState(); + return; + } + + const interactingChainId = this.messenger.call( + 'NetworkController:getNetworkClientById', + networkClientId, + ).configuration.chainId; + + const { allTokens, allDetectedTokens, allIgnoredTokens } = this.state; const ignoredTokensMap: { [key: string]: true } = {}; + const ignoredTokens = + allIgnoredTokens[interactingChainId]?.[this.#getSelectedAddress()] ?? []; let newIgnoredTokens: string[] = [...ignoredTokens]; + const tokens = + allTokens[interactingChainId]?.[this.#getSelectedAddress()] ?? []; + + const detectedTokens = + allDetectedTokens[interactingChainId]?.[this.#getSelectedAddress()] ?? []; + const checksummedTokenAddresses = tokenAddressesToIgnore.map((address) => { const checksumAddress = toChecksumHexAddress(address); ignoredTokensMap[address.toLowerCase()] = true; @@ -485,19 +718,17 @@ export class TokensController extends BaseController< ); const { newAllIgnoredTokens, newAllDetectedTokens, newAllTokens } = - this._getNewAllTokensState({ + this.#getNewAllTokensState({ newIgnoredTokens, newDetectedTokens, newTokens, + interactingChainId, }); - this.update({ - ignoredTokens: newIgnoredTokens, - tokens: newTokens, - detectedTokens: newDetectedTokens, - allIgnoredTokens: newAllIgnoredTokens, - allDetectedTokens: newAllDetectedTokens, - allTokens: newAllTokens, + this.update((state) => { + state.allIgnoredTokens = newAllIgnoredTokens; + state.allDetectedTokens = newAllDetectedTokens; + state.allTokens = newAllTokens; }); } @@ -511,12 +742,25 @@ export class TokensController extends BaseController< */ async addDetectedTokens( incomingDetectedTokens: Token[], - detectionDetails?: { selectedAddress: string; chainId: Hex }, + detectionDetails: { selectedAddress?: string; chainId: Hex }, ) { - const releaseLock = await this.mutex.acquire(); - const { tokens, detectedTokens, ignoredTokens } = this.state; - const newTokens: Token[] = [...tokens]; - let newDetectedTokens: Token[] = [...detectedTokens]; + if (this.#isDeprecated()) { + this.#enforceDisabledState(); + return; + } + + const releaseLock = await this.#mutex.acquire(); + + const { chainId } = detectionDetails; + // Previously selectedAddress could be an empty string. This is to preserve the behaviour + const accountAddress = + detectionDetails?.selectedAddress ?? this.#getSelectedAddress(); + + const { allTokens, allDetectedTokens, allIgnoredTokens } = this.state; + let newTokens = [...(allTokens?.[chainId]?.[accountAddress] ?? [])]; + let newDetectedTokens = [ + ...(allDetectedTokens?.[chainId]?.[accountAddress] ?? []), + ]; try { incomingDetectedTokens.forEach((tokenToAdd) => { @@ -528,6 +772,7 @@ export class TokensController extends BaseController< aggregators, isERC721, name, + rwaData, } = tokenToAdd; const checksumAddress = toChecksumHexAddress(address); const newEntry: Token = { @@ -538,29 +783,29 @@ export class TokensController extends BaseController< isERC721, aggregators, name, + ...(rwaData && { rwaData }), }; - const previousImportedEntry = newTokens.find( + + const previousImportedIndex = newTokens.findIndex( (token) => token.address.toLowerCase() === checksumAddress.toLowerCase(), ); - if (previousImportedEntry) { + + if (previousImportedIndex !== -1) { // Update existing data of imported token - const previousImportedIndex = newTokens.indexOf( - previousImportedEntry, - ); newTokens[previousImportedIndex] = newEntry; } else { - const ignoredTokenIndex = ignoredTokens.indexOf(address); + const ignoredTokenIndex = + allIgnoredTokens?.[chainId]?.[accountAddress]?.indexOf(address) ?? + -1; + if (ignoredTokenIndex === -1) { // Add detected token - const previousDetectedEntry = newDetectedTokens.find( + const previousDetectedIndex = newDetectedTokens.findIndex( (token) => token.address.toLowerCase() === checksumAddress.toLowerCase(), ); - if (previousDetectedEntry) { - const previousDetectedIndex = newDetectedTokens.indexOf( - previousDetectedEntry, - ); + if (previousDetectedIndex !== -1) { newDetectedTokens[previousDetectedIndex] = newEntry; } else { newDetectedTokens.push(newEntry); @@ -569,32 +814,26 @@ export class TokensController extends BaseController< } }); - const { - selectedAddress: interactingAddress, - chainId: interactingChainId, - } = detectionDetails || {}; - - const { newAllTokens, newAllDetectedTokens } = this._getNewAllTokensState( + const { newAllTokens, newAllDetectedTokens } = this.#getNewAllTokensState( { newTokens, newDetectedTokens, - interactingAddress, - interactingChainId, + interactingAddress: accountAddress, + interactingChainId: chainId, }, ); - const { chainId, selectedAddress } = this.config; - // if the newly added detectedTokens were detected on (and therefore added to) a different chainId/selectedAddress than the currently configured combo - // the newDetectedTokens (which should contain the detectedTokens on the current chainId/address combo) needs to be repointed to the current chainId/address pair - // if the detectedTokens were detected on the current chainId/address then this won't change anything. + // We may be detecting tokens on a different chain/account pair than are currently configured. + // Re-point `tokens` and `detectedTokens` to keep them referencing the current chain/account. + const selectedAddress = this.#getSelectedAddress(); + + newTokens = newAllTokens?.[chainId]?.[selectedAddress] ?? []; newDetectedTokens = - newAllDetectedTokens?.[chainId]?.[selectedAddress] || []; + newAllDetectedTokens?.[chainId]?.[selectedAddress] ?? []; - this.update({ - tokens: newTokens, - allTokens: newAllTokens, - detectedTokens: newDetectedTokens, - allDetectedTokens: newAllDetectedTokens, + this.update((state) => { + state.allTokens = newAllTokens; + state.allDetectedTokens = newAllDetectedTokens; }); } finally { releaseLock(); @@ -606,40 +845,35 @@ export class TokensController extends BaseController< * were previously added which do not yet had isERC721 field. * * @param tokenAddress - The contract address of the token requiring the isERC721 field added. + * @param networkClientId - The network client ID of the network on which the token is detected. * @returns The new token object with the added isERC721 field. */ - async updateTokenType(tokenAddress: string) { - const isERC721 = await this._detectIsERC721(tokenAddress); - const { tokens } = this.state; + async updateTokenType( + tokenAddress: string, + networkClientId: NetworkClientId, + ): Promise { + if (this.#isDeprecated()) { + this.#enforceDisabledState(); + throw new Error('TokensController is deprecated'); + } + + const chainIdToUse = this.messenger.call( + 'NetworkController:getNetworkClientById', + networkClientId, + ).configuration.chainId; + + const isERC721 = await this.#detectIsERC721(tokenAddress, networkClientId); + const accountAddress = this.#getSelectedAddress(); + const tokens = [...this.state.allTokens[chainIdToUse][accountAddress]]; const tokenIndex = tokens.findIndex((token) => { return token.address.toLowerCase() === tokenAddress.toLowerCase(); }); - tokens[tokenIndex].isERC721 = isERC721; - this.update({ tokens }); - return tokens[tokenIndex]; - } - - /** - * This is a function that updates the tokens name for the tokens name if it is not defined. - * - * @param tokenList - Represents the fetched token list from service API - * @param tokenAttribute - Represents the token attribute that we want to update on the token list - */ - private updateTokensAttribute( - tokenList: TokenListMap, - tokenAttribute: keyof Token & keyof TokenListToken, - ) { - const { tokens } = this.state; - - const newTokens = tokens.map((token) => { - const newToken = tokenList[token.address.toLowerCase()]; - - return !token[tokenAttribute] && newToken?.[tokenAttribute] - ? { ...token, [tokenAttribute]: newToken[tokenAttribute] } - : { ...token }; + const updatedToken = { ...tokens[tokenIndex], isERC721 }; + tokens[tokenIndex] = updatedToken; + this.update((state) => { + state.allTokens[chainIdToUse][accountAddress] = tokens; }); - - this.update({ tokens: newTokens }); + return updatedToken; } /** @@ -650,7 +884,7 @@ export class TokensController extends BaseController< * @returns A boolean indicating whether the token address passed in supports the EIP-721 * interface. */ - async _detectIsERC721( + async #detectIsERC721( tokenAddress: string, networkClientId?: NetworkClientId, ) { @@ -663,14 +897,14 @@ export class TokensController extends BaseController< return Promise.resolve(false); } - const tokenContract = this._createEthersContract( + const tokenContract = this.#createEthersContract( tokenAddress, abiERC721, networkClientId, ); try { return await tokenContract.supportsInterface(ERC721_INTERFACE_ID); - } catch (error: any) { + } catch (error) { // currently we see a variety of errors across different networks when // token contracts are not ERC721 compatible. We need to figure out a better // way of differentiating token interface types but for now if we get an error @@ -679,21 +913,28 @@ export class TokensController extends BaseController< } } - _createEthersContract( + #getProvider(networkClientId?: NetworkClientId): Web3Provider { + return new Web3Provider( + networkClientId + ? this.messenger.call( + 'NetworkController:getNetworkClientById', + networkClientId, + ).provider + : this.#provider, + ); + } + + #createEthersContract( tokenAddress: string, abi: string, networkClientId?: NetworkClientId, ): Contract { - const provider = networkClientId - ? this.getNetworkClientById(networkClientId).provider - : this.config?.provider; - - const web3provider = new Web3Provider(provider); + const web3provider = this.#getProvider(networkClientId); const tokenContract = new Contract(tokenAddress, abi, web3provider); return tokenContract; } - _generateRandomId(): string { + #generateRandomId(): string { return random(); } @@ -706,45 +947,146 @@ export class TokensController extends BaseController< * @param options.type - The asset type. * @param options.interactingAddress - The address of the account that is requesting to watch the asset. * @param options.networkClientId - Network Client ID. - * @returns Object containing a Promise resolving to the suggestedAsset address if accepted. + * @param options.origin - The origin to set on the approval request. + * @param options.pageMeta - The metadata for the page initiating the request. + * @param options.requestMetadata - Metadata for the request, including pageMeta and origin. + * @returns A promise that resolves if the asset was watched successfully, and rejects otherwise. */ async watchAsset({ asset, type, interactingAddress, networkClientId, + origin, + pageMeta, + requestMetadata, }: { asset: Token; type: string; interactingAddress?: string; - networkClientId?: NetworkClientId; + networkClientId: NetworkClientId; + origin?: string; + pageMeta?: Record; + requestMetadata?: WatchAssetRequestMetadata; }): Promise { + if (this.#isDeprecated()) { + this.#enforceDisabledState(); + return; + } + if (type !== ERC20) { throw new Error(`Asset of type ${type} not supported`); } - const { selectedAddress } = this.config; + if (!asset.address) { + throw rpcErrors.invalidParams('Address must be specified'); + } + + if (!isValidHexAddress(asset.address)) { + throw rpcErrors.invalidParams(`Invalid address "${asset.address}"`); + } + + const selectedAddress = + this.#getAddressOrSelectedAddress(interactingAddress); + + // Validate contract + + if (await this.#detectIsERC721(asset.address, networkClientId)) { + throw rpcErrors.invalidParams( + `Contract ${asset.address} must match type ${type}, but was detected as ${ERC721}`, + ); + } + + const provider = this.#getProvider(networkClientId); + const isErc1155 = await safelyExecute(() => + new ERC1155Standard(provider).contractSupportsBase1155Interface( + asset.address, + ), + ); + if (isErc1155) { + throw rpcErrors.invalidParams( + `Contract ${asset.address} must match type ${type}, but was detected as ${ERC1155}`, + ); + } + + const erc20 = new ERC20Standard(provider); + const [contractName, contractSymbol, contractDecimals] = await Promise.all([ + safelyExecute(() => erc20.getTokenName(asset.address)), + safelyExecute(() => erc20.getTokenSymbol(asset.address)), + safelyExecute(async () => erc20.getTokenDecimals(asset.address)), + ]); + + asset.name = contractName; + + // Validate symbol + + if (!asset.symbol && !contractSymbol) { + throw rpcErrors.invalidParams( + 'A symbol is required, but was not found in either the request or contract', + ); + } + + if ( + contractSymbol !== undefined && + asset.symbol !== undefined && + asset.symbol.toUpperCase() !== contractSymbol.toUpperCase() + ) { + throw rpcErrors.invalidParams( + `The symbol in the request (${asset.symbol}) does not match the symbol in the contract (${contractSymbol})`, + ); + } + + asset.symbol = contractSymbol ?? asset.symbol; + if (typeof asset.symbol !== 'string') { + throw rpcErrors.invalidParams(`Invalid symbol: not a string`); + } + + if (asset.symbol.length > 11) { + throw rpcErrors.invalidParams( + `Invalid symbol "${asset.symbol}": longer than 11 characters`, + ); + } + + // Validate decimals + + if (asset.decimals === undefined && contractDecimals === undefined) { + throw rpcErrors.invalidParams( + 'Decimals are required, but were not found in either the request or contract', + ); + } + + if ( + contractDecimals !== undefined && + asset.decimals !== undefined && + String(asset.decimals) !== contractDecimals + ) { + throw rpcErrors.invalidParams( + `The decimals in the request (${asset.decimals}) do not match the decimals in the contract (${contractDecimals})`, + ); + } + + const decimalsStr = contractDecimals ?? asset.decimals; + const decimalsNum = parseInt(decimalsStr as unknown as string, 10); + if (!Number.isInteger(decimalsNum) || decimalsNum > 36 || decimalsNum < 0) { + throw rpcErrors.invalidParams( + `Invalid decimals "${decimalsStr}": must be an integer 0 <= 36`, + ); + } + asset.decimals = decimalsNum; const suggestedAssetMeta: SuggestedAssetMeta = { asset, - id: this._generateRandomId(), + id: this.#generateRandomId(), time: Date.now(), type, - interactingAddress: interactingAddress || selectedAddress, + interactingAddress: selectedAddress, + origin: getNonEmptyString(requestMetadata?.origin, origin), + pageMeta: requestMetadata?.pageMeta ?? pageMeta, }; - validateTokenToWatch(asset); - - await this._requestApproval(suggestedAssetMeta); - - let name; - try { - name = await this.getERC20TokenName(asset.address, networkClientId); - } catch (error) { - name = undefined; - } + await this.#requestApproval(suggestedAssetMeta); - const { address, symbol, decimals, image } = asset; + const { address, symbol, decimals, name, image, rwaData } = asset; await this.addToken({ address, symbol, @@ -753,6 +1095,7 @@ export class TokensController extends BaseController< image, interactingAddress: suggestedAssetMeta.interactingAddress, networkClientId, + rwaData, }); } @@ -768,12 +1111,12 @@ export class TokensController extends BaseController< * @param params.interactingChainId - The chainId to use to store the tokens. * @returns The updated `allTokens` and `allIgnoredTokens` state. */ - _getNewAllTokensState(params: { + #getNewAllTokensState(params: { newTokens?: Token[]; newIgnoredTokens?: string[]; newDetectedTokens?: Token[]; interactingAddress?: string; - interactingChainId?: Hex; + interactingChainId: Hex; }) { const { newTokens, @@ -783,27 +1126,26 @@ export class TokensController extends BaseController< interactingChainId, } = params; const { allTokens, allIgnoredTokens, allDetectedTokens } = this.state; - const { chainId, selectedAddress } = this.config; - const userAddressToAddTokens = interactingAddress ?? selectedAddress; - const chainIdToAddTokens = interactingChainId ?? chainId; + const userAddressToAddTokens = + this.#getAddressOrSelectedAddress(interactingAddress); let newAllTokens = allTokens; if ( newTokens?.length || (newTokens && allTokens && - allTokens[chainIdToAddTokens] && - allTokens[chainIdToAddTokens][userAddressToAddTokens]) + allTokens[interactingChainId] && + allTokens[interactingChainId][userAddressToAddTokens]) ) { - const networkTokens = allTokens[chainIdToAddTokens]; + const networkTokens = allTokens[interactingChainId]; const newNetworkTokens = { ...networkTokens, ...{ [userAddressToAddTokens]: newTokens }, }; newAllTokens = { ...allTokens, - ...{ [chainIdToAddTokens]: newNetworkTokens }, + ...{ [interactingChainId]: newNetworkTokens }, }; } @@ -812,17 +1154,17 @@ export class TokensController extends BaseController< newIgnoredTokens?.length || (newIgnoredTokens && allIgnoredTokens && - allIgnoredTokens[chainIdToAddTokens] && - allIgnoredTokens[chainIdToAddTokens][userAddressToAddTokens]) + allIgnoredTokens[interactingChainId] && + allIgnoredTokens[interactingChainId][userAddressToAddTokens]) ) { - const networkIgnoredTokens = allIgnoredTokens[chainIdToAddTokens]; + const networkIgnoredTokens = allIgnoredTokens[interactingChainId]; const newIgnoredNetworkTokens = { ...networkIgnoredTokens, ...{ [userAddressToAddTokens]: newIgnoredTokens }, }; newAllIgnoredTokens = { ...allIgnoredTokens, - ...{ [chainIdToAddTokens]: newIgnoredNetworkTokens }, + ...{ [interactingChainId]: newIgnoredNetworkTokens }, }; } @@ -831,50 +1173,98 @@ export class TokensController extends BaseController< newDetectedTokens?.length || (newDetectedTokens && allDetectedTokens && - allDetectedTokens[chainIdToAddTokens] && - allDetectedTokens[chainIdToAddTokens][userAddressToAddTokens]) + allDetectedTokens[interactingChainId] && + allDetectedTokens[interactingChainId][userAddressToAddTokens]) ) { - const networkDetectedTokens = allDetectedTokens[chainIdToAddTokens]; + const networkDetectedTokens = allDetectedTokens[interactingChainId]; const newDetectedNetworkTokens = { ...networkDetectedTokens, ...{ [userAddressToAddTokens]: newDetectedTokens }, }; newAllDetectedTokens = { ...allDetectedTokens, - ...{ [chainIdToAddTokens]: newDetectedNetworkTokens }, + ...{ [interactingChainId]: newDetectedNetworkTokens }, }; } return { newAllTokens, newAllIgnoredTokens, newAllDetectedTokens }; } + #getAddressOrSelectedAddress(address: string | undefined): string { + if (address) { + return address; + } + + return this.#getSelectedAddress(); + } + /** * Removes all tokens from the ignored list. */ clearIgnoredTokens() { - this.update({ ignoredTokens: [], allIgnoredTokens: {} }); + if (this.#isDeprecated()) { + this.#enforceDisabledState(); + return; + } + + this.update((state) => { + state.allIgnoredTokens = {}; + }); } - async _requestApproval(suggestedAssetMeta: SuggestedAssetMeta) { - return this.messagingSystem.call( + async #requestApproval(suggestedAssetMeta: SuggestedAssetMeta) { + const requestData: Record = { + id: suggestedAssetMeta.id, + interactingAddress: suggestedAssetMeta.interactingAddress, + asset: { + address: suggestedAssetMeta.asset.address, + decimals: suggestedAssetMeta.asset.decimals, + symbol: suggestedAssetMeta.asset.symbol, + image: + suggestedAssetMeta.asset.image && + suggestedAssetMeta.asset.image.trim() !== '' + ? suggestedAssetMeta.asset.image + : null, + }, + }; + if (suggestedAssetMeta.pageMeta) { + requestData.metadata = { + pageMeta: suggestedAssetMeta.pageMeta, + }; + } + + return this.messenger.call( 'ApprovalController:addRequest', { id: suggestedAssetMeta.id, - origin: ORIGIN_METAMASK, + origin: getNonEmptyString(suggestedAssetMeta.origin) ?? ORIGIN_METAMASK, type: ApprovalType.WatchAsset, - requestData: { - id: suggestedAssetMeta.id, - interactingAddress: suggestedAssetMeta.interactingAddress, - asset: { - address: suggestedAssetMeta.asset.address, - decimals: suggestedAssetMeta.asset.decimals, - symbol: suggestedAssetMeta.asset.symbol, - image: suggestedAssetMeta.asset.image || null, - }, - }, + requestData, }, true, ); } + + #getSelectedAccount() { + return this.messenger.call('AccountsController:getSelectedAccount'); + } + + #getSelectedAddress() { + // If the address is not defined (or empty), we fallback to the currently selected account's address + const account = this.messenger.call( + 'AccountsController:getAccount', + this.#selectedAccountId, + ); + return account?.address ?? ''; + } + + /** + * Reset the controller state to the default state. + */ + resetState() { + this.update(() => { + return getDefaultTokensState(); + }); + } } export default TokensController; diff --git a/packages/assets-controllers/src/__fixtures__/account-api-v4-mocks.ts b/packages/assets-controllers/src/__fixtures__/account-api-v4-mocks.ts new file mode 100644 index 00000000000..a0e7030b24c --- /dev/null +++ b/packages/assets-controllers/src/__fixtures__/account-api-v4-mocks.ts @@ -0,0 +1,65 @@ +import type { Hex } from '@metamask/utils'; +import nock from 'nock'; + +export const mockResponse_accountsAPI_MultichainAccountBalances = ( + accountAddress: Hex, +) => ({ + count: 8, + balances: [ + { + object: 'token', + address: '0x0000000000000000000000000000000000000000', + symbol: 'MATIC', + name: 'MATIC', + type: 'native', + decimals: 18, + chainId: 137, + balance: '168.699548832017288710', + accountAddress: `eip155:137:${accountAddress}`, + }, + { + object: 'token', + address: '0x2791bca1f2de4661ed88a30c99a7a9449aa84174', + name: 'USD Coin (PoS)', + symbol: 'USDC', + decimals: 6, + balance: '8.174688', + chainId: 137, + accountAddress: `eip155:137:${accountAddress}`, + }, + { + object: 'token', + address: '0x53e0bca35ec356bd5dddfebbd1fc0fd03fabad39', + name: 'ChainLink Token', + symbol: 'LINK', + decimals: 18, + balance: '0.000734044925209136', + chainId: 137, + accountAddress: `eip155:137:${accountAddress}`, + }, + { + object: 'token', + address: '0x6d80113e533a2c0fe82eabd35f1875dcea89ea97', + name: 'Aave Polygon WMATIC', + symbol: 'aPolWMATIC', + decimals: 18, + balance: '1.001966754893761781', + chainId: 137, + accountAddress: `eip155:137:${accountAddress}`, + }, + ], + unprocessedNetworks: [], +}); + +export const mockAPI_accountsAPI_MultichainAccountBalances = ( + accountAddress: Hex, +) => + nock('https://accounts.api.cx.metamask.io/v4/multiaccount/balances') + .get('') + .query({ + accountAddresses: `eip155:137:${accountAddress}`, + }) + .reply( + 200, + mockResponse_accountsAPI_MultichainAccountBalances(accountAddress), + ); diff --git a/packages/assets-controllers/src/__fixtures__/test-utils.ts b/packages/assets-controllers/src/__fixtures__/test-utils.ts new file mode 100644 index 00000000000..e57363787e8 --- /dev/null +++ b/packages/assets-controllers/src/__fixtures__/test-utils.ts @@ -0,0 +1,43 @@ +type WaitForOptions = { + intervalMs?: number; + timeoutMs?: number; +}; + +/** + * Testing Utility - waitFor. Waits for and checks (at an interval) if assertion is reached. + * + * @param assertionFn - assertion function + * @param options - set wait for options + * @returns promise that you need to await in tests + */ +export const waitFor = async ( + assertionFn: () => void, + options: WaitForOptions = {}, +): Promise => { + const { intervalMs = 50, timeoutMs = 2000 } = options; + + const startTime = Date.now(); + + return new Promise((resolve, reject) => { + let lastError: unknown; + const intervalId = setInterval(() => { + try { + assertionFn(); + clearInterval(intervalId); + resolve(); + } catch (error) { + lastError = error; + if (Date.now() - startTime >= timeoutMs) { + clearInterval(intervalId); + const assertionDetail = + lastError instanceof Error ? lastError.message : String(lastError); + reject( + new Error( + `waitFor: timeout reached after ${timeoutMs}ms. Last assertion error: ${assertionDetail}`, + ), + ); + } + } + }, intervalMs); + }); +}; diff --git a/packages/assets-controllers/src/assetsUtil.test.ts b/packages/assets-controllers/src/assetsUtil.test.ts index 79ab8c29a66..509057f10d9 100644 --- a/packages/assets-controllers/src/assetsUtil.test.ts +++ b/packages/assets-controllers/src/assetsUtil.test.ts @@ -3,10 +3,17 @@ import { ChainId, convertHexToDecimal, toHex, + toChecksumHexAddress, } from '@metamask/controller-utils'; +import { add0x } from '@metamask/utils'; +import type { Hex } from '@metamask/utils'; -import * as assetsUtil from './assetsUtil'; -import type { Nft, NftMetadata } from './NftController'; +import * as assetsUtil from './assetsUtil.js'; +import { TOKEN_PRICES_BATCH_SIZE } from './assetsUtil.js'; +import type { Nft, NftMetadata } from './NftController.js'; +import { EvmAssetWithMarketData } from './token-prices-service/abstract-token-prices-service.js'; +import { getNativeTokenAddress } from './token-prices-service/index.js'; +import type { AbstractTokenPricesService } from './token-prices-service/index.js'; const DEFAULT_IPFS_URL_FORMAT = 'ipfs://'; const ALTERNATIVE_IPFS_URL_FORMAT = 'ipfs://ipfs/'; @@ -53,6 +60,30 @@ describe('assetsUtil', () => { expect(different).toBe(true); }); + it('should resolve true if only tokenURI is different', () => { + const nftMetadata: NftMetadata = { + description: null, + favorite: false, + image: 'test', + name: null, + standard: 'ERC1155', + tokenURI: 'foo', + }; + const nft: Nft = { + address: '0x1D03117e63c3A476a236a897147a1358579F2c45', + description: null, + favorite: false, + image: 'test', + name: null, + standard: 'ERC1155', + tokenId: '1', + tokenURI: 'bar', + }; + + const different = assetsUtil.compareNftMetadata(nftMetadata, nft); + expect(different).toBe(true); + }); + it('should resolve true if any key is different as always as metadata is not undefined', () => { const nftMetadata: NftMetadata = { name: 'name', @@ -124,124 +155,96 @@ describe('assetsUtil', () => { chainId: ChainId.mainnet, tokenAddress: linkTokenAddress, }); - const expectedValue = `https://static.metafi.codefi.network/api/v1/tokenIcons/${convertHexToDecimal( + const expectedValue = `https://static.cx.metamask.io/api/v1/tokenIcons/${convertHexToDecimal( ChainId.mainnet, )}/${linkTokenAddress}.png`; expect(formattedIconUrl).toStrictEqual(expectedValue); }); }); - describe('validateTokenToWatch', () => { - it('should throw if undefined token atrributes', () => { - expect(() => - assetsUtil.validateTokenToWatch({ - address: undefined, - decimals: 0, - symbol: 'TKN', - } as any), - ).toThrow('Must specify address, symbol, and decimals.'); - - expect(() => - assetsUtil.validateTokenToWatch({ - address: '0x1', - decimals: 0, - symbol: undefined, - } as any), - ).toThrow('Must specify address, symbol, and decimals.'); - - expect(() => - assetsUtil.validateTokenToWatch({ - address: '0x1', - decimals: undefined, - symbol: 'TKN', - } as any), - ).toThrow('Must specify address, symbol, and decimals.'); - }); - - it('should throw if symbol is not a string', () => { - expect(() => - assetsUtil.validateTokenToWatch({ - address: '0xe9f786dfdd9be4d57e830acb52296837765f0e5b', - decimals: 0, - symbol: { foo: 'bar' }, - } as any), - ).toThrow('Invalid symbol: not a string.'); - }); - - it('should throw if symbol is an empty string', () => { - expect(() => - assetsUtil.validateTokenToWatch({ - address: '0xe9f786dfdd9be4d57e830acb52296837765f0e5b', - decimals: 0, - symbol: '', - } as any), - ).toThrow('Must specify address, symbol, and decimals.'); - }); - - it('should not throw if symbol is exactly 1 character long', () => { - expect(() => - assetsUtil.validateTokenToWatch({ - address: '0xe9f786dfdd9be4d57e830acb52296837765f0e5b', - decimals: 0, - symbol: 'T', - } as any), - ).not.toThrow(); - }); - - it('should not throw if symbol is exactly 11 characters long', () => { - expect(() => - assetsUtil.validateTokenToWatch({ - address: '0xe9f786dfdd9be4d57e830acb52296837765f0e5b', - decimals: 0, - symbol: 'TKNTKNTKNTK', - } as any), - ).not.toThrow(); - }); - - it('should throw if symbol is more than 11 characters long', () => { - expect(() => - assetsUtil.validateTokenToWatch({ - address: '0xe9f786dfdd9be4d57e830acb52296837765f0e5b', - decimals: 0, - symbol: 'TKNTKNTKNTKN', - } as any), - ).toThrow('Invalid symbol "TKNTKNTKNTKN": longer than 11 characters.'); - }); - - it('should throw if invalid decimals', () => { - expect(() => - assetsUtil.validateTokenToWatch({ - address: '0xe9f786dfdd9be4d57e830acb52296837765f0e5b', - decimals: 0, - symbol: 'TKN', - } as any), - ).not.toThrow(); - - expect(() => - assetsUtil.validateTokenToWatch({ - address: '0xe9f786dfdd9be4d57e830acb52296837765f0e5b', - decimals: 38, - symbol: 'TKN', - } as any), - ).toThrow('Invalid decimals "38": must be 0 <= 36.'); - - expect(() => - assetsUtil.validateTokenToWatch({ - address: '0xe9f786dfdd9be4d57e830acb52296837765f0e5b', - decimals: -1, - symbol: 'TKN', - } as any), - ).toThrow('Invalid decimals "-1": must be 0 <= 36.'); - }); - - it('should throw if invalid address', () => { - expect(() => - assetsUtil.validateTokenToWatch({ - address: '0xe9', - decimals: 0, - symbol: 'TKN', - } as any), - ).toThrow('Invalid address "0xe9".'); + describe('hasNewCollectionFields', () => { + let baseNftMetadata: NftMetadata; + let baseNft: Nft; + + beforeEach(() => { + baseNftMetadata = { + name: 'name', + image: 'image', + description: 'description', + standard: 'standard', + backgroundColor: 'backgroundColor', + imagePreview: 'imagePreview', + imageThumbnail: 'imageThumbnail', + imageOriginal: 'imageOriginal', + animation: 'animation', + animationOriginal: 'animationOriginal', + externalLink: 'externalLink', + }; + + baseNft = { + ...baseNftMetadata, + address: 'address', + tokenId: '123', + }; + }); + it('should return false if both objects do not have collection', () => { + const different = assetsUtil.hasNewCollectionFields( + baseNftMetadata, + baseNft, + ); + expect(different).toBe(false); + }); + + it('should return false if existing object has collection and new nft metadata object does not', () => { + const different = assetsUtil.hasNewCollectionFields(baseNftMetadata, { + ...baseNft, + collection: { + id: 'address', + openseaVerificationStatus: 'verified', + }, + }); + expect(different).toBe(false); + }); + + it('should return false if both objects has the same keys', () => { + const nftMetadata: NftMetadata = { + ...baseNftMetadata, + collection: { + id: 'address', + openseaVerificationStatus: 'verified', + }, + }; + const nft: Nft = { + ...baseNft, + collection: { + id: 'address', + openseaVerificationStatus: 'verified', + }, + }; + const different = assetsUtil.hasNewCollectionFields(nftMetadata, nft); + expect(different).toBe(false); + }); + + it('should return true if new nft metadata object has keys that do not exist in the existing NFT', () => { + const nftMetadata: NftMetadata = { + ...baseNftMetadata, + collection: { + id: 'address', + openseaVerificationStatus: 'verified', + tokenCount: '5555', + ownerCount: '555', + contractDeployedAt: 'timestamp', + }, + }; + const nft: Nft = { + ...baseNft, + collection: { + id: 'address', + openseaVerificationStatus: 'verified', + }, + }; + const different = assetsUtil.hasNewCollectionFields(nftMetadata, nft); + expect(different).toBe(true); }); }); @@ -249,7 +252,7 @@ describe('assetsUtil', () => { it('returns true for Mainnet', () => { expect( assetsUtil.isTokenDetectionSupportedForNetwork( - assetsUtil.SupportedTokenDetectionNetworks.mainnet, + assetsUtil.SupportedTokenDetectionNetworks.Mainnet, ), ).toBe(true); }); @@ -257,7 +260,7 @@ describe('assetsUtil', () => { it('returns true for custom network such as BSC', () => { expect( assetsUtil.isTokenDetectionSupportedForNetwork( - assetsUtil.SupportedTokenDetectionNetworks.bsc, + assetsUtil.SupportedTokenDetectionNetworks.Bsc, ), ).toBe(true); }); @@ -265,7 +268,7 @@ describe('assetsUtil', () => { it('returns true for the Aurora network', () => { expect( assetsUtil.isTokenDetectionSupportedForNetwork( - assetsUtil.SupportedTokenDetectionNetworks.aurora, + assetsUtil.SupportedTokenDetectionNetworks.Aurora, ), ).toBe(true); }); @@ -281,21 +284,21 @@ describe('assetsUtil', () => { it('returns true for Mainnet', () => { expect( assetsUtil.isTokenListSupportedForNetwork( - assetsUtil.SupportedTokenDetectionNetworks.mainnet, + assetsUtil.SupportedTokenDetectionNetworks.Mainnet, ), ).toBe(true); }); - it('returns true for ganache local network', () => { + it('returns false for ganache local network', () => { expect(assetsUtil.isTokenListSupportedForNetwork(GANACHE_CHAIN_ID)).toBe( - true, + false, ); }); it('returns true for custom network such as Polygon', () => { expect( assetsUtil.isTokenListSupportedForNetwork( - assetsUtil.SupportedTokenDetectionNetworks.polygon, + assetsUtil.SupportedTokenDetectionNetworks.Polygon, ), ).toBe(true); }); @@ -340,33 +343,33 @@ describe('assetsUtil', () => { }); describe('getIpfsCIDv1AndPath', () => { - it('should return content identifier from default ipfs url format', () => { + it('should return content identifier from default ipfs url format', async () => { expect( - assetsUtil.getIpfsCIDv1AndPath( + await assetsUtil.getIpfsCIDv1AndPath( `${DEFAULT_IPFS_URL_FORMAT}${IPFS_CID_V0}`, ), ).toStrictEqual({ cid: IPFS_CID_V1, path: undefined }); }); - it('should return content identifier from alternative ipfs url format', () => { + it('should return content identifier from alternative ipfs url format', async () => { expect( - assetsUtil.getIpfsCIDv1AndPath( + await assetsUtil.getIpfsCIDv1AndPath( `${ALTERNATIVE_IPFS_URL_FORMAT}${IPFS_CID_V0}`, ), ).toStrictEqual({ cid: IPFS_CID_V1, path: undefined }); }); - it('should return unchanged content identifier if already v1', () => { + it('should return unchanged content identifier if already v1', async () => { expect( - assetsUtil.getIpfsCIDv1AndPath( + await assetsUtil.getIpfsCIDv1AndPath( `${DEFAULT_IPFS_URL_FORMAT}${IPFS_CID_V1}`, ), ).toStrictEqual({ cid: IPFS_CID_V1, path: undefined }); }); - it('should return a path when url contains one', () => { + it('should return a path when url contains one', async () => { expect( - assetsUtil.getIpfsCIDv1AndPath( + await assetsUtil.getIpfsCIDv1AndPath( `${DEFAULT_IPFS_URL_FORMAT}${IPFS_CID_V1}/test/test/test`, ), ).toStrictEqual({ cid: IPFS_CID_V1, path: '/test/test/test' }); @@ -374,9 +377,9 @@ describe('assetsUtil', () => { }); describe('getFormattedIpfsUrl', () => { - it('should return a correctly formatted subdomained ipfs url when passed ipfsGateway without protocol prefix, no path and subdomainSupported argument set to true', () => { + it('should return a correctly formatted subdomained ipfs url when passed ipfsGateway without protocol prefix, no path and subdomainSupported argument set to true', async () => { expect( - assetsUtil.getFormattedIpfsUrl( + await assetsUtil.getFormattedIpfsUrl( IFPS_GATEWAY, `${DEFAULT_IPFS_URL_FORMAT}${IPFS_CID_V1}`, true, @@ -384,9 +387,9 @@ describe('assetsUtil', () => { ).toBe(`https://${IPFS_CID_V1}.ipfs.${IFPS_GATEWAY}`); }); - it('should return a correctly formatted subdomained ipfs url when passed ipfsGateway with protocol prefix, a cidv0 and no path and subdomainSupported argument set to true', () => { + it('should return a correctly formatted subdomained ipfs url when passed ipfsGateway with protocol prefix, a cidv0 and no path and subdomainSupported argument set to true', async () => { expect( - assetsUtil.getFormattedIpfsUrl( + await assetsUtil.getFormattedIpfsUrl( `https://${IFPS_GATEWAY}`, `${DEFAULT_IPFS_URL_FORMAT}${IPFS_CID_V0}`, true, @@ -394,9 +397,9 @@ describe('assetsUtil', () => { ).toBe(`https://${IPFS_CID_V1}.ipfs.${IFPS_GATEWAY}`); }); - it('should return a correctly formatted subdomained ipfs url when passed ipfsGateway with protocol prefix, a path at the end of the url, and subdomainSupported argument set to true', () => { + it('should return a correctly formatted subdomained ipfs url when passed ipfsGateway with protocol prefix, a path at the end of the url, and subdomainSupported argument set to true', async () => { expect( - assetsUtil.getFormattedIpfsUrl( + await assetsUtil.getFormattedIpfsUrl( `https://${IFPS_GATEWAY}`, `${DEFAULT_IPFS_URL_FORMAT}${IPFS_CID_V1}/test`, true, @@ -404,9 +407,9 @@ describe('assetsUtil', () => { ).toBe(`https://${IPFS_CID_V1}.ipfs.${IFPS_GATEWAY}/test`); }); - it('should return a correctly formatted non-subdomained ipfs url when passed ipfsGateway with no "/ipfs/" appended, a path at the end of the url, and subdomainSupported argument set to false', () => { + it('should return a correctly formatted non-subdomained ipfs url when passed ipfsGateway with no "/ipfs/" appended, a path at the end of the url, and subdomainSupported argument set to false', async () => { expect( - assetsUtil.getFormattedIpfsUrl( + await assetsUtil.getFormattedIpfsUrl( `https://${IFPS_GATEWAY}`, `${DEFAULT_IPFS_URL_FORMAT}${IPFS_CID_V1}/test`, false, @@ -414,9 +417,9 @@ describe('assetsUtil', () => { ).toBe(`https://${IFPS_GATEWAY}/ipfs/${IPFS_CID_V1}/test`); }); - it('should return a correctly formatted non-subdomained ipfs url when passed an ipfsGateway with "/ipfs/" appended, a path at the end of the url, subdomainSupported argument set to false', () => { + it('should return a correctly formatted non-subdomained ipfs url when passed an ipfsGateway with "/ipfs/" appended, a path at the end of the url, subdomainSupported argument set to false', async () => { expect( - assetsUtil.getFormattedIpfsUrl( + await assetsUtil.getFormattedIpfsUrl( `https://${IFPS_GATEWAY}/ipfs/`, `${DEFAULT_IPFS_URL_FORMAT}${IPFS_CID_V1}/test`, false, @@ -436,4 +439,424 @@ describe('assetsUtil', () => { expect(assetsUtil.addUrlProtocolPrefix(SOME_API)).toStrictEqual(SOME_API); }); }); + + describe('divideIntoBatches', () => { + describe('given a non-empty list of values', () => { + it('partitions the values into max-N-sized groups', () => { + const batches = assetsUtil.divideIntoBatches([1, 2, 3, 4, 5, 6], { + batchSize: 2, + }); + expect(batches).toStrictEqual([ + [1, 2], + [3, 4], + [5, 6], + ]); + }); + + it('does not fill every group completely if the number of values does not divide evenly', () => { + const batches = assetsUtil.divideIntoBatches([1, 2, 3, 4, 5], { + batchSize: 4, + }); + expect(batches).toStrictEqual([[1, 2, 3, 4], [5]]); + }); + }); + + describe('given a empty list of values', () => { + it('returns an empty array', () => { + const batches = assetsUtil.divideIntoBatches([], { + batchSize: 2, + }); + expect(batches).toStrictEqual([]); + }); + }); + }); + + describe('reduceInBatchesSerially', () => { + it('can build an object from running the given async function for each batch of the given values', async () => { + const results = await assetsUtil.reduceInBatchesSerially< + string, + Record + >({ + values: ['a', 'b', 'c', 'd', 'e', 'f'], + batchSize: 2, + eachBatch: (workingResult, batch) => { + const newBatch = batch.reduce>>( + (obj, value) => { + // We can assume that the first character is present. + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + const codePoint = value.codePointAt(0)!; + return { + ...obj, + [value]: codePoint, + }; + }, + {}, + ); + return { ...workingResult, ...newBatch }; + }, + initialResult: {}, + }); + + expect(results).toStrictEqual({ + a: 97, + b: 98, + c: 99, + d: 100, + e: 101, + f: 102, + }); + }); + + it('processes each batch one after another, not in parallel, even if the given callback is async', async () => { + const timestampsByIndex = await assetsUtil.reduceInBatchesSerially< + string, + Record + >({ + values: ['a', 'b', 'c', 'd', 'e', 'f'], + batchSize: 2, + eachBatch: async (workingResult, _batch, index) => { + const timestamp = new Date().getTime(); + await new Promise((resolve) => { + let duration: number; + switch (index) { + case 0: + duration = 2; + break; + case 1: + duration = 10; + break; + case 2: + duration = 4; + break; + default: + throw new Error(`invalid index ${index}`); + } + setTimeout(resolve, duration); + }); + const newBatch = { [index]: timestamp }; + return { ...workingResult, ...newBatch }; + }, + initialResult: {}, + }); + + let previousTimestamp = 0; + let timestampsIncreasing = true; + for (const timestamp of Object.values(timestampsByIndex)) { + if (timestamp <= previousTimestamp) { + timestampsIncreasing = false; + break; + } + previousTimestamp = timestamp; + } + + expect(Object.keys(timestampsByIndex)).toHaveLength(3); + expect(timestampsIncreasing).toBe(true); + }); + + it('works when the result is an array', async () => { + const results = await assetsUtil.reduceInBatchesSerially< + string, + string[] + >({ + values: ['a', 'b', 'c', 'd', 'e', 'f'], + batchSize: 2, + eachBatch: async (workingResult, batch) => { + return [...workingResult, ...batch.map((s) => s.toUpperCase())]; + }, + initialResult: [], + }); + + expect(results).toStrictEqual(['A', 'B', 'C', 'D', 'E', 'F']); + }); + + it('works when the result is a number', async () => { + const results = await assetsUtil.reduceInBatchesSerially({ + values: [1, 2, 3, 4, 5], + batchSize: 2, + eachBatch: async (workingResult, batch) => { + return workingResult + batch.reduce((a, b) => a + b, 0); + }, + initialResult: 0, + }); + + expect(results).toBe(15); + }); + }); + + describe('fetchAndMapExchangeRates', () => { + it('should return empty object when chainId not supported', async () => { + const testTokenAddress = '0x7BEF710a5759d197EC0Bf621c3Df802C2D60D848'; + const mockPriceService = createMockPriceService(); + + jest + .spyOn(mockPriceService, 'validateChainIdSupported') + .mockReturnValue(false); + + const result = await assetsUtil.fetchTokenContractExchangeRates({ + tokenPricesService: mockPriceService, + nativeCurrency: 'ETH', + tokenAddresses: [testTokenAddress], + chainId: '0x0', + }); + + expect(result).toStrictEqual({}); + }); + + it('should return empty object when nativeCurrency not supported', async () => { + const testTokenAddress = '0x7BEF710a5759d197EC0Bf621c3Df802C2D60D848'; + const mockPriceService = createMockPriceService(); + jest + .spyOn(mockPriceService, 'validateCurrencySupported') + .mockReturnValue(false); + + const result = await assetsUtil.fetchTokenContractExchangeRates({ + tokenPricesService: mockPriceService, + nativeCurrency: 'X', + tokenAddresses: [testTokenAddress], + chainId: '0x1', + }); + + expect(result).toStrictEqual({}); + }); + + it('should return successfully with a number of tokens less than the batch size', async () => { + const testTokenAddress = '0x7BEF710a5759d197EC0Bf621c3Df802C2D60D848'; + const testNativeCurrency = 'ETH'; + const testChainId = '0x1'; + const mockPriceService = createMockPriceService(); + + jest.spyOn(mockPriceService, 'fetchTokenPrices').mockResolvedValue([ + { + tokenAddress: testTokenAddress, + chainId: testChainId, + currency: testNativeCurrency, + allTimeHigh: 4000, + allTimeLow: 900, + circulatingSupply: 2000, + dilutedMarketCap: 100, + high1d: 200, + low1d: 100, + marketCap: 1000, + marketCapPercentChange1d: 100, + price: 0.0004588648479937523, + pricePercentChange14d: 100, + pricePercentChange1h: 1, + pricePercentChange1y: 200, + pricePercentChange200d: 300, + pricePercentChange30d: 200, + pricePercentChange7d: 100, + totalVolume: 100, + priceChange1d: 100, + pricePercentChange1d: 100, + }, + ]); + + const result = await assetsUtil.fetchTokenContractExchangeRates({ + tokenPricesService: mockPriceService, + nativeCurrency: testNativeCurrency, + tokenAddresses: [testTokenAddress], + chainId: testChainId, + }); + + expect(result).toMatchObject({ + [testTokenAddress]: 0.0004588648479937523, + }); + }); + + it('should fetch successfully in batches', async () => { + const mockPriceService = createMockPriceService(); + const tokenAddresses = [...new Array(200).keys()] + .map(buildAddress) + .sort(); + + const testNativeCurrency = 'ETH'; + const testChainId = '0x1'; + + const fetchTokenPricesSpy = jest.spyOn( + mockPriceService, + 'fetchTokenPrices', + ); + + await assetsUtil.fetchTokenContractExchangeRates({ + tokenPricesService: mockPriceService, + nativeCurrency: testNativeCurrency, + tokenAddresses: tokenAddresses as Hex[], + chainId: testChainId, + }); + + const numBatches = Math.ceil( + tokenAddresses.length / TOKEN_PRICES_BATCH_SIZE, + ); + expect(fetchTokenPricesSpy).toHaveBeenCalledTimes(numBatches); + + const tokenAddressesWithNativeToken = [ + getNativeTokenAddress(testChainId), + ...tokenAddresses, + ]; + for (let i = 1; i <= numBatches; i++) { + expect(fetchTokenPricesSpy).toHaveBeenNthCalledWith(i, { + assets: tokenAddressesWithNativeToken + .slice( + (i - 1) * TOKEN_PRICES_BATCH_SIZE, + i * TOKEN_PRICES_BATCH_SIZE, + ) + .map((tokenAddress) => ({ + chainId: testChainId, + tokenAddress, + })), + currency: testNativeCurrency, + }); + } + }); + + it('should sort token addresses when batching', async () => { + const mockPriceService = createMockPriceService(); + + // Mock addresses in descending order + const tokenAddresses = [...new Array(200).keys()] + .map(buildAddress) + .sort() + .reverse(); + + const testNativeCurrency = 'ETH'; + const testChainId = '0x1'; + + const fetchTokenPricesSpy = jest.spyOn( + mockPriceService, + 'fetchTokenPrices', + ); + + await assetsUtil.fetchTokenContractExchangeRates({ + tokenPricesService: mockPriceService, + nativeCurrency: testNativeCurrency, + tokenAddresses: tokenAddresses as Hex[], + chainId: testChainId, + }); + + // Expect batches in ascending order + tokenAddresses.sort(); + + const numBatches = Math.ceil( + tokenAddresses.length / TOKEN_PRICES_BATCH_SIZE, + ); + expect(fetchTokenPricesSpy).toHaveBeenCalledTimes(numBatches); + + const tokenAddressesWithNativeToken = [ + getNativeTokenAddress(testChainId), + ...tokenAddresses, + ]; + for (let i = 1; i <= numBatches; i++) { + expect(fetchTokenPricesSpy).toHaveBeenNthCalledWith(i, { + assets: tokenAddressesWithNativeToken + .slice( + (i - 1) * TOKEN_PRICES_BATCH_SIZE, + i * TOKEN_PRICES_BATCH_SIZE, + ) + .map((tokenAddress) => ({ + chainId: testChainId, + tokenAddress, + })), + currency: testNativeCurrency, + }); + } + }); + + it('should return full market data keyed by checksummed address when includeMarketData is true', async () => { + const testTokenAddress = + '0x7bef710a5759d197ec0bf621c3df802c2d60d848' as Hex; + const checksummedAddress = '0x7BEF710a5759d197EC0Bf621c3Df802C2D60D848'; + const testNativeCurrency = 'ETH'; + const testChainId = '0x1'; + const mockPriceService = createMockPriceService(); + + const mockMarketData = { + tokenAddress: testTokenAddress, + chainId: testChainId, + currency: testNativeCurrency, + allTimeHigh: 4000, + allTimeLow: 900, + circulatingSupply: 2000, + dilutedMarketCap: 100, + high1d: 200, + low1d: 100, + marketCap: 1000, + marketCapPercentChange1d: 100, + price: 0.0004588648479937523, + pricePercentChange14d: 100, + pricePercentChange1h: 1, + pricePercentChange1y: 200, + pricePercentChange200d: 300, + pricePercentChange30d: 200, + pricePercentChange7d: 100, + totalVolume: 100, + priceChange1d: 100, + pricePercentChange1d: 100, + }; + + jest + .spyOn(mockPriceService, 'fetchTokenPrices') + .mockResolvedValue([ + mockMarketData as unknown as EvmAssetWithMarketData, + ]); + + const result = await assetsUtil.fetchTokenContractExchangeRates({ + tokenPricesService: mockPriceService, + nativeCurrency: testNativeCurrency, + tokenAddresses: [testTokenAddress], + chainId: testChainId, + includeMarketData: true, + }); + + expect(result).toStrictEqual({ + [checksummedAddress]: { + ...mockMarketData, + tokenAddress: checksummedAddress, + }, + }); + }); + }); + + describe('getKeyByValue', () => { + it('should return correct key for a specific value', () => { + const testMap = new Map([ + ['toto', 'koko'], + ['foo', 'bar'], + ]); + const result = assetsUtil.getKeyByValue(testMap, 'koko'); + expect(result).toBe('toto'); + }); + }); }); + +/** + * Constructs a checksum Ethereum address. + * + * @param number - The address as a decimal number. + * @returns The address as an 0x-prefixed ERC-55 mixed-case checksum address in + * hexadecimal format. + */ +function buildAddress(number: number) { + return toChecksumHexAddress(add0x(number.toString(16).padStart(40, '0'))); +} + +/** + * Creates a mock for token prices service. + * + * @returns The mocked functions of token prices service. + */ +function createMockPriceService(): AbstractTokenPricesService { + return { + validateChainIdSupported(_chainId: unknown): _chainId is Hex { + return true; + }, + validateCurrencySupported(_currency: unknown): _currency is string { + return true; + }, + async fetchTokenPrices() { + return []; + }, + async fetchExchangeRates() { + return {}; + }, + }; +} diff --git a/packages/assets-controllers/src/assetsUtil.ts b/packages/assets-controllers/src/assetsUtil.ts index e18ed10cf90..947968c07e0 100644 --- a/packages/assets-controllers/src/assetsUtil.ts +++ b/packages/assets-controllers/src/assetsUtil.ts @@ -1,16 +1,32 @@ import type { BigNumber } from '@ethersproject/bignumber'; import { convertHexToDecimal, - isValidHexAddress, - GANACHE_CHAIN_ID, + toChecksumHexAddress, } from '@metamask/controller-utils'; -import { rpcErrors } from '@metamask/rpc-errors'; import type { Hex } from '@metamask/utils'; -import { BN, stripHexPrefix } from 'ethereumjs-util'; +import { + hexToNumber, + KnownCaipNamespace, + remove0x, + toCaipChainId, +} from '@metamask/utils'; +import BN from 'bn.js'; import { CID } from 'multiformats/cid'; -import type { Nft, NftMetadata } from './NftController'; -import type { Token } from './TokenRatesController'; +import type { Nft, NftMetadata } from './NftController.js'; +import type { EvmAssetWithMarketData } from './token-prices-service/abstract-token-prices-service.js'; +import { getNativeTokenAddress } from './token-prices-service/index.js'; +import type { AbstractTokenPricesService } from './token-prices-service/index.js'; +import type { + ContractExchangeRates, + ContractMarketData, +} from './TokenRatesController.js'; + +/** + * The maximum number of token addresses that should be sent to the Price API in + * a single request. + */ +export const TOKEN_PRICES_BATCH_SIZE = 30; /** * Compares nft metadata entries to any nft entry. @@ -31,6 +47,8 @@ export function compareNftMetadata(newNftMetadata: NftMetadata, nft: Nft) { 'animation', 'animationOriginal', 'externalLink', + 'tokenURI', + 'chainId', ]; const differentValues = keys.reduce((value, key) => { if (newNftMetadata[key] && newNftMetadata[key] !== nft[key]) { @@ -41,6 +59,23 @@ export function compareNftMetadata(newNftMetadata: NftMetadata, nft: Nft) { return differentValues > 0; } +/** + * Checks whether the existing nft object has all the keys of the new incoming nft metadata object + * + * @param newNftMetadata - New nft metadata object + * @param nft - Existing nft object to compare with + * @returns Whether the existing nft object has all the new keys from the new Nft metadata object + */ +export function hasNewCollectionFields( + newNftMetadata: NftMetadata, + nft: Nft, +): boolean { + const keysNewNftMetadata = Object.keys(newNftMetadata.collection ?? {}); + const keysExistingNft = new Set(Object.keys(nft.collection ?? {})); + + return keysNewNftMetadata.some((key) => !keysExistingNft.has(key)); +} + const aggregatorNameByKey: Record = { aave: 'Aave', bancor: 'Bancor', @@ -100,54 +135,44 @@ export const formatIconUrlWithProxy = ({ tokenAddress: string; }) => { const chainIdDecimal = convertHexToDecimal(chainId).toString(); - return `https://static.metafi.codefi.network/api/v1/tokenIcons/${chainIdDecimal}/${tokenAddress.toLowerCase()}.png`; + return `https://static.cx.metamask.io/api/v1/tokenIcons/${chainIdDecimal}/${tokenAddress.toLowerCase()}.png`; }; /** - * Validates a ERC20 token to be added with EIP747. - * - * @param token - Token object to validate. + * Networks where token detection is supported - Values are in hex format */ -export function validateTokenToWatch(token: Token) { - const { address, symbol, decimals } = token; - if (!address || !symbol || typeof decimals === 'undefined') { - throw rpcErrors.invalidParams( - `Must specify address, symbol, and decimals.`, - ); - } - - if (typeof symbol !== 'string') { - throw rpcErrors.invalidParams(`Invalid symbol: not a string.`); - } - - if (symbol.length > 11) { - throw rpcErrors.invalidParams( - `Invalid symbol "${symbol}": longer than 11 characters.`, - ); - } - const numDecimals = parseInt(decimals as unknown as string, 10); - if (isNaN(numDecimals) || numDecimals > 36 || numDecimals < 0) { - throw rpcErrors.invalidParams( - `Invalid decimals "${decimals}": must be 0 <= 36.`, - ); - } - - if (!isValidHexAddress(address)) { - throw rpcErrors.invalidParams(`Invalid address "${address}".`); - } +export enum SupportedTokenDetectionNetworks { + Mainnet = '0x1', // decimal: 1 + Bsc = '0x38', // decimal: 56 + Polygon = '0x89', // decimal: 137 + Avax = '0xa86a', // decimal: 43114 + Aurora = '0x4e454152', // decimal: 1313161554 + LineaGoerli = '0xe704', // decimal: 59140 + LineaMainnet = '0xe708', // decimal: 59144 + Arbitrum = '0xa4b1', // decimal: 42161 + Optimism = '0xa', // decimal: 10 + Base = '0x2105', // decimal: 8453 + Zksync = '0x144', // decimal: 324 + Cronos = '0x19', // decimal: 25 + Celo = '0xa4ec', // decimal: 42220 + Gnosis = '0x64', // decimal: 100 + Fantom = '0xfa', // decimal: 250 + PolygonZkevm = '0x44d', // decimal: 1101 + Moonbeam = '0x504', // decimal: 1284 + Moonriver = '0x505', // decimal: 1285 + Sei = '0x531', // decimal: 1329 + MonadMainnet = '0x8f', // decimal: 143 + Hyperevm = '0x3e7', // decimal: 999 + Arc = '0x13b2', // decimal: 5042 + Robinhood = '0x1237', // decimal: 4663 } /** - * Networks where token detection is supported - Values are in decimal format + * Networks where staked balance is supported - Values are in hex format */ -export enum SupportedTokenDetectionNetworks { - mainnet = '0x1', // decimal: 1 - bsc = '0x38', // decimal: 56 - polygon = '0x89', // decimal: 137 - avax = '0xa86a', // decimal: 43114 - aurora = '0x4e454152', // decimal: 1313161554 - linea_goerli = '0xe704', // decimal: 59140 - linea_mainnet = '0xe708', // decimal: 59144 +export enum SupportedStakedBalanceNetworks { + Mainnet = '0x1', // decimal: 1 + Hoodi = '0x88bb0', // decimal: 560048 } /** @@ -168,9 +193,7 @@ export function isTokenDetectionSupportedForNetwork(chainId: Hex): boolean { * @returns Whether the current network supports tokenlists */ export function isTokenListSupportedForNetwork(chainId: Hex): boolean { - return ( - isTokenDetectionSupportedForNetwork(chainId) || chainId === GANACHE_CHAIN_ID - ); + return isTokenDetectionSupportedForNetwork(chainId); } /** @@ -197,10 +220,10 @@ export function removeIpfsProtocolPrefix(ipfsUrl: string) { * @returns IFPS content identifier (cid) and sub path as string. * @throws Will throw if the url passed is not ipfs. */ -export function getIpfsCIDv1AndPath(ipfsUrl: string): { +export async function getIpfsCIDv1AndPath(ipfsUrl: string): Promise<{ cid: string; path?: string; -} { +}> { const url = removeIpfsProtocolPrefix(ipfsUrl); // check if there is a path @@ -225,14 +248,14 @@ export function getIpfsCIDv1AndPath(ipfsUrl: string): { * @param subdomainSupported - Boolean indicating whether the URL should be formatted with subdomains or not. * @returns A formatted URL, with the user's preferred IPFS gateway and format (subdomain or not), pointing to an asset hosted on IPFS. */ -export function getFormattedIpfsUrl( +export async function getFormattedIpfsUrl( ipfsGateway: string, ipfsUrl: string, subdomainSupported: boolean, -): string { +): Promise { const { host, protocol, origin } = new URL(addUrlProtocolPrefix(ipfsGateway)); if (subdomainSupported) { - const { cid, path } = getIpfsCIDv1AndPath(ipfsUrl); + const { cid, path } = await getIpfsCIDv1AndPath(ipfsUrl); return `${protocol}//${cid}.ipfs.${host}${path ?? ''}`; } const cidAndPath = removeIpfsProtocolPrefix(ipfsUrl); @@ -259,5 +282,203 @@ export function addUrlProtocolPrefix(urlString: string): string { * @returns A BN object. */ export function ethersBigNumberToBN(bigNumber: BigNumber): BN { - return new BN(stripHexPrefix(bigNumber.toHexString()), 'hex'); + return new BN(remove0x(bigNumber.toHexString()), 'hex'); +} + +/** + * Partitions a list of values into groups that are at most `batchSize` in + * length. + * + * @param values - The list of values. + * @param args - The remaining arguments. + * @param args.batchSize - The desired maximum number of values per batch. + * @returns The list of batches. + */ +export function divideIntoBatches( + values: Value[], + { batchSize }: { batchSize: number }, +): Value[][] { + const batches = []; + for (let i = 0; i < values.length; i += batchSize) { + batches.push(values.slice(i, i + batchSize)); + } + return batches; +} + +/** + * Constructs a result from processing batches of the given values + * sequentially. + * + * @param args - The arguments to this function. + * @param args.values - A list of values to iterate over. + * @param args.batchSize - The maximum number of values in each batch. + * @param args.eachBatch - A function to call for each batch. This function is + * similar to the function that `Array.prototype.reduce` takes, in that it + * receives the object that is being built, each batch in the list of batches + * and the index, and should return an updated version of the object. + * @param args.initialResult - The initial value of the final data structure, + * i.e., the value that will be fed into the first call of `eachBatch`. + * @returns The built result. + */ +export async function reduceInBatchesSerially({ + values, + batchSize, + eachBatch, + initialResult, +}: { + values: Value[]; + batchSize: number; + eachBatch: ( + workingResult: Partial, + batch: Value[], + index: number, + ) => Partial | Promise>; + initialResult: Partial; +}): Promise { + const batches = divideIntoBatches(values, { batchSize }); + let workingResult = initialResult; + for (const [index, batch] of batches.entries()) { + workingResult = await eachBatch(workingResult, batch, index); + } + // There's no way around this — we have to assume that in the end, the result + // matches the intended type. + const finalResult = workingResult as Result; + return finalResult; +} + +type FetchTokenContractExchangeRatesArgs = { + tokenPricesService: AbstractTokenPricesService; + nativeCurrency: string; + tokenAddresses: Hex[]; + chainId: Hex; +}; + +/** + * Retrieves token prices for a set of contract addresses in a specific currency and chainId. + * + * @param args - The arguments to function. + * @param args.tokenPricesService - An object in charge of retrieving token prices. + * @param args.nativeCurrency - The native currency to request price in. + * @param args.tokenAddresses - The list of contract addresses. + * @param args.chainId - The chainId of the tokens. + * @param args.includeMarketData - When true, returns full market data (price, + * percentage changes, market cap, etc.) per token instead of just the price. + * @returns The prices (or full market data) for the requested tokens. + */ +export async function fetchTokenContractExchangeRates( + args: FetchTokenContractExchangeRatesArgs & { includeMarketData: true }, +): Promise; + +export async function fetchTokenContractExchangeRates( + args: FetchTokenContractExchangeRatesArgs & { includeMarketData?: false }, +): Promise; + +export async function fetchTokenContractExchangeRates({ + tokenPricesService, + nativeCurrency, + tokenAddresses, + chainId, + includeMarketData = false, +}: FetchTokenContractExchangeRatesArgs & { + includeMarketData?: boolean; +}): Promise { + const isChainIdSupported = + tokenPricesService.validateChainIdSupported(chainId); + const isCurrencySupported = + tokenPricesService.validateCurrencySupported(nativeCurrency); + + if (!isChainIdSupported || !isCurrencySupported) { + return {}; + } + + const tokenPricesByTokenAddress = await reduceInBatchesSerially< + Hex, + Record + >({ + values: [...tokenAddresses, getNativeTokenAddress(chainId)].sort(), + batchSize: TOKEN_PRICES_BATCH_SIZE, + eachBatch: async (allTokenPricesByTokenAddress, batch) => { + const tokenPricesByTokenAddressForBatch = ( + await tokenPricesService.fetchTokenPrices({ + assets: batch.map((tokenAddress) => ({ + chainId, + tokenAddress, + })), + currency: nativeCurrency, + }) + ).reduce>((acc, tokenPrice) => { + acc[tokenPrice.tokenAddress] = tokenPrice; + return acc; + }, {}); + + return { + ...allTokenPricesByTokenAddress, + ...tokenPricesByTokenAddressForBatch, + }; + }, + initialResult: {}, + }); + + if (includeMarketData) { + return Object.entries(tokenPricesByTokenAddress).reduce( + (obj, [tokenAddress, tokenPrice]) => { + if (tokenPrice) { + const checksummedAddress = toChecksumHexAddress(tokenAddress); + return { + ...obj, + [checksummedAddress]: { + ...tokenPrice, + tokenAddress: checksummedAddress, + }, + }; + } + return obj; + }, + {}, + ); + } + + return Object.entries(tokenPricesByTokenAddress).reduce( + (obj, [tokenAddress, tokenPrice]) => { + return { + ...obj, + [toChecksumHexAddress(tokenAddress)]: tokenPrice?.price, + }; + }, + {}, + ); +} + +/** + * Function to search for a specific value in a given map and return the key + * + * @param map - map input to search value + * @param value - the value to search for + * @returns returns key that corresponds to the value + */ +export function getKeyByValue(map: Map, value: string) { + for (const [key, val] of map.entries()) { + if (val === value) { + return key; + } + } + return null; // Return null if no match is found +} + +/** + * Converts a hex chainId and account address to a CAIP account reference. + * + * @param chainId - The hex chain ID + * @param accountAddress - The account address + * @returns The CAIP account reference in format "namespace:reference:address" + */ +export function accountAddressToCaipReference( + chainId: Hex, + accountAddress: string, +) { + const caipChainId = toCaipChainId( + KnownCaipNamespace.Eip155, + hexToNumber(chainId).toString(), + ); + return `${caipChainId}:${accountAddress}`; } diff --git a/packages/assets-controllers/src/balances.test.ts b/packages/assets-controllers/src/balances.test.ts new file mode 100644 index 00000000000..6327d98264a --- /dev/null +++ b/packages/assets-controllers/src/balances.test.ts @@ -0,0 +1,2066 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import { AccountWalletType, AccountGroupType } from '@metamask/account-api'; + +import { STAKING_CONTRACT_ADDRESS_BY_CHAINID } from './AssetsContractController.js'; +import { + calculateBalanceForAllWallets, + calculateBalanceChangeForAllWallets, + calculateBalanceChangeForAccountGroup, +} from './balances.js'; +import { getNativeTokenAddress } from './token-prices-service/codefi-v2.js'; + +const createBaseMockState = (userCurrency = 'USD') => ({ + AccountTreeController: { + accountTree: { + wallets: { + 'entropy:entropy-source-1': { + id: 'entropy:entropy-source-1', + type: AccountWalletType.Entropy, + metadata: { + name: 'Wallet 1', + entropy: { id: 'entropy-source-1', index: 0 }, + }, + groups: { + 'entropy:entropy-source-1/0': { + id: 'entropy:entropy-source-1/0', + type: AccountGroupType.MultichainAccount, + accounts: ['account-1', 'account-2'], + metadata: { + name: 'Group 0', + pinned: false, + hidden: false, + entropy: { groupIndex: 0 }, + }, + }, + 'entropy:entropy-source-1/1': { + id: 'entropy:entropy-source-1/1', + type: AccountGroupType.MultichainAccount, + accounts: ['account-3'], + metadata: { + name: 'Group 1', + pinned: false, + hidden: false, + entropy: { groupIndex: 1 }, + }, + }, + }, + }, + }, + }, + selectedAccountGroup: 'entropy:entropy-source-1/0', + accountGroupsMetadata: {}, + accountWalletsMetadata: {}, + }, + AccountsController: { + internalAccounts: { + accounts: { + 'account-1': { + id: 'account-1', + address: '0x1234567890123456789012345678901234567890', + type: 'eip155:eoa', + scopes: ['eip155:1', 'eip155:89', 'eip155:a4b1'], + methods: [], + options: {}, + metadata: { + name: 'Account 1', + keyring: { type: 'hd' }, + importTime: 0, + }, + }, + 'account-2': { + id: 'account-2', + address: '0x2345678901234567890123456789012345678901', + type: 'eip155:eoa', + scopes: ['eip155:1'], + methods: [], + options: {}, + metadata: { + name: 'Account 2', + keyring: { type: 'hd' }, + importTime: 0, + }, + }, + 'account-3': { + id: 'account-3', + address: '0x3456789012345678901234567890123456789012', + type: 'eip155:eoa', + scopes: ['eip155:1'], + methods: [], + options: {}, + metadata: { + name: 'Account 3', + keyring: { type: 'hd' }, + importTime: 0, + }, + }, + }, + selectedAccount: 'account-1', + }, + }, + TokenBalancesController: { + tokenBalances: { + '0x1234567890123456789012345678901234567890': { + '0x1': { + '0x1234567890123456789012345678901234567890': '0x5f5e100', + '0x2345678901234567890123456789012345678901': '0xbebc200', + }, + '0x89': { + '0x1234567890123456789012345678901234567890': '0x1dcd6500', + '0x2345678901234567890123456789012345678901': '0x3b9aca00', + }, + '0xa4b1': { + '0x1234567890123456789012345678901234567890': '0x2faf080', + '0x2345678901234567890123456789012345678901': '0x8f0d180', + }, + }, + '0x2345678901234567890123456789012345678901': { + '0x1': { + '0xC0b86a33E6441b8C4C3C1d3e2C1d3e2C1d3e2C1': '0x56bc75e2d63100000', + '0xD0b86a33E6441b8C4C3C1d3e2C1d3e2C1d3e2C1': '0xde0b6b3a7640000', + }, + }, + }, + }, + TokenRatesController: { + marketData: { + '0x1': { + '0x1234567890123456789012345678901234567890': { + tokenAddress: '0x123...', + currency: 'ETH', + price: 0.00041, + }, + '0x2345678901234567890123456789012345678901': { + tokenAddress: '0x234...', + currency: 'ETH', + price: 0.00041, + }, + '0xC0b86a33E6441b8C4C3C1d3e2C1d3e2C1d3e2C1': { + tokenAddress: '0xC0b...', + currency: 'ETH', + price: 0.00041, + }, + '0xD0b86a33E6441b8C4C3C1d3e2C1d3e2C1d3e2C1': { + tokenAddress: '0xD0b...', + currency: 'ETH', + price: 1.0, + }, + }, + '0x89': { + '0x1234567890123456789012345678901234567890': { + tokenAddress: '0x123...', + currency: 'MATIC', + price: 1.25, + }, + '0x2345678901234567890123456789012345678901': { + tokenAddress: '0x234...', + currency: 'MATIC', + price: 1.25, + }, + }, + '0xa4b1': { + '0x1234567890123456789012345678901234567890': { + tokenAddress: '0x123...', + currency: 'ARB', + price: 0.91, + }, + '0x2345678901234567890123456789012345678901': { + tokenAddress: '0x234...', + currency: 'ARB', + price: 0.91, + }, + }, + }, + }, + TokensController: { + allTokens: { + '0x1': { + '0x1234567890123456789012345678901234567890': [ + { + address: '0x1234567890123456789012345678901234567890', + decimals: 6, + symbol: 'USDC', + name: 'USD Coin', + }, + { + address: '0x2345678901234567890123456789012345678901', + decimals: 6, + symbol: 'USDT', + name: 'Tether USD', + }, + { + address: '0xC0b86a33E6441b8C4C3C1d3e2C1d3e2C1d3e2C1', + decimals: 18, + symbol: 'DAI', + name: 'Dai', + }, + { + address: '0xD0b86a33E6441b8C4C3C1d3e2C1d3e2C1d3e2C1', + decimals: 18, + symbol: 'WETH', + name: 'Wrapped Ether', + }, + ], + '0x2345678901234567890123456789012345678901': [ + { + address: '0xC0b86a33E6441b8C4C3C1d3e2C1d3e2C1d3e2C1', + decimals: 18, + symbol: 'DAI', + name: 'Dai', + }, + { + address: '0xD0b86a33E6441b8C4C3C1d3e2C1d3e2C1d3e2C1', + decimals: 18, + symbol: 'WETH', + name: 'Wrapped Ether', + }, + ], + }, + '0x89': { + '0x1234567890123456789012345678901234567890': [ + { + address: '0x1234567890123456789012345678901234567890', + decimals: 6, + symbol: 'USDC', + name: 'USD Coin', + }, + { + address: '0x2345678901234567890123456789012345678901', + decimals: 6, + symbol: 'USDT', + name: 'Tether USD', + }, + ], + }, + '0xa4b1': { + '0x1234567890123456789012345678901234567890': [ + { + address: '0x1234567890123456789012345678901234567890', + decimals: 6, + symbol: 'USDC', + name: 'USD Coin', + }, + { + address: '0x2345678901234567890123456789012345678901', + decimals: 6, + symbol: 'USDT', + name: 'Tether USD', + }, + ], + }, + }, + }, + MultichainAssetsRatesController: { conversionRates: {} }, + MultichainBalancesController: { balances: {} }, + MultichainAssetsController: { + assetsMetadata: {}, + accountsAssets: {}, + allIgnoredAssets: {}, + }, + CurrencyRateController: { + currentCurrency: userCurrency, + currencyRates: { + ETH: { conversionRate: 2400, usdConversionRate: 2400 }, + MATIC: { conversionRate: 0.8, usdConversionRate: 0.8 }, + ARB: { conversionRate: 1.1, usdConversionRate: 1.1 }, + }, + }, +}); + +const createMobileMockState = (userCurrency = 'USD') => ({ + engine: { backgroundState: createBaseMockState(userCurrency) }, +}); + +describe('calculateBalanceForAllWallets', () => { + it('computes all wallets total in USD', () => { + const state = createMobileMockState('USD'); + const result = calculateBalanceForAllWallets( + state.engine.backgroundState.AccountTreeController as any, + state.engine.backgroundState.AccountsController as any, + state.engine.backgroundState.TokenBalancesController as any, + state.engine.backgroundState.TokenRatesController as any, + state.engine.backgroundState.MultichainAssetsRatesController as any, + state.engine.backgroundState.MultichainBalancesController as any, + state.engine.backgroundState.MultichainAssetsController as any, + state.engine.backgroundState.TokensController as any, + state.engine.backgroundState.CurrencyRateController as any, + undefined, + ); + expect(result.totalBalanceInUserCurrency).toBeCloseTo(4493.8, 1); + }); + + it('computes totals in EUR (different conversion rates)', () => { + const state = createMobileMockState('EUR'); + state.engine.backgroundState.CurrencyRateController.currencyRates.ETH.conversionRate = 2040; + state.engine.backgroundState.CurrencyRateController.currencyRates.MATIC.conversionRate = 0.68; + state.engine.backgroundState.CurrencyRateController.currencyRates.ARB.conversionRate = 0.935; + + const result = calculateBalanceForAllWallets( + state.engine.backgroundState.AccountTreeController as any, + state.engine.backgroundState.AccountsController as any, + state.engine.backgroundState.TokenBalancesController as any, + state.engine.backgroundState.TokenRatesController as any, + state.engine.backgroundState.MultichainAssetsRatesController as any, + state.engine.backgroundState.MultichainBalancesController as any, + state.engine.backgroundState.MultichainAssetsController as any, + state.engine.backgroundState.TokensController as any, + state.engine.backgroundState.CurrencyRateController as any, + undefined, + ); + expect(result.totalBalanceInUserCurrency).toBeCloseTo(3819.73, 2); + expect(result.userCurrency).toBe('EUR'); + }); + + it('includes non-EVM balances when provided', () => { + const state = createMobileMockState('EUR'); + // Adjust EUR rates + state.engine.backgroundState.CurrencyRateController.currencyRates.ETH.conversionRate = 2040; + state.engine.backgroundState.CurrencyRateController.currencyRates.MATIC.conversionRate = 0.68; + state.engine.backgroundState.CurrencyRateController.currencyRates.ARB.conversionRate = 0.935; + + // Add non-EVM account to group 0 + ( + state.engine.backgroundState as any + ).AccountsController.internalAccounts.accounts['account-4'] = { + id: 'account-4', + address: 'FzQ4QJ...yCzPq8dYc', + type: 'solana:eoa', + scopes: ['solana:mainnet'], + methods: [], + options: {}, + metadata: { name: 'Sol', keyring: { type: 'hd' }, importTime: 0 }, + }; + ( + state.engine.backgroundState as any + ).AccountTreeController.accountTree.wallets[ + 'entropy:entropy-source-1' + ].groups['entropy:entropy-source-1/0'].accounts.push('account-4'); + + // Non-EVM balance and conversion rate (already in user currency) + (state.engine.backgroundState as any).MultichainBalancesController.balances[ + 'account-4' + ] = { + 'solana:mainnet/solana:FzQ4QJ...yCzPq8dYc': { + amount: '50.0', + unit: 'SOL', + }, + }; + ( + state.engine.backgroundState as any + ).MultichainAssetsRatesController.conversionRates[ + 'solana:mainnet/solana:FzQ4QJ...yCzPq8dYc' + ] = { + rate: '50.0', + conversionTime: 0, + }; + + const result = calculateBalanceForAllWallets( + state.engine.backgroundState.AccountTreeController as any, + state.engine.backgroundState.AccountsController as any, + state.engine.backgroundState.TokenBalancesController as any, + state.engine.backgroundState.TokenRatesController as any, + state.engine.backgroundState.MultichainAssetsRatesController as any, + state.engine.backgroundState.MultichainBalancesController as any, + state.engine.backgroundState.MultichainAssetsController as any, + state.engine.backgroundState.TokensController as any, + state.engine.backgroundState.CurrencyRateController as any, + undefined, + ); + // 3819.73 EUR (EVM from previous test) + 50*50 = 2500 = 6319.73 + expect(result.totalBalanceInUserCurrency).toBeCloseTo(6319.73, 2); + }); + + it('filters out disabled chains via enabledNetworkMap (mobile semantics: false disables)', () => { + const state = createMobileMockState('USD'); + const enabledNetworkMap = { + eip155: { '0x1': true, '0x89': true, '0xa4b1': false }, + } as Record>; + const result = calculateBalanceForAllWallets( + state.engine.backgroundState.AccountTreeController as any, + state.engine.backgroundState.AccountsController as any, + state.engine.backgroundState.TokenBalancesController as any, + state.engine.backgroundState.TokenRatesController as any, + state.engine.backgroundState.MultichainAssetsRatesController as any, + state.engine.backgroundState.MultichainBalancesController as any, + state.engine.backgroundState.MultichainAssetsController as any, + state.engine.backgroundState.TokensController as any, + state.engine.backgroundState.CurrencyRateController as any, + enabledNetworkMap, + ); + // Excluding ARB group amounts (200.2) from 4493.8 => 4293.6 + expect(result.totalBalanceInUserCurrency).toBeCloseTo(4293.6, 1); + }); + + it('filters out chains missing from enabledNetworkMap (extension semantics: missing disables)', () => { + const state = createMobileMockState('USD'); + const enabledNetworkMap = { + eip155: { '0x1': true, '0x89': true }, + } as Record>; // 0xa4b1 missing + const result = calculateBalanceForAllWallets( + state.engine.backgroundState.AccountTreeController as any, + state.engine.backgroundState.AccountsController as any, + state.engine.backgroundState.TokenBalancesController as any, + state.engine.backgroundState.TokenRatesController as any, + state.engine.backgroundState.MultichainAssetsRatesController as any, + state.engine.backgroundState.MultichainBalancesController as any, + state.engine.backgroundState.MultichainAssetsController as any, + state.engine.backgroundState.TokensController as any, + state.engine.backgroundState.CurrencyRateController as any, + enabledNetworkMap, + ); + expect(result.totalBalanceInUserCurrency).toBeCloseTo(4293.6, 1); + }); + + it('handles undefined wallet entries when aggregating totals', () => { + const state = createMobileMockState('USD'); + ( + state.engine.backgroundState as any + ).AccountTreeController.accountTree.wallets['undefined:wallet'] = undefined; + + const result = calculateBalanceForAllWallets( + state.engine.backgroundState.AccountTreeController as any, + state.engine.backgroundState.AccountsController as any, + state.engine.backgroundState.TokenBalancesController as any, + state.engine.backgroundState.TokenRatesController as any, + state.engine.backgroundState.MultichainAssetsRatesController as any, + state.engine.backgroundState.MultichainBalancesController as any, + state.engine.backgroundState.MultichainAssetsController as any, + state.engine.backgroundState.TokensController as any, + state.engine.backgroundState.CurrencyRateController as any, + undefined, + ); + expect(result.totalBalanceInUserCurrency).toBeGreaterThanOrEqual(0); + }); + + it('ignores EVM token that is not listed in allTokens', () => { + const state = createMobileMockState('USD'); + (state.engine.backgroundState as any).TokenBalancesController.tokenBalances[ + '0x1234567890123456789012345678901234567890' + ]['0x1']['0xEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEE'] = '0x1'; + + const result = calculateBalanceForAllWallets( + state.engine.backgroundState.AccountTreeController as any, + state.engine.backgroundState.AccountsController as any, + state.engine.backgroundState.TokenBalancesController as any, + state.engine.backgroundState.TokenRatesController as any, + state.engine.backgroundState.MultichainAssetsRatesController as any, + state.engine.backgroundState.MultichainBalancesController as any, + state.engine.backgroundState.MultichainAssetsController as any, + state.engine.backgroundState.TokensController as any, + state.engine.backgroundState.CurrencyRateController as any, + undefined, + ); + expect(result.totalBalanceInUserCurrency).toBeGreaterThan(0); + }); + + it('skips non-EVM totals for disabled chain and NaN inputs', () => { + const state = createMobileMockState('USD'); + ( + state.engine.backgroundState as any + ).AccountsController.internalAccounts.accounts['account-8'] = { + id: 'account-8', + address: 'NonEvm4', + type: 'solana:eoa', + scopes: ['solana:mainnet'], + methods: [], + options: {}, + metadata: { name: 'Sol4', keyring: { type: 'hd' }, importTime: 0 }, + }; + ( + state.engine.backgroundState as any + ).AccountTreeController.accountTree.wallets[ + 'entropy:entropy-source-1' + ].groups['entropy:entropy-source-1/0'].accounts.push('account-8'); + + (state.engine.backgroundState as any).MultichainBalancesController.balances[ + 'account-8' + ] = { + 'solana:mainnet/asset:disabled': { amount: '5', unit: 'X' }, + 'solana:mainnet/asset:nan-amount': { amount: 'abc', unit: 'Y' }, + 'solana:mainnet/asset:nan-rate': { amount: '3', unit: 'Z' }, + }; + ( + state.engine.backgroundState as any + ).MultichainAssetsRatesController.conversionRates[ + 'solana:mainnet/asset:disabled' + ] = { + rate: '2', + marketData: { pricePercentChange: { P1D: 10 } }, + conversionTime: 0, + }; + ( + state.engine.backgroundState as any + ).MultichainAssetsRatesController.conversionRates[ + 'solana:mainnet/asset:nan-amount' + ] = { + rate: '2', + marketData: { pricePercentChange: { P1D: 10 } }, + conversionTime: 0, + }; + ( + state.engine.backgroundState as any + ).MultichainAssetsRatesController.conversionRates[ + 'solana:mainnet/asset:nan-rate' + ] = { + rate: 'NaN', + marketData: { pricePercentChange: { P1D: 10 } }, + conversionTime: 0, + }; + + const enabledNetworkMap = { solana: { 'solana:mainnet': false } } as Record< + string, + Record + >; + + const result = calculateBalanceForAllWallets( + state.engine.backgroundState.AccountTreeController as any, + state.engine.backgroundState.AccountsController as any, + state.engine.backgroundState.TokenBalancesController as any, + state.engine.backgroundState.TokenRatesController as any, + state.engine.backgroundState.MultichainAssetsRatesController as any, + state.engine.backgroundState.MultichainBalancesController as any, + state.engine.backgroundState.MultichainAssetsController as any, + state.engine.backgroundState.TokensController as any, + state.engine.backgroundState.CurrencyRateController as any, + enabledNetworkMap, + ); + expect(result.totalBalanceInUserCurrency).toBeGreaterThanOrEqual(0); + }); + + it('skips non-EVM assets when conversion rate is missing', () => { + const state = createMobileMockState('USD'); + + // Add a non-EVM account + ( + state.engine.backgroundState as any + ).AccountsController.internalAccounts.accounts['account-missing-rate'] = { + id: 'account-missing-rate', + address: 'NonEvmMissingRate', + type: 'solana:eoa', + scopes: ['solana:mainnet'], + methods: [], + options: {}, + metadata: { + name: 'SolMissingRate', + keyring: { type: 'hd' }, + importTime: 0, + }, + }; + + // Add the account to a wallet group + ( + state.engine.backgroundState as any + ).AccountTreeController.accountTree.wallets[ + 'entropy:entropy-source-1' + ].groups['entropy:entropy-source-1/0'].accounts.push( + 'account-missing-rate', + ); + + // Set up balance for an asset without a corresponding conversion rate + (state.engine.backgroundState as any).MultichainBalancesController.balances[ + 'account-missing-rate' + ] = { + 'solana:mainnet/asset:no-rate': { amount: '100', unit: 'NORATES' }, + }; + + // Intentionally NOT setting a conversion rate for this asset + // This tests line 238 in balances.ts: if (!conversionRate) { return null; } + + const result = calculateBalanceForAllWallets( + state.engine.backgroundState.AccountTreeController as any, + state.engine.backgroundState.AccountsController as any, + state.engine.backgroundState.TokenBalancesController as any, + state.engine.backgroundState.TokenRatesController as any, + state.engine.backgroundState.MultichainAssetsRatesController as any, + state.engine.backgroundState.MultichainBalancesController as any, + state.engine.backgroundState.MultichainAssetsController as any, + state.engine.backgroundState.TokensController as any, + state.engine.backgroundState.CurrencyRateController as any, + undefined, + ); + + // The calculation should complete successfully, excluding the asset with missing rate + expect(result.totalBalanceInUserCurrency).toBeGreaterThanOrEqual(0); + // The total should remain the same as without the missing-rate asset since it gets filtered out + expect(typeof result.totalBalanceInUserCurrency).toBe('number'); + expect(Number.isFinite(result.totalBalanceInUserCurrency)).toBe(true); + }); + + it('includes native and staked balances in totals', () => { + const state = createMobileMockState('USD'); + + const baseline = calculateBalanceForAllWallets( + state.engine.backgroundState.AccountTreeController as any, + state.engine.backgroundState.AccountsController as any, + state.engine.backgroundState.TokenBalancesController as any, + state.engine.backgroundState.TokenRatesController as any, + state.engine.backgroundState.MultichainAssetsRatesController as any, + state.engine.backgroundState.MultichainBalancesController as any, + state.engine.backgroundState.MultichainAssetsController as any, + state.engine.backgroundState.TokensController as any, + state.engine.backgroundState.CurrencyRateController as any, + undefined, + ); + + const account = '0x1234567890123456789012345678901234567890'; + const chainId = '0x1'; + const ZERO = '0x0000000000000000000000000000000000000000'; + const nativeMktAddr = getNativeTokenAddress(chainId as any); + (state.engine.backgroundState as any).TokenRatesController.marketData[ + chainId + ][nativeMktAddr] = { + tokenAddress: nativeMktAddr, + currency: 'ETH', + price: 1.0, + } as any; + + // 1 ETH native + (state.engine.backgroundState as any).TokenBalancesController.tokenBalances[ + account + ][chainId][ZERO] = '0xde0b6b3a7640000'; + + // 0.5 staked ETH + const stakingAddr = ( + STAKING_CONTRACT_ADDRESS_BY_CHAINID as Record + )[chainId]; + (state.engine.backgroundState as any).TokenBalancesController.tokenBalances[ + account + ][chainId][stakingAddr] = '0x6f05b59d3b20000'; + + const result = calculateBalanceForAllWallets( + state.engine.backgroundState.AccountTreeController as any, + state.engine.backgroundState.AccountsController as any, + state.engine.backgroundState.TokenBalancesController as any, + state.engine.backgroundState.TokenRatesController as any, + state.engine.backgroundState.MultichainAssetsRatesController as any, + state.engine.backgroundState.MultichainBalancesController as any, + state.engine.backgroundState.MultichainAssetsController as any, + state.engine.backgroundState.TokensController as any, + state.engine.backgroundState.CurrencyRateController as any, + undefined, + ); + + // ETH->USD = 2400, price=1, amounts 1.0 + 0.5 => +3600 + expect(result.totalBalanceInUserCurrency).toBeCloseTo( + baseline.totalBalanceInUserCurrency + 3600, + 6, + ); + }); + + describe('calculateBalanceChangeForAllWallets', () => { + it('computes 1d change for EVM tokens', () => { + const state = createMobileMockState('USD'); + // Inject percent change into market data for one token to exercise change calc + (state.engine.backgroundState as any).TokenRatesController.marketData[ + '0x1' + ]['0xD0b86a33E6441b8C4C3C1d3e2C1d3e2C1d3e2C1'].pricePercentChange1d = 10; + + const out = calculateBalanceChangeForAllWallets( + state.engine.backgroundState.AccountTreeController as any, + state.engine.backgroundState.AccountsController as any, + state.engine.backgroundState.TokenBalancesController as any, + state.engine.backgroundState.TokenRatesController as any, + state.engine.backgroundState.MultichainAssetsRatesController as any, + state.engine.backgroundState.MultichainBalancesController as any, + state.engine.backgroundState.MultichainAssetsController as any, + state.engine.backgroundState.TokensController as any, + state.engine.backgroundState.CurrencyRateController as any, + undefined, + '1d', + ); + + // Expect exact calculations: + // 1 WETH @ 1 ETH, 1 ETH = 2400 USD => current = 2400 + // previous = 2400 / 1.1, delta = current - previous, pct = 10% + expect(out.userCurrency).toBe('USD'); + expect(out.period).toBe('1d'); + expect(out.currentTotalInUserCurrency).toBeCloseTo(2400, 6); + expect(out.previousTotalInUserCurrency).toBeCloseTo(2400 / 1.1, 6); + expect(out.amountChangeInUserCurrency).toBeCloseTo(2400 - 2400 / 1.1, 6); + expect(out.percentChange).toBeCloseTo(10, 6); + }); + + it('respects enabledNetworkMap', () => { + const state = createMobileMockState('USD'); + (state.engine.backgroundState as any).TokenRatesController.marketData[ + '0x1' + ]['0xD0b86a33E6441b8C4C3C1d3e2C1d3e2C1d3e2C1'].pricePercentChange1d = 10; + const enabledNetworkMap = { + eip155: { '0x1': false, '0x89': true, '0xa4b1': true }, + } as Record>; + + const out = calculateBalanceChangeForAllWallets( + state.engine.backgroundState.AccountTreeController as any, + state.engine.backgroundState.AccountsController as any, + state.engine.backgroundState.TokenBalancesController as any, + state.engine.backgroundState.TokenRatesController as any, + state.engine.backgroundState.MultichainAssetsRatesController as any, + state.engine.backgroundState.MultichainBalancesController as any, + state.engine.backgroundState.MultichainAssetsController as any, + state.engine.backgroundState.TokensController as any, + state.engine.backgroundState.CurrencyRateController as any, + enabledNetworkMap, + '1d', + ); + + // With ETH disabled, change should exclude 0x1 tokens => zeros across the board + expect(out.currentTotalInUserCurrency).toBe(0); + expect(out.previousTotalInUserCurrency).toBe(0); + expect(out.amountChangeInUserCurrency).toBe(0); + expect(out.percentChange).toBe(0); + }); + + it('computes 1d change aggregating EVM and non-EVM assets (complex case)', () => { + const state = createMobileMockState('USD'); + + // EVM side: 1 WETH @ 1 ETH, ETH→USD=2400, +10% (pricePercentChange1d) + (state.engine.backgroundState as any).TokenRatesController.marketData[ + '0x1' + ]['0xD0b86a33E6441b8C4C3C1d3e2C1d3e2C1d3e2C1'].pricePercentChange1d = 10; + + // Non-EVM side: add a Solana-like asset with 10 units @ 50 USD each, +20% (P1D) + ( + state.engine.backgroundState as any + ).AccountsController.internalAccounts.accounts['account-4'] = { + id: 'account-4', + address: 'FzQ4QJ...yCzPq8dYc', + type: 'solana:eoa', + scopes: ['solana:mainnet'], + methods: [], + options: {}, + metadata: { name: 'Sol', keyring: { type: 'hd' }, importTime: 0 }, + }; + ( + state.engine.backgroundState as any + ).AccountTreeController.accountTree.wallets[ + 'entropy:entropy-source-1' + ].groups['entropy:entropy-source-1/0'].accounts.push('account-4'); + + ( + state.engine.backgroundState as any + ).MultichainBalancesController.balances['account-4'] = { + 'solana:mainnet/solana:FzQ4QJ...yCzPq8dYc': { + amount: '10.0', + unit: 'SOL', + }, + }; + ( + state.engine.backgroundState as any + ).MultichainAssetsRatesController.conversionRates[ + 'solana:mainnet/solana:FzQ4QJ...yCzPq8dYc' + ] = { + rate: '50.0', + marketData: { pricePercentChange: { P1D: 20 } }, + conversionTime: 0, + }; + + const out = calculateBalanceChangeForAllWallets( + state.engine.backgroundState.AccountTreeController as any, + state.engine.backgroundState.AccountsController as any, + state.engine.backgroundState.TokenBalancesController as any, + state.engine.backgroundState.TokenRatesController as any, + state.engine.backgroundState.MultichainAssetsRatesController as any, + state.engine.backgroundState.MultichainBalancesController as any, + state.engine.backgroundState.MultichainAssetsController as any, + state.engine.backgroundState.TokensController as any, + state.engine.backgroundState.CurrencyRateController as any, + undefined, + '1d', + ); + + // Calculation: + // EVM current = 1 * 1 ETH * 2400 USD = 2400; previous = 2400 / 1.1 + // non-EVM current = 10 * 50 = 500; previous = 500 / 1.2 + // total current = 2400 + 500 = 2900 + // total previous = 2400/1.1 + 500/1.2 + // amount change = current - previous + // percent change = (amount change / previous) * 100 + const expectedCurrent = 2400 + 500; + const expectedPrevious = 2400 / 1.1 + 500 / 1.2; + const expectedDelta = expectedCurrent - expectedPrevious; + const expectedPct = (expectedDelta / expectedPrevious) * 100; + + expect(out.currentTotalInUserCurrency).toBeCloseTo(expectedCurrent, 6); + expect(out.previousTotalInUserCurrency).toBeCloseTo(expectedPrevious, 6); + expect(out.amountChangeInUserCurrency).toBeCloseTo(expectedDelta, 6); + expect(out.percentChange).toBeCloseTo(expectedPct, 6); + }); + + it('skips EVM asset when percent change is missing (coverage of guard path)', () => { + const state = createMobileMockState('USD'); + // Ensure price exists but percent is missing + delete (state.engine.backgroundState as any).TokenRatesController + .marketData['0x1']['0xD0b86a33E6441b8C4C3C1d3e2C1d3e2C1d3e2C1'] + .pricePercentChange1d; + + const out = calculateBalanceChangeForAllWallets( + state.engine.backgroundState.AccountTreeController as any, + state.engine.backgroundState.AccountsController as any, + state.engine.backgroundState.TokenBalancesController as any, + state.engine.backgroundState.TokenRatesController as any, + state.engine.backgroundState.MultichainAssetsRatesController as any, + state.engine.backgroundState.MultichainBalancesController as any, + state.engine.backgroundState.MultichainAssetsController as any, + state.engine.backgroundState.TokensController as any, + state.engine.backgroundState.CurrencyRateController as any, + undefined, + '1d', + ); + + expect(out.currentTotalInUserCurrency).toBe(0); + expect(out.previousTotalInUserCurrency).toBe(0); + expect(out.amountChangeInUserCurrency).toBe(0); + expect(out.percentChange).toBe(0); + }); + + it('skips non-EVM asset when rate is NaN or percent is NaN (coverage of guard path)', () => { + const state = createMobileMockState('USD'); + + // Add a non-EVM account with a balance + ( + state.engine.backgroundState as any + ).AccountsController.internalAccounts.accounts['account-5'] = { + id: 'account-5', + address: 'NonEvmAddress', + type: 'solana:eoa', + scopes: ['solana:mainnet'], + methods: [], + options: {}, + metadata: { name: 'Sol', keyring: { type: 'hd' }, importTime: 0 }, + }; + ( + state.engine.backgroundState as any + ).AccountTreeController.accountTree.wallets[ + 'entropy:entropy-source-1' + ].groups['entropy:entropy-source-1/0'].accounts.push('account-5'); + + ( + state.engine.backgroundState as any + ).MultichainBalancesController.balances['account-5'] = { + 'solana:mainnet/asset:bad-rate': { + amount: '10.0', + unit: 'BAD', + }, + 'solana:mainnet/asset:bad-percent': { + amount: '10.0', + unit: 'BADPCT', + }, + }; + // First asset: non-numeric rate + ( + state.engine.backgroundState as any + ).MultichainAssetsRatesController.conversionRates[ + 'solana:mainnet/asset:bad-rate' + ] = { + rate: 'not-a-number', + marketData: { pricePercentChange: { P1D: 10 } }, + conversionTime: 0, + }; + // Second asset: NaN percent + ( + state.engine.backgroundState as any + ).MultichainAssetsRatesController.conversionRates[ + 'solana:mainnet/asset:bad-percent' + ] = { + rate: '5.0', + marketData: { pricePercentChange: { P1D: Number.NaN } }, + conversionTime: 0, + }; + + const out = calculateBalanceChangeForAllWallets( + state.engine.backgroundState.AccountTreeController as any, + state.engine.backgroundState.AccountsController as any, + state.engine.backgroundState.TokenBalancesController as any, + state.engine.backgroundState.TokenRatesController as any, + state.engine.backgroundState.MultichainAssetsRatesController as any, + state.engine.backgroundState.MultichainBalancesController as any, + state.engine.backgroundState.MultichainAssetsController as any, + state.engine.backgroundState.TokensController as any, + state.engine.backgroundState.CurrencyRateController as any, + undefined, + '1d', + ); + + // Both non-EVM entries should be skipped, so everything zero + expect(out.currentTotalInUserCurrency).toBe(0); + expect(out.previousTotalInUserCurrency).toBe(0); + expect(out.amountChangeInUserCurrency).toBe(0); + expect(out.percentChange).toBe(0); + }); + + it('skips EVM asset when percent change is -100 (denom === 0)', () => { + const state = createMobileMockState('USD'); + (state.engine.backgroundState as any).TokenRatesController.marketData[ + '0x1' + ]['0xD0b86a33E6441b8C4C3C1d3e2C1d3e2C1d3e2C1'].pricePercentChange1d = + -100; + + const out = calculateBalanceChangeForAllWallets( + state.engine.backgroundState.AccountTreeController as any, + state.engine.backgroundState.AccountsController as any, + state.engine.backgroundState.TokenBalancesController as any, + state.engine.backgroundState.TokenRatesController as any, + state.engine.backgroundState.MultichainAssetsRatesController as any, + state.engine.backgroundState.MultichainBalancesController as any, + state.engine.backgroundState.MultichainAssetsController as any, + state.engine.backgroundState.TokensController as any, + state.engine.backgroundState.CurrencyRateController as any, + undefined, + '1d', + ); + + expect(out.currentTotalInUserCurrency).toBe(0); + expect(out.previousTotalInUserCurrency).toBe(0); + expect(out.amountChangeInUserCurrency).toBe(0); + expect(out.percentChange).toBe(0); + }); + + it('skips non-EVM asset when percent change is -100 (denom === 0)', () => { + const state = createMobileMockState('USD'); + + ( + state.engine.backgroundState as any + ).AccountsController.internalAccounts.accounts['account-6'] = { + id: 'account-6', + address: 'NonEvm2', + type: 'solana:eoa', + scopes: ['solana:mainnet'], + methods: [], + options: {}, + metadata: { name: 'Sol2', keyring: { type: 'hd' }, importTime: 0 }, + }; + ( + state.engine.backgroundState as any + ).AccountTreeController.accountTree.wallets[ + 'entropy:entropy-source-1' + ].groups['entropy:entropy-source-1/0'].accounts.push('account-6'); + + ( + state.engine.backgroundState as any + ).MultichainBalancesController.balances['account-6'] = { + 'solana:mainnet/asset:denom-zero': { + amount: '7.0', + unit: 'BAD100', + }, + }; + ( + state.engine.backgroundState as any + ).MultichainAssetsRatesController.conversionRates[ + 'solana:mainnet/asset:denom-zero' + ] = { + rate: '10.0', + marketData: { pricePercentChange: { P1D: -100 } }, + conversionTime: 0, + }; + + const out = calculateBalanceChangeForAllWallets( + state.engine.backgroundState.AccountTreeController as any, + state.engine.backgroundState.AccountsController as any, + state.engine.backgroundState.TokenBalancesController as any, + state.engine.backgroundState.TokenRatesController as any, + state.engine.backgroundState.MultichainAssetsRatesController as any, + state.engine.backgroundState.MultichainBalancesController as any, + state.engine.backgroundState.MultichainAssetsController as any, + state.engine.backgroundState.TokensController as any, + state.engine.backgroundState.CurrencyRateController as any, + undefined, + '1d', + ); + + expect(out.currentTotalInUserCurrency).toBe(0); + expect(out.previousTotalInUserCurrency).toBe(0); + expect(out.amountChangeInUserCurrency).toBe(0); + expect(out.percentChange).toBe(0); + }); + + it('change calc ignores undefined wallet entry', () => { + const state = createMobileMockState('USD'); + ( + state.engine.backgroundState as any + ).AccountTreeController.accountTree.wallets['undefined:wallet'] = + undefined; + const out = calculateBalanceChangeForAllWallets( + state.engine.backgroundState.AccountTreeController as any, + state.engine.backgroundState.AccountsController as any, + state.engine.backgroundState.TokenBalancesController as any, + state.engine.backgroundState.TokenRatesController as any, + state.engine.backgroundState.MultichainAssetsRatesController as any, + state.engine.backgroundState.MultichainBalancesController as any, + state.engine.backgroundState.MultichainAssetsController as any, + state.engine.backgroundState.TokensController as any, + state.engine.backgroundState.CurrencyRateController as any, + undefined, + '1d', + ); + expect(out.currentTotalInUserCurrency).toBe(0); + expect(out.previousTotalInUserCurrency).toBe(0); + }); + + it('change calc ignores EVM token not in allTokens', () => { + const state = createMobileMockState('USD'); + ( + state.engine.backgroundState as any + ).TokenBalancesController.tokenBalances[ + '0x1234567890123456789012345678901234567890' + ]['0x1']['0xEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEE'] = '0x1'; + (state.engine.backgroundState as any).TokenRatesController.marketData[ + '0x1' + ]['0xEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEE'] = { + tokenAddress: '0xEEEE', + currency: 'ETH', + price: 1.0, + pricePercentChange1d: 5, + } as any; + const out = calculateBalanceChangeForAllWallets( + state.engine.backgroundState.AccountTreeController as any, + state.engine.backgroundState.AccountsController as any, + state.engine.backgroundState.TokenBalancesController as any, + state.engine.backgroundState.TokenRatesController as any, + state.engine.backgroundState.MultichainAssetsRatesController as any, + state.engine.backgroundState.MultichainBalancesController as any, + state.engine.backgroundState.MultichainAssetsController as any, + state.engine.backgroundState.TokensController as any, + state.engine.backgroundState.CurrencyRateController as any, + undefined, + '1d', + ); + expect(out.currentTotalInUserCurrency).toBe(0); + expect(out.previousTotalInUserCurrency).toBe(0); + }); + + it('change calc ignores EVM token with invalid hex balance', () => { + const state = createMobileMockState('USD'); + ( + state.engine.backgroundState as any + ).TokenBalancesController.tokenBalances[ + '0x2345678901234567890123456789012345678901' + ]['0x1']['0xD0b86a33E6441b8C4C3C1d3e2C1d3e2C1d3e2C1'] = '0xZZZ'; + (state.engine.backgroundState as any).TokenRatesController.marketData[ + '0x1' + ]['0xD0b86a33E6441b8C4C3C1d3e2C1d3e2C1d3e2C1'] = { + tokenAddress: '0xD0b', + currency: 'ETH', + price: 1.0, + pricePercentChange1d: 5, + } as any; + const out = calculateBalanceChangeForAllWallets( + state.engine.backgroundState.AccountTreeController as any, + state.engine.backgroundState.AccountsController as any, + state.engine.backgroundState.TokenBalancesController as any, + state.engine.backgroundState.TokenRatesController as any, + state.engine.backgroundState.MultichainAssetsRatesController as any, + state.engine.backgroundState.MultichainBalancesController as any, + state.engine.backgroundState.MultichainAssetsController as any, + state.engine.backgroundState.TokensController as any, + state.engine.backgroundState.CurrencyRateController as any, + undefined, + '1d', + ); + expect(out.currentTotalInUserCurrency).toBe(0); + expect(out.previousTotalInUserCurrency).toBe(0); + }); + + it('change calc ignores EVM token when price missing', () => { + const state = createMobileMockState('USD'); + ( + state.engine.backgroundState as any + ).TokenBalancesController.tokenBalances[ + '0x1234567890123456789012345678901234567890' + ]['0x1']['0xD0b86a33E6441b8C4C3C1d3e2C1d3e2C1d3e2C1'] = '0x1'; + (state.engine.backgroundState as any).TokenRatesController.marketData[ + '0x1' + ]['0xD0b86a33E6441b8C4C3C1d3e2C1d3e2C1d3e2C1'] = { + tokenAddress: '0xD0b', + currency: 'ETH', + pricePercentChange1d: 5, + } as any; + const out = calculateBalanceChangeForAllWallets( + state.engine.backgroundState.AccountTreeController as any, + state.engine.backgroundState.AccountsController as any, + state.engine.backgroundState.TokenBalancesController as any, + state.engine.backgroundState.TokenRatesController as any, + state.engine.backgroundState.MultichainAssetsRatesController as any, + state.engine.backgroundState.MultichainBalancesController as any, + state.engine.backgroundState.MultichainAssetsController as any, + state.engine.backgroundState.TokensController as any, + state.engine.backgroundState.CurrencyRateController as any, + undefined, + '1d', + ); + expect(out.currentTotalInUserCurrency).toBe(0); + expect(out.previousTotalInUserCurrency).toBe(0); + }); + + it('change calc ignores EVM token when native conversion missing', () => { + const state = createMobileMockState('USD'); + ( + state.engine.backgroundState as any + ).TokenBalancesController.tokenBalances[ + '0x1234567890123456789012345678901234567890' + ]['0x1']['0xD0b86a33E6441b8C4C3C1d3e2C1d3e2C1d3e2C1'] = '0x1'; + (state.engine.backgroundState as any).TokenRatesController.marketData[ + '0x1' + ]['0xD0b86a33E6441b8C4C3C1d3e2C1d3e2C1d3e2C1'] = { + tokenAddress: '0xD0b', + currency: 'ETH', + price: 1.0, + pricePercentChange1d: 5, + } as any; + delete (state.engine.backgroundState as any).CurrencyRateController + .currencyRates.ETH; + const out = calculateBalanceChangeForAllWallets( + state.engine.backgroundState.AccountTreeController as any, + state.engine.backgroundState.AccountsController as any, + state.engine.backgroundState.TokenBalancesController as any, + state.engine.backgroundState.TokenRatesController as any, + state.engine.backgroundState.MultichainAssetsRatesController as any, + state.engine.backgroundState.MultichainBalancesController as any, + state.engine.backgroundState.MultichainAssetsController as any, + state.engine.backgroundState.TokensController as any, + state.engine.backgroundState.CurrencyRateController as any, + undefined, + '1d', + ); + expect(out.currentTotalInUserCurrency).toBe(0); + expect(out.previousTotalInUserCurrency).toBe(0); + }); + + it('change calc ignores non-EVM account with no balances', () => { + const state = createMobileMockState('USD'); + ( + state.engine.backgroundState as any + ).AccountsController.internalAccounts.accounts['account-10'] = { + id: 'account-10', + address: 'NonEvmX', + type: 'solana:eoa', + scopes: ['solana:mainnet'], + methods: [], + options: {}, + metadata: { name: 'SolX', keyring: { type: 'hd' }, importTime: 0 }, + }; + ( + state.engine.backgroundState as any + ).AccountTreeController.accountTree.wallets[ + 'entropy:entropy-source-1' + ].groups['entropy:entropy-source-1/0'].accounts.push('account-10'); + const out = calculateBalanceChangeForAllWallets( + state.engine.backgroundState.AccountTreeController as any, + state.engine.backgroundState.AccountsController as any, + state.engine.backgroundState.TokenBalancesController as any, + state.engine.backgroundState.TokenRatesController as any, + state.engine.backgroundState.MultichainAssetsRatesController as any, + state.engine.backgroundState.MultichainBalancesController as any, + state.engine.backgroundState.MultichainAssetsController as any, + state.engine.backgroundState.TokensController as any, + state.engine.backgroundState.CurrencyRateController as any, + undefined, + '1d', + ); + expect(out.currentTotalInUserCurrency).toBe(0); + expect(out.previousTotalInUserCurrency).toBe(0); + }); + + it('change calc ignores non-EVM asset when chain disabled', () => { + const state = createMobileMockState('USD'); + ( + state.engine.backgroundState as any + ).AccountsController.internalAccounts.accounts['account-11'] = { + id: 'account-11', + address: 'NonEvmY', + type: 'solana:eoa', + scopes: ['solana:mainnet'], + methods: [], + options: {}, + metadata: { name: 'SolY', keyring: { type: 'hd' }, importTime: 0 }, + }; + ( + state.engine.backgroundState as any + ).AccountTreeController.accountTree.wallets[ + 'entropy:entropy-source-1' + ].groups['entropy:entropy-source-1/0'].accounts.push('account-11'); + ( + state.engine.backgroundState as any + ).MultichainBalancesController.balances['account-11'] = { + 'solana:mainnet/asset:Z': { amount: '5', unit: 'Z' }, + }; + ( + state.engine.backgroundState as any + ).MultichainAssetsRatesController.conversionRates[ + 'solana:mainnet/asset:Z' + ] = { + rate: '2', + marketData: { pricePercentChange: { P1D: 10 } }, + conversionTime: 0, + }; + const enabledNetworkMap = { + solana: { 'solana:mainnet': false }, + } as Record>; + const out = calculateBalanceChangeForAllWallets( + state.engine.backgroundState.AccountTreeController as any, + state.engine.backgroundState.AccountsController as any, + state.engine.backgroundState.TokenBalancesController as any, + state.engine.backgroundState.TokenRatesController as any, + state.engine.backgroundState.MultichainAssetsRatesController as any, + state.engine.backgroundState.MultichainBalancesController as any, + state.engine.backgroundState.MultichainAssetsController as any, + state.engine.backgroundState.TokensController as any, + state.engine.backgroundState.CurrencyRateController as any, + enabledNetworkMap, + '1d', + ); + expect(out.currentTotalInUserCurrency).toBe(0); + expect(out.previousTotalInUserCurrency).toBe(0); + }); + + it('change calc ignores non-EVM asset with NaN amount', () => { + const state = createMobileMockState('USD'); + ( + state.engine.backgroundState as any + ).AccountsController.internalAccounts.accounts['account-12'] = { + id: 'account-12', + address: 'NonEvmZ', + type: 'solana:eoa', + scopes: ['solana:mainnet'], + methods: [], + options: {}, + metadata: { name: 'SolZ', keyring: { type: 'hd' }, importTime: 0 }, + }; + ( + state.engine.backgroundState as any + ).AccountTreeController.accountTree.wallets[ + 'entropy:entropy-source-1' + ].groups['entropy:entropy-source-1/0'].accounts.push('account-12'); + ( + state.engine.backgroundState as any + ).MultichainBalancesController.balances['account-12'] = { + 'solana:mainnet/asset:W': { amount: 'abc', unit: 'W' }, + }; + ( + state.engine.backgroundState as any + ).MultichainAssetsRatesController.conversionRates[ + 'solana:mainnet/asset:W' + ] = { + rate: '2', + marketData: { pricePercentChange: { P1D: 10 } }, + conversionTime: 0, + }; + const out = calculateBalanceChangeForAllWallets( + state.engine.backgroundState.AccountTreeController as any, + state.engine.backgroundState.AccountsController as any, + state.engine.backgroundState.TokenBalancesController as any, + state.engine.backgroundState.TokenRatesController as any, + state.engine.backgroundState.MultichainAssetsRatesController as any, + state.engine.backgroundState.MultichainBalancesController as any, + state.engine.backgroundState.MultichainAssetsController as any, + state.engine.backgroundState.TokensController as any, + state.engine.backgroundState.CurrencyRateController as any, + undefined, + '1d', + ); + expect(out.currentTotalInUserCurrency).toBe(0); + expect(out.previousTotalInUserCurrency).toBe(0); + }); + + it('records zero group total when group has no accounts', () => { + const state = createMobileMockState('USD'); + ( + state.engine.backgroundState as any + ).AccountTreeController.accountTree.wallets[ + 'entropy:entropy-source-1' + ].groups['entropy:entropy-source-1/empty'] = { + id: 'entropy:entropy-source-1/empty', + type: AccountGroupType.MultichainAccount, + accounts: [], + metadata: {}, + }; + const res = calculateBalanceForAllWallets( + state.engine.backgroundState.AccountTreeController as any, + state.engine.backgroundState.AccountsController as any, + state.engine.backgroundState.TokenBalancesController as any, + state.engine.backgroundState.TokenRatesController as any, + state.engine.backgroundState.MultichainAssetsRatesController as any, + state.engine.backgroundState.MultichainBalancesController as any, + state.engine.backgroundState.MultichainAssetsController as any, + state.engine.backgroundState.TokensController as any, + state.engine.backgroundState.CurrencyRateController as any, + undefined, + ); + expect( + res.wallets['entropy:entropy-source-1'].groups[ + 'entropy:entropy-source-1/empty' + ].totalBalanceInUserCurrency, + ).toBe(0); + }); + + it('ignores invalid hex EVM balance in totals', () => { + const state = createMobileMockState('USD'); + const baseline = calculateBalanceForAllWallets( + state.engine.backgroundState.AccountTreeController as any, + state.engine.backgroundState.AccountsController as any, + state.engine.backgroundState.TokenBalancesController as any, + state.engine.backgroundState.TokenRatesController as any, + state.engine.backgroundState.MultichainAssetsRatesController as any, + state.engine.backgroundState.MultichainBalancesController as any, + state.engine.backgroundState.MultichainAssetsController as any, + state.engine.backgroundState.TokensController as any, + state.engine.backgroundState.CurrencyRateController as any, + undefined, + ); + ( + state.engine.backgroundState as any + ).TokenBalancesController.tokenBalances[ + '0x1234567890123456789012345678901234567890' + ]['0x1']['0xC0b86a33E6441b8C4C3C1d3e2C1d3e2C1d3e2C1'] = '0xZZZ'; + const res = calculateBalanceForAllWallets( + state.engine.backgroundState.AccountTreeController as any, + state.engine.backgroundState.AccountsController as any, + state.engine.backgroundState.TokenBalancesController as any, + state.engine.backgroundState.TokenRatesController as any, + state.engine.backgroundState.MultichainAssetsRatesController as any, + state.engine.backgroundState.MultichainBalancesController as any, + state.engine.backgroundState.MultichainAssetsController as any, + state.engine.backgroundState.TokensController as any, + state.engine.backgroundState.CurrencyRateController as any, + undefined, + ); + expect(res.totalBalanceInUserCurrency).toBe( + baseline.totalBalanceInUserCurrency, + ); + }); + + it('skips non-EVM balances with NaN amount in totals', () => { + const state = createMobileMockState('USD'); + ( + state.engine.backgroundState as any + ).AccountsController.internalAccounts.accounts['account-sol2'] = { + id: 'account-sol2', + address: 'SolAcc2', + type: 'solana:eoa', + scopes: ['solana:mainnet'], + methods: [], + options: {}, + metadata: { name: 'Sol2', keyring: { type: 'hd' }, importTime: 0 }, + }; + ( + state.engine.backgroundState as any + ).AccountTreeController.accountTree.wallets[ + 'entropy:entropy-source-1' + ].groups['entropy:entropy-source-1/0'].accounts.push('account-sol2'); + ( + state.engine.backgroundState as any + ).MultichainBalancesController.balances['account-sol2'] = { + 'solana:mainnet/asset:X': { amount: 'abc', unit: 'X' }, + }; + ( + state.engine.backgroundState as any + ).MultichainAssetsRatesController.conversionRates[ + 'solana:mainnet/asset:X' + ] = { rate: '2', conversionTime: 0 }; + const res = calculateBalanceForAllWallets( + state.engine.backgroundState.AccountTreeController as any, + state.engine.backgroundState.AccountsController as any, + state.engine.backgroundState.TokenBalancesController as any, + state.engine.backgroundState.TokenRatesController as any, + state.engine.backgroundState.MultichainAssetsRatesController as any, + state.engine.backgroundState.MultichainBalancesController as any, + state.engine.backgroundState.MultichainAssetsController as any, + state.engine.backgroundState.TokensController as any, + state.engine.backgroundState.CurrencyRateController as any, + undefined, + ); + expect(res.totalBalanceInUserCurrency).toBeGreaterThanOrEqual(0); + }); + + it('skips non-EVM balances with NaN rate in totals', () => { + const state = createMobileMockState('USD'); + ( + state.engine.backgroundState as any + ).AccountsController.internalAccounts.accounts['account-sol3'] = { + id: 'account-sol3', + address: 'SolAcc3', + type: 'solana:eoa', + scopes: ['solana:mainnet'], + methods: [], + options: {}, + metadata: { name: 'Sol3', keyring: { type: 'hd' }, importTime: 0 }, + }; + ( + state.engine.backgroundState as any + ).AccountTreeController.accountTree.wallets[ + 'entropy:entropy-source-1' + ].groups['entropy:entropy-source-1/0'].accounts.push('account-sol3'); + ( + state.engine.backgroundState as any + ).MultichainBalancesController.balances['account-sol3'] = { + 'solana:mainnet/asset:Y': { amount: '5', unit: 'Y' }, + }; + ( + state.engine.backgroundState as any + ).MultichainAssetsRatesController.conversionRates[ + 'solana:mainnet/asset:Y' + ] = { rate: 'abc', conversionTime: 0 }; + const res = calculateBalanceForAllWallets( + state.engine.backgroundState.AccountTreeController as any, + state.engine.backgroundState.AccountsController as any, + state.engine.backgroundState.TokenBalancesController as any, + state.engine.backgroundState.TokenRatesController as any, + state.engine.backgroundState.MultichainAssetsRatesController as any, + state.engine.backgroundState.MultichainBalancesController as any, + state.engine.backgroundState.MultichainAssetsController as any, + state.engine.backgroundState.TokensController as any, + state.engine.backgroundState.CurrencyRateController as any, + undefined, + ); + expect(res.totalBalanceInUserCurrency).toBeGreaterThanOrEqual(0); + }); + }); + + describe('calculateBalanceChangeForAccountGroup', () => { + it('eVM path computes previous/current (denom > 0) for group with balances', () => { + const state = createMobileMockState('USD'); + // Ensure group 1 contains an account with EVM balances (account-2) + ( + state.engine.backgroundState as any + ).AccountTreeController.accountTree.wallets[ + 'entropy:entropy-source-1' + ].groups['entropy:entropy-source-1/1'].accounts.push('account-2'); + + // Provide 1d percent change for a token that account-2 holds on mainnet + (state.engine.backgroundState as any).TokenRatesController.marketData[ + '0x1' + ]['0xC0b86a33E6441b8C4C3C1d3e2C1d3e2C1d3e2C1'].pricePercentChange1d = 10; + + const res = calculateBalanceChangeForAccountGroup( + state.engine.backgroundState.AccountTreeController as any, + state.engine.backgroundState.AccountsController as any, + state.engine.backgroundState.TokenBalancesController as any, + state.engine.backgroundState.TokenRatesController as any, + state.engine.backgroundState.MultichainAssetsRatesController as any, + state.engine.backgroundState.MultichainBalancesController as any, + state.engine.backgroundState.MultichainAssetsController as any, + state.engine.backgroundState.TokensController as any, + state.engine.backgroundState.CurrencyRateController as any, + undefined, + 'entropy:entropy-source-1/1', + '1d', + ); + + expect(res.currentTotalInUserCurrency).toBeGreaterThan(0); + expect(res.previousTotalInUserCurrency).toBeGreaterThan(0); + expect(res.previousTotalInUserCurrency).toBeLessThan( + res.currentTotalInUserCurrency, + ); + }); + it('computes 1d change for specified EVM-only group', () => { + const state = createMobileMockState('USD'); + // attach percent change to one token on mainnet + (state.engine.backgroundState as any).TokenRatesController.marketData[ + '0x1' + ]['0xC0b86a33E6441b8C4C3C1d3e2C1d3e2C1d3e2C1'] = { + tokenAddress: '0xC0b', + currency: 'ETH', + price: 0.00041, + pricePercentChange1d: 10, + }; + const res = calculateBalanceChangeForAccountGroup( + state.engine.backgroundState.AccountTreeController as any, + state.engine.backgroundState.AccountsController as any, + state.engine.backgroundState.TokenBalancesController as any, + state.engine.backgroundState.TokenRatesController as any, + state.engine.backgroundState.MultichainAssetsRatesController as any, + state.engine.backgroundState.MultichainBalancesController as any, + state.engine.backgroundState.MultichainAssetsController as any, + state.engine.backgroundState.TokensController as any, + state.engine.backgroundState.CurrencyRateController as any, + undefined, + 'entropy:entropy-source-1/1', + '1d', + ); + expect(res.userCurrency).toBe('USD'); + expect(res.period).toBe('1d'); + // Non-zero change expected if token balance and price exist + expect(res.currentTotalInUserCurrency).toBeGreaterThanOrEqual(0); + }); + + it('computes 1d change including native and staked balances', () => { + const state = createMobileMockState('USD'); + + // Baseline: give WETH a 10% change so baseline is 2400 current + (state.engine.backgroundState as any).TokenRatesController.marketData[ + '0x1' + ]['0xD0b86a33E6441b8C4C3C1d3e2C1d3e2C1d3e2C1'].pricePercentChange1d = 10; + + const before = calculateBalanceChangeForAllWallets( + state.engine.backgroundState.AccountTreeController as any, + state.engine.backgroundState.AccountsController as any, + state.engine.backgroundState.TokenBalancesController as any, + state.engine.backgroundState.TokenRatesController as any, + state.engine.backgroundState.MultichainAssetsRatesController as any, + state.engine.backgroundState.MultichainBalancesController as any, + state.engine.backgroundState.MultichainAssetsController as any, + state.engine.backgroundState.TokensController as any, + state.engine.backgroundState.CurrencyRateController as any, + undefined, + '1d', + ); + + const account = '0x1234567890123456789012345678901234567890'; + const chainId = '0x1'; + const ZERO = '0x0000000000000000000000000000000000000000'; + const nativeMktAddr = getNativeTokenAddress(chainId as any); + (state.engine.backgroundState as any).TokenRatesController.marketData[ + chainId + ][nativeMktAddr] = { + tokenAddress: nativeMktAddr, + currency: 'ETH', + price: 1.0, + pricePercentChange1d: 10, + } as any; + + // 1 ETH native and 0.5 staked ETH + ( + state.engine.backgroundState as any + ).TokenBalancesController.tokenBalances[account][chainId][ZERO] = + '0xde0b6b3a7640000'; + const stakingAddr = ( + STAKING_CONTRACT_ADDRESS_BY_CHAINID as Record + )[chainId]; + ( + state.engine.backgroundState as any + ).TokenBalancesController.tokenBalances[account][chainId][stakingAddr] = + '0x6f05b59d3b20000'; + + const after = calculateBalanceChangeForAllWallets( + state.engine.backgroundState.AccountTreeController as any, + state.engine.backgroundState.AccountsController as any, + state.engine.backgroundState.TokenBalancesController as any, + state.engine.backgroundState.TokenRatesController as any, + state.engine.backgroundState.MultichainAssetsRatesController as any, + state.engine.backgroundState.MultichainBalancesController as any, + state.engine.backgroundState.MultichainAssetsController as any, + state.engine.backgroundState.TokensController as any, + state.engine.backgroundState.CurrencyRateController as any, + undefined, + '1d', + ); + + // Additional current = 2400 + 1200; additional previous = (3600 / 1.1) + expect(after.currentTotalInUserCurrency).toBeCloseTo( + before.currentTotalInUserCurrency + 3600, + 6, + ); + expect(after.previousTotalInUserCurrency).toBeCloseTo( + before.previousTotalInUserCurrency + 3600 / 1.1, + 6, + ); + }); + + it('respects enabledNetworkMap for group', () => { + const state = createMobileMockState('USD'); + const enabledNetworkMap = { + eip155: { '0x1': true, '0x89': false }, + } as Record>; + // Add percent change for a polygon token that should be filtered out + (state.engine.backgroundState as any).TokenRatesController.marketData[ + '0x89' + ]['0x1234567890123456789012345678901234567890'] = { + tokenAddress: '0x123', + currency: 'MATIC', + price: 1.25, + pricePercentChange1d: 15, + }; + const res = calculateBalanceChangeForAccountGroup( + state.engine.backgroundState.AccountTreeController as any, + state.engine.backgroundState.AccountsController as any, + state.engine.backgroundState.TokenBalancesController as any, + state.engine.backgroundState.TokenRatesController as any, + state.engine.backgroundState.MultichainAssetsRatesController as any, + state.engine.backgroundState.MultichainBalancesController as any, + state.engine.backgroundState.MultichainAssetsController as any, + state.engine.backgroundState.TokensController as any, + state.engine.backgroundState.CurrencyRateController as any, + enabledNetworkMap, + 'entropy:entropy-source-1/0', + '1d', + ); + // Polygon chain disabled, so totals should reflect only other enabled chains + expect(res.currentTotalInUserCurrency).toBeGreaterThanOrEqual(0); + }); + + it('handles non-EVM balances for group', () => { + const state = createMobileMockState('USD'); + // create a new solana:eoa account inside group 0 and give it a non-evm asset + ( + state.engine.backgroundState as any + ).AccountsController.internalAccounts.accounts['account-sol'] = { + id: 'account-sol', + address: 'SolAcc', + type: 'solana:eoa', + scopes: ['solana:mainnet'], + methods: [], + options: {}, + metadata: { name: 'Sol', keyring: { type: 'hd' }, importTime: 0 }, + }; + ( + state.engine.backgroundState as any + ).AccountTreeController.accountTree.wallets[ + 'entropy:entropy-source-1' + ].groups['entropy:entropy-source-1/0'].accounts.push('account-sol'); + ( + state.engine.backgroundState as any + ).MultichainBalancesController.balances['account-sol'] = { + 'solana:mainnet/asset:SOL': { amount: '2', unit: 'SOL' }, + }; + ( + state.engine.backgroundState as any + ).MultichainAssetsRatesController.conversionRates[ + 'solana:mainnet/asset:SOL' + ] = { + rate: '100', + marketData: { pricePercentChange: { P1D: 5 } }, + conversionTime: 0, + }; + const res = calculateBalanceChangeForAccountGroup( + state.engine.backgroundState.AccountTreeController as any, + state.engine.backgroundState.AccountsController as any, + state.engine.backgroundState.TokenBalancesController as any, + state.engine.backgroundState.TokenRatesController as any, + state.engine.backgroundState.MultichainAssetsRatesController as any, + state.engine.backgroundState.MultichainBalancesController as any, + state.engine.backgroundState.MultichainAssetsController as any, + state.engine.backgroundState.TokensController as any, + state.engine.backgroundState.CurrencyRateController as any, + undefined, + 'entropy:entropy-source-1/0', + '1d', + ); + expect(res.currentTotalInUserCurrency).toBeGreaterThan(0); + expect(res.amountChangeInUserCurrency).toBeGreaterThanOrEqual(0); + }); + + it('returns zeros when group has no accounts', () => { + const state = createMobileMockState('USD'); + const res = calculateBalanceChangeForAccountGroup( + state.engine.backgroundState.AccountTreeController as any, + state.engine.backgroundState.AccountsController as any, + state.engine.backgroundState.TokenBalancesController as any, + state.engine.backgroundState.TokenRatesController as any, + state.engine.backgroundState.MultichainAssetsRatesController as any, + state.engine.backgroundState.MultichainBalancesController as any, + state.engine.backgroundState.MultichainAssetsController as any, + state.engine.backgroundState.TokensController as any, + state.engine.backgroundState.CurrencyRateController as any, + undefined, + 'entropy:entropy-source-1/999', + '1d', + ); + expect(res.currentTotalInUserCurrency).toBe(0); + expect(res.previousTotalInUserCurrency).toBe(0); + expect(res.amountChangeInUserCurrency).toBe(0); + expect(res.percentChange).toBe(0); + }); + + it('returns zeros when group wallet is missing', () => { + const state = createMobileMockState('USD'); + const res = calculateBalanceChangeForAccountGroup( + state.engine.backgroundState.AccountTreeController as any, + state.engine.backgroundState.AccountsController as any, + state.engine.backgroundState.TokenBalancesController as any, + state.engine.backgroundState.TokenRatesController as any, + state.engine.backgroundState.MultichainAssetsRatesController as any, + state.engine.backgroundState.MultichainBalancesController as any, + state.engine.backgroundState.MultichainAssetsController as any, + state.engine.backgroundState.TokensController as any, + state.engine.backgroundState.CurrencyRateController as any, + undefined, + 'entropy:missing-wallet/0', + '1d', + ); + expect(res.currentTotalInUserCurrency).toBe(0); + expect(res.previousTotalInUserCurrency).toBe(0); + }); + + it('ignores EVM token not in allTokens for group', () => { + const state = createMobileMockState('USD'); + ( + state.engine.backgroundState as any + ).TokenBalancesController.tokenBalances[ + '0x1234567890123456789012345678901234567890' + ]['0x1']['0xEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEE'] = '0x1'; + (state.engine.backgroundState as any).TokenRatesController.marketData[ + '0x1' + ]['0xEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEE'] = { + tokenAddress: '0xEEEE', + currency: 'ETH', + price: 1.0, + pricePercentChange1d: 5, + } as any; + const res = calculateBalanceChangeForAccountGroup( + state.engine.backgroundState.AccountTreeController as any, + state.engine.backgroundState.AccountsController as any, + state.engine.backgroundState.TokenBalancesController as any, + state.engine.backgroundState.TokenRatesController as any, + state.engine.backgroundState.MultichainAssetsRatesController as any, + state.engine.backgroundState.MultichainBalancesController as any, + state.engine.backgroundState.MultichainAssetsController as any, + state.engine.backgroundState.TokensController as any, + state.engine.backgroundState.CurrencyRateController as any, + undefined, + 'entropy:entropy-source-1/0', + '1d', + ); + expect(res.currentTotalInUserCurrency).toBe(0); + expect(res.previousTotalInUserCurrency).toBe(0); + }); + + it('ignores invalid hex EVM balance for group', () => { + const state = createMobileMockState('USD'); + ( + state.engine.backgroundState as any + ).TokenBalancesController.tokenBalances[ + '0x1234567890123456789012345678901234567890' + ]['0x1']['0xC0b86a33E6441b8C4C3C1d3e2C1d3e2C1d3e2C1'] = '0xZZZ'; + const res = calculateBalanceChangeForAccountGroup( + state.engine.backgroundState.AccountTreeController as any, + state.engine.backgroundState.AccountsController as any, + state.engine.backgroundState.TokenBalancesController as any, + state.engine.backgroundState.TokenRatesController as any, + state.engine.backgroundState.MultichainAssetsRatesController as any, + state.engine.backgroundState.MultichainBalancesController as any, + state.engine.backgroundState.MultichainAssetsController as any, + state.engine.backgroundState.TokensController as any, + state.engine.backgroundState.CurrencyRateController as any, + undefined, + 'entropy:entropy-source-1/0', + '1d', + ); + expect(res.currentTotalInUserCurrency).toBe(0); + expect(res.previousTotalInUserCurrency).toBe(0); + }); + + it('ignores EVM token when price is missing for group', () => { + const state = createMobileMockState('USD'); + (state.engine.backgroundState as any).TokenRatesController.marketData[ + '0x1' + ]['0xC0b86a33E6441b8C4C3C1d3e2C1d3e2C1d3e2C1'] = { + tokenAddress: '0xC0b', + currency: 'ETH', + pricePercentChange1d: 10, + } as any; + const res = calculateBalanceChangeForAccountGroup( + state.engine.backgroundState.AccountTreeController as any, + state.engine.backgroundState.AccountsController as any, + state.engine.backgroundState.TokenBalancesController as any, + state.engine.backgroundState.TokenRatesController as any, + state.engine.backgroundState.MultichainAssetsRatesController as any, + state.engine.backgroundState.MultichainBalancesController as any, + state.engine.backgroundState.MultichainAssetsController as any, + state.engine.backgroundState.TokensController as any, + state.engine.backgroundState.CurrencyRateController as any, + undefined, + 'entropy:entropy-source-1/1', + '1d', + ); + expect(res.currentTotalInUserCurrency).toBe(0); + }); + + it('ignores EVM token when native conversion missing for group', () => { + const state = createMobileMockState('USD'); + (state.engine.backgroundState as any).TokenRatesController.marketData[ + '0x1' + ]['0xC0b86a33E6441b8C4C3C1d3e2C1d3e2C1d3e2C1'] = { + tokenAddress: '0xC0b', + currency: 'ETH', + price: 1.0, + pricePercentChange1d: 10, + } as any; + delete (state.engine.backgroundState as any).CurrencyRateController + .currencyRates.ETH; + const res = calculateBalanceChangeForAccountGroup( + state.engine.backgroundState.AccountTreeController as any, + state.engine.backgroundState.AccountsController as any, + state.engine.backgroundState.TokenBalancesController as any, + state.engine.backgroundState.TokenRatesController as any, + state.engine.backgroundState.MultichainAssetsRatesController as any, + state.engine.backgroundState.MultichainBalancesController as any, + state.engine.backgroundState.MultichainAssetsController as any, + state.engine.backgroundState.TokensController as any, + state.engine.backgroundState.CurrencyRateController as any, + undefined, + 'entropy:entropy-source-1/1', + '1d', + ); + expect(res.currentTotalInUserCurrency).toBe(0); + }); + + it('non-EVM group path: continues when account has no balances', () => { + const state = createMobileMockState('USD'); + ( + state.engine.backgroundState as any + ).AccountsController.internalAccounts.accounts['account-sol4'] = { + id: 'account-sol4', + address: 'SolAcc4', + type: 'solana:eoa', + scopes: ['solana:mainnet'], + methods: [], + options: {}, + metadata: { name: 'Sol4', keyring: { type: 'hd' }, importTime: 0 }, + }; + ( + state.engine.backgroundState as any + ).AccountTreeController.accountTree.wallets[ + 'entropy:entropy-source-1' + ].groups['entropy:entropy-source-1/0'].accounts.push('account-sol4'); + const res = calculateBalanceChangeForAccountGroup( + state.engine.backgroundState.AccountTreeController as any, + state.engine.backgroundState.AccountsController as any, + state.engine.backgroundState.TokenBalancesController as any, + state.engine.backgroundState.TokenRatesController as any, + state.engine.backgroundState.MultichainAssetsRatesController as any, + state.engine.backgroundState.MultichainBalancesController as any, + state.engine.backgroundState.MultichainAssetsController as any, + state.engine.backgroundState.TokensController as any, + state.engine.backgroundState.CurrencyRateController as any, + undefined, + 'entropy:entropy-source-1/0', + '1d', + ); + expect(res.currentTotalInUserCurrency).toBeGreaterThanOrEqual(0); + }); + + it('non-EVM group path: disabled chain is skipped', () => { + const state = createMobileMockState('USD'); + ( + state.engine.backgroundState as any + ).AccountsController.internalAccounts.accounts['account-sol5'] = { + id: 'account-sol5', + address: 'SolAcc5', + type: 'solana:eoa', + scopes: ['solana:mainnet'], + methods: [], + options: {}, + metadata: { name: 'Sol5', keyring: { type: 'hd' }, importTime: 0 }, + }; + ( + state.engine.backgroundState as any + ).AccountTreeController.accountTree.wallets[ + 'entropy:entropy-source-1' + ].groups['entropy:entropy-source-1/0'].accounts.push('account-sol5'); + ( + state.engine.backgroundState as any + ).MultichainBalancesController.balances['account-sol5'] = { + 'solana:mainnet/asset:Q': { amount: '3', unit: 'Q' }, + }; + ( + state.engine.backgroundState as any + ).MultichainAssetsRatesController.conversionRates[ + 'solana:mainnet/asset:Q' + ] = { rate: '10', marketData: { pricePercentChange: { P1D: 2 } } }; + const enabledNetworkMap = { + solana: { 'solana:mainnet': false }, + } as Record>; + const res = calculateBalanceChangeForAccountGroup( + state.engine.backgroundState.AccountTreeController as any, + state.engine.backgroundState.AccountsController as any, + state.engine.backgroundState.TokenBalancesController as any, + state.engine.backgroundState.TokenRatesController as any, + state.engine.backgroundState.MultichainAssetsRatesController as any, + state.engine.backgroundState.MultichainBalancesController as any, + state.engine.backgroundState.MultichainAssetsController as any, + state.engine.backgroundState.TokensController as any, + state.engine.backgroundState.CurrencyRateController as any, + enabledNetworkMap, + 'entropy:entropy-source-1/0', + '1d', + ); + expect(res.currentTotalInUserCurrency).toBeGreaterThanOrEqual(0); + }); + + it('falls back to currencyRateState for native token when tokenRatesState has no market data', () => { + const state = createMobileMockState('USD'); + const account = '0x1234567890123456789012345678901234567890'; + + ( + state.engine.backgroundState as any + ).TokenBalancesController.tokenBalances[account]['0x2105'] = { + '0x0000000000000000000000000000000000000000': '0xde0b6b3a7640000', // 1 ETH in wei + }; + + ( + state.engine.backgroundState as any + ).CurrencyRateController.currencyRates.BASE = { + conversionRate: 3000, + usdConversionRate: 3000, + }; + + const networkConfigurationsByChainId = { + '0x2105': { nativeCurrency: 'BASE' }, + }; + + const res = calculateBalanceChangeForAccountGroup( + state.engine.backgroundState.AccountTreeController as any, + state.engine.backgroundState.AccountsController as any, + state.engine.backgroundState.TokenBalancesController as any, + state.engine.backgroundState.TokenRatesController as any, + state.engine.backgroundState.MultichainAssetsRatesController as any, + state.engine.backgroundState.MultichainBalancesController as any, + state.engine.backgroundState.MultichainAssetsController as any, + state.engine.backgroundState.TokensController as any, + state.engine.backgroundState.CurrencyRateController as any, + undefined, + 'entropy:entropy-source-1/0', + '1d', + networkConfigurationsByChainId, + ); + expect(res.currentTotalInUserCurrency).toBeGreaterThanOrEqual(3000); + }); + + it('does not fall back for ERC-20 tokens when tokenRatesState has no market data', () => { + const state = createMobileMockState('USD'); + const account = '0x1234567890123456789012345678901234567890'; + + ( + state.engine.backgroundState as any + ).TokenBalancesController.tokenBalances[account]['0x2105'] = { + '0xabc0000000000000000000000000000000000001': '0xde0b6b3a7640000', + }; + + const networkConfigurationsByChainId = { + '0x2105': { nativeCurrency: 'BASE' }, + }; + + const res = calculateBalanceChangeForAccountGroup( + state.engine.backgroundState.AccountTreeController as any, + state.engine.backgroundState.AccountsController as any, + state.engine.backgroundState.TokenBalancesController as any, + state.engine.backgroundState.TokenRatesController as any, + state.engine.backgroundState.MultichainAssetsRatesController as any, + state.engine.backgroundState.MultichainBalancesController as any, + state.engine.backgroundState.MultichainAssetsController as any, + state.engine.backgroundState.TokensController as any, + state.engine.backgroundState.CurrencyRateController as any, + undefined, + 'entropy:entropy-source-1/0', + '1d', + networkConfigurationsByChainId, + ); + expect(res.currentTotalInUserCurrency).toBeGreaterThanOrEqual(0); + }); + + it('native fallback skips when networkConfigurationsByChainId is not provided', () => { + const state = createMobileMockState('USD'); + const account = '0x1234567890123456789012345678901234567890'; + + ( + state.engine.backgroundState as any + ).TokenBalancesController.tokenBalances[account]['0x2105'] = { + '0x0000000000000000000000000000000000000000': '0xde0b6b3a7640000', + }; + + const res = calculateBalanceChangeForAccountGroup( + state.engine.backgroundState.AccountTreeController as any, + state.engine.backgroundState.AccountsController as any, + state.engine.backgroundState.TokenBalancesController as any, + state.engine.backgroundState.TokenRatesController as any, + state.engine.backgroundState.MultichainAssetsRatesController as any, + state.engine.backgroundState.MultichainBalancesController as any, + state.engine.backgroundState.MultichainAssetsController as any, + state.engine.backgroundState.TokensController as any, + state.engine.backgroundState.CurrencyRateController as any, + undefined, + 'entropy:entropy-source-1/0', + '1d', + ); + expect(res.currentTotalInUserCurrency).toBeGreaterThanOrEqual(0); + }); + + it('fallback works for calculateBalanceChangeForAllWallets', () => { + const state = createMobileMockState('USD'); + const account = '0x1234567890123456789012345678901234567890'; + + ( + state.engine.backgroundState as any + ).TokenBalancesController.tokenBalances[account]['0x2105'] = { + '0x0000000000000000000000000000000000000000': '0xde0b6b3a7640000', + }; + + ( + state.engine.backgroundState as any + ).CurrencyRateController.currencyRates.BASE = { + conversionRate: 3000, + usdConversionRate: 3000, + }; + + const networkConfigurationsByChainId = { + '0x2105': { nativeCurrency: 'BASE' }, + }; + + const res = calculateBalanceChangeForAllWallets( + state.engine.backgroundState.AccountTreeController as any, + state.engine.backgroundState.AccountsController as any, + state.engine.backgroundState.TokenBalancesController as any, + state.engine.backgroundState.TokenRatesController as any, + state.engine.backgroundState.MultichainAssetsRatesController as any, + state.engine.backgroundState.MultichainBalancesController as any, + state.engine.backgroundState.MultichainAssetsController as any, + state.engine.backgroundState.TokensController as any, + state.engine.backgroundState.CurrencyRateController as any, + undefined, + '1d', + networkConfigurationsByChainId, + ); + expect(res.currentTotalInUserCurrency).toBeGreaterThanOrEqual(3000); + }); + + it('non-EVM group path: skips NaN amount, NaN rate, and denom zero', () => { + const state = createMobileMockState('USD'); + ( + state.engine.backgroundState as any + ).AccountsController.internalAccounts.accounts['account-sol6'] = { + id: 'account-sol6', + address: 'SolAcc6', + type: 'solana:eoa', + scopes: ['solana:mainnet'], + methods: [], + options: {}, + metadata: { name: 'Sol6', keyring: { type: 'hd' }, importTime: 0 }, + }; + ( + state.engine.backgroundState as any + ).AccountTreeController.accountTree.wallets[ + 'entropy:entropy-source-1' + ].groups['entropy:entropy-source-1/0'].accounts.push('account-sol6'); + ( + state.engine.backgroundState as any + ).MultichainBalancesController.balances['account-sol6'] = { + 'solana:mainnet/asset:R': { amount: 'abc', unit: 'R' }, + 'solana:mainnet/asset:S': { amount: '5', unit: 'S' }, + 'solana:mainnet/asset:T': { amount: '5', unit: 'T' }, + }; + ( + state.engine.backgroundState as any + ).MultichainAssetsRatesController.conversionRates[ + 'solana:mainnet/asset:S' + ] = { rate: 'abc', marketData: { pricePercentChange: { P1D: 1 } } }; + ( + state.engine.backgroundState as any + ).MultichainAssetsRatesController.conversionRates[ + 'solana:mainnet/asset:T' + ] = { rate: '10', marketData: { pricePercentChange: { P1D: -100 } } }; + const res = calculateBalanceChangeForAccountGroup( + state.engine.backgroundState.AccountTreeController as any, + state.engine.backgroundState.AccountsController as any, + state.engine.backgroundState.TokenBalancesController as any, + state.engine.backgroundState.TokenRatesController as any, + state.engine.backgroundState.MultichainAssetsRatesController as any, + state.engine.backgroundState.MultichainBalancesController as any, + state.engine.backgroundState.MultichainAssetsController as any, + state.engine.backgroundState.TokensController as any, + state.engine.backgroundState.CurrencyRateController as any, + undefined, + 'entropy:entropy-source-1/0', + '1d', + ); + expect(res.currentTotalInUserCurrency).toBeGreaterThanOrEqual(0); + }); + }); +}); diff --git a/packages/assets-controllers/src/balances.ts b/packages/assets-controllers/src/balances.ts new file mode 100644 index 00000000000..da5d9cafa3d --- /dev/null +++ b/packages/assets-controllers/src/balances.ts @@ -0,0 +1,891 @@ +import { parseAccountGroupId } from '@metamask/account-api'; +import type { AccountGroupId } from '@metamask/account-api'; +import type { AccountTreeControllerState } from '@metamask/account-tree-controller'; +import type { AccountsControllerState } from '@metamask/accounts-controller'; +import { isEvmAccountType } from '@metamask/keyring-api'; +import type { InternalAccount } from '@metamask/keyring-internal-api'; +import type { Hex } from '@metamask/utils'; +import type { CaipAssetType, CaipChainId } from '@metamask/utils'; +import { + KnownCaipNamespace, + parseCaipAssetType, + parseCaipChainId, + isStrictHexString, +} from '@metamask/utils'; + +import { STAKING_CONTRACT_ADDRESS_BY_CHAINID } from './AssetsContractController.js'; +import type { CurrencyRateState } from './CurrencyRateController.js'; +import type { MultichainAssetsControllerState } from './MultichainAssetsController/index.js'; +import type { MultichainAssetsRatesControllerState } from './MultichainAssetsRatesController/index.js'; +import type { MultichainBalancesControllerState } from './MultichainBalancesController/index.js'; +import { getNativeTokenAddress } from './token-prices-service/codefi-v2.js'; +import type { TokenBalancesControllerState } from './TokenBalancesController.js'; +import type { TokenRatesControllerState } from './TokenRatesController.js'; +import type { TokensControllerState } from './TokensController.js'; + +export type AccountGroupBalance = { + walletId: string; + groupId: string; + totalBalanceInUserCurrency: number; + userCurrency: string; +}; + +export type WalletBalance = { + walletId: string; + groups: Record; + totalBalanceInUserCurrency: number; + userCurrency: string; +}; + +export type AllWalletsBalance = { + wallets: Record; + totalBalanceInUserCurrency: number; + userCurrency: string; +}; + +export type BalanceChangePeriod = '1d' | '7d' | '30d'; + +const evmRatePropertiesRecord = { + '1d': 'pricePercentChange1d', + '7d': 'pricePercentChange7d', + '30d': 'pricePercentChange30d', +} as const; + +const nonEvmRatePropertiesRecord = { + '1d': 'P1D', + '7d': 'P7D', + '30d': 'P30D', +}; + +export type BalanceChangeResult = { + period: BalanceChangePeriod; + currentTotalInUserCurrency: number; + previousTotalInUserCurrency: number; + amountChangeInUserCurrency: number; + percentChange: number; + userCurrency: string; +}; + +const isChainEnabledByMap = ( + map: Record> | undefined, + id: Hex | CaipChainId, +): boolean => { + if (!map) { + return true; + } + if (isStrictHexString(id)) { + return Boolean(map[KnownCaipNamespace.Eip155]?.[id]); + } + const { namespace } = parseCaipChainId(id); + return Boolean(map[namespace]?.[id]); +}; + +const getInternalAccountsForGroup = ( + accountTreeState: AccountTreeControllerState, + accountsState: AccountsControllerState, + groupId: string, +): InternalAccount[] => { + const walletId = parseAccountGroupId(groupId).wallet.id; + const wallet = accountTreeState.accountTree.wallets[walletId]; + if (!wallet) { + return []; + } + const group = wallet.groups[groupId as AccountGroupId]; + if (!group) { + return []; + } + return group.accounts + .map( + (accountId: string) => accountsState.internalAccounts.accounts[accountId], + ) + .filter(Boolean); +}; + +const isNonNaNNumber = (value: unknown): value is number => + typeof value === 'number' && !Number.isNaN(value); + +/** + * Minimal network config shape needed to derive native currency by chain. + * Compatible with NetworkController's networkConfigurationsByChainId entries. + */ +export type NetworkConfigurationNativeCurrency = { + nativeCurrency: string; +}; + +/** + * Combined function that gets valid token balances with calculation data + * + * @param account - Internal account. + * @param tokenBalancesState - Token balances state. + * @param tokensState - Tokens state. + * @param tokenRatesState - Token rates state. + * @param currencyRateState - Currency rate state. + * @param isEvmChainEnabled - Predicate to check EVM chain enablement. + * @param networkConfigurationsByChainId - Network configurations keyed by chain ID, used to look up native currency for fallback pricing. + * @returns token calculation data + */ +function getEvmTokenBalances( + account: InternalAccount, + tokenBalancesState: TokenBalancesControllerState, + tokensState: TokensControllerState, + tokenRatesState: TokenRatesControllerState, + currencyRateState: CurrencyRateState, + isEvmChainEnabled: (chainId: Hex) => boolean, + networkConfigurationsByChainId?: Record< + Hex, + NetworkConfigurationNativeCurrency + >, +) { + const ZERO_ADDRESS = '0x0000000000000000000000000000000000000000' as Hex; + const accountBalances = + tokenBalancesState.tokenBalances[account.address as Hex] ?? {}; + + return Object.entries(accountBalances) + .filter(([chainId]) => isEvmChainEnabled(chainId as Hex)) + .flatMap(([chainId, chainBalances]) => + Object.entries(chainBalances).map(([tokenAddress, balance]) => ({ + chainId: chainId as Hex, + tokenAddress: tokenAddress as Hex, + balance, + })), + ) + .map((tokenBalance) => { + const { chainId, tokenAddress, balance } = tokenBalance; + + const stakingContractAddress = + STAKING_CONTRACT_ADDRESS_BY_CHAINID[chainId]; + const isNative = tokenAddress === ZERO_ADDRESS; + const isStakedNative = stakingContractAddress + ? tokenAddress.toLowerCase() === stakingContractAddress.toLowerCase() + : false; + + // Get Token Info (skip allTokens check for native and staked native) + if (!isNative && !isStakedNative) { + const accountTokens = + tokensState?.allTokens?.[chainId]?.[account.address]; + const token = accountTokens?.find((t) => t.address === tokenAddress); + if (!token) { + return null; + } + } + + // Get market data + const marketDataAddress = + isNative || isStakedNative + ? getNativeTokenAddress(chainId) + : tokenAddress; + const tokenMarketData = + tokenRatesState?.marketData?.[chainId]?.[marketDataAddress]; + + // For native/staked-native tokens, fall back to currencyRateState when + // tokenRatesState has no market data for the chain. + if (!tokenMarketData?.price && (isNative || isStakedNative)) { + const nativeCurrency = + networkConfigurationsByChainId?.[chainId]?.nativeCurrency; + const fallbackRate = nativeCurrency + ? currencyRateState.currencyRates[nativeCurrency]?.conversionRate + : undefined; + if (!fallbackRate) { + return null; + } + + const decimalBalance = parseInt(balance, 16); + if (!isNonNaNNumber(decimalBalance)) { + return null; + } + + const userCurrencyValue = + (decimalBalance / Math.pow(10, 18)) * fallbackRate; + + return { + userCurrencyValue, + tokenMarketData: tokenMarketData ?? null, + }; + } + + if (!tokenMarketData?.price) { + return null; + } + + // Get conversion rate + const nativeToUserRate = + currencyRateState.currencyRates[tokenMarketData.currency] + ?.conversionRate; + if (!nativeToUserRate) { + return null; + } + + // Calculate values + let decimals = 18; + if (!isNative && !isStakedNative) { + const accountTokens = + tokensState?.allTokens?.[chainId]?.[account.address]; + const token = accountTokens?.find((t) => t.address === tokenAddress); + decimals = isNonNaNNumber(token?.decimals) ? token?.decimals : 18; + } + const decimalBalance = parseInt(balance, 16); + if (!isNonNaNNumber(decimalBalance)) { + return null; + } + + const userCurrencyValue = + (decimalBalance / Math.pow(10, decimals)) * + tokenMarketData.price * + nativeToUserRate; + + return { + userCurrencyValue, + tokenMarketData, // Only needed for change calculations + }; + }) + .filter((item): item is NonNullable => item !== null); +} + +/** + * Combined function that gets valid non-EVM asset balances with calculation data + * + * @param account - Internal account. + * @param multichainBalancesState - Multichain balances state. + * @param multichainAssetsState - Multichain assets state (for ignored assets). + * @param multichainRatesState - Multichain rates state. + * @param isAssetChainEnabled - Predicate to check asset chain enablement. + * @returns token calculation data + */ +function getNonEvmAssetBalances( + account: InternalAccount, + multichainBalancesState: MultichainBalancesControllerState, + multichainAssetsState: MultichainAssetsControllerState, + multichainRatesState: MultichainAssetsRatesControllerState, + isAssetChainEnabled: (assetId: CaipAssetType) => boolean, +) { + const accountBalances = multichainBalancesState.balances[account.id] ?? {}; + const ignoredAssets = + multichainAssetsState.allIgnoredAssets[account.id] || []; + + return Object.entries(accountBalances) + .filter( + ([assetId]) => + isAssetChainEnabled(assetId as CaipAssetType) && + !ignoredAssets.includes(assetId as CaipAssetType), + ) + .map(([assetId, balanceData]) => { + const balanceAmount = parseFloat(balanceData.amount); + if (Number.isNaN(balanceAmount)) { + return null; + } + + const conversionRate = + multichainRatesState.conversionRates[assetId as CaipAssetType]; + if (!conversionRate) { + return null; + } + + const conversionRateValue = parseFloat(conversionRate.rate); + if (Number.isNaN(conversionRateValue)) { + return null; + } + + const userCurrencyValue = balanceAmount * conversionRateValue; + + return { + assetId: assetId as CaipAssetType, + userCurrencyValue, + conversionRate, // Only needed for change calculations + }; + }) + .filter((item): item is NonNullable => item !== null); +} + +/** + * Sum EVM account token balances in user currency. + * + * @param account - Internal account. + * @param tokenBalancesState - Token balances state. + * @param tokensState - Tokens state. + * @param tokenRatesState - Token rates state. + * @param currencyRateState - Currency rate state. + * @param isEvmChainEnabled - Predicate to check EVM chain enablement. + * @param networkConfigurationsByChainId - Network configurations keyed by chain ID for fallback pricing. + * @returns Total value in user currency. + */ +function sumEvmAccountBalanceInUserCurrency( + account: InternalAccount, + tokenBalancesState: TokenBalancesControllerState, + tokensState: TokensControllerState, + tokenRatesState: TokenRatesControllerState, + currencyRateState: CurrencyRateState, + isEvmChainEnabled: (chainId: Hex) => boolean, + networkConfigurationsByChainId?: Record< + Hex, + NetworkConfigurationNativeCurrency + >, +): number { + const tokenBalances = getEvmTokenBalances( + account, + tokenBalancesState, + tokensState, + tokenRatesState, + currencyRateState, + isEvmChainEnabled, + networkConfigurationsByChainId, + ); + return tokenBalances.reduce((a, b) => a + b.userCurrencyValue, 0); +} + +/** + * Sum non‑EVM account balances in user currency from multichain sources. + * + * @param account - Internal account. + * @param multichainBalancesState - Multichain balances state. + * @param multichainAssetsState - Multichain assets state (for ignored assets). + * @param multichainRatesState - Multichain rates state. + * @param isAssetChainEnabled - Predicate to check asset chain enablement. + * @returns Total value in user currency. + */ +function sumNonEvmAccountBalanceInUserCurrency( + account: InternalAccount, + multichainBalancesState: MultichainBalancesControllerState, + multichainAssetsState: MultichainAssetsControllerState, + multichainRatesState: MultichainAssetsRatesControllerState, + isAssetChainEnabled: (assetId: CaipAssetType) => boolean, +): number { + const assetBalances = getNonEvmAssetBalances( + account, + multichainBalancesState, + multichainAssetsState, + multichainRatesState, + isAssetChainEnabled, + ); + + return assetBalances.reduce((a, b) => a + b.userCurrencyValue, 0); +} + +/** + * Calculate balances for all wallets and groups. + * Pure function – accepts controller states and returns aggregated totals. + * + * @param accountTreeState - AccountTreeController state + * @param accountsState - AccountsController state + * @param tokenBalancesState - TokenBalancesController state + * @param tokenRatesState - TokenRatesController state + * @param multichainRatesState - MultichainAssetsRatesController state + * @param multichainBalancesState - MultichainBalancesController state + * @param multichainAssetsState - MultichainAssetsController state + * @param tokensState - TokensController state + * @param currencyRateState - CurrencyRateController state + * @param enabledNetworkMap - Map of enabled networks keyed by namespace + * @param networkConfigurationsByChainId - Network configurations by chain ID. + * @returns Aggregated balances for all wallets + */ +export function calculateBalanceForAllWallets( + accountTreeState: AccountTreeControllerState, + accountsState: AccountsControllerState, + tokenBalancesState: TokenBalancesControllerState, + tokenRatesState: TokenRatesControllerState, + multichainRatesState: MultichainAssetsRatesControllerState, + multichainBalancesState: MultichainBalancesControllerState, + multichainAssetsState: MultichainAssetsControllerState, + tokensState: TokensControllerState, + currencyRateState: CurrencyRateState, + enabledNetworkMap: Record> | undefined, + networkConfigurationsByChainId?: Record< + Hex, + NetworkConfigurationNativeCurrency + >, +): AllWalletsBalance { + const isEvmChainEnabled = (chainId: Hex): boolean => + isChainEnabledByMap(enabledNetworkMap, chainId); + + const isAssetChainEnabled = (assetId: CaipAssetType): boolean => + isChainEnabledByMap(enabledNetworkMap, parseCaipAssetType(assetId).chainId); + + const getBalance = { + evm: (account: InternalAccount) => + sumEvmAccountBalanceInUserCurrency( + account, + tokenBalancesState, + tokensState, + tokenRatesState, + currencyRateState, + isEvmChainEnabled, + networkConfigurationsByChainId, + ), + nonEvm: (account: InternalAccount) => + sumNonEvmAccountBalanceInUserCurrency( + account, + multichainBalancesState, + multichainAssetsState, + multichainRatesState, + isAssetChainEnabled, + ), + }; + + const getFlatAccountBalances = () => + Object.entries(accountTreeState.accountTree.wallets ?? {}) + .flatMap(([walletId, wallet]) => + Object.keys(wallet?.groups || {}).flatMap((groupId) => { + const accounts = getInternalAccountsForGroup( + accountTreeState, + accountsState, + groupId, + ); + + return accounts.map((account) => ({ + walletId, + groupId, + account, + isEvm: isEvmAccountType(account.type), + })); + }), + ) + .map((flatAccount) => { + const flatAccountWithBalance = flatAccount as typeof flatAccount & { + balance: number; + }; + flatAccountWithBalance.balance = flatAccount.isEvm + ? getBalance.evm(flatAccount.account) + : getBalance.nonEvm(flatAccount.account); + return flatAccountWithBalance; + }); + + const getAggWalletBalance = ( + flatAccountBalances: ReturnType, + ): number => flatAccountBalances.reduce((a, b) => a + b.balance, 0); + + const getWalletBalances = ( + flatAccountBalances: ReturnType, + ): Record => { + const wallets: Record = {}; + const defaultWalletBalance = (walletId: string): WalletBalance => ({ + walletId, + groups: {}, + totalBalanceInUserCurrency: 0, + userCurrency: currencyRateState.currentCurrency, + }); + const defaultGroupBalance = ( + walletId: string, + groupId: string, + ): AccountGroupBalance => ({ + walletId, + groupId, + totalBalanceInUserCurrency: 0, + userCurrency: currencyRateState.currentCurrency, + }); + + flatAccountBalances.forEach((flatAccount) => { + const { walletId, groupId, balance } = flatAccount; + wallets[walletId] ??= defaultWalletBalance(walletId); + wallets[walletId].groups[groupId] ??= defaultGroupBalance( + walletId, + groupId, + ); + wallets[walletId].groups[groupId].totalBalanceInUserCurrency += balance; + wallets[walletId].totalBalanceInUserCurrency += balance; + }); + + // Ensure all groups (including empty ones) are represented + Object.entries(accountTreeState.accountTree.wallets ?? {}).forEach( + ([walletId, wallet]) => { + if (!wallet) { + return; + } + wallets[walletId] ??= defaultWalletBalance(walletId); + Object.keys(wallet.groups || {}).forEach((groupId) => { + wallets[walletId].groups[groupId] ??= defaultGroupBalance( + walletId, + groupId, + ); + }); + }, + ); + + return wallets; + }; + + const flatAccounts = getFlatAccountBalances(); + return { + wallets: getWalletBalances(flatAccounts), + totalBalanceInUserCurrency: getAggWalletBalance(flatAccounts), + userCurrency: currencyRateState.currentCurrency, + }; +} + +/** + * Calculate aggregated portfolio value change for a given period (1d, 7d, 30d). + * Logic mirrors extension/mobile historical aggregation: + * - For each asset with available percent change for the requested period, compute current value in user currency. + * - Reconstruct previous value by dividing current by (1 + percent/100). + * - Sum across all assets, then compute amount change and percent change. + * + * @param accountTreeState - AccountTreeController state. + * @param accountsState - AccountsController state. + * @param tokenBalancesState - TokenBalancesController state. + * @param tokenRatesState - TokenRatesController state. + * @param multichainRatesState - MultichainAssetsRatesController state. + * @param multichainBalancesState - MultichainBalancesController state. + * @param multichainAssetsState - MultichainAssetsController state. + * @param tokensState - TokensController state. + * @param currencyRateState - CurrencyRateController state. + * @param enabledNetworkMap - Map of enabled networks keyed by namespace. + * @param period - Period to compute change for ('1d' | '7d' | '30d'). + * @param networkConfigurationsByChainId - Optional network configurations to derive native currency fallback pricing. + * @returns Aggregated change details for the requested period. + */ +export function calculateBalanceChangeForAllWallets( + accountTreeState: AccountTreeControllerState, + accountsState: AccountsControllerState, + tokenBalancesState: TokenBalancesControllerState, + tokenRatesState: TokenRatesControllerState, + multichainRatesState: MultichainAssetsRatesControllerState, + multichainBalancesState: MultichainBalancesControllerState, + multichainAssetsState: MultichainAssetsControllerState, + tokensState: TokensControllerState, + currencyRateState: CurrencyRateState, + enabledNetworkMap: Record> | undefined, + period: BalanceChangePeriod, + networkConfigurationsByChainId?: Record< + Hex, + NetworkConfigurationNativeCurrency + >, +): BalanceChangeResult { + const isEvmChainEnabled = (chainId: Hex): boolean => + isChainEnabledByMap(enabledNetworkMap, chainId); + + const isAssetChainEnabled = (assetId: CaipAssetType): boolean => { + const { chainId } = parseCaipAssetType(assetId); + return isChainEnabledByMap(enabledNetworkMap, chainId); + }; + + const getAccountChange = { + evm: (account: InternalAccount) => + sumEvmAccountChangeForPeriod( + account, + period, + tokenBalancesState, + tokensState, + tokenRatesState, + currencyRateState, + isEvmChainEnabled, + networkConfigurationsByChainId, + ), + nonEvm: (account: InternalAccount) => + sumNonEvmAccountChangeForPeriod( + account, + period, + multichainBalancesState, + multichainAssetsState, + multichainRatesState, + isAssetChainEnabled, + ), + }; + + const getFlatAccountChanges = () => + Object.entries(accountTreeState.accountTree.wallets ?? {}) + .flatMap(([walletId, wallet]) => + Object.keys(wallet?.groups || {}).flatMap((groupId) => { + const accounts = getInternalAccountsForGroup( + accountTreeState, + accountsState, + groupId, + ); + return accounts.map((account) => ({ + walletId, + groupId, + account, + isEvm: isEvmAccountType(account.type), + })); + }), + ) + .map((flatAccount) => { + const flatAccountWithChange = flatAccount as typeof flatAccount & { + current: number; + previous: number; + }; + + const change = flatAccount.isEvm + ? getAccountChange.evm(flatAccount.account) + : getAccountChange.nonEvm(flatAccount.account); + + flatAccountWithChange.current = change.current; + flatAccountWithChange.previous = change.previous; + return flatAccountWithChange; + }); + + const getAggregatedTotals = ( + flatAccountChanges: ReturnType, + ) => { + return flatAccountChanges.reduce( + (totals, account) => { + totals.current += account.current; + totals.previous += account.previous; + return totals; + }, + { current: 0, previous: 0 }, + ); + }; + + const flatAccountChanges = getFlatAccountChanges(); + const aggregatedTotals = getAggregatedTotals(flatAccountChanges); + const amountChange = aggregatedTotals.current - aggregatedTotals.previous; + const percentChange = + aggregatedTotals.previous !== 0 + ? (amountChange / aggregatedTotals.previous) * 100 + : 0; + + return { + period, + currentTotalInUserCurrency: Number(aggregatedTotals.current.toFixed(8)), + previousTotalInUserCurrency: Number(aggregatedTotals.previous.toFixed(8)), + amountChangeInUserCurrency: Number(amountChange.toFixed(8)), + percentChange: Number(percentChange.toFixed(8)), + userCurrency: currencyRateState.currentCurrency, + }; +} + +/** + * Sum EVM account change for a period (current and previous totals). + * + * @param account - Internal account to aggregate. + * @param period - Change period ('1d' | '7d' | '30d'). + * @param tokenBalancesState - Token balances controller state. + * @param tokensState - Tokens controller state. + * @param tokenRatesState - Token rates controller state. + * @param currencyRateState - Currency rate controller state. + * @param isEvmChainEnabled - Predicate that returns true if the EVM chain is enabled. + * @param networkConfigurationsByChainId - Network configurations keyed by chain ID for fallback pricing. + * @returns Object with current and previous totals in user currency. + */ +function sumEvmAccountChangeForPeriod( + account: InternalAccount, + period: BalanceChangePeriod, + tokenBalancesState: TokenBalancesControllerState, + tokensState: TokensControllerState, + tokenRatesState: TokenRatesControllerState, + currencyRateState: CurrencyRateState, + isEvmChainEnabled: (chainId: Hex) => boolean, + networkConfigurationsByChainId?: Record< + Hex, + NetworkConfigurationNativeCurrency + >, +): { current: number; previous: number } { + const tokenBalances = getEvmTokenBalances( + account, + tokenBalancesState, + tokensState, + tokenRatesState, + currencyRateState, + isEvmChainEnabled, + networkConfigurationsByChainId, + ); + + const tokenChanges = tokenBalances + .map((token) => { + const percentRaw = + token.tokenMarketData?.[evmRatePropertiesRecord[period]]; + if (!isNonNaNNumber(percentRaw)) { + // Fallback tokens (no market data) still contribute their current value + // but are treated as having 0% change. + if (token.tokenMarketData === null) { + return { + current: token.userCurrencyValue, + previous: token.userCurrencyValue, + }; + } + return null; + } + + const denom = Number((1 + percentRaw / 100).toFixed(8)); + if (denom === 0) { + return null; + } + + return { + current: token.userCurrencyValue, + previous: token.userCurrencyValue / denom, + }; + }) + .filter((change): change is NonNullable => change !== null); + + return tokenChanges.reduce( + (totals, change) => { + totals.current += change.current; + totals.previous += change.previous; + return totals; + }, + { current: 0, previous: 0 }, + ); +} + +/** + * Sum non-EVM account change for a period (current and previous totals). + * + * @param account - Internal account to aggregate. + * @param period - Change period ('1d' | '7d' | '30d'). + * @param multichainBalancesState - Multichain balances controller state. + * @param multichainAssetsState - Multichain assets controller state. + * @param multichainRatesState - Multichain assets rates controller state. + * @param isAssetChainEnabled - Predicate that returns true if the asset's chain is enabled. + * @returns Object with current and previous totals in user currency. + */ +function sumNonEvmAccountChangeForPeriod( + account: InternalAccount, + period: BalanceChangePeriod, + multichainBalancesState: MultichainBalancesControllerState, + multichainAssetsState: MultichainAssetsControllerState, + multichainRatesState: MultichainAssetsRatesControllerState, + isAssetChainEnabled: (assetId: CaipAssetType) => boolean, +): { current: number; previous: number } { + const assetBalances = getNonEvmAssetBalances( + account, + multichainBalancesState, + multichainAssetsState, + multichainRatesState, + isAssetChainEnabled, + ); + + const assetChanges = assetBalances + .map((asset) => { + // Safely access the percent change data with proper type checking + const marketData = asset.conversionRate?.marketData; + const pricePercentChange = marketData?.pricePercentChange; + const percentRaw = + pricePercentChange?.[nonEvmRatePropertiesRecord[period]]; + + if (!isNonNaNNumber(percentRaw)) { + return null; + } + + const denom = Number((1 + percentRaw / 100).toFixed(8)); + if (denom === 0) { + return null; + } + + return { + current: asset.userCurrencyValue, + previous: asset.userCurrencyValue / denom, + }; + }) + .filter((change): change is NonNullable => change !== null); + + return assetChanges.reduce( + (totals, change) => ({ + current: totals.current + change.current, + previous: totals.previous + change.previous, + }), + { current: 0, previous: 0 }, + ); +} + +/** + * Calculate portfolio value change for a specific account group and period. + * + * @param accountTreeState - AccountTreeController state. + * @param accountsState - AccountsController state. + * @param tokenBalancesState - TokenBalancesController state. + * @param tokenRatesState - TokenRatesController state. + * @param multichainRatesState - MultichainAssetsRatesController state. + * @param multichainBalancesState - MultichainBalancesController state. + * @param multichainAssetsState - MultichainAssetsController state. + * @param tokensState - TokensController state. + * @param currencyRateState - CurrencyRateController state. + * @param enabledNetworkMap - Map of enabled networks keyed by namespace. + * @param groupId - Account group ID to compute change for. + * @param period - Change period ('1d' | '7d' | '30d'). + * @param networkConfigurationsByChainId - Optional network configurations to derive native currency fallback pricing. + * @returns Change result including current, previous, delta, percent, and period. + */ +export function calculateBalanceChangeForAccountGroup( + accountTreeState: AccountTreeControllerState, + accountsState: AccountsControllerState, + tokenBalancesState: TokenBalancesControllerState, + tokenRatesState: TokenRatesControllerState, + multichainRatesState: MultichainAssetsRatesControllerState, + multichainBalancesState: MultichainBalancesControllerState, + multichainAssetsState: MultichainAssetsControllerState, + tokensState: TokensControllerState, + currencyRateState: CurrencyRateState, + enabledNetworkMap: Record> | undefined, + groupId: string, + period: BalanceChangePeriod, + networkConfigurationsByChainId?: Record< + Hex, + NetworkConfigurationNativeCurrency + >, +): BalanceChangeResult { + const isEvmChainEnabled = (chainId: Hex): boolean => + isChainEnabledByMap(enabledNetworkMap, chainId); + + const isAssetChainEnabled = (assetId: CaipAssetType): boolean => { + const { chainId } = parseCaipAssetType(assetId); + return isChainEnabledByMap(enabledNetworkMap, chainId); + }; + + const getAccountChange = { + evm: (account: InternalAccount) => + sumEvmAccountChangeForPeriod( + account, + period, + tokenBalancesState, + tokensState, + tokenRatesState, + currencyRateState, + isEvmChainEnabled, + networkConfigurationsByChainId, + ), + nonEvm: (account: InternalAccount) => + sumNonEvmAccountChangeForPeriod( + account, + period, + multichainBalancesState, + multichainAssetsState, + multichainRatesState, + isAssetChainEnabled, + ), + }; + + const getFlatAccountChanges = () => { + const accounts = getInternalAccountsForGroup( + accountTreeState, + accountsState, + groupId, + ); + return accounts.map((account) => ({ + account, + isEvm: isEvmAccountType(account.type), + })); + }; + + const getAggregatedTotals = ( + flatAccountChanges: ReturnType, + ) => { + return flatAccountChanges.reduce( + (totals, { account, isEvm }) => { + const change = isEvm + ? getAccountChange.evm(account) + : getAccountChange.nonEvm(account); + totals.current += change.current; + totals.previous += change.previous; + return totals; + }, + { current: 0, previous: 0 }, + ); + }; + + const flatAccountChanges = getFlatAccountChanges(); + const aggregatedTotals = getAggregatedTotals(flatAccountChanges); + + const amountChange = aggregatedTotals.current - aggregatedTotals.previous; + const percentChange = + aggregatedTotals.previous !== 0 + ? (amountChange / aggregatedTotals.previous) * 100 + : 0; + + return { + period, + currentTotalInUserCurrency: Number(aggregatedTotals.current.toFixed(8)), + previousTotalInUserCurrency: Number(aggregatedTotals.previous.toFixed(8)), + amountChangeInUserCurrency: Number(amountChange.toFixed(8)), + percentChange: Number(percentChange.toFixed(8)), + userCurrency: currencyRateState.currentCurrency, + }; +} diff --git a/packages/assets-controllers/src/constants.ts b/packages/assets-controllers/src/constants.ts index 79dacd79ef1..d1cbbbdb03f 100644 --- a/packages/assets-controllers/src/constants.ts +++ b/packages/assets-controllers/src/constants.ts @@ -1,5 +1,110 @@ +import { CHAIN_IDS_WITH_NO_NATIVE_TOKEN } from '@metamask/controller-utils'; +import type { Hex } from '@metamask/utils'; + export enum Source { Custom = 'custom', Dapp = 'dapp', Detected = 'detected', } + +// TODO: delete this once we have the v4 endpoint for supported networks +export const SUPPORTED_NETWORKS_ACCOUNTS_API_V4 = [ + '0x1', // 1 + '0x89', // 137 + '0x38', // 56 + '0xe708', // 59144 + '0x2105', // 8453 + '0xa', // 10 + '0xa4b1', // 42161 + '0x82750', // 534352 + '0x531', // 1329 + '0x8f', // 143 + '0x3e7', // 999 HyperEVM + '0x13b2', // 5042 Arc + '0x1237', // 4663 Robinhood +]; + +/** Lowercase ERC-20 address for MetaMask USD (mUSD), same contract on listed chains. */ +export const MUSD_ERC20_ADDRESS_LOWER = + '0xaca92e438df0b2401ff60da7e4337b687a2435da'; + +/** + * EVM chains where mUSD is always merged into the token-detection candidate list. + * Metadata matches `GET /v3/assets` on `tokens.api.cx.metamask.io` (assetIds CAIP-19). + */ +export const MUSD_TOKEN_DETECTION_CHAIN_IDS = [ + '0x1', // Ethereum mainnet (eip155:1) + '0xe708', // Linea (eip155:59144) + '0x8f', // Monad mainnet (eip155:143) +] as const satisfies readonly Hex[]; + +/** Raw `aggregators` keys from the Tokens API (same shape as token list cache). */ +export type MusdTokenDetectionMetadata = { + name: string; + symbol: string; + decimals: number; + aggregators: string[]; +}; + +export const MUSD_TOKEN_METADATA_BY_CHAIN: Record< + (typeof MUSD_TOKEN_DETECTION_CHAIN_IDS)[number], + MusdTokenDetectionMetadata +> = { + '0x1': { + name: 'MetaMask USD', + symbol: 'MUSD', + decimals: 6, + aggregators: ['metamask', 'liFi', 'socket', 'rubic', 'rango'], + }, + '0xe708': { + name: 'MetaMask USD', + symbol: 'MUSD', + decimals: 6, + aggregators: ['metamask', 'liFi', 'socket', 'rubic', 'squid', 'rango'], + }, + '0x8f': { + name: 'MetaMask USD', + symbol: 'mUSD', + decimals: 6, + aggregators: ['dynamic'], + }, +}; + +/** + * Determines if native token fetching should be included for the given chain. + * Returns false for chains that return arbitrary large numbers (e.g., Tempo networks). + * + * @param chainId - Chain ID in hex format (e.g., "0xa5bf") or CAIP-2 format (e.g., "eip155:42431"). + * @returns True if native token should be included, false if it should be skipped. + */ +export function shouldIncludeNativeToken(chainId: string): boolean { + // Convert hex format to CAIP-2 for comparison + if (chainId.startsWith('0x')) { + try { + const decimal = parseInt(chainId, 16); + const caipChainId = `eip155:${decimal}`; + if ( + CHAIN_IDS_WITH_NO_NATIVE_TOKEN.includes( + caipChainId as (typeof CHAIN_IDS_WITH_NO_NATIVE_TOKEN)[number], + ) + ) { + return false; + } + } catch { + // If conversion fails, assume it should be included + return true; + } + return true; + } + + // Check CAIP-2 format directly + if ( + CHAIN_IDS_WITH_NO_NATIVE_TOKEN.includes( + chainId as (typeof CHAIN_IDS_WITH_NO_NATIVE_TOKEN)[number], + ) + ) { + return false; + } + + return true; +} diff --git a/packages/assets-controllers/src/crypto-compare-service/crypto-compare.test.ts b/packages/assets-controllers/src/crypto-compare-service/crypto-compare.test.ts new file mode 100644 index 00000000000..f69bb44bcd5 --- /dev/null +++ b/packages/assets-controllers/src/crypto-compare-service/crypto-compare.test.ts @@ -0,0 +1,224 @@ +import nock from 'nock'; + +import { fetchExchangeRate, fetchMultiExchangeRate } from './crypto-compare.js'; + +const cryptoCompareHost = 'https://min-api.cryptocompare.com'; + +describe('CryptoCompare', () => { + it('should return CAD conversion rate', async () => { + nock(cryptoCompareHost) + .get('/data/price?fsym=ETH&tsyms=CAD') + .reply(200, { CAD: 2000.42 }); + + const { conversionRate } = await fetchExchangeRate('CAD', 'ETH'); + + expect(conversionRate).toBe(2000.42); + }); + + it('should return CAD conversion rate given lower-cased currency', async () => { + nock(cryptoCompareHost) + .get('/data/price?fsym=ETH&tsyms=CAD') + .reply(200, { CAD: 2000.42 }); + + const { conversionRate } = await fetchExchangeRate('cad', 'ETH'); + + expect(conversionRate).toBe(2000.42); + }); + + it('should return CAD conversion rate given lower-cased native currency', async () => { + nock(cryptoCompareHost) + .get('/data/price?fsym=ETH&tsyms=CAD') + .reply(200, { CAD: 2000.42 }); + + const { conversionRate } = await fetchExchangeRate('CAD', 'eth'); + + expect(conversionRate).toBe(2000.42); + }); + + it('should not return USD conversion rate when fetching just CAD conversion rate', async () => { + nock(cryptoCompareHost) + .get('/data/price?fsym=ETH&tsyms=CAD') + .reply(200, { CAD: 1000.42 }); + + const { usdConversionRate } = await fetchExchangeRate('CAD', 'ETH'); + + expect(usdConversionRate).toBeNaN(); + }); + + it('should return USD conversion rate for USD even when includeUSD is disabled', async () => { + nock(cryptoCompareHost) + .get('/data/price?fsym=ETH&tsyms=USD') + .reply(200, { USD: 1000.42 }); + + const { conversionRate, usdConversionRate } = await fetchExchangeRate( + 'USD', + 'ETH', + false, + ); + + expect(conversionRate).toBe(1000.42); + expect(usdConversionRate).toBe(1000.42); + }); + + it('should return USD conversion rate for USD when includeUSD is enabled', async () => { + nock(cryptoCompareHost) + .get('/data/price?fsym=ETH&tsyms=USD') + .reply(200, { USD: 1000.42 }); + + const { conversionRate, usdConversionRate } = await fetchExchangeRate( + 'USD', + 'ETH', + true, + ); + + expect(conversionRate).toBe(1000.42); + expect(usdConversionRate).toBe(1000.42); + }); + + it('should return CAD and USD conversion rate', async () => { + nock(cryptoCompareHost) + .get('/data/price?fsym=ETH&tsyms=CAD,USD') + .reply(200, { CAD: 2000.42, USD: 1000.42 }); + + const { conversionRate, usdConversionRate } = await fetchExchangeRate( + 'CAD', + 'ETH', + true, + ); + + expect(conversionRate).toBe(2000.42); + expect(usdConversionRate).toBe(1000.42); + }); + + it('should throw if fetch throws', async () => { + nock(cryptoCompareHost) + .get('/data/price?fsym=ETH&tsyms=CAD') + .replyWithError('Example network error'); + + await expect(fetchExchangeRate('CAD', 'ETH')).rejects.toThrow( + 'Example network error', + ); + }); + + it('should throw if fetch returns unsuccessful response', async () => { + nock(cryptoCompareHost).get('/data/price?fsym=ETH&tsyms=CAD').reply(500); + + await expect(fetchExchangeRate('CAD', 'ETH')).rejects.toThrow( + `Fetch failed with status '500' for request '${cryptoCompareHost}/data/price?fsym=ETH&tsyms=CAD'`, + ); + }); + + it('should throw if conversion rate is invalid', async () => { + nock(cryptoCompareHost) + .get('/data/price?fsym=ETH&tsyms=CAD') + .reply(200, { CAD: 'invalid' }); + + await expect(fetchExchangeRate('CAD', 'ETH')).rejects.toThrow( + 'Invalid response for CAD: invalid', + ); + }); + + it('should throw if USD conversion rate is invalid', async () => { + nock(cryptoCompareHost) + .get('/data/price?fsym=ETH&tsyms=CAD,USD') + .reply(200, { CAD: 2000.47, USD: 'invalid' }); + + await expect(fetchExchangeRate('CAD', 'ETH', true)).rejects.toThrow( + 'Invalid response for usdConversionRate: invalid', + ); + }); + + it('should throw an error if either currency is invalid', async () => { + nock(cryptoCompareHost) + .get('/data/price?fsym=ETH&tsyms=EUABRT') + .reply(200, { + Response: 'Error', + Message: 'Market does not exist for this coin pair', + }); + + await expect(fetchExchangeRate('EUABRT', 'ETH')).rejects.toThrow( + 'Market does not exist for this coin pair', + ); + }); + + it('should override native symbol when the CryptoCompare identifier is different', async () => { + nock(cryptoCompareHost) + .get('/data/price?fsym=MANTLE&tsyms=USD') + .reply(200, { USD: 123 }); + + const { conversionRate } = await fetchExchangeRate('USD', 'MNT'); + expect(conversionRate).toBe(123); + }); + + it('should override currency symbol when the CryptoCompare identifier is different', async () => { + nock(cryptoCompareHost) + .get('/data/price?fsym=USD&tsyms=MANTLE') + .reply(200, { MANTLE: 1234 }); + + const { conversionRate } = await fetchExchangeRate('MNT', 'USD'); + expect(conversionRate).toBe(1234); + }); + + describe('fetchMultiExchangeRate', () => { + it('should return CAD and USD conversion rate for BTC, ETH, and SOL', async () => { + nock(cryptoCompareHost) + .get('/data/pricemulti?fsyms=BTC,ETH,SOL&tsyms=CAD,USD') + .reply(200, { + BTC: { CAD: 2000.42, USD: 1000.42 }, + ETH: { CAD: 3000.42, USD: 2000.42 }, + SOL: { CAD: 4000.42, USD: 3000.42 }, + }); + + const response = await fetchMultiExchangeRate( + 'CAD', + ['BTC', 'ETH', 'SOL'], + true, + ); + + expect(response).toStrictEqual({ + btc: { cad: 2000.42, usd: 1000.42 }, + eth: { cad: 3000.42, usd: 2000.42 }, + sol: { cad: 4000.42, usd: 3000.42 }, + }); + }); + + it('should not return USD value if not requested', async () => { + nock(cryptoCompareHost) + .get('/data/pricemulti?fsyms=BTC,ETH,SOL&tsyms=EUR') + .reply(200, { + BTC: { EUR: 1000 }, + ETH: { EUR: 2000 }, + SOL: { EUR: 3000 }, + }); + + // @ts-expect-error Testing the case where the USD rate is not included + const response = await fetchMultiExchangeRate('EUR', [ + 'BTC', + 'ETH', + 'SOL', + ]); + + expect(response).toStrictEqual({ + btc: { eur: 1000 }, + eth: { eur: 2000 }, + sol: { eur: 3000 }, + }); + }); + + it('should override native symbol for mantle native token', async () => { + nock(cryptoCompareHost) + .get('/data/pricemulti?fsyms=MANTLE,ETH&tsyms=EUR') + .reply(200, { + MANTLE: { EUR: 1000 }, + ETH: { EUR: 2000 }, + }); + + // @ts-expect-error Testing the case where the USD rate is not included + const response = await fetchMultiExchangeRate('EUR', ['MNT', 'ETH']); + expect(response).toStrictEqual({ + eth: { eur: 2000 }, + mnt: { eur: 1000 }, + }); + }); + }); +}); diff --git a/packages/assets-controllers/src/crypto-compare-service/crypto-compare.ts b/packages/assets-controllers/src/crypto-compare-service/crypto-compare.ts new file mode 100644 index 00000000000..cb030c07391 --- /dev/null +++ b/packages/assets-controllers/src/crypto-compare-service/crypto-compare.ts @@ -0,0 +1,163 @@ +import { handleFetch } from '@metamask/controller-utils'; + +import { getKeyByValue } from '../assetsUtil.js'; + +/** + * A map from native currency symbol to CryptoCompare identifier. + * This is only needed when the values don't match. + */ +const nativeSymbolOverrides = new Map([ + ['MNT', 'MANTLE'], + ['OMNI', 'OMNINET'], +]); + +const CRYPTO_COMPARE_DOMAIN = 'https://min-api.cryptocompare.com'; + +/** + * Get the CryptoCompare API URL for getting the conversion rate from the given native currency to + * the given currency. Optionally, the conversion rate from the native currency to USD can also be + * included in the response. + * + * @param currentCurrency - The currency to get a conversion rate for. + * @param nativeCurrency - The native currency to convert from. + * @param includeUSDRate - Whether or not the native currency to USD conversion rate should be + * included in the response as well. + * @returns The API URL for getting the conversion rate. + */ +function getPricingURL( + currentCurrency: string, + nativeCurrency: string, + includeUSDRate?: boolean, +) { + return ( + `${CRYPTO_COMPARE_DOMAIN}/data/price?fsym=` + + `${nativeCurrency}&tsyms=${currentCurrency}` + + `${includeUSDRate && currentCurrency.toUpperCase() !== 'USD' ? ',USD' : ''}` + ); +} + +/** + * Get the CryptoCompare API URL for getting the conversion rate from a given array of native currencies + * to the given currencies. Optionally, the conversion rate from the native currency to USD can also be + * included in the response. + * + * @param fsyms - The native currencies to get conversion rates for. + * @param tsyms - The currencies to convert to. + * @param includeUSDRate - Whether or not the native currency to USD conversion rate should be included. + * @returns The API URL for getting the conversion rates. + */ +function getMultiPricingURL( + fsyms: string[], + tsyms: string[], + includeUSDRate = false, +) { + const updatedTsyms = + includeUSDRate && !tsyms.some((t) => t.toUpperCase() === 'USD') + ? [...tsyms, 'USD'] + : tsyms; + + const params = new URLSearchParams(); + params.append('fsyms', fsyms.join(',')); + params.append('tsyms', updatedTsyms.join(',')); + + const url = new URL(`${CRYPTO_COMPARE_DOMAIN}/data/pricemulti`); + url.search = params.toString(); + return url.toString(); +} + +/** + * Handles an error response from the CryptoCompare API. + * Expected error response format + * { Response: "Error", Message: "...", HasWarning: false } + * + * @param json - The JSON response from the CryptoCompare API. + * @param json.Response - The response status. + * @param json.Message - The error message. + */ +function handleErrorResponse(json: { Response?: string; Message?: string }) { + if (json.Response === 'Error') { + throw new Error(json.Message); + } +} + +/** + * Fetches the exchange rate for a given currency. + * + * @param currency - ISO 4217 currency code. + * @param nativeCurrency - Symbol for base asset. + * @param includeUSDRate - Whether to add the USD rate to the fetch. + * @returns Promise resolving to exchange rate for given currency. + */ +export async function fetchExchangeRate( + currency: string, + nativeCurrency: string, + includeUSDRate?: boolean, +): Promise<{ + conversionRate: number; + usdConversionRate: number; +}> { + currency = currency.toUpperCase(); + nativeCurrency = nativeCurrency.toUpperCase(); + currency = nativeSymbolOverrides.get(currency) ?? currency; + nativeCurrency = nativeSymbolOverrides.get(nativeCurrency) ?? nativeCurrency; + + const json = await handleFetch( + getPricingURL(currency, nativeCurrency, includeUSDRate), + ); + + handleErrorResponse(json); + const conversionRate = Number(json[currency.toUpperCase()]); + + const usdConversionRate = Number(json.USD); + if (!Number.isFinite(conversionRate)) { + throw new Error( + `Invalid response for ${currency.toUpperCase()}: ${ + json[currency.toUpperCase()] + }`, + ); + } + + if (includeUSDRate && !Number.isFinite(usdConversionRate)) { + throw new Error(`Invalid response for usdConversionRate: ${json.USD}`); + } + + return { + conversionRate, + usdConversionRate, + }; +} + +/** + * Fetches the exchange rates for multiple currencies. + * + * @param fiatCurrency - The currency of the rates (ISO 4217). + * @param cryptocurrencies - The cryptocurrencies to get conversion rates for. Min length: 1. Max length: 300. + * @param includeUSDRate - Whether to add the USD rate to the fetch. + * @returns Promise resolving to exchange rates for given currencies. + */ +export async function fetchMultiExchangeRate( + fiatCurrency: string, + cryptocurrencies: string[], + includeUSDRate: boolean, +): Promise>> { + const fsyms = cryptocurrencies.map( + (nativeCurrency) => + nativeSymbolOverrides.get(nativeCurrency) ?? nativeCurrency, + ); + const url = getMultiPricingURL(fsyms, [fiatCurrency], includeUSDRate); + const response = await handleFetch(url); + handleErrorResponse(response); + + const rates: Record> = {}; + for (const [cryptocurrency, values] of Object.entries>( + response, + )) { + const key = getKeyByValue(nativeSymbolOverrides, cryptocurrency); + rates[key?.toLowerCase() ?? cryptocurrency.toLowerCase()] = { + [fiatCurrency.toLowerCase()]: values[fiatCurrency.toUpperCase()], + ...(includeUSDRate && { usd: values.USD }), + }; + } + + return rates; +} diff --git a/packages/assets-controllers/src/crypto-compare-service/index.ts b/packages/assets-controllers/src/crypto-compare-service/index.ts new file mode 100644 index 00000000000..c33d3e11b9d --- /dev/null +++ b/packages/assets-controllers/src/crypto-compare-service/index.ts @@ -0,0 +1 @@ +export { fetchExchangeRate, fetchMultiExchangeRate } from './crypto-compare.js'; diff --git a/packages/assets-controllers/src/crypto-compare.test.ts b/packages/assets-controllers/src/crypto-compare.test.ts deleted file mode 100644 index d3db1a307d3..00000000000 --- a/packages/assets-controllers/src/crypto-compare.test.ts +++ /dev/null @@ -1,143 +0,0 @@ -import nock from 'nock'; - -import { fetchExchangeRate } from './crypto-compare'; - -const cryptoCompareHost = 'https://min-api.cryptocompare.com'; - -describe('CryptoCompare', () => { - it('should return CAD conversion rate', async () => { - nock(cryptoCompareHost) - .get('/data/price?fsym=ETH&tsyms=CAD') - .reply(200, { CAD: 2000.42 }); - - const { conversionRate } = await fetchExchangeRate('CAD', 'ETH'); - - expect(conversionRate).toBe(2000.42); - }); - - it('should return CAD conversion rate given lower-cased currency', async () => { - nock(cryptoCompareHost) - .get('/data/price?fsym=ETH&tsyms=CAD') - .reply(200, { CAD: 2000.42 }); - - const { conversionRate } = await fetchExchangeRate('cad', 'ETH'); - - expect(conversionRate).toBe(2000.42); - }); - - it('should return CAD conversion rate given lower-cased native currency', async () => { - nock(cryptoCompareHost) - .get('/data/price?fsym=ETH&tsyms=CAD') - .reply(200, { CAD: 2000.42 }); - - const { conversionRate } = await fetchExchangeRate('CAD', 'eth'); - - expect(conversionRate).toBe(2000.42); - }); - - it('should not return USD conversion rate when fetching just CAD conversion rate', async () => { - nock(cryptoCompareHost) - .get('/data/price?fsym=ETH&tsyms=CAD') - .reply(200, { CAD: 1000.42 }); - - const { usdConversionRate } = await fetchExchangeRate('CAD', 'ETH'); - - expect(usdConversionRate).toBeNaN(); - }); - - it('should return USD conversion rate for USD even when includeUSD is disabled', async () => { - nock(cryptoCompareHost) - .get('/data/price?fsym=ETH&tsyms=USD') - .reply(200, { USD: 1000.42 }); - - const { conversionRate, usdConversionRate } = await fetchExchangeRate( - 'USD', - 'ETH', - false, - ); - - expect(conversionRate).toBe(1000.42); - expect(usdConversionRate).toBe(1000.42); - }); - - it('should return USD conversion rate for USD when includeUSD is enabled', async () => { - nock(cryptoCompareHost) - .get('/data/price?fsym=ETH&tsyms=USD') - .reply(200, { USD: 1000.42 }); - - const { conversionRate, usdConversionRate } = await fetchExchangeRate( - 'USD', - 'ETH', - true, - ); - - expect(conversionRate).toBe(1000.42); - expect(usdConversionRate).toBe(1000.42); - }); - - it('should return CAD and USD conversion rate', async () => { - nock(cryptoCompareHost) - .get('/data/price?fsym=ETH&tsyms=CAD,USD') - .reply(200, { CAD: 2000.42, USD: 1000.42 }); - - const { conversionRate, usdConversionRate } = await fetchExchangeRate( - 'CAD', - 'ETH', - true, - ); - - expect(conversionRate).toBe(2000.42); - expect(usdConversionRate).toBe(1000.42); - }); - - it('should throw if fetch throws', async () => { - nock(cryptoCompareHost) - .get('/data/price?fsym=ETH&tsyms=CAD') - .replyWithError('Example network error'); - - await expect(fetchExchangeRate('CAD', 'ETH')).rejects.toThrow( - 'Example network error', - ); - }); - - it('should throw if fetch returns unsuccessful response', async () => { - nock(cryptoCompareHost).get('/data/price?fsym=ETH&tsyms=CAD').reply(500); - - await expect(fetchExchangeRate('CAD', 'ETH')).rejects.toThrow( - `Fetch failed with status '500' for request '${cryptoCompareHost}/data/price?fsym=ETH&tsyms=CAD'`, - ); - }); - - it('should throw if conversion rate is invalid', async () => { - nock(cryptoCompareHost) - .get('/data/price?fsym=ETH&tsyms=CAD') - .reply(200, { CAD: 'invalid' }); - - await expect(fetchExchangeRate('CAD', 'ETH')).rejects.toThrow( - 'Invalid response for CAD: invalid', - ); - }); - - it('should throw if USD conversion rate is invalid', async () => { - nock(cryptoCompareHost) - .get('/data/price?fsym=ETH&tsyms=CAD,USD') - .reply(200, { CAD: 2000.47, USD: 'invalid' }); - - await expect(fetchExchangeRate('CAD', 'ETH', true)).rejects.toThrow( - 'Invalid response for usdConversionRate: invalid', - ); - }); - - it('should throw an error if either currency is invalid', async () => { - nock(cryptoCompareHost) - .get('/data/price?fsym=ETH&tsyms=EUABRT') - .reply(200, { - Response: 'Error', - Message: 'Market does not exist for this coin pair', - }); - - await expect(fetchExchangeRate('EUABRT', 'ETH')).rejects.toThrow( - 'Market does not exist for this coin pair', - ); - }); -}); diff --git a/packages/assets-controllers/src/crypto-compare.ts b/packages/assets-controllers/src/crypto-compare.ts deleted file mode 100644 index 619484131e6..00000000000 --- a/packages/assets-controllers/src/crypto-compare.ts +++ /dev/null @@ -1,77 +0,0 @@ -import { handleFetch } from '@metamask/controller-utils'; - -/** - * Get the CryptoCompare API URL for getting the conversion rate from the given native currency to - * the given currency. Optionally, the conversion rate from the native currency to USD can also be - * included in the response. - * - * @param currentCurrency - The currency to get a conversion rate for. - * @param nativeCurrency - The native currency to convert from. - * @param includeUSDRate - Whether or not the native currency to USD conversion rate should be - * included in the response as well. - * @returns The API URL for getting the conversion rate. - */ -function getPricingURL( - currentCurrency: string, - nativeCurrency: string, - includeUSDRate?: boolean, -) { - return ( - `https://min-api.cryptocompare.com/data/price?fsym=` + - `${nativeCurrency.toUpperCase()}&tsyms=${currentCurrency.toUpperCase()}` + - `${includeUSDRate && currentCurrency.toUpperCase() !== 'USD' ? ',USD' : ''}` - ); -} - -/** - * Fetches the exchange rate for a given currency. - * - * @param currency - ISO 4217 currency code. - * @param nativeCurrency - Symbol for base asset. - * @param includeUSDRate - Whether to add the USD rate to the fetch. - * @returns Promise resolving to exchange rate for given currency. - */ -export async function fetchExchangeRate( - currency: string, - nativeCurrency: string, - includeUSDRate?: boolean, -): Promise<{ - conversionRate: number; - usdConversionRate: number; -}> { - const json = await handleFetch( - getPricingURL(currency, nativeCurrency, includeUSDRate), - ); - - /* - Example expected error response (if pair is not found) - { - Response: "Error", - Message: "cccagg_or_exchange market does not exist for this coin pair (ETH-)", - HasWarning: false, - } - */ - if (json.Response === 'Error') { - throw new Error(json.Message); - } - - const conversionRate = Number(json[currency.toUpperCase()]); - - const usdConversionRate = Number(json.USD); - if (!Number.isFinite(conversionRate)) { - throw new Error( - `Invalid response for ${currency.toUpperCase()}: ${ - json[currency.toUpperCase()] - }`, - ); - } - - if (includeUSDRate && !Number.isFinite(usdConversionRate)) { - throw new Error(`Invalid response for usdConversionRate: ${json.USD}`); - } - - return { - conversionRate, - usdConversionRate, - }; -} diff --git a/packages/assets-controllers/src/index.ts b/packages/assets-controllers/src/index.ts index 65f742dedc7..9b5e069a036 100644 --- a/packages/assets-controllers/src/index.ts +++ b/packages/assets-controllers/src/index.ts @@ -1,15 +1,344 @@ -export * from './AccountTrackerController'; -export * from './AssetsContractController'; -export * from './CurrencyRateController'; -export * from './NftController'; -export * from './NftDetectionController'; -export * from './TokenBalancesController'; -export * from './TokenDetectionController'; -export * from './TokenListController'; -export * from './TokenRatesController'; -export * from './TokensController'; +export type { + AccountInformation, + AccountTrackerControllerMessenger, + AccountTrackerControllerState, + AccountTrackerControllerActions, + AccountTrackerControllerGetStateAction, + AccountTrackerControllerStateChangeEvent, + AccountTrackerControllerEvents, +} from './AccountTrackerController.js'; +export { AccountTrackerController } from './AccountTrackerController.js'; +export type { + AccountTrackerControllerUpdateNativeBalancesAction, + AccountTrackerControllerUpdateStakedBalancesAction, + AccountTrackerControllerRefreshAction, + AccountTrackerControllerSyncBalanceWithAddressesAction, +} from './AccountTrackerController-method-action-types.js'; +export type { + AssetsContractControllerActions, + AssetsContractControllerEvents, + AssetsContractControllerMessenger, + BalanceMap, +} from './AssetsContractController.js'; +export type { + AssetsContractControllerGetERC20StandardAction, + AssetsContractControllerGetERC721StandardAction, + AssetsContractControllerGetERC1155StandardAction, + AssetsContractControllerGetERC20BalanceOfAction, + AssetsContractControllerGetERC20TokenDecimalsAction, + AssetsContractControllerGetERC20TokenNameAction, + AssetsContractControllerGetERC721NftTokenIdAction, + AssetsContractControllerGetERC721TokenURIAction, + AssetsContractControllerGetERC721AssetNameAction, + AssetsContractControllerGetERC721AssetSymbolAction, + AssetsContractControllerGetERC721OwnerOfAction, + AssetsContractControllerGetERC1155TokenURIAction, + AssetsContractControllerGetERC1155BalanceOfAction, + AssetsContractControllerTransferSingleERC1155Action, + AssetsContractControllerGetTokenStandardAndDetailsAction, + AssetsContractControllerGetBalancesInSingleCallAction, + AssetsContractControllerGetStakedBalanceForChainAction, +} from './AssetsContractController-method-action-types.js'; +export { + SINGLE_CALL_BALANCES_ADDRESS_BY_CHAINID, + AssetsContractController, +} from './AssetsContractController.js'; +export * from './CurrencyRateController.js'; +export type { + CurrencyRateControllerSetCurrentCurrencyAction, + CurrencyRateControllerUpdateExchangeRateAction, +} from './CurrencyRateController-method-action-types.js'; +export type { + NftControllerState, + NftControllerMessenger, + NftControllerActions, + NftControllerGetStateAction, + NftControllerEvents, + NftControllerStateChangeEvent, + Nft, + NftContract, + NftMetadata, +} from './NftController.js'; +export { + getDefaultNftControllerState, + NftController, +} from './NftController.js'; +export type { + NftDetectionControllerMessenger, + ApiNft, + ApiNftContract, + ApiNftLastSale, + ApiNftCreator, + ReservoirResponse, + TokensResponse, + BlockaidResultType, + Blockaid, + Market, + TokenResponse, + TopBid, + LastSale, + FeeBreakdown, + Attributes, + Collection, + Royalties, + Ownership, + FloorAsk, + Price, + Metadata, +} from './NftDetectionController.js'; +export { NftDetectionController } from './NftDetectionController.js'; +export type { + TokenBalancesControllerActions, + TokenBalancesControllerGetStateAction, + TokenBalancesControllerEvents, + TokenBalancesControllerMessenger, + TokenBalancesControllerOptions, + TokenBalancesControllerStateChangeEvent, + TokenBalancesControllerState, +} from './TokenBalancesController.js'; +export { TokenBalancesController } from './TokenBalancesController.js'; +export type { + TokenBalancesControllerUpdateChainPollingConfigsAction, + TokenBalancesControllerGetChainPollingConfigAction, + TokenBalancesControllerUpdateBalancesAction, + TokenBalancesControllerResetStateAction, +} from './TokenBalancesController-method-action-types.js'; +export type { + TokenDetectionControllerMessenger, + TokenDetectionControllerActions, + TokenDetectionControllerGetStateAction, + TokenDetectionControllerEvents, + TokenDetectionControllerStateChangeEvent, +} from './TokenDetectionController.js'; +export type { + TokenDetectionControllerEnableAction, + TokenDetectionControllerDisableAction, + TokenDetectionControllerStartAction, + TokenDetectionControllerStopAction, + TokenDetectionControllerDetectTokensAction, + TokenDetectionControllerAddDetectedTokensViaWsAction, + TokenDetectionControllerAddDetectedTokensViaPollingAction, +} from './TokenDetectionController-method-action-types.js'; +export { TokenDetectionController } from './TokenDetectionController.js'; +export type { + TokenListState, + TokenListToken, + TokenListMap, + TokenListStateChange, + TokenListControllerEvents, + GetTokenListState, + TokenListControllerActions, + TokenListControllerMessenger, +} from './TokenListController.js'; +export { TokenListController } from './TokenListController.js'; +export { TokenListService, buildTokenListMap } from './TokenListService.js'; +export type { + ContractExchangeRates, + ContractMarketData, + Token, + TokenRatesControllerActions, + TokenRatesControllerEvents, + TokenRatesControllerGetStateAction, + TokenRatesControllerMessenger, + TokenRatesControllerState, + TokenRatesControllerStateChangeEvent, + MarketDataDetails, +} from './TokenRatesController.js'; +export { + getDefaultTokenRatesControllerState, + TokenRatesController, +} from './TokenRatesController.js'; +export type { + TokensControllerState, + TokensControllerActions, + TokensControllerGetStateAction, + TokensControllerEvents, + TokensControllerStateChangeEvent, + TokensControllerMessenger, +} from './TokensController.js'; +export type { + TokensControllerAddTokenAction, + TokensControllerAddTokensAction, + TokensControllerIgnoreTokensAction, + TokensControllerAddDetectedTokensAction, + TokensControllerUpdateTokenTypeAction, + TokensControllerWatchAssetAction, + TokensControllerClearIgnoredTokensAction, + TokensControllerResetStateAction, +} from './TokensController-method-action-types.js'; +export { TokensController } from './TokensController.js'; export { isTokenDetectionSupportedForNetwork, formatIconUrlWithProxy, getFormattedIpfsUrl, -} from './assetsUtil'; + fetchTokenContractExchangeRates, + getKeyByValue, +} from './assetsUtil.js'; +export { + CodefiTokenPricesServiceV2, + SUPPORTED_CHAIN_IDS, + getNativeTokenAddress, + SPOT_PRICES_SUPPORT_INFO, + getAssetId, +} from './token-prices-service/index.js'; +export { + fetchRwas, + searchTokens, + getTrendingTokens, + fetchTokenAssets, +} from './token-service.js'; +export { RatesController, Cryptocurrency } from './RatesController/index.js'; +export type { + RatesControllerState, + RatesControllerEvents, + RatesControllerActions, + RatesControllerMessenger, + RatesControllerGetStateAction, + RatesControllerStateChangeEvent, + RatesControllerPollingStartedEvent, + RatesControllerPollingStoppedEvent, +} from './RatesController/index.js'; +export { MultichainBalancesController } from './MultichainBalancesController/index.js'; +export type { + MultichainBalancesControllerState, + MultichainBalancesControllerGetStateAction, + MultichainBalancesControllerStateChange, + MultichainBalancesControllerActions, + MultichainBalancesControllerEvents, + MultichainBalancesControllerMessenger, +} from './MultichainBalancesController/index.js'; + +export { + MultichainAssetsController, + getDefaultMultichainAssetsControllerState, +} from './MultichainAssetsController/index.js'; + +export type { + MultichainAssetsControllerState, + MultichainAssetsControllerGetStateAction, + MultichainAssetsControllerStateChangeEvent, + MultichainAssetsControllerActions, + MultichainAssetsControllerEvents, + MultichainAssetsControllerAccountAssetListUpdatedEvent, + MultichainAssetsControllerMessenger, +} from './MultichainAssetsController/index.js'; +export type { + MultichainAssetsControllerGetAssetMetadataAction, + MultichainAssetsControllerIgnoreAssetsAction, + MultichainAssetsControllerAddAssetsAction, +} from './MultichainAssetsController/MultichainAssetsController-method-action-types.js'; + +export { + MultichainAssetsRatesController, + getDefaultMultichainAssetsRatesControllerState, +} from './MultichainAssetsRatesController/index.js'; +export { MAP_CAIP_CURRENCIES } from './MultichainAssetsRatesController/index.js'; + +export type { + MultichainAssetsRatesControllerState, + MultichainAssetsRatesControllerActions, + MultichainAssetsRatesControllerEvents, + MultichainAssetsRatesControllerGetStateAction, + MultichainAssetsRatesControllerStateChange, + MultichainAssetsRatesControllerMessenger, +} from './MultichainAssetsRatesController/index.js'; + +export type { + MultichainAssetsRatesControllerUpdateAssetsRatesAction, + MultichainAssetsRatesControllerFetchHistoricalPricesForAssetAction, +} from './MultichainAssetsRatesController/MultichainAssetsRatesController-method-action-types.js'; + +export { TokenSearchDiscoveryDataController } from './TokenSearchDiscoveryDataController/index.js'; +export type { + TokenDisplayData, + TokenSearchDiscoveryDataControllerState, + TokenSearchDiscoveryDataControllerGetStateAction, + TokenSearchDiscoveryDataControllerEvents, + TokenSearchDiscoveryDataControllerStateChangeEvent, + TokenSearchDiscoveryDataControllerActions, + TokenSearchDiscoveryDataControllerMessenger, +} from './TokenSearchDiscoveryDataController/index.js'; +export { DeFiPositionsController } from './DeFiPositionsController/DeFiPositionsController.js'; +export type { + DeFiPositionsControllerState, + DeFiPositionsControllerActions, + DeFiPositionsControllerEvents, + DeFiPositionsControllerGetStateAction, + DeFiPositionsControllerStateChangeEvent, + DeFiPositionsControllerMessenger, +} from './DeFiPositionsController/DeFiPositionsController.js'; +export type { GroupedDeFiPositions } from './DeFiPositionsController/group-defi-positions.js'; +export { + DeFiPositionsControllerV2, + getDefaultDeFiPositionsControllerV2State, +} from './DeFiPositionsController/DeFiPositionsControllerV2.js'; +export type { + DeFiPositionsControllerV2State, + DeFiPositionsControllerV2Actions, + DeFiPositionsControllerV2Events, + DeFiPositionsControllerV2GetStateAction, + DeFiPositionsControllerV2StateChangedEvent, + DeFiPositionsControllerV2Messenger, +} from './DeFiPositionsController/DeFiPositionsControllerV2.js'; +export type { DeFiPositionsControllerV2FetchDeFiPositionsAction } from './DeFiPositionsController/DeFiPositionsControllerV2-method-action-types.js'; +export { + DEFI_POSITION_TYPES, + DEFI_POSITION_LIABILITY_TYPES, +} from './DeFiPositionsController/group-defi-positions-v6.js'; +export type { + DeFiPositionsByAccount, + DeFiProtocolPositionGroup, + DeFiPositionDetailsSection, + DeFiUnderlyingPosition, + DeFiPositionIconGroupItem, + DeFiPositionType, +} from './DeFiPositionsController/group-defi-positions-v6.js'; +export { mergePositionsForAccounts } from './DeFiPositionsController/merge-positions-for-accounts.js'; +export type { + AccountGroupBalance, + WalletBalance, + AllWalletsBalance, +} from './balances.js'; +export { calculateBalanceForAllWallets } from './balances.js'; +export type { + BalanceChangePeriod, + BalanceChangeResult, + NetworkConfigurationNativeCurrency, +} from './balances.js'; +export { + calculateBalanceChangeForAllWallets, + calculateBalanceChangeForAccountGroup, +} from './balances.js'; +export type { + AssetsByAccountGroup, + AccountGroupAssets, + Asset, + AssetListState, +} from './selectors/token-selectors.js'; +export { + selectAssetsBySelectedAccountGroup, + selectAllAssets, +} from './selectors/token-selectors.js'; +export { createFormatters } from './utils/formatters.js'; +export type { + SortTrendingBy, + TrendingAsset, + TrendingTokensQueryParams, + TokenSearchItem, + PageInfo, + TokenAsset, + TokenRwaData, + TokenSecurityData, + TokenSecurityFeature, + TokenSecurityHolder, + TokenSecurityMarket, + TokenSecurityFees, + TokenSecurityFinancialStats, + TokenSecurityMetadata, + RwaMarket, + RwaTokenData, + RwaToken, + RwasResponse, + RwaSortBy, + FetchRwasParams, +} from './token-service.js'; diff --git a/packages/assets-controllers/src/multi-chain-accounts-service/api-balance-fetcher.test.ts b/packages/assets-controllers/src/multi-chain-accounts-service/api-balance-fetcher.test.ts new file mode 100644 index 00000000000..d6ab34e40cd --- /dev/null +++ b/packages/assets-controllers/src/multi-chain-accounts-service/api-balance-fetcher.test.ts @@ -0,0 +1,2346 @@ +import type { InternalAccount } from '@metamask/keyring-internal-api'; +import BN from 'bn.js'; + +import { createMockInternalAccount } from '../../../accounts-controller/tests/mocks.js'; +import { SUPPORTED_NETWORKS_ACCOUNTS_API_V4 } from '../constants.js'; +import * as ConstantsModule from '../constants.js'; +import { AccountsApiBalanceFetcher } from './api-balance-fetcher.js'; +import type { ChainIdHex, ChecksumAddress } from './api-balance-fetcher.js'; +import type { GetBalancesResponse } from './types.js'; + +// Mock dependencies that cause import issues +jest.mock('../AssetsContractController', () => ({ + STAKING_CONTRACT_ADDRESS_BY_CHAINID: { + '0x1': '0x4FEF9D741011476750A243aC70b9789a63dd47Df', + '0x4268': '0x4FEF9D741011476750A243aC70b9789a63dd47Df', + }, +})); + +const MOCK_ADDRESS_1 = '0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045'; +const MOCK_ADDRESS_2 = '0x742d35cc6675c4f17f41140100aa83a4b1fa4c82'; +const MOCK_CHAIN_ID = '0x1' as ChainIdHex; +const MOCK_UNSUPPORTED_CHAIN_ID = '0x999' as ChainIdHex; +const ZERO_ADDRESS = + '0x0000000000000000000000000000000000000000' as ChecksumAddress; +const STAKING_CONTRACT_ADDRESS = + '0x4FEF9D741011476750A243aC70b9789a63dd47Df' as ChecksumAddress; + +type TokenBalance = GetBalancesResponse['balances'][number]; + +const createMockNativeTokenBalance = ( + overrides?: Partial, +): TokenBalance => ({ + object: 'token', + address: '0x0000000000000000000000000000000000000000', + symbol: 'ETH', + name: 'Ether', + type: 'native', + timestamp: '2015-07-30T03:26:13.000Z', + decimals: 18, + chainId: 1, + balance: '1.5', + accountAddress: 'eip155:1:0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045', + ...overrides, +}); +const createMockERCTokenBalance = ( + overrides?: Partial, +): TokenBalance => ({ + object: 'token', + address: '0x6B175474E89094C44Da98b954EedeAC495271d0F', + name: 'Dai Stablecoin', + symbol: 'DAI', + decimals: 18, + chainId: 1, + balance: '100.0', + accountAddress: 'eip155:1:0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045', + ...overrides, +}); + +const MOCK_BALANCES_RESPONSE: GetBalancesResponse = { + count: 3, + balances: [ + createMockNativeTokenBalance(), + createMockERCTokenBalance(), + createMockNativeTokenBalance({ + balance: '2.0', + accountAddress: 'eip155:1:0x742d35cc6675c4f17f41140100aa83a4b1fa4c82', + }), + ], + unprocessedNetworks: [], +}; + +const MOCK_LARGE_BALANCES_RESPONSE_BATCH_1: GetBalancesResponse = { + count: 2, + balances: [ + createMockNativeTokenBalance({ balance: '1.0' }), + createMockERCTokenBalance({ name: 'Dai', balance: '50.0' }), + ], + unprocessedNetworks: [], +}; + +const MOCK_LARGE_BALANCES_RESPONSE_BATCH_2: GetBalancesResponse = { + count: 1, + balances: [ + createMockNativeTokenBalance({ + balance: '2.0', + accountAddress: 'eip155:1:0x742d35cc6675c4f17f41140100aa83a4b1fa4c82', + }), + ], + unprocessedNetworks: [], +}; + +const MOCK_INTERNAL_ACCOUNTS: InternalAccount[] = [ + { + id: '1', + address: MOCK_ADDRESS_1, + type: 'eip155:eoa', + options: {}, + methods: [], + scopes: [], + metadata: { + name: 'Account 1', + importTime: Date.now(), + keyring: { + type: 'HD Key Tree', + }, + }, + }, + { + id: '2', + address: MOCK_ADDRESS_2, + type: 'eip155:eoa', + options: {}, + methods: [], + scopes: [], + metadata: { + name: 'Account 2', + importTime: Date.now(), + keyring: { + type: 'HD Key Tree', + }, + }, + }, +]; + +// Mock the imports +jest.mock('@metamask/controller-utils', () => ({ + safelyExecute: jest.fn(), + safelyExecuteWithTimeout: jest.fn(), + toHex: jest.fn(), + toChecksumHexAddress: jest.fn(), +})); + +jest.mock('./multi-chain-accounts', () => ({ + fetchMultiChainBalancesV4: jest.fn(), +})); + +jest.mock('../assetsUtil', () => ({ + accountAddressToCaipReference: jest.fn(), + reduceInBatchesSerially: jest.fn(), + SupportedStakedBalanceNetworks: { + Mainnet: '0x1', + Hoodi: '0x4268', + }, + STAKING_CONTRACT_ADDRESS_BY_CHAINID: { + '0x1': '0x4FEF9D741011476750A243aC70b9789a63dd47Df', + '0x4268': '0x4FEF9D741011476750A243aC70b9789a63dd47Df', + }, +})); + +jest.mock('@ethersproject/contracts', () => ({ + Contract: jest.fn(), +})); + +jest.mock('@ethersproject/bignumber', () => ({ + BigNumber: { + from: jest.fn(), + }, +})); + +jest.mock('@ethersproject/providers', () => ({ + Web3Provider: jest.fn(), +})); + +const mockSafelyExecute = jest.requireMock( + '@metamask/controller-utils', +).safelyExecute; +const mockSafelyExecuteWithTimeout = jest.requireMock( + '@metamask/controller-utils', +).safelyExecuteWithTimeout; +const mockToHex = jest.requireMock('@metamask/controller-utils').toHex; +const mockToChecksumHexAddress = jest.requireMock( + '@metamask/controller-utils', +).toChecksumHexAddress; +const mockFetchMultiChainBalancesV4 = jest.requireMock( + './multi-chain-accounts', +).fetchMultiChainBalancesV4; +const mockAccountAddressToCaipReference = + jest.requireMock('../assetsUtil').accountAddressToCaipReference; +const mockReduceInBatchesSerially = + jest.requireMock('../assetsUtil').reduceInBatchesSerially; + +describe('AccountsApiBalanceFetcher', () => { + let balanceFetcher: AccountsApiBalanceFetcher; + + beforeEach(() => { + jest.clearAllMocks(); + + // Setup default mock implementations + mockToHex.mockImplementation((value: number | string) => { + if (typeof value === 'number') { + return `0x${value.toString(16)}`; + } + return value; + }); + + mockToChecksumHexAddress.mockImplementation((address: string) => address); + + mockAccountAddressToCaipReference.mockImplementation( + (chainId: string, address: string) => + `eip155:${parseInt(chainId, 16)}:${address}`, + ); + + mockSafelyExecute.mockImplementation( + async (fn: () => Promise) => await fn(), + ); + + // Mock safelyExecuteWithTimeout to just execute the function + mockSafelyExecuteWithTimeout.mockImplementation( + async (operation: () => Promise) => { + try { + return await operation(); + } catch { + return undefined; + } + }, + ); + }); + + describe('constructor', () => { + it('should create instance with default platform (extension)', () => { + balanceFetcher = new AccountsApiBalanceFetcher(); + expect(balanceFetcher).toBeInstanceOf(AccountsApiBalanceFetcher); + }); + + it('should create instance with mobile platform', () => { + balanceFetcher = new AccountsApiBalanceFetcher('mobile'); + expect(balanceFetcher).toBeInstanceOf(AccountsApiBalanceFetcher); + }); + + it('should create instance with extension platform', () => { + balanceFetcher = new AccountsApiBalanceFetcher('extension'); + expect(balanceFetcher).toBeInstanceOf(AccountsApiBalanceFetcher); + }); + + it('should create instance with getProvider function for staked balance functionality', () => { + const mockGetProvider = jest.fn(); + balanceFetcher = new AccountsApiBalanceFetcher( + 'extension', + mockGetProvider, + ); + expect(balanceFetcher).toBeInstanceOf(AccountsApiBalanceFetcher); + }); + }); + + describe('supports', () => { + beforeEach(() => { + balanceFetcher = new AccountsApiBalanceFetcher(); + }); + + it('should return true for supported chain IDs', () => { + for (const chainId of SUPPORTED_NETWORKS_ACCOUNTS_API_V4) { + expect(balanceFetcher.supports(chainId as ChainIdHex)).toBe(true); + } + }); + + it('should return false for unsupported chain IDs', () => { + expect(balanceFetcher.supports(MOCK_UNSUPPORTED_CHAIN_ID)).toBe(false); + expect(balanceFetcher.supports('0x123' as ChainIdHex)).toBe(false); + }); + }); + + describe('fetch', () => { + beforeEach(() => { + balanceFetcher = new AccountsApiBalanceFetcher('extension'); + }); + + it('should return empty array when no chain IDs are provided', async () => { + const result = await balanceFetcher.fetch({ + chainIds: [], + queryAllAccounts: false, + selectedAccount: MOCK_ADDRESS_1 as ChecksumAddress, + allAccounts: MOCK_INTERNAL_ACCOUNTS, + }); + + expect(result).toStrictEqual({ balances: [] }); + expect(mockFetchMultiChainBalancesV4).not.toHaveBeenCalled(); + }); + + it('should return empty array when no supported chain IDs are provided', async () => { + const result = await balanceFetcher.fetch({ + chainIds: [MOCK_UNSUPPORTED_CHAIN_ID], + queryAllAccounts: false, + selectedAccount: MOCK_ADDRESS_1 as ChecksumAddress, + allAccounts: MOCK_INTERNAL_ACCOUNTS, + }); + + expect(result).toStrictEqual({ balances: [] }); + expect(mockFetchMultiChainBalancesV4).not.toHaveBeenCalled(); + }); + + it('should fetch balances for selected account only', async () => { + const selectedAccountResponse: GetBalancesResponse = { + count: 2, + balances: [ + createMockNativeTokenBalance({ + accountAddress: `eip155:1:${MOCK_ADDRESS_1}`, + }), + createMockERCTokenBalance({ + accountAddress: `eip155:1:${MOCK_ADDRESS_1}`, + }), + ], + unprocessedNetworks: [], + }; + + mockFetchMultiChainBalancesV4.mockResolvedValue(selectedAccountResponse); + + const result = await balanceFetcher.fetch({ + chainIds: [MOCK_CHAIN_ID], + queryAllAccounts: false, + selectedAccount: MOCK_ADDRESS_1 as ChecksumAddress, + allAccounts: MOCK_INTERNAL_ACCOUNTS, + }); + + expect(mockFetchMultiChainBalancesV4).toHaveBeenCalledWith( + { + accountAddresses: [ + 'eip155:1:0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045', + ], + }, + 'extension', + undefined, + ); + + expect(result.balances).toHaveLength(2); + expect(result.balances[0]).toStrictEqual({ + success: true, + value: new BN('1500000000000000000'), + account: MOCK_ADDRESS_1, + token: '0x0000000000000000000000000000000000000000', + chainId: '0x1', + }); + expect(result.balances[1]).toStrictEqual({ + success: true, + value: new BN('100000000000000000000'), + account: MOCK_ADDRESS_1, + token: '0x6B175474E89094C44Da98b954EedeAC495271d0F', + chainId: '0x1', + }); + }); + + it('should fetch balances for all accounts when queryAllAccounts is true', async () => { + mockFetchMultiChainBalancesV4.mockResolvedValue(MOCK_BALANCES_RESPONSE); + + const result = await balanceFetcher.fetch({ + chainIds: [MOCK_CHAIN_ID], + queryAllAccounts: true, + selectedAccount: MOCK_ADDRESS_1 as ChecksumAddress, + allAccounts: MOCK_INTERNAL_ACCOUNTS, + }); + + expect(mockFetchMultiChainBalancesV4).toHaveBeenCalledWith( + { + accountAddresses: [ + 'eip155:1:0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045', + 'eip155:1:0x742d35cc6675c4f17f41140100aa83a4b1fa4c82', + ], + }, + 'extension', + undefined, + ); + + expect(result.balances).toHaveLength(3); + }); + + it('should convert unprocessedNetworks from decimal to hex chain IDs', async () => { + const responseWithUnprocessed = { + count: 1, + balances: [ + createMockNativeTokenBalance({ + accountAddress: `eip155:1:${MOCK_ADDRESS_1}`, + }), + ], + unprocessedNetworks: [137, 42161, 10, 8453], // Polygon, Arbitrum, Optimism, Base (in decimal) + }; + + mockFetchMultiChainBalancesV4.mockResolvedValue(responseWithUnprocessed); + + const result = await balanceFetcher.fetch({ + chainIds: [MOCK_CHAIN_ID], + queryAllAccounts: false, + selectedAccount: MOCK_ADDRESS_1 as ChecksumAddress, + allAccounts: MOCK_INTERNAL_ACCOUNTS, + }); + + // Verify conversion from decimal to hex + expect(result.unprocessedChainIds).toBeDefined(); + expect(result.unprocessedChainIds).toStrictEqual([ + '0x89', // 137 -> 0x89 (Polygon) + '0xa4b1', // 42161 -> 0xa4b1 (Arbitrum) + '0xa', // 10 -> 0xa (Optimism) + '0x2105', // 8453 -> 0x2105 (Base) + ]); + }); + + it('should handle large batch requests using reduceInBatchesSerially', async () => { + // Create a large number of CAIP addresses to exceed ACCOUNTS_API_BATCH_SIZE (50) + const largeAccountList: InternalAccount[] = []; + const caipAddresses: string[] = []; + + for (let i = 0; i < 60; i++) { + const address = + `0x${'0'.repeat(39)}${i.toString().padStart(1, '0')}` as ChecksumAddress; + largeAccountList.push(createMockInternalAccount({ address })); + caipAddresses.push(`eip155:1:${address}`); + } + + // Mock reduceInBatchesSerially to return combined results + mockReduceInBatchesSerially.mockImplementation( + async ({ + eachBatch, + initialResult, + }: { + eachBatch: ( + result: unknown, + batch: unknown, + index: number, + ) => Promise; + initialResult: unknown; + }) => { + const batch1 = caipAddresses.slice(0, 50); + const batch2 = caipAddresses.slice(50); + + let result = initialResult; + result = await eachBatch(result, batch1, 0); + result = await eachBatch(result, batch2, 1); + + return result; + }, + ); + + mockFetchMultiChainBalancesV4 + .mockResolvedValueOnce(MOCK_LARGE_BALANCES_RESPONSE_BATCH_1) + .mockResolvedValueOnce(MOCK_LARGE_BALANCES_RESPONSE_BATCH_2); + + const result = await balanceFetcher.fetch({ + chainIds: [MOCK_CHAIN_ID], + queryAllAccounts: true, + selectedAccount: MOCK_ADDRESS_1 as ChecksumAddress, + allAccounts: largeAccountList, + }); + + expect(mockReduceInBatchesSerially).toHaveBeenCalledWith({ + values: caipAddresses, + batchSize: 20, + eachBatch: expect.any(Function), + initialResult: [], + }); + + expect(mockFetchMultiChainBalancesV4).toHaveBeenCalledTimes(2); + // Should have more results due to native token guarantees for all 60 accounts + expect(result.balances.length).toBeGreaterThan(3); + }); + + it('should collect unprocessedNetworks from multiple batches', async () => { + // Create a large number of CAIP addresses to exceed ACCOUNTS_API_BATCH_SIZE (50) + const largeAccountList: InternalAccount[] = []; + const caipAddresses: string[] = []; + + for (let i = 0; i < 60; i++) { + const address = + `0x${'0'.repeat(39)}${i.toString().padStart(1, '0')}` as ChecksumAddress; + largeAccountList.push({ + id: i.toString(), + address, + type: 'eip155:eoa', + options: {}, + methods: [], + scopes: [], + metadata: { + name: `Account ${i}`, + importTime: Date.now(), + keyring: { type: 'HD Key Tree' }, + }, + }); + caipAddresses.push(`eip155:1:${address}`); + } + + // Mock reduceInBatchesSerially to simulate batching behavior + mockReduceInBatchesSerially.mockImplementation( + async ({ + eachBatch, + initialResult, + }: { + eachBatch: ( + result: unknown, + batch: unknown, + index: number, + ) => Promise; + initialResult: unknown; + }) => { + const batch1 = caipAddresses.slice(0, 50); + const batch2 = caipAddresses.slice(50); + + let result = initialResult; + result = await eachBatch(result, batch1, 0); + result = await eachBatch(result, batch2, 1); + + return result; + }, + ); + + // Mock the API to return different unprocessedNetworks for each batch + mockFetchMultiChainBalancesV4 + .mockResolvedValueOnce({ + count: 0, + balances: [], + unprocessedNetworks: [137, 42161], // Batch 1: Polygon and Arbitrum + }) + .mockResolvedValueOnce({ + count: 0, + balances: [], + unprocessedNetworks: [10, 137], // Batch 2: Optimism and Polygon (duplicate) + }); + + const result = await balanceFetcher.fetch({ + chainIds: [MOCK_CHAIN_ID], + queryAllAccounts: true, + selectedAccount: MOCK_ADDRESS_1 as ChecksumAddress, + allAccounts: largeAccountList, + }); + + // Should have been called twice (2 batches) + expect(mockFetchMultiChainBalancesV4).toHaveBeenCalledTimes(2); + + // should have collected all unique networks from both batches + // The Set deduplicates 137 (Polygon) which appears in both batches + expect(result.unprocessedChainIds).toBeDefined(); + expect(result.unprocessedChainIds).toStrictEqual( + expect.arrayContaining(['0x89', '0xa4b1', '0xa']), + ); + expect(result.unprocessedChainIds).toHaveLength(3); // No duplicates + }); + + it('should handle missing account address in response', async () => { + const responseWithMissingAccount: GetBalancesResponse = { + count: 1, + balances: [ + { + object: 'token', + address: '0x0000000000000000000000000000000000000000', + symbol: 'ETH', + name: 'Ether', + decimals: 18, + chainId: 1, + balance: '1.0', + // accountAddress is missing + }, + ], + unprocessedNetworks: [], + }; + + mockFetchMultiChainBalancesV4.mockResolvedValue( + responseWithMissingAccount, + ); + + const result = await balanceFetcher.fetch({ + chainIds: [MOCK_CHAIN_ID], + queryAllAccounts: false, + selectedAccount: MOCK_ADDRESS_1 as ChecksumAddress, + allAccounts: MOCK_INTERNAL_ACCOUNTS, + }); + + // Should have native token guarantee even with missing account address + expect(result.balances).toHaveLength(1); + expect(result.balances[0].token).toBe(ZERO_ADDRESS); + expect(result.balances[0].success).toBe(true); + expect(result.balances[0].value).toStrictEqual(new BN('0')); + }); + + it('should correctly convert balance values with different decimals', async () => { + const responseWithDifferentDecimals: GetBalancesResponse = { + count: 2, + balances: [ + createMockERCTokenBalance({ name: 'Dai', balance: '123.456789' }), + createMockERCTokenBalance({ + address: '0xA0b86a33E6441c86c33E1C6B9cD964c0BA2A86B', + symbol: 'USDC', + name: 'USD Coin', + decimals: 6, + balance: '100.5', + }), + ], + unprocessedNetworks: [], + }; + + mockFetchMultiChainBalancesV4.mockResolvedValue( + responseWithDifferentDecimals, + ); + + const result = await balanceFetcher.fetch({ + chainIds: [MOCK_CHAIN_ID], + queryAllAccounts: false, + selectedAccount: MOCK_ADDRESS_1 as ChecksumAddress, + allAccounts: MOCK_INTERNAL_ACCOUNTS, + }); + + expect(result.balances).toHaveLength(3); // 2 tokens + native token guarantee + + // DAI with 18 decimals: 123.456789 -> using string-based conversion + // Convert received hex value to decimal to get the correct expected value + const expectedDaiValue = new BN('6b14e9f7e4f5a5000', 16); + expect(result.balances[0]).toStrictEqual({ + success: true, + value: expectedDaiValue, + account: MOCK_ADDRESS_1, + token: '0x6B175474E89094C44Da98b954EedeAC495271d0F', + chainId: '0x1', + }); + + // USDC with 6 decimals: 100.5 * 10^6 + expect(result.balances[1]).toStrictEqual({ + success: true, + value: new BN('100500000'), + account: MOCK_ADDRESS_1, + token: '0xA0b86a33E6441c86c33E1C6B9cD964c0BA2A86B', + chainId: '0x1', + }); + }); + + it('should handle multiple chain IDs', async () => { + mockFetchMultiChainBalancesV4.mockResolvedValue(MOCK_BALANCES_RESPONSE); + + await balanceFetcher.fetch({ + chainIds: [MOCK_CHAIN_ID, '0x89' as ChainIdHex], // Ethereum and Polygon + queryAllAccounts: false, + selectedAccount: MOCK_ADDRESS_1 as ChecksumAddress, + allAccounts: MOCK_INTERNAL_ACCOUNTS, + }); + + expect(mockAccountAddressToCaipReference).toHaveBeenCalledWith( + MOCK_CHAIN_ID, + MOCK_ADDRESS_1, + ); + expect(mockAccountAddressToCaipReference).toHaveBeenCalledWith( + '0x89', + MOCK_ADDRESS_1, + ); + + expect(mockFetchMultiChainBalancesV4).toHaveBeenCalledWith( + { + accountAddresses: [ + 'eip155:1:0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045', + 'eip155:137:0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045', + ], + }, + 'extension', + undefined, + ); + }); + + it('should pass correct platform to fetchMultiChainBalancesV4', async () => { + const mobileBalanceFetcher = new AccountsApiBalanceFetcher('mobile'); + mockFetchMultiChainBalancesV4.mockResolvedValue(MOCK_BALANCES_RESPONSE); + + await mobileBalanceFetcher.fetch({ + chainIds: [MOCK_CHAIN_ID], + queryAllAccounts: false, + selectedAccount: MOCK_ADDRESS_1 as ChecksumAddress, + allAccounts: MOCK_INTERNAL_ACCOUNTS, + }); + + expect(mockFetchMultiChainBalancesV4).toHaveBeenCalledWith( + { + accountAddresses: [ + 'eip155:1:0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045', + ], + }, + 'mobile', + undefined, + ); + }); + }); + + describe('native token guarantee', () => { + beforeEach(() => { + balanceFetcher = new AccountsApiBalanceFetcher('extension'); + }); + + it('should include native token entry for addresses even when API does not return native balance', async () => { + const responseWithoutNative: GetBalancesResponse = { + count: 1, + balances: [createMockERCTokenBalance()], + unprocessedNetworks: [], + }; + + mockFetchMultiChainBalancesV4.mockResolvedValue(responseWithoutNative); + + const result = await balanceFetcher.fetch({ + chainIds: [MOCK_CHAIN_ID], + queryAllAccounts: false, + selectedAccount: MOCK_ADDRESS_1 as ChecksumAddress, + allAccounts: MOCK_INTERNAL_ACCOUNTS, + }); + + expect(result.balances).toHaveLength(2); // DAI token + native token (zero balance) + + // Should include the DAI token + const daiBalance = result.balances.find( + (res) => res.token === '0x6B175474E89094C44Da98b954EedeAC495271d0F', + ); + expect(daiBalance).toBeDefined(); + expect(daiBalance?.success).toBe(true); + + // Should include native token with zero balance + const nativeBalance = result.balances.find( + (res) => res.token === ZERO_ADDRESS, + ); + expect(nativeBalance).toBeDefined(); + expect(nativeBalance?.success).toBe(true); + expect(nativeBalance?.value).toStrictEqual(new BN('0')); + expect(nativeBalance?.account).toBe(MOCK_ADDRESS_1); + expect(nativeBalance?.chainId).toBe(MOCK_CHAIN_ID); + }); + + it('should include native token entries for all addresses when querying multiple accounts', async () => { + const responsePartialNative: GetBalancesResponse = { + count: 2, + balances: [ + createMockNativeTokenBalance(), + // Native balance missing for MOCK_ADDRESS_2 + createMockERCTokenBalance({ + name: 'Dai', + balance: '50.0', + accountAddress: + 'eip155:1:0x742d35cc6675c4f17f41140100aa83a4b1fa4c82', + }), + ], + unprocessedNetworks: [], + }; + + mockFetchMultiChainBalancesV4.mockResolvedValue(responsePartialNative); + + const result = await balanceFetcher.fetch({ + chainIds: [MOCK_CHAIN_ID], + queryAllAccounts: true, + selectedAccount: MOCK_ADDRESS_1 as ChecksumAddress, + allAccounts: MOCK_INTERNAL_ACCOUNTS, + }); + + // Should have 4 entries: ETH for addr1, DAI for addr2, and native (0) for addr2 + expect(result.balances).toHaveLength(3); + + // Verify native balances for both addresses + const nativeBalances = result.balances.filter( + (res) => res.token === ZERO_ADDRESS, + ); + expect(nativeBalances).toHaveLength(2); + + const nativeAddr1 = nativeBalances.find( + (res) => res.account === MOCK_ADDRESS_1, + ); + const nativeAddr2 = nativeBalances.find( + (res) => res.account === MOCK_ADDRESS_2, + ); + + expect(nativeAddr1?.value).toStrictEqual(new BN('1500000000000000000')); // 1.5 ETH + expect(nativeAddr2?.value).toStrictEqual(new BN('0')); // Zero balance (not returned by API) + }); + + it('should not zero out native balances for addresses excluded from selected-account requests', async () => { + const excludedAddress = '0x1111111111111111111111111111111111111111'; + + mockAccountAddressToCaipReference.mockReturnValue( + `eip155:1:${excludedAddress}`, + ); + mockFetchMultiChainBalancesV4.mockResolvedValue({ + count: 0, + balances: [], + unprocessedNetworks: [], + }); + + const result = await balanceFetcher.fetch({ + chainIds: [MOCK_CHAIN_ID], + queryAllAccounts: false, + selectedAccount: MOCK_ADDRESS_1 as ChecksumAddress, + allAccounts: MOCK_INTERNAL_ACCOUNTS, + }); + + expect(result.balances).toStrictEqual([]); + }); + + it('should not zero out native balances for addresses excluded from all-accounts requests', async () => { + const excludedAddress = '0x1111111111111111111111111111111111111111'; + + mockAccountAddressToCaipReference.mockReturnValue( + `eip155:1:${excludedAddress}`, + ); + mockFetchMultiChainBalancesV4.mockResolvedValue({ + count: 0, + balances: [], + unprocessedNetworks: [], + }); + + const result = await balanceFetcher.fetch({ + chainIds: [MOCK_CHAIN_ID], + queryAllAccounts: true, + selectedAccount: MOCK_ADDRESS_1 as ChecksumAddress, + allAccounts: MOCK_INTERNAL_ACCOUNTS, + }); + + expect(result.balances).toStrictEqual([]); + }); + }); + + describe('erc20 token unprocessed token handling', () => { + const arrangeBalanceFetcher = (): AccountsApiBalanceFetcher => { + const responseWithoutErc20: GetBalancesResponse = { + count: 1, + // Example of no erc20 balance, but does contain native token balance + balances: [createMockNativeTokenBalance({ chainId: 1 })], + unprocessedNetworks: [], + }; + + mockFetchMultiChainBalancesV4.mockResolvedValue(responseWithoutErc20); + + balanceFetcher = new AccountsApiBalanceFetcher( + 'extension', + undefined, + () => ({ + [MOCK_ADDRESS_1]: { + '0x1': { + [ZERO_ADDRESS]: {}, + '0x0xaf88d065e77c8cC2239327C5EDb3A432268e5831': '0x814a20', // previously had balance, should be zero now if api doesn't return it + }, + }, + }), + ); + + return balanceFetcher; + }; + + it('includes unprocessed tokens for missing erc20 balances for selected account', async () => { + balanceFetcher = arrangeBalanceFetcher(); + + const result = await balanceFetcher.fetch({ + chainIds: ['0x1'], + queryAllAccounts: false, + selectedAccount: MOCK_ADDRESS_1 as ChecksumAddress, + allAccounts: MOCK_INTERNAL_ACCOUNTS, + }); + + expect(result.balances).toHaveLength(1); + expect(result.balances[0].token).toStrictEqual(ZERO_ADDRESS); + expect(result.unprocessedTokens).toStrictEqual({ + [MOCK_ADDRESS_1.toLowerCase()]: { + '0x1': ['0x0xaf88d065e77c8cC2239327C5EDb3A432268e5831'.toLowerCase()], + }, + }); + }); + + it('does not include unprocessed tokens for non selected accounts', async () => { + const selectedAccountToken = + '0x0xaf88d065e77c8cC2239327C5EDb3A432268e5831'; + const excludedAccountToken = '0xB97EF9Ef8734C71904D8002F8b6Bc66Dd9c48a6E'; + + mockFetchMultiChainBalancesV4.mockResolvedValue({ + count: 1, + balances: [createMockNativeTokenBalance({ chainId: 1 })], + unprocessedNetworks: [], + }); + + balanceFetcher = new AccountsApiBalanceFetcher( + 'extension', + undefined, + () => ({ + [MOCK_ADDRESS_1]: { + '0x1': { + [ZERO_ADDRESS]: {}, + [selectedAccountToken]: '0x814a20', + }, + }, + [MOCK_ADDRESS_2]: { + '0x1': { + [ZERO_ADDRESS]: {}, + [excludedAccountToken]: '0x814a20', + }, + }, + }), + ); + + const result = await balanceFetcher.fetch({ + chainIds: ['0x1'], + queryAllAccounts: false, + selectedAccount: MOCK_ADDRESS_1 as ChecksumAddress, + allAccounts: MOCK_INTERNAL_ACCOUNTS, + }); + + expect(result.unprocessedTokens).toStrictEqual({ + // Does not include non-selected accounts + [MOCK_ADDRESS_1.toLowerCase()]: { + '0x1': [selectedAccountToken.toLowerCase()], + }, + }); + + expect( + result.unprocessedTokens?.[MOCK_ADDRESS_2.toLowerCase()], + ).toBeUndefined(); + }); + + it('includes unprocessed tokens for missing erc20 balances for all accounts', async () => { + const includedAccountToken = + '0x0xaf88d065e77c8cC2239327C5EDb3A432268e5831'; + const excludedAccountToken = '0xA0b86a33E6441c86c33E1C6B9cD964c0BA2A86B'; + + mockFetchMultiChainBalancesV4.mockResolvedValue({ + count: 2, + balances: [ + createMockNativeTokenBalance({ + accountAddress: `eip155:1:${MOCK_ADDRESS_1}`, + }), + createMockNativeTokenBalance({ + balance: '2.0', + accountAddress: `eip155:1:${MOCK_ADDRESS_2}`, + }), + ], + unprocessedNetworks: [], + }); + + balanceFetcher = new AccountsApiBalanceFetcher( + 'extension', + undefined, + () => ({ + [MOCK_ADDRESS_1]: { + '0x1': { + [ZERO_ADDRESS]: {}, + [includedAccountToken]: '0x814a20', + }, + }, + [MOCK_ADDRESS_2]: { + '0x1': { + [ZERO_ADDRESS]: {}, + [excludedAccountToken]: '0x814a20', + }, + }, + }), + ); + + const result = await balanceFetcher.fetch({ + chainIds: ['0x1'], + queryAllAccounts: true, + selectedAccount: MOCK_ADDRESS_1 as ChecksumAddress, + allAccounts: MOCK_INTERNAL_ACCOUNTS, + }); + + expect(result.unprocessedTokens).toStrictEqual({ + [MOCK_ADDRESS_1.toLowerCase()]: { + '0x1': [includedAccountToken.toLowerCase()], + }, + [MOCK_ADDRESS_2.toLowerCase()]: { + '0x1': [excludedAccountToken.toLowerCase()], + }, + }); + }); + + it('should not include erc20 token entry for chains that are not supported by account API', async () => { + balanceFetcher = arrangeBalanceFetcher(); + + balanceFetcher = new AccountsApiBalanceFetcher( + 'extension', + undefined, + () => ({ + [MOCK_ADDRESS_1]: { + '0x1': { + [ZERO_ADDRESS]: {}, + }, + // Avalanche is not a supported chain, so balances should not be zeroed out + '0xa86a': { + [ZERO_ADDRESS]: {}, + '0xB97EF9Ef8734C71904D8002F8b6Bc66Dd9c48a6E': '0x814a20', // USDC AVAX has balance, should not be zeroed out + }, + }, + }), + ); + + const result = await balanceFetcher.fetch({ + chainIds: ['0x1', '0xa86a' as ChainIdHex], + queryAllAccounts: false, + selectedAccount: MOCK_ADDRESS_1 as ChecksumAddress, + allAccounts: MOCK_INTERNAL_ACCOUNTS, + }); + + expect(result.balances).toHaveLength(1); + expect(result.balances[0]).toStrictEqual( + expect.objectContaining({ + chainId: '0x1', + token: ZERO_ADDRESS, + value: expect.any(BN), + }), + ); + }); + }); + + describe('staked balance functionality', () => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + let mockProvider: any; + let mockGetProvider: jest.Mock; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + let mockContract: any; + + beforeEach(() => { + // Setup contract mock with required methods + mockContract = { + getShares: jest.fn(), + convertToAssets: jest.fn(), + }; + + // Mock the Contract constructor to return our mock contract + const mockContractConstructor = jest.requireMock( + '@ethersproject/contracts', + ).Contract; + mockContractConstructor.mockImplementation(() => mockContract); + + mockProvider = { + call: jest.fn(), + }; + mockGetProvider = jest.fn().mockReturnValue(mockProvider); + balanceFetcher = new AccountsApiBalanceFetcher( + 'extension', + mockGetProvider, + ); + }); + + it('should fetch staked balances when getProvider is available', async () => { + // Mock successful staking contract calls with BigNumber-like objects + const mockShares = { + toString: (): string => '1000000000000000000', // 1 share + gt: jest.fn().mockReturnValue(true), // shares > 0 + }; + const mockAssets = { + toString: (): string => '2000000000000000000', // 2 ETH equivalent + }; + + mockContract.getShares.mockResolvedValue(mockShares); + mockContract.convertToAssets.mockResolvedValue(mockAssets); + + mockFetchMultiChainBalancesV4.mockResolvedValue(MOCK_BALANCES_RESPONSE); + + const result = await balanceFetcher.fetch({ + chainIds: [MOCK_CHAIN_ID], + queryAllAccounts: true, + selectedAccount: MOCK_ADDRESS_1 as ChecksumAddress, + allAccounts: MOCK_INTERNAL_ACCOUNTS, + }); + + // Should include API balances + staked balance + expect(result.balances.length).toBeGreaterThan(3); // Original 3 + staked balances + + // Check for staked balance + const stakedBalance = result.balances.find( + (res) => res.token === STAKING_CONTRACT_ADDRESS, + ); + expect(stakedBalance).toBeDefined(); + expect(stakedBalance?.success).toBe(true); + expect(stakedBalance?.value).toStrictEqual(new BN('2000000000000000000')); // 2 ETH + }); + + it('should handle zero staked balances', async () => { + // Mock staking contract calls to return zero shares + const mockZeroShares = { + toString: (): string => '0', // 0 shares + gt: jest.fn().mockReturnValue(false), // shares = 0, not > 0 + }; + mockContract.getShares.mockResolvedValue(mockZeroShares); + + mockFetchMultiChainBalancesV4.mockResolvedValue(MOCK_BALANCES_RESPONSE); + + const result = await balanceFetcher.fetch({ + chainIds: [MOCK_CHAIN_ID], + queryAllAccounts: false, + selectedAccount: MOCK_ADDRESS_1 as ChecksumAddress, + allAccounts: MOCK_INTERNAL_ACCOUNTS, + }); + + // Should include staked balance entry with zero value when shares are zero + const stakedBalance = result.balances.find( + (res) => res.token === STAKING_CONTRACT_ADDRESS, + ); + expect(stakedBalance).toBeDefined(); + expect(stakedBalance?.success).toBe(true); + expect(stakedBalance?.value).toStrictEqual(new BN('0')); + }); + + it('should handle staking contract errors gracefully', async () => { + // Mock staking contract call to fail + mockContract.getShares.mockRejectedValue( + new Error('Contract call failed'), + ); + + mockFetchMultiChainBalancesV4.mockResolvedValue(MOCK_BALANCES_RESPONSE); + + const result = await balanceFetcher.fetch({ + chainIds: [MOCK_CHAIN_ID], + queryAllAccounts: false, + selectedAccount: MOCK_ADDRESS_1 as ChecksumAddress, + allAccounts: MOCK_INTERNAL_ACCOUNTS, + }); + + // Should still return API balances + native token guarantee, but failed staked balance + expect(result.balances.length).toBeGreaterThan(2); // API results + native token + failed staking + const stakedBalance = result.balances.find( + (res) => res.token === STAKING_CONTRACT_ADDRESS, + ); + expect(stakedBalance).toBeDefined(); + expect(stakedBalance?.success).toBe(false); + }); + + it('should skip staked balance fetching for unsupported chains', async () => { + const unsupportedChainResponse: GetBalancesResponse = { + count: 1, + balances: [ + { + object: 'token', + address: ZERO_ADDRESS, + symbol: 'MATIC', + name: 'Polygon', + decimals: 18, + chainId: parseInt(MOCK_UNSUPPORTED_CHAIN_ID, 16), + balance: '1.0', + accountAddress: `eip155:${parseInt(MOCK_UNSUPPORTED_CHAIN_ID, 16)}:${MOCK_ADDRESS_1}`, + }, + ], + unprocessedNetworks: [], + }; + + mockFetchMultiChainBalancesV4.mockResolvedValue(unsupportedChainResponse); + + const result = await balanceFetcher.fetch({ + chainIds: [MOCK_UNSUPPORTED_CHAIN_ID], + queryAllAccounts: false, + selectedAccount: MOCK_ADDRESS_1 as ChecksumAddress, + allAccounts: MOCK_INTERNAL_ACCOUNTS, + }); + + // Should not call provider for unsupported chains + expect(mockGetProvider).not.toHaveBeenCalled(); + expect(mockProvider.call).not.toHaveBeenCalled(); + + // Should not include staked balance + const stakedBalance = result.balances.find( + (res) => res.token === STAKING_CONTRACT_ADDRESS, + ); + expect(stakedBalance).toBeUndefined(); + }); + + it('should skip staked balance fetching for API-supported but staking-unsupported chains', async () => { + // Use Polygon (0x89) - it's supported by the API but NOT supported for staking + const polygonChainId = '0x89' as ChainIdHex; + + // Mock API response for Polygon + const polygonResponse: GetBalancesResponse = { + count: 1, + balances: [ + { + object: 'token', + address: ZERO_ADDRESS, + symbol: 'MATIC', + name: 'Polygon', + decimals: 18, + chainId: parseInt(polygonChainId, 16), + balance: '1.0', + accountAddress: `eip155:${parseInt(polygonChainId, 16)}:${MOCK_ADDRESS_1}`, + }, + ], + unprocessedNetworks: [], + }; + + mockFetchMultiChainBalancesV4.mockResolvedValue(polygonResponse); + + const result = await balanceFetcher.fetch({ + chainIds: [polygonChainId], // Polygon is API-supported but not staking-supported + queryAllAccounts: false, + selectedAccount: MOCK_ADDRESS_1 as ChecksumAddress, + allAccounts: MOCK_INTERNAL_ACCOUNTS, + }); + + // Should include native token but no staked balance for Polygon + expect(result.balances.length).toBeGreaterThan(0); + const stakedBalance = result.balances.find( + (res) => res.token === STAKING_CONTRACT_ADDRESS, + ); + expect(stakedBalance).toBeUndefined(); // No staked balance for unsupported staking chain + + // Should have native token balance + const nativeBalance = result.balances.find( + (res) => res.token === ZERO_ADDRESS, + ); + expect(nativeBalance).toBeDefined(); + }); + + it('should skip staked balance when supported network has no contract address', async () => { + // In the current implementation, is essentially unreachable because + // SupportedStakedBalanceNetworks and STAKING_CONTRACT_ADDRESS_BY_CHAINID are always in sync. + // However, we can create a test scenario by directly testing the #fetchStakedBalances method + // with a mock configuration where this mismatch exists. + + // The test mocks define hoodi as '0x4268', but let's temporarily modify the mock + // to remove '0x4268' from STAKING_CONTRACT_ADDRESS_BY_CHAINID while keeping it + // in SupportedStakedBalanceNetworks + + const testChainId = '0x4268' as ChainIdHex; // Use the mock hoodi chain ID + + // Get the mocked module + const mockAssetsController = jest.requireMock( + '../AssetsContractController', + ); + + // Store original mock + const originalContractAddresses = + mockAssetsController.STAKING_CONTRACT_ADDRESS_BY_CHAINID; + + // Temporarily remove '0x4268' from contract addresses + mockAssetsController.STAKING_CONTRACT_ADDRESS_BY_CHAINID = { + '0x1': '0x4FEF9D741011476750A243aC70b9789a63dd47Df', // Keep mainnet + // Remove '0x4268' (hoodi) from contract addresses + }; + + // Also need to add '0x4268' to supported API networks temporarily + const originalSupported = [...SUPPORTED_NETWORKS_ACCOUNTS_API_V4]; + Object.defineProperty( + ConstantsModule, + 'SUPPORTED_NETWORKS_ACCOUNTS_API_V4', + { value: [...SUPPORTED_NETWORKS_ACCOUNTS_API_V4, testChainId] }, + ); + + try { + // Mock API response for the test chain + const testResponse: GetBalancesResponse = { + count: 1, + balances: [ + { + object: 'token', + address: ZERO_ADDRESS, + symbol: 'HOD', + name: 'Hoodi Token', + decimals: 18, + chainId: parseInt(testChainId, 16), + balance: '1.0', + accountAddress: `eip155:${parseInt(testChainId, 16)}:${MOCK_ADDRESS_1}`, + }, + ], + unprocessedNetworks: [], + }; + + mockFetchMultiChainBalancesV4.mockResolvedValue(testResponse); + + const result = await balanceFetcher.fetch({ + chainIds: [testChainId], // 0x4268 is in mocked SupportedStakedBalanceNetworks but not in modified STAKING_CONTRACT_ADDRESS_BY_CHAINID + queryAllAccounts: false, + selectedAccount: MOCK_ADDRESS_1 as ChecksumAddress, + allAccounts: MOCK_INTERNAL_ACCOUNTS, + }); + + // Should include native token but no staked balance due to missing contract address + expect(result.balances.length).toBeGreaterThan(0); + const stakedBalance = result.balances.find( + (res) => res.token === STAKING_CONTRACT_ADDRESS, + ); + expect(stakedBalance).toBeUndefined(); // No staked balance due to missing contract address + + // Should have native token balance + const nativeBalance = result.balances.find( + (res) => res.token === ZERO_ADDRESS, + ); + expect(nativeBalance).toBeDefined(); + } finally { + // Restore original mocks + mockAssetsController.STAKING_CONTRACT_ADDRESS_BY_CHAINID = + originalContractAddresses; + + // Restore original supported networks + Object.defineProperty( + ConstantsModule, + 'SUPPORTED_NETWORKS_ACCOUNTS_API_V4', + { value: [...originalSupported] }, + ); + } + }); + + it('should handle contract setup errors gracefully', async () => { + // This test covers the outer catch block in #fetchStakedBalances + // when contract creation fails + + // Setup mocks for contract creation failure + const mockProvider2 = { + call: jest.fn(), + }; + const mockGetProvider2 = jest.fn().mockReturnValue(mockProvider2); + + // Mock Contract constructor to throw an error + const mockContractConstructor = jest.requireMock( + '@ethersproject/contracts', + ).Contract; + mockContractConstructor.mockImplementation(() => { + throw new Error('Contract creation failed'); + }); + + const testFetcher = new AccountsApiBalanceFetcher( + 'extension', + mockGetProvider2, + ); + + // Setup console.error spy to verify the error is logged + const consoleSpy = jest.spyOn(console, 'error').mockImplementation(); + + try { + mockFetchMultiChainBalancesV4.mockResolvedValue(MOCK_BALANCES_RESPONSE); + + const result = await testFetcher.fetch({ + chainIds: [MOCK_CHAIN_ID], // Use mainnet which has staking support + queryAllAccounts: false, + selectedAccount: MOCK_ADDRESS_1 as ChecksumAddress, + allAccounts: MOCK_INTERNAL_ACCOUNTS, + }); + + // Should still return API balances and native token guarantee, but no staked balances + expect(result.balances.length).toBeGreaterThan(0); + + // Verify console.error was called with contract setup error + expect(consoleSpy).toHaveBeenCalledWith( + expect.stringContaining( + 'Error setting up staking contract for chain', + ), + expect.any(Error), + ); + + // Should not have any staked balance due to contract setup failure + const stakedBalance = result.balances.find( + (res) => res.token === STAKING_CONTRACT_ADDRESS, + ); + expect(stakedBalance).toBeUndefined(); + } finally { + consoleSpy.mockRestore(); + // Restore the original Contract mock implementation + mockContractConstructor.mockReset(); + } + }); + + it('should handle staked balances when getProvider is not provided', async () => { + // Create fetcher without getProvider + const fetcherWithoutProvider = new AccountsApiBalanceFetcher('extension'); + + mockFetchMultiChainBalancesV4.mockResolvedValue(MOCK_BALANCES_RESPONSE); + + const result = await fetcherWithoutProvider.fetch({ + chainIds: [MOCK_CHAIN_ID], + queryAllAccounts: false, + selectedAccount: MOCK_ADDRESS_1 as ChecksumAddress, + allAccounts: MOCK_INTERNAL_ACCOUNTS, + }); + + // Should return API balances plus native token guarantee (but no staked balances) + expect(result.balances).toHaveLength(3); // Original API results + native token + const stakedBalance = result.balances.find( + (res) => res.token === STAKING_CONTRACT_ADDRESS, + ); + expect(stakedBalance).toBeUndefined(); + }); + }); + + describe('additional coverage tests', () => { + beforeEach(() => { + balanceFetcher = new AccountsApiBalanceFetcher('extension'); + }); + + it('should test checksum and toCaipAccount helper functions indirectly', async () => { + mockToChecksumHexAddress.mockReturnValue('0xCHECKSUMMED'); + mockAccountAddressToCaipReference.mockReturnValue( + 'eip155:1:0xCHECKSUMMED', + ); + + mockFetchMultiChainBalancesV4.mockResolvedValue({ + count: 0, + balances: [], + unprocessedNetworks: [], + }); + + await balanceFetcher.fetch({ + chainIds: [MOCK_CHAIN_ID], + queryAllAccounts: false, + selectedAccount: MOCK_ADDRESS_1 as ChecksumAddress, + allAccounts: MOCK_INTERNAL_ACCOUNTS, + }); + + expect(mockToChecksumHexAddress).toHaveBeenCalled(); + expect(mockAccountAddressToCaipReference).toHaveBeenCalled(); + }); + + it('should handle the single account branch', async () => { + // This specifically tests the else branch that adds single account + mockFetchMultiChainBalancesV4.mockResolvedValue(MOCK_BALANCES_RESPONSE); + + const result = await balanceFetcher.fetch({ + chainIds: [MOCK_CHAIN_ID], + queryAllAccounts: false, + selectedAccount: MOCK_ADDRESS_1 as ChecksumAddress, + allAccounts: MOCK_INTERNAL_ACCOUNTS, + }); + + expect(mockAccountAddressToCaipReference).toHaveBeenCalledWith( + MOCK_CHAIN_ID, + MOCK_ADDRESS_1, + ); + expect(result.balances.length).toBeGreaterThan(0); + }); + + it('should handle balance parsing errors gracefully', async () => { + const responseWithNaNBalance: GetBalancesResponse = { + count: 1, + balances: [ + createMockERCTokenBalance({ + name: 'Dai', + balance: 'not-a-number', + }), + ], + unprocessedNetworks: [], + }; + + mockFetchMultiChainBalancesV4.mockResolvedValue(responseWithNaNBalance); + + const result = await balanceFetcher.fetch({ + chainIds: [MOCK_CHAIN_ID], + queryAllAccounts: false, + selectedAccount: MOCK_ADDRESS_1 as ChecksumAddress, + allAccounts: MOCK_INTERNAL_ACCOUNTS, + }); + + // Should have native token (guaranteed) and failed balance + expect(result.balances).toHaveLength(2); + + const failedBalance = result.balances.find( + (res) => res.token === '0x6B175474E89094C44Da98b954EedeAC495271d0F', + ); + expect(failedBalance?.success).toBe(false); + expect(failedBalance?.value).toBeUndefined(); + }); + + it('should handle parallel fetching of API balances and staked balances', async () => { + // Setup contract mock with required methods + const localMockContract = { + getShares: jest.fn().mockResolvedValue({ toString: () => '0' }), + convertToAssets: jest.fn(), + }; + + // Mock the Contract constructor to return our mock contract + const mockContractConstructor = jest.requireMock( + '@ethersproject/contracts', + ).Contract; + mockContractConstructor.mockImplementation(() => localMockContract); + + const mockGetProvider = jest.fn(); + const mockProvider = { + call: jest + .fn() + .mockResolvedValue( + '0x0000000000000000000000000000000000000000000000000000000000000000', + ), + }; + mockGetProvider.mockReturnValue(mockProvider); + + const fetcherWithProvider = new AccountsApiBalanceFetcher( + 'extension', + mockGetProvider, + ); + + mockFetchMultiChainBalancesV4.mockResolvedValue(MOCK_BALANCES_RESPONSE); + + const result = await fetcherWithProvider.fetch({ + chainIds: [MOCK_CHAIN_ID], + queryAllAccounts: true, + selectedAccount: MOCK_ADDRESS_1 as ChecksumAddress, + allAccounts: MOCK_INTERNAL_ACCOUNTS, + }); + + // Verify both API balances and staked balance processing occurred + expect(mockFetchMultiChainBalancesV4).toHaveBeenCalled(); + expect(mockGetProvider).toHaveBeenCalledWith(MOCK_CHAIN_ID); + expect(result.balances.length).toBeGreaterThan(0); + }); + + it('should handle native balance tracking and guarantee (lines 304-306, 322-338)', async () => { + const responseWithMixedBalances: GetBalancesResponse = { + count: 3, + balances: [ + createMockNativeTokenBalance({ balance: '1.0' }), + createMockERCTokenBalance({ + name: 'Dai', + balance: '100.0', + accountAddress: + 'eip155:1:0x742d35cc6675c4f17f41140100aa83a4b1fa4c82', + }), + // Missing native balance for second address + ], + unprocessedNetworks: [], + }; + + mockFetchMultiChainBalancesV4.mockResolvedValue( + responseWithMixedBalances, + ); + + const result = await balanceFetcher.fetch({ + chainIds: [MOCK_CHAIN_ID], + queryAllAccounts: true, + selectedAccount: MOCK_ADDRESS_1 as ChecksumAddress, + allAccounts: MOCK_INTERNAL_ACCOUNTS, + }); + + // Should have guaranteed native balances for both addresses + const nativeBalances = result.balances.filter( + (res) => res.token === ZERO_ADDRESS, + ); + expect(nativeBalances).toHaveLength(2); + + const addr1Native = nativeBalances.find( + (res) => res.account === MOCK_ADDRESS_1, + ); + const addr2Native = nativeBalances.find( + (res) => res.account === MOCK_ADDRESS_2, + ); + + expect(addr1Native?.value).toStrictEqual(new BN('1000000000000000000')); // 1 ETH from API + expect(addr2Native?.value).toStrictEqual(new BN('0')); // Zero balance (guaranteed) + }); + }); + + describe('staked balance internal method coverage', () => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + let mockProvider: any; + let mockGetProvider: jest.Mock; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + let mockContract: any; + + beforeEach(() => { + // Setup contract mock with required methods + mockContract = { + getShares: jest.fn(), + convertToAssets: jest.fn(), + }; + + // Mock the Contract constructor to return our mock contract + const mockContractConstructor = jest.requireMock( + '@ethersproject/contracts', + ).Contract; + mockContractConstructor.mockImplementation(() => mockContract); + + mockProvider = { + call: jest.fn(), + }; + mockGetProvider = jest.fn().mockReturnValue(mockProvider); + balanceFetcher = new AccountsApiBalanceFetcher( + 'extension', + mockGetProvider, + ); + }); + + it('should test full staked balance flow with successful shares and conversion', async () => { + // Mock successful getShares call with BigNumber-like object + const mockShares = { + toString: (): string => '1000000000000000000', // 1 share + gt: jest.fn().mockReturnValue(true), // shares > 0 + }; + mockContract.getShares.mockResolvedValue(mockShares); + + // Mock successful convertToAssets call + const mockAssets = { + toString: (): string => '2000000000000000000', // 2 ETH equivalent + }; + mockContract.convertToAssets.mockResolvedValue(mockAssets); + + mockFetchMultiChainBalancesV4.mockResolvedValue({ + count: 0, + balances: [], + unprocessedNetworks: [], + }); + + const result = await balanceFetcher.fetch({ + chainIds: [MOCK_CHAIN_ID], + queryAllAccounts: false, + selectedAccount: MOCK_ADDRESS_1 as ChecksumAddress, + allAccounts: MOCK_INTERNAL_ACCOUNTS, + }); + + // Should include staked balance + const stakedBalance = result.balances.find( + (res) => res.token === STAKING_CONTRACT_ADDRESS, + ); + expect(stakedBalance).toBeDefined(); + expect(stakedBalance?.success).toBe(true); + expect(stakedBalance?.value).toStrictEqual(new BN('2000000000000000000')); + }); + + it('should handle contract call failures in staking flow', async () => { + // Mock getShares to fail + mockContract.getShares.mockRejectedValue(new Error('Contract error')); + + mockFetchMultiChainBalancesV4.mockResolvedValue({ + count: 0, + balances: [], + unprocessedNetworks: [], + }); + + const result = await balanceFetcher.fetch({ + chainIds: [MOCK_CHAIN_ID], + queryAllAccounts: false, + selectedAccount: MOCK_ADDRESS_1 as ChecksumAddress, + allAccounts: MOCK_INTERNAL_ACCOUNTS, + }); + + // Should include failed staked balance when contract calls fail + const stakedBalance = result.balances.find( + (res) => res.token === STAKING_CONTRACT_ADDRESS, + ); + expect(stakedBalance).toBeDefined(); + expect(stakedBalance?.success).toBe(false); + }); + + it('should handle conversion failures after successful shares fetch', async () => { + // Mock successful getShares with BigNumber-like object + const mockShares = { + toString: (): string => '1000000000000000000', + gt: jest.fn().mockReturnValue(true), // shares > 0 + }; + mockContract.getShares.mockResolvedValue(mockShares); + + // Mock failed convertToAssets + mockContract.convertToAssets.mockRejectedValue( + new Error('Conversion failed'), + ); + + mockFetchMultiChainBalancesV4.mockResolvedValue({ + count: 0, + balances: [], + unprocessedNetworks: [], + }); + + const result = await balanceFetcher.fetch({ + chainIds: [MOCK_CHAIN_ID], + queryAllAccounts: false, + selectedAccount: MOCK_ADDRESS_1 as ChecksumAddress, + allAccounts: MOCK_INTERNAL_ACCOUNTS, + }); + + // Should include failed staked balance when conversion fails + const stakedBalance = result.balances.find( + (res) => res.token === STAKING_CONTRACT_ADDRESS, + ); + expect(stakedBalance).toBeDefined(); + expect(stakedBalance?.success).toBe(false); + }); + + it('should handle zero shares from staking contract', async () => { + // Mock getShares returning zero with BigNumber-like object + const mockZeroShares = { + toString: (): string => '0', + gt: jest.fn().mockReturnValue(false), // shares = 0, not > 0 + }; + mockContract.getShares.mockResolvedValue(mockZeroShares); + + mockFetchMultiChainBalancesV4.mockResolvedValue({ + count: 0, + balances: [], + unprocessedNetworks: [], + }); + + const result = await balanceFetcher.fetch({ + chainIds: [MOCK_CHAIN_ID], + queryAllAccounts: false, + selectedAccount: MOCK_ADDRESS_1 as ChecksumAddress, + allAccounts: MOCK_INTERNAL_ACCOUNTS, + }); + + // Should include staked balance with zero value when shares are zero + const stakedBalance = result.balances.find( + (res) => res.token === STAKING_CONTRACT_ADDRESS, + ); + expect(stakedBalance).toBeDefined(); + expect(stakedBalance?.success).toBe(true); + expect(stakedBalance?.value).toStrictEqual(new BN('0')); + }); + + it('should handle multiple addresses with staking', async () => { + // Mock different shares for different addresses with BigNumber-like objects + const mockAddr1Shares = { + toString: (): string => '1000000000000000000', // addr1: 1 share + gt: jest.fn().mockReturnValue(true), // shares > 0 + }; + const mockAddr2Shares = { + toString: (): string => '0', // addr2: 0 shares + gt: jest.fn().mockReturnValue(false), // shares = 0 + }; + + mockContract.getShares + .mockResolvedValueOnce(mockAddr1Shares) + .mockResolvedValueOnce(mockAddr2Shares); + + mockContract.convertToAssets.mockResolvedValueOnce({ + toString: () => '2000000000000000000', + }); // addr1: 2 ETH + + mockFetchMultiChainBalancesV4.mockResolvedValue({ + count: 0, + balances: [], + unprocessedNetworks: [], + }); + + const result = await balanceFetcher.fetch({ + chainIds: [MOCK_CHAIN_ID], + queryAllAccounts: true, + selectedAccount: MOCK_ADDRESS_1 as ChecksumAddress, + allAccounts: MOCK_INTERNAL_ACCOUNTS, + }); + + // Should include staked balance entries for both addresses + const stakedBalances = result.balances.filter( + (res) => res.token === STAKING_CONTRACT_ADDRESS, + ); + expect(stakedBalances).toHaveLength(2); + + // First address should have non-zero balance + const addr1Balance = stakedBalances.find( + (res) => res.account === MOCK_ADDRESS_1, + ); + expect(addr1Balance).toBeDefined(); + expect(addr1Balance?.success).toBe(true); + expect(addr1Balance?.value).toStrictEqual(new BN('2000000000000000000')); + + // Second address should have zero balance + const addr2Balance = stakedBalances.find( + (res) => res.account === MOCK_ADDRESS_2, + ); + expect(addr2Balance).toBeDefined(); + expect(addr2Balance?.success).toBe(true); + expect(addr2Balance?.value).toStrictEqual(new BN('0')); + }); + }); + + describe('API error handling and recovery', () => { + beforeEach(() => { + balanceFetcher = new AccountsApiBalanceFetcher('extension'); + }); + + it('should throw error when API fails (error propagates for RPC fallback)', async () => { + // Setup successful staking contract (but it won't be reached) + const mockShares = { + toString: (): string => '1000000000000000000', + gt: jest.fn().mockReturnValue(true), + }; + const mockAssets = { + toString: (): string => '2000000000000000000', + }; + + const localMockContract = { + getShares: jest.fn().mockResolvedValue(mockShares), + convertToAssets: jest.fn().mockResolvedValue(mockAssets), + }; + + const mockContractConstructor = jest.requireMock( + '@ethersproject/contracts', + ).Contract; + mockContractConstructor.mockImplementation(() => localMockContract); + + const mockProvider = { call: jest.fn() }; + const mockGetProvider = jest.fn().mockReturnValue(mockProvider); + + const fetcherWithProvider = new AccountsApiBalanceFetcher( + 'extension', + mockGetProvider, + ); + + // Make API fail - safelyExecuteWithTimeout will return undefined + mockFetchMultiChainBalancesV4.mockRejectedValue(new Error('API failure')); + + // Should now throw error immediately to allow RPC fallback in TokenBalancesController + await expect( + fetcherWithProvider.fetch({ + chainIds: [MOCK_CHAIN_ID], + queryAllAccounts: false, + selectedAccount: MOCK_ADDRESS_1 as ChecksumAddress, + allAccounts: MOCK_INTERNAL_ACCOUNTS, + }), + ).rejects.toThrow('Accounts API request timed out or failed'); + }); + }); + + describe('precision handling in balance conversion', () => { + beforeEach(() => { + balanceFetcher = new AccountsApiBalanceFetcher('extension'); + }); + + it('should correctly handle high precision balances like PEPE token case', async () => { + const highPrecisionResponse: GetBalancesResponse = { + count: 1, + balances: [ + { + object: 'token', + address: '0x25d887ce7a35172c62febfd67a1856f20faebb00', + symbol: 'PEPE', + name: 'Pepe', + decimals: 18, + chainId: 42161, + balance: '568013.300780982071882412', + accountAddress: + 'eip155:42161:0xd8da6bf26964af9d7eed9e03e53415d37aa96045', + }, + ], + unprocessedNetworks: [], + }; + + mockFetchMultiChainBalancesV4.mockResolvedValue(highPrecisionResponse); + + const result = await balanceFetcher.fetch({ + chainIds: ['0xa4b1' as ChainIdHex], // Arbitrum + queryAllAccounts: false, + selectedAccount: + '0xd8da6bf26964af9d7eed9e03e53415d37aa96045' as ChecksumAddress, + allAccounts: MOCK_INTERNAL_ACCOUNTS, + }); + + expect(result.balances).toHaveLength(2); // PEPE token + native token guarantee + + const pepeBalance = result.balances.find( + (res) => res.token === '0x25d887ce7a35172c62febfd67a1856f20faebb00', + ); + expect(pepeBalance).toBeDefined(); + expect(pepeBalance?.success).toBe(true); + + // Expected: 568013.300780982071882412 with 18 decimals + // = 568013300780982071882412 (no precision loss) + expect(pepeBalance?.value).toStrictEqual( + new BN('568013300780982071882412'), + ); + }); + + it('should handle balances with fewer decimal places than token decimals', async () => { + const responseWithShortDecimals: GetBalancesResponse = { + count: 1, + balances: [ + { + object: 'token', + address: '0x6B175474E89094C44Da98b954EedeAC495271d0F', + symbol: 'DAI', + name: 'Dai', + decimals: 18, + chainId: 1, + balance: '100.5', // Only 1 decimal place, needs padding + accountAddress: + 'eip155:1:0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045', + }, + ], + unprocessedNetworks: [], + }; + + mockFetchMultiChainBalancesV4.mockResolvedValue( + responseWithShortDecimals, + ); + + const result = await balanceFetcher.fetch({ + chainIds: [MOCK_CHAIN_ID], + queryAllAccounts: false, + selectedAccount: MOCK_ADDRESS_1 as ChecksumAddress, + allAccounts: MOCK_INTERNAL_ACCOUNTS, + }); + + const daiBalance = result.balances.find( + (res) => res.token === '0x6B175474E89094C44Da98b954EedeAC495271d0F', + ); + expect(daiBalance?.success).toBe(true); + + // Expected: 100.5 with 18 decimals = 100500000000000000000 + expect(daiBalance?.value).toStrictEqual(new BN('100500000000000000000')); + }); + + it('should handle balances with no decimal places', async () => { + const responseWithIntegerBalance: GetBalancesResponse = { + count: 1, + balances: [ + { + object: 'token', + address: '0xA0b86a33E6441c86c33E1C6B9cD964c0BA2A86B', + symbol: 'USDC', + name: 'USD Coin', + decimals: 6, + chainId: 1, + balance: '1000', // No decimal point + accountAddress: + 'eip155:1:0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045', + }, + ], + unprocessedNetworks: [], + }; + + mockFetchMultiChainBalancesV4.mockResolvedValue( + responseWithIntegerBalance, + ); + + const result = await balanceFetcher.fetch({ + chainIds: [MOCK_CHAIN_ID], + queryAllAccounts: false, + selectedAccount: MOCK_ADDRESS_1 as ChecksumAddress, + allAccounts: MOCK_INTERNAL_ACCOUNTS, + }); + + const usdcBalance = result.balances.find( + (res) => res.token === '0xA0b86a33E6441c86c33E1C6B9cD964c0BA2A86B', + ); + expect(usdcBalance?.success).toBe(true); + + // Expected: 1000 with 6 decimals = 1000000000 + expect(usdcBalance?.value).toStrictEqual(new BN('1000000000')); + }); + + it('should handle balances with more decimal places than token decimals', async () => { + const responseWithExtraDecimals: GetBalancesResponse = { + count: 1, + balances: [ + createMockERCTokenBalance({ + address: '0xA0b86a33E6441c86c33E1C6B9cD964c0BA2A86B', + symbol: 'USDC', + name: 'USD Coin', + decimals: 6, + balance: '100.1234567890123', // 13 decimal places, token has 6 + }), + ], + unprocessedNetworks: [], + }; + + mockFetchMultiChainBalancesV4.mockResolvedValue( + responseWithExtraDecimals, + ); + + const result = await balanceFetcher.fetch({ + chainIds: [MOCK_CHAIN_ID], + queryAllAccounts: false, + selectedAccount: MOCK_ADDRESS_1 as ChecksumAddress, + allAccounts: MOCK_INTERNAL_ACCOUNTS, + }); + + const usdcBalance = result.balances.find( + (res) => res.token === '0xA0b86a33E6441c86c33E1C6B9cD964c0BA2A86B', + ); + expect(usdcBalance?.success).toBe(true); + + // Expected: 100.1234567890123 truncated to 6 decimals = 100.123456 = 100123456 + expect(usdcBalance?.value).toStrictEqual(new BN('100123456')); + }); + + it('should handle very large numbers with high precision', async () => { + const responseWithLargeNumber: GetBalancesResponse = { + count: 1, + balances: [ + createMockERCTokenBalance({ + address: '0x95aD61b0a150d79219dCF64E1E6Cc01f0B64C4cE', + symbol: 'SHIB', + name: 'Shiba Inu', + balance: '123456789123456789.123456789123456789', // Very large with high precision + }), + ], + unprocessedNetworks: [], + }; + + mockFetchMultiChainBalancesV4.mockResolvedValue(responseWithLargeNumber); + + const result = await balanceFetcher.fetch({ + chainIds: [MOCK_CHAIN_ID], + queryAllAccounts: false, + selectedAccount: MOCK_ADDRESS_1 as ChecksumAddress, + allAccounts: MOCK_INTERNAL_ACCOUNTS, + }); + + const shibBalance = result.balances.find( + (res) => res.token === '0x95aD61b0a150d79219dCF64E1E6Cc01f0B64C4cE', + ); + expect(shibBalance?.success).toBe(true); + + // Expected: 123456789123456789.123456789123456789 with 18 decimals + // = 123456789123456789123456789123456789 + expect(shibBalance?.value).toStrictEqual( + new BN('123456789123456789123456789123456789'), + ); + }); + + it('should handle zero balances correctly', async () => { + const responseWithZeroBalance: GetBalancesResponse = { + count: 1, + balances: [createMockERCTokenBalance({ name: 'Dai', balance: '0' })], + unprocessedNetworks: [], + }; + + mockFetchMultiChainBalancesV4.mockResolvedValue(responseWithZeroBalance); + + const result = await balanceFetcher.fetch({ + chainIds: [MOCK_CHAIN_ID], + queryAllAccounts: false, + selectedAccount: MOCK_ADDRESS_1 as ChecksumAddress, + allAccounts: MOCK_INTERNAL_ACCOUNTS, + }); + + const daiBalance = result.balances.find( + (res) => res.token === '0x6B175474E89094C44Da98b954EedeAC495271d0F', + ); + expect(daiBalance?.success).toBe(true); + expect(daiBalance?.value).toStrictEqual(new BN('0')); + }); + + it('should handle balance starting with decimal point', async () => { + const responseWithDecimalStart: GetBalancesResponse = { + count: 1, + balances: [ + createMockERCTokenBalance({ + name: 'Dai', + balance: '.123456789', // Starts with decimal point + }), + ], + unprocessedNetworks: [], + }; + + mockFetchMultiChainBalancesV4.mockResolvedValue(responseWithDecimalStart); + + const result = await balanceFetcher.fetch({ + chainIds: [MOCK_CHAIN_ID], + queryAllAccounts: false, + selectedAccount: MOCK_ADDRESS_1 as ChecksumAddress, + allAccounts: MOCK_INTERNAL_ACCOUNTS, + }); + + const daiBalance = result.balances.find( + (res) => res.token === '0x6B175474E89094C44Da98b954EedeAC495271d0F', + ); + expect(daiBalance?.success).toBe(true); + + // Expected: .123456789 with 18 decimals = 0.123456789000000000 = 123456789000000000 + expect(daiBalance?.value).toStrictEqual(new BN('123456789000000000')); + }); + + it('should maintain precision compared to old floating-point method', async () => { + // This test demonstrates that the new method maintains precision where the old method would fail + const precisionTestResponse: GetBalancesResponse = { + count: 1, + balances: [ + createMockERCTokenBalance({ + name: 'Dai', + balance: '1234567890123456.123456789012345678', // High precision that would cause floating-point issues + }), + ], + unprocessedNetworks: [], + }; + + mockFetchMultiChainBalancesV4.mockResolvedValue(precisionTestResponse); + + const result = await balanceFetcher.fetch({ + chainIds: [MOCK_CHAIN_ID], + queryAllAccounts: false, + selectedAccount: MOCK_ADDRESS_1 as ChecksumAddress, + allAccounts: MOCK_INTERNAL_ACCOUNTS, + }); + + const daiBalance = result.balances.find( + (res) => res.token === '0x6B175474E89094C44Da98b954EedeAC495271d0F', + ); + expect(daiBalance?.success).toBe(true); + + // New method: 1234567890123456.123456789012345678 with 18 decimals + // = 1234567890123456 + 123456789012345678 = 1234567890123456123456789012345678 + expect(daiBalance?.value).toStrictEqual( + new BN('1234567890123456123456789012345678'), + ); + + // Old method would have precision loss due to JavaScript floating-point limitations + const oldMethodCalculation = + parseFloat('1234567890123456.123456789012345678') * 10 ** 18; + + // The new method should maintain all digits precisely, while old method loses precision + // We can verify this by checking that our result has the expected exact digits + expect(daiBalance?.value?.toString()).toBe( + '1234567890123456123456789012345678', + ); + + // And verify that the old method would produce different (less precise) results + expect(oldMethodCalculation.toString()).toContain('e+'); // Should be in scientific notation + }); + + it('should handle balance string with only integer part', async () => { + // Test the default destructuring values when balance has no decimal point + const responseWithZeroBalance: GetBalancesResponse = { + count: 1, + balances: [ + createMockNativeTokenBalance({ + balance: '0', // Just "0", no decimal point - tests integerPart='0', decimalPart='' + }), + ], + unprocessedNetworks: [], + }; + + mockFetchMultiChainBalancesV4.mockResolvedValue(responseWithZeroBalance); + + const result = await balanceFetcher.fetch({ + chainIds: [MOCK_CHAIN_ID], + queryAllAccounts: false, + selectedAccount: MOCK_ADDRESS_1 as ChecksumAddress, + allAccounts: MOCK_INTERNAL_ACCOUNTS, + }); + + const ethBalance = result.balances.find( + (res) => res.token === ZERO_ADDRESS, + ); + expect(ethBalance?.success).toBe(true); + expect(ethBalance?.value).toStrictEqual(new BN('0')); + }); + + it('should accumulate balances correctly in batch processing', async () => { + // This test explicitly verifies balances from multiple batches are combined correctly + const largeAccountList: InternalAccount[] = []; + const caipAddresses: string[] = []; + + // Create 60 accounts to force batching (50 per batch) + for (let i = 0; i < 60; i++) { + const address = `0x${i.toString(16).padStart(40, '0')}` as const; + largeAccountList.push({ + id: i.toString(), + address, + type: 'eip155:eoa', + options: {}, + methods: [], + scopes: [], + metadata: { + name: `Account ${i}`, + importTime: Date.now(), + keyring: { type: 'HD Key Tree' }, + }, + }); + caipAddresses.push(`eip155:1:${address}`); + } + + // Mock batching behavior + mockReduceInBatchesSerially.mockImplementation( + async ({ + eachBatch, + initialResult, + }: { + eachBatch: ( + result: unknown, + batch: unknown, + index: number, + ) => Promise; + initialResult: unknown; + }) => { + const batch1 = caipAddresses.slice(0, 50); + const batch2 = caipAddresses.slice(50); + + // First batch: workingResult will be [] (initialResult) + let result = await eachBatch(initialResult, batch1, 0); + // Second batch: workingResult will be the result from batch1å + result = await eachBatch(result, batch2, 1); + + return result; + }, + ); + + const batch1Response: GetBalancesResponse = { + count: 1, + balances: [ + { + object: 'token', + address: '0x0000000000000000000000000000000000000000', + symbol: 'ETH', + name: 'Ether', + decimals: 18, + chainId: 1, + balance: '1.0', + accountAddress: `eip155:1:${caipAddresses[0].split(':')[2]}`, + }, + ], + unprocessedNetworks: [], + }; + + const batch2Response: GetBalancesResponse = { + count: 1, + balances: [ + { + object: 'token', + address: '0x0000000000000000000000000000000000000000', + symbol: 'ETH', + name: 'Ether', + decimals: 18, + chainId: 1, + balance: '2.0', + accountAddress: `eip155:1:${caipAddresses[50].split(':')[2]}`, + }, + ], + unprocessedNetworks: [], + }; + + mockFetchMultiChainBalancesV4 + .mockResolvedValueOnce(batch1Response) + .mockResolvedValueOnce(batch2Response); + + const result = await balanceFetcher.fetch({ + chainIds: [MOCK_CHAIN_ID], + queryAllAccounts: true, + selectedAccount: MOCK_ADDRESS_1 as ChecksumAddress, + allAccounts: largeAccountList, + }); + + // Should have called API twice (2 batches) + expect(mockFetchMultiChainBalancesV4).toHaveBeenCalledTimes(2); + + // Should have balances from both batches accumulated + // The batching logic combines results from multiple API calls + const ethBalances = result.balances.filter( + (res) => res.token === ZERO_ADDRESS, + ); + + // Should have at least 2 native token balances (from both batches + guarantees for all accounts) + expect(ethBalances.length).toBeGreaterThanOrEqual(2); + + // Verify that we have successful balance entries + const successfulBalances = ethBalances.filter((b) => b.success); + expect(successfulBalances.length).toBeGreaterThan(0); + }); + + it('should handle falsy workingResult in batch accumulation', async () => { + // This test explicitly covers the "|| []" fallback + // when workingResult is undefined/null (first batch) + const largeAccountList: InternalAccount[] = []; + const caipAddresses: string[] = []; + + // Create 55 accounts to force batching + for (let i = 0; i < 55; i++) { + const address = `0x${i.toString(16).padStart(40, '0')}` as const; + largeAccountList.push({ + id: i.toString(), + address, + type: 'eip155:eoa', + options: {}, + methods: [], + scopes: [], + metadata: { + name: `Account ${i}`, + importTime: Date.now(), + keyring: { type: 'HD Key Tree' }, + }, + }); + caipAddresses.push(`eip155:1:${address}`); + } + + // Mock batching to pass undefined/null as workingResult for first batch + mockReduceInBatchesSerially.mockImplementation( + async ({ + eachBatch, + }: { + eachBatch: ( + result: unknown, + batch: unknown, + index: number, + ) => Promise; + initialResult: unknown; + }) => { + const batch1 = caipAddresses.slice(0, 50); + const batch2 = caipAddresses.slice(50); + + // Pass undefined as first argument to test "|| []" branch + // This simulates the case where workingResult might be undefined + let result = await eachBatch(undefined, batch1, 0); + result = await eachBatch(result, batch2, 1); + + return result; + }, + ); + + const batch1Response: GetBalancesResponse = { + count: 1, + balances: [ + { + object: 'token', + address: '0x0000000000000000000000000000000000000000', + symbol: 'ETH', + name: 'Ether', + decimals: 18, + chainId: 1, + balance: '5.0', + accountAddress: `eip155:1:${caipAddresses[0].split(':')[2]}`, + }, + ], + unprocessedNetworks: [], + }; + + const batch2Response: GetBalancesResponse = { + count: 1, + balances: [ + { + object: 'token', + address: '0x0000000000000000000000000000000000000000', + symbol: 'ETH', + name: 'Ether', + decimals: 18, + chainId: 1, + balance: '10.0', + accountAddress: `eip155:1:${caipAddresses[50].split(':')[2]}`, + }, + ], + unprocessedNetworks: [], + }; + + mockFetchMultiChainBalancesV4 + .mockResolvedValueOnce(batch1Response) + .mockResolvedValueOnce(batch2Response); + + const result = await balanceFetcher.fetch({ + chainIds: [MOCK_CHAIN_ID], + queryAllAccounts: true, + selectedAccount: MOCK_ADDRESS_1 as ChecksumAddress, + allAccounts: largeAccountList, + }); + + // When workingResult is undefined, it uses [] via the "|| []" fallback + expect(mockFetchMultiChainBalancesV4).toHaveBeenCalledTimes(2); + + // Should still have balances from both batches despite undefined workingResult + const ethBalances = result.balances.filter( + (res) => res.token === ZERO_ADDRESS, + ); + expect(ethBalances.length).toBeGreaterThan(0); + }); + + it('should throw error when API fails (safelyExecuteWithTimeout returns undefined)', async () => { + const mockApiError = new Error('Complete API failure'); + + // Mock fetchMultiChainBalancesV4 to throw - safelyExecuteWithTimeout will catch and return undefined + mockFetchMultiChainBalancesV4.mockRejectedValue(mockApiError); + + // Create a balance fetcher WITHOUT staking provider + const balanceFetcherNoStaking = new AccountsApiBalanceFetcher( + 'extension', + ); + + // Should throw immediately when apiResponse is undefined + await expect( + balanceFetcherNoStaking.fetch({ + chainIds: [MOCK_CHAIN_ID], + queryAllAccounts: false, + selectedAccount: MOCK_ADDRESS_1 as ChecksumAddress, + allAccounts: MOCK_INTERNAL_ACCOUNTS, + }), + ).rejects.toThrow('Accounts API request timed out or failed'); + }); + }); +}); diff --git a/packages/assets-controllers/src/multi-chain-accounts-service/api-balance-fetcher.ts b/packages/assets-controllers/src/multi-chain-accounts-service/api-balance-fetcher.ts new file mode 100644 index 00000000000..3e52a5336b6 --- /dev/null +++ b/packages/assets-controllers/src/multi-chain-accounts-service/api-balance-fetcher.ts @@ -0,0 +1,520 @@ +import type { BigNumber } from '@ethersproject/bignumber'; +import { Contract } from '@ethersproject/contracts'; +import type { Web3Provider } from '@ethersproject/providers'; +import { + safelyExecute, + safelyExecuteWithTimeout, + toHex, + toChecksumHexAddress, +} from '@metamask/controller-utils'; +import type { InternalAccount } from '@metamask/keyring-internal-api'; +import type { CaipAccountAddress, CaipChainId, Hex } from '@metamask/utils'; +import { parseCaipChainId } from '@metamask/utils'; +import BN from 'bn.js'; + +import { STAKING_CONTRACT_ADDRESS_BY_CHAINID } from '../AssetsContractController.js'; +import { + accountAddressToCaipReference, + reduceInBatchesSerially, + SupportedStakedBalanceNetworks, +} from '../assetsUtil.js'; +import { SUPPORTED_NETWORKS_ACCOUNTS_API_V4 } from '../constants.js'; +import { fetchMultiChainBalancesV4 } from './multi-chain-accounts.js'; +import type { GetBalancesResponse } from './types.js'; + +// Maximum number of account addresses that can be sent to the accounts API in a single request +const ACCOUNTS_API_BATCH_SIZE = 20; + +// Timeout for accounts API requests (10 seconds) +const ACCOUNTS_API_TIMEOUT_MS = 10_000; + +export type ChainIdHex = Hex; +export type ChecksumAddress = Hex; + +export type ProcessedBalance = { + success: boolean; + value?: BN; + account: ChecksumAddress | string; + token: ChecksumAddress; + chainId: ChainIdHex; +}; + +/** + * Account -> ChainId -> TokenAddress[] + */ +export type UnprocessedTokens = { + [account: string]: { + [chainId: ChainIdHex]: string[]; + }; +}; + +export type BalanceFetchResult = { + balances: ProcessedBalance[]; + unprocessedChainIds?: ChainIdHex[]; + unprocessedTokens?: UnprocessedTokens; +}; + +export type BalanceFetcher = { + supports(chainId: ChainIdHex): boolean; + fetch(input: { + chainIds: ChainIdHex[]; + queryAllAccounts: boolean; + selectedAccount: ChecksumAddress; + allAccounts: InternalAccount[]; + jwtToken?: string; + unprocessedTokens?: UnprocessedTokens; // API Balance Fetcher does not process unprocessed tokens + }): Promise; +}; + +const checksum = (addr: string): ChecksumAddress => + toChecksumHexAddress(addr) as ChecksumAddress; + +const toCaipAccount = ( + chainId: ChainIdHex, + account: ChecksumAddress, +): CaipAccountAddress => accountAddressToCaipReference(chainId, account); + +export type GetProviderFunction = (chainId: ChainIdHex) => Web3Provider; + +export class AccountsApiBalanceFetcher implements BalanceFetcher { + readonly #platform: 'extension' | 'mobile' = 'extension'; + + readonly #getProvider?: GetProviderFunction; + + readonly #getUserTokens?: () => { + [accountId: ChecksumAddress]: { + [chainId: ChainIdHex]: { [tokenAddress: ChecksumAddress]: unknown }; + }; + }; + + constructor( + platform: 'extension' | 'mobile' = 'extension', + getProvider?: GetProviderFunction, + getUserTokens?: () => { + [account: ChecksumAddress]: { + [chainId: ChainIdHex]: { [tokenAddress: ChecksumAddress]: unknown }; + }; + }, + ) { + this.#platform = platform; + this.#getProvider = getProvider; + this.#getUserTokens = getUserTokens; + } + + supports(chainId: ChainIdHex): boolean { + return SUPPORTED_NETWORKS_ACCOUNTS_API_V4.includes(chainId); + } + + async #fetchStakedBalances( + addrs: CaipAccountAddress[], + ): Promise { + // Return empty array if no provider is available for blockchain calls + if (!this.#getProvider) { + return []; + } + + const results: ProcessedBalance[] = []; + + // Group addresses by chain ID + const addressesByChain: Record = {}; + + for (const caipAddr of addrs) { + const [, chainRef, address] = caipAddr.split(':'); + const chainId = toHex(parseInt(chainRef, 10)); + const checksumAddress = checksum(address); + + if (!addressesByChain[chainId]) { + addressesByChain[chainId] = []; + } + addressesByChain[chainId].push(checksumAddress); + } + + // Process each supported chain + for (const [chainId, addresses] of Object.entries(addressesByChain)) { + const chainIdHex = chainId as ChainIdHex; + + // Only fetch staked balance on supported networks (mainnet and hoodi) + if ( + ![ + SupportedStakedBalanceNetworks.Mainnet, + SupportedStakedBalanceNetworks.Hoodi, + ].includes(chainIdHex as SupportedStakedBalanceNetworks) + ) { + continue; + } + + // Only fetch staked balance if contract address exists + if (!(chainIdHex in STAKING_CONTRACT_ADDRESS_BY_CHAINID)) { + continue; + } + + const contractAddress = STAKING_CONTRACT_ADDRESS_BY_CHAINID[chainIdHex]; + const provider = this.#getProvider(chainIdHex); + + const 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', + }, + ]; + + try { + const contract = new Contract(contractAddress, abi, provider); + + // Get shares for each address + for (const address of addresses) { + try { + const shares = await safelyExecute(() => + contract.getShares(address), + ); + + if (shares && (shares as BigNumber).gt(0)) { + // Convert shares to assets (actual staked ETH amount) + const assets = await safelyExecute(() => + contract.convertToAssets(shares), + ); + + if (assets) { + results.push({ + success: true, + value: new BN((assets as BigNumber).toString()), + account: address, + token: checksum(contractAddress), + chainId: chainIdHex, + }); + } + } else { + // Return zero balance for accounts with no staked assets + results.push({ + success: true, + value: new BN('0'), + account: address, + token: checksum(contractAddress), + chainId: chainIdHex, + }); + } + } catch (error) { + // Log error and continue with next address + console.error( + `Error fetching staked balance for ${address}:`, + error, + ); + results.push({ + success: false, + account: address, + token: checksum(contractAddress), + chainId: chainIdHex, + }); + } + } + } catch (error) { + console.error( + `Error setting up staking contract for chain ${chainId}:`, + error, + ); + } + } + + return results; + } + + async #fetchBalances( + addrs: CaipAccountAddress[], + jwtToken?: string, + ): Promise { + // If we have fewer than or equal to the batch size, make a single request + if (addrs.length <= ACCOUNTS_API_BATCH_SIZE) { + return await fetchMultiChainBalancesV4( + { accountAddresses: addrs }, + this.#platform, + jwtToken, + ); + } + + // Otherwise, batch the requests to respect the 50-element limit + type BalanceData = Awaited< + ReturnType + >['balances'][number]; + + type ResponseData = Awaited>; + + const allUnprocessedNetworks = new Set(); + const allBalances = await reduceInBatchesSerially< + CaipAccountAddress, + BalanceData[] + >({ + values: addrs, + batchSize: ACCOUNTS_API_BATCH_SIZE, + eachBatch: async (workingResult, batch) => { + const response = await fetchMultiChainBalancesV4( + { accountAddresses: batch }, + this.#platform, + jwtToken, + ); + // Collect unprocessed networks from each batch + if (response.unprocessedNetworks) { + response.unprocessedNetworks.forEach((network) => + allUnprocessedNetworks.add(network), + ); + } + return [...(workingResult || []), ...response.balances]; + }, + initialResult: [], + }); + + return { + balances: allBalances, + unprocessedNetworks: Array.from(allUnprocessedNetworks), + } as ResponseData; + } + + async fetch({ + chainIds, + queryAllAccounts, + selectedAccount, + allAccounts, + jwtToken, + }: Parameters[0]): Promise { + const caipAddrs: CaipAccountAddress[] = []; + + for (const chainId of chainIds.filter((chain) => this.supports(chain))) { + if (queryAllAccounts) { + allAccounts.forEach((a) => + caipAddrs.push(toCaipAccount(chainId, a.address as ChecksumAddress)), + ); + } else { + caipAddrs.push(toCaipAccount(chainId, selectedAccount)); + } + } + + if (!caipAddrs.length) { + return { balances: [] }; + } + + // Let errors propagate to TokenBalancesController for RPC fallback + // Use timeout to prevent hanging API calls (30 seconds) + const apiResponse = await safelyExecuteWithTimeout( + () => this.#fetchBalances(caipAddrs, jwtToken), + false, // don't log error here, let it propagate + ACCOUNTS_API_TIMEOUT_MS, + ); + + // If API call timed out or failed, throw error to trigger RPC fallback + if (!apiResponse) { + throw new Error('Accounts API request timed out or failed'); + } + + // Extract unprocessed networks and convert to hex chain IDs + // V4 API returns CAIP chain IDs like 'eip155:1329', need to parse them + // V2 API returns decimal numbers, handle both cases + const unprocessedChainIds: ChainIdHex[] | undefined = apiResponse + .unprocessedNetworks?.length + ? apiResponse.unprocessedNetworks.map((network) => { + if (typeof network === 'string') { + // CAIP chain ID format: 'eip155:1329' + return toHex(parseCaipChainId(network as CaipChainId).reference); + } + // Decimal number format + return toHex(network); + }) + : undefined; + + const stakedBalances = await this.#fetchStakedBalances(caipAddrs); + + const results: ProcessedBalance[] = []; + + // Collect all unique addresses and chains from the CAIP addresses + const addressChainMap = new Map>(); + caipAddrs.forEach((caipAddr) => { + const [, chainRef, address] = caipAddr.split(':'); + const chainId = toHex(parseInt(chainRef, 10)); + const checksumAddress = checksum(address); + + if (!addressChainMap.has(checksumAddress)) { + addressChainMap.set(checksumAddress, new Set()); + } + addressChainMap.get(checksumAddress)?.add(chainId); + }); + + // Ensure native token entries exist for all addresses on all requested chains + const ZERO_ADDRESS = + '0x0000000000000000000000000000000000000000' as ChecksumAddress; + const nativeBalancesFromAPI = new Map(); // key: `${accountAddress}-${chainId}` + const nonNativeBalancesFromAPI = new Map(); // key: `${accountAddress}-${tokenAddress}-${chainId}` + + // Process regular API balances + if (apiResponse.balances) { + const apiBalances = apiResponse.balances.flatMap( + (b: GetBalancesResponse['balances'][number]) => { + const addressPart = b.accountAddress?.split(':')[2]; + if (!addressPart) { + return []; + } + const account = checksum(addressPart); + const token = checksum(b.address); + // Use original address for zero address tokens, checksummed for others + // TODO: this is a hack to get the correct account address type but needs to be fixed + // by mgrating tokenBalancesController to checksum addresses + const finalAccount: ChecksumAddress | string = + token === ZERO_ADDRESS ? account : addressPart; + const chainId = toHex(b.chainId); + + let value: BN | undefined; + try { + // Convert string balance to BN avoiding floating point precision issues + const { balance: balanceStr, decimals } = b; + + // Split the balance string into integer and decimal parts + const [integerPart = '0', decimalPart = ''] = balanceStr.split('.'); + + // Pad or truncate decimal part to match token decimals + const paddedDecimalPart = decimalPart + .padEnd(decimals, '0') + .slice(0, decimals); + + // Combine and create BN + const fullIntegerStr = integerPart + paddedDecimalPart; + value = new BN(fullIntegerStr); + } catch { + value = undefined; + } + + // Track native balances for later + if (token === ZERO_ADDRESS && value !== undefined) { + nativeBalancesFromAPI.set(`${finalAccount}-${chainId}`, value); + } + + if (token !== ZERO_ADDRESS && value !== undefined) { + nonNativeBalancesFromAPI.set( + `${finalAccount.toLowerCase()}-${token.toLowerCase()}-${chainId}`, + value, + ); + } + + return [ + { + success: value !== undefined, + value, + account: finalAccount, + token, + chainId, + }, + ]; + }, + ); + results.push(...apiBalances); + } + + const isAccountIncludedInRequest = (address: string): boolean => + queryAllAccounts + ? allAccounts.some( + (currentAccount) => + currentAccount.address.toLowerCase() === address.toLowerCase(), + ) + : selectedAccount.toLowerCase() === address.toLowerCase(); + + const unprocessedTokens: UnprocessedTokens = {}; + + const addUnprocessedToken = ( + account: string, + chainId: ChainIdHex, + tokenAddress: string, + ): void => { + unprocessedTokens[account] ??= {}; + const accountUnprocessedTokensByChain = unprocessedTokens[account]; + accountUnprocessedTokensByChain[chainId] ??= []; + const accountUnprocessedTokens = accountUnprocessedTokensByChain[chainId]; + if (!accountUnprocessedTokens.includes(tokenAddress)) { + accountUnprocessedTokens.push(tokenAddress); + } + }; + + // Add zero native balance entries for addresses that API didn't return + addressChainMap.forEach((chains, address) => { + chains.forEach((chainId) => { + const key = `${address}-${chainId}`; + const existingBalance = nativeBalancesFromAPI.get(key); + const isChainIncludedInRequest = chainIds.includes(chainId); + const isChainSupported = this.supports(chainId); + const isAccountIncluded = isAccountIncludedInRequest(address); + const shouldZeroOutBalance = + !existingBalance && + isChainIncludedInRequest && + isChainSupported && + isAccountIncluded; + + if (shouldZeroOutBalance) { + // Add zero native balance entry if API succeeded but didn't return one + results.push({ + success: true, + value: new BN('0'), + account: address as ChecksumAddress, + token: ZERO_ADDRESS, + chainId, + }); + } + }); + }); + + // Track ERC-20 balances that were not returned by Accounts API. + // These can then be fetched by a fallback fetcher (RPC) without + // overwriting potentially stale balances with zero values. + if (this.#getUserTokens) { + const userTokens = this.#getUserTokens(); + Object.entries(userTokens).forEach(([account, chains]) => { + Object.entries(chains).forEach(([chainId, tokens]) => { + Object.entries(tokens).forEach(([tokenAddress]) => { + const tokenLowerCase = tokenAddress.toLowerCase(); + const key = `${account.toLowerCase()}-${tokenLowerCase}-${chainId}`; + const isERC = tokenAddress !== ZERO_ADDRESS; + const existingBalance = nonNativeBalancesFromAPI.get(key); + const isChainIncludedInRequest = chainIds.includes(chainId as Hex); + const isChainSupported = this.supports(chainId as Hex); + const isAccountIncluded = isAccountIncludedInRequest(account); + const shouldZeroOutBalance = + !existingBalance && + isChainIncludedInRequest && + isChainSupported && + isAccountIncluded; + + if (isERC && shouldZeroOutBalance) { + addUnprocessedToken( + account.toLowerCase(), + chainId as ChainIdHex, + tokenLowerCase, + ); + } + }); + }); + }); + } + + // Add staked balances + results.push(...stakedBalances); + + return { + balances: results, + unprocessedChainIds, + unprocessedTokens: + Object.keys(unprocessedTokens).length > 0 + ? unprocessedTokens + : undefined, + }; + } +} diff --git a/packages/assets-controllers/src/multi-chain-accounts-service/index.ts b/packages/assets-controllers/src/multi-chain-accounts-service/index.ts new file mode 100644 index 00000000000..15361688b45 --- /dev/null +++ b/packages/assets-controllers/src/multi-chain-accounts-service/index.ts @@ -0,0 +1,9 @@ +export type { + GetBalancesResponse, + GetSupportedNetworksResponse, +} from './types.js'; + +export { + fetchMultiChainBalances, + fetchSupportedNetworks, +} from './multi-chain-accounts.js'; diff --git a/packages/assets-controllers/src/multi-chain-accounts-service/mocks/mock-get-balances.test.ts b/packages/assets-controllers/src/multi-chain-accounts-service/mocks/mock-get-balances.test.ts new file mode 100644 index 00000000000..af0acef7bd0 --- /dev/null +++ b/packages/assets-controllers/src/multi-chain-accounts-service/mocks/mock-get-balances.test.ts @@ -0,0 +1,95 @@ +import { + MOCK_GET_BALANCES_RESPONSE, + createMockGetBalancesResponse, +} from './mock-get-balances.js'; + +describe('mock-get-balances', () => { + describe('MOCK_GET_BALANCES_RESPONSE', () => { + it('should have the correct count', () => { + expect(MOCK_GET_BALANCES_RESPONSE.count).toBe(6); + }); + + it('should have balances array with correct length', () => { + expect(MOCK_GET_BALANCES_RESPONSE.balances).toHaveLength(8); + }); + + it('should have empty unprocessedNetworks', () => { + expect(MOCK_GET_BALANCES_RESPONSE.unprocessedNetworks).toStrictEqual([]); + }); + + it('should have native ETH token as first balance', () => { + expect(MOCK_GET_BALANCES_RESPONSE.balances[0]).toStrictEqual({ + object: 'token', + address: '0x0000000000000000000000000000000000000000', + symbol: 'ETH', + name: 'Ether', + type: 'native', + timestamp: '2015-07-30T03:26:13.000Z', + decimals: 18, + chainId: 1, + balance: '0.026380882267770930', + }); + }); + }); + + describe('createMockGetBalancesResponse', () => { + it('should create a response with correct count', () => { + const tokenAddrs = ['0xtoken1', '0xtoken2', '0xtoken3']; + const chainId = 1; + + const response = createMockGetBalancesResponse(tokenAddrs, chainId); + + expect(response.count).toBe(3); + }); + + it('should create balances for each token address', () => { + const tokenAddrs = ['0xtoken1', '0xtoken2']; + const chainId = 137; + + const response = createMockGetBalancesResponse(tokenAddrs, chainId); + + expect(response.balances).toHaveLength(2); + expect(response.balances[0].address).toBe('0xtoken1'); + expect(response.balances[1].address).toBe('0xtoken2'); + }); + + it('should set correct chainId for all balances', () => { + const tokenAddrs = ['0xtoken1']; + const chainId = 42161; + + const response = createMockGetBalancesResponse(tokenAddrs, chainId); + + expect(response.balances[0].chainId).toBe(42161); + }); + + it('should set default mock values for balance properties', () => { + const tokenAddrs = ['0xtoken1']; + const chainId = 1; + + const response = createMockGetBalancesResponse(tokenAddrs, chainId); + + expect(response.balances[0]).toStrictEqual({ + object: 'token', + address: '0xtoken1', + name: 'Mock Token', + symbol: 'MOCK', + decimals: 18, + balance: '10.18', + chainId: 1, + }); + }); + + it('should have empty unprocessedNetworks', () => { + const response = createMockGetBalancesResponse(['0xtoken'], 1); + + expect(response.unprocessedNetworks).toStrictEqual([]); + }); + + it('should handle empty token addresses array', () => { + const response = createMockGetBalancesResponse([], 1); + + expect(response.count).toBe(0); + expect(response.balances).toHaveLength(0); + }); + }); +}); diff --git a/packages/assets-controllers/src/multi-chain-accounts-service/mocks/mock-get-balances.ts b/packages/assets-controllers/src/multi-chain-accounts-service/mocks/mock-get-balances.ts new file mode 100644 index 00000000000..6de434c4d82 --- /dev/null +++ b/packages/assets-controllers/src/multi-chain-accounts-service/mocks/mock-get-balances.ts @@ -0,0 +1,101 @@ +import type { GetBalancesResponse } from '../types.js'; + +export const MOCK_GET_BALANCES_RESPONSE: GetBalancesResponse = { + count: 6, + balances: [ + { + object: 'token', + address: '0x0000000000000000000000000000000000000000', + symbol: 'ETH', + name: 'Ether', + type: 'native', + timestamp: '2015-07-30T03:26:13.000Z', + decimals: 18, + chainId: 1, + balance: '0.026380882267770930', + }, + { + object: 'token', + address: '0x4200000000000000000000000000000000000042', + name: 'Optimism', + symbol: 'OP', + decimals: 18, + balance: '5.250000000000000000', + chainId: 10, + }, + { + object: 'token', + address: '0x2791bca1f2de4661ed88a30c99a7a9449aa84174', + name: 'USD Coin (PoS)', + symbol: 'USDC', + decimals: 6, + balance: '22.484688', + chainId: 137, + }, + { + object: 'token', + address: '0x0000000000000000000000000000000000000000', + symbol: 'MATIC', + name: 'MATIC', + type: 'native', + timestamp: '2020-05-30T07:47:16.000Z', + decimals: 18, + chainId: 137, + balance: '2.873547261071381088', + }, + { + object: 'token', + address: '0x912ce59144191c1204e64559fe8253a0e49e6548', + name: 'Arbitrum', + symbol: 'ARB', + decimals: 18, + balance: '14.640000000000000000', + chainId: 42161, + }, + { + object: 'token', + address: '0xd83af4fbd77f3ab65c3b1dc4b38d7e67aecf599a', + name: 'Linea Voyage XP', + symbol: 'LXP', + decimals: 18, + balance: '100.000000000000000000', + chainId: 59144, + }, + { + object: 'token', + address: '0x514910771AF9Ca656af840dff83E8264EcF986CA', + name: 'Chainlink', + symbol: 'LINK', + decimals: 18, + balance: '10', + chainId: 1, + }, + { + object: 'token', + address: '0x514910771AF9Ca656af840dff83E8264EcF986CA', + name: 'Chainlink', + symbol: 'LINK', + decimals: 18, + balance: '10', + chainId: 137, + }, + ], + unprocessedNetworks: [], +}; + +export const createMockGetBalancesResponse = ( + tokenAddrs: string[], + chainId: number, +): GetBalancesResponse => ({ + count: tokenAddrs.length, + balances: tokenAddrs.map((a) => ({ + object: 'token', + address: a, + name: 'Mock Token', + symbol: 'MOCK', + decimals: 18, + balance: '10.18', + chainId, + })), + unprocessedNetworks: [], +}); diff --git a/packages/assets-controllers/src/multi-chain-accounts-service/mocks/mock-get-supported-networks.ts b/packages/assets-controllers/src/multi-chain-accounts-service/mocks/mock-get-supported-networks.ts new file mode 100644 index 00000000000..094928d9a10 --- /dev/null +++ b/packages/assets-controllers/src/multi-chain-accounts-service/mocks/mock-get-supported-networks.ts @@ -0,0 +1,9 @@ +import type { GetSupportedNetworksResponse } from '../types.js'; + +export const MOCK_GET_SUPPORTED_NETWORKS_RESPONSE: GetSupportedNetworksResponse = + { + fullSupport: [1, 137, 56, 59144, 8453, 10, 42161, 534352], + partialSupport: { + balances: [59141, 42220, 43114], + }, + }; diff --git a/packages/assets-controllers/src/multi-chain-accounts-service/multi-chain-accounts.test.ts b/packages/assets-controllers/src/multi-chain-accounts-service/multi-chain-accounts.test.ts new file mode 100644 index 00000000000..13ed68205bf --- /dev/null +++ b/packages/assets-controllers/src/multi-chain-accounts-service/multi-chain-accounts.test.ts @@ -0,0 +1,269 @@ +import nock from 'nock'; + +import { MOCK_GET_BALANCES_RESPONSE } from './mocks/mock-get-balances.js'; +import { MOCK_GET_SUPPORTED_NETWORKS_RESPONSE } from './mocks/mock-get-supported-networks.js'; +import { + MULTICHAIN_ACCOUNTS_DOMAIN, + fetchMultiChainBalances, + fetchMultiChainBalancesV4, + fetchSupportedNetworks, +} from './multi-chain-accounts.js'; + +const MOCK_ADDRESS = '0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045'; +const MOCK_CAIP_ADDRESSES = [ + 'eip155:1:0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045', + 'eip155:137:0x742d35cc6675c4f17f41140100aa83a4b1fa4c82', +]; + +describe('fetchSupportedNetworks()', () => { + const createMockAPI = () => + nock(MULTICHAIN_ACCOUNTS_DOMAIN).get('/v1/supportedNetworks'); + + it('should successfully return supported networks array', async () => { + const mockAPI = createMockAPI().reply( + 200, + MOCK_GET_SUPPORTED_NETWORKS_RESPONSE, + ); + + const result = await fetchSupportedNetworks(); + expect(result).toStrictEqual( + MOCK_GET_SUPPORTED_NETWORKS_RESPONSE.fullSupport, + ); + expect(mockAPI.isDone()).toBe(true); + }); + + it('should throw error when fetch fails', async () => { + const mockAPI = createMockAPI().reply(500); + + await expect(async () => await fetchSupportedNetworks()).rejects.toThrow( + expect.any(Error), + ); + expect(mockAPI.isDone()).toBe(true); + }); +}); + +describe('fetchMultiChainBalances()', () => { + const createMockAPI = () => + nock(MULTICHAIN_ACCOUNTS_DOMAIN).get( + `/v2/accounts/${MOCK_ADDRESS}/balances`, + ); + + it('should successfully return balances response', async () => { + const mockAPI = createMockAPI().reply(200, MOCK_GET_BALANCES_RESPONSE); + + const result = await fetchMultiChainBalances(MOCK_ADDRESS, {}, 'extension'); + expect(result).toBeDefined(); + expect(result).toStrictEqual(MOCK_GET_BALANCES_RESPONSE); + expect(mockAPI.isDone()).toBe(true); + }); + + it('should successfully return balances response with query params to refine search', async () => { + const mockAPI = createMockAPI() + .query({ + networks: '1,10', + }) + .reply(200, MOCK_GET_BALANCES_RESPONSE); + + const result = await fetchMultiChainBalances( + MOCK_ADDRESS, + { + networks: [1, 10], + }, + 'extension', + ); + expect(result).toBeDefined(); + expect(result).toStrictEqual(MOCK_GET_BALANCES_RESPONSE); + expect(mockAPI.isDone()).toBe(true); + }); + + const testMatrix = [ + { httpCode: 429, httpCodeName: 'Too Many Requests' }, // E.g. Rate Limit + { httpCode: 422, httpCodeName: 'Unprocessable Content' }, // E.g. fails to fetch any balances from specified chains + { httpCode: 500, httpCodeName: 'Internal Server Error' }, // E.g. Server Rekt + ]; + + it.each(testMatrix)( + 'should throw when $httpCode "$httpCodeName"', + async ({ httpCode }) => { + const mockAPI = createMockAPI().reply(httpCode); + + await expect( + async () => + await fetchMultiChainBalances(MOCK_ADDRESS, {}, 'extension'), + ).rejects.toThrow(expect.any(Error)); + expect(mockAPI.isDone()).toBe(true); + }, + ); + + it('should successfully return balances response with mobile platform', async () => { + const mockAPI = createMockAPI().reply(200, MOCK_GET_BALANCES_RESPONSE); + + const result = await fetchMultiChainBalances(MOCK_ADDRESS, {}, 'mobile'); + expect(result).toBeDefined(); + expect(result).toStrictEqual(MOCK_GET_BALANCES_RESPONSE); + expect(mockAPI.isDone()).toBe(true); + }); +}); + +describe('fetchMultiChainBalancesV4()', () => { + const createMockAPI = () => + nock(MULTICHAIN_ACCOUNTS_DOMAIN).get('/v4/multiaccount/balances'); + + it('should successfully return balances response', async () => { + const mockAPI = createMockAPI().reply(200, MOCK_GET_BALANCES_RESPONSE); + + const result = await fetchMultiChainBalancesV4({}, 'extension'); + expect(result).toBeDefined(); + expect(result).toStrictEqual(MOCK_GET_BALANCES_RESPONSE); + expect(mockAPI.isDone()).toBe(true); + }); + + it('should include JWT token in Authorization header when provided', async () => { + const mockJwtToken = 'test-jwt-token-v4-456'; + const mockAPI = createMockAPI() + .matchHeader('authorization', `Bearer ${mockJwtToken}`) + .reply(200, MOCK_GET_BALANCES_RESPONSE); + + const result = await fetchMultiChainBalancesV4( + {}, + 'extension', + mockJwtToken, + ); + expect(result).toBeDefined(); + expect(result).toStrictEqual(MOCK_GET_BALANCES_RESPONSE); + expect(mockAPI.isDone()).toBe(true); + }); + + it('should work without JWT token when not provided', async () => { + const mockAPI = createMockAPI().reply(200, MOCK_GET_BALANCES_RESPONSE); + + const result = await fetchMultiChainBalancesV4({}, 'extension'); + expect(result).toBeDefined(); + expect(result).toStrictEqual(MOCK_GET_BALANCES_RESPONSE); + expect(mockAPI.isDone()).toBe(true); + }); + + it('should include JWT token with account addresses and networks', async () => { + const mockJwtToken = 'test-jwt-token-v4-789'; + const mockAPI = createMockAPI() + .query({ + networks: '1,137', + accountAddresses: MOCK_CAIP_ADDRESSES.join(), + }) + .matchHeader('authorization', `Bearer ${mockJwtToken}`) + .reply(200, MOCK_GET_BALANCES_RESPONSE); + + const result = await fetchMultiChainBalancesV4( + { + accountAddresses: MOCK_CAIP_ADDRESSES, + networks: [1, 137], + }, + 'extension', + mockJwtToken, + ); + expect(result).toBeDefined(); + expect(result).toStrictEqual(MOCK_GET_BALANCES_RESPONSE); + expect(mockAPI.isDone()).toBe(true); + }); + + it('should successfully return balances response with account addresses', async () => { + const mockAPI = createMockAPI() + .query({ + accountAddresses: MOCK_CAIP_ADDRESSES.join(), + }) + .reply(200, MOCK_GET_BALANCES_RESPONSE); + + const result = await fetchMultiChainBalancesV4( + { + accountAddresses: MOCK_CAIP_ADDRESSES, + }, + 'extension', + ); + expect(result).toBeDefined(); + expect(result).toStrictEqual(MOCK_GET_BALANCES_RESPONSE); + expect(mockAPI.isDone()).toBe(true); + }); + + it('should successfully return balances response with networks query parameter', async () => { + const mockAPI = createMockAPI() + .query({ + networks: '1,137', + accountAddresses: MOCK_CAIP_ADDRESSES.join(), + }) + .reply(200, MOCK_GET_BALANCES_RESPONSE); + + const result = await fetchMultiChainBalancesV4( + { + accountAddresses: MOCK_CAIP_ADDRESSES, + networks: [1, 137], + }, + 'extension', + ); + expect(result).toBeDefined(); + expect(result).toStrictEqual(MOCK_GET_BALANCES_RESPONSE); + expect(mockAPI.isDone()).toBe(true); + }); + + it('should successfully return balances response with networks only', async () => { + const mockAPI = createMockAPI() + .query({ + networks: '1,10', + }) + .reply(200, MOCK_GET_BALANCES_RESPONSE); + + const result = await fetchMultiChainBalancesV4( + { + networks: [1, 10], + }, + 'extension', + ); + expect(result).toBeDefined(); + expect(result).toStrictEqual(MOCK_GET_BALANCES_RESPONSE); + expect(mockAPI.isDone()).toBe(true); + }); + + it('should successfully return balances response with mobile platform', async () => { + const mockAPI = createMockAPI().reply(200, MOCK_GET_BALANCES_RESPONSE); + + const result = await fetchMultiChainBalancesV4({}, 'mobile'); + expect(result).toBeDefined(); + expect(result).toStrictEqual(MOCK_GET_BALANCES_RESPONSE); + expect(mockAPI.isDone()).toBe(true); + }); + + it('should handle empty account addresses array', async () => { + const mockAPI = createMockAPI() + .query({ + accountAddresses: '', + }) + .reply(200, MOCK_GET_BALANCES_RESPONSE); + + const result = await fetchMultiChainBalancesV4( + { + accountAddresses: [], + }, + 'extension', + ); + expect(result).toBeDefined(); + expect(result).toStrictEqual(MOCK_GET_BALANCES_RESPONSE); + expect(mockAPI.isDone()).toBe(true); + }); + + const testMatrixV4 = [ + { httpCode: 429, httpCodeName: 'Too Many Requests' }, + { httpCode: 422, httpCodeName: 'Unprocessable Content' }, + { httpCode: 500, httpCodeName: 'Internal Server Error' }, + ]; + + it.each(testMatrixV4)( + 'should throw when $httpCode "$httpCodeName"', + async ({ httpCode }) => { + const mockAPI = createMockAPI().reply(httpCode); + + await expect( + async () => await fetchMultiChainBalancesV4({}, 'extension'), + ).rejects.toThrow(expect.any(Error)); + expect(mockAPI.isDone()).toBe(true); + }, + ); +}); diff --git a/packages/assets-controllers/src/multi-chain-accounts-service/multi-chain-accounts.ts b/packages/assets-controllers/src/multi-chain-accounts-service/multi-chain-accounts.ts new file mode 100644 index 00000000000..5f72dd51692 --- /dev/null +++ b/packages/assets-controllers/src/multi-chain-accounts-service/multi-chain-accounts.ts @@ -0,0 +1,119 @@ +import { handleFetch } from '@metamask/controller-utils'; +import type { CaipAccountAddress } from '@metamask/utils'; + +import type { + GetBalancesQueryParams, + GetBalancesQueryParamsV4, + GetBalancesResponse, + GetSupportedNetworksResponse, +} from './types.js'; + +export const MULTICHAIN_ACCOUNTS_DOMAIN = 'https://accounts.api.cx.metamask.io'; + +const getBalancesUrl = ( + address: string, + queryParams?: GetBalancesQueryParams, +) => { + const url = new URL( + `${MULTICHAIN_ACCOUNTS_DOMAIN}/v2/accounts/${address}/balances`, + ); + + if (queryParams?.networks !== undefined) { + url.searchParams.append('networks', queryParams.networks); + } + + return url; +}; + +const getBalancesUrlV4 = (queryParams?: GetBalancesQueryParamsV4) => { + const url = new URL(`${MULTICHAIN_ACCOUNTS_DOMAIN}/v4/multiaccount/balances`); + + if (queryParams?.networks !== undefined) { + url.searchParams.append('networks', queryParams.networks); + } + + if (queryParams?.accountAddresses !== undefined) { + url.searchParams.append('accountAddresses', queryParams.accountAddresses); + } + + return url; +}; + +/** + * Fetches Supported Networks. + * + * @returns supported networks (decimal) + */ +export async function fetchSupportedNetworks(): Promise { + const url = new URL(`${MULTICHAIN_ACCOUNTS_DOMAIN}/v1/supportedNetworks`); + const response: GetSupportedNetworksResponse = await handleFetch(url); + return response.fullSupport; +} + +/** + * Fetches Balances for multiple networks. + * + * @param address - address to fetch balances from + * @param options - params to pass down for a more refined search + * @param options.networks - the networks (in decimal) that you want to filter by + * @param platform - indicates whether the platform is extension or mobile + * @param jwtToken - JWT token for authentication + * @returns a Balances Response + */ +export async function fetchMultiChainBalances( + address: string, + options: { networks?: number[] }, + platform: 'extension' | 'mobile', + jwtToken?: string, +) { + const url = getBalancesUrl(address, { + networks: options?.networks?.join(), + }); + + const headers: Record = { + 'x-metamask-clientproduct': `metamask-${platform}`, + }; + + if (jwtToken) { + headers.Authorization = `Bearer ${jwtToken}`; + } + + const response: GetBalancesResponse = await handleFetch(url, { + headers, + }); + return response; +} + +/** + * Fetches Balances for multiple networks. + * + * @param options - params to pass down for a more refined search + * @param options.accountAddresses - the account addresses that you want to filter by + * @param options.networks - the networks (in decimal) that you want to filter by + * @param platform - indicates whether the platform is extension or mobile + * @param jwtToken - JWT token for authentication + * @returns a Balances Response + */ +export async function fetchMultiChainBalancesV4( + options: { accountAddresses?: CaipAccountAddress[]; networks?: number[] }, + platform: 'extension' | 'mobile', + jwtToken?: string, +) { + const url = getBalancesUrlV4({ + accountAddresses: options?.accountAddresses?.join(), + networks: options?.networks?.join(), + }); + + const headers: Record = { + 'x-metamask-clientproduct': `metamask-${platform}`, + }; + + if (jwtToken) { + headers.Authorization = `Bearer ${jwtToken}`; + } + + const response: GetBalancesResponse = await handleFetch(url, { + headers, + }); + return response; +} diff --git a/packages/assets-controllers/src/multi-chain-accounts-service/types.ts b/packages/assets-controllers/src/multi-chain-accounts-service/types.ts new file mode 100644 index 00000000000..9c161eff2f6 --- /dev/null +++ b/packages/assets-controllers/src/multi-chain-accounts-service/types.ts @@ -0,0 +1,49 @@ +export type GetSupportedNetworksResponse = { + fullSupport: number[]; + partialSupport: { + balances: number[]; + }; +}; + +export type GetBalancesQueryParams = { + /** Comma-separated network/chain IDs */ + networks?: string; + /** Whether or not to filter the assets to contain only the tokens existing in the Token API */ + filterSupportedTokens?: boolean; + /** Specific token addresses to fetch balances for across specified network(s) */ + includeTokenAddresses?: string; + /** Whether to include balances of the account's staked asset balances */ + includeStakedAssets?: boolean; +}; + +export type GetBalancesQueryParamsV4 = { + /** Comma-separated network/chain IDs */ + networks?: string; + + /** Comma-separated account addresses */ + accountAddresses?: string; +}; + +export type GetBalancesResponse = { + count: number; + balances: { + /** Underlying object type. Seems to be always `token` */ + object: string; + /** Token Type: This is only supplied as `native` to native chain tokens (e.g. - ETH, POL) */ + type?: string; + /** Timestamp is only provided for `native` chain tokens */ + timestamp?: string; + address: string; + symbol: string; + name: string; + decimals: number; + chainId: number; + /** string representation of the balance in decimal format (decimals adjusted). e.g. - 123.456789 */ + balance: string; + /** Account address for V4 API responses */ + accountAddress?: string; + }[]; + /** networks that failed to process, if no network is processed, returns HTTP 422 */ + /** V4 API returns CAIP chain IDs like 'eip155:1329', V2 API returns decimal numbers */ + unprocessedNetworks: (number | string)[]; +}; diff --git a/packages/assets-controllers/src/multicall.test.ts b/packages/assets-controllers/src/multicall.test.ts new file mode 100644 index 00000000000..b225b24cc5c --- /dev/null +++ b/packages/assets-controllers/src/multicall.test.ts @@ -0,0 +1,1958 @@ +import { defaultAbiCoder } from '@ethersproject/abi'; +import { Contract } from '@ethersproject/contracts'; +import { Web3Provider } from '@ethersproject/providers'; +import { abiERC20 } from '@metamask/metamask-eth-abis'; +import type { Hex } from '@metamask/utils'; +import BN from 'bn.js'; + +import { + multicallOrFallback, + aggregate3, + getTokenBalancesForMultipleAddresses, + getStakedBalancesForAddresses, + getNftOwnershipForMultipleNfts, +} from './multicall.js'; +import type { Aggregate3Call, NftOwnershipQuery } from './multicall.js'; + +const provider = new Web3Provider(jest.fn()); + +// Create a mock contract for testing +const mockContract = new Contract( + '0x1234567890123456789012345678901234567890', + abiERC20, + provider, +); + +describe('multicall', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('should return empty results for empty calls', async () => { + const results = await multicallOrFallback([], '0x1', provider); + expect(results).toStrictEqual([]); + }); + + describe('when calls are non empty', () => { + // Mock mutiple calls + const call = (accountAddress: string, tokenAddress: string) => ({ + contract: new Contract(tokenAddress, abiERC20, provider), + functionSignature: 'balanceOf(address)', + arguments: [accountAddress], + }); + + const calls = [ + call( + '0x0000000000000000000000000000000000000000', + '0x0000000000000000000000000000000000000001', + ), + call( + '0x0000000000000000000000000000000000000002', + '0x0000000000000000000000000000000000000003', + ), + ]; + + it('should return results via multicall on supported chains', async () => { + // Mock return value for the single multicall + jest + .spyOn(provider, 'call') + .mockResolvedValue( + defaultAbiCoder.encode( + ['tuple(bool,bytes)[]'], + [ + calls.map((_, i) => [ + true, + defaultAbiCoder.encode(['uint256'], [i + 1]), + ]), + ], + ), + ); + + const results = await multicallOrFallback(calls, '0x1', provider); + expect(results).toMatchObject([ + { + success: true, + value: { _hex: '0x01' }, + }, + { + success: true, + value: { _hex: '0x02' }, + }, + ]); + }); + + it('should handle the multicall contract returning false for success', async () => { + // Mock an unsuccessful multicall + jest + .spyOn(provider, 'call') + .mockResolvedValue( + defaultAbiCoder.encode( + ['tuple(bool,bytes)[]'], + [ + calls.map((_, i) => [ + false, + defaultAbiCoder.encode(['uint256'], [i + 1]), + ]), + ], + ), + ); + + const results = await multicallOrFallback(calls, '0x1', provider); + expect(results).toMatchObject([ + { + success: false, + value: undefined, + }, + { + success: false, + value: undefined, + }, + ]); + }); + + it('should fallback to parallel calls on unsupported chains', async () => { + // Mock return values for each call + let timesCalled = 0; + jest + .spyOn(provider, 'call') + .mockImplementation(() => + Promise.resolve( + defaultAbiCoder.encode(['uint256'], [(timesCalled += 1)]), + ), + ); + + const results = await multicallOrFallback(calls, '0x123456789', provider); + expect(results).toMatchObject([ + { + success: true, + value: { _hex: '0x01' }, + }, + { + success: true, + value: { _hex: '0x02' }, + }, + ]); + }); + }); + + describe('error handling of reverts', () => { + const call = { + contract: new Contract( + '0x0000000000000000000000000000000000000001', + abiERC20, + provider, + ), + functionSignature: 'balanceOf(address)', + arguments: ['0x0000000000000000000000000000000000000000'], + }; + + it('should fall back to parallel calls when multicall reverts', async () => { + jest.spyOn(provider, 'call').mockImplementationOnce(() => { + const error = { code: 'CALL_EXCEPTION' }; + return Promise.reject(error); + }); + + jest + .spyOn(provider, 'call') + .mockImplementationOnce(() => + Promise.resolve(defaultAbiCoder.encode(['uint256'], [1])), + ); + + const results = await multicallOrFallback([call], '0x1', provider); + + expect(results).toMatchObject([ + { + success: true, + value: { _hex: '0x01' }, + }, + ]); + }); + + it('should throw rpc errors other than revert', async () => { + const error = { code: 'network error' }; + jest.spyOn(provider, 'call').mockImplementationOnce(() => { + return Promise.reject(error); + }); + + await expect( + multicallOrFallback([call], '0x1', provider), + ).rejects.toMatchObject(error); + }); + }); + + describe('aggregate3', () => { + it('should return empty results for empty calls', async () => { + const results = await aggregate3([], '0x1', provider); + expect(results).toStrictEqual([]); + }); + + it('should execute aggregate3 calls successfully', async () => { + const calls: Aggregate3Call[] = [ + { + target: '0x0000000000000000000000000000000000000001', + allowFailure: true, + callData: + '0x70a08231000000000000000000000000000000000000000000000000000000000000000a', + }, + { + target: '0x0000000000000000000000000000000000000002', + allowFailure: false, + callData: + '0x70a08231000000000000000000000000000000000000000000000000000000000000000b', + }, + ]; + + // Mock the aggregate3 contract call + jest.spyOn(provider, 'call').mockResolvedValue( + defaultAbiCoder.encode( + ['tuple(bool,bytes)[]'], + [ + [ + [true, defaultAbiCoder.encode(['uint256'], [100])], + [true, defaultAbiCoder.encode(['uint256'], [200])], + ], + ], + ), + ); + + const results = await aggregate3(calls, '0x1', provider); + expect(results).toHaveLength(2); + expect(results[0].success).toBe(true); + expect(results[1].success).toBe(true); + }); + + it('should handle failed aggregate3 calls', async () => { + const calls: Aggregate3Call[] = [ + { + target: '0x0000000000000000000000000000000000000001', + allowFailure: true, + callData: + '0x70a08231000000000000000000000000000000000000000000000000000000000000000a', + }, + ]; + + // Mock a failed call + jest + .spyOn(provider, 'call') + .mockResolvedValue( + defaultAbiCoder.encode(['tuple(bool,bytes)[]'], [[[false, '0x']]]), + ); + + const results = await aggregate3(calls, '0x1', provider); + expect(results).toHaveLength(1); + expect(results[0].success).toBe(false); + }); + + it('should handle unsupported chain by attempting call', async () => { + const calls: Aggregate3Call[] = [ + { + target: '0x0000000000000000000000000000000000000001', + allowFailure: true, + callData: + '0x70a08231000000000000000000000000000000000000000000000000000000000000000a', + }, + ]; + + // For unsupported chains, aggregate3 will try to create a contract with undefined address + // which will throw an ethers error + await expect(aggregate3(calls, '0x999999', provider)).rejects.toThrow( + 'invalid contract address', + ); + }); + + it('should handle contract call errors', async () => { + const calls: Aggregate3Call[] = [ + { + target: '0x0000000000000000000000000000000000000001', + allowFailure: true, + callData: + '0x70a08231000000000000000000000000000000000000000000000000000000000000000a', + }, + ]; + + const error = new Error('Contract call failed'); + jest.spyOn(provider, 'call').mockRejectedValue(error); + + await expect(aggregate3(calls, '0x1', provider)).rejects.toThrow( + 'Contract call failed', + ); + }); + }); + + describe('getTokenBalancesForMultipleAddresses', () => { + const tokenAddresses = [ + '0x0000000000000000000000000000000000000001', + '0x0000000000000000000000000000000000000002', + ]; + const userAddresses = [ + '0x000000000000000000000000000000000000000a', + '0x000000000000000000000000000000000000000b', + ]; + + // Create groups for testing + const testGroups = [ + { + accountAddress: userAddresses[0] as Hex, + tokenAddresses: tokenAddresses as Hex[], + }, + { + accountAddress: userAddresses[1] as Hex, + tokenAddresses: tokenAddresses as Hex[], + }, + ]; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('should return empty results for empty inputs', async () => { + const results = await getTokenBalancesForMultipleAddresses( + [], + '0x1', + provider, + false, + false, + ); + expect(results).toStrictEqual({ tokenBalances: {} }); + }); + + it('should return empty results when no pairs and native disabled', async () => { + const results = await getTokenBalancesForMultipleAddresses( + [], + '0x1', + provider, + false, + false, + ); + expect(results).toStrictEqual({ tokenBalances: {} }); + }); + + it('should handle empty pairs array', async () => { + const results = await getTokenBalancesForMultipleAddresses( + [], + '0x1', + provider, + false, + false, + ); + expect(results).toStrictEqual({ tokenBalances: {} }); + }); + + it('should get ERC20 balances successfully using aggregate3', async () => { + // Mock aggregate3 response for ERC20 balances + const mockBalance1 = new BN('1000000000000000000'); // 1 token + const mockBalance2 = new BN('2000000000000000000'); // 2 tokens + const mockBalance3 = new BN('3000000000000000000'); // 3 tokens + const mockBalance4 = new BN('4000000000000000000'); // 4 tokens + + jest.spyOn(provider, 'call').mockResolvedValue( + defaultAbiCoder.encode( + ['tuple(bool,bytes)[]'], + [ + [ + [ + true, + defaultAbiCoder.encode(['uint256'], [mockBalance1.toString()]), + ], + [ + true, + defaultAbiCoder.encode(['uint256'], [mockBalance2.toString()]), + ], + [ + true, + defaultAbiCoder.encode(['uint256'], [mockBalance3.toString()]), + ], + [ + true, + defaultAbiCoder.encode(['uint256'], [mockBalance4.toString()]), + ], + ], + ], + ), + ); + + const results = await getTokenBalancesForMultipleAddresses( + testGroups, + '0x1', + provider, + false, + false, + ); + + expect(results.tokenBalances).toHaveProperty(tokenAddresses[0]); + expect(results.tokenBalances).toHaveProperty(tokenAddresses[1]); + expect(results.tokenBalances[tokenAddresses[0]]).toHaveProperty( + userAddresses[0], + ); + expect(results.tokenBalances[tokenAddresses[0]]).toHaveProperty( + userAddresses[1], + ); + expect(results.tokenBalances[tokenAddresses[1]]).toHaveProperty( + userAddresses[0], + ); + expect(results.tokenBalances[tokenAddresses[1]]).toHaveProperty( + userAddresses[1], + ); + }); + + it('should get native balances using aggregate3', async () => { + const mockNativeBalance1 = new BN('5000000000000000000'); // 5 ETH + const mockNativeBalance2 = new BN('6000000000000000000'); // 6 ETH + + jest.spyOn(provider, 'call').mockResolvedValue( + defaultAbiCoder.encode( + ['tuple(bool,bytes)[]'], + [ + [ + [ + true, + defaultAbiCoder.encode( + ['uint256'], + [mockNativeBalance1.toString()], + ), + ], + [ + true, + defaultAbiCoder.encode( + ['uint256'], + [mockNativeBalance2.toString()], + ), + ], + ], + ], + ), + ); + + const results = await getTokenBalancesForMultipleAddresses( + [], + '0x1', + provider, + true, + false, + ); + + expect(results).toStrictEqual({ tokenBalances: {} }); + }); + + it('should handle mixed ERC20 and native balances', async () => { + const mockERC20Balance = new BN('1000000000000000000'); + const mockNativeBalance = new BN('2000000000000000000'); + + jest.spyOn(provider, 'call').mockResolvedValue( + defaultAbiCoder.encode( + ['tuple(bool,bytes)[]'], + [ + [ + [ + true, + defaultAbiCoder.encode( + ['uint256'], + [mockERC20Balance.toString()], + ), + ], + [ + true, + defaultAbiCoder.encode( + ['uint256'], + [mockNativeBalance.toString()], + ), + ], + ], + ], + ), + ); + + const results = await getTokenBalancesForMultipleAddresses( + [ + { + accountAddress: userAddresses[0] as Hex, + tokenAddresses: [tokenAddresses[0]] as Hex[], + }, + ], + '0x1', + provider, + true, + false, + ); + + expect(results.tokenBalances).toHaveProperty(tokenAddresses[0]); + expect(results.tokenBalances).toHaveProperty( + '0x0000000000000000000000000000000000000000', + ); + }); + + it('should handle failed balance calls gracefully', async () => { + jest.spyOn(provider, 'call').mockResolvedValue( + defaultAbiCoder.encode( + ['tuple(bool,bytes)[]'], + [ + [ + [false, '0x'], // Failed call + [ + true, + defaultAbiCoder.encode(['uint256'], ['1000000000000000000']), + ], // Successful call + ], + ], + ), + ); + + const results = await getTokenBalancesForMultipleAddresses( + [ + { + accountAddress: userAddresses[0] as Hex, + tokenAddresses: [tokenAddresses[0]] as Hex[], + }, + { + accountAddress: userAddresses[1] as Hex, + tokenAddresses: [tokenAddresses[0]] as Hex[], + }, + ], + '0x1', + provider, + false, + false, + ); + + // Should only have balance for the successful call + expect(results.tokenBalances[tokenAddresses[0]]).toHaveProperty( + userAddresses[1], + ); + expect(results.tokenBalances[tokenAddresses[0]]).not.toHaveProperty( + userAddresses[0], + ); + }); + + it('should use fallback for unsupported chains', async () => { + // Mock provider.call for individual ERC20 calls + jest + .spyOn(provider, 'call') + .mockResolvedValue( + defaultAbiCoder.encode(['uint256'], ['1000000000000000000']), + ); + + // Mock provider.getBalance for native balance calls + jest + .spyOn(provider, 'getBalance') + // eslint-disable-next-line @typescript-eslint/no-explicit-any + .mockResolvedValue({ toString: () => '2000000000000000000' } as any); + + const results = await getTokenBalancesForMultipleAddresses( + [ + { + accountAddress: userAddresses[0] as Hex, + tokenAddresses: [tokenAddresses[0]] as Hex[], + }, + ], + '0x999999' as Hex, // Unsupported chain + provider, + true, + false, + ); + + expect(results.tokenBalances).toHaveProperty(tokenAddresses[0]); + expect(results.tokenBalances).toHaveProperty( + '0x0000000000000000000000000000000000000000', + ); + }); + + it('should handle errors in fallback mode gracefully', async () => { + // Mock provider.call to fail for ERC20 calls + jest.spyOn(provider, 'call').mockRejectedValue(new Error('Call failed')); + + // Mock provider.getBalance to fail for native balance calls + jest + .spyOn(provider, 'getBalance') + .mockRejectedValue(new Error('Balance call failed')); + + const results = await getTokenBalancesForMultipleAddresses( + [ + { + accountAddress: userAddresses[0] as Hex, + tokenAddresses: [tokenAddresses[0]] as Hex[], + }, + ], + '0x999999', // Unsupported chain + provider, + true, + false, + ); + + // Should return empty structure since all calls failed + expect(Object.keys(results.tokenBalances)).toHaveLength(0); + }); + + it('should handle large batches by splitting calls', async () => { + // Create many token addresses to test batching (but keep reasonable for testing) + const manyTokens = Array.from( + { length: 5 }, + (_, i) => `0x000000000000000000000000000000000000000${i + 1}`, + ); + + jest + .spyOn(provider, 'call') + .mockResolvedValue( + defaultAbiCoder.encode( + ['tuple(bool,bytes)[]'], + [ + Array.from({ length: 5 }, () => [ + true, + defaultAbiCoder.encode(['uint256'], ['1000000000000000000']), + ]), + ], + ), + ); + + const results = await getTokenBalancesForMultipleAddresses( + [ + { + accountAddress: userAddresses[0] as Hex, + tokenAddresses: manyTokens as Hex[], + }, + ], + '0x1', + provider, + false, + false, + ); + + // Should handle all tokens despite batching + expect(Object.keys(results.tokenBalances)).toHaveLength(5); + }); + + it('should handle contract call errors and rethrow non-revert errors', async () => { + const error = new Error('Network error'); + jest.spyOn(provider, 'call').mockRejectedValue(error); + + await expect( + getTokenBalancesForMultipleAddresses( + userAddresses.map((userAddress) => ({ + accountAddress: userAddress as Hex, + tokenAddresses: tokenAddresses as Hex[], + })), + '0x1', + provider, + false, + false, + ), + ).rejects.toThrow('Network error'); + }); + + it('should fallback on CALL_EXCEPTION errors', async () => { + // Mock aggregate3 to fail with CALL_EXCEPTION + const callExceptionError = { code: 'CALL_EXCEPTION' }; + jest.spyOn(provider, 'call').mockRejectedValueOnce(callExceptionError); + + // Mock fallback calls to succeed + jest + .spyOn(provider, 'call') + .mockResolvedValue( + defaultAbiCoder.encode(['uint256'], ['1000000000000000000']), + ); + + // Mock provider.getBalance for native balance calls + jest + .spyOn(provider, 'getBalance') + // eslint-disable-next-line @typescript-eslint/no-explicit-any + .mockResolvedValue({ toString: () => '2000000000000000000' } as any); + + const results = await getTokenBalancesForMultipleAddresses( + [ + { + accountAddress: userAddresses[0] as Hex, + tokenAddresses: [tokenAddresses[0]] as Hex[], + }, + ], + '0x1', + provider, + true, + false, + ); + + // Should get results from fallback + expect(results.tokenBalances).toHaveProperty(tokenAddresses[0]); + expect(results.tokenBalances).toHaveProperty( + '0x0000000000000000000000000000000000000000', + ); + }); + }); + + describe('edge cases and improved coverage', () => { + it('should handle aggregate3 with empty calls array', async () => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const calls: any[] = []; + const result = await aggregate3(calls, '0x1', provider); + expect(result).toStrictEqual([]); + }); + + it('should handle failed native balance calls in multicall', async () => { + const groups = [ + { + accountAddress: '0x1111111111111111111111111111111111111111' as const, + tokenAddresses: [ + '0x0000000000000000000000000000000000000000' as const, + ], // Native token + }, + ]; + + // Mock aggregate3 to return failed native balance call + jest.spyOn(provider, 'call').mockResolvedValue( + defaultAbiCoder.encode( + ['tuple(bool success, bytes returnData)[]'], + [ + [ + { success: false, returnData: '0x' }, // Failed native balance call + ], + ], + ), + ); + + const result = await getTokenBalancesForMultipleAddresses( + groups, + '0x1', + provider, + true, // includeNative + false, // includeStaked + ); + + expect(result.tokenBalances).toBeDefined(); + expect(Object.keys(result.tokenBalances)).toHaveLength(0); + }); + + it('should handle mixed success and failure in aggregate3 calls', async () => { + const calls = [ + { + target: '0x1111111111111111111111111111111111111111', + callData: '0x1234', + allowFailure: true, + }, + { + target: '0x2222222222222222222222222222222222222222', + callData: '0x5678', + allowFailure: true, + }, + ]; + + jest.spyOn(provider, 'call').mockResolvedValue( + defaultAbiCoder.encode( + ['tuple(bool success, bytes returnData)[]'], + [ + [ + { + success: true, + returnData: defaultAbiCoder.encode(['uint256'], ['1000']), + }, + { success: false, returnData: '0x' }, + ], + ], + ), + ); + + const results = await aggregate3(calls, '0x1', provider); + expect(results).toHaveLength(2); + expect(results[0].success).toBe(true); + expect(results[0].returnData).toBe( + '0x00000000000000000000000000000000000000000000000000000000000003e8', + ); + expect(results[1].success).toBe(false); + expect(results[1].returnData).toBe('0x'); + }); + + it('should handle error in aggregate3 by rejecting with error', async () => { + const account1 = '0x1111111111111111111111111111111111111111' as const; + + const groups = [ + { + accountAddress: account1, + tokenAddresses: [ + '0x1111111111111111111111111111111111111111' as const, + ], + }, + ]; + + // Mock aggregate3 to fail + jest + .spyOn(provider, 'call') + .mockRejectedValue(new Error('Aggregate3 not supported')); + + // The function should handle the error appropriately + await expect( + getTokenBalancesForMultipleAddresses( + groups, + '0x1', + provider, + false, // includeNative + true, // includeStaked + ), + ).rejects.toThrow('Aggregate3 not supported'); + }); + + it('should handle staked balances fallback if contract not suppoerted on the chain', async () => { + const account1 = '0x1111111111111111111111111111111111111111' as const; + + const groups = [ + { + accountAddress: account1, + tokenAddresses: [ + '0x1111111111111111111111111111111111111111' as const, + ], + }, + ]; + + // mock getBalance + // eslint-disable-next-line @typescript-eslint/no-explicit-any + jest.spyOn(provider, 'getBalance').mockResolvedValue('1000' as any); + + // mock getBalance + jest + .spyOn(provider, 'call') + .mockResolvedValue(defaultAbiCoder.encode(['uint256'], ['1000'])); + + // mock getShares + jest + .spyOn(provider, 'call') + .mockResolvedValue(defaultAbiCoder.encode(['uint256'], ['1000'])); + + const result = await getTokenBalancesForMultipleAddresses( + groups, + '0x88bb0', + provider, + false, // includeNative + true, // includeStaked + ); + expect(result.stakedBalances).toBeDefined(); + }); + + describe('error handling branches coverage', () => { + it('should throw error when multicall fails with null error', async () => { + const calls = [ + { + contract: mockContract, + functionSignature: 'balanceOf(address)', + arguments: ['0x1234567890123456789012345678901234567890'], + }, + ]; + + // Mock provider.call to throw null error (covers !error branch) + jest.spyOn(provider, 'call').mockRejectedValue(null); + + await expect( + multicallOrFallback(calls, '0x1', provider), + ).rejects.toBeNull(); + }); + + it('should throw error when multicall fails with string error', async () => { + const calls = [ + { + contract: mockContract, + functionSignature: 'balanceOf(address)', + arguments: ['0x1234567890123456789012345678901234567890'], + }, + ]; + + // Mock provider.call to throw string error (covers typeof error !== 'object' branch) + jest.spyOn(provider, 'call').mockRejectedValue('Network error'); + + await expect(multicallOrFallback(calls, '0x1', provider)).rejects.toBe( + 'Network error', + ); + }); + + it('should throw error when multicall fails with object without code property', async () => { + const calls = [ + { + contract: mockContract, + functionSignature: 'balanceOf(address)', + arguments: ['0x1234567890123456789012345678901234567890'], + }, + ]; + + // Mock provider.call to throw object without code (covers !('code' in error) branch) + const errorWithoutCode = { message: 'Something went wrong' }; + jest.spyOn(provider, 'call').mockRejectedValue(errorWithoutCode); + + await expect( + multicallOrFallback(calls, '0x1', provider), + ).rejects.toStrictEqual(errorWithoutCode); + }); + + it('should throw error when multicall fails with non-CALL_EXCEPTION code', async () => { + const calls = [ + { + contract: mockContract, + functionSignature: 'balanceOf(address)', + arguments: ['0x1234567890123456789012345678901234567890'], + }, + ]; + + // Mock provider.call to throw error with different code (covers error.code !== 'CALL_EXCEPTION' branch) + const errorWithDifferentCode = { + code: 'NETWORK_ERROR', + message: 'Network issue', + }; + jest.spyOn(provider, 'call').mockRejectedValue(errorWithDifferentCode); + + await expect( + multicallOrFallback(calls, '0x1', provider), + ).rejects.toStrictEqual(errorWithDifferentCode); + }); + + it('should throw error when getTokenBalancesForMultipleAddresses fails with null error', async () => { + const groups = [ + { + accountAddress: + '0x1111111111111111111111111111111111111111' as const, + tokenAddresses: [ + '0x1111111111111111111111111111111111111111' as const, + ], + }, + ]; + + // Mock provider.call to throw null error (covers !error branch in getTokenBalancesForMultipleAddresses) + jest.spyOn(provider, 'call').mockRejectedValue(null); + + await expect( + getTokenBalancesForMultipleAddresses( + groups, + '0x1', + provider, + true, + false, + ), + ).rejects.toBeNull(); + }); + + it('should throw error when getTokenBalancesForMultipleAddresses fails with string error', async () => { + const groups = [ + { + accountAddress: + '0x1111111111111111111111111111111111111111' as const, + tokenAddresses: [ + '0x1111111111111111111111111111111111111111' as const, + ], + }, + ]; + + // Mock provider.call to throw string error + jest.spyOn(provider, 'call').mockRejectedValue('Connection timeout'); + + await expect( + getTokenBalancesForMultipleAddresses( + groups, + '0x1', + provider, + true, + false, + ), + ).rejects.toBe('Connection timeout'); + }); + + it('should throw error when getTokenBalancesForMultipleAddresses fails with object without code', async () => { + const groups = [ + { + accountAddress: + '0x1111111111111111111111111111111111111111' as const, + tokenAddresses: [ + '0x1111111111111111111111111111111111111111' as const, + ], + }, + ]; + + // Mock provider.call to throw object without code + const errorWithoutCode = { + reason: 'Invalid transaction', + data: '0x123', + }; + jest.spyOn(provider, 'call').mockRejectedValue(errorWithoutCode); + + await expect( + getTokenBalancesForMultipleAddresses( + groups, + '0x1', + provider, + true, + false, + ), + ).rejects.toStrictEqual(errorWithoutCode); + }); + + it('should throw error when getTokenBalancesForMultipleAddresses fails with non-CALL_EXCEPTION code', async () => { + const groups = [ + { + accountAddress: + '0x1111111111111111111111111111111111111111' as const, + tokenAddresses: [ + '0x1111111111111111111111111111111111111111' as const, + ], + }, + ]; + + // Mock provider.call to throw error with different code + const errorWithDifferentCode = { + code: 'INSUFFICIENT_FUNDS', + message: 'Not enough gas', + }; + jest.spyOn(provider, 'call').mockRejectedValue(errorWithDifferentCode); + + await expect( + getTokenBalancesForMultipleAddresses( + groups, + '0x1', + provider, + true, + false, + ), + ).rejects.toStrictEqual(errorWithDifferentCode); + }); + + it('should handle Promise.allSettled rejection in getNativeBalancesFallback', async () => { + const groups = [ + { + accountAddress: + '0x1111111111111111111111111111111111111111' as const, + tokenAddresses: [], + }, + ]; + + // Mock aggregate3 to fail, forcing fallback + jest + .spyOn(provider, 'call') + .mockRejectedValue({ code: 'CALL_EXCEPTION' }); + + // Mock getBalance to throw an error (this will be caught by Promise.allSettled) + jest + .spyOn(provider, 'getBalance') + .mockRejectedValue(new Error('Balance fetch failed')); + + const result = await getTokenBalancesForMultipleAddresses( + groups, + '0x1', + provider, + true, // includeNative + false, // includeStaked + ); + + expect(result.tokenBalances).toBeDefined(); + expect(Object.keys(result.tokenBalances)).toHaveLength(0); + }); + + it('should handle case where balance is null in getNativeBalancesFallback', async () => { + const groups = [ + { + accountAddress: + '0x1111111111111111111111111111111111111111' as const, + tokenAddresses: [], + }, + ]; + + // Mock aggregate3 to fail, forcing fallback + jest + .spyOn(provider, 'call') + .mockRejectedValue({ code: 'CALL_EXCEPTION' }); + + // Mock getBalance to return null (testing the null check in line 652) + jest.spyOn(provider, 'getBalance').mockImplementation(() => { + return Promise.resolve({ + toString: () => 'null', + // eslint-disable-next-line @typescript-eslint/no-explicit-any + } as any); + }); + + const result = await getTokenBalancesForMultipleAddresses( + groups, + '0x1', + provider, + true, // includeNative + false, // includeStaked + ); + + expect(result.tokenBalances).toBeDefined(); + }); + + it('should handle empty tokenAddresses in getTokenBalancesFallback', async () => { + const groups = [ + { + accountAddress: + '0x1111111111111111111111111111111111111111' as const, + tokenAddresses: [], + }, + ]; + + // Mock aggregate3 to fail, forcing fallback + jest + .spyOn(provider, 'call') + .mockRejectedValue({ code: 'CALL_EXCEPTION' }); + + // Mock getBalance for native balance + // eslint-disable-next-line @typescript-eslint/no-explicit-any + jest.spyOn(provider, 'getBalance').mockResolvedValue('1000' as any); + + const result = await getTokenBalancesForMultipleAddresses( + groups, + '0x1', + provider, + true, // includeNative + false, // includeStaked + ); + + expect(result.tokenBalances).toBeDefined(); + expect( + result.tokenBalances['0x0000000000000000000000000000000000000000'], + ).toBeDefined(); + }); + + it('should handle mixed Promise.allSettled results in fallback mode', async () => { + const groups = [ + { + accountAddress: + '0x1111111111111111111111111111111111111111' as const, + tokenAddresses: [ + '0x1111111111111111111111111111111111111111' as const, + ], + }, + ]; + + // Mock aggregate3 to fail, forcing fallback + jest + .spyOn(provider, 'call') + .mockRejectedValue({ code: 'CALL_EXCEPTION' }); + + // Mock individual calls - some succeed, some fail + jest + .spyOn(provider, 'call') + .mockRejectedValueOnce({ code: 'CALL_EXCEPTION' }) // First aggregate3 call fails + .mockResolvedValueOnce(defaultAbiCoder.encode(['uint256'], ['1000'])) // Token balance succeeds + .mockRejectedValueOnce(new Error('Individual call failed')); // Some individual calls fail + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + jest.spyOn(provider, 'getBalance').mockResolvedValue('2000' as any); + + const result = await getTokenBalancesForMultipleAddresses( + groups, + '0x1', + provider, + true, // includeNative + false, // includeStaked + ); + + expect(result.tokenBalances).toBeDefined(); + }); + + it('should handle case where no staking contract address exists for chain (staking handled separately)', async () => { + const groups = [ + { + accountAddress: + '0x1111111111111111111111111111111111111111' as const, + tokenAddresses: [ + '0x1111111111111111111111111111111111111111' as const, + ], + }, + ]; + + // Use a chain ID that doesn't have staking support + const unsupportedChainId = '0x999' as const; + + // Mock the provider call for token balances + jest.spyOn(provider, 'call').mockResolvedValue( + defaultAbiCoder.encode( + ['tuple(bool success, bytes returnData)[]'], + [ + [ + { + success: true, + returnData: defaultAbiCoder.encode(['uint256'], ['1000']), + }, + ], + ], + ), + ); + + const result = await getTokenBalancesForMultipleAddresses( + groups, + unsupportedChainId, + provider, + false, // includeNative + false, // includeStaked - Note: staking is handled separately now + ); + + expect(result.tokenBalances).toBeDefined(); + expect(result.stakedBalances).toBeUndefined(); + }); + + it('should not return early when groups empty but includeNative is true', async () => { + const groups: { accountAddress: Hex; tokenAddresses: Hex[] }[] = []; + + // Mock getBalance for native balance + // eslint-disable-next-line @typescript-eslint/no-explicit-any + jest.spyOn(provider, 'getBalance').mockResolvedValue('1000' as any); + + const result = await getTokenBalancesForMultipleAddresses( + groups, + '0x1', + provider, + true, // includeNative - this should prevent early return + false, // includeStaked + ); + + expect(result.tokenBalances).toBeDefined(); + // Should have processed native balances despite empty groups + }); + + it('should return empty results when groups are empty (staking handled separately)', async () => { + const groups: { accountAddress: Hex; tokenAddresses: Hex[] }[] = []; + + // Mock for staking contract call + jest + .spyOn(provider, 'call') + .mockResolvedValue( + defaultAbiCoder.encode( + ['tuple(bool success, bytes returnData)[]'], + [[]], + ), + ); + + const result = await getTokenBalancesForMultipleAddresses( + groups, + '0x1', + provider, + false, // includeNative + true, // includeStaked - this should prevent early return + ); + + expect(result.tokenBalances).toBeDefined(); + // Should have processed staking even with empty groups + }); + + it('should process native balances when groups are empty and includeNative is true', async () => { + const groups: { accountAddress: Hex; tokenAddresses: Hex[] }[] = []; + + // Mock getBalance for native balance + // eslint-disable-next-line @typescript-eslint/no-explicit-any + jest.spyOn(provider, 'getBalance').mockResolvedValue('1000' as any); + + // Mock for staking contract call + jest + .spyOn(provider, 'call') + .mockResolvedValue( + defaultAbiCoder.encode( + ['tuple(bool success, bytes returnData)[]'], + [[]], + ), + ); + + const result = await getTokenBalancesForMultipleAddresses( + groups, + '0x1', + provider, + true, // includeNative + false, // includeStaked + ); + + expect(result.tokenBalances).toBeDefined(); + }); + + it('should handle token balance calls when only token calls are made', async () => { + const groups = [ + { + accountAddress: + '0x1111111111111111111111111111111111111111' as const, + tokenAddresses: [ + '0x1111111111111111111111111111111111111111' as const, + ], + }, + ]; + + // Mock the aggregate3 call to succeed with only token balance result + jest.spyOn(provider, 'call').mockResolvedValue( + defaultAbiCoder.encode( + ['tuple(bool success, bytes returnData)[]'], + [ + [ + // Token balance call + { + success: true, + returnData: defaultAbiCoder.encode(['uint256'], ['1000']), + }, + ], + ], + ), + ); + + const result = await getTokenBalancesForMultipleAddresses( + groups, + '0x1', // Use mainnet + provider, + false, // includeNative + false, // includeStaked + ); + + expect(result.tokenBalances).toBeDefined(); + expect(result.stakedBalances).toBeUndefined(); + }); + }); + }); + + describe('getStakedBalancesForAddresses', () => { + const testAddresses = [ + '0x1111111111111111111111111111111111111111', + '0x2222222222222222222222222222222222222222', + ]; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('should fetch staked balances for addresses with non-zero shares', async () => { + // Mock getShares calls - first address has shares, second doesn't + jest + .spyOn(provider, 'call') + .mockResolvedValueOnce( + defaultAbiCoder.encode( + ['tuple(bool,bytes)[]'], + [ + [ + [ + true, + defaultAbiCoder.encode(['uint256'], ['1000000000000000000']), + ], // 1 share for address 1 + [true, defaultAbiCoder.encode(['uint256'], ['0'])], // 0 shares for address 2 + ], + ], + ), + ) + // Mock convertToAssets call for address 1 + .mockResolvedValueOnce( + defaultAbiCoder.encode( + ['tuple(bool,bytes)[]'], + [ + [ + [ + true, + defaultAbiCoder.encode(['uint256'], ['2000000000000000000']), + ], // 2 ETH for 1 share + ], + ], + ), + ); + + const result = await getStakedBalancesForAddresses( + testAddresses, + '0x1', + provider, + ); + + expect(result).toStrictEqual({ + [testAddresses[0]]: new BN('2000000000000000000'), // 2 ETH + // Address 2 not included since it has 0 shares + }); + + // Should have been called twice - once for getShares, once for convertToAssets + expect(provider.call).toHaveBeenCalledTimes(2); + }); + + it('should return empty object when all addresses have zero shares', async () => { + // Mock getShares calls - all addresses have zero shares + jest.spyOn(provider, 'call').mockResolvedValueOnce( + defaultAbiCoder.encode( + ['tuple(bool,bytes)[]'], + [ + [ + [true, defaultAbiCoder.encode(['uint256'], ['0'])], // 0 shares for address 1 + [true, defaultAbiCoder.encode(['uint256'], ['0'])], // 0 shares for address 2 + ], + ], + ), + ); + + const result = await getStakedBalancesForAddresses( + testAddresses, + '0x1', + provider, + ); + + expect(result).toStrictEqual({}); + + // Should only have been called once for getShares + expect(provider.call).toHaveBeenCalledTimes(1); + }); + + it('should handle failed getShares calls gracefully', async () => { + // Mock getShares with some failures + jest + .spyOn(provider, 'call') + .mockResolvedValueOnce( + defaultAbiCoder.encode( + ['tuple(bool,bytes)[]'], + [ + [ + [false, '0x'], // Failed call for address 1 + [ + true, + defaultAbiCoder.encode(['uint256'], ['1000000000000000000']), + ], // Success for address 2 + ], + ], + ), + ) + // Mock convertToAssets for successful address + .mockResolvedValueOnce( + defaultAbiCoder.encode( + ['tuple(bool,bytes)[]'], + [ + [ + [ + true, + defaultAbiCoder.encode(['uint256'], ['2000000000000000000']), + ], // 2 ETH + ], + ], + ), + ); + + const result = await getStakedBalancesForAddresses( + testAddresses, + '0x1', + provider, + ); + + expect(result).toStrictEqual({ + [testAddresses[1]]: new BN('2000000000000000000'), // Only successful address + }); + }); + + it('should handle failed convertToAssets calls gracefully', async () => { + // Mock successful getShares + jest + .spyOn(provider, 'call') + .mockResolvedValueOnce( + defaultAbiCoder.encode( + ['tuple(bool,bytes)[]'], + [ + [ + [ + true, + defaultAbiCoder.encode(['uint256'], ['1000000000000000000']), + ], // 1 share + ], + ], + ), + ) + // Mock failed convertToAssets + .mockResolvedValueOnce( + defaultAbiCoder.encode( + ['tuple(bool,bytes)[]'], + [ + [ + [false, '0x'], // Failed convertToAssets call + ], + ], + ), + ); + + const result = await getStakedBalancesForAddresses( + [testAddresses[0]], + '0x1', + provider, + ); + + expect(result).toStrictEqual({}); // No results due to failed conversion + }); + + it('should handle unsupported chains', async () => { + const callSpy = jest.spyOn(provider, 'call'); + + const result = await getStakedBalancesForAddresses( + testAddresses, + '0x999', // Unsupported chain + provider, + ); + + expect(result).toStrictEqual({}); + expect(callSpy).not.toHaveBeenCalled(); + }); + + it('should handle contract call errors gracefully', async () => { + // Mock contract call to throw error + jest + .spyOn(provider, 'call') + .mockRejectedValue(new Error('Contract error')); + + const result = await getStakedBalancesForAddresses( + testAddresses, + '0x1', + provider, + ); + + expect(result).toStrictEqual({}); + }); + + it('should handle empty user addresses array', async () => { + const callSpy = jest.spyOn(provider, 'call'); + + const result = await getStakedBalancesForAddresses([], '0x1', provider); + + expect(result).toStrictEqual({}); + expect(callSpy).not.toHaveBeenCalled(); + }); + + it('should handle multiple addresses with mixed shares', async () => { + const manyAddresses = [ + '0x1111111111111111111111111111111111111111', + '0x2222222222222222222222222222222222222222', + '0x3333333333333333333333333333333333333333', + '0x4444444444444444444444444444444444444444', + ]; + + // Mock getShares - addresses 1 and 3 have shares, 2 and 4 don't + jest + .spyOn(provider, 'call') + .mockResolvedValueOnce( + defaultAbiCoder.encode( + ['tuple(bool,bytes)[]'], + [ + [ + [ + true, + defaultAbiCoder.encode(['uint256'], ['1000000000000000000']), + ], // Address 1: 1 share + [true, defaultAbiCoder.encode(['uint256'], ['0'])], // Address 2: 0 shares + [ + true, + defaultAbiCoder.encode(['uint256'], ['500000000000000000']), + ], // Address 3: 0.5 shares + [true, defaultAbiCoder.encode(['uint256'], ['0'])], // Address 4: 0 shares + ], + ], + ), + ) + // Mock convertToAssets for addresses with shares + .mockResolvedValueOnce( + defaultAbiCoder.encode( + ['tuple(bool,bytes)[]'], + [ + [ + [ + true, + defaultAbiCoder.encode(['uint256'], ['2000000000000000000']), + ], // 2 ETH for 1 share + [ + true, + defaultAbiCoder.encode(['uint256'], ['1000000000000000000']), + ], // 1 ETH for 0.5 shares + ], + ], + ), + ); + + const result = await getStakedBalancesForAddresses( + manyAddresses, + '0x1', + provider, + ); + + expect(result).toStrictEqual({ + [manyAddresses[0]]: new BN('2000000000000000000'), // 2 ETH + [manyAddresses[2]]: new BN('1000000000000000000'), // 1 ETH + // Addresses 1 and 3 not included (zero shares) + }); + }); + }); + + describe('getNftOwnershipForMultipleNfts', () => { + const ownerAddress = '0x0000000000000000000000000000000000000001'; + const otherAddress = '0x0000000000000000000000000000000000000099'; + const nftAddress = '0x0000000000000000000000000000000000000ABC'; + const supportedChainId: Hex = '0x1'; + const unsupportedChainId: Hex = '0x999999'; + + const makeQuery = ( + overrides: Partial = {}, + ): NftOwnershipQuery => ({ + nftAddress, + tokenId: '1', + userAddress: ownerAddress, + standard: null, + ...overrides, + }); + + const encodeAggregate3Response = ( + results: { success: boolean; data: string }[], + ): string => + defaultAbiCoder.encode( + ['tuple(bool,bytes)[]'], + [results.map(({ success, data }) => [success, data])], + ); + + const encodeOwnerOfResult = (owner: string): string => + defaultAbiCoder.encode(['address'], [owner]); + + const encodeBalanceOfResult = (balance: number): string => + defaultAbiCoder.encode(['uint256'], [balance]); + + it('should return empty array for empty input', async () => { + const results = await getNftOwnershipForMultipleNfts( + [], + supportedChainId, + provider, + ); + expect(results).toStrictEqual([]); + }); + + describe('via multicall (supported chain)', () => { + it('should detect ERC-721 ownership when ownerOf matches', async () => { + jest.spyOn(provider, 'call').mockResolvedValueOnce( + encodeAggregate3Response([ + { success: true, data: encodeOwnerOfResult(ownerAddress) }, + { success: false, data: '0x' }, + ]), + ); + + const results = await getNftOwnershipForMultipleNfts( + [makeQuery()], + supportedChainId, + provider, + ); + + expect(results).toStrictEqual([ + { nftAddress, tokenId: '1', isOwned: true }, + ]); + }); + + it('should detect ERC-721 non-ownership when ownerOf returns a different address', async () => { + jest.spyOn(provider, 'call').mockResolvedValueOnce( + encodeAggregate3Response([ + { success: true, data: encodeOwnerOfResult(otherAddress) }, + { success: false, data: '0x' }, + ]), + ); + + const results = await getNftOwnershipForMultipleNfts( + [makeQuery()], + supportedChainId, + provider, + ); + + expect(results).toStrictEqual([ + { nftAddress, tokenId: '1', isOwned: false }, + ]); + }); + + it('should fall back to ERC-1155 balanceOf when ownerOf fails', async () => { + jest.spyOn(provider, 'call').mockResolvedValueOnce( + encodeAggregate3Response([ + { success: false, data: '0x' }, + { success: true, data: encodeBalanceOfResult(3) }, + ]), + ); + + const results = await getNftOwnershipForMultipleNfts( + [makeQuery()], + supportedChainId, + provider, + ); + + expect(results).toStrictEqual([ + { nftAddress, tokenId: '1', isOwned: true }, + ]); + }); + + it('should return isOwned=false when ERC-1155 balance is zero', async () => { + jest.spyOn(provider, 'call').mockResolvedValueOnce( + encodeAggregate3Response([ + { success: false, data: '0x' }, + { success: true, data: encodeBalanceOfResult(0) }, + ]), + ); + + const results = await getNftOwnershipForMultipleNfts( + [makeQuery()], + supportedChainId, + provider, + ); + + expect(results).toStrictEqual([ + { nftAddress, tokenId: '1', isOwned: false }, + ]); + }); + + it('should return isOwned=undefined when both calls fail', async () => { + jest.spyOn(provider, 'call').mockResolvedValueOnce( + encodeAggregate3Response([ + { success: false, data: '0x' }, + { success: false, data: '0x' }, + ]), + ); + + const results = await getNftOwnershipForMultipleNfts( + [makeQuery()], + supportedChainId, + provider, + ); + + expect(results).toStrictEqual([ + { nftAddress, tokenId: '1', isOwned: undefined }, + ]); + }); + + it('should skip ERC-1155 call when standard is ERC721', async () => { + jest + .spyOn(provider, 'call') + .mockResolvedValueOnce( + encodeAggregate3Response([ + { success: true, data: encodeOwnerOfResult(ownerAddress) }, + ]), + ); + + const results = await getNftOwnershipForMultipleNfts( + [makeQuery({ standard: 'ERC721' })], + supportedChainId, + provider, + ); + + expect(results).toStrictEqual([ + { nftAddress, tokenId: '1', isOwned: true }, + ]); + // Only 1 subcall should have been sent (ownerOf), not 2 + expect(provider.call).toHaveBeenCalledTimes(1); + }); + + it('should skip ERC-721 call when standard is ERC1155', async () => { + jest + .spyOn(provider, 'call') + .mockResolvedValueOnce( + encodeAggregate3Response([ + { success: true, data: encodeBalanceOfResult(1) }, + ]), + ); + + const results = await getNftOwnershipForMultipleNfts( + [makeQuery({ standard: 'ERC1155' })], + supportedChainId, + provider, + ); + + expect(results).toStrictEqual([ + { nftAddress, tokenId: '1', isOwned: true }, + ]); + expect(provider.call).toHaveBeenCalledTimes(1); + }); + + it('should not let ERC-1155 result override a definitive ERC-721 result', async () => { + jest.spyOn(provider, 'call').mockResolvedValueOnce( + encodeAggregate3Response([ + { success: true, data: encodeOwnerOfResult(ownerAddress) }, + { success: true, data: encodeBalanceOfResult(0) }, + ]), + ); + + const results = await getNftOwnershipForMultipleNfts( + [makeQuery()], + supportedChainId, + provider, + ); + + expect(results).toStrictEqual([ + { nftAddress, tokenId: '1', isOwned: true }, + ]); + }); + + it('should handle multiple NFTs in a single batch', async () => { + const nftAddress2 = '0x0000000000000000000000000000000000000DEF'; + jest.spyOn(provider, 'call').mockResolvedValueOnce( + encodeAggregate3Response([ + { success: true, data: encodeOwnerOfResult(ownerAddress) }, + { success: false, data: '0x' }, + { success: false, data: '0x' }, + { success: true, data: encodeBalanceOfResult(5) }, + ]), + ); + + const results = await getNftOwnershipForMultipleNfts( + [makeQuery(), makeQuery({ nftAddress: nftAddress2, tokenId: '42' })], + supportedChainId, + provider, + ); + + expect(results).toStrictEqual([ + { nftAddress, tokenId: '1', isOwned: true }, + { nftAddress: nftAddress2, tokenId: '42', isOwned: true }, + ]); + }); + }); + + describe('via individual calls (unsupported chain)', () => { + it('should detect ERC-721 ownership via individual ownerOf call', async () => { + jest + .spyOn(provider, 'call') + .mockResolvedValueOnce(encodeOwnerOfResult(ownerAddress)); + + const results = await getNftOwnershipForMultipleNfts( + [makeQuery()], + unsupportedChainId, + provider, + ); + + expect(results).toStrictEqual([ + { nftAddress, tokenId: '1', isOwned: true }, + ]); + }); + + it('should fall back to ERC-1155 when ERC-721 call reverts', async () => { + jest + .spyOn(provider, 'call') + .mockRejectedValueOnce(new Error('not ERC721')) + .mockResolvedValueOnce(encodeBalanceOfResult(2)); + + const results = await getNftOwnershipForMultipleNfts( + [makeQuery()], + unsupportedChainId, + provider, + ); + + expect(results).toStrictEqual([ + { nftAddress, tokenId: '1', isOwned: true }, + ]); + }); + + it('should return isOwned=undefined when both individual calls fail', async () => { + jest + .spyOn(provider, 'call') + .mockRejectedValueOnce(new Error('not ERC721')) + .mockRejectedValueOnce(new Error('not ERC1155')); + + const results = await getNftOwnershipForMultipleNfts( + [makeQuery()], + unsupportedChainId, + provider, + ); + + expect(results).toStrictEqual([ + { nftAddress, tokenId: '1', isOwned: undefined }, + ]); + }); + + it('should skip ERC-1155 when standard is ERC721', async () => { + jest + .spyOn(provider, 'call') + .mockResolvedValueOnce(encodeOwnerOfResult(ownerAddress)); + + const results = await getNftOwnershipForMultipleNfts( + [makeQuery({ standard: 'ERC721' })], + unsupportedChainId, + provider, + ); + + expect(results).toStrictEqual([ + { nftAddress, tokenId: '1', isOwned: true }, + ]); + expect(provider.call).toHaveBeenCalledTimes(1); + }); + + it('should skip ERC-721 when standard is ERC1155', async () => { + jest + .spyOn(provider, 'call') + .mockResolvedValueOnce(encodeBalanceOfResult(1)); + + const results = await getNftOwnershipForMultipleNfts( + [makeQuery({ standard: 'ERC1155' })], + unsupportedChainId, + provider, + ); + + expect(results).toStrictEqual([ + { nftAddress, tokenId: '1', isOwned: true }, + ]); + expect(provider.call).toHaveBeenCalledTimes(1); + }); + }); + + describe('multicall fallback', () => { + it('should fall back to individual calls when multicall3 throws', async () => { + const callSpy = jest.spyOn(provider, 'call'); + // First call: multicall3 aggregate3 fails + callSpy.mockRejectedValueOnce(new Error('multicall3 reverted')); + // Subsequent calls: individual ownerOf succeeds + callSpy.mockResolvedValueOnce(encodeOwnerOfResult(ownerAddress)); + + const consoleSpy = jest + .spyOn(console, 'warn') + .mockImplementation(() => undefined); + + const results = await getNftOwnershipForMultipleNfts( + [makeQuery()], + supportedChainId, + provider, + ); + + expect(results).toStrictEqual([ + { nftAddress, tokenId: '1', isOwned: true }, + ]); + expect(consoleSpy).toHaveBeenCalledWith( + 'Multicall3 NFT ownership check failed, falling back to individual calls', + expect.any(Error), + ); + + consoleSpy.mockRestore(); + }); + }); + + describe('unknown-standard NFT exclusion', () => { + it('should skip NFTs with explicitly unrecognized standard entirely', async () => { + const cryptoPunksAddress = '0xb47e3cd837dDF8e4c57F05d70Ab865de6e193BBB'; + const callSpy = jest.spyOn(provider, 'call'); + + // Only call: multicall aggregate3 for the known-standard NFT + callSpy.mockResolvedValueOnce( + encodeAggregate3Response([ + { success: true, data: encodeOwnerOfResult(ownerAddress) }, + ]), + ); + + const results = await getNftOwnershipForMultipleNfts( + [ + makeQuery({ standard: 'ERC721' }), + makeQuery({ + nftAddress: cryptoPunksAddress, + tokenId: '1434', + standard: 'UNKNOWN', + }), + ], + supportedChainId, + provider, + ); + + expect(results).toStrictEqual([ + { nftAddress, tokenId: '1', isOwned: true }, + { + nftAddress: cryptoPunksAddress, + tokenId: '1434', + isOwned: undefined, + }, + ]); + // Only 1 multicall call — no individual calls for the UNKNOWN NFT + expect(callSpy).toHaveBeenCalledTimes(1); + }); + + it('should still include standard=null NFTs in multicall batch', async () => { + jest.spyOn(provider, 'call').mockResolvedValueOnce( + encodeAggregate3Response([ + { success: true, data: encodeOwnerOfResult(ownerAddress) }, + { success: false, data: '0x' }, + ]), + ); + + const results = await getNftOwnershipForMultipleNfts( + [makeQuery({ standard: null })], + supportedChainId, + provider, + ); + + expect(results).toStrictEqual([ + { nftAddress, tokenId: '1', isOwned: true }, + ]); + // Single multicall call (no individual calls) + expect(provider.call).toHaveBeenCalledTimes(1); + }); + }); + }); +}); diff --git a/packages/assets-controllers/src/multicall.ts b/packages/assets-controllers/src/multicall.ts new file mode 100644 index 00000000000..f5a6a9b19f6 --- /dev/null +++ b/packages/assets-controllers/src/multicall.ts @@ -0,0 +1,1495 @@ +import { Contract } from '@ethersproject/contracts'; +import type { Web3Provider } from '@ethersproject/providers'; +import type { Hex } from '@metamask/utils'; +import BN from 'bn.js'; + +import { STAKING_CONTRACT_ADDRESS_BY_CHAINID } from './AssetsContractController.js'; +import { reduceInBatchesSerially } from './assetsUtil.js'; + +// https://github.com/mds1/multicall/blob/main/deployments.json +const MULTICALL_CONTRACT_BY_CHAINID = { + '0x1': '0xcA11bde05977b3631167028862bE2a173976CA11', + '0x2a': '0xcA11bde05977b3631167028862bE2a173976CA11', + '0x4': '0xcA11bde05977b3631167028862bE2a173976CA11', + '0x5': '0xcA11bde05977b3631167028862bE2a173976CA11', + '0x3': '0xcA11bde05977b3631167028862bE2a173976CA11', + '0xaa36a7': '0xcA11bde05977b3631167028862bE2a173976CA11', + '0x4268': '0xcA11bde05977b3631167028862bE2a173976CA11', + '0x5e9': '0xcA11bde05977b3631167028862bE2a173976CA11', + '0x1b6e6': '0xcA11bde05977b3631167028862bE2a173976CA11', + '0x18fc4a': '0xcA11bde05977b3631167028862bE2a173976CA11', + '0xa': '0xcA11bde05977b3631167028862bE2a173976CA11', + '0x45': '0xcA11bde05977b3631167028862bE2a173976CA11', + '0x1a4': '0xcA11bde05977b3631167028862bE2a173976CA11', + '0xaa37dc': '0xcA11bde05977b3631167028862bE2a173976CA11', + '0xa4b1': '0xcA11bde05977b3631167028862bE2a173976CA11', + '0xa4ba': '0xcA11bde05977b3631167028862bE2a173976CA11', + '0x66eed': '0xcA11bde05977b3631167028862bE2a173976CA11', + '0x66eee': '0xcA11bde05977b3631167028862bE2a173976CA11', + '0x66eeb': '0xcA11bde05977b3631167028862bE2a173976CA11', + '0x15f2249': '0xcA11bde05977b3631167028862bE2a173976CA11', + '0x89': '0xcA11bde05977b3631167028862bE2a173976CA11', + '0x13881': '0xcA11bde05977b3631167028862bE2a173976CA11', + '0x13882': '0xcA11bde05977b3631167028862bE2a173976CA11', + '0x44d': '0xca11bde05977b3631167028862be2a173976ca11', + '0x5a2': '0xca11bde05977b3631167028862be2a173976ca11', + '0x98a': '0xca11bde05977b3631167028862be2a173976ca11', + '0x64': '0xcA11bde05977b3631167028862bE2a173976CA11', + '0x27d8': '0xcA11bde05977b3631167028862bE2a173976CA11', + '0xa86a': '0xcA11bde05977b3631167028862bE2a173976CA11', + '0xa869': '0xcA11bde05977b3631167028862bE2a173976CA11', + '0xfa2': '0xcA11bde05977b3631167028862bE2a173976CA11', + '0xfa': '0xcA11bde05977b3631167028862bE2a173976CA11', + '0xfaf0': '0xca11bde05977b3631167028862be2a173976ca11', + '0x38': '0xcA11bde05977b3631167028862bE2a173976CA11', + '0x61': '0xcA11bde05977b3631167028862bE2a173976CA11', + '0x15eb': '0xcA11bde05977b3631167028862bE2a173976CA11', + '0xcc': '0xcA11bde05977b3631167028862bE2a173976CA11', + '0x504': '0xcA11bde05977b3631167028862bE2a173976CA11', + '0x505': '0xcA11bde05977b3631167028862bE2a173976CA11', + '0x507': '0xcA11bde05977b3631167028862bE2a173976CA11', + '0x2a15c308d': '0xca11bde05977b3631167028862be2a173976ca11', + '0x2a15c3083': '0xca11bde05977b3631167028862be2a173976ca11', + '0x63564c40': '0xcA11bde05977b3631167028862bE2a173976CA11', + '0x19': '0xcA11bde05977b3631167028862bE2a173976CA11', + '0x152': '0xcA11bde05977b3631167028862bE2a173976CA11', + '0x5535072': '0xcA11bde05977b3631167028862bE2a173976CA11', + '0x6c1': '0xcA11bde05977b3631167028862bE2a173976CA11', + '0x7a': '0xcA11bde05977b3631167028862bE2a173976CA11', + '0xe': '0xcA11bde05977b3631167028862bE2a173976CA11', + '0x13': '0xcA11bde05977b3631167028862bE2a173976CA11', + '0x10': '0xcA11bde05977b3631167028862bE2a173976CA11', + '0x72': '0xcA11bde05977b3631167028862bE2a173976CA11', + '0x120': '0xcA11bde05977b3631167028862bE2a173976CA11', + '0x4e454152': '0xcA11bde05977b3631167028862bE2a173976CA11', + '0x250': '0xcA11bde05977b3631167028862bE2a173976CA11', + '0x5c2359': '0xcA11bde05977b3631167028862bE2a173976CA11', + '0xec0': '0xcA11bde05977b3631167028862bE2a173976CA11', + '0x42': '0xcA11bde05977b3631167028862bE2a173976CA11', + '0x80': '0xcA11bde05977b3631167028862bE2a173976CA11', + '0x440': '0xcA11bde05977b3631167028862bE2a173976CA11', + '0x257': '0xcA11bde05977b3631167028862bE2a173976CA11', + '0xe9fe': '0xcA11bde05977b3631167028862bE2a173976CA11', + '0xd3a0': '0xcA11bde05977b3631167028862bE2a173976CA11', + '0x84444': '0xcA11bde05977b3631167028862bE2a173976CA11', + // Rootstock, bytecode OK and referenced as "RSK" in https://www.multicall3.com/deployments + '0x1e': '0xcA11bde05977b3631167028862bE2a173976CA11', + '0x1f': '0xcA11bde05977b3631167028862bE2a173976CA11', + '0x2329': '0xcA11bde05977b3631167028862bE2a173976CA11', + '0x2328': '0xcA11bde05977b3631167028862bE2a173976CA11', + '0x6c': '0xcA11bde05977b3631167028862bE2a173976CA11', + '0x12': '0xcA11bde05977b3631167028862bE2a173976CA11', + '0xa516': '0xcA11bde05977b3631167028862bE2a173976CA11', + '0x5afe': '0xcA11bde05977b3631167028862bE2a173976CA11', + '0xa4ec': '0xcA11bde05977b3631167028862bE2a173976CA11', + '0xaef3': '0xcA11bde05977b3631167028862bE2a173976CA11', + '0x116ea': '0xcA11bde05977b3631167028862bE2a173976CA11', + '0x116e9': '0xcA11bde05977b3631167028862bE2a173976CA11', + '0x2019': '0xcA11bde05977b3631167028862bE2a173976CA11', + '0x3e9': '0xca11bde05977b3631167028862be2a173976ca11', + '0x7d1': '0xcA11bde05977b3631167028862bE2a173976CA11', + '0x141': '0xcA11bde05977b3631167028862bE2a173976CA11', + '0x6a': '0xcA11bde05977b3631167028862bE2a173976CA11', + '0x28': '0xcA11bde05977b3631167028862bE2a173976CA11', + '0x4d2': '0xcA11bde05977b3631167028862bE2a173976CA11', + '0x1e14': '0xcA11bde05977b3631167028862bE2a173976CA11', + '0x1e15': '0xcA11bde05977b3631167028862bE2a173976CA11', + '0x1251': '0xcA11bde05977b3631167028862bE2a173976CA11', + '0x7f08': '0xcA11bde05977b3631167028862bE2a173976CA11', + '0x8ae': '0xcA11bde05977b3631167028862bE2a173976CA11', + '0x138b': '0xcA11bde05977b3631167028862bE2a173976CA11', + '0x1389': '0xcA11bde05977b3631167028862bE2a173976CA11', + '0x1388': '0xcA11bde05977b3631167028862bE2a173976CA11', + '0x1f92': '0xcA11bde05977b3631167028862bE2a173976CA11', + '0x14a33': '0xca11bde05977b3631167028862be2a173976ca11', + '0x14a34': '0xcA11bde05977b3631167028862bE2a173976CA11', + '0x2105': '0xca11bde05977b3631167028862be2a173976ca11', + '0x936': '0xcA11bde05977b3631167028862bE2a173976CA11', + '0xff': '0xcA11bde05977b3631167028862bE2a173976CA11', + '0x46a': '0xcA11bde05977b3631167028862bE2a173976CA11', + '0x46b': '0xcA11bde05977b3631167028862bE2a173976CA11', + '0x8a': '0xcA11bde05977b3631167028862bE2a173976CA11', + '0x14f': '0xcA11bde05977b3631167028862bE2a173976CA11', + '0xd2af': '0xcA11bde05977b3631167028862bE2a173976CA11', + '0xe9ac0ce': '0xcA11bde05977b3631167028862bE2a173976CA11', + '0xe705': '0xca11bde05977b3631167028862be2a173976ca11', + '0xe704': '0xcA11bde05977b3631167028862bE2a173976CA11', + '0xe708': '0xcA11bde05977b3631167028862bE2a173976CA11', + '0x2b6f': '0xcA11bde05977b3631167028862bE2a173976CA11', + '0x39': '0xcA11bde05977b3631167028862bE2a173976CA11', + '0x23a': '0xcA11bde05977b3631167028862bE2a173976CA11', + '0x1644': '0xcA11bde05977b3631167028862bE2a173976CA11', + '0xdea8': '0xcA11bde05977b3631167028862bE2a173976CA11', + '0x3af': '0xcA11bde05977b3631167028862bE2a173976CA11', + '0x171': '0xcA11bde05977b3631167028862bE2a173976CA11', + // HyperEVM (999) + '0x3e7': '0xcA11bde05977b3631167028862bE2a173976CA11', + '0x76adf1': '0xcA11bde05977b3631167028862bE2a173976CA11', + '0x3b9ac9ff': '0xcA11bde05977b3631167028862bE2a173976CA11', + '0x2c': '0xca11bde05977b3631167028862be2a173976ca11', + '0x2e': '0xca11bde05977b3631167028862be2a173976ca11', + '0x15b3': '0xcA11bde05977b3631167028862bE2a173976CA11', + '0x82751': '0xcA11bde05977b3631167028862bE2a173976CA11', + '0x8274f': '0xca11bde05977b3631167028862be2a173976ca11', + '0x82750': '0xcA11bde05977b3631167028862bE2a173976CA11', + '0x96f': '0xcA11bde05977b3631167028862bE2a173976CA11', + '0x3cc5': '0xcA11bde05977b3631167028862bE2a173976CA11', + '0x4571': '0xcA11bde05977b3631167028862bE2a173976CA11', + '0xe99': '0xcA11bde05977b3631167028862bE2a173976CA11', + '0x7d0': '0xcA11bde05977b3631167028862bE2a173976CA11', + '0x1297': '0xcA11bde05977b3631167028862bE2a173976CA11', + '0x1d5e': '0xcA11bde05977b3631167028862bE2a173976CA11', + '0x3a14269b': '0xcA11bde05977b3631167028862bE2a173976CA11', + '0x561bf78b': '0xcA11bde05977b3631167028862bE2a173976CA11', + '0x235ddd0': '0xcA11bde05977b3631167028862bE2a173976CA11', + '0x3cd156dc': '0xcA11bde05977b3631167028862bE2a173976CA11', + '0x5d456c62': '0xcA11bde05977b3631167028862bE2a173976CA11', + '0x79f99296': '0xcA11bde05977b3631167028862bE2a173976CA11', + '0x585eb4b1': '0xcA11bde05977b3631167028862bE2a173976CA11', + '0x507aaa2a': '0xcA11bde05977b3631167028862bE2a173976CA11', + '0x1fc3': '0xcA11bde05977b3631167028862bE2a173976CA11', + '0x32d': '0xcA11bde05977b3631167028862bE2a173976CA11', + '0x8a73': '0xcA11bde05977b3631167028862bE2a173976CA11', + '0x8a72': '0xcA11bde05977b3631167028862bE2a173976CA11', + '0x8a71': '0xcA11bde05977b3631167028862bE2a173976CA11', + '0xe9ac0d6': '0xca11bde05977b3631167028862be2a173976ca11', + '0x1069': '0xcA11bde05977b3631167028862bE2a173976CA11', + '0x7e5': '0xcA11bde05977b3631167028862bE2a173976CA11', + '0x53': '0xca11bde05977b3631167028862be2a173976ca11', + '0x52': '0xca11bde05977b3631167028862be2a173976ca11', + '0xe298': '0xcA11bde05977b3631167028862bE2a173976CA11', + '0x1a8': '0xcA11bde05977b3631167028862bE2a173976CA11', + '0x94': '0xcA11bde05977b3631167028862bE2a173976CA11', + '0x2c6': '0xcA11bde05977b3631167028862bE2a173976CA11', + '0x2803': '0xcA11bde05977b3631167028862bE2a173976CA11', + '0x2802': '0xcA11bde05977b3631167028862bE2a173976CA11', + '0xa9': '0xcA11bde05977b3631167028862bE2a173976CA11', + '0x28c5f': '0xcA11bde05977b3631167028862bE2a173976CA11', + '0x28c60': '0xcA11bde05977b3631167028862bE2a173976CA11', + '0x13a': '0xcA11bde05977b3631167028862bE2a173976CA11', + '0x4cb2f': '0xdbfa261cd7d17bb40479a0493ad6c0fee435859e', + '0x7f93': '0xcA11bde05977b3631167028862bE2a173976CA11', + '0xb660': '0xcA11bde05977b3631167028862bE2a173976CA11', + '0xb02113d3f': '0xcA11bde05977b3631167028862bE2a173976CA11', + '0xdad': '0xcA11bde05977b3631167028862bE2a173976CA11', + '0xdae': '0xcA11bde05977b3631167028862bE2a173976CA11', + '0x15b38': '0xcA11bde05977b3631167028862bE2a173976CA11', + '0x15b32': '0xcA11bde05977b3631167028862bE2a173976CA11', + '0x45c': '0xcA11bde05977b3631167028862bE2a173976CA11', + '0x45b': '0xcA11bde05977b3631167028862bE2a173976CA11', + '0x3d': '0xcA11bde05977b3631167028862bE2a173976CA11', + '0x41a6ace': '0xcA11bde05977b3631167028862bE2a173976CA11', + // Etherlink mainnet, bytecode OK and referenced in https://www.multicall3.com/deployments + '0xa729': '0xcA11bde05977b3631167028862bE2a173976CA11', + '0x1f47b': '0xcA11bde05977b3631167028862bE2a173976CA11', + '0x1b59': '0xca11bde05977b3631167028862be2a173976ca11', + '0x1b58': '0xcA11bde05977b3631167028862bE2a173976CA11', + '0xc3': '0xca11bde05977b3631167028862be2a173976ca11', + '0x16fd8': '0xcA11bde05977b3631167028862bE2a173976CA11', + '0xc7': '0xca11bde05977b3631167028862be2a173976ca11', + '0x405': '0xca11bde05977b3631167028862be2a173976ca11', + '0x334': '0xcA11bde05977b3631167028862bE2a173976CA11', + '0x1ce': '0xca11bde05977b3631167028862be2a173976ca11', + '0x1cf': '0xca11bde05977b3631167028862be2a173976ca11', + '0xa70e': '0xcA11bde05977b3631167028862bE2a173976CA11', + '0x868b': '0xcA11bde05977b3631167028862bE2a173976CA11', + '0xa0c71fd': '0xcA11bde05977b3631167028862bE2a173976CA11', + '0x13e31': '0xcA11bde05977b3631167028862bE2a173976CA11', + '0xa1337': '0xcA11bde05977b3631167028862bE2a173976CA11', + '0x1f2b': '0xcA11bde05977b3631167028862bE2a173976CA11', + '0xf63': '0xcA11bde05977b3631167028862bE2a173976CA11', + '0x144': '0xF9cda624FBC7e059355ce98a31693d299FACd963', + '0x118': '0xF9cda624FBC7e059355ce98a31693d299FACd963', + '0x12c': '0xF9cda624FBC7e059355ce98a31693d299FACd963', + '0x18995f': '0xF9cda624FBC7e059355ce98a31693d299FACd963', + '0x2b74': '0xF9cda624FBC7e059355ce98a31693d299FACd963', + '0xfc': '0xcA11bde05977b3631167028862bE2a173976CA11', + '0x9da': '0xcA11bde05977b3631167028862bE2a173976CA11', + '0x137': '0xcA11bde05977b3631167028862bE2a173976CA11', + '0x13ed': '0xcA11bde05977b3631167028862bE2a173976CA11', + '0x24b1': '0xcA11bde05977b3631167028862bE2a173976CA11', + '0xba9302': '0xcA11bde05977b3631167028862bE2a173976CA11', + '0x7c8': '0xcA11bde05977b3631167028862bE2a173976CA11', + '0x138d5': '0xcA11bde05977b3631167028862bE2a173976CA11', + '0x6d': '0xcA11bde05977b3631167028862bE2a173976CA11', + '0x343b': '0xcA11bde05977b3631167028862bE2a173976CA11', + '0x34a1': '0xcA11bde05977b3631167028862bE2a173976CA11', + '0x3109': '0xcA11bde05977b3631167028862bE2a173976CA11', + '0x91b': '0xcA11bde05977b3631167028862bE2a173976CA11', + '0xa96': '0xcA11bde05977b3631167028862bE2a173976CA11', + '0x22c3': '0xcA11bde05977b3631167028862bE2a173976CA11', + '0x2be3': '0xcA11bde05977b3631167028862bE2a173976CA11', + '0xbf03': '0xcA11bde05977b3631167028862bE2a173976CA11', + '0x1b254': '0xcA11bde05977b3631167028862bE2a173976CA11', + '0xa7b14': '0xcA11bde05977b3631167028862bE2a173976CA11', + '0x2276': '0xcA11bde05977b3631167028862bE2a173976CA11', + '0x1b9e': '0xcA11bde05977b3631167028862bE2a173976CA11', + '0x6a63bb8': '0xcA11bde05977b3631167028862bE2a173976CA11', + '0x15af3': '0xcA11bde05977b3631167028862bE2a173976CA11', + '0x15af1': '0xcA11bde05977b3631167028862bE2a173976CA11', + '0xae3f3': '0xcA11bde05977b3631167028862bE2a173976CA11', + '0x531': '0xcA11bde05977b3631167028862bE2a173976CA11', + '0x28c61': '0xcA11bde05977b3631167028862bE2a173976CA11', + '0x28c58': '0xca11bde05977b3631167028862be2a173976ca11', + '0x1d88': '0xcA11bde05977b3631167028862bE2a173976CA11', + '0x5b9b': '0xcA11bde05977b3631167028862bE2a173976CA11', + '0x4c7e1': '0xcA11bde05977b3631167028862bE2a173976CA11', + '0xa53b': '0xcA11bde05977b3631167028862bE2a173976CA11', + '0x1a2b': '0xcA11bde05977b3631167028862bE2a173976CA11', + '0x406': '0xca11bde05977b3631167028862be2a173976ca11', + '0x2cef': '0xcA11bde05977b3631167028862bE2a173976CA11', + '0x18b2': '0xca11bde05977b3631167028862be2a173976ca11', + '0x182a9': '0xcA11bde05977b3631167028862bE2a173976CA11', + '0xc4': '0xcA11bde05977b3631167028862bE2a173976CA11', + '0xfdd': '0xcA11bde05977b3631167028862bE2a173976CA11', + '0xfde': '0xcA11bde05977b3631167028862bE2a173976CA11', + '0x99c0a0f': '0xca11bde05977b3631167028862be2a173976ca11', + '0x22cf': '0xcA11bde05977b3631167028862bE2a173976CA11', + '0x310c5': '0xca11bde05977b3631167028862be2a173976ca11', + '0x46f': '0xcA11bde05977b3631167028862bE2a173976CA11', + '0x659': '0xca11bde05977b3631167028862be2a173976ca11', + '0x139c968f9': '0xcA11bde05977b3631167028862bE2a173976CA11', + // BOB, bytecode OK and referenced in https://www.multicall3.com/deployments + '0xed88': '0xcA11bde05977b3631167028862bE2a173976CA11', + '0xd036': '0xcA11bde05977b3631167028862bE2a173976CA11', + '0x1f3': '0xcA11bde05977b3631167028862bE2a173976CA11', + '0x31bf8c3': '0xcA11bde05977b3631167028862bE2a173976CA11', + '0x1cbc67bfdc': '0xcA11bde05977b3631167028862bE2a173976CA11', + '0x98967f': '0xcA11bde05977b3631167028862bE2a173976CA11', + '0x4f588': '0xcA11bde05977b3631167028862bE2a173976CA11', + '0x16db': '0xca11bde05977b3631167028862be2a173976ca11', + '0x3a': '0xca11bde05977b3631167028862be2a173976ca11', + '0x59': '0xca11bde05977b3631167028862be2a173976ca11', + '0x1e0': '0xcA11bde05977b3631167028862bE2a173976CA11', + '0x2eb': '0xcA11bde05977b3631167028862bE2a173976CA11', + '0x221': '0xcA11bde05977b3631167028862bE2a173976CA11', + // Injective, contract found but not in multicall3 repo + '0x6f0': '0xcA11bde05977b3631167028862bE2a173976CA11', + // Hemi, contract found but not in multicall3 repo + '0xa867': '0xcA11bde05977b3631167028862bE2a173976CA11', + // Plasma, contract found but not in multicall3 repo + '0x2611': '0xcA11bde05977b3631167028862bE2a173976CA11', + // Nonmia, contract found but not in multicall3 repo + '0xa6': '0xcA11bde05977b3631167028862bE2a173976CA11', + // XRPL, contract found but not in multicall3 repo + '0x15f900': '0x6B5eFbC0C82eBb26CA13a4F11836f36Fc6fdBC5D', + // Soneium, contract found but not in multicall3 repo + '0x74c': '0xcA11bde05977b3631167028862bE2a173976CA11', + // Genesys, contract found but not in multicall3 repo + '0x407b': '0x90a2377F233E3461BACa6080d4837837d8762927', + // EDU (Animoca) + '0xa3c3': '0xcA11bde05977b3631167028862bE2a173976CA11', + // Abstract + '0xab5': '0xF9cda624FBC7e059355ce98a31693d299FACd963', + // Berachain, contract found but not in multicall3 repo + '0x138de': '0xcA11bde05977b3631167028862bE2a173976CA11', + // MegaETH TESTNET + '0x18c6': '0xcA11bde05977b3631167028862bE2a173976CA11', + // Apechain + '0x8173': '0xcA11bde05977b3631167028862bE2a173976CA11', + // Matchain, contract found but not in multicall3 repo + '0x2ba': '0xcA11bde05977b3631167028862bE2a173976CA11', + // Monad TESTNET + '0x279f': '0xcA11bde05977b3631167028862bE2a173976CA11', + // Katana + '0xb67d2': '0xcA11bde05977b3631167028862bE2a173976CA11', + // Lens, contract found but not in multicall3 repo + '0xe8': '0xcA11bde05977b3631167028862bE2a173976CA11', + // Plume + '0x18232': '0xcA11bde05977b3631167028862bE2a173976CA11', + // Monad Mainnet + '0x8f': '0xcA11bde05977b3631167028862bE2a173976CA11', + // XDC, contract found but not in multicall3 repo + '0x32': '0x0B1795ccA8E4eC4df02346a082df54D437F8D9aF', + // MegaETH TESTNET v2 (timothy chain ID 6343) + '0x18c7': '0xcA11bde05977b3631167028862bE2a173976CA11', + // MegaETH mainnet, contract found matching multicall3 bytecode + '0x10e6': '0xcA11bde05977b3631167028862bE2a173976CA11', + // MSU (contract they deployed by their team for us) + '0x10b3e': '0x99423C88EB5723A590b4C644426069042f137B9e', + // INK Mainnet + '0xdef1': '0xcA11bde05977b3631167028862bE2a173976CA11', + // Stable (988) + '0x3dc': '0xcA11bde05977b3631167028862bE2a173976CA11', + // Tempo Testnet Moderato (42431) + '0xa5bf': '0xcA11bde05977b3631167028862bE2a173976CA11', + // Tempo Mainnet (4217) + '0x1079': '0xcA11bde05977b3631167028862bE2a173976CA11', + // Arc (5042) + '0x13b2': '0xcA11bde05977b3631167028862bE2a173976CA11', + // Robinhood Chain (4663) + '0x1237': '0xcA11bde05977b3631167028862bE2a173976CA11', + // Somnia (5031), MultiCallV3 per docs.somnia.network/developer/smart-contracts + '0x13a7': '0x5e44F178E8cF9B2F5409B6f18ce936aB817C5a11', + // 0G (16661), canonical mds1/multicall3 deployment, live-verified + // (matching bytecode across primary + both fallback RPCs) + '0x4115': '0xcA11bde05977b3631167028862bE2a173976CA11', +} as Record; + +const multicallAbi = [ + { + name: 'tryAggregate', + type: 'function', + stateMutability: 'payable', + inputs: [ + { name: 'requireSuccess', type: 'bool' }, + { + name: 'calls', + type: 'tuple[]', + components: [ + { name: 'target', type: 'address' }, + { name: 'callData', type: 'bytes' }, + ], + }, + ], + outputs: [ + { + name: 'returnData', + type: 'tuple[]', + components: [ + { name: 'success', type: 'bool' }, + { name: 'returnData', type: 'bytes' }, + ], + }, + ], + }, +]; + +// Multicall3 ABI for aggregate3 function +const multicall3Abi = [ + { + name: 'aggregate3', + type: 'function', + stateMutability: 'payable', + inputs: [ + { + name: 'calls', + type: 'tuple[]', + components: [ + { name: 'target', type: 'address' }, + { name: 'allowFailure', type: 'bool' }, + { name: 'callData', type: 'bytes' }, + ], + }, + ], + outputs: [ + { + name: 'returnData', + type: 'tuple[]', + components: [ + { name: 'success', type: 'bool' }, + { name: 'returnData', type: 'bytes' }, + ], + }, + ], + }, +]; + +export type Call = { + contract: Contract; + functionSignature: string; + arguments: unknown[]; +}; + +export type MulticallResult = { success: boolean; value: unknown }; + +export type Aggregate3Call = { + target: string; + allowFailure: boolean; + callData: string; +}; + +export type Aggregate3Result = { + success: boolean; + returnData: string; +}; + +// Constants for encoded strings and addresses +const ZERO_ADDRESS = '0x0000000000000000000000000000000000000000'; +const BALANCE_OF_FUNCTION = 'balanceOf(address)'; +const OWNER_OF_FUNCTION = 'ownerOf(uint256)'; +const ERC1155_BALANCE_OF_FUNCTION = 'balanceOf(address,uint256)'; +const GET_ETH_BALANCE_FUNCTION = 'getEthBalance'; +const GET_SHARES_FUNCTION = 'getShares'; +const CONVERT_TO_ASSETS_FUNCTION = 'convertToAssets'; + +// ERC-721 ownerOf ABI +const ERC721_OWNER_OF_ABI = [ + { + name: 'ownerOf', + type: 'function', + inputs: [{ name: 'tokenId', type: 'uint256' }], + outputs: [{ name: 'owner', type: 'address' }], + stateMutability: 'view', + }, +]; + +// ERC-1155 balanceOf ABI +const ERC1155_BALANCE_OF_ABI = [ + { + name: 'balanceOf', + type: 'function', + inputs: [ + { name: 'account', type: 'address' }, + { name: 'id', type: 'uint256' }, + ], + outputs: [{ name: 'balance', type: 'uint256' }], + stateMutability: 'view', + }, +]; + +// ERC20 balanceOf ABI +const ERC20_BALANCE_OF_ABI = [ + { + name: 'balanceOf', + type: 'function', + inputs: [{ name: 'account', type: 'address' }], + outputs: [{ name: '', type: 'uint256' }], + stateMutability: 'view', + }, +]; + +// Multicall3 getEthBalance ABI +const MULTICALL3_GET_ETH_BALANCE_ABI = [ + { + name: 'getEthBalance', + type: 'function', + inputs: [{ name: 'addr', type: 'address' }], + outputs: [{ name: 'balance', type: 'uint256' }], + stateMutability: 'view', + }, +]; + +// Staking contract ABI with both getShares and convertToAssets +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', + }, +]; + +const multicall = async ( + calls: Call[], + multicallAddress: Hex, + provider: Web3Provider, + maxCallsPerMulticall: number, +): Promise => { + const multicallContract = new Contract( + multicallAddress, + multicallAbi, + provider, + ); + + return await reduceInBatchesSerially({ + values: calls, + batchSize: maxCallsPerMulticall, + initialResult: [], + eachBatch: async (workingResult, batch) => { + const calldata = batch.map((call) => ({ + target: call.contract.address, + callData: call.contract.interface.encodeFunctionData( + call.contract.interface.functions[call.functionSignature], + call.arguments, + ), + })); + + const results = await multicallContract.callStatic.tryAggregate( + false, + calldata, + ); + + return [ + ...workingResult, + ...results.map( + (r: { success: boolean; returnData: string }, i: number) => ({ + success: r.success, + value: r.success + ? batch[i].contract.interface.decodeFunctionResult( + batch[i].functionSignature, + r.returnData, + )[0] + : undefined, + }), + ), + ]; + }, + }); +}; + +const fallback = async ( + calls: Call[], + maxCallsParallel: number, +): Promise => { + return await reduceInBatchesSerially({ + values: calls, + batchSize: maxCallsParallel, + initialResult: [], + eachBatch: async (workingResult, batch) => { + const results = await Promise.allSettled( + batch.map((call) => + call.contract[call.functionSignature](...call.arguments), + ), + ); + return [ + ...workingResult, + ...results.map((p) => ({ + success: p.status === 'fulfilled', + value: p.status === 'fulfilled' ? p.value : undefined, + })), + ]; + }, + }); +}; + +/** + * Executes an array of contract calls. If the chain supports multicalls, + * the calls will be executed in single RPC requests (up to maxCallsPerMulticall). + * Otherwise the calls will be executed separately in parallel (up to maxCallsParallel). + * + * @param calls - An array of contract calls to execute. + * @param chainId - The hexadecimal chain id. + * @param provider - An ethers rpc provider. + * @param maxCallsPerMulticall - If multicall is supported, the maximum number of calls to exeute in each multicall. + * @param maxCallsParallel - If multicall is not supported, the maximum number of calls to execute in parallel. + * @returns An array of results, with a success boolean and value for each call. + */ +export const multicallOrFallback = async ( + calls: Call[], + chainId: Hex, + provider: Web3Provider, + maxCallsPerMulticall = 300, + maxCallsParallel = 20, +): Promise => { + if (calls.length === 0) { + return []; + } + + const multicallAddress = MULTICALL_CONTRACT_BY_CHAINID[chainId]; + if (multicallAddress) { + try { + return await multicall( + calls, + multicallAddress, + provider, + maxCallsPerMulticall, + ); + } catch (error: unknown) { + // Fallback only on revert + // https://docs.ethers.org/v5/troubleshooting/errors/#help-CALL_EXCEPTION + if ( + !error || + typeof error !== 'object' || + !('code' in error) || + error.code !== 'CALL_EXCEPTION' + ) { + throw error; + } + } + } + + return await fallback(calls, maxCallsParallel); +}; + +/** + * Execute multiple contract calls using Multicall3's aggregate3 function. + * This allows for more efficient batch calls with individual failure handling. + * + * @param calls - Array of calls to execute via aggregate3 + * @param chainId - The hexadecimal chain id + * @param provider - An ethers rpc provider + * @returns Promise resolving to array of results from aggregate3 + */ +export const aggregate3 = async ( + calls: Aggregate3Call[], + chainId: Hex, + provider: Web3Provider, +): Promise => { + if (calls.length === 0) { + return []; + } + + const multicall3Address = MULTICALL_CONTRACT_BY_CHAINID[chainId]; + const multicall3Contract = new Contract( + multicall3Address, + multicall3Abi, + provider, + ); + + return await multicall3Contract.callStatic.aggregate3(calls); +}; + +/** + * Processes and decodes balance results from aggregate3 calls + * + * @param results - Array of results from aggregate3 calls + * @param callMapping - Array mapping call indices to token and user addresses + * @param chainId - The hexadecimal chain id + * @param provider - An ethers rpc provider + * @param includeStaked - Whether to include staked balances + * @returns Map of token address to map of user address to balance + */ +const processBalanceResults = ( + results: Aggregate3Result[], + callMapping: { + tokenAddress: string; + userAddress: string; + callType: 'erc20' | 'native' | 'staking'; + }[], + chainId: Hex, + provider: Web3Provider, + includeStaked: boolean, +): { + tokenBalances: Record>; + stakedBalances?: Record; +} => { + const balanceMap: Record> = {}; + const stakedBalanceMap: Record = {}; + + // Create contract instances for decoding + const erc20Contract = new Contract( + ZERO_ADDRESS, + ERC20_BALANCE_OF_ABI, + provider, + ); + + const multicall3Address = MULTICALL_CONTRACT_BY_CHAINID[chainId]; + const multicall3Contract = new Contract( + multicall3Address, + MULTICALL3_GET_ETH_BALANCE_ABI, + provider, + ); + + // Staking contracts are now handled separately in two-step process + + results.forEach((result, index) => { + if (result.success) { + const { tokenAddress, userAddress, callType } = callMapping[index]; + if (callType === 'native') { + // For native token, decode the getEthBalance result + const balanceRaw = multicall3Contract.interface.decodeFunctionResult( + GET_ETH_BALANCE_FUNCTION, + result.returnData, + )[0]; + + if (!balanceMap[tokenAddress]) { + balanceMap[tokenAddress] = {}; + } + balanceMap[tokenAddress][userAddress] = new BN(balanceRaw.toString()); + } else if (callType === 'staking') { + // Staking is now handled separately in two-step process + // This case should not occur anymore + console.warn( + 'Staking callType found in main processing - this should not happen', + ); + } else { + // For ERC20 tokens, decode the balanceOf result + const balanceRaw = erc20Contract.interface.decodeFunctionResult( + BALANCE_OF_FUNCTION, + result.returnData, + )[0]; + + if (!balanceMap[tokenAddress]) { + balanceMap[tokenAddress] = {}; + } + balanceMap[tokenAddress][userAddress] = new BN(balanceRaw.toString()); + } + } + }); + + const result: { + tokenBalances: Record>; + stakedBalances?: Record; + } = { tokenBalances: balanceMap }; + + if (includeStaked && Object.keys(stakedBalanceMap).length > 0) { + result.stakedBalances = stakedBalanceMap; + } + + return result; +}; + +/** + * Fallback function to get native token balances using individual eth_getBalance calls + * when Multicall3 is not supported on the chain. + * + * @param userAddresses - Array of user addresses to check balances for + * @param provider - An ethers rpc provider + * @param maxCallsParallel - Maximum number of parallel calls (default: 20) + * @returns Promise resolving to map of user address to balance + */ +const getNativeBalancesFallback = async ( + userAddresses: string[], + provider: Web3Provider, + maxCallsParallel = 20, +): Promise> => { + const balanceMap: Record = {}; + + await reduceInBatchesSerially({ + values: userAddresses, + batchSize: maxCallsParallel, + initialResult: undefined, + eachBatch: async (_, batch) => { + const results = await Promise.allSettled( + batch.map(async (userAddress) => { + const balance = await provider.getBalance(userAddress); + return { + success: true, + balance: new BN(balance.toString()), + userAddress, + }; + }), + ); + + results.forEach((result) => { + if ( + result.status === 'fulfilled' && + result.value.success && + result.value.balance !== null + ) { + balanceMap[result.value.userAddress] = result.value.balance; + } + }); + }, + }); + + return balanceMap; +}; + +/** + * Fallback function to get token balances using individual calls + * when Multicall3 is not supported or when aggregate3 calls fail. + * + * @param tokenAddresses - Array of ERC20 token contract addresses + * @param userAddresses - Array of user addresses to check balances for + * @param provider - An ethers rpc provider + * @param includeNative - Whether to include native token balances (default: true) + * @param maxCallsParallel - Maximum number of parallel calls (default: 20) + * @returns Promise resolving to map of token address to map of user address to balance + */ +const getTokenBalancesFallback = async ( + tokenAddresses: string[], + userAddresses: string[], + provider: Web3Provider, + includeNative: boolean, + maxCallsParallel: number, +): Promise>> => { + const balanceMap: Record> = {}; + + // Handle ERC20 token balances using the existing fallback function + if (tokenAddresses.length > 0) { + const erc20Calls: Call[] = []; + const callMapping: { tokenAddress: string; userAddress: string }[] = []; + + tokenAddresses.forEach((tokenAddress) => { + userAddresses.forEach((userAddress) => { + const contract = new Contract( + tokenAddress, + ERC20_BALANCE_OF_ABI, + provider, + ); + erc20Calls.push({ + contract, + functionSignature: BALANCE_OF_FUNCTION, + arguments: [userAddress], + }); + callMapping.push({ tokenAddress, userAddress }); + }); + }); + + const erc20Results = await fallback(erc20Calls, maxCallsParallel); + erc20Results.forEach((result, index) => { + if (result.success) { + const { tokenAddress, userAddress } = callMapping[index]; + if (!balanceMap[tokenAddress]) { + balanceMap[tokenAddress] = {}; + } + balanceMap[tokenAddress][userAddress] = result.value as BN; + } + }); + } + + // Handle native token balances using the native fallback function + if (includeNative) { + const nativeBalances = await getNativeBalancesFallback( + userAddresses, + provider, + maxCallsParallel, + ); + if (Object.keys(nativeBalances).length > 0) { + balanceMap[ZERO_ADDRESS] = nativeBalances; + } + } + + return balanceMap; +}; + +/** + * Fallback function to get staked balances using individual calls + * when Multicall3 is not supported or when aggregate3 calls fail. + * + * @param userAddresses - Array of user addresses to check staked balances for + * @param chainId - The hexadecimal chain id + * @param provider - An ethers rpc provider + * @param maxCallsParallel - Maximum number of parallel calls (default: 20) + * @returns Promise resolving to map of user address to staked balance + */ +const getStakedBalancesFallback = async ( + userAddresses: string[], + chainId: Hex, + provider: Web3Provider, + maxCallsParallel: number, +): Promise> => { + const stakedBalanceMap: Record = {}; + + const stakingContractAddress = STAKING_CONTRACT_ADDRESS_BY_CHAINID[chainId]; + + if (!stakingContractAddress) { + // No staking support for this chain + return stakedBalanceMap; + } + + const stakingCalls: Call[] = []; + const callMapping: { userAddress: string }[] = []; + + userAddresses.forEach((userAddress) => { + const contract = new Contract( + stakingContractAddress, + STAKING_CONTRACT_ABI, + provider, + ); + stakingCalls.push({ + contract, + functionSignature: GET_SHARES_FUNCTION, + arguments: [userAddress], + }); + callMapping.push({ userAddress }); + }); + + const stakingResults = await fallback(stakingCalls, maxCallsParallel); + stakingResults.forEach((result, index) => { + if (result.success) { + const { userAddress } = callMapping[index]; + stakedBalanceMap[userAddress] = result.value as BN; + } + }); + + return stakedBalanceMap; +}; + +/** + * Get staked balances for multiple addresses using two-step process: + * 1. Get shares for all addresses + * 2. Convert non-zero shares to assets + * + * @param userAddresses - Array of user addresses to check + * @param chainId - Chain ID as hex string + * @param provider - Ethers provider + * @returns Promise resolving to map of user address to staked balance + */ +export const getStakedBalancesForAddresses = async ( + userAddresses: string[], + chainId: Hex, + provider: Web3Provider, +): Promise> => { + const stakingContractAddress = STAKING_CONTRACT_ADDRESS_BY_CHAINID[chainId]; + + if (!stakingContractAddress) { + return {}; + } + + const stakingContract = new Contract( + stakingContractAddress, + STAKING_CONTRACT_ABI, + provider, + ); + + try { + // Step 1: Get shares for all addresses + const shareCalls: Aggregate3Call[] = userAddresses.map((userAddress) => ({ + target: stakingContractAddress, + allowFailure: true, + callData: stakingContract.interface.encodeFunctionData( + GET_SHARES_FUNCTION, + [userAddress], + ), + })); + + const shareResults = await aggregate3(shareCalls, chainId, provider); + + // Step 2: For addresses with non-zero shares, convert to assets + const nonZeroSharesData: { address: string; shares: BN }[] = []; + shareResults.forEach((result, index) => { + if (result.success) { + const sharesRaw = stakingContract.interface.decodeFunctionResult( + GET_SHARES_FUNCTION, + result.returnData, + )[0]; + const shares = new BN(sharesRaw.toString()); + + if (shares.gt(new BN(0))) { + nonZeroSharesData.push({ + address: userAddresses[index], + shares, + }); + } + } + }); + + if (nonZeroSharesData.length === 0) { + return {}; + } + + // Step 3: Convert shares to assets for addresses with non-zero shares + const assetCalls: Aggregate3Call[] = nonZeroSharesData.map( + ({ shares }) => ({ + target: stakingContractAddress, + allowFailure: true, + callData: stakingContract.interface.encodeFunctionData( + CONVERT_TO_ASSETS_FUNCTION, + [shares.toString()], + ), + }), + ); + + const assetResults = await aggregate3(assetCalls, chainId, provider); + + // Step 4: Build final result mapping + const result: Record = {}; + assetResults.forEach((assetResult, index) => { + if (assetResult.success) { + const assetsRaw = stakingContract.interface.decodeFunctionResult( + CONVERT_TO_ASSETS_FUNCTION, + assetResult.returnData, + )[0]; + const assets = new BN(assetsRaw.toString()); + + const { address } = nonZeroSharesData[index]; + result[address] = assets; + } + }); + + return result; + } catch (error) { + console.error('Error fetching staked balances:', error); + return {}; + } +}; + +/** + * Get token balances (both ERC20 and native) for multiple addresses using aggregate3. + * This is more efficient than individual balanceOf calls for multiple addresses and tokens. + * Native token balances are mapped to the zero address (0x0000000000000000000000000000000000000000). + * + * @param accountTokenGroups - Array of objects containing account addresses and their associated token addresses + * @param chainId - The hexadecimal chain id + * @param provider - An ethers rpc provider + * @param includeNative - Whether to include native token balances (default: true) + * @param includeStaked - Whether to include staked balances from supported staking contracts (default: false) + * @returns Promise resolving to object containing tokenBalances map and optional stakedBalances map + */ +export const getTokenBalancesForMultipleAddresses = async ( + accountTokenGroups: { accountAddress: Hex; tokenAddresses: Hex[] }[], + chainId: Hex, + provider: Web3Provider, + includeNative: boolean, + includeStaked: boolean, +): Promise<{ + tokenBalances: Record>; + stakedBalances?: Record; +}> => { + // Return early if no groups provided + if (accountTokenGroups.length === 0 && !includeNative && !includeStaked) { + return { tokenBalances: {} }; + } + + // Extract unique token addresses and user addresses from groups + const uniqueTokenAddresses = Array.from( + new Set(accountTokenGroups.flatMap((group) => group.tokenAddresses)), + ).filter((tokenAddress) => tokenAddress !== ZERO_ADDRESS); // Exclude native token from ERC20 calls + + const uniqueUserAddresses = Array.from( + new Set(accountTokenGroups.map((group) => group.accountAddress)), + ); + + // Check if Multicall3 is supported on this chain + if (!MULTICALL_CONTRACT_BY_CHAINID[chainId]) { + // Fallback to individual balance calls when Multicall3 is not supported + const tokenBalances = await getTokenBalancesFallback( + uniqueTokenAddresses, + uniqueUserAddresses, + provider, + includeNative, + 20, + ); + + const result: { + tokenBalances: Record>; + stakedBalances?: Record; + } = { tokenBalances }; + + // Handle staked balances fallback if requested + if (includeStaked) { + const stakedBalances = await getStakedBalancesFallback( + uniqueUserAddresses, + chainId, + provider, + 20, + ); + + if (Object.keys(stakedBalances).length > 0) { + result.stakedBalances = stakedBalances; + } + } + + return result; + } + + try { + // Create calls directly from pairs + const allCalls: Aggregate3Call[] = []; + const allCallMapping: { + tokenAddress: string; + userAddress: string; + callType: 'erc20' | 'native' | 'staking'; + }[] = []; + + // Create a temporary ERC20 contract for encoding + const tempERC20Contract = new Contract( + ZERO_ADDRESS, + ERC20_BALANCE_OF_ABI, + provider, + ); + + // Create ERC20 balance calls for all account-token combinations + accountTokenGroups.forEach((group) => { + group.tokenAddresses + .filter((tokenAddress) => tokenAddress !== ZERO_ADDRESS) + .forEach((tokenAddress) => { + allCalls.push({ + target: tokenAddress, + allowFailure: true, + callData: tempERC20Contract.interface.encodeFunctionData( + BALANCE_OF_FUNCTION, + [group.accountAddress], + ), + }); + allCallMapping.push({ + tokenAddress, + userAddress: group.accountAddress, + callType: 'erc20', + }); + }); + }); + + // Add native token balance calls if requested + if (includeNative) { + const multicall3Address = MULTICALL_CONTRACT_BY_CHAINID[chainId]; + const multicall3TempContract = new Contract( + multicall3Address, + MULTICALL3_GET_ETH_BALANCE_ABI, + provider, + ); + + uniqueUserAddresses.forEach((userAddress) => { + allCalls.push({ + target: multicall3Address, + allowFailure: true, + callData: multicall3TempContract.interface.encodeFunctionData( + GET_ETH_BALANCE_FUNCTION, + [userAddress], + ), + }); + allCallMapping.push({ + tokenAddress: ZERO_ADDRESS, + userAddress, + callType: 'native', + }); + }); + } + + // Note: Staking balances will be handled separately in two steps after token/native calls + + // Execute all calls in batches + const maxCallsPerBatch = 300; // Limit calls per batch to avoid gas/size limits + const allResults: Aggregate3Result[] = []; + + await reduceInBatchesSerially({ + values: allCalls, + batchSize: maxCallsPerBatch, + initialResult: undefined, + eachBatch: async (_, batch) => { + const batchResults = await aggregate3(batch, chainId, provider); + allResults.push(...batchResults); + }, + }); + + // Handle staking balances in two steps if requested + let stakedBalances: Record = {}; + if (includeStaked) { + stakedBalances = await getStakedBalancesForAddresses( + uniqueUserAddresses, + chainId, + provider, + ); + } + + // Process and return results + const result = processBalanceResults( + allResults, + allCallMapping, + chainId, + provider, + false, // Don't include staked from main processing + ); + + // Add staked balances to result + if (includeStaked && Object.keys(stakedBalances).length > 0) { + result.stakedBalances = stakedBalances; + } + + return result; + } catch (error) { + // Fallback only on revert + // https://docs.ethers.org/v5/troubleshooting/errors/#help-CALL_EXCEPTION + if ( + !error || + typeof error !== 'object' || + !('code' in error) || + error.code !== 'CALL_EXCEPTION' + ) { + throw error; + } + + // Fallback to individual balance calls when aggregate3 fails + const tokenBalances = await getTokenBalancesFallback( + uniqueTokenAddresses, + uniqueUserAddresses, + provider, + includeNative, + 20, + ); + + const result: { + tokenBalances: Record>; + stakedBalances?: Record; + } = { tokenBalances }; + + // Handle staked balances fallback if requested + if (includeStaked) { + const stakedBalances = await getStakedBalancesFallback( + uniqueUserAddresses, + chainId, + provider, + 20, + ); + + if (Object.keys(stakedBalances).length > 0) { + result.stakedBalances = stakedBalances; + } + } + + return result; + } +}; + +export type NftOwnershipQuery = { + nftAddress: string; + tokenId: string; + userAddress: string; + standard: string | null; +}; + +export type NftOwnershipResult = { + nftAddress: string; + tokenId: string; + isOwned: boolean | undefined; +}; + +type NftCallMeta = { nftIndex: number; callVariant: 'erc721' | 'erc1155' }; + +const normalizeNftStandard = ( + standard: string | null, +): 'ERC721' | 'ERC1155' | null => { + if (!standard) { + return null; + } + const upper = standard.toUpperCase(); + if (upper === 'ERC721') { + return 'ERC721'; + } + if (upper === 'ERC1155') { + return 'ERC1155'; + } + return null; +}; + +const getNftOwnershipViaMulticall = async ( + nfts: NftOwnershipQuery[], + chainId: Hex, + provider: Web3Provider, +): Promise => { + const erc721Contract = new Contract( + ZERO_ADDRESS, + ERC721_OWNER_OF_ABI, + provider, + ); + const erc1155Contract = new Contract( + ZERO_ADDRESS, + ERC1155_BALANCE_OF_ABI, + provider, + ); + + const calls: Aggregate3Call[] = []; + const meta: NftCallMeta[] = []; + + // When the standard is known, emit only the relevant call to halve the number + // of multicall subcalls. When unknown (null), emit both — ERC-721 first so + // the `!== undefined` guard below replicates the original early-return + // behavior: once ERC-721 gives a definitive answer, ERC-1155 is skipped. + nfts.forEach(({ nftAddress, tokenId, userAddress, standard }, i) => { + const normalized = normalizeNftStandard(standard); + const tryErc721 = normalized !== 'ERC1155'; + const tryErc1155 = normalized !== 'ERC721'; + + if (tryErc721) { + calls.push({ + target: nftAddress, + allowFailure: true, + callData: erc721Contract.interface.encodeFunctionData( + OWNER_OF_FUNCTION, + [tokenId], + ), + }); + meta.push({ nftIndex: i, callVariant: 'erc721' }); + } + + if (tryErc1155) { + calls.push({ + target: nftAddress, + allowFailure: true, + callData: erc1155Contract.interface.encodeFunctionData( + ERC1155_BALANCE_OF_FUNCTION, + [userAddress, tokenId], + ), + }); + meta.push({ nftIndex: i, callVariant: 'erc1155' }); + } + }); + + const maxCallsPerBatch = 300; + const allReturnData: Aggregate3Result[] = []; + // Batches are processed serially and results are pushed in order, so + // allReturnData[i] always corresponds to meta[i]. Do NOT change this to + // parallel batching without also reworking the index correspondence. + await reduceInBatchesSerially({ + values: calls, + batchSize: maxCallsPerBatch, + initialResult: undefined, + eachBatch: async (_, batch) => { + const batchResults = await aggregate3(batch, chainId, provider); + allReturnData.push(...batchResults); + }, + }); + + const results: NftOwnershipResult[] = nfts.map(({ nftAddress, tokenId }) => ({ + nftAddress, + tokenId, + isOwned: undefined, + })); + + allReturnData.forEach(({ success, returnData: data }, i) => { + if (!success) { + return; + } + const { nftIndex, callVariant } = meta[i]; + if (results[nftIndex].isOwned !== undefined) { + return; + } + try { + if (callVariant === 'erc721') { + const [owner] = erc721Contract.interface.decodeFunctionResult( + OWNER_OF_FUNCTION, + data, + ); + results[nftIndex].isOwned = + owner.toLowerCase() === nfts[nftIndex].userAddress.toLowerCase(); + } else { + const [balance] = erc1155Contract.interface.decodeFunctionResult( + ERC1155_BALANCE_OF_FUNCTION, + data, + ); + results[nftIndex].isOwned = new BN(balance.toString()).gt(new BN(0)); + } + } catch { + // Malformed return data from a non-standard contract; leave isOwned as undefined. + } + }); + + return results; +}; + +const MAX_PARALLEL_NFT_CALLS = 50; + +const getNftOwnershipIndividually = async ( + nfts: NftOwnershipQuery[], + provider: Web3Provider, +): Promise => { + const erc721Iface = new Contract(ZERO_ADDRESS, ERC721_OWNER_OF_ABI, provider) + .interface; + const erc1155Iface = new Contract( + ZERO_ADDRESS, + ERC1155_BALANCE_OF_ABI, + provider, + ).interface; + + return reduceInBatchesSerially({ + values: nfts, + batchSize: MAX_PARALLEL_NFT_CALLS, + initialResult: [], + eachBatch: async (workingResult, batch) => { + const batchResults = await Promise.all( + batch.map(async ({ nftAddress, tokenId, userAddress, standard }) => { + let isOwned: boolean | undefined; + const normalized = normalizeNftStandard(standard); + const tryErc721 = normalized !== 'ERC1155'; + const tryErc1155 = normalized !== 'ERC721'; + + if (tryErc721) { + try { + const callData = erc721Iface.encodeFunctionData( + OWNER_OF_FUNCTION, + [tokenId], + ); + const raw = await provider.call({ + to: nftAddress, + data: callData, + }); + const [owner] = erc721Iface.decodeFunctionResult( + OWNER_OF_FUNCTION, + raw, + ); + isOwned = owner.toLowerCase() === userAddress.toLowerCase(); + } catch { + // ERC-721 unavailable; try ERC-1155 below if applicable. + } + } + + if (isOwned === undefined && tryErc1155) { + try { + const callData = erc1155Iface.encodeFunctionData( + ERC1155_BALANCE_OF_FUNCTION, + [userAddress, tokenId], + ); + const raw = await provider.call({ + to: nftAddress, + data: callData, + }); + const [balance] = erc1155Iface.decodeFunctionResult( + ERC1155_BALANCE_OF_FUNCTION, + raw, + ); + isOwned = new BN(balance.toString()).gt(new BN(0)); + } catch { + // ownership remains undefined + } + } + + return { nftAddress, tokenId, isOwned }; + }), + ); + return [...workingResult, ...batchResults]; + }, + }); +}; + +/** + * Check ownership for multiple NFTs, using Multicall3 when available to batch + * all calls into a single RPC request, falling back to individual calls otherwise. + * + * @param nfts - Array of NFT queries containing address, tokenId, owner address, and standard. + * @param chainId - The hexadecimal chain id. + * @param provider - An ethers rpc provider. + * @returns Promise resolving to array of ownership results. `isOwned` is `undefined` + * when ownership could not be determined (e.g. unsupported standard or RPC error). + */ +export const getNftOwnershipForMultipleNfts = async ( + nfts: NftOwnershipQuery[], + chainId: Hex, + provider: Web3Provider, +): Promise => { + if (nfts.length === 0) { + return []; + } + + const results: NftOwnershipResult[] = nfts.map(({ nftAddress, tokenId }) => ({ + nftAddress, + tokenId, + isOwned: undefined, + })); + + // Filter out NFTs whose standard is explicitly unrecognized (e.g. + // CryptoPunks with standard="UNKNOWN"). Such contracts use pre-Solidity- + // 0.4.10 bytecode that compiles unrecognized selectors to the INVALID + // opcode, which consumes ALL forwarded gas. Including them in a Multicall3 + // aggregate3 batch causes the entire batch to revert, and calling them + // individually also always fails. They stay as isOwned=undefined. + // + // NFTs with `standard: null` (not yet categorized) are still included + // because they are likely valid ERC-721/ERC-1155 contracts. + const callable = nfts.reduce<{ nft: NftOwnershipQuery; index: number }[]>( + (acc, nft, index) => { + const hasExplicitNonStandard = + nft.standard !== null && normalizeNftStandard(nft.standard) === null; + if (!hasExplicitNonStandard) { + acc.push({ nft, index }); + } + return acc; + }, + [], + ); + + if (callable.length === 0) { + return results; + } + + const multicallAddress = MULTICALL_CONTRACT_BY_CHAINID[chainId]; + + if (multicallAddress) { + try { + const batchResults = await getNftOwnershipViaMulticall( + callable.map(({ nft }) => nft), + chainId, + provider, + ); + batchResults.forEach((result, batchIndex) => { + results[callable[batchIndex].index] = result; + }); + return results; + } catch (error) { + console.warn( + 'Multicall3 NFT ownership check failed, falling back to individual calls', + error, + ); + } + } + + const individualResults = await getNftOwnershipIndividually( + callable.map(({ nft }) => nft), + provider, + ); + individualResults.forEach((result, batchIndex) => { + results[callable[batchIndex].index] = result; + }); + + return results; +}; diff --git a/packages/assets-controllers/src/rpc-service/rpc-balance-fetcher.test.ts b/packages/assets-controllers/src/rpc-service/rpc-balance-fetcher.test.ts new file mode 100644 index 00000000000..8bf6cd2ac1f --- /dev/null +++ b/packages/assets-controllers/src/rpc-service/rpc-balance-fetcher.test.ts @@ -0,0 +1,970 @@ +import type { Web3Provider } from '@ethersproject/providers'; +import type { InternalAccount } from '@metamask/keyring-internal-api'; +import type { NetworkClient } from '@metamask/network-controller'; +import BN from 'bn.js'; + +import type { UnprocessedTokens } from '../multi-chain-accounts-service/api-balance-fetcher.js'; +import type { TokensControllerState } from '../TokensController.js'; +import { RpcBalanceFetcher } from './rpc-balance-fetcher.js'; +import type { ChainIdHex, ChecksumAddress } from './rpc-balance-fetcher.js'; + +const MOCK_ADDRESS_1 = '0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045'; +const MOCK_ADDRESS_2 = '0x742d35cc6675c4f17f41140100aa83a4b1fa4c82'; +const MOCK_TOKEN_ADDRESS_1 = '0x6B175474E89094C44Da98b954EedeAC495271d0F'; +const MOCK_TOKEN_ADDRESS_2 = '0xA0b86a33E6441c86c33E1C6B9cD964c0BA2A86B'; +const MOCK_CHAIN_ID = '0x1' as ChainIdHex; +const MOCK_CHAIN_ID_2 = '0x89' as ChainIdHex; +const ZERO_ADDRESS = + '0x0000000000000000000000000000000000000000' as ChecksumAddress; +const STAKING_CONTRACT_ADDRESS = + '0x4FEF9D741011476750A243aC70b9789a63dd47Df' as ChecksumAddress; + +const MOCK_INTERNAL_ACCOUNTS: InternalAccount[] = [ + { + id: '1', + address: MOCK_ADDRESS_1, + type: 'eip155:eoa', + options: {}, + methods: [], + scopes: [], + metadata: { + name: 'Account 1', + importTime: Date.now(), + keyring: { + type: 'HD Key Tree', + }, + }, + }, + { + id: '2', + address: MOCK_ADDRESS_2, + type: 'eip155:eoa', + options: {}, + methods: [], + scopes: [], + metadata: { + name: 'Account 2', + importTime: Date.now(), + keyring: { + type: 'HD Key Tree', + }, + }, + }, +]; + +const MOCK_TOKENS_STATE: { + allTokens: TokensControllerState['allTokens']; + allDetectedTokens: TokensControllerState['allDetectedTokens']; +} = { + allTokens: { + [MOCK_CHAIN_ID]: { + [MOCK_ADDRESS_1]: [ + { + address: MOCK_TOKEN_ADDRESS_1, + decimals: 18, + symbol: 'DAI', + name: 'Dai Stablecoin', + }, + ], + [MOCK_ADDRESS_2]: [ + { + address: MOCK_TOKEN_ADDRESS_2, + decimals: 6, + symbol: 'USDC', + name: 'USD Coin', + }, + ], + }, + [MOCK_CHAIN_ID_2]: { + [MOCK_ADDRESS_1]: [ + { + address: MOCK_TOKEN_ADDRESS_1, + decimals: 18, + symbol: 'DAI', + name: 'Dai Stablecoin', + }, + ], + }, + }, + allDetectedTokens: { + [MOCK_CHAIN_ID]: { + [MOCK_ADDRESS_1]: [ + { + address: MOCK_TOKEN_ADDRESS_2, + decimals: 6, + symbol: 'USDC', + name: 'USD Coin (Detected)', + }, + ], + }, + }, +}; + +const MOCK_TOKEN_BALANCES = { + [MOCK_TOKEN_ADDRESS_1]: { + [MOCK_ADDRESS_1]: new BN('1000000000000000000'), // 1 DAI + [MOCK_ADDRESS_2]: new BN('2000000000000000000'), // 2 DAI + }, + [MOCK_TOKEN_ADDRESS_2]: { + [MOCK_ADDRESS_1]: new BN('500000000'), // 500 USDC + [MOCK_ADDRESS_2]: null, // Failed balance + }, + [ZERO_ADDRESS]: { + [MOCK_ADDRESS_1]: new BN('3000000000000000000'), // 3 ETH + [MOCK_ADDRESS_2]: new BN('4000000000000000000'), // 4 ETH + }, +}; + +const MOCK_STAKED_BALANCES = { + [MOCK_ADDRESS_1]: new BN('5000000000000000000'), // 5 ETH staked + [MOCK_ADDRESS_2]: new BN('6000000000000000000'), // 6 ETH staked +}; + +// Mock the imports +jest.mock('@metamask/controller-utils', () => ({ + toChecksumHexAddress: jest.fn(), + safelyExecuteWithTimeout: jest.fn(), +})); + +jest.mock('../multicall', () => ({ + getTokenBalancesForMultipleAddresses: jest.fn(), +})); + +const mockToChecksumHexAddress = jest.requireMock( + '@metamask/controller-utils', +).toChecksumHexAddress; +const mockSafelyExecuteWithTimeout = jest.requireMock( + '@metamask/controller-utils', +).safelyExecuteWithTimeout; +const mockGetTokenBalancesForMultipleAddresses = + jest.requireMock('../multicall').getTokenBalancesForMultipleAddresses; + +describe('RpcBalanceFetcher', () => { + let rpcBalanceFetcher: RpcBalanceFetcher; + let mockProvider: jest.Mocked; + let mockGetProvider: jest.Mock; + let mockGetNetworkClient: jest.Mock; + let mockGetTokensState: jest.Mock; + let mockNetworkClient: jest.Mocked; + + beforeEach(() => { + jest.clearAllMocks(); + jest.resetAllMocks(); + + // Setup mock provider + mockProvider = { + send: jest.fn(), + } as unknown as jest.Mocked; + + // Setup mock network client + mockNetworkClient = { + blockTracker: { + checkForLatestBlock: jest.fn().mockResolvedValue(undefined), + }, + } as unknown as jest.Mocked; + + // Setup mock functions + mockGetProvider = jest.fn().mockReturnValue(mockProvider); + mockGetNetworkClient = jest.fn().mockReturnValue(mockNetworkClient); + mockGetTokensState = jest.fn().mockReturnValue(MOCK_TOKENS_STATE); + + // Setup mock implementations + mockToChecksumHexAddress.mockImplementation((address: string) => { + // Properly checksum the staking contract address for tests + if ( + address.toLowerCase() === '0x4fef9d741011476750a243ac70b9789a63dd47df' + ) { + return '0x4FEF9D741011476750A243aC70b9789a63dd47Df'; + } + // For other addresses, use the actual implementation + const { toChecksumHexAddress } = jest.requireActual( + '@metamask/controller-utils', + ); + return toChecksumHexAddress(address); + }); + + // Mock safelyExecuteWithTimeout to just execute the function + mockSafelyExecuteWithTimeout.mockImplementation( + async (operation: () => Promise) => { + try { + return await operation(); + } catch { + return undefined; + } + }, + ); + + mockGetTokenBalancesForMultipleAddresses.mockResolvedValue({ + tokenBalances: MOCK_TOKEN_BALANCES, + stakedBalances: MOCK_STAKED_BALANCES, + }); + + mockProvider.send.mockResolvedValue('0x12345'); // Mock block number + + rpcBalanceFetcher = new RpcBalanceFetcher( + mockGetProvider, + mockGetNetworkClient, + mockGetTokensState, + ); + }); + + describe('constructor', () => { + it('should create instance with provider, network client, and tokens state getters', () => { + expect(rpcBalanceFetcher).toBeInstanceOf(RpcBalanceFetcher); + }); + }); + + describe('supports', () => { + it('should always return true (fallback provider)', () => { + expect(rpcBalanceFetcher.supports()).toBe(true); + }); + }); + + describe('fetch', () => { + it('should return empty array when no chain IDs are provided', async () => { + const result = await rpcBalanceFetcher.fetch({ + chainIds: [], + queryAllAccounts: false, + selectedAccount: MOCK_ADDRESS_1 as ChecksumAddress, + allAccounts: MOCK_INTERNAL_ACCOUNTS, + }); + + expect(result).toStrictEqual({ balances: [] }); + expect(mockGetTokensState).not.toHaveBeenCalled(); + expect(mockGetProvider).not.toHaveBeenCalled(); + }); + + it('should fetch balances for selected account only', async () => { + // Use a simpler tokens state for this test + const simpleTokensState = { + allTokens: { + [MOCK_CHAIN_ID]: { + [MOCK_ADDRESS_1]: [ + { + address: MOCK_TOKEN_ADDRESS_1, + decimals: 18, + symbol: 'DAI', + name: 'Dai Stablecoin', + }, + ], + }, + }, + allDetectedTokens: {}, + }; + mockGetTokensState.mockReturnValue(simpleTokensState); + + const result = await rpcBalanceFetcher.fetch({ + chainIds: [MOCK_CHAIN_ID], + queryAllAccounts: false, + selectedAccount: MOCK_ADDRESS_1 as ChecksumAddress, + allAccounts: MOCK_INTERNAL_ACCOUNTS, + }); + + expect(mockGetTokensState).toHaveBeenCalled(); + expect(mockGetProvider).toHaveBeenCalledWith(MOCK_CHAIN_ID); + expect(mockGetNetworkClient).toHaveBeenCalledWith(MOCK_CHAIN_ID); + expect( + mockNetworkClient.blockTracker.checkForLatestBlock, + ).toHaveBeenCalled(); + expect(mockGetTokenBalancesForMultipleAddresses).toHaveBeenCalledWith( + [ + { + accountAddress: MOCK_ADDRESS_1, + tokenAddresses: [MOCK_TOKEN_ADDRESS_1, ZERO_ADDRESS], + }, + ], + MOCK_CHAIN_ID, + mockProvider, + true, + true, + ); + + // Should return all balances from the mock (DAI for both accounts + USDC + ETH for both) + expect(result.balances.length).toBeGreaterThan(0); + + // Check that we get balances for the selected account + const address1Balances = result.balances.filter( + (r) => r.account === MOCK_ADDRESS_1, + ); + expect(address1Balances.length).toBeGreaterThan(0); + }); + + it('should fetch balances for all accounts when queryAllAccounts is true', async () => { + const result = await rpcBalanceFetcher.fetch({ + chainIds: [MOCK_CHAIN_ID], + queryAllAccounts: true, + selectedAccount: MOCK_ADDRESS_1 as ChecksumAddress, + allAccounts: MOCK_INTERNAL_ACCOUNTS, + }); + + // With queryAllAccounts=true, the function includes native tokens with each account's token group + expect(mockGetTokenBalancesForMultipleAddresses).toHaveBeenCalledWith( + [ + { + accountAddress: MOCK_ADDRESS_1, + tokenAddresses: [ + MOCK_TOKEN_ADDRESS_1, + MOCK_TOKEN_ADDRESS_2, + ZERO_ADDRESS, + ], + }, + { + accountAddress: MOCK_ADDRESS_2, + tokenAddresses: [MOCK_TOKEN_ADDRESS_2, ZERO_ADDRESS], + }, + ], + MOCK_CHAIN_ID, + mockProvider, + true, + true, + ); + + // Should return all balances from the mock + expect(result.balances.length).toBeGreaterThan(0); + }); + + it('should handle multiple chain IDs', async () => { + await rpcBalanceFetcher.fetch({ + chainIds: [MOCK_CHAIN_ID, MOCK_CHAIN_ID_2], + queryAllAccounts: false, + selectedAccount: MOCK_ADDRESS_1 as ChecksumAddress, + allAccounts: MOCK_INTERNAL_ACCOUNTS, + }); + + expect(mockGetProvider).toHaveBeenCalledWith(MOCK_CHAIN_ID); + expect(mockGetProvider).toHaveBeenCalledWith(MOCK_CHAIN_ID_2); + expect(mockGetTokenBalancesForMultipleAddresses).toHaveBeenCalledTimes(2); + }); + + it('should handle null balances as failed', async () => { + const result = await rpcBalanceFetcher.fetch({ + chainIds: [MOCK_CHAIN_ID], + queryAllAccounts: false, + selectedAccount: MOCK_ADDRESS_2 as ChecksumAddress, + allAccounts: MOCK_INTERNAL_ACCOUNTS, + }); + + // Check that we have failed balances (null values) + const failedBalances = result.balances.filter((r) => !r.success); + expect(failedBalances.length).toBeGreaterThan(0); + + // Verify the failed balance structure + expect(failedBalances[0]).toMatchObject({ + success: false, + value: null, + account: expect.any(String), + token: expect.any(String), + chainId: MOCK_CHAIN_ID, + }); + }); + + it('should skip chains with no account token groups', async () => { + // Mock empty tokens state + mockGetTokensState.mockReturnValue({ + allTokens: {}, + allDetectedTokens: {}, + }); + + const result = await rpcBalanceFetcher.fetch({ + chainIds: [MOCK_CHAIN_ID], + queryAllAccounts: false, + selectedAccount: MOCK_ADDRESS_1 as ChecksumAddress, + allAccounts: MOCK_INTERNAL_ACCOUNTS, + }); + + // Even with no tokens, native token and staked balances will still be processed + expect(result.balances.length).toBeGreaterThan(0); + expect(mockGetProvider).toHaveBeenCalled(); + expect(mockGetTokenBalancesForMultipleAddresses).toHaveBeenCalled(); + }); + + it('should call blockTracker to ensure latest block', async () => { + await rpcBalanceFetcher.fetch({ + chainIds: [MOCK_CHAIN_ID], + queryAllAccounts: false, + selectedAccount: MOCK_ADDRESS_1 as ChecksumAddress, + allAccounts: MOCK_INTERNAL_ACCOUNTS, + }); + + expect( + mockNetworkClient.blockTracker.checkForLatestBlock, + ).toHaveBeenCalled(); + }); + + it('should handle blockTracker errors gracefully', async () => { + ( + mockNetworkClient.blockTracker.checkForLatestBlock as jest.Mock + ).mockRejectedValue(new Error('BlockTracker error')); + + const result = await rpcBalanceFetcher.fetch({ + chainIds: [MOCK_CHAIN_ID], + queryAllAccounts: false, + selectedAccount: MOCK_ADDRESS_1 as ChecksumAddress, + allAccounts: MOCK_INTERNAL_ACCOUNTS, + }); + + // With parallel processing and safelyExecuteWithTimeout, errors are caught gracefully + // and an empty array is returned for failed chains + expect(result).toStrictEqual({ balances: [] }); + }); + + it('should handle multicall errors gracefully', async () => { + mockGetTokenBalancesForMultipleAddresses.mockRejectedValue( + new Error('Multicall error'), + ); + + const result = await rpcBalanceFetcher.fetch({ + chainIds: [MOCK_CHAIN_ID], + queryAllAccounts: false, + selectedAccount: MOCK_ADDRESS_1 as ChecksumAddress, + allAccounts: MOCK_INTERNAL_ACCOUNTS, + }); + + // With parallel processing and safelyExecuteWithTimeout, errors are caught gracefully + // and an empty array is returned for failed chains + expect(result).toStrictEqual({ balances: [] }); + }); + + it('should handle timeout gracefully when safelyExecuteWithTimeout returns undefined', async () => { + // Mock safelyExecuteWithTimeout to return undefined (simulating timeout) + mockSafelyExecuteWithTimeout.mockResolvedValueOnce(undefined); + + const result = await rpcBalanceFetcher.fetch({ + chainIds: [MOCK_CHAIN_ID], + queryAllAccounts: false, + selectedAccount: MOCK_ADDRESS_1 as ChecksumAddress, + allAccounts: MOCK_INTERNAL_ACCOUNTS, + }); + + // Should return empty array when timeout occurs + expect(result).toStrictEqual({ balances: [] }); + expect(mockSafelyExecuteWithTimeout).toHaveBeenCalled(); + }); + + it('should handle partial success with multiple chains (some timeout, some succeed)', async () => { + // First chain times out, second chain succeeds + mockSafelyExecuteWithTimeout + .mockResolvedValueOnce(undefined) // First chain times out + .mockImplementationOnce(async (operation: () => Promise) => { + // Second chain succeeds + return await operation(); + }); + + const result = await rpcBalanceFetcher.fetch({ + chainIds: [MOCK_CHAIN_ID, MOCK_CHAIN_ID_2], + queryAllAccounts: false, + selectedAccount: MOCK_ADDRESS_1 as ChecksumAddress, + allAccounts: MOCK_INTERNAL_ACCOUNTS, + }); + + // Should return results only from the successful chain + expect(result.balances.length).toBeGreaterThan(0); + expect(result.balances.every((r) => r.chainId === MOCK_CHAIN_ID_2)).toBe( + true, + ); + }); + + it('uses unprocessed tokens for selected account and skips native/staked fetches', async () => { + const unprocessedTokens: UnprocessedTokens = { + [MOCK_ADDRESS_1.toLowerCase()]: { + [MOCK_CHAIN_ID]: [MOCK_TOKEN_ADDRESS_1], + }, + }; + + mockGetTokenBalancesForMultipleAddresses.mockResolvedValue({ + tokenBalances: { + [MOCK_TOKEN_ADDRESS_1]: { + [MOCK_ADDRESS_1.toLowerCase()]: new BN('123'), + }, + }, + stakedBalances: { + [MOCK_ADDRESS_1.toLowerCase()]: new BN('999'), + }, + }); + + const result = await rpcBalanceFetcher.fetch({ + chainIds: [MOCK_CHAIN_ID], + queryAllAccounts: false, + selectedAccount: MOCK_ADDRESS_1 as ChecksumAddress, + allAccounts: MOCK_INTERNAL_ACCOUNTS, + unprocessedTokens, + }); + + expect(mockGetTokenBalancesForMultipleAddresses).toHaveBeenCalledWith( + [ + { + accountAddress: MOCK_ADDRESS_1.toLowerCase(), + tokenAddresses: [MOCK_TOKEN_ADDRESS_1], + }, + ], + MOCK_CHAIN_ID, + mockProvider, + false, + false, + ); + expect(result.balances).toHaveLength(1); + expect(result.balances[0]).toMatchObject({ + account: MOCK_ADDRESS_1.toLowerCase(), + chainId: MOCK_CHAIN_ID, + }); + expect( + result.balances.some((balance) => balance.token === ZERO_ADDRESS), + ).toBe(false); + expect( + result.balances.some( + (balance) => balance.token === STAKING_CONTRACT_ADDRESS, + ), + ).toBe(false); + }); + + it('uses unprocessed tokens per-chain and falls back to regular mode for other chains', async () => { + const unprocessedTokens: UnprocessedTokens = { + [MOCK_ADDRESS_1.toLowerCase()]: { + [MOCK_CHAIN_ID]: [MOCK_TOKEN_ADDRESS_1], + }, + }; + + await rpcBalanceFetcher.fetch({ + chainIds: [MOCK_CHAIN_ID, MOCK_CHAIN_ID_2], + queryAllAccounts: true, + selectedAccount: MOCK_ADDRESS_1 as ChecksumAddress, + allAccounts: MOCK_INTERNAL_ACCOUNTS, + unprocessedTokens, + }); + + const chain1Call = + mockGetTokenBalancesForMultipleAddresses.mock.calls.find( + ([, chainId]) => chainId === MOCK_CHAIN_ID, + ); + expect(chain1Call).toBeDefined(); + expect(chain1Call?.[0]).toStrictEqual([ + { + accountAddress: MOCK_ADDRESS_1.toLowerCase(), + tokenAddresses: [MOCK_TOKEN_ADDRESS_1], + }, + ]); + expect(chain1Call?.[3]).toBe(false); + expect(chain1Call?.[4]).toBe(false); + + const chain2Call = + mockGetTokenBalancesForMultipleAddresses.mock.calls.find( + ([, chainId]) => chainId === MOCK_CHAIN_ID_2, + ); + expect(chain2Call).toBeDefined(); + expect(chain2Call?.[0]).toStrictEqual([ + { + accountAddress: MOCK_ADDRESS_1, + tokenAddresses: [MOCK_TOKEN_ADDRESS_1, ZERO_ADDRESS], + }, + { + accountAddress: MOCK_ADDRESS_2, + tokenAddresses: [ZERO_ADDRESS], + }, + ]); + expect(chain2Call?.[3]).toBe(true); + expect(chain2Call?.[4]).toBe(true); + }); + + it('ignores unprocessed tokens from non-selected accounts when queryAllAccounts is false', async () => { + const unprocessedTokens: UnprocessedTokens = { + [MOCK_ADDRESS_2.toLowerCase()]: { + [MOCK_CHAIN_ID]: [MOCK_TOKEN_ADDRESS_2], + }, + }; + + await rpcBalanceFetcher.fetch({ + chainIds: [MOCK_CHAIN_ID], + queryAllAccounts: false, + selectedAccount: MOCK_ADDRESS_1 as ChecksumAddress, + allAccounts: MOCK_INTERNAL_ACCOUNTS, + unprocessedTokens, + }); + + expect(mockGetTokenBalancesForMultipleAddresses).toHaveBeenCalledWith( + [ + { + accountAddress: MOCK_ADDRESS_1, + tokenAddresses: [ + MOCK_TOKEN_ADDRESS_1, + MOCK_TOKEN_ADDRESS_2, + ZERO_ADDRESS, + ], + }, + ], + MOCK_CHAIN_ID, + mockProvider, + true, + true, + ); + }); + }); + + describe('Token grouping integration (via fetch)', () => { + it('should handle empty tokens state correctly', async () => { + mockGetTokensState.mockReturnValue({ + allTokens: {}, + allDetectedTokens: {}, + }); + + const result = await rpcBalanceFetcher.fetch({ + chainIds: [MOCK_CHAIN_ID], + queryAllAccounts: false, + selectedAccount: MOCK_ADDRESS_1 as ChecksumAddress, + allAccounts: MOCK_INTERNAL_ACCOUNTS, + }); + + // Even with no tokens, native token and staked balances will still be processed + expect(result.balances.length).toBeGreaterThan(0); + expect(mockGetTokenBalancesForMultipleAddresses).toHaveBeenCalled(); + }); + + it('should merge imported and detected tokens correctly', async () => { + const tokensStateWithBoth = { + allTokens: { + [MOCK_CHAIN_ID]: { + [MOCK_ADDRESS_1]: [ + { + address: MOCK_TOKEN_ADDRESS_1, + decimals: 18, + symbol: 'DAI', + }, + ], + }, + }, + allDetectedTokens: { + [MOCK_CHAIN_ID]: { + [MOCK_ADDRESS_1]: [ + { + address: MOCK_TOKEN_ADDRESS_2, + decimals: 6, + symbol: 'USDC', + }, + ], + }, + }, + }; + + mockGetTokensState.mockReturnValue(tokensStateWithBoth); + + await rpcBalanceFetcher.fetch({ + chainIds: [MOCK_CHAIN_ID], + queryAllAccounts: false, + selectedAccount: MOCK_ADDRESS_1 as ChecksumAddress, + allAccounts: MOCK_INTERNAL_ACCOUNTS, + }); + + expect(mockGetTokenBalancesForMultipleAddresses).toHaveBeenCalledWith( + [ + { + accountAddress: MOCK_ADDRESS_1, + tokenAddresses: [ + MOCK_TOKEN_ADDRESS_1, + MOCK_TOKEN_ADDRESS_2, + ZERO_ADDRESS, + ], + }, + ], + MOCK_CHAIN_ID, + mockProvider, + true, + true, + ); + }); + + it('should include native token when queryAllAccounts is true and no other tokens', async () => { + mockGetTokensState.mockReturnValue({ + allTokens: {}, + allDetectedTokens: {}, + }); + + await rpcBalanceFetcher.fetch({ + chainIds: [MOCK_CHAIN_ID], + queryAllAccounts: true, + selectedAccount: MOCK_ADDRESS_1 as ChecksumAddress, + allAccounts: MOCK_INTERNAL_ACCOUNTS, + }); + + expect(mockGetTokenBalancesForMultipleAddresses).toHaveBeenCalledWith( + [ + { + accountAddress: MOCK_ADDRESS_1, + tokenAddresses: [ZERO_ADDRESS], + }, + { + accountAddress: MOCK_ADDRESS_2, + tokenAddresses: [ZERO_ADDRESS], + }, + ], + MOCK_CHAIN_ID, + mockProvider, + true, + true, + ); + }); + + it('should filter to selected account only when queryAllAccounts is false', async () => { + const tokensStateMultipleAccounts = { + allTokens: { + [MOCK_CHAIN_ID]: { + [MOCK_ADDRESS_1]: [ + { + address: MOCK_TOKEN_ADDRESS_1, + decimals: 18, + symbol: 'DAI', + }, + ], + [MOCK_ADDRESS_2]: [ + { + address: MOCK_TOKEN_ADDRESS_2, + decimals: 6, + symbol: 'USDC', + }, + ], + }, + }, + allDetectedTokens: {}, + }; + + mockGetTokensState.mockReturnValue(tokensStateMultipleAccounts); + + await rpcBalanceFetcher.fetch({ + chainIds: [MOCK_CHAIN_ID], + queryAllAccounts: false, + selectedAccount: MOCK_ADDRESS_1 as ChecksumAddress, + allAccounts: MOCK_INTERNAL_ACCOUNTS, + }); + + expect(mockGetTokenBalancesForMultipleAddresses).toHaveBeenCalledWith( + [ + { + accountAddress: MOCK_ADDRESS_1, + tokenAddresses: [MOCK_TOKEN_ADDRESS_1, ZERO_ADDRESS], + }, + ], + MOCK_CHAIN_ID, + mockProvider, + true, + true, + ); + }); + + it('removes duplicates in the same group', async () => { + const tokensStateWithDuplicates = { + allTokens: { + [MOCK_CHAIN_ID]: { + [MOCK_ADDRESS_1]: [ + { + address: MOCK_TOKEN_ADDRESS_1, + decimals: 18, + symbol: 'DAI', + }, + ], + }, + }, + allDetectedTokens: { + [MOCK_CHAIN_ID]: { + [MOCK_ADDRESS_1]: [ + { + address: MOCK_TOKEN_ADDRESS_1, // Same token as in imported + decimals: 18, + symbol: 'DAI', + }, + ], + }, + }, + }; + + mockGetTokensState.mockReturnValue(tokensStateWithDuplicates); + + await rpcBalanceFetcher.fetch({ + chainIds: [MOCK_CHAIN_ID], + queryAllAccounts: false, + selectedAccount: MOCK_ADDRESS_1 as ChecksumAddress, + allAccounts: MOCK_INTERNAL_ACCOUNTS, + }); + + // Should include duplicate tokens (this tests the actual behavior) + expect(mockGetTokenBalancesForMultipleAddresses).toHaveBeenCalledWith( + [ + { + accountAddress: MOCK_ADDRESS_1, + tokenAddresses: [ + MOCK_TOKEN_ADDRESS_1, // we do not have duplicates addresses in request! + ZERO_ADDRESS, + ], + }, + ], + MOCK_CHAIN_ID, + mockProvider, + true, + true, + ); + }); + }); + + describe('staked balance functionality', () => { + it('should include staked balances in results when returned by multicall', async () => { + const result = await rpcBalanceFetcher.fetch({ + chainIds: [MOCK_CHAIN_ID], + queryAllAccounts: false, + selectedAccount: MOCK_ADDRESS_1 as ChecksumAddress, + allAccounts: MOCK_INTERNAL_ACCOUNTS, + }); + + // Should include staked balance for the selected account only (queryAllAccounts: false) + const stakingResults = result.balances.filter( + (r) => r.token === STAKING_CONTRACT_ADDRESS, + ); + const stakedBalance1 = stakingResults.find( + (r) => r.account === MOCK_ADDRESS_1, + ); + + expect(stakedBalance1).toBeDefined(); + expect(stakedBalance1?.success).toBe(true); + expect(stakedBalance1?.value).toStrictEqual( + MOCK_STAKED_BALANCES[MOCK_ADDRESS_1], + ); + + // Should not include staked balance for other accounts when queryAllAccounts: false + const stakedBalance2 = stakingResults.find( + (r) => r.account === MOCK_ADDRESS_2, + ); + expect(stakedBalance2).toBeUndefined(); + }); + + it('should include zero staked balance entry when no staked balance is returned', async () => { + // Mock multicall to return no staked balances + mockGetTokenBalancesForMultipleAddresses.mockResolvedValue({ + tokenBalances: MOCK_TOKEN_BALANCES, + stakedBalances: {}, + }); + + const result = await rpcBalanceFetcher.fetch({ + chainIds: [MOCK_CHAIN_ID], + queryAllAccounts: false, + selectedAccount: MOCK_ADDRESS_1 as ChecksumAddress, + allAccounts: MOCK_INTERNAL_ACCOUNTS, + }); + + // Should still include staked balance entries with zero values + const stakingResults = result.balances.filter( + (r) => r.token === STAKING_CONTRACT_ADDRESS, + ); + const stakedBalance = stakingResults.find( + (r) => r.account === MOCK_ADDRESS_1, + ); + + expect(stakedBalance).toBeDefined(); + expect(stakedBalance?.success).toBe(true); + expect(stakedBalance?.value).toStrictEqual(new BN('0')); + }); + + it('should handle staked balances with queryAllAccounts', async () => { + const result = await rpcBalanceFetcher.fetch({ + chainIds: [MOCK_CHAIN_ID], + queryAllAccounts: true, + selectedAccount: MOCK_ADDRESS_1 as ChecksumAddress, + allAccounts: MOCK_INTERNAL_ACCOUNTS, + }); + + // Should include staked balances for all accounts when queryAllAccounts: true + const stakedBalances = result.balances.filter( + (r) => r.token === STAKING_CONTRACT_ADDRESS, + ); + + expect(stakedBalances).toHaveLength(2); + + const stakedBalance1 = stakedBalances.find( + (r) => r.account === MOCK_ADDRESS_1, + ); + const stakedBalance2 = stakedBalances.find( + (r) => r.account === MOCK_ADDRESS_2, + ); + + expect(stakedBalance1?.value).toStrictEqual( + MOCK_STAKED_BALANCES[MOCK_ADDRESS_1], + ); + expect(stakedBalance2?.value).toStrictEqual( + MOCK_STAKED_BALANCES[MOCK_ADDRESS_2], + ); + }); + + it('should handle unsupported chains gracefully (no staking)', async () => { + const result = await rpcBalanceFetcher.fetch({ + chainIds: [MOCK_CHAIN_ID_2], // Polygon - no staking support + queryAllAccounts: false, + selectedAccount: MOCK_ADDRESS_1 as ChecksumAddress, + allAccounts: MOCK_INTERNAL_ACCOUNTS, + }); + + // Should not include any staking balances for unsupported chains + const stakedBalances = result.balances.filter( + (r) => r.token === STAKING_CONTRACT_ADDRESS, + ); + + expect(stakedBalances).toHaveLength(0); + }); + }); + + describe('native token always included', () => { + it('should always include native token entry for selected account even when balance is zero', async () => { + // Mock multicall to return no native balance + const tokensWithoutNative = { ...MOCK_TOKEN_BALANCES }; + delete tokensWithoutNative[ZERO_ADDRESS]; + + mockGetTokenBalancesForMultipleAddresses.mockResolvedValue({ + tokenBalances: tokensWithoutNative, + stakedBalances: MOCK_STAKED_BALANCES, + }); + + const result = await rpcBalanceFetcher.fetch({ + chainIds: [MOCK_CHAIN_ID], + queryAllAccounts: false, + selectedAccount: MOCK_ADDRESS_1 as ChecksumAddress, + allAccounts: MOCK_INTERNAL_ACCOUNTS, + }); + + // Should still include native token entry with zero value + const nativeResults = result.balances.filter( + (r) => r.token === ZERO_ADDRESS, + ); + const nativeBalance = nativeResults.find( + (r) => r.account === MOCK_ADDRESS_1, + ); + + expect(nativeBalance).toBeDefined(); + expect(nativeBalance?.success).toBe(true); + expect(nativeBalance?.value).toStrictEqual(new BN('0')); + }); + + it('should include native token for all accounts when queryAllAccounts is true', async () => { + const result = await rpcBalanceFetcher.fetch({ + chainIds: [MOCK_CHAIN_ID], + queryAllAccounts: true, + selectedAccount: MOCK_ADDRESS_1 as ChecksumAddress, + allAccounts: MOCK_INTERNAL_ACCOUNTS, + }); + + // Should include native balances for all accounts + const nativeBalances = result.balances.filter( + (r) => r.token === ZERO_ADDRESS, + ); + + expect(nativeBalances).toHaveLength(2); + + const nativeBalance1 = nativeBalances.find( + (r) => r.account === MOCK_ADDRESS_1, + ); + const nativeBalance2 = nativeBalances.find( + (r) => r.account === MOCK_ADDRESS_2, + ); + + expect(nativeBalance1?.value).toStrictEqual( + MOCK_TOKEN_BALANCES[ZERO_ADDRESS][MOCK_ADDRESS_1], + ); + expect(nativeBalance2?.value).toStrictEqual( + MOCK_TOKEN_BALANCES[ZERO_ADDRESS][MOCK_ADDRESS_2], + ); + }); + }); +}); diff --git a/packages/assets-controllers/src/rpc-service/rpc-balance-fetcher.ts b/packages/assets-controllers/src/rpc-service/rpc-balance-fetcher.ts new file mode 100644 index 00000000000..679822c56c6 --- /dev/null +++ b/packages/assets-controllers/src/rpc-service/rpc-balance-fetcher.ts @@ -0,0 +1,394 @@ +import type { Web3Provider } from '@ethersproject/providers'; +import { + toChecksumHexAddress, + safelyExecuteWithTimeout, +} from '@metamask/controller-utils'; +import type { InternalAccount } from '@metamask/keyring-internal-api'; +import type { NetworkClient } from '@metamask/network-controller'; +import type { Hex } from '@metamask/utils'; +import BN from 'bn.js'; + +import { STAKING_CONTRACT_ADDRESS_BY_CHAINID } from '../AssetsContractController.js'; +import { shouldIncludeNativeToken } from '../constants.js'; +import type { UnprocessedTokens } from '../multi-chain-accounts-service/api-balance-fetcher.js'; +import { getTokenBalancesForMultipleAddresses } from '../multicall.js'; +import type { TokensControllerState } from '../TokensController.js'; + +const RPC_TIMEOUT_MS = 30000; + +export type ChainIdHex = Hex; +export type ChecksumAddress = Hex; + +export type ProcessedBalance = { + success: boolean; + value?: BN; + account: ChecksumAddress; + token: ChecksumAddress; + chainId: ChainIdHex; +}; + +export type BalanceFetchResult = { + balances: ProcessedBalance[]; + unprocessedChainIds?: ChainIdHex[]; + unprocessedTokens?: UnprocessedTokens; +}; + +export type BalanceFetcher = { + supports(chainId: ChainIdHex): boolean; + fetch(input: { + chainIds: ChainIdHex[]; + queryAllAccounts: boolean; + selectedAccount: ChecksumAddress; + allAccounts: InternalAccount[]; + unprocessedTokens?: UnprocessedTokens; + }): Promise; +}; + +const ZERO_ADDRESS = + '0x0000000000000000000000000000000000000000' as ChecksumAddress; + +const checksum = (addr: string): ChecksumAddress => + toChecksumHexAddress(addr) as ChecksumAddress; + +export class RpcBalanceFetcher implements BalanceFetcher { + readonly #getProvider: (chainId: ChainIdHex) => Web3Provider; + + readonly #getNetworkClient: (chainId: ChainIdHex) => NetworkClient; + + readonly #getTokensState: () => { + allTokens: TokensControllerState['allTokens']; + allDetectedTokens: TokensControllerState['allDetectedTokens']; + }; + + constructor( + getProvider: (chainId: ChainIdHex) => Web3Provider, + getNetworkClient: (chainId: ChainIdHex) => NetworkClient, + getTokensState: () => { + allTokens: TokensControllerState['allTokens']; + allDetectedTokens: TokensControllerState['allDetectedTokens']; + }, + ) { + this.#getProvider = getProvider; + this.#getNetworkClient = getNetworkClient; + this.#getTokensState = getTokensState; + } + + supports(): boolean { + return true; // fallback – supports every chain + } + + #getStakingContractAddress(chainId: ChainIdHex): string | undefined { + return STAKING_CONTRACT_ADDRESS_BY_CHAINID[chainId]; + } + + async fetch({ + chainIds, + queryAllAccounts, + selectedAccount, + allAccounts, + unprocessedTokens, + }: Parameters[0]): Promise { + // Process all chains in parallel for better performance + const chainProcessingPromises = chainIds.map(async (chainId) => { + // if there are unprocessed tokens for a chain, it means the chain was partially processed. + // because of this, we need to build distinct account <-> token groups to process + const hasUnprocessedTokensForChain = queryAllAccounts + ? Object.values(unprocessedTokens ?? {}).some((chainMap) => + Boolean(chainMap[chainId] && chainMap[chainId].length > 0), + ) + : Boolean( + unprocessedTokens?.[selectedAccount.toLowerCase()]?.[chainId] && + unprocessedTokens[selectedAccount.toLowerCase()][chainId].length > + 0, + ); + + const tokensState = this.#getTokensState(); + const { accountTokenGroups, includeNativeAndStaked } = + hasUnprocessedTokensForChain + ? buildUnprocessedAccountTokenGroupsStatic( + chainId, + queryAllAccounts, + selectedAccount, + unprocessedTokens as UnprocessedTokens, + ) + : buildAccountTokenGroupsStatic( + chainId, + queryAllAccounts, + selectedAccount, + allAccounts, + tokensState.allTokens, + tokensState.allDetectedTokens, + ); + + if (!accountTokenGroups.length) { + return []; + } + + const provider = this.#getProvider(chainId); + await this.#ensureFreshBlockData(chainId); + + // Skip native token fetching for chains that return arbitrary large numbers + const includeNative = shouldIncludeNativeToken(chainId); + + const balanceResult = await safelyExecuteWithTimeout( + async () => { + return await getTokenBalancesForMultipleAddresses( + accountTokenGroups, + chainId, + provider, + // Skip native for Tempo chains + includeNative && includeNativeAndStaked, + includeNativeAndStaked, + ); + }, + true, + RPC_TIMEOUT_MS, + ); + + // If timeout or error occurred, return empty array for this chain + if (!balanceResult) { + return []; + } + + const { tokenBalances, stakedBalances } = balanceResult; + const chainResults: ProcessedBalance[] = []; + + if (includeNative && includeNativeAndStaked) { + // Add native token entries for all addresses being processed + const allAddressesForNative = new Set(); + accountTokenGroups.forEach((group) => { + allAddressesForNative.add(group.accountAddress); + }); + + // Ensure native token entries exist for all addresses + allAddressesForNative.forEach((address) => { + const nativeBalance = tokenBalances[ZERO_ADDRESS]?.[address] || null; + chainResults.push({ + success: true, + value: nativeBalance || new BN('0'), + account: address as ChecksumAddress, + token: ZERO_ADDRESS, + chainId, + }); + }); + } + + // Add other token balances + Object.entries(tokenBalances).forEach(([tokenAddr, balances]) => { + // Skip native token since we handled it explicitly above + if (tokenAddr === ZERO_ADDRESS) { + return; + } + Object.entries(balances).forEach(([acct, bn]) => { + chainResults.push({ + success: bn !== null, + value: bn, + account: acct as ChecksumAddress, + token: checksum(tokenAddr), + chainId, + }); + }); + }); + + // Add staked balances for all addresses being processed + const stakingContractAddress = this.#getStakingContractAddress(chainId); + if (includeNativeAndStaked && stakingContractAddress) { + // Get all unique addresses being processed for this chain + const allAddresses = new Set(); + accountTokenGroups.forEach((group) => { + allAddresses.add(group.accountAddress); + }); + + // Add staked balance entry for each address + const checksummedStakingAddress = checksum(stakingContractAddress); + allAddresses.forEach((address) => { + const stakedBalance = stakedBalances?.[address] ?? null; + chainResults.push({ + success: true, + value: stakedBalance ?? new BN('0'), + account: address as ChecksumAddress, + token: checksummedStakingAddress, + chainId, + }); + }); + } + + return chainResults; + }); + + // Wait for all chains to complete (or fail) and collect results + const chainResultsArray = await Promise.allSettled(chainProcessingPromises); + const results: ProcessedBalance[] = []; + + chainResultsArray.forEach((chainResult) => { + if (chainResult.status === 'fulfilled') { + results.push(...chainResult.value); + } + }); + + return { balances: results }; + } + + /** + * Ensures that the block tracker has the latest block data before performing multicall operations. + * This is a temporary fix to ensure that the block number is up to date. + * + * @param chainId - The chain id to update block data for. + */ + async #ensureFreshBlockData(chainId: Hex): Promise { + // Force fresh block data before multicall + // TODO: This is a temporary fix to ensure that the block number is up to date. + // We should remove this once we have a better solution for this on the block tracker controller. + const networkClient = this.#getNetworkClient(chainId); + await networkClient.blockTracker?.checkForLatestBlock?.(); + } +} + +type AccountTokenGroup = { + accountAddress: ChecksumAddress; + tokenAddresses: ChecksumAddress[]; +}; + +function buildAccountTokenGroups( + queryAllAccounts: boolean, + selectedAccount: ChecksumAddress, + accountTokenMap: { [account: string]: string[] }, +): AccountTokenGroup[] { + const pairs: { + accountAddress: ChecksumAddress; + tokenAddress: ChecksumAddress; + }[] = []; + + const add = ([account, tokens]: [string, string[]]): void => { + const checksumAccount = checksum(account); + const shouldInclude = + queryAllAccounts || checksumAccount === checksum(selectedAccount); + if (!shouldInclude) { + return; + } + tokens.forEach((token: string) => + pairs.push({ + accountAddress: account as ChecksumAddress, + tokenAddress: checksum(token), + }), + ); + }; + + Object.entries(accountTokenMap).forEach(add); + + // group by account + const map = new Map(); + pairs.forEach(({ accountAddress, tokenAddress }) => { + if (!map.has(accountAddress)) { + map.set(accountAddress, []); + } + const tokens = map.get(accountAddress); + if (tokens) { + tokens.push(tokenAddress); + } + }); + + return Array.from(map.entries()).map(([accountAddress, tokenAddresses]) => ({ + accountAddress, + tokenAddresses, + })); +} + +/** + * Merges imported & detected tokens for the requested chain and returns a list + * of `{ accountAddress, tokenAddresses[] }` suitable for getTokenBalancesForMultipleAddresses. + * + * @param chainId - The chain ID to build account token groups for + * @param queryAllAccounts - Whether to query all accounts or just the selected one + * @param selectedAccount - The currently selected account + * @param allAccounts - All available accounts + * @param allTokens - All tokens from TokensController + * @param allDetectedTokens - All detected tokens from TokensController + * @returns Array of account/token groups for multicall + */ +function buildAccountTokenGroupsStatic( + chainId: ChainIdHex, + queryAllAccounts: boolean, + selectedAccount: ChecksumAddress, + allAccounts: InternalAccount[], + allTokens: TokensControllerState['allTokens'], + allDetectedTokens: TokensControllerState['allDetectedTokens'], +): { + accountTokenGroups: AccountTokenGroup[]; + includeNativeAndStaked: true; +} { + const accountTokenMap: { [account: string]: string[] } = {}; + + // Add all tokens + Object.entries(allTokens[chainId] ?? {}).forEach(([account, tokens]) => { + accountTokenMap[account] = tokens.map((token) => token.address); + }); + + // Add all detected tokens + Object.entries(allDetectedTokens[chainId] ?? {}).forEach( + ([account, tokens]) => { + if (!accountTokenMap[account]) { + accountTokenMap[account] = []; + } + accountTokenMap[account] = Array.from( + new Set([ + ...accountTokenMap[account], + ...tokens.map((token) => token.address), + ]), + ); + }, + ); + + // Add native tokens + if (queryAllAccounts) { + allAccounts.forEach((a) => { + accountTokenMap[a.address] ??= []; + accountTokenMap[a.address].push(ZERO_ADDRESS); + }); + } else { + accountTokenMap[selectedAccount] ??= []; + accountTokenMap[selectedAccount].push(ZERO_ADDRESS); + } + + return { + accountTokenGroups: buildAccountTokenGroups( + queryAllAccounts, + selectedAccount, + accountTokenMap, + ), + includeNativeAndStaked: true, + }; +} + +function buildUnprocessedAccountTokenGroupsStatic( + chainId: ChainIdHex, + queryAllAccounts: boolean, + selectedAccount: ChecksumAddress, + unprocessedTokens: UnprocessedTokens, +): { + accountTokenGroups: AccountTokenGroup[]; + includeNativeAndStaked: false; +} { + const accountTokenMap: { [account: string]: string[] } = {}; + Object.entries(unprocessedTokens).forEach(([account, tokens]) => { + const lowercaseAccount = account.toLowerCase(); + if ( + queryAllAccounts || + lowercaseAccount === selectedAccount.toLowerCase() + ) { + const tokenAddresses = + tokens?.[chainId]?.map((tokenAddress) => tokenAddress.toLowerCase()) ?? + []; + accountTokenMap[lowercaseAccount] = tokenAddresses; + } + }); + + return { + accountTokenGroups: buildAccountTokenGroups( + queryAllAccounts, + selectedAccount, + accountTokenMap, + ), + includeNativeAndStaked: false, + }; +} diff --git a/packages/assets-controllers/src/selectors/__fixtures__/arrange-tron-state.ts b/packages/assets-controllers/src/selectors/__fixtures__/arrange-tron-state.ts new file mode 100644 index 00000000000..2d006d54d46 --- /dev/null +++ b/packages/assets-controllers/src/selectors/__fixtures__/arrange-tron-state.ts @@ -0,0 +1,462 @@ +export const MOCK_TRON_TOKENS = { + 'tron:728126428': [ + { + accountType: 'tron:eoa', + assetId: 'tron:728126428/slip44:195', + isNative: true, + image: + 'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/tron/info/logo.png', + name: 'Tron', + symbol: 'TRX', + accountId: 'de5c3465-d01e-4091-a219-232903e982bb', + decimals: 6, + rawBalance: '0x0', + balance: '0', + fiat: { + balance: 0, + currency: 'usd', + conversionRate: 0.28516, + }, + chainId: 'tron:728126428', + }, + { + accountType: 'tron:eoa', + assetId: 'tron:728126428/slip44:195-staked-for-bandwidth', + isNative: true, + image: + 'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/tron/info/logo.png', + name: 'Staked for Bandwidth', + symbol: 'sTRX-BANDWIDTH', + accountId: 'de5c3465-d01e-4091-a219-232903e982bb', + decimals: 6, + rawBalance: '0x0', + balance: '0', + chainId: 'tron:728126428', + fiat: undefined, + }, + { + accountType: 'tron:eoa', + assetId: 'tron:728126428/slip44:195-staked-for-energy', + isNative: true, + image: + 'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/tron/info/logo.png', + name: 'Staked for Energy', + symbol: 'sTRX-ENERGY', + accountId: 'de5c3465-d01e-4091-a219-232903e982bb', + decimals: 6, + rawBalance: '0x0', + balance: '0', + chainId: 'tron:728126428', + fiat: undefined, + }, + { + accountType: 'tron:eoa', + assetId: 'tron:728126428/slip44:bandwidth', + isNative: true, + image: + 'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/tron/info/logo.png', + name: 'Bandwidth', + symbol: 'BANDWIDTH', + accountId: 'de5c3465-d01e-4091-a219-232903e982bb', + decimals: 0, + rawBalance: '0x0', + balance: '0', + chainId: 'tron:728126428', + fiat: undefined, + }, + { + accountType: 'tron:eoa', + assetId: 'tron:728126428/slip44:maximum-bandwidth', + isNative: true, + image: + 'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/tron/info/logo.png', + name: 'Max Bandwidth', + symbol: 'MAX-BANDWIDTH', + accountId: 'de5c3465-d01e-4091-a219-232903e982bb', + decimals: 0, + rawBalance: '0x0', + balance: '0', + chainId: 'tron:728126428', + fiat: undefined, + }, + { + accountType: 'tron:eoa', + assetId: 'tron:728126428/slip44:energy', + isNative: true, + image: + 'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/tron/info/logo.png', + name: 'Energy', + symbol: 'ENERGY', + accountId: 'de5c3465-d01e-4091-a219-232903e982bb', + decimals: 0, + rawBalance: '0x0', + balance: '0', + chainId: 'tron:728126428', + fiat: undefined, + }, + { + accountType: 'tron:eoa', + assetId: 'tron:728126428/slip44:maximum-energy', + isNative: true, + image: + 'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/tron/info/logo.png', + name: 'Max Energy', + symbol: 'MAX-ENERGY', + accountId: 'de5c3465-d01e-4091-a219-232903e982bb', + decimals: 0, + rawBalance: '0x0', + balance: '0', + chainId: 'tron:728126428', + fiat: undefined, + }, + { + accountType: 'tron:eoa', + assetId: 'tron:728126428/slip44:195-ready-for-withdrawal', + isNative: true, + image: + 'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/tron/info/logo.png', + name: 'Ready for Withdrawal', + symbol: 'trx-ready-for-withdrawal', + accountId: 'de5c3465-d01e-4091-a219-232903e982bb', + decimals: 6, + rawBalance: '0x0', + balance: '0', + chainId: 'tron:728126428', + fiat: undefined, + }, + { + accountType: 'tron:eoa', + assetId: 'tron:728126428/slip44:195-staking-rewards', + isNative: true, + image: + 'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/tron/info/logo.png', + name: 'Staking Rewards', + symbol: 'trx-staking-rewards', + accountId: 'de5c3465-d01e-4091-a219-232903e982bb', + decimals: 6, + rawBalance: '0x0', + balance: '0', + chainId: 'tron:728126428', + fiat: undefined, + }, + { + accountType: 'tron:eoa', + assetId: 'tron:728126428/slip44:195-in-lock-period', + isNative: true, + image: + 'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/tron/info/logo.png', + name: 'In Lock Period', + symbol: 'trx-in-lock-period', + accountId: 'de5c3465-d01e-4091-a219-232903e982bb', + decimals: 6, + rawBalance: '0x0', + balance: '0', + chainId: 'tron:728126428', + fiat: undefined, + }, + ], + 'tron:3448148188': [ + { + accountType: 'tron:eoa', + assetId: 'tron:3448148188/slip44:195', + isNative: true, + image: + 'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/tron/info/logo.png', + name: 'Tron', + symbol: 'TRX', + accountId: 'de5c3465-d01e-4091-a219-232903e982bb', + decimals: 6, + rawBalance: '0x0', + balance: '0', + chainId: 'tron:3448148188', + fiat: undefined, + }, + { + accountType: 'tron:eoa', + assetId: 'tron:3448148188/slip44:195-staked-for-bandwidth', + isNative: true, + image: + 'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/tron/info/logo.png', + name: 'Staked for Bandwidth', + symbol: 'sTRX-BANDWIDTH', + accountId: 'de5c3465-d01e-4091-a219-232903e982bb', + decimals: 6, + rawBalance: '0x0', + balance: '0', + chainId: 'tron:3448148188', + fiat: undefined, + }, + { + accountType: 'tron:eoa', + assetId: 'tron:3448148188/slip44:195-staked-for-energy', + isNative: true, + image: + 'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/tron/info/logo.png', + name: 'Staked for Energy', + symbol: 'sTRX-ENERGY', + accountId: 'de5c3465-d01e-4091-a219-232903e982bb', + decimals: 6, + rawBalance: '0x0', + balance: '0', + chainId: 'tron:3448148188', + fiat: undefined, + }, + { + accountType: 'tron:eoa', + assetId: 'tron:3448148188/slip44:bandwidth', + isNative: true, + image: + 'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/tron/info/logo.png', + name: 'Bandwidth', + symbol: 'BANDWIDTH', + accountId: 'de5c3465-d01e-4091-a219-232903e982bb', + decimals: 0, + rawBalance: '0x0', + balance: '0', + chainId: 'tron:3448148188', + fiat: undefined, + }, + { + accountType: 'tron:eoa', + assetId: 'tron:3448148188/slip44:maximum-bandwidth', + isNative: true, + image: + 'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/tron/info/logo.png', + name: 'Max Bandwidth', + symbol: 'MAX-BANDWIDTH', + accountId: 'de5c3465-d01e-4091-a219-232903e982bb', + decimals: 0, + rawBalance: '0x0', + balance: '0', + chainId: 'tron:3448148188', + fiat: undefined, + }, + { + accountType: 'tron:eoa', + assetId: 'tron:3448148188/slip44:energy', + isNative: true, + image: + 'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/tron/info/logo.png', + name: 'Energy', + symbol: 'ENERGY', + accountId: 'de5c3465-d01e-4091-a219-232903e982bb', + decimals: 0, + rawBalance: '0x0', + balance: '0', + chainId: 'tron:3448148188', + fiat: undefined, + }, + { + accountType: 'tron:eoa', + assetId: 'tron:3448148188/slip44:maximum-energy', + isNative: true, + image: + 'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/tron/info/logo.png', + name: 'Max Energy', + symbol: 'MAX-ENERGY', + accountId: 'de5c3465-d01e-4091-a219-232903e982bb', + decimals: 0, + rawBalance: '0x0', + balance: '0', + chainId: 'tron:3448148188', + fiat: undefined, + }, + { + accountType: 'tron:eoa', + assetId: 'tron:3448148188/slip44:195-ready-for-withdrawal', + isNative: true, + image: + 'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/tron/info/logo.png', + name: 'Ready for Withdrawal', + symbol: 'trx-ready-for-withdrawal', + accountId: 'de5c3465-d01e-4091-a219-232903e982bb', + decimals: 6, + rawBalance: '0x0', + balance: '0', + chainId: 'tron:3448148188', + fiat: undefined, + }, + { + accountType: 'tron:eoa', + assetId: 'tron:3448148188/slip44:195-staking-rewards', + isNative: true, + image: + 'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/tron/info/logo.png', + name: 'Staking Rewards', + symbol: 'trx-staking-rewards', + accountId: 'de5c3465-d01e-4091-a219-232903e982bb', + decimals: 6, + rawBalance: '0x0', + balance: '0', + chainId: 'tron:3448148188', + fiat: undefined, + }, + { + accountType: 'tron:eoa', + assetId: 'tron:3448148188/slip44:195-in-lock-period', + isNative: true, + image: + 'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/tron/info/logo.png', + name: 'In Lock Period', + symbol: 'trx-in-lock-period', + accountId: 'de5c3465-d01e-4091-a219-232903e982bb', + decimals: 6, + rawBalance: '0x0', + balance: '0', + chainId: 'tron:3448148188', + fiat: undefined, + }, + ], + 'tron:2494104990': [ + { + accountType: 'tron:eoa', + assetId: 'tron:2494104990/slip44:195', + isNative: true, + image: + 'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/tron/info/logo.png', + name: 'Tron', + symbol: 'TRX', + accountId: 'de5c3465-d01e-4091-a219-232903e982bb', + decimals: 6, + rawBalance: '0x0', + balance: '0', + chainId: 'tron:2494104990', + fiat: undefined, + }, + { + accountType: 'tron:eoa', + assetId: 'tron:2494104990/slip44:195-staked-for-bandwidth', + isNative: true, + image: + 'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/tron/info/logo.png', + name: 'Staked for Bandwidth', + symbol: 'sTRX-BANDWIDTH', + accountId: 'de5c3465-d01e-4091-a219-232903e982bb', + decimals: 6, + rawBalance: '0x0', + balance: '0', + chainId: 'tron:2494104990', + fiat: undefined, + }, + { + accountType: 'tron:eoa', + assetId: 'tron:2494104990/slip44:195-staked-for-energy', + isNative: true, + image: + 'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/tron/info/logo.png', + name: 'Staked for Energy', + symbol: 'sTRX-ENERGY', + accountId: 'de5c3465-d01e-4091-a219-232903e982bb', + decimals: 6, + rawBalance: '0x0', + balance: '0', + chainId: 'tron:2494104990', + fiat: undefined, + }, + { + accountType: 'tron:eoa', + assetId: 'tron:2494104990/slip44:bandwidth', + isNative: true, + image: + 'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/tron/info/logo.png', + name: 'Bandwidth', + symbol: 'BANDWIDTH', + accountId: 'de5c3465-d01e-4091-a219-232903e982bb', + decimals: 0, + rawBalance: '0x0', + balance: '0', + chainId: 'tron:2494104990', + fiat: undefined, + }, + { + accountType: 'tron:eoa', + assetId: 'tron:2494104990/slip44:maximum-bandwidth', + isNative: true, + image: + 'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/tron/info/logo.png', + name: 'Max Bandwidth', + symbol: 'MAX-BANDWIDTH', + accountId: 'de5c3465-d01e-4091-a219-232903e982bb', + decimals: 0, + rawBalance: '0x0', + balance: '0', + chainId: 'tron:2494104990', + fiat: undefined, + }, + { + accountType: 'tron:eoa', + assetId: 'tron:2494104990/slip44:energy', + isNative: true, + image: + 'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/tron/info/logo.png', + name: 'Energy', + symbol: 'ENERGY', + accountId: 'de5c3465-d01e-4091-a219-232903e982bb', + decimals: 0, + rawBalance: '0x0', + balance: '0', + chainId: 'tron:2494104990', + fiat: undefined, + }, + { + accountType: 'tron:eoa', + assetId: 'tron:2494104990/slip44:maximum-energy', + isNative: true, + image: + 'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/tron/info/logo.png', + name: 'Max Energy', + symbol: 'MAX-ENERGY', + accountId: 'de5c3465-d01e-4091-a219-232903e982bb', + decimals: 0, + rawBalance: '0x0', + balance: '0', + chainId: 'tron:2494104990', + fiat: undefined, + }, + { + accountType: 'tron:eoa', + assetId: 'tron:2494104990/slip44:195-ready-for-withdrawal', + isNative: true, + image: + 'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/tron/info/logo.png', + name: 'Ready for Withdrawal', + symbol: 'trx-ready-for-withdrawal', + accountId: 'de5c3465-d01e-4091-a219-232903e982bb', + decimals: 6, + rawBalance: '0x0', + balance: '0', + chainId: 'tron:2494104990', + fiat: undefined, + }, + { + accountType: 'tron:eoa', + assetId: 'tron:2494104990/slip44:195-staking-rewards', + isNative: true, + image: + 'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/tron/info/logo.png', + name: 'Staking Rewards', + symbol: 'trx-staking-rewards', + accountId: 'de5c3465-d01e-4091-a219-232903e982bb', + decimals: 6, + rawBalance: '0x0', + balance: '0', + chainId: 'tron:2494104990', + fiat: undefined, + }, + { + accountType: 'tron:eoa', + assetId: 'tron:2494104990/slip44:195-in-lock-period', + isNative: true, + image: + 'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/tron/info/logo.png', + name: 'In Lock Period', + symbol: 'trx-in-lock-period', + accountId: 'de5c3465-d01e-4091-a219-232903e982bb', + decimals: 6, + rawBalance: '0x0', + balance: '0', + chainId: 'tron:2494104990', + fiat: undefined, + }, + ], +} as const; diff --git a/packages/assets-controllers/src/selectors/stringify-balance.test.ts b/packages/assets-controllers/src/selectors/stringify-balance.test.ts new file mode 100644 index 00000000000..5ba6d2e805b --- /dev/null +++ b/packages/assets-controllers/src/selectors/stringify-balance.test.ts @@ -0,0 +1,134 @@ +import { bigIntToHex } from '@metamask/utils'; + +import { + stringifyBalanceWithDecimals, + parseBalanceWithDecimals, +} from './stringify-balance.js'; + +describe('stringifyBalanceWithDecimals', () => { + it('returns the balance early if it is 0', () => { + const result = stringifyBalanceWithDecimals(0n, 18); + expect(result).toBe('0'); + }); + + it('returns a balance equal or greater than 1 as a string', () => { + const result = stringifyBalanceWithDecimals(1000000000000000000n, 18); + expect(result).toBe('1'); + }); + + it('returns a balance lower than 1 as a string', () => { + const result = stringifyBalanceWithDecimals(100000000000000000n, 18); + expect(result).toBe('0.1'); + }); + + it('skips decimals if balanceDecimals is 0', () => { + const result = stringifyBalanceWithDecimals(100000000000000000n, 18, 0); + expect(result).toBe('0'); + }); +}); + +describe('parseBalanceWithDecimals', () => { + describe('basic functionality', () => { + it('converts integer string with decimals', () => { + const result = parseBalanceWithDecimals('123', 18); + expect(result).toBe(bigIntToHex(123000000000000000000n)); + }); + + it('converts decimal string with exact decimals', () => { + const result = parseBalanceWithDecimals('123.456', 3); + expect(result).toBe(bigIntToHex(123456n)); + }); + + it('converts decimal string with fewer decimals than needed (pads with zeros)', () => { + const result = parseBalanceWithDecimals('123.45', 6); + expect(result).toBe(bigIntToHex(123450000n)); + }); + + it('converts decimal string with more decimals than needed (truncates)', () => { + const result = parseBalanceWithDecimals('123.456789', 3); + expect(result).toBe(bigIntToHex(123456n)); + }); + + it('handles zero decimals parameter', () => { + const result = parseBalanceWithDecimals('123.456', 0); + expect(result).toBe(bigIntToHex(123n)); + }); + + it('handles zero balance', () => { + const result = parseBalanceWithDecimals('0', 18); + expect(result).toBe(bigIntToHex(0n)); + }); + + it('handles zero with decimals', () => { + const result = parseBalanceWithDecimals('0.000', 18); + expect(result).toBe(bigIntToHex(0n)); + }); + + it('handles very small decimal values', () => { + const result = parseBalanceWithDecimals('0.001', 18); + expect(result).toBe(bigIntToHex(1000000000000000n)); + }); + + it('handles leading zeros in integer part', () => { + const result = parseBalanceWithDecimals('000123.456', 3); + expect(result).toBe(bigIntToHex(123456n)); + }); + }); + + describe('input validation', () => { + it('returns undefined for empty string', () => { + const result = parseBalanceWithDecimals('', 18); + expect(result).toBeUndefined(); + }); + + it('returns undefined for whitespace-only string', () => { + const result = parseBalanceWithDecimals(' ', 18); + expect(result).toBeUndefined(); + }); + + it('returns undefined for negative numbers', () => { + const result = parseBalanceWithDecimals('-123.456', 3); + expect(result).toBeUndefined(); + }); + + it('returns undefined for non-numeric characters', () => { + const result = parseBalanceWithDecimals('abc', 3); + expect(result).toBeUndefined(); + }); + + it('returns undefined for mixed alphanumeric', () => { + const result = parseBalanceWithDecimals('123abc', 3); + expect(result).toBeUndefined(); + }); + + it('returns undefined for multiple decimal points', () => { + const result = parseBalanceWithDecimals('123.45.67', 3); + expect(result).toBeUndefined(); + }); + + it('returns undefined for trailing decimal point only', () => { + const result = parseBalanceWithDecimals('123.', 3); + expect(result).toBeUndefined(); + }); + + it('returns undefined for scientific notation', () => { + const result = parseBalanceWithDecimals('1e10', 3); + expect(result).toBeUndefined(); + }); + + it('returns undefined for hexadecimal numbers', () => { + const result = parseBalanceWithDecimals('0x123', 3); + expect(result).toBeUndefined(); + }); + + it('returns undefined for decimal-only numbers (starting with dot)', () => { + const result = parseBalanceWithDecimals('.123', 6); + expect(result).toBeUndefined(); + }); + + it('returns undefined for string with leading/trailing whitespace', () => { + const result = parseBalanceWithDecimals(' 123.456 ', 3); + expect(result).toBeUndefined(); + }); + }); +}); diff --git a/packages/assets-controllers/src/selectors/stringify-balance.ts b/packages/assets-controllers/src/selectors/stringify-balance.ts new file mode 100644 index 00000000000..acfcce77490 --- /dev/null +++ b/packages/assets-controllers/src/selectors/stringify-balance.ts @@ -0,0 +1,94 @@ +// From https://github.com/MetaMask/eth-token-tracker/blob/main/lib/util.js +// Ensures backwards compatibility with display formatting. + +import { bigIntToHex } from '@metamask/utils'; +import type { Hex } from '@metamask/utils'; + +/** + * @param balance - The balance to stringify as a decimal string + * @param decimals - The number of decimals of the balance + * @param balanceDecimals - The number of decimals to display + * @returns The stringified balance with the specified number of decimals + */ +export function stringifyBalanceWithDecimals( + balance: bigint, + decimals: number, + balanceDecimals = 5, +) { + if (balance === 0n || decimals === 0) { + return balance.toString(); + } + + let bal = balance.toString(); + let len = bal.length; + let decimalIndex = len - decimals; + let prefix = ''; + + if (decimalIndex <= 0) { + while (prefix.length <= decimalIndex * -1) { + prefix += '0'; + len += 1; + } + bal = prefix + bal; + decimalIndex = 1; + } + + const whole = bal.slice(0, len - decimals); + + if (balanceDecimals === 0) { + return whole; + } + + const fractional = bal.slice(decimalIndex, decimalIndex + balanceDecimals); + if (/0+$/u.test(fractional)) { + let withOnlySigZeroes = bal.slice(decimalIndex).replace(/0+$/u, ''); + if (withOnlySigZeroes.length > 0) { + withOnlySigZeroes = `.${withOnlySigZeroes}`; + } + return `${whole}${withOnlySigZeroes}`; + } + return `${whole}.${fractional}`; +} + +/** + * Converts a decimal string representation back to a Hex balance. + * This is the inverse operation of stringifyBalanceWithDecimals. + * + * @param balanceString - The decimal string representation (e.g., "123.456") + * @param decimals - The number of decimals to apply (shifts decimal point right) + * @returns The balance as a Hex string + * + * @example + * parseBalanceWithDecimals("123.456", 18) // Returns '0x6B14BD1E6EEA00000' + * parseBalanceWithDecimals("0.001", 18) // Returns '0x38D7EA4C68000' + * parseBalanceWithDecimals("123", 18) // Returns '0x6AAF7C8516D0C0000' + */ +export function parseBalanceWithDecimals( + balanceString: string, + decimals: number, +): Hex | undefined { + // Allows: "123", "123.456", "0.123", but not: "-123", "123.", "abc", "12.34.56" + if (!/^\d+(\.\d+)?$/u.test(balanceString)) { + return undefined; + } + + const [integerPart, fractionalPart = ''] = balanceString.split('.'); + + if (decimals === 0) { + return bigIntToHex(BigInt(integerPart)); + } + + if (fractionalPart.length >= decimals) { + return bigIntToHex( + BigInt(`${integerPart}${fractionalPart.slice(0, decimals)}`), + ); + } + + return bigIntToHex( + BigInt( + `${integerPart}${fractionalPart}${'0'.repeat( + decimals - fractionalPart.length, + )}`, + ), + ); +} diff --git a/packages/assets-controllers/src/selectors/token-selectors.test.ts b/packages/assets-controllers/src/selectors/token-selectors.test.ts new file mode 100644 index 00000000000..3ca474bde2e --- /dev/null +++ b/packages/assets-controllers/src/selectors/token-selectors.test.ts @@ -0,0 +1,1182 @@ +import { toChecksumAddress } from '@ethereumjs/util'; +import { AccountGroupType, AccountWalletType } from '@metamask/account-api'; +import type { + AccountTreeControllerState, + AccountWalletObject, +} from '@metamask/account-tree-controller'; +import type { AccountsControllerState } from '@metamask/accounts-controller'; +import { TrxScope } from '@metamask/keyring-api'; +import type { NetworkState } from '@metamask/network-controller'; +import type { Hex } from '@metamask/utils'; +import { cloneDeep } from 'lodash'; + +import type { AccountGroupMultichainAccountObject } from '../../../account-tree-controller/src/group.js'; +import type { CurrencyRateState } from '../CurrencyRateController.js'; +import type { MultichainAssetsControllerState } from '../MultichainAssetsController/index.js'; +import type { MultichainAssetsRatesControllerState } from '../MultichainAssetsRatesController/index.js'; +import type { MultichainBalancesControllerState } from '../MultichainBalancesController/index.js'; +import type { TokenBalancesControllerState } from '../TokenBalancesController.js'; +import type { TokenRatesControllerState } from '../TokenRatesController.js'; +import type { TokensControllerState } from '../TokensController.js'; +import { MOCK_TRON_TOKENS } from './__fixtures__/arrange-tron-state.js'; +import { selectAssetsBySelectedAccountGroup } from './token-selectors.js'; + +const mockTokensControllerState: TokensControllerState = { + allTokens: { + '0x1': { + '0x2bd63233fe369b0f13eaf25292af5a9b63d2b7ab': [ + { + address: '0x40D16FC0246aD3160Ccc09B8D0D3A2cD28aE6C2f', + decimals: 18, + symbol: 'GHO', + name: 'GHO Token', + image: + 'https://static.cx.metamask.io/api/v1/tokenIcons/1/0x40d16fc0246ad3160ccc09b8d0d3a2cd28ae6c2f.png', + }, + { + address: '0x6B3595068778DD592e39A122f4f5a5cF09C90fE2', + decimals: 18, + symbol: 'SUSHI', + name: 'SushiSwap', + image: + 'https://static.cx.metamask.io/api/v1/tokenIcons/1/0x6b3595068778dd592e39a122f4f5a5cf09c90fe2.png', + }, + { + // This token will be skipped because it exists in the ignored tokens list + address: '0xCd5fE23C85820F7B72D0926FC9b05b43E359b7ee', + decimals: 18, + symbol: 'WEETH', + name: 'Wrapped eETH', + image: + 'https://static.cx.metamask.io/api/v1/tokenIcons/1/0xcd5fe23c85820f7b72d0926fc9b05b43e359b7ee.png', + }, + { + // This token will be skipped because it has no balance + address: '0x6B175474E89094C44Da98b954EedeAC495271d0F', + decimals: 18, + symbol: 'DAI', + name: 'Dai Stablecoin', + image: + 'https://static.cx.metamask.io/api/v1/tokenIcons/1/0x6B175474E89094C44Da98b954EedeAC495271d0F.png', + }, + ], + '0x0413078b85a6cb85f8f75181ad1a23d265d49202': [ + { + // This token is missing market data + address: '0x5e74c9036fb86bd7ecdcb084a0673efc32ea31cb', + decimals: 18, + symbol: 'SETH', + name: 'Synth sETH', + image: + 'https://static.cx.metamask.io/api/v1/tokenIcons/1/0x5e74c9036fb86bd7ecdcb084a0673efc32ea31cb.png', + }, + { + // This token is missing a conversion rate + address: '0xae7ab96520de3a18e5e111b5eaab095312d7fe84', + decimals: 18, + symbol: 'stETH', + name: 'Lido Staked Ether', + image: + 'https://static.cx.metamask.io/api/v1/tokenIcons/10/0xae7ab96520de3a18e5e111b5eaab095312d7fe84.png', + }, + { + address: '0x514910771AF9Ca656af840dff83E8264EcF986CA', + decimals: 18, + symbol: 'LINK', + name: 'ChainLink Token', + image: + 'https://static.cx.metamask.io/api/v1/tokenIcons/10/0x514910771AF9Ca656af840dff83E8264EcF986CA.png', + }, + ], + '0x1010101010101010101010101010101010101010': [ + { + address: '0xae7ab96520de3a18e5e111b5eaab095312d7fe84', + decimals: 18, + symbol: 'stETH', + name: 'Lido Staked Ether', + image: + 'https://static.cx.metamask.io/api/v1/tokenIcons/10/0xae7ab96520de3a18e5e111b5eaab095312d7fe84.png', + }, + ], + }, + '0xa': { + '0x2bd63233fe369b0f13eaf25292af5a9b63d2b7ab': [ + { + address: '0x0b2C639c533813f4Aa9D7837CAf62653d097Ff85', + decimals: 6, + symbol: 'USDC', + name: 'USDCoin', + image: + 'https://static.cx.metamask.io/api/v1/tokenIcons/10/0x0b2c639c533813f4aa9d7837caf62653d097ff85.png', + }, + ], + }, + }, + allIgnoredTokens: { + '0x1': { + '0x2bd63233fe369b0f13eaf25292af5a9b63d2b7ab': [ + '0xCd5fE23C85820F7B72D0926FC9b05b43E359b7ee', + ], + }, + }, + allDetectedTokens: {}, +}; + +const mockTokenBalancesControllerState: TokenBalancesControllerState = { + tokenBalances: { + '0x2bd63233fe369b0f13eaf25292af5a9b63d2b7ab': { + '0x1': { + '0x40D16FC0246aD3160Ccc09B8D0D3A2cD28aE6C2f': '0x56BC75E2D63100000', // 100000000000000000000 (100 18 decimals) + '0x6B3595068778DD592e39A122f4f5a5cF09C90fE2': '0xAD78EBC5AC6200000', // 200000000000000000000 (200 18 decimals) + '0xCd5fE23C85820F7B72D0926FC9b05b43E359b7ee': '0x2B5E3AF16B1880000', // 50000000000000000000 (50 18 decimals) + }, + '0xa': { + '0x0b2C639c533813f4Aa9D7837CAf62653d097Ff85': '0x3B9ACA00', // 1000000000 (1000 6 decimals) + }, + }, + '0x0413078b85a6cb85f8f75181ad1a23d265d49202': { + '0x1': { + '0x5e74c9036fb86bd7ecdcb084a0673efc32ea31cb': '0x56BC75E2D63100000', // 100000000000000000000 (100 18 decimals) + '0xae7ab96520de3a18e5e111b5eaab095312d7fe84': '0x56BC75E2D63100000', // 100000000000000000000 (100 18 decimals) + '0x514910771AF9Ca656af840dff83E8264EcF986CA': '0x56BC75E2D63100000', // 100000000000000000000 (100 18 decimals) + }, + }, + }, +}; + +const mockTokenRatesControllerState = { + marketData: { + '0x1': { + '0x0000000000000000000000000000000000000000': { + tokenAddress: '0x0000000000000000000000000000000000000000', + currency: 'ETH', + price: 1, + }, + '0x40D16FC0246aD3160Ccc09B8D0D3A2cD28aE6C2f': { + tokenAddress: '0x40D16FC0246aD3160Ccc09B8D0D3A2cD28aE6C2f', + currency: 'ETH', + price: 0.00009, + }, + '0x6B3595068778DD592e39A122f4f5a5cF09C90fE2': { + tokenAddress: '0x6B3595068778DD592e39A122f4f5a5cF09C90fE2', + currency: 'ETH', + price: 0.002, + }, + '0xCd5fE23C85820F7B72D0926FC9b05b43E359b7ee': { + tokenAddress: '0xCd5fE23C85820F7B72D0926FC9b05b43E359b7ee', + currency: 'ETH', + price: 0.1, + }, + '0x5e74c9036fb86bd7ecdcb084a0673efc32ea31cb': { + tokenAddress: '0x5e74c9036fb86bd7ecdcb084a0673efc32ea31cb', + currency: 'ETH', + price: 0.25, + }, + '0x514910771AF9Ca656af840dff83E8264EcF986CA': { + tokenAddress: '0x514910771AF9Ca656af840dff83E8264EcF986CA', + currency: 'ETH', + price: 0.005, + }, + }, + '0xa': { + '0x0000000000000000000000000000000000000000': { + tokenAddress: '0x0000000000000000000000000000000000000000', + currency: 'ETH', + price: 1, + }, + '0x0b2C639c533813f4Aa9D7837CAf62653d097Ff85': { + tokenAddress: '0x0b2C639c533813f4Aa9D7837CAf62653d097Ff85', + currency: 'ETH', + price: 0.005, + }, + }, + }, +} as unknown as TokenRatesControllerState; + +const mockCurrencyRateControllerState = { + currentCurrency: 'USD', + currencyRates: { + ETH: { + conversionRate: 2400, + }, + }, +} as unknown as CurrencyRateState; + +const mockMultichainAssetsControllerState: MultichainAssetsControllerState = { + accountsAssets: { + '2d89e6a0-b4e6-45a8-a707-f10cef143b42': [ + 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/slip44:501', + 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/token:JUPyiwrYJFskUPiHa7hkeR8VUtAeFoSYbKedZNsDvCN', + 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/token:2fUFhZyd47Mapv9wcfXh5gnQwFXtqcYu9xAN4THBpump', + ], + '40fe5e20-525a-4434-bb83-c51ce5560a8c': [ + 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/token:2zMMhcVQEXDtdE6vsFS7S7D5oUodfJHE8vd1gnBouauv', + ], + '767fef5b-0cfd-417a-b618-60ed0f459df7': [ + 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/token:2zMMhcVQEXDtdE6vsFS7S7D5oUodfJHE8vd1gnBouauv', + ], + }, + assetsMetadata: { + 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/slip44:501': { + fungible: true, + iconUrl: + 'https://static.cx.metamask.io/api/v2/tokenIcons/assets/solana/5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/slip44/501.png', + name: 'Solana', + symbol: 'SOL', + units: [ + { + decimals: 9, + name: 'Solana', + symbol: 'SOL', + }, + ], + }, + 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/token:JUPyiwrYJFskUPiHa7hkeR8VUtAeFoSYbKedZNsDvCN': + { + name: 'Jupiter', + symbol: 'JUP', + fungible: true, + iconUrl: + 'https://static.cx.metamask.io/api/v2/tokenIcons/assets/solana/5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/token/JUPyiwrYJFskUPiHa7hkeR8VUtAeFoSYbKedZNsDvCN.png', + units: [ + { + name: 'Jupiter', + symbol: 'JUP', + decimals: 6, + }, + ], + }, + 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/token:2fUFhZyd47Mapv9wcfXh5gnQwFXtqcYu9xAN4THBpump': + { + fungible: true, + iconUrl: + 'https://static.cx.metamask.io/api/v2/tokenIcons/assets/solana/5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/token/2fUFhZyd47Mapv9wcfXh5gnQwFXtqcYu9xAN4THBpump.png', + name: 'RNT', + symbol: 'RNT', + units: [ + { + decimals: 6, + name: 'RNT', + symbol: 'RNT', + }, + ], + }, + 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/token:2zMMhcVQEXDtdE6vsFS7S7D5oUodfJHE8vd1gnBouauv': + { + fungible: true, + iconUrl: + 'https://static.cx.metamask.io/api/v2/tokenIcons/assets/solana/5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/token/2zMMhcVQEXDtdE6vsFS7S7D5oUodfJHE8vd1gnBouauv.png', + name: 'Pudgy Penguins', + symbol: 'PENGU', + units: [ + { + decimals: 6, + name: 'Pudgy Penguins', + symbol: 'PENGU', + }, + ], + }, + }, + allIgnoredAssets: {}, +}; + +const mockAccountTreeControllerState = { + accountTree: { + wallets: { + 'entropy:01K1TJY9QPSCKNBSVGZNG510GJ': { + id: 'entropy:01K1TJY9QPSCKNBSVGZNG510GJ', + type: AccountWalletType.Entropy, + groups: { + 'entropy:01K1TJY9QPSCKNBSVGZNG510GJ/0': { + id: 'entropy:01K1TJY9QPSCKNBSVGZNG510GJ/0', + type: AccountGroupType.MultichainAccount, + accounts: [ + 'd7f11451-9d79-4df4-a012-afd253443639', + '2d89e6a0-b4e6-45a8-a707-f10cef143b42', + ], + } as unknown as AccountGroupMultichainAccountObject, + 'entropy:01K1TJY9QPSCKNBSVGZNG510GJ/1': { + id: 'entropy:01K1TJY9QPSCKNBSVGZNG510GJ/1', + type: AccountGroupType.MultichainAccount, + accounts: [ + '2c311cc8-eeeb-48c7-a629-bb1d9c146b47', + '40fe5e20-525a-4434-bb83-c51ce5560a8c', + ], + } as unknown as AccountGroupMultichainAccountObject, + }, + }, + } as unknown as AccountWalletObject, + }, + selectedAccountGroup: 'entropy:01K1TJY9QPSCKNBSVGZNG510GJ/0', +} as unknown as AccountTreeControllerState; + +const mockAccountControllerState: AccountsControllerState = { + internalAccounts: { + accounts: { + 'd7f11451-9d79-4df4-a012-afd253443639': { + id: 'd7f11451-9d79-4df4-a012-afd253443639', + address: '0x2bd63233fe369b0f13eaf25292af5a9b63d2b7ab', + options: { + entropySource: '01K1TJY9QPSCKNBSVGZNG510GJ', + derivationPath: "m/44'/60'/0'/0/0", + groupIndex: 0, + entropy: { + type: 'mnemonic', + id: '01K1TJY9QPSCKNBSVGZNG510GJ', + derivationPath: "m/44'/60'/0'/0/0", + groupIndex: 0, + }, + }, + methods: [ + 'personal_sign', + 'eth_sign', + 'eth_signTransaction', + 'eth_signTypedData_v1', + 'eth_signTypedData_v3', + 'eth_signTypedData_v4', + ], + scopes: ['eip155:0'], + type: 'eip155:eoa', + metadata: { + name: 'My main test', + importTime: 1754312681246, + lastSelected: 1754312803548, + keyring: { + type: 'HD Key Tree', + }, + nameLastUpdatedAt: 1753697497354, + }, + }, + '2c311cc8-eeeb-48c7-a629-bb1d9c146b47': { + id: '2c311cc8-eeeb-48c7-a629-bb1d9c146b47', + address: '0x0413078b85a6cb85f8f75181ad1a23d265d49202', + options: { + entropySource: '01K1TJY9QPSCKNBSVGZNG510GJ', + derivationPath: "m/44'/60'/0'/0/1", + groupIndex: 1, + entropy: { + type: 'mnemonic', + id: '01K1TJY9QPSCKNBSVGZNG510GJ', + derivationPath: "m/44'/60'/0'/0/1", + groupIndex: 1, + }, + }, + methods: [ + 'personal_sign', + 'eth_sign', + 'eth_signTransaction', + 'eth_signTypedData_v1', + 'eth_signTypedData_v3', + 'eth_signTypedData_v4', + ], + scopes: ['eip155:0'], + type: 'eip155:eoa', + metadata: { + name: 'Account 2', + importTime: 1754312687780, + lastSelected: 0, + keyring: { + type: 'HD Key Tree', + }, + }, + }, + '2d89e6a0-b4e6-45a8-a707-f10cef143b42': { + type: 'solana:data-account', + id: '2d89e6a0-b4e6-45a8-a707-f10cef143b42', + address: '4KTpypSSbugxHe67NC9JURQWfCBNKdQTo4K8rZmYapS7', + options: { + scope: 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp', + derivationPath: "m/44'/501'/0'/0'", + entropySource: '01K1TJY9QPSCKNBSVGZNG510GJ', + synchronize: true, + index: 0, + entropy: { + type: 'mnemonic', + id: '01K1TJY9QPSCKNBSVGZNG510GJ', + groupIndex: 0, + derivationPath: "m/44'/501'/0'/0'", + }, + }, + methods: [ + 'signAndSendTransaction', + 'signTransaction', + 'signMessage', + 'signIn', + ], + scopes: [ + 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp', + 'solana:4uhcVJyU9pJkvQyS88uRDiswHXSCkY3z', + 'solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1', + ], + metadata: { + name: 'Solana Account 2', + importTime: 1754312691747, + keyring: { + type: 'Snap Keyring', + }, + snap: { + id: 'npm:@metamask/solana-wallet-snap', + name: 'Solana', + enabled: true, + }, + lastSelected: 1754312843994, + }, + }, + '40fe5e20-525a-4434-bb83-c51ce5560a8c': { + type: 'solana:data-account', + id: '40fe5e20-525a-4434-bb83-c51ce5560a8c', + address: '7XrST6XEcmjwTVrdfGcH6JFvaiSnokB8LdWCviMuGBjc', + options: { + scope: 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp', + derivationPath: "m/44'/501'/1'/0'", + entropySource: '01K1TJY9QPSCKNBSVGZNG510GJ', + synchronize: true, + index: 1, + entropy: { + type: 'mnemonic', + id: '01K1TJY9QPSCKNBSVGZNG510GJ', + groupIndex: 1, + derivationPath: "m/44'/501'/1'/0'", + }, + }, + methods: [ + 'signAndSendTransaction', + 'signTransaction', + 'signMessage', + 'signIn', + ], + scopes: [ + 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp', + 'solana:4uhcVJyU9pJkvQyS88uRDiswHXSCkY3z', + 'solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1', + ], + metadata: { + name: 'Solana Account 3', + importTime: 1754312692867, + keyring: { + type: 'Snap Keyring', + }, + snap: { + id: 'npm:@metamask/solana-wallet-snap', + name: 'Solana', + enabled: true, + }, + lastSelected: 0, + }, + }, + }, + selectedAccount: 'd7f11451-9d79-4df4-a012-afd253443639', + }, +}; + +const mockMultichainBalancesControllerState = { + balances: { + '2d89e6a0-b4e6-45a8-a707-f10cef143b42': { + 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/slip44:501': { + amount: '10', + unit: 'SOL', + }, + 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/token:JUPyiwrYJFskUPiHa7hkeR8VUtAeFoSYbKedZNsDvCN': + { + amount: '200', + unit: 'JUP', + }, + }, + '40fe5e20-525a-4434-bb83-c51ce5560a8c': { + 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/slip44:501': { + amount: '5', + unit: 'SOL', + }, + 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/token:2zMMhcVQEXDtdE6vsFS7S7D5oUodfJHE8vd1gnBouauv': + { + amount: '100', + unit: 'PENGU', + }, + }, + }, +} as unknown as MultichainBalancesControllerState; + +const mockMultichainAssetsRatesControllerState = { + conversionRates: { + 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/slip44:501': { + rate: '163.55', + currency: 'swift:0/iso4217:USD', + }, + 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/token:JUPyiwrYJFskUPiHa7hkeR8VUtAeFoSYbKedZNsDvCN': + { + rate: '0.463731', + currency: 'swift:0/iso4217:USD', + }, + }, +} as unknown as MultichainAssetsRatesControllerState; + +const mockNetworkControllerState = { + networkConfigurationsByChainId: { + '0x1': { + nativeCurrency: 'ETH', + }, + '0xa': { + nativeCurrency: 'ETH', + }, + '0x89': { + nativeCurrency: 'POL', + }, + }, +} as unknown as NetworkState; + +const mockAccountsTrackerControllerState: { + accountsByChainId: Record< + Hex, + Record< + Hex, + { + balance: Hex | null; + } + > + >; +} = { + accountsByChainId: { + '0x1': { + '0x2bd63233fe369b0f13eaf25292af5a9b63d2b7ab': { + balance: '0x8AC7230489E80000', // 10000000000000000000 (10 - 18 decimals) + }, + '0x0413078b85a6cb85f8f75181ad1a23d265d49202': { + balance: '0xDE0B6B3A7640000', // 1000000000000000000 (1 - 18 decimals) + }, + '0x1010101010101010101010101010101010101010': { + balance: '0xDE0B6B3A7640000', // 1000000000000000000 (1 - 18 decimals) + }, + }, + '0xa': { + '0x2bd63233fe369b0f13eaf25292af5a9b63d2b7ab': { + balance: '0xDE0B6B3A7640000', // 1000000000000000000 (1 - 18 decimals) + }, + }, + '0x89': { + '0x0413078b85a6cb85f8f75181ad1a23d265d49202': { + balance: '0x8AC7230489E80000', // 10000000000000000000 (10 - 18 decimals) + }, + }, + }, +}; + +const mockedMergedState = { + ...mockAccountTreeControllerState, + ...mockAccountControllerState, + ...mockTokensControllerState, + ...mockMultichainAssetsControllerState, + ...mockTokenBalancesControllerState, + ...mockTokenRatesControllerState, + ...mockCurrencyRateControllerState, + ...mockMultichainBalancesControllerState, + ...mockMultichainAssetsRatesControllerState, + ...mockNetworkControllerState, + ...mockAccountsTrackerControllerState, +}; + +const expectedMockResult = { + '0x1': [ + { + accountType: 'eip155:eoa', + accountId: 'd7f11451-9d79-4df4-a012-afd253443639', + chainId: '0x1', + assetId: '0x40D16FC0246aD3160Ccc09B8D0D3A2cD28aE6C2f', + address: '0x40D16FC0246aD3160Ccc09B8D0D3A2cD28aE6C2f', + image: + 'https://static.cx.metamask.io/api/v1/tokenIcons/1/0x40d16fc0246ad3160ccc09b8d0d3a2cd28ae6c2f.png', + name: 'GHO Token', + symbol: 'GHO', + isNative: false, + decimals: 18, + rawBalance: '0x56BC75E2D63100000', + balance: '100', + fiat: { + balance: 21.6, + conversionRate: 2400, + currency: 'USD', + }, + }, + { + accountType: 'eip155:eoa', + accountId: 'd7f11451-9d79-4df4-a012-afd253443639', + chainId: '0x1', + assetId: '0x6B3595068778DD592e39A122f4f5a5cF09C90fE2', + address: '0x6B3595068778DD592e39A122f4f5a5cF09C90fE2', + image: + 'https://static.cx.metamask.io/api/v1/tokenIcons/1/0x6b3595068778dd592e39a122f4f5a5cf09c90fe2.png', + name: 'SushiSwap', + symbol: 'SUSHI', + isNative: false, + decimals: 18, + rawBalance: '0xAD78EBC5AC6200000', + balance: '200', + fiat: { + balance: 960, + conversionRate: 2400, + currency: 'USD', + }, + }, + { + accountType: 'eip155:eoa', + accountId: 'd7f11451-9d79-4df4-a012-afd253443639', + chainId: '0x1', + assetId: '0x0000000000000000000000000000000000000000', + address: '0x0000000000000000000000000000000000000000', + image: '', + name: 'Ethereum', + symbol: 'ETH', + isNative: true, + decimals: 18, + rawBalance: '0x8AC7230489E80000', + balance: '10', + fiat: { + balance: 24000, + conversionRate: 2400, + currency: 'USD', + }, + }, + ], + '0xa': [ + { + accountType: 'eip155:eoa', + accountId: 'd7f11451-9d79-4df4-a012-afd253443639', + chainId: '0xa', + assetId: '0x0b2C639c533813f4Aa9D7837CAf62653d097Ff85', + address: '0x0b2C639c533813f4Aa9D7837CAf62653d097Ff85', + image: + 'https://static.cx.metamask.io/api/v1/tokenIcons/10/0x0b2c639c533813f4aa9d7837caf62653d097ff85.png', + name: 'USDCoin', + symbol: 'USDC', + isNative: false, + decimals: 6, + rawBalance: '0x3B9ACA00', + balance: '1000', + fiat: { + balance: 12000, + conversionRate: 2400, + currency: 'USD', + }, + }, + { + accountType: 'eip155:eoa', + accountId: 'd7f11451-9d79-4df4-a012-afd253443639', + chainId: '0xa', + assetId: '0x0000000000000000000000000000000000000000', + address: '0x0000000000000000000000000000000000000000', + image: '', + name: 'Ethereum', + symbol: 'ETH', + isNative: true, + decimals: 18, + rawBalance: '0xDE0B6B3A7640000', + balance: '1', + fiat: { + balance: 2400, + conversionRate: 2400, + currency: 'USD', + }, + }, + ], + 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp': [ + { + accountType: 'solana:data-account', + accountId: '2d89e6a0-b4e6-45a8-a707-f10cef143b42', + chainId: 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp', + assetId: 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/slip44:501', + image: + 'https://static.cx.metamask.io/api/v2/tokenIcons/assets/solana/5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/slip44/501.png', + name: 'Solana', + symbol: 'SOL', + isNative: true, + decimals: 9, + rawBalance: '0x2540be400', + balance: '10', + fiat: { + balance: 1635.5, + conversionRate: 163.55, + currency: 'USD', + }, + }, + { + accountType: 'solana:data-account', + accountId: '2d89e6a0-b4e6-45a8-a707-f10cef143b42', + chainId: 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp', + assetId: + 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/token:JUPyiwrYJFskUPiHa7hkeR8VUtAeFoSYbKedZNsDvCN', + image: + 'https://static.cx.metamask.io/api/v2/tokenIcons/assets/solana/5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/token/JUPyiwrYJFskUPiHa7hkeR8VUtAeFoSYbKedZNsDvCN.png', + name: 'Jupiter', + symbol: 'JUP', + isNative: false, + decimals: 6, + rawBalance: '0xbebc200', + balance: '200', + fiat: { + balance: 92.7462, + conversionRate: 0.463731, + currency: 'USD', + }, + }, + ], +}; + +describe('token-selectors', () => { + describe('selectAssetsBySelectedAccountGroup', () => { + it('does not include ignored evm tokens', () => { + const result = selectAssetsBySelectedAccountGroup(mockedMergedState); + + const ignoredTokenAddress = '0xCd5fE23C85820F7B72D0926FC9b05b43E359b7ee'; + + expect( + result['0x1'].find((asset) => asset.assetId === ignoredTokenAddress), + ).toBeUndefined(); + }); + + it('does not include evm tokens with no balance', () => { + const result = selectAssetsBySelectedAccountGroup(mockedMergedState); + + const tokenWithNoBalance = '0x6B175474E89094C44Da98b954EedeAC495271d0F'; + + expect( + result['0x1'].find((asset) => asset.assetId === tokenWithNoBalance), + ).toBeUndefined(); + }); + + it('includes evm tokens with no fiat balance due to missing conversion rate to native token', () => { + const result = selectAssetsBySelectedAccountGroup({ + ...mockedMergedState, + selectedAccountGroup: 'entropy:01K1TJY9QPSCKNBSVGZNG510GJ/1', + }); + + const tokenWithNoFiatBalance = result['0x1'].find( + (asset) => + asset.assetId === '0xae7ab96520de3a18e5e111b5eaab095312d7fe84', + ); + + expect(tokenWithNoFiatBalance).toStrictEqual({ + accountId: '2c311cc8-eeeb-48c7-a629-bb1d9c146b47', + address: '0xae7ab96520de3a18e5e111b5eaab095312d7fe84', + assetId: '0xae7ab96520de3a18e5e111b5eaab095312d7fe84', + rawBalance: '0x56BC75E2D63100000', + balance: '100', + chainId: '0x1', + decimals: 18, + fiat: undefined, + image: + 'https://static.cx.metamask.io/api/v1/tokenIcons/10/0xae7ab96520de3a18e5e111b5eaab095312d7fe84.png', + isNative: false, + name: 'Lido Staked Ether', + symbol: 'stETH', + accountType: 'eip155:eoa', + }); + }); + + it('includes evm tokens with no fiat balance due to missing conversion rate to fiat', () => { + const result = selectAssetsBySelectedAccountGroup({ + ...mockedMergedState, + selectedAccountGroup: 'entropy:01K1TJY9QPSCKNBSVGZNG510GJ/1', + currencyRates: {}, + }); + + const tokenWithNoFiatBalance = result['0x1'].find( + (asset) => + asset.assetId === '0x514910771AF9Ca656af840dff83E8264EcF986CA', + ); + + expect(tokenWithNoFiatBalance).toStrictEqual({ + accountId: '2c311cc8-eeeb-48c7-a629-bb1d9c146b47', + address: '0x514910771AF9Ca656af840dff83E8264EcF986CA', + assetId: '0x514910771AF9Ca656af840dff83E8264EcF986CA', + rawBalance: '0x56BC75E2D63100000', + balance: '100', + chainId: '0x1', + decimals: 18, + fiat: undefined, + image: + 'https://static.cx.metamask.io/api/v1/tokenIcons/10/0x514910771AF9Ca656af840dff83E8264EcF986CA.png', + isNative: false, + name: 'ChainLink Token', + symbol: 'LINK', + accountType: 'eip155:eoa', + }); + }); + + it('does not include multichaintokens with no balance', () => { + const result = selectAssetsBySelectedAccountGroup(mockedMergedState); + + const tokenWithNoBalance = + 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/token:2fUFhZyd47Mapv9wcfXh5gnQwFXtqcYu9xAN4THBpump'; + + expect( + result['solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp'].find( + (asset) => asset.assetId === tokenWithNoBalance, + ), + ).toBeUndefined(); + }); + + it('includes multichain tokens with no fiat balance due to missing conversion rate to fiat', () => { + const result = selectAssetsBySelectedAccountGroup({ + ...mockedMergedState, + selectedAccountGroup: 'entropy:01K1TJY9QPSCKNBSVGZNG510GJ/1', + }); + + const tokenWithNoFiatBalance = result[ + 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp' + ].find( + (asset) => + asset.assetId === + 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/token:2zMMhcVQEXDtdE6vsFS7S7D5oUodfJHE8vd1gnBouauv', + ); + + expect(tokenWithNoFiatBalance).toStrictEqual({ + accountId: '40fe5e20-525a-4434-bb83-c51ce5560a8c', + assetId: + 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/token:2zMMhcVQEXDtdE6vsFS7S7D5oUodfJHE8vd1gnBouauv', + rawBalance: '0x5f5e100', + balance: '100', + chainId: 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp', + decimals: 6, + fiat: undefined, + image: + 'https://static.cx.metamask.io/api/v2/tokenIcons/assets/solana/5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/token/2zMMhcVQEXDtdE6vsFS7S7D5oUodfJHE8vd1gnBouauv.png', + isNative: false, + name: 'Pudgy Penguins', + symbol: 'PENGU', + accountType: 'solana:data-account', + }); + }); + + it('extracts native currency names from network configuration', () => { + const result = selectAssetsBySelectedAccountGroup({ + ...mockedMergedState, + selectedAccountGroup: 'entropy:01K1TJY9QPSCKNBSVGZNG510GJ/1', + }); + + const nativeToken = result['0x89'].find((asset) => asset.isNative); + + expect(nativeToken).toStrictEqual({ + accountId: '2c311cc8-eeeb-48c7-a629-bb1d9c146b47', + assetId: '0x0000000000000000000000000000000000001010', + address: '0x0000000000000000000000000000000000001010', + rawBalance: '0x8AC7230489E80000', + chainId: '0x89', + name: 'POL', + symbol: 'POL', + image: '', + isNative: true, + decimals: 18, + balance: '10', + fiat: undefined, + accountType: 'eip155:eoa', + }); + }); + + it('returns all assets for the selected account group', () => { + const result = selectAssetsBySelectedAccountGroup(mockedMergedState); + + expect(result).toStrictEqual(expectedMockResult); + }); + + it('skips accounts referenced in accountTree but missing from internalAccounts', () => { + const state = cloneDeep(mockedMergedState); + + state.accountTree.wallets['entropy:01K1TJY9QPSCKNBSVGZNG510GJ'].groups[ + 'entropy:01K1TJY9QPSCKNBSVGZNG510GJ/0' + ].accounts.push('non-existent-account-id'); + + const result = selectAssetsBySelectedAccountGroup(state); + + expect(result).toStrictEqual(expectedMockResult); + }); + + it('returns no tokens if there is no selected account group', () => { + const result = selectAssetsBySelectedAccountGroup({ + ...mockedMergedState, + selectedAccountGroup: '', + }); + + expect(result).toStrictEqual({}); + }); + + it('returns assets even when addresses from AccountsTrackerController are checksummed', () => { + const result = selectAssetsBySelectedAccountGroup({ + ...mockedMergedState, + accountsByChainId: Object.fromEntries( + Object.entries(mockedMergedState.accountsByChainId).map( + ([chainId, accounts]) => [ + chainId, + Object.fromEntries( + Object.entries(accounts).map(([address, data]) => [ + toChecksumAddress(address), + data, + ]), + ), + ], + ), + ), + }); + + expect(result).toStrictEqual(expectedMockResult); + }); + + const arrangeTronState = () => { + const state = cloneDeep(mockedMergedState); + + // Add Tron account to the selected account group + state.accountTree.wallets['entropy:01K1TJY9QPSCKNBSVGZNG510GJ'].groups[ + 'entropy:01K1TJY9QPSCKNBSVGZNG510GJ/0' + ].accounts.push('de5c3465-d01e-4091-a219-232903e982bb'); + + // Add Tron account to accounts controller + state.internalAccounts.accounts['de5c3465-d01e-4091-a219-232903e982bb'] = + { + id: 'de5c3465-d01e-4091-a219-232903e982bb', + address: 'TYasKMTpukV8rLfkSzqrFH7VcCwjBFJ7s9', + type: 'tron:eoa', + scopes: ['tron:728126428', 'tron:3448148188', 'tron:2494104990'], + methods: ['tron_signTransaction', 'tron_signMessage'], + options: { + entropySource: '01K1TJY9QPSCKNBSVGZNG510GJ', + groupIndex: 0, + }, + metadata: { + name: 'Tron Account 1', + keyring: { type: 'Snap Keyring' }, + importTime: Date.now(), + }, + }; + + // Extract asset IDs from MOCK_TRON_TOKENS and add to accountsAssets + const allTronAssetIds = Object.values(MOCK_TRON_TOKENS) + .flat() + .map((token) => token.assetId); + state.accountsAssets['de5c3465-d01e-4091-a219-232903e982bb'] = + allTronAssetIds; + + // Create metadata from MOCK_TRON_TOKENS + Object.values(MOCK_TRON_TOKENS) + .flat() + .forEach((token) => { + state.assetsMetadata[token.assetId] = { + fungible: true, + iconUrl: token.image, + name: token.name, + symbol: token.symbol, + units: [ + { + decimals: token.decimals, + name: token.name, + symbol: token.symbol, + }, + ], + }; + }); + + // Create balances from MOCK_TRON_TOKENS + state.balances['de5c3465-d01e-4091-a219-232903e982bb'] = {}; + Object.values(MOCK_TRON_TOKENS) + .flat() + .forEach((token) => { + state.balances['de5c3465-d01e-4091-a219-232903e982bb'][ + token.assetId + ] = { + amount: token.balance, + unit: token.symbol, + }; + }); + + // Create conversion rates from MOCK_TRON_TOKENS (only for tokens with fiat data) + Object.values(MOCK_TRON_TOKENS) + .flat() + .forEach((token) => { + if (token.fiat?.conversionRate) { + state.conversionRates[token.assetId] = { + rate: token.fiat.conversionRate.toString(), + conversionTime: Date.now(), + }; + } + }); + + return state; + }; + + it('filters out tron staked tokens', () => { + const state = arrangeTronState(); + + const result = selectAssetsBySelectedAccountGroup(state); + + expect(result[TrxScope.Mainnet]).toHaveLength(1); + expect(result[TrxScope.Nile]).toHaveLength(1); + expect(result[TrxScope.Shasta]).toHaveLength(1); + }); + + it('does not filter out tron staked tokens', () => { + const state = arrangeTronState(); + + const result = selectAssetsBySelectedAccountGroup(state, { + filterTronStakedTokens: false, + }); + + expect(result[TrxScope.Mainnet].length > 1).toBe(true); + expect(result[TrxScope.Nile].length > 1).toBe(true); + expect(result[TrxScope.Shasta].length > 1).toBe(true); + }); + + it('calculates fiat for native token using currency rate fallback when market data is missing', () => { + // Setup: Add a new chain (Ink chain 0xdef1) with native balance but NO market data + const inkChainId = '0xdef1' as Hex; + const stateWithInkChain = { + ...mockedMergedState, + // Add Ink chain to network configuration + networkConfigurationsByChainId: { + ...mockNetworkControllerState.networkConfigurationsByChainId, + [inkChainId]: { + nativeCurrency: 'ETH', // Ink chain uses ETH as native currency + }, + }, + // Add native balance for the account on Ink chain + accountsByChainId: { + ...mockAccountsTrackerControllerState.accountsByChainId, + [inkChainId]: { + '0x2bd63233fe369b0f13eaf25292af5a9b63d2b7ab': { + balance: '0xDE0B6B3A7640000', // 1 ETH (1000000000000000000 wei) + }, + }, + }, + // Market data does NOT include Ink chain native token + // (using existing mockTokenRatesControllerState which doesn't have 0xdef1) + }; + + const result = selectAssetsBySelectedAccountGroup(stateWithInkChain); + + // Find the Ink chain native token + const inkNativeToken = result[inkChainId]?.find( + (asset) => asset.isNative, + ); + + // Should have fiat calculated using the ETH currency rate fallback + expect(inkNativeToken).toStrictEqual({ + accountType: 'eip155:eoa', + accountId: 'd7f11451-9d79-4df4-a012-afd253443639', + chainId: inkChainId, + assetId: '0x0000000000000000000000000000000000000000', + address: '0x0000000000000000000000000000000000000000', + image: '', + name: 'Ethereum', + symbol: 'ETH', + isNative: true, + decimals: 18, + rawBalance: '0xDE0B6B3A7640000', + balance: '1', + fiat: { + balance: 2400, // 1 ETH * 2400 USD/ETH + conversionRate: 2400, + currency: 'USD', + }, + }); + }); + + it('returns undefined fiat for native token when both market data and currency rate are missing', () => { + const inkChainId = '0xdef1' as Hex; + const stateWithMissingCurrencyRate = { + ...mockedMergedState, + networkConfigurationsByChainId: { + ...mockNetworkControllerState.networkConfigurationsByChainId, + [inkChainId]: { + nativeCurrency: 'INK', // Custom native currency with no currency rate + }, + }, + accountsByChainId: { + ...mockAccountsTrackerControllerState.accountsByChainId, + [inkChainId]: { + '0x2bd63233fe369b0f13eaf25292af5a9b63d2b7ab': { + balance: '0xDE0B6B3A7640000', + }, + }, + }, + // currencyRates doesn't have 'INK', only 'ETH' + }; + + const result = selectAssetsBySelectedAccountGroup( + stateWithMissingCurrencyRate, + ); + + const inkNativeToken = result[inkChainId]?.find( + (asset) => asset.isNative, + ); + + // Should have undefined fiat since there's no currency rate for 'INK' + expect(inkNativeToken?.fiat).toBeUndefined(); + }); + + it('hides native tokens on Tempo testnet (0xa5bf)', () => { + const tempoTestnetChainId = '0xa5bf' as Hex; // 42431 in decimal + const stateWithTempoTestnet = { + ...mockedMergedState, + networkConfigurationsByChainId: { + ...mockNetworkControllerState.networkConfigurationsByChainId, + [tempoTestnetChainId]: { + nativeCurrency: 'ETH', + }, + }, + accountsByChainId: { + ...mockAccountsTrackerControllerState.accountsByChainId, + [tempoTestnetChainId]: { + '0x2bd63233fe369b0f13eaf25292af5a9b63d2b7ab': { + balance: '0xDE0B6B3A7640000', // 1 ETH + }, + }, + }, + }; + + const result = selectAssetsBySelectedAccountGroup(stateWithTempoTestnet); + + // Native token should be hidden on Tempo testnet + const nativeToken = result[tempoTestnetChainId]?.find( + (asset) => asset.isNative, + ); + expect(nativeToken).toBeUndefined(); + }); + + it('hides native tokens on Tempo mainnet (0x1079)', () => { + const tempoMainnetChainId = '0x1079' as Hex; // 4217 in decimal + const stateWithTempoMainnet = { + ...mockedMergedState, + networkConfigurationsByChainId: { + ...mockNetworkControllerState.networkConfigurationsByChainId, + [tempoMainnetChainId]: { + nativeCurrency: 'ETH', + }, + }, + accountsByChainId: { + ...mockAccountsTrackerControllerState.accountsByChainId, + [tempoMainnetChainId]: { + '0x2bd63233fe369b0f13eaf25292af5a9b63d2b7ab': { + balance: '0xDE0B6B3A7640000', // 1 ETH + }, + }, + }, + }; + + const result = selectAssetsBySelectedAccountGroup(stateWithTempoMainnet); + + // Native token should be hidden on Tempo mainnet + const nativeToken = result[tempoMainnetChainId]?.find( + (asset) => asset.isNative, + ); + expect(nativeToken).toBeUndefined(); + }); + + it('does not hide native tokens on non-Tempo networks', () => { + const ethereumChainId = '0x1' as Hex; + const result = selectAssetsBySelectedAccountGroup(mockedMergedState); + + // Native token should still be visible on Ethereum + const nativeToken = result[ethereumChainId]?.find( + (asset) => asset.isNative, + ); + expect(nativeToken).toBeDefined(); + expect(nativeToken?.symbol).toBe('ETH'); + }); + }); +}); diff --git a/packages/assets-controllers/src/selectors/token-selectors.ts b/packages/assets-controllers/src/selectors/token-selectors.ts new file mode 100644 index 00000000000..4dc85e8ef72 --- /dev/null +++ b/packages/assets-controllers/src/selectors/token-selectors.ts @@ -0,0 +1,660 @@ +import type { AccountGroupId } from '@metamask/account-api'; +import type { AccountTreeControllerState } from '@metamask/account-tree-controller'; +import type { AccountsControllerState } from '@metamask/accounts-controller'; +import { convertHexToDecimal } from '@metamask/controller-utils'; +import { TrxScope } from '@metamask/keyring-api'; +import type { InternalAccount } from '@metamask/keyring-internal-api'; +import type { NetworkState } from '@metamask/network-controller'; +import { hexToBigInt, parseCaipAssetType } from '@metamask/utils'; +import type { Hex } from '@metamask/utils'; +import { createSelector, weakMapMemoize } from 'reselect'; +import { TokenRwaData } from 'src/token-service'; + +import { shouldIncludeNativeToken } from '../constants.js'; +import type { CurrencyRateState } from '../CurrencyRateController.js'; +import type { MultichainAssetsControllerState } from '../MultichainAssetsController/index.js'; +import type { MultichainAssetsRatesControllerState } from '../MultichainAssetsRatesController/index.js'; +import type { MultichainBalancesControllerState } from '../MultichainBalancesController/index.js'; +import { getNativeTokenAddress } from '../token-prices-service/codefi-v2.js'; +import type { TokenBalancesControllerState } from '../TokenBalancesController.js'; +import type { + Token, + TokenRatesControllerState, +} from '../TokenRatesController.js'; +import type { TokensControllerState } from '../TokensController.js'; +import { + parseBalanceWithDecimals, + stringifyBalanceWithDecimals, +} from './stringify-balance.js'; + +// Asset Tron Filters +export const TRON_RESOURCE = { + ENERGY: 'energy', + BANDWIDTH: 'bandwidth', + MAX_ENERGY: 'max-energy', + MAX_BANDWIDTH: 'max-bandwidth', + STRX_ENERGY: 'strx-energy', + STRX_BANDWIDTH: 'strx-bandwidth', + TRX_READY_FOR_WITHDRAWAL: 'trx-ready-for-withdrawal', + TRX_STAKING_REWARDS: 'trx-staking-rewards', + TRX_IN_LOCK_PERIOD: 'trx-in-lock-period', +} as const; + +export type TronResourceSymbol = + (typeof TRON_RESOURCE)[keyof typeof TRON_RESOURCE]; + +export const TRON_RESOURCE_SYMBOLS = Object.values( + TRON_RESOURCE, +) as readonly TronResourceSymbol[]; + +export const TRON_RESOURCE_SYMBOLS_SET: ReadonlySet = + new Set(TRON_RESOURCE_SYMBOLS); + +export type AssetsByAccountGroup = { + [accountGroupId: AccountGroupId]: AccountGroupAssets; +}; + +export type AccountGroupAssets = { + [network: string]: Asset[]; +}; + +type EvmAccountType = Extract; +type MultichainAccountType = Exclude< + InternalAccount['type'], + `eip155:${string}` +>; + +export type Asset = ( + | { + accountType: EvmAccountType; + assetId: Hex; // This is also the address for EVM tokens + address: Hex; + chainId: Hex; + } + | { + accountType: MultichainAccountType; + assetId: `${string}:${string}/${string}:${string}`; + chainId: `${string}:${string}`; + } +) & { + accountId: string; + image: string; + name: string; + symbol: string; + decimals: number; + isNative: boolean; + rawBalance: Hex; + balance: string; + fiat: + | { + balance: number; + currency: string; + conversionRate: number; + } + | undefined; + rwaData?: TokenRwaData; +}; + +export type AssetListState = { + accountTree: AccountTreeControllerState['accountTree']; + selectedAccountGroup: AccountTreeControllerState['selectedAccountGroup']; + internalAccounts: AccountsControllerState['internalAccounts']; + allTokens: TokensControllerState['allTokens']; + allIgnoredTokens: TokensControllerState['allIgnoredTokens']; + tokenBalances: TokenBalancesControllerState['tokenBalances']; + marketData: TokenRatesControllerState['marketData']; + currencyRates: CurrencyRateState['currencyRates']; + accountsAssets: MultichainAssetsControllerState['accountsAssets']; + allIgnoredAssets: MultichainAssetsControllerState['allIgnoredAssets']; + assetsMetadata: MultichainAssetsControllerState['assetsMetadata']; + balances: MultichainBalancesControllerState['balances']; + conversionRates: MultichainAssetsRatesControllerState['conversionRates']; + currentCurrency: CurrencyRateState['currentCurrency']; + networkConfigurationsByChainId: NetworkState['networkConfigurationsByChainId']; + // This is the state from AccountTrackerController. The state is different on mobile and extension + // accountsByChainId with a balance is the only field that both clients have in common + // This field could be removed once TokenBalancesController returns native balances + accountsByChainId: Record< + Hex, + Record< + Hex, + { + balance: Hex | null; + } + > + >; +}; + +const createAssetListSelector = createSelector.withTypes(); + +const selectAccountsToGroupIdMap = createAssetListSelector( + [(state) => state.accountTree, (state) => state.internalAccounts], + (accountTree, internalAccounts) => { + const accountsMap: Record< + string, + { + accountGroupId: AccountGroupId; + type: InternalAccount['type']; + accountId: string; + } + > = {}; + for (const { groups } of Object.values(accountTree.wallets)) { + for (const { id: accountGroupId, accounts } of Object.values(groups)) { + for (const accountId of accounts) { + const internalAccount = internalAccounts.accounts[accountId]; + + if (!internalAccount) { + continue; + } + + accountsMap[ + // TODO: We would not need internalAccounts if evmTokens state had the accountId + internalAccount.type.startsWith('eip155') + ? internalAccount.address + : accountId + ] = { accountGroupId, type: internalAccount.type, accountId }; + } + } + } + + return accountsMap; + }, +); + +// TODO: This selector will not be needed once the native balances are part of the evm tokens state +const selectAllEvmAccountNativeBalances = createAssetListSelector( + [ + selectAccountsToGroupIdMap, + (state) => state.accountsByChainId, + (state) => state.marketData, + (state) => state.currencyRates, + (state) => state.currentCurrency, + (state) => state.networkConfigurationsByChainId, + ], + ( + accountsMap, + accountsByChainId, + marketData, + currencyRates, + currentCurrency, + networkConfigurationsByChainId, + ) => { + const groupAssets: AssetsByAccountGroup = {}; + + for (const [chainId, chainAccounts] of Object.entries( + accountsByChainId, + ) as [Hex, Record][]) { + // Skip native tokens on Tempo networks + if (!shouldIncludeNativeToken(chainId)) { + continue; + } + for (const [accountAddress, accountBalance] of Object.entries( + chainAccounts, + )) { + const account = accountsMap[accountAddress.toLowerCase()]; + if (!account) { + continue; + } + + const { accountGroupId, type, accountId } = account; + + groupAssets[accountGroupId] ??= {}; + groupAssets[accountGroupId][chainId] ??= []; + const groupChainAssets = groupAssets[accountGroupId][chainId]; + + // If a native balance is missing, we still want to show it as 0 + const rawBalance = accountBalance.balance || '0x0'; + + const nativeCurrency = + networkConfigurationsByChainId[chainId]?.nativeCurrency || 'NATIVE'; + + const nativeToken = { + address: getNativeTokenAddress(chainId), + decimals: 18, + name: nativeCurrency === 'ETH' ? 'Ethereum' : nativeCurrency, + symbol: nativeCurrency, + // This field need to be filled at client level for now + image: '', + }; + + const fiatData = getFiatBalanceForEvmToken( + rawBalance, + nativeToken.decimals, + marketData, + currencyRates, + chainId, + nativeToken.address, + nativeCurrency, // Pass native currency symbol for fallback when market data is missing + ); + + groupChainAssets.push({ + accountType: type as EvmAccountType, + assetId: nativeToken.address, + isNative: true, + address: nativeToken.address, + image: nativeToken.image, + name: nativeToken.name, + symbol: nativeToken.symbol, + accountId, + decimals: nativeToken.decimals, + rawBalance, + balance: stringifyBalanceWithDecimals( + hexToBigInt(rawBalance), + nativeToken.decimals, + ), + fiat: fiatData + ? { + balance: fiatData.balance, + currency: currentCurrency, + conversionRate: fiatData.conversionRate, + } + : undefined, + chainId, + }); + } + } + + return groupAssets; + }, +); + +const selectAllEvmAssets = createAssetListSelector( + [ + selectAccountsToGroupIdMap, + (state) => state.allTokens, + (state) => state.allIgnoredTokens, + (state) => state.tokenBalances, + (state) => state.marketData, + (state) => state.currencyRates, + (state) => state.currentCurrency, + ], + ( + accountsMap, + evmTokens, + ignoredEvmTokens, + tokenBalances, + marketData, + currencyRates, + currentCurrency, + ) => { + const groupAssets: AssetsByAccountGroup = {}; + + for (const [chainId, chainTokens] of Object.entries(evmTokens) as [ + Hex, + { [key: string]: Token[] }, + ][]) { + for (const [accountAddress, addressTokens] of Object.entries( + chainTokens, + ) as [Hex, Token[]][]) { + for (const token of addressTokens) { + const tokenAddress = token.address as Hex; + const account = accountsMap[accountAddress]; + if (!account) { + continue; + } + + const { accountGroupId, type, accountId } = account; + + if ( + ignoredEvmTokens[chainId]?.[accountAddress]?.includes(tokenAddress) + ) { + continue; + } + + const rawBalance = + tokenBalances[accountAddress]?.[chainId]?.[tokenAddress]; + + if (!rawBalance) { + continue; + } + + groupAssets[accountGroupId] ??= {}; + groupAssets[accountGroupId][chainId] ??= []; + const groupChainAssets = groupAssets[accountGroupId][chainId]; + + const fiatData = getFiatBalanceForEvmToken( + rawBalance, + token.decimals, + marketData, + currencyRates, + chainId, + tokenAddress, + ); + + groupChainAssets.push({ + accountType: type as EvmAccountType, + assetId: tokenAddress, + isNative: false, + address: tokenAddress, + image: token.image ?? '', + name: token.name ?? token.symbol, + symbol: token.symbol, + accountId, + decimals: token.decimals, + rawBalance, + balance: stringifyBalanceWithDecimals( + hexToBigInt(rawBalance), + token.decimals, + ), + fiat: fiatData + ? { + balance: fiatData.balance, + currency: currentCurrency, + conversionRate: fiatData.conversionRate, + } + : undefined, + chainId, + ...(token.rwaData && { rwaData: token.rwaData }), + }); + } + } + } + + return groupAssets; + }, +); + +const selectAllMultichainAssets = createAssetListSelector( + [ + selectAccountsToGroupIdMap, + (state) => state.accountsAssets, + (state) => state.allIgnoredAssets, + (state) => state.assetsMetadata, + (state) => state.balances, + (state) => state.conversionRates, + (state) => state.currentCurrency, + ], + ( + accountsMap, + multichainTokens, + ignoredMultichainAssets, + multichainAssetsMetadata, + multichainBalances, + multichainConversionRates, + currentCurrency, + ) => { + const groupAssets: AssetsByAccountGroup = {}; + + for (const [accountId, accountAssets] of Object.entries(multichainTokens)) { + for (const assetId of accountAssets) { + let caipAsset: ReturnType; + try { + caipAsset = parseCaipAssetType(assetId); + } catch { + // TODO: We should log this error when we have the ability to inject a logger from the client + continue; + } + + const { chainId } = caipAsset; + const asset = `${caipAsset.assetNamespace}:${caipAsset.assetReference}`; + + const account = accountsMap[accountId]; + const assetMetadata = multichainAssetsMetadata[assetId]; + if (!account || !assetMetadata) { + continue; + } + + const { accountGroupId, type } = account; + + if (ignoredMultichainAssets?.[accountId]?.includes(assetId)) { + continue; + } + + groupAssets[accountGroupId] ??= {}; + groupAssets[accountGroupId][chainId] ??= []; + const groupChainAssets = groupAssets[accountGroupId][chainId]; + + const balance: + | { + amount: string; + unit: string; + } + | undefined = multichainBalances[accountId]?.[assetId]; + + const decimals = assetMetadata.units?.find( + (unit) => + unit.name === assetMetadata.name && + unit.symbol === assetMetadata.symbol, + )?.decimals; + + if (!balance || decimals === undefined) { + continue; + } + + const rawBalance = parseBalanceWithDecimals(balance.amount, decimals); + + if (!rawBalance) { + continue; + } + + const fiatData = getFiatBalanceForMultichainAsset( + balance, + multichainConversionRates, + assetId, + ); + + // TODO: We shouldn't have to rely on fallbacks for name and symbol, they should not be optional + groupChainAssets.push({ + accountType: type as MultichainAccountType, + assetId, + isNative: caipAsset.assetNamespace === 'slip44', + image: assetMetadata.iconUrl, + name: assetMetadata.name ?? assetMetadata.symbol ?? asset, + symbol: assetMetadata.symbol ?? asset, + accountId, + decimals, + rawBalance, + balance: balance.amount, + fiat: fiatData + ? { + balance: fiatData.balance, + currency: currentCurrency, + conversionRate: fiatData.conversionRate, + } + : undefined, + chainId, + }); + } + } + + return groupAssets; + }, +); + +export const selectAllAssets = createAssetListSelector( + [ + selectAllEvmAssets, + selectAllMultichainAssets, + selectAllEvmAccountNativeBalances, + ], + (evmAssets, multichainAssets, evmAccountNativeBalances) => { + const groupAssets: AssetsByAccountGroup = {}; + + mergeAssets(groupAssets, evmAssets); + + mergeAssets(groupAssets, multichainAssets); + + mergeAssets(groupAssets, evmAccountNativeBalances); + + return groupAssets; + }, +); + +export type SelectAccountGroupAssetOpts = { + filterTronStakedTokens: boolean; +}; + +const defaultSelectAccountGroupAssetOpts: SelectAccountGroupAssetOpts = { + filterTronStakedTokens: true, +}; + +const filterTronStakedTokens = (assetsByAccountGroup: AccountGroupAssets) => { + const newAssetsByAccountGroup = { ...assetsByAccountGroup }; + + Object.values(TrxScope).forEach((tronChainId) => { + if (!newAssetsByAccountGroup[tronChainId]) { + return; + } + + newAssetsByAccountGroup[tronChainId] = newAssetsByAccountGroup[ + tronChainId + ].filter((asset: Asset) => { + if ( + asset.chainId.startsWith('tron:') && + TRON_RESOURCE_SYMBOLS_SET.has( + asset.symbol?.toLowerCase() as TronResourceSymbol, + ) + ) { + return false; + } + return true; + }); + }); + + return newAssetsByAccountGroup; +}; + +export const selectAssetsBySelectedAccountGroup = createAssetListSelector( + [ + selectAllAssets, + (state) => state.selectedAccountGroup, + ( + _state, + opts: SelectAccountGroupAssetOpts = defaultSelectAccountGroupAssetOpts, + ) => opts, + ], + (groupAssets, selectedAccountGroup, opts) => { + if (!selectedAccountGroup) { + return {}; + } + + let result = groupAssets[selectedAccountGroup] || {}; + + if (opts.filterTronStakedTokens) { + result = filterTronStakedTokens(result); + } + + return result; + }, + { + memoize: weakMapMemoize, + argsMemoize: weakMapMemoize, + }, +); + +// TODO: Once native assets are part of the evm tokens state, this function can be simplified as chains will always be unique +/** + * Merges the new assets into the existing assets + * + * @param existingAssets - The existing assets + * @param newAssets - The new assets + */ +function mergeAssets( + existingAssets: AssetsByAccountGroup, + newAssets: AssetsByAccountGroup, +) { + for (const [accountGroupId, accountAssets] of Object.entries(newAssets) as [ + AccountGroupId, + AccountGroupAssets, + ][]) { + const existingAccountGroupAssets = existingAssets[accountGroupId]; + + if (!existingAccountGroupAssets) { + existingAssets[accountGroupId] = {}; + for (const [network, chainAssets] of Object.entries(accountAssets)) { + existingAssets[accountGroupId][network] = [...chainAssets]; + } + } else { + for (const [network, chainAssets] of Object.entries(accountAssets)) { + existingAccountGroupAssets[network] ??= []; + existingAccountGroupAssets[network].push(...chainAssets); + } + } + } +} + +/** + * @param rawBalance - The balance of the token + * @param decimals - The decimals of the token + * @param marketData - The market data for the token + * @param currencyRates - The currency rates for the token + * @param chainId - The chain id of the token + * @param tokenAddress - The address of the token + * @param nativeCurrencySymbol - The native currency symbol (e.g., 'ETH', 'BNB') - used for fallback when market data is missing for native tokens + * @returns The price and currency of the token in the current currency. Returns undefined if the asset is not found in the market data or currency rates. + */ +function getFiatBalanceForEvmToken( + rawBalance: Hex, + decimals: number, + marketData: TokenRatesControllerState['marketData'], + currencyRates: CurrencyRateState['currencyRates'], + chainId: Hex, + tokenAddress: Hex, + nativeCurrencySymbol?: string, +) { + const tokenMarketData = marketData[chainId]?.[tokenAddress]; + + // For native tokens: if no market data exists, use price=1 and look up currency rate directly + // This is because native tokens are priced in themselves (1 ETH = 1 ETH) + if (!tokenMarketData && nativeCurrencySymbol) { + const currencyRate = currencyRates[nativeCurrencySymbol]; + + if (!currencyRate?.conversionRate) { + return undefined; + } + + const fiatBalance = + (convertHexToDecimal(rawBalance) / 10 ** decimals) * + currencyRate.conversionRate; + + return { + balance: fiatBalance, + conversionRate: currencyRate.conversionRate, + }; + } + + if (!tokenMarketData) { + return undefined; + } + + const currencyRate = currencyRates[tokenMarketData.currency]; + + if (!currencyRate?.conversionRate) { + return undefined; + } + + const fiatBalance = + (convertHexToDecimal(rawBalance) / 10 ** decimals) * + tokenMarketData.price * + currencyRate.conversionRate; + + return { + balance: fiatBalance, + conversionRate: currencyRate.conversionRate, + }; +} + +/** + * @param balance - The balance of the asset, in the format { amount: string; unit: string } + * @param balance.amount - The amount of the balance + * @param balance.unit - The unit of the balance + * @param multichainConversionRates - The conversion rates for the multichain asset + * @param assetId - The asset id of the asset + * @returns The price and currency of the token in the current currency. Returns undefined if the asset is not found in the conversion rates. + */ +function getFiatBalanceForMultichainAsset( + balance: { amount: string; unit: string }, + multichainConversionRates: MultichainAssetsRatesControllerState['conversionRates'], + assetId: `${string}:${string}/${string}:${string}`, +) { + const assetMarketData = multichainConversionRates[assetId]; + + if (!assetMarketData?.rate) { + return undefined; + } + + return { + balance: Number(balance.amount) * Number(assetMarketData.rate), + conversionRate: Number(assetMarketData.rate), + }; +} diff --git a/packages/assets-controllers/src/token-prices-service/abstract-token-prices-service.ts b/packages/assets-controllers/src/token-prices-service/abstract-token-prices-service.ts new file mode 100644 index 00000000000..23376d97955 --- /dev/null +++ b/packages/assets-controllers/src/token-prices-service/abstract-token-prices-service.ts @@ -0,0 +1,121 @@ +import type { ServicePolicy } from '@metamask/controller-utils'; +import type { CaipAssetType, CaipChainId, Hex } from '@metamask/utils'; + +import type { MarketDataDetails } from '../TokenRatesController.js'; + +/** + * A map of CAIP-2 chain IDs to their native asset identifiers (CAIP-19 format). + */ +export type NativeAssetIdentifiersMap = Record; + +/** + * Represents an exchange rate. + */ +export type ExchangeRate = { + name: string; + ticker: string; + value: number; + currencyType: string; + usd?: number; +}; + +/** + * A map of currency to its exchange rate. + */ +export type ExchangeRatesByCurrency = { + [C in Currency]: ExchangeRate; +}; + +export type EvmAssetAddressWithChain = { + tokenAddress: Hex; + chainId: ChainId; +}; + +export type EvmAssetWithId = + EvmAssetAddressWithChain & { + assetId: CaipAssetType; + }; + +export type EvmAssetWithMarketData< + ChainId extends Hex = Hex, + Currency extends string = string, +> = EvmAssetAddressWithChain & + MarketDataDetails & { currency: Currency }; + +/** + * An ideal token prices service. All implementations must confirm to this + * interface. + * + * @template ChainId - A type union of valid arguments for the `chainId` + * argument to `fetchTokenPrices`. + * @template Currency - A type union of valid arguments for the `currency` + * argument to `fetchTokenPrices`. + */ +export type AbstractTokenPricesService< + ChainId extends Hex = Hex, + Currency extends string = string, +> = Partial> & { + /** + * Retrieves prices in the given currency for the tokens identified by the + * given addresses which are expected to live on the given chain. + * + * @param args - The arguments to this function. + * @param args.assets - The assets to get prices for. + * @param args.currency - The desired currency of the token prices. + * @returns The prices for the requested tokens. + */ + fetchTokenPrices({ + assets, + currency, + }: { + assets: EvmAssetAddressWithChain[]; + currency: Currency; + }): Promise[]>; + + /** + * Retrieves exchange rates in the given currency. + * + * @param args - The arguments to this function. + * @param args.baseCurrency - The desired currency of the token prices. + * @param args.includeUsdRate - Whether to include the USD rate in the response. + * @param args.cryptocurrencies - The cryptocurrencies to get exchange rates for. + * @returns The exchange rates in the requested base currency. + */ + fetchExchangeRates({ + baseCurrency, + includeUsdRate, + cryptocurrencies, + }: { + baseCurrency: Currency; + includeUsdRate: boolean; + cryptocurrencies: string[]; + }): Promise>; + + /** + * Type guard for whether the API can return token prices for the given chain + * ID. + * + * @param chainId - The chain ID to check. + * @returns True if the API supports the chain ID, false otherwise. + */ + validateChainIdSupported(chainId: unknown): chainId is ChainId; + + /** + * Type guard for whether the API can return token prices in the given + * currency. + * + * @param currency - The currency to check. + * @returns True if the API supports the currency, false otherwise. + */ + validateCurrencySupported(currency: unknown): currency is Currency; + + /** + * Sets the native asset identifiers map for resolving native token CAIP-19 IDs. + * This should be called with data from NetworkEnablementController.state.nativeAssetIdentifiers. + * + * @param nativeAssetIdentifiers - Map of CAIP-2 chain IDs to native asset identifiers. + */ + setNativeAssetIdentifiers?( + nativeAssetIdentifiers: NativeAssetIdentifiersMap, + ): void; +}; 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 new file mode 100644 index 00000000000..f81cf6879e7 --- /dev/null +++ b/packages/assets-controllers/src/token-prices-service/codefi-v2.test.ts @@ -0,0 +1,2353 @@ +import { KnownCaipNamespace } from '@metamask/utils'; +import type { Hex } from '@metamask/utils'; +import nock, { isDone } from 'nock'; + +import { + CodefiTokenPricesServiceV2, + SUPPORTED_CHAIN_IDS, + SUPPORTED_CURRENCIES, + SUPPORTED_CURRENCIES_FALLBACK, + ZERO_ADDRESS, + getNativeTokenAddress, + fetchSupportedCurrencies, + getSupportedCurrencies, + resetSupportedCurrenciesCache, + fetchSupportedNetworks, + getSupportedNetworks, + resetSupportedNetworksCache, + getAssetId, +} from './codefi-v2.js'; + +// We're not customizing the default max delay +// The default can be found here: https://github.com/connor4312/cockatiel?tab=readme-ov-file#exponentialbackoff +const defaultMaxRetryDelay = 30_000; + +describe('CodefiTokenPricesServiceV2', () => { + describe('onBreak', () => { + beforeEach(() => { + jest.useFakeTimers({ + now: Date.now(), + doNotFake: ['nextTick', 'queueMicrotask'], + }); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + it('registers a listener that is called upon break', async () => { + const retries = 3; + // Max consencutive failures is set to match number of calls in three update attempts (including retries) + const maximumConsecutiveFailures = (1 + retries) * 3; + // Initial interceptor for failing requests + nock('https://price.api.cx.metamask.io') + .get('/v3/spot-prices') + .query({ + assetIds: buildMultipleAssetIds(['0xAAA', '0xBBB', '0xCCC']), + vsCurrency: 'ETH', + includeMarketData: 'true', + }) + .times(maximumConsecutiveFailures) + .replyWithError('Failed to fetch'); + // This interceptor should not be used + nock('https://price.api.cx.metamask.io') + .get('/v3/spot-prices') + .query({ + assetIds: buildMultipleAssetIds(['0xAAA', '0xBBB', '0xCCC']), + vsCurrency: 'ETH', + includeMarketData: 'true', + }) + .reply(200, { + [buildTokenAssetId('0xAAA')]: { + price: 148.17205755299946, + currency: 'ETH', + pricePercentChange1d: 1, + priceChange1d: 1, + marketCap: 117219.99428314982, + allTimeHigh: 0.00060467892389492, + allTimeLow: 0.00002303954000865728, + totalVolume: 5155.094053542448, + high1d: 0.00008020715848194385, + low1d: 0.00007792083564549064, + circulatingSupply: 1494269733.9526057, + dilutedMarketCap: 117669.5125951733, + marketCapPercentChange1d: 0.76671, + pricePercentChange1h: -1.0736342953259423, + pricePercentChange7d: -7.351582573655089, + pricePercentChange14d: -1.0799098946709822, + pricePercentChange30d: -25.776321124365992, + pricePercentChange200d: 46.091571238599165, + pricePercentChange1y: -2.2992517267242754, + }, + [buildTokenAssetId('0xBBB')]: { + price: 33689.98134554716, + currency: 'ETH', + pricePercentChange1d: 1, + priceChange1d: 1, + marketCap: 117219.99428314982, + allTimeHigh: 0.00060467892389492, + allTimeLow: 0.00002303954000865728, + totalVolume: 5155.094053542448, + high1d: 0.00008020715848194385, + low1d: 0.00007792083564549064, + circulatingSupply: 1494269733.9526057, + dilutedMarketCap: 117669.5125951733, + marketCapPercentChange1d: 0.76671, + pricePercentChange1h: -1.0736342953259423, + pricePercentChange7d: -7.351582573655089, + pricePercentChange14d: -1.0799098946709822, + pricePercentChange30d: -25.776321124365992, + pricePercentChange200d: 46.091571238599165, + pricePercentChange1y: -2.2992517267242754, + }, + [buildTokenAssetId('0xCCC')]: { + price: 148.1344197578456, + currency: 'ETH', + pricePercentChange1d: 1, + priceChange1d: 1, + marketCap: 117219.99428314982, + allTimeHigh: 0.00060467892389492, + allTimeLow: 0.00002303954000865728, + totalVolume: 5155.094053542448, + high1d: 0.00008020715848194385, + low1d: 0.00007792083564549064, + circulatingSupply: 1494269733.9526057, + dilutedMarketCap: 117669.5125951733, + marketCapPercentChange1d: 0.76671, + pricePercentChange1h: -1.0736342953259423, + pricePercentChange7d: -7.351582573655089, + pricePercentChange14d: -1.0799098946709822, + pricePercentChange30d: -25.776321124365992, + pricePercentChange200d: 46.091571238599165, + pricePercentChange1y: -2.2992517267242754, + }, + }); + const onBreakHandler = jest.fn(); + const service = new CodefiTokenPricesServiceV2({ + retries, + maximumConsecutiveFailures, + // Ensure break duration is well over the max delay for a single request, so that the + // break doesn't end during a retry attempt + circuitBreakDuration: defaultMaxRetryDelay * 10, + }); + service.onBreak(onBreakHandler); + const fetchTokenPrices = (): ReturnType< + typeof service.fetchTokenPrices + > => + service.fetchTokenPrices({ + assets: [ + { + chainId: '0x1', + tokenAddress: '0xAAA', + }, + { + chainId: '0x1', + tokenAddress: '0xBBB', + }, + { + chainId: '0x1', + tokenAddress: '0xCCC', + }, + ], + currency: 'ETH', + }); + expect(onBreakHandler).not.toHaveBeenCalled(); + + // Initial three calls to exhaust maximum allowed failures + // eslint-disable-next-line @typescript-eslint/no-unused-vars + for (const _retryAttempt of Array(retries).keys()) { + await expect(() => + fetchTokenPricesWithFakeTimers({ + fetchTokenPrices, + retries, + }), + ).rejects.toThrow('Failed to fetch'); + } + + expect(onBreakHandler).toHaveBeenCalledTimes(1); + }); + }); + + describe('onDegraded', () => { + beforeEach(() => { + jest.useFakeTimers({ + now: Date.now(), + doNotFake: ['nextTick', 'queueMicrotask'], + }); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + it('calls onDegraded when request is slower than threshold', async () => { + const degradedThreshold = 1000; + const retries = 0; + nock('https://price.api.cx.metamask.io') + .get('/v3/spot-prices') + .query({ + assetIds: buildMultipleAssetIds(['0xAAA', '0xBBB', '0xCCC']), + vsCurrency: 'ETH', + includeMarketData: 'true', + }) + .delay(degradedThreshold * 2) + .reply(200, { + [buildTokenAssetId('0xAAA')]: { + price: 148.17205755299946, + currency: 'ETH', + pricePercentChange1d: 1, + priceChange1d: 1, + }, + [buildTokenAssetId('0xBBB')]: { + price: 33689.98134554716, + currency: 'ETH', + pricePercentChange1d: 1, + priceChange1d: 1, + }, + [buildTokenAssetId('0xCCC')]: { + price: 148.1344197578456, + currency: 'ETH', + pricePercentChange1d: 1, + priceChange1d: 1, + }, + }); + const onDegradedHandler = jest.fn(); + const service = new CodefiTokenPricesServiceV2({ + degradedThreshold, + retries, + }); + service.onDegraded(onDegradedHandler); + + await fetchTokenPricesWithFakeTimers({ + fetchTokenPrices: () => + service.fetchTokenPrices({ + assets: [ + { + chainId: '0x1', + tokenAddress: '0xAAA', + }, + { + chainId: '0x1', + tokenAddress: '0xBBB', + }, + { + chainId: '0x1', + tokenAddress: '0xCCC', + }, + ], + currency: 'ETH', + }), + retries, + }); + + expect(onDegradedHandler).toHaveBeenCalledTimes(1); + }); + }); + + describe('fetchTokenPrices', () => { + it('uses the /spot-prices endpoint of the Codefi Price API to gather prices for the given tokens', async () => { + nock('https://price.api.cx.metamask.io') + .get('/v3/spot-prices') + .query({ + assetIds: buildMultipleAssetIds(['0xAAA', '0xBBB', '0xCCC']), + vsCurrency: 'ETH', + includeMarketData: 'true', + }) + .reply(200, { + [buildTokenAssetId('0xAAA')]: { + price: 148.17205755299946, + currency: 'ETH', + pricePercentChange1d: 1, + priceChange1d: 1, + marketCap: 117219.99428314982, + allTimeHigh: 0.00060467892389492, + allTimeLow: 0.00002303954000865728, + totalVolume: 5155.094053542448, + high1d: 0.00008020715848194385, + low1d: 0.00007792083564549064, + circulatingSupply: 1494269733.9526057, + dilutedMarketCap: 117669.5125951733, + marketCapPercentChange1d: 0.76671, + pricePercentChange1h: -1.0736342953259423, + pricePercentChange7d: -7.351582573655089, + pricePercentChange14d: -1.0799098946709822, + pricePercentChange30d: -25.776321124365992, + pricePercentChange200d: 46.091571238599165, + pricePercentChange1y: -2.2992517267242754, + }, + [buildTokenAssetId('0xBBB')]: { + price: 33689.98134554716, + currency: 'ETH', + pricePercentChange1d: 1, + priceChange1d: 1, + marketCap: 117219.99428314982, + allTimeHigh: 0.00060467892389492, + allTimeLow: 0.00002303954000865728, + totalVolume: 5155.094053542448, + high1d: 0.00008020715848194385, + low1d: 0.00007792083564549064, + circulatingSupply: 1494269733.9526057, + dilutedMarketCap: 117669.5125951733, + marketCapPercentChange1d: 0.76671, + pricePercentChange1h: -1.0736342953259423, + pricePercentChange7d: -7.351582573655089, + pricePercentChange14d: -1.0799098946709822, + pricePercentChange30d: -25.776321124365992, + pricePercentChange200d: 46.091571238599165, + pricePercentChange1y: -2.2992517267242754, + }, + [buildTokenAssetId('0xCCC')]: { + price: 148.1344197578456, + currency: 'ETH', + pricePercentChange1d: 1, + priceChange1d: 1, + marketCap: 117219.99428314982, + allTimeHigh: 0.00060467892389492, + allTimeLow: 0.00002303954000865728, + totalVolume: 5155.094053542448, + high1d: 0.00008020715848194385, + low1d: 0.00007792083564549064, + circulatingSupply: 1494269733.9526057, + dilutedMarketCap: 117669.5125951733, + marketCapPercentChange1d: 0.76671, + pricePercentChange1h: -1.0736342953259423, + pricePercentChange7d: -7.351582573655089, + pricePercentChange14d: -1.0799098946709822, + pricePercentChange30d: -25.776321124365992, + pricePercentChange200d: 46.091571238599165, + pricePercentChange1y: -2.2992517267242754, + }, + }); + + const marketDataTokensByAddress = + await new CodefiTokenPricesServiceV2().fetchTokenPrices({ + assets: [ + { + chainId: '0x1', + tokenAddress: '0xAAA', + }, + { + chainId: '0x1', + tokenAddress: '0xBBB', + }, + { + chainId: '0x1', + tokenAddress: '0xCCC', + }, + ], + currency: 'ETH', + }); + + expect(marketDataTokensByAddress).toStrictEqual([ + { + tokenAddress: '0xAAA', + assetId: buildTokenAssetId('0xAAA'), + chainId: '0x1', + currency: 'ETH', + pricePercentChange1d: 1, + priceChange1d: 1, + marketCap: 117219.99428314982, + allTimeHigh: 0.00060467892389492, + allTimeLow: 0.00002303954000865728, + totalVolume: 5155.094053542448, + high1d: 0.00008020715848194385, + low1d: 0.00007792083564549064, + price: 148.17205755299946, + circulatingSupply: 1494269733.9526057, + dilutedMarketCap: 117669.5125951733, + marketCapPercentChange1d: 0.76671, + pricePercentChange1h: -1.0736342953259423, + pricePercentChange7d: -7.351582573655089, + pricePercentChange14d: -1.0799098946709822, + pricePercentChange30d: -25.776321124365992, + pricePercentChange200d: 46.091571238599165, + pricePercentChange1y: -2.2992517267242754, + }, + { + tokenAddress: '0xBBB', + assetId: buildTokenAssetId('0xBBB'), + chainId: '0x1', + currency: 'ETH', + pricePercentChange1d: 1, + priceChange1d: 1, + marketCap: 117219.99428314982, + allTimeHigh: 0.00060467892389492, + allTimeLow: 0.00002303954000865728, + totalVolume: 5155.094053542448, + high1d: 0.00008020715848194385, + low1d: 0.00007792083564549064, + price: 33689.98134554716, + circulatingSupply: 1494269733.9526057, + dilutedMarketCap: 117669.5125951733, + marketCapPercentChange1d: 0.76671, + pricePercentChange1h: -1.0736342953259423, + pricePercentChange7d: -7.351582573655089, + pricePercentChange14d: -1.0799098946709822, + pricePercentChange30d: -25.776321124365992, + pricePercentChange200d: 46.091571238599165, + pricePercentChange1y: -2.2992517267242754, + }, + { + tokenAddress: '0xCCC', + assetId: buildTokenAssetId('0xCCC'), + chainId: '0x1', + currency: 'ETH', + pricePercentChange1d: 1, + priceChange1d: 1, + marketCap: 117219.99428314982, + allTimeHigh: 0.00060467892389492, + allTimeLow: 0.00002303954000865728, + totalVolume: 5155.094053542448, + high1d: 0.00008020715848194385, + low1d: 0.00007792083564549064, + price: 148.1344197578456, + circulatingSupply: 1494269733.9526057, + dilutedMarketCap: 117669.5125951733, + marketCapPercentChange1d: 0.76671, + pricePercentChange1h: -1.0736342953259423, + pricePercentChange7d: -7.351582573655089, + pricePercentChange14d: -1.0799098946709822, + pricePercentChange30d: -25.776321124365992, + pricePercentChange200d: 46.091571238599165, + pricePercentChange1y: -2.2992517267242754, + }, + ]); + }); + + it('handles native token addresses', async () => { + nock('https://price.api.cx.metamask.io') + .get('/v3/spot-prices') + .query({ + assetIds: buildMultipleAssetIds([ZERO_ADDRESS]), + vsCurrency: 'ETH', + includeMarketData: 'true', + }) + .reply(200, { + [buildTokenAssetId(ZERO_ADDRESS)]: { + price: 33689.98134554716, + currency: 'ETH', + }, + }); + + const result = await new CodefiTokenPricesServiceV2().fetchTokenPrices({ + assets: [ + { + chainId: '0x1', + tokenAddress: ZERO_ADDRESS, + }, + ], + currency: 'ETH', + }); + + expect(result).toStrictEqual([ + { + tokenAddress: ZERO_ADDRESS, + assetId: buildTokenAssetId(ZERO_ADDRESS), + chainId: '0x1', + currency: 'ETH', + price: 33689.98134554716, + }, + ]); + }); + + it('should not include token price object for token address when token price in not included the response data', async () => { + nock('https://price.api.cx.metamask.io') + .get('/v3/spot-prices') + .query({ + assetIds: buildMultipleAssetIds(['0xAAA', '0xBBB', '0xCCC']), + vsCurrency: 'ETH', + includeMarketData: 'true', + }) + .reply(200, { + [buildTokenAssetId('0xBBB')]: { + price: 33689.98134554716, + currency: 'ETH', + pricePercentChange1d: 1, + priceChange1d: 1, + marketCap: 117219.99428314982, + allTimeHigh: 0.00060467892389492, + allTimeLow: 0.00002303954000865728, + totalVolume: 5155.094053542448, + high1d: 0.00008020715848194385, + low1d: 0.00007792083564549064, + circulatingSupply: 1494269733.9526057, + dilutedMarketCap: 117669.5125951733, + marketCapPercentChange1d: 0.76671, + pricePercentChange1h: -1.0736342953259423, + pricePercentChange7d: -7.351582573655089, + pricePercentChange14d: -1.0799098946709822, + pricePercentChange30d: -25.776321124365992, + pricePercentChange200d: 46.091571238599165, + pricePercentChange1y: -2.2992517267242754, + }, + [buildTokenAssetId('0xCCC')]: { + price: 148.1344197578456, + currency: 'ETH', + pricePercentChange1d: 1, + priceChange1d: 1, + marketCap: 117219.99428314982, + allTimeHigh: 0.00060467892389492, + allTimeLow: 0.00002303954000865728, + totalVolume: 5155.094053542448, + high1d: 0.00008020715848194385, + low1d: 0.00007792083564549064, + circulatingSupply: 1494269733.9526057, + dilutedMarketCap: 117669.5125951733, + marketCapPercentChange1d: 0.76671, + pricePercentChange1h: -1.0736342953259423, + pricePercentChange7d: -7.351582573655089, + pricePercentChange14d: -1.0799098946709822, + pricePercentChange30d: -25.776321124365992, + pricePercentChange200d: 46.091571238599165, + pricePercentChange1y: -2.2992517267242754, + }, + }); + + const result = await new CodefiTokenPricesServiceV2().fetchTokenPrices({ + assets: [ + { + chainId: '0x1', + tokenAddress: '0xAAA', + }, + { + chainId: '0x1', + tokenAddress: '0xBBB', + }, + { + chainId: '0x1', + tokenAddress: '0xCCC', + }, + ], + currency: 'ETH', + }); + expect(result).toStrictEqual([ + { + tokenAddress: '0xBBB', + assetId: buildTokenAssetId('0xBBB'), + chainId: '0x1', + currency: 'ETH', + pricePercentChange1d: 1, + priceChange1d: 1, + marketCap: 117219.99428314982, + allTimeHigh: 0.00060467892389492, + allTimeLow: 0.00002303954000865728, + totalVolume: 5155.094053542448, + high1d: 0.00008020715848194385, + low1d: 0.00007792083564549064, + price: 33689.98134554716, + circulatingSupply: 1494269733.9526057, + dilutedMarketCap: 117669.5125951733, + marketCapPercentChange1d: 0.76671, + pricePercentChange1h: -1.0736342953259423, + pricePercentChange7d: -7.351582573655089, + pricePercentChange14d: -1.0799098946709822, + pricePercentChange30d: -25.776321124365992, + pricePercentChange200d: 46.091571238599165, + pricePercentChange1y: -2.2992517267242754, + }, + { + tokenAddress: '0xCCC', + assetId: buildTokenAssetId('0xCCC'), + chainId: '0x1', + currency: 'ETH', + pricePercentChange1d: 1, + priceChange1d: 1, + marketCap: 117219.99428314982, + allTimeHigh: 0.00060467892389492, + allTimeLow: 0.00002303954000865728, + totalVolume: 5155.094053542448, + high1d: 0.00008020715848194385, + low1d: 0.00007792083564549064, + price: 148.1344197578456, + circulatingSupply: 1494269733.9526057, + dilutedMarketCap: 117669.5125951733, + marketCapPercentChange1d: 0.76671, + pricePercentChange1h: -1.0736342953259423, + pricePercentChange7d: -7.351582573655089, + pricePercentChange14d: -1.0799098946709822, + pricePercentChange30d: -25.776321124365992, + pricePercentChange200d: 46.091571238599165, + pricePercentChange1y: -2.2992517267242754, + }, + ]); + }); + + it('should not include token price object for token address when price is undefined for token response data', async () => { + nock('https://price.api.cx.metamask.io') + .get('/v3/spot-prices') + .query({ + assetIds: buildMultipleAssetIds(['0xAAA', '0xBBB', '0xCCC']), + vsCurrency: 'ETH', + includeMarketData: 'true', + }) + .reply(200, { + [buildTokenAssetId('0xAAA')]: {}, + [buildTokenAssetId('0xBBB')]: { + price: 33689.98134554716, + pricePercentChange1d: 1, + priceChange1d: 1, + marketCap: 117219.99428314982, + allTimeHigh: 0.00060467892389492, + allTimeLow: 0.00002303954000865728, + totalVolume: 5155.094053542448, + high1d: 0.00008020715848194385, + low1d: 0.00007792083564549064, + circulatingSupply: 1494269733.9526057, + dilutedMarketCap: 117669.5125951733, + marketCapPercentChange1d: 0.76671, + pricePercentChange1h: -1.0736342953259423, + pricePercentChange7d: -7.351582573655089, + pricePercentChange14d: -1.0799098946709822, + pricePercentChange30d: -25.776321124365992, + pricePercentChange200d: 46.091571238599165, + pricePercentChange1y: -2.2992517267242754, + }, + [buildTokenAssetId('0xCCC')]: { + price: 148.1344197578456, + pricePercentChange1d: 1, + priceChange1d: 1, + marketCap: 117219.99428314982, + allTimeHigh: 0.00060467892389492, + allTimeLow: 0.00002303954000865728, + totalVolume: 5155.094053542448, + high1d: 0.00008020715848194385, + low1d: 0.00007792083564549064, + circulatingSupply: 1494269733.9526057, + dilutedMarketCap: 117669.5125951733, + marketCapPercentChange1d: 0.76671, + pricePercentChange1h: -1.0736342953259423, + pricePercentChange7d: -7.351582573655089, + pricePercentChange14d: -1.0799098946709822, + pricePercentChange30d: -25.776321124365992, + pricePercentChange200d: 46.091571238599165, + pricePercentChange1y: -2.2992517267242754, + }, + }); + + const result = await new CodefiTokenPricesServiceV2().fetchTokenPrices({ + assets: [ + { + chainId: '0x1', + tokenAddress: '0xAAA', + }, + { + chainId: '0x1', + tokenAddress: '0xBBB', + }, + { + chainId: '0x1', + tokenAddress: '0xCCC', + }, + ], + currency: 'ETH', + }); + + expect(result).toStrictEqual([ + { + tokenAddress: '0xAAA', + assetId: buildTokenAssetId('0xAAA'), + chainId: '0x1', + currency: 'ETH', + }, + { + tokenAddress: '0xBBB', + assetId: buildTokenAssetId('0xBBB'), + chainId: '0x1', + currency: 'ETH', + pricePercentChange1d: 1, + priceChange1d: 1, + marketCap: 117219.99428314982, + allTimeHigh: 0.00060467892389492, + allTimeLow: 0.00002303954000865728, + totalVolume: 5155.094053542448, + high1d: 0.00008020715848194385, + low1d: 0.00007792083564549064, + price: 33689.98134554716, + circulatingSupply: 1494269733.9526057, + dilutedMarketCap: 117669.5125951733, + marketCapPercentChange1d: 0.76671, + pricePercentChange1h: -1.0736342953259423, + pricePercentChange7d: -7.351582573655089, + pricePercentChange14d: -1.0799098946709822, + pricePercentChange30d: -25.776321124365992, + pricePercentChange200d: 46.091571238599165, + pricePercentChange1y: -2.2992517267242754, + }, + { + tokenAddress: '0xCCC', + assetId: buildTokenAssetId('0xCCC'), + chainId: '0x1', + currency: 'ETH', + pricePercentChange1d: 1, + priceChange1d: 1, + marketCap: 117219.99428314982, + allTimeHigh: 0.00060467892389492, + allTimeLow: 0.00002303954000865728, + totalVolume: 5155.094053542448, + high1d: 0.00008020715848194385, + low1d: 0.00007792083564549064, + price: 148.1344197578456, + circulatingSupply: 1494269733.9526057, + dilutedMarketCap: 117669.5125951733, + marketCapPercentChange1d: 0.76671, + pricePercentChange1h: -1.0736342953259423, + pricePercentChange7d: -7.351582573655089, + pricePercentChange14d: -1.0799098946709822, + pricePercentChange30d: -25.776321124365992, + pricePercentChange200d: 46.091571238599165, + pricePercentChange1y: -2.2992517267242754, + }, + ]); + }); + + it('should correctly handle null market data for a token address', async () => { + nock('https://price.api.cx.metamask.io') + .get('/v3/spot-prices') + .query({ + assetIds: buildMultipleAssetIds(['0xAAA', '0xBBB', '0xCCC']), + vsCurrency: 'ETH', + includeMarketData: 'true', + }) + .reply(200, { + [buildTokenAssetId('0xAAA')]: null, // Simulating API returning null for market data + [buildTokenAssetId('0xBBB')]: { + price: 33689.98134554716, + currency: 'ETH', + }, + [buildTokenAssetId('0xCCC')]: { + price: 148.1344197578456, + currency: 'ETH', + }, + }); + + const result = await new CodefiTokenPricesServiceV2().fetchTokenPrices({ + assets: [ + { + chainId: '0x1', + tokenAddress: '0xAAA', + }, + { + chainId: '0x1', + tokenAddress: '0xBBB', + }, + { + chainId: '0x1', + tokenAddress: '0xCCC', + }, + ], + currency: 'ETH', + }); + + expect(result).toStrictEqual([ + { + tokenAddress: '0xBBB', + assetId: buildTokenAssetId('0xBBB'), + chainId: '0x1', + currency: 'ETH', + price: 33689.98134554716, + }, + { + tokenAddress: '0xCCC', + assetId: buildTokenAssetId('0xCCC'), + chainId: '0x1', + currency: 'ETH', + price: 148.1344197578456, + }, + ]); + }); + + it('throws if the request fails consistently', async () => { + nock('https://price.api.cx.metamask.io') + .get('/v3/spot-prices') + .query({ + assetIds: buildMultipleAssetIds(['0xAAA', '0xBBB', '0xCCC']), + vsCurrency: 'ETH', + includeMarketData: 'true', + }) + .replyWithError('Failed to fetch') + .persist(); + + await expect( + new CodefiTokenPricesServiceV2().fetchTokenPrices({ + assets: [ + { + chainId: '0x1', + tokenAddress: '0xAAA', + }, + { + chainId: '0x1', + tokenAddress: '0xBBB', + }, + { + chainId: '0x1', + tokenAddress: '0xCCC', + }, + ], + currency: 'ETH', + }), + ).rejects.toThrow('Failed to fetch'); + }); + + it('throws if the initial request and all retries fail', async () => { + const retries = 3; + nock('https://price.api.cx.metamask.io') + .get('/v3/spot-prices') + .query({ + assetIds: buildMultipleAssetIds(['0xAAA', '0xBBB', '0xCCC']), + vsCurrency: 'ETH', + includeMarketData: 'true', + }) + .times(1 + retries) + .replyWithError('Failed to fetch'); + + await expect( + new CodefiTokenPricesServiceV2({ retries }).fetchTokenPrices({ + assets: [ + { + chainId: '0x1', + tokenAddress: '0xAAA', + }, + { + chainId: '0x1', + tokenAddress: '0xBBB', + }, + { + chainId: '0x1', + tokenAddress: '0xCCC', + }, + ], + currency: 'ETH', + }), + ).rejects.toThrow('Failed to fetch'); + }); + + it('succeeds if the last retry succeeds', async () => { + const retries = 3; + // Initial interceptor for failing requests + nock('https://price.api.cx.metamask.io') + .get('/v3/spot-prices') + .query({ + assetIds: buildMultipleAssetIds(['0xAAA', '0xBBB', '0xCCC']), + vsCurrency: 'ETH', + includeMarketData: 'true', + }) + .times(retries) + .replyWithError('Failed to fetch'); + // Interceptor for successful request + nock('https://price.api.cx.metamask.io') + .get('/v3/spot-prices') + .query({ + assetIds: buildMultipleAssetIds(['0xAAA', '0xBBB', '0xCCC']), + vsCurrency: 'ETH', + includeMarketData: 'true', + }) + .reply(200, { + [buildTokenAssetId('0xAAA')]: { + price: 148.17205755299946, + currency: 'ETH', + pricePercentChange1d: 1, + priceChange1d: 1, + marketCap: 117219.99428314982, + allTimeHigh: 0.00060467892389492, + allTimeLow: 0.00002303954000865728, + totalVolume: 5155.094053542448, + high1d: 0.00008020715848194385, + low1d: 0.00007792083564549064, + circulatingSupply: 1494269733.9526057, + dilutedMarketCap: 117669.5125951733, + marketCapPercentChange1d: 0.76671, + pricePercentChange1h: -1.0736342953259423, + pricePercentChange7d: -7.351582573655089, + pricePercentChange14d: -1.0799098946709822, + pricePercentChange30d: -25.776321124365992, + pricePercentChange200d: 46.091571238599165, + pricePercentChange1y: -2.2992517267242754, + }, + [buildTokenAssetId('0xBBB')]: { + price: 33689.98134554716, + currency: 'ETH', + pricePercentChange1d: 1, + priceChange1d: 1, + marketCap: 117219.99428314982, + allTimeHigh: 0.00060467892389492, + allTimeLow: 0.00002303954000865728, + totalVolume: 5155.094053542448, + high1d: 0.00008020715848194385, + low1d: 0.00007792083564549064, + circulatingSupply: 1494269733.9526057, + dilutedMarketCap: 117669.5125951733, + marketCapPercentChange1d: 0.76671, + pricePercentChange1h: -1.0736342953259423, + pricePercentChange7d: -7.351582573655089, + pricePercentChange14d: -1.0799098946709822, + pricePercentChange30d: -25.776321124365992, + pricePercentChange200d: 46.091571238599165, + pricePercentChange1y: -2.2992517267242754, + }, + [buildTokenAssetId('0xCCC')]: { + price: 148.1344197578456, + currency: 'ETH', + pricePercentChange1d: 1, + priceChange1d: 1, + marketCap: 117219.99428314982, + allTimeHigh: 0.00060467892389492, + allTimeLow: 0.00002303954000865728, + totalVolume: 5155.094053542448, + high1d: 0.00008020715848194385, + low1d: 0.00007792083564549064, + circulatingSupply: 1494269733.9526057, + dilutedMarketCap: 117669.5125951733, + marketCapPercentChange1d: 0.76671, + pricePercentChange1h: -1.0736342953259423, + pricePercentChange7d: -7.351582573655089, + pricePercentChange14d: -1.0799098946709822, + pricePercentChange30d: -25.776321124365992, + pricePercentChange200d: 46.091571238599165, + pricePercentChange1y: -2.2992517267242754, + }, + }); + + const marketDataTokensByAddress = await new CodefiTokenPricesServiceV2({ + retries, + }).fetchTokenPrices({ + assets: [ + { + chainId: '0x1', + tokenAddress: '0xAAA', + }, + { + chainId: '0x1', + tokenAddress: '0xBBB', + }, + { + chainId: '0x1', + tokenAddress: '0xCCC', + }, + ], + currency: 'ETH', + }); + + expect(marketDataTokensByAddress).toStrictEqual([ + { + tokenAddress: '0xAAA', + assetId: buildTokenAssetId('0xAAA'), + chainId: '0x1', + currency: 'ETH', + pricePercentChange1d: 1, + priceChange1d: 1, + marketCap: 117219.99428314982, + allTimeHigh: 0.00060467892389492, + allTimeLow: 0.00002303954000865728, + totalVolume: 5155.094053542448, + high1d: 0.00008020715848194385, + low1d: 0.00007792083564549064, + price: 148.17205755299946, + circulatingSupply: 1494269733.9526057, + dilutedMarketCap: 117669.5125951733, + marketCapPercentChange1d: 0.76671, + pricePercentChange1h: -1.0736342953259423, + pricePercentChange7d: -7.351582573655089, + pricePercentChange14d: -1.0799098946709822, + pricePercentChange30d: -25.776321124365992, + pricePercentChange200d: 46.091571238599165, + pricePercentChange1y: -2.2992517267242754, + }, + { + tokenAddress: '0xBBB', + assetId: buildTokenAssetId('0xBBB'), + chainId: '0x1', + currency: 'ETH', + pricePercentChange1d: 1, + priceChange1d: 1, + marketCap: 117219.99428314982, + allTimeHigh: 0.00060467892389492, + allTimeLow: 0.00002303954000865728, + totalVolume: 5155.094053542448, + high1d: 0.00008020715848194385, + low1d: 0.00007792083564549064, + price: 33689.98134554716, + circulatingSupply: 1494269733.9526057, + dilutedMarketCap: 117669.5125951733, + marketCapPercentChange1d: 0.76671, + pricePercentChange1h: -1.0736342953259423, + pricePercentChange7d: -7.351582573655089, + pricePercentChange14d: -1.0799098946709822, + pricePercentChange30d: -25.776321124365992, + pricePercentChange200d: 46.091571238599165, + pricePercentChange1y: -2.2992517267242754, + }, + { + tokenAddress: '0xCCC', + assetId: buildTokenAssetId('0xCCC'), + chainId: '0x1', + currency: 'ETH', + pricePercentChange1d: 1, + priceChange1d: 1, + marketCap: 117219.99428314982, + allTimeHigh: 0.00060467892389492, + allTimeLow: 0.00002303954000865728, + totalVolume: 5155.094053542448, + high1d: 0.00008020715848194385, + low1d: 0.00007792083564549064, + price: 148.1344197578456, + circulatingSupply: 1494269733.9526057, + dilutedMarketCap: 117669.5125951733, + marketCapPercentChange1d: 0.76671, + pricePercentChange1h: -1.0736342953259423, + pricePercentChange7d: -7.351582573655089, + pricePercentChange14d: -1.0799098946709822, + pricePercentChange30d: -25.776321124365992, + pricePercentChange200d: 46.091571238599165, + pricePercentChange1y: -2.2992517267242754, + }, + ]); + }); + + it('returns an empty array when all assets have unsupported chain IDs', async () => { + // Use a chain ID that is not in SUPPORTED_CHAIN_IDS_V3 to trigger early return + const unsupportedAssets = [ + { chainId: '0x999999999999999' as Hex, tokenAddress: '0xAAA' as Hex }, + { chainId: '0x999999999999999' as Hex, tokenAddress: '0xBBB' as Hex }, + ]; + + const result = await new CodefiTokenPricesServiceV2().fetchTokenPrices({ + // @ts-expect-error Testing with unsupported chain ID + assets: unsupportedAssets, + currency: 'ETH', + }); + + expect(result).toStrictEqual([]); + }); + + describe('before circuit break', () => { + beforeEach(() => { + jest.useFakeTimers({ + now: Date.now(), + doNotFake: ['nextTick', 'queueMicrotask'], + }); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + it('calls onDegraded when request is slower than threshold', async () => { + const degradedThreshold = 1000; + const retries = 0; + nock('https://price.api.cx.metamask.io') + .get('/v3/spot-prices') + .query({ + assetIds: buildMultipleAssetIds(['0xAAA', '0xBBB', '0xCCC']), + vsCurrency: 'ETH', + includeMarketData: 'true', + }) + .delay(degradedThreshold * 2) + .reply(200, { + [buildTokenAssetId('0xAAA')]: { + price: 148.17205755299946, + currency: 'ETH', + pricePercentChange1d: 1, + priceChange1d: 1, + }, + [buildTokenAssetId('0xBBB')]: { + price: 33689.98134554716, + currency: 'ETH', + pricePercentChange1d: 1, + priceChange1d: 1, + }, + [buildTokenAssetId('0xCCC')]: { + price: 148.1344197578456, + currency: 'ETH', + pricePercentChange1d: 1, + priceChange1d: 1, + }, + }); + const onDegradedHandler = jest.fn(); + const service = new CodefiTokenPricesServiceV2({ + degradedThreshold, + onDegraded: onDegradedHandler, + retries, + }); + + await fetchTokenPricesWithFakeTimers({ + fetchTokenPrices: () => + service.fetchTokenPrices({ + assets: [ + { + chainId: '0x1', + tokenAddress: '0xAAA', + }, + { + chainId: '0x1', + tokenAddress: '0xBBB', + }, + { + chainId: '0x1', + tokenAddress: '0xCCC', + }, + ], + currency: 'ETH', + }), + retries, + }); + + expect(onDegradedHandler).toHaveBeenCalledTimes(1); + }); + }); + + describe('after circuit break', () => { + beforeEach(() => { + jest.useFakeTimers({ + now: Date.now(), + doNotFake: ['nextTick', 'queueMicrotask'], + }); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + it('calls onBreak handler upon break', async () => { + const retries = 3; + // Max consencutive failures is set to match number of calls in three update attempts (including retries) + const maximumConsecutiveFailures = (1 + retries) * 3; + // Initial interceptor for failing requests + nock('https://price.api.cx.metamask.io') + .get('/v3/spot-prices') + .query({ + assetIds: buildMultipleAssetIds(['0xAAA', '0xBBB', '0xCCC']), + vsCurrency: 'ETH', + includeMarketData: 'true', + }) + .times(maximumConsecutiveFailures) + .replyWithError('Failed to fetch'); + // This interceptor should not be used + nock('https://price.api.cx.metamask.io') + .get('/v3/spot-prices') + .query({ + assetIds: buildMultipleAssetIds(['0xAAA', '0xBBB', '0xCCC']), + vsCurrency: 'ETH', + includeMarketData: 'true', + }) + .reply(200, { + [buildTokenAssetId('0xAAA')]: { + price: 148.17205755299946, + currency: 'ETH', + pricePercentChange1d: 1, + priceChange1d: 1, + marketCap: 117219.99428314982, + allTimeHigh: 0.00060467892389492, + allTimeLow: 0.00002303954000865728, + totalVolume: 5155.094053542448, + high1d: 0.00008020715848194385, + low1d: 0.00007792083564549064, + circulatingSupply: 1494269733.9526057, + dilutedMarketCap: 117669.5125951733, + marketCapPercentChange1d: 0.76671, + pricePercentChange1h: -1.0736342953259423, + pricePercentChange7d: -7.351582573655089, + pricePercentChange14d: -1.0799098946709822, + pricePercentChange30d: -25.776321124365992, + pricePercentChange200d: 46.091571238599165, + pricePercentChange1y: -2.2992517267242754, + }, + [buildTokenAssetId('0xBBB')]: { + price: 33689.98134554716, + currency: 'ETH', + pricePercentChange1d: 1, + priceChange1d: 1, + marketCap: 117219.99428314982, + allTimeHigh: 0.00060467892389492, + allTimeLow: 0.00002303954000865728, + totalVolume: 5155.094053542448, + high1d: 0.00008020715848194385, + low1d: 0.00007792083564549064, + circulatingSupply: 1494269733.9526057, + dilutedMarketCap: 117669.5125951733, + marketCapPercentChange1d: 0.76671, + pricePercentChange1h: -1.0736342953259423, + pricePercentChange7d: -7.351582573655089, + pricePercentChange14d: -1.0799098946709822, + pricePercentChange30d: -25.776321124365992, + pricePercentChange200d: 46.091571238599165, + pricePercentChange1y: -2.2992517267242754, + }, + [buildTokenAssetId('0xCCC')]: { + price: 148.1344197578456, + currency: 'ETH', + pricePercentChange1d: 1, + priceChange1d: 1, + marketCap: 117219.99428314982, + allTimeHigh: 0.00060467892389492, + allTimeLow: 0.00002303954000865728, + totalVolume: 5155.094053542448, + high1d: 0.00008020715848194385, + low1d: 0.00007792083564549064, + circulatingSupply: 1494269733.9526057, + dilutedMarketCap: 117669.5125951733, + marketCapPercentChange1d: 0.76671, + pricePercentChange1h: -1.0736342953259423, + pricePercentChange7d: -7.351582573655089, + pricePercentChange14d: -1.0799098946709822, + pricePercentChange30d: -25.776321124365992, + pricePercentChange200d: 46.091571238599165, + pricePercentChange1y: -2.2992517267242754, + }, + }); + const onBreakHandler = jest.fn(); + const service = new CodefiTokenPricesServiceV2({ + retries, + maximumConsecutiveFailures, + onBreak: onBreakHandler, + // Ensure break duration is well over the max delay for a single request, so that the + // break doesn't end during a retry attempt + circuitBreakDuration: defaultMaxRetryDelay * 10, + }); + const fetchTokenPrices = (): ReturnType< + typeof service.fetchTokenPrices + > => + service.fetchTokenPrices({ + assets: [ + { + chainId: '0x1', + tokenAddress: '0xAAA', + }, + { + chainId: '0x1', + tokenAddress: '0xBBB', + }, + { + chainId: '0x1', + tokenAddress: '0xCCC', + }, + ], + currency: 'ETH', + }); + expect(onBreakHandler).not.toHaveBeenCalled(); + + // Initial three calls to exhaust maximum allowed failures + // eslint-disable-next-line @typescript-eslint/no-unused-vars + for (const _retryAttempt of Array(retries).keys()) { + await expect(() => + fetchTokenPricesWithFakeTimers({ + fetchTokenPrices, + retries, + }), + ).rejects.toThrow('Failed to fetch'); + } + + expect(onBreakHandler).toHaveBeenCalledTimes(1); + }); + }); + }); + + describe('fetchExchangeRates', () => { + const exchangeRatesMockResponseUsd = { + btc: { + name: 'Bitcoin', + ticker: 'btc', + value: 0.000008880690393396647, + currencyType: 'crypto', + }, + eth: { + name: 'Ether', + ticker: 'eth', + value: 0.000240977533824818, + currencyType: 'crypto', + }, + ltc: { + name: 'Litecoin', + ticker: 'ltc', + value: 0.01021289164000047, + currencyType: 'crypto', + }, + }; + + const exchangeRatesMockResponseEur = { + btc: { + name: 'Bitcoin', + ticker: 'btc', + value: 0.000010377048177666853, + currencyType: 'crypto', + }, + eth: { + name: 'Ether', + ticker: 'eth', + value: 0.0002845697921761581, + currencyType: 'crypto', + }, + ltc: { + name: 'Litecoin', + ticker: 'ltc', + value: 0.011983861448641322, + currencyType: 'crypto', + }, + }; + + const cryptocurrencies = ['ETH']; + + describe('when includeUsdRate is true and baseCurrency is not USD', () => { + it('throws when all calls to price fail', async () => { + nock('https://price.api.cx.metamask.io') + .get('/v1/exchange-rates') + .query({ + baseCurrency: 'eur', + }) + .replyWithError('Failed to fetch'); + + nock('https://price.api.cx.metamask.io') + .get('/v1/exchange-rates') + .query({ + baseCurrency: 'usd', + }) + .replyWithError('Failed to fetch'); + await expect(() => + new CodefiTokenPricesServiceV2().fetchExchangeRates({ + baseCurrency: 'eur', + includeUsdRate: true, + cryptocurrencies: ['btc', 'eth'], + }), + ).rejects.toThrow('Failed to fetch'); + }); + it('throws an error if none of the cryptocurrencies are supported', async () => { + nock('https://price.api.cx.metamask.io') + .get('/v1/exchange-rates') + .query({ + baseCurrency: 'eur', + }) + .reply(200, exchangeRatesMockResponseEur); + + nock('https://price.api.cx.metamask.io') + .get('/v1/exchange-rates') + .query({ + baseCurrency: 'usd', + }) + .reply(200, exchangeRatesMockResponseUsd); + + await expect( + new CodefiTokenPricesServiceV2().fetchExchangeRates({ + baseCurrency: 'eur', + includeUsdRate: true, + cryptocurrencies: ['not-supported'], + }), + ).rejects.toThrow( + 'None of the cryptocurrencies are supported by price api', + ); + }); + + it('returns result when some of the cryptocurrencies are supported', async () => { + nock('https://price.api.cx.metamask.io') + .get('/v1/exchange-rates') + .query({ + baseCurrency: 'eur', + }) + .reply(200, exchangeRatesMockResponseEur); + + nock('https://price.api.cx.metamask.io') + .get('/v1/exchange-rates') + .query({ + baseCurrency: 'usd', + }) + .reply(200, exchangeRatesMockResponseUsd); + + const result = + await new CodefiTokenPricesServiceV2().fetchExchangeRates({ + baseCurrency: 'eur', + includeUsdRate: true, + cryptocurrencies: ['not-supported', 'eth'], + }); + + expect(result).toStrictEqual({ + eth: { + ...exchangeRatesMockResponseEur.eth, + usd: 0.000240977533824818, + }, + }); + }); + + it('returns successfully usd values when all the cryptocurrencies are supported', async () => { + nock('https://price.api.cx.metamask.io') + .get('/v1/exchange-rates') + .query({ + baseCurrency: 'eur', + }) + .reply(200, exchangeRatesMockResponseEur); + + nock('https://price.api.cx.metamask.io') + .get('/v1/exchange-rates') + .query({ + baseCurrency: 'usd', + }) + .reply(200, exchangeRatesMockResponseUsd); + + const result = + await new CodefiTokenPricesServiceV2().fetchExchangeRates({ + baseCurrency: 'eur', + includeUsdRate: true, + cryptocurrencies: ['btc', 'eth'], + }); + + expect(result).toStrictEqual({ + eth: { + ...exchangeRatesMockResponseEur.eth, + usd: 0.000240977533824818, + }, + btc: { + ...exchangeRatesMockResponseEur.btc, + usd: 0.000008880690393396647, + }, + }); + }); + + it('does not return usd values when one call to price fails', async () => { + nock('https://price.api.cx.metamask.io') + .get('/v1/exchange-rates') + .query({ + baseCurrency: 'eur', + }) + .reply(200, exchangeRatesMockResponseEur); + + nock('https://price.api.cx.metamask.io') + .get('/v1/exchange-rates') + .query({ + baseCurrency: 'usd', + }) + .replyWithError('Failed to fetch'); + + const result = + await new CodefiTokenPricesServiceV2().fetchExchangeRates({ + baseCurrency: 'eur', + includeUsdRate: true, + cryptocurrencies: ['btc', 'eth'], + }); + + expect(result).toStrictEqual({ + eth: { + ...exchangeRatesMockResponseEur.eth, + }, + btc: { + ...exchangeRatesMockResponseEur.btc, + }, + }); + }); + }); + + describe('when includeUsdRate is true and baseCurrency is equal to USD', () => { + it('throws when the call to price fails', async () => { + nock('https://price.api.cx.metamask.io') + .get('/v1/exchange-rates') + .query({ + baseCurrency: 'usd', + }) + .replyWithError('Failed to fetch') + .persist(); + + await expect(() => + new CodefiTokenPricesServiceV2().fetchExchangeRates({ + baseCurrency: 'usd', + includeUsdRate: true, + cryptocurrencies: ['btc', 'eth'], + }), + ).rejects.toThrow('Failed to fetch'); + }); + + it('returns successfully usd values when all the cryptocurrencies are supported', async () => { + nock('https://price.api.cx.metamask.io') + .get('/v1/exchange-rates') + .query({ + baseCurrency: 'usd', + }) + .reply(200, exchangeRatesMockResponseUsd); + + const result = + await new CodefiTokenPricesServiceV2().fetchExchangeRates({ + baseCurrency: 'usd', + includeUsdRate: true, + cryptocurrencies: ['btc', 'eth'], + }); + + expect(result).toStrictEqual({ + eth: { + ...exchangeRatesMockResponseUsd.eth, + usd: exchangeRatesMockResponseUsd.eth.value, + }, + btc: { + ...exchangeRatesMockResponseUsd.btc, + usd: exchangeRatesMockResponseUsd.btc.value, + }, + }); + }); + + it('returns successfully usd values when some of the cryptocurrencies are supported', async () => { + nock('https://price.api.cx.metamask.io') + .get('/v1/exchange-rates') + .query({ + baseCurrency: 'usd', + }) + .reply(200, exchangeRatesMockResponseUsd); + + const result = + await new CodefiTokenPricesServiceV2().fetchExchangeRates({ + baseCurrency: 'usd', + includeUsdRate: true, + cryptocurrencies: ['not-supported', 'eth'], + }); + + expect(result).toStrictEqual({ + eth: { + ...exchangeRatesMockResponseUsd.eth, + usd: exchangeRatesMockResponseUsd.eth.value, + }, + }); + }); + }); + + describe('when includeUsdRate is false and baseCurrency is not USD', () => { + it('does not include usd in the returned result', async () => { + nock('https://price.api.cx.metamask.io') + .get('/v1/exchange-rates') + .query({ + baseCurrency: 'eur', + }) + .reply(200, exchangeRatesMockResponseEur); + + const result = + await new CodefiTokenPricesServiceV2().fetchExchangeRates({ + baseCurrency: 'eur', + includeUsdRate: false, + cryptocurrencies: ['eth'], + }); + + expect(result).toStrictEqual({ + eth: exchangeRatesMockResponseEur.eth, + }); + }); + }); + + describe('when includeUsdRate is false and baseCurrency is USD', () => { + it('includes usd in the returned result', async () => { + nock('https://price.api.cx.metamask.io') + .get('/v1/exchange-rates') + .query({ + baseCurrency: 'usd', + }) + .reply(200, exchangeRatesMockResponseUsd); + + const result = + await new CodefiTokenPricesServiceV2().fetchExchangeRates({ + baseCurrency: 'usd', + includeUsdRate: false, + cryptocurrencies: ['eth'], + }); + + expect(result).toStrictEqual({ + eth: { + ...exchangeRatesMockResponseUsd.eth, + usd: exchangeRatesMockResponseUsd.eth.value, + }, + }); + }); + }); + + it('throws if the request fails consistently', async () => { + nock('https://price.api.cx.metamask.io') + .get('/v1/exchange-rates') + .query({ + baseCurrency: 'eur', + }) + .replyWithError('Failed to fetch'); + + await expect( + new CodefiTokenPricesServiceV2().fetchExchangeRates({ + baseCurrency: 'eur', + includeUsdRate: false, + cryptocurrencies, + }), + ).rejects.toThrow('Failed to fetch'); + }); + + it('throws if the initial request and all retries fail', async () => { + const retries = 3; + nock('https://price.api.cx.metamask.io') + .get('/v1/exchange-rates') + .query({ + baseCurrency: 'eur', + }) + .times(1 + retries) + .replyWithError('Failed to fetch'); + + await expect( + new CodefiTokenPricesServiceV2({ retries }).fetchExchangeRates({ + baseCurrency: 'eur', + includeUsdRate: false, + cryptocurrencies, + }), + ).rejects.toThrow('Failed to fetch'); + }); + + it('succeeds if the last retry succeeds', async () => { + const retries = 3; + // Initial interceptor for failing requests + nock('https://price.api.cx.metamask.io') + .get('/v1/exchange-rates') + .query({ + baseCurrency: 'eur', + }) + .times(retries) + .replyWithError('Failed to fetch'); + // Interceptor for successful request + nock('https://price.api.cx.metamask.io') + .get('/v1/exchange-rates') + .query({ + baseCurrency: 'eur', + }) + .reply(200, exchangeRatesMockResponseEur); + + const exchangeRates = await new CodefiTokenPricesServiceV2({ + retries, + }).fetchExchangeRates({ + baseCurrency: 'eur', + includeUsdRate: false, + cryptocurrencies, + }); + + expect(exchangeRates).toStrictEqual({ + eth: exchangeRatesMockResponseEur.eth, + }); + }); + + it('triggers background refresh of supported currencies', async () => { + // Mock supportedVsCurrencies endpoint + const supportedCurrenciesNock = nock('https://price.api.cx.metamask.io') + .get('/v1/supportedVsCurrencies') + .reply(200, ['usd', 'eur', 'gbp']); + + // Mock exchange-rates to succeed + nock('https://price.api.cx.metamask.io') + .get('/v1/exchange-rates') + .query({ + baseCurrency: 'eur', + }) + .reply(200, exchangeRatesMockResponseEur); + + const result = await new CodefiTokenPricesServiceV2().fetchExchangeRates({ + baseCurrency: 'eur', + includeUsdRate: false, + cryptocurrencies: ['eth'], + }); + + // Wait for background fetch to complete + await new Promise((resolve) => setTimeout(resolve, 0)); + + expect(result).toStrictEqual({ + eth: exchangeRatesMockResponseEur.eth, + }); + + // Verify the supportedVsCurrencies endpoint was called + expect(supportedCurrenciesNock.isDone()).toBe(true); + + // Reset cache so other tests use fallback list + resetSupportedCurrenciesCache(); + }); + + describe('before circuit break', () => { + beforeEach(() => { + jest.useFakeTimers({ + now: Date.now(), + doNotFake: ['nextTick', 'queueMicrotask'], + }); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + it('calls onDegraded when request is slower than threshold', async () => { + const degradedThreshold = 1000; + const retries = 0; + nock('https://price.api.cx.metamask.io') + .get('/v1/exchange-rates') + .query({ + baseCurrency: 'eur', + }) + .delay(degradedThreshold * 2) + .reply(200, exchangeRatesMockResponseEur); + const onDegradedHandler = jest.fn(); + const service = new CodefiTokenPricesServiceV2({ + degradedThreshold, + onDegraded: onDegradedHandler, + retries, + }); + + await fetchExchangeRatesWithFakeTimers({ + fetchExchangeRates: () => + service.fetchExchangeRates({ + baseCurrency: 'eur', + includeUsdRate: false, + cryptocurrencies, + }), + retries, + }); + + expect(onDegradedHandler).toHaveBeenCalledTimes(1); + }); + }); + + describe('after circuit break', () => { + beforeEach(() => { + jest.useFakeTimers({ + now: Date.now(), + doNotFake: ['nextTick', 'queueMicrotask'], + }); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + it('calls onBreak handler upon break', async () => { + const retries = 3; + // Max consencutive failures is set to match number of calls in three update attempts (including retries) + const maximumConsecutiveFailures = (1 + retries) * 3; + // Initial interceptor for failing requests + nock('https://price.api.cx.metamask.io') + .get('/v1/exchange-rates') + .query({ + baseCurrency: 'eur', + }) + .times(maximumConsecutiveFailures) + .replyWithError('Failed to fetch'); + // This interceptor should not be used + nock('https://price.api.cx.metamask.io') + .get('/v1/exchange-rates') + .query({ + baseCurrency: 'eur', + }) + .reply(200, exchangeRatesMockResponseEur); + const onBreakHandler = jest.fn(); + const service = new CodefiTokenPricesServiceV2({ + retries, + maximumConsecutiveFailures, + onBreak: onBreakHandler, + // Ensure break duration is well over the max delay for a single request, so that the + // break doesn't end during a retry attempt + circuitBreakDuration: defaultMaxRetryDelay * 10, + }); + const fetchExchangeRates = (): ReturnType< + typeof service.fetchExchangeRates + > => + service.fetchExchangeRates({ + baseCurrency: 'eur', + includeUsdRate: false, + cryptocurrencies, + }); + expect(onBreakHandler).not.toHaveBeenCalled(); + + // Initial three calls to exhaust maximum allowed failures + // eslint-disable-next-line @typescript-eslint/no-unused-vars + for (const _retryAttempt of Array(retries).keys()) { + await expect(() => + fetchExchangeRatesWithFakeTimers({ + fetchExchangeRates, + retries, + }), + ).rejects.toThrow('Failed to fetch'); + } + + expect(onBreakHandler).toHaveBeenCalledTimes(1); + }); + }); + }); + + describe('validateChainIdSupported', () => { + it.each(SUPPORTED_CHAIN_IDS)( + 'returns true if the given chain ID is %s', + (chainId) => { + expect( + new CodefiTokenPricesServiceV2().validateChainIdSupported(chainId), + ).toBe(true); + }, + ); + + it('returns false if the given chain ID is not one of the supported chain IDs', () => { + expect( + new CodefiTokenPricesServiceV2().validateChainIdSupported( + '0x999999999999999', + ), + ).toBe(false); + }); + }); + + describe('validateCurrencySupported', () => { + it.each(SUPPORTED_CURRENCIES)( + 'returns true if the given currency is %s', + (currency) => { + expect( + new CodefiTokenPricesServiceV2().validateCurrencySupported(currency), + ).toBe(true); + }, + ); + + it.each(SUPPORTED_CURRENCIES.map((currency) => currency.toLowerCase()))( + 'returns true if the given currency is %s', + (currency) => { + expect( + new CodefiTokenPricesServiceV2().validateCurrencySupported(currency), + ).toBe(true); + }, + ); + + it('returns false if the given currency is not one of the supported currencies', () => { + expect( + new CodefiTokenPricesServiceV2().validateCurrencySupported('LOL'), + ).toBe(false); + }); + }); + + describe('getNativeTokenAddress', () => { + it('should return unique native token address for MATIC', () => { + expect(getNativeTokenAddress('0x89')).toBe( + '0x0000000000000000000000000000000000001010', + ); + }); + it('should return zero address for other chains', () => { + (['0x1', '0x2', '0x1337'] as const).forEach((chainId) => { + expect(getNativeTokenAddress(chainId)).toBe(ZERO_ADDRESS); + }); + }); + }); + + describe('fetchSupportedCurrencies', () => { + afterEach(() => { + resetSupportedCurrenciesCache(); + }); + + it('fetches supported currencies from the API and returns them in lowercase', async () => { + const mockCurrencies = ['USD', 'EUR', 'GBP', 'JPY']; + nock('https://price.api.cx.metamask.io') + .get('/v1/supportedVsCurrencies') + .reply(200, mockCurrencies); + + const result = await fetchSupportedCurrencies(); + + expect(result).toStrictEqual(['usd', 'eur', 'gbp', 'jpy']); + }); + + it('returns the fallback list when the API returns an invalid response', async () => { + nock('https://price.api.cx.metamask.io') + .get('/v1/supportedVsCurrencies') + .reply(200, { invalid: 'response' }); + + const result = await fetchSupportedCurrencies(); + + expect(result).toStrictEqual([...SUPPORTED_CURRENCIES_FALLBACK]); + }); + + it('returns the fallback list when the API request fails', async () => { + nock('https://price.api.cx.metamask.io') + .get('/v1/supportedVsCurrencies') + .replyWithError('Network error'); + + const result = await fetchSupportedCurrencies(); + + expect(result).toStrictEqual([...SUPPORTED_CURRENCIES_FALLBACK]); + }); + + it('returns the fallback list when the API returns a non-200 status', async () => { + nock('https://price.api.cx.metamask.io') + .get('/v1/supportedVsCurrencies') + .reply(500, 'Internal Server Error'); + + const result = await fetchSupportedCurrencies(); + + expect(result).toStrictEqual([...SUPPORTED_CURRENCIES_FALLBACK]); + }); + + it('updates getSupportedCurrencies after a successful fetch', async () => { + const mockCurrencies = ['btc', 'eth', 'usd']; + nock('https://price.api.cx.metamask.io') + .get('/v1/supportedVsCurrencies') + .reply(200, mockCurrencies); + + await fetchSupportedCurrencies(); + const result = getSupportedCurrencies(); + + expect(result).toStrictEqual(['btc', 'eth', 'usd']); + }); + }); + + describe('getSupportedCurrencies', () => { + beforeEach(() => { + resetSupportedCurrenciesCache(); + }); + + it('returns the fallback list when no currencies have been fetched', async () => { + // Note: This test may be affected by prior test state. + // In a fresh module state, it should return the fallback list. + const result = getSupportedCurrencies(); + + // Should be one of: the fallback list or a previously fetched list + expect(Array.isArray(result)).toBe(true); + expect(result.length).toBeGreaterThan(0); + }); + }); + + describe('updateSupportedCurrencies', () => { + afterEach(() => { + resetSupportedCurrenciesCache(); + }); + + it('fetches and returns supported currencies via the service method', async () => { + const mockCurrencies = ['usd', 'eur', 'gbp']; + nock('https://price.api.cx.metamask.io') + .get('/v1/supportedVsCurrencies') + .reply(200, mockCurrencies); + + const service = new CodefiTokenPricesServiceV2(); + const result = await service.updateSupportedCurrencies(); + + expect(result).toStrictEqual(['usd', 'eur', 'gbp']); + }); + + it('returns the fallback list when the API fails', async () => { + nock('https://price.api.cx.metamask.io') + .get('/v1/supportedVsCurrencies') + .replyWithError('Network error'); + + const service = new CodefiTokenPricesServiceV2(); + const result = await service.updateSupportedCurrencies(); + + expect(result).toStrictEqual([...SUPPORTED_CURRENCIES_FALLBACK]); + }); + }); + + describe('fetchSupportedNetworks', () => { + afterEach(() => { + resetSupportedNetworksCache(); + }); + + it('fetches supported networks from the API and returns them', async () => { + const mockResponse = { + fullSupport: ['eip155:1', 'eip155:137'], + partialSupport: { + spotPricesV2: ['eip155:1', 'eip155:137', 'eip155:56'], + spotPricesV3: ['eip155:1', 'eip155:137', 'eip155:42161'], + }, + }; + nock('https://price.api.cx.metamask.io') + .get('/v2/supportedNetworks') + .reply(200, mockResponse); + + const result = await fetchSupportedNetworks(); + + expect(result).toStrictEqual(mockResponse); + }); + + it('returns the fallback list when the API returns an invalid response', async () => { + nock('https://price.api.cx.metamask.io') + .get('/v2/supportedNetworks') + .reply(200, { invalid: 'response' }); + + const result = await fetchSupportedNetworks(); + + expect(result.fullSupport).toBeDefined(); + expect(result.partialSupport).toBeDefined(); + expect(result.partialSupport.spotPricesV3).toBeDefined(); + }); + + it('returns the fallback list when the API request fails', async () => { + nock('https://price.api.cx.metamask.io') + .get('/v2/supportedNetworks') + .replyWithError('Network error'); + + const result = await fetchSupportedNetworks(); + + expect(result.fullSupport).toBeDefined(); + expect(result.partialSupport).toBeDefined(); + expect(result.partialSupport.spotPricesV3).toBeDefined(); + }); + + it('updates getSupportedNetworks after a successful fetch', async () => { + const mockResponse = { + fullSupport: ['eip155:1'], + partialSupport: { + spotPricesV2: ['eip155:1', 'eip155:56'], + spotPricesV3: ['eip155:1', 'eip155:42161'], + }, + }; + nock('https://price.api.cx.metamask.io') + .get('/v2/supportedNetworks') + .reply(200, mockResponse); + + await fetchSupportedNetworks(); + const result = getSupportedNetworks(); + + expect(result).toStrictEqual(mockResponse); + }); + + it('deduplicates concurrent requests to the same endpoint', async () => { + const mockResponse = { + fullSupport: ['eip155:1'], + partialSupport: { + spotPricesV2: ['eip155:1', 'eip155:56'], + spotPricesV3: ['eip155:1', 'eip155:42161'], + }, + }; + // Only set up the mock to respond once + nock('https://price.api.cx.metamask.io') + .get('/v2/supportedNetworks') + .reply(200, mockResponse); + + // Make 5 concurrent calls + const promises = [ + fetchSupportedNetworks(), + fetchSupportedNetworks(), + fetchSupportedNetworks(), + fetchSupportedNetworks(), + fetchSupportedNetworks(), + ]; + + const results = await Promise.all(promises); + + // All promises should resolve to the same response + results.forEach((result) => { + expect(result).toStrictEqual(mockResponse); + }); + + // Verify no pending mocks (i.e., only one request was made) + expect(isDone()).toBe(true); + }); + }); + + describe('getSupportedNetworks', () => { + beforeEach(() => { + resetSupportedNetworksCache(); + }); + + it('returns the fallback list when no networks have been fetched', () => { + const result = getSupportedNetworks(); + + expect(result.fullSupport).toBeDefined(); + expect(result.partialSupport).toBeDefined(); + expect(result.partialSupport.spotPricesV3).toBeDefined(); + expect(Array.isArray(result.fullSupport)).toBe(true); + expect(Array.isArray(result.partialSupport.spotPricesV3)).toBe(true); + }); + }); + + describe('updateSupportedNetworks', () => { + afterEach(() => { + resetSupportedNetworksCache(); + }); + + it('fetches and returns supported networks via the service method', async () => { + const mockResponse = { + fullSupport: ['eip155:1', 'eip155:10'], + partialSupport: { + spotPricesV2: ['eip155:1'], + spotPricesV3: ['eip155:1', 'eip155:10'], + }, + }; + nock('https://price.api.cx.metamask.io') + .get('/v2/supportedNetworks') + .reply(200, mockResponse); + + const service = new CodefiTokenPricesServiceV2(); + const result = await service.updateSupportedNetworks(); + + expect(result).toStrictEqual(mockResponse); + }); + + it('returns the fallback list when the API fails', async () => { + nock('https://price.api.cx.metamask.io') + .get('/v2/supportedNetworks') + .replyWithError('Network error'); + + const service = new CodefiTokenPricesServiceV2(); + const result = await service.updateSupportedNetworks(); + + expect(result.fullSupport).toBeDefined(); + expect(result.partialSupport).toBeDefined(); + }); + }); + + describe('setNativeAssetIdentifiers', () => { + afterEach(() => { + resetSupportedNetworksCache(); + }); + + it('sets native asset identifiers and uses them in fetchTokenPrices', async () => { + // Mock supportedNetworks to include our test chain + const mockNetworksResponse = { + fullSupport: ['eip155:1'], + partialSupport: { + spotPricesV2: [], + spotPricesV3: ['eip155:1'], + }, + }; + nock('https://price.api.cx.metamask.io') + .get('/v2/supportedNetworks') + .reply(200, mockNetworksResponse) + .persist(); + + // Pre-populate the cache + await fetchSupportedNetworks(); + + const customNativeAssetId = 'eip155:1/slip44:60'; + const nativeAssetIdentifiers = { + 'eip155:1': customNativeAssetId, + }; + + // Mock the spot-prices response + nock('https://price.api.cx.metamask.io') + .get('/v3/spot-prices') + .query(true) + .reply(200, { + [customNativeAssetId]: { + price: 2000, + currency: 'USD', + pricePercentChange1d: 1, + priceChange1d: 1, + marketCap: 1000000, + allTimeHigh: 5000, + allTimeLow: 100, + totalVolume: 50000, + high1d: 2100, + low1d: 1900, + circulatingSupply: 1000000, + dilutedMarketCap: 2000000, + marketCapPercentChange1d: 0.5, + pricePercentChange1h: 0.1, + pricePercentChange7d: 5, + pricePercentChange14d: 10, + pricePercentChange30d: 15, + pricePercentChange200d: 50, + pricePercentChange1y: 100, + }, + }); + + const service = new CodefiTokenPricesServiceV2(); + service.setNativeAssetIdentifiers( + nativeAssetIdentifiers as Record< + `${string}:${string}`, + `${string}:${string}/${string}:${string}` + >, + ); + + const result = await service.fetchTokenPrices({ + assets: [{ chainId: '0x1', tokenAddress: ZERO_ADDRESS }], + currency: 'USD', + }); + + expect(result).toHaveLength(1); + expect(result[0].price).toBe(2000); + }); + }); + + describe('validateChainIdSupported with dynamic networks', () => { + afterEach(() => { + resetSupportedNetworksCache(); + }); + + it('returns true for chains in the dynamic supported networks list', async () => { + const mockResponse = { + fullSupport: ['eip155:999999'], + partialSupport: { + spotPricesV2: [], + spotPricesV3: ['eip155:999999'], + }, + }; + nock('https://price.api.cx.metamask.io') + .get('/v2/supportedNetworks') + .reply(200, mockResponse); + + await fetchSupportedNetworks(); + + const service = new CodefiTokenPricesServiceV2(); + // 0xf423f = 999999 in hex + expect(service.validateChainIdSupported('0xf423f')).toBe(true); + }); + + it('returns true for chains in the hardcoded fallback list', () => { + resetSupportedNetworksCache(); + + const service = new CodefiTokenPricesServiceV2(); + // 0x1 (Ethereum mainnet) should always be in the hardcoded list + expect(service.validateChainIdSupported('0x1')).toBe(true); + }); + + it('returns false for unsupported chains', () => { + resetSupportedNetworksCache(); + + const service = new CodefiTokenPricesServiceV2(); + // Some random chain ID that's not supported + expect(service.validateChainIdSupported('0xdeadbeef')).toBe(false); + }); + }); + + describe('getAssetId', () => { + it('returns a CAIP-19 erc20 id with a lowercased address for ERC20 tokens', () => { + expect(getAssetId({ chainId: '0x1', tokenAddress: '0xABCDEF' })).toBe( + 'eip155:1/erc20:0xabcdef', + ); + }); + + it('returns the hardcoded id for native tokens on supported chains', () => { + expect(getAssetId({ chainId: '0x1', tokenAddress: ZERO_ADDRESS })).toBe( + 'eip155:1/slip44:60', + ); + }); + + it('detects native tokens on chains with a custom native address', () => { + expect( + getAssetId({ + chainId: '0x89', + tokenAddress: '0x0000000000000000000000000000000000001010', + }), + ).toBe('eip155:137/slip44:966'); + }); + + it('matches native token addresses case-insensitively', () => { + expect( + getAssetId({ + chainId: '0x89', + tokenAddress: '0x0000000000000000000000000000000000001010' + .toUpperCase() + .replace('0X', '0x'), + }), + ).toBe('eip155:137/slip44:966'); + }); + + it('falls back to nativeAssetIdentifiers when the chain has no hardcoded entry', () => { + // 0x42 (OKXChain) is not in SPOT_PRICES_SUPPORT_INFO + expect( + getAssetId({ + chainId: '0x42', + tokenAddress: ZERO_ADDRESS, + nativeAssetIdentifiers: { 'eip155:66': 'eip155:66/slip44:996' }, + }), + ).toBe('eip155:66/slip44:996'); + }); + + it('prefers the hardcoded entry over nativeAssetIdentifiers', () => { + expect( + getAssetId({ + chainId: '0x1', + tokenAddress: ZERO_ADDRESS, + nativeAssetIdentifiers: { 'eip155:1': 'eip155:1/erc20:0xwrong' }, + }), + ).toBe('eip155:1/slip44:60'); + }); + + it('returns undefined for a native token with no hardcoded entry and no identifier', () => { + expect( + getAssetId({ chainId: '0x42', tokenAddress: ZERO_ADDRESS }), + ).toBeUndefined(); + }); + + it('returns undefined instead of throwing when given a malformed chain ID', () => { + expect( + // @ts-expect-error Testing runtime behavior with invalid input + getAssetId({ chainId: 'not-a-hex-string', tokenAddress: '0xAAA' }), + ).toBeUndefined(); + }); + }); +}); + +/** + * Calls the 'fetchTokenPrices' function while advancing the clock, allowing + * the function to resolve. + * + * Fetching token rates is challenging in an environment with fake timers + * because we're using a library that automatically retries failed requests, + * which uses `setTimeout` internally. We have to advance the clock after the + * update call starts but before awaiting the result, otherwise it never + * resolves. + * + * @param args - Arguments + * @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({ + fetchTokenPrices, + retries, +}: { + fetchTokenPrices: () => ReturnType< + CodefiTokenPricesServiceV2['fetchTokenPrices'] + >; + retries: number; +}): ReturnType { + const pendingUpdate = fetchTokenPrices(); + pendingUpdate.catch(() => { + // suppress Unhandled Promise error + }); + + // Advance timer enough to exceed max possible retry delay for initial call, and all + // subsequent retries + // eslint-disable-next-line @typescript-eslint/no-unused-vars + for (const _retryAttempt of Array(retries + 1).keys()) { + await jest.advanceTimersByTimeAsync(defaultMaxRetryDelay); + } + + return await pendingUpdate; +} + +/** + * Calls the 'fetchExchangeRates' function while advancing the clock, allowing + * the function to resolve. + * + * Fetching rates is challenging in an environment with fake timers + * because we're using a library that automatically retries failed requests, + * which uses `setTimeout` internally. We have to advance the clock after the + * update call starts but before awaiting the result, otherwise it never + * resolves. + * + * @param args - Arguments + * @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({ + fetchExchangeRates, + retries, +}: { + fetchExchangeRates: () => ReturnType< + CodefiTokenPricesServiceV2['fetchExchangeRates'] + >; + retries: number; +}): ReturnType { + const pendingUpdate = fetchExchangeRates(); + pendingUpdate.catch(() => { + // suppress Unhandled Promise error + }); + + // Advance timer enough to exceed max possible retry delay for initial call, and all + // subsequent retries + // eslint-disable-next-line @typescript-eslint/no-unused-vars + for (const _retryAttempt of Array(retries + 1).keys()) { + await jest.advanceTimersByTimeAsync(defaultMaxRetryDelay); + } + + return await pendingUpdate; +} + +/** + * + * @param tokenAddress - The token address. + * @returns The token asset id. + */ +function buildTokenAssetId(tokenAddress: Hex): string { + return tokenAddress === ZERO_ADDRESS + ? `${KnownCaipNamespace.Eip155}:1/slip44:60` + : `${KnownCaipNamespace.Eip155}:1/erc20:${tokenAddress.toLowerCase()}`; +} + +/** + * + * @param tokenAddresses - The token addresses. + * @returns The token asset ids. + */ +function buildMultipleAssetIds(tokenAddresses: Hex[]): string { + return tokenAddresses.map(buildTokenAssetId).join(','); +} diff --git a/packages/assets-controllers/src/token-prices-service/codefi-v2.ts b/packages/assets-controllers/src/token-prices-service/codefi-v2.ts new file mode 100644 index 00000000000..bd00f5dfed6 --- /dev/null +++ b/packages/assets-controllers/src/token-prices-service/codefi-v2.ts @@ -0,0 +1,1009 @@ +import { + createServicePolicy, + DEFAULT_CIRCUIT_BREAK_DURATION, + DEFAULT_DEGRADED_THRESHOLD, + DEFAULT_MAX_CONSECUTIVE_FAILURES, + DEFAULT_MAX_RETRIES, + handleFetch, +} from '@metamask/controller-utils'; +import type { ServicePolicy } from '@metamask/controller-utils'; +import type { CaipAssetType, Hex } from '@metamask/utils'; +import { + hexToNumber, + KnownCaipNamespace, + numberToHex, + toCaipChainId, +} from '@metamask/utils'; + +import type { MarketDataDetails } from '../TokenRatesController.js'; +import type { + AbstractTokenPricesService, + EvmAssetAddressWithChain, + EvmAssetWithId, + EvmAssetWithMarketData, + ExchangeRatesByCurrency, + NativeAssetIdentifiersMap, +} from './abstract-token-prices-service.js'; + +/** + * The list of currencies that can be supplied as the `vsCurrency` parameter to + * the `/spot-prices` endpoint, in lowercase form. + * This is the fallback list used when the API is unavailable. + */ +export const SUPPORTED_CURRENCIES_FALLBACK = [ + // Bitcoin + 'btc', + // Ether + 'eth', + // Litecoin + 'ltc', + // Bitcoin Cash + 'bch', + // Binance Coin + 'bnb', + // EOS + 'eos', + // XRP + 'xrp', + // Lumens + 'xlm', + // Chainlink + 'link', + // Polkadot + 'dot', + // Yearn.finance + 'yfi', + // US Dollar + 'usd', + // United Arab Emirates Dirham + 'aed', + // Argentine Peso + 'ars', + // Australian Dollar + 'aud', + // Bangladeshi Taka + 'bdt', + // Bahraini Dinar + 'bhd', + // Bermudian Dollar + 'bmd', + // Brazil Real + 'brl', + // Canadian Dollar + 'cad', + // Swiss Franc + 'chf', + // Chilean Peso + 'clp', + // Chinese Yuan + 'cny', + // Czech Koruna + 'czk', + // Danish Krone + 'dkk', + // Euro + 'eur', + // British Pound Sterling + 'gbp', + // Georgian Lari + 'gel', + // Hong Kong Dollar + 'hkd', + // Hungarian Forint + 'huf', + // Indonesian Rupiah + 'idr', + // Israeli New Shekel + 'ils', + // Indian Rupee + 'inr', + // Japanese Yen + 'jpy', + // South Korean Won + 'krw', + // Kuwaiti Dinar + 'kwd', + // Sri Lankan Rupee + 'lkr', + // Burmese Kyat + 'mmk', + // Mexican Peso + 'mxn', + // Malaysian Ringgit + 'myr', + // Monad + 'mon', + // Nigerian Naira + 'ngn', + // Norwegian Krone + 'nok', + // New Zealand Dollar + 'nzd', + // Philippine Peso + 'php', + // Pakistani Rupee + 'pkr', + // Polish Zloty + 'pln', + // Russian Ruble + 'rub', + // Saudi Riyal + 'sar', + // Swedish Krona + 'sek', + // Singapore Dollar + 'sgd', + // Thai Baht + 'thb', + // Turkish Lira + 'try', + // New Taiwan Dollar + 'twd', + // Ukrainian hryvnia + 'uah', + // Venezuelan bolívar fuerte + 'vef', + // Vietnamese đồng + 'vnd', + // South African Rand + 'zar', + // IMF Special Drawing Rights + 'xdr', + // Silver - Troy Ounce + 'xag', + // Gold - Troy Ounce + 'xau', + // Bits + 'bits', + // Satoshi + 'sats', + // Colombian Peso + 'cop', + // Kenyan Shilling + 'kes', + // Romanian Leu + 'ron', + // Dominican Peso + 'dop', + // Costa Rican Colón + 'crc', + // Honduran Lempira + 'hnl', + // Zambian Kwacha + 'zmw', + // Salvadoran Colón + 'svc', + // Bosnia-Herzegovina Convertible Mark + 'bam', + // Peruvian Sol + 'pen', + // Guatemalan Quetzal + 'gtq', + // Lebanese Pound + 'lbp', + // Armenian Dram + 'amd', + // Solana + 'sol', + // Sei + 'sei', + // Sonic + 'sonic', + // Tron + 'trx', + // Taiko + 'taiko', + // Pepu + 'pepu', + // Polygon + 'pol', + // Mantle + 'mnt', + // Onomy + 'nom', + // Avalanche + 'avax', + // Apechain + 'ape', +] as const; + +/** + * @deprecated Use `getSupportedCurrencies()` or `fetchSupportedCurrencies()` instead. + * This is an alias for backward compatibility. + */ +export const SUPPORTED_CURRENCIES = SUPPORTED_CURRENCIES_FALLBACK; + +/** + * Represents the zero address, commonly used as a placeholder in blockchain transactions. + * In the context of fetching market data, the zero address is utilized to retrieve information + * specifically for native currencies. This allows for a standardized approach to query market + * data for blockchain-native assets without a specific contract address. + */ +export const ZERO_ADDRESS: Hex = + '0x0000000000000000000000000000000000000000' as const; + +/** + * A mapping from chain id to the address of the chain's native token. + * Only for chains whose native tokens have a specific address. + */ +const chainIdToNativeTokenAddress: Record = { + '0x89': '0x0000000000000000000000000000000000001010', // Polygon + '0x440': '0xdeaddeaddeaddeaddeaddeaddeaddeaddead0000', // Metis Andromeda + '0x1388': '0xdeaddeaddeaddeaddeaddeaddeaddeaddead0000', // Mantle +}; + +/** + * Returns the address that should be used to query the price api for the + * chain's native token. On most chains, this is signified by the zero address. + * But on some chains, the native token has a specific address. + * + * @param chainId - The hexadecimal chain id. + * @returns The address of the chain's native token. + */ +export const getNativeTokenAddress = (chainId: Hex): Hex => + chainIdToNativeTokenAddress[chainId] ?? ZERO_ADDRESS; + +// Price API v3/spot-prices chains only — verify support before adding: +// Source: https://github.com/consensys-vertical-apps/va-mmcx-price-api/blob/main/src/constants/slip44.ts +// https://price.api.cx.metamask.io/v2/supportedNetworks +// https://price.api.cx.metamask.io/v3/spot-prices?assetIds=&vsCurrency=usd +// Include chain name + native symbol. Keep sorted by chain ID. +export const SPOT_PRICES_SUPPORT_INFO = { + '0x1': 'eip155:1/slip44:60', // Ethereum Mainnet - Native symbol: ETH + '0xa': 'eip155:10/slip44:60', // OP Mainnet - Native symbol: ETH + '0x19': 'eip155:25/slip44:394', // Cronos Mainnet - Native symbol: CRO + '0x1e': 'eip155:30/slip44:137', // Rootstock Mainnet - Native symbol: RBTC + '0x2a': 'eip155:42/erc20:0x0000000000000000000000000000000000000000', // Lukso - native symbol: LYX + '0x32': 'eip155:50/erc20:0x0000000000000000000000000000000000000000', // xdc-network - native symbol: XDC + '0x38': 'eip155:56/slip44:714', // BNB Smart Chain Mainnet - Native symbol: BNB + '0x39': 'eip155:57/slip44:57', // Syscoin Mainnet - Native symbol: SYS + '0x52': 'eip155:82/slip44:18000', // Meter Mainnet - Native symbol: MTR + '0x58': 'eip155:88/slip44:889', // TomoChain - Native symbol: TOMO + '0x64': 'eip155:100/erc20:0x0000000000000000000000000000000000000000', // Gnosis (formerly xDAI Chain) - Native symbol: xDAI + '0x6a': 'eip155:106/slip44:5655640', // Velas EVM Mainnet - Native symbol: VLX + '0x7a': 'eip155:122/erc20:0x0000000000000000000000000000000000000000', // Fuse Mainnet - Native symbol: FUSE + '0x80': 'eip155:128/slip44:1010', // Huobi ECO Chain Mainnet - Native symbol: HT + '0x89': 'eip155:137/slip44:966', // Polygon Mainnet - Native symbol: POL + '0x8f': 'eip155:143/slip44:268435779', // Monad Mainnet - Native symbol: MON + '0x92': 'eip155:146/slip44:10007', // Sonic Mainnet - Native symbol: S + '0xc4': 'eip155:196/erc20:0x0000000000000000000000000000000000000000', // X Layer Mainnet - Native symbol: OKB + '0xe8': 'eip155:232/erc20:0x0000000000000000000000000000000000000000', // Lens Mainnet - Native symbol: GHO + '0xfa': 'eip155:250/slip44:1007', // Fantom Opera - Native symbol: FTM + '0xfc': 'eip155:252/erc20:0x0000000000000000000000000000000000000000', // Fraxtal - native symbol: FRAX + '0x120': 'eip155:288/slip44:60', // Boba Network (Ethereum L2) - Native symbol: ETH + '0x141': 'eip155:321/slip44:641', // KCC Mainnet - Native symbol: KCS + '0x144': 'eip155:324/slip44:60', // zkSync Era Mainnet (Ethereum L2) - Native symbol: ETH + '0x150': 'eip155:336/slip44:809', // Shiden - Native symbol: SDN + '0x169': 'eip155:361/slip44:589', // Theta Mainnet - Native symbol: TFUEL + '0x2eb': 'eip155:747/slip44:539', // Flow evm - Native symbol: Flow + '0x3dc': 'eip155:988/erc20:0x0000000000000000000000000000000000000000', // Stable - Native symbol: USDT0 + '0x3e7': 'eip155:999/slip44:2457', // HyperEVM - Native symbol: HYPE + '0x440': 'eip155:1088/erc20:0xdeaddeaddeaddeaddeaddeaddeaddeaddead0000', // Metis Andromeda Mainnet (Ethereum L2) - Native symbol: METIS + '0x44d': 'eip155:1101/slip44:60', // Polygon zkEVM mainnet - Native symbol: ETH + '0x504': 'eip155:1284/slip44:1284', // Moonbeam - Native symbol: GLMR + '0x505': 'eip155:1285/slip44:1285', // Moonriver - Native symbol: MOVR + '0x531': 'eip155:1329/slip44:19000118', // Sei Mainnet - Native symbol: SEI + '0x6f0': 'eip155:1776/slip44:22000119', // Injective Mainnet - Native symbol: INJ + '0x74c': 'eip155:1868/erc20:0x0000000000000000000000000000000000000000', // Soneium - Native symbol: ETH + '0x9dd': 'eip155:2525/erc20:0x0000000000000000000000000000000000000000', // inEVM Mainnet - Native symbol: INV + '0xab5': 'eip155:2741/erc20:0x0000000000000000000000000000000000000000', // Abstract - Native symbol: ETH + '0x1079': 'eip155:4217/slip44:60', // Tempo Mainnet - No native asset + '0x10e6': 'eip155:4326/erc20:0x0000000000000000000000000000000000000000', // MegaETH Mainnet - Native symbol: ETH + '0x1388': 'eip155:5000/erc20:0xdeaddeaddeaddeaddeaddeaddeaddeaddead0000', // Mantle - Native symbol: MNT + '0x13a7': 'eip155:5031/slip44:5031', // Somnia Mainnet - Native symbol: SOMI + '0x13b2': 'eip155:5042/slip44:5042', // Arc - Native symbol: USDC + '0x1b58': 'eip155:7000/slip44:7000', // ZetaChain - Native symbol: ZETA + '0x2105': 'eip155:8453/slip44:60', // Base - Native symbol: ETH + '0x1237': 'eip155:4663/slip44:60', // Robinhood Chain - Native symbol: ETH + '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 + '0xa4b1': 'eip155:42161/slip44:60', // Arbitrum One - Native symbol: ETH + '0xa4ec': 'eip155:42220/slip44:52752', // Celo Mainnet - Native symbol: CELO + '0xa516': 'eip155:42262/slip44:474', // Oasis Emerald - Native symbol: ROSE + '0xa5bf': 'eip155:42431/slip44:60', // Tempo Testnet Moderato - No native asset + '0xa729': 'eip155:42793/erc20:0x0000000000000000000000000000000000000000', // Etherlink - Native symbol: XTZ (Tezos L2) + '0xa867': 'eip155:43111/erc20:0x0000000000000000000000000000000000000000', // Hemi - Native symbol: ETH + '0xa86a': 'eip155:43114/slip44:9005', // Avalanche C-Chain - Native symbol: AVAX + '0xdef1': 'eip155:57073/slip44:60', // Ink Mainnet - Native symbol: ETH + '0xe708': 'eip155:59144/slip44:60', // Linea Mainnet - Native symbol: ETH + '0xed88': 'eip155:60808/erc20:0x0000000000000000000000000000000000000000', // BOB - Native symbol: ETH + '0x10b3e': 'eip155:68414/erc20:0x0000000000000000000000000000000000000000', // MapleStory Universe - no slip44 + '0x11d9b': 'eip155:73115/erc20:0x0000000000000000000000000000000000000000', // ICB Network - Native symbol: ICBX, + '0x138de': 'eip155:80094/erc20:0x0000000000000000000000000000000000000000', // Berachain - Native symbol: Bera', + '0x13e31': '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 + '0x518af': 'eip155:333999/slip44:1997', // Polis Mainnet - Native symbol: POLIS + '0x82750': 'eip155:534352/slip44:60', // Scroll Mainnet - Native symbol: ETH + '0xb67d2': 'eip155:747474/erc20:0x0000000000000000000000000000000000000000', // katana - Native symbol: ETH + '0xf043a': 'eip155:984122/erc20:0x0000000000000000000000000000000000000000', // Forma - Native symbol: TIA (Celestia) + '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 + '0x4115': 'eip155:16661/slip44:1111116661', // 0G Chain - Native symbol: 0G +} as const; + +// MISSING CHAINS WITH NO NATIVE ASSET PRICES +// '0x42': 'eip155:66/slip44:996', // OKXChain Mainnet - Native symbol: OKT +// '0x46': 'eip155:70/slip44:1170', // Hoo Smart Chain - Native symbol: HOO +// '0x926': 'eip155:2342/erc20:0x0000000000000000000000000000000000000000', // Omnia Chain - Native symbol: OMNIA +// '0x407b': 'eip155:16507/erc20:0x0000000000000000000000000000000000000000', // Genesys Mainnet - Native symbol: GSYS + +/** + * A currency that can be supplied as the `vsCurrency` parameter to + * the `/spot-prices` endpoint. Covers both uppercase and lowercase versions. + */ +type SupportedCurrency = + | (typeof SUPPORTED_CURRENCIES_FALLBACK)[number] + | Uppercase<(typeof SUPPORTED_CURRENCIES_FALLBACK)[number]>; + +/** + * The list of chain IDs that can be supplied in the URL for the `/spot-prices` + * endpoint, but in hexadecimal form (for consistency with how we represent + * chain IDs in other places). + * + * @see Used by {@link CodefiTokenPricesServiceV2} to validate that a given chain ID is supported by V2 of the Codefi Price API. + */ +export const SUPPORTED_CHAIN_IDS = Object.keys( + SPOT_PRICES_SUPPORT_INFO, +) as (keyof typeof SPOT_PRICES_SUPPORT_INFO)[]; + +/** + * A chain ID that can be supplied in the URL for the `/spot-prices` endpoint, + * but in hexadecimal form (for consistency with how we represent chain IDs in + * other places). + */ +type SupportedChainId = (typeof SUPPORTED_CHAIN_IDS)[number]; + +const BASE_URL_V1 = 'https://price.api.cx.metamask.io/v1'; + +const BASE_URL_V2 = 'https://price.api.cx.metamask.io/v2'; + +const BASE_URL_V3 = 'https://price.api.cx.metamask.io/v3'; + +/** + * Response type for the /v2/supportedNetworks endpoint. + */ +type SupportedNetworksResponse = { + fullSupport: string[]; + partialSupport: { + spotPricesV2: string[]; + spotPricesV3: string[]; + }; +}; + +/** + * In-memory store for the last successfully fetched supported networks. + */ +let lastFetchedSupportedNetworks: SupportedNetworksResponse | null = null; + +/** + * In-flight promise to prevent concurrent requests to the supported networks endpoint. + */ +let runningSupportedNetworksRequest: Promise | null = + null; + +/** + * Converts a CAIP-2 chain ID (e.g., 'eip155:1') to a hex chain ID (e.g., '0x1'). + * + * @param caipChainId - The CAIP-2 chain ID string. + * @returns The hex chain ID or null if not an EIP-155 chain. + */ +function caipChainIdToHex(caipChainId: string): Hex | null { + const match = caipChainId.match(/^eip155:(\d+)$/u); + if (!match) { + return null; + } + return numberToHex(parseInt(match[1], 10)); +} + +/** + * Executes the actual fetch to the supported networks endpoint. + * Handles errors internally by falling back to the hardcoded list, + * and clears the in-flight promise when done. + * + * @returns The supported networks response. + */ +async function executeSupportedNetworksFetch(): Promise { + try { + const url = `${BASE_URL_V2}/supportedNetworks`; + const response = await handleFetch(url, { + headers: { 'Cache-Control': 'no-cache' }, + }); + + if ( + response && + typeof response === 'object' && + 'fullSupport' in response && + 'partialSupport' in response + ) { + lastFetchedSupportedNetworks = response as SupportedNetworksResponse; + return lastFetchedSupportedNetworks; + } + + // Invalid response format, fall back to hardcoded list + return getSupportedNetworksFallback(); + } catch { + // On any error, fall back to the hardcoded list + return getSupportedNetworksFallback(); + } finally { + // Clear the in-flight promise once the request completes + runningSupportedNetworksRequest = null; + } +} + +/** + * Fetches the list of supported networks from the API. + * Falls back to the hardcoded list if the fetch fails. + * Deduplicates concurrent requests by returning the same promise if a fetch is already in progress. + * + * @returns The supported networks response. + */ +export async function fetchSupportedNetworks(): Promise { + // If a fetch is already in progress, return the same promise + if (runningSupportedNetworksRequest) { + return runningSupportedNetworksRequest; + } + + // Start a new fetch and cache the promise + runningSupportedNetworksRequest = executeSupportedNetworksFetch(); + + return runningSupportedNetworksRequest; +} + +/** + * Synchronously gets the list of supported networks. + * Returns the last fetched value if available, otherwise returns the fallback list. + * + * @returns The supported networks response. + */ +export function getSupportedNetworks(): SupportedNetworksResponse { + if (lastFetchedSupportedNetworks !== null) { + return lastFetchedSupportedNetworks; + } + return getSupportedNetworksFallback(); +} + +/** + * Generates a fallback supported networks response from the hardcoded SPOT_PRICES_SUPPORT_INFO. + * + * @returns A SupportedNetworksResponse derived from hardcoded data. + */ +function getSupportedNetworksFallback(): SupportedNetworksResponse { + const caipChainIds = Object.keys(SPOT_PRICES_SUPPORT_INFO).map((hexChainId) => + toCaipChainId( + KnownCaipNamespace.Eip155, + hexToNumber(hexChainId as Hex).toString(), + ), + ); + + return { + fullSupport: caipChainIds.slice(0, 11), // First 11 chains as "full support" + partialSupport: { + spotPricesV2: caipChainIds, + spotPricesV3: caipChainIds, + }, + }; +} + +/** + * Resets the supported networks cache. + * This is primarily intended for testing purposes. + */ +export function resetSupportedNetworksCache(): void { + lastFetchedSupportedNetworks = null; + runningSupportedNetworksRequest = null; +} + +/** + * Gets the list of supported chain IDs for spot prices v3 as hex values. + * + * @returns Array of hex chain IDs supported by spot prices v3. + */ +function getSupportedChainIdsV3AsHex(): Hex[] { + const supportedNetworks = getSupportedNetworks(); + const allV3Chains = [ + ...supportedNetworks.fullSupport, + ...supportedNetworks.partialSupport.spotPricesV3, + ]; + + return allV3Chains + .map(caipChainIdToHex) + .filter((hexChainId): hexChainId is Hex => hexChainId !== null); +} + +/** + * In-memory store for the last successfully fetched supported currencies. + */ +let lastFetchedCurrencies: string[] | null = null; + +/** + * Fetches the list of supported currencies from the API. + * Falls back to the hardcoded list if the fetch fails. + * + * @returns The list of supported currencies in lowercase. + */ +export async function fetchSupportedCurrencies(): Promise { + try { + const url = `${BASE_URL_V1}/supportedVsCurrencies`; + const response = await handleFetch(url, { + headers: { 'Cache-Control': 'no-cache' }, + }); + + if (Array.isArray(response)) { + const currencies = response.map((currency: string) => + currency.toLowerCase(), + ); + lastFetchedCurrencies = currencies; + return currencies; + } + + // Invalid response format, fall back to hardcoded list + return [...SUPPORTED_CURRENCIES_FALLBACK]; + } catch { + // On any error, fall back to the hardcoded list + return [...SUPPORTED_CURRENCIES_FALLBACK]; + } +} + +/** + * Synchronously gets the list of supported currencies. + * Returns the last fetched value if available, otherwise returns the fallback list. + * + * @returns The list of supported currencies in lowercase. + */ +export function getSupportedCurrencies(): readonly string[] { + if (lastFetchedCurrencies !== null) { + return lastFetchedCurrencies; + } + return SUPPORTED_CURRENCIES_FALLBACK; +} + +/** + * Resets the supported currencies cache. + * This is primarily intended for testing purposes. + */ +export function resetSupportedCurrenciesCache(): void { + lastFetchedCurrencies = null; +} + +/** + * Derives the CAIP-19 asset ID used to query the Price API for a token on a + * given chain. + * + * For native tokens, uses the hardcoded {@link SPOT_PRICES_SUPPORT_INFO} entry + * when defined, otherwise falls back to the provided native asset identifiers + * (sourced from NetworkEnablementController). For ERC20 tokens, constructs the + * CAIP-19 ID dynamically. + * + * @param args - The arguments to this function. + * @param args.chainId - The hexadecimal chain ID the token lives on. + * @param args.tokenAddress - The token's address. + * @param args.nativeAssetIdentifiers - Map of CAIP-2 chain IDs to native asset + * identifiers, used as a fallback for native tokens. + * @returns The CAIP-19 asset ID, or undefined if it cannot be determined. + */ +export function getAssetId({ + chainId, + tokenAddress, + nativeAssetIdentifiers = {}, +}: { + chainId: Hex; + tokenAddress: string; + nativeAssetIdentifiers?: NativeAssetIdentifiersMap; +}): CaipAssetType | undefined { + try { + const caipChainId = toCaipChainId( + KnownCaipNamespace.Eip155, + hexToNumber(chainId).toString(), + ); + + const nativeAddress = getNativeTokenAddress(chainId); + const isNativeToken = + nativeAddress.toLowerCase() === tokenAddress.toLowerCase(); + + if (isNativeToken) { + const hardcodedId = ( + SPOT_PRICES_SUPPORT_INFO as Partial> + )[chainId]; + return (hardcodedId ?? nativeAssetIdentifiers[caipChainId]) as + | CaipAssetType + | undefined; + } + + return `${caipChainId}/erc20:${tokenAddress.toLowerCase()}` as CaipAssetType; + } catch { + // This block should never be reached as long as using Typescript, but added for safety. + return undefined; + } +} + +/** + * This version of the token prices service uses V2 of the Codefi Price API to + * fetch token prices. + */ +export class CodefiTokenPricesServiceV2 implements AbstractTokenPricesService< + SupportedChainId, + SupportedCurrency +> { + readonly #policy: ServicePolicy; + + /** + * Map of CAIP-2 chain IDs to their native asset identifiers. + * Updated via setNativeAssetIdentifiers(). + */ + #nativeAssetIdentifiers: NativeAssetIdentifiersMap = {}; + + /** + * Construct a Codefi Token Price Service. + * + * @param args - The arguments. + * @param args.degradedThreshold - The length of time (in milliseconds) + * that governs when the service is regarded as degraded (affecting when + * `onDegraded` is called). Defaults to 5 seconds. + * @param args.retries - Number of retry attempts for each fetch request. + * @param args.maximumConsecutiveFailures - The maximum number of consecutive + * failures allowed before breaking the circuit and pausing further updates. + * @param args.circuitBreakDuration - The amount of time to wait when the + * circuit breaks from too many consecutive failures. + */ + constructor(args?: { + degradedThreshold?: number; + retries?: number; + maximumConsecutiveFailures?: number; + circuitBreakDuration?: number; + }); + + /** + * Construct a Codefi Token Price Service. + * + * @deprecated This signature is deprecated; please use the `onBreak` and + * `onDegraded` methods instead. + * @param args - The arguments. + * @param args.degradedThreshold - The length of time (in milliseconds) + * that governs when the service is regarded as degraded (affecting when + * `onDegraded` is called). Defaults to 5 seconds. + * @param args.retries - Number of retry attempts for each fetch request. + * @param args.maximumConsecutiveFailures - The maximum number of consecutive + * failures allowed before breaking the circuit and pausing further updates. + * @param args.onBreak - Callback for when the circuit breaks, useful + * for capturing metrics about network failures. + * @param args.onDegraded - Callback for when the API responds successfully + * but takes too long to respond (5 seconds or more). + * @param args.circuitBreakDuration - The amount of time to wait when the + * circuit breaks from too many consecutive failures. + */ + // eslint-disable-next-line @typescript-eslint/unified-signatures + constructor(args?: { + degradedThreshold?: number; + retries?: number; + maximumConsecutiveFailures?: number; + onBreak?: () => void; + onDegraded?: () => void; + circuitBreakDuration?: number; + }); + + constructor({ + degradedThreshold = DEFAULT_DEGRADED_THRESHOLD, + retries = DEFAULT_MAX_RETRIES, + maximumConsecutiveFailures = DEFAULT_MAX_CONSECUTIVE_FAILURES, + onBreak, + onDegraded, + circuitBreakDuration = DEFAULT_CIRCUIT_BREAK_DURATION, + }: { + degradedThreshold?: number; + retries?: number; + maximumConsecutiveFailures?: number; + onBreak?: () => void; + onDegraded?: () => void; + circuitBreakDuration?: number; + } = {}) { + this.#policy = createServicePolicy({ + maxRetries: retries, + maxConsecutiveFailures: maximumConsecutiveFailures, + circuitBreakDuration, + degradedThreshold, + }); + if (onBreak) { + this.#policy.onBreak(onBreak); + } + if (onDegraded) { + this.#policy.onDegraded(onDegraded); + } + } + + /** + * Listens for when the request to the API fails too many times in a row. + * + * @param args - The same arguments that {@link ServicePolicy.onBreak} + * takes. + * @returns What {@link ServicePolicy.onBreak} returns. + */ + onBreak( + ...args: Parameters + ): ReturnType { + return this.#policy.onBreak(...args); + } + + /** + * Listens for when the API is degraded. + * + * @param args - The same arguments that {@link ServicePolicy.onDegraded} + * takes. + * @returns What {@link ServicePolicy.onDegraded} returns. + */ + onDegraded( + ...args: Parameters + ): ReturnType { + return this.#policy.onDegraded(...args); + } + + /** + * Sets the native asset identifiers map for resolving native token CAIP-19 IDs. + * This should be called with data from NetworkEnablementController.state.nativeAssetIdentifiers. + * + * @param nativeAssetIdentifiers - Map of CAIP-2 chain IDs to native asset identifiers. + */ + setNativeAssetIdentifiers( + nativeAssetIdentifiers: NativeAssetIdentifiersMap, + ): void { + this.#nativeAssetIdentifiers = nativeAssetIdentifiers; + } + + /** + * Updates the supported networks cache by fetching from the API. + * This should be called periodically to keep the supported networks list fresh. + * + * @returns The updated supported networks response. + */ + async updateSupportedNetworks(): Promise { + return fetchSupportedNetworks(); + } + + /** + * Retrieves prices in the given currency for the tokens identified by the + * given addresses which are expected to live on the given chain. + * + * @param args - The arguments to function. + * @param args.assets - The assets to get prices for. + * @param args.currency - The desired currency of the token prices. + * @returns The prices for the requested tokens. + */ + async fetchTokenPrices({ + assets, + currency, + }: { + assets: EvmAssetAddressWithChain[]; + currency: SupportedCurrency; + }): Promise[]> { + // Refresh supported networks in background (non-blocking) + // This ensures the list stays fresh during normal polling + // Note: fetchSupportedNetworks handles errors internally and always resolves + if (!lastFetchedSupportedNetworks) { + // eslint-disable-next-line @typescript-eslint/no-floating-promises + fetchSupportedNetworks(); + } + + // Get dynamically fetched supported chain IDs for V3 + const supportedChainIdsV3 = getSupportedChainIdsV3AsHex(); + + const assetsWithIds: EvmAssetWithId[] = assets + // Filter out assets that are not supported by V3 of the Price API. + .filter((asset) => supportedChainIdsV3.includes(asset.chainId)) + .map((asset) => { + const assetId = getAssetId({ + chainId: asset.chainId, + tokenAddress: asset.tokenAddress, + nativeAssetIdentifiers: this.#nativeAssetIdentifiers, + }); + + if (!assetId) { + return undefined; + } + + return { + ...asset, + assetId, + }; + }) + .filter( + (asset): asset is EvmAssetWithId => + asset !== undefined, + ); + + if (assetsWithIds.length === 0) { + return []; + } + + const url = new URL(`${BASE_URL_V3}/spot-prices`); + url.searchParams.append( + 'assetIds', + assetsWithIds.map((asset) => asset.assetId).join(','), + ); + url.searchParams.append('vsCurrency', currency); + url.searchParams.append('includeMarketData', 'true'); + + const addressCryptoDataMap: { + [assetId: CaipAssetType]: Omit< + MarketDataDetails, + 'currency' | 'tokenAddress' + >; + } = await this.#policy.execute(() => + handleFetch(url, { headers: { 'Cache-Control': 'no-cache' } }), + ); + + return assetsWithIds + .map((assetWithId) => { + const marketData = addressCryptoDataMap[assetWithId.assetId]; + + if (!marketData) { + return undefined; + } + + return { + ...marketData, + ...assetWithId, + currency, + }; + }) + .filter((entry): entry is NonNullable => Boolean(entry)); + } + + /** + * Retrieves exchange rates in the given base currency. + * + * @param args - The arguments to this function. + * @param args.baseCurrency - The desired base currency of the exchange rates. + * @param args.includeUsdRate - Whether to include the USD rate in the response. + * @param args.cryptocurrencies - The cryptocurrencies to get exchange rates for. + * @returns The exchange rates for the requested base currency. + */ + async fetchExchangeRates({ + baseCurrency, + includeUsdRate, + cryptocurrencies, + }: { + baseCurrency: SupportedCurrency; + includeUsdRate: boolean; + cryptocurrencies: string[]; + }): Promise> { + // Refresh supported currencies in background (non-blocking) + // This ensures the list stays fresh during normal polling + // Note: fetchSupportedCurrencies handles errors internally and always resolves + if (!lastFetchedCurrencies) { + // eslint-disable-next-line @typescript-eslint/no-floating-promises + fetchSupportedCurrencies(); + } + + const url = new URL(`${BASE_URL_V1}/exchange-rates`); + url.searchParams.append('baseCurrency', baseCurrency); + + const urlUsd = new URL(`${BASE_URL_V1}/exchange-rates`); + urlUsd.searchParams.append('baseCurrency', 'usd'); + + const [exchangeRatesResult, exchangeRatesResultUsd] = + await Promise.allSettled([ + this.#policy.execute(() => + handleFetch(url, { headers: { 'Cache-Control': 'no-cache' } }), + ), + ...(includeUsdRate && baseCurrency.toLowerCase() !== 'usd' + ? [ + this.#policy.execute(() => + handleFetch(urlUsd, { + headers: { 'Cache-Control': 'no-cache' }, + }), + ), + ] + : []), + ]); + + // Handle resolved/rejected + const exchangeRates = + exchangeRatesResult.status === 'fulfilled' + ? exchangeRatesResult.value + : {}; + const exchangeRatesUsd = + exchangeRatesResultUsd?.status === 'fulfilled' + ? exchangeRatesResultUsd.value + : {}; + + if (exchangeRatesResult.status === 'rejected') { + throw new Error('Failed to fetch'); + } + + const filteredExchangeRates = cryptocurrencies.reduce((acc, key) => { + if (exchangeRates[key.toLowerCase() as SupportedCurrency]) { + acc[key.toLowerCase() as SupportedCurrency] = + exchangeRates[key.toLowerCase() as SupportedCurrency]; + } + return acc; + }, {} as ExchangeRatesByCurrency); + + if (Object.keys(filteredExchangeRates).length === 0) { + throw new Error( + 'None of the cryptocurrencies are supported by price api', + ); + } + + const filteredUsdExchangeRates = cryptocurrencies.reduce((acc, key) => { + if (exchangeRatesUsd[key.toLowerCase() as SupportedCurrency]) { + acc[key.toLowerCase() as SupportedCurrency] = + exchangeRatesUsd[key.toLowerCase() as SupportedCurrency]; + } + return acc; + }, {} as ExchangeRatesByCurrency); + + if (baseCurrency.toLowerCase() === 'usd') { + Object.keys(filteredExchangeRates).forEach((key) => { + filteredExchangeRates[key as SupportedCurrency] = { + ...filteredExchangeRates[key as SupportedCurrency], + usd: filteredExchangeRates[key as SupportedCurrency]?.value, + }; + }); + return filteredExchangeRates; + } + if (!includeUsdRate) { + return filteredExchangeRates; + } + + const merged = Object.keys(filteredExchangeRates).reduce((acc, key) => { + acc[key as SupportedCurrency] = { + ...filteredExchangeRates[key as SupportedCurrency], + ...(filteredUsdExchangeRates[key as SupportedCurrency]?.value + ? { usd: filteredUsdExchangeRates[key as SupportedCurrency]?.value } + : {}), + }; + return acc; + }, {} as ExchangeRatesByCurrency); + + return merged; + } + + /** + * Type guard for whether the API can return token prices for the given chain + * ID. + * + * @param chainId - The chain ID to check. + * @returns True if the API supports the chain ID, false otherwise. + */ + validateChainIdSupported(chainId: unknown): chainId is SupportedChainId { + // Use dynamically fetched supported networks + const supportedChainIds = getSupportedChainIdsV3AsHex(); + // Also include the hardcoded fallback list for backwards compatibility + const allSupportedChainIds: readonly string[] = [ + ...new Set([...supportedChainIds, ...SUPPORTED_CHAIN_IDS]), + ]; + return ( + typeof chainId === 'string' && allSupportedChainIds.includes(chainId) + ); + } + + /** + * Type guard for whether the API can return token prices in the given + * currency. + * + * @param currency - The currency to check. If a string, can be either + * lowercase or uppercase. + * @returns True if the API supports the currency, false otherwise. + */ + validateCurrencySupported(currency: unknown): currency is SupportedCurrency { + const supportedCurrencies = getSupportedCurrencies(); + return ( + typeof currency === 'string' && + supportedCurrencies.includes(currency.toLowerCase()) + ); + } + + /** + * Fetches the list of supported currencies from the API. + * + * @returns The list of supported currencies. + */ + async updateSupportedCurrencies(): Promise { + return fetchSupportedCurrencies(); + } +} diff --git a/packages/assets-controllers/src/token-prices-service/index.test.ts b/packages/assets-controllers/src/token-prices-service/index.test.ts new file mode 100644 index 00000000000..250d755a260 --- /dev/null +++ b/packages/assets-controllers/src/token-prices-service/index.test.ts @@ -0,0 +1,18 @@ +import * as allExports from './index.js'; + +describe('token-prices-service', () => { + it('has expected exports', () => { + expect(Object.keys(allExports)).toMatchInlineSnapshot(` + [ + "CodefiTokenPricesServiceV2", + "SUPPORTED_CHAIN_IDS", + "getNativeTokenAddress", + "fetchSupportedNetworks", + "getSupportedNetworks", + "resetSupportedNetworksCache", + "SPOT_PRICES_SUPPORT_INFO", + "getAssetId", + ] + `); + }); +}); diff --git a/packages/assets-controllers/src/token-prices-service/index.ts b/packages/assets-controllers/src/token-prices-service/index.ts new file mode 100644 index 00000000000..8361bb68afa --- /dev/null +++ b/packages/assets-controllers/src/token-prices-service/index.ts @@ -0,0 +1,14 @@ +export type { + AbstractTokenPricesService, + NativeAssetIdentifiersMap, +} from './abstract-token-prices-service.js'; +export { + CodefiTokenPricesServiceV2, + SUPPORTED_CHAIN_IDS, + getNativeTokenAddress, + fetchSupportedNetworks, + getSupportedNetworks, + resetSupportedNetworksCache, + SPOT_PRICES_SUPPORT_INFO, + getAssetId, +} from './codefi-v2.js'; diff --git a/packages/assets-controllers/src/token-service.test.ts b/packages/assets-controllers/src/token-service.test.ts index 0a88265ae78..6efdefec434 100644 --- a/packages/assets-controllers/src/token-service.test.ts +++ b/packages/assets-controllers/src/token-service.test.ts @@ -1,16 +1,48 @@ import { toHex } from '@metamask/controller-utils'; -import nock from 'nock'; +import type { CaipChainId } from '@metamask/utils'; +import type { CaipAssetType } from '@metamask/utils'; +import nock, { cleanAll } from 'nock'; +import type { SortTrendingBy } from './token-service.js'; import { + fetchRwas, + fetchTokenAssets, fetchTokenListByChainId, fetchTokenMetadata, + getTrendingTokens, + resetSuggestedOccurrenceFloorsCacheForTesting, + searchTokens, TOKEN_END_POINT_API, TOKEN_METADATA_NO_SUPPORT_ERROR, -} from './token-service'; +} from './token-service.js'; const ONE_MILLISECOND = 1; const ONE_SECOND_IN_MILLISECONDS = 1_000; +/** + * Default `/v1/suggestedOccurrenceFloors` payload for token-list tests. + * Mirrors production shape (decimal chain ID → floor). + */ +const DEFAULT_SUGGESTED_OCCURRENCE_FLOORS: Record = { + '1': 3, + '59144': 1, +}; + +/** + * Persist a nock for suggested occurrence floors. + * + * @param floors - Override payload; defaults to {@link DEFAULT_SUGGESTED_OCCURRENCE_FLOORS}. + * @returns The nock scope for the floors endpoint. + */ +function nockSuggestedOccurrenceFloors( + floors: Record = DEFAULT_SUGGESTED_OCCURRENCE_FLOORS, +): nock.Scope { + return nock(TOKEN_END_POINT_API) + .get('/v1/suggestedOccurrenceFloors') + .reply(200, floors) + .persist(); +} + const sampleTokenList = [ { address: '0xbbbbca6a901c926f240b89eacb641d8aec7aeafd', @@ -112,6 +144,107 @@ const sampleTokenList = [ }, ]; +const sampleTokenListLinea = [ + { + address: '0xbbbbca6a901c926f240b89eacb641d8aec7aeafd', + symbol: 'LRC', + decimals: 18, + occurrences: 11, + aggregators: [ + 'lineaTeam', + 'pmm', + 'airswapLight', + 'zeroEx', + 'bancor', + 'coinGecko', + 'zapper', + 'kleros', + 'zerion', + 'cmc', + 'oneInch', + ], + }, + { + address: '0xc011a73ee8576fb46f5e1c5751ca3b9fe0af2a6f', + symbol: 'SNX', + decimals: 18, + occurrences: 11, + aggregators: [ + 'lineaTeam', + 'pmm', + 'airswapLight', + 'zeroEx', + 'bancor', + 'coinGecko', + 'zapper', + 'kleros', + 'zerion', + 'cmc', + 'oneInch', + ], + name: 'Synthetix', + }, + { + address: '0x408e41876cccdc0f92210600ef50372656052a38', + symbol: 'REN', + decimals: 18, + occurrences: 11, + aggregators: [ + 'lineaTeam', + 'pmm', + 'airswapLight', + 'zeroEx', + 'bancor', + 'coinGecko', + 'zapper', + 'kleros', + 'zerion', + 'cmc', + 'oneInch', + ], + }, + { + address: '0x514910771af9ca656af840dff83e8264ecf986ca', + symbol: 'LINK', + decimals: 18, + occurrences: 11, + aggregators: [ + 'lineaTeam', + 'pmm', + 'airswapLight', + 'zeroEx', + 'bancor', + 'coinGecko', + 'zapper', + 'kleros', + 'zerion', + 'cmc', + 'oneInch', + ], + name: 'Chainlink', + }, + { + address: '0x1f573d6fb3f13d689ff844b4ce37794d79a7ff1c', + symbol: 'BNT', + decimals: 18, + occurrences: 11, + aggregators: [ + 'lineaTeam', + 'pmm', + 'airswapLight', + 'zeroEx', + 'bancor', + 'coinGecko', + 'zapper', + 'kleros', + 'zerion', + 'cmc', + 'oneInch', + ], + name: 'Bancor', + }, +]; + const sampleToken = { address: '0x514910771af9ca656af840dff83e8264ecf986ca', symbol: 'LINK', @@ -133,15 +266,73 @@ const sampleToken = { name: 'Chainlink', }; +const sampleSearchResults = [ + { + address: '0xa0b86a33e6c166428cf041c73490a6b448b7f2c2', + symbol: 'USDC', + decimals: 6, + name: 'USD Coin', + occurrences: 12, + aggregators: [ + 'paraswap', + 'pmm', + 'airswapLight', + 'zeroEx', + 'bancor', + 'coinGecko', + 'zapper', + 'kleros', + 'zerion', + 'cmc', + 'oneInch', + 'uniswap', + ], + }, + { + address: '0xdac17f958d2ee523a2206206994597c13d831ec7', + symbol: 'USDT', + decimals: 6, + name: 'Tether USD', + occurrences: 11, + aggregators: [ + 'paraswap', + 'pmm', + 'airswapLight', + 'zeroEx', + 'bancor', + 'coinGecko', + 'zapper', + 'kleros', + 'zerion', + 'cmc', + 'oneInch', + ], + }, +]; + const sampleDecimalChainId = 1; const sampleChainId = toHex(sampleDecimalChainId); +const sampleCaipChainId: CaipChainId = 'eip155:1'; +const polygonCaipChainId: CaipChainId = 'eip155:137'; describe('Token service', () => { describe('fetchTokenListByChainId', () => { + beforeEach(() => { + resetSuggestedOccurrenceFloorsCacheForTesting(); + nockSuggestedOccurrenceFloors(); + }); + + afterEach(() => { + cleanAll(); + resetSuggestedOccurrenceFloorsCacheForTesting(); + }); + it('should call the tokens api and return the list of tokens', async () => { const { signal } = new AbortController(); nock(TOKEN_END_POINT_API) - .get(`/tokens/${sampleDecimalChainId}`) + .get( + `/tokens/${sampleDecimalChainId}?occurrenceFloor=3&includeNativeAssets=false&includeTokenFees=false&includeAssetType=false&includeERC20Permit=false&includeStorage=false&includeRwaData=true`, + ) .reply(200, sampleTokenList) .persist(); @@ -150,10 +341,143 @@ describe('Token service', () => { expect(tokens).toStrictEqual(sampleTokenList); }); + it('should call the tokens api and return the list of tokens on linea mainnet', async () => { + const { signal } = new AbortController(); + const lineaChainId = 59144; + const lineaHexChain = toHex(lineaChainId); + + nock(TOKEN_END_POINT_API) + .get( + `/tokens/${lineaChainId}?occurrenceFloor=1&includeNativeAssets=false&includeTokenFees=false&includeAssetType=false&includeERC20Permit=false&includeStorage=false&includeRwaData=true`, + ) + .reply(200, sampleTokenListLinea) + .persist(); + + const tokens = await fetchTokenListByChainId(lineaHexChain, signal); + + expect(tokens).toStrictEqual(sampleTokenListLinea); + }); + + it('should use occurrenceFloor from suggestedOccurrenceFloors for the chain', async () => { + const { signal } = new AbortController(); + cleanAll(); + resetSuggestedOccurrenceFloorsCacheForTesting(); + nockSuggestedOccurrenceFloors({ '1': 5 }); + nock(TOKEN_END_POINT_API) + .get( + `/tokens/${sampleDecimalChainId}?occurrenceFloor=5&includeNativeAssets=false&includeTokenFees=false&includeAssetType=false&includeERC20Permit=false&includeStorage=false&includeRwaData=true`, + ) + .reply(200, sampleTokenList); + + const tokens = await fetchTokenListByChainId(sampleChainId, signal); + + expect(tokens).toStrictEqual(sampleTokenList); + }); + + it('should fall back to occurrenceFloor 3 when the chain is missing from suggestedOccurrenceFloors', async () => { + const { signal } = new AbortController(); + cleanAll(); + resetSuggestedOccurrenceFloorsCacheForTesting(); + nockSuggestedOccurrenceFloors({ '59144': 1 }); + nock(TOKEN_END_POINT_API) + .get( + `/tokens/${sampleDecimalChainId}?occurrenceFloor=3&includeNativeAssets=false&includeTokenFees=false&includeAssetType=false&includeERC20Permit=false&includeStorage=false&includeRwaData=true`, + ) + .reply(200, sampleTokenList); + + const tokens = await fetchTokenListByChainId(sampleChainId, signal); + + expect(tokens).toStrictEqual(sampleTokenList); + }); + + it('should fall back to occurrenceFloor 3 when suggestedOccurrenceFloors fails', async () => { + const { signal } = new AbortController(); + cleanAll(); + resetSuggestedOccurrenceFloorsCacheForTesting(); + nock(TOKEN_END_POINT_API).get('/v1/suggestedOccurrenceFloors').reply(500); + nock(TOKEN_END_POINT_API) + .get( + `/tokens/${sampleDecimalChainId}?occurrenceFloor=3&includeNativeAssets=false&includeTokenFees=false&includeAssetType=false&includeERC20Permit=false&includeStorage=false&includeRwaData=true`, + ) + .reply(200, sampleTokenList); + + const tokens = await fetchTokenListByChainId(sampleChainId, signal); + + expect(tokens).toStrictEqual(sampleTokenList); + }); + + it('should cache suggestedOccurrenceFloors across token list fetches', async () => { + const { signal } = new AbortController(); + cleanAll(); + resetSuggestedOccurrenceFloorsCacheForTesting(); + const floorsScope = nock(TOKEN_END_POINT_API) + .get('/v1/suggestedOccurrenceFloors') + .reply(200, DEFAULT_SUGGESTED_OCCURRENCE_FLOORS); + nock(TOKEN_END_POINT_API) + .get( + `/tokens/${sampleDecimalChainId}?occurrenceFloor=3&includeNativeAssets=false&includeTokenFees=false&includeAssetType=false&includeERC20Permit=false&includeStorage=false&includeRwaData=true`, + ) + .times(2) + .reply(200, sampleTokenList); + + await fetchTokenListByChainId(sampleChainId, signal); + await fetchTokenListByChainId(sampleChainId, signal); + + expect(floorsScope.isDone()).toBe(true); + }); + + it('should correctly filter linea tokens: include if has lineaTeam OR >= 3 aggregators', async () => { + const { signal } = new AbortController(); + const lineaChainId = 59144; + const lineaHexChain = toHex(lineaChainId); + + const mixedTokens = [ + { + // Should be included (has lineaTeam) + address: '0x1', + symbol: 'T1', + decimals: 18, + aggregators: ['lineaTeam', 'other'], + }, + { + // Should be included (no lineaTeam, but 3 aggregators) + address: '0x2', + symbol: 'T2', + decimals: 18, + aggregators: ['a1', 'a2', 'a3'], + }, + { + // Should be excluded (no lineaTeam, only 2 aggregators) + address: '0x3', + symbol: 'T3', + decimals: 18, + aggregators: ['a1', 'a2'], + }, + ]; + + nock(TOKEN_END_POINT_API) + .get( + `/tokens/${lineaChainId}?occurrenceFloor=1&includeNativeAssets=false&includeTokenFees=false&includeAssetType=false&includeERC20Permit=false&includeStorage=false&includeRwaData=true`, + ) + .reply(200, mixedTokens) + .persist(); + + const tokens = (await fetchTokenListByChainId(lineaHexChain, signal)) as { + address: string; + }[]; + + expect(tokens).toHaveLength(2); + expect(tokens.find((token) => token.address === '0x1')).toBeDefined(); + expect(tokens.find((token) => token.address === '0x2')).toBeDefined(); + expect(tokens.find((token) => token.address === '0x3')).toBeUndefined(); + }); + it('should return undefined if the fetch is aborted', async () => { const abortController = new AbortController(); nock(TOKEN_END_POINT_API) - .get(`/tokens/${sampleDecimalChainId}`) + .get( + `/tokens/${sampleDecimalChainId}?occurrenceFloor=3&includeNativeAssets=false&includeTokenFees=false&includeAssetType=false&includeERC20Permit=false&includeStorage=false&includeRwaData=true`, + ) // well beyond time it will take to abort .delay(ONE_SECOND_IN_MILLISECONDS) .reply(200, sampleTokenList) @@ -171,7 +495,9 @@ describe('Token service', () => { it('should return undefined if the fetch fails with a network error', async () => { const { signal } = new AbortController(); nock(TOKEN_END_POINT_API) - .get(`/tokens/${sampleDecimalChainId}`) + .get( + `/tokens/${sampleDecimalChainId}?occurrenceFloor=3&includeNativeAssets=false&includeTokenFees=false&includeAssetType=false&includeERC20Permit=false&includeStorage=false&includeRwaData=true`, + ) .replyWithError('Example network error') .persist(); @@ -183,7 +509,9 @@ describe('Token service', () => { it('should return undefined if the fetch fails with an unsuccessful status code', async () => { const { signal } = new AbortController(); nock(TOKEN_END_POINT_API) - .get(`/tokens/${sampleDecimalChainId}`) + .get( + `/tokens/${sampleDecimalChainId}?occurrenceFloor=3&includeNativeAssets=false&includeTokenFees=false&includeAssetType=false&includeERC20Permit=false&includeStorage=false&includeRwaData=true`, + ) .reply(500) .persist(); @@ -195,7 +523,9 @@ describe('Token service', () => { it('should return undefined if the fetch fails with a timeout', async () => { const { signal } = new AbortController(); nock(TOKEN_END_POINT_API) - .get(`/tokens/${sampleDecimalChainId}`) + .get( + `/tokens/${sampleDecimalChainId}?occurrenceFloor=3&includeNativeAssets=false&includeTokenFees=false&includeAssetType=false&includeERC20Permit=false&includeStorage=false&includeRwaData=true`, + ) // well beyond timeout .delay(ONE_SECOND_IN_MILLISECONDS) .reply(200, sampleTokenList) @@ -214,7 +544,7 @@ describe('Token service', () => { const { signal } = new AbortController(); nock(TOKEN_END_POINT_API) .get( - `/token/${sampleDecimalChainId}?address=0x514910771af9ca656af840dff83e8264ecf986ca`, + `/token/${sampleDecimalChainId}?address=0x514910771af9ca656af840dff83e8264ecf986ca&includeRwaData=true`, ) .reply(200, sampleToken) .persist(); @@ -310,15 +640,1416 @@ describe('Token service', () => { }); }); - it('should call the tokens api and return undefined', async () => { - const { signal } = new AbortController(); - nock(TOKEN_END_POINT_API) - .get(`/tokens/${sampleDecimalChainId}`) - .reply(404, undefined) - .persist(); + describe('searchTokens', () => { + it('should call the search api and return the list of matching tokens for single chain', async () => { + const searchQuery = 'USD'; + const mockResponse = { + count: sampleSearchResults.length, + data: sampleSearchResults, + pageInfo: { hasNextPage: false, endCursor: null }, + }; + + nock(TOKEN_END_POINT_API) + .get( + `/tokens/search?networks=${encodeURIComponent(sampleCaipChainId)}&query=${searchQuery}&first=10&includeMarketData=false&includeRwaData=true`, + ) + .reply(200, mockResponse) + .persist(); + + const results = await searchTokens([sampleCaipChainId], searchQuery); + + expect(results).toStrictEqual({ + count: sampleSearchResults.length, + data: sampleSearchResults, + pageInfo: { hasNextPage: false, endCursor: null }, + }); + }); + + it('should call the search api with custom limit parameter', async () => { + const searchQuery = 'USDC'; + const customLimit = 5; + const mockResponse = { + count: 1, + data: [sampleSearchResults[0]], + pageInfo: { hasNextPage: false, endCursor: null }, + }; + + nock(TOKEN_END_POINT_API) + .get( + `/tokens/search?networks=${encodeURIComponent(sampleCaipChainId)}&query=${searchQuery}&first=${customLimit}&includeMarketData=false&includeRwaData=true`, + ) + .reply(200, mockResponse) + .persist(); + + const results = await searchTokens([sampleCaipChainId], searchQuery, { + limit: customLimit, + }); + + expect(results).toStrictEqual({ + count: 1, + data: [sampleSearchResults[0]], + pageInfo: { hasNextPage: false, endCursor: null }, + }); + }); + + it('should properly encode search queries with special characters', async () => { + const searchQuery = 'USD Coin & Token'; + const encodedQuery = 'USD%20Coin%20%26%20Token'; + const mockResponse = { + count: sampleSearchResults.length, + data: sampleSearchResults, + pageInfo: { hasNextPage: false, endCursor: null }, + }; + + nock(TOKEN_END_POINT_API) + .get( + `/tokens/search?networks=${encodeURIComponent(sampleCaipChainId)}&query=${encodedQuery}&first=10&includeMarketData=false&includeRwaData=true`, + ) + .reply(200, mockResponse) + .persist(); + + const results = await searchTokens([sampleCaipChainId], searchQuery); + + expect(results).toStrictEqual({ + count: sampleSearchResults.length, + data: sampleSearchResults, + pageInfo: { hasNextPage: false, endCursor: null }, + }); + }); + + it('should search across multiple chains in a single request', async () => { + const searchQuery = 'USD'; + const encodedChainIds = [sampleCaipChainId, polygonCaipChainId] + .map((id) => encodeURIComponent(id)) + .join(','); + const mockResponse = { + count: sampleSearchResults.length, + data: sampleSearchResults, + pageInfo: { hasNextPage: false, endCursor: null }, + }; + + nock(TOKEN_END_POINT_API) + .get( + `/tokens/search?networks=${encodedChainIds}&query=${searchQuery}&first=10&includeMarketData=false&includeRwaData=true`, + ) + .reply(200, mockResponse) + .persist(); + + const results = await searchTokens( + [sampleCaipChainId, polygonCaipChainId], + searchQuery, + ); + + expect(results).toStrictEqual({ + count: sampleSearchResults.length, + data: sampleSearchResults, + pageInfo: { hasNextPage: false, endCursor: null }, + }); + }); + + 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( + `/tokens/search?networks=${encodeURIComponent(sampleCaipChainId)}&query=${searchQuery}&first=10&includeMarketData=false&includeRwaData=true`, + ) + .replyWithError('Example network error') + .persist(); - const tokens = await fetchTokenListByChainId(sampleChainId, signal); + const result = await searchTokens([sampleCaipChainId], searchQuery); - expect(tokens).toBeUndefined(); + expect(result).toStrictEqual({ + count: 0, + data: [], + error: expect.stringContaining('Example network error'), + }); + }); + + it('should return empty array with error if the fetch fails with 400 error', async () => { + const searchQuery = 'USD'; + nock(TOKEN_END_POINT_API) + .get( + `/tokens/search?networks=${encodeURIComponent(sampleCaipChainId)}&query=${searchQuery}&first=10&includeMarketData=false&includeRwaData=true`, + ) + .reply(400, { error: 'Bad Request' }) + .persist(); + + const result = await searchTokens([sampleCaipChainId], searchQuery); + + expect(result).toStrictEqual({ + count: 0, + data: [], + error: expect.stringContaining("Fetch failed with status '400'"), + }); + }); + + it('should return empty array with error if the fetch fails with 500 error', async () => { + const searchQuery = 'USD'; + nock(TOKEN_END_POINT_API) + .get( + `/tokens/search?networks=${encodeURIComponent(sampleCaipChainId)}&query=${searchQuery}&first=10&includeMarketData=false&includeRwaData=true`, + ) + .reply(500) + .persist(); + + const result = await searchTokens([sampleCaipChainId], searchQuery); + + 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}&first=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 () => { + const searchQuery = 'NONEXISTENT'; + const mockResponse = { + count: 0, + data: [], + pageInfo: { hasNextPage: false, endCursor: null }, + }; + + nock(TOKEN_END_POINT_API) + .get( + `/tokens/search?networks=${encodeURIComponent(sampleCaipChainId)}&query=${searchQuery}&first=10&includeMarketData=false&includeRwaData=true`, + ) + .reply(200, mockResponse) + .persist(); + + const results = await searchTokens([sampleCaipChainId], searchQuery); + + expect(results).toStrictEqual({ + count: 0, + data: [], + pageInfo: { hasNextPage: false, endCursor: null }, + }); + }); + + it('should return empty array when no chainIds are provided', async () => { + const searchQuery = 'USD'; + const mockResponse = { + count: 0, + data: [], + pageInfo: { hasNextPage: false, endCursor: null }, + }; + + nock(TOKEN_END_POINT_API) + .get( + `/tokens/search?networks=&query=${searchQuery}&first=10&includeMarketData=false&includeRwaData=true`, + ) + .reply(200, mockResponse) + .persist(); + + const results = await searchTokens([], searchQuery); + + expect(results).toStrictEqual({ + count: 0, + data: [], + pageInfo: { hasNextPage: false, endCursor: null }, + }); + }); + + it('should handle API error responses in JSON format', async () => { + const searchQuery = 'USD'; + const errorResponse = { error: 'Invalid search query' }; + nock(TOKEN_END_POINT_API) + .get( + `/tokens/search?networks=${encodeURIComponent(sampleCaipChainId)}&query=${searchQuery}&first=10&includeMarketData=false&includeRwaData=true`, + ) + .reply(200, errorResponse) + .persist(); + + const result = await searchTokens([sampleCaipChainId], searchQuery); + + // 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 () => { + const searchQuery = 'USD'; + const solanaChainId: CaipChainId = + 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp'; + const tronChainId: CaipChainId = 'tron:728126428'; + + const multiChainIds: CaipChainId[] = [ + sampleCaipChainId, + solanaChainId, + tronChainId, + ]; + const encodedChainIds = multiChainIds + .map((id) => encodeURIComponent(id)) + .join(','); + const mockResponse = { + count: sampleSearchResults.length, + data: sampleSearchResults, + pageInfo: { hasNextPage: false, endCursor: null }, + }; + + nock(TOKEN_END_POINT_API) + .get( + `/tokens/search?networks=${encodedChainIds}&query=${searchQuery}&first=10&includeMarketData=false&includeRwaData=true`, + ) + .reply(200, mockResponse) + .persist(); + + const result = await searchTokens(multiChainIds, searchQuery); + + expect(result).toStrictEqual({ + count: sampleSearchResults.length, + data: sampleSearchResults, + pageInfo: { hasNextPage: false, endCursor: null }, + }); + }); + + it('should include market data when includeMarketData is true', async () => { + const searchQuery = 'USD'; + const mockResponse = { + count: sampleSearchResults.length, + data: sampleSearchResults, + pageInfo: { hasNextPage: false, endCursor: null }, + }; + + nock(TOKEN_END_POINT_API) + .get( + `/tokens/search?networks=${encodeURIComponent(sampleCaipChainId)}&query=${searchQuery}&first=10&includeMarketData=true&includeRwaData=true`, + ) + .reply(200, mockResponse) + .persist(); + + const results = await searchTokens([sampleCaipChainId], searchQuery, { + includeMarketData: true, + }); + + expect(results).toStrictEqual({ + count: sampleSearchResults.length, + data: sampleSearchResults, + pageInfo: { hasNextPage: false, endCursor: null }, + }); + }); + + it('should clamp limit to 50 for regular queries', async () => { + const searchQuery = 'USD'; + const largeLimit = 100; + const mockResponse = { + count: sampleSearchResults.length, + data: sampleSearchResults, + pageInfo: { hasNextPage: false, endCursor: null }, + }; + + nock(TOKEN_END_POINT_API) + .get( + `/tokens/search?networks=${encodeURIComponent(sampleCaipChainId)}&query=${searchQuery}&first=50&includeMarketData=false&includeRwaData=true`, + ) + .reply(200, mockResponse) + .persist(); + + const results = await searchTokens([sampleCaipChainId], searchQuery, { + limit: largeLimit, + }); + + expect(results).toStrictEqual({ + count: sampleSearchResults.length, + data: sampleSearchResults, + pageInfo: { hasNextPage: false, endCursor: null }, + }); + }); + + it('should allow larger limits for Ondo queries up to 500', async () => { + const searchQuery = 'Ondo Finance Token'; + const ondoLimit = 200; + const mockResponse = { + count: sampleSearchResults.length, + data: sampleSearchResults, + pageInfo: { hasNextPage: false, endCursor: null }, + }; + + nock(TOKEN_END_POINT_API) + .get( + `/tokens/search?networks=${encodeURIComponent(sampleCaipChainId)}&query=${encodeURIComponent(searchQuery)}&first=${ondoLimit}&includeMarketData=false&includeRwaData=true`, + ) + .reply(200, mockResponse) + .persist(); + + const results = await searchTokens([sampleCaipChainId], searchQuery, { + limit: ondoLimit, + }); + + expect(results).toStrictEqual({ + count: sampleSearchResults.length, + data: sampleSearchResults, + pageInfo: { hasNextPage: false, endCursor: null }, + }); + }); + + it('should clamp very large limits to 50 even for Ondo queries', async () => { + const searchQuery = 'Ondo Token'; + const veryLargeLimit = 1000; + const mockResponse = { + count: sampleSearchResults.length, + data: sampleSearchResults, + pageInfo: { hasNextPage: false, endCursor: null }, + }; + + nock(TOKEN_END_POINT_API) + .get( + `/tokens/search?networks=${encodeURIComponent(sampleCaipChainId)}&query=${encodeURIComponent(searchQuery)}&first=50&includeMarketData=false&includeRwaData=true`, + ) + .reply(200, mockResponse) + .persist(); + + const results = await searchTokens([sampleCaipChainId], searchQuery, { + limit: veryLargeLimit, + }); + + expect(results).toStrictEqual({ + count: sampleSearchResults.length, + data: sampleSearchResults, + pageInfo: { hasNextPage: false, endCursor: null }, + }); + }); + + it('should use default limit of 10 when limit is not provided', async () => { + const searchQuery = 'USD'; + const mockResponse = { + count: sampleSearchResults.length, + data: sampleSearchResults, + pageInfo: { hasNextPage: false, endCursor: null }, + }; + + nock(TOKEN_END_POINT_API) + .get( + `/tokens/search?networks=${encodeURIComponent(sampleCaipChainId)}&query=${searchQuery}&first=10&includeMarketData=false&includeRwaData=true`, + ) + .reply(200, mockResponse) + .persist(); + + const results = await searchTokens([sampleCaipChainId], searchQuery); + + expect(results).toStrictEqual({ + count: sampleSearchResults.length, + data: sampleSearchResults, + pageInfo: { hasNextPage: false, endCursor: null }, + }); + }); + + it('should forward pageInfo and totalCount when the API returns them', async () => { + const searchQuery = 'USD'; + const mockResponse = { + count: sampleSearchResults.length, + totalCount: 2343, + data: sampleSearchResults, + pageInfo: { hasNextPage: true, endCursor: 'MA==' }, + }; + + nock(TOKEN_END_POINT_API) + .get( + `/tokens/search?networks=${encodeURIComponent(sampleCaipChainId)}&query=${searchQuery}&first=10&includeMarketData=false&includeRwaData=true`, + ) + .reply(200, mockResponse) + .persist(); + + const results = await searchTokens([sampleCaipChainId], searchQuery); + + expect(results).toStrictEqual({ + count: sampleSearchResults.length, + totalCount: 2343, + data: sampleSearchResults, + pageInfo: { hasNextPage: true, endCursor: 'MA==' }, + }); + }); + + it('should send the after cursor as a query parameter', async () => { + const searchQuery = 'USD'; + const cursor = 'MA=='; + const mockResponse = { + count: sampleSearchResults.length, + totalCount: 2343, + data: sampleSearchResults, + pageInfo: { hasNextPage: false, endCursor: 'MQ==' }, + }; + + nock(TOKEN_END_POINT_API) + .get( + `/tokens/search?networks=${encodeURIComponent(sampleCaipChainId)}&query=${searchQuery}&first=10&after=${encodeURIComponent(cursor)}&includeMarketData=false&includeRwaData=true`, + ) + .reply(200, mockResponse) + .persist(); + + const results = await searchTokens([sampleCaipChainId], searchQuery, { + after: cursor, + }); + + expect(results).toStrictEqual({ + count: sampleSearchResults.length, + totalCount: 2343, + data: sampleSearchResults, + pageInfo: { hasNextPage: false, endCursor: 'MQ==' }, + }); + }); + + it('should omit pageInfo and totalCount when the API does not return them', async () => { + const searchQuery = 'USD'; + const mockResponse = { + count: sampleSearchResults.length, + data: sampleSearchResults, + }; + + nock(TOKEN_END_POINT_API) + .get( + `/tokens/search?networks=${encodeURIComponent(sampleCaipChainId)}&query=${searchQuery}&first=10&includeMarketData=false&includeRwaData=true`, + ) + .reply(200, mockResponse) + .persist(); + + const results = await searchTokens([sampleCaipChainId], searchQuery); + + expect(results).toStrictEqual({ + count: sampleSearchResults.length, + data: sampleSearchResults, + }); + expect(results).not.toHaveProperty('pageInfo'); + expect(results).not.toHaveProperty('totalCount'); + }); + }); + + describe('getTrendingTokens', () => { + const sampleTrendingTokens = [ + { + assetId: 'eip155:1/erc20:0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48', + name: 'USDC', + symbol: 'USDC', + decimals: 6, + price: '1.00294333595976', + aggregatedUsdVolume: 455616484.38, + marketCap: 75877371441.07, + }, + { + assetId: 'eip155:1/erc20:0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2', + name: 'Wrapped Ether', + symbol: 'WETH', + decimals: 18, + price: '3406.01599421582', + aggregatedUsdVolume: 358982988.74, + marketCap: 7610628690.4, + }, + ]; + + const sampleTrendingTokensWithSecurityData = [ + { + assetId: 'eip155:1/erc20:0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2', + name: 'Wrapped Ether', + symbol: 'WETH', + decimals: 18, + price: '2076.8761460147', + aggregatedUsdVolume: 563290706.83, + marketCap: 338433.56, + labels: ['blue_chip'], + priceChangePct: { + m5: '0', + m15: '0.195', + m30: '0.706', + h1: '3.39', + h6: '6.26', + h24: '6.7', + }, + securityData: { + resultType: 'Verified', + maliciousScore: '0.0', + fees: { + transfer: 0, + transferFeeMaxAmount: null, + buy: 0, + sell: 0, + }, + features: [ + { + featureId: 'EXTERNAL_FUNCTIONS', + type: 'Info', + description: + 'External calls make this token contract highly dependent on other contracts', + }, + { + featureId: 'HIGH_REPUTATION_TOKEN', + type: 'Benign', + description: 'Token with verified high reputation', + }, + { + featureId: 'VERIFIED_CONTRACT', + type: 'Info', + description: 'The token contract is verified', + }, + ], + financialStats: { + supply: 2.0555493268851862e24, + topHolders: [ + { + label: 'contract', + name: null, + address: '0xf04a5cc80b1e94c69b48f5ee68a08cd2f09a7c3e', + holdingPercentage: 21.962, + }, + { + label: 'contract', + name: null, + address: '0x2f0b23f53734252bda2277357e97e1517d6b042a', + holdingPercentage: 11.953, + }, + ], + holdersCount: 2877494, + tradeVolume24h: 801557137, + lockedLiquidityPct: 0, + markets: [ + { + marketType: 'AMM', + marketName: 'uniswap_v3', + pairName: 'WETH / USDC', + reserveUSD: 94676995.1127, + }, + { + marketType: 'AMM', + marketName: 'uniswap_v3', + pairName: 'WETH / USDT', + reserveUSD: 57330581.2498, + }, + ], + }, + metadata: { + externalLinks: { + homepage: 'https://ethereum.org/en/wrapped-eth', + twitterPage: null, + telegramChannelId: null, + }, + }, + created: '2017-12-12T11:17:35', + }, + }, + { + assetId: 'eip155:1/erc20:0x2260fac5e5542a773aa44fbcfedf7c193bc2c599', + name: 'Wrapped Bitcoin', + symbol: 'WBTC', + decimals: 8, + price: '71179.754177197', + aggregatedUsdVolume: 133023037.36, + marketCap: 8533496716, + priceChangePct: { + m5: '0.13', + m15: '0.035', + m30: '0.702', + h1: '2.67', + h6: '5.53', + h24: '7.15', + }, + securityData: { + resultType: 'Verified', + maliciousScore: '0.0', + fees: { + transfer: 0, + transferFeeMaxAmount: null, + buy: 0, + sell: null, + }, + features: [ + { + featureId: 'IS_MINTABLE', + type: 'Info', + description: 'Token is mintable', + }, + { + featureId: 'HIGH_REPUTATION_TOKEN', + type: 'Benign', + description: 'Token with verified high reputation', + }, + { + featureId: 'TRANSFER_PAUSEABLE', + type: 'Info', + description: + 'The token owner has the authority to suspend or freeze trading, rendering the token non-tradable and preventing buying or selling', + }, + { + featureId: 'VERIFIED_CONTRACT', + type: 'Info', + description: 'The token contract is verified', + }, + ], + financialStats: { + supply: 11995665562622, + topHolders: [ + { + label: 'contract', + name: null, + address: '0x5ee5bf7ae06d1be5997a1a72006fe6c607ec6de8', + holdingPercentage: 33.806, + }, + ], + holdersCount: 147805, + tradeVolume24h: 164416843, + lockedLiquidityPct: 0, + markets: [ + { + marketType: 'UNKNOWN', + marketName: 'curve', + pairName: 'crvUSD / WBTC', + reserveUSD: 94306532.7221, + }, + ], + }, + metadata: { + externalLinks: { + homepage: 'https://www.wbtc.network/', + twitterPage: 'WrappedBTC', + telegramChannelId: 'wbtc_community', + }, + }, + created: '2018-11-24T21:45:52', + }, + }, + ]; + it('returns empty array if no chains are provided', async () => { + const result = await getTrendingTokens({ chainIds: [] }); + expect(result).toStrictEqual([]); + }); + + it('returns empty array if api returns non-array response', async () => { + nock(TOKEN_END_POINT_API) + .get( + `/v3/tokens/trending?chainIds=${encodeURIComponent(sampleCaipChainId)}&includeRwaData=true&usePriceApiData=true`, + ) + .reply(200, { error: 'Invalid response' }) + .persist(); + + const result = await getTrendingTokens({ chainIds: [sampleCaipChainId] }); + expect(result).toStrictEqual([]); + }); + + it('returns empty array if the fetch fails', async () => { + nock(TOKEN_END_POINT_API) + .get( + `/v3/tokens/trending?chainIds=${encodeURIComponent(sampleCaipChainId)}&includeRwaData=true&usePriceApiData=true`, + ) + .reply(500) + .persist(); + + const result = await getTrendingTokens({ chainIds: [sampleCaipChainId] }); + expect(result).toStrictEqual([]); + }); + + it('returns the list of trending tokens if the fetch succeeds', async () => { + const testChainId = 'eip155:1'; + const sort: SortTrendingBy = 'm5_trending'; + const testMinLiquidity = 1000000; + const testMinVolume24hUsd = 1000000; + const testMaxVolume24hUsd = 1000000; + const testMinMarketCap = 1000000; + const testMaxMarketCap = 1000000; + nock(TOKEN_END_POINT_API) + .get( + `/v3/tokens/trending?chainIds=${encodeURIComponent(testChainId)}&sort=${sort}&minLiquidity=${testMinLiquidity}&minVolume24hUsd=${testMinVolume24hUsd}&maxVolume24hUsd=${testMaxVolume24hUsd}&minMarketCap=${testMinMarketCap}&maxMarketCap=${testMaxMarketCap}&includeRwaData=true&usePriceApiData=true`, + ) + .reply(200, sampleTrendingTokens) + .persist(); + + const result = await getTrendingTokens({ + chainIds: [testChainId], + sort, + minLiquidity: testMinLiquidity, + minVolume24hUsd: testMinVolume24hUsd, + maxVolume24hUsd: testMaxVolume24hUsd, + minMarketCap: testMinMarketCap, + maxMarketCap: testMaxMarketCap, + }); + expect(result).toStrictEqual(sampleTrendingTokens); + }); + + it('returns the list of trending tokens if the fetch succeeds with no query params', async () => { + const testChainId = 'eip155:1'; + + nock(TOKEN_END_POINT_API) + .get( + `/v3/tokens/trending?chainIds=${encodeURIComponent(testChainId)}&includeRwaData=true&usePriceApiData=true`, + ) + .reply(200, sampleTrendingTokens) + .persist(); + + const result = await getTrendingTokens({ + chainIds: [testChainId], + }); + expect(result).toStrictEqual(sampleTrendingTokens); + }); + + it('returns the list of trending tokens with excludeLabels', async () => { + const testChainId = 'eip155:1'; + const testExcludeLabels = ['stable_coin', 'blue_chip']; + + nock(TOKEN_END_POINT_API) + .get( + `/v3/tokens/trending?chainIds=${encodeURIComponent(testChainId)}&excludeLabels=${testExcludeLabels.join(',')}&includeRwaData=true&usePriceApiData=true`, + ) + .reply(200, sampleTrendingTokens) + .persist(); + + const result = await getTrendingTokens({ + chainIds: [testChainId], + excludeLabels: testExcludeLabels, + }); + expect(result).toStrictEqual(sampleTrendingTokens); + }); + + it('returns the list of trending tokens with includeRwaData', async () => { + const testChainId = 'eip155:1'; + + nock(TOKEN_END_POINT_API) + .get( + `/v3/tokens/trending?chainIds=${encodeURIComponent(testChainId)}&includeRwaData=true&usePriceApiData=true`, + ) + .reply(200, sampleTrendingTokens) + .persist(); + + const result = await getTrendingTokens({ + chainIds: [testChainId], + includeRwaData: true, + }); + expect(result).toStrictEqual(sampleTrendingTokens); + }); + + it('includes includeTokenSecurityData param in the URL and returns securityData', async () => { + const testChainId = 'eip155:1'; + + nock(TOKEN_END_POINT_API) + .get( + `/v3/tokens/trending?chainIds=${encodeURIComponent(testChainId)}&includeRwaData=true&usePriceApiData=true&includeTokenSecurityData=true`, + ) + .reply(200, sampleTrendingTokensWithSecurityData) + .persist(); + + const result = await getTrendingTokens({ + chainIds: [testChainId], + includeTokenSecurityData: true, + }); + + expect(result).toStrictEqual(sampleTrendingTokensWithSecurityData); + expect(result[0].securityData?.resultType).toBe('Verified'); + expect(result[0].securityData?.maliciousScore).toBe('0.0'); + expect(result[0].securityData?.features).toHaveLength(3); + expect(result[0].securityData?.financialStats.holdersCount).toBe(2877494); + expect(result[0].securityData?.financialStats.topHolders).toHaveLength(2); + expect(result[1].securityData?.fees.sell).toBeNull(); + }); + + it('does not include includeTokenSecurityData param when not provided', async () => { + const testChainId = 'eip155:1'; + + nock(TOKEN_END_POINT_API) + .get( + `/v3/tokens/trending?chainIds=${encodeURIComponent(testChainId)}&includeRwaData=true&usePriceApiData=true`, + ) + .reply(200, sampleTrendingTokens) + .persist(); + + const result = await getTrendingTokens({ + chainIds: [testChainId], + }); + expect(result).toStrictEqual(sampleTrendingTokens); + }); + + it('combines includeTokenSecurityData with other query params', async () => { + const testChainId = 'eip155:1'; + const testMinLiquidity = 200000; + const testMinVolume = 1000000; + + nock(TOKEN_END_POINT_API) + .get( + `/v3/tokens/trending?chainIds=${encodeURIComponent(testChainId)}&sort=h6_trending&minLiquidity=${testMinLiquidity}&minVolume24hUsd=${testMinVolume}&includeRwaData=false&usePriceApiData=true&includeTokenSecurityData=true`, + ) + .reply(200, sampleTrendingTokensWithSecurityData) + .persist(); + + const result = await getTrendingTokens({ + chainIds: [testChainId], + sort: 'h6_trending', + minLiquidity: testMinLiquidity, + minVolume24hUsd: testMinVolume, + includeRwaData: false, + includeTokenSecurityData: true, + }); + expect(result).toStrictEqual(sampleTrendingTokensWithSecurityData); + }); + + it('passes unknown query params through to the URL', async () => { + const testChainId = 'eip155:1'; + + nock(TOKEN_END_POINT_API) + .get( + `/v3/tokens/trending?chainIds=${encodeURIComponent(testChainId)}&includeRwaData=true&usePriceApiData=true&vsCurrency=eur`, + ) + .reply(200, sampleTrendingTokens) + .persist(); + + const result = await getTrendingTokens({ + chainIds: [testChainId], + vsCurrency: 'eur', + }); + expect(result).toStrictEqual(sampleTrendingTokens); + }); + }); + + describe('searchTokens with includeTokenSecurityData', () => { + const sampleSearchResultsWithSecurityData = [ + { + assetId: 'eip155:1/erc20:0x95ad61b0a150d79219dcf64e1e6cc01f0b64c4ce', + symbol: 'SHIB', + decimals: 18, + name: 'SHIBA INU', + securityData: { + resultType: 'Verified', + maliciousScore: '0.0', + fees: { + transfer: 0, + transferFeeMaxAmount: null, + buy: 0, + sell: 0, + }, + features: [ + { + featureId: 'HIGH_REPUTATION_TOKEN', + type: 'Benign', + description: 'Token with verified high reputation', + }, + { + featureId: 'LISTED_ON_CENTRALIZED_EXCHANGE', + type: 'Benign', + description: + 'The token is listed on a leading, well-known centralized exchange', + }, + { + featureId: 'VERIFIED_CONTRACT', + type: 'Info', + description: 'The token contract is verified', + }, + ], + financialStats: { + supply: 9.99982335599866e32, + topHolders: [ + { + label: 'wallet', + name: null, + address: '0xdead000000000000000042069420694206942069', + holdingPercentage: 41.044, + }, + { + label: 'wallet', + name: null, + address: '0x02e2201576fbbefb52812f2ee7f08eb4774b481e', + holdingPercentage: 5.955, + }, + ], + holdersCount: 1557078, + tradeVolume24h: 107499, + lockedLiquidityPct: null, + markets: [ + { + marketType: 'UNKNOWN', + marketName: 'shibaswap', + pairName: 'SHIB / WETH', + reserveUSD: 2671998.6275, + }, + { + marketType: 'AMM', + marketName: 'uniswap_v2', + pairName: 'SHIB / WETH', + reserveUSD: 540915.3049, + }, + ], + }, + metadata: { + externalLinks: { + homepage: 'https://shibatoken.com/', + twitterPage: 'shibarium_', + telegramChannelId: 'ShibaInu_Dogecoinkiller', + }, + }, + created: '2020-07-31T18:32:43', + }, + }, + ]; + + it('includes includeTokenSecurityData param in the URL and returns securityData', async () => { + const searchQuery = 'shiba'; + const mockResponse = { + count: sampleSearchResultsWithSecurityData.length, + data: sampleSearchResultsWithSecurityData, + pageInfo: { hasNextPage: false, endCursor: null }, + }; + + nock(TOKEN_END_POINT_API) + .get( + `/tokens/search?networks=${encodeURIComponent(sampleCaipChainId)}&query=${searchQuery}&first=10&includeMarketData=false&includeRwaData=true&includeTokenSecurityData=true`, + ) + .reply(200, mockResponse) + .persist(); + + const results = await searchTokens([sampleCaipChainId], searchQuery, { + includeTokenSecurityData: true, + }); + + expect(results).toStrictEqual({ + count: sampleSearchResultsWithSecurityData.length, + data: sampleSearchResultsWithSecurityData, + pageInfo: { hasNextPage: false, endCursor: null }, + }); + expect(results.data[0].securityData?.resultType).toBe('Verified'); + expect(results.data[0].securityData?.maliciousScore).toBe('0.0'); + expect(results.data[0].securityData?.features).toHaveLength(3); + expect(results.data[0].securityData?.financialStats.holdersCount).toBe( + 1557078, + ); + expect( + results.data[0].securityData?.financialStats.topHolders[0]?.address, + ).toBe('0xdead000000000000000042069420694206942069'); + expect( + results.data[0].securityData?.metadata.externalLinks.homepage, + ).toBe('https://shibatoken.com/'); + }); + + it('does not include includeTokenSecurityData param when not provided', async () => { + const searchQuery = 'shiba'; + const mockResponse = { + count: 1, + data: [ + { + assetId: + 'eip155:1/erc20:0x95ad61b0a150d79219dcf64e1e6cc01f0b64c4ce', + symbol: 'SHIB', + decimals: 18, + name: 'SHIBA INU', + }, + ], + pageInfo: { hasNextPage: false, endCursor: null }, + }; + + nock(TOKEN_END_POINT_API) + .get( + `/tokens/search?networks=${encodeURIComponent(sampleCaipChainId)}&query=${searchQuery}&first=10&includeMarketData=false&includeRwaData=true`, + ) + .reply(200, mockResponse) + .persist(); + + const results = await searchTokens([sampleCaipChainId], searchQuery); + + expect(results.data[0].securityData).toBeUndefined(); + }); + }); + + describe('fetchTokenAssets', () => { + const oneInchAssetId: CaipAssetType = + 'eip155:1/erc20:0x111111111117dc0aa78b770fa6a738034120c302'; + const wethAssetId: CaipAssetType = + 'eip155:1/erc20:0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2'; + + const sampleTokenAssets = [ + { + assetId: oneInchAssetId, + symbol: '1INCH', + name: '1INCH Token', + decimals: 18, + securityData: { + resultType: 'Verified', + maliciousScore: '0.0', + fees: { + transfer: 0, + transferFeeMaxAmount: null, + buy: 0, + sell: 0, + }, + features: [ + { + featureId: 'LISTED_ON_CENTRALIZED_EXCHANGE', + type: 'Benign', + description: + 'The token is listed on a leading, well-known centralized exchange', + }, + { + featureId: 'HIGH_REPUTATION_TOKEN', + type: 'Benign', + description: 'Token with verified high reputation', + }, + { + featureId: 'EXTERNAL_FUNCTIONS', + type: 'Info', + description: + 'External calls make this token contract highly dependent on other contracts', + }, + { + featureId: 'OWNERSHIP_RENOUNCED', + type: 'Info', + description: + 'The token owner has renounced ownership, meaning the token is no longer controlled by any entity', + }, + { + featureId: 'IS_MINTABLE', + type: 'Info', + description: 'Token is mintable', + }, + { + featureId: 'VERIFIED_CONTRACT', + type: 'Info', + description: 'The token contract is verified', + }, + ], + financialStats: { + supply: 1.499999999997e27, + topHolders: [ + { + label: 'contract', + name: null, + address: '0x9a0c8ff858d273f57072d714bca7411d717501d7', + holdingPercentage: 16.613, + }, + { + label: 'contract', + name: null, + address: '0x225d3822de44e58ee935440e0c0b829c4232086e', + holdingPercentage: 9.62, + }, + { + label: 'wallet', + name: null, + address: '0x6630444cdbd42a024da079615f3bbce8edd5a7ba', + holdingPercentage: 8.266, + }, + ], + holdersCount: 110817, + tradeVolume24h: 632418, + lockedLiquidityPct: 0, + markets: [ + { + marketType: 'AMM', + marketName: 'uniswap-v4-ethereum', + pairName: '1INCH / wstETH', + reserveUSD: 6630065.1876, + }, + { + marketType: 'AMM', + marketName: 'uniswap-v4-ethereum', + pairName: '1INCH / WBTC', + reserveUSD: 3368702.9552, + }, + ], + }, + metadata: { + externalLinks: { + homepage: 'https://1inch.com/', + twitterPage: '1inch', + telegramChannelId: 'OneInchNetwork', + }, + }, + created: '2020-12-23T18:13:31', + }, + }, + ]; + + it('returns empty array if no asset IDs are provided', async () => { + const result = await fetchTokenAssets([]); + expect(result).toStrictEqual([]); + }); + + it('fetches a single asset by ID', async () => { + nock(TOKEN_END_POINT_API) + .get(`/assets?assetIds=${encodeURIComponent(oneInchAssetId)}`) + .reply(200, sampleTokenAssets) + .persist(); + + const result = await fetchTokenAssets([oneInchAssetId]); + expect(result).toStrictEqual(sampleTokenAssets); + }); + + it('fetches multiple assets by ID', async () => { + const multipleAssets = [ + ...sampleTokenAssets, + { + assetId: wethAssetId, + symbol: 'WETH', + name: 'Wrapped Ether', + decimals: 18, + }, + ]; + const encodedIds = [oneInchAssetId, wethAssetId] + .map(encodeURIComponent) + .join(','); + + nock(TOKEN_END_POINT_API) + .get(`/assets?assetIds=${encodedIds}`) + .reply(200, multipleAssets) + .persist(); + + const result = await fetchTokenAssets([oneInchAssetId, wethAssetId]); + expect(result).toStrictEqual(multipleAssets); + expect(result).toHaveLength(2); + }); + + it('includes includeTokenSecurityData param in the URL and returns securityData', async () => { + nock(TOKEN_END_POINT_API) + .get( + `/assets?assetIds=${encodeURIComponent(oneInchAssetId)}&includeTokenSecurityData=true`, + ) + .reply(200, sampleTokenAssets) + .persist(); + + const result = await fetchTokenAssets([oneInchAssetId], { + includeTokenSecurityData: true, + }); + + expect(result).toStrictEqual(sampleTokenAssets); + expect(result[0].securityData?.resultType).toBe('Verified'); + expect(result[0].securityData?.maliciousScore).toBe('0.0'); + expect(result[0].securityData?.features).toHaveLength(6); + expect(result[0].securityData?.financialStats.holdersCount).toBe(110817); + expect(result[0].securityData?.financialStats.topHolders).toHaveLength(3); + expect(result[0].securityData?.metadata.externalLinks.homepage).toBe( + 'https://1inch.com/', + ); + expect(result[0].securityData?.metadata.externalLinks.twitterPage).toBe( + '1inch', + ); + }); + + it('includes multiple optional flags in the request URL', async () => { + nock(TOKEN_END_POINT_API) + .get( + `/assets?assetIds=${encodeURIComponent(oneInchAssetId)}&includeAggregators=true&includeCoingeckoId=true&includeLabels=true&includeMarketData=true&includeOccurrences=true&includeTokenSecurityData=true&includeRwaData=true`, + ) + .reply(200, sampleTokenAssets) + .persist(); + + const result = await fetchTokenAssets([oneInchAssetId], { + includeAggregators: true, + includeCoingeckoId: true, + includeLabels: true, + includeMarketData: true, + includeOccurrences: true, + includeTokenSecurityData: true, + includeRwaData: true, + }); + expect(result).toStrictEqual(sampleTokenAssets); + }); + + it('does not append params for undefined options', async () => { + nock(TOKEN_END_POINT_API) + .get( + `/assets?assetIds=${encodeURIComponent(oneInchAssetId)}&includeRwaData=true`, + ) + .reply(200, sampleTokenAssets) + .persist(); + + const result = await fetchTokenAssets([oneInchAssetId], { + includeRwaData: true, + }); + expect(result).toStrictEqual(sampleTokenAssets); + }); + + it.each([ + [ + 'non-array response', + (): nock.Scope => + nock(TOKEN_END_POINT_API) + .get(`/assets?assetIds=${encodeURIComponent(oneInchAssetId)}`) + .reply(200, { error: 'Invalid request' }), + ], + [ + 'network error', + (): nock.Scope => + nock(TOKEN_END_POINT_API) + .get(`/assets?assetIds=${encodeURIComponent(oneInchAssetId)}`) + .replyWithError('Example network error'), + ], + [ + '500 error', + (): nock.Scope => + nock(TOKEN_END_POINT_API) + .get(`/assets?assetIds=${encodeURIComponent(oneInchAssetId)}`) + .reply(500), + ], + ])('returns empty array on %s', async (_label, setupNock) => { + setupNock(); + const result = await fetchTokenAssets([oneInchAssetId]); + expect(result).toStrictEqual([]); + }); + }); + + describe('fetchRwas', () => { + const sampleRwasResponse = { + data: [ + { + id: 'eip155:1/erc20:0x1234567890123456789012345678901234567890', + assetId: 'eip155:1/erc20:0x1234567890123456789012345678901234567890', + symbol: 'TSLAx', + decimals: 18, + name: 'Tesla xStock', + rwaData: { + price: '342.13', + priceChange: '1.23', + marketCap: 1090000000000, + aggregatedUsdVolume: 1234567, + active: true, + ticker: 'TSLA', + instrumentType: 'stock', + custodians: ['ondo'], + industry: ['consumer discretionary'], + market: { + nextOpen: '2026-05-29T13:30:00.000Z', + nextClose: '2026-05-29T20:00:00.000Z', + }, + }, + }, + ], + count: 1, + totalCount: 1, + pageInfo: { + nextCursor: null, + hasNextPage: false, + }, + }; + + it('fetches RWAs with default params', async () => { + nock(TOKEN_END_POINT_API) + .get('/v1/rwas?limit=100') + .reply(200, sampleRwasResponse) + .persist(); + + const result = await fetchRwas(); + + expect(result).toStrictEqual(sampleRwasResponse); + }); + + it('includes supported query params and trims the search query', async () => { + nock(TOKEN_END_POINT_API) + .get( + '/v1/rwas?chainIds=eip155%3A1%2Ceip155%3A137&query=Tesla&active=true&custodian=ondo&type=stock&industry=consumer+discretionary&sortBy=market_cap_desc&limit=25&after=cursor-1', + ) + .reply(200, sampleRwasResponse) + .persist(); + + const result = await fetchRwas({ + chainIds: ['eip155:1', 'eip155:137'], + query: ' Tesla ', + sortBy: 'market_cap_desc', + limit: 25, + after: 'cursor-1', + active: true, + custodian: 'ondo', + type: 'stock', + industry: 'consumer discretionary', + }); + + expect(result).toStrictEqual(sampleRwasResponse); + }); + + it('omits blank and undefined query params', async () => { + nock(TOKEN_END_POINT_API) + .get('/v1/rwas?active=false&limit=100') + .reply(200, sampleRwasResponse) + .persist(); + + const result = await fetchRwas({ + query: ' ', + active: false, + }); + + expect(result).toStrictEqual(sampleRwasResponse); + }); + + it('passes unknown query params through to the URL', async () => { + nock(TOKEN_END_POINT_API) + .get( + '/v1/rwas?chainIds=eip155%3A1&limit=100&foo=bar&enabled=true&page=2', + ) + .reply(200, sampleRwasResponse) + .persist(); + + const result = await fetchRwas({ + chainIds: ['eip155:1'], + foo: 'bar', + enabled: true, + page: 2, + }); + + expect(result).toStrictEqual(sampleRwasResponse); + }); + + it('passes through offhours field when present in the response', async () => { + const responseWithOffhours = { + ...sampleRwasResponse, + data: [ + { + ...sampleRwasResponse.data[0], + rwaData: { + ...sampleRwasResponse.data[0].rwaData, + offhours: { + nextOpen: '2026-08-08T00:05:00Z', + nextClose: '2026-08-09T23:55:00Z', + }, + }, + }, + ], + }; + + nock(TOKEN_END_POINT_API) + .get('/v1/rwas?limit=100') + .reply(200, responseWithOffhours) + .persist(); + + const result = await fetchRwas(); + + expect(result.data[0].rwaData.offhours).toStrictEqual({ + nextOpen: '2026-08-08T00:05:00Z', + nextClose: '2026-08-09T23:55:00Z', + }); + }); + + it('returns rwaData without offhours field when asset does not support off-hours trading', async () => { + nock(TOKEN_END_POINT_API) + .get('/v1/rwas?limit=100') + .reply(200, sampleRwasResponse) + .persist(); + + const result = await fetchRwas(); + + expect(result.data[0].rwaData.offhours).toBeUndefined(); + }); + + it('throws if the fetch fails', async () => { + nock(TOKEN_END_POINT_API).get('/v1/rwas?limit=100').reply(500); + + await expect(fetchRwas()).rejects.toThrow( + "Fetch failed with status '500'", + ); + }); }); }); diff --git a/packages/assets-controllers/src/token-service.ts b/packages/assets-controllers/src/token-service.ts index e0654597416..206c33fc7a7 100644 --- a/packages/assets-controllers/src/token-service.ts +++ b/packages/assets-controllers/src/token-service.ts @@ -1,20 +1,126 @@ -import { convertHexToDecimal, timeoutFetch } from '@metamask/controller-utils'; -import type { Hex } from '@metamask/utils'; +import { + ChainId, + convertHexToDecimal, + handleFetch, + timeoutFetch, +} from '@metamask/controller-utils'; +import type { CaipAssetType, CaipChainId, Hex } from '@metamask/utils'; -import { isTokenListSupportedForNetwork } from './assetsUtil'; +import { isTokenListSupportedForNetwork } from './assetsUtil.js'; -export const TOKEN_END_POINT_API = 'https://token-api.metaswap.codefi.network'; +export const TOKEN_END_POINT_API = 'https://token.api.cx.metamask.io'; export const TOKEN_METADATA_NO_SUPPORT_ERROR = 'TokenService Error: Network does not support fetchTokenMetadata'; +/** + * Per-chain suggested occurrence floors endpoint. Used as the `occurrenceFloor` + * query param when fetching token lists (aligned with assets-controller + * TokenDataSource / TokensApiClient). + */ +const SUGGESTED_OCCURRENCE_FLOORS_URL = `${TOKEN_END_POINT_API}/v1/suggestedOccurrenceFloors`; + +/** + * Fallback `occurrenceFloor` when `/v1/suggestedOccurrenceFloors` has no entry + * for the chain, or the floors request fails. + */ +const DEFAULT_OCCURRENCE_FLOOR = 3; + +/** How long to keep suggested occurrence floors cached (1 hour). */ +const SUGGESTED_OCCURRENCE_FLOORS_CACHE_TTL_MS = 60 * 60_000; + +/** Decimal chain ID → suggested occurrence floor. */ +let suggestedOccurrenceFloorsCache: Record | undefined; + +/** Timestamp of the last successful (or failed-open) floors fetch. */ +let suggestedOccurrenceFloorsCachedAt = 0; + +/** In-flight floors request shared across concurrent callers. */ +let suggestedOccurrenceFloorsRefreshPromise: + | Promise> + | undefined; + +/** + * Clears the suggested occurrence floors cache. Exported for unit tests only. + */ +export function resetSuggestedOccurrenceFloorsCacheForTesting(): void { + suggestedOccurrenceFloorsCache = undefined; + suggestedOccurrenceFloorsCachedAt = 0; + suggestedOccurrenceFloorsRefreshPromise = undefined; +} + +/** + * Fetch `/v1/suggestedOccurrenceFloors` with a 1h in-memory cache. Failures + * return an empty map so callers fall back to {@link DEFAULT_OCCURRENCE_FLOOR}. + * + * @returns Map of decimal chain ID → suggested occurrence floor. + */ +async function getSuggestedOccurrenceFloors(): Promise> { + const now = Date.now(); + if ( + suggestedOccurrenceFloorsCache !== undefined && + now - suggestedOccurrenceFloorsCachedAt < + SUGGESTED_OCCURRENCE_FLOORS_CACHE_TTL_MS + ) { + return suggestedOccurrenceFloorsCache; + } + + if (suggestedOccurrenceFloorsRefreshPromise !== undefined) { + return suggestedOccurrenceFloorsRefreshPromise; + } + + suggestedOccurrenceFloorsRefreshPromise = (async (): Promise< + Record + > => { + try { + const response = await fetch(SUGGESTED_OCCURRENCE_FLOORS_URL); + if (response.ok) { + const data = (await response.json()) as unknown; + suggestedOccurrenceFloorsCache = + data && typeof data === 'object' && !Array.isArray(data) + ? (data as Record) + : {}; + } else { + suggestedOccurrenceFloorsCache = {}; + } + } catch { + suggestedOccurrenceFloorsCache = {}; + } finally { + suggestedOccurrenceFloorsCachedAt = Date.now(); + suggestedOccurrenceFloorsRefreshPromise = undefined; + } + return suggestedOccurrenceFloorsCache ?? {}; + })(); + + return suggestedOccurrenceFloorsRefreshPromise; +} + +/** + * Resolve the `occurrenceFloor` query param for a chain from Token API + * `/v1/suggestedOccurrenceFloors`. Falls back to + * {@link DEFAULT_OCCURRENCE_FLOOR} when the chain is missing or the request + * fails. + * + * @param chainId - Hex chain ID. + * @returns Occurrence floor to send to `/tokens/{chainId}`. + */ +async function getOccurrenceFloor(chainId: Hex): Promise { + const floors = await getSuggestedOccurrenceFloors(); + const decimalChainId = String(convertHexToDecimal(chainId)); + return floors[decimalChainId] ?? DEFAULT_OCCURRENCE_FLOOR; +} + /** * Get the tokens URL for a specific network. * * @param chainId - The chain ID of the network the tokens requested are on. * @returns The tokens URL. */ -function getTokensURL(chainId: Hex) { - return `${TOKEN_END_POINT_API}/tokens/${convertHexToDecimal(chainId)}`; +async function getTokensURL(chainId: Hex): Promise { + const occurrenceFloor = await getOccurrenceFloor(chainId); + + return `${TOKEN_END_POINT_API}/tokens/${convertHexToDecimal( + chainId, + )}?occurrenceFloor=${occurrenceFloor}&includeNativeAssets=false&includeTokenFees=false&includeAssetType=false&includeERC20Permit=false&includeStorage=false&includeRwaData=true`; } /** @@ -24,10 +130,208 @@ function getTokensURL(chainId: Hex) { * @param tokenAddress - The token address. * @returns The token metadata URL. */ -function getTokenMetadataURL(chainId: Hex, tokenAddress: string) { +function getTokenMetadataURL(chainId: Hex, tokenAddress: string): string { return `${TOKEN_END_POINT_API}/token/${convertHexToDecimal( chainId, - )}?address=${tokenAddress}`; + )}?address=${tokenAddress}&includeRwaData=true`; +} + +/** + * The sort by field for trending tokens. + */ +export type SortTrendingBy = + | 'm5_trending' + | 'h1_trending' + | 'h6_trending' + | 'h24_trending'; + +/** + * Get the token search URL for the given networks and search query. + * + * @param options - Options for getting token search URL. + * @param options.chainIds - Array of CAIP format chain IDs (e.g., 'eip155:1', 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp'). + * @param options.query - The search query (token name, symbol, or address). + * @param options.limit - Optional limit for the number of results (defaults to 10). + * @param options.after - Optional cursor for fetching the next page of results. + * @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). + * @param options.includeTokenSecurityData - Optional flag to include token security data in the results (defaults to false). + * @returns The token search URL. + */ +function getTokenSearchURL(options: { + chainIds: CaipChainId[]; + query: string; + limit?: number; + after?: string; + includeMarketData?: boolean; + includeRwaData?: boolean; + includeTokenSecurityData?: boolean; +}): string { + const { chainIds, query, limit, after, ...optionalParams } = options; + const encodedQuery = encodeURIComponent(query); + const encodedChainIds = chainIds + .map((id) => encodeURIComponent(id)) + .join(','); + const queryParams = new URLSearchParams(); + Object.entries(optionalParams).forEach(([key, value]) => { + if (value !== undefined) { + queryParams.append(key, String(value)); + } + }); + + let numberOfItems; + if (limit) { + if (limit <= 50) { + numberOfItems = limit; + } else if (query.includes('Ondo') && limit <= 500) { + // There is an exception on the API side https://github.com/consensys-vertical-apps/va-mmcx-token-api/pull/287 + numberOfItems = limit; + } else { + numberOfItems = 50; + } + } + + return `${TOKEN_END_POINT_API}/tokens/search?networks=${encodedChainIds}&query=${encodedQuery}${numberOfItems ? `&first=${numberOfItems}` : ''}${after ? `&after=${encodeURIComponent(after)}` : ''}&${queryParams.toString()}`; +} + +/** + * Get the token assets URL for the given asset IDs. + * + * @param options - Options for getting token assets. + * @param options.assetIds - Array of CAIP-19 asset IDs (e.g., ['eip155:1/erc20:0x...', 'solana:5eykt.../slip44:501']). + * @param options.includeAggregators - Optional flag to include aggregator list in the results (defaults to false). + * @param options.includeCoingeckoId - Optional flag to include CoinGecko ID in the results (defaults to false). + * @param options.includeLabels - Optional flag to include labels in the results (defaults to false). + * @param options.includeMarketData - Optional flag to include market data in the results (defaults to false). + * @param options.includeOccurrences - Optional flag to include occurrence count in the results (defaults to false). + * @param options.includeTokenSecurityData - Optional flag to include token security data in the results (defaults to false). + * @param options.includeRwaData - Optional flag to include RWA data in the results (defaults to false). + * @returns The token assets URL. + */ +function getTokenAssetsURL(options: { + assetIds: CaipAssetType[]; + includeAggregators?: boolean; + includeCoingeckoId?: boolean; + includeLabels?: boolean; + includeMarketData?: boolean; + includeOccurrences?: boolean; + includeTokenSecurityData?: boolean; + includeRwaData?: boolean; +}): string { + const { assetIds, ...queryOptions } = options; + const encodedAssetIds = assetIds + .map((id) => encodeURIComponent(id)) + .join(','); + const queryParams = new URLSearchParams(); + Object.entries(queryOptions).forEach(([key, value]) => { + if (value !== undefined) { + queryParams.append(key, String(value)); + } + }); + return `${TOKEN_END_POINT_API}/assets?assetIds=${encodedAssetIds}${queryParams.toString() ? `&${queryParams.toString()}` : ''}`; +} + +/** + * Get the RWAs URL for the given query params. + * + * @param options - Options for getting RWAs. + * @returns The RWAs URL. + */ +function getRwasURL(options: FetchRwasParams): string { + const { + chainIds, + query: searchQuery, + active, + custodian, + type, + industry, + sortBy, + limit = 100, + after, + ...additionalParams + } = options; + const trimmedSearchQuery = searchQuery?.trim(); + const queryParams = new URLSearchParams(); + + Object.entries({ + ...(chainIds?.length ? { chainIds } : {}), + ...(trimmedSearchQuery && { query: trimmedSearchQuery }), + ...(active === undefined ? {} : { active }), + ...(custodian && { custodian }), + ...(type && { type }), + ...(industry && { industry }), + ...(sortBy && { sortBy }), + limit, + ...(after && { after }), + ...additionalParams, + }).forEach(([key, value]) => { + if (value === undefined || value === '') { + return; + } + + if (Array.isArray(value)) { + if (value.length > 0) { + queryParams.append(key, value.join(',')); + } + return; + } + + queryParams.append(key, String(value)); + }); + + return `${TOKEN_END_POINT_API}/v1/rwas?${queryParams.toString()}`; +} + +/** + * Shared query-parameter type for the v3 trending tokens endpoint. + * + * Known parameters are explicitly typed for autocomplete and documentation. + * The index signature allows new API parameters to pass through without + * requiring a core release — callers can add any additional key/value and + * it will be forwarded as a query parameter. + */ +export type TrendingTokensQueryParams = { + sort?: SortTrendingBy; + minLiquidity?: number; + minVolume24hUsd?: number; + maxVolume24hUsd?: number; + minMarketCap?: number; + maxMarketCap?: number; + excludeLabels?: string[]; + includeRwaData?: boolean; + usePriceApiData?: boolean; + includeTokenSecurityData?: boolean; + [key: string]: string | number | boolean | string[] | undefined; +}; + +/** + * Get the trending tokens URL for the given networks and search query. + * + * @param options - Options bag: `chainIds` (required) plus any query params. + * @returns The trending tokens URL. + */ +function getTrendingTokensURL( + options: { chainIds: CaipChainId[] } & TrendingTokensQueryParams, +): string { + const encodedChainIds = options.chainIds + .map((id) => encodeURIComponent(id)) + .join(','); + const queryParams = new URLSearchParams(); + const { chainIds, excludeLabels, ...rest } = options; + Object.entries(rest).forEach(([key, value]) => { + if (value !== undefined) { + queryParams.append(key, String(value)); + } + }); + + // Handle excludeLabels separately to avoid encoding the commas + // The API expects: excludeLabels=stable_coin,blue_chip (not %2C) + const excludeLabelsParam = + excludeLabels !== undefined && excludeLabels.length > 0 + ? `&excludeLabels=${excludeLabels.join(',')}` + : ''; + + return `${TOKEN_END_POINT_API}/v3/tokens/trending?chainIds=${encodedChainIds}${queryParams.toString() ? `&${queryParams.toString()}` : ''}${excludeLabelsParam}`; } const tenSecondsInMilliseconds = 10_000; @@ -51,14 +355,431 @@ export async function fetchTokenListByChainId( abortSignal: AbortSignal, { timeout = defaultTimeout } = {}, ): Promise { - const tokenURL = getTokensURL(chainId); + const tokenURL = await getTokensURL(chainId); const response = await queryApi(tokenURL, abortSignal, timeout); if (response) { - return parseJsonResponse(response); + const result = await parseJsonResponse(response); + if (Array.isArray(result) && chainId === ChainId['linea-mainnet']) { + return result.filter( + (elm) => + Boolean(elm.aggregators.includes('lineaTeam')) || + elm.aggregators.length >= 3, + ); + } + return result; } return undefined; } +export type TokenRwaData = { + market?: { + nextOpen?: string; + nextClose?: string; + }; + nextPause?: { + start?: string; + end?: string; + }; + offhours?: { + nextOpen?: string; + nextClose?: string; + }; + ticker?: string; + instrumentType?: string; +}; + +export type RwaMarket = { + nextOpen?: string; + nextClose?: string; +}; + +export type RwaTokenData = { + price: string; + priceChange: string; + marketCap: number; + aggregatedUsdVolume: number; + active: boolean; + ticker: string; + instrumentType: string; + custodians: string[]; + industry: string[]; + market?: RwaMarket; + nextPause?: Record; + offhours?: { + nextOpen?: string; + nextClose?: string; + }; + sharesOutstanding?: number; + restrictedCountries?: string[]; + updatedAt?: string; + addressType?: string; +}; + +export type RwaToken = { + id: string; + assetId: CaipAssetType; + symbol: string; + decimals: number; + name: string; + rwaData: RwaTokenData; +}; + +export type RwasResponse = { + data: RwaToken[]; + count: number; + totalCount: number; + pageInfo: { + nextCursor: string | null; + hasNextPage: boolean; + }; +}; + +export type RwaSortBy = + | 'price_change_asc' + | 'price_change_desc' + | 'volume_asc' + | 'volume_desc' + | 'market_cap_asc' + | 'market_cap_desc'; + +export type FetchRwasParams = { + chainIds?: CaipChainId[]; + query?: string; + sortBy?: RwaSortBy; + limit?: number; + after?: string; + active?: boolean; + custodian?: 'ondo'; + type?: 'stock' | 'etf'; + industry?: + | 'industrials' + | 'technology' + | 'healthcare' + | 'consumer discretionary' + | 'financials' + | 'materials' + | 'utilities' + | 'energy' + | 'real estate'; + [key: string]: string | number | boolean | string[] | undefined; +}; + +export type TokenSecurityFeature = { + featureId: string; + type: string; + description: string; +}; + +export type TokenSecurityHolder = { + label: string; + name: string | null; + address: string; + holdingPercentage: number; +}; + +export type TokenSecurityMarket = { + marketType: string; + marketName: string; + pairName: string; + reserveUSD: number; +}; + +export type TokenSecurityFees = { + transfer: number; + transferFeeMaxAmount: number | null; + buy: number; + sell: number | null; +}; + +export type TokenSecurityFinancialStats = { + supply: number; + topHolders: TokenSecurityHolder[]; + holdersCount: number; + tradeVolume24h: number | null; + lockedLiquidityPct: number | null; + markets: TokenSecurityMarket[]; +}; + +export type TokenSecurityMetadata = { + externalLinks: { + homepage: string | null; + twitterPage: string | null; + telegramChannelId: string | null; + }; +}; + +export type TokenSecurityData = { + resultType: string; + maliciousScore: string; + fees: TokenSecurityFees; + features: TokenSecurityFeature[]; + financialStats: TokenSecurityFinancialStats; + metadata: TokenSecurityMetadata; + created: string; +}; + +export type TokenSearchItem = { + assetId: CaipAssetType; + name: string; + symbol: string; + decimals: number; + /** Optional RWA data for tokens when includeRwaData is true */ + rwaData?: TokenRwaData; + /** Optional security data for tokens when includeTokenSecurityData is true */ + securityData?: TokenSecurityData; +}; + +export type PageInfo = { + hasNextPage: boolean; + endCursor: string | null; +}; + +type SearchTokenOptions = { + limit?: number; + /** Cursor returned by a previous response's `pageInfo.endCursor` to fetch the next page. */ + after?: string; + includeMarketData?: boolean; + includeRwaData?: boolean; + includeTokenSecurityData?: boolean; +}; + +/** + * Search for tokens across one or more networks by query string using CAIP format chain IDs. + * + * @param chainIds - Array of CAIP format chain IDs (e.g., ['eip155:1', 'eip155:137', 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp']). + * @param query - The search query (token name, symbol, or address). + * @param options - Additional fetch options. + * @param options.limit - The maximum number of results to return. + * @param options.after - Cursor from a previous response's `pageInfo.endCursor` to fetch the next page. + * @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). + * @param options.includeTokenSecurityData - Optional flag to include token security data in the results (defaults to false). + * @returns Object containing count, totalCount, data array, optional pageInfo for pagination, and an optional error message if the request failed. + */ +export async function searchTokens( + chainIds: CaipChainId[], + query: string, + { + limit = 10, + after, + includeMarketData = false, + includeRwaData = true, + includeTokenSecurityData, + }: SearchTokenOptions = {}, +): Promise<{ + count: number; + totalCount?: number; + data: TokenSearchItem[]; + pageInfo?: PageInfo; + error?: string; +}> { + const tokenSearchURL = getTokenSearchURL({ + chainIds, + query, + limit, + after, + includeMarketData, + includeRwaData, + includeTokenSecurityData, + }); + + try { + const result: { + count: number; + totalCount?: number; + data: TokenSearchItem[]; + pageInfo?: PageInfo; + } = await handleFetch(tokenSearchURL); + + if (result && typeof result === 'object' && Array.isArray(result.data)) { + return { + count: result.count ?? result.data.length, + ...(result.totalCount !== undefined && { + totalCount: result.totalCount, + }), + data: result.data, + ...(result.pageInfo !== undefined && { pageInfo: result.pageInfo }), + }; + } + + // Handle non-expected responses + return { count: 0, data: [], error: 'Unexpected API response format' }; + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + return { count: 0, data: [], error: errorMessage }; + } +} + +/** + * The trending asset type. + */ +export type TrendingAsset = { + assetId: string; + name: string; + symbol: string; + decimals: number; + price: string; + aggregatedUsdVolume: number; + marketCap: number; + priceChangePct?: { + m5?: string; + m15?: string; + m30?: string; + h1?: string; + h6?: string; + h24?: string; + }; + labels?: string[]; + /** Optional RWA data for tokens when includeRwaData is true */ + rwaData?: TokenRwaData; + /** Optional security data for tokens when includeTokenSecurityData is true */ + securityData?: TokenSecurityData; +}; + +/** + * Get the trending tokens for the given chains. + * + * Accepts all known query parameters plus any additional ones via the + * index signature on {@link TrendingTokensQueryParams}. New API parameters + * can be passed without updating this function. + * + * @param options - Options bag: `chainIds` (required) plus any query params + * supported by the v3 trending endpoint. + * @returns The trending tokens. + * @throws Will throw if the request fails. + */ +export async function getTrendingTokens( + options: { chainIds: CaipChainId[] } & TrendingTokensQueryParams, +): Promise { + const { chainIds, ...rest } = options; + + if (chainIds.length === 0) { + console.error('No chains provided'); + return []; + } + + const trendingTokensURL = getTrendingTokensURL({ + chainIds, + ...rest, + includeRwaData: rest.includeRwaData ?? true, + usePriceApiData: rest.usePriceApiData ?? true, + }); + + try { + const result = await handleFetch(trendingTokensURL); + + // Validate that the API returned an array + if (Array.isArray(result)) { + return result; + } + + // Handle non-expected responses + console.error('Trending tokens API returned non-array response:', result); + return []; + } catch (error) { + console.error('Trending tokens request failed:', error); + return []; + } +} + +/** + * The token asset type returned by the /assets endpoint. + */ +export type TokenAsset = { + assetId: CaipAssetType; + name: string; + symbol: string; + decimals: number; + /** Aggregator list when includeAggregators is true */ + aggregators?: string[]; + /** CoinGecko ID when includeCoingeckoId is true */ + coingeckoId?: string; + /** Labels when includeLabels is true */ + labels?: string[]; + /** Occurrence count when includeOccurrences is true */ + occurrences?: number; + /** RWA data when includeRwaData is true */ + rwaData?: TokenRwaData; + /** Security data when includeTokenSecurityData is true */ + securityData?: TokenSecurityData; +}; + +type FetchTokenAssetsOptions = { + includeAggregators?: boolean; + includeCoingeckoId?: boolean; + includeLabels?: boolean; + includeMarketData?: boolean; + includeOccurrences?: boolean; + includeTokenSecurityData?: boolean; + includeRwaData?: boolean; +}; + +/** + * Fetch asset metadata for the given CAIP-19 asset IDs. + * + * @param assetIds - Array of CAIP-19 asset IDs (e.g., ['eip155:1/erc20:0x...', 'solana:5eykt.../slip44:501']). + * @param options - Additional fetch options. + * @param options.includeAggregators - Optional flag to include aggregator list in the results (defaults to false). + * @param options.includeCoingeckoId - Optional flag to include CoinGecko ID in the results (defaults to false). + * @param options.includeLabels - Optional flag to include labels in the results (defaults to false). + * @param options.includeMarketData - Optional flag to include market data in the results (defaults to false). + * @param options.includeOccurrences - Optional flag to include occurrence count in the results (defaults to false). + * @param options.includeTokenSecurityData - Optional flag to include token security data in the results (defaults to false). + * @param options.includeRwaData - Optional flag to include RWA data in the results (defaults to false). + * @returns Array of token assets, or empty array if the request failed or no IDs were provided. + */ +export async function fetchTokenAssets( + assetIds: CaipAssetType[], + { + includeAggregators, + includeCoingeckoId, + includeLabels, + includeMarketData, + includeOccurrences, + includeTokenSecurityData, + includeRwaData, + }: FetchTokenAssetsOptions = {}, +): Promise { + if (assetIds.length === 0) { + return []; + } + + const tokenAssetsURL = getTokenAssetsURL({ + assetIds, + includeAggregators, + includeCoingeckoId, + includeLabels, + includeMarketData, + includeOccurrences, + includeTokenSecurityData, + includeRwaData, + }); + + try { + const result = await handleFetch(tokenAssetsURL); + + if (Array.isArray(result)) { + return result; + } + + return []; + } catch { + return []; + } +} + +/** + * Fetch real-world asset tokens. + * + * @param params - Query params used to filter, sort, and paginate RWAs. + * @returns The paginated RWA response. + */ +export async function fetchRwas( + params: FetchRwasParams = {}, +): Promise { + return handleFetch(getRwasURL(params), { headers: { accept: '*/*' } }); +} + /** * Fetch metadata for the token address provided for a given network. This request is cancellable * using the abort signal passed in. @@ -70,19 +791,19 @@ export async function fetchTokenListByChainId( * @param options.timeout - The fetch timeout. * @returns The token metadata, or `undefined` if the request was either aborted or failed. */ -export async function fetchTokenMetadata( +export async function fetchTokenMetadata( chainId: Hex, tokenAddress: string, abortSignal: AbortSignal, { timeout = defaultTimeout } = {}, -): Promise { +): Promise { if (!isTokenListSupportedForNetwork(chainId)) { throw new Error(TOKEN_METADATA_NO_SUPPORT_ERROR); } const tokenMetadataURL = getTokenMetadataURL(chainId, tokenAddress); const response = await queryApi(tokenMetadataURL, abortSignal, timeout); if (response) { - return parseJsonResponse(response) as Promise; + return parseJsonResponse(response) as Promise; } return undefined; } @@ -107,9 +828,10 @@ async function queryApi( mode: 'cors', signal: abortSignal, cache: 'default', + headers: { + 'Content-Type': 'application/json', + }, }; - fetchOptions.headers = new window.Headers(); - fetchOptions.headers.set('Content-Type', 'application/json'); try { return await timeoutFetch(apiURL, fetchOptions, timeout); } catch (error) { diff --git a/packages/assets-controllers/src/types/vendor/multiformats.d.ts b/packages/assets-controllers/src/types/vendor/multiformats.d.ts new file mode 100644 index 00000000000..f59bde0af1f --- /dev/null +++ b/packages/assets-controllers/src/types/vendor/multiformats.d.ts @@ -0,0 +1,13 @@ +/** + * Partial type definitions for `multiformats/cid`. This only covers the parts + * used in the codebase. + */ +declare module 'multiformats/cid' { + export class CID { + static parse(cidString: string): CID; + + toV1(): CID; + + toString(): string; + } +} diff --git a/packages/assets-controllers/src/utils/create-batch-handler.test.ts b/packages/assets-controllers/src/utils/create-batch-handler.test.ts new file mode 100644 index 00000000000..f91763e4f78 --- /dev/null +++ b/packages/assets-controllers/src/utils/create-batch-handler.test.ts @@ -0,0 +1,185 @@ +import { createBatchedHandler } from './create-batch-handler.js'; + +const TEST_BATCH_MS = 50; + +describe('createBatchedHandler', () => { + beforeEach(() => { + jest.useFakeTimers(); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + const advanceAndFlush = async (): Promise => { + await jest.advanceTimersByTimeAsync(TEST_BATCH_MS); + }; + + const createNumberHandler = ( + onFlush = jest.fn().mockResolvedValue(undefined), + ): { capture: (n: number) => Promise; onFlush: jest.Mock } => { + const capture = createBatchedHandler( + (buffer) => buffer.reduce((sum, item) => sum + item, 0), + TEST_BATCH_MS, + onFlush, + ); + return { capture, onFlush }; + }; + + function createObjectHandler( + onFlush = jest.fn().mockResolvedValue(undefined), + ): { + capture: (item: { ids: number[] }) => Promise; + onFlush: jest.Mock; + } { + const capture = createBatchedHandler<{ ids: number[] }>( + (buffer) => ({ ids: buffer.flatMap((item) => item.ids) }), + TEST_BATCH_MS, + onFlush, + ); + return { capture, onFlush }; + } + + describe('buffering and aggregation', () => { + it.each([ + { + name: 'sums numbers and flushes once after debounce', + arrangeAct: (): ReturnType & { + act: () => Promise; + } => { + const ctx = createNumberHandler(); + return { + ...ctx, + act: async (): Promise => { + await Promise.all([ + ctx.capture(1), + ctx.capture(2), + ctx.capture(3), + ]); + }, + }; + }, + expectedCalls: 1, + expectedArg: 6, + }, + { + name: 'merges object arrays with custom aggregator', + arrangeAct: (): ReturnType & { + act: () => Promise; + } => { + const ctx = createObjectHandler(); + return { + ...ctx, + act: async (): Promise => { + await Promise.all([ + ctx.capture({ ids: [1] }), + ctx.capture({ ids: [2, 3] }), + ]); + }, + }; + }, + expectedCalls: 1, + expectedArg: { ids: [1, 2, 3] }, + }, + ])('$name', async ({ arrangeAct, expectedCalls, expectedArg }) => { + const { onFlush, act } = arrangeAct(); + expect(onFlush).not.toHaveBeenCalled(); + + const promiseResult = act(); + expect(onFlush).not.toHaveBeenCalled(); + + await advanceAndFlush(); + await promiseResult; + + expect(onFlush).toHaveBeenCalledTimes(expectedCalls); + expect(onFlush).toHaveBeenCalledWith(expectedArg); + }); + }); + + describe('lifecycle and edge cases', () => { + it('does not call onFlush when capture was never invoked', async () => { + const onFlush = jest.fn().mockResolvedValue(undefined); + createBatchedHandler( + (b) => b.reduce((a, item) => a + item, 0), + TEST_BATCH_MS, + onFlush, + ); + + await advanceAndFlush(); + + expect(onFlush).not.toHaveBeenCalled(); + }); + + it('resets buffer after flush and can capture again', async () => { + const { capture, onFlush } = createNumberHandler(); + + const promise1 = capture(1); + await advanceAndFlush(); + await promise1; + expect(onFlush).toHaveBeenCalledWith(1); + + const promise2 = capture(2); + await advanceAndFlush(); + await promise2; + expect(onFlush).toHaveBeenCalledTimes(2); + expect(onFlush).toHaveBeenLastCalledWith(2); + }); + + it('returns a Promise that resolves when the batch flush completes', async () => { + const { capture, onFlush } = createNumberHandler(); + + const promise = capture(1); + expect(onFlush).not.toHaveBeenCalled(); + + await advanceAndFlush(); + await promise; + expect(onFlush).toHaveBeenCalledWith(1); + }); + + const actAssertRejected = async ( + capture: (n: number) => Promise, + expectedError: unknown, + ): Promise => { + const p1 = capture(1); + const p2 = capture(2); + const settled = Promise.allSettled([p1, p2]); + + await advanceAndFlush(); + const [r1, r2] = await settled; + + expect(r1.status).toBe('rejected'); + expect((r1 as PromiseRejectedResult).reason).toBe(expectedError); + expect(r2.status).toBe('rejected'); + expect((r2 as PromiseRejectedResult).reason).toBe(expectedError); + }; + + it('rejects all callers in the same batch when onFlush throws', async () => { + const error = new Error('flush failed'); + const onFlush = jest.fn().mockRejectedValue(error); + const capture = createBatchedHandler( + (buffer) => buffer.reduce((sum, item) => sum + item, 0), + TEST_BATCH_MS, + onFlush, + ); + + await actAssertRejected(capture, error); + expect(onFlush).toHaveBeenCalled(); + }); + + it('rejects all callers in the same batch when aggregatorFn throws', async () => { + const error = new Error('aggregation failed'); + const aggregatorFn = jest.fn(() => { + throw error; + }); + const onFlush = jest.fn().mockResolvedValue(undefined); + const capture = createBatchedHandler( + aggregatorFn, + TEST_BATCH_MS, + onFlush, + ); + + await actAssertRejected(capture, error); + expect(onFlush).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/packages/assets-controllers/src/utils/create-batch-handler.ts b/packages/assets-controllers/src/utils/create-batch-handler.ts new file mode 100644 index 00000000000..4d202b9575d --- /dev/null +++ b/packages/assets-controllers/src/utils/create-batch-handler.ts @@ -0,0 +1,63 @@ +import { debounce } from 'lodash'; + +type PendingSettler = { + resolve: () => void; + reject: (reason: unknown) => void; +}; + +/** + * Batched handler: buffers arguments, debounces flush, then runs an aggregator + * on the buffer and invokes onFlush with the result. Used to coalesce rapid + * updateBalances calls without dropping params. + * + * Each call to the returned function returns a Promise that resolves when the + * flush that includes that call completes, or rejects if onFlush throws, so + * callers can await or use .catch() for error handling. + * + * @param aggregatorFn - Reduces the buffered items into one. + * @param timeframeMs - Debounce wait before flushing. + * @param onFlush - Called with the aggregated result when flush runs. + * @returns Function that accepts an item, schedules a batched flush, and returns a Promise that settles when that batch completes. + */ +export function createBatchedHandler( + aggregatorFn: (buffer: Item[]) => Item, + timeframeMs: number, + onFlush: (merged: Item) => void | Promise, +): (arg: Item) => Promise { + let eventBuffer: Item[] = []; + let pendingSettlers: PendingSettler[] = []; + + const flush = async (): Promise => { + if (eventBuffer.length === 0) { + return; + } + const buffer = eventBuffer; + const settlers = pendingSettlers; + eventBuffer = []; + pendingSettlers = []; + + try { + const merged = aggregatorFn(buffer); + await onFlush(merged); + settlers.forEach((settler) => settler.resolve()); + } catch (error) { + settlers.forEach((settler) => settler.reject(error)); + } + }; + + const debouncedFlush = debounce(flush, timeframeMs, { + leading: false, + trailing: true, + }); + + const capture = (arg: Item): Promise => { + return new Promise((resolve, reject) => { + eventBuffer.push(arg); + pendingSettlers.push({ resolve, reject }); + // eslint-disable-next-line @typescript-eslint/no-floating-promises -- Rejections are forwarded to capture() callers via pendingSettlers. + debouncedFlush(); + }); + }; + + return capture; +} diff --git a/packages/assets-controllers/src/utils/formatters.test.ts b/packages/assets-controllers/src/utils/formatters.test.ts new file mode 100644 index 00000000000..35badf5b32f --- /dev/null +++ b/packages/assets-controllers/src/utils/formatters.test.ts @@ -0,0 +1,198 @@ +import { createFormatters } from './formatters.js'; + +const locale = 'en-US'; + +const invalidValues = [ + Number.NaN, + Number.POSITIVE_INFINITY, + Number.NEGATIVE_INFINITY, +]; + +describe('formatNumber', () => { + const { formatNumber } = createFormatters({ locale }); + + it('formats a basic integer', () => { + expect(formatNumber(1234)).toBe('1,234'); + }); + + it('respects fraction digit options', () => { + expect( + formatNumber(1.2345, { + minimumFractionDigits: 2, + maximumFractionDigits: 2, + }), + ).toBe('1.23'); + }); + + it('returns empty string for invalid number', () => { + expect(formatNumber(NaN)).toBe(''); + }); +}); + +describe('formatCurrency', () => { + const { formatCurrency } = createFormatters({ locale }); + + const testCases = [ + { value: 1_234.56, expected: '$1,234.56' }, + { value: 0, expected: '$0.00' }, + { value: -42.5, expected: '-$42.50' }, + ]; + + it('formats values correctly', () => { + testCases.forEach(({ value, expected }) => { + expect(formatCurrency(value, 'USD')).toBe(expected); + }); + }); + + it('handles invalid values', () => { + invalidValues.forEach((input) => { + expect(formatCurrency(input, 'USD')).toBe(''); + }); + }); + + it('formats values correctly with different locale', () => { + const { formatCurrency: formatCurrencyGB } = createFormatters({ + locale: 'en-GB', + }); + expect(formatCurrencyGB(1234.56, 'GBP')).toBe('£1,234.56'); + }); +}); + +describe('formatCurrencyWithMinThreshold', () => { + const { formatCurrencyWithMinThreshold } = createFormatters({ locale }); + + const testCases = [ + { value: 0, expected: '$0.00' }, + + // Values below minimum threshold + { value: 0.000001, expected: '<$0.01' }, + { value: 0.001, expected: '<$0.01' }, + { value: -0.001, expected: '<$0.01' }, + + // Values at and above minimum threshold + { value: 0.01, expected: '$0.01' }, + { value: 0.1, expected: '$0.10' }, + { value: 1, expected: '$1.00' }, + { value: -0.01, expected: '-$0.01' }, + { value: -1, expected: '-$1.00' }, + { value: -100, expected: '-$100.00' }, + { value: 1_000, expected: '$1,000.00' }, + { value: 1_000_000, expected: '$1,000,000.00' }, + ]; + + it('formats values correctly', () => { + testCases.forEach(({ value, expected }) => { + expect(formatCurrencyWithMinThreshold(value, 'USD')).toBe(expected); + }); + }); + + it('handles invalid values', () => { + invalidValues.forEach((input) => { + expect(formatCurrencyWithMinThreshold(input, 'USD')).toBe(''); + }); + }); +}); + +describe('formatCurrencyTokenPrice', () => { + const { formatCurrencyTokenPrice } = createFormatters({ locale }); + + const testCases = [ + { value: 0, expected: '$0.00' }, + + // Values below minimum threshold + { value: 0.000000001, expected: '<$0.00000001' }, + { value: -0.000000001, expected: '<$0.00000001' }, + + // Values above minimum threshold but less than 1 + { value: 0.0000123, expected: '$0.0000123' }, + { value: 0.001, expected: '$0.00100' }, + { value: 0.999, expected: '$0.999' }, + + // Values at and above 1 but less than 1,000,000 + { value: 1, expected: '$1.00' }, + { value: -1, expected: '-$1.00' }, + { value: -500, expected: '-$500.00' }, + + // Values 1,000,000 and above + { value: 1_000_000, expected: '$1.00M' }, + { value: -2_000_000, expected: '-$2.00M' }, + ]; + + it('formats values correctly', () => { + testCases.forEach(({ value, expected }) => { + expect(formatCurrencyTokenPrice(value, 'USD')).toBe(expected); + }); + }); + + it('handles invalid values', () => { + invalidValues.forEach((input) => { + expect(formatCurrencyTokenPrice(input, 'USD')).toBe(''); + }); + }); +}); + +describe('formatToken', () => { + const { formatToken } = createFormatters({ locale }); + + const testCases = [ + { value: 1.234, symbol: 'ETH', expected: '1.234 ETH' }, + { value: 0, symbol: 'USDC', expected: '0 USDC' }, + { value: 1_000, symbol: 'DAI', expected: '1,000 DAI' }, + ]; + + it('formats token values', () => { + testCases.forEach(({ value, symbol, expected }) => { + expect(formatToken(value, symbol)).toBe(expected); + }); + }); + + it('handles invalid values', () => { + invalidValues.forEach((input) => { + expect(formatToken(input, 'ETH')).toBe(''); + }); + }); +}); + +describe('formatTokenQuantity', () => { + const { formatTokenQuantity } = createFormatters({ locale }); + + const testCases = [ + { value: 0, symbol: 'ETH', expected: '0 ETH' }, + + // Values below minimum threshold + { value: 0.000000001, symbol: 'ETH', expected: '<0.00001 ETH' }, + { value: -0.000000001, symbol: 'ETH', expected: '<0.00001 ETH' }, + { value: 0.0000005, symbol: 'USDC', expected: '<0.00001 USDC' }, + + // Values above minimum threshold but less than 1 + { value: 0.00001, symbol: 'ETH', expected: '0.0000100 ETH' }, + { value: 0.001234, symbol: 'BTC', expected: '0.00123 BTC' }, + { value: 0.123456, symbol: 'USDC', expected: '0.123 USDC' }, + + // Values 1 and above but less than 1,000,000 + { value: 1, symbol: 'ETH', expected: '1 ETH' }, + { value: -1, symbol: 'ETH', expected: '-1 ETH' }, + { value: -25.5, symbol: 'ETH', expected: '-25.5 ETH' }, + { value: 1.2345678, symbol: 'BTC', expected: '1.235 BTC' }, + { value: 123.45678, symbol: 'USDC', expected: '123.457 USDC' }, + { value: 999_999, symbol: 'DAI', expected: '999,999 DAI' }, + + // Values 1,000,000 and above + { value: 1_000_000, symbol: 'ETH', expected: '1.00M ETH' }, + { value: -1_500_000, symbol: 'ETH', expected: '-1.50M ETH' }, + { value: 1_234_567, symbol: 'BTC', expected: '1.23M BTC' }, + { value: 1_000_000_000, symbol: 'USDC', expected: '1.00B USDC' }, + ]; + + it('formats token quantities correctly', () => { + testCases.forEach(({ value, symbol, expected }) => { + expect(formatTokenQuantity(value, symbol)).toBe(expected); + }); + }); + + it('handles invalid values', () => { + invalidValues.forEach((input) => { + expect(formatTokenQuantity(input, 'ETH')).toBe(''); + }); + }); +}); diff --git a/packages/assets-controllers/src/utils/formatters.ts b/packages/assets-controllers/src/utils/formatters.ts new file mode 100644 index 00000000000..7dcccb1861e --- /dev/null +++ b/packages/assets-controllers/src/utils/formatters.ts @@ -0,0 +1,341 @@ +const FALLBACK_LOCALE = 'en'; + +const twoDecimals = { + minimumFractionDigits: 2, + maximumFractionDigits: 2, +}; + +const oneSignificantDigit = { + minimumSignificantDigits: 1, + maximumSignificantDigits: 1, +}; + +const threeSignificantDigits = { + minimumSignificantDigits: 3, + maximumSignificantDigits: 3, +}; + +const numberFormatCache: Record = {}; + +/** + * Get cached number format instance. + * + * @param locale - Locale string. + * @param options - Optional Intl.NumberFormat options. + * @returns Cached Intl.NumberFormat instance. + */ +function getCachedNumberFormat( + locale: string, + options: Intl.NumberFormatOptions = {}, +) { + const key = `${locale}_${JSON.stringify(options)}`; + + let format = numberFormatCache[key]; + + if (format) { + return format; + } + + try { + format = new Intl.NumberFormat(locale, options); + } catch (error) { + if (error instanceof RangeError) { + // Fallback for invalid options (e.g. currency code) + format = new Intl.NumberFormat(locale, twoDecimals); + } else { + throw error; + } + } + + numberFormatCache[key] = format; + return format; +} + +/** + * Format a number with optional Intl overrides. + * + * @param config - Configuration object with locale. + * @param config.locale - Locale string. + * @param value - Numeric value to format. + * @param options - Optional Intl.NumberFormat overrides. + * @returns Formatted number string. + */ +function formatNumber( + config: { locale: string }, + value: number | bigint | `${number}`, + options: Intl.NumberFormatOptions = {}, +) { + if (!Number.isFinite(Number(value))) { + return ''; + } + + const numberFormat = getCachedNumberFormat(config.locale, options); + + // @ts-expect-error Remove this comment once TypeScript is updated to 5.5+ + return numberFormat.format(value); +} + +/** + * Format a value as a currency string. + * + * @param config - Configuration object with locale. + * @param config.locale - Locale string. + * @param value - Numeric value to format. + * @param currency - ISO 4217 currency code. + * @param options - Optional Intl.NumberFormat overrides. + * @returns Formatted currency string. + */ +function formatCurrency( + config: { locale: string }, + value: number | bigint | `${number}`, + currency: Intl.NumberFormatOptions['currency'], + options: Intl.NumberFormatOptions = {}, +) { + if (!Number.isFinite(Number(value))) { + return ''; + } + + const numberFormat = getCachedNumberFormat(config.locale, { + style: 'currency', + currency, + ...options, + }); + + // @ts-expect-error Remove this comment once TypeScript is updated to 5.5+ + return numberFormat.format(value); +} + +/** + * Compact currency formatting (e.g. $1.2K, $3.4M). + * + * @param config - Configuration object with locale. + * @param config.locale - Locale string. + * @param value - Numeric value to format. + * @param currency - ISO 4217 currency code. + * @returns Formatted compact currency string. + */ +function formatCurrencyCompact( + config: { locale: string }, + value: number | bigint | `${number}`, + currency: Intl.NumberFormatOptions['currency'], +) { + return formatCurrency(config, value, currency, { + notation: 'compact', + ...twoDecimals, + }); +} + +/** + * Currency formatting with minimum threshold for small values. + * + * @param config - Configuration object with locale. + * @param config.locale - Locale string. + * @param value - Numeric value to format. + * @param currency - ISO 4217 currency code. + * @returns Formatted currency string with threshold handling. + */ +function formatCurrencyWithMinThreshold( + config: { locale: string }, + value: number | bigint | `${number}`, + currency: Intl.NumberFormatOptions['currency'], +) { + const minThreshold = 0.01; + const number = Number(value); + const absoluteValue = Math.abs(number); + + if (!Number.isFinite(number)) { + return ''; + } + + if (number === 0) { + return formatCurrency(config, 0, currency); + } + + if (absoluteValue < minThreshold) { + const formattedMin = formatCurrency(config, minThreshold, currency); + return `<${formattedMin}`; + } + + return formatCurrency(config, number, currency); +} + +/** + * Format a value as a token string with symbol. + * + * @param config - Configuration object with locale. + * @param config.locale - Locale string. + * @param value - Numeric value to format. + * @param symbol - Token symbol. + * @param options - Optional Intl.NumberFormat overrides. + * @returns Formatted token string. + */ +function formatToken( + config: { locale: string }, + value: number | bigint | `${number}`, + symbol: string, + options: Intl.NumberFormatOptions = {}, +) { + if (!Number.isFinite(Number(value))) { + return ''; + } + + const numberFormat = getCachedNumberFormat(config.locale, { + style: 'decimal', + ...options, + }); + + // @ts-expect-error Remove this comment once TypeScript is updated to 5.5+ + const formattedNumber = numberFormat.format(value); + + return `${formattedNumber} ${symbol}`; +} + +/** + * Format token price with varying precision based on value. + * + * @param config - Configuration object with locale. + * @param config.locale - Locale string. + * @param value - Numeric value to format. + * @param currency - ISO 4217 currency code. + * @returns Formatted token price string. + */ +function formatCurrencyTokenPrice( + config: { locale: string }, + value: number | bigint | `${number}`, + currency: Intl.NumberFormatOptions['currency'], +) { + const minThreshold = 0.00000001; + const number = Number(value); + const absoluteValue = Math.abs(number); + + if (!Number.isFinite(number)) { + return ''; + } + + if (number === 0) { + return formatCurrency(config, 0, currency); + } + + if (absoluteValue < minThreshold) { + return `<${formatCurrency(config, minThreshold, currency, oneSignificantDigit)}`; + } + + if (absoluteValue < 1) { + return formatCurrency(config, number, currency, threeSignificantDigits); + } + + if (absoluteValue < 1_000_000) { + return formatCurrency(config, number, currency); + } + + return formatCurrencyCompact(config, number, currency); +} + +/** + * Format token quantity with varying precision based on value. + * + * @param config - Configuration object with locale. + * @param config.locale - Locale string. + * @param value - Numeric value to format. + * @param symbol - Token symbol. + * @returns Formatted token quantity string. + */ +function formatTokenQuantity( + config: { locale: string }, + value: number | bigint | `${number}`, + symbol: string, +) { + const minThreshold = 0.00001; + const number = Number(value); + const absoluteValue = Math.abs(number); + + if (!Number.isFinite(number)) { + return ''; + } + + if (number === 0) { + return formatToken(config, 0, symbol); + } + + if (absoluteValue < minThreshold) { + return `<${formatToken(config, minThreshold, symbol, oneSignificantDigit)}`; + } + + if (absoluteValue < 1) { + return formatToken(config, number, symbol, threeSignificantDigits); + } + + if (absoluteValue < 1_000_000) { + return formatToken(config, number, symbol); + } + + return formatToken(config, number, symbol, { + notation: 'compact', + ...twoDecimals, + }); +} + +/** + * Create formatter functions with the given locale. + * + * @param options - Configuration options. + * @param options.locale - Locale string. + * @returns Object with formatter functions. + */ +export function createFormatters({ locale = FALLBACK_LOCALE }) { + return { + /** + * Format a number with optional Intl overrides. + * + * @param value - Numeric value to format. + * @param options - Optional Intl.NumberFormat overrides. + */ + formatNumber: formatNumber.bind(null, { locale }), + /** + * Format a value as a currency string. + * + * @param value - Numeric value to format. + * @param currency - ISO 4217 currency code (e.g. 'USD'). + * @param options - Optional Intl.NumberFormat overrides. + */ + formatCurrency: formatCurrency.bind(null, { locale }), + /** + * Compact currency (e.g. $1.2K, $3.4M) with up to two decimal digits. + * + * @param value - Numeric value to format. + * @param currency - ISO 4217 currency code. + */ + formatCurrencyCompact: formatCurrencyCompact.bind(null, { locale }), + /** + * Currency with thresholds for small values. + * + * @param value - Numeric value to format. + * @param currency - ISO 4217 currency code. + */ + formatCurrencyWithMinThreshold: formatCurrencyWithMinThreshold.bind(null, { + locale, + }), + /** + * Format token price with varying precision based on value. + * + * @param value - Numeric value to format. + * @param currency - ISO 4217 currency code. + */ + formatCurrencyTokenPrice: formatCurrencyTokenPrice.bind(null, { locale }), + /** + * Format a value as a token string with symbol. + * + * @param value - Numeric value to format. + * @param symbol - Token symbol (e.g. 'ETH', 'SepoliaETH'). + * @param options - Optional Intl.NumberFormat overrides. + */ + formatToken: formatToken.bind(null, { locale }), + /** + * Format token quantity with varying precision based on value. + * + * @param value - Numeric value to format. + * @param symbol - Token symbol (e.g. 'ETH', 'SepoliaETH'). + */ + formatTokenQuantity: formatTokenQuantity.bind(null, { locale }), + }; +} diff --git a/packages/assets-controllers/src/utils/timeout-with-retry.test.ts b/packages/assets-controllers/src/utils/timeout-with-retry.test.ts new file mode 100644 index 00000000000..e9bcb6a8356 --- /dev/null +++ b/packages/assets-controllers/src/utils/timeout-with-retry.test.ts @@ -0,0 +1,109 @@ +import { flushPromises } from '../../../../tests/helpers.js'; +import { timeoutWithRetry } from './timeout-with-retry.js'; + +describe('timeoutWithRetry', () => { + const timeout = 1000; + + beforeEach(() => { + jest.useFakeTimers(); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + it('returns the result when call completes before timeout', async () => { + const mockCall = jest.fn(async () => 'success'); + + const resultPromise = timeoutWithRetry(mockCall, timeout, 0); + jest.runAllTimers(); + const result = await resultPromise; + + expect(result).toBe('success'); + expect(mockCall).toHaveBeenCalledTimes(1); + }); + + describe('retry behaviour', () => { + it('throws when maxRetries is negative', async () => { + const mockCall = jest.fn(async () => 'success'); + + await expect(() => + timeoutWithRetry(mockCall, timeout, -1), + ).rejects.toThrow('maxRetries must be greater than or equal to 0'); + }); + + it('returns the result when call completes just before timeout', async () => { + const mockCall = createMockCallWithRetries(timeout, 0); + + const resultPromise = timeoutWithRetry(mockCall, timeout, 0); + jest.runAllTimers(); + const result = await resultPromise; + + expect(result).toBe('success'); + expect(mockCall).toHaveBeenCalledTimes(1); + }); + + it('succeeds after multiple retries', async () => { + const mockCall = createMockCallWithRetries(timeout, 2); + + const resultPromise = timeoutWithRetry(mockCall, timeout, 3); + jest.runAllTimers(); + await flushPromises(); + jest.runAllTimers(); + const result = await resultPromise; + + expect(result).toBe('success'); + expect(mockCall).toHaveBeenCalledTimes(3); + }); + + it('throws when all retries are exhausted', async () => { + const mockCall = createMockCallWithRetries(timeout, 2); + + const resultPromise = timeoutWithRetry(mockCall, timeout, 1); + jest.runAllTimers(); + await flushPromises(); + jest.runAllTimers(); + + await expect(resultPromise).rejects.toThrow('timeout'); + expect(mockCall).toHaveBeenCalledTimes(2); + }); + }); + + describe('non-timeout errors', () => { + it('throws immediately on non-timeout error without retrying', async () => { + const customError = new Error('custom error'); + const mockCall = jest.fn(async () => { + throw customError; + }); + + const resultPromise = timeoutWithRetry(mockCall, timeout, 0); + jest.runAllTimers(); + + await expect(resultPromise).rejects.toThrow('custom error'); + expect(mockCall).toHaveBeenCalledTimes(1); + }); + }); +}); + +/** + * @param timeout - The timeout in milliseconds. + * @param timeoutsBeforeSuccess - The number of timeouts before the call succeeds. + * @returns A mock call function that times out for a specific number of times before returning 'success'. + */ +function createMockCallWithRetries( + timeout: number, + timeoutsBeforeSuccess: number, +) { + let callCount = 0; + const mockCall = jest.fn(async () => { + callCount += 1; + + if (callCount < timeoutsBeforeSuccess + 1) { + await new Promise((resolve) => setTimeout(resolve, timeout + 1)); + } + + return 'success'; + }); + + return mockCall; +} diff --git a/packages/assets-controllers/src/utils/timeout-with-retry.ts b/packages/assets-controllers/src/utils/timeout-with-retry.ts new file mode 100644 index 00000000000..90e0be1b964 --- /dev/null +++ b/packages/assets-controllers/src/utils/timeout-with-retry.ts @@ -0,0 +1,39 @@ +import { assert } from '@metamask/utils'; + +const TIMEOUT_ERROR = new Error('timeout'); + +/** + * + * @param call - The async function to call. + * @param timeout - Timeout in milliseconds for each call attempt. + * @param maxRetries - Maximum number of retries on timeout. + * @returns The resolved value of the call, or throws the last error if not a timeout or retries exhausted. + */ +// eslint-disable-next-line consistent-return +export async function timeoutWithRetry Promise>( + call: T, + timeout: number, + maxRetries: number, + // @ts-expect-error TS2366: Assertion guarantees loop executes +): Promise>> { + assert(maxRetries >= 0, 'maxRetries must be greater than or equal to 0'); + + let attempt = 0; + + while (attempt <= maxRetries) { + try { + return (await Promise.race([ + call(), + new Promise((_resolve, reject) => + setTimeout(() => reject(TIMEOUT_ERROR), timeout), + ), + ])) as Awaited>; + } catch (err) { + if (err === TIMEOUT_ERROR && attempt < maxRetries) { + attempt += 1; + continue; + } + throw err; + } + } +} diff --git a/packages/assets-controllers/tsconfig.build.json b/packages/assets-controllers/tsconfig.build.json index 93737886c16..6e3978c1457 100644 --- a/packages/assets-controllers/tsconfig.build.json +++ b/packages/assets-controllers/tsconfig.build.json @@ -6,12 +6,64 @@ "rootDir": "./src" }, "references": [ - { "path": "../approval-controller/tsconfig.build.json" }, - { "path": "../base-controller/tsconfig.build.json" }, - { "path": "../controller-utils/tsconfig.build.json" }, - { "path": "../network-controller/tsconfig.build.json" }, - { "path": "../preferences-controller/tsconfig.build.json" }, - { "path": "../polling-controller/tsconfig.build.json" } + { + "path": "../account-tree-controller/tsconfig.build.json" + }, + { + "path": "../accounts-controller/tsconfig.build.json" + }, + { + "path": "../approval-controller/tsconfig.build.json" + }, + { + "path": "../core-backend/tsconfig.build.json" + }, + { + "path": "../base-controller/tsconfig.build.json" + }, + { + "path": "../controller-utils/tsconfig.build.json" + }, + { + "path": "../keyring-controller/tsconfig.build.json" + }, + { + "path": "../network-controller/tsconfig.build.json" + }, + { + "path": "../network-enablement-controller/tsconfig.build.json" + }, + { + "path": "../messenger/tsconfig.build.json" + }, + { + "path": "../preferences-controller/tsconfig.build.json" + }, + { + "path": "../polling-controller/tsconfig.build.json" + }, + { + "path": "../permission-controller/tsconfig.build.json" + }, + { + "path": "../storage-service/tsconfig.build.json" + }, + { + "path": "../transaction-controller/tsconfig.build.json" + }, + { + "path": "../phishing-controller/tsconfig.build.json" + }, + { + "path": "../multichain-account-service/tsconfig.build.json" + }, + { + "path": "../profile-sync-controller/tsconfig.build.json" + }, + { + "path": "../remote-feature-flag-controller/tsconfig.build.json" + } ], - "include": ["../../types", "./src"] + "include": ["../../types", "./src"], + "exclude": ["**/*.test.ts", "**/__fixtures__/"] } diff --git a/packages/assets-controllers/tsconfig.json b/packages/assets-controllers/tsconfig.json index 2900e14b0eb..7a5d296a792 100644 --- a/packages/assets-controllers/tsconfig.json +++ b/packages/assets-controllers/tsconfig.json @@ -1,15 +1,67 @@ { "extends": "../../tsconfig.packages.json", "compilerOptions": { - "baseUrl": "./" + "baseUrl": "./", + "rootDir": "../.." }, "references": [ - { "path": "../approval-controller" }, - { "path": "../base-controller" }, - { "path": "../controller-utils" }, - { "path": "../network-controller" }, - { "path": "../preferences-controller" }, - { "path": "../polling-controller" } + { + "path": "../account-tree-controller" + }, + { + "path": "../accounts-controller" + }, + { + "path": "../approval-controller" + }, + { + "path": "../core-backend" + }, + { + "path": "../base-controller" + }, + { + "path": "../controller-utils" + }, + { + "path": "../keyring-controller" + }, + { + "path": "../network-controller" + }, + { + "path": "../network-enablement-controller" + }, + { + "path": "../messenger" + }, + { + "path": "../preferences-controller" + }, + { + "path": "../phishing-controller" + }, + { + "path": "../polling-controller" + }, + { + "path": "../permission-controller" + }, + { + "path": "../storage-service" + }, + { + "path": "../transaction-controller" + }, + { + "path": "../multichain-account-service" + }, + { + "path": "../profile-sync-controller" + }, + { + "path": "../remote-feature-flag-controller" + } ], - "include": ["../../types", "./src"] + "include": ["../../types", "./src", "../../tests"] } diff --git a/packages/authenticated-user-storage/CHANGELOG.md b/packages/authenticated-user-storage/CHANGELOG.md new file mode 100644 index 00000000000..c26cf910ffd --- /dev/null +++ b/packages/authenticated-user-storage/CHANGELOG.md @@ -0,0 +1,90 @@ +# 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] + +## [3.0.2] + +### Changed + +- Bump `@metamask/superstruct` from `^3.1.0` to `^3.4.1` ([#9754](https://github.com/MetaMask/core/pull/9754)) +- Bump `@metamask/base-data-service` from `^0.1.3` to `^1.0.0` ([#9972](https://github.com/MetaMask/core/pull/9972)) + +## [3.0.1] + +### Changed + +- Bump `@metamask/messenger` from `^1.2.0` to `^2.0.0` ([#9392](https://github.com/MetaMask/core/pull/9392)) + +### Fixed + +- Fix `getAssetsWatchlist` and `setAssetsWatchlist` to use the correct API path `/preferences/assets-watchlist` instead of `/assets-watchlist` ([#9441](https://github.com/MetaMask/core/pull/9441)) + +## [3.0.0] + +### Added + +- Add `PriceAlertPreference` type, required `priceAlerts` field on `NotificationPreferences`, and `DEFAULT_PRICE_ALERT_PREFERENCES` constant ([#9316](https://github.com/MetaMask/core/pull/9316)) + +### Changed + +- **BREAKING:** Make `agenticCli` required on `NotificationPreferences` (previously optional on the type) ([#9316](https://github.com/MetaMask/core/pull/9316)) +- Remove client-side backfill of `agenticCli` and `priceAlerts` in `getNotificationPreferences`; the API merges defaults on GET ([#9316](https://github.com/MetaMask/core/pull/9316)) + +## [2.1.0] + +### Added + +- Add `getAssetsWatchlist` and `setAssetsWatchlist` methods to `AuthenticatedUserStorageService` for managing the authenticated user's assets-watchlist, along with corresponding messenger actions (`AuthenticatedUserStorageService:getAssetsWatchlist`, `AuthenticatedUserStorageService:setAssetsWatchlist`), the `AssetsWatchlistBlob` type, and the `ASSETS_WATCHLIST_MAX_ASSETS` constant ([#8836](https://github.com/MetaMask/core/pull/8836)) + - `getAssetsWatchlist` returns the assets-watchlist blob or `null` on 404, mirroring `getNotificationPreferences`. + - `setAssetsWatchlist` writes the full blob and enforces a maximum of `ASSETS_WATCHLIST_MAX_ASSETS` (100) assets before sending the request, via a superstruct `size` constraint on the write-side schema. +- Add `AgenticCliPreference` type and optional `agenticCli` field to `NotificationPreferences` for Agentic CLI notification preferences ([#8933](https://github.com/MetaMask/core/pull/8933)) + - `agenticCli` is optional on the type for this release; the next major release should make it required. + - `getNotificationPreferences` backfills legacy blobs that omit `agenticCli` with `DEFAULT_AGENTIC_CLI_PREFERENCES`, then validates the result against the full schema. + - `putNotificationPreferences` relies on the TypeScript type for write shape; no runtime validation is performed on PUT. +- Add `DEFAULT_AGENTIC_CLI_PREFERENCES` for Agentic CLI notification preferences ([#8933](https://github.com/MetaMask/core/pull/8933)) + +### Changed + +- Bump `@metamask/utils` from `^11.9.0` to `^11.11.0` ([#9074](https://github.com/MetaMask/core/pull/9074)) +- Bump `@metamask/controller-utils` from `^12.1.0` to `^12.3.0` ([#9058](https://github.com/MetaMask/core/pull/9058), [#9083](https://github.com/MetaMask/core/pull/9083), [#9218](https://github.com/MetaMask/core/pull/9218)) + +## [2.0.0] + +### Changed + +- Bump `@metamask/controller-utils` from `^12.0.0` to `^12.1.0` ([#8774](https://github.com/MetaMask/core/pull/8774)) +- Bump `@metamask/base-data-service` from `^0.1.2` to `^0.1.3` ([#8799](https://github.com/MetaMask/core/pull/8799)) +- **BREAKING:** Replace `enabled` by `inAppNotificationsEnabled` and `pushNotificationsEnabled` in all the `NotificationPreferences` type fields and validation to match the API payload. ([#8784](https://github.com/MetaMask/core/pull/8784)) + +## [1.0.1] + +### Changed + +- Bump `@metamask/messenger` from `^1.1.1` to `^1.2.0` ([#8632](https://github.com/MetaMask/core/pull/8632)) +- Bump `@metamask/base-data-service` from `^0.1.1` to `^0.1.2` ([#8755](https://github.com/MetaMask/core/pull/8755)) +- Bump `@metamask/controller-utils` from `^11.20.0` to `^12.0.0` ([#8755](https://github.com/MetaMask/core/pull/8755)) + +## [1.0.0] + +### Added + +- Initial release ([#8260](https://github.com/MetaMask/core/pull/8260)) + - `AuthenticatedUserStorageService` class with namespaced domain accessors: `delegations` (list, create, revoke) and `preferences` (getNotifications, putNotifications) + +### Changed + +- **BREAKING**: Rename `SocialAIPreference.traderProfileIds` to `mutedTraderProfileIds` in types and notification-preferences validation to match the API payload. ([#8536](https://github.com/MetaMask/core/pull/8536)) + +[Unreleased]: https://github.com/MetaMask/core/compare/@metamask/authenticated-user-storage@3.0.2...HEAD +[3.0.2]: https://github.com/MetaMask/core/compare/@metamask/authenticated-user-storage@3.0.1...@metamask/authenticated-user-storage@3.0.2 +[3.0.1]: https://github.com/MetaMask/core/compare/@metamask/authenticated-user-storage@3.0.0...@metamask/authenticated-user-storage@3.0.1 +[3.0.0]: https://github.com/MetaMask/core/compare/@metamask/authenticated-user-storage@2.1.0...@metamask/authenticated-user-storage@3.0.0 +[2.1.0]: https://github.com/MetaMask/core/compare/@metamask/authenticated-user-storage@2.0.0...@metamask/authenticated-user-storage@2.1.0 +[2.0.0]: https://github.com/MetaMask/core/compare/@metamask/authenticated-user-storage@1.0.1...@metamask/authenticated-user-storage@2.0.0 +[1.0.1]: https://github.com/MetaMask/core/compare/@metamask/authenticated-user-storage@1.0.0...@metamask/authenticated-user-storage@1.0.1 +[1.0.0]: https://github.com/MetaMask/core/releases/tag/@metamask/authenticated-user-storage@1.0.0 diff --git a/packages/authenticated-user-storage/LICENSE b/packages/authenticated-user-storage/LICENSE new file mode 100644 index 00000000000..fe29e78e0fe --- /dev/null +++ b/packages/authenticated-user-storage/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/authenticated-user-storage/README.md b/packages/authenticated-user-storage/README.md new file mode 100644 index 00000000000..5feef69789f --- /dev/null +++ b/packages/authenticated-user-storage/README.md @@ -0,0 +1,160 @@ +# `@metamask/authenticated-user-storage` + +A TypeScript SDK for MetaMask's Authenticated User Storage API. Unlike E2EE user-storage, authenticated user storage holds **structured JSON** scoped to the authenticated user. The server can read and validate the contents, which allows other backend services to consume the data (e.g. delegation execution, notification delivery). + +The SDK currently supports three domains: + +- **Delegations** -- immutable, EIP-712 signed delegation records (list, create, revoke). +- **Notification Preferences** -- mutable per-user notification settings (get, put). +- **Assets watchlist** -- mutable per-user list of CAIP-19 asset identifiers (get, set). + +## Installation + +`yarn add @metamask/authenticated-user-storage` + +or + +`npm install @metamask/authenticated-user-storage` + +## Usage + +### Creating a service + +`AuthenticatedUserStorageService` extends `BaseDataService` and requires a messenger and an environment: + +- **`messenger`** -- a namespaced messenger for registering actions and events. The messenger must have access to `AuthenticationController:getBearerToken` to retrieve access tokens. +- **`env`** -- selects the backend environment (`DEV`, `UAT`, or `PRD`). + +```typescript +import { Messenger } from '@metamask/messenger'; +import { AuthenticatedUserStorageService } from '@metamask/authenticated-user-storage'; +import type { + AuthenticatedUserStorageMessenger, + AuthenticatedUserStorageActions, + AuthenticatedUserStorageEvents, +} from '@metamask/authenticated-user-storage'; + +// Create the messenger +const messenger = new Messenger< + 'AuthenticatedUserStorageService', + AuthenticatedUserStorageActions, + AuthenticatedUserStorageEvents +>({ + namespace: 'AuthenticatedUserStorageService', + parent: rootMessenger, +}); + +// Instantiate the service +const service = new AuthenticatedUserStorageService({ + messenger, + environment: 'prod', +}); +``` + +The `environment` option selects the backend environment: + +| Value | Server | +| -------- | ------------------------------------- | +| `'dev'` | `user-storage.dev-api.cx.metamask.io` | +| `'uat'` | `user-storage.uat-api.cx.metamask.io` | +| `'prod'` | `user-storage.api.cx.metamask.io` | + +### Calling methods via the messenger + +Once instantiated, all service methods are available as messenger actions. This allows any consumer with access to the messenger to call them without needing a direct reference to the service instance: + +```typescript +const delegations = await rootMessenger.call( + 'AuthenticatedUserStorageService:listDelegations', +); +``` + +### Delegations + +Delegations are immutable once stored. They can only be revoked (deleted), not updated. + +```typescript +import type { DelegationSubmission } from '@metamask/authenticated-user-storage'; + +// List all delegations for the authenticated user +const delegations = await service.listDelegations(); + +// Submit a new signed delegation +const submission: DelegationSubmission = { + signedDelegation: { ... }, + metadata: { ... }, +}; +await service.createDelegation(submission, 'extension'); + +// Revoke a delegation by its hash +await service.revokeDelegation('0xdae6d1...'); +``` + +### Notification preferences + +Preferences are mutable. The first call creates the record; subsequent calls update it. + +```typescript +import type { NotificationPreferences } from '@metamask/authenticated-user-storage'; + +// Retrieve current preferences (returns null if none have been set) +const prefs = await service.getNotificationPreferences(); + +// Create or update preferences +const updated: NotificationPreferences = { + walletActivity: { ... }, + marketing: { ... }, + perps: { ... }, + socialAI: { ... }, +}; +await service.putNotificationPreferences(updated, 'extension'); +``` + +### Assets watchlist + +The assets-watchlist is a mutable per-user singleton blob. The first call to `setAssetsWatchlist` creates the record; subsequent calls overwrite it. Each entry in `assets` is a [CAIP-19](https://chainagnostic.org/CAIPs/caip-19) asset identifier (e.g. `eip155:1/erc20:0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48`). The blob carries an explicit `version: 1` literal so the shape can evolve without breaking existing consumers. + +The SDK enforces a maximum of `ASSETS_WATCHLIST_MAX_ASSETS` (100) entries on writes; oversized blobs throw a superstruct `StructError` before the request is sent. + +```typescript +import { ASSETS_WATCHLIST_MAX_ASSETS } from '@metamask/authenticated-user-storage'; +import type { AssetsWatchlistBlob } from '@metamask/authenticated-user-storage'; + +// Retrieve the current assets-watchlist (returns null on the first read) +const watchlist = await service.getAssetsWatchlist(); + +// Create or update the assets-watchlist +const updated: AssetsWatchlistBlob = { + version: 1, + assets: [ + 'eip155:1/erc20:0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48', + 'eip155:1/slip44:60', + ], +}; +await service.setAssetsWatchlist(updated, 'extension'); +``` + +## Response validation + +All API responses are validated at runtime using [`@metamask/superstruct`](https://github.com/MetaMask/superstruct) schemas before being returned to callers. If the server returns data that doesn't match the expected shape, the SDK throws with details about the structural mismatch rather than silently returning malformed data. + +## Error handling + +HTTP errors are represented as `HttpError` from `@metamask/controller-utils`. All errors are encouraged to bubble up to the caller. The service policy provided by `BaseDataService` automatically retries transient failures before propagating the error. + +```typescript +import { HttpError } from '@metamask/controller-utils'; + +try { + await service.createDelegation(submission); +} catch (error) { + if (error instanceof HttpError) { + console.error(error.message); + // e.g. "Failed to create delegation: 409" + } +} +``` + +## 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/authenticated-user-storage/jest.config.js b/packages/authenticated-user-storage/jest.config.js new file mode 100644 index 00000000000..ca084133399 --- /dev/null +++ b/packages/authenticated-user-storage/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/authenticated-user-storage/package.json b/packages/authenticated-user-storage/package.json new file mode 100644 index 00000000000..80c0084d13e --- /dev/null +++ b/packages/authenticated-user-storage/package.json @@ -0,0 +1,80 @@ +{ + "name": "@metamask/authenticated-user-storage", + "version": "3.0.2", + "description": "SDK for authenticated (non-encrypted) user storage endpoints", + "keywords": [ + "Ethereum", + "MetaMask" + ], + "homepage": "https://github.com/MetaMask/core/tree/main/packages/authenticated-user-storage#readme", + "bugs": { + "url": "https://github.com/MetaMask/core/issues" + }, + "license": "MIT", + "repository": { + "type": "git", + "url": "https://github.com/MetaMask/core.git" + }, + "files": [ + "dist/" + ], + "sideEffects": false, + "main": "./dist/index.cjs", + "types": "./dist/index.d.cts", + "exports": { + ".": { + "import": { + "types": "./dist/index.d.mts", + "default": "./dist/index.mjs" + }, + "require": { + "types": "./dist/index.d.cts", + "default": "./dist/index.cjs" + } + }, + "./package.json": "./package.json" + }, + "publishConfig": { + "access": "public", + "registry": "https://registry.npmjs.org/" + }, + "scripts": { + "build": "ts-bridge --project tsconfig.build.json --verbose --clean --no-references", + "build:all": "ts-bridge --project tsconfig.build.json --verbose --clean", + "build:docs": "typedoc", + "changelog:update": "../../scripts/update-changelog.sh @metamask/authenticated-user-storage", + "changelog:validate": "../../scripts/validate-changelog.sh @metamask/authenticated-user-storage", + "lint:tsconfigs": "tsx ../../scripts/lint-tsconfigs/lint-tsconfigs.mts", + "lint:tsconfigs:fix": "tsx ../../scripts/lint-tsconfigs/lint-tsconfigs.mts --fix", + "messenger-action-types:check": "tsx ../../packages/messenger-cli/src/cli.ts --esm --check", + "messenger-action-types:generate": "tsx ../../packages/messenger-cli/src/cli.ts --esm --generate", + "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-data-service": "^1.0.0", + "@metamask/controller-utils": "^12.3.0", + "@metamask/messenger": "^2.0.0", + "@metamask/superstruct": "^3.4.1", + "@metamask/utils": "^11.11.0" + }, + "devDependencies": { + "@metamask/auto-changelog": "^6.1.0", + "@ts-bridge/cli": "^0.6.4", + "@types/jest": "^30.0.0", + "deepmerge": "^4.2.2", + "jest": "^30.4.2", + "nock": "^13.3.1", + "ts-jest": "^29.4.11", + "tsx": "^4.20.5", + "typedoc": "^0.25.13", + "typedoc-plugin-missing-exports": "^2.0.0", + "typescript": "~5.3.3" + }, + "engines": { + "node": "^18.18 || >=20" + } +} diff --git a/packages/authenticated-user-storage/src/authenticated-user-storage-method-action-types.ts b/packages/authenticated-user-storage/src/authenticated-user-storage-method-action-types.ts new file mode 100644 index 00000000000..39c88ae9c5b --- /dev/null +++ b/packages/authenticated-user-storage/src/authenticated-user-storage-method-action-types.ts @@ -0,0 +1,98 @@ +/** + * This file is auto generated. + * Do not edit manually. + */ + +import type { AuthenticatedUserStorageService } from './authenticated-user-storage.js'; + +/** + * Returns all delegation records belonging to the authenticated user. + * + * @returns An array of delegation records, or an empty array if none exist. + */ +export type AuthenticatedUserStorageServiceListDelegationsAction = { + type: `AuthenticatedUserStorageService:listDelegations`; + handler: AuthenticatedUserStorageService['listDelegations']; +}; + +/** + * Stores a signed delegation record for the authenticated user. + * + * @param submission - The signed delegation and its metadata. + * @param clientType - Optional client type header. + */ +export type AuthenticatedUserStorageServiceCreateDelegationAction = { + type: `AuthenticatedUserStorageService:createDelegation`; + handler: AuthenticatedUserStorageService['createDelegation']; +}; + +/** + * Revokes (deletes) a delegation record. + * + * @param delegationHash - The unique hash identifying the delegation. + */ +export type AuthenticatedUserStorageServiceRevokeDelegationAction = { + type: `AuthenticatedUserStorageService:revokeDelegation`; + handler: AuthenticatedUserStorageService['revokeDelegation']; +}; + +/** + * Returns the notification preferences for the authenticated user. + * + * @returns The notification preferences object, or `null` if none have been + * set (404). + */ +export type AuthenticatedUserStorageServiceGetNotificationPreferencesAction = { + type: `AuthenticatedUserStorageService:getNotificationPreferences`; + handler: AuthenticatedUserStorageService['getNotificationPreferences']; +}; + +/** + * Creates or updates the notification preferences for the authenticated user. + * + * @param prefs - The full notification preferences object. + * @param clientType - Optional client type header. + */ +export type AuthenticatedUserStorageServicePutNotificationPreferencesAction = { + type: `AuthenticatedUserStorageService:putNotificationPreferences`; + handler: AuthenticatedUserStorageService['putNotificationPreferences']; +}; + +/** + * Returns the assets-watchlist for the authenticated user. + * + * @returns The assets-watchlist blob, or `null` if none has been set (404). + */ +export type AuthenticatedUserStorageServiceGetAssetsWatchlistAction = { + type: `AuthenticatedUserStorageService:getAssetsWatchlist`; + handler: AuthenticatedUserStorageService['getAssetsWatchlist']; +}; + +/** + * Creates or updates the assets-watchlist for the authenticated user. + * + * @param blob - The full assets-watchlist blob. The `assets` array may + * contain at most `ASSETS_WATCHLIST_MAX_ASSETS` CAIP-19 asset identifiers; + * this is enforced by `assertAssetsWatchlistBlobForWrite` before the + * request is sent. + * @param clientType - Optional client type header. + * @throws A `StructError` from `@metamask/superstruct` if `blob` is + * structurally invalid or `assets` exceeds the cap; an `HttpError` from + * `@metamask/controller-utils` if the API responds with a non-2xx status. + */ +export type AuthenticatedUserStorageServiceSetAssetsWatchlistAction = { + type: `AuthenticatedUserStorageService:setAssetsWatchlist`; + handler: AuthenticatedUserStorageService['setAssetsWatchlist']; +}; + +/** + * Union of all AuthenticatedUserStorageService action types. + */ +export type AuthenticatedUserStorageServiceMethodActions = + | AuthenticatedUserStorageServiceListDelegationsAction + | AuthenticatedUserStorageServiceCreateDelegationAction + | AuthenticatedUserStorageServiceRevokeDelegationAction + | AuthenticatedUserStorageServiceGetNotificationPreferencesAction + | AuthenticatedUserStorageServicePutNotificationPreferencesAction + | AuthenticatedUserStorageServiceGetAssetsWatchlistAction + | AuthenticatedUserStorageServiceSetAssetsWatchlistAction; diff --git a/packages/authenticated-user-storage/src/authenticated-user-storage.test.ts b/packages/authenticated-user-storage/src/authenticated-user-storage.test.ts new file mode 100644 index 00000000000..f0c5c3ee8f8 --- /dev/null +++ b/packages/authenticated-user-storage/src/authenticated-user-storage.test.ts @@ -0,0 +1,610 @@ +import { Messenger, MOCK_ANY_NAMESPACE } from '@metamask/messenger'; +import type { + MockAnyNamespace, + MessengerActions, + MessengerEvents, +} from '@metamask/messenger'; +import nock from 'nock'; + +import { + handleMockListDelegations, + handleMockCreateDelegation, + handleMockRevokeDelegation, + handleMockGetNotificationPreferences, + handleMockPutNotificationPreferences, + handleMockGetAssetsWatchlist, + handleMockSetAssetsWatchlist, +} from '../tests/fixtures/authenticated-userstorage.js'; +import { + MOCK_DELEGATION_RESPONSE, + MOCK_DELEGATION_SUBMISSION, + MOCK_INVALID_ASSETS_WATCHLIST_BLOB, + MOCK_NOTIFICATION_PREFERENCES, + MOCK_ASSETS_WATCHLIST_BLOB, + MOCK_ASSETS_WATCHLIST_URL, +} from '../tests/mocks/authenticated-userstorage.js'; +import type { AuthenticatedUserStorageMessenger } from './authenticated-user-storage.js'; +import { + getAuthenticatedStorageUrl, + AuthenticatedUserStorageService, +} from './authenticated-user-storage.js'; +import type { Environment } from './env.js'; +import { getUserStorageApiUrl } from './env.js'; +import { ASSETS_WATCHLIST_MAX_ASSETS } from './validators.js'; + +const MOCK_ACCESS_TOKEN = 'mock-access-token'; + +describe('getUserStorageApiUrl()', () => { + it('returns the API URL for a valid environment', () => { + const result = getUserStorageApiUrl('prod'); + expect(result).toBe('https://user-storage.api.cx.metamask.io'); + }); + + it('throws for an invalid environment', () => { + expect(() => getUserStorageApiUrl('invalid' as Environment)).toThrow( + 'Invalid environment: invalid', + ); + }); +}); + +describe('getAuthenticatedStorageUrl()', () => { + it('generates the base URL for a given environment', () => { + const result = getAuthenticatedStorageUrl('prod'); + expect(result).toBe('https://user-storage.api.cx.metamask.io/api/v1'); + }); +}); + +describe('AuthenticatedUserStorageService', () => { + afterEach(() => { + nock.cleanAll(); // eslint-disable-line import-x/no-named-as-default-member + }); + + describe('AuthenticatedUserStorageService:listDelegations', () => { + it('returns delegation records via the messenger', async () => { + handleMockListDelegations(); + const { rootMessenger } = createService(); + + const result = await rootMessenger.call( + 'AuthenticatedUserStorageService:listDelegations', + ); + + expect(result).toStrictEqual([MOCK_DELEGATION_RESPONSE]); + }); + }); + + describe('listDelegations', () => { + it('returns delegation records from the API', async () => { + const mock = handleMockListDelegations(); + const { service } = createService(); + + const result = await service.listDelegations(); + + expect(mock.isDone()).toBe(true); + expect(result).toStrictEqual([MOCK_DELEGATION_RESPONSE]); + }); + + it('throws when the API returns a non-200 status', async () => { + handleMockListDelegations({ status: 500 }); + const { service } = createService(); + + await expect(service.listDelegations()).rejects.toThrow( + 'Failed to list delegations: 500', + ); + }); + }); + + describe('createDelegation', () => { + it('submits a delegation to the API', async () => { + const mock = handleMockCreateDelegation(); + const { service } = createService(); + + await service.createDelegation(MOCK_DELEGATION_SUBMISSION); + + expect(mock.isDone()).toBe(true); + }); + + it('includes X-Client-Type header when clientType is provided', async () => { + const mock = handleMockCreateDelegation(); + const { service } = createService(); + + await service.createDelegation(MOCK_DELEGATION_SUBMISSION, 'extension'); + + expect(mock.isDone()).toBe(true); + }); + + it('throws when the API returns a 409 conflict', async () => { + handleMockCreateDelegation({ status: 409 }); + const { service } = createService(); + + await expect( + service.createDelegation(MOCK_DELEGATION_SUBMISSION), + ).rejects.toThrow('Failed to create delegation: 409'); + }); + + it('throws when the API returns a non-200 status', async () => { + handleMockCreateDelegation({ status: 400 }); + const { service } = createService(); + + await expect( + service.createDelegation(MOCK_DELEGATION_SUBMISSION), + ).rejects.toThrow('Failed to create delegation: 400'); + }); + + it('sends the correct request body', async () => { + handleMockCreateDelegation(undefined, async (_, requestBody) => { + expect(requestBody).toStrictEqual(MOCK_DELEGATION_SUBMISSION); + }); + const { service } = createService(); + + await service.createDelegation(MOCK_DELEGATION_SUBMISSION); + }); + }); + + describe('revokeDelegation', () => { + it('revokes a delegation via the API', async () => { + const mock = handleMockRevokeDelegation(); + const { service } = createService(); + + await service.revokeDelegation( + MOCK_DELEGATION_SUBMISSION.metadata.delegationHash, + ); + + expect(mock.isDone()).toBe(true); + }); + + it('throws when the API returns a 404', async () => { + handleMockRevokeDelegation({ status: 404 }); + const { service } = createService(); + + await expect(service.revokeDelegation('0xdeadbeef')).rejects.toThrow( + 'Failed to revoke delegation: 404', + ); + }); + + it('throws when the API returns a non-200 status', async () => { + handleMockRevokeDelegation({ status: 500 }); + const { service } = createService(); + + await expect(service.revokeDelegation('0xdeadbeef')).rejects.toThrow( + 'Failed to revoke delegation: 500', + ); + }); + }); + + describe('getNotificationPreferences', () => { + it('returns notification preferences from the API', async () => { + const mock = handleMockGetNotificationPreferences(); + const { service } = createService(); + + const result = await service.getNotificationPreferences(); + + expect(mock.isDone()).toBe(true); + expect(result).toStrictEqual(MOCK_NOTIFICATION_PREFERENCES); + }); + + it('returns null when preferences are not found', async () => { + handleMockGetNotificationPreferences({ status: 404 }); + const { service } = createService(); + + const result = await service.getNotificationPreferences(); + + expect(result).toBeNull(); + }); + + it('throws when the API returns a non-200/404 status', async () => { + handleMockGetNotificationPreferences({ status: 500 }); + const { service } = createService(); + + await expect(service.getNotificationPreferences()).rejects.toThrow( + 'Failed to get notification preferences: 500', + ); + }); + }); + + describe('putNotificationPreferences', () => { + it('submits notification preferences to the API', async () => { + const mock = handleMockPutNotificationPreferences(); + const { service } = createService(); + + await service.putNotificationPreferences(MOCK_NOTIFICATION_PREFERENCES); + + expect(mock.isDone()).toBe(true); + }); + + it('includes X-Client-Type header when clientType is provided', async () => { + const mock = handleMockPutNotificationPreferences(); + const { service } = createService(); + + await service.putNotificationPreferences( + MOCK_NOTIFICATION_PREFERENCES, + 'mobile', + ); + + expect(mock.isDone()).toBe(true); + }); + + it('sends the correct request body', async () => { + handleMockPutNotificationPreferences( + undefined, + async (_, requestBody) => { + expect(requestBody).toStrictEqual(MOCK_NOTIFICATION_PREFERENCES); + }, + ); + const { service } = createService(); + + await service.putNotificationPreferences(MOCK_NOTIFICATION_PREFERENCES); + }); + + it('throws when the API returns a non-200 status', async () => { + handleMockPutNotificationPreferences({ status: 400 }); + const { service } = createService(); + + await expect( + service.putNotificationPreferences(MOCK_NOTIFICATION_PREFERENCES), + ).rejects.toThrow('Failed to put notification preferences: 400'); + }); + }); + + describe('AuthenticatedUserStorageService:getAssetsWatchlist', () => { + it('returns the assets-watchlist via the messenger', async () => { + handleMockGetAssetsWatchlist(); + const { rootMessenger } = createService(); + + const result = await rootMessenger.call( + 'AuthenticatedUserStorageService:getAssetsWatchlist', + ); + + expect(result).toStrictEqual(MOCK_ASSETS_WATCHLIST_BLOB); + }); + }); + + describe('AuthenticatedUserStorageService:setAssetsWatchlist', () => { + it('sets the assets-watchlist via the messenger', async () => { + const mock = handleMockSetAssetsWatchlist(); + const { rootMessenger } = createService(); + + await rootMessenger.call( + 'AuthenticatedUserStorageService:setAssetsWatchlist', + MOCK_ASSETS_WATCHLIST_BLOB, + ); + + expect(mock.isDone()).toBe(true); + }); + }); + + describe('getAssetsWatchlist', () => { + it('returns the assets-watchlist from the API', async () => { + const mock = handleMockGetAssetsWatchlist(); + const { service } = createService(); + + const result = await service.getAssetsWatchlist(); + + expect(mock.isDone()).toBe(true); + expect(result).toStrictEqual(MOCK_ASSETS_WATCHLIST_BLOB); + }); + + it('sends the Authorization header', async () => { + const scope = nock(MOCK_ASSETS_WATCHLIST_URL, { + reqheaders: { + authorization: 'Bearer mock-access-token', + }, + }) + .get('') + .reply(200, MOCK_ASSETS_WATCHLIST_BLOB); + + const { service } = createService(); + const result = await service.getAssetsWatchlist(); + + expect(scope.isDone()).toBe(true); + expect(result).toStrictEqual(MOCK_ASSETS_WATCHLIST_BLOB); + }); + + it('returns null when the assets-watchlist is not found', async () => { + handleMockGetAssetsWatchlist({ status: 404 }); + const { service } = createService(); + + const result = await service.getAssetsWatchlist(); + + expect(result).toBeNull(); + }); + + it('throws when the API returns a non-200/404 status', async () => { + handleMockGetAssetsWatchlist({ status: 500 }); + const { service } = createService(); + + await expect(service.getAssetsWatchlist()).rejects.toThrow( + 'Failed to get assets watchlist: 500', + ); + }); + + it('throws when the API returns a 401', async () => { + handleMockGetAssetsWatchlist({ status: 401 }); + const { service } = createService(); + + await expect(service.getAssetsWatchlist()).rejects.toThrow( + 'Failed to get assets watchlist: 401', + ); + }); + + it('throws when the response body is malformed', async () => { + handleMockGetAssetsWatchlist({ + status: 200, + body: MOCK_INVALID_ASSETS_WATCHLIST_BLOB, + }); + const { service } = createService(); + + await expect(service.getAssetsWatchlist()).rejects.toThrow( + /Expected.*but received/u, + ); + }); + + it('caches the result so a second call within staleTime does not re-fetch', async () => { + const scope = nock(MOCK_ASSETS_WATCHLIST_URL) + .get('') + .once() + .reply(200, MOCK_ASSETS_WATCHLIST_BLOB); + const { service } = createService(); + + const first = await service.getAssetsWatchlist(); + const second = await service.getAssetsWatchlist(); + + expect(scope.isDone()).toBe(true); + expect(first).toStrictEqual(MOCK_ASSETS_WATCHLIST_BLOB); + expect(second).toStrictEqual(MOCK_ASSETS_WATCHLIST_BLOB); + }); + }); + + describe('setAssetsWatchlist', () => { + it('submits the assets-watchlist to the API', async () => { + const mock = handleMockSetAssetsWatchlist(); + const { service } = createService(); + + await service.setAssetsWatchlist(MOCK_ASSETS_WATCHLIST_BLOB); + + expect(mock.isDone()).toBe(true); + }); + + it('sends the correct request body', async () => { + handleMockSetAssetsWatchlist(undefined, async (_, requestBody) => { + expect(requestBody).toStrictEqual(MOCK_ASSETS_WATCHLIST_BLOB); + }); + const { service } = createService(); + + await service.setAssetsWatchlist(MOCK_ASSETS_WATCHLIST_BLOB); + }); + + it('sends Content-Type and Authorization headers but no X-Client-Type when clientType is omitted', async () => { + const scope = nock(MOCK_ASSETS_WATCHLIST_URL, { + reqheaders: { + 'content-type': 'application/json', + authorization: 'Bearer mock-access-token', + }, + badheaders: ['x-client-type'], + }) + .put('') + .reply(200); + const { service } = createService(); + + await service.setAssetsWatchlist(MOCK_ASSETS_WATCHLIST_BLOB); + + expect(scope.isDone()).toBe(true); + }); + + it('includes X-Client-Type header when clientType is provided', async () => { + const scope = nock(MOCK_ASSETS_WATCHLIST_URL, { + reqheaders: { + 'x-client-type': 'extension', + }, + }) + .put('') + .reply(200); + const { service } = createService(); + + await service.setAssetsWatchlist(MOCK_ASSETS_WATCHLIST_BLOB, 'extension'); + + expect(scope.isDone()).toBe(true); + }); + + it('throws when the API returns a non-200 status', async () => { + handleMockSetAssetsWatchlist({ status: 400 }); + const { service } = createService(); + + await expect( + service.setAssetsWatchlist(MOCK_ASSETS_WATCHLIST_BLOB), + ).rejects.toThrow('Failed to put assets watchlist: 400'); + }); + + it(`throws synchronously when the blob exceeds ${ASSETS_WATCHLIST_MAX_ASSETS} assets`, async () => { + const { service } = createService(); + const oversized = { + version: 1 as const, + assets: Array.from( + { length: ASSETS_WATCHLIST_MAX_ASSETS + 1 }, + (_, index) => + `eip155:1/erc20:0x${index.toString(16).padStart(40, '0')}`, + ), + }; + + await expect(service.setAssetsWatchlist(oversized)).rejects.toThrow( + new RegExp( + `At path: assets -- Expected a array with a length between \`0\` and \`${ASSETS_WATCHLIST_MAX_ASSETS}\` but received one with a length of \`${ASSETS_WATCHLIST_MAX_ASSETS + 1}\``, + 'u', + ), + ); + }); + + it('throws a structural error before sending the request when the blob is malformed', async () => { + const { service } = createService(); + const malformed = { + version: 2, + assets: ['eip155:1/slip44:60'], + } as unknown as Parameters[0]; + + await expect(service.setAssetsWatchlist(malformed)).rejects.toThrow( + /At path: version -- Expected the literal/u, + ); + }); + + it(`accepts a blob with exactly ${ASSETS_WATCHLIST_MAX_ASSETS} assets`, async () => { + const mock = handleMockSetAssetsWatchlist(); + const { service } = createService(); + const maxBlob = { + version: 1 as const, + assets: Array.from( + { length: ASSETS_WATCHLIST_MAX_ASSETS }, + (_, index) => + `eip155:1/erc20:0x${index.toString(16).padStart(40, '0')}`, + ), + }; + + await service.setAssetsWatchlist(maxBlob); + + expect(mock.isDone()).toBe(true); + }); + }); + + describe('cache invalidation', () => { + it('invalidates listDelegations cache after createDelegation', async () => { + handleMockCreateDelegation(); + handleMockListDelegations(); + const { service } = createService(); + const invalidateSpy = jest.spyOn(service, 'invalidateQueries'); + + await service.createDelegation(MOCK_DELEGATION_SUBMISSION); + + expect(invalidateSpy).toHaveBeenCalledWith({ + queryKey: ['AuthenticatedUserStorageService:listDelegations'], + }); + }); + + it('invalidates listDelegations cache after revokeDelegation', async () => { + handleMockRevokeDelegation(); + handleMockListDelegations(); + const { service } = createService(); + const invalidateSpy = jest.spyOn(service, 'invalidateQueries'); + + await service.revokeDelegation( + MOCK_DELEGATION_SUBMISSION.metadata.delegationHash, + ); + + expect(invalidateSpy).toHaveBeenCalledWith({ + queryKey: ['AuthenticatedUserStorageService:listDelegations'], + }); + }); + + it('invalidates getNotificationPreferences cache after putNotificationPreferences', async () => { + handleMockPutNotificationPreferences(); + handleMockGetNotificationPreferences(); + const { service } = createService(); + const invalidateSpy = jest.spyOn(service, 'invalidateQueries'); + + await service.putNotificationPreferences(MOCK_NOTIFICATION_PREFERENCES); + + expect(invalidateSpy).toHaveBeenCalledWith({ + queryKey: [ + 'AuthenticatedUserStorageService:getNotificationPreferences', + ], + }); + }); + + it('invalidates getAssetsWatchlist cache after setAssetsWatchlist', async () => { + handleMockSetAssetsWatchlist(); + handleMockGetAssetsWatchlist(); + const { service } = createService(); + const invalidateSpy = jest.spyOn(service, 'invalidateQueries'); + + await service.setAssetsWatchlist(MOCK_ASSETS_WATCHLIST_BLOB); + + expect(invalidateSpy).toHaveBeenCalledWith({ + queryKey: ['AuthenticatedUserStorageService:getAssetsWatchlist'], + }); + }); + + it('causes a subsequent getAssetsWatchlist to refetch after setAssetsWatchlist', async () => { + const updatedBlob = { + version: 1 as const, + assets: ['eip155:137/slip44:966'], + }; + const getScope = nock(MOCK_ASSETS_WATCHLIST_URL) + .get('') + .reply(200, MOCK_ASSETS_WATCHLIST_BLOB) + .put('') + .reply(200) + .get('') + .reply(200, updatedBlob); + + const { service } = createService(); + const first = await service.getAssetsWatchlist(); + await service.setAssetsWatchlist(updatedBlob); + const second = await service.getAssetsWatchlist(); + + expect(getScope.isDone()).toBe(true); + expect(first).toStrictEqual(MOCK_ASSETS_WATCHLIST_BLOB); + expect(second).toStrictEqual(updatedBlob); + }); + }); + + describe('authorization', () => { + it('passes the access token as a Bearer header', async () => { + handleMockListDelegations(); + const { service, mockGetBearerToken } = createService(); + + await service.listDelegations(); + + expect(mockGetBearerToken).toHaveBeenCalledTimes(1); + }); + }); +}); + +// === Test helpers === + +type RootMessenger = Messenger< + MockAnyNamespace, + MessengerActions, + MessengerEvents +>; + +function createRootMessenger(): RootMessenger { + return new Messenger({ namespace: MOCK_ANY_NAMESPACE }); +} + +function createServiceMessenger( + rootMessenger: RootMessenger, +): AuthenticatedUserStorageMessenger { + return new Messenger({ + namespace: 'AuthenticatedUserStorageService', + parent: rootMessenger, + }); +} + +function createService({ + options = {}, +}: { + options?: Partial< + ConstructorParameters[0] + >; +} = {}): { + service: AuthenticatedUserStorageService; + rootMessenger: RootMessenger; + messenger: AuthenticatedUserStorageMessenger; + mockGetBearerToken: jest.Mock; +} { + const rootMessenger = createRootMessenger(); + const mockGetBearerToken = jest.fn().mockResolvedValue(MOCK_ACCESS_TOKEN); + rootMessenger.registerActionHandler( + 'AuthenticationController:getBearerToken', + mockGetBearerToken, + ); + const messenger = createServiceMessenger(rootMessenger); + rootMessenger.delegate({ + messenger, + actions: ['AuthenticationController:getBearerToken'], + }); + const service = new AuthenticatedUserStorageService({ + messenger, + environment: 'prod', + ...options, + }); + + return { service, rootMessenger, messenger, mockGetBearerToken }; +} diff --git a/packages/authenticated-user-storage/src/authenticated-user-storage.ts b/packages/authenticated-user-storage/src/authenticated-user-storage.ts new file mode 100644 index 00000000000..2b14522fe3c --- /dev/null +++ b/packages/authenticated-user-storage/src/authenticated-user-storage.ts @@ -0,0 +1,448 @@ +import type { + DataServiceCacheUpdatedEvent, + DataServiceGranularCacheUpdatedEvent, + DataServiceInvalidateQueriesAction, +} from '@metamask/base-data-service'; +import { BaseDataService } from '@metamask/base-data-service'; +import type { CreateServicePolicyOptions } from '@metamask/controller-utils'; +import { HttpError } from '@metamask/controller-utils'; +import type { Messenger } from '@metamask/messenger'; +import type { Json } from '@metamask/utils'; + +import type { AuthenticatedUserStorageServiceMethodActions } from './authenticated-user-storage-method-action-types.js'; +import type { Environment } from './env.js'; +import { getUserStorageApiUrl } from './env.js'; +import type { + AssetsWatchlistBlob, + ClientType, + DelegationResponse, + DelegationSubmission, + NotificationPreferences, +} from './types.js'; +import { + assertAssetsWatchlistBlob, + assertAssetsWatchlistBlobForWrite, + assertDelegationResponseArray, + assertNotificationPreferences, +} from './validators.js'; + +// === GENERAL === + +/** + * The name of the {@link AuthenticatedUserStorageService} service, used to + * namespace the service's actions and events. + */ +export const serviceName = 'AuthenticatedUserStorageService'; + +/** + * Builds the versioned API base URL for a given environment. + * + * @param environment - The target environment. + * @returns The base URL including the `/api/v1` path segment. + */ +export function getAuthenticatedStorageUrl(environment: Environment): string { + return `${getUserStorageApiUrl(environment)}/api/v1`; +} + +// === MESSENGER === + +const MESSENGER_EXPOSED_METHODS = [ + 'listDelegations', + 'createDelegation', + 'revokeDelegation', + 'getNotificationPreferences', + 'putNotificationPreferences', + 'getAssetsWatchlist', + 'setAssetsWatchlist', +] as const; + +/** + * Invalidates cached queries for {@link AuthenticatedUserStorageService}. + */ +export type AuthenticatedUserStorageInvalidateQueriesAction = + DataServiceInvalidateQueriesAction; + +/** + * Actions that {@link AuthenticatedUserStorageService} exposes to other + * consumers. + */ +export type AuthenticatedUserStorageActions = + | AuthenticatedUserStorageServiceMethodActions + | AuthenticatedUserStorageInvalidateQueriesAction; + +/** + * Retrieves a bearer token from the `AuthenticationController`, logging in the + * user if necessary. + */ +type AuthenticationControllerGetBearerTokenAction = { + type: 'AuthenticationController:getBearerToken'; + handler: (entropySourceId?: string) => Promise; +}; + +/** + * Actions from other messengers that {@link AuthenticatedUserStorageService} + * calls. + */ +type AllowedActions = AuthenticationControllerGetBearerTokenAction; + +/** + * Published when {@link AuthenticatedUserStorageService}'s cache is updated. + */ +export type AuthenticatedUserStorageCacheUpdatedEvent = + DataServiceCacheUpdatedEvent; + +/** + * Published when a key within {@link AuthenticatedUserStorageService}'s cache + * is updated. + */ +export type AuthenticatedUserStorageGranularCacheUpdatedEvent = + DataServiceGranularCacheUpdatedEvent; + +/** + * Events that {@link AuthenticatedUserStorageService} exposes to other + * consumers. + */ +export type AuthenticatedUserStorageEvents = + | AuthenticatedUserStorageCacheUpdatedEvent + | AuthenticatedUserStorageGranularCacheUpdatedEvent; + +/** + * Events from other messengers that + * {@link AuthenticatedUserStorageService} subscribes to. + */ +type AllowedEvents = never; + +/** + * The messenger which is restricted to actions and events accessed by + * {@link AuthenticatedUserStorageService}. + */ +export type AuthenticatedUserStorageMessenger = Messenger< + typeof serviceName, + AuthenticatedUserStorageActions | AllowedActions, + AuthenticatedUserStorageEvents | AllowedEvents +>; + +// === SERVICE === + +/** + * Data service wrapping authenticated user-storage API endpoints. + * + * Provides methods for managing delegations and notification preferences + * for the authenticated user. + */ +export class AuthenticatedUserStorageService extends BaseDataService< + typeof serviceName, + AuthenticatedUserStorageMessenger +> { + readonly #environment: Environment; + + /** + * Constructs a new AuthenticatedUserStorageService. + * + * @param args - The constructor arguments. + * @param args.messenger - The messenger suited for this service. + * @param args.environment - The target environment (dev, uat, prod). + * @param args.policyOptions - Options to pass to `createServicePolicy`, which + * is used to wrap each request. See {@link CreateServicePolicyOptions}. + */ + constructor({ + messenger, + environment, + policyOptions, + }: { + messenger: AuthenticatedUserStorageMessenger; + environment: Environment; + policyOptions?: CreateServicePolicyOptions; + }) { + super({ name: serviceName, messenger, policyOptions }); + this.#environment = environment; + + this.messenger.registerMethodActionHandlers( + this, + MESSENGER_EXPOSED_METHODS, + ); + } + + /** + * Returns all delegation records belonging to the authenticated user. + * + * @returns An array of delegation records, or an empty array if none exist. + */ + async listDelegations(): Promise { + const url = `${getAuthenticatedStorageUrl(this.#environment)}/delegations`; + + const data = await this.fetchQuery({ + queryKey: [`${this.name}:listDelegations`], + queryFn: async () => { + const headers = await this.#getHeaders(); + const response = await fetch(url, { headers }); + + if (!response.ok) { + throw new HttpError( + response.status, + `Failed to list delegations: ${response.status}`, + ); + } + + return response.json(); + }, + }); + + assertDelegationResponseArray(data); + return data; + } + + /** + * Stores a signed delegation record for the authenticated user. + * + * @param submission - The signed delegation and its metadata. + * @param clientType - Optional client type header. + */ + async createDelegation( + submission: DelegationSubmission, + clientType?: ClientType, + ): Promise { + const url = `${getAuthenticatedStorageUrl(this.#environment)}/delegations`; + + await this.fetchQuery({ + queryKey: [ + `${this.name}:createDelegation`, + submission.metadata.delegationHash, + ], + staleTime: 0, + queryFn: async () => { + const headers = await this.#getHeaders(clientType); + const response = await fetch(url, { + method: 'POST', + headers, + body: JSON.stringify(submission), + }); + + if (!response.ok) { + throw new HttpError( + response.status, + `Failed to create delegation: ${response.status}`, + ); + } + + return null; + }, + }); + + await this.invalidateQueries({ + queryKey: [`${this.name}:listDelegations`], + }); + } + + /** + * Revokes (deletes) a delegation record. + * + * @param delegationHash - The unique hash identifying the delegation. + */ + async revokeDelegation(delegationHash: string): Promise { + const url = `${getAuthenticatedStorageUrl(this.#environment)}/delegations/${encodeURIComponent(delegationHash)}`; + + await this.fetchQuery({ + queryKey: [`${this.name}:revokeDelegation`, delegationHash], + staleTime: 0, + queryFn: async () => { + const headers = await this.#getHeaders(); + const response = await fetch(url, { + method: 'DELETE', + headers, + }); + + if (!response.ok) { + throw new HttpError( + response.status, + `Failed to revoke delegation: ${response.status}`, + ); + } + + return null; + }, + }); + + await this.invalidateQueries({ + queryKey: [`${this.name}:listDelegations`], + }); + } + + /** + * Returns the notification preferences for the authenticated user. + * + * @returns The notification preferences object, or `null` if none have been + * set (404). + */ + async getNotificationPreferences(): Promise { + const url = `${getAuthenticatedStorageUrl(this.#environment)}/preferences/notifications`; + + const data = await this.fetchQuery({ + queryKey: [`${this.name}:getNotificationPreferences`], + queryFn: async () => { + const headers = await this.#getHeaders(); + const response = await fetch(url, { headers }); + + if (response.status === 404) { + return null; + } + + if (!response.ok) { + throw new HttpError( + response.status, + `Failed to get notification preferences: ${response.status}`, + ); + } + + return response.json(); + }, + }); + + if (data === null) { + return null; + } + + assertNotificationPreferences(data); + return data; + } + + /** + * Creates or updates the notification preferences for the authenticated user. + * + * @param prefs - The full notification preferences object. + * @param clientType - Optional client type header. + */ + async putNotificationPreferences( + prefs: NotificationPreferences, + clientType?: ClientType, + ): Promise { + const url = `${getAuthenticatedStorageUrl(this.#environment)}/preferences/notifications`; + + await this.fetchQuery({ + queryKey: [ + `${this.name}:putNotificationPreferences`, + prefs as unknown as Json, + ], + staleTime: 0, + queryFn: async () => { + const headers = await this.#getHeaders(clientType); + const response = await fetch(url, { + method: 'PUT', + headers, + body: JSON.stringify(prefs), + }); + + if (!response.ok) { + throw new HttpError( + response.status, + `Failed to put notification preferences: ${response.status}`, + ); + } + + return null; + }, + }); + + await this.invalidateQueries({ + queryKey: [`${this.name}:getNotificationPreferences`], + }); + } + + /** + * Returns the assets-watchlist for the authenticated user. + * + * @returns The assets-watchlist blob, or `null` if none has been set (404). + */ + async getAssetsWatchlist(): Promise { + const url = `${getAuthenticatedStorageUrl(this.#environment)}/preferences/assets-watchlist`; + + const data = await this.fetchQuery({ + queryKey: [`${this.name}:getAssetsWatchlist`], + queryFn: async () => { + const headers = await this.#getHeaders(); + const response = await fetch(url, { headers }); + + if (response.status === 404) { + return null; + } + + if (!response.ok) { + throw new HttpError( + response.status, + `Failed to get assets watchlist: ${response.status}`, + ); + } + + return response.json(); + }, + }); + + if (data === null) { + return null; + } + + assertAssetsWatchlistBlob(data); + return data; + } + + /** + * Creates or updates the assets-watchlist for the authenticated user. + * + * @param blob - The full assets-watchlist blob. The `assets` array may + * contain at most `ASSETS_WATCHLIST_MAX_ASSETS` CAIP-19 asset identifiers; + * this is enforced by `assertAssetsWatchlistBlobForWrite` before the + * request is sent. + * @param clientType - Optional client type header. + * @throws A `StructError` from `@metamask/superstruct` if `blob` is + * structurally invalid or `assets` exceeds the cap; an `HttpError` from + * `@metamask/controller-utils` if the API responds with a non-2xx status. + */ + async setAssetsWatchlist( + blob: AssetsWatchlistBlob, + clientType?: ClientType, + ): Promise { + assertAssetsWatchlistBlobForWrite(blob); + + const url = `${getAuthenticatedStorageUrl(this.#environment)}/preferences/assets-watchlist`; + + await this.fetchQuery({ + queryKey: [`${this.name}:setAssetsWatchlist`, blob as unknown as Json], + staleTime: 0, + queryFn: async () => { + const headers = await this.#getHeaders(clientType); + const response = await fetch(url, { + method: 'PUT', + headers, + body: JSON.stringify(blob), + }); + + if (!response.ok) { + throw new HttpError( + response.status, + `Failed to put assets watchlist: ${response.status}`, + ); + } + + return null; + }, + }); + + await this.invalidateQueries({ + queryKey: [`${this.name}:getAssetsWatchlist`], + }); + } + + async #getHeaders(clientType?: ClientType): Promise> { + const accessToken = await this.messenger.call( + 'AuthenticationController:getBearerToken', + ); + const headers: Record = { + 'Content-Type': 'application/json', + Authorization: `Bearer ${accessToken}`, + }; + if (clientType) { + headers['X-Client-Type'] = clientType; + } + return headers; + } +} diff --git a/packages/authenticated-user-storage/src/env.ts b/packages/authenticated-user-storage/src/env.ts new file mode 100644 index 00000000000..38584f144d4 --- /dev/null +++ b/packages/authenticated-user-storage/src/env.ts @@ -0,0 +1,22 @@ +export type Environment = 'dev' | 'uat' | 'prod'; + +const API_SUBDOMAINS = { + dev: 'dev-api', + uat: 'uat-api', + prod: 'api', +} as const satisfies Record; + +/** + * Returns the user-storage API base URL for the given environment. + * + * @param environment - The target environment. + * @returns The base URL for the user-storage API. + * @throws If the environment is invalid. + */ +export function getUserStorageApiUrl(environment: Environment): string { + const subdomain = API_SUBDOMAINS[environment]; + if (!subdomain) { + throw new Error(`Invalid environment: ${String(environment)}`); + } + return `https://user-storage.${subdomain}.cx.metamask.io`; +} diff --git a/packages/authenticated-user-storage/src/index.ts b/packages/authenticated-user-storage/src/index.ts new file mode 100644 index 00000000000..d1bb02bdbca --- /dev/null +++ b/packages/authenticated-user-storage/src/index.ts @@ -0,0 +1,47 @@ +export { + getAuthenticatedStorageUrl, + AuthenticatedUserStorageService, +} from './authenticated-user-storage.js'; +export { + ASSETS_WATCHLIST_MAX_ASSETS, + DEFAULT_AGENTIC_CLI_PREFERENCES, + DEFAULT_PRICE_ALERT_PREFERENCES, +} from './validators.js'; +export type { + AuthenticatedUserStorageActions, + AuthenticatedUserStorageCacheUpdatedEvent, + AuthenticatedUserStorageEvents, + AuthenticatedUserStorageGranularCacheUpdatedEvent, + AuthenticatedUserStorageInvalidateQueriesAction, + AuthenticatedUserStorageMessenger, +} from './authenticated-user-storage.js'; +export type { + AuthenticatedUserStorageServiceListDelegationsAction, + AuthenticatedUserStorageServiceCreateDelegationAction, + AuthenticatedUserStorageServiceRevokeDelegationAction, + AuthenticatedUserStorageServiceGetNotificationPreferencesAction, + AuthenticatedUserStorageServicePutNotificationPreferencesAction, + AuthenticatedUserStorageServiceGetAssetsWatchlistAction, + AuthenticatedUserStorageServiceSetAssetsWatchlistAction, +} from './authenticated-user-storage-method-action-types.js'; +export { getUserStorageApiUrl } from './env.js'; +export type { Environment } from './env.js'; +export type { + Caveat, + SignedDelegation, + DelegationMetadata, + DelegationSubmission, + DelegationResponse, + WalletActivityAccount, + WalletActivityPreference, + MarketingPreference, + PerpsWatchlistExchange, + PerpsWatchlistMarkets, + PerpsPreference, + SocialAIPreference, + AgenticCliPreference, + PriceAlertPreference, + NotificationPreferences, + AssetsWatchlistBlob, + ClientType, +} from './types.js'; diff --git a/packages/authenticated-user-storage/src/types.ts b/packages/authenticated-user-storage/src/types.ts new file mode 100644 index 00000000000..8775fc68032 --- /dev/null +++ b/packages/authenticated-user-storage/src/types.ts @@ -0,0 +1,143 @@ +import type { Hex } from '@metamask/utils'; + +// --------------------------------------------------------------------------- +// Delegations +// --------------------------------------------------------------------------- + +/** A single caveat attached to a delegation. */ +export type Caveat = { + /** Address of the caveat enforcer contract (0x-prefixed). */ + enforcer: Hex; + /** ABI-encoded caveat terms (0x-prefixed). */ + terms: Hex; + /** ABI-encoded caveat arguments (0x-prefixed). */ + args: Hex; +}; + +/** An EIP-712 signed delegation. */ +export type SignedDelegation = { + /** Address the delegation is granted to (0x-prefixed). */ + delegate: Hex; + /** Address granting the delegation (0x-prefixed). */ + delegator: Hex; + /** Root authority or parent delegation hash (0x-prefixed). */ + authority: Hex; + /** Caveats restricting how the delegation may be used. */ + caveats: Caveat[]; + /** Unique salt to prevent replay (0x-prefixed). */ + salt: Hex; + /** EIP-712 signature over the delegation (0x-prefixed). */ + signature: Hex; +}; + +/** Metadata associated with a delegation. */ +export type DelegationMetadata = { + /** Keccak-256 hash uniquely identifying the delegation (0x-prefixed). */ + delegationHash: Hex; + /** Chain ID in hex format (0x-prefixed). */ + chainIdHex: Hex; + /** Token allowance in hex format (0x-prefixed). */ + allowance: Hex; + /** Symbol of the token (e.g. "USDC"). */ + tokenSymbol: string; + /** Token contract address (0x-prefixed). */ + tokenAddress: Hex; + /** Type of delegation. */ + type: string; +}; + +/** Request body for submitting a new delegation. */ +export type DelegationSubmission = { + signedDelegation: SignedDelegation; + metadata: DelegationMetadata; +}; + +/** A stored delegation record returned by the API. */ +export type DelegationResponse = { + signedDelegation: SignedDelegation; + metadata: DelegationMetadata; +}; + +// --------------------------------------------------------------------------- +// Preferences +// --------------------------------------------------------------------------- + +/** Wallet activity tracking for a single address. */ +export type WalletActivityAccount = { + /** Wallet address to track activity for (0x-prefixed). */ + address: Hex; + enabled: boolean; +}; + +export type AgenticCliPreference = { + inAppNotificationsEnabled: boolean; + pushNotificationsEnabled: boolean; +}; + +export type WalletActivityPreference = { + inAppNotificationsEnabled: boolean; + pushNotificationsEnabled: boolean; + accounts: WalletActivityAccount[]; +}; + +export type MarketingPreference = { + inAppNotificationsEnabled: boolean; + pushNotificationsEnabled: boolean; +}; + +export type PerpsWatchlistExchange = { + testnet: string[]; + mainnet: string[]; +}; + +export type PerpsWatchlistMarkets = { + hyperliquid: PerpsWatchlistExchange; + myx: PerpsWatchlistExchange; +}; + +export type PerpsPreference = { + inAppNotificationsEnabled: boolean; + pushNotificationsEnabled: boolean; + watchlistMarkets?: PerpsWatchlistMarkets; +}; + +export type SocialAIPreference = { + inAppNotificationsEnabled: boolean; + pushNotificationsEnabled: boolean; + txAmountLimit?: number; + mutedTraderProfileIds: string[]; +}; + +export type PriceAlertPreference = { + inAppNotificationsEnabled: boolean; + pushNotificationsEnabled: boolean; +}; + +/** + * Notification preferences for the authenticated user. + */ +export type NotificationPreferences = { + walletActivity: WalletActivityPreference; + marketing: MarketingPreference; + perps: PerpsPreference; + socialAI: SocialAIPreference; + agenticCli: AgenticCliPreference; + priceAlerts: PriceAlertPreference; +}; + +// --------------------------------------------------------------------------- +// Assets watchlist +// --------------------------------------------------------------------------- + +// `AssetsWatchlistBlob` is inferred from `AssetsWatchlistBlobSchema` in +// `./validators` and re-exported here so the public type surface remains in +// `./types`. Keeping the runtime schema and the static type co-located in +// one file keeps the two in lock-step. +export type { AssetsWatchlistBlob } from './validators.js'; + +// --------------------------------------------------------------------------- +// Shared +// --------------------------------------------------------------------------- + +/** The type of client making the request. */ +export type ClientType = 'extension' | 'mobile' | 'portfolio'; diff --git a/packages/authenticated-user-storage/src/validators.ts b/packages/authenticated-user-storage/src/validators.ts new file mode 100644 index 00000000000..8bc6098c6c6 --- /dev/null +++ b/packages/authenticated-user-storage/src/validators.ts @@ -0,0 +1,238 @@ +import type { Infer } from '@metamask/superstruct'; +import { + array, + assert, + assign, + boolean, + literal, + number, + optional, + pattern, + size, + string, + type, +} from '@metamask/superstruct'; + +import type { + AgenticCliPreference, + DelegationResponse, + NotificationPreferences, + PriceAlertPreference, +} from './types.js'; + +/** + * Matches a 0x-prefixed hex string with zero or more hex digits. + * Unlike `StrictHexStruct` from `@metamask/utils` (which requires at least + * one digit after the prefix), this also accepts `"0x"` — the standard + * encoding for empty bytes that the delegation API returns. + */ +const HexDataStruct = pattern(string(), /^0x[0-9a-f]*$/iu); + +const CaveatSchema = type({ + enforcer: HexDataStruct, + terms: HexDataStruct, + args: HexDataStruct, +}); + +const SignedDelegationSchema = type({ + delegate: HexDataStruct, + delegator: HexDataStruct, + authority: HexDataStruct, + caveats: array(CaveatSchema), + salt: HexDataStruct, + signature: HexDataStruct, +}); + +const DelegationMetadataSchema = type({ + delegationHash: HexDataStruct, + chainIdHex: HexDataStruct, + allowance: HexDataStruct, + tokenSymbol: string(), + tokenAddress: HexDataStruct, + type: string(), +}); + +const DelegationResponseSchema = type({ + signedDelegation: SignedDelegationSchema, + metadata: DelegationMetadataSchema, +}); + +const WalletActivityAccountSchema = type({ + address: HexDataStruct, + enabled: boolean(), +}); + +const WalletActivityPreferenceSchema = type({ + inAppNotificationsEnabled: boolean(), + pushNotificationsEnabled: boolean(), + accounts: array(WalletActivityAccountSchema), +}); + +const MarketingPreferenceSchema = type({ + inAppNotificationsEnabled: boolean(), + pushNotificationsEnabled: boolean(), +}); + +const PerpsWatchlistExchangeSchema = type({ + testnet: array(string()), + mainnet: array(string()), +}); + +const PerpsWatchlistMarketsSchema = type({ + hyperliquid: PerpsWatchlistExchangeSchema, + myx: PerpsWatchlistExchangeSchema, +}); + +const PerpsPreferenceSchema = type({ + inAppNotificationsEnabled: boolean(), + pushNotificationsEnabled: boolean(), + watchlistMarkets: optional(PerpsWatchlistMarketsSchema), +}); + +const SocialAIPreferenceSchema = type({ + inAppNotificationsEnabled: boolean(), + pushNotificationsEnabled: boolean(), + txAmountLimit: optional(number()), + mutedTraderProfileIds: array(string()), +}); + +const AgenticCliPreferenceSchema = type({ + inAppNotificationsEnabled: boolean(), + pushNotificationsEnabled: boolean(), +}); + +const PriceAlertPreferenceSchema = type({ + inAppNotificationsEnabled: boolean(), + pushNotificationsEnabled: boolean(), +}); + +const NotificationPreferencesSchema = type({ + walletActivity: WalletActivityPreferenceSchema, + marketing: MarketingPreferenceSchema, + perps: PerpsPreferenceSchema, + socialAI: SocialAIPreferenceSchema, + agenticCli: AgenticCliPreferenceSchema, + priceAlerts: PriceAlertPreferenceSchema, +}); + +/** + * Default Agentic CLI notification preferences for consumers building a + * fresh `NotificationPreferences` object. + */ +export const DEFAULT_AGENTIC_CLI_PREFERENCES: AgenticCliPreference = { + inAppNotificationsEnabled: true, + pushNotificationsEnabled: true, +}; + +/** + * Default price-alert notification preferences for consumers building a + * fresh `NotificationPreferences` object. + */ +export const DEFAULT_PRICE_ALERT_PREFERENCES: PriceAlertPreference = { + inAppNotificationsEnabled: true, + pushNotificationsEnabled: true, +}; + +/** + * Maximum number of entries allowed in an assets-watchlist on write. Reads + * are lenient: a server payload exceeding this size will still validate as + * an `AssetsWatchlistBlob`. Encoded into + * {@link AssetsWatchlistBlobWriteSchema}. + */ +export const ASSETS_WATCHLIST_MAX_ASSETS = 100; + +/** + * The shape we accept on the way **in** from the server. Lenient by design: + * a malformed payload throws, but a well-formed payload with more than + * {@link ASSETS_WATCHLIST_MAX_ASSETS} assets is still considered valid so we + * don't reject existing server-side data. + */ +const AssetsWatchlistBlobSchema = type({ + version: literal(1), + assets: array(string()), +}); + +/** + * The shape we accept on the way **out** to the server. Extends + * {@link AssetsWatchlistBlobSchema} with a hard cap on `assets.length`. + * Validation failures throw a `StructError`, e.g. + * `"At path: assets -- Expected a array with a length between \`0\` and + * \`100\` but received one with a length of \`N\`"`. + */ +const AssetsWatchlistBlobWriteSchema = assign( + AssetsWatchlistBlobSchema, + type({ + assets: size(array(string()), 0, ASSETS_WATCHLIST_MAX_ASSETS), + }), +); + +/** + * The authenticated user's assets-watchlist: a mutable per-user singleton + * blob. + * + * Each entry is a CAIP-19 asset identifier + * (e.g. `eip155:1/erc20:0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48`). + * + * The `version` literal is carried inside the blob (not in the URL) so the + * schema can evolve in a backwards-compatible way; bumping the version + * indicates a different `assets` element shape. + * + * Inferred from {@link AssetsWatchlistBlobSchema} so the runtime schema and + * the static type stay in lock-step. The size constraint on writes is + * enforced by {@link AssetsWatchlistBlobWriteSchema} and is not encoded in + * this static type (TypeScript cannot express "array of length ≤ N"). + */ +export type AssetsWatchlistBlob = Infer; + +/** + * Asserts that the given value is a valid `DelegationResponse[]`. + * + * @param data - The unknown value to validate. + * @throws If the value does not match the expected schema. + */ +export function assertDelegationResponseArray( + data: unknown, +): asserts data is DelegationResponse[] { + assert(data, array(DelegationResponseSchema)); +} + +/** + * Asserts that the given value is a valid `NotificationPreferences`. + * + * @param data - The unknown value to validate. + * @throws If the value does not match the expected schema. + */ +export function assertNotificationPreferences( + data: unknown, +): asserts data is NotificationPreferences { + assert(data, NotificationPreferencesSchema); +} + +/** + * Asserts that the given value is a valid `AssetsWatchlistBlob` (read-side, + * lenient). + * + * @param data - The unknown value to validate. + * @throws If the value does not match the expected schema. + */ +export function assertAssetsWatchlistBlob( + data: unknown, +): asserts data is AssetsWatchlistBlob { + assert(data, AssetsWatchlistBlobSchema); +} + +/** + * Asserts that the given value is a valid `AssetsWatchlistBlob` for + * **writes**. In addition to the structural checks performed by + * {@link assertAssetsWatchlistBlob}, this enforces that `assets` contains at + * most {@link ASSETS_WATCHLIST_MAX_ASSETS} entries. + * + * @param data - The unknown value to validate. + * @throws A `StructError` if the value does not match the expected schema + * (including the size constraint). + */ +export function assertAssetsWatchlistBlobForWrite( + data: unknown, +): asserts data is AssetsWatchlistBlob { + assert(data, AssetsWatchlistBlobWriteSchema); +} diff --git a/packages/authenticated-user-storage/tests/fixtures/authenticated-userstorage.ts b/packages/authenticated-user-storage/tests/fixtures/authenticated-userstorage.ts new file mode 100644 index 00000000000..10d005b4e90 --- /dev/null +++ b/packages/authenticated-user-storage/tests/fixtures/authenticated-userstorage.ts @@ -0,0 +1,105 @@ +import nock from 'nock'; + +import { + MOCK_ASSETS_WATCHLIST_BLOB, + MOCK_ASSETS_WATCHLIST_URL, + MOCK_DELEGATIONS_URL, + MOCK_DELEGATION_RESPONSE, + MOCK_NOTIFICATION_PREFERENCES, + MOCK_NOTIFICATION_PREFERENCES_URL, +} from '../mocks/authenticated-userstorage.js'; + +type MockReply = { + status: nock.StatusCode; + body?: nock.Body; +}; + +export function handleMockListDelegations(mockReply?: MockReply): nock.Scope { + const reply = mockReply ?? { + status: 200, + body: [MOCK_DELEGATION_RESPONSE], + }; + return nock(MOCK_DELEGATIONS_URL) + .persist() + .get('') + .reply(reply.status, reply.body); +} + +export function handleMockCreateDelegation( + mockReply?: MockReply, + callback?: (uri: string, requestBody: nock.Body) => Promise, +): nock.Scope { + const reply = mockReply ?? { status: 200 }; + const interceptor = nock(MOCK_DELEGATIONS_URL).persist().post(''); + + if (callback) { + return interceptor.reply(reply.status, async (uri, requestBody) => { + return callback(uri, requestBody); + }); + } + return interceptor.reply(reply.status, reply.body); +} + +export function handleMockRevokeDelegation(mockReply?: MockReply): nock.Scope { + const reply = mockReply ?? { status: 204 }; + return nock(MOCK_DELEGATIONS_URL) + .persist() + .delete(/.*/u) + .reply(reply.status, reply.body); +} + +export function handleMockGetNotificationPreferences( + mockReply?: MockReply, +): nock.Scope { + const reply = mockReply ?? { + status: 200, + body: MOCK_NOTIFICATION_PREFERENCES, + }; + return nock(MOCK_NOTIFICATION_PREFERENCES_URL) + .persist() + .get('') + .reply(reply.status, reply.body); +} + +export function handleMockPutNotificationPreferences( + mockReply?: MockReply, + callback?: (uri: string, requestBody: nock.Body) => Promise, +): nock.Scope { + const reply = mockReply ?? { status: 200 }; + const interceptor = nock(MOCK_NOTIFICATION_PREFERENCES_URL).persist().put(''); + + if (callback) { + return interceptor.reply(reply.status, async (uri, requestBody) => { + return callback(uri, requestBody); + }); + } + return interceptor.reply(reply.status, reply.body); +} + +export function handleMockGetAssetsWatchlist( + mockReply?: MockReply, +): nock.Scope { + const reply = mockReply ?? { + status: 200, + body: MOCK_ASSETS_WATCHLIST_BLOB, + }; + return nock(MOCK_ASSETS_WATCHLIST_URL) + .persist() + .get('') + .reply(reply.status, reply.body); +} + +export function handleMockSetAssetsWatchlist( + mockReply?: MockReply, + callback?: (uri: string, requestBody: nock.Body) => Promise, +): nock.Scope { + const reply = mockReply ?? { status: 200 }; + const interceptor = nock(MOCK_ASSETS_WATCHLIST_URL).persist().put(''); + + if (callback) { + return interceptor.reply(reply.status, async (uri, requestBody) => { + return callback(uri, requestBody); + }); + } + return interceptor.reply(reply.status, reply.body); +} diff --git a/packages/authenticated-user-storage/tests/mocks/authenticated-userstorage.ts b/packages/authenticated-user-storage/tests/mocks/authenticated-userstorage.ts new file mode 100644 index 00000000000..e959a2fa211 --- /dev/null +++ b/packages/authenticated-user-storage/tests/mocks/authenticated-userstorage.ts @@ -0,0 +1,90 @@ +import { getAuthenticatedStorageUrl } from '../../src/authenticated-user-storage.js'; +import type { + AssetsWatchlistBlob, + DelegationResponse, + DelegationSubmission, + NotificationPreferences, +} from '../../src/types.js'; +import { DEFAULT_PRICE_ALERT_PREFERENCES } from '../../src/validators.js'; + +export const MOCK_DELEGATIONS_URL = `${getAuthenticatedStorageUrl('prod')}/delegations`; +export const MOCK_NOTIFICATION_PREFERENCES_URL = `${getAuthenticatedStorageUrl('prod')}/preferences/notifications`; +export const MOCK_ASSETS_WATCHLIST_URL = `${getAuthenticatedStorageUrl('prod')}/preferences/assets-watchlist`; + +export const MOCK_DELEGATION_SUBMISSION: DelegationSubmission = { + signedDelegation: { + delegate: '0x1111111111111111111111111111111111111111', + delegator: '0x2222222222222222222222222222222222222222', + authority: + '0x0000000000000000000000000000000000000000000000000000000000000000', + caveats: [ + { + enforcer: '0x1234567890abcdef1234567890abcdef12345678', + terms: '0xabcdef', + args: '0x', + }, + ], + salt: '0x00000001', + signature: '0xaabbcc', + }, + metadata: { + delegationHash: + '0xdae6d132587770a2eb84411e125d9458a5fa3ec28615fee332f1947515041d10', + chainIdHex: '0x1', + allowance: '0xde0b6b3a7640000', + tokenSymbol: 'USDC', + tokenAddress: '0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48', + type: 'spend', + }, +}; + +export const MOCK_DELEGATION_RESPONSE: DelegationResponse = + MOCK_DELEGATION_SUBMISSION; + +export const MOCK_NOTIFICATION_PREFERENCES: NotificationPreferences = { + walletActivity: { + inAppNotificationsEnabled: true, + pushNotificationsEnabled: true, + accounts: [ + { + address: '0x1234567890abcdef1234567890abcdef12345678', + enabled: true, + }, + ], + }, + marketing: { + inAppNotificationsEnabled: false, + pushNotificationsEnabled: false, + }, + perps: { + inAppNotificationsEnabled: true, + pushNotificationsEnabled: true, + }, + socialAI: { + inAppNotificationsEnabled: true, + pushNotificationsEnabled: true, + txAmountLimit: 100, + mutedTraderProfileIds: [ + 'b3a7c9d1-4e2f-4a8b-9c6d-1f2e3a4b5c6d', + 'e8f2a1b3-5c4d-4e6f-8a9b-2c3d4e5f6a7b', + ], + }, + agenticCli: { + inAppNotificationsEnabled: true, + pushNotificationsEnabled: false, + }, + priceAlerts: { ...DEFAULT_PRICE_ALERT_PREFERENCES }, +}; + +export const MOCK_ASSETS_WATCHLIST_BLOB: AssetsWatchlistBlob = { + version: 1, + assets: [ + 'eip155:1/erc20:0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48', + 'eip155:1/slip44:60', + ], +}; + +export const MOCK_INVALID_ASSETS_WATCHLIST_BLOB = { + version: 2, + assets: 'not-an-array', +} as const; diff --git a/packages/authenticated-user-storage/tsconfig.build.json b/packages/authenticated-user-storage/tsconfig.build.json new file mode 100644 index 00000000000..02d3bf93d6f --- /dev/null +++ b/packages/authenticated-user-storage/tsconfig.build.json @@ -0,0 +1,14 @@ +{ + "extends": "../../tsconfig.packages.build.json", + "compilerOptions": { + "baseUrl": "./", + "outDir": "./dist", + "rootDir": "./src" + }, + "references": [ + { "path": "../base-data-service/tsconfig.build.json" }, + { "path": "../controller-utils/tsconfig.build.json" }, + { "path": "../messenger/tsconfig.build.json" } + ], + "include": ["../../types", "./src"] +} diff --git a/packages/authenticated-user-storage/tsconfig.json b/packages/authenticated-user-storage/tsconfig.json new file mode 100644 index 00000000000..97077caafb9 --- /dev/null +++ b/packages/authenticated-user-storage/tsconfig.json @@ -0,0 +1,12 @@ +{ + "extends": "../../tsconfig.packages.json", + "compilerOptions": { + "baseUrl": "./" + }, + "references": [ + { "path": "../base-data-service" }, + { "path": "../controller-utils" }, + { "path": "../messenger" } + ], + "include": ["../../types", "./src", "./tests"] +} diff --git a/packages/authenticated-user-storage/typedoc.json b/packages/authenticated-user-storage/typedoc.json new file mode 100644 index 00000000000..c9da015dbf8 --- /dev/null +++ b/packages/authenticated-user-storage/typedoc.json @@ -0,0 +1,7 @@ +{ + "entryPoints": ["./src/index.ts"], + "excludePrivate": true, + "hideGenerator": true, + "out": "docs", + "tsconfig": "./tsconfig.build.json" +} diff --git a/packages/base-controller/CHANGELOG.md b/packages/base-controller/CHANGELOG.md index 485aad9898e..b837af716bf 100644 --- a/packages/base-controller/CHANGELOG.md +++ b/packages/base-controller/CHANGELOG.md @@ -1,4 +1,5 @@ # 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/), @@ -6,60 +7,423 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Changed + +- Bump `@metamask/utils` from `^11.9.0` to `^11.11.0` ([#9074](https://github.com/MetaMask/core/pull/9074)) +- Bump `@metamask/messenger` from `^1.1.1` to `^2.0.0` ([#8632](https://github.com/MetaMask/core/pull/8632), [#9392](https://github.com/MetaMask/core/pull/9392)) + +## [9.1.0] + +### Added + +- Add `${ControllerName}:stateChanged` as alternative to `${ControllerName}:stateChange` ([#8187](https://github.com/MetaMask/core/pull/8187)) + - Add corresponding utility type, `ControllerStateChangedEvent`, as well. + +### Changed + +- Bump `@metamask/messenger` from `^1.0.0` to `^1.1.1` ([#8364](https://github.com/MetaMask/core/pull/8364), [#8373](https://github.com/MetaMask/core/pull/8373)) + +### Deprecated + +- Deprecate `${ControllerName}:stateChange` event in favor of `${ControllerName}:stateChanged` ([#8187](https://github.com/MetaMask/core/pull/8187)) + +## [9.0.1] + +### Changed + +- Bump `@metamask/messenger` from `^0.3.0` to `^1.0.0` ([#8317](https://github.com/MetaMask/core/pull/8317)) +- Upgrade `@metamask/utils` from `^11.8.1` to `^11.9.0` ([#7511](https://github.com/MetaMask/core/pull/7511)) + +## [9.0.0] + +### Changed + +- **BREAKING:** Update `BaseController` type and constructor to require new `Messenger` from `@metamask/messenger` rather than `RestrictedMessenger` ([#6318](https://github.com/MetaMask/core/pull/6318), [#6926](https://github.com/MetaMask/core/pull/6926)) +- **BREAKING:** Rename `anonymous` metadata property to `includeInDebugSnapshot` ([#6593](https://github.com/MetaMask/core/pull/6593), [#6926](https://github.com/MetaMask/core/pull/6926)) +- **BREAKING:** Make `includeInStateLogs` and `usedInUi` metadata properties required ([#6593](https://github.com/MetaMask/core/pull/6593), [#6926](https://github.com/MetaMask/core/pull/6926)) + - This applies both to the `BaseController` type and the `StatePropertyMetadataConstraint` type +- **BREAKING:** Rename `ListenerV2` type export to `StateChangeListener` ([#6339](https://github.com/MetaMask/core/pull/6339), [#6926](https://github.com/MetaMask/core/pull/6926)) +- **BREAKING:** Rename `messagingSystem` protected instance variable to `messenger` ([#6337](https://github.com/MetaMask/core/pull/6337), [#6926](https://github.com/MetaMask/core/pull/6926)) + +### Removed + +- **BREAKING:** Remove `Messenger` and `RestrictedMessenger` ([#6926](https://github.com/MetaMask/core/pull/6926)) +- **BREAKING:** Remove `isBaseController` ([#6341](https://github.com/MetaMask/core/pull/6341), [#6926](https://github.com/MetaMask/core/pull/6926)) +- **BREAKING:** Remove deprecated exports `getPersistentState` and `getAnonymizedState` ([#6611](https://github.com/MetaMask/core/pull/6611), [#6926](https://github.com/MetaMask/core/pull/6926)) +- **BREAKING:** Remove `next` export ([#6926](https://github.com/MetaMask/core/pull/6926)) + +## [8.4.2] + +### Fixed + +- Fix TypeScript module resolution for `/next` subpath export with legacy resolution mode ([#6915](https://github.com/MetaMask/core/pull/6915)) + - Added `next.d.ts` file to enable imports like `import { BaseController } from '@metamask/base-controller/next'` to work with both legacy TypeScript module resolution and Node16/NodeNext resolution modes + - Previously, this import pattern only worked with Node16/NodeNext resolution which uses the `exports` field in package.json + +## [8.4.1] + +### Changed + +- Bump `@metamask/utils` from `^11.8.0` to `^11.8.1` ([#6708](https://github.com/MetaMask/core/pull/6708)) + +## [8.4.0] + +### Added + +- Add optional `captureException` parameter to `deriveStateFromMetadata`, `getPersistentState`, and `getAnonymizedState` ([#6606](https://github.com/MetaMask/core/pull/6606)) + - This function will be used to capture any errors encountered during state derivation. + +### Changed + +- Bump `@metamask/utils` from `^11.4.2` to `^11.8.0` ([#6588](https://github.com/MetaMask/core/pull/6588)) +- In experimental `next` export, rename `anonymous` metadata property to `includeInDebugSnapshot` ([#6593](https://github.com/MetaMask/core/pull/6593)) +- In experimental `next` export, make `includeInStateLogs` and `usedInUi` metadata properties required ([#6593](https://github.com/MetaMask/core/pull/6593)) +- In experimental `next` export, remove deprecated exports `getPersistentState` and `getAnonymizedState` ([#6611](https://github.com/MetaMask/core/pull/6611)) +- Stop re-throwing state derivation errors in a `setTimeout` ([#6606](https://github.com/MetaMask/core/pull/6606)) + - Instead errors are captured with `captureException`, or logged to the console. +- Bump `@metamask/messenger` from `^0.2.0` to `^0.3.0` ([#6632](https://github.com/MetaMask/core/pull/6632)) + +## [8.3.0] + +### Added + +- Add `deriveStateFromMetadata` export, which can derive state for any metadata property ([#6359](https://github.com/MetaMask/core/pull/6359)) + - This change has also been made to the experimental `next` export. +- Add optional `includeInStateLogs` and `usedInUi` metadata properties ([#6359](https://github.com/MetaMask/core/pull/6359)) + - State derivation is disallowed for `usedInUi`. + - This change has also been made to the experimental `next` export. + +### Changed + +- Bump `@metamask/messenger` from `^0.1.0` to `^0.2.0` ([#6465](https://github.com/MetaMask/core/pull/6465)) + +### Deprecated + +- Deprecate `getPersistentState` and `getAnonymizedState`, recommending `deriveStateFromMetadata` instead ([#6359](https://github.com/MetaMask/core/pull/6359)) + - This change has also been made to the experimental `next` export. + +## [8.2.0] + +### Added + +- Add experimental `next` export for testing upcoming breaking changes ([#6316](https://github.com/MetaMask/core/pull/6316)) + - Note that this should generally not be used, and further breaking changes may be made under this export without a corresponding major version bump for this package. + - Changes: + - Update `BaseController` type and constructor to require new `Messenger` from `@metamask/messenger` rather than `RestrictedMessenger` ([#6318](https://github.com/MetaMask/core/pull/6318)) + - Rename `ListenerV2` type export to `StateChangeListener` ([#6339](https://github.com/MetaMask/core/pull/6339)) + - Rename `messagingSystem` protected instance variable to `messenger` ([#6337](https://github.com/MetaMask/core/pull/6337)) + - Remove `isBaseController` ([#6341](https://github.com/MetaMask/core/pull/6341)) + +### Changed + +- Add dependency on `@metamask/messenger` ([#6318](https://github.com/MetaMask/core/pull/6318)) + - This is only used by the experimental `next` export for now. + +## [8.1.0] + +### Added + +- Add `registerMethodActionHandlers` method to `Messenger`, and `RestrictedMessenger` for simplified bulk action handler registration ([#5927](https://github.com/MetaMask/core/pull/5927)) + - Allows registering action handlers that map to methods on a messenger client at once by passing an array of method names + - Automatically binds action handlers to the given messenger client + +### Changed + +- Bump `@metamask/utils` from `^11.2.0` to `^11.4.2` ([#6054](https://github.com/MetaMask/core/pull/6054)) +- Add default for `ReturnHandler` type parameter of `SelectorEventHandler` and `SelectorFunction` ([#6262](https://github.com/MetaMask/core/pull/6262), [#6264](https://github.com/MetaMask/core/pull/6264)) + +### Fixed + +- Update `unsubscribe` type signature to support selector event handlers ([#6262](https://github.com/MetaMask/core/pull/6262)) + +## [8.0.1] + +### Changed + +- Don't emit `:stateChange` from `BaseController` unnecessarily ([#5480](https://github.com/MetaMask/core/pull/5480)) + +## [8.0.0] + +### Changed + +- **BREAKING:** Remove deprecated messenger-related exports and simplify `RestrictedMessenger` constructor ([#5260](https://github.com/MetaMask/core/pull/5260)) + - Remove `ControllerMessenger` export which was an alias for `Messenger`. Consumers should import `Messenger` directly + - Remove `RestrictedControllerMessenger` export which was an alias for `RestrictedMessenger`. Consumers should import `RestrictedMessenger` directly + - Remove `RestrictedControllerMessengerConstraint` type export which was an alias for `RestrictedMessengerConstraint`. Consumers should use `RestrictedMessengerConstraint` type directly + - Simplify `RestrictedMessenger` constructor by removing deprecated `controllerMessenger` parameter. The messenger instance should now be passed using only the `messenger` parameter instead of supporting both options +- Widen input parameter for type guard `isBaseController` from `ControllerInstance` to `unknown` ([#5018](https://github.com/MetaMask/core/pull/5018/)) +- Bump `@metamask/json-rpc-engine` from `^10.0.2` to `^10.0.3` ([#5272](https://github.com/MetaMask/core/pull/5272)) +- Bump `@metamask/utils` from `^11.0.1` to `^11.1.0` ([#5223](https://github.com/MetaMask/core/pull/5223)) + +### Removed + +- **BREAKING:** Remove class `BaseControllerV1` and type guard `isBaseControllerV1` ([#5018](https://github.com/MetaMask/core/pull/5018/)) +- **BREAKING:** Remove types `BaseConfig`, `BaseControllerV1Instance`, `BaseState`, `ConfigConstraintV1`, `Listener`, `StateConstraintV1`, `LegacyControllerStateConstraint`, `ControllerInstance` ([#5018](https://github.com/MetaMask/core/pull/5018/)) + +## [7.1.1] + +### Changed + +- Bump `@metamask/utils` from `^10.0.0` to `^11.0.1` ([#5080](https://github.com/MetaMask/core/pull/5080)) + +## [7.1.0] + +### Changed + +- Rename `ControllerMessenger` to `Messenger` ([#5050](https://github.com/MetaMask/core/pull/5050)) + - `ControllerMessenger` has been renamed to `Messenger` + - `RestrictedControllerMessengerConstraint` has been renamed to `RestrictedMessengerConstraint` + - `RestrictedControllerMessenger` has been renamed to `RestrictedMessenger` + - The `RestrictedMessenger` constructor parameter `controllerMessenger` has been renamed to `messenger`, though the old name is still accepted + - The old names remain exported as deprecated aliases of the new names, so this is not a breaking change. + +## [7.0.2] + +### Changed + +- Bump `@metamask/utils` from `^9.1.0` to `^10.0.0` ([#4831](https://github.com/MetaMask/core/pull/4831)) + +## [7.0.1] + +### Fixed + +- Produce and export ESM-compatible TypeScript type declaration files in addition to CommonJS-compatible declaration files ([#4648](https://github.com/MetaMask/core/pull/4648)) + - Previously, this package shipped with only one variant of type declaration + files, and these files were only CommonJS-compatible, and the `exports` + field in `package.json` linked to these files. This is an anti-pattern and + was rightfully flagged by the + ["Are the Types Wrong?"](https://arethetypeswrong.github.io/) tool as + ["masquerading as CJS"](https://github.com/arethetypeswrong/arethetypeswrong.github.io/blob/main/docs/problems/FalseCJS.md). + All of the ATTW checks now pass. +- Remove chunk files ([#4648](https://github.com/MetaMask/core/pull/4648)). + - Previously, the build tool we used to generate JavaScript files extracted + common code to "chunk" files. While this was intended to make this package + more tree-shakeable, it also made debugging more difficult for our + development teams. These chunk files are no longer present. + +## [7.0.0] + +### Added + +- Migrate from `@metamask/composable-controller@8.0.0` into `@metamask/base-controller`: types `LegacyControllerStateConstraint`, `RestrictedControllerMessengerConstraint` and type guard functions `isBaseController`, `isBaseControllerV1` ([#4581](https://github.com/MetaMask/core/pull/4581)) +- Add and export types `ControllerInstance`, `BaseControllerInstance`, `StateDeriverConstraint`, `StateMetadataConstraint`, `StatePropertyMetadataConstraint`, `BaseControllerV1Instance`, `ConfigConstraintV1`, `StateConstraintV1` ([#4581](https://github.com/MetaMask/core/pull/4581)) + +### Fixed + +- **BREAKING:** Fix `StateMetadata` type so that it requires associated metadata for all optional and non-optional top-level state properties ([#4612](https://github.com/MetaMask/core/pull/4612)) + - Fixes issue of runtime error being thrown during `BaseController` instantiation due to missing metadata for optional state properties. + +## [6.0.3] + +### Changed + +- Bump `typescript` from `~5.0.4` to `~5.2.2` ([#4576](https://github.com/MetaMask/core/pull/4576), [#4584](https://github.com/MetaMask/core/pull/4584)) + +## [6.0.2] + +### Changed + +- Bump TypeScript version to `~5.0.4` and set `moduleResolution` option to `Node16` ([#3645](https://github.com/MetaMask/core/pull/3645)) +- Bump `@metamask/utils` from `^9.0.0` to `^9.1.0` ([#4529](https://github.com/MetaMask/core/pull/4529)) + +## [6.0.1] + +### Changed + +- Bump `@metamask/rpc-errors` from `6.2.1` to `^6.3.1` ([#4516](https://github.com/MetaMask/core/pull/4516)) +- Bump `@metamask/utils` from `^8.3.0` to `^9.0.0` ([#4516](https://github.com/MetaMask/core/pull/4516)) + +## [6.0.0] + +### Changed + +- **BREAKING:** Bump minimum Node version to 18.18 ([#3611](https://github.com/MetaMask/core/pull/3611)) + +## [5.0.2] + +### Changed + +- Bump TypeScript version to `~4.9.5` ([#4084](https://github.com/MetaMask/core/pull/4084)) + +## [5.0.1] + +### Fixed + +- Fix `types` field in `package.json` ([#4047](https://github.com/MetaMask/core/pull/4047)) + +## [5.0.0] + +### Added + +- **BREAKING**: Add ESM build ([#3998](https://github.com/MetaMask/core/pull/3998)) + - It's no longer possible to import files from `./dist` directly. +- Add and export type `StateConstraint`, which is an alias for `Record` ([#3949](https://github.com/MetaMask/core/pull/3949)) + - This type represents the narrowest supertype of the state of all controllers. + - Importing this type enables controllers to constrain state objects and types to be JSON-serializable without having to directly add `@metamask/utils` as a dependency. + +### Changed + +- **BREAKING:** Narrow the return types of functions `getAnonymizedState` and `getPersistentState` from `Record` to `Record`. ([#3949](https://github.com/MetaMask/core/pull/3949), [#4040](https://github.com/MetaMask/core/pull/4040)) +- **BREAKING:** Align type-level and runtime behavior of `getRestricted` so that omitted or empty inputs consistently represent a set of empty allowlists ([#4013](https://github.com/MetaMask/core/pull/4013)) + - If the `AllowedActions` and `AllowedEvents` generic parameters are omitted, they are always assumed to be `never`. + - Previously, omission of these generic parameters resulted in the full allowlists for the controller being inferred as type constraints for the `allowedActions` and `allowedEvents` function parameters. + - If the function parameters `allowedActions` and `allowedEvents` are a non-empty array, their corresponding type names must be explicitly passed into generic parameters `AllowedActions` and `AllowedEvents` to avoid type errors. + - This may cause some duplication of allowlists between type-level and value-level code. + - This requirement is only relevant for TypeScript code. A JavaScript consumer only needs to pass in the correct value-level function parameters. Because of this, these changes should not affect downstream JavaScript code, but may be disruptive to TypeScript code. + - `getRestricted` is still able to flag `AllowedActions` and `AllowedEvents` members that should not be included in the allowlists, based on the `Action` and `Event` generic arguments passed into the `ControllerMessenger` instance. +- **BREAKING:** The `RestrictedControllerMessenger` class constructor now expects `allowedActions` and `allowedEvents` as required options ([#4013](https://github.com/MetaMask/core/pull/4013)) +- **BREAKING**: Add `string` as generic constraint to the `Name` generic parameter of the types `NamespacedBy` and `NotNamespacedBy` ([#4036](https://github.com/MetaMask/core/pull/4036)) +- **BREAKING:** The `getRestricted` method of the `ControllerMessenger` class now expects both `allowedActions` and `allowedEvents` as required parameters. + - An empty array is required if no allowed actions or events are desired. +- Convert interface `StatePropertyMetadata` into a type alias ([#3949](https://github.com/MetaMask/core/pull/3949)) + +### Removed + +- **BREAKING:** Remove the deprecated `subscribe` class field from `BaseController` ([#3949](https://github.com/MetaMask/core/pull/3949)) + - This property was used to differentiate between `BaseControllerV1` and `BaseController` (v2) controllers. It is no longer used. + +### Fixed + +- **BREAKING:** Narrow the generic constraint of the `ControllerState` parameter from `Record` to `Record` for types `ControllerGetStateAction`, `ControllerStateChangeEvent`, `ControllerActions`, and `ControllerEvents` ([#3949](https://github.com/MetaMask/core/pull/3949)) +- **BREAKING:** Fix `BaseController` so that mutating state directly now results in a runtime error ([#4011](https://github.com/MetaMask/core/pull/4011)) + - Directly modifying the state outside of an `update` call may lead to parts of the application being out of sync, because such modifications do not result in the `stateChange` event being fired. + - Instead of mutating the state of a controller after instantiation, consumers should either initialize that controller with the proper state via options or should use the `update` method to safely modify the state. +- **BREAKING**: Fix `subscribe` on `ControllerMessenger` and `RestrictedControllerMessenger` to infer correct types for `selector` arguments ([#4012](https://github.com/MetaMask/core/pull/4012)) + - Previously, the types of the arguments that the `selector` function received would always be inferred as `never`, but now the types match those of `publish` (the "event payload"). This means that you shouldn't need to add use type annotations or assertions to type the `selector` arguments. + +## [4.1.1] + +### Changed + +- Bump `@metamask/utils` to `^8.3.0` ([#3769](https://github.com/MetaMask/core/pull/3769)) + +## [4.1.0] + +### Added + +- Add `registerInitialEventPayload` to `ControllerMessenger` and `RestrictedControllerMessenger` ([#3697](https://github.com/MetaMask/core/pull/3697)) + - This allows registering an event payload function for an event, which has the benefit of ensuring the "subscription selector" feature works correctly the first time the event is fired after subscribing. + +### Fixed + +- Fix `subscribe` method selector support on first publish ([#3697](https://github.com/MetaMask/core/pull/3697)) + - An event with a registered initial event payload function will work better with selectors, in that it will correctly compare with the initial selected state and return the previous value the first time it's published. Without this, the initial published event will always return `undefined` as the previous value. +- Subscribers to the `stateChange` event of any `BaseControllerV2`-based controllers will now correctly handle the initial state change event ([#3702](https://github.com/MetaMask/core/pull/3702)) + - Previously the initial state change would always result in this event firing, even for subscriptions with selectors where the selected value has not changed. Additionally, the `previousValue` returned was always set to `undefined` the first time. + - `BaseControllerV2` has been updated to correctly compare with the previous value even for the first state change. The returned `previousValue` is also now guaranteed to be correct even for the initial state change. + +## [4.0.1] + +### Changed + +- Deprecate `subscribe` property from `BaseControllerV2` ([#3590](https://github.com/MetaMask/core/pull/3590), [#3698](https://github.com/MetaMask/core/pull/3698)) + - This property was used to differentiate between `BaseControllerV1` and `BaseControllerV2` controllers. It is no longer used, so it has been marked as deprecated. + +## [4.0.0] + +### Added + +- Add `ControllerGetStateAction` and `ControllerStateChangeEvent` types ([#1890](https://github.com/MetaMask/core/pull/1890), [#2029](https://github.com/MetaMask/core/pull/2029)) +- Add `NamespacedName` type ([#1890](https://github.com/MetaMask/core/pull/1890)) + - This is the narrowest supertype of all names defined within a given namespace. +- Add `NotNamespacedBy` type, which matches an action/event name if and only if it is not prefixed by a given namespace ([#2051](https://github.com/MetaMask/core/pull/2051)) + +### Changed + +- **BREAKING:** Alter controller messenger `ActionHandler` type so `Action` type parameter must satisfy (updated) `ActionConstraint` ([#1890](https://github.com/MetaMask/core/pull/1890)) +- **BREAKING:** Alter controller messenger `ExtractActionParameters` utility type so `Action` type parameter must satisfy (updated) `ActionConstraint` ([#1890](https://github.com/MetaMask/core/pull/1890)) +- **BREAKING:** Alter controller messenger `ExtractEventHandler` utility type so `Event` type parameter must satisfy `EventConstraint` ([#1890](https://github.com/MetaMask/core/pull/1890)) +- **BREAKING:** Alter controller messenger `ExtractEventPayload` utility type so `Event` type parameter must satisfy `EventConstraint` and `Event['payload']` must be an array (to match behavior of `ExtractEventHandler`) ([#1890](https://github.com/MetaMask/core/pull/1890)) +- **BREAKING:** Alter controller messenger `SelectorFunction` type so that its generic parameter `Args` is replaced by `Event`, which must satisfy `EventConstraint`, and it returns a function whose arguments satisfy the event payload type specified by `Event` ([#1890](https://github.com/MetaMask/core/pull/1890)) +- **BREAKING:** `BaseController` is now renamed to `BaseControllerV1` and has been deprecated; `BaseController` now points to what was previously called `BaseControllerV2` ([#2078](https://github.com/MetaMask/core/pull/2078)) + - This should encourage use of `BaseController` v2 for new controllers going forward. + - If your controller is importing `BaseControllerV2`, you will need to import `BaseController` instead. + - If your controller is still importing `BaseController` v1, you will need to import and use `BaseControllerV1` instead. That said, please consider migrating your controller to v2. +- **BREAKING:** The restricted controller messenger now allows calling all internal events and actions by default and prohibits explicitly allowlisting any of them ([#2050](https://github.com/MetaMask/core/pull/2050), [#2051](https://github.com/MetaMask/core/pull/2051)) + - Previously internal events and actions were only usable if they were listed as "allowed" via the `allowedActions` or `allowedEvents` options to the `RestrictedControllerMessenger` constructor or `ControllerMessenger.getRestricted()`. Now this works implicitly. + - In fact, attempting to allowlist any of them will raise a type error, as otherwise, it would be possible to specify a partial list of allowed actions or events, and that would be misleading, since all of them are allowed anyway. +- **BREAKING:** Rename `Namespaced` type to `NamespacedBy` ([#2051](https://github.com/MetaMask/core/pull/2051)) +- Alter controller messenger `ActionConstraint['handler']` type to remove usage of `any` ([#1890](https://github.com/MetaMask/core/pull/1890)) + - This type is now defined as the universal supertype of all functions, meaning any function can be safely assigned as an action handler, regardless of argument types, number of arguments, or return value type. +- Bump `@metamask/utils` to ^8.2.0 ([#1957](https://github.com/MetaMask/core/pull/1957)) + ## [3.2.3] + ### Changed + - Bump dependency on `@metamask/utils` to ^8.1.0 ([#1639](https://github.com/MetaMask/core/pull/1639)) ## [3.2.2] + ### Changed + - Update TypeScript to v4.8.x ([#1718](https://github.com/MetaMask/core/pull/1718)) ## [3.2.1] + ### Changed + - There are no consumer-facing changes to this package. This version is a part of a synchronized release across all packages in our monorepo. ## [3.2.0] + ### Changed -- When deriving state, skip properties with invalid metadata ([#1529](https://github.com/MetaMask/core/pull/1529)) + +- When deriving state, skip properties with invalid metadata ([#1529](https://github.com/MetaMask/core/pull/1529)) - The previous behavior was to throw an error - An error is thrown in a timeout handler so that it can still be captured in the console, and by global unhandled error handlers. - Update `@metamask/utils` to `^6.2.0` ([#1514](https://github.com/MetaMask/core/pull/1514)) ## [3.1.0] + ### Changed + - Prevent event publish from throwing error ([#1475](https://github.com/MetaMask/core/pull/1475)) - The controller messenger will no longer throw when an event subscriber throws an error. Calls to `publish` (either within controllers or on a messenger instance directly) will no longer throw errors. - Errors are thrown in a timeout handler so that they can still be logged and captured. ## [3.0.0] + ### Changed + - **BREAKING:** Bump to Node 16 ([#1262](https://github.com/MetaMask/core/pull/1262)) - Replace `@metamask/controller-utils` dependency with `@metamask/utils` ([#1370](https://github.com/MetaMask/core/pull/1370)) ## [2.0.0] + ### Removed + - **BREAKING:** Remove `isomorphic-fetch` ([#1106](https://github.com/MetaMask/controllers/pull/1106)) - Consumers must now import `isomorphic-fetch` or another polyfill themselves if they are running in an environment without `fetch` ## [1.1.2] + ### Changed + - Rename this repository to `core` ([#1031](https://github.com/MetaMask/controllers/pull/1031)) -- Update `@metamask/controller-utils` package ([#1041](https://github.com/MetaMask/controllers/pull/1041)) +- Update `@metamask/controller-utils` package ([#1041](https://github.com/MetaMask/controllers/pull/1041)) ## [1.1.1] + ### Changed + - Relax dependency on `@metamask/controller-utils` (use `^` instead of `~`) ([#998](https://github.com/MetaMask/core/pull/998)) ## [1.1.0] + ### Added + - Add `applyPatches` function to BaseControllerV2 ([#980](https://github.com/MetaMask/core/pull/980)) ### Changed + - Action and event handler types are now exported ([#987](https://github.com/MetaMask/core/pull/987)) - Update `update` function to expose patches ([#980](https://github.com/MetaMask/core/pull/980)) ## [1.0.0] + ### Added + - Initial release - As a result of converting our shared controllers repo into a monorepo ([#831](https://github.com/MetaMask/core/pull/831)), we've created this package from select parts of [`@metamask/controllers` v33.0.0](https://github.com/MetaMask/core/tree/v33.0.0), namely: - `src/BaseController.ts` @@ -73,7 +437,34 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 All changes listed after this point were applied to this package following the monorepo conversion. -[Unreleased]: https://github.com/MetaMask/core/compare/@metamask/base-controller@3.2.3...HEAD +[Unreleased]: https://github.com/MetaMask/core/compare/@metamask/base-controller@9.1.0...HEAD +[9.1.0]: https://github.com/MetaMask/core/compare/@metamask/base-controller@9.0.1...@metamask/base-controller@9.1.0 +[9.0.1]: https://github.com/MetaMask/core/compare/@metamask/base-controller@9.0.0...@metamask/base-controller@9.0.1 +[9.0.0]: https://github.com/MetaMask/core/compare/@metamask/base-controller@8.4.2...@metamask/base-controller@9.0.0 +[8.4.2]: https://github.com/MetaMask/core/compare/@metamask/base-controller@8.4.1...@metamask/base-controller@8.4.2 +[8.4.1]: https://github.com/MetaMask/core/compare/@metamask/base-controller@8.4.0...@metamask/base-controller@8.4.1 +[8.4.0]: https://github.com/MetaMask/core/compare/@metamask/base-controller@8.3.0...@metamask/base-controller@8.4.0 +[8.3.0]: https://github.com/MetaMask/core/compare/@metamask/base-controller@8.2.0...@metamask/base-controller@8.3.0 +[8.2.0]: https://github.com/MetaMask/core/compare/@metamask/base-controller@8.1.0...@metamask/base-controller@8.2.0 +[8.1.0]: https://github.com/MetaMask/core/compare/@metamask/base-controller@8.0.1...@metamask/base-controller@8.1.0 +[8.0.1]: https://github.com/MetaMask/core/compare/@metamask/base-controller@8.0.0...@metamask/base-controller@8.0.1 +[8.0.0]: https://github.com/MetaMask/core/compare/@metamask/base-controller@7.1.1...@metamask/base-controller@8.0.0 +[7.1.1]: https://github.com/MetaMask/core/compare/@metamask/base-controller@7.1.0...@metamask/base-controller@7.1.1 +[7.1.0]: https://github.com/MetaMask/core/compare/@metamask/base-controller@7.0.2...@metamask/base-controller@7.1.0 +[7.0.2]: https://github.com/MetaMask/core/compare/@metamask/base-controller@7.0.1...@metamask/base-controller@7.0.2 +[7.0.1]: https://github.com/MetaMask/core/compare/@metamask/base-controller@7.0.0...@metamask/base-controller@7.0.1 +[7.0.0]: https://github.com/MetaMask/core/compare/@metamask/base-controller@6.0.3...@metamask/base-controller@7.0.0 +[6.0.3]: https://github.com/MetaMask/core/compare/@metamask/base-controller@6.0.2...@metamask/base-controller@6.0.3 +[6.0.2]: https://github.com/MetaMask/core/compare/@metamask/base-controller@6.0.1...@metamask/base-controller@6.0.2 +[6.0.1]: https://github.com/MetaMask/core/compare/@metamask/base-controller@6.0.0...@metamask/base-controller@6.0.1 +[6.0.0]: https://github.com/MetaMask/core/compare/@metamask/base-controller@5.0.2...@metamask/base-controller@6.0.0 +[5.0.2]: https://github.com/MetaMask/core/compare/@metamask/base-controller@5.0.1...@metamask/base-controller@5.0.2 +[5.0.1]: https://github.com/MetaMask/core/compare/@metamask/base-controller@5.0.0...@metamask/base-controller@5.0.1 +[5.0.0]: https://github.com/MetaMask/core/compare/@metamask/base-controller@4.1.1...@metamask/base-controller@5.0.0 +[4.1.1]: https://github.com/MetaMask/core/compare/@metamask/base-controller@4.1.0...@metamask/base-controller@4.1.1 +[4.1.0]: https://github.com/MetaMask/core/compare/@metamask/base-controller@4.0.1...@metamask/base-controller@4.1.0 +[4.0.1]: https://github.com/MetaMask/core/compare/@metamask/base-controller@4.0.0...@metamask/base-controller@4.0.1 +[4.0.0]: https://github.com/MetaMask/core/compare/@metamask/base-controller@3.2.3...@metamask/base-controller@4.0.0 [3.2.3]: https://github.com/MetaMask/core/compare/@metamask/base-controller@3.2.2...@metamask/base-controller@3.2.3 [3.2.2]: https://github.com/MetaMask/core/compare/@metamask/base-controller@3.2.1...@metamask/base-controller@3.2.2 [3.2.1]: https://github.com/MetaMask/core/compare/@metamask/base-controller@3.2.0...@metamask/base-controller@3.2.1 diff --git a/packages/base-controller/LICENSE b/packages/base-controller/LICENSE index ddfbecf9020..bbed2e24b91 100644 --- a/packages/base-controller/LICENSE +++ b/packages/base-controller/LICENSE @@ -18,3 +18,4 @@ 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/base-controller/jest.config.js b/packages/base-controller/jest.config.js index 53f0f395de8..ca084133399 100644 --- a/packages/base-controller/jest.config.js +++ b/packages/base-controller/jest.config.js @@ -17,7 +17,7 @@ module.exports = merge(baseConfig, { // An object that configures minimum threshold enforcement for coverage results coverageThreshold: { global: { - branches: 98, + branches: 100, functions: 100, lines: 100, statements: 100, diff --git a/packages/base-controller/package.json b/packages/base-controller/package.json index cfea31ef760..2d08ff61bd3 100644 --- a/packages/base-controller/package.json +++ b/packages/base-controller/package.json @@ -1,54 +1,74 @@ { "name": "@metamask/base-controller", - "version": "3.2.3", + "version": "9.1.0", "description": "Provides scaffolding for controllers as well a communication system for all controllers", "keywords": [ - "MetaMask", - "Ethereum" + "Ethereum", + "MetaMask" ], "homepage": "https://github.com/MetaMask/core/tree/main/packages/base-controller#readme", "bugs": { "url": "https://github.com/MetaMask/core/issues" }, + "license": "MIT", "repository": { "type": "git", "url": "https://github.com/MetaMask/core.git" }, - "license": "MIT", - "main": "./dist/index.js", - "types": "./dist/index.d.ts", "files": [ "dist/" ], + "main": "./dist/index.cjs", + "types": "./dist/index.d.cts", + "exports": { + ".": { + "import": { + "types": "./dist/index.d.mts", + "default": "./dist/index.mjs" + }, + "require": { + "types": "./dist/index.d.cts", + "default": "./dist/index.cjs" + } + }, + "./package.json": "./package.json" + }, + "publishConfig": { + "access": "public", + "registry": "https://registry.npmjs.org/" + }, "scripts": { + "build": "ts-bridge --project tsconfig.build.json --verbose --clean --no-references", + "build:all": "ts-bridge --project tsconfig.build.json --verbose --clean", "build:docs": "typedoc", + "changelog:update": "../../scripts/update-changelog.sh @metamask/base-controller", "changelog:validate": "../../scripts/validate-changelog.sh @metamask/base-controller", - "publish:preview": "yarn npm publish --tag preview", - "test": "jest", - "test:clean": "jest --clearCache", - "test:watch": "jest --watch" + "lint:tsconfigs": "tsx ../../scripts/lint-tsconfigs/lint-tsconfigs.mts", + "lint:tsconfigs:fix": "tsx ../../scripts/lint-tsconfigs/lint-tsconfigs.mts --fix", + "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/utils": "^8.1.0", + "@metamask/messenger": "^2.0.0", + "@metamask/utils": "^11.11.0", "immer": "^9.0.6" }, "devDependencies": { - "@metamask/auto-changelog": "^3.1.0", - "@types/jest": "^27.4.1", - "@types/sinon": "^9.0.10", + "@metamask/auto-changelog": "^6.1.0", + "@ts-bridge/cli": "^0.6.4", + "@types/jest": "^30.0.0", "deepmerge": "^4.2.2", - "jest": "^27.5.1", - "sinon": "^9.2.4", - "ts-jest": "^27.1.4", - "typedoc": "^0.24.8", + "jest": "^30.4.2", + "ts-jest": "^29.4.11", + "tsx": "^4.20.5", + "typedoc": "^0.25.13", "typedoc-plugin-missing-exports": "^2.0.0", - "typescript": "~4.8.4" + "typescript": "~5.3.3" }, "engines": { - "node": ">=16.0.0" - }, - "publishConfig": { - "access": "public", - "registry": "https://registry.npmjs.org/" + "node": "^18.18 || >=20" } } diff --git a/packages/base-controller/src/BaseController.test.ts b/packages/base-controller/src/BaseController.test.ts index 33db173025f..e2b7614a3a1 100644 --- a/packages/base-controller/src/BaseController.test.ts +++ b/packages/base-controller/src/BaseController.test.ts @@ -1,74 +1,1193 @@ -import * as sinon from 'sinon'; +/* eslint-disable jest/no-export */ +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 type { BaseConfig, BaseState } from './BaseController'; -import { BaseController } from './BaseController'; +import type { + ControllerActions, + ControllerEvents, + ControllerGetStateAction, + ControllerStateChangeEvent, + ControllerStateChangedEvent, + StatePropertyMetadata, +} from './BaseController.js'; +import { BaseController, deriveStateFromMetadata } from './BaseController.js'; -const STATE = { name: 'foo' }; -const CONFIG = { disabled: true }; +export const countControllerName = 'CountController'; -class TestController extends BaseController { - constructor(config?: BaseConfig, state?: BaseState) { - super(config, state); - this.initialize(); +type CountControllerState = { + count: number; +}; + +export type CountControllerAction = ControllerGetStateAction< + typeof countControllerName, + CountControllerState +>; + +export type CountControllerEvent = + | ControllerStateChangedEvent< + typeof countControllerName, + CountControllerState + > + | ControllerStateChangeEvent< + typeof countControllerName, + CountControllerState + >; + +export const countControllerStateMetadata = { + count: { + includeInDebugSnapshot: true, + includeInStateLogs: true, + persist: true, + usedInUi: true, + }, +}; + +type CountMessenger = Messenger< + typeof countControllerName, + CountControllerAction, + CountControllerEvent +>; + +/** + * Constructs a messenger for the Count controller. + * + * @returns A messenger for the Count controller. + */ +export function getCountMessenger(): CountMessenger { + return new Messenger< + typeof countControllerName, + CountControllerAction, + CountControllerEvent + >({ namespace: countControllerName }); +} + +export class CountController extends BaseController< + typeof countControllerName, + CountControllerState, + CountMessenger +> { + update( + callback: ( + state: Draft, + ) => void | CountControllerState, + ): { + nextState: CountControllerState; + patches: Patch[]; + inversePatches: Patch[]; + } { + return super.update(callback); + } + + applyPatches(patches: Patch[]): void { + super.applyPatches(patches); + } + + destroy(): void { + super.destroy(); + } +} + +const messagesControllerName = 'MessagesController'; + +type Message = { + subject: string; + body: string; + headers: Record; +}; + +type MessagesControllerState = { + messages: Message[]; +}; + +type MessagesControllerAction = ControllerGetStateAction< + typeof messagesControllerName, + MessagesControllerState +>; + +type MessagesControllerEvent = ControllerStateChangedEvent< + typeof messagesControllerName, + MessagesControllerState +>; + +const messagesControllerStateMetadata = { + messages: { + includeInDebugSnapshot: true, + includeInStateLogs: true, + persist: true, + usedInUi: true, + }, +}; + +type MessagesMessenger = Messenger< + typeof messagesControllerName, + MessagesControllerAction, + MessagesControllerEvent +>; + +/** + * Constructs a messenger for the Messages controller. + * + * @returns A messenger for the Messages controller. + */ +function getMessagesMessenger(): MessagesMessenger { + return new Messenger< + typeof messagesControllerName, + MessagesControllerAction, + MessagesControllerEvent + >({ namespace: messagesControllerName }); +} + +class MessagesController extends BaseController< + typeof messagesControllerName, + MessagesControllerState, + MessagesMessenger +> { + update( + callback: ( + state: Draft, + ) => void | MessagesControllerState, + ): { + nextState: MessagesControllerState; + patches: Patch[]; + inversePatches: Patch[]; + } { + return super.update(callback); + } + + applyPatches(patches: Patch[]): void { + super.applyPatches(patches); + } + + destroy(): void { + super.destroy(); } } describe('BaseController', () => { - afterEach(() => { - sinon.restore(); + it('should set initial state', () => { + const controller = new CountController({ + messenger: getCountMessenger(), + name: countControllerName, + state: { count: 0 }, + metadata: countControllerStateMetadata, + }); + + expect(controller.state).toStrictEqual({ count: 0 }); }); - it('should set initial state', () => { - const controller = new TestController(undefined, STATE); - expect(controller.state).toStrictEqual(STATE); + it('should allow getting state via the getState action', () => { + const messenger = getCountMessenger(); + + // eslint-disable-next-line no-new + new CountController({ + messenger, + name: countControllerName, + state: { count: 0 }, + metadata: countControllerStateMetadata, + }); + + expect(messenger.call('CountController:getState')).toStrictEqual({ + count: 0, + }); + }); + + it('should set initial schema', () => { + const controller = new CountController({ + messenger: getCountMessenger(), + name: 'CountController', + state: { count: 0 }, + metadata: countControllerStateMetadata, + }); + + expect(controller.metadata).toStrictEqual(countControllerStateMetadata); + }); + + it('should not allow reassigning the `state` property', () => { + const controller = new CountController({ + messenger: getCountMessenger(), + name: 'CountController', + state: { count: 0 }, + metadata: countControllerStateMetadata, + }); + + expect(() => { + controller.state = { count: 1 }; + }).toThrow( + "Controller state cannot be directly mutated; use 'update' method instead.", + ); }); - it('should set initial config', () => { - const controller = new TestController(CONFIG); - expect(controller.config).toStrictEqual(CONFIG); + it('should not allow reassigning an object property that exists in state', () => { + const controller = new MessagesController({ + messenger: getMessagesMessenger(), + name: messagesControllerName, + state: { + messages: [ + { + subject: 'Hi', + body: 'Hello, I hope you have a good day', + headers: { + 'X-Foo': 'Bar', + }, + }, + ], + }, + metadata: messagesControllerStateMetadata, + }); + + expect(() => { + controller.state.messages[0].headers['X-Baz'] = 'Qux'; + }).toThrow('Cannot add property X-Baz, object is not extensible'); }); - it('should overwrite state', () => { - const controller = new TestController(); - expect(controller.state).toStrictEqual({}); - controller.update(STATE, true); - expect(controller.state).toStrictEqual(STATE); + it('should not allow pushing a value onto an array property that exists in state', () => { + const controller = new MessagesController({ + messenger: getMessagesMessenger(), + name: messagesControllerName, + state: { + messages: [ + { + subject: 'Hi', + body: 'Hello, I hope you have a good day', + headers: { + 'X-Foo': 'Bar', + }, + }, + ], + }, + metadata: messagesControllerStateMetadata, + }); + + expect(() => { + controller.state.messages.push({ + subject: 'Hello again', + body: 'Please join my network on LinkedIn', + headers: {}, + }); + }).toThrow('Cannot add property 1, object is not extensible'); }); - it('should overwrite config', () => { - const controller = new TestController(); - expect(controller.config).toStrictEqual({}); - controller.configure(CONFIG, true); - expect(controller.config).toStrictEqual(CONFIG); + it('should allow updating state by modifying draft', () => { + const controller = new CountController({ + messenger: getCountMessenger(), + name: 'CountController', + state: { count: 0 }, + metadata: countControllerStateMetadata, + }); + + controller.update((draft) => { + draft.count += 1; + }); + + expect(controller.state).toStrictEqual({ count: 1 }); }); - it('should be able to partially update the config', () => { - const controller = new TestController(CONFIG); - expect(controller.config).toStrictEqual(CONFIG); - controller.configure({ disabled: false }, false, false); - expect(controller.config).toStrictEqual({ disabled: false }); + it('should allow updating state by return a value', () => { + const controller = new CountController({ + messenger: getCountMessenger(), + name: 'CountController', + state: { count: 0 }, + metadata: countControllerStateMetadata, + }); + + controller.update(() => { + return { count: 1 }; + }); + + expect(controller.state).toStrictEqual({ count: 1 }); }); - it('should notify all listeners', () => { - const controller = new TestController(undefined, STATE); - const listenerOne = sinon.stub(); - const listenerTwo = sinon.stub(); - controller.subscribe(listenerOne); - controller.subscribe(listenerTwo); - controller.notify(); - expect(listenerOne.calledOnce).toBe(true); - expect(listenerTwo.calledOnce).toBe(true); - expect(listenerOne.getCall(0).args[0]).toStrictEqual(STATE); - expect(listenerTwo.getCall(0).args[0]).toStrictEqual(STATE); + it('should not call publish if the state has not been modified', () => { + const messenger = getCountMessenger(); + const publishSpy = jest.spyOn(messenger, 'publish'); + + const controller = new CountController({ + messenger, + name: 'CountController', + state: { count: 0 }, + metadata: countControllerStateMetadata, + }); + + controller.update((_draft) => { + // no-op + }); + + expect(controller.state).toStrictEqual({ count: 0 }); + expect(publishSpy).not.toHaveBeenCalled(); }); - it('should not notify unsubscribed listeners', () => { - const controller = new TestController(); - const listener = sinon.stub(); - controller.subscribe(listener); - controller.unsubscribe(listener); - controller.unsubscribe(() => null); - controller.notify(); - expect(listener.called).toBe(false); + it('should return next state, patches and inverse patches after an update', () => { + const controller = new CountController({ + messenger: getCountMessenger(), + name: 'CountController', + state: { count: 0 }, + metadata: countControllerStateMetadata, + }); + + const returnObj = controller.update((draft) => { + draft.count += 1; + }); + + expect(returnObj).toBeDefined(); + expect(returnObj.nextState).toStrictEqual({ count: 1 }); + expect(returnObj.patches).toStrictEqual([ + { op: 'replace', path: ['count'], value: 1 }, + ]); + + expect(returnObj.inversePatches).toStrictEqual([ + { op: 'replace', path: ['count'], value: 0 }, + ]); + }); + + it('should throw an error if update callback modifies draft and returns value', () => { + const controller = new CountController({ + messenger: getCountMessenger(), + name: 'CountController', + state: { count: 0 }, + metadata: countControllerStateMetadata, + }); + + expect(() => { + controller.update((draft) => { + draft.count += 1; + return { count: 10 }; + }); + }).toThrow( + '[Immer] An immer producer returned a new value *and* modified its draft. Either return a new value *or* modify the draft.', + ); + }); + + it('should allow for applying immer patches to state', () => { + const controller = new CountController({ + messenger: getCountMessenger(), + name: 'CountController', + state: { count: 0 }, + metadata: countControllerStateMetadata, + }); + + const returnObj = controller.update((draft) => { + draft.count += 1; + }); + + controller.applyPatches(returnObj.inversePatches); + + expect(controller.state).toStrictEqual({ count: 0 }); + }); + + for (const eventName of [ + 'CountController:stateChanged', + 'CountController:stateChange', + ] as const) { + const shortEventName = eventName.replace(/^(.+)(:.+)$/u, '$2'); + + it(`should inform subscribers of state changes via ${shortEventName} as a result of applying patches`, () => { + const messenger = getCountMessenger(); + const controller = new CountController({ + messenger, + name: 'CountController', + state: { count: 0 }, + metadata: countControllerStateMetadata, + }); + const listener1 = jest.fn(); + + messenger.subscribe(eventName, listener1); + const { inversePatches } = controller.update(() => { + return { count: 1 }; + }); + + controller.applyPatches(inversePatches); + + expect(listener1).toHaveBeenCalledTimes(2); + expect(listener1.mock.calls[0]).toStrictEqual([ + { count: 1 }, + [{ op: 'replace', path: [], value: { count: 1 } }], + ]); + + expect(listener1.mock.calls[1]).toStrictEqual([ + { count: 0 }, + [{ op: 'replace', path: [], value: { count: 0 } }], + ]); + }); + + it(`should inform subscribers of state changes via ${shortEventName}`, () => { + const messenger = getCountMessenger(); + const controller = new CountController({ + messenger, + name: 'CountController', + state: { count: 0 }, + metadata: countControllerStateMetadata, + }); + const listener1 = jest.fn(); + const listener2 = jest.fn(); + + messenger.subscribe(eventName, listener1); + messenger.subscribe(eventName, listener2); + controller.update(() => { + return { count: 1 }; + }); + + expect(listener1).toHaveBeenCalledTimes(1); + expect(listener1.mock.calls[0]).toStrictEqual([ + { count: 1 }, + [{ op: 'replace', path: [], value: { count: 1 } }], + ]); + expect(listener2).toHaveBeenCalledTimes(1); + expect(listener2.mock.calls[0]).toStrictEqual([ + { count: 1 }, + [{ op: 'replace', path: [], value: { count: 1 } }], + ]); + }); + + it(`should notify a subscriber with a selector of state changes via ${shortEventName}`, () => { + const messenger = getCountMessenger(); + const controller = new CountController({ + messenger, + name: 'CountController', + state: { count: 0 }, + metadata: countControllerStateMetadata, + }); + const listener = jest.fn(); + messenger.subscribe( + eventName, + listener, + ({ count }: CountControllerState) => { + // Selector rounds down to nearest multiple of 10 + return Math.floor(count / 10); + }, + ); + + controller.update(() => { + return { count: 10 }; + }); + + expect(listener).toHaveBeenCalledTimes(1); + expect(listener.mock.calls[0]).toStrictEqual([1, 0]); + }); + + it(`should not inform a subscriber of state changes via ${shortEventName} if the selected value is unchanged`, () => { + const messenger = getCountMessenger(); + const controller = new CountController({ + messenger, + name: 'CountController', + state: { count: 0 }, + metadata: countControllerStateMetadata, + }); + const listener = jest.fn(); + messenger.subscribe( + eventName, + listener, + ({ count }: CountControllerState) => { + // Selector rounds down to nearest multiple of 10 + return Math.floor(count / 10); + }, + ); + + controller.update(() => { + // Note that this rounds down to zero, so the selected value is still zero + return { count: 1 }; + }); + + expect(listener).toHaveBeenCalledTimes(0); + }); + + it(`should inform a subscriber of each state change via ${shortEventName} once even after multiple subscriptions`, () => { + const messenger = getCountMessenger(); + const controller = new CountController({ + messenger, + name: 'CountController', + state: { count: 0 }, + metadata: countControllerStateMetadata, + }); + const listener1 = jest.fn(); + + messenger.subscribe(eventName, listener1); + messenger.subscribe(eventName, listener1); + + controller.update(() => { + return { count: 1 }; + }); + + expect(listener1).toHaveBeenCalledTimes(1); + expect(listener1.mock.calls[0]).toStrictEqual([ + { count: 1 }, + [{ op: 'replace', path: [], value: { count: 1 } }], + ]); + }); + + it(`should no longer inform a subscriber about state changes via ${shortEventName} after unsubscribing`, () => { + const messenger = getCountMessenger(); + const controller = new CountController({ + messenger, + name: 'CountController', + state: { count: 0 }, + metadata: countControllerStateMetadata, + }); + const listener1 = jest.fn(); + + messenger.subscribe(eventName, listener1); + messenger.unsubscribe(eventName, listener1); + controller.update(() => { + return { count: 1 }; + }); + + expect(listener1).toHaveBeenCalledTimes(0); + }); + + it(`should no longer inform a subscriber about state changes via ${shortEventName} after unsubscribing once, even if they subscribed many times`, () => { + const messenger = getCountMessenger(); + const controller = new CountController({ + messenger, + name: 'CountController', + state: { count: 0 }, + metadata: countControllerStateMetadata, + }); + const listener1 = jest.fn(); + + messenger.subscribe(eventName, listener1); + messenger.subscribe(eventName, listener1); + messenger.unsubscribe(eventName, listener1); + controller.update(() => { + return { count: 1 }; + }); + + expect(listener1).toHaveBeenCalledTimes(0); + }); + + it(`should no longer update subscribers via ${shortEventName} after being destroyed`, () => { + const messenger = getCountMessenger(); + const controller = new CountController({ + messenger, + name: 'CountController', + state: { count: 0 }, + metadata: countControllerStateMetadata, + }); + const listener1 = jest.fn(); + const listener2 = jest.fn(); + + messenger.subscribe(eventName, listener1); + messenger.subscribe(eventName, listener2); + controller.destroy(); + controller.update(() => { + return { count: 1 }; + }); + + expect(listener1).toHaveBeenCalledTimes(0); + expect(listener2).toHaveBeenCalledTimes(0); + }); + } + + it('should throw when unsubscribing listener who was never subscribed', () => { + const messenger = getCountMessenger(); + + // eslint-disable-next-line no-new + new CountController({ + messenger, + name: 'CountController', + state: { count: 0 }, + metadata: countControllerStateMetadata, + }); + const listener1 = jest.fn(); + + expect(() => { + messenger.unsubscribe('CountController:stateChanged', listener1); + }).toThrow( + 'Subscription not found for event: CountController:stateChanged', + ); + }); + + describe('inter-controller communication', () => { + // These two contrived mock controllers are setup to test with. + // The 'VisitorController' records strings that represent visitors. + // The 'VisitorOverflowController' monitors the 'VisitorController' to ensure the number of + // visitors doesn't exceed the maximum capacity. If it does, it will clear out all visitors. + + const visitorName = 'VisitorController'; + + type VisitorControllerState = { + visitors: string[]; + }; + type VisitorControllerClearAction = { + type: `${typeof visitorName}:clear`; + handler: () => void; + }; + type VisitorExternalActions = VisitorOverflowUpdateMaxAction; + type VisitorControllerStateChangedEvent = ControllerStateChangedEvent< + typeof visitorName, + VisitorControllerState + >; + type VisitorControllerActions = + | VisitorControllerClearAction + | ControllerActions; + type VisitorExternalEvents = VisitorOverflowStateChangedEvent; + type VisitorControllerEvents = VisitorControllerStateChangedEvent; + + const visitorControllerStateMetadata = { + visitors: { + includeInDebugSnapshot: true, + includeInStateLogs: true, + persist: true, + usedInUi: true, + }, + }; + + type VisitorMessenger = Messenger< + typeof visitorName, + VisitorControllerActions | VisitorExternalActions, + VisitorControllerEvents | VisitorExternalEvents + >; + class VisitorController extends BaseController< + typeof visitorName, + VisitorControllerState, + VisitorMessenger + > { + constructor(messenger: VisitorMessenger) { + super({ + messenger, + metadata: visitorControllerStateMetadata, + name: visitorName, + state: { visitors: [] }, + }); + + messenger.registerActionHandler('VisitorController:clear', this.clear); + } + + clear: () => void = () => { + this.update(() => { + return { visitors: [] }; + }); + }; + + addVisitor(visitor: string): void { + this.update(({ visitors }) => { + return { visitors: [...visitors, visitor] }; + }); + } + + destroy(): void { + super.destroy(); + } + } + + const visitorOverflowName = 'VisitorOverflowController'; + + type VisitorOverflowControllerState = { + maxVisitors: number; + }; + type VisitorOverflowUpdateMaxAction = { + type: `${typeof visitorOverflowName}:updateMax`; + handler: (max: number) => void; + }; + type VisitorOverflowExternalActions = VisitorControllerClearAction; + type VisitorOverflowControllerActions = + | VisitorOverflowUpdateMaxAction + | ControllerActions< + typeof visitorOverflowName, + VisitorOverflowControllerState + >; + type VisitorOverflowStateChangedEvent = ControllerEvents< + typeof visitorOverflowName, + VisitorOverflowControllerState + >; + type VisitorOverflowExternalEvents = VisitorControllerStateChangedEvent; + type VisitorOverflowControllerEvents = VisitorOverflowStateChangedEvent; + + const visitorOverflowControllerMetadata = { + maxVisitors: { + includeInDebugSnapshot: true, + includeInStateLogs: true, + persist: false, + usedInUi: true, + }, + }; + + type VisitorOverflowMessenger = Messenger< + typeof visitorOverflowName, + VisitorOverflowControllerActions | VisitorOverflowExternalActions, + VisitorOverflowControllerEvents | VisitorOverflowExternalEvents + >; + + class VisitorOverflowController extends BaseController< + typeof visitorOverflowName, + VisitorOverflowControllerState, + VisitorOverflowMessenger + > { + constructor(messenger: VisitorOverflowMessenger) { + super({ + messenger, + metadata: visitorOverflowControllerMetadata, + name: visitorOverflowName, + state: { maxVisitors: 5 }, + }); + + messenger.registerActionHandler( + 'VisitorOverflowController:updateMax', + this.updateMax, + ); + + messenger.subscribe('VisitorController:stateChanged', this.onVisit); + } + + onVisit: ({ visitors }: VisitorControllerState) => void = ({ + visitors, + }: VisitorControllerState) => { + if (visitors.length > this.state.maxVisitors) { + this.messenger.call('VisitorController:clear'); + } + }; + + updateMax: (max: number) => void = (max: number) => { + this.update(() => { + return { maxVisitors: max }; + }); + }; + + destroy(): void { + super.destroy(); + } + } + + it('should allow messaging between controllers', () => { + // Construct root messenger + const rootMessenger = new Messenger< + MockAnyNamespace, + VisitorControllerActions | VisitorOverflowControllerActions, + VisitorControllerEvents | VisitorOverflowControllerEvents + >({ namespace: MOCK_ANY_NAMESPACE }); + // Construct controller messengers, delegating to parent + const visitorControllerMessenger = new Messenger< + typeof visitorName, + VisitorControllerActions | VisitorOverflowUpdateMaxAction, + VisitorControllerEvents | VisitorOverflowStateChangedEvent, + typeof rootMessenger + >({ namespace: visitorName, parent: rootMessenger }); + const visitorOverflowControllerMessenger = new Messenger< + typeof visitorOverflowName, + VisitorOverflowControllerActions | VisitorControllerClearAction, + VisitorOverflowControllerEvents | VisitorControllerStateChangedEvent, + typeof rootMessenger + >({ namespace: visitorOverflowName, parent: rootMessenger }); + // Delegate external actions/events to controller messengers + rootMessenger.delegate({ + actions: ['VisitorController:clear'], + events: ['VisitorController:stateChanged'], + messenger: visitorOverflowControllerMessenger, + }); + rootMessenger.delegate({ + actions: ['VisitorOverflowController:updateMax'], + events: ['VisitorOverflowController:stateChanged'], + messenger: visitorControllerMessenger, + }); + // Construct controllers + const visitorController = new VisitorController( + visitorControllerMessenger, + ); + const visitorOverflowController = new VisitorOverflowController( + visitorOverflowControllerMessenger, + ); + + rootMessenger.call('VisitorOverflowController:updateMax', 2); + visitorController.addVisitor('A'); + visitorController.addVisitor('B'); + visitorController.addVisitor('C'); // this should trigger an overflow + + expect(visitorOverflowController.state.maxVisitors).toBe(2); + expect(visitorController.state.visitors).toHaveLength(0); + }); + }); +}); + +describe('deriveStateFromMetadata', () => { + it('returns an empty object when deriving state for an unset property', () => { + const derivedState = deriveStateFromMetadata( + { count: 1 }, + { + count: { + includeInDebugSnapshot: false, + includeInStateLogs: false, + persist: false, + usedInUi: false, + }, + }, + // @ts-expect-error Intentionally passing in fake unset property + 'unset', + ); + + expect(derivedState).toStrictEqual({}); + }); + + describe.each([ + 'includeInDebugSnapshot', + 'includeInStateLogs', + 'persist', + 'usedInUi', + ] as const)('%s', (property: keyof StatePropertyMetadata) => { + it('should return empty state', () => { + expect(deriveStateFromMetadata({}, {}, property)).toStrictEqual({}); + }); + + it('should return empty state when no properties are enabled', () => { + const derivedState = deriveStateFromMetadata( + { count: 1 }, + { + count: { + includeInDebugSnapshot: false, + includeInStateLogs: false, + persist: false, + usedInUi: false, + [property]: false, + }, + }, + property, + ); + + expect(derivedState).toStrictEqual({}); + }); + + it('should return derived state', () => { + const derivedState = deriveStateFromMetadata( + { + password: 'secret password', + privateKey: '123', + network: 'mainnet', + tokens: ['DAI', 'USDC'], + }, + { + password: { + includeInDebugSnapshot: false, + includeInStateLogs: false, + persist: false, + usedInUi: false, + [property]: true, + }, + privateKey: { + includeInDebugSnapshot: false, + includeInStateLogs: false, + persist: false, + usedInUi: false, + [property]: true, + }, + network: { + includeInDebugSnapshot: false, + includeInStateLogs: false, + persist: false, + usedInUi: false, + [property]: false, + }, + tokens: { + includeInDebugSnapshot: false, + includeInStateLogs: false, + persist: false, + usedInUi: false, + [property]: false, + }, + }, + property, + ); + + expect(derivedState).toStrictEqual({ + password: 'secret password', + privateKey: '123', + }); + }); + + if (property !== 'usedInUi') { + it('should use function to derive state', () => { + const normalizeTransactionHash = (hash: string): string => { + return hash.toLowerCase(); + }; + + const derivedState = deriveStateFromMetadata( + { + transactionHash: '0X1234', + }, + { + transactionHash: { + includeInDebugSnapshot: false, + includeInStateLogs: false, + persist: false, + usedInUi: false, + [property]: normalizeTransactionHash, + }, + }, + property, + ); + + expect(derivedState).toStrictEqual({ transactionHash: '0x1234' }); + }); + + it('should allow returning a partial object from a deriver', () => { + const getDerivedTxMeta = (txMeta: { + hash: string; + value: number; + }): { + value: number; + } => { + return { value: txMeta.value }; + }; + + const derivedState = deriveStateFromMetadata( + { + txMeta: { + hash: '0x123', + value: 10, + }, + }, + { + txMeta: { + includeInDebugSnapshot: false, + includeInStateLogs: false, + persist: false, + usedInUi: false, + [property]: getDerivedTxMeta, + }, + }, + property, + ); + + expect(derivedState).toStrictEqual({ txMeta: { value: 10 } }); + }); + + it('should allow returning a nested partial object from a deriver', () => { + const getDerivedTxMeta = (txMeta: { + hash: string; + value: number; + history: { hash: string; value: number }[]; + }): { history: { value: number }[]; value: number } => { + return { + history: txMeta.history.map((entry) => { + return { value: entry.value }; + }), + value: txMeta.value, + }; + }; + + const derivedState = deriveStateFromMetadata( + { + txMeta: { + hash: '0x123', + history: [ + { + hash: '0x123', + value: 9, + }, + ], + value: 10, + }, + }, + { + txMeta: { + includeInDebugSnapshot: false, + includeInStateLogs: false, + persist: false, + usedInUi: false, + [property]: getDerivedTxMeta, + }, + }, + property, + ); + + expect(derivedState).toStrictEqual({ + txMeta: { history: [{ value: 9 }], value: 10 }, + }); + }); + + it('should allow transforming types in a deriver', () => { + const derivedState = deriveStateFromMetadata( + { + count: '1', + }, + { + count: { + includeInDebugSnapshot: false, + includeInStateLogs: false, + persist: false, + usedInUi: false, + [property]: (count: string) => Number(count), + }, + }, + property, + ); + + expect(derivedState).toStrictEqual({ count: 1 }); + }); + } + + it('reports thrown error when deriving state', () => { + const captureException = jest.fn(); + const derivedState = deriveStateFromMetadata( + { + extraState: 'extraState', + privateKey: '123', + network: 'mainnet', + }, + // @ts-expect-error Intentionally testing invalid state + { + privateKey: { + includeInDebugSnapshot: false, + includeInStateLogs: false, + persist: false, + usedInUi: false, + [property]: true, + }, + network: { + includeInDebugSnapshot: false, + includeInStateLogs: false, + persist: false, + usedInUi: false, + [property]: false, + }, + }, + property, + captureException, + ); + + expect(derivedState).toStrictEqual({ + privateKey: '123', + }); + + expect(captureException).toHaveBeenCalledTimes(1); + expect(captureException).toHaveBeenCalledWith( + new Error(`No metadata found for 'extraState'`), + ); + }); + + it('reports thrown non-error when deriving state, wrapping it in an error', () => { + const captureException = jest.fn(); + const testException = 'Non-Error exception'; + const derivedState = deriveStateFromMetadata( + { + extraState: 'extraState', + privateKey: '123', + network: 'mainnet', + }, + { + extraState: { + includeInDebugSnapshot: false, + includeInStateLogs: false, + persist: false, + usedInUi: false, + [property]: () => { + // Intentionally throwing non-error to test handling + // eslint-disable-next-line @typescript-eslint/only-throw-error + throw testException; + }, + }, + privateKey: { + includeInDebugSnapshot: false, + includeInStateLogs: false, + persist: false, + usedInUi: false, + [property]: true, + }, + network: { + includeInDebugSnapshot: false, + includeInStateLogs: false, + persist: false, + usedInUi: false, + [property]: false, + }, + }, + property, + captureException, + ); + + expect(derivedState).toStrictEqual({ + privateKey: '123', + }); + + expect(captureException).toHaveBeenCalledTimes(1); + expect(captureException).toHaveBeenCalledWith(new Error(testException)); + }); + + it('logs thrown error and captureException error to console if captureException throws', () => { + const consoleError = jest.fn(); + const testError = new Error('Test error'); + const captureException = jest.fn().mockImplementation(() => { + throw testError; + }); + jest.spyOn(console, 'error').mockImplementation(consoleError); + const derivedState = deriveStateFromMetadata( + { + extraState: 'extraState', + privateKey: '123', + network: 'mainnet', + }, + // @ts-expect-error Intentionally testing invalid state + { + privateKey: { + includeInDebugSnapshot: false, + includeInStateLogs: false, + persist: false, + usedInUi: false, + [property]: true, + }, + network: { + includeInDebugSnapshot: false, + includeInStateLogs: false, + persist: false, + usedInUi: false, + [property]: false, + }, + }, + property, + captureException, + ); + + expect(derivedState).toStrictEqual({ + privateKey: '123', + }); + + expect(consoleError).toHaveBeenCalledTimes(2); + expect(consoleError).toHaveBeenNthCalledWith( + 1, + new Error(`Error thrown when calling 'captureException'`), + testError, + ); + expect(consoleError).toHaveBeenNthCalledWith( + 2, + new Error(`No metadata found for 'extraState'`), + ); + }); + + it('logs thrown error to console when deriving state if no captureException function is given', () => { + const consoleError = jest.fn(); + jest.spyOn(console, 'error').mockImplementation(consoleError); + const derivedState = deriveStateFromMetadata( + { + extraState: 'extraState', + privateKey: '123', + network: 'mainnet', + }, + // @ts-expect-error Intentionally testing invalid state + { + privateKey: { + includeInDebugSnapshot: false, + includeInStateLogs: false, + persist: false, + usedInUi: false, + [property]: true, + }, + network: { + includeInDebugSnapshot: false, + includeInStateLogs: false, + persist: false, + usedInUi: false, + [property]: false, + }, + }, + property, + ); + + expect(derivedState).toStrictEqual({ + privateKey: '123', + }); + + expect(consoleError).toHaveBeenCalledTimes(1); + expect(consoleError).toHaveBeenCalledWith( + new Error(`No metadata found for 'extraState'`), + ); + }); }); }); diff --git a/packages/base-controller/src/BaseController.ts b/packages/base-controller/src/BaseController.ts index 1838247a3c0..2cc4047e45d 100644 --- a/packages/base-controller/src/BaseController.ts +++ b/packages/base-controller/src/BaseController.ts @@ -1,187 +1,448 @@ +import type { + ActionConstraint, + EventConstraint, + Messenger, + MessengerActions, + MessengerEvents, +} from '@metamask/messenger'; +import type { Json, PublicInterface } from '@metamask/utils'; +import { enablePatches, produceWithPatches, applyPatches, freeze } from 'immer'; +import type { Draft, Patch } from 'immer'; + +enablePatches(); + /** - * State change callbacks + * A type that constrains the state of all controllers. + * + * In other words, the narrowest supertype encompassing all controller state. */ -export type Listener = (state: T) => void; +export type StateConstraint = Record; /** - * @type BaseConfig + * A state change listener. * - * Base controller configuration - * @property disabled - Determines if this controller is enabled + * This function will get called for each state change, and is given a copy of + * the new state along with a set of patches describing the changes since the + * last update. + * + * @param state - The new controller state. + * @param patches - A list of patches describing any changes (see here for more + * information: https://immerjs.github.io/immer/docs/patches) */ -export interface BaseConfig { - disabled?: boolean; -} +export type StateChangeListener = (state: Type, patches: Patch[]) => void; /** - * @type BaseState + * An function to derive state. * - * Base state representation - * @property name - Unique name for this controller + * This function will accept one piece of the controller state (one property), + * and will return some derivation of that state. + * + * @param value - A piece of controller state. + * @returns Something derived from controller state. */ -export interface BaseState { - name?: string; -} +export type StateDeriver = (value: Type) => Json; /** - * Controller class that provides configuration, state management, and subscriptions. + * State metadata. * - * The core purpose of every controller is to maintain an internal data object - * called "state". Each controller is responsible for its own state, and all global wallet state - * is tracked in a controller as state. + * This metadata describes which parts of state should be persisted, and how to + * get an anonymized representation of the state. + */ +export type StateMetadata = { + [Key in keyof Type]-?: StatePropertyMetadata; +}; + +/** + * Metadata for a single state property */ -export class BaseController { +export type StatePropertyMetadata = { /** - * Default options used to configure this controller + * Indicates whether this property should be included in debug snapshots attached to Sentry + * errors. + * + * Set this to false if the state may contain personally identifiable information, or if it's + * too large to include in a debug snapshot. */ - defaultConfig: C = {} as C; - + includeInDebugSnapshot: boolean | StateDeriver; /** - * Default state set on this controller + * Indicates whether this property should be included in state logs. + * + * Set this to false if the data should be kept hidden from support agents (e.g. if it contains + * secret keys, or personally-identifiable information that is not useful for debugging). + * + * We do allow state logs to contain some personally identifiable information to assist with + * diagnosing errors (e.g. transaction hashes, addresses), but we still attempt to limit the + * data we expose to what is most useful for helping users. */ - defaultState: S = {} as S; - + includeInStateLogs: boolean | StateDeriver; /** - * Determines if listeners are notified of state changes + * Indicates whether this property should be persisted. + * + * If true, the property will be persisted and saved between sessions. + * If false, the property will not be saved between sessions, and it will always be missing from the `state` constructor parameter. */ - disabled = false; - + persist: boolean | StateDeriver; /** - * Name of this controller used during composition + * Indicates whether this property is used by the UI. + * + * If true, the property will be accessible from the UI. + * If false, it will be inaccessible from the UI. + * + * Making a property accessible to the UI has a performance overhead, so it's better to set this + * to `false` if it's not used in the UI, especially for properties that can be large in size. + * + * Note that we disallow the use of a state derivation function here to preserve type information + * for the UI (the state deriver type always returns `Json`). */ - name = 'BaseController'; + usedInUi: boolean; +}; + +/** + * A universal supertype of `StateDeriver` types. + * This type can be assigned to any `StateDeriver` type. + */ +export type StateDeriverConstraint = (value: never) => Json; + +/** + * A universal supertype of `StatePropertyMetadata` types. + * This type can be assigned to any `StatePropertyMetadata` type. + */ +export type StatePropertyMetadataConstraint = { + includeInDebugSnapshot: boolean | StateDeriverConstraint; + includeInStateLogs: boolean | StateDeriverConstraint; + persist: boolean | StateDeriverConstraint; + usedInUi: boolean; +}; + +/** + * A universal supertype of `StateMetadata` types. + * This type can be assigned to any `StateMetadata` type. + */ +export type StateMetadataConstraint = Record< + string, + StatePropertyMetadataConstraint +>; - private readonly initialConfig: C; +/** + * The widest subtype of all controller instances that inherit from `BaseController` (formerly `BaseControllerV2`). + * Any `BaseController` subclass instance can be assigned to this type. + */ +export type BaseControllerInstance = Omit< + PublicInterface< + BaseController< + string, + StateConstraint, + // Use `any` to allow any parent to be set. + // eslint-disable-next-line @typescript-eslint/no-explicit-any + Messenger + > + >, + 'metadata' +> & { + metadata: StateMetadataConstraint; +}; - private readonly initialState: S; +export type ControllerGetStateAction< + ControllerName extends string, + ControllerState extends StateConstraint, +> = { + type: `${ControllerName}:getState`; + handler: () => ControllerState; +}; - private internalConfig: C = this.defaultConfig; +/** + * @deprecated This event type is deprecated. Please use + * `ControllerStateChangedEvent` instead. + */ +export type ControllerStateChangeEvent< + ControllerName extends string, + ControllerState extends StateConstraint, +> = { + type: `${ControllerName}:stateChange`; + payload: [ControllerState, Patch[]]; +}; + +export type ControllerStateChangedEvent< + ControllerName extends string, + ControllerState extends StateConstraint, +> = { + type: `${ControllerName}:stateChanged`; + payload: [ControllerState, Patch[]]; +}; - private internalState: S = this.defaultState; +export type ControllerActions< + ControllerName extends string, + ControllerState extends StateConstraint, +> = ControllerGetStateAction; - private readonly internalListeners: Listener[] = []; +export type ControllerEvents< + ControllerName extends string, + ControllerState extends StateConstraint, +> = + | ControllerStateChangeEvent + | ControllerStateChangedEvent; +/** + * Controller class that provides state management, subscriptions, and state metadata + */ +export class BaseController< + ControllerName extends string, + ControllerState extends StateConstraint, + ControllerMessenger extends Messenger< + ControllerName, + ActionConstraint, + EventConstraint, + // Use `any` to allow any parent to be set. `any` is harmless in a type constraint anyway, + // it's the one totally safe place to use it. + // eslint-disable-next-line @typescript-eslint/no-explicit-any + any + >, +> { /** - * Creates a BaseController instance. Both initial state and initial - * configuration options are merged with defaults upon initialization. - * - * @param config - Initial options used to configure this controller. - * @param state - Initial state to set on this controller. + * The controller state. */ - constructor(config: Partial = {} as C, state: Partial = {} as S) { - // Use assign since generics can't be spread: https://git.io/vpRhY - this.initialState = state as S; - this.initialConfig = config as C; - } + #internalState: ControllerState; /** - * Enables the controller. This sets each config option as a member - * variable on this instance and triggers any defined setters. This - * also sets initial state and triggers any listeners. - * - * @returns This controller instance. + * The controller messenger. This is used to interact with other parts of the application. */ - protected initialize() { - this.internalState = this.defaultState; - this.internalConfig = this.defaultConfig; - this.configure(this.initialConfig); - this.update(this.initialState); - return this; - } + protected messenger: ControllerMessenger; /** - * Retrieves current controller configuration options. + * The controller messenger. * - * @returns The current configuration. + * This is the same as the `messenger` property, but has a type that only lets us use + * actions and events that are part of the `BaseController` class. */ - get config() { - return this.internalConfig; - } + readonly #messenger: Messenger< + ControllerName, + ControllerActions, + ControllerEvents + >; /** - * Retrieves current controller state. + * The name of the controller. * - * @returns The current state. + * This is used by the ComposableController to construct a composed application state. */ - get state() { - return this.internalState; - } + public readonly name: ControllerName; + + public readonly metadata: StateMetadata; /** - * Updates controller configuration. + * Creates a BaseController instance. * - * @param config - New configuration options. - * @param overwrite - Overwrite config instead of merging. - * @param fullUpdate - Boolean that defines if the update is partial or not. + * @param options - Controller options. + * @param options.messenger - The controller messenger. + * @param options.metadata - ControllerState metadata, describing how to "anonymize" the state, and which + * parts should be persisted. + * @param options.name - The name of the controller, used as a namespace for events and actions. + * @param options.state - Initial controller state. */ - configure(config: Partial, overwrite = false, fullUpdate = true) { - if (fullUpdate) { - this.internalConfig = overwrite - ? (config as C) - : Object.assign(this.internalConfig, config); - - for (const key in this.internalConfig) { - if (typeof this.internalConfig[key] !== 'undefined') { - (this as any)[key as string] = this.internalConfig[key]; - } - } - } else { - for (const key in config) { - /* istanbul ignore else */ - if (typeof this.internalConfig[key] !== 'undefined') { - this.internalConfig[key] = config[key] as any; - (this as any)[key as string] = config[key]; - } - } - } + constructor({ + messenger, + metadata, + name, + state, + }: { + messenger: ControllerActions< + ControllerName, + ControllerState + >['type'] extends MessengerActions['type'] + ? ControllerStateChangeEvent< + ControllerName, + ControllerState + >['type'] extends MessengerEvents['type'] + ? ControllerMessenger + : ControllerStateChangedEvent< + ControllerName, + ControllerState + >['type'] extends MessengerEvents['type'] + ? ControllerMessenger + : never + : never; + metadata: StateMetadata; + name: ControllerName; + state: ControllerState; + }) { + // The parameter type validates that the expected actions/events are present + // We don't have a way to validate the type property because the type is invariant + this.#messenger = messenger as unknown as Messenger< + ControllerName, + ControllerActions, + ControllerEvents + >; + this.messenger = messenger; + this.name = name; + // Here we use `freeze` from Immer to enforce that the state is deeply + // immutable. Note that this is a runtime check, not a compile-time check. + // That is, unlike `Object.freeze`, this does not narrow the type + // recursively to `Readonly`. The equivalent in Immer is `Immutable`, but + // `Immutable` does not handle recursive types such as our `Json` type. + this.#internalState = freeze(state, true); + this.metadata = metadata; + + this.#messenger.registerActionHandler(`${name}:getState`, () => this.state); + + this.#messenger.registerInitialEventPayload({ + eventType: `${name}:stateChange`, + getPayload: () => [this.state, []], + }); + this.#messenger.registerInitialEventPayload({ + eventType: `${name}:stateChanged`, + getPayload: () => [this.state, []], + }); } /** - * Notifies all subscribed listeners of current state. + * Retrieves current controller state. + * + * @returns The current state. */ - notify() { - if (this.disabled) { - return; - } + get state(): ControllerState { + return this.#internalState; + } - this.internalListeners.forEach((listener) => { - listener(this.internalState); - }); + set state(_) { + throw new Error( + `Controller state cannot be directly mutated; use 'update' method instead.`, + ); } /** - * Adds new listener to be notified of state changes. + * Updates controller state. Accepts a callback that is passed a draft copy + * of the controller state. If a value is returned, it is set as the new + * state. Otherwise, any changes made within that callback to the draft are + * applied to the controller state. * - * @param listener - The callback triggered when state changes. + * @param callback - Callback for updating state, passed a draft state + * object. Return a new state object or mutate the draft to update state. + * @returns An object that has the next state, patches applied in the update and inverse patches to + * rollback the update. */ - subscribe(listener: Listener) { - this.internalListeners.push(listener); + protected update( + callback: (state: Draft) => void | ControllerState, + ): { + nextState: ControllerState; + patches: Patch[]; + inversePatches: Patch[]; + } { + // We run into ts2589, "infinite type depth", if we don't cast + // produceWithPatches here. + const [nextState, patches, inversePatches] = ( + produceWithPatches as unknown as ( + state: ControllerState, + callbackFn: typeof callback, + ) => [ControllerState, Patch[], Patch[]] + )(this.#internalState, callback); + + // Protect against unnecessary state updates when there is no state diff. + if (patches.length > 0) { + this.#internalState = nextState; + this.#messenger.publish( + `${this.name}:stateChange` as const, + nextState, + patches, + ); + this.#messenger.publish( + `${this.name}:stateChanged` as const, + nextState, + patches, + ); + } + + return { nextState, patches, inversePatches }; } /** - * Removes existing listener from receiving state changes. + * Applies immer patches to the current state. The patches come from the + * update function itself and can either be normal or inverse patches. * - * @param listener - The callback to remove. - * @returns `true` if a listener is found and unsubscribed. + * @param patches - An array of immer patches that are to be applied to make + * or undo changes. */ - unsubscribe(listener: Listener) { - const index = this.internalListeners.findIndex((cb) => listener === cb); - index > -1 && this.internalListeners.splice(index, 1); - return index > -1; + protected applyPatches(patches: Patch[]): void { + const nextState = applyPatches(this.#internalState, patches); + this.#internalState = nextState; + this.#messenger.publish( + `${this.name}:stateChange` as const, + nextState, + patches, + ); + this.#messenger.publish( + `${this.name}:stateChanged` as const, + nextState, + patches, + ); } /** - * Updates controller state. + * Prepares the controller for garbage collection. This should be extended + * by any subclasses to clean up any additional connections or events. * - * @param state - The new state. - * @param overwrite - Overwrite state instead of merging. + * The only cleanup performed here is to remove listeners. While technically + * this is not required to ensure this instance is garbage collected, it at + * least ensures this instance won't be responsible for preventing the + * listeners from being garbage collected. */ - update(state: Partial, overwrite = false) { - this.internalState = overwrite - ? Object.assign({}, state as S) - : Object.assign({}, this.internalState, state); - this.notify(); + protected destroy(): void { + this.messenger.clearEventSubscriptions(`${this.name}:stateChange`); + this.messenger.clearEventSubscriptions(`${this.name}:stateChanged`); } } -export default BaseController; +/** + * Use the metadata to derive state according to the given metadata property. + * + * @param state - The full controller state. + * @param metadata - The controller metadata. + * @param metadataProperty - The metadata property to use to derive state. + * @param captureException - Reports an error to an error monitoring service. + * @returns The metadata-derived controller state. + */ +export function deriveStateFromMetadata< + ControllerState extends StateConstraint, +>( + state: ControllerState, + metadata: StateMetadata, + metadataProperty: keyof StatePropertyMetadata, + captureException?: (error: Error) => void, +): Record { + return (Object.keys(state) as (keyof ControllerState)[]).reduce< + Record + >((derivedState, key) => { + try { + const stateMetadata = metadata[key]; + if (!stateMetadata) { + throw new Error(`No metadata found for '${String(key)}'`); + } + const propertyMetadata = stateMetadata[metadataProperty]; + const stateProperty = state[key]; + if (typeof propertyMetadata === 'function') { + derivedState[key] = propertyMetadata(stateProperty); + } else if (propertyMetadata) { + derivedState[key] = stateProperty; + } + return derivedState; + } catch (error) { + // Capture error without interrupting state-related operations + // See [ADR core#0016](https://github.com/MetaMask/decisions/blob/main/decisions/core/0016-core-classes-error-reporting.md) + if (captureException) { + try { + captureException( + error instanceof Error ? error : new Error(String(error)), + ); + } catch (captureExceptionError) { + console.error( + new Error(`Error thrown when calling 'captureException'`), + captureExceptionError, + ); + console.error(error); + } + } else { + console.error(error); + } + return derivedState; + } + }, {} as never); +} diff --git a/packages/base-controller/src/BaseControllerV2.test.ts b/packages/base-controller/src/BaseControllerV2.test.ts deleted file mode 100644 index 7a3a261682b..00000000000 --- a/packages/base-controller/src/BaseControllerV2.test.ts +++ /dev/null @@ -1,944 +0,0 @@ -import type { Draft, Patch } from 'immer'; -import * as sinon from 'sinon'; - -import { - BaseController, - getAnonymizedState, - getPersistentState, -} from './BaseControllerV2'; -import type { RestrictedControllerMessenger } from './ControllerMessenger'; -import { ControllerMessenger } from './ControllerMessenger'; - -const countControllerName = 'CountController'; - -type CountControllerState = { - count: number; -}; -type CountControllerAction = { - type: `${typeof countControllerName}:getState`; - handler: () => CountControllerState; -}; - -type CountControllerEvent = { - type: `${typeof countControllerName}:stateChange`; - payload: [CountControllerState, Patch[]]; -}; - -const countControllerStateMetadata = { - count: { - persist: true, - anonymous: true, - }, -}; - -type CountMessenger = RestrictedControllerMessenger< - typeof countControllerName, - CountControllerAction, - CountControllerEvent, - never, - never ->; - -/** - * Constructs a restricted controller messenger for the Count controller. - * - * @param controllerMessenger - The controller messenger. - * @returns A restricted controller messenger for the Count controller. - */ -function getCountMessenger( - controllerMessenger?: ControllerMessenger< - CountControllerAction, - CountControllerEvent - >, -): CountMessenger { - if (!controllerMessenger) { - controllerMessenger = new ControllerMessenger< - CountControllerAction, - CountControllerEvent - >(); - } - return controllerMessenger.getRestricted<'CountController', never, never>({ - name: countControllerName, - }); -} - -class CountController extends BaseController< - typeof countControllerName, - CountControllerState, - CountMessenger -> { - update( - callback: ( - state: Draft, - ) => void | CountControllerState, - ) { - const res = super.update(callback); - return res; - } - - applyPatches(patches: Patch[]) { - super.applyPatches(patches); - } - - destroy() { - super.destroy(); - } -} - -describe('BaseController', () => { - afterEach(() => { - sinon.restore(); - }); - - it('should set initial state', () => { - const controller = new CountController({ - messenger: getCountMessenger(), - name: countControllerName, - state: { count: 0 }, - metadata: countControllerStateMetadata, - }); - - expect(controller.state).toStrictEqual({ count: 0 }); - }); - - it('should allow getting state via the getState action', () => { - const controllerMessenger = new ControllerMessenger< - CountControllerAction, - CountControllerEvent - >(); - new CountController({ - messenger: getCountMessenger(controllerMessenger), - name: countControllerName, - state: { count: 0 }, - metadata: countControllerStateMetadata, - }); - - expect(controllerMessenger.call('CountController:getState')).toStrictEqual({ - count: 0, - }); - }); - - it('should set initial schema', () => { - const controller = new CountController({ - messenger: getCountMessenger(), - name: 'CountController', - state: { count: 0 }, - metadata: countControllerStateMetadata, - }); - - expect(controller.metadata).toStrictEqual(countControllerStateMetadata); - }); - - it('should not allow mutating state directly', () => { - const controller = new CountController({ - messenger: getCountMessenger(), - name: 'CountController', - state: { count: 0 }, - metadata: countControllerStateMetadata, - }); - - expect(() => { - controller.state = { count: 1 }; - }).toThrow( - "Controller state cannot be directly mutated; use 'update' method instead.", - ); - }); - - it('should allow updating state by modifying draft', () => { - const controller = new CountController({ - messenger: getCountMessenger(), - name: 'CountController', - state: { count: 0 }, - metadata: countControllerStateMetadata, - }); - - controller.update((draft) => { - draft.count += 1; - }); - - expect(controller.state).toStrictEqual({ count: 1 }); - }); - - it('should allow updating state by return a value', () => { - const controller = new CountController({ - messenger: getCountMessenger(), - name: 'CountController', - state: { count: 0 }, - metadata: countControllerStateMetadata, - }); - - controller.update(() => { - return { count: 1 }; - }); - - expect(controller.state).toStrictEqual({ count: 1 }); - }); - - it('should return next state, patches and inverse patches after an update', () => { - const controller = new CountController({ - messenger: getCountMessenger(), - name: 'CountController', - state: { count: 0 }, - metadata: countControllerStateMetadata, - }); - - const returnObj = controller.update((draft) => { - draft.count += 1; - }); - - expect(returnObj).toBeDefined(); - expect(returnObj.nextState).toStrictEqual({ count: 1 }); - expect(returnObj.patches).toStrictEqual([ - { op: 'replace', path: ['count'], value: 1 }, - ]); - - expect(returnObj.inversePatches).toStrictEqual([ - { op: 'replace', path: ['count'], value: 0 }, - ]); - }); - - it('should throw an error if update callback modifies draft and returns value', () => { - const controller = new CountController({ - messenger: getCountMessenger(), - name: 'CountController', - state: { count: 0 }, - metadata: countControllerStateMetadata, - }); - - expect(() => { - controller.update((draft) => { - draft.count += 1; - return { count: 10 }; - }); - }).toThrow( - '[Immer] An immer producer returned a new value *and* modified its draft. Either return a new value *or* modify the draft.', - ); - }); - - it('should allow for applying immer patches to state', () => { - const controller = new CountController({ - messenger: getCountMessenger(), - name: 'CountController', - state: { count: 0 }, - metadata: countControllerStateMetadata, - }); - - const returnObj = controller.update((draft) => { - draft.count += 1; - }); - - controller.applyPatches(returnObj.inversePatches); - - expect(controller.state).toStrictEqual({ count: 0 }); - }); - - it('should inform subscribers of state changes as a result of applying patches', () => { - const controllerMessenger = new ControllerMessenger< - never, - CountControllerEvent - >(); - const controller = new CountController({ - messenger: getCountMessenger(controllerMessenger), - name: 'CountController', - state: { count: 0 }, - metadata: countControllerStateMetadata, - }); - const listener1 = sinon.stub(); - - controllerMessenger.subscribe('CountController:stateChange', listener1); - const { inversePatches } = controller.update(() => { - return { count: 1 }; - }); - - controller.applyPatches(inversePatches); - - expect(listener1.callCount).toBe(2); - expect(listener1.firstCall.args).toStrictEqual([ - { count: 1 }, - [{ op: 'replace', path: [], value: { count: 1 } }], - ]); - - expect(listener1.secondCall.args).toStrictEqual([ - { count: 0 }, - [{ op: 'replace', path: [], value: { count: 0 } }], - ]); - }); - - it('should inform subscribers of state changes', () => { - const controllerMessenger = new ControllerMessenger< - never, - CountControllerEvent - >(); - const controller = new CountController({ - messenger: getCountMessenger(controllerMessenger), - name: 'CountController', - state: { count: 0 }, - metadata: countControllerStateMetadata, - }); - const listener1 = sinon.stub(); - const listener2 = sinon.stub(); - - controllerMessenger.subscribe('CountController:stateChange', listener1); - controllerMessenger.subscribe('CountController:stateChange', listener2); - controller.update(() => { - return { count: 1 }; - }); - - expect(listener1.callCount).toBe(1); - expect(listener1.firstCall.args).toStrictEqual([ - { count: 1 }, - [{ op: 'replace', path: [], value: { count: 1 } }], - ]); - expect(listener2.callCount).toBe(1); - expect(listener2.firstCall.args).toStrictEqual([ - { count: 1 }, - [{ op: 'replace', path: [], value: { count: 1 } }], - ]); - }); - - it('should inform a subscriber of each state change once even after multiple subscriptions', () => { - const controllerMessenger = new ControllerMessenger< - never, - CountControllerEvent - >(); - const controller = new CountController({ - messenger: getCountMessenger(controllerMessenger), - name: 'CountController', - state: { count: 0 }, - metadata: countControllerStateMetadata, - }); - const listener1 = sinon.stub(); - - controllerMessenger.subscribe('CountController:stateChange', listener1); - controllerMessenger.subscribe('CountController:stateChange', listener1); - - controller.update(() => { - return { count: 1 }; - }); - - expect(listener1.callCount).toBe(1); - expect(listener1.firstCall.args).toStrictEqual([ - { count: 1 }, - [{ op: 'replace', path: [], value: { count: 1 } }], - ]); - }); - - it('should no longer inform a subscriber about state changes after unsubscribing', () => { - const controllerMessenger = new ControllerMessenger< - never, - CountControllerEvent - >(); - const controller = new CountController({ - messenger: getCountMessenger(controllerMessenger), - name: 'CountController', - state: { count: 0 }, - metadata: countControllerStateMetadata, - }); - const listener1 = sinon.stub(); - - controllerMessenger.subscribe('CountController:stateChange', listener1); - controllerMessenger.unsubscribe('CountController:stateChange', listener1); - controller.update(() => { - return { count: 1 }; - }); - - expect(listener1.callCount).toBe(0); - }); - - it('should no longer inform a subscriber about state changes after unsubscribing once, even if they subscribed many times', () => { - const controllerMessenger = new ControllerMessenger< - never, - CountControllerEvent - >(); - const controller = new CountController({ - messenger: getCountMessenger(controllerMessenger), - name: 'CountController', - state: { count: 0 }, - metadata: countControllerStateMetadata, - }); - const listener1 = sinon.stub(); - - controllerMessenger.subscribe('CountController:stateChange', listener1); - controllerMessenger.subscribe('CountController:stateChange', listener1); - controllerMessenger.unsubscribe('CountController:stateChange', listener1); - controller.update(() => { - return { count: 1 }; - }); - - expect(listener1.callCount).toBe(0); - }); - - it('should throw when unsubscribing listener who was never subscribed', () => { - const controllerMessenger = new ControllerMessenger< - never, - CountControllerEvent - >(); - new CountController({ - messenger: getCountMessenger(controllerMessenger), - name: 'CountController', - state: { count: 0 }, - metadata: countControllerStateMetadata, - }); - const listener1 = sinon.stub(); - - expect(() => { - controllerMessenger.unsubscribe('CountController:stateChange', listener1); - }).toThrow('Subscription not found for event: CountController:stateChange'); - }); - - it('should no longer update subscribers after being destroyed', () => { - const controllerMessenger = new ControllerMessenger< - never, - CountControllerEvent - >(); - const controller = new CountController({ - messenger: getCountMessenger(controllerMessenger), - name: 'CountController', - state: { count: 0 }, - metadata: countControllerStateMetadata, - }); - const listener1 = sinon.stub(); - const listener2 = sinon.stub(); - - controllerMessenger.subscribe('CountController:stateChange', listener1); - controllerMessenger.subscribe('CountController:stateChange', listener2); - controller.destroy(); - controller.update(() => { - return { count: 1 }; - }); - - expect(listener1.callCount).toBe(0); - expect(listener2.callCount).toBe(0); - }); -}); - -describe('getAnonymizedState', () => { - afterEach(() => { - sinon.restore(); - }); - - it('should return empty state', () => { - expect(getAnonymizedState({}, {})).toStrictEqual({}); - }); - - it('should return empty state when no properties are anonymized', () => { - const anonymizedState = getAnonymizedState( - { count: 1 }, - { count: { anonymous: false, persist: false } }, - ); - expect(anonymizedState).toStrictEqual({}); - }); - - it('should return state that is already anonymized', () => { - const anonymizedState = getAnonymizedState( - { - password: 'secret password', - privateKey: '123', - network: 'mainnet', - tokens: ['DAI', 'USDC'], - }, - { - password: { - anonymous: false, - persist: false, - }, - privateKey: { - anonymous: false, - persist: false, - }, - network: { - anonymous: true, - persist: false, - }, - tokens: { - anonymous: true, - persist: false, - }, - }, - ); - expect(anonymizedState).toStrictEqual({ - network: 'mainnet', - tokens: ['DAI', 'USDC'], - }); - }); - - it('should use anonymizing function to anonymize state', () => { - const anonymizeTransactionHash = (hash: string) => { - return hash.split('').reverse().join(''); - }; - - const anonymizedState = getAnonymizedState( - { - transactionHash: '0x1234', - }, - { - transactionHash: { - anonymous: anonymizeTransactionHash, - persist: false, - }, - }, - ); - - expect(anonymizedState).toStrictEqual({ transactionHash: '4321x0' }); - }); - - it('should allow returning a partial object from an anonymizing function', () => { - const anonymizeTxMeta = (txMeta: { hash: string; value: number }) => { - return { value: txMeta.value }; - }; - - const anonymizedState = getAnonymizedState( - { - txMeta: { - hash: '0x123', - value: 10, - }, - }, - { - txMeta: { - anonymous: anonymizeTxMeta, - persist: false, - }, - }, - ); - - expect(anonymizedState).toStrictEqual({ txMeta: { value: 10 } }); - }); - - it('should allow returning a nested partial object from an anonymizing function', () => { - const anonymizeTxMeta = (txMeta: { - hash: string; - value: number; - history: { hash: string; value: number }[]; - }) => { - return { - history: txMeta.history.map((entry) => { - return { value: entry.value }; - }), - value: txMeta.value, - }; - }; - - const anonymizedState = getAnonymizedState( - { - txMeta: { - hash: '0x123', - history: [ - { - hash: '0x123', - value: 9, - }, - ], - value: 10, - }, - }, - { - txMeta: { - anonymous: anonymizeTxMeta, - persist: false, - }, - }, - ); - - expect(anonymizedState).toStrictEqual({ - txMeta: { history: [{ value: 9 }], value: 10 }, - }); - }); - - it('should allow transforming types in an anonymizing function', () => { - const anonymizedState = getAnonymizedState( - { - count: '1', - }, - { - count: { - anonymous: (count) => Number(count), - persist: false, - }, - }, - ); - - expect(anonymizedState).toStrictEqual({ count: 1 }); - }); - - it('should suppress errors thrown when deriving state', () => { - const setTimeoutStub = sinon.stub(globalThis, 'setTimeout'); - const persistentState = getAnonymizedState( - { - extraState: 'extraState', - privateKey: '123', - network: 'mainnet', - }, - // @ts-expect-error Intentionally testing invalid state - { - privateKey: { - anonymous: true, - persist: true, - }, - network: { - anonymous: false, - persist: false, - }, - }, - ); - expect(persistentState).toStrictEqual({ - privateKey: '123', - }); - expect(setTimeoutStub.callCount).toBe(1); - const onTimeout = setTimeoutStub.firstCall.args[0]; - expect(() => onTimeout()).toThrow(`No metadata found for 'extraState'`); - }); -}); - -describe('getPersistentState', () => { - afterEach(() => { - sinon.restore(); - }); - - it('should return empty state', () => { - expect(getPersistentState({}, {})).toStrictEqual({}); - }); - - it('should return empty state when no properties are persistent', () => { - const persistentState = getPersistentState( - { count: 1 }, - { count: { anonymous: false, persist: false } }, - ); - expect(persistentState).toStrictEqual({}); - }); - - it('should return persistent state', () => { - const persistentState = getPersistentState( - { - password: 'secret password', - privateKey: '123', - network: 'mainnet', - tokens: ['DAI', 'USDC'], - }, - { - password: { - anonymous: false, - persist: true, - }, - privateKey: { - anonymous: false, - persist: true, - }, - network: { - anonymous: false, - persist: false, - }, - tokens: { - anonymous: false, - persist: false, - }, - }, - ); - expect(persistentState).toStrictEqual({ - password: 'secret password', - privateKey: '123', - }); - }); - - it('should use function to derive persistent state', () => { - const normalizeTransacitonHash = (hash: string) => { - return hash.toLowerCase(); - }; - - const persistentState = getPersistentState( - { - transactionHash: '0X1234', - }, - { - transactionHash: { - anonymous: false, - persist: normalizeTransacitonHash, - }, - }, - ); - - expect(persistentState).toStrictEqual({ transactionHash: '0x1234' }); - }); - - it('should allow returning a partial object from a persist function', () => { - const getPersistentTxMeta = (txMeta: { hash: string; value: number }) => { - return { value: txMeta.value }; - }; - - const persistentState = getPersistentState( - { - txMeta: { - hash: '0x123', - value: 10, - }, - }, - { - txMeta: { - anonymous: false, - persist: getPersistentTxMeta, - }, - }, - ); - - expect(persistentState).toStrictEqual({ txMeta: { value: 10 } }); - }); - - it('should allow returning a nested partial object from a persist function', () => { - const getPersistentTxMeta = (txMeta: { - hash: string; - value: number; - history: { hash: string; value: number }[]; - }) => { - return { - history: txMeta.history.map((entry) => { - return { value: entry.value }; - }), - value: txMeta.value, - }; - }; - - const persistentState = getPersistentState( - { - txMeta: { - hash: '0x123', - history: [ - { - hash: '0x123', - value: 9, - }, - ], - value: 10, - }, - }, - { - txMeta: { - anonymous: false, - persist: getPersistentTxMeta, - }, - }, - ); - - expect(persistentState).toStrictEqual({ - txMeta: { history: [{ value: 9 }], value: 10 }, - }); - }); - - it('should allow transforming types in a persist function', () => { - const persistentState = getPersistentState( - { - count: '1', - }, - { - count: { - anonymous: false, - persist: (count) => Number(count), - }, - }, - ); - - expect(persistentState).toStrictEqual({ count: 1 }); - }); - - it('should suppress errors thrown when deriving state', () => { - const setTimeoutStub = sinon.stub(globalThis, 'setTimeout'); - const persistentState = getPersistentState( - { - extraState: 'extraState', - privateKey: '123', - network: 'mainnet', - }, - // @ts-expect-error Intentionally testing invalid state - { - privateKey: { - anonymous: false, - persist: true, - }, - network: { - anonymous: false, - persist: false, - }, - }, - ); - expect(persistentState).toStrictEqual({ - privateKey: '123', - }); - expect(setTimeoutStub.callCount).toBe(1); - const onTimeout = setTimeoutStub.firstCall.args[0]; - expect(() => onTimeout()).toThrow(`No metadata found for 'extraState'`); - }); - - describe('inter-controller communication', () => { - // These two contrived mock controllers are setup to test with. - // The 'VisitorController' records strings that represent visitors. - // The 'VisitorOverflowController' monitors the 'VisitorController' to ensure the number of - // visitors doesn't exceed the maximum capacity. If it does, it will clear out all visitors. - - const visitorName = 'VisitorController'; - - type VisitorControllerState = { - visitors: string[]; - }; - type VisitorControllerAction = { - type: `${typeof visitorName}:clear`; - handler: () => void; - }; - type VisitorControllerEvent = { - type: `${typeof visitorName}:stateChange`; - payload: [VisitorControllerState, Patch[]]; - }; - - const visitorControllerStateMetadata = { - visitors: { - persist: true, - anonymous: true, - }, - }; - - type VisitorMessenger = RestrictedControllerMessenger< - typeof visitorName, - VisitorControllerAction | VisitorOverflowControllerAction, - VisitorControllerEvent | VisitorOverflowControllerEvent, - never, - never - >; - class VisitorController extends BaseController< - typeof visitorName, - VisitorControllerState, - VisitorMessenger - > { - constructor(messagingSystem: VisitorMessenger) { - super({ - messenger: messagingSystem, - metadata: visitorControllerStateMetadata, - name: visitorName, - state: { visitors: [] }, - }); - - messagingSystem.registerActionHandler( - 'VisitorController:clear', - this.clear, - ); - } - - clear = () => { - this.update(() => { - return { visitors: [] }; - }); - }; - - addVisitor(visitor: string) { - this.update(({ visitors }) => { - return { visitors: [...visitors, visitor] }; - }); - } - - destroy() { - super.destroy(); - } - } - - const visitorOverflowName = 'VisitorOverflowController'; - - type VisitorOverflowControllerState = { - maxVisitors: number; - }; - type VisitorOverflowControllerAction = { - type: `${typeof visitorOverflowName}:updateMax`; - handler: (max: number) => void; - }; - type VisitorOverflowControllerEvent = { - type: `${typeof visitorOverflowName}:stateChange`; - payload: [VisitorOverflowControllerState, Patch[]]; - }; - - const visitorOverflowControllerMetadata = { - maxVisitors: { - persist: false, - anonymous: true, - }, - }; - - type VisitorOverflowMessenger = RestrictedControllerMessenger< - typeof visitorOverflowName, - VisitorControllerAction | VisitorOverflowControllerAction, - VisitorControllerEvent | VisitorOverflowControllerEvent, - `${typeof visitorName}:clear`, - `${typeof visitorName}:stateChange` - >; - - class VisitorOverflowController extends BaseController< - typeof visitorOverflowName, - VisitorOverflowControllerState, - VisitorOverflowMessenger - > { - constructor(messagingSystem: VisitorOverflowMessenger) { - super({ - messenger: messagingSystem, - metadata: visitorOverflowControllerMetadata, - name: visitorOverflowName, - state: { maxVisitors: 5 }, - }); - - messagingSystem.registerActionHandler( - 'VisitorOverflowController:updateMax', - this.updateMax, - ); - - messagingSystem.subscribe( - 'VisitorController:stateChange', - this.onVisit, - ); - } - - onVisit = ({ visitors }: VisitorControllerState) => { - if (visitors.length > this.state.maxVisitors) { - this.messagingSystem.call('VisitorController:clear'); - } - }; - - updateMax = (max: number) => { - this.update(() => { - return { maxVisitors: max }; - }); - }; - - destroy() { - super.destroy(); - } - } - - it('should allow messaging between controllers', () => { - const controllerMessenger = new ControllerMessenger< - VisitorControllerAction | VisitorOverflowControllerAction, - VisitorControllerEvent | VisitorOverflowControllerEvent - >(); - const visitorControllerMessenger = controllerMessenger.getRestricted< - typeof visitorName, - never, - never - >({ - name: visitorName, - }); - const visitorController = new VisitorController( - visitorControllerMessenger, - ); - const visitorOverflowControllerMessenger = - controllerMessenger.getRestricted({ - name: visitorOverflowName, - allowedActions: ['VisitorController:clear'], - allowedEvents: ['VisitorController:stateChange'], - }); - const visitorOverflowController = new VisitorOverflowController( - visitorOverflowControllerMessenger, - ); - - controllerMessenger.call('VisitorOverflowController:updateMax', 2); - visitorController.addVisitor('A'); - visitorController.addVisitor('B'); - visitorController.addVisitor('C'); // this should trigger an overflow - - expect(visitorOverflowController.state.maxVisitors).toBe(2); - expect(visitorController.state.visitors).toHaveLength(0); - }); - }); -}); diff --git a/packages/base-controller/src/BaseControllerV2.ts b/packages/base-controller/src/BaseControllerV2.ts deleted file mode 100644 index c1c8552346d..00000000000 --- a/packages/base-controller/src/BaseControllerV2.ts +++ /dev/null @@ -1,275 +0,0 @@ -import type { Json } from '@metamask/utils'; -import { enablePatches, produceWithPatches, applyPatches } from 'immer'; -import type { Draft, Patch } from 'immer'; - -import type { - RestrictedControllerMessenger, - Namespaced, -} from './ControllerMessenger'; - -enablePatches(); - -/** - * A state change listener. - * - * This function will get called for each state change, and is given a copy of - * the new state along with a set of patches describing the changes since the - * last update. - * - * @param state - The new controller state. - * @param patches - A list of patches describing any changes (see here for more - * information: https://immerjs.github.io/immer/docs/patches) - */ -export type Listener = (state: T, patches: Patch[]) => void; - -/** - * An function to derive state. - * - * This function will accept one piece of the controller state (one property), - * and will return some derivation of that state. - * - * @param value - A piece of controller state. - * @returns Something derived from controller state. - */ -export type StateDeriver = (value: T) => Json; - -/** - * State metadata. - * - * This metadata describes which parts of state should be persisted, and how to - * get an anonymized representation of the state. - */ -export type StateMetadata> = { - [P in keyof T]: StatePropertyMetadata; -}; - -/** - * Metadata for a single state property - * - * @property persist - Indicates whether this property should be persisted - * (`true` for persistent, `false` for transient), or is set to a function - * that derives the persistent state from the state. - * @property anonymous - Indicates whether this property is already anonymous, - * (`true` for anonymous, `false` if it has potential to be personally - * identifiable), or is set to a function that returns an anonymized - * representation of this state. - */ -export interface StatePropertyMetadata { - persist: boolean | StateDeriver; - anonymous: boolean | StateDeriver; -} - -/** - * Controller class that provides state management, subscriptions, and state metadata - */ -export class BaseController< - N extends string, - S extends Record, - messenger extends RestrictedControllerMessenger, -> { - private internalState: S; - - protected messagingSystem: messenger; - - /** - * The name of the controller. - * - * This is used by the ComposableController to construct a composed application state. - */ - public readonly name: N; - - public readonly metadata: StateMetadata; - - /** - * The existence of the `subscribe` property is how the ComposableController detects whether a - * controller extends the old BaseController or the new one. We set it to `undefined` here to - * ensure the ComposableController never mistakes them for an older style controller. - */ - public readonly subscribe: undefined; - - /** - * Creates a BaseController instance. - * - * @param options - Controller options. - * @param options.messenger - Controller messaging system. - * @param options.metadata - State metadata, describing how to "anonymize" the state, and which - * parts should be persisted. - * @param options.name - The name of the controller, used as a namespace for events and actions. - * @param options.state - Initial controller state. - */ - constructor({ - messenger, - metadata, - name, - state, - }: { - messenger: messenger; - metadata: StateMetadata; - name: N; - state: S; - }) { - this.messagingSystem = messenger; - this.name = name; - this.internalState = state; - this.metadata = metadata; - - this.messagingSystem.registerActionHandler( - `${name}:getState`, - () => this.state, - ); - } - - /** - * Retrieves current controller state. - * - * @returns The current state. - */ - get state() { - return this.internalState; - } - - set state(_) { - throw new Error( - `Controller state cannot be directly mutated; use 'update' method instead.`, - ); - } - - /** - * Updates controller state. Accepts a callback that is passed a draft copy - * of the controller state. If a value is returned, it is set as the new - * state. Otherwise, any changes made within that callback to the draft are - * applied to the controller state. - * - * @param callback - Callback for updating state, passed a draft state - * object. Return a new state object or mutate the draft to update state. - * @returns An object that has the next state, patches applied in the update and inverse patches to - * rollback the update. - */ - protected update(callback: (state: Draft) => void | S): { - nextState: S; - patches: Patch[]; - inversePatches: Patch[]; - } { - // We run into ts2589, "infinite type depth", if we don't cast - // produceWithPatches here. - const [nextState, patches, inversePatches] = ( - produceWithPatches as unknown as ( - state: S, - cb: typeof callback, - ) => [S, Patch[], Patch[]] - )(this.internalState, callback); - - this.internalState = nextState; - this.messagingSystem.publish( - `${this.name}:stateChange` as Namespaced, - nextState, - patches, - ); - - return { nextState, patches, inversePatches }; - } - - /** - * Applies immer patches to the current state. The patches come from the - * update function itself and can either be normal or inverse patches. - * - * @param patches - An array of immer patches that are to be applied to make - * or undo changes. - */ - protected applyPatches(patches: Patch[]) { - const nextState = applyPatches(this.internalState, patches); - this.internalState = nextState; - this.messagingSystem.publish( - `${this.name}:stateChange` as Namespaced, - nextState, - patches, - ); - } - - /** - * Prepares the controller for garbage collection. This should be extended - * by any subclasses to clean up any additional connections or events. - * - * The only cleanup performed here is to remove listeners. While technically - * this is not required to ensure this instance is garbage collected, it at - * least ensures this instance won't be responsible for preventing the - * listeners from being garbage collected. - */ - protected destroy() { - this.messagingSystem.clearEventSubscriptions( - `${this.name}:stateChange` as Namespaced, - ); - } -} - -/** - * Returns an anonymized representation of the controller state. - * - * By "anonymized" we mean that it should not contain any information that could be personally - * identifiable. - * - * @param state - The controller state. - * @param metadata - The controller state metadata, which describes how to derive the - * anonymized state. - * @returns The anonymized controller state. - */ -export function getAnonymizedState>( - state: S, - metadata: StateMetadata, -): Record { - return deriveStateFromMetadata(state, metadata, 'anonymous'); -} - -/** - * Returns the subset of state that should be persisted. - * - * @param state - The controller state. - * @param metadata - The controller state metadata, which describes which pieces of state should be persisted. - * @returns The subset of controller state that should be persisted. - */ -export function getPersistentState>( - state: S, - metadata: StateMetadata, -): Record { - return deriveStateFromMetadata(state, metadata, 'persist'); -} - -/** - * Use the metadata to derive state according to the given metadata property. - * - * @param state - The full controller state. - * @param metadata - The controller metadata. - * @param metadataProperty - The metadata property to use to derive state. - * @returns The metadata-derived controller state. - */ -function deriveStateFromMetadata>( - state: S, - metadata: StateMetadata, - metadataProperty: 'anonymous' | 'persist', -): Record { - return Object.keys(state).reduce((persistedState, key) => { - try { - const stateMetadata = metadata[key as keyof S]; - if (!stateMetadata) { - throw new Error(`No metadata found for '${key}'`); - } - const propertyMetadata = stateMetadata[metadataProperty]; - const stateProperty = state[key]; - if (typeof propertyMetadata === 'function') { - persistedState[key as string] = propertyMetadata( - stateProperty as S[keyof S], - ); - } else if (propertyMetadata) { - persistedState[key as string] = stateProperty; - } - return persistedState; - } catch (error) { - // Throw error after timeout so that it is captured as a console error - // (and by Sentry) without interrupting state-related operations - setTimeout(() => { - throw error; - }); - return persistedState; - } - }, {} as Record); -} diff --git a/packages/base-controller/src/ControllerMessenger.test.ts b/packages/base-controller/src/ControllerMessenger.test.ts deleted file mode 100644 index a045d547c40..00000000000 --- a/packages/base-controller/src/ControllerMessenger.test.ts +++ /dev/null @@ -1,1128 +0,0 @@ -import type { Patch } from 'immer'; -import * as sinon from 'sinon'; - -import { ControllerMessenger } from './ControllerMessenger'; - -describe('ControllerMessenger', () => { - afterEach(() => { - sinon.restore(); - }); - - it('should allow registering and calling an action handler', () => { - type CountAction = { type: 'count'; handler: (increment: number) => void }; - const controllerMessenger = new ControllerMessenger(); - - let count = 0; - controllerMessenger.registerActionHandler('count', (increment: number) => { - count += increment; - }); - controllerMessenger.call('count', 1); - - expect(count).toBe(1); - }); - - it('should allow registering and calling multiple different action handlers', () => { - // These 'Other' types are included to demonstrate that controller messenger - // generics can indeed be unions of actions and events from different - // controllers. - type GetOtherState = { - type: `OtherController:getState`; - handler: () => { stuff: string }; - }; - - type OtherStateChange = { - type: `OtherController:stateChange`; - payload: [{ stuff: string }, Patch[]]; - }; - - type MessageAction = - | { type: 'concat'; handler: (message: string) => void } - | { type: 'reset'; handler: (initialMessage: string) => void }; - const controllerMessenger = new ControllerMessenger< - MessageAction | GetOtherState, - OtherStateChange - >(); - - let message = ''; - controllerMessenger.registerActionHandler( - 'reset', - (initialMessage: string) => { - message = initialMessage; - }, - ); - - controllerMessenger.registerActionHandler('concat', (s: string) => { - message += s; - }); - - controllerMessenger.call('reset', 'hello'); - controllerMessenger.call('concat', ', world'); - - expect(message).toBe('hello, world'); - }); - - it('should allow registering and calling an action handler with no parameters', () => { - type IncrementAction = { type: 'increment'; handler: () => void }; - const controllerMessenger = new ControllerMessenger< - IncrementAction, - never - >(); - - let count = 0; - controllerMessenger.registerActionHandler('increment', () => { - count += 1; - }); - controllerMessenger.call('increment'); - - expect(count).toBe(1); - }); - - it('should allow registering and calling an action handler with multiple parameters', () => { - type MessageAction = { - type: 'message'; - handler: (to: string, message: string) => void; - }; - const controllerMessenger = new ControllerMessenger(); - - const messages: Record = {}; - controllerMessenger.registerActionHandler('message', (to, message) => { - messages[to] = message; - }); - controllerMessenger.call('message', '0x123', 'hello'); - - expect(messages['0x123']).toBe('hello'); - }); - - it('should allow registering and calling an action handler with a return value', () => { - type AddAction = { type: 'add'; handler: (a: number, b: number) => number }; - const controllerMessenger = new ControllerMessenger(); - - controllerMessenger.registerActionHandler('add', (a, b) => { - return a + b; - }); - const result = controllerMessenger.call('add', 5, 10); - - expect(result).toBe(15); - }); - - it('should not allow registering multiple action handlers under the same name', () => { - type PingAction = { type: 'ping'; handler: () => void }; - const controllerMessenger = new ControllerMessenger(); - - controllerMessenger.registerActionHandler('ping', () => undefined); - - expect(() => { - controllerMessenger.registerActionHandler('ping', () => undefined); - }).toThrow('A handler for ping has already been registered'); - }); - - it('should throw when calling unregistered action', () => { - type PingAction = { type: 'ping'; handler: () => void }; - const controllerMessenger = new ControllerMessenger(); - - expect(() => { - controllerMessenger.call('ping'); - }).toThrow('A handler for ping has not been registered'); - }); - - it('should throw when calling an action that has been unregistered', () => { - type PingAction = { type: 'ping'; handler: () => void }; - const controllerMessenger = new ControllerMessenger(); - - expect(() => { - controllerMessenger.call('ping'); - }).toThrow('A handler for ping has not been registered'); - - let pingCount = 0; - controllerMessenger.registerActionHandler('ping', () => { - pingCount += 1; - }); - - controllerMessenger.unregisterActionHandler('ping'); - - expect(() => { - controllerMessenger.call('ping'); - }).toThrow('A handler for ping has not been registered'); - expect(pingCount).toBe(0); - }); - - it('should throw when calling an action after actions have been reset', () => { - type PingAction = { type: 'ping'; handler: () => void }; - const controllerMessenger = new ControllerMessenger(); - - expect(() => { - controllerMessenger.call('ping'); - }).toThrow('A handler for ping has not been registered'); - - let pingCount = 0; - controllerMessenger.registerActionHandler('ping', () => { - pingCount += 1; - }); - - controllerMessenger.clearActions(); - - expect(() => { - controllerMessenger.call('ping'); - }).toThrow('A handler for ping has not been registered'); - expect(pingCount).toBe(0); - }); - - it('should publish event to subscriber', () => { - type MessageEvent = { type: 'message'; payload: [string] }; - const controllerMessenger = new ControllerMessenger(); - - const handler = sinon.stub(); - controllerMessenger.subscribe('message', handler); - controllerMessenger.publish('message', 'hello'); - - expect(handler.calledWithExactly('hello')).toBe(true); - expect(handler.callCount).toBe(1); - }); - - it('should allow publishing multiple different events to subscriber', () => { - type MessageEvent = - | { type: 'message'; payload: [string] } - | { type: 'ping'; payload: [] }; - const controllerMessenger = new ControllerMessenger(); - - const messageHandler = sinon.stub(); - const pingHandler = sinon.stub(); - controllerMessenger.subscribe('message', messageHandler); - controllerMessenger.subscribe('ping', pingHandler); - - controllerMessenger.publish('message', 'hello'); - controllerMessenger.publish('ping'); - - expect(messageHandler.calledWithExactly('hello')).toBe(true); - expect(messageHandler.callCount).toBe(1); - expect(pingHandler.calledWithExactly()).toBe(true); - expect(pingHandler.callCount).toBe(1); - }); - - it('should publish event with no payload to subscriber', () => { - type PingEvent = { type: 'ping'; payload: [] }; - const controllerMessenger = new ControllerMessenger(); - - const handler = sinon.stub(); - controllerMessenger.subscribe('ping', handler); - controllerMessenger.publish('ping'); - - expect(handler.calledWithExactly()).toBe(true); - expect(handler.callCount).toBe(1); - }); - - it('should publish event with multiple payload parameters to subscriber', () => { - type MessageEvent = { type: 'message'; payload: [string, string] }; - const controllerMessenger = new ControllerMessenger(); - - const handler = sinon.stub(); - controllerMessenger.subscribe('message', handler); - controllerMessenger.publish('message', 'hello', 'there'); - - expect(handler.calledWithExactly('hello', 'there')).toBe(true); - expect(handler.callCount).toBe(1); - }); - - it('should publish event once to subscriber even if subscribed multiple times', () => { - type MessageEvent = { type: 'message'; payload: [string] }; - const controllerMessenger = new ControllerMessenger(); - - const handler = sinon.stub(); - controllerMessenger.subscribe('message', handler); - controllerMessenger.subscribe('message', handler); - controllerMessenger.publish('message', 'hello'); - - expect(handler.calledWithExactly('hello')).toBe(true); - expect(handler.callCount).toBe(1); - }); - - it('should publish event to many subscribers', () => { - type MessageEvent = { type: 'message'; payload: [string] }; - const controllerMessenger = new ControllerMessenger(); - - const handler1 = sinon.stub(); - const handler2 = sinon.stub(); - controllerMessenger.subscribe('message', handler1); - controllerMessenger.subscribe('message', handler2); - controllerMessenger.publish('message', 'hello'); - - expect(handler1.calledWithExactly('hello')).toBe(true); - expect(handler1.callCount).toBe(1); - expect(handler2.calledWithExactly('hello')).toBe(true); - expect(handler2.callCount).toBe(1); - }); - - it('should publish event with selector to subscriber', () => { - type MessageEvent = { - type: 'complexMessage'; - payload: [Record]; - }; - const controllerMessenger = new ControllerMessenger(); - - const handler = sinon.stub(); - const selector = sinon.fake((obj: Record) => obj.prop1); - controllerMessenger.subscribe('complexMessage', handler, selector); - controllerMessenger.publish('complexMessage', { prop1: 'a', prop2: 'b' }); - - expect(handler.calledWithExactly('a', undefined)).toBe(true); - expect(handler.callCount).toBe(1); - expect(selector.calledWithExactly({ prop1: 'a', prop2: 'b' })).toBe(true); - expect(selector.callCount).toBe(1); - }); - - it('should call selector event handler with previous selector return value', () => { - type MessageEvent = { - type: 'complexMessage'; - payload: [Record]; - }; - const controllerMessenger = new ControllerMessenger(); - - const handler = sinon.stub(); - const selector = sinon.fake((obj: Record) => obj.prop1); - controllerMessenger.subscribe('complexMessage', handler, selector); - controllerMessenger.publish('complexMessage', { prop1: 'a', prop2: 'b' }); - controllerMessenger.publish('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( - selector.getCall(0).calledWithExactly({ prop1: 'a', prop2: 'b' }), - ).toBe(true); - - expect( - selector.getCall(1).calledWithExactly({ prop1: 'z', prop2: 'b' }), - ).toBe(true); - expect(selector.callCount).toBe(2); - }); - - it('should not publish event with selector if selector return value is unchanged', () => { - type MessageEvent = { - type: 'complexMessage'; - payload: [Record]; - }; - const controllerMessenger = new ControllerMessenger(); - - const handler = sinon.stub(); - const selector = sinon.fake((obj: Record) => obj.prop1); - controllerMessenger.subscribe('complexMessage', handler, selector); - controllerMessenger.publish('complexMessage', { prop1: 'a', prop2: 'b' }); - controllerMessenger.publish('complexMessage', { prop1: 'a', prop3: 'c' }); - - expect(handler.calledWithExactly('a', undefined)).toBe(true); - expect(handler.callCount).toBe(1); - expect( - selector.getCall(0).calledWithExactly({ prop1: 'a', prop2: 'b' }), - ).toBe(true); - - expect( - selector.getCall(1).calledWithExactly({ prop1: 'a', prop3: 'c' }), - ).toBe(true); - expect(selector.callCount).toBe(2); - }); - - it('should publish event to many subscribers with the same selector', () => { - type MessageEvent = { - type: 'complexMessage'; - payload: [Record]; - }; - const controllerMessenger = new ControllerMessenger(); - - const handler1 = sinon.stub(); - const handler2 = sinon.stub(); - const selector = sinon.fake((obj: Record) => obj.prop1); - controllerMessenger.subscribe('complexMessage', handler1, selector); - controllerMessenger.subscribe('complexMessage', handler2, selector); - controllerMessenger.publish('complexMessage', { prop1: 'a', prop2: 'b' }); - controllerMessenger.publish('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); - }); - - it('should throw subscriber errors in a timeout', () => { - const setTimeoutStub = sinon.stub(globalThis, 'setTimeout'); - type MessageEvent = { type: 'message'; payload: [string] }; - const controllerMessenger = new ControllerMessenger(); - - const handler = sinon.stub().throws(() => new Error('Example error')); - controllerMessenger.subscribe('message', handler); - - expect(() => controllerMessenger.publish('message', 'hello')).not.toThrow(); - expect(setTimeoutStub.callCount).toBe(1); - const onTimeout = setTimeoutStub.firstCall.args[0]; - expect(() => onTimeout()).toThrow('Example error'); - }); - - it('should continue calling subscribers when one throws', () => { - const setTimeoutStub = sinon.stub(globalThis, 'setTimeout'); - type MessageEvent = { type: 'message'; payload: [string] }; - const controllerMessenger = new ControllerMessenger(); - - const handler1 = sinon.stub().throws(() => new Error('Example error')); - const handler2 = sinon.stub(); - controllerMessenger.subscribe('message', handler1); - controllerMessenger.subscribe('message', handler2); - - expect(() => controllerMessenger.publish('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(setTimeoutStub.callCount).toBe(1); - const onTimeout = setTimeoutStub.firstCall.args[0]; - expect(() => onTimeout()).toThrow('Example error'); - }); - - it('should not call subscriber after unsubscribing', () => { - type MessageEvent = { type: 'message'; payload: [string] }; - const controllerMessenger = new ControllerMessenger(); - - const handler = sinon.stub(); - controllerMessenger.subscribe('message', handler); - controllerMessenger.unsubscribe('message', handler); - controllerMessenger.publish('message', 'hello'); - - expect(handler.callCount).toBe(0); - }); - - it('should not call subscriber with selector after unsubscribing', () => { - type MessageEvent = { - type: 'complexMessage'; - payload: [Record]; - }; - const controllerMessenger = new ControllerMessenger(); - - const handler = sinon.stub(); - const selector = sinon.fake((obj: Record) => obj.prop1); - controllerMessenger.subscribe('complexMessage', handler, selector); - controllerMessenger.unsubscribe('complexMessage', handler); - controllerMessenger.publish('complexMessage', { prop1: 'a', prop2: 'b' }); - - expect(handler.callCount).toBe(0); - expect(selector.callCount).toBe(0); - }); - - it('should throw when unsubscribing when there are no subscriptions', () => { - type MessageEvent = { type: 'message'; payload: [string] }; - const controllerMessenger = new ControllerMessenger(); - - const handler = sinon.stub(); - expect(() => controllerMessenger.unsubscribe('message', handler)).toThrow( - 'Subscription not found for event: message', - ); - }); - - it('should throw when unsubscribing a handler that is not subscribed', () => { - type MessageEvent = { type: 'message'; payload: [string] }; - const controllerMessenger = new ControllerMessenger(); - - const handler1 = sinon.stub(); - const handler2 = sinon.stub(); - controllerMessenger.subscribe('message', handler1); - - expect(() => controllerMessenger.unsubscribe('message', handler2)).toThrow( - 'Subscription not found for event: message', - ); - }); - - it('should not call subscriber after clearing event subscriptions', () => { - type MessageEvent = { type: 'message'; payload: [string] }; - const controllerMessenger = new ControllerMessenger(); - - const handler = sinon.stub(); - controllerMessenger.subscribe('message', handler); - controllerMessenger.clearEventSubscriptions('message'); - controllerMessenger.publish('message', 'hello'); - - expect(handler.callCount).toBe(0); - }); - - it('should not throw when clearing event that has no subscriptions', () => { - type MessageEvent = { type: 'message'; payload: [string] }; - const controllerMessenger = new ControllerMessenger(); - - expect(() => - controllerMessenger.clearEventSubscriptions('message'), - ).not.toThrow(); - }); - - it('should not call subscriber after resetting subscriptions', () => { - type MessageEvent = { type: 'message'; payload: [string] }; - const controllerMessenger = new ControllerMessenger(); - - const handler = sinon.stub(); - controllerMessenger.subscribe('message', handler); - controllerMessenger.clearSubscriptions(); - controllerMessenger.publish('message', 'hello'); - - expect(handler.callCount).toBe(0); - }); -}); - -describe('RestrictedControllerMessenger', () => { - it('should allow registering and calling an action handler', () => { - type CountAction = { - type: 'CountController:count'; - handler: (increment: number) => void; - }; - const controllerMessenger = new ControllerMessenger(); - const restrictedControllerMessenger = controllerMessenger.getRestricted({ - name: 'CountController', - allowedActions: ['CountController:count'], - }); - - let count = 0; - restrictedControllerMessenger.registerActionHandler( - 'CountController:count', - (increment: number) => { - count += increment; - }, - ); - restrictedControllerMessenger.call('CountController:count', 1); - - expect(count).toBe(1); - }); - - it('should allow registering and calling multiple different action handlers', () => { - type MessageAction = - | { type: 'MessageController:concat'; handler: (message: string) => void } - | { - type: 'MessageController:reset'; - handler: (initialMessage: string) => void; - }; - const controllerMessenger = new ControllerMessenger(); - const restrictedControllerMessenger = controllerMessenger.getRestricted({ - name: 'MessageController', - allowedActions: ['MessageController:reset', 'MessageController:concat'], - }); - - let message = ''; - restrictedControllerMessenger.registerActionHandler( - 'MessageController:reset', - (initialMessage: string) => { - message = initialMessage; - }, - ); - - restrictedControllerMessenger.registerActionHandler( - 'MessageController:concat', - (s: string) => { - message += s; - }, - ); - - restrictedControllerMessenger.call('MessageController:reset', 'hello'); - restrictedControllerMessenger.call('MessageController:concat', ', world'); - - expect(message).toBe('hello, world'); - }); - - it('should allow registering and calling an action handler with no parameters', () => { - type IncrementAction = { - type: 'CountController:increment'; - handler: () => void; - }; - const controllerMessenger = new ControllerMessenger< - IncrementAction, - never - >(); - const restrictedControllerMessenger = controllerMessenger.getRestricted({ - name: 'CountController', - allowedActions: ['CountController:increment'], - }); - - let count = 0; - restrictedControllerMessenger.registerActionHandler( - 'CountController:increment', - () => { - count += 1; - }, - ); - restrictedControllerMessenger.call('CountController:increment'); - - expect(count).toBe(1); - }); - - it('should allow registering and calling an action handler with multiple parameters', () => { - type MessageAction = { - type: 'MessageController:message'; - handler: (to: string, message: string) => void; - }; - const controllerMessenger = new ControllerMessenger(); - const restrictedControllerMessenger = controllerMessenger.getRestricted({ - name: 'MessageController', - allowedActions: ['MessageController:message'], - }); - - const messages: Record = {}; - restrictedControllerMessenger.registerActionHandler( - 'MessageController:message', - (to, message) => { - messages[to] = message; - }, - ); - - restrictedControllerMessenger.call( - 'MessageController:message', - '0x123', - 'hello', - ); - - expect(messages['0x123']).toBe('hello'); - }); - - it('should allow registering and calling an action handler with a return value', () => { - type AddAction = { - type: 'MathController:add'; - handler: (a: number, b: number) => number; - }; - const controllerMessenger = new ControllerMessenger(); - const restrictedControllerMessenger = controllerMessenger.getRestricted({ - name: 'MathController', - allowedActions: ['MathController:add'], - }); - - restrictedControllerMessenger.registerActionHandler( - 'MathController:add', - (a, b) => { - return a + b; - }, - ); - const result = restrictedControllerMessenger.call( - 'MathController:add', - 5, - 10, - ); - - expect(result).toBe(15); - }); - - it('should not allow registering multiple action handlers under the same name', () => { - type CountAction = { type: 'PingController:ping'; handler: () => void }; - const controllerMessenger = new ControllerMessenger(); - const restrictedControllerMessenger = controllerMessenger.getRestricted({ - name: 'PingController', - allowedActions: ['PingController:ping'], - }); - - restrictedControllerMessenger.registerActionHandler( - 'PingController:ping', - () => undefined, - ); - - expect(() => { - restrictedControllerMessenger.registerActionHandler( - 'PingController:ping', - () => undefined, - ); - }).toThrow('A handler for PingController:ping has already been registered'); - }); - - it('should throw when calling unregistered action', () => { - type CountAction = { type: 'PingController:ping'; handler: () => void }; - const controllerMessenger = new ControllerMessenger(); - const restrictedControllerMessenger = controllerMessenger.getRestricted({ - name: 'PingController', - allowedActions: ['PingController:ping'], - }); - - expect(() => { - restrictedControllerMessenger.call('PingController:ping'); - }).toThrow('A handler for PingController:ping has not been registered'); - }); - - it('should throw when calling an action that has been unregistered', () => { - type PingAction = { type: 'PingController:ping'; handler: () => void }; - const controllerMessenger = new ControllerMessenger(); - const restrictedControllerMessenger = controllerMessenger.getRestricted({ - name: 'PingController', - allowedActions: ['PingController:ping'], - }); - - expect(() => { - restrictedControllerMessenger.call('PingController:ping'); - }).toThrow('A handler for PingController:ping has not been registered'); - - let pingCount = 0; - restrictedControllerMessenger.registerActionHandler( - 'PingController:ping', - () => { - pingCount += 1; - }, - ); - - restrictedControllerMessenger.unregisterActionHandler( - 'PingController:ping', - ); - - expect(() => { - restrictedControllerMessenger.call('PingController:ping'); - }).toThrow('A handler for PingController:ping has not been registered'); - expect(pingCount).toBe(0); - }); - - it('should publish event to subscriber', () => { - type MessageEvent = { - type: 'MessageController:message'; - payload: [string]; - }; - const controllerMessenger = new ControllerMessenger(); - const restrictedControllerMessenger = controllerMessenger.getRestricted({ - name: 'MessageController', - allowedEvents: ['MessageController:message'], - }); - - const handler = sinon.stub(); - restrictedControllerMessenger.subscribe( - 'MessageController:message', - handler, - ); - restrictedControllerMessenger.publish('MessageController:message', 'hello'); - - expect(handler.calledWithExactly('hello')).toBe(true); - expect(handler.callCount).toBe(1); - }); - - it('should publish event with selector to subscriber', () => { - type MessageEvent = { - type: 'MessageController:complexMessage'; - payload: [Record]; - }; - const controllerMessenger = new ControllerMessenger(); - const restrictedControllerMessenger = controllerMessenger.getRestricted({ - name: 'MessageController', - allowedEvents: ['MessageController:complexMessage'], - }); - - const handler = sinon.stub(); - const selector = sinon.fake((obj: Record) => obj.prop1); - restrictedControllerMessenger.subscribe( - 'MessageController:complexMessage', - handler, - selector, - ); - - restrictedControllerMessenger.publish('MessageController:complexMessage', { - prop1: 'a', - prop2: 'b', - }); - - expect(handler.calledWithExactly('a', undefined)).toBe(true); - expect(handler.callCount).toBe(1); - expect(selector.calledWithExactly({ prop1: 'a', prop2: 'b' })).toBe(true); - expect(selector.callCount).toBe(1); - }); - - it('should allow publishing multiple different events to subscriber', () => { - type MessageEvent = - | { type: 'MessageController:message'; payload: [string] } - | { type: 'MessageController:ping'; payload: [] }; - const controllerMessenger = new ControllerMessenger(); - const restrictedControllerMessenger = controllerMessenger.getRestricted({ - name: 'MessageController', - allowedEvents: ['MessageController:message', 'MessageController:ping'], - }); - - const messageHandler = sinon.stub(); - const pingHandler = sinon.stub(); - restrictedControllerMessenger.subscribe( - 'MessageController:message', - messageHandler, - ); - - restrictedControllerMessenger.subscribe( - 'MessageController:ping', - pingHandler, - ); - - restrictedControllerMessenger.publish('MessageController:message', 'hello'); - restrictedControllerMessenger.publish('MessageController:ping'); - - expect(messageHandler.calledWithExactly('hello')).toBe(true); - expect(messageHandler.callCount).toBe(1); - expect(pingHandler.calledWithExactly()).toBe(true); - expect(pingHandler.callCount).toBe(1); - }); - - it('should publish event with no payload to subscriber', () => { - type PingEvent = { type: 'PingController:ping'; payload: [] }; - const controllerMessenger = new ControllerMessenger(); - const restrictedControllerMessenger = controllerMessenger.getRestricted({ - name: 'PingController', - allowedEvents: ['PingController:ping'], - }); - - const handler = sinon.stub(); - restrictedControllerMessenger.subscribe('PingController:ping', handler); - restrictedControllerMessenger.publish('PingController:ping'); - - expect(handler.calledWithExactly()).toBe(true); - expect(handler.callCount).toBe(1); - }); - - it('should publish event with multiple payload parameters to subscriber', () => { - type MessageEvent = { - type: 'MessageController:message'; - payload: [string, string]; - }; - const controllerMessenger = new ControllerMessenger(); - const restrictedControllerMessenger = controllerMessenger.getRestricted({ - name: 'MessageController', - allowedEvents: ['MessageController:message'], - }); - - const handler = sinon.stub(); - restrictedControllerMessenger.subscribe( - 'MessageController:message', - handler, - ); - - restrictedControllerMessenger.publish( - 'MessageController:message', - 'hello', - 'there', - ); - - expect(handler.calledWithExactly('hello', 'there')).toBe(true); - expect(handler.callCount).toBe(1); - }); - - it('should publish event once to subscriber even if subscribed multiple times', () => { - type MessageEvent = { - type: 'MessageController:message'; - payload: [string]; - }; - const controllerMessenger = new ControllerMessenger(); - const restrictedControllerMessenger = controllerMessenger.getRestricted({ - name: 'MessageController', - allowedEvents: ['MessageController:message'], - }); - - const handler = sinon.stub(); - restrictedControllerMessenger.subscribe( - 'MessageController:message', - handler, - ); - - restrictedControllerMessenger.subscribe( - 'MessageController:message', - handler, - ); - restrictedControllerMessenger.publish('MessageController:message', 'hello'); - - expect(handler.calledWithExactly('hello')).toBe(true); - expect(handler.callCount).toBe(1); - }); - - it('should publish event to many subscribers', () => { - type MessageEvent = { - type: 'MessageController:message'; - payload: [string]; - }; - const controllerMessenger = new ControllerMessenger(); - const restrictedControllerMessenger = controllerMessenger.getRestricted({ - name: 'MessageController', - allowedEvents: ['MessageController:message'], - }); - - const handler1 = sinon.stub(); - const handler2 = sinon.stub(); - restrictedControllerMessenger.subscribe( - 'MessageController:message', - handler1, - ); - - restrictedControllerMessenger.subscribe( - 'MessageController:message', - handler2, - ); - restrictedControllerMessenger.publish('MessageController:message', 'hello'); - - expect(handler1.calledWithExactly('hello')).toBe(true); - expect(handler1.callCount).toBe(1); - expect(handler2.calledWithExactly('hello')).toBe(true); - expect(handler2.callCount).toBe(1); - }); - - it('should not call subscriber after unsubscribing', () => { - type MessageEvent = { - type: 'MessageController:message'; - payload: [string]; - }; - const controllerMessenger = new ControllerMessenger(); - const restrictedControllerMessenger = controllerMessenger.getRestricted({ - name: 'MessageController', - allowedEvents: ['MessageController:message'], - }); - - const handler = sinon.stub(); - restrictedControllerMessenger.subscribe( - 'MessageController:message', - handler, - ); - - restrictedControllerMessenger.unsubscribe( - 'MessageController:message', - handler, - ); - restrictedControllerMessenger.publish('MessageController:message', 'hello'); - - expect(handler.callCount).toBe(0); - }); - - it('should throw when unsubscribing when there are no subscriptions', () => { - type MessageEvent = { - type: 'MessageController:message'; - payload: [string]; - }; - const controllerMessenger = new ControllerMessenger(); - const restrictedControllerMessenger = controllerMessenger.getRestricted({ - name: 'MessageController', - allowedEvents: ['MessageController:message'], - }); - - const handler = sinon.stub(); - expect(() => - restrictedControllerMessenger.unsubscribe( - 'MessageController:message', - handler, - ), - ).toThrow(`Subscription not found for event: MessageController:message`); - }); - - it('should throw when unsubscribing a handler that is not subscribed', () => { - type MessageEvent = { - type: 'MessageController:message'; - payload: [string]; - }; - const controllerMessenger = new ControllerMessenger(); - const restrictedControllerMessenger = controllerMessenger.getRestricted({ - name: 'MessageController', - allowedEvents: ['MessageController:message'], - }); - - const handler1 = sinon.stub(); - const handler2 = sinon.stub(); - restrictedControllerMessenger.subscribe( - 'MessageController:message', - handler1, - ); - - expect(() => - restrictedControllerMessenger.unsubscribe( - 'MessageController:message', - handler2, - ), - ).toThrow(`Subscription not found for event: MessageController:message`); - }); - - it('should not call subscriber after clearing event subscriptions', () => { - type MessageEvent = { - type: 'MessageController:message'; - payload: [string]; - }; - const controllerMessenger = new ControllerMessenger(); - const restrictedControllerMessenger = controllerMessenger.getRestricted({ - name: 'MessageController', - allowedEvents: ['MessageController:message'], - }); - - const handler = sinon.stub(); - restrictedControllerMessenger.subscribe( - 'MessageController:message', - handler, - ); - - restrictedControllerMessenger.clearEventSubscriptions( - 'MessageController:message', - ); - restrictedControllerMessenger.publish('MessageController:message', 'hello'); - - expect(handler.callCount).toBe(0); - }); - - it('should not throw when clearing event that has no subscriptions', () => { - type MessageEvent = { - type: 'MessageController:message'; - payload: [string]; - }; - const controllerMessenger = new ControllerMessenger(); - const restrictedControllerMessenger = controllerMessenger.getRestricted({ - name: 'MessageController', - allowedEvents: ['MessageController:message'], - }); - - expect(() => - restrictedControllerMessenger.clearEventSubscriptions( - 'MessageController:message', - ), - ).not.toThrow(); - }); - - it('should allow calling an external action', () => { - type CountAction = { - type: 'CountController:count'; - handler: (increment: number) => void; - }; - const controllerMessenger = new ControllerMessenger(); - const externalRestrictedControllerMessenger = - controllerMessenger.getRestricted({ - name: 'CountController', - }); - const restrictedControllerMessenger = controllerMessenger.getRestricted({ - name: 'OtherController', - allowedActions: ['CountController:count'], - }); - - let count = 0; - externalRestrictedControllerMessenger.registerActionHandler( - 'CountController:count', - (increment: number) => { - count += increment; - }, - ); - restrictedControllerMessenger.call('CountController:count', 1); - - expect(count).toBe(1); - }); - - it('should allow subscribing to an external event', () => { - type MessageEvent = { - type: 'MessageController:message'; - payload: [string]; - }; - const controllerMessenger = new ControllerMessenger(); - const externalRestrictedControllerMessenger = - controllerMessenger.getRestricted({ - name: 'MessageController', - }); - const restrictedControllerMessenger = controllerMessenger.getRestricted({ - name: 'OtherController', - allowedEvents: ['MessageController:message'], - }); - - const handler = sinon.stub(); - restrictedControllerMessenger.subscribe( - 'MessageController:message', - handler, - ); - - externalRestrictedControllerMessenger.publish( - 'MessageController:message', - 'hello', - ); - - expect(handler.calledWithExactly('hello')).toBe(true); - expect(handler.callCount).toBe(1); - }); - - it('should allow interacting with internal and external actions', () => { - type MessageAction = - | { type: 'MessageController:concat'; handler: (message: string) => void } - | { - type: 'MessageController:reset'; - handler: (initialMessage: string) => void; - }; - type CountAction = { - type: 'CountController:count'; - handler: (increment: number) => void; - }; - const controllerMessenger = new ControllerMessenger< - MessageAction | CountAction, - never - >(); - - const messageControllerMessenger = controllerMessenger.getRestricted({ - name: 'MessageController', - allowedActions: ['MessageController:reset', 'CountController:count'], - }); - const countControllerMessenger = controllerMessenger.getRestricted({ - name: 'CountController', - }); - - let count = 0; - countControllerMessenger.registerActionHandler( - 'CountController:count', - (increment: number) => { - count += increment; - }, - ); - - let fullMessage = ''; - messageControllerMessenger.registerActionHandler( - 'MessageController:concat', - (message: string) => { - fullMessage += message; - }, - ); - - messageControllerMessenger.registerActionHandler( - 'MessageController:reset', - (message: string) => { - fullMessage = message; - }, - ); - - messageControllerMessenger.call('MessageController:reset', 'hello'); - messageControllerMessenger.call('CountController:count', 1); - - expect(fullMessage).toBe('hello'); - expect(count).toBe(1); - }); - - it('should allow interacting with internal and external events', () => { - type MessageEvent = - | { type: 'MessageController:message'; payload: [string] } - | { type: 'MessageController:ping'; payload: [] }; - type CountEvent = { type: 'CountController:update'; payload: [number] }; - const controllerMessenger = new ControllerMessenger< - never, - MessageEvent | CountEvent - >(); - - const messageControllerMessenger = controllerMessenger.getRestricted({ - name: 'MessageController', - allowedEvents: ['MessageController:ping', 'CountController:update'], - }); - const countControllerMessenger = controllerMessenger.getRestricted({ - name: 'CountController', - }); - - let pings = 0; - messageControllerMessenger.subscribe('MessageController:ping', () => { - pings += 1; - }); - let currentCount; - messageControllerMessenger.subscribe( - 'CountController:update', - (newCount: number) => { - currentCount = newCount; - }, - ); - messageControllerMessenger.publish('MessageController:ping'); - countControllerMessenger.publish('CountController:update', 10); - - expect(pings).toBe(1); - expect(currentCount).toBe(10); - }); -}); diff --git a/packages/base-controller/src/ControllerMessenger.ts b/packages/base-controller/src/ControllerMessenger.ts deleted file mode 100644 index d3ab0306306..00000000000 --- a/packages/base-controller/src/ControllerMessenger.ts +++ /dev/null @@ -1,644 +0,0 @@ -export type ActionHandler = ( - ...args: ExtractActionParameters -) => ExtractActionResponse; -export type ExtractActionParameters = Action extends { - type: T; - handler: (...args: infer H) => any; -} - ? H - : never; -export type ExtractActionResponse = Action extends { - type: T; - handler: (...args: any) => infer H; -} - ? H - : never; - -export type ExtractEventHandler = Event extends { - type: T; - payload: infer P; -} - ? P extends unknown[] - ? (...payload: P) => void - : never - : never; -export type ExtractEventPayload = Event extends { - type: T; - payload: infer P; -} - ? P - : never; - -export type GenericEventHandler = (...args: unknown[]) => void; - -export type SelectorFunction = ( - ...args: Args -) => ReturnValue; -export type SelectorEventHandler = ( - newValue: SelectorReturnValue, - previousValue: SelectorReturnValue | undefined, -) => void; - -export type ActionConstraint = { - type: string; - handler: (...args: any) => unknown; -}; -export type EventConstraint = { type: string; payload: unknown[] }; - -type EventSubscriptionMap = Map< - GenericEventHandler | SelectorEventHandler, - SelectorFunction | undefined ->; - -/** - * A namespaced string - * - * This type verifies that the string T is prefixed by the string Name followed by a colon. - * - * @template Name - The namespace we're checking for. - * @template T - The full string, including the namespace. - */ -export type Namespaced = T extends `${Name}:${string}` - ? T - : never; - -type NarrowToNamespace = T extends { - type: `${Namespace}:${string}`; -} - ? T - : never; - -type NarrowToAllowed = T extends { - type: Allowed; -} - ? T - : never; - -/** - * A restricted controller messenger. - * - * This acts as a wrapper around the controller messenger instance that restricts access to actions - * and events. - * - * @template N - The namespace for this messenger. Typically this is the name of the controller or - * module that this messenger has been created for. The authority to publish events and register - * actions under this namespace is granted to this restricted messenger instance. - * @template Action - A type union of all Action types. - * @template Event - A type union of all Event types. - * @template AllowedAction - A type union of the 'type' string for any allowed actions. - * @template AllowedEvent - A type union of the 'type' string for any allowed events. - */ -export class RestrictedControllerMessenger< - N extends string, - Action extends ActionConstraint, - Event extends EventConstraint, - AllowedAction extends string, - AllowedEvent extends string, -> { - private readonly controllerMessenger: ControllerMessenger< - ActionConstraint, - EventConstraint - >; - - private readonly controllerName: N; - - private readonly allowedActions: AllowedAction[] | null; - - private readonly allowedEvents: AllowedEvent[] | null; - - /** - * Constructs a restricted controller messenger - * - * The provided allowlists grant the ability to call the listed actions and subscribe to the - * listed events. The "name" provided grants ownership of any actions and events under that - * namespace. Ownership allows registering actions and publishing events, as well as - * unregistering actions and clearing event subscriptions. - * - * @param options - The controller options. - * @param options.controllerMessenger - The controller messenger instance that is being wrapped. - * @param options.name - The name of the thing this messenger will be handed to (e.g. the - * controller name). This grants "ownership" of actions and events under this namespace to the - * restricted controller messenger returned. - * @param options.allowedActions - The list of actions that this restricted controller messenger - * should be alowed to call. - * @param options.allowedEvents - The list of events that this restricted controller messenger - * should be allowed to subscribe to. - */ - constructor({ - controllerMessenger, - name, - allowedActions, - allowedEvents, - }: { - controllerMessenger: ControllerMessenger; - name: N; - allowedActions?: AllowedAction[]; - allowedEvents?: AllowedEvent[]; - }) { - this.controllerMessenger = controllerMessenger; - this.controllerName = name; - this.allowedActions = allowedActions || null; - this.allowedEvents = allowedEvents || null; - } - - /** - * Register an action handler. - * - * This will make the registered function available to call via the `call` method. - * - * The action type this handler is registered under *must* be in the current namespace. - * - * @param action - The action type. This is a unqiue identifier for this action. - * @param handler - The action handler. This function gets called when the `call` method is - * invoked with the given action type. - * @throws Will throw when a handler has been registered for this action type already. - * @template T - A type union of Action type strings that are namespaced by N. - */ - registerActionHandler>( - action: T, - handler: ActionHandler, - ) { - /* istanbul ignore if */ // Branch unreachable with valid types - if (!action.startsWith(`${this.controllerName}:`)) { - throw new Error( - `Only allowed registering action handlers prefixed by '${this.controllerName}:'`, - ); - } - this.controllerMessenger.registerActionHandler(action, handler); - } - - /** - * Unregister an action handler. - * - * This will prevent this action from being called. - * - * The action type being unregistered *must* be in the current namespace. - * - * @param action - The action type. This is a unqiue identifier for this action. - * @template T - A type union of Action type strings that are namespaced by N. - */ - unregisterActionHandler>(action: T) { - /* istanbul ignore if */ // Branch unreachable with valid types - if (!action.startsWith(`${this.controllerName}:`)) { - throw new Error( - `Only allowed unregistering action handlers prefixed by '${this.controllerName}:'`, - ); - } - this.controllerMessenger.unregisterActionHandler(action); - } - - /** - * Call an action. - * - * This function will call the action handler corresponding to the given action type, passing - * along any parameters given. - * - * The action type being called must be on the action allowlist. - * - * @param action - The action type. This is a unqiue identifier for this action. - * @param params - The action parameters. These must match the type of the parameters of the - * registered action handler. - * @throws Will throw when no handler has been registered for the given type. - * @template T - A type union of allowed Action type strings. - * @returns The action return value. - */ - call( - action: T, - ...params: ExtractActionParameters - ): ExtractActionResponse { - /* istanbul ignore next */ // Branches unreachable with valid types - if (this.allowedActions === null) { - throw new Error('No actions allowed'); - } else if (!this.allowedActions.includes(action)) { - throw new Error(`Action missing from allow list: ${action}`); - } - return this.controllerMessenger.call(action, ...params); - } - - /** - * Publish an event. - * - * Publishes the given payload to all subscribers of the given event type. - * - * The event type being published *must* be in the current namespace. - * - * @param event - The event type. This is a unique identifier for this event. - * @param payload - The event payload. The type of the parameters for each event handler must - * match the type of this payload. - * @template E - A type union of Event type strings that are namespaced by N. - */ - publish>( - event: E, - ...payload: ExtractEventPayload - ) { - /* istanbul ignore if */ // Branch unreachable with valid types - if (!event.startsWith(`${this.controllerName}:`)) { - throw new Error( - `Only allowed publishing events prefixed by '${this.controllerName}:'`, - ); - } - this.controllerMessenger.publish(event, ...payload); - } - - /** - * Subscribe to an event. - * - * Registers the given function as an event handler for the given event type. - * - * The event type being subscribed to must be on the event allowlist. - * - * @param eventType - The event type. This is a unique identifier for this event. - * @param handler - The event handler. The type of the parameters for this event handler must - * match the type of the payload for this event type. - * @template E - A type union of Event type strings. - */ - subscribe( - eventType: E, - handler: ExtractEventHandler, - ): void; - - /** - * Subscribe to an event, with a selector. - * - * Registers the given handler function as an event handler for the given - * event type. When an event is published, its payload is first passed to the - * selector. The event handler is only called if the selector's return value - * differs from its last known return value. - * - * The event type being subscribed to must be on the event allowlist. - * - * @param eventType - The event type. This is a unique identifier for this event. - * @param handler - The event handler. The type of the parameters for this event - * handler must match the return type of the selector. - * @param selector - The selector function used to select relevant data from - * the event payload. The type of the parameters for this selector must match - * the type of the payload for this event type. - * @template E - A type union of Event type strings. - * @template V - The selector return value. - */ - subscribe( - eventType: E, - handler: SelectorEventHandler, - selector: SelectorFunction, V>, - ): void; - - subscribe( - event: E, - handler: ExtractEventHandler, - selector?: SelectorFunction, V>, - ) { - /* istanbul ignore next */ // Branches unreachable with valid types - if (this.allowedEvents === null) { - throw new Error('No events allowed'); - } else if (!this.allowedEvents.includes(event)) { - throw new Error(`Event missing from allow list: ${event}`); - } - - if (selector) { - return this.controllerMessenger.subscribe(event, handler, selector); - } - return this.controllerMessenger.subscribe(event, handler); - } - - /** - * Unsubscribe from an event. - * - * Unregisters the given function as an event handler for the given event. - * - * The event type being unsubscribed to must be on the event allowlist. - * - * @param event - The event type. This is a unique identifier for this event. - * @param handler - The event handler to unregister. - * @throws Will throw when the given event handler is not registered for this event. - * @template T - A type union of allowed Event type strings. - */ - unsubscribe( - event: E, - handler: ExtractEventHandler, - ) { - /* istanbul ignore next */ // Branches unreachable with valid types - if (this.allowedEvents === null) { - throw new Error('No events allowed'); - } else if (!this.allowedEvents.includes(event)) { - throw new Error(`Event missing from allow list: ${event}`); - } - this.controllerMessenger.unsubscribe(event, handler); - } - - /** - * Clear subscriptions for a specific event. - * - * This will remove all subscribed handlers for this event. - * - * The event type being cleared *must* be in the current namespace. - * - * @param event - The event type. This is a unique identifier for this event. - * @template E - A type union of Event type strings that are namespaced by N. - */ - clearEventSubscriptions>(event: E) { - /* istanbul ignore if */ // Branch unreachable with valid types - if (!event.startsWith(`${this.controllerName}:`)) { - throw new Error( - `Only allowed clearing events prefixed by '${this.controllerName}:'`, - ); - } - this.controllerMessenger.clearEventSubscriptions(event); - } -} - -/** - * A messaging system for controllers. - * - * The controller messenger allows registering functions as 'actions' that can be called elsewhere, - * and it allows publishing and subscribing to events. Both actions and events are identified by - * unique strings. - * - * @template Action - A type union of all Action types. - * @template Event - A type union of all Event types. - */ -export class ControllerMessenger< - Action extends ActionConstraint, - Event extends EventConstraint, -> { - private readonly actions = new Map(); - - private readonly events = new Map(); - - /** - * A cache of selector return values for their respective handlers. - */ - private readonly eventPayloadCache = new Map< - GenericEventHandler, - unknown | undefined - >(); - - /** - * Register an action handler. - * - * This will make the registered function available to call via the `call` method. - * - * @param actionType - The action type. This is a unqiue identifier for this action. - * @param handler - The action handler. This function gets called when the `call` method is - * invoked with the given action type. - * @throws Will throw when a handler has been registered for this action type already. - * @template T - A type union of Action type strings. - */ - registerActionHandler( - actionType: T, - handler: ActionHandler, - ) { - if (this.actions.has(actionType)) { - throw new Error( - `A handler for ${actionType} has already been registered`, - ); - } - this.actions.set(actionType, handler); - } - - /** - * Unregister an action handler. - * - * This will prevent this action from being called. - * - * @param actionType - The action type. This is a unqiue identifier for this action. - * @template T - A type union of Action type strings. - */ - unregisterActionHandler(actionType: T) { - this.actions.delete(actionType); - } - - /** - * Unregister all action handlers. - * - * This prevents all actions from being called. - */ - clearActions() { - this.actions.clear(); - } - - /** - * Call an action. - * - * This function will call the action handler corresponding to the given action type, passing - * along any parameters given. - * - * @param actionType - The action type. This is a unqiue identifier for this action. - * @param params - The action parameters. These must match the type of the parameters of the - * registered action handler. - * @throws Will throw when no handler has been registered for the given type. - * @template T - A type union of Action type strings. - * @returns The action return value. - */ - call( - actionType: T, - ...params: ExtractActionParameters - ): ExtractActionResponse { - const handler = this.actions.get(actionType) as ActionHandler; - if (!handler) { - throw new Error(`A handler for ${actionType} has not been registered`); - } - return handler(...params); - } - - /** - * Publish an event. - * - * Publishes the given payload to all subscribers of the given event type. - * - * Note that this method should never throw directly. Any errors from - * subscribers are captured and re-thrown in a timeout handler. - * - * @param eventType - The event type. This is a unique identifier for this event. - * @param payload - The event payload. The type of the parameters for each event handler must - * match the type of this payload. - * @template E - A type union of Event type strings. - */ - publish( - eventType: E, - ...payload: ExtractEventPayload - ) { - const subscribers = this.events.get(eventType); - - if (subscribers) { - for (const [handler, selector] of subscribers.entries()) { - try { - if (selector) { - const previousValue = this.eventPayloadCache.get(handler); - const newValue = selector(...payload); - - if (newValue !== previousValue) { - this.eventPayloadCache.set(handler, newValue); - handler(newValue, previousValue); - } - } else { - (handler as GenericEventHandler)(...payload); - } - } catch (error) { - // Throw error after timeout so that it is capured as a console error - // (and by Sentry) without interrupting the event publishing. - setTimeout(() => { - throw error; - }); - } - } - } - } - - /** - * Subscribe to an event. - * - * Registers the given function as an event handler for the given event type. - * - * @param eventType - The event type. This is a unique identifier for this event. - * @param handler - The event handler. The type of the parameters for this event handler must - * match the type of the payload for this event type. - * @template E - A type union of Event type strings. - */ - subscribe( - eventType: E, - handler: ExtractEventHandler, - ): void; - - /** - * Subscribe to an event, with a selector. - * - * Registers the given handler function as an event handler for the given - * event type. When an event is published, its payload is first passed to the - * selector. The event handler is only called if the selector's return value - * differs from its last known return value. - * - * @param eventType - The event type. This is a unique identifier for this event. - * @param handler - The event handler. The type of the parameters for this event - * handler must match the return type of the selector. - * @param selector - The selector function used to select relevant data from - * the event payload. The type of the parameters for this selector must match - * the type of the payload for this event type. - * @template E - A type union of Event type strings. - * @template V - The selector return value. - */ - subscribe( - eventType: E, - handler: SelectorEventHandler, - selector: SelectorFunction, V>, - ): void; - - subscribe( - eventType: E, - handler: ExtractEventHandler, - selector?: SelectorFunction, V>, - ): void { - let subscribers = this.events.get(eventType); - if (!subscribers) { - subscribers = new Map(); - this.events.set(eventType, subscribers); - } - - subscribers.set(handler, selector); - } - - /** - * Unsubscribe from an event. - * - * Unregisters the given function as an event handler for the given event. - * - * @param eventType - The event type. This is a unique identifier for this event. - * @param handler - The event handler to unregister. - * @throws Will throw when the given event handler is not registered for this event. - * @template E - A type union of Event type strings. - */ - unsubscribe( - eventType: E, - handler: ExtractEventHandler, - ) { - const subscribers = this.events.get(eventType); - - if (!subscribers || !subscribers.has(handler)) { - throw new Error(`Subscription not found for event: ${eventType}`); - } - - const selector = subscribers.get(handler); - if (selector) { - this.eventPayloadCache.delete(handler); - } - - subscribers.delete(handler); - } - - /** - * Clear subscriptions for a specific event. - * - * This will remove all subscribed handlers for this event. - * - * @param eventType - The event type. This is a unique identifier for this event. - * @template E - A type union of Event type strings. - */ - clearEventSubscriptions(eventType: E) { - this.events.delete(eventType); - } - - /** - * Clear all subscriptions. - * - * This will remove all subscribed handlers for all events. - */ - clearSubscriptions() { - this.events.clear(); - } - - /** - * Get a restricted controller messenger - * - * Returns a wrapper around the controller messenger instance that restricts access to actions - * and events. The provided allowlists grant the ability to call the listed actions and subscribe - * to the listed events. The "name" provided grants ownership of any actions and events under - * that namespace. Ownership allows registering actions and publishing events, as well as - * unregistering actions and clearing event subscriptions. - * - * @param options - Controller messenger options. - * @param options.name - The name of the thing this messenger will be handed to (e.g. the - * controller name). This grants "ownership" of actions and events under this namespace to the - * restricted controller messenger returned. - * @param options.allowedActions - The list of actions that this restricted controller messenger - * should be alowed to call. - * @param options.allowedEvents - The list of events that this restricted controller messenger - * should be allowed to subscribe to. - * @template N - The namespace for this messenger. Typically this is the name of the controller or - * module that this messenger has been created for. The authority to publish events and register - * actions under this namespace is granted to this restricted messenger instance. - * @template AllowedAction - A type union of the 'type' string for any allowed actions. - * @template AllowedEvent - A type union of the 'type' string for any allowed events. - * @returns The restricted controller messenger. - */ - getRestricted< - N extends string, - AllowedAction extends string, - AllowedEvent extends string, - >({ - name, - allowedActions, - allowedEvents, - }: { - name: N; - allowedActions?: Extract[]; - allowedEvents?: Extract[]; - }): RestrictedControllerMessenger< - N, - NarrowToNamespace | NarrowToAllowed, - NarrowToNamespace | NarrowToAllowed, - AllowedAction, - AllowedEvent - > { - return new RestrictedControllerMessenger< - N, - NarrowToNamespace | NarrowToAllowed, - NarrowToNamespace | NarrowToAllowed, - AllowedAction, - AllowedEvent - >({ - controllerMessenger: this, - name, - allowedActions, - allowedEvents, - }); - } -} diff --git a/packages/base-controller/src/index.ts b/packages/base-controller/src/index.ts index c9e9d25eed3..0a615b60e1e 100644 --- a/packages/base-controller/src/index.ts +++ b/packages/base-controller/src/index.ts @@ -1,14 +1,15 @@ -export type { BaseConfig, BaseState, Listener } from './BaseController'; -export { BaseController } from './BaseController'; export type { - Listener as ListenerV2, + BaseControllerInstance, + StateChangeListener, + StateConstraint, StateDeriver, + StateDeriverConstraint, StateMetadata, + StateMetadataConstraint, StatePropertyMetadata, -} from './BaseControllerV2'; -export { - BaseController as BaseControllerV2, - getAnonymizedState, - getPersistentState, -} from './BaseControllerV2'; -export * from './ControllerMessenger'; + StatePropertyMetadataConstraint, + ControllerGetStateAction, + ControllerStateChangeEvent, + ControllerStateChangedEvent, +} from './BaseController.js'; +export { BaseController, deriveStateFromMetadata } from './BaseController.js'; diff --git a/packages/base-controller/tsconfig.build.json b/packages/base-controller/tsconfig.build.json index 1d66e6732a3..6b68c1d0498 100644 --- a/packages/base-controller/tsconfig.build.json +++ b/packages/base-controller/tsconfig.build.json @@ -7,7 +7,7 @@ }, "references": [ { - "path": "../controller-utils/tsconfig.build.json" + "path": "../messenger/tsconfig.build.json" } ], "include": ["../../types", "./src"] diff --git a/packages/base-controller/tsconfig.json b/packages/base-controller/tsconfig.json index 6738b9571bd..fb6c16010d4 100644 --- a/packages/base-controller/tsconfig.json +++ b/packages/base-controller/tsconfig.json @@ -5,8 +5,8 @@ }, "references": [ { - "path": "../controller-utils" + "path": "../messenger" } ], - "include": ["../../types", "./src"] + "include": ["../../types", "./src", "./tests"] } diff --git a/packages/base-controller/tsconfig.lint.json b/packages/base-controller/tsconfig.lint.json new file mode 100644 index 00000000000..1e03d050875 --- /dev/null +++ b/packages/base-controller/tsconfig.lint.json @@ -0,0 +1,12 @@ +{ + "extends": ["./tsconfig.json", "../../tsconfig.packages.lint.json"], + "compilerOptions": { + "outDir": "./.tsc-lint-cache", + "tsBuildInfoFile": "./.tsc-lint-cache/tsconfig.tsbuildinfo" + }, + "references": [ + { + "path": "../messenger/tsconfig.lint.json" + } + ] +} diff --git a/packages/base-data-service/CHANGELOG.md b/packages/base-data-service/CHANGELOG.md new file mode 100644 index 00000000000..f638110f985 --- /dev/null +++ b/packages/base-data-service/CHANGELOG.md @@ -0,0 +1,86 @@ +# 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 + +- Add support for cache persistence ([#9445](https://github.com/MetaMask/core/pull/9445)) + - Persistence can be configured by passing a `persistenceConfig` option to the constructor. +- Add `createServicePolicy` and related symbols, copied from `@metamask/controller-utils` ([#9418](https://github.com/MetaMask/core/pull/9418)) + - Added functions: + `createServicePolicy` + - Added constants: + - `DEFAULT_CIRCUIT_BREAK_DURATION` + - `DEFAULT_DEGRADED_THRESHOLD` + - `DEFAULT_MAX_CONSECUTIVE_FAILURES` + - `DEFAULT_MAX_RETRIES` + - Added types: + - `CreateServicePolicyOptions` + - `ServicePolicy` + - Added re-exports from `cockatiel`: + - `BrokenCircuitError` + - `CircuitState` + - `CockatielEventEmitter` + - `CockatielEvent` + - `CockatielFailureReason` + - `ConstantBackoff` + - `ExponentialBackoff` + - `handleAll` + - `handleWhen` +- Export types `DataServiceActions` and `DataServiceEvents` ([#9475](https://github.com/MetaMask/core/pull/9475)) +- Add `responseStruct` option to `fetchQuery` and `fetchInfiniteQuery` for validating query responses using Superstruct ([#9540](https://github.com/MetaMask/core/pull/9540)) + - When provided, the struct is used to validate the response and for inferring the return type of the query + +### Changed + +- **BREAKING:** Remove `TPageData` type parameter from `invalidateQueries` method ([#9526](https://github.com/MetaMask/core/pull/9526)) + - This is technically a breaking change, but this was not used in any of our codebases +- **BREAKING:** Bump `@tanstack/query-core` from `^4.43.0` to `^5.62.16` ([#9712](https://github.com/MetaMask/core/pull/9712)) + - The option types accepted by `fetchQuery`, `fetchInfiniteQuery`, and `invalidateQueries` now follow the query-core v5 API. Subclasses need to rename `cacheTime` to `gcTime`. + - `fetchInfiniteQuery` now requires `initialPageParam` and `getNextPageParam`, matching query-core's options for infinite queries. This also lets `TPageParam` be inferred instead of spelled out in the type parameters. +- **BREAKING:** `fetchQuery` and `fetchInfiniteQuery` now take one more type parameter: `TDataStruct` is the third parameter and the others have been shifted up ([#9540](https://github.com/MetaMask/core/pull/9540)) +- **BREAKING:** Improve type safety of constructor by ensuring the messenger has required actions/events ([#9525](https://github.com/MetaMask/core/pull/9525)) + - No change needed, unless the messenger being passed into the constructor was typed incorrectly. +- Bump `@metamask/utils` from `^11.9.0` to `^11.11.0` ([#9074](https://github.com/MetaMask/core/pull/9074)) +- Bump `@metamask/controller-utils` from `^12.1.0` to `^12.3.0` ([#9058](https://github.com/MetaMask/core/pull/9058), [#9083](https://github.com/MetaMask/core/pull/9083), [#9218](https://github.com/MetaMask/core/pull/9218)) +- Bump `@metamask/messenger` from `^1.2.0` to `^2.0.0` ([#9392](https://github.com/MetaMask/core/pull/9392)) +- Add dependency `cockatiel` (`^3.1.2`) ([#9418](https://github.com/MetaMask/core/pull/9418)) + +## [0.1.3] + +### Changed + +- Bump `@metamask/controller-utils` from `^12.0.0` to `^12.1.0` ([#8774](https://github.com/MetaMask/core/pull/8774)) + +## [0.1.2] + +### Changed + +- Bump `@metamask/controller-utils` from `^11.19.0` to `^12.0.0` ([#8344](https://github.com/MetaMask/core/pull/8344), [#8755](https://github.com/MetaMask/core/pull/8755)) +- Bump `@metamask/messenger` from `^1.0.0` to `^1.2.0` ([#8364](https://github.com/MetaMask/core/pull/8364), [#8373](https://github.com/MetaMask/core/pull/8373), [#8632](https://github.com/MetaMask/core/pull/8632)) + +## [0.1.1] + +### Changed + +- Bump `@metamask/messenger` from `^0.3.0` to `^1.0.0` ([#8317](https://github.com/MetaMask/core/pull/8317)) + +## [0.1.0] + +### Added + +- Initial release ([#8039](https://github.com/MetaMask/core/pull/8039), [#8292](https://github.com/MetaMask/core/pull/8292)) + +[Unreleased]: https://github.com/MetaMask/core/compare/@metamask/base-data-service@1.0.0...HEAD +[1.0.0]: https://github.com/MetaMask/core/compare/@metamask/base-data-service@0.1.3...@metamask/base-data-service@1.0.0 +[0.1.3]: https://github.com/MetaMask/core/compare/@metamask/base-data-service@0.1.2...@metamask/base-data-service@0.1.3 +[0.1.2]: https://github.com/MetaMask/core/compare/@metamask/base-data-service@0.1.1...@metamask/base-data-service@0.1.2 +[0.1.1]: https://github.com/MetaMask/core/compare/@metamask/base-data-service@0.1.0...@metamask/base-data-service@0.1.1 +[0.1.0]: https://github.com/MetaMask/core/releases/tag/@metamask/base-data-service@0.1.0 diff --git a/packages/base-data-service/LICENSE b/packages/base-data-service/LICENSE new file mode 100644 index 00000000000..2bf58141733 --- /dev/null +++ b/packages/base-data-service/LICENSE @@ -0,0 +1,6 @@ +This project is licensed under either of + + * MIT license ([LICENSE.MIT](LICENSE.MIT)) + * Apache License, Version 2.0 ([LICENSE.APACHE2](LICENSE.APACHE2)) + +at your option. \ No newline at end of file diff --git a/packages/base-data-service/LICENSE.APACHE2 b/packages/base-data-service/LICENSE.APACHE2 new file mode 100644 index 00000000000..18002eac9ae --- /dev/null +++ b/packages/base-data-service/LICENSE.APACHE2 @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright 2026 MetaMask + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. \ No newline at end of file diff --git a/packages/base-data-service/LICENSE.MIT b/packages/base-data-service/LICENSE.MIT new file mode 100644 index 00000000000..e0278643409 --- /dev/null +++ b/packages/base-data-service/LICENSE.MIT @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 MetaMask + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. \ No newline at end of file diff --git a/packages/base-data-service/README.md b/packages/base-data-service/README.md new file mode 100644 index 00000000000..bc4fd6e042c --- /dev/null +++ b/packages/base-data-service/README.md @@ -0,0 +1,15 @@ +# `@metamask/base-data-service` + +Provides utilities for building data services + +## Installation + +`yarn add @metamask/base-data-service` + +or + +`npm install @metamask/base-data-service` + +## 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/base-data-service/jest.config.js b/packages/base-data-service/jest.config.js new file mode 100644 index 00000000000..e45df2b6e59 --- /dev/null +++ b/packages/base-data-service/jest.config.js @@ -0,0 +1,26 @@ +/* + * For a detailed explanation regarding each configuration property and type check, visit: + * https://jestjs.io/docs/configuration + */ + +const merge = require('deepmerge'); +const path = require('path'); + +const baseConfig = require('../../jest.config.packages'); + +const displayName = path.basename(__dirname); + +module.exports = merge(baseConfig, { + // The display name when running multiple projects + displayName, + + // An object that configures minimum threshold enforcement for coverage results + coverageThreshold: { + global: { + branches: 96.49, + functions: 100, + lines: 100, + statements: 100, + }, + }, +}); diff --git a/packages/base-data-service/package.json b/packages/base-data-service/package.json new file mode 100644 index 00000000000..117f94bcd45 --- /dev/null +++ b/packages/base-data-service/package.json @@ -0,0 +1,85 @@ +{ + "name": "@metamask/base-data-service", + "version": "1.0.0", + "description": "Provides utilities for building data services", + "keywords": [ + "Ethereum", + "MetaMask" + ], + "homepage": "https://github.com/MetaMask/core/tree/main/packages/base-data-service#readme", + "bugs": { + "url": "https://github.com/MetaMask/core/issues" + }, + "license": "(MIT OR Apache-2.0)", + "repository": { + "type": "git", + "url": "https://github.com/MetaMask/core.git" + }, + "files": [ + "dist/" + ], + "sideEffects": false, + "main": "./dist/index.cjs", + "types": "./dist/index.d.cts", + "exports": { + ".": { + "import": { + "types": "./dist/index.d.mts", + "default": "./dist/index.mjs" + }, + "require": { + "types": "./dist/index.d.cts", + "default": "./dist/index.cjs" + } + }, + "./package.json": "./package.json" + }, + "publishConfig": { + "access": "public", + "registry": "https://registry.npmjs.org/" + }, + "scripts": { + "build": "ts-bridge --project tsconfig.build.json --verbose --clean --no-references", + "build:all": "ts-bridge --project tsconfig.build.json --verbose --clean", + "build:docs": "typedoc", + "changelog:update": "../../scripts/update-changelog.sh @metamask/base-data-service", + "changelog:validate": "../../scripts/validate-changelog.sh @metamask/base-data-service", + "lint:tsconfigs": "tsx ../../scripts/lint-tsconfigs/lint-tsconfigs.mts", + "lint:tsconfigs:fix": "tsx ../../scripts/lint-tsconfigs/lint-tsconfigs.mts --fix", + "messenger-action-types:check": "tsx ../../packages/messenger-cli/src/cli.ts --formatter oxfmt --check", + "messenger-action-types:generate": "tsx ../../packages/messenger-cli/src/cli.ts --formatter oxfmt --generate", + "publish:preview": "yarn npm publish --tag preview", + "since-latest-release": "../../scripts/since-latest-release.sh", + "test": "NODE_OPTIONS=--experimental-vm-modules jest --reporters=jest-silent-reporter", + "test:clean": "NODE_OPTIONS=--experimental-vm-modules jest --clearCache", + "test:verbose": "NODE_OPTIONS=--experimental-vm-modules jest --verbose", + "test:watch": "NODE_OPTIONS=--experimental-vm-modules jest --watch" + }, + "dependencies": { + "@metamask/messenger": "^2.0.0", + "@metamask/storage-service": "^1.0.2", + "@metamask/superstruct": "^3.4.1", + "@metamask/utils": "^11.11.0", + "@tanstack/query-core": "^5.62.16", + "cockatiel": "^3.1.2", + "fast-deep-equal": "^3.1.3", + "lodash": "^4.17.21" + }, + "devDependencies": { + "@metamask/auto-changelog": "^6.1.0", + "@ts-bridge/cli": "^0.6.4", + "@types/jest": "^30.0.0", + "@types/lodash": "^4.14.191", + "deepmerge": "^4.2.2", + "jest": "^30.4.2", + "nock": "^13.3.1", + "ts-jest": "^29.4.11", + "tsx": "^4.20.5", + "typedoc": "^0.25.13", + "typedoc-plugin-missing-exports": "^2.0.0", + "typescript": "~5.3.3" + }, + "engines": { + "node": "^18.18 || >=20" + } +} diff --git a/packages/base-data-service/src/BaseDataService.test.ts b/packages/base-data-service/src/BaseDataService.test.ts new file mode 100644 index 00000000000..f4e965de5c3 --- /dev/null +++ b/packages/base-data-service/src/BaseDataService.test.ts @@ -0,0 +1,658 @@ +import { MOCK_ANY_NAMESPACE, Messenger } from '@metamask/messenger'; +import { hashKey } from '@tanstack/query-core'; +import { BrokenCircuitError } from 'cockatiel'; +import { cleanAll } from 'nock'; + +import { + ExampleDataService, + serviceName, +} from '../tests/ExampleDataService.js'; +import { + mockAssets, + mockTransactionsPage1, + mockTransactionsPage2, + mockTransactionsPage3, + TRANSACTIONS_PAGE_2_CURSOR, + TRANSACTIONS_PAGE_3_CURSOR, +} from '../tests/mocks.js'; +import { STORAGE_SERVICE_KEY } from './BaseDataService.js'; + +const TEST_ADDRESS = '0x4bbeEB066eD09B7AEd07bF39EEe0460DFa261520'; + +const MOCK_ASSETS = [ + 'eip155:1/slip44:60', + 'bip122:000000000019d6689c085ae165831e93/slip44:0', + 'eip155:1/erc20:0x6b175474e89094c44da98b954eedeac495271d0f', +]; + +describe('BaseDataService', () => { + beforeAll(() => { + jest.useFakeTimers({ doNotFake: ['nextTick', 'setImmediate'] }); + }); + + afterAll(() => { + jest.useRealTimers(); + }); + + beforeEach(() => { + mockAssets(); + mockTransactionsPage1(); + mockTransactionsPage2(); + mockTransactionsPage3(); + }); + + it('handles basic queries', async () => { + const messenger = new Messenger({ namespace: serviceName }); + const service = new ExampleDataService(messenger); + + expect(await service.getAssets(MOCK_ASSETS)).toStrictEqual([ + { + assetId: 'eip155:1/erc20:0x6b175474e89094c44da98b954eedeac495271d0f', + decimals: 18, + name: 'Dai Stablecoin', + symbol: 'DAI', + }, + { + assetId: 'bip122:000000000019d6689c085ae165831e93/slip44:0', + decimals: 8, + name: 'Bitcoin', + symbol: 'BTC', + }, + { + assetId: 'eip155:1/slip44:60', + decimals: 18, + name: 'Ethereum', + symbol: 'ETH', + }, + ]); + }); + + it('handles paginated queries', async () => { + const messenger = new Messenger({ namespace: serviceName }); + const service = new ExampleDataService(messenger); + + const page1 = await service.getActivity(TEST_ADDRESS); + + expect(page1.data).toHaveLength(3); + + const page2 = await service.getActivity(TEST_ADDRESS, { + after: page1.pageInfo.endCursor, + }); + + expect(page2.data).toHaveLength(3); + + expect(page2.data).not.toStrictEqual(page1.data); + }); + + it('handles paginated queries starting at a specific page', async () => { + const messenger = new Messenger({ namespace: serviceName }); + const service = new ExampleDataService(messenger); + + const page2 = await service.getActivity(TEST_ADDRESS, { + after: TRANSACTIONS_PAGE_2_CURSOR, + }); + + expect(page2.data).toHaveLength(3); + + const page3 = await service.getActivity(TEST_ADDRESS, { + after: page2.pageInfo.endCursor, + }); + + expect(page3.data).toHaveLength(3); + + expect(page3.data).not.toStrictEqual(page2.data); + }); + + it('handles backwards queries starting at a specific page', async () => { + const messenger = new Messenger({ namespace: serviceName }); + const service = new ExampleDataService(messenger); + + const page3 = await service.getActivity(TEST_ADDRESS, { + after: TRANSACTIONS_PAGE_3_CURSOR, + }); + + expect(page3.data).toHaveLength(3); + + const page2 = await service.getActivity(TEST_ADDRESS, { + before: page3.pageInfo.startCursor, + }); + + expect(page2.data).toHaveLength(3); + expect(page2.data).not.toStrictEqual(page3.data); + }); + + it('emits `:cacheUpdated` events when cache is updated', async () => { + const messenger = new Messenger({ namespace: serviceName }); + const service = new ExampleDataService(messenger); + + const publishSpy = jest.spyOn(messenger, 'publish'); + + await service.getAssets(MOCK_ASSETS); + + const queryKey = ['ExampleDataService:getAssets', MOCK_ASSETS]; + + const hash = hashKey(queryKey); + + expect(publishSpy).toHaveBeenNthCalledWith( + 6, + `ExampleDataService:cacheUpdated:${hash}`, + { + type: 'updated', + state: { + mutations: [], + queries: [ + expect.objectContaining({ + state: expect.objectContaining({ + status: 'success', + data: [ + { + assetId: + 'eip155:1/erc20:0x6b175474e89094c44da98b954eedeac495271d0f', + decimals: 18, + name: 'Dai Stablecoin', + symbol: 'DAI', + }, + { + assetId: 'bip122:000000000019d6689c085ae165831e93/slip44:0', + decimals: 8, + name: 'Bitcoin', + symbol: 'BTC', + }, + { + assetId: 'eip155:1/slip44:60', + decimals: 18, + name: 'Ethereum', + symbol: 'ETH', + }, + ], + }), + }), + ], + }, + }, + ); + }); + + it('emits `:cacheUpdated` events when cache entry is removed', async () => { + const messenger = new Messenger({ namespace: serviceName }); + const service = new ExampleDataService(messenger); + + const publishSpy = jest.spyOn(messenger, 'publish'); + + await service.getAssets(MOCK_ASSETS); + + // Wait for GC + jest.runAllTimers(); + + const queryKey = ['ExampleDataService:getAssets', MOCK_ASSETS]; + + const hash = hashKey(queryKey); + + expect(publishSpy).toHaveBeenNthCalledWith( + 8, + `ExampleDataService:cacheUpdated:${hash}`, + { + type: 'removed', + state: null, + }, + ); + }); + + it('does not emit events after being destroyed', async () => { + const messenger = new Messenger({ namespace: serviceName }); + const service = new ExampleDataService(messenger); + const publishSpy = jest.spyOn(messenger, 'publish'); + + service.destroy(); + + await service.getAssets(MOCK_ASSETS); + + expect(publishSpy).toHaveBeenCalledTimes(0); + }); + + it('invalidates queries when requested', async () => { + const messenger = new Messenger({ namespace: serviceName }); + const service = new ExampleDataService(messenger); + const publishSpy = jest.spyOn(messenger, 'publish'); + + await service.getAssets(MOCK_ASSETS); + + expect(publishSpy).toHaveBeenCalledTimes(6); + + const queryKey = ['ExampleDataService:getAssets', MOCK_ASSETS]; + await service.invalidateQueries({ queryKey }); + + expect(publishSpy).toHaveBeenCalledTimes(8); + }); + + describe('validation', () => { + beforeAll(() => { + jest.useRealTimers(); + }); + + afterAll(() => { + jest.useFakeTimers({ doNotFake: ['nextTick', 'setImmediate'] }); + }); + + beforeEach(() => { + cleanAll(); + }); + + it('throws when fetchQuery response fails struct validation', async () => { + const messenger = new Messenger({ namespace: serviceName }); + const service = new ExampleDataService(messenger); + + mockAssets({ status: 200, body: { foo: 'bar' } }); + + await expect(service.getAssets(MOCK_ASSETS)).rejects.toThrow( + 'Query function for "ExampleDataService:getAssets" returned an unexpected response: Expected an array value, but received: [object Object].', + ); + + service.destroy(); + }); + }); + + describe('service policy', () => { + beforeAll(() => { + jest.useRealTimers(); + }); + + afterAll(() => { + jest.useFakeTimers({ doNotFake: ['nextTick', 'setImmediate'] }); + }); + + beforeEach(() => { + cleanAll(); + }); + + it('retries failed queries using the service policy', async () => { + const messenger = new Messenger({ namespace: serviceName }); + const service = new ExampleDataService(messenger); + + mockAssets({ status: 500 }); + mockAssets({ status: 500 }); + mockAssets(); + + const result = await service.getAssets(MOCK_ASSETS); + + expect(result).toStrictEqual([ + { + assetId: 'eip155:1/erc20:0x6b175474e89094c44da98b954eedeac495271d0f', + decimals: 18, + name: 'Dai Stablecoin', + symbol: 'DAI', + }, + { + assetId: 'bip122:000000000019d6689c085ae165831e93/slip44:0', + decimals: 8, + name: 'Bitcoin', + symbol: 'BTC', + }, + { + assetId: 'eip155:1/slip44:60', + decimals: 18, + name: 'Ethereum', + symbol: 'ETH', + }, + ]); + + service.destroy(); + }); + + it('throws after exhausting service policy retries', async () => { + const messenger = new Messenger({ namespace: serviceName }); + const service = new ExampleDataService(messenger); + + mockAssets({ status: 500, body: { error: 'internal server error' } }); + mockAssets({ status: 500, body: { error: 'internal server error' } }); + mockAssets({ status: 500, body: { error: 'internal server error' } }); + + await expect(service.getAssets(MOCK_ASSETS)).rejects.toThrow( + 'Query failed with status code: 500.', + ); + + service.destroy(); + }); + + it('breaks the circuit after consecutive failures', async () => { + const messenger = new Messenger({ namespace: serviceName }); + const service = new ExampleDataService(messenger); + + mockAssets({ status: 500, body: { error: 'internal server error' } }); + mockAssets({ status: 500, body: { error: 'internal server error' } }); + mockAssets({ status: 500, body: { error: 'internal server error' } }); + + await expect(service.getAssets(MOCK_ASSETS)).rejects.toThrow( + 'Query failed with status code: 500.', + ); + + await expect(service.getAssets(MOCK_ASSETS)).rejects.toThrow( + BrokenCircuitError, + ); + + service.destroy(); + }); + }); + + describe('persistence', () => { + it('persists the cache using the StorageService', async () => { + const rootMessenger = new Messenger({ + namespace: MOCK_ANY_NAMESPACE, + captureException: console.error, + }); + + const setItem = jest.fn(); + rootMessenger.registerActionHandler('StorageService:setItem', setItem); + + const messenger = rootMessenger.buildChild({ + namespace: serviceName, + actions: ['StorageService:getItem', 'StorageService:setItem'], + }); + const service = new ExampleDataService(messenger); + + mockAssets(); + + await service.getAssets(MOCK_ASSETS); + + jest.runAllTimers(); + + expect(setItem).toHaveBeenCalledWith(serviceName, STORAGE_SERVICE_KEY, { + state: { + queries: [ + { + dehydratedAt: expect.any(Number), + queryHash: + '["ExampleDataService:getAssets",["eip155:1/slip44:60","bip122:000000000019d6689c085ae165831e93/slip44:0","eip155:1/erc20:0x6b175474e89094c44da98b954eedeac495271d0f"]]', + queryKey: [ + 'ExampleDataService:getAssets', + [ + 'eip155:1/slip44:60', + 'bip122:000000000019d6689c085ae165831e93/slip44:0', + 'eip155:1/erc20:0x6b175474e89094c44da98b954eedeac495271d0f', + ], + ], + state: { + data: [ + { + assetId: + 'eip155:1/erc20:0x6b175474e89094c44da98b954eedeac495271d0f', + decimals: 18, + name: 'Dai Stablecoin', + symbol: 'DAI', + }, + { + assetId: 'bip122:000000000019d6689c085ae165831e93/slip44:0', + decimals: 8, + name: 'Bitcoin', + symbol: 'BTC', + }, + { + assetId: 'eip155:1/slip44:60', + decimals: 18, + name: 'Ethereum', + symbol: 'ETH', + }, + ], + dataUpdateCount: 1, + dataUpdatedAt: expect.any(Number), + error: null, + errorUpdateCount: 0, + errorUpdatedAt: 0, + fetchFailureCount: 0, + fetchFailureReason: null, + fetchMeta: null, + fetchStatus: 'idle', + isInvalidated: false, + status: 'success', + }, + }, + ], + mutations: [], + }, + timestamp: expect.any(Number), + }); + }); + + it('rehydrates the cache using the StorageService', async () => { + const rootMessenger = new Messenger({ + namespace: MOCK_ANY_NAMESPACE, + captureException: console.error, + }); + + rootMessenger.registerActionHandler('StorageService:setItem', jest.fn()); + rootMessenger.registerActionHandler('StorageService:getItem', () => { + return { + result: { + state: { + queries: [ + { + queryHash: + '["ExampleDataService:getAssets",["eip155:1/slip44:60","bip122:000000000019d6689c085ae165831e93/slip44:0","eip155:1/erc20:0x6b175474e89094c44da98b954eedeac495271d0f"]]', + queryKey: [ + 'ExampleDataService:getAssets', + [ + 'eip155:1/slip44:60', + 'bip122:000000000019d6689c085ae165831e93/slip44:0', + 'eip155:1/erc20:0x6b175474e89094c44da98b954eedeac495271d0f', + ], + ], + state: { + data: [ + { + assetId: + 'eip155:1/erc20:0x6b175474e89094c44da98b954eedeac495271d0f', + decimals: 18, + name: 'Dai Stablecoin', + symbol: 'DAI', + }, + { + assetId: + 'bip122:000000000019d6689c085ae165831e93/slip44:0', + decimals: 8, + name: 'Bitcoin', + symbol: 'BTC', + }, + { + assetId: 'eip155:1/slip44:60', + decimals: 18, + name: 'Ethereum', + symbol: 'ETH', + }, + ], + dataUpdateCount: 1, + dataUpdatedAt: Date.now(), + error: null, + errorUpdateCount: 0, + errorUpdatedAt: 0, + fetchFailureCount: 0, + fetchFailureReason: null, + fetchMeta: null, + fetchStatus: 'idle', + isInvalidated: false, + status: 'success', + }, + }, + ], + mutations: [], + }, + timestamp: Date.now(), + }, + }; + }); + + const messenger = rootMessenger.buildChild({ + namespace: serviceName, + actions: ['StorageService:getItem', 'StorageService:setItem'], + }); + const spy = jest.spyOn(messenger, 'call'); + const service = new ExampleDataService(messenger); + service.init(); + + await rootMessenger.waitUntil('ExampleDataService:cacheUpdated'); + + mockAssets({ status: 500 }); + + const result = await service.getAssets(MOCK_ASSETS); + + expect(result).toHaveLength(3); + + expect(spy).toHaveBeenCalledWith( + 'StorageService:getItem', + serviceName, + STORAGE_SERVICE_KEY, + ); + }); + + it('discards the cache if it has expired', async () => { + const rootMessenger = new Messenger({ + namespace: MOCK_ANY_NAMESPACE, + captureException: console.error, + }); + + rootMessenger.registerActionHandler('StorageService:setItem', jest.fn()); + rootMessenger.registerActionHandler('StorageService:getItem', () => { + return { + result: { + state: { + queries: [], + mutations: [], + }, + timestamp: 1783516587702, + }, + }; + }); + + rootMessenger.registerActionHandler( + 'StorageService:removeItem', + jest.fn(), + ); + + const messenger = rootMessenger.buildChild({ + namespace: serviceName, + actions: [ + 'StorageService:getItem', + 'StorageService:setItem', + 'StorageService:removeItem', + ], + }); + + const callSpy = jest.spyOn(messenger, 'call'); + const publishSpy = jest.spyOn(messenger, 'publish'); + + const service = new ExampleDataService(messenger); + service.init(); + + expect(callSpy).toHaveBeenCalledWith( + 'StorageService:getItem', + serviceName, + STORAGE_SERVICE_KEY, + ); + + expect(publishSpy).not.toHaveBeenCalled(); + + await Promise.resolve(); + + expect(callSpy).toHaveBeenCalledWith( + 'StorageService:removeItem', + serviceName, + STORAGE_SERVICE_KEY, + ); + }); + + it('removes the persisted cache from the StorageService if the cache is empty', async () => { + const rootMessenger = new Messenger({ + namespace: MOCK_ANY_NAMESPACE, + captureException: console.error, + }); + + const setItem = jest.fn(); + const removeItem = jest.fn(); + rootMessenger.registerActionHandler('StorageService:setItem', setItem); + rootMessenger.registerActionHandler( + 'StorageService:removeItem', + removeItem, + ); + + const messenger = rootMessenger.buildChild({ + namespace: serviceName, + actions: ['StorageService:setItem', 'StorageService:removeItem'], + }); + const service = new ExampleDataService(messenger); + + mockAssets(); + + await service.getAssets(MOCK_ASSETS); + + // Wait for GC + jest.runAllTimers(); + + expect(removeItem).toHaveBeenCalledWith(serviceName, STORAGE_SERVICE_KEY); + }); + + it('skips persisting cache if persistConfig is not set', async () => { + const messenger = new Messenger({ namespace: serviceName }); + const callSpy = jest.spyOn(messenger, 'call'); + const service = new ExampleDataService(messenger, {}); + + mockAssets(); + + await service.getAssets(MOCK_ASSETS); + + jest.runAllTimers(); + + expect(callSpy).not.toHaveBeenCalledWith( + 'StorageService:setItem', + expect.anything(), + expect.anything(), + expect.anything(), + ); + }); + + it('skips rehydrating cache if persistConfig is not set', async () => { + const messenger = new Messenger({ namespace: serviceName }); + const callSpy = jest.spyOn(messenger, 'call'); + const service = new ExampleDataService(messenger, {}); + + service.init(); + + expect(callSpy).not.toHaveBeenCalledWith( + 'StorageService:getItem', + expect.anything(), + expect.anything(), + ); + }); + + it('ignores rehydration if the StorageService fails', async () => { + const rootMessenger = new Messenger({ + namespace: MOCK_ANY_NAMESPACE, + captureException: console.error, + }); + + rootMessenger.registerActionHandler('StorageService:setItem', jest.fn()); + rootMessenger.registerActionHandler('StorageService:getItem', () => { + return { + error: new Error('Failed to retrieve item.'), + }; + }); + + const messenger = rootMessenger.buildChild({ + namespace: serviceName, + actions: ['StorageService:getItem', 'StorageService:setItem'], + }); + + const callSpy = jest.spyOn(messenger, 'call'); + const publishSpy = jest.spyOn(messenger, 'publish'); + + const service = new ExampleDataService(messenger); + service.init(); + + expect(callSpy).toHaveBeenCalledWith( + 'StorageService:getItem', + serviceName, + STORAGE_SERVICE_KEY, + ); + + expect(publishSpy).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/packages/base-data-service/src/BaseDataService.ts b/packages/base-data-service/src/BaseDataService.ts new file mode 100644 index 00000000000..c694a761a17 --- /dev/null +++ b/packages/base-data-service/src/BaseDataService.ts @@ -0,0 +1,528 @@ +import { + Messenger, + ActionConstraint, + EventConstraint, + MessengerActions, + MessengerEvents, +} from '@metamask/messenger'; +import type { + StorageServiceGetItemAction, + StorageServiceRemoveItemAction, + StorageServiceSetItemAction, +} from '@metamask/storage-service'; +import { Struct } from '@metamask/superstruct'; +import { Duration, inMilliseconds } from '@metamask/utils'; +import type { Json } from '@metamask/utils'; +import { + DefaultError, + DefaultOptions, + DehydratedState, + FetchQueryOptions, + InfiniteData, + InfiniteQueryPageParamsOptions, + InvalidateOptions, + InvalidateQueryFilters, + OmitKeyof, + QueryClient, + QueryClientConfig, + QueryFunction, + WithRequired, + dehydrate, + hydrate, +} from '@tanstack/query-core'; +import deepEqual from 'fast-deep-equal'; +import { debounce, DebouncedFunc } from 'lodash'; + +import { + createServicePolicy, + CreateServicePolicyOptions, + ServicePolicy, +} from './createServicePolicy.js'; +import { processQueryResponse } from './utils.js'; + +// Data service queries use the following format: ['ServiceActionName', ...params] +export type QueryKey = [string, ...Json[]]; + +/** + * The supertype of all messengers, scoped to a namespace. + * + * @template Namespace - The namespace for the messenger's own actions and + * events. + */ +export type BaseMessenger = Messenger< + Namespace, + ActionConstraint, + EventConstraint, + // Use `any` to allow any parent to be set. `any` is harmless in a type constraint anyway, + // it's the one totally safe place to use it. + // eslint-disable-next-line @typescript-eslint/no-explicit-any + any +>; + +export type DataServiceGranularCacheUpdatedPayload = + | { type: 'added' | 'updated'; state: DehydratedState } + | { + type: 'removed'; + state: null; + }; + +export type DataServiceCacheUpdatedPayload = + DataServiceGranularCacheUpdatedPayload & { + hash: string; + }; + +type CacheUpdatedType = DataServiceCacheUpdatedPayload['type']; + +export type DataServiceInvalidateQueriesAction = { + type: `${ServiceName}:invalidateQueries`; + handler: BaseDataService< + ServiceName, + BaseMessenger + >['invalidateQueries']; +}; + +export type DataServiceActions = + DataServiceInvalidateQueriesAction; + +type DataServiceAllowedActions = + | StorageServiceGetItemAction + | StorageServiceSetItemAction + | StorageServiceRemoveItemAction; + +export type DataServiceCacheUpdatedEvent = { + type: `${ServiceName}:cacheUpdated`; + payload: [DataServiceCacheUpdatedPayload]; +}; + +export type DataServiceGranularCacheUpdatedEvent = { + type: `${ServiceName}:cacheUpdated:${string}`; + payload: [DataServiceGranularCacheUpdatedPayload]; +}; + +export type DataServiceEvents = + | DataServiceCacheUpdatedEvent + | DataServiceGranularCacheUpdatedEvent; + +// Defaults to apply to all data service queries if no default option specified +const QUERY_CLIENT_DEFAULTS: DefaultOptions = { + queries: { + retry: false, + staleTime: inMilliseconds(1, Duration.Minute), + }, +}; + +export const STORAGE_SERVICE_KEY = 'cache'; + +/** + * Options for persistence configuration. + */ +export type PersistenceConfiguration = { + /** + * The maximum age before the cache is treated as expired in milliseconds. + * This is relevant for rehydrating the state during initialization, + * if the cached state is too old it will be discarded. + */ + maxAge: number; + /** + * The number of milliseconds to wait before triggering persistence following a cache update. + */ + writeDelay?: number; + /** + * The maximum number of milliseconds to wait between persistence writes. + */ + maxWriteDelay?: number; +}; + +type PersistedCache = { + state: DehydratedState; + timestamp: number; +}; + +export class BaseDataService< + ServiceName extends string, + ServiceMessenger extends BaseMessenger, +> { + public readonly name: ServiceName; + + readonly #messenger: Messenger< + ServiceName, + DataServiceActions, + DataServiceEvents + >; + + readonly #externalMessenger: Messenger< + ServiceName, + DataServiceAllowedActions + >; + + protected messenger: ServiceMessenger; + + readonly #policy: ServicePolicy; + + readonly #queryClient: QueryClient; + + readonly #queryCacheUnsubscribe: () => void; + + readonly #debouncedPersist?: DebouncedFunc<() => void>; + + readonly #persistenceConfig?: PersistenceConfiguration; + + constructor({ + name, + messenger, + queryClientConfig = {}, + policyOptions, + persistenceConfig, + }: { + name: ServiceName; + messenger: DataServiceActions['type'] extends + | MessengerActions['type'] + | DataServiceAllowedActions['type'] + ? DataServiceEvents['type'] extends MessengerEvents['type'] + ? ServiceMessenger + : never + : never; + queryClientConfig?: QueryClientConfig; + policyOptions?: CreateServicePolicyOptions; + persistenceConfig?: PersistenceConfiguration; + }) { + this.name = name; + + // We store two narrowly-typed messengers alongside the generic public one: + // - #messenger handles the service's own action registration and event publishing + // - #externalMessenger handles calls to external actions + // Splitting them avoids TypeScript issues with mixing template-literals with regular strings + this.#messenger = messenger as unknown as Messenger< + ServiceName, + DataServiceActions, + DataServiceEvents + >; + this.#externalMessenger = messenger as unknown as Messenger< + ServiceName, + DataServiceAllowedActions + >; + this.messenger = messenger; + + this.#queryClient = new QueryClient({ + ...queryClientConfig, + defaultOptions: { + queries: { + ...QUERY_CLIENT_DEFAULTS.queries, + ...queryClientConfig.defaultOptions?.queries, + }, + mutations: queryClientConfig.defaultOptions?.mutations, + }, + }); + + this.#persistenceConfig = persistenceConfig; + + this.#policy = createServicePolicy(policyOptions); + + this.#debouncedPersist = + this.#persistenceConfig && + debounce( + () => { + this.#persistCache().catch( + /* istanbul ignore next */ + (error) => this.#messenger.captureException?.(error), + ); + }, + this.#persistenceConfig.writeDelay ?? + inMilliseconds(10, Duration.Second), + { + maxWait: + this.#persistenceConfig.maxWriteDelay ?? + inMilliseconds(1, Duration.Minute), + }, + ); + + this.#queryCacheUnsubscribe = this.#queryClient + .getQueryCache() + .subscribe((event) => { + if (['added', 'updated', 'removed'].includes(event.type)) { + this.#publishCacheUpdate( + event.query.queryHash, + event.type as CacheUpdatedType, + ); + + this.#debouncedPersist?.(); + } + }); + + this.#messenger.registerActionHandler( + `${this.name}:invalidateQueries`, + this.invalidateQueries.bind(this), + ); + } + + /** + * Fetch a query. + * + * @param options - The options defining the query. Keep in mind that `queryKey` and `queryFn` are required when using data services. + * Additionally `retry` and `retryDelay` are not available, retries can be customized using the `servicePolicyOptions`. + * @param options.responseStruct - An optional struct for validating the response of the query function. + * @returns The query results. + */ + protected async fetchQuery< + TQueryFnData extends Json, + TError = DefaultError, + TDataStruct extends Struct | undefined = undefined, + TData = TDataStruct extends Struct + ? StructType + : TQueryFnData, + TQueryKey extends QueryKey = QueryKey, + >({ + responseStruct, + ...options + }: WithRequired< + OmitKeyof< + FetchQueryOptions, + 'retry' | 'retryDelay' | 'queryFn' + >, + 'queryKey' + > & { + queryFn: QueryFunction; + responseStruct?: TDataStruct; + }): Promise { + return this.#queryClient.fetchQuery({ + ...options, + queryFn: async (context) => { + const response = await this.#policy.execute(() => + options.queryFn(context), + ); + return processQueryResponse(options.queryKey, response, responseStruct); + }, + }); + } + + /** + * Fetch a paginated query. + * + * @param options - The options defining the query. Keep in mind that `queryKey` and `queryFn` are required when using data services. + * Additionally `retry` and `retryDelay` are not available, retries can be customized using the `servicePolicyOptions`. + * @param options.responseStruct - An optional struct for validating the response of the query function. + * @param pageParam - An optional page parameter. + * @returns The query result, exclusively the requested page is returned. + */ + protected async fetchInfiniteQuery< + TQueryFnData extends Json, + TError = DefaultError, + TDataStruct extends Struct | undefined = undefined, + TData extends TQueryFnData = TDataStruct extends Struct + ? StructType + : TQueryFnData, + TQueryKey extends QueryKey = QueryKey, + TPageParam extends Json = Json, + >( + { + responseStruct, + ...options + }: WithRequired< + OmitKeyof< + FetchQueryOptions< + TQueryFnData, + TError, + InfiniteData, + TQueryKey, + TPageParam + >, + 'retry' | 'retryDelay' | 'queryFn' | 'initialPageParam' + >, + 'queryKey' + > & + InfiniteQueryPageParamsOptions & { + queryFn: QueryFunction; + responseStruct?: TDataStruct; + }, + pageParam?: TPageParam, + ): Promise { + const cache = this.#queryClient.getQueryCache(); + + const query = cache.find< + TQueryFnData, + TError, + InfiniteData + >({ + queryKey: options.queryKey, + }); + + if (!query?.state.data || !pageParam) { + const result = await this.#queryClient.fetchInfiniteQuery({ + ...options, + initialPageParam: pageParam ?? options.initialPageParam, + queryFn: async (context) => { + const response = await this.#policy.execute(async () => + options.queryFn({ + ...context, + pageParam: context.meta?.pageParam ?? context.pageParam, + }), + ); + return processQueryResponse( + options.queryKey, + response, + responseStruct, + ); + }, + }); + + return result.pages[0]; + } + + const { pages, pageParams } = query.state.data; + const next = options.getNextPageParam( + pages[pages.length - 1], + pages, + pageParams[pageParams.length - 1], + pageParams, + ); + + const direction = deepEqual(pageParam, next) ? 'forward' : 'backward'; + + const result = await query.fetch( + { ...query.options, meta: { pageParam } }, + { meta: { fetchMore: { direction } } }, + ); + + const pageIndex = result.pageParams.findIndex((param) => + deepEqual(param, pageParam), + ); + + return result.pages[pageIndex]; + } + + /** + * Invalidate queries serviced by this data service. + * + * @param filters - Optional filter for selecting specific queries. + * @param options - Additional optional options for query invalidations. + * @returns Nothing. + */ + async invalidateQueries( + filters?: InvalidateQueryFilters, + options?: InvalidateOptions, + ): Promise { + return this.#queryClient.invalidateQueries(filters, options); + } + + /** + * Initialize the service, rehydrating the cache with persisted data if possible. + */ + init(): void { + this.#loadCache().catch( + /* istanbul ignore next */ + (error) => this.#messenger.captureException?.(error), + ); + } + + /** + * Prepares the service for garbage collection. This should be extended + * by any subclasses to clean up any additional connections or events. + */ + destroy(): void { + this.#debouncedPersist?.cancel(); + this.#queryCacheUnsubscribe(); + this.#queryClient.clear(); + this.messenger.clearSubscriptions(); + this.messenger.clearActions(); + } + + /** + * Publish `cacheUpdated` events when a given query changes. + * + * @param hash The hash of the query. + * @param type The type of cache update. + */ + #publishCacheUpdate(hash: string, type: CacheUpdatedType): void { + const state = + type === 'added' || type === 'updated' + ? dehydrate(this.#queryClient, { + shouldDehydrateQuery: (query) => query.queryHash === hash, + }) + : null; + + this.#messenger.publish( + `${this.name}:cacheUpdated` as const, + { + type, + hash, + state, + } as DataServiceCacheUpdatedPayload, + ); + + this.#messenger.publish( + `${this.name}:cacheUpdated:${hash}` as const, + { + type, + state, + } as DataServiceGranularCacheUpdatedPayload, + ); + } + + /** + * Persist the query client cache using the StorageService, if the cache is not empty. + * + * @returns Nothing. + */ + async #persistCache(): Promise { + const state = dehydrate(this.#queryClient, { + // This is the default, but we specify it to be explicit. + shouldDehydrateQuery: (query) => query.state.status === 'success', + }); + + if (state.queries.length === 0 && state.mutations.length === 0) { + await this.#externalMessenger.call( + 'StorageService:removeItem', + this.name, + STORAGE_SERVICE_KEY, + ); + return; + } + + const cache: PersistedCache = { + timestamp: Date.now(), + state, + }; + + await this.#externalMessenger.call( + 'StorageService:setItem', + this.name, + STORAGE_SERVICE_KEY, + cache as unknown as Json, + ); + } + + /** + * Load the query client cache from the StorageService, if persistence is configured + * and the persisted cache is not expired. + * + * @returns Nothing. + */ + async #loadCache(): Promise { + if (!this.#persistenceConfig) { + return; + } + + const { result: untypedCache } = await this.#externalMessenger.call( + 'StorageService:getItem', + this.name, + STORAGE_SERVICE_KEY, + ); + + if (!untypedCache) { + return; + } + + const cache = untypedCache as unknown as PersistedCache; + + if (Date.now() - cache.timestamp >= this.#persistenceConfig.maxAge) { + await this.#externalMessenger.call( + 'StorageService:removeItem', + this.name, + STORAGE_SERVICE_KEY, + ); + return; + } + + hydrate(this.#queryClient, cache.state); + } +} diff --git a/packages/base-data-service/src/createServicePolicy.test.ts b/packages/base-data-service/src/createServicePolicy.test.ts new file mode 100644 index 00000000000..fd96f62d165 --- /dev/null +++ b/packages/base-data-service/src/createServicePolicy.test.ts @@ -0,0 +1,964 @@ +import { CircuitState, ConstantBackoff, handleWhen } from 'cockatiel'; + +import { + createServicePolicy, + DEFAULT_CIRCUIT_BREAK_DURATION, + DEFAULT_DEGRADED_THRESHOLD, + DEFAULT_MAX_CONSECUTIVE_FAILURES, + DEFAULT_MAX_RETRIES, + ServicePolicy, +} from './createServicePolicy.js'; + +describe('createServicePolicy', () => { + beforeEach(() => { + jest.useFakeTimers({ doNotFake: ['nextTick', 'queueMicrotask'] }); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + describe('execute', () => { + describe('when the service succeeds at least on the first attempt', () => { + it('returns what the service returns', async () => { + const policy = createServicePolicy(); + const result = await policy.execute(() => ({ some: 'data' })); + expect(result).toStrictEqual({ some: 'data' }); + }); + + it('fires onAvailable on the first successful execution and not again on subsequent successful executions', async () => { + const mockService = jest.fn(); + const onAvailableListener = jest.fn(); + const policy = createServicePolicy(); + policy.onAvailable(onAvailableListener); + + await policy.execute(mockService); + await policy.execute(mockService); + await policy.execute(mockService); + + expect(onAvailableListener).toHaveBeenCalledTimes(1); + }); + + it('does not fire onDegraded when the service responds within the degraded threshold', async () => { + const onDegradedListener = jest.fn(); + const policy = createServicePolicy(); + policy.onDegraded(onDegradedListener); + + await policy.execute(jest.fn()); + + expect(onDegradedListener).not.toHaveBeenCalled(); + }); + + it('fires onDegraded when the service takes longer than the degraded threshold', async () => { + const degradedThreshold = 2_000; + const delay = degradedThreshold + 1; + const mockService = jest.fn( + () => + new Promise((resolve) => setTimeout(() => resolve(), delay)), + ); + const onDegradedListener = jest.fn(); + const policy = createServicePolicy({ + degradedThreshold, + }); + policy.onDegraded(onDegradedListener); + + const promise = policy.execute(mockService); + jest.advanceTimersByTime(delay); + await promise; + + expect(onDegradedListener).toHaveBeenCalledTimes(1); + }); + + it('does not fire onAvailable when the service takes longer than the degraded threshold', async () => { + const degradedThreshold = 2_000; + const delay = degradedThreshold + 1; + const mockService = jest.fn( + () => + new Promise((resolve) => setTimeout(() => resolve(), delay)), + ); + const onAvailableListener = jest.fn(); + const policy = createServicePolicy({ + degradedThreshold, + }); + policy.onAvailable(onAvailableListener); + + const promise = policy.execute(mockService); + jest.advanceTimersByTime(delay); + await promise; + + expect(onAvailableListener).not.toHaveBeenCalled(); + }); + + it('uses the default degraded threshold when none is provided', async () => { + const delay = DEFAULT_DEGRADED_THRESHOLD + 1; + const mockService = jest.fn( + () => + new Promise((resolve) => setTimeout(() => resolve(), delay)), + ); + const onDegradedListener = jest.fn(); + const policy = createServicePolicy(); + policy.onDegraded(onDegradedListener); + + const promise = policy.execute(mockService); + jest.advanceTimersByTime(delay); + await promise; + + expect(onDegradedListener).toHaveBeenCalledTimes(1); + }); + + it('does not fire onBreak', async () => { + const onBreakListener = jest.fn(); + const policy = createServicePolicy(); + policy.onBreak(onBreakListener); + + await policy.execute(jest.fn()); + + expect(onBreakListener).not.toHaveBeenCalled(); + }); + }); + + describe('when the service throws an error which has an httpStatus property', () => { + it('treats errors with httpStatus >= 500 as service failures, making them circuit-breakable', async () => { + const error = Object.assign(new Error('server error'), { + httpStatus: 500, + }); + const mockService = createErroringService({ error }); + const onBreakListener = jest.fn(); + const policy = createServicePolicyForTestingRetries({ + breakAfterFirstExecution: true, + }); + policy.onBreak(onBreakListener); + + await ignoreRejection(policy.execute(mockService)); + + expect(onBreakListener).toHaveBeenCalledTimes(1); + }); + + it('treats errors with httpStatus < 500 as non-service failures, making them non-circuit-breakable', async () => { + const error = Object.assign(new Error('client error'), { + httpStatus: 404, + }); + const mockService = createErroringService({ error }); + const onBreakListener = jest.fn(); + const policy = createServicePolicyForTestingRetries({ + breakAfterFirstExecution: true, + }); + policy.onBreak(onBreakListener); + + await ignoreRejection(policy.execute(mockService)); + + expect(onBreakListener).not.toHaveBeenCalled(); + }); + }); + + describe('when the service always throws', () => { + describe.each([ + { + desc: `using a default maxRetries (of ${DEFAULT_MAX_RETRIES})`, + maxRetries: DEFAULT_MAX_RETRIES, + options: {}, + }, + { + desc: 'using a custom maxRetries', + maxRetries: 5, + options: { maxRetries: 5 }, + }, + ])('using $desc', ({ maxRetries, options }) => { + it(`calls the service maxRetries + 1 times`, async () => { + const mockService = createErroringService(); + const policy = createServicePolicyForTestingRetries({ options }); + + await ignoreRejection(policy.execute(mockService)); + + expect(mockService).toHaveBeenCalledTimes(maxRetries + 1); + }); + + it('fires onRetry once per retry', async () => { + const mockService = createErroringService(); + const onRetryListener = jest.fn().mockImplementation(() => { + jest.advanceTimersToNextTimer(); + }); + const policy = createServicePolicyForTestingRetries({ + options, + onRetryListener, + }); + + await ignoreRejection(policy.execute(mockService)); + + expect(onRetryListener).toHaveBeenCalledTimes(maxRetries); + }); + }); + + describe('when a single retry round does not break the circuit', () => { + // Setting the number of attempts (maxRetries + 1) less than the + // maximum number of consecutive failures causes the circuit to stay + // closed even after calling `.execute` once + const maxRetries = 2; + const maxConsecutiveFailures = 4; + + it('throws the original error', async () => { + const error = new Error('failure'); + const mockService = createErroringService({ error }); + const policy = createServicePolicyForTestingRetries({ + options: { + maxRetries, + maxConsecutiveFailures, + }, + }); + + await expect(policy.execute(mockService)).rejects.toThrow(error); + }); + + it('does not fire onAvailable', async () => { + const mockService = createErroringService(); + const onAvailableListener = jest.fn(); + const policy = createServicePolicyForTestingRetries({ + options: { + maxRetries, + maxConsecutiveFailures, + }, + }); + policy.onAvailable(onAvailableListener); + + await ignoreRejection(policy.execute(mockService)); + + expect(onAvailableListener).not.toHaveBeenCalled(); + }); + + it('fires onDegraded with the error', async () => { + const error = new Error('failure'); + const mockService = createErroringService({ error }); + const onDegradedListener = jest.fn(); + const policy = createServicePolicyForTestingRetries({ + options: { + maxRetries, + maxConsecutiveFailures, + }, + }); + policy.onDegraded(onDegradedListener); + + await ignoreRejection(policy.execute(mockService)); + + expect(onDegradedListener).toHaveBeenCalledTimes(1); + expect(onDegradedListener).toHaveBeenCalledWith({ error }); + }); + + it('does not fire onBreak', async () => { + const mockService = createErroringService(); + const onBreakListener = jest.fn(); + const policy = createServicePolicyForTestingRetries({ + options: { + maxRetries, + maxConsecutiveFailures, + }, + }); + policy.onBreak(onBreakListener); + + await ignoreRejection(policy.execute(mockService)); + + expect(onBreakListener).not.toHaveBeenCalled(); + }); + }); + + describe('when a single retry round breaks the circuit after the last attempt', () => { + // Setting maxConsecutiveFailures equal to maxRetries + 1 causes the + // circuit to open after calling `.execute` only once + const maxRetries = 2; + const maxConsecutiveFailures = 3; + + it('throws the original error', async () => { + const error = new Error('failure'); + const mockService = createErroringService({ error }); + const policy = createServicePolicyForTestingRetries({ + options: { + maxRetries, + maxConsecutiveFailures, + }, + }); + + await expect(policy.execute(mockService)).rejects.toThrow(error); + }); + + it('does not fire onAvailable', async () => { + const mockService = createErroringService(); + const onAvailableListener = jest.fn(); + const policy = createServicePolicyForTestingRetries({ + options: { + maxRetries, + maxConsecutiveFailures, + }, + }); + policy.onAvailable(onAvailableListener); + + await ignoreRejection(policy.execute(mockService)); + + expect(onAvailableListener).not.toHaveBeenCalled(); + }); + + it('does not fire onDegraded', async () => { + const mockService = createErroringService(); + const onDegradedListener = jest.fn(); + const policy = createServicePolicyForTestingRetries({ + options: { + maxRetries, + maxConsecutiveFailures, + }, + }); + policy.onDegraded(onDegradedListener); + + await ignoreRejection(policy.execute(mockService)); + + expect(onDegradedListener).not.toHaveBeenCalled(); + }); + + it('fires onBreak', async () => { + const mockService = createErroringService(); + const onBreakListener = jest.fn(); + const policy = createServicePolicyForTestingRetries({ + options: { + maxRetries, + maxConsecutiveFailures, + }, + }); + policy.onBreak(onBreakListener); + + await ignoreRejection(policy.execute(mockService)); + + expect(onBreakListener).toHaveBeenCalledTimes(1); + }); + + it('throws BrokenCircuitError on the next service execution', async () => { + const mockService = createErroringService(); + const policy = createServicePolicyForTestingRetries({ + options: { + maxRetries, + maxConsecutiveFailures, + }, + }); + + await ignoreRejection(policy.execute(mockService)); + + await expect(policy.execute(mockService)).rejects.toThrow( + 'Execution prevented because the circuit breaker is open', + ); + }); + }); + + describe('when a single retry round breaks the circuit before reaching the max number of retries', () => { + // Setting the number of attempts (maxRetries + 1) greater than the + // maximum number of consecutive failures causes the circuit to break + // before the last attempt is reached + const maxRetries = 3; + const maxConsecutiveFailures = 3; + + it('throws BrokenCircuitError', async () => { + const mockService = createErroringService(); + const policy = createServicePolicyForTestingRetries({ + options: { + maxRetries, + maxConsecutiveFailures, + }, + }); + + await expect(policy.execute(mockService)).rejects.toThrow( + 'Execution prevented because the circuit breaker is open', + ); + }); + + it('does not fire onAvailable', async () => { + const mockService = createErroringService(); + const onAvailableListener = jest.fn(); + const policy = createServicePolicyForTestingRetries({ + options: { + maxRetries, + maxConsecutiveFailures, + }, + }); + policy.onAvailable(onAvailableListener); + + await ignoreRejection(policy.execute(mockService)); + + expect(onAvailableListener).not.toHaveBeenCalled(); + }); + + it('does not fire onDegraded', async () => { + const mockService = createErroringService(); + const onDegradedListener = jest.fn(); + const policy = createServicePolicyForTestingRetries({ + options: { + maxRetries, + maxConsecutiveFailures, + }, + }); + policy.onDegraded(onDegradedListener); + + await ignoreRejection(policy.execute(mockService)); + + expect(onDegradedListener).not.toHaveBeenCalled(); + }); + + it('fires onBreak', async () => { + const mockService = createErroringService(); + const onBreakListener = jest.fn(); + const policy = createServicePolicyForTestingRetries({ + options: { + maxRetries, + maxConsecutiveFailures, + }, + }); + policy.onBreak(onBreakListener); + + await ignoreRejection(policy.execute(mockService)); + + expect(onBreakListener).toHaveBeenCalledTimes(1); + }); + }); + }); + + describe('when the service throws at first but succeeds on the final attempt', () => { + it('returns the eventual successful result from the service', async () => { + const mockService = createErroringService({ + failUntilNthAttempt: DEFAULT_MAX_RETRIES + 1, + }); + const policy = createServicePolicyForTestingRetries(); + + const result = await policy.execute(mockService); + + expect(result).toStrictEqual({ some: 'data' }); + }); + + it('fires onAvailable on the first successful (fast) execution and not again on subsequent successful (fast) executions', async () => { + const mockService = createErroringService({ + failUntilNthAttempt: DEFAULT_MAX_RETRIES + 1, + }); + const onAvailableListener = jest.fn(); + const policy = createServicePolicyForTestingRetries(); + policy.onAvailable(onAvailableListener); + + await policy.execute(mockService); + await policy.execute(() => { + // dummy function + }); + + expect(onAvailableListener).toHaveBeenCalledTimes(1); + }); + + it('does not fire onDegraded if the final attempt takes less time than the degraded threshold', async () => { + const mockService = createErroringService({ + failUntilNthAttempt: DEFAULT_MAX_RETRIES + 1, + }); + const onDegradedListener = jest.fn(); + const policy = createServicePolicyForTestingRetries(); + policy.onDegraded(onDegradedListener); + + await policy.execute(mockService); + + expect(onDegradedListener).not.toHaveBeenCalled(); + }); + + it('fires onDegraded when the final attempt takes longer than the degraded threshold', async () => { + const degradedThreshold = 2_000; + const delay = degradedThreshold + 1; + let attempts = 0; + const mockService = jest.fn( + () => + new Promise<{ some: string }>((resolve, reject) => { + attempts += 1; + if (attempts === 1 + DEFAULT_MAX_RETRIES) { + setTimeout(() => resolve({ some: 'data' }), delay); + } else { + reject(new Error('failure')); + } + }), + ); + const onDegradedListener = jest.fn(); + const policy = createServicePolicyForTestingRetries({ + options: { + degradedThreshold, + }, + }); + policy.onDegraded(onDegradedListener); + + const promise = policy.execute(mockService); + await jest.runAllTimersAsync(); + await promise; + + expect(onDegradedListener).toHaveBeenCalledTimes(1); + }); + + it('does not fire onAvailable when the final attempt takes longer than the degraded threshold', async () => { + const degradedThreshold = 2_000; + const delay = degradedThreshold + 1; + let attempts = 0; + const mockService = jest.fn( + () => + new Promise<{ some: string }>((resolve, reject) => { + attempts += 1; + if (attempts === 1 + DEFAULT_MAX_RETRIES) { + setTimeout(() => resolve({ some: 'data' }), delay); + } else { + reject(new Error('failure')); + } + }), + ); + const onAvailableListener = jest.fn(); + const policy = createServicePolicyForTestingRetries({ + options: { + degradedThreshold, + }, + }); + policy.onAvailable(onAvailableListener); + + const promise = policy.execute(mockService); + await jest.runAllTimersAsync(); + await promise; + + expect(onAvailableListener).not.toHaveBeenCalled(); + }); + }); + + describe('when the service fails enough times to break the circuit, then the circuit break duration elapses', () => { + it('returns what the service returns if it then succeeds', async () => { + // Setup + const circuitBreakDuration = 5_000; + let attempts = 0; + const mockService = jest.fn(() => { + attempts += 1; + if (attempts > DEFAULT_MAX_CONSECUTIVE_FAILURES) { + return { some: 'data' }; + } + throw new Error('failure'); + }); + const policy = createServicePolicyForTestingRetries({ + options: { + circuitBreakDuration, + }, + }); + + // Drive the circuit open, then advance past the circuit break duration + await ignoreRejection(policy.execute(mockService)); + await ignoreRejection(policy.execute(mockService)); + await ignoreRejection(policy.execute(mockService)); + jest.advanceTimersByTime(circuitBreakDuration); + + const result = await policy.execute(mockService); + expect(result).toStrictEqual({ some: 'data' }); + }); + + it('uses the default circuit break duration when none is provided', async () => { + // Setup + let attempts = 0; + const mockService = jest.fn(() => { + attempts += 1; + if (attempts > DEFAULT_MAX_CONSECUTIVE_FAILURES) { + return { some: 'data' }; + } + throw new Error('failure'); + }); + const policy = createServicePolicyForTestingRetries(); + + // Drive the circuit open, then advance past the circuit break duration + await ignoreRejection(policy.execute(mockService)); + await ignoreRejection(policy.execute(mockService)); + await ignoreRejection(policy.execute(mockService)); + jest.advanceTimersByTime(DEFAULT_CIRCUIT_BREAK_DURATION); + + const result = await policy.execute(mockService); + expect(result).toStrictEqual({ some: 'data' }); + }); + + it('fires onAvailable again after the circuit recovers', async () => { + // Setup + const circuitBreakDuration = 5_000; + let attempts = 0; + const mockService = jest.fn(() => { + attempts += 1; + if ( + attempts === 1 || + attempts > DEFAULT_MAX_CONSECUTIVE_FAILURES + 1 + ) { + return { some: 'data' }; + } + throw new Error('failure'); + }); + const onAvailableListener = jest.fn(); + const policy = createServicePolicyForTestingRetries({ + options: { + circuitBreakDuration, + }, + }); + policy.onAvailable(onAvailableListener); + + // Check that onAvailable fires at first for completeness + await policy.execute(mockService); + expect(onAvailableListener).toHaveBeenCalledTimes(1); + + // Drive the circuit open + await ignoreRejection(policy.execute(mockService)); + await ignoreRejection(policy.execute(mockService)); + await ignoreRejection(policy.execute(mockService)); + + // Recover + jest.advanceTimersByTime(circuitBreakDuration); + await policy.execute(mockService); + expect(onAvailableListener).toHaveBeenCalledTimes(2); + }); + + it('does not fire onAvailable again if the circuit recovers but then the service fails', async () => { + // Setup + const circuitBreakDuration = 5_000; + let attempts = 0; + const mockService = jest.fn(() => { + attempts += 1; + if (attempts === 1) { + return { some: 'data' }; + } + throw new Error('failure'); + }); + const onAvailableListener = jest.fn(); + const policy = createServicePolicyForTestingRetries({ + options: { + circuitBreakDuration, + }, + }); + policy.onAvailable(onAvailableListener); + + // Establish baseline + await policy.execute(mockService); + expect(onAvailableListener).toHaveBeenCalledTimes(1); + + // Drive the circuit open + await ignoreRejection(policy.execute(mockService)); + await ignoreRejection(policy.execute(mockService)); + await ignoreRejection(policy.execute(mockService)); + + // Recover + jest.advanceTimersByTime(circuitBreakDuration); + await ignoreRejection(policy.execute(mockService)); + expect(onAvailableListener).toHaveBeenCalledTimes(1); + }); + }); + + describe('using a custom retryFilterPolicy', () => { + it('throws the error immediately without retrying if retryFilterPolicy filters the error out', async () => { + const error = new Error('failure'); + const mockService = jest.fn(() => { + throw error; + }); + const policy = createServicePolicyForTestingRetries({ + options: { + retryFilterPolicy: handleWhen( + (caughtError) => caughtError.message !== 'failure', + ), + }, + }); + + await expect(policy.execute(mockService)).rejects.toThrow(error); + expect(mockService).toHaveBeenCalledTimes(1); + }); + + it('does not fire onRetry, onBreak, onDegraded, or onAvailable if retryFilterPolicy filters the error out', async () => { + const error = new Error('failure'); + const mockService = jest.fn(() => { + throw error; + }); + const onRetryListener = jest.fn(); + const onBreakListener = jest.fn(); + const onDegradedListener = jest.fn(); + const onAvailableListener = jest.fn(); + const policy = createServicePolicyForTestingRetries({ + options: { + retryFilterPolicy: handleWhen( + (caughtError) => caughtError.message !== 'failure', + ), + }, + }); + policy.onRetry(onRetryListener); + policy.onBreak(onBreakListener); + policy.onDegraded(onDegradedListener); + policy.onAvailable(onAvailableListener); + + await ignoreRejection(policy.execute(mockService)); + + expect(onRetryListener).not.toHaveBeenCalled(); + expect(onBreakListener).not.toHaveBeenCalled(); + expect(onDegradedListener).not.toHaveBeenCalled(); + expect(onAvailableListener).not.toHaveBeenCalled(); + }); + + it('throws the error after retrying if retryFilterPolicy filters the error in', async () => { + const error = new Error('failure'); + const mockService = jest.fn(() => { + throw error; + }); + const policy = createServicePolicyForTestingRetries({ + options: { + retryFilterPolicy: handleWhen( + (caughtError) => caughtError.message === 'failure', + ), + }, + }); + + await expect(policy.execute(mockService)).rejects.toThrow(error); + expect(mockService).toHaveBeenCalledTimes(DEFAULT_MAX_RETRIES + 1); + }); + + it('fires onRetry if retryFilterPolicy filters the error in', async () => { + const error = new Error('failure'); + const mockService = jest.fn(() => { + throw error; + }); + const onRetryListener = jest.fn(); + const policy = createServicePolicyForTestingRetries({ + options: { + retryFilterPolicy: handleWhen( + (caughtError) => caughtError.message === 'failure', + ), + }, + }); + policy.onRetry(onRetryListener); + + await ignoreRejection(policy.execute(mockService)); + expect(onRetryListener).toHaveBeenCalled(); + }); + }); + + describe('using a custom isServiceFailure predicate', () => { + it('opens the circuit when the predicate treats the error as a service failure', async () => { + const error = new Error('failure'); + const mockService = createErroringService({ error }); + const onBreakListener = jest.fn(); + const policy = createServicePolicyForTestingRetries({ + options: { + isServiceFailure: () => true, + }, + breakAfterFirstExecution: true, + }); + policy.onBreak(onBreakListener); + + await ignoreRejection(policy.execute(mockService)); + + expect(onBreakListener).toHaveBeenCalledTimes(1); + expect(onBreakListener).toHaveBeenCalledWith({ error }); + }); + + it('never opens the circuit when the predicate does not treat the error as a service failure', async () => { + const error = new Error('failure'); + const mockService = createErroringService({ error }); + const onBreakListener = jest.fn(); + const policy = createServicePolicyForTestingRetries({ + options: { + isServiceFailure: () => false, + }, + breakAfterFirstExecution: true, + }); + policy.onBreak(onBreakListener); + + await ignoreRejection(policy.execute(mockService)); + + expect(onBreakListener).not.toHaveBeenCalled(); + }); + }); + }); + + describe('getRemainingCircuitOpenDuration', () => { + it('returns null when the circuit is closed', () => { + const policy = createServicePolicyForTestingRetries(); + expect(policy.getRemainingCircuitOpenDuration()).toBeNull(); + }); + + it('returns the milliseconds remaining before the circuit transitions to half-open', async () => { + const policy = createServicePolicyForTestingRetries(); + + // Drive the circuit open + await ignoreRejection(policy.execute(createErroringService())); + await ignoreRejection(policy.execute(createErroringService())); + await ignoreRejection(policy.execute(createErroringService())); + jest.advanceTimersByTime(1_000); + + expect(policy.getRemainingCircuitOpenDuration()).toBe( + DEFAULT_CIRCUIT_BREAK_DURATION - 1_000, + ); + }); + }); + + describe('getCircuitState', () => { + it('tracks circuit state transitions: Closed → Open → HalfOpen → Open', async () => { + // Establish initial state + const policy = createServicePolicyForTestingRetries(); + expect(policy.getCircuitState()).toBe(CircuitState.Closed); + + // Drive the circuit open + await ignoreRejection(policy.execute(createErroringService())); + await ignoreRejection(policy.execute(createErroringService())); + await ignoreRejection(policy.execute(createErroringService())); + expect(policy.getCircuitState()).toBe(CircuitState.Open); + + // Advance to half-open + jest.advanceTimersByTime(DEFAULT_CIRCUIT_BREAK_DURATION); + const promise = ignoreRejection(policy.execute(createErroringService())); + expect(policy.getCircuitState()).toBe(CircuitState.HalfOpen); + await promise; + expect(policy.getCircuitState()).toBe(CircuitState.Open); + }); + }); + + describe('reset', () => { + it('transitions the circuit from Open to Closed', async () => { + const policy = createServicePolicyForTestingRetries(); + // Drive the circuit open + await ignoreRejection(policy.execute(createErroringService())); + await ignoreRejection(policy.execute(createErroringService())); + await ignoreRejection(policy.execute(createErroringService())); + expect(policy.getCircuitState()).toBe(CircuitState.Open); + + policy.reset(); + + expect(policy.getCircuitState()).toBe(CircuitState.Closed); + }); + + it('allows the service to succeed after the circuit was open', async () => { + let attempts = 0; + const mockService = jest.fn(() => { + attempts += 1; + if (attempts > DEFAULT_MAX_CONSECUTIVE_FAILURES) { + return { some: 'data' }; + } + throw new Error('failure'); + }); + const policy = createServicePolicyForTestingRetries(); + // Drive the circuit open + await ignoreRejection(policy.execute(mockService)); + await ignoreRejection(policy.execute(mockService)); + await ignoreRejection(policy.execute(mockService)); + + policy.reset(); + + expect(await policy.execute(mockService)).toStrictEqual({ some: 'data' }); + }); + + it('allows the service to fail again without throwing BrokenCircuitError', async () => { + const service = createErroringService(); + const policy = createServicePolicyForTestingRetries(); + // Drive the circuit open + await ignoreRejection(policy.execute(service)); + await ignoreRejection(policy.execute(service)); + await ignoreRejection(policy.execute(service)); + + policy.reset(); + + await expect(policy.execute(service)).rejects.toThrow('failure'); + }); + + it('fires onAvailable again after reset when the service succeeds', async () => { + // Setup + let attempts = 0; + const mockService = jest.fn(() => { + attempts += 1; + if (attempts === 1 || attempts > DEFAULT_MAX_CONSECUTIVE_FAILURES + 1) { + return { some: 'data' }; + } + throw new Error('failure'); + }); + const onAvailableListener = jest.fn(); + const policy = createServicePolicyForTestingRetries(); + policy.onAvailable(onAvailableListener); + + // Establish baseline + await policy.execute(mockService); + expect(onAvailableListener).toHaveBeenCalledTimes(1); + + // Drive the circuit open + await ignoreRejection(policy.execute(mockService)); + await ignoreRejection(policy.execute(mockService)); + await ignoreRejection(policy.execute(mockService)); + + // Reset + policy.reset(); + await policy.execute(mockService); + expect(onAvailableListener).toHaveBeenCalledTimes(2); + }); + }); +}); + +/** + * Some tests involve a rejected promise that is not necessarily the focus of + * the test. In these cases we don't want to ignore the error in case the + * promise _isn't_ rejected, but we don't want to highlight the assertion, + * either. + * + * @param promise - A promise that rejects. + */ +async function ignoreRejection(promise: Promise): Promise { + await expect(promise).rejects.toThrow(expect.any(Error)); +} + +/** + * Builds a service policy that takes care of some boilerplate when testing + * retries by: + * + * - using a zero-delay constant backoff so tests do not need to account for + * jitter when advancing timers + * - advancing timers automatically whenever retries occur + * - allowing for the service to break on first execution + * + * @param args - The arguments. + * @param args.options - Any additional options to pass to `createServicePolicy`. + * @param args.onRetryListener - The onRetry callback to register. + * @returns The service policy. + * @param args.breakAfterFirstExecution - Assuming that the service always + * error, causes the policy's circuit to break the first time the service is + * executed. + */ +function createServicePolicyForTestingRetries({ + options, + onRetryListener = (): ReturnType[0]> => + jest.advanceTimersToNextTimer(), + breakAfterFirstExecution = false, +}: { + options?: Parameters[0]; + onRetryListener?: Parameters[0]; + breakAfterFirstExecution?: boolean; +} = {}): ReturnType { + const policy = createServicePolicy({ + backoff: new ConstantBackoff(0), + ...(breakAfterFirstExecution + ? { maxRetries: 0, maxConsecutiveFailures: 1 } + : {}), + ...options, + }); + policy.onRetry(onRetryListener); + return policy; +} + +/** + * Builds a mock service that throws `error` on every call before some number of + * attempts, then returns `result`. + * + * @param options - Options. + * @param options.failUntilNthAttempt - The 1-based attempt number at which the + * service should start succeeding (default: Infinity — never succeeds). + * @param options.error - The error to throw on failure (default: `new + * Error('failure')`). + * @param options.result - The value to return on success (default: `{ some: + * 'data' }`). + * @returns A Jest mock function. + */ +function createErroringService({ + failUntilNthAttempt = Infinity, + error = new Error('failure'), + result = { some: 'data' }, +}: { + failUntilNthAttempt?: number; + error?: Error; + result?: unknown; +} = {}): jest.Mock { + let attempts = 0; + return jest.fn(() => { + attempts += 1; + if (attempts >= failUntilNthAttempt) { + return result; + } + throw error; + }); +} diff --git a/packages/base-data-service/src/createServicePolicy.ts b/packages/base-data-service/src/createServicePolicy.ts new file mode 100644 index 00000000000..906f85229aa --- /dev/null +++ b/packages/base-data-service/src/createServicePolicy.ts @@ -0,0 +1,403 @@ +import { + CircuitState, + EventEmitter as CockatielEventEmitter, + ConsecutiveBreaker, + ExponentialBackoff, + circuitBreaker, + handleAll, + handleWhen, + retry, + wrap, +} from 'cockatiel'; +import type { + CircuitBreakerPolicy, + Event as CockatielEvent, + FailureReason, + IBackoffFactory, + IPolicy, + Policy, + RetryPolicy, +} from 'cockatiel'; + +/** + * The options for `createServicePolicy`. + */ +export type CreateServicePolicyOptions = { + /** + * The backoff strategy to use. Mainly useful for testing so that a constant + * backoff can be used when mocking timers. Defaults to an instance of + * ExponentialBackoff. + */ + backoff?: IBackoffFactory; + /** + * The length of time (in milliseconds) to pause retries of the action after + * the number of failures reaches `maxConsecutiveFailures`. + */ + circuitBreakDuration?: number; + /** + * The length of time (in milliseconds) that governs when the service is + * regarded as degraded (affecting when `onDegraded` is called). + */ + degradedThreshold?: number; + /** + * Predicate function for when an error should be considered a service failure. + */ + isServiceFailure?: (error: unknown) => boolean; + /** + * The maximum number of times that the service is allowed to fail before + * pausing further retries. + */ + maxConsecutiveFailures?: number; + /** + * The maximum number of times that a failing service should be re-invoked + * before giving up. + */ + maxRetries?: number; + /** + * The policy used to control when the service should be retried based on + * either the result of the service or an error that it throws. For instance, + * you could use this to retry only certain errors. See `handleWhen` and + * friends from Cockatiel for more. + */ + retryFilterPolicy?: Policy; +}; + +/** + * The service policy object. + */ +export type ServicePolicy = IPolicy & { + /** + * The Cockatiel circuit breaker policy that the service policy uses + * internally. + */ + circuitBreakerPolicy: CircuitBreakerPolicy; + /** + * The amount of time to pause requests to the service if the number of + * maximum consecutive failures is reached. + */ + circuitBreakDuration: number; + /** + * @returns The state of the underlying circuit. + */ + getCircuitState: () => CircuitState; + /** + * If the circuit is open and ongoing requests are paused, returns the number + * of milliseconds before the requests will be attempted again. If the circuit + * is not open, returns null. + */ + getRemainingCircuitOpenDuration: () => number | null; + /** + * Resets the internal circuit breaker policy (if it is open, it will now be + * closed). + */ + reset: () => void; + /** + * The Cockatiel retry policy that the service policy uses internally. + */ + retryPolicy: RetryPolicy; + /** + * A function which is called when the number of times that the service fails + * in a row meets the set maximum number of consecutive failures. + */ + onBreak: CircuitBreakerPolicy['onBreak']; + /** + * A function which is called in two circumstances: 1) when the service + * succeeds before the maximum number of consecutive failures is reached, but + * takes more time than the `degradedThreshold` to run, or 2) if the service + * never succeeds before the retry policy gives up and before the maximum + * number of consecutive failures has been reached. + */ + onDegraded: CockatielEvent | { duration: number }>; + /** + * A function which is called when the service succeeds for the first time, + * or when the service fails enough times to cause the circuit to break and + * then recovers. + */ + onAvailable: CockatielEvent; + /** + * A function which will be called by the retry policy each time the service + * fails and the policy kicks off a timer to re-run the service. This is + * primarily useful in tests where we are mocking timers. + */ + onRetry: RetryPolicy['onRetry']; +}; + +/** + * Parts of the circuit breaker's internal and external state as necessary in + * order to compute the time remaining before the circuit will reopen. + */ +type InternalCircuitState = + | { + state: CircuitState.Open; + openedAt: number; + } + | { state: Exclude }; + +/** + * Availability statuses that the service can be in. + * + * Used to keep track of whether the `onAvailable` event should be fired. + */ +const AVAILABILITY_STATUSES = { + Available: 'available', + Degraded: 'degraded', + Unavailable: 'unavailable', + Unknown: 'unknown', +} as const; + +/** + * Availability statuses that the service can be in. + * + * Used to keep track of whether the `onAvailable` event should be fired. + */ +type AvailabilityStatus = + (typeof AVAILABILITY_STATUSES)[keyof typeof AVAILABILITY_STATUSES]; + +/** + * The maximum number of times that a failing service should be re-run before + * giving up. + */ +export const DEFAULT_MAX_RETRIES = 3; + +/** + * The maximum number of times that the service is allowed to fail before + * pausing further retries. This is set to a value such that if given a + * service that continually fails, the policy needs to be executed 3 times + * before further retries are paused. + */ +export const DEFAULT_MAX_CONSECUTIVE_FAILURES = (1 + DEFAULT_MAX_RETRIES) * 3; + +/** + * The default length of time (in milliseconds) to temporarily pause retries of + * the service after enough consecutive failures. + */ +export const DEFAULT_CIRCUIT_BREAK_DURATION = 30 * 60 * 1000; + +/** + * The default length of time (in milliseconds) that governs when the service is + * regarded as degraded (affecting when `onDegraded` is called). + */ +export const DEFAULT_DEGRADED_THRESHOLD = 5_000; + +const defaultIsServiceFailure = (error: unknown): boolean => { + if ( + typeof error === 'object' && + error !== null && + 'httpStatus' in error && + typeof error.httpStatus === 'number' + ) { + return error.httpStatus >= 500; + } + + // If the error is not an object, or doesn't have a numeric httpStatus + // property, consider it a service failure (e.g., network errors, timeouts, + // etc.) + return true; +}; + +/** + * The circuit breaker policy inside of the Cockatiel library exposes some of + * its state, but not all of it. Notably, the time that the circuit opened is + * not publicly accessible. So we have to record this ourselves. + * + * This function therefore allows us to obtain the circuit breaker state that we + * wish we could access. + * + * @param state - The public state of a circuit breaker policy. + * @returns if the circuit is open, the state of the circuit breaker policy plus + * the time that it opened, otherwise just the circuit state. + */ +function getInternalCircuitState(state: CircuitState): InternalCircuitState { + if (state === CircuitState.Open) { + return { state, openedAt: Date.now() }; + } + return { state }; +} + +/** + * Constructs an object exposing an `execute` method which, given a function — + * hereafter called the "service" — will retry that service with ever increasing + * delays until it succeeds. If the policy detects too many consecutive + * failures, it will block further retries until a designated time period has + * passed; this particular behavior is primarily designed for services that wrap + * API calls so as not to make needless HTTP requests when the API is down and + * to be able to recover when the API comes back up. In addition, hooks allow + * for responding to certain events, one of which can be used to detect when an + * HTTP request is performing slowly. + * + * Internally, this function makes use of the retry and circuit breaker policies + * from the [Cockatiel](https://www.npmjs.com/package/cockatiel) library; see + * there for more. + * + * @param options - The options to this function. See + * {@link CreateServicePolicyOptions}. + * @returns The service policy. + * @example + * This function is designed to be used in the context of a service class like + * this: + * ``` ts + * class Service { + * constructor() { + * this.#policy = createServicePolicy({ + * maxRetries: 3, + * retryFilterPolicy: handleWhen((error) => { + * return error.message.includes('oops'); + * }), + * maxConsecutiveFailures: 3, + * circuitBreakDuration: 5000, + * degradedThreshold: 2000, + * onBreak: () => { + * console.log('Circuit broke'); + * }, + * onDegraded: () => { + * console.log('Service is degraded'); + * }, + * }); + * } + * + * async fetch() { + * return await this.#policy.execute(async () => { + * const response = await fetch('https://some/url'); + * return await response.json(); + * }); + * } + * } + * ``` + */ +export function createServicePolicy( + options: CreateServicePolicyOptions = {}, +): ServicePolicy { + const { + maxRetries = DEFAULT_MAX_RETRIES, + retryFilterPolicy = handleAll, + maxConsecutiveFailures = DEFAULT_MAX_CONSECUTIVE_FAILURES, + circuitBreakDuration = DEFAULT_CIRCUIT_BREAK_DURATION, + degradedThreshold = DEFAULT_DEGRADED_THRESHOLD, + backoff = new ExponentialBackoff(), + isServiceFailure = defaultIsServiceFailure, + } = options; + + let availabilityStatus: AvailabilityStatus = AVAILABILITY_STATUSES.Unknown; + + const retryPolicy = retry(retryFilterPolicy, { + // Note that although the option here is called "max attempts", it's really + // maximum number of *retries* (attempts past the initial attempt). + maxAttempts: maxRetries, + // Retries of the service will be executed following ever increasing delays, + // determined by a backoff formula. + backoff, + }); + const onRetry = retryPolicy.onRetry.bind(retryPolicy); + + const consecutiveBreaker = new ConsecutiveBreaker(maxConsecutiveFailures); + const circuitBreakerPolicy = circuitBreaker(handleWhen(isServiceFailure), { + // While the circuit is open, any additional invocations of the service + // passed to the policy (either via automatic retries or by manually + // executing the policy again) will result in a BrokenCircuitError. This + // will remain the case until `circuitBreakDuration` passes, after which the + // service will be allowed to run again. If the service succeeds, the + // circuit will close, otherwise it will remain open. + halfOpenAfter: circuitBreakDuration, + breaker: consecutiveBreaker, + }); + + let internalCircuitState: InternalCircuitState = getInternalCircuitState( + circuitBreakerPolicy.state, + ); + circuitBreakerPolicy.onStateChange((state) => { + internalCircuitState = getInternalCircuitState(state); + }); + + circuitBreakerPolicy.onBreak(() => { + availabilityStatus = AVAILABILITY_STATUSES.Unavailable; + }); + const onBreak = circuitBreakerPolicy.onBreak.bind(circuitBreakerPolicy); + + const onDegradedEventEmitter = new CockatielEventEmitter< + FailureReason | { duration: number } + >(); + const onDegraded = onDegradedEventEmitter.addListener; + + const onAvailableEventEmitter = new CockatielEventEmitter(); + const onAvailable = onAvailableEventEmitter.addListener; + + retryPolicy.onGiveUp((data) => { + if (circuitBreakerPolicy.state === CircuitState.Closed) { + availabilityStatus = AVAILABILITY_STATUSES.Degraded; + onDegradedEventEmitter.emit(data); + } + }); + retryPolicy.onSuccess(({ duration }) => { + if (circuitBreakerPolicy.state === CircuitState.Closed) { + if (duration > degradedThreshold) { + availabilityStatus = AVAILABILITY_STATUSES.Degraded; + onDegradedEventEmitter.emit({ duration }); + } else if (availabilityStatus !== AVAILABILITY_STATUSES.Available) { + availabilityStatus = AVAILABILITY_STATUSES.Available; + onAvailableEventEmitter.emit(); + } + } + }); + + // Every time the retry policy makes an attempt, it executes the circuit + // breaker policy, which executes the service. + // + // Calling: + // + // policy.execute(() => { + // // do what the service does + // }) + // + // is equivalent to: + // + // retryPolicy.execute(() => { + // circuitBreakerPolicy.execute(() => { + // // do what the service does + // }); + // }); + // + // So if the retry policy succeeds or fails, it is because the circuit breaker + // policy succeeded or failed. And if there are any event listeners registered + // on the retry policy, by the time they are called, the state of the circuit + // breaker will have already changed. + const policy = wrap(retryPolicy, circuitBreakerPolicy); + + const getRemainingCircuitOpenDuration = (): number | null => { + if (internalCircuitState.state === CircuitState.Open) { + return internalCircuitState.openedAt + circuitBreakDuration - Date.now(); + } + return null; + }; + + const getCircuitState = (): CircuitState => { + return circuitBreakerPolicy.state; + }; + + const reset = (): void => { + // Set the state of the policy to "isolated" regardless of its current state + const { dispose } = circuitBreakerPolicy.isolate(); + // Reset the state to "closed" + dispose(); + + // Reset the counter on the breaker as well + consecutiveBreaker.success(); + + // Re-initialize the availability status so that if the service is executed + // successfully, onAvailable listeners will be called again + availabilityStatus = AVAILABILITY_STATUSES.Unknown; + }; + + return { + ...policy, + circuitBreakerPolicy, + circuitBreakDuration, + getCircuitState, + getRemainingCircuitOpenDuration, + reset, + retryPolicy, + onBreak, + onDegraded, + onAvailable, + onRetry, + }; +} diff --git a/packages/base-data-service/src/index.ts b/packages/base-data-service/src/index.ts new file mode 100644 index 00000000000..58090738556 --- /dev/null +++ b/packages/base-data-service/src/index.ts @@ -0,0 +1,39 @@ +export type { + Event as CockatielEvent, + FailureReason as CockatielFailureReason, +} from 'cockatiel'; + +export { + BrokenCircuitError, + EventEmitter as CockatielEventEmitter, + CircuitState, + ConstantBackoff, + ExponentialBackoff, + handleAll, + handleWhen, +} from 'cockatiel'; + +export type { + DataServiceActions, + DataServiceEvents, + DataServiceCacheUpdatedPayload, + DataServiceGranularCacheUpdatedPayload, + DataServiceInvalidateQueriesAction, + DataServiceCacheUpdatedEvent, + DataServiceGranularCacheUpdatedEvent, + QueryKey, + PersistenceConfiguration, +} from './BaseDataService.js'; +export { BaseDataService } from './BaseDataService.js'; + +export { + DEFAULT_CIRCUIT_BREAK_DURATION, + DEFAULT_DEGRADED_THRESHOLD, + DEFAULT_MAX_CONSECUTIVE_FAILURES, + DEFAULT_MAX_RETRIES, + createServicePolicy, +} from './createServicePolicy.js'; +export type { + CreateServicePolicyOptions, + ServicePolicy, +} from './createServicePolicy.js'; diff --git a/packages/base-data-service/src/utils.ts b/packages/base-data-service/src/utils.ts new file mode 100644 index 00000000000..5a9329f5a10 --- /dev/null +++ b/packages/base-data-service/src/utils.ts @@ -0,0 +1,32 @@ +import { Struct, validate } from '@metamask/superstruct'; + +import type { QueryKey } from './BaseDataService.js'; + +/** + * Process query responses, validating them using Superstruct if a struct is defined. + * + * @param queryKey - The query key. + * @param response - The query response + * @param struct - The struct defining the schema for the query response. + * @returns The query response, coerced by Superstruct if needed. + * @throws If the query response does not match the struct. + */ +export function processQueryResponse( + queryKey: QueryKey, + response: Response, + struct?: Struct, +): Response { + if (!struct) { + return response; + } + + const [error, result] = validate(response, struct); + + if (error) { + throw new Error( + `Query function for "${queryKey[0]}" returned an unexpected response: ${error.message}.`, + ); + } + + return result; +} diff --git a/packages/base-data-service/tests/ExampleDataService-method-action-types.ts b/packages/base-data-service/tests/ExampleDataService-method-action-types.ts new file mode 100644 index 00000000000..b15943c584d --- /dev/null +++ b/packages/base-data-service/tests/ExampleDataService-method-action-types.ts @@ -0,0 +1,23 @@ +/** + * This file is auto generated. + * Do not edit manually. + */ + +import type { ExampleDataService } from './ExampleDataService.js'; + +export type ExampleDataServiceGetAssetsAction = { + type: `ExampleDataService:getAssets`; + handler: ExampleDataService['getAssets']; +}; + +export type ExampleDataServiceGetActivityAction = { + type: `ExampleDataService:getActivity`; + handler: ExampleDataService['getActivity']; +}; + +/** + * Union of all ExampleDataService action types. + */ +export type ExampleDataServiceMethodActions = + | ExampleDataServiceGetAssetsAction + | ExampleDataServiceGetActivityAction; diff --git a/packages/base-data-service/tests/ExampleDataService.ts b/packages/base-data-service/tests/ExampleDataService.ts new file mode 100644 index 00000000000..477f24a3c49 --- /dev/null +++ b/packages/base-data-service/tests/ExampleDataService.ts @@ -0,0 +1,171 @@ +import { Messenger } from '@metamask/messenger'; +import { object, number, string, array } from '@metamask/superstruct'; +import { + CaipAssetType, + CaipAssetTypeStruct, + Duration, + inMilliseconds, + Json, +} from '@metamask/utils'; +import { ConstantBackoff } from 'cockatiel'; + +import { + BaseDataService, + DataServiceInvalidateQueriesAction, + DataServiceCacheUpdatedEvent, + DataServiceGranularCacheUpdatedEvent, + PersistenceConfiguration, +} from '../src/BaseDataService.js'; +import { ExampleDataServiceMethodActions } from './ExampleDataService-method-action-types.js'; + +export const serviceName = 'ExampleDataService'; + +export type ExampleDataServiceActions = + | ExampleDataServiceMethodActions + | DataServiceInvalidateQueriesAction; + +export type ExampleDataServiceEvents = + | DataServiceCacheUpdatedEvent + | DataServiceGranularCacheUpdatedEvent; + +export type ExampleMessenger = Messenger< + typeof serviceName, + ExampleDataServiceActions, + ExampleDataServiceEvents +>; + +export type GetAssetsResponse = { + assetId: CaipAssetType; + decimals: number; + name: string; + symbol: string; +}[]; + +const GetAssetsResponseStruct = array( + object({ + assetId: CaipAssetTypeStruct, + decimals: number(), + name: string(), + symbol: string(), + }), +); + +export type GetActivityResponse = { + data: Json[]; + pageInfo: { + count: number; + hasNextPage: boolean; + hasPreviousPage: boolean; + startCursor: string; + endCursor: string; + }; +}; + +export type PageParam = + | { + before: string; + } + | { after: string } + | null; + +const MESSENGER_EXPOSED_METHODS = ['getAssets', 'getActivity'] as const; + +export class ExampleDataService extends BaseDataService< + typeof serviceName, + ExampleMessenger +> { + readonly #accountsBaseUrl = 'https://accounts.api.cx.metamask.io'; + + readonly #tokensBaseUrl = 'https://tokens.api.cx.metamask.io'; + + constructor( + messenger: ExampleMessenger, + { persistenceConfig }: { persistenceConfig?: PersistenceConfiguration } = { + persistenceConfig: { maxAge: inMilliseconds(1, Duration.Day) }, + }, + ) { + super({ + name: serviceName, + messenger, + policyOptions: { + maxRetries: 2, + maxConsecutiveFailures: 3, + backoff: new ConstantBackoff(0), + }, + persistenceConfig, + }); + + this.messenger.registerMethodActionHandlers( + this, + MESSENGER_EXPOSED_METHODS, + ); + } + + async getAssets(assets: string[]): Promise { + return this.fetchQuery({ + queryKey: [`${this.name}:getAssets`, assets], + queryFn: async () => { + const url = new URL( + `${this.#tokensBaseUrl}/v3/assets?assetIds=${assets.join(',')}`, + ); + + const response = await fetch(url); + + if (!response.ok) { + throw new Error(`Query failed with status code: ${response.status}.`); + } + + return response.json(); + }, + staleTime: inMilliseconds(1, Duration.Day), + gcTime: inMilliseconds(1, Duration.Day), + responseStruct: GetAssetsResponseStruct, + }); + } + + async getActivity( + address: string, + page?: PageParam, + ): Promise { + return this.fetchInfiniteQuery( + { + queryKey: [`${this.name}:getActivity`, address], + initialPageParam: null as PageParam, + queryFn: async ({ pageParam }) => { + const caipAddress = `eip155:0:${address.toLowerCase()}`; + const url = new URL( + `${this.#accountsBaseUrl}/v4/multiaccount/transactions?limit=3&accountAddresses=${caipAddress}`, + ); + + // eslint-disable-next-line no-restricted-syntax + if (pageParam && 'after' in pageParam) { + url.searchParams.set('after', pageParam.after); + // eslint-disable-next-line no-restricted-syntax + } else if (pageParam && 'before' in pageParam) { + url.searchParams.set('before', pageParam.before); + } + + const response = await fetch(url); + + if (!response.ok) { + throw new Error( + `Query failed with status code: ${response.status}.`, + ); + } + + return response.json(); + }, + getPreviousPageParam: ({ pageInfo }) => + pageInfo.hasPreviousPage ? { before: pageInfo.startCursor } : null, + getNextPageParam: ({ pageInfo }) => + pageInfo.hasNextPage ? { after: pageInfo.endCursor } : null, + staleTime: inMilliseconds(5, Duration.Minute), + }, + page, + ); + } + + destroy(): void { + super.destroy(); + } +} diff --git a/packages/base-data-service/tests/mocks.ts b/packages/base-data-service/tests/mocks.ts new file mode 100644 index 00000000000..82341e06721 --- /dev/null +++ b/packages/base-data-service/tests/mocks.ts @@ -0,0 +1,418 @@ +import nock from 'nock'; + +type MockReply = { + status: nock.StatusCode; + body?: nock.Body; +}; + +export function mockAssets(mockReply?: MockReply): nock.Scope { + const reply = mockReply ?? { + status: 200, + body: [ + { + assetId: 'eip155:1/erc20:0x6b175474e89094c44da98b954eedeac495271d0f', + decimals: 18, + name: 'Dai Stablecoin', + symbol: 'DAI', + }, + { + assetId: 'bip122:000000000019d6689c085ae165831e93/slip44:0', + decimals: 8, + name: 'Bitcoin', + symbol: 'BTC', + }, + { + assetId: 'eip155:1/slip44:60', + decimals: 18, + name: 'Ethereum', + symbol: 'ETH', + }, + ], + }; + + return nock('https://tokens.api.cx.metamask.io:443', { + encodedQueryParams: true, + }) + .get('/v3/assets') + .query({ + assetIds: + 'eip155%3A1%2Fslip44%3A60%2Cbip122%3A000000000019d6689c085ae165831e93%2Fslip44%3A0%2Ceip155%3A1%2Ferc20%3A0x6b175474e89094c44da98b954eedeac495271d0f', + }) + .reply(reply.status, reply.body); +} + +export const TRANSACTIONS_PAGE_2_CURSOR = + 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJlaXAxNTU6MToweDRiYmVlYjA2NmVkMDliN2FlZDA3YmYzOWVlZTA0NjBkZmEyNjE1MjAiOnsibGFzdFRpbWVzdGFtcCI6IjIwMjYtMDEtMTZUMjA6MTY6MTYuMDAwWiIsImhhc05leHRQYWdlIjp0cnVlfSwiZWlwMTU1OjEwOjB4NGJiZWViMDY2ZWQwOWI3YWVkMDdiZjM5ZWVlMDQ2MGRmYTI2MTUyMCI6eyJsYXN0VGltZXN0YW1wIjoiMjAyNi0wMS0xNlQyMDoxNjoxNi4wMDBaIiwiaGFzTmV4dFBhZ2UiOnRydWV9LCJlaXAxNTU6MTM3OjB4NGJiZWViMDY2ZWQwOWI3YWVkMDdiZjM5ZWVlMDQ2MGRmYTI2MTUyMCI6eyJsYXN0VGltZXN0YW1wIjoiMjAyNi0wMS0xNlQyMDoxNjoxNi4wMDBaIiwiaGFzTmV4dFBhZ2UiOnRydWV9LCJlaXAxNTU6NDIxNjE6MHg0YmJlZWIwNjZlZDA5YjdhZWQwN2JmMzllZWUwNDYwZGZhMjYxNTIwIjp7Imxhc3RUaW1lc3RhbXAiOiIyMDI2LTAxLTE2VDIwOjE2OjE2LjAwMFoiLCJoYXNOZXh0UGFnZSI6dHJ1ZX0sImVpcDE1NTo1MzQzNTI6MHg0YmJlZWIwNjZlZDA5YjdhZWQwN2JmMzllZWUwNDYwZGZhMjYxNTIwIjp7Imxhc3RUaW1lc3RhbXAiOiIyMDI2LTAxLTE2VDIwOjE2OjE2LjAwMFoiLCJoYXNOZXh0UGFnZSI6dHJ1ZX0sImVpcDE1NTo1NjoweDRiYmVlYjA2NmVkMDliN2FlZDA3YmYzOWVlZTA0NjBkZmEyNjE1MjAiOnsibGFzdFRpbWVzdGFtcCI6IjIwMjYtMDEtMTZUMjA6MTY6MTYuMDAwWiIsImhhc05leHRQYWdlIjp0cnVlfSwiZWlwMTU1OjU5MTQ0OjB4NGJiZWViMDY2ZWQwOWI3YWVkMDdiZjM5ZWVlMDQ2MGRmYTI2MTUyMCI6eyJsYXN0VGltZXN0YW1wIjoiMjAyNi0wMS0xNlQyMDoxNjoxNi4wMDBaIiwiaGFzTmV4dFBhZ2UiOnRydWV9LCJlaXAxNTU6ODQ1MzoweDRiYmVlYjA2NmVkMDliN2FlZDA3YmYzOWVlZTA0NjBkZmEyNjE1MjAiOnsibGFzdFRpbWVzdGFtcCI6IjIwMjYtMDEtMTZUMjA6MTY6MTYuMDAwWiIsImhhc05leHRQYWdlIjp0cnVlfSwiaWF0IjoxNzcyMTg0NjQ5fQ.btHnBzYlpbZtAA0kgdyZ5rZ-BC91PZyZQPUuXj1jj6M'; + +export const TRANSACTIONS_PAGE_3_START_CURSOR = + 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJlaXAxNTU6MToweDRiYmVlYjA2NmVkMDliN2FlZDA3YmYzOWVlZTA0NjBkZmEyNjE1MjAiOnsibGFzdFRpbWVzdGFtcCI6IjIwMjUtMTItMTRUMTI6MDY6MDIuMDAwWiIsImhhc1ByZXZpb3VzUGFnZSI6dHJ1ZX0sImVpcDE1NToxMDoweDRiYmVlYjA2NmVkMDliN2FlZDA3YmYzOWVlZTA0NjBkZmEyNjE1MjAiOnsibGFzdFRpbWVzdGFtcCI6IjIwMjUtMTItMTRUMTI6MDY6MDIuMDAwWiIsImhhc1ByZXZpb3VzUGFnZSI6dHJ1ZX0sImVpcDE1NToxMzc6MHg0YmJlZWIwNjZlZDA5YjdhZWQwN2JmMzllZWUwNDYwZGZhMjYxNTIwIjp7Imxhc3RUaW1lc3RhbXAiOiIyMDI1LTEyLTE0VDEyOjA2OjAyLjAwMFoiLCJoYXNQcmV2aW91c1BhZ2UiOnRydWV9LCJlaXAxNTU6NDIxNjE6MHg0YmJlZWIwNjZlZDA5YjdhZWQwN2JmMzllZWUwNDYwZGZhMjYxNTIwIjp7Imxhc3RUaW1lc3RhbXAiOiIyMDI1LTEyLTE0VDEyOjA2OjAyLjAwMFoiLCJoYXNQcmV2aW91c1BhZ2UiOnRydWV9LCJlaXAxNTU6NTM0MzUyOjB4NGJiZWViMDY2ZWQwOWI3YWVkMDdiZjM5ZWVlMDQ2MGRmYTI2MTUyMCI6eyJsYXN0VGltZXN0YW1wIjoiMjAyNS0xMi0xNFQxMjowNjowMi4wMDBaIiwiaGFzUHJldmlvdXNQYWdlIjp0cnVlfSwiZWlwMTU1OjU2OjB4NGJiZWViMDY2ZWQwOWI3YWVkMDdiZjM5ZWVlMDQ2MGRmYTI2MTUyMCI6eyJsYXN0VGltZXN0YW1wIjoiMjAyNS0xMi0xNFQxMjowNjowMi4wMDBaIiwiaGFzUHJldmlvdXNQYWdlIjp0cnVlfSwiZWlwMTU1OjU5MTQ0OjB4NGJiZWViMDY2ZWQwOWI3YWVkMDdiZjM5ZWVlMDQ2MGRmYTI2MTUyMCI6eyJsYXN0VGltZXN0YW1wIjoiMjAyNS0xMi0xNFQxMjowNjowMi4wMDBaIiwiaGFzUHJldmlvdXNQYWdlIjp0cnVlfSwiZWlwMTU1Ojg0NTM6MHg0YmJlZWIwNjZlZDA5YjdhZWQwN2JmMzllZWUwNDYwZGZhMjYxNTIwIjp7Imxhc3RUaW1lc3RhbXAiOiIyMDI1LTEyLTE0VDEyOjA2OjAyLjAwMFoiLCJoYXNQcmV2aW91c1BhZ2UiOnRydWV9LCJpYXQiOjE3NzIxODQ4MjJ9.mQOxvn8fFy8yLtntxJspuvL0i4A7QoyjGoJOn-XcnJI'; + +export const TRANSACTIONS_PAGE_3_CURSOR = + 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJlaXAxNTU6MToweDRiYmVlYjA2NmVkMDliN2FlZDA3YmYzOWVlZTA0NjBkZmEyNjE1MjAiOnsibGFzdFRpbWVzdGFtcCI6IjIwMjUtMTItMTRUMTI6NTU6MTYuMDAwWiIsImhhc05leHRQYWdlIjp0cnVlfSwiZWlwMTU1OjEwOjB4NGJiZWViMDY2ZWQwOWI3YWVkMDdiZjM5ZWVlMDQ2MGRmYTI2MTUyMCI6eyJsYXN0VGltZXN0YW1wIjoiMjAyNS0xMi0xNFQxMjo1NToxNi4wMDBaIiwiaGFzTmV4dFBhZ2UiOnRydWV9LCJlaXAxNTU6MTM3OjB4NGJiZWViMDY2ZWQwOWI3YWVkMDdiZjM5ZWVlMDQ2MGRmYTI2MTUyMCI6eyJsYXN0VGltZXN0YW1wIjoiMjAyNS0xMi0xNFQxMjo1NToxNi4wMDBaIiwiaGFzTmV4dFBhZ2UiOnRydWV9LCJlaXAxNTU6NDIxNjE6MHg0YmJlZWIwNjZlZDA5YjdhZWQwN2JmMzllZWUwNDYwZGZhMjYxNTIwIjp7Imxhc3RUaW1lc3RhbXAiOiIyMDI1LTEyLTE0VDEyOjU1OjE2LjAwMFoiLCJoYXNOZXh0UGFnZSI6dHJ1ZX0sImVpcDE1NTo1MzQzNTI6MHg0YmJlZWIwNjZlZDA5YjdhZWQwN2JmMzllZWUwNDYwZGZhMjYxNTIwIjp7Imxhc3RUaW1lc3RhbXAiOiIyMDI1LTEyLTE0VDEyOjU1OjE2LjAwMFoiLCJoYXNOZXh0UGFnZSI6dHJ1ZX0sImVpcDE1NTo1NjoweDRiYmVlYjA2NmVkMDliN2FlZDA3YmYzOWVlZTA0NjBkZmEyNjE1MjAiOnsibGFzdFRpbWVzdGFtcCI6IjIwMjUtMTItMTRUMTI6NTU6MTYuMDAwWiIsImhhc05leHRQYWdlIjp0cnVlfSwiZWlwMTU1OjU5MTQ0OjB4NGJiZWViMDY2ZWQwOWI3YWVkMDdiZjM5ZWVlMDQ2MGRmYTI2MTUyMCI6eyJsYXN0VGltZXN0YW1wIjoiMjAyNS0xMi0xNFQxMjo1NToxNi4wMDBaIiwiaGFzTmV4dFBhZ2UiOnRydWV9LCJlaXAxNTU6ODQ1MzoweDRiYmVlYjA2NmVkMDliN2FlZDA3YmYzOWVlZTA0NjBkZmEyNjE1MjAiOnsibGFzdFRpbWVzdGFtcCI6IjIwMjUtMTItMTRUMTI6NTU6MTYuMDAwWiIsImhhc05leHRQYWdlIjp0cnVlfSwiaWF0IjoxNzcyMTg0NzE4fQ.3bzO_0SLGmIbhN8HoN_JTqaiOOcVqF25U8ftRuth2ow'; + +export function mockTransactionsPage1(mockReply?: MockReply): nock.Scope { + const reply = mockReply ?? { + status: 200, + body: { + data: [ + { + hash: '0xb398bcc8a9287ca18b5a7c4d6f52eaf4ae599d5ac85b860143f5293ed57724fb', + timestamp: '2026-02-07T22:44:17.000Z', + chainId: 8453, + accountId: 'eip155:8453:0x4bbeeb066ed09b7aed07bf39eee0460dfa261520', + blockNumber: 41857455, + blockHash: + '0x6700e8704b880e83081f3dadcf745eb5bb95ffd1c6557ecdd5dc78d0eb310e52', + gas: 20037644, + gasUsed: 19878709, + gasPrice: '3289893', + effectiveGasPrice: '3289893', + nonce: 800, + cumulativeGasUsed: 55796136, + methodId: '0x9ec68f0f', + value: '0', + to: '0x671fdde61d38f00dffb4f8ce8701d0aabb4b405d', + from: '0x6d052d8e0c666ed8011b966d94f240713cf08ea1', + isError: false, + valueTransfers: [ + { + from: '0x671fdde61d38f00dffb4f8ce8701d0aabb4b405d', + to: '0x4bbeeb066ed09b7aed07bf39eee0460dfa261520', + amount: '100000000000000000000', + decimal: 18, + contractAddress: '0x491b67a94ec0a59b81b784f4719d0387c4510c36', + symbol: 'PF', + name: 'Purple Frog', + transferType: 'erc20', + }, + ], + }, + { + hash: '0x8e773bc374095ef6410b40b3c95e898077a30c70a9b74297738c60deb888dc34', + timestamp: '2026-02-02T02:25:59.000Z', + chainId: 1, + accountId: 'eip155:1:0x4bbeeb066ed09b7aed07bf39eee0460dfa261520', + blockNumber: 24366180, + blockHash: + '0x3e057041ce87230e33a95d9dc7b9018bd86d2982c00a9a4d43d2f8ae6e9c5bac', + gas: 16000000, + gasUsed: 13402794, + gasPrice: '93000000', + effectiveGasPrice: '93000000', + nonce: 94, + cumulativeGasUsed: 42756417, + methodId: '0x60806040', + value: '0', + to: '0x0000000000000000000000000000000000000000', + from: '0x07838cbd1a74c6ad20cab35cb464bb36c1c761e3', + isError: false, + valueTransfers: [ + { + from: '0x340eb3a94d7e6802742d0a82c1afe852629f7b08', + to: '0x4bbeeb066ed09b7aed07bf39eee0460dfa261520', + amount: '10000000000000000', + decimal: 18, + contractAddress: '0x94f31ac896c9823d81cf9c2c93feceed4923218f', + symbol: 'YFTE', + name: 'YfTether.io', + transferType: 'erc20', + }, + ], + }, + { + hash: '0x3147f8bf154e854b27b24caf51ecb8e87ba625bb9c6b0bab60ac8f44057defc4', + timestamp: '2026-01-16T20:16:16.000Z', + chainId: 137, + accountId: 'eip155:137:0x4bbeeb066ed09b7aed07bf39eee0460dfa261520', + blockNumber: 81737302, + blockHash: + '0x397ad0a9bde0c50ade4ed009178a6d658abd7ee3fa32e34410e40be970ba0f13', + gas: 119472, + gasUsed: 98586, + gasPrice: '295049518159', + effectiveGasPrice: '295049518159', + nonce: 999, + cumulativeGasUsed: 874735, + methodId: '0xd47e107e', + value: '0', + to: '0xe581b0a826de8c199be934604c1962ee306ba292', + from: '0xca6e515cc0f52a255cb430c3c2e291e0b7c4476a', + isError: false, + valueTransfers: [ + { + from: '0x0000000000000000000000000000000000000000', + to: '0x4bbeeb066ed09b7aed07bf39eee0460dfa261520', + tokenId: '1106', + contractAddress: '0xe581b0a826de8c199be934604c1962ee306ba292', + transferType: 'erc721', + }, + ], + }, + ], + unprocessedNetworks: [], + pageInfo: { + count: 3, + hasNextPage: true, + hasPreviousPage: false, + startCursor: null, + endCursor: TRANSACTIONS_PAGE_2_CURSOR, + }, + }, + }; + return nock('https://accounts.api.cx.metamask.io:443', { + encodedQueryParams: true, + }) + .get('/v4/multiaccount/transactions') + .query({ + limit: '3', + accountAddresses: + 'eip155%3A0%3A0x4bbeeb066ed09b7aed07bf39eee0460dfa261520', + }) + .reply(reply.status, reply.body); +} + +export function mockTransactionsPage2(mockReply?: MockReply): nock.Scope { + const reply = mockReply ?? { + status: 200, + body: { + data: [ + { + hash: '0xcecd28aa5bd781ffd2a6d960578ffc6c89ac390e8d02baebc977a827956394e9', + timestamp: '2025-12-29T11:51:08.000Z', + chainId: 56, + accountId: 'eip155:56:0x4bbeeb066ed09b7aed07bf39eee0460dfa261520', + blockNumber: 73342543, + blockHash: + '0xf229f9ef08e817dbcbb53595cb1e3a502107314b0b8b73a5f055770b457cd3f3', + gas: 5825657, + gasUsed: 5778628, + gasPrice: '78650000', + effectiveGasPrice: '78650000', + nonce: 1746, + cumulativeGasUsed: 8070157, + methodId: '0x1239ec8c', + value: '0', + to: '0x72fe31aae72fea4e1f9048a8a3ca580eeba3cd58', + from: '0x053577f23edd3d6bf15fc53db9ca8042d4796fa7', + isError: false, + valueTransfers: [ + { + from: '0x053577f23edd3d6bf15fc53db9ca8042d4796fa7', + to: '0x4bbeeb066ed09b7aed07bf39eee0460dfa261520', + amount: '29006498000000000', + decimal: 18, + contractAddress: '0x18d0e455b3491e09210292d3953157a4bf104444', + symbol: '比特币', + name: '比特币', + transferType: 'erc20', + }, + ], + }, + { + hash: '0xdb40973b60f774a14616e6e2be7af6e426b559d29e25e9b2938b3a733f361b78', + timestamp: '2025-12-22T09:18:48.000Z', + chainId: 56, + accountId: 'eip155:56:0x4bbeeb066ed09b7aed07bf39eee0460dfa261520', + blockNumber: 72524170, + blockHash: + '0xd43d7bb4c06ccfc0ecd172ed08fccacb774ed29e1c58b727687c5b075bc3343d', + gas: 85408, + gasUsed: 56133, + gasPrice: '52330000', + effectiveGasPrice: '52330000', + nonce: 104, + cumulativeGasUsed: 24011496, + methodId: '0xa9059cbb', + value: '0', + to: '0xcba411922349ecd7eec13aac1825b1ddca223fc8', + from: '0x0325f3aa3ef51e24b3f31a0c390e0bc984b5490f', + isError: false, + valueTransfers: [ + { + from: '0x0325f3aa3ef51e24b3f31a0c390e0bc984b5490f', + to: '0x4bbeeb066ed09b7aed07bf39eee0460dfa261520', + amount: '100000000000000000000', + decimal: 18, + contractAddress: '0xcba411922349ecd7eec13aac1825b1ddca223fc8', + symbol: 'MOB', + name: 'MOB', + transferType: 'erc20', + }, + ], + }, + { + hash: '0x07bb21d1937b66aab9dfe1632e4eee9b96e82f54f41f17b3cc4378ec0188af61', + timestamp: '2025-12-14T12:55:16.000Z', + chainId: 56, + accountId: 'eip155:56:0x4bbeeb066ed09b7aed07bf39eee0460dfa261520', + blockNumber: 71620155, + blockHash: + '0xe0e71f46bba84eb4060565b376bc3ede99a45e84fad2e6588bbd003e5e623313', + gas: 30424536, + gasUsed: 3138845, + gasPrice: '50500000', + effectiveGasPrice: '50500000', + nonce: 968, + cumulativeGasUsed: 18618033, + methodId: '0x729ad39e', + value: '0', + to: '0xdd7eb7809d283ae3ffa880183f20e7016ebe8374', + from: '0x6c604c63fb280ca69559f42f6c5a4a4bfcf661d5', + isError: false, + valueTransfers: [ + { + from: '0x6c604c63fb280ca69559f42f6c5a4a4bfcf661d5', + to: '0x4bbeeb066ed09b7aed07bf39eee0460dfa261520', + amount: 1, + tokenId: '0', + contractAddress: '0xdd7eb7809d283ae3ffa880183f20e7016ebe8374', + transferType: 'erc1155', + }, + ], + }, + ], + unprocessedNetworks: [], + pageInfo: { + count: 3, + hasNextPage: true, + hasPreviousPage: false, + startCursor: null, + endCursor: TRANSACTIONS_PAGE_3_CURSOR, + }, + }, + }; + return nock('https://accounts.api.cx.metamask.io:443', { + encodedQueryParams: true, + }) + .get('/v4/multiaccount/transactions') + .query( + (args) => + args.limit === '3' && + args.accountAddresses === + 'eip155:0:0x4bbeeb066ed09b7aed07bf39eee0460dfa261520' && + (args.before === TRANSACTIONS_PAGE_3_START_CURSOR || + args.after === TRANSACTIONS_PAGE_2_CURSOR), + ) + .reply(reply.status, reply.body); +} + +export function mockTransactionsPage3(mockReply?: MockReply): nock.Scope { + const reply = mockReply ?? { + status: 200, + body: { + data: [ + { + hash: '0xb7cec2f0aab8013c0f69a6e8841a565d925e9d9dff39d6f55236ef62df11f2ae', + timestamp: '2025-12-14T12:06:02.000Z', + chainId: 534352, + accountId: 'eip155:534352:0x4bbeeb066ed09b7aed07bf39eee0460dfa261520', + blockNumber: 26534356, + blockHash: + '0xca1eadb6d82aa3ae9ab3dfb4cde81c69537152b54242b5dc53a8f7167beaf68e', + gas: 20000000, + gasUsed: 13860597, + gasPrice: '120118', + effectiveGasPrice: '120118', + nonce: 270515, + cumulativeGasUsed: 13860597, + methodId: '0xc204642c', + value: '0', + to: '0x20cc3197f82c389978d70ec3169eecccf0d63cef', + from: '0x8245637968c2e16e9c28d45067bf6dd4334e6db0', + isError: false, + valueTransfers: [ + { + from: '0xaf061718473fbcfc4315e33cd29ccba0bb3f8ac8', + to: '0x4bbeeb066ed09b7aed07bf39eee0460dfa261520', + amount: 1, + tokenId: '1', + contractAddress: '0x20cc3197f82c389978d70ec3169eecccf0d63cef', + transferType: 'erc1155', + }, + ], + }, + { + hash: '0x0fd46d8c05d0817fbfff845d32a39f1eadb0ced2a10136f9cca3603ab21f577d', + timestamp: '2025-12-14T11:25:35.000Z', + chainId: 1, + accountId: 'eip155:1:0x4bbeeb066ed09b7aed07bf39eee0460dfa261520', + blockNumber: 24010531, + blockHash: + '0x24ffc87ef6dee436018f114a9e1756ea874e3a10c79744465e5f297e03f3b914', + gas: 21000, + gasUsed: 21000, + gasPrice: '20000000000', + effectiveGasPrice: '20000000000', + nonce: 2, + cumulativeGasUsed: 14457098, + methodId: null, + value: '5000000000000000', + to: '0x4bbeeb066ed09b7aed07bf39eee0460dfa261520', + from: '0xc50103d72598734f6d6007cedc5d1d22d227710d', + isError: false, + valueTransfers: [ + { + from: '0xc50103d72598734f6d6007cedc5d1d22d227710d', + to: '0x4bbeeb066ed09b7aed07bf39eee0460dfa261520', + amount: '5000000000000000', + decimal: 18, + transferType: 'normal', + }, + ], + }, + { + hash: '0x136142885cf873cb681cfe2967bc96b28d696b7a5d8b23d00dacd4e395a001b0', + timestamp: '2025-12-13T04:59:23.000Z', + chainId: 1, + accountId: 'eip155:1:0x4bbeeb066ed09b7aed07bf39eee0460dfa261520', + blockNumber: 24001456, + blockHash: + '0x50f4c60b4f7aa5944f0bff7f51e2417afa8ae3ce1a010ed4af5046c85bf01809', + gas: 16000000, + gasUsed: 12517751, + gasPrice: '50000000', + effectiveGasPrice: '50000000', + nonce: 242, + cumulativeGasUsed: 35408463, + methodId: '0x60806040', + value: '0', + to: '0x0000000000000000000000000000000000000000', + from: '0x8c984ec1dea4ecb9ae790ccca1e7ebb92b9631b0', + isError: false, + valueTransfers: [ + { + from: '0xadae2631d69c848698ac4a73a9b1fc38f478fb8a', + to: '0x4bbeeb066ed09b7aed07bf39eee0460dfa261520', + amount: '3682800000000000000', + decimal: 18, + contractAddress: '0xcb696c86917175dfb4f0037ddc4f2e877a9f081a', + symbol: 'MD+', + name: 'MoonDayPlus.com', + transferType: 'erc20', + }, + ], + }, + ], + unprocessedNetworks: [], + pageInfo: { + count: 3, + hasNextPage: true, + hasPreviousPage: true, + startCursor: TRANSACTIONS_PAGE_3_START_CURSOR, + endCursor: + 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJlaXAxNTU6MToweDRiYmVlYjA2NmVkMDliN2FlZDA3YmYzOWVlZTA0NjBkZmEyNjE1MjAiOnsibGFzdFRpbWVzdGFtcCI6IjIwMjUtMTItMTNUMDQ6NTk6MjMuMDAwWiIsImhhc05leHRQYWdlIjp0cnVlfSwiZWlwMTU1OjEwOjB4NGJiZWViMDY2ZWQwOWI3YWVkMDdiZjM5ZWVlMDQ2MGRmYTI2MTUyMCI6eyJsYXN0VGltZXN0YW1wIjoiMjAyNS0xMi0xM1QwNDo1OToyMy4wMDBaIiwiaGFzTmV4dFBhZ2UiOnRydWV9LCJlaXAxNTU6MTM3OjB4NGJiZWViMDY2ZWQwOWI3YWVkMDdiZjM5ZWVlMDQ2MGRmYTI2MTUyMCI6eyJsYXN0VGltZXN0YW1wIjoiMjAyNS0xMi0xM1QwNDo1OToyMy4wMDBaIiwiaGFzTmV4dFBhZ2UiOnRydWV9LCJlaXAxNTU6NDIxNjE6MHg0YmJlZWIwNjZlZDA5YjdhZWQwN2JmMzllZWUwNDYwZGZhMjYxNTIwIjp7Imxhc3RUaW1lc3RhbXAiOiIyMDI1LTEyLTEzVDA0OjU5OjIzLjAwMFoiLCJoYXNOZXh0UGFnZSI6dHJ1ZX0sImVpcDE1NTo1MzQzNTI6MHg0YmJlZWIwNjZlZDA5YjdhZWQwN2JmMzllZWUwNDYwZGZhMjYxNTIwIjp7Imxhc3RUaW1lc3RhbXAiOiIyMDI1LTEyLTEzVDA0OjU5OjIzLjAwMFoiLCJoYXNOZXh0UGFnZSI6dHJ1ZX0sImVpcDE1NTo1NjoweDRiYmVlYjA2NmVkMDliN2FlZDA3YmYzOWVlZTA0NjBkZmEyNjE1MjAiOnsibGFzdFRpbWVzdGFtcCI6IjIwMjUtMTItMTNUMDQ6NTk6MjMuMDAwWiIsImhhc05leHRQYWdlIjp0cnVlfSwiZWlwMTU1OjU5MTQ0OjB4NGJiZWViMDY2ZWQwOWI3YWVkMDdiZjM5ZWVlMDQ2MGRmYTI2MTUyMCI6eyJsYXN0VGltZXN0YW1wIjoiMjAyNS0xMi0xM1QwNDo1OToyMy4wMDBaIiwiaGFzTmV4dFBhZ2UiOnRydWV9LCJlaXAxNTU6ODQ1MzoweDRiYmVlYjA2NmVkMDliN2FlZDA3YmYzOWVlZTA0NjBkZmEyNjE1MjAiOnsibGFzdFRpbWVzdGFtcCI6IjIwMjUtMTItMTNUMDQ6NTk6MjMuMDAwWiIsImhhc05leHRQYWdlIjp0cnVlfSwiaWF0IjoxNzcyMTg0ODIyfQ.-JOxS3Ly3j0XLp9P-PfRHJuzVsHQh6uRzvYJvcW_PGs', + }, + }, + }; + return nock('https://accounts.api.cx.metamask.io:443', { + encodedQueryParams: true, + }) + .get('/v4/multiaccount/transactions') + .query({ + limit: '3', + accountAddresses: + 'eip155%3A0%3A0x4bbeeb066ed09b7aed07bf39eee0460dfa261520', + after: TRANSACTIONS_PAGE_3_CURSOR, + }) + .reply(reply.status, reply.body); +} diff --git a/packages/base-data-service/tsconfig.build.json b/packages/base-data-service/tsconfig.build.json new file mode 100644 index 00000000000..b8f6416befa --- /dev/null +++ b/packages/base-data-service/tsconfig.build.json @@ -0,0 +1,13 @@ +{ + "extends": "../../tsconfig.packages.build.json", + "compilerOptions": { + "baseUrl": "./", + "outDir": "./dist", + "rootDir": "./src" + }, + "references": [ + { "path": "../messenger/tsconfig.build.json" }, + { "path": "../storage-service/tsconfig.build.json" } + ], + "include": ["../../types", "./src"] +} diff --git a/packages/base-data-service/tsconfig.json b/packages/base-data-service/tsconfig.json new file mode 100644 index 00000000000..e63c2bfd348 --- /dev/null +++ b/packages/base-data-service/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../../tsconfig.packages.json", + "compilerOptions": { + "baseUrl": "./" + }, + "references": [{ "path": "../messenger" }, { "path": "../storage-service" }], + "include": ["../../types", "./src", "./tests"] +} diff --git a/packages/base-data-service/typedoc.json b/packages/base-data-service/typedoc.json new file mode 100644 index 00000000000..c9da015dbf8 --- /dev/null +++ b/packages/base-data-service/typedoc.json @@ -0,0 +1,7 @@ +{ + "entryPoints": ["./src/index.ts"], + "excludePrivate": true, + "hideGenerator": true, + "out": "docs", + "tsconfig": "./tsconfig.build.json" +} diff --git a/packages/bitcoin-regtest-up/CHANGELOG.md b/packages/bitcoin-regtest-up/CHANGELOG.md new file mode 100644 index 00000000000..29eb0135d41 --- /dev/null +++ b/packages/bitcoin-regtest-up/CHANGELOG.md @@ -0,0 +1,20 @@ +# 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 ([#9314](https://github.com/MetaMask/core/pull/9314)) + - Installs a pinned Bitcoin Core runtime for local development and CI + - Exposes `bitcoin-regtest-up`, `bitcoind`, and `bitcoin-cli` binaries via `node_modules/.bin` + - Uses `@metamask/local-node-utils` for cache resolution, downloads, and executable wrappers + +[Unreleased]: https://github.com/MetaMask/core/compare/@metamask/bitcoin-regtest-up@1.0.0...HEAD +[1.0.0]: https://github.com/MetaMask/core/releases/tag/@metamask/bitcoin-regtest-up@1.0.0 diff --git a/packages/bitcoin-regtest-up/LICENSE b/packages/bitcoin-regtest-up/LICENSE new file mode 100644 index 00000000000..9ec4f4514ea --- /dev/null +++ b/packages/bitcoin-regtest-up/LICENSE @@ -0,0 +1,6 @@ +This project is licensed under either of + + * MIT license ([LICENSE.MIT](LICENSE.MIT)) + * Apache License, Version 2.0 ([LICENSE.APACHE2](LICENSE.APACHE2)) + +at your option. diff --git a/packages/bitcoin-regtest-up/LICENSE.APACHE2 b/packages/bitcoin-regtest-up/LICENSE.APACHE2 new file mode 100644 index 00000000000..56752e8ff49 --- /dev/null +++ b/packages/bitcoin-regtest-up/LICENSE.APACHE2 @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright 2026 MetaMask + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/packages/bitcoin-regtest-up/LICENSE.MIT b/packages/bitcoin-regtest-up/LICENSE.MIT new file mode 100644 index 00000000000..fe29e78e0fe --- /dev/null +++ b/packages/bitcoin-regtest-up/LICENSE.MIT @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 MetaMask + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packages/bitcoin-regtest-up/README.md b/packages/bitcoin-regtest-up/README.md new file mode 100644 index 00000000000..9d20b7075c2 --- /dev/null +++ b/packages/bitcoin-regtest-up/README.md @@ -0,0 +1,116 @@ +# `@metamask/bitcoin-regtest-up` + +`bitcoin-regtest-up` installs a pinned Bitcoin Core runtime for local +development and CI. It follows the same runtime-only shape as +`@metamask/foundryup`: this package installs external runtime artifacts into the +MetaMask cache and exposes binaries in `node_modules/.bin`; the consuming test +harness owns process startup, regtest config, readiness checks, and seeding. + +This package does not use Docker and does not start or seed a Bitcoin node. + +## Usage + +Install the package in the consuming repo: + +```bash +yarn add @metamask/bitcoin-regtest-up +npm install @metamask/bitcoin-regtest-up +``` + +For Yarn v4 projects, it is usually simplest to add package scripts in the +consuming repo: + +```json +{ + "scripts": { + "bitcoin-regtest-up": "node_modules/.bin/bitcoin-regtest-up", + "bitcoind": "node_modules/.bin/bitcoind", + "bitcoin-cli": "node_modules/.bin/bitcoin-cli" + } +} +``` + +Install bitcoind and bitcoin-cli: + +```bash +yarn bitcoin-regtest-up install +``` + +Run the installed Bitcoin Core wrappers: + +```bash +node_modules/.bin/bitcoind -regtest +node_modules/.bin/bitcoin-cli -regtest getblockchaininfo +``` + +For MetaMask Extension E2E tests, the Bitcoin seeder should spawn +`node_modules/.bin/bitcoind`, pass its generated regtest datadir and ports, use +`node_modules/.bin/bitcoin-cli` for node setup, poll JSON-RPC directly, and +perform all wallet/funding seeding itself. + +## Installed Artifacts + +`bitcoin-regtest-up` installs: + +- a platform-specific Bitcoin Core release archive +- a `node_modules/.bin/bitcoind` wrapper +- a `node_modules/.bin/bitcoin-cli` wrapper + +## CLI + +```bash +bitcoin-regtest-up [install] [options] +bitcoin-regtest-up cache clean [options] +``` + +Options: + +- `--bin-directory `: directory for generated wrappers. Defaults to + `node_modules/.bin`. +- `--cache-directory `: artifact cache directory. Defaults to + `.metamask/cache`. +- `--bitcoin-core-url ` and `--bitcoin-core-checksum `: override the + Bitcoin Core archive for the current platform. +- `--platform `: override platform selection, for example + `linux-x64`. + +## Default Release + +The package currently pins Bitcoin Core `30.2` for `darwin-arm64`, +`darwin-x64`, `linux-arm64`, and `linux-x64`. + +## Cache + +The cache defaults to `.metamask/cache` in the current repo. `enableGlobalCache` +is read by parsing `.yarnrc.yml` as YAML; when it is `true`, the cache moves to +`~/.cache/metamask`, matching the `@metamask/foundryup` behavior. + +Clean only this package's cache namespace: + +```bash +yarn bitcoin-regtest-up cache clean +``` + +## Package Config + +The consuming repo can override the pinned artifact URLs and checksums in its +root `package.json`: + +```json +{ + "bitcoinRegtestUp": { + "bitcoinCore": { + "version": "30.2", + "platforms": { + "linux-x64": { + "url": "https://bitcoincore.org/bin/bitcoin-core-30.2/bitcoin-30.2-x86_64-linux-gnu.tar.gz", + "checksum": "6aa7bb4feb699c4c6262dd23e4004191f6df7f373b5d5978b5bcdd4bb72f75d8" + } + } + } + } +} +``` + +Supported package config keys are `bitcoinRegtestUp`, `bitcoinregtestup`, and +`bitcoin-regtest-up`. diff --git a/packages/bitcoin-regtest-up/jest.config.js b/packages/bitcoin-regtest-up/jest.config.js new file mode 100644 index 00000000000..2bc1c7a6203 --- /dev/null +++ b/packages/bitcoin-regtest-up/jest.config.js @@ -0,0 +1,32 @@ +/* + * 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, + + // The CLI entrypoint is exercised through package builds and installed-bin smoke tests. + coveragePathIgnorePatterns: [ + ...baseConfig.coveragePathIgnorePatterns, + './src/bin/bitcoin-regtest-up.ts', + ], + + // An object that configures minimum threshold enforcement for coverage results + coverageThreshold: { + global: { + branches: 62.5, + functions: 100, + lines: 93.26, + statements: 93.33, + }, + }, +}); diff --git a/packages/bitcoin-regtest-up/package.json b/packages/bitcoin-regtest-up/package.json new file mode 100644 index 00000000000..dfc8d44e055 --- /dev/null +++ b/packages/bitcoin-regtest-up/package.json @@ -0,0 +1,76 @@ +{ + "name": "@metamask/bitcoin-regtest-up", + "version": "1.0.0", + "description": "Bitcoin Core regtest runtime installer for MetaMask E2E tests", + "keywords": [ + "Ethereum", + "MetaMask" + ], + "homepage": "https://github.com/MetaMask/core/tree/main/packages/bitcoin-regtest-up#readme", + "bugs": { + "url": "https://github.com/MetaMask/core/issues" + }, + "license": "(MIT OR Apache-2.0)", + "repository": { + "type": "git", + "url": "https://github.com/MetaMask/core.git" + }, + "bin": "./dist/bin/bitcoin-regtest-up.mjs", + "files": [ + "dist/" + ], + "sideEffects": false, + "main": "./dist/index.cjs", + "types": "./dist/index.d.cts", + "exports": { + ".": { + "import": { + "types": "./dist/index.d.mts", + "default": "./dist/index.mjs" + }, + "require": { + "types": "./dist/index.d.cts", + "default": "./dist/index.cjs" + } + }, + "./package.json": "./package.json" + }, + "publishConfig": { + "access": "public", + "registry": "https://registry.npmjs.org/" + }, + "scripts": { + "build": "ts-bridge --project tsconfig.build.json --verbose --clean --no-references", + "build:all": "ts-bridge --project tsconfig.build.json --verbose --clean", + "build:docs": "typedoc", + "changelog:update": "../../scripts/update-changelog.sh @metamask/bitcoin-regtest-up", + "changelog:validate": "../../scripts/validate-changelog.sh @metamask/bitcoin-regtest-up", + "lint:tsconfigs": "tsx ../../scripts/lint-tsconfigs/lint-tsconfigs.mts", + "lint:tsconfigs:fix": "tsx ../../scripts/lint-tsconfigs/lint-tsconfigs.mts --fix", + "messenger-action-types:check": "tsx ../../packages/messenger-cli/src/cli.ts --formatter oxfmt --check", + "messenger-action-types:generate": "tsx ../../packages/messenger-cli/src/cli.ts --formatter oxfmt --generate", + "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/local-node-utils": "^1.0.0" + }, + "devDependencies": { + "@metamask/auto-changelog": "^6.1.0", + "@ts-bridge/cli": "^0.6.4", + "@types/jest": "^30.0.0", + "deepmerge": "^4.2.2", + "jest": "^30.4.2", + "ts-jest": "^29.4.11", + "tsx": "^4.20.5", + "typedoc": "^0.25.13", + "typedoc-plugin-missing-exports": "^2.0.0", + "typescript": "~5.3.3" + }, + "engines": { + "node": "^18.18 || >=20" + } +} diff --git a/packages/bitcoin-regtest-up/src/bin/bitcoin-regtest-up.ts b/packages/bitcoin-regtest-up/src/bin/bitcoin-regtest-up.ts new file mode 100644 index 00000000000..029995a4bbb --- /dev/null +++ b/packages/bitcoin-regtest-up/src/bin/bitcoin-regtest-up.ts @@ -0,0 +1,67 @@ +#!/usr/bin/env node +/* eslint-disable no-restricted-globals */ +import { + cleanBitcoinRegtestCache, + installBitcoinRegtest, + parseBitcoinRegtestInstallCliOptions, + readBitcoinRegtestInstallOptionsFromPackageJson, +} from '../install.js'; + +async function main(): Promise { + const [command, ...args] = process.argv.slice(2); + + if (command === '--help' || command === 'help') { + printHelp(); + return; + } + + if (command === 'cache' && args[0] === 'clean') { + await cleanBitcoinRegtestCache({ + ...readBitcoinRegtestInstallOptionsFromPackageJson(), + ...parseBitcoinRegtestInstallCliOptions(args.slice(1)), + }); + console.log('[bitcoin-regtest-up] cache cleaned'); + return; + } + + const installArgs = command === 'install' ? args : process.argv.slice(2); + const result = await installBitcoinRegtest({ + ...readBitcoinRegtestInstallOptionsFromPackageJson(), + ...parseBitcoinRegtestInstallCliOptions(installArgs), + }); + + console.log( + `[bitcoin-regtest-up] Bitcoin Core ${ + result.cacheHit ? 'found in cache' : 'installed' + }`, + ); + console.log( + `[bitcoin-regtest-up] bitcoind installed at ${result.bitcoindBinary}`, + ); + console.log( + `[bitcoin-regtest-up] bitcoin-cli installed at ${result.bitcoinCliBinary}`, + ); +} + +main().catch((error) => { + console.error(error); + process.exit(1); +}); + +function printHelp(): void { + console.log(`Usage: bitcoin-regtest-up [install] [options] + bitcoin-regtest-up cache clean [options] + +Commands: + install Install Bitcoin Core bitcoind and bitcoin-cli. Default command. + cache clean Remove cached bitcoin-regtest-up artifacts. + +Options: + --bin-directory Directory for executable wrappers. + Defaults to node_modules/.bin. + --cache-directory Cache directory. Defaults to .metamask/cache. + --bitcoin-core-url Bitcoin Core archive URL for the current platform. + --bitcoin-core-checksum Expected Bitcoin Core SHA-256 checksum. + --platform Override platform key, e.g. linux-x64. + --help Show this help text.`); +} diff --git a/packages/bitcoin-regtest-up/src/index.ts b/packages/bitcoin-regtest-up/src/index.ts new file mode 100644 index 00000000000..fe800015793 --- /dev/null +++ b/packages/bitcoin-regtest-up/src/index.ts @@ -0,0 +1,15 @@ +export { + BITCOIN_REGTEST_DEFAULT_CORE, + cleanBitcoinRegtestCache, + getBitcoinRegtestCacheDirectory, + installBitcoinRegtest, + parseBitcoinRegtestInstallCliOptions, + readBitcoinRegtestInstallOptionsFromPackageJson, +} from './install.js'; +export type { + BitcoinRegtestArtifactConfig, + BitcoinRegtestArtifactPlatformConfig, + BitcoinRegtestInstallDependencies, + BitcoinRegtestInstallOptions, + BitcoinRegtestInstallResult, +} from './install.js'; diff --git a/packages/bitcoin-regtest-up/src/install.test.ts b/packages/bitcoin-regtest-up/src/install.test.ts new file mode 100644 index 00000000000..f543af50195 --- /dev/null +++ b/packages/bitcoin-regtest-up/src/install.test.ts @@ -0,0 +1,594 @@ +/* eslint-disable jest/expect-expect, n/no-sync */ +import assert from 'node:assert/strict'; +import { execFileSync } from 'node:child_process'; +import { createHash } from 'node:crypto'; +import { + chmodSync, + existsSync, + lstatSync, + mkdtempSync, + readFileSync, + rmSync, + symlinkSync, + writeFileSync, +} from 'node:fs'; +import { mkdir, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { + BITCOIN_REGTEST_DEFAULT_CORE, + cleanBitcoinRegtestCache, + getBitcoinRegtestCacheDirectory, + installBitcoinRegtest, + parseBitcoinRegtestInstallCliOptions, + readBitcoinRegtestInstallOptionsFromPackageJson, +} from './install.js'; +import type { BitcoinRegtestInstallDependencies } from './install.js'; + +describe('bitcoin-regtest-up installer', () => { + let tempDirs: string[] = []; + + afterEach(() => { + for (const tempDir of tempDirs) { + rmSync(tempDir, { force: true, recursive: true }); + } + tempDirs = []; + }); + + it('pins a runnable Bitcoin Core release', () => { + assert.equal(BITCOIN_REGTEST_DEFAULT_CORE.version, '30.2'); + assert.equal( + BITCOIN_REGTEST_DEFAULT_CORE.platforms['darwin-arm64']?.checksum, + 'c2ecab62891de22228043815cb6211549a32272be3d5d052ff19847d3420bd10', + ); + assert.equal( + BITCOIN_REGTEST_DEFAULT_CORE.platforms['linux-x64']?.checksum, + '6aa7bb4feb699c4c6262dd23e4004191f6df7f373b5d5978b5bcdd4bb72f75d8', + ); + }); + + it('uses the global MetaMask cache when Yarn global cache is enabled', () => { + const cwd = createTempDir(); + const homeDirectory = join(cwd, 'home'); + writeFileSync(join(cwd, '.yarnrc.yml'), 'enableGlobalCache: true\n'); + + assert.equal( + getBitcoinRegtestCacheDirectory({ cwd, homeDirectory }), + join(homeDirectory, '.cache', 'metamask'), + ); + }); + + it('uses the local MetaMask cache when Yarn global cache is disabled', () => { + const cwd = createTempDir(); + writeFileSync(join(cwd, '.yarnrc.yml'), 'enableGlobalCache: false\n'); + + assert.equal( + getBitcoinRegtestCacheDirectory({ cwd }), + join(cwd, '.metamask', 'cache'), + ); + }); + + it('uses the local MetaMask cache when .yarnrc.yml is missing', () => { + const cwd = createTempDir(); + + assert.equal( + getBitcoinRegtestCacheDirectory({ cwd }), + join(cwd, '.metamask', 'cache'), + ); + }); + + it('uses the local MetaMask cache when .yarnrc.yml is unreadable', () => { + const cwd = createTempDir(); + const yarnRcPath = join(cwd, '.yarnrc.yml'); + writeFileSync(yarnRcPath, 'enableGlobalCache: true\n'); + chmodSync(yarnRcPath, 0o000); + + try { + assert.equal( + getBitcoinRegtestCacheDirectory({ cwd }), + join(cwd, '.metamask', 'cache'), + ); + } finally { + chmodSync(yarnRcPath, 0o644); + } + }); + + it('returns empty installer options when package.json is missing', () => { + const cwd = createTempDir(); + + assert.deepEqual( + readBitcoinRegtestInstallOptionsFromPackageJson({ cwd }), + {}, + ); + }); + + it('reads pinned installer options from package.json', () => { + const cwd = createTempDir(); + writeFileSync( + join(cwd, 'package.json'), + JSON.stringify({ + bitcoinRegtestUp: { + bitcoinCore: { + platforms: { + 'linux-x64': { + checksum: sha256('bitcoin-core-from-package-json'), + url: 'https://example.test/bitcoin.tar.gz', + }, + }, + version: 'test-version', + }, + }, + }), + ); + + assert.deepEqual(readBitcoinRegtestInstallOptionsFromPackageJson({ cwd }), { + bitcoinCore: { + platforms: { + 'linux-x64': { + checksum: sha256('bitcoin-core-from-package-json'), + url: 'https://example.test/bitcoin.tar.gz', + }, + }, + version: 'test-version', + }, + }); + }); + + it('parses installer CLI options', () => { + assert.deepEqual( + parseBitcoinRegtestInstallCliOptions([ + '--cache-directory', + '/tmp/cache', + '--bin-directory', + '/tmp/bin', + '--bitcoin-core-url', + 'https://example.test/bitcoin.tar.gz', + '--bitcoin-core-checksum', + 'abc123', + ]), + { + binDirectory: '/tmp/bin', + bitcoinCore: { + platforms: { + current: { + checksum: 'abc123', + url: 'https://example.test/bitcoin.tar.gz', + }, + }, + }, + cacheDirectory: '/tmp/cache', + }, + ); + }); + + it('downloads, verifies, caches, and installs Bitcoin Core wrappers', async () => { + const cwd = createTempDir(); + const cacheDirectory = join(cwd, '.metamask', 'cache'); + const binDirectory = join(cwd, 'node_modules', '.bin'); + const downloads: { destination: string; url: string }[] = []; + const bitcoinCoreContent = 'fake bitcoin core archive'; + const dependencies = createDependencies({ bitcoinCoreContent, downloads }); + + const result = await installBitcoinRegtest( + { + binDirectory, + bitcoinCore: { + platforms: { + 'darwin-arm64': { + checksum: sha256(bitcoinCoreContent), + url: 'https://example.test/bitcoin.tar.gz', + }, + }, + version: 'test-bitcoin', + }, + cacheDirectory, + cwd, + platform: 'darwin-arm64', + }, + dependencies, + ); + + assert.equal(result.cacheHit, false); + assert.equal(result.version, 'test-bitcoin'); + assert.equal(result.bitcoindBinary, join(binDirectory, 'bitcoind')); + assert.equal(result.bitcoinCliBinary, join(binDirectory, 'bitcoin-cli')); + assert.ok(existsSync(result.bitcoindBinary)); + assert.ok(existsSync(result.bitcoinCliBinary)); + assert.ok( + readFileSync(result.bitcoindBinary, 'utf8').includes( + `const executablePath = ${JSON.stringify(result.sourceBitcoindBinary)};`, + ), + ); + assert.deepEqual( + downloads.map(({ url }) => url), + ['https://example.test/bitcoin.tar.gz'], + ); + + const wrapperOutput = execFileSync( + process.execPath, + [result.bitcoindBinary, '-version'], + { encoding: 'utf8' }, + ); + assert.equal(wrapperOutput.trim(), 'bitcoind -version'); + }); + + it('merges partial bitcoinCore overrides with pinned defaults', async () => { + const cwd = createTempDir(); + const cacheDirectory = join(cwd, '.metamask', 'cache'); + const binDirectory = join(cwd, 'node_modules', '.bin'); + const overrideContent = 'override linux archive'; + const overrideDownloads: { destination: string; url: string }[] = []; + const defaultDownloads: { destination: string; url: string }[] = []; + const partialOverride = { + platforms: { + 'linux-x64': { + checksum: sha256(overrideContent), + url: 'https://example.test/override-linux.tar.gz', + }, + }, + version: 'override-version', + }; + + const overrideResult = await installBitcoinRegtest( + { + binDirectory, + bitcoinCore: partialOverride, + cacheDirectory, + cwd, + platform: 'linux-x64', + }, + createDependencies({ + bitcoinCoreContent: overrideContent, + downloads: overrideDownloads, + }), + ); + + assert.equal(overrideResult.version, 'override-version'); + assert.deepEqual( + overrideDownloads.map(({ url }) => url), + ['https://example.test/override-linux.tar.gz'], + ); + + await assert.rejects( + () => + installBitcoinRegtest( + { + binDirectory, + bitcoinCore: partialOverride, + cacheDirectory, + cwd, + platform: 'darwin-arm64', + }, + { + downloadFile: async (url, destination): Promise => { + defaultDownloads.push({ destination, url }); + throw new Error('stop after recording download url'); + }, + }, + ), + /stop after recording download url/u, + ); + assert.deepEqual( + defaultDownloads.map(({ url }) => url), + [BITCOIN_REGTEST_DEFAULT_CORE.platforms['darwin-arm64']?.url], + ); + }); + + it('exits non-zero when the wrapped executable terminates via a signal', async () => { + const cwd = createTempDir(); + const cacheDirectory = join(cwd, '.metamask', 'cache'); + const binDirectory = join(cwd, 'node_modules', '.bin'); + const bitcoinCoreContent = 'signal-terminated bitcoin core archive'; + + const result = await installBitcoinRegtest( + { + binDirectory, + bitcoinCore: { + platforms: { + 'darwin-arm64': { + checksum: sha256(bitcoinCoreContent), + url: 'https://example.test/bitcoin.tar.gz', + }, + }, + }, + cacheDirectory, + cwd, + platform: 'darwin-arm64', + }, + createDependencies({ bitcoinCoreContent }), + ); + + writeFileSync( + result.sourceBitcoindBinary, + `#!/usr/bin/env node\nprocess.kill(process.pid, 'SIGTERM');\n`, + { mode: 0o755 }, + ); + + assert.throws( + () => { + execFileSync(process.execPath, [result.bitcoindBinary, '-version']); + }, + (error: NodeJS.ErrnoException) => + (error.status !== undefined && error.status !== 0) || + Boolean(error.signal), + ); + }); + + it('replaces stale bin symlinks without modifying their targets', async () => { + const cwd = createTempDir(); + const cacheDirectory = join(cwd, '.metamask', 'cache'); + const binDirectory = join(cwd, 'node_modules', '.bin'); + const bitcoinCoreContent = 'fake bitcoin core archive'; + const staleBitcoindTarget = join(cwd, 'stale-bitcoind-target'); + const staleBitcoinCliTarget = join(cwd, 'stale-bitcoin-cli-target'); + + await mkdir(binDirectory, { recursive: true }); + writeFileSync(staleBitcoindTarget, 'do not overwrite bitcoind'); + writeFileSync(staleBitcoinCliTarget, 'do not overwrite bitcoin-cli'); + symlinkSync(staleBitcoindTarget, join(binDirectory, 'bitcoind')); + symlinkSync(staleBitcoinCliTarget, join(binDirectory, 'bitcoin-cli')); + + const result = await installBitcoinRegtest( + { + binDirectory, + bitcoinCore: { + platforms: { + 'darwin-arm64': { + checksum: sha256(bitcoinCoreContent), + url: 'https://example.test/bitcoin.tar.gz', + }, + }, + }, + cacheDirectory, + cwd, + platform: 'darwin-arm64', + }, + createDependencies({ bitcoinCoreContent }), + ); + + assert.equal( + readFileSync(staleBitcoindTarget, 'utf8'), + 'do not overwrite bitcoind', + ); + assert.equal( + readFileSync(staleBitcoinCliTarget, 'utf8'), + 'do not overwrite bitcoin-cli', + ); + assert.equal(lstatSync(result.bitcoindBinary).isSymbolicLink(), false); + assert.equal(lstatSync(result.bitcoinCliBinary).isSymbolicLink(), false); + }); + + it('installs a bitcoind wrapper for Bitcoin Core archives that ship bitcoin-node', async () => { + const cwd = createTempDir(); + const cacheDirectory = join(cwd, '.metamask', 'cache'); + const binDirectory = join(cwd, 'node_modules', '.bin'); + const bitcoinCoreContent = 'fake bitcoin core archive with bitcoin-node'; + + const result = await installBitcoinRegtest( + { + binDirectory, + bitcoinCore: { + platforms: { + 'darwin-arm64': { + checksum: sha256(bitcoinCoreContent), + url: 'https://example.test/bitcoin.tar.gz', + }, + }, + version: 'test-bitcoin-node', + }, + cacheDirectory, + cwd, + platform: 'darwin-arm64', + }, + createDependencies({ + bitcoinCoreContent, + daemonBinaryName: 'bitcoin-node', + }), + ); + + assert.ok(result.sourceBitcoindBinary.endsWith('/libexec/bitcoin-node')); + assert.ok(existsSync(result.bitcoindBinary)); + + const wrapperOutput = execFileSync( + process.execPath, + [result.bitcoindBinary, '-version'], + { encoding: 'utf8' }, + ); + assert.equal(wrapperOutput.trim(), 'bitcoin-node -version'); + }); + + it('installs a bitcoind wrapper for Bitcoin Core archives that ship the bitcoin launcher', async () => { + const cwd = createTempDir(); + const cacheDirectory = join(cwd, '.metamask', 'cache'); + const binDirectory = join(cwd, 'node_modules', '.bin'); + const bitcoinCoreContent = + 'fake bitcoin core archive with bitcoin launcher'; + + const result = await installBitcoinRegtest( + { + binDirectory, + bitcoinCore: { + platforms: { + 'darwin-arm64': { + checksum: sha256(bitcoinCoreContent), + url: 'https://example.test/bitcoin.tar.gz', + }, + }, + version: 'test-bitcoin-launcher', + }, + cacheDirectory, + cwd, + platform: 'darwin-arm64', + }, + createDependencies({ + bitcoinCoreContent, + daemonBinaryName: 'bitcoin', + }), + ); + + assert.ok(result.sourceBitcoindBinary.endsWith('/bin/bitcoin')); + assert.ok(existsSync(result.bitcoindBinary)); + + const wrapperOutput = execFileSync( + process.execPath, + [result.bitcoindBinary, '-version'], + { encoding: 'utf8' }, + ); + assert.equal(wrapperOutput.trim(), 'bitcoin node -version'); + }); + + it('reuses cached Bitcoin Core artifacts without downloading again', async () => { + const cwd = createTempDir(); + const cacheDirectory = join(cwd, '.metamask', 'cache'); + const binDirectory = join(cwd, 'node_modules', '.bin'); + const bitcoinCoreContent = 'cached bitcoin core archive'; + const bitcoinCore = { + platforms: { + 'linux-x64': { + checksum: sha256(bitcoinCoreContent), + url: 'https://example.test/bitcoin.tar.gz', + }, + }, + version: 'cached-version', + }; + + await installBitcoinRegtest( + { binDirectory, bitcoinCore, cacheDirectory, cwd, platform: 'linux-x64' }, + createDependencies({ bitcoinCoreContent }), + ); + + const result = await installBitcoinRegtest( + { binDirectory, bitcoinCore, cacheDirectory, cwd, platform: 'linux-x64' }, + { + downloadFile: async (): Promise => { + throw new Error('cache miss'); + }, + }, + ); + + assert.equal(result.cacheHit, true); + }); + + it('replaces cached Bitcoin Core artifacts when the daemon is not runnable', async () => { + const cwd = createTempDir(); + const cacheDirectory = join(cwd, '.metamask', 'cache'); + const binDirectory = join(cwd, 'node_modules', '.bin'); + const bitcoinCoreContent = 'fresh bitcoin core archive'; + const checksum = sha256(bitcoinCoreContent); + const url = 'https://example.test/bitcoin.tar.gz'; + const cacheKey = sha256(`${url}:${checksum}`); + const cachedBinDirectory = join( + cacheDirectory, + 'bitcoin-regtest-up', + 'bitcoin-core', + cacheKey, + 'bitcoin-30.2', + 'bin', + ); + const downloads: { destination: string; url: string }[] = []; + + await mkdir(cachedBinDirectory, { recursive: true }); + await writeFile( + join(cachedBinDirectory, '..', '..', '.source-checksum'), + checksum, + ); + await writeFile( + join(cachedBinDirectory, 'bitcoin'), + '#!/usr/bin/env node\nprocess.exit(1);\n', + { mode: 0o755 }, + ); + await writeExecutable( + join(cachedBinDirectory, 'bitcoin-cli'), + 'bitcoin-cli', + ); + + const result = await installBitcoinRegtest( + { + binDirectory, + bitcoinCore: { + platforms: { + 'darwin-arm64': { + checksum, + url, + }, + }, + version: 'cached-version', + }, + cacheDirectory, + cwd, + platform: 'darwin-arm64', + }, + createDependencies({ bitcoinCoreContent, downloads }), + ); + + assert.equal(result.cacheHit, false); + assert.equal(downloads.length, 1); + assert.ok(result.sourceBitcoindBinary.endsWith('/bin/bitcoind')); + }); + + it('cleans only the bitcoin-regtest-up cache namespace', async () => { + const cwd = createTempDir(); + const cacheDirectory = join(cwd, '.metamask', 'cache'); + await mkdir(join(cacheDirectory, 'bitcoin-regtest-up', 'old'), { + recursive: true, + }); + await mkdir(join(cacheDirectory, 'foundryup', 'kept'), { + recursive: true, + }); + + await cleanBitcoinRegtestCache({ cacheDirectory, cwd }); + + assert.equal(existsSync(join(cacheDirectory, 'bitcoin-regtest-up')), false); + assert.equal(existsSync(join(cacheDirectory, 'foundryup', 'kept')), true); + }); + + function createTempDir(): string { + const tempDir = mkdtempSync(join(tmpdir(), 'bitcoin-regtest-up-test-')); + tempDirs.push(tempDir); + return tempDir; + } +}); + +function createDependencies({ + bitcoinCoreContent, + daemonBinaryName = 'bitcoind', + downloads = [], +}: { + bitcoinCoreContent: string; + daemonBinaryName?: string; + downloads?: { destination: string; url: string }[]; +}): BitcoinRegtestInstallDependencies { + return { + downloadFile: async (url, destination): Promise => { + downloads.push({ destination, url }); + await writeFile(destination, bitcoinCoreContent); + }, + extractArchive: async (_archivePath, destination): Promise => { + const binDirectory = join(destination, 'bitcoin-30.2', 'bin'); + const libexecDirectory = join(destination, 'bitcoin-30.2', 'libexec'); + await mkdir(binDirectory, { recursive: true }); + await mkdir(libexecDirectory, { recursive: true }); + await writeExecutable( + join( + daemonBinaryName === 'bitcoin-node' ? libexecDirectory : binDirectory, + daemonBinaryName, + ), + daemonBinaryName, + ); + await writeExecutable(join(binDirectory, 'bitcoin-cli'), 'bitcoin-cli'); + }, + }; +} + +async function writeExecutable(path: string, name: string): Promise { + await writeFile( + path, + `#!/usr/bin/env node\nconsole.log(${JSON.stringify(name)} + ' ' + process.argv.slice(2).join(' '));\n`, + { mode: 0o755 }, + ); +} + +function sha256(content: string): string { + return createHash('sha256').update(content).digest('hex'); +} diff --git a/packages/bitcoin-regtest-up/src/install.ts b/packages/bitcoin-regtest-up/src/install.ts new file mode 100644 index 00000000000..c207d865ddd --- /dev/null +++ b/packages/bitcoin-regtest-up/src/install.ts @@ -0,0 +1,380 @@ +/* eslint-disable import-x/no-nodejs-modules, no-restricted-globals */ +import { + cleanInstallerCache, + downloadFileFromUrl, + extractTarGzArchive, + findExecutable, + getCacheKey, + getMetamaskCacheDirectory, + getPlatformKey, + installExecutableWrapper, + mergeArtifactConfig, + readCliValue, + readPackageJsonToolConfig, + requireCompletePlatformConfig, + resolvePlatformConfig, + runCommand, + verifyFileChecksum, +} from '@metamask/local-node-utils'; +import type { + ArtifactConfig, + ArtifactPlatformConfig, + InstallDependencies, +} from '@metamask/local-node-utils'; +import { existsSync, readFileSync } from 'node:fs'; +import { mkdir, rename, rm, writeFile } from 'node:fs/promises'; +import { dirname, join } from 'node:path'; + +const BITCOIN_REGTEST_CACHE_NAMESPACE = 'bitcoin-regtest-up'; +const BITCOIN_CORE_CACHE_NAMESPACE = 'bitcoin-core'; + +export type BitcoinRegtestArtifactConfig = ArtifactConfig; + +export type BitcoinRegtestArtifactPlatformConfig = ArtifactPlatformConfig; + +export type BitcoinRegtestInstallOptions = { + binDirectory?: string; + bitcoinCore?: BitcoinRegtestArtifactConfig; + cacheDirectory?: string; + cwd?: string; + platform?: string; +}; + +export type BitcoinRegtestInstallResult = { + bitcoinCliBinary: string; + bitcoindBinary: string; + cacheHit: boolean; + checksum: string; + sourceBitcoindArgs: string[]; + sourceBitcoinCliBinary: string; + sourceBitcoindBinary: string; + version?: string; +}; + +export type BitcoinRegtestInstallDependencies = InstallDependencies; + +type BitcoinRegtestPackageJsonConfig = Pick< + BitcoinRegtestInstallOptions, + 'binDirectory' | 'bitcoinCore' | 'cacheDirectory' +>; + +export const BITCOIN_REGTEST_DEFAULT_CORE: BitcoinRegtestArtifactConfig = { + version: '30.2', + platforms: { + 'darwin-arm64': { + checksum: + 'c2ecab62891de22228043815cb6211549a32272be3d5d052ff19847d3420bd10', + url: 'https://bitcoincore.org/bin/bitcoin-core-30.2/bitcoin-30.2-arm64-apple-darwin.tar.gz', + }, + 'darwin-x64': { + checksum: + '99d5cee9b9c37be506396c30837a4b98e320bfea71c474d6120a7e8eb6075c7b', + url: 'https://bitcoincore.org/bin/bitcoin-core-30.2/bitcoin-30.2-x86_64-apple-darwin.tar.gz', + }, + 'linux-arm64': { + checksum: + '73e76c14edc79808a0511c744d102ffbb494807ee90cbcba176568243254b532', + url: 'https://bitcoincore.org/bin/bitcoin-core-30.2/bitcoin-30.2-aarch64-linux-gnu.tar.gz', + }, + 'linux-x64': { + checksum: + '6aa7bb4feb699c4c6262dd23e4004191f6df7f373b5d5978b5bcdd4bb72f75d8', + url: 'https://bitcoincore.org/bin/bitcoin-core-30.2/bitcoin-30.2-x86_64-linux-gnu.tar.gz', + }, + }, +}; + +export function getBitcoinRegtestCacheDirectory({ + cwd = process.cwd(), + homeDirectory, +}: { + cwd?: string; + homeDirectory?: string; +} = {}): string { + return getMetamaskCacheDirectory({ + cwd, + homeDirectory, + toolName: BITCOIN_REGTEST_CACHE_NAMESPACE, + }); +} + +export function readBitcoinRegtestInstallOptionsFromPackageJson({ + cwd = process.cwd(), + packageJsonPath = join(cwd, 'package.json'), +}: { + cwd?: string; + packageJsonPath?: string; +} = {}): BitcoinRegtestInstallOptions { + const config = readPackageJsonToolConfig({ + cwd, + packageJsonPath, + configKeys: ['bitcoinRegtestUp', 'bitcoinregtestup', 'bitcoin-regtest-up'], + }) as Partial; + const options: BitcoinRegtestInstallOptions = {}; + + if (config.binDirectory) { + options.binDirectory = config.binDirectory; + } + if (config.bitcoinCore) { + options.bitcoinCore = config.bitcoinCore; + } + if (config.cacheDirectory) { + options.cacheDirectory = config.cacheDirectory; + } + + return options; +} + +export function parseBitcoinRegtestInstallCliOptions( + args: string[], +): BitcoinRegtestInstallOptions { + const options: BitcoinRegtestInstallOptions = {}; + const bitcoinCore: Partial = {}; + + for (let index = 0; index < args.length; index += 1) { + const arg = args[index]; + const value = args[index + 1]; + + switch (arg) { + case '--bin-directory': + options.binDirectory = readCliValue(arg, value); + index += 1; + break; + case '--bitcoin-core-checksum': + bitcoinCore.checksum = readCliValue(arg, value); + index += 1; + break; + case '--bitcoin-core-url': + bitcoinCore.url = readCliValue(arg, value); + index += 1; + break; + case '--cache-directory': + options.cacheDirectory = readCliValue(arg, value); + index += 1; + break; + case '--platform': + options.platform = readCliValue(arg, value); + index += 1; + break; + default: + throw new Error(`Unknown bitcoin-regtest-up install option: ${arg}`); + } + } + + if (bitcoinCore.url || bitcoinCore.checksum) { + options.bitcoinCore = { + platforms: { + current: requireCompletePlatformConfig( + bitcoinCore, + 'Bitcoin Core CLI options', + ), + }, + }; + } + + return options; +} + +export async function installBitcoinRegtest( + options: BitcoinRegtestInstallOptions = {}, + dependencies: BitcoinRegtestInstallDependencies = {}, +): Promise { + const cwd = options.cwd ?? process.cwd(); + const cacheDirectory = + options.cacheDirectory ?? getBitcoinRegtestCacheDirectory({ cwd }); + const binDirectory = + options.binDirectory ?? join(cwd, 'node_modules', '.bin'); + const platformKey = options.platform ?? getPlatformKey(); + const bitcoinCore = mergeArtifactConfig( + BITCOIN_REGTEST_DEFAULT_CORE, + options.bitcoinCore, + ); + const bitcoinCoreConfig = resolvePlatformConfig( + bitcoinCore, + platformKey, + 'Bitcoin Core archive', + ); + const bitcoinCoreResult = await installBitcoinCoreArchive( + { cacheDirectory, config: bitcoinCoreConfig }, + dependencies, + ); + const bitcoindBinary = await installExecutableWrapper({ + binDirectory, + commandName: 'bitcoind', + executableArgs: bitcoinCoreResult.sourceBitcoindArgs, + executablePath: bitcoinCoreResult.sourceBitcoindBinary, + pathResolution: 'absolute', + }); + const bitcoinCliBinary = await installExecutableWrapper({ + binDirectory, + commandName: 'bitcoin-cli', + executablePath: bitcoinCoreResult.sourceBitcoinCliBinary, + pathResolution: 'absolute', + }); + + return { + bitcoinCliBinary, + bitcoindBinary, + cacheHit: bitcoinCoreResult.cacheHit, + checksum: bitcoinCoreConfig.checksum, + sourceBitcoindArgs: bitcoinCoreResult.sourceBitcoindArgs, + sourceBitcoinCliBinary: bitcoinCoreResult.sourceBitcoinCliBinary, + sourceBitcoindBinary: bitcoinCoreResult.sourceBitcoindBinary, + version: bitcoinCore.version, + }; +} + +export async function cleanBitcoinRegtestCache( + options: Pick = {}, +): Promise { + const cwd = options.cwd ?? process.cwd(); + const cacheDirectory = + options.cacheDirectory ?? getBitcoinRegtestCacheDirectory({ cwd }); + + await cleanInstallerCache({ + cacheDirectory, + namespace: BITCOIN_REGTEST_CACHE_NAMESPACE, + }); +} + +async function installBitcoinCoreArchive( + { + cacheDirectory, + config, + }: { + cacheDirectory: string; + config: BitcoinRegtestArtifactPlatformConfig; + }, + dependencies: BitcoinRegtestInstallDependencies, +): Promise<{ + cacheHit: boolean; + sourceBitcoindArgs: string[]; + sourceBitcoinCliBinary: string; + sourceBitcoindBinary: string; +}> { + const cacheKey = getCacheKey(config); + const cacheRoot = join( + cacheDirectory, + BITCOIN_REGTEST_CACHE_NAMESPACE, + BITCOIN_CORE_CACHE_NAMESPACE, + cacheKey, + ); + const checksumPath = join(cacheRoot, '.source-checksum'); + const cached = findBitcoinCoreBinaries(cacheRoot); + + if ( + cached && + existsSync(checksumPath) && + readFileSync(checksumPath, 'utf8') === config.checksum && + (await areBitcoinCoreBinariesRunnable(cached)) + ) { + return { cacheHit: true, ...cached }; + } + + const tempRoot = `${cacheRoot}.downloading`; + const archivePath = join(tempRoot, 'bitcoin-core.tar.gz'); + const downloadFile = dependencies.downloadFile ?? downloadFileFromUrl; + const extractArchive = dependencies.extractArchive ?? extractTarGzArchive; + + await rm(tempRoot, { force: true, recursive: true }); + await rm(cacheRoot, { force: true, recursive: true }); + await mkdir(tempRoot, { recursive: true }); + + try { + await downloadFile(config.url, archivePath); + await verifyFileChecksum( + archivePath, + config.checksum, + 'Downloaded Bitcoin Core', + ); + await extractArchive(archivePath, tempRoot); + + const binaries = findBitcoinCoreBinaries(tempRoot); + if (!binaries) { + throw new Error( + 'Bitcoin Core archive did not contain a node daemon (bitcoind, bitcoin-node, or bitcoin) and bin/bitcoin-cli.', + ); + } + await assertBitcoinCoreBinariesRunnable(binaries); + + await writeFile(checksumPath.replace(cacheRoot, tempRoot), config.checksum); + await mkdir(dirname(cacheRoot), { recursive: true }); + await rename(tempRoot, cacheRoot); + + return { + cacheHit: false, + sourceBitcoindArgs: binaries.sourceBitcoindArgs, + sourceBitcoinCliBinary: binaries.sourceBitcoinCliBinary.replace( + tempRoot, + cacheRoot, + ), + sourceBitcoindBinary: binaries.sourceBitcoindBinary.replace( + tempRoot, + cacheRoot, + ), + }; + } catch (error) { + await rm(tempRoot, { force: true, recursive: true }); + await rm(cacheRoot, { force: true, recursive: true }); + throw error; + } +} + +async function areBitcoinCoreBinariesRunnable(binaries: { + sourceBitcoindArgs: string[]; + sourceBitcoinCliBinary: string; + sourceBitcoindBinary: string; +}): Promise { + try { + await assertBitcoinCoreBinariesRunnable(binaries); + return true; + } catch { + return false; + } +} + +async function assertBitcoinCoreBinariesRunnable(binaries: { + sourceBitcoindArgs: string[]; + sourceBitcoinCliBinary: string; + sourceBitcoindBinary: string; +}): Promise { + await runCommand(binaries.sourceBitcoindBinary, [ + ...binaries.sourceBitcoindArgs, + '-version', + ]); + await runCommand(binaries.sourceBitcoinCliBinary, ['-version']); +} + +function findBitcoinCoreBinaries(root: string): + | { + sourceBitcoindArgs: string[]; + sourceBitcoinCliBinary: string; + sourceBitcoindBinary: string; + } + | undefined { + const sourceBitcoinCliBinary = findExecutable(root, 'bitcoin-cli'); + const sourceBitcoindBinary = findBitcoinCoreDaemonBinary(root); + + if (!sourceBitcoindBinary || !sourceBitcoinCliBinary) { + return undefined; + } + + return { + sourceBitcoindArgs: sourceBitcoindBinary.name === 'bitcoin' ? ['node'] : [], + sourceBitcoinCliBinary, + sourceBitcoindBinary: sourceBitcoindBinary.path, + }; +} + +function findBitcoinCoreDaemonBinary( + root: string, +): { name: string; path: string } | undefined { + for (const name of ['bitcoind', 'bitcoin-node', 'bitcoin']) { + const path = findExecutable(root, name); + if (path) { + return { name, path }; + } + } + + return undefined; +} diff --git a/packages/bitcoin-regtest-up/tsconfig.build.json b/packages/bitcoin-regtest-up/tsconfig.build.json new file mode 100644 index 00000000000..82530a36ddc --- /dev/null +++ b/packages/bitcoin-regtest-up/tsconfig.build.json @@ -0,0 +1,10 @@ +{ + "extends": "../../tsconfig.packages.build.json", + "compilerOptions": { + "baseUrl": "./", + "outDir": "./dist", + "rootDir": "./src" + }, + "references": [{ "path": "../local-node-utils/tsconfig.build.json" }], + "include": ["../../types", "./src"] +} diff --git a/packages/bitcoin-regtest-up/tsconfig.json b/packages/bitcoin-regtest-up/tsconfig.json new file mode 100644 index 00000000000..437bfaf93ab --- /dev/null +++ b/packages/bitcoin-regtest-up/tsconfig.json @@ -0,0 +1,12 @@ +{ + "extends": "../../tsconfig.packages.json", + "compilerOptions": { + "baseUrl": "./" + }, + "references": [ + { + "path": "../local-node-utils" + } + ], + "include": ["../../types", "./src"] +} diff --git a/packages/bitcoin-regtest-up/typedoc.json b/packages/bitcoin-regtest-up/typedoc.json new file mode 100644 index 00000000000..c9da015dbf8 --- /dev/null +++ b/packages/bitcoin-regtest-up/typedoc.json @@ -0,0 +1,7 @@ +{ + "entryPoints": ["./src/index.ts"], + "excludePrivate": true, + "hideGenerator": true, + "out": "docs", + "tsconfig": "./tsconfig.build.json" +} diff --git a/packages/bridge-controller/CHANGELOG.md b/packages/bridge-controller/CHANGELOG.md new file mode 100644 index 00000000000..fc3584c8803 --- /dev/null +++ b/packages/bridge-controller/CHANGELOG.md @@ -0,0 +1,2152 @@ +# 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] + +## [80.1.0] + +### Added + +- Include sufficient-funds and normalized slippage properties in Unified SwapBridge quote metrics ([#9986](https://github.com/MetaMask/core/pull/9986)) + +### Changed + +- Bump `@metamask/remote-feature-flag-controller` from `^6.0.0` to `^6.1.0` ([#9980](https://github.com/MetaMask/core/pull/9980)) + +## [80.0.0] + +### Added + +- Export `QuoteMetadataMigrationPhase` (`'1' | '1.5' | '2'`) ([#9744](https://github.com/MetaMask/core/pull/9744)) +- Export `isQuoteResponseV2`, a type guard that's true when `quote.quote` has a `src` property ([#9744](https://github.com/MetaMask/core/pull/9744)) + +### Changed + +- **BREAKING:** Require `migrationPhase` on `selectBridgeQuotes` / `selectBatchSellQuotes` client params ([#9744](https://github.com/MetaMask/core/pull/9744)) + - `V1Data` (`'1'`): omit API V2 currency metadata; serve legacy `calcQuoteMetadata` + - `V2WithV1Fallback` (`'1.5'`): prefer API V2 metadata (plus fiat from `usd`); fall back to legacy + - `V2Only` (`'2'`): API V2 metadata only +- **BREAKING:** `mergeQuoteMetadata` only accepts `QuoteResponse` V2, and takes optional `migrationPhase` and `currencyValues` ([#9744](https://github.com/MetaMask/core/pull/9744)) + +### Fixed + +- `toQuoteMetadataV2` and `toQuoteResponseV2` omit empty `feeData` / `priceData` objects ([#9744](https://github.com/MetaMask/core/pull/9744)) + +## [79.3.1] + +### Changed + +- Bump `@metamask/remote-feature-flag-controller` from `^5.0.0` to `^6.0.0` ([#9945](https://github.com/MetaMask/core/pull/9945)) +- Bump `@metamask/assets-controller` from `^14.0.0` to `^14.0.2` ([#9960](https://github.com/MetaMask/core/pull/9960), [#9969](https://github.com/MetaMask/core/pull/9969)) +- Bump `@metamask/assets-controllers` from `^111.1.1` to `^111.1.3` ([#9960](https://github.com/MetaMask/core/pull/9960), [#9969](https://github.com/MetaMask/core/pull/9969)) +- Bump `@metamask/transaction-controller` from `^69.5.2` to `^69.6.1` ([#9960](https://github.com/MetaMask/core/pull/9960), [#9969](https://github.com/MetaMask/core/pull/9969)) +- Bump `@metamask/accounts-controller` from `^39.1.0` to `^39.1.1` ([#9969](https://github.com/MetaMask/core/pull/9969)) +- Bump `@metamask/gas-fee-controller` from `^26.3.1` to `^26.3.2` ([#9969](https://github.com/MetaMask/core/pull/9969)) +- Bump `@metamask/multichain-network-controller` from `^3.2.3` to `^3.2.4` ([#9969](https://github.com/MetaMask/core/pull/9969)) +- Bump `@metamask/network-controller` from `^35.0.1` to `^36.0.0` ([#9969](https://github.com/MetaMask/core/pull/9969)) + +## [79.3.0] + +### Added + +- Add Sentry quote-fetch and provider first-result performance traces ([#9899](https://github.com/MetaMask/core/pull/9899)) + +### Changed + +- Bump `@metamask/assets-controller` from `^13.1.2` to `^14.0.0` ([#9873](https://github.com/MetaMask/core/pull/9873), [#9886](https://github.com/MetaMask/core/pull/9886), [#9923](https://github.com/MetaMask/core/pull/9923)) +- Bump `@metamask/assets-controllers` from `^111.1.0` to `^111.1.1` ([#9886](https://github.com/MetaMask/core/pull/9886)) + +## [79.2.0] + +### Added + +- Export `assetIdsMatch` util to compare assetIds. EVM assetIds are case insensitive ([#9831](https://github.com/MetaMask/core/pull/9831)) + +### Fixed + +- Filter fees by `assetId` when coercing V1 quotes to V2 ([#9831](https://github.com/MetaMask/core/pull/9831)) + +## [79.1.0] + +### Added + +- Point Arc's native USDC to the registered `slip44:5042` asset ID instead of the `erc20:0x0000...` placeholder ([#9796](https://github.com/MetaMask/core/pull/9796)) + +### Changed + +- Bump `@metamask/transaction-controller` from `^69.5.0` to `^69.5.2` ([#9798](https://github.com/MetaMask/core/pull/9798), [#9823](https://github.com/MetaMask/core/pull/9823)) +- Bump `@metamask/accounts-controller` from `^39.0.6` to `^39.1.0` ([#9791](https://github.com/MetaMask/core/pull/9791), [#9807](https://github.com/MetaMask/core/pull/9807)) +- Bump `@metamask/multichain-network-controller` from `^3.2.2` to `^3.2.3` ([#9791](https://github.com/MetaMask/core/pull/9791)) +- Bump `@metamask/assets-controllers` from `^111.0.0` to `^111.1.0` ([#9793](https://github.com/MetaMask/core/pull/9793)) +- Bump `@metamask/assets-controller` from `^13.1.1` to `^13.1.2` ([#9813](https://github.com/MetaMask/core/pull/9813)) + +### Fixed + +- Populate QuotesReceived event's `usd_amount_source` property ([#9828](https://github.com/MetaMask/core/pull/9828)) + +## [79.0.1] + +### Changed + +- Bump `@metamask/assets-controller` from `^13.1.0` to `^13.1.1` ([#9788](https://github.com/MetaMask/core/pull/9788)) +- Bump `@metamask/assets-controllers` from `^110.1.1` to `^111.0.0` ([#9788](https://github.com/MetaMask/core/pull/9788)) + +## [79.0.0] + +### Added + +- Implement coercers between `QuoteResponse` v1 and v2 ([#9725](https://github.com/MetaMask/core/pull/9725)) + - `toQuoteResponseV2` and `toQuoteResponseV1` convert quote responses to required schema when needed + - `toQuoteMetadataV2` and `toQuoteMetadataV1` convert quote metadata to required schema when needed + - `toNormalizedAmounts`converts atomic amounts to display-ready values +- Export `sumAmounts` util that adds up fees or token amounts ([#9725](https://github.com/MetaMask/core/pull/9725)) + +### Changed + +- **BREAKING:** Use QuoteResponse V2 within the BridgeController; this affects the batch-sell, unified swap/bridge and quickBuy experiences ([#9726](https://github.com/MetaMask/core/pull/9726)) + - convert quotes to QuoteResponse v2 in `fetchBridgeQuoteStream` + - store quotes as QuoteResponse v2 in the BridgeController + - `QuoteResponse` export now means v2; v1 is still exported as `QuoteResponseV1` + - `fetchBridgeQuoteStream` and `fetchBatchSellTrades` now return `QuoteResponse` v2 + - `fetchBatchSellTrades` expects V2 quotes, then transforms them to V1 for backend compatibility +- **BREAKING:** `appendFeesToQuotes` interface now requires a chainId parameter, but still accepts both V1 and V2 quotes ([#9726](https://github.com/MetaMask/core/pull/9726)) +- Update `calcQuoteMetadata` util to handle both V1 and V2 quotes. Legacy metadata calculators continue to use the V1 schema ([#9727](https://github.com/MetaMask/core/pull/9727)) +- Bump `@metamask/transaction-controller` from `^69.4.0` to `^69.5.0` ([#9780](https://github.com/MetaMask/core/pull/9780)) +- Bump `@metamask/keyring-api` from `^23.7.0` to `^24.0.0` ([#9754](https://github.com/MetaMask/core/pull/9754)) + +## [78.1.0] + +### Added + +- Define `QuoteResponse` v2 and `validateQuoteResponse` ([#9724](https://github.com/MetaMask/core/pull/9724)) +- Export `BridgeAsset` and `validateBridgeAsset` used by QuoteResponse v2, and token endpoints ([#9724](https://github.com/MetaMask/core/pull/9724)) + +### Changed + +- Bump `@metamask/network-controller` from `^35.0.0` to `^35.0.1` ([#9758](https://github.com/MetaMask/core/pull/9758)) +- Bump `@metamask/assets-controller` from `^13.0.0` to `^13.1.0` ([#9743](https://github.com/MetaMask/core/pull/9743)) +- Bump `@metamask/assets-controllers` from `^110.0.3` to `^110.1.1` ([#9743](https://github.com/MetaMask/core/pull/9743), [#9779](https://github.com/MetaMask/core/pull/9779)) +- Bump `@metamask/profile-sync-controller` from `^28.3.0` to `^29.0.0` ([#9779](https://github.com/MetaMask/core/pull/9779)) + +## [78.0.3] + +### Changed + +- Bump `@metamask/assets-controller` from `^12.0.0` to `^13.0.0` ([#9740](https://github.com/MetaMask/core/pull/9740)) + +## [78.0.2] + +### Changed + +- Bump `@metamask/assets-controllers` from `^110.0.0` to `^110.0.3` ([#9693](https://github.com/MetaMask/core/pull/9693), [#9706](https://github.com/MetaMask/core/pull/9706), [#9735](https://github.com/MetaMask/core/pull/9735)) +- Bump `@metamask/transaction-controller` from `^69.2.1` to `^69.4.0` ([#9693](https://github.com/MetaMask/core/pull/9693), [#9735](https://github.com/MetaMask/core/pull/9735)) +- Bump `@metamask/assets-controller` from `^11.2.1` to `^12.0.0` ([#9693](https://github.com/MetaMask/core/pull/9693), [#9706](https://github.com/MetaMask/core/pull/9706), [#9735](https://github.com/MetaMask/core/pull/9735)) +- Bump `@metamask/accounts-controller` from `^39.0.5` to `^39.0.6` ([#9735](https://github.com/MetaMask/core/pull/9735)) +- Bump `@metamask/gas-fee-controller` from `^26.3.0` to `^26.3.1` ([#9735](https://github.com/MetaMask/core/pull/9735)) +- Bump `@metamask/multichain-network-controller` from `^3.2.1` to `^3.2.2` ([#9735](https://github.com/MetaMask/core/pull/9735)) +- Bump `@metamask/network-controller` from `^34.0.0` to `^35.0.0` ([#9735](https://github.com/MetaMask/core/pull/9735)) +- Bump `@metamask/polling-controller` from `^16.0.8` to `^16.0.9` ([#9735](https://github.com/MetaMask/core/pull/9735)) +- Bump `@metamask/remote-feature-flag-controller` from `^4.2.2` to `^5.0.0` ([#9735](https://github.com/MetaMask/core/pull/9735)) + +## [78.0.1] + +### Changed + +- Bump `@metamask/assets-controller` from `^11.2.0` to `^11.2.1` ([#9648](https://github.com/MetaMask/core/pull/9648)) +- Bump `@metamask/keyring-api` from `^23.5.0` to `^23.7.0` ([#9676](https://github.com/MetaMask/core/pull/9676)) + +### Fixed + +- Always fetch and save token prices to `assetExchangeRates` ([#9687](https://github.com/MetaMask/core/pull/9687)) + +## [78.0.0] + +### Added + +- Return `priceImpact` and `relayerFee` as part of `QuoteMetadata` ([#9507](https://github.com/MetaMask/core/pull/9507)) + +### Changed + +- **BREAKING:** Make `QuoteMetadata` fields optional and remove unused values + - Remove falsy (`0` and `null`) fallbacks; missing values are now `undefined` + - Replace `gasFee.effective`, `gasFee.max`, and `totalMaxNetworkFee` usages with `gasFee.total` and `totalNetworkFee`. +- Extract quote-metadata calculation into `utils/quote-metadata/` ([#9507](https://github.com/MetaMask/core/pull/9507)) +- Implement `mergeQuoteMetadata` util which appends QuoteMetadata to QuoteResponse ([#9507](https://github.com/MetaMask/core/pull/9507)) +- Bump `@metamask/assets-controller` from `^11.1.1` to `^11.2.0` ([#9629](https://github.com/MetaMask/core/pull/9629)) +- Bump `@metamask/gas-fee-controller` from `^26.2.4` to `^26.3.0` ([#9629](https://github.com/MetaMask/core/pull/9629)) + +### Fixed + +- Remove Arc and Stellar from `DEFAULT_CHAIN_RANKING`. This is a short term fix for a very rare edge case where when launchdarkly is not reachable (API issue or internet down), the network selector relies on a default list defined in the bridge controller to display the list of networks for swap/bridge, we want to remove Arc and Stellar from this list since they have not launched yet. ([#9635](https://github.com/MetaMask/core/pull/9635)) + +## [77.8.0] + +### Added + +- Add `BRIDGE_UAT_API_BASE_URL` constant so consumers can point the bridge and bridge-status controllers at the UAT environment via `customBridgeApiBaseUrl` ([#9613](https://github.com/MetaMask/core/pull/9613)) + +## [77.7.0] + +### Added + +- Add `BottomNavBar` value to `MetaMetricsSwapsEventSource` for attributing swap and bridge flows to the bottom navigation bar entry point ([#9551](https://github.com/MetaMask/core/pull/9551)) + +### Changed + +- Bump `@metamask/transaction-controller` from `^69.0.0` to `^69.2.1` ([#9568](https://github.com/MetaMask/core/pull/9568), [#9589](https://github.com/MetaMask/core/pull/9589), [#9593](https://github.com/MetaMask/core/pull/9593)) +- Bump `@metamask/assets-controller` from `^11.0.0` to `^11.1.1` ([#9579](https://github.com/MetaMask/core/pull/9579), [#9593](https://github.com/MetaMask/core/pull/9593)) +- Bump `@metamask/assets-controllers` from `^109.4.1` to `^110.0.0` ([#9593](https://github.com/MetaMask/core/pull/9593)) + +### Fixed + +- Fix `selectExchangeRateByAssetId` returning a `"0"` exchange rate for EVM tokens whose market data entry has a missing or zero price. It now returns `{}` in that case, so `selectIsAssetExchangeRateInState` no longer treats such tokens as already priced (a non-empty `"0"` string is truthy) and the controller fetches the token's real rate. This fixes quotes into these tokens (e.g. mUSD) displaying a `$0.00` fiat value. ([#9556](https://github.com/MetaMask/core/pull/9556)) + +## [77.6.0] + +### Added + +- Added `FollowTradingTokenScreen` and `FollowTradingFeedScreen` values to `MetaMetricsSwapsEventSource` enum for attributing swap and bridge flows to follow trading entry points ([#9553](https://github.com/MetaMask/core/pull/9553)) +- Added `FollowTrader` value to `MetaMetricsSwapsEventSource` enum for attributing swap and bridge flows to the follow trader entry point ([#9552](https://github.com/MetaMask/core/pull/9552)) + +## [77.5.0] + +### Added + +- Add the optional `transaction_internal_id` property to `Unified SwapBridge Completed` events. ([#9494](https://github.com/MetaMask/core/pull/9494)) + +### Changed + +- Bump `@metamask/assets-controller` from `^10.2.1` to `^11.0.0` ([#9485](https://github.com/MetaMask/core/pull/9485)) + +### Fixed + +- chore: MIT license text update ([#9472](https://github.com/MetaMask/core/pull/9472)) + +## [77.4.1] + +### Changed + +- Bump `@metamask/profile-sync-controller` from `^28.2.0` to `^28.3.0` ([#9463](https://github.com/MetaMask/core/pull/9463)) +- Bump `@metamask/accounts-controller` from `^39.0.4` to `^39.0.5` ([#9470](https://github.com/MetaMask/core/pull/9470)) +- Bump `@metamask/assets-controller` from `^10.2.0` to `^10.2.1` ([#9470](https://github.com/MetaMask/core/pull/9470)) +- Bump `@metamask/assets-controllers` from `^109.4.0` to `^109.4.1` ([#9470](https://github.com/MetaMask/core/pull/9470)) +- Bump `@metamask/transaction-controller` from `^68.4.0` to `^69.0.0` ([#9470](https://github.com/MetaMask/core/pull/9470)) + +## [77.4.0] + +### Added + +- Add Robinhood Chain mainnet as a supported bridge network. ([#9459](https://github.com/MetaMask/core/pull/9459)) + +### Changed + +- Split up validators into smaller files to prepare for QuoteResponse V2 migration ([#9413](https://github.com/MetaMask/core/pull/9413)) +- Bump `@metamask/messenger` from `^1.2.0` to `^2.0.0` ([#9392](https://github.com/MetaMask/core/pull/9392)) +- Bump `@metamask/assets-controller` from `^10.0.1` to `^10.2.0` ([#9411](https://github.com/MetaMask/core/pull/9411), [#9450](https://github.com/MetaMask/core/pull/9450)) +- Bump `@metamask/transaction-controller` from `^68.2.2` to `^68.4.0` ([#9421](https://github.com/MetaMask/core/pull/9421), [#9456](https://github.com/MetaMask/core/pull/9456)) +- Bump `@metamask/keyring-api` from `^23.3.0` to `^23.5.0` ([#9390](https://github.com/MetaMask/core/pull/9390)) +- Bump `@metamask/assets-controllers` from `^109.3.0` to `^109.4.0` ([#9429](https://github.com/MetaMask/core/pull/9429), [#9450](https://github.com/MetaMask/core/pull/9450)) + +## [77.3.2] + +### Fixed + +- Fix Arc native token symbol from `USDC-native` to `USDC` ([#9364](https://github.com/MetaMask/core/pull/9364)) + +## [77.3.1] + +### Changed + +- Bump `@metamask/transaction-controller` from `^68.2.0` to `^68.2.2` ([#9337](https://github.com/MetaMask/core/pull/9337), [#9349](https://github.com/MetaMask/core/pull/9349)) +- Bump `@metamask/accounts-controller` from `^39.0.3` to `^39.0.4` ([#9349](https://github.com/MetaMask/core/pull/9349)) +- Bump `@metamask/assets-controller` from `^10.0.0` to `^10.0.1` ([#9349](https://github.com/MetaMask/core/pull/9349)) +- Bump `@metamask/assets-controllers` from `^109.2.2` to `^109.3.0` ([#9349](https://github.com/MetaMask/core/pull/9349)) +- Bump `@metamask/gas-fee-controller` from `^26.2.3` to `^26.2.4` ([#9349](https://github.com/MetaMask/core/pull/9349)) +- Bump `@metamask/multichain-network-controller` from `^3.2.0` to `^3.2.1` ([#9349](https://github.com/MetaMask/core/pull/9349)) +- Bump `@metamask/network-controller` from `^33.0.0` to `^34.0.0` ([#9349](https://github.com/MetaMask/core/pull/9349)) +- Bump `@metamask/polling-controller` from `^16.0.7` to `^16.0.8` ([#9349](https://github.com/MetaMask/core/pull/9349)) + +## [77.3.0] + +### Added + +- Export `fetchBridgeQuoteStream` and `appendFeesToQuotes` from the package entry point ([#9313](https://github.com/MetaMask/core/pull/9313)) + +## [77.2.0] + +### Added + +- Add `DiscountType` and expose `quote.feeData.metabridge.discountType` in quote validation ([#9305](https://github.com/MetaMask/core/pull/9305)) + +### Changed + +- Bump `@metamask/assets-controller` from `^9.1.0` to `^10.0.0` ([#9312](https://github.com/MetaMask/core/pull/9312)) + +## [77.1.0] + +### Added + +- Add Batch Sell analytics event types ([#9272](https://github.com/MetaMask/core/pull/9272)) + - `Batch Sell Token Page Viewed` + - `Batch Sell Token Page Continue Clicked` + - `Batch Sell Quote Page Viewed` + - `Batch Sell Quote Page Review Clicked` + - `Batch Sell Review Modal Submitted` + +### Changed + +- Bump `@metamask/multichain-network-controller` from `^3.1.4` to `^3.2.0` ([#9264](https://github.com/MetaMask/core/pull/9264)) + +### Fixed + +- Fix Batch Sell-only `sentAmount.usd` metadata calculations to use each quote's source token exchange rate ([#9272](https://github.com/MetaMask/core/pull/9272)) + +## [77.0.0] + +### Added + +- Added `getLocation()` method to `BridgeController` for reading the current swap/bridge entry point ([#9243](https://github.com/MetaMask/core/pull/9243)) +- Added `Unknown` value to `MetaMetricsSwapsEventSource` enum for unattributed swap and bridge flows ([#9243](https://github.com/MetaMask/core/pull/9243)) +- Added `ActivityTabEmptyState`, `TransactionShield`, `TransactionDetails`, and `DeepLink` values to `MetaMetricsSwapsEventSource` enum for attributing swap and bridge flows to additional entry points ([#9241](https://github.com/MetaMask/core/pull/9241)) + +### Changed + +- **BREAKING:** Default `location` fallback for Unified SwapBridge events now uses `Unknown` instead of `Main View` when no entry point is set ([#9243](https://github.com/MetaMask/core/pull/9243)) +- Bump `@metamask/assets-controller` from `^9.0.2` to `^9.1.0` ([#9244](https://github.com/MetaMask/core/pull/9244)) +- Bump `@metamask/keyring-api` from `^23.1.0` to `^23.3.0` ([#9249](https://github.com/MetaMask/core/pull/9249)) +- Bump `@metamask/transaction-controller` from `^68.1.1` to `^68.2.0` ([#9253](https://github.com/MetaMask/core/pull/9253)) + +## [76.1.0] + +### Added + +- Add `quick_buy_explore` to the `FeatureId` enum ([#9222](https://github.com/MetaMask/core/pull/9222)) + +### Changed + +- Bump `@metamask/assets-controllers` from `^109.2.1` to `^109.2.2` ([#9231](https://github.com/MetaMask/core/pull/9231)) +- Bump `@metamask/accounts-controller` from `^39.0.2` to `^39.0.3` ([#9231](https://github.com/MetaMask/core/pull/9231)) + +## [76.0.0] + +### Added + +- **BREAKING**: Add persisted input primary denomination state and `Unified SwapBridge Fiat Crypto Toggle Clicked` analytics event support ([#9147](https://github.com/MetaMask/core/pull/9147)) +- Add Stellar support for bridge token flows: `isStellarChainId`, `ChainId.STELLAR`, native XLM metadata, CAIP/decimal formatting aligned with Bridge API, and Stellar pubnet/testnet in `isNonEvmChainId` ([#8829](https://github.com/MetaMask/core/pull/8829)) +- Add `StellarTradeDataSchema`, `StellarTradeData`, and `isStellarTrade`; extend `extractTradeData` to read Stellar XDR from `{ xdrBase64 }` or `{ xdr }` objects ([#8829](https://github.com/MetaMask/core/pull/8829)) + +## [75.2.1] + +### Changed + +- Bump `@metamask/accounts-controller` from `^39.0.1` to `^39.0.2` ([#9218](https://github.com/MetaMask/core/pull/9218)) +- Bump `@metamask/assets-controller` from `^9.0.1` to `^9.0.2` ([#9218](https://github.com/MetaMask/core/pull/9218)) +- Bump `@metamask/assets-controllers` from `^109.2.0` to `^109.2.1` ([#9218](https://github.com/MetaMask/core/pull/9218)) +- Bump `@metamask/controller-utils` from `^12.2.0` to `^12.3.0` ([#9218](https://github.com/MetaMask/core/pull/9218)) +- Bump `@metamask/gas-fee-controller` from `^26.2.2` to `^26.2.3` ([#9218](https://github.com/MetaMask/core/pull/9218)) +- Bump `@metamask/multichain-network-controller` from `^3.1.3` to `^3.1.4` ([#9218](https://github.com/MetaMask/core/pull/9218)) +- Bump `@metamask/network-controller` from `^32.0.0` to `^33.0.0` ([#9218](https://github.com/MetaMask/core/pull/9218)) +- Bump `@metamask/polling-controller` from `^16.0.6` to `^16.0.7` ([#9218](https://github.com/MetaMask/core/pull/9218)) +- Bump `@metamask/transaction-controller` from `^68.1.0` to `^68.1.1` ([#9218](https://github.com/MetaMask/core/pull/9218)) + +## [75.2.0] + +### Added + +- Add optional `environment_type` property to the `ButtonClicked` unified swap/bridge event context ([#9121](https://github.com/MetaMask/core/pull/9121)) + +### Changed + +- Bump `@metamask/assets-controllers` from `^109.0.0` to `^109.2.0` ([#9110](https://github.com/MetaMask/core/pull/9110), [#9202](https://github.com/MetaMask/core/pull/9202)) +- Bump `@metamask/assets-controllers` from `^109.0.0` to `^109.1.0` ([#9110](https://github.com/MetaMask/core/pull/9110)) +- Refactor selector unit tests to prepare for V2 QuoteResponse migration ([#9098](https://github.com/MetaMask/core/pull/9098)) +- Bump `@metamask/utils` from `^11.9.0` to `^11.11.0` ([#9074](https://github.com/MetaMask/core/pull/9074)) +- Bump `@metamask/assets-controller` from `^9.0.0` to `^9.0.1` ([#9083](https://github.com/MetaMask/core/pull/9083)) +- Bump `@metamask/controller-utils` from `^12.1.1` to `^12.2.0` ([#9083](https://github.com/MetaMask/core/pull/9083)) +- Bump `@metamask/transaction-controller` from `^67.1.0` to `^68.1.0` ([#9089](https://github.com/MetaMask/core/pull/9089), [#9177](https://github.com/MetaMask/core/pull/9177), [#9203](https://github.com/MetaMask/core/pull/9203)) +- Bump `@metamask/profile-sync-controller` from `^28.1.1` to `^28.2.0` ([#9119](https://github.com/MetaMask/core/pull/9119)) + +### Fixed + +- Include `token_symbol_source` and `token_symbol_destination` in `getQuotesReceivedProperties` return value, derived from the active quote's asset metadata ([#9213](https://github.com/MetaMask/core/pull/9213)) + +## [75.1.1] + +### Changed + +- Bump `@metamask/assets-controller` from `^8.3.3` to `^9.0.0` ([#9078](https://github.com/MetaMask/core/pull/9078)) +- Bump `@metamask/assets-controllers` from `^108.6.0` to `^109.0.0` ([#9078](https://github.com/MetaMask/core/pull/9078)) + +## [75.1.0] + +### Added + +- Add `UNKNOWN` to `FeatureId` enum ([#9071](https://github.com/MetaMask/core/pull/9071)) + +## [75.0.0] + +### Added + +- Add `QUICK_BUY_FOLLOW_TRADING`, `QUICK_BUY_TOKEN_DETAILS`, `BATCH_SELL` and `UNIFIED_SWAP_BRIDGE` to FeatureId enum ([#8964](https://github.com/MetaMask/core/pull/8964)) +- Update metrics schema with `batch_id` property ([#8964](https://github.com/MetaMask/core/pull/8964)) +- Add `ARC` network support ([#9007](https://github.com/MetaMask/core/pull/9007)) + - Add `ARC` into constants `ALLOWED_BRIDGE_CHAIN_IDS`, `SWAPS_TOKEN_OBJECT` and `NETWORK_TO_NAME_MAP` + +### Changed + +- **BREAKING**: require all events to have the `feature_id` property ([#8964](https://github.com/MetaMask/core/pull/8964)) +- **BREAKING**: require FeatureId argument when calling `BridgeController:fetchQuotes` ([#8964](https://github.com/MetaMask/core/pull/8964)) +- Rename FeatureIds to match segment property conventions ([#8964](https://github.com/MetaMask/core/pull/8964)) + - `quickBuy` to `quick_buy_follow_trading` and `quick_buy_token_details` + - `dappSwap` to `dapp_swap` +- Bump `@metamask/accounts-controller` from `^39.0.0` to `^39.0.1` ([#9058](https://github.com/MetaMask/core/pull/9058)) +- Bump `@metamask/assets-controller` from `^8.3.2` to `^8.3.3` ([#9058](https://github.com/MetaMask/core/pull/9058)) +- Bump `@metamask/assets-controllers` from `^108.5.0` to `^108.6.0` ([#9058](https://github.com/MetaMask/core/pull/9058)) +- Bump `@metamask/controller-utils` from `^12.1.0` to `^12.1.1` ([#9058](https://github.com/MetaMask/core/pull/9058)) +- Bump `@metamask/transaction-controller` from `^67.0.0` to `^67.1.0` ([#9066](https://github.com/MetaMask/core/pull/9066)) + +## [74.0.0] + +### Added + +- **BREAKING**: Add required `stxEnabled` parameter to `updateBatchSellTrades`, `fetchBatchSellTrades`, and `formatBatchSellTradesRequest`. The flag is sent to the obtainGaslessBatch API so the backend can estimate gas costs more precisely when Smart Transactions are enabled. ([#9036](https://github.com/MetaMask/core/pull/9036)) + +### Changed + +- Bump `@metamask/transaction-controller` from `^66.0.1` to `^67.0.0` ([#9021](https://github.com/MetaMask/core/pull/9021)) + +## [73.2.1] + +### Changed + +- Bump `@metamask/assets-controllers` from `^108.2.0` to `^108.5.0` ([#8941](https://github.com/MetaMask/core/pull/8941), [#8981](https://github.com/MetaMask/core/pull/8981), [#8999](https://github.com/MetaMask/core/pull/8999)) +- Bump `@metamask/assets-controller` from `^8.1.0` to `^8.3.2` ([#8943](https://github.com/MetaMask/core/pull/8943), [#8981](https://github.com/MetaMask/core/pull/8981), [#8985](https://github.com/MetaMask/core/pull/8985), [#8999](https://github.com/MetaMask/core/pull/8999)) +- Bump `@metamask/remote-feature-flag-controller` from `^4.2.1` to `^4.2.2` ([#8986](https://github.com/MetaMask/core/pull/8986)) +- Bump `@metamask/accounts-controller` from `^38.1.2` to `^39.0.0` ([#8999](https://github.com/MetaMask/core/pull/8999)) +- Bump `@metamask/multichain-network-controller` from `^3.1.2` to `^3.1.3` ([#8999](https://github.com/MetaMask/core/pull/8999)) +- Bump `@metamask/transaction-controller` from `^66.0.0` to `^66.0.1` ([#8999](https://github.com/MetaMask/core/pull/8999)) + +## [73.2.0] + +### Added + +- Add `gasIncluded` and `gasIncluded7702` to `BatchSellTradesResponseSchema` ([#8775](https://github.com/MetaMask/core/pull/8775)) +- Add optional `has_sufficient_gas_for_quote` property to `QuotesReceived` event and `getQuotesReceivedProperties` utility to allow clients to pass whether the user has sufficient gas to submit the quote ([#8895](https://github.com/MetaMask/core/pull/8895)) + +### Changed + +- Bump `@metamask/assets-controller` from `^8.0.2` to `^8.1.0` ([#8919](https://github.com/MetaMask/core/pull/8919)) + +### Fixed + +- Fix EVM token exchange-rate lookups when asset ID address casing differs from `marketData` keys, restoring Batch Sell network fee fiat values ([#8928](https://github.com/MetaMask/core/pull/8928)) + +## [73.1.0] + +### Added + +- Expose gasless batch loading state through selectBatchSellTrades's `isLoading` value ([#8913](https://github.com/MetaMask/core/pull/8913)) + +### Changed + +- Bump `@metamask/assets-controller` from `^8.0.0` to `^8.0.2` ([#8874](https://github.com/MetaMask/core/pull/8874), [#8912](https://github.com/MetaMask/core/pull/8912)) +- Bump `@metamask/assets-controllers` from `^108.1.0` to `^108.2.0` ([#8911](https://github.com/MetaMask/core/pull/8911)) +- Bump `@metamask/accounts-controller` from `^38.1.1` to `^38.1.2` ([#8912](https://github.com/MetaMask/core/pull/8912)) +- Bump `@metamask/profile-sync-controller` from `^28.1.0` to `^28.1.1` ([#8912](https://github.com/MetaMask/core/pull/8912)) + +### Removed + +- **BREAKING**: Deprecate `BridgeUserAction` and `BridgeBackgroundAction` enums ([#8775](https://github.com/MetaMask/core/pull/8775)) + +## [73.0.1] + +### Changed + +- Bump `@metamask/assets-controller` from `^7.1.2` to `^8.0.0` ([#8866](https://github.com/MetaMask/core/pull/8866)) + +### Fixed + +- Fix `calcSentAmount` double-counting fees for intent-based swap quotes ([#8845](https://github.com/MetaMask/core/pull/8845)) + +## [73.0.0] + +### Added + +- Implement transaction batch and fee fetching for BatchSell quotes ([#8805](https://github.com/MetaMask/core/pull/8805)) + - add new states `batchSellTrades` and `batchSellTradesLoadingStatus` to contain transaction data and its fetch status + - support transaction batch data fetching with the new `updateBatchSellTrades` handler. Clients will need to call this whenever the recommended quotes update + - implement `selectBatchSellTrades` selector which returns whether a batch is submittable, and the `totalNetworkFee` provided by the `obtainGaslessBatch` endpoint and its converted values + +### Changed + +- **BREAKING**: Narrow TxData validation from generic string to Hex ([#8805](https://github.com/MetaMask/core/pull/ +- Bump `@metamask/assets-controller` from `^7.1.1` to `^7.1.2` ([#8783](https://github.com/MetaMask/core/pull/8783)) +- Bump `@metamask/assets-controllers` from `^108.0.0` to `^108.1.0` ([#8783](https://github.com/MetaMask/core/pull/8783)) +- Bump `@metamask/profile-sync-controller` from `^28.0.2` to `^28.1.0` ([#8783](https://github.com/MetaMask/core/pull/8783)) +- Bump `@metamask/transaction-controller` from `^65.3.0` to `^66.0.0` ([#8796](https://github.com/MetaMask/core/pull/8796), [#8848](https://github.com/MetaMask/core/pull/8848)) +- Bump `@metamask/gas-fee-controller` from `^26.2.1` to `^26.2.2` ([#8834](https://github.com/MetaMask/core/pull/8834)) +- Bump `@metamask/multichain-network-controller` from `^3.1.1` to `^3.1.2` ([#8834](https://github.com/MetaMask/core/pull/8834)) +- Bump `@metamask/polling-controller` from `^16.0.5` to `^16.0.6` ([#8834](https://github.com/MetaMask/core/pull/8834)) + +### Removed + +- **BREAKING**: Remove `totalNetworkFee` from the `selectBatchSellQuotes`'s results. Clients should use `selectBatchSellTrades` instead ([#8805](https://github.com/MetaMask/core/pull/8805)) + +### Fixed + +- fix non-evm token type detection ([#8811](https://github.com/MetaMask/core/pull/8811)) + +## [72.0.4] + +### Changed + +- Bump `@metamask/accounts-controller` from `^38.1.0` to `^38.1.1` ([#8774](https://github.com/MetaMask/core/pull/8774)) +- Bump `@metamask/assets-controller` from `^7.1.0` to `^7.1.1` ([#8774](https://github.com/MetaMask/core/pull/8774)) +- Bump `@metamask/assets-controllers` from `^107.0.0` to `^108.0.0` ([#8774](https://github.com/MetaMask/core/pull/8774)) +- Bump `@metamask/network-controller` from `^31.1.0` to `^32.0.0` ([#8774](https://github.com/MetaMask/core/pull/8774)) +- Bump `@metamask/controller-utils` from `^12.0.0` to `^12.1.0` ([#8774](https://github.com/MetaMask/core/pull/8774)) + +## [72.0.3] + +### Changed + +- Bump `@metamask/network-controller` from `^31.0.0` to `^31.1.0` ([#8765](https://github.com/MetaMask/core/pull/8765)) +- Bump `@metamask/assets-controller` from `^7.0.1` to `^7.1.0` ([#8773](https://github.com/MetaMask/core/pull/8773)) +- Bump `@metamask/assets-controllers` from `^106.0.1` to `^107.0.0` ([#8773](https://github.com/MetaMask/core/pull/8773)) + +## [72.0.2] + +### Changed + +- Bump `@metamask/accounts-controller` from `^38.0.0` to `^38.1.0` ([#8755](https://github.com/MetaMask/core/pull/8755)) +- Bump `@metamask/assets-controller` from `^7.0.0` to `^7.0.1` ([#8755](https://github.com/MetaMask/core/pull/8755)) +- Bump `@metamask/assets-controllers` from `^106.0.0` to `^106.0.1` ([#8755](https://github.com/MetaMask/core/pull/8755)) +- Bump `@metamask/controller-utils` from `^11.20.0` to `^12.0.0` ([#8755](https://github.com/MetaMask/core/pull/8755)) +- Bump `@metamask/gas-fee-controller` from `^26.2.0` to `^26.2.1` ([#8755](https://github.com/MetaMask/core/pull/8755)) +- Bump `@metamask/multichain-network-controller` from `^3.1.0` to `^3.1.1` ([#8755](https://github.com/MetaMask/core/pull/8755)) +- Bump `@metamask/network-controller` from `^30.1.0` to `^31.0.0` ([#8755](https://github.com/MetaMask/core/pull/8755)) +- Bump `@metamask/polling-controller` from `^16.0.4` to `^16.0.5` ([#8755](https://github.com/MetaMask/core/pull/8755)) +- Bump `@metamask/remote-feature-flag-controller` from `^4.2.0` to `^4.2.1` ([#8755](https://github.com/MetaMask/core/pull/8755)) +- Bump `@metamask/transaction-controller` from `^65.2.0` to `^65.3.0` ([#8755](https://github.com/MetaMask/core/pull/8755)) + +## [72.0.1] + +### Changed + +- Bump `@metamask/assets-controller` from `^6.4.0` to `^7.0.0` ([#8738](https://github.com/MetaMask/core/pull/8738)) + +## [72.0.0] + +### Added + +- **BREAKING:** Add support for BatchSell quotes ([#8711](https://github.com/MetaMask/core/pull/8711)) + - change `quoteRequest`'s type from `QuoteRequest` to `QuoteRequest[]` + - allow callers to update specific quote requests within a batch by adding 2 optional parameters to `updateBridgeQuoteRequest`: quoteRequestIndex and quoteRequestCount + - export `isValidBatchSellQuoteRequest` request validator + - fetch multiple swap quotes through a single SSE stream and append `quoteRequestIndex` to link each one to its originating quoteRequest + - implement `selectBatchSellQuotes` selector which returns the recommended quote for each batched quote, and their aggregated fees and received amounts + - trace BatchSell quote fetch operations in Sentry using label `Batch Sell Quotes Fetched` + +### Changed + +- Bump `@metamask/gas-fee-controller` from `^26.1.1` to `^26.2.0` ([#8722](https://github.com/MetaMask/core/pull/8722)) +- Bump `@metamask/transaction-controller` from `^65.1.0` to `^65.2.0` ([#8722](https://github.com/MetaMask/core/pull/8722)) + +## [71.1.1] + +### Changed + +- Bump `@metamask/assets-controller` from `^6.3.0` to `^6.4.0` ([#8721](https://github.com/MetaMask/core/pull/8721)) +- Bump `@metamask/assets-controllers` from `^105.1.0` to `^106.0.0` ([#8721](https://github.com/MetaMask/core/pull/8721)) + +## [71.1.0] + +### Added + +- Add optional `batchSellDestStablecoins` chain-level feature flag to bridge configuration ([#8705](https://github.com/MetaMask/core/pull/8705)) + +### Changed + +- Bump `@metamask/transaction-controller` from `^65.0.0` to `^65.1.0` ([#8691](https://github.com/MetaMask/core/pull/8691)) +- Bump `@metamask/multichain-network-controller` from `^3.0.6` to `^3.1.0` ([#8665](https://github.com/MetaMask/core/pull/8665)) +- Bump `@metamask/accounts-controller` from `^37.2.0` to `^38.0.0` ([#8665](https://github.com/MetaMask/core/pull/8665)) +- Bump `@metamask/assets-controller` from `^6.2.1` to `^6.3.0` ([#8661](https://github.com/MetaMask/core/pull/8661)) +- Bump `@metamask/assets-controllers` from `^105.0.0` to `^105.1.0` ([#8661](https://github.com/MetaMask/core/pull/8661)) +- Bump `@metamask/keyring-api` from `^23.0.1` to `^23.1.0` ([#8647](https://github.com/MetaMask/core/pull/8647)) +- Bump `@metamask/messenger` from `^1.1.1` to `^1.2.0` ([#8632](https://github.com/MetaMask/core/pull/8632)) +- Bump `@metamask/network-controller` from `^30.0.1` to `^30.1.0` ([#8636](https://github.com/MetaMask/core/pull/8636)) + +## [71.0.0] + +### Added + +- **BREAKING:** Add `quickBuy` and `dappSwap` FeatureIds for external swap quote consumers ([#8598](https://github.com/MetaMask/core/pull/8598)) +- **BREAKING:** Add `market_closed` and `quote_expired` QuoteWarning ([#8598](https://github.com/MetaMask/core/pull/8598)) +- Add `tokenSecurityTypeDestination: string | null` to `BridgeControllerState` (default `null`), set via `updateBridgeQuoteRequestParams` and reset by `resetState` ([#8595](https://github.com/MetaMask/core/pull/8595)) + +### Changed + +- **BREAKING:** Add required `token_security_type_destination: string \| null` to `RequestParams`, `RequiredEventContextFromClient[InputSourceDestinationSwitched]`, and the `context` arg of `updateBridgeQuoteRequestParams`; emitted on every analytics event that includes `token_address_destination` ([#8595](https://github.com/MetaMask/core/pull/8595)) +- **BREAKING:** `getRequestParams` now takes a second positional argument `tokenSecurityTypeDestination: string \| null` ([#8595](https://github.com/MetaMask/core/pull/8595)) +- Bump `@metamask/transaction-controller` from `^64.3.0` to `^65.0.0` ([#8585](https://github.com/MetaMask/core/pull/8585), [#8613](https://github.com/MetaMask/core/pull/8613)) +- Bump `@metamask/assets-controller` from `^6.1.0` to `^6.2.1` ([#8590](https://github.com/MetaMask/core/pull/8590), [#8622](https://github.com/MetaMask/core/pull/8622)) +- Bump `@metamask/assets-controllers` from `^104.3.0` to `^105.0.0` ([#8622](https://github.com/MetaMask/core/pull/8622)) + +## [70.2.0] + +### Added + +- Add `AccountHardwareType` type and `getAccountHardwareType` function to the package exports ([#8503](https://github.com/MetaMask/core/pull/8503)) + - `AccountHardwareType` is a union of `'Ledger' | 'Trezor' | 'QR Hardware' | 'Lattice' | null` + - `getAccountHardwareType` maps a keyring type string to the corresponding `AccountHardwareType` value +- Read 'maxPendingHistoryItemAgeMs' feature flag from LaunchDarkly, which indicates when a history item can be treated as a failure ([#8479](https://github.com/MetaMask/core/pull/8479)) +- Add the `invalid_transaction_hash` polling reason to indicate that a history item was removed from state do to having an invalid hash ([#8479](https://github.com/MetaMask/core/pull/8479)) + +### Changed + +- Add `account_hardware_type` field to `RequestMetadata` and all cross-chain swap analytics events ([#8503](https://github.com/MetaMask/core/pull/8503)) + - `account_hardware_type` carries the specific hardware wallet brand (e.g. `'Ledger'`) or `null` for software wallets + - `is_hardware_wallet` is now derived from `account_hardware_type !== null`, keeping both fields in sync + - `EventPropertiesFromControllerState[PageViewed]` now includes `account_hardware_type`, `is_hardware_wallet`, `custom_slippage`, `slippage_limit`, and `swap_type` (previously only `RequestParams` fields were included) +- Bump `@metamask/assets-controller` from `^6.0.0` to `^6.1.0` ([#8559](https://github.com/MetaMask/core/pull/8559)) +- Bump `@metamask/assets-controllers` from `^104.0.0` to `^104.3.0` ([#8509](https://github.com/MetaMask/core/pull/8509), [#8544](https://github.com/MetaMask/core/pull/8544), [#8559](https://github.com/MetaMask/core/pull/8559)) +- Bump `@metamask/transaction-controller` from `^64.2.0` to `^64.3.0` ([#8482](https://github.com/MetaMask/core/pull/8482)) +- Bump `@metamask/keyring-api` from `^21.6.0` to `^23.0.1` ([#8464](https://github.com/MetaMask/core/pull/8464)) + +## [70.1.1] + +### Changed + +- Bump `@metamask/assets-controller` from `^5.0.1` to `^6.0.0` ([#8474](https://github.com/MetaMask/core/pull/8474)) + +## [70.1.0] + +### Added + +- Add action types for all public `BridgeController` methods ([#8367](https://github.com/MetaMask/core/pull/8367)) + - The following types are now available: + - `BridgeControllerUpdateBridgeQuoteRequestParamsAction` + - `BridgeControllerFetchQuotesAction` + - `BridgeControllerStopPollingForQuotesAction` + - `BridgeControllerSetLocationAction` + - `BridgeControllerResetStateAction` + - `BridgeControllerSetChainIntervalLengthAction` + - `BridgeControllerTrackUnifiedSwapBridgeEventAction` + +### Changed + +- Bump `@metamask/assets-controller` from `^4.0.0` to `^5.0.1` ([#8406](https://github.com/MetaMask/core/pull/8406), [#8466](https://github.com/MetaMask/core/pull/8466)) +- Bump `@metamask/accounts-controller` from `^37.1.1` to `^37.2.0` ([#8363](https://github.com/MetaMask/core/pull/8363)) +- Bump `@metamask/messenger` from `^1.0.0` to `^1.1.1` ([#8364](https://github.com/MetaMask/core/pull/8364), [#8373](https://github.com/MetaMask/core/pull/8373)) +- Bump `@metamask/transaction-controller` from `^64.0.0` to `^64.2.0` ([#8432](https://github.com/MetaMask/core/pull/8432), [#8447](https://github.com/MetaMask/core/pull/8447)) +- Bump `@metamask/base-controller` from `^9.0.1` to `^9.1.0` ([#8457](https://github.com/MetaMask/core/pull/8457)) +- Bump `@metamask/assets-controllers` from `^103.1.1` to `^104.0.0` ([#8466](https://github.com/MetaMask/core/pull/8466)) + +### Deprecated + +- Deprecate `BridgeControllerAction`, `BridgeUserAction` and `BridgeBackgroundAction` in favor of separate action types ([#8367](https://github.com/MetaMask/core/pull/8367)) + +## [70.0.1] + +### Changed + +- Bump `@metamask/transaction-controller` from `^63.3.1` to `^64.0.0` ([#8359](https://github.com/MetaMask/core/pull/8359)) +- Bump `@metamask/controller-utils` from `^11.19.0` to `^11.20.0` ([#8344](https://github.com/MetaMask/core/pull/8344)) +- Bump `@metamask/assets-controller` from `^3.2.1` to `^4.0.0` ([#8355](https://github.com/MetaMask/core/pull/8355), [#8359](https://github.com/MetaMask/core/pull/8359)) +- Bump `@metamask/assets-controllers` from `^103.0.0` to `^103.1.1` ([#8355](https://github.com/MetaMask/core/pull/8355), [#8359](https://github.com/MetaMask/core/pull/8359)) + +## [70.0.0] + +### Added + +- **BREAKING:** Add `quoteStreamComplete` state field to `BridgeControllerState`, populated from the `complete` SSE event emitted by the quote stream ([#8306](https://github.com/MetaMask/core/pull/8306)) + - Exposes `QuoteStreamCompleteData` type and `validateQuoteStreamComplete` validator + - `quoteStreamComplete` is cleared at the start of each fetch and on `resetState` + +## [69.2.3] + +### Changed + +- Bump `@metamask/snaps-controllers` from `^17.2.0` to `^19.0.0` ([#8319](https://github.com/MetaMask/core/pull/8319)) +- Bump `@metamask/accounts-controller` from `^37.1.0` to `^37.1.1` ([#8325](https://github.com/MetaMask/core/pull/8325)) +- Bump `@metamask/assets-controller` from `^3.2.0` to `^3.2.1` ([#8325](https://github.com/MetaMask/core/pull/8325)) +- Bump `@metamask/assets-controllers` from `^102.0.0` to `^103.0.0` ([#8325](https://github.com/MetaMask/core/pull/8325)) +- Bump `@metamask/profile-sync-controller` from `^28.0.1` to `^28.0.2` ([#8325](https://github.com/MetaMask/core/pull/8325)) + +## [69.2.2] + +### Changed + +- Bump `@metamask/accounts-controller` from `^37.0.0` to `^37.1.0` ([#8317](https://github.com/MetaMask/core/pull/8317)) +- Bump `@metamask/assets-controllers` from `^101.0.1` to `^102.0.0` ([#8317](https://github.com/MetaMask/core/pull/8317)) +- Bump `@metamask/base-controller` from `^9.0.0` to `^9.0.1` ([#8317](https://github.com/MetaMask/core/pull/8317)) +- Bump `@metamask/gas-fee-controller` from `^26.1.0` to `^26.1.1` ([#8317](https://github.com/MetaMask/core/pull/8317)) +- Bump `@metamask/messenger` from `^0.3.0` to `^1.0.0` ([#8317](https://github.com/MetaMask/core/pull/8317)) +- Bump `@metamask/multichain-network-controller` from `^3.0.5` to `^3.0.6` ([#8317](https://github.com/MetaMask/core/pull/8317)) +- Bump `@metamask/network-controller` from `^30.0.0` to `^30.0.1` ([#8317](https://github.com/MetaMask/core/pull/8317)) +- Bump `@metamask/polling-controller` from `^16.0.3` to `^16.0.4` ([#8317](https://github.com/MetaMask/core/pull/8317)) +- Bump `@metamask/profile-sync-controller` from `^28.0.0` to `^28.0.1` ([#8317](https://github.com/MetaMask/core/pull/8317)) +- Bump `@metamask/remote-feature-flag-controller` from `^4.1.0` to `^4.2.0` ([#8317](https://github.com/MetaMask/core/pull/8317)) +- Bump `@metamask/assets-controller` from `^3.1.0` to `^3.2.0` ([#8298](https://github.com/MetaMask/core/pull/8298), [#8317](https://github.com/MetaMask/core/pull/8317)) +- Bump `@metamask/transaction-controller` from `^63.1.0` to `^63.3.1` ([#8301](https://github.com/MetaMask/core/pull/8301), [#8313](https://github.com/MetaMask/core/pull/8313), [#8317](https://github.com/MetaMask/core/pull/8317)) + +## [69.2.1] + +### Changed + +- Bump `@metamask/transaction-controller` from `^63.0.0` to `^63.1.0` ([#8272](https://github.com/MetaMask/core/pull/8272)) +- Bump `@metamask/assets-controller` from `^3.0.0` to `^3.1.0` ([#8276](https://github.com/MetaMask/core/pull/8276)) + +## [69.2.0] + +### Added + +- Consume `token_warning` SSE events from the bridge-api quote stream and expose them as `tokenWarnings` in `BridgeControllerState` ([#8198](https://github.com/MetaMask/core/pull/8198)) +- Export `TokenFeature` type and `TokenFeatureType` enum for use by clients ([#8198](https://github.com/MetaMask/core/pull/8198)) + +### Changed + +- Bump `@metamask/keyring-api` from `^21.5.0` to `^21.6.0` ([#8259](https://github.com/MetaMask/core/pull/8259)) +- Bump `@metamask/assets-controller` from `^2.4.0` to `^3.0.0` ([#8232](https://github.com/MetaMask/core/pull/8232)) +- Bump `@metamask/assets-controllers` from `^101.0.0` to `^101.0.1` ([#8232](https://github.com/MetaMask/core/pull/8232)) + +## [69.1.1] + +### Changed + +- Bump `@metamask/assets-controller` from `^2.3.0` to `^2.4.0` ([#8225](https://github.com/MetaMask/core/pull/8225)) +- Bump `@metamask/assets-controllers` from `^100.2.1` to `^101.0.0` ([#8225](https://github.com/MetaMask/core/pull/8225)) +- Bump `@metamask/gas-fee-controller` from `^26.0.3` to `^26.1.0` ([#8225](https://github.com/MetaMask/core/pull/8225)) +- Bump `@metamask/transaction-controller` from `^62.21.0` to `^63.0.0` ([#8217](https://github.com/MetaMask/core/pull/8217), [#8225](https://github.com/MetaMask/core/pull/8225)) + +## [69.1.0] + +### Added + +- Add optional `active_ab_tests` property in Unified SwapBridge metrics event context and payload types, alongside existing `ab_tests`. ([#8152](https://github.com/MetaMask/core/pull/8152)) + +### Fixed + +- Check whether `selectedQuote` exists in `selectBridgeQuotes.sortedQuotes` before returning it as the `activeQuote`. Fall back on the `recommendedQuote` if selectedQuote is stale ([#8154](https://github.com/MetaMask/core/pull/8154)) + +## [69.0.1] + +### Changed + +- Bump `@metamask/profile-sync-controller` from `^27.1.0` to `^28.0.0` ([#8162](https://github.com/MetaMask/core/pull/8162)) +- Bump `@metamask/assets-controllers` from `^100.2.0` to `^100.2.1` ([#8162](https://github.com/MetaMask/core/pull/8162)) + +## [69.0.0] + +### Added + +- **BREAKING:** Optional constructor option `getUseAssetsControllerForRates`: when it returns true, exchange rates are read from the new `@metamask/assets-controller` (`AssetsController:getExchangeRatesForBridge`) instead of `MultichainAssetsRatesController`, `TokenRatesController`, and `CurrencyRateController`. ([#8090](https://github.com/MetaMask/core/pull/8090)) +- Add `AssetPickerOpened` unified swap bridge metrics event with an `asset_location` property to indicate `'source'` or `'destination'`. ([#7985](https://github.com/MetaMask/core/pull/7985)) + +### Changed + +- Bump `@metamask/assets-controllers` from `^100.0.3` to `^100.2.0`,, ([#8107](https://github.com/MetaMask/core/pull/8107), [#8140](https://github.com/MetaMask/core/pull/8140)) +- Bump `@metamask/transaction-controller` from `^62.19.0` to `^62.21.0`,, ([#8104](https://github.com/MetaMask/core/pull/8104), [#8140](https://github.com/MetaMask/core/pull/8140)) +- Bump `@metamask/accounts-controller` from `36.0.1` to `37.0.0` ([#8140](https://github.com/MetaMask/core/pull/8140)) +- Bump `@metamask/assets-controller` from `2.2.0` to `2.3.0` ([#8140](https://github.com/MetaMask/core/pull/8140)) +- Bump `@metamask/multichain-network-controller` from `3.0.4` to `3.0.5` ([#8140](https://github.com/MetaMask/core/pull/8140)) +- Update price impact danger field to error in bridge ([#8150](https://github.com/MetaMask/core/pull/8150)) +- Update price impact threshold LD config schema to include new unified properties ([#8143](https://github.com/MetaMask/core/pull/8143)) + +## [68.0.0] + +### Changed + +- **BREAKING:** Add validation support for intent EIP-712 `typedData` payloads so clients can pass signed intent data through the bridge flow. ([#8048](https://github.com/MetaMask/core/pull/8048)) + +### Fixed + +- Use 9005 as AVAX slip44 reference to match the token api's responses ([#8098](https://github.com/MetaMask/core/pull/8098)) + +## [67.4.0] + +### Changed + +- Widen `RequiredEventContextFromClient` `InputChanged.input_amount_preset` to accept arbitrary string labels (for example `85%`, `95%`, `MAX`) while preserving compatibility with `InputAmountPreset` enum values. ([#8069](https://github.com/MetaMask/core/pull/8069)) + +## [67.3.0] + +### Added + +- Added optional `ab_tests` property to `RequiredEventContextFromClient` and `CrossChainSwapsEventProperties` types for A/B test experiment attribution ([#8007](https://github.com/MetaMask/core/pull/8007)) + +### Changed + +- Bump `@metamask/remote-feature-flag-controller` from `^4.0.0` to `^4.1.0` ([#8041](https://github.com/MetaMask/core/pull/8041)) +- Bump `@metamask/transaction-controller` from `^62.18.0` to `^62.19.0` ([#8031](https://github.com/MetaMask/core/pull/8031)) +- Bump `@metamask/assets-controllers` from `^100.0.2` to `^100.0.3` ([#8029](https://github.com/MetaMask/core/pull/8029)) + +## [67.2.0] + +### Changed + +- Bump `@metamask/transaction-controller` from `^62.17.1` to `^62.18.0` ([#8005](https://github.com/MetaMask/core/pull/8005)) +- Bump `@metamask/assets-controllers` from `^100.0.1` to `^100.0.2` ([#8004](https://github.com/MetaMask/core/pull/8004)) +- Replace `PERCENT_90` with `PERCENT_75` in `InputAmountPreset` enum ([#7997](https://github.com/MetaMask/core/pull/7997)) +- Add `PERCENT_90` in `InputAmountPreset` enum ([#8008](https://github.com/MetaMask/core/pull/8008)) + +## [67.1.1] + +### Changed + +- Bump `@metamask/accounts-controller` from `^36.0.0` to `^36.0.1` ([#7996](https://github.com/MetaMask/core/pull/7996)) +- Bump `@metamask/assets-controllers` from `^100.0.0` to `^100.0.1` ([#7996](https://github.com/MetaMask/core/pull/7996)) +- Bump `@metamask/gas-fee-controller` from `^26.0.2` to `^26.0.3` ([#7996](https://github.com/MetaMask/core/pull/7996)) +- Bump `@metamask/multichain-network-controller` from `^3.0.3` to `^3.0.4` ([#7996](https://github.com/MetaMask/core/pull/7996)) +- Bump `@metamask/network-controller` from `^29.0.0` to `^30.0.0` ([#7996](https://github.com/MetaMask/core/pull/7996)) +- Bump `@metamask/polling-controller` from `^16.0.2` to `^16.0.3` ([#7996](https://github.com/MetaMask/core/pull/7996)) +- Bump `@metamask/transaction-controller` from `^62.17.0` to `^62.17.1` ([#7996](https://github.com/MetaMask/core/pull/7996)) + +## [67.1.0] + +### Added + +- Added optional `input_amount_preset` property to the `InputChanged` event in `RequiredEventContextFromClient` ([#7987](https://github.com/MetaMask/core/pull/7987)) + +### Changed + +- Bump `@metamask/assets-controllers` from `^99.4.0` to `^100.0.0` ([#7995](https://github.com/MetaMask/core/pull/7995)) +- Bump `@metamask/controller-utils` from `^11.18.0` to `^11.19.0` ([#7995](https://github.com/MetaMask/core/pull/7995)) + +## [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)) + +### 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)) + +## [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.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] + +### Changed + +- Bump `@metamask/transaction-controller` from `^62.14.0` to `^62.15.0` ([#7854](https://github.com/MetaMask/core/pull/7854)) +- 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)) + +## [65.3.0] [DEPRECATED] + +### Added + +- Add `MEGAETH` network support ([#7823](https://github.com/MetaMask/core/pull/7823)) + - Add `MEGAETH` into constants `ALLOWED_BRIDGE_CHAIN_IDS`, `DEFAULT_CHAIN_RANKING`, `CHAIN_IDS`, `CURRENCY_SYMBOLS` and `SWAPS_CHAINID_DEFAULT_TOKEN_MAP` +- Export `isTronChainId` from the package entrypoint ([#7697](https://github.com/MetaMask/core/pull/7697)) + +### Changed + +- **BREAKING** Use `gasEstimatesByChainId` instead of `gasEstimates` to remove reference to the global selected network. Clients need to replace gasEstimates with the `gasEstimatesByChainId` state from the GasFeeController when using the `selectBridgeQuotes` selector ([#7826](https://github.com/MetaMask/core/pull/7826)) +- Bump `@metamask/transaction-controller` from `^62.13.0` to `^62.14.0` ([#7832](https://github.com/MetaMask/core/pull/7832)) + +## [65.2.0] + +### Added + +- Add `HYPEREVM` network support ([#7787](https://github.com/MetaMask/core/pull/7787)) + - Add `HYPEREVM` into constants `ALLOWED_BRIDGE_CHAIN_IDS`, `SWAPS_TOKEN_OBJECT` and `NETWORK_TO_NAME_MAP` +- Add `PollingStatusUpdated` to `UnifiedSwapBridgeEventName` enum and `PollingStatus` enum with `MaxPollingReached` and `ManuallyRestarted` values ([#7825](https://github.com/MetaMask/core/pull/7825)) + +### Changed + +- Bump `@metamask/transaction-controller` from `^62.11.0` to `^62.13.0` ([#7775](https://github.com/MetaMask/core/pull/7775), [#7802](https://github.com/MetaMask/core/pull/7802)) +- Bump `@metamask/assets-controllers` from `^99.0.0` to `^99.2.0` ([#7771](https://github.com/MetaMask/core/pull/7771), [#7802](https://github.com/MetaMask/core/pull/7802)) + +## [65.1.0] + +### Added + +- Restore `getMinimumBalanceForRentExemptionInLamports`, `getMinimumBalanceForRentExemptionRequest`, `selectMinimumBalanceForRentExemptionInSOL`, and `minimumBalanceForRentExemptionInLamports` to state ([#7742](https://github.com/MetaMask/core/pull/7742)) + +### Changed + +- Bump `@metamask/transaction-controller` from `^62.10.0` to `^62.11.0` ([#7760](https://github.com/MetaMask/core/pull/7760)) + +## [65.0.1] + +### Changed + +- Bump `@metamask/assets-controllers` from `^98.0.0` to `^99.0.0` ([#7751](https://github.com/MetaMask/core/pull/7751)) +- Bump `@metamask/transaction-controller` from `^62.9.2` to `^62.10.0` ([#7737](https://github.com/MetaMask/core/pull/7737)) + +## [65.0.0] + +### Changed + +- Bump `@metamask/assets-controllers` from `^97.0.0` to `^98.0.0` ([#7731](https://github.com/MetaMask/core/pull/7731)) +- Corrects the previous 64.8.2 release to document breaking changes that were missed: + - **BREAKING:** Remove `getMinimumBalanceForRentExemptionInLamports`, `getMinimumBalanceForRentExemptionRequest`, `selectMinimumBalanceForRentExemptionInSOL`, and `minimumBalanceForRentExemptionInLamports` from state ([#7715](https://github.com/MetaMask/core/pull/7715)) + +## [64.8.2] [DEPRECATED] + +### Changed + +- Bump `@metamask/assets-controllers` from `^96.0.0` to `^97.0.0` ([#7722](https://github.com/MetaMask/core/pull/7722)) + +## [64.8.1] + +### Changed + +- Bump `@metamask/assets-controllers` from `^95.3.0` to `^96.0.0` ([#7704](https://github.com/MetaMask/core/pull/7704)) + +## [64.8.0] + +### Changed + +- Added check to return default values if chainRanking is empty ([#7698](https://github.com/MetaMask/core/pull/7698)) + +## [64.7.0] + +### Changed + +- Made chainRanking an optional flag ([#7691](https://github.com/MetaMask/core/pull/7691)) + +## [64.6.1] + +### Fixed + +- Fixed a typo in polling abort naming ([#7669](https://github.com/MetaMask/core/pull/7669)) + +## [64.6.0] + +### Added + +- Added chainRanking type to feature flags ([#6933](https://github.com/MetaMask/core/pull/6933)) + +## [64.5.1] + +### Changed + +- Bump `@metamask/accounts-controller` from `^35.0.1` to `^35.0.2` ([#7642](https://github.com/MetaMask/core/pull/7642)) +- Bump `@metamask/assets-controllers` from `^95.2.0` to `^95.3.0` ([#7642](https://github.com/MetaMask/core/pull/7642)) +- Bump `@metamask/gas-fee-controller` from `^26.0.1` to `^26.0.2` ([#7642](https://github.com/MetaMask/core/pull/7642)) +- Bump `@metamask/multichain-network-controller` from `^3.0.1` to `^3.0.2` ([#7642](https://github.com/MetaMask/core/pull/7642)) +- Bump `@metamask/network-controller` from `^28.0.0` to `^29.0.0` ([#7642](https://github.com/MetaMask/core/pull/7642)) +- Bump `@metamask/polling-controller` from `^16.0.1` to `^16.0.2` ([#7642](https://github.com/MetaMask/core/pull/7642)) +- Bump `@metamask/transaction-controller` from `^62.9.1` to `^62.9.2` ([#7642](https://github.com/MetaMask/core/pull/7642)) + +## [64.5.0] + +### Added + +- Add `has_gas_included_quote` property to `QuoteFetchData` type and compute it in `QuotesReceived` event to indicate if any received quote has gas included ([#7611](https://github.com/MetaMask/core/pull/7611)) +- Add optional `usd_balance_source` property to `QuotesReceived` event and `getQuotesReceivedProperties` utility to allow clients to pass the source token balance in USD ([#7611](https://github.com/MetaMask/core/pull/7611)) + +### Changed + +- Bump `@metamask/assets-controllers` from `^95.1.0` to `^95.2.0` ([#7622](https://github.com/MetaMask/core/pull/7622)) + +## [64.4.1] + +### Changed + +- Bump `@metamask/transaction-controller` from `^62.8.0` to `^62.9.1` ([#7602](https://github.com/MetaMask/core/pull/7602), [#7604](https://github.com/MetaMask/core/pull/7604)) +- Bump `@metamask/assets-controllers` from `^95.0.0` to `^95.1.0` ([#7600](https://github.com/MetaMask/core/pull/7600)) +- Bump `@metamask/network-controller` from `^27.2.0` to `^28.0.0` ([#7604](https://github.com/MetaMask/core/pull/7604)) +- Bump `@metamask/accounts-controller` from `^35.0.0` to `^35.0.1` ([#7604](https://github.com/MetaMask/core/pull/7604)) +- Bump `@metamask/gas-fee-controller` from `^26.0.0` to `^26.0.1` ([#7604](https://github.com/MetaMask/core/pull/7604)) +- Bump `@metamask/multichain-network-controller` from `^3.0.0` to `^3.0.1` ([#7604](https://github.com/MetaMask/core/pull/7604)) + +## [64.4.0] + +### Added + +- Add intent based transaction support ([#6547](https://github.com/MetaMask/core/pull/6547)) + +### Changed + +- Bump `@metamask/transaction-controller` from `^62.7.0` to `^62.8.0` ([#7596](https://github.com/MetaMask/core/pull/7596)) +- Bump `@metamask/controller-utils` from `^11.17.0` to `^11.18.0` ([#7583](https://github.com/MetaMask/core/pull/7583)) +- Bump `@metamask/network-controller` from `^27.1.0` to `^27.2.0` ([#7583](https://github.com/MetaMask/core/pull/7583)) +- Bump `@metamask/assets-controllers` from `^94.0.0` to `^95.0.0` ([#7584](https://github.com/MetaMask/core/pull/7584)) + +## [64.3.0] + +### Changed + +- Bump `@metamask/snaps-controllers` from `^14.0.0` to `^17.2.0` ([#7550](https://github.com/MetaMask/core/pull/7550)) +- Bump `@metamask/remote-feature-flag-controller` from `^3.1.0` to `^4.0.0` ([#7546](https://github.com/MetaMask/core/pull/7546)) +- Upgrade `@metamask/utils` from `^11.8.1` to `^11.9.0` ([#7511](https://github.com/MetaMask/core/pull/7511)) +- Bump `@metamask/network-controller` from `^27.0.0` to `^27.1.0` ([#7534](https://github.com/MetaMask/core/pull/7534)) +- Bump `@metamask/controller-utils` from `^11.16.0` to `^11.17.0` ([#7534](https://github.com/MetaMask/core/pull/7534)) + +### Fixed + +- Change fee_limit param naming to feeLimit for Tron ([#7571](https://github.com/MetaMask/core/pull/7571)) + +## [64.2.0] + +### Changed + +- Bump `@metamask/assets-controllers` from `^93.1.0` to `^94.1.0` ([#7444](https://github.com/MetaMask/core/pull/7444), [#7488](https://github.com/MetaMask/core/pull/7488)) +- Bump `@metamask/transaction-controller` from `^62.5.0` to `^62.7.0` ([#7430](https://github.com/MetaMask/core/pull/7430), [#7494](https://github.com/MetaMask/core/pull/7494)) +- Bump `@metamask/remote-feature-flag-controller` from `^3.0.0` to `^3.1.0` ([#7519](https://github.com/MetaMask/core/pull/7519)) +- Add fee limit passthrough for Tron snap fee computation ([#7426](https://github.com/MetaMask/core/pull/7426)) + +## [64.1.0] + +### Changed + +- Bump `@metamask/assets-controllers` from `^93.0.0` to `^93.1.0` ([#7309](https://github.com/MetaMask/core/pull/7309)) +- Bump `@metamask/remote-feature-flag-controller` from `^2.0.1` to `^3.0.0` ([#7309](https://github.com/MetaMask/core/pull/7309)) +- Bump `@metamask/transaction-controller` from `^62.4.0` to `^62.5.0` ([#7325](https://github.com/MetaMask/core/pull/7325)) + +### Fixed + +- Update gas calculation logic to use the priority fee provided by the gas-api and stop adding the base fee ([#7403](https://github.com/MetaMask/core/pull/7403), [#7406](https://github.com/MetaMask/core/pull/7406)) + +## [64.0.0] + +### Added + +- Port `fetchTokens` and `type SwapsToken` from `@metamask/swaps-controller` and export them to allow deprecating `swaps-controller` while still supporting downstream consumers ([#7278](https://github.com/MetaMask/core/pull/7278)) +- Handle edge case in which approvals fail if an EVM account has an insufficient non-zero USDT allowance on mainnet ([#7228](https://github.com/MetaMask/core/pull/7228)) + - Set quoteRequest `resetApproval` parameter by calculating the wallet's USDT allowance on mainnet for the swap or bridge spender + - When a valid quote is received, append the `resetApproval` trade data to set the wallet's USDT allowance to `0` + - Include the `resetApproval` tx in network fee calculations + +### Changed + +- **BREAKING:** Remove `SWAPS_TESTNET_CHAIN_ID` export and use `CHAIN_IDS.LOCALHOST` instead ([#7278](https://github.com/MetaMask/core/pull/7278)) +- Bump `@metamask/network-controller` from `^26.0.0` to `^27.0.0` ([#7258](https://github.com/MetaMask/core/pull/7258)) +- Bump `@metamask/transaction-controller` from `^62.3.0` to `^62.4.0` ([#7257](https://github.com/MetaMask/core/pull/7257), [#7289](https://github.com/MetaMask/core/pull/7289)) +- Bump `@metamask/assets-controllers` from `^92.0.0` to `^93.0.0` ([#7291](https://github.com/MetaMask/core/pull/7291)) + +### Removed + +- **BREAKING** Remove public `getBridgeERC20Allowance` action to prevent consumers from using it. This handler is only applicable to Swap and Bridge txs involving USDT on mainnet ([#7228](https://github.com/MetaMask/core/pull/7228)) + +### Fixed + +- **BREAKING:** Add `usd_amount_source` to QuotesRequested event properties. Clients will need to add this value to the quoteRequest context ([#7294](https://github.com/MetaMask/core/pull/7294)) +- Add missing MON (Monad) and SEI (Sei) to integer chain IDs ([#7252](https://github.com/MetaMask/core/pull/7252)) + +## [63.2.0] + +### Changed + +- Update `stopPollingForQuotes` to accept metrics context for the QuotesReceived event. If context is provided and quotes are still loading when the handler is called, the `Unified SwapBridge Quotes Received` is published before the poll is cancelled ([#7242](https://github.com/MetaMask/core/pull/7242)) + +## [63.1.0] + +### Added + +- Port the following constants from `SwapsController` and export them: `SWAPS_TESTNET_CHAIN_ID`, `SWAPS_CONTRACT_ADDRESSES`, `SWAPS_WRAPPED_TOKENS_ADDRESSES`, `ALLOWED_CONTRACT_ADDRESSES` ([#7233](https://github.com/MetaMask/core/pull/7233)) +- Port the following utils from `SwapsController` and export them: `isValidSwapsContractAddress`, `getSwapsContractAddress` ([#7233](https://github.com/MetaMask/core/pull/7233)) + +### Changed + +- Move peer dependencies for controller and service packages to direct dependencies ([#7209](https://github.com/MetaMask/core/pull/7209), [#7220](https://github.com/MetaMask/core/pull/7220), [#7236](https://github.com/MetaMask/core/pull/7236)) + - The dependencies moved are: + - `@metamask/accounts-controller` (^35.0.0) + - `@metamask/assets-controllers` (^91.0.0) + - `@metamask/network-controller` (^26.0.0) + - `@metamask/remote-feature-flag-controller` (^2.0.1) + - `@metamask/snaps-controllers` (^14.0.0) + - `@metamask/transaction-controller` (^62.3.0) + - In clients, it is now possible for multiple versions of these packages to exist in the dependency tree. + - For example, this scenario would be valid: a client relies on `@metamask/controller-a` 1.0.0 and `@metamask/controller-b` 1.0.0, and `@metamask/controller-b` depends on `@metamask/controller-a` 1.1.0. + - Note, however, that the versions specified in the client's `package.json` always "win", and you are expected to keep them up to date so as not to break controller and service intercommunication. + +### Fixed + +- Update `quotesLoadingStatus` to "LOADING" if a balance fetch is needed before fetching quotes ((https://github.com/MetaMask/core/pull/7227)[#7227]) +- Wait for async SSE message handlers before updating `quotesLoadingStatus` to prevent clients from displaying "No quotes" warnings ((https://github.com/MetaMask/core/pull/7227)[#7227]) + +## [63.0.0] + +### Changed + +- **BREAKING:** Bump `@metamask/assets-controllers` from `^90.0.0` to `^91.0.0` ([#7207](https://github.com/MetaMask/core/pull/7207)) + +## [62.0.0] + +### Added + +- Add and export `getQuotesReceivedProperties` utility to build the metrics payload for clients ([#7182](https://github.com/MetaMask/core/pull/7182)) + +### Changed + +- Bump `@metamask/polling-controller` from `^15.0.0` to `^16.0.0` ([#7202](https://github.com/MetaMask/core/pull/7202)) +- Bump `@metamask/multichain-network-controller` from `^2.0.0` to `^3.0.0` ([#7202](https://github.com/MetaMask/core/pull/7202)) +- Bump `@metamask/gas-fee-controller` from `^25.0.0` to `^26.0.0` ([#7202](https://github.com/MetaMask/core/pull/7202)) +- Bump `@metamask/controller-utils` from `^11.15.0` to `^11.16.0` ([#7202](https://github.com/MetaMask/core/pull/7202)) +- **BREAKING:** Bump `@metamask/transaction-controller` from `^61.0.0` to `^62.0.0` ([#7202](https://github.com/MetaMask/core/pull/7202)) +- **BREAKING:** Bump `@metamask/network-controller` from `^25.0.0` to `^26.0.0` ([#7202](https://github.com/MetaMask/core/pull/7202)) +- **BREAKING:** Bump `@metamask/assets-controllers` from `^89.0.0` to `^90.0.0` ([#7202](https://github.com/MetaMask/core/pull/7202)) +- **BREAKING:** Bump `@metamask/accounts-controller` from `^34.0.0` to `^35.0.0` ([#7202](https://github.com/MetaMask/core/pull/7202)) + +## [61.0.0] + +### Changed + +- **BREAKING:** Bump `@metamask/assets-controller` from `^88.0.0` to `^89.0.0` ([#7179](https://github.com/MetaMask/core/pull/7179)) + +## [60.1.0] + +### Added + +- Added support for bridging and swapping tokens on the Tron blockchain ([#6862](https://github.com/MetaMask/core/pull/6862)) + +## [60.0.0] + +### Changed + +- **BREAKING:** Bump `@metamask/assets-controller` from `^87.0.0` to `^88.0.0` ([#7100](https://github.com/MetaMask/core/pull/7100)) + +## [59.0.0] + +### Added + +- Quotes as returned by `fetchQuotes` now include a `gasSponsored` property ([#6687](https://github.com/MetaMask/core/pull/6687)) + +### Changed + +- **BREAKING:** Bump `@metamask/assets-controller` from `^86.0.0` to `^87.0.0` ([#7043](https://github.com/MetaMask/core/pull/7043)) + +## [58.0.0] + +### Changed + +- **BREAKING:** Bump `@metamask/assets-controller` from `^85.0.0` to `^86.0.0` ([#7011](https://github.com/MetaMask/core/pull/7011)) +- **BREAKING:** `noFee` flag was replaced with `fee` flag in bridge api requests ([#6964](https://github.com/MetaMask/core/pull/6964)) + +## [57.0.0] + +### Changed + +- **BREAKING:** Bump `@metamask/assets-controller` from `^84.0.0` to `^85.0.0` ([#7003](https://github.com/MetaMask/core/pull/7003)) + +## [56.0.3] + +### Fixed + +- Removes all selectedNetworkClientId usages by finding network clients via srcChainId ([#6996](https://github.com/MetaMask/core/pull/6996)) + +## [56.0.2] + +### Fixed + +- Remove global selected network reference in `getBridgeERC20Allowance` handler ([#6994](https://github.com/MetaMask/core/pull/6994)) + +## [56.0.1] + +### Changed + +- Clean up SSE stream reader after use ([#6965](https://github.com/MetaMask/core/pull/6965)) + +### Fixed + +- Fix Bitcoin network fee computation by extracting `unsignedPsbtBase64` from Bitcoin trade objects and supporting `'priority'` fee type from Bitcoin snap ([#6932](https://github.com/MetaMask/core/pull/6932)) + +## [56.0.0] + +### Added + +- Add `BridgeControllerGetStateAction` and `BridgeControllerStateChangeEvent` types ([#6444](https://github.com/MetaMask/core/pull/6444)) + +### Changed + +- **BREAKING:** Use new `Messenger` from `@metamask/messenger` ([#6444](https://github.com/MetaMask/core/pull/6444)) + - Previously, `BridgeController` accepted a `RestrictedMessenger` instance from `@metamask/base-controller`. +- **BREAKING:** Metadata property `anonymous` renamed to `includeInDebugSnapshot` ([#6444](https://github.com/MetaMask/core/pull/6444)) +- **BREAKING:** Bump `@metamask/accounts-controller` from `^33.0.0` to `^34.0.0` ([#6962](https://github.com/MetaMask/core/pull/6962)) +- **BREAKING:** Bump `@metamask/network-controller` from `^24.0.0` to `^25.0.0` ([#6962](https://github.com/MetaMask/core/pull/6962)) +- **BREAKING:** Bump `@metamask/assets-controller` from `^83.0.0` to `^84.0.0` ([#6962](https://github.com/MetaMask/core/pull/6962)) +- **BREAKING:** Bump `@metamask/remote-feature-flag-controller` from `^1.6.0` to `^2.0.0` ([#6962](https://github.com/MetaMask/core/pull/6962)) +- **BREAKING:** Bump `@metamask/transaction-controller` from `^60.0.0` to `^61.0.0` ([#6962](https://github.com/MetaMask/core/pull/6962)) +- Bump `@metamask/base-controller` from `^8.4.2` to `^9.0.0` ([#6962](https://github.com/MetaMask/core/pull/6962)) +- Bump `@metamask/gas-fee-controller` from `^24.1.0` to `^25.0.0` ([#6940](https://github.com/MetaMask/core/pull/6940), [#6962](https://github.com/MetaMask/core/pull/6962)) +- Bump `@metamask/multichain-network-controller` from `^1.0.1` to `^2.0.0` ([#6940](https://github.com/MetaMask/core/pull/6940), [#6962](https://github.com/MetaMask/core/pull/6962)) +- Bump `@metamask/polling-controller` from `^14.0.1` to `^15.0.0` ([#6940](https://github.com/MetaMask/core/pull/6940), [#6962](https://github.com/MetaMask/core/pull/6962)) + +## [55.0.0] + +### Changed + +- **BREAKING:** Bump peer dependency `@metamask/assets-controllers` from `^82.0.0` to `^83.0.0` ([#6923](https://github.com/MetaMask/core/pull/6923)) +- Bump `@metamask/base-controller` from `^8.4.1` to `^8.4.2` ([#6917](https://github.com/MetaMask/core/pull/6917)) + +## [54.0.0] + +### Changed + +- **BREAKING:** Bump peer dependency `@metamask/assets-controllers` from `^81.0.0` to `^82.0.0` ([#6908](https://github.com/MetaMask/core/pull/6908)) + +## [53.1.0] + +### Added + +- Add `MONAD` network support ([#6828](https://github.com/MetaMask/core/pull/6828)) + - Add `MONAD` into constants `ALLOWED_BRIDGE_CHAIN_IDS`, `SWAPS_TOKEN_OBJECT` and `NETWORK_TO_NAME_MAP` +- Implement `fetchServerEvents` util that parses server events and parses them into JSON ([#6892](https://github.com/MetaMask/core/pull/6892)) + +### Changed + +- **BREAKING:** Add BitcoinTradeData to QuoteResponse validation ([#6892](https://github.com/MetaMask/core/pull/6892)) +- Replace `fetchEventSource` with `fetchServerEvents` ([#6892](https://github.com/MetaMask/core/pull/6892)) + +### Removed + +- Removed dependency on `@microsoft/fetch-event-source` at `^2.0.1` ([#6892](https://github.com/MetaMask/core/pull/6892)) + +## [53.0.0] + +### Changed + +- **BREAKING:** Require clientVersion in BridgeController constructor ([#6891](https://github.com/MetaMask/core/pull/6891)) +- Update the `sseEnabled` LD flag to include minimumVersion, which is used to determine whether to enable SSE ([#6891](https://github.com/MetaMask/core/pull/6891)) +- Bump `@metamask/network-controller` from `^24.2.2` to `^24.3.0` ([#6883](https://github.com/MetaMask/core/pull/6883)) +- Bump `@metamask/transaction-controller` from `^60.7.0` to `^60.8.0` ([#6883](https://github.com/MetaMask/core/pull/6883)) + +## [52.0.0] + +### Changed + +- **BREAKING:** Bump peer dependency `@metamask/assets-controllers` from `^80.0.0` to `^81.0.0` ([#6834](https://github.com/MetaMask/core/pull/6834)) + +## [51.0.0] + +### Added + +- Introduce server‑sent events quote streaming and integrates incremental quote updates into the bridge controller polling flow ([#6760](https://github.com/MetaMask/core/pull/6760)) + - Add private `handleQuoteStreaming` method that calls `getQuoteStream` when the `sseEnabled` flag is enabled in LaunchDarkly + - Reuse existing polling, metrics and validation utilities when processing server-sent quotes +- Add dependency on `@microsoft/fetch-event-source` at `^2.0.1` ([#6760](https://github.com/MetaMask/core/pull/6760)) + - Note that clients need to patch this library such that it rejects instead of resolving when the quote request is cancelled. This preserves the controller's expected request cancellation behavior + +### Changed + +- Extract some logic from bridge-controller and move them to utility files for better readability ([#6760](https://github.com/MetaMask/core/pull/6760)) + +### Removed + +- Remove cache options from spot-prices and getQuote api calls since they are only required by the extension client ([#6760](https://github.com/MetaMask/core/pull/6760)) + +### Fixed + +- Pass abortSignal to fetchAssetPricesForCurrency in order to cancel exchange rate fetching when quote parameters change ([#6760](https://github.com/MetaMask/core/pull/6760)) + +## [50.0.0] + +### Changed + +- **BREAKING:** Bump peer dependency `@metamask/assets-controllers` from `^79.0.0` to `^80.0.0` ([#6818](https://github.com/MetaMask/core/pull/6818)) + +## [49.0.1] + +### Changed + +- Bump `@metamask/base-controller` from `^8.4.0` to `^8.4.1` ([#6807](https://github.com/MetaMask/core/pull/6807)) +- Bump `@metamask/controller-utils` from `^11.14.0` to `^11.14.1` ([#6807](https://github.com/MetaMask/core/pull/6807)) +- Bump `@metamask/gas-fee-controller` from `^24.0.0` to `^24.1.0` ([#6807](https://github.com/MetaMask/core/pull/6807)) +- Bump `@metamask/multichain-network-controller` from `^1.0.0` to `^1.0.1` ([#6807](https://github.com/MetaMask/core/pull/6807)) +- Bump `@metamask/polling-controller` from `^14.0.0` to `^14.0.1` ([#6807](https://github.com/MetaMask/core/pull/6807)) + +## [49.0.0] + +### Changed + +- **BREAKING:** Bump peer dependency `@metamask/assets-controllers` from `^78.0.0` to `^79.0.0` ([#6806](https://github.com/MetaMask/core/pull/6806)) +- Add optional `Client-Version` header to bridge API requests ([#6791](https://github.com/MetaMask/core/pull/6791)) + +## [48.0.0] + +### Changed + +- **BREAKING:** Bump peer dependency `@metamask/assets-controllers` from `^77.0.0` to `^78.0.0` ([#6780](https://github.com/MetaMask/core/pull/6780)) + +## [47.2.0] + +### Added + +- Append quote's `featureId` to QuoteResponse object, if defined. Swap and bridge quotes have an `undefined` featureId value for backwards compatibility with old history entries ([#6739](https://github.com/MetaMask/core/pull/6739)) + +## [47.1.0] + +### Added + +- Add `bip44DefaultPairs` and `chains[chainId].defaultPairs` to feature flag types and validators ([#6645](https://github.com/MetaMask/core/pull/6645)) + +### Changed + +- Bump `@metamask/assets-controllers` from `77.0.0` to `77.0.1` ([#6747](https://github.com/MetaMask/core/pull/6747)) +- Bump `@metamask/transaction-controller` from `60.4.0` to `60.5.0` ([#6733](https://github.com/MetaMask/core/pull/6733)) + +## [47.0.0] + +### Changed + +- **BREAKING** Make `walletAddress` a required quote request parameter when calling the `updateBridgeQuoteRequestParams` handler ([#6719](https://github.com/MetaMask/core/pull/6719)) +- Bump `@metamask/utils` from `^11.8.0` to `^11.8.1` ([#6708](https://github.com/MetaMask/core/pull/6708)) + +### Removed + +- Deprecate the unused `SnapConfirmationViewed` event ([#6719](https://github.com/MetaMask/core/pull/6719)) + +### Fixed + +- Replace `AccountsController:getSelectedMultichainAccount` usages with AccountsController:getAccountByAddress` when retrieving Solana account details for quote metadata ([#6719](https://github.com/MetaMask/core/pull/6719)) + +## [46.0.0] + +### Added + +- Add support for Bitcoin bridge transactions ([#6705](https://github.com/MetaMask/core/pull/6705)) + - Handle Bitcoin PSBT (Partially Signed Bitcoin Transaction) format in trade data + - Support Bitcoin chain ID (`ChainId.BTC = 20000000000001`) and CAIP format (`bip122:000000000019d6689c085ae165831e93`) +- Export `isNonEvmChainId` utility function to check for non-EVM chains (Solana, Bitcoin) ([#6705](https://github.com/MetaMask/core/pull/6705)) + +### Changed + +- **BREAKING:** Rename fee handling for non-EVM chains ([#6705](https://github.com/MetaMask/core/pull/6705)) + - Replace `SolanaFees` type with `NonEvmFees` type (exported type) + - Replace `solanaFeesInLamports` property in quote responses with `nonEvmFeesInNative` property + - The `nonEvmFeesInNative` property stores fees in the native units for each chain (SOL for Solana, BTC for Bitcoin) +- **BREAKING:** Update Snap methods to use new unified interface for non-EVM chains ([#6705](https://github.com/MetaMask/core/pull/6705)) + - Snaps must now implement `computeFee` method instead of `getFeeForTransaction` for fee calculation + - The `computeFee` method returns fees in native token units rather than smallest units + +## [45.0.0] + +### Changed + +- Bump `@metamask/assets-controllers` from `^76.0.0` to `^77.0.0` ([#6716](https://github.com/MetaMask/core/pull/6716), [#6629](https://github.com/MetaMask/core/pull/6629)) + +## [44.0.1] + +### Changed + +- Revert accidental breaking changes included in v44.0.0 ([#6454](https://github.com/MetaMask/core/pull/6454)) + +## [44.0.0] [DEPRECATED] + +### Changed + +- This version was deprecated because it accidentally included additional breaking changes; use v44.0.1 or later versions instead +- **BREAKING:** Bump peer dependency `@metamask/assets-controllers` from `^75.0.0` to `^76.0.0` ([#6676](https://github.com/MetaMask/core/pull/6676)) + +## [43.2.1] + +### Added + +- Add Solana Devnet support to bridge controller ([#6670](https://github.com/MetaMask/core/pull/6670)) + +## [43.2.0] + +### Added + +- Add optional `noFeeAssets` property to the `ChainConfigurationSchema` type ([#6665](https://github.com/MetaMask/core/pull/6665)) + +## [43.1.0] + +### Added + +- Add `selectDefaultSlippagePercentage` that returns the default slippage for a chain and token combination ([#6616](https://github.com/MetaMask/core/pull/6616)) + - Return `0.5` if requesting a bridge quote + - Return `undefined` (auto) if requesting a Solana swap + - Return `0.5` if both tokens are stablecoins (based on dynamic `stablecoins` list from LD chain config) + - Return `2` for all other EVM swaps +- Add new controller metadata properties to `BridgeController` ([#6589](https://github.com/MetaMask/core/pull/6589)) + +### Changed + +- Bump `@metamask/controller-utils` from `^11.12.0` to `^11.14.0` ([#6620](https://github.com/MetaMask/core/pull/6620), [#6629](https://github.com/MetaMask/core/pull/6629)) +- Bump `@metamask/base-controller` from `^8.3.0` to `^8.4.0` ([#6632](https://github.com/MetaMask/core/pull/6632)) + +## [43.0.0] + +### Added + +- Add `totalFeeAmountUsd` to `quote` to support rewards estimation ([#6592](https://github.com/MetaMask/core/pull/6592)) + +### Changed + +- **BREAKING:** Bump peer dependency `@metamask/assets-controller` from `^74.0.0` to `^75.0.0` ([#6570](https://github.com/MetaMask/core/pull/6570)) +- Bump `@metamask/keyring-api` from `^20.1.0` to `^21.0.0` ([#6560](https://github.com/MetaMask/core/pull/6560)) +- Add optional `isGaslessSwapEnabled` LaunchDarkly config to feature flags schema ([#6573](https://github.com/MetaMask/core/pull/6573)) +- Bump `@metamask/utils` from `^11.4.2` to `^11.8.0` ([#6588](https://github.com/MetaMask/core/pull/6588)) + +## [42.0.0] + +### Added + +- Add `gas_included_7702` field to metrics tracking for EIP-7702 gasless transactions ([#6363](https://github.com/MetaMask/core/pull/6363)) + +### Changed + +- **BREAKING** Rename QuotesError and InputSourceDestinationSwitched events to match segment schema ([#6447](https://github.com/MetaMask/core/pull/6447)) +- Bump `@metamask/base-controller` from `^8.2.0` to `^8.3.0` ([#6465](https://github.com/MetaMask/core/pull/6465)) +- **BREAKING** Rename `gasless7702` to `gasIncluded7702` in QuoteRequest and Quote types + +## [41.4.0] + +### Added + +- Add Bitcoin as a supported bridge chain ([#6389](https://github.com/MetaMask/core/pull/6389)) +- Export `isBitcoinChainId` utility function ([#6389](https://github.com/MetaMask/core/pull/6389)) + +## [41.3.0] + +### Added + +- Publish `QuotesValidationFailed` and `StatusValidationFailed` events ([#6362](https://github.com/MetaMask/core/pull/6362)) + +## [41.2.0] + +### Changed + +- Update quotes to account for minDestTokenAmount ([#6373](https://github.com/MetaMask/core/pull/6373)) + +## [41.1.0] + +### Added + +- Add `UnifiedSwapBridgeEventName.AssetDetailTooltipClicked` event ([#6352](https://github.com/MetaMask/core/pull/6352)) + +### Changed + +- Bump `@metamask/base-controller` from `^8.1.0` to `^8.2.0` ([#6355](https://github.com/MetaMask/core/pull/6355)) + +## [41.0.0] + +### Added + +- Add `gasless7702` field to QuoteRequest and Quote types to support EIP-7702 delegated gasless execution ([#6346](https://github.com/MetaMask/core/pull/6346)) + +### Fixed + +- **BREAKING** Update the implementation of `UnifiedSwapBridgeEventName.Submitted` to require event publishers to provide all properties. This is in needed because the Submitted event can be published after the BridgeController's state has been reset ([#6314](https://github.com/MetaMask/core/pull/6314)) + +## [40.0.0] + +### Changed + +- **BREAKING:** Bump peer dependency `@metamask/accounts-controller` from `^32.0.0` to `^33.0.0` ([#6345](https://github.com/MetaMask/core/pull/6345)) +- **BREAKING:** Bump peer dependency `@metamask/assets-controller` from `^73.0.0` to `^74.0.0` ([#6345](https://github.com/MetaMask/core/pull/6345)) +- **BREAKING:** Bump peer dependency `@metamask/transaction-controller` from `^59.0.0` to `^60.0.0` ([#6345](https://github.com/MetaMask/core/pull/6345)) +- Bump accounts related packages ([#6309](https://github.com/MetaMask/core/pull/6309)) + - Bump `@metamask/keyring-api` from `^20.0.0` to `^20.1.0` +- Bump `@metamask/assets-controller` from `^73.2.0` to `^73.3.0` ([#6334](https://github.com/MetaMask/core/pull/6334)) + +## [39.1.0] + +### Fixed + +- Ignore error messages thrown when quote requests are cancelled. This prevents the `QuoteError` event from being published when an error is expected ([#6299](https://github.com/MetaMask/core/pull/6299)) + +## [39.0.1] + +### Changed + +- Bump `@metamask/controller-utils` from `^11.11.0` to `^11.12.0` ([#6303](https://github.com/MetaMask/core/pull/6303)) + +## [39.0.0] + +### Added + +- **BREAKING** Added the `effective`, `max` and `total` keys to the `QuoteMetadata.gasFee` type ([#6295](https://github.com/MetaMask/core/pull/6295)) +- Response validation for the QuoteReponse.trade.effectiveGas field ([#6295](https://github.com/MetaMask/core/pull/6295)) +- Calculate the effective gas (amount spent after refunds) for transactions and use it to sort quotes. This value is reflected in the `totalNetworkFee` ([#6295](https://github.com/MetaMask/core/pull/6295)) + - The `totalNetworkFee` should be displayed along with the client quotes + - The `totalMaxNetworkFee` should be used to disable tx submission + +### Changed + +- **BREAKING** Remove `getActionType` export and hardcode `action_type` to `swapbridge-v1`. Deprecate `crosschain-v1` MetricsActionType because it shouldn't be used after swaps and bridge are unified ([#6270](https://github.com/MetaMask/core/pull/6270)) +- Change default gas priority fee level from high -> medium to show more accurate estimates in the clients ([#6295](https://github.com/MetaMask/core/pull/6295)) +- Bump `@metamask/multichain-network-controller` from `^0.11.0` to `^0.11.1` ([#6273](https://github.com/MetaMask/core/pull/6273)) +- Bump `@metamask/base-controller` from `^8.0.1` to `^8.1.0` ([#6284](https://github.com/MetaMask/core/pull/6284)) + +## [38.0.0] + +### Fixed + +- **BREAKING** Require clients to define `can_submit` property when publishing `QuoteSelected`, `AllQuotesSorted`, `AllQuotesOpened` and `QuotesReceived` events ([#6254](https://github.com/MetaMask/core/pull/6254)) +- Rename the InputChanged event's `value` property key to `input_value` ([#6254](https://github.com/MetaMask/core/pull/6254)) + +## [37.2.0] + +### Added + +- Expose `fetchQuotes` method that returns a list of quotes directly rather than adding them to the controller state. This enables clients to retrieve quotes directly without automatic polling and state management ([#6236](https://github.com/MetaMask/core/pull/6236)) + +### Changed + +- Bump `@metamask/keyring-api` from `^19.0.0` to `^20.0.0` ([#6248](https://github.com/MetaMask/core/pull/6248)) + +## [37.1.0] + +### Added + +- Add schema for the new price impact threshold feature flag to the types for PlatformConfigSchema ([#6223](https://github.com/MetaMask/core/pull/6223)) + +## [37.0.0] + +### Changed + +- **BREAKING:** Bump peer dependency `@metamask/accounts-controller` from `^31.0.0` to `^32.0.0` ([#6171](https://github.com/MetaMask/core/pull/6171)) +- **BREAKING:** Bump peer dependency `@metamask/assets-controllers` from `^72.0.0` to `^73.0.0` ([#6171](https://github.com/MetaMask/core/pull/6171)) +- **BREAKING:** Bump peer dependency `@metamask/transaction-controller` from `^58.0.0` to `^59.0.0`, ([#6171](https://github.com/MetaMask/core/pull/6171), [#6027](https://github.com/MetaMask/core/pull/6027)) + +## [36.2.0] + +### Changed + +- Bump `@metamask/keyring-api` from `^18.0.0` to `^19.0.0` ([#6146](https://github.com/MetaMask/core/pull/6146)) + +## [36.1.0] + +### Changed + +- Include EVM assetIds in `isNativeAddress` util when checking whether an address string is a native token ([#6076](https://github.com/MetaMask/core/pull/6076)) + +## [36.0.0] + +### Changed + +- Bump `@metamask/multichain-network-controller` from `^0.9.0` to `^0.10.0` ([#6114](https://github.com/MetaMask/core/pull/6114)) +- **BREAKING** Require `destWalletAddress` in `isValidQuoteRequest` if bridging to or from Solana ([#6091](https://github.com/MetaMask/core/pull/6091)) +- Bump `@metamask/assets-controllers` to `^72.0.0` ([#6120](https://github.com/MetaMask/core/pull/6120)) + +## [35.0.0] + +### Added + +- Add an optional `isSingleSwapBridgeButtonEnabled` feature flag that indicates whether Swap and Bridge entrypoints should be combined ([#6078](https://github.com/MetaMask/core/pull/6078)) + +### Changed + +- **BREAKING:** Bump peer dependency `@metamask/assets-controllers` from `^69.0.0` to `^71.0.0` ([#6061](https://github.com/MetaMask/core/pull/6061), [#6098](https://github.com/MetaMask/core/pull/6098)) +- **BREAKING:** Bump peer dependency `@metamask/snaps-controllers` from `^12.0.0` to `^14.0.0` ([#6035](https://github.com/MetaMask/core/pull/6035)) +- **BREAKING** Remove `isSnapConfirmationEnabled` feature flag from `ChainConfigurationSchema` validation ([#6077](https://github.com/MetaMask/core/pull/6077)) +- Bump `@metamask/controller-utils` from `^11.10.0` to `^11.11.0` ([#6069](https://github.com/MetaMask/core/pull/6069)) +- Bump `@metamask/utils` from `^11.2.0` to `^11.4.2` ([#6054](https://github.com/MetaMask/core/pull/6054)) + +## [34.0.0] + +### Added + +- **BREAKING** Add a required `gasIncluded` quote request parameter to indicate whether the bridge-api should return gasless swap quotes. The clients need to pass in a Boolean value indicating whether the user is opted in to STX and if their current network has STX support ([#6030](https://github.com/MetaMask/core/pull/6030)) +- Add `gasIncluded` to QuoteResponse, which indicates whether the quote includes tx fees (gas-less) ([#6030](https://github.com/MetaMask/core/pull/6030)) +- Add `feeData.txFees` to QuoteResponse, which contains data about tx fees taken from either the source or destination asset ([#6030](https://github.com/MetaMask/core/pull/6030)) +- Add `includedTxFees` to QuoteMetadata, which clients can display as the included tx fee when displaying a gasless quote ([#6039](https://github.com/MetaMask/core/pull/6039)) +- Calculate and return value of `includedTxFees` ([#6039](https://github.com/MetaMask/core/pull/6039)) + +### Changed + +- Consolidate validator and type definitions for `QuoteResponse`, `BridgeAsset` and `PlatformConfigSchema` so new response fields only need to be defined once ([#6030](https://github.com/MetaMask/core/pull/6030)) +- Add `txFees` to total sentAmount ([#6039](https://github.com/MetaMask/core/pull/6039)) +- When gas is included and is taken from the destination token amount, ignore network fees in `adjustedReturn` calculation ([#6039](https://github.com/MetaMask/core/pull/6039)) + +### Fixed + +- Calculate EVM token exchange rates accurately in `selectExchangeRateByChainIdAndAddress` when the `marketData` conversion rate is in the native currency ([#6030](https://github.com/MetaMask/core/pull/6030)) +- Convert `trade.value` to decimal when calculating relayer fee ([#6039](https://github.com/MetaMask/core/pull/6039)) +- Revert QuoteResponse ChainId schema to expect a number instead of a string ([#6045](https://github.com/MetaMask/core/pull/6045)) + +## [33.0.1] + +### Fixed + +- Set correct `can_submit` property on Unified SwapBridge events ([#5993](https://github.com/MetaMask/core/pull/5993)) +- Use activeQuote to populate default properties for Submitted and Failed events, if tx fails before being confirmed on chain ([#5993](https://github.com/MetaMask/core/pull/5993)) + +## [33.0.0] + +### Added + +- Add `stopPollingForQuotes` handler that stops quote polling without resetting the bridge controller's state ([#5994](https://github.com/MetaMask/core/pull/5994)) + +### Changed + +- **BREAKING:** Bump peer dependency `@metamask/accounts-controller` to `^31.0.0` ([#5999](https://github.com/MetaMask/core/pull/5999)) +- **BREAKING:** Bump peer dependency `@metamask/assets-controller` to `^69.0.0` ([#5999](https://github.com/MetaMask/core/pull/5999)) +- **BREAKING:** Bump peer dependency `@metamask/network-controller` to `^24.0.0` ([#5999](https://github.com/MetaMask/core/pull/5999)) +- **BREAKING:** Bump peer dependency `@metamask/transaction-controller` to `^58.0.0` ([#5999](https://github.com/MetaMask/core/pull/5999)) +- Bump dependency `@metamask/gas-fee-controller` to `^24.0.0` ([#5999](https://github.com/MetaMask/core/pull/5999)) +- Bump dependency `@metamask/multichain-network-controller` to `^0.9.0` ([#5999](https://github.com/MetaMask/core/pull/5999)) +- Bump dependency `@metamask/polling-controller` to `^14.0.0` ([#5999](https://github.com/MetaMask/core/pull/5999)) + +## [32.2.0] + +### Changed + +- Export feature flag util for bridge status controller ([#5961](https://github.com/MetaMask/core/pull/5961)) + +## [32.1.2] + +### Changed + +- Bump `@metamask/controller-utils` to `^11.10.0` ([#5935](https://github.com/MetaMask/core/pull/5935)) +- Bump `@metamask/transaction-controller` to `^57.3.0` ([#5954](https://github.com/MetaMask/core/pull/5954)) + +## [32.1.1] + +### Fixed + +- Fetch `minimumBalanceForRentExemptionInLamports` asynchronously to prevent blocking the getQuote network call ([#5921](https://github.com/MetaMask/core/pull/5921)) +- Fix invalid `getMinimumBalanceForRentExemption` commitment parameter ([#5921](https://github.com/MetaMask/core/pull/5921)) + +## [32.1.0] + +### Added + +- Include all invalid quote properties in sentry logs ([#5913](https://github.com/MetaMask/core/pull/5913)) + +## [32.0.1] + +### Fixed + +- Remove `error_message` property from QuotesRequested event payload ([#5900](https://github.com/MetaMask/core/pull/5900)) +- Fail gracefully when fee calculations return invalid value or throw errors + - Filter out single quote if `TransactionController.getLayer1GasFee` returns `undefined` ([#5910](https://github.com/MetaMask/core/pull/5910)) + - Filter out single quote if an error is thrown by `getLayer1GasFee` ([#5910](https://github.com/MetaMask/core/pull/5910)) + - Filter out single quote if an error is thrown by Solana snap's `getFeeForTransaction` method ([#5910](https://github.com/MetaMask/core/pull/5910)) + +## [32.0.0] + +### Added + +- **BREAKING:** Add required property `minimumBalanceForRentExemptionInLamports` to `BridgeState` ([#5827](https://github.com/MetaMask/core/pull/5827)) +- Add selector `selectMinimumBalanceForRentExemptionInSOL` ([#5827](https://github.com/MetaMask/core/pull/5827)) + +### Changed + +- Add new dependency `uuid` ([#5827](https://github.com/MetaMask/core/pull/5827)) + +## [31.0.0] + +### Added + +- Add `SEI` network support ([#5695](https://github.com/MetaMask/core/pull/5695)) + - Add `SEI` into constants `ALLOWED_BRIDGE_CHAIN_IDS`, `SWAPS_TOKEN_OBJECT` and `NETWORK_TO_NAME_MAP` + +### Changed + +- **BREAKING:** Bump `@metamask/assets-controller` peer dependency to `^68.0.0` ([#5894](https://github.com/MetaMask/core/pull/5894)) + +## [30.0.0] + +### Changed + +- **BREAKING:** Bump `@metamask/assets-controller` peer dependency to `^67.0.0` ([#5888](https://github.com/MetaMask/core/pull/5888)) +- **BREAKING:** Bump `@metamask/accounts-controller` peer dependency to `^30.0.0` ([#5888](https://github.com/MetaMask/core/pull/5888)) +- **BREAKING:** Bump `@metamask/transaction-controller` peer dependency to `^57.0.0` ([#5888](https://github.com/MetaMask/core/pull/5888)) +- **BREAKING:** Bump `@metamask/snaps-controllers` peer dependency from `^11.0.0` to `^12.0.0` ([#5871](https://github.com/MetaMask/core/pull/5871)) +- Bump `@metamask/keyring-api` dependency from `^17.4.0` to `^18.0.0` ([#5871](https://github.com/MetaMask/core/pull/5871)) + +## [29.0.0] + +### Changed + +- **BREAKING:** Bump `@metamask/assets-controller` peer dependency to `^66.0.0` ([#5872](https://github.com/MetaMask/core/pull/5872)) + +## [28.0.0] + +### Changed + +- **BREAKING:** Bump `@metamask/assets-controller` peer dependency to `^65.0.0` ([#5863](https://github.com/MetaMask/core/pull/5863)) + +## [27.0.0] + +### Changed + +- **BREAKING:** Bump `@metamask/assets-controller` peer dependency to `^64.0.0` ([#5854](https://github.com/MetaMask/core/pull/5854)) + +## [26.0.0] + +### Added + +- **BREAKING:** Added a required `minimumVersion` to feature flag response schema ([#5834](https://github.com/MetaMask/core/pull/5834)) + +### Changed + +- Consume `bridgeConfigV2` in the feature flag response schema for Mobile and export `DEFAULT_FEATURE_FLAG_CONFIG` ([#5837](https://github.com/MetaMask/core/pull/5837)) + +## [25.1.0] + +### Added + +- Added optional `isUnifiedUIEnabled` flag to chain-level feature-flag `ChainConfiguration` type and updated the validation schema to accept the new flag ([#5783](https://github.com/MetaMask/core/pull/5783)) +- Add and export `calcSlippagePercentage`, a utility that calculates the absolute slippage percentage based on the adjusted return and the sent amount. ([#5723](https://github.com/MetaMask/core/pull/5723)) +- Error logs for invalid getQuote responses ([#5816](https://github.com/MetaMask/core/pull/5816)) + +### Changed + +- Bump `@metamask/controller-utils` to `^11.9.0` ([#5812](https://github.com/MetaMask/core/pull/5812)) + +## [25.0.1] + +### Fixed + +- Use zero address as solana's default native address instead of assetId ([#5799](https://github.com/MetaMask/core/pull/5799)) + +## [25.0.0] + +### Changed + +- **BREAKING:** bump `@metamask/accounts-controller` peer dependency to `^29.0.0` ([#5802](https://github.com/MetaMask/core/pull/5802)) +- **BREAKING:** bump `@metamask/assets-controllers` peer dependency to `^63.0.0` ([#5802](https://github.com/MetaMask/core/pull/5802)) +- **BREAKING:** bump `@metamask/transaction-controller` peer dependency to `^56.0.0` ([#5802](https://github.com/MetaMask/core/pull/5802)) + +## [24.0.0] + +### Added + +- Sentry traces for `BridgeQuotesFetched` and `SwapQuotesFetched` events ([#5780](https://github.com/MetaMask/core/pull/5780)) +- Export `isCrossChain` utility ([#5780](https://github.com/MetaMask/core/pull/5780)) + +### Changed + +- **BREAKING:** Remove `BridgeToken` export ([#5768](https://github.com/MetaMask/core/pull/5768)) +- `traceFn` added to BridgeController constructor to enable clients to pass in a custom sentry trace handler ([#5768](https://github.com/MetaMask/core/pull/5768)) + +## [23.0.0] + +### Changed + +- **BREAKING** Rename `QuoteResponse.bridgePriceData` to `priceData` ([#5784](https://github.com/MetaMask/core/pull/5784)) + +### Fixed + +- Handle cancelled bridge quote polling gracefully by skipping state updates ([#5787](https://github.com/MetaMask/core/pull/5787)) + +## [22.0.0] + +### Changed + +- **BREAKING:** Bump `@metamask/assets-controller` peer dependency to `^62.0.0` ([#5780](https://github.com/MetaMask/core/pull/5780)) +- Bump `@metamask/controller-utils` to `^11.8.0` ([#5765](https://github.com/MetaMask/core/pull/5765)) + +## [21.0.0] + +### Changed + +- **BREAKING:** Bump `@metamask/accounts-controller` peer dependency to `^28.0.0` ([#5763](https://github.com/MetaMask/core/pull/5763)) +- **BREAKING:** Bump `@metamask/assets-controller` peer dependency to `^61.0.0` ([#5763](https://github.com/MetaMask/core/pull/5763)) +- **BREAKING:** Bump `@metamask/transaction-controller` peer dependency to `^55.0.0` ([#5763](https://github.com/MetaMask/core/pull/5763)) + +## [20.0.0] + +### Changed + +- Bump `@metamask/base-controller` from ^8.0.0 to ^8.0.1 ([#5722](https://github.com/MetaMask/core/pull/5722)) +- Update `Quote` type with `bridgePriceData`, which includes metadata about transferred amounts and the trade's priceImpact ([#5721](https://github.com/MetaMask/core/pull/5721)) +- Include submitted quote's `priceImpact` as a property in analytics events ([#5721](https://github.com/MetaMask/core/pull/5721)) +- **BREAKING:** Add additional required properties to Submitted, Completed, Failed and SnapConfirmationViewed events ([#5721](https://github.com/MetaMask/core/pull/5721)) +- **BREAKING:** Use `RemoteFeatureFlagController` to fetch feature flags, removed client specific feature flag keys. The feature flags you receive are now client specific based on the `RemoteFeatureFlagController` state. ([#5708](https://github.com/MetaMask/core/pull/5708)) + +### Fixed + +- Update MetricsSwapType.SINGLE to `single_chain` to match segment events schema ([#5721](https://github.com/MetaMask/core/pull/5721)) + +## [19.0.0] + +### Changed + +- **BREAKING:** Bump `@metamask/assets-controllers` peer dependency to `^60.0.0` ([#5717](https://github.com/MetaMask/core/pull/5717)) + +## [18.0.0] + +### Changed + +- **BREAKING:** Bump `@metamask/assets-controllers` peer dependency to `^59.0.0` ([#5712](https://github.com/MetaMask/core/pull/5712)) + +## [17.0.0] + +### Added + +- Add analytics events for the Unified SwapBridge experience ([#5684](https://github.com/MetaMask/core/pull/5684)) + +### Changed + +- Bump `@metamask/multichain-network-controller` dependency to `^0.5.1` ([#5678](https://github.com/MetaMask/core/pull/5678)) +- **BREAKING:** trackMetaMetricsFn added to BridgeController constructor to enable clients to pass in a custom analytics handler ([#5684](https://github.com/MetaMask/core/pull/5684)) +- **BREAKING:** added a context argument to `updateBridgeQuoteRequestParams` to provide values required for analytics events ([#5684](https://github.com/MetaMask/core/pull/5684)) + +### Fixed + +- Fixes undefined native EVM exchange rates and snap handler calls ([#5696](https://github.com/MetaMask/core/pull/5696)) + +## [16.0.0] + +### Changed + +- **BREAKING** Bump `@metamask/assets-controllers` peer dependency to `^58.0.0` ([#5672](https://github.com/MetaMask/core/pull/5672)) +- **BREAKING** Bump `@metamask/snaps-controllers` peer dependency from ^9.19.0 to ^11.0.0 ([#5639](https://github.com/MetaMask/core/pull/5639)) +- Bump `@metamask/multichain-network-controller` dependency to `^0.5.0` ([#5669](https://github.com/MetaMask/core/pull/5669)) + +## [15.0.0] + +### Changed + +- **BREAKING:** Bump `@metamask/assets-controllers` peer dependency to `^57.0.0` ([#5665](https://github.com/MetaMask/core/pull/5665)) + +## [14.0.0] + +### Added + +- **BREAKING:** Add `@metamask/assets-controllers` as a required peer dependency at `^56.0.0` ([#5614](https://github.com/MetaMask/core/pull/5614)) +- Add `reselect` as a dependency at `^5.1.1` ([#5614](https://github.com/MetaMask/core/pull/5614)) +- **BREAKING:** assetExchangeRates added to BridgeController state to support tokens which are not supported by assets controllers ([#5614](https://github.com/MetaMask/core/pull/5614)) +- selectExchangeRateByChainIdAndAddress selector added, which looks up exchange rates from assets and bridge controller states ([#5614](https://github.com/MetaMask/core/pull/5614)) +- selectBridgeQuotes selector added, which returns sorted quotes including their metadata ([#5614](https://github.com/MetaMask/core/pull/5614)) +- selectIsQuoteExpired selector added, which returns whether quotes are expired or stale ([#5614](https://github.com/MetaMask/core/pull/5614)) + +### Changed + +- **BREAKING:** Change TokenAmountValues key types from BigNumber to string ([#5614](https://github.com/MetaMask/core/pull/5614)) +- **BREAKING:** Assets controller getState actions have been added to `AllowedActions` so clients will need to include `TokenRatesController:getState`,`MultichainAssetsRatesController:getState` and `CurrencyRateController:getState` in controller initializations ([#5614](https://github.com/MetaMask/core/pull/5614)) +- Make srcAsset and destAsset optional in Step type to be optional ([#5614](https://github.com/MetaMask/core/pull/5614)) +- Make QuoteResponse trade generic to support Solana quotes which have string trade data ([#5614](https://github.com/MetaMask/core/pull/5614)) +- Bump `@metamask/multichain-network-controller` peer dependency to `^0.4.0` ([#5649](https://github.com/MetaMask/core/pull/5649)) + +## [13.0.0] + +### Changed + +- **BREAKING:** Bump `@metamask/transaction-controller` peer dependency to `^54.0.0` ([#5615](https://github.com/MetaMask/core/pull/5615)) + +## [12.0.0] + +### Added + +- Occurrences added to BridgeToken type ([#5572](https://github.com/MetaMask/core/pull/5572)) + +### Changed + +- **BREAKING:** Bump `@metamask/transaction-controller` peer dependency to `^53.0.0` ([#5585](https://github.com/MetaMask/core/pull/5585)) +- Bump `@metamask/controller-utils` to `^11.7.0` ([#5583](https://github.com/MetaMask/core/pull/5583)) + +## [11.0.0] + +### Added + +- BREAKING: Bump dependency @metamask/keyring-api to ^17.2.0 ([#5486](https://github.com/MetaMask/core/pull/5486)) +- BREAKING: Bump dependency @metamask/multichain-network-controller to ^0.3.0 ([#5486](https://github.com/MetaMask/core/pull/5486)) +- BREAKING: Bump dependency @metamask/snaps-utils to ^8.10.0 ([#5486](https://github.com/MetaMask/core/pull/5486)) +- BREAKING: Bump peer dependency @metamask/snaps-controllers to ^9.19.0 ([#5486](https://github.com/MetaMask/core/pull/5486)) +- Solana constants, utils, quote and token support ([#5486](https://github.com/MetaMask/core/pull/5486)) +- Utilities to convert chainIds between `ChainId`, `Hex`, `string` and `CaipChainId` ([#5486](https://github.com/MetaMask/core/pull/5486)) +- Add `refreshRate` feature flag to enable chain-specific quote refresh intervals ([#5486](https://github.com/MetaMask/core/pull/5486)) +- `isNativeAddress` and `isSolanaChainId` utilities that can be used by both the controller and clients ([#5486](https://github.com/MetaMask/core/pull/5486)) + +### Changed + +- Replace QuoteRequest usages with `GenericQuoteRequest` to support both EVM and multichain input parameters ([#5486](https://github.com/MetaMask/core/pull/5486)) +- Make `QuoteRequest.slippage` optional ([#5486](https://github.com/MetaMask/core/pull/5486)) +- Deprecate `SwapsTokenObject` and replace usages with multichain BridgeAsset ([#5486](https://github.com/MetaMask/core/pull/5486)) +- Changed `bridgeFeatureFlags.extensionConfig.chains` to key configs by CAIP chainIds ([#5486](https://github.com/MetaMask/core/pull/5486)) + +## [10.0.0] + +### Changed + +- **BREAKING:** Bump `@metamask/transaction-controller` peer dependency to `^52.0.0` ([#5513](https://github.com/MetaMask/core/pull/5513)) + +## [9.0.0] + +### Changed + +- **BREAKING:** Bump peer dependency `@metamask/accounts-controller` to `^27.0.0` ([#5507](https://github.com/MetaMask/core/pull/5507)) +- **BREAKING:** Bump peer dependency `@metamask/network-controller` to `^23.0.0` ([#5507](https://github.com/MetaMask/core/pull/5507)) +- **BREAKING:** Bump peer dependency `@metamask/transaction-controller` to `^51.0.0` ([#5507](https://github.com/MetaMask/core/pull/5507)) +- Bump `@metamask/polling-controller` to `^13.0.0` ([#5507](https://github.com/MetaMask/core/pull/5507)) + +## [8.0.0] + +### Changed + +- **BREAKING:** Bump `@metamask/transaction-controller` peer dependency to `^50.0.0` ([#5496](https://github.com/MetaMask/core/pull/5496)) + +## [7.0.0] + +### Changed + +- Bump `@metamask/accounts-controller` dev dependency to `^26.1.0` ([#5481](https://github.com/MetaMask/core/pull/5481)) +- **BREAKING:** Allow changing the Bridge API url through the `config` param in the constructor. Remove previous method of doing it through `process.env`. ([#5465](https://github.com/MetaMask/core/pull/5465)) + +### Fixed + +- Make `QuoteResponse.approval` optional to align with response from API ([#5475](https://github.com/MetaMask/core/pull/5475)) +- Export enums properly rather than as types ([#5466](https://github.com/MetaMask/core/pull/5466)) + +## [6.0.0] + +### Changed + +- **BREAKING:** Bump `@metamask/transaction-controller` peer dependency to `^49.0.0` ([#5471](https://github.com/MetaMask/core/pull/5471)) + +## [5.0.0] + +### Changed + +- **BREAKING:** Bump `@metamask/accounts-controller` peer dependency to `^26.0.0` ([#5439](https://github.com/MetaMask/core/pull/5439)) +- **BREAKING:** Bump `@metamask/transaction-controller` peer dependency to `^48.0.0` ([#5439](https://github.com/MetaMask/core/pull/5439)) + +## [4.0.0] + +### Changed + +- **BREAKING:** Bump `@metamask/accounts-controller` peer dependency to `^25.0.0` ([#5426](https://github.com/MetaMask/core/pull/5426)) +- **BREAKING:** Bump `@metamask/transaction-controller` peer dependency to `^47.0.0` ([#5426](https://github.com/MetaMask/core/pull/5426)) + +## [3.0.0] + +### Changed + +- **BREAKING:** Switch over from `ethers` at v6 to `@ethersproject` packages at v5.7.0 for mobile compatibility ([#5416](https://github.com/MetaMask/core/pull/5416)) +- Improve `BridgeController` API response validation readability by using `@metamask/superstruct` ([#5408](https://github.com/MetaMask/core/pull/5408)) + +## [2.0.0] + +### Added + +- Mobile feature flags ([#5359](https://github.com/MetaMask/core/pull/5359)) + +### Changed + +- **BREAKING:** Change `BridgeController` state structure to have all fields at root of state ([#5406](https://github.com/MetaMask/core/pull/5406)) +- **BREAKING:** Change `BridgeController` state defaults to `null` instead of `undefined` ([#5406](https://github.com/MetaMask/core/pull/5406)) + +## [1.0.0] + +### Added + +- Initial release ([#5317](https://github.com/MetaMask/core/pull/5317)) + +[Unreleased]: https://github.com/MetaMask/core/compare/@metamask/bridge-controller@80.1.0...HEAD +[80.1.0]: https://github.com/MetaMask/core/compare/@metamask/bridge-controller@80.0.0...@metamask/bridge-controller@80.1.0 +[80.0.0]: https://github.com/MetaMask/core/compare/@metamask/bridge-controller@79.3.1...@metamask/bridge-controller@80.0.0 +[79.3.1]: https://github.com/MetaMask/core/compare/@metamask/bridge-controller@79.3.0...@metamask/bridge-controller@79.3.1 +[79.3.0]: https://github.com/MetaMask/core/compare/@metamask/bridge-controller@79.2.0...@metamask/bridge-controller@79.3.0 +[79.2.0]: https://github.com/MetaMask/core/compare/@metamask/bridge-controller@79.1.0...@metamask/bridge-controller@79.2.0 +[79.1.0]: https://github.com/MetaMask/core/compare/@metamask/bridge-controller@79.0.1...@metamask/bridge-controller@79.1.0 +[79.0.1]: https://github.com/MetaMask/core/compare/@metamask/bridge-controller@79.0.0...@metamask/bridge-controller@79.0.1 +[79.0.0]: https://github.com/MetaMask/core/compare/@metamask/bridge-controller@78.1.0...@metamask/bridge-controller@79.0.0 +[78.1.0]: https://github.com/MetaMask/core/compare/@metamask/bridge-controller@78.0.3...@metamask/bridge-controller@78.1.0 +[78.0.3]: https://github.com/MetaMask/core/compare/@metamask/bridge-controller@78.0.2...@metamask/bridge-controller@78.0.3 +[78.0.2]: https://github.com/MetaMask/core/compare/@metamask/bridge-controller@78.0.1...@metamask/bridge-controller@78.0.2 +[78.0.1]: https://github.com/MetaMask/core/compare/@metamask/bridge-controller@78.0.0...@metamask/bridge-controller@78.0.1 +[78.0.0]: https://github.com/MetaMask/core/compare/@metamask/bridge-controller@77.8.0...@metamask/bridge-controller@78.0.0 +[77.8.0]: https://github.com/MetaMask/core/compare/@metamask/bridge-controller@77.7.0...@metamask/bridge-controller@77.8.0 +[77.7.0]: https://github.com/MetaMask/core/compare/@metamask/bridge-controller@77.6.0...@metamask/bridge-controller@77.7.0 +[77.6.0]: https://github.com/MetaMask/core/compare/@metamask/bridge-controller@77.5.0...@metamask/bridge-controller@77.6.0 +[77.5.0]: https://github.com/MetaMask/core/compare/@metamask/bridge-controller@77.4.1...@metamask/bridge-controller@77.5.0 +[77.4.1]: https://github.com/MetaMask/core/compare/@metamask/bridge-controller@77.4.0...@metamask/bridge-controller@77.4.1 +[77.4.0]: https://github.com/MetaMask/core/compare/@metamask/bridge-controller@77.3.2...@metamask/bridge-controller@77.4.0 +[77.3.2]: https://github.com/MetaMask/core/compare/@metamask/bridge-controller@77.3.1...@metamask/bridge-controller@77.3.2 +[77.3.1]: https://github.com/MetaMask/core/compare/@metamask/bridge-controller@77.3.0...@metamask/bridge-controller@77.3.1 +[77.3.0]: https://github.com/MetaMask/core/compare/@metamask/bridge-controller@77.2.0...@metamask/bridge-controller@77.3.0 +[77.2.0]: https://github.com/MetaMask/core/compare/@metamask/bridge-controller@77.1.0...@metamask/bridge-controller@77.2.0 +[77.1.0]: https://github.com/MetaMask/core/compare/@metamask/bridge-controller@77.0.0...@metamask/bridge-controller@77.1.0 +[77.0.0]: https://github.com/MetaMask/core/compare/@metamask/bridge-controller@76.1.0...@metamask/bridge-controller@77.0.0 +[76.1.0]: https://github.com/MetaMask/core/compare/@metamask/bridge-controller@76.0.0...@metamask/bridge-controller@76.1.0 +[76.0.0]: https://github.com/MetaMask/core/compare/@metamask/bridge-controller@75.2.1...@metamask/bridge-controller@76.0.0 +[75.2.1]: https://github.com/MetaMask/core/compare/@metamask/bridge-controller@75.2.0...@metamask/bridge-controller@75.2.1 +[75.2.0]: https://github.com/MetaMask/core/compare/@metamask/bridge-controller@75.1.1...@metamask/bridge-controller@75.2.0 +[75.1.1]: https://github.com/MetaMask/core/compare/@metamask/bridge-controller@75.1.0...@metamask/bridge-controller@75.1.1 +[75.1.0]: https://github.com/MetaMask/core/compare/@metamask/bridge-controller@75.0.0...@metamask/bridge-controller@75.1.0 +[75.0.0]: https://github.com/MetaMask/core/compare/@metamask/bridge-controller@74.0.0...@metamask/bridge-controller@75.0.0 +[74.0.0]: https://github.com/MetaMask/core/compare/@metamask/bridge-controller@73.2.1...@metamask/bridge-controller@74.0.0 +[73.2.1]: https://github.com/MetaMask/core/compare/@metamask/bridge-controller@73.2.0...@metamask/bridge-controller@73.2.1 +[73.2.0]: https://github.com/MetaMask/core/compare/@metamask/bridge-controller@73.1.0...@metamask/bridge-controller@73.2.0 +[73.1.0]: https://github.com/MetaMask/core/compare/@metamask/bridge-controller@73.0.1...@metamask/bridge-controller@73.1.0 +[73.0.1]: https://github.com/MetaMask/core/compare/@metamask/bridge-controller@73.0.0...@metamask/bridge-controller@73.0.1 +[73.0.0]: https://github.com/MetaMask/core/compare/@metamask/bridge-controller@72.0.4...@metamask/bridge-controller@73.0.0 +[72.0.4]: https://github.com/MetaMask/core/compare/@metamask/bridge-controller@72.0.3...@metamask/bridge-controller@72.0.4 +[72.0.3]: https://github.com/MetaMask/core/compare/@metamask/bridge-controller@72.0.2...@metamask/bridge-controller@72.0.3 +[72.0.2]: https://github.com/MetaMask/core/compare/@metamask/bridge-controller@72.0.1...@metamask/bridge-controller@72.0.2 +[72.0.1]: https://github.com/MetaMask/core/compare/@metamask/bridge-controller@72.0.0...@metamask/bridge-controller@72.0.1 +[72.0.0]: https://github.com/MetaMask/core/compare/@metamask/bridge-controller@71.1.1...@metamask/bridge-controller@72.0.0 +[71.1.1]: https://github.com/MetaMask/core/compare/@metamask/bridge-controller@71.1.0...@metamask/bridge-controller@71.1.1 +[71.1.0]: https://github.com/MetaMask/core/compare/@metamask/bridge-controller@71.0.0...@metamask/bridge-controller@71.1.0 +[71.0.0]: https://github.com/MetaMask/core/compare/@metamask/bridge-controller@70.2.0...@metamask/bridge-controller@71.0.0 +[70.2.0]: https://github.com/MetaMask/core/compare/@metamask/bridge-controller@70.1.1...@metamask/bridge-controller@70.2.0 +[70.1.1]: https://github.com/MetaMask/core/compare/@metamask/bridge-controller@70.1.0...@metamask/bridge-controller@70.1.1 +[70.1.0]: https://github.com/MetaMask/core/compare/@metamask/bridge-controller@70.0.1...@metamask/bridge-controller@70.1.0 +[70.0.1]: https://github.com/MetaMask/core/compare/@metamask/bridge-controller@70.0.0...@metamask/bridge-controller@70.0.1 +[70.0.0]: https://github.com/MetaMask/core/compare/@metamask/bridge-controller@69.2.3...@metamask/bridge-controller@70.0.0 +[69.2.3]: https://github.com/MetaMask/core/compare/@metamask/bridge-controller@69.2.2...@metamask/bridge-controller@69.2.3 +[69.2.2]: https://github.com/MetaMask/core/compare/@metamask/bridge-controller@69.2.1...@metamask/bridge-controller@69.2.2 +[69.2.1]: https://github.com/MetaMask/core/compare/@metamask/bridge-controller@69.2.0...@metamask/bridge-controller@69.2.1 +[69.2.0]: https://github.com/MetaMask/core/compare/@metamask/bridge-controller@69.1.1...@metamask/bridge-controller@69.2.0 +[69.1.1]: https://github.com/MetaMask/core/compare/@metamask/bridge-controller@69.1.0...@metamask/bridge-controller@69.1.1 +[69.1.0]: https://github.com/MetaMask/core/compare/@metamask/bridge-controller@69.0.1...@metamask/bridge-controller@69.1.0 +[69.0.1]: https://github.com/MetaMask/core/compare/@metamask/bridge-controller@69.0.0...@metamask/bridge-controller@69.0.1 +[69.0.0]: https://github.com/MetaMask/core/compare/@metamask/bridge-controller@68.0.0...@metamask/bridge-controller@69.0.0 +[68.0.0]: https://github.com/MetaMask/core/compare/@metamask/bridge-controller@67.4.0...@metamask/bridge-controller@68.0.0 +[67.4.0]: https://github.com/MetaMask/core/compare/@metamask/bridge-controller@67.3.0...@metamask/bridge-controller@67.4.0 +[67.3.0]: https://github.com/MetaMask/core/compare/@metamask/bridge-controller@67.2.0...@metamask/bridge-controller@67.3.0 +[67.2.0]: https://github.com/MetaMask/core/compare/@metamask/bridge-controller@67.1.1...@metamask/bridge-controller@67.2.0 +[67.1.1]: https://github.com/MetaMask/core/compare/@metamask/bridge-controller@67.1.0...@metamask/bridge-controller@67.1.1 +[67.1.0]: https://github.com/MetaMask/core/compare/@metamask/bridge-controller@67.0.0...@metamask/bridge-controller@67.1.0 +[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 +[65.1.0]: https://github.com/MetaMask/core/compare/@metamask/bridge-controller@65.0.1...@metamask/bridge-controller@65.1.0 +[65.0.1]: https://github.com/MetaMask/core/compare/@metamask/bridge-controller@65.0.0...@metamask/bridge-controller@65.0.1 +[65.0.0]: https://github.com/MetaMask/core/compare/@metamask/bridge-controller@64.8.2...@metamask/bridge-controller@65.0.0 +[64.8.2]: https://github.com/MetaMask/core/compare/@metamask/bridge-controller@64.8.1...@metamask/bridge-controller@64.8.2 +[64.8.1]: https://github.com/MetaMask/core/compare/@metamask/bridge-controller@64.8.0...@metamask/bridge-controller@64.8.1 +[64.8.0]: https://github.com/MetaMask/core/compare/@metamask/bridge-controller@64.7.0...@metamask/bridge-controller@64.8.0 +[64.7.0]: https://github.com/MetaMask/core/compare/@metamask/bridge-controller@64.6.1...@metamask/bridge-controller@64.7.0 +[64.6.1]: https://github.com/MetaMask/core/compare/@metamask/bridge-controller@64.6.0...@metamask/bridge-controller@64.6.1 +[64.6.0]: https://github.com/MetaMask/core/compare/@metamask/bridge-controller@64.5.1...@metamask/bridge-controller@64.6.0 +[64.5.1]: https://github.com/MetaMask/core/compare/@metamask/bridge-controller@64.5.0...@metamask/bridge-controller@64.5.1 +[64.5.0]: https://github.com/MetaMask/core/compare/@metamask/bridge-controller@64.4.1...@metamask/bridge-controller@64.5.0 +[64.4.1]: https://github.com/MetaMask/core/compare/@metamask/bridge-controller@64.4.0...@metamask/bridge-controller@64.4.1 +[64.4.0]: https://github.com/MetaMask/core/compare/@metamask/bridge-controller@64.3.0...@metamask/bridge-controller@64.4.0 +[64.3.0]: https://github.com/MetaMask/core/compare/@metamask/bridge-controller@64.2.0...@metamask/bridge-controller@64.3.0 +[64.2.0]: https://github.com/MetaMask/core/compare/@metamask/bridge-controller@64.1.0...@metamask/bridge-controller@64.2.0 +[64.1.0]: https://github.com/MetaMask/core/compare/@metamask/bridge-controller@64.0.0...@metamask/bridge-controller@64.1.0 +[64.0.0]: https://github.com/MetaMask/core/compare/@metamask/bridge-controller@63.2.0...@metamask/bridge-controller@64.0.0 +[63.2.0]: https://github.com/MetaMask/core/compare/@metamask/bridge-controller@63.1.0...@metamask/bridge-controller@63.2.0 +[63.1.0]: https://github.com/MetaMask/core/compare/@metamask/bridge-controller@63.0.0...@metamask/bridge-controller@63.1.0 +[63.0.0]: https://github.com/MetaMask/core/compare/@metamask/bridge-controller@62.0.0...@metamask/bridge-controller@63.0.0 +[62.0.0]: https://github.com/MetaMask/core/compare/@metamask/bridge-controller@61.0.0...@metamask/bridge-controller@62.0.0 +[61.0.0]: https://github.com/MetaMask/core/compare/@metamask/bridge-controller@60.1.0...@metamask/bridge-controller@61.0.0 +[60.1.0]: https://github.com/MetaMask/core/compare/@metamask/bridge-controller@60.0.0...@metamask/bridge-controller@60.1.0 +[60.0.0]: https://github.com/MetaMask/core/compare/@metamask/bridge-controller@59.0.0...@metamask/bridge-controller@60.0.0 +[59.0.0]: https://github.com/MetaMask/core/compare/@metamask/bridge-controller@58.0.0...@metamask/bridge-controller@59.0.0 +[58.0.0]: https://github.com/MetaMask/core/compare/@metamask/bridge-controller@57.0.0...@metamask/bridge-controller@58.0.0 +[57.0.0]: https://github.com/MetaMask/core/compare/@metamask/bridge-controller@56.0.3...@metamask/bridge-controller@57.0.0 +[56.0.3]: https://github.com/MetaMask/core/compare/@metamask/bridge-controller@56.0.2...@metamask/bridge-controller@56.0.3 +[56.0.2]: https://github.com/MetaMask/core/compare/@metamask/bridge-controller@56.0.1...@metamask/bridge-controller@56.0.2 +[56.0.1]: https://github.com/MetaMask/core/compare/@metamask/bridge-controller@56.0.0...@metamask/bridge-controller@56.0.1 +[56.0.0]: https://github.com/MetaMask/core/compare/@metamask/bridge-controller@55.0.0...@metamask/bridge-controller@56.0.0 +[55.0.0]: https://github.com/MetaMask/core/compare/@metamask/bridge-controller@54.0.0...@metamask/bridge-controller@55.0.0 +[54.0.0]: https://github.com/MetaMask/core/compare/@metamask/bridge-controller@53.1.0...@metamask/bridge-controller@54.0.0 +[53.1.0]: https://github.com/MetaMask/core/compare/@metamask/bridge-controller@53.0.0...@metamask/bridge-controller@53.1.0 +[53.0.0]: https://github.com/MetaMask/core/compare/@metamask/bridge-controller@52.0.0...@metamask/bridge-controller@53.0.0 +[52.0.0]: https://github.com/MetaMask/core/compare/@metamask/bridge-controller@51.0.0...@metamask/bridge-controller@52.0.0 +[51.0.0]: https://github.com/MetaMask/core/compare/@metamask/bridge-controller@50.0.0...@metamask/bridge-controller@51.0.0 +[50.0.0]: https://github.com/MetaMask/core/compare/@metamask/bridge-controller@49.0.1...@metamask/bridge-controller@50.0.0 +[49.0.1]: https://github.com/MetaMask/core/compare/@metamask/bridge-controller@49.0.0...@metamask/bridge-controller@49.0.1 +[49.0.0]: https://github.com/MetaMask/core/compare/@metamask/bridge-controller@48.0.0...@metamask/bridge-controller@49.0.0 +[48.0.0]: https://github.com/MetaMask/core/compare/@metamask/bridge-controller@47.2.0...@metamask/bridge-controller@48.0.0 +[47.2.0]: https://github.com/MetaMask/core/compare/@metamask/bridge-controller@47.1.0...@metamask/bridge-controller@47.2.0 +[47.1.0]: https://github.com/MetaMask/core/compare/@metamask/bridge-controller@47.0.0...@metamask/bridge-controller@47.1.0 +[47.0.0]: https://github.com/MetaMask/core/compare/@metamask/bridge-controller@46.0.0...@metamask/bridge-controller@47.0.0 +[46.0.0]: https://github.com/MetaMask/core/compare/@metamask/bridge-controller@45.0.0...@metamask/bridge-controller@46.0.0 +[45.0.0]: https://github.com/MetaMask/core/compare/@metamask/bridge-controller@44.0.1...@metamask/bridge-controller@45.0.0 +[44.0.1]: https://github.com/MetaMask/core/compare/@metamask/bridge-controller@44.0.0...@metamask/bridge-controller@44.0.1 +[44.0.0]: https://github.com/MetaMask/core/compare/@metamask/bridge-controller@43.2.1...@metamask/bridge-controller@44.0.0 +[43.2.1]: https://github.com/MetaMask/core/compare/@metamask/bridge-controller@43.2.0...@metamask/bridge-controller@43.2.1 +[43.2.0]: https://github.com/MetaMask/core/compare/@metamask/bridge-controller@43.1.0...@metamask/bridge-controller@43.2.0 +[43.1.0]: https://github.com/MetaMask/core/compare/@metamask/bridge-controller@43.0.0...@metamask/bridge-controller@43.1.0 +[43.0.0]: https://github.com/MetaMask/core/compare/@metamask/bridge-controller@42.0.0...@metamask/bridge-controller@43.0.0 +[42.0.0]: https://github.com/MetaMask/core/compare/@metamask/bridge-controller@41.4.0...@metamask/bridge-controller@42.0.0 +[41.4.0]: https://github.com/MetaMask/core/compare/@metamask/bridge-controller@41.3.0...@metamask/bridge-controller@41.4.0 +[41.3.0]: https://github.com/MetaMask/core/compare/@metamask/bridge-controller@41.2.0...@metamask/bridge-controller@41.3.0 +[41.2.0]: https://github.com/MetaMask/core/compare/@metamask/bridge-controller@41.1.0...@metamask/bridge-controller@41.2.0 +[41.1.0]: https://github.com/MetaMask/core/compare/@metamask/bridge-controller@41.0.0...@metamask/bridge-controller@41.1.0 +[41.0.0]: https://github.com/MetaMask/core/compare/@metamask/bridge-controller@40.0.0...@metamask/bridge-controller@41.0.0 +[40.0.0]: https://github.com/MetaMask/core/compare/@metamask/bridge-controller@39.1.0...@metamask/bridge-controller@40.0.0 +[39.1.0]: https://github.com/MetaMask/core/compare/@metamask/bridge-controller@39.0.1...@metamask/bridge-controller@39.1.0 +[39.0.1]: https://github.com/MetaMask/core/compare/@metamask/bridge-controller@39.0.0...@metamask/bridge-controller@39.0.1 +[39.0.0]: https://github.com/MetaMask/core/compare/@metamask/bridge-controller@38.0.0...@metamask/bridge-controller@39.0.0 +[38.0.0]: https://github.com/MetaMask/core/compare/@metamask/bridge-controller@37.2.0...@metamask/bridge-controller@38.0.0 +[37.2.0]: https://github.com/MetaMask/core/compare/@metamask/bridge-controller@37.1.0...@metamask/bridge-controller@37.2.0 +[37.1.0]: https://github.com/MetaMask/core/compare/@metamask/bridge-controller@37.0.0...@metamask/bridge-controller@37.1.0 +[37.0.0]: https://github.com/MetaMask/core/compare/@metamask/bridge-controller@36.2.0...@metamask/bridge-controller@37.0.0 +[36.2.0]: https://github.com/MetaMask/core/compare/@metamask/bridge-controller@36.1.0...@metamask/bridge-controller@36.2.0 +[36.1.0]: https://github.com/MetaMask/core/compare/@metamask/bridge-controller@36.0.0...@metamask/bridge-controller@36.1.0 +[36.0.0]: https://github.com/MetaMask/core/compare/@metamask/bridge-controller@35.0.0...@metamask/bridge-controller@36.0.0 +[35.0.0]: https://github.com/MetaMask/core/compare/@metamask/bridge-controller@34.0.0...@metamask/bridge-controller@35.0.0 +[34.0.0]: https://github.com/MetaMask/core/compare/@metamask/bridge-controller@33.0.1...@metamask/bridge-controller@34.0.0 +[33.0.1]: https://github.com/MetaMask/core/compare/@metamask/bridge-controller@33.0.0...@metamask/bridge-controller@33.0.1 +[33.0.0]: https://github.com/MetaMask/core/compare/@metamask/bridge-controller@32.2.0...@metamask/bridge-controller@33.0.0 +[32.2.0]: https://github.com/MetaMask/core/compare/@metamask/bridge-controller@32.1.2...@metamask/bridge-controller@32.2.0 +[32.1.2]: https://github.com/MetaMask/core/compare/@metamask/bridge-controller@32.1.1...@metamask/bridge-controller@32.1.2 +[32.1.1]: https://github.com/MetaMask/core/compare/@metamask/bridge-controller@32.1.0...@metamask/bridge-controller@32.1.1 +[32.1.0]: https://github.com/MetaMask/core/compare/@metamask/bridge-controller@32.0.1...@metamask/bridge-controller@32.1.0 +[32.0.1]: https://github.com/MetaMask/core/compare/@metamask/bridge-controller@32.0.0...@metamask/bridge-controller@32.0.1 +[32.0.0]: https://github.com/MetaMask/core/compare/@metamask/bridge-controller@31.0.0...@metamask/bridge-controller@32.0.0 +[31.0.0]: https://github.com/MetaMask/core/compare/@metamask/bridge-controller@30.0.0...@metamask/bridge-controller@31.0.0 +[30.0.0]: https://github.com/MetaMask/core/compare/@metamask/bridge-controller@29.0.0...@metamask/bridge-controller@30.0.0 +[29.0.0]: https://github.com/MetaMask/core/compare/@metamask/bridge-controller@28.0.0...@metamask/bridge-controller@29.0.0 +[28.0.0]: https://github.com/MetaMask/core/compare/@metamask/bridge-controller@27.0.0...@metamask/bridge-controller@28.0.0 +[27.0.0]: https://github.com/MetaMask/core/compare/@metamask/bridge-controller@26.0.0...@metamask/bridge-controller@27.0.0 +[26.0.0]: https://github.com/MetaMask/core/compare/@metamask/bridge-controller@25.1.0...@metamask/bridge-controller@26.0.0 +[25.1.0]: https://github.com/MetaMask/core/compare/@metamask/bridge-controller@25.0.1...@metamask/bridge-controller@25.1.0 +[25.0.1]: https://github.com/MetaMask/core/compare/@metamask/bridge-controller@25.0.0...@metamask/bridge-controller@25.0.1 +[25.0.0]: https://github.com/MetaMask/core/compare/@metamask/bridge-controller@24.0.0...@metamask/bridge-controller@25.0.0 +[24.0.0]: https://github.com/MetaMask/core/compare/@metamask/bridge-controller@23.0.0...@metamask/bridge-controller@24.0.0 +[23.0.0]: https://github.com/MetaMask/core/compare/@metamask/bridge-controller@22.0.0...@metamask/bridge-controller@23.0.0 +[22.0.0]: https://github.com/MetaMask/core/compare/@metamask/bridge-controller@21.0.0...@metamask/bridge-controller@22.0.0 +[21.0.0]: https://github.com/MetaMask/core/compare/@metamask/bridge-controller@20.0.0...@metamask/bridge-controller@21.0.0 +[20.0.0]: https://github.com/MetaMask/core/compare/@metamask/bridge-controller@19.0.0...@metamask/bridge-controller@20.0.0 +[19.0.0]: https://github.com/MetaMask/core/compare/@metamask/bridge-controller@18.0.0...@metamask/bridge-controller@19.0.0 +[18.0.0]: https://github.com/MetaMask/core/compare/@metamask/bridge-controller@17.0.0...@metamask/bridge-controller@18.0.0 +[17.0.0]: https://github.com/MetaMask/core/compare/@metamask/bridge-controller@16.0.0...@metamask/bridge-controller@17.0.0 +[16.0.0]: https://github.com/MetaMask/core/compare/@metamask/bridge-controller@15.0.0...@metamask/bridge-controller@16.0.0 +[15.0.0]: https://github.com/MetaMask/core/compare/@metamask/bridge-controller@14.0.0...@metamask/bridge-controller@15.0.0 +[14.0.0]: https://github.com/MetaMask/core/compare/@metamask/bridge-controller@13.0.0...@metamask/bridge-controller@14.0.0 +[13.0.0]: https://github.com/MetaMask/core/compare/@metamask/bridge-controller@12.0.0...@metamask/bridge-controller@13.0.0 +[12.0.0]: https://github.com/MetaMask/core/compare/@metamask/bridge-controller@11.0.0...@metamask/bridge-controller@12.0.0 +[11.0.0]: https://github.com/MetaMask/core/compare/@metamask/bridge-controller@10.0.0...@metamask/bridge-controller@11.0.0 +[10.0.0]: https://github.com/MetaMask/core/compare/@metamask/bridge-controller@9.0.0...@metamask/bridge-controller@10.0.0 +[9.0.0]: https://github.com/MetaMask/core/compare/@metamask/bridge-controller@8.0.0...@metamask/bridge-controller@9.0.0 +[8.0.0]: https://github.com/MetaMask/core/compare/@metamask/bridge-controller@7.0.0...@metamask/bridge-controller@8.0.0 +[7.0.0]: https://github.com/MetaMask/core/compare/@metamask/bridge-controller@6.0.0...@metamask/bridge-controller@7.0.0 +[6.0.0]: https://github.com/MetaMask/core/compare/@metamask/bridge-controller@5.0.0...@metamask/bridge-controller@6.0.0 +[5.0.0]: https://github.com/MetaMask/core/compare/@metamask/bridge-controller@4.0.0...@metamask/bridge-controller@5.0.0 +[4.0.0]: https://github.com/MetaMask/core/compare/@metamask/bridge-controller@3.0.0...@metamask/bridge-controller@4.0.0 +[3.0.0]: https://github.com/MetaMask/core/compare/@metamask/bridge-controller@2.0.0...@metamask/bridge-controller@3.0.0 +[2.0.0]: https://github.com/MetaMask/core/compare/@metamask/bridge-controller@1.0.0...@metamask/bridge-controller@2.0.0 +[1.0.0]: https://github.com/MetaMask/core/releases/tag/@metamask/bridge-controller@1.0.0 diff --git a/packages/bridge-controller/LICENSE b/packages/bridge-controller/LICENSE new file mode 100644 index 00000000000..c259cd7ebcf --- /dev/null +++ b/packages/bridge-controller/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2025 MetaMask + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packages/bridge-controller/README.md b/packages/bridge-controller/README.md new file mode 100644 index 00000000000..adb050aedec --- /dev/null +++ b/packages/bridge-controller/README.md @@ -0,0 +1,15 @@ +# `@metamask/bridge-controller` + +Manages bridge-related quote fetching functionality for MetaMask. + +## Installation + +`yarn add @metamask/bridge-controller` + +or + +`npm install @metamask/bridge-controller` + +## Contributing + +This package is part of a monorepo. Instructions for contributing can be found in the [monorepo README](https://github.com/MetaMask/core#readme). diff --git a/packages/bridge-controller/jest.config.js b/packages/bridge-controller/jest.config.js new file mode 100644 index 00000000000..f9e336cb4c5 --- /dev/null +++ b/packages/bridge-controller/jest.config.js @@ -0,0 +1,38 @@ +/* + * 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: { + './src/bridge-controller.ts': { + branches: 93.49, + functions: 98.43, + lines: 97.98, + statements: 97.99, + }, + './src/selectors.ts': { + branches: 91.42, + functions: 100, + lines: 100, + statements: 100, + }, + global: { + branches: 92, + functions: 98, + lines: 98, + statements: 98, + }, + }, +}); diff --git a/packages/bridge-controller/package.json b/packages/bridge-controller/package.json new file mode 100644 index 00000000000..33d2d591c5e --- /dev/null +++ b/packages/bridge-controller/package.json @@ -0,0 +1,103 @@ +{ + "name": "@metamask/bridge-controller", + "version": "80.1.0", + "description": "Manages bridge-related quote fetching functionality for MetaMask", + "keywords": [ + "Ethereum", + "MetaMask" + ], + "homepage": "https://github.com/MetaMask/core/tree/main/packages/bridge-controller#readme", + "bugs": { + "url": "https://github.com/MetaMask/core/issues" + }, + "license": "MIT", + "repository": { + "type": "git", + "url": "https://github.com/MetaMask/core.git" + }, + "files": [ + "dist/" + ], + "sideEffects": false, + "main": "./dist/index.cjs", + "types": "./dist/index.d.cts", + "exports": { + ".": { + "import": { + "types": "./dist/index.d.mts", + "default": "./dist/index.mjs" + }, + "require": { + "types": "./dist/index.d.cts", + "default": "./dist/index.cjs" + } + }, + "./package.json": "./package.json" + }, + "publishConfig": { + "access": "public", + "registry": "https://registry.npmjs.org/" + }, + "scripts": { + "build": "ts-bridge --project tsconfig.build.json --verbose --clean --no-references", + "build:all": "ts-bridge --project tsconfig.build.json --verbose --clean", + "build:docs": "typedoc", + "changelog:update": "../../scripts/update-changelog.sh @metamask/bridge-controller", + "changelog:validate": "../../scripts/validate-changelog.sh @metamask/bridge-controller", + "generate-method-action-types": "tsx ../../packages/messenger/src/generate-action-types/cli.ts", + "lint:tsconfigs": "tsx ../../scripts/lint-tsconfigs/lint-tsconfigs.mts", + "lint:tsconfigs:fix": "tsx ../../scripts/lint-tsconfigs/lint-tsconfigs.mts --fix", + "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": { + "@ethersproject/address": "^5.7.0", + "@ethersproject/bignumber": "^5.7.0", + "@ethersproject/constants": "^5.7.0", + "@ethersproject/contracts": "^5.7.0", + "@ethersproject/providers": "^5.7.0", + "@metamask/accounts-controller": "^39.1.1", + "@metamask/assets-controller": "^14.0.2", + "@metamask/assets-controllers": "^111.1.3", + "@metamask/base-controller": "^9.1.0", + "@metamask/controller-utils": "^12.3.0", + "@metamask/gas-fee-controller": "^26.3.2", + "@metamask/keyring-api": "^24.0.0", + "@metamask/messenger": "^2.0.0", + "@metamask/metamask-eth-abis": "^3.1.1", + "@metamask/multichain-network-controller": "^3.2.4", + "@metamask/network-controller": "^36.0.0", + "@metamask/polling-controller": "^16.0.9", + "@metamask/profile-sync-controller": "^29.0.0", + "@metamask/remote-feature-flag-controller": "^6.1.0", + "@metamask/snaps-controllers": "^19.0.0", + "@metamask/transaction-controller": "^69.6.1", + "@metamask/utils": "^11.11.0", + "bignumber.js": "^9.1.2", + "reselect": "^5.1.1", + "uuid": "^8.3.2" + }, + "devDependencies": { + "@metamask/auto-changelog": "^6.1.0", + "@metamask/eth-json-rpc-provider": "^6.0.1", + "@metamask/superstruct": "^3.4.1", + "@ts-bridge/cli": "^0.6.4", + "@types/jest": "^30.0.0", + "deepmerge": "^4.2.2", + "jest": "^30.4.2", + "jest-environment-jsdom": "^30.4.1", + "lodash": "^4.17.21", + "nock": "^13.3.1", + "ts-jest": "^29.4.11", + "tsx": "^4.20.5", + "typedoc": "^0.25.13", + "typedoc-plugin-missing-exports": "^2.0.0", + "typescript": "~5.3.3" + }, + "engines": { + "node": "^18.18 || >=20" + } +} diff --git a/packages/bridge-controller/src/__snapshots__/bridge-controller.sse.batch.test.ts.snap b/packages/bridge-controller/src/__snapshots__/bridge-controller.sse.batch.test.ts.snap new file mode 100644 index 00000000000..157a704c069 --- /dev/null +++ b/packages/bridge-controller/src/__snapshots__/bridge-controller.sse.batch.test.ts.snap @@ -0,0 +1,133 @@ +// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing + +exports[`BridgeController BatchSell (multiple quote requests) SSE fetch quotes should trigger quote polling if request is valid 1`] = ` +{ + "assetExchangeRates": { + "eip155:10/erc20:0x1f9840a85d5af5bf1d1762f925bdaddc4201f984": { + "exchangeRate": undefined, + "usdExchangeRate": "100", + }, + }, + "batchSellTrades": null, + "batchSellTradesLoadingStatus": 0, + "inputPrimaryDenomination": "token_amount", + "minimumBalanceForRentExemptionInLamports": "0", + "quoteFetchError": null, + "quoteRequest": [ + { + "destChainId": "137", + "destTokenAddress": "0x3c499c542cef5e3811e1192ce70d8cc03d5c3359", + "destWalletAddress": "SolanaWalletAddres1234", + "insufficientBal": false, + "resetApproval": false, + "slippage": 0.5, + "srcChainId": "10", + "srcTokenAddress": "0x0000000000000000000000000000000000000000", + "srcTokenAmount": "100000000000000000", + "walletAddress": "0x30E8ccaD5A980BDF30447f8c2C48e70989D9d294", + }, + { + "destChainId": "137", + "destTokenAddress": "0x3c499c542cef5e3811e1192ce70d8cc03d5c3359", + "destWalletAddress": "SolanaWalletAddres1234", + "insufficientBal": false, + "resetApproval": false, + "slippage": 0.5, + "srcChainId": "10", + "srcTokenAddress": "0x0b2c639c533813f4aa9d7837caf62653d097ff85", + "srcTokenAmount": "1000000000000000000", + "walletAddress": "0x30E8ccaD5A980BDF30447f8c2C48e70989D9d294", + }, + { + "destChainId": "137", + "destTokenAddress": "0x3c499c542cef5e3811e1192ce70d8cc03d5c3359", + "destWalletAddress": "SolanaWalletAddres1234", + "insufficientBal": false, + "resetApproval": false, + "slippage": 0.5, + "srcChainId": "10", + "srcTokenAddress": "0x0000000000000000000000000000000000000000", + "srcTokenAmount": "1000000000000000000", + "walletAddress": "0x30E8ccaD5A980BDF30447f8c2C48e70989D9d294", + }, + ], + "quoteStreamComplete": null, + "quotes": [], + "quotesInitialLoadTime": null, + "quotesLoadingStatus": 0, + "quotesRefreshCount": 0, + "tokenSecurityTypeDestination": null, + "tokenWarnings": [], +} +`; + +exports[`BridgeController BatchSell (multiple quote requests) SSE fetch quotes should trigger quote polling if request is valid 3`] = ` +[ + [ + "Unified SwapBridge Input Changed", + { + "action_type": "swapbridge-v1", + "feature_id": "batch_sell", + "input": "chain_source", + "input_value": "eip155:10", + "location": "Unknown", + }, + ], + [ + "Unified SwapBridge Input Changed", + { + "action_type": "swapbridge-v1", + "feature_id": "batch_sell", + "input": "chain_destination", + "input_value": "eip155:137", + "location": "Unknown", + }, + ], + [ + "Unified SwapBridge Input Changed", + { + "action_type": "swapbridge-v1", + "feature_id": "batch_sell", + "input": "token_destination", + "input_value": "eip155:137/erc20:0x3c499c542cEF5E3811e1192ce70d8cC03d5c3359", + "location": "Unknown", + }, + ], + [ + "Unified SwapBridge Input Changed", + { + "action_type": "swapbridge-v1", + "feature_id": "batch_sell", + "input": "slippage", + "input_value": 0.5, + "location": "Unknown", + }, + ], + [ + "Unified SwapBridge Quotes Requested", + { + "account_hardware_type": null, + "action_type": "swapbridge-v1", + "chain_id_destination": "eip155:137", + "chain_id_source": "eip155:10", + "custom_slippage": true, + "feature_id": "batch_sell", + "has_sufficient_funds": true, + "input_primary_denomination": "token_amount", + "is_hardware_wallet": false, + "location": "Unknown", + "security_warnings": [], + "slippage_limit": 0.5, + "stx_enabled": true, + "swap_type": "crosschain", + "token_address_destination": "eip155:137/erc20:0x3c499c542cEF5E3811e1192ce70d8cC03d5c3359", + "token_address_source": "eip155:10/slip44:60", + "token_security_type_destination": null, + "token_symbol_destination": "USDC", + "token_symbol_source": "ETH", + "usd_amount_source": 100, + "warnings": [], + }, + ], +] +`; 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 new file mode 100644 index 00000000000..7fcfe0b4439 --- /dev/null +++ b/packages/bridge-controller/src/__snapshots__/bridge-controller.sse.test.ts.snap @@ -0,0 +1,423 @@ +// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing + +exports[`BridgeController SSE should publish validation failures 4`] = ` +[ + [ + "Unified SwapBridge Quotes Failed Validation", + { + "action_type": "swapbridge-v1", + "chain_id_destination": "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp", + "chain_id_source": "eip155:1", + "failures": [ + "lifi|quote.src", + "lifi|quote.dest", + "lifi|quote.feeData.metabridge", + "lifi|quote.aggregator", + "lifi|quote.protocols", + "lifi|quote.steps.0.src", + "lifi|quote.steps.0.dest", + "lifi|quote.steps.1.src", + "lifi|quote.steps.1.dest", + ], + "feature_id": "unified_swap_bridge", + "location": "Unknown", + "refresh_count": 1, + "token_address_destination": "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/token:123d1", + "token_address_source": "eip155:1/slip44:60", + "token_security_type_destination": "test", + }, + ], + [ + "Unified SwapBridge Quotes Failed Validation", + { + "action_type": "swapbridge-v1", + "chain_id_destination": "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp", + "chain_id_source": "eip155:1", + "failures": [ + "unknown|unknown", + ], + "feature_id": "unified_swap_bridge", + "location": "Unknown", + "refresh_count": 1, + "token_address_destination": "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/token:123d1", + "token_address_source": "eip155:1/slip44:60", + "token_security_type_destination": "test", + }, + ], + [ + "Unified SwapBridge Quotes Failed Validation", + { + "action_type": "swapbridge-v1", + "chain_id_destination": "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp", + "chain_id_source": "eip155:1", + "failures": [ + "unknown|quote", + ], + "feature_id": "unified_swap_bridge", + "location": "Unknown", + "refresh_count": 1, + "token_address_destination": "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/token:123d1", + "token_address_source": "eip155:1/slip44:60", + "token_security_type_destination": "test", + }, + ], +] +`; + +exports[`BridgeController SSE should replace all stale quotes after a refresh and first quote is received 1`] = ` +[ + "Unified SwapBridge Quotes Requested", + { + "account_hardware_type": null, + "action_type": "swapbridge-v1", + "chain_id_destination": "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp", + "chain_id_source": "eip155:1", + "custom_slippage": true, + "feature_id": "unified_swap_bridge", + "has_sufficient_funds": true, + "input_primary_denomination": "token_amount", + "is_hardware_wallet": false, + "location": "Unknown", + "security_warnings": [], + "slippage_limit": 0.5, + "stx_enabled": true, + "swap_type": "crosschain", + "token_address_destination": "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/token:123d1", + "token_address_source": "eip155:1/slip44:60", + "token_security_type_destination": null, + "token_symbol_destination": "USDC", + "token_symbol_source": "ETH", + "usd_amount_source": 100, + "warnings": [], + }, +] +`; + +exports[`BridgeController SSE should reset and refetch quotes after quote request is changed 1`] = ` +[ + [ + "Unified SwapBridge Quotes Requested", + { + "account_hardware_type": null, + "action_type": "swapbridge-v1", + "chain_id_destination": "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp", + "chain_id_source": "eip155:1", + "custom_slippage": true, + "feature_id": "unified_swap_bridge", + "has_sufficient_funds": false, + "input_primary_denomination": "token_amount", + "is_hardware_wallet": false, + "location": "Unknown", + "security_warnings": [], + "slippage_limit": 0.5, + "stx_enabled": true, + "swap_type": "crosschain", + "token_address_destination": "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/token:123d1", + "token_address_source": "eip155:1/slip44:60", + "token_security_type_destination": null, + "token_symbol_destination": "USDC", + "token_symbol_source": "ETH", + "usd_amount_source": 100, + }, + ], +] +`; + +exports[`BridgeController SSE should reset quotes list if quote refresh fails 2`] = ` +[ + [ + "Unified SwapBridge Quotes Requested", + { + "account_hardware_type": null, + "action_type": "swapbridge-v1", + "chain_id_destination": "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp", + "chain_id_source": "eip155:1", + "custom_slippage": true, + "feature_id": "unified_swap_bridge", + "has_sufficient_funds": true, + "input_primary_denomination": "token_amount", + "is_hardware_wallet": false, + "location": "Unknown", + "security_warnings": [], + "slippage_limit": 0.5, + "stx_enabled": true, + "swap_type": "crosschain", + "token_address_destination": "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/token:123d1", + "token_address_source": "eip155:1/slip44:60", + "token_security_type_destination": null, + "token_symbol_destination": "USDC", + "token_symbol_source": "ETH", + "usd_amount_source": 100, + "warnings": [], + }, + ], + [ + "Unified SwapBridge Quotes Error", + { + "account_hardware_type": null, + "action_type": "swapbridge-v1", + "chain_id_destination": "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp", + "chain_id_source": "eip155:1", + "custom_slippage": true, + "error_message": "Network error", + "feature_id": "unified_swap_bridge", + "has_sufficient_funds": true, + "is_hardware_wallet": false, + "location": "Unknown", + "security_warnings": [], + "slippage_limit": 0.5, + "stx_enabled": true, + "swap_type": "crosschain", + "token_address_destination": "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/token:123d1", + "token_address_source": "eip155:1/slip44:60", + "token_security_type_destination": null, + "token_symbol_destination": "USDC", + "token_symbol_source": "ETH", + "usd_amount_source": 100, + "warnings": [], + }, + ], +] +`; + +exports[`BridgeController SSE should rethrow error from server 1`] = ` +{ + "assetExchangeRates": { + "eip155:10/erc20:0x1f9840a85d5af5bf1d1762f925bdaddc4201f984": { + "exchangeRate": undefined, + "usdExchangeRate": "100", + }, + }, + "batchSellTrades": null, + "batchSellTradesLoadingStatus": null, + "inputPrimaryDenomination": "token_amount", + "minimumBalanceForRentExemptionInLamports": "0", + "quoteFetchError": null, + "quoteRequest": [ + { + "destChainId": "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp", + "destTokenAddress": "123d1", + "destWalletAddress": "SolanaWalletAddres1234", + "insufficientBal": false, + "resetApproval": false, + "slippage": 0.5, + "srcChainId": "0x1", + "srcTokenAddress": "0x0000000000000000000000000000000000000000", + "srcTokenAmount": "1000000000000000000", + "walletAddress": "0x30E8ccaD5A980BDF30447f8c2C48e70989D9d294", + }, + ], + "quoteStreamComplete": null, + "quotes": [], + "quotesInitialLoadTime": null, + "quotesLoadingStatus": 0, + "quotesRefreshCount": 0, + "tokenSecurityTypeDestination": null, + "tokenWarnings": [], +} +`; + +exports[`BridgeController SSE should rethrow error from server 3`] = ` +[ + [ + "Unified SwapBridge Input Changed", + { + "action_type": "swapbridge-v1", + "feature_id": "unified_swap_bridge", + "input": "chain_source", + "input_value": "eip155:1", + "location": "Unknown", + }, + ], + [ + "Unified SwapBridge Input Changed", + { + "action_type": "swapbridge-v1", + "feature_id": "unified_swap_bridge", + "input": "chain_destination", + "input_value": "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp", + "location": "Unknown", + }, + ], + [ + "Unified SwapBridge Input Changed", + { + "action_type": "swapbridge-v1", + "feature_id": "unified_swap_bridge", + "input": "token_destination", + "input_value": "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/token:123d1", + "location": "Unknown", + }, + ], + [ + "Unified SwapBridge Input Changed", + { + "action_type": "swapbridge-v1", + "feature_id": "unified_swap_bridge", + "input": "slippage", + "input_value": 0.5, + "location": "Unknown", + }, + ], + [ + "Unified SwapBridge Quotes Requested", + { + "account_hardware_type": null, + "action_type": "swapbridge-v1", + "chain_id_destination": "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp", + "chain_id_source": "eip155:1", + "custom_slippage": true, + "feature_id": "unified_swap_bridge", + "has_sufficient_funds": true, + "input_primary_denomination": "token_amount", + "is_hardware_wallet": false, + "location": "Unknown", + "security_warnings": [], + "slippage_limit": 0.5, + "stx_enabled": true, + "swap_type": "crosschain", + "token_address_destination": "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/token:123d1", + "token_address_source": "eip155:1/slip44:60", + "token_security_type_destination": null, + "token_symbol_destination": "USDC", + "token_symbol_source": "ETH", + "usd_amount_source": 100, + "warnings": [], + }, + ], + [ + "Unified SwapBridge Quotes Error", + { + "account_hardware_type": null, + "action_type": "swapbridge-v1", + "chain_id_destination": "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp", + "chain_id_source": "eip155:1", + "custom_slippage": true, + "error_message": "Bridge-api error: timeout from server", + "feature_id": "unified_swap_bridge", + "has_sufficient_funds": true, + "is_hardware_wallet": false, + "location": "Unknown", + "security_warnings": [], + "slippage_limit": 0.5, + "stx_enabled": true, + "swap_type": "crosschain", + "token_address_destination": "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/token:123d1", + "token_address_source": "eip155:1/slip44:60", + "token_security_type_destination": null, + "token_symbol_destination": "USDC", + "token_symbol_source": "ETH", + "usd_amount_source": 100, + "warnings": [], + }, + ], +] +`; + +exports[`BridgeController SSE should trigger quote polling if request is valid 1`] = ` +{ + "assetExchangeRates": { + "eip155:10/erc20:0x1f9840a85d5af5bf1d1762f925bdaddc4201f984": { + "exchangeRate": undefined, + "usdExchangeRate": "100", + }, + }, + "batchSellTrades": null, + "batchSellTradesLoadingStatus": null, + "inputPrimaryDenomination": "token_amount", + "minimumBalanceForRentExemptionInLamports": "0", + "quoteFetchError": null, + "quoteRequest": [ + { + "destChainId": "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp", + "destTokenAddress": "123d1", + "destWalletAddress": "SolanaWalletAddres1234", + "insufficientBal": false, + "resetApproval": false, + "slippage": 0.5, + "srcChainId": "0x1", + "srcTokenAddress": "0x0000000000000000000000000000000000000000", + "srcTokenAmount": "1000000000000000000", + "walletAddress": "0x30E8ccaD5A980BDF30447f8c2C48e70989D9d294", + }, + ], + "quoteStreamComplete": null, + "quotes": [], + "quotesInitialLoadTime": null, + "quotesLoadingStatus": 0, + "quotesRefreshCount": 0, + "tokenSecurityTypeDestination": null, + "tokenWarnings": [], +} +`; + +exports[`BridgeController SSE should trigger quote polling if request is valid 2`] = ` +[ + [ + "Unified SwapBridge Input Changed", + { + "action_type": "swapbridge-v1", + "feature_id": "unified_swap_bridge", + "input": "chain_source", + "input_value": "eip155:1", + "location": "Unknown", + }, + ], + [ + "Unified SwapBridge Input Changed", + { + "action_type": "swapbridge-v1", + "feature_id": "unified_swap_bridge", + "input": "chain_destination", + "input_value": "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp", + "location": "Unknown", + }, + ], + [ + "Unified SwapBridge Input Changed", + { + "action_type": "swapbridge-v1", + "feature_id": "unified_swap_bridge", + "input": "token_destination", + "input_value": "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/token:123d1", + "location": "Unknown", + }, + ], + [ + "Unified SwapBridge Input Changed", + { + "action_type": "swapbridge-v1", + "feature_id": "unified_swap_bridge", + "input": "slippage", + "input_value": 0.5, + "location": "Unknown", + }, + ], + [ + "Unified SwapBridge Quotes Requested", + { + "account_hardware_type": null, + "action_type": "swapbridge-v1", + "chain_id_destination": "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp", + "chain_id_source": "eip155:1", + "custom_slippage": true, + "feature_id": "unified_swap_bridge", + "has_sufficient_funds": true, + "input_primary_denomination": "token_amount", + "is_hardware_wallet": false, + "location": "Unknown", + "security_warnings": [], + "slippage_limit": 0.5, + "stx_enabled": true, + "swap_type": "crosschain", + "token_address_destination": "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/token:123d1", + "token_address_source": "eip155:1/slip44:60", + "token_security_type_destination": null, + "token_symbol_destination": "USDC", + "token_symbol_source": "ETH", + "usd_amount_source": 100, + "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 new file mode 100644 index 00000000000..83398cef0fa --- /dev/null +++ b/packages/bridge-controller/src/__snapshots__/bridge-controller.test.ts.snap @@ -0,0 +1,1321 @@ +// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing + +exports[`BridgeController should handle errors from fetchBridgeQuotes 1`] = ` +{ + "assetExchangeRates": { + "eip155:10/erc20:0x1f9840a85d5af5bf1d1762f925bdaddc4201f984": { + "exchangeRate": undefined, + "usdExchangeRate": "100", + }, + }, + "batchSellTrades": null, + "batchSellTradesLoadingStatus": null, + "inputPrimaryDenomination": "token_amount", + "minimumBalanceForRentExemptionInLamports": "0", + "quoteFetchError": null, + "quoteRequest": [ + { + "destChainId": "0x1", + "destTokenAddress": "0x0000000000000000000000000000000000000000", + "insufficientBal": false, + "resetApproval": false, + "srcChainId": "0xa", + "srcTokenAddress": "0x4200000000000000000000000000000000000006", + "srcTokenAmount": "991250000000000000", + "walletAddress": "eip:id/id:id/0x123", + }, + ], + "quoteStreamComplete": null, + "quotesInitialLoadTime": 10000, + "quotesLoadingStatus": 1, + "quotesRefreshCount": 1, + "tokenSecurityTypeDestination": null, + "tokenWarnings": [], +} +`; + +exports[`BridgeController should handle errors from fetchBridgeQuotes 2`] = ` +{ + "assetExchangeRates": { + "eip155:10/erc20:0x1f9840a85d5af5bf1d1762f925bdaddc4201f984": { + "exchangeRate": undefined, + "usdExchangeRate": "100", + }, + }, + "batchSellTrades": null, + "batchSellTradesLoadingStatus": null, + "inputPrimaryDenomination": "token_amount", + "minimumBalanceForRentExemptionInLamports": "0", + "quoteFetchError": null, + "quoteRequest": [ + { + "destChainId": "0x1", + "destTokenAddress": "0x0000000000000000000000000000000000000000", + "insufficientBal": false, + "resetApproval": false, + "srcChainId": "0xa", + "srcTokenAddress": "0x4200000000000000000000000000000000000006", + "srcTokenAmount": "991250000000000000", + "walletAddress": "eip:id/id:id/0x123", + }, + ], + "quoteStreamComplete": null, + "quotesInitialLoadTime": 10000, + "quotesLoadingStatus": 1, + "quotesRefreshCount": 1, + "tokenSecurityTypeDestination": null, + "tokenWarnings": [], +} +`; + +exports[`BridgeController trackUnifiedSwapBridgeEvent bridge-status-controller calls should track the Completed event 1`] = ` +[ + [ + "Unified SwapBridge Completed", + { + "account_hardware_type": null, + "action_type": "swapbridge-v1", + "actual_time_minutes": 10, + "approval_transaction": "PENDING", + "chain_id_destination": "eip155:10", + "chain_id_source": "eip155:1", + "custom_slippage": true, + "destination_transaction": "PENDING", + "feature_id": "unified_swap_bridge", + "gas_included": false, + "gas_included_7702": false, + "is_hardware_wallet": false, + "price_impact": 6, + "provider": "provider_bridge", + "quote_vs_execution_ratio": 1, + "quoted_time_minutes": 0, + "quoted_vs_used_gas_ratio": 1, + "source_transaction": "PENDING", + "stx_enabled": false, + "swap_type": "crosschain", + "token_address_destination": "eip155:10/slip44:60", + "token_address_source": "eip155:1/slip44:60", + "token_symbol_destination": "USDC", + "token_symbol_source": "ETH", + "transaction_internal_id": "transaction-id", + "usd_actual_gas": 10, + "usd_actual_return": 100, + "usd_amount_source": 100, + "usd_quoted_gas": 0, + "usd_quoted_return": 0, + }, + ], +] +`; + +exports[`BridgeController trackUnifiedSwapBridgeEvent bridge-status-controller calls should track the Failed event 1`] = ` +[ + [ + "Unified SwapBridge Failed", + { + "account_hardware_type": null, + "action_type": "swapbridge-v1", + "actual_time_minutes": 10, + "allowance_reset_transaction": "PENDING", + "approval_transaction": "PENDING", + "chain_id_destination": "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp", + "chain_id_source": "eip155:1", + "custom_slippage": true, + "destination_transaction": "PENDING", + "error_message": "error_message", + "feature_id": "unified_swap_bridge", + "gas_included": false, + "gas_included_7702": false, + "has_gas_included_quote": false, + "initial_load_time_all_quotes": 0, + "is_hardware_wallet": false, + "location": "Unknown", + "price_impact": 0, + "provider": "provider_bridge", + "quoted_time_minutes": 0, + "quotes_count": 0, + "quotes_list": [], + "security_warnings": [], + "slippage_limit": 0, + "source_transaction": "PENDING", + "stx_enabled": false, + "swap_type": "crosschain", + "token_address_destination": "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/slip44:501", + "token_address_source": "eip155:1/slip44:60", + "token_security_type_destination": null, + "token_symbol_destination": "USDC", + "token_symbol_source": "ETH", + "usd_amount_source": 100, + "usd_quoted_gas": 0, + "usd_quoted_return": 0, + }, + ], +] +`; + +exports[`BridgeController trackUnifiedSwapBridgeEvent bridge-status-controller calls should track the Failed event before tx is submitted 1`] = ` +[ + [ + "Unified SwapBridge Failed", + { + "account_hardware_type": null, + "action_type": "swapbridge-v1", + "chain_id_destination": "eip155:1", + "chain_id_source": "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp", + "custom_slippage": true, + "error_message": "Failed to submit tx", + "feature_id": "unified_swap_bridge", + "gas_included": false, + "gas_included_7702": false, + "has_gas_included_quote": false, + "initial_load_time_all_quotes": 0, + "is_hardware_wallet": false, + "location": "Unknown", + "price_impact": 12, + "provider": "provider_bridge", + "quoted_time_minutes": 2, + "quotes_count": 2, + "quotes_list": [ + "lifi_mayan", + "lifi_mayanMCTP", + ], + "slippage_limit": 0.5, + "stx_enabled": false, + "swap_type": "crosschain", + "token_address_destination": "eip155:1/erc20:0x1234", + "token_address_source": "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/token:NATIVE", + "token_security_type_destination": null, + "token_symbol_destination": "USDC", + "token_symbol_source": "ETH", + "usd_amount_source": 100, + "usd_quoted_gas": 1, + "usd_quoted_return": 113, + }, + ], +] +`; + +exports[`BridgeController trackUnifiedSwapBridgeEvent bridge-status-controller calls should track the StatusValidationFailed event 1`] = ` +[ + [ + "Unified SwapBridge Status Failed Validation", + { + "action_type": "swapbridge-v1", + "failures": [ + "Failed to submit tx", + ], + "feature_id": "perps", + "location": "Unknown", + "refresh_count": 0, + }, + ], +] +`; + +exports[`BridgeController trackUnifiedSwapBridgeEvent bridge-status-controller calls should track the Submitted event 1`] = ` +[ + [ + "Unified SwapBridge Submitted", + { + "account_hardware_type": null, + "action_type": "swapbridge-v1", + "chain_id_destination": "eip155:1", + "chain_id_source": "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp", + "custom_slippage": false, + "feature_id": "unified_swap_bridge", + "gas_included": false, + "gas_included_7702": false, + "is_hardware_wallet": false, + "price_impact": 12, + "provider": "provider_bridge", + "quoted_time_minutes": 2, + "slippage_limit": 0.5, + "stx_enabled": false, + "swap_type": "crosschain", + "token_symbol_destination": "USDC", + "token_symbol_source": "ETH", + "usd_amount_source": 100, + "usd_quoted_gas": 1, + "usd_quoted_return": 113, + }, + ], +] +`; + +exports[`BridgeController trackUnifiedSwapBridgeEvent client-side calls should track the AllQuotesOpened event 1`] = ` +[ + [ + "Unified SwapBridge All Quotes Opened", + { + "account_hardware_type": null, + "action_type": "swapbridge-v1", + "can_submit": true, + "chain_id_destination": null, + "chain_id_source": "eip155:1", + "custom_slippage": false, + "feature_id": "unified_swap_bridge", + "gas_included": false, + "has_gas_included_quote": false, + "initial_load_time_all_quotes": 0, + "is_hardware_wallet": false, + "location": "Unknown", + "price_impact": 6, + "quotes_count": 0, + "quotes_list": [], + "slippage_limit": 0, + "stx_enabled": false, + "swap_type": "crosschain", + "token_address_destination": null, + "token_address_source": "eip155:1/slip44:60", + "token_security_type_destination": null, + "token_symbol_destination": "USDC", + "token_symbol_source": "ETH", + }, + ], +] +`; + +exports[`BridgeController trackUnifiedSwapBridgeEvent client-side calls should track the AllQuotesSorted event 1`] = ` +[ + [ + "Unified SwapBridge All Quotes Sorted", + { + "account_hardware_type": null, + "action_type": "swapbridge-v1", + "best_quote_provider": "provider_bridge2", + "can_submit": true, + "chain_id_destination": null, + "chain_id_source": "eip155:1", + "custom_slippage": false, + "feature_id": "unified_swap_bridge", + "gas_included": false, + "has_gas_included_quote": false, + "initial_load_time_all_quotes": 0, + "is_hardware_wallet": false, + "location": "Unknown", + "price_impact": 6, + "quotes_count": 0, + "quotes_list": [], + "slippage_limit": 0, + "sort_order": "cost_ascending", + "stx_enabled": false, + "swap_type": "crosschain", + "token_address_destination": null, + "token_address_source": "eip155:1/slip44:60", + "token_security_type_destination": null, + "token_symbol_destination": "USDC", + "token_symbol_source": "ETH", + }, + ], +] +`; + +exports[`BridgeController trackUnifiedSwapBridgeEvent client-side calls should track the AssetDetailTooltipClicked event 1`] = ` +[ + [ + "Unified SwapBridge Asset Detail Tooltip Clicked", + { + "action_type": "swapbridge-v1", + "chain_id": "1", + "chain_name": "Ethereum", + "feature_id": "unified_swap_bridge", + "location": "Unknown", + "token_contract": "0x123", + "token_name": "ETH", + "token_symbol": "ETH", + }, + ], +] +`; + +exports[`BridgeController trackUnifiedSwapBridgeEvent client-side calls should track the ButtonClicked event 1`] = ` +[ + [ + "Unified SwapBridge Button Clicked", + { + "action_type": "swapbridge-v1", + "chain_id_destination": null, + "chain_id_source": "eip155:1", + "feature_id": "quick_buy_follow_trading", + "location": "Main View", + "token_address_destination": null, + "token_address_source": "eip155:1/slip44:60", + "token_security_type_destination": null, + "token_symbol_destination": null, + "token_symbol_source": "ETH", + }, + ], +] +`; + +exports[`BridgeController trackUnifiedSwapBridgeEvent client-side calls should track the FiatCryptoToggleClicked event 1`] = ` +[ + [ + "Unified SwapBridge Fiat Crypto Toggle Clicked", + { + "action_type": "swapbridge-v1", + "chain_id_destination": "eip155:1", + "chain_id_source": "eip155:1", + "feature_id": "quick_buy_follow_trading", + "location": "Main View", + "new_primary_denomination": "fiat_value", + "previous_primary_denomination": "token_amount", + "swap_type": "single_chain", + "token_address_destination": "eip155:1/erc20:0xdAC17F958D2ee523a2206206994597C13D831ec7", + "token_address_source": "eip155:1/slip44:60", + "token_security_type_destination": null, + "token_symbol_destination": "USDC", + "token_symbol_source": "ETH", + }, + ], +] +`; + +exports[`BridgeController trackUnifiedSwapBridgeEvent client-side calls should track the InputSourceDestinationFlipped event 1`] = ` +[ + [ + "Unified SwapBridge Source Destination Switched", + { + "action_type": "swapbridge-v1", + "chain_id_destination": "eip155:10", + "chain_id_source": "eip155:1", + "feature_id": "unified_swap_bridge", + "location": "Unknown", + "security_warnings": [ + "warning1", + ], + "token_address_destination": "eip155:10/slip44:60", + "token_address_source": "eip155:1/slip44:60", + "token_security_type_destination": null, + "token_symbol_destination": "USDC", + "token_symbol_source": "ETH", + }, + ], +] +`; + +exports[`BridgeController trackUnifiedSwapBridgeEvent client-side calls should track the PageViewed event 1`] = ` +[ + [ + "Unified SwapBridge Page Viewed", + { + "account_hardware_type": null, + "action_type": "swapbridge-v1", + "chain_id_destination": null, + "chain_id_source": "eip155:1", + "custom_slippage": false, + "feature_id": "quick_buy_token_details", + "input_primary_denomination": "token_amount", + "is_hardware_wallet": false, + "location": "Unknown", + "slippage_limit": 0, + "swap_type": "crosschain", + "token_address_destination": null, + "token_address_source": "eip155:1/slip44:60", + "token_security_type_destination": null, + }, + ], +] +`; + +exports[`BridgeController trackUnifiedSwapBridgeEvent client-side calls should track the QuoteSelected event 1`] = ` +[ + [ + "Unified SwapBridge Quote Selected", + { + "account_hardware_type": null, + "action_type": "swapbridge-v1", + "best_quote_provider": "provider_bridge2", + "can_submit": false, + "chain_id_destination": null, + "chain_id_source": "eip155:1", + "custom_slippage": false, + "feature_id": "unified_swap_bridge", + "gas_included": false, + "gas_included_7702": false, + "has_gas_included_quote": false, + "initial_load_time_all_quotes": 0, + "is_best_quote": true, + "is_hardware_wallet": false, + "location": "Unknown", + "price_impact": 0, + "provider": "provider_bridge", + "quoted_time_minutes": 10, + "quotes_count": 0, + "quotes_list": [], + "slippage_limit": 0, + "swap_type": "crosschain", + "token_address_destination": null, + "token_address_source": "eip155:1/slip44:60", + "token_security_type_destination": null, + "usd_quoted_gas": 0, + "usd_quoted_return": 100, + }, + ], +] +`; + +exports[`BridgeController trackUnifiedSwapBridgeEvent client-side calls should track the QuotesReceived event 1`] = ` +[ + [ + "AccountsController:getAccountByAddress", + "0x123", + ], +] +`; + +exports[`BridgeController trackUnifiedSwapBridgeEvent client-side calls should track the QuotesReceived event 2`] = ` +[ + [ + "Unified SwapBridge Quotes Received", + { + "account_hardware_type": null, + "action_type": "swapbridge-v1", + "best_quote_provider": "provider_bridge2", + "can_submit": true, + "chain_id_destination": null, + "chain_id_source": "eip155:1", + "custom_slippage": false, + "feature_id": "unified_swap_bridge", + "gas_included": false, + "gas_included_7702": false, + "has_gas_included_quote": false, + "has_sufficient_funds": true, + "initial_load_time_all_quotes": 0, + "input_primary_denomination": "token_amount", + "is_hardware_wallet": false, + "location": "Unknown", + "price_impact": 0, + "provider": "provider_bridge", + "quoted_time_minutes": 10, + "quotes_count": 0, + "quotes_list": [], + "refresh_count": 0, + "slippage_limit": 0, + "swap_type": "crosschain", + "token_address_destination": null, + "token_address_source": "eip155:1/slip44:60", + "token_security_type_destination": null, + "usd_balance_source": 0, + "usd_quoted_gas": 0, + "usd_quoted_return": 100, + "warnings": [ + "insufficient_balance", + ], + }, + ], +] +`; + +exports[`BridgeController updateBridgeQuoteRequestParams should only poll once if insufficientBal=true 1`] = ` +[ + [ + "Unified SwapBridge Input Changed", + { + "action_type": "swapbridge-v1", + "feature_id": "unified_swap_bridge", + "input": "chain_source", + "input_value": "eip155:1", + "location": "Unknown", + }, + ], + [ + "Unified SwapBridge Input Changed", + { + "action_type": "swapbridge-v1", + "feature_id": "unified_swap_bridge", + "input": "chain_destination", + "input_value": "eip155:10", + "location": "Unknown", + }, + ], + [ + "Unified SwapBridge Input Changed", + { + "action_type": "swapbridge-v1", + "feature_id": "unified_swap_bridge", + "input": "token_destination", + "input_value": "eip155:10/erc20:0x123", + "location": "Unknown", + }, + ], + [ + "Unified SwapBridge Input Changed", + { + "action_type": "swapbridge-v1", + "feature_id": "unified_swap_bridge", + "input": "slippage", + "input_value": 0.5, + "location": "Unknown", + }, + ], + [ + "Unified SwapBridge Quotes Requested", + { + "account_hardware_type": null, + "action_type": "swapbridge-v1", + "chain_id_destination": "eip155:10", + "chain_id_source": "eip155:1", + "custom_slippage": true, + "feature_id": "unified_swap_bridge", + "has_sufficient_funds": false, + "input_primary_denomination": "token_amount", + "is_hardware_wallet": false, + "location": "Unknown", + "security_warnings": [], + "slippage_limit": 0.5, + "stx_enabled": true, + "swap_type": "crosschain", + "token_address_destination": "eip155:10/erc20:0x123", + "token_address_source": "eip155:1/slip44:60", + "token_security_type_destination": null, + "token_symbol_destination": "USDC", + "token_symbol_source": "ETH", + "usd_amount_source": 100, + "warnings": [], + }, + ], + [ + "Unified SwapBridge Quotes Received", + { + "account_hardware_type": null, + "action_type": "swapbridge-v1", + "best_quote_provider": "provider_bridge2", + "can_submit": true, + "chain_id_destination": "eip155:10", + "chain_id_source": "eip155:1", + "custom_slippage": true, + "feature_id": "dapp_swap", + "gas_included": false, + "gas_included_7702": false, + "has_gas_included_quote": false, + "has_sufficient_funds": false, + "initial_load_time_all_quotes": 11000, + "input_primary_denomination": "token_amount", + "is_hardware_wallet": false, + "location": "Unknown", + "price_impact": 0, + "provider": "provider_bridge", + "quoted_time_minutes": 10, + "quotes_count": 2, + "quotes_list": [ + "lifi_across", + "lifi_celercircle", + ], + "refresh_count": 1, + "slippage_limit": 0.5, + "swap_type": "crosschain", + "token_address_destination": "eip155:10/erc20:0x123", + "token_address_source": "eip155:1/slip44:60", + "token_security_type_destination": null, + "usd_balance_source": 0, + "usd_quoted_gas": 0, + "usd_quoted_return": 100, + "warnings": [ + "low_return", + ], + }, + ], +] +`; + +exports[`BridgeController updateBridgeQuoteRequestParams should reset minimumBalanceForRentExemptionInLamports if getMinimumBalanceForRentExemption call fails 1`] = ` +[ + [ + "Error setting minimum balance for rent exemption", + [Error: Min balance error], + ], +] +`; + +exports[`BridgeController updateBridgeQuoteRequestParams should reset minimumBalanceForRentExemptionInLamports if getMinimumBalanceForRentExemption call fails 2`] = ` +[ + [ + "SnapController:handleRequest", + { + "handler": "onProtocolRequest", + "origin": "metamask", + "request": { + "jsonrpc": "2.0", + "method": " ", + "params": { + "request": { + "id": "test-uuid-1234", + "jsonrpc": "2.0", + "method": "getMinimumBalanceForRentExemption", + "params": [ + 0, + { + "commitment": "confirmed", + }, + ], + }, + "scope": "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp", + }, + }, + "snapId": "npm:@metamask/solana-snap", + }, + ], + [ + "SnapController:handleRequest", + { + "handler": "onClientRequest", + "origin": "metamask", + "request": { + "id": "test-uuid-1234", + "jsonrpc": "2.0", + "method": "computeFee", + "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=", + }, + }, + "snapId": "npm:@metamask/solana-snap", + }, + ], + [ + "SnapController:handleRequest", + { + "handler": "onClientRequest", + "origin": "metamask", + "request": { + "id": "test-uuid-1234", + "jsonrpc": "2.0", + "method": "computeFee", + "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", + }, + }, + "snapId": "npm:@metamask/solana-snap", + }, + ], + [ + "SnapController:handleRequest", + { + "handler": "onProtocolRequest", + "origin": "metamask", + "request": { + "jsonrpc": "2.0", + "method": " ", + "params": { + "request": { + "id": "test-uuid-1234", + "jsonrpc": "2.0", + "method": "getMinimumBalanceForRentExemption", + "params": [ + 0, + { + "commitment": "confirmed", + }, + ], + }, + "scope": "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp", + }, + }, + "snapId": "npm:@metamask/solana-snap", + }, + ], + [ + "SnapController:handleRequest", + { + "handler": "onProtocolRequest", + "origin": "metamask", + "request": { + "jsonrpc": "2.0", + "method": " ", + "params": { + "request": { + "id": "test-uuid-1234", + "jsonrpc": "2.0", + "method": "getMinimumBalanceForRentExemption", + "params": [ + 0, + { + "commitment": "confirmed", + }, + ], + }, + "scope": "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp", + }, + }, + "snapId": "npm:@metamask/solana-snap", + }, + ], + [ + "SnapController:handleRequest", + { + "handler": "onClientRequest", + "origin": "metamask", + "request": { + "id": "test-uuid-1234", + "jsonrpc": "2.0", + "method": "computeFee", + "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=", + }, + }, + "snapId": "npm:@metamask/solana-snap", + }, + ], + [ + "SnapController:handleRequest", + { + "handler": "onClientRequest", + "origin": "metamask", + "request": { + "id": "test-uuid-1234", + "jsonrpc": "2.0", + "method": "computeFee", + "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", + }, + }, + "snapId": "npm:@metamask/solana-snap", + }, + ], + [ + "SnapController:handleRequest", + { + "handler": "onClientRequest", + "origin": "metamask", + "request": { + "id": "test-uuid-1234", + "jsonrpc": "2.0", + "method": "computeFee", + "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=", + }, + }, + "snapId": "npm:@metamask/solana-snap", + }, + ], + [ + "SnapController:handleRequest", + { + "handler": "onClientRequest", + "origin": "metamask", + "request": { + "id": "test-uuid-1234", + "jsonrpc": "2.0", + "method": "computeFee", + "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", + }, + }, + "snapId": "npm:@metamask/solana-snap", + }, + ], + [ + "SnapController:handleRequest", + { + "handler": "onProtocolRequest", + "origin": "metamask", + "request": { + "jsonrpc": "2.0", + "method": " ", + "params": { + "request": { + "id": "test-uuid-1234", + "jsonrpc": "2.0", + "method": "getMinimumBalanceForRentExemption", + "params": [ + 0, + { + "commitment": "confirmed", + }, + ], + }, + "scope": "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp", + }, + }, + "snapId": "npm:@metamask/solana-snap", + }, + ], + [ + "SnapController:handleRequest", + { + "handler": "onClientRequest", + "origin": "metamask", + "request": { + "id": "test-uuid-1234", + "jsonrpc": "2.0", + "method": "computeFee", + "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=", + }, + }, + "snapId": "npm:@metamask/solana-snap", + }, + ], + [ + "SnapController:handleRequest", + { + "handler": "onClientRequest", + "origin": "metamask", + "request": { + "id": "test-uuid-1234", + "jsonrpc": "2.0", + "method": "computeFee", + "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", + }, + }, + "snapId": "npm:@metamask/solana-snap", + }, + ], +] +`; + +exports[`BridgeController updateBridgeQuoteRequestParams should trigger quote polling if request is valid 1`] = ` +{ + "assetExchangeRates": {}, + "batchSellTrades": null, + "batchSellTradesLoadingStatus": null, + "inputPrimaryDenomination": "token_amount", + "minimumBalanceForRentExemptionInLamports": "0", + "quoteFetchError": null, + "quoteRequest": [ + { + "destChainId": "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp", + "destTokenAddress": "123d1", + "destWalletAddress": "SolanaWalletAddres1234", + "insufficientBal": false, + "resetApproval": false, + "slippage": 0.5, + "srcChainId": "0x1", + "srcTokenAddress": "0x0000000000000000000000000000000000000000", + "srcTokenAmount": "10", + "walletAddress": "0x123", + }, + ], + "quoteStreamComplete": null, + "quotes": [], + "quotesInitialLoadTime": null, + "quotesLastFetched": null, + "quotesLoadingStatus": 0, + "quotesRefreshCount": 0, + "tokenSecurityTypeDestination": null, + "tokenWarnings": [], +} +`; + +exports[`BridgeController updateBridgeQuoteRequestParams should trigger quote polling if request is valid 2`] = ` +{ + "assetExchangeRates": { + "eip155:10/erc20:0x1f9840a85d5af5bf1d1762f925bdaddc4201f984": { + "exchangeRate": undefined, + "usdExchangeRate": "100", + }, + }, + "batchSellTrades": null, + "batchSellTradesLoadingStatus": null, + "inputPrimaryDenomination": "token_amount", + "minimumBalanceForRentExemptionInLamports": "0", + "quoteFetchError": null, + "quoteRequest": [ + { + "destChainId": "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp", + "destTokenAddress": "123d1", + "destWalletAddress": "SolanaWalletAddres1234", + "insufficientBal": false, + "resetApproval": false, + "slippage": 0.5, + "srcChainId": "0x1", + "srcTokenAddress": "0x0000000000000000000000000000000000000000", + "srcTokenAmount": "10", + "walletAddress": "0x123", + }, + ], + "quoteStreamComplete": null, + "quotesInitialLoadTime": 10000, + "quotesLoadingStatus": 1, + "quotesRefreshCount": 1, + "tokenSecurityTypeDestination": null, + "tokenWarnings": [], +} +`; + +exports[`BridgeController updateBridgeQuoteRequestParams should trigger quote polling if request is valid 3`] = ` +[ + [ + "Unified SwapBridge Input Changed", + { + "action_type": "swapbridge-v1", + "feature_id": "unified_swap_bridge", + "input": "chain_source", + "input_value": "eip155:1", + "location": "Unknown", + }, + ], + [ + "Unified SwapBridge Input Changed", + { + "action_type": "swapbridge-v1", + "feature_id": "unified_swap_bridge", + "input": "chain_destination", + "input_value": "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp", + "location": "Unknown", + }, + ], + [ + "Unified SwapBridge Input Changed", + { + "action_type": "swapbridge-v1", + "feature_id": "unified_swap_bridge", + "input": "token_destination", + "input_value": "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/token:123d1", + "location": "Unknown", + }, + ], + [ + "Unified SwapBridge Input Changed", + { + "action_type": "swapbridge-v1", + "feature_id": "unified_swap_bridge", + "input": "slippage", + "input_value": 0.5, + "location": "Unknown", + }, + ], + [ + "Unified SwapBridge Quotes Requested", + { + "account_hardware_type": null, + "action_type": "swapbridge-v1", + "chain_id_destination": "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp", + "chain_id_source": "eip155:1", + "custom_slippage": true, + "feature_id": "unified_swap_bridge", + "has_sufficient_funds": true, + "input_primary_denomination": "token_amount", + "is_hardware_wallet": false, + "location": "Unknown", + "security_warnings": [], + "slippage_limit": 0.5, + "stx_enabled": true, + "swap_type": "crosschain", + "token_address_destination": "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/token:123d1", + "token_address_source": "eip155:1/slip44:60", + "token_security_type_destination": null, + "token_symbol_destination": "USDC", + "token_symbol_source": "ETH", + "usd_amount_source": 100, + "warnings": [], + }, + ], + [ + "Unified SwapBridge Quotes Requested", + { + "account_hardware_type": null, + "action_type": "swapbridge-v1", + "chain_id_destination": "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp", + "chain_id_source": "eip155:1", + "custom_slippage": true, + "feature_id": "unified_swap_bridge", + "has_sufficient_funds": true, + "input_primary_denomination": "token_amount", + "is_hardware_wallet": false, + "location": "Unknown", + "security_warnings": [], + "slippage_limit": 0.5, + "stx_enabled": true, + "swap_type": "crosschain", + "token_address_destination": "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/token:123d1", + "token_address_source": "eip155:1/slip44:60", + "token_security_type_destination": null, + "token_symbol_destination": "USDC", + "token_symbol_source": "ETH", + "usd_amount_source": 100, + "warnings": [], + }, + ], + [ + "Unified SwapBridge Quotes Requested", + { + "account_hardware_type": null, + "action_type": "swapbridge-v1", + "chain_id_destination": "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp", + "chain_id_source": "eip155:1", + "custom_slippage": true, + "feature_id": "unified_swap_bridge", + "has_sufficient_funds": true, + "input_primary_denomination": "token_amount", + "is_hardware_wallet": false, + "location": "Unknown", + "security_warnings": [], + "slippage_limit": 0.5, + "stx_enabled": true, + "swap_type": "crosschain", + "token_address_destination": "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/token:123d1", + "token_address_source": "eip155:1/slip44:60", + "token_security_type_destination": null, + "token_symbol_destination": "USDC", + "token_symbol_source": "ETH", + "usd_amount_source": 100, + "warnings": [], + }, + ], + [ + "Unified SwapBridge Quotes Error", + { + "account_hardware_type": null, + "action_type": "swapbridge-v1", + "chain_id_destination": "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp", + "chain_id_source": "eip155:1", + "custom_slippage": true, + "error_message": "Network error", + "feature_id": "unified_swap_bridge", + "has_sufficient_funds": true, + "is_hardware_wallet": false, + "location": "Unknown", + "security_warnings": [], + "slippage_limit": 0.5, + "stx_enabled": true, + "swap_type": "crosschain", + "token_address_destination": "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/token:123d1", + "token_address_source": "eip155:1/slip44:60", + "token_security_type_destination": null, + "token_symbol_destination": "USDC", + "token_symbol_source": "ETH", + "usd_amount_source": 100, + "warnings": [], + }, + ], + [ + "Unified SwapBridge Quotes Requested", + { + "account_hardware_type": null, + "action_type": "swapbridge-v1", + "chain_id_destination": "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp", + "chain_id_source": "eip155:1", + "custom_slippage": true, + "feature_id": "unified_swap_bridge", + "has_sufficient_funds": true, + "input_primary_denomination": "token_amount", + "is_hardware_wallet": false, + "location": "Unknown", + "security_warnings": [], + "slippage_limit": 0.5, + "stx_enabled": true, + "swap_type": "crosschain", + "token_address_destination": "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/token:123d1", + "token_address_source": "eip155:1/slip44:60", + "token_security_type_destination": null, + "token_symbol_destination": "USDC", + "token_symbol_source": "ETH", + "usd_amount_source": 100, + }, + ], +] +`; + +exports[`BridgeController updateBridgeQuoteRequestParams should update the quoteRequest state 1`] = ` +[ + [ + "Unified SwapBridge Input Changed", + { + "action_type": "swapbridge-v1", + "feature_id": "unified_swap_bridge", + "input": "chain_source", + "input_value": "eip155:1", + "location": "Unknown", + }, + ], + [ + "Unified SwapBridge Input Changed", + { + "action_type": "swapbridge-v1", + "feature_id": "unified_swap_bridge", + "input": "chain_destination", + "input_value": "eip155:10", + "location": "Unknown", + }, + ], + [ + "Unified SwapBridge Input Changed", + { + "action_type": "swapbridge-v1", + "feature_id": "unified_swap_bridge", + "input": "slippage", + "input_value": 0.5, + "location": "Unknown", + }, + ], +] +`; + +exports[`BridgeController updateBridgeQuoteRequestParams: should append solanaFees for Solana quotes 1`] = ` +[ + [ + "SnapController:handleRequest", + { + "handler": "onProtocolRequest", + "origin": "metamask", + "request": { + "jsonrpc": "2.0", + "method": " ", + "params": { + "request": { + "id": "test-uuid-1234", + "jsonrpc": "2.0", + "method": "getMinimumBalanceForRentExemption", + "params": [ + 0, + { + "commitment": "confirmed", + }, + ], + }, + "scope": "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp", + }, + }, + "snapId": "npm:@metamask/solana-snap", + }, + ], + [ + "SnapController:handleRequest", + { + "handler": "onClientRequest", + "origin": "metamask", + "request": { + "id": "test-uuid-1234", + "jsonrpc": "2.0", + "method": "computeFee", + "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=", + }, + }, + "snapId": "npm:@metamask/solana-snap", + }, + ], + [ + "SnapController:handleRequest", + { + "handler": "onClientRequest", + "origin": "metamask", + "request": { + "id": "test-uuid-1234", + "jsonrpc": "2.0", + "method": "computeFee", + "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", + }, + }, + "snapId": "npm:@metamask/solana-snap", + }, + ], +] +`; + +exports[`BridgeController updateBridgeQuoteRequestParams: should append solanaFees for Solana quotes 2`] = `[]`; + +exports[`BridgeController updateBridgeQuoteRequestParams: should handle malformed quotes 1`] = ` +[ + [ + "SnapController:handleRequest", + { + "handler": "onProtocolRequest", + "origin": "metamask", + "request": { + "jsonrpc": "2.0", + "method": " ", + "params": { + "request": { + "id": "test-uuid-1234", + "jsonrpc": "2.0", + "method": "getMinimumBalanceForRentExemption", + "params": [ + 0, + { + "commitment": "confirmed", + }, + ], + }, + "scope": "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp", + }, + }, + "snapId": "npm:@metamask/solana-snap", + }, + ], +] +`; + +exports[`BridgeController updateBridgeQuoteRequestParams: should handle malformed quotes 2`] = ` +[ + [ + "Unified SwapBridge Quotes Failed Validation", + { + "action_type": "swapbridge-v1", + "chain_id_destination": "eip155:1", + "chain_id_source": "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp", + "failures": [ + "socket|quote.srcAsset.decimals", + "socket|quote.destAsset.address", + "lifi|quote.srcAsset.decimals", + ], + "feature_id": "unified_swap_bridge", + "location": "Unknown", + "refresh_count": 0, + "token_address_destination": "eip155:1/slip44:60", + "token_address_source": "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/token:NATIVE", + "token_security_type_destination": null, + }, + ], +] +`; + +exports[`BridgeController updateBridgeQuoteRequestParams: should handle mixed Solana and non-Solana quotes by not appending fees 1`] = ` +[ + [ + "SnapController:handleRequest", + { + "handler": "onProtocolRequest", + "origin": "metamask", + "request": { + "jsonrpc": "2.0", + "method": " ", + "params": { + "request": { + "id": "test-uuid-1234", + "jsonrpc": "2.0", + "method": "getMinimumBalanceForRentExemption", + "params": [ + 0, + { + "commitment": "confirmed", + }, + ], + }, + "scope": "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp", + }, + }, + "snapId": "npm:@metamask/solana-snap", + }, + ], +] +`; + +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`] = `[]`; + +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..aed0f15f23e --- /dev/null +++ b/packages/bridge-controller/src/__snapshots__/selectors.test.ts.snap @@ -0,0 +1,403 @@ +// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing + +exports[`Bridge Selectors selectBridgeQuotes should return sorted quotes with metadata (Phase 1.5) 1`] = ` +{ + "adjustedReturn": { + "usd": "2.099927", + "valueInCurrency": "419.985546", + }, + "approval": { + "chainId": 1, + "data": "0x0", + "effectiveGas": 46000, + "from": "0x0000000000000000000000000000000000000000", + "gasLimit": 49000, + "to": "0x0000000000000000000000000000000000000000", + "value": "0x0", + }, + "chainId": "eip155:1", + "cost": { + "usd": "8.900073", + "valueInCurrency": "1758.014454", + }, + "estimatedProcessingTimeInSeconds": 300, + "gasFee": { + "total": { + "amount": "0.0000073", + "usd": "0.000073", + "valueInCurrency": "0.014454", + }, + }, + "minToTokenAmount": { + "amount": "1.8", + "usd": "1.8", + "valueInCurrency": "360", + }, + "namespace": "eip155", + "priceImpact": { + "usd": "8.9", + "valueInCurrency": "1758", + }, + "quote": { + "aggregator": "bridge1", + "dest": { + "amount": "2100000000000000000", + "asset": { + "assetId": "eip155:137/slip44:966", + "decimals": 18, + "name": "Polygon", + "symbol": "POL", + }, + "minAmount": "1800000000000000000", + "minAmountNormalized": "1.8", + "minAmountUsd": "1.8", + "minAmountValueInCurrency": "360", + "normalizedAmount": "2.1", + "usd": "2.1", + "valueInCurrency": "420", + }, + "feeData": { + "metabridge": [ + { + "amount": "100000000000000000", + "asset": { + "assetId": "eip155:1/slip44:60", + "decimals": 18, + "name": "Ethereum", + "symbol": "ETH", + }, + }, + ], + "network": [ + { + "amount": "7500000000000", + "asset": { + "assetId": "eip155:1/slip44:60", + "decimals": 18, + "name": "Ether", + "symbol": "ETH", + }, + "normalizedAmount": "0.0000075", + "usd": "0.01514", + "valueInCurrency": "2.99772", + }, + ], + "relayer": [ + { + "amount": "100000000000", + "asset": { + "assetId": "eip155:1/slip44:60", + "decimals": 18, + "name": "Ether", + "symbol": "ETH", + }, + "normalizedAmount": "0.0000001", + }, + ], + "txFee": undefined, + }, + "priceData": { + "adjustedReturn": { + "usd": "2.099927", + "valueInCurrency": "419.985546", + }, + "priceImpact": { + "usd": "7.9", + "valueInCurrency": "1564.2", + }, + "swapRate": "1.90909090909090909091", + }, + "protocols": [ + "bridge1", + ], + "requestId": "456", + "src": { + "amount": "1100000000000000000", + "asset": { + "assetId": "eip155:1/slip44:60", + "decimals": 18, + "name": "Ethereum", + "symbol": "ETH", + }, + "normalizedAmount": "1.1", + "usd": "1", + "valueInCurrency": "198", + }, + "steps": [], + }, + "sentAmount": { + "amount": "1.1", + "usd": "11", + "valueInCurrency": "2178", + }, + "swapRate": "1.90909090909090909091", + "toTokenAmount": { + "amount": "2.1", + "usd": "2.1", + "valueInCurrency": "420", + }, + "totalNetworkFee": { + "amount": "0.0000073", + "usd": "0.000073", + "valueInCurrency": "0.014454", + }, + "trade": { + "chainId": 1, + "data": "0x0", + "effectiveGas": 21000, + "from": "0x0000000000000000000000000000000000000000", + "gasLimit": 24000, + "to": "0x0000000000000000000000000000000000000000", + "value": "0x0", + }, +} +`; + +exports[`Bridge Selectors selectBridgeQuotes should return sorted quotes with metadata (Phase 2) 1`] = ` +{ + "approval": { + "chainId": 1, + "data": "0x0", + "effectiveGas": 46000, + "from": "0x0000000000000000000000000000000000000000", + "gasLimit": 49000, + "to": "0x0000000000000000000000000000000000000000", + "value": "0x0", + }, + "chainId": "eip155:1", + "estimatedProcessingTimeInSeconds": 300, + "namespace": "eip155", + "quote": { + "aggregator": "bridge1", + "dest": { + "amount": "2100000000000000000", + "asset": { + "assetId": "eip155:137/slip44:966", + "decimals": 18, + "name": "Polygon", + "symbol": "POL", + }, + "minAmount": "1800000000000000000", + "minAmountNormalized": "1.8", + "normalizedAmount": "2.1", + "usd": "2", + "valueInCurrency": "396", + }, + "feeData": { + "metabridge": [ + { + "amount": "100000000000000000", + "asset": { + "assetId": "eip155:1/slip44:60", + "decimals": 18, + "name": "Ethereum", + "symbol": "ETH", + }, + }, + ], + "network": [ + { + "amount": "7500000000000", + "asset": { + "assetId": "eip155:1/slip44:60", + "decimals": 18, + "name": "Ether", + "symbol": "ETH", + }, + "normalizedAmount": "0.0000075", + "usd": undefined, + }, + ], + "relayer": [ + { + "amount": "100000000000", + "asset": { + "assetId": "eip155:1/slip44:60", + "decimals": 18, + "name": "Ether", + "symbol": "ETH", + }, + "normalizedAmount": "0.0000001", + "usd": "0.0001", + "valueInCurrency": "0.0198", + }, + ], + "txFee": undefined, + }, + "priceData": { + "priceImpact": { + "usd": "7.9", + "valueInCurrency": "1564.2", + }, + }, + "protocols": [ + "bridge1", + ], + "requestId": "456", + "src": { + "amount": "1100000000000000000", + "asset": { + "assetId": "eip155:1/slip44:60", + "decimals": 18, + "name": "Ethereum", + "symbol": "ETH", + }, + "normalizedAmount": "1.1", + "usd": "1", + "valueInCurrency": "198", + }, + "steps": [], + }, + "trade": { + "chainId": 1, + "data": "0x0", + "effectiveGas": 21000, + "from": "0x0000000000000000000000000000000000000000", + "gasLimit": 24000, + "to": "0x0000000000000000000000000000000000000000", + "value": "0x0", + }, +} +`; + +exports[`Bridge Selectors selectBridgeQuotes should return sorted quotes with metadata 1`] = ` +{ + "adjustedReturn": { + "usd": "2.099927", + "valueInCurrency": "419.985546", + }, + "approval": { + "chainId": 1, + "data": "0x0", + "effectiveGas": 46000, + "from": "0x0000000000000000000000000000000000000000", + "gasLimit": 49000, + "to": "0x0000000000000000000000000000000000000000", + "value": "0x0", + }, + "chainId": "eip155:1", + "cost": { + "usd": "8.900073", + "valueInCurrency": "1758.014454", + }, + "estimatedProcessingTimeInSeconds": 300, + "gasFee": { + "total": { + "amount": "0.0000073", + "usd": "0.000073", + "valueInCurrency": "0.014454", + }, + }, + "minToTokenAmount": { + "amount": "1.8", + "usd": "1.8", + "valueInCurrency": "360", + }, + "namespace": "eip155", + "priceImpact": { + "usd": "8.9", + "valueInCurrency": "1758", + }, + "quote": { + "aggregator": "bridge1", + "dest": { + "amount": "2100000000000000000", + "asset": { + "assetId": "eip155:137/slip44:966", + "decimals": 18, + "name": "Polygon", + "symbol": "POL", + }, + "minAmount": "1800000000000000000", + "minAmountNormalized": "1.8", + "minAmountUsd": "1.8", + "minAmountValueInCurrency": "360", + "normalizedAmount": "2.1", + "usd": "2.1", + "valueInCurrency": "420", + }, + "feeData": { + "metabridge": [ + { + "amount": "100000000000000000", + "asset": { + "assetId": "eip155:1/slip44:60", + "decimals": 18, + "name": "Ethereum", + "symbol": "ETH", + }, + }, + ], + "network": [ + { + "amount": "7300000000000", + "asset": { + "assetId": "eip155:1/slip44:60", + "decimals": 18, + "name": "Ether", + "symbol": "ETH", + }, + "normalizedAmount": "0.0000073", + "usd": "0.000073", + "valueInCurrency": "0.014454", + }, + ], + "relayer": undefined, + "txFee": undefined, + }, + "priceData": { + "adjustedReturn": { + "usd": "2.099927", + "valueInCurrency": "419.985546", + }, + "priceImpact": { + "usd": "8.9", + "valueInCurrency": "1758", + }, + "swapRate": "1.90909090909090909091", + }, + "protocols": [ + "bridge1", + ], + "requestId": "456", + "src": { + "amount": "1100000000000000000", + "asset": { + "assetId": "eip155:1/slip44:60", + "decimals": 18, + "name": "Ethereum", + "symbol": "ETH", + }, + "normalizedAmount": "1.1", + "usd": "11", + "valueInCurrency": "2178", + }, + "steps": [], + }, + "sentAmount": { + "amount": "1.1", + "usd": "11", + "valueInCurrency": "2178", + }, + "swapRate": "1.90909090909090909091", + "toTokenAmount": { + "amount": "2.1", + "usd": "2.1", + "valueInCurrency": "420", + }, + "totalNetworkFee": { + "amount": "0.0000073", + "usd": "0.000073", + "valueInCurrency": "0.014454", + }, + "trade": { + "chainId": 1, + "data": "0x0", + "effectiveGas": 21000, + "from": "0x0000000000000000000000000000000000000000", + "gasLimit": 24000, + "to": "0x0000000000000000000000000000000000000000", + "value": "0x0", + }, +} +`; diff --git a/packages/bridge-controller/src/bridge-controller-method-action-types.ts b/packages/bridge-controller/src/bridge-controller-method-action-types.ts new file mode 100644 index 00000000000..f10b7d816fe --- /dev/null +++ b/packages/bridge-controller/src/bridge-controller-method-action-types.ts @@ -0,0 +1,71 @@ +/** + * This file is auto generated. + * Do not edit manually. + */ + +import type { BridgeController } from './bridge-controller.js'; + +export type BridgeControllerUpdateBridgeQuoteRequestParamsAction = { + type: `BridgeController:updateBridgeQuoteRequestParams`; + handler: BridgeController['updateBridgeQuoteRequestParams']; +}; + +export type BridgeControllerFetchQuotesAction = { + type: `BridgeController:fetchQuotes`; + handler: BridgeController['fetchQuotes']; +}; + +export type BridgeControllerUpdateBatchSellTradesAction = { + type: `BridgeController:updateBatchSellTrades`; + handler: BridgeController['updateBatchSellTrades']; +}; + +export type BridgeControllerStopPollingForQuotesAction = { + type: `BridgeController:stopPollingForQuotes`; + handler: BridgeController['stopPollingForQuotes']; +}; + +export type BridgeControllerSetLocationAction = { + type: `BridgeController:setLocation`; + handler: BridgeController['setLocation']; +}; + +export type BridgeControllerGetLocationAction = { + type: `BridgeController:getLocation`; + handler: BridgeController['getLocation']; +}; + +export type BridgeControllerSetInputPrimaryDenominationAction = { + type: `BridgeController:setInputPrimaryDenomination`; + handler: BridgeController['setInputPrimaryDenomination']; +}; + +export type BridgeControllerResetStateAction = { + type: `BridgeController:resetState`; + handler: BridgeController['resetState']; +}; + +export type BridgeControllerSetChainIntervalLengthAction = { + type: `BridgeController:setChainIntervalLength`; + handler: BridgeController['setChainIntervalLength']; +}; + +export type BridgeControllerTrackUnifiedSwapBridgeEventAction = { + type: `BridgeController:trackUnifiedSwapBridgeEvent`; + handler: BridgeController['trackUnifiedSwapBridgeEvent']; +}; + +/** + * Union of all BridgeController action types. + */ +export type BridgeControllerMethodActions = + | BridgeControllerUpdateBridgeQuoteRequestParamsAction + | BridgeControllerFetchQuotesAction + | BridgeControllerUpdateBatchSellTradesAction + | BridgeControllerStopPollingForQuotesAction + | BridgeControllerSetLocationAction + | BridgeControllerGetLocationAction + | BridgeControllerSetInputPrimaryDenominationAction + | BridgeControllerResetStateAction + | BridgeControllerSetChainIntervalLengthAction + | BridgeControllerTrackUnifiedSwapBridgeEventAction; diff --git a/packages/bridge-controller/src/bridge-controller.sse.batch.test.ts b/packages/bridge-controller/src/bridge-controller.sse.batch.test.ts new file mode 100644 index 00000000000..0eef36e57a5 --- /dev/null +++ b/packages/bridge-controller/src/bridge-controller.sse.batch.test.ts @@ -0,0 +1,929 @@ +import { SolScope } from '@metamask/keyring-api'; +import { Messenger, MOCK_ANY_NAMESPACE } from '@metamask/messenger'; +import type { + MessengerActions, + MessengerEvents, + MockAnyNamespace, +} from '@metamask/messenger'; + +import { flushPromises } from '../../../tests/helpers.js'; +import { + getMockBridgeQuotesErc20Erc20V2, + mockBridgeQuotesErc20Erc20V1, +} from '../tests/mock-quotes-erc20-erc20.js'; +import { + getMockBridgeQuotesNativeErc20V2, + mockBridgeQuotesNativeErc20V1, +} from '../tests/mock-quotes-native-erc20.js'; +import { + advanceToNthTimerThenFlush, + mockSseBatchSellEventSource, +} from '../tests/mock-sse.js'; +import { BridgeController } from './bridge-controller.js'; +import { + BridgeClientId, + BRIDGE_PROD_API_BASE_URL, + DEFAULT_BRIDGE_CONTROLLER_STATE, +} from './constants/bridge.js'; +import { ChainId, RequestStatus } from './types.js'; +import type { BridgeControllerMessenger } from './types.js'; +import * as balanceUtils from './utils/balance.js'; +import * as featureFlagUtils from './utils/feature-flags.js'; +import * as fetchUtils from './utils/fetch.js'; +import { FeatureId } from './validators/feature-flags.js'; + +type RootMessenger = Messenger< + MockAnyNamespace, + MessengerActions, + MessengerEvents +>; + +const BRIDGE_CONTROLLER_ALLOWED_EXTERNAL_ACTIONS = [ + 'AccountsController:getAccountByAddress', + 'AuthenticationController:getBearerToken', + 'CurrencyRateController:getState', + 'TokenRatesController:getState', + 'MultichainAssetsRatesController:getState', + 'SnapController:handleRequest', + 'NetworkController:findNetworkClientIdByChainId', + 'NetworkController:getNetworkClientById', + 'RemoteFeatureFlagController:getState', + 'AssetsController:getExchangeRatesForBridge', +] as const; + +const messengerCallMock = jest.fn(); +const getLayer1GasFeeMock = jest.fn(); +const mockFetchFn = jest.fn(); +const trackMetaMetricsFn = jest.fn(); + +const quoteRequest = { + srcChainId: '0x1', + destChainId: SolScope.Mainnet, + srcTokenAddress: '0x0000000000000000000000000000000000000000', + destTokenAddress: '123d1', + srcTokenAmount: '1000000000000000000', + slippage: 0.5, + walletAddress: '0x30E8ccaD5A980BDF30447f8c2C48e70989D9d294', + destWalletAddress: 'SolanaWalletAddres1234', + resetApproval: false, +}; +const metricsContext = { + feature_id: FeatureId.BATCH_SELL, + token_symbol_source: 'ETH', + token_symbol_destination: 'USDC', + usd_amount_source: 100, + stx_enabled: true, + security_warnings: [], + warnings: [], + token_security_type_destination: null, +}; + +const assetExchangeRates = { + 'eip155:10/erc20:0x1f9840a85d5af5bf1d1762f925bdaddc4201f984': { + exchangeRate: undefined, + usdExchangeRate: '100', + }, +}; + +type WithControllerCallback = (payload: { + controller: BridgeController; + rootMessenger: RootMessenger; + stopAllPollingSpy: jest.SpyInstance; + startPollingSpy: jest.SpyInstance; + hasSufficientBalanceSpy: jest.SpyInstance; + fetchBridgeQuotesSpy: jest.SpyInstance; + fetchAssetPricesSpy: jest.SpyInstance; + consoleLogSpy: jest.SpyInstance; +}) => Promise | ReturnValue; + +type WithControllerOptions = { + options?: Partial[0]>; +}; + +async function withController( + ...args: + | [WithControllerCallback] + | [WithControllerOptions, WithControllerCallback] +): Promise { + const [{ options = {} }, testFunction] = + args.length === 2 ? args : [{}, args[0]]; + + const rootMessenger: RootMessenger = new Messenger({ + namespace: MOCK_ANY_NAMESPACE, + }); + + const messenger: BridgeControllerMessenger = new Messenger({ + namespace: 'BridgeController', + parent: rootMessenger, + }); + + rootMessenger.delegate({ + messenger, + actions: [...BRIDGE_CONTROLLER_ALLOWED_EXTERNAL_ACTIONS], + }); + + for (const action of BRIDGE_CONTROLLER_ALLOWED_EXTERNAL_ACTIONS) { + rootMessenger.registerActionHandler(action, (...actionArgs) => + messengerCallMock(action, ...actionArgs), + ); + } + + jest.useFakeTimers(); + + getLayer1GasFeeMock.mockResolvedValue('0x1'); + + messengerCallMock.mockImplementation( + (...messengerArgs: Parameters) => { + switch (messengerArgs[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, + refreshRate: 30000, + support: true, + sse: { + enabled: true, + minimumVersion: '13.8.0', + }, + chains: { + '10': { isActiveSrc: true, isActiveDest: false }, + '534352': { isActiveSrc: true, isActiveDest: false }, + '137': { isActiveSrc: false, isActiveDest: true }, + '42161': { isActiveSrc: false, isActiveDest: true }, + [ChainId.SOLANA]: { + isActiveSrc: true, + isActiveDest: true, + }, + }, + chainRanking: [{ chainId: 'eip155:1' as const, name: 'Ethereum' }], + }); + + const controller = new BridgeController({ + messenger, + getLayer1GasFee: getLayer1GasFeeMock, + clientId: BridgeClientId.EXTENSION, + fetchFn: mockFetchFn, + trackMetaMetricsFn, + clientVersion: '13.8.0', + ...options, + }); + + const stopAllPollingSpy = jest.spyOn(controller, 'stopAllPolling'); + const startPollingSpy = jest.spyOn(controller, 'startPolling'); + const hasSufficientBalanceSpy = jest + .spyOn(balanceUtils, 'hasSufficientBalance') + .mockResolvedValue(true); + const fetchBridgeQuotesSpy = jest.spyOn(fetchUtils, 'fetchBridgeQuoteStream'); + const fetchAssetPricesSpy = jest + .spyOn(fetchUtils, 'fetchAssetPrices') + .mockResolvedValue({ + 'eip155:10/erc20:0x1f9840a85d5af5bf1d1762f925bdaddc4201f984': { + usd: '100', + }, + }); + + const consoleLogSpy = jest.spyOn(console, 'log'); + + return await testFunction({ + controller, + rootMessenger, + stopAllPollingSpy, + startPollingSpy, + hasSufficientBalanceSpy, + fetchBridgeQuotesSpy, + fetchAssetPricesSpy, + consoleLogSpy, + }); +} + +describe('BridgeController BatchSell (multiple quote requests) SSE', function () { + describe('fetch quotes', function () { + beforeEach(() => { + jest.clearAllMocks(); + jest.clearAllTimers(); + jest.resetAllMocks(); + }); + + it('should trigger quote polling if request is valid', async function () { + const consoleWarnSpy = jest.spyOn(console, 'warn'); + await withController( + async ({ + controller: bridgeController, + rootMessenger, + stopAllPollingSpy, + startPollingSpy, + hasSufficientBalanceSpy, + fetchBridgeQuotesSpy, + fetchAssetPricesSpy, + consoleLogSpy, + }) => { + mockFetchFn.mockImplementationOnce(async () => { + return mockSseBatchSellEventSource([ + mockBridgeQuotesNativeErc20V1, + mockBridgeQuotesErc20Erc20V1.map((quote) => ({ + ...quote, + quoteRequestIndex: 1, + })), + ]); + }); + hasSufficientBalanceSpy.mockResolvedValue(true); + + const quoteRequest0 = { + ...quoteRequest, + srcTokenAddress: + mockBridgeQuotesNativeErc20V1[0].quote.srcAsset.address, + destTokenAddress: + mockBridgeQuotesNativeErc20V1[0].quote.destAsset.address, + srcChainId: + mockBridgeQuotesNativeErc20V1[0].quote.srcAsset.chainId.toString(), + destChainId: + mockBridgeQuotesNativeErc20V1[0].quote.destAsset.chainId.toString(), + srcTokenAmount: '100000000000000000', + }; + const quoteRequest1 = { + ...quoteRequest, + srcTokenAddress: + mockBridgeQuotesErc20Erc20V1[0].quote.srcAsset.address, + destTokenAddress: + mockBridgeQuotesErc20Erc20V1[0].quote.destAsset.address, + srcChainId: + mockBridgeQuotesErc20Erc20V1[0].quote.srcAsset.chainId.toString(), + destChainId: + mockBridgeQuotesErc20Erc20V1[0].quote.destAsset.chainId.toString(), + srcTokenAmount: '1000000000000000000', + }; + const quoteRequest2 = { + ...quoteRequest, + srcTokenAddress: + mockBridgeQuotesNativeErc20V1[0].quote.srcAsset.address, + destTokenAddress: + mockBridgeQuotesNativeErc20V1[0].quote.destAsset.address, + srcChainId: + mockBridgeQuotesNativeErc20V1[0].quote.srcAsset.chainId.toString(), + destChainId: + mockBridgeQuotesNativeErc20V1[0].quote.destAsset.chainId.toString(), + srcTokenAmount: '1000000000000000000', + }; + + await rootMessenger.call( + 'BridgeController:updateBridgeQuoteRequestParams', + quoteRequest2, + metricsContext, + 4, + 1, + ); + await rootMessenger.call( + 'BridgeController:updateBridgeQuoteRequestParams', + quoteRequest2, + metricsContext, + 1, + 2, + ); + await rootMessenger.call( + 'BridgeController:updateBridgeQuoteRequestParams', + quoteRequest2, + metricsContext, + 4, + 1, + ); + await rootMessenger.call( + 'BridgeController:updateBridgeQuoteRequestParams', + quoteRequest0, + metricsContext, + 0, + 1, + ); + await rootMessenger.call( + 'BridgeController:updateBridgeQuoteRequestParams', + quoteRequest1, + metricsContext, + 1, + 2, + ); + await rootMessenger.call( + 'BridgeController:updateBridgeQuoteRequestParams', + quoteRequest1, + metricsContext, + 1, + 3, + ); + await rootMessenger.call( + 'BridgeController:updateBridgeQuoteRequestParams', + quoteRequest2, + metricsContext, + 2, + 3, + ); + + // Before polling starts + expect(stopAllPollingSpy).toHaveBeenCalledTimes(5); + expect(startPollingSpy).toHaveBeenCalledTimes(4); + expect( + startPollingSpy.mock.calls + .map((call) => call[0].quoteRequests) + .flat() + .find((call) => !call), + ).toBeUndefined(); + expect(bridgeController.state.quoteRequest).toStrictEqual([ + { ...quoteRequest0, insufficientBal: false }, + { ...quoteRequest1, insufficientBal: false }, + { ...quoteRequest2, insufficientBal: false }, + ]); + expect(fetchAssetPricesSpy).toHaveBeenCalledTimes(0); + const expectedState = { + ...DEFAULT_BRIDGE_CONTROLLER_STATE, + quoteRequest: [ + { ...quoteRequest0, insufficientBal: false }, + { ...quoteRequest1, insufficientBal: false }, + { ...quoteRequest2, insufficientBal: false }, + ], + quotesLoadingStatus: RequestStatus.LOADING, + }; + expect(bridgeController.state).toStrictEqual(expectedState); + + // Loading state + jest.advanceTimersByTime(1000); + await advanceToNthTimerThenFlush(); + expect(bridgeController.state.quotesLoadingStatus).toBe( + RequestStatus.LOADING, + ); + expect(hasSufficientBalanceSpy).toHaveBeenCalledTimes(4); + expect(fetchBridgeQuotesSpy).toHaveBeenCalledWith( + mockFetchFn, + [ + { + ...quoteRequest0, + insufficientBal: false, + resetApproval: false, + }, + { + ...quoteRequest1, + insufficientBal: false, + resetApproval: false, + }, + { + ...quoteRequest2, + insufficientBal: false, + resetApproval: false, + }, + ], + expect.any(AbortSignal), + FeatureId.BATCH_SELL, + BridgeClientId.EXTENSION, + 'AUTH_TOKEN', + BRIDGE_PROD_API_BASE_URL, + { + onQuoteValidationFailure: expect.any(Function), + onValidQuoteReceived: expect.any(Function), + onTokenWarning: expect.any(Function), + onComplete: expect.any(Function), + onClose: expect.any(Function), + }, + '13.8.0', + ); + const { quotesLastFetched: t1, ...stateWithoutTimestamp } = + bridgeController.state; + // eslint-disable-next-line jest/no-restricted-matchers + expect(stateWithoutTimestamp).toMatchSnapshot(); + expect(t1).toBeCloseTo(Date.now() - 1000); + + // After first fetch + jest.advanceTimersByTime(5000); + await flushPromises(); + expect(consoleWarnSpy.mock.calls).toMatchInlineSnapshot(`[]`); + expect(fetchAssetPricesSpy).toHaveBeenCalledTimes(1); + expect(bridgeController.state).toStrictEqual({ + ...expectedState, + quotesInitialLoadTime: 6000, + quoteRequest: [ + { + ...quoteRequest0, + insufficientBal: false, + resetApproval: false, + }, + { + ...quoteRequest1, + insufficientBal: false, + resetApproval: false, + }, + { + ...quoteRequest2, + insufficientBal: false, + resetApproval: false, + }, + ], + quotes: getMockBridgeQuotesNativeErc20V2() + .map((quote) => ({ + ...quote, + l1GasFeesInHexWei: '0x1', + resetApproval: undefined, + quoteRequestIndex: 0, + featureId: FeatureId.BATCH_SELL, + })) + .concat( + getMockBridgeQuotesErc20Erc20V2({ quoteRequestIndex: 1 }).map( + (quote) => ({ + ...quote, + l1GasFeesInHexWei: '0x2', + resetApproval: undefined, + quoteRequestIndex: 1, + featureId: FeatureId.BATCH_SELL, + }), + ), + ), + quotesRefreshCount: 1, + quotesLoadingStatus: 1, + quotesLastFetched: t1, + assetExchangeRates, + batchSellTrades: null, + batchSellTradesLoadingStatus: RequestStatus.LOADING, + }); + expect(fetchBridgeQuotesSpy).toHaveBeenCalledTimes(1); + expect(consoleLogSpy).toHaveBeenCalledTimes(0); + expect(hasSufficientBalanceSpy).toHaveBeenCalledTimes(4); + expect(getLayer1GasFeeMock).toHaveBeenCalledTimes(6); + // eslint-disable-next-line jest/no-restricted-matchers + expect(trackMetaMetricsFn.mock.calls).toMatchSnapshot(); + expect(fetchAssetPricesSpy).toHaveBeenCalledTimes(1); + }, + ); + }); + }); + + describe('fetch trades/fees', function () { + beforeEach(() => { + jest.clearAllMocks(); + jest.clearAllTimers(); + jest.resetAllMocks(); + }); + + it('should fetch batch gasless trades and fees', async function () { + await withController( + async ({ + controller: bridgeController, + rootMessenger, + stopAllPollingSpy, + startPollingSpy, + fetchAssetPricesSpy, + consoleLogSpy, + }) => { + jest.useFakeTimers(); + const abortControllerSpy = jest.spyOn( + AbortController.prototype, + 'abort', + ); + const fetchBatchSellTradesSpy = jest.spyOn( + fetchUtils, + 'fetchBatchSellTrades', + ); + const mockBatchSellTrades = { + transactions: [], + fee: { + amount: '100', + asset: { + symbol: 'USDC', + chainId: 10, + address: '0x1f9840a85d5af5bf1d1762f925bdaddc4201f984', + name: 'USD Coin', + decimals: 6, + assetId: + 'eip155:10/erc20:0x1f9840a85d5af5bf1d1762f925bdaddc4201f984', + } as const, + }, + }; + + // Before initial fetch + expect(stopAllPollingSpy).toHaveBeenCalledTimes(0); + expect(abortControllerSpy).toHaveBeenCalledTimes(0); + expect(fetchBatchSellTradesSpy).toHaveBeenCalledTimes(0); + expect(startPollingSpy).not.toHaveBeenCalled(); + expect(bridgeController.state.batchSellTrades).toBeNull(); + expect( + bridgeController.state.batchSellTradesLoadingStatus, + ).toBeNull(); + expect(fetchAssetPricesSpy).toHaveBeenCalledTimes(0); + expect(bridgeController.state).toStrictEqual( + DEFAULT_BRIDGE_CONTROLLER_STATE, + ); + + fetchBatchSellTradesSpy.mockImplementationOnce( + () => + new Promise((resolve) => { + jest.useRealTimers(); + setTimeout(() => { + jest.useFakeTimers(); + resolve(mockBatchSellTrades); + }, 2000); + }), + ); + + // Initial fetch + await rootMessenger.call( + 'BridgeController:updateBatchSellTrades', + [], + false, + ); + + await jest.advanceTimersByTimeAsync(1000); + await flushPromises(); + + expect(stopAllPollingSpy).toHaveBeenCalledTimes(0); + expect(abortControllerSpy).toHaveBeenCalledTimes(0); + expect(fetchBatchSellTradesSpy.mock.calls[0][0]).toStrictEqual([]); + expect(startPollingSpy).not.toHaveBeenCalled(); + expect(bridgeController.state.batchSellTrades).toStrictEqual( + mockBatchSellTrades, + ); + expect(fetchAssetPricesSpy).toHaveBeenCalledTimes(0); + + await jest.advanceTimersByTimeAsync(1000); + await flushPromises(); + + expect(bridgeController.state).toStrictEqual({ + ...DEFAULT_BRIDGE_CONTROLLER_STATE, + batchSellTradesLoadingStatus: RequestStatus.FETCHED, + batchSellTrades: mockBatchSellTrades, + }); + + expect(fetchBatchSellTradesSpy).toHaveBeenCalledTimes(1); + expect(consoleLogSpy.mock.calls).toMatchInlineSnapshot(`[]`); + expect(trackMetaMetricsFn).toHaveBeenCalledTimes(0); + jest.useRealTimers(); + }, + ); + }); + + it('should abort previous fetch if new fetch is called', async function () { + await withController( + async ({ + controller: bridgeController, + rootMessenger, + stopAllPollingSpy, + startPollingSpy, + fetchAssetPricesSpy, + consoleLogSpy, + }) => { + jest.useFakeTimers(); + const abortControllerSpy = jest.spyOn( + AbortController.prototype, + 'abort', + ); + const fetchBatchSellTradesSpy = jest.spyOn( + fetchUtils, + 'fetchBatchSellTrades', + ); + const mockBatchSellTrades = { + transactions: [], + fee: { + amount: '100', + asset: { + symbol: 'USDC', + chainId: 10, + address: '0x1f9840a85d5af5bf1d1762f925bdaddc4201f984', + name: 'USD Coin', + decimals: 6, + assetId: + 'eip155:10/erc20:0x1f9840a85d5af5bf1d1762f925bdaddc4201f984', + } as const, + }, + }; + const mockBatchSellTrades2 = { + transactions: [], + fee: { + amount: '500', + asset: { + ...mockBatchSellTrades.fee.asset, + }, + }, + }; + + // Before initial fetch + expect(stopAllPollingSpy).toHaveBeenCalledTimes(0); + expect(abortControllerSpy).toHaveBeenCalledTimes(0); + expect(fetchBatchSellTradesSpy).toHaveBeenCalledTimes(0); + expect(startPollingSpy).not.toHaveBeenCalled(); + expect(bridgeController.state.batchSellTrades).toBeNull(); + expect( + bridgeController.state.batchSellTradesLoadingStatus, + ).toBeNull(); + expect(fetchAssetPricesSpy).toHaveBeenCalledTimes(0); + expect(bridgeController.state).toStrictEqual( + DEFAULT_BRIDGE_CONTROLLER_STATE, + ); + + fetchBatchSellTradesSpy.mockImplementationOnce( + () => + new Promise((resolve) => { + jest.useRealTimers(); + setTimeout(() => { + jest.useFakeTimers(); + resolve(mockBatchSellTrades); + }, 2000); + }), + ); + + fetchBatchSellTradesSpy.mockImplementationOnce( + () => + new Promise((resolve) => { + resolve(mockBatchSellTrades2); + }), + ); + + // Call twice in a row + await rootMessenger.call( + 'BridgeController:updateBatchSellTrades', + [], + false, + ); + await rootMessenger.call( + 'BridgeController:updateBatchSellTrades', + getMockBridgeQuotesErc20Erc20V2(), + false, + ); + + await jest.advanceTimersByTimeAsync(1000); + await flushPromises(); + + expect(stopAllPollingSpy).toHaveBeenCalledTimes(0); + expect(abortControllerSpy).toHaveBeenCalledTimes(1); + expect(fetchBatchSellTradesSpy.mock.calls[0][0]).toStrictEqual([]); + expect(startPollingSpy).not.toHaveBeenCalled(); + expect(bridgeController.state.batchSellTrades).toStrictEqual( + mockBatchSellTrades2, + ); + expect( + bridgeController.state.batchSellTradesLoadingStatus, + ).toStrictEqual(RequestStatus.FETCHED); + expect(fetchAssetPricesSpy).toHaveBeenCalledTimes(0); + + await jest.advanceTimersByTimeAsync(1000); + await flushPromises(); + + expect(bridgeController.state).toStrictEqual({ + ...DEFAULT_BRIDGE_CONTROLLER_STATE, + batchSellTradesLoadingStatus: RequestStatus.FETCHED, + batchSellTrades: mockBatchSellTrades2, + }); + + expect(fetchBatchSellTradesSpy).toHaveBeenCalledTimes(2); + expect(fetchBatchSellTradesSpy.mock.calls[0]).toStrictEqual([ + [], + false, + expect.any(AbortSignal), + 'extension', + 'AUTH_TOKEN', + expect.any(Function), + 'https://bridge.api.cx.metamask.io', + '13.8.0', + ]); + expect(fetchBatchSellTradesSpy.mock.calls[1]).toStrictEqual([ + getMockBridgeQuotesErc20Erc20V2(), + false, + expect.any(AbortSignal), + 'extension', + 'AUTH_TOKEN', + expect.any(Function), + 'https://bridge.api.cx.metamask.io', + '13.8.0', + ]); + expect(consoleLogSpy.mock.calls).toMatchInlineSnapshot(`[]`); + expect(trackMetaMetricsFn).toHaveBeenCalledTimes(0); + jest.useRealTimers(); + }, + ); + }); + + it('should abort previous fetch if resetState is called', async function () { + await withController( + async ({ + controller: bridgeController, + rootMessenger, + stopAllPollingSpy, + startPollingSpy, + fetchAssetPricesSpy, + consoleLogSpy, + }) => { + jest.useFakeTimers(); + const abortControllerSpy = jest.spyOn( + AbortController.prototype, + 'abort', + ); + const fetchBatchSellTradesSpy = jest.spyOn( + fetchUtils, + 'fetchBatchSellTrades', + ); + const mockBatchSellTrades = { + transactions: [], + fee: { + amount: '100', + asset: { + symbol: 'USDC', + chainId: 10, + address: '0x1f9840a85d5af5bf1d1762f925bdaddc4201f984', + name: 'USD Coin', + decimals: 6, + assetId: + 'eip155:10/erc20:0x1f9840a85d5af5bf1d1762f925bdaddc4201f984', + } as const, + }, + }; + + // Before initial fetch + expect(bridgeController.state).toStrictEqual( + DEFAULT_BRIDGE_CONTROLLER_STATE, + ); + + fetchBatchSellTradesSpy.mockImplementationOnce( + () => + new Promise((resolve) => { + jest.useRealTimers(); + setTimeout(() => { + jest.useFakeTimers(); + resolve(mockBatchSellTrades); + }, 2000); + }), + ); + + // Reset after starting fetch + await rootMessenger.call( + 'BridgeController:updateBatchSellTrades', + [], + false, + ); + rootMessenger.call('BridgeController:resetState'); + + await jest.advanceTimersByTimeAsync(1000); + await flushPromises(); + + expect(stopAllPollingSpy).toHaveBeenCalledTimes(1); + expect(abortControllerSpy).toHaveBeenCalledTimes(2); + expect(fetchBatchSellTradesSpy.mock.calls[0][0]).toStrictEqual([]); + expect(startPollingSpy).not.toHaveBeenCalled(); + expect(bridgeController.state.batchSellTrades).toBeNull(); + expect( + bridgeController.state.batchSellTradesLoadingStatus, + ).toBeNull(); + expect(fetchAssetPricesSpy).toHaveBeenCalledTimes(0); + + await jest.advanceTimersByTimeAsync(1000); + await flushPromises(); + + expect(bridgeController.state).toStrictEqual( + DEFAULT_BRIDGE_CONTROLLER_STATE, + ); + + expect(fetchBatchSellTradesSpy).toHaveBeenCalledTimes(1); + expect(fetchBatchSellTradesSpy.mock.calls[0]).toStrictEqual([ + [], + false, + expect.any(AbortSignal), + 'extension', + 'AUTH_TOKEN', + expect.any(Function), + 'https://bridge.api.cx.metamask.io', + '13.8.0', + ]); + expect(consoleLogSpy.mock.calls).toMatchInlineSnapshot(`[]`); + expect(trackMetaMetricsFn).toHaveBeenCalledTimes(0); + jest.useRealTimers(); + }, + ); + }); + + it('should reset batch trade states if fetch throws an error', async function () { + await withController( + async ({ + controller: bridgeController, + rootMessenger, + stopAllPollingSpy, + startPollingSpy, + fetchAssetPricesSpy, + consoleLogSpy, + }) => { + jest.useFakeTimers(); + const abortControllerSpy = jest.spyOn( + AbortController.prototype, + 'abort', + ); + const fetchBatchSellTradesSpy = jest.spyOn( + fetchUtils, + 'fetchBatchSellTrades', + ); + const mockBatchSellTrades = { + transactions: [], + fee: { + amount: '100', + asset: { + symbol: 'USDC', + chainId: 10, + address: '0x1f9840a85d5af5bf1d1762f925bdaddc4201f984', + name: 'USD Coin', + decimals: 6, + assetId: + 'eip155:10/erc20:0x1f9840a85d5af5bf1d1762f925bdaddc4201f984', + } as const, + }, + }; + + expect(bridgeController.state).toStrictEqual( + DEFAULT_BRIDGE_CONTROLLER_STATE, + ); + + fetchBatchSellTradesSpy.mockImplementationOnce( + () => + new Promise((resolve) => { + jest.useRealTimers(); + setTimeout(() => { + jest.useFakeTimers(); + resolve(mockBatchSellTrades); + }, 1000); + }), + ); + fetchBatchSellTradesSpy.mockRejectedValueOnce( + new Error('Network error'), + ); + + // 1st fetch + await rootMessenger.call( + 'BridgeController:updateBatchSellTrades', + [], + false, + ); + + await jest.advanceTimersByTimeAsync(1000); + await flushPromises(); + + expect(stopAllPollingSpy).toHaveBeenCalledTimes(0); + expect(abortControllerSpy).toHaveBeenCalledTimes(0); + expect(fetchBatchSellTradesSpy.mock.calls[0][0]).toStrictEqual([]); + expect(startPollingSpy).not.toHaveBeenCalled(); + expect(bridgeController.state.batchSellTrades).toStrictEqual( + mockBatchSellTrades, + ); + expect(fetchAssetPricesSpy).toHaveBeenCalledTimes(0); + expect(bridgeController.state.batchSellTradesLoadingStatus).toBe( + RequestStatus.FETCHED, + ); + + expect(bridgeController.state).toStrictEqual({ + ...DEFAULT_BRIDGE_CONTROLLER_STATE, + batchSellTrades: mockBatchSellTrades, + batchSellTradesLoadingStatus: RequestStatus.FETCHED, + }); + + // 2nd fetch + await rootMessenger.call( + 'BridgeController:updateBatchSellTrades', + getMockBridgeQuotesErc20Erc20V2(), + false, + ); + + await jest.advanceTimersByTimeAsync(2000); + await flushPromises(); + + expect(stopAllPollingSpy).toHaveBeenCalledTimes(0); + expect(abortControllerSpy).toHaveBeenCalledTimes(1); + expect(fetchBatchSellTradesSpy.mock.calls[1][0]).toStrictEqual( + getMockBridgeQuotesErc20Erc20V2(), + ); + expect(startPollingSpy).not.toHaveBeenCalled(); + expect(bridgeController.state.batchSellTrades).toBeNull(); + expect(bridgeController.state.batchSellTradesLoadingStatus).toBe( + RequestStatus.ERROR, + ); + expect(fetchAssetPricesSpy).toHaveBeenCalledTimes(0); + + expect(bridgeController.state).toStrictEqual({ + ...DEFAULT_BRIDGE_CONTROLLER_STATE, + batchSellTradesLoadingStatus: RequestStatus.ERROR, + }); + + expect(fetchBatchSellTradesSpy).toHaveBeenCalledTimes(2); + expect(consoleLogSpy.mock.calls).toMatchInlineSnapshot(` + [ + [ + "Failed to fetch batch sell trades", + [Error: Network error], + ], + ] + `); + expect(trackMetaMetricsFn).toHaveBeenCalledTimes(0); + jest.useRealTimers(); + }, + ); + }); + }); +}); diff --git a/packages/bridge-controller/src/bridge-controller.sse.test.ts b/packages/bridge-controller/src/bridge-controller.sse.test.ts new file mode 100644 index 00000000000..2c412553bf3 --- /dev/null +++ b/packages/bridge-controller/src/bridge-controller.sse.test.ts @@ -0,0 +1,1934 @@ +import { BigNumber } from '@ethersproject/bignumber'; +import * as ethersContractUtils from '@ethersproject/contracts'; +import type { TraceRequest } from '@metamask/controller-utils'; +import { SolScope } from '@metamask/keyring-api'; +import { Messenger, MOCK_ANY_NAMESPACE } from '@metamask/messenger'; +import type { + MessengerActions, + MessengerEvents, + MockAnyNamespace, +} from '@metamask/messenger'; +import { abiERC20 } from '@metamask/metamask-eth-abis'; + +import { flushPromises } from '../../../tests/helpers.js'; +import { + mockBridgeQuotesErc20Erc20V1, + getMockBridgeQuotesErc20Erc20V2, +} from '../tests/mock-quotes-erc20-erc20.js'; +import { + getMockBridgeQuotesNativeErc20EthV2, + mockBridgeQuotesNativeErc20EthV1, +} from '../tests/mock-quotes-native-erc20-eth.js'; +import { + getMockBridgeQuotesNativeErc20V2, + mockBridgeQuotesNativeErc20V1, +} from '../tests/mock-quotes-native-erc20.js'; +import { + advanceToNthTimer, + advanceToNthTimerThenFlush, + mockSseEventSource, + mockSseEventSourceWithComplete, + mockSseEventSourceWithMultipleDelays, + mockSseEventSourceWithWarnings, + mockSseServerError, +} from '../tests/mock-sse.js'; +import { BridgeController } from './bridge-controller.js'; +import { + BridgeClientId, + BRIDGE_PROD_API_BASE_URL, + DEFAULT_BRIDGE_CONTROLLER_STATE, + ETH_USDT_ADDRESS, +} from './constants/bridge.js'; +import { TraceName } from './constants/traces.js'; +import { ChainId, RequestStatus } from './types.js'; +import type { BridgeControllerMessenger } from './types.js'; +import * as balanceUtils from './utils/balance.js'; +import { + formatChainIdToCaip, + formatChainIdToDec, +} from './utils/caip-formatters.js'; +import * as featureFlagUtils from './utils/feature-flags.js'; +import * as fetchUtils from './utils/fetch.js'; +import { AbortReason } from './utils/metrics/constants.js'; +import { FeatureId } from './validators/feature-flags.js'; +import { validateQuoteResponseV1 } from './validators/quote-response-v1.js'; +import { QuoteStreamCompleteReason } from './validators/quote-stream-complete.js'; +import { TokenFeatureType } from './validators/token-feature.js'; +import type { TxData } from './validators/trade.js'; + +jest.mock('uuid', () => ({ + v4: (): string => 'test-uuid-1234', +})); + +type RootMessenger = Messenger< + MockAnyNamespace, + MessengerActions, + MessengerEvents +>; + +const BRIDGE_CONTROLLER_ALLOWED_EXTERNAL_ACTIONS = [ + 'AccountsController:getAccountByAddress', + 'AuthenticationController:getBearerToken', + 'CurrencyRateController:getState', + 'TokenRatesController:getState', + 'MultichainAssetsRatesController:getState', + 'SnapController:handleRequest', + 'NetworkController:findNetworkClientIdByChainId', + 'NetworkController:getNetworkClientById', + 'RemoteFeatureFlagController:getState', + 'AssetsController:getExchangeRatesForBridge', +] as const; + +const messengerCallMock = jest.fn(); +const getLayer1GasFeeMock = jest.fn(); +const mockFetchFn = jest.fn(); +const trackMetaMetricsFn = jest.fn(); + +const FIRST_FETCH_DELAY = 4000; +const SECOND_FETCH_DELAY = 9000; +const THIRD_FETCH_DELAY = 2000; +const FOURTH_FETCH_DELAY = 3000; + +const quoteRequest = { + srcChainId: '0x1', + destChainId: SolScope.Mainnet, + srcTokenAddress: '0x0000000000000000000000000000000000000000', + destTokenAddress: '123d1', + srcTokenAmount: '1000000000000000000', + slippage: 0.5, + walletAddress: '0x30E8ccaD5A980BDF30447f8c2C48e70989D9d294', + destWalletAddress: 'SolanaWalletAddres1234', + resetApproval: false, +}; +const metricsContext = { + feature_id: FeatureId.UNIFIED_SWAP_BRIDGE, + token_symbol_source: 'ETH', + token_symbol_destination: 'USDC', + usd_amount_source: 100, + stx_enabled: true, + security_warnings: [], + warnings: [], + token_security_type_destination: null, +}; + +const createTraceCallback = (traceRequests: TraceRequest[]): jest.Mock => + jest + .fn() + .mockImplementation( + async (request: TraceRequest, callback?: () => unknown) => { + traceRequests.push(request); + return await callback?.(); + }, + ); + +const assetExchangeRates = { + 'eip155:10/erc20:0x1f9840a85d5af5bf1d1762f925bdaddc4201f984': { + exchangeRate: undefined, + usdExchangeRate: '100', + }, +}; + +type WithControllerCallback = (payload: { + controller: BridgeController; + rootMessenger: RootMessenger; + stopAllPollingSpy: jest.SpyInstance; + startPollingSpy: jest.SpyInstance; + hasSufficientBalanceSpy: jest.SpyInstance; + fetchBridgeQuotesSpy: jest.SpyInstance; + fetchAssetPricesSpy: jest.SpyInstance; + consoleLogSpy: jest.SpyInstance; +}) => Promise | ReturnValue; + +type WithControllerOptions = { + options?: Partial[0]>; +}; + +async function withController( + ...args: + | [WithControllerCallback] + | [WithControllerOptions, WithControllerCallback] +): Promise { + const [{ options = {} }, testFunction] = + args.length === 2 ? args : [{}, args[0]]; + + const rootMessenger: RootMessenger = new Messenger({ + namespace: MOCK_ANY_NAMESPACE, + }); + + const messenger: BridgeControllerMessenger = new Messenger({ + namespace: 'BridgeController', + parent: rootMessenger, + }); + + rootMessenger.delegate({ + messenger, + actions: [...BRIDGE_CONTROLLER_ALLOWED_EXTERNAL_ACTIONS], + }); + + for (const action of BRIDGE_CONTROLLER_ALLOWED_EXTERNAL_ACTIONS) { + rootMessenger.registerActionHandler(action, (...actionArgs) => + messengerCallMock(action, ...actionArgs), + ); + } + + jest.useFakeTimers(); + + getLayer1GasFeeMock.mockResolvedValue('0x1'); + + messengerCallMock.mockImplementation( + (...messengerArgs: Parameters) => { + switch (messengerArgs[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, + refreshRate: 30000, + support: true, + sse: { + enabled: true, + minimumVersion: '13.8.0', + }, + chains: { + '10': { isActiveSrc: true, isActiveDest: false }, + '534352': { isActiveSrc: true, isActiveDest: false }, + '137': { isActiveSrc: false, isActiveDest: true }, + '42161': { isActiveSrc: false, isActiveDest: true }, + [ChainId.SOLANA]: { + isActiveSrc: true, + isActiveDest: true, + }, + }, + chainRanking: [{ chainId: 'eip155:1' as const, name: 'Ethereum' }], + }); + + const controller = new BridgeController({ + messenger, + getLayer1GasFee: getLayer1GasFeeMock, + clientId: BridgeClientId.EXTENSION, + fetchFn: mockFetchFn, + trackMetaMetricsFn, + clientVersion: '13.8.0', + ...options, + }); + + const stopAllPollingSpy = jest.spyOn(controller, 'stopAllPolling'); + const startPollingSpy = jest.spyOn(controller, 'startPolling'); + const hasSufficientBalanceSpy = jest + .spyOn(balanceUtils, 'hasSufficientBalance') + .mockResolvedValue(true); + const fetchBridgeQuotesSpy = jest.spyOn(fetchUtils, 'fetchBridgeQuoteStream'); + const fetchAssetPricesSpy = jest + .spyOn(fetchUtils, 'fetchAssetPrices') + .mockResolvedValue({ + 'eip155:10/erc20:0x1f9840a85d5af5bf1d1762f925bdaddc4201f984': { + usd: '100', + }, + }); + + const consoleLogSpy = jest.spyOn(console, 'log'); + + return await testFunction({ + controller, + rootMessenger, + stopAllPollingSpy, + startPollingSpy, + hasSufficientBalanceSpy, + fetchBridgeQuotesSpy, + fetchAssetPricesSpy, + consoleLogSpy, + }); +} + +describe('BridgeController SSE', function () { + beforeEach(() => { + jest.clearAllMocks(); + jest.clearAllTimers(); + jest.resetAllMocks(); + }); + + describe('quote tracing', () => { + const runTraceScenario = async ({ + response, + request = quoteRequest, + abort = false, + }: { + response: () => unknown; + request?: typeof quoteRequest; + abort?: boolean; + }): Promise => { + const traceRequests: TraceRequest[] = []; + + await withController( + { + options: { + traceFn: createTraceCallback(traceRequests), + }, + }, + async ({ controller, rootMessenger }) => { + mockFetchFn.mockImplementationOnce(async () => response()); + await rootMessenger.call( + 'BridgeController:updateBridgeQuoteRequestParams', + request, + metricsContext, + ); + jest.advanceTimersByTime(1000); + await advanceToNthTimerThenFlush(); + if (abort) { + controller.stopPollingForQuotes(AbortReason.NewQuoteRequest); + } + jest.advanceTimersByTime(11000); + await flushPromises(); + }, + ); + + return traceRequests; + }; + + const getTrace = ( + requests: TraceRequest[], + name: TraceName, + ): TraceRequest | undefined => + requests.find((request) => request.name === name); + + it('records cross-chain success and the first result from each provider', async () => { + const firstQuote = mockBridgeQuotesErc20Erc20V1[0]; + const secondProviderQuote = { + ...firstQuote, + quote: { + ...firstQuote.quote, + requestId: 'second-provider-request', + bridgeId: 'hop', + bridges: ['hop'], + protocols: ['hop'], + }, + }; + + const requests = await runTraceScenario({ + response: (): unknown => + mockSseEventSource([firstQuote, firstQuote, secondProviderQuote]), + }); + const providerTraces = requests.filter( + (request) => request.name === TraceName.QuoteProviderFirstResult, + ); + + expect( + getTrace(requests, TraceName.BridgeQuotesFetched)?.data, + ).toStrictEqual( + expect.objectContaining({ + request_id: 'test-uuid-1234', + feature_id: FeatureId.UNIFIED_SWAP_BRIDGE, + result: 'success', + }), + ); + expect( + providerTraces.map((request) => request.data?.provider), + ).toStrictEqual(['socket_across', 'hop_hop']); + expect( + providerTraces.every((request) => Number.isFinite(request.startTime)), + ).toBe(true); + }); + + it.each([ + { + name: 'same-chain success', + response: (): unknown => + mockSseEventSource([mockBridgeQuotesErc20Erc20V1[0]]), + request: { + ...quoteRequest, + destChainId: quoteRequest.srcChainId, + }, + traceName: TraceName.SwapQuotesFetched, + result: 'success', + providerCount: 1, + }, + { + name: 'no quotes', + response: (): unknown => mockSseEventSource([]), + request: quoteRequest, + traceName: TraceName.BridgeQuotesFetched, + result: 'no_quotes', + providerCount: 0, + }, + { + name: 'error', + response: (): unknown => mockSseServerError('provider request failed'), + request: quoteRequest, + traceName: TraceName.BridgeQuotesFetched, + result: 'error', + providerCount: 0, + }, + ])('records $name', async (scenario) => { + const requests = await runTraceScenario(scenario); + + expect(getTrace(requests, scenario.traceName)?.data).toStrictEqual( + expect.objectContaining({ + result: scenario.result, + }), + ); + expect( + requests.filter( + (request) => request.name === TraceName.QuoteProviderFirstResult, + ), + ).toHaveLength(scenario.providerCount); + }); + + it('records cancellation for an expected abort', async () => { + const requests = await runTraceScenario({ + response: (): unknown => + mockSseEventSource([mockBridgeQuotesErc20Erc20V1[0]], 10000), + abort: true, + }); + + expect( + getTrace(requests, TraceName.BridgeQuotesFetched)?.data, + ).toStrictEqual( + expect.objectContaining({ + result: 'cancelled', + }), + ); + }); + }); + + it('should trigger quote polling if request is valid', async function () { + await withController( + async ({ + controller: bridgeController, + rootMessenger, + stopAllPollingSpy, + startPollingSpy, + hasSufficientBalanceSpy, + fetchBridgeQuotesSpy, + fetchAssetPricesSpy, + consoleLogSpy, + }) => { + mockFetchFn.mockImplementationOnce(async () => { + return mockSseEventSource(mockBridgeQuotesNativeErc20V1); + }); + await rootMessenger.call( + 'BridgeController:updateBridgeQuoteRequestParams', + quoteRequest, + metricsContext, + ); + + // Before polling starts + expect(stopAllPollingSpy).toHaveBeenCalledTimes(1); + expect(startPollingSpy).toHaveBeenCalledTimes(1); + expect(hasSufficientBalanceSpy).toHaveBeenCalledTimes(1); + expect(startPollingSpy).toHaveBeenCalledWith({ + quoteRequests: [ + { + ...quoteRequest, + insufficientBal: false, + }, + ], + context: metricsContext, + }); + expect(fetchAssetPricesSpy).toHaveBeenCalledTimes(0); + const expectedState = { + ...DEFAULT_BRIDGE_CONTROLLER_STATE, + quoteRequest: [{ ...quoteRequest, insufficientBal: false }], + quotesLoadingStatus: RequestStatus.LOADING, + }; + expect(bridgeController.state).toStrictEqual(expectedState); + + // Loading state + jest.advanceTimersByTime(1000); + await advanceToNthTimerThenFlush(); + expect(fetchBridgeQuotesSpy).toHaveBeenCalledWith( + mockFetchFn, + [ + { + ...quoteRequest, + insufficientBal: false, + resetApproval: false, + }, + ], + expect.any(AbortSignal), + FeatureId.UNIFIED_SWAP_BRIDGE, + BridgeClientId.EXTENSION, + 'AUTH_TOKEN', + BRIDGE_PROD_API_BASE_URL, + { + onQuoteValidationFailure: expect.any(Function), + onValidQuoteReceived: expect.any(Function), + onTokenWarning: expect.any(Function), + onComplete: expect.any(Function), + onClose: expect.any(Function), + }, + '13.8.0', + ); + const { quotesLastFetched: t1, ...stateWithoutTimestamp } = + bridgeController.state; + // eslint-disable-next-line jest/no-restricted-matchers + expect(stateWithoutTimestamp).toMatchSnapshot(); + expect(t1).toBeCloseTo(Date.now() - 1000); + + // After first fetch + jest.advanceTimersByTime(5000); + await flushPromises(); + expect(fetchAssetPricesSpy).toHaveBeenCalledTimes(1); + expect(bridgeController.state).toStrictEqual({ + ...expectedState, + quotesInitialLoadTime: 6000, + quoteRequest: [ + { + ...quoteRequest, + insufficientBal: false, + resetApproval: false, + }, + ], + quotes: getMockBridgeQuotesNativeErc20V2().map((quote) => ({ + ...quote, + l1GasFeesInHexWei: '0x1', + resetApproval: undefined, + featureId: FeatureId.UNIFIED_SWAP_BRIDGE, + })), + quotesRefreshCount: 1, + quotesLoadingStatus: 1, + quotesLastFetched: t1, + assetExchangeRates, + }); + expect(fetchBridgeQuotesSpy).toHaveBeenCalledTimes(1); + expect(consoleLogSpy).toHaveBeenCalledTimes(0); + expect(hasSufficientBalanceSpy).toHaveBeenCalledTimes(1); + expect(getLayer1GasFeeMock).toHaveBeenCalledTimes(2); + // eslint-disable-next-line jest/no-restricted-matchers + expect(trackMetaMetricsFn.mock.calls).toMatchSnapshot(); + }, + ); + }); + + it.each([ + [ + 'swapping', + '1', + '0x1', + '0x095ea7b3000000000000000000000000881d40237659c251811cec9c364ef91dc08d300c0000000000000000000000000000000000000000000000000000000000000000', + ], + [ + 'bridging', + '1', + SolScope.Mainnet, + '0x095ea7b30000000000000000000000000439e60f02a8900a951603950d8d4527f400c3f10000000000000000000000000000000000000000000000000000000000000000', + ], + ['swapping', '0', '0x1', undefined, false, 1], + ])( + 'should append resetApproval when %s USDT on Ethereum (%s, %s)', + async function ( + _: string, + allowance: string, + destChainId: string, + tradeData?: string, + resetApproval: boolean = true, + mockContractCalls: number = 3, + srcTokenAddress: string = ETH_USDT_ADDRESS, + ) { + await withController( + async ({ + controller: bridgeController, + rootMessenger, + stopAllPollingSpy, + startPollingSpy, + fetchBridgeQuotesSpy, + consoleLogSpy, + }) => { + const mockUSDTQuoteResponse = getMockBridgeQuotesErc20Erc20V2({ + quote: { + srcAsset: { + address: ETH_USDT_ADDRESS, + assetId: + `${formatChainIdToCaip(1)}/erc20:${srcTokenAddress}` as const, + symbol: 'USDT', + name: 'Tether USD', + decimals: 6, + chainId: 1, + iconUrl: 'https://media.socket.tech/tokens/all/USDT', + }, + srcChainId: 1, + }, + }); + mockFetchFn.mockImplementationOnce(async () => { + return mockSseEventSource(mockUSDTQuoteResponse); + }); + + const contractMock = new ethersContractUtils.Contract( + ETH_USDT_ADDRESS, + abiERC20, + ); + const contractMockSpy = jest + .spyOn(ethersContractUtils, 'Contract') + .mockImplementation(() => { + return { + ...jest.requireActual('@ethersproject/contracts').Contract, + interface: contractMock.interface, + allowance: jest + .fn() + .mockResolvedValue(BigNumber.from(allowance)), + }; + }); + + const usdtQuoteRequest = { + ...quoteRequest, + srcTokenAddress, + srcChainId: '0x1', + destChainId, + }; + + await rootMessenger.call( + 'BridgeController:updateBridgeQuoteRequestParams', + usdtQuoteRequest, + metricsContext, + ); + + // Before polling starts + expect(stopAllPollingSpy).toHaveBeenCalledTimes(1); + expect(startPollingSpy).toHaveBeenCalledTimes(1); + expect(startPollingSpy).toHaveBeenCalledWith({ + quoteRequests: [ + { + ...usdtQuoteRequest, + insufficientBal: false, + resetApproval, + }, + ], + context: metricsContext, + }); + const expectedState = { + ...DEFAULT_BRIDGE_CONTROLLER_STATE, + quoteRequest: [ + { ...usdtQuoteRequest, insufficientBal: false, resetApproval }, + ], + quotesLoadingStatus: RequestStatus.LOADING, + }; + expect(bridgeController.state).toStrictEqual(expectedState); + + // Loading state + jest.advanceTimersByTime(1000); + await advanceToNthTimerThenFlush(); + expect(fetchBridgeQuotesSpy).toHaveBeenCalledWith( + mockFetchFn, + [ + { + ...usdtQuoteRequest, + insufficientBal: false, + resetApproval, + }, + ], + expect.any(AbortSignal), + FeatureId.UNIFIED_SWAP_BRIDGE, + BridgeClientId.EXTENSION, + 'AUTH_TOKEN', + BRIDGE_PROD_API_BASE_URL, + { + onQuoteValidationFailure: expect.any(Function), + onValidQuoteReceived: expect.any(Function), + onTokenWarning: expect.any(Function), + onComplete: expect.any(Function), + onClose: expect.any(Function), + }, + '13.8.0', + ); + const { quotesLastFetched: t1, quoteRequest: stateQuoteRequest } = + bridgeController.state; + expect(stateQuoteRequest).toStrictEqual([ + { + ...usdtQuoteRequest, + insufficientBal: false, + resetApproval, + }, + ]); + expect(t1).toBeCloseTo(Date.now() - 1000); + + // After first fetch + jest.advanceTimersByTime(5000); + await flushPromises(); + expect(bridgeController.state).toStrictEqual({ + ...expectedState, + quotesInitialLoadTime: 6000, + quoteRequest: [ + { + ...usdtQuoteRequest, + insufficientBal: false, + resetApproval, + }, + ], + quotes: getMockBridgeQuotesErc20Erc20V2({ + quote: { + srcAsset: { + address: ETH_USDT_ADDRESS, + assetId: `eip155:1/erc20:${ETH_USDT_ADDRESS}`, + symbol: 'USDT', + name: 'Tether USD', + decimals: 6, + chainId: 1, + iconUrl: 'https://media.socket.tech/tokens/all/USDT', + }, + srcChainId: 1, + destChainId: formatChainIdToDec(destChainId), + }, + }).map((quote) => ({ + ...quote, + featureId: FeatureId.UNIFIED_SWAP_BRIDGE, + resetApproval: tradeData + ? { + ...quote.approval, + data: tradeData, + } + : undefined, + })), + quotesRefreshCount: 1, + quotesLoadingStatus: 1, + quotesLastFetched: t1, + assetExchangeRates, + }); + expect(fetchBridgeQuotesSpy).toHaveBeenCalledTimes(1); + expect(consoleLogSpy).toHaveBeenCalledTimes(0); + expect(getLayer1GasFeeMock).not.toHaveBeenCalled(); + expect(contractMockSpy.mock.calls).toHaveLength(mockContractCalls); + }, + ); + }, + ); + + it('should use resetApproval and insufficientBal fallback values if provider is not found', async function () { + await withController( + async ({ + controller: bridgeController, + rootMessenger, + stopAllPollingSpy, + startPollingSpy, + fetchBridgeQuotesSpy, + consoleLogSpy, + }) => { + messengerCallMock.mockImplementation( + (...args: Parameters) => { + if (args[0] === 'AuthenticationController:getBearerToken') { + return 'AUTH_TOKEN'; + } + return { + address: '0x123', + provider: undefined, + currencyRates: {}, + marketData: {}, + conversionRates: {}, + } as never; + }, + ); + const mockUSDTQuoteResponse = mockBridgeQuotesErc20Erc20V1.map( + (quote) => ({ + ...quote, + quote: { + ...quote.quote, + srcAsset: { + address: ETH_USDT_ADDRESS, + assetId: `eip155:1/erc20:${ETH_USDT_ADDRESS}` as const, + chainId: 1, + symbol: 'USDT', + name: 'Tether USD', + decimals: 6, + iconUrl: 'https://media.socket.tech/tokens/all/USDT', + }, + srcChainId: 1, + }, + }), + ); + mockUSDTQuoteResponse.forEach((quote) => + validateQuoteResponseV1(quote), + ); + + mockFetchFn.mockImplementationOnce(async () => { + return mockSseEventSource(mockUSDTQuoteResponse); + }); + + const contractMock = new ethersContractUtils.Contract( + ETH_USDT_ADDRESS, + abiERC20, + ); + const contractMockSpy = jest + .spyOn(ethersContractUtils, 'Contract') + .mockImplementation(() => { + return { + ...jest.requireActual('@ethersproject/contracts').Contract, + interface: contractMock.interface, + allowance: jest.fn().mockResolvedValue(BigNumber.from('1')), + }; + }); + + const usdtQuoteRequest = { + ...quoteRequest, + srcTokenAddress: ETH_USDT_ADDRESS, + srcChainId: '0x1', + }; + + await rootMessenger.call( + 'BridgeController:updateBridgeQuoteRequestParams', + usdtQuoteRequest, + metricsContext, + ); + + // Before polling starts + expect(stopAllPollingSpy).toHaveBeenCalledTimes(1); + expect(startPollingSpy).toHaveBeenCalledTimes(1); + expect(startPollingSpy).toHaveBeenCalledWith({ + quoteRequests: [ + { + ...usdtQuoteRequest, + insufficientBal: true, + resetApproval: true, + }, + ], + context: metricsContext, + }); + const expectedState = { + ...DEFAULT_BRIDGE_CONTROLLER_STATE, + quoteRequest: [ + { ...usdtQuoteRequest, insufficientBal: true, resetApproval: true }, + ], + quotesLoadingStatus: RequestStatus.LOADING, + }; + expect(bridgeController.state).toStrictEqual(expectedState); + + // Loading state + jest.advanceTimersByTime(1000); + // Wait for JWT token retrieval + await advanceToNthTimerThenFlush(); + expect(fetchBridgeQuotesSpy).toHaveBeenCalledWith( + mockFetchFn, + [ + { + ...usdtQuoteRequest, + insufficientBal: true, + resetApproval: true, + }, + ], + expect.any(AbortSignal), + FeatureId.UNIFIED_SWAP_BRIDGE, + BridgeClientId.EXTENSION, + 'AUTH_TOKEN', + BRIDGE_PROD_API_BASE_URL, + { + onQuoteValidationFailure: expect.any(Function), + onValidQuoteReceived: expect.any(Function), + onTokenWarning: expect.any(Function), + onComplete: expect.any(Function), + onClose: expect.any(Function), + }, + '13.8.0', + ); + const { quotesLastFetched: t1, quoteRequest: stateQuoteRequest } = + bridgeController.state; + expect(stateQuoteRequest[0]).toStrictEqual({ + ...usdtQuoteRequest, + insufficientBal: true, + resetApproval: true, + }); + expect(t1).toBeCloseTo(Date.now() - 1000); + + // After first fetch + jest.advanceTimersByTime(5000); + await flushPromises(); + expect(bridgeController.state).toStrictEqual({ + ...expectedState, + quotesInitialLoadTime: 6000, + quoteRequest: [ + { + ...usdtQuoteRequest, + insufficientBal: true, + resetApproval: true, + }, + ], + quotes: getMockBridgeQuotesErc20Erc20V2({ + quote: { + srcAsset: { + address: ETH_USDT_ADDRESS, + assetId: `eip155:1/erc20:${ETH_USDT_ADDRESS}`, + name: 'Tether USD', + decimals: 6, + symbol: 'USDT', + chainId: 1, + iconUrl: 'https://media.socket.tech/tokens/all/USDT', + }, + srcChainId: 1, + }, + }).map((quote) => ({ + ...quote, + featureId: FeatureId.UNIFIED_SWAP_BRIDGE, + resetApproval: { + ...quote.approval, + data: '0x095ea7b30000000000000000000000000439e60f02a8900a951603950d8d4527f400c3f10000000000000000000000000000000000000000000000000000000000000000', + }, + })), + quotesRefreshCount: 1, + quotesLoadingStatus: 1, + quotesLastFetched: t1, + assetExchangeRates, + }); + expect(fetchBridgeQuotesSpy).toHaveBeenCalledTimes(1); + expect(consoleLogSpy).toHaveBeenCalledTimes(0); + expect(getLayer1GasFeeMock).not.toHaveBeenCalled(); + expect(contractMockSpy.mock.calls).toHaveLength(2); + }, + ); + }); + + it('should replace all stale quotes after a refresh and first quote is received', async function () { + await withController( + async ({ + controller: bridgeController, + rootMessenger, + stopAllPollingSpy, + startPollingSpy, + hasSufficientBalanceSpy, + fetchBridgeQuotesSpy, + consoleLogSpy, + }) => { + mockFetchFn.mockImplementationOnce(async () => { + return mockSseEventSource( + mockBridgeQuotesNativeErc20V1, + FIRST_FETCH_DELAY, + ); + }); + mockFetchFn.mockImplementationOnce(async () => { + return mockSseEventSourceWithMultipleDelays( + mockBridgeQuotesNativeErc20EthV1, + SECOND_FETCH_DELAY, + ); + }); + await rootMessenger.call( + 'BridgeController:updateBridgeQuoteRequestParams', + quoteRequest, + metricsContext, + ); + // Wait for JWT token retrieval + await advanceToNthTimerThenFlush(); + // 1st fetch + jest.advanceTimersByTime(FIRST_FETCH_DELAY); + await flushPromises(); + expect(bridgeController.state.quotes).toStrictEqual( + getMockBridgeQuotesNativeErc20V2().map((quote) => ({ + ...quote, + l1GasFeesInHexWei: '0x1', + resetApproval: undefined, + featureId: FeatureId.UNIFIED_SWAP_BRIDGE, + })), + ); + const t1 = bridgeController.state.quotesLastFetched; + expect(stopAllPollingSpy).toHaveBeenCalledTimes(1); + expect(startPollingSpy).toHaveBeenCalledTimes(1); + + // Wait for next polling interval + jest.advanceTimersToNextTimer(); + await flushPromises(); + + const expectedState = { + ...DEFAULT_BRIDGE_CONTROLLER_STATE, + quotesInitialLoadTime: FIRST_FETCH_DELAY, + quoteRequest: [ + { + ...quoteRequest, + insufficientBal: false, + resetApproval: false, + }, + ], + quotes: [getMockBridgeQuotesNativeErc20EthV2()[0]].map((quote) => ({ + ...quote, + resetApproval: undefined, + featureId: FeatureId.UNIFIED_SWAP_BRIDGE, + })), + quotesLoadingStatus: RequestStatus.LOADING, + quotesRefreshCount: 1, + assetExchangeRates, + }; + + // 2nd fetch request's first server event + jest.advanceTimersToNextTimer(); + jest.advanceTimersByTime(SECOND_FETCH_DELAY - 1000); + await flushPromises(); + expect(fetchBridgeQuotesSpy).toHaveBeenCalledTimes(2); + expect(bridgeController.state).toStrictEqual({ + ...expectedState, + quotesLastFetched: expect.any(Number), + }); + const t2 = bridgeController.state.quotesLastFetched; + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + expect(t2).toBeGreaterThan(t1!); + + // After 2nd server event + await advanceToNthTimerThenFlush(); + expect(bridgeController.state).toStrictEqual({ + ...expectedState, + quotes: getMockBridgeQuotesNativeErc20EthV2().map((quote) => ({ + ...quote, + resetApproval: undefined, + featureId: FeatureId.UNIFIED_SWAP_BRIDGE, + })), + quotesLastFetched: t2, + quotesRefreshCount: 2, + quotesLoadingStatus: RequestStatus.FETCHED, + }); + + expect(fetchBridgeQuotesSpy).toHaveBeenCalledTimes(2); + expect(consoleLogSpy).toHaveBeenCalledTimes(0); + expect(hasSufficientBalanceSpy).toHaveBeenCalledTimes(1); + expect(getLayer1GasFeeMock).toHaveBeenCalledTimes(2); + // eslint-disable-next-line jest/no-restricted-matchers + expect(trackMetaMetricsFn.mock.calls.at(-1)).toMatchSnapshot(); + }, + ); + }); + + it('should reset quotes list if quote refresh fails', async function () { + await withController( + async ({ + controller: bridgeController, + rootMessenger, + stopAllPollingSpy, + startPollingSpy, + hasSufficientBalanceSpy, + fetchBridgeQuotesSpy, + consoleLogSpy, + }) => { + mockFetchFn.mockImplementationOnce(async () => { + return mockSseEventSource( + mockBridgeQuotesNativeErc20V1, + FIRST_FETCH_DELAY, + ); + }); + mockFetchFn.mockImplementationOnce(async () => { + return mockSseEventSourceWithMultipleDelays( + mockBridgeQuotesNativeErc20EthV1, + SECOND_FETCH_DELAY, + ); + }); + mockFetchFn.mockRejectedValueOnce('Network error'); + await rootMessenger.call( + 'BridgeController:updateBridgeQuoteRequestParams', + quoteRequest, + metricsContext, + ); + + consoleLogSpy.mockImplementationOnce(jest.fn()); + // Wait for JWT token retrieval + await advanceToNthTimerThenFlush(); + // 1st fetch + jest.advanceTimersByTime(FIRST_FETCH_DELAY); + await flushPromises(); + expect(stopAllPollingSpy).toHaveBeenCalledTimes(1); + expect(startPollingSpy).toHaveBeenCalledTimes(1); + expect(bridgeController.state.quotesInitialLoadTime).toBe( + FIRST_FETCH_DELAY, + ); + + // 2nd fetch + await advanceToNthTimerThenFlush(); + await advanceToNthTimerThenFlush(2); + expect(bridgeController.state.quotesRefreshCount).toBe(2); + expect(bridgeController.state.quotesInitialLoadTime).toBe( + FIRST_FETCH_DELAY, + ); + expect(bridgeController.state.quotes).toStrictEqual( + getMockBridgeQuotesNativeErc20EthV2().map((quote) => ({ + ...quote, + resetApproval: undefined, + featureId: FeatureId.UNIFIED_SWAP_BRIDGE, + })), + ); + const t2 = bridgeController.state.quotesLastFetched; + + // 3nd fetch throws an error + await advanceToNthTimerThenFlush(); + expect(fetchBridgeQuotesSpy).toHaveBeenCalledTimes(3); + expect(bridgeController.state).toStrictEqual({ + ...DEFAULT_BRIDGE_CONTROLLER_STATE, + quotesInitialLoadTime: FIRST_FETCH_DELAY, + quoteRequest: [ + { + ...quoteRequest, + insufficientBal: false, + resetApproval: false, + }, + ], + quotes: [], + quotesLoadingStatus: 2, + quoteFetchError: 'Network error', + quotesRefreshCount: 3, + quotesLastFetched: Date.now(), + assetExchangeRates, + }); + expect( + bridgeController.state.quotesLastFetched, + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + ).toBeGreaterThan(t2!); + expect(consoleLogSpy.mock.calls).toMatchInlineSnapshot(` + [ + [ + "Failed to stream bridge quotes", + "Network error", + ], + ] + `); + expect(hasSufficientBalanceSpy).toHaveBeenCalledTimes(1); + expect(getLayer1GasFeeMock).toHaveBeenCalledTimes(2); + expect(trackMetaMetricsFn).toHaveBeenCalledTimes(8); + // eslint-disable-next-line jest/no-restricted-matchers + expect(trackMetaMetricsFn.mock.calls.slice(6, 8)).toMatchSnapshot(); + }, + ); + }); + + it('should reset and refetch quotes after quote request is changed', async function () { + await withController( + async ({ + controller: bridgeController, + rootMessenger, + stopAllPollingSpy, + startPollingSpy, + hasSufficientBalanceSpy, + fetchBridgeQuotesSpy, + consoleLogSpy, + }) => { + mockFetchFn.mockImplementationOnce(async () => { + return mockSseEventSource( + mockBridgeQuotesNativeErc20V1, + FIRST_FETCH_DELAY, + ); + }); + mockFetchFn.mockImplementationOnce(async () => { + return mockSseEventSourceWithMultipleDelays( + mockBridgeQuotesNativeErc20EthV1, + SECOND_FETCH_DELAY, + ); + }); + mockFetchFn.mockRejectedValueOnce('Network error'); + mockFetchFn.mockImplementationOnce(async () => { + return mockSseEventSourceWithMultipleDelays( + [ + ...mockBridgeQuotesNativeErc20V1, + ...mockBridgeQuotesNativeErc20V1, + ], + THIRD_FETCH_DELAY, + ); + }); + + await rootMessenger.call( + 'BridgeController:updateBridgeQuoteRequestParams', + quoteRequest, + metricsContext, + ); + + 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(); + expect(stopAllPollingSpy).toHaveBeenCalledTimes(1); + expect(startPollingSpy).toHaveBeenCalledTimes(1); + + // Wait for next polling interval + jest.advanceTimersToNextTimer(); + await flushPromises(); + + // 2nd fetch + jest.advanceTimersToNextTimer(); + jest.advanceTimersToNextTimer(); + await flushPromises(); + expect(bridgeController.state.quotesRefreshCount).toBe(2); + const t2 = bridgeController.state.quotesLastFetched; + + // 3nd fetch throws an error + await advanceToNthTimerThenFlush(); + const t5 = bridgeController.state.quotesLastFetched; + expect(bridgeController.state.quotesRefreshCount).toBe(3); + expect(bridgeController.state.quotes).toStrictEqual([]); + expect(consoleLogSpy).toHaveBeenCalledTimes(1); + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + expect(t5).toBeGreaterThan(t2!); + const expectedState = { + ...DEFAULT_BRIDGE_CONTROLLER_STATE, + quotesLoadingStatus: RequestStatus.LOADING, + quoteRequest: [ + { + ...quoteRequest, + srcTokenAmount: '10', + insufficientBal: true, + }, + ], + assetExchangeRates: {}, + }; + // Start new quote request + await rootMessenger.call( + 'BridgeController:updateBridgeQuoteRequestParams', + { ...quoteRequest, srcTokenAmount: '10' }, + { + stx_enabled: true, + token_symbol_source: 'ETH', + token_symbol_destination: 'USDC', + security_warnings: [], + usd_amount_source: 100, + token_security_type_destination: null, + feature_id: FeatureId.UNIFIED_SWAP_BRIDGE, + }, + ); + // Right after state update, before fetch has started + expect(bridgeController.state).toStrictEqual(expectedState); + advanceToNthTimer(); + expect(bridgeController.state).toStrictEqual({ + ...expectedState, + quoteRequest: [ + { + ...quoteRequest, + srcTokenAmount: '10', + insufficientBal: true, + resetApproval: false, + }, + ], + quotesLastFetched: Date.now(), + quotesLoadingStatus: RequestStatus.LOADING, + }); + const t1 = bridgeController.state.quotesLastFetched; + // Wait for JWT token retrieval + await advanceToNthTimerThenFlush(); + // 1st quote is received + await advanceToNthTimerThenFlush(); + const expectedStateAfterFirstQuote = { + ...expectedState, + quotesInitialLoadTime: THIRD_FETCH_DELAY, + quotes: [ + { + ...getMockBridgeQuotesNativeErc20V2()[0], + l1GasFeesInHexWei: '0x1', + resetApproval: undefined, + featureId: FeatureId.UNIFIED_SWAP_BRIDGE, + }, + ], + quotesRefreshCount: 0, + quotesLoadingStatus: RequestStatus.LOADING, + quoteRequest: [ + { + ...quoteRequest, + srcTokenAmount: '10', + insufficientBal: true, + resetApproval: false, + }, + ], + quotesLastFetched: t1, + assetExchangeRates, + }; + expect(bridgeController.state.quotes).toHaveLength(1); + expect(bridgeController.state).toStrictEqual({ + ...expectedStateAfterFirstQuote, + }); + const t4 = bridgeController.state.quotesLastFetched; + expect(t4).toBe( + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + t5!, + ); + // All other quotes are received + await advanceToNthTimerThenFlush(3); + expect(bridgeController.state).toStrictEqual({ + ...expectedStateAfterFirstQuote, + quotesRefreshCount: 1, + quotesLoadingStatus: RequestStatus.FETCHED, + quotes: [ + ...getMockBridgeQuotesNativeErc20V2(), + ...getMockBridgeQuotesNativeErc20V2(), + ].map((quote) => ({ + ...quote, + l1GasFeesInHexWei: '0x1', + resetApproval: undefined, + featureId: FeatureId.UNIFIED_SWAP_BRIDGE, + })), + assetExchangeRates, + }); + expect( + bridgeController.state.quotesLastFetched, + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + ).toBe(t4!); + + expect(consoleLogSpy).toHaveBeenCalledTimes(1); + expect(fetchBridgeQuotesSpy).toHaveBeenCalledTimes(4); + expect(hasSufficientBalanceSpy).toHaveBeenCalledTimes(2); + expect(getLayer1GasFeeMock).toHaveBeenCalledTimes(6); + expect(trackMetaMetricsFn).toHaveBeenCalledTimes(9); + // eslint-disable-next-line jest/no-restricted-matchers + expect(trackMetaMetricsFn.mock.calls.slice(8, 9)).toMatchSnapshot(); + }, + ); + }); + + it('should publish validation failures', async function () { + await withController( + async ({ + controller: bridgeController, + rootMessenger, + stopAllPollingSpy, + startPollingSpy, + hasSufficientBalanceSpy, + fetchBridgeQuotesSpy, + consoleLogSpy, + }) => { + mockFetchFn.mockImplementationOnce(async () => { + return mockSseEventSource( + mockBridgeQuotesNativeErc20V1, + FIRST_FETCH_DELAY, + ); + }); + mockFetchFn.mockImplementationOnce(async () => { + return mockSseEventSourceWithMultipleDelays( + mockBridgeQuotesNativeErc20EthV1, + SECOND_FETCH_DELAY, + ); + }); + mockFetchFn.mockRejectedValueOnce('Network error'); + mockFetchFn.mockImplementationOnce(async () => { + return mockSseEventSourceWithMultipleDelays( + [ + ...mockBridgeQuotesNativeErc20V1, + ...mockBridgeQuotesNativeErc20V1, + ], + THIRD_FETCH_DELAY, + ); + }); + mockFetchFn.mockImplementationOnce(async () => { + const { quote, ...rest } = mockBridgeQuotesNativeErc20V1[0]; + return mockSseEventSourceWithMultipleDelays( + [ + { + ...mockBridgeQuotesNativeErc20EthV1[1], + trade: { abc: '123' } as unknown as TxData, + }, + '' as unknown as never, + mockBridgeQuotesNativeErc20EthV1[0], + rest as unknown as never, + ], + FOURTH_FETCH_DELAY, + ); + }); + await rootMessenger.call( + 'BridgeController:updateBridgeQuoteRequestParams', + quoteRequest, + metricsContext, + ); + + consoleLogSpy.mockImplementationOnce(jest.fn()); + const consoleWarnSpy = jest + .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(); + expect(stopAllPollingSpy).toHaveBeenCalledTimes(1); + expect(startPollingSpy).toHaveBeenCalledTimes(1); + + // Wait for next polling interval + await advanceToNthTimerThenFlush(); + + // Wait for JWT token retrieval + await advanceToNthTimerThenFlush(); + + // 2nd fetch + await advanceToNthTimerThenFlush(1); + expect(bridgeController.state.quotesRefreshCount).toBe(2); + + // 3nd fetch throws an error + await advanceToNthTimerThenFlush(); + const t5 = bridgeController.state.quotesLastFetched; + expect(bridgeController.state.quotesRefreshCount).toBe(3); + expect(bridgeController.state.quotes).toStrictEqual([]); + expect(consoleLogSpy).toHaveBeenCalledTimes(1); + + // Start new quote request + await rootMessenger.call( + 'BridgeController:updateBridgeQuoteRequestParams', + { ...quoteRequest, srcTokenAmount: '10' }, + { + stx_enabled: true, + token_symbol_source: 'ETH', + token_symbol_destination: 'USDC', + security_warnings: [], + usd_amount_source: 100, + token_security_type_destination: 'test', + feature_id: FeatureId.UNIFIED_SWAP_BRIDGE, + }, + ); + + // 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 + t5!, + ); + expect(bridgeController.state.quotesRefreshCount).toBe(0); + expect(bridgeController.state.quotesLoadingStatus).toBe( + RequestStatus.LOADING, + ); + + // 2nd quote is received + await advanceToNthTimerThenFlush(3); + expect(bridgeController.state.quotes).toStrictEqual( + [ + ...getMockBridgeQuotesNativeErc20V2(), + ...getMockBridgeQuotesNativeErc20V2(), + ].map((quote) => ({ + ...quote, + featureId: FeatureId.UNIFIED_SWAP_BRIDGE, + l1GasFeesInHexWei: '0x1', + resetApproval: undefined, + })), + ); + + // Wait for next polling interval + jest.advanceTimersToNextTimer(); + await flushPromises(); + + // 2nd fetch after request is updated + // Iterate through a list of received valid and invalid quotes + // Invalid quotes received + // Invalid quote + jest.advanceTimersByTime(FOURTH_FETCH_DELAY - 1000); + await flushPromises(); + const expectedState = { + ...DEFAULT_BRIDGE_CONTROLLER_STATE, + quotesInitialLoadTime: 2000, + quoteRequest: [ + { + ...quoteRequest, + srcTokenAmount: '10', + insufficientBal: false, + resetApproval: false, + }, + ], + quotes: [getMockBridgeQuotesNativeErc20EthV2()[0]].map((quote) => ({ + ...quote, + resetApproval: undefined, + featureId: FeatureId.UNIFIED_SWAP_BRIDGE, + })), + quotesRefreshCount: 1, + quoteFetchError: null, + quotesLoadingStatus: RequestStatus.LOADING, + assetExchangeRates, + quotesLastFetched: expect.any(Number), + tokenSecurityTypeDestination: 'test', + }; + const t6 = bridgeController.state.quotesLastFetched; + expect(t6).toBeCloseTo(Date.now() - 2000); + // Empty event.data + await advanceToNthTimerThenFlush(); + // Valid quote + await advanceToNthTimerThenFlush(); + await advanceToNthTimerThenFlush(); + expect(bridgeController.state).toStrictEqual(expectedState); + const t7 = bridgeController.state.quotesLastFetched; + expect(t7).toBe( + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + t6!, + ); + expect(consoleWarnSpy.mock.calls[0]).toMatchInlineSnapshot(` + [ + "Quote validation failed", + [ + "At path: quote.src (type) -- Expected an object, but received: undefined", + "At path: quote.dest (type) -- Expected an object, but received: undefined", + "At path: quote.feeData.metabridge (type) -- Expected an array value, but received: [object Object]", + "At path: quote.aggregator (type) -- Expected a string, but received: undefined", + "At path: quote.protocols (type) -- Expected an array value, but received: undefined", + "At path: quote.steps.0.src (type) -- Expected an object, but received: undefined", + "At path: quote.steps.0.dest (type) -- Expected an object, but received: undefined", + "At path: quote.steps.1.src (type) -- Expected an object, but received: undefined", + "At path: quote.steps.1.dest (type) -- Expected an object, but received: undefined", + ], + ] + `); + // Invalid quote + jest.advanceTimersByTime(FOURTH_FETCH_DELAY * 3 - 1000); + await flushPromises(); + expect(bridgeController.state).toStrictEqual({ + ...expectedState, + quotesRefreshCount: 2, + quotesLoadingStatus: RequestStatus.FETCHED, + }); + expect(bridgeController.state.quotesLastFetched).toBe( + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + t7!, + ); + expect(consoleWarnSpy.mock.calls).toHaveLength(3); + expect(consoleWarnSpy.mock.calls[1]).toMatchInlineSnapshot(` + [ + "Quote validation failed", + [ + "At path: (type) -- Expected an object, but received: """, + ], + ] + `); + expect(consoleWarnSpy.mock.calls[2]).toMatchInlineSnapshot(` + [ + "Quote validation failed", + [ + "At path: quote (type) -- Expected an object, but received: undefined", + ], + ] + `); + + expect(consoleLogSpy).toHaveBeenCalledTimes(1); + expect(fetchBridgeQuotesSpy).toHaveBeenCalledTimes(5); + expect(hasSufficientBalanceSpy).toHaveBeenCalledTimes(2); + expect(getLayer1GasFeeMock).toHaveBeenCalledTimes(6); + expect(trackMetaMetricsFn).toHaveBeenCalledTimes(13); + // eslint-disable-next-line jest/no-restricted-matchers + expect(trackMetaMetricsFn.mock.calls.slice(10, 13)).toMatchSnapshot(); + }, + ); + }); + + it('should rethrow error from server', async function () { + await withController( + async ({ + controller: bridgeController, + rootMessenger, + stopAllPollingSpy, + startPollingSpy, + hasSufficientBalanceSpy, + fetchBridgeQuotesSpy, + fetchAssetPricesSpy, + consoleLogSpy, + }) => { + mockFetchFn.mockImplementationOnce(async () => { + return mockSseServerError('timeout from server'); + }); + await rootMessenger.call( + 'BridgeController:updateBridgeQuoteRequestParams', + quoteRequest, + metricsContext, + ); + + // Before polling starts + expect(stopAllPollingSpy).toHaveBeenCalledTimes(1); + expect(startPollingSpy).toHaveBeenCalledTimes(1); + expect(hasSufficientBalanceSpy).toHaveBeenCalledTimes(1); + expect(startPollingSpy).toHaveBeenCalledWith({ + quoteRequests: [ + { + ...quoteRequest, + insufficientBal: false, + resetApproval: false, + }, + ], + context: metricsContext, + }); + const expectedState = { + ...DEFAULT_BRIDGE_CONTROLLER_STATE, + quoteRequest: [ + { + ...quoteRequest, + insufficientBal: false, + resetApproval: false, + }, + ], + assetExchangeRates: {}, + quotesLoadingStatus: RequestStatus.LOADING, + }; + expect(bridgeController.state).toStrictEqual(expectedState); + + // Loading state + jest.advanceTimersByTime(1000); + // Wait for JWT token retrieval + await advanceToNthTimerThenFlush(); + expect(hasSufficientBalanceSpy).toHaveBeenCalledTimes(1); + expect(bridgeController.state.quotesLoadingStatus).toBe( + RequestStatus.LOADING, + ); + expect(fetchBridgeQuotesSpy).toHaveBeenCalledWith( + mockFetchFn, + [ + { + ...quoteRequest, + insufficientBal: false, + resetApproval: false, + }, + ], + expect.any(AbortSignal), + FeatureId.UNIFIED_SWAP_BRIDGE, + BridgeClientId.EXTENSION, + 'AUTH_TOKEN', + BRIDGE_PROD_API_BASE_URL, + { + onQuoteValidationFailure: expect.any(Function), + onValidQuoteReceived: expect.any(Function), + onTokenWarning: expect.any(Function), + onComplete: expect.any(Function), + onClose: expect.any(Function), + }, + '13.8.0', + ); + expect(fetchAssetPricesSpy).toHaveBeenCalledTimes(1); + const { quotesLastFetched: t1, ...stateWithoutTimestamp } = + bridgeController.state; + // eslint-disable-next-line jest/no-restricted-matchers + expect(stateWithoutTimestamp).toMatchSnapshot(); + expect(t1).toBeCloseTo(Date.now() - 1000); + + // After first fetch + jest.advanceTimersByTime(5000); + await flushPromises(); + expect(bridgeController.state).toStrictEqual({ + ...expectedState, + assetExchangeRates, + quoteRequest: [ + { + ...quoteRequest, + insufficientBal: false, + resetApproval: false, + }, + ], + quotesRefreshCount: 1, + quotesLoadingStatus: 2, + quoteFetchError: 'Bridge-api error: timeout from server', + quotesLastFetched: t1, + }); + expect(fetchBridgeQuotesSpy).toHaveBeenCalledTimes(1); + expect(consoleLogSpy).toHaveBeenCalledTimes(1); + expect(consoleLogSpy.mock.calls[0]).toMatchInlineSnapshot(` + [ + "Failed to stream bridge quotes", + [Error: Bridge-api error: timeout from server], + ] + `); + expect(hasSufficientBalanceSpy).toHaveBeenCalledTimes(1); + expect(getLayer1GasFeeMock).toHaveBeenCalledTimes(0); + // eslint-disable-next-line jest/no-restricted-matchers + expect(trackMetaMetricsFn.mock.calls).toMatchSnapshot(); + }, + ); + }); + + it('should populate tokenWarnings from token_warning SSE events', async function () { + await withController(async ({ controller: bridgeController }) => { + const mockWarning = { + feature_id: 'HONEYPOT', + type: TokenFeatureType.MALICIOUS, + description: 'Token is a honeypot', + }; + mockFetchFn.mockImplementationOnce(async () => { + return mockSseEventSourceWithWarnings(mockBridgeQuotesNativeErc20V1, [ + mockWarning, + ]); + }); + + await bridgeController.updateBridgeQuoteRequestParams( + quoteRequest, + metricsContext, + ); + + expect(bridgeController.state.tokenWarnings).toStrictEqual([]); + + jest.advanceTimersByTime(1000); + await advanceToNthTimerThenFlush(); + + // After stream completes + jest.advanceTimersByTime(5000); + await flushPromises(); + + expect(bridgeController.state.tokenWarnings).toStrictEqual([mockWarning]); + expect(bridgeController.state.quotes.length).toBeGreaterThan(0); + }); + }); + + it('should clear tokenWarnings on resetState', async function () { + await withController(async ({ controller: bridgeController }) => { + const mockWarning = { + feature_id: 'HONEYPOT', + type: TokenFeatureType.MALICIOUS, + description: 'Token is a honeypot', + }; + mockFetchFn.mockImplementationOnce(async () => { + return mockSseEventSourceWithWarnings(mockBridgeQuotesNativeErc20V1, [ + mockWarning, + ]); + }); + + await bridgeController.updateBridgeQuoteRequestParams( + quoteRequest, + metricsContext, + ); + + jest.advanceTimersByTime(1000); + await advanceToNthTimerThenFlush(); + jest.advanceTimersByTime(5000); + await flushPromises(); + + expect(bridgeController.state.tokenWarnings).toStrictEqual([mockWarning]); + + bridgeController.resetState(); + expect(bridgeController.state.tokenWarnings).toStrictEqual([]); + }); + }); + + it('should deduplicate tokenWarnings with the same feature_id', async function () { + await withController(async ({ controller: bridgeController }) => { + const mockWarning = { + feature_id: 'HONEYPOT', + type: TokenFeatureType.MALICIOUS, + description: 'Token is a honeypot', + }; + const duplicateWarning = { + feature_id: 'HONEYPOT', + type: TokenFeatureType.MALICIOUS, + description: 'Duplicate warning', + }; + mockFetchFn.mockImplementationOnce(async () => { + return mockSseEventSourceWithWarnings(mockBridgeQuotesNativeErc20V1, [ + mockWarning, + duplicateWarning, + ]); + }); + + await bridgeController.updateBridgeQuoteRequestParams( + quoteRequest, + metricsContext, + ); + + jest.advanceTimersByTime(1000); + await advanceToNthTimerThenFlush(); + jest.advanceTimersByTime(5000); + await flushPromises(); + + expect(bridgeController.state.tokenWarnings).toStrictEqual([mockWarning]); + }); + }); + + it('should deduplicate tokenWarnings with the same feature_id but different type', async function () { + await withController(async ({ controller: bridgeController }) => { + const maliciousWarning = { + feature_id: 'HONEYPOT', + type: TokenFeatureType.MALICIOUS, + description: 'Token is a honeypot', + }; + const infoWarning = { + feature_id: 'HONEYPOT', + type: TokenFeatureType.INFO, + description: 'Informational notice', + }; + mockFetchFn.mockImplementationOnce(async () => { + return mockSseEventSourceWithWarnings(mockBridgeQuotesNativeErc20V1, [ + maliciousWarning, + infoWarning, + ]); + }); + + await bridgeController.updateBridgeQuoteRequestParams( + quoteRequest, + metricsContext, + ); + + jest.advanceTimersByTime(1000); + await advanceToNthTimerThenFlush(); + jest.advanceTimersByTime(5000); + await flushPromises(); + + expect(bridgeController.state.tokenWarnings).toStrictEqual([ + maliciousWarning, + ]); + }); + }); + + it('should keep tokenWarnings with the same type but different feature_id', async function () { + await withController(async ({ controller: bridgeController }) => { + const honeypotWarning = { + feature_id: 'HONEYPOT', + type: TokenFeatureType.MALICIOUS, + description: 'Token is a honeypot', + }; + const fakeTokenWarning = { + feature_id: 'FAKE_TOKEN', + type: TokenFeatureType.MALICIOUS, + description: 'Possible fake token', + }; + mockFetchFn.mockImplementationOnce(async () => { + return mockSseEventSourceWithWarnings(mockBridgeQuotesNativeErc20V1, [ + honeypotWarning, + fakeTokenWarning, + ]); + }); + + await bridgeController.updateBridgeQuoteRequestParams( + quoteRequest, + metricsContext, + ); + + jest.advanceTimersByTime(1000); + await advanceToNthTimerThenFlush(); + jest.advanceTimersByTime(5000); + await flushPromises(); + + expect(bridgeController.state.tokenWarnings).toStrictEqual([ + honeypotWarning, + fakeTokenWarning, + ]); + }); + }); + + it('should populate quoteStreamComplete from complete SSE event', async function () { + await withController(async ({ controller: bridgeController }) => { + const mockComplete = { + quoteCount: 2, + hasQuotes: true, + reason: QuoteStreamCompleteReason.RETRY, + context: { source: 'bridge-api' }, + }; + mockFetchFn.mockImplementationOnce(async () => { + return mockSseEventSourceWithComplete( + mockBridgeQuotesNativeErc20V1, + [], + mockComplete, + ); + }); + + await bridgeController.updateBridgeQuoteRequestParams( + quoteRequest, + metricsContext, + ); + + expect(bridgeController.state.quoteStreamComplete).toBeNull(); + + jest.advanceTimersByTime(1000); + await advanceToNthTimerThenFlush(); + jest.advanceTimersByTime(5000); + await flushPromises(); + + expect(bridgeController.state.quoteStreamComplete).toStrictEqual( + mockComplete, + ); + expect(bridgeController.state.quotes.length).toBeGreaterThan(0); + }); + }); + + it('should populate quoteStreamComplete with optional fields omitted', async function () { + await withController(async ({ controller: bridgeController }) => { + const mockComplete = { + quoteCount: 0, + hasQuotes: false, + }; + mockFetchFn.mockImplementationOnce(async () => { + return mockSseEventSourceWithComplete([], [], mockComplete); + }); + + await bridgeController.updateBridgeQuoteRequestParams( + quoteRequest, + metricsContext, + ); + + jest.advanceTimersByTime(1000); + await advanceToNthTimerThenFlush(); + jest.advanceTimersByTime(5000); + await flushPromises(); + + expect(bridgeController.state.quoteStreamComplete).toStrictEqual( + mockComplete, + ); + }); + }); + + it('should clear quoteStreamComplete on resetState', async function () { + await withController(async ({ controller: bridgeController }) => { + const mockComplete = { + quoteCount: 2, + hasQuotes: true, + }; + mockFetchFn.mockImplementationOnce(async () => { + return mockSseEventSourceWithComplete( + mockBridgeQuotesNativeErc20V1, + [], + mockComplete, + ); + }); + + await bridgeController.updateBridgeQuoteRequestParams( + quoteRequest, + metricsContext, + ); + + jest.advanceTimersByTime(1000); + await advanceToNthTimerThenFlush(); + jest.advanceTimersByTime(5000); + await flushPromises(); + + expect(bridgeController.state.quoteStreamComplete).toStrictEqual( + mockComplete, + ); + + bridgeController.resetState(); + expect(bridgeController.state.quoteStreamComplete).toBeNull(); + }); + }); + + it('should clear quoteStreamComplete at the start of each fetch', async function () { + await withController(async ({ controller: bridgeController }) => { + const mockComplete = { + quoteCount: 2, + hasQuotes: true, + }; + mockFetchFn.mockImplementation(async () => { + return mockSseEventSourceWithComplete( + mockBridgeQuotesNativeErc20V1, + [], + mockComplete, + ); + }); + + await bridgeController.updateBridgeQuoteRequestParams( + quoteRequest, + metricsContext, + ); + + jest.advanceTimersByTime(1000); + await advanceToNthTimerThenFlush(); + jest.advanceTimersByTime(5000); + await flushPromises(); + + expect(bridgeController.state.quoteStreamComplete).toStrictEqual( + mockComplete, + ); + + // Trigger a second fetch — quoteStreamComplete should be cleared before the stream completes + jest.advanceTimersByTime(1000); + await advanceToNthTimerThenFlush(); + + expect(bridgeController.state.quoteStreamComplete).toBeNull(); + + jest.advanceTimersByTime(5000); + await flushPromises(); + + expect(bridgeController.state.quoteStreamComplete).toStrictEqual( + mockComplete, + ); + }); + }); +}); diff --git a/packages/bridge-controller/src/bridge-controller.test.ts b/packages/bridge-controller/src/bridge-controller.test.ts new file mode 100644 index 00000000000..4e9146b425d --- /dev/null +++ b/packages/bridge-controller/src/bridge-controller.test.ts @@ -0,0 +1,4432 @@ +/* eslint-disable jest/no-restricted-matchers */ +import { deriveStateFromMetadata } from '@metamask/base-controller'; +import { handleFetch } from '@metamask/controller-utils'; +import { + BtcScope, + EthAccountType, + EthScope, + SolAccountType, + SolScope, +} from '@metamask/keyring-api'; +import { Messenger, MOCK_ANY_NAMESPACE } from '@metamask/messenger'; +import type { + MessengerActions, + MessengerEvents, + MockAnyNamespace, +} from '@metamask/messenger'; +import type { CaipAssetType } from '@metamask/utils'; +import nock from 'nock'; + +import { flushPromises } from '../../../tests/helpers.js'; +import { mockBridgeQuotesErc20NativeV1 } from '../tests/mock-quotes-erc20-native.js'; +import { + mockBridgeQuotesNativeErc20EthV1, + getMockBridgeQuotesNativeErc20EthV2, +} from '../tests/mock-quotes-native-erc20-eth.js'; +import { mockBridgeQuotesNativeErc20V1 } from '../tests/mock-quotes-native-erc20.js'; +import { + getMockBridgeQuotesSolErc20V2, + mockBridgeQuotesSolErc20V1, +} from '../tests/mock-quotes-sol-erc20.js'; +import { advanceToNthTimerThenFlush } from '../tests/mock-sse.js'; +import { BridgeController } from './bridge-controller.js'; +import { + BridgeClientId, + BRIDGE_PROD_API_BASE_URL, + DEFAULT_BRIDGE_CONTROLLER_STATE, + ETH_USDT_ADDRESS, +} from './constants/bridge.js'; +import { SWAPS_API_V2_BASE_URL } from './constants/swaps.js'; +import * as selectors from './selectors.js'; +import { ChainId, RequestStatus, SortOrder, StatusTypes } from './types.js'; +import type { + BridgeControllerMessenger, + GenericQuoteRequest, +} from './types.js'; +import * as balanceUtils from './utils/balance.js'; +import { getNativeAssetForChainId, isSolanaChainId } from './utils/bridge.js'; +import { + formatAddressToAssetId, + formatChainIdToCaip, +} from './utils/caip-formatters.js'; +import * as featureFlagUtils from './utils/feature-flags.js'; +import * as fetchUtils from './utils/fetch.js'; +import { + BatchSellMetricsEventName, + BatchSellMetricsLocation, + InputAmountPreset, + MetaMetricsSwapsEventSource, + MetricsActionType, + MetricsSwapType, + UnifiedSwapBridgeEventName, +} from './utils/metrics/constants.js'; +import { FeatureId } from './validators/feature-flags.js'; +import type { QuoteResponseV1 } from './validators/quote-response-v1.js'; + +const EMPTY_INIT_STATE = DEFAULT_BRIDGE_CONTROLLER_STATE; + +jest.mock('uuid', () => ({ + v4: (): string => 'test-uuid-1234', +})); + +jest.mock('@ethersproject/contracts', () => { + return { + ...jest.requireActual('@ethersproject/contracts'), + Contract: jest.fn(), + }; +}); + +const getLayer1GasFeeMock = jest.fn(); +const mockFetchFn = handleFetch; +const trackMetaMetricsFn = jest.fn(); +let fetchAssetPricesSpy: jest.SpyInstance; + +const bridgeConfig = { + minimumVersion: '0.0.0', + maxRefreshCount: 3, + refreshRate: 3, + support: true, + chainRanking: [], + chains: { + '10': { isActiveSrc: true, isActiveDest: false }, + '534352': { isActiveSrc: true, isActiveDest: false }, + '137': { isActiveSrc: false, isActiveDest: true }, + '42161': { isActiveSrc: false, isActiveDest: true }, + [ChainId.SOLANA]: { + isActiveSrc: true, + isActiveDest: true, + }, + }, + sse: { + enabled: true, + minimumVersion: '13.8.0', + }, +}; + +const metricsContext = { + feature_id: FeatureId.UNIFIED_SWAP_BRIDGE, + token_symbol_source: 'ETH', + token_symbol_destination: 'USDC', + usd_amount_source: 100, + stx_enabled: true, + security_warnings: [], + warnings: [], + token_security_type_destination: null, +}; + +type RootMessenger = Messenger< + MockAnyNamespace, + MessengerActions, + MessengerEvents +>; + +const BRIDGE_CONTROLLER_ALLOWED_EXTERNAL_ACTIONS = [ + 'AccountsController:getAccountByAddress', + 'AuthenticationController:getBearerToken', + 'CurrencyRateController:getState', + 'TokenRatesController:getState', + 'MultichainAssetsRatesController:getState', + 'SnapController:handleRequest', + 'NetworkController:findNetworkClientIdByChainId', + 'NetworkController:getNetworkClientById', + 'RemoteFeatureFlagController:getState', + 'AssetsController:getExchangeRatesForBridge', +] as const; + +const messengerCallMock = jest.fn(); + +type WithControllerCallback = (payload: { + controller: BridgeController; + rootMessenger: RootMessenger; +}) => Promise | ReturnValue; + +type WithControllerOptions = { + options?: Partial[0]>; +}; + +async function withController( + ...args: + | [WithControllerCallback] + | [WithControllerOptions, WithControllerCallback] +): Promise { + const [{ options = {} }, testFunction] = + args.length === 2 ? args : [{}, args[0]]; + const newRootMessenger: RootMessenger = new Messenger({ + namespace: MOCK_ANY_NAMESPACE, + }); + const messenger: BridgeControllerMessenger = new Messenger({ + namespace: 'BridgeController', + parent: newRootMessenger, + }); + newRootMessenger.delegate({ + messenger, + actions: [...BRIDGE_CONTROLLER_ALLOWED_EXTERNAL_ACTIONS], + }); + for (const action of BRIDGE_CONTROLLER_ALLOWED_EXTERNAL_ACTIONS) { + newRootMessenger.registerActionHandler(action, (...actionArgs) => + messengerCallMock(action, ...actionArgs), + ); + } + const controller = new BridgeController({ + messenger, + getLayer1GasFee: getLayer1GasFeeMock, + clientId: BridgeClientId.EXTENSION, + clientVersion: '13.7.0', + fetchFn: mockFetchFn, + trackMetaMetricsFn, + ...options, + }); + if (!options.state) { + newRootMessenger.call('BridgeController:resetState'); + } + return await testFunction({ controller, rootMessenger: newRootMessenger }); +} + +describe('BridgeController', function () { + beforeEach(function () { + jest.clearAllMocks(); + jest.clearAllTimers(); + + nock(BRIDGE_PROD_API_BASE_URL) + .get('/getTokens?chainId=10') + .reply(200, [ + { + address: '0x1f9840a85d5af5bf1d1762f925bdaddc4201f984', + symbol: 'ABC', + decimals: 16, + aggregators: ['lifl', 'socket'], + }, + { + address: '0x1291478912', + symbol: 'DEF', + decimals: 16, + }, + ]); + nock(SWAPS_API_V2_BASE_URL) + .get('/networks/10/topAssets') + .reply(200, [ + { + address: '0x1f9840a85d5af5bf1d1762f925bdaddc4201f984', + symbol: 'ABC', + }, + ]); + + fetchAssetPricesSpy = jest + .spyOn(fetchUtils, 'fetchAssetPrices') + .mockResolvedValue({ + 'eip155:10/erc20:0x1f9840a85d5af5bf1d1762f925bdaddc4201f984': { + usd: '100', + }, + }); + }); + + it('constructor should setup correctly', async function () { + await withController(async ({ controller: bridgeController }) => { + expect(bridgeController.state).toStrictEqual(EMPTY_INIT_STATE); + }); + }); + + it('setBridgeFeatureFlags should fetch and set the bridge feature flags', async function () { + await withController( + async ({ controller: bridgeController, rootMessenger }) => { + const remoteFeatureFlagControllerState = { + cacheTimestamp: 1745515389440, + remoteFeatureFlags: { + bridgeConfig, + assetsNotificationsEnabled: false, + confirmation_redesign: { + contract_interaction: false, + signatures: false, + staking_confirmations: false, + }, + confirmations_eip_7702: {}, + earnFeatureFlagTemplate: { + enabled: false, + minimumVersion: '0.0.0', + }, + earnPooledStakingEnabled: { + enabled: false, + minimumVersion: '0.0.0', + }, + earnPooledStakingServiceInterruptionBannerEnabled: { + enabled: false, + minimumVersion: '0.0.0', + }, + earnStablecoinLendingEnabled: { + enabled: false, + minimumVersion: '0.0.0', + }, + earnStablecoinLendingServiceInterruptionBannerEnabled: { + enabled: false, + minimumVersion: '0.0.0', + }, + mobileMinimumVersions: { + androidMinimumAPIVersion: 0, + appMinimumBuild: 0, + appleMinimumOS: 0, + }, + productSafetyDappScanning: false, + testFlagForThreshold: {}, + tokenSearchDiscoveryEnabled: false, + transactionsPrivacyPolicyUpdate: 'no_update', + transactionsTxHashInAnalytics: false, + walletFrameworkRpcFailoverEnabled: false, + }, + }; + + expect(bridgeController.state).toStrictEqual(EMPTY_INIT_STATE); + + const setIntervalLengthSpy = jest.spyOn( + bridgeController, + 'setIntervalLength', + ); + messengerCallMock.mockImplementation(() => { + return remoteFeatureFlagControllerState; + }); + + rootMessenger.call('BridgeController:setChainIntervalLength'); + + expect(setIntervalLengthSpy).toHaveBeenCalledTimes(1); + expect(setIntervalLengthSpy).toHaveBeenCalledWith(3); + }, + ); + }); + + it('updateBridgeQuoteRequestParams should update the quoteRequest state', async function () { + await withController( + async ({ controller: bridgeController, rootMessenger }) => { + messengerCallMock.mockReturnValue({ + currentCurrency: 'usd', + } as never); + + await rootMessenger.call( + 'BridgeController:updateBridgeQuoteRequestParams', + { + srcChainId: 1, + walletAddress: '0x123', + srcTokenAddress: '0x0000000000000000000000000000000000000000', + }, + metricsContext, + ); + expect(bridgeController.state.quoteRequest).toStrictEqual([ + { + walletAddress: '0x123', + srcChainId: 1, + srcTokenAddress: '0x0000000000000000000000000000000000000000', + }, + ]); + expect(trackMetaMetricsFn).toHaveBeenCalledTimes(1); + + await rootMessenger.call( + 'BridgeController:updateBridgeQuoteRequestParams', + { + destChainId: 10, + walletAddress: '0x123', + srcTokenAddress: '0x0000000000000000000000000000000000000000', + }, + metricsContext, + ); + expect(bridgeController.state.quoteRequest).toStrictEqual([ + { + walletAddress: '0x123', + destChainId: 10, + srcTokenAddress: '0x0000000000000000000000000000000000000000', + }, + ]); + + await rootMessenger.call( + 'BridgeController:updateBridgeQuoteRequestParams', + { + destChainId: undefined, + walletAddress: '0x123abc', + }, + metricsContext, + ); + expect(bridgeController.state.quoteRequest[0]).toStrictEqual({ + ...DEFAULT_BRIDGE_CONTROLLER_STATE.quoteRequest[0], + walletAddress: '0x123abc', + destChainId: undefined, + }); + + await rootMessenger.call( + 'BridgeController:updateBridgeQuoteRequestParams', + { + srcTokenAddress: undefined, + walletAddress: '0x123', + }, + metricsContext, + ); + expect(bridgeController.state.quoteRequest[0]).toStrictEqual({ + walletAddress: '0x123', + srcTokenAddress: undefined, + }); + + await rootMessenger.call( + 'BridgeController:updateBridgeQuoteRequestParams', + { + srcTokenAmount: '100000', + destTokenAddress: '0x123', + slippage: 0.5, + srcTokenAddress: '0x0000000000000000000000000000000000000000', + walletAddress: '0x123', + }, + metricsContext, + ); + expect(bridgeController.state.quoteRequest[0]).toStrictEqual({ + walletAddress: '0x123', + srcTokenAmount: '100000', + destTokenAddress: '0x123', + slippage: 0.5, + srcTokenAddress: '0x0000000000000000000000000000000000000000', + }); + + await rootMessenger.call( + 'BridgeController:updateBridgeQuoteRequestParams', + { + srcTokenAddress: '0x2ABC', + walletAddress: '0x123', + }, + metricsContext, + ); + expect(bridgeController.state.quoteRequest[0]).toStrictEqual({ + walletAddress: '0x123', + srcTokenAddress: '0x2ABC', + }); + + rootMessenger.call('BridgeController:resetState'); + expect(bridgeController.state.quoteRequest[0]).toStrictEqual({ + srcTokenAddress: '0x0000000000000000000000000000000000000000', + }); + + expect(trackMetaMetricsFn).toHaveBeenCalledTimes(3); + + expect(trackMetaMetricsFn.mock.calls).toMatchSnapshot(); + }, + ); + }); + + it('stores tokenSecurityTypeDestination from the metrics context and resets it', async function () { + await withController( + async ({ controller: bridgeController, rootMessenger }) => { + messengerCallMock.mockReturnValue({ + currentCurrency: 'usd', + } as never); + + expect(bridgeController.state.tokenSecurityTypeDestination).toBeNull(); + + await rootMessenger.call( + 'BridgeController:updateBridgeQuoteRequestParams', + { srcChainId: 1, walletAddress: '0x123' }, + { ...metricsContext, token_security_type_destination: 'Malicious' }, + ); + expect(bridgeController.state.tokenSecurityTypeDestination).toBe( + 'Malicious', + ); + + await rootMessenger.call( + 'BridgeController:updateBridgeQuoteRequestParams', + { srcChainId: 1, walletAddress: '0x123' }, + metricsContext, + ); + expect(bridgeController.state.tokenSecurityTypeDestination).toBeNull(); + + await rootMessenger.call( + 'BridgeController:updateBridgeQuoteRequestParams', + { srcChainId: 1, walletAddress: '0x123' }, + { ...metricsContext, token_security_type_destination: 'Warning' }, + ); + expect(bridgeController.state.tokenSecurityTypeDestination).toBe( + 'Warning', + ); + + rootMessenger.call('BridgeController:resetState'); + expect(bridgeController.state.tokenSecurityTypeDestination).toBeNull(); + }, + ); + }); + + it('updateBridgeQuoteRequestParams should not call fetchBridgeQuotes if SSE is enabled', async function () { + jest.useFakeTimers(); + await withController( + async ({ controller: bridgeController, rootMessenger }) => { + const stopAllPollingSpy = jest.spyOn( + bridgeController, + 'stopAllPolling', + ); + const startPollingSpy = jest.spyOn(bridgeController, 'startPolling'); + const hasSufficientBalanceSpy = jest + .spyOn(balanceUtils, 'hasSufficientBalance') + .mockResolvedValue(true); + + messengerCallMock.mockReturnValue({ + address: '0x123', + provider: jest.fn(), + currencyRates: {}, + marketData: {}, + conversionRates: {}, + remoteFeatureFlags: { + bridgeConfig: { + ...bridgeConfig, + sse: { enabled: true, minimumVersion: '13.1.0' }, + }, + }, + } as never); + + const fetchQuotesStreamSpy = jest + .spyOn(fetchUtils, 'fetchBridgeQuoteStream') + .mockImplementationOnce(async () => { + return await new Promise((resolve) => { + return setTimeout(() => { + resolve(); + }, 1000); + }); + }); + const fetchBridgeQuotesSpy = jest.spyOn( + fetchUtils, + 'fetchBridgeQuotes', + ); + + const quoteParams = { + srcChainId: '0x1', + destChainId: SolScope.Mainnet, + srcTokenAddress: '0x0000000000000000000000000000000000000000', + destTokenAddress: '123d1', + srcTokenAmount: '1000000000000000000', + slippage: 0.5, + walletAddress: '0x123', + destWalletAddress: 'SolanaWalletAddres1234', + }; + const quoteRequest = { + ...quoteParams, + }; + await rootMessenger.call( + 'BridgeController:updateBridgeQuoteRequestParams', + quoteParams, + metricsContext, + ); + + expect(stopAllPollingSpy).toHaveBeenCalledTimes(1); + expect(startPollingSpy).toHaveBeenCalledTimes(1); + expect(hasSufficientBalanceSpy).toHaveBeenCalledTimes(1); + expect(startPollingSpy).toHaveBeenCalledWith({ + quoteRequests: [ + { + ...quoteRequest, + insufficientBal: false, + resetApproval: false, + }, + ], + context: metricsContext, + }); + expect(fetchAssetPricesSpy).toHaveBeenCalledTimes(0); + + expect(bridgeController.state).toStrictEqual( + expect.objectContaining({ + quoteRequest: [ + expect.objectContaining({ + ...quoteRequest, + walletAddress: '0x123', + }), + ], + quotes: DEFAULT_BRIDGE_CONTROLLER_STATE.quotes, + quotesLastFetched: + DEFAULT_BRIDGE_CONTROLLER_STATE.quotesLastFetched, + quotesLoadingStatus: RequestStatus.LOADING, + }), + ); + + jest.advanceTimersToNextTimer(); + 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 () { + jest.useFakeTimers(); + await withController( + async ({ controller: bridgeController, rootMessenger }) => { + const stopAllPollingSpy = jest.spyOn( + bridgeController, + 'stopAllPolling', + ); + const startPollingSpy = jest.spyOn(bridgeController, 'startPolling'); + const hasSufficientBalanceSpy = jest + .spyOn(balanceUtils, 'hasSufficientBalance') + .mockResolvedValue(true); + messengerCallMock.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; + } + }, + ); + + const fetchBridgeQuotesSpy = jest + .spyOn(fetchUtils, 'fetchBridgeQuotes') + .mockImplementationOnce(async () => { + return await new Promise((resolve) => { + return setTimeout(() => { + resolve({ + quotes: mockBridgeQuotesNativeErc20EthV1, + validationFailures: [], + }); + }, 5000); + }); + }); + + fetchBridgeQuotesSpy.mockImplementationOnce(async () => { + return await new Promise((resolve) => { + return setTimeout(() => { + resolve({ + quotes: [ + ...mockBridgeQuotesNativeErc20EthV1, + ...mockBridgeQuotesNativeErc20EthV1, + ], + validationFailures: [], + }); + }, 10000); + }); + }); + + fetchBridgeQuotesSpy.mockImplementationOnce(async () => { + return await new Promise((_resolve, reject) => { + return setTimeout(() => { + reject(new Error('Network error')); + }, 10000); + }); + }); + + fetchBridgeQuotesSpy.mockImplementationOnce(async () => { + return await new Promise((resolve) => { + return setTimeout(() => { + resolve({ + quotes: [ + ...mockBridgeQuotesNativeErc20EthV1, + ...mockBridgeQuotesNativeErc20EthV1, + ], + validationFailures: [], + }); + }, 10000); + }); + }); + + const consoleLogSpy = jest + .spyOn(console, 'log') + .mockImplementationOnce(jest.fn()); + + const quoteParams = { + srcChainId: '0x1', + destChainId: SolScope.Mainnet, + srcTokenAddress: '0x0000000000000000000000000000000000000000', + destTokenAddress: '123d1', + srcTokenAmount: '1000000000000000000', + slippage: 0.5, + walletAddress: '0x123', + destWalletAddress: 'SolanaWalletAddres1234', + }; + const quoteRequest = { + ...quoteParams, + }; + await rootMessenger.call( + 'BridgeController:updateBridgeQuoteRequestParams', + quoteParams, + metricsContext, + ); + + expect(stopAllPollingSpy).toHaveBeenCalledTimes(1); + expect(startPollingSpy).toHaveBeenCalledTimes(1); + expect(hasSufficientBalanceSpy).toHaveBeenCalledTimes(1); + expect(startPollingSpy).toHaveBeenCalledWith({ + quoteRequests: [ + { + ...quoteRequest, + insufficientBal: false, + resetApproval: false, + }, + ], + context: metricsContext, + }); + expect(fetchAssetPricesSpy).toHaveBeenCalledTimes(0); + + expect(bridgeController.state).toStrictEqual( + expect.objectContaining({ + quoteRequest: [ + expect.objectContaining({ + ...quoteRequest, + walletAddress: '0x123', + }), + ], + quotes: DEFAULT_BRIDGE_CONTROLLER_STATE.quotes, + quotesLastFetched: + DEFAULT_BRIDGE_CONTROLLER_STATE.quotesLastFetched, + quotesLoadingStatus: RequestStatus.LOADING, + }), + ); + + // Loading state + jest.advanceTimersByTime(1000); + await flushPromises(); + expect(fetchBridgeQuotesSpy).toHaveBeenCalledTimes(1); + expect(fetchAssetPricesSpy).toHaveBeenCalledTimes(1); + expect(fetchBridgeQuotesSpy).toHaveBeenCalledWith( + { + ...quoteRequest, + insufficientBal: false, + resetApproval: false, + }, + expect.any(AbortSignal), + BridgeClientId.EXTENSION, + 'AUTH_TOKEN', + mockFetchFn, + BRIDGE_PROD_API_BASE_URL, + FeatureId.UNIFIED_SWAP_BRIDGE, + '13.7.0', + ); + expect(bridgeController.state.quotesLastFetched).toBeCloseTo( + Date.now() - 1000, + ); + + expect(bridgeController.state).toStrictEqual( + expect.objectContaining({ + quoteRequest: [ + { + ...quoteRequest, + insufficientBal: false, + resetApproval: false, + }, + ], + quotes: [], + quotesLoadingStatus: 0, + }), + ); + + // After first fetch + jest.advanceTimersToNextTimer(); + await flushPromises(); + expect(bridgeController.state).toStrictEqual( + expect.objectContaining({ + quoteRequest: [ + { + ...quoteRequest, + insufficientBal: false, + resetApproval: false, + }, + ], + quotes: getMockBridgeQuotesNativeErc20EthV2(), + quotesLoadingStatus: 1, + }), + ); + const firstFetchTime = bridgeController.state.quotesLastFetched; + expect(firstFetchTime).toBeGreaterThan(0); + + expect(fetchBridgeQuotesSpy).toHaveBeenCalledTimes(1); + // After 2nd fetch + jest.advanceTimersToNextTimer(); + await flushPromises(); + jest.advanceTimersToNextTimer(); + await flushPromises(); + expect(bridgeController.state).toStrictEqual( + expect.objectContaining({ + quoteRequest: [ + { + ...quoteRequest, + insufficientBal: false, + resetApproval: false, + }, + ], + quotes: [ + ...getMockBridgeQuotesNativeErc20EthV2(), + ...getMockBridgeQuotesNativeErc20EthV2(), + ], + quotesLoadingStatus: 1, + quoteFetchError: null, + quotesRefreshCount: 2, + }), + ); + expect(fetchBridgeQuotesSpy).toHaveBeenCalledTimes(2); + const secondFetchTime = bridgeController.state.quotesLastFetched; + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + expect(secondFetchTime).toBeGreaterThan(firstFetchTime!); + + // After 3nd fetch throws an error + jest.advanceTimersToNextTimer(); + await flushPromises(); + jest.advanceTimersToNextTimer(); + await flushPromises(); + expect(fetchBridgeQuotesSpy).toHaveBeenCalledTimes(3); + expect(bridgeController.state).toStrictEqual( + expect.objectContaining({ + quoteRequest: [ + { + ...quoteRequest, + insufficientBal: false, + resetApproval: false, + }, + ], + quotes: [], + quotesLoadingStatus: 2, + quoteFetchError: 'Network error', + quotesRefreshCount: 3, + }), + ); + expect( + bridgeController.state.quotesLastFetched, + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + ).toBeGreaterThan(secondFetchTime!); + const thirdFetchTime = bridgeController.state.quotesLastFetched; + + // Incoming request update aborts current polling + jest.advanceTimersToNextTimer(); + await flushPromises(); + await rootMessenger.call( + 'BridgeController:updateBridgeQuoteRequestParams', + { ...quoteRequest, srcTokenAmount: '10', insufficientBal: false }, + { + stx_enabled: true, + token_symbol_source: 'ETH', + token_symbol_destination: 'USDC', + security_warnings: [], + usd_amount_source: 100, + token_security_type_destination: null, + feature_id: FeatureId.UNIFIED_SWAP_BRIDGE, + }, + ); + await flushPromises(); + expect(fetchBridgeQuotesSpy).toHaveBeenCalledTimes(3); + + expect(bridgeController.state).toMatchSnapshot(); + expect(consoleLogSpy).toHaveBeenCalledTimes(1); + expect(consoleLogSpy).toHaveBeenCalledWith( + 'Failed to fetch bridge quotes', + new Error('Network error'), + ); + + // Next fetch succeeds + jest.advanceTimersToNextTimer(); + await flushPromises(); + jest.advanceTimersToNextTimer(); + await flushPromises(); + expect(fetchBridgeQuotesSpy).toHaveBeenCalledTimes(4); + const { quotesLastFetched, quotes, ...stateWithoutTimestamp } = + bridgeController.state; + + expect(stateWithoutTimestamp).toMatchSnapshot(); + expect(quotes).toStrictEqual([ + ...getMockBridgeQuotesNativeErc20EthV2(), + ...getMockBridgeQuotesNativeErc20EthV2(), + ]); + expect( + quotesLastFetched, + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + ).toBeGreaterThan(thirdFetchTime!); + + expect(hasSufficientBalanceSpy).toHaveBeenCalledTimes(1); + expect(getLayer1GasFeeMock).not.toHaveBeenCalled(); + + expect(trackMetaMetricsFn).toHaveBeenCalledTimes(9); + + expect(trackMetaMetricsFn.mock.calls).toMatchSnapshot(); + }, + ); + }); + + it('updateBridgeQuoteRequestParams should reset minimumBalanceForRentExemptionInLamports if getMinimumBalanceForRentExemption call fails', async function () { + jest.useFakeTimers(); + jest.clearAllMocks(); + await withController( + async ({ controller: bridgeController, rootMessenger }) => { + jest + .spyOn(balanceUtils, 'hasSufficientBalance') + .mockResolvedValue(false); + const consoleErrorSpy = jest + .spyOn(console, 'error') + .mockImplementation(jest.fn()); + const consoleWarnSpy = jest + .spyOn(console, 'warn') + .mockImplementation(jest.fn()); + + const setupMessengerMock = (shouldMinBalanceFail = false): void => { + messengerCallMock.mockImplementation( + ( + ...args: Parameters + ): ReturnType => { + const [actionType, params] = args; + + if (actionType === 'CurrencyRateController:getState') { + throw new Error('Currency rate error'); + } + + if (actionType === 'AccountsController:getAccountByAddress') { + return { + type: SolAccountType.DataAccount, + id: 'account1', + scopes: [SolScope.Mainnet], + methods: [], + address: '0x123', + metadata: { + name: 'Account 1', + importTime: 1717334400, + keyring: { + type: 'Keyring', + }, + snap: { + id: 'npm:@metamask/solana-snap', + }, + }, + options: { + scope: SolScope.Mainnet, + }, + }; + } + + if (actionType === 'SnapController:handleRequest') { + return new Promise((resolve, reject) => { + if ( + (params as { handler: string })?.handler === + 'onProtocolRequest' + ) { + if (shouldMinBalanceFail) { + return setTimeout(() => { + reject(new Error('Min balance error')); + }, 200); + } + return setTimeout(() => { + resolve('5000'); + }, 200); + } + if ( + (params as { handler: string })?.handler === + 'onClientRequest' && + (params as { request?: { method: string } })?.request + ?.method === 'computeFee' + ) { + return setTimeout(() => { + resolve([ + { + type: 'base', + asset: { + unit: 'SOL', + type: 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/token:11111111111111111111111111111111', + amount: '0.000000014', // 14 lamports in SOL + fungible: true, + }, + }, + ]); + }, 100); + } + return setTimeout(() => { + resolve({ value: '14' }); + }, 100); + }); + } + return { + provider: jest.fn() as never, + } as never; + }, + ); + }; + jest + .spyOn(selectors, 'selectIsAssetExchangeRateInState') + .mockReturnValue(true); + + setupMessengerMock(); + const fetchBridgeQuotesSpy = jest + .spyOn(fetchUtils, 'fetchBridgeQuotes') + .mockImplementation(async () => { + return await new Promise((resolve) => { + return setTimeout(() => { + resolve({ + quotes: mockBridgeQuotesSolErc20V1, + validationFailures: [], + }); + }, 2000); + }); + }); + + const quoteParams = { + srcChainId: SolScope.Mainnet, + destChainId: SolScope.Mainnet, + srcTokenAddress: '0x0000000000000000000000000000000000000000', + destTokenAddress: '0x123', + srcTokenAmount: '1000000000000000000', + walletAddress: '0x123', + slippage: 0.5, + }; + + /* + Set quote request with Solana srcChainId + */ + await rootMessenger.call( + 'BridgeController:updateBridgeQuoteRequestParams', + quoteParams, + metricsContext, + ); + + // Initial state check + expect(bridgeController.state).toStrictEqual( + expect.objectContaining({ + quoteRequest: [expect.objectContaining(quoteParams)], + minimumBalanceForRentExemptionInLamports: '0', + quotesLoadingStatus: + DEFAULT_BRIDGE_CONTROLLER_STATE.quotesLoadingStatus, + }), + ); + + // Advance timers and check loading state + jest.advanceTimersToNextTimer(); + await flushPromises(); + jest.advanceTimersToNextTimer(); + await flushPromises(); + expect(fetchBridgeQuotesSpy).toHaveBeenCalledTimes(1); + expect(bridgeController.state).toStrictEqual( + expect.objectContaining({ + minimumBalanceForRentExemptionInLamports: '5000', + quotes: [], + quotesLoadingStatus: RequestStatus.LOADING, + }), + ); + + // Advance timers and check final state + jest.advanceTimersToNextTimer(); + await flushPromises(); + jest.advanceTimersToNextTimer(); + await flushPromises(); + expect(bridgeController.state).toStrictEqual( + expect.objectContaining({ + minimumBalanceForRentExemptionInLamports: '5000', + quotes: getMockBridgeQuotesSolErc20V2().map((quote) => ({ + ...quote, + nonEvmFeesInNative: '0.000000014', + })), + quotesLoadingStatus: RequestStatus.FETCHED, + quoteRequest: [ + { + ...quoteParams, + resetApproval: false, + insufficientBal: undefined, + }, + ], + quoteFetchError: null, + assetExchangeRates: {}, + quotesRefreshCount: 1, + quotesInitialLoadTime: 2100, + quotesLastFetched: expect.any(Number), + }), + ); + expect(consoleErrorSpy).not.toHaveBeenCalled(); + expect( + messengerCallMock.mock.calls.filter(([action]) => + action.includes('SnapController'), + ), + ).toHaveLength(3); + + /* + Update quote request params to EVM and back to Solana + */ + await rootMessenger.call( + 'BridgeController:updateBridgeQuoteRequestParams', + { ...quoteParams, srcChainId: '0x1' }, + metricsContext, + ); + jest.advanceTimersByTime(2000); + expect(bridgeController.state).toStrictEqual( + expect.objectContaining({ + minimumBalanceForRentExemptionInLamports: '0', + quotes: [], + quotesLoadingStatus: null, + }), + ); + + /* + Add destWalletAddress + */ + await rootMessenger.call( + 'BridgeController:updateBridgeQuoteRequestParams', + { ...quoteParams, destWalletAddress: 'SolanaWalletAddres1234' }, + metricsContext, + ); + jest.advanceTimersByTime(2000); + expect(bridgeController.state).toStrictEqual( + expect.objectContaining({ + minimumBalanceForRentExemptionInLamports: '0', + quotes: [], + quotesLoadingStatus: RequestStatus.LOADING, + }), + ); + + await rootMessenger.call( + 'BridgeController:updateBridgeQuoteRequestParams', + quoteParams, + metricsContext, + ); + jest.advanceTimersToNextTimer(); + await flushPromises(); + jest.advanceTimersToNextTimer(); + await flushPromises(); + jest.advanceTimersToNextTimer(); + await flushPromises(); + jest.advanceTimersToNextTimer(); + await flushPromises(); + expect(fetchBridgeQuotesSpy).toHaveBeenCalledTimes(3); + expect(bridgeController.state).toStrictEqual( + expect.objectContaining({ + minimumBalanceForRentExemptionInLamports: '5000', + quotes: getMockBridgeQuotesSolErc20V2().map((quote) => ({ + ...quote, + nonEvmFeesInNative: '0.000000014', + })), + quotesLoadingStatus: RequestStatus.FETCHED, + quoteRequest: [ + { + ...quoteParams, + resetApproval: false, + insufficientBal: undefined, + }, + ], + quoteFetchError: null, + assetExchangeRates: {}, + quotesRefreshCount: expect.any(Number), + quotesInitialLoadTime: expect.any(Number), + quotesLastFetched: expect.any(Number), + }), + ); + expect(consoleErrorSpy).not.toHaveBeenCalled(); + expect( + messengerCallMock.mock.calls.filter(([action]) => + action.includes('SnapController'), + ), + ).toHaveLength(9); + + /* + Test min balance fetch failure + */ + setupMessengerMock(true); + await rootMessenger.call( + 'BridgeController:updateBridgeQuoteRequestParams', + { ...quoteParams, srcTokenAmount: '11111' }, + metricsContext, + ); + + // Check states during failure scenario + jest.advanceTimersToNextTimer(); + await flushPromises(); + jest.advanceTimersToNextTimer(); + await flushPromises(); + jest.advanceTimersToNextTimer(); + await flushPromises(); + jest.advanceTimersToNextTimer(); + await flushPromises(); + expect(fetchBridgeQuotesSpy).toHaveBeenCalledTimes(4); + expect(bridgeController.state).toStrictEqual( + expect.objectContaining({ + minimumBalanceForRentExemptionInLamports: '0', + quotes: getMockBridgeQuotesSolErc20V2().map((quote) => ({ + ...quote, + nonEvmFeesInNative: '0.000000014', + })), + quotesLoadingStatus: RequestStatus.FETCHED, + quoteRequest: [ + { + ...quoteParams, + srcTokenAmount: '11111', + insufficientBal: undefined, + resetApproval: false, + }, + ], + quoteFetchError: null, + assetExchangeRates: {}, + quotesRefreshCount: 1, + quotesInitialLoadTime: 2100, + quotesLastFetched: expect.any(Number), + }), + ); + + // Verify error handling + expect(consoleErrorSpy.mock.calls).toMatchSnapshot(); + expect( + messengerCallMock.mock.calls.filter(([action]) => + action.includes('SnapController'), + ), + ).toHaveLength(12); + expect( + messengerCallMock.mock.calls.filter(([action]) => + action.includes('SnapController'), + ), + ).toMatchSnapshot(); + expect(consoleWarnSpy).toHaveBeenCalledTimes(4); + expect(consoleWarnSpy).toHaveBeenCalledWith( + 'Failed to fetch asset exchange rates', + new Error('Currency rate error'), + ); + }, + ); + }); + + it('updateBridgeQuoteRequestParams should only poll once if insufficientBal=true', async function () { + jest.useFakeTimers(); + await withController( + async ({ controller: bridgeController, rootMessenger }) => { + const stopAllPollingSpy = jest.spyOn( + bridgeController, + 'stopAllPolling', + ); + const startPollingSpy = jest.spyOn(bridgeController, 'startPolling'); + const hasSufficientBalanceSpy = jest + .spyOn(balanceUtils, 'hasSufficientBalance') + .mockResolvedValue(false); + messengerCallMock.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: mockBridgeQuotesNativeErc20EthV1, + validationFailures: [], + }); + }, 5000); + }); + }); + + fetchBridgeQuotesSpy.mockImplementationOnce(async () => { + return await new Promise((resolve) => { + return setTimeout(() => { + resolve({ + quotes: [ + ...mockBridgeQuotesNativeErc20EthV1, + ...mockBridgeQuotesNativeErc20EthV1, + ], + validationFailures: [], + }); + }, 10000); + }); + }); + + const quoteParams = { + srcChainId: '0x1', + destChainId: '0xa', + srcTokenAddress: '0x0000000000000000000000000000000000000000', + destTokenAddress: '0x123', + srcTokenAmount: '1000000000000000000', + walletAddress: '0x123', + slippage: 0.5, + }; + const quoteRequest = { + ...quoteParams, + }; + await rootMessenger.call( + 'BridgeController:updateBridgeQuoteRequestParams', + quoteParams, + metricsContext, + ); + + expect(stopAllPollingSpy).toHaveBeenCalledTimes(1); + expect(startPollingSpy).toHaveBeenCalledTimes(1); + expect(hasSufficientBalanceSpy).toHaveBeenCalledTimes(1); + expect(startPollingSpy).toHaveBeenCalledWith({ + quoteRequests: [ + { + ...quoteRequest, + insufficientBal: true, + resetApproval: false, + }, + ], + context: metricsContext, + }); + expect(fetchAssetPricesSpy).not.toHaveBeenCalled(); + + expect(bridgeController.state).toStrictEqual( + expect.objectContaining({ + quoteRequest: [expect.objectContaining(quoteRequest)], + quotes: DEFAULT_BRIDGE_CONTROLLER_STATE.quotes, + quotesLastFetched: + DEFAULT_BRIDGE_CONTROLLER_STATE.quotesLastFetched, + quotesInitialLoadTime: null, + quotesLoadingStatus: RequestStatus.LOADING, + }), + ); + + // Loading state + jest.advanceTimersByTime(1000); + await flushPromises(); + expect(fetchBridgeQuotesSpy).toHaveBeenCalledTimes(1); + expect(fetchBridgeQuotesSpy).toHaveBeenCalledWith( + { + ...quoteRequest, + insufficientBal: true, + resetApproval: false, + }, + expect.any(AbortSignal), + BridgeClientId.EXTENSION, + 'AUTH_TOKEN', + mockFetchFn, + BRIDGE_PROD_API_BASE_URL, + FeatureId.UNIFIED_SWAP_BRIDGE, + '13.7.0', + ); + expect(bridgeController.state.quotesLastFetched).toBeCloseTo( + Date.now() - 1000, + ); + const t1 = bridgeController.state.quotesLastFetched; + + expect(bridgeController.state).toStrictEqual( + expect.objectContaining({ + quoteRequest: [ + { + ...quoteRequest, + insufficientBal: true, + resetApproval: false, + }, + ], + quotes: [], + quotesLoadingStatus: 0, + quotesLastFetched: t1, + }), + ); + + // After first fetch + jest.advanceTimersByTime(10000); + await flushPromises(); + expect(bridgeController.state).toStrictEqual( + expect.objectContaining({ + quoteRequest: [ + { + ...quoteRequest, + insufficientBal: true, + resetApproval: false, + }, + ], + quotes: getMockBridgeQuotesNativeErc20EthV2(), + quotesLoadingStatus: 1, + quotesRefreshCount: 1, + quotesInitialLoadTime: 11000, + }), + ); + const firstFetchTime = bridgeController.state.quotesLastFetched; + expect(firstFetchTime).toBeGreaterThan(0); + rootMessenger.call( + 'BridgeController:trackUnifiedSwapBridgeEvent', + UnifiedSwapBridgeEventName.QuotesReceived, + { + warnings: ['low_return'], + usd_quoted_gas: 0, + gas_included: false, + gas_included_7702: false, + quoted_time_minutes: 10, + usd_quoted_return: 100, + price_impact: 0, + provider: 'provider_bridge', + best_quote_provider: 'provider_bridge2', + can_submit: true, + usd_balance_source: 0, + feature_id: FeatureId.DAPP_SWAP, + }, + ); + + expect(trackMetaMetricsFn.mock.calls).toMatchSnapshot(); + + // After 2nd fetch + jest.advanceTimersByTime(50000); + await flushPromises(); + expect(fetchBridgeQuotesSpy).toHaveBeenCalledTimes(1); + expect(bridgeController.state).toStrictEqual( + expect.objectContaining({ + quoteRequest: [ + { + ...quoteRequest, + insufficientBal: true, + resetApproval: false, + }, + ], + quotes: getMockBridgeQuotesNativeErc20EthV2(), + quotesLoadingStatus: 1, + quotesRefreshCount: 1, + quotesInitialLoadTime: 11000, + }), + ); + const secondFetchTime = bridgeController.state.quotesLastFetched; + expect(secondFetchTime).toStrictEqual(t1); + expect(secondFetchTime).toStrictEqual(firstFetchTime); + expect(getLayer1GasFeeMock).not.toHaveBeenCalled(); + }, + ); + }); + + it('updateBridgeQuoteRequestParams should set insufficientBal=true if RPC provider is tenderly', async function () { + jest.useFakeTimers(); + await withController( + async ({ controller: bridgeController, rootMessenger }) => { + const stopAllPollingSpy = jest.spyOn( + bridgeController, + 'stopAllPolling', + ); + const startPollingSpy = jest.spyOn(bridgeController, 'startPolling'); + const hasSufficientBalanceSpy = jest + .spyOn(balanceUtils, 'hasSufficientBalance') + .mockResolvedValue(false); + + messengerCallMock.mockImplementation( + ( + ...args: Parameters + ): ReturnType => { + const actionType = args[0]; + + if (actionType === 'AccountsController:getAccountByAddress') { + return { + type: SolAccountType.DataAccount, + id: 'account1', + scopes: [SolScope.Mainnet], + methods: [], + address: '0x123', + metadata: { + snap: { + id: 'npm:@metamask/solana-snap', + }, + name: 'Account 1', + importTime: 1717334400, + keyring: { + type: 'Keyring', + }, + }, + options: { + scope: 'mainnet', + }, + }; + } + + if (actionType === 'NetworkController:getNetworkClientById') { + return { + configuration: { rpcUrl: 'https://rpc.tenderly.co' }, + } as never; + } + return { + provider: jest.fn() as never, + } as never; + }, + ); + + const fetchBridgeQuotesSpy = jest + .spyOn(fetchUtils, 'fetchBridgeQuotes') + .mockImplementationOnce(async () => { + return await new Promise((resolve) => { + return setTimeout(() => { + resolve({ + quotes: mockBridgeQuotesNativeErc20EthV1, + validationFailures: [], + }); + }, 5000); + }); + }); + + fetchBridgeQuotesSpy.mockImplementationOnce(async () => { + return await new Promise((resolve) => { + return setTimeout(() => { + resolve({ + quotes: [ + ...mockBridgeQuotesNativeErc20EthV1, + ...mockBridgeQuotesNativeErc20EthV1, + ], + validationFailures: [], + }); + }, 10000); + }); + }); + + const quoteParams = { + srcChainId: '0x1', + destChainId: '0xa', + srcTokenAddress: '0x0000000000000000000000000000000000000000', + destTokenAddress: '0x123', + srcTokenAmount: '1000000000000000000', + walletAddress: '0x123', + slippage: 0.5, + }; + const quoteRequest = { + ...quoteParams, + }; + await rootMessenger.call( + 'BridgeController:updateBridgeQuoteRequestParams', + quoteParams, + metricsContext, + ); + + expect(stopAllPollingSpy).toHaveBeenCalledTimes(1); + expect(startPollingSpy).toHaveBeenCalledTimes(1); + expect(hasSufficientBalanceSpy).not.toHaveBeenCalled(); + expect(startPollingSpy).toHaveBeenCalledWith({ + quoteRequests: [ + { + ...quoteRequest, + insufficientBal: true, + resetApproval: false, + }, + ], + context: metricsContext, + }); + + // Loading state + jest.advanceTimersByTime(1000); + await flushPromises(); + + // After first fetch + jest.advanceTimersByTime(10000); + await flushPromises(); + expect(bridgeController.state).toStrictEqual( + expect.objectContaining({ + quoteRequest: [ + { + ...quoteRequest, + insufficientBal: true, + resetApproval: false, + }, + ], + quotes: getMockBridgeQuotesNativeErc20EthV2(), + quotesLoadingStatus: 1, + quotesRefreshCount: 1, + quotesInitialLoadTime: 11000, + }), + ); + const firstFetchTime = bridgeController.state.quotesLastFetched; + expect(firstFetchTime).toBeGreaterThan(0); + }, + ); + }); + + it('updateBridgeQuoteRequestParams should not trigger quote polling if request is invalid', async function () { + await withController( + async ({ controller: bridgeController, rootMessenger }) => { + const stopAllPollingSpy = jest.spyOn( + bridgeController, + 'stopAllPolling', + ); + const startPollingSpy = jest.spyOn(bridgeController, 'startPolling'); + messengerCallMock.mockReturnValue({ + address: '0x123WalletAddress', + provider: jest.fn(), + } as never); + + await rootMessenger.call( + 'BridgeController:updateBridgeQuoteRequestParams', + { + walletAddress: '0x123WalletAddress', + srcChainId: 1, + destChainId: 10, + srcTokenAddress: '0x0000000000000000000000000000000000000000', + destTokenAddress: '0x123', + slippage: 0.5, + }, + metricsContext, + ); + + expect(stopAllPollingSpy).toHaveBeenCalledTimes(1); + expect(startPollingSpy).not.toHaveBeenCalled(); + + expect(bridgeController.state).toStrictEqual( + expect.objectContaining({ + quoteRequest: [ + { + srcChainId: 1, + slippage: 0.5, + srcTokenAddress: '0x0000000000000000000000000000000000000000', + walletAddress: '0x123WalletAddress', + destChainId: 10, + destTokenAddress: '0x123', + }, + ], + quotes: DEFAULT_BRIDGE_CONTROLLER_STATE.quotes, + quotesLastFetched: + DEFAULT_BRIDGE_CONTROLLER_STATE.quotesLastFetched, + quotesLoadingStatus: + DEFAULT_BRIDGE_CONTROLLER_STATE.quotesLoadingStatus, + }), + ); + }, + ); + }); + + it('updateBridgeQuoteRequestParams should not trigger quote polling if bridging to or from solana and destWalletAddress is undefined', async function () { + await withController( + async ({ controller: bridgeController, rootMessenger }) => { + const stopAllPollingSpy = jest.spyOn( + bridgeController, + 'stopAllPolling', + ); + const startPollingSpy = jest.spyOn(bridgeController, 'startPolling'); + messengerCallMock.mockReturnValue({ + address: '0xabcWalletAddress', + provider: jest.fn(), + } as never); + + await rootMessenger.call( + 'BridgeController:updateBridgeQuoteRequestParams', + { + walletAddress: '0xabcWalletAddress', + srcChainId: 1, + destChainId: ChainId.SOLANA, + srcTokenAddress: '0x0000000000000000000000000000000000000000', + destTokenAddress: '0x123', + slippage: 0.5, + }, + metricsContext, + ); + + expect(stopAllPollingSpy).toHaveBeenCalledTimes(1); + expect(startPollingSpy).not.toHaveBeenCalled(); + + expect(bridgeController.state).toStrictEqual( + expect.objectContaining({ + quoteRequest: [ + { + srcChainId: 1, + slippage: 0.5, + srcTokenAddress: '0x0000000000000000000000000000000000000000', + walletAddress: '0xabcWalletAddress', + destChainId: ChainId.SOLANA, + destTokenAddress: '0x123', + }, + ], + quotes: DEFAULT_BRIDGE_CONTROLLER_STATE.quotes, + quotesLastFetched: + DEFAULT_BRIDGE_CONTROLLER_STATE.quotesLastFetched, + quotesLoadingStatus: + DEFAULT_BRIDGE_CONTROLLER_STATE.quotesLoadingStatus, + }), + ); + }, + ); + }); + + it('updateBridgeQuoteRequestParams should include undefined Authentication header if getBearerToken throws an error', async function () { + const consoleErrorSpy = jest + .spyOn(console, 'error') + .mockImplementationOnce(jest.fn()) + .mockImplementationOnce(jest.fn()); + jest.spyOn(balanceUtils, 'hasSufficientBalance').mockResolvedValue(true); + jest.useFakeTimers(); + await withController( + async ({ controller: bridgeController, rootMessenger }) => { + const startPollingSpy = jest.spyOn(bridgeController, 'startPolling'); + messengerCallMock.mockImplementation( + (...args: Parameters) => { + switch (args[0]) { + case 'AuthenticationController:getBearerToken': + throw new Error( + 'AuthenticationController:getBearerToken not implemented', + ); + default: + return { + address: '0x141d32a89a1e0a5Ef360034a2f60a4B917c18838', + 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: mockBridgeQuotesNativeErc20EthV1, + validationFailures: [], + }); + }, 5000); + }); + }); + + const quoteParams = { + srcChainId: '0x1', + destChainId: '0xa', + srcTokenAddress: '0x0000000000000000000000000000000000000000', + destTokenAddress: '0x141d32a89a1e0a5Ef360034a2f60a4B917c18838', + srcTokenAmount: '1000000000000000000', + walletAddress: '0x141d32a89a1e0a5Ef360034a2f60a4B917c18838', + slippage: 0.5, + }; + + await rootMessenger.call( + 'BridgeController:updateBridgeQuoteRequestParams', + quoteParams, + metricsContext, + ); + + await advanceToNthTimerThenFlush(); + + expect(startPollingSpy).toHaveBeenCalledTimes(1); + expect(fetchBridgeQuotesSpy).toHaveBeenCalledTimes(1); + expect(fetchBridgeQuotesSpy.mock.calls[0][3]).toBeUndefined(); + expect(consoleErrorSpy.mock.calls).toMatchInlineSnapshot(` + [ + [ + "Error getting JWT token for bridge-api request", + [Error: AuthenticationController:getBearerToken not implemented], + ], + [ + "Error getting JWT token for bridge-api request", + [Error: AuthenticationController:getBearerToken not implemented], + ], + ] + `); + }, + ); + }); + + it('updateBridgeQuoteRequestParams should include auth token as Authentication header', async function () { + jest.spyOn(balanceUtils, 'hasSufficientBalance').mockResolvedValue(true); + jest.useFakeTimers(); + await withController( + async ({ controller: bridgeController, rootMessenger }) => { + const startPollingSpy = jest.spyOn(bridgeController, 'startPolling'); + messengerCallMock.mockImplementation( + (...args: Parameters) => { + switch (args[0]) { + case 'AuthenticationController:getBearerToken': + return 'AUTH_TOKEN'; + default: + return { + address: '0x141d32a89a1e0a5Ef360034a2f60a4B917c18838', + provider: jest.fn(), + currentCurrency: 'usd', + currencyRates: {}, + marketData: {}, + conversionRates: {}, + } as never; + } + }, + ); + jest + .spyOn(selectors, 'selectIsAssetExchangeRateInState') + .mockReturnValue(true); + + jest + .spyOn(balanceUtils, 'hasSufficientBalance') + .mockResolvedValue(true); + const fetchBridgeQuotesSpy = jest + .spyOn(fetchUtils, 'fetchBridgeQuotes') + .mockImplementationOnce(async () => { + return await new Promise((resolve) => { + return setTimeout(() => { + resolve({ + quotes: mockBridgeQuotesNativeErc20EthV1, + validationFailures: [], + }); + }, 5000); + }); + }); + + const quoteParams = { + srcChainId: '0x1', + destChainId: '0xa', + srcTokenAddress: '0x0000000000000000000000000000000000000000', + destTokenAddress: '0x141d32a89a1e0a5Ef360034a2f60a4B917c18838', + srcTokenAmount: '1000000000000000000', + walletAddress: '0x141d32a89a1e0a5Ef360034a2f60a4B917c18838', + slippage: 0.5, + }; + + await rootMessenger.call( + 'BridgeController:updateBridgeQuoteRequestParams', + quoteParams, + metricsContext, + ); + + await advanceToNthTimerThenFlush(); + + expect(startPollingSpy).toHaveBeenCalledTimes(1); + expect(fetchBridgeQuotesSpy).toHaveBeenCalledTimes(1); + expect(fetchBridgeQuotesSpy.mock.calls[0][3]).toBe('AUTH_TOKEN'); + }, + ); + }); + + it.each([ + [ + 'should append l1GasFees if srcChain is 10 and srcToken is erc20', + mockBridgeQuotesErc20NativeV1, + ['0x2', '0x1'], + [6, 12], + ], + [ + 'should append l1GasFees if srcChain is 10 and srcToken is native', + mockBridgeQuotesNativeErc20V1, + ['0x1', '0x1'], + [2, 2], + ], + [ + 'should not append l1GasFees if srcChain is not 10', + mockBridgeQuotesNativeErc20EthV1, + [], + [2, 0], + ], + [ + 'should filter out quote if getL1Fees returns undefined', + mockBridgeQuotesErc20NativeV1, + ['0x2', undefined], + [5, 12], + ], + [ + 'should filter out quote if L1 fee calculation fails', + mockBridgeQuotesErc20NativeV1, + ['0x2', '0x1', 'L1 gas fee calculation failed'], + [5, 11], + ], + ])( + 'updateBridgeQuoteRequestParams: %s', + async ( + _testTitle: string, + quoteResponse: QuoteResponseV1[], + [totalL1GasFeesInHexWei, tradeL1GasFeesInHexWei, tradeL1GasFeeError]: ( + | string + | undefined + )[], + [expectedQuotesLength, expectedGetLayer1GasFeeMockCallCount]: number[], + ) => { + const errorSpy = jest + .spyOn(console, 'error') + .mockImplementation(jest.fn()); + jest.useFakeTimers(); + await withController( + async ({ controller: bridgeController, rootMessenger }) => { + const stopAllPollingSpy = jest.spyOn( + bridgeController, + 'stopAllPolling', + ); + const startPollingSpy = jest.spyOn(bridgeController, 'startPolling'); + const hasSufficientBalanceSpy = jest + .spyOn(balanceUtils, 'hasSufficientBalance') + .mockResolvedValue(false); + messengerCallMock.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) { + getLayer1GasFeeMock.mockRejectedValueOnce( + new Error(tradeL1GasFeeError), + ); + continue; + } + + if (quote.approval) { + getLayer1GasFeeMock.mockResolvedValueOnce('0x1'); + } + + if (tradeL1GasFeesInHexWei === undefined && index === 0) { + getLayer1GasFeeMock.mockResolvedValueOnce(undefined); + continue; + } + getLayer1GasFeeMock.mockResolvedValueOnce( + tradeL1GasFeesInHexWei ?? '0x1', + ); + } + + const fetchBridgeQuotesSpy = jest + .spyOn(fetchUtils, 'fetchBridgeQuotes') + .mockImplementationOnce(async () => { + return await new Promise((resolve) => { + return setTimeout(() => { + resolve({ + quotes: quoteResponse, + validationFailures: [], + }); + }, 1000); + }); + }); + + const quoteParams = { + srcChainId: '0xa', + destChainId: '0x1', + srcTokenAddress: '0x4200000000000000000000000000000000000006', + destTokenAddress: '0x0000000000000000000000000000000000000000', + srcTokenAmount: '991250000000000000', + walletAddress: 'eip:id/id:id/0x123', + slippage: 0.5, + }; + const quoteRequest = { + ...quoteParams, + }; + await rootMessenger.call( + 'BridgeController:updateBridgeQuoteRequestParams', + quoteParams, + metricsContext, + ); + + expect(stopAllPollingSpy).toHaveBeenCalledTimes(1); + expect(startPollingSpy).toHaveBeenCalledTimes(1); + expect(hasSufficientBalanceSpy).toHaveBeenCalledTimes(1); + expect(startPollingSpy).toHaveBeenCalledWith({ + quoteRequests: [ + { + ...quoteRequest, + insufficientBal: true, + resetApproval: false, + }, + ], + context: metricsContext, + }); + + expect(bridgeController.state).toStrictEqual( + expect.objectContaining({ + quoteRequest: [expect.objectContaining(quoteRequest)], + quotes: DEFAULT_BRIDGE_CONTROLLER_STATE.quotes, + quotesLastFetched: + DEFAULT_BRIDGE_CONTROLLER_STATE.quotesLastFetched, + quotesLoadingStatus: RequestStatus.LOADING, + }), + ); + + // Loading state + jest.advanceTimersByTime(500); + await flushPromises(); + expect(fetchBridgeQuotesSpy).toHaveBeenCalledTimes(1); + expect(fetchBridgeQuotesSpy).toHaveBeenCalledWith( + { + ...quoteRequest, + insufficientBal: true, + resetApproval: false, + }, + expect.any(AbortSignal), + BridgeClientId.EXTENSION, + 'AUTH_TOKEN', + mockFetchFn, + BRIDGE_PROD_API_BASE_URL, + FeatureId.UNIFIED_SWAP_BRIDGE, + '13.7.0', + ); + expect(bridgeController.state.quotesLastFetched).toBeCloseTo( + Date.now() - 500, + ); + + expect(bridgeController.state).toStrictEqual( + expect.objectContaining({ + quoteRequest: [ + { + ...quoteRequest, + insufficientBal: true, + resetApproval: false, + }, + ], + quotes: [], + quotesLoadingStatus: 0, + }), + ); + + // After first fetch + jest.advanceTimersByTime(1500); + await flushPromises(); + const { quotes } = bridgeController.state; + expect(quotes).toHaveLength(expectedQuotesLength); + expect(bridgeController.state).toStrictEqual( + expect.objectContaining({ + quoteRequest: [ + { + ...quoteRequest, + insufficientBal: true, + resetApproval: false, + }, + ], + quotesLoadingStatus: 1, + quotesRefreshCount: 1, + }), + ); + quotes.forEach((quote) => { + const expectedQuote = { + ...quote, + l1GasFeesInHexWei: totalL1GasFeesInHexWei, + }; + // eslint-disable-next-line jest/prefer-strict-equal + expect(quote).toEqual(expectedQuote); + }); + + const firstFetchTime = bridgeController.state.quotesLastFetched; + expect(firstFetchTime).toBeGreaterThan(0); + + expect(getLayer1GasFeeMock).toHaveBeenCalledTimes( + expectedGetLayer1GasFeeMockCallCount, + ); + + expect(errorSpy).toHaveBeenCalledTimes(tradeL1GasFeeError ? 1 : 0); + }, + ); + }, + ); + + it('should handle errors from fetchBridgeQuotes', async () => { + jest.useFakeTimers(); + await withController( + async ({ controller: bridgeController, rootMessenger }) => { + const fetchBridgeQuotesSpy = jest.spyOn( + fetchUtils, + 'fetchBridgeQuotes', + ); + messengerCallMock.mockReturnValue({ + address: '0x123', + provider: jest.fn(), + } as never); + + jest + .spyOn(balanceUtils, 'hasSufficientBalance') + .mockResolvedValue(true); + + const consoleLogSpy = jest + .spyOn(console, 'log') + .mockImplementationOnce(jest.fn()); + + // Fetch throws unknown Error + fetchBridgeQuotesSpy.mockImplementationOnce(async () => { + return await new Promise((_resolve, reject) => { + return setTimeout(() => { + reject(new Error('Other error')); + }, 1000); + }); + }); + + // Fetch succeeds + fetchBridgeQuotesSpy.mockImplementationOnce(async () => { + return await new Promise((resolve) => { + return setTimeout(() => { + resolve({ + quotes: mockBridgeQuotesNativeErc20EthV1, + validationFailures: [], + }); + }, 1000); + }); + }); + + // Fetch throws string error + fetchBridgeQuotesSpy.mockImplementationOnce(async () => { + return await new Promise((_resolve, reject) => { + return setTimeout(() => { + // eslint-disable-next-line @typescript-eslint/prefer-promise-reject-errors + reject('Test error'); + }, 1000); + }); + }); + + const quoteParams = { + srcChainId: '0xa', + destChainId: '0x1', + srcTokenAddress: '0x4200000000000000000000000000000000000006', + destTokenAddress: '0x0000000000000000000000000000000000000000', + srcTokenAmount: '991250000000000000', + walletAddress: 'eip:id/id:id/0x123', + }; + + await rootMessenger.call( + 'BridgeController:updateBridgeQuoteRequestParams', + quoteParams, + metricsContext, + ); + + // Advance timers to trigger fetch + jest.advanceTimersToNextTimer(); + await flushPromises(); + jest.advanceTimersToNextTimer(); + await flushPromises(); + // Verify state wasn't updated due to abort + expect(bridgeController.state.quoteFetchError).toBe('Other error'); + expect(bridgeController.state.quotesLoadingStatus).toBe( + RequestStatus.ERROR, + ); + expect(bridgeController.state.quotes).toStrictEqual([]); + + // Verify state is reset + rootMessenger.call('BridgeController:resetState'); + expect(bridgeController.state.quoteFetchError).toBeNull(); + expect(bridgeController.state.quotesLoadingStatus).toBeNull(); + expect(bridgeController.state.quotes).toStrictEqual([]); + + // Verify quotes are fetched + await rootMessenger.call( + 'BridgeController:updateBridgeQuoteRequestParams', + quoteParams, + metricsContext, + ); + + jest.advanceTimersToNextTimer(); + await flushPromises(); + jest.advanceTimersByTime(10000); + await flushPromises(); + const { quotes, quotesLastFetched, ...stateWithoutQuotes } = + bridgeController.state; + + expect(stateWithoutQuotes).toMatchSnapshot(); + expect(quotes).toStrictEqual(getMockBridgeQuotesNativeErc20EthV2()); + expect(quotesLastFetched).toBeCloseTo(Date.now() - 10000); + + jest.advanceTimersByTime(10000); + await flushPromises(); + const { + quotes: quotes2, + quotesLastFetched: quotesLastFetched2, + ...stateWithoutQuotes2 + } = bridgeController.state; + + expect(stateWithoutQuotes2).toMatchSnapshot(); + expect(quotes2).toStrictEqual(getMockBridgeQuotesNativeErc20EthV2()); + + expect(quotesLastFetched2).toBe(quotesLastFetched); + expect(consoleLogSpy).toHaveBeenCalledTimes(1); + expect(consoleLogSpy).toHaveBeenCalledWith( + 'Failed to fetch bridge quotes', + new Error('Other error'), + ); + }, + ); + }); + + it('returns early on AbortError without updating post-fetch state', async () => { + jest.useFakeTimers(); + await withController( + async ({ controller: bridgeController, rootMessenger }) => { + const abortError = new Error('Aborted'); + // Make it look like an AbortError to hit the early return + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + abortError.name = 'AbortError'; + + const fetchBridgeQuotesSpy = jest + .spyOn(fetchUtils, 'fetchBridgeQuotes') + .mockImplementationOnce( + async () => + await new Promise((_resolve, reject) => { + setTimeout(() => reject(abortError), 1000); + }), + ); + + // Minimal messenger/env setup to allow polling to start + messengerCallMock.mockReturnValue({ + address: '0x123', + provider: jest.fn(), + currencyRates: {}, + marketData: {}, + conversionRates: {}, + } as never); + + jest + .spyOn(balanceUtils, 'hasSufficientBalance') + .mockResolvedValue(true); + + const quoteParams = { + srcChainId: '0x1', + destChainId: '0xa', + srcTokenAddress: '0x0000000000000000000000000000000000000000', + destTokenAddress: '0x123', + srcTokenAmount: '1000000000000000000', + walletAddress: '0x123', + slippage: 0.5, + }; + + await rootMessenger.call( + 'BridgeController:updateBridgeQuoteRequestParams', + quoteParams, + metricsContext, + ); + + // Trigger the fetch + abort rejection + await jest.advanceTimersByTimeAsync(1000); + + // Early return path: no post-fetch updates + expect(fetchBridgeQuotesSpy).toHaveBeenCalledTimes(1); + expect(bridgeController.state.quoteFetchError).toBeNull(); + expect(bridgeController.state.quotesLoadingStatus).toBe( + RequestStatus.LOADING, + ); + expect(bridgeController.state.quotesLastFetched).toBeCloseTo( + Date.now() - 1000, + ); + expect(bridgeController.state.quotesRefreshCount).toBe(0); + expect(bridgeController.state.quotes).toStrictEqual([]); + }, + ); + }); + + it.each([ + [ + 'should append solanaFees for Solana quotes', + mockBridgeQuotesSolErc20V1, + [], + 2, + '0.000005000', // SOL amount (5000 lamports) + '300', + ], + [ + 'should not append solanaFees if selected account is not a snap', + mockBridgeQuotesSolErc20V1, + [], + 2, + undefined, + '0', + true, + ], + [ + 'should handle mixed Solana and non-Solana quotes by not appending fees', + [...mockBridgeQuotesSolErc20V1, ...mockBridgeQuotesErc20NativeV1], + [], + 8, + undefined, + '1', + ], + [ + 'should handle malformed quotes', + [...mockBridgeQuotesSolErc20V1, ...mockBridgeQuotesErc20NativeV1], + [ + 'socket|quote.srcAsset.decimals', + 'socket|quote.destAsset.address', + 'lifi|quote.srcAsset.decimals', + ], + 8, + undefined, + '1', + ], + ])( + 'updateBridgeQuoteRequestParams: %s', + async ( + _testTitle: string, + quoteResponse: QuoteResponseV1[], + validationFailures: string[], + expectedQuotesLength: number, + expectedFees: string | undefined, + expectedMinBalance: string | undefined, + isEvmAccount = false, + ) => { + jest.useFakeTimers(); + await withController( + async ({ controller: bridgeController, rootMessenger }) => { + const stopAllPollingSpy = jest.spyOn( + bridgeController, + 'stopAllPolling', + ); + const startPollingSpy = jest.spyOn(bridgeController, 'startPolling'); + const hasSufficientBalanceSpy = jest + .spyOn(balanceUtils, 'hasSufficientBalance') + .mockResolvedValue(false); + + messengerCallMock.mockImplementation( + ( + ...args: Parameters + ): ReturnType => { + const [actionType, params] = args; + + if (actionType === 'AuthenticationController:getBearerToken') { + return 'AUTH_TOKEN'; + } + + if (actionType === 'AccountsController:getAccountByAddress') { + if (isEvmAccount) { + return { + type: EthAccountType.Eoa, + id: 'account1', + scopes: [EthScope.Eoa], + methods: [], + address: '0x123', + metadata: { + name: 'Account 1', + importTime: 1717334400, + keyring: { + type: 'Keyring', + }, + }, + options: { + scope: 'mainnet', + }, + }; + } + return { + type: SolAccountType.DataAccount, + id: 'account1', + scopes: [SolScope.Mainnet], + methods: [], + address: '0x123', + metadata: { + name: 'Account 1', + importTime: 1717334400, + keyring: { + type: 'Keyring', + }, + snap: { + id: 'npm:@metamask/solana-snap', + }, + }, + options: { + scope: SolScope.Mainnet, + }, + }; + } + + if (actionType === 'SnapController:handleRequest') { + return new Promise((resolve) => { + if ( + (params as { handler: string })?.handler === + 'onProtocolRequest' + ) { + return setTimeout(() => { + resolve(expectedMinBalance); + }, 200); + } + if ( + (params as { handler: string })?.handler === + 'onClientRequest' && + (params as { request?: { method: string } })?.request + ?.method === 'computeFee' + ) { + return setTimeout(() => { + resolve([ + { + type: 'base', + asset: { + unit: 'SOL', + type: 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/token:11111111111111111111111111111111', + amount: expectedFees ?? '0', + fungible: true, + }, + }, + ]); + }, 100); + } + return setTimeout(() => { + resolve({ value: expectedFees }); + }, 100); + }); + } + return { + provider: jest.fn() as never, + } as never; + }, + ); + + const fetchBridgeQuotesSpy = jest + .spyOn(fetchUtils, 'fetchBridgeQuotes') + .mockImplementation(async () => { + return await new Promise((resolve) => { + return setTimeout(() => { + resolve({ + quotes: quoteResponse, + validationFailures, + }); + }, 1000); + }); + }); + + const quoteParams = { + srcChainId: SolScope.Mainnet, + destChainId: '1', + srcTokenAddress: 'NATIVE', + destTokenAddress: '0x0000000000000000000000000000000000000000', + srcTokenAmount: '1000000', + walletAddress: '0x123', + destWalletAddress: '0x5342', + slippage: 0.5, + }; + + await rootMessenger.call( + 'BridgeController:updateBridgeQuoteRequestParams', + quoteParams, + metricsContext, + ); + + expect(stopAllPollingSpy).toHaveBeenCalledTimes(1); + expect(startPollingSpy).toHaveBeenCalledTimes(1); + expect(hasSufficientBalanceSpy).not.toHaveBeenCalled(); + + // 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, + quotes: [], + minimumBalanceForRentExemptionInLamports: expectedMinBalance, + }), + ); + jest.advanceTimersByTime(295); + await flushPromises(); + expect(fetchBridgeQuotesSpy).toHaveBeenCalledTimes(1); + + // After fetch completes + jest.advanceTimersByTime(2601); + await flushPromises(); + + jest.advanceTimersByTime(100); + await flushPromises(); + const { quotes } = bridgeController.state; + expect(quotes).toHaveLength(expectedQuotesLength); + expect(bridgeController.state).toStrictEqual( + expect.objectContaining({ + quotesLoadingStatus: RequestStatus.FETCHED, + quotesRefreshCount: 1, + }), + ); + + // Verify non-EVM fees + quotes.forEach((quote) => { + expect(quote.nonEvmFeesInNative).toBe( + isSolanaChainId(quote.chainId) ? expectedFees : undefined, + ); + }); + + // Verify snap interaction + const snapCalls = messengerCallMock.mock.calls.filter( + ([methodName]) => methodName === 'SnapController:handleRequest', + ); + + expect(snapCalls).toMatchSnapshot(); + + // Verify validation failure tracking + expect(trackMetaMetricsFn).toHaveBeenCalledTimes( + 6 + (validationFailures.length ? 1 : 0), + ); + expect( + trackMetaMetricsFn.mock.calls.filter( + ([eventName]) => + eventName === UnifiedSwapBridgeEventName.QuotesValidationFailed, + ), + ).toMatchSnapshot(); + }, + ); + }, + ); + + it('should handle BTC chain fees correctly', async () => { + jest.useFakeTimers(); + await withController( + async ({ controller: bridgeController, rootMessenger }) => { + // Use the actual Solana mock which already has string trade type + const btcQuoteResponse = mockBridgeQuotesSolErc20V1.map((quote) => ({ + ...quote, + quote: { + ...quote.quote, + srcChainId: ChainId.BTC, + }, + })); + + messengerCallMock.mockImplementation( + ( + ...args: Parameters + ): ReturnType => { + const [actionType, params] = args; + + if (actionType === 'AccountsController:getAccountByAddress') { + return { + type: 'btc:p2wpkh', + id: 'btc-account-1', + scopes: [BtcScope.Mainnet], + methods: [], + address: 'bc1q...', + metadata: { + name: 'BTC Account 1', + importTime: 1717334400, + keyring: { + type: 'Snap Keyring', + }, + snap: { + id: 'btc-snap-id', + name: 'BTC Snap', + }, + }, + } as never; + } + + if (actionType === 'SnapController:handleRequest') { + return new Promise((resolve) => { + if ( + (params as { handler: string })?.handler === + 'onClientRequest' && + (params as { request?: { method: string } })?.request + ?.method === 'computeFee' + ) { + return setTimeout(() => { + resolve([ + { + type: 'priority', + asset: { + unit: 'BTC', + type: 'bip122:000000000019d6689c085ae165831e93/slip44:0', + amount: '0.00005', // BTC fee + fungible: true, + }, + }, + ]); + }, 100); + } + return setTimeout(() => { + resolve('5000'); + }, 200); + }); + } + + return { + provider: jest.fn() as never, + } as never; + }, + ); + + jest.spyOn(fetchUtils, 'fetchBridgeQuotes').mockResolvedValue({ + quotes: btcQuoteResponse, + validationFailures: [], + }); + + const quoteParams = { + srcChainId: ChainId.BTC.toString(), + destChainId: '1', + srcTokenAddress: 'NATIVE', + destTokenAddress: '0x0000000000000000000000000000000000000000', + srcTokenAmount: '100000', // satoshis + walletAddress: 'bc1q...', + destWalletAddress: '0x5342', + slippage: 0.5, + }; + + await rootMessenger.call( + 'BridgeController:updateBridgeQuoteRequestParams', + quoteParams, + metricsContext, + ); + + // Wait for polling to start + jest.advanceTimersByTime(201); + await flushPromises(); + + // Wait for fetch to trigger + jest.advanceTimersByTime(295); + await flushPromises(); + + // Wait for fetch to complete + jest.advanceTimersByTime(2601); + await flushPromises(); + + // Final wait for fee calculation + jest.advanceTimersByTime(100); + await flushPromises(); + + const { quotes } = bridgeController.state; + expect(quotes).toHaveLength(2); // mockBridgeQuotesSolErc20V1 has 2 quotes + expect(quotes[0].nonEvmFeesInNative).toBe('0.00005'); // BTC fee as-is + expect(quotes[1].nonEvmFeesInNative).toBe('0.00005'); // BTC fee as-is + }, + ); + }); + + it('should catch BTC chain fees errors and return undefined fees', async () => { + jest.useFakeTimers(); + await withController( + async ({ controller: bridgeController, rootMessenger }) => { + // Use the actual Solana mock which already has string trade type + const btcQuoteResponse = mockBridgeQuotesSolErc20V1.map((quote) => ({ + ...quote, + quoteId: quote.quote.requestId, + quote: { + ...quote.quote, + srcChainId: ChainId.BTC, + }, + })); + + const consoleErrorSpy = jest + .spyOn(console, 'error') + .mockImplementation(jest.fn()); + + messengerCallMock.mockImplementation( + ( + ...args: Parameters + ): ReturnType => { + const [actionType] = args; + + if (actionType === 'AccountsController:getAccountByAddress') { + return { + type: 'btc:p2wpkh', + id: 'btc-account-1', + scopes: [BtcScope.Mainnet], + methods: [], + address: 'bc1q...', + metadata: { + name: 'BTC Account 1', + importTime: 1717334400, + keyring: { + type: 'Snap Keyring', + }, + snap: { + id: 'btc-snap-id', + name: 'BTC Snap', + }, + }, + } as never; + } + + if (actionType === 'SnapController:handleRequest') { + return new Promise((_resolve, reject) => { + reject(new Error('Failed to compute fees')); + }); + } + + return { + provider: jest.fn() as never, + } as never; + }, + ); + + jest.spyOn(fetchUtils, 'fetchBridgeQuotes').mockResolvedValue({ + quotes: btcQuoteResponse, + validationFailures: [], + }); + + const quoteParams = { + srcChainId: ChainId.BTC.toString(), + destChainId: '1', + srcTokenAddress: 'NATIVE', + destTokenAddress: '0x0000000000000000000000000000000000000000', + srcTokenAmount: '100000', // satoshis + walletAddress: 'bc1q...', + destWalletAddress: '0x5342', + slippage: 0.5, + }; + + await rootMessenger.call( + 'BridgeController:updateBridgeQuoteRequestParams', + quoteParams, + metricsContext, + ); + + // Wait for polling to start + jest.advanceTimersByTime(201); + await flushPromises(); + + // Wait for fetch to trigger + jest.advanceTimersByTime(295); + await flushPromises(); + + // Wait for fetch to complete + jest.advanceTimersByTime(2601); + await flushPromises(); + + // Final wait for fee calculation + jest.advanceTimersByTime(100); + await flushPromises(); + + const { quotes } = bridgeController.state; + expect(quotes).toHaveLength(2); // mockBridgeQuotesSolErc20V1 has 2 quotes + expect(quotes[0].nonEvmFeesInNative).toBeUndefined(); + expect(quotes[1].nonEvmFeesInNative).toBeUndefined(); + expect(consoleErrorSpy).toHaveBeenCalledTimes(2); + expect(consoleErrorSpy).toHaveBeenCalledWith( + 'Failed to compute non-EVM fees for quote 5cb5a527-d4e4-4b5e-b753-136afc3986d3', + new Error('Failed to compute fees'), + ); + expect(consoleErrorSpy).toHaveBeenCalledWith( + 'Failed to compute non-EVM fees for quote 12c94d29-4b5c-4aee-92de-76eee4172d3d', + new Error('Failed to compute fees'), + ); + }, + ); + }); + + describe('getLocation and setLocation', () => { + it('returns Unknown by default and updates after setLocation', async () => { + await withController(async ({ rootMessenger }) => { + expect(rootMessenger.call('BridgeController:getLocation')).toBe( + MetaMetricsSwapsEventSource.Unknown, + ); + + rootMessenger.call( + 'BridgeController:setLocation', + MetaMetricsSwapsEventSource.TokenView, + ); + + expect(rootMessenger.call('BridgeController:getLocation')).toBe( + MetaMetricsSwapsEventSource.TokenView, + ); + + rootMessenger.call( + 'BridgeController:setLocation', + BatchSellMetricsLocation.AssetPicker, + ); + + expect(rootMessenger.call('BridgeController:getLocation')).toBe( + BatchSellMetricsLocation.AssetPicker, + ); + }); + }); + }); + + describe('trackUnifiedSwapBridgeEvent client-side calls', () => { + beforeEach(() => { + jest.clearAllMocks(); + messengerCallMock.mockImplementationOnce( + (): ReturnType => { + return { + provider: jest.fn() as never, + rpcUrl: 'https://mainnet.infura.io/v3/123', + configuration: { + chainId: 'eip155:1', + }, + } as never; + }, + ); + messengerCallMock.mockImplementationOnce( + (): ReturnType => { + return { + provider: jest.fn() as never, + rpcUrl: 'https://mainnet.infura.io/v3/123', + configuration: { + chainId: 'eip155:1', + }, + } as never; + }, + ); + messengerCallMock.mockImplementationOnce( + (): ReturnType => { + return { + type: EthAccountType.Eoa, + id: 'account1', + scopes: [EthScope.Eoa], + methods: [], + address: '0x123', + metadata: { + name: 'Account 1', + importTime: 1717334400, + keyring: { + type: 'Keyring', + }, + }, + options: { + scope: 'mainnet', + }, + } as never; + }, + ); + }); + + it('should track the ButtonClicked event', async () => { + await withController(async ({ rootMessenger }) => { + // Ignore console.warn for this test bc there will be expected asset rate fetching warnings + jest.spyOn(console, 'warn').mockImplementationOnce(jest.fn()); + // Add walletAddress to the quoteRequest because it's required for some events + await rootMessenger.call( + 'BridgeController:updateBridgeQuoteRequestParams', + { + walletAddress: '0x123', + }, + { + stx_enabled: false, + security_warnings: [], + token_symbol_source: 'ETH', + token_symbol_destination: 'USDC', + usd_amount_source: 100, + token_security_type_destination: null, + feature_id: FeatureId.QUICK_BUY_FOLLOW_TRADING, + }, + ); + jest.clearAllMocks(); + rootMessenger.call( + 'BridgeController:trackUnifiedSwapBridgeEvent', + UnifiedSwapBridgeEventName.ButtonClicked, + { + location: MetaMetricsSwapsEventSource.MainView, + token_symbol_source: 'ETH', + token_symbol_destination: null, + feature_id: FeatureId.QUICK_BUY_FOLLOW_TRADING, + }, + ); + expect(trackMetaMetricsFn).toHaveBeenCalledTimes(1); + + expect(trackMetaMetricsFn.mock.calls).toMatchSnapshot(); + }); + }); + + it('should track the PageViewed event', async () => { + await withController(async ({ rootMessenger }) => { + // Ignore console.warn for this test bc there will be expected asset rate fetching warnings + jest.spyOn(console, 'warn').mockImplementationOnce(jest.fn()); + // Add walletAddress to the quoteRequest because it's required for some events + await rootMessenger.call( + 'BridgeController:updateBridgeQuoteRequestParams', + { + walletAddress: '0x123', + }, + { + stx_enabled: false, + security_warnings: [], + token_symbol_source: 'ETH', + token_symbol_destination: 'USDC', + usd_amount_source: 100, + token_security_type_destination: null, + feature_id: FeatureId.QUICK_BUY_FOLLOW_TRADING, + }, + ); + jest.clearAllMocks(); + rootMessenger.call( + 'BridgeController:trackUnifiedSwapBridgeEvent', + UnifiedSwapBridgeEventName.PageViewed, + { feature_id: FeatureId.QUICK_BUY_TOKEN_DETAILS }, + ); + expect(trackMetaMetricsFn).toHaveBeenCalledTimes(1); + + expect(trackMetaMetricsFn.mock.calls).toMatchSnapshot(); + }); + }); + + it('should track Batch Sell token page events with client chain ids', async () => { + await withController(async ({ rootMessenger }) => { + const chainIdSource = formatChainIdToCaip(ChainId.POLYGON); + const chainIdDestination = formatChainIdToCaip(ChainId.BASE); + const sourceTokenAddresses = [ + 'eip155:137/erc20:0x1111111111111111111111111111111111111111', + 'eip155:137/erc20:0x2222222222222222222222222222222222222222', + ] satisfies CaipAssetType[]; + const sourceTokenSymbols = ['LINK', 'UNI']; + + rootMessenger.call( + 'BridgeController:trackUnifiedSwapBridgeEvent', + BatchSellMetricsEventName.BatchSellTokenPageViewed, + { + chain_id_source: chainIdSource, + chain_id_destination: chainIdDestination, + location: BatchSellMetricsLocation.TradeMenu, + }, + ); + rootMessenger.call( + 'BridgeController:trackUnifiedSwapBridgeEvent', + BatchSellMetricsEventName.BatchSellTokenPageContinueClicked, + { + chain_id_source: chainIdSource, + chain_id_destination: chainIdDestination, + location: BatchSellMetricsLocation.AssetPicker, + source_token_symbols: sourceTokenSymbols, + source_token_addresses: sourceTokenAddresses, + }, + ); + + expect(trackMetaMetricsFn).toHaveBeenNthCalledWith( + 1, + BatchSellMetricsEventName.BatchSellTokenPageViewed, + { + chain_id_source: chainIdSource, + chain_id_destination: chainIdDestination, + location: BatchSellMetricsLocation.TradeMenu, + }, + ); + expect(trackMetaMetricsFn).toHaveBeenNthCalledWith( + 2, + BatchSellMetricsEventName.BatchSellTokenPageContinueClicked, + { + chain_id_source: chainIdSource, + chain_id_destination: chainIdDestination, + location: BatchSellMetricsLocation.AssetPicker, + source_token_count: sourceTokenAddresses.length, + source_token_symbols: sourceTokenSymbols, + source_token_addresses: sourceTokenAddresses, + }, + ); + }); + }); + + it('should track Batch Sell quote page events with selected token metadata', async () => { + await withController(async ({ rootMessenger }) => { + await rootMessenger.call( + 'BridgeController:updateBridgeQuoteRequestParams', + { + walletAddress: '0x123', + srcChainId: ChainId.OPTIMISM, + destChainId: ChainId.OPTIMISM, + }, + { + stx_enabled: false, + security_warnings: [], + token_symbol_source: 'ETH', + token_symbol_destination: 'USDC', + usd_amount_source: 100, + token_security_type_destination: null, + feature_id: FeatureId.BATCH_SELL, + }, + ); + jest.clearAllMocks(); + + const chainIdSource = formatChainIdToCaip(ChainId.POLYGON); + const chainIdDestination = formatChainIdToCaip(ChainId.BASE); + const sourceTokenAddresses = [ + 'eip155:137/erc20:0x1111111111111111111111111111111111111111', + 'eip155:137/erc20:0x2222222222222222222222222222222222222222', + ] satisfies CaipAssetType[]; + const destinationTokenAddress = + 'eip155:8453/erc20:0x3333333333333333333333333333333333333333' satisfies CaipAssetType; + const sharedProperties = { + location: BatchSellMetricsLocation.Deeplink, + source_token_symbols: ['WETH', 'OP'], + source_token_addresses: sourceTokenAddresses, + destination_token_symbol: 'USDC', + destination_token_address: destinationTokenAddress, + usd_amount_source_tokens: [10, 20], + usd_amount_source_total: 30, + source_token_slippages: [0.5, 1], + }; + const properties = { + chain_id_source: chainIdSource, + chain_id_destination: chainIdDestination, + ...sharedProperties, + }; + const expectedProperties = { + source_token_count: sourceTokenAddresses.length, + ...properties, + }; + + rootMessenger.call( + 'BridgeController:trackUnifiedSwapBridgeEvent', + BatchSellMetricsEventName.BatchSellQuotePageViewed, + properties, + ); + rootMessenger.call( + 'BridgeController:trackUnifiedSwapBridgeEvent', + BatchSellMetricsEventName.BatchSellQuotePageReviewClicked, + properties, + ); + rootMessenger.call( + 'BridgeController:trackUnifiedSwapBridgeEvent', + BatchSellMetricsEventName.BatchSellReviewModalSubmitted, + { + ...properties, + usd_quoted_gas: 1, + usd_quoted_return: 29, + }, + ); + + expect(trackMetaMetricsFn).toHaveBeenNthCalledWith( + 1, + BatchSellMetricsEventName.BatchSellQuotePageViewed, + expectedProperties, + ); + expect(trackMetaMetricsFn).toHaveBeenNthCalledWith( + 2, + BatchSellMetricsEventName.BatchSellQuotePageReviewClicked, + expectedProperties, + ); + expect(trackMetaMetricsFn).toHaveBeenNthCalledWith( + 3, + BatchSellMetricsEventName.BatchSellReviewModalSubmitted, + { + ...expectedProperties, + usd_quoted_gas: 1, + usd_quoted_return: 29, + }, + ); + }); + }); + + it('should track the FiatCryptoToggleClicked event', async () => { + await withController(async ({ rootMessenger, controller }) => { + jest.spyOn(console, 'warn').mockImplementationOnce(jest.fn()); + await rootMessenger.call( + 'BridgeController:updateBridgeQuoteRequestParams', + { + walletAddress: '0x123', + }, + { + stx_enabled: false, + security_warnings: [], + token_symbol_source: 'ETH', + token_symbol_destination: 'USDC', + usd_amount_source: 100, + token_security_type_destination: null, + feature_id: FeatureId.QUICK_BUY_FOLLOW_TRADING, + }, + ); + rootMessenger.call( + 'BridgeController:setInputPrimaryDenomination', + 'fiat_value', + ); + expect(controller.state.inputPrimaryDenomination).toBe('fiat_value'); + jest.clearAllMocks(); + rootMessenger.call( + 'BridgeController:trackUnifiedSwapBridgeEvent', + UnifiedSwapBridgeEventName.FiatCryptoToggleClicked, + { + location: MetaMetricsSwapsEventSource.MainView, + previous_primary_denomination: 'token_amount', + new_primary_denomination: 'fiat_value', + token_symbol_source: 'ETH', + token_symbol_destination: 'USDC', + chain_id_source: formatChainIdToCaip(ChainId.ETH), + chain_id_destination: formatChainIdToCaip(ChainId.ETH), + token_address_source: formatAddressToAssetId('', ChainId.ETH), + token_address_destination: formatAddressToAssetId( + ETH_USDT_ADDRESS, + ChainId.ETH, + ), + token_security_type_destination: null, + swap_type: MetricsSwapType.SINGLE, + feature_id: FeatureId.QUICK_BUY_FOLLOW_TRADING, + }, + ); + expect(trackMetaMetricsFn).toHaveBeenCalledTimes(1); + expect(trackMetaMetricsFn.mock.calls).toMatchSnapshot(); + }); + }); + + it('should track InputChanged with an enum quick amount preset label', async () => { + await withController(async ({ rootMessenger }) => { + // Ignore console.warn for this test bc there will be expected asset rate fetching warnings + jest.spyOn(console, 'warn').mockImplementationOnce(jest.fn()); + // Add walletAddress to the quoteRequest because it's required for some events + await rootMessenger.call( + 'BridgeController:updateBridgeQuoteRequestParams', + { + walletAddress: '0x123', + }, + { + stx_enabled: false, + security_warnings: [], + token_symbol_source: 'ETH', + token_symbol_destination: 'USDC', + usd_amount_source: 100, + token_security_type_destination: null, + feature_id: FeatureId.QUICK_BUY_FOLLOW_TRADING, + }, + ); + jest.clearAllMocks(); + rootMessenger.call( + 'BridgeController:trackUnifiedSwapBridgeEvent', + UnifiedSwapBridgeEventName.InputChanged, + { + input: 'token_amount_source', + input_value: '1', + input_amount_preset: InputAmountPreset.PERCENT_90, + feature_id: FeatureId.QUICK_BUY_FOLLOW_TRADING, + }, + ); + + expect(trackMetaMetricsFn).toHaveBeenCalledTimes(1); + expect(trackMetaMetricsFn).toHaveBeenNthCalledWith( + 1, + UnifiedSwapBridgeEventName.InputChanged, + expect.objectContaining({ + input: 'token_amount_source', + input_value: '1', + input_amount_preset: InputAmountPreset.PERCENT_90, + feature_id: FeatureId.QUICK_BUY_FOLLOW_TRADING, + }), + ); + }); + }); + + it('should track InputChanged with arbitrary quick amount preset labels', async () => { + await withController(async ({ rootMessenger }) => { + // Ignore console.warn for this test bc there will be expected asset rate fetching warnings + jest.spyOn(console, 'warn').mockImplementationOnce(jest.fn()); + // Add walletAddress to the quoteRequest because it's required for some events + await rootMessenger.call( + 'BridgeController:updateBridgeQuoteRequestParams', + { + walletAddress: '0x123', + }, + { + stx_enabled: false, + security_warnings: [], + token_symbol_source: 'ETH', + token_symbol_destination: 'USDC', + usd_amount_source: 100, + token_security_type_destination: null, + feature_id: FeatureId.QUICK_BUY_FOLLOW_TRADING, + }, + ); + jest.clearAllMocks(); + rootMessenger.call( + 'BridgeController:trackUnifiedSwapBridgeEvent', + UnifiedSwapBridgeEventName.InputChanged, + { + input: 'token_amount_source', + input_value: '1', + input_amount_preset: '85%', + feature_id: FeatureId.QUICK_BUY_FOLLOW_TRADING, + }, + ); + rootMessenger.call( + 'BridgeController:trackUnifiedSwapBridgeEvent', + UnifiedSwapBridgeEventName.InputChanged, + { + input: 'token_amount_source', + input_value: '1', + input_amount_preset: '95%', + feature_id: FeatureId.UNIFIED_SWAP_BRIDGE, + }, + ); + + expect(trackMetaMetricsFn).toHaveBeenCalledTimes(2); + expect(trackMetaMetricsFn).toHaveBeenNthCalledWith( + 1, + UnifiedSwapBridgeEventName.InputChanged, + expect.objectContaining({ + input_amount_preset: '85%', + }), + ); + expect(trackMetaMetricsFn).toHaveBeenNthCalledWith( + 2, + UnifiedSwapBridgeEventName.InputChanged, + expect.objectContaining({ + input_amount_preset: '95%', + }), + ); + }); + }); + + it('should track the InputSourceDestinationFlipped event', async () => { + await withController(async ({ rootMessenger }) => { + // Ignore console.warn for this test bc there will be expected asset rate fetching warnings + jest.spyOn(console, 'warn').mockImplementationOnce(jest.fn()); + // Add walletAddress to the quoteRequest because it's required for some events + await rootMessenger.call( + 'BridgeController:updateBridgeQuoteRequestParams', + { + walletAddress: '0x123', + }, + { + stx_enabled: false, + security_warnings: [], + token_symbol_source: 'ETH', + token_symbol_destination: 'USDC', + usd_amount_source: 100, + token_security_type_destination: null, + feature_id: FeatureId.UNIFIED_SWAP_BRIDGE, + }, + ); + jest.clearAllMocks(); + rootMessenger.call( + 'BridgeController:trackUnifiedSwapBridgeEvent', + UnifiedSwapBridgeEventName.InputSourceDestinationSwitched, + { + token_symbol_destination: 'USDC', + token_symbol_source: 'ETH', + security_warnings: ['warning1'], + chain_id_source: formatChainIdToCaip(1), + token_address_source: getNativeAssetForChainId(1).assetId, + chain_id_destination: formatChainIdToCaip(10), + token_address_destination: getNativeAssetForChainId(10).assetId, + feature_id: FeatureId.UNIFIED_SWAP_BRIDGE, + }, + ); + expect(trackMetaMetricsFn).toHaveBeenCalledTimes(1); + + expect(trackMetaMetricsFn.mock.calls).toMatchSnapshot(); + }); + }); + + it('should track the AllQuotesOpened event', async () => { + await withController(async ({ rootMessenger }) => { + // Ignore console.warn for this test bc there will be expected asset rate fetching warnings + jest.spyOn(console, 'warn').mockImplementationOnce(jest.fn()); + // Add walletAddress to the quoteRequest because it's required for some events + await rootMessenger.call( + 'BridgeController:updateBridgeQuoteRequestParams', + { + walletAddress: '0x123', + }, + { + stx_enabled: false, + security_warnings: [], + token_symbol_source: 'ETH', + token_symbol_destination: 'USDC', + usd_amount_source: 100, + token_security_type_destination: null, + feature_id: FeatureId.UNIFIED_SWAP_BRIDGE, + }, + ); + jest.clearAllMocks(); + rootMessenger.call( + 'BridgeController:trackUnifiedSwapBridgeEvent', + UnifiedSwapBridgeEventName.AllQuotesOpened, + { + price_impact: 6, + token_symbol_source: 'ETH', + token_symbol_destination: 'USDC', + gas_included: false, + stx_enabled: false, + can_submit: true, + feature_id: FeatureId.UNIFIED_SWAP_BRIDGE, + }, + ); + expect(trackMetaMetricsFn).toHaveBeenCalledTimes(1); + + expect(trackMetaMetricsFn.mock.calls).toMatchSnapshot(); + }); + }); + + it('should track the AllQuotesSorted event', async () => { + await withController(async ({ rootMessenger }) => { + // Ignore console.warn for this test bc there will be expected asset rate fetching warnings + jest.spyOn(console, 'warn').mockImplementationOnce(jest.fn()); + // Add walletAddress to the quoteRequest because it's required for some events + await rootMessenger.call( + 'BridgeController:updateBridgeQuoteRequestParams', + { + walletAddress: '0x123', + }, + { + stx_enabled: false, + security_warnings: [], + token_symbol_source: 'ETH', + token_symbol_destination: 'USDC', + usd_amount_source: 100, + token_security_type_destination: null, + feature_id: FeatureId.UNIFIED_SWAP_BRIDGE, + }, + ); + jest.clearAllMocks(); + rootMessenger.call( + 'BridgeController:trackUnifiedSwapBridgeEvent', + UnifiedSwapBridgeEventName.AllQuotesSorted, + { + sort_order: SortOrder.COST_ASC, + price_impact: 6, + gas_included: false, + stx_enabled: false, + token_symbol_source: 'ETH', + best_quote_provider: 'provider_bridge2', + token_symbol_destination: 'USDC', + can_submit: true, + feature_id: FeatureId.UNIFIED_SWAP_BRIDGE, + }, + ); + expect(trackMetaMetricsFn).toHaveBeenCalledTimes(1); + + expect(trackMetaMetricsFn.mock.calls).toMatchSnapshot(); + }); + }); + + it('should track the QuoteSelected event', async () => { + await withController(async ({ rootMessenger }) => { + // Ignore console.warn for this test bc there will be expected asset rate fetching warnings + jest.spyOn(console, 'warn').mockImplementationOnce(jest.fn()); + // Add walletAddress to the quoteRequest because it's required for some events + await rootMessenger.call( + 'BridgeController:updateBridgeQuoteRequestParams', + { + walletAddress: '0x123', + }, + { + stx_enabled: false, + security_warnings: [], + token_symbol_source: 'ETH', + token_symbol_destination: 'USDC', + usd_amount_source: 100, + token_security_type_destination: null, + feature_id: FeatureId.UNIFIED_SWAP_BRIDGE, + }, + ); + jest.clearAllMocks(); + rootMessenger.call( + 'BridgeController:trackUnifiedSwapBridgeEvent', + UnifiedSwapBridgeEventName.QuoteSelected, + { + is_best_quote: true, + usd_quoted_gas: 0, + gas_included: false, + gas_included_7702: false, + quoted_time_minutes: 10, + usd_quoted_return: 100, + price_impact: 0, + provider: 'provider_bridge', + best_quote_provider: 'provider_bridge2', + can_submit: false, + feature_id: FeatureId.UNIFIED_SWAP_BRIDGE, + }, + ); + expect(trackMetaMetricsFn).toHaveBeenCalledTimes(1); + + expect(trackMetaMetricsFn.mock.calls).toMatchSnapshot(); + }); + }); + + it('should track the QuotesReceived event', async () => { + await withController(async ({ rootMessenger }) => { + // Ignore console.warn for this test bc there will be expected asset rate fetching warnings + jest.spyOn(console, 'warn').mockImplementationOnce(jest.fn()); + // Add walletAddress to the quoteRequest because it's required for some events + await rootMessenger.call( + 'BridgeController:updateBridgeQuoteRequestParams', + { + walletAddress: '0x123', + }, + { + stx_enabled: false, + security_warnings: [], + token_symbol_source: 'ETH', + token_symbol_destination: 'USDC', + usd_amount_source: 100, + token_security_type_destination: null, + feature_id: FeatureId.UNIFIED_SWAP_BRIDGE, + }, + ); + jest.clearAllMocks(); + rootMessenger.call( + 'BridgeController:trackUnifiedSwapBridgeEvent', + UnifiedSwapBridgeEventName.QuotesReceived, + { + warnings: ['insufficient_balance'], + usd_quoted_gas: 0, + gas_included: false, + gas_included_7702: false, + quoted_time_minutes: 10, + usd_quoted_return: 100, + price_impact: 0, + provider: 'provider_bridge', + best_quote_provider: 'provider_bridge2', + can_submit: true, + usd_balance_source: 0, + feature_id: FeatureId.UNIFIED_SWAP_BRIDGE, + }, + ); + expect(messengerCallMock.mock.calls).toMatchSnapshot(); + expect(trackMetaMetricsFn).toHaveBeenCalledTimes(1); + + expect(trackMetaMetricsFn.mock.calls).toMatchSnapshot(); + }); + }); + + it('includes tokenSecurityTypeDestination on the tracked event payload when set', async () => { + await withController(async ({ rootMessenger }) => { + jest.spyOn(console, 'warn').mockImplementationOnce(jest.fn()); + await rootMessenger.call( + 'BridgeController:updateBridgeQuoteRequestParams', + { walletAddress: '0x123' }, + { + stx_enabled: false, + security_warnings: [], + token_symbol_source: 'ETH', + token_symbol_destination: 'USDC', + usd_amount_source: 100, + token_security_type_destination: 'Malicious', + feature_id: FeatureId.UNIFIED_SWAP_BRIDGE, + }, + ); + jest.clearAllMocks(); + rootMessenger.call( + 'BridgeController:trackUnifiedSwapBridgeEvent', + UnifiedSwapBridgeEventName.QuotesReceived, + { + warnings: [], + usd_quoted_gas: 0, + gas_included: false, + gas_included_7702: false, + quoted_time_minutes: 10, + usd_quoted_return: 100, + price_impact: 0, + provider: 'provider_bridge', + best_quote_provider: 'provider_bridge2', + can_submit: true, + usd_balance_source: 0, + feature_id: FeatureId.UNIFIED_SWAP_BRIDGE, + }, + ); + expect(trackMetaMetricsFn).toHaveBeenCalledTimes(1); + expect(trackMetaMetricsFn.mock.calls[0][1]).toStrictEqual( + expect.objectContaining({ + token_security_type_destination: 'Malicious', + }), + ); + }); + }); + + it('should track the AssetDetailTooltipClicked event', async () => { + await withController(async ({ rootMessenger }) => { + // Ignore console.warn for this test bc there will be expected asset rate fetching warnings + jest.spyOn(console, 'warn').mockImplementationOnce(jest.fn()); + // Add walletAddress to the quoteRequest because it's required for some events + await rootMessenger.call( + 'BridgeController:updateBridgeQuoteRequestParams', + { + walletAddress: '0x123', + }, + { + stx_enabled: false, + security_warnings: [], + token_symbol_source: 'ETH', + token_symbol_destination: 'USDC', + usd_amount_source: 100, + token_security_type_destination: null, + feature_id: FeatureId.UNIFIED_SWAP_BRIDGE, + }, + ); + jest.clearAllMocks(); + rootMessenger.call( + 'BridgeController:trackUnifiedSwapBridgeEvent', + UnifiedSwapBridgeEventName.AssetDetailTooltipClicked, + { + token_name: 'ETH', + token_symbol: 'ETH', + token_contract: '0x123', + chain_name: 'Ethereum', + chain_id: '1', + feature_id: FeatureId.UNIFIED_SWAP_BRIDGE, + }, + ); + expect(trackMetaMetricsFn).toHaveBeenCalledTimes(1); + + expect(trackMetaMetricsFn.mock.calls).toMatchSnapshot(); + }); + }); + }); + + describe('trackUnifiedSwapBridgeEvent bridge-status-controller calls', () => { + beforeEach(() => { + jest.clearAllMocks(); + + jest.restoreAllMocks(); + messengerCallMock.mockImplementation(() => { + return { + provider: jest.fn() as never, + rpcUrl: 'https://mainnet.infura.io/v3/123', + configuration: { + chainId: 'eip155:1', + }, + } as never; + }); + }); + + it('should track the Submitted event', async () => { + await withController( + { options: { clientVersion: '1.0.0', state: { ...EMPTY_INIT_STATE } } }, + async ({ rootMessenger: localRootMessenger }) => { + localRootMessenger.call( + 'BridgeController:trackUnifiedSwapBridgeEvent', + UnifiedSwapBridgeEventName.Submitted, + { + action_type: MetricsActionType.SWAPBRIDGE_V1, + swap_type: MetricsSwapType.CROSSCHAIN, + chain_id_source: formatChainIdToCaip(ChainId.SOLANA), + chain_id_destination: formatChainIdToCaip(1), + custom_slippage: false, + is_hardware_wallet: false, + account_hardware_type: null, + slippage_limit: 0.5, + usd_quoted_gas: 1, + gas_included: false, + gas_included_7702: false, + quoted_time_minutes: 2, + usd_quoted_return: 113, + provider: 'provider_bridge', + price_impact: 12, + token_symbol_source: 'ETH', + token_symbol_destination: 'USDC', + stx_enabled: false, + usd_amount_source: 100, + feature_id: FeatureId.UNIFIED_SWAP_BRIDGE, + }, + ); + expect(trackMetaMetricsFn).toHaveBeenCalledTimes(1); + + expect(trackMetaMetricsFn.mock.calls).toMatchSnapshot(); + }, + ); + }); + + it('should track the Completed event', async () => { + await withController(async ({ rootMessenger }) => { + rootMessenger.call( + 'BridgeController:trackUnifiedSwapBridgeEvent', + UnifiedSwapBridgeEventName.Completed, + { + action_type: MetricsActionType.SWAPBRIDGE_V1, + approval_transaction: StatusTypes.PENDING, + source_transaction: StatusTypes.PENDING, + destination_transaction: StatusTypes.PENDING, + actual_time_minutes: 10, + usd_actual_return: 100, + usd_actual_gas: 10, + quote_vs_execution_ratio: 1, + quoted_vs_used_gas_ratio: 1, + transaction_internal_id: 'transaction-id', + chain_id_source: formatChainIdToCaip(1), + token_symbol_source: 'ETH', + token_address_source: getNativeAssetForChainId(1).assetId, + custom_slippage: true, + usd_amount_source: 100, + stx_enabled: false, + is_hardware_wallet: false, + account_hardware_type: null, + swap_type: MetricsSwapType.CROSSCHAIN, + provider: 'provider_bridge', + price_impact: 6, + gas_included: false, + gas_included_7702: false, + usd_quoted_gas: 0, + quoted_time_minutes: 0, + usd_quoted_return: 0, + chain_id_destination: formatChainIdToCaip(10), + token_symbol_destination: 'USDC', + token_address_destination: getNativeAssetForChainId(10).assetId, + feature_id: FeatureId.UNIFIED_SWAP_BRIDGE, + }, + ); + expect(trackMetaMetricsFn).toHaveBeenCalledTimes(1); + + expect(trackMetaMetricsFn.mock.calls).toMatchSnapshot(); + }); + }); + + it('should track the Failed event', async () => { + await withController(async ({ rootMessenger }) => { + rootMessenger.call( + 'BridgeController:trackUnifiedSwapBridgeEvent', + UnifiedSwapBridgeEventName.Failed, + { + allowance_reset_transaction: StatusTypes.PENDING, + approval_transaction: StatusTypes.PENDING, + source_transaction: StatusTypes.PENDING, + destination_transaction: StatusTypes.PENDING, + usd_quoted_gas: 0, + gas_included: false, + gas_included_7702: false, + quoted_time_minutes: 0, + usd_quoted_return: 0, + price_impact: 0, + provider: 'provider_bridge', + actual_time_minutes: 10, + error_message: 'error_message', + chain_id_source: formatChainIdToCaip(1), + token_symbol_source: 'ETH', + token_address_source: getNativeAssetForChainId(1).assetId, + custom_slippage: true, + usd_amount_source: 100, + stx_enabled: false, + is_hardware_wallet: false, + account_hardware_type: null, + swap_type: MetricsSwapType.CROSSCHAIN, + chain_id_destination: formatChainIdToCaip(ChainId.SOLANA), + token_symbol_destination: 'USDC', + token_address_destination: getNativeAssetForChainId(ChainId.SOLANA) + .assetId, + security_warnings: [], + feature_id: FeatureId.UNIFIED_SWAP_BRIDGE, + }, + ); + expect(messengerCallMock).toHaveBeenCalledTimes(0); + expect(trackMetaMetricsFn).toHaveBeenCalledTimes(1); + + expect(trackMetaMetricsFn.mock.calls).toMatchSnapshot(); + }); + }); + + it('should track the Failed event before tx is submitted', async () => { + await withController( + { + options: { + clientVersion: '1.0.0', + state: { + quoteRequest: [ + { + srcChainId: SolScope.Mainnet, + destChainId: '1', + srcTokenAddress: 'NATIVE', + destTokenAddress: '0x1234', + srcTokenAmount: '1000000', + walletAddress: '0x123', + slippage: 0.5, + }, + ], + quotes: getMockBridgeQuotesSolErc20V2(), + }, + }, + }, + async ({ rootMessenger: localRootMessenger }) => { + localRootMessenger.call( + 'BridgeController:trackUnifiedSwapBridgeEvent', + UnifiedSwapBridgeEventName.Failed, + { + error_message: 'Failed to submit tx', + is_hardware_wallet: false, + usd_quoted_gas: 1, + gas_included: false, + gas_included_7702: false, + quoted_time_minutes: 2, + usd_quoted_return: 113, + provider: 'provider_bridge', + price_impact: 12, + token_symbol_source: 'ETH', + token_symbol_destination: 'USDC', + stx_enabled: false, + usd_amount_source: 100, + feature_id: FeatureId.UNIFIED_SWAP_BRIDGE, + }, + ); + expect(trackMetaMetricsFn).toHaveBeenCalledTimes(1); + + expect(trackMetaMetricsFn.mock.calls).toMatchSnapshot(); + }, + ); + }); + + it('should track the StatusValidationFailed event', async () => { + await withController( + { + options: { + clientVersion: '1.0.0', + state: { + quoteRequest: [ + { + srcChainId: SolScope.Mainnet, + destChainId: '1', + srcTokenAddress: 'NATIVE', + destTokenAddress: '0x1234', + srcTokenAmount: '1000000', + walletAddress: '0x123', + slippage: 0.5, + }, + ], + quotes: getMockBridgeQuotesSolErc20V2(), + }, + }, + }, + async ({ rootMessenger: localRootMessenger }) => { + localRootMessenger.call( + 'BridgeController:trackUnifiedSwapBridgeEvent', + UnifiedSwapBridgeEventName.StatusValidationFailed, + { + failures: ['Failed to submit tx'], + refresh_count: 0, + feature_id: FeatureId.PERPS, + }, + ); + expect(trackMetaMetricsFn).toHaveBeenCalledTimes(1); + + expect(trackMetaMetricsFn.mock.calls).toMatchSnapshot(); + }, + ); + }); + }); + + describe('trackUnifiedSwapBridgeEvent client-side call exceptions', () => { + beforeEach(() => { + jest.clearAllMocks(); + messengerCallMock.mockImplementation( + ( + ...args: Parameters + ): ReturnType => { + const actionType = args[0]; + if (actionType === 'AccountsController:getAccountByAddress') { + return { + type: SolAccountType.DataAccount, + id: 'account1', + scopes: [SolScope.Mainnet], + methods: [], + address: '0x123', + metadata: { + snap: { + id: 'npm:@metamask/solana-snap', + name: 'Solana Snap', + enabled: true, + }, + name: 'Account 1', + importTime: 1717334400, + } as never, + options: { + scope: 'mainnet', + }, + }; + } + return { + provider: jest.fn() as never, + rpcUrl: 'https://mainnet.infura.io/v3/123', + configuration: { + chainId: 'eip155:1', + }, + } as never; + }, + ); + }); + + it('should not track the event if the account keyring type is not set', async () => { + await withController(async ({ rootMessenger }) => { + rootMessenger.call( + 'BridgeController:setLocation', + MetaMetricsSwapsEventSource.TrendingExplore, + ); + const errorSpy = jest + .spyOn(console, 'error') + .mockImplementationOnce(jest.fn()); + await rootMessenger.call( + 'BridgeController:updateBridgeQuoteRequestParams', + { + walletAddress: '0x123', + }, + { + stx_enabled: false, + security_warnings: [], + token_symbol_source: 'ETH', + usd_amount_source: 100, + token_symbol_destination: 'USDC', + token_security_type_destination: null, + feature_id: FeatureId.UNIFIED_SWAP_BRIDGE, + }, + ); + rootMessenger.call( + 'BridgeController:trackUnifiedSwapBridgeEvent', + UnifiedSwapBridgeEventName.QuotesReceived, + { + warnings: ['low_return'], + usd_quoted_gas: 0, + gas_included: false, + gas_included_7702: false, + quoted_time_minutes: 10, + usd_quoted_return: 100, + price_impact: 0, + provider: 'provider_bridge', + best_quote_provider: 'provider_bridge2', + can_submit: true, + usd_balance_source: 0, + feature_id: FeatureId.UNIFIED_SWAP_BRIDGE, + }, + ); + expect(trackMetaMetricsFn).toHaveBeenCalledTimes(0); + expect(errorSpy).toHaveBeenCalledTimes(1); + expect(errorSpy).toHaveBeenCalledWith( + 'Error tracking cross-chain swaps MetaMetrics event Unified SwapBridge Quotes Received', + new TypeError("Cannot read properties of undefined (reading 'type')"), + ); + }); + }); + }); + + describe('fetchQuotes', () => { + const defaultFlags = { + minimumVersion: '0.0.0', + maxRefreshCount: 3, + refreshRate: 3, + support: true, + chains: { + '10': { isActiveSrc: true, isActiveDest: false }, + '534352': { isActiveSrc: true, isActiveDest: false }, + '137': { isActiveSrc: false, isActiveDest: true }, + '42161': { isActiveSrc: false, isActiveDest: true }, + [ChainId.SOLANA]: { + isActiveSrc: true, + isActiveDest: true, + }, + }, + sse: { + enabled: true, + minimumVersion: '13.8.0', + }, + chainRanking: [{ chainId: 'eip155:1' as const, name: 'Ethereum' }], + }; + + const quotesByDecreasingProcessingTime = [...mockBridgeQuotesSolErc20V1]; + quotesByDecreasingProcessingTime.reverse(); + + const makeQuoteRequest = ( + overrides: Partial = {}, + ): GenericQuoteRequest => ({ + walletAddress: '0x123', + srcChainId: 1, + destChainId: 10, + srcTokenAddress: '0x0000000000000000000000000000000000000000', + destTokenAddress: '0x0000000000000000000000000000000000000000', + srcTokenAmount: '1000', + slippage: 0.5, + gasIncluded: false, + gasIncluded7702: false, + ...overrides, + }); + + beforeEach(() => { + jest.clearAllMocks(); + jest + .spyOn(featureFlagUtils, 'getBridgeFeatureFlags') + .mockReturnValueOnce({ + ...defaultFlags, + quoteRequestOverrides: { + [FeatureId.PERPS]: { + aggIds: ['debridge', 'socket'], + bridgeIds: ['bridge1', 'bridge2'], + fee: 0, + }, + [FeatureId.QUICK_BUY_FOLLOW_TRADING]: undefined, + [FeatureId.QUICK_BUY_TOKEN_DETAILS]: undefined, + [FeatureId.BATCH_SELL]: undefined, + [FeatureId.UNIFIED_SWAP_BRIDGE]: undefined, + [FeatureId.DAPP_SWAP]: undefined, + }, + }); + messengerCallMock.mockResolvedValueOnce('AUTH_TOKEN'); + messengerCallMock.mockResolvedValueOnce('AUTH_TOKEN'); + messengerCallMock.mockReturnValueOnce(() => ({ + address: '0x123', + })); + }); + + it('should override aggIds and fee in perps request', async () => { + await withController( + async ({ controller: bridgeController, rootMessenger }) => { + const fetchBridgeQuotesSpy = jest + .spyOn(fetchUtils, 'fetchBridgeQuotes') + .mockResolvedValueOnce({ + quotes: quotesByDecreasingProcessingTime, + validationFailures: [], + }); + const expectedControllerState = bridgeController.state; + + const quotes = await rootMessenger.call( + 'BridgeController:fetchQuotes', + { + srcChainId: SolScope.Mainnet, + destChainId: '1', + srcTokenAddress: 'NATIVE', + destTokenAddress: '0x1234', + srcTokenAmount: '1000000', + walletAddress: '0x123', + slippage: 0.5, + aggIds: ['other'], + bridgeIds: ['other', 'debridge'], + gasIncluded: false, + gasIncluded7702: false, + fee: 0, + }, + FeatureId.PERPS, + null, + ); + + expect(fetchBridgeQuotesSpy).toHaveBeenCalledTimes(1); + expect(fetchBridgeQuotesSpy.mock.calls).toMatchInlineSnapshot(` + [ + [ + { + "aggIds": [ + "debridge", + "socket", + ], + "bridgeIds": [ + "bridge1", + "bridge2", + ], + "destChainId": "1", + "destTokenAddress": "0x1234", + "fee": 0, + "gasIncluded": false, + "gasIncluded7702": false, + "resetApproval": false, + "slippage": 0.5, + "srcChainId": "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp", + "srcTokenAddress": "NATIVE", + "srcTokenAmount": "1000000", + "walletAddress": "0x123", + }, + null, + "extension", + "AUTH_TOKEN", + [Function], + "https://bridge.api.cx.metamask.io", + "perps", + "13.7.0", + ], + ] + `); + expect(quotes).toStrictEqual(mockBridgeQuotesSolErc20V1); + expect(bridgeController.state).toStrictEqual(expectedControllerState); + }, + ); + }); + + it('should throw error if account is not found', async () => { + await withController( + async ({ controller: bridgeController, rootMessenger }) => { + const fetchBridgeQuotesSpy = jest + .spyOn(fetchUtils, 'fetchBridgeQuotes') + .mockResolvedValueOnce({ + quotes: quotesByDecreasingProcessingTime, + validationFailures: [], + }); + const expectedControllerState = bridgeController.state; + + await expect( + rootMessenger.call( + 'BridgeController:fetchQuotes', + { + srcChainId: SolScope.Mainnet, + destChainId: '1', + srcTokenAddress: 'NATIVE', + destTokenAddress: '0x1234', + srcTokenAmount: '1000000', + slippage: 0.5, + aggIds: ['other'], + bridgeIds: ['other', 'debridge'], + gasIncluded: false, + gasIncluded7702: false, + walletAddress: undefined as never, + }, + FeatureId.PERPS, + null, + ), + ).rejects.toThrow('Account address is required'); + + expect(fetchBridgeQuotesSpy).toHaveBeenCalledTimes(1); + expect(bridgeController.state).toStrictEqual(expectedControllerState); + }, + ); + }); + + it('should add aggIds and fee to perps request', async () => { + await withController( + async ({ controller: bridgeController, rootMessenger }) => { + const fetchBridgeQuotesSpy = jest + .spyOn(fetchUtils, 'fetchBridgeQuotes') + .mockResolvedValueOnce({ + quotes: quotesByDecreasingProcessingTime, + validationFailures: [], + }); + const expectedControllerState = bridgeController.state; + + const quotes = await rootMessenger.call( + 'BridgeController:fetchQuotes', + { + srcChainId: SolScope.Mainnet, + destChainId: '1', + srcTokenAddress: 'NATIVE', + destTokenAddress: '0x1234', + srcTokenAmount: '1000000', + walletAddress: '0x123', + slippage: 0.5, + gasIncluded: false, + gasIncluded7702: false, + }, + FeatureId.PERPS, + null, + ); + + expect(fetchBridgeQuotesSpy).toHaveBeenCalledTimes(1); + expect(fetchBridgeQuotesSpy.mock.calls).toMatchInlineSnapshot(` + [ + [ + { + "aggIds": [ + "debridge", + "socket", + ], + "bridgeIds": [ + "bridge1", + "bridge2", + ], + "destChainId": "1", + "destTokenAddress": "0x1234", + "fee": 0, + "gasIncluded": false, + "gasIncluded7702": false, + "resetApproval": false, + "slippage": 0.5, + "srcChainId": "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp", + "srcTokenAddress": "NATIVE", + "srcTokenAmount": "1000000", + "walletAddress": "0x123", + }, + null, + "extension", + "AUTH_TOKEN", + [Function], + "https://bridge.api.cx.metamask.io", + "perps", + "13.7.0", + ], + ] + `); + expect(quotes).toStrictEqual(mockBridgeQuotesSolErc20V1); + expect(bridgeController.state).toStrictEqual(expectedControllerState); + }, + ); + }); + + it('should not add aggIds and fee if featureId is not specified', async () => { + await withController( + async ({ controller: bridgeController, rootMessenger }) => { + const fetchBridgeQuotesSpy = jest + .spyOn(fetchUtils, 'fetchBridgeQuotes') + .mockResolvedValueOnce({ + quotes: mockBridgeQuotesSolErc20V1, + validationFailures: [], + }); + const expectedControllerState = bridgeController.state; + + const quotes = await rootMessenger.call( + 'BridgeController:fetchQuotes', + { + srcChainId: SolScope.Mainnet, + destChainId: '1', + srcTokenAddress: 'NATIVE', + destTokenAddress: '0x1234', + srcTokenAmount: '1000000', + walletAddress: '0x123', + slippage: 0.5, + gasIncluded: false, + gasIncluded7702: false, + }, + FeatureId.UNIFIED_SWAP_BRIDGE, + null, + ); + + expect(fetchBridgeQuotesSpy).toHaveBeenCalledTimes(1); + expect(fetchBridgeQuotesSpy.mock.calls).toMatchInlineSnapshot(` + [ + [ + { + "destChainId": "1", + "destTokenAddress": "0x1234", + "gasIncluded": false, + "gasIncluded7702": false, + "resetApproval": false, + "slippage": 0.5, + "srcChainId": "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp", + "srcTokenAddress": "NATIVE", + "srcTokenAmount": "1000000", + "walletAddress": "0x123", + }, + null, + "extension", + "AUTH_TOKEN", + [Function], + "https://bridge.api.cx.metamask.io", + "unified_swap_bridge", + "13.7.0", + ], + ] + `); + expect(quotes).toStrictEqual(mockBridgeQuotesSolErc20V1); + expect(bridgeController.state).toStrictEqual(expectedControllerState); + }, + ); + }); + + it('should not add aggIds and fee if quoteRequestOverrides is not set', async () => { + await withController( + async ({ controller: bridgeController, rootMessenger }) => { + const getBridgeFeatureFlagsSpy = jest.spyOn( + featureFlagUtils, + 'getBridgeFeatureFlags', + ); + getBridgeFeatureFlagsSpy.mockRestore(); + getBridgeFeatureFlagsSpy.mockReturnValueOnce({ + ...defaultFlags, + quoteRequestOverrides: undefined, + }); + + const fetchBridgeQuotesSpy = jest + .spyOn(fetchUtils, 'fetchBridgeQuotes') + .mockResolvedValueOnce({ + quotes: mockBridgeQuotesSolErc20V1, + validationFailures: [], + }); + const expectedControllerState = bridgeController.state; + + const quotes = await rootMessenger.call( + 'BridgeController:fetchQuotes', + { + srcChainId: SolScope.Mainnet, + destChainId: '1', + srcTokenAddress: 'NATIVE', + destTokenAddress: '0x1234', + srcTokenAmount: '1000000', + walletAddress: '0x123', + slippage: 0.5, + gasIncluded: false, + gasIncluded7702: false, + }, + FeatureId.PERPS, + null, + ); + + expect(fetchBridgeQuotesSpy).toHaveBeenCalledTimes(1); + expect(fetchBridgeQuotesSpy.mock.calls).toMatchInlineSnapshot(` + [ + [ + { + "destChainId": "1", + "destTokenAddress": "0x1234", + "gasIncluded": false, + "gasIncluded7702": false, + "resetApproval": false, + "slippage": 0.5, + "srcChainId": "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp", + "srcTokenAddress": "NATIVE", + "srcTokenAmount": "1000000", + "walletAddress": "0x123", + }, + null, + "extension", + "AUTH_TOKEN", + [Function], + "https://bridge.api.cx.metamask.io", + "perps", + "13.7.0", + ], + ] + `); + expect(quotes).toStrictEqual(mockBridgeQuotesSolErc20V1); + expect(bridgeController.state).toStrictEqual(expectedControllerState); + }, + ); + }); + + it('should preserve gasSponsored flag on quotes', async () => { + await withController(async ({ rootMessenger }) => { + const firstQuoteWithFlag: QuoteResponseV1 = { + ...mockBridgeQuotesNativeErc20EthV1[0], + quote: { + ...mockBridgeQuotesNativeErc20EthV1[0].quote, + gasSponsored: true, + }, + }; + const secondQuote: QuoteResponseV1 = + mockBridgeQuotesNativeErc20EthV1[1]; + const quotesWithFlag: QuoteResponseV1[] = [ + firstQuoteWithFlag, + secondQuote, + ]; + + const fetchBridgeQuotesSpy = jest + .spyOn(fetchUtils, 'fetchBridgeQuotes') + .mockResolvedValueOnce({ + quotes: quotesWithFlag, + validationFailures: [], + }); + + const quotes = await rootMessenger.call( + 'BridgeController:fetchQuotes', + makeQuoteRequest(), + FeatureId.UNIFIED_SWAP_BRIDGE, + ); + + expect(fetchBridgeQuotesSpy).toHaveBeenCalledTimes(1); + expect(quotes).toHaveLength(2); + expect(quotes[0].quote.gasSponsored).toBe(true); + expect(quotes[1].quote.gasSponsored).toBeUndefined(); + }); + }); + }); + + describe('updateBatchSellTrades', () => { + const mockBatchSellTradesResponse = { + transactions: [ + { + chainId: 1, + to: '0xabc' as `0x${string}`, + from: '0x123' as `0x${string}`, + value: '0x0' as `0x${string}`, + data: '0x' as `0x${string}`, + gasLimit: null, + maxFeePerGas: '0x1' as `0x${string}`, + maxPriorityFeePerGas: '0x1' as `0x${string}`, + type: 'trade' as const, + }, + ], + }; + + const mockQuote = getMockBridgeQuotesNativeErc20EthV2()[0]; + + beforeEach(() => { + jest.clearAllMocks(); + messengerCallMock.mockImplementation((actionType: string) => { + if (actionType === 'AuthenticationController:getBearerToken') { + return Promise.resolve('AUTH_TOKEN'); + } + return undefined; + }); + }); + + it('sets loading status, fetches trades, and updates state on success', async () => { + await withController(async ({ rootMessenger, controller }) => { + const fetchBatchSellTradesSpy = jest + .spyOn(fetchUtils, 'fetchBatchSellTrades') + .mockResolvedValueOnce(mockBatchSellTradesResponse as never); + + await rootMessenger.call( + 'BridgeController:updateBatchSellTrades', + [mockQuote], + true, + ); + + expect(fetchBatchSellTradesSpy).toHaveBeenCalledTimes(1); + expect(fetchBatchSellTradesSpy).toHaveBeenCalledWith( + [mockQuote], + true, + expect.any(AbortSignal), + BridgeClientId.EXTENSION, + 'AUTH_TOKEN', + mockFetchFn, + BRIDGE_PROD_API_BASE_URL, + '13.7.0', + ); + expect(controller.state.batchSellTrades).toStrictEqual( + mockBatchSellTradesResponse, + ); + expect(controller.state.batchSellTradesLoadingStatus).toBe( + RequestStatus.FETCHED, + ); + }); + }); + + it('filters out null quotes before sending the request', async () => { + await withController(async ({ rootMessenger }) => { + const fetchBatchSellTradesSpy = jest + .spyOn(fetchUtils, 'fetchBatchSellTrades') + .mockResolvedValueOnce(mockBatchSellTradesResponse as never); + + await rootMessenger.call( + 'BridgeController:updateBatchSellTrades', + [null, mockQuote, null], + false, + ); + + expect(fetchBatchSellTradesSpy).toHaveBeenCalledWith( + [null, mockQuote, null], + false, + expect.any(AbortSignal), + BridgeClientId.EXTENSION, + 'AUTH_TOKEN', + mockFetchFn, + BRIDGE_PROD_API_BASE_URL, + '13.7.0', + ); + }); + }); + + it('sets ERROR status and resets batchSellTrades on non-abort error', async () => { + await withController(async ({ rootMessenger, controller }) => { + const fetchError = new Error('Network failure'); + jest + .spyOn(fetchUtils, 'fetchBatchSellTrades') + .mockRejectedValueOnce(fetchError); + + await rootMessenger.call( + 'BridgeController:updateBatchSellTrades', + [mockQuote], + false, + ); + + expect(controller.state.batchSellTrades).toBeNull(); + expect(controller.state.batchSellTradesLoadingStatus).toBe( + RequestStatus.ERROR, + ); + }); + }); + + it('ignores AbortError and leaves state unchanged', async () => { + await withController(async ({ rootMessenger, controller }) => { + const abortError = new Error('AbortError: The operation was aborted'); + abortError.name = 'AbortError'; + jest + .spyOn(fetchUtils, 'fetchBatchSellTrades') + .mockRejectedValueOnce(abortError); + + await rootMessenger.call( + 'BridgeController:updateBatchSellTrades', + [mockQuote], + false, + ); + + // State should remain in its initial form — no ERROR status written + expect(controller.state.batchSellTrades).toBeNull(); + expect(controller.state.batchSellTradesLoadingStatus).toBe( + RequestStatus.LOADING, + ); + }); + }); + + it('aborts the previous call when called again before it resolves', async () => { + await withController(async ({ rootMessenger, controller }) => { + let resolveFirst!: (value: unknown) => void; + const firstCallPromise = new Promise((resolve) => { + resolveFirst = resolve; + }); + + const fetchBatchSellTradesSpy = jest + .spyOn(fetchUtils, 'fetchBatchSellTrades') + // First call hangs until we resolve manually + .mockImplementationOnce(() => firstCallPromise as never) + // Second call resolves immediately + .mockResolvedValueOnce(mockBatchSellTradesResponse as never); + + // Start first call (do not await yet) + const firstCall = rootMessenger.call( + 'BridgeController:updateBatchSellTrades', + [mockQuote], + false, + ); + + // Start second call while first is still pending — this should abort the first + const secondCall = rootMessenger.call( + 'BridgeController:updateBatchSellTrades', + [mockQuote], + true, + ); + + // Let the first call resolve (the abort has already been fired) + resolveFirst(mockBatchSellTradesResponse); + + await Promise.all([firstCall, secondCall]); + + expect(fetchBatchSellTradesSpy).toHaveBeenCalledTimes(2); + // Final state reflects the second (successful) call + expect(controller.state.batchSellTrades).toStrictEqual( + mockBatchSellTradesResponse, + ); + expect(controller.state.batchSellTradesLoadingStatus).toBe( + RequestStatus.FETCHED, + ); + }); + }); + }); + + describe('metadata', () => { + it('includes expected state in debug snapshots', async () => { + await withController(async ({ controller: bridgeController }) => { + expect( + deriveStateFromMetadata( + bridgeController.state, + bridgeController.metadata, + 'includeInDebugSnapshot', + ), + ).toMatchInlineSnapshot(`{}`); + }); + }); + + it('includes expected state in state logs', async () => { + await withController(async ({ controller: bridgeController }) => { + expect( + deriveStateFromMetadata( + bridgeController.state, + bridgeController.metadata, + 'includeInStateLogs', + ), + ).toMatchInlineSnapshot(` + { + "assetExchangeRates": {}, + "batchSellTrades": null, + "batchSellTradesLoadingStatus": null, + "inputPrimaryDenomination": "token_amount", + "minimumBalanceForRentExemptionInLamports": "0", + "quoteFetchError": null, + "quoteRequest": [ + { + "srcTokenAddress": "0x0000000000000000000000000000000000000000", + }, + ], + "quoteStreamComplete": null, + "quotes": [], + "quotesInitialLoadTime": null, + "quotesLastFetched": null, + "quotesLoadingStatus": null, + "quotesRefreshCount": 0, + "tokenSecurityTypeDestination": null, + "tokenWarnings": [], + } + `); + }); + }); + + it('persists expected state', async () => { + await withController(async ({ controller: bridgeController }) => { + expect( + deriveStateFromMetadata( + bridgeController.state, + bridgeController.metadata, + 'persist', + ), + ).toMatchInlineSnapshot(` + { + "inputPrimaryDenomination": "token_amount", + } + `); + }); + }); + + it('exposes expected state to UI', async () => { + await withController(async ({ controller: bridgeController }) => { + expect( + deriveStateFromMetadata( + bridgeController.state, + bridgeController.metadata, + 'usedInUi', + ), + ).toMatchInlineSnapshot(` + { + "assetExchangeRates": {}, + "batchSellTrades": null, + "batchSellTradesLoadingStatus": null, + "inputPrimaryDenomination": "token_amount", + "minimumBalanceForRentExemptionInLamports": "0", + "quoteFetchError": null, + "quoteRequest": [ + { + "srcTokenAddress": "0x0000000000000000000000000000000000000000", + }, + ], + "quoteStreamComplete": null, + "quotes": [], + "quotesInitialLoadTime": null, + "quotesLastFetched": null, + "quotesLoadingStatus": null, + "quotesRefreshCount": 0, + "tokenSecurityTypeDestination": null, + "tokenWarnings": [], + } + `); + }); + }); + }); +}); diff --git a/packages/bridge-controller/src/bridge-controller.ts b/packages/bridge-controller/src/bridge-controller.ts new file mode 100644 index 00000000000..18780eedec8 --- /dev/null +++ b/packages/bridge-controller/src/bridge-controller.ts @@ -0,0 +1,1540 @@ +/* eslint-disable @typescript-eslint/explicit-function-return-type */ +import { BigNumber } from '@ethersproject/bignumber'; +import { Contract } from '@ethersproject/contracts'; +import { Web3Provider } from '@ethersproject/providers'; +import type { StateMetadata } from '@metamask/base-controller'; +import type { TraceCallback, TraceRequest } from '@metamask/controller-utils'; +import type { InternalAccount } from '@metamask/keyring-internal-api'; +import { abiERC20 } from '@metamask/metamask-eth-abis'; +import { StaticIntervalPollingController } from '@metamask/polling-controller'; +import type { TransactionController } from '@metamask/transaction-controller'; +import type { CaipAssetType, Hex } from '@metamask/utils'; +import { v4 as uuid } from 'uuid'; + +import { toQuoteResponseV2 } from './coercers/quote-response-v1-to-v2.js'; +import type { BridgeClientId } from './constants/bridge.js'; +import { + BRIDGE_CONTROLLER_NAME, + BRIDGE_PROD_API_BASE_URL, + DEFAULT_BRIDGE_CONTROLLER_STATE, + METABRIDGE_ETHEREUM_ADDRESS, + REFRESH_INTERVAL_MS, +} from './constants/bridge.js'; +import { CHAIN_IDS } from './constants/chains.js'; +import { SWAPS_CONTRACT_ADDRESSES } from './constants/swaps.js'; +import { TraceName } from './constants/traces.js'; +import { RequestStatus } from './types.js'; +import type { + L1GasFees, + GenericQuoteRequest, + NonEvmFees, + QuoteRequest, + BridgeControllerState, + BridgeControllerMessenger, + FetchFunction, + InputPrimaryDenomination, +} from './types.js'; +import { getAssetIdsForToken, toExchangeRates } from './utils/assets.js'; +import { hasSufficientBalance } from './utils/balance.js'; +import { + getDefaultBridgeControllerState, + isCrossChain, + isEthUsdt, + isNonEvmChainId, + isSolanaChainId, +} from './utils/bridge.js'; +import { + formatAddressToCaipReference, + formatChainIdToCaip, + formatChainIdToHex, +} from './utils/caip-formatters.js'; +import { + getBridgeFeatureFlags, + hasMinimumRequiredVersion, +} from './utils/feature-flags.js'; +import { + fetchAssetPrices, + fetchBridgeQuotes, + fetchBridgeQuoteStream, + fetchBatchSellTrades, +} from './utils/fetch.js'; +import { + AbortReason, + BatchSellMetricsEventName, + MetaMetricsSwapsEventSource, + MetricsActionType, + UnifiedSwapBridgeEventName, +} from './utils/metrics/constants.js'; +import type { + BridgeControllerMetricsEventName, + BridgeControllerMetricsLocation, +} from './utils/metrics/constants.js'; +import { + formatProviderLabel, + getAccountHardwareType, + getRequestParams, + getSwapType, + getSwapTypeFromQuote, + isCustomSlippage, + toInputChangedPropertyKey, + toInputChangedPropertyValue, +} from './utils/metrics/properties.js'; +import type { + QuoteFetchData, + RequestMetadata, + RequiredEventContextFromClient, +} from './utils/metrics/types.js'; +import type { CrossChainSwapsEventProperties } from './utils/metrics/types.js'; +import { appendFeesToQuotes } from './utils/quote-fees.js'; +import { getMinimumBalanceForRentExemptionInLamports } from './utils/snaps.js'; +import { sortQuotes } from './utils/sort-quotes.js'; +import type { FeatureId } from './validators/feature-flags.js'; +import { + isValidQuoteRequest, + isValidBatchSellQuoteRequest, +} from './validators/quote-request.js'; +import type { QuoteResponseV1 } from './validators/quote-response-v1.js'; +import type { QuoteResponse } from './validators/quote-response.js'; + +const metadata: StateMetadata = { + quoteRequest: { + includeInStateLogs: true, + persist: false, + includeInDebugSnapshot: false, + usedInUi: true, + }, + quotes: { + includeInStateLogs: true, + persist: false, + includeInDebugSnapshot: false, + usedInUi: true, + }, + quotesInitialLoadTime: { + includeInStateLogs: true, + persist: false, + includeInDebugSnapshot: false, + usedInUi: true, + }, + quotesLastFetched: { + includeInStateLogs: true, + persist: false, + includeInDebugSnapshot: false, + usedInUi: true, + }, + quotesLoadingStatus: { + includeInStateLogs: true, + persist: false, + includeInDebugSnapshot: false, + usedInUi: true, + }, + quoteFetchError: { + includeInStateLogs: true, + persist: false, + includeInDebugSnapshot: false, + usedInUi: true, + }, + quotesRefreshCount: { + includeInStateLogs: true, + persist: false, + includeInDebugSnapshot: false, + usedInUi: true, + }, + assetExchangeRates: { + includeInStateLogs: true, + persist: false, + includeInDebugSnapshot: false, + usedInUi: true, + }, + minimumBalanceForRentExemptionInLamports: { + includeInStateLogs: true, + persist: false, + includeInDebugSnapshot: false, + usedInUi: true, + }, + tokenWarnings: { + includeInStateLogs: true, + persist: false, + includeInDebugSnapshot: false, + usedInUi: true, + }, + tokenSecurityTypeDestination: { + includeInStateLogs: true, + persist: false, + includeInDebugSnapshot: false, + usedInUi: true, + }, + inputPrimaryDenomination: { + includeInStateLogs: true, + persist: true, + includeInDebugSnapshot: false, + usedInUi: true, + }, + quoteStreamComplete: { + includeInStateLogs: true, + persist: false, + includeInDebugSnapshot: false, + usedInUi: true, + }, + batchSellTrades: { + includeInStateLogs: true, + persist: false, + includeInDebugSnapshot: false, + usedInUi: true, + }, + batchSellTradesLoadingStatus: { + includeInStateLogs: true, + persist: false, + includeInDebugSnapshot: false, + usedInUi: true, + }, +}; + +/** + * The input to start polling for the {@link BridgeController} + * + * @param updatedQuoteRequest - The updated quote request + * @param context - The context contains properties that can't be populated by the + * controller and need to be provided by the client for analytics + */ +type BridgePollingInput = { + quoteRequests: GenericQuoteRequest[]; + context: RequiredEventContextFromClient[UnifiedSwapBridgeEventName.QuotesError] & + RequiredEventContextFromClient[UnifiedSwapBridgeEventName.QuotesRequested]; +}; + +type QuoteTraceResult = 'success' | 'cancelled' | 'no_quotes' | 'error'; + +const QUOTE_ABORT_REASONS = new Set(Object.values(AbortReason)); + +const isExpectedQuoteAbort = ( + error: unknown, + signal?: AbortSignal, +): boolean => { + if (signal?.aborted) { + return true; + } + + if (QUOTE_ABORT_REASONS.has(String(error))) { + return true; + } + + const errorText = + error instanceof Error ? `${error.name} ${error.message}` : String(error); + + return ( + errorText.includes('AbortError') || + errorText.includes('FetchRequestCanceledException') + ); +}; + +const MESSENGER_EXPOSED_METHODS = [ + 'updateBridgeQuoteRequestParams', + 'fetchQuotes', + 'updateBatchSellTrades', + 'stopPollingForQuotes', + 'setLocation', + 'getLocation', + 'setInputPrimaryDenomination', + 'resetState', + 'setChainIntervalLength', + 'trackUnifiedSwapBridgeEvent', +] as const; + +export class BridgeController extends StaticIntervalPollingController()< + typeof BRIDGE_CONTROLLER_NAME, + BridgeControllerState, + BridgeControllerMessenger +> { + #abortController: AbortController | undefined; + + #batchSellTradesAbortController: AbortController | undefined; + + #quotesFirstFetched: number | undefined; + + /** + * Stores the location/entry point from which the user initiated the swap or bridge flow. + * Set via setLocation() before navigating to the swap/bridge flow. + * Used as default for all subsequent internal events. + */ + #location: BridgeControllerMetricsLocation = + MetaMetricsSwapsEventSource.Unknown; + + readonly #clientId: BridgeClientId; + + readonly #clientVersion: string; + + readonly #getLayer1GasFee: typeof TransactionController.prototype.getLayer1GasFee; + + readonly #fetchFn: FetchFunction; + + readonly #trackMetaMetricsFn: < + EventName extends BridgeControllerMetricsEventName, + >( + eventName: EventName, + properties: CrossChainSwapsEventProperties, + ) => void; + + readonly #trace: TraceCallback; + + readonly #config: { + customBridgeApiBaseUrl?: string; + }; + + /** + * Returns whether to use AssetsController for exchange rates. + * Set via constructor option getUseAssetsControllerForRates; defaults to false. + * + * @returns True when exchange rates should be read from AssetsController:getExchangeRatesForBridge. + */ + readonly #getUseAssetsControllerForRates: () => boolean; + + constructor({ + messenger, + state, + clientId, + clientVersion, + getLayer1GasFee, + fetchFn, + config, + trackMetaMetricsFn, + traceFn, + getUseAssetsControllerForRates, + }: { + messenger: BridgeControllerMessenger; + state?: Partial; + clientId: BridgeClientId; + clientVersion: string; + getLayer1GasFee: typeof TransactionController.prototype.getLayer1GasFee; + fetchFn: FetchFunction; + config?: { + customBridgeApiBaseUrl?: string; + }; + trackMetaMetricsFn: ( + eventName: EventName, + properties: CrossChainSwapsEventProperties, + ) => void; + traceFn?: TraceCallback; + /** + * When provided, called to determine whether to use AssetsController for exchange rates. + * When true, rates are read from AssetsController:getExchangeRatesForBridge instead of + * MultichainAssetsRatesController, TokenRatesController, and CurrencyRateController. + */ + getUseAssetsControllerForRates?: () => boolean; + }) { + super({ + name: BRIDGE_CONTROLLER_NAME, + metadata, + messenger, + state: { + ...getDefaultBridgeControllerState(), + ...state, + }, + }); + + this.setIntervalLength(REFRESH_INTERVAL_MS); + + this.#abortController = new AbortController(); + this.#getLayer1GasFee = getLayer1GasFee; + this.#clientId = clientId; + this.#clientVersion = clientVersion; + this.#fetchFn = fetchFn; + this.#trackMetaMetricsFn = trackMetaMetricsFn; + this.#config = config ?? {}; + this.#trace = traceFn ?? (((_request, fn) => fn?.()) as TraceCallback); + this.#getUseAssetsControllerForRates = + getUseAssetsControllerForRates ?? (() => false); + + this.messenger.registerMethodActionHandlers( + this, + MESSENGER_EXPOSED_METHODS, + ); + } + + _executePoll = async (pollingInput: BridgePollingInput) => { + await this.#fetchBridgeQuotes(pollingInput); + }; + + /** + * Updates the quote request at the specified index with the given parameters, then starts + * polling for quotes. + * + * @param paramsToUpdate - The parameters to update in the quote request at the specified index + * @param context - metrics context + * @param quoteRequestIndex - The index of the quote request to update + * @param quoteRequestCount - The number of quote requests in the UI + */ + updateBridgeQuoteRequestParams = async ( + paramsToUpdate: Partial & { + walletAddress: GenericQuoteRequest['walletAddress']; + }, + context: BridgePollingInput['context'], + quoteRequestIndex: number = 0, + quoteRequestCount: number = 1, + ) => { + // Guard against updating a quote request that doesn't exist + if (quoteRequestIndex >= quoteRequestCount) { + return; + } + this.#trackInputChangedEvents( + paramsToUpdate, + context.feature_id, + quoteRequestIndex, + ); + this.resetState(AbortReason.QuoteRequestUpdated, quoteRequestIndex); + this.update((state) => { + // Update only the specified quote request and keep the rest of the quote requests unchanged + state.quoteRequest = state.quoteRequest + .slice(0, quoteRequestIndex) + .concat({ + ...DEFAULT_BRIDGE_CONTROLLER_STATE.quoteRequest[0], + ...paramsToUpdate, + }) + .concat( + state.quoteRequest.slice(quoteRequestIndex + 1, quoteRequestCount), + ); + state.tokenSecurityTypeDestination = + context.token_security_type_destination ?? null; + }); + + // BatchSell and Unified swaps both use the same polling logic so both validations should pass + if ( + isValidQuoteRequest(paramsToUpdate) && + isValidBatchSellQuoteRequest(this.state.quoteRequest) + ) { + this.#quotesFirstFetched = Date.now(); + // Update the insufficientBal and resetApproval params for the quote request + const quoteWithInsufficientBalAndResetApproval = + await this.#appendInsufficientBalAndResetApproval(paramsToUpdate); + this.update((state) => { + state.quoteRequest[quoteRequestIndex] = + quoteWithInsufficientBalAndResetApproval; + }); + + // Set refresh rate based on the source chain before starting polling + this.setChainIntervalLength(); + this.startPolling({ + quoteRequests: this.state.quoteRequest, + context, + }); + } + }; + + /** + * Fetches quotes for specified request without updating the controller state + * This method does not start polling for quotes and does not emit UnifiedSwapBridge events + * + * @param quoteRequest - The parameters for quote requests to fetch + * @param featureId - The feature ID that maps to quoteParam overrides from LD + * @param abortSignal - The abort signal to cancel all the requests + * @returns A list of validated quotes + */ + fetchQuotes = async ( + quoteRequest: GenericQuoteRequest, + featureId: FeatureId, + abortSignal: AbortSignal | null = null, + ): Promise<(QuoteResponseV1 & L1GasFees & NonEvmFees)[]> => { + 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] + : undefined; + const resetApproval = await this.#shouldResetApproval(quoteRequest); + + // If quoteRequestOverrides is specified, merge it with the quoteRequest + const { quotes: baseQuotes, validationFailures } = await fetchBridgeQuotes( + quoteRequestOverrides + ? { ...quoteRequest, ...quoteRequestOverrides, resetApproval } + : { ...quoteRequest, resetApproval }, + abortSignal, + this.#clientId, + jwt, + this.#fetchFn, + this.#config.customBridgeApiBaseUrl ?? BRIDGE_PROD_API_BASE_URL, + featureId, + this.#clientVersion, + ); + + this.#trackQuoteValidationFailures(validationFailures, featureId); + const srcChainIds = Array.from( + new Set(baseQuotes.map((quote) => quote.quote.srcChainId)), + ).filter(Boolean); + + const quotesWithFees = + srcChainIds.length > 1 || srcChainIds.length === 0 + ? // Don't append fees if there are multiple srcChainIds + baseQuotes + : await appendFeesToQuotes( + formatChainIdToCaip(srcChainIds[0]), + baseQuotes, + this.messenger, + this.#getLayer1GasFee, + this.#getMultichainSelectedAccount(quoteRequest.walletAddress), + ); + + return sortQuotes(quotesWithFees, featureId); + }; + + /** + * Fetches gasless transaction data and fees for BatchSell quotes. + * To use this in the clients, add a listener for the recommendedQuotes and call + * this handler whenever they change. + * + * @param quotes - The quotes to fetch the gasless transaction data and fees for + * @param stxEnabled - Flag to estimate gas cost more precisely for the batch sell feature. + */ + updateBatchSellTrades = async ( + quotes: (QuoteResponse | null)[], + stxEnabled: boolean, + ): Promise => { + this.#batchSellTradesAbortController?.abort( + AbortReason.GaslessTxBatchFetched, + ); + this.#batchSellTradesAbortController = new AbortController(); + + this.update((state) => { + // Set loading status again if recommended quotes are re-ordered + state.batchSellTradesLoadingStatus = RequestStatus.LOADING; + }); + + try { + const batchSellTradesResponse = await fetchBatchSellTrades( + quotes, + stxEnabled, + this.#batchSellTradesAbortController.signal, + this.#clientId, + await this.#getJwt(), + this.#fetchFn, + this.#config.customBridgeApiBaseUrl ?? BRIDGE_PROD_API_BASE_URL, + this.#clientVersion, + ); + + this.update((state) => { + state.batchSellTrades = batchSellTradesResponse; + state.batchSellTradesLoadingStatus = RequestStatus.FETCHED; + }); + + // TODO if fee.asset.assetId is not in exchange rates, fetch the exchange rate and update the state + } catch (error) { + // Ignore abort errors + if ( + (error as Error).toString().includes('AbortError') || + (error as Error).toString().includes('FetchRequestCanceledException') || + [ + AbortReason.ResetState, + AbortReason.NewQuoteRequest, + AbortReason.QuoteRequestUpdated, + AbortReason.TransactionSubmitted, + AbortReason.GaslessTxBatchFetched, + ].includes(error as AbortReason) + ) { + // Exit the function early to prevent other state updates + return; + } + + this.update((state) => { + // Reset the batch sell trades if the fetch fails to avoid showing stale data + state.batchSellTrades = DEFAULT_BRIDGE_CONTROLLER_STATE.batchSellTrades; + // Update loading status + state.batchSellTradesLoadingStatus = RequestStatus.ERROR; + }); + console.log(`Failed to fetch batch sell trades`, error); + } + }; + + readonly #trackQuoteValidationFailures = ( + validationFailures: string[], + featureId: FeatureId, + ) => { + if (validationFailures.length === 0) { + return; + } + this.trackUnifiedSwapBridgeEvent( + UnifiedSwapBridgeEventName.QuotesValidationFailed, + { + feature_id: featureId, + failures: validationFailures, + location: this.#location, + }, + ); + }; + + /** + * Fetches the exchange rates for the assets in the quote request if they are not already in the state + * In addition to the selected tokens, this also fetches the native asset for the source and destination chains + * + * @param quoteRequests - The quote requests to fetch the exchange rates for + */ + readonly #fetchAssetExchangeRates = async ( + quoteRequests: GenericQuoteRequest[], + ) => { + // Get unique assetIds for all quote requests + const assetIds = new Set( + quoteRequests.flatMap((quoteRequest) => + [ + getAssetIdsForToken( + quoteRequest.srcTokenAddress, + quoteRequest.srcChainId, + ), + getAssetIdsForToken( + quoteRequest.destTokenAddress, + quoteRequest.destChainId, + ), + ].flat(), + ), + ); + + const currency = this.#getUseAssetsControllerForRates() + ? this.messenger.call('AssetsController:getExchangeRatesForBridge') + .currentCurrency + : this.messenger.call('CurrencyRateController:getState').currentCurrency; + + if (assetIds.size === 0) { + return; + } + + const pricesByAssetId = await fetchAssetPrices({ + assetIds, + currencies: new Set([currency, 'usd']), + clientId: this.#clientId, + clientVersion: this.#clientVersion, + fetchFn: this.#fetchFn, + signal: this.#abortController?.signal, + }); + const exchangeRates = toExchangeRates(currency, pricesByAssetId); + this.update((state) => { + state.assetExchangeRates = { + ...state.assetExchangeRates, + ...exchangeRates, + }; + }); + }; + + readonly #hasInsufficientBalance = async ( + quoteRequest: GenericQuoteRequest, + ) => { + try { + const srcChainIdInHex = formatChainIdToHex(quoteRequest.srcChainId); + const provider = + this.#getNetworkClientByChainId(srcChainIdInHex)?.provider; + const normalizedSrcTokenAddress = formatAddressToCaipReference( + quoteRequest.srcTokenAddress, + ); + + return !( + provider && + normalizedSrcTokenAddress && + quoteRequest.srcTokenAmount && + srcChainIdInHex && + (await hasSufficientBalance( + provider, + quoteRequest.walletAddress, + normalizedSrcTokenAddress, + quoteRequest.srcTokenAmount, + srcChainIdInHex, + )) + ); + } catch (error) { + console.warn('Failed to set insufficientBal', error); + // Fall back to true so the backend returns quotes + return true; + } + }; + + readonly #appendInsufficientBalAndResetApproval = async ( + quoteRequest: GenericQuoteRequest, + ) => { + const isSrcChainNonEVM = isNonEvmChainId(quoteRequest.srcChainId); + const providerConfig = isSrcChainNonEVM + ? undefined + : this.#getNetworkClientByChainId( + formatChainIdToHex(quoteRequest.srcChainId), + )?.configuration; + + let insufficientBal: boolean | undefined; + let resetApproval: boolean = Boolean(quoteRequest.resetApproval); + if (isSrcChainNonEVM) { + // If the source chain is not an EVM network, use value from params + insufficientBal = quoteRequest.insufficientBal; + } else if (providerConfig?.rpcUrl?.includes('tenderly')) { + // If the rpcUrl is a tenderly fork (e2e tests), set insufficientBal=true + // The bridge-api filters out quotes if the balance on mainnet is insufficient so this override allows quotes to always be returned + insufficientBal = true; + } else { + // Set loading status if RPC calls are made before the quotes are fetched + this.update((state) => { + state.quotesLoadingStatus = RequestStatus.LOADING; + }); + resetApproval = await this.#shouldResetApproval(quoteRequest); + // Otherwise query the src token balance from the RPC provider + insufficientBal = + quoteRequest.insufficientBal ?? + (await this.#hasInsufficientBalance(quoteRequest)); + } + + return { + ...quoteRequest, + insufficientBal, + resetApproval, + }; + }; + + readonly #shouldResetApproval = async (quoteRequest: GenericQuoteRequest) => { + if (isNonEvmChainId(quoteRequest.srcChainId)) { + return false; + } + try { + const normalizedSrcTokenAddress = formatAddressToCaipReference( + quoteRequest.srcTokenAddress, + ); + if (isEthUsdt(quoteRequest.srcChainId, normalizedSrcTokenAddress)) { + const allowance = BigNumber.from( + await this.#getUSDTMainnetAllowance( + quoteRequest.walletAddress, + normalizedSrcTokenAddress, + quoteRequest.destChainId, + ), + ); + return allowance.lt(quoteRequest.srcTokenAmount) && allowance.gt(0); + } + return false; + } catch (error) { + console.warn('Failed to set resetApproval', error); + // Fall back to true so the backend returns quotes + return true; + } + }; + + stopPollingForQuotes = ( + reason?: AbortReason, + context?: RequiredEventContextFromClient[UnifiedSwapBridgeEventName.QuotesReceived], + ) => { + this.stopAllPolling(); + // If polling is stopped before quotes finish loading, track QuotesReceived + if (this.state.quotesLoadingStatus === RequestStatus.LOADING && context) { + this.trackUnifiedSwapBridgeEvent( + UnifiedSwapBridgeEventName.QuotesReceived, + context, + ); + } + // Clears quotes list in state + this.#abortController?.abort(reason); + this.#batchSellTradesAbortController?.abort(reason); + }; + + /** + * Sets the location/entry point for the current swap or bridge flow. + * Call this when the user enters the flow so that all internally-fired + * events (InputChanged, QuotesRequested, etc.) carry the correct location. + * + * @param location - The entry point from which the user initiated the flow + */ + setLocation = (location: BridgeControllerMetricsLocation) => { + this.#location = location; + }; + + /** + * Returns the location/entry point for the current swap or bridge flow. + * + * @returns The entry point from which the user initiated the flow + */ + getLocation = (): BridgeControllerMetricsLocation => { + return this.#location; + }; + + setInputPrimaryDenomination = ( + inputPrimaryDenomination: InputPrimaryDenomination, + ) => { + this.update((state) => { + state.inputPrimaryDenomination = inputPrimaryDenomination; + }); + }; + + resetState = ( + reason = AbortReason.ResetState, + quoteRequestIndex: number | null = null, + context?: RequiredEventContextFromClient[UnifiedSwapBridgeEventName.QuotesReceived], + ) => { + this.stopPollingForQuotes(reason, context); + this.update((state) => { + // Cannot do direct assignment to state, i.e. state = {... }, need to manually assign each field + if (quoteRequestIndex === null) { + // Clear all requests if index is null + state.quoteRequest = DEFAULT_BRIDGE_CONTROLLER_STATE.quoteRequest; + } else { + // Otherwise only clear the specified request + state.quoteRequest = state.quoteRequest + .slice(0, quoteRequestIndex) + .concat(DEFAULT_BRIDGE_CONTROLLER_STATE.quoteRequest[0]) + .concat(state.quoteRequest.slice(quoteRequestIndex + 1)); + } + state.quotesInitialLoadTime = + DEFAULT_BRIDGE_CONTROLLER_STATE.quotesInitialLoadTime; + state.quotes = DEFAULT_BRIDGE_CONTROLLER_STATE.quotes; + state.quotesLastFetched = + DEFAULT_BRIDGE_CONTROLLER_STATE.quotesLastFetched; + state.quotesLoadingStatus = + DEFAULT_BRIDGE_CONTROLLER_STATE.quotesLoadingStatus; + state.quoteFetchError = DEFAULT_BRIDGE_CONTROLLER_STATE.quoteFetchError; + state.quotesRefreshCount = + DEFAULT_BRIDGE_CONTROLLER_STATE.quotesRefreshCount; + state.assetExchangeRates = + DEFAULT_BRIDGE_CONTROLLER_STATE.assetExchangeRates; + state.minimumBalanceForRentExemptionInLamports = + DEFAULT_BRIDGE_CONTROLLER_STATE.minimumBalanceForRentExemptionInLamports; + state.tokenWarnings = DEFAULT_BRIDGE_CONTROLLER_STATE.tokenWarnings; + state.tokenSecurityTypeDestination = + DEFAULT_BRIDGE_CONTROLLER_STATE.tokenSecurityTypeDestination; + state.quoteStreamComplete = + DEFAULT_BRIDGE_CONTROLLER_STATE.quoteStreamComplete; + state.batchSellTrades = DEFAULT_BRIDGE_CONTROLLER_STATE.batchSellTrades; + state.batchSellTradesLoadingStatus = + DEFAULT_BRIDGE_CONTROLLER_STATE.batchSellTradesLoadingStatus; + }); + }; + + /** + * Sets the interval length based on the source chain + */ + setChainIntervalLength = () => { + const { state } = this; + // Assume that BatchSell quote requests all have the same source chain + // Use the first one to determine refresh rate + const { srcChainId } = state.quoteRequest[0]; + const bridgeFeatureFlags = getBridgeFeatureFlags(this.messenger); + + const refreshRateOverride = srcChainId + ? bridgeFeatureFlags.chains[formatChainIdToCaip(srcChainId)]?.refreshRate + : undefined; + const defaultRefreshRate = bridgeFeatureFlags.refreshRate; + this.setIntervalLength(refreshRateOverride ?? defaultRefreshRate); + }; + + readonly #fetchBridgeQuotes = async ({ + quoteRequests, + context, + }: BridgePollingInput) => { + this.#abortController?.abort(AbortReason.NewQuoteRequest); + this.#batchSellTradesAbortController?.abort(AbortReason.NewQuoteRequest); + + this.#abortController = new AbortController(); + const quoteTraceStartTime = Date.now(); + const quoteTraceRequestId = uuid(); + const quoteAbortSignal = this.#abortController.signal; + + this.#fetchAssetExchangeRates(quoteRequests).catch((error) => + console.warn('Failed to fetch asset exchange rates', error), + ); + + this.trackUnifiedSwapBridgeEvent( + UnifiedSwapBridgeEventName.QuotesRequested, + context, + ); + + const { sse, maxRefreshCount } = getBridgeFeatureFlags(this.messenger); + const shouldStream = + sse?.enabled && + hasMinimumRequiredVersion(this.#clientVersion, sse.minimumVersion); + const isBatchSellRequest = quoteRequests.length > 1; + + this.update((state) => { + state.quoteFetchError = DEFAULT_BRIDGE_CONTROLLER_STATE.quoteFetchError; + state.tokenWarnings = DEFAULT_BRIDGE_CONTROLLER_STATE.tokenWarnings; + state.quoteStreamComplete = + DEFAULT_BRIDGE_CONTROLLER_STATE.quoteStreamComplete; + state.quotesLastFetched = Date.now(); + state.quotesLoadingStatus = RequestStatus.LOADING; + // Prevent clients from displaying stale batch sell fees + if (quoteRequests.length > 1) { + state.batchSellTradesLoadingStatus = RequestStatus.LOADING; + state.batchSellTrades = DEFAULT_BRIDGE_CONTROLLER_STATE.batchSellTrades; + } + }); + + const jwt = await this.#getJwt(); + + const [firstQuoteRequest] = quoteRequests; + let traceResult: QuoteTraceResult = 'error'; + let traceName = TraceName.BatchSellQuotesFetched; + if (!isBatchSellRequest) { + traceName = isCrossChain( + firstQuoteRequest.srcChainId, + firstQuoteRequest.destChainId, + ) + ? TraceName.BridgeQuotesFetched + : TraceName.SwapQuotesFetched; + } + const tracedProviders = new Set(); + const traceWithoutImpact = async (request: TraceRequest): Promise => { + try { + await this.#trace(request, () => undefined); + } catch { + // Telemetry failures must not affect quote fetching or state updates. + } + }; + const traceProviderFirstResult = ( + providerData: Parameters[0], + ) => { + const provider = formatProviderLabel(providerData); + if (isBatchSellRequest || tracedProviders.has(provider)) { + return; + } + tracedProviders.add(provider); + // Provider telemetry must not delay quote processing. + // eslint-disable-next-line @typescript-eslint/no-floating-promises + traceWithoutImpact({ + name: TraceName.QuoteProviderFirstResult, + startTime: quoteTraceStartTime, + data: { + provider, + feature_id: context.feature_id, + request_id: quoteTraceRequestId, + swap_type: getSwapType( + firstQuoteRequest.srcChainId, + firstQuoteRequest.destChainId, + ), + srcChainId: formatChainIdToCaip(firstQuoteRequest.srcChainId), + destChainId: formatChainIdToCaip(firstQuoteRequest.destChainId), + result: 'success', + }, + }); + }; + + try { + const selectedAccount = this.#getMultichainSelectedAccount( + firstQuoteRequest.walletAddress, + ); + // This call is not awaited to prevent blocking quote fetching if the snap takes too long to respond + // eslint-disable-next-line @typescript-eslint/no-floating-promises + this.#setMinimumBalanceForRentExemptionInLamports( + firstQuoteRequest.srcChainId, + selectedAccount?.metadata?.snap?.id, + ); + // Use SSE if enabled and return early + if (shouldStream || isBatchSellRequest) { + const quoteCount = await this.#handleQuoteStreaming({ + quoteRequests, + featureId: context.feature_id, + jwt, + selectedAccount, + signal: quoteAbortSignal, + traceProviderFirstResult, + }); + if (quoteAbortSignal.aborted) { + traceResult = 'cancelled'; + return; + } + traceResult = quoteCount > 0 ? 'success' : 'no_quotes'; + } else { + // Otherwise use regular fetch + const quotes = await this.fetchQuotes( + firstQuoteRequest, + context.feature_id, + quoteAbortSignal, + ); + for (const quote of quotes) { + traceProviderFirstResult(quote.quote); + } + this.update((state) => { + // Set the initial load time if this is the first fetch + if ( + state.quotesRefreshCount === + DEFAULT_BRIDGE_CONTROLLER_STATE.quotesRefreshCount && + this.#quotesFirstFetched + ) { + state.quotesInitialLoadTime = Date.now() - this.#quotesFirstFetched; + } + state.quotes = quotes.map(toQuoteResponseV2); + state.quotesLoadingStatus = RequestStatus.FETCHED; + }); + traceResult = quotes.length > 0 ? 'success' : 'no_quotes'; + } + } catch (error) { + // Reset the quotes list if the fetch fails to avoid showing stale quotes + this.update((state) => { + state.quotes = DEFAULT_BRIDGE_CONTROLLER_STATE.quotes; + }); + // Ignore abort errors + if (isExpectedQuoteAbort(error, quoteAbortSignal)) { + traceResult = 'cancelled'; + // Exit the function early to prevent other state updates + return; + } + + // Update loading status and error message + this.update((state) => { + // The error object reference is not guaranteed to exist on mobile so reading + // the message directly could cause an error. + let errorMessage; + try { + errorMessage = + (error as Error)?.message ?? (error as Error).toString(); + } catch { + // Intentionally empty + } finally { + state.quoteFetchError = errorMessage ?? 'Unknown error'; + } + state.quotesLoadingStatus = RequestStatus.ERROR; + }); + // Track event and log error + this.trackUnifiedSwapBridgeEvent( + UnifiedSwapBridgeEventName.QuotesError, + context, + ); + console.log( + `Failed to ${shouldStream ? 'stream' : 'fetch'} bridge quotes`, + error, + ); + } finally { + await traceWithoutImpact({ + name: traceName, + startTime: quoteTraceStartTime, + data: { + srcChainId: formatChainIdToCaip(firstQuoteRequest.srcChainId), + destChainId: formatChainIdToCaip(firstQuoteRequest.destChainId), + ...(!isBatchSellRequest && { + request_id: quoteTraceRequestId, + feature_id: context.feature_id, + result: traceResult, + }), + }, + }); + } + + // Update refresh count after fetching, validation and fee calculation have completed + this.update((state) => { + state.quotesRefreshCount += 1; + }); + const hasNoFundedQuoteRequests = quoteRequests.every( + ({ insufficientBal }) => Boolean(insufficientBal), + ); + + if ( + hasNoFundedQuoteRequests + ? // If all quote requests are insufficiently funded, stop polling + // So if a BatchSell has at least 1 sufficiently funded quote request, polling continues + true + : // Otherwise continue polling until the maximum number of refreshes has been reached + this.state.quotesRefreshCount >= maxRefreshCount + ) { + this.stopAllPolling(); + } + }; + + readonly #handleQuoteStreaming = async ({ + quoteRequests, + featureId, + jwt, + selectedAccount, + signal, + traceProviderFirstResult, + }: { + quoteRequests: GenericQuoteRequest[]; + featureId: FeatureId; + jwt?: string; + selectedAccount?: InternalAccount; + signal?: AbortSignal; + traceProviderFirstResult: ( + providerData: Parameters[0], + ) => void; + }): Promise => { + /** + * Tracks the number of valid quotes received from the current stream, which is used + * to determine when to clear the quotes list and set the initial load time + */ + let validQuotesCounter = 0; + /** + * Tracks all pending promises from appendFeesToQuotes calls to ensure they complete + * before setting quotesLoadingStatus to FETCHED + */ + const pendingFeeAppendPromises = new Set>(); + + await fetchBridgeQuoteStream( + this.#fetchFn, + quoteRequests, + signal, + featureId, + this.#clientId, + jwt, + this.#config.customBridgeApiBaseUrl ?? BRIDGE_PROD_API_BASE_URL, + { + onQuoteValidationFailure: (validationFailures) => + this.#trackQuoteValidationFailures(validationFailures, featureId), + onValidQuoteReceived: async (quote: QuoteResponse) => { + const feeAppendPromise = (async () => { + const quotesWithFees = await appendFeesToQuotes( + quote.chainId, + [quote], + this.messenger, + this.#getLayer1GasFee, + selectedAccount, + ); + if (quotesWithFees.length > 0) { + validQuotesCounter += 1; + traceProviderFirstResult(quote.quote); + } + this.update((state) => { + // Clear previous quotes and quotes load time when first quote in the current + // polling loop is received + // This enables clients to continue showing the previous quotes while new + // quotes are loading + // Note: If there are no valid quotes until the 2nd fetch, quotesInitialLoadTime will be > refreshRate + if (validQuotesCounter === 1) { + state.quotes = DEFAULT_BRIDGE_CONTROLLER_STATE.quotes; + if (!state.quotesInitialLoadTime && this.#quotesFirstFetched) { + // Set the initial load time after the first quote is received + state.quotesInitialLoadTime = + Date.now() - this.#quotesFirstFetched; + } + } + state.quotes = [...state.quotes, ...quotesWithFees]; + }); + })(); + pendingFeeAppendPromises.add(feeAppendPromise); + feeAppendPromise + .catch((error) => { + // Catch errors to prevent them from breaking stream processing + // If appendFeesToQuotes throws, the state update never happens, so no invalid entry is added + console.error('Error appending fees to quote', error); + }) + .finally(() => { + pendingFeeAppendPromises.delete(feeAppendPromise); + }); + // Await the promise to ensure errors are caught and handled before continuing + // The promise is also tracked in pendingFeeAppendPromises for onClose to wait for + await feeAppendPromise; + }, + onTokenWarning: (warning) => { + this.update((state) => { + const isDuplicate = state.tokenWarnings.some( + (existing) => existing.feature_id === warning.feature_id, + ); + if (!isDuplicate) { + state.tokenWarnings = [...state.tokenWarnings, warning]; + } + }); + }, + onComplete: (data) => { + this.update((state) => { + state.quoteStreamComplete = data; + }); + }, + onClose: async () => { + // Wait for all pending appendFeesToQuotes operations to complete + // before setting quotesLoadingStatus to FETCHED + await Promise.allSettled(Array.from(pendingFeeAppendPromises)); + this.update((state) => { + // If there are no valid quotes in the current stream, clear the quotes list + // to remove quotes from the previous stream + if (validQuotesCounter === 0) { + state.quotes = DEFAULT_BRIDGE_CONTROLLER_STATE.quotes; + } + state.quotesLoadingStatus = RequestStatus.FETCHED; + }); + }, + }, + this.#clientVersion, + ); + + return validQuotesCounter; + }; + + readonly #setMinimumBalanceForRentExemptionInLamports = async ( + srcChainId: GenericQuoteRequest['srcChainId'], + snapId?: string, + ) => { + if (!isSolanaChainId(srcChainId) || !snapId) { + return; + } + const minimumBalanceForRentExemptionInLamports = + await getMinimumBalanceForRentExemptionInLamports(snapId, this.messenger); + this.update((state) => { + state.minimumBalanceForRentExemptionInLamports = + minimumBalanceForRentExemptionInLamports; + }); + }; + + #getMultichainSelectedAccount( + walletAddress?: GenericQuoteRequest['walletAddress'], + ) { + // Assume that all quotes in a batch are for the same account + const addressToUse = + walletAddress ?? this.state.quoteRequest[0].walletAddress; + if (!addressToUse) { + throw new Error('Account address is required'); + } + const selectedAccount = this.messenger.call( + 'AccountsController:getAccountByAddress', + addressToUse, + ); + return selectedAccount; + } + + #getNetworkClientByChainId(chainId: Hex) { + const networkClientId = this.messenger.call( + 'NetworkController:findNetworkClientIdByChainId', + chainId, + ); + if (!networkClientId) { + throw new Error(`No network client found for chainId: ${chainId}`); + } + const networkClient = this.messenger.call( + 'NetworkController:getNetworkClientById', + networkClientId, + ); + return networkClient; + } + + readonly #getJwt = async (): Promise => { + 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 = ( + quoteRequestIndex: number = 0, + ): Omit< + RequestMetadata, + 'stx_enabled' | 'usd_amount_source' | 'security_warnings' + > => { + const quoteRequest = this.state.quoteRequest[quoteRequestIndex]; + const { walletAddress } = quoteRequest; + const accountHardwareType = getAccountHardwareType( + walletAddress + ? this.#getMultichainSelectedAccount(walletAddress) + : undefined, + ); + + return { + slippage_limit: quoteRequest.slippage ?? 0, + swap_type: getSwapTypeFromQuote(quoteRequest), + custom_slippage: isCustomSlippage(quoteRequest.slippage), + account_hardware_type: accountHardwareType, + is_hardware_wallet: accountHardwareType !== null, + }; + }; + + readonly #getQuoteFetchData = (): Omit< + QuoteFetchData, + 'best_quote_provider' | 'price_impact' | 'can_submit' + > => { + return { + quotes_count: this.state.quotes.length, + quotes_list: this.state.quotes.map(({ quote }) => + formatProviderLabel(quote), + ), + initial_load_time_all_quotes: this.state.quotesInitialLoadTime ?? 0, + has_gas_included_quote: this.state.quotes.some( + ({ quote }) => quote.gasIncluded, + ), + }; + }; + + readonly #getEventProperties = < + EventName extends BridgeControllerMetricsEventName, + >( + eventName: EventName, + propertiesFromClient: Pick< + RequiredEventContextFromClient, + EventName + >[EventName], + quoteRequestIndex: number = 0, + ) => { + const clientProps = propertiesFromClient as Record; + const baseProperties = { + ...propertiesFromClient, + location: clientProps?.location ?? this.#location, + action_type: MetricsActionType.SWAPBRIDGE_V1, + }; + const inputPrimaryDenominationProperties = { + input_primary_denomination: this.state.inputPrimaryDenomination, + }; + const batchSellClientChainProperties = propertiesFromClient as Pick< + RequiredEventContextFromClient[BatchSellMetricsEventName.BatchSellTokenPageViewed], + 'chain_id_source' | 'chain_id_destination' + >; + const batchSellBaseProperties = { + chain_id_source: batchSellClientChainProperties.chain_id_source, + chain_id_destination: batchSellClientChainProperties.chain_id_destination, + location: clientProps?.location ?? this.#location, + }; + const quoteRequest = this.state.quoteRequest[quoteRequestIndex]; + switch (eventName) { + case UnifiedSwapBridgeEventName.ButtonClicked: + return { + ...getRequestParams( + quoteRequest, + this.state.tokenSecurityTypeDestination, + ), + ...baseProperties, + }; + case UnifiedSwapBridgeEventName.PageViewed: + return { + ...getRequestParams( + quoteRequest, + this.state.tokenSecurityTypeDestination, + ), + ...this.#getRequestMetadata(), + ...inputPrimaryDenominationProperties, + ...baseProperties, + }; + case BatchSellMetricsEventName.BatchSellTokenPageViewed: + return batchSellBaseProperties; + case BatchSellMetricsEventName.BatchSellTokenPageContinueClicked: { + const propsFromClient = + propertiesFromClient as RequiredEventContextFromClient[BatchSellMetricsEventName.BatchSellTokenPageContinueClicked]; + return { + ...batchSellBaseProperties, + source_token_count: propsFromClient.source_token_addresses.length, + source_token_symbols: propsFromClient.source_token_symbols, + source_token_addresses: propsFromClient.source_token_addresses, + }; + } + case BatchSellMetricsEventName.BatchSellQuotePageViewed: + case BatchSellMetricsEventName.BatchSellQuotePageReviewClicked: { + const propsFromClient = + propertiesFromClient as RequiredEventContextFromClient[BatchSellMetricsEventName.BatchSellQuotePageViewed]; + return { + ...batchSellBaseProperties, + source_token_count: propsFromClient.source_token_addresses.length, + source_token_symbols: propsFromClient.source_token_symbols, + source_token_addresses: propsFromClient.source_token_addresses, + destination_token_symbol: propsFromClient.destination_token_symbol, + destination_token_address: propsFromClient.destination_token_address, + usd_amount_source_tokens: propsFromClient.usd_amount_source_tokens, + usd_amount_source_total: propsFromClient.usd_amount_source_total, + source_token_slippages: propsFromClient.source_token_slippages, + }; + } + case BatchSellMetricsEventName.BatchSellReviewModalSubmitted: { + const reviewModalProperties = + propertiesFromClient as RequiredEventContextFromClient[BatchSellMetricsEventName.BatchSellReviewModalSubmitted]; + return { + ...batchSellBaseProperties, + source_token_count: + reviewModalProperties.source_token_addresses.length, + source_token_symbols: reviewModalProperties.source_token_symbols, + source_token_addresses: reviewModalProperties.source_token_addresses, + destination_token_symbol: + reviewModalProperties.destination_token_symbol, + destination_token_address: + reviewModalProperties.destination_token_address, + usd_amount_source_tokens: + reviewModalProperties.usd_amount_source_tokens, + usd_amount_source_total: + reviewModalProperties.usd_amount_source_total, + source_token_slippages: reviewModalProperties.source_token_slippages, + usd_quoted_gas: reviewModalProperties.usd_quoted_gas, + usd_quoted_return: reviewModalProperties.usd_quoted_return, + }; + } + case UnifiedSwapBridgeEventName.FiatCryptoToggleClicked: + return { + ...getRequestParams( + quoteRequest, + this.state.tokenSecurityTypeDestination, + ), + swap_type: getSwapTypeFromQuote(quoteRequest), + ...baseProperties, + }; + case UnifiedSwapBridgeEventName.QuotesValidationFailed: + return { + ...getRequestParams( + quoteRequest, + this.state.tokenSecurityTypeDestination, + ), + refresh_count: this.state.quotesRefreshCount, + ...baseProperties, + }; + case UnifiedSwapBridgeEventName.QuotesReceived: + return { + ...getRequestParams( + quoteRequest, + this.state.tokenSecurityTypeDestination, + ), + ...this.#getRequestMetadata(), + ...this.#getQuoteFetchData(), + refresh_count: this.state.quotesRefreshCount, + has_sufficient_funds: !quoteRequest.insufficientBal, + ...inputPrimaryDenominationProperties, + ...baseProperties, + }; + case UnifiedSwapBridgeEventName.QuotesRequested: + return { + ...getRequestParams( + quoteRequest, + this.state.tokenSecurityTypeDestination, + ), + ...this.#getRequestMetadata(), + has_sufficient_funds: !quoteRequest.insufficientBal, + ...inputPrimaryDenominationProperties, + ...baseProperties, + }; + case UnifiedSwapBridgeEventName.QuotesError: + return { + ...getRequestParams( + quoteRequest, + this.state.tokenSecurityTypeDestination, + ), + ...this.#getRequestMetadata(), + error_message: this.state.quoteFetchError, + has_sufficient_funds: !quoteRequest.insufficientBal, + ...baseProperties, + }; + case UnifiedSwapBridgeEventName.AllQuotesOpened: + case UnifiedSwapBridgeEventName.AllQuotesSorted: + case UnifiedSwapBridgeEventName.QuoteSelected: + return { + ...getRequestParams( + quoteRequest, + this.state.tokenSecurityTypeDestination, + ), + ...this.#getRequestMetadata(), + ...this.#getQuoteFetchData(), + ...baseProperties, + }; + case UnifiedSwapBridgeEventName.Failed: { + // Populate the properties that the error occurred before the tx was submitted + return { + ...baseProperties, + ...getRequestParams( + quoteRequest, + this.state.tokenSecurityTypeDestination, + ), + ...this.#getRequestMetadata(), + ...this.#getQuoteFetchData(), + ...propertiesFromClient, + }; + } + case UnifiedSwapBridgeEventName.AssetDetailTooltipClicked: + case UnifiedSwapBridgeEventName.AssetPickerOpened: + return baseProperties; + // Inject `token_security_type_destination` from controller state so the + // field is always present on this event. `baseProperties` (which spreads + // `propertiesFromClient`) wins if the client supplies a value explicitly. + case UnifiedSwapBridgeEventName.InputSourceDestinationSwitched: + return { + token_security_type_destination: + this.state.tokenSecurityTypeDestination, + ...baseProperties, + }; + // These events may be published after the bridge-controller state is reset + // So the BridgeStatusController populates all the properties + case UnifiedSwapBridgeEventName.Submitted: + case UnifiedSwapBridgeEventName.Completed: + return propertiesFromClient; + case UnifiedSwapBridgeEventName.InputChanged: + default: + return baseProperties; + } + }; + + readonly #trackInputChangedEvents = ( + paramsToUpdate: Partial, + featureId: FeatureId, + quoteRequestIndex: number = 0, + ) => { + Object.entries(paramsToUpdate).forEach(([key, value]) => { + const inputKey = toInputChangedPropertyKey[key as keyof QuoteRequest]; + const inputValue = + toInputChangedPropertyValue[key as keyof QuoteRequest]?.( + paramsToUpdate, + ); + if ( + inputKey && + inputValue !== undefined && + this.state.quoteRequest[quoteRequestIndex] && + value !== + this.state.quoteRequest[quoteRequestIndex][ + key as keyof GenericQuoteRequest + ] + ) { + this.trackUnifiedSwapBridgeEvent( + UnifiedSwapBridgeEventName.InputChanged, + { + input: inputKey, + input_value: inputValue, + location: this.#location, + feature_id: featureId, + }, + ); + } + }); + }; + + /** + * This method tracks cross-chain swaps events + * + * @param eventName - The name of the event to track + * @param propertiesFromClient - Properties that can't be calculated from the event name and need to be provided by the client + * @param quoteRequestIndex - The index of the quote request to track the event for + * @example + * this.trackUnifiedSwapBridgeEvent(UnifiedSwapBridgeEventName.ActionOpened, { + * location: MetaMetricsSwapsEventSource.MainView, + * }); + */ + trackUnifiedSwapBridgeEvent = < + EventName extends BridgeControllerMetricsEventName, + >( + eventName: EventName, + propertiesFromClient: Pick< + RequiredEventContextFromClient, + EventName + >[EventName], + quoteRequestIndex: number = 0, + ) => { + try { + const combinedPropertiesForEvent = this.#getEventProperties( + eventName, + propertiesFromClient, + quoteRequestIndex, + ); + + this.#trackMetaMetricsFn( + eventName, + combinedPropertiesForEvent as CrossChainSwapsEventProperties, + ); + } catch (error) { + console.error( + `Error tracking cross-chain swaps MetaMetrics event ${eventName}`, + error, + ); + } + }; + + /** + * + * @param walletAddress - The address of the account to get the allowance for + * @param contractAddress - The address of the ERC20 token contract on mainnet + * @param destinationChainId - The chain ID of the destination network + * @returns The atomic allowance of the ERC20 token contract + */ + readonly #getUSDTMainnetAllowance = async ( + walletAddress: string, + contractAddress: string, + destinationChainId: GenericQuoteRequest['destChainId'], + ): Promise => { + const networkClient = this.#getNetworkClientByChainId(CHAIN_IDS.MAINNET); + const provider = networkClient?.provider; + if (!provider) { + throw new Error('No provider found'); + } + + const ethersProvider = new Web3Provider(provider); + const contract = new Contract(contractAddress, abiERC20, ethersProvider); + const spenderAddress = isCrossChain(CHAIN_IDS.MAINNET, destinationChainId) + ? METABRIDGE_ETHEREUM_ADDRESS + : SWAPS_CONTRACT_ADDRESSES[CHAIN_IDS.MAINNET]; + const allowance: BigNumber = await contract.allowance( + walletAddress, + spenderAddress, + ); + return allowance.toString(); + }; +} diff --git a/packages/bridge-controller/src/coercers/quote-response-v1-to-v2.test.ts b/packages/bridge-controller/src/coercers/quote-response-v1-to-v2.test.ts new file mode 100644 index 00000000000..b38b530f5b9 --- /dev/null +++ b/packages/bridge-controller/src/coercers/quote-response-v1-to-v2.test.ts @@ -0,0 +1,172 @@ +import { KnownCaipNamespace } from '@metamask/utils'; +import { QuoteMetadata } from 'src/utils/quote-metadata/types'; + +import { mockBridgeQuotesErc20Erc20V2Migration } from '../../tests/mock-quotes-erc20-erc20-migration-v2.js'; +import { mockBridgeQuotesErc20Erc20V1 } from '../../tests/mock-quotes-erc20-erc20.js'; +import { mergeQuoteMetadata } from '../utils/quote-metadata/merge.js'; +import { toQuoteMetadataV1 } from '../utils/quote-metadata/to-quote-metadata-v1.js'; +import { toQuoteResponseV2 } from './quote-response-v1-to-v2.js'; + +const TEST_METADATA: QuoteMetadata = { + sentAmount: { + amount: '14', + usd: undefined, + valueInCurrency: undefined, + }, + toTokenAmount: { + amount: '13.98428', + usd: undefined, + valueInCurrency: undefined, + }, + minToTokenAmount: { + amount: '13.7', + usd: undefined, + valueInCurrency: undefined, + }, + relayerFee: { + amount: '0.00001', + usd: undefined, + valueInCurrency: undefined, + }, + totalNetworkFee: { + amount: '0.001', + usd: undefined, + valueInCurrency: undefined, + }, + gasFee: { + total: { amount: '0.00099', usd: undefined, valueInCurrency: undefined }, + }, + swapRate: '0.99887714285714285714', + priceImpact: { + usd: '1.5', + valueInCurrency: '1.5', + }, +}; + +const quoteResponseV1WithMetadata = { + ...mockBridgeQuotesErc20Erc20V1[0], + ...TEST_METADATA, +}; + +describe('quote-response-v2 migration', () => { + describe('toQuoteResponseV2', () => { + it('should return a validation error for an invalid quote response', () => { + const quoteResponse = { + quote: { + requestId: '123', + }, + }; + + expect(() => + toQuoteResponseV2(quoteResponse), + ).toThrowErrorMatchingInlineSnapshot( + `"At path: quote.src -- Expected an object, but received: undefined"`, + ); + }); + + it('should return QuoteResponse with no normalized amounts and no metadata (V1 input)', () => { + const quoteResponseV2 = toQuoteResponseV2(quoteResponseV1WithMetadata); + + const expectedQuoteResponseV2 = mockBridgeQuotesErc20Erc20V2Migration[0]; + delete expectedQuoteResponseV2.quote.feeData.network; + expect( + quoteResponseV2.quote.feeData?.network?.[0]?.amount, + ).toBeUndefined(); + + expect(quoteResponseV2).toStrictEqual({ + ...expectedQuoteResponseV2, + ...TEST_METADATA, + namespace: KnownCaipNamespace.Eip155, + chainId: 'eip155:10', + }); + + const extractedMetadata = toQuoteMetadataV1(quoteResponseV2); + expect(extractedMetadata).toStrictEqual(TEST_METADATA); + }); + + it('should return QuoteResponse with no normalized amounts and preserve metadata (V1 input)', () => { + const quoteResponseV2 = mergeQuoteMetadata( + toQuoteResponseV2(quoteResponseV1WithMetadata), + TEST_METADATA, + ); + const expectedQuoteResponseV2 = mergeQuoteMetadata( + mockBridgeQuotesErc20Erc20V2Migration[0], + TEST_METADATA, + ); + + expect(expectedQuoteResponseV2.quote.feeData).toMatchInlineSnapshot(` + { + "metabridge": [ + { + "amount": "0", + "asset": { + "assetId": "eip155:10/erc20:0x0b2c639c533813f4aa9d7837caf62653d097ff85", + "decimals": 6, + "name": "USD Coin", + "symbol": "USDC", + }, + }, + ], + "network": [ + { + "amount": "990000000000000", + "asset": { + "assetId": "eip155:10/slip44:60", + "decimals": 18, + "name": "Ether", + "symbol": "ETH", + }, + "normalizedAmount": "0.00099", + "usd": undefined, + "valueInCurrency": undefined, + }, + ], + "relayer": [ + { + "amount": "10000000000000", + "asset": { + "assetId": "eip155:10/slip44:60", + "decimals": 18, + "name": "Ether", + "symbol": "ETH", + }, + "normalizedAmount": "0.00001", + "usd": undefined, + "valueInCurrency": undefined, + }, + ], + "txFee": undefined, + } + `); + + const extractedMetadata = toQuoteMetadataV1(quoteResponseV2); + expect(quoteResponseV2).toStrictEqual({ + ...expectedQuoteResponseV2, + ...TEST_METADATA, + namespace: KnownCaipNamespace.Eip155, + chainId: 'eip155:10', + }); + expect(extractedMetadata).toStrictEqual(TEST_METADATA); + }); + + it('should return QuoteResponse and preserve metadata (V2 input)', () => { + const quoteResponse = { + ...mockBridgeQuotesErc20Erc20V2Migration[0], + ...TEST_METADATA, + }; + const quoteResponseV2 = toQuoteResponseV2(quoteResponse); + expect(quoteResponseV2).toStrictEqual({ + ...quoteResponse, + namespace: KnownCaipNamespace.Eip155, + chainId: 'eip155:10', + }); + expect(toQuoteMetadataV1(quoteResponseV2)).toStrictEqual(TEST_METADATA); + }); + + it('should throw an error for a null input', () => { + expect(() => toQuoteResponseV2(null)).toThrow( + 'Expected an object, but received: null', + ); + }); + }); +}); diff --git a/packages/bridge-controller/src/coercers/quote-response-v1-to-v2.ts b/packages/bridge-controller/src/coercers/quote-response-v1-to-v2.ts new file mode 100644 index 00000000000..17a27911ae4 --- /dev/null +++ b/packages/bridge-controller/src/coercers/quote-response-v1-to-v2.ts @@ -0,0 +1,199 @@ +import { create, coerce, Infer, is, intersection } from '@metamask/superstruct'; +import { parseCaipAssetType } from '@metamask/utils'; + +import { assetIdsMatch } from '../utils/assets.js'; +import { formatAddressToAssetId } from '../utils/caip-formatters.js'; +import { sumAmounts } from '../utils/number-formatters.js'; +import { + BridgeAssetSchema, + BridgeAssetV2Schema, + MinimalAssetSchema, +} from '../validators/bridge-asset.js'; +import { QuoteResponseSchemaV1 } from '../validators/quote-response-v1.js'; +import type { QuoteResponseV1 } from '../validators/quote-response-v1.js'; +import { + QuoteResponseSchemaV2, + validateQuoteResponse, +} from '../validators/quote-response.js'; +import type { QuoteResponse } from '../validators/quote-response.js'; +import { QuoteSchemaV2, FeeType, QuoteSchema } from '../validators/quote.js'; +import { StepSchemaV2, StepSchema } from '../validators/step.js'; + +const BridgeAssetV2FromV1 = coerce( + BridgeAssetV2Schema, + intersection([BridgeAssetSchema, MinimalAssetSchema]), + (value) => { + const { chainId, address, iconUrl, icon, assetId, ...rest } = value; + + const resolvedIconUrl = iconUrl ?? icon; + + return { + assetId: + assetId ?? + /* istanbul ignore next */ formatAddressToAssetId(address, chainId), + ...(resolvedIconUrl && { iconUrl: resolvedIconUrl }), + ...rest, + }; + }, +); + +export const toBridgeAssetV2 = ( + data: unknown, +): Infer => { + return create(data, BridgeAssetV2FromV1); +}; + +const StepSchemaV2FromV1 = coerce(StepSchemaV2, StepSchema, (value) => { + const { srcAsset, destAsset, action } = value; + return { + action, + src: { + asset: toBridgeAssetV2(srcAsset), + }, + dest: { + asset: toBridgeAssetV2(destAsset), + }, + }; +}); +const toStepV2 = (step: Infer): Infer => + create(step, StepSchemaV2FromV1); + +const QuoteV2FromV1 = coerce(QuoteSchemaV2, QuoteSchema, (value) => { + const { + srcTokenAmount, + destTokenAmount, + minDestTokenAmount, + srcAsset, + destAsset, + srcChainId, + destChainId, + walletAddress, + destWalletAddress, + priceData, + feeData, + bridgeId, + bridges, + steps, + intent, + ...restQuote + } = value; + + const srcAssetV2 = toBridgeAssetV2(srcAsset); + + return { + src: { + amount: sumAmounts([ + { amount: srcTokenAmount, asset: srcAssetV2 }, + ...(intent + ? [] + : [feeData[FeeType.TX_FEE], feeData[FeeType.METABRIDGE]].filter( + (fee) => assetIdsMatch(fee?.asset?.assetId, srcAssetV2.assetId), + )), + ])?.amount, + asset: srcAssetV2, + ...(walletAddress && { walletAddress }), + }, + dest: { + amount: destTokenAmount, + asset: toBridgeAssetV2(destAsset), + ...(destWalletAddress && { walletAddress: destWalletAddress }), + minAmount: minDestTokenAmount, + }, + ...(priceData?.priceImpact && { + priceData: { + priceImpact: { + amount: priceData.priceImpact, + }, + }, + }), + feeData: { + [FeeType.METABRIDGE]: [ + { + ...feeData[FeeType.METABRIDGE], + asset: toBridgeAssetV2(feeData[FeeType.METABRIDGE].asset), + ...(priceData?.totalFeeAmountUsd && /* istanbul ignore next */ { + usd: priceData?.totalFeeAmountUsd, + }), + }, + ], + ...(feeData[FeeType.TX_FEE] && /* istanbul ignore next */ { + [FeeType.TX_FEE]: [ + { + ...feeData[FeeType.TX_FEE], + asset: toBridgeAssetV2(feeData[FeeType.TX_FEE].asset), + }, + ], + }), + }, + steps: steps?.map(toStepV2), + ...restQuote, + ...(intent && /* istanbul ignore next */ { intent }), + protocols: bridges, + aggregator: bridgeId, + }; +}); + +const toQuoteV2 = ( + quote: Infer, +): Infer => { + const quoteV2 = create(quote, QuoteV2FromV1); + return quoteV2; +}; + +const QuoteResponseV2FromV1 = coerce( + QuoteResponseSchemaV2, + QuoteResponseSchemaV1, + (value: QuoteResponseV1) => { + const { quote, l1GasFeesInHexWei, nonEvmFeesInNative, ...rest } = value; + const { srcAsset } = quote; + + const { + chain: { namespace }, + chainId, + } = parseCaipAssetType(srcAsset.assetId); + + return { + ...rest, + ...(nonEvmFeesInNative && { nonEvmFeesInNative }), + ...(l1GasFeesInHexWei && { l1GasFeesInHexWei }), + namespace, + chainId, + quote: toQuoteV2(quote), + }; + }, +); + +/** + * Converts a partial quote response to a {@link QuoteResponse}. + * This does not preserve any post-fetch metadata. + * + * @param quoteResponse - The {@link QuoteResponseV1} to convert + * @returns The {@link QuoteResponse} + */ +export function toQuoteResponseV2(quoteResponse: unknown): QuoteResponse { + let quoteResponseV2: QuoteResponse | null = null; + + // V1 quote + /* istanbul ignore else */ + if (is(quoteResponse, QuoteResponseSchemaV1)) { + quoteResponseV2 = create(quoteResponse, QuoteResponseV2FromV1); + } + // V2 quote + else if (validateQuoteResponse(quoteResponse)) { + quoteResponseV2 = quoteResponse; + } + + /* istanbul ignore else */ + if (quoteResponseV2) { + const { + chain: { namespace }, + chainId, + } = parseCaipAssetType(quoteResponseV2.quote.src.asset.assetId); + + // Add namespace, chainId + return { ...quoteResponseV2, namespace: namespace as never, chainId }; + } + + /* istanbul ignore next */ + throw new Error('QuoteResponseV1 to V2 conversion failed'); +} diff --git a/packages/bridge-controller/src/coercers/quote-response-v2-to-v1.test.ts b/packages/bridge-controller/src/coercers/quote-response-v2-to-v1.test.ts new file mode 100644 index 00000000000..d59dd70abf1 --- /dev/null +++ b/packages/bridge-controller/src/coercers/quote-response-v2-to-v1.test.ts @@ -0,0 +1,294 @@ +import { Failure, StructError } from '@metamask/superstruct'; +import { KnownCaipNamespace } from '@metamask/utils'; + +import { mockBridgeQuotesErc20Erc20V2Migration } from '../../tests/mock-quotes-erc20-erc20-migration-v2.js'; +import { mockBridgeQuotesErc20Erc20V1 } from '../../tests/mock-quotes-erc20-erc20.js'; +import { mergeQuoteMetadata } from '../utils/quote-metadata/merge.js'; +import { toQuoteMetadataV1 } from '../utils/quote-metadata/to-quote-metadata-v1.js'; +import { formatStructErrors } from '../utils/struct-error.js'; +import { toQuoteResponseV2 } from './quote-response-v1-to-v2.js'; +import { toQuoteResponseV1 } from './quote-response-v2-to-v1.js'; + +const MOCK_QUOTE_METADATA = { + adjustedReturn: { + usd: '2.08686', + valueInCurrency: '419.98686', + }, + cost: { + usd: '8.91314', + valueInCurrency: '1758.01314', + }, + minToTokenAmount: { + amount: '13.7', + usd: undefined, + valueInCurrency: undefined, + }, + sentAmount: { + amount: '14', + usd: '11', + valueInCurrency: '2178', + }, + swapRate: '1.90909090909090909091', + toTokenAmount: { + amount: '13.984280', + usd: '2.1', + valueInCurrency: '420', + }, + totalNetworkFee: { + amount: '0.0000073', + usd: '0.01314', + valueInCurrency: '0.01314', + }, + gasFee: { + total: { + amount: '0.000007', + usd: '0.0131', + valueInCurrency: '0.0131', + }, + }, + relayerFee: { + amount: '0.000003', + usd: '0.00004', + valueInCurrency: '0.00004', + }, + priceImpact: { + valueInCurrency: '10', + usd: '10', + }, +}; + +describe('quote-response-v1 compatibility', () => { + describe('toQuoteResponseV1', () => { + it('should return a validation error for an invalid quote response', () => { + const quoteResponse = { + quote: { + requestId: '123', + }, + }; + + const expectedError = new StructError( + { + value: '', + key: '', + type: '', + message: + 'Expected the value to satisfy a union of `intersection | intersection | intersection | intersection', + explanation: + 'Expected the value to satisfy a union of `intersection | intersection | intersection | intersection`, but received: [object Object]', + branch: [], + path: [], + refinement: undefined, + }, + function (): Generator { + return [ + { + path: ['quote', 'src'], + message: 'Expected an object, but received: undefined', + }, + { + path: ['quote', 'dest'], + message: 'Expected an object, but received: undefined', + }, + { + path: ['quote', 'feeData'], + message: 'Expected an object, but received: undefined', + }, + { + path: ['quote', 'aggregator'], + message: 'Expected a string, but received: undefined', + }, + { + path: ['quote', 'protocols'], + message: 'Expected an array value, but received: undefined', + }, + { + path: ['estimatedProcessingTimeInSeconds'], + message: 'Expected a number, but received: undefined', + }, + { + path: ['namespace'], + message: + 'Expected the literal `"eip155"`, but received: undefined', + }, + { + path: ['chainId'], + message: + 'Expected a value of type `CaipChainId`, but received: `undefined`', + }, + { + path: ['trade'], + message: 'Expected an object, but received: undefined', + }, + { + path: ['namespace'], + message: + 'Expected the literal `"solana"`, but received: undefined', + }, + { + path: ['trade'], + message: 'Expected a string, but received: undefined', + }, + { + path: ['namespace'], + message: 'Expected the literal `"tron"`, but received: undefined', + }, + + { + path: ['namespace'], + message: + 'Expected the literal `"bip122"`, but received: undefined', + }, + ] as unknown as Generator; + }, + ); + // @ts-expect-error - invalid quote response + expect(() => toQuoteResponseV1(quoteResponse)) + .toThrowErrorMatchingInlineSnapshot(` + "Failed to convert QuoteResponseV2 to QuoteResponseV1. [ + "At path: quote.srcChainId (number) -- Expected a number, but received: undefined", + "At path: quote.srcAsset (number) -- Expected an object, but received: undefined", + "At path: quote.srcTokenAmount (number) -- Expected a string, but received: undefined", + "At path: quote.destChainId (number) -- Expected a number, but received: undefined", + "At path: quote.destAsset (number) -- Expected an object, but received: undefined", + "At path: quote.destTokenAmount (number) -- Expected a string, but received: undefined", + "At path: quote.feeData (number) -- Expected an object, but received: undefined", + "At path: quote.bridgeId (number) -- Expected a string, but received: undefined", + "At path: quote.bridges (number) -- Expected an array value, but received: undefined", + "At path: quote.steps (number) -- Expected an array value, but received: undefined", + "At path: estimatedProcessingTimeInSeconds (number) -- Expected a number, but received: undefined", + "At path: trade (number) -- Expected the value to satisfy a union of \`type | type | type | union | string\`, but received: undefined", + "At path: trade (number) -- Expected an object, but received: undefined", + "At path: trade (number) -- Expected the value to satisfy a union of \`type | type\`, but received: undefined", + "At path: trade (number) -- Expected a string, but received: undefined" + ]" + `); + + expect(formatStructErrors(expectedError)).toMatchInlineSnapshot(` + [ + "At path: -- Expected the value to satisfy a union of \`intersection | intersection | intersection | intersection", + "At path: quote.src -- Expected an object, but received: undefined", + "At path: quote.dest -- Expected an object, but received: undefined", + "At path: quote.feeData -- Expected an object, but received: undefined", + "At path: quote.aggregator -- Expected a string, but received: undefined", + "At path: quote.protocols -- Expected an array value, but received: undefined", + "At path: estimatedProcessingTimeInSeconds -- Expected a number, but received: undefined", + "At path: namespace -- Expected the literal \`"eip155"\`, but received: undefined", + "At path: chainId -- Expected a value of type \`CaipChainId\`, but received: \`undefined\`", + "At path: trade -- Expected an object, but received: undefined", + "At path: namespace -- Expected the literal \`"solana"\`, but received: undefined", + "At path: trade -- Expected a string, but received: undefined", + "At path: namespace -- Expected the literal \`"tron"\`, but received: undefined", + "At path: namespace -- Expected the literal \`"bip122"\`, but received: undefined", + ] + `); + }); + + it('should return a valid QuoteResponseV1 with V2 input (no metadata)', () => { + const quoteResponse = mockBridgeQuotesErc20Erc20V1[0]; + expect(quoteResponse.quote.minDestTokenAmount).toBe('13700000'); + + const quoteResponseV2 = toQuoteResponseV2(quoteResponse); + expect(quoteResponseV2.quote.feeData.network).toBeUndefined(); + + const quoteMetadata = toQuoteMetadataV1(quoteResponseV2); + expect( + Object.values(quoteMetadata).every((value) => value === undefined), + ).toBe(true); + + expect(quoteResponseV2.quote.dest.minAmount).toBe('13700000'); + const expectedQuoteResponseV2 = mockBridgeQuotesErc20Erc20V2Migration[0]; + delete expectedQuoteResponseV2.quote.feeData.network; + + expect(quoteResponseV2).toStrictEqual({ + ...expectedQuoteResponseV2, + namespace: KnownCaipNamespace.Eip155, + chainId: 'eip155:10', + }); + expect(quoteResponseV2.quote.dest.minAmount).toMatchInlineSnapshot( + `"13700000"`, + ); + expect(quoteResponseV2.quote.feeData.network).toBeUndefined(); + + const quoteResponseV1 = toQuoteResponseV1(quoteResponseV2); + expect(quoteResponseV1.quote.minDestTokenAmount).toBe('13700000'); + + expect(quoteResponseV1).toStrictEqual(quoteResponse); + }); + + it('should return a valid QuoteResponseV1 with V2 input (remove metadata)', () => { + const quoteResponseV1WithMetadata = { + ...mockBridgeQuotesErc20Erc20V1[0], + ...MOCK_QUOTE_METADATA, + }; + + // Build input data by converting V1 to V2 + const quoteResponseV2 = mergeQuoteMetadata( + toQuoteResponseV2(quoteResponseV1WithMetadata), + MOCK_QUOTE_METADATA, + ); + + const expectedQuoteResponseV2 = mergeQuoteMetadata( + toQuoteResponseV2(mockBridgeQuotesErc20Erc20V2Migration[0]), + MOCK_QUOTE_METADATA, + ); + + expect( + toQuoteResponseV2(quoteResponseV1WithMetadata).quote.feeData + ?.network?.[0], + ).toMatchInlineSnapshot(`undefined`); + expect(quoteResponseV2.quote.feeData?.network?.[0]) + .toMatchInlineSnapshot(` + { + "amount": "7000000000000", + "asset": { + "assetId": "eip155:10/slip44:60", + "decimals": 18, + "name": "Ether", + "symbol": "ETH", + }, + "normalizedAmount": "0.000007", + "usd": "0.0131", + "valueInCurrency": "0.0131", + } + `); + + expect(quoteResponseV2).toStrictEqual({ + ...expectedQuoteResponseV2, + namespace: KnownCaipNamespace.Eip155, + chainId: 'eip155:10', + ...MOCK_QUOTE_METADATA, + }); + + // Convert V2 to V1 + const quoteResponseV1 = toQuoteResponseV1(quoteResponseV2); + expect(quoteResponseV1).toStrictEqual(mockBridgeQuotesErc20Erc20V1[0]); + }); + + it('should return a valid QuoteResponse with V1 input', () => { + const quoteResponse = mockBridgeQuotesErc20Erc20V1[0]; + const quoteResponseV2 = toQuoteResponseV1(quoteResponse); + expect(quoteResponseV2).toStrictEqual(mockBridgeQuotesErc20Erc20V1[0]); + }); + + it('should return a valid QuoteResponseV1 with V1 input and metadata', () => { + const quoteResponse = { + ...mockBridgeQuotesErc20Erc20V1[0], + ...MOCK_QUOTE_METADATA, + }; + + // Convert to V1 + const quoteResponseV1 = toQuoteResponseV1(quoteResponse); + expect(quoteResponseV1).toStrictEqual(quoteResponse); + expect(toQuoteMetadataV1(quoteResponseV1)).toStrictEqual( + MOCK_QUOTE_METADATA, + ); + }); + + it('should throw an error for a null input', () => { + // @ts-expect-error - null input + expect(() => toQuoteResponseV1(null)).toThrow( + 'Failed to convert QuoteResponseV2 + metadata to QuoteResponseV1. [\n "At path: (type) -- Expected an object, but received: null"\n]', + ); + }); + }); +}); diff --git a/packages/bridge-controller/src/coercers/quote-response-v2-to-v1.ts b/packages/bridge-controller/src/coercers/quote-response-v2-to-v1.ts new file mode 100644 index 00000000000..b604fdfc2d0 --- /dev/null +++ b/packages/bridge-controller/src/coercers/quote-response-v2-to-v1.ts @@ -0,0 +1,236 @@ +import { + create, + coerce, + is, + StructError, + intersection, + Infer, +} from '@metamask/superstruct'; +import { parseCaipAssetType } from '@metamask/utils'; + +import type { Step } from '../types.js'; +import { + formatAddressToCaipReference, + formatChainIdToDec, +} from '../utils/caip-formatters.js'; +import type { QuoteMetadata } from '../utils/quote-metadata/types.js'; +import { formatStructErrors } from '../utils/struct-error.js'; +import { + BridgeAssetSchema, + BridgeAssetV2Schema, + MinimalAssetSchema, +} from '../validators/bridge-asset.js'; +import type { BridgeAssetV2 } from '../validators/bridge-asset.js'; +import { QuoteResponseSchemaV1 } from '../validators/quote-response-v1.js'; +import type { QuoteResponseV1 } from '../validators/quote-response-v1.js'; +import { QuoteResponseSchemaV2 } from '../validators/quote-response.js'; +import type { QuoteResponse } from '../validators/quote-response.js'; +import { + QuoteSchemaV2, + FeeType, + Quote, + QuoteSchema, +} from '../validators/quote.js'; +import { StepSchemaV2, StepSchema } from '../validators/step.js'; + +const BridgeAssetV1FromV2 = coerce( + intersection([BridgeAssetSchema, MinimalAssetSchema]), + BridgeAssetV2Schema, + (value) => { + const { assetId, ...rest } = value; + + const { chainId } = parseCaipAssetType(assetId); + return { + address: formatAddressToCaipReference(assetId), + chainId: formatChainIdToDec(chainId), + assetId, + ...rest, + }; + }, +); + +const toBridgeAssetV1 = ( + data: BridgeAssetV2, +): Infer => { + return create(data, BridgeAssetV1FromV2); +}; + +const StepSchemaV1FromV2 = coerce(StepSchema, StepSchemaV2, (value) => { + const { src, dest, action } = value; + const srcAsset = toBridgeAssetV1(src.asset); + const destAsset = toBridgeAssetV1(dest.asset); + + return { + action, + srcChainId: srcAsset.chainId, + destChainId: destAsset.chainId, + srcAsset, + destAsset, + }; +}); + +const toStepV1 = (step: Infer): Step => { + const stepV2 = create(step, StepSchemaV1FromV2); + return stepV2; +}; + +const QuoteV1FromV2 = coerce(QuoteSchema, QuoteSchemaV2, (value) => { + const { + priceData, + feeData, + steps, + protocols, + aggregator, + src, + dest, + intent, + ...restQuote + } = value; + + const { chainId: srcChainIdInCaip } = parseCaipAssetType(src.asset.assetId); + const { chainId: destChainIdInCaip } = parseCaipAssetType(dest.asset.assetId); + + const srcChainId = formatChainIdToDec(srcChainIdInCaip); + const destChainId = formatChainIdToDec(destChainIdInCaip); + + const { usd, ...metabridgeFeeData } = feeData[FeeType.METABRIDGE][0]; + + return { + bridges: protocols, + bridgeId: aggregator, + protocols, + aggregator, + srcChainId, + destChainId, + srcAsset: toBridgeAssetV1(src.asset), + destAsset: toBridgeAssetV1(dest.asset), + srcTokenAmount: src.amount, + destTokenAmount: dest.amount, + minDestTokenAmount: dest.minAmount ?? dest.amount, + feeData: { + [FeeType.METABRIDGE]: { + ...metabridgeFeeData, + asset: toBridgeAssetV1(metabridgeFeeData.asset), + }, + ...(feeData[FeeType.TX_FEE]?.length && /* istanbul ignore next */ { + [FeeType.TX_FEE]: { + ...feeData[FeeType.TX_FEE][0], + asset: toBridgeAssetV1(feeData[FeeType.TX_FEE][0].asset), + }, + }), + }, + ...(dest.walletAddress && /* istanbul ignore next */ { + destWalletAddress: dest.walletAddress, + }), + ...(src.walletAddress && /* istanbul ignore next */ { + walletAddress: src.walletAddress, + }), + ...(priceData?.priceImpact?.amount && /* istanbul ignore next */ { + priceData: { + priceImpact: priceData.priceImpact.amount, + }, + }), + ...(intent && /* istanbul ignore next */ { intent }), + /** + * @deprecated This field is deprecated. + */ + steps: steps?.map(toStepV1), + ...restQuote, + }; +}); + +const toQuoteV1 = (quote: Infer): Quote => { + const quoteV2 = create(quote, QuoteV1FromV2); + return quoteV2; +}; + +const QuoteResponseV1FromV2 = coerce( + QuoteResponseSchemaV1, + QuoteResponseSchemaV2, + (value: QuoteResponse | null) => { + if (!value) { + return null; + } + const { + quote, + estimatedProcessingTimeInSeconds, + approval, + // @ts-expect-error - Some networks don't have an approval field + resetApproval, + featureId, + trade, + quoteRequestIndex, + nonEvmFeesInNative, + l1GasFeesInHexWei, + quoteId, + } = value; + + const quoteV1 = toQuoteV1(quote); + return { + estimatedProcessingTimeInSeconds, + approval, + trade, + quote: quoteV1, + ...(featureId && /* istanbul ignore next */ { featureId }), + ...(quoteId && /* istanbul ignore next */ { quoteId }), + ...(resetApproval && /* istanbul ignore next */ { resetApproval }), + ...(quoteRequestIndex !== undefined && /* istanbul ignore next */ { + quoteRequestIndex, + }), + ...(nonEvmFeesInNative && /* istanbul ignore next */ { + nonEvmFeesInNative, + }), + ...(l1GasFeesInHexWei && /* istanbul ignore next */ { + l1GasFeesInHexWei, + }), + }; + }, +); + +/** + * Converts a {@link QuoteResponse} to a {@link QuoteResponseV1} for backwards compatibility. + * This does not preserve any post-fetch {@link QuoteMetadata}. + * + * @deprecated Avoid introducing new code that uses this function. It is only for backwards compatibility with the old quote response format. + * @param quoteResponse - The {@link QuoteResponse} to convert + * @returns The {@link QuoteResponseV1} + */ +export const toQuoteResponseV1 = ( + quoteResponse: + | QuoteResponse + | (QuoteResponseV1 & QuoteMetadata) + | QuoteResponseV1, +): QuoteResponseV1 & QuoteMetadata => { + let errorMessage = 'Failed to convert'; + + // V1 quote + if (is(quoteResponse, QuoteResponseSchemaV1)) { + errorMessage += ' unmodified QuoteResponseV1'; + return quoteResponse as QuoteResponseV1 & QuoteMetadata; + } + + try { + // V2 with namespace, chainId, maybe QuoteMetadata + if (is(quoteResponse, QuoteResponseSchemaV2)) { + errorMessage += ' QuoteResponseV2 + metadata to QuoteResponseV1'; + const quoteResponseV1 = create(quoteResponse, QuoteResponseV1FromV2); + return quoteResponseV1; + } + + // V2 with no namespace, chainId + errorMessage += ' QuoteResponseV2 to QuoteResponseV1'; + return create(quoteResponse, QuoteResponseV1FromV2); + } catch (error) { + /* istanbul ignore next */ + let errorDetails = error instanceof Error ? error.message : 'Unknown error'; + + /* istanbul ignore next */ + if (error instanceof StructError) { + const formattedErrors = formatStructErrors(error); + errorDetails = JSON.stringify(formattedErrors, null, 2); + console.warn(errorMessage, formatStructErrors(error)); + } + + throw new Error(`${errorMessage}. ${errorDetails}`); + } +}; diff --git a/packages/bridge-controller/src/constants/bridge.ts b/packages/bridge-controller/src/constants/bridge.ts new file mode 100644 index 00000000000..5f2f0d9a128 --- /dev/null +++ b/packages/bridge-controller/src/constants/bridge.ts @@ -0,0 +1,112 @@ +import { AddressZero } from '@ethersproject/constants'; +import { BtcScope, SolScope, TrxScope, XlmScope } from '@metamask/keyring-api'; +import type { Hex } from '@metamask/utils'; + +import type { + BridgeControllerState, + FeatureFlagsPlatformConfig, +} from '../types.js'; +import { CHAIN_IDS } from './chains.js'; + +export const ALLOWED_BRIDGE_CHAIN_IDS = [ + CHAIN_IDS.MAINNET, + CHAIN_IDS.BSC, + CHAIN_IDS.POLYGON, + CHAIN_IDS.ZKSYNC_ERA, + CHAIN_IDS.AVALANCHE, + CHAIN_IDS.OPTIMISM, + CHAIN_IDS.ARBITRUM, + CHAIN_IDS.LINEA_MAINNET, + CHAIN_IDS.BASE, + CHAIN_IDS.SEI, + CHAIN_IDS.MONAD, + CHAIN_IDS.HYPEREVM, + CHAIN_IDS.MEGAETH, + CHAIN_IDS.ARC, + CHAIN_IDS.ROBINHOOD, + SolScope.Mainnet, + BtcScope.Mainnet, + TrxScope.Mainnet, + XlmScope.Pubnet, +] as const; + +export type AllowedBridgeChainIds = (typeof ALLOWED_BRIDGE_CHAIN_IDS)[number]; + +export const BRIDGE_DEV_API_BASE_URL = 'https://bridge.dev-api.cx.metamask.io'; +export const BRIDGE_UAT_API_BASE_URL = 'https://bridge.uat-api.cx.metamask.io'; +export const BRIDGE_PROD_API_BASE_URL = 'https://bridge.api.cx.metamask.io'; + +export enum BridgeClientId { + EXTENSION = 'extension', + MOBILE = 'mobile', +} + +export const ETH_USDT_ADDRESS = '0xdac17f958d2ee523a2206206994597c13d831ec7'; +export const METABRIDGE_ETHEREUM_ADDRESS = + '0x0439e60F02a8900a951603950d8D4527f400C3f1'; +export const BRIDGE_QUOTE_MAX_ETA_SECONDS = 60 * 60; // 1 hour +export const BRIDGE_QUOTE_MAX_RETURN_DIFFERENCE_PERCENTAGE = 0.5; // if a quote returns in x times less return than the best quote, ignore it + +export const BRIDGE_PREFERRED_GAS_ESTIMATE = 'medium'; +export const BRIDGE_MM_FEE_RATE = 0.875; +export const REFRESH_INTERVAL_MS = 30 * 1000; +export const DEFAULT_MAX_REFRESH_COUNT = 5; + +export const BRIDGE_CONTROLLER_NAME = 'BridgeController'; + +export const DEFAULT_CHAIN_RANKING = [ + { chainId: 'eip155:1', name: 'Ethereum' }, + { chainId: 'eip155:56', name: 'BNB' }, + { chainId: 'bip122:000000000019d6689c085ae165831e93', name: 'BTC' }, + { chainId: 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp', name: 'Solana' }, + { chainId: 'tron:728126428', name: 'Tron' }, + // { chainId: 'stellar:pubnet', name: 'Stellar' }, // Disabled until further notice + { chainId: 'eip155:8453', name: 'Base' }, + { chainId: 'eip155:42161', name: 'Arbitrum' }, + { chainId: 'eip155:59144', name: 'Linea' }, + { chainId: 'eip155:137', name: 'Polygon' }, + { chainId: 'eip155:43114', name: 'Avalanche' }, + { chainId: 'eip155:10', name: 'Optimism' }, + { chainId: 'eip155:143', name: 'Monad' }, + { chainId: 'eip155:1329', name: 'Sei' }, + { chainId: 'eip155:999', name: 'HyperEVM' }, + { chainId: 'eip155:4326', name: 'MegaETH' }, + // { chainId: 'eip155:5042', name: 'Arc' }, // Disabled until further notice + { chainId: 'eip155:4663', name: 'Robinhood Chain' }, + { chainId: 'eip155:324', name: 'zkSync' }, +] as const; + +export const DEFAULT_FEATURE_FLAG_CONFIG: FeatureFlagsPlatformConfig = { + minimumVersion: '0.0.0', + refreshRate: REFRESH_INTERVAL_MS, + maxRefreshCount: DEFAULT_MAX_REFRESH_COUNT, + support: false, + chains: {}, + chainRanking: [...DEFAULT_CHAIN_RANKING], +}; + +export const DEFAULT_BRIDGE_CONTROLLER_STATE: BridgeControllerState = { + quoteRequest: [ + { + srcTokenAddress: AddressZero, + }, + ], + quotesInitialLoadTime: null, + quotes: [], + quotesLastFetched: null, + quotesLoadingStatus: null, + quoteFetchError: null, + quotesRefreshCount: 0, + assetExchangeRates: {}, + minimumBalanceForRentExemptionInLamports: '0', + tokenWarnings: [], + tokenSecurityTypeDestination: null, + inputPrimaryDenomination: 'token_amount', + quoteStreamComplete: null, + batchSellTrades: null, + batchSellTradesLoadingStatus: null, +}; + +export const METABRIDGE_CHAIN_TO_ADDRESS_MAP: Record = { + [CHAIN_IDS.MAINNET]: METABRIDGE_ETHEREUM_ADDRESS, +}; diff --git a/packages/bridge-controller/src/constants/chains.ts b/packages/bridge-controller/src/constants/chains.ts new file mode 100644 index 00000000000..6762581d494 --- /dev/null +++ b/packages/bridge-controller/src/constants/chains.ts @@ -0,0 +1,174 @@ +/** + * An object containing all of the chain ids for networks both built in and + * those that we have added custom code to support our feature set. + */ +export const CHAIN_IDS = { + MAINNET: '0x1', + GOERLI: '0x5', + LOCALHOST: '0x539', + BSC: '0x38', + BSC_TESTNET: '0x61', + OPTIMISM: '0xa', + OPTIMISM_TESTNET: '0xaa37dc', + OPTIMISM_GOERLI: '0x1a4', + BASE: '0x2105', + BASE_TESTNET: '0x14a33', + OPBNB: '0xcc', + OPBNB_TESTNET: '0x15eb', + POLYGON: '0x89', + POLYGON_TESTNET: '0x13881', + AVALANCHE: '0xa86a', + AVALANCHE_TESTNET: '0xa869', + FANTOM: '0xfa', + FANTOM_TESTNET: '0xfa2', + CELO: '0xa4ec', + ARBITRUM: '0xa4b1', + HARMONY: '0x63564c40', + PALM: '0x2a15c308d', + SEPOLIA: '0xaa36a7', + HOLESKY: '0x4268', + LINEA_GOERLI: '0xe704', + LINEA_SEPOLIA: '0xe705', + AMOY: '0x13882', + BASE_SEPOLIA: '0x14a34', + BLAST_SEPOLIA: '0xa0c71fd', + OPTIMISM_SEPOLIA: '0xaa37dc', + PALM_TESTNET: '0x2a15c3083', + CELO_TESTNET: '0xaef3', + ZK_SYNC_ERA_TESTNET: '0x12c', + MANTA_SEPOLIA: '0x138b', + UNICHAIN_SEPOLIA: '0x515', + LINEA_MAINNET: '0xe708', + AURORA: '0x4e454152', + MOONBEAM: '0x504', + MOONBEAM_TESTNET: '0x507', + MOONRIVER: '0x505', + CRONOS: '0x19', + GNOSIS: '0x64', + ZKSYNC_ERA: '0x144', + TEST_ETH: '0x539', + ARBITRUM_GOERLI: '0x66eed', + BLAST: '0x13e31', + FILECOIN: '0x13a', + POLYGON_ZKEVM: '0x44d', + SCROLL: '0x82750', + SCROLL_SEPOLIA: '0x8274f', + WETHIO: '0x4e', + CHZ: '0x15b38', + NUMBERS: '0x290b', + SEI: '0x531', + APE_TESTNET: '0x8157', + APE_MAINNET: '0x8173', + BERACHAIN: '0x138d5', + METACHAIN_ONE: '0x1b6e6', + ARBITRUM_SEPOLIA: '0x66eee', + NEAR: '0x18d', + NEAR_TESTNET: '0x18e', + B3: '0x208d', + B3_TESTNET: '0x7c9', + GRAVITY_ALPHA_MAINNET: '0x659', + GRAVITY_ALPHA_TESTNET_SEPOLIA: '0x34c1', + LISK: '0x46f', + LISK_SEPOLIA: '0x106a', + INK_SEPOLIA: '0xba5eD', + INK: '0xdef1', + MODE_SEPOLIA: '0x397', + MODE: '0x868b', + MONAD: '0x8f', + HYPEREVM: '0x3e7', + MEGAETH: '0x10e6', + ARC: '0x13b2', + ROBINHOOD: '0x1237', +} as const; + +export const NETWORK_TYPES = { + GOERLI: 'goerli', + LOCALHOST: 'localhost', + MAINNET: 'mainnet', + SEPOLIA: 'sepolia', + LINEA_GOERLI: 'linea-goerli', + LINEA_SEPOLIA: 'linea-sepolia', + LINEA_MAINNET: 'linea-mainnet', +} as const; + +export const MAINNET_DISPLAY_NAME = 'Ethereum Mainnet'; +export const GOERLI_DISPLAY_NAME = 'Goerli'; +export const SEPOLIA_DISPLAY_NAME = 'Sepolia'; +export const LINEA_GOERLI_DISPLAY_NAME = 'Linea Goerli'; +export const LINEA_SEPOLIA_DISPLAY_NAME = 'Linea Sepolia'; +export const LINEA_MAINNET_DISPLAY_NAME = 'Linea Mainnet'; +export const LOCALHOST_DISPLAY_NAME = 'Localhost 8545'; +export const BSC_DISPLAY_NAME = 'Binance Smart Chain'; +export const POLYGON_DISPLAY_NAME = 'Polygon'; +export const AVALANCHE_DISPLAY_NAME = 'Avalanche Network C-Chain'; +export const ARBITRUM_DISPLAY_NAME = 'Arbitrum One'; +export const BNB_DISPLAY_NAME = 'BNB Chain'; +export const OPTIMISM_DISPLAY_NAME = 'OP Mainnet'; +export const FANTOM_DISPLAY_NAME = 'Fantom Opera'; +export const HARMONY_DISPLAY_NAME = 'Harmony Mainnet Shard 0'; +export const PALM_DISPLAY_NAME = 'Palm'; +export const CELO_DISPLAY_NAME = 'Celo Mainnet'; +export const GNOSIS_DISPLAY_NAME = 'Gnosis'; +export const ZK_SYNC_ERA_DISPLAY_NAME = 'zkSync Era Mainnet'; +export const BASE_DISPLAY_NAME = 'Base Mainnet'; +export const AURORA_DISPLAY_NAME = 'Aurora Mainnet'; +export const CRONOS_DISPLAY_NAME = 'Cronos'; +export const POLYGON_ZKEVM_DISPLAY_NAME = 'Polygon zkEVM'; +export const MOONBEAM_DISPLAY_NAME = 'Moonbeam'; +export const MOONRIVER_DISPLAY_NAME = 'Moonriver'; +export const SCROLL_DISPLAY_NAME = 'Scroll'; +export const SCROLL_SEPOLIA_DISPLAY_NAME = 'Scroll Sepolia'; +export const OP_BNB_DISPLAY_NAME = 'opBNB'; +export const BERACHAIN_DISPLAY_NAME = 'Berachain Artio'; +export const METACHAIN_ONE_DISPLAY_NAME = 'Metachain One Mainnet'; +export const LISK_DISPLAY_NAME = 'Lisk'; +export const LISK_SEPOLIA_DISPLAY_NAME = 'Lisk Sepolia'; +export const INK_SEPOLIA_DISPLAY_NAME = 'Ink Sepolia'; +export const INK_DISPLAY_NAME = 'Ink Mainnet'; +export const SONEIUM_DISPLAY_NAME = 'Soneium Mainnet'; +export const MODE_SEPOLIA_DISPLAY_NAME = 'Mode Sepolia'; +export const MODE_DISPLAY_NAME = 'Mode Mainnet'; +export const SEI_DISPLAY_NAME = 'Sei Network'; +export const MONAD_DISPLAY_NAME = 'Monad'; +export const HYPEREVM_DISPLAY_NAME = 'HyperEVM'; +export const MEGAETH_DISPLAY_NAME = 'MegaETH'; +export const ARC_DISPLAY_NAME = 'Arc'; +export const ROBINHOOD_DISPLAY_NAME = 'Robinhood Chain'; + +export const NETWORK_TO_NAME_MAP = { + [NETWORK_TYPES.GOERLI]: GOERLI_DISPLAY_NAME, + [NETWORK_TYPES.MAINNET]: MAINNET_DISPLAY_NAME, + [NETWORK_TYPES.LINEA_GOERLI]: LINEA_GOERLI_DISPLAY_NAME, + [NETWORK_TYPES.LINEA_SEPOLIA]: LINEA_SEPOLIA_DISPLAY_NAME, + [NETWORK_TYPES.LINEA_MAINNET]: LINEA_MAINNET_DISPLAY_NAME, + [NETWORK_TYPES.LOCALHOST]: LOCALHOST_DISPLAY_NAME, + [NETWORK_TYPES.SEPOLIA]: SEPOLIA_DISPLAY_NAME, + + [CHAIN_IDS.ARBITRUM]: ARBITRUM_DISPLAY_NAME, + [CHAIN_IDS.AVALANCHE]: AVALANCHE_DISPLAY_NAME, + [CHAIN_IDS.BSC]: BSC_DISPLAY_NAME, + [CHAIN_IDS.BASE]: BASE_DISPLAY_NAME, + [CHAIN_IDS.GOERLI]: GOERLI_DISPLAY_NAME, + [CHAIN_IDS.MAINNET]: MAINNET_DISPLAY_NAME, + [CHAIN_IDS.LINEA_GOERLI]: LINEA_GOERLI_DISPLAY_NAME, + [CHAIN_IDS.LINEA_MAINNET]: LINEA_MAINNET_DISPLAY_NAME, + [CHAIN_IDS.LINEA_SEPOLIA]: LINEA_SEPOLIA_DISPLAY_NAME, + [CHAIN_IDS.LOCALHOST]: LOCALHOST_DISPLAY_NAME, + [CHAIN_IDS.OPTIMISM]: OPTIMISM_DISPLAY_NAME, + [CHAIN_IDS.POLYGON]: POLYGON_DISPLAY_NAME, + [CHAIN_IDS.SCROLL]: SCROLL_DISPLAY_NAME, + [CHAIN_IDS.SCROLL_SEPOLIA]: SCROLL_SEPOLIA_DISPLAY_NAME, + [CHAIN_IDS.SEPOLIA]: SEPOLIA_DISPLAY_NAME, + [CHAIN_IDS.OPBNB]: OP_BNB_DISPLAY_NAME, + [CHAIN_IDS.ZKSYNC_ERA]: ZK_SYNC_ERA_DISPLAY_NAME, + [CHAIN_IDS.BERACHAIN]: BERACHAIN_DISPLAY_NAME, + [CHAIN_IDS.METACHAIN_ONE]: METACHAIN_ONE_DISPLAY_NAME, + [CHAIN_IDS.LISK]: LISK_DISPLAY_NAME, + [CHAIN_IDS.LISK_SEPOLIA]: LISK_SEPOLIA_DISPLAY_NAME, + [CHAIN_IDS.SEI]: SEI_DISPLAY_NAME, + [CHAIN_IDS.MONAD]: MONAD_DISPLAY_NAME, + [CHAIN_IDS.HYPEREVM]: HYPEREVM_DISPLAY_NAME, + [CHAIN_IDS.MEGAETH]: MEGAETH_DISPLAY_NAME, + [CHAIN_IDS.ARC]: ARC_DISPLAY_NAME, + [CHAIN_IDS.ROBINHOOD]: ROBINHOOD_DISPLAY_NAME, +} as const; diff --git a/packages/bridge-controller/src/constants/swaps.ts b/packages/bridge-controller/src/constants/swaps.ts new file mode 100644 index 00000000000..652634dc17f --- /dev/null +++ b/packages/bridge-controller/src/constants/swaps.ts @@ -0,0 +1,113 @@ +import type { Hex } from '@metamask/utils'; + +import { CHAIN_IDS } from './chains.js'; + +export const SWAPS_API_V2_BASE_URL = 'https://swap.api.cx.metamask.io'; + +const ETH_SWAPS_CONTRACT_ADDRESS = '0x881d40237659c251811cec9c364ef91dc08d300c'; +const BSC_SWAPS_CONTRACT_ADDRESS = '0x1a1ec25dc08e98e5e93f1104b5e5cdd298707d31'; +const POLYGON_SWAPS_CONTRACT_ADDRESS = + '0x1a1ec25dc08e98e5e93f1104b5e5cdd298707d31'; +const AVALANCHE_SWAPS_CONTRACT_ADDRESS = + '0x1a1ec25dc08e98e5e93f1104b5e5cdd298707d31'; +const ARBITRUM_SWAPS_CONTRACT_ADDRESS = + '0x9dda6ef3d919c9bc8885d5560999a3640431e8e6'; +const OPTIMISM_SWAPS_CONTRACT_ADDRESS = + '0x9dda6ef3d919c9bc8885d5560999a3640431e8e6'; +const ZKSYNC_ERA_SWAPS_CONTRACT_ADDRESS = + '0xf504c1fe13d14df615e66dcd0abf39e60c697f34'; +const LINEA_SWAPS_CONTRACT_ADDRESS = + '0x9dda6ef3d919c9bc8885d5560999a3640431e8e6'; +const BASE_SWAPS_CONTRACT_ADDRESS = + '0x9dda6ef3d919c9bc8885d5560999a3640431e8e6'; +const SEI_SWAPS_CONTRACT_ADDRESS = '0x962287c9d5B8a682389E61edAE90ec882325d08b'; + +export const SWAPS_CONTRACT_ADDRESSES: Record = { + [CHAIN_IDS.MAINNET]: ETH_SWAPS_CONTRACT_ADDRESS, + [CHAIN_IDS.LOCALHOST]: ETH_SWAPS_CONTRACT_ADDRESS, + [CHAIN_IDS.BSC]: BSC_SWAPS_CONTRACT_ADDRESS, + [CHAIN_IDS.POLYGON]: POLYGON_SWAPS_CONTRACT_ADDRESS, + [CHAIN_IDS.AVALANCHE]: AVALANCHE_SWAPS_CONTRACT_ADDRESS, + [CHAIN_IDS.ARBITRUM]: ARBITRUM_SWAPS_CONTRACT_ADDRESS, + [CHAIN_IDS.OPTIMISM]: OPTIMISM_SWAPS_CONTRACT_ADDRESS, + [CHAIN_IDS.ZKSYNC_ERA]: ZKSYNC_ERA_SWAPS_CONTRACT_ADDRESS, + [CHAIN_IDS.LINEA_MAINNET]: LINEA_SWAPS_CONTRACT_ADDRESS, + [CHAIN_IDS.BASE]: BASE_SWAPS_CONTRACT_ADDRESS, + [CHAIN_IDS.SEI]: SEI_SWAPS_CONTRACT_ADDRESS, +}; + +const WETH_CONTRACT_ADDRESS = '0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2'; +const WBNB_CONTRACT_ADDRESS = '0xbb4cdb9cbd36b01bd1cbaebf2de08d9173bc095c'; +const WMATIC_CONTRACT_ADDRESS = '0x0d500b1d8e8ef31e21c99d1db9a6444d3adf1270'; +const WAVAX_CONTRACT_ADDRESS = '0xb31f66aa3c1e785363f0875a1b74e27b85fd66c7'; +const WETH_ARBITRUM_CONTRACT_ADDRESS = + '0x82af49447d8a07e3bd95bd0d56f35241523fbab1'; +const WETH_OPTIMISM_CONTRACT_ADDRESS = + '0x4200000000000000000000000000000000000006'; +const WETH_ZKSYNC_ERA_CONTRACT_ADDRESS = + '0x5AEa5775959fBC2557Cc8789bC1bf90A239D9a91'; +const WETH_LINEA_CONTRACT_ADDRESS = + '0xe5D7C2a44FfDDf6b295A15c148167daaAf5Cf34f'; +const WETH_BASE_CONTRACT_ADDRESS = '0x4200000000000000000000000000000000000006'; +const WSEI_SEI_CONTRACT_ADDRESS = '0xe30fedd158a2e3b13e9badaeabafc5516e95e8c7'; + +export const SWAPS_WRAPPED_TOKENS_ADDRESSES: Record = { + [CHAIN_IDS.MAINNET]: WETH_CONTRACT_ADDRESS, + [CHAIN_IDS.LOCALHOST]: WETH_CONTRACT_ADDRESS, + [CHAIN_IDS.BSC]: WBNB_CONTRACT_ADDRESS, + [CHAIN_IDS.POLYGON]: WMATIC_CONTRACT_ADDRESS, + [CHAIN_IDS.AVALANCHE]: WAVAX_CONTRACT_ADDRESS, + [CHAIN_IDS.ARBITRUM]: WETH_ARBITRUM_CONTRACT_ADDRESS, + [CHAIN_IDS.OPTIMISM]: WETH_OPTIMISM_CONTRACT_ADDRESS, + [CHAIN_IDS.ZKSYNC_ERA]: WETH_ZKSYNC_ERA_CONTRACT_ADDRESS, + [CHAIN_IDS.LINEA_MAINNET]: WETH_LINEA_CONTRACT_ADDRESS, + [CHAIN_IDS.BASE]: WETH_BASE_CONTRACT_ADDRESS, + [CHAIN_IDS.SEI]: WSEI_SEI_CONTRACT_ADDRESS, +}; + +export const ALLOWED_CONTRACT_ADDRESSES: Record = { + [CHAIN_IDS.MAINNET]: [ + SWAPS_CONTRACT_ADDRESSES[CHAIN_IDS.MAINNET], + SWAPS_WRAPPED_TOKENS_ADDRESSES[CHAIN_IDS.MAINNET], + ], + [CHAIN_IDS.LOCALHOST]: [ + SWAPS_CONTRACT_ADDRESSES[CHAIN_IDS.LOCALHOST], + SWAPS_WRAPPED_TOKENS_ADDRESSES[CHAIN_IDS.LOCALHOST], + ], + [CHAIN_IDS.BSC]: [ + SWAPS_CONTRACT_ADDRESSES[CHAIN_IDS.BSC], + SWAPS_WRAPPED_TOKENS_ADDRESSES[CHAIN_IDS.BSC], + ], + [CHAIN_IDS.POLYGON]: [ + SWAPS_CONTRACT_ADDRESSES[CHAIN_IDS.POLYGON], + SWAPS_WRAPPED_TOKENS_ADDRESSES[CHAIN_IDS.POLYGON], + ], + [CHAIN_IDS.AVALANCHE]: [ + SWAPS_CONTRACT_ADDRESSES[CHAIN_IDS.AVALANCHE], + SWAPS_WRAPPED_TOKENS_ADDRESSES[CHAIN_IDS.AVALANCHE], + ], + [CHAIN_IDS.ARBITRUM]: [ + SWAPS_CONTRACT_ADDRESSES[CHAIN_IDS.ARBITRUM], + SWAPS_WRAPPED_TOKENS_ADDRESSES[CHAIN_IDS.ARBITRUM], + ], + [CHAIN_IDS.OPTIMISM]: [ + SWAPS_CONTRACT_ADDRESSES[CHAIN_IDS.OPTIMISM], + SWAPS_WRAPPED_TOKENS_ADDRESSES[CHAIN_IDS.OPTIMISM], + ], + [CHAIN_IDS.ZKSYNC_ERA]: [ + SWAPS_CONTRACT_ADDRESSES[CHAIN_IDS.ZKSYNC_ERA], + SWAPS_WRAPPED_TOKENS_ADDRESSES[CHAIN_IDS.ZKSYNC_ERA], + ], + [CHAIN_IDS.LINEA_MAINNET]: [ + SWAPS_CONTRACT_ADDRESSES[CHAIN_IDS.LINEA_MAINNET], + SWAPS_WRAPPED_TOKENS_ADDRESSES[CHAIN_IDS.LINEA_MAINNET], + ], + [CHAIN_IDS.BASE]: [ + SWAPS_CONTRACT_ADDRESSES[CHAIN_IDS.BASE], + SWAPS_WRAPPED_TOKENS_ADDRESSES[CHAIN_IDS.BASE], + ], + [CHAIN_IDS.SEI]: [ + SWAPS_CONTRACT_ADDRESSES[CHAIN_IDS.SEI], + SWAPS_WRAPPED_TOKENS_ADDRESSES[CHAIN_IDS.SEI], + ], +}; diff --git a/packages/bridge-controller/src/constants/tokens.ts b/packages/bridge-controller/src/constants/tokens.ts new file mode 100644 index 00000000000..3202cd27b5d --- /dev/null +++ b/packages/bridge-controller/src/constants/tokens.ts @@ -0,0 +1,268 @@ +import { BtcScope, SolScope, TrxScope, XlmScope } from '@metamask/keyring-api'; + +import type { AllowedBridgeChainIds } from './bridge.js'; +import { CHAIN_IDS } from './chains.js'; + +export type SwapsTokenObject = { + /** + * The symbol of token object + */ + symbol: string; + /** + * The name for the network + */ + name: string; + /** + * An address that the metaswap-api recognizes as the default token + */ + address: string; + /** + * Number of digits after decimal point + */ + decimals: number; + /** + * URL for token icon + */ + iconUrl: string; +}; + +export const DEFAULT_TOKEN_ADDRESS = + '0x0000000000000000000000000000000000000000'; + +const CURRENCY_SYMBOLS = { + ARBITRUM: 'ETH', + AVALANCHE: 'AVAX', + BNB: 'BNB', + BUSD: 'BUSD', + CELO: 'CELO', + DAI: 'DAI', + GNOSIS: 'XDAI', + ETH: 'ETH', + FANTOM: 'FTM', + HARMONY: 'ONE', + PALM: 'PALM', + MATIC: 'MATIC', + POL: 'POL', + TEST_ETH: 'TESTETH', + USDC: 'USDC', + USDT: 'USDT', + WETH: 'WETH', + OPTIMISM: 'ETH', + CRONOS: 'CRO', + GLIMMER: 'GLMR', + MOONRIVER: 'MOVR', + ONE: 'ONE', + SOL: 'SOL', + SEI: 'SEI', + BTC: 'BTC', + TRX: 'TRX', + MON: 'MON', + HYPE: 'HYPE', + MEGAETH: 'ETH', + XLM: 'XLM', + ARC: 'USDC', + ROBINHOOD: 'ETH', +} as const; + +const ETH_SWAPS_TOKEN_OBJECT = { + symbol: CURRENCY_SYMBOLS.ETH, + name: 'Ether', + address: DEFAULT_TOKEN_ADDRESS, + decimals: 18, + iconUrl: '', +}; + +const BNB_SWAPS_TOKEN_OBJECT = { + symbol: CURRENCY_SYMBOLS.BNB, + name: 'Binance Coin', + address: DEFAULT_TOKEN_ADDRESS, + decimals: 18, + iconUrl: '', +} as const; + +const MATIC_SWAPS_TOKEN_OBJECT = { + symbol: CURRENCY_SYMBOLS.POL, + name: 'Polygon', + address: DEFAULT_TOKEN_ADDRESS, + decimals: 18, + iconUrl: '', +} as const; + +const AVAX_SWAPS_TOKEN_OBJECT = { + symbol: CURRENCY_SYMBOLS.AVALANCHE, + name: 'Avalanche', + address: DEFAULT_TOKEN_ADDRESS, + decimals: 18, + iconUrl: '', +} as const; + +const TEST_ETH_SWAPS_TOKEN_OBJECT = { + symbol: CURRENCY_SYMBOLS.TEST_ETH, + name: 'Test Ether', + address: DEFAULT_TOKEN_ADDRESS, + decimals: 18, + iconUrl: '', +} as const; + +const GOERLI_SWAPS_TOKEN_OBJECT = { + symbol: CURRENCY_SYMBOLS.ETH, + name: 'Ether', + address: DEFAULT_TOKEN_ADDRESS, + decimals: 18, + iconUrl: '', +} as const; + +const SEPOLIA_SWAPS_TOKEN_OBJECT = { + symbol: CURRENCY_SYMBOLS.ETH, + name: 'Ether', + address: DEFAULT_TOKEN_ADDRESS, + decimals: 18, + iconUrl: '', +} as const; + +const ARBITRUM_SWAPS_TOKEN_OBJECT = { + ...ETH_SWAPS_TOKEN_OBJECT, +} as const; + +const OPTIMISM_SWAPS_TOKEN_OBJECT = { + ...ETH_SWAPS_TOKEN_OBJECT, +} as const; + +const ZKSYNC_ERA_SWAPS_TOKEN_OBJECT = { + ...ETH_SWAPS_TOKEN_OBJECT, +} as const; + +const LINEA_SWAPS_TOKEN_OBJECT = { + ...ETH_SWAPS_TOKEN_OBJECT, +} as const; + +const BASE_SWAPS_TOKEN_OBJECT = { + ...ETH_SWAPS_TOKEN_OBJECT, +} as const; + +const SOLANA_SWAPS_TOKEN_OBJECT = { + symbol: CURRENCY_SYMBOLS.SOL, + name: 'Solana', + address: DEFAULT_TOKEN_ADDRESS, + decimals: 9, + iconUrl: '', +} as const; + +const BTC_SWAPS_TOKEN_OBJECT = { + symbol: CURRENCY_SYMBOLS.BTC, + name: 'Bitcoin', + address: DEFAULT_TOKEN_ADDRESS, + decimals: 8, + iconUrl: '', +} as const; + +const SEI_SWAPS_TOKEN_OBJECT = { + symbol: CURRENCY_SYMBOLS.SEI, + name: 'Sei', + address: DEFAULT_TOKEN_ADDRESS, + decimals: 18, + iconUrl: '', +} as const; + +const TRX_SWAPS_TOKEN_OBJECT = { + symbol: CURRENCY_SYMBOLS.TRX, + name: 'Tron', + address: DEFAULT_TOKEN_ADDRESS, + decimals: 6, + iconUrl: '', +} as const; + +const XLM_SWAPS_TOKEN_OBJECT = { + symbol: CURRENCY_SYMBOLS.XLM, + name: 'Stellar Lumens', + address: DEFAULT_TOKEN_ADDRESS, + decimals: 7, + iconUrl: '', +} as const; + +const MONAD_SWAPS_TOKEN_OBJECT = { + symbol: CURRENCY_SYMBOLS.MON, + name: 'Mon', + address: DEFAULT_TOKEN_ADDRESS, + decimals: 18, + iconUrl: '', +} as const; + +const HYPEREVM_SWAPS_TOKEN_OBJECT = { + symbol: CURRENCY_SYMBOLS.HYPE, + name: 'Hyperliquid', + address: DEFAULT_TOKEN_ADDRESS, + decimals: 18, + iconUrl: '', +} as const; + +const MEGAETH_SWAPS_TOKEN_OBJECT = { + ...ETH_SWAPS_TOKEN_OBJECT, +} as const; + +const ROBINHOOD_SWAPS_TOKEN_OBJECT = { + ...ETH_SWAPS_TOKEN_OBJECT, +} as const; + +// Leaving for code consistency but we won't display it in the asset picker +const ARC_SWAPS_TOKEN_OBJECT = { + symbol: 'USDC', + name: 'USDC', + address: DEFAULT_TOKEN_ADDRESS, + decimals: 18, + iconUrl: '', +} as const; + +export const SWAPS_CHAINID_DEFAULT_TOKEN_MAP = { + [CHAIN_IDS.MAINNET]: ETH_SWAPS_TOKEN_OBJECT, + [CHAIN_IDS.LOCALHOST]: TEST_ETH_SWAPS_TOKEN_OBJECT, + [CHAIN_IDS.BSC]: BNB_SWAPS_TOKEN_OBJECT, + [CHAIN_IDS.POLYGON]: MATIC_SWAPS_TOKEN_OBJECT, + [CHAIN_IDS.GOERLI]: GOERLI_SWAPS_TOKEN_OBJECT, + [CHAIN_IDS.SEPOLIA]: SEPOLIA_SWAPS_TOKEN_OBJECT, + [CHAIN_IDS.AVALANCHE]: AVAX_SWAPS_TOKEN_OBJECT, + [CHAIN_IDS.OPTIMISM]: OPTIMISM_SWAPS_TOKEN_OBJECT, + [CHAIN_IDS.ARBITRUM]: ARBITRUM_SWAPS_TOKEN_OBJECT, + [CHAIN_IDS.ZKSYNC_ERA]: ZKSYNC_ERA_SWAPS_TOKEN_OBJECT, + [CHAIN_IDS.LINEA_MAINNET]: LINEA_SWAPS_TOKEN_OBJECT, + [CHAIN_IDS.BASE]: BASE_SWAPS_TOKEN_OBJECT, + [CHAIN_IDS.SEI]: SEI_SWAPS_TOKEN_OBJECT, + [CHAIN_IDS.MONAD]: MONAD_SWAPS_TOKEN_OBJECT, + [CHAIN_IDS.HYPEREVM]: HYPEREVM_SWAPS_TOKEN_OBJECT, + [CHAIN_IDS.MEGAETH]: MEGAETH_SWAPS_TOKEN_OBJECT, + [CHAIN_IDS.ARC]: ARC_SWAPS_TOKEN_OBJECT, + [CHAIN_IDS.ROBINHOOD]: ROBINHOOD_SWAPS_TOKEN_OBJECT, + [SolScope.Mainnet]: SOLANA_SWAPS_TOKEN_OBJECT, + [SolScope.Devnet]: SOLANA_SWAPS_TOKEN_OBJECT, + [BtcScope.Mainnet]: BTC_SWAPS_TOKEN_OBJECT, + [TrxScope.Mainnet]: TRX_SWAPS_TOKEN_OBJECT, + [XlmScope.Pubnet]: XLM_SWAPS_TOKEN_OBJECT, +} as const; + +export type SupportedSwapsNativeCurrencySymbols = + (typeof SWAPS_CHAINID_DEFAULT_TOKEN_MAP)[ + | AllowedBridgeChainIds + | typeof CHAIN_IDS.LOCALHOST]['symbol']; + +/** + * A map of native currency symbols to their SLIP-44 representation + * From {@link https://github.com/satoshilabs/slips/blob/master/slip-0044.md} + */ +export const SYMBOL_TO_SLIP44_MAP: Record< + SupportedSwapsNativeCurrencySymbols, + `${string}:${string}` +> = { + SOL: 'slip44:501', + BTC: 'slip44:0', + ETH: 'slip44:60', + POL: 'slip44:966', + BNB: 'slip44:714', + AVAX: 'slip44:9005', + TESTETH: 'slip44:60', + SEI: 'slip44:19000118', + TRX: 'slip44:195', + XLM: 'slip44:148', + MON: 'slip44:268435779', + HYPE: 'slip44:2457', + USDC: 'slip44:5042', +}; diff --git a/packages/bridge-controller/src/constants/traces.ts b/packages/bridge-controller/src/constants/traces.ts new file mode 100644 index 00000000000..9a61707ce5a --- /dev/null +++ b/packages/bridge-controller/src/constants/traces.ts @@ -0,0 +1,6 @@ +export enum TraceName { + BatchSellQuotesFetched = 'Batch Sell Quotes Fetched', + BridgeQuotesFetched = 'Bridge Quotes Fetched', + QuoteProviderFirstResult = 'Quote Provider First Result', + SwapQuotesFetched = 'Swap Quotes Fetched', +} diff --git a/packages/bridge-controller/src/index.ts b/packages/bridge-controller/src/index.ts new file mode 100644 index 00000000000..edadc586175 --- /dev/null +++ b/packages/bridge-controller/src/index.ts @@ -0,0 +1,267 @@ +export { BridgeController } from './bridge-controller.js'; + +export { + BatchSellMetricsEventName, + UnifiedSwapBridgeEventName, + BATCH_SELL_EVENT_CATEGORY, + UNIFIED_SWAP_BRIDGE_EVENT_CATEGORY, + BatchSellMetricsLocation, + InputAmountPreset, + MetaMetricsSwapsEventSource, + PollingStatus, +} from './utils/metrics/constants.js'; + +export type { BridgeControllerMetricsEventName } from './utils/metrics/constants.js'; +export type { BridgeControllerMetricsLocation } from './utils/metrics/constants.js'; + +export type { + AccountHardwareType, + RequiredEventContextFromClient, + CrossChainSwapsEventProperties, + TradeData, + RequestParams, + RequestMetadata, + TxStatusData, + QuoteFetchData, + QuoteWarning, + InputPrimaryDenominationData, +} from './utils/metrics/types.js'; + +export { + getAccountHardwareType, + formatProviderLabel, + getRequestParams, + getSwapType, + isHardwareWallet, + isCustomSlippage, + getQuotesReceivedProperties, +} from './utils/metrics/properties.js'; + +export type { + ChainConfiguration, + L1GasFees, + NonEvmFees, + GasMultiplierByChainId, + FeatureFlagResponse, + GenericQuoteRequest, + BatchSellTradesResponse, + GaslessProperties, + SimulatedGasFeeLimits, + Step, + RefuelData, + FeeData, + Intent, + IntentOrderLike, + BridgeControllerState, + InputPrimaryDenomination, + BridgeControllerAction, + BridgeControllerActions, + BridgeControllerEvents, + BridgeControllerMessenger, + FeatureFlagsPlatformConfig, + TxFeeGasLimits, + TokenFeature, + QuoteStreamCompleteData, + BridgeControllerGetStateAction, + BridgeControllerStateChangeEvent, + DeepPartial, +} from './types.js'; + +export { + type QuoteMetadata, + type TokenAmountValues, + QuoteMetadataMigrationPhase, +} from './utils/quote-metadata/types.js'; +export { + validateQuoteResponseV1, + QuoteResponseSchemaV1, + type QuoteResponseV1, +} from './validators/quote-response-v1.js'; +export { mergeQuoteMetadata } from './utils/quote-metadata/merge.js'; + +export { + AssetType, + SortOrder, + ChainId, + RequestStatus, + StatusTypes, +} from './types.js'; + +export type { + BridgeControllerUpdateBridgeQuoteRequestParamsAction, + BridgeControllerFetchQuotesAction, + BridgeControllerStopPollingForQuotesAction, + BridgeControllerSetLocationAction, + BridgeControllerGetLocationAction, + BridgeControllerSetInputPrimaryDenominationAction, + BridgeControllerResetStateAction, + BridgeControllerSetChainIntervalLengthAction, + BridgeControllerTrackUnifiedSwapBridgeEventAction, + BridgeControllerUpdateBatchSellTradesAction, +} from './bridge-controller-method-action-types.js'; + +export { AbortReason } from './utils/metrics/constants.js'; + +export type { + TxData, + BitcoinTradeData, + TronTradeData, + StellarTradeData, + Trade, +} from './validators/trade.js'; +export { + isBitcoinTrade, + isTronTrade, + isEvmTxData, + isStellarTrade, +} from './validators/trade.js'; +export { + validateQuoteResponse, + type QuoteResponse, + isQuoteResponseV2, +} from './validators/quote-response.js'; +export type { Quote } from './validators/quote.js'; +export { FeeType, DiscountType } from './validators/quote.js'; +export { ActionTypes } from './validators/step.js'; +export { toQuoteResponseV1 } from './coercers/quote-response-v2-to-v1.js'; +export { toQuoteResponseV2 } from './coercers/quote-response-v1-to-v2.js'; + +export { toQuoteMetadataV1 } from './utils/quote-metadata/to-quote-metadata-v1.js'; +export { toQuoteMetadataV2 } from './utils/quote-metadata/to-quote-metadata-v2.js'; + +export { sumAmounts } from './utils/number-formatters.js'; +export { assetIdsMatch } from './utils/assets.js'; + +export { + validateQuoteStreamComplete, + QuoteStreamCompleteReason, +} from './validators/quote-stream-complete.js'; +export { BatchSellTransactionType } from './validators/batch-sell.js'; +export { AmountsAndAssetSchema } from './validators/amount-and-asset.js'; +export { TokenFeatureType } from './validators/token-feature.js'; +export type { + BridgeAsset, + BridgeAssetV2, + MinimalAsset, +} from './validators/bridge-asset.js'; +export { + BridgeAssetSchema, + validateBridgeAsset, + validateBridgeAssetV2, + MinimalAssetSchema, + BridgeAssetV2Schema, + BridgeAssetSecurityDataType, +} from './validators/bridge-asset.js'; +export { FeatureId } from './validators/feature-flags.js'; +export { toBridgeAssetV2 } from './coercers/quote-response-v1-to-v2.js'; + +export { + ALLOWED_BRIDGE_CHAIN_IDS, + BridgeClientId, + BRIDGE_CONTROLLER_NAME, + BRIDGE_QUOTE_MAX_ETA_SECONDS, + BRIDGE_QUOTE_MAX_RETURN_DIFFERENCE_PERCENTAGE, + BRIDGE_PREFERRED_GAS_ESTIMATE, + BRIDGE_MM_FEE_RATE, + REFRESH_INTERVAL_MS, + DEFAULT_MAX_REFRESH_COUNT, + DEFAULT_BRIDGE_CONTROLLER_STATE, + METABRIDGE_CHAIN_TO_ADDRESS_MAP, + BRIDGE_DEV_API_BASE_URL, + BRIDGE_UAT_API_BASE_URL, + BRIDGE_PROD_API_BASE_URL, +} from './constants/bridge.js'; + +export type { AllowedBridgeChainIds } from './constants/bridge.js'; + +export { + /** + * @deprecated This type should not be used. Use {@link BridgeAsset} instead. + */ + type SwapsTokenObject, + /** + * @deprecated This map should not be used. Use getNativeAssetForChainId" } instead. + */ + SWAPS_CHAINID_DEFAULT_TOKEN_MAP, +} from './constants/tokens.js'; + +export { + SWAPS_API_V2_BASE_URL, + SWAPS_CONTRACT_ADDRESSES, + SWAPS_WRAPPED_TOKENS_ADDRESSES, + ALLOWED_CONTRACT_ADDRESSES, +} from './constants/swaps.js'; + +export { + MetricsActionType, + MetricsSwapType, +} from './utils/metrics/constants.js'; + +export { + isEthUsdt, + isNativeAddress, + isSolanaChainId, + isBitcoinChainId, + isTronChainId, + isStellarChainId, + isNonEvmChainId, + getNativeAssetForChainId, + getDefaultBridgeControllerState, + isCrossChain, +} from './utils/bridge.js'; + +export { + isValidQuoteRequest, + isValidBatchSellQuoteRequest, +} from './validators/quote-request.js'; + +export { + calcSlippagePercentage, + calcQuoteMetadata, +} from './utils/quote-metadata/calculators.js'; + +export { calcLatestSrcBalance } from './utils/balance.js'; + +export { + fetchBridgeTokens, + getClientHeaders, + fetchBridgeQuoteStream, +} from './utils/fetch.js'; + +export { appendFeesToQuotes } from './utils/quote-fees.js'; + +export { + formatChainIdToCaip, + formatChainIdToHex, + formatAddressToCaipReference, + formatAddressToAssetId, + formatChainIdToDec, +} from './utils/caip-formatters.js'; + +export { extractTradeData } from './utils/trade-utils.js'; + +export { + selectBridgeQuotes, + selectBatchSellQuotes, + selectBatchSellTrades, + selectDefaultSlippagePercentage, + type BridgeAppState, + selectExchangeRateByAssetId, + selectIsQuoteExpired, + selectBridgeFeatureFlags, + selectMinimumBalanceForRentExemptionInSOL, + selectTokenWarnings, +} from './selectors.js'; + +export { DEFAULT_FEATURE_FLAG_CONFIG } from './constants/bridge.js'; + +export { getBridgeFeatureFlags } from './utils/feature-flags.js'; + +export { BRIDGE_DEFAULT_SLIPPAGE } from './utils/slippage.js'; + +export { + isValidSwapsContractAddress, + getSwapsContractAddress, + fetchTokens, + type SwapsToken, +} from './utils/swaps.js'; diff --git a/packages/bridge-controller/src/selectors.test.ts b/packages/bridge-controller/src/selectors.test.ts new file mode 100644 index 00000000000..39983e7caaa --- /dev/null +++ b/packages/bridge-controller/src/selectors.test.ts @@ -0,0 +1,2979 @@ +import { getAddress } from '@ethersproject/address'; +import type { MarketDataDetails } from '@metamask/assets-controllers'; +import { toHex } from '@metamask/controller-utils'; +import { SolScope } from '@metamask/keyring-api'; +import { + KnownCaipNamespace, + parseCaipAssetType, + parseCaipChainId, +} from '@metamask/utils'; +import { BigNumber } from 'bignumber.js'; +import { merge } from 'lodash'; + +import { mockBridgeQuotesErc20Erc20V1 } from '../tests/mock-quotes-erc20-erc20.js'; +import { + getMockBridgeQuotesNativeErc20V2, + mockBridgeQuotesNativeErc20V1, +} from '../tests/mock-quotes-native-erc20.js'; +import { toQuoteResponseV2 } from './coercers/quote-response-v1-to-v2.js'; +import { toBridgeAssetV2 } from './coercers/quote-response-v1-to-v2.js'; +import { DEFAULT_CHAIN_RANKING, ETH_USDT_ADDRESS } from './constants/bridge.js'; +import type { BridgeAppState } from './selectors.js'; +import { + selectExchangeRateByAssetId, + selectIsAssetExchangeRateInState, + selectBridgeQuotes, + selectIsQuoteExpired, + selectBridgeFeatureFlags, + selectMinimumBalanceForRentExemptionInSOL, + selectDefaultSlippagePercentage, + selectTokenWarnings, + selectBatchSellQuotes, + selectBatchSellTrades, +} from './selectors.js'; +import { SortOrder, RequestStatus, ChainId, NonEvmFees } from './types.js'; +import type { DeepPartial } from './types.js'; +import { getNativeAssetForChainId, isNativeAddress } from './utils/bridge.js'; +import { + formatAddressToAssetId, + formatAddressToCaipReference, + formatChainIdToCaip, + formatChainIdToDec, + formatChainIdToHex, +} from './utils/caip-formatters.js'; +import { calcQuoteMetadata } from './utils/quote-metadata/calculators.js'; +import { mergeQuoteMetadata } from './utils/quote-metadata/merge.js'; +import { toQuoteMetadataV1 } from './utils/quote-metadata/to-quote-metadata-v1.js'; +import { QuoteMetadataMigrationPhase } from './utils/quote-metadata/types.js'; +import { BatchSellTransactionType } from './validators/batch-sell.js'; +import type { BridgeAssetV2 } from './validators/bridge-asset.js'; +import { validateQuoteResponseV1 } from './validators/quote-response-v1.js'; +import type { QuoteResponse } from './validators/quote-response.js'; + +const MOCK_USDC_ADDRESS = '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48'; +const MOCK_MUSD_ADDRESS = '0x12345A7890123456789012345678901234567890'; + +describe('Bridge Selectors', () => { + describe('selectExchangeRateByAssetId', () => { + const mockExchangeRateSources = { + assetExchangeRates: { + [formatAddressToAssetId(MOCK_USDC_ADDRESS, '1')?.toLowerCase() ?? + MOCK_USDC_ADDRESS]: { + exchangeRate: '2.5', + usdExchangeRate: '1.5', + }, + 'solana:101/token:456': { + exchangeRate: '3.0', + }, + }, + currencyRates: { + ETH: { + conversionRate: 2468.12, // ETH rate in the user's selected currency + usdConversionRate: 1800, // ETH rate in USD + }, + }, + marketData: { + '0x1': { + [MOCK_MUSD_ADDRESS]: { + price: 50 / 2468.12, + currency: 'ETH', + }, + }, + }, + conversionRates: { + [`${SolScope.Mainnet}/token:789`]: { + rate: '4.0', + }, + }, + } as unknown as BridgeAppState; + + it('should return empty object if chainId or address is missing', () => { + expect( + selectExchangeRateByAssetId(mockExchangeRateSources, undefined), + ).toStrictEqual({}); + expect( + selectExchangeRateByAssetId( + mockExchangeRateSources, + formatAddressToAssetId(MOCK_USDC_ADDRESS), + ), + ).toStrictEqual({}); + }); + + it('should return bridge controller rate if available', () => { + const result = selectExchangeRateByAssetId( + mockExchangeRateSources, + formatAddressToAssetId(MOCK_USDC_ADDRESS, '1'), + ); + expect(result).toStrictEqual({ + exchangeRate: '2.5', + usdExchangeRate: '1.5', + }); + }); + + it('should handle Solana chain rates', () => { + const result = selectExchangeRateByAssetId( + mockExchangeRateSources, + formatAddressToAssetId('789', SolScope.Mainnet), + ); + // 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 = selectExchangeRateByAssetId( + { + ...mockExchangeRateSources, + currencyRates: {}, + } as unknown as BridgeAppState, + formatAddressToAssetId('789', SolScope.Mainnet), + ); + expect(result).toStrictEqual({ + exchangeRate: '4.0', + usdExchangeRate: undefined, + }); + }); + + it('should return undefined usdExchangeRate for Solana when currencyRates is undefined', () => { + const result = selectExchangeRateByAssetId( + { + ...mockExchangeRateSources, + currencyRates: undefined, + } as unknown as BridgeAppState, + formatAddressToAssetId('789', SolScope.Mainnet), + ); + expect(result).toStrictEqual({ + exchangeRate: '4.0', + usdExchangeRate: undefined, + }); + }); + + it('should return empty object for Solana when conversion rate is missing', () => { + const result = selectExchangeRateByAssetId( + mockExchangeRateSources, + formatAddressToAssetId('456', SolScope.Mainnet), + ); + expect(result).toStrictEqual({}); + }); + + it('should return rate as usdExchangeRate for Solana when user currency is USD', () => { + const result = selectExchangeRateByAssetId( + { + ...mockExchangeRateSources, + currencyRates: { + ETH: { + conversionRate: 1800, + usdConversionRate: 1800, + }, + }, + } as unknown as BridgeAppState, + formatAddressToAssetId('789', SolScope.Mainnet), + ); + // 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 = selectExchangeRateByAssetId( + mockExchangeRateSources, + formatAddressToAssetId( + '0x0000000000000000000000000000000000000000', + '1', + ), + ); + expect(result).toStrictEqual({ + exchangeRate: '2468.12', + usdExchangeRate: '1800', + }); + }); + + it('should handle EVM token rates', () => { + const result = selectExchangeRateByAssetId( + mockExchangeRateSources, + formatAddressToAssetId(MOCK_MUSD_ADDRESS.toLowerCase(), '1'), + ); + expect(result).toStrictEqual({ + exchangeRate: '50.00000000000000162804', + usdExchangeRate: '36.4650017017000806', + }); + }); + + it('should handle EVM token rates when the asset ID address is lowercase and market data is checksummed', () => { + const result = selectExchangeRateByAssetId( + mockExchangeRateSources, + `eip155:1/erc20:${MOCK_MUSD_ADDRESS.toLowerCase()}`, + ); + expect(result).toStrictEqual({ + exchangeRate: '50.00000000000000162804', + usdExchangeRate: '36.4650017017000806', + }); + }); + + it('should return empty object for an EVM token whose market data price is zero', () => { + const result = selectExchangeRateByAssetId( + { + ...mockExchangeRateSources, + marketData: { + '0x1': { + [MOCK_MUSD_ADDRESS]: { + price: 0, + currency: 'ETH', + }, + }, + }, + } as unknown as BridgeAppState, + formatAddressToAssetId(MOCK_MUSD_ADDRESS.toLowerCase(), '1'), + ); + expect(result).toStrictEqual({}); + }); + + it('should return empty object for an EVM token whose market data has no price', () => { + const result = selectExchangeRateByAssetId( + { + ...mockExchangeRateSources, + marketData: { + '0x1': { + [MOCK_MUSD_ADDRESS]: { + currency: 'ETH', + }, + }, + }, + } as unknown as BridgeAppState, + formatAddressToAssetId(MOCK_MUSD_ADDRESS.toLowerCase(), '1'), + ); + expect(result).toStrictEqual({}); + }); + + it('should not throw when EVM token rate asset ID has a malformed hex address', () => { + expect(() => + selectExchangeRateByAssetId( + mockExchangeRateSources, + 'eip155:1/erc20:0x123', + ), + ).not.toThrow(); + expect( + selectExchangeRateByAssetId( + mockExchangeRateSources, + 'eip155:1/erc20:0x123', + ), + ).toStrictEqual({}); + }); + + it('should return empty object for an EVM token when marketData is undefined', () => { + const result = selectExchangeRateByAssetId( + { + ...mockExchangeRateSources, + marketData: undefined, + } as unknown as BridgeAppState, + formatAddressToAssetId(MOCK_MUSD_ADDRESS.toLowerCase(), '1'), + ); + expect(result).toStrictEqual({}); + }); + + it('should return empty object when EVM token address is not a hex string', () => { + expect( + selectExchangeRateByAssetId( + mockExchangeRateSources, + 'eip155:1/erc20:nothex', + ), + ).toStrictEqual({}); + }); + }); + + describe('selectIsAssetExchangeRateInState', () => { + const assetId = + formatAddressToAssetId(MOCK_USDC_ADDRESS, '1')?.toLowerCase() ?? ''; + const mockExchangeRateSources = { + assetExchangeRates: { + [assetId]: { + exchangeRate: '2.5', + }, + }, + currencyRates: {}, + marketData: {}, + conversionRates: {}, + } as unknown as BridgeAppState; + + it('should return true if exchange rate exists for both currency and USD', () => { + expect( + selectIsAssetExchangeRateInState( + { + ...mockExchangeRateSources, + assetExchangeRates: { + ...mockExchangeRateSources.assetExchangeRates, + [assetId]: { + // @ts-expect-error - ignore type error + ...mockExchangeRateSources.assetExchangeRates[assetId], + usdExchangeRate: '1.5', + }, + }, + }, + formatAddressToAssetId(MOCK_USDC_ADDRESS, '1'), + ), + ).toBe(true); + }); + + it('should return false if USD exchange rate does not exist', () => { + expect( + selectIsAssetExchangeRateInState( + mockExchangeRateSources, + formatAddressToAssetId(MOCK_USDC_ADDRESS, '1'), + ), + ).toBe(false); + }); + + it('should return false if exchange rate does not exist', () => { + expect( + selectIsAssetExchangeRateInState( + mockExchangeRateSources, + formatAddressToAssetId(ETH_USDT_ADDRESS, '1'), + ), + ).toBe(false); + }); + + it('should return false for an EVM token whose only market data price is zero', () => { + // A zero-price market data entry must not be mistaken for a known rate, + // otherwise the controller skips fetching the token's real price. + expect( + selectIsAssetExchangeRateInState( + { + ...mockExchangeRateSources, + assetExchangeRates: {}, + currencyRates: { + ETH: { + conversionRate: 2468.12, + usdConversionRate: 1800, + }, + }, + marketData: { + '0x1': { + [MOCK_MUSD_ADDRESS]: { + price: 0, + currency: 'ETH', + }, + }, + }, + } as unknown as BridgeAppState, + formatAddressToAssetId(MOCK_MUSD_ADDRESS.toLowerCase(), '1'), + ), + ).toBe(false); + }); + + it('should return false if parameters are missing', () => { + expect(selectIsAssetExchangeRateInState(mockExchangeRateSources)).toBe( + false, + ); + expect( + selectIsAssetExchangeRateInState(mockExchangeRateSources, undefined), + ).toBe(false); + }); + }); + + describe('selectIsQuoteExpired', () => { + const mockState = { + quotes: [], + quoteRequest: [ + { + srcChainId: '1', + destChainId: '137', + srcTokenAddress: '0x0000000000000000000000000000000000000000', + destTokenAddress: '0x0000000000000000000000000000000000000000', + insufficientBal: false, + }, + ], + quotesLastFetched: Date.now(), + quotesLoadingStatus: RequestStatus.FETCHED, + quoteFetchError: null, + quotesRefreshCount: 0, + quotesInitialLoadTime: Date.now(), + remoteFeatureFlags: { + bridgeConfig: { + maxRefreshCount: 5, + refreshRate: 30000, + chainRanking: [], + chains: {}, + support: true, + minimumVersion: '0.0.0', + }, + }, + assetExchangeRates: {}, + currencyRates: {}, + marketData: {}, + conversionRates: {}, + participateInMetaMetrics: true, + gasFeeEstimatesByChainId: { + '0x1': { + gasFeeEstimates: { + estimatedBaseFee: '50', + medium: { + suggestedMaxPriorityFeePerGas: '75', + suggestedMaxFeePerGas: '77', + }, + high: { + suggestedMaxPriorityFeePerGas: '100', + suggestedMaxFeePerGas: '102', + }, + }, + }, + }, + } as unknown as BridgeAppState; + + const mockClientParams = { + sortOrder: SortOrder.COST_ASC, + selectedQuote: null, + }; + + it('should return false when quote is not expired', () => { + const result = selectIsQuoteExpired( + mockState, + mockClientParams, + Date.now(), + ); + expect(result).toBe(false); + }); + + it('should return true when quote is expired', () => { + const stateWithOldQuote = { + ...mockState, + quotesRefreshCount: 5, + quotesLastFetched: Date.now() - 40000, // 40 seconds ago + } as unknown as BridgeAppState; + + const result = selectIsQuoteExpired( + stateWithOldQuote, + mockClientParams, + Date.now(), + ); + expect(result).toBe(true); + }); + + it('should handle chain-specific quote refresh rate', () => { + const stateWithOldQuote = { + ...mockState, + quotesRefreshCount: 5, + quotesLastFetched: Date.now() - 40000, // 40 seconds ago + remoteFeatureFlags: { + bridgeConfig: { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + ...(mockState.remoteFeatureFlags.bridgeConfig as any), + chainRanking: [], + chains: { + '1': { + refreshRate: 41000, + isActiveSrc: true, + isActiveDest: true, + }, + }, + }, + }, + } as unknown as BridgeAppState; + + const result = selectIsQuoteExpired( + stateWithOldQuote, + mockClientParams, + Date.now(), + ); + expect(result).toBe(false); + }); + + it('should handle quote expiration when srcChainId is unset', () => { + const stateWithOldQuote = { + ...mockState, + quoteRequest: [ + { + ...mockState.quoteRequest[0], + srcChainId: undefined, + }, + ], + quotesRefreshCount: 5, + quotesLastFetched: Date.now() - 40000, // 40 seconds ago + remoteFeatureFlags: { + bridgeConfig: { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + ...(mockState.remoteFeatureFlags.bridgeConfig as any), + chainRanking: [], + chains: { + '1': { + refreshRate: 41000, + isActiveSrc: true, + isActiveDest: true, + }, + }, + }, + }, + } as unknown as BridgeAppState; + + const result = selectIsQuoteExpired( + stateWithOldQuote, + mockClientParams, + Date.now(), + ); + expect(result).toBe(true); + }); + }); + + describe('selectBridgeQuotes', () => { + const getMockState = ( + chainId: ChainId, + quoteOverrides: DeepPartial = {}, + stateOverrides?: Partial, + ): BridgeAppState => { + const decChainId = formatChainIdToDec(chainId); + const caipChainId = formatChainIdToCaip(chainId); + const mockQuoteV1 = { + quote: { + requestId: '123', + srcChainId: decChainId, + destChainId: 137, + srcTokenAmount: '1000000000000000000', + destTokenAmount: '2000000000000000000', + minDestTokenAmount: '1800000000000000000', + srcAsset: { + chainId: decChainId, + address: '0x0000000000000000000000000000000000000000', + decimals: 18, + assetId: getNativeAssetForChainId( + chainId, + ).assetId.toLowerCase() as `${string}:${string}/${string}:${string}`, + symbol: 'ETH', + name: 'Ethereum', + }, + destAsset: { + chainId: 137, + address: '0x0000000000000000000000000000000000000000', + decimals: 18, + assetId: getNativeAssetForChainId( + 137, + ).assetId.toLowerCase() as `${string}:${string}/${string}:${string}`, + symbol: 'POL', + name: 'Polygon', + }, + bridges: ['bridge1'], + bridgeId: 'bridge1', + steps: [], + feeData: { + metabridge: { + amount: '100000000000000000', + asset: { + chainId: decChainId, + address: '0x0000000000000000000000000000000000000000', + decimals: 18, + symbol: 'ETH', + name: 'Ethereum', + assetId: + getNativeAssetForChainId(chainId).assetId.toLowerCase(), + }, + }, + }, + }, + estimatedProcessingTimeInSeconds: 300, + ...(parseCaipChainId(caipChainId).namespace === + KnownCaipNamespace.Eip155 + ? { + trade: { + value: '0x0', + gasLimit: 24000, + effectiveGas: 21000, + chainId: decChainId, + from: '0x0000000000000000000000000000000000000000', + to: '0x0000000000000000000000000000000000000000', + data: '0x0', + }, + } + : { trade: 'SOLANATRADE' }), + ...(parseCaipChainId(caipChainId).namespace === + KnownCaipNamespace.Eip155 + ? { + approval: { + gasLimit: 49000, + effectiveGas: 46000, + chainId: decChainId, + from: '0x0000000000000000000000000000000000000000', + to: '0x0000000000000000000000000000000000000000', + data: '0x0', + value: '0x0', + }, + } + : {}), + }; + + const mockQuoteV2 = [ + mockQuoteV1, + { + ...mockQuoteV1, + quote: { + ...mockQuoteV1.quote, + requestId: '456', + destTokenAmount: '2100000000000000000', + }, + }, + ] + .map(toQuoteResponseV2) + .map((quote) => ({ + ...merge({}, quote, quoteOverrides), + quote: merge({}, quote.quote, quoteOverrides?.quote ?? {}), + })); + + const srcChainId = parseCaipAssetType( + mockQuoteV2[0].quote.src.asset.assetId, + ).chainId; + const destChainId = parseCaipAssetType( + mockQuoteV2[0].quote.dest.asset.assetId, + ).chainId; + + return { + quotes: mockQuoteV2, + quoteRequest: [ + { + srcChainId: srcChainId ?? decChainId, + destChainId: destChainId ?? 137, + srcTokenAddress: mockQuoteV2[0].quote.src.asset.assetId, + destTokenAddress: mockQuoteV2[0].quote.dest.asset.assetId, + insufficientBal: false, + }, + ], + ...merge( + {}, + { + quotesLastFetched: Date.now(), + quotesLoadingStatus: RequestStatus.FETCHED, + quoteFetchError: null, + quotesRefreshCount: 0, + quotesInitialLoadTime: Date.now(), + remoteFeatureFlags: { + bridgeConfig: { + minimumVersion: '0.0.0', + maxRefreshCount: 5, + refreshRate: 30000, + chainRanking: [], + chains: {}, + support: true, + }, + }, + assetExchangeRates: {}, + currencyRates: { + [getNativeAssetForChainId(chainId).symbol]: { + conversionRate: 1800, + usdConversionRate: 1800, + }, + }, + marketData: {}, + conversionRates: {}, + participateInMetaMetrics: true, + gasFeeEstimatesByChainId: { + [formatChainIdToHex(decChainId)]: { + gasFeeEstimates: { + estimatedBaseFee: '0', + medium: { + suggestedMaxPriorityFeePerGas: '.1', + suggestedMaxFeePerGas: '.1', + }, + high: { + suggestedMaxPriorityFeePerGas: '.1', + suggestedMaxFeePerGas: '.2', + }, + }, + }, + }, + }, + stateOverrides, + ), + } as unknown as BridgeAppState; + }; + + const mockClientParams = { + sortOrder: SortOrder.COST_ASC, + selectedQuote: null, + migrationPhase: QuoteMetadataMigrationPhase.V1Data, + }; + + it('should return sorted quotes with metadata', () => { + const mockState = getMockState(1); + const mockQuote = mockState.quotes[0]; + const { quotesInitialLoadTimeMs, quotesLastFetchedMs, ...result } = + selectBridgeQuotes( + { + ...mockState, + quotes: mockState.quotes.map((quote) => ({ + ...quote, + quote: { + ...quote.quote, + src: { ...quote.quote.src, usd: '1' }, + dest: { ...quote.quote.dest, usd: '2' }, + feeData: { + ...quote.quote.feeData, + network: [ + { + amount: '7500000000000', + usd: '0.01514', + asset: toBridgeAssetV2(getNativeAssetForChainId(1)), + }, + ], + }, + priceData: { + ...quote.quote.priceData, + ...(quote.quote.requestId === '456' && { + priceImpact: { + usd: '7.9', + }, + }), + }, + }, + })), + assetExchangeRates: { + [mockQuote.quote.src.asset.assetId]: { + exchangeRate: '1980', + usdExchangeRate: '10', + }, + [mockQuote.quote.dest.asset.assetId]: { + exchangeRate: '200', + usdExchangeRate: '1', + }, + }, + }, + mockClientParams, + ); + + const expectedQuoteMetadata = calcQuoteMetadata(mockState.quotes[1], { + srcTokenExchangeRate: { exchangeRate: '1980', usdExchangeRate: '10' }, + bridgeFeesPerGas: { + estimatedBaseFeeInDecGwei: '0', + feePerGasInDecGwei: '.1', + }, + destTokenExchangeRate: { exchangeRate: '200', usdExchangeRate: '1' }, + nativeExchangeRate: { exchangeRate: '1980', usdExchangeRate: '10' }, + }); + expect(toQuoteMetadataV1(result.recommendedQuote)).toStrictEqual( + expectedQuoteMetadata, + ); + + expect(result.sortedQuotes[0].cost?.valueInCurrency).toBe('1758.014454'); + // eslint-disable-next-line jest/no-restricted-matchers + expect(result.recommendedQuote).toMatchSnapshot(); + }); + + it('should return sorted quotes with metadata (Phase 1.5)', () => { + const migrationPhase = QuoteMetadataMigrationPhase.V2WithV1Fallback; + const mockState = getMockState(1); + const mockQuote = mockState.quotes[0]; + const quotes = mockState.quotes.map((quote) => ({ + ...quote, + quote: { + ...quote.quote, + src: { ...quote.quote.src, usd: '1' }, + feeData: { + ...quote.quote.feeData, + network: [ + { + amount: '7500000000000', + usd: '0.01514', + asset: toBridgeAssetV2(getNativeAssetForChainId(1)), + }, + ], + relayer: [ + { + amount: '100000000000', + asset: toBridgeAssetV2(getNativeAssetForChainId(1)), + }, + ], + }, + priceData: { + ...quote.quote.priceData, + ...(quote.quote.requestId === '456' && { + priceImpact: { + usd: '7.9', + }, + }), + }, + }, + })); + const { quotesInitialLoadTimeMs, quotesLastFetchedMs, ...result } = + selectBridgeQuotes( + { + ...mockState, + quotes, + assetExchangeRates: { + [mockQuote.quote.src.asset.assetId]: { + exchangeRate: '1980', + usdExchangeRate: '10', + }, + [mockQuote.quote.dest.asset.assetId]: { + exchangeRate: '200', + usdExchangeRate: '1', + }, + }, + }, + { ...mockClientParams, migrationPhase }, + ); + + const expectedQuoteMetadata = calcQuoteMetadata(quotes[1], { + srcTokenExchangeRate: { exchangeRate: '1980', usdExchangeRate: '10' }, + bridgeFeesPerGas: { + estimatedBaseFeeInDecGwei: '0', + feePerGasInDecGwei: '.1', + }, + destTokenExchangeRate: { exchangeRate: '200', usdExchangeRate: '1' }, + nativeExchangeRate: { exchangeRate: '1980', usdExchangeRate: '10' }, + }); + + // eslint-disable-next-line jest/no-restricted-matchers + expect(result.sortedQuotes[0]).toMatchSnapshot(); + expect(result.recommendedQuote).toMatchObject(expectedQuoteMetadata); + expect(result.recommendedQuote).not.toMatchObject( + toQuoteMetadataV1(result.recommendedQuote, migrationPhase), + ); + }); + + it('should return sorted quotes with metadata (Phase 2)', () => { + const migrationPhase = QuoteMetadataMigrationPhase.V2Only; + const mockState = getMockState(1); + const mockQuote = mockState.quotes[0]; + const quotes = mockState.quotes.map((quote) => ({ + ...quote, + quote: { + ...quote.quote, + src: { ...quote.quote.src, usd: '1' }, + dest: { ...quote.quote.dest, usd: '2' }, + feeData: { + ...quote.quote.feeData, + network: [ + { + amount: '7500000000000', + asset: toBridgeAssetV2(getNativeAssetForChainId(1)), + usd: undefined, + }, + ], + relayer: [ + { + amount: '100000000000', + usd: '0.0001', + asset: toBridgeAssetV2(getNativeAssetForChainId(1)), + }, + ], + }, + priceData: { + ...quote.quote.priceData, + ...(quote.quote.requestId === '456' && { + priceImpact: { + usd: '7.9', + }, + }), + }, + }, + })); + const { quotesInitialLoadTimeMs, quotesLastFetchedMs, ...result } = + selectBridgeQuotes( + { + ...mockState, + quotes, + assetExchangeRates: { + [mockQuote.quote.src.asset.assetId]: { + exchangeRate: '1980', + usdExchangeRate: '10', + }, + [mockQuote.quote.dest.asset.assetId]: { + exchangeRate: '200', + usdExchangeRate: '1', + }, + }, + }, + { ...mockClientParams, migrationPhase }, + ); + + // eslint-disable-next-line jest/no-restricted-matchers + expect(result.recommendedQuote).toMatchSnapshot(); + }); + + it('should return metadata when quotes are empty', () => { + const mockState = getMockState(1); + const mockQuote = mockState.quotes[0]; + const { quotesInitialLoadTimeMs, quotesLastFetchedMs, ...result } = + selectBridgeQuotes( + { + ...mockState, + quotes: [], + assetExchangeRates: { + [mockQuote.quote.src.asset.assetId]: { + exchangeRate: '1980', + usdExchangeRate: '10', + }, + [mockQuote.quote.dest.asset.assetId]: { + exchangeRate: '200', + usdExchangeRate: '1', + }, + }, + }, + mockClientParams, + ); + + expect(result).toMatchInlineSnapshot(` + { + "activeQuote": null, + "isLoading": false, + "isQuoteGoingToRefresh": true, + "quoteFetchError": null, + "quotesRefreshCount": 0, + "recommendedQuote": null, + "sortedQuotes": [], + } + `); + expect(result.sortedQuotes).toHaveLength(0); + }); + + it('should use destTokenAmount to sort quotes if exchange rate is not available', () => { + const mockState = getMockState(1); + const { quotesInitialLoadTimeMs, quotesLastFetchedMs, ...result } = + selectBridgeQuotes( + { ...mockState, assetExchangeRates: {}, marketData: {} }, + mockClientParams, + ); + + const expectedQuoteMetadata = { + gasFee: { + total: { + amount: '0.0000073', + usd: '0.01314', + valueInCurrency: '0.01314', + }, + }, + minToTokenAmount: { + amount: '1.8', + usd: undefined, + valueInCurrency: undefined, + }, + sentAmount: { + amount: '1.1', + usd: '1980', + valueInCurrency: '1980', + }, + swapRate: '1.90909090909090909091', + toTokenAmount: { + amount: '2.1', + usd: undefined, + valueInCurrency: undefined, + }, + totalNetworkFee: { + amount: '0.0000073', + usd: '0.01314', + valueInCurrency: '0.01314', + }, + }; + + const expectedQuoteV2 = mockState.quotes[1]; + expect(result.sortedQuotes[0]).toStrictEqual( + mergeQuoteMetadata(expectedQuoteV2, expectedQuoteMetadata), + ); + expect( + result.sortedQuotes[0].quote.priceData?.priceImpact, + ).toBeUndefined(); + expect(result.recommendedQuote?.quote.dest.amount).toBe( + '2100000000000000000', + ); + expect(result.recommendedQuote?.quote.dest.normalizedAmount).toBe('2.1'); + }); + + it('should use priceImpact to sort quotes if exchange rate is not available', () => { + const mockState = getMockState(1); + const quotesWithPriceImpact = [ + { + ...mockState.quotes[0], + quote: { + ...mockState.quotes[0].quote, + priceData: { priceImpact: { amount: '0.01' } }, + }, + }, + { + ...mockState.quotes[1], + quote: { + ...mockState.quotes[1].quote, + priceData: { priceImpact: { amount: '-0.02' } }, + }, + }, + ]; + const { quotesInitialLoadTimeMs, quotesLastFetchedMs, ...result } = + selectBridgeQuotes( + { + ...mockState, + assetExchangeRates: {}, + marketData: {}, + quotes: quotesWithPriceImpact, + }, + mockClientParams, + ); + + const expectedQuoteMetadata = { + minToTokenAmount: { + amount: '1.8', + usd: undefined, + valueInCurrency: undefined, + }, + sentAmount: { + amount: '1.1', + usd: '1980', + valueInCurrency: '1980', + }, + swapRate: '1.90909090909090909091', + toTokenAmount: { + amount: '2.1', + usd: undefined, + valueInCurrency: undefined, + }, + totalNetworkFee: { + amount: '0.0000073', + usd: '0.01314', + valueInCurrency: '0.01314', + }, + gasFee: { + total: { + amount: '0.0000073', + usd: '0.01314', + valueInCurrency: '0.01314', + }, + }, + }; + + const expectedQuoteV2 = quotesWithPriceImpact[1]; + + expect( + result.sortedQuotes[0].quote.priceData?.priceImpact?.valueInCurrency, + ).toBeUndefined(); + expect(result.recommendedQuote).toStrictEqual( + mergeQuoteMetadata(expectedQuoteV2, expectedQuoteMetadata), + ); + expect( + result.recommendedQuote?.quote.priceData?.priceImpact?.amount, + ).toBe('-0.02'); + }); + + describe('returns swap metadata', () => { + const getMockSwapState = ( + srcAsset: Omit, + destAsset: Omit, + txFee?: { + amount: string; + asset: Omit; + }, + gasIncluded7702?: boolean, + gasEstimatesChainId?: number, + ): BridgeAppState => { + const srcTokenAddress = formatAddressToCaipReference(srcAsset.assetId); + const destTokenAddress = formatAddressToCaipReference( + destAsset.assetId, + ); + + const { chainId: caipChainId } = parseCaipAssetType(srcAsset.assetId); + const chainId = formatChainIdToDec(caipChainId); + const hexChainId = formatChainIdToHex(chainId); + const nativeAsset = toBridgeAssetV2(getNativeAssetForChainId(chainId)); + const currencyRates = { + [nativeAsset.symbol]: { + conversionRate: 551.98, + usdConversionRate: 645.12, + conversionDate: Date.now(), + }, + }; + const marketData = { + [hexChainId]: { + [destTokenAddress]: { + price: '0.0015498387253001357', + currency: nativeAsset.symbol, + }, + [srcTokenAddress]: { + price: '1', + currency: nativeAsset.symbol, + }, + '0x0000000000000000000000000000000000000000': { + price: '1', + currency: nativeAsset.symbol, + }, + '0x0000000000000000000000000000000000000001': { + price: '1.5498387253001357', + currency: nativeAsset.symbol, + }, + }, + } as unknown as Record>; + + const srcTokenAmount = new BigNumber('10') // $10 worth of src token + .dividedBy(marketData[hexChainId][srcTokenAddress].price) + .dividedBy(currencyRates[nativeAsset.symbol].conversionRate) + .multipliedBy(10 ** srcAsset.decimals) + .toFixed(0); + + const quoteResponse = { + quoteId: '123', + quote: { + walletAddress: '0x0000000000000000000000000000000000000000', + destWalletAddress: '0x0000000000000000000000000000000000000000', + bridgeId: 'uniswap', + bridges: ['uniswap'], + steps: [], + requestId: '123', + srcChainId: chainId, + destChainId: chainId, + srcAsset: { + ...srcAsset, + address: srcTokenAddress, + chainId, + }, + destAsset: { + ...destAsset, + address: destTokenAddress, + chainId, + }, + priceData: { + priceImpact: '-0.11', + }, + feeData: { + metabridge: { + amount: '0', + asset: { + address: srcTokenAddress, + decimals: srcAsset.decimals, + assetId: srcAsset.assetId, + chainId, + symbol: srcAsset.symbol, + name: srcAsset.name, + }, + }, + ...(txFee + ? { + txFee: { + ...txFee, + maxFeePerGas: '2616919731', + maxPriorityFeePerGas: '2100000004', + asset: { + ...txFee?.asset, + address: formatAddressToCaipReference( + txFee?.asset?.assetId, + ), + chainId: formatChainIdToDec( + parseCaipAssetType(txFee?.asset?.assetId).chainId, + ), + }, + }, + } + : {}), + }, + gasIncluded: Boolean(txFee) && !gasIncluded7702, + gasIncluded7702: Boolean(gasIncluded7702), + srcTokenAmount, + destTokenAmount: new BigNumber('9') + .dividedBy(marketData[hexChainId][destTokenAddress].price) + .dividedBy(currencyRates[nativeAsset.symbol].conversionRate) + .multipliedBy(10 ** destAsset.decimals) + .toFixed(0), + minDestTokenAmount: new BigNumber('9') + .dividedBy(marketData[hexChainId][destTokenAddress].price) + .dividedBy(currencyRates[nativeAsset.symbol].conversionRate) + .multipliedBy(10 ** destAsset.decimals) + .multipliedBy(0.95) // 5% slippage + .toFixed(0), + }, + estimatedProcessingTimeInSeconds: 300, + approval: { + chainId, + from: '0x0000000000000000000000000000000000000000', + to: '0x0000000000000000000000000000000000000000', + value: '0x0', + data: '0x0', + gasLimit: 21211, + }, + trade: { + chainId, + from: '0x0000000000000000000000000000000000000000', + to: '0x0000000000000000000000000000000000000000', + data: '0x0', + gasLimit: 59659, + value: isNativeAddress(srcTokenAddress) + ? toHex( + new BigNumber(srcTokenAmount) + .plus(txFee?.amount ?? '0') + .toString(), + ) + : '0x0', + }, + }; + validateQuoteResponseV1(quoteResponse); + const mockState = getMockState(gasEstimatesChainId ?? chainId); + + return { + ...mockState, + quotes: [toQuoteResponseV2(quoteResponse)], + currencyRates, + marketData, + quoteRequest: [ + { + ...mockState.quoteRequest, + srcChainId: chainId, + destChainId: chainId, + srcTokenAddress, + destTokenAddress, + }, + ], + }; + }; + + it('for native -> erc20', () => { + const srcAsset = { + decimals: 18, + assetId: getNativeAssetForChainId(1).assetId, + symbol: 'ETH', + name: 'Ethereum', + }; + const destAsset = { + decimals: 18, + assetId: + 'eip155:1/erc20:0x8AC76a51cc950d9822D68b83fE1Ad97B32Cd580d' as const, + symbol: 'USDC', + name: 'USD Coin', + }; + + const newState = getMockSwapState(srcAsset, destAsset); + + const { sortedQuotes } = selectBridgeQuotes(newState, mockClientParams); + + const expectedQuoteMetadata = { + adjustedReturn: { + usd: '10.513424894341876155230359150867612640256', + valueInCurrency: '8.995536137740000000254299423511757231474', + }, + cost: { + usd: '1.173955083193541475489640849132387359744', + valueInCurrency: '1.004463862259999726625700576488242768526', + }, + gasFee: { + total: { + amount: '0.000008087', + usd: '0.00521708544', + valueInCurrency: '0.00446386226', + }, + }, + minToTokenAmount: { + amount: '9.994389353314869106', + usd: '9.992709880792782347418849595400950831104', + valueInCurrency: '8.550000000000000000198810453356610924716', + }, + sentAmount: { + amount: '0.018116598427479256', + usd: '11.68737997753541763072', + valueInCurrency: '9.99999999999999972688', + }, + swapRate: '580.70558265713069471891', + toTokenAmount: { + amount: '10.520409845594599059', + usd: '10.518641979781876155230359150867612640256', + valueInCurrency: '9.000000000000000000254299423511757231474', + }, + totalNetworkFee: { + amount: '0.000008087', + usd: '0.00521708544', + valueInCurrency: '0.00446386226', + }, + priceImpact: { + usd: '1.168737997753541475489640849132387359744', + valueInCurrency: '0.999999999999999726625700576488242768526', + }, + }; + + expect(sortedQuotes[0]).toStrictEqual( + mergeQuoteMetadata(newState.quotes[0], expectedQuoteMetadata), + ); + }); + + it('erc20 -> native', () => { + const newState = getMockSwapState( + { + symbol: 'USDC', + name: 'USD Coin', + assetId: + 'eip155:1/erc20:0x8AC76a51cc950d9822D68b83fE1Ad97B32Cd580d', + decimals: 18, + }, + { + decimals: 18, + assetId: + 'eip155:1/erc20:0x0000000000000000000000000000000000000000', + symbol: 'ETH', + name: 'Ethereum', + }, + ); + + const { sortedQuotes } = selectBridgeQuotes(newState, mockClientParams); + + const expectedQuoteMetadata = { + priceImpact: { + usd: '1.168737997753541376', + valueInCurrency: '0.9999999999999996415', + }, + adjustedReturn: { + usd: '10.51342489434187625472', + valueInCurrency: '8.99553613774000008538', + }, + cost: { + usd: '1.173955083193541376', + valueInCurrency: '1.0044638622599996415', + }, + minToTokenAmount: { + amount: '0.015489691655494764', + usd: '9.99270988079278215168', + valueInCurrency: '8.54999999999999983272', + }, + sentAmount: { + amount: '0.018116598427479256', + usd: '11.68737997753541763072', + valueInCurrency: '9.99999999999999972688', + }, + swapRate: '0.90000000000000003312', + toTokenAmount: { + amount: '0.016304938584731331', + usd: '10.51864197978187625472', + valueInCurrency: '9.00000000000000008538', + }, + totalNetworkFee: { + amount: '0.000008087', + usd: '0.00521708544', + valueInCurrency: '0.00446386226', + }, + gasFee: { + total: { + amount: '0.000008087', + usd: '0.00521708544', + valueInCurrency: '0.00446386226', + }, + }, + }; + + const quoteResponseV2 = newState.quotes[0]; + expect(sortedQuotes[0]).toStrictEqual( + mergeQuoteMetadata(quoteResponseV2, expectedQuoteMetadata), + ); + }); + + it('erc20 -> native but gas estimates are not available', () => { + const newState = getMockSwapState( + { + decimals: 18, + assetId: + 'eip155:1/erc20:0x8AC76a51cc950d9822D68b83fE1Ad97B32Cd580d', + symbol: 'USDC', + name: 'USD Coin', + }, + { + decimals: 18, + assetId: + 'eip155:1/erc20:0x0000000000000000000000000000000000000000', + symbol: 'ETH', + name: 'Ethereum', + }, + undefined, + undefined, + 10, + ); + + const { sortedQuotes } = selectBridgeQuotes(newState, mockClientParams); + + const expectedQuoteMetadata = { + adjustedReturn: { + usd: '10.51864197978187625472', + valueInCurrency: '9.00000000000000008538', + }, + cost: { + usd: '1.168737997753541376', + valueInCurrency: '0.9999999999999996415', + }, + priceImpact: { + usd: '1.168737997753541376', + valueInCurrency: '0.9999999999999996415', + }, + minToTokenAmount: { + amount: '0.015489691655494764', + usd: '9.99270988079278215168', + valueInCurrency: '8.54999999999999983272', + }, + sentAmount: { + amount: '0.018116598427479256', + usd: '11.68737997753541763072', + valueInCurrency: '9.99999999999999972688', + }, + gasFee: { + total: { + amount: '0', + usd: '0', + valueInCurrency: '0', + }, + }, + swapRate: '0.90000000000000003312', + toTokenAmount: { + amount: '0.016304938584731331', + usd: '10.51864197978187625472', + valueInCurrency: '9.00000000000000008538', + }, + totalNetworkFee: { + amount: '0', + usd: '0', + valueInCurrency: '0', + }, + }; + + const quoteResponseV2 = newState.quotes[0]; + expect(sortedQuotes[0]).toStrictEqual( + mergeQuoteMetadata(quoteResponseV2, expectedQuoteMetadata), + ); + }); + + it('when gas is included and is taken from dest token', () => { + const newState = getMockSwapState( + { + decimals: 18, + assetId: + 'eip155:1/erc20:0x8AC76a51cc950d9822D68b83fE1Ad97B32Cd580d', + symbol: 'USDC', + name: 'USD Coin', + }, + { + decimals: 18, + assetId: + 'eip155:1/erc20:0x0000000000000000000000000000000000000000', + symbol: 'ETH', + name: 'Ethereum', + }, + { + amount: '1000000000000000', + asset: { + decimals: 18, + assetId: + 'eip155:1/erc20:0x0000000000000000000000000000000000000000', + symbol: 'ETH', + name: 'Ethereum', + }, + }, + ); + + const { sortedQuotes } = selectBridgeQuotes(newState, mockClientParams); + + const expectedQuoteMetadata = { + adjustedReturn: { + usd: '10.51864197978187625472', + valueInCurrency: '9.00000000000000008538', + }, + cost: { + usd: '1.168737997753541376', + valueInCurrency: '0.9999999999999996415', + }, + gasFee: { + total: { + amount: '0.000008087', + usd: '0.00521708544', + valueInCurrency: '0.00446386226', + }, + }, + includedTxFees: { + amount: '0.001', + usd: '0.64512', + valueInCurrency: '0.55198', + }, + priceImpact: { + usd: '1.168737997753541376', + valueInCurrency: '0.9999999999999996415', + }, + minToTokenAmount: { + amount: '0.015489691655494764', + usd: '9.99270988079278215168', + valueInCurrency: '8.54999999999999983272', + }, + sentAmount: { + amount: '0.018116598427479256', + usd: '11.68737997753541763072', + valueInCurrency: '9.99999999999999972688', + }, + swapRate: '0.90000000000000003312', + toTokenAmount: { + amount: '0.016304938584731331', + usd: '10.51864197978187625472', + valueInCurrency: '9.00000000000000008538', + }, + totalNetworkFee: { + amount: '0.000008087', + usd: '0.00521708544', + valueInCurrency: '0.00446386226', + }, + }; + + const quoteResponseV2 = newState.quotes[0]; + expect(sortedQuotes[0]).toStrictEqual( + mergeQuoteMetadata(quoteResponseV2, expectedQuoteMetadata), + ); + }); + + it('when gas is included and is taken from src token', () => { + const state = getMockSwapState( + { + symbol: 'USDC', + name: 'USD Coin', + assetId: + 'eip155:1/erc20:0x8AC76a51cc950d9822D68b83fE1Ad97B32Cd580d', + decimals: 6, + }, + { + decimals: 18, + assetId: 'eip155:1/slip44:60', + symbol: 'ETH', + name: 'Ethereum', + }, + { + amount: '3000000', + asset: { + decimals: 6, + assetId: + 'eip155:1/erc20:0x8AC76a51cc950d9822D68b83fE1Ad97B32Cd580d', + symbol: 'ETH', + name: 'Ethereum', + }, + }, + ); + + const newState = { + ...state, + quotes: state.quotes.map((quote, index) => ({ + ...quote, + quote: { + ...quote.quote, + priceData: { + priceImpact: { + usd: '1935.36', + }, + swapRate: '1', + }, + feeData: { + ...quote.quote.feeData, + txFee: [ + { + amount: `${(3 + index) * 1000000}`, + asset: quote.quote.src.asset, + usd: '1935.36', + maxFeePerGas: '1000000000000000000', + maxPriorityFeePerGas: '1000000000000000000', + }, + ], + }, + }, + })), + }; + const { sortedQuotes } = selectBridgeQuotes(newState, mockClientParams); + + const expectedQuoteMetadata = calcQuoteMetadata(newState.quotes[0], { + srcTokenExchangeRate: { + exchangeRate: '551.98', + usdExchangeRate: '645.12', + }, + bridgeFeesPerGas: { + estimatedBaseFeeInDecGwei: '0', + feePerGasInDecGwei: '.1', + }, + destTokenExchangeRate: { + exchangeRate: '551.98', + usdExchangeRate: '645.12', + }, + nativeExchangeRate: { + exchangeRate: '551.98', + usdExchangeRate: '645.12', + }, + }); + + expect(sortedQuotes[0].quote.feeData.txFee?.[0]).toStrictEqual({ + amount: '3000000', + asset: { + assetId: + 'eip155:1/erc20:0x8AC76a51cc950d9822D68b83fE1Ad97B32Cd580d', + decimals: 6, + name: 'USD Coin', + symbol: 'USDC', + }, + maxFeePerGas: '1000000000000000000', + maxPriorityFeePerGas: '1000000000000000000', + normalizedAmount: '3', + usd: '1935.36', + valueInCurrency: '1655.94', + }); + expect(sortedQuotes[0].quote.src?.normalizedAmount).toBe('3.018117'); + expect(sortedQuotes[0].quote.feeData.txFee?.[0].normalizedAmount).toBe( + '3', + ); + expect(sortedQuotes[0].sentAmount?.amount).toBe('3.018117'); + const expectedQuoteV2 = mergeQuoteMetadata( + newState.quotes[0], + expectedQuoteMetadata, + ); + expect(sortedQuotes[0]).toStrictEqual(expectedQuoteV2); + }); + + it('when gasIncluded7702=true and is taken from dest token', () => { + const newState = getMockSwapState( + { + decimals: 18, + assetId: + 'eip155:1/erc20:0x8AC76a51cc950d9822D68b83fE1Ad97B32Cd580d', + symbol: 'USDC', + name: 'USD Coin', + }, + { + decimals: 18, + assetId: + 'eip155:1/erc20:0x0000000000000000000000000000000000000001', + symbol: 'WETH', + name: 'Ethereum', + }, + { + amount: '1000000000000000000', + asset: { + decimals: 18, + assetId: + 'eip155:1/erc20:0x0000000000000000000000000000000000000001', + symbol: 'WETH', + name: 'Ethereum', + }, + }, + true, + ); + + const { sortedQuotes } = selectBridgeQuotes(newState, mockClientParams); + + const expectedQuoteMetadata = { + adjustedReturn: { + usd: '10.518641979781876096240273601395823616', + valueInCurrency: '8.999999999999999949780980627632791914', + }, + cost: { + usd: '1.168737997753541534479726398604176384', + valueInCurrency: '0.999999999999999777099019372367208086', + }, + gasFee: { + total: { + amount: '0.000008087', + usd: '0.00521708544', + valueInCurrency: '0.00446386226', + }, + }, + priceImpact: { + usd: '1.168737997753541534479726398604176384', + valueInCurrency: '0.999999999999999777099019372367208086', + }, + includedTxFees: { + amount: '1', + usd: '999.831958465623542784', + valueInCurrency: '855.479979591168903686', + }, + minToTokenAmount: { + amount: '0.009994389353314869', + usd: '9.992709880792782241436661998044855296', + valueInCurrency: '8.549999999999999909517932616692707134', + }, + sentAmount: { + amount: '0.018116598427479256', + usd: '11.68737997753541763072', + valueInCurrency: '9.99999999999999972688', + }, + swapRate: '0.58070558265713069146', + toTokenAmount: { + amount: '0.010520409845594599', + usd: '10.518641979781876096240273601395823616', + valueInCurrency: '8.999999999999999949780980627632791914', + }, + totalNetworkFee: { + amount: '0.000008087', + usd: '0.00521708544', + valueInCurrency: '0.00446386226', + }, + }; + + const quoteResponseV2 = newState.quotes[0]; + expect(sortedQuotes[0]).toStrictEqual( + mergeQuoteMetadata(quoteResponseV2, expectedQuoteMetadata), + ); + }); + }); + + it('should only fetch quotes once if balance is insufficient', () => { + const mockState = getMockState(1); + const result = selectBridgeQuotes( + { + ...mockState, + quoteRequest: [ + { ...mockState.quoteRequest[0], insufficientBal: true }, + ], + }, + mockClientParams, + ); + + expect(result.sortedQuotes).toHaveLength(2); + expect(result.recommendedQuote).toBeDefined(); + expect(result.activeQuote).toBeDefined(); + expect(result.isLoading).toBe(false); + expect(result.quoteFetchError).toBeNull(); + expect(result.isQuoteGoingToRefresh).toBe(false); + }); + + it('should handle different sort orders', () => { + const mockState = getMockState(1); + const resultCostAsc = selectBridgeQuotes(mockState, { + ...mockClientParams, + sortOrder: SortOrder.COST_ASC, + }); + const resultEtaAsc = selectBridgeQuotes(mockState, { + ...mockClientParams, + sortOrder: SortOrder.ETA_ASC, + }); + + 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', () => { + const mockState = getMockState(1); + const selectedQuote = { + ...mockState.quotes[0], + quote: { ...mockState.quotes[0].quote, requestId: '123' }, + }; + + const result = selectBridgeQuotes(mockState, { + ...mockClientParams, + selectedQuote, + }); + + const recommendedQuoteV2 = mergeQuoteMetadata(mockState.quotes[1], { + minToTokenAmount: { + amount: '1.8', + usd: undefined, + valueInCurrency: undefined, + }, + sentAmount: { + amount: '1.1', + usd: '1980', + valueInCurrency: '1980', + }, + toTokenAmount: { + amount: '2.1', + usd: undefined, + valueInCurrency: undefined, + }, + swapRate: '1.90909090909090909091', + totalNetworkFee: { + amount: '0.0000073', + usd: '0.01314', + valueInCurrency: '0.01314', + }, + gasFee: { + total: { + amount: '0.0000073', + usd: '0.01314', + valueInCurrency: '0.01314', + }, + }, + }); + expect(result.recommendedQuote).toStrictEqual(recommendedQuoteV2); + expect(result.recommendedQuote).not.toStrictEqual(selectedQuote); + expect(result.activeQuote?.quote.requestId).toStrictEqual( + selectedQuote.quote.requestId, + ); + }); + + it('should set recommendedQuote as activeQuote when selected quote is not found', () => { + const mockState = getMockState(1); + const selectedQuote = { + ...mockState.quotes[0], + quote: { ...mockState.quotes[0].quote, requestId: 'abc' }, + } as never; + + const result = selectBridgeQuotes(mockState, { + ...mockClientParams, + selectedQuote, + }); + + const expectedQuote = mergeQuoteMetadata(mockState.quotes[1], { + minToTokenAmount: { + amount: '1.8', + usd: undefined, + valueInCurrency: undefined, + }, + sentAmount: { + amount: '1.1', + usd: '1980', + valueInCurrency: '1980', + }, + toTokenAmount: { + amount: '2.1', + usd: undefined, + valueInCurrency: undefined, + }, + swapRate: '1.90909090909090909091', + totalNetworkFee: { + amount: '0.0000073', + usd: '0.01314', + valueInCurrency: '0.01314', + }, + gasFee: { + total: { + amount: '0.0000073', + usd: '0.01314', + valueInCurrency: '0.01314', + }, + }, + }); + expect(result.recommendedQuote).toStrictEqual(expectedQuote); + expect(result.activeQuote).toStrictEqual(result.recommendedQuote); + }); + + it('should handle quote refresh state', () => { + const mockState = getMockState(1); + const stateWithMaxRefresh = { + ...mockState, + quotesRefreshCount: 5, + } as unknown as BridgeAppState; + + const result = selectBridgeQuotes(stateWithMaxRefresh, mockClientParams); + expect(result.isQuoteGoingToRefresh).toBe(false); + }); + + it('should handle loading state', () => { + const mockState = getMockState(1); + const loadingState = { + ...mockState, + quotesLoadingStatus: RequestStatus.LOADING, + } as unknown as BridgeAppState; + + const result = selectBridgeQuotes(loadingState, mockClientParams); + expect(result.isLoading).toBe(true); + }); + + it('should handle error state', () => { + const mockState = getMockState(1); + const errorState = { + ...mockState, + quoteFetchError: new Error('Test error'), + quotesLoadingStatus: RequestStatus.ERROR, + } as unknown as BridgeAppState; + + const result = selectBridgeQuotes(errorState, mockClientParams); + expect(result.quoteFetchError).toBeDefined(); + }); + + it('should handle Solana quotes', () => { + const solanaState = getMockState( + ChainId.SOLANA, + { + namespace: KnownCaipNamespace.Solana, + chainId: 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp', + nonEvmFeesInNative: '5000', + trade: 'SOLANATRADE', + quote: { + src: { + asset: { + decimals: 9, + assetId: getNativeAssetForChainId(ChainId.SOLANA).assetId, + symbol: 'SOL', + name: 'SOL', + }, + }, + dest: { + asset: { + decimals: 18, + assetId: + 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/token:gjslkdfjsljflds', + symbol: 'USDC', + name: 'USD Coin', + }, + }, + feeData: { + metabridge: [ + { + amount: '3000', + asset: toBridgeAssetV2( + getNativeAssetForChainId(ChainId.SOLANA), + ), + usd: '999', + }, + ], + network: [ + { + amount: '3000', + asset: toBridgeAssetV2( + getNativeAssetForChainId(ChainId.SOLANA), + ), + usd: '999', + }, + ], + }, + priceData: { + priceImpact: { + usd: '999', + valueInCurrency: '999', + }, + swapRate: '0.9', + }, + }, + }, + { + assetExchangeRates: { + 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/slip44:501': { + exchangeRate: '0.5', + usdExchangeRate: '10', + }, + 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/token:gjslkdfjsljflds': { + exchangeRate: '50005', + usdExchangeRate: '100000', + }, + }, + currencyRates: { + SOL: { + conversionDate: Date.now(), + conversionRate: 100, + usdConversionRate: 10000, + }, + }, + }, + ); + + const solanaQuote = solanaState.quotes[1]; + expect(solanaQuote.quote.dest.amount).toBe('2100000000000000000'); + + const expectedQuoteMetadata = calcQuoteMetadata(solanaQuote, { + srcTokenExchangeRate: { exchangeRate: '0.5', usdExchangeRate: '10' }, + bridgeFeesPerGas: { + estimatedBaseFeeInDecGwei: '0', + feePerGasInDecGwei: '.1', + }, + destTokenExchangeRate: { + exchangeRate: '50005', + usdExchangeRate: '100000', + }, + nativeExchangeRate: { exchangeRate: '0.5', usdExchangeRate: '10' }, + }); + const expectedQuoteV2 = mergeQuoteMetadata( + solanaQuote, + expectedQuoteMetadata, + ); + expect(expectedQuoteV2?.quote.dest.amount).toBe('2100000000000000000'); + + const result = selectBridgeQuotes(solanaState, mockClientParams); + expect(result.sortedQuotes).toHaveLength(2); + expect(result.recommendedQuote).toStrictEqual(expectedQuoteV2); + }); + }); + + describe('selectBatchSellQuotes', () => { + const getMockState = (chainId: string): BridgeAppState => + ({ + quotes: [ + ...mockBridgeQuotesErc20Erc20V1.map((quote) => ({ + ...quote, + quoteRequestIndex: 1, + })), + ...mockBridgeQuotesNativeErc20V1.map((quote) => ({ + ...quote, + quoteRequestIndex: 0, + })), + ].map(toQuoteResponseV2), + quoteRequest: [ + { + srcChainId: '10', + destChainId: '137', + srcTokenAddress: '0x0000000000000000000000000000000000000000', + destTokenAddress: '0x3c499c542cef5e3811e1192ce70d8cc03d5c3359', + insufficientBal: false, + }, + { + srcChainId: '10', + destChainId: '137', + srcTokenAddress: '0x0b2C639c533813f4Aa9D7837CAf62653d097Ff85', + destTokenAddress: '0x3c499c542cef5e3811e1192ce70d8cc03d5c3359', + insufficientBal: false, + }, + ], + quotesLastFetched: Date.now(), + quotesLoadingStatus: RequestStatus.FETCHED, + quoteFetchError: null, + quotesRefreshCount: 0, + quotesInitialLoadTime: Date.now(), + remoteFeatureFlags: { + bridgeConfig: { + minimumVersion: '0.0.0', + maxRefreshCount: 5, + refreshRate: 30000, + chainRanking: [], + chains: {}, + support: true, + }, + }, + assetExchangeRates: {}, + marketData: {}, + conversionRates: {}, + participateInMetaMetrics: true, + gasFeeEstimatesByChainId: { + [formatChainIdToHex(chainId)]: { + gasFeeEstimates: { + estimatedBaseFee: '0', + medium: { + suggestedMaxPriorityFeePerGas: '.1', + suggestedMaxFeePerGas: '.1', + }, + high: { + suggestedMaxPriorityFeePerGas: '.1', + suggestedMaxFeePerGas: '.2', + }, + }, + }, + }, + }) as unknown as BridgeAppState; + + const mockState = getMockState('10'); + + const mockClientParams = { + sortOrder: SortOrder.COST_ASC, + selectedQuote: null, + }; + + it('should return sorted quotes with metadata', () => { + const { quotesInitialLoadTimeMs, quotesLastFetchedMs, ...result } = + selectBatchSellQuotes( + { + ...mockState, + assetExchangeRates: { + 'eip155:10/erc20:0x0b2c639c533813f4aa9d7837caf62653d097ff85': { + exchangeRate: '1980', + usdExchangeRate: '10', + }, + 'eip155:137/erc20:0x3c499c542cef5e3811e1192ce70d8cc03d5c3359': { + exchangeRate: '200', + usdExchangeRate: '1', + }, + 'eip155:10/slip44:60': { + exchangeRate: '1800', + usdExchangeRate: '10', + }, + }, + }, + { + ...mockClientParams, + requestCount: 2, + migrationPhase: QuoteMetadataMigrationPhase.V1Data, + }, + ); + + const { totalReceived, minimumReceived, recommendedQuotes, ...rest } = + result; + + expect(totalReceived).toMatchInlineSnapshot(` + { + "amount": "38240503", + "asset": { + "assetId": "eip155:137/erc20:0x3c499c542cef5e3811e1192ce70d8cc03d5c3359", + "decimals": 6, + "iconUrl": "https://media.socket.tech/tokens/all/USDC", + "name": "Native USD Coin (POS)", + "symbol": "USDC", + }, + "minAmount": "37460000", + "minAmountNormalized": "37.46", + "minAmountUsd": "37.46", + "minAmountValueInCurrency": "7492", + "normalizedAmount": "38.240503", + "usd": "38.240503", + "valueInCurrency": "7648.1006", + } + `); + expect(minimumReceived).toMatchInlineSnapshot(` + { + "amount": "37460000", + "asset": { + "assetId": "eip155:137/erc20:0x3c499c542cef5e3811e1192ce70d8cc03d5c3359", + "decimals": 6, + "iconUrl": "https://media.socket.tech/tokens/all/USDC", + "name": "Native USD Coin (POS)", + "symbol": "USDC", + }, + "normalizedAmount": "37.46", + "usd": "37.46", + "valueInCurrency": "7492", + } + `); + expect(rest).toMatchInlineSnapshot(` + { + "isLoading": false, + "isQuoteGoingToRefresh": true, + "quoteFetchError": null, + "quotesRefreshCount": 0, + } + `); + expect(recommendedQuotes.map((quote) => quote?.quote.requestId)) + .toMatchInlineSnapshot(` + [ + "4277a368-40d7-4e82-aa67-74f29dc5f98a", + "90ae8e69-f03a-4cf6-bab7-ed4e3431eb37", + ] + `); + expect(recommendedQuotes.map((quote) => quote?.quote.src)) + .toMatchInlineSnapshot(` + [ + { + "amount": "10000000000000000", + "asset": { + "assetId": "eip155:10/slip44:60", + "decimals": 18, + "iconUrl": "https://media.socket.tech/tokens/all/ETH", + "name": "Ethereum", + "symbol": "ETH", + }, + "normalizedAmount": "0.01", + "usd": "0.1", + "valueInCurrency": "18", + }, + { + "amount": "14000000", + "asset": { + "assetId": "eip155:10/erc20:0x0b2c639c533813f4aa9d7837caf62653d097ff85", + "decimals": 6, + "iconUrl": "https://media.socket.tech/tokens/all/USDC", + "name": "USD Coin", + "symbol": "USDC", + }, + "normalizedAmount": "14", + "usd": "140", + "valueInCurrency": "27720", + }, + ] + `); + }); + + it('should return metadata when quotes are empty', () => { + const { quotesInitialLoadTimeMs, quotesLastFetchedMs, ...result } = + selectBatchSellQuotes( + { + ...mockState, + quotes: [], + assetExchangeRates: { + 'eip155:10/erc20:0x0b2c639c533813f4aa9d7837caf62653d097ff85': { + exchangeRate: '1980', + usdExchangeRate: '10', + }, + 'eip155:137/erc20:0x3c499c542cef5e3811e1192ce70d8cc03d5c3359': { + exchangeRate: '200', + usdExchangeRate: '1', + }, + }, + }, + { + ...mockClientParams, + requestCount: 2, + migrationPhase: QuoteMetadataMigrationPhase.V1Data, + }, + ); + + const { totalReceived, minimumReceived, recommendedQuotes, ...rest } = + result; + + expect(totalReceived).toBeUndefined(); + expect(minimumReceived).toBeUndefined(); + expect(rest).toMatchInlineSnapshot(` + { + "isLoading": false, + "isQuoteGoingToRefresh": true, + "quoteFetchError": null, + "quotesRefreshCount": 0, + } + `); + expect(mockState.quoteRequest).toHaveLength(2); + expect(recommendedQuotes).toStrictEqual([null, null]); + }); + + it('should default quoteRequestIndex to 0 when unset', () => { + const { recommendedQuotes } = selectBatchSellQuotes( + { + ...mockState, + quotes: getMockBridgeQuotesNativeErc20V2().map((quote) => ({ + ...quote, + quoteRequestIndex: undefined, + })), + assetExchangeRates: { + 'eip155:10/erc20:0x0b2c639c533813f4aa9d7837caf62653d097ff85': { + exchangeRate: '1980', + usdExchangeRate: '10', + }, + 'eip155:137/erc20:0x3c499c542cef5e3811e1192ce70d8cc03d5c3359': { + exchangeRate: '200', + usdExchangeRate: '1', + }, + }, + }, + { + ...mockClientParams, + requestCount: 1, + migrationPhase: QuoteMetadataMigrationPhase.V1Data, + }, + ); + + expect(recommendedQuotes).toHaveLength(1); + expect(recommendedQuotes[0]?.quote.requestId).toBeDefined(); + }); + }); + + describe('selectBatchSellTrades', () => { + const getMockState = (chainId: string): BridgeAppState => + ({ + quotes: [ + ...mockBridgeQuotesErc20Erc20V1.map((quote) => ({ + ...quote, + quoteRequestIndex: 1, + })), + ...mockBridgeQuotesNativeErc20V1.map((quote) => ({ + ...quote, + quoteRequestIndex: 0, + })), + ], + quoteRequest: [ + { + srcChainId: '10', + destChainId: '137', + srcTokenAddress: '0x0000000000000000000000000000000000000000', + destTokenAddress: '0x3c499c542cef5e3811e1192ce70d8cc03d5c3359', + insufficientBal: false, + }, + { + srcChainId: '10', + destChainId: '137', + srcTokenAddress: '0x0b2C639c533813f4Aa9D7837CAf62653d097Ff85', + destTokenAddress: '0x3c499c542cef5e3811e1192ce70d8cc03d5c3359', + insufficientBal: false, + }, + ], + quotesLastFetched: Date.now(), + quotesLoadingStatus: RequestStatus.FETCHED, + quoteFetchError: null, + quotesRefreshCount: 0, + quotesInitialLoadTime: Date.now(), + remoteFeatureFlags: { + bridgeConfig: { + minimumVersion: '0.0.0', + maxRefreshCount: 5, + refreshRate: 30000, + chainRanking: [], + chains: {}, + support: true, + }, + }, + assetExchangeRates: {}, + currencyRates: { + ETH: { + conversionRate: 1800, + usdConversionRate: 1800, + }, + }, + marketData: {}, + conversionRates: {}, + participateInMetaMetrics: true, + gasFeeEstimatesByChainId: { + [formatChainIdToHex(chainId)]: { + gasFeeEstimates: { + estimatedBaseFee: '0', + medium: { + suggestedMaxPriorityFeePerGas: '.1', + suggestedMaxFeePerGas: '.1', + }, + high: { + suggestedMaxPriorityFeePerGas: '.1', + suggestedMaxFeePerGas: '.2', + }, + }, + }, + }, + }) as unknown as BridgeAppState; + + const mockState = getMockState('10'); + + const mockBatchSellTrades = { + transactions: [ + { + chainId: 137, + to: '0x3c499c542cef5e3811e1192ce70d8cc03d5c3359', + from: '0x0b2C639c533813f4Aa9D7837CAf62653d097Ff85', + value: '0x0', + data: '0x', + gasLimit: 21000, + effectiveGas: 21000, + maxFeePerGas: '0x5d21dba00', + maxPriorityFeePerGas: '0x5d21dba00', + type: BatchSellTransactionType.TRANSFER, + } as const, + ], + fee: { + amount: '10000', + asset: { + assetId: + 'eip155:137/erc20:0x3c499c542cef5e3811e1192ce70d8cc03d5c3359' as const, + symbol: 'USDC', + chainId: 137, + address: '0x3c499c542cef5e3811e1192ce70d8cc03d5c3359', + name: 'USD Coin', + decimals: 6, + }, + }, + }; + + it('should return total network fee', () => { + const result = selectBatchSellTrades({ + ...mockState, + assetExchangeRates: { + 'eip155:10/erc20:0x0b2c639c533813f4aa9d7837caf62653d097ff85': { + exchangeRate: '1980', + usdExchangeRate: '10', + }, + 'eip155:137/erc20:0x3c499c542cef5e3811e1192ce70d8cc03d5c3359': { + exchangeRate: '200', + usdExchangeRate: '5', + }, + }, + batchSellTradesLoadingStatus: RequestStatus.FETCHED, + batchSellTrades: mockBatchSellTrades, + }); + + expect(result.totalNetworkFee).toMatchInlineSnapshot(` + { + "amount": "0.01", + "asset": { + "address": "0x3c499c542cef5e3811e1192ce70d8cc03d5c3359", + "assetId": "eip155:137/erc20:0x3c499c542cef5e3811e1192ce70d8cc03d5c3359", + "chainId": 137, + "decimals": 6, + "name": "USD Coin", + "symbol": "USDC", + }, + "usd": "0.05", + "valueInCurrency": "2", + } + `); + expect(result.isBatchSellTradeAvailable).toBe(true); + }); + + it('should return total network fee value when fee asset ID address is lowercase and market data is checksummed', () => { + const result = selectBatchSellTrades({ + ...mockState, + currencyRates: { + ETH: { + conversionRate: 1, + usdConversionRate: 1, + conversionDate: Date.now(), + }, + }, + marketData: { + '0x89': { + [getAddress(mockBatchSellTrades.fee.asset.address)]: { + price: 1, + currency: 'ETH', + } as never, + }, + }, + batchSellTradesLoadingStatus: RequestStatus.FETCHED, + batchSellTrades: mockBatchSellTrades, + } as unknown as BridgeAppState); + + expect(result.totalNetworkFee).toMatchInlineSnapshot(` + { + "amount": "0.01", + "asset": { + "address": "0x3c499c542cef5e3811e1192ce70d8cc03d5c3359", + "assetId": "eip155:137/erc20:0x3c499c542cef5e3811e1192ce70d8cc03d5c3359", + "chainId": 137, + "decimals": 6, + "name": "USD Coin", + "symbol": "USDC", + }, + "usd": "0.01", + "valueInCurrency": "0.01", + } + `); + expect(result.isBatchSellTradeAvailable).toBe(true); + }); + + it('should return total network fee (exchange rates are not available)', () => { + const result = selectBatchSellTrades({ + ...mockState, + assetExchangeRates: { + 'eip155:10/erc20:0x0b2c639c533813f4aa9d7837caf62653d097ff84': { + exchangeRate: '1980', + usdExchangeRate: '10', + }, + 'eip155:137/erc20:0x3c499c542cef5e3811e1192ce70d8cc03d5c3354': { + exchangeRate: '200', + usdExchangeRate: '5', + }, + }, + batchSellTradesLoadingStatus: RequestStatus.FETCHED, + batchSellTrades: mockBatchSellTrades, + }); + + expect(result.totalNetworkFee).toMatchInlineSnapshot(` + { + "amount": "0.01", + "asset": { + "address": "0x3c499c542cef5e3811e1192ce70d8cc03d5c3359", + "assetId": "eip155:137/erc20:0x3c499c542cef5e3811e1192ce70d8cc03d5c3359", + "chainId": 137, + "decimals": 6, + "name": "USD Coin", + "symbol": "USDC", + }, + "usd": null, + "valueInCurrency": null, + } + `); + expect(result.isBatchSellTradeAvailable).toBe(true); + expect(result.isLoading).toBe(false); + }); + + it('should return empty data when batch sell trades are not defined', () => { + const result = selectBatchSellTrades({ + ...mockState, + assetExchangeRates: { + 'eip155:10/erc20:0x0b2c639c533813f4aa9d7837caf62653d097ff85': { + exchangeRate: '1980', + usdExchangeRate: '10', + }, + 'eip155:137/erc20:0x3c499c542cef5e3811e1192ce70d8cc03d5c3359': { + exchangeRate: '200', + usdExchangeRate: '5', + }, + }, + batchSellTradesLoadingStatus: RequestStatus.FETCHED, + batchSellTrades: null, + }); + + expect(result.totalNetworkFee).toMatchInlineSnapshot(`undefined`); + expect(result.isBatchSellTradeAvailable).toBe(false); + expect(result.isLoading).toBe(false); + }); + + it.each([ + { + status: RequestStatus.LOADING, + transactions: [ + { + chainId: 137, + to: '0x3c499c542cef5e3811e1192ce70d8cc03d5c3359', + from: '0x0b2C639c533813f4Aa9D7837CAf62653d097Ff85', + value: '0x0', + data: '0x', + gasLimit: 21000, + effectiveGas: 21000, + maxFeePerGas: '0x5d21dba00', + maxPriorityFeePerGas: '0x5d21dba00', + }, + ], + expectedResult: false, + expectedLoadingResult: true, + }, + { + status: RequestStatus.FETCHED, + transactions: [ + { + chainId: 137, + to: '0x3c499c542cef5e3811e1192ce70d8cc03d5c3359', + from: '0x0b2C639c533813f4Aa9D7837CAf62653d097Ff85', + value: '0x0', + data: '0x', + gasLimit: 21000, + effectiveGas: 21000, + maxFeePerGas: '0x5d21dba00', + maxPriorityFeePerGas: '0x5d21dba00', + }, + ], + expectedResult: true, + expectedLoadingResult: false, + }, + { + status: RequestStatus.FETCHED, + transactions: undefined, + expectedResult: false, + expectedLoadingResult: false, + }, + { + status: RequestStatus.FETCHED, + transactions: [], + expectedResult: false, + expectedLoadingResult: false, + }, + { + status: RequestStatus.ERROR, + transactions: undefined, + expectedResult: false, + expectedLoadingResult: false, + }, + ])( + 'should return loading state when status is $status', + ({ status, transactions, expectedResult, expectedLoadingResult }) => { + const { isBatchSellTradeAvailable, isLoading } = selectBatchSellTrades({ + ...mockState, + batchSellTradesLoadingStatus: status, + // @ts-expect-error - test data + batchSellTrades: transactions + ? { + fee: { + amount: '10000', + }, + transactions, + } + : null, + assetExchangeRates: { + 'eip155:10/erc20:0x0b2c639c533813f4aa9d7837caf62653d097ff85': { + exchangeRate: '1980', + usdExchangeRate: '10', + }, + 'eip155:137/erc20:0x3c499c542cef5e3811e1192ce70d8cc03d5c3359': { + exchangeRate: '200', + usdExchangeRate: '1', + }, + }, + }); + + expect(isBatchSellTradeAvailable).toBe(expectedResult); + expect(isLoading).toBe(expectedLoadingResult); + }, + ); + }); + + describe('selectBridgeFeatureFlags', () => { + const mockValidBridgeConfig = { + minimumVersion: '0.0.0', + refreshRate: 3, + maxRefreshCount: 1, + support: true, + chainRanking: [], + chains: { + '1': { + isActiveSrc: true, + isActiveDest: true, + batchSellDestStablecoins: [ + 'eip155:1/erc20:0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48', + 'eip155:1/slip44:60', + ], + }, + '10': { + isActiveSrc: true, + isActiveDest: false, + }, + '59144': { + isActiveSrc: true, + isActiveDest: true, + }, + '120': { + isActiveSrc: true, + isActiveDest: false, + }, + '137': { + isActiveSrc: false, + isActiveDest: true, + }, + '11111': { + isActiveSrc: false, + isActiveDest: true, + }, + '1151111081099710': { + isActiveSrc: true, + isActiveDest: true, + }, + }, + }; + + const mockInvalidBridgeConfig = { + minimumVersion: 1, // Should be a string + maxRefreshCount: 'invalid', // Should be a number + refreshRate: 'invalid', // Should be a number + chains: 'invalid', // Should be an object + }; + + it('should return formatted feature flags when valid config is provided', () => { + const result = selectBridgeFeatureFlags({ + remoteFeatureFlags: { + bridgeConfig: mockValidBridgeConfig, + }, + }); + + expect(result).toStrictEqual({ + minimumVersion: '0.0.0', + refreshRate: 3, + maxRefreshCount: 1, + support: true, + chainRanking: [...DEFAULT_CHAIN_RANKING], + chains: { + 'eip155:1': { + isActiveSrc: true, + isActiveDest: true, + batchSellDestStablecoins: [ + 'eip155:1/erc20:0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48', + 'eip155:1/slip44:60', + ], + }, + 'eip155:10': { + isActiveSrc: true, + isActiveDest: false, + }, + 'eip155:59144': { + isActiveSrc: true, + isActiveDest: true, + }, + 'eip155:120': { + isActiveSrc: true, + isActiveDest: false, + }, + 'eip155:137': { + isActiveSrc: false, + isActiveDest: true, + }, + 'eip155:11111': { + isActiveSrc: false, + isActiveDest: true, + }, + 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp': { + isActiveSrc: true, + isActiveDest: true, + }, + }, + }); + }); + + it('should return default feature flags when invalid config is provided', () => { + const result = selectBridgeFeatureFlags({ + remoteFeatureFlags: { + bridgeConfig: mockInvalidBridgeConfig, + }, + }); + + expect(result).toStrictEqual({ + minimumVersion: '0.0.0', + maxRefreshCount: 5, + refreshRate: 30000, + chainRanking: [...DEFAULT_CHAIN_RANKING], + chains: {}, + support: false, + }); + }); + + it('should return default feature flags when bridgeConfig is undefined', () => { + const result = selectBridgeFeatureFlags({ + // @ts-expect-error - This is a test case + remoteFeatureFlags: {}, + }); + + expect(result).toStrictEqual({ + minimumVersion: '0.0.0', + maxRefreshCount: 5, + refreshRate: 30000, + chainRanking: [...DEFAULT_CHAIN_RANKING], + chains: {}, + support: false, + }); + }); + + it('should return default feature flags when bridgeConfig is null', () => { + const result = selectBridgeFeatureFlags({ + remoteFeatureFlags: { + bridgeConfig: null, + }, + }); + + expect(result).toStrictEqual({ + minimumVersion: '0.0.0', + maxRefreshCount: 5, + refreshRate: 30000, + chainRanking: [...DEFAULT_CHAIN_RANKING], + chains: {}, + support: false, + }); + }); + }); + + describe('selectMinimumBalanceForRentExemptionInSOL', () => { + it('should convert lamports to SOL', () => { + const state = { + minimumBalanceForRentExemptionInLamports: '1000000000', // 1 SOL + } as BridgeAppState; + + const result = selectMinimumBalanceForRentExemptionInSOL(state); + + expect(result).toBe('1'); + }); + + it('should handle undefined minimumBalanceForRentExemptionInLamports', () => { + const state = {} as BridgeAppState; + + const result = selectMinimumBalanceForRentExemptionInSOL(state); + + expect(result).toBe('0'); + }); + + it('should handle null minimumBalanceForRentExemptionInLamports', () => { + const state = { + minimumBalanceForRentExemptionInLamports: null, + } as unknown as BridgeAppState; + + const result = selectMinimumBalanceForRentExemptionInSOL(state); + + expect(result).toBe('0'); + }); + + it('should handle fractional SOL amounts', () => { + const state = { + minimumBalanceForRentExemptionInLamports: '500000000', // 0.5 SOL + } as BridgeAppState; + + const result = selectMinimumBalanceForRentExemptionInSOL(state); + + expect(result).toBe('0.5'); + }); + }); + + describe('selectDefaultSlippagePercentage', () => { + const mockValidBridgeConfig = { + minimumVersion: '0.0.0', + refreshRate: 3, + maxRefreshCount: 1, + support: true, + chainRanking: [], + chains: { + '1': { + isActiveSrc: true, + isActiveDest: true, + stablecoins: [MOCK_USDC_ADDRESS, '0x456'], + }, + '10': { + isActiveSrc: true, + isActiveDest: false, + }, + '1151111081099710': { + isActiveSrc: true, + isActiveDest: true, + }, + }, + }; + + it('should return swap default slippage when stablecoins list is not defined', () => { + const result = selectDefaultSlippagePercentage( + { + remoteFeatureFlags: { + bridgeConfig: mockValidBridgeConfig, + }, + } as never, + { + srcTokenAddress: MOCK_USDC_ADDRESS, + destTokenAddress: '0x456', + srcChainId: '10', + destChainId: '10', + }, + ); + + expect(result).toBe(2); + }); + + it('should return bridge default slippage when requesting an EVM bridge quote', () => { + const result = selectDefaultSlippagePercentage( + { + remoteFeatureFlags: { + bridgeConfig: mockValidBridgeConfig, + }, + } as never, + { + srcTokenAddress: MOCK_USDC_ADDRESS, + destTokenAddress: '0x456', + srcChainId: '1', + destChainId: ChainId.SOLANA, + }, + ); + + expect(result).toBe(0.5); + }); + + it('should return bridge default slippage when requesting a Solana bridge quote', () => { + const result = selectDefaultSlippagePercentage( + { + remoteFeatureFlags: { + bridgeConfig: mockValidBridgeConfig, + }, + } as never, + { + srcTokenAddress: MOCK_USDC_ADDRESS, + destTokenAddress: '0x456', + destChainId: '1', + srcChainId: ChainId.SOLANA, + }, + ); + + expect(result).toBe(0.5); + }); + + it('should return swap auto slippage when requesting a Solana swap quote', () => { + const result = selectDefaultSlippagePercentage( + { + remoteFeatureFlags: { + bridgeConfig: mockValidBridgeConfig, + }, + } as never, + { + srcTokenAddress: MOCK_USDC_ADDRESS, + destTokenAddress: '0x456', + destChainId: ChainId.SOLANA, + srcChainId: ChainId.SOLANA, + }, + ); + + expect(result).toBeUndefined(); + }); + + it('should return swap default slippage when dest token is not a stablecoin', () => { + const result = selectDefaultSlippagePercentage( + { + remoteFeatureFlags: { + bridgeConfig: mockValidBridgeConfig, + }, + } as never, + { + srcTokenAddress: MOCK_USDC_ADDRESS, + destTokenAddress: '0x789', + destChainId: '1', + srcChainId: '1', + }, + ); + + expect(result).toBe(2); + }); + + it('should return swap default slippage when src token is not a stablecoin', () => { + const result = selectDefaultSlippagePercentage( + { + remoteFeatureFlags: { + bridgeConfig: mockValidBridgeConfig, + }, + } as never, + { + srcTokenAddress: '0x789', + destTokenAddress: '0x456', + destChainId: '1', + srcChainId: '1', + }, + ); + + expect(result).toBe(2); + }); + + it('should return swap stablecoin slippage when both tokens are stablecoins', () => { + const result = selectDefaultSlippagePercentage( + { + remoteFeatureFlags: { + bridgeConfig: mockValidBridgeConfig, + }, + } as never, + { + srcTokenAddress: MOCK_USDC_ADDRESS, + destTokenAddress: '0x456', + destChainId: '1', + srcChainId: '1', + }, + ); + + expect(result).toBe(0.5); + }); + + it('should return bridge default slippage when srcChainId is undefined', () => { + const result = selectDefaultSlippagePercentage( + { + remoteFeatureFlags: { + bridgeConfig: mockValidBridgeConfig, + }, + } as never, + { + srcTokenAddress: MOCK_USDC_ADDRESS, + destTokenAddress: '0x456', + destChainId: '1', + }, + ); + + expect(result).toBe(0.5); + }); + + it('should return swap stablecoin slippage when destChainId is undefined', () => { + const result = selectDefaultSlippagePercentage( + { + remoteFeatureFlags: { + bridgeConfig: mockValidBridgeConfig, + }, + } as never, + { + srcTokenAddress: MOCK_USDC_ADDRESS, + destTokenAddress: '0x456', + srcChainId: '1', + }, + ); + + expect(result).toBe(0.5); + }); + + it('should return swap default slippage when destChainId is undefined', () => { + const result = selectDefaultSlippagePercentage( + { + remoteFeatureFlags: { + bridgeConfig: mockValidBridgeConfig, + }, + } as never, + { + srcTokenAddress: '0x789', + destTokenAddress: '0x456', + srcChainId: '1', + }, + ); + + expect(result).toBe(2); + }); + }); + + describe('selectTokenWarnings', () => { + it('should return the tokenWarnings array from state', () => { + const warnings = [ + { + feature_id: 'HONEYPOT', + type: 'Malicious', + description: 'Token is a honeypot', + }, + { + feature_id: 'FAKE_TOKEN', + type: 'Warning', + description: 'Possible fake token', + }, + ]; + const state = { tokenWarnings: warnings } as unknown as BridgeAppState; + + expect(selectTokenWarnings(state)).toBe(warnings); + }); + + it('should return an empty array when there are no warnings', () => { + const state = { tokenWarnings: [] } as unknown as BridgeAppState; + + expect(selectTokenWarnings(state)).toStrictEqual([]); + }); + }); +}); diff --git a/packages/bridge-controller/src/selectors.ts b/packages/bridge-controller/src/selectors.ts new file mode 100644 index 00000000000..39677f3c211 --- /dev/null +++ b/packages/bridge-controller/src/selectors.ts @@ -0,0 +1,748 @@ +/* eslint-disable @typescript-eslint/explicit-function-return-type */ +import { getAddress } from '@ethersproject/address'; +import type { + CurrencyRateState, + MultichainAssetsRatesControllerState, + TokenRatesControllerState, +} from '@metamask/assets-controllers'; +import type { + GasFeeEstimates, + GasFeeEstimatesByChainId, +} from '@metamask/gas-fee-controller'; +import type { CaipAssetType } from '@metamask/utils'; +import { isStrictHexString, parseCaipAssetType } from '@metamask/utils'; +import { BigNumber } from 'bignumber.js'; +import { orderBy } from 'lodash'; +import { + createSelector as createSelector_, + createStructuredSelector as createStructuredSelector_, +} from 'reselect'; + +import { BRIDGE_PREFERRED_GAS_ESTIMATE } from './constants/bridge.js'; +import type { + BridgeControllerState, + DeepPartial, + ExchangeRate, +} from './types.js'; +import { RequestStatus, SortOrder } from './types.js'; +import { + getNativeAssetForChainId, + isNativeAddress, + isNonEvmChainId, +} from './utils/bridge.js'; +import { + formatAddressToAssetId, + formatAddressToCaipReference, + formatChainIdToCaip, + formatChainIdToHex, +} from './utils/caip-formatters.js'; +import { processFeatureFlags } from './utils/feature-flags.js'; +import { sumAmounts } from './utils/number-formatters.js'; +import { + calcBatchFees, + calcQuoteMetadata, +} from './utils/quote-metadata/calculators.js'; +import { mergeQuoteMetadata } from './utils/quote-metadata/merge.js'; +import { toCurrencyValues } from './utils/quote-metadata/to-currency-values.js'; +import type { QuoteMetadata } from './utils/quote-metadata/types.js'; +import { QuoteMetadataMigrationPhase } from './utils/quote-metadata/types.js'; +import { getDefaultSlippagePercentage } from './utils/slippage.js'; +import type { QuoteResponse } from './validators/quote-response.js'; + +const EMPTY_QUOTE_METADATA: never[] = []; + +/** + * The controller states that provide exchange rates + */ +type ExchangeRateControllerState = MultichainAssetsRatesControllerState & + TokenRatesControllerState & + CurrencyRateState & + Pick; +/** + * The state of the bridge controller and all its dependency controllers + */ +type RemoteFeatureFlagControllerState = { + remoteFeatureFlags: { + bridgeConfig: unknown; + }; +}; + +/** + * Minimal shape required for exchange-rate lookups (used by getExchangeRateByChainIdAndAddress). + * Uses types from assets-controllers; marketData and conversionRates also accept the bridge format. + */ +export type ExchangeRateSourcesForLookup = Pick< + BridgeControllerState, + 'assetExchangeRates' +> & + Partial> & { + marketData?: + | TokenRatesControllerState['marketData'] + | Record>; + conversionRates?: + | MultichainAssetsRatesControllerState['conversionRates'] + | Record; + }; + +export type BridgeAppState = BridgeControllerState & { + gasFeeEstimatesByChainId: GasFeeEstimatesByChainId; +} & ExchangeRateControllerState & { + participateInMetaMetrics: boolean; + } & RemoteFeatureFlagControllerState; +/** + * Creates a structured selector for the bridge controller + */ +const createStructuredBridgeSelector = + createStructuredSelector_.withTypes(); +/** + * Creates a typed selector for the bridge controller + */ +const createBridgeSelector = createSelector_.withTypes(); +/** + * Required parameters that clients must provide for the bridge quotes selector + */ +type BridgeQuotesClientParams = { + sortOrder: SortOrder; + selectedQuote: (QuoteResponse & QuoteMetadata) | null; + migrationPhase: QuoteMetadataMigrationPhase; +}; + +type EvmTokenExchangeRate = { price?: number; currency?: string }; +type EvmTokenExchangeRates = Record; + +const createFeatureFlagsSelector = + createSelector_.withTypes(); + +/** + * Selects the bridge feature flags + * + * @param state - The state of the bridge controller + * @returns The bridge feature flags + * + * @example + * ```ts + * const featureFlags = useSelector(state => selectBridgeFeatureFlags(state)); + * + * Or + * + * export const selectBridgeFeatureFlags = createSelector( + * selectRemoteFeatureFlags, + * (remoteFeatureFlags) => + * selectBridgeFeatureFlagsBase({ + * bridgeConfig: remoteFeatureFlags.bridgeConfig, + * }), + * ); + * ``` + */ +export const selectBridgeFeatureFlags = createFeatureFlagsSelector( + [(state) => state.remoteFeatureFlags.bridgeConfig], + (bridgeConfig: unknown) => processFeatureFlags(bridgeConfig), +); + +const getEvmTokenExchangeRateForAddress = ( + evmTokenExchangeRates: EvmTokenExchangeRates | undefined, + address: string, +): EvmTokenExchangeRate | null | undefined => { + try { + return isStrictHexString(address) + ? (evmTokenExchangeRates?.[getAddress(address)] ?? + evmTokenExchangeRates?.[address.toLowerCase()]) + : null; + } catch { + return null; + } +}; + +/** + * Selects the asset exchange rate for a given chain and address + * + * @param exchangeRateSources - the controller states containing the exchange rates + * @param assetId - the assetId to get the exchange rate for + * @returns The asset exchange rate for the given assetId + */ +export const selectExchangeRateByAssetId = ( + exchangeRateSources: ExchangeRateSourcesForLookup, + assetId?: CaipAssetType, +): ExchangeRate => { + if (!assetId) { + return {}; + } + + const { assetExchangeRates, currencyRates, marketData, conversionRates } = + exchangeRateSources; + + // 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.toLowerCase() as CaipAssetType] ?? + assetExchangeRates?.[assetId]; + if ( + bridgeControllerRate?.exchangeRate && + bridgeControllerRate?.usdExchangeRate + ) { + return bridgeControllerRate; + } + + const { chainId } = parseCaipAssetType(assetId); + + // If the chain is a non-EVM chain, use the conversion rate from the multichain assets controller + if (isNonEvmChainId(chainId)) { + const conversionRatesByKey = conversionRates as + | Record + | undefined; + const multichainAssetExchangeRate = conversionRatesByKey?.[assetId]; + const rate = multichainAssetExchangeRate?.rate; + if (rate) { + // 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 = + currencyRates && + Object.values(currencyRates).find( + (rateEntry) => + rateEntry?.conversionRate !== undefined && + rateEntry?.conversionRate !== null && + rateEntry?.usdConversionRate !== undefined && + rateEntry?.usdConversionRate !== null, + ); + const usersCurrencyToUsdRate = + nativeCurrencyRate?.conversionRate !== undefined && + nativeCurrencyRate?.conversionRate !== null && + nativeCurrencyRate?.usdConversionRate !== undefined && + nativeCurrencyRate?.usdConversionRate !== null + ? new BigNumber(nativeCurrencyRate.usdConversionRate).div( + nativeCurrencyRate.conversionRate, + ) + : undefined; + const usdExchangeRate = usersCurrencyToUsdRate + ? new BigNumber(rate).times(usersCurrencyToUsdRate).toString() + : undefined; + return { + exchangeRate: rate, + usdExchangeRate, + }; + } + return {}; + } + + const address = formatAddressToCaipReference(assetId); + + // If the chain is an EVM chain, use the conversion rate from the currency rates controller + if (isNativeAddress(address)) { + const { symbol } = getNativeAssetForChainId(chainId); + const evmNativeExchangeRate = currencyRates?.[symbol]; + if (evmNativeExchangeRate) { + return { + exchangeRate: evmNativeExchangeRate.conversionRate?.toString(), + usdExchangeRate: evmNativeExchangeRate.usdConversionRate?.toString(), + }; + } + return {}; + } + // If the chain is an EVM chain and the asset is not the native asset, use the conversion rate from the token rates controller + if (!isNonEvmChainId(chainId)) { + const marketDataByChain = + (marketData as Record | undefined) ?? {}; + const evmTokenExchangeRates = + marketDataByChain[formatChainIdToHex(chainId)]; + const evmTokenExchangeRateForAddress = getEvmTokenExchangeRateForAddress( + evmTokenExchangeRates, + address, + ); + const currencyKey = evmTokenExchangeRateForAddress?.currency; + const nativeCurrencyRate = + currencyKey !== undefined && currencyKey !== null + ? currencyRates?.[currencyKey] + : undefined; + const price = evmTokenExchangeRateForAddress?.price; + // A missing or zero price is not a usable exchange rate. Returning a "0" + // rate here is harmful in two ways: it surfaces a $0 fiat value, and it + // makes `selectIsAssetExchangeRateInState` treat the token as already + // priced (the non-empty "0" string is truthy), which prevents the + // controller from fetching the real rate. Fall through to `{}` instead so + // the rate gets fetched from the price API. + if (price && nativeCurrencyRate) { + return { + exchangeRate: new BigNumber(price) + .multipliedBy(nativeCurrencyRate.conversionRate ?? 0) + .toString(), + usdExchangeRate: new BigNumber(price) + .multipliedBy(nativeCurrencyRate.usdConversionRate ?? 0) + .toString(), + }; + } + } + + return {}; +}; + +/** + * Checks whether an exchange rate is available for a given assetId + * + * @param state The state of the bridge controller and its dependency controllers + * @param assetId The assetId to check + * @returns Whether an exchange rate is available for the given chain and address + */ +export const selectIsAssetExchangeRateInState = ( + state: ExchangeRateSourcesForLookup, + assetId?: CaipAssetType, +) => + Boolean(selectExchangeRateByAssetId(state, assetId)?.exchangeRate) && + Boolean(selectExchangeRateByAssetId(state, assetId)?.usdExchangeRate); + +/** + * Selects the gas fee estimates from the gas fee controller. All potential networks + * support EIP1559 gas fees so assume that gasFeeEstimates is of type GasFeeEstimates + * + * @param state - The state of the bridge controller and its dependency controllers + * @param state.gasFeeEstimatesByChainId - gasEstimates by Hex ChainId + * @param state.quotes - Fetched bridge/swap quotes + * @returns The gas fee estimates in decGWEI + */ +const selectBridgeFeesPerGas = createBridgeSelector( + [ + (state) => state.gasFeeEstimatesByChainId, + (state) => state.quotes?.[0]?.chainId, + ], + (gasFeeEstimatesByChainId, srcChainId) => { + if (!srcChainId) { + return null; + } + if (isNonEvmChainId(srcChainId)) { + return null; + } + // @ts-expect-error - all supported networks use this type of estimates + const gasFeeEstimates: GasFeeEstimates | undefined = + gasFeeEstimatesByChainId?.[ + formatChainIdToHex(srcChainId) as keyof typeof gasFeeEstimatesByChainId + ]?.gasFeeEstimates; + if (!gasFeeEstimates) { + return null; + } + return { + estimatedBaseFeeInDecGwei: gasFeeEstimates.estimatedBaseFee, + feePerGasInDecGwei: + gasFeeEstimates[BRIDGE_PREFERRED_GAS_ESTIMATE]?.suggestedMaxFeePerGas, + maxFeePerGasInDecGwei: gasFeeEstimates.high?.suggestedMaxFeePerGas, + }; + }, +); + +const selectExchangeRateSources = createStructuredBridgeSelector({ + currencyRates: (state) => state.currencyRates, + marketData: (state) => state.marketData, + conversionRates: (state) => state.conversionRates, + assetExchangeRates: (state) => state.assetExchangeRates, +}); + +// Selects metadata for cross-chain swap quotes +const selectMetadata = createBridgeSelector( + [ + ({ quotes }) => quotes, + selectBridgeFeesPerGas, + selectExchangeRateSources, + ({ quoteRequest }) => quoteRequest, + (_, { migrationPhase }: BridgeQuotesClientParams) => migrationPhase, + ], + ( + quotes, + bridgeFeesPerGas, + exchangeRateSources, + quoteRequest, + migrationPhase, + ) => { + // Return early if the migration phase is V2Only because we don't need to calculate metadata + if (migrationPhase === QuoteMetadataMigrationPhase.V2Only) { + return EMPTY_QUOTE_METADATA; + } + const { destTokenAddress, srcChainId, destChainId } = quoteRequest[0] ?? {}; + + return quotes.map((quote) => + calcQuoteMetadata(quote, { + srcTokenExchangeRate: selectExchangeRateByAssetId( + exchangeRateSources, + quote.quote.src.asset.assetId, + ), + bridgeFeesPerGas, + destTokenExchangeRate: selectExchangeRateByAssetId( + exchangeRateSources, + quote.quote.dest.asset.assetId ?? + formatAddressToAssetId( + destTokenAddress ?? quote.quote.dest.asset.assetId, + destChainId, + ), + ), + nativeExchangeRate: selectExchangeRateByAssetId( + exchangeRateSources, + getNativeAssetForChainId(srcChainId ?? quote.chainId)?.assetId, + ), + }), + ); + }, +); + +/** + * Selects the USD to fiat exchange rate based on the native asset's price + * + * @param options - The options for the selector + * @param options.quoteRequest - The quote request + * @returns The USD to fiat exchange rate in string format + */ +const selectUsdToCurrencyExchangeRate = createBridgeSelector( + [ + selectExchangeRateSources, + ({ quoteRequest }) => + getNativeAssetForChainId(quoteRequest[0]?.srcChainId ?? 1)?.assetId, + ], + (exchangeRateSources, nativeAssetId) => { + const exchangeRate = selectExchangeRateByAssetId( + exchangeRateSources, + nativeAssetId, + ); + return exchangeRate?.exchangeRate && exchangeRate?.usdExchangeRate + ? new BigNumber(exchangeRate.exchangeRate) + .div(exchangeRate.usdExchangeRate) + .toFixed() + : undefined; + }, +); + +const selectCurrencyValues = createBridgeSelector( + [ + ({ quotes }) => quotes, + selectUsdToCurrencyExchangeRate, + (_, { migrationPhase }: BridgeQuotesClientParams) => migrationPhase, + ], + (quotes, usdToFiatExchangeRateString, migrationPhase) => { + if (migrationPhase === QuoteMetadataMigrationPhase.V1Data) { + return EMPTY_QUOTE_METADATA; + } + const usdToFiatExchangeRate = usdToFiatExchangeRateString + ? new BigNumber(usdToFiatExchangeRateString) + : undefined; + return quotes.map((quote) => + toCurrencyValues(quote, usdToFiatExchangeRate), + ); + }, +); + +// Selects cross-chain swap quotes including their metadata +const selectBridgeQuotesWithMetadata = createBridgeSelector( + [ + selectMetadata, + selectCurrencyValues, + ({ quotes }) => quotes, + (_, { migrationPhase }: BridgeQuotesClientParams) => migrationPhase, + ], + (legacyQuoteMetadata, quoteMetadataV2, quotes, migrationPhase) => + quotes.map((quote, index) => + mergeQuoteMetadata( + quote, + legacyQuoteMetadata[index], + migrationPhase, + quoteMetadataV2[index], + ), + ), +); + +const selectSortedBridgeQuotes = createBridgeSelector( + [ + selectBridgeQuotesWithMetadata, + (_, { sortOrder }: BridgeQuotesClientParams) => sortOrder, + ], + (quotesWithMetadata, sortOrder): (QuoteResponse & QuoteMetadata)[] => { + switch (sortOrder) { + case SortOrder.ETA_ASC: + return orderBy( + quotesWithMetadata, + (quote) => quote.estimatedProcessingTimeInSeconds, + 'asc', + ); + default: + if ( + quotesWithMetadata.every( + (quote) => quote.quote.priceData?.priceImpact?.amount, + ) + ) { + return orderBy( + quotesWithMetadata, + ({ quote: { priceData } }) => + Number(priceData?.priceImpact?.amount), + 'asc', + ); + } else if ( + quotesWithMetadata.every( + (quote) => quote.quote.priceData?.priceImpact?.valueInCurrency, + ) + ) { + return orderBy( + quotesWithMetadata, + ({ quote: { priceData } }) => + Number(priceData?.priceImpact?.valueInCurrency), + 'asc', + ); + } + return orderBy( + quotesWithMetadata, + ({ quote }) => Number(quote.dest.amount), + 'desc', + ); + } + }, +); + +const selectRecommendedQuote = createBridgeSelector( + [selectSortedBridgeQuotes], + (quotes) => (quotes.length > 0 ? quotes[0] : null), +); + +const selectActiveQuote = createBridgeSelector( + [ + selectRecommendedQuote, + selectSortedBridgeQuotes, + (_, { selectedQuote }) => selectedQuote?.quote.requestId, + ], + (recommendedQuote, sortedQuotes, requestId) => + sortedQuotes.find((quote) => quote.quote.requestId === requestId) ?? + recommendedQuote, +); + +const selectIsQuoteGoingToRefresh = createBridgeSelector( + [ + selectBridgeFeatureFlags, + // If at least one quote request is sufficiently funded, continue polling until max refresh count is reached + (state) => + state.quoteRequest.every((quoteRequest) => + Boolean(quoteRequest.insufficientBal), + ), + (state) => state.quotesRefreshCount, + ], + (featureFlags, insufficientBal, quotesRefreshCount) => + insufficientBal ? false : featureFlags.maxRefreshCount > quotesRefreshCount, +); + +const selectQuoteRefreshRate = createBridgeSelector( + [selectBridgeFeatureFlags, (state) => state.quoteRequest[0]?.srcChainId], + (featureFlags, srcChainId) => + (srcChainId + ? featureFlags.chains[formatChainIdToCaip(srcChainId)]?.refreshRate + : featureFlags.refreshRate) ?? featureFlags.refreshRate, +); + +export const selectIsQuoteExpired = createBridgeSelector( + [ + selectIsQuoteGoingToRefresh, + ({ quotesLastFetched }) => quotesLastFetched, + selectQuoteRefreshRate, + (_, _ignoredParam, currentTimeInMs: number) => currentTimeInMs, + ], + (isQuoteGoingToRefresh, quotesLastFetched, refreshRate, currentTimeInMs) => + Boolean( + !isQuoteGoingToRefresh && + quotesLastFetched && + currentTimeInMs - quotesLastFetched > refreshRate, + ), +); + +/** + * Selects sorted cross-chain swap quotes. By default, the quotes are sorted by cost in ascending order. + * + * @param state - The state of the bridge controller and its dependency controllers + * @param sortOrder - The sort order of the quotes + * @param selectedQuote - The quote that is currently selected by the user, should be cleared by clients when the req params change + * @returns The activeQuote, recommendedQuote, sortedQuotes, and other quote fetching metadata + * + * @example + * ```ts + * const quotes = useSelector(state => selectBridgeQuotes( + * { ...state.metamask, bridgeConfig: remoteFeatureFlags.bridgeConfig }, + * { + * sortOrder: state.bridge.sortOrder, + * selectedQuote: state.bridge.selectedQuote, + * migrationPhase: '1.5', + * } + * )); + * ``` + */ +export const selectBridgeQuotes = createStructuredBridgeSelector({ + sortedQuotes: selectSortedBridgeQuotes, + recommendedQuote: selectRecommendedQuote, + activeQuote: selectActiveQuote, + quotesLastFetchedMs: (state) => state.quotesLastFetched, + isLoading: (state) => state.quotesLoadingStatus === RequestStatus.LOADING, + quoteFetchError: (state) => state.quoteFetchError, + quotesRefreshCount: (state) => state.quotesRefreshCount, + quotesInitialLoadTimeMs: (state) => state.quotesInitialLoadTime, + isQuoteGoingToRefresh: selectIsQuoteGoingToRefresh, +}); + +const selectRecommendedQuotes = createBridgeSelector( + [ + selectSortedBridgeQuotes, + (_, { requestCount }: { requestCount: number }) => requestCount, + ], + (quotes, requestCount) => + quotes.reduce((acc, quote) => { + const requestIndex = quote.quoteRequestIndex ?? 0; + acc[requestIndex] ??= quote; + return acc; + }, Array(requestCount).fill(null)), +); + +const selectDestAmountSum = createBridgeSelector( + [selectRecommendedQuotes], + (recommendedQuotes) => { + return sumAmounts(recommendedQuotes.map((quote) => quote?.quote.dest)); + }, +); + +const selectMinDestAmountSum = createBridgeSelector( + [selectDestAmountSum], + (destAmountSum): DeepPartial | undefined => { + if (!destAmountSum) { + return undefined; + } + + const { + minAmount, + minAmountNormalized, + minAmountValueInCurrency, + minAmountUsd, + asset, + } = destAmountSum; + + return { + amount: minAmount, + normalizedAmount: minAmountNormalized, + valueInCurrency: minAmountValueInCurrency, + usd: minAmountUsd, + asset, + }; + }, +); + +/** + * Selects the recommended swap quotes for a batch of quote requests. + * + * @param state - The state of the bridge controller and its dependency controllers + * @param sortOrder - The sort order of the quotes + * @param requestCount - The number of quote requests fetched in the batch + * @returns The quotes for multiple quote requests, including their recommendedQuotes, + * totalReceived, minimumReceived, totalNetworkFee, and other quote fetching metadata. + * + * @example + * ```ts + * const quotes = useSelector(state => selectBatchSellQuotes( + * { ...state.metamask }, + * { + * sortOrder: state.bridge.sortOrder, + * requestCount: 4, + * } + * )); + * ``` + */ +export const selectBatchSellQuotes = createStructuredBridgeSelector({ + recommendedQuotes: selectRecommendedQuotes, + totalReceived: selectDestAmountSum, + minimumReceived: selectMinDestAmountSum, + quotesLastFetchedMs: (state) => state.quotesLastFetched, + isLoading: (state) => state.quotesLoadingStatus === RequestStatus.LOADING, + quoteFetchError: (state) => state.quoteFetchError, + quotesRefreshCount: (state) => state.quotesRefreshCount, + quotesInitialLoadTimeMs: (state) => state.quotesInitialLoadTime, + isQuoteGoingToRefresh: selectIsQuoteGoingToRefresh, +}); + +const selectBatchSellFees = createBridgeSelector( + [ + (state) => state.batchSellTrades?.fee?.amount, + (state) => state.batchSellTrades?.fee?.asset, + (state) => + selectExchangeRateByAssetId( + state, + state.batchSellTrades?.fee?.asset?.assetId, + ), + ], + (feeAmount, feeAsset, exchangeRate) => { + return feeAmount && feeAsset && exchangeRate + ? calcBatchFees(feeAmount, feeAsset, exchangeRate) + : undefined; + }, +); + +/** + * Selects the batch transactions and fees for a batch of quotes + * + * @param state - The state of the bridge controller and its dependency controllers + * @returns The total transaction fees and whether the batch sell trades are submittable. + * + * @example + * ```ts + * const { totalNetworkFee, isBatchSellTradeAvailable } = useSelector(state => selectBatchSellTrades(state.metamask)); + * ``` + */ +export const selectBatchSellTrades = createBridgeSelector( + [ + (state) => state.batchSellTradesLoadingStatus === RequestStatus.FETCHED, + (state) => state.batchSellTrades, + selectBatchSellFees, + (state) => state.batchSellTradesLoadingStatus === RequestStatus.LOADING, + ], + (isBatchSellTradeAvailable, batchSellTrades, batchFees, isLoading) => { + return { + totalNetworkFee: batchFees, + /** + * Whether the batch sell trades have been fetched and transactions are ready to be submitted + */ + isBatchSellTradeAvailable: + isBatchSellTradeAvailable && + Boolean(batchSellTrades?.transactions?.length), + isLoading, + }; + }, +); + +export const selectMinimumBalanceForRentExemptionInSOL = ( + state: BridgeAppState, +) => + new BigNumber(state.minimumBalanceForRentExemptionInLamports ?? 0) + .div(10 ** 9) + .toString(); + +export const selectTokenWarnings = (state: BridgeAppState) => + state.tokenWarnings; + +export const selectDefaultSlippagePercentage = createBridgeSelector( + [ + (state) => selectBridgeFeatureFlags(state).chains, + (_, slippageParams: Parameters[0]) => + slippageParams.srcTokenAddress, + (_, slippageParams: Parameters[0]) => + slippageParams.destTokenAddress, + (_, slippageParams: Parameters[0]) => + slippageParams.srcChainId + ? formatChainIdToCaip(slippageParams.srcChainId) + : undefined, + (_, slippageParams: Parameters[0]) => + slippageParams.destChainId + ? formatChainIdToCaip(slippageParams.destChainId) + : undefined, + ], + ( + featureFlagsByChain, + srcTokenAddress, + destTokenAddress, + srcChainId, + destChainId, + ) => { + return getDefaultSlippagePercentage( + { + srcTokenAddress, + destTokenAddress, + srcChainId, + destChainId, + }, + srcChainId ? featureFlagsByChain[srcChainId]?.stablecoins : undefined, + destChainId ? featureFlagsByChain[destChainId]?.stablecoins : undefined, + ); + }, +); diff --git a/packages/bridge-controller/src/types.ts b/packages/bridge-controller/src/types.ts new file mode 100644 index 00000000000..cb44f9615b0 --- /dev/null +++ b/packages/bridge-controller/src/types.ts @@ -0,0 +1,378 @@ +/* eslint-disable @typescript-eslint/naming-convention */ +import type { AccountsControllerGetAccountByAddressAction } from '@metamask/accounts-controller'; +import type { AssetsControllerGetExchangeRatesForBridgeAction } from '@metamask/assets-controller'; +import type { CurrencyRateControllerGetStateAction } from '@metamask/assets-controllers'; +import type { + ControllerGetStateAction, + ControllerStateChangeEvent, +} from '@metamask/base-controller'; +import type { Messenger } from '@metamask/messenger'; +import type { + NetworkControllerFindNetworkClientIdByChainIdAction, + NetworkControllerGetNetworkClientByIdAction, +} from '@metamask/network-controller'; +import type { AuthenticationControllerGetBearerTokenAction } from '@metamask/profile-sync-controller/auth'; +import type { RemoteFeatureFlagControllerGetStateAction } from '@metamask/remote-feature-flag-controller'; +import type { SnapControllerHandleRequestAction } from '@metamask/snaps-controllers'; +import type { Infer } from '@metamask/superstruct'; +import type { + CaipAccountId, + CaipAssetId, + CaipAssetType, + CaipChainId, + Hex, +} from '@metamask/utils'; + +import type { BridgeControllerMethodActions } from './bridge-controller-method-action-types.js'; +import type { BridgeController } from './bridge-controller.js'; +import type { BRIDGE_CONTROLLER_NAME } from './constants/bridge.js'; +import type { SimulatedGasFeeLimitsSchema } from './validators/batch-sell.js'; +import type { BatchSellTradesResponseSchema } from './validators/batch-sell.js'; +import type { + ChainConfigurationSchema, + ChainRankingSchema, + PlatformConfigSchema, +} from './validators/feature-flags.js'; +import type { IntentSchema } from './validators/intent.js'; +import type { QuoteResponseV1 } from './validators/quote-response-v1.js'; +import type { QuoteResponse } from './validators/quote-response.js'; +import type { QuoteStreamCompleteSchema } from './validators/quote-stream-complete.js'; +import type { TxFeeGasLimitsSchema } from './validators/quote.js'; +import type { FeeDataSchema } from './validators/quote.js'; +import type { GaslessPropertiesSchema } from './validators/quote.js'; +import type { StepSchema } from './validators/step.js'; +import type { TokenFeatureSchema } from './validators/token-feature.js'; + +export type FetchFunction = ( + input: RequestInfo | URL | string, + init?: RequestInit, + // eslint-disable-next-line @typescript-eslint/no-explicit-any +) => Promise; + +/** + * The types of assets that a user can send + */ +export enum AssetType { + /** The native asset for the current network, such as ETH */ + native = 'NATIVE', + /** An ERC20 token */ + token = 'TOKEN', + /** An ERC721 or ERC1155 token. */ + NFT = 'NFT', + /** + * A transaction interacting with a contract that isn't a token method + * interaction will be marked as dealing with an unknown asset type. + */ + unknown = 'UNKNOWN', +} + +export type ChainConfiguration = Infer; + +export type ChainRanking = Infer; + +/** + * @deprecated Avoid introducing new usages and use the QuoteResponseV2 feeData.network value instead + */ +export type L1GasFees = { + l1GasFeesInHexWei?: Hex; // l1 fees for approval and trade in hex wei, appended by BridgeController.#appendL1GasFees +}; + +/** + * @deprecated Avoid introducing new usages and use the QuoteResponseV2 feeData.network value instead + */ +export type NonEvmFees = { + nonEvmFeesInNative?: string; // Non-EVM chain fees in native units (SOL for Solana, BTC for Bitcoin) +}; + +export type InputPrimaryDenomination = 'token_amount' | 'fiat_value'; + +/** + * Asset exchange rate values for a given chain and address + */ +export type ExchangeRate = { exchangeRate?: string; usdExchangeRate?: string }; + +/** + * Sort order set by the user + */ +export enum SortOrder { + COST_ASC = 'cost_ascending', + ETA_ASC = 'time_descending', +} + +/** + * This is the interface for the token object used in the extension client + * In addition to the {@link BridgeAsset} fields, it includes balance information + */ +export type BridgeToken = { + address: string; + symbol: string; + image: string; + decimals: number; + chainId: number | Hex | ChainId | CaipChainId; + balance: string; // raw balance + // TODO deprecate this field and use balance instead + string: string | undefined; // normalized balance as a stringified number + tokenFiatAmount?: number | null; + occurrences?: number; +}; + +type DecimalChainId = string; +export type GasMultiplierByChainId = Record; + +export type FeatureFlagResponse = Infer; + +// TODO move definition to validators.ts +/** + * This is the interface for the quote request sent to the bridge-api + * and should only be used by the fetchBridgeQuotes utility function + * Components and redux stores should use the {@link GenericQuoteRequest} type + */ +export type QuoteRequest< + ChainIdType = ChainId | number, + TokenAddressType = string, + WalletAddressType = string, +> = { + walletAddress: WalletAddressType; + destWalletAddress?: WalletAddressType; + srcChainId: ChainIdType; + destChainId: ChainIdType; + srcTokenAddress: TokenAddressType; + destTokenAddress: TokenAddressType; + /** + * This is the amount sent, in atomic amount + */ + srcTokenAmount: string; + slippage?: number; + aggIds?: string[]; + bridgeIds?: string[]; + insufficientBal?: boolean; + resetApproval?: boolean; + refuel?: boolean; + /** + * Whether the response should include gasless swap quotes + * This should be true if the user has opted in to STX on the client + * and the current network has STX support + */ + gasIncluded: boolean; + /** + * Whether to request quotes that use EIP-7702 delegated gasless execution + */ + gasIncluded7702: boolean; + /** + * The fee that will be charged by MetaMask + */ + fee?: number; +}; + +export enum StatusTypes { + SUBMITTED = 'SUBMITTED', + UNKNOWN = 'UNKNOWN', + FAILED = 'FAILED', + PENDING = 'PENDING', + COMPLETE = 'COMPLETE', +} + +/** + * These are types that components pass in. Since data is a mix of types when coming from the redux store, we need to use a generic type that can cover all the types. + * Payloads with this type are transformed into QuoteRequest by fetchBridgeQuotes right before fetching quotes + */ +export type GenericQuoteRequest = QuoteRequest< + Hex | CaipChainId | string | number, // chainIds + Hex | CaipAssetId | string, // assetIds/addresses + Hex | CaipAccountId | string // accountIds/addresses +>; + +export type Step = Infer; + +export type RefuelData = Step; + +export type FeeData = Infer; + +export type Intent = Infer; +export type IntentOrderLike = Intent['order']; + +export type BatchSellTradesRequest = { + quotes: QuoteResponseV1[]; + stxEnabled: boolean; +}; + +/** + * This is the bridge-api response for the obtainGaslessBatch method + */ +export type BatchSellTradesResponse = Infer< + typeof BatchSellTradesResponseSchema +>; + +export type SimulatedGasFeeLimits = Infer; +export type TxFeeGasLimits = Infer; + +export type GaslessProperties = Infer; + +type DeepPartialValue = + NonNullable extends (infer U)[] + ? DeepPartial[] + : NonNullable extends readonly (infer U)[] + ? readonly DeepPartial[] + : NonNullable extends object + ? DeepPartial> + : Type; +export type DeepPartial = Type extends string + ? Type + : { + [K in keyof Type]?: null extends Type[K] + ? DeepPartialValue | null + : DeepPartialValue; + }; + +export enum ChainId { + ETH = 1, + OPTIMISM = 10, + BSC = 56, + POLYGON = 137, + ZKSYNC = 324, + BASE = 8453, + ARBITRUM = 42161, + AVALANCHE = 43114, + LINEA = 59144, + SOLANA = 1151111081099710, + BTC = 20000000000001, + /** Internal bridge / token-list id for Stellar pubnet (Token API chain: stellar:pubnet). */ + STELLAR = 20000000000002, + TRON = 728126428, + SEI = 1329, + MONAD = 143, + HYPEREVM = 999, + MEGAETH = 4326, + ARC = 5042, + ROBINHOOD = 4663, +} + +export type FeatureFlagsPlatformConfig = Infer; + +export type TokenFeature = Infer; + +export type QuoteStreamCompleteData = Infer; + +export enum RequestStatus { + LOADING = 0, + FETCHED = 1, + ERROR = 2, +} + +export type BridgeControllerState = { + quoteRequest: Partial[]; + quotes: QuoteResponse[]; + /** + * The time elapsed between the initial quote fetch and when the first valid quote was received + */ + quotesInitialLoadTime: number | null; + /** + * The timestamp of when the latest quote fetch started + */ + quotesLastFetched: number | null; + /** + * The status of the quote fetch, including fee calculations and validations + * This is set to + * - LOADING when the quote fetch starts + * - FETCHED when the process completes successfully, including when quotes are empty + * - ERROR when any errors occur + * + * When SSE is enabled, this is set to LOADING even when a quote is available. It is only + * set to FETCHED when the stream is closed and all quotes have been received + */ + quotesLoadingStatus: RequestStatus | null; + quoteFetchError: string | null; + /** + * The number of times the quotes have been refreshed, starts at 0 and is + * incremented at the end of each quote fetch + */ + quotesRefreshCount: number; + /** + * Asset exchange rates for EVM and multichain assets that are not indexed by the assets controllers + */ + assetExchangeRates: Record; + /** + * When the src token is SOL, this needs to be subtracted from their balance to determine + * the max amount that can be sent. + */ + minimumBalanceForRentExemptionInLamports: string | null; + /** + * Security alerts for the destination token in the current quote request, + * populated from `token_warning` SSE events. + */ + tokenWarnings: TokenFeature[]; + /** + * Client-supplied security classification for the destination token in the + * current quote request, used as the `token_security_type_destination` + * analytics property. Set via the `context` arg of + * `updateBridgeQuoteRequestParams` and reset whenever the quote request is + * reset. `null` when the client has no security data for the token. + */ + tokenSecurityTypeDestination: string | null; + /** + * The denomination currently shown as the primary source amount input. + * This is persisted as a user preference so returning to the flow restores + * the last selected fiat/token display mode. + */ + inputPrimaryDenomination: InputPrimaryDenomination; + /** + * Metadata about the completed quote stream, populated from the `complete` SSE event. + * Set to null at the start of each fetch and updated when the complete event is received. + */ + quoteStreamComplete: QuoteStreamCompleteData | null; + /** + * Contains gasless transaction data and fees for BatchSell quotes, provided by the obtainGaslessBatch API + */ + batchSellTrades: BatchSellTradesResponse | null; + /** + * The status of the batch sell trades fetch, including fee calculations and validations + */ + batchSellTradesLoadingStatus: RequestStatus | null; +}; + +/** + * @deprecated Use the separate method action types (e.g., + * `BridgeControllerFetchQuotesAction`) instead. + */ +export type BridgeControllerAction< + FunctionName extends keyof BridgeController, +> = { + type: `${typeof BRIDGE_CONTROLLER_NAME}:${FunctionName}`; + handler: BridgeController[FunctionName]; +}; + +export type BridgeControllerGetStateAction = ControllerGetStateAction< + typeof BRIDGE_CONTROLLER_NAME, + BridgeControllerState +>; + +export type BridgeControllerStateChangeEvent = ControllerStateChangeEvent< + typeof BRIDGE_CONTROLLER_NAME, + BridgeControllerState +>; + +export type BridgeControllerActions = + | BridgeControllerGetStateAction + | BridgeControllerMethodActions; + +export type BridgeControllerEvents = BridgeControllerStateChangeEvent; + +export type AllowedActions = + | AccountsControllerGetAccountByAddressAction + | AuthenticationControllerGetBearerTokenAction + | CurrencyRateControllerGetStateAction + | SnapControllerHandleRequestAction + | NetworkControllerFindNetworkClientIdByChainIdAction + | NetworkControllerGetNetworkClientByIdAction + | RemoteFeatureFlagControllerGetStateAction + | AssetsControllerGetExchangeRatesForBridgeAction; +export type AllowedEvents = never; + +/** + * The messenger for the BridgeController. + */ +export type BridgeControllerMessenger = Messenger< + typeof BRIDGE_CONTROLLER_NAME, + BridgeControllerActions | AllowedActions, + BridgeControllerEvents | AllowedEvents +>; diff --git a/packages/bridge-controller/src/utils/__snapshots__/fetch.test.ts.snap b/packages/bridge-controller/src/utils/__snapshots__/fetch.test.ts.snap new file mode 100644 index 00000000000..3df3fbcc9bb --- /dev/null +++ b/packages/bridge-controller/src/utils/__snapshots__/fetch.test.ts.snap @@ -0,0 +1,30 @@ +// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing + +exports[`fetch fetchBridgeQuotes should filter out malformed bridge quotes 2`] = ` +[ + [ + "Quote validation failed", + [ + "unknown|quote", + "lifi|quote.requestId", + "lifi|quote.srcChainId", + "lifi|quote.srcAsset.decimals", + "lifi|quote.srcTokenAmount", + "lifi|quote.destChainId", + "lifi|quote.destAsset", + "lifi|quote.destTokenAmount", + "lifi|quote.feeData", + "lifi|quote.steps", + "socket|quote.requestId", + "socket|quote.srcChainId", + "socket|quote.srcAsset", + "socket|quote.srcTokenAmount", + "socket|quote.destChainId", + "socket|quote.destAsset.address", + "socket|quote.destTokenAmount", + "socket|quote.feeData", + "socket|quote.steps", + ], + ], +] +`; diff --git a/packages/bridge-controller/src/utils/assets.test.ts b/packages/bridge-controller/src/utils/assets.test.ts new file mode 100644 index 00000000000..8e85e00e726 --- /dev/null +++ b/packages/bridge-controller/src/utils/assets.test.ts @@ -0,0 +1,193 @@ +import type { CaipAssetType } from '@metamask/utils'; + +import { getAssetIdsForToken, toExchangeRates } from './assets.js'; +import { getNativeAssetForChainId } from './bridge.js'; +import { formatAddressToAssetId } from './caip-formatters.js'; + +// Mock the imported functions +jest.mock('./bridge', () => ({ + getNativeAssetForChainId: jest.fn(), +})); + +jest.mock('./caip-formatters', () => ({ + formatAddressToAssetId: jest.fn(), +})); + +describe('assets utils', () => { + describe('getAssetIdsForToken', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('should return empty array when formatAddressToAssetId returns null', () => { + (formatAddressToAssetId as jest.Mock).mockReturnValue(null); + + const result = getAssetIdsForToken('0x123', '1'); + + expect(result).toStrictEqual([]); + expect(formatAddressToAssetId).toHaveBeenCalledWith('0x123', '1'); + expect(getNativeAssetForChainId).not.toHaveBeenCalled(); + }); + + it('should return token asset ID when native asset has no assetId', () => { + (formatAddressToAssetId as jest.Mock).mockReturnValue( + 'eip155:1/erc20:0x123', + ); + (getNativeAssetForChainId as jest.Mock).mockReturnValue({ + address: '0x0', + symbol: 'ETH', + // no assetId + }); + + const result = getAssetIdsForToken('0x123', '1'); + + expect(result).toStrictEqual(['eip155:1/erc20:0x123']); + expect(formatAddressToAssetId).toHaveBeenCalledWith('0x123', '1'); + expect(getNativeAssetForChainId).toHaveBeenCalledWith('1'); + }); + + it('should return both token and native asset IDs when both exist', () => { + (formatAddressToAssetId as jest.Mock).mockReturnValue( + 'eip155:1/erc20:0x123', + ); + (getNativeAssetForChainId as jest.Mock).mockReturnValue({ + address: '0x0', + symbol: 'ETH', + assetId: 'eip155:1/slip44:60', + }); + + const result = getAssetIdsForToken('0x123', '1'); + + expect(result).toStrictEqual([ + 'eip155:1/erc20:0x123', + 'eip155:1/slip44:60', + ]); + expect(formatAddressToAssetId).toHaveBeenCalledWith('0x123', '1'); + expect(getNativeAssetForChainId).toHaveBeenCalledWith('1'); + }); + }); + + describe('toExchangeRates', () => { + it('should convert price data to exchange rates format', () => { + const pricesByAssetId = { + 'eip155:1/erc20:0x123': { + usd: '1.5', + eur: '1.3', + gbp: '1.2', + }, + 'eip155:1/slip44:60': { + usd: '1800', + eur: '1650', + gbp: '1500', + }, + } as Record; + + const result = toExchangeRates('eur', pricesByAssetId); + + expect(result).toStrictEqual({ + 'eip155:1/erc20:0x123': { + exchangeRate: '1.3', + usdExchangeRate: '1.5', + }, + 'eip155:1/slip44:60': { + exchangeRate: '1650', + usdExchangeRate: '1800', + }, + }); + }); + + it('should handle missing USD prices', () => { + const pricesByAssetId = { + 'eip155:1/erc20:0x123': { + eur: '1.3', + gbp: '1.2', + }, + } as Record; + + const result = toExchangeRates('eur', pricesByAssetId); + + expect(result).toStrictEqual({ + 'eip155:1/erc20:0x123': { + exchangeRate: '1.3', + usdExchangeRate: undefined, + }, + }); + }); + + it('should handle missing requested currency prices', () => { + const pricesByAssetId = { + 'eip155:1/erc20:0x123': { + usd: '1.5', + gbp: '1.2', + }, + } as Record; + + const result = toExchangeRates('eur', pricesByAssetId); + + expect(result).toStrictEqual({ + 'eip155:1/erc20:0x123': { + exchangeRate: undefined, + usdExchangeRate: '1.5', + }, + }); + }); + + it('should handle empty price data', () => { + const result = toExchangeRates('eur', {}); + + expect(result).toStrictEqual({}); + }); + + it('should handle asset with no prices', () => { + const pricesByAssetId = { + 'eip155:1/erc20:0x123': {}, + } as Record; + + const result = toExchangeRates('eur', pricesByAssetId); + + expect(result).toStrictEqual({ + 'eip155:1/erc20:0x123': { + exchangeRate: undefined, + usdExchangeRate: undefined, + }, + }); + }); + + it('should handle multiple assets with mixed price availability', () => { + const pricesByAssetId = { + 'eip155:1/erc20:0x123': { + usd: '1.5', + eur: '1.3', + }, + 'eip155:1/erc20:0x456': { + eur: '2.3', + }, + 'eip155:1/erc20:0x789': { + usd: '3.5', + }, + 'eip155:1/erc20:0xabc': {}, + } as Record; + + const result = toExchangeRates('eur', pricesByAssetId); + + expect(result).toStrictEqual({ + 'eip155:1/erc20:0x123': { + exchangeRate: '1.3', + usdExchangeRate: '1.5', + }, + 'eip155:1/erc20:0x456': { + exchangeRate: '2.3', + usdExchangeRate: undefined, + }, + 'eip155:1/erc20:0x789': { + exchangeRate: undefined, + usdExchangeRate: '3.5', + }, + 'eip155:1/erc20:0xabc': { + exchangeRate: undefined, + usdExchangeRate: undefined, + }, + }); + }); + }); +}); diff --git a/packages/bridge-controller/src/utils/assets.ts b/packages/bridge-controller/src/utils/assets.ts new file mode 100644 index 00000000000..d3d7f7e869f --- /dev/null +++ b/packages/bridge-controller/src/utils/assets.ts @@ -0,0 +1,60 @@ +import { KnownCaipNamespace, parseCaipAssetType } from '@metamask/utils'; +import type { CaipAssetType } from '@metamask/utils'; + +import type { ExchangeRate, GenericQuoteRequest } from '../types.js'; +import { getNativeAssetForChainId } from './bridge.js'; +import { formatAddressToAssetId } from './caip-formatters.js'; + +export const getAssetIdsForToken = ( + tokenAddress: GenericQuoteRequest['srcTokenAddress'], + chainId: GenericQuoteRequest['srcChainId'], +) => { + const assetIdsToFetch: CaipAssetType[] = []; + + const assetId = formatAddressToAssetId(tokenAddress, chainId); + if (assetId) { + assetIdsToFetch.push(assetId); + getNativeAssetForChainId(chainId)?.assetId && + assetIdsToFetch.push(getNativeAssetForChainId(chainId).assetId); + } + + return assetIdsToFetch; +}; + +export const toExchangeRates = ( + currency: string, + pricesByAssetId: { + [assetId: CaipAssetType]: { [currency: string]: string } | undefined; + }, +) => { + const exchangeRates = Object.entries(pricesByAssetId).reduce< + Record + >((acc, [assetId, prices]) => { + if (prices) { + acc[assetId as CaipAssetType] = { + exchangeRate: prices[currency], + usdExchangeRate: prices.usd, + }; + } + return acc; + }, {}); + return exchangeRates; +}; + +export const assetIdsMatch = ( + assetId1?: CaipAssetType, + assetId2?: CaipAssetType, +): boolean => { + if (!assetId2 || !assetId1) { + return false; + } + + return ( + assetId1 === assetId2 || + (parseCaipAssetType(assetId1).chain.namespace === + KnownCaipNamespace.Eip155 && + parseCaipAssetType(assetId2).chain.namespace === + KnownCaipNamespace.Eip155 && + assetId1.toLowerCase() === assetId2.toLowerCase()) + ); +}; diff --git a/packages/bridge-controller/src/utils/balance.test.ts b/packages/bridge-controller/src/utils/balance.test.ts new file mode 100644 index 00000000000..9bea4dbf5f7 --- /dev/null +++ b/packages/bridge-controller/src/utils/balance.test.ts @@ -0,0 +1,260 @@ +import { BigNumber } from '@ethersproject/bignumber'; +import { AddressZero } from '@ethersproject/constants'; +import { Contract } from '@ethersproject/contracts'; +import { Web3Provider } from '@ethersproject/providers'; +import { abiERC20 } from '@metamask/metamask-eth-abis'; +import type { Provider } from '@metamask/network-controller'; + +import { FakeProvider } from '../../../../tests/fake-provider.js'; +import * as balanceUtils from './balance.js'; +import { fetchTokenBalance } from './balance.js'; + +declare global { + var ethereumProvider: Provider; +} + +jest.mock('@ethersproject/contracts', () => { + return { + ...jest.requireActual('@ethersproject/contracts'), + Contract: jest.fn(), + }; +}); + +jest.mock('@ethersproject/providers', () => { + return { + ...jest.requireActual('@ethersproject/providers'), + Web3Provider: jest.fn(), + }; +}); + +describe('balance', () => { + beforeEach(() => { + jest.clearAllMocks(); + global.ethereumProvider = new FakeProvider(); + }); + + describe('calcLatestSrcBalance', () => { + it('should return the ERC20 token balance', async () => { + const mockBalanceOf = jest + .fn() + .mockResolvedValueOnce(BigNumber.from(100)); + (Contract as unknown as jest.Mock).mockImplementation(() => ({ + balanceOf: mockBalanceOf, + })); + + expect( + await balanceUtils.calcLatestSrcBalance( + global.ethereumProvider, + '0x123', + '0x456', + '0x789', + ), + ).toStrictEqual(BigNumber.from(100)); + expect(mockBalanceOf).toHaveBeenCalledTimes(1); + expect(mockBalanceOf).toHaveBeenCalledWith('0x123'); + }); + + it('should return the native asset balance', async () => { + const mockGetBalance = jest.fn().mockImplementation(() => { + return BigNumber.from(100); + }); + (Web3Provider as unknown as jest.Mock).mockImplementation(() => { + return { + getBalance: mockGetBalance, + }; + }); + + expect( + await balanceUtils.calcLatestSrcBalance( + global.ethereumProvider, + '0x141d32a89a1e0a5Ef360034a2f60a4B917c18838', + AddressZero, + '0x789', + ), + ).toStrictEqual(BigNumber.from(100)); + expect(mockGetBalance).toHaveBeenCalledTimes(1); + expect(mockGetBalance).toHaveBeenCalledWith( + '0x141d32a89a1e0a5Ef360034a2f60a4B917c18838', + ); + }); + + it('should return undefined if token address and chainId are undefined', async () => { + const mockGetBalance = jest.fn(); + (Web3Provider as unknown as jest.Mock).mockImplementation(() => { + return { + getBalance: mockGetBalance, + }; + }); + + const mockFetchTokenBalance = jest.spyOn( + balanceUtils, + 'fetchTokenBalance', + ); + expect( + await balanceUtils.calcLatestSrcBalance( + global.ethereumProvider, + '0x141d32a89a1e0a5Ef360034a2f60a4B917c18838', + undefined as never, + undefined as never, + ), + ).toBeUndefined(); + expect(mockFetchTokenBalance).not.toHaveBeenCalled(); + expect(mockGetBalance).not.toHaveBeenCalled(); + }); + }); + + describe('hasSufficientBalance', () => { + it('should return true if user has sufficient balance', async () => { + const mockGetBalance = jest.fn(); + (Web3Provider as unknown as jest.Mock).mockImplementation(() => { + return { + getBalance: mockGetBalance, + }; + }); + + mockGetBalance.mockImplementation(() => { + return BigNumber.from('10000000000000000000'); + }); + + const mockBalanceOf = jest + .fn() + .mockResolvedValueOnce(BigNumber.from('10000000000000000001')); + (Contract as unknown as jest.Mock).mockImplementation(() => ({ + balanceOf: mockBalanceOf, + })); + + expect( + await balanceUtils.hasSufficientBalance( + global.ethereumProvider, + '0x141d32a89a1e0a5ef360034a2f60a4b917c18838', + AddressZero, + '10000000000000000000', + '0x1', + ), + ).toBe(true); + + expect( + await balanceUtils.hasSufficientBalance( + global.ethereumProvider, + '0x141d32a89a1e0a5ef360034a2f60a4b917c18838', + '0xDA10009cBd5D07dd0CeCc66161FC93D7c9000da1', + '10000000000000000000', + '0x1', + ), + ).toBe(true); + }); + + it('should return false if user has native assets but insufficient ERC20 src tokens', async () => { + const mockGetBalance = jest.fn(); + (Web3Provider as unknown as jest.Mock).mockImplementation(() => { + return { + getBalance: mockGetBalance, + }; + }); + + mockGetBalance.mockImplementation(() => { + return BigNumber.from('10000000000000000000'); + }); + const mockFetchTokenBalance = jest.spyOn( + balanceUtils, + 'fetchTokenBalance', + ); + mockFetchTokenBalance.mockResolvedValueOnce( + BigNumber.from('9000000000000000000'), + ); + + expect( + await balanceUtils.hasSufficientBalance( + global.ethereumProvider, + '0x141d32a89a1e0a5ef360034a2f60a4b917c18838', + '0xDA10009cBd5D07dd0CeCc66161FC93D7c9000da1', + '10000000000000000000', + '0x1', + ), + ).toBe(false); + }); + + it('should return false if source token balance is undefined', async () => { + const mockBalanceOf = jest.fn().mockResolvedValueOnce(undefined); + (Contract as unknown as jest.Mock).mockImplementation(() => ({ + balanceOf: mockBalanceOf, + })); + + expect( + await balanceUtils.hasSufficientBalance( + global.ethereumProvider, + '0x141d32a89a1e0a5ef360034a2f60a4b917c18838', + '0xDA10009cBd5D07dd0CeCc66161FC93D7c9000da1', + '10000000000000000000', + '0x1', + ), + ).toBe(false); + + expect(mockBalanceOf).toHaveBeenCalledTimes(1); + expect(mockBalanceOf).toHaveBeenCalledWith( + '0x141d32a89a1e0a5ef360034a2f60a4b917c18838', + ); + }); + }); +}); + +describe('fetchTokenBalance', () => { + let mockProvider: FakeProvider; + const mockAddress = '0x1234567890123456789012345678901234567890'; + const mockUserAddress = '0x9876543210987654321098765432109876543210'; + const mockBalance = BigNumber.from(1000); + + beforeEach(() => { + jest.clearAllMocks(); + mockProvider = new FakeProvider(); + + // Mock Web3Provider + (Web3Provider as unknown as jest.Mock).mockImplementation(() => ({ + // Add any provider methods needed + })); + }); + + it('should fetch token balance when contract is valid', async () => { + // Mock Contract + const mockBalanceOf = jest.fn().mockResolvedValue(mockBalance); + (Contract as unknown as jest.Mock).mockImplementation(() => ({ + balanceOf: mockBalanceOf, + })); + + const result = await fetchTokenBalance( + mockAddress, + mockUserAddress, + mockProvider, + ); + + expect(Web3Provider).toHaveBeenCalledWith(mockProvider); + expect(Contract).toHaveBeenCalledWith( + mockAddress, + abiERC20, + expect.anything(), + ); + expect(mockBalanceOf).toHaveBeenCalledWith(mockUserAddress); + expect(result).toBe(mockBalance); + }); + + it('should return undefined when contract is invalid', async () => { + // Mock Contract to return an object without balanceOf method + (Contract as unknown as jest.Mock).mockImplementation(() => ({ + // Empty object without balanceOf method + })); + + const result = await fetchTokenBalance( + mockAddress, + mockUserAddress, + mockProvider, + ); + + expect(Web3Provider).toHaveBeenCalledWith(mockProvider); + expect(Contract).toHaveBeenCalledWith( + mockAddress, + abiERC20, + expect.anything(), + ); + expect(result).toBeUndefined(); + }); +}); diff --git a/packages/bridge-controller/src/utils/balance.ts b/packages/bridge-controller/src/utils/balance.ts new file mode 100644 index 00000000000..4120c1d11d4 --- /dev/null +++ b/packages/bridge-controller/src/utils/balance.ts @@ -0,0 +1,56 @@ +import { getAddress } from '@ethersproject/address'; +import type { BigNumber } from '@ethersproject/bignumber'; +import { Contract } from '@ethersproject/contracts'; +import { Web3Provider } from '@ethersproject/providers'; +import { abiERC20 } from '@metamask/metamask-eth-abis'; +import type { Provider } from '@metamask/network-controller'; +import type { Hex } from '@metamask/utils'; + +import { isNativeAddress } from './bridge.js'; + +export const fetchTokenBalance = async ( + address: string, + userAddress: string, + provider: Provider, +): Promise => { + const ethersProvider = new Web3Provider(provider); + const tokenContract = new Contract(address, abiERC20, ethersProvider); + const tokenBalancePromise = + typeof tokenContract?.balanceOf === 'function' + ? tokenContract.balanceOf(userAddress) + : Promise.resolve(undefined); + return await tokenBalancePromise; +}; + +export const calcLatestSrcBalance = async ( + provider: Provider, + selectedAddress: string, + tokenAddress: string, + chainId: Hex, +): Promise => { + if (tokenAddress && chainId) { + if (isNativeAddress(tokenAddress)) { + const ethersProvider = new Web3Provider(provider); + return await ethersProvider.getBalance(getAddress(selectedAddress)); + } + return await fetchTokenBalance(tokenAddress, selectedAddress, provider); + } + return undefined; +}; + +export const hasSufficientBalance = async ( + provider: Provider, + selectedAddress: string, + tokenAddress: string, + fromTokenAmount: string, + chainId: Hex, +) => { + const srcTokenBalance = await calcLatestSrcBalance( + provider, + selectedAddress, + tokenAddress, + chainId, + ); + + return srcTokenBalance ? srcTokenBalance.gte(fromTokenAmount) : false; +}; diff --git a/packages/bridge-controller/src/utils/bridge.test.ts b/packages/bridge-controller/src/utils/bridge.test.ts new file mode 100644 index 00000000000..360b53cc3c1 --- /dev/null +++ b/packages/bridge-controller/src/utils/bridge.test.ts @@ -0,0 +1,339 @@ +import { BtcScope, SolScope, XlmScope } from '@metamask/keyring-api'; +import type { Hex } from '@metamask/utils'; + +import { + ETH_USDT_ADDRESS, + METABRIDGE_ETHEREUM_ADDRESS, +} from '../constants/bridge.js'; +import { CHAIN_IDS } from '../constants/chains.js'; +import { SWAPS_CHAINID_DEFAULT_TOKEN_MAP } from '../constants/tokens.js'; +import { ChainId } from '../types.js'; +import { + getNativeAssetForChainId, + isBitcoinChainId, + isCrossChain, + isEthUsdt, + isNonEvmChainId, + isSolanaChainId, + isStellarChainId, + isSwapsDefaultTokenAddress, + isSwapsDefaultTokenSymbol, + sumHexes, +} from './bridge.js'; + +describe('Bridge utils', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe('sumHexes', () => { + it('returns 0x0 for empty input', () => { + expect(sumHexes()).toBe('0x0'); + }); + + it('returns same value for single input', () => { + expect(sumHexes('0xff')).toBe('0xff'); + expect(sumHexes('0x0')).toBe('0x0'); + expect(sumHexes('0x1')).toBe('0x1'); + }); + + it('correctly sums two hex values', () => { + expect(sumHexes('0x1', '0x1')).toBe('0x2'); + expect(sumHexes('0xff', '0x1')).toBe('0x100'); + expect(sumHexes('0x0', '0xff')).toBe('0xff'); + }); + + it('correctly sums multiple hex values', () => { + expect(sumHexes('0x1', '0x2', '0x3')).toBe('0x6'); + expect(sumHexes('0xff', '0xff', '0x2')).toBe('0x200'); + expect(sumHexes('0x0', '0x0', '0x0')).toBe('0x0'); + }); + + it('handles large numbers', () => { + expect(sumHexes('0xffffffff', '0x1')).toBe('0x100000000'); + expect(sumHexes('0xffffffff', '0xffffffff')).toBe('0x1fffffffe'); + }); + + it('throws for invalid hex strings', () => { + expect(() => sumHexes('0xg')).toThrow('Cannot convert 0xg to a BigInt'); + }); + }); + + describe('isEthUsdt', () => { + it('returns true for ETH USDT address on mainnet', () => { + expect(isEthUsdt(CHAIN_IDS.MAINNET, ETH_USDT_ADDRESS)).toBe(true); + expect(isEthUsdt(CHAIN_IDS.MAINNET, ETH_USDT_ADDRESS.toUpperCase())).toBe( + true, + ); + }); + + it('returns false for non-mainnet chain', () => { + expect(isEthUsdt(CHAIN_IDS.GOERLI, ETH_USDT_ADDRESS)).toBe(false); + }); + + it('returns false for different address on mainnet', () => { + expect(isEthUsdt(CHAIN_IDS.MAINNET, METABRIDGE_ETHEREUM_ADDRESS)).toBe( + false, + ); + }); + }); + + describe('isSwapsDefaultTokenAddress', () => { + it('returns true for default token address of given chain', () => { + const chainId = Object.keys(SWAPS_CHAINID_DEFAULT_TOKEN_MAP)[0] as Hex; + const defaultToken = getNativeAssetForChainId(chainId); + + expect(isSwapsDefaultTokenAddress(defaultToken.address, chainId)).toBe( + true, + ); + }); + + it('returns false for non-default token address', () => { + const chainId = Object.keys(SWAPS_CHAINID_DEFAULT_TOKEN_MAP)[0] as Hex; + expect(isSwapsDefaultTokenAddress('0x1234', chainId)).toBe(false); + }); + + it('returns false for invalid inputs', () => { + const chainId = Object.keys(SWAPS_CHAINID_DEFAULT_TOKEN_MAP)[0] as Hex; + expect(isSwapsDefaultTokenAddress('', chainId)).toBe(false); + expect(isSwapsDefaultTokenAddress('0x1234', '' as Hex)).toBe(false); + }); + }); + + describe('isSwapsDefaultTokenSymbol', () => { + it('returns true for default token symbol of given chain', () => { + const chainId = Object.keys(SWAPS_CHAINID_DEFAULT_TOKEN_MAP)[0] as Hex; + const defaultToken = getNativeAssetForChainId(chainId); + + expect(isSwapsDefaultTokenSymbol(defaultToken.symbol, chainId)).toBe( + true, + ); + }); + + it('returns false for non-default token symbol', () => { + const chainId = Object.keys(SWAPS_CHAINID_DEFAULT_TOKEN_MAP)[0] as Hex; + expect(isSwapsDefaultTokenSymbol('FAKE', chainId)).toBe(false); + }); + + it('returns false for invalid inputs', () => { + const chainId = Object.keys(SWAPS_CHAINID_DEFAULT_TOKEN_MAP)[0] as Hex; + expect(isSwapsDefaultTokenSymbol('', chainId)).toBe(false); + expect(isSwapsDefaultTokenSymbol('ETH', '' as Hex)).toBe(false); + }); + }); + + describe('isSolanaChainId', () => { + it('returns true for ChainId.SOLANA', () => { + expect(isSolanaChainId(1151111081099710)).toBe(true); + }); + + it('returns true for SolScope.Mainnet', () => { + expect(isSolanaChainId(SolScope.Mainnet)).toBe(true); + }); + + it('returns false for other chainIds', () => { + expect(isSolanaChainId(1)).toBe(false); + expect(isSolanaChainId('0x0')).toBe(false); + }); + }); + + describe('isBitcoinChainId', () => { + it('returns true for ChainId.BTC (numeric)', () => { + expect(isBitcoinChainId(ChainId.BTC)).toBe(true); + expect(isBitcoinChainId(20000000000001)).toBe(true); + }); + + it('returns true for ChainId.BTC (string)', () => { + expect(isBitcoinChainId('20000000000001')).toBe(true); + expect(isBitcoinChainId(ChainId.BTC.toString())).toBe(true); + }); + + it('returns true for BtcScope.Mainnet', () => { + expect(isBitcoinChainId(BtcScope.Mainnet)).toBe(true); + }); + + it('returns true for BtcScope.Mainnet as string', () => { + expect(isBitcoinChainId(BtcScope.Mainnet.toString())).toBe(true); + }); + + it('returns false for EVM chainIds (hex)', () => { + expect(isBitcoinChainId('0x1')).toBe(false); + expect(isBitcoinChainId('0x89')).toBe(false); + expect(isBitcoinChainId(CHAIN_IDS.MAINNET)).toBe(false); + }); + + it('returns false for EVM chainIds (numeric)', () => { + expect(isBitcoinChainId(1)).toBe(false); + expect(isBitcoinChainId(137)).toBe(false); + expect(isBitcoinChainId(56)).toBe(false); + }); + + it('returns false for EVM CAIP chainIds', () => { + expect(isBitcoinChainId('eip155:1')).toBe(false); + expect(isBitcoinChainId('eip155:137')).toBe(false); + }); + + it('returns false for Solana chainIds', () => { + expect(isBitcoinChainId(ChainId.SOLANA)).toBe(false); + expect(isBitcoinChainId(SolScope.Mainnet)).toBe(false); + expect(isBitcoinChainId('1151111081099710')).toBe(false); + }); + + it('returns false for invalid chainIds', () => { + expect(isBitcoinChainId('invalid')).toBe(false); + expect(isBitcoinChainId('test')).toBe(false); + expect(isBitcoinChainId('')).toBe(false); + }); + }); + + describe('isStellarChainId', () => { + it('returns true for Stellar CAIP-2 chain ids', () => { + expect(isStellarChainId(XlmScope.Pubnet)).toBe(true); + expect(isStellarChainId(XlmScope.Testnet)).toBe(true); + }); + + it('returns true for internal Stellar bridge chain id', () => { + expect(isStellarChainId(ChainId.STELLAR)).toBe(true); + expect(isStellarChainId(String(ChainId.STELLAR))).toBe(true); + }); + + it('returns false for other chainIds', () => { + expect(isStellarChainId(SolScope.Mainnet)).toBe(false); + expect(isStellarChainId('0x1')).toBe(false); + expect(isStellarChainId(1)).toBe(false); + }); + }); + + describe('isNonEvmChainId', () => { + it('returns true for Solana chainIds', () => { + expect(isNonEvmChainId(ChainId.SOLANA)).toBe(true); + expect(isNonEvmChainId(SolScope.Mainnet)).toBe(true); + expect(isNonEvmChainId('1151111081099710')).toBe(true); + }); + + it('returns true for Bitcoin chainIds', () => { + expect(isNonEvmChainId(ChainId.BTC)).toBe(true); + expect(isNonEvmChainId(BtcScope.Mainnet)).toBe(true); + expect(isNonEvmChainId('20000000000001')).toBe(true); + }); + + it('returns true for Stellar chainIds', () => { + expect(isNonEvmChainId(XlmScope.Pubnet)).toBe(true); + expect(isNonEvmChainId(XlmScope.Testnet)).toBe(true); + expect(isNonEvmChainId(ChainId.STELLAR)).toBe(true); + }); + + it('returns false for EVM chainIds', () => { + expect(isNonEvmChainId('0x1')).toBe(false); + expect(isNonEvmChainId(1)).toBe(false); + expect(isNonEvmChainId('eip155:1')).toBe(false); + expect(isNonEvmChainId(ChainId.ETH)).toBe(false); + expect(isNonEvmChainId(ChainId.POLYGON)).toBe(false); + }); + + it('returns false for invalid chainIds', () => { + expect(isNonEvmChainId('invalid')).toBe(false); + expect(isNonEvmChainId('test')).toBe(false); + expect(isNonEvmChainId('')).toBe(false); + }); + }); + + describe('getNativeAssetForChainId', () => { + it('should return native asset for hex chainId', () => { + const result = getNativeAssetForChainId('0x1'); + expect(result).toStrictEqual({ + ...SWAPS_CHAINID_DEFAULT_TOKEN_MAP['0x1'], + chainId: 1, + assetId: 'eip155:1/slip44:60', + }); + }); + + it('should return native asset for decimal chainId', () => { + const result = getNativeAssetForChainId(137); + expect(result).toStrictEqual({ + ...SWAPS_CHAINID_DEFAULT_TOKEN_MAP['0x89'], + chainId: 137, + assetId: 'eip155:137/slip44:966', + }); + }); + + it('should return native asset for CAIP chainId', () => { + const result = getNativeAssetForChainId('eip155:1'); + expect(result).toStrictEqual({ + ...SWAPS_CHAINID_DEFAULT_TOKEN_MAP['0x1'], + chainId: 1, + assetId: 'eip155:1/slip44:60', + }); + }); + + it('should return native asset for Solana chainId', () => { + const result = getNativeAssetForChainId(SolScope.Mainnet); + expect(result).toStrictEqual({ + ...SWAPS_CHAINID_DEFAULT_TOKEN_MAP[SolScope.Mainnet], + chainId: 1151111081099710, + assetId: 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/slip44:501', + }); + }); + + it('should return native asset for Bitcoin chainId', () => { + const result = getNativeAssetForChainId(BtcScope.Mainnet); + expect(result).toStrictEqual({ + ...SWAPS_CHAINID_DEFAULT_TOKEN_MAP[BtcScope.Mainnet], + chainId: 20000000000001, + assetId: 'bip122:000000000019d6689c085ae165831e93/slip44:0', + }); + }); + + it('should return native asset for Bitcoin numeric chainId', () => { + const result = getNativeAssetForChainId(ChainId.BTC); + expect(result).toStrictEqual({ + ...SWAPS_CHAINID_DEFAULT_TOKEN_MAP[BtcScope.Mainnet], + chainId: 20000000000001, + assetId: 'bip122:000000000019d6689c085ae165831e93/slip44:0', + }); + }); + + it('should return native asset for Stellar chainId', () => { + const result = getNativeAssetForChainId(XlmScope.Pubnet); + expect(result).toStrictEqual({ + ...SWAPS_CHAINID_DEFAULT_TOKEN_MAP[XlmScope.Pubnet], + chainId: ChainId.STELLAR, + assetId: 'stellar:pubnet/slip44:148', + }); + }); + + it('should throw error for unsupported chainId', () => { + expect(() => getNativeAssetForChainId('999999')).toThrow( + 'No XChain Swaps native asset found for chainId: 999999', + ); + }); + + it('should handle different chainId formats for the same chain', () => { + const hexResult = getNativeAssetForChainId('0x89'); + const decimalResult = getNativeAssetForChainId(137); + const stringifiedDecimalResult = getNativeAssetForChainId('137'); + const caipResult = getNativeAssetForChainId('eip155:137'); + + expect(hexResult).toStrictEqual(decimalResult); + expect(decimalResult).toStrictEqual(caipResult); + expect(decimalResult).toStrictEqual(stringifiedDecimalResult); + }); + }); + + describe('isCrossChain', () => { + it('should return false when there is no destChainId', () => { + const result = isCrossChain('0x1'); + expect(result).toBe(false); + }); + + it('should return false when srcChainId is invalid', () => { + const result = isCrossChain('a', '0x1'); + expect(result).toBe(false); + }); + + it('should return false when destChainId is invalid', () => { + const result = isCrossChain('0x1', 'a'); + expect(result).toBe(false); + }); + }); +}); diff --git a/packages/bridge-controller/src/utils/bridge.ts b/packages/bridge-controller/src/utils/bridge.ts new file mode 100644 index 00000000000..cc4fb259300 --- /dev/null +++ b/packages/bridge-controller/src/utils/bridge.ts @@ -0,0 +1,274 @@ +/* eslint-disable @typescript-eslint/explicit-function-return-type */ +import { AddressZero } from '@ethersproject/constants'; +import { Contract } from '@ethersproject/contracts'; +import { BtcScope, SolScope, TrxScope, XlmScope } from '@metamask/keyring-api'; +import { abiERC20 } from '@metamask/metamask-eth-abis'; +import { isCaipChainId, isStrictHexString } from '@metamask/utils'; +import type { CaipAssetType, CaipChainId, Hex } from '@metamask/utils'; + +import { + DEFAULT_BRIDGE_CONTROLLER_STATE, + ETH_USDT_ADDRESS, + METABRIDGE_ETHEREUM_ADDRESS, +} from '../constants/bridge.js'; +import { CHAIN_IDS } from '../constants/chains.js'; +import { SWAPS_CONTRACT_ADDRESSES } from '../constants/swaps.js'; +import { + SWAPS_CHAINID_DEFAULT_TOKEN_MAP, + SYMBOL_TO_SLIP44_MAP, +} from '../constants/tokens.js'; +import type { SupportedSwapsNativeCurrencySymbols } from '../constants/tokens.js'; +import type { BridgeControllerState, GenericQuoteRequest } from '../types.js'; +import { ChainId } from '../types.js'; +import type { BridgeAsset } from '../validators/bridge-asset.js'; +import type { QuoteResponseV1 } from '../validators/quote-response-v1.js'; +import type { TxData } from '../validators/trade.js'; +import { + formatChainIdToCaip, + formatChainIdToDec, + formatChainIdToHex, +} from './caip-formatters.js'; + +/** + * Checks whether the transaction is a cross-chain transaction by comparing the source and destination chainIds + * + * @param srcChainId - The source chainId + * @param destChainId - The destination chainId + * @returns Whether the transaction is a cross-chain transaction + */ +export const isCrossChain = ( + srcChainId: GenericQuoteRequest['srcChainId'], + destChainId?: GenericQuoteRequest['destChainId'], +) => { + try { + if (!destChainId) { + return false; + } + return formatChainIdToCaip(srcChainId) !== formatChainIdToCaip(destChainId); + } catch { + return false; + } +}; + +export const getDefaultBridgeControllerState = (): BridgeControllerState => { + return DEFAULT_BRIDGE_CONTROLLER_STATE; +}; + +/** + * Returns the native assetType for a given chainId and native currency symbol + * Note that the return value is used as the assetId although it is a CaipAssetType + * + * @param chainId - The chainId to get the native assetType for + * @param nativeCurrencySymbol - The native currency symbol for the given chainId + * @returns The native assetType for the given chainId + */ +const getNativeAssetCaipAssetType = ( + chainId: CaipChainId, + nativeCurrencySymbol: SupportedSwapsNativeCurrencySymbols, +): CaipAssetType => { + return `${formatChainIdToCaip(chainId)}/${SYMBOL_TO_SLIP44_MAP[nativeCurrencySymbol]}`; +}; + +/** + * Returns the native swaps or bridge asset for a given chainId + * + * @param chainId - The chainId to get the default token for + * @returns The native asset for the given chainId + * @throws If no native asset is defined for the given chainId + */ +export const getNativeAssetForChainId = ( + chainId: string | number | Hex | CaipChainId, +): BridgeAsset => { + const chainIdInCaip = formatChainIdToCaip(chainId); + const nativeToken = + SWAPS_CHAINID_DEFAULT_TOKEN_MAP[ + formatChainIdToCaip( + chainId, + ) as keyof typeof SWAPS_CHAINID_DEFAULT_TOKEN_MAP + ] ?? + SWAPS_CHAINID_DEFAULT_TOKEN_MAP[ + formatChainIdToHex( + chainId, + ) as keyof typeof SWAPS_CHAINID_DEFAULT_TOKEN_MAP + ]; + + if (!nativeToken) { + throw new Error( + `No XChain Swaps native asset found for chainId: ${chainId}`, + ); + } + + return { + ...nativeToken, + chainId: formatChainIdToDec(chainId), + assetId: getNativeAssetCaipAssetType(chainIdInCaip, nativeToken.symbol), + }; +}; + +/** + * A function to return the txParam data for setting allowance to 0 for USDT on Ethereum + * + * @param destChainId - The destination chain ID + * @returns The txParam data that will reset allowance to 0, combine it with the approval tx params received from Bridge API + */ +export const getEthUsdtResetData = ( + destChainId: GenericQuoteRequest['destChainId'], +) => { + const spenderAddress = isCrossChain(CHAIN_IDS.MAINNET, destChainId) + ? METABRIDGE_ETHEREUM_ADDRESS + : SWAPS_CONTRACT_ADDRESSES[CHAIN_IDS.MAINNET]; + const UsdtContractInterface = new Contract(ETH_USDT_ADDRESS, abiERC20) + .interface; + const data = UsdtContractInterface.encodeFunctionData('approve', [ + spenderAddress, + '0', + ]); + + return data as Hex; +}; + +export const isEthUsdt = ( + chainId: GenericQuoteRequest['srcChainId'], + address: string, +) => + formatChainIdToDec(chainId) === ChainId.ETH && + address.toLowerCase() === ETH_USDT_ADDRESS.toLowerCase(); + +export const sumHexes = (...hexStrings: string[]): Hex => { + if (hexStrings.length === 0) { + return '0x0'; + } + + const sum = hexStrings.reduce( + (acc, hexString) => acc + BigInt(hexString), + BigInt(0), + ); + return `0x${sum.toString(16)}`; +}; + +/** + * Checks whether the provided address is strictly equal to the address for + * the default swaps token of the provided chain. + * + * @param address - The string to compare to the default token address + * @param chainId - The hex encoded chain ID of the default swaps token to check + * @returns Whether the address is the provided chain's default token address + */ +export const isSwapsDefaultTokenAddress = ( + address: string, + chainId: Hex | CaipChainId, +) => { + if (!address || !chainId) { + return false; + } + + return address === getNativeAssetForChainId(chainId)?.address; +}; + +/** + * Checks whether the provided symbol is strictly equal to the symbol for + * the default swaps token of the provided chain. + * + * @param symbol - The string to compare to the default token symbol + * @param chainId - The hex encoded chain ID of the default swaps token to check + * @returns Whether the symbol is the provided chain's default token symbol + */ +export const isSwapsDefaultTokenSymbol = ( + symbol: string, + chainId: Hex | CaipChainId, +) => { + if (!symbol || !chainId) { + return false; + } + + return symbol === getNativeAssetForChainId(chainId)?.symbol; +}; + +/** + * Checks whether the address is a native asset in any supported xchain swaps network + * + * @param address - The address to check + * @returns Whether the address is a native asset + */ +export const isNativeAddress = (address?: string | null) => + address === AddressZero || // bridge and swap apis set the native asset address to zero + address === '' || // assets controllers set the native asset address to an empty string + !address || + (!isStrictHexString(address) && + Object.values(SYMBOL_TO_SLIP44_MAP).some( + // check if it matches any supported SLIP44 references + (reference) => address.includes(reference) || reference.endsWith(address), + )); + +/** + * Checks whether the chainId matches Solana in CaipChainId or number format + * + * @param chainId - The chainId to check + * @returns Whether the chainId is Solana + */ +export const isSolanaChainId = ( + chainId: Hex | number | CaipChainId | string, +) => { + if (isCaipChainId(chainId)) { + return chainId === SolScope.Mainnet.toString(); + } + return chainId.toString() === ChainId.SOLANA.toString(); +}; + +export const isBitcoinChainId = ( + chainId: Hex | number | CaipChainId | string, +) => { + if (isCaipChainId(chainId)) { + return chainId === BtcScope.Mainnet.toString(); + } + return chainId.toString() === ChainId.BTC.toString(); +}; + +export const isTronChainId = (chainId: Hex | number | CaipChainId | string) => { + if (isCaipChainId(chainId)) { + return chainId === TrxScope.Mainnet.toString(); + } + return chainId.toString() === ChainId.TRON.toString(); +}; + +/** + * Checks whether the chainId matches Stellar pubnet or testnet (CAIP-2). + * + * @param chainId - The chainId to check + * @returns Whether the chainId is Stellar + */ +export const isStellarChainId = ( + chainId: Hex | number | CaipChainId | string, +): boolean => { + if (isCaipChainId(chainId)) { + return ( + chainId === XlmScope.Pubnet.toString() || + chainId === XlmScope.Testnet.toString() + ); + } + return chainId.toString() === ChainId.STELLAR.toString(); +}; + +/** + * Checks if a chain ID represents a non-EVM blockchain supported by swaps + * Currently supports Solana, Bitcoin, Tron, and Stellar + * + * @param chainId - The chain ID to check + * @returns True if the chain is a supported non-EVM chain, false otherwise + */ +export const isNonEvmChainId = ( + chainId: GenericQuoteRequest['srcChainId'], +): boolean => { + return ( + isSolanaChainId(chainId) || + isBitcoinChainId(chainId) || + isTronChainId(chainId) || + isStellarChainId(chainId) + ); +}; + +export const isEvmQuoteResponse = ( + quoteResponse: QuoteResponseV1, +): quoteResponse is QuoteResponseV1 => { + return !isNonEvmChainId(quoteResponse.quote.srcChainId); +}; diff --git a/packages/bridge-controller/src/utils/caip-formatters.test.ts b/packages/bridge-controller/src/utils/caip-formatters.test.ts new file mode 100644 index 00000000000..4f373cf1693 --- /dev/null +++ b/packages/bridge-controller/src/utils/caip-formatters.test.ts @@ -0,0 +1,293 @@ +import { AddressZero } from '@ethersproject/constants'; +import { BtcScope, SolScope, TrxScope, XlmScope } from '@metamask/keyring-api'; + +import { CHAIN_IDS } from '../constants/chains.js'; +import { ChainId } from '../types.js'; +import { + formatChainIdToCaip, + formatChainIdToDec, + formatChainIdToHex, + formatAddressToCaipReference, + formatAddressToAssetId, +} from './caip-formatters.js'; + +describe('CAIP Formatters', () => { + describe('formatChainIdToCaip', () => { + it('should return the same value if already CAIP format', () => { + expect(formatChainIdToCaip('eip155:1')).toBe('eip155:1'); + }); + + it('should convert hex chainId to CAIP format', () => { + expect(formatChainIdToCaip('0x1')).toBe('eip155:1'); + }); + + it('should convert Solana chainId to SolScope.Mainnet', () => { + expect(formatChainIdToCaip(ChainId.SOLANA)).toBe(SolScope.Mainnet); + expect(formatChainIdToCaip(SolScope.Mainnet)).toBe(SolScope.Mainnet); + }); + + it('should convert Bitcoin chainId to BtcScope.Mainnet', () => { + expect(formatChainIdToCaip(ChainId.BTC)).toBe(BtcScope.Mainnet); + expect(formatChainIdToCaip(BtcScope.Mainnet)).toBe(BtcScope.Mainnet); + }); + + it('should convert Bitcoin numeric chainId to BtcScope.Mainnet', () => { + expect(formatChainIdToCaip(20000000000001)).toBe(BtcScope.Mainnet); + expect(formatChainIdToCaip('20000000000001')).toBe(BtcScope.Mainnet); + }); + + it('should convert Tron chainId to TrxScope.Mainnet', () => { + expect(formatChainIdToCaip(ChainId.TRON)).toBe(TrxScope.Mainnet); + expect(formatChainIdToCaip(TrxScope.Mainnet)).toBe(TrxScope.Mainnet); + }); + + it('should convert Stellar chainId to XlmScope', () => { + expect(formatChainIdToCaip(ChainId.STELLAR)).toBe(XlmScope.Pubnet); + expect(formatChainIdToCaip(XlmScope.Pubnet)).toBe(XlmScope.Pubnet); + expect(formatChainIdToCaip(XlmScope.Testnet)).toBe(XlmScope.Testnet); + }); + + it('should convert number to CAIP format', () => { + expect(formatChainIdToCaip(1)).toBe('eip155:1'); + }); + }); + + describe('formatChainIdToDec', () => { + it('should convert hex chainId to decimal', () => { + expect(formatChainIdToDec('0x1')).toBe(1); + }); + + it('should handle Solana mainnet', () => { + expect(formatChainIdToDec(SolScope.Mainnet)).toBe(ChainId.SOLANA); + }); + + it('should handle Bitcoin mainnet', () => { + expect(formatChainIdToDec(BtcScope.Mainnet)).toBe(ChainId.BTC); + }); + + it('should handle Bitcoin numeric chainId', () => { + expect(formatChainIdToDec(20000000000001)).toBe(20000000000001); + expect(formatChainIdToDec('20000000000001')).toBe(20000000000001); + }); + + it('should handle Tron mainnet', () => { + expect(formatChainIdToDec(TrxScope.Mainnet)).toBe(ChainId.TRON); + }); + + it('should handle Stellar mainnet', () => { + expect(formatChainIdToDec(XlmScope.Pubnet)).toBe(ChainId.STELLAR); + expect(formatChainIdToDec(XlmScope.Testnet)).toBe(ChainId.STELLAR); + expect(formatChainIdToDec(ChainId.STELLAR)).toBe(ChainId.STELLAR); + }); + + it('should parse CAIP chainId to decimal', () => { + expect(formatChainIdToDec('eip155:1')).toBe(1); + }); + + it('should handle numeric strings', () => { + expect(formatChainIdToDec('1')).toBe(1); + }); + + it('should return same number if number provided', () => { + expect(formatChainIdToDec(1)).toBe(1); + }); + }); + + describe('formatChainIdToHex', () => { + it('should return same value if already hex', () => { + expect(formatChainIdToHex('0x1')).toBe('0x1'); + }); + + it('should convert number to hex', () => { + expect(formatChainIdToHex(1)).toBe('0x1'); + }); + + it('should convert CAIP chainId to hex', () => { + expect(formatChainIdToHex('eip155:1')).toBe('0x1'); + }); + + it('should throw error for invalid chainId', () => { + expect(() => formatChainIdToHex('invalid')).toThrow( + 'Invalid cross-chain swaps chainId: invalid', + ); + }); + + it('should throw error for Bitcoin chainId (non-EVM)', () => { + expect(() => formatChainIdToHex(BtcScope.Mainnet)).toThrow( + `Invalid cross-chain swaps chainId: ${BtcScope.Mainnet}`, + ); + }); + + it('should throw error for Solana chainId (non-EVM)', () => { + expect(() => formatChainIdToHex(SolScope.Mainnet)).toThrow( + `Invalid cross-chain swaps chainId: ${SolScope.Mainnet}`, + ); + }); + }); + + describe('formatAddressToCaipReference', () => { + it('should checksum hex addresses', () => { + expect( + formatAddressToCaipReference( + '0x1234567890123456789012345678901234567890', + ), + ).toBe('0x1234567890123456789012345678901234567890'); + }); + + it('should return zero address for native token addresses', () => { + expect(formatAddressToCaipReference(AddressZero)).toStrictEqual( + AddressZero, + ); + expect(formatAddressToCaipReference('')).toStrictEqual(AddressZero); + expect( + formatAddressToCaipReference(`${SolScope.Mainnet}/slip44:501`), + ).toStrictEqual(AddressZero); + expect( + formatAddressToCaipReference(`${BtcScope.Mainnet}/slip44:0`), + ).toStrictEqual(AddressZero); + }); + + it('should extract address from CAIP format', () => { + expect( + formatAddressToCaipReference( + 'eip155:1:0x1234567890123456789012345678901234567890', + ), + ).toBe('0x1234567890123456789012345678901234567890'); + }); + + it('should handle Bitcoin addresses without prefix', () => { + const btcAddress = 'bc1qxy2kgdygjrsqtzq2n0yrf2493p83kkfjhx0wlh'; + expect(formatAddressToCaipReference(btcAddress)).toBe(btcAddress); + }); + + it('should extract Bitcoin address from CAIP format', () => { + const btcAddress = 'bc1qxy2kgdygjrsqtzq2n0yrf2493p83kkfjhx0wlh'; + expect( + formatAddressToCaipReference( + `bip122:000000000019d6689c085ae165831e93:${btcAddress}`, + ), + ).toBe(btcAddress); + }); + + it('should throw error for invalid address', () => { + expect(() => formatAddressToCaipReference('test:')).toThrow( + 'Invalid address', + ); + }); + }); + + describe('formatAddressToAssetId', () => { + it('should return the same value if already CAIP asset type', () => { + const caipAssetType = + 'eip155:1/erc20:0x1234567890123456789012345678901234567890'; + expect(formatAddressToAssetId(caipAssetType, 'eip155:1')).toBe( + caipAssetType, + ); + }); + + it('should return native asset for chainId when address is native (AddressZero)', () => { + const result = formatAddressToAssetId(AddressZero, CHAIN_IDS.MAINNET); + expect(result).toBe('eip155:1/slip44:60'); + }); + + it('should return native asset for chainId when address is empty string', () => { + const result = formatAddressToAssetId('', CHAIN_IDS.MAINNET); + expect(result).toBe('eip155:1/slip44:60'); + }); + + it('should return native asset for chainId when address is Solana native asset', () => { + const result = formatAddressToAssetId('501', SolScope.Mainnet); + expect(result).toBe('solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/slip44:501'); + }); + + it('should return native asset for chainId when address is Bitcoin native asset', () => { + const result = formatAddressToAssetId('0', BtcScope.Mainnet); + expect(result).toBe('bip122:000000000019d6689c085ae165831e93/slip44:0'); + }); + + it('should return native asset for chainId when address is BSC native asset', () => { + const result = formatAddressToAssetId('714', '0x38'); + expect(result).toBe('eip155:56/slip44:714'); + }); + + it('should return native asset for chainId when address is BSC native assetId', () => { + const result = formatAddressToAssetId('slip44:714', 56); + expect(result).toBe('eip155:56/slip44:714'); + }); + + it('should return native asset for chainId=BSC when address is zero address', () => { + const result = formatAddressToAssetId(AddressZero, 56); + expect(result).toBe('eip155:56/slip44:714'); + }); + + it('should create Solana token asset type when chainId is Solana', () => { + const tokenAddress = '7dHbWXmci3dT8UF5YZ5ppK9w4ppCH654F4H1Fp16m6Fn'; + const expectedAssetType = `${SolScope.Mainnet}/token:${tokenAddress}`; + + expect(formatAddressToAssetId(tokenAddress, SolScope.Mainnet)).toBe( + expectedAssetType, + ); + }); + + it('should return undefined for non-hex EVM addresses', () => { + expect( + formatAddressToAssetId('invalid-address', CHAIN_IDS.MAINNET), + ).toBeUndefined(); + }); + + it('should create EVM ERC20 asset type for valid hex addresses', () => { + const tokenAddress = '0x1234567890123456789012345678901234567890'; + const expectedAssetType = `eip155:1/erc20:${tokenAddress}`; + + expect(formatAddressToAssetId(tokenAddress, CHAIN_IDS.MAINNET)).toBe( + expectedAssetType, + ); + }); + + it('should create EVM ERC20 asset type for valid hex addresses with numeric chainId', () => { + const tokenAddress = '0x1234567890123456789012345678901234567890'; + const expectedAssetType = `eip155:1/erc20:${tokenAddress}`; + + expect(formatAddressToAssetId(tokenAddress, 1)).toBe(expectedAssetType); + }); + + it('should create EVM ERC20 asset type for valid hex addresses with CAIP chainId', () => { + const tokenAddress = '0x1234567890123456789012345678901234567890'; + const expectedAssetType = `eip155:1/erc20:${tokenAddress}`; + + expect(formatAddressToAssetId(tokenAddress, 'eip155:1')).toBe( + expectedAssetType, + ); + }); + + it('should handle different chain IDs correctly', () => { + const tokenAddress = '0x1234567890123456789012345678901234567890'; + + // Test with Polygon + expect(formatAddressToAssetId(tokenAddress, CHAIN_IDS.POLYGON)).toBe( + `eip155:137/erc20:${tokenAddress}`, + ); + + // Test with BSC + expect(formatAddressToAssetId(tokenAddress, CHAIN_IDS.BSC)).toBe( + `eip155:56/erc20:${tokenAddress}`, + ); + + // Test with Avalanche + expect(formatAddressToAssetId(tokenAddress, CHAIN_IDS.AVALANCHE)).toBe( + `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 new file mode 100644 index 00000000000..c3626eb79c2 --- /dev/null +++ b/packages/bridge-controller/src/utils/caip-formatters.ts @@ -0,0 +1,193 @@ +/* eslint-disable @typescript-eslint/explicit-function-return-type */ +import { getAddress } from '@ethersproject/address'; +import { AddressZero } from '@ethersproject/constants'; +import { + convertHexToDecimal, + toChecksumHexAddress, +} from '@metamask/controller-utils'; +import { BtcScope, SolScope, TrxScope, XlmScope } from '@metamask/keyring-api'; +import { toEvmCaipChainId } from '@metamask/multichain-network-controller'; +import { + isCaipChainId, + isStrictHexString, + parseCaipChainId, + isCaipReference, + isCaipAssetType, + CaipAssetTypeStruct, + numberToHex, +} from '@metamask/utils'; +import type { CaipAssetType, CaipChainId, Hex } from '@metamask/utils'; + +import type { GenericQuoteRequest } from '../types.js'; +import { ChainId } from '../types.js'; +import { + getNativeAssetForChainId, + isBitcoinChainId, + isNativeAddress, + isSolanaChainId, + isStellarChainId, + isTronChainId, +} from './bridge.js'; + +/** + * Converts a chainId to a CaipChainId + * + * @param chainId - The chainId to convert + * @returns The CaipChainId + */ +export const formatChainIdToCaip = ( + chainId: Hex | number | CaipChainId | string, +): CaipChainId => { + if (isCaipChainId(chainId)) { + return chainId; + } + if (isStrictHexString(chainId)) { + return toEvmCaipChainId(chainId); + } + if (isSolanaChainId(chainId)) { + return SolScope.Mainnet; + } + if (isBitcoinChainId(chainId)) { + return BtcScope.Mainnet; + } + if (isTronChainId(chainId)) { + return TrxScope.Mainnet; + } + if (isStellarChainId(chainId)) { + if (chainId === XlmScope.Testnet) { + return XlmScope.Testnet; + } + return XlmScope.Pubnet; + } + return toEvmCaipChainId(numberToHex(Number(chainId))); +}; + +/** + * Converts a chainId to a decimal number that can be used for bridge-api requests + * + * @param chainId - The chainId to convert + * @returns The decimal number + */ +export const formatChainIdToDec = ( + chainId: number | Hex | CaipChainId | string, +) => { + if (isStrictHexString(chainId)) { + return convertHexToDecimal(chainId); + } + if (chainId === SolScope.Mainnet) { + return ChainId.SOLANA; + } + if (chainId === BtcScope.Mainnet) { + return ChainId.BTC; + } + if (chainId === TrxScope.Mainnet) { + return ChainId.TRON; + } + if (isStellarChainId(chainId)) { + return ChainId.STELLAR; + } + if (isCaipChainId(chainId)) { + return Number(chainId.split(':').at(-1)); + } + if (typeof chainId === 'string') { + return parseInt(chainId, 10); + } + return chainId; +}; + +/** + * Converts a chainId to a hex string used to read controller data within the app + * Hex chainIds are also used for fetching exchange rates + * + * @param chainId - The chainId to convert + * @returns The hex string + */ +export const formatChainIdToHex = ( + chainId: Hex | CaipChainId | string | number, +): Hex => { + if (isStrictHexString(chainId)) { + return chainId; + } + if (typeof chainId === 'number' || parseInt(chainId, 10)) { + return numberToHex(Number(chainId)); + } + if (isCaipChainId(chainId)) { + const { reference } = parseCaipChainId(chainId); + if (isCaipReference(reference) && !isNaN(Number(reference))) { + return numberToHex(Number(reference)); + } + } + // Throw an error if a non-evm chainId is passed to this function + // This should never happen, but it's a sanity check + throw new Error(`Invalid cross-chain swaps chainId: ${chainId}`); +}; + +/** + * Converts an asset or account address to a string that can be used for bridge-api requests + * + * @param address - The address to convert + * @returns The converted address + */ +export const formatAddressToCaipReference = (address: string) => { + if (isStrictHexString(address)) { + return getAddress(address); + } + // If the address looks like a native token, return the zero address because it's + // what bridge-api uses to represent a native asset + if (isNativeAddress(address)) { + return AddressZero; + } + const addressWithoutPrefix = address.split(':').at(-1); + // If the address is not a valid hex string or CAIP address, throw an error + // This should never happen, but it's a sanity check + if (!addressWithoutPrefix) { + throw new Error('Invalid address'); + } + return addressWithoutPrefix; +}; + +/** + * Converts an address or assetId to a checksummed CaipAssetType + * + * @param addressOrAssetId - The address or assetId to convert + * @param chainId - The chainId of the asset + * @returns The CaipAssetType + */ +export const formatAddressToAssetId = ( + addressOrAssetId: Hex | CaipAssetType | string, + chainId?: GenericQuoteRequest['srcChainId'], +): CaipAssetType | undefined => { + if (isCaipAssetType(addressOrAssetId)) { + return addressOrAssetId; + } + if (!chainId) { + return undefined; + } + + const chainIdCaip = formatChainIdToCaip(chainId); + if (isNativeAddress(addressOrAssetId)) { + return getNativeAssetForChainId(chainIdCaip).assetId; + } + 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( + `${chainIdCaip}/erc20:${checksummedAddress}`, + ); +}; diff --git a/packages/bridge-controller/src/utils/feature-flags.test.ts b/packages/bridge-controller/src/utils/feature-flags.test.ts new file mode 100644 index 00000000000..3ec28a6ac18 --- /dev/null +++ b/packages/bridge-controller/src/utils/feature-flags.test.ts @@ -0,0 +1,591 @@ +import { DEFAULT_CHAIN_RANKING } from '../constants/bridge.js'; +import type { + FeatureFlagsPlatformConfig, + BridgeControllerMessenger, +} from '../types.js'; +import { + formatFeatureFlags, + getBridgeFeatureFlags, + hasMinimumRequiredVersion, +} from './feature-flags.js'; + +describe('feature-flags', () => { + describe('formatFeatureFlags', () => { + it('should format chain IDs to CAIP format', () => { + const bridgeConfig = { + refreshRate: 3, + maxRefreshCount: 1, + support: true, + minimumVersion: '0.0.0', + chains: { + '1': { + isActiveSrc: true, + isActiveDest: true, + batchSellDestStablecoins: [ + 'eip155:1/erc20:0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48', + 'eip155:1/slip44:60', + ], + }, + '10': { + isActiveSrc: true, + isActiveDest: false, + }, + '59144': { + isActiveSrc: true, + isActiveDest: true, + }, + '120': { + isActiveSrc: true, + isActiveDest: false, + }, + '137': { + isActiveSrc: false, + isActiveDest: true, + }, + '11111': { + isActiveSrc: false, + isActiveDest: true, + }, + '1151111081099710': { + isActiveSrc: true, + isActiveDest: true, + }, + }, + chainRanking: [], + }; + + const result = formatFeatureFlags(bridgeConfig); + + expect(result).toStrictEqual({ + refreshRate: 3, + maxRefreshCount: 1, + support: true, + minimumVersion: '0.0.0', + chainRanking: [], + chains: { + 'eip155:1': { + isActiveSrc: true, + isActiveDest: true, + batchSellDestStablecoins: [ + 'eip155:1/erc20:0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48', + 'eip155:1/slip44:60', + ], + }, + 'eip155:10': { + isActiveSrc: true, + isActiveDest: false, + }, + 'eip155:59144': { + isActiveSrc: true, + isActiveDest: true, + }, + 'eip155:120': { + isActiveSrc: true, + isActiveDest: false, + }, + 'eip155:137': { + isActiveSrc: false, + isActiveDest: true, + }, + 'eip155:11111': { + isActiveSrc: false, + isActiveDest: true, + }, + 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp': { + isActiveSrc: true, + isActiveDest: true, + }, + }, + }); + }); + + it('should handle empty chains object', () => { + const bridgeConfig: FeatureFlagsPlatformConfig = { + refreshRate: 3, + maxRefreshCount: 1, + support: true, + minimumVersion: '0.0.0', + chains: {}, + chainRanking: [], + }; + + const result = formatFeatureFlags(bridgeConfig); + + expect(result).toStrictEqual({ + refreshRate: 3, + maxRefreshCount: 1, + support: true, + minimumVersion: '0.0.0', + chainRanking: [], + chains: {}, + }); + }); + + it('should handle invalid chain IDs', () => { + const bridgeConfig: FeatureFlagsPlatformConfig = { + refreshRate: 3, + maxRefreshCount: 1, + support: true, + minimumVersion: '0.0.0', + chains: { + 'eip155:invalid': { + isActiveSrc: true, + isActiveDest: true, + }, + 'eip155:0x123': { + isActiveSrc: true, + isActiveDest: false, + }, + }, + chainRanking: [], + }; + + const result = formatFeatureFlags(bridgeConfig); + + expect(result).toStrictEqual({ + refreshRate: 3, + maxRefreshCount: 1, + support: true, + minimumVersion: '0.0.0', + chainRanking: [], + chains: { + 'eip155:invalid': { + isActiveSrc: true, + isActiveDest: true, + }, + 'eip155:0x123': { + isActiveSrc: true, + isActiveDest: false, + }, + }, + }); + }); + + it('should preserve non-empty chainRanking', () => { + const customChainRanking = [ + { chainId: 'eip155:1' as const, name: 'Ethereum' }, + { chainId: 'eip155:137' as const, name: 'Polygon' }, + ]; + const bridgeConfig: FeatureFlagsPlatformConfig = { + refreshRate: 3, + maxRefreshCount: 1, + support: true, + minimumVersion: '0.0.0', + chains: {}, + chainRanking: customChainRanking, + }; + + const result = formatFeatureFlags(bridgeConfig); + + expect(result).toStrictEqual({ + refreshRate: 3, + maxRefreshCount: 1, + support: true, + minimumVersion: '0.0.0', + chainRanking: customChainRanking, + chains: {}, + }); + }); + }); + describe('getBridgeFeatureFlags', () => { + const mockMessenger = { + call: jest.fn(), + publish: jest.fn(), + registerActionHandler: jest.fn(), + registerInitialEventPayload: jest.fn(), + } as unknown as BridgeControllerMessenger; + + it('should fetch bridge feature flags successfully', async () => { + const bridgeConfig = { + refreshRate: 3, + maxRefreshCount: 1, + support: true, + minimumVersion: '0.0.0', + chainRanking: [], + chains: { + '1': { + isActiveSrc: true, + isActiveDest: true, + }, + '10': { + isActiveSrc: true, + isActiveDest: false, + }, + '59144': { + isActiveSrc: true, + isActiveDest: true, + }, + '120': { + isActiveSrc: true, + isActiveDest: false, + }, + '137': { + isActiveSrc: false, + isActiveDest: true, + }, + '11111': { + isActiveSrc: false, + isActiveDest: true, + }, + '1151111081099710': { + isActiveSrc: true, + isActiveDest: true, + }, + }, + }; + + const remoteFeatureFlagControllerState = { + cacheTimestamp: 1745515389440, + remoteFeatureFlags: { + bridgeConfig, + assetsNotificationsEnabled: false, + confirmation_redesign: { + contract_interaction: false, + signatures: false, + staking_confirmations: false, + }, + confirmations_eip_7702: {}, + earnFeatureFlagTemplate: { + enabled: false, + minimumVersion: '0.0.0', + }, + earnPooledStakingEnabled: { + enabled: false, + minimumVersion: '0.0.0', + }, + earnPooledStakingServiceInterruptionBannerEnabled: { + enabled: false, + minimumVersion: '0.0.0', + }, + earnStablecoinLendingEnabled: { + enabled: false, + minimumVersion: '0.0.0', + }, + earnStablecoinLendingServiceInterruptionBannerEnabled: { + enabled: false, + minimumVersion: '0.0.0', + }, + mobileMinimumVersions: { + androidMinimumAPIVersion: 0, + appMinimumBuild: 0, + appleMinimumOS: 0, + }, + productSafetyDappScanning: false, + testFlagForThreshold: {}, + tokenSearchDiscoveryEnabled: false, + transactionsPrivacyPolicyUpdate: 'no_update', + transactionsTxHashInAnalytics: false, + walletFrameworkRpcFailoverEnabled: false, + }, + }; + + (mockMessenger.call as jest.Mock).mockImplementation(() => { + return remoteFeatureFlagControllerState; + }); + + const result = getBridgeFeatureFlags(mockMessenger); + + const expectedBridgeConfig = { + maxRefreshCount: 1, + refreshRate: 3, + support: true, + minimumVersion: '0.0.0', + chainRanking: [...DEFAULT_CHAIN_RANKING], + chains: { + 'eip155:1': { + isActiveDest: true, + isActiveSrc: true, + }, + 'eip155:10': { + isActiveDest: false, + isActiveSrc: true, + }, + 'eip155:11111': { + isActiveDest: true, + isActiveSrc: false, + }, + 'eip155:120': { + isActiveDest: false, + isActiveSrc: true, + }, + 'eip155:137': { + isActiveDest: true, + isActiveSrc: false, + }, + 'eip155:59144': { + isActiveDest: true, + isActiveSrc: true, + }, + 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp': { + isActiveDest: true, + isActiveSrc: true, + }, + }, + }; + + expect(result).toStrictEqual(expectedBridgeConfig); + }); + + it('should use fallback bridge feature flags if response is unexpected', async () => { + const bridgeConfig = { + refreshRate: 3, + maxRefreshCount: 1, + support: 25, + minimumVersion: '0.0.0', + chains: { + a: { + isActiveSrc: 1, + isActiveDest: 'test', + }, + '2': { + isActiveSrc: 'test', + isActiveDest: 2, + }, + }, + }; + const remoteFeatureFlagControllerState = { + cacheTimestamp: 1745515389440, + remoteFeatureFlags: { + bridgeConfig, + assetsNotificationsEnabled: false, + confirmation_redesign: { + contract_interaction: false, + signatures: false, + staking_confirmations: false, + }, + confirmations_eip_7702: {}, + earnFeatureFlagTemplate: { + enabled: false, + minimumVersion: '0.0.0', + }, + earnPooledStakingEnabled: { + enabled: false, + minimumVersion: '0.0.0', + }, + earnPooledStakingServiceInterruptionBannerEnabled: { + enabled: false, + minimumVersion: '0.0.0', + }, + earnStablecoinLendingEnabled: { + enabled: false, + minimumVersion: '0.0.0', + }, + earnStablecoinLendingServiceInterruptionBannerEnabled: { + enabled: false, + minimumVersion: '0.0.0', + }, + mobileMinimumVersions: { + androidMinimumAPIVersion: 0, + appMinimumBuild: 0, + appleMinimumOS: 0, + }, + productSafetyDappScanning: false, + testFlagForThreshold: {}, + tokenSearchDiscoveryEnabled: false, + transactionsPrivacyPolicyUpdate: 'no_update', + transactionsTxHashInAnalytics: false, + walletFrameworkRpcFailoverEnabled: false, + }, + }; + + (mockMessenger.call as jest.Mock).mockResolvedValue( + remoteFeatureFlagControllerState, + ); + + const result = getBridgeFeatureFlags(mockMessenger); + + const expectedBridgeConfig = { + maxRefreshCount: 5, + refreshRate: 30000, + support: false, + minimumVersion: '0.0.0', + chainRanking: [...DEFAULT_CHAIN_RANKING], + chains: {}, + }; + expect(result).toStrictEqual(expectedBridgeConfig); + }); + + it('should prioritize bridgeConfigV2 over bridgeConfig', async () => { + const bridgeConfigV2 = { + refreshRate: 5, + maxRefreshCount: 2, + support: true, + minimumVersion: '1.0.0', + chains: { + '1': { + isActiveSrc: true, + isActiveDest: true, + batchSellDestStablecoins: [ + 'eip155:1/erc20:0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48', + 'eip155:1/slip44:60', + ], + }, + }, + chainRanking: [], + }; + + const bridgeConfig = { + refreshRate: 3, + maxRefreshCount: 1, + support: true, + minimumVersion: '0.0.0', + chains: { + '1': { + isActiveSrc: true, + isActiveDest: true, + }, + }, + chainRanking: [], + }; + + const remoteFeatureFlagControllerState = { + cacheTimestamp: 1745515389440, + remoteFeatureFlags: { + bridgeConfigV2, + bridgeConfig, + assetsNotificationsEnabled: false, + }, + }; + + (mockMessenger.call as jest.Mock).mockImplementation(() => { + return remoteFeatureFlagControllerState; + }); + + const result = getBridgeFeatureFlags(mockMessenger); + + const expectedBridgeConfig = { + refreshRate: 5, + maxRefreshCount: 2, + support: true, + minimumVersion: '1.0.0', + chains: { + 'eip155:1': { + isActiveSrc: true, + isActiveDest: true, + batchSellDestStablecoins: [ + 'eip155:1/erc20:0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48', + 'eip155:1/slip44:60', + ], + }, + }, + chainRanking: [...DEFAULT_CHAIN_RANKING], + }; + + expect(result).toStrictEqual(expectedBridgeConfig); + }); + + it('should fallback to bridgeConfig when bridgeConfigV2 is not available', async () => { + const bridgeConfig = { + refreshRate: 3, + maxRefreshCount: 1, + support: true, + minimumVersion: '0.0.0', + chains: { + '1': { + isActiveSrc: true, + isActiveDest: true, + }, + }, + chainRanking: [], + }; + + const remoteFeatureFlagControllerState = { + cacheTimestamp: 1745515389440, + remoteFeatureFlags: { + bridgeConfig, + assetsNotificationsEnabled: false, + }, + }; + + (mockMessenger.call as jest.Mock).mockImplementation(() => { + return remoteFeatureFlagControllerState; + }); + + const result = getBridgeFeatureFlags(mockMessenger); + + const expectedBridgeConfig = { + refreshRate: 3, + maxRefreshCount: 1, + support: true, + minimumVersion: '0.0.0', + chains: { + 'eip155:1': { + isActiveSrc: true, + isActiveDest: true, + }, + }, + chainRanking: [...DEFAULT_CHAIN_RANKING], + }; + + expect(result).toStrictEqual(expectedBridgeConfig); + }); + + it('should preserve non-empty chainRanking from remote config', async () => { + const customChainRanking = [ + { chainId: 'eip155:1' as const, name: 'Ethereum' }, + { chainId: 'eip155:137' as const, name: 'Polygon' }, + ]; + const bridgeConfig = { + refreshRate: 3, + maxRefreshCount: 1, + support: true, + minimumVersion: '0.0.0', + chains: { + '1': { + isActiveSrc: true, + isActiveDest: true, + }, + }, + chainRanking: customChainRanking, + }; + + const remoteFeatureFlagControllerState = { + cacheTimestamp: 1745515389440, + remoteFeatureFlags: { + bridgeConfig, + assetsNotificationsEnabled: false, + }, + }; + + (mockMessenger.call as jest.Mock).mockImplementation(() => { + return remoteFeatureFlagControllerState; + }); + + const result = getBridgeFeatureFlags(mockMessenger); + + const expectedBridgeConfig = { + refreshRate: 3, + maxRefreshCount: 1, + support: true, + minimumVersion: '0.0.0', + chains: { + 'eip155:1': { + isActiveSrc: true, + isActiveDest: true, + }, + }, + chainRanking: customChainRanking, + }; + + expect(result).toStrictEqual(expectedBridgeConfig); + }); + }); + + describe('hasMinimumRequiredVersion', () => { + it('should return true if the client version is greater than or equal to the minimum required version', () => { + expect(hasMinimumRequiredVersion('13.8.0', '13.7.0')).toBe(true); + expect(hasMinimumRequiredVersion('13.8.1', '13.8.0')).toBe(true); + expect(hasMinimumRequiredVersion('14.0.0', '13.7.0')).toBe(true); + expect(hasMinimumRequiredVersion('13.9.0', '13.8.1')).toBe(true); + }); + + it('should return false if the client version is less than the minimum required version', () => { + expect(hasMinimumRequiredVersion('13.7.0', '13.8.0')).toBe(false); + expect(hasMinimumRequiredVersion('13.7.1', '13.8.0')).toBe(false); + expect(hasMinimumRequiredVersion('13.7.1', '13.7.2')).toBe(false); + expect(hasMinimumRequiredVersion('13.6.0', '13.8.0')).toBe(false); + expect(hasMinimumRequiredVersion('13.7.0', '14.7.0')).toBe(false); + expect(hasMinimumRequiredVersion('13.7.0', '13.8.1')).toBe(false); + }); + }); +}); diff --git a/packages/bridge-controller/src/utils/feature-flags.ts b/packages/bridge-controller/src/utils/feature-flags.ts new file mode 100644 index 00000000000..d4f304e7df0 --- /dev/null +++ b/packages/bridge-controller/src/utils/feature-flags.ts @@ -0,0 +1,113 @@ +import type { RemoteFeatureFlagControllerState } from '@metamask/remote-feature-flag-controller'; + +import { + DEFAULT_CHAIN_RANKING, + DEFAULT_FEATURE_FLAG_CONFIG, +} from '../constants/bridge.js'; +import type { + FeatureFlagsPlatformConfig, + ChainConfiguration, +} from '../types.js'; +import { validateFeatureFlagsResponse } from '../validators/feature-flags.js'; +import { formatChainIdToCaip } from './caip-formatters.js'; + +export const formatFeatureFlags = ( + bridgeFeatureFlags: FeatureFlagsPlatformConfig, +) => { + const getChainsObj = (chains: Record) => + Object.entries(chains).reduce( + (acc, [chainId, value]) => ({ + ...acc, + [formatChainIdToCaip(chainId)]: value, + }), + {}, + ); + + return { + ...bridgeFeatureFlags, + chains: getChainsObj(bridgeFeatureFlags.chains), + }; +}; + +export const processFeatureFlags = ( + bridgeFeatureFlags: unknown, +): FeatureFlagsPlatformConfig => { + if (validateFeatureFlagsResponse(bridgeFeatureFlags)) { + const formattedFlags = formatFeatureFlags(bridgeFeatureFlags); + // If chainRanking is undefined or empty, use the default chainRanking + if ( + !formattedFlags.chainRanking || + formattedFlags.chainRanking.length === 0 + ) { + return { + ...formattedFlags, + chainRanking: [...DEFAULT_CHAIN_RANKING], + }; + } + return formattedFlags; + } + return DEFAULT_FEATURE_FLAG_CONFIG; +}; + +/** + * Gets the bridge feature flags from the remote feature flag controller + * + * @param messenger - Any messenger with access to RemoteFeatureFlagController:getState + * @returns The bridge feature flags + */ +export function getBridgeFeatureFlags< + T extends { + call( + action: 'RemoteFeatureFlagController:getState', + ): RemoteFeatureFlagControllerState; + }, +>(messenger: T): FeatureFlagsPlatformConfig { + // This will return the bridgeConfig for the current platform even without specifying the platform + const remoteFeatureFlagControllerState = messenger.call( + 'RemoteFeatureFlagController:getState', + ); + + // bridgeConfigV2 is the feature flag for the mobile app + // bridgeConfig for Mobile has been deprecated since release of bridge and Solana in 7.46.0 was pushed back + // and there's no way to turn on bridgeConfig for 7.47.0 without affecting 7.46.0 as well. + // You will still get bridgeConfig returned from remoteFeatureFlagControllerState but you should use bridgeConfigV2 instead + // Mobile's bridgeConfig will be permanently serving the disabled variation, so falling back to it in Mobile will be ok + const rawMobileFlags = + remoteFeatureFlagControllerState?.remoteFeatureFlags?.bridgeConfigV2; + + // Extension LaunchDarkly will not have the bridgeConfigV2 field, so we'll continue to use bridgeConfig + const rawBridgeConfig = + remoteFeatureFlagControllerState?.remoteFeatureFlags?.bridgeConfig; + + return processFeatureFlags(rawMobileFlags || rawBridgeConfig); +} + +/** + * Checks if the client version is greater than or equal to the minimum required version + * + * @param clientVersion - The client version + * @param minRequiredVersion - The minimum required version + * @returns True if the client version is greater than or equal to the minimum required version, false otherwise + */ +export const hasMinimumRequiredVersion = ( + clientVersion: string, + minRequiredVersion: string, +) => { + const [clientMajor, clientMinor, clientPatch] = clientVersion + .split('.') + .map(Number); + const [minRequiredMajor, minRequiredMinor, minRequiredPatch] = + minRequiredVersion.split('.').map(Number); + + if (clientMajor > minRequiredMajor) { + return true; + } + if (clientMajor === minRequiredMajor && clientMinor > minRequiredMinor) { + return true; + } + return ( + clientMajor === minRequiredMajor && + clientMinor === minRequiredMinor && + clientPatch >= minRequiredPatch + ); +}; diff --git a/packages/bridge-controller/src/utils/fetch-server-events.ts b/packages/bridge-controller/src/utils/fetch-server-events.ts new file mode 100644 index 00000000000..8bfe24a9aa0 --- /dev/null +++ b/packages/bridge-controller/src/utils/fetch-server-events.ts @@ -0,0 +1,86 @@ +/** + * Streams server-sent events from the given URL + * + * @param url - The URL to stream events from + * @param options - The options for the SSE stream + * @param options.onMessage - The function to call when a message is received + * @param options.onError - The function to call when an error occurs + * @param options.onClose - The function to call when the stream finishes successfully + * @param options.fetchFn - The function to use to fetch the events. Consumers need to provide a fetch function that supports server-sent events. + */ +export const fetchServerEvents = async ( + url: string, + { + onMessage, + onError, + onClose, + fetchFn, + ...requestOptions + }: RequestInit & { + onMessage: ( + data: Record, + eventName?: string, + ) => Promise; + onError?: (err: unknown) => void; + onClose?: () => void | Promise; + fetchFn: typeof fetch; + }, +) => { + let reader: ReadableStreamDefaultReader | undefined; + try { + const response = await fetchFn(url, requestOptions); + if (!response.ok || !response.body) { + throw new Error(`${response.status}`); + } + + reader = response.body.getReader(); + const decoder = new TextDecoder('utf-8'); + let buffer = ''; + + while (true) { + const { done, value } = await reader.read(); + + if (done) { + break; + } + + buffer += decoder.decode(value, { stream: true }); + + // Split SSE messages at double newlines + const parts = buffer.split('\n\n'); + buffer = parts.pop() || ''; + + // Split chunks into lines and parse the data + for (const chunk of parts) { + const lines = chunk.split('\n'); + let eventName: string | undefined; + const dataLines: string[] = []; + + for (const line of lines) { + if (line.startsWith('event:')) { + eventName = line.slice(6).trim(); + } else if (line.startsWith('data:')) { + dataLines.push(line.slice(5).trim()); + } + } + + if (eventName === 'error') { + throw new Error(`Bridge-api error: ${dataLines.join('\n')}`); + } + if (dataLines.length > 0) { + const parsedJSONData = JSON.parse(dataLines.join('\n')); + await onMessage(parsedJSONData, eventName); + } + } + } + await onClose?.(); + } catch (error) { + onError?.(error); + } finally { + try { + await reader?.cancel(); + } catch (error) { + console.error('Error cleaning up stream reader', error); + } + } +}; diff --git a/packages/bridge-controller/src/utils/fetch.test.ts b/packages/bridge-controller/src/utils/fetch.test.ts new file mode 100644 index 00000000000..5c52db8da30 --- /dev/null +++ b/packages/bridge-controller/src/utils/fetch.test.ts @@ -0,0 +1,989 @@ +import { AddressZero } from '@ethersproject/constants'; +import type { CaipAssetType } from '@metamask/utils'; + +import { + getMockBridgeQuotesErc20Erc20V2, + mockBridgeQuotesErc20Erc20V1, +} from '../../tests/mock-quotes-erc20-erc20.js'; +import { mockBridgeQuotesNativeErc20V1 } from '../../tests/mock-quotes-native-erc20.js'; +import { toQuoteResponseV2 } from '../coercers/quote-response-v1-to-v2.js'; +import { + BridgeClientId, + BRIDGE_PROD_API_BASE_URL, +} from '../constants/bridge.js'; +import { BatchSellTransactionType } from '../validators/batch-sell.js'; +import { FeatureId } from '../validators/feature-flags.js'; +import { + fetchBridgeQuotes, + fetchBridgeTokens, + fetchAssetPrices, + fetchBatchSellTrades, + formatBatchSellTradesRequest, +} from './fetch.js'; + +const mockFetchFn = jest.fn(); + +describe('fetch', () => { + describe('fetchBridgeTokens', () => { + it('should fetch bridge tokens successfully', async () => { + const mockResponse = [ + { + address: '0x0000000000000000000000000000000000000000', + assetId: 'eip155:10/slip44:60', + symbol: 'ETH', + decimals: 18, + name: 'Ether', + coingeckoId: 'ethereum', + aggregators: [], + iconUrl: + 'https://static.cx.metamask.io/api/v2/tokenIcons/assets/eip155/10/native/60.png', + metadata: { + honeypotStatus: {}, + isContractVerified: false, + erc20Permit: false, + description: {}, + createdAt: '2023-10-31T22:16:37.494Z', + }, + chainId: 10, + }, + { + address: '0x1f9840a85d5af5bf1d1762f925bdaddc4201f984', + assetId: 'eip155:10/erc20:0x1f9840a85d5af5bf1d1762f925bdaddc4201f984', + symbol: 'ABC', + name: 'ABC', + decimals: 16, + chainId: 10, + }, + { + address: '0x1f9840a85d5af5bf1d1762f925bdaddc4201f985', + assetId: 'eip155:10/erc20:0x1f9840a85d5af5bf1d1762f925bdaddc4201f985', + decimals: 16, + chainId: 10, + }, + { + address: '0x1f9840a85d5af5bf1d1762f925bdaddc4201f986', + assetId: 'eip155:10/erc20:0x1f9840a85d5af5bf1d1762f925bdaddc4201f986', + decimals: 16, + symbol: 'DEF', + name: 'DEF', + aggregators: ['lifi'], + chainId: 10, + }, + { + address: '0x1f9840a85d5af5bf1d1762f925bdaddc4201f987', + assetId: 'eip155:10/erc20:0x1f9840a85d5af5bf1d1762f925bdaddc4201f987', + symbol: 'DEF', + chainId: 10, + }, + { + address: '0x124', + assetId: 'eip155:10/erc20:0x0b2c639c533813f4aa9d7837caf62653d097ff85', + symbol: 'JKL', + decimals: 16, + chainId: 10, + }, + ]; + + mockFetchFn.mockResolvedValue(mockResponse); + + const result = await fetchBridgeTokens( + '0xa', + BridgeClientId.EXTENSION, + 'AUTH_TOKEN', + mockFetchFn, + BRIDGE_PROD_API_BASE_URL, + '1.0.0', + ); + + expect(mockFetchFn).toHaveBeenCalledWith( + 'https://bridge.api.cx.metamask.io/getTokens?chainId=10', + { + headers: { + 'X-Client-Id': 'extension', + 'Client-Version': '1.0.0', + Authorization: 'Bearer AUTH_TOKEN', + }, + }, + ); + + expect(result).toStrictEqual({ + '0x0000000000000000000000000000000000000000': { + address: '0x0000000000000000000000000000000000000000', + aggregators: [], + assetId: 'eip155:10/slip44:60', + chainId: 10, + coingeckoId: 'ethereum', + decimals: 18, + iconUrl: + 'https://static.cx.metamask.io/api/v2/tokenIcons/assets/eip155/10/native/60.png', + metadata: { + createdAt: '2023-10-31T22:16:37.494Z', + description: {}, + erc20Permit: false, + honeypotStatus: {}, + isContractVerified: false, + }, + name: 'Ether', + symbol: 'ETH', + }, + '0x1f9840a85d5af5bf1d1762f925bdaddc4201f986': { + address: '0x1f9840a85d5af5bf1d1762f925bdaddc4201f986', + assetId: 'eip155:10/erc20:0x1f9840a85d5af5bf1d1762f925bdaddc4201f986', + chainId: 10, + decimals: 16, + name: 'DEF', + symbol: 'DEF', + aggregators: ['lifi'], + }, + '0x1f9840a85d5af5bf1d1762f925bdaddc4201f984': { + address: '0x1f9840a85d5af5bf1d1762f925bdaddc4201f984', + assetId: 'eip155:10/erc20:0x1f9840a85d5af5bf1d1762f925bdaddc4201f984', + chainId: 10, + decimals: 16, + name: 'ABC', + symbol: 'ABC', + }, + }); + }); + + it('should handle fetch error', async () => { + const mockError = new Error('Failed to fetch'); + + mockFetchFn.mockRejectedValue(mockError); + + await expect( + fetchBridgeTokens( + '0xa', + BridgeClientId.EXTENSION, + 'AUTH_TOKEN', + mockFetchFn, + BRIDGE_PROD_API_BASE_URL, + '1.0.0', + ), + ).rejects.toThrow(mockError); + }); + }); + + describe('fetchBridgeQuotes', () => { + it('should fetch bridge quotes successfully, no approvals', async () => { + const mockConsoleWarn = jest + .spyOn(console, 'warn') + .mockImplementation(jest.fn()); + mockFetchFn.mockResolvedValue(mockBridgeQuotesNativeErc20V1); + const { signal } = new AbortController(); + + const result = await fetchBridgeQuotes( + { + walletAddress: '0x1f9840a85d5aF5bf1D1762F925BDADdC4201F984', + srcChainId: 1, + destChainId: 10, + srcTokenAddress: AddressZero, + destTokenAddress: AddressZero, + srcTokenAmount: '20000', + slippage: 0.5, + gasIncluded: false, + gasIncluded7702: false, + }, + signal, + BridgeClientId.EXTENSION, + 'AUTH_TOKEN', + mockFetchFn, + BRIDGE_PROD_API_BASE_URL, + null, + '1.0.0', + ); + + 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', + Authorization: 'Bearer AUTH_TOKEN', + }, + signal, + }, + ); + + expect(result.quotes).toStrictEqual( + mockBridgeQuotesNativeErc20V1.map((quote) => ({ + ...quote, + featureId: undefined, + resetApproval: undefined, + })), + ); + expect(result.validationFailures).toStrictEqual([]); + expect(mockConsoleWarn).not.toHaveBeenCalled(); + mockConsoleWarn.mockRestore(); + }); + + it('should fetch bridge quotes successfully, with approvals', async () => { + const mockConsoleWarn = jest + .spyOn(console, 'warn') + .mockImplementation(jest.fn()); + mockFetchFn.mockResolvedValue([ + ...mockBridgeQuotesErc20Erc20V1, + { + ...mockBridgeQuotesErc20Erc20V1[0], + quote: { + ...mockBridgeQuotesErc20Erc20V1[0].quote, + bridges: ['lifi'], + bridgeId: 'lifi', + }, + approval: null, + }, + { ...mockBridgeQuotesErc20Erc20V1[0], trade: null }, + ]); + const { signal } = new AbortController(); + + const result = await fetchBridgeQuotes( + { + walletAddress: '0x1f9840a85d5aF5bf1D1762F925BDADdC4201F984', + srcChainId: 1, + destChainId: 10, + srcTokenAddress: AddressZero, + destTokenAddress: AddressZero, + srcTokenAmount: '20000', + slippage: 0.5, + gasIncluded: false, + gasIncluded7702: false, + }, + signal, + BridgeClientId.EXTENSION, + 'AUTH_TOKEN', + mockFetchFn, + BRIDGE_PROD_API_BASE_URL, + null, + '1.0.0', + ); + + 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', + Authorization: 'Bearer AUTH_TOKEN', + }, + signal, + }, + ); + + expect(result.quotes).toStrictEqual( + mockBridgeQuotesErc20Erc20V1.map((quote) => ({ + ...quote, + featureId: undefined, + resetApproval: undefined, + })), + ); + expect(result.validationFailures).toStrictEqual([ + 'lifi|approval', + 'socket|trade', + ]); + expect(mockConsoleWarn.mock.calls).toMatchInlineSnapshot(` + [ + [ + "Quote validation failed", + [ + "lifi|approval", + "socket|trade", + ], + ], + ] + `); + mockConsoleWarn.mockRestore(); + }); + + it('should filter out malformed bridge quotes', async () => { + const mockConsoleWarn = jest + .spyOn(console, 'warn') + .mockImplementation(jest.fn()); + mockFetchFn.mockResolvedValue([ + ...mockBridgeQuotesErc20Erc20V1, + ...mockBridgeQuotesErc20Erc20V1.map( + ({ quote, ...restOfQuote }) => restOfQuote, + ), + { + ...mockBridgeQuotesErc20Erc20V1[0], + quote: { + bridges: ['lifi'], + bridgeId: 'lifi', + srcAsset: { + ...mockBridgeQuotesErc20Erc20V1[0].quote.srcAsset, + decimals: undefined, + }, + }, + }, + { + ...mockBridgeQuotesErc20Erc20V1[1], + quote: { + bridges: ['socket'], + bridgeId: 'socket', + destAsset: { + ...mockBridgeQuotesErc20Erc20V1[1].quote.destAsset, + address: undefined, + }, + }, + }, + ]); + const { signal } = new AbortController(); + + const result = await fetchBridgeQuotes( + { + walletAddress: '0x1f9840a85d5aF5bf1D1762F925BDADdC4201F984', + srcChainId: 1, + destChainId: 10, + srcTokenAddress: AddressZero, + destTokenAddress: AddressZero, + srcTokenAmount: '20000', + slippage: 0.5, + gasIncluded: false, + gasIncluded7702: false, + }, + signal, + BridgeClientId.EXTENSION, + 'AUTH_TOKEN', + mockFetchFn, + BRIDGE_PROD_API_BASE_URL, + null, + '1.0.0', + ); + + 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', + Authorization: 'Bearer AUTH_TOKEN', + }, + signal, + }, + ); + + expect(result.quotes).toStrictEqual( + mockBridgeQuotesErc20Erc20V1.map((quote) => ({ + ...quote, + featureId: undefined, + resetApproval: undefined, + })), + ); + expect(result.validationFailures).toMatchInlineSnapshot(` + [ + "unknown|quote", + "lifi|quote.requestId", + "lifi|quote.srcChainId", + "lifi|quote.srcAsset.decimals", + "lifi|quote.srcTokenAmount", + "lifi|quote.destChainId", + "lifi|quote.destAsset", + "lifi|quote.destTokenAmount", + "lifi|quote.feeData", + "lifi|quote.steps", + "socket|quote.requestId", + "socket|quote.srcChainId", + "socket|quote.srcAsset", + "socket|quote.srcTokenAmount", + "socket|quote.destChainId", + "socket|quote.destAsset.address", + "socket|quote.destTokenAmount", + "socket|quote.feeData", + "socket|quote.steps", + ] + `); + // eslint-disable-next-line jest/no-restricted-matchers + expect(mockConsoleWarn.mock.calls).toMatchSnapshot(); + mockConsoleWarn.mockRestore(); + }); + + it('should fetch bridge quotes successfully, with aggIds, bridgeIds and fee=0', async () => { + mockFetchFn.mockResolvedValue(mockBridgeQuotesNativeErc20V1); + const { signal } = new AbortController(); + + const result = await fetchBridgeQuotes( + { + walletAddress: '0x1f9840a85d5aF5bf1D1762F925BDADdC4201F984', + srcChainId: 1, + destChainId: 10, + srcTokenAddress: AddressZero, + destTokenAddress: AddressZero, + srcTokenAmount: '20000', + slippage: 0.5, + gasIncluded: false, + gasIncluded7702: false, + aggIds: ['socket', 'lifi'], + bridgeIds: ['bridge1', 'bridge2'], + fee: 0, + }, + signal, + BridgeClientId.EXTENSION, + 'AUTH_TOKEN', + mockFetchFn, + BRIDGE_PROD_API_BASE_URL, + FeatureId.PERPS, + '1.0.0', + ); + + 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', + Authorization: 'Bearer AUTH_TOKEN', + }, + signal, + }, + ); + + expect(result.quotes).toStrictEqual( + mockBridgeQuotesNativeErc20V1.map((quote) => ({ + ...quote, + featureId: FeatureId.PERPS, + resetApproval: undefined, + })), + ); + expect(result.validationFailures).toStrictEqual([]); + }); + }); + + describe('fetchAssetPrices', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('should fetch and combine prices for multiple currencies successfully', async () => { + mockFetchFn + .mockResolvedValueOnce({ + 'eip155:1/erc20:0x123': { USD: '1.5' }, + 'eip155:1/erc20:0x456': { USD: '2.5' }, + }) + .mockResolvedValueOnce({ + 'eip155:1/erc20:0x123': { JPY: '1.3' }, + 'eip155:1/erc20:0x456': null, + }) + .mockResolvedValueOnce({ + 'eip155:1/erc20:0x123': { EUR: '1.3' }, + 'eip155:1/erc20:0x456': { EUR: '2.2' }, + }); + + const request = { + currencies: new Set(['USD', 'JPY', 'EUR']), + baseUrl: 'https://api.example.com', + fetchFn: mockFetchFn, + clientId: 'test', + clientVersion: '1.0.0', + assetIds: new Set([ + 'eip155:1/erc20:0x123', + 'eip155:1/erc20:0x456', + ]) as Set, + }; + + const result = await fetchAssetPrices(request); + + expect(result).toStrictEqual({ + 'eip155:1/erc20:0x123': { + USD: '1.5', + JPY: '1.3', + EUR: '1.3', + }, + 'eip155:1/erc20:0x456': { + USD: '2.5', + EUR: '2.2', + }, + }); + + expect(mockFetchFn).toHaveBeenCalledTimes(3); + expect(mockFetchFn).toHaveBeenCalledWith( + 'https://price.api.cx.metamask.io/v3/spot-prices?assetIds=eip155%3A1%2Ferc20%3A0x123%2Ceip155%3A1%2Ferc20%3A0x456&vsCurrency=USD', + { + headers: { 'X-Client-Id': 'test', 'Client-Version': '1.0.0' }, + }, + ); + expect(mockFetchFn).toHaveBeenCalledWith( + 'https://price.api.cx.metamask.io/v3/spot-prices?assetIds=eip155%3A1%2Ferc20%3A0x123%2Ceip155%3A1%2Ferc20%3A0x456&vsCurrency=EUR', + { + headers: { 'X-Client-Id': 'test', 'Client-Version': '1.0.0' }, + }, + ); + }); + + it('should handle empty currencies set', async () => { + const request = { + currencies: new Set(), + baseUrl: 'https://api.example.com', + fetchFn: mockFetchFn, + clientId: 'test', + clientVersion: '1.0.0', + assetIds: new Set([ + 'eip155:1/erc20:0x123', + 'eip155:1/erc20:0x456', + ]) as Set, + }; + + const result = await fetchAssetPrices(request); + + expect(result).toStrictEqual({}); + expect(mockFetchFn).not.toHaveBeenCalled(); + }); + + it('should handle failed requests for some currencies', async () => { + mockFetchFn + .mockResolvedValueOnce({ + 'eip155:1/erc20:0x123': { USD: '1.5' }, + }) + .mockRejectedValueOnce(new Error('Failed to fetch EUR prices')); + + const request = { + currencies: new Set(['USD', 'EUR']), + baseUrl: 'https://api.example.com', + fetchFn: mockFetchFn, + clientId: 'test', + clientVersion: '1.0.0', + assetIds: new Set([ + 'eip155:1/erc20:0x123', + 'eip155:1/erc20:0x456', + ]) as Set, + }; + + const result = await fetchAssetPrices(request); + + expect(result).toStrictEqual({ + 'eip155:1/erc20:0x123': { + USD: '1.5', + }, + }); + + expect(mockFetchFn).toHaveBeenCalledTimes(2); + }); + + it('should handle all failed requests', async () => { + mockFetchFn.mockRejectedValue(new Error('Failed to fetch prices')); + + const request = { + currencies: new Set(['USD', 'EUR']), + baseUrl: 'https://api.example.com', + fetchFn: mockFetchFn, + clientId: 'test', + clientVersion: '1.0.0', + assetIds: new Set([ + 'eip155:1/erc20:0x123', + 'eip155:1/erc20:0x456', + ]) as Set, + }; + + const result = await fetchAssetPrices(request); + + expect(result).toStrictEqual({}); + expect(mockFetchFn).toHaveBeenCalledTimes(2); + }); + + it('should merge prices for same asset from different currencies', async () => { + mockFetchFn + .mockResolvedValueOnce({ + 'eip155:1/erc20:0x123': { USD: '1.5' }, + 'eip155:1/erc20:0x456': null, + }) + .mockResolvedValueOnce({ + 'eip155:1/erc20:0x123': { GBP: '1.2' }, + 'eip155:1/erc20:0x456': null, + }) + .mockResolvedValueOnce({ + 'eip155:1/erc20:0x123': { JPY: '165' }, + 'eip155:1/erc20:0x456': null, + }) + .mockResolvedValueOnce({ + 'eip155:1/erc20:0x123': { EUR: '1.3' }, + 'eip155:1/erc20:0x456': null, + }); + + const request = { + currencies: new Set(['USD', 'GBP', 'JPY', 'EUR']), + baseUrl: 'https://api.example.com', + fetchFn: mockFetchFn, + clientId: 'test', + clientVersion: '1.0.0', + assetIds: new Set([ + 'eip155:1/erc20:0x123', + 'eip155:1/erc20:0x456', + ]) as Set, + }; + + const result = await fetchAssetPrices(request); + + expect(result).toStrictEqual({ + 'eip155:1/erc20:0x123': { + USD: '1.5', + GBP: '1.2', + EUR: '1.3', + JPY: '165', + }, + }); + }); + + it('should handle mixed successful and empty responses', async () => { + mockFetchFn + .mockResolvedValueOnce({ + 'eip155:1/erc20:0x123': { USD: '1.5' }, + }) + .mockResolvedValueOnce({}); + + const request = { + currencies: new Set(['USD', 'EUR']), + baseUrl: 'https://api.example.com', + fetchFn: mockFetchFn, + clientId: 'test', + clientVersion: '1.0.0', + assetIds: new Set([ + 'eip155:1/erc20:0x123', + 'eip155:1/erc20:0x456', + ]) as Set, + }; + + const result = await fetchAssetPrices(request); + + expect(result).toStrictEqual({ + 'eip155:1/erc20:0x123': { + USD: '1.5', + }, + }); + }); + + it('should handle malformed API responses', async () => { + mockFetchFn + .mockResolvedValueOnce(null) + .mockResolvedValueOnce(undefined) + .mockResolvedValueOnce('invalid format'); + + const request = { + currencies: new Set(['USD', 'EUR', 'GBP']), + baseUrl: 'https://api.example.com', + fetchFn: mockFetchFn, + clientId: 'test', + clientVersion: '1.0.0', + assetIds: new Set([ + 'eip155:1/erc20:0x123', + 'eip155:1/erc20:0x456', + ]) as Set, + }; + + const result = await fetchAssetPrices(request); + + expect(result).toStrictEqual({}); + expect(mockFetchFn).toHaveBeenCalledTimes(3); + }); + + it('should handle empty assetIds', async () => { + const request = { + currencies: new Set(['USD', 'EUR', 'GBP']), + baseUrl: 'https://api.example.com', + fetchFn: mockFetchFn, + clientId: 'test', + clientVersion: '1.0.0', + assetIds: new Set([]) as Set, + }; + + const result = await fetchAssetPrices(request); + + expect(result).toStrictEqual({}); + expect(mockFetchFn).toHaveBeenCalledTimes(0); + }); + + it('should handle network errors with appropriate status codes', async () => { + mockFetchFn + .mockRejectedValueOnce(new Error('404 Not Found')) + .mockRejectedValueOnce(new Error('500 Internal Server Error')) + .mockRejectedValueOnce(new Error('Network Error')); + + const request = { + currencies: new Set(['USD', 'EUR', 'GBP']), + baseUrl: 'https://api.example.com', + fetchFn: mockFetchFn, + clientId: 'test', + clientVersion: '1.0.0', + assetIds: new Set([ + 'eip155:1/erc20:0x123', + 'eip155:1/erc20:0x456', + ]) as Set, + }; + + const result = await fetchAssetPrices(request); + + expect(result).toStrictEqual({}); + expect(mockFetchFn).toHaveBeenCalledTimes(3); + }); + }); + + describe('fetchBatchSellTrades', () => { + let mockConsoleWarn: jest.SpyInstance; + + const mockBatchSellTrades = { + transactions: mockBridgeQuotesErc20Erc20V1.flatMap( + ({ trade, approval }) => [ + { + ...trade, + type: BatchSellTransactionType.TRADE, + maxFeePerGas: '0x123', + maxPriorityFeePerGas: '0x456', + }, + { + ...approval, + type: BatchSellTransactionType.APPROVAL, + maxFeePerGas: '0x123', + maxPriorityFeePerGas: '0x456', + }, + ], + ), + fee: { + amount: '100', + asset: { + symbol: 'USDC', + chainId: 10, + address: '0x1f9840a85d5af5bf1d1762f925bdaddc4201f984', + name: 'USD Coin', + decimals: 6, + assetId: 'eip155:10/erc20:0x0b2c639c533813f4aa9d7837caf62653d097ff85', + } as const, + }, + }; + + const expectedRequestOptions = ( + signal: AbortSignal, + stxEnabled: boolean, + ): Record => ({ + headers: { + 'X-Client-Id': 'extension', + 'Client-Version': '1.0.0', + Authorization: 'Bearer AUTH_TOKEN', + 'Content-Type': 'application/json', + }, + signal, + method: 'POST', + body: JSON.stringify( + formatBatchSellTradesRequest( + mockBridgeQuotesErc20Erc20V1.map(toQuoteResponseV2), + stxEnabled, + ), + ), + }); + + beforeEach(() => { + jest.clearAllMocks(); + mockConsoleWarn = jest + .spyOn(console, 'warn') + .mockImplementation(jest.fn()); + }); + + afterEach(() => { + mockConsoleWarn.mockRestore(); + }); + + describe('when stxEnabled is false', () => { + it('sends stxEnabled: false in the request body and returns the response', async () => { + mockFetchFn.mockResolvedValue({ + ok: true, + json: jest.fn().mockResolvedValue(mockBatchSellTrades), + }); + const { signal } = new AbortController(); + + const result = await fetchBatchSellTrades( + getMockBridgeQuotesErc20Erc20V2(), + false, + signal, + BridgeClientId.EXTENSION, + 'AUTH_TOKEN', + mockFetchFn, + BRIDGE_PROD_API_BASE_URL, + '1.0.0', + ); + + expect(mockFetchFn).toHaveBeenCalledWith( + 'https://bridge.api.cx.metamask.io/obtainGaslessBatch', + expectedRequestOptions(signal, false), + ); + expect(result).toStrictEqual(mockBatchSellTrades); + expect(mockConsoleWarn).not.toHaveBeenCalled(); + }); + + it('throws when the server responds with a non-ok status', async () => { + mockFetchFn.mockResolvedValue({ + ok: false, + statusText: 'Fetch error', + }); + const { signal } = new AbortController(); + + await expect( + fetchBatchSellTrades( + getMockBridgeQuotesErc20Erc20V2(), + false, + signal, + BridgeClientId.EXTENSION, + 'AUTH_TOKEN', + mockFetchFn, + BRIDGE_PROD_API_BASE_URL, + '1.0.0', + ), + ).rejects.toThrow('Fetch error'); + + expect(mockFetchFn).toHaveBeenCalledWith( + 'https://bridge.api.cx.metamask.io/obtainGaslessBatch', + expectedRequestOptions(signal, false), + ); + expect(mockConsoleWarn).not.toHaveBeenCalled(); + }); + + it('throws on a malformed response', async () => { + mockFetchFn.mockResolvedValueOnce({ + ok: true, + json: jest.fn().mockResolvedValue({ + ...mockBatchSellTrades, + transactions: mockBatchSellTrades.transactions.map( + ({ maxFeePerGas, maxPriorityFeePerGas, ...rest }) => rest, + ), + }), + }); + mockFetchFn.mockResolvedValueOnce({ + ok: true, + json: jest.fn().mockResolvedValue({ + ...mockBatchSellTrades, + transactions: mockBatchSellTrades.transactions.map((trade) => ({ + ...trade, + maxFeePerGas: 1000, + maxPriorityFeePerGas: 1000, + })), + }), + }); + mockFetchFn.mockResolvedValueOnce({ + ok: true, + json: jest.fn().mockResolvedValue({ + ...mockBatchSellTrades, + transactions: mockBatchSellTrades.transactions.map((trade) => ({ + ...trade, + maxFeePerGas: '1000', + maxPriorityFeePerGas: '1000', + })), + }), + }); + mockFetchFn.mockResolvedValueOnce({ + ok: true, + json: jest.fn().mockResolvedValue({ + ...mockBatchSellTrades, + transactions: mockBatchSellTrades.transactions.map((trade) => ({ + ...trade, + maxFeePerGas: 0x123, + maxPriorityFeePerGas: 0x456, + })), + }), + }); + + const { signal } = new AbortController(); + + await expect( + fetchBatchSellTrades( + [...getMockBridgeQuotesErc20Erc20V2(), null], + false, + signal, + BridgeClientId.EXTENSION, + 'AUTH_TOKEN', + mockFetchFn, + BRIDGE_PROD_API_BASE_URL, + '1.0.0', + ), + ).rejects.toThrow('Invalid batch simulation response'); + + const result = await Promise.allSettled( + Array.from({ length: 3 }, () => + fetchBatchSellTrades( + getMockBridgeQuotesErc20Erc20V2(), + false, + signal, + BridgeClientId.EXTENSION, + 'AUTH_TOKEN', + mockFetchFn, + BRIDGE_PROD_API_BASE_URL, + '1.0.0', + ), + ), + ); + expect( + // @ts-expect-error - reason is not in type + result.map((error) => ({ ...error, reason: error.reason?.message })), + ).toMatchInlineSnapshot(` + [ + { + "reason": "Invalid batch simulation response. StructError: At path: transactions.0.maxFeePerGas -- Expected a string, but received: 1000", + "status": "rejected", + }, + { + "reason": "Invalid batch simulation response. StructError: At path: transactions.0.maxFeePerGas -- Expected a string matching \`/^0x[0-9a-f]+$/\` but received "1000"", + "status": "rejected", + }, + { + "reason": "Invalid batch simulation response. StructError: At path: transactions.0.maxFeePerGas -- Expected a string, but received: 291", + "status": "rejected", + }, + ] + `); + expect(mockConsoleWarn).not.toHaveBeenCalled(); + mockConsoleWarn.mockRestore(); + + expect(mockFetchFn).toHaveBeenCalledTimes(4); + expect(mockFetchFn).toHaveBeenCalledWith( + 'https://bridge.api.cx.metamask.io/obtainGaslessBatch', + expectedRequestOptions(signal, false), + ); + + expect( + // @ts-expect-error - reason is not in type + result.map((error) => ({ ...error, reason: error.reason?.message })), + ).toMatchInlineSnapshot(` + [ + { + "reason": "Invalid batch simulation response. StructError: At path: transactions.0.maxFeePerGas -- Expected a string, but received: 1000", + "status": "rejected", + }, + { + "reason": "Invalid batch simulation response. StructError: At path: transactions.0.maxFeePerGas -- Expected a string matching \`/^0x[0-9a-f]+$/\` but received "1000"", + "status": "rejected", + }, + { + "reason": "Invalid batch simulation response. StructError: At path: transactions.0.maxFeePerGas -- Expected a string, but received: 291", + "status": "rejected", + }, + ] + `); + expect(mockConsoleWarn).not.toHaveBeenCalled(); + }); + }); + + describe('when stxEnabled is true', () => { + it('sends stxEnabled: true in the request body', async () => { + mockFetchFn.mockResolvedValue({ + ok: true, + json: jest.fn().mockResolvedValue(mockBatchSellTrades), + }); + const { signal } = new AbortController(); + + await fetchBatchSellTrades( + getMockBridgeQuotesErc20Erc20V2(), + true, + signal, + BridgeClientId.EXTENSION, + 'AUTH_TOKEN', + mockFetchFn, + BRIDGE_PROD_API_BASE_URL, + '1.0.0', + ); + + expect(mockFetchFn).toHaveBeenCalledWith( + 'https://bridge.api.cx.metamask.io/obtainGaslessBatch', + expectedRequestOptions(signal, true), + ); + const sentBody = JSON.parse( + (mockFetchFn.mock.calls[0][1] as { body: string }).body, + ); + expect(sentBody.stxEnabled).toBe(true); + }); + }); + }); +}); diff --git a/packages/bridge-controller/src/utils/fetch.ts b/packages/bridge-controller/src/utils/fetch.ts new file mode 100644 index 00000000000..bde74d3d691 --- /dev/null +++ b/packages/bridge-controller/src/utils/fetch.ts @@ -0,0 +1,576 @@ +/* eslint-disable @typescript-eslint/explicit-function-return-type */ +import { StructError } from '@metamask/superstruct'; +import { KnownCaipNamespace } from '@metamask/utils'; +import type { CaipAssetType, CaipChainId, Hex } from '@metamask/utils'; + +import { toQuoteResponseV2 } from '../coercers/quote-response-v1-to-v2.js'; +import { toQuoteResponseV1 } from '../coercers/quote-response-v2-to-v1.js'; +import type { + FetchFunction, + GenericQuoteRequest, + QuoteRequest, + TokenFeature, + QuoteStreamCompleteData, + BatchSellTradesRequest, + BatchSellTradesResponse, +} from '../types.js'; +import { validateBatchSellTradesResponse } from '../validators/batch-sell.js'; +import type { BridgeAsset } from '../validators/bridge-asset.js'; +import { validateBridgeAsset } from '../validators/bridge-asset.js'; +import type { FeatureId } from '../validators/feature-flags.js'; +import type { QuoteResponseV1 } from '../validators/quote-response-v1.js'; +import { validateQuoteResponseV1 } from '../validators/quote-response-v1.js'; +import type { QuoteResponse } from '../validators/quote-response.js'; +import { validateQuoteStreamComplete } from '../validators/quote-stream-complete.js'; +import { validateTokenFeature } from '../validators/token-feature.js'; +import { isEvmTxData } from '../validators/trade.js'; +import type { TxData } from '../validators/trade.js'; +import { getEthUsdtResetData } from './bridge.js'; +import { + formatAddressToAssetId, + formatAddressToCaipReference, + formatChainIdToDec, +} from './caip-formatters.js'; +import { fetchServerEvents } from './fetch-server-events.js'; +import type { QuoteMetadata } from './quote-metadata/types.js'; +import { formatStructErrors } from './struct-error.js'; + +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) + * @returns A list of enabled (unblocked) tokens + */ +export async function fetchBridgeTokens( + chainId: Hex | CaipChainId, + clientId: string, + jwt: string | undefined, + fetchFn: FetchFunction, + bridgeApiBaseUrl: string, + clientVersion?: string, +): Promise> { + 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, jwt }), + }); + + const transformedTokens: Record = {}; + tokens.forEach((token: unknown) => { + if (validateBridgeAsset(token)) { + transformedTokens[token.address] = token; + } + }); + return transformedTokens; +} + +/** + * Converts the generic quote request to QuoteRequest + * + * @param request - The quote request + * @returns A QuoteRequest object + */ +const formatQuoteRequest = (request: GenericQuoteRequest): QuoteRequest => { + const destWalletAddress = request.destWalletAddress ?? request.walletAddress; + // Transform the generic quote request into QuoteRequest + const normalizedRequest: QuoteRequest = { + walletAddress: formatAddressToCaipReference(request.walletAddress), + destWalletAddress: formatAddressToCaipReference(destWalletAddress), + srcChainId: formatChainIdToDec(request.srcChainId), + destChainId: formatChainIdToDec(request.destChainId), + srcTokenAddress: formatAddressToCaipReference(request.srcTokenAddress), + destTokenAddress: formatAddressToCaipReference(request.destTokenAddress), + srcTokenAmount: request.srcTokenAmount, + insufficientBal: Boolean(request.insufficientBal), + resetApproval: Boolean(request.resetApproval), + gasIncluded: Boolean(request.gasIncluded), + gasIncluded7702: Boolean(request.gasIncluded7702), + }; + if (request.slippage !== undefined) { + normalizedRequest.slippage = request.slippage; + } + if (request.fee !== undefined) { + normalizedRequest.fee = request.fee; + } + if (request.aggIds && request.aggIds.length > 0) { + normalizedRequest.aggIds = request.aggIds; + } + if (request.bridgeIds && request.bridgeIds.length > 0) { + normalizedRequest.bridgeIds = request.bridgeIds; + } + + return normalizedRequest; +}; + +/** + * Converts the generic quote request to the type that the bridge-api expects + * + * @param normalizedRequest - The normalized quote request + * @returns A URLSearchParams object with the query parameters + */ +const formatQueryParams = ( + normalizedRequest: QuoteRequest, +): URLSearchParams => { + const queryParams = new URLSearchParams(); + Object.entries(normalizedRequest).forEach(([key, value]) => { + queryParams.append(key, value.toString()); + }); + return queryParams; +}; + +/** + * Fetches quotes from the bridge-api's getQuote endpoint + * + * @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 + * @param clientVersion - The client version for metrics (optional) + * @returns A list of bridge tx quotes + */ +export async function fetchBridgeQuotes( + request: GenericQuoteRequest, + signal: AbortSignal | null, + clientId: string, + jwt: string | undefined, + fetchFn: FetchFunction, + bridgeApiBaseUrl: string, + featureId: FeatureId | null, + clientVersion?: string, +): Promise<{ + quotes: QuoteResponseV1[]; + validationFailures: string[]; +}> { + const normalizedRequest = formatQuoteRequest(request); + const queryParams = formatQueryParams(normalizedRequest); + + const url = `${bridgeApiBaseUrl}/getQuote?${queryParams}`; + const quotes: unknown[] = await fetchFn(url, { + headers: getClientHeaders({ clientId, clientVersion, jwt }), + signal, + }); + + const uniqueValidationFailures: Set = new Set([]); + const filteredQuotes = quotes + .filter((quoteResponse: unknown): quoteResponse is QuoteResponseV1 => { + try { + return validateQuoteResponseV1(quoteResponse); + } catch (error) { + if (error instanceof StructError) { + error.failures().forEach(({ branch, path }) => { + const aggregatorId = + branch?.[0]?.quote?.bridgeId ?? + branch?.[0]?.quote?.bridges?.[0] ?? + (quoteResponse as QuoteResponseV1)?.quote?.bridgeId ?? + (quoteResponse as QuoteResponseV1)?.quote?.bridges?.[0] ?? + 'unknown'; + const pathString = path?.join('.') || 'unknown'; + uniqueValidationFailures.add([aggregatorId, pathString].join('|')); + }); + } + return false; + } + }) + .map((quote) => ({ + ...quote, + featureId: featureId ?? undefined, + // Append the reset approval data to the quote response if the request + // has resetApproval set to true and the quote has an approval + resetApproval: + request.resetApproval && quote.approval && isEvmTxData(quote.approval) + ? { + ...quote.approval, + data: getEthUsdtResetData(request.destChainId), + } + : undefined, + })); + + const validationFailures = Array.from(uniqueValidationFailures); + if (uniqueValidationFailures.size > 0) { + console.warn('Quote validation failed', validationFailures); + } + + return { + quotes: filteredQuotes, + validationFailures, + }; +} + +const fetchAssetPricesForCurrency = async (request: { + currency: string; + assetIds: Set; + clientId: string; + clientVersion?: string; + fetchFn: FetchFunction; + signal?: AbortSignal; +}): Promise> => { + const { currency, assetIds, clientId, clientVersion, fetchFn, signal } = + request; + const validAssetIds = Array.from(assetIds).filter(Boolean); + if (validAssetIds.length === 0) { + return {}; + } + + const queryParams = new URLSearchParams({ + assetIds: validAssetIds.filter(Boolean).join(','), + vsCurrency: currency, + }); + const url = `https://price.api.cx.metamask.io/v3/spot-prices?${queryParams}`; + const priceApiResponse = (await fetchFn(url, { + headers: getClientHeaders({ clientId, clientVersion }), + signal, + })) as unknown as Record; + if (!priceApiResponse || typeof priceApiResponse !== 'object') { + return {}; + } + + return Object.entries(priceApiResponse).reduce< + Record + >((acc, [assetId, currencyToPrice]) => { + if (!currencyToPrice) { + return acc; + } + if (!acc[assetId as CaipAssetType]) { + acc[assetId as CaipAssetType] = {}; + } + if (currencyToPrice[currency]) { + acc[assetId as CaipAssetType][currency] = + currencyToPrice[currency].toString(); + } + return acc; + }, {}); +}; + +/** + * Fetches the asset prices from the price API for multiple currencies + * + * @param request - The request object + * @returns The asset prices by assetId + */ +export const fetchAssetPrices = async ( + request: { + currencies: Set; + } & Omit[0], 'currency'>, +): Promise< + Record +> => { + const { currencies, ...args } = request; + + const combinedPrices = await Promise.allSettled( + Array.from(currencies).map( + async (currency) => + await fetchAssetPricesForCurrency({ ...args, currency }), + ), + ).then((priceApiResponse) => { + return priceApiResponse.reduce< + Record + >((acc, result) => { + if (result.status === 'fulfilled') { + Object.entries(result.value).forEach(([assetId, currencyToPrice]) => { + const existingPrices = acc[assetId as CaipAssetType]; + if (!existingPrices) { + acc[assetId as CaipAssetType] = {}; + } + Object.entries(currencyToPrice).forEach(([currency, price]) => { + acc[assetId as CaipAssetType][currency] = price; + }); + }); + } + return acc; + }, {}); + }); + + return combinedPrices; +}; + +const getQuoteRequestId = ({ + srcChainId, + destChainId, + srcTokenAddress, + destTokenAddress, +}: QuoteRequest): string => + `${formatAddressToAssetId(srcTokenAddress, srcChainId)}-${formatAddressToAssetId(destTokenAddress, destChainId)}`.toLowerCase(); + +const getQuoteResponseId = ({ + src: { asset: srcAsset }, + dest: { asset: destAsset }, +}: QuoteResponse['quote']): string => + `${srcAsset.assetId}-${destAsset.assetId}`.toLowerCase(); + +/** + * Fetches quotes from the bridge-api + * + * @param fetchFn - The fetch function to use + * @param quoteRequests - An array of GenericQuoteRequest objects + * @param signal - The abort signal + * @param featureId - The {@link FeatureId} for the experience that's requesting the quotes + * @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.onQuoteValidationFailure - The function to handle quote validation failures + * @param serverEventHandlers.onValidQuoteReceived - The function to handle valid quotes + * @param serverEventHandlers.onTokenWarning - The function to handle token warning events + * @param serverEventHandlers.onComplete - The function to handle the complete event emitted when the stream finishes + * @param serverEventHandlers.onClose - The function to run when the stream is closed and there are no thrown errors + * @param clientVersion - The client version for metrics (optional) + * @returns A list of bridge tx quote promises + */ +export async function fetchBridgeQuoteStream( + fetchFn: FetchFunction, + quoteRequests: GenericQuoteRequest[], + signal: AbortSignal | undefined, + featureId: FeatureId, + clientId: string, + jwt: string | undefined, + bridgeApiBaseUrl: string, + serverEventHandlers: { + onClose: () => void | Promise; + onQuoteValidationFailure: (validationFailures: string[]) => void; + onValidQuoteReceived: ( + quotes: QuoteResponse & { resetApproval?: TxData }, + ) => Promise; + onTokenWarning: (warning: TokenFeature) => void; + onComplete: (data: QuoteStreamCompleteData) => void; + }, + clientVersion?: string, +): Promise { + /** + * If the request includes multiple quote requests, it is a batch sell request. + * A batch sell consists of multiple swaps that are executed in a single tx submission. + */ + const isBatchSellRequest = quoteRequests.length > 1; + const normalizedQuoteRequests = quoteRequests.map(formatQuoteRequest); + const quoteRequestIds = isBatchSellRequest + ? normalizedQuoteRequests.map(getQuoteRequestId) + : undefined; + + const onQuoteReceived = async (quoteResponse: unknown): Promise => { + const uniqueValidationFailures: Set = new Set([]); + + try { + // Always coerce to QuoteResponseV2 + const quoteResponseV2 = toQuoteResponseV2(quoteResponse); + // Fallback to 0 if the quote doesn't match any requests + const matchedQuoteRequestIdx = Math.max( + quoteRequestIds?.findIndex((id) => { + return id === getQuoteResponseId(quoteResponseV2.quote); + }) ?? 0, + 0, + ); + const matchingQuoteRequest = + normalizedQuoteRequests[matchedQuoteRequestIdx]; + + return await serverEventHandlers.onValidQuoteReceived({ + ...quoteResponseV2, + featureId, + // Append the reset approval data to the quote response if the request has resetApproval set to true and the quote has an approval + resetApproval: + quoteResponseV2.namespace === KnownCaipNamespace.Eip155 && + matchingQuoteRequest.resetApproval && + quoteResponseV2.approval + ? { + ...quoteResponseV2.approval, + data: getEthUsdtResetData(matchingQuoteRequest.destChainId), + } + : undefined, + ...(isBatchSellRequest && { + quoteRequestIndex: matchedQuoteRequestIdx, + }), + }); + } catch (error) { + if (error instanceof StructError) { + console.warn('Quote validation failed', formatStructErrors(error)); + error.failures().forEach(({ branch, path }) => { + const aggregatorId = + branch?.[0]?.quote?.aggregator ?? + branch?.[0]?.quote?.protocols?.[0] ?? + (quoteResponse as QuoteResponseV1)?.quote?.protocols?.[0] ?? + (quoteResponse as QuoteResponseV1)?.quote?.bridgeId ?? + (quoteResponse as QuoteResponseV1)?.quote?.bridges?.[0] ?? + (quoteResponse as QuoteResponse)?.quote?.aggregator ?? + 'unknown'; + const pathString = path?.join('.') || 'unknown'; + uniqueValidationFailures.add([aggregatorId, pathString].join('|')); + }); + } + const validationFailures = Array.from(uniqueValidationFailures); + if (uniqueValidationFailures.size > 0) { + return serverEventHandlers.onQuoteValidationFailure(validationFailures); + } + // Rethrow any unexpected errors + throw error; + } + }; + + const onTokenWarningReceived = (data: unknown): void => { + try { + if (validateTokenFeature(data)) { + serverEventHandlers.onTokenWarning(data); + } + } catch (error) { + console.warn('Token warning validation failed', error); + } + }; + + const onCompleteReceived = (data: unknown): void => { + try { + if (validateQuoteStreamComplete(data)) { + serverEventHandlers.onComplete(data); + } + } catch (error) { + console.warn('Quote stream complete validation failed', error); + } + }; + + const onMessage = async ( + data: Record, + eventName?: string, + ): Promise => { + switch (eventName) { + case 'quote': + return await onQuoteReceived(data); + case 'token_warning': + return onTokenWarningReceived(data); + case 'complete': + return onCompleteReceived(data); + default: + return undefined; + } + }; + + const sharedFetchOptions = { + signal, + onMessage, + onError: (error: unknown) => { + // Rethrow error to prevent silent fetch failures + throw error; + }, + onClose: async () => { + await serverEventHandlers.onClose(); + }, + fetchFn, + }; + + if (isBatchSellRequest) { + const urlStream = `${bridgeApiBaseUrl}/getBatchQuoteStream`; + await fetchServerEvents(urlStream, { + method: 'POST', + body: JSON.stringify({ requests: normalizedQuoteRequests }), + headers: { + ...getClientHeaders({ clientId, clientVersion, jwt }), + 'Content-Type': 'application/json', + }, + ...sharedFetchOptions, + }); + return; + } + + const queryParams = formatQueryParams(normalizedQuoteRequests[0]); + const urlStream = `${bridgeApiBaseUrl}/getQuoteStream?${queryParams}`; + await fetchServerEvents(urlStream, { + headers: { + ...getClientHeaders({ clientId, clientVersion, jwt }), + 'Content-Type': 'text/event-stream', + }, + ...sharedFetchOptions, + }); +} + +export const formatBatchSellTradesRequest = ( + quotes: (QuoteResponse | (QuoteResponseV1 & QuoteMetadata) | null)[], + stxEnabled: boolean, +): BatchSellTradesRequest => ({ + quotes: quotes + .filter( + (quote): quote is QuoteResponse | (QuoteResponseV1 & QuoteMetadata) => + quote !== null && Boolean(quote), + ) + .map(toQuoteResponseV1), + stxEnabled, +}); + +/** + * Fetches quotes from the bridge-api's getQuote endpoint + * + * @param quotes - The quotes to fetch the gasless transaction data and fees for. May contain null values if a quote is not available for a swap + * @param stxEnabled - Flag to estimate gas cost more precisely for the batch sell feature. + * @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 clientVersion - The client version for metrics (optional) + * @returns The batch sell trades and the total network fee + */ +export async function fetchBatchSellTrades( + quotes: (QuoteResponse | null)[], + stxEnabled: boolean, + signal: AbortSignal | null, + clientId: string, + jwt: string | undefined, + fetchFn: FetchFunction, + bridgeApiBaseUrl: string, + clientVersion?: string, +): Promise { + const url = `${bridgeApiBaseUrl}/obtainGaslessBatch`; + const request: BatchSellTradesRequest = formatBatchSellTradesRequest( + quotes, + stxEnabled, + ); + const batchSellTradesResponse = await fetchFn(url, { + headers: { + ...getClientHeaders({ + clientId, + clientVersion, + jwt, + }), + 'Content-Type': 'application/json', + }, + signal, + method: 'POST', + body: JSON.stringify(request), + }); + + if (!batchSellTradesResponse.ok) { + throw new Error( + `Failed to fetch batch sell trades. ${batchSellTradesResponse.statusText}`, + ); + } + + try { + const data = await batchSellTradesResponse.json(); + validateBatchSellTradesResponse(data); + return data; + } catch (error: unknown) { + // TODO validation failure event + throw new Error(`Invalid batch simulation response. ${error?.toString()}`); + } +} diff --git a/packages/bridge-controller/src/utils/metrics/constants.ts b/packages/bridge-controller/src/utils/metrics/constants.ts new file mode 100644 index 00000000000..791da068b0d --- /dev/null +++ b/packages/bridge-controller/src/utils/metrics/constants.ts @@ -0,0 +1,107 @@ +/* eslint-disable @typescript-eslint/naming-convention */ +export const UNIFIED_SWAP_BRIDGE_EVENT_CATEGORY = 'Unified SwapBridge'; +export const BATCH_SELL_EVENT_CATEGORY = 'Batch Sell'; + +/** + * These event names map to events defined in the segment-schema: https://github.com/Consensys/segment-schema/tree/main/libraries/events/metamask-cross-chain-swaps + */ +export enum UnifiedSwapBridgeEventName { + ButtonClicked = `${UNIFIED_SWAP_BRIDGE_EVENT_CATEGORY} Button Clicked`, + PageViewed = `${UNIFIED_SWAP_BRIDGE_EVENT_CATEGORY} Page Viewed`, + InputChanged = `${UNIFIED_SWAP_BRIDGE_EVENT_CATEGORY} Input Changed`, + FiatCryptoToggleClicked = `${UNIFIED_SWAP_BRIDGE_EVENT_CATEGORY} Fiat Crypto Toggle Clicked`, + InputSourceDestinationSwitched = `${UNIFIED_SWAP_BRIDGE_EVENT_CATEGORY} Source Destination Switched`, + QuotesRequested = `${UNIFIED_SWAP_BRIDGE_EVENT_CATEGORY} Quotes Requested`, + QuotesReceived = `${UNIFIED_SWAP_BRIDGE_EVENT_CATEGORY} Quotes Received`, + QuotesError = `${UNIFIED_SWAP_BRIDGE_EVENT_CATEGORY} Quotes Error`, + Submitted = `${UNIFIED_SWAP_BRIDGE_EVENT_CATEGORY} Submitted`, + Completed = `${UNIFIED_SWAP_BRIDGE_EVENT_CATEGORY} Completed`, + Failed = `${UNIFIED_SWAP_BRIDGE_EVENT_CATEGORY} Failed`, + AllQuotesOpened = `${UNIFIED_SWAP_BRIDGE_EVENT_CATEGORY} All Quotes Opened`, + AllQuotesSorted = `${UNIFIED_SWAP_BRIDGE_EVENT_CATEGORY} All Quotes Sorted`, + QuoteSelected = `${UNIFIED_SWAP_BRIDGE_EVENT_CATEGORY} Quote Selected`, + 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`, +} + +export enum BatchSellMetricsEventName { + BatchSellTokenPageViewed = `${BATCH_SELL_EVENT_CATEGORY} Token Page Viewed`, + BatchSellTokenPageContinueClicked = `${BATCH_SELL_EVENT_CATEGORY} Token Page Continue Clicked`, + BatchSellQuotePageViewed = `${BATCH_SELL_EVENT_CATEGORY} Quote Page Viewed`, + BatchSellQuotePageReviewClicked = `${BATCH_SELL_EVENT_CATEGORY} Quote Page Review Clicked`, + BatchSellReviewModalSubmitted = `${BATCH_SELL_EVENT_CATEGORY} Review Modal Submitted`, +} + +export type BridgeControllerMetricsEventName = + | UnifiedSwapBridgeEventName + | BatchSellMetricsEventName; + +export enum PollingStatus { + MaxPollingReached = 'max_polling_reached', + InvalidTransactionHash = 'invalid_transaction_hash', + ManuallyRestarted = 'manually_restarted', +} + +export enum AbortReason { + NewQuoteRequest = 'New Quote Request', + QuoteRequestUpdated = 'Quote Request Updated', + ResetState = 'Reset controller state', + TransactionSubmitted = 'Transaction submitted', + GaslessTxBatchFetched = 'Gasless transaction batch fetched', +} + +/** + * 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', + FollowTradingTokenScreen = 'Follow Trading Token Screen', + FollowTradingFeedScreen = 'Follow Trading Feed Screen', + ActivityTabEmptyState = 'Activity Tab Empty State', + TransactionShield = 'Transaction Shield', + TransactionDetails = 'Transaction Details', + DeepLink = 'Deep Link', + Unknown = 'Unknown', + BottomNavBar = 'Bottom Nav Bar', +} + +export enum BatchSellMetricsLocation { + TradeMenu = 'trade_menu', + Deeplink = 'deeplink', + AssetPicker = 'asset_picker', + Unknown = 'Unknown', +} + +export type BridgeControllerMetricsLocation = + | MetaMetricsSwapsEventSource + | BatchSellMetricsLocation; + +export enum InputAmountPreset { + PERCENT_25 = '25%', + PERCENT_50 = '50%', + PERCENT_75 = '75%', + PERCENT_90 = '90%', + // "Max" may not equal 100% of balance (e.g. gas reserves are withheld) + MAX = 'MAX', +} + +export enum MetricsActionType { + /** + * @deprecated new events should use SWAPBRIDGE_V1 instead + */ + CROSSCHAIN_V1 = 'crosschain-v1', + SWAPBRIDGE_V1 = 'swapbridge-v1', +} + +export enum MetricsSwapType { + SINGLE = 'single_chain', + CROSSCHAIN = 'crosschain', +} diff --git a/packages/bridge-controller/src/utils/metrics/properties.test.ts b/packages/bridge-controller/src/utils/metrics/properties.test.ts new file mode 100644 index 00000000000..3050b4a3279 --- /dev/null +++ b/packages/bridge-controller/src/utils/metrics/properties.test.ts @@ -0,0 +1,583 @@ +import { SolScope } from '@metamask/keyring-api'; +import type { CaipChainId } from '@metamask/utils'; + +import { toQuoteResponseV2 } from '../../coercers/quote-response-v1-to-v2.js'; +import type { QuoteResponseV1 } from '../../validators/quote-response-v1.js'; +import { validateQuoteResponseV1 } from '../../validators/quote-response-v1.js'; +import { getNativeAssetForChainId } from '../bridge.js'; +import { formatChainIdToCaip } from '../caip-formatters.js'; +import type { QuoteMetadata } from '../quote-metadata/types.js'; +import { MetricsSwapType } from './constants.js'; +import { + getAccountHardwareType, + isHardwareWallet, + toInputChangedPropertyKey, + toInputChangedPropertyValue, + getSwapTypeFromQuote, + formatProviderLabel, + getRequestParams, + getQuotesReceivedProperties, +} from './properties.js'; + +describe('properties', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe('quoteRequestToInputChangedProperties', () => { + it('should map quote request properties to input keys', () => { + expect(toInputChangedPropertyKey.srcTokenAddress).toBe('token_source'); + expect(toInputChangedPropertyKey.destTokenAddress).toBe( + 'token_destination', + ); + expect(toInputChangedPropertyKey.srcChainId).toBe('chain_source'); + expect(toInputChangedPropertyKey.destChainId).toBe('chain_destination'); + expect(toInputChangedPropertyKey.slippage).toBe('slippage'); + }); + }); + + describe('quoteRequestToInputChangedPropertyValues', () => { + it('should format srcTokenAddress correctly', () => { + const srcTokenAddressFormatter = + toInputChangedPropertyValue.srcTokenAddress; + const result = srcTokenAddressFormatter?.({ + srcTokenAddress: '0x123', + srcChainId: '1', + }); + + expect(result).toBe('eip155:1/erc20:0x123'); + }); + + it('should format srcTokenAddress when srcAssetId is undefined', () => { + const srcTokenAddressFormatter = + toInputChangedPropertyValue.srcTokenAddress; + const result = srcTokenAddressFormatter?.({ + srcTokenAddress: '123', + srcChainId: '2', + }); + + expect(result).toBeUndefined(); + }); + + it('should format srcTokenAddress when srcTokenAddress is undefined', () => { + const srcTokenAddressFormatter = + toInputChangedPropertyValue.srcTokenAddress; + const result = srcTokenAddressFormatter?.({ + srcChainId: '1', + }); + + expect(result).toBe('eip155:1/slip44:60'); + }); + + it('should return undefined for srcTokenAddress when srcChainId is missing', () => { + const srcTokenAddressFormatter = + toInputChangedPropertyValue.srcTokenAddress; + const result = srcTokenAddressFormatter?.({ + srcTokenAddress: '0x123', + }); + + expect(result).toBeUndefined(); + }); + + it('should format destTokenAddress correctly', () => { + const destTokenAddressFormatter = + toInputChangedPropertyValue.destTokenAddress; + const result = destTokenAddressFormatter?.({ + destTokenAddress: '0x123', + destChainId: '1', + }); + + expect(result).toBe('eip155:1/erc20:0x123'); + }); + + it('should format destTokenAddress correctly when destTokenAddress is undefined', () => { + const destTokenAddressFormatter = + toInputChangedPropertyValue.destTokenAddress; + const result = destTokenAddressFormatter?.({ + destChainId: '1', + }); + + expect(result).toBe('eip155:1/slip44:60'); + }); + + it('should format srcChainId correctly', () => { + const srcChainIdFormatter = toInputChangedPropertyValue.srcChainId; + const result = srcChainIdFormatter?.({ + srcChainId: '1', + }); + + expect(result).toBe('eip155:1'); + }); + + it('should format srcChainId correctly when srcChainId is undefined', () => { + const srcChainIdFormatter = toInputChangedPropertyValue.srcChainId; + const result = srcChainIdFormatter?.({}); + + expect(result).toBeUndefined(); + }); + + it('should format destChainId correctly', () => { + const destChainIdFormatter = toInputChangedPropertyValue.destChainId; + const result = destChainIdFormatter?.({ + destChainId: '1', + }); + + expect(result).toBe('eip155:1'); + }); + + it('should format slippage correctly', () => { + const slippageFormatter = toInputChangedPropertyValue.slippage; + const result = slippageFormatter?.({ + slippage: 0.5, + }); + + expect(result).toBe(0.5); + }); + + it('should format slippage correctly when slippage is undefined', () => { + const slippageFormatter = toInputChangedPropertyValue.slippage; + const result = slippageFormatter?.({}); + + expect(result).toBeUndefined(); + }); + }); + + describe('getSwapType', () => { + it('should return SINGLE when srcChainId equals destChainId', () => { + const result = getSwapTypeFromQuote({ + srcChainId: 1, + destChainId: 1, + }); + + expect(result).toBe(MetricsSwapType.SINGLE); + }); + + it('should return SINGLE when destChainId is undefined', () => { + const result = getSwapTypeFromQuote({ + srcChainId: 1, + }); + + expect(result).toBe(MetricsSwapType.SINGLE); + }); + + it('should return CROSSCHAIN when srcChainId does not equal destChainId', () => { + const result = getSwapTypeFromQuote({ + srcChainId: 1, + destChainId: 10, + }); + + expect(result).toBe(MetricsSwapType.CROSSCHAIN); + }); + }); + + describe('formatProviderLabel', () => { + it('should format provider label correctly', () => { + const mockQuoteResponse = { + quote: { + bridgeId: 'bridge1', + bridges: ['bridge1'], + steps: [], + }, + trade: { + chainId: 1, + to: '0x789', + from: '0xabc', + value: '0x0', + data: '0x', + gasLimit: 100000, + }, + }; + + const result = formatProviderLabel(mockQuoteResponse.quote); + + expect(result).toBe('bridge1_bridge1'); + }); + + it('should format provider label correctly (V2)', () => { + const mockQuoteResponse = { + quote: { + aggregator: 'bridge1', + protocols: ['bridge1'], + }, + }; + + const result = formatProviderLabel(mockQuoteResponse.quote); + + expect(result).toBe('bridge1_bridge1'); + }); + }); + + describe('getAccountHardwareType', () => { + it('returns null for non-hardware accounts', () => { + expect( + getAccountHardwareType({ + metadata: { + keyring: { + type: 'HD Key Tree', + }, + }, + } as never), + ).toBeNull(); + expect(isHardwareWallet(undefined)).toBe(false); + }); + + it.each([ + ['Ledger Hardware', 'Ledger'], + ['Trezor Hardware', 'Trezor'], + ['QR Hardware Wallet Device', 'QR Hardware'], + ['Lattice Hardware', 'Lattice'], + ] as const)('maps %s to %s', (keyringType, expected) => { + const account = { + metadata: { + keyring: { + type: keyringType, + }, + }, + } as never; + + expect(getAccountHardwareType(account)).toBe(expected); + expect(isHardwareWallet(account)).toBe(true); + }); + }); + + describe('getRequestParams', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('should format request params correctly with all values provided', () => { + const result = getRequestParams( + { + srcChainId: 1, + destChainId: SolScope.Mainnet, + srcTokenAddress: '0x123', + destTokenAddress: 'ABD456', + }, + 'Malicious', + ); + + expect(result).toStrictEqual({ + chain_id_destination: SolScope.Mainnet, + chain_id_source: 'eip155:1', + token_address_destination: + 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/token:ABD456', + token_address_source: 'eip155:1/erc20:0x123', + token_security_type_destination: 'Malicious', + }); + }); + + it('should fallback to src chainId when destChainId is undefined', () => { + const result = getRequestParams( + { + srcChainId: formatChainIdToCaip(1), + srcTokenAddress: getNativeAssetForChainId('0x1')?.address, + destTokenAddress: getNativeAssetForChainId('0xa')?.address, + }, + null, + ); + + expect(result).toStrictEqual({ + chain_id_source: 'eip155:1', + chain_id_destination: null, + token_address_source: 'eip155:1/slip44:60', + token_address_destination: 'eip155:1/slip44:60', + token_security_type_destination: null, + }); + }); + + it('should use native asset when srcTokenAddress is not provided', () => { + const result = getRequestParams( + { + srcChainId: 'eip155:1' as CaipChainId, + destChainId: '2', + srcTokenAddress: undefined, + destTokenAddress: '0x456', + }, + null, + ); + + expect(result).toStrictEqual({ + chain_id_destination: 'eip155:2', + chain_id_source: 'eip155:1', + token_address_destination: 'eip155:2/erc20:0x456', + token_address_source: 'eip155:1/slip44:60', + token_security_type_destination: null, + }); + }); + + it('should use native asset when formatAddressToAssetId returns null', () => { + const result = getRequestParams( + { + srcChainId: 'eip155:1' as CaipChainId, + destChainId: '2', + srcTokenAddress: '123', + destTokenAddress: '456', + }, + null, + ); + + expect(result).toStrictEqual({ + chain_id_source: 'eip155:1', + chain_id_destination: 'eip155:2', + token_address_destination: null, + token_address_source: 'eip155:1/slip44:60', + token_security_type_destination: null, + }); + }); + + it('passes through the supplied tokenSecurityTypeDestination value', () => { + const result = getRequestParams( + { + srcChainId: 1, + destChainId: 1, + srcTokenAddress: '0x123', + destTokenAddress: '0x456', + }, + 'Warning', + ); + + expect(result.token_security_type_destination).toBe('Warning'); + }); + }); + + describe('getQuotesReceivedProperties', () => { + const mockTokenAmount = { amount: '0', valueInCurrency: '0', usd: '0' }; + const mockQuoteMetadata: QuoteMetadata = { + gasFee: { + total: mockTokenAmount, + }, + totalNetworkFee: mockTokenAmount, + toTokenAmount: mockTokenAmount, + minToTokenAmount: mockTokenAmount, + adjustedReturn: { valueInCurrency: '0', usd: '0' }, + sentAmount: mockTokenAmount, + swapRate: '0', + cost: { valueInCurrency: '0', usd: '0' }, + }; + + it('should return quotes received properties correctly', () => { + const mockQuoteResponse: QuoteResponseV1 & QuoteMetadata = { + ...mockQuoteMetadata, + quote: { + requestId: 'request1', + srcChainId: 1, + srcAsset: { + chainId: 1, + address: '0x123', + symbol: 'ETH', + name: 'Ethereum', + decimals: 18, + assetId: 'eip155:1/slip44:60', + }, + srcTokenAmount: '1000000000000000000', + destChainId: 1, + destAsset: { + chainId: 1, + address: '0x456', + symbol: 'USDC', + name: 'USD Coin', + decimals: 6, + assetId: 'eip155:1/erc20:0x456', + }, + destTokenAmount: '1000000', + minDestTokenAmount: '950000', + feeData: { + metabridge: { + amount: '10000000000000000', + asset: { + chainId: 1, + address: '0x123', + symbol: 'ETH', + name: 'Ethereum', + decimals: 18, + assetId: 'eip155:1/slip44:60', + }, + }, + }, + bridgeId: 'bridge1', + bridges: ['bridge1'], + steps: [], + }, + trade: { + chainId: 1, + to: '0x141d32a89a1e0a5ef360034a2f60a4b917c18838', + from: '0x141d32a89a1e0a5ef360034a2f60a4b917c18838', + value: '0x0', + data: '0x0', + gasLimit: 100000, + effectiveGas: 100000, + }, + estimatedProcessingTimeInSeconds: 60, + }; + validateQuoteResponseV1(mockQuoteResponse); + const mockQuoteResponseV2 = toQuoteResponseV2(mockQuoteResponse); + + const result = getQuotesReceivedProperties( + mockQuoteResponseV2, + [], + false, + { + ...mockQuoteResponseV2, + quote: { + ...mockQuoteResponseV2.quote, + aggregator: 'bridge2', + protocols: ['bridge2'], + }, + }, + ); + + expect(result).toMatchInlineSnapshot(` + { + "best_quote_provider": "bridge2_bridge2", + "can_submit": false, + "feature_id": "unified_swap_bridge", + "gas_included": false, + "gas_included_7702": false, + "price_impact": 0, + "provider": "bridge1_bridge1", + "quoted_time_minutes": 1, + "slippage_limit": 0, + "token_symbol_destination": "USDC", + "token_symbol_source": "ETH", + "usd_amount_source": 0, + "usd_balance_source": 0, + "usd_quoted_gas": 0, + "usd_quoted_return": 0, + "warnings": [], + } + `); + + const quoteWithSlippage = { + ...mockQuoteResponseV2, + quote: { + ...mockQuoteResponseV2.quote, + slippage: 0.5, + }, + }; + + expect( + getQuotesReceivedProperties( + quoteWithSlippage, + [], + true, + undefined, + undefined, + undefined, + { slippage_limit: 3.5 }, + ).slippage_limit, + ).toBe(3.5); + expect( + getQuotesReceivedProperties( + quoteWithSlippage, + [], + true, + undefined, + undefined, + undefined, + { custom_slippage: true, slippage_limit: undefined }, + ).slippage_limit, + ).toBe(0.5); + expect( + getQuotesReceivedProperties(quoteWithSlippage).slippage_limit, + ).toBe(0.5); + }); + + it('should return empty source and null destination token symbols when activeQuote is null', () => { + const result = getQuotesReceivedProperties(null); + + expect(result.token_symbol_source).toBe(''); + expect(result.token_symbol_destination).toBeNull(); + expect(result.slippage_limit).toBe(0); + }); + + it('should use client fallbacks and explicit slippage context', () => { + const result = getQuotesReceivedProperties( + null, + [], + true, + undefined, + undefined, + undefined, + { + custom_slippage: true, + slippage_limit: 3.5, + usd_amount_source: 100, + token_symbol_source: 'ETH', + token_symbol_destination: 'USDC', + }, + ); + + expect(result).toStrictEqual( + expect.objectContaining({ + custom_slippage: true, + slippage_limit: 3.5, + token_symbol_source: 'ETH', + token_symbol_destination: 'USDC', + usd_amount_source: 100, + }), + ); + }); + + it('should derive token symbols from the active quote asset metadata', () => { + const mockQuoteResponse: QuoteResponseV1 & QuoteMetadata = { + ...mockQuoteMetadata, + quote: { + requestId: 'request1', + srcChainId: 1, + srcAsset: { + chainId: 1, + address: '0x123', + symbol: 'WETH', + name: 'Wrapped Ether', + decimals: 18, + assetId: 'eip155:1/erc20:0x123', + }, + srcTokenAmount: '1000000000000000000', + destChainId: 1, + destAsset: { + chainId: 1, + address: '0x456', + symbol: 'DAI', + name: 'Dai Stablecoin', + decimals: 18, + assetId: 'eip155:1/erc20:0x456', + }, + destTokenAmount: '1000000', + minDestTokenAmount: '950000', + feeData: { + metabridge: { + amount: '10000000000000000', + asset: { + chainId: 1, + address: '0x123', + symbol: 'WETH', + name: 'Wrapped Ether', + decimals: 18, + assetId: 'eip155:1/erc20:0x123', + }, + }, + }, + bridgeId: 'bridge1', + bridges: ['bridge1'], + steps: [], + }, + trade: { + chainId: 1, + to: '0x789', + from: '0xabc', + value: '0x0', + data: '0x', + gasLimit: 100000, + }, + estimatedProcessingTimeInSeconds: 60, + }; + + const result = getQuotesReceivedProperties( + toQuoteResponseV2(mockQuoteResponse), + ); + + expect(result.token_symbol_source).toBe('WETH'); + expect(result.token_symbol_destination).toBe('DAI'); + }); + }); +}); diff --git a/packages/bridge-controller/src/utils/metrics/properties.ts b/packages/bridge-controller/src/utils/metrics/properties.ts new file mode 100644 index 00000000000..8e4b4ccc088 --- /dev/null +++ b/packages/bridge-controller/src/utils/metrics/properties.ts @@ -0,0 +1,223 @@ +/* eslint-disable @typescript-eslint/explicit-function-return-type */ +import type { AccountsControllerState } from '@metamask/accounts-controller'; + +import { DEFAULT_BRIDGE_CONTROLLER_STATE } from '../../constants/bridge.js'; +import { ChainId } from '../../types.js'; +import type { GenericQuoteRequest, QuoteRequest } from '../../types.js'; +import { FeatureId } from '../../validators/feature-flags.js'; +import type { QuoteResponse } from '../../validators/quote-response.js'; +import { getNativeAssetForChainId, isCrossChain } from '../bridge.js'; +import { + formatAddressToAssetId, + formatChainIdToCaip, +} from '../caip-formatters.js'; +import { MetricsSwapType } from './constants.js'; +import type { + AccountHardwareType, + InputKeys, + InputValues, + QuoteWarning, + RequestParams, +} from './types.js'; + +export const toInputChangedPropertyKey: Partial< + Record +> = { + srcTokenAddress: 'token_source', + destTokenAddress: 'token_destination', + srcChainId: 'chain_source', + destChainId: 'chain_destination', + slippage: 'slippage', +}; + +export const toInputChangedPropertyValue: Partial< + Record< + keyof typeof toInputChangedPropertyKey, + ( + input_value: Partial, + ) => InputValues[keyof InputValues] | undefined + > +> = { + srcTokenAddress: ({ srcTokenAddress, srcChainId }) => + srcChainId + ? formatAddressToAssetId(srcTokenAddress ?? '', srcChainId) + : undefined, + destTokenAddress: ({ destTokenAddress, destChainId }) => + destChainId + ? formatAddressToAssetId(destTokenAddress ?? '', destChainId) + : undefined, + srcChainId: ({ srcChainId }) => + srcChainId ? formatChainIdToCaip(srcChainId) : undefined, + destChainId: ({ destChainId }) => + destChainId ? formatChainIdToCaip(destChainId) : undefined, + slippage: ({ slippage }) => (slippage ? Number(slippage) : slippage), +}; + +export const getSwapType = ( + srcChainId?: GenericQuoteRequest['srcChainId'], + destChainId?: GenericQuoteRequest['destChainId'], +) => { + if (srcChainId && !isCrossChain(srcChainId, destChainId ?? srcChainId)) { + return MetricsSwapType.SINGLE; + } + return MetricsSwapType.CROSSCHAIN; +}; + +export const getSwapTypeFromQuote = ( + quoteRequest: Partial, +) => { + return getSwapType(quoteRequest.srcChainId, quoteRequest.destChainId); +}; + +export const formatProviderLabel = ({ + aggregator, + protocols, + bridges, + bridgeId, +}: { + aggregator?: string; + protocols?: string[]; + bridges?: string[]; + bridgeId?: string; +}): `${string}_${string}` => + `${aggregator ?? bridgeId}_${protocols?.[0] ?? bridges?.[0]}`; + +/** + * @param quoteRequest - The current quote request used to derive chain and token identity fields. + * @param quoteRequest.srcChainId - Source chain id of the quote request. + * @param quoteRequest.destChainId - Destination chain id of the quote request. + * @param quoteRequest.srcTokenAddress - Source token address of the quote request. + * @param quoteRequest.destTokenAddress - Destination token address of the quote request. + * @param tokenSecurityTypeDestination - The security classification of the destination token, + * supplied by the client (e.g. from token security/scanning data). Pass `null` when no + * security data is available for the selected destination token. + * @returns The analytics request params derived from the quote request. Token symbols are + * omitted because the quote request only stores addresses; use + * {@link getQuotesReceivedProperties} when building a `QuotesReceived` payload. + */ +export const getRequestParams = ( + { + srcChainId, + destChainId, + srcTokenAddress, + destTokenAddress, + }: Partial, + tokenSecurityTypeDestination: string | null, +): Omit => { + // Fallback to ETH if srcChainId is not defined. This is ok since the clients default to Ethereum as the source chain + // This also doesn't happen at runtime since the quote request is validated before metrics are published + const srcChainIdCaip = formatChainIdToCaip(srcChainId ?? ChainId.ETH); + return { + chain_id_source: srcChainIdCaip, + chain_id_destination: destChainId ? formatChainIdToCaip(destChainId) : null, + token_address_source: srcTokenAddress + ? (formatAddressToAssetId(srcTokenAddress, srcChainIdCaip) ?? + getNativeAssetForChainId(srcChainIdCaip)?.assetId ?? + null) + : (getNativeAssetForChainId(srcChainIdCaip)?.assetId ?? null), + token_address_destination: destTokenAddress + ? (formatAddressToAssetId( + destTokenAddress, + destChainId ?? srcChainIdCaip, + ) ?? null) + : null, + token_security_type_destination: tokenSecurityTypeDestination, + }; +}; + +export const getAccountHardwareType = ( + selectedAccount?: AccountsControllerState['internalAccounts']['accounts'][string], +): AccountHardwareType => { + // Unified bridge analytics only support the schema enum values for hardware accounts. + switch (selectedAccount?.metadata?.keyring.type) { + case 'Ledger Hardware': + return 'Ledger'; + case 'Trezor Hardware': + return 'Trezor'; + case 'QR Hardware Wallet Device': + return 'QR Hardware'; + case 'Lattice Hardware': + return 'Lattice'; + default: + return null; + } +}; + +export const isHardwareWallet = ( + selectedAccount?: AccountsControllerState['internalAccounts']['accounts'][string], +) => { + return getAccountHardwareType(selectedAccount) !== null; +}; + +/** + * @param slippage - The slippage percentage + * @returns Whether the default slippage was overridden by the user + * + * @deprecated This function should not be used. Use {@link selectDefaultSlippagePercentage} instead. + */ +export const isCustomSlippage = (slippage: GenericQuoteRequest['slippage']) => { + return slippage !== DEFAULT_BRIDGE_CONTROLLER_STATE.quoteRequest[0]?.slippage; +}; + +export const getQuotesReceivedProperties = ( + activeQuote: null | QuoteResponse, + warnings: QuoteWarning[] = [], + isSubmittable: boolean = true, + recommendedQuote?: null | QuoteResponse, + usdBalanceSource?: number, + hasSufficientGasForQuote?: boolean | null, + options: { + // eslint-disable-next-line @typescript-eslint/naming-convention -- analytics property + custom_slippage?: boolean; + // eslint-disable-next-line @typescript-eslint/naming-convention -- analytics property + slippage_limit?: number; + // eslint-disable-next-line @typescript-eslint/naming-convention -- analytics property + usd_amount_source?: number; + // eslint-disable-next-line @typescript-eslint/naming-convention -- analytics property + token_symbol_source?: string; + // eslint-disable-next-line @typescript-eslint/naming-convention -- analytics property + token_symbol_destination?: string | null; + } = {}, +) => { + const provider = activeQuote ? formatProviderLabel(activeQuote.quote) : '_'; + const quoteUsdAmountSource = activeQuote?.quote?.src?.usd; + const quoteTokenSymbolSource = activeQuote?.quote.src.asset.symbol; + const quoteTokenSymbolDestination = activeQuote?.quote.dest.asset.symbol; + const usdAmountSource = Number( + quoteUsdAmountSource ?? options.usd_amount_source ?? 0, + ); + const slippageLimit = + options.slippage_limit ?? activeQuote?.quote?.slippage ?? 0; + return { + can_submit: isSubmittable, + gas_included: Boolean(activeQuote?.quote?.gasIncluded), + gas_included_7702: Boolean(activeQuote?.quote?.gasIncluded7702), + quoted_time_minutes: activeQuote?.estimatedProcessingTimeInSeconds + ? activeQuote.estimatedProcessingTimeInSeconds / 60 + : 0, + usd_quoted_gas: Number(activeQuote?.quote?.feeData?.network?.[0]?.usd ?? 0), + usd_quoted_return: Number(activeQuote?.quote?.dest?.usd ?? 0), + usd_balance_source: usdBalanceSource ?? 0, + usd_amount_source: usdAmountSource, + slippage_limit: slippageLimit, + best_quote_provider: recommendedQuote + ? formatProviderLabel(recommendedQuote.quote) + : provider, + provider, + token_symbol_source: + quoteTokenSymbolSource ?? options.token_symbol_source ?? '', + token_symbol_destination: + quoteTokenSymbolDestination ?? options.token_symbol_destination ?? null, + warnings, + price_impact: Number( + activeQuote?.quote.priceData?.priceImpact?.amount ?? 0, + ), + ...(hasSufficientGasForQuote !== undefined && { + has_sufficient_gas_for_quote: hasSufficientGasForQuote, + }), + ...(options.custom_slippage !== undefined && { + custom_slippage: options.custom_slippage, + }), + feature_id: activeQuote?.featureId ?? FeatureId.UNIFIED_SWAP_BRIDGE, + }; +}; diff --git a/packages/bridge-controller/src/utils/metrics/types.ts b/packages/bridge-controller/src/utils/metrics/types.ts new file mode 100644 index 00000000000..42405645d96 --- /dev/null +++ b/packages/bridge-controller/src/utils/metrics/types.ts @@ -0,0 +1,484 @@ +/* eslint-disable @typescript-eslint/naming-convention */ +import type { CaipAssetType, CaipChainId } from '@metamask/utils'; + +import type { + InputPrimaryDenomination, + SortOrder, + StatusTypes, +} from '../../types.js'; +import type { FeatureId } from '../../validators/feature-flags.js'; +import type { + UnifiedSwapBridgeEventName, + BatchSellMetricsEventName, + BatchSellMetricsLocation, + BridgeControllerMetricsEventName, + BridgeControllerMetricsLocation, + MetricsActionType, + MetricsSwapType, + PollingStatus, +} from './constants.js'; + +/** + * These properties map to properties required by the segment-schema. For example: https://github.com/Consensys/segment-schema/blob/main/libraries/properties/cross-chain-swaps-action.yaml + */ +export type RequestParams = { + chain_id_source: CaipChainId; + chain_id_destination: CaipChainId | null; + token_symbol_source: string; + token_symbol_destination: string | null; + token_address_source: CaipAssetType; + token_address_destination: CaipAssetType | null; + /** + * Client-supplied security classification for the destination token + * (e.g. from token security/scanning data). Stored on the controller + * and merged into every analytics event that includes + * `token_address_destination`. Pass `null` when no security data is + * available for the selected destination token. + */ + token_security_type_destination: string | null; +}; + +export type AccountHardwareType = + | 'Ledger' + | 'Trezor' + | 'QR Hardware' + | 'Lattice' + | null; + +export type RequestMetadata = { + slippage_limit: number; // 0 === auto when no numeric limit is available + custom_slippage: boolean; + usd_amount_source: number; // Use quoteResponse when available + stx_enabled: boolean; + is_hardware_wallet: boolean; + account_hardware_type: AccountHardwareType; + swap_type: MetricsSwapType; + security_warnings: string[]; +}; + +export type QuoteFetchData = { + can_submit: boolean; + best_quote_provider?: `${string}_${string}`; + quotes_count: number; + quotes_list: `${string}_${string}`[]; + initial_load_time_all_quotes: number; + price_impact: number; + has_gas_included_quote: boolean; +}; + +export type TradeData = { + usd_quoted_gas: number; + gas_included: boolean; + gas_included_7702: boolean; + quoted_time_minutes: number; + usd_quoted_return: number; + provider: `${string}_${string}`; +}; + +export type TxStatusData = { + allowance_reset_transaction?: StatusTypes; + approval_transaction?: StatusTypes; + source_transaction?: StatusTypes; + destination_transaction?: StatusTypes; +}; + +export type InputPrimaryDenominationData = { + input_primary_denomination?: InputPrimaryDenomination; +}; + +export type InputKeys = + | 'token_source' + | 'token_destination' + | 'chain_source' + | 'chain_destination' + | 'slippage' + | 'token_amount_source'; + +export type InputValues = { + token_source: CaipAssetType; + token_destination: CaipAssetType; + chain_source: CaipChainId; + chain_destination: CaipChainId; + slippage: number; + token_amount_source: string; +}; + +export type QuoteWarning = + | 'low_return' + | 'no_quotes' + | 'insufficient_gas_balance' + | 'insufficient_gas_for_selected_quote' + | 'insufficient_balance' + | 'market_closed' + | 'price_impact' + | 'quote_expired' + | 'tx_alert'; + +type BatchSellChainProperties = { + chain_id_source: CaipChainId; + chain_id_destination: CaipChainId | null; +}; + +type BatchSellTokenPageEventContext = BatchSellChainProperties & { + location: BatchSellMetricsLocation; +}; + +type BatchSellSourceTokenEventContext = BatchSellTokenPageEventContext & { + source_token_symbols: string[]; + source_token_addresses: CaipAssetType[]; +}; + +type BatchSellQuotePageEventContext = BatchSellSourceTokenEventContext & { + destination_token_symbol: string; + destination_token_address: CaipAssetType; + usd_amount_source_tokens: number[]; + usd_amount_source_total: number; + source_token_slippages: number[]; +}; + +type BatchSellReviewModalSubmittedEventContext = + BatchSellQuotePageEventContext & + Pick; + +type BatchSellTokenPageEventProperties = BatchSellChainProperties & + BatchSellTokenPageEventContext; + +type BatchSellSourceTokenEventProperties = BatchSellChainProperties & + BatchSellSourceTokenEventContext & { + source_token_count: number; + }; + +type BatchSellQuotePageEventProperties = BatchSellChainProperties & + BatchSellQuotePageEventContext & { + source_token_count: number; + }; + +type BatchSellReviewModalSubmittedEventProperties = BatchSellChainProperties & + BatchSellReviewModalSubmittedEventContext & { + source_token_count: number; + }; + +type SharedEventContextFromClient = { + ab_tests?: Record; + active_ab_tests?: { key: string; value: string }[]; + feature_id: FeatureId; +}; + +type OptionalLocationContextFromClient = T extends { + location: unknown; +} + ? object + : { location?: BridgeControllerMetricsLocation }; + +/** + * Properties that are required to be provided when trackUnifiedSwapBridgeEvent is called. + * Most events receive an optional location via RequiredEventContextFromClient; + * Batch Sell events define their own required location enum. + */ +type RequiredEventContextFromClientBase = { + [UnifiedSwapBridgeEventName.ButtonClicked]: Pick< + RequestParams, + 'token_symbol_source' | 'token_symbol_destination' + > & { environment_type?: string }; + // When type is object, the payload can be anything + [UnifiedSwapBridgeEventName.PageViewed]: object; + [UnifiedSwapBridgeEventName.InputChanged]: { + input: + | 'token_source' + | 'token_destination' + | 'chain_source' + | 'chain_destination' + | 'slippage' + | 'token_amount_source'; + input_value: InputValues[keyof InputValues]; + input_amount_preset?: string; + }; + [UnifiedSwapBridgeEventName.FiatCryptoToggleClicked]: { + token_symbol_source: string; + token_symbol_destination: string | null; + previous_primary_denomination: InputPrimaryDenomination; + new_primary_denomination: InputPrimaryDenomination; + chain_id_source?: RequestParams['chain_id_source']; + chain_id_destination?: RequestParams['chain_id_destination']; + token_address_source?: RequestParams['token_address_source']; + token_address_destination?: RequestParams['token_address_destination']; + token_security_type_destination?: RequestParams['token_security_type_destination']; + swap_type?: RequestMetadata['swap_type']; + }; + [UnifiedSwapBridgeEventName.InputSourceDestinationSwitched]: { + token_symbol_source: RequestParams['token_symbol_source']; + token_symbol_destination: RequestParams['token_symbol_destination']; + token_address_source: RequestParams['token_address_source']; + token_address_destination: RequestParams['token_address_destination']; + token_security_type_destination: RequestParams['token_security_type_destination']; + chain_id_source: RequestParams['chain_id_source']; + chain_id_destination: RequestParams['chain_id_destination']; + } & Pick; + [UnifiedSwapBridgeEventName.QuotesRequested]: Pick< + RequestMetadata, + 'stx_enabled' | 'usd_amount_source' + > & { + token_symbol_source: RequestParams['token_symbol_source']; + token_symbol_destination: RequestParams['token_symbol_destination']; + token_security_type_destination: RequestParams['token_security_type_destination']; + custom_slippage?: RequestMetadata['custom_slippage']; + } & InputPrimaryDenominationData; + [UnifiedSwapBridgeEventName.QuotesReceived]: TradeData & + Pick & + InputPrimaryDenominationData & { + warnings: QuoteWarning[]; + best_quote_provider: QuoteFetchData['best_quote_provider']; + price_impact: QuoteFetchData['price_impact']; + can_submit: QuoteFetchData['can_submit']; + usd_balance_source?: number; + has_sufficient_gas_for_quote?: boolean | null; + usd_amount_source: number; + custom_slippage?: RequestMetadata['custom_slippage']; + slippage_limit?: RequestMetadata['slippage_limit']; + }; + [UnifiedSwapBridgeEventName.QuotesError]: Pick< + RequestMetadata, + 'stx_enabled' + > & { + token_symbol_source: RequestParams['token_symbol_source']; + token_symbol_destination: RequestParams['token_symbol_destination']; + } & Pick; + // Emitted by BridgeStatusController + [UnifiedSwapBridgeEventName.Submitted]: TradeData & + Pick & + Omit & + Pick< + RequestParams, + | 'token_symbol_source' + | 'token_symbol_destination' + | 'token_address_source' + | 'token_address_destination' + | 'chain_id_source' + | 'chain_id_destination' + | 'token_security_type_destination' + > & { + action_type: MetricsActionType; + batch_id?: string; + } & InputPrimaryDenominationData; + [UnifiedSwapBridgeEventName.Completed]: TradeData & + Pick & + Omit & + TxStatusData & + RequestParams & { + actual_time_minutes: number; + usd_actual_return: number; + usd_actual_gas: number; + quote_vs_execution_ratio: number; + quoted_vs_used_gas_ratio: number; + action_type: MetricsActionType; + batch_id?: string; + transaction_internal_id?: string; + } & InputPrimaryDenominationData; + [UnifiedSwapBridgeEventName.Failed]: ( + | // Tx failed before confirmation + (Pick< + RequestMetadata, + | 'stx_enabled' + | 'usd_amount_source' + | 'is_hardware_wallet' + | 'account_hardware_type' + > & + Pick< + RequestParams, + | 'token_symbol_source' + | 'token_symbol_destination' + | 'token_address_source' + | 'token_address_destination' + | 'token_security_type_destination' + >) + // Tx failed after confirmation + | (RequestParams & + RequestMetadata & + TxStatusData & { + actual_time_minutes: number; + }) + ) & + TradeData & + Pick & { + error_message: string; + batch_id?: string; + }; + [UnifiedSwapBridgeEventName.PollingStatusUpdated]: { + polling_status: PollingStatus; + retry_attempts: number; + }; + [UnifiedSwapBridgeEventName.StatusValidationFailed]: { + failures: string[]; + refresh_count: number; + } & Partial; + // Emitted by clients + [UnifiedSwapBridgeEventName.AllQuotesOpened]: Pick< + TradeData, + 'gas_included' | 'gas_included_7702' + > & + Pick & + Pick & { + stx_enabled: RequestMetadata['stx_enabled']; + can_submit: QuoteFetchData['can_submit']; + }; + [UnifiedSwapBridgeEventName.AllQuotesSorted]: Pick< + TradeData, + 'gas_included' | 'gas_included_7702' + > & + Pick & + Pick & { + stx_enabled: RequestMetadata['stx_enabled']; + sort_order: SortOrder; + best_quote_provider: QuoteFetchData['best_quote_provider']; + can_submit: QuoteFetchData['can_submit']; + }; + [UnifiedSwapBridgeEventName.QuoteSelected]: TradeData & { + is_best_quote: boolean; + best_quote_provider: QuoteFetchData['best_quote_provider']; + price_impact: QuoteFetchData['price_impact']; + can_submit: QuoteFetchData['can_submit']; + }; + [UnifiedSwapBridgeEventName.AssetDetailTooltipClicked]: { + token_name: string; + token_symbol: string; + token_contract: string; + chain_name: string; + chain_id: string; + }; + [UnifiedSwapBridgeEventName.QuotesValidationFailed]: { + failures: string[]; + }; + [UnifiedSwapBridgeEventName.AssetPickerOpened]: { + asset_location: 'source' | 'destination'; + }; + [BatchSellMetricsEventName.BatchSellTokenPageViewed]: BatchSellTokenPageEventContext; + [BatchSellMetricsEventName.BatchSellTokenPageContinueClicked]: BatchSellSourceTokenEventContext; + [BatchSellMetricsEventName.BatchSellQuotePageViewed]: BatchSellQuotePageEventContext; + [BatchSellMetricsEventName.BatchSellQuotePageReviewClicked]: BatchSellQuotePageEventContext; + [BatchSellMetricsEventName.BatchSellReviewModalSubmitted]: BatchSellReviewModalSubmittedEventContext; +}; + +/** + * 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 Unknown). + * + * `ab_tests` is the legacy field and `active_ab_tests` is the newer field. + * Both are kept for a migration window and are treated as separate payloads. + */ +export type RequiredEventContextFromClient = { + [K in keyof RequiredEventContextFromClientBase]: K extends BatchSellMetricsEventName + ? RequiredEventContextFromClientBase[K] + : RequiredEventContextFromClientBase[K] & + OptionalLocationContextFromClient< + RequiredEventContextFromClientBase[K] + > & + SharedEventContextFromClient; +}; + +/** + * Properties that can be derived from the bridge controller state + */ +export type EventPropertiesFromControllerState = { + [UnifiedSwapBridgeEventName.ButtonClicked]: RequestParams; + [UnifiedSwapBridgeEventName.PageViewed]: RequestParams & + Omit< + RequestMetadata, + 'stx_enabled' | 'usd_amount_source' | 'security_warnings' + > & + InputPrimaryDenominationData; + [UnifiedSwapBridgeEventName.InputChanged]: { + input: InputKeys; + input_value: string; + }; + [UnifiedSwapBridgeEventName.FiatCryptoToggleClicked]: RequestParams & + Pick; + [UnifiedSwapBridgeEventName.InputSourceDestinationSwitched]: RequestParams; + [UnifiedSwapBridgeEventName.QuotesRequested]: RequestParams & + RequestMetadata & { + has_sufficient_funds: boolean; + } & InputPrimaryDenominationData; + [UnifiedSwapBridgeEventName.QuotesReceived]: RequestParams & + RequestMetadata & + QuoteFetchData & + TradeData & { + refresh_count: number; // starts from 0 + has_sufficient_funds: boolean; + } & InputPrimaryDenominationData; + [UnifiedSwapBridgeEventName.QuotesError]: RequestParams & + RequestMetadata & { + has_sufficient_funds: boolean; + error_message: string; + }; + [UnifiedSwapBridgeEventName.Submitted]: null; + [UnifiedSwapBridgeEventName.Completed]: null; + [UnifiedSwapBridgeEventName.Failed]: RequestParams & + RequestMetadata & + TxStatusData & + TradeData & + Pick & { + actual_time_minutes: number; + }; + [UnifiedSwapBridgeEventName.AllQuotesOpened]: RequestParams & + RequestMetadata & + TradeData & + QuoteFetchData; + [UnifiedSwapBridgeEventName.AllQuotesSorted]: RequestParams & + RequestMetadata & + TradeData & + QuoteFetchData; + [UnifiedSwapBridgeEventName.QuoteSelected]: RequestParams & + RequestMetadata & + QuoteFetchData & + TradeData; + [UnifiedSwapBridgeEventName.AssetDetailTooltipClicked]: null; + [UnifiedSwapBridgeEventName.QuotesValidationFailed]: RequestParams & { + refresh_count: number; + }; + [UnifiedSwapBridgeEventName.StatusValidationFailed]: RequestParams; + [UnifiedSwapBridgeEventName.AssetPickerOpened]: null; + [UnifiedSwapBridgeEventName.PollingStatusUpdated]: TradeData & + Pick & + Omit & + Pick< + RequestParams, + | 'token_symbol_source' + | 'token_symbol_destination' + | 'chain_id_source' + | 'chain_id_destination' + > & { + batch_id?: string; + }; + [BatchSellMetricsEventName.BatchSellTokenPageViewed]: BatchSellTokenPageEventProperties; + [BatchSellMetricsEventName.BatchSellTokenPageContinueClicked]: BatchSellSourceTokenEventProperties; + [BatchSellMetricsEventName.BatchSellQuotePageViewed]: BatchSellQuotePageEventProperties; + [BatchSellMetricsEventName.BatchSellQuotePageReviewClicked]: BatchSellQuotePageEventProperties; + [BatchSellMetricsEventName.BatchSellReviewModalSubmitted]: BatchSellReviewModalSubmittedEventProperties; +}; + +type SharedCrossChainSwapsEventProperties< + T extends BridgeControllerMetricsEventName, +> = + | { + feature_id: FeatureId; + action_type: MetricsActionType; + location: BridgeControllerMetricsLocation; + ab_tests?: Record; + active_ab_tests?: { key: string; value: string }[]; + } + | Pick[T] + | Pick[T]; + +/** + * trackUnifiedSwapBridgeEvent payload properties consist of required properties from the client + * and properties from the bridge controller + * + * `ab_tests` will be deprecated in favor of `active_ab_tests` in the future. + * `ab_tests` and `active_ab_tests` intentionally coexist during migration. + */ +export type CrossChainSwapsEventProperties< + T extends BridgeControllerMetricsEventName, +> = T extends BatchSellMetricsEventName + ? Pick[T] + : SharedCrossChainSwapsEventProperties; diff --git a/packages/bridge-controller/src/utils/number-formatters.ts b/packages/bridge-controller/src/utils/number-formatters.ts new file mode 100644 index 00000000000..c18723df97f --- /dev/null +++ b/packages/bridge-controller/src/utils/number-formatters.ts @@ -0,0 +1,134 @@ +/* eslint-disable @typescript-eslint/explicit-function-return-type */ +import { BigNumber } from 'bignumber.js'; + +import type { DeepPartial } from '../types.js'; +import type { QuoteResponse } from '../validators/quote-response.js'; +import { assetIdsMatch } from './assets.js'; + +/** + * 1500000 -> 1.5 + * + * @param value - The value to convert to token amount + * @param decimals - The number of decimals to convert to + * @returns The token amount in string format + */ +export const calcNormalizedTokenAmount = ( + value: string | BigNumber | undefined, + decimals: number | undefined, +) => { + if (value === undefined || decimals === undefined) { + return undefined; + } + const divisor = new BigNumber(10).pow(decimals ?? 0); + return new BigNumber(value).div(divisor); +}; + +/** + * 1.5 -> 1500000 + * + * @param value - The amount to convert to token value + * @param decimals - The number of decimals to convert to + * @returns The token value in string format + */ +export const calcAtomicTokenAmount = ( + value: string | BigNumber | undefined, + decimals: number | undefined, +) => { + if (value === undefined || decimals === undefined) { + return undefined; + } + const divisor = new BigNumber(10).pow(decimals); + return new BigNumber(value).times(divisor).toFixed(); +}; + +/** + * @deprecated No longer used + * @param estimatedProcessingTimeInSeconds - The estimated processing time in seconds + * @returns The estimated processing time in minutes + */ +export const formatEtaInMinutes = ( + estimatedProcessingTimeInSeconds: number, +) => { + if (estimatedProcessingTimeInSeconds < 60) { + return `< 1`; + } + return (estimatedProcessingTimeInSeconds / 60).toFixed(); +}; + +/** + * Aggregates a list of amounts into a single fee object. If fees have different assets, + * the returned object will only aggregate the usd and valueInCurrency values. + * + * @param maybeFees - The list of fees to aggregate + * @returns The aggregated fee object, or null if no fees are provided + */ +export const sumAmounts = ( + ...maybeFees: ( + | (DeepPartial | undefined | null)[] + | undefined + )[] +): DeepPartial | undefined => { + const fees = maybeFees + .flat() + .flat() + .filter( + (value): value is Partial => + value !== undefined && value !== null, + ); + + if (!fees || fees.length === 0) { + return undefined; + } + + /** + * Fees and prices can be denominated in different assets, so we need to check if all fees have the same units + */ + const isSameAssetForAllFees = fees.reduce( + (acc, fee) => + acc && assetIdsMatch(fee.asset?.assetId, fees[0]?.asset?.assetId), + true, + ); + + /** + * Keys that require the asset to be the same for all fees + */ + const AMOUNT_KEYS = [ + 'amount' as const, + 'normalizedAmount' as const, + 'minAmount' as const, + 'minAmountNormalized' as const, + ]; + + /** + * Keys that can be aggregated across all fees + */ + const FIAT_OR_USD_KEYS = [ + 'valueInCurrency' as const, + 'usd' as const, + 'minAmountValueInCurrency' as const, + 'minAmountUsd' as const, + ]; + + return fees.reduce((acc, fee) => { + const newAcc = { ...acc }; + if (isSameAssetForAllFees && fee.asset) { + newAcc.asset = fee.asset; + } + + AMOUNT_KEYS.forEach((key) => { + const value = fee[key]; + if (value && isSameAssetForAllFees) { + newAcc[key] = new BigNumber(acc[key] ?? 0).plus(value).toFixed(); + } + }); + + FIAT_OR_USD_KEYS.forEach((key) => { + const value = fee[key]; + if (value) { + newAcc[key] = new BigNumber(acc[key] ?? 0).plus(value).toFixed(); + } + }); + + return newAcc; + }, {}); +}; diff --git a/packages/bridge-controller/src/utils/quote-fees.ts b/packages/bridge-controller/src/utils/quote-fees.ts new file mode 100644 index 00000000000..db8e3001798 --- /dev/null +++ b/packages/bridge-controller/src/utils/quote-fees.ts @@ -0,0 +1,228 @@ +import type { InternalAccount } from '@metamask/keyring-internal-api'; +import type { TransactionController } from '@metamask/transaction-controller'; +import type { CaipChainId } from '@metamask/utils'; + +import { CHAIN_IDS } from '../constants/chains.js'; +import type { + L1GasFees, + NonEvmFees, + BridgeControllerMessenger, +} from '../types.js'; +import type { QuoteResponseV1 } from '../validators/quote-response-v1.js'; +import { isTronTrade } from '../validators/trade.js'; +import type { TxData } from '../validators/trade.js'; +import { isNonEvmChainId, sumHexes } from './bridge.js'; +import { formatChainIdToCaip, formatChainIdToHex } from './caip-formatters.js'; +import { computeFeeRequest } from './snaps.js'; +import { extractTradeData } from './trade-utils.js'; + +/** + * Appends transaction fees for EVM chains to quotes + * + * @param chainId - The CAIP srcChainId of the quotes + * @param quotes - Array of quote responses to append fees to + * @param getLayer1GasFee - The function to use to get the layer 1 gas fee + * @returns Array of quotes with fees appended, or undefined if quotes are for non-EVM chains + */ +const appendL1GasFees = async < + QuoteType extends Omit, +>( + chainId: CaipChainId, + quotes: QuoteType[], + getLayer1GasFee: typeof TransactionController.prototype.getLayer1GasFee, +): Promise<(QuoteType & L1GasFees)[] | undefined> => { + // Indicates whether some of the quotes are not for optimism or base + const hasInvalidQuotes = ![CHAIN_IDS.OPTIMISM, CHAIN_IDS.BASE] + .map(formatChainIdToCaip) + .includes(chainId); + + // Only append L1 gas fees if all quotes are for either optimism or base + if (hasInvalidQuotes) { + return undefined; + } + + const hexChainId = formatChainIdToHex(chainId); + const l1GasFeePromises = Promise.allSettled( + quotes.map(async (quoteResponse) => { + const { trade, approval } = quoteResponse; + + const getTxParams = (txData: TxData) => ({ + from: txData.from, + to: txData.to, + value: txData.value, + data: txData.data, + gasLimit: txData.gasLimit?.toString(), + }); + const approvalL1GasFees = approval + ? await getLayer1GasFee({ + transactionParams: getTxParams(approval as TxData), + chainId: hexChainId, + }) + : '0x0'; + const tradeL1GasFees = await getLayer1GasFee({ + transactionParams: getTxParams(trade as TxData), + chainId: hexChainId, + }); + + if (approvalL1GasFees === undefined || tradeL1GasFees === undefined) { + return undefined; + } + + return { + ...quoteResponse, + l1GasFeesInHexWei: sumHexes(approvalL1GasFees, tradeL1GasFees), + }; + }), + ); + + const quotesWithL1GasFees = (await l1GasFeePromises).reduce< + (QuoteType & L1GasFees)[] + >((acc, result) => { + if (result.status === 'fulfilled' && result.value) { + acc.push(result.value); + } else if (result.status === 'rejected') { + console.error('Error calculating L1 gas fees for quote', result.reason); + } + return acc; + }, []); + + if (quotesWithL1GasFees.length) { + return quotesWithL1GasFees; + } + return undefined; +}; + +/** + * Appends transaction fees for non-EVM chains to quotes + * + * @param chainId - The CAIP chain ID of the quotes + * @param quotes - Array of quote responses to append fees to + * @param messenger - The messaging system to use to call the snap controller + * @param selectedAccount - The selected account for which the quotes were requested + * @returns Array of quotes with fees appended, or undefined if quotes are for EVM chains + */ +const appendNonEvmFees = async < + QuoteType extends Omit, +>( + chainId: CaipChainId, + quotes: QuoteType[], + messenger: BridgeControllerMessenger, + selectedAccount?: InternalAccount, +): Promise<(QuoteType & NonEvmFees)[] | undefined> => { + if (!isNonEvmChainId(chainId)) { + return undefined; + } + + const nonEvmFeePromises = Promise.allSettled( + quotes.map(async (quoteResponse) => { + const { trade } = quoteResponse; + + // Skip fee computation if no snap account or trade data + if (!selectedAccount?.metadata?.snap?.id || !trade) { + return quoteResponse; + } + + try { + const transaction = extractTradeData(trade); + + // Tron trades need the visible flag and contract type to be included in the request options + const options = isTronTrade(trade) + ? { + visible: trade.visible, + type: trade.raw_data?.contract?.[0]?.type, + feeLimit: trade.raw_data?.fee_limit, + } + : undefined; + + const response = (await messenger.call( + 'SnapController:handleRequest', + computeFeeRequest( + selectedAccount?.metadata?.snap?.id, + transaction, + selectedAccount?.id, + chainId, + options, + ), + )) as { + type: 'base' | 'priority'; + asset: { + unit: string; + type: string; + amount: string; + fungible: true; + }; + }[]; + + // Bitcoin snap returns 'priority' fee, Solana returns 'base' fee + const fee = + response?.find((f) => f.type === 'base') || + response?.find((f) => f.type === 'priority') || + response?.[0]; + const feeInNative = fee?.asset?.amount || '0'; + + return { + ...quoteResponse, + nonEvmFeesInNative: feeInNative, + }; + } catch (error) { + // Return quote with undefined fee if snap fails (e.g., insufficient UTXO funds) + // Client can render special UI or skip the quote card row for quotes with missing fee data + console.error( + `Failed to compute non-EVM fees for quote ${quoteResponse.quoteId ?? ''}`, + error, + ); + return { + ...quoteResponse, + nonEvmFeesInNative: undefined, + }; + } + }), + ); + + const quotesWithNonEvmFees = (await nonEvmFeePromises).reduce< + (QuoteType & NonEvmFees)[] + >((acc, result) => { + if (result.status === 'fulfilled' && result.value) { + acc.push(result.value); + } + return acc; + }, []); + + return quotesWithNonEvmFees; +}; + +/** + * Appends transaction fees to quotes + * + * @param chainId - The CAIP chain ID of the quotes + * @param quotes - Array of quote responses to append fees to + * @param messenger - The bridge controller to use to call the snap controller + * @param getLayer1GasFee - The function to use to get the layer 1 gas fee + * @param selectedAccount - The selected account for which the quotes were requested + * @returns Array of quotes with fees appended, or undefined if quotes are for EVM chains + */ +export const appendFeesToQuotes = async < + QuoteType extends Omit, +>( + chainId: CaipChainId, + quotes: QuoteType[], + messenger: BridgeControllerMessenger, + getLayer1GasFee: typeof TransactionController.prototype.getLayer1GasFee, + selectedAccount?: InternalAccount, +): Promise<(QuoteType & L1GasFees & NonEvmFees)[]> => { + // Safe to cast: appendL1GasFees checks if all quotes are EVM and returns undefined otherwise + const quotesWithL1GasFees = await appendL1GasFees( + chainId, + quotes, + getLayer1GasFee, + ); + + const quotesWithNonEvmFees = await appendNonEvmFees( + chainId, + quotes, + messenger, + selectedAccount, + ); + + return quotesWithL1GasFees ?? quotesWithNonEvmFees ?? quotes; +}; diff --git a/packages/bridge-controller/src/utils/quote-metadata/calculators.test.ts b/packages/bridge-controller/src/utils/quote-metadata/calculators.test.ts new file mode 100644 index 00000000000..b29c8df1b6b --- /dev/null +++ b/packages/bridge-controller/src/utils/quote-metadata/calculators.test.ts @@ -0,0 +1,1067 @@ +import { AddressZero } from '@ethersproject/constants'; +import { convertHexToDecimal } from '@metamask/controller-utils'; +import { BigNumber } from 'bignumber.js'; + +import { getMockBridgeQuotesErc20Erc20V1 } from '../../../tests/mock-quotes-erc20-erc20.js'; +import { getMockBridgeQuotesNativeErc20V1 } from '../../../tests/mock-quotes-native-erc20.js'; +import { getMockBridgeQuotesSolErc20V1 } from '../../../tests/mock-quotes-sol-erc20.js'; +import type { GenericQuoteRequest, L1GasFees } from '../../types.js'; +import { isValidQuoteRequest } from '../../validators/quote-request.js'; +import { QuoteResponseV1 } from '../../validators/quote-response-v1.js'; +import type { Quote } from '../../validators/quote.js'; +import type { TxData } from '../../validators/trade.js'; +import { getNativeAssetForChainId, isNativeAddress } from '../bridge.js'; +import { formatEtaInMinutes } from '../number-formatters.js'; +import { + calcNonEvmTotalNetworkFee, + calcToAmount, + calcSentAmount, + calcRelayerFee, + calcEstimatedAndMaxTotalGasFee, + calcTotalEstimatedNetworkFee, + calcAdjustedReturn, + calcSwapRate, + calcCost, + calcSlippagePercentage, + calcPriceImpact, +} from './calculators.js'; + +describe('Quote Utils', () => { + describe('isValidQuoteRequest', () => { + const validRequest: GenericQuoteRequest = { + srcTokenAddress: '0x123', + destTokenAddress: '0x456', + srcChainId: '1', + destChainId: '137', + walletAddress: '0x789', + srcTokenAmount: '1000', + slippage: 0.5, + gasIncluded: false, + gasIncluded7702: false, + }; + + it('should return true for valid request with all required fields', () => { + expect(isValidQuoteRequest(validRequest)).toBe(true); + }); + + it('should return false if any required string field is missing', () => { + const requiredFields = [ + 'srcTokenAddress', + 'destTokenAddress', + 'srcChainId', + 'destChainId', + 'walletAddress', + 'srcTokenAmount', + ]; + + requiredFields.forEach((field) => { + const invalidRequest = { ...validRequest }; + delete invalidRequest[field as keyof GenericQuoteRequest]; + expect(isValidQuoteRequest(invalidRequest)).toBe(false); + }); + }); + + it('should return false if any required string field is empty', () => { + const requiredFields = [ + 'srcTokenAddress', + 'destTokenAddress', + 'srcChainId', + 'destChainId', + 'walletAddress', + 'srcTokenAmount', + ]; + + requiredFields.forEach((field) => { + const invalidRequest = { + ...validRequest, + [field]: '', + }; + expect(isValidQuoteRequest(invalidRequest)).toBe(false); + }); + }); + + it('should return false if any required string field is null', () => { + const invalidRequest = { + ...validRequest, + srcTokenAddress: null, + }; + expect(isValidQuoteRequest(invalidRequest as never)).toBe(false); + }); + + it('should return false if srcTokenAmount is not a valid positive integer', () => { + const invalidAmounts = ['0', '-1', '1.5', 'abc', '01']; + invalidAmounts.forEach((amount) => { + const invalidRequest = { + ...validRequest, + srcTokenAmount: amount, + }; + expect(isValidQuoteRequest(invalidRequest)).toBe(false); + }); + }); + + it('should return true for valid srcTokenAmount values', () => { + const validAmounts = ['1', '100', '999999']; + validAmounts.forEach((amount) => { + const validAmountRequest = { + ...validRequest, + srcTokenAmount: amount, + }; + expect(isValidQuoteRequest(validAmountRequest)).toBe(true); + }); + }); + + it('should validate request without amount when requireAmount is false', () => { + const { srcTokenAmount, ...requestWithoutAmount } = validRequest; + expect(isValidQuoteRequest(requestWithoutAmount, false)).toBe(true); + }); + + describe('slippage validation', () => { + it('should return true when slippage is a valid number', () => { + const requestWithSlippage = { + ...validRequest, + slippage: 1.5, + }; + expect(isValidQuoteRequest(requestWithSlippage)).toBe(true); + }); + + it('should return false when slippage is NaN', () => { + const requestWithInvalidSlippage = { + ...validRequest, + slippage: NaN, + }; + expect(isValidQuoteRequest(requestWithInvalidSlippage)).toBe(false); + }); + + it('should return false when slippage is null', () => { + const requestWithInvalidSlippage = { + ...validRequest, + slippage: null, + }; + expect(isValidQuoteRequest(requestWithInvalidSlippage as never)).toBe( + false, + ); + }); + + it('should return true when slippage is undefined', () => { + const requestWithoutSlippage = { ...validRequest }; + delete requestWithoutSlippage.slippage; + expect(isValidQuoteRequest(requestWithoutSlippage)).toBe(true); + }); + }); + }); +}); + +describe('Quote Metadata Utils', () => { + describe('calcSentAmount', () => { + it('should calculate sent amount correctly with exchange rates', () => { + const mockQuote = getMockBridgeQuotesErc20Erc20V1({ + quote: { + srcTokenAmount: '2555423', + srcAsset: { + decimals: 6, + assetId: + 'eip155:10/erc20:0x0b2c639c533813f4aa9d7837caf62653d097ff85', + }, + feeData: { + metabridge: { + amount: '110000000', + asset: { + assetId: + 'eip155:10/erc20:0x0b2c639c533813f4aa9d7837caf62653d097ff85', + }, + }, + }, + }, + })[0].quote; + expect(mockQuote.feeData.metabridge.asset?.assetId).toBe( + mockQuote.srcAsset.assetId, + ); + + const result = calcSentAmount(mockQuote, { + exchangeRate: '2.14', + usdExchangeRate: '1.5', + }); + + expect(result).toMatchInlineSnapshot(` + { + "amount": "112.555423", + "usd": "168.8331345", + "valueInCurrency": "240.86860522", + } + `); + }); + + it('should handle missing exchange rates', () => { + const mockQuote = getMockBridgeQuotesErc20Erc20V1({ + quote: { + srcTokenAmount: '1000000000', + srcAsset: { decimals: 6 }, + feeData: { + metabridge: { amount: '100000000' }, + }, + }, + })[0].quote; + const result = calcSentAmount(mockQuote, {}); + + expect(result.amount).toBe('1100'); + expect(result.valueInCurrency).toBeUndefined(); + expect(result.usd).toBeUndefined(); + }); + + it('should handle zero values', () => { + const zeroQuote = getMockBridgeQuotesErc20Erc20V1({ + quote: { + srcTokenAmount: '0', + srcAsset: { decimals: 6 }, + + feeData: { + metabridge: { amount: '0' }, + }, + }, + })[0].quote; + + const result = calcSentAmount(zeroQuote, { + exchangeRate: '2', + usdExchangeRate: '1.5', + }); + + expect(result.amount).toBe('0'); + expect(result.valueInCurrency).toBe('0'); + expect(result.usd).toBe('0'); + }); + + it('should handle large numbers', () => { + const largeQuote = getMockBridgeQuotesErc20Erc20V1({ + quote: { + srcTokenAmount: '1000000000000000000', + srcAsset: { + decimals: 18, + assetId: + 'eip155:1/erc20:0x0000000000000000000000000000000000000000', + }, + feeData: { + metabridge: { + amount: '100000000000000000', + asset: { + assetId: + 'eip155:1/erc20:0x0000000000000000000000000000000000000000', + address: '0x0000000000000000000000000000000000000000', + decimals: 18, + }, + }, + }, + }, + })[0].quote; + + const result = calcSentAmount(largeQuote, { + exchangeRate: '2', + usdExchangeRate: '1.5', + }); + + // (1 + 0.1) ETH = 1.1 ETH + expect(result.amount).toBe('1.1'); + expect(result.valueInCurrency).toBe('2.2'); + expect(result.usd).toBe('1.65'); + }); + + it('should not add feeData fees for intent-based quotes', () => { + // For intent-based swaps (e.g. CoW Protocol), srcTokenAmount is already + // the total fixed commitment including protocol fees. Adding feeData fees + // on top would double-count them. + + const intentQuote = getMockBridgeQuotesErc20Erc20V1({ + quote: { + srcTokenAmount: '10000000', // 10 USDT (6 decimals), fee already included + srcAsset: { + decimals: 6, + assetId: + 'eip155:1/erc20:0xdAC17F958D2ee523a2206206994597C13D831ec7', + }, + feeData: { + metabridge: { + amount: '500000', // 0.5 USDT protocol fee — already inside srcTokenAmount + asset: { + assetId: + 'eip155:1/erc20:0xdAC17F958D2ee523a2206206994597C13D831ec7', + address: '0xdAC17F958D2ee523a2206206994597C13D831ec7', + decimals: 6, + }, + }, + }, + intent: { + protocol: 'cow', + order: { + sellToken: '0xdAC17F958D2ee523a2206206994597C13D831ec7', + buyToken: '0x0000000000000000000000000000000000000000', + validTo: 1717027200, + appData: 'some-app-data', + appDataHash: '0xabcd', + feeAmount: '100', + kind: 'sell' as const, + partiallyFillable: false, + sellAmount: '1000', + }, + typedData: { + types: {}, + domain: {}, + primaryType: 'Order', + message: {}, + }, + }, + }, + })[0].quote; + + const result = calcSentAmount(intentQuote, { + exchangeRate: '1', + usdExchangeRate: '1', + }); + + // Should be exactly 10 USDT — not 10.5 (which would double-count the fee) + expect(result.amount).toBe('10'); + expect(result.valueInCurrency).toBe('10'); + expect(result.usd).toBe('10'); + }); + }); + + describe('calcNonEvmTotalNetworkFee', () => { + const mockBridgeQuote = getMockBridgeQuotesSolErc20V1({ + nonEvmFeesInNative: '1', + })[0]; + + it('should calculate Solana fees correctly with exchange rates', () => { + const result = calcNonEvmTotalNetworkFee(mockBridgeQuote, { + exchangeRate: '2', + usdExchangeRate: '1.5', + }); + + expect(result.amount).toBe('1'); + expect(result.valueInCurrency).toBe('2'); + expect(result.usd).toBe('1.5'); + }); + + it('should calculate Bitcoin fees correctly with exchange rates', () => { + const btcQuote = getMockBridgeQuotesSolErc20V1({ + nonEvmFeesInNative: '0.00005', // BTC fee in native units + })[0]; + + const result = calcNonEvmTotalNetworkFee(btcQuote, { + exchangeRate: '60000', + usdExchangeRate: '60000', + }); + + expect(result.amount).toBe('0.00005'); + expect(result.valueInCurrency).toBe('3'); // 0.00005 * 60000 = 3 + expect(result.usd).toBe('3'); // 0.00005 * 60000 = 3 + }); + + it('should handle missing exchange rates', () => { + const result = calcNonEvmTotalNetworkFee(mockBridgeQuote, {}); + + expect(result.amount).toBe('1'); + expect(result.valueInCurrency).toBeUndefined(); + expect(result.usd).toBeUndefined(); + }); + + it('should handle zero fees', () => { + const result = calcNonEvmTotalNetworkFee( + { ...mockBridgeQuote, nonEvmFeesInNative: '0' }, + { exchangeRate: '2', usdExchangeRate: '1.5' }, + ); + + expect(result.amount).toBe('0'); + expect(result.valueInCurrency).toBe('0'); + expect(result.usd).toBe('0'); + }); + }); + + describe('calcToAmount', () => { + const mockQuote: Quote = { + destTokenAmount: '1000000000', + minDestTokenAmount: '950000000', + destAsset: { decimals: 6 }, + } as Quote; + + it('should calculate destination amount correctly with exchange rates', () => { + const result = calcToAmount( + mockQuote.destTokenAmount, + mockQuote.destAsset, + { + exchangeRate: '2', + usdExchangeRate: '1.5', + }, + ); + + expect(result.amount).toBe('1000'); + expect(result.valueInCurrency).toBe('2000'); + expect(result.usd).toBe('1500'); + }); + + it('should handle missing exchange rates', () => { + const result = calcToAmount( + mockQuote.destTokenAmount, + mockQuote.destAsset, + {}, + ); + + expect(result.amount).toBe('1000'); + expect(result.valueInCurrency).toBeUndefined(); + expect(result.usd).toBeUndefined(); + }); + }); + + describe('calcRelayerFee', () => { + const mockBridgeQuote = getMockBridgeQuotesNativeErc20V1({ + quote: { + srcAsset: { address: '0x123', decimals: 18 }, + srcTokenAmount: '1000000000000000000', + feeData: { + metabridge: { + amount: '10000000000000000', + }, + }, + }, + trade: { value: '0x10A741A462780000' }, + })[0]; + + it('should calculate relayer fee correctly with exchange rates', () => { + const result = calcRelayerFee( + calcSentAmount( + mockBridgeQuote.quote, + { + exchangeRate: '2', + usdExchangeRate: '1.5', + }, + false, + ), + mockBridgeQuote, + { + exchangeRate: '2', + usdExchangeRate: '1.5', + }, + ); + + expect(new BigNumber(mockBridgeQuote.trade.value, 16).toFixed()).toBe( + '1200000000000000000', + ); + + expect(mockBridgeQuote.quote.srcAsset.assetId).toStrictEqual( + mockBridgeQuote.quote.feeData.metabridge.asset?.assetId, + ); + expect(isNativeAddress(mockBridgeQuote.quote.srcAsset.assetId)).toBe( + true, + ); + + expect(result?.amount).toStrictEqual(new BigNumber(0.19).toFixed()); + expect(result?.valueInCurrency).toStrictEqual( + new BigNumber(0.38).toFixed(), + ); + expect(result?.usd).toStrictEqual(new BigNumber(0.285).toFixed()); + }); + + it('should calculate relayer fee correctly with no trade.value', () => { + const mockQuote = getMockBridgeQuotesNativeErc20V1({ + // @ts-expect-error - trade.value is an object + trade: { ...mockBridgeQuote.trade, value: '0x0' }, + })[0]; + + const result = calcRelayerFee( + calcSentAmount( + mockQuote.quote, + { + exchangeRate: '2', + usdExchangeRate: '1.5', + }, + false, + ), + mockQuote, + { + exchangeRate: '2', + usdExchangeRate: '1.5', + }, + ); + + expect(result).toBeUndefined(); + }); + + it('should handle native token address', () => { + const nativeBridgeQuote = getMockBridgeQuotesNativeErc20V1({ + quote: { + srcTokenAmount: '1000000000000000000', + feeData: { + metabridge: { + amount: '100000000000000000', + asset: { + address: AddressZero, + decimals: 18, + assetId: getNativeAssetForChainId(1).assetId, + }, + }, + }, + srcAsset: { + address: AddressZero, + decimals: 18, + assetId: getNativeAssetForChainId(1).assetId, + }, + }, + trade: { + value: '0x10A741A462780000', + }, + })[0]; + + const result = calcRelayerFee( + calcSentAmount( + nativeBridgeQuote.quote, + { + exchangeRate: '2', + usdExchangeRate: '1.5', + }, + false, + ), + nativeBridgeQuote, + { + exchangeRate: '2', + usdExchangeRate: '1.5', + }, + ); + + expect( + convertHexToDecimal(nativeBridgeQuote.trade.value).toString(), + ).toBe('1200000000000000000'); + expect(result).toMatchInlineSnapshot(` + { + "amount": "0.1", + "usd": "0.15", + "valueInCurrency": "0.2", + } + `); + }); + }); + + describe('calcEstimatedAndMaxTotalGasFee', () => { + const mockBridgeQuote: QuoteResponseV1 & L1GasFees = { + quote: {} as Quote, + trade: { gasLimit: 21000 }, + approval: { gasLimit: 46000 }, + l1GasFeesInHexWei: '0x5AF3107A4000', + } as unknown as QuoteResponseV1 & L1GasFees; + + it('should calculate estimated and max gas fees correctly', () => { + const result = calcEstimatedAndMaxTotalGasFee({ + bridgeQuote: mockBridgeQuote, + feePerGasInDecGwei: '52', + exchangeRate: '2000', + usdExchangeRate: '1500', + }); + + expect(result).toMatchInlineSnapshot(` + { + "total": { + "amount": "0.003584", + "usd": "5.376", + "valueInCurrency": "7.168", + }, + } + `); + expect(result?.total?.amount).toBeDefined(); + }); + + it('should calculate estimated and max gas fees correctly when effectiveGas is available', () => { + const result = calcEstimatedAndMaxTotalGasFee({ + bridgeQuote: { + ...mockBridgeQuote, + trade: { gasLimit: 21000, effectiveGas: 10000 }, + approval: { gasLimit: 46000, effectiveGas: 20000 }, + } as QuoteResponseV1 & L1GasFees, + feePerGasInDecGwei: '52', + exchangeRate: '2000', + usdExchangeRate: '1500', + }); + + expect(result).toMatchInlineSnapshot(` + { + "total": { + "amount": "0.003584", + "usd": "5.376", + "valueInCurrency": "7.168", + }, + } + `); + expect(result?.total?.amount).toBeDefined(); + }); + + it('should handle missing exchange rates', () => { + const result = calcEstimatedAndMaxTotalGasFee({ + bridgeQuote: mockBridgeQuote, + feePerGasInDecGwei: '102', + exchangeRate: undefined, + usdExchangeRate: undefined, + }); + + expect(result?.total?.valueInCurrency).toBeUndefined(); + expect(result?.total?.usd).toBeUndefined(); + expect(result?.total?.amount).toBeDefined(); + }); + + it('should handle only display currency exchange rate', () => { + const result = calcEstimatedAndMaxTotalGasFee({ + bridgeQuote: mockBridgeQuote, + feePerGasInDecGwei: '102', + exchangeRate: '2000', + usdExchangeRate: undefined, + }); + + expect(result?.total?.valueInCurrency).toBeDefined(); + expect(result?.total?.usd).toBeUndefined(); + }); + + it('should handle only USD exchange rate', () => { + const result = calcEstimatedAndMaxTotalGasFee({ + bridgeQuote: mockBridgeQuote, + feePerGasInDecGwei: '102', + exchangeRate: undefined, + usdExchangeRate: '1500', + }); + + expect(result?.total?.valueInCurrency).toBeUndefined(); + expect(result?.total?.usd).toBeDefined(); + }); + + it('should handle zero gas limits', () => { + const zeroGasQuote = { + quote: {} as Quote, + trade: { gasLimit: 0 }, + approval: { gasLimit: 0 }, + l1GasFeesInHexWei: '0x0', + estimatedProcessingTimeInSeconds: 60, + } as unknown as QuoteResponseV1 & L1GasFees; + + const result = calcEstimatedAndMaxTotalGasFee({ + bridgeQuote: zeroGasQuote, + feePerGasInDecGwei: '102', + exchangeRate: '2000', + usdExchangeRate: '1500', + }); + + expect(result?.total?.amount).toBeUndefined(); + expect(result?.total?.valueInCurrency).toBeUndefined(); + expect(result?.total?.usd).toBeUndefined(); + }); + + it('should handle missing approval', () => { + const noApprovalQuote = { + quote: {} as Quote, + trade: { gasLimit: 21000 } as TxData, + approval: undefined, + l1GasFeesInHexWei: '0x5AF3107A4000', + estimatedProcessingTimeInSeconds: 60, + } as QuoteResponseV1 & L1GasFees; + + const result = calcEstimatedAndMaxTotalGasFee({ + bridgeQuote: noApprovalQuote, + feePerGasInDecGwei: '102', + exchangeRate: '2000', + usdExchangeRate: '1500', + }); + + expect(result?.total?.amount).toBeDefined(); + }); + + it('should handle missing trade and approval gasLimits, with l1GasFeesInHexWei', () => { + const noGasLimitQuote = { + quote: {} as Quote, + trade: { gasLimit: undefined }, + approval: { gasLimit: undefined }, + l1GasFeesInHexWei: '0x5AF3107A4000', + estimatedProcessingTimeInSeconds: 60, + } as unknown as QuoteResponseV1 & L1GasFees; + + const result = calcEstimatedAndMaxTotalGasFee({ + bridgeQuote: noGasLimitQuote, + feePerGasInDecGwei: '102', + exchangeRate: '2000', + usdExchangeRate: '1500', + }); + + expect(result?.total?.amount).toBeUndefined(); + }); + + it('should handle missing trade gasLimit, with l1GasFeesInHexWei', () => { + const noGasLimitQuote = { + quote: {} as Quote, + trade: { gasLimit: undefined }, + approval: { gasLimit: 46000 }, + l1GasFeesInHexWei: '0x5AF3107A4000', + estimatedProcessingTimeInSeconds: 60, + } as unknown as QuoteResponseV1 & L1GasFees; + + const result = calcEstimatedAndMaxTotalGasFee({ + bridgeQuote: noGasLimitQuote, + feePerGasInDecGwei: '102', + exchangeRate: '2000', + usdExchangeRate: '1500', + }); + + expect(result?.total?.amount).toBe('0.004792'); + }); + + it('should handle missing trade gasLimit, with approval', () => { + const noGasLimitQuote = { + quote: {} as Quote, + trade: { gasLimit: undefined }, + approval: { gasLimit: 46000 }, + estimatedProcessingTimeInSeconds: 60, + } as unknown as QuoteResponseV1 & L1GasFees; + + const result = calcEstimatedAndMaxTotalGasFee({ + bridgeQuote: noGasLimitQuote, + feePerGasInDecGwei: '102', + exchangeRate: '2000', + usdExchangeRate: '1500', + }); + + expect(result?.total?.amount).toBe('0.004692'); + }); + + it('should handle missing trade and approval gasLimit', () => { + const noGasLimitQuote = { + quote: {} as Quote, + trade: { gasLimit: undefined }, + approval: { gasLimit: undefined }, + estimatedProcessingTimeInSeconds: 60, + } as unknown as QuoteResponseV1 & L1GasFees; + + const result = calcEstimatedAndMaxTotalGasFee({ + bridgeQuote: noGasLimitQuote, + feePerGasInDecGwei: '102', + exchangeRate: '2000', + usdExchangeRate: '1500', + }); + + expect(result?.total?.amount).toBeUndefined(); + }); + + it('should handle large gas limits and fees', () => { + const largeGasQuote = { + quote: {} as Quote, + trade: { gasLimit: 1000000 } as TxData, + approval: { gasLimit: 500000 } as TxData, + l1GasFeesInHexWei: '0x1BC16D674EC80000', // 2 ETH in wei + estimatedProcessingTimeInSeconds: 60, + } as QuoteResponseV1 & L1GasFees; + + const result = calcEstimatedAndMaxTotalGasFee({ + bridgeQuote: largeGasQuote, + feePerGasInDecGwei: '210', + exchangeRate: '3000', + usdExchangeRate: '2500', + }); + + expect(parseFloat(result?.total?.amount ?? '0')).toBeGreaterThan(2); // Should be > 2 ETH due to L1 fees + expect(result?.total?.valueInCurrency).toBeDefined(); + expect(result?.total?.usd).toBeDefined(); + expect( + parseFloat((result?.total?.valueInCurrency as string) ?? '0'), + ).toBeGreaterThan(6000); + expect(parseFloat((result?.total?.usd as string) ?? '0')).toBeGreaterThan( + 5000, + ); + }); + }); + + describe('formatEtaInMinutes', () => { + it('should format seconds less than 60 as "< 1"', () => { + expect(formatEtaInMinutes(30)).toBe('< 1'); + expect(formatEtaInMinutes(59)).toBe('< 1'); + }); + + it('should correctly format minutes for values >= 60 seconds', () => { + expect(formatEtaInMinutes(60)).toBe('1'); + expect(formatEtaInMinutes(120)).toBe('2'); + expect(formatEtaInMinutes(150)).toBe('3'); + }); + + it('should handle large values', () => { + expect(formatEtaInMinutes(3600)).toBe('60'); + }); + }); + + describe('calcSwapRate', () => { + it('should calculate correct swap rate', () => { + expect(calcSwapRate('1', '2')).toBe('2'); + expect(calcSwapRate('2', '1')).toBe('0.5'); + expect(calcSwapRate('100', '250')).toBe('2.5'); + }); + + it('should handle large numbers', () => { + expect(calcSwapRate('1000000000000000000', '2000000000000000000')).toBe( + '2', + ); + }); + }); + + describe('calcTotalEstimatedNetworkFee', () => { + const mockGasFee = { + effective: { amount: '0.1', valueInCurrency: '200', usd: '150' }, + total: { amount: '0.1', valueInCurrency: '200', usd: '150' }, + max: { amount: '0.2', valueInCurrency: '400', usd: '300' }, + }; + + const mockRelayerFee = { + amount: '0.05', + valueInCurrency: '100', + usd: '75', + }; + + it('should calculate total estimated network fee correctly', () => { + const result = calcTotalEstimatedNetworkFee(mockGasFee, mockRelayerFee); + + expect(result.amount).toBe('0.15'); + expect(result.valueInCurrency).toBe('300'); + expect(result.usd).toBe('225'); + }); + + it('should calculate total estimated network fee correctly with no relayer fee', () => { + const result = calcTotalEstimatedNetworkFee(mockGasFee, { + amount: '0', + valueInCurrency: undefined, + usd: undefined, + }); + + expect(result.amount).toBe('0.1'); + expect(result.valueInCurrency).toBe('200'); + expect(result.usd).toBe('150'); + }); + }); + + describe('calcAdjustedReturn', () => { + const mockToAmount = { + amount: '1000', + valueInCurrency: '1000', + usd: '750', + }; + + const mockNetworkFee = { + amount: '48', + valueInCurrency: '100', + usd: '75', + }; + + const mockQuote = { + feeData: { + txFee: { + asset: { + assetId: + 'eip155:1/erc20:0x0000000000000000000000000000000000000000', + }, + }, + }, + destAsset: { + assetId: 'eip155:10/erc20:0x0000000000000000000000000000000000000000', + }, + } as unknown as Quote; + it('should calculate adjusted return correctly', () => { + const result = calcAdjustedReturn( + mockToAmount, + mockNetworkFee, + mockQuote, + ); + + expect(result.valueInCurrency).toBe('900'); + expect(result.usd).toBe('675'); + }); + + it('should handle null values', () => { + const result = calcAdjustedReturn( + { amount: '1000', valueInCurrency: undefined, usd: undefined }, + mockNetworkFee, + mockQuote, + ); + + expect(result.valueInCurrency).toBeUndefined(); + expect(result.usd).toBeUndefined(); + }); + }); + + describe('calcCost', () => { + const mockAdjustedReturn = { + amount: '1000', + valueInCurrency: '900', + usd: '675', + }; + + const mockSentAmount = { + amount: '100111', + valueInCurrency: '1000', + usd: '750', + }; + + it('should calculate cost correctly', () => { + const result = calcCost(mockAdjustedReturn, mockSentAmount); + + expect(result.valueInCurrency).toBe('100'); + expect(result.usd).toBe('75'); + }); + + it('should handle null values', () => { + const result = calcCost( + { valueInCurrency: undefined, usd: undefined }, + mockSentAmount, + ); + + expect(result.valueInCurrency).toBeUndefined(); + expect(result.usd).toBeUndefined(); + }); + }); + + describe('calcSlippagePercentage', () => { + it.each([ + ['100', undefined, '100', undefined, '0'], + ['95', '95', '100', '100', '5'], + ['98.3', '98.3', '100', '100', '1.7'], + [undefined, '100', undefined, '100', '0'], + [undefined, undefined, undefined, '100', null], + ['105', '105', '100', '100', '5'], + ])( + 'calcSlippagePercentage: calculate slippage absolute value for received amount %p, usd %p, sent amount %p, usd %p to expected slippage %p', + ( + returnValueInCurrency: string | undefined, + returnUsd: string | undefined, + sentValueInCurrency: string | undefined, + sentUsd: string | undefined, + expectedSlippage: string | undefined | null, + ) => { + const result = calcSlippagePercentage( + { + valueInCurrency: returnValueInCurrency, + usd: returnUsd, + }, + { + amount: '1000', + valueInCurrency: sentValueInCurrency, + usd: sentUsd, + }, + ); + expect(result).toBe(expectedSlippage); + }, + ); + + it('should handle edge case with zero values', () => { + const result = calcSlippagePercentage( + { valueInCurrency: '0', usd: '0' }, + { amount: '100', valueInCurrency: '100', usd: '100' }, + ); + expect(result).toBe('100'); + }); + }); + + describe('calcPriceImpact', () => { + it('returns undefined when activeQuote is null', () => { + expect(calcPriceImpact(null)).toBeUndefined(); + }); + + it('returns undefined when activeQuote is undefined', () => { + expect(calcPriceImpact(undefined)).toBeUndefined(); + }); + + it('returns undefined when sentAmount.valueInCurrency is null', () => { + expect( + calcPriceImpact({ + sentAmount: { valueInCurrency: undefined }, + toTokenAmount: { valueInCurrency: '900' }, + }), + ).toMatchInlineSnapshot(`undefined`); + }); + + it('returns undefined when toTokenAmount.valueInCurrency is undefined', () => { + expect( + calcPriceImpact({ + sentAmount: { valueInCurrency: '1000' }, + toTokenAmount: { valueInCurrency: undefined }, + }), + ).toMatchInlineSnapshot(`undefined`); + }); + + it('returns undefined when sentAmount is missing', () => { + expect( + calcPriceImpact({ + sentAmount: {}, + toTokenAmount: { valueInCurrency: '900' }, + }), + ).toBeUndefined(); + }); + + it('returns undefined when toTokenAmount is missing', () => { + expect( + calcPriceImpact({ + sentAmount: { valueInCurrency: '1000' }, + toTokenAmount: {}, + }), + ).toBeUndefined(); + }); + + it('formats the absolute difference between source and destination fiat amounts', () => { + const result = calcPriceImpact({ + sentAmount: { valueInCurrency: '1000', usd: '995.77' }, + toTokenAmount: { valueInCurrency: '995.77', usd: '1000' }, + }); + expect(result).toMatchInlineSnapshot(` + { + "usd": "4.23", + "valueInCurrency": "4.23", + } + `); + }); + + it('uses the absolute value so a favourable quote does not produce a negative result', () => { + const result = calcPriceImpact({ + sentAmount: { valueInCurrency: '900' }, + toTokenAmount: { valueInCurrency: '1000' }, + }); + expect(result).toMatchInlineSnapshot(` + { + "usd": undefined, + "valueInCurrency": "100", + } + `); + }); + + it('handles string numeric inputs', () => { + const result = calcPriceImpact({ + sentAmount: { valueInCurrency: '500.50', usd: '5' }, + toTokenAmount: { valueInCurrency: '496.27' }, + }); + expect(result).toMatchInlineSnapshot(` + { + "usd": undefined, + "valueInCurrency": "4.23", + } + `); + }); + + it('handles numeric inputs', () => { + const result = calcPriceImpact({ + sentAmount: { valueInCurrency: '1000', usd: '1.5' }, + toTokenAmount: { valueInCurrency: '10', usd: '2.49' }, + }); + expect(result).toMatchInlineSnapshot(` + { + "usd": "0.99", + "valueInCurrency": "990", + } + `); + }); + + it('handles NaN inputs', () => { + const result = calcPriceImpact({ + sentAmount: { valueInCurrency: 'a', usd: '-1.5' }, + toTokenAmount: { valueInCurrency: '10', usd: '2.49' }, + }); + expect(result).toMatchInlineSnapshot(` + { + "usd": "3.99", + "valueInCurrency": undefined, + } + `); + }); + }); +}); diff --git a/packages/bridge-controller/src/utils/quote-metadata/calculators.ts b/packages/bridge-controller/src/utils/quote-metadata/calculators.ts new file mode 100644 index 00000000000..ae15425e7d3 --- /dev/null +++ b/packages/bridge-controller/src/utils/quote-metadata/calculators.ts @@ -0,0 +1,535 @@ +/* eslint-disable @typescript-eslint/explicit-function-return-type */ +import { + convertHexToDecimal, + toHex, + weiHexToGweiDec, +} from '@metamask/controller-utils'; +import { is } from '@metamask/superstruct'; +import { BigNumber } from 'bignumber.js'; + +import { toQuoteResponseV1 } from '../../coercers/quote-response-v2-to-v1.js'; +import type { + L1GasFees, + ExchangeRate, + NonEvmFees, + DeepPartial, +} from '../../types.js'; +import type { BridgeAsset } from '../../validators/bridge-asset.js'; +import { FloatStringSchema } from '../../validators/number.js'; +import type { QuoteResponseV1 } from '../../validators/quote-response-v1.js'; +import { isQuoteResponseV2 } from '../../validators/quote-response.js'; +import type { QuoteResponse } from '../../validators/quote-response.js'; +import type { TxData } from '../../validators/trade.js'; +import { assetIdsMatch } from '../assets.js'; +import { isEvmQuoteResponse, isNativeAddress } from '../bridge.js'; +import { calcNormalizedTokenAmount } from '../number-formatters.js'; +import { includeIfTruthy } from './include-if-truthy.js'; +import type { QuoteMetadata, TokenAmountValues } from './types.js'; + +export const calcNonEvmTotalNetworkFee = ( + bridgeQuote: QuoteResponseV1 & NonEvmFees, + { exchangeRate, usdExchangeRate }: ExchangeRate, +) => { + const { nonEvmFeesInNative } = bridgeQuote; + // Fees are now stored directly in native units (SOL, BTC) without conversion + const feeInNative = nonEvmFeesInNative + ? new BigNumber(nonEvmFeesInNative) + : undefined; + + return { + amount: feeInNative?.toFixed(), + valueInCurrency: exchangeRate && feeInNative?.times(exchangeRate).toFixed(), + usd: usdExchangeRate && feeInNative?.times(usdExchangeRate).toFixed(), + }; +}; + +export const calcToAmount = ( + destTokenAmount: string | undefined, + destAsset: BridgeAsset, + { exchangeRate, usdExchangeRate }: ExchangeRate, +) => { + const normalizedDestAmount = calcNormalizedTokenAmount( + destTokenAmount, + destAsset.decimals, + ); + return { + amount: normalizedDestAmount?.toFixed(), + valueInCurrency: + exchangeRate && normalizedDestAmount?.times(exchangeRate).toFixed(), + usd: + usdExchangeRate && normalizedDestAmount?.times(usdExchangeRate).toFixed(), + }; +}; + +export const calcSentAmount = ( + { srcTokenAmount, srcAsset, feeData, intent }: QuoteResponseV1['quote'], + { exchangeRate, usdExchangeRate }: ExchangeRate, + isQuoteV2: boolean = false, +) => { + // For intent-based swaps or converted V2 quote responses, srcTokenAmount is the total + // fixed commitment the user makes to the protocol — the protocol fee is + // already baked in. Adding feeData fees on top would double-count them. + // For conventional swaps, srcTokenAmount is the net routing amount (fees + // excluded), so the src-token fees must be added to get the wallet deduction. + const sentAmount = + intent || isQuoteV2 + ? new BigNumber(srcTokenAmount) + : Object.values(feeData) + .filter( + (fee) => + fee?.amount && + assetIdsMatch(fee.asset?.assetId, srcAsset.assetId), + ) + .reduce( + (acc, { amount }) => acc.plus(amount), + new BigNumber(srcTokenAmount), + ); + const normalizedSentAmount = calcNormalizedTokenAmount( + sentAmount, + srcAsset.decimals, + ); + return { + amount: normalizedSentAmount?.toFixed(), + valueInCurrency: + exchangeRate && normalizedSentAmount?.times(exchangeRate).toFixed(), + usd: + usdExchangeRate && normalizedSentAmount?.times(usdExchangeRate).toFixed(), + }; +}; + +export const calcBatchFees = ( + amount: string, + asset: BridgeAsset, + { exchangeRate, usdExchangeRate }: ExchangeRate, +) => { + const normalizedAmount = calcNormalizedTokenAmount(amount, asset.decimals); + + return { + amount: normalizedAmount?.toFixed(), + valueInCurrency: exchangeRate + ? normalizedAmount?.times(exchangeRate).toFixed() + : null, + usd: usdExchangeRate + ? normalizedAmount?.times(usdExchangeRate).toFixed() + : null, + asset, + }; +}; + +export const calcRelayerFee = ( + sentAmount: ReturnType, + quoteResponse: QuoteResponseV1, + { exchangeRate, usdExchangeRate }: ExchangeRate, +) => { + const { quote, trade } = quoteResponse; + const relayerFeeAmount = trade.value + ? new BigNumber(convertHexToDecimal(trade.value)) + : undefined; + let relayerFeeInNative = relayerFeeAmount + ? calcNormalizedTokenAmount(relayerFeeAmount, 18) + : undefined; + + // Subtract srcAmount and other fees from trade value if srcAsset is native + if (isNativeAddress(quote.srcAsset.assetId)) { + relayerFeeInNative = relayerFeeInNative?.minus(sentAmount.amount ?? '0'); + } + + if (relayerFeeInNative?.lte(0)) { + return undefined; + } + + return { + amount: relayerFeeInNative?.toFixed(), + valueInCurrency: + exchangeRate && relayerFeeInNative?.times(exchangeRate).toFixed(), + usd: + usdExchangeRate && relayerFeeInNative?.times(usdExchangeRate).toFixed(), + }; +}; + +const calcTotalGasFee = ({ + approvalGasLimit, + resetApprovalGasLimit, + tradeGasLimit, + l1GasFeesInHexWei, + feePerGasInDecGwei, + nativeToDisplayCurrencyExchangeRate, + nativeToUsdExchangeRate, +}: { + approvalGasLimit?: number | null; + resetApprovalGasLimit?: number | null; + tradeGasLimit?: number | null; + l1GasFeesInHexWei?: string | null; + feePerGasInDecGwei?: string; + nativeToDisplayCurrencyExchangeRate?: string; + nativeToUsdExchangeRate?: string; +}) => { + const totalGasLimitInDec = + tradeGasLimit || approvalGasLimit || resetApprovalGasLimit + ? new BigNumber(tradeGasLimit?.toFixed() ?? '0') + .plus(approvalGasLimit?.toFixed() ?? '0') + .plus(resetApprovalGasLimit?.toFixed() ?? '0') + : undefined; + + const l1GasFeesInDecGWei = l1GasFeesInHexWei + ? weiHexToGweiDec(toHex(l1GasFeesInHexWei)) + : undefined; + + const gasFeesInDecGwei = totalGasLimitInDec + ? totalGasLimitInDec + ?.times(feePerGasInDecGwei ?? '0') + ?.plus(l1GasFeesInDecGWei ?? '0') + : undefined; + const gasFeesInDecEth = gasFeesInDecGwei?.times(new BigNumber(10).pow(-9)); + + const gasFeesInDisplayCurrency = nativeToDisplayCurrencyExchangeRate + ? gasFeesInDecEth?.times(nativeToDisplayCurrencyExchangeRate) + : undefined; + const gasFeesInUSD = nativeToUsdExchangeRate + ? gasFeesInDecEth?.times(nativeToUsdExchangeRate) + : undefined; + + return { + amount: gasFeesInDecEth?.toFixed(), + valueInCurrency: gasFeesInDisplayCurrency?.toFixed(), + usd: gasFeesInUSD?.toFixed(), + }; +}; + +export const calcEstimatedAndMaxTotalGasFee = ({ + bridgeQuote: { approval, trade, l1GasFeesInHexWei, resetApproval }, + feePerGasInDecGwei, + exchangeRate: nativeToDisplayCurrencyExchangeRate, + usdExchangeRate: nativeToUsdExchangeRate, +}: { + bridgeQuote: QuoteResponseV1 & L1GasFees; + feePerGasInDecGwei?: string; +} & ExchangeRate) => { + // Estimated total gas fee, including refunded fees (medium) + const { amount, valueInCurrency, usd } = calcTotalGasFee({ + approvalGasLimit: approval?.gasLimit, + resetApprovalGasLimit: resetApproval?.gasLimit, + tradeGasLimit: trade?.gasLimit, + l1GasFeesInHexWei, + feePerGasInDecGwei, + nativeToDisplayCurrencyExchangeRate, + nativeToUsdExchangeRate, + }); + + return { + total: { + amount, + valueInCurrency, + usd, + }, + }; +}; + +/** + * Calculates the total estimated network fees for the bridge transaction + * + * @param gasFee - The gas fee for the bridge transaction + * @param gasFee.total - The fee to display to the user. If not available, this is equal to the gasLimit (total) + * @param relayerFee - The relayer fee paid to bridge providers + * @returns The total estimated network fee for the bridge transaction, including the relayer fee paid to bridge providers + */ +export const calcTotalEstimatedNetworkFee = ( + gasFee: { total?: Partial } | undefined, + relayerFee: ReturnType, +) => { + const { total: gasFeeToDisplay } = gasFee ?? {}; + return { + amount: + (gasFeeToDisplay?.amount ?? relayerFee?.amount) && + new BigNumber(gasFeeToDisplay?.amount ?? '0') + .plus(relayerFee?.amount ?? '0') + .toFixed(), + valueInCurrency: + (gasFeeToDisplay?.valueInCurrency ?? relayerFee?.valueInCurrency) && + new BigNumber(gasFeeToDisplay?.valueInCurrency ?? '0') + .plus(relayerFee?.valueInCurrency ?? '0') + .toFixed(), + usd: + (gasFeeToDisplay?.usd ?? relayerFee?.usd) && + new BigNumber(gasFeeToDisplay?.usd ?? '0') + .plus(relayerFee?.usd ?? '0') + .toFixed(), + }; +}; + +// Gas is included for some swap quotes and this is the value displayed in the client +export const calcIncludedTxFees = ( + { + gasIncluded, + gasIncluded7702, + srcAsset, + feeData: { txFee }, + }: QuoteResponseV1['quote'], + srcTokenExchangeRate: ExchangeRate, + destTokenExchangeRate: ExchangeRate, +) => { + if (!txFee || !(gasIncluded || gasIncluded7702)) { + return undefined; + } + // Use exchange rate of the token that is being used to pay for the transaction + const { exchangeRate, usdExchangeRate } = assetIdsMatch( + txFee?.asset?.assetId, + srcAsset.assetId, + ) + ? srcTokenExchangeRate + : destTokenExchangeRate; + const normalizedTxFeeAmount = calcNormalizedTokenAmount( + txFee?.amount, + txFee?.asset.decimals, + ); + + return { + amount: normalizedTxFeeAmount?.toFixed(), + valueInCurrency: + exchangeRate && normalizedTxFeeAmount?.times(exchangeRate).toFixed(), + usd: + usdExchangeRate && + normalizedTxFeeAmount?.times(usdExchangeRate).toFixed(), + }; +}; + +export const calcAdjustedReturn = ( + toTokenAmount: Partial, + totalEstimatedNetworkFee: Partial, + { + feeData: { txFee }, + destAsset: { assetId: destAssetId }, + }: QuoteResponseV1['quote'], +) => { + // If gas is included and is taken from the dest token, don't subtract network fee from return + if (assetIdsMatch(txFee?.asset?.assetId, destAssetId)) { + return { + valueInCurrency: toTokenAmount.valueInCurrency, + usd: toTokenAmount.usd, + }; + } + return { + valueInCurrency: + toTokenAmount.valueInCurrency && + totalEstimatedNetworkFee.valueInCurrency && + new BigNumber(toTokenAmount.valueInCurrency) + .minus(totalEstimatedNetworkFee.valueInCurrency) + .toFixed(), + usd: + toTokenAmount.usd && + totalEstimatedNetworkFee.usd && + new BigNumber(toTokenAmount.usd) + .minus(totalEstimatedNetworkFee.usd) + .toFixed(), + }; +}; + +export const calcSwapRate = (sentAmount?: string, destTokenAmount?: string) => + destTokenAmount && sentAmount + ? new BigNumber(destTokenAmount).div(sentAmount).toFixed() + : undefined; + +export const calcCost = ( + adjustedReturn: ReturnType, + sentAmount: ReturnType, +) => ({ + valueInCurrency: + adjustedReturn.valueInCurrency && + sentAmount.valueInCurrency && + new BigNumber(sentAmount.valueInCurrency) + .minus(adjustedReturn.valueInCurrency) + .toFixed(), + usd: + adjustedReturn.usd && + sentAmount.usd && + new BigNumber(sentAmount.usd).minus(adjustedReturn.usd).toFixed(), +}); + +/** + * Calculates the slippage absolute value percentage based on the adjusted return and sent amount. + * + * @param adjustedReturn - Adjusted return value + * @param sentAmount - Sent amount value + * @returns the slippage in percentage + */ +export const calcSlippagePercentage = ( + adjustedReturn: ReturnType, + sentAmount: ReturnType, +): string | null => { + const cost = calcCost(adjustedReturn, sentAmount); + + if (cost.valueInCurrency && sentAmount.valueInCurrency) { + return new BigNumber(cost.valueInCurrency) + .div(sentAmount.valueInCurrency) + .times(100) + .abs() + .toFixed(); + } + + if (cost.usd && sentAmount.usd) { + return new BigNumber(cost.usd) + .div(sentAmount.usd) + .times(100) + .abs() + .toFixed(); + } + + return null; +}; + +/** + * Returns the fiat price impact for a bridge quote — the difference between + * the source input fiat amount and the destination output fiat amount + * + * @param quote - The active quote + * @returns Formatted fiat impact string, or `undefined` when either fiat value is unavailable. + */ +export const calcPriceImpact = ( + quote?: DeepPartial< + Pick + > | null, +) => { + if (!quote?.sentAmount || !quote?.toTokenAmount) { + return undefined; + } + + const sourceFiat = quote.sentAmount.valueInCurrency; + const destFiat = quote.toTokenAmount.valueInCurrency; + const sourceUsd = quote.sentAmount.usd; + const destUsd = quote.toTokenAmount.usd; + + const isSourceFiatValid = (value: unknown): value is string[] => + is(value, FloatStringSchema); + + const valueInCurrency = + isSourceFiatValid(sourceFiat) && isSourceFiatValid(destFiat) + ? new BigNumber(sourceFiat).minus(destFiat).abs().toFixed() + : undefined; + const usd = + isSourceFiatValid(sourceUsd) && isSourceFiatValid(destUsd) + ? new BigNumber(sourceUsd).minus(destUsd).abs().toFixed() + : undefined; + + if (!valueInCurrency && !usd) { + return undefined; + } + + return { + valueInCurrency, + usd, + }; +}; + +/** + * Calculates quote metadata, such as converted fiat amounts and fees, + * based on the controller state and the quote response + * + * @param quote - The quote response to calculate the metadata for + * @param options - The options for the calculation + * @param options.bridgeFeesPerGas - The bridge fees per gas + * @param options.srcTokenExchangeRate - The exchange rate for the source token + * @param options.destTokenExchangeRate - The exchange rate for the destination token + * @param options.nativeExchangeRate - The exchange rate for the native token + * @returns The calculated metadata + */ +export const calcQuoteMetadata = ( + quote: QuoteResponseV1 | QuoteResponse, + options: { + bridgeFeesPerGas: null | { + estimatedBaseFeeInDecGwei: string | null; + feePerGasInDecGwei?: string; + }; + srcTokenExchangeRate: ExchangeRate; + destTokenExchangeRate: ExchangeRate; + nativeExchangeRate: ExchangeRate; + }, +): QuoteMetadata => { + const { + bridgeFeesPerGas = {}, + srcTokenExchangeRate = {}, + destTokenExchangeRate = {}, + nativeExchangeRate = {}, + } = options; + + const isQuoteV2 = isQuoteResponseV2(quote); + const quoteV1 = isQuoteV2 ? toQuoteResponseV1(quote) : quote; + + const sentAmount = calcSentAmount( + quoteV1.quote, + srcTokenExchangeRate, + isQuoteV2, + ); + + const toTokenAmount = calcToAmount( + quoteV1.quote.destTokenAmount, + quoteV1.quote.destAsset, + destTokenExchangeRate, + ); + const minToTokenAmount = calcToAmount( + quoteV1.quote.minDestTokenAmount ?? quoteV1.quote.destTokenAmount, + quoteV1.quote.destAsset, + destTokenExchangeRate, + ); + + const includedTxFees = calcIncludedTxFees( + quoteV1.quote, + srcTokenExchangeRate, + destTokenExchangeRate, + ); + + let totalEstimatedNetworkFee, relayerFee, gasFee; + + if (isEvmQuoteResponse(quoteV1)) { + relayerFee = calcRelayerFee(sentAmount, quoteV1, nativeExchangeRate); + gasFee = calcEstimatedAndMaxTotalGasFee({ + bridgeQuote: quoteV1, + ...bridgeFeesPerGas, + ...nativeExchangeRate, + }); + // Uses total gasFee to calculate the total estimated network fee + totalEstimatedNetworkFee = calcTotalEstimatedNetworkFee(gasFee, relayerFee); + } else { + // Use the new generic function for all non-EVM chains + totalEstimatedNetworkFee = calcNonEvmTotalNetworkFee( + quoteV1, + nativeExchangeRate, + ); + gasFee = { + total: totalEstimatedNetworkFee, + }; + } + + const adjustedReturn = calcAdjustedReturn( + toTokenAmount, + totalEstimatedNetworkFee, + quoteV1.quote, + ); + const cost = calcCost(adjustedReturn, sentAmount); + + // The quote has not been updated at this point, so we need to calculate the price impact using sentAmount and toTokenAmount + const priceImpact = calcPriceImpact({ sentAmount, toTokenAmount }); + + return { + sentAmount, + toTokenAmount, + minToTokenAmount, + swapRate: calcSwapRate(sentAmount.amount, toTokenAmount.amount), + /** + This is the amount required to submit all the transactions. + Includes the relayer fee or other native fees. + Should be used for balance checks and tx submission. + */ + totalNetworkFee: totalEstimatedNetworkFee, + /** + This contains gas fee estimates for the bridge transaction + Does not include the relayer fee (if needed), just the gasLimit and effectiveGas returned by the bridge API. + Should only be used for display purposes. + */ + gasFee, + ...includeIfTruthy(adjustedReturn, { adjustedReturn }), + ...includeIfTruthy(cost, { cost }), + ...includeIfTruthy(includedTxFees, { includedTxFees }), + ...includeIfTruthy(relayerFee, { relayerFee }), + ...includeIfTruthy(priceImpact, { priceImpact }), + }; +}; diff --git a/packages/bridge-controller/src/utils/quote-metadata/include-if-truthy.ts b/packages/bridge-controller/src/utils/quote-metadata/include-if-truthy.ts new file mode 100644 index 00000000000..4e2cf9becdb --- /dev/null +++ b/packages/bridge-controller/src/utils/quote-metadata/include-if-truthy.ts @@ -0,0 +1,21 @@ +/** + * Includes the `result` if any of `value`'s properties are truthy + * + * @param value - The value object + * @param result - The result to include + * @returns The result if any of the values in the value object are truthy, otherwise undefined + */ +export const includeIfTruthy = >( + value: Record | undefined, + result: ResultType, +): ResultType | undefined => { + if (!value) { + return undefined; + } + + if (Object.values(value).some(Boolean)) { + return result; + } + + return undefined; +}; diff --git a/packages/bridge-controller/src/utils/quote-metadata/merge.test.ts b/packages/bridge-controller/src/utils/quote-metadata/merge.test.ts new file mode 100644 index 00000000000..820f77c0067 --- /dev/null +++ b/packages/bridge-controller/src/utils/quote-metadata/merge.test.ts @@ -0,0 +1,310 @@ +import { merge } from 'lodash'; + +import { getMockBridgeQuotesErc20Erc20V2 } from '../../../tests/mock-quotes-erc20-erc20.js'; +import { + getNativeAssetForChainId, + toBridgeAssetV2, + toQuoteMetadataV2, +} from '../../index.js'; +import type { QuoteResponse } from '../../validators/quote-response.js'; +import { mergeQuoteMetadata } from './merge.js'; +import { toNormalizedAmounts } from './to-normalized-amounts.js'; +import { QuoteMetadataMigrationPhase } from './types.js'; +import type { QuoteMetadata } from './types.js'; + +const EMPTY_QUOTE = { + quote: { + src: { + normalizedAmount: undefined, + usd: undefined, + valueInCurrency: undefined, + amount: undefined, + }, + dest: { + minAmountNormalized: undefined, + normalizedAmount: undefined, + amount: undefined, + minAmount: undefined, + minAmountUsd: undefined, + minAmountValueInCurrency: undefined, + usd: undefined, + valueInCurrency: undefined, + }, + feeData: { + network: undefined, + relayer: undefined, + txFee: undefined, + }, + priceData: { + swapRate: undefined, + }, + }, +}; + +const quoteResponseV2 = getMockBridgeQuotesErc20Erc20V2()[0]; +const normalizedAmounts = toNormalizedAmounts(quoteResponseV2); + +const v2PartialMetadata = { + quote: { + feeData: { + relayer: [ + { + amount: '100', + usd: '100', + asset: toBridgeAssetV2(getNativeAssetForChainId(10)), + }, + ], + txFee: [ + { + amount: '100', + usd: '100', + asset: { + decimals: 18, + assetId: + 'eip155:10/erc20:0x0b2c639c533813f4aa9d7837caf62653d097ff85', + }, + }, + ], + }, + }, +}; + +const v2Metadata = merge({}, v2PartialMetadata, { + quote: { + feeData: { + network: [ + { + amount: '100', + usd: '100', + asset: toBridgeAssetV2(getNativeAssetForChainId(10)), + }, + ], + }, + }, +}); + +const partialLegacyQuoteMetadata = { + relayerFee: { + amount: '0.000000000000000105', + valueInCurrency: '100', + usd: '10', + }, + includedTxFees: { + amount: '0.000000000000000105', + valueInCurrency: '100', + usd: '10', + }, +}; + +const legacyQuoteMetadata = { + ...partialLegacyQuoteMetadata, + totalNetworkFee: { + amount: '0.0000000000000004', + usd: '400', + valueInCurrency: '401', + }, +}; + +describe('mergeQuoteMetadata', () => { + // PHASE 1 + it.each([ + { + title: 'includes normalized amounts when quoteMetadata is empty', + quoteResponse: quoteResponseV2, + quoteMetadata: {}, + mergedQuote: merge({}, EMPTY_QUOTE, quoteResponseV2, normalizedAmounts), + }, + { + title: 'omits network fee when legacy metadata has no totalNetworkFee', + quoteResponse: merge({}, quoteResponseV2, v2Metadata), + quoteMetadata: partialLegacyQuoteMetadata, + mergedQuote: merge( + {}, + EMPTY_QUOTE, + quoteResponseV2, + partialLegacyQuoteMetadata, + normalizedAmounts, + toQuoteMetadataV2( + partialLegacyQuoteMetadata, + merge({}, quoteResponseV2, v2Metadata), + ), + ), + }, + { + title: 'includes network fee when legacy metadata has totalNetworkFee', + quoteResponse: merge({}, quoteResponseV2, v2PartialMetadata), + quoteMetadata: legacyQuoteMetadata, + mergedQuote: merge( + {}, + EMPTY_QUOTE, + quoteResponseV2, + legacyQuoteMetadata, + normalizedAmounts, + toQuoteMetadataV2( + legacyQuoteMetadata, + merge({}, quoteResponseV2, v2PartialMetadata), + ), + ), + }, + { + title: 'replaces network fee when legacy metadata has totalNetworkFee', + quoteResponse: merge({}, quoteResponseV2, v2Metadata), + quoteMetadata: legacyQuoteMetadata, + mergedQuote: merge( + {}, + EMPTY_QUOTE, + quoteResponseV2, + legacyQuoteMetadata, + normalizedAmounts, + toQuoteMetadataV2( + legacyQuoteMetadata, + merge({}, quoteResponseV2, v2Metadata), + ), + ), + }, + { + title: 'includes empty quote response when quoteResponse is invalid', + quoteResponse: { a: 1 }, + quoteMetadata: { b: 2 } as QuoteMetadata, + mergedQuote: { a: 1, b: 2, ...EMPTY_QUOTE }, + }, + ])( + 'merged quote $title (Phase 1)', + ({ quoteResponse, quoteMetadata, mergedQuote }) => { + expect( + mergeQuoteMetadata( + quoteResponse as QuoteResponse, + quoteMetadata, + QuoteMetadataMigrationPhase.V1Data, + ), + ).toStrictEqual(mergedQuote); + }, + ); + + // PHASE 1.5 + it.each([ + { + title: 'includes normalized amounts when quoteMetadata is empty', + quoteResponse: quoteResponseV2, + quoteMetadata: {}, + mergedQuote: merge({}, EMPTY_QUOTE, quoteResponseV2, normalizedAmounts), + }, + { + title: 'includes nested network fee', + quoteResponse: merge({}, quoteResponseV2, v2Metadata), + quoteMetadata: partialLegacyQuoteMetadata, + mergedQuote: merge( + {}, + EMPTY_QUOTE, + quoteResponseV2, + partialLegacyQuoteMetadata, + toQuoteMetadataV2( + partialLegacyQuoteMetadata, + merge({}, quoteResponseV2, v2Metadata), + ), + toNormalizedAmounts(merge({}, quoteResponseV2, v2Metadata)), + v2Metadata, + ), + }, + { + title: 'includes legacy network fee when nested network fee is undefined', + quoteResponse: merge({}, quoteResponseV2, v2PartialMetadata), + quoteMetadata: legacyQuoteMetadata, + mergedQuote: merge( + {}, + EMPTY_QUOTE, + quoteResponseV2, + legacyQuoteMetadata, + toQuoteMetadataV2( + legacyQuoteMetadata, + merge({}, quoteResponseV2, v2PartialMetadata), + ), + toNormalizedAmounts(merge({}, quoteResponseV2, v2PartialMetadata)), + v2PartialMetadata, + ), + }, + { + title: 'replaces legacy network fee when metadata has network fee', + quoteResponse: merge({}, quoteResponseV2, v2Metadata), + quoteMetadata: legacyQuoteMetadata, + mergedQuote: merge( + {}, + EMPTY_QUOTE, + quoteResponseV2, + legacyQuoteMetadata, + toQuoteMetadataV2( + legacyQuoteMetadata, + merge({}, quoteResponseV2, v2Metadata), + ), + toNormalizedAmounts(merge({}, quoteResponseV2, v2Metadata)), + v2Metadata, + ), + }, + { + title: 'includes empty quote response when quoteResponse is invalid', + quoteResponse: { a: 1 }, + quoteMetadata: { b: 2 } as QuoteMetadata, + mergedQuote: { a: 1, b: 2, ...EMPTY_QUOTE }, + }, + ])( + 'merged quote $title (Phase 1.5)', + ({ quoteResponse, quoteMetadata, mergedQuote }) => { + expect( + mergeQuoteMetadata( + quoteResponse as QuoteResponse, + quoteMetadata, + QuoteMetadataMigrationPhase.V2WithV1Fallback, + ), + ).toStrictEqual(mergedQuote); + }, + ); + + // PHASE 2 + it.each([ + { + title: 'includes normalized amounts when quoteMetadata is empty', + quoteResponse: quoteResponseV2, + quoteMetadata: {}, + mergedQuote: merge({}, quoteResponseV2, normalizedAmounts), + }, + { + title: 'only includes nested network fee', + quoteResponse: merge({}, quoteResponseV2, v2Metadata), + quoteMetadata: legacyQuoteMetadata, + mergedQuote: merge( + {}, + quoteResponseV2, + v2Metadata, + toNormalizedAmounts(merge({}, quoteResponseV2, v2Metadata)), + ), + }, + { + title: 'excludes network fee when nested network fee is undefined', + quoteResponse: merge({}, quoteResponseV2, v2PartialMetadata), + quoteMetadata: legacyQuoteMetadata, + mergedQuote: merge( + {}, + quoteResponseV2, + toNormalizedAmounts(merge({}, quoteResponseV2, v2PartialMetadata)), + v2PartialMetadata, + ), + }, + { + title: 'includes empty quote response when quoteResponse is invalid', + quoteResponse: { a: 1 }, + quoteMetadata: { b: 2 } as QuoteMetadata, + mergedQuote: { a: 1, ...toNormalizedAmounts({}) }, + }, + ])( + 'merged quote $title (Phase 2)', + ({ quoteResponse, quoteMetadata, mergedQuote }) => { + expect( + mergeQuoteMetadata( + quoteResponse as QuoteResponse, + quoteMetadata, + QuoteMetadataMigrationPhase.V2Only, + ), + ).toStrictEqual(mergedQuote); + }, + ); +}); diff --git a/packages/bridge-controller/src/utils/quote-metadata/merge.ts b/packages/bridge-controller/src/utils/quote-metadata/merge.ts new file mode 100644 index 00000000000..8ba6bddf4b3 --- /dev/null +++ b/packages/bridge-controller/src/utils/quote-metadata/merge.ts @@ -0,0 +1,93 @@ +import { merge } from 'lodash'; + +import type { DeepPartial } from '../../types.js'; +import type { QuoteResponse } from '../../validators/quote-response.js'; +import { includeIfTruthy } from './include-if-truthy.js'; +import { toNormalizedAmounts } from './to-normalized-amounts.js'; +import { toQuoteMetadataV2 } from './to-quote-metadata-v2.js'; +import type { QuoteMetadata } from './types.js'; +import { QuoteMetadataMigrationPhase } from './types.js'; + +/** + * Merges legacy {@link QuoteMetadata} values into the {@link QuoteResponse} + * + * @param quoteResponse - The {@link QuoteResponse} to merge the metadata into + * @param legacyQuoteMetadata - The {@link QuoteMetadata} values to merge + * @param migrationPhase - The active {@link QuoteMetadataMigrationPhase} + * @param currencyValues - The amounts in the user's currency, derived from the backend's `usd` values + * @returns The {@link QuoteResponse} with the metadata merged in + */ +export function mergeQuoteMetadata( + quoteResponse: QuoteResponse, + legacyQuoteMetadata: QuoteMetadata = {}, + migrationPhase: QuoteMetadataMigrationPhase = QuoteMetadataMigrationPhase.V1Data, + currencyValues?: DeepPartial, +): QuoteResponse & QuoteMetadata { + if (migrationPhase === QuoteMetadataMigrationPhase.V2Only) { + return merge( + {}, + quoteResponse, + toNormalizedAmounts(quoteResponse), + currencyValues, + ); + } + + const legacyQuoteMetadataV2 = toQuoteMetadataV2( + legacyQuoteMetadata, + quoteResponse, + ); + + if (migrationPhase === QuoteMetadataMigrationPhase.V2WithV1Fallback) { + return merge( + {}, + legacyQuoteMetadataV2, + legacyQuoteMetadata, // legacyQuoteMetadata is returned for testing purposes only + quoteResponse, + toNormalizedAmounts(quoteResponse), + currencyValues, + ); + } + + // Sanitize the bridge-api's quote response by removing fee and price data that will be replaced with legacy metadata values + const { quote, ...restQuoteResponse } = quoteResponse; + const { feeData, priceData, ...restQuote } = quote ?? {}; + + const txFeeGasParams = { + maxFeePerGas: feeData?.txFee?.[0]?.maxFeePerGas, + maxPriorityFeePerGas: feeData?.txFee?.[0]?.maxPriorityFeePerGas, + }; + const txFeeData = includeIfTruthy(txFeeGasParams, { + txFee: [txFeeGasParams], + }); + + const metabridgeFeeData = includeIfTruthy(feeData?.metabridge[0], { + metabridge: feeData?.metabridge, + }); + + const priceImpactData = priceData?.priceImpact?.amount && { + priceData: { + priceImpact: { + amount: priceData?.priceImpact?.amount, + }, + }, + }; + + const sanitizedQuoteResponseV2 = { + quote: { + ...restQuote, + feeData: { + ...(metabridgeFeeData ?? {}), + ...(txFeeData ?? {}), + }, + ...(priceImpactData ?? {}), + }, + }; + return merge( + {}, + restQuoteResponse, + sanitizedQuoteResponseV2, + toNormalizedAmounts(sanitizedQuoteResponseV2), + legacyQuoteMetadataV2, + legacyQuoteMetadata, + ); +} diff --git a/packages/bridge-controller/src/utils/quote-metadata/to-currency-values.ts b/packages/bridge-controller/src/utils/quote-metadata/to-currency-values.ts new file mode 100644 index 00000000000..d5fe1765066 --- /dev/null +++ b/packages/bridge-controller/src/utils/quote-metadata/to-currency-values.ts @@ -0,0 +1,83 @@ +import { BigNumber } from 'bignumber.js'; + +import type { DeepPartial } from '../../types.js'; +import type { AmountsAndAsset } from '../../validators/amount-and-asset.js'; +import type { QuoteResponse } from '../../validators/quote-response.js'; +import { FeeType } from '../../validators/quote.js'; + +const toCurrency = ( + fee?: Pick, + usdToFiatExchangeRate?: BigNumber, +): Pick | undefined => { + const { usd, valueInCurrency } = fee ?? {}; + if (usd && usdToFiatExchangeRate) { + return { valueInCurrency: usdToFiatExchangeRate.times(usd).toFixed() }; + } + if (valueInCurrency) { + return { valueInCurrency }; + } + return undefined; +}; + +/** + * Builds a partial {@link QuoteResponse} object with fiat values derived from the usd values provided by the bridge-api + * + * @param quote - The quote response to calculate the metadata for + * @param usdToFiatExchangeRate - The usd to fiat exchange rate + * @returns The partial {@link QuoteResponse} object with fiat values + */ +export const toCurrencyValues = ( + quote: QuoteResponse, + usdToFiatExchangeRate?: BigNumber, +): DeepPartial => { + const { + quote: { src, dest, feeData, priceData }, + } = quote; + + const { adjustedReturn, priceImpact } = priceData ?? {}; + + const priceImpactFiat = toCurrency(priceImpact, usdToFiatExchangeRate); + const adjustedReturnFiat = toCurrency(adjustedReturn, usdToFiatExchangeRate); + + const minAmountValueInCurrency = toCurrency( + { + usd: dest.minAmountUsd, + valueInCurrency: dest.minAmountValueInCurrency, + }, + usdToFiatExchangeRate, + )?.valueInCurrency; + + return { + quote: { + src: toCurrency(src, usdToFiatExchangeRate), + dest: { + ...toCurrency(dest, usdToFiatExchangeRate), + ...(minAmountValueInCurrency && { + minAmountValueInCurrency, + }), + }, + feeData: + feeData && + Object.fromEntries( + Object.values(FeeType) + .filter((feeType) => feeData[feeType]) + .map((feeType) => [ + feeType, + feeData[feeType]?.map((fee) => + toCurrency(fee, usdToFiatExchangeRate), + ), + ]), + ), + ...((priceImpactFiat ?? adjustedReturnFiat) && { + priceData: { + ...(priceImpactFiat && { + priceImpact: priceImpactFiat, + }), + ...(adjustedReturnFiat && { + adjustedReturn: adjustedReturnFiat, + }), + }, + }), + }, + }; +}; diff --git a/packages/bridge-controller/src/utils/quote-metadata/to-normalized-amounts.ts b/packages/bridge-controller/src/utils/quote-metadata/to-normalized-amounts.ts new file mode 100644 index 00000000000..789459f4b26 --- /dev/null +++ b/packages/bridge-controller/src/utils/quote-metadata/to-normalized-amounts.ts @@ -0,0 +1,57 @@ +import type { DeepPartial } from '../../types.js'; +import type { QuoteResponse } from '../../validators/quote-response.js'; +import { FeeType } from '../../validators/quote.js'; +import { calcNormalizedTokenAmount } from '../number-formatters.js'; + +/** + * Builds a partial {@link QuoteResponese} with normalized amounts + * + * @param quoteResponseV2 - The {@link QuoteResponse} to convert + * @returns The {@link DeepPartial} + */ +export const toNormalizedAmounts = ( + quoteResponseV2: DeepPartial, +): DeepPartial => { + const { src, dest, feeData } = quoteResponseV2.quote ?? {}; + + return { + quote: { + src: { + normalizedAmount: calcNormalizedTokenAmount( + src?.amount, + src?.asset?.decimals, + )?.toFixed(), + }, + dest: { + normalizedAmount: calcNormalizedTokenAmount( + dest?.amount, + dest?.asset?.decimals, + )?.toFixed(), + minAmountNormalized: calcNormalizedTokenAmount( + dest?.minAmount, + dest?.asset?.decimals, + )?.toFixed(), + }, + feeData: { + network: feeData?.[FeeType.NETWORK]?.map((networkFee) => ({ + normalizedAmount: calcNormalizedTokenAmount( + networkFee?.amount, + networkFee?.asset?.decimals, + )?.toFixed(), + })), + relayer: feeData?.[FeeType.RELAYER]?.map((relayerFee) => ({ + normalizedAmount: calcNormalizedTokenAmount( + relayerFee.amount, + relayerFee.asset?.decimals, + )?.toFixed(), + })), + txFee: feeData?.[FeeType.TX_FEE]?.map((txFee) => ({ + normalizedAmount: calcNormalizedTokenAmount( + txFee.amount, + txFee.asset?.decimals, + )?.toFixed(), + })), + }, + }, + }; +}; diff --git a/packages/bridge-controller/src/utils/quote-metadata/to-quote-metadata-v1.ts b/packages/bridge-controller/src/utils/quote-metadata/to-quote-metadata-v1.ts new file mode 100644 index 00000000000..9f37cb4e113 --- /dev/null +++ b/packages/bridge-controller/src/utils/quote-metadata/to-quote-metadata-v1.ts @@ -0,0 +1,126 @@ +import { merge } from 'lodash'; + +import type { DeepPartial } from '../../types.js'; +import type { AmountsAndAsset } from '../../validators/amount-and-asset.js'; +import type { QuoteResponseV1 } from '../../validators/quote-response-v1.js'; +import { isQuoteResponseV2 } from '../../validators/quote-response.js'; +import type { QuoteResponse } from '../../validators/quote-response.js'; +import { sumAmounts } from '../number-formatters.js'; +import { includeIfTruthy } from './include-if-truthy.js'; +import type { QuoteMetadata, TokenAmountValues } from './types.js'; +import { QuoteMetadataMigrationPhase } from './types.js'; + +const toTokenAmountValues = ( + data?: Pick, +): Partial => { + return { + amount: data?.normalizedAmount, + usd: data?.usd, + valueInCurrency: data?.valueInCurrency, + }; +}; + +/** + * Extracts legacy {@link QuoteMetadata} values from a {@link QuoteResponse} or {@link QuoteResponseV1}. + * If a QuoteResponse is provided, this assumes that its `valueInCurrency` properties are set. + * + * @param quoteResponse - The quote to extract the metadata from + * @param migrationPhase - The migration phase to use + * @returns A partial {@link QuoteMetadata} object + */ +export const toQuoteMetadataV1 = ( + quoteResponse: + | (DeepPartial & QuoteMetadata) + | null, + migrationPhase: QuoteMetadataMigrationPhase = QuoteMetadataMigrationPhase.V1Data, +): QuoteMetadata => { + /* istanbul ignore if */ + if (!quoteResponse) { + return {}; + } + + const { + toTokenAmount, + minToTokenAmount, + sentAmount, + swapRate, + adjustedReturn, + cost, + includedTxFees, + relayerFee, + totalNetworkFee, + gasFee, + priceImpact, + } = quoteResponse; + + const legacyMetadata = { + sentAmount, + toTokenAmount, + minToTokenAmount, + swapRate, + gasFee, + totalNetworkFee, + ...includeIfTruthy(adjustedReturn, { adjustedReturn }), + ...includeIfTruthy(cost, { cost }), + ...includeIfTruthy(priceImpact, { priceImpact }), + ...includeIfTruthy(relayerFee, { relayerFee }), + ...includeIfTruthy(includedTxFees, { includedTxFees }), + }; + + if ( + migrationPhase === QuoteMetadataMigrationPhase.V1Data || + !isQuoteResponseV2(quoteResponse) + ) { + return legacyMetadata; + } + + const { quote } = quoteResponse; + const { src, dest, priceData, feeData } = quote; + const { network, relayer, txFee } = feeData; + + const totalNetworkFeeV2 = sumAmounts(network, relayer); + + // Build V1 from V2 quote + const v2Metadata: QuoteMetadata = { + ...includeIfTruthy(src, { + sentAmount: toTokenAmountValues(src), + }), + ...includeIfTruthy(dest, { + toTokenAmount: toTokenAmountValues(dest), + minToTokenAmount: { + amount: dest.minAmountNormalized, + valueInCurrency: dest.minAmountValueInCurrency, + usd: dest.minAmountUsd, + }, + }), + ...includeIfTruthy(priceData?.adjustedReturn, { + adjustedReturn: toTokenAmountValues(priceData?.adjustedReturn), + }), + ...includeIfTruthy(network?.[0], { + gasFee: { + total: toTokenAmountValues(network?.[0]), + }, + }), + ...includeIfTruthy(totalNetworkFeeV2, { + totalNetworkFee: toTokenAmountValues(totalNetworkFeeV2), + }), + ...includeIfTruthy(priceData?.priceImpact, { + priceImpact: toTokenAmountValues(priceData?.priceImpact), + // Use priceImpact as cost + cost: toTokenAmountValues(priceData?.priceImpact), + }), + ...includeIfTruthy(relayer?.[0], { + relayerFee: toTokenAmountValues(relayer?.[0]), + }), + ...includeIfTruthy(txFee?.[0], { + includedTxFees: toTokenAmountValues(txFee?.[0]), + }), + ...(priceData?.swapRate && { swapRate: priceData.swapRate }), + }; + + if (migrationPhase === QuoteMetadataMigrationPhase.V2WithV1Fallback) { + return merge({}, legacyMetadata, v2Metadata); + } + + return v2Metadata; +}; diff --git a/packages/bridge-controller/src/utils/quote-metadata/to-quote-metadata-v2.ts b/packages/bridge-controller/src/utils/quote-metadata/to-quote-metadata-v2.ts new file mode 100644 index 00000000000..889cb124c59 --- /dev/null +++ b/packages/bridge-controller/src/utils/quote-metadata/to-quote-metadata-v2.ts @@ -0,0 +1,114 @@ +import { parseCaipAssetType } from '@metamask/utils'; + +import { toBridgeAssetV2 } from '../../coercers/quote-response-v1-to-v2.js'; +import type { DeepPartial } from '../../types.js'; +import type { AmountsAndAsset } from '../../validators/amount-and-asset.js'; +import type { BridgeAssetV2 } from '../../validators/bridge-asset.js'; +import type { QuoteResponse } from '../../validators/quote-response.js'; +import { getNativeAssetForChainId } from '../bridge.js'; +import { calcAtomicTokenAmount } from '../number-formatters.js'; +import { includeIfTruthy } from './include-if-truthy.js'; +import type { QuoteMetadata, TokenAmountValues } from './types.js'; + +const toAmountAndAsset = ( + asset?: DeepPartial, + metadata?: Partial, + extraFields?: DeepPartial, +): DeepPartial => { + return { + amount: calcAtomicTokenAmount(metadata?.amount, asset?.decimals), + normalizedAmount: metadata?.amount, + valueInCurrency: metadata?.valueInCurrency, + usd: metadata?.usd, + ...extraFields, + }; +}; + +/** + * Converts a {@link QuoteMetadata} to a partial {@link QuoteResponse} containing only metadata + * + * @param quoteMetadata - The {@link QuoteMetadata} to convert + * @param quoteResponseV2 - The {@link QuoteResponse} to use for token data + * @returns The {@link DeepPartial} + */ +export const toQuoteMetadataV2 = ( + quoteMetadata: QuoteMetadata, + quoteResponseV2?: DeepPartial, +): DeepPartial => { + const { + sentAmount, + toTokenAmount, + minToTokenAmount, + swapRate, + totalNetworkFee, + gasFee, + adjustedReturn, + cost, + includedTxFees, + relayerFee, + priceImpact, + ...rest + } = quoteMetadata; + + const srcAsset = quoteResponseV2?.quote?.src?.asset; + const destAsset = quoteResponseV2?.quote?.dest?.asset; + + const chainId = srcAsset?.assetId + ? parseCaipAssetType(srcAsset.assetId)?.chainId + : undefined; + const nativeAsset = chainId + ? toBridgeAssetV2(getNativeAssetForChainId(chainId)) + : undefined; + const txFeeAsset = quoteResponseV2?.quote?.feeData?.txFee?.[0]?.asset; + + const priceImpactToUse = { + usd: priceImpact?.usd ?? cost?.usd, + valueInCurrency: priceImpact?.valueInCurrency ?? cost?.valueInCurrency, + }; + const networkFeeToUse = gasFee?.total ?? totalNetworkFee; + + return { + ...rest, + quote: { + src: toAmountAndAsset(srcAsset, sentAmount), + dest: { + ...toAmountAndAsset(destAsset, toTokenAmount), + minAmount: calcAtomicTokenAmount( + minToTokenAmount?.amount, + destAsset?.decimals, + ), + minAmountNormalized: minToTokenAmount?.amount, + minAmountUsd: minToTokenAmount?.usd, + minAmountValueInCurrency: minToTokenAmount?.valueInCurrency, + }, + feeData: { + ...includeIfTruthy(networkFeeToUse, { + network: [ + toAmountAndAsset(nativeAsset, networkFeeToUse, { + asset: nativeAsset, + }), + ], + }), + ...includeIfTruthy(relayerFee, { + relayer: [ + toAmountAndAsset(nativeAsset, relayerFee, { asset: nativeAsset }), + ], + }), + ...includeIfTruthy(includedTxFees, { + txFee: [ + toAmountAndAsset(txFeeAsset, includedTxFees, { asset: txFeeAsset }), + ], + }), + }, + priceData: { + ...includeIfTruthy(priceImpactToUse, { + priceImpact: priceImpactToUse, + }), + ...includeIfTruthy(adjustedReturn, { + adjustedReturn, + }), + swapRate, + }, + }, + }; +}; diff --git a/packages/bridge-controller/src/utils/quote-metadata/types.ts b/packages/bridge-controller/src/utils/quote-metadata/types.ts new file mode 100644 index 00000000000..98daf9c6593 --- /dev/null +++ b/packages/bridge-controller/src/utils/quote-metadata/types.ts @@ -0,0 +1,113 @@ +import type { DeepPartial } from '../../types.js'; + +/** + * The types of values for the token amount and its values when converted to the user's selected currency and USD + */ +export type TokenAmountValues = { + /** + * The amount of the token + * + * @example "1.005" + */ + amount: string; + /** + * The amount of the token in the user's selected currency + * + * @example "4.55" + */ + valueInCurrency: string; + /** + * The amount of the token in USD + * + * @example "1.234" + */ + usd: string; +}; + +/** + * Values derived from the quote response + * + * @deprecated Avoid introducing new usages and use the QuoteResponse V2 type instead + */ +type QuoteMetadataV1 = { + /** + * If gas is included, this is the value of the src or dest token that was used to pay for the gas. + * Show this value to indicate transaction fees for gasless quotes. + */ + includedTxFees?: Partial; + /** + * The gas fee for the bridge transaction. + * effective is the gas fee that is shown to the user. If this value is not + * included in the trade, the calculation falls back to the gasLimit (total) + * total is the gas fee that is spent by the user, including refunds. + * max is the max gas fee that will be used by the transaction. + */ + gasFee: Record<'total', TokenAmountValues>; + relayerFee?: Partial; // relayer/provider fee in native units + /** + * The total network fee required to submit the trade and any approvals. This includes + * the relayer fee or other native fees. Should be used for balance checks and tx submission. + * Note: This is only accurate for non-gasless transactions. Use {@link QuoteMetadata.includedTxFees} to + * get the total network fee for gasless transactions. + */ + totalNetworkFee: TokenAmountValues; // gasFee.total + relayerFee + /** + * The amount that the user will receive (destTokenAmount) + */ + toTokenAmount: TokenAmountValues; + /** + * The minimum amount that the user will receive (minDestTokenAmount) + */ + minToTokenAmount: TokenAmountValues; + /** + * If gas is included: {@link QuoteMetadata.toTokenAmount} - {@link QuoteMetadata.includedTxFees}. + * Otherwise: {@link QuoteMetadata.toTokenAmount} - {@link QuoteMetadata.totalNetworkFee}. + */ + adjustedReturn: Omit; + /** + * The amount that the user will send, including fees that are paid in the src token + * {@link Quote.srcTokenAmount} + {@link Quote.feeData[FeeType.METABRIDGE].amount} + {@link Quote.feeData[FeeType.TX_FEE].amount} + */ + sentAmount: TokenAmountValues; + /** + * The swap rate is the amount that the user will receive per amount sent. Accounts for fees paid in the src or dest token. + * This is calculated as {@link QuoteMetadata.toTokenAmount} / {@link QuoteMetadata.sentAmount}. + */ + swapRate: string; + /** + * The cost of the trade, which is the difference between the amount sent and the adjusted return. + * This is calculated as {@link QuoteMetadata.sentAmount} - {@link QuoteMetadata.adjustedReturn}. + */ + cost: Omit; // sentAmount - adjustedReturn + + /** + * The price impact for the quote. + */ + priceImpact: Omit; // abs(sentAmount - toTokenAmount); +}; + +/** + * The partial legacy quote metadata + * + * @deprecated Avoid introducing new usages and use the nested QuoteResponse metadata instead + */ +export type QuoteMetadata = DeepPartial; + +export const QuoteMetadataMigrationPhase = { + /** + * Phase 1: omit API V2 currency metadata; serve legacy calcQuoteMetadata + * into V2 nested shape + */ + V1Data: '1', + /** + * Phase 1.5: prefer API V2 metadata (+ fiat from usd); fall back to legacy. + */ + V2WithV1Fallback: '1.5', + /** + * Phase 2: API V2 metadata only; legacy metadata utils can be removed. + */ + V2Only: '2', +} as const; + +export type QuoteMetadataMigrationPhase = + (typeof QuoteMetadataMigrationPhase)[keyof typeof QuoteMetadataMigrationPhase]; diff --git a/packages/bridge-controller/src/utils/slippage.ts b/packages/bridge-controller/src/utils/slippage.ts new file mode 100644 index 00000000000..9134438fb51 --- /dev/null +++ b/packages/bridge-controller/src/utils/slippage.ts @@ -0,0 +1,66 @@ +import type { GenericQuoteRequest } from '../types.js'; +import { isCrossChain, isSolanaChainId } from './bridge.js'; + +export const BRIDGE_DEFAULT_SLIPPAGE = 0.5; +const SWAP_SOLANA_SLIPPAGE = undefined; +const SWAP_EVM_STABLECOIN_SLIPPAGE = 0.5; +const SWAP_EVM_DEFAULT_SLIPPAGE = 2; + +/** + * Calculates the appropriate slippage based on the transaction context + * + * Rules: + * - Bridge (cross-chain): Always 0.5% + * - Swap on Solana: Always undefined (AUTO mode) + * - Swap on EVM stablecoin pairs (same chain only): 0.5% + * - Swap on EVM other pairs: 2% + * + * @param options - the options for the destination chain + * @param options.srcTokenAddress - the source token address + * @param options.destTokenAddress - the destination token address + * @param options.srcChainId - the source chain id + * @param options.destChainId - the destination chain id + * @param srcStablecoins - the list of stablecoins on the source chain + * @param destStablecoins - the list of stablecoins on the destination chain + + * @returns the default slippage percentage for the chain and token pair + */ +export const getDefaultSlippagePercentage = ( + { + srcTokenAddress, + destTokenAddress, + srcChainId, + destChainId, + }: Partial< + Pick< + GenericQuoteRequest, + 'srcTokenAddress' | 'destTokenAddress' | 'srcChainId' | 'destChainId' + > + >, + srcStablecoins?: string[], + destStablecoins?: string[], +) => { + if (!srcChainId || isCrossChain(srcChainId, destChainId)) { + return BRIDGE_DEFAULT_SLIPPAGE; + } + + if (isSolanaChainId(srcChainId)) { + return SWAP_SOLANA_SLIPPAGE; + } + + if ( + srcTokenAddress && + destTokenAddress && + srcStablecoins + ?.map((stablecoin) => stablecoin.toLowerCase()) + .includes(srcTokenAddress.toLowerCase()) && + // If destChainId is undefined, treat req as a swap and fallback to srcStablecoins + (destStablecoins ?? srcStablecoins) + ?.map((stablecoin) => stablecoin.toLowerCase()) + .includes(destTokenAddress.toLowerCase()) + ) { + return SWAP_EVM_STABLECOIN_SLIPPAGE; + } + + return SWAP_EVM_DEFAULT_SLIPPAGE; +}; diff --git a/packages/bridge-controller/src/utils/snaps.test.ts b/packages/bridge-controller/src/utils/snaps.test.ts new file mode 100644 index 00000000000..a561ac4eecb --- /dev/null +++ b/packages/bridge-controller/src/utils/snaps.test.ts @@ -0,0 +1,78 @@ +import { SolScope } from '@metamask/keyring-api'; +import { v4 as uuid } from 'uuid'; + +import { + getMinimumBalanceForRentExemptionRequest, + computeFeeRequest, +} from './snaps.js'; + +jest.mock('uuid', () => ({ + v4: jest.fn(), +})); + +describe('Snaps Utils', () => { + beforeEach(() => { + jest.clearAllMocks(); + (uuid as jest.Mock).mockReturnValue('test-uuid-1234'); + }); + + describe('getMinimumBalanceForRentExemptionRequest', () => { + it('should create a proper request for getting minimum balance for rent exemption', () => { + const snapId = 'test-snap-id'; + const result = getMinimumBalanceForRentExemptionRequest(snapId); + + expect(result.snapId).toBe(snapId); + expect(result.origin).toBe('metamask'); + expect(result.handler).toBe('onProtocolRequest'); + expect(result.request.method).toBe(' '); + expect(result.request.jsonrpc).toBe('2.0'); + expect(result.request.params.scope).toBe(SolScope.Mainnet); + expect(result.request.params.request.id).toBe('test-uuid-1234'); + expect(result.request.params.request.jsonrpc).toBe('2.0'); + expect(result.request.params.request.method).toBe( + 'getMinimumBalanceForRentExemption', + ); + expect(result.request.params.request.params).toStrictEqual([ + 0, + { commitment: 'confirmed' }, + ]); + }); + }); + + describe('computeFeeRequest', () => { + it('should create a proper request for computing fees', () => { + const snapId = 'test-snap-id'; + const transaction = 'base64-encoded-transaction'; + const accountId = 'test-account-id'; + const scope = 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp' as const; + + const result = computeFeeRequest(snapId, transaction, accountId, scope); + + expect(result.snapId).toBe(snapId); + expect(result.origin).toBe('metamask'); + expect(result.handler).toBe('onClientRequest'); + expect(result.request.id).toBe('test-uuid-1234'); + expect(result.request.jsonrpc).toBe('2.0'); + expect(result.request.method).toBe('computeFee'); + expect(result.request.params.transaction).toBe(transaction); + expect(result.request.params.accountId).toBe(accountId); + expect(result.request.params.scope).toBe(scope); + }); + + it('should handle different chain scopes', () => { + const snapId = 'test-snap-id'; + const transaction = 'base64-encoded-transaction'; + const accountId = 'test-account-id'; + const btcScope = 'bip122:000000000019d6689c085ae165831e93' as const; + + const result = computeFeeRequest( + snapId, + transaction, + accountId, + btcScope, + ); + + expect(result.request.params.scope).toBe(btcScope); + }); + }); +}); diff --git a/packages/bridge-controller/src/utils/snaps.ts b/packages/bridge-controller/src/utils/snaps.ts new file mode 100644 index 00000000000..2787612e770 --- /dev/null +++ b/packages/bridge-controller/src/utils/snaps.ts @@ -0,0 +1,92 @@ +import { SolScope } from '@metamask/keyring-api'; +import type { CaipChainId } from '@metamask/utils'; +import { v4 as uuid } from 'uuid'; + +import { DEFAULT_BRIDGE_CONTROLLER_STATE } from '../constants/bridge.js'; +import type { BridgeControllerMessenger } from '../types.js'; + +export const getMinimumBalanceForRentExemptionRequest = (snapId: string) => { + return { + snapId: snapId as never, + origin: 'metamask', + handler: 'onProtocolRequest' as never, + request: { + method: ' ', + jsonrpc: '2.0', + params: { + scope: SolScope.Mainnet, + request: { + id: uuid(), + jsonrpc: '2.0', + method: 'getMinimumBalanceForRentExemption', + params: [0, { commitment: 'confirmed' }], + }, + }, + }, + }; +}; + +/** + * Gets the minimum balance for rent exemption in lamports for a given chain ID and selected account + * + * @param snapId - The snap ID to send the request to + * @param messenger - The messaging system to use to call the snap controller + * @returns The minimum balance for rent exemption in lamports + */ +export const getMinimumBalanceForRentExemptionInLamports = async ( + snapId: string, + messenger: BridgeControllerMessenger, +) => { + return String( + await messenger + .call( + 'SnapController:handleRequest', + getMinimumBalanceForRentExemptionRequest(snapId), + ) + // eslint-disable-next-line @typescript-eslint/no-explicit-any + .catch((error: any) => { + console.error( + 'Error setting minimum balance for rent exemption', + error, + ); + return DEFAULT_BRIDGE_CONTROLLER_STATE.minimumBalanceForRentExemptionInLamports; + }), + ); +}; + +/** + * Creates a request to compute fees for a transaction using the new unified interface + * Returns fees in native token amount (e.g., Solana instead of Lamports) + * + * @param snapId - The snap ID to send the request to + * @param transaction - The base64 encoded transaction string + * @param accountId - The account ID + * @param scope - The CAIP-2 chain scope + * @param options - Additional options to include in the request + * @returns The snap request object + */ +export const computeFeeRequest = ( + snapId: string, + transaction: string, + accountId: string, + scope: CaipChainId, + options?: Record, +) => { + return { + // TODO: remove 'as never' typing. + snapId: snapId as never, + origin: 'metamask', + handler: 'onClientRequest' as never, + request: { + id: uuid(), + jsonrpc: '2.0', + method: 'computeFee', + params: { + transaction, + accountId, + scope, + ...(options && { options }), + }, + }, + }; +}; diff --git a/packages/bridge-controller/src/utils/sort-quotes.ts b/packages/bridge-controller/src/utils/sort-quotes.ts new file mode 100644 index 00000000000..ef17b40a146 --- /dev/null +++ b/packages/bridge-controller/src/utils/sort-quotes.ts @@ -0,0 +1,18 @@ +/* eslint-disable @typescript-eslint/explicit-function-return-type */ +import { FeatureId } from '../validators/feature-flags.js'; +import type { QuoteResponseV1 } from '../validators/quote-response-v1.js'; + +export const sortQuotes = ( + quotes: QuoteResponseV1[], + featureId: FeatureId | null, +) => { + // Sort perps quotes by increasing estimated processing time (fastest first) + if (featureId === FeatureId.PERPS) { + return quotes.sort((a, b) => { + return ( + a.estimatedProcessingTimeInSeconds - b.estimatedProcessingTimeInSeconds + ); + }); + } + return quotes; +}; diff --git a/packages/bridge-controller/src/utils/struct-error.ts b/packages/bridge-controller/src/utils/struct-error.ts new file mode 100644 index 00000000000..420de17e356 --- /dev/null +++ b/packages/bridge-controller/src/utils/struct-error.ts @@ -0,0 +1,21 @@ +import type { StructError } from '@metamask/superstruct'; + +/** + * Formats validation errors (StructError) into an array of messages + * that match the format used for metrics + * + * @param error - The validation errors (StructError) to format + * + * @returns An array of error messages + */ +export const formatStructErrors = (error: StructError): string[] => + Array.from( + new Set( + error + .failures() + .map( + ({ message, path }) => + `At path: ${path.join('.') || ''}${error.type ? ` (${error.type})` : ''} -- ${message}`, + ), + ), + ); diff --git a/packages/bridge-controller/src/utils/swaps.test.ts b/packages/bridge-controller/src/utils/swaps.test.ts new file mode 100644 index 00000000000..ea4fcad48f0 --- /dev/null +++ b/packages/bridge-controller/src/utils/swaps.test.ts @@ -0,0 +1,231 @@ +import type { Hex } from '@metamask/utils'; + +import { CHAIN_IDS } from '../constants/chains.js'; +import { + ALLOWED_CONTRACT_ADDRESSES, + SWAPS_CONTRACT_ADDRESSES, +} from '../constants/swaps.js'; +import { + DEFAULT_TOKEN_ADDRESS, + SWAPS_CHAINID_DEFAULT_TOKEN_MAP, +} from '../constants/tokens.js'; +import type { FetchFunction } from '../types.js'; +import { + API_BASE_URL, + fetchTokens, + getSwapsContractAddress, + isValidSwapsContractAddress, +} from './swaps.js'; +import type { SwapsToken } from './swaps.js'; + +describe('Swaps utils', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe('isValidSwapsContractAddress', () => { + it('returns true for valid swaps contract address', () => { + const contract = SWAPS_CONTRACT_ADDRESSES[CHAIN_IDS.MAINNET]; + expect(isValidSwapsContractAddress(CHAIN_IDS.MAINNET, contract)).toBe( + true, + ); + }); + + it('returns true for any allowed contract address', () => { + const allowedAddresses = ALLOWED_CONTRACT_ADDRESSES[CHAIN_IDS.MAINNET]; + allowedAddresses.forEach((address) => { + expect(isValidSwapsContractAddress(CHAIN_IDS.MAINNET, address)).toBe( + true, + ); + }); + }); + + it('returns true for contract address with different case', () => { + const contract = SWAPS_CONTRACT_ADDRESSES[CHAIN_IDS.MAINNET]; + const upperCaseContract = contract.toUpperCase() as Hex; + const mixedCaseContract = + '0x881D40237659C251811CEC9C364EF91DC08D300C' as Hex; + + expect( + isValidSwapsContractAddress(CHAIN_IDS.MAINNET, upperCaseContract), + ).toBe(true); + expect( + isValidSwapsContractAddress(CHAIN_IDS.MAINNET, mixedCaseContract), + ).toBe(true); + }); + + it('returns false for invalid contract address', () => { + const invalidContract = + '0x1234567890123456789012345678901234567890' as Hex; + expect( + isValidSwapsContractAddress(CHAIN_IDS.MAINNET, invalidContract), + ).toBe(false); + }); + + it('returns false when contract is undefined', () => { + expect(isValidSwapsContractAddress(CHAIN_IDS.MAINNET, undefined)).toBe( + false, + ); + }); + + it('returns false for unsupported chain ID', () => { + const contract = SWAPS_CONTRACT_ADDRESSES[CHAIN_IDS.MAINNET]; + const unsupportedChainId = '0x999' as Hex; + expect(isValidSwapsContractAddress(unsupportedChainId, contract)).toBe( + false, + ); + }); + + it('returns false when chain ID is not in ALLOWED_CONTRACT_ADDRESSES', () => { + const contract = '0x881d40237659c251811cec9c364ef91dc08d300c' as Hex; + const unknownChainId = '0xabc' as Hex; + expect(isValidSwapsContractAddress(unknownChainId, contract)).toBe(false); + }); + + it('returns false for empty contract address', () => { + expect(isValidSwapsContractAddress(CHAIN_IDS.MAINNET, '' as Hex)).toBe( + false, + ); + }); + + it('returns false for contract address on wrong chain', () => { + const mainnetContract = SWAPS_CONTRACT_ADDRESSES[CHAIN_IDS.MAINNET]; + expect(isValidSwapsContractAddress(CHAIN_IDS.BSC, mainnetContract)).toBe( + false, + ); + }); + + it('validates all wrapped token addresses', () => { + // Test that wrapped token addresses are also in the allowed list + Object.keys(ALLOWED_CONTRACT_ADDRESSES).forEach((chainId) => { + const allowedAddresses = ALLOWED_CONTRACT_ADDRESSES[chainId as Hex]; + // Each chain should have at least the swaps contract and wrapped token + expect(allowedAddresses.length).toBeGreaterThanOrEqual(2); + + // Verify each allowed address validates correctly + allowedAddresses.forEach((address) => { + expect(isValidSwapsContractAddress(chainId as Hex, address)).toBe( + true, + ); + }); + }); + }); + }); + + describe('getSwapsContractAddress', () => { + it('returns correct swaps contract address', () => { + expect(getSwapsContractAddress(CHAIN_IDS.MAINNET)).toBe( + '0x881d40237659c251811cec9c364ef91dc08d300c', + ); + }); + + it('returns undefined for unsupported chain ID', () => { + const unsupportedChainId = '0x999' as Hex; + expect(getSwapsContractAddress(unsupportedChainId)).toBeUndefined(); + }); + + it('returns addresses that match the SWAPS_CONTRACT_ADDRESSES constant', () => { + Object.keys(SWAPS_CONTRACT_ADDRESSES).forEach((chainId) => { + const address = getSwapsContractAddress(chainId as Hex); + expect(address).toBe(SWAPS_CONTRACT_ADDRESSES[chainId as Hex]); + }); + }); + }); + + describe('fetchTokens', () => { + const mockTokens: SwapsToken[] = [ + { + address: DEFAULT_TOKEN_ADDRESS, + symbol: 'ETH', + name: 'Ether', + decimals: 18, + iconUrl: 'https://example.com/eth.png', + }, + { + address: '0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48', + symbol: 'USDC', + name: 'USD Coin', + decimals: 6, + iconUrl: 'https://example.com/usdc.png', + }, + { + address: '0xdac17f958d2ee523a2206206994597c13d831ec7', + symbol: 'USDT', + name: 'Tether USD', + decimals: 6, + iconUrl: 'https://example.com/usdt.png', + }, + ]; + + it('fetches and returns tokens with native token', async () => { + const mockFetchFn = jest.fn().mockResolvedValue(mockTokens); + + const result = await fetchTokens( + CHAIN_IDS.MAINNET, + mockFetchFn as unknown as FetchFunction, + ); + + expect(mockFetchFn).toHaveBeenCalledWith( + `${API_BASE_URL}/networks/1/tokens`, + { + headers: undefined, + }, + ); + + // Should filter out the default token address and add native token + expect(result).toHaveLength(3); + expect(result[0].address).toBe( + '0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48', + ); + expect(result[1].address).toBe( + '0xdac17f958d2ee523a2206206994597c13d831ec7', + ); + expect(result[2]).toStrictEqual( + SWAPS_CHAINID_DEFAULT_TOKEN_MAP[CHAIN_IDS.MAINNET], + ); + }); + + it('includes client ID header when provided', async () => { + const mockFetchFn = jest.fn().mockResolvedValue(mockTokens); + const clientId = 'test-client-id'; + + await fetchTokens( + CHAIN_IDS.MAINNET, + mockFetchFn as unknown as FetchFunction, + clientId, + ); + + expect(mockFetchFn).toHaveBeenCalledWith( + `${API_BASE_URL}/networks/1/tokens`, + { + headers: { 'X-Client-Id': clientId }, + }, + ); + }); + + it('does not include client ID header when not provided', async () => { + const mockFetchFn = jest.fn().mockResolvedValue(mockTokens); + + await fetchTokens( + CHAIN_IDS.MAINNET, + mockFetchFn as unknown as FetchFunction, + ); + + expect(mockFetchFn).toHaveBeenCalledWith( + `${API_BASE_URL}/networks/1/tokens`, + { + headers: undefined, + }, + ); + }); + + it('propagates fetch errors', async () => { + const mockError = new Error('Network error'); + const mockFetchFn = jest.fn().mockRejectedValue(mockError); + + await expect( + fetchTokens(CHAIN_IDS.MAINNET, mockFetchFn as unknown as FetchFunction), + ).rejects.toThrow('Network error'); + }); + }); +}); diff --git a/packages/bridge-controller/src/utils/swaps.ts b/packages/bridge-controller/src/utils/swaps.ts new file mode 100644 index 00000000000..6708e87ea42 --- /dev/null +++ b/packages/bridge-controller/src/utils/swaps.ts @@ -0,0 +1,108 @@ +import type { Hex } from '@metamask/utils'; + +import { CHAIN_IDS } from '../constants/chains.js'; +import { + ALLOWED_CONTRACT_ADDRESSES, + SWAPS_CONTRACT_ADDRESSES, +} from '../constants/swaps.js'; +import { + DEFAULT_TOKEN_ADDRESS, + SWAPS_CHAINID_DEFAULT_TOKEN_MAP, +} from '../constants/tokens.js'; +import type { FetchFunction } from '../types.js'; +import { formatChainIdToDec } from './caip-formatters.js'; + +/** + * Checks if the given contract address is valid for the given chain ID. + * + * @param chainId - The chain ID. + * @param contract - The contract address. + * @returns True if the contract address is valid, false otherwise. + */ +export function isValidSwapsContractAddress( + chainId: Hex, + contract: Hex | undefined, +): boolean { + if (!contract || !ALLOWED_CONTRACT_ADDRESSES[chainId]) { + return false; + } + return ALLOWED_CONTRACT_ADDRESSES[chainId].some( + (allowedContract) => + contract.toLowerCase() === allowedContract.toLowerCase(), + ); +} + +/** + * Gets the swaps contract address for the given chain ID. + * + * @param chainId - The chain ID. + * @returns The swaps contract address. + */ +export function getSwapsContractAddress(chainId: Hex): string { + return SWAPS_CONTRACT_ADDRESSES[chainId]; +} + +/** + * Gets the client ID header. + * + * @param clientId - The client ID. + * @returns The client ID header. + */ +function getClientIdHeader(clientId?: string) { + if (!clientId) { + return undefined; + } + return { + 'X-Client-Id': clientId, + }; +} + +export const API_BASE_URL = 'https://swap.api.cx.metamask.io'; +export const DEV_BASE_URL = 'https://swap.dev-api.cx.metamask.io'; + +export type SwapsToken = { + address: string; + symbol: string; + name?: string; + decimals: number; + iconUrl?: string; + occurrences?: number; +}; + +/** + * Fetches token metadata from API URL. + * + * @param chainId - Current chainId. + * @param fetchFn - Fetch function. + * @param clientId - Client id. + * @returns Promise resolving to an object containing token metadata. + */ +export async function fetchTokens( + chainId: Hex, + fetchFn: FetchFunction, + clientId?: string, +): Promise { + const [apiChainId, apiBaseUrl] = + chainId === CHAIN_IDS.LOCALHOST + ? [CHAIN_IDS.MAINNET, DEV_BASE_URL] + : [chainId, API_BASE_URL]; + + const apiDecimalChainId = formatChainIdToDec(apiChainId); + const tokenUrl = `${apiBaseUrl}/networks/${apiDecimalChainId}/tokens`; + + const tokens: SwapsToken[] = await fetchFn(tokenUrl, { + headers: getClientIdHeader(clientId), + }); + + const filteredTokens = tokens.filter((token) => { + return token.address !== DEFAULT_TOKEN_ADDRESS; + }); + + const nativeSwapsToken = + SWAPS_CHAINID_DEFAULT_TOKEN_MAP[ + chainId as keyof typeof SWAPS_CHAINID_DEFAULT_TOKEN_MAP + ]; + + filteredTokens.push(nativeSwapsToken); + return filteredTokens; +} diff --git a/packages/bridge-controller/src/utils/trade-utils.test.ts b/packages/bridge-controller/src/utils/trade-utils.test.ts new file mode 100644 index 00000000000..4a686d18a33 --- /dev/null +++ b/packages/bridge-controller/src/utils/trade-utils.test.ts @@ -0,0 +1,273 @@ +import type { + TxData, + BitcoinTradeData, + TronTradeData, + Trade, +} from '../validators/trade.js'; +import { + isEvmTxData, + isBitcoinTrade, + isStellarTrade, + isTronTrade, +} from '../validators/trade.js'; +import { extractTradeData } from './trade-utils.js'; + +describe('Trade utils', () => { + describe('isEvmTxData', () => { + it('returns true for EVM TxData object', () => { + const evmTxData: TxData = { + chainId: 1, + to: '0x742d35Cc6634C0532925a3b844Bc9e7595f0bEb', + from: '0x742d35Cc6634C0532925a3b844Bc9e7595f0bEb', + value: '0x0', + data: '0x1234567890abcdef', + gasLimit: null, + }; + expect(isEvmTxData(evmTxData)).toBe(true); + }); + + it('returns true for EVM TxData with optional effectiveGas', () => { + const evmTxData: TxData = { + chainId: 1, + to: '0x742d35Cc6634C0532925a3b844Bc9e7595f0bEb', + from: '0x742d35Cc6634C0532925a3b844Bc9e7595f0bEb', + value: '0x0', + data: '0x1234567890abcdef', + gasLimit: 21000, + effectiveGas: 20000, + }; + expect(isEvmTxData(evmTxData)).toBe(true); + }); + + it('returns false for string trade', () => { + const stringTrade = 'someTransactionString'; + expect(isEvmTxData(stringTrade)).toBe(false); + }); + + it('returns false for Bitcoin trade', () => { + const bitcoinTrade = { + unsignedPsbtBase64: 'cHNidP8BAH...', + inputsToSign: null, + }; + expect(isEvmTxData(bitcoinTrade)).toBe(false); + }); + + it('returns false for Tron trade', () => { + const tronTrade = { + raw_data_hex: '0a02...', + visible: true, + }; + expect(isEvmTxData(tronTrade)).toBe(false); + }); + + it('returns false for null', () => { + expect(isEvmTxData(null as unknown as Trade)).toBe(false); + }); + + it('returns false for empty object', () => { + expect(isEvmTxData({} as unknown as Trade)).toBe(false); + }); + + it('returns false for object with only data property', () => { + expect(isEvmTxData({ data: '0x123' } as unknown as Trade)).toBe(false); + }); + + it('returns false for object with only chainId and to', () => { + expect(isEvmTxData({ chainId: 1, to: '0x123' } as unknown as Trade)).toBe( + false, + ); + }); + }); + + describe('isBitcoinTrade', () => { + it('returns true for Bitcoin trade with unsignedPsbtBase64', () => { + const bitcoinTrade: BitcoinTradeData = { + unsignedPsbtBase64: 'cHNidP8BAH...', + inputsToSign: null, + }; + expect(isBitcoinTrade(bitcoinTrade)).toBe(true); + }); + + it('returns false for string trade', () => { + const stringTrade = 'someTransactionString'; + expect(isBitcoinTrade(stringTrade)).toBe(false); + }); + + it('returns false for Tron trade', () => { + const tronTrade = { + raw_data_hex: '0a02...', + visible: true, + }; + expect(isBitcoinTrade(tronTrade)).toBe(false); + }); + + it('returns false for null', () => { + expect(isBitcoinTrade(null as unknown as Trade)).toBe(false); + }); + + it('returns false for empty object', () => { + expect(isBitcoinTrade({} as unknown as Trade)).toBe(false); + }); + }); + + describe('isTronTrade', () => { + it('returns true for Tron trade with raw_data_hex', () => { + const tronTrade: TronTradeData = { + raw_data_hex: '0a02...', + visible: true, + raw_data: { + contract: [{ type: 'TransferContract' }], + }, + }; + expect(isTronTrade(tronTrade)).toBe(true); + }); + + it('returns true for minimal Tron trade', () => { + const tronTrade = { + raw_data_hex: '0a02...', + }; + expect(isTronTrade(tronTrade)).toBe(true); + }); + + it('returns false for string trade', () => { + const stringTrade = 'someTransactionString'; + expect(isTronTrade(stringTrade)).toBe(false); + }); + + it('returns false for Bitcoin trade', () => { + const bitcoinTrade = { + unsignedPsbtBase64: 'cHNidP8BAH...', + }; + expect(isTronTrade(bitcoinTrade as unknown as Trade)).toBe(false); + }); + + it('returns false for null', () => { + expect(isTronTrade(null as unknown as Trade)).toBe(false); + }); + + it('returns false for empty object', () => { + expect(isTronTrade({} as unknown as Trade)).toBe(false); + }); + }); + + describe('isStellarTrade', () => { + it('returns true for xdrBase64 object', () => { + expect( + isStellarTrade({ xdrBase64: 'AAAABg==' } as unknown as Trade), + ).toBe(true); + }); + + it('returns true for xdr object', () => { + expect(isStellarTrade({ xdr: 'AAAABg==' } as unknown as Trade)).toBe( + true, + ); + }); + + it('returns false for Tron trade', () => { + expect( + isStellarTrade({ + raw_data_hex: 'ab', + } as unknown as Trade), + ).toBe(false); + }); + }); + + describe('extractTradeData', () => { + it('returns string as-is for Solana trades', () => { + const solanaTrade = 'base64EncodedSolanaTransaction'; + expect(extractTradeData(solanaTrade)).toBe(solanaTrade); + }); + + it('returns xdrBase64 for Stellar trade object', () => { + expect( + extractTradeData({ + xdrBase64: 'stellarXdrPayload', + } as unknown as Trade), + ).toBe('stellarXdrPayload'); + }); + + it('returns xdr for Stellar trade object with xdr key', () => { + expect( + extractTradeData({ + xdr: 'stellarXdrAlt', + } as unknown as Trade), + ).toBe('stellarXdrAlt'); + }); + + it('falls back to xdr when xdrBase64 is present but not a string', () => { + expect( + extractTradeData({ + xdrBase64: null, + xdr: 'stellarXdrAlt', + } as unknown as Trade), + ).toBe('stellarXdrAlt'); + }); + + it('extracts data property from EVM TxData object', () => { + const evmTxData: TxData = { + chainId: 1, + to: '0x742d35Cc6634C0532925a3b844Bc9e7595f0bEb', + from: '0x742d35Cc6634C0532925a3b844Bc9e7595f0bEb', + value: '0x0', + data: '0x1234567890abcdef', + gasLimit: null, + }; + expect(extractTradeData(evmTxData)).toBe('0x1234567890abcdef'); + }); + + it('extracts data property from EVM TxData with gasLimit', () => { + const evmTxData: TxData = { + chainId: 137, + to: '0x742d35Cc6634C0532925a3b844Bc9e7595f0bEb', + from: '0x1234567890abcdef1234567890abcdef12345678', + value: '0x1234', + data: '0xabcdef123456', + gasLimit: 50000, + effectiveGas: 48000, + }; + expect(extractTradeData(evmTxData)).toBe('0xabcdef123456'); + }); + + it('extracts unsignedPsbtBase64 from Bitcoin trade', () => { + const bitcoinTrade: BitcoinTradeData = { + unsignedPsbtBase64: 'cHNidP8BAH...', + inputsToSign: null, + }; + expect(extractTradeData(bitcoinTrade)).toBe('cHNidP8BAH...'); + }); + + it('converts raw_data_hex to base64 for Tron trade', () => { + const tronTrade: TronTradeData = { + raw_data_hex: '68656c6c6f', // 'hello' in hex + visible: true, + }; + const result = extractTradeData(tronTrade); + // Buffer.from('68656c6c6f', 'hex').toString('base64') === 'aGVsbG8=' + expect(result).toBe('aGVsbG8='); + }); + + it('handles Tron trade with complex raw_data', () => { + const tronTrade: TronTradeData = { + raw_data_hex: '0a0212', + visible: false, + raw_data: { + contract: [{ type: 'TransferContract' }], + }, + }; + const result = extractTradeData(tronTrade); + // Buffer.from('0a0212', 'hex').toString('base64') + expect(result).toBe('CgIS'); + }); + + it('returns empty string for unrecognized trade format', () => { + const unknownTrade = { + someOtherField: 'value', + }; + expect(extractTradeData(unknownTrade as unknown as Trade)).toBe(''); + }); + + it('returns empty string for empty object', () => { + expect(extractTradeData({} as unknown as Trade)).toBe(''); + }); + }); +}); diff --git a/packages/bridge-controller/src/utils/trade-utils.ts b/packages/bridge-controller/src/utils/trade-utils.ts new file mode 100644 index 00000000000..a0319d2df03 --- /dev/null +++ b/packages/bridge-controller/src/utils/trade-utils.ts @@ -0,0 +1,49 @@ +import { + Trade, + isBitcoinTrade, + isTronTrade, + isEvmTxData, + isStellarTrade, + hasOwnProp, +} from '../validators/trade.js'; + +/** + * Extracts the transaction data from different trade formats + * + * @param trade - The trade object which can be a TxData, string, Bitcoin trade, or Tron trade + * @returns The extracted transaction data as a base64 string for SnapController + */ +export const extractTradeData = (trade: Trade): string => { + // Check more specific trade types first to prevent misidentification + if (isBitcoinTrade(trade)) { + // Bitcoin trades are already base64 encoded + return trade.unsignedPsbtBase64; + } + + if (isTronTrade(trade)) { + // Tron trades need hex to base64 conversion for SnapController + return Buffer.from(trade.raw_data_hex, 'hex').toString('base64'); + } + + if (isStellarTrade(trade)) { + if ( + hasOwnProp(trade, 'xdrBase64') && + typeof (trade as { xdrBase64: unknown }).xdrBase64 === 'string' + ) { + return (trade as { xdrBase64: string }).xdrBase64; + } + return (trade as { xdr: string }).xdr; + } + + if (typeof trade === 'string') { + // Solana txs - assuming already in correct format + return trade; + } + + if (isEvmTxData(trade)) { + // EVM TxData object - return the data property + return trade.data; + } + + return ''; +}; diff --git a/packages/bridge-controller/src/validators/amount-and-asset.ts b/packages/bridge-controller/src/validators/amount-and-asset.ts new file mode 100644 index 00000000000..90b465d4250 --- /dev/null +++ b/packages/bridge-controller/src/validators/amount-and-asset.ts @@ -0,0 +1,26 @@ +import { type, optional, union, string, Infer } from '@metamask/superstruct'; + +import { BridgeAssetV2Schema } from './bridge-asset.js'; +import { PositiveNumberStringSchema, FloatStringSchema } from './number.js'; + +export const AmountsAndAssetSchema = type({ + /* + * The atomic amount of the asset + * @example "1000000000000000000" + */ + amount: PositiveNumberStringSchema, + asset: BridgeAssetV2Schema, + /* + * The normalized amount of the asset + * @example "1.5" + */ + normalizedAmount: optional(FloatStringSchema), + // TODO remove string and fix usd in backend + usd: optional(union([FloatStringSchema, string()])), + /* + * The value of the asset in the currency, calculated based on usd value + * @example "0.15" + */ + valueInCurrency: optional(FloatStringSchema), +}); +export type AmountsAndAsset = Infer; diff --git a/packages/bridge-controller/src/validators/batch-sell.ts b/packages/bridge-controller/src/validators/batch-sell.ts new file mode 100644 index 00000000000..6aa45c9b296 --- /dev/null +++ b/packages/bridge-controller/src/validators/batch-sell.ts @@ -0,0 +1,52 @@ +import { + intersection, + type, + array, + enums, + optional, + assert, +} from '@metamask/superstruct'; +import type { Infer } from '@metamask/superstruct'; +import { StrictHexStruct } from '@metamask/utils'; + +import { BridgeAssetSchema } from './bridge-asset.js'; +import { PositiveNumberStringSchema } from './number.js'; +import { GaslessPropertiesSchema } from './quote.js'; +import { TxDataSchema } from './trade.js'; + +export enum BatchSellTransactionType { + TRADE = 'trade', + APPROVAL = 'approval', + TRANSFER = 'transfer', +} + +export const SimulatedGasFeeLimitsSchema = type({ + maxFeePerGas: StrictHexStruct, + maxPriorityFeePerGas: StrictHexStruct, +}); + +export const BatchSellTradesResponseSchema = intersection([ + type({ + transactions: array( + intersection([ + TxDataSchema, + SimulatedGasFeeLimitsSchema, + type({ type: enums(Object.values(BatchSellTransactionType)) }), + ]), + ), + fee: optional( + type({ + asset: BridgeAssetSchema, + amount: PositiveNumberStringSchema, + }), + ), + }), + GaslessPropertiesSchema, +]); + +export const validateBatchSellTradesResponse = ( + data: unknown, +): data is Infer => { + assert(data, BatchSellTradesResponseSchema); + return true; +}; diff --git a/packages/bridge-controller/src/validators/bridge-asset.ts b/packages/bridge-controller/src/validators/bridge-asset.ts new file mode 100644 index 00000000000..94834857ced --- /dev/null +++ b/packages/bridge-controller/src/validators/bridge-asset.ts @@ -0,0 +1,142 @@ +import { + number, + type, + string, + optional, + nullable, + is, + intersection, + enums, + array, + boolean, +} from '@metamask/superstruct'; +import type { Infer } from '@metamask/superstruct'; +import { CaipAssetTypeStruct } from '@metamask/utils'; + +export const ChainIdSchema = number(); + +export const MinimalAssetSchema = type({ + /** + * Case-sensitive for non-EVM chains, case-insensitive for EVM chains + */ + assetId: CaipAssetTypeStruct, + /** + * The symbol of the asset + */ + symbol: string(), + /** + * The name of the asset + */ + name: string(), + decimals: number(), +}); + +export type MinimalAsset = Infer; + +export enum BridgeAssetSecurityDataType { + INFO = 'Info', + BENIGN = 'Benign', + VERIFIED = 'Verified', + WARNING = 'Warning', + SPAM = 'Spam', + MALICIOUS = 'Malicious', +} + +const BridgeAssetSecurityData = type({ + isVerified: optional(boolean()), + securityData: optional( + type({ + type: enums(Object.values(BridgeAssetSecurityDataType)), + metadata: optional( + type({ + features: array( + type({ + featureId: string(), + type: enums(Object.values(BridgeAssetSecurityDataType)), + description: string(), + }), + ), + }), + ), + }), + ), +}); + +export const BridgeAssetV2Schema = intersection([ + MinimalAssetSchema, + BridgeAssetSecurityData, + type({ + /** + * URL for token icon + */ + iconUrl: nullable(optional(string())), + noFee: optional( + type({ + isDestination: nullable(optional(boolean())), + isSource: nullable(optional(boolean())), + }), + ), + }), +]); +export type BridgeAssetV2 = Infer; + +export const BridgeAssetSchema = type({ + /** + * The chainId of the token + */ + chainId: ChainIdSchema, + /** + * An address that the metaswap-api recognizes as the default token + */ + address: string(), + /** + * The assetId of the token + */ + assetId: CaipAssetTypeStruct, + /** + * The symbol of token object + */ + symbol: string(), + /** + * The name for the network + */ + name: string(), + decimals: number(), + /** + * URL for token icon + */ + icon: optional(nullable(string())), + /** + * URL for token icon + */ + iconUrl: optional(nullable(string())), +}); + +/** + * This is the interface for the asset object returned by the bridge-api + * This type is used in the QuoteResponse and in the fetchBridgeTokens response + * + * @deprecated Avoid introducing new code that uses this function. Use BridgeAssetV2 instead + */ +export type BridgeAsset = Infer; + +/** + * Validates a token object from the bridge-api + * + * @deprecated Avoid introducing new code that uses this function. Use BridgeAssetV2 instead + * + * @param data - The data to validate + * @returns Whether the data satisfies the {@link BridgeAssetSchema} + */ +export const validateBridgeAsset = ( + data: unknown, +): data is Infer => { + return is(data, BridgeAssetSchema); +}; + +/* istanbul ignore next */ +export const validateBridgeAssetV2 = ( + data: unknown, +): data is Infer => { + return is(data, BridgeAssetV2Schema); +}; diff --git a/packages/bridge-controller/src/validators/feature-flags.ts b/packages/bridge-controller/src/validators/feature-flags.ts new file mode 100644 index 00000000000..766ecb00d70 --- /dev/null +++ b/packages/bridge-controller/src/validators/feature-flags.ts @@ -0,0 +1,135 @@ +/* eslint-disable @typescript-eslint/naming-convention */ +import { + type, + record, + string, + optional, + array, + boolean, + number, + is, + define, +} from '@metamask/superstruct'; +import type { Infer } from '@metamask/superstruct'; +import { CaipChainIdStruct, CaipAssetTypeStruct } from '@metamask/utils'; + +export enum FeatureId { + UNKNOWN = 'unknown', + PERPS = 'perps', + QUICK_BUY_FOLLOW_TRADING = 'quick_buy_follow_trading', + QUICK_BUY_TOKEN_DETAILS = 'quick_buy_token_details', + QUICK_BUY_EXPLORE = 'quick_buy_explore', + DAPP_SWAP = 'dapp_swap', + BATCH_SELL = 'batch_sell', + UNIFIED_SWAP_BRIDGE = 'unified_swap_bridge', +} + +export const VersionStringSchema = define( + 'VersionString', + (value: unknown) => + typeof value === 'string' && + /^(\d+\.*){2}\d+$/u.test(value) && + value.split('.').length === 3, +); + +const DefaultPairSchema = type({ + /** + * The standard default pairs. Use this if the pair is only set once. + * The key is the CAIP asset type of the src token and the value is the CAIP asset type of the dest token. + */ + standard: record(string(), string()), + /** + * The other default pairs. Use this if the dest token depends on the src token and can be set multiple times. + * The key is the CAIP asset type of the src token and the value is the CAIP asset type of the dest token. + */ + other: record(string(), string()), +}); + +export const ChainRankingItemSchema = type({ + /** + * The CAIP-2 chain identifier (e.g., "eip155:1" for Ethereum mainnet) + */ + chainId: CaipChainIdStruct, + /** + * The display name of the chain (e.g., "Ethereum") + */ + name: string(), +}); + +export const ChainRankingSchema = optional(array(ChainRankingItemSchema)); + +export const ChainConfigurationSchema = type({ + isActiveSrc: boolean(), + isActiveDest: boolean(), + refreshRate: optional(number()), + topAssets: optional(array(string())), + stablecoins: optional(array(string())), + batchSellDestStablecoins: optional(array(CaipAssetTypeStruct)), + isUnifiedUIEnabled: optional(boolean()), + isSingleSwapBridgeButtonEnabled: optional(boolean()), + isGaslessSwapEnabled: optional(boolean()), + noFeeAssets: optional(array(string())), + defaultPairs: optional(DefaultPairSchema), +}); + +export const PriceImpactThresholdSchema = type({ + // We are moving into a unified approach where + // price impact thresholds will be segmented by + // importance rather than transaction type. + // The introduction of warning/danger will first be handled + // by mobile, followed by extension and then removal of gasless/normal + // from LD configs. + // To make the migration easier, we define all fields as optional for now. + // After the migration takes place, gasless/normal will be removed + // and warning/danger will be set as required fields. + gasless: number(), // Percentage value in decimal format (eg 0.02 is 2%) + normal: number(), // Percentage value in decimal format + warning: optional(number()), // Percentage value in decimal format + error: optional(number()), // Percentage value in decimal format +}); + +const GenericQuoteRequestSchema = type({ + aggIds: optional(array(string())), + bridgeIds: optional(array(string())), + fee: optional(number()), +}); + +/** + * This is the schema for the feature flags response from the RemoteFeatureFlagController + */ +export const PlatformConfigSchema = type({ + priceImpactThreshold: optional(PriceImpactThresholdSchema), + quoteRequestOverrides: optional( + record(string(), optional(GenericQuoteRequestSchema)), + ), + minimumVersion: string(), + refreshRate: number(), + maxRefreshCount: number(), + support: boolean(), + chains: record(string(), ChainConfigurationSchema), + /** + * The bip44 default pairs for the chains + * Key is the CAIP chainId namespace + */ + bip44DefaultPairs: optional(record(string(), optional(DefaultPairSchema))), + sse: optional( + type({ + enabled: boolean(), + /** + * The minimum version of the client required to enable SSE, for example 13.8.0 + */ + minimumVersion: VersionStringSchema, + }), + ), + /** + * Array of chain objects ordered by preference/ranking + */ + chainRanking: ChainRankingSchema, + maxPendingHistoryItemAgeMs: optional(number()), +}); + +export const validateFeatureFlagsResponse = ( + data: unknown, +): data is Infer => { + return is(data, PlatformConfigSchema); +}; diff --git a/packages/bridge-controller/src/validators/intent.ts b/packages/bridge-controller/src/validators/intent.ts new file mode 100644 index 00000000000..3f42e164bc2 --- /dev/null +++ b/packages/bridge-controller/src/validators/intent.ts @@ -0,0 +1,152 @@ +import { + union, + number, + string, + type, + optional, + enums, + boolean, + record, + array, + any, +} from '@metamask/superstruct'; + +import { TruthyDigitStringSchema } from './number.js'; +import { HexString } from './trade.js'; + +// Allow digit strings for amounts/validTo for flexibility across providers +const DigitStringOrNumberSchema = union([TruthyDigitStringSchema, number()]); +/** + * Identifier of the intent protocol used for order creation and submission. + * + * Examples: + * - CoW Swap + * - Other EIP-712–based intent protocols + */ +const IntentProtocolSchema = string(); +/** + * Schema for an intent-based order used for EIP-712 signing and submission. + * + * This represents the minimal subset of fields required by intent-based + * protocols (e.g. CoW Swap) to build, sign, and submit an order. + */ + +export const IntentOrderSchema = type({ + /** + * Address of the token being sold. + */ + sellToken: HexString, + + /** + * Address of the token being bought. + */ + buyToken: HexString, + + /** + * Optional receiver of the bought tokens. + * If omitted, defaults to the signer / order owner. + */ + receiver: optional(HexString), + + /** + * Order expiration time. + * + * Can be provided as a UNIX timestamp in seconds, either as a number + * or as a digit string, depending on provider requirements. + */ + validTo: DigitStringOrNumberSchema, + + /** + * Arbitrary application-specific data attached to the order. + */ + appData: string(), + + /** + * Hash of the `appData` field, used for EIP-712 signing. + */ + appDataHash: HexString, + + /** + * Fee amount paid for order execution, expressed as a digit string. + */ + feeAmount: TruthyDigitStringSchema, + + /** + * Order kind. + * + * - `sell`: exact sell amount, variable buy amount + * - `buy`: exact buy amount, variable sell amount + */ + kind: enums(['sell', 'buy']), + + /** + * Whether the order can be partially filled. + */ + partiallyFillable: boolean(), + + /** + * Exact amount of the sell token. + * + * Required for `sell` orders. + */ + sellAmount: optional(TruthyDigitStringSchema), + + /** + * Exact amount of the buy token. + * + * Required for `buy` orders. + */ + buyAmount: optional(TruthyDigitStringSchema), + + /** + * Optional order owner / sender address. + * + * Provided for convenience when building the EIP-712 domain and message. + */ + from: optional(HexString), +}); +/** + * Schema representing an intent submission payload. + * + * Wraps the intent order along with protocol and optional routing metadata + * required by the backend or relayer infrastructure. + */ + +export const IntentSchema = type({ + /** + * Identifier of the intent protocol used to interpret the order. + */ + protocol: IntentProtocolSchema, + + /** + * The intent order to be signed and submitted. + */ + order: IntentOrderSchema, + + /** + * Optional settlement contract address used for execution. + */ + settlementContract: optional(HexString), + + /** + * Optional EIP-712 typed data payload for signing. + * Must be JSON-serializable and include required EIP-712 fields. + */ + typedData: type({ + // Keep values as `any()` here. Using `unknown()` in this record causes + // TS2321/TS2589 (excessive type instantiation depth) in bridge state + // inference during build. + types: record( + string(), + array( + type({ + name: string(), + type: string(), + }), + ), + ), + primaryType: string(), + domain: record(string(), any()), + message: record(string(), any()), + }), +}); diff --git a/packages/bridge-controller/src/validators/number.ts b/packages/bridge-controller/src/validators/number.ts new file mode 100644 index 00000000000..7d6ebc1c1fc --- /dev/null +++ b/packages/bridge-controller/src/validators/number.ts @@ -0,0 +1,13 @@ +import { define, pattern, string } from '@metamask/superstruct'; + +export const PositiveNumberStringSchema = define( + 'NumberString', + (value: unknown) => typeof value === 'string' && /^\d+$/u.test(value), +); + +export const TruthyDigitStringSchema = pattern(string(), /^\d+$/u); + +export const FloatStringSchema = define( + 'FloatString', + (value: unknown) => typeof value === 'string' && /^-*\d*\.*\d+$/u.test(value), +); diff --git a/packages/bridge-controller/src/validators/quote-request.ts b/packages/bridge-controller/src/validators/quote-request.ts new file mode 100644 index 00000000000..9bc4219cea3 --- /dev/null +++ b/packages/bridge-controller/src/validators/quote-request.ts @@ -0,0 +1,72 @@ +/* eslint-disable no-restricted-syntax */ +import type { GenericQuoteRequest } from '../types.js'; +import { isNonEvmChainId } from '../utils/bridge.js'; + +export const isValidQuoteRequest = ( + partialRequest: Partial, + requireAmount = true, +): partialRequest is GenericQuoteRequest => { + const stringFields = [ + 'srcTokenAddress', + 'destTokenAddress', + 'srcChainId', + 'destChainId', + 'walletAddress', + ]; + if (requireAmount) { + stringFields.push('srcTokenAmount'); + } + // If bridging between different chain types or different non-EVM chains, require dest wallet address + // Cases that need destWalletAddress: + // 1. EVM -> non-EVM + // 2. non-EVM -> EVM + // 3. non-EVM -> different non-EVM (e.g., SOL -> BTC) + // Only same-chain swaps don't need destWalletAddress + if ( + partialRequest.destChainId && + partialRequest.srcChainId && + partialRequest.destChainId !== partialRequest.srcChainId && // Different chains + (isNonEvmChainId(partialRequest.destChainId) || + isNonEvmChainId(partialRequest.srcChainId)) // At least one is non-EVM + ) { + stringFields.push('destWalletAddress'); + if (!partialRequest.destWalletAddress) { + return false; + } + } + const numberFields = []; + // if slippage is defined, require it to be a number + if (partialRequest.slippage !== undefined) { + numberFields.push('slippage'); + } + + return ( + stringFields.every( + (field) => + field in partialRequest && + typeof partialRequest[field as keyof typeof partialRequest] === + 'string' && + partialRequest[field as keyof typeof partialRequest] !== undefined && + partialRequest[field as keyof typeof partialRequest] !== '' && + partialRequest[field as keyof typeof partialRequest] !== null, + ) && + numberFields.every( + (field) => + field in partialRequest && + typeof partialRequest[field as keyof typeof partialRequest] === + 'number' && + partialRequest[field as keyof typeof partialRequest] !== undefined && + !isNaN(Number(partialRequest[field as keyof typeof partialRequest])) && + partialRequest[field as keyof typeof partialRequest] !== null, + ) && + (requireAmount + ? Boolean((partialRequest.srcTokenAmount ?? '').match(/^[1-9]\d*$/u)) + : true) + ); +}; + +export const isValidBatchSellQuoteRequest = ( + quoteRequests: Partial[], + requireAmount = true, +): quoteRequests is GenericQuoteRequest[] => + quoteRequests.every((req) => isValidQuoteRequest(req, requireAmount)); diff --git a/packages/bridge-controller/src/validators/quote-response-v1.ts b/packages/bridge-controller/src/validators/quote-response-v1.ts new file mode 100644 index 00000000000..0956b693083 --- /dev/null +++ b/packages/bridge-controller/src/validators/quote-response-v1.ts @@ -0,0 +1,85 @@ +import type { Infer } from '@metamask/superstruct'; +import { + string, + number, + type, + optional, + enums, + union, + assert, +} from '@metamask/superstruct'; +import { StrictHexStruct } from '@metamask/utils'; + +import { FeatureId } from './feature-flags.js'; +import { FloatStringSchema } from './number.js'; +import { QuoteSchema } from './quote.js'; +import { + TxDataSchema, + TronTradeDataSchema, + BitcoinTradeDataSchema, + StellarTradeDataSchema, +} from './trade.js'; +import type { + BitcoinTradeData, + StellarTradeData, + TronTradeData, + TxData, +} from './trade.js'; + +export const QuoteResponseSchemaV1 = type({ + featureId: optional(enums(Object.values(FeatureId))), + quoteId: optional(string()), + quote: QuoteSchema, + estimatedProcessingTimeInSeconds: number(), + approval: optional(union([TxDataSchema, TronTradeDataSchema])), + trade: union([ + TxDataSchema, + BitcoinTradeDataSchema, + TronTradeDataSchema, + StellarTradeDataSchema, + string(), + ]), + l1GasFeesInHexWei: optional(StrictHexStruct), + nonEvmFeesInNative: optional(FloatStringSchema), +}); + +export const validateQuoteResponseV1 = ( + data: unknown, +): data is Infer => { + assert(data, QuoteResponseSchemaV1); + return true; +}; + +/** + * This is the type for the quote response from the bridge-api + * TxDataType can be overriden to be a string when the quote is non-evm + * ApprovalType can be overriden when you know the specific approval type (e.g., TxData for EVM-only contexts) + * + * @deprecated Use `QuoteResponseV2` instead + */ +export type QuoteResponseV1< + TxDataType = + | TxData + | string + | BitcoinTradeData + | TronTradeData + | StellarTradeData, + ApprovalType = TxData | TronTradeData, +> = Infer & { + trade: TxDataType; + approval?: ApprovalType; + /** + * Appended to the quote response based on the quote request + */ + featureId?: FeatureId; + /** + * Appended to the quote response based on the quote request resetApproval flag + * If defined, the quote's total network fee will include the reset approval's gas limit. + */ + resetApproval?: TxData; + /** + * Appended to the quote if there are multiple quote requests in a batch. This + * indicates which quoteRequest the quote is for + */ + quoteRequestIndex?: number; +}; diff --git a/packages/bridge-controller/src/validators/quote-response.test.ts b/packages/bridge-controller/src/validators/quote-response.test.ts new file mode 100644 index 00000000000..5f15b86e29c --- /dev/null +++ b/packages/bridge-controller/src/validators/quote-response.test.ts @@ -0,0 +1,33 @@ +import { KnownCaipNamespace } from '@metamask/utils'; + +import { mockBridgeQuotesErc20Erc20V2Migration } from '../../tests/mock-quotes-erc20-erc20-migration-v2.js'; +import { validateQuoteResponse } from './quote-response.js'; + +describe('quote-response-v2', () => { + describe('validateQuoteResponse', () => { + it('should return a validation error for an invalid quote response', () => { + const quoteResponse = { + quote: { + requestId: '123', + }, + }; + + expect(() => + validateQuoteResponse(quoteResponse), + ).toThrowErrorMatchingInlineSnapshot( + `"At path: quote.src -- Expected an object, but received: undefined"`, + ); + }); + + it('should validate a valid quote response', () => { + const quoteResponse = mockBridgeQuotesErc20Erc20V2Migration[0]; + expect(validateQuoteResponse(quoteResponse)).toBe(true); + }); + + it('should return false when quote namespace is not supported', () => { + const quoteResponse = mockBridgeQuotesErc20Erc20V2Migration[0]; + quoteResponse.quote.src.asset.assetId = `${KnownCaipNamespace.Wallet}:123/token:test`; + expect(validateQuoteResponse(quoteResponse)).toBe(false); + }); + }); +}); diff --git a/packages/bridge-controller/src/validators/quote-response.ts b/packages/bridge-controller/src/validators/quote-response.ts new file mode 100644 index 00000000000..741a4438034 --- /dev/null +++ b/packages/bridge-controller/src/validators/quote-response.ts @@ -0,0 +1,229 @@ +import { + assert, + Infer, + number, + optional, + string, + type, + union, + intersection, + Describe, + nullable, + enums, + literal, + AnyStruct, +} from '@metamask/superstruct'; +import { + CaipChainId, + CaipChainIdStruct, + KnownCaipNamespace, + parseCaipAssetType, + StrictHexStruct, +} from '@metamask/utils'; + +import { FeatureId } from './feature-flags.js'; +import { FloatStringSchema } from './number.js'; +import { QuoteSchemaV2 } from './quote.js'; +import { + BitcoinTradeData, + BitcoinTradeDataSchema, + StellarTradeData, + StellarTradeDataSchema, + TronTradeData, + TronTradeDataSchema, + TxData, + TxDataSchema, +} from './trade.js'; + +const CommonQuoteResponseSchema = type({ + quoteId: optional(string()), + quote: QuoteSchemaV2, + estimatedProcessingTimeInSeconds: number(), + /** + * Appended to the quote if there are multiple quote requests in a batch. This + * indicates which quoteRequest the quote is for + */ + quoteRequestIndex: optional(number()), + /** + * Appended to the quote response based on the quote requested featureId + */ + featureId: optional(enums(Object.values(FeatureId))), + /** + * Appended to the quote response based on the quote request nonEvmFeesInNative flag + * + * @deprecated Use network feeData + */ + nonEvmFeesInNative: optional(FloatStringSchema), + /** + * Appended to the quote response based on the quote request l1GasFeesInHexWei flag + * + * @deprecated Use network feeData + */ + l1GasFeesInHexWei: optional(StrictHexStruct), +}); + +const EvmQuoteResponseSchema = intersection([ + CommonQuoteResponseSchema, + type({ + namespace: literal(KnownCaipNamespace.Eip155), + chainId: CaipChainIdStruct, + trade: TxDataSchema, + approval: optional(TxDataSchema), + resetApproval: optional(TxDataSchema), + }), +]); + +const TronQuoteResponseSchema = intersection([ + CommonQuoteResponseSchema, + type({ + namespace: literal(KnownCaipNamespace.Tron), + chainId: CaipChainIdStruct, + trade: TronTradeDataSchema, + approval: optional(TronTradeDataSchema), + }), +]); + +const SolanaQuoteResponseSchema = intersection([ + CommonQuoteResponseSchema, + type({ + namespace: literal(KnownCaipNamespace.Solana), + chainId: CaipChainIdStruct, + trade: string(), + approval: optional(TxDataSchema), + }), +]); + +const BitcoinQuoteResponseSchema = intersection([ + CommonQuoteResponseSchema, + type({ + namespace: literal(KnownCaipNamespace.Bip122), + chainId: CaipChainIdStruct, + trade: BitcoinTradeDataSchema, + approval: optional(TxDataSchema), + }), +]); + +const StellarQuoteResponseSchema = intersection([ + CommonQuoteResponseSchema, + type({ + namespace: literal(KnownCaipNamespace.Stellar), + chainId: CaipChainIdStruct, + trade: StellarTradeDataSchema, + approval: optional(TxDataSchema), + }), +]); + +export const QuoteResponseSchemaV2 = nullable( + union([ + EvmQuoteResponseSchema, + SolanaQuoteResponseSchema, + TronQuoteResponseSchema, + BitcoinQuoteResponseSchema, + StellarQuoteResponseSchema, + ]), +); + +/** + * This is the V2 QuoteResponse type, including metadata calculated after quote fetch + */ +export type QuoteResponse = Omit< + NonNullable>, + 'trade' | 'approval' | 'resetApproval' +> & + ( + | { + namespace: KnownCaipNamespace.Eip155; + chainId: CaipChainId; + trade: TxData & { + data: string; + }; + approval?: TxData; + /** + * Appended to the quote response based on the quote request resetApproval flag + * If defined, the quote's total network fee will include the reset approval's gas limit. + */ + resetApproval?: TxData; + } + | { + namespace: KnownCaipNamespace.Solana; + chainId: CaipChainId; + trade: string; + approval?: TxData; + } + | { + namespace: KnownCaipNamespace.Tron; + chainId: CaipChainId; + trade: TronTradeData & { + // eslint-disable-next-line @typescript-eslint/naming-convention + raw_data_hex: string; + }; + approval?: TronTradeData; + } + | { + namespace: KnownCaipNamespace.Bip122; + chainId: CaipChainId; + trade: BitcoinTradeData; + approval?: TxData; + } + | { + namespace: KnownCaipNamespace.Stellar; + chainId: CaipChainId; + trade: StellarTradeData; + approval?: TxData; + } + ); +// This ensures the QuoteResponse type is in sync with the QuoteResponseSchemaV2 +const QuoteResponse: Describe = QuoteResponseSchemaV2; + +const NAMESPACE_TO_TRADE_SCHEMA: Record = + { + [KnownCaipNamespace.Eip155]: EvmQuoteResponseSchema, + [KnownCaipNamespace.Tron]: TronQuoteResponseSchema, + [KnownCaipNamespace.Solana]: SolanaQuoteResponseSchema, + [KnownCaipNamespace.Bip122]: BitcoinQuoteResponseSchema, + [KnownCaipNamespace.Stellar]: StellarQuoteResponseSchema, + }; + +export const validateQuoteResponse = ( + quoteResponse: unknown, +): quoteResponse is QuoteResponse => { + // Validate common fields first + assert(quoteResponse, CommonQuoteResponseSchema); + + // Extract the namespace and chainId from the src asset + const { + chain: { namespace: namespaceString }, + chainId, + } = parseCaipAssetType(quoteResponse.quote.src.asset.assetId); + const namespace = namespaceString as QuoteResponse['namespace']; + + if (!NAMESPACE_TO_TRADE_SCHEMA[namespace]) { + return false; + } + + // Validate the trade and approval fields based on the src chain's namespace + assert( + { + ...quoteResponse, + namespace, + chainId, + }, + NAMESPACE_TO_TRADE_SCHEMA[namespace], + ); + return true; +}; + +/** + * Shallow check for the presence of the `src` property + * + * @param quote - The quote to check + * @returns True if the quote is a V2 quote, false otherwise + */ +export const isQuoteResponseV2 = ( + quote: { quote?: object } | null | undefined, +): quote is QuoteResponse => { + if (!quote?.quote) { + return false; + } + return Object.prototype.hasOwnProperty.call(quote.quote, 'src'); +}; diff --git a/packages/bridge-controller/src/validators/quote-stream-complete.ts b/packages/bridge-controller/src/validators/quote-stream-complete.ts new file mode 100644 index 00000000000..4e4dc790b98 --- /dev/null +++ b/packages/bridge-controller/src/validators/quote-stream-complete.ts @@ -0,0 +1,39 @@ +/* eslint-disable @typescript-eslint/naming-convention */ +import { + type, + number, + boolean, + optional, + enums, + record, + string, + any, + assert, +} from '@metamask/superstruct'; +import type { Infer } from '@metamask/superstruct'; + +export enum QuoteStreamCompleteReason { + RETRY = 'RETRY', + AMOUNT_TOO_HIGH = 'AMOUNT_TOO_HIGH', + AMOUNT_TOO_LOW = 'AMOUNT_TOO_LOW', + SLIPPAGE_TOO_HIGH = 'SLIPPAGE_TOO_HIGH', + SLIPPAGE_TOO_LOW = 'SLIPPAGE_TOO_LOW', + TOKEN_NOT_SUPPORTED = 'TOKEN_NOT_SUPPORTED', + RWA_GEO_RESTRICTED = 'RWA_GEO_RESTRICTED', + RWA_NATIVE_TOKEN_UNSUPPORTED = 'RWA_NATIVE_TOKEN_UNSUPPORTED', + RWA_MARKET_UNAVAILABLE = 'RWA_MARKET_UNAVAILABLE', +} + +export const QuoteStreamCompleteSchema = type({ + quoteCount: number(), + hasQuotes: boolean(), + reason: optional(enums(Object.values(QuoteStreamCompleteReason))), + context: optional(record(string(), any())), +}); + +export const validateQuoteStreamComplete = ( + data: unknown, +): data is Infer => { + assert(data, QuoteStreamCompleteSchema); + return true; +}; diff --git a/packages/bridge-controller/src/validators/quote.ts b/packages/bridge-controller/src/validators/quote.ts new file mode 100644 index 00000000000..ca318670c92 --- /dev/null +++ b/packages/bridge-controller/src/validators/quote.ts @@ -0,0 +1,196 @@ +import { + type, + optional, + boolean, + intersection, + string, + number, + array, + nullable, + partial, + pick, +} from '@metamask/superstruct'; +import type { Infer } from '@metamask/superstruct'; + +import { AmountsAndAssetSchema } from './amount-and-asset.js'; +import { ChainIdSchema, BridgeAssetSchema } from './bridge-asset.js'; +import { IntentSchema } from './intent.js'; +import { + TruthyDigitStringSchema, + PositiveNumberStringSchema, + FloatStringSchema, +} from './number.js'; +import { RefuelDataSchema, StepSchema, StepSchemaV2 } from './step.js'; + +export enum FeeType { + METABRIDGE = 'metabridge', + REFUEL = 'refuel', + // eslint-disable-next-line @typescript-eslint/naming-convention + TX_FEE = 'txFee', + NETWORK = 'network', + RELAYER = 'relayer', +} + +export enum DiscountType { + VIP = 'vip', + PROMO = 'promo', + DAO = 'dao', +} + +export const FeeDataSchema = type({ + amount: TruthyDigitStringSchema, + asset: BridgeAssetSchema, + discountType: optional(nullable(string())), +}); + +export const TxFeeGasLimitsSchema = type({ + maxFeePerGas: PositiveNumberStringSchema, + maxPriorityFeePerGas: PositiveNumberStringSchema, +}); + +export const GaslessPropertiesSchema = type({ + gasIncluded: optional(boolean()), + /** + * Whether the quote can use EIP-7702 delegated gasless execution + */ + gasIncluded7702: optional(boolean()), + /** + * A third party sponsors the gas. If true, then gasIncluded7702 is also true. + */ + gasSponsored: optional(boolean()), +}); + +export const QuoteSchema = intersection([ + GaslessPropertiesSchema, + type({ + requestId: string(), + srcChainId: ChainIdSchema, + srcAsset: BridgeAssetSchema, + /** + * The amount sent, in atomic amount: amount sent - fees + * Some tokens have a fee of 0, so sometimes it's equal to amount sent + */ + srcTokenAmount: string(), + destChainId: ChainIdSchema, + destAsset: BridgeAssetSchema, + /** + * The amount received, in atomic amount + */ + destTokenAmount: string(), + /** + * The minimum amount that will be received, in atomic amount + */ + minDestTokenAmount: optional(string()), + feeData: type({ + [FeeType.METABRIDGE]: intersection([ + FeeDataSchema, + type({ + quoteBpsFee: optional(number()), + baseBpsFee: optional(number()), + }), + ]), + /** + * This is the fee for the swap transaction taken from either the + * src or dest token if the quote has gas fees included or "gasless" + */ + [FeeType.TX_FEE]: optional( + intersection([FeeDataSchema, TxFeeGasLimitsSchema]), + ), + }), + bridgeId: string(), + bridges: array(string()), + // TODO require this after v2 migration + aggregator: optional(string()), + steps: array(StepSchema), + refuel: optional(RefuelDataSchema), + priceData: optional( + type({ + totalFromAmountUsd: optional(string()), + totalToAmountUsd: optional(string()), + priceImpact: optional(string()), + totalFeeAmountUsd: optional(string()), + }), + ), + intent: optional(IntentSchema), + walletAddress: optional(string()), + destWalletAddress: optional(string()), + slippage: optional(number()), + // TODO require this after v2 migration + protocols: optional(array(string())), + }), +]); + +export type Quote = Infer; + +export const QuoteSchemaV2 = intersection([ + GaslessPropertiesSchema, + type({ + requestId: string(), + src: intersection([ + AmountsAndAssetSchema, + type({ + walletAddress: optional(string()), + }), + ]), + dest: intersection([ + AmountsAndAssetSchema, + type({ + minAmount: optional(string()), + minAmountUsd: optional(string()), + minAmountValueInCurrency: optional(string()), + minAmountNormalized: optional(string()), + walletAddress: optional(string()), + }), + ]), + priceData: optional( + partial( + type({ + swapRate: FloatStringSchema, + priceImpact: intersection([ + type({ + amount: optional(FloatStringSchema), + }), + pick(AmountsAndAssetSchema, ['usd', 'valueInCurrency']), + ]), + adjustedReturn: pick(AmountsAndAssetSchema, [ + 'usd', + 'valueInCurrency', + ]), + }), + ), + ), + feeData: type({ + [FeeType.METABRIDGE]: array( + intersection([ + AmountsAndAssetSchema, + type({ + quoteBpsFee: optional(number()), + baseBpsFee: optional(number()), + discountType: optional(nullable(string())), + }), + ]), + ), + [FeeType.REFUEL]: optional(array(AmountsAndAssetSchema)), + /** + * The tx fees included in the quote for gasless execution + */ + [FeeType.TX_FEE]: optional( + array(intersection([AmountsAndAssetSchema, TxFeeGasLimitsSchema])), + ), + /** + * The gas fees for the quote, excluding any provider or relayer fees + */ + [FeeType.NETWORK]: optional(array(AmountsAndAssetSchema)), + /** + * The relayer or provider fees for the quote, + */ + [FeeType.RELAYER]: optional(array(AmountsAndAssetSchema)), + }), + aggregator: string(), + protocols: array(string()), + steps: optional(array(StepSchemaV2)), + refuel: optional(StepSchema), + intent: optional(IntentSchema), + slippage: optional(number()), + }), +]); diff --git a/packages/bridge-controller/src/validators/step.ts b/packages/bridge-controller/src/validators/step.ts new file mode 100644 index 00000000000..64b185fca76 --- /dev/null +++ b/packages/bridge-controller/src/validators/step.ts @@ -0,0 +1,26 @@ +import { type, enums, optional, pick } from '@metamask/superstruct'; + +import { AmountsAndAssetSchema } from './amount-and-asset.js'; +import { BridgeAssetSchema, ChainIdSchema } from './bridge-asset.js'; + +export enum ActionTypes { + BRIDGE = 'bridge', + SWAP = 'swap', + REFUEL = 'refuel', +} + +export const StepSchema = type({ + action: enums(Object.values(ActionTypes)), + srcChainId: ChainIdSchema, + destChainId: optional(ChainIdSchema), + srcAsset: optional(BridgeAssetSchema), + destAsset: optional(BridgeAssetSchema), +}); + +export const RefuelDataSchema = StepSchema; + +export const StepSchemaV2 = type({ + action: enums(Object.values(ActionTypes)), + src: pick(AmountsAndAssetSchema, ['asset']), + dest: pick(AmountsAndAssetSchema, ['asset']), +}); diff --git a/packages/bridge-controller/src/validators/token-feature.ts b/packages/bridge-controller/src/validators/token-feature.ts new file mode 100644 index 00000000000..e9e5fcaf841 --- /dev/null +++ b/packages/bridge-controller/src/validators/token-feature.ts @@ -0,0 +1,22 @@ +import { type, string, enums, assert } from '@metamask/superstruct'; +import type { Infer } from '@metamask/superstruct'; + +export enum TokenFeatureType { + MALICIOUS = 'Malicious', + WARNING = 'Warning', + INFO = 'Info', + BENIGN = 'Benign', +} + +export const TokenFeatureSchema = type({ + feature_id: string(), + type: enums(Object.values(TokenFeatureType)), + description: string(), +}); + +export const validateTokenFeature = ( + data: unknown, +): data is Infer => { + assert(data, TokenFeatureSchema); + return true; +}; diff --git a/packages/bridge-controller/src/validators/trade.ts b/packages/bridge-controller/src/validators/trade.ts new file mode 100644 index 00000000000..dde71c9f179 --- /dev/null +++ b/packages/bridge-controller/src/validators/trade.ts @@ -0,0 +1,140 @@ +/* eslint-disable no-restricted-syntax */ +import { + type, + number, + nullable, + optional, + string, + array, + boolean, + define, + union, +} from '@metamask/superstruct'; +import type { Infer } from '@metamask/superstruct'; + +export const HexString = define<`0x${string}`>( + 'HexString', + (value: unknown): value is `0x${string}` => + typeof value === 'string' && /^0x[a-zA-Z0-9]*$/u.test(value), +); + +export const TxDataSchema = type({ + chainId: number(), + to: HexString, + from: HexString, + value: HexString, + data: HexString, + gasLimit: nullable(number()), + effectiveGas: optional(number()), +}); + +export const BitcoinTradeDataSchema = type({ + unsignedPsbtBase64: string(), + inputsToSign: nullable(array(type({}))), +}); + +export const TronTradeDataSchema = type({ + raw_data_hex: string(), + visible: optional(boolean()), + raw_data: optional( + nullable( + type({ + contract: optional( + array( + type({ + type: optional(string()), + }), + ), + ), + fee_limit: optional(number()), + }), + ), + ), +}); // Union type representing all possible trade formats (EVM, Solana, Bitcoin, Tron) + +/** + * Stellar bridge quote: unsigned transaction envelope as XDR (base64). + */ +export const StellarTradeDataSchema = union([ + type({ xdrBase64: string() }), + type({ xdr: string() }), +]); + +export type Trade = + | TxData + | string + | BitcoinTradeData + | TronTradeData + | StellarTradeData; +/** + * Type guard to check if a trade is an EVM TxData object + * + * @param trade - The trade object to check + * @returns True if the trade is a TxData object with data property + */ + +export const isEvmTxData = (trade: Trade): trade is TxData => { + return ( + typeof trade === 'object' && + trade !== null && + 'data' in trade && + 'chainId' in trade && + 'to' in trade + ); +}; +/** + * Type guard to check if a trade is a Bitcoin trade with unsignedPsbtBase64 + * + * @param trade - The trade object to check + * @returns True if the trade is a Bitcoin trade with unsignedPsbtBase64 property + */ + +export const isBitcoinTrade = (trade: Trade): trade is BitcoinTradeData => { + return ( + typeof trade === 'object' && trade !== null && 'unsignedPsbtBase64' in trade + ); +}; +/** + * Type guard to check if a trade is a Tron trade with raw_data_hex + * + * @param trade - The trade object to check + * @returns True if the trade is a Tron trade with raw_data_hex property + */ + +export const isTronTrade = (trade: Trade): trade is TronTradeData => { + return typeof trade === 'object' && trade !== null && 'raw_data_hex' in trade; +}; + +export const hasOwnProp = (obj: object, key: PropertyKey): boolean => + Object.prototype.hasOwnProperty.call(obj, key); + +/** + * Type guard to check if a trade is a Stellar trade with XDR (base64) payload + * + * @param trade - The trade object to check + * @returns True if the trade is a Stellar trade with xdrBase64 or xdr property + */ +export const isStellarTrade = (trade: Trade): trade is StellarTradeData => { + if (typeof trade !== 'object' || trade === null) { + return false; + } + if ( + hasOwnProp(trade, 'xdrBase64') && + typeof (trade as { xdrBase64: unknown }).xdrBase64 === 'string' + ) { + return true; + } + if ( + hasOwnProp(trade, 'xdr') && + typeof (trade as { xdr: unknown }).xdr === 'string' + ) { + return true; + } + return false; +}; + +export type BitcoinTradeData = Infer; + +export type TronTradeData = Infer; +export type TxData = Infer; +export type StellarTradeData = Infer; diff --git a/packages/bridge-controller/src/validators/validators.test.ts b/packages/bridge-controller/src/validators/validators.test.ts new file mode 100644 index 00000000000..58a0bfc835d --- /dev/null +++ b/packages/bridge-controller/src/validators/validators.test.ts @@ -0,0 +1,603 @@ +import { is } from '@metamask/superstruct'; + +import { mockBridgeQuotesNativeErc20EthV1 } from '../../tests/mock-quotes-native-erc20-eth.js'; +import { validateFeatureFlagsResponse } from './feature-flags.js'; +import { IntentSchema } from './intent.js'; +import { validateQuoteResponseV1 } from './quote-response-v1.js'; +import { + validateQuoteStreamComplete, + QuoteStreamCompleteReason, +} from './quote-stream-complete.js'; +import { DiscountType } from './quote.js'; +import { FeeDataSchema } from './quote.js'; + +describe('validators', () => { + describe('validateFeatureFlagsResponse', () => { + it.each([ + { + response: { + chains: { + '1': { + isActiveDest: true, + isActiveSrc: true, + batchSellDestStablecoins: [ + 'eip155:1/erc20:0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48', + 'eip155:1/slip44:60', + ], + }, + }, + maxRefreshCount: 5, + refreshRate: 30000, + support: true, + minimumVersion: '0.0.0', + chainRanking: [{ chainId: 'eip155:1', name: 'Ethereum' }], + }, + type: 'batch sell destination stablecoins', + expected: true, + }, + { + response: { + chains: { + '1': { + isActiveDest: true, + isActiveSrc: true, + batchSellDestStablecoins: ['not-a-caip-asset-type'], + }, + }, + maxRefreshCount: 5, + refreshRate: 30000, + support: true, + minimumVersion: '0.0.0', + chainRanking: [{ chainId: 'eip155:1', name: 'Ethereum' }], + }, + type: 'malformed batch sell destination stablecoins', + expected: false, + }, + { + response: { + chains: { + '1': { + isActiveDest: true, + isActiveSrc: true, + isGaslessSwapEnabled: true, + }, + '10': { isActiveDest: true, isActiveSrc: true }, + '137': { isActiveDest: true, isActiveSrc: true }, + '324': { isActiveDest: true, isActiveSrc: true }, + '42161': { isActiveDest: true, isActiveSrc: true }, + '43114': { + isActiveDest: true, + isActiveSrc: true, + isGaslessSwapEnabled: false, + }, + '56': { isActiveDest: true, isActiveSrc: true }, + '59144': { isActiveDest: true, isActiveSrc: true }, + '8453': { isActiveDest: true, isActiveSrc: true }, + }, + maxRefreshCount: 5, + refreshRate: 30000, + support: true, + minimumVersion: '0.0.0', + chainRanking: [{ chainId: 'eip155:1', name: 'Ethereum' }], + }, + type: 'all evm chains active', + expected: true, + }, + { + response: { + chains: {}, + maxRefreshCount: 1, + refreshRate: 3000000, + support: false, + minimumVersion: '0.0.0', + chainRanking: [], + }, + type: 'bridge disabled', + expected: true, + }, + { + response: { + chains: { + '1': { + isActiveDest: true, + isActiveSrc: true, + }, + '10': { + isActiveDest: true, + isActiveSrc: true, + }, + '56': { + isActiveDest: true, + isActiveSrc: true, + }, + '137': { + isActiveDest: true, + isActiveSrc: true, + }, + '324': { + isActiveDest: true, + isActiveSrc: true, + }, + '8453': { + isActiveDest: true, + isActiveSrc: true, + }, + '42161': { + isActiveDest: true, + isActiveSrc: true, + }, + '43114': { + isActiveDest: true, + isActiveSrc: true, + }, + '59144': { + isActiveDest: true, + isActiveSrc: true, + }, + '1151111081099710': { + isActiveDest: true, + isActiveSrc: true, + refreshRate: 10000, + topAssets: [ + 'EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v', + '6p6xgHyF7AeE6TZkSmFsko444wqoP15icUSqi2jfGiPN', + 'JUPyiwrYJFskUPiHa7hkeR8VUtAeFoSYbKedZNsDvCN', + '7vfCXTUXx5WJV5JADk17DUJ4ksgau7utNKj4b963voxsDx8F8k8k3uYw1PDC', + '3iQL8BFS2vE7mww4ehAqQHAsbmRNCrPxizWAT2Zfyr9y', + '9zNQRsGLjNKwCUU5Gq5LR8beUCPzQMVMqKAi3SSZh54u', + 'DezXAZ8z7PnrnRJjz3wXBoRgixCa6xjnB7YaB1pPB263', + 'rndrizKT3MK1iimdxRdWabcF7Zg7AR5T4nud4EkHBof', + '21AErpiB8uSb94oQKRcwuHqyHF93njAxBSbdUrpupump', + ], + }, + }, + maxRefreshCount: 5, + refreshRate: 30000, + support: true, + minimumVersion: '0.0.0', + chainRanking: [{ chainId: 'eip155:1', name: 'Ethereum' }], + }, + type: 'evm and solana chain config', + expected: true, + }, + { + response: { + chains: { + '1': { + isActiveDest: true, + isActiveSrc: true, + defaultPairs: { + standard: { + 'bip122:000000000019d6689c085ae165831e93/slip44:0': + 'eip155:1/slip44:60', + }, + other: {}, + }, + }, + '10': { + isActiveDest: true, + isActiveSrc: true, + }, + '56': { + isActiveDest: true, + isActiveSrc: true, + }, + '137': { + isActiveDest: true, + isActiveSrc: true, + }, + '324': { + isActiveDest: true, + isActiveSrc: true, + }, + '8453': { + isActiveDest: true, + isActiveSrc: true, + }, + '42161': { + isActiveDest: true, + isActiveSrc: true, + }, + '43114': { + isActiveDest: true, + isActiveSrc: true, + }, + '59144': { + isActiveDest: true, + isActiveSrc: true, + }, + '1151111081099710': { + isActiveDest: true, + isActiveSrc: true, + refreshRate: 10000, + topAssets: [ + 'EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v', + '6p6xgHyF7AeE6TZkSmFsko444wqoP15icUSqi2jfGiPN', + 'JUPyiwrYJFskUPiHa7hkeR8VUtAeFoSYbKedZNsDvCN', + '7vfCXTUXx5WJV5JADk17DUJ4ksgau7utNKj4b963voxsDx8F8k8k3uYw1PDC', + '3iQL8BFS2vE7mww4ehAqQHAsbmRNCrPxizWAT2Zfyr9y', + '9zNQRsGLjNKwCUU5Gq5LR8beUCPzQMVMqKAi3SSZh54u', + 'DezXAZ8z7PnrnRJjz3wXBoRgixCa6xjnB7YaB1pPB263', + 'rndrizKT3MK1iimdxRdWabcF7Zg7AR5T4nud4EkHBof', + '21AErpiB8uSb94oQKRcwuHqyHF93njAxBSbdUrpupump', + ], + }, + }, + bip44DefaultPairs: { + bip122: { + standard: { + 'bip122:000000000019d6689c085ae165831e93/slip44:0': + 'eip155:1/slip44:60', + }, + other: {}, + }, + eip155: { + standard: { + 'eip155:1/slip44:60': + 'eip155:1/erc20:0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48', + }, + other: { + 'eip155:1/slip44:60': + 'eip155:1/erc20:0x1234567890123456789012345678901234567890', + }, + }, + solana: { + standard: { + 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/slip44:501': + 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/token:EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v', + }, + other: {}, + }, + }, + maxRefreshCount: 5, + refreshRate: 30000, + support: true, + minimumVersion: '0.0.0', + chainRanking: [{ chainId: 'eip155:1', name: 'Ethereum' }], + }, + type: 'evm and solana chain config + bip44 default pairs', + expected: true, + }, + { + response: { + chains: { + '1': { + isActiveDest: true, + isActiveSrc: true, + defaultPairs: { + standard: { + 'bip122:000000000019d6689c085ae165831e93/slip44:0': + 'eip155:1/slip44:60', + }, + other: {}, + }, + }, + }, + maxRefreshCount: 5, + refreshRate: 30000, + support: true, + minimumVersion: '0.0.0', + sse: { + enabled: true, + minimumVersion: '13.8.0', + }, + chainRanking: [{ chainId: 'eip155:1', name: 'Ethereum' }], + }, + type: 'sse config', + expected: true, + }, + { + response: { + chains: { + '1': { + isActiveDest: true, + isActiveSrc: true, + defaultPairs: { + standard: { + 'bip122:000000000019d6689c085ae165831e93/slip44:0': + 'eip155:1/slip44:60', + }, + other: {}, + }, + }, + }, + maxRefreshCount: 5, + refreshRate: 30000, + support: true, + minimumVersion: '0.0.0', + sse: { + enabled: true, + }, + }, + type: 'sse config - missing minimum version', + expected: false, + }, + { + response: { + chains: { + '1': { + isActiveDest: true, + isActiveSrc: true, + defaultPairs: { + standard: { + 'bip122:000000000019d6689c085ae165831e93/slip44:0': + 'eip155:1/slip44:60', + }, + other: {}, + }, + }, + }, + maxRefreshCount: 5, + refreshRate: 30000, + support: true, + minimumVersion: '0.0.0', + sse: { + enabled: true, + minimumVersion: '0.0', + }, + }, + type: 'sse config - malformed minimum version', + expected: false, + }, + { + response: undefined, + type: 'no response', + expected: false, + }, + { + response: { + chains: { + '1': { isActiveDest: true, isActiveSrc: true }, + '10': { isActiveDest: true, isActiveSrc: true }, + '137': { isActiveDest: true, isActiveSrc: true }, + '324': { isActiveDest: true, isActiveSrc: true }, + '42161': { isActiveDest: true, isActiveSrc: true }, + '43114': { isActiveDest: true, isActiveSrc: true }, + '56': { isActiveDest: true, isActiveSrc: true }, + '59144': { isActiveDest: true, isActiveSrc: true }, + '8453': { isActiveDest: true, isActiveSrc: true }, + }, + maxRefreshCount: 5, + refreshRate: 30000, + support: true, + minimumVersion: '0.0.0', + extraField: 'foo', + chainRanking: [{ chainId: 'eip155:1', name: 'Ethereum' }], + }, + type: 'all evm chains active + an extra field not specified in the schema', + expected: true, + }, + ])('should return $expected for: $type', ({ response, expected }) => { + expect(validateFeatureFlagsResponse(response)).toBe(expected); + }); + }); + + describe('IntentSchema', () => { + const validOrder = { + sellToken: '0x0000000000000000000000000000000000000001', + buyToken: '0x0000000000000000000000000000000000000002', + validTo: 1717027200, + appData: 'some-app-data', + appDataHash: '0xabcd', + feeAmount: '100', + kind: 'sell' as const, + partiallyFillable: false, + sellAmount: '1000', + }; + + const validIntent = { + protocol: 'cowswap', + order: validOrder, + typedData: { + types: { Order: [{ name: 'sellToken', type: 'address' }] }, + domain: { name: 'GPv2Settlement', chainId: 1 }, + primaryType: 'Order', + message: { sellToken: '0x01', buyToken: '0x02' }, + }, + }; + + it('accepts a valid intent with required fields only', () => { + expect(is(validIntent, IntentSchema)).toBe(true); + }); + + it('accepts intent with optional settlementContract', () => { + expect( + is( + { + ...validIntent, + settlementContract: '0x9008D19f58AAbd9eD0D60971565AA8510560ab41', + }, + IntentSchema, + ), + ).toBe(true); + }); + + it('rejects intent without typedData', () => { + const { typedData: _, ...intentWithoutTypedData } = validIntent; + expect(is(intentWithoutTypedData, IntentSchema)).toBe(false); + }); + + it('rejects intent with typedData missing domain', () => { + expect( + is( + { + ...validIntent, + typedData: { types: {}, primaryType: 'Order', message: {} }, + }, + IntentSchema, + ), + ).toBe(false); + }); + + it('rejects intent with typedData missing message', () => { + expect( + is( + { + ...validIntent, + typedData: { types: {}, domain: {}, primaryType: 'Order' }, + }, + IntentSchema, + ), + ).toBe(false); + }); + + it('rejects intent with typedData missing types', () => { + expect( + is( + { + ...validIntent, + typedData: { domain: {}, primaryType: 'Order', message: {} }, + }, + IntentSchema, + ), + ).toBe(false); + }); + + it('rejects intent with typedData missing primaryType', () => { + expect( + is( + { + ...validIntent, + typedData: { types: {}, domain: {}, message: {} }, + }, + IntentSchema, + ), + ).toBe(false); + }); + + it('rejects intent without protocol', () => { + const { protocol: _, ...intentWithoutProtocol } = validIntent; + expect(is(intentWithoutProtocol, IntentSchema)).toBe(false); + }); + + it('rejects intent without order', () => { + const { order: _, ...intentWithoutOrder } = validIntent; + expect(is(intentWithoutOrder, IntentSchema)).toBe(false); + }); + + it('accepts intent with empty typedData records', () => { + expect( + is( + { + ...validIntent, + typedData: { + types: {}, + domain: {}, + primaryType: 'Order', + message: {}, + }, + }, + IntentSchema, + ), + ).toBe(true); + }); + }); + + describe('FeeDataSchema', () => { + const metabridgeFee = + mockBridgeQuotesNativeErc20EthV1[0].quote.feeData.metabridge; + + it.each([ + ['absent', undefined], + ['null', null], + ['vip', DiscountType.VIP], + ['promo', DiscountType.PROMO], + ['dao', DiscountType.DAO], + ['future value', 'seasonal'], + ])('accepts %s discountType', (_label, discountType) => { + expect( + is( + discountType === undefined + ? metabridgeFee + : { ...metabridgeFee, discountType }, + FeeDataSchema, + ), + ).toBe(true); + }); + + it('rejects non-string discountType values', () => { + expect(is({ ...metabridgeFee, discountType: 123 }, FeeDataSchema)).toBe( + false, + ); + }); + }); + + describe('validateQuoteResponseV1', () => { + it('accepts a quote with metabridge discountType', () => { + expect( + validateQuoteResponseV1({ + ...mockBridgeQuotesNativeErc20EthV1[0], + quote: { + ...mockBridgeQuotesNativeErc20EthV1[0].quote, + feeData: { + ...mockBridgeQuotesNativeErc20EthV1[0].quote.feeData, + metabridge: { + ...mockBridgeQuotesNativeErc20EthV1[0].quote.feeData.metabridge, + discountType: DiscountType.PROMO, + }, + }, + }, + }), + ).toBe(true); + }); + }); + + describe('validateQuoteStreamComplete', () => { + it('accepts a valid complete event with all fields', () => { + expect( + validateQuoteStreamComplete({ + quoteCount: 3, + hasQuotes: true, + reason: QuoteStreamCompleteReason.RETRY, + context: { source: 'bridge-api' }, + }), + ).toBe(true); + }); + + it('accepts a valid complete event with only required fields', () => { + expect( + validateQuoteStreamComplete({ quoteCount: 0, hasQuotes: false }), + ).toBe(true); + }); + + it('accepts all defined reason values', () => { + for (const reason of Object.values(QuoteStreamCompleteReason)) { + expect( + validateQuoteStreamComplete({ + quoteCount: 1, + hasQuotes: true, + reason, + }), + ).toBe(true); + } + }); + + it('rejects an unknown reason string', () => { + expect(() => + validateQuoteStreamComplete({ + quoteCount: 1, + hasQuotes: true, + reason: 'UNKNOWN_REASON', + }), + ).toThrow('At path: reason'); + }); + + it('rejects data missing quoteCount', () => { + expect(() => validateQuoteStreamComplete({ hasQuotes: true })).toThrow( + 'At path: quoteCount', + ); + }); + + it('rejects data missing hasQuotes', () => { + expect(() => validateQuoteStreamComplete({ quoteCount: 1 })).toThrow( + 'At path: hasQuotes', + ); + }); + + it('rejects data with wrong type for quoteCount', () => { + expect(() => + validateQuoteStreamComplete({ quoteCount: 'three', hasQuotes: true }), + ).toThrow('At path: quoteCount'); + }); + }); +}); diff --git a/packages/bridge-controller/tests/mock-quotes-erc20-erc20-migration-v2.ts b/packages/bridge-controller/tests/mock-quotes-erc20-erc20-migration-v2.ts new file mode 100644 index 00000000000..cb03da379a1 --- /dev/null +++ b/packages/bridge-controller/tests/mock-quotes-erc20-erc20-migration-v2.ts @@ -0,0 +1,113 @@ +import type { QuoteResponse } from '../src/validators/quote-response.js'; +import { ActionTypes } from '../src/validators/step.js'; + +/** + * This is the V2 QuoteResponse + */ +export const mockBridgeQuotesErc20Erc20V2Migration: Omit< + QuoteResponse, + 'chainId' | 'namespace' +>[] = [ + { + approval: { + chainId: 10, + data: '0x095ea7b3000000000000000000000000b90357f2b86dbfd59c3502215d4060f71df8ca0e0000000000000000000000000000000000000000000000000000000000d59f80', + from: '0x141d32a89a1e0a5ef360034a2f60a4b917c18838', + gasLimit: 61865, + to: '0x0b2c639c533813f4aa9d7837caf62653d097ff85', + value: '0x00', + }, + estimatedProcessingTimeInSeconds: 60, + quote: { + dest: { + amount: '13984280', + asset: { + assetId: + 'eip155:137/erc20:0x3c499c542cef5e3811e1192ce70d8cc03d5c3359', + decimals: 6, + name: 'Native USD Coin (POS)', + symbol: 'USDC', + iconUrl: 'https://media.socket.tech/tokens/all/USDC', + }, + minAmount: '13700000', + }, + feeData: { + metabridge: [ + { + amount: '0', + asset: { + assetId: + 'eip155:10/erc20:0x0b2c639c533813f4aa9d7837caf62653d097ff85', + decimals: 6, + name: 'USD Coin', + symbol: 'USDC', + }, + }, + ], + network: [ + { + amount: '150000000', + asset: { + assetId: 'eip155:10/slip44:60', + decimals: 18, + name: 'Ether', + symbol: 'ETH', + }, + usd: undefined, + valueInCurrency: undefined, + }, + ], + }, + gasIncluded: false, + gasIncluded7702: false, + gasSponsored: false, + protocols: ['across'], + aggregator: 'socket', + requestId: '90ae8e69-f03a-4cf6-bab7-ed4e3431eb37', + slippage: 2, + src: { + amount: '14000000', + asset: { + assetId: 'eip155:10/erc20:0x0b2c639c533813f4aa9d7837caf62653d097ff85', + decimals: 6, + name: 'USD Coin', + symbol: 'USDC', + iconUrl: 'https://media.socket.tech/tokens/all/USDC', + }, + }, + steps: [ + { + action: ActionTypes.BRIDGE, + src: { + asset: { + symbol: 'USDC', + assetId: + 'eip155:10/erc20:0x0b2c639c533813f4aa9d7837caf62653d097ff85', + name: 'USD Coin', + decimals: 6, + iconUrl: 'https://assets.polygon.technology/tokenAssets/usdc.svg', + }, + }, + dest: { + asset: { + symbol: 'USDC', + assetId: + 'eip155:137/erc20:0x3c499c542cef5e3811e1192ce70d8cc03d5c3359', + name: 'Native USD Coin (POS)', + decimals: 6, + iconUrl: 'https://assets.polygon.technology/tokenAssets/usdc.svg', + }, + }, + }, + ], + }, + trade: { + chainId: 10, + data: '0x3ce33bff00000000000000000000000000000000000000000000000000000000000000800000000000000000000000000b2c639c533813f4aa9d7837caf62653d097ff850000000000000000000000000000000000000000000000000000000000d59f8000000000000000000000000000000000000000000000000000000000000000c0000000000000000000000000000000000000000000000000000000000000000f736f636b6574416461707465725632000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000005e00000000000000000000000003a23f943181408eac424116af7b7790c94cb97a50000000000000000000000003a23f943181408eac424116af7b7790c94cb97a500000000000000000000000000000000000000000000000000000000000000890000000000000000000000000b2c639c533813f4aa9d7837caf62653d097ff850000000000000000000000003c499c542cef5e3811e1192ce70d8cc03d5c33590000000000000000000000000000000000000000000000000000000000d59f8000000000000000000000000000000000000000000000000000000000000001400000000000000000000000000000000000000000000000000000000000000000000000000000000000000000716a8b9dd056055c84b7a2ba0a016099465a518700000000000000000000000000000000000000000000000000000000000004a0c3540448000000000000000000000000000000000000000000000000000000000000004000000000000000000000000000000000000000000000000000000000000000a000000000000000000000000000000000000000000000000000000000000000020000000000000000000000000000000000000000000000000000000000000005000000000000000000000000000000000000000000000000000000000000019d0000000000000000000000000000000000000000000000000000000000000002000000000000000000000000000000000000000000000000000000000000004000000000000000000000000000000000000000000000000000000000000001000000000000000000000000000000000000000000000000000000000000000084ad69fa4f00000000000000000000000000000000000000000000000000038d7ea4c68000000000000000000000000000141d32a89a1e0a5ef360034a2f60a4b917c1883800000000000000000000000000000000000000000000000000000000000000890000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000284792ebcb90000000000000000000000000000000000000000000000000000000000d59f80000000000000000000000000000000000000000000000000000000000000004000000000000000000000000000000000000000000000000000000000000000c00000000000000000000000000000000000000000000000000000000000000120000000000000000000000000000000000000000000000000000000000000018000000000000000000000000000000000000000000000000000000000000001e0000000000000000000000000000000000000000000000000000000000000454000000000000000000000000000000000000000000000000000000000000000c40000000000000000000000000000000000000000000000000000000000000002000000000000000000000000141d32a89a1e0a5ef360034a2f60a4b917c18838000000000000000000000000141d32a89a1e0a5ef360034a2f60a4b917c1883800000000000000000000000000000000000000000000000000000000000000020000000000000000000000000b2c639c533813f4aa9d7837caf62653d097ff850000000000000000000000003c499c542cef5e3811e1192ce70d8cc03d5c335900000000000000000000000000000000000000000000000000000000000000020000000000000000000000000000000000000000000000000000000000d55a40000000000000000000000000000000000000000000000000000000000000008900000000000000000000000000000000000000000000000000000000000000020000000000000000000000000000000000000000000000000000000067041c47000000000000000000000000000000000000000000000000000000006704704d00000000000000000000000000000000000000000000000000000000d00dfeeddeadbeef765753be7f7a64d5509974b0d678e1e3149b02f42c7402906f9888136205038026f20b3f6df2899044cab41d632bc7a6c35debd40516df85de6f194aeb05b72cb9ea4d5ce0f7c56c91a79536331112f1a846dc641c', + from: '0x141d32a89a1e0a5ef360034a2f60a4b917c18838', + gasLimit: 287227, + to: '0xB90357f2b86dbfD59c3502215d4060f71DF8ca0e', + value: '0x038d7ea4c68000', + }, + }, +]; diff --git a/packages/bridge-controller/tests/mock-quotes-erc20-erc20.ts b/packages/bridge-controller/tests/mock-quotes-erc20-erc20.ts new file mode 100644 index 00000000000..eb64b1806a6 --- /dev/null +++ b/packages/bridge-controller/tests/mock-quotes-erc20-erc20.ts @@ -0,0 +1,212 @@ +import { merge } from 'lodash'; + +import { toQuoteResponseV2 } from '../src/index.js'; +import type { DeepPartial } from '../src/types.js'; +import { + validateQuoteResponseV1, + QuoteResponseV1, +} from '../src/validators/quote-response-v1.js'; +import type { QuoteResponse } from '../src/validators/quote-response.js'; +import { ActionTypes } from '../src/validators/step.js'; + +export const mockBridgeQuotesErc20Erc20V1: QuoteResponseV1[] = [ + { + quote: { + gasIncluded: false, + gasIncluded7702: false, + gasSponsored: false, + slippage: 2, + requestId: '90ae8e69-f03a-4cf6-bab7-ed4e3431eb37', + srcChainId: 10, + srcAsset: { + chainId: 10, + address: '0x0b2c639c533813f4aa9d7837caf62653d097ff85', + assetId: 'eip155:10/erc20:0x0b2c639c533813f4aa9d7837caf62653d097ff85', + symbol: 'USDC', + name: 'USD Coin', + decimals: 6, + iconUrl: 'https://media.socket.tech/tokens/all/USDC', + }, + srcTokenAmount: '14000000', + destChainId: 137, + destAsset: { + chainId: 137, + address: '0x3c499c542cef5e3811e1192ce70d8cc03d5c3359', + assetId: 'eip155:137/erc20:0x3c499c542cef5e3811e1192ce70d8cc03d5c3359', + symbol: 'USDC', + name: 'Native USD Coin (POS)', + decimals: 6, + iconUrl: 'https://media.socket.tech/tokens/all/USDC', + }, + destTokenAmount: '13984280', + minDestTokenAmount: '13700000', + feeData: { + metabridge: { + amount: '0', + asset: { + chainId: 10, + address: '0x0b2c639c533813f4aa9d7837caf62653d097ff85', + assetId: + 'eip155:10/erc20:0x0b2c639c533813f4aa9d7837caf62653d097ff85', + symbol: 'USDC', + name: 'USD Coin', + decimals: 6, + }, + }, + }, + bridgeId: 'socket', + bridges: ['across'], + aggregator: 'socket', + protocols: ['across'], + steps: [ + { + action: ActionTypes.BRIDGE, + srcChainId: 10, + destChainId: 137, + srcAsset: { + chainId: 10, + address: '0x0b2c639c533813f4aa9d7837caf62653d097ff85', + assetId: + 'eip155:10/erc20:0x0b2c639c533813f4aa9d7837caf62653d097ff85', + symbol: 'USDC', + name: 'USD Coin', + decimals: 6, + iconUrl: 'https://assets.polygon.technology/tokenAssets/usdc.svg', + }, + destAsset: { + chainId: 137, + address: '0x3c499c542cef5e3811e1192ce70d8cc03d5c3359', + assetId: + 'eip155:137/erc20:0x3c499c542cef5e3811e1192ce70d8cc03d5c3359', + symbol: 'USDC', + name: 'Native USD Coin (POS)', + decimals: 6, + iconUrl: 'https://assets.polygon.technology/tokenAssets/usdc.svg', + }, + }, + ], + }, + approval: { + chainId: 10, + to: '0x0b2c639c533813f4aa9d7837caf62653d097ff85', + from: '0x141d32a89a1e0a5ef360034a2f60a4b917c18838', + value: '0x00', + data: '0x095ea7b3000000000000000000000000b90357f2b86dbfd59c3502215d4060f71df8ca0e0000000000000000000000000000000000000000000000000000000000d59f80', + gasLimit: 61865, + }, + trade: { + chainId: 10, + to: '0xB90357f2b86dbfD59c3502215d4060f71DF8ca0e', + from: '0x141d32a89a1e0a5ef360034a2f60a4b917c18838', + value: '0x038d7ea4c68000', + data: '0x3ce33bff00000000000000000000000000000000000000000000000000000000000000800000000000000000000000000b2c639c533813f4aa9d7837caf62653d097ff850000000000000000000000000000000000000000000000000000000000d59f8000000000000000000000000000000000000000000000000000000000000000c0000000000000000000000000000000000000000000000000000000000000000f736f636b6574416461707465725632000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000005e00000000000000000000000003a23f943181408eac424116af7b7790c94cb97a50000000000000000000000003a23f943181408eac424116af7b7790c94cb97a500000000000000000000000000000000000000000000000000000000000000890000000000000000000000000b2c639c533813f4aa9d7837caf62653d097ff850000000000000000000000003c499c542cef5e3811e1192ce70d8cc03d5c33590000000000000000000000000000000000000000000000000000000000d59f8000000000000000000000000000000000000000000000000000000000000001400000000000000000000000000000000000000000000000000000000000000000000000000000000000000000716a8b9dd056055c84b7a2ba0a016099465a518700000000000000000000000000000000000000000000000000000000000004a0c3540448000000000000000000000000000000000000000000000000000000000000004000000000000000000000000000000000000000000000000000000000000000a000000000000000000000000000000000000000000000000000000000000000020000000000000000000000000000000000000000000000000000000000000005000000000000000000000000000000000000000000000000000000000000019d0000000000000000000000000000000000000000000000000000000000000002000000000000000000000000000000000000000000000000000000000000004000000000000000000000000000000000000000000000000000000000000001000000000000000000000000000000000000000000000000000000000000000084ad69fa4f00000000000000000000000000000000000000000000000000038d7ea4c68000000000000000000000000000141d32a89a1e0a5ef360034a2f60a4b917c1883800000000000000000000000000000000000000000000000000000000000000890000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000284792ebcb90000000000000000000000000000000000000000000000000000000000d59f80000000000000000000000000000000000000000000000000000000000000004000000000000000000000000000000000000000000000000000000000000000c00000000000000000000000000000000000000000000000000000000000000120000000000000000000000000000000000000000000000000000000000000018000000000000000000000000000000000000000000000000000000000000001e0000000000000000000000000000000000000000000000000000000000000454000000000000000000000000000000000000000000000000000000000000000c40000000000000000000000000000000000000000000000000000000000000002000000000000000000000000141d32a89a1e0a5ef360034a2f60a4b917c18838000000000000000000000000141d32a89a1e0a5ef360034a2f60a4b917c1883800000000000000000000000000000000000000000000000000000000000000020000000000000000000000000b2c639c533813f4aa9d7837caf62653d097ff850000000000000000000000003c499c542cef5e3811e1192ce70d8cc03d5c335900000000000000000000000000000000000000000000000000000000000000020000000000000000000000000000000000000000000000000000000000d55a40000000000000000000000000000000000000000000000000000000000000008900000000000000000000000000000000000000000000000000000000000000020000000000000000000000000000000000000000000000000000000067041c47000000000000000000000000000000000000000000000000000000006704704d00000000000000000000000000000000000000000000000000000000d00dfeeddeadbeef765753be7f7a64d5509974b0d678e1e3149b02f42c7402906f9888136205038026f20b3f6df2899044cab41d632bc7a6c35debd40516df85de6f194aeb05b72cb9ea4d5ce0f7c56c91a79536331112f1a846dc641c', + gasLimit: 287227, + }, + estimatedProcessingTimeInSeconds: 60, + }, + { + quote: { + requestId: '0b6caac9-456d-47e6-8982-1945ae81ae82', + srcChainId: 10, + srcAsset: { + chainId: 10, + address: '0x0b2C639c533813f4Aa9D7837CAf62653d097Ff85', + assetId: 'eip155:10/erc20:0x0b2c639c533813f4aa9d7837caf62653d097ff85', + symbol: 'USDC', + name: 'USD Coin', + decimals: 6, + iconUrl: 'https://media.socket.tech/tokens/all/USDC', + }, + srcTokenAmount: '14000000', + destChainId: 137, + destAsset: { + chainId: 137, + address: '0x3c499c542cef5e3811e1192ce70d8cc03d5c3359', + assetId: 'eip155:137/erc20:0x3c499c542cef5e3811e1192ce70d8cc03d5c3359', + symbol: 'USDC', + name: 'Native USD Coin (POS)', + decimals: 6, + iconUrl: 'https://media.socket.tech/tokens/all/USDC', + }, + destTokenAmount: '13800000', + minDestTokenAmount: '13530000', + feeData: { + metabridge: { + amount: '0', + asset: { + chainId: 10, + address: '0x0b2C639c533813f4Aa9D7837CAf62653d097Ff85', + assetId: + 'eip155:10/erc20:0x0b2c639c533813f4aa9d7837caf62653d097ff85', + symbol: 'USDC', + name: 'USD Coin', + decimals: 6, + iconUrl: 'https://media.socket.tech/tokens/all/USDC', + }, + }, + }, + bridgeId: 'socket', + bridges: ['celercircle'], + steps: [ + { + action: ActionTypes.BRIDGE, + srcChainId: 10, + destChainId: 137, + srcAsset: { + chainId: 10, + address: '0x0b2c639c533813f4aa9d7837caf62653d097ff85', + assetId: + 'eip155:10/erc20:0x0b2c639c533813f4aa9d7837caf62653d097ff85', + symbol: 'USDC', + name: 'USD Coin', + decimals: 6, + iconUrl: 'https://assets.polygon.technology/tokenAssets/usdc.svg', + }, + destAsset: { + chainId: 137, + address: '0x3c499c542cef5e3811e1192ce70d8cc03d5c3359', + assetId: + 'eip155:137/erc20:0x3c499c542cef5e3811e1192ce70d8cc03d5c3359', + symbol: 'USDC', + name: 'Native USD Coin (POS)', + decimals: 6, + iconUrl: 'https://assets.polygon.technology/tokenAssets/usdc.svg', + }, + }, + ], + }, + approval: { + chainId: 10, + to: '0x0b2c639c533813f4aa9d7837caf62653d097ff85', + from: '0x141d32a89a1e0a5ef360034a2f60a4b917c18838', + value: '0x00', + data: '0x095ea7b3000000000000000000000000b90357f2b86dbfd59c3502215d4060f71df8ca0e0000000000000000000000000000000000000000000000000000000000d59f80', + gasLimit: 61865, + }, + trade: { + chainId: 10, + to: '0xB90357f2b86dbfD59c3502215d4060f71DF8ca0e', + from: '0x141d32a89a1e0a5ef360034a2f60a4b917c18838', + value: '0x038d7ea4c68000', + data: '0x3ce33bff00000000000000000000000000000000000000000000000000000000000000800000000000000000000000000b2c639c533813f4aa9d7837caf62653d097ff850000000000000000000000000000000000000000000000000000000000d59f8000000000000000000000000000000000000000000000000000000000000000c0000000000000000000000000000000000000000000000000000000000000000f736f636b6574416461707465725632000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000004400000000000000000000000003a23f943181408eac424116af7b7790c94cb97a50000000000000000000000003a23f943181408eac424116af7b7790c94cb97a500000000000000000000000000000000000000000000000000000000000000890000000000000000000000000b2c639c533813f4aa9d7837caf62653d097ff850000000000000000000000003c499c542cef5e3811e1192ce70d8cc03d5c33590000000000000000000000000000000000000000000000000000000000d59f8000000000000000000000000000000000000000000000000000000000000001400000000000000000000000000000000000000000000000000000000000000000000000000000000000000000716a8b9dd056055c84b7a2ba0a016099465a518700000000000000000000000000000000000000000000000000000000000002e4c3540448000000000000000000000000000000000000000000000000000000000000004000000000000000000000000000000000000000000000000000000000000000a000000000000000000000000000000000000000000000000000000000000000020000000000000000000000000000000000000000000000000000000000000005000000000000000000000000000000000000000000000000000000000000018c0000000000000000000000000000000000000000000000000000000000000002000000000000000000000000000000000000000000000000000000000000004000000000000000000000000000000000000000000000000000000000000001000000000000000000000000000000000000000000000000000000000000000084ad69fa4f00000000000000000000000000000000000000000000000000038d7ea4c68000000000000000000000000000141d32a89a1e0a5ef360034a2f60a4b917c18838000000000000000000000000000000000000000000000000000000000000008900000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000e4b7dfe9d00000000000000000000000000000000000000000000000000000000000d59f8000000000000000000000000000000000000000000000000000000000000000c4000000000000000000000000141d32a89a1e0a5ef360034a2f60a4b917c188380000000000000000000000000b2c639c533813f4aa9d7837caf62653d097ff85000000000000000000000000000000000000000000000000000000000000008900000000000000000000000000000000000000000000000000000000000000070000000000000000000000000000000000000000000000000000000000030d400000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000138bc5930d51a475e4669db259f69e61ca33803675e76540f062a76af8cbaef4672c9926e56d6a8c29a263de3ee8f734ad760461c448f82fdccdd8c2360fffba1b', + gasLimit: 343079, + }, + estimatedProcessingTimeInSeconds: 1560, + }, +]; + +export const getMockBridgeQuotesErc20Erc20V1 = ( + quoteOverrides?: DeepPartial, +): QuoteResponseV1[] => { + return mockBridgeQuotesErc20Erc20V1.map((quote) => { + const mergedQuote = merge({}, quote, quoteOverrides); + validateQuoteResponseV1(mergedQuote); + return mergedQuote; + }); +}; + +export const getMockBridgeQuotesErc20Erc20V2 = ( + quoteOverrides?: DeepPartial, +): QuoteResponse[] => { + return getMockBridgeQuotesErc20Erc20V1(quoteOverrides).map(toQuoteResponseV2); +}; diff --git a/packages/bridge-controller/tests/mock-quotes-erc20-native.ts b/packages/bridge-controller/tests/mock-quotes-erc20-native.ts new file mode 100644 index 00000000000..05f6c011530 --- /dev/null +++ b/packages/bridge-controller/tests/mock-quotes-erc20-native.ts @@ -0,0 +1,991 @@ +import { merge } from 'lodash'; + +import { toQuoteResponseV2 } from '../src/coercers/quote-response-v1-to-v2.js'; +import type { DeepPartial } from '../src/types.js'; +import type { QuoteResponseV1 } from '../src/validators/quote-response-v1.js'; +import { validateQuoteResponseV1 } from '../src/validators/quote-response-v1.js'; +import type { QuoteResponse } from '../src/validators/quote-response.js'; +import { ActionTypes } from '../src/validators/step.js'; + +export const mockBridgeQuotesErc20NativeV1: QuoteResponseV1[] = [ + { + quote: { + requestId: 'a63df72a-75ae-4416-a8ab-aff02596c75c', + srcChainId: 10, + srcTokenAmount: '991250000000000000', + srcAsset: { + address: '0x4200000000000000000000000000000000000006', + assetId: 'eip155:10/erc20:0x4200000000000000000000000000000000000006', + chainId: 10, + symbol: 'WETH', + decimals: 18, + name: 'Wrapped ETH', + icon: 'https://static.debank.com/image/op_token/logo_url/0x4200000000000000000000000000000000000006/61844453e63cf81301f845d7864236f6.png', + }, + destChainId: 42161, + destTokenAmount: '991225000000000000', + minDestTokenAmount: '970000000000000000', + destAsset: { + address: '0x0000000000000000000000000000000000000000', + assetId: 'eip155:42161/slip44:60', + chainId: 42161, + symbol: 'ETH', + decimals: 18, + name: 'ETH', + icon: 'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png', + }, + feeData: { + metabridge: { + amount: '8750000000000000', + asset: { + address: '0x4200000000000000000000000000000000000006', + assetId: + 'eip155:10/erc20:0x4200000000000000000000000000000000000006', + chainId: 10, + symbol: 'WETH', + decimals: 18, + name: 'Wrapped ETH', + icon: 'https://static.debank.com/image/op_token/logo_url/0x4200000000000000000000000000000000000006/61844453e63cf81301f845d7864236f6.png', + }, + }, + }, + bridgeId: 'lifi', + bridges: ['stargate'], + steps: [ + { + action: ActionTypes.BRIDGE, + srcChainId: 10, + destChainId: 42161, + protocol: { + name: 'stargate', + displayName: 'StargateV2 (Fast mode)', + icon: 'https://raw.githubusercontent.com/lifinance/types/5685c638772f533edad80fcb210b4bb89e30a50f/src/assets/icons/bridges/stargate.png', + }, + srcAsset: { + address: '0x0000000000000000000000000000000000000000', + assetId: 'eip155:10/slip44:60', + chainId: 10, + symbol: 'ETH', + decimals: 18, + name: 'ETH', + icon: 'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png', + }, + destAsset: { + address: '0x0000000000000000000000000000000000000000', + assetId: 'eip155:42161/slip44:60', + chainId: 42161, + symbol: 'ETH', + decimals: 18, + name: 'ETH', + icon: 'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png', + }, + srcAmount: '991250000000000000', + destAmount: '991225000000000000', + }, + ], + }, + approval: { + chainId: 10, + to: '0x4200000000000000000000000000000000000006', + from: '0x141d32a89a1e0a5ef360034a2f60a4b917c18838', + value: '0x00', + data: '0x095ea7b3000000000000000000000000b90357f2b86dbfd59c3502215d4060f71df8ca0e0000000000000000000000000000000000000000000000000de0b6b3a7640000', + gasLimit: 49122, + effectiveGas: 29122, + }, + trade: { + chainId: 10, + to: '0xB90357f2b86dbfD59c3502215d4060f71DF8ca0e', + from: '0x141d32a89a1e0a5ef360034a2f60a4b917c18838', + value: '0x1c8598b5db2e', + data: '0x3ce33bff000000000000000000000000000000000000000000000000000000000000008000000000000000000000000042000000000000000000000000000000000000060000000000000000000000000000000000000000000000000de0b6b3a764000000000000000000000000000000000000000000000000000000000000000000c0000000000000000000000000000000000000000000000000000000000000000d6c6966694164617074657256320000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000006c00000000000000000000000001231deb6f5749ef6ce6943a275a1d3e7486f4eae0000000000000000000000001231deb6f5749ef6ce6943a275a1d3e7486f4eae000000000000000000000000000000000000000000000000000000000000a4b1000000000000000000000000420000000000000000000000000000000000000600000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000dc1a09f859b20000000000000000000000000000000000000000000000000000000000000000140000000000000000000000000000000000000000000000000001f161421c8e000000000000000000000000000716a8b9dd056055c84b7a2ba0a016099465a51870000000000000000000000000000000000000000000000000000000000000564a6010a660000000000000000000000000000000000000000000000000000000000000060000000000000000000000000000000000000000000000000000000000000022000000000000000000000000000000000000000000000000000000000000003804bdedbea3f94faf8c8fac5ec841251d96cf5e64e8706ada4688877885e5249520000000000000000000000000000000000000000000000000000000000000140000000000000000000000000000000000000000000000000000000000000018000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000141d32a89a1e0a5ef360034a2f60a4b917c188380000000000000000000000000000000000000000000000000dc1a09f859b2000000000000000000000000000000000000000000000000000000000000000a4b100000000000000000000000000000000000000000000000000000000000000010000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000a7374617267617465563200000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000f6d6574616d61736b2d6272696467650000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000100000000000000000000000000000000000000000000000000000000000000200000000000000000000000005215e9fd223bc909083fbdb2860213873046e45d0000000000000000000000005215e9fd223bc909083fbdb2860213873046e45d000000000000000000000000420000000000000000000000000000000000000600000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000dc1a09f859b200000000000000000000000000000000000000000000000000000000000000000e0000000000000000000000000000000000000000000000000000000000000000100000000000000000000000000000000000000000000000000000000000000043ccfd60b00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000d00000000000000000000000000000000000000000000000000000000000000a000000000000000000000000000000000000000000000000000001c8598b5db2e0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000141d32a89a1e0a5ef360034a2f60a4b917c18838000000000000000000000000000000000000000000000000000000000000759e000000000000000000000000141d32a89a1e0a5ef360034a2f60a4b917c188380000000000000000000000000000000000000000000000000dc1a09f859b2000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000e00000000000000000000000000000000000000000000000000000000000000100000000000000000000000000000000000000000000000000000000000000012000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000c83dc7c11df600d7293f778cb365d3dfcc1ffa2221cf5447a8f2ea407a97792135d9f585ecb68916479dfa1f071f169cbe1cfec831b5ad01f4e4caa09204e5181c', + gasLimit: 841446, + effectiveGas: 641446, + }, + estimatedProcessingTimeInSeconds: 64, + }, + { + quote: { + requestId: 'aad73198-a64d-4310-b12d-9dcc81c412e2', + srcChainId: 10, + srcTokenAmount: '991250000000000000', + srcAsset: { + address: '0x4200000000000000000000000000000000000006', + assetId: 'eip155:10/erc20:0x4200000000000000000000000000000000000006', + chainId: 10, + symbol: 'WETH', + decimals: 18, + name: 'Wrapped ETH', + icon: 'https://static.debank.com/image/op_token/logo_url/0x4200000000000000000000000000000000000006/61844453e63cf81301f845d7864236f6.png', + }, + destChainId: 42161, + destTokenAmount: '991147696728676903', + minDestTokenAmount: '969000000000000000', + destAsset: { + address: '0x0000000000000000000000000000000000000000', + assetId: 'eip155:42161/slip44:60', + chainId: 42161, + symbol: 'ETH', + decimals: 18, + name: 'ETH', + coinKey: 'ETH', + logoURI: + 'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png', + priceUSD: '3135.46', + icon: 'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png', + }, + feeData: { + metabridge: { + amount: '8750000000000000', + asset: { + address: '0x4200000000000000000000000000000000000006', + assetId: + 'eip155:10/erc20:0x4200000000000000000000000000000000000006', + chainId: 10, + symbol: 'WETH', + decimals: 18, + name: 'Wrapped ETH', + icon: 'https://static.debank.com/image/op_token/logo_url/0x4200000000000000000000000000000000000006/61844453e63cf81301f845d7864236f6.png', + }, + }, + }, + bridgeId: 'lifi', + bridges: ['celer'], + steps: [ + { + action: ActionTypes.BRIDGE, + srcChainId: 10, + destChainId: 42161, + protocol: { + name: 'celer', + displayName: 'Celer cBridge', + icon: 'https://raw.githubusercontent.com/lifinance/types/main/src/assets/icons/bridges/cbridge.svg', + }, + srcAsset: { + address: '0x4200000000000000000000000000000000000006', + assetId: + 'eip155:10/erc20:0x4200000000000000000000000000000000000006', + chainId: 10, + symbol: 'WETH', + decimals: 18, + name: 'Wrapped ETH', + icon: 'https://static.debank.com/image/op_token/logo_url/0x4200000000000000000000000000000000000006/61844453e63cf81301f845d7864236f6.png', + }, + destAsset: { + address: '0x0000000000000000000000000000000000000000', + assetId: 'eip155:42161/slip44:60', + chainId: 42161, + symbol: 'ETH', + decimals: 18, + name: 'ETH', + icon: 'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png', + }, + srcAmount: '991250000000000000', + destAmount: '991147696728676903', + }, + ], + }, + approval: { + chainId: 10, + to: '0x4200000000000000000000000000000000000006', + from: '0x141d32a89a1e0a5ef360034a2f60a4b917c18838', + value: '0x00', + data: '0x095ea7b3000000000000000000000000b90357f2b86dbfd59c3502215d4060f71df8ca0e0000000000000000000000000000000000000000000000000de0b6b3a7640000', + gasLimit: 55122, + effectiveGas: 29122, + }, + trade: { + chainId: 10, + to: '0xB90357f2b86dbfD59c3502215d4060f71DF8ca0e', + from: '0x141d32a89a1e0a5ef360034a2f60a4b917c18838', + value: '0x00', + data: '0x3ce33bff000000000000000000000000000000000000000000000000000000000000008000000000000000000000000042000000000000000000000000000000000000060000000000000000000000000000000000000000000000000de0b6b3a764000000000000000000000000000000000000000000000000000000000000000000c0000000000000000000000000000000000000000000000000000000000000000d6c6966694164617074657256320000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000001a0000000000000000000000000e7bf43c55551b1036e796e7fd3b125d1f9903e2e000000000000000000000000e7bf43c55551b1036e796e7fd3b125d1f9903e2e000000000000000000000000000000000000000000000000000000000000a4b1000000000000000000000000420000000000000000000000000000000000000600000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000dc1a09f859b20000000000000000000000000000000000000000000000000000000000000000140000000000000000000000000000000000000000000000000001f161421c8e000000000000000000000000000716a8b9dd056055c84b7a2ba0a016099465a51870000000000000000000000000000000000000000000000000000000000000050f68486970f93a855b27794b8141d32a89a1e0a5ef360034a2f60a4b917c188380000a4b1420000000000000000000000000000000000000600000000000000000dc1a09f859b20002c03873900002777000000000000000000000000000000002d68122053030bf8df41a8bb8c6f0a9de411c7d94eed376b7d91234e1585fd9f77dcf974dd25160d0c2c16c8382d8aa85b0edd429edff19b4d4cdcf50d0a9d4d1c', + gasLimit: 553352, + effectiveGas: 203352, + }, + estimatedProcessingTimeInSeconds: 53, + }, + { + quote: { + requestId: '6cfd4952-c9b2-4aec-9349-af39c212f84b', + srcChainId: 10, + srcTokenAmount: '991250000000000000', + srcAsset: { + address: '0x4200000000000000000000000000000000000006', + assetId: 'eip155:10/erc20:0x4200000000000000000000000000000000000006', + chainId: 10, + symbol: 'WETH', + decimals: 18, + name: 'Wrapped ETH', + coinKey: 'WETH', + logoURI: + 'https://static.debank.com/image/op_token/logo_url/0x4200000000000000000000000000000000000006/61844453e63cf81301f845d7864236f6.png', + priceUSD: '3136', + icon: 'https://static.debank.com/image/op_token/logo_url/0x4200000000000000000000000000000000000006/61844453e63cf81301f845d7864236f6.png', + }, + destChainId: 42161, + destTokenAmount: '991112862890876485', + minDestTokenAmount: '968000000000000000', + destAsset: { + address: '0x0000000000000000000000000000000000000000', + assetId: 'eip155:42161/slip44:60', + chainId: 42161, + symbol: 'ETH', + decimals: 18, + name: 'ETH', + coinKey: 'ETH', + logoURI: + 'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png', + priceUSD: '3135.46', + icon: 'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png', + }, + feeData: { + metabridge: { + amount: '8750000000000000', + asset: { + address: '0x4200000000000000000000000000000000000006', + assetId: + 'eip155:10/erc20:0x4200000000000000000000000000000000000006', + chainId: 10, + symbol: 'WETH', + decimals: 18, + name: 'Wrapped ETH', + coinKey: 'WETH', + logoURI: + 'https://static.debank.com/image/op_token/logo_url/0x4200000000000000000000000000000000000006/61844453e63cf81301f845d7864236f6.png', + priceUSD: '3136', + icon: 'https://static.debank.com/image/op_token/logo_url/0x4200000000000000000000000000000000000006/61844453e63cf81301f845d7864236f6.png', + }, + }, + }, + bridgeId: 'lifi', + bridges: ['across'], + steps: [ + { + action: 'bridge', + srcChainId: 10, + destChainId: 42161, + protocol: { + name: 'across', + displayName: 'Across', + icon: 'https://raw.githubusercontent.com/lifinance/types/main/src/assets/icons/bridges/acrossv2.png', + }, + srcAsset: { + address: '0x4200000000000000000000000000000000000006', + assetId: + 'eip155:10/erc20:0x4200000000000000000000000000000000000006', + chainId: 10, + symbol: 'WETH', + decimals: 18, + name: 'Wrapped ETH', + coinKey: 'WETH', + logoURI: + 'https://static.debank.com/image/op_token/logo_url/0x4200000000000000000000000000000000000006/61844453e63cf81301f845d7864236f6.png', + priceUSD: '3136', + icon: 'https://static.debank.com/image/op_token/logo_url/0x4200000000000000000000000000000000000006/61844453e63cf81301f845d7864236f6.png', + }, + destAsset: { + address: '0x0000000000000000000000000000000000000000', + assetId: 'eip155:42161/slip44:60', + chainId: 42161, + symbol: 'ETH', + decimals: 18, + name: 'ETH', + coinKey: 'ETH', + logoURI: + 'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png', + priceUSD: '3135.46', + icon: 'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png', + }, + srcAmount: '991250000000000000', + destAmount: '991112862890876485', + }, + ], + }, + approval: { + chainId: 10, + to: '0x4200000000000000000000000000000000000006', + from: '0x141d32a89a1e0a5ef360034a2f60a4b917c18838', + value: '0x00', + data: '0x095ea7b3000000000000000000000000b90357f2b86dbfd59c3502215d4060f71df8ca0e0000000000000000000000000000000000000000000000000de0b6b3a7640000', + gasLimit: 39122, + effectiveGas: 29122, + }, + trade: { + chainId: 10, + to: '0xB90357f2b86dbfD59c3502215d4060f71DF8ca0e', + from: '0x141d32a89a1e0a5ef360034a2f60a4b917c18838', + value: '0x00', + data: '0x3ce33bff000000000000000000000000000000000000000000000000000000000000008000000000000000000000000042000000000000000000000000000000000000060000000000000000000000000000000000000000000000000de0b6b3a764000000000000000000000000000000000000000000000000000000000000000000c0000000000000000000000000000000000000000000000000000000000000000d6c6966694164617074657256320000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000001e0000000000000000000000000e397c4883ec89ed4fc9d258f00c689708b2799c9000000000000000000000000e397c4883ec89ed4fc9d258f00c689708b2799c9000000000000000000000000000000000000000000000000000000000000a4b1000000000000000000000000420000000000000000000000000000000000000600000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000dc1a09f859b20000000000000000000000000000000000000000000000000000000000000000140000000000000000000000000000000000000000000000000001f161421c8e000000000000000000000000000716a8b9dd056055c84b7a2ba0a016099465a518700000000000000000000000000000000000000000000000000000000000000902340ab8f6a57ef0c43231b98141d32a89a1e0a5ef360034a2f60a4b917c18838420000000000000000000000000000000000000600000000000000000dc1a09f859b20000000a4b100007dd39298f9ad673645ebffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffd00dfeeddeadbeef8932eb23bad9bddb5cf81426f78279a53c6c3b710000000000000000000000000000000088d06e7971021eee573a0ab6bc3e22039fc1c5ded5d12c4cf2b6311f47f909e06197aa8b2f647ae78ae33a6ea5d23f7c951c0e1686abecd01d7c796990d56f391c', + gasLimit: 277423, + effectiveGas: 177423, + }, + estimatedProcessingTimeInSeconds: 15, + }, + { + quote: { + requestId: '2c2ba7d8-3922-4081-9f27-63b7d5cc1986', + srcChainId: 10, + srcTokenAmount: '991250000000000000', + srcAsset: { + address: '0x4200000000000000000000000000000000000006', + assetId: 'eip155:10/erc20:0x4200000000000000000000000000000000000006', + chainId: 10, + symbol: 'WETH', + decimals: 18, + name: 'Wrapped ETH', + coinKey: 'WETH', + logoURI: + 'https://static.debank.com/image/op_token/logo_url/0x4200000000000000000000000000000000000006/61844453e63cf81301f845d7864236f6.png', + priceUSD: '3136', + icon: 'https://static.debank.com/image/op_token/logo_url/0x4200000000000000000000000000000000000006/61844453e63cf81301f845d7864236f6.png', + }, + destChainId: 42161, + destTokenAmount: '990221346602370184', + minDestTokenAmount: '967000000000000000', + destAsset: { + address: '0x0000000000000000000000000000000000000000', + assetId: 'eip155:42161/slip44:60', + chainId: 42161, + symbol: 'ETH', + decimals: 18, + name: 'ETH', + coinKey: 'ETH', + logoURI: + 'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png', + priceUSD: '3135.46', + icon: 'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png', + }, + feeData: { + metabridge: { + amount: '8750000000000000', + asset: { + address: '0x4200000000000000000000000000000000000006', + assetId: + 'eip155:10/erc20:0x4200000000000000000000000000000000000006', + chainId: 10, + symbol: 'WETH', + decimals: 18, + name: 'Wrapped ETH', + coinKey: 'WETH', + logoURI: + 'https://static.debank.com/image/op_token/logo_url/0x4200000000000000000000000000000000000006/61844453e63cf81301f845d7864236f6.png', + priceUSD: '3136', + icon: 'https://static.debank.com/image/op_token/logo_url/0x4200000000000000000000000000000000000006/61844453e63cf81301f845d7864236f6.png', + }, + }, + }, + bridgeId: 'lifi', + bridges: ['hop'], + steps: [ + { + action: 'bridge', + srcChainId: 10, + destChainId: 42161, + protocol: { + name: 'hop', + displayName: 'Hop', + icon: 'https://raw.githubusercontent.com/lifinance/types/main/src/assets/icons/bridges/hop.png', + }, + srcAsset: { + address: '0x0000000000000000000000000000000000000000', + assetId: 'eip155:10/slip44:60', + chainId: 10, + symbol: 'ETH', + decimals: 18, + name: 'ETH', + coinKey: 'ETH', + logoURI: + 'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png', + priceUSD: '3136', + icon: 'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png', + }, + destAsset: { + address: '0x0000000000000000000000000000000000000000', + assetId: 'eip155:42161/slip44:60', + chainId: 42161, + symbol: 'ETH', + decimals: 18, + name: 'ETH', + coinKey: 'ETH', + logoURI: + 'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png', + priceUSD: '3135.46', + icon: 'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png', + }, + srcAmount: '991250000000000000', + destAmount: '990221346602370184', + }, + ], + }, + approval: { + chainId: 10, + to: '0x4200000000000000000000000000000000000006', + from: '0x141d32a89a1e0a5ef360034a2f60a4b917c18838', + value: '0x00', + data: '0x095ea7b3000000000000000000000000b90357f2b86dbfd59c3502215d4060f71df8ca0e0000000000000000000000000000000000000000000000000de0b6b3a7640000', + gasLimit: 39122, + effectiveGas: 29122, + }, + trade: { + chainId: 10, + to: '0xB90357f2b86dbfD59c3502215d4060f71DF8ca0e', + from: '0x141d32a89a1e0a5ef360034a2f60a4b917c18838', + value: '0x00', + data: '0x3ce33bff000000000000000000000000000000000000000000000000000000000000008000000000000000000000000042000000000000000000000000000000000000060000000000000000000000000000000000000000000000000de0b6b3a764000000000000000000000000000000000000000000000000000000000000000000c0000000000000000000000000000000000000000000000000000000000000000d6c6966694164617074657256320000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000005e00000000000000000000000001231deb6f5749ef6ce6943a275a1d3e7486f4eae0000000000000000000000001231deb6f5749ef6ce6943a275a1d3e7486f4eae000000000000000000000000000000000000000000000000000000000000a4b1000000000000000000000000420000000000000000000000000000000000000600000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000dc1a09f859b20000000000000000000000000000000000000000000000000000000000000000140000000000000000000000000000000000000000000000000001f161421c8e000000000000000000000000000716a8b9dd056055c84b7a2ba0a016099465a51870000000000000000000000000000000000000000000000000000000000000484ca360ae0000000000000000000000000000000000000000000000000000000000000016000000000000000000000000000000000000000000000000000000000000003200000000000000000000000000000000000000000000000000001168a464edd170000000000000000000000000000000000000000000000000dac6213fc70c84400000000000000000000000000000000000000000000000000000000673a3b080000000000000000000000000000000000000000000000000dac6213fc70c84400000000000000000000000000000000000000000000000000000000673a3b0800000000000000000000000086ca30bef97fb651b8d866d45503684b90cb3312000000000000000000000000710bda329b2a6224e4b44833de30f38e7f81d5640000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000067997b63db4b9059d22e50750707b46a6d48dfbb32e50d85fc3bff1170ed9ca30000000000000000000000000000000000000000000000000000000000000140000000000000000000000000000000000000000000000000000000000000018000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000141d32a89a1e0a5ef360034a2f60a4b917c188380000000000000000000000000000000000000000000000000dc1a09f859b2000000000000000000000000000000000000000000000000000000000000000a4b1000000000000000000000000000000000000000000000000000000000000000100000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000003686f700000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000f6d6574616d61736b2d6272696467650000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000100000000000000000000000000000000000000000000000000000000000000200000000000000000000000005215e9fd223bc909083fbdb2860213873046e45d0000000000000000000000005215e9fd223bc909083fbdb2860213873046e45d000000000000000000000000420000000000000000000000000000000000000600000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000dc1a09f859b200000000000000000000000000000000000000000000000000000000000000000e0000000000000000000000000000000000000000000000000000000000000000100000000000000000000000000000000000000000000000000000000000000043ccfd60b000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000099d00cde1f22e8afd37d7f103ec3c6c1eb835ace46e502ec8c5ab51413e539461b89c0e26892efd1de1cbfe4222b5589e76231080252197507cce4fb72a30b031b', + effectiveGas: 547501, + gasLimit: 647501, + }, + estimatedProcessingTimeInSeconds: 24.159, + }, + { + quote: { + requestId: 'a77bc7b2-e8c8-4463-89db-5dd239d6aacc', + srcChainId: 10, + srcAsset: { + chainId: 10, + address: '0x4200000000000000000000000000000000000006', + assetId: 'eip155:10/erc20:0x4200000000000000000000000000000000000006', + symbol: 'WETH', + name: 'Wrapped Ether', + decimals: 18, + icon: 'https://media.socket.tech/tokens/all/WETH', + logoURI: 'https://media.socket.tech/tokens/all/WETH', + chainAgnosticId: 'ETH', + }, + srcTokenAmount: '991250000000000000', + destChainId: 42161, + destAsset: { + chainId: 42161, + address: '0x0000000000000000000000000000000000000000', + assetId: 'eip155:42161/slip44:60', + symbol: 'ETH', + name: 'Ethereum', + decimals: 18, + icon: 'https://media.socket.tech/tokens/all/ETH', + logoURI: 'https://media.socket.tech/tokens/all/ETH', + chainAgnosticId: null, + }, + destTokenAmount: '991147696728676903', + minDestTokenAmount: '969000000000000000', + feeData: { + metabridge: { + amount: '8750000000000000', + asset: { + chainId: 10, + address: '0x4200000000000000000000000000000000000006', + assetId: + 'eip155:10/erc20:0x4200000000000000000000000000000000000006', + symbol: 'WETH', + name: 'Wrapped Ether', + decimals: 18, + icon: 'https://media.socket.tech/tokens/all/WETH', + logoURI: 'https://media.socket.tech/tokens/all/WETH', + chainAgnosticId: 'ETH', + }, + }, + }, + bridgeId: 'socket', + bridges: ['celer'], + steps: [ + { + action: 'bridge', + srcChainId: 10, + destChainId: 42161, + protocol: { + name: 'celer', + displayName: 'Celer', + icon: 'https://media.socket.tech/bridges/celer.svg', + }, + srcAsset: { + chainId: 10, + address: '0x4200000000000000000000000000000000000006', + assetId: + 'eip155:10/erc20:0x4200000000000000000000000000000000000006', + symbol: 'WETH', + name: 'Wrapped Ether', + decimals: 18, + icon: 'https://media.socket.tech/tokens/all/WETH', + logoURI: 'https://media.socket.tech/tokens/all/WETH', + chainAgnosticId: 'ETH', + }, + destAsset: { + chainId: 42161, + address: '0x0000000000000000000000000000000000000000', + assetId: 'eip155:42161/slip44:60', + symbol: 'ETH', + name: 'Ethereum', + decimals: 18, + icon: 'https://media.socket.tech/tokens/all/ETH', + logoURI: 'https://media.socket.tech/tokens/all/ETH', + chainAgnosticId: null, + }, + srcAmount: '991250000000000000', + destAmount: '991147696728676903', + }, + ], + }, + approval: { + chainId: 10, + to: '0x4200000000000000000000000000000000000006', + from: '0x141d32a89a1e0a5ef360034a2f60a4b917c18838', + value: '0x00', + data: '0x095ea7b3000000000000000000000000b90357f2b86dbfd59c3502215d4060f71df8ca0e0000000000000000000000000000000000000000000000000de0b6b3a7640000', + effectiveGas: 29122, + gasLimit: 39122, + }, + trade: { + chainId: 10, + to: '0xB90357f2b86dbfD59c3502215d4060f71DF8ca0e', + from: '0x141d32a89a1e0a5ef360034a2f60a4b917c18838', + value: '0x00', + data: '0x3ce33bff000000000000000000000000000000000000000000000000000000000000008000000000000000000000000042000000000000000000000000000000000000060000000000000000000000000000000000000000000000000de0b6b3a764000000000000000000000000000000000000000000000000000000000000000000c0000000000000000000000000000000000000000000000000000000000000000f736f636b6574416461707465725632000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000001a00000000000000000000000003a23f943181408eac424116af7b7790c94cb97a50000000000000000000000003a23f943181408eac424116af7b7790c94cb97a5000000000000000000000000000000000000000000000000000000000000a4b1000000000000000000000000420000000000000000000000000000000000000600000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000dc1a09f859b20000000000000000000000000000000000000000000000000000000000000000140000000000000000000000000000000000000000000000000001f161421c8e000000000000000000000000000716a8b9dd056055c84b7a2ba0a016099465a5187000000000000000000000000000000000000000000000000000000000000004c0000001252106ce9141d32a89a1e0a5ef360034a2f60a4b917c18838420000000000000000000000000000000000000600000000000000000dc1a09f859b20000000a4b1245fa5dd00002777000000000000000000000000000000000000000022be703a074ef6089a301c364c2bbf391d51067ea5cd91515c9ec5421cdaabb23451cd2086f3ebe3e19ff138f3a9be154dcae6033838cc5fabeeb0d260b075cb1c', + gasLimit: 282048, + effectiveGas: 182048, + }, + estimatedProcessingTimeInSeconds: 360, + }, + { + quote: { + requestId: '4f2154d9b330221b2ad461adf63acc2c', + srcChainId: 10, + srcTokenAmount: '991250000000000000', + srcAsset: { + id: '10_0x4200000000000000000000000000000000000006', + symbol: 'WETH', + address: '0x4200000000000000000000000000000000000006', + assetId: 'eip155:10/erc20:0x4200000000000000000000000000000000000006', + chainId: 10, + name: 'Wrapped ETH', + decimals: 18, + usdPrice: 3135.9632118339764, + coingeckoId: 'weth', + type: 'evm', + logoURI: + 'https://raw.githubusercontent.com/0xsquid/assets/main/images/tokens/weth.svg', + volatility: 2, + axelarNetworkSymbol: 'WETH', + subGraphIds: [], + enabled: true, + subGraphOnly: false, + active: true, + icon: 'https://raw.githubusercontent.com/0xsquid/assets/main/images/tokens/weth.svg', + }, + destChainId: 42161, + destTokenAmount: '989989428114299041', + minDestTokenAmount: '966000000000000000', + destAsset: { + id: '42161_0xeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee', + symbol: 'ETH', + address: '0x0000000000000000000000000000000000000000', + assetId: 'eip155:42161/slip44:60', + chainId: 42161, + name: 'ETH', + decimals: 18, + usdPrice: 3133.259355489038, + coingeckoId: 'ethereum', + type: 'evm', + logoURI: + 'https://raw.githubusercontent.com/0xsquid/assets/main/images/tokens/eth.svg', + volatility: 2, + axelarNetworkSymbol: 'ETH', + subGraphIds: ['chainflip-bridge'], + enabled: true, + subGraphOnly: false, + active: true, + icon: 'https://raw.githubusercontent.com/0xsquid/assets/main/images/tokens/eth.svg', + }, + feeData: { + metabridge: { + amount: '8750000000000000', + asset: { + id: '10_0x4200000000000000000000000000000000000006', + symbol: 'WETH', + address: '0x4200000000000000000000000000000000000006', + assetId: + 'eip155:10/erc20:0x4200000000000000000000000000000000000006', + chainId: 10, + name: 'Wrapped ETH', + decimals: 18, + usdPrice: 3135.9632118339764, + coingeckoId: 'weth', + type: 'evm', + logoURI: + 'https://raw.githubusercontent.com/0xsquid/assets/main/images/tokens/weth.svg', + volatility: 2, + axelarNetworkSymbol: 'WETH', + subGraphIds: [], + enabled: true, + subGraphOnly: false, + active: true, + icon: 'https://raw.githubusercontent.com/0xsquid/assets/main/images/tokens/weth.svg', + }, + }, + }, + bridgeId: 'squid', + bridges: ['axelar'], + steps: [ + { + action: 'swap', + srcChainId: 10, + destChainId: 10, + protocol: { + name: 'Uniswap V3', + displayName: 'Uniswap V3', + }, + srcAsset: { + id: '10_0x4200000000000000000000000000000000000006', + symbol: 'WETH', + address: '0x4200000000000000000000000000000000000006', + assetId: + 'eip155:10/erc20:0x4200000000000000000000000000000000000006', + chainId: 10, + name: 'Wrapped ETH', + decimals: 18, + usdPrice: 3135.9632118339764, + coingeckoId: 'weth', + type: 'evm', + logoURI: + 'https://raw.githubusercontent.com/0xsquid/assets/main/images/tokens/weth.svg', + axelarNetworkSymbol: 'WETH', + subGraphIds: [], + enabled: true, + subGraphOnly: false, + active: true, + icon: 'https://raw.githubusercontent.com/0xsquid/assets/main/images/tokens/weth.svg', + }, + destAsset: { + id: '10_0x0b2c639c533813f4aa9d7837caf62653d097ff85', + symbol: 'USDC', + address: '0x0b2c639c533813f4aa9d7837caf62653d097ff85', + assetId: + 'eip155:10/erc20:0x0b2c639c533813f4aa9d7837caf62653d097ff85', + chainId: 10, + name: 'USDC', + decimals: 6, + usdPrice: 1.0003003590332982, + coingeckoId: 'usd-coin', + type: 'evm', + logoURI: + 'https://raw.githubusercontent.com/0xsquid/assets/main/images/tokens/usdc.svg', + axelarNetworkSymbol: 'USDC', + subGraphOnly: false, + subGraphIds: ['uusdc', 'cctp-uusdc-optimism-to-noble'], + enabled: true, + active: true, + icon: 'https://raw.githubusercontent.com/0xsquid/assets/main/images/tokens/usdc.svg', + }, + srcAmount: '991250000000000000', + destAmount: '3100880215', + }, + { + action: 'swap', + srcChainId: 10, + destChainId: 10, + protocol: { + name: 'Uniswap V3', + displayName: 'Uniswap V3', + }, + srcAsset: { + id: '10_0x0b2c639c533813f4aa9d7837caf62653d097ff85', + symbol: 'USDC', + address: '0x0b2c639c533813f4aa9d7837caf62653d097ff85', + assetId: + 'eip155:10/erc20:0x0b2c639c533813f4aa9d7837caf62653d097ff85', + chainId: 10, + name: 'USDC', + decimals: 6, + usdPrice: 1.0003003590332982, + coingeckoId: 'usd-coin', + type: 'evm', + logoURI: + 'https://raw.githubusercontent.com/0xsquid/assets/main/images/tokens/usdc.svg', + axelarNetworkSymbol: 'USDC', + subGraphOnly: false, + subGraphIds: ['uusdc', 'cctp-uusdc-optimism-to-noble'], + enabled: true, + active: true, + icon: 'https://raw.githubusercontent.com/0xsquid/assets/main/images/tokens/usdc.svg', + }, + destAsset: { + id: '10_0x7f5c764cbc14f9669b88837ca1490cca17c31607', + symbol: 'USDC.e', + address: '0x7f5c764cbc14f9669b88837ca1490cca17c31607', + assetId: + 'eip155:10/erc20:0x7f5c764cbc14f9669b88837ca1490cca17c31607', + chainId: 10, + name: 'USDC.e', + decimals: 6, + usdPrice: 1.0003003590332982, + coingeckoId: 'usd-coin', + type: 'evm', + logoURI: + 'https://raw.githubusercontent.com/0xsquid/assets/main/images/tokens/usdc.svg', + axelarNetworkSymbol: 'USDC.e', + subGraphIds: [], + enabled: true, + subGraphOnly: false, + active: true, + icon: 'https://raw.githubusercontent.com/0xsquid/assets/main/images/tokens/usdc.svg', + }, + srcAmount: '3100880215', + destAmount: '3101045779', + }, + { + action: 'swap', + srcChainId: 10, + destChainId: 10, + protocol: { + name: 'Uniswap V3', + displayName: 'Uniswap V3', + }, + srcAsset: { + id: '10_0x7f5c764cbc14f9669b88837ca1490cca17c31607', + symbol: 'USDC.e', + address: '0x7f5c764cbc14f9669b88837ca1490cca17c31607', + assetId: + 'eip155:10/erc20:0x7f5c764cbc14f9669b88837ca1490cca17c31607', + chainId: 10, + name: 'USDC.e', + decimals: 6, + usdPrice: 1.0003003590332982, + coingeckoId: 'usd-coin', + type: 'evm', + logoURI: + 'https://raw.githubusercontent.com/0xsquid/assets/main/images/tokens/usdc.svg', + axelarNetworkSymbol: 'USDC.e', + subGraphIds: [], + enabled: true, + subGraphOnly: false, + active: true, + icon: 'https://raw.githubusercontent.com/0xsquid/assets/main/images/tokens/usdc.svg', + }, + destAsset: { + id: '10_0xeb466342c4d449bc9f53a865d5cb90586f405215', + symbol: 'USDC.axl', + address: '0xeb466342c4d449bc9f53a865d5cb90586f405215', + assetId: + 'eip155:10/erc20:0xeb466342c4d449bc9f53a865d5cb90586f405215', + chainId: 10, + name: ' USDC (Axelar)', + decimals: 6, + usdPrice: 1.0003003590332982, + interchainTokenId: null, + coingeckoId: 'usd-coin', + type: 'evm', + logoURI: + 'https://raw.githubusercontent.com/axelarnetwork/axelar-configs/main/images/tokens/usdc.svg', + axelarNetworkSymbol: 'axlUSDC', + subGraphOnly: false, + subGraphIds: ['uusdc'], + enabled: true, + active: true, + icon: 'https://raw.githubusercontent.com/axelarnetwork/axelar-configs/main/images/tokens/usdc.svg', + }, + srcAmount: '3101045779', + destAmount: '3101521947', + }, + { + action: 'bridge', + srcChainId: 10, + destChainId: 42161, + protocol: { + name: 'axelar', + displayName: 'Axelar', + }, + srcAsset: { + id: '10_0xeb466342c4d449bc9f53a865d5cb90586f405215', + symbol: 'USDC.axl', + address: '0xeb466342c4d449bc9f53a865d5cb90586f405215', + assetId: + 'eip155:10/erc20:0xeb466342c4d449bc9f53a865d5cb90586f405215', + chainId: 10, + name: ' USDC (Axelar)', + decimals: 6, + usdPrice: 1.0003003590332982, + interchainTokenId: null, + coingeckoId: 'usd-coin', + type: 'evm', + logoURI: + 'https://raw.githubusercontent.com/axelarnetwork/axelar-configs/main/images/tokens/usdc.svg', + axelarNetworkSymbol: 'axlUSDC', + subGraphOnly: false, + subGraphIds: ['uusdc'], + enabled: true, + active: true, + icon: 'https://raw.githubusercontent.com/axelarnetwork/axelar-configs/main/images/tokens/usdc.svg', + }, + destAsset: { + id: '42161_0xeb466342c4d449bc9f53a865d5cb90586f405215', + symbol: 'USDC.axl', + address: '0xeb466342c4d449bc9f53a865d5cb90586f405215', + assetId: + 'eip155:42161/erc20:0xeb466342c4d449bc9f53a865d5cb90586f405215', + chainId: 42161, + name: ' USDC (Axelar)', + decimals: 6, + usdPrice: 1.0003003590332982, + interchainTokenId: null, + coingeckoId: 'usd-coin', + type: 'evm', + logoURI: + 'https://raw.githubusercontent.com/axelarnetwork/axelar-configs/main/images/tokens/usdc.svg', + axelarNetworkSymbol: 'axlUSDC', + subGraphOnly: false, + subGraphIds: ['uusdc'], + enabled: true, + active: true, + icon: 'https://raw.githubusercontent.com/axelarnetwork/axelar-configs/main/images/tokens/usdc.svg', + }, + srcAmount: '3101521947', + destAmount: '3101521947', + }, + { + action: 'swap', + srcChainId: 42161, + destChainId: 42161, + protocol: { + name: 'Pancakeswap V3', + displayName: 'Pancakeswap V3', + }, + srcAsset: { + id: '42161_0xeb466342c4d449bc9f53a865d5cb90586f405215', + symbol: 'USDC.axl', + address: '0xeb466342c4d449bc9f53a865d5cb90586f405215', + assetId: + 'eip155:42161/erc20:0xeb466342c4d449bc9f53a865d5cb90586f405215', + chainId: 42161, + name: ' USDC (Axelar)', + decimals: 6, + usdPrice: 1.0003003590332982, + interchainTokenId: null, + coingeckoId: 'usd-coin', + type: 'evm', + logoURI: + 'https://raw.githubusercontent.com/axelarnetwork/axelar-configs/main/images/tokens/usdc.svg', + axelarNetworkSymbol: 'axlUSDC', + subGraphOnly: false, + subGraphIds: ['uusdc'], + enabled: true, + active: true, + icon: 'https://raw.githubusercontent.com/axelarnetwork/axelar-configs/main/images/tokens/usdc.svg', + }, + destAsset: { + id: '42161_0xaf88d065e77c8cc2239327c5edb3a432268e5831', + symbol: 'USDC', + address: '0xaf88d065e77c8cc2239327c5edb3a432268e5831', + assetId: + 'eip155:42161/erc20:0xaf88d065e77c8cc2239327c5edb3a432268e5831', + chainId: 42161, + name: 'USDC', + decimals: 6, + usdPrice: 1.0003003590332982, + coingeckoId: 'usd-coin', + type: 'evm', + logoURI: + 'https://raw.githubusercontent.com/0xsquid/assets/main/images/tokens/usdc.svg', + axelarNetworkSymbol: 'USDC', + subGraphOnly: false, + subGraphIds: [ + 'uusdc', + 'cctp-uusdc-arbitrum-to-noble', + 'chainflip-bridge', + ], + enabled: true, + active: true, + icon: 'https://raw.githubusercontent.com/0xsquid/assets/main/images/tokens/usdc.svg', + }, + srcAmount: '3101521947', + destAmount: '3100543869', + }, + { + action: 'swap', + srcChainId: 42161, + destChainId: 42161, + protocol: { + name: 'Uniswap V3', + displayName: 'Uniswap V3', + }, + srcAsset: { + id: '42161_0xaf88d065e77c8cc2239327c5edb3a432268e5831', + symbol: 'USDC', + address: '0xaf88d065e77c8cc2239327c5edb3a432268e5831', + assetId: + 'eip155:42161/erc20:0xaf88d065e77c8cc2239327c5edb3a432268e5831', + chainId: 42161, + name: 'USDC', + decimals: 6, + usdPrice: 1.0003003590332982, + coingeckoId: 'usd-coin', + type: 'evm', + logoURI: + 'https://raw.githubusercontent.com/0xsquid/assets/main/images/tokens/usdc.svg', + axelarNetworkSymbol: 'USDC', + subGraphOnly: false, + subGraphIds: [ + 'uusdc', + 'cctp-uusdc-arbitrum-to-noble', + 'chainflip-bridge', + ], + enabled: true, + active: true, + icon: 'https://raw.githubusercontent.com/0xsquid/assets/main/images/tokens/usdc.svg', + }, + destAsset: { + id: '42161_0x82af49447d8a07e3bd95bd0d56f35241523fbab1', + symbol: 'WETH', + address: '0x82af49447d8a07e3bd95bd0d56f35241523fbab1', + chainId: 42161, + assetId: + 'eip155:42161/erc20:0x82af49447d8a07e3bd95bd0d56f35241523fbab1', + name: 'Wrapped ETH', + decimals: 18, + usdPrice: 3135.9632118339764, + interchainTokenId: null, + coingeckoId: 'weth', + type: 'evm', + logoURI: + 'https://raw.githubusercontent.com/axelarnetwork/axelar-configs/main/images/tokens/weth.svg', + axelarNetworkSymbol: 'WETH', + subGraphOnly: false, + subGraphIds: ['arbitrum-weth-wei'], + enabled: true, + active: true, + icon: 'https://raw.githubusercontent.com/axelarnetwork/axelar-configs/main/images/tokens/weth.svg', + }, + srcAmount: '3100543869', + destAmount: '989989428114299041', + }, + ], + }, + approval: { + chainId: 10, + to: '0x4200000000000000000000000000000000000006', + from: '0x141d32a89a1e0a5ef360034a2f60a4b917c18838', + value: '0x00', + data: '0x095ea7b3000000000000000000000000b90357f2b86dbfd59c3502215d4060f71df8ca0e0000000000000000000000000000000000000000000000000de0b6b3a7640000', + gasLimit: 49122, + effectiveGas: 29122, + }, + trade: { + chainId: 10, + to: '0xB90357f2b86dbfD59c3502215d4060f71DF8ca0e', + from: '0x141d32a89a1e0a5ef360034a2f60a4b917c18838', + value: '0x4653ce53e6b1', + data: '0x3ce33bff000000000000000000000000000000000000000000000000000000000000008000000000000000000000000042000000000000000000000000000000000000060000000000000000000000000000000000000000000000000de0b6b3a764000000000000000000000000000000000000000000000000000000000000000000c0000000000000000000000000000000000000000000000000000000000000000e73717569644164617074657256320000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000001b60000000000000000000000000ce16f69375520ab01377ce7b88f5ba8c48f8d666000000000000000000000000ce16f69375520ab01377ce7b88f5ba8c48f8d666000000000000000000000000000000000000000000000000000000000000a4b1000000000000000000000000420000000000000000000000000000000000000600000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000dc1a09f859b20000000000000000000000000000000000000000000000000000000000000000140000000000000000000000000000000000000000000000000001f161421c8e000000000000000000000000000716a8b9dd056055c84b7a2ba0a016099465a51870000000000000000000000000000000000000000000000000000000000001a14846a1bc600000000000000000000000042000000000000000000000000000000000000060000000000000000000000000000000000000000000000000dc1a09f859b200000000000000000000000000000000000000000000000000000000000000001200000000000000000000000000000000000000000000000000000000000000ce00000000000000000000000000000000000000000000000000000000000000d200000000000000000000000000000000000000000000000000000000000000d600000000000000000000000000000000000000000000000000000000000000dc0000000000000000000000000141d32a89a1e0a5ef360034a2f60a4b917c188380000000000000000000000000000000000000000000000000000000000000001000000000000000000000000000000000000000000000000000000000000000600000000000000000000000000000000000000000000000000000000000000c00000000000000000000000000000000000000000000000000000000000000240000000000000000000000000000000000000000000000000000000000000046000000000000000000000000000000000000000000000000000000000000005e00000000000000000000000000000000000000000000000000000000000000800000000000000000000000000000000000000000000000000000000000000098000000000000000000000000000000000000000000000000000000000000000000000000000000000000000004200000000000000000000000000000000000006000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000a000000000000000000000000000000000000000000000000000000000000001200000000000000000000000000000000000000000000000000000000000000044095ea7b300000000000000000000000068b3465833fb72a70ecdf485e0e4c7bd8665fc45ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000004000000000000000000000000042000000000000000000000000000000000000060000000000000000000000000000000000000000000000000000000000000001000000000000000000000000000000000000000000000000000000000000000000000000000000000000000068b3465833fb72a70ecdf485e0e4c7bd8665fc45000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000a000000000000000000000000000000000000000000000000000000000000001c000000000000000000000000000000000000000000000000000000000000000e404e45aaf00000000000000000000000042000000000000000000000000000000000000060000000000000000000000000b2c639c533813f4aa9d7837caf62653d097ff8500000000000000000000000000000000000000000000000000000000000001f4000000000000000000000000ea749fd6ba492dbc14c24fe8a3d08769229b896c0000000000000000000000000000000000000000000000000dc1a09f859b200000000000000000000000000000000000000000000000000000000000b8833d8e00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000400000000000000000000000004200000000000000000000000000000000000006000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000000000000000000000000000000b2c639c533813f4aa9d7837caf62653d097ff85000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000a000000000000000000000000000000000000000000000000000000000000001200000000000000000000000000000000000000000000000000000000000000044095ea7b300000000000000000000000068b3465833fb72a70ecdf485e0e4c7bd8665fc45ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000b2c639c533813f4aa9d7837caf62653d097ff850000000000000000000000000000000000000000000000000000000000000001000000000000000000000000000000000000000000000000000000000000000100000000000000000000000068b3465833fb72a70ecdf485e0e4c7bd8665fc45000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000a000000000000000000000000000000000000000000000000000000000000001c000000000000000000000000000000000000000000000000000000000000000e404e45aaf0000000000000000000000000b2c639c533813f4aa9d7837caf62653d097ff850000000000000000000000007f5c764cbc14f9669b88837ca1490cca17c316070000000000000000000000000000000000000000000000000000000000000064000000000000000000000000ea749fd6ba492dbc14c24fe8a3d08769229b896c00000000000000000000000000000000000000000000000000000000b8d3ad5700000000000000000000000000000000000000000000000000000000b8c346b000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000b2c639c533813f4aa9d7837caf62653d097ff85000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000000000000000000000000000007f5c764cbc14f9669b88837ca1490cca17c31607000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000a000000000000000000000000000000000000000000000000000000000000001200000000000000000000000000000000000000000000000000000000000000044095ea7b300000000000000000000000068b3465833fb72a70ecdf485e0e4c7bd8665fc45ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000400000000000000000000000007f5c764cbc14f9669b88837ca1490cca17c316070000000000000000000000000000000000000000000000000000000000000001000000000000000000000000000000000000000000000000000000000000000100000000000000000000000068b3465833fb72a70ecdf485e0e4c7bd8665fc45000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000a000000000000000000000000000000000000000000000000000000000000001c000000000000000000000000000000000000000000000000000000000000000e404e45aaf0000000000000000000000007f5c764cbc14f9669b88837ca1490cca17c31607000000000000000000000000eb466342c4d449bc9f53a865d5cb90586f4052150000000000000000000000000000000000000000000000000000000000000064000000000000000000000000ce16f69375520ab01377ce7b88f5ba8c48f8d66600000000000000000000000000000000000000000000000000000000b8d6341300000000000000000000000000000000000000000000000000000000b8ca89fa00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000400000000000000000000000007f5c764cbc14f9669b88837ca1490cca17c316070000000000000000000000000000000000000000000000000000000000000004000000000000000000000000000000000000000000000000000000000000000761786c55534443000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000008417262697472756d000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000002a307863653136463639333735353230616230313337376365374238386635424138433438463844363636000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000c100000000000000000000000000000000000000000000000000000000000000040000000000000000000000000141d32a89a1e0a5ef360034a2f60a4b917c18838000000000000000000000000000000000000000000000000000000000000000700000000000000000000000000000000000000000000000000000000000000e000000000000000000000000000000000000000000000000000000000000001e000000000000000000000000000000000000000000000000000000000000003600000000000000000000000000000000000000000000000000000000000000580000000000000000000000000000000000000000000000000000000000000070000000000000000000000000000000000000000000000000000000000000009200000000000000000000000000000000000000000000000000000000000000a8000000000000000000000000000000000000000000000000000000000000000030000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000a000000000000000000000000000000000000000000000000000000000000000c000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000020000000000000000000000000eb466342c4d449bc9f53a865d5cb90586f4052150000000000000000000000000000000000000000000000000000000000000000000000000000000000000000eb466342c4d449bc9f53a865d5cb90586f405215000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000a000000000000000000000000000000000000000000000000000000000000001200000000000000000000000000000000000000000000000000000000000000044095ea7b300000000000000000000000032226588378236fd0c7c4053999f88ac0e5cac77ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000040000000000000000000000000eb466342c4d449bc9f53a865d5cb90586f4052150000000000000000000000000000000000000000000000000000000000000001000000000000000000000000000000000000000000000000000000000000000100000000000000000000000032226588378236fd0c7c4053999f88ac0e5cac77000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000a000000000000000000000000000000000000000000000000000000000000001c000000000000000000000000000000000000000000000000000000000000000e404e45aaf000000000000000000000000eb466342c4d449bc9f53a865d5cb90586f405215000000000000000000000000af88d065e77c8cc2239327c5edb3a432268e58310000000000000000000000000000000000000000000000000000000000000064000000000000000000000000ea749fd6ba492dbc14c24fe8a3d08769229b896c00000000000000000000000000000000000000000000000000000000b8dd781b00000000000000000000000000000000000000000000000000000000b8bb9ee30000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000040000000000000000000000000eb466342c4d449bc9f53a865d5cb90586f40521500000000000000000000000000000000000000000000000000000000000000040000000000000000000000000000000000000000000000000000000000000000000000000000000000000000af88d065e77c8cc2239327c5edb3a432268e5831000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000a000000000000000000000000000000000000000000000000000000000000001200000000000000000000000000000000000000000000000000000000000000044095ea7b300000000000000000000000068b3465833fb72a70ecdf485e0e4c7bd8665fc45ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000040000000000000000000000000af88d065e77c8cc2239327c5edb3a432268e58310000000000000000000000000000000000000000000000000000000000000001000000000000000000000000000000000000000000000000000000000000000100000000000000000000000068b3465833fb72a70ecdf485e0e4c7bd8665fc45000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000a000000000000000000000000000000000000000000000000000000000000001c000000000000000000000000000000000000000000000000000000000000000e404e45aaf000000000000000000000000af88d065e77c8cc2239327c5edb3a432268e583100000000000000000000000082af49447d8a07e3bd95bd0d56f35241523fbab100000000000000000000000000000000000000000000000000000000000001f4000000000000000000000000ea749fd6ba492dbc14c24fe8a3d08769229b896c00000000000000000000000000000000000000000000000000000000b8ce8b7d0000000000000000000000000000000000000000000000000db72b79f837011c0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000040000000000000000000000000af88d065e77c8cc2239327c5edb3a432268e58310000000000000000000000000000000000000000000000000000000000000004000000000000000000000000000000000000000000000000000000000000000100000000000000000000000082af49447d8a07e3bd95bd0d56f35241523fbab1000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000a0000000000000000000000000000000000000000000000000000000000000010000000000000000000000000000000000000000000000000000000000000000242e1a7d4d000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000004000000000000000000000000082af49447d8a07e3bd95bd0d56f35241523fbab100000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000002000000000000000000000000141d32a89a1e0a5ef360034a2f60a4b917c18838000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000a000000000000000000000000000000000000000000000000000000000000000c000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000040000000000000000000000000eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee00000000000000000000000000000000000000000000000000000000000000004f2154d9b330221b2ad461adf63acc2c000000000000000000000000000000004f2154d9b330221b2ad461adf63acc2c0000000000000000000000003c17c95cdb5887c334bfae85750ce00e1a720a76eff35e60db6c9f3b8384a6d63db3c56f1ce6545b50ba2f250429055ca77e7e6203ddd65a7a4d89ae1af3d61b1c', + gasLimit: 910342, + effectiveGas: 710342, + }, + estimatedProcessingTimeInSeconds: 20, + }, +] as unknown as QuoteResponseV1[]; + +export const getMockBridgeQuotesErc20NativeV2 = ( + quoteOverrides?: DeepPartial, +): QuoteResponse[] => { + return mockBridgeQuotesErc20NativeV1.map((quote) => { + const mergedQuote = merge({}, quote, quoteOverrides); + validateQuoteResponseV1(mergedQuote); + return toQuoteResponseV2(mergedQuote); + }); +}; diff --git a/packages/bridge-controller/tests/mock-quotes-native-erc20-eth.ts b/packages/bridge-controller/tests/mock-quotes-native-erc20-eth.ts new file mode 100644 index 00000000000..6ef89d6770e --- /dev/null +++ b/packages/bridge-controller/tests/mock-quotes-native-erc20-eth.ts @@ -0,0 +1,223 @@ +import { merge } from 'lodash'; + +import { toQuoteResponseV2 } from '../src/coercers/quote-response-v1-to-v2.js'; +import type { DeepPartial } from '../src/types.js'; +import type { QuoteResponseV1 } from '../src/validators/quote-response-v1.js'; +import { validateQuoteResponseV1 } from '../src/validators/quote-response-v1.js'; +import type { QuoteResponse } from '../src/validators/quote-response.js'; +import { ActionTypes } from '../src/validators/step.js'; + +export const mockBridgeQuotesNativeErc20EthV1: QuoteResponseV1[] = [ + { + quote: { + requestId: '34c4136d-8558-4d87-bdea-eef8d2d30d6d', + srcChainId: 1, + srcTokenAmount: '991250000000000000', + srcAsset: { + address: '0x0000000000000000000000000000000000000000', + assetId: 'eip155:1/slip44:60', + chainId: 1, + symbol: 'ETH', + decimals: 18, + name: 'ETH', + icon: 'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png', + }, + destChainId: 42161, + destTokenAmount: '3104367033', + minDestTokenAmount: '3040000000', + destAsset: { + address: '0xaf88d065e77c8cC2239327C5EDb3A432268e5831', + assetId: + 'eip155:42161/erc20:0xaf88d065e77c8cC2239327C5EDb3A432268e5831', + chainId: 42161, + symbol: 'USDC', + decimals: 6, + name: 'USD Coin', + icon: 'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48/logo.png', + }, + feeData: { + metabridge: { + amount: '8750000000000000', + asset: { + address: '0x0000000000000000000000000000000000000000', + assetId: 'eip155:1/slip44:60', + chainId: 1, + symbol: 'ETH', + decimals: 18, + name: 'ETH', + }, + }, + }, + bridgeId: 'lifi', + bridges: ['across'], + steps: [ + { + action: ActionTypes.SWAP, + srcChainId: 1, + destChainId: 1, + srcAsset: { + address: '0x0000000000000000000000000000000000000000', + assetId: 'eip155:1/slip44:60', + chainId: 1, + symbol: 'ETH', + decimals: 18, + name: 'ETH', + }, + destAsset: { + address: '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48', + assetId: + 'eip155:42161/erc20:0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48', + chainId: 1, + symbol: 'USDC', + decimals: 6, + name: 'USD Coin', + }, + }, + { + action: ActionTypes.BRIDGE, + srcChainId: 1, + destChainId: 42161, + srcAsset: { + address: '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48', + assetId: + 'eip155:42161/erc20:0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48', + chainId: 1, + symbol: 'USDC', + decimals: 6, + name: 'USD Coin', + }, + destAsset: { + address: '0xaf88d065e77c8cC2239327C5EDb3A432268e5831', + assetId: + 'eip155:42161/erc20:0xaf88d065e77c8cC2239327C5EDb3A432268e5831', + chainId: 42161, + symbol: 'USDC', + decimals: 6, + name: 'USD Coin', + }, + }, + ], + }, + trade: { + chainId: 1, + to: '0x0439e60F02a8900a951603950d8D4527f400C3f1', + from: '0x141d32a89a1e0a5ef360034a2f60a4b917c18838', + value: '0x0de0b6b3a7640000', + data: '0x3ce33bff000000000000000000000000000000000000000000000000000000000000008000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000de0b6b3a764000000000000000000000000000000000000000000000000000000000000000000c0000000000000000000000000000000000000000000000000000000000000000d6c696669416461707465725632000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000b400000000000000000000000001231deb6f5749ef6ce6943a275a1d3e7486f4eae0000000000000000000000001231deb6f5749ef6ce6943a275a1d3e7486f4eae000000000000000000000000000000000000000000000000000000000000a4b10000000000000000000000000000000000000000000000000000000000000000000000000000000000000000af88d065e77c8cc2239327c5edb3a432268e58310000000000000000000000000000000000000000000000000dc1a09f859b20000000000000000000000000000000000000000000000000000000000000000140000000000000000000000000000000000000000000000000001f161421c8e000000000000000000000000000e6b738da243e8fa2a0ed5915645789add5de51520000000000000000000000000000000000000000000000000000000000000a003a3f733200000000000000000000000000000000000000000000000000000000000000600000000000000000000000000000000000000000000000000000000000000220000000000000000000000000000000000000000000000000000000000000094027363a1fac5600d1f7e8a4c50087ff1f32a09359512d2379d46b331c6033cc7b000000000000000000000000000000000000000000000000000000000000014000000000000000000000000000000000000000000000000000000000000001800000000000000000000000000000000000000000000000000000000000000000000000000000000000000000a0b86991c6218b36c1d19d4a2e9eb0ce3606eb48000000000000000000000000141d32a89a1e0a5ef360034a2f60a4b917c1883800000000000000000000000000000000000000000000000000000000b8211d6e000000000000000000000000000000000000000000000000000000000000a4b10000000000000000000000000000000000000000000000000000000000000001000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000066163726f73730000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000f6d6574616d61736b2d6272696467650000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000100000000000000000000000000000000000000000000000000000000000000200000000000000000000000000000000000001ff3684f28c67538d4d072c227340000000000000000000000000000000000001ff3684f28c67538d4d072c227340000000000000000000000000000000000000000000000000000000000000000000000000000000000000000a0b86991c6218b36c1d19d4a2e9eb0ce3606eb480000000000000000000000000000000000000000000000000dc1a09f859b200000000000000000000000000000000000000000000000000000000000000000e0000000000000000000000000000000000000000000000000000000000000000100000000000000000000000000000000000000000000000000000000000005c42213bc0b00000000000000000000000070bf6634ee8cb27d04478f184b9b8bb13e5f471000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000dc1a09f859b200000000000000000000000000070bf6634ee8cb27d04478f184b9b8bb13e5f471000000000000000000000000000000000000000000000000000000000000000a000000000000000000000000000000000000000000000000000000000000004e41fff991f0000000000000000000000001231deb6f5749ef6ce6943a275a1d3e7486f4eae000000000000000000000000a0b86991c6218b36c1d19d4a2e9eb0ce3606eb4800000000000000000000000000000000000000000000000000000000b909399a00000000000000000000000000000000000000000000000000000000000000a094cc69295a8f2a3016ede239627ab300000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000003000000000000000000000000000000000000000000000000000000000000006000000000000000000000000000000000000000000000000000000000000001a000000000000000000000000000000000000000000000000000000000000002c0000000000000000000000000000000000000000000000000000000000000010438c9c147000000000000000000000000eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee0000000000000000000000000000000000000000000000000000000000002710000000000000000000000000c02aaa39b223fe8d0a0e5c4f27ead9083c756cc2000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000a00000000000000000000000000000000000000000000000000000000000000024d0e30db00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000e48d68a15600000000000000000000000070bf6634ee8cb27d04478f184b9b8bb13e5f4710000000000000000000000000000000000000000000000000000000000000271000000000000000000000000000000000000000000000000000000000000000800000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000002cc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2010001f4a0b86991c6218b36c1d19d4a2e9eb0ce3606eb48000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000012438c9c147000000000000000000000000a0b86991c6218b36c1d19d4a2e9eb0ce3606eb480000000000000000000000000000000000000000000000000000000000000005000000000000000000000000a0b86991c6218b36c1d19d4a2e9eb0ce3606eb48000000000000000000000000000000000000000000000000000000000000002400000000000000000000000000000000000000000000000000000000000000a00000000000000000000000000000000000000000000000000000000000000044a9059cbb000000000000000000000000ad01c20d5886137e056775af56915de824c8fce50000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000620541d325b000000000000000000000000000000000000000000000000000000000673656d70000000000000000000000000000000000000000000000000000000000000080ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff0000000000000000000000000000000000000000000000000000000000000000d00dfeeddeadbeef8932eb23bad9bddb5cf81426f78279a53c6c3b71dcbfe555f9a744b18195d9b52032871d6f3c5a558275c08a71c2b6214801f5161be976f49181b854a3ebcbe1f2b896133b03314a5ff2746e6494c43e59d0c9ee1c', + gasLimit: 540099, + effectiveGas: 540076, + }, + estimatedProcessingTimeInSeconds: 45, + }, + { + quote: { + requestId: '5bf0f2f0-655c-4e13-a545-1ebad6f9d2bc', + srcChainId: 1, + srcTokenAmount: '991250000000000000', + srcAsset: { + address: '0x0000000000000000000000000000000000000000', + assetId: 'eip155:1/slip44:60', + chainId: 1, + symbol: 'ETH', + decimals: 18, + name: 'ETH', + icon: 'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png', + }, + destChainId: 42161, + destTokenAmount: '3104601473', + minDestTokenAmount: '3041000000', + destAsset: { + address: '0xaf88d065e77c8cC2239327C5EDb3A432268e5831', + assetId: + 'eip155:42161/erc20:0xaf88d065e77c8cC2239327C5EDb3A432268e5831', + chainId: 42161, + symbol: 'USDC', + decimals: 6, + name: 'USD Coin', + icon: 'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48/logo.png', + }, + feeData: { + metabridge: { + amount: '8750000000000000', + asset: { + address: '0x0000000000000000000000000000000000000000', + assetId: 'eip155:1/slip44:60', + chainId: 1, + symbol: 'ETH', + decimals: 18, + name: 'ETH', + }, + }, + }, + bridgeId: 'lifi', + bridges: ['celercircle'], + steps: [ + { + action: ActionTypes.SWAP, + srcChainId: 1, + destChainId: 1, + srcAsset: { + address: '0x0000000000000000000000000000000000000000', + assetId: 'eip155:1/slip44:60', + chainId: 1, + symbol: 'ETH', + decimals: 18, + name: 'ETH', + }, + destAsset: { + address: '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48', + assetId: + 'eip155:42161/erc20:0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48', + chainId: 1, + symbol: 'USDC', + decimals: 6, + name: 'USD Coin', + }, + }, + { + action: ActionTypes.BRIDGE, + srcChainId: 1, + destChainId: 42161, + srcAsset: { + address: '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48', + assetId: + 'eip155:42161/erc20:0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48', + chainId: 1, + symbol: 'USDC', + decimals: 6, + name: 'USD Coin', + }, + destAsset: { + address: '0xaf88d065e77c8cC2239327C5EDb3A432268e5831', + assetId: + 'eip155:42161/erc20:0xaf88d065e77c8cC2239327C5EDb3A432268e5831', + chainId: 42161, + symbol: 'USDC', + decimals: 6, + name: 'USD Coin', + }, + }, + ], + }, + trade: { + chainId: 1, + to: '0x0439e60F02a8900a951603950d8D4527f400C3f1', + from: '0x141d32a89a1e0a5ef360034a2f60a4b917c18838', + value: '0x0de0b6b3a7640000', + data: '0x3ce33bff000000000000000000000000000000000000000000000000000000000000008000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000de0b6b3a764000000000000000000000000000000000000000000000000000000000000000000c0000000000000000000000000000000000000000000000000000000000000000d6c696669416461707465725632000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000a800000000000000000000000001231deb6f5749ef6ce6943a275a1d3e7486f4eae0000000000000000000000001231deb6f5749ef6ce6943a275a1d3e7486f4eae000000000000000000000000000000000000000000000000000000000000a4b10000000000000000000000000000000000000000000000000000000000000000000000000000000000000000af88d065e77c8cc2239327c5edb3a432268e58310000000000000000000000000000000000000000000000000dc1a09f859b20000000000000000000000000000000000000000000000000000000000000000140000000000000000000000000000000000000000000000000001f161421c8e000000000000000000000000000e6b738da243e8fa2a0ed5915645789add5de515200000000000000000000000000000000000000000000000000000000000009248fab066300000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000200b431adcab44c6fe13ade53dbd3b714f57922ab5b776924a913685ad0fe680f6c000000000000000000000000000000000000000000000000000000000000014000000000000000000000000000000000000000000000000000000000000001800000000000000000000000000000000000000000000000000000000000000000000000000000000000000000a0b86991c6218b36c1d19d4a2e9eb0ce3606eb48000000000000000000000000141d32a89a1e0a5ef360034a2f60a4b917c1883800000000000000000000000000000000000000000000000000000000b8211d6e000000000000000000000000000000000000000000000000000000000000a4b100000000000000000000000000000000000000000000000000000000000000010000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000b63656c6572636972636c65000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000f6d6574616d61736b2d6272696467650000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000100000000000000000000000000000000000000000000000000000000000000200000000000000000000000000000000000001ff3684f28c67538d4d072c227340000000000000000000000000000000000001ff3684f28c67538d4d072c227340000000000000000000000000000000000000000000000000000000000000000000000000000000000000000a0b86991c6218b36c1d19d4a2e9eb0ce3606eb480000000000000000000000000000000000000000000000000dc1a09f859b200000000000000000000000000000000000000000000000000000000000000000e0000000000000000000000000000000000000000000000000000000000000000100000000000000000000000000000000000000000000000000000000000005c42213bc0b00000000000000000000000070bf6634ee8cb27d04478f184b9b8bb13e5f471000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000dc1a09f859b200000000000000000000000000070bf6634ee8cb27d04478f184b9b8bb13e5f471000000000000000000000000000000000000000000000000000000000000000a000000000000000000000000000000000000000000000000000000000000004e41fff991f0000000000000000000000001231deb6f5749ef6ce6943a275a1d3e7486f4eae000000000000000000000000a0b86991c6218b36c1d19d4a2e9eb0ce3606eb4800000000000000000000000000000000000000000000000000000000b909399a00000000000000000000000000000000000000000000000000000000000000a0c0452b52ecb7cf70409b16cd627ab300000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000003000000000000000000000000000000000000000000000000000000000000006000000000000000000000000000000000000000000000000000000000000001a000000000000000000000000000000000000000000000000000000000000002c0000000000000000000000000000000000000000000000000000000000000010438c9c147000000000000000000000000eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee0000000000000000000000000000000000000000000000000000000000002710000000000000000000000000c02aaa39b223fe8d0a0e5c4f27ead9083c756cc2000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000a00000000000000000000000000000000000000000000000000000000000000024d0e30db00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000e48d68a15600000000000000000000000070bf6634ee8cb27d04478f184b9b8bb13e5f4710000000000000000000000000000000000000000000000000000000000000271000000000000000000000000000000000000000000000000000000000000000800000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000002cc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2010001f4a0b86991c6218b36c1d19d4a2e9eb0ce3606eb48000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000012438c9c147000000000000000000000000a0b86991c6218b36c1d19d4a2e9eb0ce3606eb480000000000000000000000000000000000000000000000000000000000000005000000000000000000000000a0b86991c6218b36c1d19d4a2e9eb0ce3606eb48000000000000000000000000000000000000000000000000000000000000002400000000000000000000000000000000000000000000000000000000000000a00000000000000000000000000000000000000000000000000000000000000044a9059cbb000000000000000000000000ad01c20d5886137e056775af56915de824c8fce50000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000047896dca097909ba9db4c9631bce0e53090bce14a9b7d203e21fa80cee7a16fa049aa1ef7d663c2ec3148e698e01774b62ddedc9c2dcd21994e549cd6f318f971b', + gasLimit: 682999, + effectiveGas: 682910, + }, + estimatedProcessingTimeInSeconds: 1029.717, + }, +]; + +export const getMockBridgeQuotesNativeErc20EthV2 = ( + quoteOverrides?: DeepPartial, +): QuoteResponse[] => { + return mockBridgeQuotesNativeErc20EthV1.map((quote) => { + const mergedQuote = merge({}, quote, quoteOverrides); + validateQuoteResponseV1(mergedQuote); + return toQuoteResponseV2(mergedQuote); + }); +}; diff --git a/packages/bridge-controller/tests/mock-quotes-native-erc20.ts b/packages/bridge-controller/tests/mock-quotes-native-erc20.ts new file mode 100644 index 00000000000..52214e3c433 --- /dev/null +++ b/packages/bridge-controller/tests/mock-quotes-native-erc20.ts @@ -0,0 +1,283 @@ +import { KnownCaipNamespace } from '@metamask/utils'; +import { merge } from 'lodash'; + +import { toQuoteResponseV2 } from '../src/coercers/quote-response-v1-to-v2.js'; +import type { DeepPartial } from '../src/types.js'; +import type { QuoteResponseV1 } from '../src/validators/quote-response-v1.js'; +import { validateQuoteResponseV1 } from '../src/validators/quote-response-v1.js'; +import type { QuoteResponse } from '../src/validators/quote-response.js'; +import { ActionTypes } from '../src/validators/step.js'; +import type { TxData } from '../src/validators/trade.js'; + +export const mockBridgeQuotesNativeErc20V1: QuoteResponseV1[] = [ + { + quote: { + requestId: '381c23bc-e3e4-48fe-bc53-257471e388ad', + srcChainId: 10, + srcAsset: { + chainId: 10, + address: '0x0000000000000000000000000000000000000000', + assetId: 'eip155:10/slip44:60', + symbol: 'ETH', + name: 'Ethereum', + decimals: 18, + icon: 'https://media.socket.tech/tokens/all/ETH', + }, + srcTokenAmount: '9912500000000000', + destChainId: 137, + destAsset: { + chainId: 137, + address: '0x3c499c542cef5e3811e1192ce70d8cc03d5c3359', + assetId: 'eip155:137/erc20:0x3c499c542cef5e3811e1192ce70d8cc03d5c3359', + symbol: 'USDC', + name: 'Native USD Coin (POS)', + decimals: 6, + icon: 'https://media.socket.tech/tokens/all/USDC', + }, + destTokenAmount: '24438902', + minDestTokenAmount: '23900000', + feeData: { + metabridge: { + amount: '87500000000000', + asset: { + chainId: 10, + address: '0x0000000000000000000000000000000000000000', + assetId: 'eip155:10/slip44:60', + symbol: 'ETH', + name: 'Ethereum', + decimals: 18, + icon: 'https://media.socket.tech/tokens/all/ETH', + }, + }, + }, + bridgeId: 'socket', + bridges: ['across'], + steps: [ + { + action: ActionTypes.SWAP, + srcChainId: 10, + destChainId: 10, + srcAsset: { + chainId: 10, + address: '0x0000000000000000000000000000000000000000', + assetId: 'eip155:10/slip44:60', + symbol: 'ETH', + name: 'Ethereum', + decimals: 18, + icon: 'https://assets.polygon.technology/tokenAssets/eth.svg', + }, + destAsset: { + chainId: 10, + address: '0x0b2c639c533813f4aa9d7837caf62653d097ff85', + assetId: + 'eip155:10/erc20:0x0b2c639c533813f4aa9d7837caf62653d097ff85', + symbol: 'USDC', + name: 'USD Coin', + decimals: 6, + icon: 'https://assets.polygon.technology/tokenAssets/usdc.svg', + }, + }, + { + action: ActionTypes.BRIDGE, + srcChainId: 10, + destChainId: 137, + srcAsset: { + chainId: 10, + address: '0x0b2c639c533813f4aa9d7837caf62653d097ff85', + assetId: + 'eip155:10/erc20:0x0b2c639c533813f4aa9d7837caf62653d097ff85', + symbol: 'USDC', + name: 'USD Coin', + decimals: 6, + icon: 'https://assets.polygon.technology/tokenAssets/usdc.svg', + }, + destAsset: { + chainId: 137, + address: '0x3c499c542cef5e3811e1192ce70d8cc03d5c3359', + assetId: + 'eip155:137/erc20:0x3c499c542cef5e3811e1192ce70d8cc03d5c3359', + symbol: 'USDC', + name: 'Native USD Coin (POS)', + decimals: 6, + icon: 'https://assets.polygon.technology/tokenAssets/usdc.svg', + }, + }, + ], + refuel: { + action: ActionTypes.REFUEL, + srcChainId: 10, + destChainId: 137, + srcAsset: { + chainId: 10, + address: '0x0000000000000000000000000000000000000000', + assetId: 'eip155:10/slip44:60', + symbol: 'ETH', + name: 'Ether', + decimals: 18, + }, + destAsset: { + chainId: 137, + address: '0x0000000000000000000000000000000000000000', + assetId: 'eip155:137/slip44:966', + symbol: 'MATIC', + name: 'Matic', + decimals: 18, + }, + }, + }, + trade: { + chainId: 10, + to: '0xB90357f2b86dbfD59c3502215d4060f71DF8ca0e', + from: '0x141d32a89a1e0a5ef360034a2f60a4b917c18838', + value: '0x27147114878000', + data: '0x3ce33bff00000000000000000000000000000000000000000000000000000000000000800000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000002714711487800000000000000000000000000000000000000000000000000000000000000000c0000000000000000000000000000000000000000000000000000000000000000f736f636b657441646170746572563200000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000f600000000000000000000000003a23f943181408eac424116af7b7790c94cb97a50000000000000000000000003a23f943181408eac424116af7b7790c94cb97a5000000000000000000000000000000000000000000000000000000000000008900000000000000000000000000000000000000000000000000000000000000000000000000000000000000003c499c542cef5e3811e1192ce70d8cc03d5c33590000000000000000000000000000000000000000000000000023375dc1560800000000000000000000000000000000000000000000000000000000000000014000000000000000000000000000000000000000000000000000004f94ae6af800000000000000000000000000716a8b9dd056055c84b7a2ba0a016099465a51870000000000000000000000000000000000000000000000000000000000000e2037c6145a0000000000000000000000000000000000000000000000000000000000000020000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000d64123506490000000000000000000000000000000000000000000000000000000000000020000000000000000000000000000000000000000000000000000000000000000500000000000000000000000000000000000000000000000000000000000000c000000000000000000000000000000000000000000000000000000000000001960000000000000000000000000000000000000000000000000000000000000180000000000000000000000000000000000000000000000000000000000000019d0000000000000000000000000000000000000000000000000000000000000ac00000000000000000000000000000000000000000000000000000000000000084ad69fa4f00000000000000000000000000000000000000000000000000038d7ea4c68000000000000000000000000000141d32a89a1e0a5ef360034a2f60a4b917c1883800000000000000000000000000000000000000000000000000000000000000890000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000904ee8f0b86000000000000000000000000eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee0000000000000000000000000b2c639c533813f4aa9d7837caf62653d097ff850000000000000000000000000000000000000000000000000023375dc156080000000000000000000000000000000000000000000000000000000000000000c400000000000000000000000000000000000000000000000000000000000000a00000000000000000000000000000000000000000000000000000000000000828415565b0000000000000000000000000eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee0000000000000000000000000b2c639c533813f4aa9d7837caf62653d097ff850000000000000000000000000000000000000000000000000023375dc15608000000000000000000000000000000000000000000000000000000000001734d0800000000000000000000000000000000000000000000000000000000000000a000000000000000000000000000000000000000000000000000000000000000040000000000000000000000000000000000000000000000000000000000000080000000000000000000000000000000000000000000000000000000000000012000000000000000000000000000000000000000000000000000000000000004e000000000000000000000000000000000000000000000000000000000000005e0000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000040000000000000000000000000eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee0000000000000000000000000000000000000000000000000023375dc15608000000000000000000000000000000000000000000000000000000000000000011000000000000000000000000000000000000000000000000000000000000004000000000000000000000000000000000000000000000000000000000000003600000000000000000000000000000000000000000000000000000000000000020000000000000000000000000000000000000000000000000000000000000000000000000000000000000000042000000000000000000000000000000000000060000000000000000000000000b2c639c533813f4aa9d7837caf62653d097ff8500000000000000000000000000000000000000000000000000000000000001400000000000000000000000000000000000000000000000000000000000000320000000000000000000000000000000000000000000000000000000000000032000000000000000000000000000000000000000000000000000000000000002e00000000000000000000000000000000000000000000000000023375dc1560800000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000003200000000000000000000000000000000000000000000000000000000000000001000000000000000000000000000000000000000000000000000000000000002000000000000000000000000000000012556e69737761705633000000000000000000000000000000000000000000000000000000000000000023375dc1560800000000000000000000000000000000000000000000000000000000000173dbd3000000000000000000000000000000000000000000000000000000000000008000000000000000000000000000000000000000000000000000000000000000c0000000000000000000000000e592427a0aece92de3edee1f18e0157c0586156400000000000000000000000000000000000000000000000000000000000000600000000000000000000000000000000000000000000000000000000000000001000000000000000000000000000000000000000000000000000000000000002b42000000000000000000000000000000000000060001f40b2c639c533813f4aa9d7837caf62653d097ff85000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000100000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000a000000000000000000000000000000000000000000000000000000000000004000000000000000000000000000000000000000000000000000000000000000a0000000000000000000000000000000000000000000000000000000000000002000000000000000000000000000000000000000000000000000000000000000010000000000000000000000000b2c639c533813f4aa9d7837caf62653d097ff850000000000000000000000000000000000000000000000000000000000008ecb000000000000000000000000ad01c20d5886137e056775af56915de824c8fce5000000000000000000000000000000000000000000000000000000000000000b000000000000000000000000000000000000000000000000000000000000004000000000000000000000000000000000000000000000000000000000000000e00000000000000000000000000000000000000000000000000000000000000020000000000000000000000000000000000000000000000000000000000000004000000000000000000000000000000000000000000000000000000000000000a000000000000000000000000000000000000000000000000000000000000000020000000000000000000000004200000000000000000000000000000000000006000000000000000000000000eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee0000000000000000000000000000000000000000000000000000000000000000869584cd0000000000000000000000001000000000000000000000000000000000000011000000000000000000000000000000000000000021582def464917822ff6092c000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000260000000000000000000000000000000000000000000000000000000000000002000000000000000000000000000000000000000000000000000000000000000c00000000000000000000000000000000000000000000000000000000000000120000000000000000000000000000000000000000000000000000000000000018000000000000000000000000000000000000000000000000000000000000001e000000000000000000000000000000000000000000000000000000000000043a900000000000000000000000000000000000000000000000000000000000000c40000000000000000000000000000000000000000000000000000000000000002000000000000000000000000141d32a89a1e0a5ef360034a2f60a4b917c18838000000000000000000000000141d32a89a1e0a5ef360034a2f60a4b917c1883800000000000000000000000000000000000000000000000000000000000000020000000000000000000000000b2c639c533813f4aa9d7837caf62653d097ff850000000000000000000000003c499c542cef5e3811e1192ce70d8cc03d5c33590000000000000000000000000000000000000000000000000000000000000002000000000000000000000000000000000000000000000000000000000174e7be000000000000000000000000000000000000000000000000000000000000008900000000000000000000000000000000000000000000000000000000000000020000000000000000000000000000000000000000000000000000000067041c47000000000000000000000000000000000000000000000000000000006704704d00000000000000000000000000000000000000000000000000000000d00dfeeddeadbeef765753be7f7a64d5509974b0d678e1e3149b02f41fec59a4aef7d9ac92ee5eeaf293cb28c2261e7fd322723a97cb83762f7302296636026e52849fdad0f9db6e1640f914660e6b13f5b1a29345344c8c5687abbf1b', + gasLimit: 610414, + effectiveGas: 610300, + }, + estimatedProcessingTimeInSeconds: 60, + }, + { + quote: { + requestId: '4277a368-40d7-4e82-aa67-74f29dc5f98a', + srcChainId: 10, + srcAsset: { + chainId: 10, + address: '0x0000000000000000000000000000000000000000', + assetId: 'eip155:10/slip44:60', + symbol: 'ETH', + name: 'Ethereum', + decimals: 18, + icon: 'https://media.socket.tech/tokens/all/ETH', + }, + srcTokenAmount: '9912500000000000', + destChainId: 137, + destAsset: { + chainId: 137, + address: '0x3c499c542cef5e3811e1192ce70d8cc03d5c3359', + assetId: 'eip155:137/erc20:0x3c499c542cef5e3811e1192ce70d8cc03d5c3359', + symbol: 'USDC', + name: 'Native USD Coin (POS)', + decimals: 6, + icon: 'https://media.socket.tech/tokens/all/USDC', + }, + destTokenAmount: '24256223', + minDestTokenAmount: '23760000', + feeData: { + metabridge: { + amount: '87500000000000', + asset: { + chainId: 10, + address: '0x0000000000000000000000000000000000000000', + assetId: 'eip155:10/slip44:60', + symbol: 'ETH', + name: 'Ethereum', + decimals: 18, + icon: 'https://media.socket.tech/tokens/all/ETH', + }, + }, + }, + bridgeId: 'socket', + bridges: ['celercircle'], + steps: [ + { + action: ActionTypes.SWAP, + srcChainId: 10, + destChainId: 137, + srcAsset: { + chainId: 10, + address: '0x0000000000000000000000000000000000000000', + assetId: 'eip155:10/slip44:60', + symbol: 'ETH', + name: 'Ethereum', + decimals: 18, + icon: 'https://assets.polygon.technology/tokenAssets/eth.svg', + }, + destAsset: { + chainId: 10, + address: '0x0b2c639c533813f4aa9d7837caf62653d097ff85', + assetId: + 'eip155:10/erc20:0x0b2c639c533813f4aa9d7837caf62653d097ff85', + symbol: 'USDC', + name: 'USD Coin', + decimals: 6, + icon: 'https://assets.polygon.technology/tokenAssets/usdc.svg', + }, + }, + { + action: ActionTypes.BRIDGE, + srcChainId: 10, + destChainId: 137, + srcAsset: { + chainId: 10, + address: '0x0b2c639c533813f4aa9d7837caf62653d097ff85', + assetId: + 'eip155:10/erc20:0x0b2c639c533813f4aa9d7837caf62653d097ff85', + symbol: 'USDC', + name: 'USD Coin', + decimals: 6, + icon: 'https://assets.polygon.technology/tokenAssets/usdc.svg', + }, + destAsset: { + chainId: 137, + address: '0x3c499c542cef5e3811e1192ce70d8cc03d5c3359', + assetId: + 'eip155:137/erc20:0x3c499c542cef5e3811e1192ce70d8cc03d5c3359', + symbol: 'USDC', + name: 'Native USD Coin (POS)', + decimals: 6, + icon: 'https://assets.polygon.technology/tokenAssets/usdc.svg', + }, + }, + ], + refuel: { + action: ActionTypes.REFUEL, + srcChainId: 10, + destChainId: 137, + srcAsset: { + chainId: 10, + address: '0x0000000000000000000000000000000000000000', + assetId: 'eip155:10/slip44:60', + symbol: 'ETH', + name: 'Ether', + decimals: 18, + }, + destAsset: { + chainId: 137, + address: '0x0000000000000000000000000000000000000000', + assetId: 'eip155:137/slip44:966', + symbol: 'MATIC', + name: 'Matic', + decimals: 18, + }, + }, + }, + trade: { + chainId: 10, + to: '0xB90357f2b86dbfD59c3502215d4060f71DF8ca0e', + from: '0x141d32a89a1e0a5ef360034a2f60a4b917c18838', + value: '0x27147114878000', + data: '0x3ce33bff00000000000000000000000000000000000000000000000000000000000000800000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000002714711487800000000000000000000000000000000000000000000000000000000000000000c0000000000000000000000000000000000000000000000000000000000000000f736f636b657441646170746572563200000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000dc00000000000000000000000003a23f943181408eac424116af7b7790c94cb97a50000000000000000000000003a23f943181408eac424116af7b7790c94cb97a5000000000000000000000000000000000000000000000000000000000000008900000000000000000000000000000000000000000000000000000000000000000000000000000000000000003c499c542cef5e3811e1192ce70d8cc03d5c33590000000000000000000000000000000000000000000000000023375dc1560800000000000000000000000000000000000000000000000000000000000000014000000000000000000000000000000000000000000000000000004f94ae6af800000000000000000000000000716a8b9dd056055c84b7a2ba0a016099465a51870000000000000000000000000000000000000000000000000000000000000c6437c6145a0000000000000000000000000000000000000000000000000000000000000020000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000bc4123506490000000000000000000000000000000000000000000000000000000000000020000000000000000000000000000000000000000000000000000000000000000500000000000000000000000000000000000000000000000000000000000000c000000000000000000000000000000000000000000000000000000000000001960000000000000000000000000000000000000000000000000000000000000180000000000000000000000000000000000000000000000000000000000000018c0000000000000000000000000000000000000000000000000000000000000ac00000000000000000000000000000000000000000000000000000000000000084ad69fa4f00000000000000000000000000000000000000000000000000038d7ea4c68000000000000000000000000000141d32a89a1e0a5ef360034a2f60a4b917c1883800000000000000000000000000000000000000000000000000000000000000890000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000904ee8f0b86000000000000000000000000eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee0000000000000000000000000b2c639c533813f4aa9d7837caf62653d097ff850000000000000000000000000000000000000000000000000023375dc156080000000000000000000000000000000000000000000000000000000000000000c400000000000000000000000000000000000000000000000000000000000000a00000000000000000000000000000000000000000000000000000000000000828415565b0000000000000000000000000eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee0000000000000000000000000b2c639c533813f4aa9d7837caf62653d097ff850000000000000000000000000000000000000000000000000023375dc15608000000000000000000000000000000000000000000000000000000000001734d0800000000000000000000000000000000000000000000000000000000000000a000000000000000000000000000000000000000000000000000000000000000040000000000000000000000000000000000000000000000000000000000000080000000000000000000000000000000000000000000000000000000000000012000000000000000000000000000000000000000000000000000000000000004e000000000000000000000000000000000000000000000000000000000000005e0000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000040000000000000000000000000eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee0000000000000000000000000000000000000000000000000023375dc15608000000000000000000000000000000000000000000000000000000000000000011000000000000000000000000000000000000000000000000000000000000004000000000000000000000000000000000000000000000000000000000000003600000000000000000000000000000000000000000000000000000000000000020000000000000000000000000000000000000000000000000000000000000000000000000000000000000000042000000000000000000000000000000000000060000000000000000000000000b2c639c533813f4aa9d7837caf62653d097ff8500000000000000000000000000000000000000000000000000000000000001400000000000000000000000000000000000000000000000000000000000000320000000000000000000000000000000000000000000000000000000000000032000000000000000000000000000000000000000000000000000000000000002e00000000000000000000000000000000000000000000000000023375dc1560800000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000003200000000000000000000000000000000000000000000000000000000000000001000000000000000000000000000000000000000000000000000000000000002000000000000000000000000000000012556e69737761705633000000000000000000000000000000000000000000000000000000000000000023375dc1560800000000000000000000000000000000000000000000000000000000000173dbd3000000000000000000000000000000000000000000000000000000000000008000000000000000000000000000000000000000000000000000000000000000c0000000000000000000000000e592427a0aece92de3edee1f18e0157c0586156400000000000000000000000000000000000000000000000000000000000000600000000000000000000000000000000000000000000000000000000000000001000000000000000000000000000000000000000000000000000000000000002b42000000000000000000000000000000000000060001f40b2c639c533813f4aa9d7837caf62653d097ff85000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000100000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000a000000000000000000000000000000000000000000000000000000000000004000000000000000000000000000000000000000000000000000000000000000a0000000000000000000000000000000000000000000000000000000000000002000000000000000000000000000000000000000000000000000000000000000010000000000000000000000000b2c639c533813f4aa9d7837caf62653d097ff850000000000000000000000000000000000000000000000000000000000008ecb000000000000000000000000ad01c20d5886137e056775af56915de824c8fce5000000000000000000000000000000000000000000000000000000000000000b000000000000000000000000000000000000000000000000000000000000004000000000000000000000000000000000000000000000000000000000000000e00000000000000000000000000000000000000000000000000000000000000020000000000000000000000000000000000000000000000000000000000000004000000000000000000000000000000000000000000000000000000000000000a000000000000000000000000000000000000000000000000000000000000000020000000000000000000000004200000000000000000000000000000000000006000000000000000000000000eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee0000000000000000000000000000000000000000000000000000000000000000869584cd00000000000000000000000010000000000000000000000000000000000000110000000000000000000000000000000000000000974132b87a5cb75e32f034280000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000c00000000000000000000000000b2c639c533813f4aa9d7837caf62653d097ff85000000000000000000000000141d32a89a1e0a5ef360034a2f60a4b917c18838000000000000000000000000000000000000000000000000000000000000000700000000000000000000000000000000000000000000000000000000000000890000000000000000000000000000000000000000000000000000000000030d4000000000000000000000000000000000000000000000000000000000000000c400000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000003f9e43204a24f476db20f2518722627a122d31a1bc7c63fc15412e6a327295a9460b76bea5bb53b1f73fa6a15811055f6bada592d2e9e6c8cf48a855ce6968951c', + gasLimit: 664389, + effectiveGas: 610300, + }, + estimatedProcessingTimeInSeconds: 15, + }, +]; + +export const getMockBridgeQuotesNativeErc20V1 = ( + quoteOverrides?: DeepPartial>, +): QuoteResponseV1[] => { + return mockBridgeQuotesNativeErc20V1.map((quote) => { + const mergedQuote = merge({}, quote, quoteOverrides); + validateQuoteResponseV1(mergedQuote); + return mergedQuote as QuoteResponseV1; + }); +}; + +export const getMockBridgeQuotesNativeErc20V2 = ( + quoteOverrides?: DeepPartial>, +): (QuoteResponse & { namespace: KnownCaipNamespace.Eip155 })[] => { + return getMockBridgeQuotesNativeErc20V1(quoteOverrides).map( + toQuoteResponseV2, + ) as (QuoteResponse & { namespace: KnownCaipNamespace.Eip155 })[]; +}; diff --git a/packages/bridge-controller/tests/mock-quotes-sol-erc20.ts b/packages/bridge-controller/tests/mock-quotes-sol-erc20.ts new file mode 100644 index 00000000000..92e90049ea9 --- /dev/null +++ b/packages/bridge-controller/tests/mock-quotes-sol-erc20.ts @@ -0,0 +1,199 @@ +import { merge } from 'lodash'; + +import { toQuoteResponseV2 } from '../src/coercers/quote-response-v1-to-v2.js'; +import type { DeepPartial } from '../src/types.js'; +import type { QuoteResponseV1 } from '../src/validators/quote-response-v1.js'; +import { validateQuoteResponseV1 } from '../src/validators/quote-response-v1.js'; +import type { QuoteResponse } from '../src/validators/quote-response.js'; +import { ActionTypes } from '../src/validators/step.js'; + +export const mockBridgeQuotesSolErc20V1: QuoteResponseV1[] = [ + { + quote: { + requestId: '5cb5a527-d4e4-4b5e-b753-136afc3986d3', + srcChainId: 1151111081099710, + srcTokenAmount: '1000000000', + srcAsset: { + address: '0x0000000000000000000000000000000000000000', + assetId: + 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/token:11111111111111111111111111111111', + symbol: 'SOL', + decimals: 9, + name: 'SOL', + iconUrl: + 'https://static.cx.metamask.io/api/v2/tokenIcons/assets/solana/5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/token/11111111111111111111111111111111.png', + chainId: 1151111081099710, + }, + destChainId: 10, + destTokenAmount: '143291269234176100000', + minDestTokenAmount: '140000000000000000000', + destAsset: { + address: '0x4200000000000000000000000000000000000042', + assetId: 'eip155:10/erc20:0x4200000000000000000000000000000000000042', + symbol: 'OP', + decimals: 18, + name: 'Optimism', + iconUrl: + 'https://static.cx.metamask.io/api/v2/tokenIcons/assets/eip155/10/erc20/0x4200000000000000000000000000000000000042.png', + chainId: 10, + }, + feeData: { + metabridge: { + amount: '0', + asset: { + address: '0x0000000000000000000000000000000000000000', + assetId: + 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/token:11111111111111111111111111111111', + symbol: 'SOL', + decimals: 9, + name: 'SOL', + iconUrl: + 'https://static.cx.metamask.io/api/v2/tokenIcons/assets/solana/5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/token/11111111111111111111111111111111.png', + chainId: 1151111081099710, + }, + }, + }, + bridgeId: 'lifi', + bridges: ['mayan'], + steps: [ + { + action: ActionTypes.BRIDGE, + srcChainId: 1151111081099710, + destChainId: 10, + srcAsset: { + address: '0x0000000000000000000000000000000000000000', + assetId: + 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/token:11111111111111111111111111111111', + symbol: 'SOL', + decimals: 9, + name: 'SOL', + iconUrl: + 'https://static.cx.metamask.io/api/v2/tokenIcons/assets/solana/5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/token/11111111111111111111111111111111.png', + chainId: 1151111081099710, + }, + destAsset: { + address: '0x4200000000000000000000000000000000000042', + assetId: + 'eip155:10/erc20:0x4200000000000000000000000000000000000042', + symbol: 'OP', + decimals: 18, + name: 'Optimism', + iconUrl: + 'https://static.cx.metamask.io/api/v2/tokenIcons/assets/eip155/10/erc20/0x4200000000000000000000000000000000000042.png', + chainId: 10, + }, + }, + ], + priceData: { + totalFromAmountUsd: '124.9200', + totalToAmountUsd: '123.9469', + priceImpact: '0.007789785462696144', + }, + }, + trade: + '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=', + estimatedProcessingTimeInSeconds: 12, + }, + { + quote: { + requestId: '12c94d29-4b5c-4aee-92de-76eee4172d3d', + srcChainId: 1151111081099710, + srcTokenAmount: '1000000000', + srcAsset: { + address: '0x0000000000000000000000000000000000000000', + assetId: + 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/token:11111111111111111111111111111111', + symbol: 'SOL', + decimals: 9, + name: 'SOL', + iconUrl: + 'https://static.cx.metamask.io/api/v2/tokenIcons/assets/solana/5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/token/11111111111111111111111111111111.png', + chainId: 1151111081099710, + }, + destChainId: 10, + destTokenAmount: '141450025181571360000', + minDestTokenAmount: '138300000000000000000', + destAsset: { + address: '0x4200000000000000000000000000000000000042', + assetId: 'eip155:10/erc20:0x4200000000000000000000000000000000000042', + symbol: 'OP', + decimals: 18, + name: 'Optimism', + iconUrl: + 'https://static.cx.metamask.io/api/v2/tokenIcons/assets/eip155/10/erc20/0x4200000000000000000000000000000000000042.png', + chainId: 10, + }, + feeData: { + metabridge: { + amount: '0', + asset: { + address: '0x0000000000000000000000000000000000000000', + assetId: + 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/token:11111111111111111111111111111111', + symbol: 'SOL', + decimals: 9, + name: 'SOL', + iconUrl: + 'https://static.cx.metamask.io/api/v2/tokenIcons/assets/solana/5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/token/11111111111111111111111111111111.png', + chainId: 1151111081099710, + }, + }, + }, + bridgeId: 'lifi', + bridges: ['mayanMCTP'], + steps: [ + { + action: ActionTypes.BRIDGE, + srcChainId: 1151111081099710, + destChainId: 10, + srcAsset: { + address: '0x0000000000000000000000000000000000000000', + assetId: + 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/token:11111111111111111111111111111111', + symbol: 'SOL', + decimals: 9, + name: 'SOL', + iconUrl: + 'https://static.cx.metamask.io/api/v2/tokenIcons/assets/solana/5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/token/11111111111111111111111111111111.png', + chainId: 1151111081099710, + }, + destAsset: { + address: '0x4200000000000000000000000000000000000042', + assetId: + 'eip155:10/erc20:0x4200000000000000000000000000000000000042', + symbol: 'OP', + decimals: 18, + name: 'Optimism', + iconUrl: + 'https://static.cx.metamask.io/api/v2/tokenIcons/assets/eip155/10/erc20/0x4200000000000000000000000000000000000042.png', + chainId: 10, + }, + }, + ], + priceData: { + totalFromAmountUsd: '124.9200', + totalToAmountUsd: '122.3543', + priceImpact: '0.020538744796669922', + }, + }, + trade: + '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', + estimatedProcessingTimeInSeconds: 120, + }, +]; + +export const getMockBridgeQuotesSolErc20V1 = ( + quoteOverrides?: DeepPartial, +): QuoteResponseV1[] => { + return mockBridgeQuotesSolErc20V1.map((quote) => { + const mergedQuote = merge({}, quote, quoteOverrides); + validateQuoteResponseV1(mergedQuote); + return mergedQuote; + }); +}; + +export const getMockBridgeQuotesSolErc20V2 = ( + quoteOverrides?: DeepPartial, +): QuoteResponse[] => { + return getMockBridgeQuotesSolErc20V1(quoteOverrides).map(toQuoteResponseV2); +}; diff --git a/packages/bridge-controller/tests/mock-sse.ts b/packages/bridge-controller/tests/mock-sse.ts new file mode 100644 index 00000000000..eaf485e5d44 --- /dev/null +++ b/packages/bridge-controller/tests/mock-sse.ts @@ -0,0 +1,242 @@ +// eslint-disable-next-line @typescript-eslint/no-shadow +import { ReadableStream } from 'node:stream/web'; + +import { flushPromises } from '../../../tests/helpers.js'; +import type { QuoteStreamCompleteData, TokenFeature } from '../src/index.js'; +import { QuoteResponseV1 } from '../src/validators/quote-response-v1.js'; + +type MockSseResponse = { status: number; ok: boolean; body: ReadableStream }; + +export const advanceToNthTimer = (nth = 1): void => { + for (let i = 0; i < nth; i++) { + jest.advanceTimersToNextTimer(); + } +}; + +export const advanceToNthTimerThenFlush = async (nth = 1): Promise => { + advanceToNthTimer(nth); + await flushPromises(); +}; + +/** + * Generates a unique event id for the server event. This matches the id + * used by the bridge-api + * + * @param index - the index of the event + * @returns a unique event id + */ +const getEventId = (index: number): string => { + return `${Date.now().toString()}-${index}`; +}; + +const emitLine = ( + // eslint-disable-next-line n/no-unsupported-features/node-builtins + controller: ReadableStreamDefaultController, + line: string, +): void => { + controller.enqueue(Buffer.from(line)); +}; + +/** + * This simulates responses from the fetch function for unit tests + * + * @param mockQuotes - a list of quotes to stream + * @param delay - the delay in milliseconds + * @returns a delayed stream of quotes + */ +export const mockSseEventSource = ( + mockQuotes: QuoteResponseV1[], + delay: number = 3000, +): MockSseResponse => { + return { + status: 200, + ok: true, + body: new ReadableStream({ + start(controller): void { + setTimeout(() => { + mockQuotes.forEach((quote, id) => { + emitLine(controller, `event: quote\n`); + emitLine(controller, `id: ${getEventId(id + 1)}\n`); + emitLine(controller, `data: ${JSON.stringify(quote)}\n\n`); + }); + controller.close(); + }, delay); + }, + }), + }; +}; + +/** + * This simulates responses from the fetch function for unit tests + * + * @param mockQuotes - a list of quotes to stream + * @param delay - the delay in milliseconds + * @returns a delayed stream of quotes + */ +export const mockSseBatchSellEventSource = ( + mockQuotes: QuoteResponseV1[][], + delay: number = 3000, +): MockSseResponse => { + return { + status: 200, + ok: true, + body: new ReadableStream({ + start(controller): void { + setTimeout(() => { + mockQuotes.forEach((quotes) => { + quotes.forEach((quote, quoteIndex) => { + emitLine(controller, `event: quote\n`); + emitLine(controller, `id: ${getEventId(quoteIndex + 1)}\n`); + emitLine(controller, `data: ${JSON.stringify(quote)}\n\n`); + }); + }); + controller.close(); + }, delay); + }, + }), + }; +}; +/** + * This simulates responses from the fetch function for unit tests + * + * @param mockQuotes - a list of quotes to stream + * @param delay - the delay in milliseconds + * @returns a stream of quotes with multiple delays in between each quote + */ +export const mockSseEventSourceWithMultipleDelays = async ( + mockQuotes: QuoteResponseV1[], + delay: number = 4000, +): Promise => { + return { + status: 200, + ok: true, + body: new ReadableStream({ + async start(controller): Promise { + mockQuotes.forEach((quote, id) => { + setTimeout( + () => { + emitLine(controller, `event: quote\n`); + emitLine(controller, `id: ${getEventId(id + 1)}\n`); + emitLine(controller, `data: ${JSON.stringify(quote)}\n\n`); + if (id === mockQuotes.length - 1) { + controller.close(); + } + }, + delay * (id + 1), + ); + }); + }, + }), + }; +}; + +/** + * Simulates an SSE stream that emits both quote and token_warning events + * + * @param mockQuotes - a list of quotes to stream + * @param mockWarnings - a list of token warnings to stream + * @param delay - the delay in milliseconds + * @returns a delayed stream of quotes and token warnings + */ +export const mockSseEventSourceWithWarnings = ( + mockQuotes: QuoteResponseV1[], + mockWarnings: TokenFeature[], + delay: number = 3000, +): MockSseResponse => { + return { + status: 200, + ok: true, + body: new ReadableStream({ + start(controller): void { + setTimeout(() => { + let eventIndex = 0; + mockWarnings.forEach((warning) => { + emitLine(controller, `event: token_warning\n`); + // eslint-disable-next-line no-plusplus + emitLine(controller, `id: ${getEventId(eventIndex++)}\n`); + emitLine(controller, `data: ${JSON.stringify(warning)}\n\n`); + }); + mockQuotes.forEach((quote) => { + emitLine(controller, `event: quote\n`); + // eslint-disable-next-line no-plusplus + emitLine(controller, `id: ${getEventId(eventIndex++)}\n`); + emitLine(controller, `data: ${JSON.stringify(quote)}\n\n`); + }); + controller.close(); + }, delay); + }, + }), + }; +}; + +/** + * Simulates an SSE stream that emits quote, token_warning, and complete events + * + * @param mockQuotes - a list of quotes to stream + * @param mockWarnings - a list of token warnings to stream + * @param mockComplete - the complete event data to emit + * @param delay - the delay in milliseconds + * @returns a delayed stream of quotes, token warnings, and a complete event + */ +export const mockSseEventSourceWithComplete = ( + mockQuotes: QuoteResponseV1[], + mockWarnings: TokenFeature[], + mockComplete: QuoteStreamCompleteData, + delay: number = 3000, +): MockSseResponse => { + return { + status: 200, + ok: true, + body: new ReadableStream({ + start(controller): void { + setTimeout(() => { + let eventIndex = 0; + mockWarnings.forEach((warning) => { + emitLine(controller, `event: token_warning\n`); + // eslint-disable-next-line no-plusplus + emitLine(controller, `id: ${getEventId(eventIndex++)}\n`); + emitLine(controller, `data: ${JSON.stringify(warning)}\n\n`); + }); + mockQuotes.forEach((quote) => { + emitLine(controller, `event: quote\n`); + // eslint-disable-next-line no-plusplus + emitLine(controller, `id: ${getEventId(eventIndex++)}\n`); + emitLine(controller, `data: ${JSON.stringify(quote)}\n\n`); + }); + emitLine(controller, `event: complete\n`); + // eslint-disable-next-line no-plusplus + emitLine(controller, `id: ${getEventId(eventIndex++)}\n`); + emitLine(controller, `data: ${JSON.stringify(mockComplete)}\n\n`); + controller.close(); + }, delay); + }, + }), + }; +}; + +/** + * This simulates responses from the fetch function for unit tests + * + * @param errorMessage - the error message to rethrow + * @param delay - the delay in milliseconds + * @returns a delayed stream of quotes + */ +export const mockSseServerError = ( + errorMessage: string, + delay: number = 3000, +): MockSseResponse => { + return { + status: 200, + ok: true, + body: new ReadableStream({ + start(controller): void { + setTimeout(() => { + emitLine(controller, `event: error\n`); + emitLine(controller, `id: ${getEventId(1)}\n`); + emitLine(controller, `data: ${errorMessage}\n\n`); + controller.close(); + }, delay); + }, + }), + }; +}; diff --git a/packages/bridge-controller/tsconfig.build.json b/packages/bridge-controller/tsconfig.build.json new file mode 100644 index 00000000000..b235a6002c1 --- /dev/null +++ b/packages/bridge-controller/tsconfig.build.json @@ -0,0 +1,53 @@ +{ + "extends": "../../tsconfig.packages.build.json", + "compilerOptions": { + "baseUrl": "./", + "outDir": "./dist", + "rootDir": "./src" + }, + "references": [ + { + "path": "../accounts-controller/tsconfig.build.json" + }, + { + "path": "../base-controller/tsconfig.build.json" + }, + { + "path": "../controller-utils/tsconfig.build.json" + }, + { + "path": "../network-controller/tsconfig.build.json" + }, + { + "path": "../polling-controller/tsconfig.build.json" + }, + { + "path": "../gas-fee-controller/tsconfig.build.json" + }, + { + "path": "../assets-controllers/tsconfig.build.json" + }, + { + "path": "../transaction-controller/tsconfig.build.json" + }, + { + "path": "../multichain-network-controller/tsconfig.build.json" + }, + { + "path": "../remote-feature-flag-controller/tsconfig.build.json" + }, + { + "path": "../assets-controller/tsconfig.build.json" + }, + { + "path": "../eth-json-rpc-provider/tsconfig.build.json" + }, + { + "path": "../messenger/tsconfig.build.json" + }, + { + "path": "../profile-sync-controller/tsconfig.build.json" + } + ], + "include": ["../../types", "./src"] +} diff --git a/packages/bridge-controller/tsconfig.json b/packages/bridge-controller/tsconfig.json new file mode 100644 index 00000000000..906b224a0d0 --- /dev/null +++ b/packages/bridge-controller/tsconfig.json @@ -0,0 +1,52 @@ +{ + "extends": "../../tsconfig.packages.json", + "compilerOptions": { + "baseUrl": "./", + "resolveJsonModule": true + }, + "references": [ + { + "path": "../accounts-controller" + }, + { + "path": "../base-controller" + }, + { + "path": "../controller-utils" + }, + { + "path": "../network-controller" + }, + { + "path": "../polling-controller" + }, + { + "path": "../transaction-controller" + }, + { + "path": "../gas-fee-controller" + }, + { + "path": "../assets-controllers" + }, + { + "path": "../multichain-network-controller" + }, + { + "path": "../profile-sync-controller" + }, + { + "path": "../remote-feature-flag-controller" + }, + { + "path": "../assets-controller" + }, + { + "path": "../eth-json-rpc-provider" + }, + { + "path": "../messenger" + } + ], + "include": ["../../types", "./src", "./tests"] +} diff --git a/packages/bridge-controller/typedoc.json b/packages/bridge-controller/typedoc.json new file mode 100644 index 00000000000..c9da015dbf8 --- /dev/null +++ b/packages/bridge-controller/typedoc.json @@ -0,0 +1,7 @@ +{ + "entryPoints": ["./src/index.ts"], + "excludePrivate": true, + "hideGenerator": true, + "out": "docs", + "tsconfig": "./tsconfig.build.json" +} diff --git a/packages/bridge-status-controller/CHANGELOG.md b/packages/bridge-status-controller/CHANGELOG.md new file mode 100644 index 00000000000..a10ba1f3155 --- /dev/null +++ b/packages/bridge-status-controller/CHANGELOG.md @@ -0,0 +1,1687 @@ +# 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] + +## [75.4.0] + +### Added + +- Preserve explicit slippage intent and normalized slippage limits in post-submission Unified SwapBridge metrics ([#9986](https://github.com/MetaMask/core/pull/9986)) + +### Changed + +- Bump `@metamask/bridge-controller` from `^80.0.0` to `^80.1.0` ([#10002](https://github.com/MetaMask/core/pull/10002)) + +## [75.3.0] + +### Changed + +- Add optional `migrationPhase` to `submitTx`, `submitIntent`, and `submitBatchSell`, used by `toQuoteMetadataV1` to convert V2 quotes. Defaults to `V1Data` (`'1'`) if omitted ([#9744](https://github.com/MetaMask/core/pull/9744)) +- Bump `@metamask/bridge-controller` from `^79.3.1` to `^80.0.0` ([#9978](https://github.com/MetaMask/core/pull/9978)) + +## [75.2.1] + +### Changed + +- Bump `@metamask/transaction-controller` from `^69.5.2` to `^69.6.1` ([#9960](https://github.com/MetaMask/core/pull/9960), [#9969](https://github.com/MetaMask/core/pull/9969)) +- Bump `@metamask/accounts-controller` from `^39.1.0` to `^39.1.1` ([#9969](https://github.com/MetaMask/core/pull/9969)) +- Bump `@metamask/bridge-controller` from `^79.3.0` to `^79.3.1` ([#9969](https://github.com/MetaMask/core/pull/9969)) +- Bump `@metamask/gas-fee-controller` from `^26.3.1` to `^26.3.2` ([#9969](https://github.com/MetaMask/core/pull/9969)) +- Bump `@metamask/network-controller` from `^35.0.1` to `^36.0.0` ([#9969](https://github.com/MetaMask/core/pull/9969)) + +## [75.2.0] + +### Added + +- Add backdated Sentry operation completion traces for single-chain and cross-chain swaps ([#9899](https://github.com/MetaMask/core/pull/9899)) + +### Changed + +- Bump `@metamask/bridge-controller` from `^79.2.0` to `^79.3.0` ([#9923](https://github.com/MetaMask/core/pull/9923)) + +## [75.1.0] + +### Changed + +- Bump `@metamask/transaction-controller` from `^69.5.0` to `^69.5.2` ([#9798](https://github.com/MetaMask/core/pull/9798), [#9823](https://github.com/MetaMask/core/pull/9823)) +- Bump `@metamask/bridge-controller` from `^79.0.0` to `^79.2.0` ([#9788](https://github.com/MetaMask/core/pull/9788), [#9827](https://github.com/MetaMask/core/pull/9827), [#9845](https://github.com/MetaMask/core/pull/9845)) +- Bump `@metamask/accounts-controller` from `^39.0.6` to `^39.1.0` ([#9791](https://github.com/MetaMask/core/pull/9791), [#9807](https://github.com/MetaMask/core/pull/9807)) +- Bump `@metamask/keyring-controller` from `^27.1.0` to `^27.1.1` ([#9791](https://github.com/MetaMask/core/pull/9791)) + +### Fixed + +- Include `usd_amount_source` value in QuotesReceived MixPanel event properties ([#9828](https://github.com/MetaMask/core/pull/9828)) +- Include quote's `slippage` value in post-submission MixPanel event properties ([#9786](https://github.com/MetaMask/core/pull/9786)) + +## [75.0.0] + +### Changed + +- **BREAKING**: Change `submitBatchSell` and `submitIntent` quoteResponse parameter from `QuoteResponseV1` to `QuoteResponse` V2 ([#9726](https://github.com/MetaMask/core/pull/9726)) + - Support both `QuoteResponseV1` and `QuoteResponse` quoteResponses in `submitTx` + - Controller logic and utils still require V1, but clients can submit quotes in both formats +- Bump `@metamask/bridge-controller` from `^78.1.0` to `^79.0.0` ([#9785](https://github.com/MetaMask/core/pull/9785)) +- Bump `@metamask/transaction-controller` from `^69.4.0` to `^69.5.0` ([#9780](https://github.com/MetaMask/core/pull/9780)) +- Bump `@metamask/superstruct` from `^3.1.0` to `^3.4.1` ([#9754](https://github.com/MetaMask/core/pull/9754)) + +## [74.6.2] + +### Changed + +- Bump `@metamask/network-controller` from `^35.0.0` to `^35.0.1` ([#9758](https://github.com/MetaMask/core/pull/9758)) +- Bump `@metamask/bridge-controller` from `^78.0.2` to `^78.1.0` ([#9740](https://github.com/MetaMask/core/pull/9740), [#9779](https://github.com/MetaMask/core/pull/9779)) +- Bump `@metamask/profile-sync-controller` from `^28.3.0` to `^29.0.0` ([#9779](https://github.com/MetaMask/core/pull/9779)) + +## [74.6.1] + +### Changed + +- Bump `@metamask/transaction-controller` from `^69.2.1` to `^69.4.0` ([#9693](https://github.com/MetaMask/core/pull/9693), [#9735](https://github.com/MetaMask/core/pull/9735)) +- Bump `@metamask/accounts-controller` from `^39.0.5` to `^39.0.6` ([#9735](https://github.com/MetaMask/core/pull/9735)) +- Bump `@metamask/bridge-controller` from `^78.0.1` to `^78.0.2` ([#9735](https://github.com/MetaMask/core/pull/9735)) +- Bump `@metamask/gas-fee-controller` from `^26.3.0` to `^26.3.1` ([#9735](https://github.com/MetaMask/core/pull/9735)) +- Bump `@metamask/network-controller` from `^34.0.0` to `^35.0.0` ([#9735](https://github.com/MetaMask/core/pull/9735)) +- Bump `@metamask/polling-controller` from `^16.0.8` to `^16.0.9` ([#9735](https://github.com/MetaMask/core/pull/9735)) + +## [74.6.0] + +### Added + +- Populate `srcTxHash` in the `QuoteStatusUpdateError` when a finalization is reported for a transaction that has no tracked quote-status entry, so the error details identify the source transaction alongside `txMetaId` and `srcChainId` ([#9673](https://github.com/MetaMask/core/pull/9673)) + +### Changed + +- Bump `@metamask/bridge-controller` from `^78.0.0` to `^78.0.1` ([#9688](https://github.com/MetaMask/core/pull/9688)) + +## [74.5.0] + +### Changed + +- Use `gasFee.total` instead of `gasFee.effective` to calculate gas metrics properties ([#9507](https://github.com/MetaMask/core/pull/9507)) +- Update utils to use `mergeQuoteMetadata` and handle optional QuoteMetadata values. ([#9507](https://github.com/MetaMask/core/pull/9507)) +- Bump `@metamask/bridge-controller` from `^77.7.0` to `^78.0.0` ([#9614](https://github.com/MetaMask/core/pull/9614), [#9637](https://github.com/MetaMask/core/pull/9637)) +- Bump `@metamask/gas-fee-controller` from `^26.2.4` to `^26.3.0` ([#9629](https://github.com/MetaMask/core/pull/9629)) + +## [74.4.0] + +### Changed + +- chore: migrate Jest from v29 to v30 ([#7905](https://github.com/MetaMask/core/pull/7905)) +- Add optional `txMetaId`, `srcTxHash`, and `srcChainId` fields to `QuoteStatusUpdateErrorDetails` to provide more context in quote-status update error reports ([#9596](https://github.com/MetaMask/core/pull/9596)) +- Bump `@metamask/bridge-controller` from `^77.5.0` to `^77.7.0` ([#9558](https://github.com/MetaMask/core/pull/9558), [#9593](https://github.com/MetaMask/core/pull/9593)) +- Bump `@metamask/transaction-controller` from `^69.0.0` to `^69.2.1` ([#9568](https://github.com/MetaMask/core/pull/9568), [#9589](https://github.com/MetaMask/core/pull/9589), [#9593](https://github.com/MetaMask/core/pull/9593)) + +## [74.3.0] + +### Added + +- Support batch sell (EIP-7702/nested batch) transactions in the quote-status flow, so every quote submitted under a single batch transaction is reported to the backend. All quotes sharing one batch source transaction are reported as `SUBMITTED` under the shared source transaction hash and `txMetaId`, and are finalized together when that transaction confirms or fails. ([#9514](https://github.com/MetaMask/core/pull/9514)) + +### Changed + +- Ensure refs in tsconfig files are synced with internal deps ([#8384](https://github.com/MetaMask/core/pull/8384)) + +## [74.2.0] + +### Added + +- Include `transaction_internal_id` in `Unified SwapBridge Completed` events for EVM source transactions. ([#9494](https://github.com/MetaMask/core/pull/9494)) + +### Changed + +- Bump `@metamask/bridge-controller` from `^77.4.1` to `^77.5.0` ([#9508](https://github.com/MetaMask/core/pull/9508)) + +### Fixed + +- chore: MIT license text update ([#9472](https://github.com/MetaMask/core/pull/9472)) + +## [74.1.2] + +### Changed + +- Bump `@metamask/transaction-controller` from `^68.3.0` to `^69.0.0` ([#9456](https://github.com/MetaMask/core/pull/9456), [#9470](https://github.com/MetaMask/core/pull/9470)) +- Bump `@metamask/bridge-controller` from `^77.3.2` to `^77.4.1` ([#9462](https://github.com/MetaMask/core/pull/9462), [#9470](https://github.com/MetaMask/core/pull/9470)) +- Bump `@metamask/profile-sync-controller` from `^28.2.0` to `^28.3.0` ([#9463](https://github.com/MetaMask/core/pull/9463)) +- Bump `@metamask/accounts-controller` from `^39.0.4` to `^39.0.5` ([#9470](https://github.com/MetaMask/core/pull/9470)) + +## [74.1.1] + +### Changed + +- Bump `@metamask/transaction-controller` from `^68.2.2` to `^68.3.0` ([#9421](https://github.com/MetaMask/core/pull/9421)) + +### Fixed + +- Remove `gasFeeToken`, `skipInitialGasEstimate`, and `excludeNativeTokenForFee` from batch-strategy `addTransactionBatch` params ([#9431](https://github.com/MetaMask/core/pull/9431)) + +## [74.1.0] + +### Added + +- Source transaction status from the `/getQuoteStatus` backend during polling for history items with an associated `quoteId`, falling back to the `/getTxStatus` bridge-api endpoint when no quote-status backend status is available yet or the QuoteStatusManager is disabled. ([#9389](https://github.com/MetaMask/core/pull/9389)) +- Seed missing quote-status entries from persisted transaction history on startup, so quotes submitted before the client closed still get their status reported to the backend. Entries are rebuilt from the history item's `quoteId`, source transaction hash (falling back to the transaction's hash), and `txMetaId`; history items older than the quote-status entry TTL are skipped. ([#9405](https://github.com/MetaMask/core/pull/9405)) + +### Changed + +- Bump `@metamask/bridge-controller` from `^77.3.1` to `^77.3.2` ([#9372](https://github.com/MetaMask/core/pull/9372)) +- Bump `@metamask/messenger` from `^1.2.0` to `^2.0.0` ([#9392](https://github.com/MetaMask/core/pull/9392)) + +### Fixed + +- Remove the redundant quote-status fetch previously issued after a `FINALIZED_SUCCESS`/`FINALIZED_FAILED` quote-status update was accepted. ([#9389](https://github.com/MetaMask/core/pull/9389)) + +## [74.0.2] + +### Fixed + +- Only include `sourceAssetId` and `destAssetId` in the snap request options for Stellar trades ([#9366](https://github.com/MetaMask/core/pull/9366)) + - Previously these options were passed for all non-EVM trades, which broke Bitcoin bridging/swapping because the Bitcoin snap strictly validates the request and rejects unexpected fields. + +## [74.0.1] + +### Changed + +- Bump `@metamask/bridge-controller` from `^77.1.0` to `^77.3.1` ([#9318](https://github.com/MetaMask/core/pull/9318), [#9326](https://github.com/MetaMask/core/pull/9326), [#9349](https://github.com/MetaMask/core/pull/9349)) +- Bump `@metamask/transaction-controller` from `^68.2.0` to `^68.2.2` ([#9337](https://github.com/MetaMask/core/pull/9337), [#9349](https://github.com/MetaMask/core/pull/9349)) +- Bump `@metamask/accounts-controller` from `^39.0.3` to `^39.0.4` ([#9349](https://github.com/MetaMask/core/pull/9349)) +- Bump `@metamask/gas-fee-controller` from `^26.2.3` to `^26.2.4` ([#9349](https://github.com/MetaMask/core/pull/9349)) +- Bump `@metamask/network-controller` from `^33.0.0` to `^34.0.0` ([#9349](https://github.com/MetaMask/core/pull/9349)) +- Bump `@metamask/polling-controller` from `^16.0.7` to `^16.0.8` ([#9349](https://github.com/MetaMask/core/pull/9349)) + +## [74.0.0] + +### Added + +- **BREAKING:** Add required `quoteId: string` and `reportedSubmittedTxHash: string` to `BridgeHistoryItem` ([#8462](https://github.com/MetaMask/core/pull/8462)) +- **BREAKING:** Add `QuoteStatusUpdateManager` for resilient quote-status reporting to the Bridge API; reports `SUBMITTED`/`FINALIZED_SUCCESS`/`FINALIZED_FAILURE`, retries immediately on retryable errors, defers on network failures, and persists the queue to `quoteUpdateStatusStore` state across service-worker restarts ([#8462](https://github.com/MetaMask/core/pull/8462)) + +### Changed + +- Bump `@metamask/bridge-controller` from `^77.0.0` to `^77.1.0` ([#9301](https://github.com/MetaMask/core/pull/9301)) + +## [73.1.0] + +### Added + +- Add Stellar support to bridge transaction submission and status tracking: thread `StellarTradeData` through the non-EVM submit strategies and include source and destination asset IDs in the snap client transaction request ([#9170](https://github.com/MetaMask/core/pull/9170)) + +## [73.0.0] + +### Changed + +- **BREAKING:** Default `location` fallback for post-submit Unified SwapBridge events now uses `Unknown` instead of `Main View` ([#9243](https://github.com/MetaMask/core/pull/9243)) +- Bump `@metamask/bridge-controller` from `^76.1.0` to `^77.0.0` ([#9256](https://github.com/MetaMask/core/pull/9256)) +- Bump `@metamask/transaction-controller` from `^68.1.1` to `^68.2.0` ([#9253](https://github.com/MetaMask/core/pull/9253)) + +### Fixed + +- Fixed `Submitted` Unified SwapBridge events reporting the wrong `location` metric: when no tx history item exists yet, `#trackUnifiedSwapBridgeEvent` now uses the `location` passed to `submitTx()` (via pre-confirmation event properties) instead of always defaulting to `Main View` ([#9243](https://github.com/MetaMask/core/pull/9243)) + +## [72.3.0] + +### Added + +- Allow `quick_buy_explore` feature id to emit `Submitted`, `Completed`, and `Failed` Unified SwapBridge status events ([#9222](https://github.com/MetaMask/core/pull/9222)) + +### Changed + +- Bump `@metamask/accounts-controller` from `^39.0.2` to `^39.0.3` ([#9231](https://github.com/MetaMask/core/pull/9231)) +- Bump `@metamask/bridge-controller` from `^76.0.0` to `^76.1.0` ([#9242](https://github.com/MetaMask/core/pull/9242)) + +## [72.2.0] + +### Added + +- Add input primary denomination to submitted bridge history and post-submit analytics ([#9147](https://github.com/MetaMask/core/pull/9147)) + +### Changed + +- Bump `@metamask/bridge-controller` from `^75.2.1` to `^76.0.0` ([#9225](https://github.com/MetaMask/core/pull/9225)) + +## [72.1.1] + +### Changed + +- Bump `@metamask/bridge-controller` from `^75.1.1` to `^75.2.1` ([#9214](https://github.com/MetaMask/core/pull/9214), [#9218](https://github.com/MetaMask/core/pull/9218)) +- Rename `solanaFeesInLamports` to `nonEvmFeesInNative` in unit test mocks ([#9098](https://github.com/MetaMask/core/pull/9098)) +- Bump `@metamask/utils` from `^11.9.0` to `^11.11.0` ([#9074](https://github.com/MetaMask/core/pull/9074)) +- Bump `@metamask/controller-utils` from `^12.1.1` to `^12.3.0` ([#9083](https://github.com/MetaMask/core/pull/9083), [#9218](https://github.com/MetaMask/core/pull/9218)) +- Bump `@metamask/bridge-controller` from `^75.0.0` to `^75.1.1` ([#9078](https://github.com/MetaMask/core/pull/9078)) +- Bump `@metamask/transaction-controller` from `^67.1.0` to `^68.1.1` ([#9089](https://github.com/MetaMask/core/pull/9089), [#9177](https://github.com/MetaMask/core/pull/9177), [#9203](https://github.com/MetaMask/core/pull/9203), [#9218](https://github.com/MetaMask/core/pull/9218)) +- Bump `@metamask/profile-sync-controller` from `^28.1.1` to `^28.2.0` ([#9119](https://github.com/MetaMask/core/pull/9119)) +- Bump `@metamask/keyring-controller` from `^27.0.0` to `^27.1.0` ([#9129](https://github.com/MetaMask/core/pull/9129)) +- Bump `@metamask/accounts-controller` from `^39.0.1` to `^39.0.2` ([#9218](https://github.com/MetaMask/core/pull/9218)) +- Bump `@metamask/gas-fee-controller` from `^26.2.2` to `^26.2.3` ([#9218](https://github.com/MetaMask/core/pull/9218)) +- Bump `@metamask/network-controller` from `^32.0.0` to `^33.0.0` ([#9218](https://github.com/MetaMask/core/pull/9218)) +- Bump `@metamask/polling-controller` from `^16.0.6` to `^16.0.7` ([#9218](https://github.com/MetaMask/core/pull/9218)) + +## [72.1.0] + +### Added + +- Add `batch_id` property to BatchSell events ([#8964](https://github.com/MetaMask/core/pull/8964)) + - pre-generate the batchId using transaction controller's `generateBatchId` util + - attach batchId to the `Submitted`, `Completed` and `Failed` events + - provide batchId to the `TransactionController:addTransactionBatch` to propagate it the TransactionMeta +- Publish tx submission metrics for `BatchSell`, `QuickBuy` and `UnifiedSwapBridge` actions ([#8964](https://github.com/MetaMask/core/pull/8964)) + +### Changed + +- Bump `@metamask/bridge-controller` from `^74.0.0` to `^75.0.0` ([#9066](https://github.com/MetaMask/core/pull/9066)) +- Bump `@metamask/transaction-controller` from `^67.0.0` to `^67.1.0` ([#9066](https://github.com/MetaMask/core/pull/9066)) + +## [72.0.3] + +### Changed + +- Bump `@metamask/transaction-controller` from `^66.0.1` to `^67.0.0` ([#9021](https://github.com/MetaMask/core/pull/9021)) +- Bump `@metamask/bridge-controller` from `^73.2.1` to `^74.0.0` ([#9045](https://github.com/MetaMask/core/pull/9045)) +- Bump `@metamask/accounts-controller` from `^39.0.0` to `^39.0.1` ([#9058](https://github.com/MetaMask/core/pull/9058)) +- Bump `@metamask/controller-utils` from `^12.1.0` to `^12.1.1` ([#9058](https://github.com/MetaMask/core/pull/9058)) +- Bump `@metamask/keyring-controller` from `^26.0.0` to `^27.0.0` ([#9058](https://github.com/MetaMask/core/pull/9058)) + +## [72.0.2] + +### Changed + +- Bump `@metamask/accounts-controller` from `^38.1.2` to `^39.0.0` ([#8999](https://github.com/MetaMask/core/pull/8999)) +- Bump `@metamask/bridge-controller` from `^73.2.0` to `^73.2.1` ([#8999](https://github.com/MetaMask/core/pull/8999)) +- Bump `@metamask/transaction-controller` from `^66.0.0` to `^66.0.1` ([#8999](https://github.com/MetaMask/core/pull/8999)) + +## [72.0.1] + +### Fixed + +- Fix gas-related transaction options for batch and batch-sell swaps ([#8979](https://github.com/MetaMask/core/pull/8979)) + - When the fee is paid in an ERC20 token, set it as the `gasFeeToken` + - Set `excludeNativeTokenForFee=true` when `gasFeeToken` is set, which tells the TransactionController to use the gasFeeToken for payment regardless of whether the user has enough native assets + - Set `skipInitialGasEstimate=false` if submitting gasIncluded7702 quotes and account has not been upgraded to a smart account + +## [72.0.0] + +### Added + +- **BREAKING:** Implement `submitBatchSell` method to submit BatchSell transactions to the TransactionController via STX or 7702. This requires clients to add `BridgeControllerGetStateAction` as an allowed action ([#8775](https://github.com/MetaMask/core/pull/8775)) +- Wire up post-submission BatchSell history ([#8775](https://github.com/MetaMask/core/pull/8775)) + - Create a history item for each STX trade in a batch, with the same batchId (key by `txMeta.id`) + - Create a history item for each trade submitted through a 7702 batch (key by `quoteId`). These won't have a reference to the batchId, and will only include quote and fee data + - Create a history item for the 7702 batch's delegation tx (key by `txMeta.id`). BatchSell delegation transactions include a list of `quoteIds` to associate the corresponding BatchSell trades with the delegation tx + - Expose `getBatchSellHistoryItemsForTxHash` util that returns history items matching either a delegation tx hash or an STX hash + - Expose `isBatchSellHistoryItem` util that returns whether a history item is a BatchSell operation + +### Changed + +- Update controller and submit strategies to support an array of quotes instead of a single one ([#8775](https://github.com/MetaMask/core/pull/8775)) +- Refactor tx submission into strategies to reduce quote-specific branching in the controller, and to de-duplicate shared logic between `submitTx` and `submitIntent`. Each strategy yields payloads that the controller uses to update history, poll, and publish metrics ([#8257](https://github.com/MetaMask/core/pull/8257)) +- Bump `@metamask/bridge-controller` from `^73.0.1` to `^73.2.0` ([#8915](https://github.com/MetaMask/core/pull/8915), [#8935](https://github.com/MetaMask/core/pull/8935)) +- Refactor batch transaction utils to handle multiple quote requests within a batch (for BatchSell integration) ([#8886](https://github.com/MetaMask/core/pull/8886)) + +### Fixed + +- Use txFee from the bridge-api whenever it's provided ([#8805](https://github.com/MetaMask/core/pull/8805)) +- Save swap failure/completion time to txHistory to populate `actual_time_minutes` event property ([#8805](https://github.com/MetaMask/core/pull/8805)) + +## [71.2.1] + +### Changed + +- Bump `@metamask/bridge-controller` from `^72.0.4` to `^73.0.1` ([#8850](https://github.com/MetaMask/core/pull/8850), [#8866](https://github.com/MetaMask/core/pull/8866)) + +### Removed + +- **BREAKING**: Remove unused `GasFeeController:getState` call ([#8886](https://github.com/MetaMask/core/pull/8886)) +- Remove unnecessary type assertions for bridge quotes ([#8805](https://github.com/MetaMask/core/pull/8805)) +- Bump `@metamask/keyring-controller` from `^25.5.0` to `^26.0.0` ([#8912](https://github.com/MetaMask/core/pull/8912)) +- Bump `@metamask/accounts-controller` from `^38.1.1` to `^38.1.2` ([#8912](https://github.com/MetaMask/core/pull/8912)) +- Bump `@metamask/profile-sync-controller` from `^28.1.0` to `^28.1.1` ([#8912](https://github.com/MetaMask/core/pull/8912)) + +## [71.2.0] + +### Changed + +- Pass `isInternal: true` to all internal `addTransaction` / `addTransactionBatch` calls to adopt the explicit `isInternal` flag introduced in `@metamask/transaction-controller` ([#8633](https://github.com/MetaMask/core/pull/8633)) +- Bump `@metamask/profile-sync-controller` from `^28.0.2` to `^28.1.0` ([#8783](https://github.com/MetaMask/core/pull/8783)) +- Bump `@metamask/transaction-controller` from `^65.3.0` to `^66.0.0` ([#8796](https://github.com/MetaMask/core/pull/8796), [#8848](https://github.com/MetaMask/core/pull/8848)) +- Bump `@metamask/gas-fee-controller` from `^26.2.1` to `^26.2.2` ([#8834](https://github.com/MetaMask/core/pull/8834)) +- Bump `@metamask/polling-controller` from `^16.0.5` to `^16.0.6` ([#8834](https://github.com/MetaMask/core/pull/8834)) + +## [71.1.4] + +### Changed + +- Bump `@metamask/accounts-controller` from `^38.1.0` to `^38.1.1` ([#8774](https://github.com/MetaMask/core/pull/8774)) +- Bump `@metamask/bridge-controller` from `^72.0.3` to `^72.0.4` ([#8774](https://github.com/MetaMask/core/pull/8774)) +- Bump `@metamask/network-controller` from `^31.1.0` to `^32.0.0` ([#8774](https://github.com/MetaMask/core/pull/8774)) +- Bump `@metamask/controller-utils` from `^12.0.0` to `^12.1.0` ([#8774](https://github.com/MetaMask/core/pull/8774)) + +## [71.1.3] + +### Changed + +- Bump `@metamask/network-controller` from `^31.0.0` to `^31.1.0` ([#8765](https://github.com/MetaMask/core/pull/8765)) +- Bump `@metamask/bridge-controller` from `^72.0.2` to `^72.0.3` ([#8773](https://github.com/MetaMask/core/pull/8773)) + +## [71.1.2] + +### Changed + +- Bump `@metamask/bridge-controller` from `^72.0.0` to `^72.0.2` ([#8738](https://github.com/MetaMask/core/pull/8738), [#8755](https://github.com/MetaMask/core/pull/8755)) +- Bump `@metamask/accounts-controller` from `^38.0.0` to `^38.1.0` ([#8755](https://github.com/MetaMask/core/pull/8755)) +- Bump `@metamask/controller-utils` from `^11.20.0` to `^12.0.0` ([#8755](https://github.com/MetaMask/core/pull/8755)) +- Bump `@metamask/gas-fee-controller` from `^26.2.0` to `^26.2.1` ([#8755](https://github.com/MetaMask/core/pull/8755)) +- Bump `@metamask/network-controller` from `^30.1.0` to `^31.0.0` ([#8755](https://github.com/MetaMask/core/pull/8755)) +- Bump `@metamask/polling-controller` from `^16.0.4` to `^16.0.5` ([#8755](https://github.com/MetaMask/core/pull/8755)) +- Bump `@metamask/transaction-controller` from `^65.2.0` to `^65.3.0` ([#8755](https://github.com/MetaMask/core/pull/8755)) + +## [71.1.1] + +### Changed + +- Bump `@metamask/bridge-controller` from `^71.0.0` to `^72.0.0` ([#8706](https://github.com/MetaMask/core/pull/8706), [#8721](https://github.com/MetaMask/core/pull/8721), [#8737](https://github.com/MetaMask/core/pull/8737)) +- Bump `@metamask/transaction-controller` from `^65.0.0` to `^65.2.0` ([#8691](https://github.com/MetaMask/core/pull/8691), [#8722](https://github.com/MetaMask/core/pull/8722)) +- Bump `@metamask/accounts-controller` from `^37.2.0` to `^38.0.0` ([#8665](https://github.com/MetaMask/core/pull/8665)) +- Bump `@metamask/messenger` from `^1.1.1` to `^1.2.0` ([#8632](https://github.com/MetaMask/core/pull/8632)) +- Bump `@metamask/keyring-controller` from `^25.2.0` to `^25.5.0` ([#8634](https://github.com/MetaMask/core/pull/8634), [#8665](https://github.com/MetaMask/core/pull/8665), [#8722](https://github.com/MetaMask/core/pull/8722)) +- Bump `@metamask/network-controller` from `^30.0.1` to `^30.1.0` ([#8636](https://github.com/MetaMask/core/pull/8636)) +- Bump `@metamask/gas-fee-controller` from `^26.1.1` to `^26.2.0` ([#8722](https://github.com/MetaMask/core/pull/8722)) + +### Fixed + +- When `submitIntent` records bridge history keyed by `orderUid`, pass `originalTransactionId` at the top level to `#addTxToHistory` so `getInitialHistoryItem` links the history item to the synthetic `TransactionController` entry instead of incorrectly using `orderUid` ([#8655](https://github.com/MetaMask/core/pull/8655)) + +## [71.1.0] + +### Added + +- Add optional `tokenSecurityTypeDestination?: string \| null` to `BridgeHistoryItem`, `StartPollingForBridgeTxStatusArgs[Serialized]`, and the `submitTx` / `submitIntent` arguments; when provided, it's persisted on the history item and emitted as `token_security_type_destination` on post-submit analytics events ([#8595](https://github.com/MetaMask/core/pull/8595)) + +### Changed + +- Bump `@metamask/transaction-controller` from `^64.3.0` to `^65.0.0` ([#8585](https://github.com/MetaMask/core/pull/8585), [#8613](https://github.com/MetaMask/core/pull/8613)) +- Bump `@metamask/bridge-controller` from `^70.2.0` to `^71.0.0` ([#8622](https://github.com/MetaMask/core/pull/8622)) + +## [71.0.0] + +### Added + +- Remove stale bridge transactions from `txHistory` to prevent excessive polling. Once a history item exceeds the configured maximum age, the status is fetched once, then the src tx hash's receipt is retrieved. If there is no receipt, the history item's hash is presumed to be invalid and the entry is deleted from state. ([#8479](https://github.com/MetaMask/core/pull/8479)) +- Add missing action types for public `BridgeStatusController` methods ([#8367](https://github.com/MetaMask/core/pull/8367)) + - The following types are now available: + - `BridgeStatusControllerSubmitTxAction` + - `BridgeStatusControllerSubmitIntentAction` + - `BridgeStatusControllerGetBridgeHistoryItemByTxMetaIdAction` + +### Changed + +- **BREAKING:** Replace `transactionFailed` and `transactionConfirmed` event subscriptions with `TransactionController:transactionStatusUpdated` ([#8479](https://github.com/MetaMask/core/pull/8479)) +- **BREAKING:** Add `RemoteFeatureFlags:getState` to allowed actions to retrieve max history item age config ([#8479](https://github.com/MetaMask/core/pull/8479)) +- Add `account_hardware_type` field to all cross-chain swap analytics events ([#8503](https://github.com/MetaMask/core/pull/8503)) + - `account_hardware_type` carries the specific hardware wallet brand (e.g. `'Ledger'`, `'QR Hardware'`) or `null` for software wallets + - `is_hardware_wallet` is now derived from `account_hardware_type !== null`, keeping both fields in sync +- `getEVMTxPropertiesFromTransactionMeta` now accepts an optional `account` parameter to populate `account_hardware_type` for `TransactionController:transactionFailed` events ([#8503](https://github.com/MetaMask/core/pull/8503)) +- Bump `@metamask/accounts-controller` from `^37.1.1` to `^37.2.0` ([#8363](https://github.com/MetaMask/core/pull/8363)) +- Bump `@metamask/keyring-controller` from `^25.1.1` to `^25.2.0` ([#8363](https://github.com/MetaMask/core/pull/8363)) +- Bump `@metamask/messenger` from `^1.0.0` to `^1.1.1` ([#8364](https://github.com/MetaMask/core/pull/8364), [#8373](https://github.com/MetaMask/core/pull/8373)) +- Bump `@metamask/transaction-controller` from `^64.0.0` to `^64.3.0` ([#8432](https://github.com/MetaMask/core/pull/8432), [#8447](https://github.com/MetaMask/core/pull/8447), [#8482](https://github.com/MetaMask/core/pull/8482)) +- Bump `@metamask/base-controller` from `^9.0.1` to `^9.1.0` ([#8457](https://github.com/MetaMask/core/pull/8457)) +- Bump `@metamask/bridge-controller` from `^70.0.1` to `^70.2.0` ([#8466](https://github.com/MetaMask/core/pull/8466), [#8474](https://github.com/MetaMask/core/pull/8474), [#8571](https://github.com/MetaMask/core/pull/8571)) + +### Fixed + +- Prevent invalid src hashes from being persisted in `txHistory` ([#8479](https://github.com/MetaMask/core/pull/8479)) + - Make transaction status subscribers generic so that `txHistory` items get updated if there are any transaction updates matching by actionId, txMetaId, hash or type + - Skip saving smart transaction hashes on transaction submission. This used to make it possible for invalid src hashes to be stored in state and polled indefinitely. Instead, the txHistory item will now be updated with the confirmed tx hash when the `transactionStatusUpdated` event is published + - If there is no srcTxHash in state, attempt to set it based on the local TransactionController state + +## [70.0.5] + +### Changed + +- Bump `@metamask/bridge-controller` from `^70.0.0` to `^70.0.1` ([#8359](https://github.com/MetaMask/core/pull/8359)) +- Bump `@metamask/transaction-controller` from `^63.3.1` to `^64.0.0` ([#8359](https://github.com/MetaMask/core/pull/8359)) +- Add missing `@metamask/messenger` dependency ([#8318](https://github.com/MetaMask/core/pull/8318)) +- Bump `@metamask/controller-utils` from `^11.19.0` to `^11.20.0` ([#8344](https://github.com/MetaMask/core/pull/8344)) + +### Deprecated + +- Deprecate `BridgeStatusAction` in favor of separate action types ([#8367](https://github.com/MetaMask/core/pull/8367)) + +## [70.0.4] + +### Changed + +- Bump `@metamask/bridge-controller` from `^69.2.3` to `^70.0.0` ([#8340](https://github.com/MetaMask/core/pull/8340)) + +## [70.0.3] + +### Changed + +- Bump `@metamask/snaps-controllers` from `^17.2.0` to `^19.0.0` ([#8319](https://github.com/MetaMask/core/pull/8319)) +- Bump `@metamask/accounts-controller` from `^37.1.0` to `^37.1.1` ([#8325](https://github.com/MetaMask/core/pull/8325)) +- Bump `@metamask/bridge-controller` from `^69.2.2` to `^69.2.3` ([#8325](https://github.com/MetaMask/core/pull/8325)) +- Bump `@metamask/profile-sync-controller` from `^28.0.1` to `^28.0.2` ([#8325](https://github.com/MetaMask/core/pull/8325)) + +## [70.0.2] + +### Changed + +- Bump `@metamask/accounts-controller` from `^37.0.0` to `^37.1.0` ([#8317](https://github.com/MetaMask/core/pull/8317)) +- Bump `@metamask/base-controller` from `^9.0.0` to `^9.0.1` ([#8317](https://github.com/MetaMask/core/pull/8317)) +- Bump `@metamask/bridge-controller` from `^69.2.1` to `^69.2.2` ([#8317](https://github.com/MetaMask/core/pull/8317)) +- Bump `@metamask/gas-fee-controller` from `^26.1.0` to `^26.1.1` ([#8317](https://github.com/MetaMask/core/pull/8317)) +- Bump `@metamask/keyring-controller` from `^25.1.0` to `^25.1.1` ([#8317](https://github.com/MetaMask/core/pull/8317)) +- Bump `@metamask/network-controller` from `^30.0.0` to `^30.0.1` ([#8317](https://github.com/MetaMask/core/pull/8317)) +- Bump `@metamask/polling-controller` from `^16.0.3` to `^16.0.4` ([#8317](https://github.com/MetaMask/core/pull/8317)) +- Bump `@metamask/profile-sync-controller` from `^28.0.0` to `^28.0.1` ([#8317](https://github.com/MetaMask/core/pull/8317)) +- Bump `@metamask/transaction-controller` from `^63.1.0` to `^63.3.1` ([#8301](https://github.com/MetaMask/core/pull/8301), [#8313](https://github.com/MetaMask/core/pull/8313), [#8317](https://github.com/MetaMask/core/pull/8317)) + +## [70.0.1] + +### Changed + +- Bump `@metamask/bridge-controller` from `^69.1.1` to `^69.2.1` ([#8265](https://github.com/MetaMask/core/pull/8265), [#8288](https://github.com/MetaMask/core/pull/8288)) +- Bump `@metamask/transaction-controller` from `^63.0.0` to `^63.1.0` ([#8272](https://github.com/MetaMask/core/pull/8272)) + +### Fixed + +- Publish UnifiedSwapBridge Failed event for any exception thrown during `submitTx`. Previously the Failed event was only getting published for EVM txs after tx submission ([#8277](https://github.com/MetaMask/core/pull/8277)) +- Keep EIP-7702 batching disabled for gasless transactions that use smart transactions / `eth_sendBundle` when the quote is gas-included but not gas-included-7702 even when the account is a smart account ([#8275](https://github.com/MetaMask/core/pull/8275)) +- For hardware wallets on MetaMask Mobile, non-batch EVM flows with ERC-20 approval now run the hardware-wallet delay before waiting for approval confirmation, preserving Ledger second-prompt spacing while gas estimation still runs after allowance is set on-chain ([#8268](https://github.com/MetaMask/core/pull/8268)) + +## [70.0.0] + +### Changed + +- **BREAKING:** Replace transaction handlers provided to the `BridgeStatusController` constructor with calls to the TransactionController, through the controller messenger. Clients will need to add the `TransactionControllerUpdateTransactionAction`, `TransactionControllerAddTransactionAction`, and `TransactionControllerEstimateGasFeeAction` permissions to their controller init modules in addition to updating the constructor ([#8188](https://github.com/MetaMask/core/pull/8188)) +- Moved controller calls from bridge-status-controller.ts to their own utils for better readability ([#8226](https://github.com/MetaMask/core/pull/8226)) + +## [69.0.0] + +### Added + +- Added more unit test coverage for intents and EVM transactions. Also refactored some mocks and code blocks to improve testability ([#8186](https://github.com/MetaMask/core/pull/8186)) + +### Changed + +- Bump `@metamask/bridge-controller` from `^69.1.0` to `^69.1.1` ([#8225](https://github.com/MetaMask/core/pull/8225)) +- Bump `@metamask/gas-fee-controller` from `^26.0.3` to `^26.1.0` ([#8225](https://github.com/MetaMask/core/pull/8225)) +- **BREAKING:** `BridgeStatusControllerMessenger` must now allow `TransactionController:isAtomicBatchSupported` action ([#8125](https://github.com/MetaMask/core/pull/8125)) +- Bump `@metamask/transaction-controller` from `^62.21.0` to `^63.0.0` ([#8217](https://github.com/MetaMask/core/pull/8217), [#8225](https://github.com/MetaMask/core/pull/8225)) + +### Fixed + +- Delegated accounts (EIP-7702) now use batched transactions to avoid in-flight transaction limit ([#8125](https://github.com/MetaMask/core/pull/8125)) +- Bridge transaction types now properly matched in 7702 batch path ([#8125](https://github.com/MetaMask/core/pull/8125)) +- Delegated account batch transactions now recorded in bridge status history ([#8125](https://github.com/MetaMask/core/pull/8125)) +- Gas fields now included for delegated account transactions that are not gas-sponsored ([#8125](https://github.com/MetaMask/core/pull/8125)) + +## [68.1.0] + +### Added + +- Added optional `activeAbTests` context support so Unified SwapBridge events can include `active_ab_tests` independently of `ab_tests`. ([#8152](https://github.com/MetaMask/core/pull/8152)) + +### Changed + +- Bump `@metamask/bridge-controller` from `^69.0.1` to `^69.1.0` ([#8168](https://github.com/MetaMask/core/pull/8168)) + +## [68.0.2] + +### Changed + +- Bump `@metamask/profile-sync-controller` from `^27.1.0` to `^28.0.0` ([#8162](https://github.com/MetaMask/core/pull/8162)) +- Bump `@metamask/bridge-controller` from `^69.0.0` to `^69.0.1` ([#8162](https://github.com/MetaMask/core/pull/8162)) + +## [68.0.1] + +### Changed + +- Bump `@metamask/transaction-controller` from `^62.19.0` to `^62.21.0`, ([#8104](https://github.com/MetaMask/core/pull/8104), [#8140](https://github.com/MetaMask/core/pull/8140)) +- Bump `@metamask/accounts-controller` from `^36.0.1` to `^37.0.0` ([8140](https://github.com/MetaMask/core/pull/8140)) +- Bump `@metamask/bridge-controller` from `^68.0.0` to `^69.0.0` ([8140](https://github.com/MetaMask/core/pull/8140)) + +## [68.0.0] + +### Added + +- Added optional `abTests` property to `BridgeHistoryItem` to persist A/B test context across the transaction lifecycle ([#8007](https://github.com/MetaMask/core/pull/8007)) +- Added optional `abTests` parameter to `StartPollingForBridgeTxStatusArgs` ([#8007](https://github.com/MetaMask/core/pull/8007)) +- Added optional `abTests` parameter to `submitTx` and `submitIntent` methods for A/B test experiment attribution ([#8007](https://github.com/MetaMask/core/pull/8007)) +- `trackUnifiedSwapBridgeEvent` now resolves `ab_tests` from event properties or transaction history and includes it in emitted events ([#8007](https://github.com/MetaMask/core/pull/8007)) + +### Changed + +- Bump `@metamask/bridge-controller` from `^67.1.1` to `^68.0.0` ([#8024](https://github.com/MetaMask/core/pull/8024), [#8051](https://github.com/MetaMask/core/pull/8051), [#8070](https://github.com/MetaMask/core/pull/8070), [#8101](https://github.com/MetaMask/core/pull/8101)) +- Bump `@metamask/transaction-controller` from `^62.17.1` to `^62.19.0` ([#8005](https://github.com/MetaMask/core/pull/8005), [#8031](https://github.com/MetaMask/core/pull/8031)) +- **BREAKING:** Move intent signing and submission orchestration into `BridgeStatusController`, including internal EIP-712 signing via `KeyringController` and the new `IntentManager` flow. ([#8048](https://github.com/MetaMask/core/pull/8048)) + +## [67.0.1] + +### Changed + +- Bump `@metamask/accounts-controller` from `^36.0.0` to `^36.0.1` ([#7996](https://github.com/MetaMask/core/pull/7996)) +- Bump `@metamask/gas-fee-controller` from `^26.0.2` to `^26.0.3` ([#7996](https://github.com/MetaMask/core/pull/7996)) +- Bump `@metamask/network-controller` from `^29.0.0` to `^30.0.0` ([#7996](https://github.com/MetaMask/core/pull/7996)) +- Bump `@metamask/polling-controller` from `^16.0.2` to `^16.0.3` ([#7996](https://github.com/MetaMask/core/pull/7996)) +- Bump `@metamask/transaction-controller` from `^62.17.0` to `^62.17.1` ([#7996](https://github.com/MetaMask/core/pull/7996)) +- Bump `@metamask/controller-utils` from `^11.18.0` to `^11.19.0` ([#7995](https://github.com/MetaMask/core/pull/7995)) +- Bump `@metamask/bridge-controller` from `^67.0.0` to `^67.1.1` ([#7995](https://github.com/MetaMask/core/pull/7995), [#7996](https://github.com/MetaMask/core/pull/7996)) + +## [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/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] + +### Added + +- Add `Unified SwapBridge Polling Status Updated` metrics event with `status` property (`max_polling_reached` or `manually_restarted`), emitted when polling stops due to max attempts or is manually restarted ([#7825](https://github.com/MetaMask/core/pull/7825)) + +### Changed + +- **BREAKING** Re-key intent history items and extract intent code into a new IntentManager class +- **BREAKING** handle intent orders in fetch bridge tx function ([#7756](https://github.com/MetaMask/core/pull/7756)) +- Bump `@metamask/transaction-controller` from `^62.11.0` to `^62.14.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)) +- Bump `@metamask/bridge-controller` from `^65.1.0` to `^65.3.0` ([#7802](https://github.com/MetaMask/core/pull/7802), [#7837](https://github.com/MetaMask/core/pull/7837)) + +### Fixed + +- Fix Tron same-chain swap polling and Completed event tracking ([#7697](https://github.com/MetaMask/core/pull/7697)) + +## [65.0.1] + +### Changed + +- Bump `@metamask/bridge-controller` from `^65.0.0` to `^65.1.0` ([#7751](https://github.com/MetaMask/core/pull/7751), [#7763](https://github.com/MetaMask/core/pull/7763)) +- Bump `@metamask/transaction-controller` from `^62.9.2` to `^62.11.0` ([#7737](https://github.com/MetaMask/core/pull/7737), [#7760](https://github.com/MetaMask/core/pull/7760)) + +## [65.0.0] + +### Changed + +- Bump `@metamask/bridge-controller` from `^64.8.2` to `^65.0.0` ([#7731](https://github.com/MetaMask/core/pull/7731)) + +## [64.4.5] + +### Changed + +- Bump `@metamask/bridge-controller` from `^64.8.1` to `^64.8.2` ([#7722](https://github.com/MetaMask/core/pull/7722)) + +### Fixed + +- Fix transaction failure tracking for pre-submission failures by using `actionId` as a temporary history key ([#7696](https://github.com/MetaMask/core/pull/7696)) + +## [64.4.4] + +### Changed + +- Bump `@metamask/bridge-controller` from `^64.5.1` to `^64.8.1` ([#7667](https://github.com/MetaMask/core/pull/7667), [#7672](https://github.com/MetaMask/core/pull/7672), [#7694](https://github.com/MetaMask/core/pull/7694), [#7700](https://github.com/MetaMask/core/pull/7700), [#7704](https://github.com/MetaMask/core/pull/7704)) + +### Fixed + +- Fix Tron same-chain swap polling and Completed event tracking ([#7697](https://github.com/MetaMask/core/pull/7697)) + +## [64.4.3] + +### Changed + +- Bump `@metamask/accounts-controller` from `^35.0.1` to `^35.0.2` ([#7642](https://github.com/MetaMask/core/pull/7642)) +- Bump `@metamask/gas-fee-controller` from `^26.0.1` to `^26.0.2` ([#7642](https://github.com/MetaMask/core/pull/7642)) +- Bump `@metamask/network-controller` from `^28.0.0` to `^29.0.0` ([#7642](https://github.com/MetaMask/core/pull/7642)) +- Bump `@metamask/polling-controller` from `^16.0.1` to `^16.0.2` ([#7642](https://github.com/MetaMask/core/pull/7642)) +- Bump `@metamask/transaction-controller` from `^62.9.1` to `^62.9.2` ([#7642](https://github.com/MetaMask/core/pull/7642)) +- Bump `@metamask/bridge-controller` from `^64.4.1` to `^64.5.1` ([#7622](https://github.com/MetaMask/core/pull/7622), [#7642](https://github.com/MetaMask/core/pull/7642)) + +## [64.4.2] + +### Changed + +- Bump `@metamask/transaction-controller` from `^62.8.0` to `^62.9.1` ([#7602](https://github.com/MetaMask/core/pull/7602), [#7604](https://github.com/MetaMask/core/pull/7604)) +- Bump `@metamask/network-controller` from `^27.2.0` to `^28.0.0` ([#7604](https://github.com/MetaMask/core/pull/7604)) +- Bump `@metamask/accounts-controller` from `^35.0.0` to `^35.0.1` ([#7604](https://github.com/MetaMask/core/pull/7604)) +- Bump `@metamask/bridge-controller` from `^64.4.0` to `^64.4.1` ([#7604](https://github.com/MetaMask/core/pull/7604)) +- Bump `@metamask/gas-fee-controller` from `^26.0.0` to `^26.0.1` ([#7604](https://github.com/MetaMask/core/pull/7604)) +- Bump `@metamask/polling-controller` from `^16.0.0` to `^16.0.1` ([#7604](https://github.com/MetaMask/core/pull/7604)) + +## [64.4.1] + +### Fixed + +- Use `BRIDGE_PREFERRED_GAS_ESTIMATE` from `@metamask/bridge-controller` for gas price estimates to align with validation ([#7582](https://github.com/MetaMask/core/pull/7582)) + +## [64.4.0] + +### Added + +- Add intent based transaction support ([#6547](https://github.com/MetaMask/core/pull/6547)) + +### Changed + +- Bump `@metamask/transaction-controller` from `^62.7.0` to `^62.8.0` ([#7596](https://github.com/MetaMask/core/pull/7596)) +- Bump `@metamask/bridge-controller` from `^64.3.0` to `^64.4.0` ([#7596](https://github.com/MetaMask/core/pull/7596)) +- Bump `@metamask/controller-utils` from `^11.17.0` to `^11.18.0` ([#7583](https://github.com/MetaMask/core/pull/7583)) +- Bump `@metamask/network-controller` from `^27.1.0` to `^27.2.0` ([#7583](https://github.com/MetaMask/core/pull/7583)) + +## [64.3.0] + +### Changed + +- **BREAKING** Use CrossChain API instead of the intent manager package for intent order submission ([#6547](https://github.com/MetaMask/core/pull/6547)) +- Bump `@metamask/snaps-controllers` from `^14.0.1` to `^17.2.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)) +- Bump `@metamask/network-controller` from `^27.0.0` to `^27.1.0` ([#7534](https://github.com/MetaMask/core/pull/7534)) +- Bump `@metamask/controller-utils` from `^11.16.0` to `^11.17.0` ([#7534](https://github.com/MetaMask/core/pull/7534)) +- Bump `@metamask/bridge-controller` from `^64.2.0` to `^64.3.0` ([#7574](https://github.com/MetaMask/core/pull/7574)) + +## [64.2.0] + +### Changed + +- Bump `@metamask/transaction-controller` from `^62.5.0` to `^62.7.0` ([#7430](https://github.com/MetaMask/core/pull/7430), [#7494](https://github.com/MetaMask/core/pull/7494)) +- Bump `@metamask/bridge-controller` from `^64.1.0` to `^64.2.0` ([#7509](https://github.com/MetaMask/core/pull/7509)) + +## [64.1.0] + +### Changed + +- Bump `@metamask/bridge-controller` from `^64.0.0` to `^64.1.0` ([#7422](https://github.com/MetaMask/core/pull/7422)) +- Bump `@metamask/transaction-controller` from `^62.4.0` to `^62.5.0` ([#7325](https://github.com/MetaMask/core/pull/7325)) + +## [64.0.1] + +### Fixed + +- Fix MAX native token swap failing with "insufficient gas" when STX is off by using quote's `txFee` instead of re-estimating gas when `gasIncluded` is true ([#7306](https://github.com/MetaMask/core/pull/7306)) + +## [64.0.0] + +### Changed + +- **BREAKING:** Bump `@metamask/bridge-controller` from `^63.2.0` to `^64.0.0` ([#7295](https://github.com/MetaMask/core/pull/7295)) +- Improve type safety by replacing tx data type assertions with type predicates ([#7228](https://github.com/MetaMask/core/pull/7228)) +- Submit `resetApproval` tx before the tx approval if it is included in the quoteResponse ([#7228](https://github.com/MetaMask/core/pull/7228)) +- Bump `@metamask/network-controller` from `^26.0.0` to `^27.0.0` ([#7258](https://github.com/MetaMask/core/pull/7258)) +- Bump `@metamask/transaction-controller` from `^62.3.0` to `^62.4.0` ([#7257](https://github.com/MetaMask/core/pull/7257), [#7289](https://github.com/MetaMask/core/pull/7289)) + +## [63.1.0] + +### Changed + +- Bump `@metamask/bridge-controller` from `^63.1.0` to `^63.2.0` ([#7245](https://github.com/MetaMask/core/pull/7245)) +- Move peer dependencies for controller and service packages to direct dependencies ([#7209](https://github.com/MetaMask/core/pull/7209), [#7220](https://github.com/MetaMask/core/pull/7220), [#7236](https://github.com/MetaMask/core/pull/7236)) + - The dependencies moved are: + - `@metamask/accounts-controller` (^35.0.0) + - `@metamask/bridge-controller` (^63.0.0) + - `@metamask/gas-fee-controller` (^26.0.0) + - `@metamask/network-controller` (^26.0.0) + - `@metamask/snaps-controllers` (^14.0.1) + - `@metamask/transaction-controller` (^62.3.0) + - In clients, it is now possible for multiple versions of these packages to exist in the dependency tree. + - For example, this scenario would be valid: a client relies on `@metamask/controller-a` 1.0.0 and `@metamask/controller-b` 1.0.0, and `@metamask/controller-b` depends on `@metamask/controller-a` 1.1.0. + - Note, however, that the versions specified in the client's `package.json` always "win", and you are expected to keep them up to date so as not to break controller and service intercommunication. +- Bump `@metamask/bridge-controller` from `^63.0.0` to `^63.1.0` ([#7238](https://github.com/MetaMask/core/pull/7238)) + +### Removed + +- Remove direct QuotesReceived event publishing to avoid race conditions that can happen when clients navigate and reset state. Update `submitTx` to accept quotesReceivedContext (replace isLoading/warnings) and propagate context to the BridgeController through the `stopPollingForQuotes call ([#7242](https://github.com/MetaMask/core/pull/7242)) + +## [63.0.0] + +### Changed + +- **BREAKING:** Bump `@metamask/bridge-controller` from `^62.0.0` to `^63.0.0` ([#7207](https://github.com/MetaMask/core/pull/7207)) + +## [62.0.0] + +### Changed + +- Bump `@metamask/polling-controller` from `^15.0.0` to `^16.0.0` ([#7202](https://github.com/MetaMask/core/pull/7202)) +- Bump `@metamask/controller-utils` from `^11.15.0` to `^11.16.0` ([#7202](https://github.com/MetaMask/core/pull/7202)) +- **BREAKING:** Bump `@metamask/transaction-controller` from `^61.0.0` to `^62.0.0` ([#7202](https://github.com/MetaMask/core/pull/7202)) +- **BREAKING:** Bump `@metamask/network-controller` from `^25.0.0` to `^26.0.0` ([#7202](https://github.com/MetaMask/core/pull/7202)) +- **BREAKING:** Bump `@metamask/gas-fee-controller` from `^25.0.0` to `^26.0.0` ([#7202](https://github.com/MetaMask/core/pull/7202)) +- **BREAKING:** Bump `@metamask/bridge-controller` from `^61.0.0` to `^62.0.0` ([#7202](https://github.com/MetaMask/core/pull/7202)) +- **BREAKING:** Bump `@metamask/accounts-controller` from `^34.0.0` to `^35.0.0` ([#7202](https://github.com/MetaMask/core/pull/7202)) +- Update `submitTx` handler to accept optional `isLoading` and `warnings` arguments. When `isLoading=true`, the QuotesReceived event is published ([#7182](https://github.com/MetaMask/core/pull/7182)) + +## [61.0.0] + +### Changed + +- **BREAKING:** Bump `@metamask/bridge-controller` from `^60.0.0` to `^61.0.0` ([#7179](https://github.com/MetaMask/core/pull/7179)) + +## [60.1.0] + +### Added + +- Added support for bridging and swapping tokens on the Tron blockchain ([#6862](https://github.com/MetaMask/core/pull/6862)) + +## [60.0.0] + +### Added + +- Add isGasFeeSponsored field in transaction batch params ([#7064](https://github.com/MetaMask/core/pull/7064)) + +### Changed + +- **BREAKING:** Bump `@metamask/bridge-controller` from `^59.0.0` to `^60.0.0` ([#7100](https://github.com/MetaMask/core/pull/7100)) + +## [59.0.0] + +### Changed + +- **BREAKING:** Bump `@metamask/bridge-controller` from `^58.0.0` to `^59.0.0` ([#7043](https://github.com/MetaMask/core/pull/7043)) + +## [58.0.0] + +### Changed + +- **BREAKING:** Bump `@metamask/bridge-controller` from `^57.0.0` to `^58.0.0` ([#7011](https://github.com/MetaMask/core/pull/7011)) + +## [57.0.0] + +### Changed + +- **BREAKING:** Bump `@metamask/bridge-controller` from `^56.0.0` to `^57.0.0` ([#7003](https://github.com/MetaMask/core/pull/7003)) + +## [56.0.0] + +### Changed + +- **BREAKING:** Use new `Messenger` from `@metamask/messenger` ([#6444](https://github.com/MetaMask/core/pull/6444)) + - Previously, `BridgeStatusController` accepted a `RestrictedMessenger` instance from `@metamask/base-controller`. +- **BREAKING:** Bump `@metamask/accounts-controller` from `^33.0.0` to `^34.0.0` ([#6962](https://github.com/MetaMask/core/pull/6962)) +- **BREAKING:** Bump `@metamask/bridge-controller` from `^55.0.0` to `^56.0.0` ([#6962](https://github.com/MetaMask/core/pull/6962)) +- **BREAKING:** Bump `@metamask/gas-fee-controller` from `^24.0.0` to `^25.0.0` ([#6962](https://github.com/MetaMask/core/pull/6962)) +- **BREAKING:** Bump `@metamask/network-controller` from `^24.0.0` to `^25.0.0` ([#6962](https://github.com/MetaMask/core/pull/6962)) +- **BREAKING:** Bump `@metamask/transaction-controller` from `^60.0.0` to `^61.0.0` ([#6962](https://github.com/MetaMask/core/pull/6962)) +- Bump `@metamask/base-controller` from `^8.4.2` to `^9.0.0` ([#6962](https://github.com/MetaMask/core/pull/6962)) +- Bump `@metamask/polling-controller` from `^14.0.1` to `^15.0.0` ([#6940](https://github.com/MetaMask/core/pull/6940), [#6962](https://github.com/MetaMask/core/pull/6962)) + +## [55.0.0] + +### Changed + +- **BREAKING:** Bump peer dependency `@metamask/bridge-controller` from `^54.0.0` to `^55.0.0` ([#6923](https://github.com/MetaMask/core/pull/6923)) +- Bump `@metamask/base-controller` from `^8.4.1` to `^8.4.2` ([#6917](https://github.com/MetaMask/core/pull/6917)) + +## [54.0.0] + +### Changed + +- **BREAKING:** Bump peer dependency `@metamask/bridge-controller` from `^53.0.0` to `^54.0.0` ([#6908](https://github.com/MetaMask/core/pull/6908)) + +## [53.0.0] + +### Fixed + +- **BREAKING:** Update QuoteResponse type used in `submitTx` handler ([#6892](https://github.com/MetaMask/core/pull/6892)) + +## [52.1.0] + +### Changed + +- Publish `destinationTransactionCompleted` event when a bridge tx completes on the destination chain ([#6900](https://github.com/MetaMask/core/pull/6900)) + +## [52.0.0] + +### Changed + +- **BREAKING:** Bump peer dependency `@metamask/bridge-controller` from `^52.0.0` to `^53.0.0` ([#6895](https://github.com/MetaMask/core/pull/6895)) +- Bump `@metamask/network-controller` from `^24.2.2` to `^24.3.0` ([#6883](https://github.com/MetaMask/core/pull/6883)) +- Bump `@metamask/transaction-controller` from `^60.7.0` to `^60.8.0` ([#6883](https://github.com/MetaMask/core/pull/6883)) + +### Fixed + +- Fix issue with Mobile where app would crash after a successful tx ([#6890](https://github.com/MetaMask/core/pull/6890)) +- Fix BridgeController initialization in unit tests ([#6891](https://github.com/MetaMask/core/pull/6891)) + +## [51.0.0] + +### Changed + +- **BREAKING:** Bump peer dependency `@metamask/bridge-controller` from `^51.0.0` to `^52.0.0` ([#6834](https://github.com/MetaMask/core/pull/6834)) + +## [50.1.0] + +### Changed + +- Bump peer dependency `@metamask/bridge-controller` from `^50.0.0` to `^51.0.0` ([#6824](https://github.com/MetaMask/core/pull/6824)) + +## [50.0.0] + +### Changed + +- **BREAKING:** Bump peer dependency `@metamask/bridge-controller` from `^49.0.0` to `^50.0.0` ([#6818](https://github.com/MetaMask/core/pull/6818)) + +## [49.0.1] + +### Changed + +- Bump `@metamask/base-controller` from `^8.4.0` to `^8.4.1` ([#6807](https://github.com/MetaMask/core/pull/6807)) +- Bump `@metamask/controller-utils` from `^11.14.0` to `^11.14.1` ([#6807](https://github.com/MetaMask/core/pull/6807)) +- Bump `@metamask/polling-controller` from `^14.0.0` to `^14.0.1` ([#6807](https://github.com/MetaMask/core/pull/6807)) + +## [49.0.0] + +### Changed + +- **BREAKING:** Bump peer dependency `@metamask/bridge-controller` from `^48.0.0` to `^49.0.0` ([#6806](https://github.com/MetaMask/core/pull/6806)) + +## [48.0.0] + +### Changed + +- **BREAKING:** Bump peer dependency `@metamask/bridge-controller` from `^47.2.0` to `^48.0.0` ([#6780](https://github.com/MetaMask/core/pull/6780)) + +## [47.2.0] + +### Changed + +- Make QuoteMetadata optional when calling `submitTx` ([#6739](https://github.com/MetaMask/core/pull/6739)) +- Skip event publishing for transactions submitted outside of the Unified Swap and Bridge experience ([#6739](https://github.com/MetaMask/core/pull/6739)) + - On tx submission, add the quote's `featureId` to txHistory + - When transaction statuses change, check the `featureId` and skip event publishing when it's not `undefined` + - This affects the Submitted, Completed and Failed events + +## [47.1.0] + +### Changed + +- Bump `@metamask/transaction-controller` from `60.4.0` to `60.5.0` ([#6733](https://github.com/MetaMask/core/pull/6733)) + +## [47.0.0] + +### Changed + +- Bump `@metamask/utils` from `^11.8.0` to `^11.8.1` ([#6708](https://github.com/MetaMask/core/pull/6708)) +- **BREAKING** Add a required `accountAddress` parameter to the `submitTx` handler ([#6719](https://github.com/MetaMask/core/pull/6719)) + +### Removed + +- Deprecate the unused `SnapConfirmationViewed` event ([#6719](https://github.com/MetaMask/core/pull/6719)) + +### Fixed + +- Replace `AccountsController:getSelectedMultichainAccount` usages with AccountsController:getAccountByAddress` when reading account details required for submitting Solana transactions ([#6719](https://github.com/MetaMask/core/pull/6719)) + +## [46.0.0] + +### Added + +- Add support for Bitcoin bridge transactions ([#6705](https://github.com/MetaMask/core/pull/6705)) + - Handle Bitcoin PSBT (Partially Signed Bitcoin Transaction) format in trade data + - Support Bitcoin transaction submission through unified Snap interface + +### Changed + +- **BREAKING:** Update transaction submission to use new unified Snap interface for all non-EVM chains ([#6705](https://github.com/MetaMask/core/pull/6705)) + - Replace `signAndSendTransactionWithoutConfirmation` with `ClientRequest:signAndSendTransaction` method for Snap communication + - This changes the expected Snap interface but maintains backward compatibility through response handling +- Export `handleSolanaTxResponse` as an alias for `handleNonEvmTxResponse` for backward compatibility (deprecated) ([#6705](https://github.com/MetaMask/core/pull/6705)) +- Rename `createClientTransactionRequest` from `signAndSendTransactionRequest` for clarity ([#6705](https://github.com/MetaMask/core/pull/6705)) + +### Removed + +- Remove direct dependency on `@metamask/keyring-api` ([#6705](https://github.com/MetaMask/core/pull/6705)) + +### Fixed + +- Fix invalid fallback chain ID for non-EVM chains in transaction metadata ([#6705](https://github.com/MetaMask/core/pull/6705)) + - Changed from invalid `0x0` to `0x1` as temporary workaround for activity list display + +## [45.0.0] + +### Changed + +- Bump `@metamask/bridge-controller` from `^44.0.1` to `^45.0.0` ([#6716](https://github.com/MetaMask/core/pull/6716), [#6629](https://github.com/MetaMask/core/pull/6629)) + +## [44.1.0] + +### Changed + +- Revert accidental breaking changes included in v44.0.0 ([#6454](https://github.com/MetaMask/core/pull/6454)) +- Refactor `handleLineaDelay` to `handleApprovalDelay` for improved abstraction and add support for Base chain by using an array and `includes` for chain ID checks ([#6674](https://github.com/MetaMask/core/pull/6674)) + +## [44.0.0] [DEPRECATED] + +### Changed + +- This version was deprecated because it accidentally included additional breaking changes; use v44.1.0 or later versions instead +- **BREAKING:** Bump peer dependency `@metamask/bridge-controller` from `^43.0.0` to `^44.0.0` ([#6652](https://github.com/MetaMask/core/pull/6652), [#6676](https://github.com/MetaMask/core/pull/6676)) + +## [43.1.0] + +### Added + +- Add new controller metadata properties to `BridgeStatusController` ([#6589](https://github.com/MetaMask/core/pull/6589)) + +### Changed + +- Bump `@metamask/controller-utils` from `^11.12.0` to `^11.14.0` ([#6620](https://github.com/MetaMask/core/pull/6620), [#6629](https://github.com/MetaMask/core/pull/6629)) +- Bump `@metamask/base-controller` from `^8.3.0` to `^8.4.0` ([#6632](https://github.com/MetaMask/core/pull/6632)) + +## [43.0.0] + +### Changed + +- **BREAKING:** Bump `@metamask/bridge-controller` peer dependency from `^42.0.0` to `^43.0.0` ([#6612](https://github.com/MetaMask/core/pull/6612)) +- Bump `@metamask/keyring-api` from `^20.1.0` to `^21.0.0` ([#6560](https://github.com/MetaMask/core/pull/6560)) +- Bump `@metamask/utils` from `^11.4.2` to `^11.8.0` ([#6588](https://github.com/MetaMask/core/pull/6588)) + +## [42.0.0] + +### Added + +- Add `getBridgeHistoryItemByTxMetaId` method available via messaging system for external access to bridge history items ([#6363](https://github.com/MetaMask/core/pull/6363)) +- Add `gas_included_7702` field to metrics tracking for EIP-7702 gasless transactions ([#6363](https://github.com/MetaMask/core/pull/6363)) + +### Changed + +- **BREAKING:** Bump `@metamask/bridge-controller` peer dependency from `^41.0.0` to `^42.0.0` ([#6476](https://github.com/MetaMask/core/pull/6476)) +- Bump `@metamask/base-controller` from `^8.2.0` to `^8.3.0` ([#6465](https://github.com/MetaMask/core/pull/6465)) +- Pass the `isGasFeeIncluded` parameter through transaction utilities ([#6363](https://github.com/MetaMask/core/pull/6363)) + +## [41.0.0] + +### Fixed + +- Set the Solana tx signature as the `txHistory` key to support lookups by hash ([#6424](https://github.com/MetaMask/core/pull/6424)) +- Read Completed swap properties from `txHistory` for consistency with bridge transactions ([#6424](https://github.com/MetaMask/core/pull/6424)) + +## [40.2.0] + +### Added + +- Publish `StatusValidationFailed` event for invalid getTxStatus responses ([#6362](https://github.com/MetaMask/core/pull/6362)) + +## [40.1.0] + +### Changed + +- Bump `@metamask/base-controller` from `^8.1.0` to `^8.2.0` ([#6355](https://github.com/MetaMask/core/pull/6355)) + +## [40.0.0] + +### Added + +- Add `getBridgeHistoryItemByTxMetaId` method to retrieve bridge history items by their transaction meta ID ([#6346](https://github.com/MetaMask/core/pull/6346)) +- Add support for EIP-7702 gasless transactions in transaction batch handling ([#6346](https://github.com/MetaMask/core/pull/6346)) + +### Changed + +- **BREAKING:** Bump peer dependency `@metamask/bridge-controller` from `^40.0.0` to `^41.0.0` ([#6350](https://github.com/MetaMask/core/pull/6350)) +- Calculate `actual_time_minutes` event property based on `txMeta.time` if available ([#6314](https://github.com/MetaMask/core/pull/6314)) +- Parse event properties from the quote request if an event needs to be published prior to tx submission (i.e., Failed, Submitted) ([#6314](https://github.com/MetaMask/core/pull/6314)) +- Update transaction batch handling to conditionally enable EIP-7702 based on quote's `gasless7702` flag ([#6346](https://github.com/MetaMask/core/pull/6346)) + +## [39.0.0] + +### Changed + +- **BREAKING:** Bump peer dependency `@metamask/accounts-controller` from `^32.0.0` to `^33.0.0` ([#6345](https://github.com/MetaMask/core/pull/6345)) +- **BREAKING:** Bump peer dependency `@metamask/bridge-controller` from `^39.0.0` to `^40.0.0` ([#6345](https://github.com/MetaMask/core/pull/6345)) +- **BREAKING:** Bump peer dependency `@metamask/transaction-controller` from `^59.0.0` to `^60.0.0` ([#6345](https://github.com/MetaMask/core/pull/6345)) +- Bump accounts related packages ([#6309](https://github.com/MetaMask/core/pull/6309)) + - Bump `@metamask/keyring-api` from `^20.0.0` to `^20.1.0` + +## [38.1.0] + +### Changed + +- Add `quotedGasAmount` to txHistory ([#6299](https://github.com/MetaMask/core/pull/6299)) + +### Fixed + +- Parse destination amount from Swap EVM tx receipt and use it to calculate finalized tx event properties ([#6299](https://github.com/MetaMask/core/pull/6299)) +- Use `status.destChain.amount` from getTxStatus response to calculate actual bridged amount ([#6299](https://github.com/MetaMask/core/pull/6299)) + +## [38.0.1] + +### Changed + +- Bump `@metamask/controller-utils` from `^11.11.0` to `^11.12.0` ([#6303](https://github.com/MetaMask/core/pull/6303)) + +### Fixed + +- Wait for Mobile hardware wallet delay before submitting Ledger tx ([#6302](https://github.com/MetaMask/core/pull/6302)) + +## [38.0.0] + +### Added + +- Include `assetsFiatValue` for sending and receiving assets in batch transaction request parameters ([#6277](https://github.com/MetaMask/core/pull/6277)) + +### Changed + +- **BREAKING:** Bump peer dependency `@metamask/bridge-controller` to `^38.0.0` ([#6268](https://github.com/MetaMask/core/pull/6268)) +- Hardcode `action_type` to `swapbridge-v1` after swaps and bridge unification ([#6270](https://github.com/MetaMask/core/pull/6270)) +- Bump `@metamask/base-controller` from `^8.0.1` to `^8.1.0` ([#6284](https://github.com/MetaMask/core/pull/6284)) +- Store the quote's effective gas fees as the `quotedGasInUsd` in txHistory; fallback to the total fees otherwise ([#6295](https://github.com/MetaMask/core/pull/6295)) + +## [37.0.1] + +### Changed + +- Bump `@metamask/keyring-api` from `^19.0.0` to `^20.0.0` ([#6248](https://github.com/MetaMask/core/pull/6248)) + +### Fixed + +- Make sure to pass the `requireApproval` for ERC20 approvals ([#6204](https://github.com/MetaMask/core/pull/6204)) + +## [37.0.0] + +### Changed + +- **BREAKING:** Bump peer dependency `@metamask/accounts-controller` to `^32.0.0` ([#6171](https://github.com/MetaMask/core/pull/6171)) +- **BREAKING:** Bump peer dependency `@metamask/bridge-controller` to `^37.0.0` ([#6171](https://github.com/MetaMask/core/pull/6171)) +- **BREAKING:** Bump peer dependency `@metamask/transaction-controller` to `^59.0.0`, ([#6171](https://github.com/MetaMask/core/pull/6171), [#6027](https://github.com/MetaMask/core/pull/6027)) + +## [36.1.0] + +### Added + +- Add `restartPollingForFailedAttempts` action to restart polling for txs that are not in a final state but have too many failed attempts ([#6149](https://github.com/MetaMask/core/pull/6149)) + +### Changed + +- Bump `@metamask/keyring-api` from `^18.0.0` to `^19.0.0` ([#6146](https://github.com/MetaMask/core/pull/6146)) + +### Fixed + +- Don't poll indefinitely for bridge tx status if the tx is not found. Implement exponential backoff to prevent overwhelming the bridge API. ([#6149](https://github.com/MetaMask/core/pull/6149)) + +## [36.0.0] + +### Changed + +- Bump `@metamask/bridge-controller` to `^36.0.0` ([#6120](https://github.com/MetaMask/core/pull/6120)) + +## [35.0.0] + +### Changed + +- **BREAKING:** Bump peer dependency `@metamask/bridge-controller` to `^35.0.0` ([#6098](https://github.com/MetaMask/core/pull/6098)) +- **BREAKING** Submit Solana transactions using `onClientRequest` RPC call by default, which hides the Snap confirmation page from clients. Clients will need to remove conditional redirect the the confirmation page on tx submission ([#6077](https://github.com/MetaMask/core/pull/6077)) +- Bump `@metamask/controller-utils` from `^11.10.0` to `^11.11.0` ([#6069](https://github.com/MetaMask/core/pull/6069)) +- Bump `@metamask/utils` from `^11.2.0` to `^11.4.2` ([#6054](https://github.com/MetaMask/core/pull/6054)) + +## [34.0.0] + +### Added + +- Add `batchId` to BridgeHistoryItem to enable querying history by batchId ([#6058](https://github.com/MetaMask/core/pull/6058)) + +### Changed + +- **BREAKING** Add tx batching functionality, which requires an `addTransactionBatchFn` handler to be passed to the BridgeStatusController's constructor ([#6058](https://github.com/MetaMask/core/pull/6058)) +- **BREAKING** Update batched txs after signing with correct tx types, which requires an `updateTransactionFn` handler to be passed to the BridgeStatusController's constructor ([#6058](https://github.com/MetaMask/core/pull/6058)) +- Add approvalTxId to txHistoryItem after signing batched transaction ([#6058](https://github.com/MetaMask/core/pull/6058)) +- Remove `addUserOperationFromTransaction` tx submission code and constructor arg since it is unsupported ([#6057](https://github.com/MetaMask/core/pull/6057)) +- Remove @metamask/user-operation-controller dependency ([#6057](https://github.com/MetaMask/core/pull/6057)) +- **BREAKING:** Bump peer dependency `@metamask/snaps-controllers` from `^12.0.0` to `^14.0.0` ([#6035](https://github.com/MetaMask/core/pull/6035)) + +### Fixed + +- Wait until a bridge transaction is confirmed before polling for its status. This reduces (or fully removes) premature `getTxStatus` calls, and enables adding batched bridge txs to history before its transaction Id is available ([#6052](https://github.com/MetaMask/core/pull/6052)) + +## [33.0.0] + +### Changed + +- Consolidate validator and type definitions for `StatusResponse` so new response fields only need to be defined once ([#6030](https://github.com/MetaMask/core/pull/6030)) + +### Removed + +- Clean up unused exports that duplicate @metamask/bridge-controller's ([#6030](https://github.com/MetaMask/core/pull/6030)) + - Asset + - SrcChainStatus + - DestChainStatus + - RefuelData + - FeeType + - ActionTypes + +### Fixed + +- Set event property `gas_included` to quote's `gasIncluded` value ([#6030](https://github.com/MetaMask/core/pull/6030)) +- Set StatusResponse ChainId schema to expect a number instead of a string ([#6045](https://github.com/MetaMask/core/pull/6045)) + +## [32.0.0] + +### Changed + +- Remove `@metamask/multichain-transactions-controller` peer dependency ([#5993](https://github.com/MetaMask/core/pull/5993)) + +### Fixed + +- Update the following events to match the Unified SwapBridge spec ([#5993](https://github.com/MetaMask/core/pull/5993)) + - `Completed`: remove multichain tx controller subscription and emit the event based on the tx submission status instead + - `Failed`: emit event when an error is thrown during solana tx submission + - `Submitted` + - set swap type for evm txs when applicable. this is currently hardcoded to bridge so swaps don't get displayed correctly on the activity list + - emit this event when submitTx is called, regardless of confirmation status + +## [31.0.0] + +### Changed + +- **BREAKING:** Adds a call to bridge-controller's `stopPollingForQuotes` handler to prevent quotes from refreshing during tx submission. This enables "pausing" the quote polling loop without resetting the entire state. Without this, it's possible for the activeQuote to change while the UI's tx submission is in-progress ([#5994](https://github.com/MetaMask/core/pull/5994)) +- **BREAKING:** BridgeStatusController now requires the `BridgeController:stopPollingForQuotes` action permission ([#5994](https://github.com/MetaMask/core/pull/5994)) +- **BREAKING:** Bump peer dependency `@metamask/accounts-controller` to `^31.0.0` ([#5999](https://github.com/MetaMask/core/pull/5999)) +- **BREAKING:** Bump peer dependency `@metamask/bridge-controller` to `^33.0.0` ([#5999](https://github.com/MetaMask/core/pull/5999)) +- **BREAKING:** Bump peer dependency `@metamask/gas-fee-controller` to `^24.0.0` ([#5999](https://github.com/MetaMask/core/pull/5999)) +- **BREAKING:** Bump peer dependency `@metamask/multichain-transactions-controller` to `^3.0.0` ([#5999](https://github.com/MetaMask/core/pull/5999)) +- **BREAKING:** Bump peer dependency `@metamask/network-controller` to `^24.0.0` ([#5999](https://github.com/MetaMask/core/pull/5999)) +- **BREAKING:** Bump peer dependency `@metamask/transaction-controller` to `^58.0.0` ([#5999](https://github.com/MetaMask/core/pull/5999)) +- Bump `@metamask/polling-controller` to `^14.0.0` ([#5999](https://github.com/MetaMask/core/pull/5999)) +- Bump `@metamask/user-operation-controller` to `^37.0.0` ([#5999](https://github.com/MetaMask/core/pull/5999)) + +### Fixed + +- Parse tx signature from `onClientRequest` response in order to identify bridge transactions ([#6001](https://github.com/MetaMask/core/pull/6001)) +- Prevent active quote from changing while transaction submission is in progress ([#5994](https://github.com/MetaMask/core/pull/5994)) + +## [30.0.0] + +### Changed + +- **BREAKING:** Implement onClientRequest for Solana snap transactions, now requires action permission for RemoteFeatureFlagController:getState ([#5961](https://github.com/MetaMask/core/pull/5961)) + +## [29.1.1] + +### Changed + +- Bump `@metamask/bridge-controller` to `^32.1.2` ([#5969](https://github.com/MetaMask/core/pull/5969)) +- Bump `@metamask/controller-utils` to `^11.10.0` ([#5935](https://github.com/MetaMask/core/pull/5935)) +- Bump `@metamask/transaction-controller` to `^57.3.0` ([#5954](https://github.com/MetaMask/core/pull/5954)) + +### Fixed + +- Properly prompt for confirmation on Ledger on Mobile for bridge transactions ([#5931](https://github.com/MetaMask/core/pull/5931)) + +## [29.1.0] + +### Added + +- Include all invalid status properties in sentry logs ([#5913](https://github.com/MetaMask/core/pull/5913)) + +## [29.0.0] + +### Changed + +- **BREAKING:** Bump `@metamask/bridge-controller` peer dependency to `^32.0.0` ([#5896](https://github.com/MetaMask/core/pull/5896)) + +## [28.0.0] + +### Changed + +- **BREAKING:** Bump `@metamask/bridge-controller` peer dependency to `^31.0.0` ([#5894](https://github.com/MetaMask/core/pull/5894)) + +## [27.0.0] + +### Changed + +- **BREAKING:** Bump `@metamask/accounts-controller` peer dependency to `^30.0.0` ([#5888](https://github.com/MetaMask/core/pull/5888)) +- **BREAKING:** Bump `@metamask/bridge-controller` peer dependency to `^30.0.0` ([#5888](https://github.com/MetaMask/core/pull/5888)) +- **BREAKING:** Bump `@metamask/transactions-controller` peer dependency to `^57.0.0` ([#5888](https://github.com/MetaMask/core/pull/5888)) +- **BREAKING:** Bump `@metamask/multichain-transactions-controller` peer dependency to `^2.0.0` ([#5888](https://github.com/MetaMask/core/pull/5888)) +- **BREAKING:** Bump `@metamask/snaps-controllers` peer dependency from `^11.0.0` to `^12.0.0` ([#5871](https://github.com/MetaMask/core/pull/5871)) +- Bump `@metamask/keyring-api` dependency from `^17.4.0` to `^18.0.0` ([#5871](https://github.com/MetaMask/core/pull/5871)) + +## [26.0.0] + +### Changed + +- **BREAKING:** Bump `@metamask/bridge-controller` peer dependency to `^29.0.0` ([#5872](https://github.com/MetaMask/core/pull/5872)) + +## [25.0.0] + +### Changed + +- **BREAKING:** Bump `@metamask/bridge-controller` peer dependency to `^28.0.0` ([#5863](https://github.com/MetaMask/core/pull/5863)) + +## [24.0.0] + +### Changed + +- **BREAKING:** Bump `@metamask/bridge-controller` peer dependency to `^27.0.0` ([#5845](https://github.com/MetaMask/core/pull/5845)) + +## [23.0.0] + +### Added + +- Subscribe to TransactionController and MultichainTransactionsController tx confirmed and failed events for swaps ([#5829](https://github.com/MetaMask/core/pull/5829)) + +### Changed + +- **BREAKING:** bump `@metamask/bridge-controller` peer dependency to `^26.0.0` ([#5842](https://github.com/MetaMask/core/pull/5842)) +- **BREAKING:** Remove the published bridgeTransactionComplete and bridgeTransactionFailed events ([#5829](https://github.com/MetaMask/core/pull/5829)) +- Modify events to use `swap` and `swapApproval` TransactionTypes when src and dest chain are the same ([#5829](https://github.com/MetaMask/core/pull/5829)) + +## [22.0.0] + +### Added + +- Subscribe to TransactionController and MultichainTransactionsController tx confirmed and failed events for swaps ([#5829](https://github.com/MetaMask/core/pull/5829)) +- Error logs for invalid getTxStatus responses ([#5816](https://github.com/MetaMask/core/pull/5816)) + +### Changed + +- **BREAKING:** Remove the published bridgeTransactionComplete and bridgeTransactionFailed events ([#5829](https://github.com/MetaMask/core/pull/5829)) +- Modify events to use `swap` and `swapApproval` TransactionTypes when src and dest chain are the same ([#5829](https://github.com/MetaMask/core/pull/5829)) +- Bump `@metamask/bridge-controller` dev dependency to `^25.0.1` ([#5811](https://github.com/MetaMask/core/pull/5811)) +- Bump `@metamask/controller-utils` to `^11.9.0` ([#5812](https://github.com/MetaMask/core/pull/5812)) + +### Fixed + +- Don't start or restart getTxStatus polling if transaction is a swap ([#5831](https://github.com/MetaMask/core/pull/5831)) + +## [21.0.0] + +### Changed + +- **BREAKING:** bump `@metamask/accounts-controller` peer dependency to `^29.0.0` ([#5802](https://github.com/MetaMask/core/pull/5802)) +- **BREAKING:** bump `@metamask/bridge-controller` peer dependency to `^25.0.0` ([#5802](https://github.com/MetaMask/core/pull/5802)) +- **BREAKING:** bump `@metamask/transaction-controller` peer dependency to `^56.0.0` ([#5802](https://github.com/MetaMask/core/pull/5802)) + +## [20.1.0] + +### Added + +- Sentry traces for Swap and Bridge `TransactionApprovalCompleted` and `TransactionCompleted` events ([#5780](https://github.com/MetaMask/core/pull/5780)) + +### Changed + +- `traceFn` added to BridgeStatusController constructor to enable clients to pass in a custom sentry trace handler ([#5768](https://github.com/MetaMask/core/pull/5768)) + +## [20.0.0] + +### Changed + +- **BREAKING:** Bump `@metamask/bridge-controller` peer dependency to `^23.0.0` ([#5795](https://github.com/MetaMask/core/pull/5795)) +- Replace `bridgePriceData` with `priceData` from QuoteResponse object ([#5784](https://github.com/MetaMask/core/pull/5784)) + +## [19.0.0] + +### Changed + +- **BREAKING:** Bump `@metamask/bridge-controller` peer dependency to `^22.0.0` ([#5780](https://github.com/MetaMask/core/pull/5780)) +- Bump `@metamask/controller-utils` to `^11.8.0` ([#5765](https://github.com/MetaMask/core/pull/5765)) + +## [18.0.0] + +### Changed + +- **BREAKING:** Bump `@metamask/bridge-controller` peer dependency to `^21.0.0` ([#5763](https://github.com/MetaMask/core/pull/5763)) +- **BREAKING:** Bump `@metamask/accounts-controller` peer dependency to `^28.0.0` ([#5763](https://github.com/MetaMask/core/pull/5763)) +- **BREAKING:** Bump `@metamask/transaction-controller` peer dependency to `^55.0.0` ([#5763](https://github.com/MetaMask/core/pull/5763)) + +## [17.0.1] + +### Fixed + +- Added a hardcoded `SolScope.Mainnet` value to ensure the `signAndSendTransaction` params are always valid. Discovered Solana accounts may have an undefined `options.scope`, which causes `handleRequest` calls to throw a JSON-RPC validation error ([#5750])(https://github.com/MetaMask/core/pull/5750) + +## [17.0.0] + +### Changed + +- Includes submitted quote's `priceImpact` as a property in analytics events ([#5721](https://github.com/MetaMask/core/pull/5721)) +- Bump `@metamask/base-controller` from ^8.0.0 to ^8.0.1 ([#5722](https://github.com/MetaMask/core/pull/5722)) +- **BREAKING:** Bump `@metamask/bridge-controller` peer dependency to `^20.0.0` ([#5717](https://github.com/MetaMask/core/pull/5717)) + +## [16.0.0] + +### Changed + +- **BREAKING:** Bump `@metamask/bridge-controller` peer dependency to `^19.0.0` ([#5717](https://github.com/MetaMask/core/pull/5717)) +- Remove `@metamask/assets-controllers` peer dependency ([#5716](https://github.com/MetaMask/core/pull/5716)) + +### Fixed + +- Fixes transaction polling failures caused by adding tokens with the incorrect account address to the TokensControler ([#5716](https://github.com/MetaMask/core/pull/5716)) + +## [15.0.0] + +### Changed + +- **BREAKING:** Bump `@metamask/assets-controllers` peer dependency to `^59.0.0` ([#5712](https://github.com/MetaMask/core/pull/5712)) +- **BREAKING:** Bump `@metamask/bridge-controller` peer dependency to `^18.0.0` ([#5712](https://github.com/MetaMask/core/pull/5712)) + +## [14.0.0] + +### Added + +- **BREAKING:** Add analytics tracking for post-tx submission events ([#5684](https://github.com/MetaMask/core/pull/5684)) +- Add optional `isStxEnabled` property to `BridgeHistoryItem` to indicate whether the transaction was submitted as a smart transaction ([#5684](https://github.com/MetaMask/core/pull/5684)) + +### Changed + +- **BREAKING:** Bump `@metamask/bridge-controller` peer dependency to `^17.0.0` ([#5700](https://github.com/MetaMask/core/pull/5700)) + +### Fixed + +- Fixes missing EVM native exchange rates by not lowercasing the symbol used for lookups ([#5696](https://github.com/MetaMask/core/pull/5696)) +- Fixes occasional snap `handleRequest` errors by setting the request scope to `SolScope.Mainnet` instead of reading it from the account metadata ([#5696](https://github.com/MetaMask/core/pull/5696)) + +## [13.1.0] + +### Fixed + +- Add optional `approvalTxId` to `BridgeHistoryItem` to prevent transaction metadata corruption ([#5670](https://github.com/MetaMask/core/pull/5670)) + - Fixes issue where `updateTransaction` was overwriting transaction metadata when associating approvals + - Stores approval transaction ID in bridge history instead of modifying transaction metadata + - Reduces duplicate quote data in state + +## [13.0.0] + +### Added + +- **BREAKING:** Add `@metamask/snaps-controllers` peer dependency at `^11.0.0` ([#5634](https://github.com/MetaMask/core/pull/5634), [#5639](https://github.com/MetaMask/core/pull/5639)) +- **BREAKING:** Add `@metamask/gas-fee-controller` peer dependency at `^23.0.0` ([#5643](https://github.com/MetaMask/core/pull/5643)) +- **BREAKING:** Add `@metamask/assets-controllers` peer dependency at `^58.0.0` ([#5643](https://github.com/MetaMask/core/pull/5643), [#5672](https://github.com/MetaMask/core/pull/5672)) +- Add `@metamask/user-operation-controller` dependency at `^33.0.0` ([#5643](https://github.com/MetaMask/core/pull/5643)) +- Add `uuid` dependency at `^8.3.2` ([#5634](https://github.com/MetaMask/core/pull/5634)) +- Add `@metamask/keyring-api` dependency at `^17.4.0` ([#5643](https://github.com/MetaMask/core/pull/5643)) +- Add `bignumber.js` dependency at `^9.1.2` ([#5643](https://github.com/MetaMask/core/pull/5643)) +- Add `submitTx` handler that submits cross-chain swaps transactions and triggers polling for destination transaction status ([#5634](https://github.com/MetaMask/core/pull/5634)) +- Enable submitting EVM transactions using `submitTx` ([#5643](https://github.com/MetaMask/core/pull/5643)) +- Add functionality for importing tokens from transaction after successful confirmation ([#5643](https://github.com/MetaMask/core/pull/5643)) + +### Changed + +- **BREAKING** Change `@metamask/bridge-controller` from dependency to peer dependency and bump to `^16.0.0` ([#5657](https://github.com/MetaMask/core/pull/5657), [#5665](https://github.com/MetaMask/core/pull/5665), [#5643](https://github.com/MetaMask/core/pull/5643), [#5672](https://github.com/MetaMask/core/pull/5672)) +- Add optional config.customBridgeApiBaseUrl constructor arg to set the bridge-api base URL ([#5634](https://github.com/MetaMask/core/pull/5634)) +- Add required `addTransactionFn` and `estimateGasFeeFn` args to the BridgeStatusController constructor to enable calling TransactionController's methods from `submitTx` ([#5643](https://github.com/MetaMask/core/pull/5643)) +- Add optional `addUserOperationFromTransactionFn` arg to the BridgeStatusController constructor to enable submitting txs from smart accounts using the UserOperationController's addUserOperationFromTransaction method ([#5643](https://github.com/MetaMask/core/pull/5643)) + +### Fixed + +- Update validators to accept any `bridge` string in the StatusResponse ([#5634](https://github.com/MetaMask/core/pull/5634)) + +## [12.0.1] + +### Fixed + +- Add `relay` to the list of bridges in the `BridgeId` enum to prevent validation from failing ([#5623](https://github.com/MetaMask/core/pull/5623)) + +## [12.0.0] + +### Changed + +- **BREAKING:** Bump `@metamask/transaction-controller` peer dependency to `^54.0.0` ([#5615](https://github.com/MetaMask/core/pull/5615)) + +## [11.0.0] + +### Changed + +- **BREAKING:** Bump `@metamask/transaction-controller` peer dependency to `^53.0.0` ([#5585](https://github.com/MetaMask/core/pull/5585)) +- Bump `@metamask/bridge-controller` dependency to `^11.0.0` ([#5525](https://github.com/MetaMask/core/pull/5525)) +- **BREAKING:** Change controller to fetch multichain address instead of EVM ([#5554](https://github.com/MetaMask/core/pull/5554)) + +## [10.0.0] + +### Changed + +- **BREAKING:** Bump `@metamask/transaction-controller` peer dependency to `^52.0.0` ([#5513](https://github.com/MetaMask/core/pull/5513)) +- Bump `@metamask/bridge-controller` peer dependency to `^10.0.0` ([#5513](https://github.com/MetaMask/core/pull/5513)) + +## [9.0.0] + +### Changed + +- **BREAKING:** Bump peer dependency `@metamask/accounts-controller` to `^27.0.0` ([#5507](https://github.com/MetaMask/core/pull/5507)) +- **BREAKING:** Bump peer dependency `@metamask/network-controller` to `^23.0.0` ([#5507](https://github.com/MetaMask/core/pull/5507)) +- **BREAKING:** Bump peer dependency `@metamask/transaction-controller` to `^51.0.0` ([#5507](https://github.com/MetaMask/core/pull/5507)) +- Bump `@metamask/bridge-controller` to `^9.0.0` ([#5507](https://github.com/MetaMask/core/pull/5507)) +- Bump `@metamask/polling-controller` to `^13.0.0` ([#5507](https://github.com/MetaMask/core/pull/5507)) + +## [8.0.0] + +### Changed + +- **BREAKING:** Bump `@metamask/transaction-controller` peer dependency to `^50.0.0` ([#5496](https://github.com/MetaMask/core/pull/5496)) + +## [7.0.0] + +### Changed + +- Bump `@metamask/accounts-controller` dev dependency to `^26.1.0` ([#5481](https://github.com/MetaMask/core/pull/5481)) +- **BREAKING:** Allow changing the Bridge API url through the `config` param in the constructor. Remove previous method of doing it through `process.env`. ([#5465](https://github.com/MetaMask/core/pull/5465)) + +### Fixed + +- `@metamask/bridge-controller` dependency is no longer a peer dependency, just a direct dependency ([#5464](https://github.com/MetaMask/core/pull/5464)) + +## [6.0.0] + +### Changed + +- **BREAKING:** Bump `@metamask/transaction-controller` peer dependency to `^49.0.0` ([#5471](https://github.com/MetaMask/core/pull/5471)) + +## [5.0.0] + +### Changed + +- **BREAKING:** Bump `@metamask/accounts-controller` peer dependency to `^26.0.0` ([#5439](https://github.com/MetaMask/core/pull/5439)) +- **BREAKING:** Bump `@metamask/transaction-controller` peer dependency to `^48.0.0` ([#5439](https://github.com/MetaMask/core/pull/5439)) +- **BREAKING:** Bump `@metamask/bridge-controller` peer dependency to `^5.0.0` ([#5439](https://github.com/MetaMask/core/pull/5439)) + +## [4.0.0] + +### Changed + +- **BREAKING:** Bump `@metamask/accounts-controller` peer dependency to `^25.0.0` ([#5426](https://github.com/MetaMask/core/pull/5426)) +- **BREAKING:** Bump `@metamask/transaction-controller` peer dependency to `^47.0.0` ([#5426](https://github.com/MetaMask/core/pull/5426)) +- **BREAKING:** Bump `@metamask/bridge-controller` peer dependency to `^4.0.0` ([#5426](https://github.com/MetaMask/core/pull/5426)) + +## [3.0.0] + +### Changed + +- **BREAKING:** Bump `@metamask/bridge-controller` to v3.0.0 +- Improve `BridgeStatusController` API response validation readability by using `@metamask/superstruct` ([#5408](https://github.com/MetaMask/core/pull/5408)) + +## [2.0.0] + +### Changed + +- **BREAKING:** Change `BridgeStatusController` state structure to have all fields at root of state ([#5406](https://github.com/MetaMask/core/pull/5406)) +- **BREAKING:** Redundant type `BridgeStatusState` removed from exports ([#5406](https://github.com/MetaMask/core/pull/5406)) + +## [1.0.0] + +### Added + +- Initial release ([#5317](https://github.com/MetaMask/core/pull/5317)) + +[Unreleased]: https://github.com/MetaMask/core/compare/@metamask/bridge-status-controller@75.4.0...HEAD +[75.4.0]: https://github.com/MetaMask/core/compare/@metamask/bridge-status-controller@75.3.0...@metamask/bridge-status-controller@75.4.0 +[75.3.0]: https://github.com/MetaMask/core/compare/@metamask/bridge-status-controller@75.2.1...@metamask/bridge-status-controller@75.3.0 +[75.2.1]: https://github.com/MetaMask/core/compare/@metamask/bridge-status-controller@75.2.0...@metamask/bridge-status-controller@75.2.1 +[75.2.0]: https://github.com/MetaMask/core/compare/@metamask/bridge-status-controller@75.1.0...@metamask/bridge-status-controller@75.2.0 +[75.1.0]: https://github.com/MetaMask/core/compare/@metamask/bridge-status-controller@75.0.0...@metamask/bridge-status-controller@75.1.0 +[75.0.0]: https://github.com/MetaMask/core/compare/@metamask/bridge-status-controller@74.6.2...@metamask/bridge-status-controller@75.0.0 +[74.6.2]: https://github.com/MetaMask/core/compare/@metamask/bridge-status-controller@74.6.1...@metamask/bridge-status-controller@74.6.2 +[74.6.1]: https://github.com/MetaMask/core/compare/@metamask/bridge-status-controller@74.6.0...@metamask/bridge-status-controller@74.6.1 +[74.6.0]: https://github.com/MetaMask/core/compare/@metamask/bridge-status-controller@74.5.0...@metamask/bridge-status-controller@74.6.0 +[74.5.0]: https://github.com/MetaMask/core/compare/@metamask/bridge-status-controller@74.4.0...@metamask/bridge-status-controller@74.5.0 +[74.4.0]: https://github.com/MetaMask/core/compare/@metamask/bridge-status-controller@74.3.0...@metamask/bridge-status-controller@74.4.0 +[74.3.0]: https://github.com/MetaMask/core/compare/@metamask/bridge-status-controller@74.2.0...@metamask/bridge-status-controller@74.3.0 +[74.2.0]: https://github.com/MetaMask/core/compare/@metamask/bridge-status-controller@74.1.2...@metamask/bridge-status-controller@74.2.0 +[74.1.2]: https://github.com/MetaMask/core/compare/@metamask/bridge-status-controller@74.1.1...@metamask/bridge-status-controller@74.1.2 +[74.1.1]: https://github.com/MetaMask/core/compare/@metamask/bridge-status-controller@74.1.0...@metamask/bridge-status-controller@74.1.1 +[74.1.0]: https://github.com/MetaMask/core/compare/@metamask/bridge-status-controller@74.0.2...@metamask/bridge-status-controller@74.1.0 +[74.0.2]: https://github.com/MetaMask/core/compare/@metamask/bridge-status-controller@74.0.1...@metamask/bridge-status-controller@74.0.2 +[74.0.1]: https://github.com/MetaMask/core/compare/@metamask/bridge-status-controller@74.0.0...@metamask/bridge-status-controller@74.0.1 +[74.0.0]: https://github.com/MetaMask/core/compare/@metamask/bridge-status-controller@73.1.0...@metamask/bridge-status-controller@74.0.0 +[73.1.0]: https://github.com/MetaMask/core/compare/@metamask/bridge-status-controller@73.0.0...@metamask/bridge-status-controller@73.1.0 +[73.0.0]: https://github.com/MetaMask/core/compare/@metamask/bridge-status-controller@72.3.0...@metamask/bridge-status-controller@73.0.0 +[72.3.0]: https://github.com/MetaMask/core/compare/@metamask/bridge-status-controller@72.2.0...@metamask/bridge-status-controller@72.3.0 +[72.2.0]: https://github.com/MetaMask/core/compare/@metamask/bridge-status-controller@72.1.1...@metamask/bridge-status-controller@72.2.0 +[72.1.1]: https://github.com/MetaMask/core/compare/@metamask/bridge-status-controller@72.1.0...@metamask/bridge-status-controller@72.1.1 +[72.1.0]: https://github.com/MetaMask/core/compare/@metamask/bridge-status-controller@72.0.3...@metamask/bridge-status-controller@72.1.0 +[72.0.3]: https://github.com/MetaMask/core/compare/@metamask/bridge-status-controller@72.0.2...@metamask/bridge-status-controller@72.0.3 +[72.0.2]: https://github.com/MetaMask/core/compare/@metamask/bridge-status-controller@72.0.1...@metamask/bridge-status-controller@72.0.2 +[72.0.1]: https://github.com/MetaMask/core/compare/@metamask/bridge-status-controller@72.0.0...@metamask/bridge-status-controller@72.0.1 +[72.0.0]: https://github.com/MetaMask/core/compare/@metamask/bridge-status-controller@71.2.1...@metamask/bridge-status-controller@72.0.0 +[71.2.1]: https://github.com/MetaMask/core/compare/@metamask/bridge-status-controller@71.2.0...@metamask/bridge-status-controller@71.2.1 +[71.2.0]: https://github.com/MetaMask/core/compare/@metamask/bridge-status-controller@71.1.4...@metamask/bridge-status-controller@71.2.0 +[71.1.4]: https://github.com/MetaMask/core/compare/@metamask/bridge-status-controller@71.1.3...@metamask/bridge-status-controller@71.1.4 +[71.1.3]: https://github.com/MetaMask/core/compare/@metamask/bridge-status-controller@71.1.2...@metamask/bridge-status-controller@71.1.3 +[71.1.2]: https://github.com/MetaMask/core/compare/@metamask/bridge-status-controller@71.1.1...@metamask/bridge-status-controller@71.1.2 +[71.1.1]: https://github.com/MetaMask/core/compare/@metamask/bridge-status-controller@71.1.0...@metamask/bridge-status-controller@71.1.1 +[71.1.0]: https://github.com/MetaMask/core/compare/@metamask/bridge-status-controller@71.0.0...@metamask/bridge-status-controller@71.1.0 +[71.0.0]: https://github.com/MetaMask/core/compare/@metamask/bridge-status-controller@70.0.5...@metamask/bridge-status-controller@71.0.0 +[70.0.5]: https://github.com/MetaMask/core/compare/@metamask/bridge-status-controller@70.0.4...@metamask/bridge-status-controller@70.0.5 +[70.0.4]: https://github.com/MetaMask/core/compare/@metamask/bridge-status-controller@70.0.3...@metamask/bridge-status-controller@70.0.4 +[70.0.3]: https://github.com/MetaMask/core/compare/@metamask/bridge-status-controller@70.0.2...@metamask/bridge-status-controller@70.0.3 +[70.0.2]: https://github.com/MetaMask/core/compare/@metamask/bridge-status-controller@70.0.1...@metamask/bridge-status-controller@70.0.2 +[70.0.1]: https://github.com/MetaMask/core/compare/@metamask/bridge-status-controller@70.0.0...@metamask/bridge-status-controller@70.0.1 +[70.0.0]: https://github.com/MetaMask/core/compare/@metamask/bridge-status-controller@69.0.0...@metamask/bridge-status-controller@70.0.0 +[69.0.0]: https://github.com/MetaMask/core/compare/@metamask/bridge-status-controller@68.1.0...@metamask/bridge-status-controller@69.0.0 +[68.1.0]: https://github.com/MetaMask/core/compare/@metamask/bridge-status-controller@68.0.2...@metamask/bridge-status-controller@68.1.0 +[68.0.2]: https://github.com/MetaMask/core/compare/@metamask/bridge-status-controller@68.0.1...@metamask/bridge-status-controller@68.0.2 +[68.0.1]: https://github.com/MetaMask/core/compare/@metamask/bridge-status-controller@68.0.0...@metamask/bridge-status-controller@68.0.1 +[68.0.0]: https://github.com/MetaMask/core/compare/@metamask/bridge-status-controller@67.0.1...@metamask/bridge-status-controller@68.0.0 +[67.0.1]: https://github.com/MetaMask/core/compare/@metamask/bridge-status-controller@67.0.0...@metamask/bridge-status-controller@67.0.1 +[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 +[64.4.5]: https://github.com/MetaMask/core/compare/@metamask/bridge-status-controller@64.4.4...@metamask/bridge-status-controller@64.4.5 +[64.4.4]: https://github.com/MetaMask/core/compare/@metamask/bridge-status-controller@64.4.3...@metamask/bridge-status-controller@64.4.4 +[64.4.3]: https://github.com/MetaMask/core/compare/@metamask/bridge-status-controller@64.4.2...@metamask/bridge-status-controller@64.4.3 +[64.4.2]: https://github.com/MetaMask/core/compare/@metamask/bridge-status-controller@64.4.1...@metamask/bridge-status-controller@64.4.2 +[64.4.1]: https://github.com/MetaMask/core/compare/@metamask/bridge-status-controller@64.4.0...@metamask/bridge-status-controller@64.4.1 +[64.4.0]: https://github.com/MetaMask/core/compare/@metamask/bridge-status-controller@64.3.0...@metamask/bridge-status-controller@64.4.0 +[64.3.0]: https://github.com/MetaMask/core/compare/@metamask/bridge-status-controller@64.2.0...@metamask/bridge-status-controller@64.3.0 +[64.2.0]: https://github.com/MetaMask/core/compare/@metamask/bridge-status-controller@64.1.0...@metamask/bridge-status-controller@64.2.0 +[64.1.0]: https://github.com/MetaMask/core/compare/@metamask/bridge-status-controller@64.0.1...@metamask/bridge-status-controller@64.1.0 +[64.0.1]: https://github.com/MetaMask/core/compare/@metamask/bridge-status-controller@64.0.0...@metamask/bridge-status-controller@64.0.1 +[64.0.0]: https://github.com/MetaMask/core/compare/@metamask/bridge-status-controller@63.1.0...@metamask/bridge-status-controller@64.0.0 +[63.1.0]: https://github.com/MetaMask/core/compare/@metamask/bridge-status-controller@63.0.0...@metamask/bridge-status-controller@63.1.0 +[63.0.0]: https://github.com/MetaMask/core/compare/@metamask/bridge-status-controller@62.0.0...@metamask/bridge-status-controller@63.0.0 +[62.0.0]: https://github.com/MetaMask/core/compare/@metamask/bridge-status-controller@61.0.0...@metamask/bridge-status-controller@62.0.0 +[61.0.0]: https://github.com/MetaMask/core/compare/@metamask/bridge-status-controller@60.1.0...@metamask/bridge-status-controller@61.0.0 +[60.1.0]: https://github.com/MetaMask/core/compare/@metamask/bridge-status-controller@60.0.0...@metamask/bridge-status-controller@60.1.0 +[60.0.0]: https://github.com/MetaMask/core/compare/@metamask/bridge-status-controller@59.0.0...@metamask/bridge-status-controller@60.0.0 +[59.0.0]: https://github.com/MetaMask/core/compare/@metamask/bridge-status-controller@58.0.0...@metamask/bridge-status-controller@59.0.0 +[58.0.0]: https://github.com/MetaMask/core/compare/@metamask/bridge-status-controller@57.0.0...@metamask/bridge-status-controller@58.0.0 +[57.0.0]: https://github.com/MetaMask/core/compare/@metamask/bridge-status-controller@56.0.0...@metamask/bridge-status-controller@57.0.0 +[56.0.0]: https://github.com/MetaMask/core/compare/@metamask/bridge-status-controller@55.0.0...@metamask/bridge-status-controller@56.0.0 +[55.0.0]: https://github.com/MetaMask/core/compare/@metamask/bridge-status-controller@54.0.0...@metamask/bridge-status-controller@55.0.0 +[54.0.0]: https://github.com/MetaMask/core/compare/@metamask/bridge-status-controller@53.0.0...@metamask/bridge-status-controller@54.0.0 +[53.0.0]: https://github.com/MetaMask/core/compare/@metamask/bridge-status-controller@52.1.0...@metamask/bridge-status-controller@53.0.0 +[52.1.0]: https://github.com/MetaMask/core/compare/@metamask/bridge-status-controller@52.0.0...@metamask/bridge-status-controller@52.1.0 +[52.0.0]: https://github.com/MetaMask/core/compare/@metamask/bridge-status-controller@51.0.0...@metamask/bridge-status-controller@52.0.0 +[51.0.0]: https://github.com/MetaMask/core/compare/@metamask/bridge-status-controller@50.1.0...@metamask/bridge-status-controller@51.0.0 +[50.1.0]: https://github.com/MetaMask/core/compare/@metamask/bridge-status-controller@50.0.0...@metamask/bridge-status-controller@50.1.0 +[50.0.0]: https://github.com/MetaMask/core/compare/@metamask/bridge-status-controller@49.0.1...@metamask/bridge-status-controller@50.0.0 +[49.0.1]: https://github.com/MetaMask/core/compare/@metamask/bridge-status-controller@49.0.0...@metamask/bridge-status-controller@49.0.1 +[49.0.0]: https://github.com/MetaMask/core/compare/@metamask/bridge-status-controller@48.0.0...@metamask/bridge-status-controller@49.0.0 +[48.0.0]: https://github.com/MetaMask/core/compare/@metamask/bridge-status-controller@47.2.0...@metamask/bridge-status-controller@48.0.0 +[47.2.0]: https://github.com/MetaMask/core/compare/@metamask/bridge-status-controller@47.1.0...@metamask/bridge-status-controller@47.2.0 +[47.1.0]: https://github.com/MetaMask/core/compare/@metamask/bridge-status-controller@47.0.0...@metamask/bridge-status-controller@47.1.0 +[47.0.0]: https://github.com/MetaMask/core/compare/@metamask/bridge-status-controller@46.0.0...@metamask/bridge-status-controller@47.0.0 +[46.0.0]: https://github.com/MetaMask/core/compare/@metamask/bridge-status-controller@45.0.0...@metamask/bridge-status-controller@46.0.0 +[45.0.0]: https://github.com/MetaMask/core/compare/@metamask/bridge-status-controller@44.1.0...@metamask/bridge-status-controller@45.0.0 +[44.1.0]: https://github.com/MetaMask/core/compare/@metamask/bridge-status-controller@44.0.0...@metamask/bridge-status-controller@44.1.0 +[44.0.0]: https://github.com/MetaMask/core/compare/@metamask/bridge-status-controller@43.1.0...@metamask/bridge-status-controller@44.0.0 +[43.1.0]: https://github.com/MetaMask/core/compare/@metamask/bridge-status-controller@43.0.0...@metamask/bridge-status-controller@43.1.0 +[43.0.0]: https://github.com/MetaMask/core/compare/@metamask/bridge-status-controller@42.0.0...@metamask/bridge-status-controller@43.0.0 +[42.0.0]: https://github.com/MetaMask/core/compare/@metamask/bridge-status-controller@41.0.0...@metamask/bridge-status-controller@42.0.0 +[41.0.0]: https://github.com/MetaMask/core/compare/@metamask/bridge-status-controller@40.2.0...@metamask/bridge-status-controller@41.0.0 +[40.2.0]: https://github.com/MetaMask/core/compare/@metamask/bridge-status-controller@40.1.0...@metamask/bridge-status-controller@40.2.0 +[40.1.0]: https://github.com/MetaMask/core/compare/@metamask/bridge-status-controller@40.0.0...@metamask/bridge-status-controller@40.1.0 +[40.0.0]: https://github.com/MetaMask/core/compare/@metamask/bridge-status-controller@39.0.0...@metamask/bridge-status-controller@40.0.0 +[39.0.0]: https://github.com/MetaMask/core/compare/@metamask/bridge-status-controller@38.1.0...@metamask/bridge-status-controller@39.0.0 +[38.1.0]: https://github.com/MetaMask/core/compare/@metamask/bridge-status-controller@38.0.1...@metamask/bridge-status-controller@38.1.0 +[38.0.1]: https://github.com/MetaMask/core/compare/@metamask/bridge-status-controller@38.0.0...@metamask/bridge-status-controller@38.0.1 +[38.0.0]: https://github.com/MetaMask/core/compare/@metamask/bridge-status-controller@37.0.1...@metamask/bridge-status-controller@38.0.0 +[37.0.1]: https://github.com/MetaMask/core/compare/@metamask/bridge-status-controller@37.0.0...@metamask/bridge-status-controller@37.0.1 +[37.0.0]: https://github.com/MetaMask/core/compare/@metamask/bridge-status-controller@36.1.0...@metamask/bridge-status-controller@37.0.0 +[36.1.0]: https://github.com/MetaMask/core/compare/@metamask/bridge-status-controller@36.0.0...@metamask/bridge-status-controller@36.1.0 +[36.0.0]: https://github.com/MetaMask/core/compare/@metamask/bridge-status-controller@35.0.0...@metamask/bridge-status-controller@36.0.0 +[35.0.0]: https://github.com/MetaMask/core/compare/@metamask/bridge-status-controller@34.0.0...@metamask/bridge-status-controller@35.0.0 +[34.0.0]: https://github.com/MetaMask/core/compare/@metamask/bridge-status-controller@33.0.0...@metamask/bridge-status-controller@34.0.0 +[33.0.0]: https://github.com/MetaMask/core/compare/@metamask/bridge-status-controller@32.0.0...@metamask/bridge-status-controller@33.0.0 +[32.0.0]: https://github.com/MetaMask/core/compare/@metamask/bridge-status-controller@31.0.0...@metamask/bridge-status-controller@32.0.0 +[31.0.0]: https://github.com/MetaMask/core/compare/@metamask/bridge-status-controller@30.0.0...@metamask/bridge-status-controller@31.0.0 +[30.0.0]: https://github.com/MetaMask/core/compare/@metamask/bridge-status-controller@29.1.1...@metamask/bridge-status-controller@30.0.0 +[29.1.1]: https://github.com/MetaMask/core/compare/@metamask/bridge-status-controller@29.1.0...@metamask/bridge-status-controller@29.1.1 +[29.1.0]: https://github.com/MetaMask/core/compare/@metamask/bridge-status-controller@29.0.0...@metamask/bridge-status-controller@29.1.0 +[29.0.0]: https://github.com/MetaMask/core/compare/@metamask/bridge-status-controller@28.0.0...@metamask/bridge-status-controller@29.0.0 +[28.0.0]: https://github.com/MetaMask/core/compare/@metamask/bridge-status-controller@27.0.0...@metamask/bridge-status-controller@28.0.0 +[27.0.0]: https://github.com/MetaMask/core/compare/@metamask/bridge-status-controller@26.0.0...@metamask/bridge-status-controller@27.0.0 +[26.0.0]: https://github.com/MetaMask/core/compare/@metamask/bridge-status-controller@25.0.0...@metamask/bridge-status-controller@26.0.0 +[25.0.0]: https://github.com/MetaMask/core/compare/@metamask/bridge-status-controller@24.0.0...@metamask/bridge-status-controller@25.0.0 +[24.0.0]: https://github.com/MetaMask/core/compare/@metamask/bridge-status-controller@23.0.0...@metamask/bridge-status-controller@24.0.0 +[23.0.0]: https://github.com/MetaMask/core/compare/@metamask/bridge-status-controller@22.0.0...@metamask/bridge-status-controller@23.0.0 +[22.0.0]: https://github.com/MetaMask/core/compare/@metamask/bridge-status-controller@21.0.0...@metamask/bridge-status-controller@22.0.0 +[21.0.0]: https://github.com/MetaMask/core/compare/@metamask/bridge-status-controller@20.1.0...@metamask/bridge-status-controller@21.0.0 +[20.1.0]: https://github.com/MetaMask/core/compare/@metamask/bridge-status-controller@20.0.0...@metamask/bridge-status-controller@20.1.0 +[20.0.0]: https://github.com/MetaMask/core/compare/@metamask/bridge-status-controller@19.0.0...@metamask/bridge-status-controller@20.0.0 +[19.0.0]: https://github.com/MetaMask/core/compare/@metamask/bridge-status-controller@18.0.0...@metamask/bridge-status-controller@19.0.0 +[18.0.0]: https://github.com/MetaMask/core/compare/@metamask/bridge-status-controller@17.0.1...@metamask/bridge-status-controller@18.0.0 +[17.0.1]: https://github.com/MetaMask/core/compare/@metamask/bridge-status-controller@17.0.0...@metamask/bridge-status-controller@17.0.1 +[17.0.0]: https://github.com/MetaMask/core/compare/@metamask/bridge-status-controller@16.0.0...@metamask/bridge-status-controller@17.0.0 +[16.0.0]: https://github.com/MetaMask/core/compare/@metamask/bridge-status-controller@15.0.0...@metamask/bridge-status-controller@16.0.0 +[15.0.0]: https://github.com/MetaMask/core/compare/@metamask/bridge-status-controller@14.0.0...@metamask/bridge-status-controller@15.0.0 +[14.0.0]: https://github.com/MetaMask/core/compare/@metamask/bridge-status-controller@13.1.0...@metamask/bridge-status-controller@14.0.0 +[13.1.0]: https://github.com/MetaMask/core/compare/@metamask/bridge-status-controller@13.0.0...@metamask/bridge-status-controller@13.1.0 +[13.0.0]: https://github.com/MetaMask/core/compare/@metamask/bridge-status-controller@12.0.1...@metamask/bridge-status-controller@13.0.0 +[12.0.1]: https://github.com/MetaMask/core/compare/@metamask/bridge-status-controller@12.0.0...@metamask/bridge-status-controller@12.0.1 +[12.0.0]: https://github.com/MetaMask/core/compare/@metamask/bridge-status-controller@11.0.0...@metamask/bridge-status-controller@12.0.0 +[11.0.0]: https://github.com/MetaMask/core/compare/@metamask/bridge-status-controller@10.0.0...@metamask/bridge-status-controller@11.0.0 +[10.0.0]: https://github.com/MetaMask/core/compare/@metamask/bridge-status-controller@9.0.0...@metamask/bridge-status-controller@10.0.0 +[9.0.0]: https://github.com/MetaMask/core/compare/@metamask/bridge-status-controller@8.0.0...@metamask/bridge-status-controller@9.0.0 +[8.0.0]: https://github.com/MetaMask/core/compare/@metamask/bridge-status-controller@7.0.0...@metamask/bridge-status-controller@8.0.0 +[7.0.0]: https://github.com/MetaMask/core/compare/@metamask/bridge-status-controller@6.0.0...@metamask/bridge-status-controller@7.0.0 +[6.0.0]: https://github.com/MetaMask/core/compare/@metamask/bridge-status-controller@5.0.0...@metamask/bridge-status-controller@6.0.0 +[5.0.0]: https://github.com/MetaMask/core/compare/@metamask/bridge-status-controller@4.0.0...@metamask/bridge-status-controller@5.0.0 +[4.0.0]: https://github.com/MetaMask/core/compare/@metamask/bridge-status-controller@3.0.0...@metamask/bridge-status-controller@4.0.0 +[3.0.0]: https://github.com/MetaMask/core/compare/@metamask/bridge-status-controller@2.0.0...@metamask/bridge-status-controller@3.0.0 +[2.0.0]: https://github.com/MetaMask/core/compare/@metamask/bridge-status-controller@1.0.0...@metamask/bridge-status-controller@2.0.0 +[1.0.0]: https://github.com/MetaMask/core/releases/tag/@metamask/bridge-status-controller@1.0.0 diff --git a/packages/bridge-status-controller/LICENSE b/packages/bridge-status-controller/LICENSE new file mode 100644 index 00000000000..c259cd7ebcf --- /dev/null +++ b/packages/bridge-status-controller/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2025 MetaMask + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packages/bridge-status-controller/README.md b/packages/bridge-status-controller/README.md new file mode 100644 index 00000000000..3c364ca0571 --- /dev/null +++ b/packages/bridge-status-controller/README.md @@ -0,0 +1,15 @@ +# `@metamask/bridge-status-controller` + +Manages bridge-related status fetching functionality for MetaMask. + +## Installation + +`yarn add @metamask/bridge-status-controller` + +or + +`npm install @metamask/bridge-status-controller` + +## Contributing + +This package is part of a monorepo. Instructions for contributing can be found in the [monorepo README](https://github.com/MetaMask/core#readme). diff --git a/packages/bridge-status-controller/jest.config.js b/packages/bridge-status-controller/jest.config.js new file mode 100644 index 00000000000..86e3fc3cfeb --- /dev/null +++ b/packages/bridge-status-controller/jest.config.js @@ -0,0 +1,48 @@ +/* + * 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, + + coverageProvider: 'v8', + + coveragePathIgnorePatterns: [ + ...baseConfig.coveragePathIgnorePatterns, + '.*/strategy/types\\.ts$', + '.*/quote-status-manager/types\\.ts$', + '.*/index\\.ts', + '.*-method-action-types\\.ts', + ], + + // An object that configures minimum threshold enforcement for coverage results + coverageThreshold: { + './src/bridge-status-controller.ts': { + branches: 100, + functions: 100, + lines: 100, + statements: 100, + }, + './src/bridge-status-controller.intent.ts': { + branches: 100, + functions: 100, + lines: 100, + statements: 100, + }, + global: { + branches: 96.5, + functions: 100, + lines: 100, + statements: 100, + }, + }, +}); diff --git a/packages/bridge-status-controller/package.json b/packages/bridge-status-controller/package.json new file mode 100644 index 00000000000..87556402052 --- /dev/null +++ b/packages/bridge-status-controller/package.json @@ -0,0 +1,92 @@ +{ + "name": "@metamask/bridge-status-controller", + "version": "75.4.0", + "description": "Manages bridge-related status fetching functionality for MetaMask", + "keywords": [ + "Ethereum", + "MetaMask" + ], + "homepage": "https://github.com/MetaMask/core/tree/main/packages/bridge-status-controller#readme", + "bugs": { + "url": "https://github.com/MetaMask/core/issues" + }, + "license": "MIT", + "repository": { + "type": "git", + "url": "https://github.com/MetaMask/core.git" + }, + "files": [ + "dist/" + ], + "sideEffects": false, + "main": "./dist/index.cjs", + "types": "./dist/index.d.cts", + "exports": { + ".": { + "import": { + "types": "./dist/index.d.mts", + "default": "./dist/index.mjs" + }, + "require": { + "types": "./dist/index.d.cts", + "default": "./dist/index.cjs" + } + }, + "./package.json": "./package.json" + }, + "publishConfig": { + "access": "public", + "registry": "https://registry.npmjs.org/" + }, + "scripts": { + "build": "ts-bridge --project tsconfig.build.json --verbose --clean --no-references", + "build:all": "ts-bridge --project tsconfig.build.json --verbose --clean", + "build:docs": "typedoc", + "changelog:update": "../../scripts/update-changelog.sh @metamask/bridge-status-controller", + "changelog:validate": "../../scripts/validate-changelog.sh @metamask/bridge-status-controller", + "generate-method-action-types": "tsx ../../packages/messenger/src/generate-action-types/cli.ts", + "lint:tsconfigs": "tsx ../../scripts/lint-tsconfigs/lint-tsconfigs.mts", + "lint:tsconfigs:fix": "tsx ../../scripts/lint-tsconfigs/lint-tsconfigs.mts --fix", + "since-latest-release": "../../scripts/since-latest-release.sh", + "test": "NODE_OPTIONS=--experimental-vm-modules jest --reporters=jest-silent-reporter", + "test:clean": "NODE_OPTIONS=--experimental-vm-modules jest --clearCache", + "test:verbose": "NODE_OPTIONS=--experimental-vm-modules jest --verbose", + "test:watch": "NODE_OPTIONS=--experimental-vm-modules jest --watch" + }, + "dependencies": { + "@metamask/accounts-controller": "^39.1.1", + "@metamask/base-controller": "^9.1.0", + "@metamask/bridge-controller": "^80.1.0", + "@metamask/controller-utils": "^12.3.0", + "@metamask/gas-fee-controller": "^26.3.2", + "@metamask/keyring-controller": "^27.1.1", + "@metamask/messenger": "^2.0.0", + "@metamask/network-controller": "^36.0.0", + "@metamask/polling-controller": "^16.0.9", + "@metamask/profile-sync-controller": "^29.0.0", + "@metamask/snaps-controllers": "^19.0.0", + "@metamask/superstruct": "^3.4.1", + "@metamask/transaction-controller": "^69.6.1", + "@metamask/utils": "^11.11.0", + "bignumber.js": "^9.1.2", + "uuid": "^8.3.2" + }, + "devDependencies": { + "@metamask/auto-changelog": "^6.1.0", + "@ts-bridge/cli": "^0.6.4", + "@types/jest": "^30.0.0", + "deepmerge": "^4.2.2", + "jest": "^30.4.2", + "jest-environment-jsdom": "^30.4.1", + "lodash": "^4.17.21", + "nock": "^13.3.1", + "ts-jest": "^29.4.11", + "tsx": "^4.20.5", + "typedoc": "^0.25.13", + "typedoc-plugin-missing-exports": "^2.0.0", + "typescript": "~5.3.3" + }, + "engines": { + "node": "^18.18 || >=20" + } +} diff --git a/packages/bridge-status-controller/src/__snapshots__/bridge-status-controller.test.ts.snap b/packages/bridge-status-controller/src/__snapshots__/bridge-status-controller.test.ts.snap new file mode 100644 index 00000000000..406a3771e5a --- /dev/null +++ b/packages/bridge-status-controller/src/__snapshots__/bridge-status-controller.test.ts.snap @@ -0,0 +1,6749 @@ +// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing + +exports[`BridgeStatusController constructor rehydrates the tx history state 1`] = ` +{ + "bridgeTxMetaId1": { + "account": "0xaccount1", + "actionId": undefined, + "approvalTxId": undefined, + "attempts": undefined, + "batchId": undefined, + "completionTime": undefined, + "estimatedProcessingTimeInSeconds": 15, + "featureId": undefined, + "hasApprovalTx": false, + "initialDestAssetBalance": undefined, + "isStxEnabled": false, + "location": "Main View", + "originalTransactionId": "bridgeTxMetaId1", + "pricingData": { + "amountSent": "1.234", + "amountSentInUsd": undefined, + "quotedGasAmount": "1.234", + "quotedGasInUsd": "2.5778", + "quotedReturnInUsd": undefined, + }, + "quote": { + "bridgeId": "lifi", + "bridges": [ + "across", + ], + "destAsset": { + "address": "0x0000000000000000000000000000000000000000", + "assetId": "eip155:10/slip44:60", + "chainId": 10, + "coinKey": "ETH", + "decimals": 18, + "icon": "https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png", + "logoURI": "https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png", + "name": "ETH", + "priceUSD": "2478.63", + "symbol": "ETH", + }, + "destChainId": 10, + "destTokenAmount": "990654755978612", + "feeData": { + "metabridge": { + "amount": "8750000000000", + "asset": { + "address": "0x0000000000000000000000000000000000000000", + "assetId": "eip155:42161/slip44:60", + "chainId": 42161, + "coinKey": "ETH", + "decimals": 18, + "icon": "https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png", + "logoURI": "https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png", + "name": "ETH", + "priceUSD": "2478.7", + "symbol": "ETH", + }, + }, + }, + "minDestTokenAmount": "941000000000000", + "requestId": "197c402f-cb96-4096-9f8c-54aed84ca776", + "srcAsset": { + "address": "0x0000000000000000000000000000000000000000", + "assetId": "eip155:42161/slip44:60", + "chainId": 42161, + "coinKey": "ETH", + "decimals": 18, + "icon": "https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png", + "logoURI": "https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png", + "name": "ETH", + "priceUSD": "2478.7", + "symbol": "ETH", + }, + "srcChainId": 42161, + "srcTokenAmount": "991250000000000", + "steps": [ + { + "action": "bridge", + "destAmount": "990654755978612", + "destAsset": { + "address": "0x0000000000000000000000000000000000000000", + "assetId": "eip155:10/slip44:60", + "chainId": 10, + "coinKey": "ETH", + "decimals": 18, + "icon": "https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png", + "logoURI": "https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png", + "name": "ETH", + "priceUSD": "2478.63", + "symbol": "ETH", + }, + "destChainId": 10, + "protocol": { + "displayName": "Across", + "icon": "https://raw.githubusercontent.com/lifinance/types/main/src/assets/icons/bridges/acrossv2.png", + "name": "across", + }, + "srcAmount": "991250000000000", + "srcAsset": { + "address": "0x0000000000000000000000000000000000000000", + "assetId": "eip155:42161/slip44:60", + "chainId": 42161, + "coinKey": "ETH", + "decimals": 18, + "icon": "https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png", + "logoURI": "https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png", + "name": "ETH", + "priceUSD": "2478.7", + "symbol": "ETH", + }, + "srcChainId": 42161, + }, + ], + }, + "quoteId": undefined, + "slippagePercentage": 0, + "startTime": 1729964825189, + "status": { + "destChain": { + "chainId": 10, + "token": {}, + }, + "srcChain": { + "amount": "991250000000000", + "chainId": 42161, + "token": { + "address": "0x0000000000000000000000000000000000000000", + "chainId": 42161, + "coinKey": "ETH", + "decimals": 18, + "icon": "https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png", + "logoURI": "https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png", + "name": "ETH", + "priceUSD": "2518.47", + "symbol": "ETH", + }, + "txHash": "0xsrcTxHash1", + }, + "status": "PENDING", + }, + "targetContractAddress": "0x23981fC34e69eeDFE2BD9a0a9fCb0719Fe09DbFC", + "txMetaId": "bridgeTxMetaId1", + }, +} +`; + +exports[`BridgeStatusController constructor when history has no tx hash, has txMeta and is older than 2 days: provider tx receipt calls 1`] = ` +[ + { + "method": "eth_getTransactionReceipt", + "params": [ + "0xsrcTxHash3", + ], + }, +] +`; + +exports[`BridgeStatusController constructor when history has no tx hash, no txMeta and is older than 2 days: provider tx receipt calls 1`] = `[]`; + +exports[`BridgeStatusController constructor when history has no txHash, no txMeta, provider returns no receipt and is older than 2 days: provider tx receipt calls 1`] = `[]`; + +exports[`BridgeStatusController constructor when history has no txHash, no txMeta, provider returns receipt and is older than 2 days: provider tx receipt calls 1`] = `[]`; + +exports[`BridgeStatusController constructor when history has no txHash, no txMeta, provider throws error and is older than 2 days: provider tx receipt calls 1`] = `[]`; + +exports[`BridgeStatusController constructor when history has solana srcChainId, no tx hash, no txMeta and is older than 2 days: provider tx receipt calls 1`] = `[]`; + +exports[`BridgeStatusController constructor when history has tx hash, no provider and is older than 2 days: provider tx receipt calls 1`] = `[]`; + +exports[`BridgeStatusController constructor when history has tx hash, provider returns no receipt and is older than 2 days: provider tx receipt calls 1`] = ` +[ + { + "method": "eth_getTransactionReceipt", + "params": [ + "0xsrcTxHash2", + ], + }, +] +`; + +exports[`BridgeStatusController constructor when history has tx hash, provider returns receipt and is older than 2 days: provider tx receipt calls 1`] = ` +[ + { + "method": "eth_getTransactionReceipt", + "params": [ + "0xsrcTxHash2", + ], + }, +] +`; + +exports[`BridgeStatusController constructor when history has tx hash, provider returns receipt, status is complete and is older than 2 days: provider tx receipt calls 1`] = `[]`; + +exports[`BridgeStatusController constructor when history has tx hash, provider throws error and is older than 2 days: provider tx receipt calls 1`] = ` +[ + { + "method": "eth_getTransactionReceipt", + "params": [ + "0xsrcTxHash2", + ], + }, +] +`; + +exports[`BridgeStatusController startPollingForBridgeTxStatus emits bridgeTransactionFailed event when the status response is failed 1`] = ` +[ + [ + "AuthenticationController:getBearerToken", + ], + [ + "AccountsController:getAccountByAddress", + "0xaccount1", + ], + [ + "TransactionController:getState", + ], + [ + "BridgeController:trackUnifiedSwapBridgeEvent", + "Unified SwapBridge Failed", + { + "account_hardware_type": null, + "action_type": "swapbridge-v1", + "actual_time_minutes": 105213.34261666666, + "allowance_reset_transaction": undefined, + "approval_transaction": undefined, + "chain_id_destination": "eip155:10", + "chain_id_source": "eip155:42161", + "custom_slippage": false, + "destination_transaction": "FAILED", + "feature_id": "unified_swap_bridge", + "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": [], + "slippage_limit": 0, + "source_transaction": "COMPLETE", + "stx_enabled": false, + "swap_type": "crosschain", + "token_address_destination": "eip155:10/slip44:60", + "token_address_source": "eip155:42161/slip44:60", + "token_security_type_destination": null, + "token_symbol_destination": "ETH", + "token_symbol_source": "ETH", + "usd_actual_gas": 0, + "usd_actual_return": 0, + "usd_amount_source": 0, + "usd_quoted_gas": 2.5778, + "usd_quoted_return": 0, + }, + ], +] +`; + +exports[`BridgeStatusController startPollingForBridgeTxStatus sets the inital tx history state 1`] = ` +{ + "bridgeTxMetaId1": { + "account": "0xaccount1", + "actionId": undefined, + "approvalTxId": undefined, + "batchId": undefined, + "estimatedProcessingTimeInSeconds": 15, + "featureId": undefined, + "hasApprovalTx": false, + "initialDestAssetBalance": undefined, + "isStxEnabled": false, + "location": "Main View", + "originalTransactionId": "bridgeTxMetaId1", + "pricingData": { + "amountSent": "1.234", + "amountSentInUsd": undefined, + "quotedGasAmount": "1.234", + "quotedGasInUsd": "2.5778", + "quotedReturnInUsd": undefined, + }, + "quote": { + "bridgeId": "lifi", + "bridges": [ + "across", + ], + "destAsset": { + "address": "0x0000000000000000000000000000000000000000", + "assetId": "eip155:10/slip44:60", + "chainId": 10, + "coinKey": "ETH", + "decimals": 18, + "icon": "https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png", + "logoURI": "https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png", + "name": "ETH", + "priceUSD": "2478.63", + "symbol": "ETH", + }, + "destChainId": 10, + "destTokenAmount": "990654755978612", + "feeData": { + "metabridge": { + "amount": "8750000000000", + "asset": { + "address": "0x0000000000000000000000000000000000000000", + "assetId": "eip155:42161/slip44:60", + "chainId": 42161, + "coinKey": "ETH", + "decimals": 18, + "icon": "https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png", + "logoURI": "https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png", + "name": "ETH", + "priceUSD": "2478.7", + "symbol": "ETH", + }, + }, + }, + "minDestTokenAmount": "941000000000000", + "requestId": "197c402f-cb96-4096-9f8c-54aed84ca776", + "srcAsset": { + "address": "0x0000000000000000000000000000000000000000", + "assetId": "eip155:42161/slip44:60", + "chainId": 42161, + "coinKey": "ETH", + "decimals": 18, + "icon": "https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png", + "logoURI": "https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png", + "name": "ETH", + "priceUSD": "2478.7", + "symbol": "ETH", + }, + "srcChainId": 42161, + "srcTokenAmount": "991250000000000", + "steps": [ + { + "action": "bridge", + "destAmount": "990654755978612", + "destAsset": { + "address": "0x0000000000000000000000000000000000000000", + "assetId": "eip155:10/slip44:60", + "chainId": 10, + "coinKey": "ETH", + "decimals": 18, + "icon": "https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png", + "logoURI": "https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png", + "name": "ETH", + "priceUSD": "2478.63", + "symbol": "ETH", + }, + "destChainId": 10, + "protocol": { + "displayName": "Across", + "icon": "https://raw.githubusercontent.com/lifinance/types/main/src/assets/icons/bridges/acrossv2.png", + "name": "across", + }, + "srcAmount": "991250000000000", + "srcAsset": { + "address": "0x0000000000000000000000000000000000000000", + "assetId": "eip155:42161/slip44:60", + "chainId": 42161, + "coinKey": "ETH", + "decimals": 18, + "icon": "https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png", + "logoURI": "https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png", + "name": "ETH", + "priceUSD": "2478.7", + "symbol": "ETH", + }, + "srcChainId": 42161, + }, + ], + }, + "quoteId": undefined, + "slippagePercentage": 0, + "startTime": 1729964825189, + "status": { + "srcChain": { + "chainId": 42161, + "txHash": "0xsrcTxHash1", + }, + "status": "PENDING", + }, + "targetContractAddress": "0x23981fC34e69eeDFE2BD9a0a9fCb0719Fe09DbFC", + "txMetaId": "bridgeTxMetaId1", + }, +} +`; + +exports[`BridgeStatusController startPollingForBridgeTxStatus stops polling when the status response is complete 1`] = ` +[ + [ + "TransactionController:getState", + ], + [ + "AuthenticationController:getBearerToken", + ], + [ + "AccountsController:getAccountByAddress", + "0xaccount1", + ], + [ + "TransactionController:getState", + ], + [ + "BridgeController:trackUnifiedSwapBridgeEvent", + "Unified SwapBridge Completed", + { + "account_hardware_type": null, + "action_type": "swapbridge-v1", + "actual_time_minutes": 105213.34261666666, + "allowance_reset_transaction": undefined, + "approval_transaction": undefined, + "chain_id_destination": "eip155:10", + "chain_id_source": "eip155:42161", + "custom_slippage": false, + "destination_transaction": "COMPLETE", + "feature_id": "unified_swap_bridge", + "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": [], + "slippage_limit": 0, + "source_transaction": "COMPLETE", + "stx_enabled": true, + "swap_type": "crosschain", + "token_address_destination": "eip155:10/slip44:60", + "token_address_source": "eip155:42161/slip44:60", + "token_security_type_destination": null, + "token_symbol_destination": "ETH", + "token_symbol_source": "ETH", + "transaction_internal_id": "bridgeTxMetaId1", + "usd_actual_gas": 0, + "usd_actual_return": 0, + "usd_amount_source": 0, + "usd_quoted_gas": 2.5778, + "usd_quoted_return": 0, + }, + ], +] +`; + +exports[`BridgeStatusController startPollingForBridgeTxStatus stops polling when the status response is complete 2`] = ` +[ + "BridgeStatusController:destinationTransactionCompleted", + "eip155:10/slip44:60", +] +`; + +exports[`BridgeStatusController submitTx: EVM bridge should delay after submitting base approval 1`] = ` +{ + "chainId": "0xa4b1", + "hash": "0xevmTxHash", + "id": "test-tx-id", + "status": "unapproved", + "time": 1234567890, + "txParams": { + "chainId": "0xa4b1", + "data": "0xdata", + "from": "0xaccount1", + "gasLimit": "0x5208", + "to": "0xbridgeContract", + "value": "0x0", + }, + "txReceipt": { + "effectiveGasPrice": "0x1880a", + "gasUsed": "0x2c92a", + }, + "type": "bridge", +} +`; + +exports[`BridgeStatusController submitTx: EVM bridge should delay after submitting base approval 2`] = ` +{ + "account": "0xaccount1", + "actionId": "1234567892.457", + "approvalTxId": "test-approval-tx-id", + "batchId": undefined, + "estimatedProcessingTimeInSeconds": 15, + "featureId": undefined, + "hasApprovalTx": true, + "initialDestAssetBalance": undefined, + "isStxEnabled": false, + "location": "Unknown", + "originalTransactionId": "test-tx-id", + "pricingData": { + "amountSent": "1.234", + "amountSentInUsd": "1.01", + "quotedGasAmount": ".00055", + "quotedGasInUsd": "2.5778", + "quotedReturnInUsd": "0.134214", + }, + "quote": { + "bridgeId": "lifi", + "bridges": [ + "across", + ], + "destAsset": { + "address": "0x0000000000000000000000000000000000000000", + "assetId": "eip155:10/slip44:60", + "chainId": 10, + "coinKey": "ETH", + "decimals": 18, + "icon": "https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png", + "logoURI": "https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png", + "name": "ETH", + "priceUSD": "2478.63", + "symbol": "ETH", + }, + "destChainId": 10, + "destTokenAmount": "990654755978612", + "feeData": { + "metabridge": { + "amount": "8750000000000", + "asset": { + "address": "0x0000000000000000000000000000000000000000", + "assetId": "eip155:42161/slip44:60", + "chainId": 42161, + "coinKey": "ETH", + "decimals": 18, + "icon": "https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png", + "logoURI": "https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png", + "name": "ETH", + "priceUSD": "2478.7", + "symbol": "ETH", + }, + }, + }, + "minDestTokenAmount": "941000000000000", + "requestId": "197c402f-cb96-4096-9f8c-54aed84ca776", + "srcAsset": { + "address": "0x0000000000000000000000000000000000000000", + "assetId": "eip155:42161/slip44:60", + "chainId": 42161, + "coinKey": "ETH", + "decimals": 18, + "icon": "https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png", + "logoURI": "https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png", + "name": "ETH", + "priceUSD": "2478.7", + "symbol": "ETH", + }, + "srcChainId": 8453, + "srcTokenAmount": "991250000000000", + "steps": [ + { + "action": "bridge", + "destAmount": "990654755978612", + "destAsset": { + "address": "0x0000000000000000000000000000000000000000", + "assetId": "eip155:10/slip44:60", + "chainId": 10, + "coinKey": "ETH", + "decimals": 18, + "icon": "https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png", + "logoURI": "https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png", + "name": "ETH", + "priceUSD": "2478.63", + "symbol": "ETH", + }, + "destChainId": 10, + "protocol": { + "displayName": "Across", + "icon": "https://raw.githubusercontent.com/lifinance/types/main/src/assets/icons/bridges/acrossv2.png", + "name": "across", + }, + "srcAmount": "991250000000000", + "srcAsset": { + "address": "0x0000000000000000000000000000000000000000", + "assetId": "eip155:42161/slip44:60", + "chainId": 42161, + "coinKey": "ETH", + "decimals": 18, + "icon": "https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png", + "logoURI": "https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png", + "name": "ETH", + "priceUSD": "2478.7", + "symbol": "ETH", + }, + "srcChainId": 42161, + }, + ], + }, + "quoteId": undefined, + "slippagePercentage": 0, + "startTime": 1234567890, + "status": { + "srcChain": { + "chainId": 8453, + "txHash": "0xevmTxHash", + }, + "status": "PENDING", + }, + "targetContractAddress": undefined, + "txMetaId": "test-tx-id", +} +`; + +exports[`BridgeStatusController submitTx: EVM bridge should delay after submitting base approval 3`] = ` +[ + [ + "BridgeController:stopPollingForQuotes", + "Transaction submitted", + undefined, + ], + [ + "AccountsController:getAccountByAddress", + "otherAccount", + ], + [ + "BridgeController:trackUnifiedSwapBridgeEvent", + "Unified SwapBridge Submitted", + { + "account_hardware_type": null, + "action_type": "swapbridge-v1", + "chain_id_destination": "eip155:10", + "chain_id_source": "eip155:8453", + "custom_slippage": false, + "feature_id": "unified_swap_bridge", + "gas_included": false, + "gas_included_7702": false, + "is_hardware_wallet": false, + "location": "Unknown", + "price_impact": 0, + "provider": "lifi_across", + "quoted_time_minutes": 0.25, + "slippage_limit": 0, + "stx_enabled": false, + "swap_type": "crosschain", + "token_address_destination": "eip155:10/slip44:60", + "token_address_source": "eip155:42161/slip44:60", + "token_security_type_destination": null, + "token_symbol_destination": "ETH", + "token_symbol_source": "ETH", + "usd_amount_source": 1.01, + "usd_quoted_gas": 2.5778, + "usd_quoted_return": 0, + }, + ], + [ + "TransactionController:isAtomicBatchSupported", + { + "address": "0xaccount1", + "chainIds": [ + "0x2105", + ], + }, + ], + [ + "AccountsController:getAccountByAddress", + "0xaccount1", + ], + [ + "NetworkController:findNetworkClientIdByChainId", + "0xa4b1", + ], + [ + "TransactionController:estimateGasFee", + { + "chainId": "0xa4b1", + "networkClientId": "arbitrum-client-id", + "transactionParams": { + "data": "0xapprovalData", + "from": "0xaccount1", + "gas": "0x5208", + "to": "0xtokenContract", + "value": "0x0", + }, + }, + ], + [ + "TransactionController:addTransaction", + { + "data": "0xapprovalData", + "from": "0xaccount1", + "gas": "0x5208", + "maxFeePerGas": undefined, + "maxPriorityFeePerGas": undefined, + "to": "0xtokenContract", + "value": "0x0", + }, + { + "actionId": "1234567891.456", + "isInternal": true, + "networkClientId": "arbitrum-client-id", + "origin": "metamask", + "requireApproval": false, + "type": "bridgeApproval", + }, + ], + [ + "TransactionController:getState", + ], + [ + "AccountsController:getAccountByAddress", + "0xaccount1", + ], + [ + "NetworkController:findNetworkClientIdByChainId", + "0xa4b1", + ], + [ + "TransactionController:estimateGasFee", + { + "chainId": "0xa4b1", + "networkClientId": "arbitrum", + "transactionParams": { + "data": "0xdata", + "from": "0xaccount1", + "gas": undefined, + "to": "0xbridgeContract", + "value": "0x0", + }, + }, + ], + [ + "TransactionController:addTransaction", + { + "data": "0xdata", + "from": "0xaccount1", + "gas": undefined, + "maxFeePerGas": undefined, + "maxPriorityFeePerGas": undefined, + "to": "0xbridgeContract", + "value": "0x0", + }, + { + "actionId": "1234567892.457", + "isInternal": true, + "networkClientId": "arbitrum", + "origin": "metamask", + "requireApproval": false, + "type": "bridge", + }, + ], + [ + "TransactionController:getState", + ], +] +`; + +exports[`BridgeStatusController submitTx: EVM bridge should delay after submitting base approval 4`] = ` +[ + [ + { + "data": { + "feature_id": "unified_swap_bridge", + "srcChainId": "eip155:8453", + "stxEnabled": false, + }, + "name": "Bridge Transaction Completed", + }, + [Function], + ], + [ + { + "data": { + "feature_id": "unified_swap_bridge", + "srcChainId": "eip155:8453", + "stxEnabled": false, + }, + "name": "Bridge Transaction Approval Completed", + }, + [Function], + ], +] +`; + +exports[`BridgeStatusController submitTx: EVM bridge should delay after submitting linea approval 1`] = ` +{ + "chainId": "0xa4b1", + "hash": "0xevmTxHash", + "id": "test-tx-id", + "status": "unapproved", + "time": 1234567890, + "txParams": { + "chainId": "0xa4b1", + "data": "0xdata", + "from": "0xaccount1", + "gasLimit": "0x5208", + "to": "0xbridgeContract", + "value": "0x0", + }, + "txReceipt": { + "effectiveGasPrice": "0x1880a", + "gasUsed": "0x2c92a", + }, + "type": "bridge", +} +`; + +exports[`BridgeStatusController submitTx: EVM bridge should delay after submitting linea approval 2`] = ` +{ + "account": "0xaccount1", + "actionId": "1234567892.457", + "approvalTxId": "test-approval-tx-id", + "batchId": undefined, + "estimatedProcessingTimeInSeconds": 15, + "featureId": undefined, + "hasApprovalTx": true, + "initialDestAssetBalance": undefined, + "isStxEnabled": false, + "location": "Unknown", + "originalTransactionId": "test-tx-id", + "pricingData": { + "amountSent": "1.234", + "amountSentInUsd": "1.01", + "quotedGasAmount": ".00055", + "quotedGasInUsd": "2.5778", + "quotedReturnInUsd": "0.134214", + }, + "quote": { + "bridgeId": "lifi", + "bridges": [ + "across", + ], + "destAsset": { + "address": "0x0000000000000000000000000000000000000000", + "assetId": "eip155:10/slip44:60", + "chainId": 10, + "coinKey": "ETH", + "decimals": 18, + "icon": "https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png", + "logoURI": "https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png", + "name": "ETH", + "priceUSD": "2478.63", + "symbol": "ETH", + }, + "destChainId": 10, + "destTokenAmount": "990654755978612", + "feeData": { + "metabridge": { + "amount": "8750000000000", + "asset": { + "address": "0x0000000000000000000000000000000000000000", + "assetId": "eip155:42161/slip44:60", + "chainId": 42161, + "coinKey": "ETH", + "decimals": 18, + "icon": "https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png", + "logoURI": "https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png", + "name": "ETH", + "priceUSD": "2478.7", + "symbol": "ETH", + }, + }, + }, + "minDestTokenAmount": "941000000000000", + "requestId": "197c402f-cb96-4096-9f8c-54aed84ca776", + "srcAsset": { + "address": "0x0000000000000000000000000000000000000000", + "assetId": "eip155:42161/slip44:60", + "chainId": 42161, + "coinKey": "ETH", + "decimals": 18, + "icon": "https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png", + "logoURI": "https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png", + "name": "ETH", + "priceUSD": "2478.7", + "symbol": "ETH", + }, + "srcChainId": 59144, + "srcTokenAmount": "991250000000000", + "steps": [ + { + "action": "bridge", + "destAmount": "990654755978612", + "destAsset": { + "address": "0x0000000000000000000000000000000000000000", + "assetId": "eip155:10/slip44:60", + "chainId": 10, + "coinKey": "ETH", + "decimals": 18, + "icon": "https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png", + "logoURI": "https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png", + "name": "ETH", + "priceUSD": "2478.63", + "symbol": "ETH", + }, + "destChainId": 10, + "protocol": { + "displayName": "Across", + "icon": "https://raw.githubusercontent.com/lifinance/types/main/src/assets/icons/bridges/acrossv2.png", + "name": "across", + }, + "srcAmount": "991250000000000", + "srcAsset": { + "address": "0x0000000000000000000000000000000000000000", + "assetId": "eip155:42161/slip44:60", + "chainId": 42161, + "coinKey": "ETH", + "decimals": 18, + "icon": "https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png", + "logoURI": "https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png", + "name": "ETH", + "priceUSD": "2478.7", + "symbol": "ETH", + }, + "srcChainId": 42161, + }, + ], + }, + "quoteId": undefined, + "slippagePercentage": 0, + "startTime": 1234567890, + "status": { + "srcChain": { + "chainId": 59144, + "txHash": "0xevmTxHash", + }, + "status": "PENDING", + }, + "targetContractAddress": undefined, + "txMetaId": "test-tx-id", +} +`; + +exports[`BridgeStatusController submitTx: EVM bridge should delay after submitting linea approval 3`] = ` +[ + [ + "BridgeController:stopPollingForQuotes", + "Transaction submitted", + undefined, + ], + [ + "AccountsController:getAccountByAddress", + "otherAccount", + ], + [ + "BridgeController:trackUnifiedSwapBridgeEvent", + "Unified SwapBridge Submitted", + { + "account_hardware_type": null, + "action_type": "swapbridge-v1", + "chain_id_destination": "eip155:10", + "chain_id_source": "eip155:59144", + "custom_slippage": false, + "feature_id": "unified_swap_bridge", + "gas_included": false, + "gas_included_7702": false, + "is_hardware_wallet": false, + "location": "Unknown", + "price_impact": 0, + "provider": "lifi_across", + "quoted_time_minutes": 0.25, + "slippage_limit": 0, + "stx_enabled": false, + "swap_type": "crosschain", + "token_address_destination": "eip155:10/slip44:60", + "token_address_source": "eip155:42161/slip44:60", + "token_security_type_destination": null, + "token_symbol_destination": "ETH", + "token_symbol_source": "ETH", + "usd_amount_source": 1.01, + "usd_quoted_gas": 2.5778, + "usd_quoted_return": 0, + }, + ], + [ + "TransactionController:isAtomicBatchSupported", + { + "address": "0xaccount1", + "chainIds": [ + "0xe708", + ], + }, + ], + [ + "AccountsController:getAccountByAddress", + "0xaccount1", + ], + [ + "NetworkController:findNetworkClientIdByChainId", + "0xa4b1", + ], + [ + "TransactionController:estimateGasFee", + { + "chainId": "0xa4b1", + "networkClientId": "arbitrum-client-id", + "transactionParams": { + "data": "0xapprovalData", + "from": "0xaccount1", + "gas": "0x5208", + "to": "0xtokenContract", + "value": "0x0", + }, + }, + ], + [ + "TransactionController:addTransaction", + { + "data": "0xapprovalData", + "from": "0xaccount1", + "gas": "0x5208", + "maxFeePerGas": undefined, + "maxPriorityFeePerGas": undefined, + "to": "0xtokenContract", + "value": "0x0", + }, + { + "actionId": "1234567891.456", + "isInternal": true, + "networkClientId": "arbitrum-client-id", + "origin": "metamask", + "requireApproval": false, + "type": "bridgeApproval", + }, + ], + [ + "TransactionController:getState", + ], + [ + "AccountsController:getAccountByAddress", + "0xaccount1", + ], + [ + "NetworkController:findNetworkClientIdByChainId", + "0xa4b1", + ], + [ + "TransactionController:estimateGasFee", + { + "chainId": "0xa4b1", + "networkClientId": "arbitrum", + "transactionParams": { + "data": "0xdata", + "from": "0xaccount1", + "gas": undefined, + "to": "0xbridgeContract", + "value": "0x0", + }, + }, + ], + [ + "TransactionController:addTransaction", + { + "data": "0xdata", + "from": "0xaccount1", + "gas": undefined, + "maxFeePerGas": undefined, + "maxPriorityFeePerGas": undefined, + "to": "0xbridgeContract", + "value": "0x0", + }, + { + "actionId": "1234567892.457", + "isInternal": true, + "networkClientId": "arbitrum", + "origin": "metamask", + "requireApproval": false, + "type": "bridge", + }, + ], + [ + "TransactionController:getState", + ], +] +`; + +exports[`BridgeStatusController submitTx: EVM bridge should delay after submitting linea approval 4`] = ` +[ + [ + { + "data": { + "feature_id": "unified_swap_bridge", + "srcChainId": "eip155:59144", + "stxEnabled": false, + }, + "name": "Bridge Transaction Completed", + }, + [Function], + ], + [ + { + "data": { + "feature_id": "unified_swap_bridge", + "srcChainId": "eip155:59144", + "stxEnabled": false, + }, + "name": "Bridge Transaction Approval Completed", + }, + [Function], + ], +] +`; + +exports[`BridgeStatusController submitTx: EVM bridge should handle smart transactions and include quotesReceivedContext 1`] = ` +{ + "batchId": "batchId1", + "chainId": "0xa4b1", + "hash": "0xevmTxHash", + "id": "test-tx-id", + "status": "unapproved", + "time": 1234567890, + "txParams": { + "chainId": "0xa4b1", + "data": "0xdata", + "from": "0xaccount1", + "gasLimit": "0x5208", + "to": "0xbridgeContract", + "value": "0x0", + }, + "txReceipt": { + "effectiveGasPrice": "0x1880a", + "gasUsed": "0x2c92a", + }, + "type": "bridge", +} +`; + +exports[`BridgeStatusController submitTx: EVM bridge should handle smart transactions and include quotesReceivedContext 2`] = ` +{ + "account": "0xaccount1", + "actionId": undefined, + "approvalTxId": undefined, + "batchId": "batchId1", + "estimatedProcessingTimeInSeconds": 15, + "featureId": undefined, + "hasApprovalTx": false, + "initialDestAssetBalance": undefined, + "isStxEnabled": true, + "location": "Unknown", + "originalTransactionId": "test-tx-id", + "pricingData": { + "amountSent": "1.234", + "amountSentInUsd": "1.01", + "quotedGasAmount": ".00055", + "quotedGasInUsd": "2.5778", + "quotedReturnInUsd": "0.134214", + }, + "quote": { + "bridgeId": "lifi", + "bridges": [ + "across", + ], + "destAsset": { + "address": "0x0000000000000000000000000000000000000000", + "assetId": "eip155:10/slip44:60", + "chainId": 10, + "coinKey": "ETH", + "decimals": 18, + "icon": "https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png", + "logoURI": "https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png", + "name": "ETH", + "priceUSD": "2478.63", + "symbol": "ETH", + }, + "destChainId": 10, + "destTokenAmount": "990654755978612", + "feeData": { + "metabridge": { + "amount": "8750000000000", + "asset": { + "address": "0x0000000000000000000000000000000000000000", + "assetId": "eip155:42161/slip44:60", + "chainId": 42161, + "coinKey": "ETH", + "decimals": 18, + "icon": "https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png", + "logoURI": "https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png", + "name": "ETH", + "priceUSD": "2478.7", + "symbol": "ETH", + }, + }, + }, + "minDestTokenAmount": "941000000000000", + "requestId": "197c402f-cb96-4096-9f8c-54aed84ca776", + "srcAsset": { + "address": "0x0000000000000000000000000000000000000000", + "assetId": "eip155:42161/slip44:60", + "chainId": 42161, + "coinKey": "ETH", + "decimals": 18, + "icon": "https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png", + "logoURI": "https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png", + "name": "ETH", + "priceUSD": "2478.7", + "symbol": "ETH", + }, + "srcChainId": 42161, + "srcTokenAmount": "991250000000000", + "steps": [ + { + "action": "bridge", + "destAmount": "990654755978612", + "destAsset": { + "address": "0x0000000000000000000000000000000000000000", + "assetId": "eip155:10/slip44:60", + "chainId": 10, + "coinKey": "ETH", + "decimals": 18, + "icon": "https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png", + "logoURI": "https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png", + "name": "ETH", + "priceUSD": "2478.63", + "symbol": "ETH", + }, + "destChainId": 10, + "protocol": { + "displayName": "Across", + "icon": "https://raw.githubusercontent.com/lifinance/types/main/src/assets/icons/bridges/acrossv2.png", + "name": "across", + }, + "srcAmount": "991250000000000", + "srcAsset": { + "address": "0x0000000000000000000000000000000000000000", + "assetId": "eip155:42161/slip44:60", + "chainId": 42161, + "coinKey": "ETH", + "decimals": 18, + "icon": "https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png", + "logoURI": "https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png", + "name": "ETH", + "priceUSD": "2478.7", + "symbol": "ETH", + }, + "srcChainId": 42161, + }, + ], + }, + "quoteId": undefined, + "slippagePercentage": 0, + "startTime": 1234567890, + "status": { + "srcChain": { + "chainId": 42161, + "txHash": undefined, + }, + "status": "PENDING", + }, + "targetContractAddress": undefined, + "txMetaId": "test-tx-id", +} +`; + +exports[`BridgeStatusController submitTx: EVM bridge should handle smart transactions and include quotesReceivedContext 3`] = ` +[ + [ + { + "atomic": true, + "disable7702": true, + "from": "0xaccount1", + "isGasFeeIncluded": false, + "isGasFeeSponsored": false, + "isInternal": true, + "networkClientId": "arbitrum", + "origin": "metamask", + "requireApproval": false, + "transactions": [ + { + "assetsFiatValues": { + "receiving": "2.9999", + "sending": "2.00", + }, + "params": { + "data": "0xdata", + "from": "0xaccount1", + "gas": "0x5208", + "maxFeePerGas": undefined, + "maxPriorityFeePerGas": undefined, + "to": "0xbridgeContract", + "value": "0x0", + }, + "type": "bridge", + }, + ], + }, + ], +] +`; + +exports[`BridgeStatusController submitTx: EVM bridge should handle smart transactions and include quotesReceivedContext 4`] = ` +[ + [ + "BridgeController:stopPollingForQuotes", + "Transaction submitted", + { + "best_quote_provider": "lifi_across", + "can_submit": true, + "feature_id": "unified_swap_bridge", + "gas_included": false, + "gas_included_7702": false, + "price_impact": 0, + "provider": "lifi_across", + "quoted_time_minutes": 0.25, + "slippage_limit": 0, + "token_symbol_destination": "ETH", + "token_symbol_source": "ETH", + "usd_amount_source": 1.01, + "usd_balance_source": 0, + "usd_quoted_gas": 2.5778, + "usd_quoted_return": 0.134214, + "warnings": [ + "low_return", + ], + }, + ], + [ + "AccountsController:getAccountByAddress", + "0xaccount1", + ], + [ + "BridgeController:trackUnifiedSwapBridgeEvent", + "Unified SwapBridge Submitted", + { + "account_hardware_type": null, + "action_type": "swapbridge-v1", + "chain_id_destination": "eip155:10", + "chain_id_source": "eip155:42161", + "custom_slippage": false, + "feature_id": "unified_swap_bridge", + "gas_included": false, + "gas_included_7702": false, + "is_hardware_wallet": false, + "location": "Unknown", + "price_impact": 0, + "provider": "lifi_across", + "quoted_time_minutes": 0.25, + "slippage_limit": 0, + "stx_enabled": true, + "swap_type": "crosschain", + "token_address_destination": "eip155:10/slip44:60", + "token_address_source": "eip155:42161/slip44:60", + "token_security_type_destination": null, + "token_symbol_destination": "ETH", + "token_symbol_source": "ETH", + "usd_amount_source": 1.01, + "usd_quoted_gas": 2.5778, + "usd_quoted_return": 0, + }, + ], + [ + "TransactionController:isAtomicBatchSupported", + { + "address": "0xaccount1", + "chainIds": [ + "0xa4b1", + ], + }, + ], + [ + "AccountsController:getAccountByAddress", + "0xaccount1", + ], + [ + "NetworkController:findNetworkClientIdByChainId", + "0xa4b1", + ], + [ + "TransactionController:estimateGasFee", + { + "chainId": "0xa4b1", + "networkClientId": "arbitrum", + "transactionParams": { + "data": "0xdata", + "from": "0xaccount1", + "gas": "0x5208", + "to": "0xbridgeContract", + "value": "0x0", + }, + }, + ], + [ + "TransactionController:getState", + ], + [ + "TransactionController:updateTransaction", + { + "batchId": "batchId1", + "chainId": "0xa4b1", + "hash": "0xevmTxHash", + "id": "test-tx-id", + "status": "unapproved", + "time": 1234567890, + "txParams": { + "chainId": "0xa4b1", + "data": "0xdata", + "from": "0xaccount1", + "gasLimit": "0x5208", + "to": "0xbridgeContract", + "value": "0x0", + }, + "txReceipt": { + "effectiveGasPrice": "0x1880a", + "gasUsed": "0x2c92a", + }, + "type": "bridge", + }, + "Update tx type to bridge", + ], +] +`; + +exports[`BridgeStatusController submitTx: EVM bridge should not call handleMobileHardwareWalletDelay on extension 1`] = ` +{ + "chainId": "0xa4b1", + "hash": "0xevmTxHash", + "id": "test-tx-id", + "status": "unapproved", + "time": 1234567890, + "txParams": { + "chainId": "0xa4b1", + "data": "0xdata", + "from": "0xaccount1", + "gasLimit": "0x5208", + "to": "0xbridgeContract", + "value": "0x0", + }, + "txReceipt": { + "effectiveGasPrice": "0x1880a", + "gasUsed": "0x2c92a", + }, + "type": "bridge", +} +`; + +exports[`BridgeStatusController submitTx: EVM bridge should not call handleMobileHardwareWalletDelay on extension 2`] = ` +{ + "account": "0xaccount1", + "actionId": "1234567892.457", + "approvalTxId": "test-approval-tx-id", + "batchId": undefined, + "estimatedProcessingTimeInSeconds": 15, + "featureId": undefined, + "hasApprovalTx": true, + "initialDestAssetBalance": undefined, + "isStxEnabled": false, + "location": "Unknown", + "originalTransactionId": "test-tx-id", + "pricingData": { + "amountSent": "1.234", + "amountSentInUsd": "1.01", + "quotedGasAmount": ".00055", + "quotedGasInUsd": "2.5778", + "quotedReturnInUsd": "0.134214", + }, + "quote": { + "bridgeId": "lifi", + "bridges": [ + "across", + ], + "destAsset": { + "address": "0x0000000000000000000000000000000000000000", + "assetId": "eip155:10/slip44:60", + "chainId": 10, + "coinKey": "ETH", + "decimals": 18, + "icon": "https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png", + "logoURI": "https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png", + "name": "ETH", + "priceUSD": "2478.63", + "symbol": "ETH", + }, + "destChainId": 10, + "destTokenAmount": "990654755978612", + "feeData": { + "metabridge": { + "amount": "8750000000000", + "asset": { + "address": "0x0000000000000000000000000000000000000000", + "assetId": "eip155:42161/slip44:60", + "chainId": 42161, + "coinKey": "ETH", + "decimals": 18, + "icon": "https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png", + "logoURI": "https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png", + "name": "ETH", + "priceUSD": "2478.7", + "symbol": "ETH", + }, + }, + }, + "minDestTokenAmount": "941000000000000", + "requestId": "197c402f-cb96-4096-9f8c-54aed84ca776", + "srcAsset": { + "address": "0x0000000000000000000000000000000000000000", + "assetId": "eip155:42161/slip44:60", + "chainId": 42161, + "coinKey": "ETH", + "decimals": 18, + "icon": "https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png", + "logoURI": "https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png", + "name": "ETH", + "priceUSD": "2478.7", + "symbol": "ETH", + }, + "srcChainId": 42161, + "srcTokenAmount": "991250000000000", + "steps": [ + { + "action": "bridge", + "destAmount": "990654755978612", + "destAsset": { + "address": "0x0000000000000000000000000000000000000000", + "assetId": "eip155:10/slip44:60", + "chainId": 10, + "coinKey": "ETH", + "decimals": 18, + "icon": "https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png", + "logoURI": "https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png", + "name": "ETH", + "priceUSD": "2478.63", + "symbol": "ETH", + }, + "destChainId": 10, + "protocol": { + "displayName": "Across", + "icon": "https://raw.githubusercontent.com/lifinance/types/main/src/assets/icons/bridges/acrossv2.png", + "name": "across", + }, + "srcAmount": "991250000000000", + "srcAsset": { + "address": "0x0000000000000000000000000000000000000000", + "assetId": "eip155:42161/slip44:60", + "chainId": 42161, + "coinKey": "ETH", + "decimals": 18, + "icon": "https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png", + "logoURI": "https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png", + "name": "ETH", + "priceUSD": "2478.7", + "symbol": "ETH", + }, + "srcChainId": 42161, + }, + ], + }, + "quoteId": undefined, + "slippagePercentage": 0, + "startTime": 1234567890, + "status": { + "srcChain": { + "chainId": 42161, + "txHash": "0xevmTxHash", + }, + "status": "PENDING", + }, + "targetContractAddress": undefined, + "txMetaId": "test-tx-id", +} +`; + +exports[`BridgeStatusController submitTx: EVM bridge should not call handleMobileHardwareWalletDelay on extension 3`] = ` +[ + [ + "BridgeController:stopPollingForQuotes", + "Transaction submitted", + undefined, + ], + [ + "AccountsController:getAccountByAddress", + "otherAccount", + ], + [ + "BridgeController:trackUnifiedSwapBridgeEvent", + "Unified SwapBridge Submitted", + { + "account_hardware_type": null, + "action_type": "swapbridge-v1", + "chain_id_destination": "eip155:10", + "chain_id_source": "eip155:42161", + "custom_slippage": false, + "feature_id": "unified_swap_bridge", + "gas_included": false, + "gas_included_7702": false, + "is_hardware_wallet": false, + "location": "Unknown", + "price_impact": 0, + "provider": "lifi_across", + "quoted_time_minutes": 0.25, + "slippage_limit": 0, + "stx_enabled": false, + "swap_type": "crosschain", + "token_address_destination": "eip155:10/slip44:60", + "token_address_source": "eip155:42161/slip44:60", + "token_security_type_destination": null, + "token_symbol_destination": "ETH", + "token_symbol_source": "ETH", + "usd_amount_source": 1.01, + "usd_quoted_gas": 2.5778, + "usd_quoted_return": 0, + }, + ], + [ + "TransactionController:isAtomicBatchSupported", + { + "address": "0xaccount1", + "chainIds": [ + "0xa4b1", + ], + }, + ], + [ + "AccountsController:getAccountByAddress", + "0xaccount1", + ], + [ + "NetworkController:findNetworkClientIdByChainId", + "0xa4b1", + ], + [ + "TransactionController:estimateGasFee", + { + "chainId": "0xa4b1", + "networkClientId": "arbitrum-client-id", + "transactionParams": { + "data": "0xapprovalData", + "from": "0xaccount1", + "gas": "0x5208", + "to": "0xtokenContract", + "value": "0x0", + }, + }, + ], + [ + "TransactionController:addTransaction", + { + "data": "0xapprovalData", + "from": "0xaccount1", + "gas": "0x5208", + "maxFeePerGas": undefined, + "maxPriorityFeePerGas": undefined, + "to": "0xtokenContract", + "value": "0x0", + }, + { + "actionId": "1234567891.456", + "isInternal": true, + "networkClientId": "arbitrum-client-id", + "origin": "metamask", + "requireApproval": false, + "type": "bridgeApproval", + }, + ], + [ + "TransactionController:getState", + ], + [ + "AccountsController:getAccountByAddress", + "0xaccount1", + ], + [ + "NetworkController:findNetworkClientIdByChainId", + "0xa4b1", + ], + [ + "TransactionController:estimateGasFee", + { + "chainId": "0xa4b1", + "networkClientId": "arbitrum", + "transactionParams": { + "data": "0xdata", + "from": "0xaccount1", + "gas": "0x5208", + "to": "0xbridgeContract", + "value": "0x0", + }, + }, + ], + [ + "TransactionController:addTransaction", + { + "data": "0xdata", + "from": "0xaccount1", + "gas": "0x5208", + "maxFeePerGas": undefined, + "maxPriorityFeePerGas": undefined, + "to": "0xbridgeContract", + "value": "0x0", + }, + { + "actionId": "1234567892.457", + "isInternal": true, + "networkClientId": "arbitrum", + "origin": "metamask", + "requireApproval": false, + "type": "bridge", + }, + ], + [ + "TransactionController:getState", + ], +] +`; + +exports[`BridgeStatusController submitTx: EVM bridge should not call handleMobileHardwareWalletDelay on extension 4`] = ` +[ + [ + { + "data": { + "feature_id": "unified_swap_bridge", + "srcChainId": "eip155:42161", + "stxEnabled": false, + }, + "name": "Bridge Transaction Completed", + }, + [Function], + ], + [ + { + "data": { + "feature_id": "unified_swap_bridge", + "srcChainId": "eip155:42161", + "stxEnabled": false, + }, + "name": "Bridge Transaction Approval Completed", + }, + [Function], + ], +] +`; + +exports[`BridgeStatusController submitTx: EVM bridge should not call handleMobileHardwareWalletDelay with true for non-hardware wallet on mobile 1`] = ` +{ + "chainId": "0xa4b1", + "hash": "0xevmTxHash", + "id": "test-tx-id", + "status": "unapproved", + "time": 1234567890, + "txParams": { + "chainId": "0xa4b1", + "data": "0xdata", + "from": "0xaccount1", + "gasLimit": "0x5208", + "to": "0xbridgeContract", + "value": "0x0", + }, + "txReceipt": { + "effectiveGasPrice": "0x1880a", + "gasUsed": "0x2c92a", + }, + "type": "bridge", +} +`; + +exports[`BridgeStatusController submitTx: EVM bridge should not call handleMobileHardwareWalletDelay with true for non-hardware wallet on mobile 2`] = ` +{ + "account": "0xaccount1", + "actionId": "1234567892.457", + "approvalTxId": "test-approval-tx-id", + "batchId": undefined, + "estimatedProcessingTimeInSeconds": 15, + "featureId": undefined, + "hasApprovalTx": true, + "initialDestAssetBalance": undefined, + "isStxEnabled": false, + "location": "Unknown", + "originalTransactionId": "test-tx-id", + "pricingData": { + "amountSent": "1.234", + "amountSentInUsd": "1.01", + "quotedGasAmount": ".00055", + "quotedGasInUsd": "2.5778", + "quotedReturnInUsd": "0.134214", + }, + "quote": { + "bridgeId": "lifi", + "bridges": [ + "across", + ], + "destAsset": { + "address": "0x0000000000000000000000000000000000000000", + "assetId": "eip155:10/slip44:60", + "chainId": 10, + "coinKey": "ETH", + "decimals": 18, + "icon": "https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png", + "logoURI": "https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png", + "name": "ETH", + "priceUSD": "2478.63", + "symbol": "ETH", + }, + "destChainId": 10, + "destTokenAmount": "990654755978612", + "feeData": { + "metabridge": { + "amount": "8750000000000", + "asset": { + "address": "0x0000000000000000000000000000000000000000", + "assetId": "eip155:42161/slip44:60", + "chainId": 42161, + "coinKey": "ETH", + "decimals": 18, + "icon": "https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png", + "logoURI": "https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png", + "name": "ETH", + "priceUSD": "2478.7", + "symbol": "ETH", + }, + }, + }, + "minDestTokenAmount": "941000000000000", + "requestId": "197c402f-cb96-4096-9f8c-54aed84ca776", + "srcAsset": { + "address": "0x0000000000000000000000000000000000000000", + "assetId": "eip155:42161/slip44:60", + "chainId": 42161, + "coinKey": "ETH", + "decimals": 18, + "icon": "https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png", + "logoURI": "https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png", + "name": "ETH", + "priceUSD": "2478.7", + "symbol": "ETH", + }, + "srcChainId": 42161, + "srcTokenAmount": "991250000000000", + "steps": [ + { + "action": "bridge", + "destAmount": "990654755978612", + "destAsset": { + "address": "0x0000000000000000000000000000000000000000", + "assetId": "eip155:10/slip44:60", + "chainId": 10, + "coinKey": "ETH", + "decimals": 18, + "icon": "https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png", + "logoURI": "https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png", + "name": "ETH", + "priceUSD": "2478.63", + "symbol": "ETH", + }, + "destChainId": 10, + "protocol": { + "displayName": "Across", + "icon": "https://raw.githubusercontent.com/lifinance/types/main/src/assets/icons/bridges/acrossv2.png", + "name": "across", + }, + "srcAmount": "991250000000000", + "srcAsset": { + "address": "0x0000000000000000000000000000000000000000", + "assetId": "eip155:42161/slip44:60", + "chainId": 42161, + "coinKey": "ETH", + "decimals": 18, + "icon": "https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png", + "logoURI": "https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png", + "name": "ETH", + "priceUSD": "2478.7", + "symbol": "ETH", + }, + "srcChainId": 42161, + }, + ], + }, + "quoteId": undefined, + "slippagePercentage": 0, + "startTime": 1234567890, + "status": { + "srcChain": { + "chainId": 42161, + "txHash": "0xevmTxHash", + }, + "status": "PENDING", + }, + "targetContractAddress": undefined, + "txMetaId": "test-tx-id", +} +`; + +exports[`BridgeStatusController submitTx: EVM bridge should not call handleMobileHardwareWalletDelay with true for non-hardware wallet on mobile 3`] = ` +[ + [ + "BridgeController:stopPollingForQuotes", + "Transaction submitted", + undefined, + ], + [ + "AccountsController:getAccountByAddress", + "0xaccount1", + ], + [ + "BridgeController:trackUnifiedSwapBridgeEvent", + "Unified SwapBridge Submitted", + { + "account_hardware_type": null, + "action_type": "swapbridge-v1", + "chain_id_destination": "eip155:10", + "chain_id_source": "eip155:42161", + "custom_slippage": false, + "feature_id": "unified_swap_bridge", + "gas_included": false, + "gas_included_7702": false, + "is_hardware_wallet": false, + "location": "Unknown", + "price_impact": 0, + "provider": "lifi_across", + "quoted_time_minutes": 0.25, + "slippage_limit": 0, + "stx_enabled": false, + "swap_type": "crosschain", + "token_address_destination": "eip155:10/slip44:60", + "token_address_source": "eip155:42161/slip44:60", + "token_security_type_destination": null, + "token_symbol_destination": "ETH", + "token_symbol_source": "ETH", + "usd_amount_source": 1.01, + "usd_quoted_gas": 2.5778, + "usd_quoted_return": 0, + }, + ], + [ + "TransactionController:isAtomicBatchSupported", + { + "address": "0xaccount1", + "chainIds": [ + "0xa4b1", + ], + }, + ], + [ + "AccountsController:getAccountByAddress", + "0xaccount1", + ], + [ + "NetworkController:findNetworkClientIdByChainId", + "0xa4b1", + ], + [ + "TransactionController:estimateGasFee", + { + "chainId": "0xa4b1", + "networkClientId": "arbitrum-client-id", + "transactionParams": { + "data": "0xapprovalData", + "from": "0xaccount1", + "gas": "0x5208", + "to": "0xtokenContract", + "value": "0x0", + }, + }, + ], + [ + "TransactionController:addTransaction", + { + "data": "0xapprovalData", + "from": "0xaccount1", + "gas": "0x5208", + "maxFeePerGas": undefined, + "maxPriorityFeePerGas": undefined, + "to": "0xtokenContract", + "value": "0x0", + }, + { + "actionId": "1234567891.456", + "isInternal": true, + "networkClientId": "arbitrum-client-id", + "origin": "metamask", + "requireApproval": false, + "type": "bridgeApproval", + }, + ], + [ + "TransactionController:getState", + ], + [ + "AccountsController:getAccountByAddress", + "0xaccount1", + ], + [ + "NetworkController:findNetworkClientIdByChainId", + "0xa4b1", + ], + [ + "TransactionController:estimateGasFee", + { + "chainId": "0xa4b1", + "networkClientId": "arbitrum", + "transactionParams": { + "data": "0xdata", + "from": "0xaccount1", + "gas": "0x5208", + "to": "0xbridgeContract", + "value": "0x0", + }, + }, + ], + [ + "TransactionController:addTransaction", + { + "data": "0xdata", + "from": "0xaccount1", + "gas": "0x5208", + "maxFeePerGas": undefined, + "maxPriorityFeePerGas": undefined, + "to": "0xbridgeContract", + "value": "0x0", + }, + { + "actionId": "1234567892.457", + "isInternal": true, + "networkClientId": "arbitrum", + "origin": "metamask", + "requireApproval": false, + "type": "bridge", + }, + ], + [ + "TransactionController:getState", + ], +] +`; + +exports[`BridgeStatusController submitTx: EVM bridge should not call handleMobileHardwareWalletDelay with true for non-hardware wallet on mobile 4`] = ` +[ + [ + { + "data": { + "feature_id": "unified_swap_bridge", + "srcChainId": "eip155:42161", + "stxEnabled": false, + }, + "name": "Bridge Transaction Completed", + }, + [Function], + ], + [ + { + "data": { + "feature_id": "unified_swap_bridge", + "srcChainId": "eip155:42161", + "stxEnabled": false, + }, + "name": "Bridge Transaction Approval Completed", + }, + [Function], + ], +] +`; + +exports[`BridgeStatusController submitTx: EVM bridge should reset USDT allowance 1`] = ` +{ + "chainId": "0xa4b1", + "hash": "0xevmTxHash", + "id": "test-tx-id", + "status": "unapproved", + "time": 1234567890, + "txParams": { + "chainId": "0xa4b1", + "data": "0xdata", + "from": "0xaccount1", + "gasLimit": "0x5208", + "to": "0xbridgeContract", + "value": "0x0", + }, + "txReceipt": { + "effectiveGasPrice": "0x1880a", + "gasUsed": "0x2c92a", + }, + "type": "bridge", +} +`; + +exports[`BridgeStatusController submitTx: EVM bridge should reset USDT allowance 2`] = ` +{ + "account": "0xaccount1", + "actionId": "1234567893.458", + "approvalTxId": "test-approval-tx-id", + "batchId": undefined, + "estimatedProcessingTimeInSeconds": 15, + "featureId": undefined, + "hasApprovalTx": true, + "initialDestAssetBalance": undefined, + "isStxEnabled": false, + "location": "Unknown", + "originalTransactionId": "test-tx-id", + "pricingData": { + "amountSent": "1.234", + "amountSentInUsd": "1.01", + "quotedGasAmount": ".00055", + "quotedGasInUsd": "2.5778", + "quotedReturnInUsd": "0.134214", + }, + "quote": { + "bridgeId": "lifi", + "bridges": [ + "across", + ], + "destAsset": { + "address": "0x0000000000000000000000000000000000000000", + "assetId": "eip155:10/slip44:60", + "chainId": 10, + "coinKey": "ETH", + "decimals": 18, + "icon": "https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png", + "logoURI": "https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png", + "name": "ETH", + "priceUSD": "2478.63", + "symbol": "ETH", + }, + "destChainId": 10, + "destTokenAmount": "990654755978612", + "feeData": { + "metabridge": { + "amount": "8750000000000", + "asset": { + "address": "0x0000000000000000000000000000000000000000", + "assetId": "eip155:42161/slip44:60", + "chainId": 42161, + "coinKey": "ETH", + "decimals": 18, + "icon": "https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png", + "logoURI": "https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png", + "name": "ETH", + "priceUSD": "2478.7", + "symbol": "ETH", + }, + }, + }, + "minDestTokenAmount": "941000000000000", + "requestId": "197c402f-cb96-4096-9f8c-54aed84ca776", + "srcAsset": { + "address": "0x0000000000000000000000000000000000000000", + "assetId": "eip155:42161/slip44:60", + "chainId": 42161, + "coinKey": "ETH", + "decimals": 18, + "icon": "https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png", + "logoURI": "https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png", + "name": "ETH", + "priceUSD": "2478.7", + "symbol": "ETH", + }, + "srcChainId": 42161, + "srcTokenAmount": "991250000000000", + "steps": [ + { + "action": "bridge", + "destAmount": "990654755978612", + "destAsset": { + "address": "0x0000000000000000000000000000000000000000", + "assetId": "eip155:10/slip44:60", + "chainId": 10, + "coinKey": "ETH", + "decimals": 18, + "icon": "https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png", + "logoURI": "https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png", + "name": "ETH", + "priceUSD": "2478.63", + "symbol": "ETH", + }, + "destChainId": 10, + "protocol": { + "displayName": "Across", + "icon": "https://raw.githubusercontent.com/lifinance/types/main/src/assets/icons/bridges/acrossv2.png", + "name": "across", + }, + "srcAmount": "991250000000000", + "srcAsset": { + "address": "0x0000000000000000000000000000000000000000", + "assetId": "eip155:42161/slip44:60", + "chainId": 42161, + "coinKey": "ETH", + "decimals": 18, + "icon": "https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png", + "logoURI": "https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png", + "name": "ETH", + "priceUSD": "2478.7", + "symbol": "ETH", + }, + "srcChainId": 42161, + }, + ], + }, + "quoteId": undefined, + "slippagePercentage": 0, + "startTime": 1234567890, + "status": { + "srcChain": { + "chainId": 42161, + "txHash": "0xevmTxHash", + }, + "status": "PENDING", + }, + "targetContractAddress": undefined, + "txMetaId": "test-tx-id", +} +`; + +exports[`BridgeStatusController submitTx: EVM bridge should reset USDT allowance 3`] = ` +[ + [ + "BridgeController:stopPollingForQuotes", + "Transaction submitted", + undefined, + ], + [ + "AccountsController:getAccountByAddress", + "0xaccount1", + ], + [ + "BridgeController:trackUnifiedSwapBridgeEvent", + "Unified SwapBridge Submitted", + { + "account_hardware_type": null, + "action_type": "swapbridge-v1", + "chain_id_destination": "eip155:10", + "chain_id_source": "eip155:42161", + "custom_slippage": false, + "feature_id": "unified_swap_bridge", + "gas_included": false, + "gas_included_7702": false, + "is_hardware_wallet": false, + "location": "Unknown", + "price_impact": 0, + "provider": "lifi_across", + "quoted_time_minutes": 0.25, + "slippage_limit": 0, + "stx_enabled": false, + "swap_type": "crosschain", + "token_address_destination": "eip155:10/slip44:60", + "token_address_source": "eip155:42161/slip44:60", + "token_security_type_destination": null, + "token_symbol_destination": "ETH", + "token_symbol_source": "ETH", + "usd_amount_source": 1.01, + "usd_quoted_gas": 2.5778, + "usd_quoted_return": 0, + }, + ], + [ + "TransactionController:isAtomicBatchSupported", + { + "address": "0xaccount1", + "chainIds": [ + "0xa4b1", + ], + }, + ], + [ + "AccountsController:getAccountByAddress", + "0xaccount1", + ], + [ + "NetworkController:findNetworkClientIdByChainId", + "0x1", + ], + [ + "TransactionController:estimateGasFee", + { + "chainId": "0x1", + "networkClientId": "arbitrum-client-id", + "transactionParams": { + "data": "0x095ea7b3000000000000000000000000881d40237659c251811cec9c364ef91dc08d300c0000000000000000000000000000000000000000000000000000000000000000", + "from": "0xaccount1", + "gas": "0x5208", + "to": "0xtokenContract", + "value": "0x0", + }, + }, + ], + [ + "TransactionController:addTransaction", + { + "data": "0x095ea7b3000000000000000000000000881d40237659c251811cec9c364ef91dc08d300c0000000000000000000000000000000000000000000000000000000000000000", + "from": "0xaccount1", + "gas": "0x5208", + "maxFeePerGas": undefined, + "maxPriorityFeePerGas": undefined, + "to": "0xtokenContract", + "value": "0x0", + }, + { + "actionId": "1234567891.456", + "isInternal": true, + "networkClientId": "arbitrum-client-id", + "origin": "metamask", + "requireApproval": false, + "type": "bridgeApproval", + }, + ], + [ + "TransactionController:getState", + ], + [ + "AccountsController:getAccountByAddress", + "0xaccount1", + ], + [ + "NetworkController:findNetworkClientIdByChainId", + "0xa4b1", + ], + [ + "TransactionController:estimateGasFee", + { + "chainId": "0xa4b1", + "networkClientId": "arbitrum-client-id", + "transactionParams": { + "data": "0xapprovalData", + "from": "0xaccount1", + "gas": "0x5208", + "to": "0xtokenContract", + "value": "0x0", + }, + }, + ], + [ + "TransactionController:addTransaction", + { + "data": "0xapprovalData", + "from": "0xaccount1", + "gas": "0x5208", + "maxFeePerGas": undefined, + "maxPriorityFeePerGas": undefined, + "to": "0xtokenContract", + "value": "0x0", + }, + { + "actionId": "1234567892.457", + "isInternal": true, + "networkClientId": "arbitrum-client-id", + "origin": "metamask", + "requireApproval": false, + "type": "bridgeApproval", + }, + ], + [ + "TransactionController:getState", + ], + [ + "AccountsController:getAccountByAddress", + "0xaccount1", + ], + [ + "NetworkController:findNetworkClientIdByChainId", + "0xa4b1", + ], + [ + "TransactionController:estimateGasFee", + { + "chainId": "0xa4b1", + "networkClientId": "arbitrum", + "transactionParams": { + "data": "0xdata", + "from": "0xaccount1", + "gas": "0x5208", + "to": "0xbridgeContract", + "value": "0x0", + }, + }, + ], + [ + "TransactionController:addTransaction", + { + "data": "0xdata", + "from": "0xaccount1", + "gas": "0x5208", + "maxFeePerGas": undefined, + "maxPriorityFeePerGas": undefined, + "to": "0xbridgeContract", + "value": "0x0", + }, + { + "actionId": "1234567893.458", + "isInternal": true, + "networkClientId": "arbitrum", + "origin": "metamask", + "requireApproval": false, + "type": "bridge", + }, + ], + [ + "TransactionController:getState", + ], +] +`; + +exports[`BridgeStatusController submitTx: EVM bridge should successfully submit an EVM bridge transaction with approval 1`] = ` +{ + "chainId": "0xa4b1", + "hash": "0xevmTxHash", + "id": "test-tx-id", + "status": "unapproved", + "time": 1234567890, + "txParams": { + "chainId": "0xa4b1", + "data": "0xdata", + "from": "0xaccount1", + "gasLimit": "0x5208", + "to": "0xbridgeContract", + "value": "0x0", + }, + "txReceipt": { + "effectiveGasPrice": "0x1880a", + "gasUsed": "0x2c92a", + }, + "type": "bridge", +} +`; + +exports[`BridgeStatusController submitTx: EVM bridge should successfully submit an EVM bridge transaction with approval 2`] = ` +{ + "account": "0xaccount1", + "actionId": "1234567892.457", + "approvalTxId": "test-approval-tx-id", + "batchId": undefined, + "estimatedProcessingTimeInSeconds": 15, + "featureId": undefined, + "hasApprovalTx": true, + "initialDestAssetBalance": undefined, + "isStxEnabled": false, + "location": "Unknown", + "originalTransactionId": "test-tx-id", + "pricingData": { + "amountSent": "1.234", + "amountSentInUsd": "1.01", + "quotedGasAmount": ".00055", + "quotedGasInUsd": "2.5778", + "quotedReturnInUsd": "0.134214", + }, + "quote": { + "bridgeId": "lifi", + "bridges": [ + "across", + ], + "destAsset": { + "address": "0x0000000000000000000000000000000000000000", + "assetId": "eip155:10/slip44:60", + "chainId": 10, + "coinKey": "ETH", + "decimals": 18, + "icon": "https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png", + "logoURI": "https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png", + "name": "ETH", + "priceUSD": "2478.63", + "symbol": "ETH", + }, + "destChainId": 10, + "destTokenAmount": "990654755978612", + "feeData": { + "metabridge": { + "amount": "8750000000000", + "asset": { + "address": "0x0000000000000000000000000000000000000000", + "assetId": "eip155:42161/slip44:60", + "chainId": 42161, + "coinKey": "ETH", + "decimals": 18, + "icon": "https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png", + "logoURI": "https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png", + "name": "ETH", + "priceUSD": "2478.7", + "symbol": "ETH", + }, + }, + }, + "minDestTokenAmount": "941000000000000", + "requestId": "197c402f-cb96-4096-9f8c-54aed84ca776", + "srcAsset": { + "address": "0x0000000000000000000000000000000000000000", + "assetId": "eip155:42161/slip44:60", + "chainId": 42161, + "coinKey": "ETH", + "decimals": 18, + "icon": "https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png", + "logoURI": "https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png", + "name": "ETH", + "priceUSD": "2478.7", + "symbol": "ETH", + }, + "srcChainId": 42161, + "srcTokenAmount": "991250000000000", + "steps": [ + { + "action": "bridge", + "destAmount": "990654755978612", + "destAsset": { + "address": "0x0000000000000000000000000000000000000000", + "assetId": "eip155:10/slip44:60", + "chainId": 10, + "coinKey": "ETH", + "decimals": 18, + "icon": "https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png", + "logoURI": "https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png", + "name": "ETH", + "priceUSD": "2478.63", + "symbol": "ETH", + }, + "destChainId": 10, + "protocol": { + "displayName": "Across", + "icon": "https://raw.githubusercontent.com/lifinance/types/main/src/assets/icons/bridges/acrossv2.png", + "name": "across", + }, + "srcAmount": "991250000000000", + "srcAsset": { + "address": "0x0000000000000000000000000000000000000000", + "assetId": "eip155:42161/slip44:60", + "chainId": 42161, + "coinKey": "ETH", + "decimals": 18, + "icon": "https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png", + "logoURI": "https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png", + "name": "ETH", + "priceUSD": "2478.7", + "symbol": "ETH", + }, + "srcChainId": 42161, + }, + ], + }, + "quoteId": undefined, + "slippagePercentage": 0, + "startTime": 1234567890, + "status": { + "srcChain": { + "chainId": 42161, + "txHash": "0xevmTxHash", + }, + "status": "PENDING", + }, + "targetContractAddress": undefined, + "txMetaId": "test-tx-id", +} +`; + +exports[`BridgeStatusController submitTx: EVM bridge should successfully submit an EVM bridge transaction with approval 3`] = ` +[ + [ + "BridgeController:stopPollingForQuotes", + "Transaction submitted", + undefined, + ], + [ + "AccountsController:getAccountByAddress", + "otherAccount", + ], + [ + "BridgeController:trackUnifiedSwapBridgeEvent", + "Unified SwapBridge Submitted", + { + "account_hardware_type": null, + "action_type": "swapbridge-v1", + "chain_id_destination": "eip155:10", + "chain_id_source": "eip155:42161", + "custom_slippage": false, + "feature_id": "unified_swap_bridge", + "gas_included": false, + "gas_included_7702": false, + "is_hardware_wallet": false, + "location": "Unknown", + "price_impact": 0, + "provider": "lifi_across", + "quoted_time_minutes": 0.25, + "slippage_limit": 0, + "stx_enabled": false, + "swap_type": "crosschain", + "token_address_destination": "eip155:10/slip44:60", + "token_address_source": "eip155:42161/slip44:60", + "token_security_type_destination": null, + "token_symbol_destination": "ETH", + "token_symbol_source": "ETH", + "usd_amount_source": 1.01, + "usd_quoted_gas": 2.5778, + "usd_quoted_return": 0, + }, + ], + [ + "TransactionController:isAtomicBatchSupported", + { + "address": "0xaccount1", + "chainIds": [ + "0xa4b1", + ], + }, + ], + [ + "AccountsController:getAccountByAddress", + "0xaccount1", + ], + [ + "NetworkController:findNetworkClientIdByChainId", + "0xa4b1", + ], + [ + "TransactionController:estimateGasFee", + { + "chainId": "0xa4b1", + "networkClientId": "arbitrum-client-id", + "transactionParams": { + "data": "0xapprovalData", + "from": "0xaccount1", + "gas": "0x5208", + "to": "0xtokenContract", + "value": "0x0", + }, + }, + ], + [ + "TransactionController:addTransaction", + { + "data": "0xapprovalData", + "from": "0xaccount1", + "gas": "0x5208", + "maxFeePerGas": undefined, + "maxPriorityFeePerGas": undefined, + "to": "0xtokenContract", + "value": "0x0", + }, + { + "actionId": "1234567891.456", + "isInternal": true, + "networkClientId": "arbitrum-client-id", + "origin": "metamask", + "requireApproval": false, + "type": "bridgeApproval", + }, + ], + [ + "TransactionController:getState", + ], + [ + "AccountsController:getAccountByAddress", + "0xaccount1", + ], + [ + "NetworkController:findNetworkClientIdByChainId", + "0xa4b1", + ], + [ + "TransactionController:estimateGasFee", + { + "chainId": "0xa4b1", + "networkClientId": "arbitrum", + "transactionParams": { + "data": "0xdata", + "from": "0xaccount1", + "gas": "0x5208", + "to": "0xbridgeContract", + "value": "0x0", + }, + }, + ], + [ + "TransactionController:addTransaction", + { + "data": "0xdata", + "from": "0xaccount1", + "gas": "0x5208", + "maxFeePerGas": undefined, + "maxPriorityFeePerGas": undefined, + "to": "0xbridgeContract", + "value": "0x0", + }, + { + "actionId": "1234567892.457", + "isInternal": true, + "networkClientId": "arbitrum", + "origin": "metamask", + "requireApproval": false, + "type": "bridge", + }, + ], + [ + "TransactionController:getState", + ], +] +`; + +exports[`BridgeStatusController submitTx: EVM bridge should successfully submit an EVM bridge transaction with no approval 1`] = ` +{ + "chainId": "0xa4b1", + "hash": "0xevmTxHash", + "id": "test-tx-id", + "status": "unapproved", + "time": 1234567890, + "txParams": { + "chainId": "0xa4b1", + "data": "0xdata", + "from": "0xaccount1", + "gasLimit": "0x5208", + "to": "0xbridgeContract", + "value": "0x0", + }, + "txReceipt": { + "effectiveGasPrice": "0x1880a", + "gasUsed": "0x2c92a", + }, + "type": "bridge", +} +`; + +exports[`BridgeStatusController submitTx: EVM bridge should successfully submit an EVM bridge transaction with no approval 2`] = ` +{ + "account": "0xaccount1", + "actionId": "1234567891.456", + "approvalTxId": undefined, + "batchId": undefined, + "estimatedProcessingTimeInSeconds": 15, + "featureId": undefined, + "hasApprovalTx": false, + "initialDestAssetBalance": undefined, + "isStxEnabled": false, + "location": "Unknown", + "originalTransactionId": "test-tx-id", + "pricingData": { + "amountSent": "1.234", + "amountSentInUsd": "1.01", + "quotedGasAmount": ".00055", + "quotedGasInUsd": "2.5778", + "quotedReturnInUsd": "0.134214", + }, + "quote": { + "bridgeId": "lifi", + "bridges": [ + "across", + ], + "destAsset": { + "address": "0x0000000000000000000000000000000000000032", + "assetId": "eip155:10/slip44:60", + "chainId": 10, + "coinKey": "WETH", + "decimals": 18, + "icon": "https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png", + "logoURI": "https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png", + "name": "WETH", + "priceUSD": "2478.63", + "symbol": "WETH", + }, + "destChainId": 10, + "destTokenAmount": "990654755978612", + "feeData": { + "metabridge": { + "amount": "8750000000000", + "asset": { + "address": "0x0000000000000000000000000000000000000000", + "assetId": "eip155:42161/slip44:60", + "chainId": 42161, + "coinKey": "ETH", + "decimals": 18, + "icon": "https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png", + "logoURI": "https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png", + "name": "ETH", + "priceUSD": "2478.7", + "symbol": "ETH", + }, + }, + }, + "minDestTokenAmount": "941000000000000", + "requestId": "197c402f-cb96-4096-9f8c-54aed84ca776", + "srcAsset": { + "address": "0x0000000000000000000000000000000000000000", + "assetId": "eip155:42161/slip44:60", + "chainId": 42161, + "coinKey": "ETH", + "decimals": 18, + "icon": "https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png", + "logoURI": "https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png", + "name": "ETH", + "priceUSD": "2478.7", + "symbol": "ETH", + }, + "srcChainId": 42161, + "srcTokenAmount": "991250000000000", + "steps": [ + { + "action": "bridge", + "destAmount": "990654755978612", + "destAsset": { + "address": "0x0000000000000000000000000000000000000000", + "assetId": "eip155:10/slip44:60", + "chainId": 10, + "coinKey": "ETH", + "decimals": 18, + "icon": "https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png", + "logoURI": "https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png", + "name": "ETH", + "priceUSD": "2478.63", + "symbol": "ETH", + }, + "destChainId": 10, + "protocol": { + "displayName": "Across", + "icon": "https://raw.githubusercontent.com/lifinance/types/main/src/assets/icons/bridges/acrossv2.png", + "name": "across", + }, + "srcAmount": "991250000000000", + "srcAsset": { + "address": "0x0000000000000000000000000000000000000000", + "assetId": "eip155:42161/slip44:60", + "chainId": 42161, + "coinKey": "ETH", + "decimals": 18, + "icon": "https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png", + "logoURI": "https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png", + "name": "ETH", + "priceUSD": "2478.7", + "symbol": "ETH", + }, + "srcChainId": 42161, + }, + ], + }, + "quoteId": undefined, + "slippagePercentage": 0, + "startTime": 1234567890, + "status": { + "srcChain": { + "chainId": 42161, + "txHash": "0xevmTxHash", + }, + "status": "PENDING", + }, + "targetContractAddress": undefined, + "txMetaId": "test-tx-id", +} +`; + +exports[`BridgeStatusController submitTx: EVM bridge should successfully submit an EVM bridge transaction with no approval 3`] = ` +[ + [ + "BridgeController:stopPollingForQuotes", + "Transaction submitted", + undefined, + ], + [ + "AccountsController:getAccountByAddress", + "0xaccount1", + ], + [ + "BridgeController:trackUnifiedSwapBridgeEvent", + "Unified SwapBridge Submitted", + { + "account_hardware_type": null, + "action_type": "swapbridge-v1", + "chain_id_destination": "eip155:10", + "chain_id_source": "eip155:42161", + "custom_slippage": false, + "feature_id": "unified_swap_bridge", + "gas_included": false, + "gas_included_7702": false, + "is_hardware_wallet": false, + "location": "Unknown", + "price_impact": 0, + "provider": "lifi_across", + "quoted_time_minutes": 0.25, + "slippage_limit": 0, + "stx_enabled": false, + "swap_type": "crosschain", + "token_address_destination": "eip155:10/slip44:60", + "token_address_source": "eip155:42161/slip44:60", + "token_security_type_destination": null, + "token_symbol_destination": "WETH", + "token_symbol_source": "ETH", + "usd_amount_source": 1.01, + "usd_quoted_gas": 2.5778, + "usd_quoted_return": 0, + }, + ], + [ + "TransactionController:isAtomicBatchSupported", + { + "address": "0xaccount1", + "chainIds": [ + "0xa4b1", + ], + }, + ], + [ + "AccountsController:getAccountByAddress", + "0xaccount1", + ], + [ + "NetworkController:findNetworkClientIdByChainId", + "0xa4b1", + ], + [ + "TransactionController:estimateGasFee", + { + "chainId": "0xa4b1", + "networkClientId": "arbitrum", + "transactionParams": { + "data": "0xdata", + "from": "0xaccount1", + "gas": "0x5208", + "to": "0xbridgeContract", + "value": "0x0", + }, + }, + ], + [ + "TransactionController:addTransaction", + { + "data": "0xdata", + "from": "0xaccount1", + "gas": "0x5208", + "maxFeePerGas": undefined, + "maxPriorityFeePerGas": undefined, + "to": "0xbridgeContract", + "value": "0x0", + }, + { + "actionId": "1234567891.456", + "isInternal": true, + "networkClientId": "arbitrum", + "origin": "metamask", + "requireApproval": false, + "type": "bridge", + }, + ], + [ + "TransactionController:getState", + ], +] +`; + +exports[`BridgeStatusController submitTx: EVM bridge should throw an error if approval tx fails 1`] = ` +[ + [ + "BridgeController:stopPollingForQuotes", + "Transaction submitted", + undefined, + ], + [ + "AccountsController:getAccountByAddress", + "0xaccount1", + ], + [ + "BridgeController:trackUnifiedSwapBridgeEvent", + "Unified SwapBridge Submitted", + { + "account_hardware_type": null, + "action_type": "swapbridge-v1", + "chain_id_destination": "eip155:10", + "chain_id_source": "eip155:42161", + "custom_slippage": false, + "feature_id": "unified_swap_bridge", + "gas_included": false, + "gas_included_7702": false, + "is_hardware_wallet": false, + "location": "Unknown", + "price_impact": 0, + "provider": "lifi_across", + "quoted_time_minutes": 0.25, + "slippage_limit": 0, + "stx_enabled": false, + "swap_type": "crosschain", + "token_address_destination": "eip155:10/slip44:60", + "token_address_source": "eip155:42161/slip44:60", + "token_security_type_destination": null, + "token_symbol_destination": "ETH", + "token_symbol_source": "ETH", + "usd_amount_source": 1.01, + "usd_quoted_gas": 2.5778, + "usd_quoted_return": 0, + }, + ], + [ + "TransactionController:isAtomicBatchSupported", + { + "address": "0xaccount1", + "chainIds": [ + "0xa4b1", + ], + }, + ], + [ + "AccountsController:getAccountByAddress", + "0xaccount1", + ], + [ + "NetworkController:findNetworkClientIdByChainId", + "0xa4b1", + ], + [ + "TransactionController:estimateGasFee", + { + "chainId": "0xa4b1", + "networkClientId": "arbitrum-client-id", + "transactionParams": { + "data": "0xapprovalData", + "from": "0xaccount1", + "gas": "0x5208", + "to": "0xtokenContract", + "value": "0x0", + }, + }, + ], + [ + "TransactionController:addTransaction", + { + "data": "0xapprovalData", + "from": "0xaccount1", + "gas": "0x5208", + "maxFeePerGas": undefined, + "maxPriorityFeePerGas": undefined, + "to": "0xtokenContract", + "value": "0x0", + }, + { + "actionId": "1234567891.456", + "isInternal": true, + "networkClientId": "arbitrum-client-id", + "origin": "metamask", + "requireApproval": false, + "type": "bridgeApproval", + }, + ], + [ + "BridgeController:trackUnifiedSwapBridgeEvent", + "Unified SwapBridge Failed", + { + "account_hardware_type": null, + "action_type": "swapbridge-v1", + "chain_id_destination": "eip155:10", + "chain_id_source": "eip155:42161", + "custom_slippage": false, + "error_message": "Approval tx failed", + "feature_id": "unified_swap_bridge", + "gas_included": false, + "gas_included_7702": false, + "is_hardware_wallet": false, + "location": "Unknown", + "price_impact": 0, + "provider": "lifi_across", + "quoted_time_minutes": 0.25, + "slippage_limit": 0, + "stx_enabled": false, + "swap_type": "crosschain", + "token_address_destination": "eip155:10/slip44:60", + "token_address_source": "eip155:42161/slip44:60", + "token_security_type_destination": null, + "token_symbol_destination": "ETH", + "token_symbol_source": "ETH", + "usd_amount_source": 1.01, + "usd_quoted_gas": 2.5778, + "usd_quoted_return": 0, + }, + ], +] +`; + +exports[`BridgeStatusController submitTx: EVM bridge should throw an error if approval tx meta does not exist 1`] = ` +[ + [ + "BridgeController:stopPollingForQuotes", + "Transaction submitted", + undefined, + ], + [ + "AccountsController:getAccountByAddress", + "0xaccount1", + ], + [ + "BridgeController:trackUnifiedSwapBridgeEvent", + "Unified SwapBridge Submitted", + { + "account_hardware_type": null, + "action_type": "swapbridge-v1", + "chain_id_destination": "eip155:10", + "chain_id_source": "eip155:42161", + "custom_slippage": false, + "feature_id": "unified_swap_bridge", + "gas_included": false, + "gas_included_7702": false, + "is_hardware_wallet": false, + "location": "Unknown", + "price_impact": 0, + "provider": "lifi_across", + "quoted_time_minutes": 0.25, + "slippage_limit": 0, + "stx_enabled": false, + "swap_type": "crosschain", + "token_address_destination": "eip155:10/slip44:60", + "token_address_source": "eip155:42161/slip44:60", + "token_security_type_destination": null, + "token_symbol_destination": "ETH", + "token_symbol_source": "ETH", + "usd_amount_source": 1.01, + "usd_quoted_gas": 2.5778, + "usd_quoted_return": 0, + }, + ], + [ + "TransactionController:isAtomicBatchSupported", + { + "address": "0xaccount1", + "chainIds": [ + "0xa4b1", + ], + }, + ], + [ + "AccountsController:getAccountByAddress", + "0xaccount1", + ], + [ + "NetworkController:findNetworkClientIdByChainId", + "0xa4b1", + ], + [ + "TransactionController:estimateGasFee", + { + "chainId": "0xa4b1", + "networkClientId": "arbitrum-client-id", + "transactionParams": { + "data": "0xapprovalData", + "from": "0xaccount1", + "gas": "0x5208", + "to": "0xtokenContract", + "value": "0x0", + }, + }, + ], + [ + "TransactionController:addTransaction", + { + "data": "0xapprovalData", + "from": "0xaccount1", + "gas": "0x5208", + "maxFeePerGas": undefined, + "maxPriorityFeePerGas": undefined, + "to": "0xtokenContract", + "value": "0x0", + }, + { + "actionId": "1234567891.456", + "isInternal": true, + "networkClientId": "arbitrum-client-id", + "origin": "metamask", + "requireApproval": false, + "type": "bridgeApproval", + }, + ], + [ + "TransactionController:getState", + ], + [ + "BridgeController:trackUnifiedSwapBridgeEvent", + "Unified SwapBridge Failed", + { + "account_hardware_type": null, + "action_type": "swapbridge-v1", + "chain_id_destination": "eip155:10", + "chain_id_source": "eip155:42161", + "custom_slippage": false, + "error_message": "Failed to submit cross-chain swap tx: txMeta for txHash was not found", + "feature_id": "unified_swap_bridge", + "gas_included": false, + "gas_included_7702": false, + "is_hardware_wallet": false, + "location": "Unknown", + "price_impact": 0, + "provider": "lifi_across", + "quoted_time_minutes": 0.25, + "slippage_limit": 0, + "stx_enabled": false, + "swap_type": "crosschain", + "token_address_destination": "eip155:10/slip44:60", + "token_address_source": "eip155:42161/slip44:60", + "token_security_type_destination": null, + "token_symbol_destination": "ETH", + "token_symbol_source": "ETH", + "usd_amount_source": 1.01, + "usd_quoted_gas": 2.5778, + "usd_quoted_return": 0, + }, + ], +] +`; + +exports[`BridgeStatusController submitTx: EVM bridge waits for approval tx confirmation before swap for hardware wallet on mobile 1`] = ` +{ + "chainId": "0xa4b1", + "hash": "0xevmTxHash", + "id": "test-tx-id", + "status": "unapproved", + "time": 1234567890, + "txParams": { + "chainId": "0xa4b1", + "data": "0xdata", + "from": "0xaccount1", + "gasLimit": "0x5208", + "to": "0xbridgeContract", + "value": "0x0", + }, + "txReceipt": { + "effectiveGasPrice": "0x1880a", + "gasUsed": "0x2c92a", + }, + "type": "bridge", +} +`; + +exports[`BridgeStatusController submitTx: EVM bridge waits for approval tx confirmation before swap for hardware wallet on mobile 2`] = ` +{ + "account": "0xaccount1", + "actionId": "1234567892.457", + "approvalTxId": "test-approval-tx-id", + "batchId": undefined, + "estimatedProcessingTimeInSeconds": 15, + "featureId": undefined, + "hasApprovalTx": true, + "initialDestAssetBalance": undefined, + "isStxEnabled": false, + "location": "Unknown", + "originalTransactionId": "test-tx-id", + "pricingData": { + "amountSent": "1.234", + "amountSentInUsd": "1.01", + "quotedGasAmount": ".00055", + "quotedGasInUsd": "2.5778", + "quotedReturnInUsd": "0.134214", + }, + "quote": { + "bridgeId": "lifi", + "bridges": [ + "across", + ], + "destAsset": { + "address": "0x0000000000000000000000000000000000000000", + "assetId": "eip155:10/slip44:60", + "chainId": 10, + "coinKey": "ETH", + "decimals": 18, + "icon": "https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png", + "logoURI": "https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png", + "name": "ETH", + "priceUSD": "2478.63", + "symbol": "ETH", + }, + "destChainId": 10, + "destTokenAmount": "990654755978612", + "feeData": { + "metabridge": { + "amount": "8750000000000", + "asset": { + "address": "0x0000000000000000000000000000000000000000", + "assetId": "eip155:42161/slip44:60", + "chainId": 42161, + "coinKey": "ETH", + "decimals": 18, + "icon": "https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png", + "logoURI": "https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png", + "name": "ETH", + "priceUSD": "2478.7", + "symbol": "ETH", + }, + }, + }, + "minDestTokenAmount": "941000000000000", + "requestId": "197c402f-cb96-4096-9f8c-54aed84ca776", + "srcAsset": { + "address": "0x0000000000000000000000000000000000000000", + "assetId": "eip155:42161/slip44:60", + "chainId": 42161, + "coinKey": "ETH", + "decimals": 18, + "icon": "https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png", + "logoURI": "https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png", + "name": "ETH", + "priceUSD": "2478.7", + "symbol": "ETH", + }, + "srcChainId": 42161, + "srcTokenAmount": "991250000000000", + "steps": [ + { + "action": "bridge", + "destAmount": "990654755978612", + "destAsset": { + "address": "0x0000000000000000000000000000000000000000", + "assetId": "eip155:10/slip44:60", + "chainId": 10, + "coinKey": "ETH", + "decimals": 18, + "icon": "https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png", + "logoURI": "https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png", + "name": "ETH", + "priceUSD": "2478.63", + "symbol": "ETH", + }, + "destChainId": 10, + "protocol": { + "displayName": "Across", + "icon": "https://raw.githubusercontent.com/lifinance/types/main/src/assets/icons/bridges/acrossv2.png", + "name": "across", + }, + "srcAmount": "991250000000000", + "srcAsset": { + "address": "0x0000000000000000000000000000000000000000", + "assetId": "eip155:42161/slip44:60", + "chainId": 42161, + "coinKey": "ETH", + "decimals": 18, + "icon": "https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png", + "logoURI": "https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png", + "name": "ETH", + "priceUSD": "2478.7", + "symbol": "ETH", + }, + "srcChainId": 42161, + }, + ], + }, + "quoteId": undefined, + "slippagePercentage": 0, + "startTime": 1234567890, + "status": { + "srcChain": { + "chainId": 42161, + "txHash": "0xevmTxHash", + }, + "status": "PENDING", + }, + "targetContractAddress": undefined, + "txMetaId": "test-tx-id", +} +`; + +exports[`BridgeStatusController submitTx: EVM bridge waits for approval tx confirmation before swap for hardware wallet on mobile 3`] = ` +[ + [ + "BridgeController:stopPollingForQuotes", + "Transaction submitted", + undefined, + ], + [ + "AccountsController:getAccountByAddress", + "0xaccount1", + ], + [ + "BridgeController:trackUnifiedSwapBridgeEvent", + "Unified SwapBridge Submitted", + { + "account_hardware_type": "Ledger", + "action_type": "swapbridge-v1", + "chain_id_destination": "eip155:10", + "chain_id_source": "eip155:42161", + "custom_slippage": false, + "feature_id": "unified_swap_bridge", + "gas_included": false, + "gas_included_7702": false, + "is_hardware_wallet": true, + "location": "Unknown", + "price_impact": 0, + "provider": "lifi_across", + "quoted_time_minutes": 0.25, + "slippage_limit": 0, + "stx_enabled": false, + "swap_type": "crosschain", + "token_address_destination": "eip155:10/slip44:60", + "token_address_source": "eip155:42161/slip44:60", + "token_security_type_destination": null, + "token_symbol_destination": "ETH", + "token_symbol_source": "ETH", + "usd_amount_source": 1.01, + "usd_quoted_gas": 2.5778, + "usd_quoted_return": 0, + }, + ], + [ + "TransactionController:isAtomicBatchSupported", + { + "address": "0xaccount1", + "chainIds": [ + "0xa4b1", + ], + }, + ], + [ + "AccountsController:getAccountByAddress", + "0xaccount1", + ], + [ + "NetworkController:findNetworkClientIdByChainId", + "0xa4b1", + ], + [ + "TransactionController:estimateGasFee", + { + "chainId": "0xa4b1", + "networkClientId": "arbitrum-client-id", + "transactionParams": { + "data": "0xapprovalData", + "from": "0xaccount1", + "gas": "0x5208", + "to": "0xtokenContract", + "value": "0x0", + }, + }, + ], + [ + "TransactionController:addTransaction", + { + "data": "0xapprovalData", + "from": "0xaccount1", + "gas": "0x5208", + "maxFeePerGas": undefined, + "maxPriorityFeePerGas": undefined, + "to": "0xtokenContract", + "value": "0x0", + }, + { + "actionId": "1234567891.456", + "isInternal": true, + "networkClientId": "arbitrum-client-id", + "origin": "metamask", + "requireApproval": true, + "type": "bridgeApproval", + }, + ], + [ + "TransactionController:getState", + ], + [ + "AccountsController:getAccountByAddress", + "0xaccount1", + ], + [ + "NetworkController:findNetworkClientIdByChainId", + "0xa4b1", + ], + [ + "TransactionController:estimateGasFee", + { + "chainId": "0xa4b1", + "networkClientId": "arbitrum", + "transactionParams": { + "data": "0xdata", + "from": "0xaccount1", + "gas": "0x5208", + "to": "0xbridgeContract", + "value": "0x0", + }, + }, + ], + [ + "TransactionController:addTransaction", + { + "data": "0xdata", + "from": "0xaccount1", + "gas": "0x5208", + "maxFeePerGas": undefined, + "maxPriorityFeePerGas": undefined, + "to": "0xbridgeContract", + "value": "0x0", + }, + { + "actionId": "1234567892.457", + "isInternal": true, + "networkClientId": "arbitrum", + "origin": "metamask", + "requireApproval": true, + "type": "bridge", + }, + ], + [ + "TransactionController:getState", + ], +] +`; + +exports[`BridgeStatusController submitTx: EVM bridge waits for approval tx confirmation before swap for hardware wallet on mobile 4`] = ` +[ + [ + { + "data": { + "feature_id": "unified_swap_bridge", + "srcChainId": "eip155:42161", + "stxEnabled": false, + }, + "name": "Bridge Transaction Completed", + }, + [Function], + ], + [ + { + "data": { + "feature_id": "unified_swap_bridge", + "srcChainId": "eip155:42161", + "stxEnabled": false, + }, + "name": "Bridge Transaction Approval Completed", + }, + [Function], + ], +] +`; + +exports[`BridgeStatusController submitTx: EVM swap should estimate gas when gasIncluded is false and STX is off 1`] = ` +{ + "chainId": "0xa4b1", + "hash": "0xevmTxHash", + "id": "test-tx-id", + "status": "unapproved", + "time": 1234567890, + "txParams": { + "chainId": "0xa4b1", + "data": "0xdata", + "from": "0xaccount1", + "gasLimit": "0x5208", + "to": "0xbridgeContract", + "value": "0x0", + }, + "type": "swap", +} +`; + +exports[`BridgeStatusController submitTx: EVM swap should gracefully handle isAtomicBatchSupported failure 1`] = ` +[ + [ + "BridgeController:stopPollingForQuotes", + "Transaction submitted", + undefined, + ], + [ + "AccountsController:getAccountByAddress", + "0xaccount1", + ], + [ + "BridgeController:trackUnifiedSwapBridgeEvent", + "Unified SwapBridge Submitted", + { + "account_hardware_type": null, + "action_type": "swapbridge-v1", + "chain_id_destination": "eip155:42161", + "chain_id_source": "eip155:42161", + "custom_slippage": false, + "feature_id": "unified_swap_bridge", + "gas_included": false, + "gas_included_7702": false, + "is_hardware_wallet": false, + "location": "Unknown", + "price_impact": 0, + "provider": "lifi_across", + "quoted_time_minutes": 0, + "slippage_limit": 0, + "stx_enabled": false, + "swap_type": "single_chain", + "token_address_destination": "eip155:10/slip44:60", + "token_address_source": "eip155:42161/slip44:60", + "token_security_type_destination": null, + "token_symbol_destination": "ETH", + "token_symbol_source": "ETH", + "usd_amount_source": 1.01, + "usd_quoted_gas": 2.5778, + "usd_quoted_return": 0, + }, + ], + [ + "TransactionController:isAtomicBatchSupported", + { + "address": "0xaccount1", + "chainIds": [ + "0xa4b1", + ], + }, + ], + [ + "AccountsController:getAccountByAddress", + "0xaccount1", + ], + [ + "NetworkController:findNetworkClientIdByChainId", + "0xa4b1", + ], + [ + "TransactionController:estimateGasFee", + { + "chainId": "0xa4b1", + "networkClientId": "arbitrum-client-id", + "transactionParams": { + "data": "0xapprovalData", + "from": "0xaccount1", + "gas": "0x5208", + "to": "0xtokenContract", + "value": "0x0", + }, + }, + ], + [ + "TransactionController:addTransaction", + { + "data": "0xapprovalData", + "from": "0xaccount1", + "gas": "0x5208", + "maxFeePerGas": undefined, + "maxPriorityFeePerGas": undefined, + "to": "0xtokenContract", + "value": "0x0", + }, + { + "actionId": "1234567891.456", + "isInternal": true, + "networkClientId": "arbitrum-client-id", + "origin": "metamask", + "requireApproval": false, + "type": "swapApproval", + }, + ], + [ + "TransactionController:getState", + ], + [ + "AccountsController:getAccountByAddress", + "0xaccount1", + ], + [ + "NetworkController:findNetworkClientIdByChainId", + "0xa4b1", + ], + [ + "TransactionController:estimateGasFee", + { + "chainId": "0xa4b1", + "networkClientId": "arbitrum", + "transactionParams": { + "data": "0xdata", + "from": "0xaccount1", + "gas": "0x5208", + "to": "0xbridgeContract", + "value": "0x0", + }, + }, + ], + [ + "TransactionController:addTransaction", + { + "data": "0xdata", + "from": "0xaccount1", + "gas": "0x5208", + "maxFeePerGas": undefined, + "maxPriorityFeePerGas": undefined, + "to": "0xbridgeContract", + "value": "0x0", + }, + { + "actionId": "1234567892.457", + "isInternal": true, + "networkClientId": "arbitrum", + "origin": "metamask", + "requireApproval": false, + "type": "swap", + }, + ], + [ + "TransactionController:getState", + ], +] +`; + +exports[`BridgeStatusController submitTx: EVM swap should handle a gasless swap transaction with approval 2`] = ` +{ + "account": "0xaccount1", + "actionId": undefined, + "approvalTxId": undefined, + "batchId": "batchId1", + "estimatedProcessingTimeInSeconds": 0, + "featureId": undefined, + "hasApprovalTx": true, + "initialDestAssetBalance": undefined, + "isStxEnabled": true, + "location": "Unknown", + "originalTransactionId": "test-tx-id", + "pricingData": { + "amountSent": "1.234", + "amountSentInUsd": "1.01", + "quotedGasAmount": "1.234", + "quotedGasInUsd": "2.5778", + "quotedReturnInUsd": "0.134214", + }, + "quote": { + "bridgeId": "lifi", + "bridges": [ + "across", + ], + "destAsset": { + "address": "0x0000000000000000000000000000000000000000", + "assetId": "eip155:10/slip44:60", + "chainId": 10, + "coinKey": "ETH", + "decimals": 18, + "icon": "https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png", + "logoURI": "https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png", + "name": "ETH", + "priceUSD": "2478.63", + "symbol": "ETH", + }, + "destChainId": 42161, + "destTokenAmount": "990654755978612", + "feeData": { + "metabridge": { + "amount": "8750000000000", + "asset": { + "address": "0x0000000000000000000000000000000000000000", + "assetId": "eip155:42161/slip44:60", + "chainId": 42161, + "coinKey": "ETH", + "decimals": 18, + "icon": "https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png", + "logoURI": "https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png", + "name": "ETH", + "priceUSD": "2478.7", + "symbol": "ETH", + }, + }, + "txFee": { + "amount": "100", + "asset": { + "address": "0x0000000000000000000000000000000000000000", + "assetId": "eip155:42161/slip44:60", + "chainId": 42161, + "decimals": 18, + "iconUrl": "", + "name": "Ether", + "symbol": "ETH", + }, + "maxFeePerGas": "123", + "maxPriorityFeePerGas": "123", + }, + }, + "gasIncluded": true, + "minDestTokenAmount": "941000000000000", + "requestId": "197c402f-cb96-4096-9f8c-54aed84ca776", + "srcAsset": { + "address": "0x0000000000000000000000000000000000000000", + "assetId": "eip155:42161/slip44:60", + "chainId": 42161, + "coinKey": "ETH", + "decimals": 18, + "icon": "https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png", + "logoURI": "https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png", + "name": "ETH", + "priceUSD": "2478.7", + "symbol": "ETH", + }, + "srcChainId": 42161, + "srcTokenAmount": "991250000000000", + "steps": [ + { + "action": "bridge", + "destAmount": "990654755978612", + "destAsset": { + "address": "0x0000000000000000000000000000000000000000", + "assetId": "eip155:10/slip44:60", + "chainId": 10, + "coinKey": "ETH", + "decimals": 18, + "icon": "https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png", + "logoURI": "https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png", + "name": "ETH", + "priceUSD": "2478.63", + "symbol": "ETH", + }, + "destChainId": 10, + "protocol": { + "displayName": "Across", + "icon": "https://raw.githubusercontent.com/lifinance/types/main/src/assets/icons/bridges/acrossv2.png", + "name": "across", + }, + "srcAmount": "991250000000000", + "srcAsset": { + "address": "0x0000000000000000000000000000000000000000", + "assetId": "eip155:42161/slip44:60", + "chainId": 42161, + "coinKey": "ETH", + "decimals": 18, + "icon": "https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png", + "logoURI": "https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png", + "name": "ETH", + "priceUSD": "2478.7", + "symbol": "ETH", + }, + "srcChainId": 42161, + }, + ], + }, + "quoteId": undefined, + "slippagePercentage": 0, + "startTime": 1234567890, + "status": { + "srcChain": { + "chainId": 42161, + "txHash": undefined, + }, + "status": "PENDING", + }, + "targetContractAddress": undefined, + "txMetaId": "test-tx-id", +} +`; + +exports[`BridgeStatusController submitTx: EVM swap should handle a gasless swap transaction with fees paid in ERC20 1`] = ` +{ + "account": "0xaccount1", + "actionId": undefined, + "approvalTxId": undefined, + "batchId": "batchId1", + "estimatedProcessingTimeInSeconds": 0, + "featureId": undefined, + "hasApprovalTx": true, + "initialDestAssetBalance": undefined, + "isStxEnabled": true, + "location": "Unknown", + "originalTransactionId": "test-tx-id", + "pricingData": { + "amountSent": "1.234", + "amountSentInUsd": "1.01", + "quotedGasAmount": "1.234", + "quotedGasInUsd": "2.5778", + "quotedReturnInUsd": "0.134214", + }, + "quoteId": undefined, + "slippagePercentage": 0, + "startTime": 1234567890, + "status": { + "srcChain": { + "chainId": 42161, + "txHash": undefined, + }, + "status": "PENDING", + }, + "targetContractAddress": undefined, + "txMetaId": "test-tx-id", +} +`; + +exports[`BridgeStatusController submitTx: EVM swap should handle smart transactions 1`] = ` +{ + "batchId": "batchId1", + "chainId": "0xa4b1", + "hash": "0xevmTxHash", + "id": "test-tx-id", + "status": "unapproved", + "time": 1234567890, + "txParams": { + "chainId": "0xa4b1", + "data": "0xdata", + "from": "0xaccount1", + "gasLimit": "0x5208", + "to": "0xbridgeContract", + "value": "0x0", + }, + "type": "swap", +} +`; + +exports[`BridgeStatusController submitTx: EVM swap should handle smart transactions 2`] = ` +{ + "account": "0xaccount1", + "actionId": undefined, + "approvalTxId": undefined, + "batchId": "batchId1", + "estimatedProcessingTimeInSeconds": 0, + "featureId": undefined, + "hasApprovalTx": true, + "initialDestAssetBalance": undefined, + "isStxEnabled": true, + "location": "Unknown", + "originalTransactionId": "test-tx-id", + "pricingData": { + "amountSent": "1.234", + "amountSentInUsd": "1.01", + "quotedGasAmount": "1.234", + "quotedGasInUsd": "2.5778", + "quotedReturnInUsd": "0.134214", + }, + "quote": { + "bridgeId": "lifi", + "bridges": [ + "across", + ], + "destAsset": { + "address": "0x0000000000000000000000000000000000000000", + "assetId": "eip155:10/slip44:60", + "chainId": 10, + "coinKey": "ETH", + "decimals": 18, + "icon": "https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png", + "logoURI": "https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png", + "name": "ETH", + "priceUSD": "2478.63", + "symbol": "ETH", + }, + "destChainId": 42161, + "destTokenAmount": "990654755978612", + "feeData": { + "metabridge": { + "amount": "8750000000000", + "asset": { + "address": "0x0000000000000000000000000000000000000000", + "assetId": "eip155:42161/slip44:60", + "chainId": 42161, + "coinKey": "ETH", + "decimals": 18, + "icon": "https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png", + "logoURI": "https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png", + "name": "ETH", + "priceUSD": "2478.7", + "symbol": "ETH", + }, + }, + }, + "minDestTokenAmount": "941000000000000", + "requestId": "197c402f-cb96-4096-9f8c-54aed84ca776", + "srcAsset": { + "address": "0x0000000000000000000000000000000000000000", + "assetId": "eip155:42161/slip44:60", + "chainId": 42161, + "coinKey": "ETH", + "decimals": 18, + "icon": "https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png", + "logoURI": "https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png", + "name": "ETH", + "priceUSD": "2478.7", + "symbol": "ETH", + }, + "srcChainId": 42161, + "srcTokenAmount": "991250000000000", + "steps": [ + { + "action": "bridge", + "destAmount": "990654755978612", + "destAsset": { + "address": "0x0000000000000000000000000000000000000000", + "assetId": "eip155:10/slip44:60", + "chainId": 10, + "coinKey": "ETH", + "decimals": 18, + "icon": "https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png", + "logoURI": "https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png", + "name": "ETH", + "priceUSD": "2478.63", + "symbol": "ETH", + }, + "destChainId": 10, + "protocol": { + "displayName": "Across", + "icon": "https://raw.githubusercontent.com/lifinance/types/main/src/assets/icons/bridges/acrossv2.png", + "name": "across", + }, + "srcAmount": "991250000000000", + "srcAsset": { + "address": "0x0000000000000000000000000000000000000000", + "assetId": "eip155:42161/slip44:60", + "chainId": 42161, + "coinKey": "ETH", + "decimals": 18, + "icon": "https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png", + "logoURI": "https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png", + "name": "ETH", + "priceUSD": "2478.7", + "symbol": "ETH", + }, + "srcChainId": 42161, + }, + ], + }, + "quoteId": undefined, + "slippagePercentage": 0, + "startTime": 1234567890, + "status": { + "srcChain": { + "chainId": 42161, + "txHash": undefined, + }, + "status": "PENDING", + }, + "targetContractAddress": undefined, + "txMetaId": "test-tx-id", +} +`; + +exports[`BridgeStatusController submitTx: EVM swap should handle smart transactions 3`] = ` +[ + [ + { + "atomic": true, + "disable7702": true, + "from": "0xaccount1", + "isGasFeeIncluded": false, + "isGasFeeSponsored": false, + "isInternal": true, + "networkClientId": "arbitrum", + "origin": "metamask", + "requireApproval": false, + "transactions": [ + { + "assetsFiatValues": undefined, + "params": { + "data": "0xapprovalData", + "from": "0xaccount1", + "gas": "0x5208", + "maxFeePerGas": undefined, + "maxPriorityFeePerGas": undefined, + "to": "0xtokenContract", + "value": "0x0", + }, + "type": "swapApproval", + }, + { + "assetsFiatValues": { + "receiving": "2.9999", + "sending": "2.00", + }, + "params": { + "data": "0xdata", + "from": "0xaccount1", + "gas": "0x5208", + "maxFeePerGas": undefined, + "maxPriorityFeePerGas": undefined, + "to": "0xbridgeContract", + "value": "0x0", + }, + "type": "swap", + }, + ], + }, + ], +] +`; + +exports[`BridgeStatusController submitTx: EVM swap should handle smart transactions 4`] = ` +[ + [ + "BridgeController:stopPollingForQuotes", + "Transaction submitted", + undefined, + ], + [ + "AccountsController:getAccountByAddress", + "0xaccount1", + ], + [ + "BridgeController:trackUnifiedSwapBridgeEvent", + "Unified SwapBridge Submitted", + { + "account_hardware_type": null, + "action_type": "swapbridge-v1", + "chain_id_destination": "eip155:42161", + "chain_id_source": "eip155:42161", + "custom_slippage": false, + "feature_id": "unified_swap_bridge", + "gas_included": false, + "gas_included_7702": false, + "is_hardware_wallet": false, + "location": "Unknown", + "price_impact": 0, + "provider": "lifi_across", + "quoted_time_minutes": 0, + "slippage_limit": 0, + "stx_enabled": true, + "swap_type": "single_chain", + "token_address_destination": "eip155:10/slip44:60", + "token_address_source": "eip155:42161/slip44:60", + "token_security_type_destination": null, + "token_symbol_destination": "ETH", + "token_symbol_source": "ETH", + "usd_amount_source": 1.01, + "usd_quoted_gas": 2.5778, + "usd_quoted_return": 0, + }, + ], + [ + "TransactionController:isAtomicBatchSupported", + { + "address": "0xaccount1", + "chainIds": [ + "0xa4b1", + ], + }, + ], + [ + "AccountsController:getAccountByAddress", + "0xaccount1", + ], + [ + "NetworkController:findNetworkClientIdByChainId", + "0xa4b1", + ], + [ + "TransactionController:estimateGasFee", + { + "chainId": "0xa4b1", + "networkClientId": "arbitrum", + "transactionParams": { + "data": "0xapprovalData", + "from": "0xaccount1", + "gas": "0x5208", + "to": "0xtokenContract", + "value": "0x0", + }, + }, + ], + [ + "TransactionController:estimateGasFee", + { + "chainId": "0xa4b1", + "networkClientId": "arbitrum", + "transactionParams": { + "data": "0xdata", + "from": "0xaccount1", + "gas": "0x5208", + "to": "0xbridgeContract", + "value": "0x0", + }, + }, + ], + [ + "TransactionController:getState", + ], + [ + "TransactionController:updateTransaction", + { + "batchId": "batchId1", + "chainId": "0xa4b1", + "hash": "0xevmTxHash", + "id": "test-tx-id", + "status": "unapproved", + "time": 1234567890, + "txParams": { + "chainId": "0xa4b1", + "data": "0xdata", + "from": "0xaccount1", + "gasLimit": "0x5208", + "to": "0xbridgeContract", + "value": "0x0", + }, + "type": "swap", + }, + "Update tx type to swap", + ], +] +`; + +exports[`BridgeStatusController submitTx: EVM swap should successfully submit an EVM swap transaction with approval 1`] = ` +{ + "chainId": "0xa4b1", + "hash": "0xevmTxHash", + "id": "test-tx-id", + "status": "unapproved", + "time": 1234567890, + "txParams": { + "chainId": "0xa4b1", + "data": "0xdata", + "from": "0xaccount1", + "gasLimit": "0x5208", + "to": "0xbridgeContract", + "value": "0x0", + }, + "type": "swap", +} +`; + +exports[`BridgeStatusController submitTx: EVM swap should successfully submit an EVM swap transaction with featureId=perps 1`] = ` +{ + "chainId": "0xa4b1", + "hash": "0xevmTxHash", + "id": "test-tx-id", + "status": "unapproved", + "time": 1234567890, + "txParams": { + "chainId": "0xa4b1", + "data": "0xdata", + "from": "0xaccount1", + "gasLimit": "0x5208", + "to": "0xbridgeContract", + "value": "0x0", + }, + "type": "swap", +} +`; + +exports[`BridgeStatusController submitTx: EVM swap should successfully submit an EVM swap transaction with featureId=perps 2`] = ` +[ + [ + "BridgeController:stopPollingForQuotes", + "Transaction submitted", + undefined, + ], + [ + "AccountsController:getAccountByAddress", + "0xaccount1", + ], + [ + "TransactionController:isAtomicBatchSupported", + { + "address": "0xaccount1", + "chainIds": [ + "0xa4b1", + ], + }, + ], + [ + "AccountsController:getAccountByAddress", + "0xaccount1", + ], + [ + "NetworkController:findNetworkClientIdByChainId", + "0xa4b1", + ], + [ + "TransactionController:estimateGasFee", + { + "chainId": "0xa4b1", + "networkClientId": "arbitrum-client-id", + "transactionParams": { + "data": "0xapprovalData", + "from": "0xaccount1", + "gas": "0x5208", + "to": "0xtokenContract", + "value": "0x0", + }, + }, + ], + [ + "TransactionController:addTransaction", + { + "data": "0xapprovalData", + "from": "0xaccount1", + "gas": "0x5208", + "maxFeePerGas": undefined, + "maxPriorityFeePerGas": undefined, + "to": "0xtokenContract", + "value": "0x0", + }, + { + "actionId": "1234567891.456", + "isInternal": true, + "networkClientId": "arbitrum-client-id", + "origin": "metamask", + "requireApproval": false, + "type": "swapApproval", + }, + ], + [ + "TransactionController:getState", + ], + [ + "AccountsController:getAccountByAddress", + "0xaccount1", + ], + [ + "NetworkController:findNetworkClientIdByChainId", + "0xa4b1", + ], + [ + "TransactionController:estimateGasFee", + { + "chainId": "0xa4b1", + "networkClientId": "arbitrum", + "transactionParams": { + "data": "0xdata", + "from": "0xaccount1", + "gas": "0x5208", + "to": "0xbridgeContract", + "value": "0x0", + }, + }, + ], + [ + "TransactionController:addTransaction", + { + "data": "0xdata", + "from": "0xaccount1", + "gas": "0x5208", + "maxFeePerGas": undefined, + "maxPriorityFeePerGas": undefined, + "to": "0xbridgeContract", + "value": "0x0", + }, + { + "actionId": "1234567892.457", + "isInternal": true, + "networkClientId": "arbitrum", + "origin": "metamask", + "requireApproval": false, + "type": "swap", + }, + ], + [ + "TransactionController:getState", + ], +] +`; + +exports[`BridgeStatusController submitTx: EVM swap should successfully submit an EVM swap transaction with no approval 1`] = ` +{ + "chainId": "0xa4b1", + "hash": "0xevmTxHash", + "id": "test-tx-id", + "status": "unapproved", + "time": 1234567890, + "txParams": { + "chainId": "0xa4b1", + "data": "0xdata", + "from": "0xaccount1", + "gasLimit": "0x5208", + "to": "0xbridgeContract", + "value": "0x0", + }, + "type": "swap", +} +`; + +exports[`BridgeStatusController submitTx: EVM swap should successfully submit an EVM swap transaction with no approval 2`] = ` +{ + "account": "0xaccount1", + "actionId": "1234567891.456", + "approvalTxId": undefined, + "batchId": undefined, + "estimatedProcessingTimeInSeconds": 0, + "featureId": undefined, + "hasApprovalTx": false, + "initialDestAssetBalance": undefined, + "isStxEnabled": false, + "location": "Unknown", + "originalTransactionId": "test-tx-id", + "pricingData": { + "amountSent": "1.234", + "amountSentInUsd": "1.01", + "quotedGasAmount": undefined, + "quotedGasInUsd": undefined, + "quotedReturnInUsd": "0.134214", + }, + "quote": { + "bridgeId": "lifi", + "bridges": [ + "across", + ], + "destAsset": { + "address": "0x0000000000000000000000000000000000000032", + "assetId": "eip155:10/slip44:60", + "chainId": 10, + "coinKey": "WETH", + "decimals": 18, + "icon": "https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png", + "logoURI": "https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png", + "name": "WETH", + "priceUSD": "2478.63", + "symbol": "WETH", + }, + "destChainId": 42161, + "destTokenAmount": "990654755978612", + "feeData": { + "metabridge": { + "amount": "8750000000000", + "asset": { + "address": "0x0000000000000000000000000000000000000000", + "assetId": "eip155:42161/slip44:60", + "chainId": 42161, + "coinKey": "ETH", + "decimals": 18, + "icon": "https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png", + "logoURI": "https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png", + "name": "ETH", + "priceUSD": "2478.7", + "symbol": "ETH", + }, + }, + }, + "minDestTokenAmount": "941000000000000", + "requestId": "197c402f-cb96-4096-9f8c-54aed84ca776", + "srcAsset": { + "address": "0x0000000000000000000000000000000000000000", + "assetId": "eip155:42161/slip44:60", + "chainId": 42161, + "coinKey": "ETH", + "decimals": 18, + "icon": "https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png", + "logoURI": "https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png", + "name": "ETH", + "priceUSD": "2478.7", + "symbol": "ETH", + }, + "srcChainId": 42161, + "srcTokenAmount": "991250000000000", + "steps": [ + { + "action": "bridge", + "destAmount": "990654755978612", + "destAsset": { + "address": "0x0000000000000000000000000000000000000000", + "assetId": "eip155:10/slip44:60", + "chainId": 10, + "coinKey": "ETH", + "decimals": 18, + "icon": "https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png", + "logoURI": "https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png", + "name": "ETH", + "priceUSD": "2478.63", + "symbol": "ETH", + }, + "destChainId": 10, + "protocol": { + "displayName": "Across", + "icon": "https://raw.githubusercontent.com/lifinance/types/main/src/assets/icons/bridges/acrossv2.png", + "name": "across", + }, + "srcAmount": "991250000000000", + "srcAsset": { + "address": "0x0000000000000000000000000000000000000000", + "assetId": "eip155:42161/slip44:60", + "chainId": 42161, + "coinKey": "ETH", + "decimals": 18, + "icon": "https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png", + "logoURI": "https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png", + "name": "ETH", + "priceUSD": "2478.7", + "symbol": "ETH", + }, + "srcChainId": 42161, + }, + ], + }, + "quoteId": undefined, + "slippagePercentage": 0, + "startTime": 1234567890, + "status": { + "srcChain": { + "chainId": 42161, + "txHash": "0xevmTxHash", + }, + "status": "PENDING", + }, + "targetContractAddress": undefined, + "txMetaId": "test-tx-id", +} +`; + +exports[`BridgeStatusController submitTx: EVM swap should successfully submit an EVM swap transaction with no approval 3`] = ` +[ + [ + "BridgeController:stopPollingForQuotes", + "Transaction submitted", + undefined, + ], + [ + "AccountsController:getAccountByAddress", + "0xaccount1", + ], + [ + "BridgeController:trackUnifiedSwapBridgeEvent", + "Unified SwapBridge Submitted", + { + "account_hardware_type": null, + "action_type": "swapbridge-v1", + "chain_id_destination": "eip155:42161", + "chain_id_source": "eip155:42161", + "custom_slippage": false, + "feature_id": "unified_swap_bridge", + "gas_included": false, + "gas_included_7702": false, + "is_hardware_wallet": false, + "location": "Unknown", + "price_impact": 0, + "provider": "lifi_across", + "quoted_time_minutes": 0, + "slippage_limit": 0, + "stx_enabled": false, + "swap_type": "single_chain", + "token_address_destination": "eip155:10/slip44:60", + "token_address_source": "eip155:42161/slip44:60", + "token_security_type_destination": null, + "token_symbol_destination": "WETH", + "token_symbol_source": "ETH", + "usd_amount_source": 1.01, + "usd_quoted_gas": 0, + "usd_quoted_return": 0, + }, + ], + [ + "TransactionController:isAtomicBatchSupported", + { + "address": "0xaccount1", + "chainIds": [ + "0xa4b1", + ], + }, + ], + [ + "AccountsController:getAccountByAddress", + "0xaccount1", + ], + [ + "NetworkController:findNetworkClientIdByChainId", + "0xa4b1", + ], + [ + "TransactionController:estimateGasFee", + { + "chainId": "0xa4b1", + "networkClientId": "arbitrum", + "transactionParams": { + "data": "0xdata", + "from": "0xaccount1", + "gas": "0x5208", + "to": "0xbridgeContract", + "value": "0x0", + }, + }, + ], + [ + "TransactionController:addTransaction", + { + "data": "0xdata", + "from": "0xaccount1", + "gas": "0x5208", + "maxFeePerGas": undefined, + "maxPriorityFeePerGas": undefined, + "to": "0xbridgeContract", + "value": "0x0", + }, + { + "actionId": "1234567891.456", + "isInternal": true, + "networkClientId": "arbitrum", + "origin": "metamask", + "requireApproval": false, + "type": "swap", + }, + ], + [ + "TransactionController:getState", + ], +] +`; + +exports[`BridgeStatusController submitTx: EVM swap should use batch path when account is delegated 1`] = ` +{ + "batchId": "batchId1", + "chainId": "0xa4b1", + "hash": "0xevmTxHash", + "id": "test-tx-id", + "status": "unapproved", + "time": 1234567890, + "txParams": { + "chainId": "0xa4b1", + "data": "0xdata", + "from": "0xaccount1", + "gasLimit": "0x5208", + "to": "0xbridgeContract", + "value": "0x0", + }, + "type": "swap", +} +`; + +exports[`BridgeStatusController submitTx: EVM swap should use batch path when gasIncluded7702 is true regardless of STX setting (with approval) 1`] = ` +{ + "batchId": "batchId1", + "chainId": "0xa4b1", + "hash": "0xevmTxHash", + "id": "test-tx-id", + "status": "unapproved", + "time": 1234567890, + "txParams": { + "chainId": "0xa4b1", + "data": "0xdata", + "from": "0xaccount1", + "gasLimit": "0x5208", + "to": "0xbridgeContract", + "value": "0x0", + }, + "type": "swap", +} +`; + +exports[`BridgeStatusController submitTx: EVM swap should use batch path when gasIncluded7702 is true regardless of STX setting 1`] = ` +{ + "batchId": "batchId1", + "chainId": "0xa4b1", + "hash": "0xevmTxHash", + "id": "test-tx-id", + "status": "unapproved", + "time": 1234567890, + "txParams": { + "chainId": "0xa4b1", + "data": "0xdata", + "from": "0xaccount1", + "gasLimit": "0x5208", + "to": "0xbridgeContract", + "value": "0x0", + }, + "type": "swap", +} +`; + +exports[`BridgeStatusController submitTx: EVM swap should use quote txFee when gasIncluded is true and STX is off (Max native token swap) 1`] = ` +{ + "chainId": "0xa4b1", + "hash": "0xevmTxHash", + "id": "test-tx-id", + "status": "unapproved", + "time": 1234567890, + "txParams": { + "chainId": "0xa4b1", + "data": "0xdata", + "from": "0xaccount1", + "gasLimit": "0x5208", + "to": "0xbridgeContract", + "value": "0x0", + }, + "type": "swap", +} +`; + +exports[`BridgeStatusController submitTx: EVM swap should use quote txFee when gasIncluded is true and STX is off (Max native token swap) 2`] = ` +{ + "account": "0xaccount1", + "actionId": "1234567891.456", + "approvalTxId": undefined, + "batchId": undefined, + "estimatedProcessingTimeInSeconds": 0, + "featureId": undefined, + "hasApprovalTx": false, + "initialDestAssetBalance": undefined, + "isStxEnabled": false, + "location": "Unknown", + "originalTransactionId": "test-tx-id", + "pricingData": { + "amountSent": "1.234", + "amountSentInUsd": "1.01", + "quotedGasAmount": "1.234", + "quotedGasInUsd": "2.5778", + "quotedReturnInUsd": "0.134214", + }, + "quote": { + "bridgeId": "lifi", + "bridges": [ + "across", + ], + "destAsset": { + "address": "0x0000000000000000000000000000000000000000", + "assetId": "eip155:10/slip44:60", + "chainId": 10, + "coinKey": "ETH", + "decimals": 18, + "icon": "https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png", + "logoURI": "https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png", + "name": "ETH", + "priceUSD": "2478.63", + "symbol": "ETH", + }, + "destChainId": 42161, + "destTokenAmount": "990654755978612", + "feeData": { + "metabridge": { + "amount": "8750000000000", + "asset": { + "address": "0x0000000000000000000000000000000000000000", + "assetId": "eip155:42161/slip44:60", + "chainId": 42161, + "coinKey": "ETH", + "decimals": 18, + "icon": "https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png", + "logoURI": "https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png", + "name": "ETH", + "priceUSD": "2478.7", + "symbol": "ETH", + }, + }, + "txFee": { + "amount": "8750000000000", + "asset": { + "address": "0x0000000000000000000000000000000000000000", + "assetId": "eip155:42161/slip44:60", + "chainId": 42161, + "coinKey": "ETH", + "decimals": 18, + "icon": "https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png", + "logoURI": "https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png", + "name": "ETH", + "priceUSD": "2478.7", + "symbol": "ETH", + }, + "maxFeePerGas": "1395348", + "maxPriorityFeePerGas": "1000001", + }, + }, + "gasIncluded": true, + "gasIncluded7702": false, + "minDestTokenAmount": "941000000000000", + "requestId": "197c402f-cb96-4096-9f8c-54aed84ca776", + "srcAsset": { + "address": "0x0000000000000000000000000000000000000000", + "assetId": "eip155:42161/slip44:60", + "chainId": 42161, + "coinKey": "ETH", + "decimals": 18, + "icon": "https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png", + "logoURI": "https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png", + "name": "ETH", + "priceUSD": "2478.7", + "symbol": "ETH", + }, + "srcChainId": 42161, + "srcTokenAmount": "991250000000000", + "steps": [ + { + "action": "bridge", + "destAmount": "990654755978612", + "destAsset": { + "address": "0x0000000000000000000000000000000000000000", + "assetId": "eip155:10/slip44:60", + "chainId": 10, + "coinKey": "ETH", + "decimals": 18, + "icon": "https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png", + "logoURI": "https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png", + "name": "ETH", + "priceUSD": "2478.63", + "symbol": "ETH", + }, + "destChainId": 10, + "protocol": { + "displayName": "Across", + "icon": "https://raw.githubusercontent.com/lifinance/types/main/src/assets/icons/bridges/acrossv2.png", + "name": "across", + }, + "srcAmount": "991250000000000", + "srcAsset": { + "address": "0x0000000000000000000000000000000000000000", + "assetId": "eip155:42161/slip44:60", + "chainId": 42161, + "coinKey": "ETH", + "decimals": 18, + "icon": "https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png", + "logoURI": "https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png", + "name": "ETH", + "priceUSD": "2478.7", + "symbol": "ETH", + }, + "srcChainId": 42161, + }, + ], + }, + "quoteId": undefined, + "slippagePercentage": 0, + "startTime": 1234567890, + "status": { + "srcChain": { + "chainId": 42161, + "txHash": "0xevmTxHash", + }, + "status": "PENDING", + }, + "targetContractAddress": undefined, + "txMetaId": "test-tx-id", +} +`; + +exports[`BridgeStatusController submitTx: EVM swap should use quote txFee when gasIncluded is true and STX is off (null gasLimit) 1`] = ` +{ + "chainId": "0xa4b1", + "hash": "0xevmTxHash", + "id": "test-tx-id", + "status": "unapproved", + "time": 1234567890, + "txParams": { + "chainId": "0xa4b1", + "data": "0xdata", + "from": "0xaccount1", + "gasLimit": "0x5208", + "to": "0xbridgeContract", + "value": "0x0", + }, + "type": "swap", +} +`; + +exports[`BridgeStatusController submitTx: EVM swap should use quote txFee when gasIncluded is true and STX is off (null gasLimit) 2`] = ` +{ + "account": "0xaccount1", + "actionId": "1234567891.456", + "approvalTxId": undefined, + "batchId": undefined, + "estimatedProcessingTimeInSeconds": 0, + "featureId": undefined, + "hasApprovalTx": false, + "initialDestAssetBalance": undefined, + "isStxEnabled": false, + "location": "Unknown", + "originalTransactionId": "test-tx-id", + "pricingData": { + "amountSent": "0", + "amountSentInUsd": undefined, + "quotedGasAmount": "1.234", + "quotedGasInUsd": "2.5778", + "quotedReturnInUsd": "0.134214", + }, + "quote": { + "bridgeId": "lifi", + "bridges": [ + "across", + ], + "destAsset": { + "address": "0x0000000000000000000000000000000000000000", + "assetId": "eip155:10/slip44:60", + "chainId": 10, + "coinKey": "ETH", + "decimals": 18, + "icon": "https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png", + "logoURI": "https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png", + "name": "ETH", + "priceUSD": "2478.63", + "symbol": "ETH", + }, + "destChainId": 42161, + "destTokenAmount": "990654755978612", + "feeData": { + "metabridge": { + "amount": "8750000000000", + "asset": { + "address": "0x0000000000000000000000000000000000000000", + "assetId": "eip155:42161/slip44:60", + "chainId": 42161, + "coinKey": "ETH", + "decimals": 18, + "icon": "https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png", + "logoURI": "https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png", + "name": "ETH", + "priceUSD": "2478.7", + "symbol": "ETH", + }, + }, + "txFee": { + "amount": "100", + "asset": { + "address": "0x0000000000000000000000000000000000000000", + "assetId": "eip155:42161/slip44:60", + "chainId": 42161, + "coinKey": "ETH", + "decimals": 18, + "icon": "https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png", + "logoURI": "https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png", + "name": "ETH", + "priceUSD": "2478.7", + "symbol": "ETH", + }, + "maxFeePerGas": "1395348", + "maxPriorityFeePerGas": "1000001", + }, + }, + "gasIncluded": true, + "gasIncluded7702": false, + "minDestTokenAmount": "941000000000000", + "requestId": "197c402f-cb96-4096-9f8c-54aed84ca776", + "slippage": 0.01, + "srcAsset": { + "address": "0x0000000000000000000000000000000000000000", + "assetId": "eip155:42161/slip44:60", + "chainId": 42161, + "coinKey": "ETH", + "decimals": 18, + "icon": "https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png", + "logoURI": "https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png", + "name": "ETH", + "priceUSD": "2478.7", + "symbol": "ETH", + }, + "srcChainId": 42161, + "srcTokenAmount": "991250000000000", + "steps": [ + { + "action": "bridge", + "destAmount": "990654755978612", + "destAsset": { + "address": "0x0000000000000000000000000000000000000000", + "assetId": "eip155:10/slip44:60", + "chainId": 10, + "coinKey": "ETH", + "decimals": 18, + "icon": "https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png", + "logoURI": "https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png", + "name": "ETH", + "priceUSD": "2478.63", + "symbol": "ETH", + }, + "destChainId": 10, + "protocol": { + "displayName": "Across", + "icon": "https://raw.githubusercontent.com/lifinance/types/main/src/assets/icons/bridges/acrossv2.png", + "name": "across", + }, + "srcAmount": "991250000000000", + "srcAsset": { + "address": "0x0000000000000000000000000000000000000000", + "assetId": "eip155:42161/slip44:60", + "chainId": 42161, + "coinKey": "ETH", + "decimals": 18, + "icon": "https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png", + "logoURI": "https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png", + "name": "ETH", + "priceUSD": "2478.7", + "symbol": "ETH", + }, + "srcChainId": 42161, + }, + ], + }, + "quoteId": undefined, + "slippagePercentage": 0.01, + "startTime": 1234567890, + "status": { + "srcChain": { + "chainId": 42161, + "txHash": "0xevmTxHash", + }, + "status": "PENDING", + }, + "targetContractAddress": undefined, + "txMetaId": "test-tx-id", +} +`; + +exports[`BridgeStatusController submitTx: Solana bridge should handle snap controller errors 1`] = ` +[ + [ + "BridgeController:stopPollingForQuotes", + "Transaction submitted", + undefined, + ], + [ + "AccountsController:getAccountByAddress", + "SOLaccountAddress", + ], + [ + "BridgeController:trackUnifiedSwapBridgeEvent", + "Unified SwapBridge Submitted", + { + "account_hardware_type": null, + "action_type": "swapbridge-v1", + "chain_id_destination": "eip155:1", + "chain_id_source": "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp", + "custom_slippage": false, + "feature_id": "unified_swap_bridge", + "gas_included": false, + "gas_included_7702": false, + "is_hardware_wallet": false, + "location": "Unknown", + "price_impact": 0, + "provider": "test-bridge_test-bridge", + "quoted_time_minutes": 5, + "slippage_limit": 0, + "stx_enabled": false, + "swap_type": "crosschain", + "token_address_destination": "eip155:1/slip44:60", + "token_address_source": "eip155:1399811149/slip44:501", + "token_security_type_destination": null, + "token_symbol_destination": "ETH", + "token_symbol_source": "SOL", + "usd_amount_source": 100, + "usd_quoted_gas": 5, + "usd_quoted_return": 985, + }, + ], + [ + "SnapController:handleRequest", + { + "handler": "onClientRequest", + "origin": "metamask", + "request": { + "id": "test-uuid-1234", + "jsonrpc": "2.0", + "method": "signAndSendTransaction", + "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=", + }, + }, + "snapId": "test-snap", + }, + ], + [ + "BridgeController:trackUnifiedSwapBridgeEvent", + "Unified SwapBridge Failed", + { + "account_hardware_type": null, + "action_type": "swapbridge-v1", + "chain_id_destination": "eip155:1", + "chain_id_source": "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp", + "custom_slippage": false, + "error_message": "Snap error", + "feature_id": "unified_swap_bridge", + "gas_included": false, + "gas_included_7702": false, + "is_hardware_wallet": false, + "location": "Unknown", + "price_impact": 0, + "provider": "test-bridge_test-bridge", + "quoted_time_minutes": 5, + "slippage_limit": 0, + "stx_enabled": false, + "swap_type": "crosschain", + "token_address_destination": "eip155:1/slip44:60", + "token_address_source": "eip155:1399811149/slip44:501", + "token_security_type_destination": null, + "token_symbol_destination": "ETH", + "token_symbol_source": "SOL", + "usd_amount_source": 100, + "usd_quoted_gas": 5, + "usd_quoted_return": 985, + }, + ], +] +`; + +exports[`BridgeStatusController submitTx: Solana bridge should successfully submit a transaction 1`] = ` +[ + [ + "BridgeController:stopPollingForQuotes", + "Transaction submitted", + undefined, + ], + [ + "AccountsController:getAccountByAddress", + "SOLaccountAddress", + ], + [ + "BridgeController:trackUnifiedSwapBridgeEvent", + "Unified SwapBridge Submitted", + { + "account_hardware_type": null, + "action_type": "swapbridge-v1", + "chain_id_destination": "eip155:1", + "chain_id_source": "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp", + "custom_slippage": false, + "feature_id": "unified_swap_bridge", + "gas_included": false, + "gas_included_7702": false, + "is_hardware_wallet": false, + "location": "Unknown", + "price_impact": 0, + "provider": "test-bridge_test-bridge", + "quoted_time_minutes": 5, + "slippage_limit": 0, + "stx_enabled": false, + "swap_type": "crosschain", + "token_address_destination": "eip155:1/slip44:60", + "token_address_source": "eip155:1399811149/slip44:501", + "token_security_type_destination": null, + "token_symbol_destination": "ETH", + "token_symbol_source": "SOL", + "usd_amount_source": 100, + "usd_quoted_gas": 5, + "usd_quoted_return": 985, + }, + ], + [ + "SnapController:handleRequest", + { + "handler": "onClientRequest", + "origin": "metamask", + "request": { + "id": "test-uuid-1234", + "jsonrpc": "2.0", + "method": "signAndSendTransaction", + "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=", + }, + }, + "snapId": "test-snap", + }, + ], +] +`; + +exports[`BridgeStatusController submitTx: Solana bridge should successfully submit a transaction 2`] = ` +{ + "approvalTxId": undefined, + "chainId": "0x416edef1601be", + "destinationChainId": "0x1", + "destinationTokenAddress": "0x...", + "destinationTokenAmount": "0.5", + "destinationTokenDecimals": 18, + "destinationTokenSymbol": "ETH", + "hash": "signature", + "id": "signature", + "isBridgeTx": true, + "isSolana": true, + "networkClientId": "test-snap", + "origin": "test-snap", + "sourceTokenAddress": "native", + "sourceTokenAmount": "1000000000", + "sourceTokenDecimals": 9, + "sourceTokenSymbol": "SOL", + "status": "submitted", + "swapTokenValue": "1", + "time": 1234567891, + "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...", + }, + "type": "bridge", +} +`; + +exports[`BridgeStatusController submitTx: Solana bridge should successfully submit a transaction 3`] = ` +{ + "bridgeTxMetaId": "signature", +} +`; + +exports[`BridgeStatusController submitTx: Solana bridge should successfully submit a transaction 4`] = ` +{ + "account": "0x123...", + "actionId": undefined, + "approvalTxId": undefined, + "batchId": undefined, + "estimatedProcessingTimeInSeconds": 300, + "featureId": undefined, + "hasApprovalTx": false, + "initialDestAssetBalance": undefined, + "isStxEnabled": false, + "location": "Unknown", + "originalTransactionId": "signature", + "pricingData": { + "amountSent": "1", + "amountSentInUsd": "100", + "quotedGasAmount": "0.05", + "quotedGasInUsd": "5", + "quotedReturnInUsd": "1000", + }, + "quote": { + "bridgeId": "test-bridge", + "bridges": [ + "test-bridge", + ], + "destAsset": { + "address": "0x...", + "assetId": "eip155:1/slip44:60", + "chainId": 1, + "decimals": 18, + "name": "Ethereum", + "symbol": "ETH", + }, + "destChainId": 1, + "destTokenAmount": "0.5", + "feeData": { + "metabridge": { + "amount": "1000000", + "asset": { + "address": "native", + "assetId": "eip155:1399811149/slip44:501", + "chainId": 1151111081099710, + "decimals": 9, + "name": "Solana", + "symbol": "SOL", + }, + }, + }, + "minDestTokenAmount": "0.475", + "requestId": "123", + "srcAsset": { + "address": "native", + "assetId": "eip155:1399811149/slip44:501", + "chainId": 1151111081099710, + "decimals": 9, + "name": "Solana", + "symbol": "SOL", + }, + "srcChainId": 1151111081099710, + "srcTokenAmount": "1000000000", + "steps": [ + { + "action": "bridge", + "destAsset": { + "address": "0x...", + "assetId": "eip155:1/slip44:60", + "chainId": 1, + "decimals": 18, + "name": "Ethereum", + "symbol": "ETH", + }, + "destChainId": 1, + "srcAsset": { + "address": "native", + "assetId": "eip155:1399811149/slip44:501", + "chainId": 1151111081099710, + "decimals": 9, + "name": "Solana", + "symbol": "SOL", + }, + "srcChainId": 1151111081099710, + }, + ], + }, + "quoteId": undefined, + "slippagePercentage": 0, + "startTime": 1234567890, + "status": { + "srcChain": { + "chainId": 1151111081099710, + "txHash": "signature", + }, + "status": "PENDING", + }, + "targetContractAddress": undefined, + "txMetaId": "signature", +} +`; + +exports[`BridgeStatusController submitTx: Solana bridge should throw error when snap ID is missing 1`] = ` +[ + [ + "BridgeController:stopPollingForQuotes", + "Transaction submitted", + undefined, + ], + [ + "AccountsController:getAccountByAddress", + "SOLaccountAddress", + ], + [ + "BridgeController:trackUnifiedSwapBridgeEvent", + "Unified SwapBridge Submitted", + { + "account_hardware_type": null, + "action_type": "swapbridge-v1", + "chain_id_destination": "eip155:1", + "chain_id_source": "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp", + "custom_slippage": false, + "feature_id": "unified_swap_bridge", + "gas_included": false, + "gas_included_7702": false, + "is_hardware_wallet": false, + "location": "Unknown", + "price_impact": 0, + "provider": "test-bridge_test-bridge", + "quoted_time_minutes": 5, + "slippage_limit": 0, + "stx_enabled": false, + "swap_type": "crosschain", + "token_address_destination": "eip155:1/slip44:60", + "token_address_source": "eip155:1399811149/slip44:501", + "token_security_type_destination": null, + "token_symbol_destination": "ETH", + "token_symbol_source": "SOL", + "usd_amount_source": 100, + "usd_quoted_gas": 5, + "usd_quoted_return": 985, + }, + ], + [ + "BridgeController:trackUnifiedSwapBridgeEvent", + "Unified SwapBridge Failed", + { + "account_hardware_type": null, + "action_type": "swapbridge-v1", + "chain_id_destination": "eip155:1", + "chain_id_source": "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp", + "custom_slippage": false, + "error_message": "Failed to submit cross-chain swap transaction: undefined snap id", + "feature_id": "unified_swap_bridge", + "gas_included": false, + "gas_included_7702": false, + "is_hardware_wallet": false, + "location": "Unknown", + "price_impact": 0, + "provider": "test-bridge_test-bridge", + "quoted_time_minutes": 5, + "slippage_limit": 0, + "stx_enabled": false, + "swap_type": "crosschain", + "token_address_destination": "eip155:1/slip44:60", + "token_address_source": "eip155:1399811149/slip44:501", + "token_security_type_destination": null, + "token_symbol_destination": "ETH", + "token_symbol_source": "SOL", + "usd_amount_source": 100, + "usd_quoted_gas": 5, + "usd_quoted_return": 985, + }, + ], +] +`; + +exports[`BridgeStatusController submitTx: Solana swap should handle snap controller errors 1`] = ` +[ + [ + "BridgeController:stopPollingForQuotes", + "Transaction submitted", + undefined, + ], + [ + "AccountsController:getAccountByAddress", + "SOLaccountAddress", + ], + [ + "BridgeController:trackUnifiedSwapBridgeEvent", + "Unified SwapBridge Submitted", + { + "account_hardware_type": "QR Hardware", + "action_type": "swapbridge-v1", + "chain_id_destination": "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp", + "chain_id_source": "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp", + "custom_slippage": false, + "feature_id": "unified_swap_bridge", + "gas_included": false, + "gas_included_7702": false, + "is_hardware_wallet": true, + "location": "Unknown", + "price_impact": 0, + "provider": "test-bridge_undefined", + "quoted_time_minutes": 5, + "slippage_limit": 0, + "stx_enabled": false, + "swap_type": "single_chain", + "token_address_destination": "eip155:1399811149/slip44:501", + "token_address_source": "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/slip44:501", + "token_security_type_destination": null, + "token_symbol_destination": "USDC", + "token_symbol_source": "SOL", + "usd_amount_source": 100, + "usd_quoted_gas": 5, + "usd_quoted_return": 985, + }, + ], + [ + "SnapController:handleRequest", + { + "handler": "onClientRequest", + "origin": "metamask", + "request": { + "id": "test-uuid-1234", + "jsonrpc": "2.0", + "method": "signAndSendTransaction", + "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=", + }, + }, + "snapId": "test-snap", + }, + ], + [ + "BridgeController:trackUnifiedSwapBridgeEvent", + "Unified SwapBridge Failed", + { + "account_hardware_type": "QR Hardware", + "action_type": "swapbridge-v1", + "chain_id_destination": "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp", + "chain_id_source": "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp", + "custom_slippage": false, + "error_message": "Snap error", + "feature_id": "unified_swap_bridge", + "gas_included": false, + "gas_included_7702": false, + "is_hardware_wallet": true, + "location": "Unknown", + "price_impact": 0, + "provider": "test-bridge_undefined", + "quoted_time_minutes": 5, + "slippage_limit": 0, + "stx_enabled": false, + "swap_type": "single_chain", + "token_address_destination": "eip155:1399811149/slip44:501", + "token_address_source": "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/slip44:501", + "token_security_type_destination": null, + "token_symbol_destination": "USDC", + "token_symbol_source": "SOL", + "usd_amount_source": 100, + "usd_quoted_gas": 5, + "usd_quoted_return": 985, + }, + ], +] +`; + +exports[`BridgeStatusController submitTx: Solana swap should successfully submit a transaction 1`] = ` +[ + [ + "BridgeController:stopPollingForQuotes", + "Transaction submitted", + undefined, + ], + [ + "AccountsController:getAccountByAddress", + "SOLaccountAddress", + ], + [ + "BridgeController:trackUnifiedSwapBridgeEvent", + "Unified SwapBridge Submitted", + { + "account_hardware_type": "QR Hardware", + "action_type": "swapbridge-v1", + "chain_id_destination": "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp", + "chain_id_source": "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp", + "custom_slippage": false, + "feature_id": "unified_swap_bridge", + "gas_included": false, + "gas_included_7702": false, + "is_hardware_wallet": true, + "location": "Unknown", + "price_impact": 0, + "provider": "test-bridge_undefined", + "quoted_time_minutes": 5, + "slippage_limit": 0, + "stx_enabled": false, + "swap_type": "single_chain", + "token_address_destination": "eip155:1399811149/slip44:501", + "token_address_source": "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/slip44:501", + "token_security_type_destination": null, + "token_symbol_destination": "USDC", + "token_symbol_source": "SOL", + "usd_amount_source": 100, + "usd_quoted_gas": 5, + "usd_quoted_return": 985, + }, + ], + [ + "SnapController:handleRequest", + { + "handler": "onClientRequest", + "origin": "metamask", + "request": { + "id": "test-uuid-1234", + "jsonrpc": "2.0", + "method": "signAndSendTransaction", + "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=", + }, + }, + "snapId": "test-snap", + }, + ], + [ + "AccountsController:getAccountByAddress", + "0x123...", + ], + [ + "TransactionController:getState", + ], + [ + "BridgeController:trackUnifiedSwapBridgeEvent", + "Unified SwapBridge Completed", + { + "account_hardware_type": "QR Hardware", + "action_type": "swapbridge-v1", + "actual_time_minutes": 0, + "allowance_reset_transaction": undefined, + "approval_transaction": undefined, + "chain_id_destination": "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp", + "chain_id_source": "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp", + "custom_slippage": false, + "destination_transaction": "PENDING", + "feature_id": "unified_swap_bridge", + "gas_included": false, + "gas_included_7702": false, + "is_hardware_wallet": true, + "location": "Unknown", + "price_impact": 0, + "provider": "test-bridge_undefined", + "quote_vs_execution_ratio": 0, + "quoted_time_minutes": 5, + "quoted_vs_used_gas_ratio": 0, + "security_warnings": [], + "slippage_limit": 0, + "source_transaction": "COMPLETE", + "stx_enabled": false, + "swap_type": "single_chain", + "token_address_destination": "eip155:1399811149/slip44:501", + "token_address_source": "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/slip44:501", + "token_security_type_destination": null, + "token_symbol_destination": "USDC", + "token_symbol_source": "SOL", + "usd_actual_gas": 0, + "usd_actual_return": 0, + "usd_amount_source": 100, + "usd_quoted_gas": 5, + "usd_quoted_return": 1000, + }, + ], +] +`; + +exports[`BridgeStatusController submitTx: Solana swap should successfully submit a transaction 2`] = ` +{ + "approvalTxId": undefined, + "chainId": "0x416edef1601be", + "destinationChainId": "0x416edef1601be", + "destinationTokenAddress": "0x...", + "destinationTokenAmount": "500000000000000000s", + "destinationTokenDecimals": 18, + "destinationTokenSymbol": "USDC", + "hash": "signature", + "id": "signature", + "isBridgeTx": false, + "isSolana": true, + "networkClientId": "test-snap", + "origin": "test-snap", + "sourceTokenAddress": "native", + "sourceTokenAmount": "1000000000", + "sourceTokenDecimals": 9, + "sourceTokenSymbol": "SOL", + "status": "submitted", + "swapTokenValue": "1", + "time": 1234567891, + "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...", + }, + "type": "swap", +} +`; + +exports[`BridgeStatusController submitTx: Solana swap should successfully submit a transaction 3`] = ` +{ + "account": "0x123...", + "actionId": undefined, + "approvalTxId": undefined, + "batchId": undefined, + "estimatedProcessingTimeInSeconds": 300, + "featureId": undefined, + "hasApprovalTx": false, + "initialDestAssetBalance": undefined, + "isStxEnabled": false, + "location": "Unknown", + "originalTransactionId": "signature", + "pricingData": { + "amountSent": "1", + "amountSentInUsd": "100", + "quotedGasAmount": "0.05", + "quotedGasInUsd": "5", + "quotedReturnInUsd": "1000", + }, + "quote": { + "bridgeId": "test-bridge", + "bridges": [], + "destAsset": { + "address": "0x...", + "assetId": "eip155:1399811149/slip44:501", + "chainId": 1151111081099710, + "decimals": 18, + "name": "USDC", + "symbol": "USDC", + }, + "destChainId": 1151111081099710, + "destTokenAmount": "500000000000000000s", + "feeData": { + "metabridge": { + "amount": "1000000", + "asset": { + "address": "native", + "assetId": "eip155:1399811149/slip44:501", + "chainId": 1151111081099710, + "decimals": 9, + "name": "Solana", + "symbol": "SOL", + }, + }, + }, + "minDestTokenAmount": "475000000000000000s", + "requestId": "123", + "srcAsset": { + "address": "native", + "assetId": "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/slip44:501", + "chainId": 1151111081099710, + "decimals": 9, + "name": "Solana", + "symbol": "SOL", + }, + "srcChainId": 1151111081099710, + "srcTokenAmount": "1000000000", + "steps": [ + { + "action": "bridge", + "destAsset": { + "address": "0x...", + "assetId": "eip155:1/slip44:60", + "chainId": 1, + "decimals": 18, + "name": "Ethereum", + "symbol": "ETH", + }, + "destChainId": 1, + "srcAsset": { + "address": "native", + "assetId": "eip155:1399811149/slip44:501", + "chainId": 1151111081099710, + "decimals": 9, + "name": "Solana", + "symbol": "SOL", + }, + "srcChainId": 1151111081099710, + }, + ], + }, + "quoteId": undefined, + "slippagePercentage": 0, + "startTime": 1234567890, + "status": { + "srcChain": { + "chainId": 1151111081099710, + "txHash": "signature", + }, + "status": "PENDING", + }, + "targetContractAddress": undefined, + "txMetaId": "signature", +} +`; + +exports[`BridgeStatusController submitTx: Solana swap should throw error when account is missing 1`] = ` +[ + [ + "BridgeController:stopPollingForQuotes", + "Transaction submitted", + undefined, + ], + [ + "AccountsController:getAccountByAddress", + "SOLaccountAddress", + ], +] +`; + +exports[`BridgeStatusController submitTx: Solana swap should throw error when snap ID is missing 1`] = ` +[ + [ + "BridgeController:stopPollingForQuotes", + "Transaction submitted", + undefined, + ], + [ + "AccountsController:getAccountByAddress", + "SOLaccountAddress", + ], + [ + "BridgeController:trackUnifiedSwapBridgeEvent", + "Unified SwapBridge Submitted", + { + "account_hardware_type": null, + "action_type": "swapbridge-v1", + "chain_id_destination": "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp", + "chain_id_source": "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp", + "custom_slippage": false, + "feature_id": "unified_swap_bridge", + "gas_included": false, + "gas_included_7702": false, + "is_hardware_wallet": false, + "location": "Unknown", + "price_impact": 0, + "provider": "test-bridge_undefined", + "quoted_time_minutes": 5, + "slippage_limit": 0, + "stx_enabled": false, + "swap_type": "single_chain", + "token_address_destination": "eip155:1399811149/slip44:501", + "token_address_source": "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/slip44:501", + "token_security_type_destination": null, + "token_symbol_destination": "USDC", + "token_symbol_source": "SOL", + "usd_amount_source": 100, + "usd_quoted_gas": 5, + "usd_quoted_return": 985, + }, + ], + [ + "BridgeController:trackUnifiedSwapBridgeEvent", + "Unified SwapBridge Failed", + { + "account_hardware_type": null, + "action_type": "swapbridge-v1", + "chain_id_destination": "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp", + "chain_id_source": "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp", + "custom_slippage": false, + "error_message": "Failed to submit cross-chain swap transaction: undefined snap id", + "feature_id": "unified_swap_bridge", + "gas_included": false, + "gas_included_7702": false, + "is_hardware_wallet": false, + "location": "Unknown", + "price_impact": 0, + "provider": "test-bridge_undefined", + "quoted_time_minutes": 5, + "slippage_limit": 0, + "stx_enabled": false, + "swap_type": "single_chain", + "token_address_destination": "eip155:1399811149/slip44:501", + "token_address_source": "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/slip44:501", + "token_security_type_destination": null, + "token_symbol_destination": "USDC", + "token_symbol_source": "SOL", + "usd_amount_source": 100, + "usd_quoted_gas": 5, + "usd_quoted_return": 985, + }, + ], +] +`; + +exports[`BridgeStatusController submitTx: Tron swap with approval should handle approval transaction errors 1`] = ` +[ + [ + "BridgeController:stopPollingForQuotes", + "Transaction submitted", + undefined, + ], + [ + "AccountsController:getAccountByAddress", + "TRXaccountAddress", + ], + [ + "BridgeController:trackUnifiedSwapBridgeEvent", + "Unified SwapBridge Submitted", + { + "account_hardware_type": null, + "action_type": "swapbridge-v1", + "chain_id_destination": "tron:728126428", + "chain_id_source": "tron:728126428", + "custom_slippage": false, + "feature_id": "unified_swap_bridge", + "gas_included": false, + "gas_included_7702": false, + "is_hardware_wallet": false, + "location": "Unknown", + "price_impact": 0, + "provider": "test-bridge_undefined", + "quoted_time_minutes": 0.5, + "slippage_limit": 0, + "stx_enabled": false, + "swap_type": "single_chain", + "token_address_destination": "tron:728126428/slip44:195", + "token_address_source": "tron:728126428/slip44:195", + "token_security_type_destination": null, + "token_symbol_destination": "TRX", + "token_symbol_source": "USDT", + "usd_amount_source": 1, + "usd_quoted_gas": 0.005, + "usd_quoted_return": 499.99, + }, + ], + [ + "SnapController:handleRequest", + { + "handler": "onClientRequest", + "origin": "metamask", + "request": { + "id": "test-uuid-1234", + "jsonrpc": "2.0", + "method": "signAndSendTransaction", + "params": { + "accountId": "tron-account-1", + "options": { + "type": undefined, + "visible": undefined, + }, + "scope": "tron:728126428", + "transaction": "CgKquyIITd6G0PaK4+VAOmgIAbJjCjF0eXBlLmdvb2dsZWFwaXMuY29tL3Byb3RvY29sLlRyaWdnZXJTbWFydENvbnRyYWN0EjMKFUGPfqjM6fi7pn165ZzUmhll1hfnGhIVQaYU+AO2/XgJhqQseOycf3fm3tE8", + }, + }, + "snapId": "npm:@metamask/tron-snap", + }, + ], + [ + "BridgeController:trackUnifiedSwapBridgeEvent", + "Unified SwapBridge Failed", + { + "account_hardware_type": null, + "action_type": "swapbridge-v1", + "chain_id_destination": "tron:728126428", + "chain_id_source": "tron:728126428", + "custom_slippage": false, + "error_message": "Approval transaction failed", + "feature_id": "unified_swap_bridge", + "gas_included": false, + "gas_included_7702": false, + "is_hardware_wallet": false, + "location": "Unknown", + "price_impact": 0, + "provider": "test-bridge_undefined", + "quoted_time_minutes": 0.5, + "slippage_limit": 0, + "stx_enabled": false, + "swap_type": "single_chain", + "token_address_destination": "tron:728126428/slip44:195", + "token_address_source": "tron:728126428/slip44:195", + "token_security_type_destination": null, + "token_symbol_destination": "TRX", + "token_symbol_source": "USDT", + "usd_amount_source": 1, + "usd_quoted_gas": 0.005, + "usd_quoted_return": 499.99, + }, + ], +] +`; + +exports[`BridgeStatusController submitTx: Tron swap with approval should successfully submit a Tron bridge with approval transaction 1`] = ` +[ + [ + "BridgeController:stopPollingForQuotes", + "Transaction submitted", + undefined, + ], + [ + "AccountsController:getAccountByAddress", + "TRXaccountAddress", + ], + [ + "BridgeController:trackUnifiedSwapBridgeEvent", + "Unified SwapBridge Submitted", + { + "account_hardware_type": null, + "action_type": "swapbridge-v1", + "chain_id_destination": "eip155:1", + "chain_id_source": "tron:728126428", + "custom_slippage": false, + "feature_id": "unified_swap_bridge", + "gas_included": false, + "gas_included_7702": false, + "is_hardware_wallet": false, + "location": "Unknown", + "price_impact": 0, + "provider": "test-bridge_undefined", + "quoted_time_minutes": 0.5, + "slippage_limit": 0, + "stx_enabled": false, + "swap_type": "crosschain", + "token_address_destination": "tron:728126428/slip44:195", + "token_address_source": "tron:728126428/slip44:195", + "token_security_type_destination": null, + "token_symbol_destination": "TRX", + "token_symbol_source": "USDT", + "usd_amount_source": 1, + "usd_quoted_gas": 0.005, + "usd_quoted_return": 499.99, + }, + ], + [ + "SnapController:handleRequest", + { + "handler": "onClientRequest", + "origin": "metamask", + "request": { + "id": "test-uuid-1234", + "jsonrpc": "2.0", + "method": "signAndSendTransaction", + "params": { + "accountId": "tron-account-1", + "options": { + "type": undefined, + "visible": undefined, + }, + "scope": "tron:728126428", + "transaction": "CgKquyIITd6G0PaK4+VAOmgIAbJjCjF0eXBlLmdvb2dsZWFwaXMuY29tL3Byb3RvY29sLlRyaWdnZXJTbWFydENvbnRyYWN0EjMKFUGPfqjM6fi7pn165ZzUmhll1hfnGhIVQaYU+AO2/XgJhqQseOycf3fm3tE8", + }, + }, + "snapId": "npm:@metamask/tron-snap", + }, + ], + [ + "SnapController:handleRequest", + { + "handler": "onClientRequest", + "origin": "metamask", + "request": { + "id": "test-uuid-1234", + "jsonrpc": "2.0", + "method": "signAndSendTransaction", + "params": { + "accountId": "tron-account-1", + "options": { + "type": undefined, + "visible": undefined, + }, + "scope": "tron:728126428", + "transaction": "CgKquyIITd6G0PaK4+VAOmgIAbJjCjF0eXBlLmdvb2dsZWFwaXMuY29tL3Byb3RvY29sLlRyaWdnZXJTbWFydENvbnRyYWN0EjMKFUGPfqjM6fi7pn165ZzUmhll1hfnGxIVQaYU+AO2/XgJhqQseOycf3fm3tE8", + }, + }, + "snapId": "npm:@metamask/tron-snap", + }, + ], +] +`; + +exports[`BridgeStatusController submitTx: Tron swap with approval should successfully submit a Tron bridge with approval transaction 2`] = ` +{ + "approvalTxId": undefined, + "chainId": "0x2b6653dc", + "destinationChainId": "0x1", + "destinationTokenAddress": "native", + "destinationTokenAmount": "500000000", + "destinationTokenDecimals": 6, + "destinationTokenSymbol": "TRX", + "hash": "bridge-signature", + "id": "bridge-signature", + "isBridgeTx": true, + "isSolana": true, + "networkClientId": "npm:@metamask/tron-snap", + "origin": "npm:@metamask/tron-snap", + "sourceTokenAddress": "TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t", + "sourceTokenAmount": "1000000", + "sourceTokenDecimals": 6, + "sourceTokenSymbol": "USDT", + "status": "submitted", + "swapTokenValue": "1", + "time": 1234567892, + "txParams": { + "data": "CgKquyIITd6G0PaK4+VAOmgIAbJjCjF0eXBlLmdvb2dsZWFwaXMuY29tL3Byb3RvY29sLlRyaWdnZXJTbWFydENvbnRyYWN0EjMKFUGPfqjM6fi7pn165ZzUmhll1hfnGxIVQaYU+AO2/XgJhqQseOycf3fm3tE8", + "from": "TRX123...", + }, + "type": "bridge", +} +`; + +exports[`BridgeStatusController submitTx: Tron swap with approval should successfully submit a Tron bridge with approval transaction 3`] = ` +{ + "bridgeTxMetaId": "bridge-signature", +} +`; + +exports[`BridgeStatusController submitTx: Tron swap with approval should successfully submit a Tron bridge with approval transaction 4`] = ` +{ + "account": "TRX123...", + "actionId": undefined, + "approvalTxId": "approval-signature", + "batchId": undefined, + "estimatedProcessingTimeInSeconds": 30, + "featureId": undefined, + "hasApprovalTx": true, + "initialDestAssetBalance": undefined, + "isStxEnabled": false, + "location": "Unknown", + "originalTransactionId": "bridge-signature", + "pricingData": { + "amountSent": "1", + "amountSentInUsd": "1", + "quotedGasAmount": "0.005", + "quotedGasInUsd": "0.005", + "quotedReturnInUsd": "500", + }, + "quote": { + "bridgeId": "test-bridge", + "bridges": [], + "destAsset": { + "address": "native", + "assetId": "tron:728126428/slip44:195", + "chainId": 728126428, + "decimals": 6, + "name": "Tron", + "symbol": "TRX", + }, + "destChainId": 1, + "destTokenAmount": "500000000", + "feeData": { + "metabridge": { + "amount": "10000", + "asset": { + "address": "native", + "assetId": "tron:728126428/slip44:195", + "chainId": 728126428, + "decimals": 6, + "name": "Tron", + "symbol": "TRX", + }, + }, + }, + "minDestTokenAmount": "475000000", + "requestId": "123", + "srcAsset": { + "address": "TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t", + "assetId": "tron:728126428/slip44:195", + "chainId": 728126428, + "decimals": 6, + "name": "Tether USD", + "symbol": "USDT", + }, + "srcChainId": 728126428, + "srcTokenAmount": "1000000", + "steps": [ + { + "action": "swap", + "destAsset": { + "address": "native", + "assetId": "tron:728126428/slip44:195", + "chainId": 728126428, + "decimals": 6, + "name": "Tron", + "symbol": "TRX", + }, + "destChainId": 728126428, + "srcAsset": { + "address": "TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t", + "assetId": "tron:728126428/slip44:195", + "chainId": 728126428, + "decimals": 6, + "name": "Tether USD", + "symbol": "USDT", + }, + "srcChainId": 728126428, + }, + ], + }, + "quoteId": undefined, + "slippagePercentage": 0, + "startTime": 1234567890, + "status": { + "srcChain": { + "chainId": 728126428, + "txHash": "bridge-signature", + }, + "status": "PENDING", + }, + "targetContractAddress": undefined, + "txMetaId": "bridge-signature", +} +`; + +exports[`BridgeStatusController submitTx: Tron swap with approval should successfully submit a Tron swap with approval transaction 1`] = ` +[ + [ + "BridgeController:stopPollingForQuotes", + "Transaction submitted", + undefined, + ], + [ + "AccountsController:getAccountByAddress", + "TRXaccountAddress", + ], + [ + "BridgeController:trackUnifiedSwapBridgeEvent", + "Unified SwapBridge Submitted", + { + "account_hardware_type": null, + "action_type": "swapbridge-v1", + "chain_id_destination": "tron:728126428", + "chain_id_source": "tron:728126428", + "custom_slippage": false, + "feature_id": "unified_swap_bridge", + "gas_included": false, + "gas_included_7702": false, + "is_hardware_wallet": false, + "location": "Unknown", + "price_impact": 0, + "provider": "test-bridge_undefined", + "quoted_time_minutes": 0.5, + "slippage_limit": 0, + "stx_enabled": false, + "swap_type": "single_chain", + "token_address_destination": "tron:728126428/slip44:195", + "token_address_source": "tron:728126428/slip44:195", + "token_security_type_destination": null, + "token_symbol_destination": "TRX", + "token_symbol_source": "USDT", + "usd_amount_source": 1, + "usd_quoted_gas": 0.005, + "usd_quoted_return": 499.99, + }, + ], + [ + "SnapController:handleRequest", + { + "handler": "onClientRequest", + "origin": "metamask", + "request": { + "id": "test-uuid-1234", + "jsonrpc": "2.0", + "method": "signAndSendTransaction", + "params": { + "accountId": "tron-account-1", + "options": { + "type": undefined, + "visible": undefined, + }, + "scope": "tron:728126428", + "transaction": "CgKquyIITd6G0PaK4+VAOmgIAbJjCjF0eXBlLmdvb2dsZWFwaXMuY29tL3Byb3RvY29sLlRyaWdnZXJTbWFydENvbnRyYWN0EjMKFUGPfqjM6fi7pn165ZzUmhll1hfnGhIVQaYU+AO2/XgJhqQseOycf3fm3tE8", + }, + }, + "snapId": "npm:@metamask/tron-snap", + }, + ], + [ + "SnapController:handleRequest", + { + "handler": "onClientRequest", + "origin": "metamask", + "request": { + "id": "test-uuid-1234", + "jsonrpc": "2.0", + "method": "signAndSendTransaction", + "params": { + "accountId": "tron-account-1", + "options": { + "type": undefined, + "visible": undefined, + }, + "scope": "tron:728126428", + "transaction": "CgKquyIITd6G0PaK4+VAOmgIAbJjCjF0eXBlLmdvb2dsZWFwaXMuY29tL3Byb3RvY29sLlRyaWdnZXJTbWFydENvbnRyYWN0EjMKFUGPfqjM6fi7pn165ZzUmhll1hfnGxIVQaYU+AO2/XgJhqQseOycf3fm3tE8", + }, + }, + "snapId": "npm:@metamask/tron-snap", + }, + ], +] +`; + +exports[`BridgeStatusController submitTx: Tron swap with approval should successfully submit a Tron swap with approval transaction 2`] = ` +{ + "approvalTxId": undefined, + "chainId": "0x2b6653dc", + "destinationChainId": "0x2b6653dc", + "destinationTokenAddress": "native", + "destinationTokenAmount": "500000000", + "destinationTokenDecimals": 6, + "destinationTokenSymbol": "TRX", + "hash": "swap-signature", + "id": "swap-signature", + "isBridgeTx": false, + "isSolana": true, + "networkClientId": "npm:@metamask/tron-snap", + "origin": "npm:@metamask/tron-snap", + "sourceTokenAddress": "TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t", + "sourceTokenAmount": "1000000", + "sourceTokenDecimals": 6, + "sourceTokenSymbol": "USDT", + "status": "submitted", + "swapTokenValue": "1", + "time": 1234567892, + "txParams": { + "data": "CgKquyIITd6G0PaK4+VAOmgIAbJjCjF0eXBlLmdvb2dsZWFwaXMuY29tL3Byb3RvY29sLlRyaWdnZXJTbWFydENvbnRyYWN0EjMKFUGPfqjM6fi7pn165ZzUmhll1hfnGxIVQaYU+AO2/XgJhqQseOycf3fm3tE8", + "from": "TRX123...", + }, + "type": "swap", +} +`; + +exports[`BridgeStatusController submitTx: Tron swap with approval should successfully submit a Tron swap with approval transaction 3`] = ` +{ + "account": "TRX123...", + "actionId": undefined, + "approvalTxId": "approval-signature", + "batchId": undefined, + "estimatedProcessingTimeInSeconds": 30, + "featureId": undefined, + "hasApprovalTx": true, + "initialDestAssetBalance": undefined, + "isStxEnabled": false, + "location": "Unknown", + "originalTransactionId": "swap-signature", + "pricingData": { + "amountSent": "1", + "amountSentInUsd": "1", + "quotedGasAmount": "0.005", + "quotedGasInUsd": "0.005", + "quotedReturnInUsd": "500", + }, + "quote": { + "bridgeId": "test-bridge", + "bridges": [], + "destAsset": { + "address": "native", + "assetId": "tron:728126428/slip44:195", + "chainId": 728126428, + "decimals": 6, + "name": "Tron", + "symbol": "TRX", + }, + "destChainId": 728126428, + "destTokenAmount": "500000000", + "feeData": { + "metabridge": { + "amount": "10000", + "asset": { + "address": "native", + "assetId": "tron:728126428/slip44:195", + "chainId": 728126428, + "decimals": 6, + "name": "Tron", + "symbol": "TRX", + }, + }, + }, + "minDestTokenAmount": "475000000", + "requestId": "123", + "srcAsset": { + "address": "TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t", + "assetId": "tron:728126428/slip44:195", + "chainId": 728126428, + "decimals": 6, + "name": "Tether USD", + "symbol": "USDT", + }, + "srcChainId": 728126428, + "srcTokenAmount": "1000000", + "steps": [ + { + "action": "swap", + "destAsset": { + "address": "native", + "assetId": "tron:728126428/slip44:195", + "chainId": 728126428, + "decimals": 6, + "name": "Tron", + "symbol": "TRX", + }, + "destChainId": 728126428, + "srcAsset": { + "address": "TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t", + "assetId": "tron:728126428/slip44:195", + "chainId": 728126428, + "decimals": 6, + "name": "Tether USD", + "symbol": "USDT", + }, + "srcChainId": 728126428, + }, + ], + }, + "quoteId": undefined, + "slippagePercentage": 0, + "startTime": 1234567890, + "status": { + "srcChain": { + "chainId": 728126428, + "txHash": "swap-signature", + }, + "status": "PENDING", + }, + "targetContractAddress": undefined, + "txMetaId": "swap-signature", +} +`; + +exports[`BridgeStatusController subscription handlers TransactionController:transactionStatusUpdated (confirmed) should not start polling for bridge tx if tx is not in txHistory 1`] = `[]`; + +exports[`BridgeStatusController subscription handlers TransactionController:transactionStatusUpdated (confirmed) should not track completed event for other transaction types 1`] = `[]`; + +exports[`BridgeStatusController subscription handlers TransactionController:transactionStatusUpdated (confirmed) should start polling for bridge tx if status response is invalid 1`] = ` +[ + [ + "AuthenticationController:getBearerToken", + ], + [ + "AuthenticationController:getBearerToken", + ], + [ + "AuthenticationController:getBearerToken", + ], + [ + "BridgeController:trackUnifiedSwapBridgeEvent", + "Unified SwapBridge Status Failed Validation", + { + "action_type": "swapbridge-v1", + "chain_id_destination": "eip155:10", + "chain_id_source": "eip155:42161", + "failures": [ + "across|status", + ], + "feature_id": "perps", + "location": "Main View", + "refresh_count": 0, + "token_address_destination": "eip155:10/slip44:60", + "token_address_source": "eip155:42161/slip44:60", + "token_security_type_destination": null, + }, + ], + [ + "BridgeController:trackUnifiedSwapBridgeEvent", + "Unified SwapBridge Status Failed Validation", + { + "action_type": "swapbridge-v1", + "chain_id_destination": "eip155:10", + "chain_id_source": "eip155:42161", + "failures": [ + "across|unknown", + ], + "feature_id": "unified_swap_bridge", + "location": "Main View", + "refresh_count": 0, + "token_address_destination": "eip155:10/slip44:60", + "token_address_source": "eip155:42161/slip44:60", + "token_security_type_destination": null, + }, + ], +] +`; + +exports[`BridgeStatusController subscription handlers TransactionController:transactionStatusUpdated (confirmed) should start polling for bridge tx if status response is invalid 2`] = ` +[ + [ + "Failed to fetch bridge tx status", + [Error: Bridge status validation failed: across|status], + ], + [ + "Failed to fetch bridge tx status", + [Error: Bridge status validation failed: across|unknown], + ], +] +`; + +exports[`BridgeStatusController subscription handlers TransactionController:transactionStatusUpdated (confirmed) should start polling for completed bridge tx with featureId=perps 2`] = ` +{ + "bridge": "across", + "destChain": { + "amount": "990654755978611", + "chainId": 10, + "token": { + "address": "0x0000000000000000000000000000000000000000", + "chainId": 10, + "coinKey": "ETH", + "decimals": 18, + "icon": "https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png", + "logoURI": "https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png", + "name": "ETH", + "priceUSD": "2478.63", + "symbol": "ETH", + }, + "txHash": "0xdestTxHash1", + }, + "isExpectedToken": true, + "srcChain": { + "amount": "991250000000000", + "chainId": 42161, + "token": { + "address": "0x0000000000000000000000000000000000000000", + "assetId": "eip155:42161/slip44:60", + "chainId": 42161, + "coinKey": "ETH", + "decimals": 18, + "icon": "https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png", + "logoURI": "https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png", + "name": "ETH", + "priceUSD": "2478.7", + "symbol": "ETH", + }, + "txHash": "0xperpsSrcTxHash1", + }, + "status": "COMPLETE", +} +`; + +exports[`BridgeStatusController subscription handlers TransactionController:transactionStatusUpdated (confirmed) should start polling for failed bridge tx with featureId=perps 2`] = ` +{ + "bridge": "debridge", + "destChain": { + "chainId": 10, + "token": {}, + }, + "srcChain": { + "amount": "991250000000000", + "chainId": 42161, + "token": { + "address": "0x0000000000000000000000000000000000000000", + "assetId": "eip155:42161/slip44:60", + "chainId": 42161, + "coinKey": "ETH", + "decimals": 18, + "icon": "https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png", + "iconUrl": "https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png", + "name": "ETH", + "symbol": "ETH", + }, + "txHash": "0xperpsSrcTxHash1", + }, + "status": "FAILED", +} +`; + +exports[`BridgeStatusController subscription handlers TransactionController:transactionStatusUpdated (confirmed) should track completed event for swap transaction 1`] = ` +[ + [ + "AccountsController:getAccountByAddress", + "0xaccount1", + ], + [ + "TransactionController:getState", + ], + [ + "BridgeController:trackUnifiedSwapBridgeEvent", + "Unified SwapBridge Completed", + { + "account_hardware_type": null, + "action_type": "swapbridge-v1", + "actual_time_minutes": 833734.9086333333, + "allowance_reset_transaction": undefined, + "approval_transaction": undefined, + "chain_id_destination": "eip155:42161", + "chain_id_source": "eip155:42161", + "custom_slippage": false, + "destination_transaction": "PENDING", + "feature_id": "unified_swap_bridge", + "gas_included": false, + "gas_included_7702": false, + "is_hardware_wallet": false, + "location": "Unknown", + "price_impact": 0, + "provider": "lifi_across", + "quote_vs_execution_ratio": 0, + "quoted_time_minutes": 0.25, + "quoted_vs_used_gas_ratio": 0, + "security_warnings": [], + "slippage_limit": 0, + "source_transaction": "COMPLETE", + "stx_enabled": false, + "swap_type": "single_chain", + "token_address_destination": "eip155:42161/slip44:60", + "token_address_source": "eip155:42161/slip44:60", + "token_security_type_destination": null, + "token_symbol_destination": "ETH", + "token_symbol_source": "ETH", + "transaction_internal_id": "swapTxMetaId1", + "usd_actual_gas": 0, + "usd_actual_return": 0, + "usd_amount_source": 0, + "usd_quoted_gas": 0, + "usd_quoted_return": 0, + }, + ], +] +`; + +exports[`BridgeStatusController subscription handlers TransactionController:transactionStatusUpdated (failed) should find history by actionId when txMeta.id not in history (pre-submission failure) 1`] = ` +[ + "BridgeController:trackUnifiedSwapBridgeEvent", + "Unified SwapBridge Failed", + { + "account_hardware_type": null, + "action_type": "swapbridge-v1", + "actual_time_minutes": 833734.9086333333, + "allowance_reset_transaction": undefined, + "approval_transaction": undefined, + "chain_id_destination": "eip155:10", + "chain_id_source": "eip155:42161", + "custom_slippage": false, + "destination_transaction": "FAILED", + "error_message": "Transaction failed. tx-error", + "feature_id": "unified_swap_bridge", + "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": [], + "slippage_limit": 0, + "source_transaction": "COMPLETE", + "stx_enabled": false, + "swap_type": "crosschain", + "token_address_destination": "eip155:10/slip44:60", + "token_address_source": "eip155:42161/slip44:60", + "token_security_type_destination": null, + "token_symbol_destination": "ETH", + "token_symbol_source": "ETH", + "usd_actual_gas": 0, + "usd_actual_return": 0, + "usd_amount_source": 0, + "usd_quoted_gas": 2.5778, + "usd_quoted_return": 0, + }, +] +`; + +exports[`BridgeStatusController subscription handlers TransactionController:transactionStatusUpdated (failed) should not track failed event for approved status 1`] = `[]`; + +exports[`BridgeStatusController subscription handlers TransactionController:transactionStatusUpdated (failed) should not track failed event for other transaction types 1`] = `[]`; + +exports[`BridgeStatusController subscription handlers TransactionController:transactionStatusUpdated (failed) should not track failed event for signed status 1`] = `[]`; + +exports[`BridgeStatusController subscription handlers TransactionController:transactionStatusUpdated (failed) should track failed event for bridge transaction 1`] = ` +[ + "BridgeController:trackUnifiedSwapBridgeEvent", + "Unified SwapBridge Failed", + { + "account_hardware_type": null, + "action_type": "swapbridge-v1", + "actual_time_minutes": 833734.9086333333, + "allowance_reset_transaction": undefined, + "approval_transaction": undefined, + "batch_id": "0xBatchIdFailed1", + "chain_id_destination": "eip155:10", + "chain_id_source": "eip155:42161", + "custom_slippage": false, + "destination_transaction": "FAILED", + "error_message": "Transaction failed. tx-error", + "feature_id": "unified_swap_bridge", + "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": [], + "slippage_limit": 0, + "source_transaction": "COMPLETE", + "stx_enabled": false, + "swap_type": "crosschain", + "token_address_destination": "eip155:10/slip44:60", + "token_address_source": "eip155:42161/slip44:60", + "token_security_type_destination": null, + "token_symbol_destination": "ETH", + "token_symbol_source": "ETH", + "usd_actual_gas": 0, + "usd_actual_return": 0, + "usd_amount_source": 0, + "usd_quoted_gas": 2.5778, + "usd_quoted_return": 0, + }, +] +`; + +exports[`BridgeStatusController subscription handlers TransactionController:transactionStatusUpdated (failed) should track failed event for bridge transaction if approval is dropped 1`] = ` +[ + [ + "AccountsController:getAccountByAddress", + "0xaccount1", + ], + [ + "TransactionController:getState", + ], + [ + "BridgeController:trackUnifiedSwapBridgeEvent", + "Unified SwapBridge Failed", + { + "account_hardware_type": null, + "action_type": "swapbridge-v1", + "actual_time_minutes": 833734.9086333333, + "allowance_reset_transaction": undefined, + "approval_transaction": "COMPLETE", + "chain_id_destination": "eip155:10", + "chain_id_source": "eip155:42161", + "custom_slippage": false, + "destination_transaction": "FAILED", + "error_message": "Transaction dropped. tx-error", + "feature_id": "unified_swap_bridge", + "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": [], + "slippage_limit": 0, + "source_transaction": "COMPLETE", + "stx_enabled": false, + "swap_type": "crosschain", + "token_address_destination": "eip155:10/slip44:60", + "token_address_source": "eip155:42161/slip44:60", + "token_security_type_destination": null, + "token_symbol_destination": "ETH", + "token_symbol_source": "ETH", + "usd_actual_gas": 0, + "usd_actual_return": 0, + "usd_amount_source": 0, + "usd_quoted_gas": 2.5778, + "usd_quoted_return": 0, + }, + ], +] +`; + +exports[`BridgeStatusController subscription handlers TransactionController:transactionStatusUpdated (failed) should track failed event for bridge transaction if not in txHistory 1`] = ` +[ + [ + "BridgeController:trackUnifiedSwapBridgeEvent", + "Unified SwapBridge Failed", + { + "account_hardware_type": null, + "action_type": "swapbridge-v1", + "actual_time_minutes": 0, + "chain_id_destination": "eip155:42161", + "chain_id_source": "eip155:42161", + "custom_slippage": false, + "error_message": "Transaction failed. tx-error", + "feature_id": "unified_swap_bridge", + "gas_included": false, + "gas_included_7702": false, + "is_hardware_wallet": false, + "location": "Unknown", + "price_impact": 0, + "provider": "", + "quote_vs_execution_ratio": 0, + "quoted_time_minutes": 0, + "quoted_vs_used_gas_ratio": 0, + "security_warnings": [], + "slippage_limit": 0, + "source_transaction": "FAILED", + "stx_enabled": false, + "swap_type": "crosschain", + "token_address_destination": "eip155:42161/slip44:60", + "token_address_source": "eip155:42161/slip44:60", + "token_security_type_destination": null, + "token_symbol_destination": "", + "token_symbol_source": "", + "usd_actual_gas": 0, + "usd_actual_return": 0, + "usd_amount_source": 0, + "usd_quoted_gas": 0, + "usd_quoted_return": 0, + }, + ], +] +`; + +exports[`BridgeStatusController subscription handlers TransactionController:transactionStatusUpdated (failed) should track failed event for swap transaction 1`] = ` +[ + [ + "AccountsController:getAccountByAddress", + "0xaccount1", + ], + [ + "TransactionController:getState", + ], + [ + "BridgeController:trackUnifiedSwapBridgeEvent", + "Unified SwapBridge Failed", + { + "account_hardware_type": null, + "action_type": "swapbridge-v1", + "actual_time_minutes": 833734.9086333333, + "allowance_reset_transaction": undefined, + "approval_transaction": undefined, + "chain_id_destination": "eip155:42161", + "chain_id_source": "eip155:42161", + "custom_slippage": false, + "destination_transaction": "FAILED", + "error_message": "Transaction failed. tx-error", + "feature_id": "unified_swap_bridge", + "gas_included": false, + "gas_included_7702": false, + "is_hardware_wallet": false, + "location": "Unknown", + "price_impact": 0, + "provider": "lifi_across", + "quote_vs_execution_ratio": 0, + "quoted_time_minutes": 0.25, + "quoted_vs_used_gas_ratio": 0, + "security_warnings": [], + "slippage_limit": 0, + "source_transaction": "COMPLETE", + "stx_enabled": false, + "swap_type": "single_chain", + "token_address_destination": "eip155:42161/slip44:60", + "token_address_source": "eip155:42161/slip44:60", + "token_security_type_destination": null, + "token_symbol_destination": "ETH", + "token_symbol_source": "ETH", + "usd_actual_gas": 0, + "usd_actual_return": 0, + "usd_amount_source": 0, + "usd_quoted_gas": 0, + "usd_quoted_return": 0, + }, + ], +] +`; + +exports[`BridgeStatusController subscription handlers TransactionController:transactionStatusUpdated (failed) should track failed event for swap transaction if approval fails 1`] = ` +[ + "BridgeController:trackUnifiedSwapBridgeEvent", + "Unified SwapBridge Failed", + { + "account_hardware_type": null, + "action_type": "swapbridge-v1", + "actual_time_minutes": 833734.9086333333, + "allowance_reset_transaction": undefined, + "approval_transaction": "COMPLETE", + "chain_id_destination": "eip155:10", + "chain_id_source": "eip155:42161", + "custom_slippage": false, + "destination_transaction": "FAILED", + "error_message": "Transaction failed. approval-tx-error", + "feature_id": "unified_swap_bridge", + "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": [], + "slippage_limit": 0, + "source_transaction": "COMPLETE", + "stx_enabled": false, + "swap_type": "crosschain", + "token_address_destination": "eip155:10/slip44:60", + "token_address_source": "eip155:42161/slip44:60", + "token_security_type_destination": null, + "token_symbol_destination": "ETH", + "token_symbol_source": "ETH", + "usd_actual_gas": 0, + "usd_actual_return": 0, + "usd_amount_source": 0, + "usd_quoted_gas": 2.5778, + "usd_quoted_return": 0, + }, +] +`; + +exports[`BridgeStatusController wipeBridgeStatus wipes the bridge status for the given address 1`] = ` +[ + [ + "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", + ], + [ + "NetworkController:getNetworkClientById", + "networkClientId", + ], +] +`; diff --git a/packages/bridge-status-controller/src/bridge-status-controller-method-action-types.ts b/packages/bridge-status-controller/src/bridge-status-controller-method-action-types.ts new file mode 100644 index 00000000000..f777b04111d --- /dev/null +++ b/packages/bridge-status-controller/src/bridge-status-controller-method-action-types.ts @@ -0,0 +1,59 @@ +/** + * This file is auto generated. + * Do not edit manually. + */ + +import type { BridgeStatusController } from './bridge-status-controller.js'; + +export type BridgeStatusControllerStartPollingForBridgeTxStatusAction = { + type: `BridgeStatusController:startPollingForBridgeTxStatus`; + handler: BridgeStatusController['startPollingForBridgeTxStatus']; +}; + +export type BridgeStatusControllerWipeBridgeStatusAction = { + type: `BridgeStatusController:wipeBridgeStatus`; + handler: BridgeStatusController['wipeBridgeStatus']; +}; + +export type BridgeStatusControllerResetStateAction = { + type: `BridgeStatusController:resetState`; + handler: BridgeStatusController['resetState']; +}; + +export type BridgeStatusControllerSubmitTxAction = { + type: `BridgeStatusController:submitTx`; + handler: BridgeStatusController['submitTx']; +}; + +export type BridgeStatusControllerSubmitIntentAction = { + type: `BridgeStatusController:submitIntent`; + handler: BridgeStatusController['submitIntent']; +}; + +export type BridgeStatusControllerRestartPollingForFailedAttemptsAction = { + type: `BridgeStatusController:restartPollingForFailedAttempts`; + handler: BridgeStatusController['restartPollingForFailedAttempts']; +}; + +export type BridgeStatusControllerGetBridgeHistoryItemByTxMetaIdAction = { + type: `BridgeStatusController:getBridgeHistoryItemByTxMetaId`; + handler: BridgeStatusController['getBridgeHistoryItemByTxMetaId']; +}; + +export type BridgeStatusControllerSubmitBatchSellAction = { + type: `BridgeStatusController:submitBatchSell`; + handler: BridgeStatusController['submitBatchSell']; +}; + +/** + * Union of all BridgeStatusController action types. + */ +export type BridgeStatusControllerMethodActions = + | BridgeStatusControllerStartPollingForBridgeTxStatusAction + | BridgeStatusControllerWipeBridgeStatusAction + | BridgeStatusControllerResetStateAction + | BridgeStatusControllerSubmitTxAction + | BridgeStatusControllerSubmitIntentAction + | BridgeStatusControllerRestartPollingForFailedAttemptsAction + | BridgeStatusControllerGetBridgeHistoryItemByTxMetaIdAction + | BridgeStatusControllerSubmitBatchSellAction; diff --git a/packages/bridge-status-controller/src/bridge-status-controller.batch-sell.test.ts b/packages/bridge-status-controller/src/bridge-status-controller.batch-sell.test.ts new file mode 100644 index 00000000000..e3cfac76c64 --- /dev/null +++ b/packages/bridge-status-controller/src/bridge-status-controller.batch-sell.test.ts @@ -0,0 +1,845 @@ +import type { + BridgeControllerMessenger, + TxData, + BatchSellTradesResponse, + Quote, +} from '@metamask/bridge-controller'; +import { + BatchSellTransactionType, + FeatureId, +} from '@metamask/bridge-controller'; +import { toHex } from '@metamask/controller-utils'; +import { Messenger, MOCK_ANY_NAMESPACE } from '@metamask/messenger'; +import type { + MessengerActions, + MessengerEvents, + MockAnyNamespace, +} from '@metamask/messenger'; +import { + TransactionStatus, + TransactionType, +} from '@metamask/transaction-controller'; + +import { + getHistoryItem, + getTxMetasForBatch, + mockBatchSellErc20Erc20, + mockBatchSellTradesErc20Erc20, +} from '../test/mock-batch-sell-erc20-erc20.js'; +import { BridgeStatusController } from './bridge-status-controller.js'; +import { BRIDGE_STATUS_CONTROLLER_NAME } from './constants.js'; +import { BridgeClientId } from './types.js'; +import type { + BridgeHistoryItem, + BridgeStatusControllerMessenger, +} from './types.js'; +import { getBatchSellHistoryItemsForTxHash } from './utils/history.js'; +import { shouldDisable7702 } from './utils/transaction.js'; + +const mockGenerateBatchId = jest.fn(); +jest.mock('@metamask/transaction-controller', () => ({ + ...jest.requireActual('@metamask/transaction-controller'), + generateBatchId: (): string => mockGenerateBatchId(), +})); + +type AllBridgeStatusControllerActions = + MessengerActions; + +type AllBridgeStatusControllerEvents = + MessengerEvents; + +type AllBridgeControllerActions = MessengerActions; + +type AllBridgeControllerEvents = MessengerEvents; + +type RootMessenger = Messenger< + MockAnyNamespace, + AllBridgeStatusControllerActions | AllBridgeControllerActions, + AllBridgeStatusControllerEvents | AllBridgeControllerEvents +>; + +const addTransactionBatchFn = jest.fn(); + +function getRootMessenger(): RootMessenger { + return new Messenger({ namespace: MOCK_ANY_NAMESPACE }); +} + +function getControllerMessenger( + rootMessenger: RootMessenger, +): BridgeStatusControllerMessenger { + const messenger = new Messenger({ + namespace: BRIDGE_STATUS_CONTROLLER_NAME, + parent: rootMessenger, + }) as unknown as BridgeStatusControllerMessenger; + rootMessenger.delegate({ + messenger, + actions: [ + 'AccountsController:getAccountByAddress', + 'NetworkController:findNetworkClientIdByChainId', + 'NetworkController:getState', + 'NetworkController:getNetworkClientById', + 'SnapController:handleRequest', + 'TransactionController:getState', + 'TransactionController:updateTransaction', + 'TransactionController:addTransaction', + 'TransactionController:estimateGasFee', + 'TransactionController:isAtomicBatchSupported', + 'BridgeController:trackUnifiedSwapBridgeEvent', + 'BridgeController:stopPollingForQuotes', + 'BridgeController:getState', + 'RemoteFeatureFlagController:getState', + 'AuthenticationController:getBearerToken', + 'KeyringController:signTypedMessage', + ], + events: ['TransactionController:transactionStatusUpdated'], + }); + return messenger; +} + +type WithControllerCallback = (payload: { + controller: BridgeStatusController; + rootMessenger: RootMessenger; + messenger: BridgeStatusControllerMessenger; + startPollingForBridgeTxStatusSpy: jest.Mock; +}) => Promise | ReturnValue; + +type WithControllerOptions = { + options?: Partial[0]>; + mockMessengerCall?: jest.Mock; +}; + +async function withController( + ...args: + | [WithControllerCallback] + | [WithControllerOptions, WithControllerCallback] +): Promise { + const [{ options = {}, mockMessengerCall = undefined }, testFunction] = + args.length === 2 ? args : [{}, args[0]]; + const rootMessenger = getRootMessenger(); + const messenger = getControllerMessenger(rootMessenger); + if (mockMessengerCall) { + jest.spyOn(messenger, 'call').mockImplementation(mockMessengerCall); + } + const controller = new BridgeStatusController({ + messenger, + clientId: BridgeClientId.EXTENSION, + fetchFn: jest.fn(), + addTransactionBatchFn, + ...options, + }); + const startPollingForBridgeTxStatusSpy = jest.fn(); + if (mockMessengerCall) { + jest + .spyOn(controller, 'startPolling') + .mockImplementation(startPollingForBridgeTxStatusSpy); + } + return await testFunction({ + controller, + rootMessenger, + messenger, + startPollingForBridgeTxStatusSpy, + }); +} + +// Define mocks at the top level +const mockSelectedAccount = { + id: 'test-account-id', + address: '0xaccount1', + type: 'eth', + metadata: { + keyring: { + type: ['any'], + }, + }, +}; +const batchId = '0xBatchId1'; +const mockQuotes = mockBatchSellErc20Erc20 + .map((quote) => ({ + ...quote, + quote: { + ...quote.quote, + // BatchSell quotes have no gasless params because they are not simulated + gasIncluded7702: undefined, + gasIncluded: undefined, + gasSponsored: undefined, + }, + })) + .map((quote) => ({ + ...quote, + ...{ + sentAmount: { + usd: '100', + valueInCurrency: '200', + }, + toTokenAmount: { + usd: '101', + valueInCurrency: '201', + }, + }, + })); +const mockTransferTx: BatchSellTradesResponse['transactions'][number] = { + chainId: 10, + from: '0xaccount1', + to: '0xaccount2', + value: '0x1', + data: '0x1', + gasLimit: 100000, + maxFeePerGas: '0x1', + maxPriorityFeePerGas: '0x1', + type: BatchSellTransactionType.TRANSFER, +}; + +describe('BridgeStatusController', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe('submitBatchSell', () => { + let mockMessengerCall: jest.Mock; + + describe.each([true, false])('when gasTransferRequired=%s,', (transfer) => { + const transferTx = transfer ? mockTransferTx : undefined; + + describe.each([true, false])('gasIncluded7702=%s,', (gasIncluded7702) => { + describe.each([true, false])('gasIncluded=%s,', (gasIncluded) => { + describe.each([true, false])('stxEnabled=%s,', (stxEnabled) => { + beforeEach(() => { + jest.clearAllMocks(); + mockMessengerCall = jest.fn(); + jest.spyOn(Date, 'now').mockReturnValue(1779922719705); + mockGenerateBatchId.mockReturnValueOnce('0xGeneratedBatchId1'); + }); + + it.each([true, false])( + 'isDelegatedAccount=%s', + async (isDelegatedAccount) => { + if ( + !( + !gasIncluded7702 && + !gasIncluded && + !stxEnabled && + !isDelegatedAccount + ) + ) { + // return; + } + const is7702 = !shouldDisable7702( + gasIncluded7702, + gasIncluded, + isDelegatedAccount, + ); + + // Get the mock tx metas for the batch, either a single tx or multiple + const mockTxMetas = getTxMetasForBatch({ + batchId, + is7702, + }); + + // Append the transfer tx if it is provided + const mockBatchSellTrades = { + ...mockBatchSellTradesErc20Erc20, + gasIncluded7702, + gasIncluded, + transactions: [ + ...mockBatchSellTradesErc20Erc20.transactions, + transferTx, + ].filter((tx) => tx !== undefined), + }; + + // Mock messenger calls + addTransactionBatchFn.mockResolvedValueOnce({ + batchId, + }); + mockMessengerCall.mockReturnValueOnce({ + batchSellTrades: mockBatchSellTrades, + }); + // stopPollingForQuotes + mockMessengerCall.mockImplementationOnce(jest.fn()); + // track event + mockMessengerCall.mockReturnValueOnce(mockSelectedAccount); + mockMessengerCall.mockImplementationOnce(jest.fn()); + // isAtomicBatchSupported + mockMessengerCall.mockReturnValueOnce( + isDelegatedAccount + ? [ + { + isSupported: true, + delegationAddress: '0x0', + }, + ] + : [], + ); + mockMessengerCall.mockReturnValueOnce(mockSelectedAccount); + mockMessengerCall.mockReturnValueOnce('networkClientId'); + mockMessengerCall.mockReturnValueOnce({ + transactions: mockTxMetas.map((txMeta) => ({ + ...txMeta, + })), + }); + + await withController( + { mockMessengerCall }, + async ({ + controller, + rootMessenger, + startPollingForBridgeTxStatusSpy, + }) => { + const result = await rootMessenger.call( + 'BridgeStatusController:submitBatchSell', + { + accountAddress: (mockQuotes[0].trade as TxData).from, + quoteResponses: mockQuotes, + isStxEnabled: stxEnabled, + }, + ); + controller.stopAllPolling(); + + // First txMeta should be returned + expect(result).toStrictEqual(mockTxMetas[0]); + + // Verify the messenger calls + expect(mockMessengerCall.mock.calls).toStrictEqual([ + ['BridgeController:getState'], + [ + 'BridgeController:stopPollingForQuotes', + 'Transaction submitted', + undefined, + ], + [ + 'AccountsController:getAccountByAddress', + '0x141d32a89a1e0a5ef360034a2f60a4b917c18838', + ], + [ + 'BridgeController:trackUnifiedSwapBridgeEvent', + 'Unified SwapBridge Submitted', + { + account_hardware_type: null, + action_type: 'swapbridge-v1', + chain_id_destination: 'eip155:10', + chain_id_source: 'eip155:10', + custom_slippage: false, + feature_id: FeatureId.BATCH_SELL, + gas_included: gasIncluded, + gas_included_7702: gasIncluded7702, + is_hardware_wallet: false, + location: 'Unknown', + price_impact: 0, + provider: 'socket_across', + quoted_time_minutes: 1, + slippage_limit: 0, + stx_enabled: stxEnabled, + swap_type: 'single_chain', + token_address_destination: + 'eip155:10/erc20:0x3c499c542cef5e3811e1192ce70d8cc03d5c3359', + token_address_source: + 'eip155:10/erc20:0x0b2c639c533813f4aa9d7837caf62653d097ff85', + token_security_type_destination: null, + token_symbol_destination: 'USDC', + token_symbol_source: 'USDC', + usd_amount_source: 100, + usd_quoted_gas: 0, + usd_quoted_return: 0, + batch_id: '0xGeneratedBatchId1', + }, + ], + [ + 'TransactionController:isAtomicBatchSupported', + { + address: '0xaccount1', + chainIds: ['0xa'], + }, + ], + [ + 'AccountsController:getAccountByAddress', + '0x141d32a89a1e0a5ef360034a2f60a4b917c18838', + ], + ['NetworkController:findNetworkClientIdByChainId', '0xa'], + ['TransactionController:getState'], + ]); + + const { transactions, ...batchParams } = + addTransactionBatchFn.mock.calls[0][0]; + + // addTransactionBatch options + expect(batchParams).toStrictEqual({ + disable7702: !is7702, + excludeNativeTokenForFee: !transferTx, + atomic: false, + from: '0xaccount1', + isGasFeeIncluded: gasIncluded7702, + isGasFeeSponsored: undefined, + isInternal: true, + networkClientId: 'networkClientId', + origin: 'metamask', + requireApproval: false, + batchId: '0xGeneratedBatchId1', + skipInitialGasEstimate: gasIncluded7702 + ? isDelegatedAccount + : Boolean(transferTx), + }); + + expect(transactions).toStrictEqual( + mockBatchSellTrades.transactions.map( + ({ type, gasLimit, chainId, ...tx }) => ({ + params: { + ...tx, + gas: toHex(Number(gasLimit)), + }, + type: + // eslint-disable-next-line no-nested-ternary + type === BatchSellTransactionType.TRADE + ? TransactionType.swap + : type === BatchSellTransactionType.APPROVAL + ? TransactionType.swapApproval + : TransactionType.tokenMethodTransfer, + assetsFiatValues: + type === BatchSellTransactionType.TRADE + ? { + sending: + mockQuotes[0].sentAmount?.valueInCurrency?.toString(), + receiving: + mockQuotes[0].toTokenAmount?.valueInCurrency?.toString(), + } + : undefined, + }), + ), + ); + + // Verify the initial history item + expect(result.id).toStrictEqual(mockTxMetas[0].id); + + const historyItem = controller.state.txHistory[result.id]; + + const expectedHistoryItem = getHistoryItem({ + isStxEnabled: stxEnabled, + batchSellData: mockBatchSellTrades, + txMetaId: result.id, + featureId: FeatureId.BATCH_SELL, + quote: { + ...mockQuotes[0].quote, + // Gas params should be merged to the initial quote + gasIncluded, + gasIncluded7702, + gasSponsored: false, + }, + + quoteIds: is7702 + ? // 7702 batch should have a list of quoteIds + [ + mockQuotes[0].quote.requestId, + mockQuotes[1].quote.requestId, + ] + : undefined, + }); + expect(historyItem).toStrictEqual(expectedHistoryItem); + + const expectedHistoryItems = []; + const quoteHistoryItem = ( + quoteObject: Quote, + ): Partial => ({ + batchId: undefined, + featureId: FeatureId.BATCH_SELL, + slippagePercentage: 0, + txMetaId: undefined, + actionId: undefined, + approvalTxId: undefined, + isStxEnabled: stxEnabled, + batchSellData: mockBatchSellTrades, + quote: { + ...quoteObject, + gasIncluded, + gasIncluded7702, + gasSponsored: false, + }, + }); + + // Add a txHistory item for each 7702 quote + for (const [ + index, + ] of expectedHistoryItem.quoteIds?.entries() ?? []) { + const quoteItem = quoteHistoryItem( + mockQuotes[index].quote, + ); + + expectedHistoryItems.push( + expect.objectContaining(quoteItem), + ); + } + + // Add a txHistory item for each STX swap tx + const stxSwapTxMetas = mockTxMetas.filter( + ({ type }) => type === TransactionType.swap, + ); + expect(stxSwapTxMetas).toHaveLength(is7702 ? 0 : 2); + for (const [index, txMeta] of stxSwapTxMetas.entries()) { + const quoteItem = { + ...quoteHistoryItem(mockQuotes[index].quote), + batchId: txMeta.batchId, + txMetaId: txMeta.id, + }; + expectedHistoryItems.push( + expect.objectContaining(quoteItem), + ); + } + + expect(expectedHistoryItems.length).toBeGreaterThan(1); + + // STX tx hash is not stored initially, so use the txMeta.id instead + const { historyItems, is7702Batch } = + getBatchSellHistoryItemsForTxHash( + controller.state.txHistory, + mockTxMetas[0].id, + ); + + expect(is7702Batch).toBe(is7702); + expect(historyItems).toHaveLength( + expectedHistoryItems.length, + ); + expect(historyItems).toStrictEqual(expectedHistoryItems); + + // No history items should be returned if no txHashOrId is provided + expect( + getBatchSellHistoryItemsForTxHash( + controller.state.txHistory, + ).historyItems, + ).toStrictEqual([]); + + // Test confirmation subscription + mockMessengerCall.mockReturnValueOnce(mockSelectedAccount); + mockMessengerCall.mockReturnValueOnce({ + transactions: mockTxMetas.map((txMeta) => ({ + ...txMeta, + })), + }); + + // Publish confirmation event for swap + rootMessenger.publish( + 'TransactionController:transactionStatusUpdated', + { + transactionMeta: { + ...mockTxMetas[0], + status: TransactionStatus.confirmed, + }, + }, + ); + + expect( + getBatchSellHistoryItemsForTxHash( + controller.state.txHistory, + mockTxMetas[0].hash, + ).historyItems, + ).toStrictEqual(expectedHistoryItems); + + // Verify the messenger calls + expect( + mockMessengerCall.mock.calls.slice(-3), + ).toStrictEqual([ + ['AccountsController:getAccountByAddress', '0xaccount1'], + ['TransactionController:getState'], + [ + 'BridgeController:trackUnifiedSwapBridgeEvent', + 'Unified SwapBridge Completed', + { + account_hardware_type: null, + action_type: 'swapbridge-v1', + batch_id: '0xBatchId1', + feature_id: FeatureId.BATCH_SELL, + // actual_time_minutes: expect.closeTo(29644790, -1), + actual_time_minutes: expect.any(Number), + allowance_reset_transaction: undefined, + approval_transaction: 'COMPLETE', + chain_id_destination: 'eip155:10', + chain_id_source: 'eip155:10', + custom_slippage: true, + destination_transaction: 'PENDING', + gas_included: gasIncluded, + gas_included_7702: gasIncluded7702, + is_hardware_wallet: false, + location: 'Unknown', + price_impact: 0, + provider: 'socket_across', + quote_vs_execution_ratio: 0, + quoted_time_minutes: 1, + quoted_vs_used_gas_ratio: 0, + security_warnings: [], + slippage_limit: 0, + source_transaction: 'COMPLETE', + stx_enabled: stxEnabled, + swap_type: 'single_chain', + token_address_destination: + 'eip155:10/erc20:0x3c499c542cef5e3811e1192ce70d8cc03d5c3359', + token_address_source: + 'eip155:10/erc20:0x0b2c639c533813f4aa9d7837caf62653d097ff85', + token_security_type_destination: null, + token_symbol_destination: 'USDC', + token_symbol_source: 'USDC', + transaction_internal_id: mockTxMetas[0].id, + usd_amount_source: 100, + usd_actual_gas: 0, + usd_actual_return: 0, + usd_quoted_gas: 0, + usd_quoted_return: 101, + }, + ], + ]); + + expect( + startPollingForBridgeTxStatusSpy, + ).toHaveBeenCalledTimes(0); + + // Test failure subscription + mockMessengerCall.mockReturnValueOnce(mockSelectedAccount); + mockMessengerCall.mockReturnValueOnce({ + transactions: mockTxMetas.map((txMeta) => ({ + ...txMeta, + })), + }); + + // Publish failed event for swap + const failedTxMeta = mockTxMetas[2] ?? mockTxMetas[0]; + rootMessenger.publish( + 'TransactionController:transactionStatusUpdated', + { + transactionMeta: { + ...failedTxMeta, + status: TransactionStatus.failed, + }, + }, + ); + + expect( + getBatchSellHistoryItemsForTxHash( + controller.state.txHistory, + failedTxMeta.hash, + ).historyItems, + ).toStrictEqual(expectedHistoryItems); + + // Verify the messenger calls + expect(mockMessengerCall.mock.calls.at(-1)).toStrictEqual([ + 'BridgeController:trackUnifiedSwapBridgeEvent', + 'Unified SwapBridge Failed', + { + account_hardware_type: null, + action_type: 'swapbridge-v1', + // actual_time_minutes: expect.closeTo(is7702 ? 1103 : 0, -1), + actual_time_minutes: expect.any(Number), + allowance_reset_transaction: undefined, + approval_transaction: 'COMPLETE', + batch_id: '0xBatchId1', + chain_id_destination: 'eip155:10', + chain_id_source: 'eip155:10', + custom_slippage: true, + destination_transaction: 'FAILED', + error_message: 'Transaction failed', + feature_id: FeatureId.BATCH_SELL, + gas_included: gasIncluded, + gas_included_7702: gasIncluded7702, + is_hardware_wallet: false, + location: 'Unknown', + price_impact: 0, + provider: is7702 + ? 'socket_across' + : 'socket_celercircle', + quote_vs_execution_ratio: 0, + quoted_time_minutes: is7702 ? 1 : 26, + quoted_vs_used_gas_ratio: 0, + security_warnings: [], + slippage_limit: 0, + source_transaction: 'COMPLETE', + stx_enabled: stxEnabled, + swap_type: 'single_chain', + token_address_destination: + 'eip155:10/erc20:0x3c499c542cef5e3811e1192ce70d8cc03d5c3359', + token_address_source: is7702 + ? 'eip155:10/erc20:0x0b2c639c533813f4aa9d7837caf62653d097ff85' + : 'eip155:10/erc20:0x0b2c639c533813f4aa9d7837caf62653d097ff81', + token_security_type_destination: null, + token_symbol_destination: 'USDC', + token_symbol_source: is7702 ? 'USDC' : 'USDT', + usd_amount_source: 100, + usd_actual_gas: 0, + usd_actual_return: 0, + usd_quoted_gas: 0, + usd_quoted_return: 101, + }, + ]); + + expect( + startPollingForBridgeTxStatusSpy, + ).toHaveBeenCalledTimes(0); + }, + ); + }, + ); + }); + }); + }); + }); + + it('returns undefined if there is no matching txMeta for the batch', async () => { + const gasIncluded7702 = true; + const gasIncluded = false; + const isDelegatedAccount = true; + const stxEnabled = false; + + // Append the transfer tx if it is provided + const mockBatchSellTrades = { + ...mockBatchSellTradesErc20Erc20, + gasIncluded7702, + gasIncluded, + transactions: [ + mockTransferTx, + ...mockBatchSellTradesErc20Erc20.transactions, + ].filter((tx) => tx !== undefined), + }; + + // Mock messenger calls + addTransactionBatchFn.mockResolvedValueOnce({ + batchId, + }); + mockMessengerCall.mockReturnValueOnce({ + batchSellTrades: mockBatchSellTrades, + }); + // stopPollingForQuotes + mockMessengerCall.mockImplementationOnce(jest.fn()); + // track event + mockMessengerCall.mockReturnValueOnce(mockSelectedAccount); + mockMessengerCall.mockImplementationOnce(jest.fn()); + // isAtomicBatchSupported + mockMessengerCall.mockReturnValueOnce( + isDelegatedAccount + ? [ + { + isSupported: true, + delegationAddress: '0x0', + }, + ] + : [], + ); + mockMessengerCall.mockReturnValueOnce(mockSelectedAccount); + mockMessengerCall.mockReturnValueOnce('networkClientId'); + mockMessengerCall.mockReturnValueOnce({ + transactions: [], + }); + + await withController( + { mockMessengerCall }, + async ({ + controller, + rootMessenger, + startPollingForBridgeTxStatusSpy, + }) => { + const expectedHistory = controller.state.txHistory; + const result = await expect( + rootMessenger.call('BridgeStatusController:submitBatchSell', { + accountAddress: (mockQuotes[0].trade as TxData).from, + quoteResponses: mockQuotes, + isStxEnabled: stxEnabled, + }), + ).rejects.toThrow( + 'Failed to add BatchSell trade to history: txMeta not found', + ); + controller.stopAllPolling(); + + // First txMeta should be returned + expect(result).toBeUndefined(); + + // Verify the messenger calls + expect(mockMessengerCall.mock.calls).toStrictEqual([ + ['BridgeController:getState'], + [ + 'BridgeController:stopPollingForQuotes', + 'Transaction submitted', + undefined, + ], + [ + 'AccountsController:getAccountByAddress', + '0x141d32a89a1e0a5ef360034a2f60a4b917c18838', + ], + [ + 'BridgeController:trackUnifiedSwapBridgeEvent', + 'Unified SwapBridge Submitted', + { + account_hardware_type: null, + action_type: 'swapbridge-v1', + chain_id_destination: 'eip155:10', + chain_id_source: 'eip155:10', + custom_slippage: false, + feature_id: FeatureId.BATCH_SELL, + gas_included: gasIncluded, + gas_included_7702: gasIncluded7702, + is_hardware_wallet: false, + location: 'Unknown', + price_impact: 0, + provider: 'socket_across', + quoted_time_minutes: 1, + slippage_limit: 0, + stx_enabled: stxEnabled, + swap_type: 'single_chain', + token_address_destination: + 'eip155:10/erc20:0x3c499c542cef5e3811e1192ce70d8cc03d5c3359', + token_address_source: + 'eip155:10/erc20:0x0b2c639c533813f4aa9d7837caf62653d097ff85', + token_security_type_destination: null, + token_symbol_destination: 'USDC', + token_symbol_source: 'USDC', + usd_amount_source: 100, + usd_quoted_gas: 0, + usd_quoted_return: 0, + }, + ], + [ + 'TransactionController:isAtomicBatchSupported', + { + address: '0xaccount1', + chainIds: ['0xa'], + }, + ], + ['AccountsController:getAccountByAddress', '0xaccount1'], + ['NetworkController:findNetworkClientIdByChainId', '0xa'], + ['TransactionController:getState'], + [ + 'BridgeController:trackUnifiedSwapBridgeEvent', + 'Unified SwapBridge Failed', + { + account_hardware_type: null, + action_type: 'swapbridge-v1', + chain_id_destination: 'eip155:10', + chain_id_source: 'eip155:10', + custom_slippage: false, + error_message: + 'Failed to add BatchSell trade to history: txMeta not found', + feature_id: FeatureId.BATCH_SELL, + gas_included: false, + gas_included_7702: true, + is_hardware_wallet: false, + location: 'Unknown', + price_impact: 0, + provider: 'socket_across', + quoted_time_minutes: 1, + slippage_limit: 0, + stx_enabled: false, + swap_type: 'single_chain', + token_address_destination: + 'eip155:10/erc20:0x3c499c542cef5e3811e1192ce70d8cc03d5c3359', + token_address_source: + 'eip155:10/erc20:0x0b2c639c533813f4aa9d7837caf62653d097ff85', + token_security_type_destination: null, + token_symbol_destination: 'USDC', + token_symbol_source: 'USDC', + usd_amount_source: 100, + usd_quoted_gas: 0, + usd_quoted_return: 0, + }, + ], + ]); + + expect(startPollingForBridgeTxStatusSpy).toHaveBeenCalledTimes(0); + + // Verify that history item was not added + expect(controller.state.txHistory).toStrictEqual(expectedHistory); + }, + ); + }); + }); +}); diff --git a/packages/bridge-status-controller/src/bridge-status-controller.intent-manager.test.ts b/packages/bridge-status-controller/src/bridge-status-controller.intent-manager.test.ts new file mode 100644 index 00000000000..73480aa50c4 --- /dev/null +++ b/packages/bridge-status-controller/src/bridge-status-controller.intent-manager.test.ts @@ -0,0 +1,690 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import { BridgeClientId, StatusTypes } from '@metamask/bridge-controller'; +import { TransactionStatus } from '@metamask/transaction-controller'; + +import { IntentManager } from './bridge-status-controller.intent'; +import type { BridgeHistoryItem } from './types.js'; +import { postSubmitOrder } from './utils/intent-api.js'; +import { IntentOrderStatus } from './utils/validators.js'; + +const makeHistoryItem = ( + overrides?: Partial, +): BridgeHistoryItem => + ({ + quote: { + srcChainId: 1, + destChainId: 1, + intent: { protocol: 'cowswap' }, + }, + status: { + status: StatusTypes.PENDING, + srcChain: { chainId: 1, txHash: '' }, + }, + account: '0xaccount1', + estimatedProcessingTimeInSeconds: 10, + slippagePercentage: 0, + hasApprovalTx: false, + ...overrides, + }) as BridgeHistoryItem; + +type IntentManagerConstructorOptions = ConstructorParameters< + typeof IntentManager +>[0]; + +const createManagerOptions = (overrides?: { + messenger?: any; + updateTransactionFn?: ReturnType; + fetchFn?: ReturnType; +}): IntentManagerConstructorOptions => ({ + messenger: overrides?.messenger ?? { call: jest.fn() }, + customBridgeApiBaseUrl: 'https://example.com', + fetchFn: overrides?.fetchFn ?? jest.fn(), +}); + +describe('IntentManager', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('returns early when no original tx id is present', () => { + const options = createManagerOptions(); + const manager = new IntentManager(options); + + manager.syncTransactionFromIntentStatus( + 'order-1', + makeHistoryItem({ + txMetaId: undefined, + originalTransactionId: undefined, + }), + ); + + expect(options.messenger.call).not.toHaveBeenCalled(); + }); + + it('logs when TransactionController access throws', async () => { + const updateTransactionFn = jest.fn(); + const manager = new IntentManager( + createManagerOptions({ + messenger: { + call: jest.fn(() => { + throw new Error('boom'); + }), + }, + updateTransactionFn, + fetchFn: jest.fn().mockResolvedValue({ + id: 'order-2', + status: IntentOrderStatus.SUBMITTED, + metadata: {}, + }), + }), + ); + + const consoleSpy = jest + .spyOn(console, 'error') + .mockImplementation(() => undefined); + + const { + quote: { srcChainId, intent }, + status: { + srcChain: { txHash }, + }, + } = makeHistoryItem({ originalTransactionId: 'tx-1' }); + await manager.getIntentTransactionStatus( + 'order-2', + srcChainId, + intent?.protocol ?? '', + BridgeClientId.MOBILE, + txHash, + ); + manager.syncTransactionFromIntentStatus( + 'order-2', + makeHistoryItem({ originalTransactionId: 'tx-1' }), + ); + + expect(consoleSpy).toHaveBeenCalled(); + expect(updateTransactionFn).not.toHaveBeenCalled(); + + consoleSpy.mockRestore(); + }); + + it('updates transaction meta when tx is found', async () => { + const existingTxMeta = { + id: 'tx-2', + status: TransactionStatus.submitted, + txReceipt: { status: '0x0' }, + }; + const completedOrder = { + id: 'order-3', + status: IntentOrderStatus.COMPLETED, + txHash: '0xhash', + metadata: {}, + }; + const mockCall = jest.fn((...args: unknown[]) => { + const [method] = args; + if (method === 'TransactionController:updateTransaction') { + return { transactions: [existingTxMeta] }; + } + return { transactions: [existingTxMeta] }; + }); + const manager = new IntentManager( + createManagerOptions({ + messenger: { + call: (...args: unknown[]) => mockCall(...args), + }, + fetchFn: jest.fn().mockResolvedValue(completedOrder), + }), + ); + + const historyItem = makeHistoryItem({ + originalTransactionId: 'tx-2', + status: { + status: StatusTypes.PENDING, + srcChain: { chainId: 1, txHash: '0xhash' }, + }, + }); + await manager.getIntentTransactionStatus( + 'order-3', + historyItem.quote.srcChainId, + historyItem.quote.intent?.protocol ?? '', + BridgeClientId.MOBILE, + historyItem.status.srcChain.txHash, + ); + manager.syncTransactionFromIntentStatus('order-3', historyItem); + + expect(mockCall.mock.calls).toMatchInlineSnapshot(` + [ + [ + "AuthenticationController:getBearerToken", + ], + [ + "TransactionController:getState", + ], + [ + "TransactionController:updateTransaction", + { + "hash": "0xhash", + "id": "tx-2", + "status": "confirmed", + "txReceipt": { + "status": "0x1", + "transactionHash": "0xhash", + }, + }, + "BridgeStatusController - Intent order status updated: completed", + ], + ] + `); + }); + + it('getIntentTransactionStatus returns undefined when getOrderStatus rejects with non-Error', async () => { + const manager = new IntentManager(createManagerOptions()); + jest + .spyOn(manager.intentApi, 'getOrderStatus') + .mockRejectedValue('non-Error rejection'); + + const { + quote: { srcChainId, intent }, + status, + } = makeHistoryItem(); + const result = await manager.getIntentTransactionStatus( + 'order-1', + srcChainId, + intent?.protocol ?? '', + BridgeClientId.MOBILE, + status.srcChain.txHash, + ); + + expect(result).toBeUndefined(); + }); + + it('getIntentTransactionStatus throws when getOrderStatus rejects with Error', async () => { + const apiError = new Error('Network failure'); + const manager = new IntentManager( + createManagerOptions({ + fetchFn: jest.fn().mockRejectedValue(apiError), + }), + ); + + let thrown: unknown; + const { + quote: { srcChainId, intent }, + status: { + srcChain: { txHash }, + }, + } = makeHistoryItem(); + try { + await manager.getIntentTransactionStatus( + 'order-1', + srcChainId, + intent?.protocol ?? '', + BridgeClientId.MOBILE, + txHash, + ); + } catch (error) { + thrown = error; + } + expect(thrown).toBeInstanceOf(Error); + expect((thrown as Error).message).toBe( + '[Intent polling] Failed to get intent order status from API: Failed to get order status: Network failure', + ); + }); + + it('getIntentTransactionStatus returns intent statuses when getOrderStatus resolves', async () => { + const order = { + id: 'order-1', + status: IntentOrderStatus.SUBMITTED, + metadata: {}, + }; + const manager = new IntentManager( + createManagerOptions({ + fetchFn: jest.fn().mockResolvedValue(order), + }), + ); + const historyItem = makeHistoryItem({ + status: { + status: StatusTypes.PENDING, + srcChain: { chainId: 1, txHash: '0xabc' }, + }, + }); + + const result = await manager.getIntentTransactionStatus( + 'order-1', + historyItem.quote.srcChainId, + historyItem.quote.intent?.protocol ?? '', + BridgeClientId.MOBILE, + historyItem.status.srcChain.txHash, + ); + + expect(result).toBeDefined(); + expect(result?.orderStatus).toBe(IntentOrderStatus.SUBMITTED); + expect(result?.bridgeStatus).toBeDefined(); + }); + + it('getIntentTransactionStatus passes undefined txHash when srcChain is missing', async () => { + const order = { + id: 'order-1', + status: IntentOrderStatus.SUBMITTED, + metadata: {}, + }; + const manager = new IntentManager( + createManagerOptions({ + fetchFn: jest.fn().mockResolvedValue(order), + }), + ); + const historyItemWithoutSrcChain = makeHistoryItem({ + status: { status: StatusTypes.PENDING } as BridgeHistoryItem['status'], + }); + + const { + quote: { srcChainId, intent }, + } = historyItemWithoutSrcChain; + const result = await manager.getIntentTransactionStatus( + 'order-1', + srcChainId, + intent?.protocol ?? '', + BridgeClientId.MOBILE, + undefined, + ); + + expect(result).toBeDefined(); + expect(result?.bridgeStatus?.status.srcChain.txHash).toBeUndefined(); + }); + + it('syncTransactionFromIntentStatus cleans up intent statuses map when order is complete', async () => { + const existingTxMeta = { + id: 'tx-2', + status: TransactionStatus.submitted, + txReceipt: { status: '0x0' }, + }; + const completedOrder = { + id: 'order-3', + status: IntentOrderStatus.COMPLETED, + txHash: + '0xb756e7c856f1bf6ca3c3feda2067b85574383fb1f4ce95b175c2d447c932cdcc', + metadata: {}, + }; + const mockCall = jest.fn((...args: unknown[]) => { + const [method] = args; + if (method === 'TransactionController:updateTransaction') { + return undefined; + } + return { transactions: [existingTxMeta] }; + }); + const manager = new IntentManager( + createManagerOptions({ + messenger: { + call: (...args: unknown[]) => mockCall(...args), + }, + fetchFn: jest.fn().mockResolvedValue(completedOrder), + }), + ); + + const historyItem = makeHistoryItem({ + originalTransactionId: 'tx-2', + status: { + status: StatusTypes.PENDING, + srcChain: { + chainId: 1, + txHash: + '0xb756e7c856f1bf6ca3c3feda2067b85574383fb1f4ce95b175c2d447c932cdcc', + }, + }, + }); + await manager.getIntentTransactionStatus( + 'order-3', + historyItem.quote.srcChainId, + historyItem.quote.intent?.protocol ?? '', + BridgeClientId.MOBILE, + historyItem.status.srcChain.txHash, + ); + manager.syncTransactionFromIntentStatus('order-3', historyItem); + + expect(mockCall).toHaveBeenCalledTimes(3); + + manager.syncTransactionFromIntentStatus('order-3', historyItem); + + expect(mockCall.mock.calls).toMatchInlineSnapshot(` + [ + [ + "AuthenticationController:getBearerToken", + ], + [ + "TransactionController:getState", + ], + [ + "TransactionController:updateTransaction", + { + "hash": "0xb756e7c856f1bf6ca3c3feda2067b85574383fb1f4ce95b175c2d447c932cdcc", + "id": "tx-2", + "status": "confirmed", + "txReceipt": { + "status": "0x1", + "transactionHash": "0xb756e7c856f1bf6ca3c3feda2067b85574383fb1f4ce95b175c2d447c932cdcc", + }, + }, + "BridgeStatusController - Intent order status updated: completed", + ], + ] + `); + }); + + it('syncTransactionFromIntentStatus cleans up intent statuses map when order has failed', async () => { + const existingTxMeta = { + id: 'tx-2', + status: TransactionStatus.submitted, + txReceipt: { status: '0x0' }, + }; + const failedOrder = { + id: 'order-3', + status: IntentOrderStatus.FAILED, + txHash: + '0xb756e7c856f1bf6ca3c3feda2067b85574383fb1f4ce95b175c2d447c932cdcc', + metadata: {}, + }; + const mockCall = jest.fn((...args: unknown[]) => { + const [method] = args; + if (method === 'TransactionController:updateTransaction') { + return undefined; + } + return { transactions: [existingTxMeta] }; + }); + const manager = new IntentManager( + createManagerOptions({ + messenger: { + call: (...args: unknown[]) => mockCall(...args), + }, + fetchFn: jest.fn().mockResolvedValue(failedOrder), + }), + ); + + const historyItem = makeHistoryItem({ + originalTransactionId: 'tx-2', + status: { + status: StatusTypes.PENDING, + srcChain: { chainId: 1, txHash: '0xhash' }, + }, + }); + await manager.getIntentTransactionStatus( + 'order-3', + historyItem.quote.srcChainId, + historyItem.quote.intent?.protocol ?? '', + BridgeClientId.MOBILE, + historyItem.status.srcChain.txHash, + ); + manager.syncTransactionFromIntentStatus('order-3', historyItem); + + expect(mockCall).toHaveBeenCalledTimes(3); + + manager.syncTransactionFromIntentStatus('order-3', historyItem); + + expect(mockCall.mock.calls).toMatchInlineSnapshot(` + [ + [ + "AuthenticationController:getBearerToken", + ], + [ + "TransactionController:getState", + ], + [ + "TransactionController:updateTransaction", + { + "hash": "0xb756e7c856f1bf6ca3c3feda2067b85574383fb1f4ce95b175c2d447c932cdcc", + "id": "tx-2", + "status": "failed", + "txReceipt": { + "status": "0x0", + "transactionHash": "0xb756e7c856f1bf6ca3c3feda2067b85574383fb1f4ce95b175c2d447c932cdcc", + }, + }, + "BridgeStatusController - Intent order status updated: failed", + ], + ] + `); + }); + + it('syncTransactionFromIntentStatus logs warn when transaction is not found', async () => { + const warnSpy = jest + .spyOn(console, 'warn') + .mockImplementation(() => undefined); + const manager = new IntentManager( + createManagerOptions({ + messenger: { + call: jest.fn(() => ({ transactions: [] })), + }, + fetchFn: jest.fn().mockResolvedValue({ + id: 'order-1', + status: IntentOrderStatus.SUBMITTED, + metadata: {}, + }), + }), + ); + + const historyItem = makeHistoryItem({ + originalTransactionId: 'tx-missing', + }); + await manager.getIntentTransactionStatus( + 'order-1', + historyItem.quote.srcChainId, + historyItem.quote.intent?.protocol ?? '', + BridgeClientId.MOBILE, + historyItem.status.srcChain.txHash, + ); + manager.syncTransactionFromIntentStatus('order-1', historyItem); + + expect(warnSpy).toHaveBeenCalledWith( + '[Intent polling] Skipping update, transaction not found', + expect.any(Object), + ); + warnSpy.mockRestore(); + }); + + it('syncTransactionFromIntentStatus updates tx with txReceipt when bridgeStatus has txHash and is not complete', async () => { + const existingTxMeta = { + id: 'tx-2', + status: TransactionStatus.submitted, + txReceipt: { status: '0x0' }, + }; + const submittedOrder = { + id: 'order-3', + status: IntentOrderStatus.SUBMITTED, + txHash: + '0xb756e7c856f1bf6ca3c3feda2067b85574383fb1f4ce95b175c2d447c932cdcc', + metadata: {}, + }; + const mockCall = jest.fn((...args: unknown[]) => { + const [method] = args; + if (method === 'TransactionController:updateTransaction') { + return { transactions: [existingTxMeta] }; + } + if (method === 'AuthenticationController:getBearerToken') { + return 'token'; + } + return { transactions: [existingTxMeta] }; + }); + const manager = new IntentManager( + createManagerOptions({ + messenger: { + call: (...args: unknown[]) => mockCall(...args), + }, + fetchFn: jest.fn().mockResolvedValue(submittedOrder), + }), + ); + + const historyItem = makeHistoryItem({ + originalTransactionId: 'tx-2', + status: { + status: StatusTypes.PENDING, + srcChain: { chainId: 1, txHash: '0xhash' }, + }, + }); + await manager.getIntentTransactionStatus( + 'order-3', + historyItem.quote.srcChainId, + historyItem.quote.intent?.protocol ?? '', + BridgeClientId.MOBILE, + historyItem.status.srcChain.txHash, + ); + manager.syncTransactionFromIntentStatus('order-3', historyItem); + + expect(mockCall).toHaveBeenCalledWith( + 'TransactionController:updateTransaction', + expect.objectContaining({ + id: 'tx-2', + txReceipt: expect.objectContaining({ + transactionHash: + '0xb756e7c856f1bf6ca3c3feda2067b85574383fb1f4ce95b175c2d447c932cdcc', + status: '0x0', + }), + }), + expect.any(String), + ); + }); + + it('syncTransactionFromIntentStatus omits hash when bridgeStatus has no txHash', async () => { + const existingTxMeta = { + id: 'tx-2', + status: TransactionStatus.submitted, + hash: undefined, + }; + const orderWithoutTxHash = { + id: 'order-3', + status: IntentOrderStatus.SUBMITTED, + metadata: {}, + }; + const mockCall = jest.fn((...args: unknown[]) => { + const [method] = args; + if (method === 'TransactionController:updateTransaction') { + return { transactions: [existingTxMeta] }; + } + return { transactions: [existingTxMeta] }; + }); + const manager = new IntentManager( + createManagerOptions({ + messenger: { + call: (...args: unknown[]) => mockCall(...args), + }, + fetchFn: jest.fn().mockResolvedValue(orderWithoutTxHash), + }), + ); + const historyItem = makeHistoryItem({ + originalTransactionId: 'tx-2', + status: { + status: StatusTypes.PENDING, + srcChain: { chainId: 1, txHash: '' }, + }, + }); + await manager.getIntentTransactionStatus( + 'order-3', + historyItem.quote.srcChainId, + historyItem.quote.intent?.protocol ?? '', + BridgeClientId.MOBILE, + historyItem.status.srcChain.txHash, + ); + manager.syncTransactionFromIntentStatus('order-3', historyItem); + + expect(mockCall.mock.calls).toMatchInlineSnapshot(` + [ + [ + "AuthenticationController:getBearerToken", + ], + [ + "TransactionController:getState", + ], + [ + "TransactionController:updateTransaction", + { + "hash": undefined, + "id": "tx-2", + "status": "submitted", + }, + "BridgeStatusController - Intent order status updated: submitted", + ], + ] + `); + }); + + it('syncTransactionFromIntentStatus logs error when updateTransactionFn throws', async () => { + const existingTxMeta = { + id: 'tx-2', + status: TransactionStatus.submitted, + txReceipt: {}, + }; + const errorSpy = jest + .spyOn(console, 'error') + .mockImplementation(() => undefined); + const manager = new IntentManager( + createManagerOptions({ + messenger: { + call: jest.fn((method) => { + if (method === 'TransactionController:updateTransaction') { + throw new Error('update failed'); + } + return { transactions: [existingTxMeta] }; + }), + }, + fetchFn: jest.fn().mockResolvedValue({ + id: 'order-3', + status: IntentOrderStatus.COMPLETED, + txHash: '0xhash', + metadata: {}, + }), + }), + ); + + const historyItem = makeHistoryItem({ + originalTransactionId: 'tx-2', + status: { + status: StatusTypes.PENDING, + srcChain: { chainId: 1, txHash: '0xhash' }, + }, + }); + await manager.getIntentTransactionStatus( + 'order-3', + historyItem.quote.srcChainId, + historyItem.quote.intent?.protocol ?? '', + BridgeClientId.MOBILE, + historyItem.status.srcChain.txHash, + ); + manager.syncTransactionFromIntentStatus('order-3', historyItem); + + expect(errorSpy).toHaveBeenCalledWith( + '[Intent polling] Failed to update transaction status', + expect.objectContaining({ + originalTxId: 'tx-2', + bridgeHistoryKey: 'order-3', + error: expect.any(Error), + }), + ); + errorSpy.mockRestore(); + }); + + it('submitIntent delegates to intentApi.submitIntent', async () => { + const expectedOrder = { + id: 'order-1', + status: IntentOrderStatus.SUBMITTED, + metadata: {}, + }; + const { customBridgeApiBaseUrl, fetchFn } = createManagerOptions({ + fetchFn: jest.fn().mockResolvedValue(expectedOrder), + }); + + const params = { + srcChainId: 1, + quoteId: 'quote-1', + signature: '0xsig', + order: { some: 'order' }, + userAddress: '0xuser', + aggregatorId: 'cowswap', + }; + + const result = await postSubmitOrder({ + params, + clientId: BridgeClientId.EXTENSION, + jwt: undefined, + fetchFn, + bridgeApiBaseUrl: customBridgeApiBaseUrl, + }); + + expect(result).toStrictEqual(expectedOrder); + }); +}); diff --git a/packages/bridge-status-controller/src/bridge-status-controller.intent.test.ts b/packages/bridge-status-controller/src/bridge-status-controller.intent.test.ts new file mode 100644 index 00000000000..aa782fb2a18 --- /dev/null +++ b/packages/bridge-status-controller/src/bridge-status-controller.intent.test.ts @@ -0,0 +1,1361 @@ +/* eslint-disable @typescript-eslint/explicit-function-return-type */ +/* eslint-disable @typescript-eslint/no-explicit-any */ +/* eslint-disable jest/no-restricted-matchers */ +import { + BridgeClientId, + UnifiedSwapBridgeEventName, + StatusTypes, + QuoteResponse as QuoteResponseV1, + getNativeAssetForChainId, + validateQuoteResponseV1, +} from '@metamask/bridge-controller'; +import type { + GasFeeEstimates, + TransactionMeta, +} from '@metamask/transaction-controller'; +import { + TransactionStatus, + TransactionType, +} from '@metamask/transaction-controller'; + +import { BridgeStatusController } from './bridge-status-controller.js'; +import { MAX_ATTEMPTS } from './constants.js'; +import type { BridgeStatusControllerState } from './types.js'; +import * as bridgeStatusUtils from './utils/bridge-status.js'; +import * as historyUtils from './utils/history.js'; +import * as intentApi from './utils/intent-api.js'; +import { IntentOrderStatus } from './utils/validators.js'; + +jest.spyOn(intentApi, 'postSubmitOrder').mockImplementation(jest.fn()); +jest + .spyOn(intentApi.IntentApiImpl.prototype, 'getOrderStatus') + .mockImplementation(jest.fn()); + +const minimalIntentQuoteResponse = ( + overrides?: Partial, +): any => { + const quote = { + quote: { + requestId: 'req-1', + srcChainId: 1, + destChainId: 1, + srcTokenAmount: '1000', + destTokenAmount: '990', + bridges: ['cowswap'], + bridgeId: 'cowswap', + minDestTokenAmount: '900', + srcAsset: { + symbol: 'ETH', + chainId: 1, + address: '0x0000000000000000000000000000000000000000', + assetId: 'eip155:1/slip44:60' as const, + name: 'ETH', + decimals: 18, + }, + destAsset: { + symbol: 'ETH', + chainId: 1, + address: '0x0000000000000000000000000000000000000000', + assetId: 'eip155:1/slip44:60' as const, + name: 'ETH', + decimals: 18, + }, + feeData: { + txFee: { + amount: '1', + asset: getNativeAssetForChainId(1), + maxFeePerGas: '1', + maxPriorityFeePerGas: '1', + }, + metabridge: { + amount: '0', + asset: getNativeAssetForChainId(1), + }, + }, + intent: { + protocol: 'cowswap', + order: { + sellToken: '0x0000000000000000000000000000000000000001', + buyToken: '0x0000000000000000000000000000000000000002', + validTo: 1717027200, + appData: 'some-app-data', + appDataHash: '0xabcd', + feeAmount: '100', + kind: 'sell' as const, + partiallyFillable: false, + sellAmount: '1000', + }, + settlementContract: '0x9008D19f58AAbd9eD0D60971565AA8510560ab41', + typedData: { + types: {}, + domain: {}, + primaryType: 'Order', + message: {}, + }, + }, + steps: [], + }, + estimatedProcessingTimeInSeconds: 15, + featureId: undefined, + approval: undefined, + resetApproval: undefined, + trade: { + chainId: 1, + from: '0x9008D19f58AAbd9eD0D60971565AA8510560ab4a', + to: '0x0000000000000000000000000000000000000001', + data: '0x', + value: '0x0', + gasLimit: 21000, + }, + ...overrides, + }; + return { + ...quote, + ...{ + sentAmount: { amount: '1', usd: '1' }, + gasFee: { effective: { amount: '0', usd: '0' } }, + toTokenAmount: { usd: '1' }, + }, + }; +}; + +const minimalBridgeQuoteResponse = ( + accountAddress: string, + overrides?: Partial, +): any => { + const quote = { + quote: { + requestId: 'req-bridge-1', + srcChainId: 1, + destChainId: 10, + srcTokenAmount: '1000', + destTokenAmount: '990', + minDestTokenAmount: '900', + srcAsset: { + symbol: 'ETH', + chainId: 1, + address: '0x0000000000000000000000000000000000000000', + assetId: 'eip155:1/slip44:60', + name: 'ETH', + decimals: 18, + }, + destAsset: { + symbol: 'ETH', + chainId: 10, + address: '0x0000000000000000000000000000000000000000', + assetId: 'eip155:10/slip44:60', + name: 'ETH', + decimals: 18, + }, + feeData: { + metabridge: { amount: '1', asset: getNativeAssetForChainId(1) }, + txFee: { + amount: '1', + asset: getNativeAssetForChainId(1), + maxFeePerGas: '1', + maxPriorityFeePerGas: '1', + }, + }, + bridges: ['across'], + bridgeId: 'socket', + steps: [], + }, + + estimatedProcessingTimeInSeconds: 15, + featureId: undefined, + approval: undefined, + resetApproval: undefined, + trade: { + chainId: 1, + from: accountAddress, + to: '0x0000000000000000000000000000000000000001', + data: '0x', + value: '0x0', + gasLimit: 21000, + }, + ...overrides, + }; + return { + ...quote, + ...{ + sentAmount: { amount: '1', usd: '1' }, + gasFee: { effective: { amount: '0', usd: '0' } }, + toTokenAmount: { usd: '1' }, + }, + }; +}; +validateQuoteResponseV1(minimalBridgeQuoteResponse('0xAccount1')); + +const createMessengerHarness = ( + accountAddress: string, + selectedChainId: string = '0x1', + keyringType: string = 'HD Key Tree', + approvalStatus?: TransactionStatus, +): any => { + const transactions: TransactionMeta[] = []; + + const messenger = { + registerActionHandler: jest.fn(), + registerMethodActionHandlers: jest.fn(), + registerInitialEventPayload: jest.fn(), // REQUIRED by BaseController + subscribe: jest.fn(), + publish: jest.fn(), + call: jest.fn((method: string, ...args: any[]) => { + switch (method) { + case 'AccountsController:getAccountByAddress': { + const addr = (args[0] as string) ?? ''; + if (addr.toLowerCase() !== accountAddress.toLowerCase()) { + return undefined; + } + + // REQUIRED so isHardwareWallet() doesn't throw + return { + address: accountAddress, + metadata: { keyring: { type: keyringType } }, + }; + } + case 'TransactionController:getState': + return { transactions }; + case 'TransactionController:estimateGasFee': + return { estimates: {} as GasFeeEstimates }; + case 'TransactionController:addTransaction': { + // Approval TX path (submitIntent -> #handleApprovalTx -> #handleEvmTransaction) + if ( + args[1]?.type === TransactionType.bridgeApproval || + args[1]?.type === TransactionType.swapApproval + ) { + const hash = '0xapprovalhash1'; + + const approvalTx = { + id: 'approvalTxId1', + type: args[1]?.type, + status: approvalStatus ?? TransactionStatus.failed, + chainId: args[0]?.chainId ?? '0x1', + hash, + networkClientId: 'network-client-id-1', + time: Date.now(), + txParams: args[0], + }; + transactions.push(approvalTx); + + return { + result: Promise.resolve(hash), + transactionMeta: approvalTx, + }; + } + + // Intent “display tx” path + const intentTx = { + id: 'intentDisplayTxId1', + type: args[1]?.type, + status: TransactionStatus.submitted, + chainId: args[0]?.chainId ?? '0x1', + hash: undefined, + networkClientId: 'network-client-id-1', + time: Date.now(), + txParams: args[0], + }; + transactions.push(intentTx); + + return { + result: Promise.resolve('0xunused'), + transactionMeta: intentTx, + }; + } + case 'AuthenticationController:getBearerToken': { + return '0xjwt'; + } + case 'NetworkController:findNetworkClientIdByChainId': + return 'network-client-id-1'; + case 'NetworkController:getState': + return { selectedNetworkClientId: 'selected-network-client-id-1' }; + case 'NetworkController:getNetworkClientById': + return { configuration: { chainId: selectedChainId } }; + case 'BridgeController:trackUnifiedSwapBridgeEvent': + return undefined; + case 'GasFeeController:getState': + return { gasFeeEstimates: {} }; + case 'KeyringController:signTypedMessage': + return '0xtest-signature'; + default: + return undefined; + } + }), + }; + + return { messenger, transactions }; +}; + +const setup = (options?: { + selectedChainId?: string; + approvalStatus?: TransactionStatus; + clientId?: BridgeClientId; + keyringType?: string; + mockTxHistory?: any; +}) => { + const accountAddress = '0xAccount1' as const; + const { messenger, transactions } = createMessengerHarness( + accountAddress, + options?.selectedChainId ?? '0x1', + options?.keyringType, + options?.approvalStatus, + ); + + const mockFetchFn = jest.fn(); + const controller = new BridgeStatusController({ + messenger, + state: { + txHistory: options?.mockTxHistory ?? {}, + }, + addTransactionBatchFn: jest.fn(), + clientId: options?.clientId ?? BridgeClientId.EXTENSION, + fetchFn: (...args: any[]) => mockFetchFn(...args), + config: { customBridgeApiBaseUrl: 'http://localhost' }, + traceFn: (_req: any, fn?: any): any => fn?.(), + }); + + const startPollingSpy = jest + .spyOn(controller, 'startPolling') + .mockReturnValue('poll-token-1'); + + const stopPollingSpy = jest.spyOn(controller, 'stopPollingByPollingToken'); + + return { + mockFetchFn, + controller, + messenger, + transactions, + startPollingSpy, + stopPollingSpy, + accountAddress, + }; +}; + +describe('BridgeStatusController (intent swaps)', () => { + beforeEach(() => { + jest.restoreAllMocks(); + jest.clearAllMocks(); + }); + + it('submitIntent: throws if approval confirmation fails (does not write history or start polling)', async () => { + const { controller, accountAddress, startPollingSpy } = setup(); + + const orderUid = 'order-uid-1'; + + // In the "throw on approval confirmation failure" behavior, we should not reach intent submission, + // but keep this here to prove it wasn't used. + const intentStatusResponse = { + id: orderUid, + status: IntentOrderStatus.SUBMITTED, + txHash: undefined, + metadata: { txHashes: [] }, + }; + const submitIntentSpy = jest + .spyOn(intentApi, 'postSubmitOrder') + .mockResolvedValue(intentStatusResponse); + + const quoteResponse = minimalIntentQuoteResponse({ + // Include approval to exercise the approval confirmation path. + // Your harness sets approval tx status to failed, so #waitForTxConfirmation should throw. + approval: { + chainId: 1, + from: accountAddress, + to: '0x0000000000000000000000000000000000000001', + data: '0x', + value: '0x0', + gasLimit: 21000, + }, + }); + + const promise = controller.submitIntent({ + quoteResponse, + accountAddress, + }); + await expect(promise).rejects.toThrowErrorMatchingInlineSnapshot( + `"Approval transaction did not confirm"`, + ); + + // Since we throw before intent order submission succeeds, we should not create the history item + // (and therefore should not start polling). + const historyKey = orderUid; + expect(controller.state.txHistory[historyKey]).toBeUndefined(); + + expect(startPollingSpy).not.toHaveBeenCalled(); + + // Optional: ensure we never called the intent API submit + expect(submitIntentSpy).not.toHaveBeenCalled(); + }); + + it('submitIntent: completes when approval tx confirms', async () => { + jest.spyOn(Date, 'now').mockReturnValue(1773879217428); + const { controller, accountAddress } = setup({ + approvalStatus: TransactionStatus.confirmed, + }); + const orderUid = 'order-uid-approve-1'; + const intentStatusResponse = { + id: orderUid, + status: IntentOrderStatus.SUBMITTED, + txHash: undefined, + metadata: { txHashes: [] }, + }; + const submitIntentSpy = jest + .spyOn(intentApi, 'postSubmitOrder') + .mockResolvedValue(intentStatusResponse); + + const quoteResponse = minimalIntentQuoteResponse({ + approval: { + chainId: 1, + from: accountAddress, + to: '0x0000000000000000000000000000000000000001', + data: '0x', + value: '0x0', + gasLimit: 21000, + }, + }); + + await expect( + controller.submitIntent({ + quoteResponse, + accountAddress, + }), + ).resolves.toMatchInlineSnapshot(` + { + "chainId": "0x1", + "hash": undefined, + "id": "intentDisplayTxId1", + "networkClientId": "network-client-id-1", + "status": "submitted", + "time": 1773879217428, + "txParams": { + "chainId": "0x1", + "data": "0xpprove-1", + "from": "0xAccount1", + "gas": "0x5208", + "gasPrice": "0x3b9aca00", + "to": "0x9008D19f58AAbd9eD0D60971565AA8510560ab41", + "value": "0x0", + }, + "type": "swap", + } + `); + + expect(submitIntentSpy).toHaveBeenCalled(); + }); + + it('submitIntent: throws when approval tx is rejected', async () => { + const { controller, accountAddress } = setup({ + approvalStatus: TransactionStatus.rejected, + clientId: BridgeClientId.MOBILE, + keyringType: 'Hardware', + }); + + const orderUid = 'order-uid-approve-2'; + const intentStatusResponse = { + id: orderUid, + status: IntentOrderStatus.SUBMITTED, + txHash: undefined, + metadata: { txHashes: [] }, + }; + const submitIntentSpy = jest + .spyOn(intentApi, 'postSubmitOrder') + .mockResolvedValue(intentStatusResponse); + + const quoteResponse = minimalIntentQuoteResponse({ + approval: { + chainId: 1, + from: accountAddress, + to: '0x0000000000000000000000000000000000000001', + data: '0x', + value: '0x0', + gasLimit: 21000, + }, + }); + + const promise = controller.submitIntent({ + quoteResponse, + accountAddress, + }); + await expect(promise).rejects.toThrowErrorMatchingInlineSnapshot( + `"Approval transaction did not confirm"`, + ); + + expect(submitIntentSpy).not.toHaveBeenCalled(); + }); + + it('submitIntent: logs error when history update fails but still returns tx meta', async () => { + const { controller, accountAddress } = setup(); + + const orderUid = 'order-uid-log-1'; + + const intentStatusResponse = { + id: orderUid, + status: IntentOrderStatus.SUBMITTED, + txHash: undefined, + metadata: { txHashes: [] }, + }; + jest + .spyOn(intentApi, 'postSubmitOrder') + .mockResolvedValue(intentStatusResponse); + + jest.spyOn(historyUtils, 'getInitialHistoryItem').mockImplementation(() => { + throw new Error('boom'); + }); + + const quoteResponse = minimalIntentQuoteResponse(); + const consoleSpy = jest + .spyOn(console, 'error') + .mockImplementation(() => undefined); + + const result = await controller.submitIntent({ + quoteResponse, + accountAddress, + }); + + expect(result).toBeDefined(); + expect(consoleSpy).toHaveBeenCalledWith( + expect.stringContaining('Failed to add to bridge history'), + expect.any(Error), + ); + + consoleSpy.mockRestore(); + }); + + it('submitIntent: signs typedData', async () => { + const { controller, messenger, accountAddress } = setup(); + + const orderUid = 'order-uid-signed-in-core-1'; + + const intentStatusResponse = { + id: orderUid, + status: IntentOrderStatus.SUBMITTED, + txHash: undefined, + metadata: { txHashes: [] }, + }; + const submitIntentSpy = jest + .spyOn(intentApi, 'postSubmitOrder') + .mockResolvedValue(intentStatusResponse); + + const quoteResponse = minimalIntentQuoteResponse(); + quoteResponse.quote.intent.typedData = { + types: {}, + primaryType: 'Order', + domain: {}, + message: {}, + }; + + const originalCallImpl = ( + messenger.call as jest.Mock + ).getMockImplementation(); + (messenger.call as jest.Mock).mockImplementation( + (method: string, ...args: any[]) => { + if (method === 'KeyringController:signTypedMessage') { + return '0xautosigned'; + } + return originalCallImpl?.(method, ...args); + }, + ); + + await controller.submitIntent({ + quoteResponse, + accountAddress, + }); + + expect((messenger.call as jest.Mock).mock.calls).toStrictEqual( + expect.arrayContaining([ + [ + 'KeyringController:signTypedMessage', + expect.objectContaining({ + from: accountAddress, + data: quoteResponse.quote.intent.typedData, + }), + 'V4', + ], + ]), + ); + + expect(submitIntentSpy.mock.calls[0]?.[0]).toMatchInlineSnapshot(` + { + "bridgeApiBaseUrl": "http://localhost", + "clientId": "extension", + "fetchFn": [Function], + "jwt": "0xjwt", + "params": { + "aggregatorId": "cowswap", + "order": { + "appData": "some-app-data", + "appDataHash": "0xabcd", + "buyToken": "0x0000000000000000000000000000000000000002", + "feeAmount": "100", + "kind": "sell", + "partiallyFillable": false, + "sellAmount": "1000", + "sellToken": "0x0000000000000000000000000000000000000001", + "validTo": 1717027200, + }, + "quoteId": "req-1", + "signature": "0xautosigned", + "srcChainId": 1, + "userAddress": "0xAccount1", + }, + } + `); + }); + + it('intent polling: updates history, merges tx hashes, updates TC tx, and stops polling on COMPLETED', async () => { + const { controller, accountAddress, messenger, stopPollingSpy } = setup(); + + const orderUid = 'order-uid-2'; + + const intentStatusResponse = { + id: orderUid, + status: IntentOrderStatus.SUBMITTED, + txHash: undefined, + metadata: { txHashes: [] }, + }; + jest + .spyOn(intentApi, 'postSubmitOrder') + .mockResolvedValue(intentStatusResponse); + + const quoteResponse = minimalIntentQuoteResponse(); + + await controller.submitIntent({ + quoteResponse, + accountAddress, + }); + + const historyKey = orderUid; + + const intentStatusResponseCompleted = { + id: orderUid, + status: IntentOrderStatus.COMPLETED, + txHash: '0xnewhash', + metadata: { txHashes: ['0xold1', '0xnewhash'] }, + }; + jest + .spyOn(intentApi.IntentApiImpl.prototype, 'getOrderStatus') + .mockResolvedValue(intentStatusResponseCompleted); + + await controller._executePoll({ bridgeTxMetaId: historyKey }); + + const updated = controller.state.txHistory[historyKey]; + expect(updated.status.status).toBe(StatusTypes.COMPLETE); + expect(updated.status.srcChain.txHash).toBe('0xnewhash'); + + expect(stopPollingSpy).toHaveBeenCalledWith('poll-token-1'); + const completedEventCall = (messenger.call as jest.Mock).mock.calls.find( + ([, eventName]) => eventName === UnifiedSwapBridgeEventName.Completed, + ); + expect(completedEventCall?.[2]).toStrictEqual( + expect.objectContaining({ + transaction_internal_id: 'intentDisplayTxId1', + }), + ); + }); + + it('intent polling: maps PENDING to PENDING, falls back to txHash when metadata hashes empty', async () => { + const { controller, accountAddress, transactions, stopPollingSpy } = + setup(); + + const orderUid = 'order-uid-expired-1'; + + const intentStatusResponse = { + id: orderUid, + status: IntentOrderStatus.SUBMITTED, + txHash: undefined, + metadata: { txHashes: [] }, + }; + jest + .spyOn(intentApi, 'postSubmitOrder') + .mockResolvedValue(intentStatusResponse); + + const quoteResponse = minimalIntentQuoteResponse(); + + await controller.submitIntent({ + quoteResponse, + accountAddress, + }); + + const historyKey = orderUid; + + // Remove TC tx so update branch logs "transaction not found" + transactions.splice(0, transactions.length); + + const intentStatusResponsePending = { + id: orderUid, + status: IntentOrderStatus.PENDING, + txHash: '0xonlyhash', + metadata: { txHashes: [] }, // forces fallback to txHash + }; + jest + .spyOn(intentApi.IntentApiImpl.prototype, 'getOrderStatus') + .mockResolvedValue(intentStatusResponsePending); + + await controller._executePoll({ bridgeTxMetaId: historyKey }); + + const updated = controller.state.txHistory[historyKey]; + expect(updated.status.status).toBe(StatusTypes.PENDING); + expect(updated.status.srcChain.txHash).toBe('0xonlyhash'); + + expect(stopPollingSpy).not.toHaveBeenCalled(); + }); + + it('intent polling: maps EXPIRED to FAILED, falls back to txHash when metadata hashes empty, and skips TC update if original tx not found', async () => { + const { controller, accountAddress, transactions, stopPollingSpy } = + setup(); + + const orderUid = 'order-uid-expired-1'; + + const intentStatusResponse = { + id: orderUid, + status: IntentOrderStatus.SUBMITTED, + txHash: undefined, + metadata: { txHashes: [] }, + }; + jest + .spyOn(intentApi, 'postSubmitOrder') + .mockResolvedValue(intentStatusResponse); + + const quoteResponse = minimalIntentQuoteResponse(); + + await controller.submitIntent({ + quoteResponse, + accountAddress, + }); + + const historyKey = orderUid; + + // Remove TC tx so update branch logs "transaction not found" + transactions.splice(0, transactions.length); + + jest + .spyOn(intentApi.IntentApiImpl.prototype, 'getOrderStatus') + .mockResolvedValue({ + id: orderUid, + status: IntentOrderStatus.EXPIRED, + txHash: '0xonlyhash', + metadata: { txHashes: [] }, // forces fallback to txHash + }); + + await controller._executePoll({ bridgeTxMetaId: historyKey }); + + const updated = controller.state.txHistory[historyKey]; + expect(updated.status.status).toBe(StatusTypes.FAILED); + expect(updated.status.srcChain.txHash).toBe('0xonlyhash'); + + expect(stopPollingSpy).toHaveBeenCalledWith('poll-token-1'); + }); + + it('intent polling: stops polling when attempts reach MAX_ATTEMPTS', async () => { + const orderUid = 'order-uid-3'; + const { controller, stopPollingSpy } = setup({ + mockTxHistory: { + [orderUid]: { + txMetaId: 'order-uid-3', + originalTransactionId: 'order-uid-3', + quote: { + ...minimalIntentQuoteResponse().quote, + }, + attempts: { + counter: MAX_ATTEMPTS - 1, + lastAttemptTime: 0, + }, + status: { + status: StatusTypes.PENDING, + srcChain: { chainId: 1, txHash: undefined }, + }, + }, + }, + }); + + jest.spyOn(intentApi, 'postSubmitOrder').mockResolvedValue({ + id: orderUid, + status: IntentOrderStatus.SUBMITTED, + txHash: undefined, + metadata: { txHashes: [] }, + }); + + const historyKey = orderUid; + + jest + .spyOn(intentApi.IntentApiImpl.prototype, 'getOrderStatus') + .mockRejectedValue(new Error('boom')); + + await controller._executePoll({ bridgeTxMetaId: historyKey }); + + expect(stopPollingSpy).toHaveBeenCalledTimes(1); + expect(controller.state.txHistory[historyKey].attempts).toStrictEqual( + expect.objectContaining({ counter: MAX_ATTEMPTS }), + ); + }); +}); + +describe('BridgeStatusController (subscriptions + bridge polling + wiping)', () => { + beforeEach(() => { + jest.restoreAllMocks(); + jest.resetModules(); + jest.clearAllMocks(); + }); + + it('restartPollingForFailedAttempts: throws when identifier missing, and when no match found', async () => { + const { controller } = setup(); + + expect(() => controller.restartPollingForFailedAttempts({})).toThrow( + /Either txMetaId or txHash must be provided/u, + ); + + expect(() => + controller.restartPollingForFailedAttempts({ + txMetaId: 'does-not-exist', + }), + ).toThrow(/No bridge transaction history found/u); + }); + + it('restartPollingForFailedAttempts: resets attempts and restarts polling via txHash lookup (bridge tx only)', async () => { + const mockTxHistory = { + bridgeTx1: { + txMetaId: 'bridgeTx1', + originalTransactionId: 'bridgeTx1', + quote: { + srcChainId: 1, + destChainId: 10, + srcAsset: { + address: '0x0000000000000000000000000000000000000000', + assetId: 'eip155:1/slip44:60', + }, + destAsset: { + address: '0x0000000000000000000000000000000000000000', + assetId: 'eip155:10/slip44:60', + }, + bridges: ['cowswap'], + bridgeId: 'cowswap', + }, + attempts: { counter: 7, lastAttemptTime: Date.now() }, + account: '0xAccount1', + status: { + status: StatusTypes.UNKNOWN, + srcChain: { chainId: 1, txHash: '0xhash-find-me' }, + }, + }, + }; + const { controller } = setup({ + mockTxHistory, + }); + + expect(controller.state.txHistory.bridgeTx1.attempts).toStrictEqual( + expect.objectContaining({ counter: 7 }), + ); + + const startPollingSpy = jest.spyOn(controller, 'startPolling'); + + controller.stopAllPolling(); + controller.restartPollingForFailedAttempts({ txHash: '0xhash-find-me' }); + + expect(controller.state.txHistory.bridgeTx1.attempts).toBeUndefined(); + expect(startPollingSpy).toHaveBeenCalledWith({ + bridgeTxMetaId: 'bridgeTx1', + }); + + controller.stopAllPolling(); + }); + + it('restartPollingForFailedAttempts: does not restart polling for same-chain swap tx', async () => { + const mockTxHistory = { + swapTx1: { + txMetaId: 'swapTx1', + originalTransactionId: 'swapTx1', + quote: { + srcChainId: 1, + destChainId: 1, + srcAsset: { assetId: 'eip155:1/slip44:60' }, + destAsset: { assetId: 'eip155:1/slip44:60' }, + }, + attempts: { counter: 7, lastAttemptTime: 0 }, + account: '0xAccount1', + status: { + status: StatusTypes.UNKNOWN, + srcChain: { chainId: 1, txHash: '0xhash-samechain' }, + }, + }, + }; + const { controller, startPollingSpy } = setup({ + mockTxHistory, + }); + + controller.restartPollingForFailedAttempts({ txMetaId: 'swapTx1' }); + + expect(controller.state.txHistory.swapTx1.attempts).toBeUndefined(); + expect(startPollingSpy).not.toHaveBeenCalled(); + }); + + it('wipeBridgeStatus(ignoreNetwork=false): stops polling and removes only matching chain+account history', async () => { + const { controller, stopPollingSpy, accountAddress } = setup({ + selectedChainId: '0x1', + }); + + const quoteResponse = minimalBridgeQuoteResponse(accountAddress); + + // Use deprecated method to create history and start polling (so token exists in controller) + controller.startPollingForBridgeTxStatus({ + accountAddress, + bridgeTxMeta: { id: 'bridgeToWipe1', hash: '0xsrc' } as TransactionMeta, + quoteResponse, + slippagePercentage: 0, + startTime: Date.now(), + isStxEnabled: false, + }); + + expect(controller.state.txHistory.bridgeToWipe1).toBeDefined(); + + controller.wipeBridgeStatus({ + address: accountAddress, + ignoreNetwork: false, + }); + + expect(stopPollingSpy).toHaveBeenCalledWith('poll-token-1'); + expect(controller.state.txHistory.bridgeToWipe1).toBeUndefined(); + }); +}); + +describe('BridgeStatusController (target uncovered branches)', () => { + beforeEach(() => { + jest.restoreAllMocks(); + jest.resetModules(); + jest.resetAllMocks(); + jest.clearAllMocks(); + }); + + it('constructor restartPolling: skips items when shouldSkipFetchDueToFetchFailures returns true', () => { + const accountAddress = '0xAccount1'; + const { messenger } = createMessengerHarness(accountAddress); + + const startPollingProtoSpy = jest + .spyOn(BridgeStatusController.prototype, 'startPolling') + .mockReturnValue('tok'); + + // seed an incomplete bridge history item (PENDING + cross-chain) + const state = { + txHistory: { + init1: { + txMetaId: 'init1', + originalTransactionId: 'init1', + quote: { srcChainId: 1, destChainId: 10 }, + account: accountAddress, + status: { + status: StatusTypes.PENDING, + srcChain: { chainId: 1, txHash: '0xsrc' }, + }, + attempts: { counter: MAX_ATTEMPTS, lastAttemptTime: Date.now() }, + }, + }, + } as unknown as BridgeStatusControllerState; + + // constructor calls #restartPollingForIncompleteHistoryItems() + // shouldSkipFetchDueToFetchFailures=true => should NOT call startPolling + const controller = new BridgeStatusController({ + messenger, + state, + clientId: BridgeClientId.EXTENSION, + fetchFn: jest.fn(), + addTransactionBatchFn: jest.fn(), + config: { customBridgeApiBaseUrl: 'http://localhost' }, + traceFn: (_r: any, fn?: any): any => fn?.(), + }); + + expect(controller.state.txHistory.init1.attempts?.counter).toBe( + MAX_ATTEMPTS, + ); + + expect(startPollingProtoSpy).not.toHaveBeenCalled(); + startPollingProtoSpy.mockRestore(); + }); + + it('startPollingForTxId: stops existing polling token when restarting same tx', () => { + const { controller, stopPollingSpy, startPollingSpy, accountAddress } = + setup(); + + // make startPolling return different tokens for the same tx + startPollingSpy.mockReturnValueOnce('tok1').mockReturnValueOnce('tok2'); + + const quoteResponse = { + ...{ + quote: { + srcChainId: 1, + destChainId: 10, + destAsset: { assetId: 'eip155:10/slip44:60' }, + }, + estimatedProcessingTimeInSeconds: 1, + }, + ...{ + sentAmount: { amount: '0' }, + gasFee: { effective: { amount: '0' } }, + toTokenAmount: { usd: '0' }, + }, + }; + + // first time => starts polling tok1 + controller.startPollingForBridgeTxStatus({ + accountAddress, + bridgeTxMeta: { id: 'sameTx' }, + statusRequest: { srcChainId: 1, srcTxHash: '0xhash', destChainId: 10 }, + quoteResponse, + slippagePercentage: 0, + startTime: Date.now(), + isStxEnabled: false, + }); + + // second time => should stop tok1 and start tok2 + controller.startPollingForBridgeTxStatus({ + accountAddress, + bridgeTxMeta: { id: 'sameTx' }, + statusRequest: { srcChainId: 1, srcTxHash: '0xhash', destChainId: 10 }, + quoteResponse, + slippagePercentage: 0, + startTime: Date.now(), + isStxEnabled: false, + }); + + expect(stopPollingSpy).toHaveBeenCalledWith('tok1'); + }); + + it('bridge polling: returns early when shouldSkipFetchDueToFetchFailures returns true', async () => { + const mockTxHistory = { + valFail1: { + txMetaId: 'valFail1', + originalTransactionId: 'valFail1', + quote: { + srcChainId: 1, + destChainId: 137, + destAsset: { assetId: 'x' }, + bridges: ['rango'], + }, + account: '0xAccount1', + status: { + status: StatusTypes.PENDING, + srcChain: { chainId: 1, txHash: '0xhash' }, + }, + attempts: { + counter: MAX_ATTEMPTS, + lastAttemptTime: Date.now(), + }, + }, + }; + const { controller, accountAddress } = setup({ + mockTxHistory, + }); + + const quoteResponse = { + ...{ + quote: { + srcChainId: 1, + destChainId: 10, + destAsset: { assetId: 'eip155:10/slip44:60' }, + bridges: ['across'], + }, + estimatedProcessingTimeInSeconds: 1, + }, + ...{ + sentAmount: { amount: '0' }, + gasFee: { effective: { amount: '0' } }, + toTokenAmount: { usd: '0' }, + }, + }; + + const statusResponse = { + status: { + status: StatusTypes.PENDING, + srcChain: { chainId: 1, txHash: '0xhash' }, + }, + validationFailures: [], + }; + const fetchBridgeTxStatusSpy = jest + .spyOn(bridgeStatusUtils, 'fetchBridgeTxStatus') + .mockResolvedValue(statusResponse); + + controller.startPollingForBridgeTxStatus({ + accountAddress, + bridgeTxMeta: { id: 'valFail1' }, + statusRequest: { srcChainId: 1, srcTxHash: '0xhash', destChainId: 10 }, + quoteResponse, + slippagePercentage: 0, + startTime: Date.now(), + isStxEnabled: false, + } as any); + + await controller._executePoll({ bridgeTxMetaId: 'valFail1' }); + + expect(fetchBridgeTxStatusSpy).not.toHaveBeenCalledWith( + expect.objectContaining({ + bridge: 'rango', + destChainId: 137, + }), + ); + }); + + it('statusValidationFailed event includes refresh_count from attempts', async () => { + const quoteResponse = minimalBridgeQuoteResponse('0xAccount1'); + const { controller, messenger, mockFetchFn } = setup({ + mockTxHistory: { + valFail1: { + txMetaId: 'valFail1', + originalTransactionId: 'valFail1', + quote: quoteResponse.quote, + account: '0xAccount1', + attempts: { counter: 3, lastAttemptTime: Date.now() - 100000000 }, + status: { + status: StatusTypes.PENDING, + srcChain: { chainId: 1, txHash: '0xhash' }, + }, + startTime: Date.now() - 1000, + }, + }, + }); + + mockFetchFn.mockResolvedValueOnce({ + srcChain: { chainId: 1, txHash: '0xhash' }, + }); + + await controller._executePoll({ bridgeTxMetaId: 'valFail1' }); + + expect(controller.state.txHistory.valFail1.attempts).toStrictEqual( + expect.objectContaining({ counter: 4 }), + ); + + expect(messenger.call.mock.calls).toMatchInlineSnapshot(` + [ + [ + "AuthenticationController:getBearerToken", + ], + [ + "BridgeController:trackUnifiedSwapBridgeEvent", + "Unified SwapBridge Status Failed Validation", + { + "action_type": "swapbridge-v1", + "chain_id_destination": "eip155:10", + "chain_id_source": "eip155:1", + "failures": [ + "across|status", + ], + "feature_id": "unified_swap_bridge", + "location": "Unknown", + "refresh_count": 3, + "token_address_destination": "eip155:10/slip44:60", + "token_address_source": "eip155:1/slip44:60", + "token_security_type_destination": null, + }, + ], + [ + "RemoteFeatureFlagController:getState", + ], + ] + `); + controller.stopAllPolling(); + }); + + it('track event: history has featureId => #trackUnifiedSwapBridgeEvent returns early (skip tracking)', () => { + const mockTxHistory = { + feat1: { + txMetaId: 'feat1', + originalTransactionId: 'feat1', + quote: minimalBridgeQuoteResponse('0xAccount1').quote, + account: '0xAccount1', + featureId: 'perps', + status: { + status: StatusTypes.PENDING, + srcChain: { chainId: 1, txHash: '0x' }, + }, + }, + }; + const { controller, messenger } = setup({ + mockTxHistory, + }); + + const failedCb = messenger.subscribe.mock.calls.find( + ([evt]: [any]) => + evt === 'TransactionController:transactionStatusUpdated', + )?.[1]; + + failedCb({ + transactionMeta: { + id: 'feat1', + type: TransactionType.bridge, + status: TransactionStatus.failed, + chainId: '0x1', + }, + }); + + // should skip due to featureId + expect((messenger.call as jest.Mock).mock.calls).not.toStrictEqual( + expect.arrayContaining([ + expect.arrayContaining([ + 'BridgeController:trackUnifiedSwapBridgeEvent', + ]), + ]), + ); + controller.stopAllPolling(); + }); + + it('intent order PENDING maps to bridge PENDING', async () => { + const mockTxHistory = { + 'order-1': { + txMetaId: 'order-1', + originalTransactionId: 'order-1', + quote: minimalIntentQuoteResponse().quote, + account: '0xAccount1', + status: { + status: StatusTypes.PENDING, + srcChain: { chainId: 1, txHash: '0xhash' }, + }, + }, + }; + const { controller } = setup({ + mockTxHistory, + }); + + jest + .spyOn(intentApi.IntentApiImpl.prototype, 'getOrderStatus') + .mockImplementation( + jest.fn().mockResolvedValue({ + id: 'order-1', + status: IntentOrderStatus.PENDING, + txHash: undefined, + metadata: { txHashes: [] }, + }), + ); + + controller.startPolling({ + bridgeTxMetaId: 'order-1', + }); + + expect(controller.state.txHistory['order-1'].status.status).toBe( + StatusTypes.PENDING, + ); + controller.stopAllPolling(); + }); + + it('intent order SUBMITTED maps to bridge SUBMITTED', async () => { + const orderStatusResponseSubmitted = { + id: 'order-1', + status: IntentOrderStatus.SUBMITTED, + txHash: undefined, + metadata: { txHashes: [] }, + }; + const getOrderStatusSpy = jest + .spyOn(intentApi.IntentApiImpl.prototype, 'getOrderStatus') + .mockImplementation( + jest.fn().mockResolvedValueOnce(orderStatusResponseSubmitted), + ); + + const { controller } = setup({ + mockTxHistory: { + 'order-1': { + txMetaId: 'order-1', + originalTransactionId: 'order-1', + quote: minimalIntentQuoteResponse().quote, + account: '0xAccount1', + status: { + status: StatusTypes.SUBMITTED, + srcChain: { chainId: 1, txHash: '0xhash' }, + }, + }, + }, + }); + const orderStatusResponse = { + id: 'order-1', + status: IntentOrderStatus.SUBMITTED, + txHash: undefined, + metadata: { txHashes: [] }, + }; + jest + .spyOn(intentApi.IntentApiImpl.prototype, 'getOrderStatus') + .mockImplementation(jest.fn().mockResolvedValue(orderStatusResponse)); + + await controller._executePoll({ bridgeTxMetaId: 'order-1' }); + + expect(getOrderStatusSpy).toHaveBeenCalledWith( + 'order-1', + 'cowswap', + 1, + 'extension', + ); + expect(controller.state.txHistory['order-1'].status.status).toBe( + StatusTypes.SUBMITTED, + ); + controller.stopAllPolling(); + }); + + it('unknown intent order status maps to bridge UNKNOWN', async () => { + const { controller } = setup({ + mockTxHistory: { + 'order-1': { + txMetaId: 'order-1', + originalTransactionId: 'order-1', + quote: minimalIntentQuoteResponse().quote, + account: '0xAccount1', + status: { + status: StatusTypes.PENDING, + srcChain: { chainId: 1, txHash: '0xhash' }, + }, + }, + }, + }); + + jest + .spyOn(intentApi.IntentApiImpl.prototype, 'getOrderStatus') + .mockImplementation( + jest.fn().mockResolvedValue({ + id: 'order-1', + status: 'SOME_NEW_STATUS' as any, // force UNKNOWN branch + txHash: undefined, + metadata: { txHashes: [] }, + }), + ); + + await controller._executePoll({ bridgeTxMetaId: 'order-1' }); + + expect(controller.state.txHistory['order-1'].status.status).toBe( + StatusTypes.UNKNOWN, + ); + + controller.stopAllPolling(); + }); + + it('intent polling: handles fetch failure when getIntentTransactionStatus returns undefined (e.g. non-Error rejection)', async () => { + const { controller } = setup({ + mockTxHistory: { + 'order-1': { + txMetaId: 'order-1', + originalTransactionId: 'order-1', + quote: minimalIntentQuoteResponse().quote, + account: '0xAccount1', + status: { + status: StatusTypes.PENDING, + srcChain: { chainId: 1, txHash: '0xhash' }, + }, + }, + }, + }); + + jest + .spyOn(intentApi.IntentApiImpl.prototype, 'getOrderStatus') + .mockImplementation(jest.fn().mockRejectedValue('non-Error rejection')); + + await controller._executePoll({ bridgeTxMetaId: 'order-1' }); + + expect(controller.state.txHistory['order-1'].status.status).toBe( + StatusTypes.PENDING, + ); + expect(controller.state.txHistory['order-1'].attempts).toBeUndefined(); + controller.stopAllPolling(); + }); + + it('bridge polling: returns early when history item is missing', async () => { + const { controller } = setup(); + const fetchBridgeTxStatusSpy = jest.spyOn( + bridgeStatusUtils, + 'fetchBridgeTxStatus', + ); + await controller._executePoll({ bridgeTxMetaId: 'missing-history' }); + + expect(fetchBridgeTxStatusSpy).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/bridge-status-controller/src/bridge-status-controller.intent.ts b/packages/bridge-status-controller/src/bridge-status-controller.intent.ts new file mode 100644 index 00000000000..1f9a42bb9fb --- /dev/null +++ b/packages/bridge-status-controller/src/bridge-status-controller.intent.ts @@ -0,0 +1,200 @@ +import { BridgeClientId, StatusTypes } from '@metamask/bridge-controller'; + +import type { + BridgeStatusControllerMessenger, + FetchFunction, +} from './types.js'; +import type { BridgeHistoryItem } from './types.js'; +import { getJwt } from './utils/authentication.js'; +import { + IntentApi, + IntentApiImpl, + IntentBridgeStatus, + translateIntentOrderToBridgeStatus, +} from './utils/intent-api.js'; +import { + getTransactionMetaById, + updateTransaction, +} from './utils/transaction.js'; +import { IntentStatusResponse, IntentOrderStatus } from './utils/validators.js'; + +type IntentStatuses = { + orderStatus: IntentOrderStatus; + bridgeStatus: IntentBridgeStatus | null; +}; + +export class IntentManager { + readonly #messenger: BridgeStatusControllerMessenger; + + readonly intentApi: IntentApi; + + readonly #intentStatusesByBridgeTxMetaId: Map = + new Map(); + + constructor({ + messenger, + customBridgeApiBaseUrl, + fetchFn, + }: { + messenger: BridgeStatusControllerMessenger; + customBridgeApiBaseUrl: string; + fetchFn: FetchFunction; + }) { + this.#messenger = messenger; + this.intentApi = new IntentApiImpl( + customBridgeApiBaseUrl, + fetchFn, + async () => await getJwt(messenger), + ); + } + + /** + * Set the intent statuses for a given bridge transaction. + * + * @param bridgeTxMetaId - The bridge transaction meta ID (key for storage). + * @param order - The intent order. + * @param srcChainId - The source chain ID. + * @param txHash - The transaction hash. + * @returns The intent statuses. + */ + + #setIntentStatuses( + bridgeTxMetaId: string, + order: IntentStatusResponse, + srcChainId: number, + txHash?: string, + ): IntentStatuses { + const bridgeStatus = translateIntentOrderToBridgeStatus( + order, + srcChainId, + txHash, + ); + const intentStatuses: IntentStatuses = { + orderStatus: order.status, + bridgeStatus, + }; + this.#intentStatusesByBridgeTxMetaId.set(bridgeTxMetaId, intentStatuses); + return intentStatuses; + } + + /** + * Get the status of an intent order. + * + * @param bridgeTxMetaId - The bridge transaction meta ID. + * @param protocol - The protocol of the intent. + * @param clientId - The client ID. + * @returns The intent order mapped status. + */ + + getIntentTransactionStatus = async ( + bridgeTxMetaId: string, + srcChainId: number, + protocol: string, + clientId: BridgeClientId, + txHash?: string, + ): Promise => { + try { + const orderStatus = await this.intentApi.getOrderStatus( + bridgeTxMetaId, + protocol, + srcChainId, + clientId, + ); + + return this.#setIntentStatuses( + bridgeTxMetaId, + orderStatus, + srcChainId, + txHash, + ); + } catch (error: unknown) { + if (error instanceof Error) { + throw new Error( + `[Intent polling] Failed to get intent order status from API: ${error.message}`, + ); + } + return undefined; + } + }; + + /** + * Sync the transaction status from the intent status. + * + * @param bridgeTxMetaId - The bridge transaction meta ID. + * @param historyItem - The history item. + */ + + syncTransactionFromIntentStatus = ( + bridgeTxMetaId: string, + historyItem: BridgeHistoryItem, + ): void => { + // Update the actual transaction in TransactionController to sync with intent status + // Use the original transaction ID (not the bridge history key) + const originalTxId = + historyItem.originalTransactionId ?? historyItem.txMetaId; + if (!originalTxId) { + return; + } + + const intentStatuses = + this.#intentStatusesByBridgeTxMetaId.get(bridgeTxMetaId); + if (!intentStatuses) { + return; + } + + try { + // Merge with existing TransactionMeta to avoid wiping required fields + const existingTxMeta = getTransactionMetaById( + this.#messenger, + originalTxId, + ); + if (!existingTxMeta) { + console.warn( + '[Intent polling] Skipping update, transaction not found', + { originalTxId, bridgeHistoryKey: bridgeTxMetaId }, + ); + return; + } + const { bridgeStatus, orderStatus } = intentStatuses; + const txHash = bridgeStatus?.txHash; + + const isComplete = bridgeStatus?.status.status === StatusTypes.COMPLETE; + const isFinalStatus = + bridgeStatus?.status.status === StatusTypes.COMPLETE || + bridgeStatus?.status.status === StatusTypes.FAILED; + const existingTxReceipt = ( + existingTxMeta as { txReceipt?: Record } + ).txReceipt; + const txReceiptUpdate = txHash + ? { + txReceipt: { + ...existingTxReceipt, + transactionHash: txHash as `0x${string}` | undefined, + status: isComplete ? '0x1' : '0x0', + }, + } + : {}; + + updateTransaction( + this.#messenger, + existingTxMeta, + { + status: bridgeStatus?.transactionStatus, + ...(txHash ? { hash: txHash } : {}), + ...txReceiptUpdate, + }, + `BridgeStatusController - Intent order status updated: ${orderStatus}`, + ); + + if (isFinalStatus) { + this.#intentStatusesByBridgeTxMetaId.delete(bridgeTxMetaId); + } + } catch (error) { + console.error('[Intent polling] Failed to update transaction status', { + originalTxId, + bridgeHistoryKey: bridgeTxMetaId, + error, + }); + } + }; +} diff --git a/packages/bridge-status-controller/src/bridge-status-controller.test.ts b/packages/bridge-status-controller/src/bridge-status-controller.test.ts new file mode 100644 index 00000000000..0799c778f90 --- /dev/null +++ b/packages/bridge-status-controller/src/bridge-status-controller.test.ts @@ -0,0 +1,7647 @@ +/* eslint-disable @typescript-eslint/explicit-function-return-type */ +/* eslint-disable jest/no-restricted-matchers */ +import { deriveStateFromMetadata } from '@metamask/base-controller'; +import type { + BridgeControllerMessenger, + QuoteResponseV1, + QuoteMetadata, + TxData, + TronTradeData, +} from '@metamask/bridge-controller'; +import { + ActionTypes, + ChainId, + FeeType, + StatusTypes, + getNativeAssetForChainId, + FeatureId, + getQuotesReceivedProperties, + UnifiedSwapBridgeEventName, + MetaMetricsSwapsEventSource, + mergeQuoteMetadata, + validateQuoteResponseV1, + toQuoteResponseV2, +} from '@metamask/bridge-controller'; +import type { TraceRequest } from '@metamask/controller-utils'; +import { Messenger, MOCK_ANY_NAMESPACE } from '@metamask/messenger'; +import type { + MessengerActions, + MessengerEvents, + MockAnyNamespace, +} from '@metamask/messenger'; +import type { NetworkState, Provider } from '@metamask/network-controller'; +import { + CHAIN_IDS, + GasFeeEstimateType, +} from '@metamask/transaction-controller'; +import { + TransactionType, + TransactionStatus, +} from '@metamask/transaction-controller'; +import type { + TransactionMeta, + TransactionParams, +} from '@metamask/transaction-controller'; +import type { CaipAssetType } from '@metamask/utils'; +import { numberToHex } from '@metamask/utils'; +import type { Hex } from '@metamask/utils'; + +import { flushPromises } from '../../../tests/helpers.js'; +import { BridgeStatusController } from './bridge-status-controller.js'; +import { + BRIDGE_STATUS_CONTROLLER_NAME, + DEFAULT_BRIDGE_STATUS_CONTROLLER_STATE, + DEFAULT_MAX_PENDING_HISTORY_ITEM_AGE_MS, + MAX_ATTEMPTS, + TraceName, +} from './constants.js'; +import { + QUOTE_STATUS_BACKFILL_WINDOW_MS, + QuoteStatusState, +} from './quote-status-manager/constants.js'; +import { BridgeClientId } from './types.js'; +import type { + BridgeId, + StartPollingForBridgeTxStatusArgsSerialized, + BridgeHistoryItem, + BridgeStatusControllerState, + BridgeStatusControllerMessenger, + StatusResponse, +} from './types.js'; +import * as bridgeStatusUtils from './utils/bridge-status.js'; +import * as historyUtils from './utils/history.js'; +import * as metricsUtils from './utils/metrics.js'; +import * as transactionUtils from './utils/transaction.js'; + +type AllBridgeStatusControllerActions = + MessengerActions; + +type AllBridgeStatusControllerEvents = + MessengerEvents; + +type AllBridgeControllerActions = MessengerActions; + +type AllBridgeControllerEvents = MessengerEvents; + +type RootMessenger = Messenger< + MockAnyNamespace, + AllBridgeStatusControllerActions | AllBridgeControllerActions, + AllBridgeStatusControllerEvents | AllBridgeControllerEvents +>; + +jest.mock('uuid', () => ({ + v4: () => 'test-uuid-1234', +})); + +const mockIsEthUsdt = jest.fn(); +jest.mock('@metamask/bridge-controller', () => ({ + ...jest.requireActual('@metamask/bridge-controller'), + isEthUsdt: () => mockIsEthUsdt(), +})); + +const EMPTY_INIT_STATE: BridgeStatusControllerState = { + ...DEFAULT_BRIDGE_STATUS_CONTROLLER_STATE, +}; + +const MockStatusResponse = { + getPending: ({ + srcTxHash = '0xsrcTxHash1', + srcChainId = 42161, + destChainId = 10, + } = {}) => ({ + status: 'PENDING' as StatusTypes, + srcChain: { + chainId: srcChainId, + txHash: srcTxHash === 'undefined' ? undefined : srcTxHash, + amount: '991250000000000', + token: { + address: '0x0000000000000000000000000000000000000000', + chainId: srcChainId, + symbol: 'ETH', + decimals: 18, + name: 'ETH', + coinKey: 'ETH', + logoURI: + 'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png', + priceUSD: '2518.47', + icon: 'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png', + }, + }, + destChain: { + chainId: destChainId, + token: {}, + }, + }), + getComplete: ({ + srcTxHash = '0xsrcTxHash1', + destTxHash = '0xdestTxHash1', + srcChainId = 42161, + destChainId = 10, + } = {}) => ({ + status: 'COMPLETE' as StatusTypes, + isExpectedToken: true, + bridge: 'across' as BridgeId, + srcChain: { + chainId: srcChainId, + txHash: srcTxHash, + amount: '991250000000000', + token: { + address: '0x0000000000000000000000000000000000000000', + assetId: `eip155:${srcChainId}/slip44:60` as CaipAssetType, + chainId: srcChainId, + symbol: 'ETH', + decimals: 18, + name: 'ETH', + coinKey: 'ETH', + logoURI: + 'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png', + priceUSD: '2478.7', + icon: 'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png', + }, + }, + destChain: { + chainId: destChainId, + txHash: destTxHash, + amount: '990654755978611', + token: { + address: '0x0000000000000000000000000000000000000000', + chainId: destChainId, + symbol: 'ETH', + decimals: 18, + name: 'ETH', + coinKey: 'ETH', + logoURI: + 'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png', + priceUSD: '2478.63', + icon: 'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png', + }, + }, + }), + getFailed: ({ + srcTxHash = '0xsrcTxHash1', + srcChainId = 42161, + destChainId = 10, + } = {}): StatusResponse => ({ + status: 'FAILED' as StatusTypes, + bridge: 'debridge' as BridgeId, + srcChain: { + chainId: srcChainId, + txHash: srcTxHash, + amount: '991250000000000', + token: { + address: '0x0000000000000000000000000000000000000000', + assetId: `eip155:${srcChainId}/slip44:60` as CaipAssetType, + chainId: srcChainId, + symbol: 'ETH', + decimals: 18, + name: 'ETH', + coinKey: 'ETH', + iconUrl: + 'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png', + icon: 'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png', + }, + }, + destChain: { + chainId: destChainId, + token: {}, + }, + }), +}; + +const getMockQuote = ({ srcChainId = 42161, destChainId = 10 } = {}) => ({ + requestId: '197c402f-cb96-4096-9f8c-54aed84ca776', + srcChainId, + srcTokenAmount: '991250000000000', + srcAsset: { + address: '0x0000000000000000000000000000000000000000', + assetId: `eip155:${srcChainId}/slip44:60` as CaipAssetType, + chainId: srcChainId, + symbol: 'ETH', + decimals: 18, + name: 'ETH', + coinKey: 'ETH', + logoURI: + 'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png', + priceUSD: '2478.7', + icon: 'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png', + }, + destChainId, + destTokenAmount: '990654755978612', + minDestTokenAmount: '941000000000000', + destAsset: { + address: '0x0000000000000000000000000000000000000000', + assetId: `eip155:${destChainId}/slip44:60` as CaipAssetType, + chainId: destChainId, + symbol: 'ETH', + decimals: 18, + name: 'ETH', + coinKey: 'ETH', + logoURI: + 'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png', + priceUSD: '2478.63', + icon: 'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png', + }, + feeData: { + metabridge: { + amount: '8750000000000', + asset: { + address: '0x0000000000000000000000000000000000000000', + assetId: `eip155:${srcChainId}/slip44:60` as CaipAssetType, + chainId: srcChainId, + symbol: 'ETH', + decimals: 18, + name: 'ETH', + coinKey: 'ETH', + logoURI: + 'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png', + priceUSD: '2478.7', + icon: 'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png', + }, + }, + }, + bridgeId: 'lifi', + bridges: ['across'], + steps: [ + { + action: 'bridge' as ActionTypes, + srcChainId, + destChainId, + protocol: { + name: 'across', + displayName: 'Across', + icon: 'https://raw.githubusercontent.com/lifinance/types/main/src/assets/icons/bridges/acrossv2.png', + }, + srcAsset: { + address: '0x0000000000000000000000000000000000000000', + assetId: `eip155:${srcChainId}/slip44:60` as CaipAssetType, + chainId: srcChainId, + symbol: 'ETH', + decimals: 18, + name: 'ETH', + coinKey: 'ETH', + logoURI: + 'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png', + priceUSD: '2478.7', + icon: 'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png', + }, + destAsset: { + address: '0x0000000000000000000000000000000000000000', + assetId: `eip155:${destChainId}/slip44:60` as CaipAssetType, + chainId: destChainId, + symbol: 'ETH', + decimals: 18, + name: 'ETH', + coinKey: 'ETH', + logoURI: + 'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png', + priceUSD: '2478.63', + icon: 'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png', + }, + srcAmount: '991250000000000', + destAmount: '990654755978612', + }, + ], +}); + +const getMockStartPollingForBridgeTxStatusArgs = ({ + txMetaId = 'bridgeTxMetaId1', + srcTxHash = '0xsrcTxHash1', + account = '0xaccount1', + srcChainId = 42161, + destChainId = 10, + isStxEnabled = false, +} = {}): StartPollingForBridgeTxStatusArgsSerialized => ({ + bridgeTxMeta: { + id: txMetaId, + hash: srcTxHash === 'undefined' ? undefined : srcTxHash, + } as TransactionMeta, + quoteResponse: { + ...{ + quote: getMockQuote({ srcChainId, destChainId }), + trade: { + chainId: srcChainId, + to: '0x23981fC34e69eeDFE2BD9a0a9fCb0719Fe09DbFC', + from: account as Hex, + value: '0x038d7ea4c68000', + data: '0x3ce33bff0000000000000000000000000000000000000000000000000000000000000080000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000038d7ea4c6800000000000000000000000000000000000000000000000000000000000000000c0000000000000000000000000000000000000000000000000000000000000000d6c6966694164617074657256320000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000001c0000000000000000000000000e397c4883ec89ed4fc9d258f00c689708b2799c9000000000000000000000000e397c4883ec89ed4fc9d258f00c689708b2799c9000000000000000000000000000000000000000000000000000000000000000a000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000038589602234000000000000000000000000000000000000000000000000000000000000000140000000000000000000000000000000000000000000000000000007f544a44c0000000000000000000000000056ca675c3633cc16bd6849e2b431d4e8de5e23bf000000000000000000000000000000000000000000000000000000000000006c5a39b10a4f4f0747826140d2c5fe6ef47965741f6f7a4734bf784bf3ae3f24520000000a000222266cc2dca0671d2a17ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffd00dfeeddeadbeef8932eb23bad9bddb5cf81426f78279a53c6c3b7100000000000000000000000000000000000000009ce3c510b3f58edc8d53ae708056e30926f62d0b42d5c9b61c391bb4e8a2c1917f8ed995169ffad0d79af2590303e83c57e15a9e0b248679849556c2e03a1c811b', + gasLimit: 282915, + }, + approval: undefined, + estimatedProcessingTimeInSeconds: 15, + }, + ...{ + sentAmount: { + amount: '1.234', + valueInCurrency: undefined, + usd: undefined, + }, + toTokenAmount: { + amount: '1.234', + valueInCurrency: undefined, + usd: undefined, + }, + minToTokenAmount: { + amount: '1.17', + valueInCurrency: undefined, + usd: undefined, + }, + totalNetworkFee: { + amount: '1.234', + valueInCurrency: undefined, + usd: undefined, + }, + gasFee: { + total: { amount: '1.234', valueInCurrency: undefined, usd: '2.5778' }, + }, + adjustedReturn: { valueInCurrency: undefined, usd: undefined }, + swapRate: '1.234', + cost: { valueInCurrency: undefined, usd: undefined }, + }, + }, + accountAddress: account, + startTime: 1729964825189, + slippagePercentage: 0, + initialDestAssetBalance: undefined, + targetContractAddress: '0x23981fC34e69eeDFE2BD9a0a9fCb0719Fe09DbFC', + isStxEnabled, + location: MetaMetricsSwapsEventSource.MainView, +}); + +const MockTxHistory = { + getInitNoSrcTxHash: ({ + txMetaId = 'bridgeTxMetaId1', + actionId = undefined, + account = '0xaccount1', + srcChainId = 42161, + destChainId = 10, + srcTxHash = '0xsrcTxHash1', + } = {}): Record => ({ + [txMetaId]: { + txMetaId, + actionId, + originalTransactionId: txMetaId, + quote: getMockQuote({ srcChainId, destChainId }), + startTime: 1729964825189, + estimatedProcessingTimeInSeconds: 15, + slippagePercentage: 0, + account, + targetContractAddress: '0x23981fC34e69eeDFE2BD9a0a9fCb0719Fe09DbFC', + initialDestAssetBalance: undefined, + pricingData: { amountSent: '1.234' }, + status: MockStatusResponse.getPending({ + srcTxHash, + srcChainId, + }), + hasApprovalTx: false, + approvalTxId: undefined, + location: undefined, + }, + }), + getInit: ({ + txMetaId = 'bridgeTxMetaId1', + actionId = undefined, + account = '0xaccount1', + srcChainId = 42161, + destChainId = 10, + } = {}): Record => ({ + [txMetaId]: { + txMetaId, + actionId, + originalTransactionId: txMetaId, + quote: getMockQuote({ srcChainId, destChainId }), + startTime: 1729964825189, + estimatedProcessingTimeInSeconds: 15, + slippagePercentage: 0, + account, + targetContractAddress: '0x23981fC34e69eeDFE2BD9a0a9fCb0719Fe09DbFC', + initialDestAssetBalance: undefined, + pricingData: { amountSent: '1.234' }, + status: MockStatusResponse.getPending({ + srcChainId, + }), + hasApprovalTx: false, + location: undefined, + }, + }), + getPending: ({ + txMetaId = 'bridgeTxMetaId1', + batchId = undefined, + actionId = undefined, + approvalTxId = undefined, + srcTxHash = '0xsrcTxHash1', + account = '0xaccount1', + srcChainId = 42161, + destChainId = 10, + featureId = undefined, + attempts = undefined as BridgeHistoryItem['attempts'], + startTime = 1729964825189, + } = {}): Record => ({ + [txMetaId]: { + txMetaId, + actionId, + originalTransactionId: txMetaId, + batchId, + quote: getMockQuote({ srcChainId, destChainId }), + startTime, + estimatedProcessingTimeInSeconds: 15, + slippagePercentage: 0, + account, + status: MockStatusResponse.getPending({ + srcTxHash, + srcChainId, + }), + targetContractAddress: '0x23981fC34e69eeDFE2BD9a0a9fCb0719Fe09DbFC', + initialDestAssetBalance: undefined, + pricingData: { + amountSent: '1.234', + amountSentInUsd: undefined, + quotedGasAmount: '1.234', + quotedGasInUsd: '2.5778', + quotedReturnInUsd: undefined, + }, + approvalTxId, + isStxEnabled: false, + hasApprovalTx: false, + completionTime: undefined, + attempts, + featureId, + location: MetaMetricsSwapsEventSource.MainView, + quoteId: undefined, + }, + }), + getUnknown: ({ + txMetaId = 'bridgeTxMetaId2', + actionId = undefined, + srcTxHash = '0xsrcTxHash2', + account = '0xaccount1', + srcChainId = 42161, + destChainId = 10, + } = {}): Record => ({ + [txMetaId]: { + txMetaId, + actionId, + originalTransactionId: txMetaId, + quote: getMockQuote({ srcChainId, destChainId }), + startTime: 1729964825189, + estimatedProcessingTimeInSeconds: 15, + slippagePercentage: 0, + account, + status: { + status: StatusTypes.UNKNOWN, + srcChain: { + chainId: srcChainId, + txHash: srcTxHash, + }, + }, + targetContractAddress: '0x23981fC34e69eeDFE2BD9a0a9fCb0719Fe09DbFC', + initialDestAssetBalance: undefined, + pricingData: { + amountSent: '1.234', + amountSentInUsd: undefined, + quotedGasInUsd: undefined, + quotedReturnInUsd: undefined, + }, + approvalTxId: undefined, + hasApprovalTx: false, + completionTime: undefined, + location: undefined, + quoteId: undefined, + }, + }), + getPendingSwap: ({ + txMetaId = 'swapTxMetaId1', + actionId = undefined, + srcTxHash = '0xsrcTxHash1', + account = '0xaccount1', + srcChainId = 42161, + destChainId = 42161, + featureId = undefined, + startTime = 1729964825189, + } = {}): Record => ({ + [txMetaId]: { + txMetaId, + actionId, + originalTransactionId: txMetaId, + quote: getMockQuote({ srcChainId, destChainId }), + startTime, + estimatedProcessingTimeInSeconds: 15, + slippagePercentage: 0, + account, + status: MockStatusResponse.getPending({ + srcTxHash, + srcChainId, + }), + targetContractAddress: '0x23981fC34e69eeDFE2BD9a0a9fCb0719Fe09DbFC', + initialDestAssetBalance: undefined, + pricingData: { + amountSent: '1.234', + amountSentInUsd: undefined, + quotedGasInUsd: undefined, + quotedReturnInUsd: undefined, + }, + approvalTxId: undefined, + isStxEnabled: false, + hasApprovalTx: false, + completionTime: undefined, + featureId, + location: undefined, + quoteId: undefined, + }, + }), + getComplete: ({ + txMetaId = 'bridgeTxMetaId1', + actionId = undefined, + batchId = undefined, + srcTxHash = '0xsrcTxHash1', + account = '0xaccount1', + srcChainId = 42161, + destChainId = 10, + } = {}): Record => ({ + [txMetaId]: { + txMetaId, + actionId, + originalTransactionId: txMetaId, + batchId, + featureId: undefined, + quote: getMockQuote({ srcChainId, destChainId }), + startTime: 1729964825189, + completionTime: 1736277625746, + estimatedProcessingTimeInSeconds: 15, + slippagePercentage: 0, + account, + status: MockStatusResponse.getComplete({ srcTxHash }), + targetContractAddress: '0x23981fC34e69eeDFE2BD9a0a9fCb0719Fe09DbFC', + initialDestAssetBalance: undefined, + pricingData: { + amountSent: '1.234', + amountSentInUsd: undefined, + quotedGasAmount: '1.234', + quotedGasInUsd: '2.5778', + quotedReturnInUsd: undefined, + }, + approvalTxId: undefined, + isStxEnabled: true, + hasApprovalTx: false, + attempts: undefined, + location: MetaMetricsSwapsEventSource.MainView, + quoteId: undefined, + }, + }), +}; + +const addTransactionBatchFn = jest.fn(); + +const createTraceCallback = (traceRequests: TraceRequest[]) => + jest + .fn() + .mockImplementation( + async (request: TraceRequest, callback?: () => unknown) => { + traceRequests.push(request); + return await callback?.(); + }, + ); + +const getSwapOperationCompletedTrace = ( + traceRequests: TraceRequest[], +): TraceRequest | undefined => + traceRequests.find(({ name }) => name === TraceName.SwapOperationCompleted); + +function getRootMessenger(): RootMessenger { + return new Messenger({ namespace: MOCK_ANY_NAMESPACE }); +} + +function getControllerMessenger( + rootMessenger: RootMessenger, +): BridgeStatusControllerMessenger { + const messenger = new Messenger({ + namespace: BRIDGE_STATUS_CONTROLLER_NAME, + parent: rootMessenger, + }) as unknown as BridgeStatusControllerMessenger; + rootMessenger.delegate({ + messenger, + actions: [ + 'AccountsController:getAccountByAddress', + 'NetworkController:findNetworkClientIdByChainId', + 'NetworkController:getState', + 'NetworkController:getNetworkClientById', + 'SnapController:handleRequest', + 'TransactionController:getState', + 'TransactionController:updateTransaction', + 'TransactionController:addTransaction', + 'TransactionController:estimateGasFee', + 'TransactionController:isAtomicBatchSupported', + 'BridgeController:trackUnifiedSwapBridgeEvent', + 'BridgeController:stopPollingForQuotes', + 'RemoteFeatureFlagController:getState', + 'AuthenticationController:getBearerToken', + 'KeyringController:signTypedMessage', + ], + events: ['TransactionController:transactionStatusUpdated'], + }); + return messenger; +} + +function registerDefaultActionHandlers( + rootMessenger: RootMessenger, + { + account = '0xaccount1', + srcChainId = 42161, + txHash = '0xsrcTxHash1', + txMetaId = 'bridgeTxMetaId1', + status = TransactionStatus.confirmed, + provider, + maxPendingHistoryItemAgeMs = DEFAULT_MAX_PENDING_HISTORY_ITEM_AGE_MS, + }: { + account?: string; + srcChainId?: number; + txHash?: string; + txMetaId?: string; + status?: TransactionStatus; + provider?: 'undefined' | Partial; + maxPendingHistoryItemAgeMs?: number; + } = {}, +) { + rootMessenger.registerActionHandler( + 'AccountsController:getAccountByAddress', + () => + ({ + address: account, + metadata: { keyring: { type: 'any' } }, + }) as never, + ); + + rootMessenger.registerActionHandler( + 'BridgeController:trackUnifiedSwapBridgeEvent', + jest.fn(), + ); + + rootMessenger.registerActionHandler( + 'NetworkController:findNetworkClientIdByChainId', + () => { + if (provider === 'undefined') { + throw new Error('Provider is undefined'); + } + return 'networkClientId'; + }, + ); + + rootMessenger.registerActionHandler( + 'NetworkController:getState', + () => + ({ + selectedNetworkClientId: 'networkClientId', + }) as NetworkState, + ); + + const mockProvider = { + request: jest.fn().mockResolvedValue('0xreceipt1'), + sendAsync: jest.fn(), + send: jest.fn(), + ...(provider && provider !== 'undefined' ? provider : {}), + }; + + rootMessenger.registerActionHandler( + 'NetworkController:getNetworkClientById', + () => + ({ + configuration: { + chainId: numberToHex(srcChainId), + } as never, + provider: mockProvider as never, + }) as never, + ); + + rootMessenger.registerActionHandler( + 'TransactionController:getState', + () => + ({ + transactions: [ + { + id: txMetaId === 'undefined' ? undefined : txMetaId, + hash: txHash, + status, + } as TransactionMeta, + ], + }) as never, + ); + + rootMessenger.registerActionHandler( + 'RemoteFeatureFlagController:getState', + () => ({ + remoteFeatureFlags: { + bridgeConfig: { + maxPendingHistoryItemAgeMs, + }, + }, + cacheTimestamp: 1776474747215, + }), + ); + + rootMessenger.registerActionHandler( + 'AuthenticationController:getBearerToken', + () => Promise.resolve('auth-token'), + ); +} + +type WithControllerCallback = (payload: { + controller: BridgeStatusController; + rootMessenger: RootMessenger; + messenger: BridgeStatusControllerMessenger; + startPollingForBridgeTxStatusSpy: jest.Mock; +}) => Promise | ReturnValue; + +type WithControllerOptions = { + options?: Partial[0]>; + mockMessengerCall?: jest.Mock; +}; + +async function withController( + ...args: + | [WithControllerCallback] + | [WithControllerOptions, WithControllerCallback] +): Promise { + const [{ options = {}, mockMessengerCall = undefined }, testFunction] = + args.length === 2 ? args : [{}, args[0]]; + const rootMessenger = getRootMessenger(); + const messenger = getControllerMessenger(rootMessenger); + if (mockMessengerCall) { + jest.spyOn(messenger, 'call').mockImplementation(mockMessengerCall); + } + const controller = new BridgeStatusController({ + messenger, + clientId: BridgeClientId.EXTENSION, + clientProduct: 'test-client-product', + fetchFn: jest.fn(), + addTransactionBatchFn, + ...options, + }); + const startPollingForBridgeTxStatusSpy = jest.fn(); + if (mockMessengerCall) { + jest + .spyOn(controller, 'startPolling') + .mockImplementation(startPollingForBridgeTxStatusSpy); + } + return await testFunction({ + controller, + rootMessenger, + messenger, + startPollingForBridgeTxStatusSpy, + }); +} + +const executePollingWithPendingStatus = async () => { + // Setup + jest.useFakeTimers(); + const fetchBridgeTxStatusSpy = jest + .spyOn(bridgeStatusUtils, 'fetchBridgeTxStatus') + .mockResolvedValueOnce({ + status: MockStatusResponse.getPending(), + validationFailures: [], + }); + + const rootMessenger = getRootMessenger(); + registerDefaultActionHandlers(rootMessenger); + const messenger = getControllerMessenger(rootMessenger); + const bridgeStatusController = new BridgeStatusController({ + messenger, + clientId: BridgeClientId.EXTENSION, + clientProduct: 'test-client-product', + fetchFn: jest.fn(), + addTransactionBatchFn: jest.fn(), + config: {}, + }); + const startPollingSpy = jest.spyOn(bridgeStatusController, 'startPolling'); + + // Execution + rootMessenger.call( + 'BridgeStatusController:startPollingForBridgeTxStatus', + getMockStartPollingForBridgeTxStatusArgs(), + ); + fetchBridgeTxStatusSpy.mockImplementationOnce(async () => { + return { + status: MockStatusResponse.getPending(), + validationFailures: [], + }; + }); + jest.advanceTimersByTime(10000); + await flushPromises(); + + return { + bridgeStatusController, + rootMessenger, + startPollingSpy, + fetchBridgeTxStatusSpy, + }; +}; + +// Define mocks at the top level +const mockSelectedAccount = { + id: 'test-account-id', + address: '0xaccount1', + type: 'eth', + metadata: { + keyring: { + type: ['any'], + }, + }, +}; + +describe('BridgeStatusController constructor', () => { + beforeEach(() => { + jest.clearAllMocks(); + jest.clearAllTimers(); + }); + + it('should setup correctly', async () => { + await withController(async ({ controller }) => { + expect(controller.state).toStrictEqual(EMPTY_INIT_STATE); + }); + }); + + it('rehydrates the tx history state', async () => { + await withController( + { options: { state: { txHistory: MockTxHistory.getPending() } } }, + async ({ controller }) => { + // Assertion + expect(controller.state.txHistory).toMatchSnapshot(); + controller.stopAllPolling(); + }, + ); + }); + + it('restarts polling for history items that are not complete', async () => { + // Setup + jest.useFakeTimers(); + const fetchBridgeTxStatusSpy = jest.spyOn( + bridgeStatusUtils, + 'fetchBridgeTxStatus', + ); + const provider = { + request: jest.fn().mockResolvedValueOnce('txReceipt1'), + }; + + jest.spyOn(historyUtils, 'isHistoryItemTooOld').mockReturnValue(false); + + await withController( + { + options: { + state: { + txHistory: { + ...MockTxHistory.getPending(), + ...MockTxHistory.getUnknown(), + ...MockTxHistory.getPendingSwap({ + srcTxHash: '0xswapSrcTxHash', + }), + ...MockTxHistory.getInitNoSrcTxHash({ + txMetaId: 'oldBridgeTxMetaId', + srcTxHash: '0xoldSrcTxHash', + }), + }, + }, + fetchFn: jest + .fn() + .mockResolvedValueOnce(MockStatusResponse.getPending()) + .mockResolvedValueOnce(MockStatusResponse.getComplete()) + .mockResolvedValueOnce(MockStatusResponse.getPending()), + }, + }, + async ({ controller, rootMessenger }) => { + registerDefaultActionHandlers(rootMessenger, { + provider, + }); + const initialStatuses = { + bridgeTxMetaId1: 'PENDING', + bridgeTxMetaId2: 'UNKNOWN', + swapTxMetaId1: 'PENDING', + oldBridgeTxMetaId: 'PENDING', + }; + expect( + Object.entries(controller.state.txHistory).reduce( + (acc, [key, value]) => ({ + ...acc, + [key]: value.status.status, + }), + {}, + ), + ).toStrictEqual(initialStatuses); + + expect( + Object.entries(controller.state.txHistory).reduce( + (acc, [key, value]) => ({ + ...acc, + [key]: value.status.srcChain.txHash, + }), + {}, + ), + ).toMatchInlineSnapshot(` + { + "bridgeTxMetaId1": "0xsrcTxHash1", + "bridgeTxMetaId2": "0xsrcTxHash2", + "oldBridgeTxMetaId": "0xoldSrcTxHash", + "swapTxMetaId1": "0xswapSrcTxHash", + } + `); + + jest.advanceTimersByTime(10000); + await flushPromises(); + + // Assertions + expect(fetchBridgeTxStatusSpy).toHaveBeenCalledTimes(3); + expect(provider.request.mock.calls.flat()).toMatchInlineSnapshot(`[]`); + + expect(controller.state.txHistory.bridgeTxMetaId1.status.status).toBe( + StatusTypes.PENDING, + ); + expect(controller.state.txHistory.bridgeTxMetaId2.status.status).toBe( + StatusTypes.COMPLETE, + ); + expect(controller.state.txHistory.swapTxMetaId1.status.status).toBe( + StatusTypes.PENDING, + ); + expect(controller.state.txHistory.oldBridgeTxMetaId.status.status).toBe( + StatusTypes.PENDING, + ); + controller.stopAllPolling(); + }, + ); + }); + + it.each([ + { + title: 'tx hash, provider returns receipt', + txHash: '0xsrcTxHash2', + providerAction: async () => await Promise.resolve('txReceipt1'), + expectedHistoryTxMetaId: 'unknownTxMetaId1', + expectedStatusFetchCount: 1, + }, + { + title: 'tx hash, provider returns receipt, status is complete', + txHash: '0xsrcTxHash2', + providerAction: async () => await Promise.resolve('txReceipt1'), + expectedHistoryTxMetaId: 'unknownTxMetaId1', + expectedStatusFetchCount: 1, + statusResponse: MockStatusResponse.getComplete, + }, + { + title: 'tx hash, provider returns no receipt', + txHash: '0xsrcTxHash2', + providerAction: async () => await Promise.resolve(), + expectedStatusFetchCount: 1, + }, + { + title: 'tx hash, provider throws error', + txHash: '0xsrcTxHash2', + providerAction: async () => + await Promise.reject(new Error('Provider error')), + expectedStatusFetchCount: 1, + }, + { + title: 'tx hash, no provider', + txHash: '0xsrcTxHash2', + provider: 'undefined' as const, + expectedStatusFetchCount: 1, + expectedHistoryTxMetaId: 'unknownTxMetaId1', + }, + { + title: 'no tx hash, has txMeta', + txMeta: { + id: 'txMetaId2', + hash: '0xsrcTxHash3', + }, + expectedStatusFetchCount: 1, + }, + { + title: 'no tx hash, no txMeta', + txMeta: { + id: 'undefined', + }, + }, + { + title: 'solana srcChainId, no tx hash, no txMeta', + txMeta: { + id: 'solanaTxMetaId1', + }, + srcChainId: ChainId.SOLANA, + expectedStatusFetchCount: 1, + txHash: 'solanaTxHash', + }, + { + title: 'no txHash, no txMeta, provider returns no receipt', + txMeta: { + id: 'undefined', + }, + providerAction: async () => await Promise.resolve(), + }, + { + title: 'no txHash, no txMeta, provider returns receipt', + txMeta: { + id: 'undefined', + }, + providerAction: async () => await Promise.resolve('txReceipt1'), + }, + { + title: 'no txHash, no txMeta, provider throws error', + txMeta: { + id: 'undefined', + }, + providerAction: async () => + await Promise.reject(new Error('Provider error')), + }, + ])( + 'when history has $title and is older than 2 days', + async ({ + txHash = 'undefined', + providerAction = () => Promise.resolve(), + txMeta, + expectedHistoryTxMetaId, + srcChainId, + provider: providerParam, + expectedStatusFetchCount = 0, + statusResponse = MockStatusResponse.getPending, + }) => { + // Setup + jest.useFakeTimers(); + const fetchBridgeTxStatusSpy = jest.spyOn( + bridgeStatusUtils, + 'fetchBridgeTxStatus', + ); + const provider = { + request: jest + .fn() + .mockImplementationOnce(async () => await providerAction()), + }; + const consoleWarnSpy = jest + .spyOn(console, 'warn') + .mockImplementationOnce(() => jest.fn()); + + const [historyKey, txHistoryItem] = Object.entries( + MockTxHistory.getPending({ + txMetaId: txMeta?.id ?? 'unknownTxMetaId1', + srcTxHash: txHash, + srcChainId, + }), + )[0]; + + const startTime = + Date.now() - DEFAULT_MAX_PENDING_HISTORY_ITEM_AGE_MS - 1000; + + await withController( + { + options: { + state: { + txHistory: { + [historyKey]: { + ...txHistoryItem, + attempts: undefined, + startTime, + }, + }, + }, + fetchFn: jest.fn().mockResolvedValueOnce( + statusResponse({ + srcTxHash: txMeta?.hash ?? txHash, + }), + ), + }, + }, + async ({ controller, rootMessenger, messenger }) => { + const stopPollingSpy = jest.spyOn( + controller, + 'stopPollingByPollingToken', + ); + const messengerCallSpy = jest.spyOn(messenger, 'call'); + + registerDefaultActionHandlers(rootMessenger, { + provider: providerParam ?? provider, + txMetaId: txMeta?.id, + txHash: txMeta?.hash, + }); + controller.startPolling({ bridgeTxMetaId: historyKey }); + + expect( + controller.state.txHistory[historyKey].status.status, + ).toStrictEqual(StatusTypes.PENDING); + + jest.advanceTimersByTime(11000); + await flushPromises(); + + // Assertions + expect(fetchBridgeTxStatusSpy.mock.calls).toHaveLength( + expectedStatusFetchCount, + ); + expect(controller.state.txHistory[historyKey]?.txMetaId).toBe( + expectedHistoryTxMetaId, + ); + expect( + consoleWarnSpy.mock.calls.map((call) => JSON.stringify(call)), + ).toStrictEqual([]); + + expect(controller.state.txHistory[historyKey]?.status.status).toBe( + expectedHistoryTxMetaId ? statusResponse()?.status : undefined, + ); + expect( + controller.state.txHistory[historyKey]?.attempts?.counter, + ).toBeUndefined(); + + // Should always stop polling for the tx after 2 days + expect(stopPollingSpy.mock.calls).toStrictEqual( + expectedHistoryTxMetaId && + statusResponse()?.status !== StatusTypes.COMPLETE + ? [] + : [['test-uuid-1234']], + ); + + expect( + controller.state.txHistory[historyKey]?.status.srcChain.txHash, + ).toBe(expectedHistoryTxMetaId ? txHash : undefined); + + expect(provider.request.mock.calls.flat()).toMatchSnapshot( + 'provider tx receipt calls', + ); + + expect( + messengerCallSpy.mock.calls.find( + (call) => + call[1] === UnifiedSwapBridgeEventName.PollingStatusUpdated, + ), + ).toStrictEqual( + expectedHistoryTxMetaId + ? undefined + : [ + 'BridgeController:trackUnifiedSwapBridgeEvent', + 'Unified SwapBridge Polling Status Updated', + { + account_hardware_type: null, + action_type: 'swapbridge-v1', + actual_time_minutes: 0, + allowance_reset_transaction: undefined, + approval_transaction: undefined, + chain_id_destination: 'eip155:10', + // eslint-disable-next-line jest/no-conditional-expect + chain_id_source: expect.any(String), + custom_slippage: false, + destination_transaction: 'PENDING', + feature_id: 'unified_swap_bridge', + gas_included: false, + gas_included_7702: false, + is_hardware_wallet: false, + location: 'Main View', + polling_status: 'invalid_transaction_hash', + price_impact: 0, + provider: 'lifi_across', + quote_vs_execution_ratio: 0, + quoted_time_minutes: 0.25, + quoted_vs_used_gas_ratio: 0, + retry_attempts: 0, + security_warnings: [], + slippage_limit: 0, + // eslint-disable-next-line jest/no-conditional-expect + source_transaction: expect.any(String), + stx_enabled: false, + swap_type: 'crosschain', + token_address_destination: 'eip155:10/slip44:60', + // eslint-disable-next-line jest/no-conditional-expect + token_address_source: expect.any(String), + token_security_type_destination: null, + token_symbol_destination: 'ETH', + token_symbol_source: 'ETH', + usd_actual_gas: 0, + usd_actual_return: 0, + usd_amount_source: 0, + usd_quoted_gas: 2.5778, + usd_quoted_return: 0, + }, + ], + ); + + jest.advanceTimersByTime(11000); + await flushPromises(); + + // Assertions + expect(controller.state.txHistory[historyKey]?.status.status).toBe( + expectedHistoryTxMetaId ? statusResponse()?.status : undefined, + ); + }, + ); + }, + ); +}); + +describe('BridgeStatusController', () => { + beforeEach(() => { + jest.clearAllMocks(); + jest.clearAllTimers(); + jest.spyOn(historyUtils, 'isHistoryItemTooOld').mockReturnValue(false); + }); + + describe('startPolling - error handling', () => { + const consoleFn = console.warn; + let consoleFnSpy: jest.SpyInstance; + + beforeEach(() => { + jest.clearAllMocks(); + jest.clearAllTimers(); + // eslint-disable-next-line no-empty-function + consoleFnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {}); + }); + + afterEach(() => { + console.warn = consoleFn; + }); + + it('should handle network errors during fetchBridgeTxStatus', async () => { + // Setup + jest.useFakeTimers(); + const fetchBridgeTxStatusSpy = jest.spyOn( + bridgeStatusUtils, + 'fetchBridgeTxStatus', + ); + + await withController( + { + options: { + fetchFn: jest + .fn() + .mockRejectedValueOnce(new Error('Network error')), + }, + }, + async ({ controller, rootMessenger }) => { + registerDefaultActionHandlers(rootMessenger); + + // Execution + rootMessenger.call( + 'BridgeStatusController:startPollingForBridgeTxStatus', + getMockStartPollingForBridgeTxStatusArgs(), + ); + + // Trigger polling + jest.advanceTimersByTime(10000); + await flushPromises(); + + // Assertions + expect(fetchBridgeTxStatusSpy).toHaveBeenCalledTimes(1); + // Transaction should still be in history but status should remain unchanged + expect(controller.state.txHistory).toHaveProperty('bridgeTxMetaId1'); + expect(controller.state.txHistory.bridgeTxMetaId1.status.status).toBe( + 'PENDING', + ); + + // Should increment attempts counter + expect( + controller.state.txHistory.bridgeTxMetaId1.attempts?.counter, + ).toBe(1); + expect( + controller.state.txHistory.bridgeTxMetaId1.attempts + ?.lastAttemptTime, + ).toBeDefined(); + + controller.stopAllPolling(); + expect(consoleFnSpy.mock.calls).toMatchInlineSnapshot(` + [ + [ + "Failed to fetch bridge tx status", + [Error: Network error], + ], + ] + `); + }, + ); + }); + + it('should stop polling after max attempts are reached', async () => { + // Setup + jest.useFakeTimers(); + const fetchBridgeTxStatusSpy = jest.spyOn( + bridgeStatusUtils, + 'fetchBridgeTxStatus', + ); + + await withController( + { + options: { + fetchFn: jest.fn().mockRejectedValue(new Error('Persistent error')), + }, + }, + async ({ controller, rootMessenger }) => { + registerDefaultActionHandlers(rootMessenger); + + // Execution + rootMessenger.call( + 'BridgeStatusController:startPollingForBridgeTxStatus', + getMockStartPollingForBridgeTxStatusArgs(), + ); + + // Trigger polling with exponential backoff timing + for (let i = 0; i < MAX_ATTEMPTS * 2; i++) { + jest.advanceTimersByTime(10_000 * 2 ** i); + await flushPromises(); + } + + // Assertions + expect(fetchBridgeTxStatusSpy).toHaveBeenCalledTimes(MAX_ATTEMPTS); + expect( + controller.state.txHistory.bridgeTxMetaId1?.attempts?.counter, + ).toBe(MAX_ATTEMPTS); + + // Verify polling stops after max attempts - even with a long wait, no more calls + const callCountBeforeExtraTime = + fetchBridgeTxStatusSpy.mock.calls.length; + jest.advanceTimersByTime(1_000_000_000); + await flushPromises(); + expect(fetchBridgeTxStatusSpy).toHaveBeenCalledTimes( + callCountBeforeExtraTime, + ); + controller.stopAllPolling(); + expect(consoleFnSpy.mock.calls).toMatchInlineSnapshot(` + [ + [ + "Failed to fetch bridge tx status", + [Error: Persistent error], + ], + [ + "Failed to fetch bridge tx status", + [Error: Persistent error], + ], + [ + "Failed to fetch bridge tx status", + [Error: Persistent error], + ], + [ + "Failed to fetch bridge tx status", + [Error: Persistent error], + ], + [ + "Failed to fetch bridge tx status", + [Error: Persistent error], + ], + [ + "Failed to fetch bridge tx status", + [Error: Persistent error], + ], + [ + "Failed to fetch bridge tx status", + [Error: Persistent error], + ], + ] + `); + }, + ); + }); + }); + + describe('startPollingForBridgeTxStatus', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('throws error when bridgeTxMeta.id is not provided', async () => { + await withController(async ({ controller, rootMessenger }) => { + const argsWithoutId = getMockStartPollingForBridgeTxStatusArgs(); + // Remove the id from bridgeTxMeta + argsWithoutId.bridgeTxMeta = {} as never; + + expect(() => { + rootMessenger.call( + 'BridgeStatusController:startPollingForBridgeTxStatus', + argsWithoutId, + ); + }).toThrow( + 'Cannot start polling: bridgeTxMeta.id is required for polling', + ); + + controller.stopAllPolling(); + }); + }); + + it('throws error when bridgeTxMeta is undefined', async () => { + await withController(async ({ controller, rootMessenger }) => { + const argsWithoutMeta = getMockStartPollingForBridgeTxStatusArgs(); + // Remove bridgeTxMeta entirely + argsWithoutMeta.bridgeTxMeta = undefined; + + expect(() => { + rootMessenger.call( + 'BridgeStatusController:startPollingForBridgeTxStatus', + argsWithoutMeta, + ); + }).toThrow( + 'Cannot start polling: bridgeTxMeta.id is required for polling', + ); + + controller.stopAllPolling(); + }); + }); + + it('sets the inital tx history state', async () => { + await withController( + { + options: { + fetchFn: jest + .fn() + .mockResolvedValueOnce(MockStatusResponse.getPending()), + }, + }, + async ({ controller, rootMessenger }) => { + // Execution + rootMessenger.call( + 'BridgeStatusController:startPollingForBridgeTxStatus', + getMockStartPollingForBridgeTxStatusArgs(), + ); + + // Assertion + expect(controller.state.txHistory).toMatchSnapshot(); + controller.stopAllPolling(); + }, + ); + }); + + it('starts polling and updates the tx history when the status response is received', async () => { + const { + bridgeStatusController, + startPollingSpy, + fetchBridgeTxStatusSpy, + } = await executePollingWithPendingStatus(); + + // Assertions + expect(startPollingSpy).toHaveBeenCalledTimes(1); + expect(fetchBridgeTxStatusSpy).toHaveBeenCalled(); + expect(bridgeStatusController.state.txHistory).toStrictEqual( + MockTxHistory.getPending(), + ); + bridgeStatusController.stopAllPolling(); + }); + + describe('quote status manager integration', () => { + it('fetches status via the quote status manager instead of the bridge API when the history item has a quoteId', async () => { + jest.useFakeTimers(); + const fetchBridgeTxStatusSpy = jest.spyOn( + bridgeStatusUtils, + 'fetchBridgeTxStatus', + ); + const fetchBridgeQuoteStatusSpy = jest + .spyOn(bridgeStatusUtils, 'fetchBridgeQuoteStatus') + .mockResolvedValueOnce({ + status: MockStatusResponse.getPending(), + validationFailures: [], + }); + + await withController( + { + options: { + isQuoteStatusManagerEnabled: () => true, + state: { + txHistory: { + bridgeTxMetaId1: { + ...MockTxHistory.getPending().bridgeTxMetaId1, + quoteId: 'quote-1', + }, + }, + }, + }, + }, + async ({ controller, rootMessenger }) => { + registerDefaultActionHandlers(rootMessenger); + controller.startPolling({ bridgeTxMetaId: 'bridgeTxMetaId1' }); + + jest.advanceTimersByTime(10000); + await flushPromises(); + + expect(fetchBridgeQuoteStatusSpy).toHaveBeenCalledWith( + expect.anything(), + 'quote-1', + ); + expect(fetchBridgeTxStatusSpy).not.toHaveBeenCalled(); + expect( + controller.state.txHistory.bridgeTxMetaId1.status.status, + ).toBe(StatusTypes.PENDING); + + controller.stopAllPolling(); + jest.restoreAllMocks(); + }, + ); + }); + + it('falls back to the bridge API when the quote status manager has no status yet', async () => { + jest.useFakeTimers(); + const fetchBridgeTxStatusSpy = jest + .spyOn(bridgeStatusUtils, 'fetchBridgeTxStatus') + .mockResolvedValueOnce({ + status: MockStatusResponse.getPending(), + validationFailures: [], + }); + const fetchBridgeQuoteStatusSpy = jest + .spyOn(bridgeStatusUtils, 'fetchBridgeQuoteStatus') + .mockResolvedValueOnce(null); + + await withController( + { + options: { + isQuoteStatusManagerEnabled: () => true, + state: { + txHistory: { + bridgeTxMetaId1: { + ...MockTxHistory.getPending().bridgeTxMetaId1, + quoteId: 'quote-1', + }, + }, + }, + }, + }, + async ({ controller, rootMessenger }) => { + registerDefaultActionHandlers(rootMessenger); + controller.startPolling({ bridgeTxMetaId: 'bridgeTxMetaId1' }); + + jest.advanceTimersByTime(10000); + await flushPromises(); + + expect(fetchBridgeQuoteStatusSpy).toHaveBeenCalledWith( + expect.anything(), + 'quote-1', + ); + expect(fetchBridgeTxStatusSpy).toHaveBeenCalledTimes(1); + expect( + controller.state.txHistory.bridgeTxMetaId1.status.status, + ).toBe(StatusTypes.PENDING); + + controller.stopAllPolling(); + jest.restoreAllMocks(); + }, + ); + }); + + it('does not call the quote status manager when the history item has no quoteId', async () => { + jest.useFakeTimers(); + const fetchBridgeTxStatusSpy = jest + .spyOn(bridgeStatusUtils, 'fetchBridgeTxStatus') + .mockResolvedValueOnce({ + status: MockStatusResponse.getPending(), + validationFailures: [], + }); + const fetchBridgeQuoteStatusSpy = jest.spyOn( + bridgeStatusUtils, + 'fetchBridgeQuoteStatus', + ); + + await withController( + { + options: { + isQuoteStatusManagerEnabled: () => true, + state: { + txHistory: MockTxHistory.getPending(), + }, + }, + }, + async ({ controller, rootMessenger }) => { + registerDefaultActionHandlers(rootMessenger); + controller.startPolling({ bridgeTxMetaId: 'bridgeTxMetaId1' }); + + jest.advanceTimersByTime(10000); + await flushPromises(); + + expect(fetchBridgeQuoteStatusSpy).not.toHaveBeenCalled(); + expect(fetchBridgeTxStatusSpy).toHaveBeenCalledTimes(1); + + controller.stopAllPolling(); + jest.restoreAllMocks(); + }, + ); + }); + }); + + it('stops polling when the status response is complete', async () => { + // Setup + jest.useFakeTimers(); + jest.spyOn(Date, 'now').mockImplementation(() => { + return MockTxHistory.getComplete().bridgeTxMetaId1.completionTime ?? 10; + }); + + await withController(async ({ controller, messenger, rootMessenger }) => { + registerDefaultActionHandlers(rootMessenger); + const messengerCallSpy = jest.spyOn(messenger, 'call'); + const messengerPublishSpy = jest.spyOn(messenger, 'publish'); + const fetchBridgeTxStatusSpy = jest.spyOn( + bridgeStatusUtils, + 'fetchBridgeTxStatus', + ); + const stopPollingByNetworkClientIdSpy = jest.spyOn( + controller, + 'stopPollingByPollingToken', + ); + + // Execution + rootMessenger.call( + 'BridgeStatusController:startPollingForBridgeTxStatus', + getMockStartPollingForBridgeTxStatusArgs({ isStxEnabled: true }), + ); + fetchBridgeTxStatusSpy.mockImplementationOnce(async () => { + return { + status: MockStatusResponse.getComplete(), + validationFailures: [], + }; + }); + jest.advanceTimersByTime(10000); + await flushPromises(); + + // Assertions + expect(fetchBridgeTxStatusSpy).toHaveBeenCalledTimes(1); + expect(stopPollingByNetworkClientIdSpy).toHaveBeenCalledTimes(1); + expect(controller.state.txHistory).toStrictEqual( + MockTxHistory.getComplete(), + ); + const completedEventCall = messengerCallSpy.mock.calls.find( + ([, eventName]) => eventName === UnifiedSwapBridgeEventName.Completed, + ); + expect(completedEventCall?.[2]).toStrictEqual( + expect.objectContaining({ + transaction_internal_id: 'bridgeTxMetaId1', + }), + ); + + expect(messengerCallSpy.mock.calls).toMatchSnapshot(); + expect(messengerPublishSpy.mock.calls.at(-1)).toMatchSnapshot(); + // Cleanup + jest.restoreAllMocks(); + }); + }); + + it('does not poll if the srcTxHash is not available', async () => { + // Setup + jest.useFakeTimers(); + + await withController(async ({ controller, rootMessenger }) => { + // Register handlers - but TransactionController:getState returns hash: undefined + registerDefaultActionHandlers(rootMessenger); + + rootMessenger.unregisterActionHandler('TransactionController:getState'); + rootMessenger.registerActionHandler( + 'TransactionController:getState', + () => + ({ + transactions: [ + { id: 'bridgeTxMetaId1', hash: undefined } as TransactionMeta, + ], + }) as never, + ); + + const fetchBridgeTxStatusSpy = jest.spyOn( + bridgeStatusUtils, + 'fetchBridgeTxStatus', + ); + + // Start polling with args that have no srcTxHash + const startPollingArgs = getMockStartPollingForBridgeTxStatusArgs({ + srcTxHash: 'undefined', + }); + rootMessenger.call( + 'BridgeStatusController:startPollingForBridgeTxStatus', + startPollingArgs, + ); + + // Advance timer to trigger polling + jest.advanceTimersByTime(10000); + await flushPromises(); + + // Assertions + expect(fetchBridgeTxStatusSpy).not.toHaveBeenCalled(); + expect(controller.state.txHistory).toHaveProperty('bridgeTxMetaId1'); + expect( + controller.state.txHistory.bridgeTxMetaId1.status.srcChain.txHash, + ).toBeFalsy(); + + // Cleanup + jest.restoreAllMocks(); + }); + }); + + it('emits bridgeTransactionComplete event when the status response is complete', async () => { + // Setup + jest.useFakeTimers(); + jest.spyOn(Date, 'now').mockImplementation(() => { + return MockTxHistory.getComplete().bridgeTxMetaId1.completionTime ?? 10; + }); + + await withController(async ({ rootMessenger }) => { + registerDefaultActionHandlers(rootMessenger); + const fetchBridgeTxStatusSpy = jest + .spyOn(bridgeStatusUtils, 'fetchBridgeTxStatus') + .mockImplementationOnce(async () => { + return { + status: MockStatusResponse.getComplete(), + validationFailures: [], + }; + }); + + // Execution + rootMessenger.call( + 'BridgeStatusController:startPollingForBridgeTxStatus', + getMockStartPollingForBridgeTxStatusArgs(), + ); + jest.advanceTimersByTime(10000); + await flushPromises(); + + // Assertions + expect(fetchBridgeTxStatusSpy).toHaveBeenCalledTimes(1); + + // Cleanup + jest.restoreAllMocks(); + }); + }); + + it('emits bridgeTransactionFailed event when the status response is failed', async () => { + // Setup + jest.useFakeTimers(); + jest.spyOn(Date, 'now').mockImplementation(() => { + return MockTxHistory.getComplete().bridgeTxMetaId1.completionTime ?? 10; + }); + + await withController(async ({ rootMessenger, messenger }) => { + registerDefaultActionHandlers(rootMessenger); + const messengerCallSpy = jest.spyOn(messenger, 'call'); + const messengerPublishSpy = jest.spyOn(messenger, 'publish'); + const fetchBridgeTxStatusSpy = jest + .spyOn(bridgeStatusUtils, 'fetchBridgeTxStatus') + .mockImplementationOnce(async () => { + return { + status: MockStatusResponse.getFailed(), + validationFailures: [], + }; + }); + + // Execution + rootMessenger.call( + 'BridgeStatusController:startPollingForBridgeTxStatus', + getMockStartPollingForBridgeTxStatusArgs(), + ); + jest.advanceTimersByTime(10000); + await flushPromises(); + + // Assertions + expect(fetchBridgeTxStatusSpy).toHaveBeenCalledTimes(1); + expect(messengerCallSpy.mock.calls).toMatchSnapshot(); + expect(messengerPublishSpy).not.toHaveBeenCalledWith( + 'BridgeStatusController:destinationTransactionCompleted', + ); + + // Cleanup + jest.restoreAllMocks(); + }); + }); + + describe('swap operation completion tracing', () => { + it.each([ + { + name: 'success', + result: 'success', + response: (): StatusResponse => MockStatusResponse.getComplete(), + destinationTxHash: '0xdestTxHash1', + }, + { + name: 'failure', + result: 'error', + response: (): StatusResponse => MockStatusResponse.getFailed(), + destinationTxHash: undefined, + }, + ])('records $name', async (scenario) => { + jest.useFakeTimers(); + const startTime = 1729964825189; + const completionTime = 1736277625746; + jest.spyOn(Date, 'now').mockImplementation(() => completionTime); + const traceRequests: TraceRequest[] = []; + + await withController( + { + options: { + traceFn: createTraceCallback(traceRequests), + }, + }, + async ({ rootMessenger }) => { + registerDefaultActionHandlers(rootMessenger); + jest + .spyOn(bridgeStatusUtils, 'fetchBridgeTxStatus') + .mockResolvedValueOnce({ + status: scenario.response(), + validationFailures: [], + }); + + rootMessenger.call( + 'BridgeStatusController:startPollingForBridgeTxStatus', + getMockStartPollingForBridgeTxStatusArgs(), + ); + jest.advanceTimersByTime(10000); + await flushPromises(); + + const trace = getSwapOperationCompletedTrace(traceRequests); + expect(trace).toStrictEqual( + expect.objectContaining({ + name: TraceName.SwapOperationCompleted, + startTime, + data: expect.objectContaining({ + srcChainId: 'eip155:42161', + destChainId: 'eip155:10', + provider: 'lifi_across', + swap_type: 'crosschain', + terminal_stage: 'destination', + quote_id: '197c402f-cb96-4096-9f8c-54aed84ca776', + transaction_id: 'bridgeTxMetaId1', + src_tx_hash: '0xsrcTxHash1', + result: scenario.result, + }), + }), + ); + expect(trace?.data?.dest_tx_hash ?? null).toBe( + scenario.destinationTxHash ?? null, + ); + expect( + traceRequests.filter( + ({ name }) => name === TraceName.SwapOperationCompleted, + ), + ).toHaveLength(1); + }, + ); + }); + + it.each([ + { + name: 'same-chain success', + history: () => MockTxHistory.getPendingSwap(), + transactionId: 'swapTxMetaId1', + transactionType: TransactionType.swap, + transactionStatus: TransactionStatus.confirmed, + result: 'success', + swapType: 'single_chain', + }, + { + name: 'cross-chain source failure', + history: () => MockTxHistory.getPending(), + transactionId: 'bridgeTxMetaId1', + transactionType: TransactionType.bridge, + transactionStatus: TransactionStatus.failed, + result: 'error', + swapType: 'crosschain', + }, + ])( + 'records $name', + async ({ + history, + transactionId, + transactionType, + transactionStatus, + result, + swapType, + }) => { + const traceRequests: TraceRequest[] = []; + + await withController( + { + options: { + state: { + txHistory: history(), + }, + traceFn: createTraceCallback(traceRequests), + }, + }, + async ({ rootMessenger }) => { + registerDefaultActionHandlers(rootMessenger); + rootMessenger.publish( + 'TransactionController:transactionStatusUpdated', + { + transactionMeta: { + chainId: CHAIN_IDS.ARBITRUM, + hash: '0xsourceTxHash', + networkClientId: 'eth-id', + time: Date.now(), + txParams: {} as unknown as TransactionParams, + type: transactionType, + status: transactionStatus, + id: transactionId, + } as TransactionMeta, + }, + ); + await flushPromises(); + + expect( + getSwapOperationCompletedTrace(traceRequests), + ).toStrictEqual( + expect.objectContaining({ + name: TraceName.SwapOperationCompleted, + data: expect.objectContaining({ + result, + swap_type: swapType, + terminal_stage: 'source', + transaction_id: transactionId, + }), + }), + ); + }, + ); + }, + ); + }); + + it.each([ + { + status: TransactionStatus.confirmed, + }, + { status: TransactionStatus.failed }, + { status: TransactionStatus.dropped }, + { status: TransactionStatus.rejected }, + { status: TransactionStatus.signed, shouldSetSrcTxHash: false }, + ])( + 'updates the srcTxHash when one is available, with status %s', + async ({ status, shouldSetSrcTxHash = true }) => { + // Setup + jest.useFakeTimers(); + let getStateCallCount = 0; + + await withController( + { + options: { + fetchFn: jest + .fn() + .mockResolvedValueOnce(MockStatusResponse.getPending()), + traceFn: jest.fn(), + }, + }, + async ({ controller, rootMessenger }) => { + registerDefaultActionHandlers(rootMessenger); + + rootMessenger.unregisterActionHandler( + 'TransactionController:getState', + ); + rootMessenger.registerActionHandler( + 'TransactionController:getState', + () => { + getStateCallCount += 1; + return { + transactions: [ + { + id: 'bridgeTxMetaId1', + hash: getStateCallCount === 0 ? undefined : '0xnewTxHash', + status, + } as TransactionMeta, + ], + } as never; + }, + ); + + // Start polling with no srcTxHash + const startPollingArgs = getMockStartPollingForBridgeTxStatusArgs({ + srcTxHash: 'undefined', + }); + rootMessenger.call( + 'BridgeStatusController:startPollingForBridgeTxStatus', + startPollingArgs, + ); + + // Verify initial state has no srcTxHash + expect( + controller.state.txHistory.bridgeTxMetaId1.status.srcChain.txHash, + ).toBeUndefined(); + + // Advance timer to trigger polling with new hash + jest.advanceTimersByTime(10000); + await flushPromises(); + + // Verify the srcTxHash was updated + expect( + controller.state.txHistory.bridgeTxMetaId1.status.srcChain.txHash, + ).toBe(shouldSetSrcTxHash ? '0xsrcTxHash1' : undefined); + + // Cleanup + controller.stopAllPolling(); + jest.restoreAllMocks(); + }, + ); + }, + ); + }); + + describe('resetState', () => { + it('resets the state', async () => { + const { bridgeStatusController, rootMessenger } = + await executePollingWithPendingStatus(); + + expect(bridgeStatusController.state.txHistory).toStrictEqual( + MockTxHistory.getPending(), + ); + rootMessenger.call('BridgeStatusController:resetState'); + expect(bridgeStatusController.state.txHistory).toStrictEqual( + EMPTY_INIT_STATE.txHistory, + ); + }); + }); + + describe('getBridgeHistoryItemByTxMetaId', () => { + it('returns the bridge history item when it exists', async () => { + const { rootMessenger } = await executePollingWithPendingStatus(); + + const txMetaId = 'bridgeTxMetaId1'; + const bridgeHistoryItem = rootMessenger.call( + 'BridgeStatusController:getBridgeHistoryItemByTxMetaId', + txMetaId, + ); + + expect(bridgeHistoryItem).toBeDefined(); + expect(bridgeHistoryItem?.quote.srcChainId).toBe(42161); + expect(bridgeHistoryItem?.quote.destChainId).toBe(10); + expect(bridgeHistoryItem?.status.status).toBe(StatusTypes.PENDING); + }); + + it('returns undefined when the transaction does not exist', async () => { + const { rootMessenger } = await executePollingWithPendingStatus(); + + const txMetaId = 'nonExistentTxId'; + const bridgeHistoryItem = rootMessenger.call( + 'BridgeStatusController:getBridgeHistoryItemByTxMetaId', + txMetaId, + ); + + expect(bridgeHistoryItem).toBeUndefined(); + }); + + it('handles the case when txHistory is empty', async () => { + await withController( + { options: { state: EMPTY_INIT_STATE } }, + async ({ rootMessenger }) => { + const bridgeHistoryItem = rootMessenger.call( + 'BridgeStatusController:getBridgeHistoryItemByTxMetaId', + 'anyTxId', + ); + expect(bridgeHistoryItem).toBeUndefined(); + }, + ); + }); + + it('returns the correct transaction when multiple transactions exist', async () => { + await withController( + { + options: { + state: { + txHistory: { + bridgeTxMetaId1: { + ...MockTxHistory.getPending().bridgeTxMetaId1, + quote: { + ...MockTxHistory.getPending().bridgeTxMetaId1.quote, + srcChainId: 10, + destChainId: 137, + }, + }, + anotherTxId: { + ...MockTxHistory.getPending().bridgeTxMetaId1, + txMetaId: 'anotherTxId', + quote: { + ...MockTxHistory.getPending().bridgeTxMetaId1.quote, + srcChainId: 1, + destChainId: 42161, + }, + }, + }, + }, + }, + }, + async ({ rootMessenger }) => { + // Get the first transaction + const firstTransaction = rootMessenger.call( + 'BridgeStatusController:getBridgeHistoryItemByTxMetaId', + 'bridgeTxMetaId1', + ); + expect(firstTransaction?.quote.srcChainId).toBe(10); + expect(firstTransaction?.quote.destChainId).toBe(137); + + // Get the second transaction + const secondTransaction = rootMessenger.call( + 'BridgeStatusController:getBridgeHistoryItemByTxMetaId', + 'anotherTxId', + ); + expect(secondTransaction?.quote.srcChainId).toBe(1); + expect(secondTransaction?.quote.destChainId).toBe(42161); + }, + ); + }); + }); + + describe('wipeBridgeStatus', () => { + beforeEach(() => { + jest.clearAllTimers(); + jest.clearAllMocks(); + }); + + it('wipes the bridge status for the given address', async () => { + // Setup + jest.useFakeTimers(); + + await withController(async ({ controller, rootMessenger, messenger }) => { + registerDefaultActionHandlers(rootMessenger, { + account: '0xaccount1', + }); + const messengerCallSpy = jest.spyOn(messenger, 'call'); + + const fetchBridgeTxStatusSpy = jest + .spyOn(bridgeStatusUtils, 'fetchBridgeTxStatus') + .mockImplementationOnce(async () => { + return { + status: MockStatusResponse.getComplete(), + validationFailures: [], + }; + }) + .mockImplementationOnce(async () => { + return { + status: MockStatusResponse.getComplete({ + srcTxHash: '0xsrcTxHash2', + destTxHash: '0xdestTxHash2', + }), + validationFailures: [], + }; + }); + + rootMessenger.call( + 'BridgeStatusController:startPollingForBridgeTxStatus', + getMockStartPollingForBridgeTxStatusArgs(), + ); + jest.advanceTimersToNextTimer(); + await flushPromises(); + jest.advanceTimersByTime(10_000); + expect(fetchBridgeTxStatusSpy).toHaveBeenCalledTimes(1); + + // Start polling for 0xaccount2 + rootMessenger.call( + 'BridgeStatusController:startPollingForBridgeTxStatus', + getMockStartPollingForBridgeTxStatusArgs({ + txMetaId: 'bridgeTxMetaId2', + srcTxHash: '0xsrcTxHash2', + account: '0xaccount2', + }), + ); + jest.advanceTimersByTime(10_000); + jest.advanceTimersToNextTimer(); + await flushPromises(); + expect(fetchBridgeTxStatusSpy).toHaveBeenCalledTimes(2); + + // Check that both accounts have a tx history entry + expect(controller.state.txHistory).toHaveProperty('bridgeTxMetaId1'); + expect(controller.state.txHistory).toHaveProperty('bridgeTxMetaId2'); + + // Wipe the status for 1 account only + rootMessenger.call('BridgeStatusController:wipeBridgeStatus', { + address: '0xaccount1', + ignoreNetwork: false, + }); + + // Assertions + const txHistoryItems = Object.values(controller.state.txHistory); + expect(txHistoryItems).toHaveLength(1); + expect(txHistoryItems[0].account).toBe('0xaccount2'); + expect( + messengerCallSpy.mock.calls.map((call) => call.slice(0, 2)), + ).toMatchSnapshot(); + }); + }); + + it('wipes the bridge status for all networks if ignoreNetwork is true', async () => { + // Setup + jest.useFakeTimers(); + await withController(async ({ controller, rootMessenger }) => { + registerDefaultActionHandlers(rootMessenger); + const fetchBridgeTxStatusSpy = jest + .spyOn(bridgeStatusUtils, 'fetchBridgeTxStatus') + .mockImplementationOnce(async () => { + return { + status: MockStatusResponse.getComplete(), + validationFailures: [], + }; + }) + .mockImplementationOnce(async () => { + return { + status: MockStatusResponse.getComplete({ + srcTxHash: '0xsrcTxHash2', + }), + validationFailures: [], + }; + }); + + // Start polling for chainId 42161 to chainId 1 + rootMessenger.call( + 'BridgeStatusController:startPollingForBridgeTxStatus', + getMockStartPollingForBridgeTxStatusArgs({ + account: '0xaccount1', + srcTxHash: '0xsrcTxHash1', + txMetaId: 'bridgeTxMetaId1', + srcChainId: 42161, + destChainId: 1, + }), + ); + jest.advanceTimersToNextTimer(); + jest.advanceTimersToNextTimer(); + await flushPromises(); + expect(fetchBridgeTxStatusSpy).toHaveBeenCalledTimes(1); + + // Start polling for chainId 10 to chainId 123 + rootMessenger.call( + 'BridgeStatusController:startPollingForBridgeTxStatus', + getMockStartPollingForBridgeTxStatusArgs({ + account: '0xaccount1', + srcTxHash: '0xsrcTxHash2', + txMetaId: 'bridgeTxMetaId2', + srcChainId: 10, + destChainId: 123, + }), + ); + jest.advanceTimersToNextTimer(); + await flushPromises(); + expect(fetchBridgeTxStatusSpy).toHaveBeenCalledTimes(2); + + // Check we have a tx history entry for each chainId + expect( + controller.state.txHistory.bridgeTxMetaId1.quote.srcChainId, + ).toBe(42161); + expect( + controller.state.txHistory.bridgeTxMetaId1.quote.destChainId, + ).toBe(1); + + expect( + controller.state.txHistory.bridgeTxMetaId2.quote.srcChainId, + ).toBe(10); + expect( + controller.state.txHistory.bridgeTxMetaId2.quote.destChainId, + ).toBe(123); + + rootMessenger.call('BridgeStatusController:wipeBridgeStatus', { + address: '0xaccount1', + ignoreNetwork: true, + }); + + // Assertions + const txHistoryItems = Object.values(controller.state.txHistory); + expect(txHistoryItems).toHaveLength(0); + }); + }); + + it('wipes the bridge status only for the current network if ignoreNetwork is false', async () => { + // Setup + jest.useFakeTimers(); + await withController(async ({ controller, rootMessenger }) => { + // This is what controls the selectedNetwork and what gets wiped in this test + registerDefaultActionHandlers(rootMessenger); + const fetchBridgeTxStatusSpy = jest + .spyOn(bridgeStatusUtils, 'fetchBridgeTxStatus') + .mockImplementationOnce(async () => { + return { + status: MockStatusResponse.getComplete(), + validationFailures: [], + }; + }) + .mockImplementationOnce(async () => { + return { + status: MockStatusResponse.getComplete({ + srcTxHash: '0xsrcTxHash2', + }), + validationFailures: [], + }; + }); + + // Start polling for chainId 42161 to chainId 1 + rootMessenger.call( + 'BridgeStatusController:startPollingForBridgeTxStatus', + getMockStartPollingForBridgeTxStatusArgs({ + account: '0xaccount1', + srcTxHash: '0xsrcTxHash1', + txMetaId: 'bridgeTxMetaId1', + srcChainId: 42161, + destChainId: 1, + }), + ); + jest.advanceTimersToNextTimer(); + await flushPromises(); + jest.advanceTimersByTime(10_000); + expect(fetchBridgeTxStatusSpy).toHaveBeenCalledTimes(1); + + // Start polling for chainId 10 to chainId 123 + rootMessenger.call( + 'BridgeStatusController:startPollingForBridgeTxStatus', + getMockStartPollingForBridgeTxStatusArgs({ + account: '0xaccount1', + srcTxHash: '0xsrcTxHash2', + txMetaId: 'bridgeTxMetaId2', + srcChainId: 10, + destChainId: 123, + }), + ); + jest.advanceTimersToNextTimer(); + await flushPromises(); + jest.advanceTimersByTime(10_000); + expect(fetchBridgeTxStatusSpy).toHaveBeenCalledTimes(2); + + // Check we have a tx history entry for each chainId + expect( + controller.state.txHistory.bridgeTxMetaId1.quote.srcChainId, + ).toBe(42161); + expect( + controller.state.txHistory.bridgeTxMetaId1.quote.destChainId, + ).toBe(1); + + expect( + controller.state.txHistory.bridgeTxMetaId2.quote.srcChainId, + ).toBe(10); + expect( + controller.state.txHistory.bridgeTxMetaId2.quote.destChainId, + ).toBe(123); + + rootMessenger.call('BridgeStatusController:wipeBridgeStatus', { + address: '0xaccount1', + ignoreNetwork: false, + }); + + // Assertions + const txHistoryItems = Object.values(controller.state.txHistory); + expect(txHistoryItems).toHaveLength(1); + expect(txHistoryItems[0].quote.srcChainId).toBe(10); + expect(txHistoryItems[0].quote.destChainId).toBe(123); + }); + }); + }); + + describe('submitTx: Solana bridge', () => { + const mockQuote: QuoteResponseV1 = { + quote: { + requestId: '123', + srcChainId: ChainId.SOLANA, + destChainId: ChainId.ETH, + srcTokenAmount: '1000000000', + srcAsset: { + chainId: ChainId.SOLANA, + address: 'native', + symbol: 'SOL', + name: 'Solana', + decimals: 9, + assetId: 'eip155:1399811149/slip44:501', + }, + destTokenAmount: '0.5', + minDestTokenAmount: '0.475', + destAsset: { + chainId: ChainId.ETH, + address: '0x...', + symbol: 'ETH', + name: 'Ethereum', + decimals: 18, + assetId: 'eip155:1/slip44:60', + }, + bridgeId: 'test-bridge', + bridges: ['test-bridge'], + steps: [ + { + action: ActionTypes.BRIDGE, + srcChainId: ChainId.SOLANA, + destChainId: ChainId.ETH, + srcAsset: { + chainId: ChainId.SOLANA, + address: 'native', + symbol: 'SOL', + name: 'Solana', + decimals: 9, + assetId: 'eip155:1399811149/slip44:501', + }, + destAsset: { + chainId: ChainId.ETH, + address: '0x...', + symbol: 'ETH', + name: 'Ethereum', + decimals: 18, + assetId: 'eip155:1/slip44:60', + }, + }, + ], + feeData: { + [FeeType.METABRIDGE]: { + amount: '1000000', + asset: { + chainId: ChainId.SOLANA, + address: 'native', + symbol: 'SOL', + name: 'Solana', + decimals: 9, + assetId: 'eip155:1399811149/slip44:501', + }, + }, + }, + }, + estimatedProcessingTimeInSeconds: 300, + trade: + '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=', + }; + const mockQuoteResponse = { + ...mockQuote, + ...{ + sentAmount: { + amount: '1', + valueInCurrency: '100', + usd: '100', + }, + toTokenAmount: { + amount: '0.5', + valueInCurrency: '1000', + usd: '1000', + }, + minToTokenAmount: { + amount: '0.475', + valueInCurrency: '950', + usd: '950', + }, + totalNetworkFee: { + amount: '0.1', + valueInCurrency: '10', + usd: '10', + }, + gasFee: { + total: { amount: '0.05', valueInCurrency: '5', usd: '5' }, + }, + adjustedReturn: { + valueInCurrency: '985', + usd: '985', + }, + cost: { + valueInCurrency: '15', + usd: '15', + }, + swapRate: '0.5', + }, + }; + + const mockSolanaAccount = { + id: 'solana-account-1', + address: '0x123...', + metadata: { + snap: { + id: 'test-snap', + }, + keyring: { + type: 'any', + }, + }, + options: { scope: 'solana-chain-id' }, + }; + + let mockMessengerCall: jest.Mock; + beforeEach(() => { + jest.clearAllMocks(); + jest.clearAllTimers(); + jest.spyOn(Date, 'now').mockReturnValueOnce(1234567890); + jest.spyOn(Date, 'now').mockReturnValueOnce(1234567891); + mockMessengerCall = jest.fn(); + mockMessengerCall.mockImplementationOnce(jest.fn()); // stopPollingForQuotes + }); + + it('should successfully submit a transaction', async () => { + mockMessengerCall.mockReturnValueOnce(mockSolanaAccount); + mockMessengerCall.mockImplementationOnce(jest.fn()); // track event + mockMessengerCall.mockResolvedValueOnce('signature'); + mockMessengerCall.mockReturnValueOnce(mockSolanaAccount); + mockMessengerCall.mockReturnValueOnce({ + transactions: [], + }); + + await withController( + { mockMessengerCall }, + async ({ + controller, + rootMessenger, + startPollingForBridgeTxStatusSpy, + }) => { + const result = await rootMessenger.call( + 'BridgeStatusController:submitTx', + 'SOLaccountAddress', + mockQuoteResponse, + false, + ); + controller.stopAllPolling(); + + expect(mockMessengerCall.mock.calls).toMatchSnapshot(); + expect(result).toMatchSnapshot(); + expect(startPollingForBridgeTxStatusSpy).toHaveBeenCalledTimes(1); + expect( + startPollingForBridgeTxStatusSpy.mock.lastCall[0], + ).toMatchSnapshot(); + expect(controller.state.txHistory[result.id]).toMatchSnapshot(); + }, + ); + }); + + it('should throw error when snap ID is missing', async () => { + const accountWithoutSnap = { + ...mockSolanaAccount, + metadata: { keyring: { type: 'any' }, snap: undefined }, + }; + mockMessengerCall.mockReturnValueOnce(accountWithoutSnap); + mockMessengerCall.mockImplementationOnce(jest.fn()); // track event + + await withController( + { mockMessengerCall }, + async ({ rootMessenger, startPollingForBridgeTxStatusSpy }) => { + await expect( + rootMessenger.call( + 'BridgeStatusController:submitTx', + 'SOLaccountAddress', + mockQuoteResponse, + false, + ), + ).rejects.toThrow( + 'Failed to submit cross-chain swap transaction: undefined snap id', + ); + expect(startPollingForBridgeTxStatusSpy).not.toHaveBeenCalled(); + expect(mockMessengerCall.mock.calls).toMatchSnapshot(); + }, + ); + }); + + it('should throw error when account is missing', async () => { + mockMessengerCall.mockReturnValueOnce(undefined); + + await withController( + { mockMessengerCall }, + async ({ rootMessenger, startPollingForBridgeTxStatusSpy }) => { + await expect( + rootMessenger.call( + 'BridgeStatusController:submitTx', + 'SOLaccountAddress', + mockQuoteResponse, + false, + ), + ).rejects.toThrow( + 'Failed to submit cross-chain swap transaction: undefined multichain account', + ); + expect(startPollingForBridgeTxStatusSpy).not.toHaveBeenCalled(); + }, + ); + }); + + it('should handle snap controller errors', async () => { + mockMessengerCall.mockReturnValueOnce(mockSolanaAccount); + mockMessengerCall.mockImplementationOnce(jest.fn()); // track event + mockMessengerCall.mockRejectedValueOnce(new Error('Snap error')); + + await withController( + { mockMessengerCall }, + async ({ rootMessenger, startPollingForBridgeTxStatusSpy }) => { + await expect( + rootMessenger.call( + 'BridgeStatusController:submitTx', + 'SOLaccountAddress', + mockQuoteResponse, + false, + ), + ).rejects.toThrow('Snap error'); + expect(mockMessengerCall.mock.calls).toMatchSnapshot(); + expect(startPollingForBridgeTxStatusSpy).not.toHaveBeenCalled(); + }, + ); + }); + }); + + describe('submitTx: Solana swap', () => { + const mockQuoteResponse: QuoteResponseV1 & QuoteMetadata = { + quote: { + requestId: '123', + srcChainId: ChainId.SOLANA, + destChainId: ChainId.SOLANA, + srcTokenAmount: '1000000000', + srcAsset: { + chainId: ChainId.SOLANA, + address: 'native', + symbol: 'SOL', + name: 'Solana', + decimals: 9, + assetId: getNativeAssetForChainId(ChainId.SOLANA).assetId, + }, + destTokenAmount: '500000000000000000s', + minDestTokenAmount: '475000000000000000s', + destAsset: { + chainId: ChainId.SOLANA, + address: '0x...', + symbol: 'USDC', + name: 'USDC', + decimals: 18, + assetId: 'eip155:1399811149/slip44:501', + }, + bridgeId: 'test-bridge', + bridges: [], + steps: [ + { + action: ActionTypes.BRIDGE, + srcChainId: ChainId.SOLANA, + destChainId: ChainId.ETH, + srcAsset: { + chainId: ChainId.SOLANA, + address: 'native', + symbol: 'SOL', + name: 'Solana', + decimals: 9, + assetId: 'eip155:1399811149/slip44:501', + }, + destAsset: { + chainId: ChainId.ETH, + address: '0x...', + symbol: 'ETH', + name: 'Ethereum', + decimals: 18, + assetId: 'eip155:1/slip44:60', + }, + }, + ], + feeData: { + [FeeType.METABRIDGE]: { + amount: '1000000', + asset: { + chainId: ChainId.SOLANA, + address: 'native', + symbol: 'SOL', + name: 'Solana', + decimals: 9, + assetId: 'eip155:1399811149/slip44:501', + }, + }, + }, + }, + estimatedProcessingTimeInSeconds: 300, + trade: + '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=', + sentAmount: { + amount: '1', + valueInCurrency: '100', + usd: '100', + }, + toTokenAmount: { + amount: '0.5', + valueInCurrency: '1000', + usd: '1000', + }, + minToTokenAmount: { + amount: '0.475', + valueInCurrency: '950', + usd: '950', + }, + totalNetworkFee: { + amount: '0.1', + valueInCurrency: '10', + usd: '10', + }, + gasFee: { + total: { amount: '0.05', valueInCurrency: '5', usd: '5' }, + }, + adjustedReturn: { + valueInCurrency: '985', + usd: '985', + }, + cost: { + valueInCurrency: '15', + usd: '15', + }, + swapRate: '0.5', + }; + + const mockSolanaAccount = { + id: 'solana-account-1', + address: '0x123...', + metadata: { + snap: { + id: 'test-snap', + }, + keyring: { + type: 'QR Hardware Wallet Device', + }, + }, + options: { scope: 'solana-chain-id' }, + }; + let mockMessengerCall: jest.Mock; + + beforeEach(() => { + jest.clearAllMocks(); + jest.clearAllTimers(); + mockMessengerCall = jest.fn(); + jest.spyOn(Date, 'now').mockReturnValueOnce(1234567890); + jest.spyOn(Date, 'now').mockReturnValueOnce(1234567891); + mockMessengerCall.mockImplementationOnce(jest.fn()); // stopPollingForQuotes + }); + + it('should successfully submit a transaction', async () => { + mockMessengerCall.mockReturnValueOnce(mockSolanaAccount); + mockMessengerCall.mockImplementationOnce(jest.fn()); // track event + mockMessengerCall.mockResolvedValueOnce({ + signature: 'signature', + }); + mockMessengerCall.mockReturnValueOnce(mockSolanaAccount); + mockMessengerCall.mockReturnValueOnce({ + transactions: [], + }); + + await withController( + { mockMessengerCall }, + async ({ + controller, + rootMessenger, + startPollingForBridgeTxStatusSpy, + }) => { + const result = await rootMessenger.call( + 'BridgeStatusController:submitTx', + 'SOLaccountAddress', + mockQuoteResponse, + false, + ); + controller.stopAllPolling(); + + expect(mockMessengerCall.mock.calls).toMatchSnapshot(); + expect(result).toMatchSnapshot(); + expect(controller.state.txHistory[result.id]).toMatchSnapshot(); + expect( + mockMessengerCall.mock.calls.find( + ([, eventName]) => + eventName === UnifiedSwapBridgeEventName.Completed, + ), + ).toStrictEqual([ + 'BridgeController:trackUnifiedSwapBridgeEvent', + UnifiedSwapBridgeEventName.Completed, + expect.not.objectContaining({ + transaction_internal_id: expect.anything(), + }), + ]); + expect(startPollingForBridgeTxStatusSpy).toHaveBeenCalledTimes(0); + }, + ); + }); + + it('should throw error when snap ID is missing', async () => { + const accountWithoutSnap = { + ...mockSolanaAccount, + metadata: { keyring: { type: 'any' }, snap: undefined }, + }; + mockMessengerCall.mockReturnValueOnce(accountWithoutSnap); + mockMessengerCall.mockImplementationOnce(jest.fn()); // track event + + await withController( + { mockMessengerCall }, + async ({ rootMessenger, startPollingForBridgeTxStatusSpy }) => { + await expect( + rootMessenger.call( + 'BridgeStatusController:submitTx', + 'SOLaccountAddress', + mockQuoteResponse, + false, + ), + ).rejects.toThrow( + 'Failed to submit cross-chain swap transaction: undefined snap id', + ); + expect(mockMessengerCall.mock.calls).toMatchSnapshot(); + expect(startPollingForBridgeTxStatusSpy).not.toHaveBeenCalled(); + }, + ); + }); + + it('should throw error when account is missing', async () => { + mockMessengerCall.mockReturnValueOnce(undefined); + + await withController( + { mockMessengerCall }, + async ({ rootMessenger, startPollingForBridgeTxStatusSpy }) => { + await expect( + rootMessenger.call( + 'BridgeStatusController:submitTx', + 'SOLaccountAddress', + mockQuoteResponse, + false, + ), + ).rejects.toThrow( + 'Failed to submit cross-chain swap transaction: undefined multichain account', + ); + expect(mockMessengerCall.mock.calls).toMatchSnapshot(); + expect(startPollingForBridgeTxStatusSpy).not.toHaveBeenCalled(); + }, + ); + }); + + it('should handle snap controller errors', async () => { + mockMessengerCall.mockReturnValueOnce(mockSolanaAccount); + mockMessengerCall.mockImplementationOnce(jest.fn()); // track event + mockMessengerCall.mockRejectedValueOnce(new Error('Snap error')); + + await withController( + { mockMessengerCall }, + async ({ rootMessenger, startPollingForBridgeTxStatusSpy }) => { + await expect( + rootMessenger.call( + 'BridgeStatusController:submitTx', + 'SOLaccountAddress', + mockQuoteResponse, + false, + ), + ).rejects.toThrow('Snap error'); + expect(mockMessengerCall.mock.calls).toMatchSnapshot(); + expect(startPollingForBridgeTxStatusSpy).not.toHaveBeenCalled(); + }, + ); + }); + }); + + describe('submitTx: Tron swap with approval', () => { + const mockTronApproval: TronTradeData = { + raw_data_hex: + '0a02aabb22084dde86d0f68ae3e5403a680801b2630a31747970652e676f6f676c65617069732e636f6d2f70726f746f636f6c2e54726967676572536d617274436f6e747261637412330a15418f7ea8cce9f8bba67d7ae59cd49a1965d617e71a121541a614f803b6fd780986a42c78ec9c7f77e6ded13c', + }; + + const mockTronTrade: TronTradeData = { + raw_data_hex: + '0a02aabb22084dde86d0f68ae3e5403a680801b2630a31747970652e676f6f676c65617069732e636f6d2f70726f746f636f6c2e54726967676572536d617274436f6e747261637412330a15418f7ea8cce9f8bba67d7ae59cd49a1965d617e71b121541a614f803b6fd780986a42c78ec9c7f77e6ded13c', + }; + + const mockQuoteResponse: QuoteResponseV1 & + QuoteMetadata = { + quote: { + requestId: '123', + srcChainId: ChainId.TRON, + destChainId: ChainId.TRON, + srcTokenAmount: '1000000', + srcAsset: { + chainId: ChainId.TRON, + address: 'TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t', // USDT on Tron + symbol: 'USDT', + name: 'Tether USD', + decimals: 6, + assetId: 'tron:728126428/slip44:195', + }, + destTokenAmount: '500000000', + minDestTokenAmount: '475000000', + destAsset: { + chainId: ChainId.TRON, + address: 'native', + symbol: 'TRX', + name: 'Tron', + decimals: 6, + assetId: 'tron:728126428/slip44:195', + }, + bridgeId: 'test-bridge', + bridges: [], + steps: [ + { + action: ActionTypes.SWAP, + srcChainId: ChainId.TRON, + destChainId: ChainId.TRON, + srcAsset: { + chainId: ChainId.TRON, + address: 'TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t', + symbol: 'USDT', + name: 'Tether USD', + decimals: 6, + assetId: 'tron:728126428/slip44:195', + }, + destAsset: { + chainId: ChainId.TRON, + address: 'native', + symbol: 'TRX', + name: 'Tron', + decimals: 6, + assetId: 'tron:728126428/slip44:195', + }, + }, + ], + feeData: { + [FeeType.METABRIDGE]: { + amount: '10000', + asset: { + chainId: ChainId.TRON, + address: 'native', + symbol: 'TRX', + name: 'Tron', + decimals: 6, + assetId: 'tron:728126428/slip44:195', + }, + }, + }, + }, + estimatedProcessingTimeInSeconds: 30, + approval: mockTronApproval, + trade: mockTronTrade, + sentAmount: { + amount: '1', + valueInCurrency: '1', + usd: '1', + }, + toTokenAmount: { + amount: '500', + valueInCurrency: '500', + usd: '500', + }, + minToTokenAmount: { + amount: '475', + valueInCurrency: '475', + usd: '475', + }, + totalNetworkFee: { + amount: '0.01', + valueInCurrency: '0.01', + usd: '0.01', + }, + gasFee: { + total: { amount: '0.005', valueInCurrency: '0.005', usd: '0.005' }, + }, + adjustedReturn: { + valueInCurrency: '499.99', + usd: '499.99', + }, + cost: { + valueInCurrency: '0.01', + usd: '0.01', + }, + swapRate: '500', + }; + + const mockTronAccount = { + id: 'tron-account-1', + address: 'TRX123...', + metadata: { + snap: { + id: 'npm:@metamask/tron-snap', + }, + keyring: { + type: 'any', + }, + }, + options: { scope: 'tron-chain-id' }, + }; + + let mockMessengerCall: jest.Mock; + beforeEach(() => { + jest.clearAllMocks(); + jest.clearAllTimers(); + jest.spyOn(Date, 'now').mockReturnValueOnce(1234567890); + jest.spyOn(Date, 'now').mockReturnValueOnce(1234567891); + jest.spyOn(Date, 'now').mockReturnValueOnce(1234567892); + mockMessengerCall = jest.fn(); + mockMessengerCall.mockImplementationOnce(jest.fn()); // stopPollingForQuotes + }); + + it('should successfully submit a Tron swap with approval transaction', async () => { + mockMessengerCall.mockReturnValueOnce(mockTronAccount); + mockMessengerCall.mockImplementationOnce(jest.fn()); // track event + mockMessengerCall.mockResolvedValueOnce('approval-signature'); // approval tx + mockMessengerCall.mockResolvedValueOnce('swap-signature'); // swap tx + + await withController( + { mockMessengerCall }, + async ({ + controller, + rootMessenger, + startPollingForBridgeTxStatusSpy, + }) => { + const result = await rootMessenger.call( + 'BridgeStatusController:submitTx', + 'TRXaccountAddress', + mockQuoteResponse, + false, + ); + controller.stopAllPolling(); + + expect(mockMessengerCall.mock.calls).toMatchSnapshot(); + expect(result).toMatchSnapshot(); + // Tron swaps start polling for async settlement + expect(startPollingForBridgeTxStatusSpy).toHaveBeenCalledTimes(1); + expect(controller.state.txHistory[result.id]).toMatchSnapshot(); + }, + ); + }); + + it('should handle approval transaction errors', async () => { + mockMessengerCall.mockReturnValueOnce(mockTronAccount); + mockMessengerCall.mockImplementationOnce(jest.fn()); // track event + mockMessengerCall.mockRejectedValueOnce( + new Error('Approval transaction failed'), + ); // approval tx error + + await withController( + { mockMessengerCall }, + async ({ rootMessenger, startPollingForBridgeTxStatusSpy }) => { + await expect( + rootMessenger.call( + 'BridgeStatusController:submitTx', + 'TRXaccountAddress', + mockQuoteResponse, + false, + ), + ).rejects.toThrow('Approval transaction failed'); + expect(startPollingForBridgeTxStatusSpy).not.toHaveBeenCalled(); + expect(mockMessengerCall.mock.calls).toMatchSnapshot(); + }, + ); + }); + + it('should successfully submit a Tron bridge with approval transaction', async () => { + const mockTronBridgeQuote = { + ...mockQuoteResponse, + quote: { + ...mockQuoteResponse.quote, + destChainId: ChainId.ETH, // Different chain = bridge + }, + }; + + mockMessengerCall.mockReturnValueOnce(mockTronAccount); + mockMessengerCall.mockImplementationOnce(jest.fn()); // track event + mockMessengerCall.mockResolvedValueOnce('approval-signature'); // approval tx + mockMessengerCall.mockResolvedValueOnce('bridge-signature'); // bridge tx + + await withController( + { mockMessengerCall }, + async ({ + controller, + rootMessenger, + startPollingForBridgeTxStatusSpy, + }) => { + const result = await rootMessenger.call( + 'BridgeStatusController:submitTx', + 'TRXaccountAddress', + mockTronBridgeQuote, + false, + ); + controller.stopAllPolling(); + + expect(mockMessengerCall.mock.calls).toMatchSnapshot(); + expect(result).toMatchSnapshot(); + expect(startPollingForBridgeTxStatusSpy).toHaveBeenCalledTimes(1); + expect( + startPollingForBridgeTxStatusSpy.mock.lastCall[0], + ).toMatchSnapshot(); + expect(controller.state.txHistory[result.id]).toMatchSnapshot(); + }, + ); + }); + }); + + describe('submitTx: EVM bridge', () => { + const mockEvmQuoteResponse: QuoteResponseV1 & QuoteMetadata = { + quote: { + ...getMockQuote(), + srcChainId: 42161, // Arbitrum + destChainId: 10, // Optimism + }, + estimatedProcessingTimeInSeconds: 15, + sentAmount: { amount: '1.234', valueInCurrency: '2.00', usd: '1.01' }, + toTokenAmount: { + amount: '1.5', + valueInCurrency: '2.9999', + usd: '0.134214', + }, + minToTokenAmount: { + amount: '1.425', + valueInCurrency: '2.85', + usd: '0.127', + }, + totalNetworkFee: { + amount: '.00055', + valueInCurrency: undefined, + usd: '2.5778', + }, + gasFee: { + total: { amount: '.00055', valueInCurrency: undefined, usd: '2.5778' }, + }, + adjustedReturn: { valueInCurrency: undefined, usd: undefined }, + swapRate: '1.234', + cost: { valueInCurrency: undefined, usd: undefined }, + trade: { + from: '0xaccount1', + to: '0xbridgeContract', + value: '0x0', + data: '0xdata', + chainId: 42161, + gasLimit: 21000, + }, + approval: { + from: '0xaccount1', + to: '0xtokenContract', + value: '0x0', + data: '0xapprovalData', + chainId: 42161, + gasLimit: 21000, + }, + }; + validateQuoteResponseV1(mockEvmQuoteResponse); + + const mockEvmTxMeta = { + id: 'test-tx-id', + hash: '0xevmTxHash', + time: 1234567890, + status: 'unapproved', + type: TransactionType.bridge, + chainId: '0xa4b1', // 42161 in hex + txParams: { + from: '0xaccount1', + to: '0xbridgeContract', + value: '0x0', + data: '0xdata', + chainId: '0xa4b1', + gasLimit: '0x5208', + }, + txReceipt: { + gasUsed: '0x2c92a', + effectiveGasPrice: '0x1880a', + }, + }; + + const mockApprovalTxMeta = { + id: 'test-approval-tx-id', + hash: '0xapprovalTxHash', + time: 1234567890, + status: 'unapproved', + type: TransactionType.bridgeApproval, + chainId: '0xa4b1', // 42161 in hex + txParams: { + from: '0xaccount1', + to: '0xtokenContract', + value: '0x0', + data: '0xapprovalData', + chainId: '0xa4b1', + gasLimit: '0x5208', + }, + txReceipt: { + gasUsed: '0x2c92a', + effectiveGasPrice: '0x1880a', + }, + }; + + const mockEstimateGasFeeResult = { + estimates: { + type: GasFeeEstimateType.FeeMarket, + high: { + suggestedMaxFeePerGas: '0x1234', + suggestedMaxPriorityFeePerGas: '0x5678', + }, + }, + }; + + let mockMessengerCall: jest.Mock; + + beforeEach(() => { + jest.clearAllMocks(); + jest.clearAllTimers(); + mockMessengerCall = jest.fn(); + jest.spyOn(Date, 'now').mockReturnValueOnce(1234567890); + jest.spyOn(Date, 'now').mockReturnValueOnce(1234567891); + jest.spyOn(Date, 'now').mockReturnValueOnce(1234567892); + jest.spyOn(Date, 'now').mockReturnValueOnce(1234567893); + jest.spyOn(Math, 'random').mockReturnValueOnce(0.456); + jest.spyOn(Math, 'random').mockReturnValueOnce(0.457); + jest.spyOn(Math, 'random').mockReturnValueOnce(0.458); + mockMessengerCall.mockImplementationOnce(jest.fn()); // stopPollingForQuotes + }); + + const setupEventTrackingMocks = (mockCall: jest.Mock) => { + mockCall.mockReturnValueOnce(mockSelectedAccount); + mockCall.mockImplementationOnce(jest.fn()); // track event + mockCall.mockReturnValueOnce([]); // isAtomicBatchSupported + }; + + const setupApprovalMocks = (mockCall: jest.Mock) => { + mockCall.mockReturnValueOnce(mockSelectedAccount); + mockCall.mockReturnValueOnce('arbitrum-client-id'); + mockMessengerCall.mockResolvedValueOnce(mockEstimateGasFeeResult); + mockMessengerCall.mockResolvedValueOnce({ + transactionMeta: mockApprovalTxMeta, + result: Promise.resolve('0xapprovalTxHash'), + }); + mockCall.mockReturnValueOnce({ + transactions: [mockApprovalTxMeta], + }); + }; + + const setupBridgeMocks = (mockCall: jest.Mock) => { + mockCall.mockReturnValueOnce(mockSelectedAccount); + mockCall.mockReturnValueOnce('arbitrum'); + mockCall.mockResolvedValueOnce(mockEstimateGasFeeResult); + mockCall.mockResolvedValueOnce({ + transactionMeta: mockEvmTxMeta, + result: Promise.resolve('0xevmTxHash'), + }); + mockCall.mockReturnValueOnce({ + transactions: [mockEvmTxMeta], + }); + + mockCall.mockReturnValueOnce(mockSelectedAccount); + + mockCall.mockReturnValue({ + transactions: [mockEvmTxMeta], + }); + }; + + const setupBridgeStxMocks = (mockCall: jest.Mock) => { + mockCall.mockReturnValueOnce(mockSelectedAccount); + mockCall.mockReturnValueOnce('arbitrum'); + mockCall.mockResolvedValueOnce(mockEstimateGasFeeResult); + addTransactionBatchFn.mockResolvedValueOnce({ + batchId: 'batchId1', + }); + mockCall.mockReturnValueOnce({ + transactions: [{ ...mockEvmTxMeta, batchId: 'batchId1' }], + }); + + mockCall.mockReturnValueOnce(mockSelectedAccount); + + mockCall.mockReturnValueOnce({ + transactions: [{ ...mockEvmTxMeta, batchId: 'batchId1' }], + }); + }; + + it('should successfully submit an EVM bridge transaction with approval', async () => { + setupEventTrackingMocks(mockMessengerCall); + setupApprovalMocks(mockMessengerCall); + setupBridgeMocks(mockMessengerCall); + + await withController( + { mockMessengerCall }, + async ({ + controller, + rootMessenger, + startPollingForBridgeTxStatusSpy, + }) => { + const result = await rootMessenger.call( + 'BridgeStatusController:submitTx', + 'otherAccount', + mockEvmQuoteResponse, + false, + ); + + expect(result).toMatchSnapshot(); + expect(startPollingForBridgeTxStatusSpy).toHaveBeenCalledTimes(0); + expect(controller.state.txHistory[result.id]).toMatchSnapshot(); + expect(mockMessengerCall.mock.calls).toMatchSnapshot(); + controller.stopAllPolling(); + }, + ); + }); + + it('should successfully submit an EVM bridge transaction with no approval', async () => { + setupEventTrackingMocks(mockMessengerCall); + setupBridgeMocks(mockMessengerCall); + + await withController( + { mockMessengerCall }, + async ({ + controller, + rootMessenger, + startPollingForBridgeTxStatusSpy, + }) => { + const erc20Token = { + address: '0x0000000000000000000000000000000000000032', + assetId: `eip155:10/slip44:60` as CaipAssetType, + chainId: 10, + symbol: 'WETH', + decimals: 18, + name: 'WETH', + coinKey: 'WETH', + logoURI: + 'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png', + priceUSD: '2478.63', + icon: 'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png', + }; + const { approval, ...quoteWithoutApproval } = mockEvmQuoteResponse; + const result = await rootMessenger.call( + 'BridgeStatusController:submitTx', + (quoteWithoutApproval.trade as TxData).from, + { + ...quoteWithoutApproval, + quote: { ...quoteWithoutApproval.quote, destAsset: erc20Token }, + }, + false, + ); + + expect(result).toMatchSnapshot(); + expect(startPollingForBridgeTxStatusSpy).toHaveBeenCalledTimes(0); + expect(controller.state.txHistory[result.id]).toMatchSnapshot(); + expect(mockMessengerCall.mock.calls).toMatchSnapshot(); + }, + ); + }); + + it('should handle smart transactions and include quotesReceivedContext', async () => { + setupEventTrackingMocks(mockMessengerCall); + setupBridgeStxMocks(mockMessengerCall); + addTransactionBatchFn.mockResolvedValueOnce({ + batchId: 'batchId1', + }); + + await withController( + { mockMessengerCall }, + async ({ + controller, + rootMessenger, + startPollingForBridgeTxStatusSpy, + }) => { + const { approval, ...quoteWithoutApproval } = mockEvmQuoteResponse; + const quoteResponseV2 = mergeQuoteMetadata( + toQuoteResponseV2(quoteWithoutApproval), + quoteWithoutApproval, + ); + const quotesReceivedContext = getQuotesReceivedProperties( + quoteResponseV2, + ['low_return'], + true, + ); + const result = await rootMessenger.call( + 'BridgeStatusController:submitTx', + (quoteWithoutApproval.trade as TxData).from, + quoteWithoutApproval, + true, + quotesReceivedContext, + ); + controller.stopAllPolling(); + + expect(result).toMatchSnapshot(); + expect(startPollingForBridgeTxStatusSpy).toHaveBeenCalledTimes(0); + expect(controller.state.txHistory[result.id]).toMatchSnapshot(); + expect(addTransactionBatchFn.mock.calls).toMatchSnapshot(); + expect(mockMessengerCall.mock.calls).toMatchSnapshot(); + }, + ); + }); + + it('should throw an error if account is not found', async () => { + setupEventTrackingMocks(mockMessengerCall); + mockMessengerCall.mockReturnValueOnce(undefined); + + await withController( + { mockMessengerCall }, + async ({ + controller, + rootMessenger, + startPollingForBridgeTxStatusSpy, + }) => { + const { approval, ...quoteWithoutApproval } = mockEvmQuoteResponse; + + await expect( + rootMessenger.call( + 'BridgeStatusController:submitTx', + (quoteWithoutApproval.trade as TxData).from, + quoteWithoutApproval, + false, + ), + ).rejects.toThrow( + 'Failed to submit cross-chain swap transaction: unknown account in trade data', + ); + controller.stopAllPolling(); + + expect(startPollingForBridgeTxStatusSpy).toHaveBeenCalledTimes(0); + const addTransactionCall = mockMessengerCall.mock.calls.find( + (call) => call[0] === 'TransactionController:addTransaction', + ); + expect(addTransactionCall).toBeUndefined(); + }, + ); + }); + + it('should throw an error if EVM trade data is not valid', async () => { + setupEventTrackingMocks(mockMessengerCall); + mockMessengerCall.mockReturnValueOnce(undefined); + + await withController( + { mockMessengerCall }, + async ({ + controller, + rootMessenger, + startPollingForBridgeTxStatusSpy, + }) => { + const { approval, ...quoteWithoutApproval } = mockEvmQuoteResponse; + + await expect( + rootMessenger.call( + 'BridgeStatusController:submitTx', + (quoteWithoutApproval.trade as TxData).from, + { + ...quoteWithoutApproval, + trade: (quoteWithoutApproval.trade as TxData).data, + }, + false, + ), + ).rejects.toThrow( + 'Failed to submit cross-chain swap transaction: trade is not an EVM transaction', + ); + controller.stopAllPolling(); + + expect(startPollingForBridgeTxStatusSpy).toHaveBeenCalledTimes(0); + const addTransactionCall = mockMessengerCall.mock.calls.find( + (call) => call[0] === 'TransactionController:addTransaction', + ); + expect(addTransactionCall).toBeUndefined(); + }, + ); + }); + + it('should throw an error if Solana trade data is not valid', async () => { + setupEventTrackingMocks(mockMessengerCall); + mockMessengerCall.mockReturnValueOnce(undefined); + + await withController( + { mockMessengerCall }, + async ({ + controller, + rootMessenger, + startPollingForBridgeTxStatusSpy, + }) => { + const { approval, ...quoteWithoutApproval } = mockEvmQuoteResponse; + + await expect( + rootMessenger.call( + 'BridgeStatusController:submitTx', + (quoteWithoutApproval.trade as TxData).from, + { + ...quoteWithoutApproval, + quote: { + ...quoteWithoutApproval.quote, + srcChainId: ChainId.SOLANA, + }, + }, + false, + ), + ).rejects.toThrow( + 'Failed to submit cross-chain swap transaction: trade is not a non-EVM transaction', + ); + controller.stopAllPolling(); + + expect(startPollingForBridgeTxStatusSpy).toHaveBeenCalledTimes(0); + const addTransactionCall = mockMessengerCall.mock.calls.find( + (call) => call[0] === 'TransactionController:addTransaction', + ); + expect(addTransactionCall).toBeUndefined(); + }, + ); + }); + + it('should reset USDT allowance', async () => { + setupEventTrackingMocks(mockMessengerCall); + mockIsEthUsdt.mockReturnValueOnce(true); + + // USDT approval reset + setupApprovalMocks(mockMessengerCall); + + // Approval tx + setupApprovalMocks(mockMessengerCall); + + // Bridge transaction + setupBridgeMocks(mockMessengerCall); + + await withController( + { mockMessengerCall }, + async ({ + controller, + rootMessenger, + startPollingForBridgeTxStatusSpy, + }) => { + const result = await rootMessenger.call( + 'BridgeStatusController:submitTx', + (mockEvmQuoteResponse.trade as TxData).from, + { + ...mockEvmQuoteResponse, + resetApproval: { + chainId: 1, + data: '0x095ea7b3000000000000000000000000881d40237659c251811cec9c364ef91dc08d300c0000000000000000000000000000000000000000000000000000000000000000', + from: '0xaccount1', + gasLimit: 21000, + to: '0xtokenContract', + value: '0x0', + }, + }, + false, + ); + controller.stopAllPolling(); + + expect(result).toMatchSnapshot(); + expect(startPollingForBridgeTxStatusSpy).toHaveBeenCalledTimes(0); + expect(controller.state.txHistory[result.id]).toMatchSnapshot(); + expect(mockMessengerCall.mock.calls).toMatchSnapshot(); + }, + ); + }); + + it('should handle smart transactions with USDT reset', async () => { + setupEventTrackingMocks(mockMessengerCall); + mockMessengerCall.mockReturnValueOnce(mockSelectedAccount); + mockMessengerCall.mockReturnValueOnce('arbitrum'); + mockMessengerCall.mockResolvedValueOnce(mockEstimateGasFeeResult); + mockMessengerCall.mockResolvedValueOnce(mockEstimateGasFeeResult); + mockMessengerCall.mockResolvedValueOnce(mockEstimateGasFeeResult); + addTransactionBatchFn.mockResolvedValueOnce({ + batchId: 'batchId1', + }); + mockMessengerCall.mockReturnValueOnce({ + transactions: [{ ...mockEvmTxMeta, batchId: 'batchId1' }], + }); + mockMessengerCall.mockReturnValueOnce(mockSelectedAccount); + + await withController( + { mockMessengerCall }, + async ({ + controller, + rootMessenger, + startPollingForBridgeTxStatusSpy, + }) => { + const result = await rootMessenger.call( + 'BridgeStatusController:submitTx', + (mockEvmQuoteResponse.trade as TxData).from, + { + ...mockEvmQuoteResponse, + resetApproval: { + chainId: 1, + data: '0x095ea7b3000000000000000000000000881d40237659c251811cec9c364ef91dc08d300c0000000000000000000000000000000000000000000000000000000000000000', + from: '0xaccount1', + gasLimit: 21000, + to: '0xtokenContract', + value: '0x0', + }, + }, + true, + ); + controller.stopAllPolling(); + + expect(startPollingForBridgeTxStatusSpy).toHaveBeenCalledTimes(0); + const { quote, txMetaId, batchId } = + controller.state.txHistory[result.id]; + expect(quote).toBeDefined(); + expect(txMetaId).toBe(result.id); + expect(batchId).toBe('batchId1'); + const mockCalls = mockMessengerCall.mock.calls; + expect( + mockCalls.filter( + ([action]) => action === 'TransactionController:estimateGasFee', + ), + ).toHaveLength(3); + expect( + mockCalls.filter( + ([action]) => action === 'TransactionController:addTransaction', + ), + ).toHaveLength(0); + expect(addTransactionBatchFn).toHaveBeenCalledTimes(1); + expect( + mockCalls.filter( + ([action]) => + action === 'TransactionController:updateTransaction', + ), + ).toHaveLength(1); + expect(mockMessengerCall).toHaveBeenCalledTimes(11); + }, + ); + }); + + it('should throw an error if approval tx fails', async () => { + setupEventTrackingMocks(mockMessengerCall); + mockMessengerCall.mockReturnValueOnce(mockSelectedAccount); + mockMessengerCall.mockReturnValueOnce('arbitrum-client-id'); + mockMessengerCall.mockResolvedValueOnce(mockEstimateGasFeeResult); + mockMessengerCall.mockRejectedValueOnce(new Error('Approval tx failed')); + + await withController( + { mockMessengerCall }, + async ({ rootMessenger, startPollingForBridgeTxStatusSpy }) => { + await expect( + rootMessenger.call( + 'BridgeStatusController:submitTx', + (mockEvmQuoteResponse.trade as TxData).from, + mockEvmQuoteResponse, + false, + ), + ).rejects.toThrow('Approval tx failed'); + + expect(startPollingForBridgeTxStatusSpy).toHaveBeenCalledTimes(0); + expect(mockMessengerCall.mock.calls).toMatchSnapshot(); + }, + ); + }); + + it('should throw an error if approval tx meta does not exist', async () => { + setupEventTrackingMocks(mockMessengerCall); + mockMessengerCall.mockReturnValueOnce(mockSelectedAccount); + mockMessengerCall.mockReturnValueOnce('arbitrum-client-id'); + mockMessengerCall.mockResolvedValueOnce(mockEstimateGasFeeResult); + mockMessengerCall.mockResolvedValueOnce({ + transactionMeta: undefined, + result: new Promise((resolve) => resolve('0xevmTxHash')), + }); + mockMessengerCall.mockReturnValueOnce({ + transactions: [], + }); + + setupBridgeMocks(mockMessengerCall); + + await withController( + { mockMessengerCall }, + async ({ rootMessenger, startPollingForBridgeTxStatusSpy }) => { + await expect( + rootMessenger.call( + 'BridgeStatusController:submitTx', + (mockEvmQuoteResponse.trade as TxData).from, + mockEvmQuoteResponse, + false, + ), + ).rejects.toThrow( + 'Failed to submit cross-chain swap tx: txMeta for txHash was not found', + ); + + expect(startPollingForBridgeTxStatusSpy).toHaveBeenCalledTimes(0); + expect(mockMessengerCall.mock.calls).toMatchSnapshot(); + }, + ); + }); + + it('should delay after submitting linea approval', async () => { + const handleLineaDelaySpy = jest + .spyOn(transactionUtils, 'handleApprovalDelay') + .mockResolvedValueOnce(); + const mockTraceFn = jest + .fn() + .mockImplementation((_p, callback) => callback()); + + setupEventTrackingMocks(mockMessengerCall); + setupApprovalMocks(mockMessengerCall); + setupBridgeMocks(mockMessengerCall); + + await withController( + { mockMessengerCall, options: { traceFn: mockTraceFn } }, + async ({ + controller, + rootMessenger, + startPollingForBridgeTxStatusSpy, + }) => { + const lineaQuoteResponse = { + ...mockEvmQuoteResponse, + quote: { ...mockEvmQuoteResponse.quote, srcChainId: 59144 }, + trade: { + ...(mockEvmQuoteResponse.trade as TxData), + gasLimit: null, + }, + }; + + const result = await rootMessenger.call( + 'BridgeStatusController:submitTx', + 'otherAccount', + lineaQuoteResponse, + false, + ); + controller.stopAllPolling(); + + expect(mockTraceFn).toHaveBeenCalledTimes(2); + expect(handleLineaDelaySpy).toHaveBeenCalledTimes(1); + expect(result).toMatchSnapshot(); + expect(startPollingForBridgeTxStatusSpy).toHaveBeenCalledTimes(0); + expect(controller.state.txHistory[result.id]).toMatchSnapshot(); + expect(mockMessengerCall.mock.calls).toMatchSnapshot(); + expect(mockTraceFn.mock.calls).toMatchSnapshot(); + }, + ); + }); + + it('should delay after submitting base approval', async () => { + const handleBaseDelaySpy = jest + .spyOn(transactionUtils, 'handleApprovalDelay') + .mockResolvedValueOnce(); + const mockTraceFn = jest + .fn() + .mockImplementation((_p, callback) => callback()); + + setupEventTrackingMocks(mockMessengerCall); + setupApprovalMocks(mockMessengerCall); + setupBridgeMocks(mockMessengerCall); + + await withController( + { mockMessengerCall, options: { traceFn: mockTraceFn } }, + async ({ + controller, + rootMessenger, + startPollingForBridgeTxStatusSpy, + }) => { + const baseQuoteResponse = { + ...mockEvmQuoteResponse, + quote: { ...mockEvmQuoteResponse.quote, srcChainId: 8453 }, + trade: { + ...(mockEvmQuoteResponse.trade as TxData), + gasLimit: null, + }, + }; + + const result = await rootMessenger.call( + 'BridgeStatusController:submitTx', + 'otherAccount', + baseQuoteResponse, + false, + ); + controller.stopAllPolling(); + + expect(mockTraceFn).toHaveBeenCalledTimes(2); + expect(handleBaseDelaySpy).toHaveBeenCalledTimes(1); + expect(result).toMatchSnapshot(); + expect(startPollingForBridgeTxStatusSpy).toHaveBeenCalledTimes(0); + expect(controller.state.txHistory[result.id]).toMatchSnapshot(); + expect(mockMessengerCall.mock.calls).toMatchSnapshot(); + expect(mockTraceFn.mock.calls).toMatchSnapshot(); + }, + ); + }); + + it('waits for approval tx confirmation before swap for hardware wallet on mobile', async () => { + const waitForTxConfirmationSpy = jest + .spyOn(transactionUtils, 'waitForTxConfirmation') + .mockResolvedValueOnce({ + ...mockApprovalTxMeta, + status: TransactionStatus.confirmed, + } as never); + const handleMobileHardwareWalletDelaySpy = jest + .spyOn(transactionUtils, 'handleMobileHardwareWalletDelay') + .mockResolvedValueOnce(); + const mockTraceFn = jest + .fn() + .mockImplementation((_p, callback) => callback()); + + // Mock for hardware wallet check + mockMessengerCall.mockReturnValueOnce({ + ...mockSelectedAccount, + metadata: { + ...mockSelectedAccount.metadata, + keyring: { + type: 'Ledger Hardware', + }, + }, + }); + mockMessengerCall.mockImplementationOnce(jest.fn()); // track event + mockMessengerCall.mockReturnValueOnce([]); // isAtomicBatchSupported + + setupApprovalMocks(mockMessengerCall); + setupBridgeMocks(mockMessengerCall); + + await withController( + { + mockMessengerCall, + options: { traceFn: mockTraceFn, clientId: BridgeClientId.MOBILE }, + }, + async ({ + controller, + rootMessenger, + startPollingForBridgeTxStatusSpy, + }) => { + const result = await rootMessenger.call( + 'BridgeStatusController:submitTx', + (mockEvmQuoteResponse.trade as TxData).from, + mockEvmQuoteResponse, + false, + ); + controller.stopAllPolling(); + + expect(mockTraceFn).toHaveBeenCalledTimes(2); + expect(handleMobileHardwareWalletDelaySpy).toHaveBeenCalledTimes(1); + expect(handleMobileHardwareWalletDelaySpy).toHaveBeenCalledWith(true); + expect( + handleMobileHardwareWalletDelaySpy.mock.invocationCallOrder[0], + ).toBeLessThan(waitForTxConfirmationSpy.mock.invocationCallOrder[0]); + expect(waitForTxConfirmationSpy).toHaveBeenCalledWith( + expect.any(Object), + mockApprovalTxMeta.id, + ); + expect(result).toMatchSnapshot(); + expect(startPollingForBridgeTxStatusSpy).toHaveBeenCalledTimes(0); + expect(controller.state.txHistory[result.id]).toMatchSnapshot(); + expect(mockMessengerCall.mock.calls).toMatchSnapshot(); + expect(mockTraceFn.mock.calls).toMatchSnapshot(); + }, + ); + }); + + it('should not call handleMobileHardwareWalletDelay on extension', async () => { + const handleMobileHardwareWalletDelaySpy = jest + .spyOn(transactionUtils, 'handleMobileHardwareWalletDelay') + .mockResolvedValueOnce(); + const mockTraceFn = jest + .fn() + .mockImplementation((_p, callback) => callback()); + + setupEventTrackingMocks(mockMessengerCall); + setupApprovalMocks(mockMessengerCall); + setupBridgeMocks(mockMessengerCall); + + await withController( + { + mockMessengerCall, + options: { + traceFn: mockTraceFn, + clientId: BridgeClientId.EXTENSION, + }, + }, + async ({ + controller, + rootMessenger, + startPollingForBridgeTxStatusSpy, + }) => { + const result = await rootMessenger.call( + 'BridgeStatusController:submitTx', + 'otherAccount', + mockEvmQuoteResponse, + false, + ); + controller.stopAllPolling(); + + expect(mockTraceFn).toHaveBeenCalledTimes(2); + // Should call the function but with false since it's Extension + expect(handleMobileHardwareWalletDelaySpy).toHaveBeenCalledTimes(1); + expect(handleMobileHardwareWalletDelaySpy).toHaveBeenCalledWith( + false, + ); + expect(result).toMatchSnapshot(); + expect(startPollingForBridgeTxStatusSpy).toHaveBeenCalledTimes(0); + expect(controller.state.txHistory[result.id]).toMatchSnapshot(); + expect(mockMessengerCall.mock.calls).toMatchSnapshot(); + expect(mockTraceFn.mock.calls).toMatchSnapshot(); + }, + ); + }); + + it('should not call handleMobileHardwareWalletDelay with true for non-hardware wallet on mobile', async () => { + const handleMobileHardwareWalletDelaySpy = jest + .spyOn(transactionUtils, 'handleMobileHardwareWalletDelay') + .mockResolvedValueOnce(); + const mockTraceFn = jest + .fn() + .mockImplementation((_p, callback) => callback()); + + // Mock for non-hardware wallet check + mockMessengerCall.mockReturnValueOnce({ + ...mockSelectedAccount, + metadata: { + ...mockSelectedAccount.metadata, + keyring: { + type: 'HD Key Tree', // Not a hardware wallet + }, + }, + }); + mockMessengerCall.mockImplementationOnce(jest.fn()); // track event + mockMessengerCall.mockReturnValueOnce([]); // isAtomicBatchSupported + + setupApprovalMocks(mockMessengerCall); + setupBridgeMocks(mockMessengerCall); + + await withController( + { + mockMessengerCall, + options: { traceFn: mockTraceFn, clientId: BridgeClientId.MOBILE }, + }, + async ({ + controller, + rootMessenger, + startPollingForBridgeTxStatusSpy, + }) => { + const result = await rootMessenger.call( + 'BridgeStatusController:submitTx', + (mockEvmQuoteResponse.trade as TxData).from, + mockEvmQuoteResponse, + false, + ); + controller.stopAllPolling(); + + expect(mockTraceFn).toHaveBeenCalledTimes(2); + // Should call the function but with false since it's not a hardware wallet + expect(handleMobileHardwareWalletDelaySpy).toHaveBeenCalledTimes(1); + expect(handleMobileHardwareWalletDelaySpy).toHaveBeenCalledWith( + false, + ); + expect(result).toMatchSnapshot(); + expect(startPollingForBridgeTxStatusSpy).toHaveBeenCalledTimes(0); + expect(controller.state.txHistory[result.id]).toMatchSnapshot(); + expect(mockMessengerCall.mock.calls).toMatchSnapshot(); + expect(mockTraceFn.mock.calls).toMatchSnapshot(); + }, + ); + }); + + describe('actionId tracking and rekeying', () => { + it('should add pre-submission history keyed by actionId and rekey to txMeta.id after success', async () => { + // Mock generateActionId to return a predictable value + const mockActionId = '1234567890.456'; + jest + .spyOn(transactionUtils, 'generateActionId') + .mockReturnValue(mockActionId); + + setupEventTrackingMocks(mockMessengerCall); + // No approval for this test - direct to bridge tx + const { approval, ...quoteWithoutApproval } = mockEvmQuoteResponse; + setupBridgeMocks(mockMessengerCall); + + await withController( + { mockMessengerCall }, + async ({ + controller, + rootMessenger, + startPollingForBridgeTxStatusSpy, + }) => { + const result = await rootMessenger.call( + 'BridgeStatusController:submitTx', + (quoteWithoutApproval.trade as TxData).from, + quoteWithoutApproval, + false, // STX disabled - uses non-batch path + ); + controller.stopAllPolling(); + + // Verify the final history is keyed by txMeta.id (not actionId) + expect(controller.state.txHistory[result.id]).toBeDefined(); + expect(controller.state.txHistory[result.id].txMetaId).toBe( + result.id, + ); + expect(controller.state.txHistory[result.id].actionId).toBe( + mockActionId, + ); + + // Verify the actionId key no longer exists (was rekeyed) + expect(controller.state.txHistory[mockActionId]).toBeUndefined(); + + // Verify srcTxHash was updated during rekey + expect( + controller.state.txHistory[result.id].status.srcChain.txHash, + ).toBe(result.hash); + + expect(startPollingForBridgeTxStatusSpy).toHaveBeenCalledTimes(0); + }, + ); + }); + + it('should preserve pre-submission history for tracking when trade tx submission fails', async () => { + const mockActionId = '9876543210.789'; + jest + .spyOn(transactionUtils, 'generateActionId') + .mockReturnValue(mockActionId); + + setupEventTrackingMocks(mockMessengerCall); + const { approval, ...quoteWithoutApproval } = mockEvmQuoteResponse; + + // Setup for trade tx (no approval) + mockMessengerCall.mockReturnValueOnce(mockSelectedAccount); + mockMessengerCall.mockReturnValueOnce('arbitrum-client-id'); + mockMessengerCall.mockResolvedValueOnce({ + estimates: { + type: GasFeeEstimateType.FeeMarket, + high: { + suggestedMaxFeePerGas: '0x1234', + suggestedMaxPriorityFeePerGas: '0x5678', + }, + }, + }); + + // Trade tx fails during submission + mockMessengerCall.mockRejectedValueOnce( + new Error('Trade tx submission failed'), + ); + + await withController( + { mockMessengerCall }, + async ({ + controller, + rootMessenger, + startPollingForBridgeTxStatusSpy, + }) => { + await expect( + rootMessenger.call( + 'BridgeStatusController:submitTx', + (quoteWithoutApproval.trade as TxData).from, + quoteWithoutApproval, + false, + ), + ).rejects.toThrow('Trade tx submission failed'); + + // Verify: Pre-submission history should still exist keyed by actionId + // This allows failed event tracking to find the quote data + expect(controller.state.txHistory[mockActionId]).toBeDefined(); + expect(controller.state.txHistory[mockActionId].actionId).toBe( + mockActionId, + ); + expect( + controller.state.txHistory[mockActionId].txMetaId, + ).toBeUndefined(); + expect( + controller.state.txHistory[mockActionId].status.srcChain.txHash, + ).toBeUndefined(); // Empty since tx w submitted + + expect(startPollingForBridgeTxStatusSpy).toHaveBeenCalledTimes(0); + }, + ); + }); + + it('should use provided actionId from addTransactionFn result', async () => { + const mockActionId = '1111111111.222'; + jest + .spyOn(transactionUtils, 'generateActionId') + .mockReturnValue(mockActionId); + + setupEventTrackingMocks(mockMessengerCall); + setupApprovalMocks(mockMessengerCall); + setupBridgeMocks(mockMessengerCall); + + await withController( + { mockMessengerCall }, + async ({ + controller, + rootMessenger, + startPollingForBridgeTxStatusSpy, + }) => { + const result = await rootMessenger.call( + 'BridgeStatusController:submitTx', + (mockEvmQuoteResponse.trade as TxData).from, + mockEvmQuoteResponse, + false, // STX disabled + ); + controller.stopAllPolling(); + + // Verify actionId is stored in the history item + expect(controller.state.txHistory[result.id].actionId).toBe( + mockActionId, + ); + expect(startPollingForBridgeTxStatusSpy).toHaveBeenCalledTimes(0); + }, + ); + }); + }); + }); + + describe('submitTx: EVM swap', () => { + const mockEvmQuoteResponse = { + ...getMockQuote(), + quote: { + ...getMockQuote(), + srcChainId: 42161, + destChainId: 42161, + }, + estimatedProcessingTimeInSeconds: 0, + sentAmount: { amount: '1.234', valueInCurrency: '2.00', usd: '1.01' }, + toTokenAmount: { + amount: '1.5', + valueInCurrency: '2.9999', + usd: '0.134214', + }, + minToTokenAmount: { + amount: '1.425', + valueInCurrency: '2.85', + usd: '0.127', + }, + totalNetworkFee: { + amount: '1.234', + valueInCurrency: undefined, + usd: undefined, + }, + gasFee: { + total: { amount: '1.234', valueInCurrency: undefined, usd: '2.5778' }, + }, + adjustedReturn: { valueInCurrency: undefined, usd: undefined }, + swapRate: '1.234', + cost: { valueInCurrency: undefined, usd: undefined }, + trade: { + from: '0xaccount1', + to: '0xbridgeContract', + value: '0x0', + data: '0xdata', + chainId: 42161, + gasLimit: 21000, + }, + approval: { + from: '0xaccount1', + to: '0xtokenContract', + value: '0x0', + data: '0xapprovalData', + chainId: 42161, + gasLimit: 21000, + }, + } as const; + + const mockEvmTxMeta = { + id: 'test-tx-id', + hash: '0xevmTxHash', + time: 1234567890, + status: 'unapproved', + type: TransactionType.swap, + chainId: '0xa4b1', // 42161 in hex + txParams: { + from: '0xaccount1', + to: '0xbridgeContract', + value: '0x0', + data: '0xdata', + chainId: '0xa4b1', + gasLimit: '0x5208', + }, + }; + + const mockApprovalTxMeta = { + id: 'test-approval-tx-id', + hash: '0xapprovalTxHash', + time: 1234567890, + status: 'unapproved', + type: TransactionType.swapApproval, + chainId: '0xa4b1', // 42161 in hex + txParams: { + from: '0xaccount1', + to: '0xtokenContract', + value: '0x0', + data: '0xapprovalData', + chainId: '0xa4b1', + gasLimit: '0x5208', + }, + }; + + const mockEstimateGasFeeResult = { + estimates: { + type: GasFeeEstimateType.FeeMarket, + high: { + suggestedMaxFeePerGas: '0x1234', + suggestedMaxPriorityFeePerGas: '0x5678', + }, + }, + }; + let mockMessengerCall: jest.Mock; + + beforeEach(() => { + jest.clearAllMocks(); + mockMessengerCall = jest.fn(); + jest.spyOn(Date, 'now').mockReturnValueOnce(1234567890); + jest.spyOn(Date, 'now').mockReturnValueOnce(1234567891); + jest.spyOn(Date, 'now').mockReturnValueOnce(1234567892); + jest.spyOn(Math, 'random').mockReturnValueOnce(0.456); + jest.spyOn(Math, 'random').mockReturnValueOnce(0.457); + mockMessengerCall.mockImplementationOnce(jest.fn()); // stopPollingForQuotes + }); + + const setupEventTrackingMocks = (mockCall: jest.Mock) => { + mockCall.mockReturnValueOnce(mockSelectedAccount); + mockCall.mockImplementationOnce(jest.fn()); // track event + mockCall.mockReturnValueOnce([]); // isAtomicBatchSupported + }; + + const setupApprovalMocks = () => { + mockMessengerCall.mockReturnValueOnce(mockSelectedAccount); + mockMessengerCall.mockReturnValueOnce('arbitrum-client-id'); + mockMessengerCall.mockResolvedValueOnce(mockEstimateGasFeeResult); + mockMessengerCall.mockResolvedValueOnce({ + transactionMeta: mockApprovalTxMeta, + result: Promise.resolve('0xapprovalTxHash'), + }); + mockMessengerCall.mockReturnValueOnce({ + transactions: [mockApprovalTxMeta], + }); + }; + + const setupBridgeMocks = () => { + mockMessengerCall.mockReturnValueOnce(mockSelectedAccount); + mockMessengerCall.mockReturnValueOnce('arbitrum'); + mockMessengerCall.mockResolvedValueOnce(mockEstimateGasFeeResult); + mockMessengerCall.mockResolvedValueOnce({ + transactionMeta: mockEvmTxMeta, + result: Promise.resolve('0xevmTxHash'), + }); + mockMessengerCall.mockReturnValueOnce({ + transactions: [mockEvmTxMeta], + }); + + mockMessengerCall.mockReturnValueOnce(mockSelectedAccount); + }; + + it('should successfully submit an EVM swap transaction with approval', async () => { + setupEventTrackingMocks(mockMessengerCall); + setupApprovalMocks(); + setupBridgeMocks(); + + await withController( + { mockMessengerCall }, + async ({ + controller, + rootMessenger, + startPollingForBridgeTxStatusSpy, + }) => { + const result = await rootMessenger.call( + 'BridgeStatusController:submitTx', + (mockEvmQuoteResponse.trade as TxData).from, + mockEvmQuoteResponse, + false, + ); + controller.stopAllPolling(); + + expect(result).toMatchSnapshot(); + expect(startPollingForBridgeTxStatusSpy).toHaveBeenCalledTimes(0); + const { approvalTxId } = controller.state.txHistory[result.id]; + expect(approvalTxId).toBe('test-approval-tx-id'); + expect( + mockMessengerCall.mock.calls.filter( + ([action]) => action === 'TransactionController:addTransaction', + ), + ).toHaveLength(2); + expect(mockMessengerCall).toHaveBeenCalledTimes(14); + }, + ); + }); + + it('should successfully submit an EVM swap transaction with featureId=perps', async () => { + mockMessengerCall.mockReturnValueOnce(mockSelectedAccount); + mockMessengerCall.mockReturnValueOnce([]); // isAtomicBatchSupported + setupApprovalMocks(); + setupBridgeMocks(); + + await withController( + { mockMessengerCall }, + async ({ + controller, + rootMessenger, + startPollingForBridgeTxStatusSpy, + }) => { + const result = await rootMessenger.call( + 'BridgeStatusController:submitTx', + (mockEvmQuoteResponse.trade as TxData).from, + { + ...mockEvmQuoteResponse, + featureId: FeatureId.PERPS, + }, + false, + ); + controller.stopAllPolling(); + + expect(result).toMatchSnapshot(); + expect(startPollingForBridgeTxStatusSpy).toHaveBeenCalledTimes(0); + const { approvalTxId } = controller.state.txHistory[result.id]; + expect(approvalTxId).toBe('test-approval-tx-id'); + expect(controller.state.txHistory[result.id].featureId).toBe( + FeatureId.PERPS, + ); + expect(mockMessengerCall.mock.calls).toMatchSnapshot(); + }, + ); + }); + + it('should handle a gasless swap transaction with approval', async () => { + setupEventTrackingMocks(mockMessengerCall); + mockMessengerCall.mockReturnValueOnce(mockSelectedAccount); + mockMessengerCall.mockReturnValueOnce('arbitrum'); + addTransactionBatchFn.mockResolvedValueOnce({ + batchId: 'batchId1', + }); + mockMessengerCall.mockReturnValueOnce({ + transactions: [{ ...mockEvmTxMeta, batchId: 'batchId1' }], + }); + + const getAddTransactionBatchParamsSpy = jest.spyOn( + transactionUtils, + 'getAddTransactionBatchParams', + ); + + await withController( + { mockMessengerCall }, + async ({ + controller, + rootMessenger, + startPollingForBridgeTxStatusSpy, + }) => { + const result = await rootMessenger.call( + 'BridgeStatusController:submitTx', + (mockEvmQuoteResponse.trade as TxData).from, + { + ...mockEvmQuoteResponse, + quote: { + ...mockEvmQuoteResponse.quote, + gasIncluded: true, + feeData: { + ...mockEvmQuoteResponse.quote.feeData, + txFee: { + amount: '100', + asset: getNativeAssetForChainId(42161), + maxFeePerGas: '123', + maxPriorityFeePerGas: '123', + }, + }, + }, + }, + true, + ); + controller.stopAllPolling(); + + const { txParams, ...resultsToCheck } = result; + expect(resultsToCheck).toMatchInlineSnapshot(` + { + "batchId": "batchId1", + "chainId": "0xa4b1", + "hash": "0xevmTxHash", + "id": "test-tx-id", + "status": "unapproved", + "time": 1234567890, + "type": "swap", + } + `); + expect(startPollingForBridgeTxStatusSpy).toHaveBeenCalledTimes(0); + expect(addTransactionBatchFn).toHaveBeenCalledTimes(1); + expect( + mockMessengerCall.mock.calls.filter( + ([action]) => action === 'TransactionController:addTransaction', + ), + ).toHaveLength(0); + expect(mockMessengerCall).toHaveBeenCalledTimes(8); + expect(controller.state.txHistory[result.id]).toMatchSnapshot(); + }, + ); + + const { messenger, tradeData, ...params } = + getAddTransactionBatchParamsSpy.mock.calls[0][0]; + expect(params).toMatchInlineSnapshot(` + { + "atomic": true, + "disable7702": true, + "isDelegatedAccount": false, + "isGasFeeIncluded": false, + "isGasFeeSponsored": false, + "requireApproval": false, + } + `); + }); + + it('should handle a gasless swap transaction with fees paid in ERC20', async () => { + setupEventTrackingMocks(mockMessengerCall); + mockMessengerCall.mockReturnValueOnce(mockSelectedAccount); + mockMessengerCall.mockReturnValueOnce('arbitrum'); + addTransactionBatchFn.mockResolvedValueOnce({ + batchId: 'batchId1', + }); + mockMessengerCall.mockReturnValueOnce({ + transactions: [{ ...mockEvmTxMeta, batchId: 'batchId1' }], + }); + + const getAddTransactionBatchParamsSpy = jest.spyOn( + transactionUtils, + 'getAddTransactionBatchParams', + ); + + await withController( + { mockMessengerCall }, + async ({ + controller, + rootMessenger, + startPollingForBridgeTxStatusSpy, + }) => { + const result = await rootMessenger.call( + 'BridgeStatusController:submitTx', + (mockEvmQuoteResponse.trade as TxData).from, + { + ...mockEvmQuoteResponse, + quote: { + ...mockEvmQuoteResponse.quote, + gasIncluded: true, + feeData: { + ...mockEvmQuoteResponse.quote.feeData, + txFee: { + amount: '100', + asset: { + address: '0x0000000000000000000000000000000000000032', + symbol: 'WETH', + chainId: 10, + assetId: 'eip155:10/slip44:60', + name: 'WETH', + decimals: 18, + }, + maxFeePerGas: '123', + maxPriorityFeePerGas: '123', + }, + }, + }, + }, + true, + ); + controller.stopAllPolling(); + + expect(startPollingForBridgeTxStatusSpy).toHaveBeenCalledTimes(0); + expect(addTransactionBatchFn).toHaveBeenCalledTimes(1); + expect( + mockMessengerCall.mock.calls.filter( + ([action]) => action === 'TransactionController:addTransaction', + ), + ).toHaveLength(0); + expect(mockMessengerCall).toHaveBeenCalledTimes(8); + const { quote, ...history } = controller.state.txHistory[result.id]; + expect(history).toMatchSnapshot(); + }, + ); + + const { messenger, tradeData, ...params } = + getAddTransactionBatchParamsSpy.mock.calls[0][0]; + expect(params).toMatchInlineSnapshot(` + { + "atomic": true, + "disable7702": true, + "isDelegatedAccount": false, + "isGasFeeIncluded": false, + "isGasFeeSponsored": false, + "requireApproval": false, + } + `); + }); + + it('should successfully submit an EVM swap transaction with no approval', async () => { + setupEventTrackingMocks(mockMessengerCall); + setupBridgeMocks(); + + await withController( + { mockMessengerCall }, + async ({ + controller, + rootMessenger, + startPollingForBridgeTxStatusSpy, + }) => { + const erc20Token = { + address: '0x0000000000000000000000000000000000000032', + assetId: `eip155:10/slip44:60` as CaipAssetType, + chainId: 10, + symbol: 'WETH', + decimals: 18, + name: 'WETH', + coinKey: 'WETH', + logoURI: + 'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png', + priceUSD: '2478.63', + icon: 'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png', + }; + const { approval, ...quoteWithoutApproval } = mockEvmQuoteResponse; + const result = await rootMessenger.call( + 'BridgeStatusController:submitTx', + (mockEvmQuoteResponse.trade as TxData).from, + { + ...quoteWithoutApproval, + quote: { ...quoteWithoutApproval.quote, destAsset: erc20Token }, + gasFee: undefined as never, + }, + false, + ); + + expect(result).toMatchSnapshot(); + expect(startPollingForBridgeTxStatusSpy).toHaveBeenCalledTimes(0); + expect(controller.state.txHistory[result.id]).toMatchSnapshot(); + expect(mockMessengerCall.mock.calls).toMatchSnapshot(); + }, + ); + }); + + it('should use quote txFee when gasIncluded is true and STX is off (Max native token swap)', async () => { + setupEventTrackingMocks(mockMessengerCall); + // Setup for single tx path - no gas estimation needed since gasIncluded=true + mockMessengerCall.mockReturnValueOnce(mockSelectedAccount); + mockMessengerCall.mockReturnValueOnce('arbitrum'); + // Skip GasFeeController mock since we use quote's txFee directly + mockMessengerCall.mockResolvedValueOnce({ + transactionMeta: mockEvmTxMeta, + result: Promise.resolve('0xevmTxHash'), + }); + mockMessengerCall.mockReturnValueOnce({ + transactions: [mockEvmTxMeta], + }); + mockMessengerCall.mockReturnValueOnce(mockSelectedAccount); + + await withController( + { mockMessengerCall }, + async ({ + controller, + rootMessenger, + startPollingForBridgeTxStatusSpy, + }) => { + const { approval, ...quoteWithoutApproval } = mockEvmQuoteResponse; + const result = await rootMessenger.call( + 'BridgeStatusController:submitTx', + (mockEvmQuoteResponse.trade as TxData).from, + { + ...quoteWithoutApproval, + quote: { + ...quoteWithoutApproval.quote, + gasIncluded: true, + gasIncluded7702: false, + feeData: { + ...quoteWithoutApproval.quote.feeData, + txFee: { + amount: + quoteWithoutApproval.quote.feeData.metabridge.amount, + asset: quoteWithoutApproval.quote.feeData.metabridge.asset, + maxFeePerGas: '1395348', // Decimal string from quote + maxPriorityFeePerGas: '1000001', + }, + }, + }, + }, + false, // isStxEnabledOnClient = FALSE (key for this test) + ); + controller.stopAllPolling(); + + const mockCalls = mockMessengerCall.mock.calls; + + // Should use single tx path (addTransactionFn), NOT batch path + const addTransactionCalls = mockCalls.filter( + ([action]) => action === 'TransactionController:addTransaction', + ); + expect(addTransactionCalls).toHaveLength(1); + // Should NOT estimate gas (uses quote's txFee instead) + const estimateGasFeeCalls = mockCalls.filter( + ([action]) => action === 'TransactionController:estimateGasFee', + ); + expect(estimateGasFeeCalls).toHaveLength(0); + + // Verify the tx params have hex-converted gas fees from quote + const txParams = addTransactionCalls[0]?.[1]; + expect(txParams.maxFeePerGas).toBe('0x154a94'); // toHex(1395348) + expect(txParams.maxPriorityFeePerGas).toBe('0xf4241'); // toHex(1000001) + expect(txParams.gas).toBe('0x5208'); + + expect(result).toMatchSnapshot(); + expect(startPollingForBridgeTxStatusSpy).toHaveBeenCalledTimes(0); + expect(controller.state.txHistory[result.id]).toMatchSnapshot(); + }, + ); + }); + + it('should use quote txFee when gasIncluded is true and STX is off (null gasLimit)', async () => { + setupEventTrackingMocks(mockMessengerCall); + // Setup for single tx path - no gas estimation needed since gasIncluded=true + mockMessengerCall.mockReturnValueOnce(mockSelectedAccount); + mockMessengerCall.mockReturnValueOnce('arbitrum'); + // Skip GasFeeController mock since we use quote's txFee directly + mockMessengerCall.mockResolvedValueOnce({ + transactionMeta: mockEvmTxMeta, + result: Promise.resolve('0xevmTxHash'), + }); + mockMessengerCall.mockReturnValueOnce({ + transactions: [mockEvmTxMeta], + }); + mockMessengerCall.mockReturnValueOnce(mockSelectedAccount); + + const { approval, ...quoteWithoutApproval } = mockEvmQuoteResponse; + const mockQuote = { + ...quoteWithoutApproval, + quote: { + ...quoteWithoutApproval.quote, + slippage: 0.01, + gasIncluded: true, + gasIncluded7702: false, + feeData: { + ...quoteWithoutApproval.quote.feeData, + txFee: { + amount: '100', + asset: mockEvmQuoteResponse.quote.feeData.metabridge.asset, + maxFeePerGas: '1395348', // Decimal string from quote + maxPriorityFeePerGas: '1000001', + }, + }, + }, + trade: { + ...(quoteWithoutApproval.trade as TxData), + gasLimit: null, + }, + sentAmount: { + amount: undefined, + valueInCurrency: undefined, + usd: undefined, + }, + }; + validateQuoteResponseV1(mockQuote); + + await withController( + { mockMessengerCall }, + async ({ + controller, + rootMessenger, + startPollingForBridgeTxStatusSpy, + }) => { + const result = await rootMessenger.call( + 'BridgeStatusController:submitTx', + (mockEvmQuoteResponse.trade as TxData).from, + mockQuote, + false, // isStxEnabledOnClient = FALSE (key for this test) + ); + controller.stopAllPolling(); + + const mockCalls = mockMessengerCall.mock.calls; + + // Should NOT estimate gas (uses quote's txFee instead) + expect( + mockCalls.filter( + ([action]) => action === 'TransactionController:estimateGasFee', + ), + ).toHaveLength(0); + expect( + mockCalls.filter( + ([action]) => + action === 'TransactionController:addTransactionBatch', + ), + ).toHaveLength(0); + + // Should use single tx path (addTransactionFn), NOT batch path + const addTransactionCalls = mockCalls.filter( + ([action]) => action === 'TransactionController:addTransaction', + ); + expect(addTransactionCalls).toHaveLength(1); + // Verify the tx params have hex-converted gas fees from quote + const txParams = addTransactionCalls[0]?.[1]; + expect(txParams.maxFeePerGas).toBe('0x154a94'); // toHex(1395348) + expect(txParams.maxPriorityFeePerGas).toBe('0xf4241'); // toHex(1000001) + expect(txParams.gas).toBeUndefined(); + + expect(result).toMatchSnapshot(); + expect(startPollingForBridgeTxStatusSpy).toHaveBeenCalledTimes(0); + expect(controller.state.txHistory[result.id]).toMatchSnapshot(); + }, + ); + }); + + it('should estimate gas when gasIncluded is false and STX is off', async () => { + setupEventTrackingMocks(mockMessengerCall); + setupBridgeMocks(); + await withController( + { mockMessengerCall }, + async ({ + controller, + rootMessenger, + startPollingForBridgeTxStatusSpy, + }) => { + const { approval, ...quoteWithoutApproval } = mockEvmQuoteResponse; + const result = await rootMessenger.call( + 'BridgeStatusController:submitTx', + (mockEvmQuoteResponse.trade as TxData).from, + { + ...quoteWithoutApproval, + quote: { + ...quoteWithoutApproval.quote, + gasIncluded: false, + gasIncluded7702: false, + }, + }, + false, // STX off + ); + controller.stopAllPolling(); + + // Should estimate gas since gasIncluded is false + const mockCalls = mockMessengerCall.mock.calls; + expect( + mockCalls.filter( + ([action]) => action === 'TransactionController:estimateGasFee', + ), + ).toHaveLength(1); + expect( + mockCalls.filter( + ([action]) => action === 'TransactionController:addTransaction', + ), + ).toHaveLength(1); + expect( + mockCalls.filter( + ([action]) => + action === 'TransactionController:addTransactionBatch', + ), + ).toHaveLength(0); + expect(startPollingForBridgeTxStatusSpy).toHaveBeenCalledTimes(0); + expect(result).toMatchSnapshot(); + }, + ); + }); + + it('should use batch path when account is delegated', async () => { + setupEventTrackingMocks(mockMessengerCall); + mockMessengerCall.mockReturnValueOnce(mockSelectedAccount); + mockMessengerCall.mockReturnValueOnce('arbitrum'); + addTransactionBatchFn.mockResolvedValueOnce({ + batchId: 'batchId1', + }); + mockMessengerCall.mockReturnValueOnce({ + transactions: [{ ...mockEvmTxMeta, batchId: 'batchId1' }], + }); + + const checkIsDelegatedAccountSpy = jest + .spyOn(transactionUtils, 'checkIsDelegatedAccount') + .mockResolvedValueOnce(true); + + const getAddTransactionBatchParamsSpy = jest.spyOn( + transactionUtils, + 'getAddTransactionBatchParams', + ); + + await withController( + { mockMessengerCall }, + async ({ + controller, + rootMessenger, + startPollingForBridgeTxStatusSpy, + }) => { + const { approval, ...quoteWithoutApproval } = mockEvmQuoteResponse; + const result = await rootMessenger.call( + 'BridgeStatusController:submitTx', + (mockEvmQuoteResponse.trade as TxData).from, + { + ...quoteWithoutApproval, + quote: { + ...quoteWithoutApproval.quote, + gasIncluded: false, + gasIncluded7702: false, + }, + }, + false, // STX off + ); + controller.stopAllPolling(); + + // Should use batch path because gasIncluded7702 = true + expect(addTransactionBatchFn).toHaveBeenCalledTimes(1); + const mockCalls = mockMessengerCall.mock.calls; + expect( + mockCalls.filter( + ([action]) => action === 'TransactionController:addTransaction', + ), + ).toHaveLength(0); + expect(startPollingForBridgeTxStatusSpy).toHaveBeenCalledTimes(0); + expect(result).toMatchSnapshot(); + }, + ); + + expect(checkIsDelegatedAccountSpy).toHaveBeenCalledTimes(1); + + const { messenger, tradeData, ...params } = + getAddTransactionBatchParamsSpy.mock.calls[0][0]; + expect(params).toMatchInlineSnapshot(` + { + "atomic": true, + "disable7702": false, + "isDelegatedAccount": true, + "isGasFeeIncluded": false, + "isGasFeeSponsored": false, + "requireApproval": false, + } + `); + }); + + it('should use batch path when gasIncluded7702 is true regardless of STX setting', async () => { + setupEventTrackingMocks(mockMessengerCall); + mockMessengerCall.mockReturnValueOnce(mockSelectedAccount); + mockMessengerCall.mockReturnValueOnce('arbitrum'); + addTransactionBatchFn.mockResolvedValueOnce({ + batchId: 'batchId1', + }); + mockMessengerCall.mockReturnValueOnce({ + transactions: [{ ...mockEvmTxMeta, batchId: 'batchId1' }], + }); + + await withController( + { mockMessengerCall }, + async ({ + controller, + rootMessenger, + startPollingForBridgeTxStatusSpy, + }) => { + const { approval, ...quoteWithoutApproval } = mockEvmQuoteResponse; + const result = await rootMessenger.call( + 'BridgeStatusController:submitTx', + (mockEvmQuoteResponse.trade as TxData).from, + { + ...quoteWithoutApproval, + quote: { + ...quoteWithoutApproval.quote, + gasIncluded: true, + gasIncluded7702: true, // 7702 takes precedence → batch path + feeData: { + ...quoteWithoutApproval.quote.feeData, + txFee: { + amount: + quoteWithoutApproval.quote.feeData.metabridge.amount, + asset: quoteWithoutApproval.quote.feeData.metabridge.asset, + maxFeePerGas: '1395348', + maxPriorityFeePerGas: '1000001', + }, + }, + }, + }, + false, // STX off, but gasIncluded7702 = true forces batch path + ); + controller.stopAllPolling(); + + // Should use batch path because gasIncluded7702 = true + expect(addTransactionBatchFn).toHaveBeenCalledTimes(1); + const mockCalls = mockMessengerCall.mock.calls; + expect( + mockCalls.filter( + ([action]) => action === 'TransactionController:addTransaction', + ), + ).toHaveLength(0); + expect(startPollingForBridgeTxStatusSpy).toHaveBeenCalledTimes(0); + expect(result).toMatchSnapshot(); + }, + ); + }); + + it('should use batch path when gasIncluded7702 is true regardless of STX setting (with approval)', async () => { + setupEventTrackingMocks(mockMessengerCall); + mockMessengerCall.mockReturnValueOnce(mockSelectedAccount); + mockMessengerCall.mockReturnValueOnce('arbitrum'); + addTransactionBatchFn.mockResolvedValueOnce({ + batchId: 'batchId1', + }); + mockMessengerCall.mockReturnValueOnce({ + transactions: [ + { ...mockApprovalTxMeta, batchId: 'batchId1' }, + { ...mockEvmTxMeta, batchId: 'batchId1' }, + ], + }); + mockMessengerCall.mockReturnValueOnce({ + transactions: [ + { ...mockApprovalTxMeta, batchId: 'batchId1' }, + { ...mockEvmTxMeta, batchId: 'batchId1' }, + ], + }); + + const getAddTransactionBatchParamsSpy = jest.spyOn( + transactionUtils, + 'getAddTransactionBatchParams', + ); + + await withController( + { mockMessengerCall }, + async ({ controller, rootMessenger }) => { + const result = await rootMessenger.call( + 'BridgeStatusController:submitTx', + (mockEvmQuoteResponse.trade as TxData).from, + { + ...mockEvmQuoteResponse, + quote: { + ...mockEvmQuoteResponse.quote, + gasIncluded: true, + gasIncluded7702: true, // 7702 takes precedence → batch path + feeData: { + ...mockEvmQuoteResponse.quote.feeData, + txFee: { + amount: + mockEvmQuoteResponse.quote.feeData.metabridge.amount, + asset: mockEvmQuoteResponse.quote.feeData.metabridge.asset, + maxFeePerGas: '1395348', + maxPriorityFeePerGas: '1000001', + }, + }, + }, + }, + false, // STX off, but gasIncluded7702 = true forces batch path + ); + controller.stopAllPolling(); + + expect(result).toMatchSnapshot(); + }, + ); + + const { messenger, tradeData, ...params } = + getAddTransactionBatchParamsSpy.mock.calls[0][0]; + expect(params).toMatchInlineSnapshot(` + { + "atomic": true, + "disable7702": false, + "isDelegatedAccount": false, + "isGasFeeIncluded": true, + "isGasFeeSponsored": false, + "requireApproval": false, + } + `); + }); + + it('should handle smart transactions', async () => { + setupEventTrackingMocks(mockMessengerCall); + mockMessengerCall.mockReturnValueOnce(mockSelectedAccount); + mockMessengerCall.mockReturnValueOnce('arbitrum'); + mockMessengerCall.mockResolvedValueOnce(mockEstimateGasFeeResult); + mockMessengerCall.mockResolvedValueOnce(mockEstimateGasFeeResult); + addTransactionBatchFn.mockResolvedValueOnce({ + batchId: 'batchId1', + }); + mockMessengerCall.mockReturnValueOnce({ + transactions: [{ ...mockEvmTxMeta, batchId: 'batchId1' }], + }); + + const getAddTransactionBatchParamsSpy = jest.spyOn( + transactionUtils, + 'getAddTransactionBatchParams', + ); + + await withController( + { mockMessengerCall }, + async ({ + controller, + rootMessenger, + startPollingForBridgeTxStatusSpy, + }) => { + const result = await rootMessenger.call( + 'BridgeStatusController:submitTx', + (mockEvmQuoteResponse.trade as TxData).from, + mockEvmQuoteResponse, + true, + ); + controller.stopAllPolling(); + + expect(result).toMatchSnapshot(); + expect(startPollingForBridgeTxStatusSpy).toHaveBeenCalledTimes(0); + expect(controller.state.txHistory[result.id]).toMatchSnapshot(); + expect(addTransactionBatchFn.mock.calls).toMatchSnapshot(); + expect(mockMessengerCall.mock.calls).toMatchSnapshot(); + }, + ); + + const { messenger, tradeData, ...params } = + getAddTransactionBatchParamsSpy.mock.calls[0][0]; + expect(params).toMatchInlineSnapshot(` + { + "atomic": true, + "disable7702": true, + "isDelegatedAccount": false, + "isGasFeeIncluded": false, + "isGasFeeSponsored": false, + "requireApproval": false, + } + `); + }); + + it('should throw error if account is not found', async () => { + setupEventTrackingMocks(mockMessengerCall); + mockMessengerCall.mockReturnValueOnce(undefined); + + await withController( + { mockMessengerCall }, + async ({ + controller, + rootMessenger, + startPollingForBridgeTxStatusSpy, + }) => { + await expect( + rootMessenger.call( + 'BridgeStatusController:submitTx', + (mockEvmQuoteResponse.trade as TxData).from, + mockEvmQuoteResponse, + true, + ), + ).rejects.toThrow( + 'Failed to submit cross-chain swap batch transaction: unknown account in trade data', + ); + controller.stopAllPolling(); + + expect(startPollingForBridgeTxStatusSpy).not.toHaveBeenCalled(); + const mockCalls = mockMessengerCall.mock.calls; + expect( + mockCalls.filter( + ([action]) => action === 'TransactionController:estimateGasFee', + ), + ).toHaveLength(0); + expect( + mockCalls.filter( + ([action]) => action === 'TransactionController:addTransaction', + ), + ).toHaveLength(0); + expect( + mockCalls.filter( + ([action]) => + action === 'TransactionController:addTransactionBatch', + ), + ).toHaveLength(0); + expect(mockMessengerCall).toHaveBeenCalledTimes(6); + expect( + mockCalls.find( + ([action, eventName]) => + action === 'BridgeController:trackUnifiedSwapBridgeEvent' && + eventName === UnifiedSwapBridgeEventName.Failed, + ), + ).toMatchInlineSnapshot(` + [ + "BridgeController:trackUnifiedSwapBridgeEvent", + "Unified SwapBridge Failed", + { + "account_hardware_type": null, + "action_type": "swapbridge-v1", + "chain_id_destination": "eip155:42161", + "chain_id_source": "eip155:42161", + "custom_slippage": false, + "error_message": "Failed to submit cross-chain swap batch transaction: unknown account in trade data", + "feature_id": "unified_swap_bridge", + "gas_included": false, + "gas_included_7702": false, + "is_hardware_wallet": false, + "location": "Unknown", + "price_impact": 0, + "provider": "lifi_across", + "quoted_time_minutes": 0, + "slippage_limit": 0, + "stx_enabled": true, + "swap_type": "single_chain", + "token_address_destination": "eip155:10/slip44:60", + "token_address_source": "eip155:42161/slip44:60", + "token_security_type_destination": null, + "token_symbol_destination": "ETH", + "token_symbol_source": "ETH", + "usd_amount_source": 1.01, + "usd_quoted_gas": 2.5778, + "usd_quoted_return": 0, + }, + ] + `); + }, + ); + }); + + it('should throw error if batched tx is not found', async () => { + setupEventTrackingMocks(mockMessengerCall); + mockMessengerCall.mockReturnValueOnce(mockSelectedAccount); + mockMessengerCall.mockReturnValueOnce('arbitrum'); + mockMessengerCall.mockResolvedValueOnce(mockEstimateGasFeeResult); + mockMessengerCall.mockResolvedValueOnce(mockEstimateGasFeeResult); + addTransactionBatchFn.mockResolvedValueOnce({ + batchId: 'batchId1', + }); + mockMessengerCall.mockReturnValueOnce({ + transactions: [{ ...mockEvmTxMeta, batchId: 'batchIdUnknown' }], + }); + + await withController( + { mockMessengerCall }, + async ({ + controller, + rootMessenger, + startPollingForBridgeTxStatusSpy, + }) => { + await expect( + rootMessenger.call( + 'BridgeStatusController:submitTx', + (mockEvmQuoteResponse.trade as TxData).from, + mockEvmQuoteResponse, + true, + ), + ).rejects.toThrow( + 'Failed to update cross-chain swap transaction batch: tradeMeta not found', + ); + controller.stopAllPolling(); + + expect(startPollingForBridgeTxStatusSpy).toHaveBeenCalledTimes(0); + const mockCalls = mockMessengerCall.mock.calls; + expect( + mockCalls.filter( + ([action]) => action === 'TransactionController:estimateGasFee', + ), + ).toHaveLength(2); + expect( + mockCalls.filter( + ([action]) => action === 'TransactionController:addTransaction', + ), + ).toHaveLength(0); + expect(addTransactionBatchFn).toHaveBeenCalledTimes(1); + expect(mockMessengerCall).toHaveBeenCalledTimes(10); + expect( + mockCalls.find( + ([action, eventName]) => + action === 'BridgeController:trackUnifiedSwapBridgeEvent' && + eventName === UnifiedSwapBridgeEventName.Failed, + ), + ).toMatchInlineSnapshot(` + [ + "BridgeController:trackUnifiedSwapBridgeEvent", + "Unified SwapBridge Failed", + { + "account_hardware_type": null, + "action_type": "swapbridge-v1", + "chain_id_destination": "eip155:42161", + "chain_id_source": "eip155:42161", + "custom_slippage": false, + "error_message": "Failed to update cross-chain swap transaction batch: tradeMeta not found", + "feature_id": "unified_swap_bridge", + "gas_included": false, + "gas_included_7702": false, + "is_hardware_wallet": false, + "location": "Unknown", + "price_impact": 0, + "provider": "lifi_across", + "quoted_time_minutes": 0, + "slippage_limit": 0, + "stx_enabled": true, + "swap_type": "single_chain", + "token_address_destination": "eip155:10/slip44:60", + "token_address_source": "eip155:42161/slip44:60", + "token_security_type_destination": null, + "token_symbol_destination": "ETH", + "token_symbol_source": "ETH", + "usd_amount_source": 1.01, + "usd_quoted_gas": 2.5778, + "usd_quoted_return": 0, + }, + ] + `); + }, + ); + }); + + it('should gracefully handle isAtomicBatchSupported failure', async () => { + // Manually set up mocks without setupEventTrackingMocks + // to control the isAtomicBatchSupported mock + mockMessengerCall.mockReturnValueOnce(mockSelectedAccount); // getAccountByAddress + mockMessengerCall.mockImplementationOnce(jest.fn()); // track event + mockMessengerCall.mockRejectedValueOnce( + new Error('isAtomicBatchSupported failed'), + ); // isAtomicBatchSupported throws + setupApprovalMocks(); + setupBridgeMocks(); + + await withController( + { mockMessengerCall }, + async ({ controller, rootMessenger }) => { + const result = await rootMessenger.call( + 'BridgeStatusController:submitTx', + (mockEvmQuoteResponse.trade as TxData).from, + mockEvmQuoteResponse, + false, // STX disabled - uses non-batch path + ); + controller.stopAllPolling(); + + // Should fall back to non-batch path when isAtomicBatchSupported throws + const mockCalls = mockMessengerCall.mock.calls; + expect( + mockCalls.filter( + ([action]) => action === 'TransactionController:estimateGasFee', + ), + ).toHaveLength(2); + expect( + mockCalls.filter( + ([action]) => action === 'TransactionController:addTransaction', + ), + ).toHaveLength(2); + expect(mockMessengerCall).toHaveBeenCalledTimes(14); + expect(addTransactionBatchFn).not.toHaveBeenCalled(); + expect(mockCalls).toMatchSnapshot(); + expect(result).toMatchInlineSnapshot(` + { + "chainId": "0xa4b1", + "hash": "0xevmTxHash", + "id": "test-tx-id", + "status": "unapproved", + "time": 1234567890, + "txParams": { + "chainId": "0xa4b1", + "data": "0xdata", + "from": "0xaccount1", + "gasLimit": "0x5208", + "to": "0xbridgeContract", + "value": "0x0", + }, + "type": "swap", + } + `); + }, + ); + }); + }); + + describe('resetAttempts', () => { + const defaultState = { + txHistory: { + ...MockTxHistory.getPending({ + txMetaId: 'bridgeTxMetaId1', + srcTxHash: '0xsrcTxHash1', + }), + ...MockTxHistory.getPendingSwap({ + txMetaId: 'swapTxMetaId1', + srcTxHash: '0xswapTxHash1', + }), + }, + }; + + describe('success cases', () => { + it('should reset attempts by txMetaId for bridge transaction', async () => { + await withController( + { + options: { + state: { + txHistory: { + bridgeTxMetaId1: { + ...MockTxHistory.getPending({ txMetaId: 'bridgeTxMetaId1' }) + .bridgeTxMetaId1, + attempts: { counter: 5, lastAttemptTime: Date.now() }, + }, + }, + }, + }, + }, + async ({ controller, rootMessenger }) => { + registerDefaultActionHandlers(rootMessenger); + expect( + controller.state.txHistory.bridgeTxMetaId1.attempts?.counter, + ).toBe(5); + + rootMessenger.call( + 'BridgeStatusController:restartPollingForFailedAttempts', + { txMetaId: 'bridgeTxMetaId1' }, + ); + + expect( + controller.state.txHistory.bridgeTxMetaId1.attempts, + ).toBeUndefined(); + }, + ); + }); + + it('should reset attempts by txHash for bridge transaction', async () => { + await withController( + { + options: { + state: { + txHistory: { + bridgeTxMetaId1: { + ...MockTxHistory.getPending({ txMetaId: 'bridgeTxMetaId1' }) + .bridgeTxMetaId1, + attempts: { counter: 3, lastAttemptTime: Date.now() }, + }, + }, + }, + }, + }, + async ({ controller, rootMessenger }) => { + registerDefaultActionHandlers(rootMessenger); + expect( + controller.state.txHistory.bridgeTxMetaId1.attempts?.counter, + ).toBe(3); + + rootMessenger.call( + 'BridgeStatusController:restartPollingForFailedAttempts', + { txHash: '0xsrcTxHash1' }, + ); + + expect( + controller.state.txHistory.bridgeTxMetaId1.attempts, + ).toBeUndefined(); + }, + ); + }); + + it('should prioritize txMetaId when both txMetaId and txHash are provided', async () => { + await withController( + { + options: { + state: { + txHistory: { + bridgeTxMetaId1: { + ...MockTxHistory.getPending({ txMetaId: 'bridgeTxMetaId1' }) + .bridgeTxMetaId1, + attempts: { counter: 3, lastAttemptTime: Date.now() }, + }, + swapTxMetaId1: { + ...MockTxHistory.getPendingSwap({ + txMetaId: 'swapTxMetaId1', + }).swapTxMetaId1, + attempts: { counter: 5, lastAttemptTime: Date.now() }, + }, + }, + }, + }, + }, + async ({ controller, rootMessenger }) => { + registerDefaultActionHandlers(rootMessenger); + // Execute with both identifiers - should use txMetaId (bridgeTxMetaId1) + rootMessenger.call( + 'BridgeStatusController:restartPollingForFailedAttempts', + { txMetaId: 'bridgeTxMetaId1', txHash: '0xswapTxHash1' }, + ); + + // Assert - only bridgeTxMetaId1 should have attempts reset + expect( + controller.state.txHistory.bridgeTxMetaId1.attempts, + ).toBeUndefined(); + expect( + controller.state.txHistory.swapTxMetaId1.attempts?.counter, + ).toBe(5); + }, + ); + }); + + it('should restart polling for bridge transaction when attempts are reset', async () => { + jest.useFakeTimers(); + const fetchBridgeTxStatusSpy = jest.spyOn( + bridgeStatusUtils, + 'fetchBridgeTxStatus', + ); + fetchBridgeTxStatusSpy + .mockImplementationOnce(async () => ({ + status: MockStatusResponse.getPending(), + validationFailures: [], + })) + .mockImplementationOnce(async () => ({ + status: MockStatusResponse.getPending(), + validationFailures: [], + })); + + await withController( + { + options: { + state: { + txHistory: { + bridgeTxMetaId1: { + ...MockTxHistory.getPending({ txMetaId: 'bridgeTxMetaId1' }) + .bridgeTxMetaId1, + attempts: { + counter: MAX_ATTEMPTS + 1, + lastAttemptTime: Date.now() - 60000, + }, + }, + }, + }, + }, + }, + async ({ controller, rootMessenger }) => { + registerDefaultActionHandlers(rootMessenger); + + expect( + controller.state.txHistory.bridgeTxMetaId1.attempts?.counter, + ).toBe(MAX_ATTEMPTS + 1); + + rootMessenger.call( + 'BridgeStatusController:restartPollingForFailedAttempts', + { txMetaId: 'bridgeTxMetaId1' }, + ); + + expect( + controller.state.txHistory.bridgeTxMetaId1.attempts, + ).toBeUndefined(); + expect(fetchBridgeTxStatusSpy).toHaveBeenCalledTimes(0); + + // 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(); + + expect(fetchBridgeTxStatusSpy).toHaveBeenCalledTimes(2); + expect( + controller.state.txHistory.bridgeTxMetaId1.attempts?.counter, + ).toBeUndefined(); + }, + ); + }); + }); + + describe('error cases', () => { + it('should throw error when no identifier is provided', async () => { + await withController( + { options: { state: defaultState } }, + async ({ rootMessenger }) => { + expect(() => { + rootMessenger.call( + 'BridgeStatusController:restartPollingForFailedAttempts', + {}, + ); + }).toThrow('Either txMetaId or txHash must be provided'); + }, + ); + }); + + it('should throw error when txMetaId is not found', async () => { + await withController( + { options: { state: defaultState } }, + async ({ rootMessenger }) => { + expect(() => { + rootMessenger.call( + 'BridgeStatusController:restartPollingForFailedAttempts', + { txMetaId: 'nonexistentTxMetaId' }, + ); + }).toThrow( + 'No bridge transaction history found for txMetaId: nonexistentTxMetaId', + ); + }, + ); + }); + + it('should throw error when txHash is not found', async () => { + await withController( + { options: { state: defaultState } }, + async ({ rootMessenger }) => { + expect(() => { + rootMessenger.call( + 'BridgeStatusController:restartPollingForFailedAttempts', + { txHash: '0xnonexistentTxHash' }, + ); + }).toThrow( + 'No bridge transaction history found for txHash: 0xnonexistentTxHash', + ); + }, + ); + }); + + it('should throw error when txMetaId is empty string', async () => { + await withController( + { options: { state: defaultState } }, + async ({ rootMessenger }) => { + expect(() => { + rootMessenger.call( + 'BridgeStatusController:restartPollingForFailedAttempts', + { txMetaId: '' }, + ); + }).toThrow('Either txMetaId or txHash must be provided'); + }, + ); + }); + + it('should throw error when txHash is empty string', async () => { + await withController( + { options: { state: defaultState } }, + async ({ rootMessenger }) => { + expect(() => { + rootMessenger.call( + 'BridgeStatusController:restartPollingForFailedAttempts', + { txHash: '' }, + ); + }).toThrow('Either txMetaId or txHash must be provided'); + }, + ); + }); + }); + + describe('edge cases', () => { + it('should handle transaction with no srcChain.txHash when searching by txHash', async () => { + await withController( + { + options: { + state: { + txHistory: { + noHashTx: { + ...MockTxHistory.getPending({ txMetaId: 'noHashTx' }) + .noHashTx, + status: { + ...MockTxHistory.getPending({ txMetaId: 'noHashTx' }) + .noHashTx.status, + srcChain: { + ...MockTxHistory.getPending({ txMetaId: 'noHashTx' }) + .noHashTx.status.srcChain, + txHash: undefined, + }, + }, + }, + }, + }, + }, + }, + async ({ rootMessenger }) => { + expect(() => { + rootMessenger.call( + 'BridgeStatusController:restartPollingForFailedAttempts', + { txHash: '0xsomeHash' }, + ); + }).toThrow( + 'No bridge transaction history found for txHash: 0xsomeHash', + ); + }, + ); + }); + + it('should handle transaction that exists but has no attempts to reset', async () => { + await withController( + { options: { state: defaultState } }, + async ({ controller, rootMessenger }) => { + expect( + controller.state.txHistory.bridgeTxMetaId1.attempts, + ).toBeUndefined(); + + expect(() => { + rootMessenger.call( + 'BridgeStatusController:restartPollingForFailedAttempts', + { txMetaId: 'bridgeTxMetaId1' }, + ); + }).not.toThrow(); + + expect( + controller.state.txHistory.bridgeTxMetaId1.attempts, + ).toBeUndefined(); + }, + ); + }); + }); + }); + + describe('subscription handlers', () => { + let mockMessenger: RootMessenger; + let mockBridgeStatusMessenger: Messenger< + 'BridgeStatusController', + MessengerActions, + MessengerEvents, + RootMessenger + >; + let bridgeStatusController: BridgeStatusController; + + let mockFetchFn: jest.Mock; + const consoleFn = console.warn; + let consoleFnSpy: jest.SpyInstance; + + beforeEach(() => { + jest.useFakeTimers(); + jest.clearAllTimers(); + jest.clearAllMocks(); + // eslint-disable-next-line no-empty-function + consoleFnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {}); + mockMessenger = new Messenger({ namespace: MOCK_ANY_NAMESPACE }); + mockBridgeStatusMessenger = new Messenger({ + namespace: BRIDGE_STATUS_CONTROLLER_NAME, + parent: mockMessenger, + }); + mockMessenger.delegate({ + messenger: mockBridgeStatusMessenger, + actions: [ + 'TransactionController:getState', + 'BridgeController:trackUnifiedSwapBridgeEvent', + 'AccountsController:getAccountByAddress', + 'RemoteFeatureFlagController:getState', + ], + events: ['TransactionController:transactionStatusUpdated'], + }); + + jest + .spyOn(mockBridgeStatusMessenger, 'call') + .mockImplementation((..._args) => { + return Promise.resolve(); + }); + + mockFetchFn = jest + .fn() + .mockResolvedValueOnce(MockStatusResponse.getPending()); + + jest.setSystemTime(1779988919707); + + // Create base history item for actionId-keyed entries + const baseHistoryItem = MockTxHistory.getPending().bridgeTxMetaId1; + + bridgeStatusController = new BridgeStatusController({ + messenger: mockBridgeStatusMessenger, + clientId: BridgeClientId.EXTENSION, + clientProduct: 'test-client-product', + fetchFn: mockFetchFn, + addTransactionBatchFn: jest.fn(), + state: { + txHistory: { + ...MockTxHistory.getPending(), + ...MockTxHistory.getPendingSwap(), + ...MockTxHistory.getPending({ + txMetaId: 'bridgeTxMetaId1WithApproval', + approvalTxId: 'bridgeApprovalTxMetaId1' as never, + }), + ...MockTxHistory.getPendingSwap({ + txMetaId: 'perpsSwapTxMetaId1', + featureId: FeatureId.PERPS as never, + }), + ...MockTxHistory.getPending({ + txMetaId: 'perpsBridgeTxMetaId1', + srcTxHash: '0xperpsSrcTxHash1', + featureId: FeatureId.PERPS as never, + }), + ...MockTxHistory.getPendingSwap({ + txMetaId: 'quickBuyBridgeTxMetaId1', + srcTxHash: '0xquickBuySrcTxHash1', + featureId: FeatureId.QUICK_BUY_FOLLOW_TRADING as never, + }), + ...MockTxHistory.getPendingSwap({ + txMetaId: 'quickBuyExploreBridgeTxMetaId1', + srcTxHash: '0xquickBuyExploreSrcTxHash1', + featureId: FeatureId.QUICK_BUY_EXPLORE as never, + }), + // ActionId-keyed entries for pre-submission failure tests + 'pre-submission-action-id': { + ...baseHistoryItem, + actionId: 'pre-submission-action-id', + txMetaId: undefined, + } as BridgeHistoryItem, + 'action-id-for-tracking': { + ...baseHistoryItem, + actionId: 'action-id-for-tracking', + txMetaId: undefined, + } as BridgeHistoryItem, + 'action-id-for-rejection': { + ...baseHistoryItem, + actionId: 'action-id-for-rejection', + txMetaId: undefined, + } as BridgeHistoryItem, + }, + }, + }); + }); + + afterEach(() => { + bridgeStatusController.stopAllPolling(); + console.warn = consoleFn; + jest.useRealTimers(); + }); + + describe('TransactionController:transactionStatusUpdated (failed)', () => { + it('should track failed event for bridge transaction', () => { + const messengerCallSpy = jest.spyOn(mockBridgeStatusMessenger, 'call'); + mockMessenger.publish( + 'TransactionController:transactionStatusUpdated', + { + transactionMeta: { + error: { name: 'Error', message: 'tx-error' }, + chainId: CHAIN_IDS.ARBITRUM, + networkClientId: 'eth-id', + time: Date.now(), + txParams: {} as unknown as TransactionParams, + type: TransactionType.bridge, + status: TransactionStatus.failed, + id: 'bridgeTxMetaId1', + batchId: '0xBatchIdFailed1', + }, + }, + ); + + expect( + bridgeStatusController.state.txHistory.bridgeTxMetaId1.status.status, + ).toBe(StatusTypes.FAILED); + expect(messengerCallSpy.mock.lastCall).toMatchSnapshot(); + }); + + it('should use txMeta properties if history item does not exist', () => { + const messengerCallSpy = jest.spyOn(mockBridgeStatusMessenger, 'call'); + + const transactionMeta = { + error: { name: 'Error', message: 'tx-error' }, + chainId: CHAIN_IDS.ARBITRUM, + networkClientId: 'eth-id', + time: Date.now(), + txParams: {} as unknown as TransactionParams, + type: TransactionType.bridge, + status: TransactionStatus.failed, + id: 'bridgeTxMetaId1', + }; + const getEVMTxPropertiesFromTransactionMetaSpy = jest + .spyOn(metricsUtils, 'getEVMTxPropertiesFromTransactionMeta') + .mockImplementationOnce(() => { + bridgeStatusController.wipeBridgeStatus({ + address: 'otherAccount', + ignoreNetwork: true, + }); + return metricsUtils.getEVMTxPropertiesFromTransactionMeta( + transactionMeta, + ); + }); + mockMessenger.publish( + 'TransactionController:transactionStatusUpdated', + { + transactionMeta, + }, + ); + + expect(getEVMTxPropertiesFromTransactionMetaSpy).toHaveBeenCalledTimes( + 2, + ); + expect(bridgeStatusController.state.txHistory).toStrictEqual({}); + expect(messengerCallSpy.mock.lastCall).toMatchInlineSnapshot(` + [ + "BridgeController:trackUnifiedSwapBridgeEvent", + "Unified SwapBridge Failed", + { + "account_hardware_type": null, + "action_type": "swapbridge-v1", + "actual_time_minutes": 0, + "chain_id_destination": "eip155:42161", + "chain_id_source": "eip155:42161", + "custom_slippage": false, + "error_message": "Transaction failed. tx-error", + "feature_id": "unified_swap_bridge", + "gas_included": false, + "gas_included_7702": false, + "is_hardware_wallet": false, + "location": "Unknown", + "price_impact": 0, + "provider": "", + "quote_vs_execution_ratio": 0, + "quoted_time_minutes": 0, + "quoted_vs_used_gas_ratio": 0, + "security_warnings": [], + "slippage_limit": 0, + "source_transaction": "FAILED", + "stx_enabled": false, + "swap_type": "crosschain", + "token_address_destination": "eip155:42161/slip44:60", + "token_address_source": "eip155:42161/slip44:60", + "token_security_type_destination": null, + "token_symbol_destination": "", + "token_symbol_source": "", + "usd_actual_gas": 0, + "usd_actual_return": 0, + "usd_amount_source": 0, + "usd_quoted_gas": 0, + "usd_quoted_return": 0, + }, + ] + `); + }); + + it('should include ab_tests and active_ab_tests from history in tracked event properties', () => { + const abTestsTxMetaId = 'bridgeTxMetaIdAbTests'; + mockMessenger.call( + 'BridgeStatusController:startPollingForBridgeTxStatus', + { + ...getMockStartPollingForBridgeTxStatusArgs({ + txMetaId: abTestsTxMetaId, + srcTxHash: '0xsrcTxHashAbTests', + }), + abTests: { token_details_layout: 'treatment' }, + activeAbTests: [ + { key: 'bridge_quote_sorting', value: 'variant_b' }, + ], + }, + ); + + const messengerCallSpy = jest.spyOn(mockBridgeStatusMessenger, 'call'); + mockMessenger.publish( + 'TransactionController:transactionStatusUpdated', + { + transactionMeta: { + error: { name: 'Error', message: 'tx-error' }, + chainId: CHAIN_IDS.ARBITRUM, + networkClientId: 'eth-id', + time: Date.now(), + txParams: {} as unknown as TransactionParams, + type: TransactionType.bridge, + status: TransactionStatus.failed, + id: abTestsTxMetaId, + }, + }, + ); + + expect(messengerCallSpy).toHaveBeenCalledWith( + 'BridgeController:trackUnifiedSwapBridgeEvent', + expect.anything(), + expect.objectContaining({ + ab_tests: { token_details_layout: 'treatment' }, + active_ab_tests: [ + { key: 'bridge_quote_sorting', value: 'variant_b' }, + ], + feature_id: FeatureId.UNIFIED_SWAP_BRIDGE, + }), + ); + }); + + it('should track failed event for bridge transaction if approval is dropped', () => { + const messengerCallSpy = jest.spyOn(mockBridgeStatusMessenger, 'call'); + mockMessenger.publish( + 'TransactionController:transactionStatusUpdated', + { + transactionMeta: { + error: { name: 'Error', message: 'tx-error' }, + chainId: CHAIN_IDS.ARBITRUM, + networkClientId: 'eth-id', + time: Date.now(), + txParams: {} as unknown as TransactionParams, + type: TransactionType.bridgeApproval, + status: TransactionStatus.dropped, + id: 'bridgeApprovalTxMetaId1', + }, + }, + ); + + mockMessenger.publish( + 'TransactionController:transactionStatusUpdated', + { + transactionMeta: { + error: { name: 'Error', message: 'tx-error' }, + chainId: CHAIN_IDS.ARBITRUM, + networkClientId: 'eth-id', + time: Date.now(), + txParams: {} as unknown as TransactionParams, + type: TransactionType.bridge, + status: TransactionStatus.dropped, + id: 'bridgeTxMetaId1WithApproval', + }, + }, + ); + + expect(messengerCallSpy.mock.calls).toMatchSnapshot(); + expect( + bridgeStatusController.state.txHistory.bridgeTxMetaId1WithApproval + .status.status, + ).toBe(StatusTypes.FAILED); + }); + + it('should not track failed event for bridge transaction with featureId=perps', () => { + const messengerCallSpy = jest.spyOn(mockBridgeStatusMessenger, 'call'); + mockMessenger.publish( + 'TransactionController:transactionStatusUpdated', + { + transactionMeta: { + error: { name: 'Error', message: 'tx-error' }, + chainId: CHAIN_IDS.ARBITRUM, + networkClientId: 'eth-id', + time: Date.now(), + txParams: {} as unknown as TransactionParams, + type: TransactionType.bridge, + status: TransactionStatus.failed, + id: 'perpsBridgeTxMetaId1', + }, + }, + ); + + expect( + bridgeStatusController.state.txHistory.perpsBridgeTxMetaId1.status + .status, + ).toBe(StatusTypes.FAILED); + expect(messengerCallSpy.mock.calls).toMatchInlineSnapshot(`[]`); + }); + + it('should track failed event for transaction with featureId=quick_buy_follow_trading', () => { + const messengerCallSpy = jest.spyOn(mockBridgeStatusMessenger, 'call'); + mockMessenger.publish( + 'TransactionController:transactionStatusUpdated', + { + transactionMeta: { + error: { name: 'Error', message: 'tx-error' }, + chainId: CHAIN_IDS.ARBITRUM, + networkClientId: 'eth-id', + time: Date.now(), + txParams: {} as unknown as TransactionParams, + type: TransactionType.bridge, + status: TransactionStatus.failed, + id: 'quickBuyBridgeTxMetaId1', + batchId: '0xBatchId3', + }, + }, + ); + + expect( + bridgeStatusController.state.txHistory.quickBuyBridgeTxMetaId1.status + .status, + ).toBe(StatusTypes.FAILED); + expect(messengerCallSpy.mock.calls).toMatchInlineSnapshot(` + [ + [ + "AccountsController:getAccountByAddress", + "0xaccount1", + ], + [ + "TransactionController:getState", + ], + [ + "BridgeController:trackUnifiedSwapBridgeEvent", + "Unified SwapBridge Failed", + { + "account_hardware_type": null, + "action_type": "swapbridge-v1", + "actual_time_minutes": 833734.9086333333, + "allowance_reset_transaction": undefined, + "approval_transaction": undefined, + "batch_id": "0xBatchId3", + "chain_id_destination": "eip155:42161", + "chain_id_source": "eip155:42161", + "custom_slippage": true, + "destination_transaction": "FAILED", + "error_message": "Transaction failed. tx-error", + "feature_id": "quick_buy_follow_trading", + "gas_included": false, + "gas_included_7702": false, + "is_hardware_wallet": false, + "location": "Unknown", + "price_impact": 0, + "provider": "lifi_across", + "quote_vs_execution_ratio": 0, + "quoted_time_minutes": 0.25, + "quoted_vs_used_gas_ratio": 0, + "security_warnings": [], + "slippage_limit": 0, + "source_transaction": "COMPLETE", + "stx_enabled": false, + "swap_type": "single_chain", + "token_address_destination": "eip155:42161/slip44:60", + "token_address_source": "eip155:42161/slip44:60", + "token_security_type_destination": null, + "token_symbol_destination": "ETH", + "token_symbol_source": "ETH", + "usd_actual_gas": 0, + "usd_actual_return": 0, + "usd_amount_source": 0, + "usd_quoted_gas": 0, + "usd_quoted_return": 0, + }, + ], + ] + `); + }); + + it('should track failed event for transaction with featureId=quick_buy_explore', () => { + const messengerCallSpy = jest.spyOn(mockBridgeStatusMessenger, 'call'); + mockMessenger.publish( + 'TransactionController:transactionStatusUpdated', + { + transactionMeta: { + error: { name: 'Error', message: 'tx-error' }, + chainId: CHAIN_IDS.ARBITRUM, + networkClientId: 'eth-id', + time: Date.now(), + txParams: {} as unknown as TransactionParams, + type: TransactionType.bridge, + status: TransactionStatus.failed, + id: 'quickBuyExploreBridgeTxMetaId1', + batchId: '0xBatchId3', + }, + }, + ); + + expect( + bridgeStatusController.state.txHistory.quickBuyExploreBridgeTxMetaId1 + .status.status, + ).toBe(StatusTypes.FAILED); + expect(messengerCallSpy.mock.calls).toMatchInlineSnapshot(` + [ + [ + "AccountsController:getAccountByAddress", + "0xaccount1", + ], + [ + "TransactionController:getState", + ], + [ + "BridgeController:trackUnifiedSwapBridgeEvent", + "Unified SwapBridge Failed", + { + "account_hardware_type": null, + "action_type": "swapbridge-v1", + "actual_time_minutes": 833734.9086333333, + "allowance_reset_transaction": undefined, + "approval_transaction": undefined, + "batch_id": "0xBatchId3", + "chain_id_destination": "eip155:42161", + "chain_id_source": "eip155:42161", + "custom_slippage": true, + "destination_transaction": "FAILED", + "error_message": "Transaction failed. tx-error", + "feature_id": "quick_buy_explore", + "gas_included": false, + "gas_included_7702": false, + "is_hardware_wallet": false, + "location": "Unknown", + "price_impact": 0, + "provider": "lifi_across", + "quote_vs_execution_ratio": 0, + "quoted_time_minutes": 0.25, + "quoted_vs_used_gas_ratio": 0, + "security_warnings": [], + "slippage_limit": 0, + "source_transaction": "COMPLETE", + "stx_enabled": false, + "swap_type": "single_chain", + "token_address_destination": "eip155:42161/slip44:60", + "token_address_source": "eip155:42161/slip44:60", + "token_security_type_destination": null, + "token_symbol_destination": "ETH", + "token_symbol_source": "ETH", + "usd_actual_gas": 0, + "usd_actual_return": 0, + "usd_amount_source": 0, + "usd_quoted_gas": 0, + "usd_quoted_return": 0, + }, + ], + ] + `); + }); + + it('should track failed event for swap transaction if approval fails', () => { + const messengerCallSpy = jest.spyOn(mockBridgeStatusMessenger, 'call'); + mockMessenger.publish( + 'TransactionController:transactionStatusUpdated', + { + transactionMeta: { + error: { name: 'Error', message: 'approval-tx-error' }, + chainId: CHAIN_IDS.ARBITRUM, + networkClientId: 'eth-id', + time: Date.now(), + txParams: {} as unknown as TransactionParams, + type: TransactionType.swapApproval, + status: TransactionStatus.failed, + id: 'bridgeApprovalTxMetaId1', + }, + }, + ); + + expect(messengerCallSpy.mock.lastCall).toMatchSnapshot(); + expect( + bridgeStatusController.state.txHistory.bridgeTxMetaId1WithApproval + .status.status, + ).toBe(StatusTypes.FAILED); + expect( + bridgeStatusController.state.txHistory.bridgeTxMetaId1WithApproval + .status.srcChain.txHash, + ).toBe('0xsrcTxHash1'); + expect( + bridgeStatusController.state.txHistory.bridgeTxMetaId1WithApproval + .approvalTxId, + ).toBe('bridgeApprovalTxMetaId1'); + }); + + it('should track failed event for bridge transaction if not in txHistory', () => { + const messengerCallSpy = jest.spyOn(mockBridgeStatusMessenger, 'call'); + const expectedHistory = bridgeStatusController.state.txHistory; + mockMessenger.publish( + 'TransactionController:transactionStatusUpdated', + { + transactionMeta: { + error: { name: 'Error', message: 'tx-error' }, + chainId: CHAIN_IDS.ARBITRUM, + networkClientId: 'eth-id', + time: Date.now(), + txParams: {} as unknown as TransactionParams, + type: TransactionType.bridge, + status: TransactionStatus.failed, + id: 'bridgeTxMetaIda', + }, + }, + ); + + expect(bridgeStatusController.state.txHistory).toStrictEqual( + expectedHistory, + ); + expect(messengerCallSpy.mock.calls).toMatchSnapshot(); + }); + + it('should track failed event for swap transaction', () => { + const messengerCallSpy = jest.spyOn(mockBridgeStatusMessenger, 'call'); + mockMessenger.publish( + 'TransactionController:transactionStatusUpdated', + { + transactionMeta: { + error: { name: 'Error', message: 'tx-error' }, + chainId: CHAIN_IDS.ARBITRUM, + networkClientId: 'eth-id', + time: Date.now(), + txParams: {} as unknown as TransactionParams, + type: TransactionType.swap, + status: TransactionStatus.failed, + id: 'swapTxMetaId1', + }, + }, + ); + + expect( + bridgeStatusController.state.txHistory.swapTxMetaId1.status.status, + ).toBe(StatusTypes.FAILED); + expect(messengerCallSpy.mock.calls).toMatchSnapshot(); + }); + + it('should not call getAccountByAddress with undefined when txParams.from is missing', () => { + const messengerCallSpy = jest.spyOn(mockBridgeStatusMessenger, 'call'); + mockMessenger.publish( + 'TransactionController:transactionStatusUpdated', + { + transactionMeta: { + error: { name: 'Error', message: 'tx-error' }, + chainId: CHAIN_IDS.ARBITRUM, + networkClientId: 'eth-id', + time: Date.now(), + txParams: {} as unknown as TransactionParams, + type: TransactionType.bridge, + status: TransactionStatus.failed, + id: 'bridgeTxMetaId1', + }, + }, + ); + + const accountLookupCalls = messengerCallSpy.mock.calls.filter( + (call) => call[0] === 'AccountsController:getAccountByAddress', + ); + expect(accountLookupCalls).not.toContainEqual([ + 'AccountsController:getAccountByAddress', + undefined, + ]); + }); + + it('should call getAccountByAddress when txParams.from is set', () => { + const messengerCallSpy = jest.spyOn(mockBridgeStatusMessenger, 'call'); + mockMessenger.publish( + 'TransactionController:transactionStatusUpdated', + { + transactionMeta: { + error: { name: 'Error', message: 'tx-error' }, + chainId: CHAIN_IDS.ARBITRUM, + networkClientId: 'eth-id', + time: Date.now(), + txParams: { from: '0xaccount1' } as unknown as TransactionParams, + type: TransactionType.bridge, + status: TransactionStatus.failed, + id: 'bridgeTxMetaId1', + }, + }, + ); + + expect(messengerCallSpy).toHaveBeenCalledWith( + 'AccountsController:getAccountByAddress', + '0xaccount1', + ); + }); + + it('should not track failed event for signed status', () => { + const messengerCallSpy = jest.spyOn(mockBridgeStatusMessenger, 'call'); + mockMessenger.publish( + 'TransactionController:transactionStatusUpdated', + { + transactionMeta: { + error: { name: 'Error', message: 'tx-error' }, + chainId: CHAIN_IDS.ARBITRUM, + networkClientId: 'eth-id', + time: Date.now(), + txParams: {} as unknown as TransactionParams, + type: TransactionType.swap, + status: TransactionStatus.signed, + id: 'swapTxMetaId1', + }, + }, + ); + + expect(messengerCallSpy.mock.calls).toMatchSnapshot(); + }); + + it('should not track failed event for approved status', () => { + const messengerCallSpy = jest.spyOn(mockBridgeStatusMessenger, 'call'); + mockMessenger.publish( + 'TransactionController:transactionStatusUpdated', + { + transactionMeta: { + error: { name: 'Error', message: 'tx-error' }, + chainId: CHAIN_IDS.ARBITRUM, + networkClientId: 'eth-id', + time: Date.now(), + txParams: {} as unknown as TransactionParams, + type: TransactionType.swap, + status: TransactionStatus.approved, + id: 'swapTxMetaId1', + }, + }, + ); + + expect(messengerCallSpy.mock.calls).toMatchSnapshot(); + }); + + it('should not track failed event for other transaction types', () => { + const messengerCallSpy = jest.spyOn(mockBridgeStatusMessenger, 'call'); + mockMessenger.publish( + 'TransactionController:transactionStatusUpdated', + { + transactionMeta: { + error: { name: 'Error', message: 'tx-error' }, + chainId: CHAIN_IDS.ARBITRUM, + networkClientId: 'eth-id', + time: Date.now(), + txParams: {} as unknown as TransactionParams, + type: TransactionType.simpleSend, + status: TransactionStatus.failed, + id: 'simpleSendTxMetaId1', + }, + }, + ); + + expect(messengerCallSpy.mock.calls).toMatchSnapshot(); + }); + + it('should find history by actionId when txMeta.id not in history (pre-submission failure)', () => { + // The history entry keyed by actionId is set up in beforeEach + const actionId = 'pre-submission-action-id'; + const unknownTxMetaId = 'unknown-tx-meta-id'; + + const messengerCallSpy = jest.spyOn(mockBridgeStatusMessenger, 'call'); + mockMessenger.publish( + 'TransactionController:transactionStatusUpdated', + { + transactionMeta: { + error: { name: 'Error', message: 'tx-error' }, + chainId: CHAIN_IDS.ARBITRUM, + networkClientId: 'eth-id', + time: Date.now(), + txParams: {} as unknown as TransactionParams, + type: TransactionType.bridge, + status: TransactionStatus.failed, + id: unknownTxMetaId, + actionId, // ActionId matches the history entry + }, + }, + ); + + // Verify: History entry keyed by actionId should be marked as failed + expect( + bridgeStatusController.state.txHistory[actionId].status.status, + ).toBe(StatusTypes.FAILED); + expect(messengerCallSpy.mock.lastCall).toMatchSnapshot(); + }); + + it('should track failed event using actionId lookup when id not found', () => { + // The history entry keyed by actionId is set up in beforeEach + const actionId = 'action-id-for-tracking'; + + const messengerCallSpy = jest.spyOn(mockBridgeStatusMessenger, 'call'); + + mockMessenger.publish( + 'TransactionController:transactionStatusUpdated', + { + transactionMeta: { + error: { name: 'Error', message: 'tx-error' }, + chainId: CHAIN_IDS.ARBITRUM, + networkClientId: 'eth-id', + time: Date.now(), + txParams: {} as unknown as TransactionParams, + type: TransactionType.bridge, + status: TransactionStatus.failed, + id: 'non-existent-tx-id', + actionId, + }, + }, + ); + + // The Failed event should be tracked with the history data from actionId lookup + expect(messengerCallSpy).toHaveBeenCalled(); + expect( + bridgeStatusController.state.txHistory[actionId].status.status, + ).toBe(StatusTypes.FAILED); + }); + + it('should not track failed event when transaction is rejected', () => { + // The history entry keyed by actionId is set up in beforeEach + const actionId = 'action-id-for-rejection'; + + const messengerCallSpy = jest.spyOn(mockBridgeStatusMessenger, 'call'); + + mockMessenger.publish( + 'TransactionController:transactionStatusUpdated', + { + transactionMeta: { + error: { name: 'Error', message: 'User rejected' }, + chainId: CHAIN_IDS.ARBITRUM, + networkClientId: 'eth-id', + time: Date.now(), + txParams: {} as unknown as TransactionParams, + type: TransactionType.bridge, + status: TransactionStatus.rejected, + id: 'rejected-tx-id', + actionId, + }, + }, + ); + + // Status should still be marked as failed + expect( + bridgeStatusController.state.txHistory[actionId].status.status, + ).toBe(StatusTypes.FAILED); + // But Failed event should NOT be tracked for rejected status + // (check that call was not made for tracking - only for marking failed) + expect(messengerCallSpy).not.toHaveBeenCalled(); + }); + }); + + describe('TransactionController:transactionStatusUpdated (confirmed)', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('should start polling for bridge tx if status response is invalid', async () => { + const messengerCallSpy = jest.spyOn(mockBridgeStatusMessenger, 'call'); + + mockFetchFn.mockClear(); + mockFetchFn.mockResolvedValueOnce({ + ...MockStatusResponse.getComplete(), + status: 'INVALID', + }); + const oldHistoryItem = mockMessenger.call( + 'BridgeStatusController:getBridgeHistoryItemByTxMetaId', + 'bridgeTxMetaId1', + ); + mockMessenger.publish( + 'TransactionController:transactionStatusUpdated', + { + transactionMeta: { + chainId: CHAIN_IDS.ARBITRUM, + networkClientId: 'eth-id', + time: Date.now(), + txParams: {} as unknown as TransactionParams, + type: TransactionType.bridge, + status: TransactionStatus.confirmed, + id: 'bridgeTxMetaId1', + }, + }, + ); + + jest.advanceTimersByTime(500); + bridgeStatusController.stopAllPolling(); + await flushPromises(); + + expect(messengerCallSpy.mock.calls).toMatchSnapshot(); + expect(mockFetchFn).toHaveBeenCalledTimes(3); + expect(mockFetchFn).toHaveBeenCalledWith( + 'https://bridge.api.cx.metamask.io/getTxStatus?bridgeId=lifi&srcTxHash=0xsrcTxHash1&bridge=across&srcChainId=42161&destChainId=10&refuel=false&requestId=197c402f-cb96-4096-9f8c-54aed84ca776', + { + headers: { 'X-Client-Id': BridgeClientId.EXTENSION }, + }, + ); + expect( + mockMessenger.call( + 'BridgeStatusController:getBridgeHistoryItemByTxMetaId', + 'bridgeTxMetaId1', + ), + ).toStrictEqual({ + ...oldHistoryItem, + attempts: expect.objectContaining({ + counter: 1, + }), + }); + expect(consoleFnSpy.mock.calls).toMatchSnapshot(); + }); + + it('should start polling for completed bridge tx with featureId=perps', async () => { + const messengerCallSpy = jest.spyOn(mockBridgeStatusMessenger, 'call'); + + mockFetchFn.mockClear(); + mockFetchFn.mockResolvedValueOnce( + MockStatusResponse.getComplete({ srcTxHash: '0xperpsSrcTxHash1' }), + ); + mockMessenger.publish( + 'TransactionController:transactionStatusUpdated', + { + transactionMeta: { + 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( + mockMessenger.call( + 'BridgeStatusController:getBridgeHistoryItemByTxMetaId', + 'perpsBridgeTxMetaId1', + )?.status, + ).toMatchSnapshot(); + expect(consoleFnSpy).not.toHaveBeenCalled(); + }); + + it('should start polling for failed bridge tx with featureId=perps', async () => { + const messengerCallSpy = jest.spyOn(mockBridgeStatusMessenger, 'call'); + + mockFetchFn.mockClear(); + mockFetchFn.mockResolvedValueOnce( + MockStatusResponse.getFailed({ srcTxHash: '0xperpsSrcTxHash1' }), + ); + mockMessenger.publish( + 'TransactionController:transactionStatusUpdated', + { + transactionMeta: { + 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(40500); + 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( + mockMessenger.call( + 'BridgeStatusController:getBridgeHistoryItemByTxMetaId', + 'perpsBridgeTxMetaId1', + )?.status, + ).toMatchSnapshot(); + expect(consoleFnSpy).not.toHaveBeenCalled(); + }); + + it('should track completed event for swap transaction', () => { + const messengerCallSpy = jest.spyOn(mockBridgeStatusMessenger, 'call'); + mockMessenger.publish( + 'TransactionController:transactionStatusUpdated', + { + transactionMeta: { + chainId: CHAIN_IDS.ARBITRUM, + networkClientId: 'eth-id', + time: Date.now(), + txParams: {} as unknown as TransactionParams, + type: TransactionType.swap, + status: TransactionStatus.confirmed, + id: 'swapTxMetaId1', + }, + }, + ); + + const completedEventCall = messengerCallSpy.mock.calls.find( + ([, eventName]) => eventName === UnifiedSwapBridgeEventName.Completed, + ); + expect(completedEventCall?.[2]).toStrictEqual( + expect.objectContaining({ + transaction_internal_id: 'swapTxMetaId1', + }), + ); + expect(messengerCallSpy.mock.calls).toMatchSnapshot(); + }); + + it('should not track completed event for swap transaction with perps featureId', () => { + const messengerCallSpy = jest.spyOn(mockBridgeStatusMessenger, 'call'); + mockMessenger.publish( + 'TransactionController:transactionStatusUpdated', + { + transactionMeta: { + chainId: CHAIN_IDS.ARBITRUM, + networkClientId: 'eth-id', + time: Date.now(), + txParams: {} as unknown as TransactionParams, + type: TransactionType.swap, + status: TransactionStatus.confirmed, + id: 'perpsSwapTxMetaId1', + }, + }, + ); + + expect(messengerCallSpy).not.toHaveBeenCalled(); + }); + + it('should not track completed event for other transaction types', () => { + const messengerCallSpy = jest.spyOn(mockBridgeStatusMessenger, 'call'); + mockMessenger.publish( + 'TransactionController:transactionStatusUpdated', + { + transactionMeta: { + chainId: CHAIN_IDS.ARBITRUM, + networkClientId: 'eth-id', + time: Date.now(), + txParams: {} as unknown as TransactionParams, + type: TransactionType.bridge, + status: TransactionStatus.confirmed, + id: 'bridgeTxMetaId1', + }, + }, + ); + + expect(messengerCallSpy.mock.calls).toMatchSnapshot(); + }); + + it('should not start poll or track completed event if the transaction is an approval', () => { + const messengerCallSpy = jest.spyOn(mockBridgeStatusMessenger, 'call'); + const startPollingSpy = jest.spyOn( + bridgeStatusController, + 'startPolling', + ); + + mockMessenger.publish( + 'TransactionController:transactionStatusUpdated', + { + transactionMeta: { + chainId: CHAIN_IDS.ARBITRUM, + networkClientId: 'eth-id', + time: Date.now(), + txParams: {} as unknown as TransactionParams, + type: TransactionType.contractInteraction, + status: TransactionStatus.confirmed, + id: 'bridgeApprovalTxMetaId1', + }, + }, + ); + + expect(messengerCallSpy.mock.calls).toHaveLength(0); + expect(startPollingSpy).not.toHaveBeenCalled(); + }); + + it('should not start polling for bridge tx if tx is not in txHistory', () => { + const messengerCallSpy = jest.spyOn(mockBridgeStatusMessenger, 'call'); + mockMessenger.publish( + 'TransactionController:transactionStatusUpdated', + { + transactionMeta: { + chainId: CHAIN_IDS.ARBITRUM, + networkClientId: 'eth-id', + time: Date.now(), + txParams: {} as unknown as TransactionParams, + type: TransactionType.bridge, + status: TransactionStatus.confirmed, + id: 'bridgeTxMetaId1Unknown', + }, + }, + ); + + 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'); + consoleFnSpy = jest + .spyOn(console, 'error') + .mockImplementationOnce(jest.fn()); + consoleFnSpy.mockImplementationOnce(jest.fn()); + + messengerCallSpy.mockReturnValueOnce({ + remoteFeatureFlags: { + bridgeConfig: { + maxPendingHistoryItemAgeMs: + DEFAULT_MAX_PENDING_HISTORY_ITEM_AGE_MS, + }, + }, + cacheTimestamp: Date.now(), + }); + + messengerCallSpy.mockImplementationOnce(() => { + throw new Error( + 'AuthenticationController:getBearerToken not implemented', + ); + }); + + messengerCallSpy.mockReturnValueOnce({ + remoteFeatureFlags: { + bridgeConfig: { + maxPendingHistoryItemAgeMs: + DEFAULT_MAX_PENDING_HISTORY_ITEM_AGE_MS, + }, + }, + cacheTimestamp: Date.now(), + }); + messengerCallSpy.mockImplementationOnce(() => { + throw new Error( + 'AuthenticationController:getBearerToken not implemented', + ); + }); + + mockFetchFn.mockClear(); + mockFetchFn.mockResolvedValueOnce( + MockStatusResponse.getComplete({ srcTxHash: '0xperpsSrcTxHash1' }), + ); + mockMessenger.publish( + 'TransactionController:transactionStatusUpdated', + { + transactionMeta: { + chainId: CHAIN_IDS.ARBITRUM, + networkClientId: 'eth-id', + time: 1729964825189, + 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", + ], + [ + "AccountsController:getAccountByAddress", + "0xaccount1", + ], + [ + "TransactionController:getState", + ], + ] + `); + 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.mock.calls).toMatchInlineSnapshot(` + [ + [ + "Error getting JWT token for bridge-api request", + [Error: AuthenticationController:getBearerToken not implemented], + ], + ] + `); + }); + }); + }); + + describe('reporting submitted quote status updates', () => { + let fetchSpy: jest.SpyInstance; + + beforeEach(() => { + jest.useFakeTimers(); + // Keep the quote-status update request in-flight so the manager never + // resolves and mutates state after the assertions/teardown. + fetchSpy = jest + .spyOn(globalThis, 'fetch') + .mockReturnValue(new Promise(() => undefined)); + }); + + afterEach(() => { + fetchSpy.mockRestore(); + jest.clearAllTimers(); + jest.useRealTimers(); + }); + + it('reports SUBMITTED once and persists the quote status store', async () => { + await withController( + { + options: { + isQuoteStatusManagerEnabled: () => true, + state: { + txHistory: { + bridgeTxMetaId1: { + ...MockTxHistory.getPending().bridgeTxMetaId1, + quoteId: 'quote-1', + }, + }, + }, + }, + mockMessengerCall: jest.fn(), + }, + async ({ controller, rootMessenger }) => { + rootMessenger.publish( + 'TransactionController:transactionStatusUpdated', + { + transactionMeta: { + chainId: CHAIN_IDS.ARBITRUM, + networkClientId: 'eth-id', + time: Date.now(), + txParams: {} as unknown as TransactionParams, + type: TransactionType.bridge, + status: TransactionStatus.submitted, + id: 'bridgeTxMetaId1', + hash: '0xsrcTxHash1', + }, + }, + ); + + expect( + controller.state.txHistory.bridgeTxMetaId1.reportedSubmittedTxHash, + ).toBe('0xsrcTxHash1'); + expect( + Object.keys(controller.state.quoteUpdateStatusStore), + ).toStrictEqual(['quote-1:0xsrcTxHash1']); + + // A second update for the same hash is a no-op (already reported). + rootMessenger.publish( + 'TransactionController:transactionStatusUpdated', + { + transactionMeta: { + chainId: CHAIN_IDS.ARBITRUM, + networkClientId: 'eth-id', + time: Date.now(), + txParams: {} as unknown as TransactionParams, + type: TransactionType.bridge, + status: TransactionStatus.submitted, + id: 'bridgeTxMetaId1', + hash: '0xsrcTxHash1', + }, + }, + ); + + expect( + Object.keys(controller.state.quoteUpdateStatusStore), + ).toStrictEqual(['quote-1:0xsrcTxHash1']); + + controller.resetState(); + }, + ); + }); + + it('does not report SUBMITTED when the history item has no quoteId', async () => { + await withController( + { + options: { + isQuoteStatusManagerEnabled: () => true, + state: { + txHistory: { + bridgeTxMetaId1: MockTxHistory.getPending().bridgeTxMetaId1, + }, + }, + }, + mockMessengerCall: jest.fn(), + }, + async ({ controller, rootMessenger }) => { + rootMessenger.publish( + 'TransactionController:transactionStatusUpdated', + { + transactionMeta: { + chainId: CHAIN_IDS.ARBITRUM, + networkClientId: 'eth-id', + time: Date.now(), + txParams: {} as unknown as TransactionParams, + type: TransactionType.bridge, + status: TransactionStatus.submitted, + id: 'bridgeTxMetaId1', + hash: '0xsrcTxHash1', + }, + }, + ); + + expect( + controller.state.txHistory.bridgeTxMetaId1.reportedSubmittedTxHash, + ).toBeUndefined(); + expect(controller.state.quoteUpdateStatusStore).toStrictEqual({}); + }, + ); + }); + + it('handles a confirmed swap with no matching history item', async () => { + await withController( + { mockMessengerCall: jest.fn() }, + async ({ controller, rootMessenger }) => { + expect(() => + rootMessenger.publish( + 'TransactionController:transactionStatusUpdated', + { + transactionMeta: { + chainId: CHAIN_IDS.ARBITRUM, + networkClientId: 'eth-id', + time: Date.now(), + txParams: {} as unknown as TransactionParams, + type: TransactionType.swap, + status: TransactionStatus.confirmed, + id: 'unmatched-tx-id', + hash: '0xunmatchedHash', + }, + }, + ), + ).not.toThrow(); + + expect(controller.state.txHistory).toStrictEqual({}); + }, + ); + }); + + describe('early reporting during submitTx for EVM txs', () => { + const EVM_TRADE_HASH = '0xevmEarlySrcTxHash'; + const EVM_TX_META_ID = 'evmEarlyTxMetaId'; + const EVM_QUOTE_ID = 'evm-early-quote-1'; + + const mockEvmSwapQuoteResponse: QuoteResponseV1 & QuoteMetadata = { + ...getMockQuote({ srcChainId: 42161, destChainId: 42161 }), + quoteId: EVM_QUOTE_ID, + quote: { + ...getMockQuote({ srcChainId: 42161, destChainId: 42161 }), + srcChainId: 42161, + destChainId: 42161, + }, + estimatedProcessingTimeInSeconds: 0, + sentAmount: { amount: '1.234', valueInCurrency: '2.00', usd: '1.01' }, + toTokenAmount: { + amount: '1.5', + valueInCurrency: '2.9999', + usd: '0.134214', + }, + minToTokenAmount: { + amount: '1.425', + valueInCurrency: '2.85', + usd: '0.127', + }, + totalNetworkFee: { + amount: '1.234', + valueInCurrency: undefined, + usd: undefined, + }, + gasFee: { + total: { + amount: '1.234', + valueInCurrency: undefined, + usd: undefined, + }, + }, + adjustedReturn: { valueInCurrency: undefined, usd: undefined }, + swapRate: '1.234', + cost: { valueInCurrency: undefined, usd: undefined }, + trade: { + from: '0xaccount1', + to: '0xbridgeContract', + value: '0x0', + data: '0xdata', + chainId: 42161, + gasLimit: 21000, + }, + }; + + const mockTradeTxMeta = { + id: EVM_TX_META_ID, + hash: EVM_TRADE_HASH, + status: TransactionStatus.submitted, + type: TransactionType.swap, + chainId: '0xa4b1', + txParams: { from: '0xaccount1' } as unknown as TransactionParams, + } as unknown as TransactionMeta; + + const registerSubmitTxHandlers = (rootMessenger: RootMessenger) => { + rootMessenger.registerActionHandler( + 'BridgeController:stopPollingForQuotes', + jest.fn(), + ); + rootMessenger.registerActionHandler( + 'AccountsController:getAccountByAddress', + (() => mockSelectedAccount) as never, + ); + rootMessenger.registerActionHandler( + 'BridgeController:trackUnifiedSwapBridgeEvent', + jest.fn(), + ); + rootMessenger.registerActionHandler( + 'TransactionController:isAtomicBatchSupported', + (() => []) as never, + ); + rootMessenger.registerActionHandler( + 'NetworkController:findNetworkClientIdByChainId', + () => 'networkClientId', + ); + rootMessenger.registerActionHandler( + 'TransactionController:estimateGasFee', + (async () => ({ + estimates: { + type: GasFeeEstimateType.FeeMarket, + high: { + suggestedMaxFeePerGas: '0x1234', + suggestedMaxPriorityFeePerGas: '0x5678', + }, + }, + })) as never, + ); + rootMessenger.registerActionHandler( + 'TransactionController:addTransaction', + (() => ({ + transactionMeta: mockTradeTxMeta, + result: Promise.resolve(EVM_TRADE_HASH), + })) as never, + ); + rootMessenger.registerActionHandler( + 'TransactionController:getState', + (() => ({ transactions: [mockTradeTxMeta] })) as never, + ); + rootMessenger.registerActionHandler( + 'AuthenticationController:getBearerToken', + (async () => 'auth-token') as never, + ); + }; + + it('reports SUBMITTED at submission time, before any status event', async () => { + await withController( + { options: { isQuoteStatusManagerEnabled: () => true } }, + async ({ controller, rootMessenger }) => { + registerSubmitTxHandlers(rootMessenger); + + const result = await rootMessenger.call( + 'BridgeStatusController:submitTx', + (mockEvmSwapQuoteResponse.trade as TxData).from, + mockEvmSwapQuoteResponse, + false, + ); + controller.stopAllPolling(); + + // Reported during submission, without any transactionStatusUpdated event. + expect(result.id).toBe(EVM_TX_META_ID); + expect( + controller.state.txHistory[EVM_TX_META_ID] + .reportedSubmittedTxHash, + ).toBe(EVM_TRADE_HASH); + expect( + Object.keys(controller.state.quoteUpdateStatusStore), + ).toStrictEqual([`${EVM_QUOTE_ID}:${EVM_TRADE_HASH}`]); + + // A later submitted event for the same hash is a no-op. + rootMessenger.publish( + 'TransactionController:transactionStatusUpdated', + { transactionMeta: mockTradeTxMeta }, + ); + expect( + Object.keys(controller.state.quoteUpdateStatusStore), + ).toStrictEqual([`${EVM_QUOTE_ID}:${EVM_TRADE_HASH}`]); + + controller.resetState(); + }, + ); + }); + + it('reports SUBMITTED again when the trade hash is replaced after submission', async () => { + await withController( + { options: { isQuoteStatusManagerEnabled: () => true } }, + async ({ controller, rootMessenger }) => { + registerSubmitTxHandlers(rootMessenger); + + await rootMessenger.call( + 'BridgeStatusController:submitTx', + (mockEvmSwapQuoteResponse.trade as TxData).from, + mockEvmSwapQuoteResponse, + false, + ); + controller.stopAllPolling(); + + const replacementHash = '0xevmReplacementSrcTxHash'; + rootMessenger.publish( + 'TransactionController:transactionStatusUpdated', + { + transactionMeta: { ...mockTradeTxMeta, hash: replacementHash }, + }, + ); + + expect( + controller.state.txHistory[EVM_TX_META_ID] + .reportedSubmittedTxHash, + ).toBe(replacementHash); + expect( + Object.keys(controller.state.quoteUpdateStatusStore).sort(), + ).toStrictEqual( + [ + `${EVM_QUOTE_ID}:${EVM_TRADE_HASH}`, + `${EVM_QUOTE_ID}:${replacementHash}`, + ].sort(), + ); + + controller.resetState(); + }, + ); + }); + + it('does not report SUBMITTED when the rekeyed history item is missing', async () => { + await withController( + { options: { isQuoteStatusManagerEnabled: () => true } }, + async ({ controller, rootMessenger }) => { + registerSubmitTxHandlers(rootMessenger); + + // Simulate a rekey that finds no pre-submission history item: the + // item is never moved to the trade-meta key, so the subsequent + // `#reportSubmittedOnce` runs against a non-existent history item + // and must bail out safely without reporting or throwing. + jest + .spyOn(historyUtils, 'rekeyHistoryItemInState') + .mockReturnValue(false); + + await rootMessenger.call( + 'BridgeStatusController:submitTx', + (mockEvmSwapQuoteResponse.trade as TxData).from, + mockEvmSwapQuoteResponse, + false, + ); + controller.stopAllPolling(); + + // Nothing was rekeyed onto the trade-meta id, so no submitted + // status was reported for it. + expect(controller.state.txHistory[EVM_TX_META_ID]).toBeUndefined(); + expect(controller.state.quoteUpdateStatusStore).toStrictEqual({}); + + controller.resetState(); + }, + ); + }); + }); + + describe('7702/nested batch sell', () => { + const BATCH_TX_META_ID = 'batchTxMetaId'; + const BATCH_SRC_TX_HASH = '0xbatchSrcTxHash'; + const BATCH_QUOTE_1 = 'batch-quote-1'; + const BATCH_QUOTE_2 = 'batch-quote-2'; + const BATCH_KEY_1 = `${BATCH_QUOTE_1}:${BATCH_SRC_TX_HASH}`; + const BATCH_KEY_2 = `${BATCH_QUOTE_2}:${BATCH_SRC_TX_HASH}`; + + /** + * Builds a 7702/nested batch history: a parent item that lists its child + * quotes via `quoteIds`, plus one child history item per quote. The + * parent's `startTime` is left at the (old) mock default so startup + * seeding is skipped and each test drives reporting via events. + * + * @returns The batch txHistory keyed by history id. + */ + function buildBatchHistory(): Record { + const parent = { + ...MockTxHistory.getPending({ + txMetaId: BATCH_TX_META_ID, + srcTxHash: BATCH_SRC_TX_HASH, + })[BATCH_TX_META_ID], + featureId: FeatureId.BATCH_SELL, + // The parent reports its quotes via `quoteIds`, not its own quoteId. + quoteId: undefined, + quoteIds: ['batchChild1', 'batchChild2'], + }; + const child1 = { + ...MockTxHistory.getPending({ + txMetaId: 'batchChild1', + srcTxHash: BATCH_SRC_TX_HASH, + }).batchChild1, + featureId: FeatureId.BATCH_SELL, + txMetaId: undefined, + quoteId: BATCH_QUOTE_1, + }; + const child2 = { + ...MockTxHistory.getPending({ + txMetaId: 'batchChild2', + srcTxHash: BATCH_SRC_TX_HASH, + }).batchChild2, + featureId: FeatureId.BATCH_SELL, + txMetaId: undefined, + quoteId: BATCH_QUOTE_2, + }; + return { + [BATCH_TX_META_ID]: parent, + batchChild1: child1, + batchChild2: child2, + }; + } + + const getBatchMessengerCall = () => + jest.fn((...args: unknown[]) => { + const action = args[0] as string; + if (action === 'TransactionController:getState') { + return { transactions: [] }; + } + if (action === 'AccountsController:getAccountByAddress') { + return mockSelectedAccount; + } + return undefined; + }); + + it('reports SUBMITTED for every quote in the batch under the shared tx hash', async () => { + await withController( + { + options: { + isQuoteStatusManagerEnabled: () => true, + state: { txHistory: buildBatchHistory() }, + }, + mockMessengerCall: jest.fn(), + }, + async ({ controller, rootMessenger }) => { + rootMessenger.publish( + 'TransactionController:transactionStatusUpdated', + { + transactionMeta: { + chainId: CHAIN_IDS.ARBITRUM, + networkClientId: 'eth-id', + time: Date.now(), + txParams: {} as unknown as TransactionParams, + type: TransactionType.swap, + status: TransactionStatus.submitted, + id: BATCH_TX_META_ID, + hash: BATCH_SRC_TX_HASH, + }, + }, + ); + + // One entry is created per quote in the batch, all keyed by the + // shared source tx hash. + expect( + Object.keys(controller.state.quoteUpdateStatusStore).sort(), + ).toStrictEqual([BATCH_KEY_1, BATCH_KEY_2].sort()); + expect( + controller.state.quoteUpdateStatusStore[BATCH_KEY_1].status, + ).toBe(QuoteStatusState.Submitted); + expect( + controller.state.quoteUpdateStatusStore[BATCH_KEY_2].status, + ).toBe(QuoteStatusState.Submitted); + expect( + controller.state.txHistory[BATCH_TX_META_ID] + .reportedSubmittedTxHash, + ).toBe(BATCH_SRC_TX_HASH); + + controller.resetState(); + }, + ); + }); + + it('does not re-report the batch quotes on a repeat submitted event', async () => { + await withController( + { + options: { + isQuoteStatusManagerEnabled: () => true, + state: { txHistory: buildBatchHistory() }, + }, + mockMessengerCall: jest.fn(), + }, + async ({ controller, rootMessenger }) => { + const submittedEvent = { + transactionMeta: { + chainId: CHAIN_IDS.ARBITRUM, + networkClientId: 'eth-id', + time: Date.now(), + txParams: {} as unknown as TransactionParams, + type: TransactionType.swap, + status: TransactionStatus.submitted, + id: BATCH_TX_META_ID, + hash: BATCH_SRC_TX_HASH, + }, + }; + + rootMessenger.publish( + 'TransactionController:transactionStatusUpdated', + submittedEvent, + ); + rootMessenger.publish( + 'TransactionController:transactionStatusUpdated', + submittedEvent, + ); + + // The already-reported guard keeps the store to exactly one entry + // per quote despite the duplicate event. + expect( + Object.keys(controller.state.quoteUpdateStatusStore).sort(), + ).toStrictEqual([BATCH_KEY_1, BATCH_KEY_2].sort()); + + controller.resetState(); + }, + ); + }); + + it('finalizes every quote in the batch as success when the batch confirms', async () => { + await withController( + { + options: { + isQuoteStatusManagerEnabled: () => true, + state: { txHistory: buildBatchHistory() }, + }, + mockMessengerCall: getBatchMessengerCall(), + }, + async ({ controller, rootMessenger }) => { + // A confirmed batch reports SUBMITTED for each quote (via the nested + // swap) and then finalizes them all under the shared txMetaId. + rootMessenger.publish( + 'TransactionController:transactionStatusUpdated', + { + transactionMeta: { + chainId: CHAIN_IDS.ARBITRUM, + networkClientId: 'eth-id', + time: Date.now(), + txParams: {} as unknown as TransactionParams, + type: TransactionType.batch, + status: TransactionStatus.confirmed, + id: BATCH_TX_META_ID, + hash: BATCH_SRC_TX_HASH, + nestedTransactions: [{ type: TransactionType.swap }], + } as unknown as TransactionMeta, + }, + ); + + expect( + Object.keys(controller.state.quoteUpdateStatusStore).sort(), + ).toStrictEqual([BATCH_KEY_1, BATCH_KEY_2].sort()); + expect( + controller.state.quoteUpdateStatusStore[BATCH_KEY_1].status, + ).toBe(QuoteStatusState.FinalizedSuccess); + expect( + controller.state.quoteUpdateStatusStore[BATCH_KEY_2].status, + ).toBe(QuoteStatusState.FinalizedSuccess); + + controller.resetState(); + }, + ); + }); + + it('finalizes every quote in the batch as failure when the batch fails', async () => { + await withController( + { + options: { + isQuoteStatusManagerEnabled: () => true, + state: { txHistory: buildBatchHistory() }, + }, + mockMessengerCall: getBatchMessengerCall(), + }, + async ({ controller, rootMessenger }) => { + // Report SUBMITTED for the batch quotes first (failure reporting does + // not create entries on its own). + rootMessenger.publish( + 'TransactionController:transactionStatusUpdated', + { + transactionMeta: { + chainId: CHAIN_IDS.ARBITRUM, + networkClientId: 'eth-id', + time: Date.now(), + txParams: {} as unknown as TransactionParams, + type: TransactionType.swap, + status: TransactionStatus.submitted, + id: BATCH_TX_META_ID, + hash: BATCH_SRC_TX_HASH, + }, + }, + ); + + rootMessenger.publish( + 'TransactionController:transactionStatusUpdated', + { + transactionMeta: { + chainId: CHAIN_IDS.ARBITRUM, + networkClientId: 'eth-id', + time: Date.now(), + txParams: {} as unknown as TransactionParams, + type: TransactionType.swap, + status: TransactionStatus.failed, + id: BATCH_TX_META_ID, + hash: BATCH_SRC_TX_HASH, + }, + }, + ); + + expect( + controller.state.quoteUpdateStatusStore[BATCH_KEY_1].status, + ).toBe(QuoteStatusState.FinalizedFailed); + expect( + controller.state.quoteUpdateStatusStore[BATCH_KEY_2].status, + ).toBe(QuoteStatusState.FinalizedFailed); + + controller.resetState(); + }, + ); + }); + + it('seeds a SUBMITTED entry for every batch quote from persisted history on startup', async () => { + await withController( + { + options: { + isQuoteStatusManagerEnabled: () => true, + state: { + txHistory: { + ...buildBatchHistory(), + // A recent startTime makes the parent eligible for backfill. + [BATCH_TX_META_ID]: { + ...buildBatchHistory()[BATCH_TX_META_ID], + startTime: Date.now(), + }, + }, + }, + }, + mockMessengerCall: getBatchMessengerCall(), + }, + async ({ controller }) => { + // Startup seeding replays reportSubmitted for each quote in the batch + // without waiting for a transaction event. + expect( + Object.keys(controller.state.quoteUpdateStatusStore).sort(), + ).toStrictEqual([BATCH_KEY_1, BATCH_KEY_2].sort()); + expect( + controller.state.txHistory[BATCH_TX_META_ID] + .reportedSubmittedTxHash, + ).toBe(BATCH_SRC_TX_HASH); + + controller.resetState(); + }, + ); + }); + }); + }); + + describe('seeding quote status entries from history on startup', () => { + let fetchSpy: jest.SpyInstance; + + beforeEach(() => { + jest.useFakeTimers(); + // Keep the quote-status update request in-flight so the manager never + // resolves and mutates state after the assertions/teardown. + fetchSpy = jest + .spyOn(globalThis, 'fetch') + .mockReturnValue(new Promise(() => undefined)); + }); + + afterEach(() => { + fetchSpy.mockRestore(); + jest.clearAllTimers(); + jest.useRealTimers(); + }); + + const getSeedMessengerCall = (transactions: TransactionMeta[] = []) => + jest.fn((...args: unknown[]) => { + const actionType = args[0] as string; + if (actionType === 'TransactionController:getState') { + return { transactions }; + } + if (actionType === 'AuthenticationController:getBearerToken') { + return Promise.resolve('auth-token'); + } + return undefined; + }); + + const getSeedHistoryItem = ({ + txMetaId, + quoteId, + srcTxHash, + startTime, + reportedSubmittedTxHash, + }: { + txMetaId: string; + quoteId?: string; + srcTxHash?: string; + startTime: number; + reportedSubmittedTxHash?: string; + }): BridgeHistoryItem => ({ + ...MockTxHistory.getPending({ + txMetaId, + srcTxHash: srcTxHash ?? 'undefined', + startTime, + })[txMetaId], + quoteId, + ...(reportedSubmittedTxHash ? { reportedSubmittedTxHash } : {}), + }); + + it('seeds a missing quote status entry from a recent history item', async () => { + await withController( + { + options: { + isQuoteStatusManagerEnabled: () => true, + state: { + txHistory: { + seedTx1: getSeedHistoryItem({ + txMetaId: 'seedTx1', + quoteId: 'seed-quote-1', + srcTxHash: '0xseedHash1', + startTime: Date.now(), + }), + }, + }, + }, + mockMessengerCall: getSeedMessengerCall(), + }, + async ({ controller }) => { + expect( + controller.state.txHistory.seedTx1.reportedSubmittedTxHash, + ).toBe('0xseedHash1'); + expect( + Object.keys(controller.state.quoteUpdateStatusStore), + ).toStrictEqual(['seed-quote-1:0xseedHash1']); + + controller.resetState(); + }, + ); + }); + + it('falls back to the transaction hash when the history item has no source hash', async () => { + const txMeta = { + id: 'seedTx2', + hash: '0xfallbackHash2', + status: TransactionStatus.submitted, + type: TransactionType.bridge, + chainId: numberToHex(42161), + txParams: {} as unknown as TransactionParams, + } as unknown as TransactionMeta; + + await withController( + { + options: { + isQuoteStatusManagerEnabled: () => true, + state: { + txHistory: { + seedTx2: getSeedHistoryItem({ + txMetaId: 'seedTx2', + quoteId: 'seed-quote-2', + srcTxHash: 'undefined', + startTime: Date.now(), + }), + }, + }, + }, + mockMessengerCall: getSeedMessengerCall([txMeta]), + }, + async ({ controller }) => { + expect( + Object.keys(controller.state.quoteUpdateStatusStore), + ).toStrictEqual(['seed-quote-2:0xfallbackHash2']); + + controller.resetState(); + }, + ); + }); + + it('skips history items older than the backfill window', async () => { + await withController( + { + options: { + isQuoteStatusManagerEnabled: () => true, + state: { + txHistory: { + seedTx3: getSeedHistoryItem({ + txMetaId: 'seedTx3', + quoteId: 'seed-quote-3', + srcTxHash: '0xseedHash3', + startTime: + Date.now() - QUOTE_STATUS_BACKFILL_WINDOW_MS - 1000, + }), + }, + }, + }, + mockMessengerCall: getSeedMessengerCall(), + }, + async ({ controller }) => { + expect(controller.state.quoteUpdateStatusStore).toStrictEqual({}); + expect( + controller.state.txHistory.seedTx3.reportedSubmittedTxHash, + ).toBeUndefined(); + }, + ); + }); + + it('skips history items without a quoteId', async () => { + await withController( + { + options: { + isQuoteStatusManagerEnabled: () => true, + state: { + txHistory: { + seedTx4: getSeedHistoryItem({ + txMetaId: 'seedTx4', + srcTxHash: '0xseedHash4', + startTime: Date.now(), + }), + }, + }, + }, + mockMessengerCall: getSeedMessengerCall(), + }, + async ({ controller }) => { + expect(controller.state.quoteUpdateStatusStore).toStrictEqual({}); + }, + ); + }); + + it('skips history items without a txMetaId', async () => { + await withController( + { + options: { + isQuoteStatusManagerEnabled: () => true, + state: { + txHistory: { + seedTx5: { + ...getSeedHistoryItem({ + txMetaId: 'seedTx5', + quoteId: 'seed-quote-5', + srcTxHash: '0xseedHash5', + startTime: Date.now(), + }), + txMetaId: undefined, + }, + }, + }, + }, + mockMessengerCall: getSeedMessengerCall(), + }, + async ({ controller }) => { + expect(controller.state.quoteUpdateStatusStore).toStrictEqual({}); + }, + ); + }); + + it('skips when neither the history item nor the transaction has a source hash', async () => { + await withController( + { + options: { + isQuoteStatusManagerEnabled: () => true, + state: { + txHistory: { + seedTx6: getSeedHistoryItem({ + txMetaId: 'seedTx6', + quoteId: 'seed-quote-6', + srcTxHash: 'undefined', + startTime: Date.now(), + }), + }, + }, + }, + mockMessengerCall: getSeedMessengerCall([]), + }, + async ({ controller }) => { + expect(controller.state.quoteUpdateStatusStore).toStrictEqual({}); + }, + ); + }); + + it('does not re-seed an entry that was already reported', async () => { + await withController( + { + options: { + isQuoteStatusManagerEnabled: () => true, + state: { + txHistory: { + seedTx7: getSeedHistoryItem({ + txMetaId: 'seedTx7', + quoteId: 'seed-quote-7', + srcTxHash: '0xseedHash7', + startTime: Date.now(), + reportedSubmittedTxHash: '0xseedHash7', + }), + }, + }, + }, + mockMessengerCall: getSeedMessengerCall(), + }, + async ({ controller }) => { + expect(controller.state.quoteUpdateStatusStore).toStrictEqual({}); + }, + ); + }); + + it('does not seed when the quote status manager is disabled', async () => { + await withController( + { + options: { + isQuoteStatusManagerEnabled: () => false, + state: { + txHistory: { + seedTx8: getSeedHistoryItem({ + txMetaId: 'seedTx8', + quoteId: 'seed-quote-8', + srcTxHash: '0xseedHash8', + startTime: Date.now(), + }), + }, + }, + }, + mockMessengerCall: getSeedMessengerCall(), + }, + async ({ controller }) => { + expect(controller.state.quoteUpdateStatusStore).toStrictEqual({}); + // The guard must not persist `reportedSubmittedTxHash`: doing so + // would mark the history item as already reported even though no + // store entry or backend update was ever created, so it could never + // be seeded once the manager is re-enabled. + expect( + controller.state.txHistory.seedTx8.reportedSubmittedTxHash, + ).toBeUndefined(); + }, + ); + }); + }); + + describe('metadata', () => { + it('includes expected state in debug snapshots', async () => { + await withController(async ({ controller }) => { + expect( + deriveStateFromMetadata( + controller.state, + controller.metadata, + 'includeInDebugSnapshot', + ), + ).toMatchInlineSnapshot(`{}`); + }); + }); + + it('includes expected state in state logs', async () => { + await withController(async ({ controller }) => { + expect( + deriveStateFromMetadata( + controller.state, + controller.metadata, + 'includeInStateLogs', + ), + ).toMatchInlineSnapshot(` + { + "txHistory": {}, + } + `); + }); + }); + + it('persists expected state', async () => { + await withController(async ({ controller }) => { + expect( + deriveStateFromMetadata( + controller.state, + controller.metadata, + 'persist', + ), + ).toMatchInlineSnapshot(` + { + "quoteUpdateStatusStore": {}, + "txHistory": {}, + } + `); + }); + }); + + it('exposes expected state to UI', async () => { + await withController(async ({ controller }) => { + expect( + deriveStateFromMetadata( + controller.state, + controller.metadata, + 'usedInUi', + ), + ).toMatchInlineSnapshot(` + { + "txHistory": {}, + } + `); + }); + }); + }); +}); diff --git a/packages/bridge-status-controller/src/bridge-status-controller.ts b/packages/bridge-status-controller/src/bridge-status-controller.ts new file mode 100644 index 00000000000..ab7f4e27328 --- /dev/null +++ b/packages/bridge-status-controller/src/bridge-status-controller.ts @@ -0,0 +1,1858 @@ +import type { StateMetadata } from '@metamask/base-controller'; +import { + QuoteMetadata, + RequiredEventContextFromClient, + QuoteResponseV1, + Trade, + FeatureId, + BatchSellTradesResponse, + InputPrimaryDenomination, + QuoteResponse, + toQuoteMetadataV1, + toQuoteResponseV1, + isQuoteResponseV2, +} from '@metamask/bridge-controller'; +import type { QuoteMetadataMigrationPhase } from '@metamask/bridge-controller'; +import { + isNonEvmChainId, + StatusTypes, + getAccountHardwareType, + UnifiedSwapBridgeEventName, + isCrossChain, + MetricsActionType, + MetaMetricsSwapsEventSource, + PollingStatus, + formatChainIdToHex, +} from '@metamask/bridge-controller'; +import type { TraceCallback } from '@metamask/controller-utils'; +import { StaticIntervalPollingController } from '@metamask/polling-controller'; +import { + TransactionStatus, + TransactionType, + TransactionController, + generateBatchId, +} from '@metamask/transaction-controller'; +import type { TransactionMeta } from '@metamask/transaction-controller'; +import { numberToHex } from '@metamask/utils'; +import type { Hex } from '@metamask/utils'; + +import { IntentManager } from './bridge-status-controller.intent'; +import { + ALLOWED_FEATURE_IDS_FOR_STATUS_EVENTS, + BRIDGE_PROD_API_BASE_URL, + BRIDGE_STATUS_CONTROLLER_NAME, + DEFAULT_BRIDGE_STATUS_CONTROLLER_STATE, + MAX_ATTEMPTS, + REFRESH_INTERVAL_MS, +} from './constants.js'; +import { + QUOTE_STATUS_BACKFILL_WINDOW_MS, + QUOTE_STATUS_UPDATE_ENTRY_TTL, + QUOTE_STATUS_UPDATE_RETRY_INTERVAL_MS, +} from './quote-status-manager/constants.js'; +import { + QuoteStatusGetError, + QuoteStatusUpdateError, +} from './quote-status-manager/errors.js'; +import { QuoteStatusManager } from './quote-status-manager/quotes-status-manager.js'; +import executeSubmitStrategy from './strategy/index.js'; +import { SubmitStep } from './strategy/types.js'; +import type { SubmitStrategyParams } from './strategy/types.js'; +import type { + BridgeStatusControllerState, + StartPollingForBridgeTxStatusArgsSerialized, + FetchFunction, + BridgeHistoryItem, +} from './types.js'; +import type { BridgeStatusControllerMessenger } from './types.js'; +import { BridgeClientId } from './types.js'; +import { getAccountByAddress } from './utils/accounts.js'; +import { getJwt } from './utils/authentication.js'; +import { + fetchBridgeTxStatus, + fetchBridgeQuoteStatus, + getStatusRequestWithSrcTxHash, + shouldSkipFetchDueToFetchFailures, + shouldWaitForFinalBridgeStatus, +} from './utils/bridge-status.js'; +import { + getBatchSellTrades, + stopPollingForQuotes, + trackMetricsEvent, +} from './utils/bridge.js'; +import { + getInitialHistoryItem, + getMatchingHistoryEntryForTxMeta, + rekeyHistoryItemInState, + shouldPollHistoryItem, + getMatchingHistoryEntryForApprovalTxMeta, +} from './utils/history.js'; +import { + getFinalizedTxProperties, + getPriceImpactFromQuote, + getRequestMetadataFromHistory, + getRequestParamFromHistory, + getTradeDataFromHistory, + getEVMTxPropertiesFromTransactionMeta, + getTxStatusesFromHistory, + getPreConfirmationPropertiesFromQuote, +} from './utils/metrics.js'; +import { getSelectedChainId } from './utils/network.js'; +import { + getSwapOperationCompletedTraceParams, + getTraceParams, +} from './utils/trace.js'; +import type { + SwapOperationResult, + SwapOperationTerminalStage, +} from './utils/trace.js'; +import { + getTransactionMetaById, + getTransactions, + checkIsDelegatedAccount, + isCrossChainTx, + updateTransactionsInBatch, + hasNestedSwapTransactions, +} from './utils/transaction.js'; + +const metadata: StateMetadata = { + // We want to persist the bridge status state so that we can show the proper data for the Activity list + // basically match the behavior of TransactionController + txHistory: { + includeInStateLogs: true, + persist: true, + includeInDebugSnapshot: false, + usedInUi: true, + }, + // Deferred status updates used by QuoteStatusUpdateManager + quoteUpdateStatusStore: { + includeInStateLogs: false, + persist: true, + includeInDebugSnapshot: false, + usedInUi: false, + }, +}; + +/** The input to start polling for the {@link BridgeStatusController} */ +type BridgeStatusPollingInput = FetchBridgeTxStatusArgs; + +type SrcTxMetaId = string; +export type FetchBridgeTxStatusArgs = { + bridgeTxMetaId: string; +}; + +const MESSENGER_EXPOSED_METHODS = [ + 'startPollingForBridgeTxStatus', + 'wipeBridgeStatus', + 'resetState', + 'submitTx', + 'submitIntent', + 'submitBatchSell', + 'restartPollingForFailedAttempts', + 'getBridgeHistoryItemByTxMetaId', +] as const; + +export class BridgeStatusController extends StaticIntervalPollingController()< + typeof BRIDGE_STATUS_CONTROLLER_NAME, + BridgeStatusControllerState, + BridgeStatusControllerMessenger +> { + #pollingTokensByTxMetaId: Record = {}; + + readonly #intentManager: IntentManager; + + readonly #quoteStatusManager: QuoteStatusManager; + + readonly #clientId: BridgeClientId; + + readonly #fetchFn: FetchFunction; + + readonly #config: { + customBridgeApiBaseUrl: string; + }; + + readonly #addTransactionBatchFn: typeof TransactionController.prototype.addTransactionBatch; + + readonly #trace: TraceCallback; + + constructor({ + messenger, + state, + clientId, + clientProduct, + clientVersion, + fetchFn, + addTransactionBatchFn, + config, + traceFn, + onQuoteStatusManagerError, + isQuoteStatusManagerEnabled, + }: { + messenger: BridgeStatusControllerMessenger; + state?: Partial; + clientId: BridgeClientId; + clientProduct: string; + clientVersion?: string; + fetchFn: FetchFunction; + addTransactionBatchFn: typeof TransactionController.prototype.addTransactionBatch; + config?: { + customBridgeApiBaseUrl?: string; + }; + traceFn?: TraceCallback; + onQuoteStatusManagerError?: ( + error: QuoteStatusUpdateError | QuoteStatusGetError, + ) => void; + isQuoteStatusManagerEnabled?: () => boolean; + }) { + super({ + name: BRIDGE_STATUS_CONTROLLER_NAME, + metadata, + messenger, + // Restore the persisted state + state: { + ...DEFAULT_BRIDGE_STATUS_CONTROLLER_STATE, + ...state, + }, + }); + + this.#clientId = clientId; + this.#fetchFn = fetchFn; + this.#addTransactionBatchFn = addTransactionBatchFn; + this.#config = { + customBridgeApiBaseUrl: + config?.customBridgeApiBaseUrl ?? BRIDGE_PROD_API_BASE_URL, + }; + this.#trace = traceFn ?? (((_request, fn) => fn?.()) as TraceCallback); + this.#intentManager = new IntentManager({ + messenger: this.messenger, + customBridgeApiBaseUrl: this.#config.customBridgeApiBaseUrl, + fetchFn: this.#fetchFn, + }); + this.#quoteStatusManager = new QuoteStatusManager({ + messenger: this.messenger, + clientId: this.#clientId, + clientProduct, + clientVersion, + apiBaseUrl: this.#config.customBridgeApiBaseUrl, + initialData: this.state.quoteUpdateStatusStore, + onPersistUpdates: (updates): void => { + this.update((draft) => { + draft.quoteUpdateStatusStore = updates; + }); + }, + entryTtlMs: QUOTE_STATUS_UPDATE_ENTRY_TTL, + updateIntervalMs: QUOTE_STATUS_UPDATE_RETRY_INTERVAL_MS, + onError: onQuoteStatusManagerError, + isEnabled: isQuoteStatusManagerEnabled, + }); + + // Register action handlers + this.messenger.registerMethodActionHandlers( + this, + MESSENGER_EXPOSED_METHODS, + ); + + // Set interval + this.setIntervalLength(REFRESH_INTERVAL_MS); + + this.messenger.subscribe< + 'TransactionController:transactionStatusUpdated', + { + historyKey?: string; + historyItem?: BridgeHistoryItem; + txMeta: TransactionMeta; + isApprovalTxMeta: boolean; + } + >( + 'TransactionController:transactionStatusUpdated', + ({ txMeta, historyKey, historyItem, isApprovalTxMeta }) => { + if (!txMeta) { + return; + } + const { type, status } = txMeta; + + // Allow event publishing if the txMeta is a swap/bridge OR if the + // corresponding history item exists + const isSwapOrBridgeTransaction = type && isCrossChainTx(type); + if (!isSwapOrBridgeTransaction && !historyKey && !historyItem) { + return; + } + + switch (status) { + case TransactionStatus.submitted: + // EVM txs report SUBMITTED here (not via transactionSubmitted) so hash + // replacements before confirmation still reach the Bridge API. + if ( + txMeta.hash && + txMeta.type && + isCrossChainTx(txMeta.type) && + !isApprovalTxMeta && + historyKey + ) { + this.#reportSubmittedOnce(historyKey, txMeta.hash, txMeta.id); + } + break; + case TransactionStatus.confirmed: + this.#onTransactionConfirmed({ + txMeta, + historyKey, + isApprovalTxMeta, + }); + break; + case TransactionStatus.failed: + case TransactionStatus.dropped: + case TransactionStatus.rejected: + this.#onTransactionFailed({ txMeta, historyKey, isApprovalTxMeta }); + break; + default: + break; + } + }, + ({ transactionMeta }) => { + const entry = getMatchingHistoryEntryForTxMeta( + this.state.txHistory, + transactionMeta, + ); + const approvalEntry = getMatchingHistoryEntryForApprovalTxMeta( + this.state.txHistory, + transactionMeta, + ); + const entryToUse = entry ?? approvalEntry; + + return { + historyKey: entryToUse?.[0], + historyItem: entryToUse?.[1], + txMeta: transactionMeta, + isApprovalTxMeta: + entryToUse?.[1]?.approvalTxId === transactionMeta.id, + }; + }, + ); + + // Seed any missing quote-status entries from persisted history before + // init(), so init()'s reconciliation loop can finalize quotes whose + // deferred entry was never created (e.g. the client closed before + // reportSubmitted ran). + this.#seedQuoteStatusEntriesFromHistory(); + + // Replay swap/bridge finalizations that resolved while the client was + // closed, before resuming polling (which recovers in-flight bridges). + this.#quoteStatusManager.init(); + + // If you close the extension, but keep the browser open, the polling continues + // If you close the browser, the polling stops + // Check for historyItems that do not have a status of complete and restart polling + this.#restartPollingForIncompleteHistoryItems(); + } + + readonly #traceSwapOperationCompleted = async ( + historyKey: string | undefined, + result: SwapOperationResult, + terminalStage: SwapOperationTerminalStage, + ): Promise => { + if (!historyKey) { + return; + } + + const historyItem = this.state.txHistory[historyKey]; + const featureId = historyItem?.featureId ?? FeatureId.UNIFIED_SWAP_BRIDGE; + if ( + !historyItem || + historyItem.batchSellData || + !ALLOWED_FEATURE_IDS_FOR_STATUS_EVENTS.includes(featureId) + ) { + return; + } + + await this.#trace( + getSwapOperationCompletedTraceParams( + historyItem, + historyKey, + result, + terminalStage, + ), + () => undefined, + ); + }; + + readonly #onTransactionFailed = ({ + txMeta, + historyKey, + isApprovalTxMeta, + }: { + txMeta: TransactionMeta; + historyKey?: string; + isApprovalTxMeta: boolean; + }): void => { + const isHistoryItemAlreadyFailed = historyKey + ? this.state.txHistory[historyKey]?.status.status === StatusTypes.FAILED + : false; + const isIntent = historyKey + ? Boolean(this.state.txHistory[historyKey]?.quote.intent) + : false; + + this.#updateHistoryItem({ + historyKey, + status: StatusTypes.FAILED, + txHash: isApprovalTxMeta ? undefined : txMeta.hash, + completionTime: Date.now(), + }); + + if (txMeta.status === TransactionStatus.rejected) { + return; + } + + if (isHistoryItemAlreadyFailed) { + return; + } + + if (!isIntent) { + this.#traceSwapOperationCompleted(historyKey, 'error', 'source').catch( + () => undefined, + ); + } + + // Report finalized failure for swap/bridge transactions. + // Note: TransactionStatus.rejected means the user cancelled signing, so the tx was never broadcast. + // `hasNestedSwapTransactions` also covers batch/7702 swaps whose type may + // still read as `batch` rather than `swap`. + if ( + (txMeta.type && isCrossChainTx(txMeta.type)) || + hasNestedSwapTransactions(txMeta) + ) { + this.#quoteStatusManager.reportFinalised( + txMeta.id, + false, + txMeta.chainId, + txMeta.hash, + ); + } + + this.#trackUnifiedSwapBridgeEvent( + UnifiedSwapBridgeEventName.Failed, + historyKey, + getEVMTxPropertiesFromTransactionMeta(txMeta), + ); + }; + + // Only EVM txs + readonly #onTransactionConfirmed = ({ + txMeta, + historyKey, + isApprovalTxMeta, + }: { + txMeta: TransactionMeta; + historyKey?: string; + isApprovalTxMeta: boolean; + }): void => { + // Return early if the confirmed txMeta is for an approval since we + // still need to wait for the trade to be confirmed + if (isApprovalTxMeta) { + return; + } + + this.#updateHistoryItem({ + historyKey, + txHash: txMeta.hash, + }); + const isIntent = historyKey + ? Boolean(this.state.txHistory[historyKey]?.quote.intent) + : false; + + const isSwap = + txMeta.type === TransactionType.swap || hasNestedSwapTransactions(txMeta); + + if (isSwap) { + this.#updateHistoryItem({ + historyKey, + status: StatusTypes.COMPLETE, + completionTime: Date.now(), + }); + + // For EVM intent-based swaps the synthetic tx transitions + // submitted→confirmed in a single update that carries the CoW + // settlement hash, so the submitted status handler never has a hash + // and reportSubmitted is never called. Call it here (before + // reportFinalised) so the deferred-queue entry is created. + const historyItem = historyKey + ? this.state.txHistory[historyKey] + : undefined; + if ( + historyKey && + historyItem && + txMeta.hash && + !isNonEvmChainId(historyItem.quote.srcChainId) + ) { + this.#reportSubmittedOnce(historyKey, txMeta.hash, txMeta.id); + } + this.#quoteStatusManager.reportFinalised( + txMeta.id, + true, + txMeta.chainId, + txMeta.hash, + ); + if (!isIntent) { + this.#traceSwapOperationCompleted( + historyKey, + 'success', + 'source', + ).catch(() => undefined); + } + this.#trackUnifiedSwapBridgeEvent( + UnifiedSwapBridgeEventName.Completed, + historyKey, + ); + } else if (historyKey) { + this.#startPollingForTxId(historyKey); + } + }; + + /** + * Reports the SUBMITTED quote status for non-EVM transactions. + * + * EVM transactions report SUBMITTED via the + * `TransactionController:transactionStatusUpdated` subscription, which also + * picks up hash replacements (speed-up/cancel) and the late hash assignment + * of smart/batch transactions. Non-EVM transactions (Solana, Bitcoin, Tron) + * are submitted through Snaps and never emit TransactionController lifecycle + * events, so they are reported here once the history item exists and the + * source tx hash is known. + * + * @param historyKey - The key of the history item in `txHistory` + * @param txMeta - The submitted trade transaction's id and hash + * @param txMeta.id - The transaction meta id, used for finalization matching + * @param txMeta.hash - The source chain transaction hash + */ + readonly #reportSubmittedForNonEvmTx = ( + historyKey: string, + txMeta?: { id?: string; hash?: string }, + ): void => { + if (!txMeta?.id || !txMeta.hash) { + return; + } + const historyItem = this.state.txHistory[historyKey]; + if (!historyItem || !isNonEvmChainId(historyItem.quote.srcChainId)) { + return; + } + this.#reportSubmittedOnce(historyKey, txMeta.hash, txMeta.id); + }; + + /** + * Reports a SUBMITTED quote status update exactly once per source tx hash. + * + * SUBMITTED can be triggered from several code paths (submission, the first + * poll where the hash is known, and the final-status branch) and the poll + * path runs on every interval. + * + * For 7702/nested batch sells a single source transaction carries multiple + * quotes: the parent history item lists all of them in `quoteIds` (each a key + * into `txHistory`). In that case every quote is reported under the shared + * source tx hash and `txMetaId`. + * + * @param historyKey - The key of the history item in `txHistory` + * @param srcTxHash - The source chain transaction hash + * @param txMetaId - The transaction meta id, used for finalization matching + */ + readonly #reportSubmittedOnce = ( + historyKey: string, + srcTxHash: string, + txMetaId: string, + ): void => { + const historyItem = this.state.txHistory[historyKey]; + if (!historyItem) { + return; + } + + // For a 7702/nested batch the parent item lists every quote in `quoteIds` + // (keys into `txHistory`); resolve each to its real quote id. Otherwise fall + // back to the item's own single quote id. + let quoteIds: string[]; + if (historyItem.quoteIds?.length) { + quoteIds = historyItem.quoteIds + .map((quoteKey) => this.state.txHistory[quoteKey]?.quoteId) + .filter((quoteId): quoteId is string => Boolean(quoteId)); + } else if (historyItem.quoteId) { + quoteIds = [historyItem.quoteId]; + } else { + quoteIds = []; + } + + if (quoteIds.length === 0) { + return; + } + + // `reportedSubmittedTxHash` is set once `reportSubmitted` is called. + // This avoids processing multiple `eportSubmitted` for the + // same swap/bridge. + if (historyItem.reportedSubmittedTxHash === srcTxHash) { + return; + } + + for (const quoteId of quoteIds) { + this.#quoteStatusManager.reportSubmitted( + quoteId, + srcTxHash, + txMetaId, + historyItem.quote.srcChainId, + ); + } + + this.update((state) => { + const item = state.txHistory[historyKey]; + if (item) { + item.reportedSubmittedTxHash = srcTxHash; + } + }); + }; + + resetState = (): void => { + this.#quoteStatusManager.destroy(); + this.update((state) => { + state.txHistory = DEFAULT_BRIDGE_STATUS_CONTROLLER_STATE.txHistory; + state.quoteUpdateStatusStore = + DEFAULT_BRIDGE_STATUS_CONTROLLER_STATE.quoteUpdateStatusStore; + }); + }; + + wipeBridgeStatus = ({ + address, + ignoreNetwork, + }: { + address: string; + ignoreNetwork: boolean; + }): void => { + // Wipe all networks for this address + if (ignoreNetwork) { + this.update((state) => { + state.txHistory = DEFAULT_BRIDGE_STATUS_CONTROLLER_STATE.txHistory; + }); + } else { + const selectedChainId = getSelectedChainId(this.messenger); + + this.#wipeBridgeStatusByChainId(address, selectedChainId); + } + }; + + /** + * Resets the attempts counter for a bridge transaction history item + * and restarts polling if it was previously stopped due to max attempts + * + * @param identifier - Object containing either txMetaId or txHash to identify the history item + * @param identifier.txMetaId - The transaction meta ID + * @param identifier.txHash - The transaction hash + */ + restartPollingForFailedAttempts = (identifier: { + txMetaId?: string; + txHash?: string; + }): void => { + const { txMetaId, txHash } = identifier; + + if (!txMetaId && !txHash) { + throw new Error('Either txMetaId or txHash must be provided'); + } + + // Find the history item by txMetaId or txHash + let targetTxMetaId: string | undefined; + + if (txMetaId) { + // Direct lookup by txMetaId + if (this.state.txHistory[txMetaId]) { + targetTxMetaId = txMetaId; + } + } else if (txHash) { + // Search by txHash in status.srcChain.txHash + targetTxMetaId = Object.keys(this.state.txHistory).find( + (id) => this.state.txHistory[id].status.srcChain.txHash === txHash, + ); + } + + if (!targetTxMetaId) { + throw new Error( + `No bridge transaction history found for ${ + txMetaId ? `txMetaId: ${txMetaId}` : `txHash: ${txHash}` + }`, + ); + } + + const historyItem = this.state.txHistory[targetTxMetaId]; + + // Capture attempts count before resetting for metrics + const previousAttempts = historyItem.attempts?.counter ?? 0; + + // Reset the attempts counter + this.update((state) => { + if (targetTxMetaId) { + state.txHistory[targetTxMetaId].attempts = undefined; + } + }); + + // Restart polling if it was stopped and this tx still needs status updates + if (shouldPollHistoryItem(historyItem)) { + // Check if polling was stopped (no active polling token) + const existingPollingToken = + this.#pollingTokensByTxMetaId[targetTxMetaId]; + + if (!existingPollingToken) { + // Restart polling + this.#startPollingForTxId(targetTxMetaId); + + // Track polling manually restarted event + this.#trackUnifiedSwapBridgeEvent( + UnifiedSwapBridgeEventName.PollingStatusUpdated, + targetTxMetaId, + { + polling_status: PollingStatus.ManuallyRestarted, + retry_attempts: previousAttempts, + }, + ); + } + } + }; + + /** + * Gets a bridge history item from the history by its transaction meta ID + * + * @param txMetaId - The transaction meta ID to look up + * @returns The bridge history item if found, undefined otherwise + */ + getBridgeHistoryItemByTxMetaId = ( + txMetaId: string, + ): BridgeHistoryItem | undefined => { + return this.state.txHistory[txMetaId]; + }; + + /** + * Seeds missing quote-status entries from persisted history on startup. + * + * The deferred quote-status entry (keyed by `${quoteId}:${srcTxHash}`) is only + * created when `reportSubmitted` runs. If the client closes after a swap/bridge + * is submitted but before that happens, the entry is never created and + * `QuoteStatusManager.init()` has nothing to reconcile. The persisted history + * item carries the `quoteId`, source tx hash, and `txMetaId` needed to rebuild + * the entry, so we replay `reportSubmitted` here (idempotent via + * `#reportSubmittedOnce`) before `init()` runs. + */ + readonly #seedQuoteStatusEntriesFromHistory = (): void => { + if (!this.#quoteStatusManager.enabled) { + // Skip seeding to prevent `#reportSubmittedOnce` from persisting + // `reportedSubmittedTxHash` so recent bridge history cannot be marked as + // already reported with no store entry or backend update. + return; + } + + for (const [historyKey, historyItem] of Object.entries( + this.state.txHistory, + )) { + const { quoteId, quoteIds, txMetaId } = historyItem; + // A 7702/nested batch parent reports its quotes via `quoteIds` rather than + // its own single `quoteId`, so accept either. `#reportSubmittedOnce` + // resolves the actual set of quotes to report. + if ((!quoteId && !quoteIds?.length) || !txMetaId) { + continue; + } + + // Skip items older than the backfill window: the backend would reject + // a status report for a quote that old, so there is no point creating + // an entry or making a request. + if ( + Date.now() - historyItem.startTime > + QUOTE_STATUS_BACKFILL_WINDOW_MS + ) { + continue; + } + + // Prefer the history hash; fall back to the tx hash (covers STX swaps + // confirmed while closed, where the history hash was never written). + const srcTxHash = + historyItem.status.srcChain.txHash ?? + getTransactionMetaById(this.messenger, txMetaId)?.hash; + if (!srcTxHash) { + continue; + } + + // Call `reportSubmittedOnce` for each history item that satisfies + // the above criteria. The method will then take care the rest + // (ie. if it actually needs to be reported or not because it already has been). + this.#reportSubmittedOnce(historyKey, srcTxHash, txMetaId); + } + }; + + /** + * Restart polling for txs that are not in a final state + * This is called during initialization + */ + readonly #restartPollingForIncompleteHistoryItems = (): void => { + // Check for historyItems that do not have a status of complete and restart polling + const { txHistory } = this.state; + const historyItems = Object.entries(txHistory); + const incompleteHistoryItems = historyItems + .filter( + ([_, historyItem]) => + historyItem.status.status === StatusTypes.PENDING || + historyItem.status.status === StatusTypes.UNKNOWN, + ) + // Only poll items with txMetaId (post-submission items) + .filter(([_, historyItem]: [string, BridgeHistoryItem]) => { + if (!historyItem.txMetaId) { + return false; + } + // Check if we are already polling this tx, if so, skip restarting polling for that + const pollingToken = + this.#pollingTokensByTxMetaId[historyItem.txMetaId]; + return !pollingToken; + }) + // Only restart polling for items that still require status updates + .filter(([_, historyItem]: [string, BridgeHistoryItem]) => { + return shouldPollHistoryItem(historyItem); + }); + + incompleteHistoryItems.forEach( + ([historyKey, historyItem]: [string, BridgeHistoryItem]) => { + const shouldSkipFetch = shouldSkipFetchDueToFetchFailures( + historyItem.attempts, + ); + if (shouldSkipFetch) { + return; + } + + // We manually call startPolling() here rather than go through startPollingForBridgeTxStatus() + // because we don't want to overwrite the existing historyItem in state + this.#startPollingForTxId(historyKey); + }, + ); + }; + + readonly #addTxToHistory = ( + historyKey: string, + ...args: Parameters + ): string => { + const txHistoryItem = getInitialHistoryItem(...args); + this.update((state) => { + state.txHistory[historyKey] = txHistoryItem; + }); + return historyKey; + }; + + /** + * Rekeys a history item from actionId to txMeta.id after successful submission. + * Also updates txMetaId and srcTxHash which weren't available pre-submission. + * + * @param oldKey - The temporary key to use for the history item, usually the actionId + * @param newKey - The new key to use, typcally the txmeta.id + * @param txMeta - The transaction meta from the successful submission + * @param txMeta.id - The transaction meta id to use as the new key + * @param txMeta.hash - The transaction hash to set on the history item + */ + readonly #rekeyHistoryItem = ( + oldKey: string, + newKey: string, + txMeta: { id: string; hash?: string }, + ): void => { + this.update((state) => { + rekeyHistoryItemInState(state, oldKey, newKey, txMeta); + }); + }; + + readonly #startPollingForTxId = (txId: string): void => { + // If we are already polling for this tx, stop polling for it before restarting + const existingPollingToken = this.#pollingTokensByTxMetaId[txId]; + if (existingPollingToken) { + this.stopPollingByPollingToken(existingPollingToken); + } + + const txHistoryItem = this.state.txHistory[txId]; + if (txHistoryItem && shouldPollHistoryItem(txHistoryItem)) { + this.#pollingTokensByTxMetaId[txId] = this.startPolling({ + bridgeTxMetaId: txId, + }); + } + }; + + /** + * @deprecated For EVM/Solana swap/bridge txs we add tx to history in submitTx() + * For Solana swap/bridge we start polling in submitTx() + * For EVM bridge we listen for 'TransactionController:transactionConfirmed' and start polling there + * No clients currently call this, safe to remove in future versions + * + * Adds tx to history and starts polling for the bridge tx status + * + * @param txHistoryMeta - The parameters for creating the history item + */ + startPollingForBridgeTxStatus = ( + txHistoryMeta: StartPollingForBridgeTxStatusArgsSerialized, + ): void => { + const { bridgeTxMeta } = txHistoryMeta; + + if (!bridgeTxMeta?.id) { + throw new Error( + 'Cannot start polling: bridgeTxMeta.id is required for polling', + ); + } + + const historyKey = this.#addTxToHistory(bridgeTxMeta.id, txHistoryMeta); + this.#startPollingForTxId(historyKey); + }; + + // This will be called after you call this.startPolling() + // The args passed in are the args you passed in to startPolling() + _executePoll = async ( + pollingInput: BridgeStatusPollingInput, + ): Promise => { + await this.#fetchBridgeTxStatus(pollingInput); + }; + + /** + * Handles the failure to fetch the bridge tx status + * We eventually stop polling for the tx if we fail too many times + * Failures (500 errors) can be due to: + * - The srcTxHash not being available immediately for STX + * - The srcTxHash being invalid for the chain. This case will never resolve so we stop polling for it to avoid hammering the Bridge API forever. + * + * @param bridgeTxMetaId - The txMetaId of the bridge tx + */ + readonly #handleFetchFailure = (bridgeTxMetaId: string): void => { + const { attempts } = this.state.txHistory[bridgeTxMetaId]; + + const newAttempts = attempts + ? { + counter: attempts.counter + 1, + lastAttemptTime: Date.now(), + } + : { + counter: 1, + lastAttemptTime: Date.now(), + }; + + // If we've failed too many times, stop polling for the tx + const pollingToken = this.#pollingTokensByTxMetaId[bridgeTxMetaId]; + if (newAttempts.counter >= MAX_ATTEMPTS && pollingToken) { + this.stopPollingByPollingToken(pollingToken); + delete this.#pollingTokensByTxMetaId[bridgeTxMetaId]; + + // Track max polling reached event + const historyItem = this.state.txHistory[bridgeTxMetaId]; + if (historyItem) { + // Track polling status updated event + this.#trackPollingStatusUpdatedEvent( + bridgeTxMetaId, + PollingStatus.MaxPollingReached, + ); + } + } + + // Update the attempts counter + this.#updateHistoryItem({ + historyKey: bridgeTxMetaId, + attempts: newAttempts, + }); + }; + + /** + * Checks if the history item should be preserved so its status can be fetched. + * + * @param bridgeTxMetaId - The txMetaId of the bridge tx + */ + readonly #handleOldHistoryItem = async ( + bridgeTxMetaId: string, + ): Promise => { + // Continue polling on next restart if the history item is valid + if ( + this.state.txHistory[bridgeTxMetaId] && + (await shouldWaitForFinalBridgeStatus( + this.messenger, + this.state.txHistory[bridgeTxMetaId], + )) + ) { + return; + } + + const pollingToken = this.#pollingTokensByTxMetaId[bridgeTxMetaId]; + + // Track polling status updated event + this.#trackPollingStatusUpdatedEvent( + bridgeTxMetaId, + PollingStatus.InvalidTransactionHash, + ); + + // If we've failed too many times, stop polling for the tx + if (pollingToken) { + this.stopPollingByPollingToken(pollingToken); + delete this.#pollingTokensByTxMetaId[bridgeTxMetaId]; + } + + // Delete the history item so polling doesn't start over on the next restart. + // Report finalization as a failure here, this is the only place that + // permanently ends polling, so it's the correct and non-duplicative point + // to emit the final status. + const historyItem = this.state.txHistory[bridgeTxMetaId]; + this.#quoteStatusManager.reportFinalised( + bridgeTxMetaId, + false, + historyItem?.quote.srcChainId, + historyItem?.status.srcChain.txHash, + ); + this.#deleteHistoryItem(bridgeTxMetaId); + }; + + readonly #fetchBridgeTxStatus = async ({ + bridgeTxMetaId, + }: FetchBridgeTxStatusArgs): Promise => { + // 1. Check for history item + + const { txHistory } = this.state; + const historyItem = txHistory[bridgeTxMetaId]; + if (!historyItem) { + return; + } + + // 2. Check for previous failures + + if (shouldSkipFetchDueToFetchFailures(historyItem.attempts)) { + return; + } + + // 3. Fetch transaction status + + try { + let status: BridgeHistoryItem['status']; + let validationFailures: string[] = []; + + if (historyItem.quote.intent) { + const intentTxStatus = + await this.#intentManager.getIntentTransactionStatus( + bridgeTxMetaId, + historyItem.quote.srcChainId, + historyItem.quote.intent.protocol, + this.#clientId, + historyItem.status.srcChain.txHash, + ); + + if ( + intentTxStatus?.bridgeStatus === null || + intentTxStatus?.bridgeStatus === undefined + ) { + return; + } + status = intentTxStatus.bridgeStatus.status; + + // Report SUBMITTED as soon as the intent's source/settlement hash is + // known at poll time, before the order reaches a terminal status. + const intentSrcTxHash = status.srcChain.txHash; + if (intentSrcTxHash) { + this.#reportSubmittedOnce( + bridgeTxMetaId, + intentSrcTxHash, + bridgeTxMetaId, + ); + } + } else { + // We try here because we receive 500 errors from Bridge API if we try to fetch immediately after submitting the source tx + // Oddly mostly happens on Optimism, never on Arbitrum. By the 2nd fetch, the Bridge API responds properly. + // Also srcTxHash may not be available immediately for STX, so we don't want to fetch in those cases + const srcTxHash = this.#setAndGetSrcTxHash(bridgeTxMetaId); + + if (!srcTxHash) { + return; + } + + // Report SUBMITTED as soon as a srcTxHash is known at poll time, for + // every chain not just non-EVM sources. + this.#reportSubmittedOnce(bridgeTxMetaId, srcTxHash, bridgeTxMetaId); + + const statusRequest = getStatusRequestWithSrcTxHash( + historyItem.quote, + srcTxHash, + ); + const response = + (historyItem.quoteId + ? await fetchBridgeQuoteStatus( + this.#quoteStatusManager, + historyItem.quoteId, + ) + : null) ?? + (await fetchBridgeTxStatus( + statusRequest, + this.#clientId, + await getJwt(this.messenger), + this.#fetchFn, + this.#config.customBridgeApiBaseUrl, + )); + status = response.status; + validationFailures = response.validationFailures; + } + + if (validationFailures.length > 0) { + this.#trackUnifiedSwapBridgeEvent( + UnifiedSwapBridgeEventName.StatusValidationFailed, + bridgeTxMetaId, + { + failures: validationFailures, + refresh_count: historyItem.attempts?.counter ?? 0, + }, + ); + throw new Error( + `Bridge status validation failed: ${validationFailures.join(', ')}`, + ); + } + + // 4. Create bridge history item + + const newBridgeHistoryItem = { + ...historyItem, + status, + completionTime: + status.status === StatusTypes.COMPLETE || + status.status === StatusTypes.FAILED + ? Date.now() + : undefined, // TODO make this more accurate by looking up dest txHash block time + attempts: undefined, + }; + + // No need to purge these on network change or account change, TransactionController does not purge either. + // TODO In theory we can skip checking status if it's not the current account/network + // we need to keep track of the account that this is associated with as well so that we don't show it in Activity list for other accounts + // First stab at this will not stop polling when you are on a different account + this.update((state) => { + state.txHistory[bridgeTxMetaId] = newBridgeHistoryItem; + }); + + if (historyItem.quote.intent) { + this.#intentManager.syncTransactionFromIntentStatus( + bridgeTxMetaId, + historyItem, + ); + } + + // 5. After effects + + const pollingToken = this.#pollingTokensByTxMetaId[bridgeTxMetaId]; + + const isFinalStatus = + status.status === StatusTypes.COMPLETE || + status.status === StatusTypes.FAILED; + + if (isFinalStatus) { + if (pollingToken) { + this.stopPollingByPollingToken(pollingToken); + delete this.#pollingTokensByTxMetaId[bridgeTxMetaId]; + } + + // Ensure a deferred entry exists before reportFinalised is called. + const settlementTxHash = newBridgeHistoryItem.status.srcChain.txHash; + if (settlementTxHash) { + this.#reportSubmittedOnce( + bridgeTxMetaId, + settlementTxHash, + bridgeTxMetaId, + ); + } + + this.#quoteStatusManager.reportFinalised( + bridgeTxMetaId, + status.status === StatusTypes.COMPLETE, + historyItem.quote.srcChainId, + settlementTxHash, + ); + + await this.#traceSwapOperationCompleted( + bridgeTxMetaId, + status.status === StatusTypes.COMPLETE ? 'success' : 'error', + isCrossChain( + historyItem.quote.srcChainId, + historyItem.quote.destChainId, + ) + ? 'destination' + : 'source', + ).catch(() => undefined); + + if (status.status === StatusTypes.COMPLETE) { + this.#trackUnifiedSwapBridgeEvent( + UnifiedSwapBridgeEventName.Completed, + bridgeTxMetaId, + ); + this.messenger.publish( + 'BridgeStatusController:destinationTransactionCompleted', + historyItem.quote.destAsset.assetId, + ); + } + if (status.status === StatusTypes.FAILED) { + this.#trackUnifiedSwapBridgeEvent( + UnifiedSwapBridgeEventName.Failed, + bridgeTxMetaId, + ); + } + } + } catch (error) { + console.warn('Failed to fetch bridge tx status', error); + this.#handleFetchFailure(bridgeTxMetaId); + } finally { + await this.#handleOldHistoryItem(bridgeTxMetaId); + } + }; + + /** + * Returns the srcTxHash for a non-STX EVM tx, the hash from the bridge status api, + * or the local hash from the TransactionController if the tx is in a finalized state + * + * @param bridgeTxMetaId - The bridge tx meta id + * @returns The srcTxHash + */ + readonly #setAndGetSrcTxHash = ( + bridgeTxMetaId: string, + ): string | undefined => { + const { txHistory } = this.state; + // Prefer the srcTxHash from bridgeStatusState so we don't have to look up in TransactionController + // But it is possible to have bridgeHistoryItem in state without the srcTxHash yet when it is an STX + const srcTxHash = txHistory[bridgeTxMetaId].status.srcChain.txHash; + + if ( + srcTxHash || + isNonEvmChainId(txHistory[bridgeTxMetaId].quote.srcChainId) + ) { + return srcTxHash; + } + + // Update history with TransactionController's hash if it has been updated + const txMeta = getTransactionMetaById(this.messenger, bridgeTxMetaId); + + if (!txMeta) { + return undefined; + } + + // Wait for finalized status before updating the history item + const localTxHash = [ + TransactionStatus.confirmed, + TransactionStatus.dropped, + TransactionStatus.rejected, + TransactionStatus.failed, + ].includes(txMeta.status) + ? txMeta.hash + : undefined; + this.#updateHistoryItem({ + historyKey: bridgeTxMetaId, + txHash: localTxHash, + }); + + return localTxHash; + }; + + readonly #updateHistoryItem = ({ + historyKey, + status, + txHash, + attempts, + completionTime, + }: { + historyKey?: string; + status?: StatusTypes; + txHash?: string; + attempts?: BridgeHistoryItem['attempts']; + completionTime?: BridgeHistoryItem['completionTime']; + }): void => { + if (!historyKey) { + return; + } + this.update((currentState) => { + if (status) { + currentState.txHistory[historyKey].status.status = status; + } + if (txHash) { + currentState.txHistory[historyKey].status.srcChain.txHash = txHash; + } + if (attempts) { + currentState.txHistory[historyKey].attempts = attempts; + } + if (completionTime) { + currentState.txHistory[historyKey].completionTime = completionTime; + } + }); + }; + + readonly #deleteHistoryItem = (historyKey: string): void => { + this.update((currentState) => { + delete currentState.txHistory[historyKey]; + }); + }; + + // Wipes the bridge status for the given address and chainId + // Will match only source chainId to the selectedChainId + readonly #wipeBridgeStatusByChainId = ( + address: string, + selectedChainId: Hex, + ): void => { + const sourceTxMetaIdsToDelete = Object.keys(this.state.txHistory).filter( + (txMetaId) => { + const bridgeHistoryItem = this.state.txHistory[txMetaId]; + + const hexSourceChainId = numberToHex( + bridgeHistoryItem.quote.srcChainId, + ); + + return ( + bridgeHistoryItem.account === address && + hexSourceChainId === selectedChainId + ); + }, + ); + + sourceTxMetaIdsToDelete.forEach((sourceTxMetaId) => { + const pollingToken = this.#pollingTokensByTxMetaId[sourceTxMetaId]; + + if (pollingToken) { + this.stopPollingByPollingToken( + this.#pollingTokensByTxMetaId[sourceTxMetaId], + ); + delete this.#pollingTokensByTxMetaId[sourceTxMetaId]; + } + }); + + this.update((state) => { + state.txHistory = sourceTxMetaIdsToDelete.reduce( + (acc, sourceTxMetaId) => { + delete acc[sourceTxMetaId]; + return acc; + }, + state.txHistory, + ); + }); + }; + + /** + * ****************************************************** + * TX SUBMISSION HANDLING + ******************************************************* + */ + + readonly #executeSubmitStrategy = async ( + params: SubmitStrategyParams, + sharedHistoryItemProperties: { + startTime: number; + location: MetaMetricsSwapsEventSource; + abTests?: Record; + activeAbTests?: { key: string; value: string }[]; + tokenSecurityTypeDestination?: string | null; + inputPrimaryDenomination?: InputPrimaryDenomination; + customSlippage?: boolean; + slippagePercentage?: number; + }, + ): Promise => { + let tradeTxMeta!: TransactionMeta; + + const steps = executeSubmitStrategy(params); + + // Each submission strategy determines when to execute step, which means these actions can happen in any order + for await (const { type, payload } of steps) { + try { + switch (type) { + case SubmitStep.RekeyHistoryItem: + this.#rekeyHistoryItem( + payload.oldHistoryKey, + payload.newHistoryKey, + payload.tradeMeta, + ); + // Report SUBMITTED as soon as the trade hash is known at submission + // time, instead of waiting for the delayed transactionStatusUpdated + // (submitted) event. + if (payload.tradeMeta.hash) { + this.#reportSubmittedOnce( + payload.newHistoryKey, + payload.tradeMeta.hash, + payload.tradeMeta.id, + ); + } + break; + + case SubmitStep.UpdateBatchTransactions: + updateTransactionsInBatch({ + messenger: this.messenger, + allTradesWithMetadata: payload.quoteAndTxMetas, + }); + break; + + case SubmitStep.SetTradeMeta: + tradeTxMeta = payload.tradeMeta; + break; + + case SubmitStep.AddHistoryItem: + this.#addTxToHistory(payload.historyKey, { + ...payload, + ...sharedHistoryItemProperties, + quoteResponse: payload.quoteResponse, + accountAddress: params.selectedAccount.address, + isStxEnabled: params.isStxEnabled, + slippagePercentage: + sharedHistoryItemProperties.slippagePercentage ?? + payload.quoteResponse.quote.slippage ?? + 0, + }); + this.#reportSubmittedForNonEvmTx( + payload.historyKey, + payload.bridgeTxMeta, + ); + break; + + case SubmitStep.StartPolling: + this.#startPollingForTxId(payload.historyKey); + break; + + case SubmitStep.PublishCompletedEvent: { + const completedHistoryItem = + this.state.txHistory[payload.historyKey]; + this.#traceSwapOperationCompleted( + payload.historyKey, + 'success', + 'source', + ).catch(() => undefined); + this.#quoteStatusManager.reportFinalised( + payload.historyKey, + true, + completedHistoryItem?.quote.srcChainId, + completedHistoryItem?.status.srcChain.txHash, + ); + this.#trackUnifiedSwapBridgeEvent( + UnifiedSwapBridgeEventName.Completed, + payload.historyKey, + ); + break; + } + + /* c8 ignore start */ + default: + throw new Error(`Unknown submit step type: ${String(type)}`); + /* c8 ignore end */ + } + } catch (error) { + console.error( + 'Failed to add to bridge history and start polling.', + error, + ); + } + } + + return tradeTxMeta; + }; + + /** + * Submits a cross-chain swap transaction + * + * @param accountAddress - The address of the account to submit the transaction for + * @param maybeQuoteResponses - A single quote response or an array of quote responses + * @param isStxEnabled - Whether smart transactions are enabled on the client, for example the getSmartTransactionsEnabled selector value from the extension + * @param quotesReceivedContext - The context for the QuotesReceived event + * @param location - The entry point from which the user initiated the swap or bridge (e.g. Main View, Token View, Trending Explore) + * @param abTests - Legacy A/B test context for `ab_tests` (backward compatibility) + * @param activeAbTests - New A/B test context for `active_ab_tests` (migration target). Attributes events to specific experiments. + * @param tokenSecurityTypeDestination - The security classification of the destination token, supplied by the client (e.g. from token security/scanning data). Pass `null` when no security data is available. + * @param batchSellTrades - Contains transaction data for the quotes, provided by the obtainGaslessBatch API + * @param inputPrimaryDenomination - The denomination shown as the primary source amount input at submission time. + * @param migrationPhase - The active migration phase for the quote response + * @returns The transaction meta + * @throws An error if transaction submission fails before it gets published + */ + submitTx = async ( + accountAddress: string, + maybeQuoteResponses: + | QuoteResponse + | QuoteResponse[] + | (QuoteResponseV1 & QuoteMetadata) + | (QuoteResponseV1 & QuoteMetadata)[], + isStxEnabled: boolean, + quotesReceivedContext?: RequiredEventContextFromClient[UnifiedSwapBridgeEventName.QuotesReceived], + location: MetaMetricsSwapsEventSource = MetaMetricsSwapsEventSource.Unknown, + abTests?: Record, + activeAbTests?: { key: string; value: string }[], + tokenSecurityTypeDestination?: string | null, + batchSellTrades?: BatchSellTradesResponse | null, + inputPrimaryDenomination?: InputPrimaryDenomination, + migrationPhase?: QuoteMetadataMigrationPhase, + ): Promise => { + /** + * If there are multiple quote responses, we assume that they all originate from the same src chain + * and the same account. In this case its safe to use the first quote response's properties for + * metrics and other pre-submission logic + */ + const quoteResponsesV1orV2 = Array.isArray(maybeQuoteResponses) + ? maybeQuoteResponses + : [maybeQuoteResponses]; + // Convert quote responses to V1 format and preserve metadata for consistency + const quoteResponses = quoteResponsesV1orV2.map((quote) => { + if (!isQuoteResponseV2(quote)) { + return quote; + } + + const quoteMetadataV1 = toQuoteMetadataV1(quote, migrationPhase); + + // This coercion omits legacy metadata from the resulting V1 quote + const quoteResponseV1 = toQuoteResponseV1(quote); + + // Merge legacy-shaped metadata to V1-shaped quote response + return { ...quoteResponseV1, ...quoteMetadataV1 }; + }); + const quoteResponse = quoteResponses[0]; + + const { quote } = quoteResponse; + const startTime = Date.now(); + + stopPollingForQuotes(this.messenger, quotesReceivedContext); + + const selectedAccount = getAccountByAddress(this.messenger, accountAddress); + if (!selectedAccount) { + throw new Error( + 'Failed to submit cross-chain swap transaction: undefined multichain account', + ); + } + const accountHardwareType = getAccountHardwareType(selectedAccount); + + /** + * For hardware wallets on Mobile, this is fixes an issue where the Ledger does not get prompted for the 2nd approval. + * Extension does not have this issue + */ + const requireApproval = + this.#clientId === BridgeClientId.MOBILE && accountHardwareType !== null; + const isBridgeTx = isCrossChain(quote.srcChainId, quote.destChainId); + + const batchId = quoteResponses.some( + ({ featureId: quoteFeatureId }) => + quoteFeatureId === FeatureId.BATCH_SELL, + ) + ? generateBatchId() + : undefined; + + const preConfirmationProperties = getPreConfirmationPropertiesFromQuote( + quoteResponse, + isStxEnabled, + accountHardwareType, + location, + abTests, + activeAbTests, + tokenSecurityTypeDestination, + batchSellTrades, + batchId, + quotesReceivedContext, + ); + + try { + // Emit Submitted event after submit button is clicked + this.#trackUnifiedSwapBridgeEvent( + UnifiedSwapBridgeEventName.Submitted, + undefined, + { + ...preConfirmationProperties, + ...(inputPrimaryDenomination && { + input_primary_denomination: inputPrimaryDenomination, + }), + }, + ); + + /** + * Check if the account is an EIP-7702 delegated account. + * Delegated accounts only allow 1 in-flight tx, so approve + swap + * must be batched into a single transaction + */ + const isDelegatedAccount = isNonEvmChainId(quote.srcChainId) + ? false + : await checkIsDelegatedAccount( + this.messenger, + selectedAccount.address as Hex, + [formatChainIdToHex(quote.srcChainId)], + ); + + const strategyParams: SubmitStrategyParams = { + messenger: this.messenger, + quoteResponses, + batchSellTrades, + isStxEnabled, + isBridgeTx, + isDelegatedAccount, + selectedAccount, + requireApproval, + clientId: this.#clientId, + bridgeApiBaseUrl: this.#config.customBridgeApiBaseUrl, + addTransactionBatchFn: this.#addTransactionBatchFn, + fetchFn: this.#fetchFn, + traceFn: this.#trace, + batchId, + }; + + return await this.#trace( + getTraceParams(quoteResponse, isStxEnabled), + async () => + await this.#executeSubmitStrategy(strategyParams, { + startTime, + location, + abTests, + activeAbTests, + tokenSecurityTypeDestination, + inputPrimaryDenomination, + customSlippage: quotesReceivedContext?.custom_slippage, + slippagePercentage: quotesReceivedContext?.slippage_limit, + }), + ); + } catch (error) { + this.#trackUnifiedSwapBridgeEvent( + UnifiedSwapBridgeEventName.Failed, + undefined, + { + error_message: (error as Error)?.message, + ...preConfirmationProperties, + }, + ); + throw error; + } + }; + + /** + * Submits an intent order and creates a synthetic history entry for UX. + * The EIP-712 payload is always signed inside this controller via KeyringController. + * + * @param params - Object containing intent submission parameters + * @param params.quoteResponse - Quote carrying intent data + * @param params.accountAddress - The EOA submitting the order + * @param params.location - The entry point from which the user initiated the swap or bridge + * @param params.abTests - Legacy A/B test context for `ab_tests` (backward compatibility) + * @param params.activeAbTests - New A/B test context for `active_ab_tests` (migration target). Attributes events to specific experiments. + * @param params.tokenSecurityTypeDestination - The security classification of the destination token, supplied by the client (e.g. from token security/scanning data). Pass `null` when no security data is available. + * @param params.inputPrimaryDenomination - The denomination shown as the primary source amount input at submission time. + * @param params.isStxEnabled - Whether smart transactions are enabled on the client, for example the getSmartTransactionsEnabled selector value from the extension + * @param params.quotesReceivedContext - The context for the QuotesReceived event + * @param params.migrationPhase - The active migration phase for the quote response + * @returns A lightweight TransactionMeta-like object for history linking + * @throws An error if intent or transaction submission fails before they get published + */ + submitIntent = async (params: { + quoteResponse: QuoteResponse; + accountAddress: string; + location?: MetaMetricsSwapsEventSource; + abTests?: Record; + activeAbTests?: { key: string; value: string }[]; + tokenSecurityTypeDestination?: string | null; + inputPrimaryDenomination?: InputPrimaryDenomination; + isStxEnabled?: boolean; + quotesReceivedContext?: RequiredEventContextFromClient[UnifiedSwapBridgeEventName.QuotesReceived]; + migrationPhase?: QuoteMetadataMigrationPhase; + }): Promise => { + const { + quoteResponse, + accountAddress, + location, + abTests, + migrationPhase, + activeAbTests, + tokenSecurityTypeDestination, + inputPrimaryDenomination, + isStxEnabled = false, + quotesReceivedContext, + } = params; + + return await this.submitTx( + accountAddress, + quoteResponse, + isStxEnabled, + quotesReceivedContext, + location, + abTests, + activeAbTests, + tokenSecurityTypeDestination, + undefined, + inputPrimaryDenomination, + migrationPhase, + ); + }; + + submitBatchSell = async (params: { + quoteResponses: (QuoteResponse | null)[]; + accountAddress: string; + location?: MetaMetricsSwapsEventSource; + abTests?: Record; + activeAbTests?: { key: string; value: string }[]; + isStxEnabled?: boolean; + quotesReceivedContext?: RequiredEventContextFromClient[UnifiedSwapBridgeEventName.QuotesReceived]; + tokenSecurityTypeDestination?: string | null; + migrationPhase?: QuoteMetadataMigrationPhase; + }): Promise => { + /** + * Retrieve the batch sell trades from the BridgeController's state to ensure we submit + * the original response data from the bridge-api + */ + const batchSellTrades = getBatchSellTrades(this.messenger); + return await this.submitTx( + params.accountAddress, + params.quoteResponses.filter( + (quoteResponse): quoteResponse is QuoteResponse & QuoteMetadata => + quoteResponse !== null, + ), + params.isStxEnabled ?? false, + params.quotesReceivedContext, + params.location, + params.abTests, + params.activeAbTests, + params.tokenSecurityTypeDestination, + batchSellTrades, + undefined, + params.migrationPhase, + ); + }; + + readonly #trackPollingStatusUpdatedEvent = ( + historyKey: string, + pollingStatus: PollingStatus, + ): void => { + // Track polling status updated event + const historyItem = this.state.txHistory[historyKey]; + this.#trackUnifiedSwapBridgeEvent( + UnifiedSwapBridgeEventName.PollingStatusUpdated, + historyKey, + { + polling_status: pollingStatus, + retry_attempts: historyItem.attempts?.counter ?? 0, + }, + ); + }; + + /** + * Tracks post-submission events for a cross-chain swap based on the history item + * + * @param eventName - The name of the event to track + * @param txHistoryKey - The txMetaId, actionId or intentUid of the history item to track the event for + * @param eventProperties - The properties for the event + */ + readonly #trackUnifiedSwapBridgeEvent = < + EventName extends + | typeof UnifiedSwapBridgeEventName.Submitted + | typeof UnifiedSwapBridgeEventName.Failed + | typeof UnifiedSwapBridgeEventName.Completed + | typeof UnifiedSwapBridgeEventName.StatusValidationFailed + | typeof UnifiedSwapBridgeEventName.PollingStatusUpdated, + EventProperties extends Omit< + RequiredEventContextFromClient[EventName], + 'feature_id' + > & { + // eslint-disable-next-line @typescript-eslint/naming-convention + feature_id?: FeatureId; + }, + >( + eventName: EventName, + txHistoryKey?: string, + eventProperties?: EventProperties, + ): void => { + const historyItem: BridgeHistoryItem | undefined = txHistoryKey + ? this.state.txHistory[txHistoryKey] + : undefined; + + const featureId = + eventProperties?.feature_id ?? + historyItem?.featureId ?? + FeatureId.UNIFIED_SWAP_BRIDGE; + + if ( + !( + ALLOWED_FEATURE_IDS_FOR_STATUS_EVENTS.includes(featureId) || + eventName === UnifiedSwapBridgeEventName.StatusValidationFailed + ) + ) { + return; + } + + // Legacy/new metrics fields are intentionally kept independent during migration. + const historyAbTests = txHistoryKey + ? this.state.txHistory?.[txHistoryKey]?.abTests + : undefined; + const historyActiveAbTests = txHistoryKey + ? this.state.txHistory?.[txHistoryKey]?.activeAbTests + : undefined; + const resolvedAbTests = eventProperties?.ab_tests ?? historyAbTests; + const resolvedActiveAbTests = + eventProperties?.active_ab_tests ?? historyActiveAbTests; + + const location = + historyItem?.location ?? + eventProperties?.location ?? + MetaMetricsSwapsEventSource.Unknown; + + const baseProperties = { + action_type: MetricsActionType.SWAPBRIDGE_V1, + feature_id: featureId ?? FeatureId.UNIFIED_SWAP_BRIDGE, + ...(historyItem?.batchId ? { batch_id: historyItem.batchId } : {}), + ...(eventProperties ?? {}), + location, + ...(resolvedAbTests && + Object.keys(resolvedAbTests).length > 0 && { + ab_tests: resolvedAbTests, + }), + ...(resolvedActiveAbTests && + resolvedActiveAbTests.length > 0 && { + active_ab_tests: resolvedActiveAbTests, + }), + }; + + // This will publish events for PERPS dropped tx failures as well + if (!historyItem) { + trackMetricsEvent({ + messenger: this.messenger, + eventName, + properties: baseProperties, + }); + return; + } + + const { approvalTxId, quote } = historyItem; + const requestParamProperties = getRequestParamFromHistory(historyItem); + + if (eventName === UnifiedSwapBridgeEventName.StatusValidationFailed) { + trackMetricsEvent({ + messenger: this.messenger, + eventName, + properties: { + ...baseProperties, + chain_id_source: requestParamProperties.chain_id_source, + chain_id_destination: requestParamProperties.chain_id_destination, + token_address_source: requestParamProperties.token_address_source, + token_address_destination: + requestParamProperties.token_address_destination, + token_security_type_destination: + requestParamProperties.token_security_type_destination, + refresh_count: historyItem.attempts?.counter ?? 0, + }, + }); + return; + } + + const selectedAccount = getAccountByAddress( + this.messenger, + historyItem.account, + ); + + const transactions = getTransactions(this.messenger); + const txMeta = transactions.find( + (tx: TransactionMeta) => tx.id === txHistoryKey, + ); + const approvalTxMeta = transactions.find( + (tx: TransactionMeta) => tx.id === approvalTxId, + ); + + const requiredEventProperties = { + ...baseProperties, + ...requestParamProperties, + ...getRequestMetadataFromHistory(historyItem, selectedAccount), + ...getTradeDataFromHistory(historyItem), + ...getTxStatusesFromHistory(historyItem), + ...getFinalizedTxProperties(historyItem, txMeta, approvalTxMeta), + ...getPriceImpactFromQuote(quote), + ...(eventName === UnifiedSwapBridgeEventName.Completed && { + ...(!isNonEvmChainId(historyItem.quote.srcChainId) && + historyItem.txMetaId && { + transaction_internal_id: historyItem.txMetaId, + }), + ...(historyItem.inputPrimaryDenomination && { + input_primary_denomination: historyItem.inputPrimaryDenomination, + }), + }), + }; + + trackMetricsEvent({ + messenger: this.messenger, + eventName, + properties: requiredEventProperties, + }); + }; +} diff --git a/packages/bridge-status-controller/src/constants.ts b/packages/bridge-status-controller/src/constants.ts new file mode 100644 index 00000000000..da2696b42b6 --- /dev/null +++ b/packages/bridge-status-controller/src/constants.ts @@ -0,0 +1,36 @@ +import { FeatureId } from '@metamask/bridge-controller'; + +import type { BridgeStatusControllerState } from './types.js'; + +export const REFRESH_INTERVAL_MS = 10 * 1000; // 10 seconds +export const MAX_ATTEMPTS = 7; // at 7 attempts, delay is 10:40, cumulative time is 21:10 +export const DEFAULT_MAX_PENDING_HISTORY_ITEM_AGE_MS = 2 * 24 * 60 * 60 * 1000; // 2 days + +export const BRIDGE_STATUS_CONTROLLER_NAME = 'BridgeStatusController'; + +export const DEFAULT_BRIDGE_STATUS_CONTROLLER_STATE: BridgeStatusControllerState = + { + txHistory: {}, + quoteUpdateStatusStore: {}, + }; + +export const BRIDGE_PROD_API_BASE_URL = 'https://bridge.api.cx.metamask.io'; + +export const APPROVAL_DELAY_MS = 5000; + +export enum TraceName { + BridgeTransactionApprovalCompleted = 'Bridge Transaction Approval Completed', + BridgeTransactionCompleted = 'Bridge Transaction Completed', + SwapTransactionApprovalCompleted = 'Swap Transaction Approval Completed', + SwapTransactionCompleted = 'Swap Transaction Completed', + // For this constant only, "Swap" is the umbrella term for single-chain and cross-chain operations; use `swap_type` to distinguish them. + SwapOperationCompleted = 'Swap Operation Completed', +} + +export const ALLOWED_FEATURE_IDS_FOR_STATUS_EVENTS = [ + FeatureId.QUICK_BUY_FOLLOW_TRADING, + FeatureId.QUICK_BUY_TOKEN_DETAILS, + FeatureId.QUICK_BUY_EXPLORE, + FeatureId.UNIFIED_SWAP_BRIDGE, + FeatureId.BATCH_SELL, +]; diff --git a/packages/bridge-status-controller/src/index.ts b/packages/bridge-status-controller/src/index.ts new file mode 100644 index 00000000000..78e3ecda008 --- /dev/null +++ b/packages/bridge-status-controller/src/index.ts @@ -0,0 +1,54 @@ +// Export custom error classes +export { + QuoteStatusUpdateError, + QuoteStatusGetError, +} from './quote-status-manager/errors.js'; +export { BaseQuoteStatusUpdateErrorTypes } from './quote-status-manager/constants.js'; + +// Export constants +export { + REFRESH_INTERVAL_MS, + DEFAULT_BRIDGE_STATUS_CONTROLLER_STATE, + BRIDGE_STATUS_CONTROLLER_NAME, + MAX_ATTEMPTS, +} from './constants.js'; + +export type { + FetchFunction, + StatusRequest, + StatusRequestDto, + StatusRequestWithSrcTxHash, + StatusResponse, + RefuelStatusResponse, + BridgeHistoryItem, + BridgeStatusControllerState, + QuoteStatusPersistEntry, + BridgeStatusControllerMessenger, + BridgeStatusControllerActions, + BridgeStatusControllerGetStateAction, + BridgeStatusControllerEvents, + BridgeStatusControllerStateChangeEvent, + StartPollingForBridgeTxStatusArgs, + StartPollingForBridgeTxStatusArgsSerialized, + TokenAmountValuesSerialized, + QuoteMetadataSerialized, +} from './types.js'; + +export type { + BridgeStatusControllerStartPollingForBridgeTxStatusAction, + BridgeStatusControllerWipeBridgeStatusAction, + BridgeStatusControllerResetStateAction, + BridgeStatusControllerSubmitTxAction, + BridgeStatusControllerSubmitIntentAction, + BridgeStatusControllerRestartPollingForFailedAttemptsAction, + BridgeStatusControllerGetBridgeHistoryItemByTxMetaIdAction, +} from './bridge-status-controller-method-action-types.js'; + +export { BridgeId, BridgeStatusAction } from './types.js'; + +export { BridgeStatusController } from './bridge-status-controller.js'; + +export { + getBatchSellHistoryItemsForTxHash, + isBatchSellHistoryItem, +} from './utils/history.js'; diff --git a/packages/bridge-status-controller/src/quote-status-manager/constants.ts b/packages/bridge-status-controller/src/quote-status-manager/constants.ts new file mode 100644 index 00000000000..d8afc2f48b5 --- /dev/null +++ b/packages/bridge-status-controller/src/quote-status-manager/constants.ts @@ -0,0 +1,204 @@ +/** + * How often the manager re-processes entries that have not yet reached a + * terminal state (`Completed`/`Expired`). + * + * Drives the periodic retry timer in `QuoteStatusUpdateManager`: on each tick + * every non-terminal entry is re-sent to the backend until it is accepted or + * evicted. The timer only runs while there is outstanding work. + */ +export const QUOTE_STATUS_UPDATE_RETRY_INTERVAL_MS = 30 * 60 * 1000; // 30 minutes + +/** + * Maximum lifetime of a tracked quote-status entry, measured from when it was + * created. + * + * Once an entry is older than this it is considered `Expired`, evicted from the + * store on the next read, and never retried again. This bounds how long the + * manager keeps trying to report a status that the backend may never accept. + */ +export const QUOTE_STATUS_UPDATE_ENTRY_TTL = 3 * 60 * 60 * 1000; // 3 hours + +/** + * Maximum age of a persisted `txHistory` item, measured from its `startTime`, + * that is still eligible to have its quote-status entry backfilled on + * startup. + * + * Used by `#seedQuoteStatusEntriesFromHistory` to skip history items whose + * `startTime` predates this window: the backend's quote data itself is only + * retained for a bounded period, so reporting a status for a quote older than + * that would be rejected regardless of how fresh the locally-recreated entry + * is. This is intentionally longer than `QUOTE_STATUS_UPDATE_ENTRY_TTL` + * (which bounds an entry's local retry lifetime from creation, not the + * underlying quote's age) so that swaps/bridges left in-flight across + * multiple closed sessions still get a chance to be resumed. + */ +export const QUOTE_STATUS_BACKFILL_WINDOW_MS = 12 * 60 * 60 * 1000; // 12 hours + +/** + * Quote lifecycle statuses as understood by the backend `quote/updateStatus` + * API. These are the values sent over the wire (and echoed back in error + * responses). + */ +export enum QuoteStatusBackendStatus { + /** + * Quote was served to the client. Unused locally, kept for parity with the backend enum. + */ + Served = 'SERVED', + /** + * User has submitted the source transaction. + */ + Submitted = 'SUBMITTED', + /** + * Transaction finalized successfully on-chain. + */ + FinalizedSuccess = 'FINALIZED_SUCCESS', + /** + * Transaction failed or reverted on-chain. + */ + FinalizedFailed = 'FINALIZED_FAILED', +} + +/** + * Machine-readable error `type` values returned by the backend + * `quote/updateStatus` API on non-2xx responses. The comments record the HTTP + * status the backend pairs with each type. + */ +export enum QuoteStatusUpdateBackendErrorType { + QuoteNotFound = 'QUOTE_NOT_FOUND', // Http status: 404 + ConcurrentUpdate = 'CONCURRENT_UPDATE', // Http status: 409 + InvalidStatusTransaction = 'INVALID_STATUS_TRANSITION', // Http status: 400 + SrcTxHashRequiredForFinalized = 'SRC_TX_HASH_REQUIRED_FOR_FINALIZED', // Http status: 400 + PersistQuoteStatusFailed = 'PERSIST_QUOTE_STATUS_FAILED', // Http status: 400 + TransactionNotIndexed = 'TRANSACTION_NOT_INDEXED', // Http status: 409 + TxDataMissingHash = 'TX_DATA_MISSING_HASH', // Http status: 400 + TxDataMissingTrade = 'TX_DATA_MISSING_TRADE', // Http status: 400 + /** + * On-chain tx payload does not match the served quote (EVM calldata, SVM message, TVM raw_data_hex). + */ + TxDataMismatch = 'TX_DATA_MISMATCH', // Http status: 400 + SvmTradeDeserializeFailed = 'SVM_TRADE_DESERIALIZE_FAILED', // Http status: 400 + /** + * Requested lifecycle status is inconsistent with observed on-chain tx status. + */ + QuoteStatusOnChainMismatch = 'QUOTE_STATUS_ONCHAIN_MISMATCH', // Http status: 400 +} + +/** + * Error types whose response payload carries no `currentStatus`. Used by the + * response validator to define the base (non-mismatch) error response schema. + */ +export const BaseQuoteStatusUpdateErrorTypes = [ + QuoteStatusUpdateBackendErrorType.QuoteNotFound, + QuoteStatusUpdateBackendErrorType.ConcurrentUpdate, + QuoteStatusUpdateBackendErrorType.SrcTxHashRequiredForFinalized, + QuoteStatusUpdateBackendErrorType.PersistQuoteStatusFailed, + QuoteStatusUpdateBackendErrorType.TransactionNotIndexed, + QuoteStatusUpdateBackendErrorType.TxDataMissingHash, + QuoteStatusUpdateBackendErrorType.TxDataMissingTrade, + QuoteStatusUpdateBackendErrorType.TxDataMismatch, + QuoteStatusUpdateBackendErrorType.SvmTradeDeserializeFailed, +] as const; + +/** + * The full set of valid backend status values. Used by the response validator + * to constrain the `currentStatus`/`newStatus` fields of error responses. + */ +export const QuoteStatusBackendValues = [ + QuoteStatusBackendStatus.Served, + QuoteStatusBackendStatus.Submitted, + QuoteStatusBackendStatus.FinalizedSuccess, + QuoteStatusBackendStatus.FinalizedFailed, +] as const; + +/** + * Error types whose response payload includes a `currentStatus` describing the + * backend's observed status. Used by the validator for the mismatch response + * schema and by the manager to reconcile local state with the backend. + */ +export const QuoteStatusUpdateBackendOnChainMismatchTypes = [ + QuoteStatusUpdateBackendErrorType.InvalidStatusTransaction, + QuoteStatusUpdateBackendErrorType.QuoteStatusOnChainMismatch, +] as const; + +/** + * Error types that represent transient backend conditions. A response with one + * of these types is retried by `updateQuoteStatusWithRetry`; any other error + * type is treated as non-retryable. + */ +export const QuoteStatusUpdateRetryableBackendTypes = [ + QuoteStatusUpdateBackendErrorType.ConcurrentUpdate, + QuoteStatusUpdateBackendErrorType.TransactionNotIndexed, +]; + +/** + * Local quote-status lifecycle states tracked by the state machine. These are + * the states the manager owns; a subset maps onto backend statuses while + * `Completed`/`Expired` are terminal, client-only states. + */ +export enum QuoteStatusState { + /** Source transaction submitted; awaiting finalization. */ + Submitted = 'Submitted', + /** Transaction finalized successfully on-chain. */ + FinalizedSuccess = 'FinalizedSuccess', + /** Transaction failed or reverted on-chain. */ + FinalizedFailed = 'FinalizedFailed', + /** Terminal: the backend accepted the final status; nothing left to report. */ + Completed = 'Completed', + /** Terminal: the entry outlived its TTL and was abandoned. */ + Expired = 'Expired', +} + +/** + * Maps each local lifecycle state to the backend status to report for it, or + * `null` for terminal states that require no backend update. The manager uses a + * `null` mapping as the signal to remove the entry instead of calling the API. + */ +export const QuoteStatusStateToBackendStatus = { + [QuoteStatusState.Submitted]: QuoteStatusBackendStatus.Submitted, + [QuoteStatusState.FinalizedSuccess]: + QuoteStatusBackendStatus.FinalizedSuccess, + [QuoteStatusState.FinalizedFailed]: QuoteStatusBackendStatus.FinalizedFailed, + [QuoteStatusState.Completed]: null, + [QuoteStatusState.Expired]: null, +}; + +/** + * Adjacency list defining the allowed forward-only transitions between + * lifecycle states. Enforced by `QuoteStatusStateFsm`; terminal states map to + * an empty array. Any state not present is treated as terminal. + */ +export const AllowedQuoteStatusStateTransitions: Record< + QuoteStatusState, + readonly QuoteStatusState[] +> = { + [QuoteStatusState.Submitted]: [ + QuoteStatusState.FinalizedFailed, + QuoteStatusState.FinalizedSuccess, + QuoteStatusState.Expired, + ], + [QuoteStatusState.FinalizedFailed]: [ + QuoteStatusState.Completed, + QuoteStatusState.Expired, + ], + [QuoteStatusState.FinalizedSuccess]: [ + QuoteStatusState.Completed, + QuoteStatusState.Expired, + ], + [QuoteStatusState.Completed]: [], + [QuoteStatusState.Expired]: [], +}; + +/** + * Outcome of a retrying status fetch call. Tells the + * manager how to proceed after an update attempt completes. + */ +export enum QuoteStatusFetchWithRetryOutcomeType { + /** The backend accepted the update (2xx); the entry can be finalized/removed. */ + Accepted = 'accepted', + /** All retry attempts for a retryable error were used up; back off and try again later. */ + RetryableExhausted = 'retryableExhausted', + /** The backend returned a non-retryable error; the entry must be reconciled or evicted. */ + NonRetryable = 'nonRetryable', + /** The request was aborted (e.g. via abort signal) before completing. */ + Interrupted = 'interrupted', +} diff --git a/packages/bridge-status-controller/src/quote-status-manager/errors.test.ts b/packages/bridge-status-controller/src/quote-status-manager/errors.test.ts new file mode 100644 index 00000000000..5f024756f4f --- /dev/null +++ b/packages/bridge-status-controller/src/quote-status-manager/errors.test.ts @@ -0,0 +1,154 @@ +import { QuoteStatusUpdateBackendErrorType } from './constants.js'; +import { QuoteStatusGetError, QuoteStatusUpdateError } from './errors.js'; + +describe('QuoteStatusUpdateError', () => { + describe('constructor', () => { + it('prefixes the message with the error type when provided', () => { + const error = new QuoteStatusUpdateError('something went wrong', { + quoteId: 'quote-1', + errorType: QuoteStatusUpdateBackendErrorType.QuoteNotFound, + }); + + expect(error.message).toBe('[QUOTE_NOT_FOUND] something went wrong'); + }); + + it('leaves the message unprefixed when no error type is provided', () => { + const error = new QuoteStatusUpdateError('something went wrong', { + quoteId: 'quote-1', + }); + + expect(error.message).toBe('something went wrong'); + }); + + it('preserves the structured details on the instance', () => { + const details = { + quoteId: 'quote-1', + errorType: QuoteStatusUpdateBackendErrorType.ConcurrentUpdate, + }; + + const error = new QuoteStatusUpdateError('conflict', details); + + expect(error.details).toStrictEqual(details); + }); + + it('preserves details when no error type is provided', () => { + const details = { quoteId: 'quote-1' }; + + const error = new QuoteStatusUpdateError('no type', details); + + expect(error.details).toStrictEqual(details); + }); + + it('sets the error name to the class name', () => { + const error = new QuoteStatusUpdateError('whatever', { + quoteId: 'quote-1', + }); + + expect(error.name).toBe('QuoteStatusUpdateError'); + }); + }); + + describe('prototype chain', () => { + it('is an instance of QuoteStatusUpdateError', () => { + const error = new QuoteStatusUpdateError('whatever', { + quoteId: 'quote-1', + }); + + expect(error).toBeInstanceOf(QuoteStatusUpdateError); + }); + + it('is an instance of Error', () => { + const error = new QuoteStatusUpdateError('whatever', { + quoteId: 'quote-1', + }); + + expect(error).toBeInstanceOf(Error); + }); + + it('can be caught as an Error', () => { + expect(() => { + throw new QuoteStatusUpdateError('boom', { quoteId: 'quote-1' }); + }).toThrow('boom'); + }); + }); +}); + +describe('QuoteStatusGetError', () => { + describe('constructor', () => { + it('keeps the provided message as-is', () => { + const error = new QuoteStatusGetError('request failed', { + quoteId: 'quote-1', + }); + + expect(error.message).toBe('request failed'); + }); + + it('preserves the structured details on the instance', () => { + const details = { quoteId: 'quote-1' }; + + const error = new QuoteStatusGetError('request failed', details); + + expect(error.details).toStrictEqual(details); + }); + + it('sets the error name to the class name', () => { + const error = new QuoteStatusGetError('whatever', { + quoteId: 'quote-1', + }); + + expect(error.name).toBe('QuoteStatusGetError'); + }); + + it('defaults retryable to false when no third argument is provided', () => { + const error = new QuoteStatusGetError('request failed', { + quoteId: 'quote-1', + }); + + expect(error.retryable).toBe(false); + }); + + it('sets retryable to true when explicitly passed', () => { + const error = new QuoteStatusGetError( + 'request failed', + { quoteId: 'quote-1' }, + true, + ); + + expect(error.retryable).toBe(true); + }); + + it('sets retryable to false when explicitly passed', () => { + const error = new QuoteStatusGetError( + 'request failed', + { quoteId: 'quote-1' }, + false, + ); + + expect(error.retryable).toBe(false); + }); + }); + + describe('prototype chain', () => { + it('is an instance of QuoteStatusGetError', () => { + const error = new QuoteStatusGetError('whatever', { + quoteId: 'quote-1', + }); + + expect(error).toBeInstanceOf(QuoteStatusGetError); + }); + + it('is an instance of Error', () => { + const error = new QuoteStatusGetError('whatever', { + quoteId: 'quote-1', + }); + + expect(error).toBeInstanceOf(Error); + }); + + it('can be caught as an Error', () => { + expect(() => { + throw new QuoteStatusGetError('boom', { quoteId: 'quote-1' }); + }).toThrow('boom'); + }); + }); +}); diff --git a/packages/bridge-status-controller/src/quote-status-manager/errors.ts b/packages/bridge-status-controller/src/quote-status-manager/errors.ts new file mode 100644 index 00000000000..25175fd1317 --- /dev/null +++ b/packages/bridge-status-controller/src/quote-status-manager/errors.ts @@ -0,0 +1,73 @@ +import { + QuoteStatusGetErrorDetails, + QuoteStatusUpdateErrorDetails, +} from './types.js'; + +/** + * Error thrown for quote status update failures. + * + * The error message is prefixed with the error type when provided, and + * structured details are preserved on the instance for downstream handling. + */ +export class QuoteStatusUpdateError extends Error { + readonly details?: QuoteStatusUpdateErrorDetails; + + /** + * Creates a quote status update error with structured context. + * + * @param message - Human-readable error message. + * @param details - Structured metadata about the failed quote update. + * @param details.errorType - Optional category for known update failures. + * @param details.quoteId - Unique quote identifier associated with the error. + * @param details.txMetaId - Optional transaction metadata id associated with + * the error. + * @param details.srcTxHash - Optional source-chain transaction hash + * associated with the error. + * @param details.srcChainId - Optional source-chain id associated with the + * error. + */ + constructor(message: string, details: QuoteStatusUpdateErrorDetails) { + super(`${details.errorType ? `[${details.errorType}] ` : ''}${message}`); + this.details = details; + this.name = QuoteStatusUpdateError.name; + Object.setPrototypeOf(this, QuoteStatusUpdateError.prototype); + } +} + +/** + * Error thrown for quote status fetch failures. + * + * Structured details are preserved on the instance for downstream handling. + */ +export class QuoteStatusGetError extends Error { + readonly details?: QuoteStatusGetErrorDetails; + + /** + * Whether the error is transient and the request may succeed on a retry. + * + * `true` for 5xx (server-side) HTTP errors; `false` for 4xx (client-side) + * errors and response-validation failures. + */ + readonly retryable: boolean; + + /** + * Creates a quote status fetch error with structured context. + * + * @param message - Human-readable error message. + * @param details - Structured metadata about the failed quote status fetch. + * @param details.quoteId - Unique quote identifier associated with the error. + * @param retryable - Whether the error is transient and may resolve on retry. + * Defaults to `false`. + */ + constructor( + message: string, + details: QuoteStatusGetErrorDetails, + retryable = false, + ) { + super(message); + this.details = details; + this.retryable = retryable; + this.name = QuoteStatusGetError.name; + Object.setPrototypeOf(this, QuoteStatusGetError.prototype); + } +} diff --git a/packages/bridge-status-controller/src/quote-status-manager/quote-status-api-service.test.ts b/packages/bridge-status-controller/src/quote-status-manager/quote-status-api-service.test.ts new file mode 100644 index 00000000000..35ed19c33aa --- /dev/null +++ b/packages/bridge-status-controller/src/quote-status-manager/quote-status-api-service.test.ts @@ -0,0 +1,856 @@ +import { StatusTypes } from '@metamask/bridge-controller'; + +import { BridgeClientId, BridgeStatusControllerMessenger } from '../types.js'; +import { + QuoteStatusUpdateBackendErrorType, + QuoteStatusBackendStatus, + QuoteStatusFetchWithRetryOutcomeType, +} from './constants.js'; +import { QuoteStatusGetError, QuoteStatusUpdateError } from './errors.js'; +import { QuoteStatusApiService } from './quote-status-api-service.js'; +import type { + QuoteStatusApiServiceOptions, + QuoteStatusGetResponse, +} from './types.js'; +import * as validators from './validators.js'; + +const API_BASE_URL = 'https://bridge.api.test'; + +const REQUEST_DATA = { + quoteId: 'quote-1', + srcTxHash: '0xabc', + newStatus: QuoteStatusBackendStatus.Submitted, +}; + +const GET_REQUEST_DATA = { + quoteId: 'quote-1', +}; + +const GET_RESPONSE_BODY: QuoteStatusGetResponse = { + submittedTx: { + status: StatusTypes.SUBMITTED, + srcChain: { + chainId: 1, + }, + }, +}; + +function createFetchResponse({ + ok, + body, + status = ok ? 200 : 500, + statusText = ok ? 'OK' : 'Internal Server Error', +}: { + ok: boolean; + body?: unknown; + status?: number; + statusText?: string; +}): Response { + return { + ok, + status, + statusText, + json: jest.fn().mockResolvedValue(body), + } as unknown as Response; +} + +/** + * Creates a messenger stub whose `call` resolves the given bearer token. + * + * @param token - The bearer token to resolve, or a rejection to simulate failure. + * @returns An object exposing the stub messenger and its `call` mock. + */ +function createMessenger(token: string | undefined = 'test-jwt'): { + messenger: BridgeStatusControllerMessenger; + call: jest.Mock; +} { + const call = jest.fn().mockResolvedValue(token); + const messenger = { call } as unknown as BridgeStatusControllerMessenger; + return { messenger, call }; +} + +/** + * Builds a {@link QuoteStatusApiService} with sensible test defaults. + * + * @param overrides - Partial options to override the defaults. + * @returns The constructed service and the options used to build it. + */ +function createService(overrides: Partial = {}): { + service: QuoteStatusApiService; + onError: jest.Mock; + messengerCall: jest.Mock; +} { + const { messenger, call } = createMessenger(); + const onError = jest.fn(); + + const options: QuoteStatusApiServiceOptions = { + messenger, + clientId: BridgeClientId.EXTENSION, + clientProduct: 'test-product', + apiBaseUrl: API_BASE_URL, + onError, + ...overrides, + }; + + return { + service: new QuoteStatusApiService(options), + onError, + messengerCall: call, + }; +} + +describe('QuoteStatusApiService', () => { + let fetchSpy: jest.SpyInstance; + + beforeEach(() => { + fetchSpy = jest.spyOn(globalThis, 'fetch'); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + describe('updateQuoteStatus', () => { + it('returns null for a 2xx response', async () => { + fetchSpy.mockResolvedValue(createFetchResponse({ ok: true })); + const { service } = createService(); + + const result = await service.updateQuoteStatus(REQUEST_DATA); + + expect(result).toBeNull(); + }); + + it('sends a POST request to the updateStatus endpoint with the payload as the body', async () => { + fetchSpy.mockResolvedValue(createFetchResponse({ ok: true })); + const { service } = createService(); + + await service.updateQuoteStatus(REQUEST_DATA); + + expect(fetchSpy).toHaveBeenCalledWith( + `${API_BASE_URL}/quote/updateStatus`, + expect.objectContaining({ + method: 'POST', + body: JSON.stringify(REQUEST_DATA), + }), + ); + }); + + it('sends the client product, content type, and authorization headers', async () => { + fetchSpy.mockResolvedValue(createFetchResponse({ ok: true })); + const { service } = createService(); + + await service.updateQuoteStatus(REQUEST_DATA); + + const { headers } = fetchSpy.mock.calls[0][1]; + expect(headers).toMatchObject({ + 'Content-Type': 'application/json', + 'x-metamask-clientproduct': 'test-product', + 'X-Client-Id': BridgeClientId.EXTENSION, + Authorization: 'Bearer test-jwt', + }); + }); + + it('includes the client version header when configured', async () => { + fetchSpy.mockResolvedValue(createFetchResponse({ ok: true })); + const { service } = createService({ clientVersion: '1.2.3' }); + + await service.updateQuoteStatus(REQUEST_DATA); + + const { headers } = fetchSpy.mock.calls[0][1]; + expect(headers).toMatchObject({ 'x-metamask-clientversion': '1.2.3' }); + }); + + it('omits the client version header when not configured', async () => { + fetchSpy.mockResolvedValue(createFetchResponse({ ok: true })); + const { service } = createService(); + + await service.updateQuoteStatus(REQUEST_DATA); + + const { headers } = fetchSpy.mock.calls[0][1]; + expect(headers).not.toHaveProperty('x-metamask-clientversion'); + }); + + it('omits the authorization header when no JWT is available', async () => { + fetchSpy.mockResolvedValue(createFetchResponse({ ok: true })); + const messenger = { + call: jest.fn().mockRejectedValue(new Error('no token')), + } as unknown as BridgeStatusControllerMessenger; + jest.spyOn(console, 'error').mockImplementation(() => undefined); + const { service } = createService({ messenger }); + + await service.updateQuoteStatus(REQUEST_DATA); + + const { headers } = fetchSpy.mock.calls[0][1]; + expect(headers).not.toHaveProperty('Authorization'); + }); + + it('forwards the abort signal to fetch', async () => { + fetchSpy.mockResolvedValue(createFetchResponse({ ok: true })); + const { service } = createService(); + const controller = new AbortController(); + + await service.updateQuoteStatus(REQUEST_DATA, controller.signal); + + expect(fetchSpy.mock.calls[0][1].signal).toBe(controller.signal); + }); + + it('returns the validated error response for a non-2xx response', async () => { + const errorBody = { + statusCode: 404, + message: 'quote not found', + type: QuoteStatusUpdateBackendErrorType.QuoteNotFound, + }; + fetchSpy.mockResolvedValue( + createFetchResponse({ ok: false, body: errorBody }), + ); + const { service } = createService(); + + const result = await service.updateQuoteStatus(REQUEST_DATA); + + expect(result).toStrictEqual(errorBody); + }); + + it('throws and notifies onError when the error response shape is unexpected', async () => { + fetchSpy.mockResolvedValue( + createFetchResponse({ ok: false, body: { unexpected: true } }), + ); + const { service, onError } = createService(); + + await expect(service.updateQuoteStatus(REQUEST_DATA)).rejects.toThrow( + 'Expected the value to satisfy a union of', + ); + expect(onError).toHaveBeenCalledTimes(1); + const [error] = onError.mock.calls[0]; + expect(error).toBeInstanceOf(QuoteStatusUpdateError); + expect(error.message).toBe( + 'unexpected response shape from quote/updateStatus', + ); + expect(error.details).toStrictEqual({ + quoteId: REQUEST_DATA.quoteId, + srcTxHash: REQUEST_DATA.srcTxHash, + }); + }); + + it('throws on an unexpected error response shape when no onError callback is provided', async () => { + fetchSpy.mockResolvedValue( + createFetchResponse({ ok: false, body: { unexpected: true } }), + ); + const { service } = createService({ onError: undefined }); + + await expect(service.updateQuoteStatus(REQUEST_DATA)).rejects.toThrow( + 'Expected the value to satisfy a union of', + ); + }); + }); + + describe('updateQuoteStatusWithRetry', () => { + const RETRY_OPTIONS = { maxRetries: 2, delayMsBetweenRetries: 0 }; + + it('returns an Accepted outcome when the update succeeds on the first attempt', async () => { + fetchSpy.mockResolvedValue(createFetchResponse({ ok: true })); + const { service } = createService(); + + const outcome = await service.updateQuoteStatusWithRetry( + REQUEST_DATA, + RETRY_OPTIONS, + ); + + expect(outcome.type).toBe(QuoteStatusFetchWithRetryOutcomeType.Accepted); + expect(fetchSpy).toHaveBeenCalledTimes(1); + }); + + it('returns a NonRetryable outcome with the response for a non-retryable error', async () => { + const errorBody = { + statusCode: 404, + message: 'quote not found', + type: QuoteStatusUpdateBackendErrorType.QuoteNotFound, + }; + fetchSpy.mockResolvedValue( + createFetchResponse({ ok: false, body: errorBody }), + ); + const { service } = createService(); + + const outcome = await service.updateQuoteStatusWithRetry( + REQUEST_DATA, + RETRY_OPTIONS, + ); + + expect(outcome.type).toBe( + QuoteStatusFetchWithRetryOutcomeType.NonRetryable, + ); + expect(outcome.response).toStrictEqual(errorBody); + expect(fetchSpy).toHaveBeenCalledTimes(1); + }); + + it('retries on a retryable error and returns RetryableExhausted after all attempts', async () => { + const errorBody = { + statusCode: 409, + message: 'concurrent update', + type: QuoteStatusUpdateBackendErrorType.ConcurrentUpdate, + }; + fetchSpy.mockResolvedValue( + createFetchResponse({ ok: false, body: errorBody }), + ); + const { service } = createService(); + + const outcome = await service.updateQuoteStatusWithRetry( + REQUEST_DATA, + RETRY_OPTIONS, + ); + + expect(outcome.type).toBe( + QuoteStatusFetchWithRetryOutcomeType.RetryableExhausted, + ); + expect(fetchSpy).toHaveBeenCalledTimes(RETRY_OPTIONS.maxRetries + 1); + }); + + it('returns Accepted when a retryable error is followed by a success', async () => { + const errorBody = { + statusCode: 409, + message: 'concurrent update', + type: QuoteStatusUpdateBackendErrorType.ConcurrentUpdate, + }; + fetchSpy + .mockResolvedValueOnce( + createFetchResponse({ ok: false, body: errorBody }), + ) + .mockResolvedValueOnce(createFetchResponse({ ok: true })); + const { service } = createService(); + + const outcome = await service.updateQuoteStatusWithRetry( + REQUEST_DATA, + RETRY_OPTIONS, + ); + + expect(outcome.type).toBe(QuoteStatusFetchWithRetryOutcomeType.Accepted); + expect(fetchSpy).toHaveBeenCalledTimes(2); + }); + + it('returns Interrupted when the signal is already aborted', async () => { + fetchSpy.mockResolvedValue(createFetchResponse({ ok: true })); + const { service } = createService(); + const controller = new AbortController(); + controller.abort(); + + const outcome = await service.updateQuoteStatusWithRetry( + REQUEST_DATA, + RETRY_OPTIONS, + controller.signal, + ); + + expect(outcome.type).toBe( + QuoteStatusFetchWithRetryOutcomeType.Interrupted, + ); + expect(fetchSpy).not.toHaveBeenCalled(); + }); + + it('returns Interrupted when retry returns false before the first attempt', async () => { + fetchSpy.mockResolvedValue(createFetchResponse({ ok: true })); + const { service } = createService(); + + const outcome = await service.updateQuoteStatusWithRetry(REQUEST_DATA, { + ...RETRY_OPTIONS, + retry: () => false, + }); + + expect(outcome.type).toBe( + QuoteStatusFetchWithRetryOutcomeType.Interrupted, + ); + expect(fetchSpy).not.toHaveBeenCalled(); + }); + + it('stops retrying once retry returns false between attempts', async () => { + const errorBody = { + statusCode: 409, + message: 'concurrent update', + type: QuoteStatusUpdateBackendErrorType.ConcurrentUpdate, + }; + fetchSpy.mockResolvedValue( + createFetchResponse({ ok: false, body: errorBody }), + ); + const { service } = createService(); + let proceed = true; + + const outcome = await service.updateQuoteStatusWithRetry(REQUEST_DATA, { + ...RETRY_OPTIONS, + // Allow the first attempt, then stop before the retry. + retry: () => { + const current = proceed; + proceed = false; + return current; + }, + }); + + expect(outcome.type).toBe( + QuoteStatusFetchWithRetryOutcomeType.Interrupted, + ); + expect(fetchSpy).toHaveBeenCalledTimes(1); + }); + + it('returns Interrupted when fetch rejects after the signal is aborted', async () => { + const controller = new AbortController(); + fetchSpy.mockImplementation(async () => { + controller.abort(); + throw new Error('aborted'); + }); + const { service } = createService(); + + const outcome = await service.updateQuoteStatusWithRetry( + REQUEST_DATA, + RETRY_OPTIONS, + controller.signal, + ); + + expect(outcome.type).toBe( + QuoteStatusFetchWithRetryOutcomeType.Interrupted, + ); + }); + + it('rethrows when fetch rejects and the signal is not aborted', async () => { + fetchSpy.mockRejectedValue(new Error('network failure')); + const { service } = createService(); + + await expect( + service.updateQuoteStatusWithRetry(REQUEST_DATA, RETRY_OPTIONS), + ).rejects.toThrow('network failure'); + }); + }); + + describe('getQuoteStatus', () => { + it('returns the validated response for a 2xx response', async () => { + fetchSpy.mockResolvedValue( + createFetchResponse({ ok: true, body: GET_RESPONSE_BODY }), + ); + const { service } = createService(); + + const result = await service.getQuoteStatus(GET_REQUEST_DATA); + + expect(result).toStrictEqual(GET_RESPONSE_BODY); + }); + + it('sends a GET request to the getQuoteStatus endpoint with the quoteId query param', async () => { + fetchSpy.mockResolvedValue( + createFetchResponse({ ok: true, body: GET_RESPONSE_BODY }), + ); + const { service } = createService(); + + await service.getQuoteStatus(GET_REQUEST_DATA); + + expect(fetchSpy).toHaveBeenCalledWith( + `${API_BASE_URL}/getQuoteStatus?quoteId=${GET_REQUEST_DATA.quoteId}`, + expect.objectContaining({ method: 'GET' }), + ); + }); + + it('sends the client product, content type, and authorization headers', async () => { + fetchSpy.mockResolvedValue( + createFetchResponse({ ok: true, body: GET_RESPONSE_BODY }), + ); + const { service } = createService(); + + await service.getQuoteStatus(GET_REQUEST_DATA); + + const { headers } = fetchSpy.mock.calls[0][1]; + expect(headers).toMatchObject({ + 'Content-Type': 'application/json', + 'x-metamask-clientproduct': 'test-product', + 'X-Client-Id': BridgeClientId.EXTENSION, + Authorization: 'Bearer test-jwt', + }); + }); + + it('includes the client version header when configured', async () => { + fetchSpy.mockResolvedValue( + createFetchResponse({ ok: true, body: GET_RESPONSE_BODY }), + ); + const { service } = createService({ clientVersion: '1.2.3' }); + + await service.getQuoteStatus(GET_REQUEST_DATA); + + const { headers } = fetchSpy.mock.calls[0][1]; + expect(headers).toMatchObject({ 'x-metamask-clientversion': '1.2.3' }); + }); + + it('omits the client version header when not configured', async () => { + fetchSpy.mockResolvedValue( + createFetchResponse({ ok: true, body: GET_RESPONSE_BODY }), + ); + const { service } = createService(); + + await service.getQuoteStatus(GET_REQUEST_DATA); + + const { headers } = fetchSpy.mock.calls[0][1]; + expect(headers).not.toHaveProperty('x-metamask-clientversion'); + }); + + it('omits the authorization header when no JWT is available', async () => { + fetchSpy.mockResolvedValue( + createFetchResponse({ ok: true, body: GET_RESPONSE_BODY }), + ); + const messenger = { + call: jest.fn().mockRejectedValue(new Error('no token')), + } as unknown as BridgeStatusControllerMessenger; + jest.spyOn(console, 'error').mockImplementation(() => undefined); + const { service } = createService({ messenger }); + + await service.getQuoteStatus(GET_REQUEST_DATA); + + const { headers } = fetchSpy.mock.calls[0][1]; + expect(headers).not.toHaveProperty('Authorization'); + }); + + it('forwards the abort signal to fetch', async () => { + fetchSpy.mockResolvedValue( + createFetchResponse({ ok: true, body: GET_RESPONSE_BODY }), + ); + const { service } = createService(); + const controller = new AbortController(); + + await service.getQuoteStatus(GET_REQUEST_DATA, controller.signal); + + expect(fetchSpy.mock.calls[0][1].signal).toBe(controller.signal); + }); + + it('throws QuoteStatusGetError and notifies onError for a non-2xx response', async () => { + fetchSpy.mockResolvedValue( + createFetchResponse({ + ok: false, + status: 404, + statusText: 'Not Found', + }), + ); + const { service, onError } = createService(); + + await expect(service.getQuoteStatus(GET_REQUEST_DATA)).rejects.toThrow( + QuoteStatusGetError, + ); + expect(onError).toHaveBeenCalledTimes(1); + const [error] = onError.mock.calls[0]; + expect(error).toBeInstanceOf(QuoteStatusGetError); + expect(error.message).toBe( + 'request error to getQuoteStatus [404: Not Found]', + ); + expect(error.details).toStrictEqual({ + quoteId: GET_REQUEST_DATA.quoteId, + }); + }); + + it('sets retryable=false on the error for 4xx responses', async () => { + fetchSpy.mockResolvedValue( + createFetchResponse({ + ok: false, + status: 404, + statusText: 'Not Found', + }), + ); + const { service } = createService(); + + const thrown = await service + .getQuoteStatus(GET_REQUEST_DATA) + .catch((error) => error); + + expect(thrown).toBeInstanceOf(QuoteStatusGetError); + expect(thrown.retryable).toBe(false); + }); + + it('sets retryable=true on the error for 5xx responses', async () => { + fetchSpy.mockResolvedValue( + createFetchResponse({ + ok: false, + status: 503, + statusText: 'Service Unavailable', + }), + ); + const { service } = createService(); + + const thrown = await service + .getQuoteStatus(GET_REQUEST_DATA) + .catch((error) => error); + + expect(thrown).toBeInstanceOf(QuoteStatusGetError); + expect(thrown.retryable).toBe(true); + }); + + it('sets retryable=false on validation-failure errors', async () => { + fetchSpy.mockResolvedValue( + createFetchResponse({ + ok: true, + body: { submittedTx: { status: 'NOT_A_STATUS_TYPE' } }, + }), + ); + const { service } = createService(); + + const thrown = await service + .getQuoteStatus(GET_REQUEST_DATA) + .catch((error) => error); + + expect(thrown).toBeInstanceOf(QuoteStatusGetError); + expect(thrown.retryable).toBe(false); + }); + + it('throws and notifies onError when the success response shape is unexpected', async () => { + fetchSpy.mockResolvedValue( + createFetchResponse({ + ok: true, + body: { + submittedTx: { + status: 'NOT_A_STATUS_TYPE', + }, + }, + }), + ); + const { service, onError } = createService(); + + await expect(service.getQuoteStatus(GET_REQUEST_DATA)).rejects.toThrow( + 'unexpected response shape from getQuoteStatus', + ); + expect(onError).toHaveBeenCalledTimes(1); + const [error] = onError.mock.calls[0]; + expect(error).toBeInstanceOf(QuoteStatusGetError); + expect(error.message).toBe( + 'unexpected response shape from getQuoteStatus', + ); + expect(error.details).toStrictEqual({ + quoteId: GET_REQUEST_DATA.quoteId, + validationFailures: expect.arrayContaining([ + expect.stringContaining('submittedTx.status'), + ]), + }); + }); + + it('throws on an unexpected success response shape when no onError callback is provided', async () => { + fetchSpy.mockResolvedValue( + createFetchResponse({ + ok: true, + body: { + submittedTx: { + status: 'NOT_A_STATUS_TYPE', + }, + }, + }), + ); + const { service } = createService({ onError: undefined }); + + await expect(service.getQuoteStatus(GET_REQUEST_DATA)).rejects.toThrow( + 'unexpected response shape from getQuoteStatus', + ); + }); + + it('re-throws a non-StructError thrown during validation without calling onError', async () => { + const nonStructError = new Error('unexpected validator crash'); + fetchSpy.mockResolvedValue( + createFetchResponse({ ok: true, body: GET_RESPONSE_BODY }), + ); + jest + .spyOn(validators, 'validateQuoteStatusGetResponse') + .mockImplementationOnce(() => { + throw nonStructError; + }); + const { service, onError } = createService(); + + await expect(service.getQuoteStatus(GET_REQUEST_DATA)).rejects.toThrow( + nonStructError, + ); + expect(onError).not.toHaveBeenCalled(); + }); + }); + + describe('getQuoteStatusWithRetry', () => { + const RETRY_OPTIONS = { maxRetries: 2, delayMsBetweenRetries: 0 }; + + it('returns an Accepted outcome with the response when the fetch succeeds on the first attempt', async () => { + fetchSpy.mockResolvedValue( + createFetchResponse({ ok: true, body: GET_RESPONSE_BODY }), + ); + const { service } = createService(); + + const outcome = await service.getQuoteStatusWithRetry( + GET_REQUEST_DATA, + RETRY_OPTIONS, + ); + + expect(outcome.type).toBe(QuoteStatusFetchWithRetryOutcomeType.Accepted); + expect(outcome.response).toStrictEqual(GET_RESPONSE_BODY); + expect(fetchSpy).toHaveBeenCalledTimes(1); + }); + + it('returns NonRetryable with the error when getQuoteStatus throws a non-retryable QuoteStatusGetError', async () => { + const { service } = createService(); + const quoteStatusGetError = new QuoteStatusGetError( + 'api error', + { quoteId: GET_REQUEST_DATA.quoteId }, + false, // non-retryable (e.g. 4xx or validation failure) + ); + jest + .spyOn(service, 'getQuoteStatus') + .mockRejectedValue(quoteStatusGetError); + + const outcome = await service.getQuoteStatusWithRetry( + GET_REQUEST_DATA, + RETRY_OPTIONS, + ); + + expect(outcome.type).toBe( + QuoteStatusFetchWithRetryOutcomeType.NonRetryable, + ); + expect(outcome.error).toBe(quoteStatusGetError); + }); + + it('does not retry when getQuoteStatus throws a non-retryable QuoteStatusGetError', async () => { + const { service } = createService(); + const quoteStatusGetError = new QuoteStatusGetError( + 'api error', + { quoteId: GET_REQUEST_DATA.quoteId }, + false, + ); + const getQuoteStatusSpy = jest + .spyOn(service, 'getQuoteStatus') + .mockRejectedValue(quoteStatusGetError); + + await service.getQuoteStatusWithRetry(GET_REQUEST_DATA, RETRY_OPTIONS); + + // Non-retryable errors short-circuit immediately; only 1 attempt. + expect(getQuoteStatusSpy).toHaveBeenCalledTimes(1); + }); + + it('retries and returns RetryableExhausted when getQuoteStatus always throws a retryable QuoteStatusGetError', async () => { + const { service } = createService(); + const retryableError = new QuoteStatusGetError( + 'request error to getQuoteStatus [500: Internal Server Error]', + { quoteId: GET_REQUEST_DATA.quoteId }, + true, // retryable (5xx) + ); + const getQuoteStatusSpy = jest + .spyOn(service, 'getQuoteStatus') + .mockRejectedValue(retryableError); + + const outcome = await service.getQuoteStatusWithRetry( + GET_REQUEST_DATA, + RETRY_OPTIONS, + ); + + expect(outcome.type).toBe( + QuoteStatusFetchWithRetryOutcomeType.RetryableExhausted, + ); + expect(getQuoteStatusSpy).toHaveBeenCalledTimes( + RETRY_OPTIONS.maxRetries + 1, + ); + }); + + it('returns Accepted when a retryable error is followed by a success', async () => { + const { service } = createService(); + const retryableError = new QuoteStatusGetError( + 'request error to getQuoteStatus [503: Service Unavailable]', + { quoteId: GET_REQUEST_DATA.quoteId }, + true, + ); + const getQuoteStatusSpy = jest + .spyOn(service, 'getQuoteStatus') + .mockRejectedValueOnce(retryableError) + .mockResolvedValueOnce(GET_RESPONSE_BODY); + + const outcome = await service.getQuoteStatusWithRetry( + GET_REQUEST_DATA, + RETRY_OPTIONS, + ); + + expect(outcome.type).toBe(QuoteStatusFetchWithRetryOutcomeType.Accepted); + expect(outcome.response).toStrictEqual(GET_RESPONSE_BODY); + expect(getQuoteStatusSpy).toHaveBeenCalledTimes(2); + }); + + it('returns RetryableExhausted after all attempts fail with a non-QuoteStatusGetError', async () => { + const { service } = createService(); + const getQuoteStatusSpy = jest + .spyOn(service, 'getQuoteStatus') + .mockRejectedValue(new Error('network error')); + + const outcome = await service.getQuoteStatusWithRetry( + GET_REQUEST_DATA, + RETRY_OPTIONS, + ); + + expect(outcome.type).toBe( + QuoteStatusFetchWithRetryOutcomeType.RetryableExhausted, + ); + expect(getQuoteStatusSpy).toHaveBeenCalledTimes( + RETRY_OPTIONS.maxRetries + 1, + ); + }); + + it('returns Accepted when failures are followed by a success', async () => { + const { service } = createService(); + const getQuoteStatusSpy = jest + .spyOn(service, 'getQuoteStatus') + .mockRejectedValueOnce(new Error('network error')) + .mockResolvedValueOnce(GET_RESPONSE_BODY); + + const outcome = await service.getQuoteStatusWithRetry( + GET_REQUEST_DATA, + RETRY_OPTIONS, + ); + + expect(outcome.type).toBe(QuoteStatusFetchWithRetryOutcomeType.Accepted); + expect(outcome.response).toStrictEqual(GET_RESPONSE_BODY); + expect(getQuoteStatusSpy).toHaveBeenCalledTimes(2); + }); + + it('returns Interrupted when the signal is already aborted', async () => { + fetchSpy.mockResolvedValue( + createFetchResponse({ ok: true, body: GET_RESPONSE_BODY }), + ); + const { service } = createService(); + const controller = new AbortController(); + controller.abort(); + + const outcome = await service.getQuoteStatusWithRetry( + GET_REQUEST_DATA, + RETRY_OPTIONS, + controller.signal, + ); + + expect(outcome.type).toBe( + QuoteStatusFetchWithRetryOutcomeType.Interrupted, + ); + expect(fetchSpy).not.toHaveBeenCalled(); + }); + + it('returns Interrupted when getQuoteStatus rejects after the signal is aborted', async () => { + const controller = new AbortController(); + fetchSpy.mockImplementation(async () => { + controller.abort(); + throw new Error('aborted'); + }); + const { service } = createService(); + + const outcome = await service.getQuoteStatusWithRetry( + GET_REQUEST_DATA, + RETRY_OPTIONS, + controller.signal, + ); + + expect(outcome.type).toBe( + QuoteStatusFetchWithRetryOutcomeType.Interrupted, + ); + }); + + it('forwards the abort signal to getQuoteStatus', async () => { + const { service } = createService(); + const controller = new AbortController(); + const getQuoteStatusSpy = jest + .spyOn(service, 'getQuoteStatus') + .mockResolvedValue(GET_RESPONSE_BODY); + + await service.getQuoteStatusWithRetry( + GET_REQUEST_DATA, + RETRY_OPTIONS, + controller.signal, + ); + + expect(getQuoteStatusSpy).toHaveBeenCalledWith( + GET_REQUEST_DATA, + controller.signal, + ); + }); + }); +}); diff --git a/packages/bridge-status-controller/src/quote-status-manager/quote-status-api-service.ts b/packages/bridge-status-controller/src/quote-status-manager/quote-status-api-service.ts new file mode 100644 index 00000000000..8465acd0706 --- /dev/null +++ b/packages/bridge-status-controller/src/quote-status-manager/quote-status-api-service.ts @@ -0,0 +1,350 @@ +import { getClientHeaders } from '@metamask/bridge-controller'; +import { StructError } from '@metamask/superstruct'; + +import { BridgeClientId, BridgeStatusControllerMessenger } from '../types.js'; +import { getJwt } from '../utils/authentication.js'; +import { + QuoteStatusBackendStatus, + QuoteStatusUpdateRetryableBackendTypes, + QuoteStatusFetchWithRetryOutcomeType, +} from './constants.js'; +import { QuoteStatusGetError, QuoteStatusUpdateError } from './errors.js'; +import { QuoteStatusGetWithRetryOutcome } from './quote-status-get-with-retry-outcome.js'; +import { QuoteStatusUpdateWithRetryOutcome } from './quote-status-update-with-retry-outcome.js'; +import { + QuoteStatusApiServiceOptions, + QuoteStatusGetResponse, + QuoteStatusUpdateResponse, +} from './types.js'; +import { sleep } from './utils.js'; +import { + validateQuoteStatusGetResponse, + validateQuoteStatusUpdateResponse, +} from './validators.js'; + +/** + * Service responsible for calling bridge quote status update APIs. + * + * It performs authentication, sends properly-scoped client headers, and validates + * error responses so callers can handle known update-status failures. + */ +export class QuoteStatusApiService { + readonly #messenger: BridgeStatusControllerMessenger; + + readonly #clientId: BridgeClientId; + + readonly #clientProduct: string; + + readonly #clientVersion: string | undefined; + + readonly #apiBaseUrl: string; + + readonly #onError: ((error: QuoteStatusUpdateError) => void) | undefined; + + /** + * Creates an API service for quote status update requests. + * + * @param options - Service dependencies and request configuration. + * @param options.messenger - Messenger used to retrieve the authentication token. + * @param options.clientId - Bridge client identifier used for request headers. + * @param options.clientProduct - Product name sent in client product headers. + * @param options.clientVersion - Optional client version sent in headers. + * @param options.apiBaseUrl - Base URL for the quote status API. + * @param options.onError - Optional callback for unexpected response-shape errors. + */ + constructor({ + messenger, + clientId, + clientProduct, + clientVersion, + apiBaseUrl, + onError, + }: QuoteStatusApiServiceOptions) { + this.#messenger = messenger; + this.#clientId = clientId; + this.#clientProduct = clientProduct; + this.#clientVersion = clientVersion; + this.#apiBaseUrl = apiBaseUrl; + this.#onError = onError; + } + + /** + * Updates a quote status in the bridge quote-status API. + * + * The endpoint returns no payload on success (`2xx`) and a structured payload + * on non-`2xx` responses. This method returns `null` for successful updates and + * the validated error payload for unsuccessful updates. + * + * @param data - Request payload identifying quote and target status transition. + * @param data.quoteId - Unique quote identifier to update. + * @param data.srcTxHash - Source transaction hash associated with the quote. + * @param data.newStatus - Target quote status to persist. + * @param signal - Optional abort signal for canceling the request. + * @returns `null` for `2xx` responses, or a validated error response for non-`2xx`. + * @throws If the non-`2xx` response body does not match the expected schema. + */ + async updateQuoteStatus( + data: { + quoteId: string; + srcTxHash: string; + newStatus: QuoteStatusBackendStatus; + }, + signal?: AbortSignal, + ): Promise { + const jwt = await getJwt(this.#messenger); + + // This method uses `globalThis.fetch` and reads the raw + // `Response` (including JSON on non-2xx). Wrappers like `handleFetch` that + // throw on non-2xx would prevent typed error handling in callers. + const res = await globalThis.fetch( + `${this.#apiBaseUrl}/quote/updateStatus`, + { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'x-metamask-clientproduct': this.#clientProduct, + ...(this.#clientVersion + ? { 'x-metamask-clientversion': this.#clientVersion } + : {}), + ...getClientHeaders({ + clientId: this.#clientId, + jwt, + }), + }, + body: JSON.stringify(data), + signal, + }, + ); + + if (res.ok) { + return null; + } + + const responseData = await res.json(); + + try { + validateQuoteStatusUpdateResponse(responseData); + return responseData; + } catch (error) { + this.#onError?.( + new QuoteStatusUpdateError( + 'unexpected response shape from quote/updateStatus', + { quoteId: data.quoteId, srcTxHash: data.srcTxHash }, + ), + ); + throw error; + } + } + + async getQuoteStatus( + data: { + quoteId: string; + }, + signal?: AbortSignal, + ): Promise { + const jwt = await getJwt(this.#messenger); + + // This method uses `globalThis.fetch` and reads the raw + // `Response` (including JSON on non-2xx). Wrappers like `handleFetch` that + // throw on non-2xx would prevent typed error handling in callers. + const res = await globalThis.fetch( + `${this.#apiBaseUrl}/getQuoteStatus?quoteId=${data.quoteId}`, + { + method: 'GET', + headers: { + 'Content-Type': 'application/json', + 'x-metamask-clientproduct': this.#clientProduct, + ...(this.#clientVersion + ? { 'x-metamask-clientversion': this.#clientVersion } + : {}), + ...getClientHeaders({ + clientId: this.#clientId, + jwt, + }), + }, + signal, + }, + ); + + if (!res.ok) { + // 5xx errors are transient (server-side); 4xx errors are client-side and + // non-retryable. The `retryable` flag lets `getQuoteStatusWithRetry` + // distinguish the two and only exit early for permanent failures. + const retryable = res.status >= 500; + const error = new QuoteStatusGetError( + `request error to getQuoteStatus [${res.status}: ${res.statusText}]`, + { quoteId: data.quoteId }, + retryable, + ); + this.#onError?.(error); + throw error; + } + + const responseData = await res.json(); + + try { + validateQuoteStatusGetResponse(responseData); + return responseData; + } catch (error) { + if (error instanceof StructError) { + const validationFailures = []; + + for (const { path } of error.failures()) { + const aggregatorId = + (responseData as QuoteStatusGetResponse)?.submittedTx?.bridge ?? + ('unknown' as string); + const pathString = path?.join('.') || 'unknown'; + validationFailures.push([aggregatorId, pathString].join('|')); + } + + const validationError = new QuoteStatusGetError( + 'unexpected response shape from getQuoteStatus', + { quoteId: data.quoteId, validationFailures }, + ); + + this.#onError?.(validationError); + + throw validationError; + } + + throw error; + } + } + + /** + * Updates a quote status, retrying on transient backend failures. + * + * Wraps {@link updateQuoteStatus} in a bounded retry loop. A request is only + * retried when the backend returns an error whose type is in + * {@link QuoteStatusUpdateRetryableBackendTypes}; any other error response + * resolves immediately as non-retryable. Retries are spaced by + * `options.delayMsBetweenRetries`, and both the abort signal and the optional + * `options.retry` predicate are checked before each attempt so an + * in-flight or pending retry can be cancelled (e.g. when the entry's status + * changed while sleeping between retries). + * + * @param data - Request payload identifying the quote and target transition. + * @param data.quoteId - Unique quote identifier to update. + * @param data.srcTxHash - Source transaction hash associated with the quote. + * @param data.newStatus - Target quote status to persist. + * @param options - Retry configuration. + * @param options.maxRetries - Maximum number of retries after the initial attempt. + * @param options.delayMsBetweenRetries - Delay in milliseconds between attempts. + * @param options.retry - Optional predicate checked before each attempt; + * when it returns `false` the loop stops early and resolves as `Interrupted`. + * @param signal - Optional abort signal for canceling the request and its retries. + * @returns An outcome describing how the update resolved: + * `Accepted` when the backend accepted the update, `NonRetryable` for a + * non-retryable error response, `Interrupted` when aborted, or + * `RetryableExhausted` when all retries were used up on retryable errors. + * @throws If a request rejects for a reason other than the abort signal (e.g. + * an unexpected response shape from {@link updateQuoteStatus}). + */ + async updateQuoteStatusWithRetry( + data: { + quoteId: string; + srcTxHash: string; + newStatus: QuoteStatusBackendStatus; + }, + options: { + maxRetries: number; + delayMsBetweenRetries: number; + retry?: () => boolean; + }, + signal?: AbortSignal, + ): Promise { + for (let attempt = 0; attempt <= options.maxRetries; attempt += 1) { + if (attempt > 0) { + await sleep(options.delayMsBetweenRetries); + } + + if (signal?.aborted || options.retry?.() === false) { + return new QuoteStatusUpdateWithRetryOutcome( + QuoteStatusFetchWithRetryOutcomeType.Interrupted, + ); + } + + try { + const response = await this.updateQuoteStatus(data, signal); + + if (response === null) { + return new QuoteStatusUpdateWithRetryOutcome( + QuoteStatusFetchWithRetryOutcomeType.Accepted, + ); + } + + if (!QuoteStatusUpdateRetryableBackendTypes.includes(response.type)) { + return new QuoteStatusUpdateWithRetryOutcome( + QuoteStatusFetchWithRetryOutcomeType.NonRetryable, + response, + ); + } + } catch (error) { + if (signal?.aborted) { + return new QuoteStatusUpdateWithRetryOutcome( + QuoteStatusFetchWithRetryOutcomeType.Interrupted, + ); + } + + throw error; + } + } + + return new QuoteStatusUpdateWithRetryOutcome( + QuoteStatusFetchWithRetryOutcomeType.RetryableExhausted, + ); + } + + async getQuoteStatusWithRetry( + data: { + quoteId: string; + }, + options: { + maxRetries: number; + delayMsBetweenRetries: number; + }, + signal?: AbortSignal, + ): Promise { + for (let attempt = 0; attempt <= options.maxRetries; attempt += 1) { + if (attempt > 0) { + await sleep(options.delayMsBetweenRetries); + } + + if (signal?.aborted) { + return new QuoteStatusGetWithRetryOutcome( + QuoteStatusFetchWithRetryOutcomeType.Interrupted, + ); + } + + try { + const response = await this.getQuoteStatus(data, signal); + + return new QuoteStatusGetWithRetryOutcome( + QuoteStatusFetchWithRetryOutcomeType.Accepted, + response, + ); + } catch (error) { + if (signal?.aborted) { + return new QuoteStatusGetWithRetryOutcome( + QuoteStatusFetchWithRetryOutcomeType.Interrupted, + ); + } + + // Only short-circuit for non-retryable errors (e.g. 4xx, validation + // failures). Retryable errors (e.g. 5xx) fall through so the loop can + // attempt the next retry instead of returning NonRetryable immediately. + if (error instanceof QuoteStatusGetError && !error.retryable) { + return new QuoteStatusGetWithRetryOutcome( + QuoteStatusFetchWithRetryOutcomeType.NonRetryable, + undefined, + error, + ); + } + } + } + + return new QuoteStatusGetWithRetryOutcome( + QuoteStatusFetchWithRetryOutcomeType.RetryableExhausted, + ); + } +} diff --git a/packages/bridge-status-controller/src/quote-status-manager/quote-status-entry-store.test.ts b/packages/bridge-status-controller/src/quote-status-manager/quote-status-entry-store.test.ts new file mode 100644 index 00000000000..86acbe3915a --- /dev/null +++ b/packages/bridge-status-controller/src/quote-status-manager/quote-status-entry-store.test.ts @@ -0,0 +1,518 @@ +import { QuoteStatusState } from './constants.js'; +import { QuoteStatusEntryStore } from './quote-status-entry-store.js'; +import { QuoteStatusStateFsm } from './quote-status-state-fsm.js'; +import type { + QuoteStatusPersistEntry, + QuoteStatusRuntimeEntry, +} from './types.js'; + +const TTL_MS = 1000; +const NOW = 1_000_000; + +/** + * Builds the value accepted by {@link QuoteStatusEntryStore.put}. + * + * @param overrides - Fields to override on the default value. + * @returns A runtime entry without the store-managed timestamps. + */ +function createPutValue( + overrides: Partial< + Omit + > = {}, +): Omit { + return { + quoteId: 'quote-1', + srcTxHash: '0xabc', + status: new QuoteStatusStateFsm(QuoteStatusState.Submitted), + ...overrides, + }; +} + +/** + * Builds a persisted entry used to seed a store via the `initial` option. + * + * @param overrides - Fields to override on the default entry. + * @returns A serializable persisted entry. + */ +function createPersistEntry( + overrides: Partial = {}, +): QuoteStatusPersistEntry { + return { + quoteId: 'quote-1', + srcTxHash: '0xabc', + status: QuoteStatusState.Submitted, + createdAt: NOW, + lastAttemptAt: NOW, + ...overrides, + }; +} + +/** + * Creates a store with a stubbed `onPersistUpdates` callback. + * + * @param options - Optional store overrides. + * @param options.initial - Initial persisted entries to seed. + * @param options.entryTtlMs - TTL override. + * @returns The store and its persistence spy. + */ +function createStore({ + initial, + entryTtlMs = TTL_MS, +}: { + initial?: Record; + entryTtlMs?: number; +} = {}): { + store: QuoteStatusEntryStore; + onPersistUpdates: jest.Mock; +} { + const onPersistUpdates = jest.fn(); + const store = new QuoteStatusEntryStore({ + onPersistUpdates, + entryTtlMs, + initial, + }); + return { store, onPersistUpdates }; +} + +describe('QuoteStatusEntryStore', () => { + beforeEach(() => { + jest.spyOn(Date, 'now').mockReturnValue(NOW); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + describe('hash', () => { + it('builds a key from the quote id and source tx hash', () => { + expect( + QuoteStatusEntryStore.hash({ quoteId: 'q1', srcTxHash: '0xdead' }), + ).toBe('q1:0xdead'); + }); + }); + + describe('constructor', () => { + it('seeds entries from the initial snapshot', () => { + const { store } = createStore({ + initial: { 'quote-1:0xabc': createPersistEntry() }, + }); + + const entry = store.get('quote-1:0xabc'); + expect(entry).not.toBeNull(); + expect(entry?.status).toBeInstanceOf(QuoteStatusStateFsm); + expect(entry?.status.state).toBe(QuoteStatusState.Submitted); + }); + + it('starts empty when no initial snapshot is provided', () => { + const { store } = createStore(); + + expect(store.size).toBe(0); + }); + + it('persists when a seeded entry transitions state', () => { + const { store, onPersistUpdates } = createStore({ + initial: { 'quote-1:0xabc': createPersistEntry() }, + }); + + const entry = store.get('quote-1:0xabc'); + entry?.status.transitionTo(QuoteStatusState.FinalizedSuccess); + + expect(onPersistUpdates).toHaveBeenCalledWith({ + 'quote-1:0xabc': expect.objectContaining({ + status: QuoteStatusState.FinalizedSuccess, + }), + }); + }); + }); + + describe('put', () => { + it('adds a new entry with created and last-attempt timestamps', () => { + const { store } = createStore(); + + store.put('quote-1:0xabc', createPutValue()); + + const entry = store.get('quote-1:0xabc'); + expect(entry).toMatchObject({ + quoteId: 'quote-1', + srcTxHash: '0xabc', + createdAt: NOW, + lastAttemptAt: NOW, + }); + }); + + it('persists the snapshot with the flattened status', () => { + const { store, onPersistUpdates } = createStore(); + + store.put('quote-1:0xabc', createPutValue()); + + expect(onPersistUpdates).toHaveBeenCalledWith({ + 'quote-1:0xabc': expect.objectContaining({ + status: QuoteStatusState.Submitted, + }), + }); + }); + + it('does not overwrite or reset an existing entry', () => { + const { store, onPersistUpdates } = createStore(); + store.put('quote-1:0xabc', createPutValue()); + onPersistUpdates.mockClear(); + + jest.spyOn(Date, 'now').mockReturnValue(NOW + 500); + store.put('quote-1:0xabc', createPutValue()); + + expect(store.get('quote-1:0xabc')?.createdAt).toBe(NOW); + expect(onPersistUpdates).not.toHaveBeenCalled(); + }); + + it('backfills a missing txMetaId on an existing entry', () => { + const { store, onPersistUpdates } = createStore(); + store.put('quote-1:0xabc', createPutValue()); + onPersistUpdates.mockClear(); + + store.put('quote-1:0xabc', createPutValue({ txMetaId: 'tx-1' })); + + expect(store.get('quote-1:0xabc')?.txMetaId).toBe('tx-1'); + expect(onPersistUpdates).toHaveBeenCalledTimes(1); + }); + + it('does not overwrite an existing txMetaId', () => { + const { store } = createStore(); + store.put('quote-1:0xabc', createPutValue({ txMetaId: 'tx-1' })); + + store.put('quote-1:0xabc', createPutValue({ txMetaId: 'tx-2' })); + + expect(store.get('quote-1:0xabc')?.txMetaId).toBe('tx-1'); + }); + + it('persists when a runtime-added entry transitions state', () => { + const { store, onPersistUpdates } = createStore(); + store.put('quote-1:0xabc', createPutValue()); + onPersistUpdates.mockClear(); + + store + .get('quote-1:0xabc') + ?.status.transitionTo(QuoteStatusState.FinalizedSuccess); + + expect(onPersistUpdates).toHaveBeenCalledTimes(1); + }); + }); + + describe('get', () => { + it('returns null for an unknown key', () => { + const { store } = createStore(); + + expect(store.get('missing')).toBeNull(); + }); + + it('transitions a stale entry to Expired but keeps it', () => { + const { store, onPersistUpdates } = createStore(); + store.put('quote-1:0xabc', createPutValue()); + onPersistUpdates.mockClear(); + jest.spyOn(Date, 'now').mockReturnValue(NOW + TTL_MS + 1); + + expect(store.get('quote-1:0xabc')?.status.state).toBe( + QuoteStatusState.Expired, + ); + expect(store.size).toBe(1); + expect(onPersistUpdates).toHaveBeenCalledTimes(1); + }); + }); + + describe('getByQuoteId', () => { + it('returns every entry matching the quote id', () => { + const { store } = createStore(); + store.put('quote-1:0xabc', createPutValue()); + store.put( + 'quote-1:0xdef', + createPutValue({ quoteId: 'quote-1', srcTxHash: '0xdef' }), + ); + store.put( + 'quote-2:0x123', + createPutValue({ quoteId: 'quote-2', srcTxHash: '0x123' }), + ); + + const matches = store.getByQuoteId('quote-1'); + + expect(matches).toHaveLength(2); + expect(matches.map((entry) => entry.srcTxHash).sort()).toStrictEqual([ + '0xabc', + '0xdef', + ]); + }); + + it('returns an empty array when no entry matches', () => { + const { store } = createStore(); + store.put('quote-1:0xabc', createPutValue()); + + expect(store.getByQuoteId('quote-missing')).toStrictEqual([]); + }); + + it('transitions stale matches to Expired before returning them', () => { + const { store } = createStore(); + store.put('quote-1:0xabc', createPutValue()); + jest.spyOn(Date, 'now').mockReturnValue(NOW + TTL_MS + 1); + + const matches = store.getByQuoteId('quote-1'); + + expect(matches).toHaveLength(1); + expect(matches[0].status.state).toBe(QuoteStatusState.Expired); + }); + }); + + describe('getByTxMetaId', () => { + it('returns the entry matching the txMetaId', () => { + const { store } = createStore(); + store.put('quote-1:0xabc', createPutValue({ txMetaId: 'tx-1' })); + + expect(store.getByTxMetaId('tx-1')?.quoteId).toBe('quote-1'); + }); + + it('returns null when no entry matches', () => { + const { store } = createStore(); + store.put('quote-1:0xabc', createPutValue({ txMetaId: 'tx-1' })); + + expect(store.getByTxMetaId('tx-missing')).toBeNull(); + }); + + it('transitions a stale matching entry to Expired but keeps it', () => { + const { store } = createStore(); + store.put('quote-1:0xabc', createPutValue({ txMetaId: 'tx-1' })); + jest.spyOn(Date, 'now').mockReturnValue(NOW + TTL_MS + 1); + + expect(store.getByTxMetaId('tx-1')?.status.state).toBe( + QuoteStatusState.Expired, + ); + expect(store.size).toBe(1); + }); + }); + + describe('getAllByTxMetaId', () => { + it('returns every entry sharing the txMetaId', () => { + const { store } = createStore(); + store.put('quote-1:0xabc', createPutValue({ txMetaId: 'tx-1' })); + store.put( + 'quote-2:0xabc', + createPutValue({ + quoteId: 'quote-2', + srcTxHash: '0xabc', + txMetaId: 'tx-1', + }), + ); + store.put( + 'quote-3:0xdef', + createPutValue({ + quoteId: 'quote-3', + srcTxHash: '0xdef', + txMetaId: 'tx-2', + }), + ); + + const matches = store.getAllByTxMetaId('tx-1'); + + expect(matches).toHaveLength(2); + expect(matches.map((entry) => entry.quoteId).sort()).toStrictEqual([ + 'quote-1', + 'quote-2', + ]); + }); + + it('returns an empty array when no entry matches', () => { + const { store } = createStore(); + store.put('quote-1:0xabc', createPutValue({ txMetaId: 'tx-1' })); + + expect(store.getAllByTxMetaId('tx-missing')).toStrictEqual([]); + }); + + it('transitions stale matches to Expired but keeps them', () => { + const { store } = createStore(); + store.put('quote-1:0xabc', createPutValue({ txMetaId: 'tx-1' })); + store.put( + 'quote-2:0xabc', + createPutValue({ + quoteId: 'quote-2', + srcTxHash: '0xabc', + txMetaId: 'tx-1', + }), + ); + jest.spyOn(Date, 'now').mockReturnValue(NOW + TTL_MS + 1); + + const matches = store.getAllByTxMetaId('tx-1'); + + expect(matches).toHaveLength(2); + expect( + matches.every( + (entry) => entry.status.state === QuoteStatusState.Expired, + ), + ).toBe(true); + expect(store.size).toBe(2); + }); + }); + + describe('update', () => { + it('persists when the entry is still tracked', () => { + const { store, onPersistUpdates } = createStore(); + store.put('quote-1:0xabc', createPutValue()); + const entry = store.get('quote-1:0xabc') as QuoteStatusRuntimeEntry; + onPersistUpdates.mockClear(); + + store.update(entry); + + expect(onPersistUpdates).toHaveBeenCalledTimes(1); + }); + + it('does not persist when the entry is no longer tracked', () => { + const { store, onPersistUpdates } = createStore(); + store.put('quote-1:0xabc', createPutValue()); + const entry = store.get('quote-1:0xabc') as QuoteStatusRuntimeEntry; + store.clear(); + onPersistUpdates.mockClear(); + + store.update(entry); + + expect(onPersistUpdates).not.toHaveBeenCalled(); + }); + }); + + describe('values', () => { + it('transitions stale entries to Expired but keeps yielding them', () => { + const { store } = createStore(); + store.put('quote-1:0xabc', createPutValue()); + store.put( + 'quote-2:0xdef', + createPutValue({ quoteId: 'quote-2', srcTxHash: '0xdef' }), + ); + jest.spyOn(Date, 'now').mockReturnValue(NOW + TTL_MS + 1); + + const entries = [...store.values()]; + + expect(entries).toHaveLength(2); + expect( + entries.every( + (entry) => entry.status.state === QuoteStatusState.Expired, + ), + ).toBe(true); + }); + + it('yields all live entries', () => { + const { store } = createStore(); + store.put('quote-1:0xabc', createPutValue()); + store.put( + 'quote-2:0xdef', + createPutValue({ quoteId: 'quote-2', srcTxHash: '0xdef' }), + ); + + expect([...store.values()]).toHaveLength(2); + }); + }); + + describe('entryHasExpired', () => { + it('returns false within the TTL window', () => { + const { store } = createStore(); + store.put('quote-1:0xabc', createPutValue()); + jest.spyOn(Date, 'now').mockReturnValue(NOW + TTL_MS); + + const entry = store.get('quote-1:0xabc') as QuoteStatusRuntimeEntry; + expect(store.entryHasExpired(entry)).toBe(false); + }); + + it('returns true once the age exceeds the TTL', () => { + const { store } = createStore(); + store.put('quote-1:0xabc', createPutValue()); + const entry = store.get('quote-1:0xabc') as QuoteStatusRuntimeEntry; + jest.spyOn(Date, 'now').mockReturnValue(NOW + TTL_MS + 1); + + expect(store.entryHasExpired(entry)).toBe(true); + }); + }); + + describe('expireEntryIfStale', () => { + it('transitions the entry to Expired (keeping it) and persists when stale', () => { + const { store, onPersistUpdates } = createStore(); + store.put('quote-1:0xabc', createPutValue()); + const entry = store.get('quote-1:0xabc') as QuoteStatusRuntimeEntry; + onPersistUpdates.mockClear(); + jest.spyOn(Date, 'now').mockReturnValue(NOW + TTL_MS + 1); + + expect(store.expireEntryIfStale(entry)).toBe(true); + expect(store.size).toBe(1); + expect(entry.status.state).toBe(QuoteStatusState.Expired); + expect(onPersistUpdates).toHaveBeenCalledTimes(1); + }); + + it('keeps the entry and returns false when not stale', () => { + const { store, onPersistUpdates } = createStore(); + store.put('quote-1:0xabc', createPutValue()); + const entry = store.get('quote-1:0xabc') as QuoteStatusRuntimeEntry; + onPersistUpdates.mockClear(); + + expect(store.expireEntryIfStale(entry)).toBe(false); + expect(store.size).toBe(1); + expect(entry.status.state).toBe(QuoteStatusState.Submitted); + expect(onPersistUpdates).not.toHaveBeenCalled(); + }); + }); + + describe('expireStaleEntries', () => { + it('transitions only stale entries to Expired and keeps all entries', () => { + const { store, onPersistUpdates } = createStore(); + store.put('quote-1:0xabc', createPutValue()); + jest.spyOn(Date, 'now').mockReturnValue(NOW + 900); + store.put( + 'quote-2:0xdef', + createPutValue({ quoteId: 'quote-2', srcTxHash: '0xdef' }), + ); + onPersistUpdates.mockClear(); + jest.spyOn(Date, 'now').mockReturnValue(NOW + TTL_MS + 1); + + store.expireStaleEntries(); + + expect(store.get('quote-1:0xabc')?.status.state).toBe( + QuoteStatusState.Expired, + ); + expect(store.get('quote-2:0xdef')?.status.state).toBe( + QuoteStatusState.Submitted, + ); + expect(store.size).toBe(2); + expect(onPersistUpdates).toHaveBeenCalledTimes(1); + }); + + it('does not persist when nothing is stale', () => { + const { store, onPersistUpdates } = createStore(); + store.put('quote-1:0xabc', createPutValue()); + onPersistUpdates.mockClear(); + + store.expireStaleEntries(); + + expect(onPersistUpdates).not.toHaveBeenCalled(); + }); + }); + + describe('clear', () => { + it('removes all entries without persisting', () => { + const { store, onPersistUpdates } = createStore(); + store.put('quote-1:0xabc', createPutValue()); + store.put( + 'quote-2:0xdef', + createPutValue({ quoteId: 'quote-2', srcTxHash: '0xdef' }), + ); + onPersistUpdates.mockClear(); + + store.clear(); + + expect(store.size).toBe(0); + expect(onPersistUpdates).not.toHaveBeenCalled(); + }); + + it('detaches FSM listeners so later transitions do not persist', () => { + const { store, onPersistUpdates } = createStore(); + store.put('quote-1:0xabc', createPutValue()); + const entry = store.get('quote-1:0xabc'); + store.clear(); + onPersistUpdates.mockClear(); + + entry?.status.transitionTo(QuoteStatusState.FinalizedSuccess); + + expect(onPersistUpdates).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/packages/bridge-status-controller/src/quote-status-manager/quote-status-entry-store.ts b/packages/bridge-status-controller/src/quote-status-manager/quote-status-entry-store.ts new file mode 100644 index 00000000000..3aaf15dc175 --- /dev/null +++ b/packages/bridge-status-controller/src/quote-status-manager/quote-status-entry-store.ts @@ -0,0 +1,338 @@ +import { QuoteStatusState } from './constants.js'; +import { QuoteStatusStateFsm } from './quote-status-state-fsm.js'; +import { + QuoteStatusEntryStoreOptions, + QuoteStatusPersistEntry, + QuoteStatusRuntimeEntry, +} from './types.js'; + +/** + * In-memory store for quote status update entries. + * + * The store deduplicates entries by key, tracks timestamps used for retries and + * TTL eviction, and persists a cloned snapshot on each mutating operation. + */ +export class QuoteStatusEntryStore { + readonly #items: Map; + + readonly #entryTtlMs: number; + + /** + * Creates a deterministic key for persisted quote status entries. + * + * @param entry - Entry identity fields. + * @param entry.quoteId - Quote identifier. + * @param entry.srcTxHash - Source transaction hash. + * @returns Stable key in `${quoteId}:${srcTxHash}` format. + */ + static hash( + entry: Pick, + ): string { + return `${entry.quoteId}:${entry.srcTxHash}`; + } + + readonly #onPersistUpdates: ( + updates: Record, + ) => void; + + /** + * Creates a quote status entry store. + * + * @param options - Store dependencies and retention configuration. + * @param options.onPersistUpdates - Callback invoked with cloned snapshot updates. + * @param options.entryTtlMs - Entry time-to-live in milliseconds. + * @param options.initial - Optional initial persisted entries used to seed the store. + */ + constructor({ + onPersistUpdates, + entryTtlMs, + initial, + }: QuoteStatusEntryStoreOptions) { + this.#onPersistUpdates = onPersistUpdates; + this.#entryTtlMs = entryTtlMs; + this.#items = new Map( + Object.entries(initial ?? {}).map(([key, entry]) => { + const quoteStatusStateFsm = new QuoteStatusStateFsm(entry.status); + + return [ + key, + // Entries from `initialDeferredUpdates` come from Immer-managed controller + // state, which deep-freezes all nested objects. Cloning each entry here + // ensures the in-memory queue holds mutable objects so that mutations + // (e.g. updating `status` or `lastAttemptAt`) work correctly without + // throwing a "read only property" error. + { ...entry, status: quoteStatusStateFsm }, + ]; + }), + ); + + // Subscribe each seeded FSM after the map (and `#onPersistUpdates`) are set + // so that any later state transition persists the updated snapshot. + for (const entry of this.#items.values()) { + this.#subscribeToStatusUpdates(entry); + } + } + + /** + * Subscribes the entry's FSM so that every state transition persists the + * current snapshot. Kept in one place so seeded and runtime-added entries + * behave identically. + * + * @param entry - Entry whose FSM should trigger persistence on transition. + */ + #subscribeToStatusUpdates(entry: QuoteStatusRuntimeEntry): void { + entry.status.onStateUpdate(() => this.#persistToState()); + } + + /** + * Adds a new entry when the key is not already present. + * + * If an entry already exists, only a missing `txMetaId` is backfilled to avoid + * resetting timestamps and creating duplicate submission updates. + * + * @param key - Unique map key for the entry. + * @param value - Entry payload without internal timestamps. + * @param value.quoteId - Quote identifier. + * @param value.srcTxHash - Source transaction hash. + * @param value.status - Latest quote status. + * @param value.txMetaId - Optional transaction metadata identifier. + * @returns The tracked entry (the existing one when the key is already + * present, otherwise the newly created entry). + */ + put( + key: string, + value: Omit, + ): QuoteStatusRuntimeEntry { + // If an entry for this key is already in the queue + // do not overwrite it. Re-enqueueing would reset state and + // could cause duplicate SUBMITTED events. + const existing = this.#items.get(key); + if (existing) { + if (!existing.txMetaId && value.txMetaId) { + existing.txMetaId = value.txMetaId; + this.#persistToState(); + } + return existing; + } + + const now = Date.now(); + const entry: QuoteStatusRuntimeEntry = { + ...value, + createdAt: now, + lastAttemptAt: now, + }; + this.#items.set(key, entry); + this.#subscribeToStatusUpdates(entry); + this.#persistToState(); + return entry; + } + + /** + * Returns an iterator over all tracked entries. + * + * Stale entries are transitioned to {@link QuoteStatusState.Expired} first (but + * kept in the store), so the iterator reflects their up-to-date terminal state. + * + * @returns An iterator over the currently tracked entries. + */ + values(): IterableIterator { + this.expireStaleEntries(); + return this.#items.values(); + } + + /** + * Returns every tracked entry matching the provided quote identifier. + * + * Stale entries are transitioned to {@link QuoteStatusState.Expired} first so + * callers observe their up-to-date terminal state. + * + * @param quoteId - Quote identifier to match. + * @returns The matching entries (empty when none exist). + */ + getByQuoteId(quoteId: string): QuoteStatusRuntimeEntry[] { + this.expireStaleEntries(); + + const matches: QuoteStatusRuntimeEntry[] = []; + for (const entry of this.#items.values()) { + if (entry.quoteId === quoteId) { + matches.push(entry); + } + } + + return matches; + } + + /** + * Number of entries currently tracked by the store. + * + * Note that this includes terminal entries (`Completed`/`Expired`), which are + * retained rather than evicted; call {@link expireStaleEntries} (or + * {@link values}) first when an up-to-date view of entry states is required. + * + * @returns The count of tracked entries. + */ + get size(): number { + return this.#items.size; + } + + /** + * Looks up an entry by its transaction metadata identifier. + * + * If the matching entry has exceeded its TTL it is transitioned to + * {@link QuoteStatusState.Expired} (but kept) before being returned. + * + * @param txMetaId - Transaction metadata identifier to search for. + * @returns The matching entry, or `null` if none exists. + */ + getByTxMetaId(txMetaId: string): QuoteStatusRuntimeEntry | null { + for (const entry of this.#items.values()) { + if (entry.txMetaId === txMetaId) { + this.expireEntryIfStale(entry); + return entry; + } + } + + return null; + } + + /** + * Looks up every entry sharing the given transaction metadata identifier. + * + * A single 7702/nested batch transaction submits multiple quotes under one + * source tx hash and one `txMetaId`, producing several entries that must all + * be finalized together. Each matching entry has its TTL checked (and is + * transitioned to {@link QuoteStatusState.Expired} if stale) before being + * returned. + * + * @param txMetaId - Transaction metadata identifier to search for. + * @returns The matching entries (empty when none exist). + */ + getAllByTxMetaId(txMetaId: string): QuoteStatusRuntimeEntry[] { + const matches: QuoteStatusRuntimeEntry[] = []; + for (const entry of this.#items.values()) { + if (entry.txMetaId === txMetaId) { + this.expireEntryIfStale(entry); + matches.push(entry); + } + } + + return matches; + } + + /** + * Retrieves an entry by key. + * + * If the entry has exceeded its TTL it is transitioned to + * {@link QuoteStatusState.Expired} (but kept) before being returned. + * + * @param key - Unique map key for the entry. + * @returns The matching entry, or `null` if absent. + */ + get(key: string): QuoteStatusRuntimeEntry | null { + const entry = this.#items.get(key); + if (!entry) { + return null; + } + + this.expireEntryIfStale(entry); + return entry; + } + + /** + * Persists the current snapshot after an in-place mutation of a tracked entry. + * + * Used when fields such as `lastAttemptAt` are mutated directly on an entry + * the caller already holds. No-ops if the entry is no longer tracked (e.g. the + * store was cleared in the meantime), so persistence only reflects live entries. + * + * @param entry - The mutated entry to persist. + */ + update(entry: QuoteStatusRuntimeEntry): void { + if (!this.#items.has(QuoteStatusEntryStore.hash(entry))) { + return; + } + + this.#persistToState(); + } + + /** + * Emits a cloned, serializable snapshot of all tracked entries via the + * `onPersistUpdates` callback. + * + * Each entry's FSM is flattened to its plain `status` value so the snapshot + * contains no class instances and is safe to store in controller state. + */ + #persistToState(): void { + const cloned: Record = {}; + for (const [key, entry] of this.#items) { + cloned[key] = { ...entry, status: entry.status.state }; + } + this.#onPersistUpdates(cloned); + } + + /** + * Transitions every stale entry to {@link QuoteStatusState.Expired}, keeping it + * in the store. + * + * Expired entries are retained so that later interactions with the same quote + * (e.g. a duplicate `reportSubmitted`) can be recognized and rejected. Each + * transition persists the updated snapshot via the entry's FSM subscription; + * already-terminal entries cannot transition and are left untouched. + */ + expireStaleEntries(): void { + for (const entry of this.#items.values()) { + if (this.entryHasExpired(entry)) { + entry.status.transitionTo(QuoteStatusState.Expired); + } + } + } + + /** + * Determines whether an entry has outlived the configured TTL. + * + * Expiry is measured from the entry's `createdAt` timestamp, not its last + * attempt, so an entry's total lifetime is bounded regardless of retries. + * + * @param entry - Entry to check. + * @returns `true` if the entry's age exceeds the TTL. + */ + entryHasExpired(entry: QuoteStatusRuntimeEntry): boolean { + const now = Date.now(); + return now - entry.createdAt > this.#entryTtlMs; + } + + /** + * Transitions an entry to {@link QuoteStatusState.Expired} (keeping it) if it + * has exceeded its TTL. + * + * The transition persists the updated snapshot via the entry's FSM + * subscription. Already-terminal entries cannot transition and are left + * untouched. + * + * @param entry - Entry to check and possibly expire. + * @returns `true` if the entry was stale, otherwise `false`. + */ + expireEntryIfStale(entry: QuoteStatusRuntimeEntry): boolean { + if (this.entryHasExpired(entry)) { + entry.status.transitionTo(QuoteStatusState.Expired); + return true; + } + + return false; + } + + /** + * Removes every entry from the store. + * + * Detaches each entry's FSM listeners before clearing to prevent leaks and + * stale persist callbacks. Does not emit a persistence update; callers are + * expected to use this during teardown. + */ + clear(): void { + for (const entry of this.#items.values()) { + entry.status.removeAllListeners(); + } + + this.#items.clear(); + } +} diff --git a/packages/bridge-status-controller/src/quote-status-manager/quote-status-get-with-retry-outcome.test.ts b/packages/bridge-status-controller/src/quote-status-manager/quote-status-get-with-retry-outcome.test.ts new file mode 100644 index 00000000000..98fce6ba968 --- /dev/null +++ b/packages/bridge-status-controller/src/quote-status-manager/quote-status-get-with-retry-outcome.test.ts @@ -0,0 +1,69 @@ +import { StatusTypes } from '@metamask/bridge-controller'; + +import { QuoteStatusFetchWithRetryOutcomeType } from './constants.js'; +import { QuoteStatusGetError } from './errors.js'; +import { QuoteStatusGetWithRetryOutcome } from './quote-status-get-with-retry-outcome.js'; +import type { QuoteStatusGetResponse } from './types.js'; + +describe('QuoteStatusGetWithRetryOutcome', () => { + it('exposes the outcome type', () => { + const outcome = new QuoteStatusGetWithRetryOutcome( + QuoteStatusFetchWithRetryOutcomeType.Accepted, + ); + + expect(outcome.type).toBe(QuoteStatusFetchWithRetryOutcomeType.Accepted); + }); + + it('leaves the response undefined when none is provided', () => { + const outcome = new QuoteStatusGetWithRetryOutcome( + QuoteStatusFetchWithRetryOutcomeType.RetryableExhausted, + ); + + expect(outcome.response).toBeUndefined(); + }); + + it('leaves the error undefined when none is provided', () => { + const outcome = new QuoteStatusGetWithRetryOutcome( + QuoteStatusFetchWithRetryOutcomeType.RetryableExhausted, + ); + + expect(outcome.error).toBeUndefined(); + }); + + it('preserves the provided response', () => { + const response: QuoteStatusGetResponse = { + submittedTx: { + status: StatusTypes.SUBMITTED, + srcChain: { + chainId: 1, + }, + }, + }; + + const outcome = new QuoteStatusGetWithRetryOutcome( + QuoteStatusFetchWithRetryOutcomeType.Accepted, + response, + ); + + expect(outcome.type).toBe(QuoteStatusFetchWithRetryOutcomeType.Accepted); + expect(outcome.response).toStrictEqual(response); + }); + + it('preserves the provided error', () => { + const error = new QuoteStatusGetError('request error to getQuoteStatus', { + quoteId: 'quote-1', + }); + + const outcome = new QuoteStatusGetWithRetryOutcome( + QuoteStatusFetchWithRetryOutcomeType.NonRetryable, + undefined, + error, + ); + + expect(outcome.type).toBe( + QuoteStatusFetchWithRetryOutcomeType.NonRetryable, + ); + expect(outcome.response).toBeUndefined(); + expect(outcome.error).toBe(error); + }); +}); diff --git a/packages/bridge-status-controller/src/quote-status-manager/quote-status-get-with-retry-outcome.ts b/packages/bridge-status-controller/src/quote-status-manager/quote-status-get-with-retry-outcome.ts new file mode 100644 index 00000000000..cd27bfdc9dd --- /dev/null +++ b/packages/bridge-status-controller/src/quote-status-manager/quote-status-get-with-retry-outcome.ts @@ -0,0 +1,43 @@ +import { QuoteStatusFetchWithRetryOutcomeType } from './constants.js'; +import { QuoteStatusGetError } from './errors.js'; +import { QuoteStatusGetResponse } from './types.js'; + +/** + * Result of a retrying quote status fetch + * ({@link QuoteStatusApiService.getQuoteStatusWithRetry}). + * + * Represents how a fetch attempt resolved so callers can branch on the + * outcome without having to re-interpret raw HTTP responses or thrown errors. + * The discriminating {@link type} indicates whether the fetch was accepted, + * was interrupted, or exhausted its retries. The optional {@link response} + * carries the backend quote status payload when the fetch succeeded. + */ +export class QuoteStatusGetWithRetryOutcome { + /** + * Discriminant describing how the fetch attempt resolved. + */ + readonly type: QuoteStatusFetchWithRetryOutcomeType; + + /** + * Backend quote status payload when the fetch was accepted + * ({@link QuoteStatusFetchWithRetryOutcomeType.Accepted}). + */ + readonly response?: QuoteStatusGetResponse; + + readonly error?: QuoteStatusGetError; + + /** + * @param outcome - The outcome type describing how the fetch resolved. + * @param response - Optional backend quote status payload when accepted. + * @param error - Optional quote status fetch error when the outcome is non-retryable. + */ + constructor( + outcome: QuoteStatusFetchWithRetryOutcomeType, + response?: QuoteStatusGetResponse, + error?: QuoteStatusGetError, + ) { + this.type = outcome; + this.response = response; + this.error = error; + } +} diff --git a/packages/bridge-status-controller/src/quote-status-manager/quote-status-state-fsm.test.ts b/packages/bridge-status-controller/src/quote-status-manager/quote-status-state-fsm.test.ts new file mode 100644 index 00000000000..5083a28d517 --- /dev/null +++ b/packages/bridge-status-controller/src/quote-status-manager/quote-status-state-fsm.test.ts @@ -0,0 +1,150 @@ +import { QuoteStatusState } from './constants.js'; +import { QuoteStatusStateFsm } from './quote-status-state-fsm.js'; + +describe('QuoteStatusStateFsm', () => { + describe('constructor', () => { + it('defaults to the Submitted state', () => { + expect(new QuoteStatusStateFsm().state).toBe(QuoteStatusState.Submitted); + }); + + it('uses the provided initial state', () => { + expect( + new QuoteStatusStateFsm(QuoteStatusState.FinalizedSuccess).state, + ).toBe(QuoteStatusState.FinalizedSuccess); + }); + }); + + describe('canTransitionTo', () => { + it('returns true for an allowed transition', () => { + const fsm = new QuoteStatusStateFsm(QuoteStatusState.Submitted); + + expect(fsm.canTransitionTo(QuoteStatusState.FinalizedSuccess)).toBe(true); + }); + + it('returns false for a disallowed transition', () => { + const fsm = new QuoteStatusStateFsm(QuoteStatusState.Submitted); + + expect(fsm.canTransitionTo(QuoteStatusState.Completed)).toBe(false); + }); + + it('returns false from a terminal state', () => { + const fsm = new QuoteStatusStateFsm(QuoteStatusState.Completed); + + expect(fsm.canTransitionTo(QuoteStatusState.Submitted)).toBe(false); + }); + + it('treats an unknown/legacy state as terminal', () => { + const fsm = new QuoteStatusStateFsm('LEGACY_STATE' as QuoteStatusState); + + expect(fsm.canTransitionTo(QuoteStatusState.Submitted)).toBe(false); + }); + }); + + describe('transitionTo', () => { + it('applies an allowed transition and returns true', () => { + const fsm = new QuoteStatusStateFsm(QuoteStatusState.Submitted); + + const result = fsm.transitionTo(QuoteStatusState.FinalizedSuccess); + + expect(result).toBe(true); + expect(fsm.state).toBe(QuoteStatusState.FinalizedSuccess); + }); + + it('does not apply a disallowed transition and returns false', () => { + const fsm = new QuoteStatusStateFsm(QuoteStatusState.Submitted); + + const result = fsm.transitionTo(QuoteStatusState.Completed); + + expect(result).toBe(false); + expect(fsm.state).toBe(QuoteStatusState.Submitted); + }); + + it('emits a state update event on a successful transition', () => { + const fsm = new QuoteStatusStateFsm(QuoteStatusState.Submitted); + const listener = jest.fn(); + fsm.onStateUpdate(listener); + + fsm.transitionTo(QuoteStatusState.FinalizedSuccess); + + expect(listener).toHaveBeenCalledWith({ + previousState: QuoteStatusState.Submitted, + nextState: QuoteStatusState.FinalizedSuccess, + }); + }); + + it('does not emit an event on a failed transition', () => { + const fsm = new QuoteStatusStateFsm(QuoteStatusState.Submitted); + const listener = jest.fn(); + fsm.onStateUpdate(listener); + + fsm.transitionTo(QuoteStatusState.Completed); + + expect(listener).not.toHaveBeenCalled(); + }); + }); + + describe('onStateUpdate', () => { + it('notifies every subscribed listener', () => { + const fsm = new QuoteStatusStateFsm(QuoteStatusState.Submitted); + const first = jest.fn(); + const second = jest.fn(); + fsm.onStateUpdate(first); + fsm.onStateUpdate(second); + + fsm.transitionTo(QuoteStatusState.FinalizedSuccess); + + expect(first).toHaveBeenCalledTimes(1); + expect(second).toHaveBeenCalledTimes(1); + }); + + it('stops notifying a listener after it unsubscribes', () => { + const fsm = new QuoteStatusStateFsm(QuoteStatusState.Submitted); + const listener = jest.fn(); + const unsubscribe = fsm.onStateUpdate(listener); + + unsubscribe(); + fsm.transitionTo(QuoteStatusState.FinalizedSuccess); + + expect(listener).not.toHaveBeenCalled(); + }); + + it('allows a listener to unsubscribe itself during emission', () => { + const fsm = new QuoteStatusStateFsm(QuoteStatusState.Submitted); + const calls: string[] = []; + const unsubscribe = fsm.onStateUpdate(() => { + calls.push('self'); + unsubscribe(); + }); + const other = jest.fn(() => calls.push('other')); + fsm.onStateUpdate(other); + + fsm.transitionTo(QuoteStatusState.FinalizedSuccess); + + expect(calls).toStrictEqual(['self', 'other']); + expect(other).toHaveBeenCalledTimes(1); + }); + }); + + describe('removeAllListeners', () => { + it('removes every subscribed listener', () => { + const fsm = new QuoteStatusStateFsm(QuoteStatusState.Submitted); + const listener = jest.fn(); + fsm.onStateUpdate(listener); + + fsm.removeAllListeners(); + fsm.transitionTo(QuoteStatusState.FinalizedSuccess); + + expect(listener).not.toHaveBeenCalled(); + }); + }); + + describe('toJson', () => { + it('serializes the current state to a plain object', () => { + const fsm = new QuoteStatusStateFsm(QuoteStatusState.FinalizedFailed); + + expect(fsm.toJson()).toStrictEqual({ + state: QuoteStatusState.FinalizedFailed, + }); + }); + }); +}); diff --git a/packages/bridge-status-controller/src/quote-status-manager/quote-status-state-fsm.ts b/packages/bridge-status-controller/src/quote-status-manager/quote-status-state-fsm.ts new file mode 100644 index 00000000000..936efb62c78 --- /dev/null +++ b/packages/bridge-status-controller/src/quote-status-manager/quote-status-state-fsm.ts @@ -0,0 +1,109 @@ +import { + AllowedQuoteStatusStateTransitions, + QuoteStatusState, +} from './constants.js'; +import type { + QuoteStatusStateUpdateEvent, + QuoteStatusStateUpdateListener, +} from './types.js'; + +/** + * Finite state machine that enforces forward-only quote status transitions. + */ +export class QuoteStatusStateFsm { + #state: QuoteStatusState; + + readonly #listeners = new Set(); + + /** + * Creates a state machine with a default {@link QuoteStatusState.Submitted} + * state. + * + * @param initialState - Optional initial state. + */ + constructor(initialState: QuoteStatusState = QuoteStatusState.Submitted) { + this.#state = initialState; + } + + /** + * Current lifecycle state. + * + * @returns The current quote status lifecycle state. + */ + get state(): QuoteStatusState { + return this.#state; + } + + /** + * Returns whether the current state can transition to the provided next state. + * + * @param nextState - Desired next state. + * @returns `true` if the transition is valid. + */ + canTransitionTo(nextState: QuoteStatusState): boolean { + // Seeded persisted state may contain an unknown/legacy value that is not a + // key in the transition map. Treat any such state as terminal (no allowed + // transitions) instead of throwing on `undefined.includes(...)`. + return ( + AllowedQuoteStatusStateTransitions[this.#state]?.includes(nextState) ?? + false + ); + } + + /** + * Transitions to the provided state if allowed. + * + * Emits a state update event when the transition succeeds. + * + * @param nextState - Desired next state. + * @returns `true` if the transition was applied, otherwise `false`. + */ + transitionTo(nextState: QuoteStatusState): boolean { + if (!this.canTransitionTo(nextState)) { + return false; + } + + const previousState = this.#state; + this.#state = nextState; + this.#emitStateUpdate({ previousState, nextState }); + return true; + } + + /** + * Subscribes to state update events. + * + * @param listener - Callback invoked on each successful state transition. + * @returns Unsubscribe function. + */ + onStateUpdate(listener: QuoteStatusStateUpdateListener): () => void { + this.#listeners.add(listener); + return () => this.#listeners.delete(listener); + } + + /** + * Removes every subscribed state update listener. + */ + removeAllListeners(): void { + this.#listeners.clear(); + } + + #emitStateUpdate(event: QuoteStatusStateUpdateEvent): void { + // Iterate over a snapshot so that a listener subscribing or unsubscribing + // (including unsubscribing itself) during emission does not affect the + // current notification pass. + for (const listener of [...this.#listeners]) { + listener(event); + } + } + + /** + * Serializes the machine to a plain, persistable object. + * + * @returns The current lifecycle state wrapped in a plain object. + */ + toJson(): { state: QuoteStatusState } { + return { + state: this.state, + }; + } +} diff --git a/packages/bridge-status-controller/src/quote-status-manager/quote-status-update-with-retry-outcome.test.ts b/packages/bridge-status-controller/src/quote-status-manager/quote-status-update-with-retry-outcome.test.ts new file mode 100644 index 00000000000..9eff74ae16d --- /dev/null +++ b/packages/bridge-status-controller/src/quote-status-manager/quote-status-update-with-retry-outcome.test.ts @@ -0,0 +1,42 @@ +import { + QuoteStatusUpdateBackendErrorType, + QuoteStatusFetchWithRetryOutcomeType, +} from './constants.js'; +import { QuoteStatusUpdateWithRetryOutcome } from './quote-status-update-with-retry-outcome.js'; +import type { QuoteStatusUpdateResponse } from './types.js'; + +describe('QuoteStatusUpdateWithRetryOutcome', () => { + it('exposes the outcome type', () => { + const outcome = new QuoteStatusUpdateWithRetryOutcome( + QuoteStatusFetchWithRetryOutcomeType.Accepted, + ); + + expect(outcome.type).toBe(QuoteStatusFetchWithRetryOutcomeType.Accepted); + }); + + it('leaves the response undefined when none is provided', () => { + const outcome = new QuoteStatusUpdateWithRetryOutcome( + QuoteStatusFetchWithRetryOutcomeType.RetryableExhausted, + ); + + expect(outcome.response).toBeUndefined(); + }); + + it('preserves the provided response', () => { + const response: QuoteStatusUpdateResponse = { + statusCode: 404, + message: 'quote not found', + type: QuoteStatusUpdateBackendErrorType.QuoteNotFound, + }; + + const outcome = new QuoteStatusUpdateWithRetryOutcome( + QuoteStatusFetchWithRetryOutcomeType.NonRetryable, + response, + ); + + expect(outcome.type).toBe( + QuoteStatusFetchWithRetryOutcomeType.NonRetryable, + ); + expect(outcome.response).toStrictEqual(response); + }); +}); diff --git a/packages/bridge-status-controller/src/quote-status-manager/quote-status-update-with-retry-outcome.ts b/packages/bridge-status-controller/src/quote-status-manager/quote-status-update-with-retry-outcome.ts new file mode 100644 index 00000000000..36634dc671f --- /dev/null +++ b/packages/bridge-status-controller/src/quote-status-manager/quote-status-update-with-retry-outcome.ts @@ -0,0 +1,38 @@ +import { QuoteStatusFetchWithRetryOutcomeType } from './constants.js'; +import { QuoteStatusUpdateResponse } from './types.js'; + +/** + * Result of a retrying quote status update + * ({@link QuoteStatusApiService.updateQuoteStatusWithRetry}). + * + * Represents how an update attempt resolved so callers can branch on the + * outcome without having to re-interpret raw HTTP responses or thrown errors. + * The discriminating {@link type} indicates whether the update was accepted, + * hit a non-retryable error, was interrupted, or exhausted its retries. The + * optional {@link response} carries the backend error payload (when present), + * which callers use to reconcile local state for non-retryable errors. + */ +export class QuoteStatusUpdateWithRetryOutcome { + /** + * Discriminant describing how the update attempt resolved. + */ + readonly type: QuoteStatusFetchWithRetryOutcomeType; + + /** + * Backend error response associated with the outcome, when one was returned + * (typically for {@link QuoteStatusUpdateWithRetryOutcomeType.NonRetryable}). + */ + readonly response?: QuoteStatusUpdateResponse; + + /** + * @param outcome - The outcome type describing how the update resolved. + * @param response - Optional backend error response associated with the outcome. + */ + constructor( + outcome: QuoteStatusFetchWithRetryOutcomeType, + response?: QuoteStatusUpdateResponse, + ) { + this.type = outcome; + this.response = response; + } +} diff --git a/packages/bridge-status-controller/src/quote-status-manager/quotes-status-manager.test.ts b/packages/bridge-status-controller/src/quote-status-manager/quotes-status-manager.test.ts new file mode 100644 index 00000000000..fa8d2b2f039 --- /dev/null +++ b/packages/bridge-status-controller/src/quote-status-manager/quotes-status-manager.test.ts @@ -0,0 +1,1395 @@ +import { + TransactionStatus, + TransactionType, +} from '@metamask/transaction-controller'; +import type { TransactionMeta } from '@metamask/transaction-controller'; + +import { BridgeClientId, BridgeStatusControllerMessenger } from '../types.js'; +import { + QuoteStatusState, + QuoteStatusUpdateBackendErrorType, + QuoteStatusBackendStatus, + QuoteStatusFetchWithRetryOutcomeType, +} from './constants.js'; +import { QuoteStatusApiService } from './quote-status-api-service.js'; +import { QuoteStatusGetWithRetryOutcome } from './quote-status-get-with-retry-outcome.js'; +import { QuoteStatusUpdateWithRetryOutcome } from './quote-status-update-with-retry-outcome.js'; +import { QuoteStatusManager } from './quotes-status-manager.js'; +import type { + QuoteStatusPersistEntry, + QuoteStatusUpdateResponse, +} from './types.js'; + +jest.mock('./quote-status-api-service'); + +const TTL_MS = 60_000; +const UPDATE_INTERVAL_MS = 1000; + +/** + * Creates a manually-resolvable promise. + * + * @returns The promise and its resolver. + */ +function deferred(): { + promise: Promise; + resolve: (value: Value) => void; +} { + let resolve!: (value: Value) => void; + const promise = new Promise((_resolve) => { + resolve = _resolve; + }); + return { promise, resolve }; +} + +/** + * Flushes pending microtasks (and zero-delay timers) under fake timers. + * + * @returns A promise that resolves once queued callbacks have run. + */ +async function flush(): Promise { + await jest.advanceTimersByTimeAsync(0); +} + +/** + * Builds a persisted entry used to seed the manager via `initialData`. + * + * @param overrides - Fields to override on the default entry. + * @returns A serializable persisted entry. + */ +function createPersistEntry( + overrides: Partial = {}, +): QuoteStatusPersistEntry { + const now = Date.now(); + return { + quoteId: 'quote-1', + srcTxHash: '0xabc', + status: QuoteStatusState.Submitted, + createdAt: now, + lastAttemptAt: now, + ...overrides, + }; +} + +/** + * Builds a minimal transaction meta used to seed the messenger's + * `TransactionController:getState` response for `init` reconciliation tests. + * + * @param overrides - Fields to override on the default transaction meta. + * @returns A transaction meta object. + */ +function createTxMeta( + overrides: Partial = {}, +): TransactionMeta { + return { + id: 'tx-1', + status: TransactionStatus.confirmed, + type: TransactionType.bridge, + ...overrides, + } as TransactionMeta; +} + +describe('QuoteStatusUpdateManager', () => { + let mockUpdate: jest.Mock; + + let mockGetQuoteStatusWithRetry: jest.Mock; + + /** + * Builds a manager with stubbed callbacks and the mocked API service. + * + * @param overrides - Partial constructor options to override the defaults. + * @param transactions - Transactions returned by the mocked + * `TransactionController:getState` action, used by `init` reconciliation. + * @returns The manager and its stubbed callbacks. + */ + function createManager( + overrides: Partial< + ConstructorParameters[0] + > = {}, + transactions: TransactionMeta[] = [], + ): { + manager: QuoteStatusManager; + onPersistUpdates: jest.Mock; + onError: jest.Mock; + isEnabled: jest.Mock; + messengerCall: jest.Mock; + } { + const onPersistUpdates = jest.fn(); + const onError = jest.fn(); + const isEnabled = jest.fn().mockReturnValue(true); + const messengerCall = jest.fn((action: string) => { + if (action === 'TransactionController:getState') { + return { transactions }; + } + return undefined; + }); + + const manager = new QuoteStatusManager({ + messenger: { + call: messengerCall, + } as unknown as BridgeStatusControllerMessenger, + clientId: BridgeClientId.EXTENSION, + clientProduct: 'test-product', + apiBaseUrl: 'https://bridge.api.test', + onPersistUpdates, + onError, + isEnabled, + entryTtlMs: TTL_MS, + updateIntervalMs: UPDATE_INTERVAL_MS, + initialData: {}, + ...overrides, + }); + + return { manager, onPersistUpdates, onError, isEnabled, messengerCall }; + } + + beforeEach(() => { + jest.useFakeTimers(); + jest.setSystemTime(new Date('2024-01-01T00:00:00Z')); + mockUpdate = jest + .fn() + .mockResolvedValue( + new QuoteStatusUpdateWithRetryOutcome( + QuoteStatusFetchWithRetryOutcomeType.RetryableExhausted, + ), + ); + mockGetQuoteStatusWithRetry = jest + .fn() + .mockResolvedValue( + new QuoteStatusGetWithRetryOutcome( + QuoteStatusFetchWithRetryOutcomeType.Accepted, + ), + ); + (QuoteStatusApiService as unknown as jest.Mock).mockImplementation(() => ({ + updateQuoteStatusWithRetry: mockUpdate, + getQuoteStatusWithRetry: mockGetQuoteStatusWithRetry, + })); + }); + + afterEach(() => { + jest.clearAllTimers(); + jest.useRealTimers(); + jest.clearAllMocks(); + }); + + describe('init', () => { + describe('processInitial', () => { + it('does not process rehydrated entries before init is called', async () => { + createManager({ + initialData: { 'quote-1:0xabc': createPersistEntry() }, + }); + await flush(); + + expect(mockUpdate).not.toHaveBeenCalled(); + }); + + it('processes each rehydrated entry', async () => { + const { manager } = createManager({ + initialData: { 'quote-1:0xabc': createPersistEntry() }, + }); + + manager.init(); + await flush(); + + expect(mockUpdate).toHaveBeenCalledWith( + expect.objectContaining({ + quoteId: 'quote-1', + srcTxHash: '0xabc', + newStatus: QuoteStatusBackendStatus.Submitted, + }), + expect.anything(), + ); + }); + + it('does not start the retry timer when there are no entries', async () => { + const { manager } = createManager(); + + manager.init(); + await flush(); + mockUpdate.mockClear(); + + await jest.advanceTimersByTimeAsync(UPDATE_INTERVAL_MS * 2); + + expect(mockUpdate).not.toHaveBeenCalled(); + }); + + it('keeps a rehydrated entry already in a terminal state and rejects new submissions', async () => { + const { manager } = createManager({ + initialData: { + 'quote-1:0xabc': createPersistEntry({ + status: QuoteStatusState.Completed, + }), + }, + }); + + manager.init(); + await flush(); + + expect(mockUpdate).not.toHaveBeenCalled(); + + // The retained terminal entry causes a later submission for the same + // quote to be rejected instead of re-sending SUBMITTED. + manager.reportSubmitted('quote-1', '0xabc', 'tx-1'); + await flush(); + + expect(mockUpdate).not.toHaveBeenCalled(); + }); + }); + + describe('reconciliation of missed finalizations', () => { + /** + * Collects the `newStatus` values reported to the backend so far. + * + * @returns The list of reported backend statuses. + */ + function getReportedStatuses(): QuoteStatusBackendStatus[] { + return mockUpdate.mock.calls.map(([payload]) => payload.newStatus); + } + + it('reports finalized success when the source transaction confirmed', async () => { + const { manager, onError } = createManager( + { + initialData: { + 'quote-1:0xabc': createPersistEntry({ txMetaId: 'tx-1' }), + }, + }, + [createTxMeta({ id: 'tx-1', status: TransactionStatus.confirmed })], + ); + + manager.init(); + await flush(); + + expect(onError).not.toHaveBeenCalled(); + expect(getReportedStatuses()).toContain( + QuoteStatusBackendStatus.FinalizedSuccess, + ); + }); + + it('reports finalized failure when the source transaction failed', async () => { + const { manager } = createManager( + { + initialData: { + 'quote-1:0xabc': createPersistEntry({ txMetaId: 'tx-1' }), + }, + }, + [createTxMeta({ id: 'tx-1', status: TransactionStatus.failed })], + ); + + manager.init(); + await flush(); + + expect(getReportedStatuses()).toContain( + QuoteStatusBackendStatus.FinalizedFailed, + ); + }); + + it('reports finalized failure when the source transaction was dropped', async () => { + const { manager } = createManager( + { + initialData: { + 'quote-1:0xabc': createPersistEntry({ txMetaId: 'tx-1' }), + }, + }, + [createTxMeta({ id: 'tx-1', status: TransactionStatus.dropped })], + ); + + manager.init(); + await flush(); + + expect(getReportedStatuses()).toContain( + QuoteStatusBackendStatus.FinalizedFailed, + ); + }); + + it('reconciles swaps tracked as batch transactions via nested swap transactions', async () => { + const { manager } = createManager( + { + initialData: { + 'quote-1:0xabc': createPersistEntry({ txMetaId: 'tx-1' }), + }, + }, + [ + createTxMeta({ + id: 'tx-1', + status: TransactionStatus.confirmed, + type: TransactionType.batch, + nestedTransactions: [{ type: TransactionType.swap }], + } as Partial), + ], + ); + + manager.init(); + await flush(); + + expect(getReportedStatuses()).toContain( + QuoteStatusBackendStatus.FinalizedSuccess, + ); + }); + + it('does not finalize entries that have no txMetaId', async () => { + const { manager } = createManager( + { + initialData: { 'quote-1:0xabc': createPersistEntry() }, + }, + [createTxMeta({ id: 'tx-1', status: TransactionStatus.confirmed })], + ); + + manager.init(); + await flush(); + + expect(getReportedStatuses()).not.toContain( + QuoteStatusBackendStatus.FinalizedSuccess, + ); + }); + + it('does not finalize when the source transaction cannot be found', async () => { + const { manager } = createManager( + { + initialData: { + 'quote-1:0xabc': createPersistEntry({ txMetaId: 'tx-1' }), + }, + }, + [], + ); + + manager.init(); + await flush(); + + expect(getReportedStatuses()).not.toContain( + QuoteStatusBackendStatus.FinalizedSuccess, + ); + }); + + it('does not finalize when the source transaction is not a swap or bridge', async () => { + const { manager } = createManager( + { + initialData: { + 'quote-1:0xabc': createPersistEntry({ txMetaId: 'tx-1' }), + }, + }, + [ + createTxMeta({ + id: 'tx-1', + status: TransactionStatus.confirmed, + type: TransactionType.simpleSend, + }), + ], + ); + + manager.init(); + await flush(); + + expect(getReportedStatuses()).not.toContain( + QuoteStatusBackendStatus.FinalizedSuccess, + ); + }); + + it('ignores a rejected source transaction', async () => { + const { manager, onError } = createManager( + { + initialData: { + 'quote-1:0xabc': createPersistEntry({ txMetaId: 'tx-1' }), + }, + }, + [createTxMeta({ id: 'tx-1', status: TransactionStatus.rejected })], + ); + + manager.init(); + await flush(); + + expect(onError).not.toHaveBeenCalled(); + const reported = getReportedStatuses(); + expect(reported).not.toContain( + QuoteStatusBackendStatus.FinalizedSuccess, + ); + expect(reported).not.toContain( + QuoteStatusBackendStatus.FinalizedFailed, + ); + }); + + it('does not re-finalize entries that are already in a finalized state', async () => { + const { manager, onError } = createManager( + { + initialData: { + 'quote-1:0xabc': createPersistEntry({ + status: QuoteStatusState.FinalizedSuccess, + txMetaId: 'tx-1', + }), + }, + }, + [createTxMeta({ id: 'tx-1', status: TransactionStatus.failed })], + ); + + manager.init(); + await flush(); + + expect(onError).not.toHaveBeenCalled(); + // processInitial re-sends the finalized status, but reconciliation must + // not transition it again based on the on-chain status. + expect(getReportedStatuses()).not.toContain( + QuoteStatusBackendStatus.FinalizedFailed, + ); + }); + }); + }); + + describe('enabled', () => { + it('returns true when the isEnabled predicate returns true', () => { + const { manager } = createManager({ + isEnabled: jest.fn().mockReturnValue(true), + }); + + expect(manager.enabled).toBe(true); + }); + + it('returns false when the isEnabled predicate returns false', () => { + const { manager } = createManager({ + isEnabled: jest.fn().mockReturnValue(false), + }); + + expect(manager.enabled).toBe(false); + }); + + it('returns false when no isEnabled predicate was provided', () => { + const { manager } = createManager({ isEnabled: undefined }); + + expect(manager.enabled).toBe(false); + }); + }); + + describe('getStatus', () => { + it('does nothing when the manager is disabled', async () => { + const { manager } = createManager({ + isEnabled: jest.fn().mockReturnValue(false), + }); + + const result = await manager.getStatus('quote-1'); + + expect(mockGetQuoteStatusWithRetry).not.toHaveBeenCalled(); + expect(result).toBeUndefined(); + }); + + it('delegates to the API service with the default retry options when enabled', async () => { + const { manager } = createManager(); + + await manager.getStatus('quote-1'); + + expect(mockGetQuoteStatusWithRetry).toHaveBeenCalledWith( + { quoteId: 'quote-1' }, + { maxRetries: 0, delayMsBetweenRetries: 1000 }, + ); + }); + + it('passes custom options to the API service', async () => { + const { manager } = createManager(); + + await manager.getStatus('quote-1', { + maxRetries: 3, + delayMsBetweenRetries: 500, + }); + + expect(mockGetQuoteStatusWithRetry).toHaveBeenCalledWith( + { quoteId: 'quote-1' }, + { maxRetries: 3, delayMsBetweenRetries: 500 }, + ); + }); + + it('resolves with the outcome returned by the API service', async () => { + const outcome = new QuoteStatusGetWithRetryOutcome( + QuoteStatusFetchWithRetryOutcomeType.Accepted, + ); + mockGetQuoteStatusWithRetry.mockResolvedValueOnce(outcome); + const { manager } = createManager(); + + const result = await manager.getStatus('quote-1'); + + expect(result).toBe(outcome); + }); + + it('resolves with undefined when the API service rejects', async () => { + mockGetQuoteStatusWithRetry.mockRejectedValueOnce(new Error('boom')); + const { manager } = createManager(); + + const result = await manager.getStatus('quote-1'); + + expect(result).toBeUndefined(); + }); + }); + + describe('reportSubmitted', () => { + it('does nothing when the manager is disabled', async () => { + const { manager, onPersistUpdates } = createManager({ + isEnabled: jest.fn().mockReturnValue(false), + }); + + manager.reportSubmitted('quote-1', '0xabc', 'tx-1'); + await flush(); + + expect(mockUpdate).not.toHaveBeenCalled(); + expect(onPersistUpdates).not.toHaveBeenCalled(); + }); + + it('tracks the quote and reports the submitted status', async () => { + const { manager } = createManager(); + + manager.reportSubmitted('quote-1', '0xabc', 'tx-1'); + await flush(); + + expect(mockUpdate).toHaveBeenCalledWith( + expect.objectContaining({ + quoteId: 'quote-1', + srcTxHash: '0xabc', + newStatus: QuoteStatusBackendStatus.Submitted, + }), + expect.anything(), + ); + }); + + it('stops re-sending the submitted status once it is accepted', async () => { + mockUpdate.mockResolvedValueOnce( + new QuoteStatusUpdateWithRetryOutcome( + QuoteStatusFetchWithRetryOutcomeType.Accepted, + ), + ); + const { manager, onPersistUpdates } = createManager(); + + manager.reportSubmitted('quote-1', '0xabc', 'tx-1'); + await flush(); + onPersistUpdates.mockClear(); + mockUpdate.mockClear(); + + await jest.advanceTimersByTimeAsync(UPDATE_INTERVAL_MS * 2); + + expect(mockUpdate).not.toHaveBeenCalled(); + }); + + it('does not re-send an already-acknowledged submitted status on a repeat report', async () => { + mockUpdate.mockResolvedValueOnce( + new QuoteStatusUpdateWithRetryOutcome( + QuoteStatusFetchWithRetryOutcomeType.Accepted, + ), + ); + const { manager } = createManager(); + + manager.reportSubmitted('quote-1', '0xabc', 'tx-1'); + await flush(); + mockUpdate.mockClear(); + + // A repeat report for the same still-Submitted quote finds the acknowledged + // entry and short-circuits instead of re-sending the accepted status. + manager.reportSubmitted('quote-1', '0xabc', 'tx-1'); + await flush(); + + expect(mockUpdate).not.toHaveBeenCalled(); + }); + + it('keeps the entry tracked after submission is accepted so it can be finalized', async () => { + mockUpdate.mockResolvedValueOnce( + new QuoteStatusUpdateWithRetryOutcome( + QuoteStatusFetchWithRetryOutcomeType.Accepted, + ), + ); + const { manager, onError } = createManager(); + + manager.reportSubmitted('quote-1', '0xabc', 'tx-1'); + await flush(); + mockUpdate.mockClear(); + + // Finalization arrives long after the submission was acknowledged. The + // entry must still be tracked so the finalized status is reported. + manager.reportFinalised('tx-1', true); + await flush(); + + expect(onError).not.toHaveBeenCalled(); + expect(mockUpdate).toHaveBeenCalledWith( + expect.objectContaining({ + newStatus: QuoteStatusBackendStatus.FinalizedSuccess, + }), + expect.anything(), + ); + }); + + it('rejects a new submission for a quote that already finalized', async () => { + mockUpdate.mockResolvedValueOnce( + new QuoteStatusUpdateWithRetryOutcome( + QuoteStatusFetchWithRetryOutcomeType.Accepted, + ), + ); + const { manager } = createManager(); + + // Drive the quote to the terminal Completed state. + manager.reportSubmitted('quote-1', '0xabc', 'tx-1'); + await flush(); + mockUpdate.mockResolvedValueOnce( + new QuoteStatusUpdateWithRetryOutcome( + QuoteStatusFetchWithRetryOutcomeType.Accepted, + ), + ); + manager.reportFinalised('tx-1', true); + await flush(); + mockUpdate.mockClear(); + + // A late/duplicate submission (even with a different source tx hash) is + // dropped instead of re-sending SUBMITTED to an already-finalized quote. + manager.reportSubmitted('quote-1', '0xnew', 'tx-2'); + await flush(); + + expect(mockUpdate).not.toHaveBeenCalled(); + }); + + it('rejects a new submission for a quote whose entry expired', async () => { + const { manager } = createManager({ entryTtlMs: 500 }); + + manager.reportSubmitted('quote-1', '0xabc', 'tx-1'); + await flush(); + + // Move past the TTL so the entry transitions to Expired (but is retained). + jest.setSystemTime(new Date('2024-01-01T01:00:00Z')); + mockUpdate.mockClear(); + + manager.reportSubmitted('quote-1', '0xnew', 'tx-2'); + await flush(); + + expect(mockUpdate).not.toHaveBeenCalled(); + }); + }); + + describe('reportFinalised', () => { + it('does nothing when the manager is disabled', () => { + const { manager, onError } = createManager({ + isEnabled: jest.fn().mockReturnValue(false), + }); + + manager.reportFinalised('tx-1', true); + + expect(onError).not.toHaveBeenCalled(); + expect(mockUpdate).not.toHaveBeenCalled(); + }); + + it('surfaces an error when the entry is not found', () => { + const { manager, onError } = createManager(); + + manager.reportFinalised('tx-missing', true, 'eip155:1', '0xabc'); + + expect(onError).toHaveBeenCalledTimes(1); + expect(onError.mock.calls[0][0].message).toBe( + 'reporting finalization status but entry was not found', + ); + expect(onError.mock.calls[0][0].details).toStrictEqual({ + quoteId: '', + txMetaId: 'tx-missing', + srcChainId: 'eip155:1', + srcTxHash: '0xabc', + }); + }); + + it('transitions to FinalizedSuccess and reports it', async () => { + const { manager } = createManager(); + manager.reportSubmitted('quote-1', '0xabc', 'tx-1'); + await flush(); + mockUpdate.mockClear(); + + manager.reportFinalised('tx-1', true); + await flush(); + + expect(mockUpdate).toHaveBeenCalledWith( + expect.objectContaining({ + newStatus: QuoteStatusBackendStatus.FinalizedSuccess, + }), + expect.anything(), + ); + }); + + it('transitions to FinalizedFailed when the transaction failed', async () => { + const { manager } = createManager(); + manager.reportSubmitted('quote-1', '0xabc', 'tx-1'); + await flush(); + mockUpdate.mockClear(); + + manager.reportFinalised('tx-1', false); + await flush(); + + expect(mockUpdate).toHaveBeenCalledWith( + expect.objectContaining({ + newStatus: QuoteStatusBackendStatus.FinalizedFailed, + }), + expect.anything(), + ); + }); + + it('ignores duplicate finalization when the entry cannot transition to the finalized state', async () => { + const { manager, onError } = createManager(); + manager.reportSubmitted('quote-1', '0xabc', 'tx-1'); + await flush(); + manager.reportFinalised('tx-1', true); + await flush(); + onError.mockClear(); + mockUpdate.mockClear(); + + manager.reportFinalised('tx-1', true); + await flush(); + + expect(onError).not.toHaveBeenCalled(); + expect(mockUpdate).not.toHaveBeenCalled(); + }); + + it('completes and retains the entry once a finalized status is accepted', async () => { + const { manager, onError } = createManager(); + manager.reportSubmitted('quote-1', '0xabc', 'tx-1'); + await flush(); + + mockUpdate.mockResolvedValueOnce( + new QuoteStatusUpdateWithRetryOutcome( + QuoteStatusFetchWithRetryOutcomeType.Accepted, + ), + ); + manager.reportFinalised('tx-1', true); + await flush(); + + // The entry is retained in the terminal Completed state, so a later + // finalization for the same tx is ignored instead of surfacing an error. + onError.mockClear(); + mockUpdate.mockClear(); + manager.reportFinalised('tx-1', true); + await flush(); + + expect(onError).not.toHaveBeenCalled(); + expect(mockUpdate).not.toHaveBeenCalled(); + }); + + describe('batch (7702/nested) finalization', () => { + /** + * Collects the `{ quoteId, newStatus }` pairs reported to the backend. + * + * @returns The reported quote id / status pairs. + */ + function getReportedQuoteStatuses(): { + quoteId: string; + newStatus: QuoteStatusBackendStatus; + }[] { + return mockUpdate.mock.calls.map(([payload]) => ({ + quoteId: payload.quoteId, + newStatus: payload.newStatus, + })); + } + + it('finalizes every quote sharing the batch txMetaId as success', async () => { + const { manager, onError } = createManager(); + // A single 7702/nested batch submits multiple quotes under one txMetaId. + manager.reportSubmitted('quote-1', '0xabc', 'tx-1'); + manager.reportSubmitted('quote-2', '0xdef', 'tx-1'); + await flush(); + mockUpdate.mockClear(); + + manager.reportFinalised('tx-1', true); + await flush(); + + expect(getReportedQuoteStatuses()).toStrictEqual( + expect.arrayContaining([ + { + quoteId: 'quote-1', + newStatus: QuoteStatusBackendStatus.FinalizedSuccess, + }, + { + quoteId: 'quote-2', + newStatus: QuoteStatusBackendStatus.FinalizedSuccess, + }, + ]), + ); + expect(onError).not.toHaveBeenCalled(); + }); + + it('finalizes every quote sharing the batch txMetaId as failure', async () => { + const { manager, onError } = createManager(); + manager.reportSubmitted('quote-1', '0xabc', 'tx-1'); + manager.reportSubmitted('quote-2', '0xdef', 'tx-1'); + await flush(); + mockUpdate.mockClear(); + + manager.reportFinalised('tx-1', false); + await flush(); + + expect(getReportedQuoteStatuses()).toStrictEqual( + expect.arrayContaining([ + { + quoteId: 'quote-1', + newStatus: QuoteStatusBackendStatus.FinalizedFailed, + }, + { + quoteId: 'quote-2', + newStatus: QuoteStatusBackendStatus.FinalizedFailed, + }, + ]), + ); + expect(onError).not.toHaveBeenCalled(); + }); + + it('finalizes only the still-pending quotes and skips terminal siblings', async () => { + const { manager, onError } = createManager({ + initialData: { + 'quote-1:0xabc': createPersistEntry({ + quoteId: 'quote-1', + srcTxHash: '0xabc', + txMetaId: 'tx-1', + }), + 'quote-2:0xdef': createPersistEntry({ + quoteId: 'quote-2', + srcTxHash: '0xdef', + txMetaId: 'tx-1', + status: QuoteStatusState.Completed, + }), + }, + }); + + manager.init(); + await flush(); + mockUpdate.mockClear(); + + manager.reportFinalised('tx-1', true); + await flush(); + + const reported = getReportedQuoteStatuses(); + expect(reported).toContainEqual({ + quoteId: 'quote-1', + newStatus: QuoteStatusBackendStatus.FinalizedSuccess, + }); + // The sibling already in a terminal state cannot transition again, so it + // is skipped rather than re-reported or surfacing an error. + expect(reported).not.toContainEqual({ + quoteId: 'quote-2', + newStatus: QuoteStatusBackendStatus.FinalizedSuccess, + }); + expect(onError).not.toHaveBeenCalled(); + }); + + it('ignores a duplicate batch finalization once every quote is terminal', async () => { + mockUpdate.mockResolvedValue( + new QuoteStatusUpdateWithRetryOutcome( + QuoteStatusFetchWithRetryOutcomeType.Accepted, + ), + ); + const { manager, onError } = createManager(); + manager.reportSubmitted('quote-1', '0xabc', 'tx-1'); + manager.reportSubmitted('quote-2', '0xdef', 'tx-1'); + await flush(); + manager.reportFinalised('tx-1', true); + await flush(); + onError.mockClear(); + mockUpdate.mockClear(); + + // Every entry is now Completed; a repeated batch finalization finds them + // all in a terminal state and no-ops instead of re-reporting or erroring. + manager.reportFinalised('tx-1', true); + await flush(); + + expect(onError).not.toHaveBeenCalled(); + expect(mockUpdate).not.toHaveBeenCalled(); + }); + + it('retains every batch entry as Completed once finalization is accepted', async () => { + mockUpdate.mockResolvedValue( + new QuoteStatusUpdateWithRetryOutcome( + QuoteStatusFetchWithRetryOutcomeType.Accepted, + ), + ); + const { manager, onPersistUpdates } = createManager(); + manager.reportSubmitted('quote-1', '0xabc', 'tx-1'); + manager.reportSubmitted('quote-2', '0xdef', 'tx-1'); + await flush(); + + manager.reportFinalised('tx-1', true); + await flush(); + + const lastSnapshot = onPersistUpdates.mock.calls.at(-1)?.[0]; + expect(lastSnapshot).toMatchObject({ + 'quote-1:0xabc': expect.objectContaining({ + status: QuoteStatusState.Completed, + }), + 'quote-2:0xdef': expect.objectContaining({ + status: QuoteStatusState.Completed, + }), + }); + }); + }); + }); + + describe('destroy', () => { + it('stops the retry timer', async () => { + const { manager } = createManager(); + manager.reportSubmitted('quote-1', '0xabc', 'tx-1'); + await flush(); + mockUpdate.mockClear(); + + manager.destroy(); + await jest.advanceTimersByTimeAsync(UPDATE_INTERVAL_MS * 3); + + expect(mockUpdate).not.toHaveBeenCalled(); + }); + }); + + describe('retry timer', () => { + it('re-processes non-terminal entries on each tick', async () => { + const { manager } = createManager(); + manager.reportSubmitted('quote-1', '0xabc', 'tx-1'); + await flush(); + mockUpdate.mockClear(); + + await jest.advanceTimersByTimeAsync(UPDATE_INTERVAL_MS); + + expect(mockUpdate).toHaveBeenCalledTimes(1); + }); + + it('skips processing while the manager is disabled', async () => { + const isEnabled = jest.fn().mockReturnValue(true); + const { manager } = createManager({ isEnabled }); + manager.reportSubmitted('quote-1', '0xabc', 'tx-1'); + await flush(); + mockUpdate.mockClear(); + isEnabled.mockReturnValue(false); + + await jest.advanceTimersByTimeAsync(UPDATE_INTERVAL_MS); + + expect(mockUpdate).not.toHaveBeenCalled(); + }); + + it('stops the timer once all entries reach a terminal state via TTL', async () => { + const { manager } = createManager({ entryTtlMs: 500 }); + manager.reportSubmitted('quote-1', '0xabc', 'tx-1'); + await flush(); + mockUpdate.mockClear(); + + // First tick (at 1000ms) is past the 500ms TTL: the entry transitions to + // the terminal Expired state (but is retained), so there is no pending work + // left and the timer stops. + await jest.advanceTimersByTimeAsync(UPDATE_INTERVAL_MS); + mockUpdate.mockClear(); + await jest.advanceTimersByTimeAsync(UPDATE_INTERVAL_MS * 2); + + expect(mockUpdate).not.toHaveBeenCalled(); + }); + }); + + describe('processEntry outcomes', () => { + it('re-processes the entry when the request is interrupted', async () => { + mockUpdate + .mockResolvedValueOnce( + new QuoteStatusUpdateWithRetryOutcome( + QuoteStatusFetchWithRetryOutcomeType.Interrupted, + ), + ) + .mockResolvedValueOnce( + new QuoteStatusUpdateWithRetryOutcome( + QuoteStatusFetchWithRetryOutcomeType.Accepted, + ), + ); + const { manager } = createManager(); + + manager.reportSubmitted('quote-1', '0xabc', 'tx-1'); + await flush(); + + expect(mockUpdate).toHaveBeenCalledTimes(2); + }); + + it('persists the attempt timestamp when retries are exhausted', async () => { + const { manager, onPersistUpdates } = createManager(); + + manager.reportSubmitted('quote-1', '0xabc', 'tx-1'); + await flush(); + + expect(onPersistUpdates).toHaveBeenCalled(); + }); + + it('persists the attempt timestamp when the request rejects', async () => { + mockUpdate.mockRejectedValueOnce(new Error('boom')); + const { manager, onPersistUpdates } = createManager(); + onPersistUpdates.mockClear(); + + manager.reportSubmitted('quote-1', '0xabc', 'tx-1'); + await flush(); + + expect(onPersistUpdates).toHaveBeenCalled(); + }); + + it('does nothing when an accepted entry was evicted mid-flight', async () => { + const accepted = deferred(); + mockUpdate.mockReturnValueOnce(accepted.promise); + const { manager, onError } = createManager(); + + manager.reportSubmitted('quote-1', '0xabc', 'tx-1'); + await flush(); + manager.destroy(); + mockUpdate.mockClear(); + + accepted.resolve( + new QuoteStatusUpdateWithRetryOutcome( + QuoteStatusFetchWithRetryOutcomeType.Accepted, + ), + ); + await flush(); + + expect(mockUpdate).not.toHaveBeenCalled(); + expect(onError).not.toHaveBeenCalled(); + }); + + it('reprocesses with the newer status when the status advanced mid-flight', async () => { + const submittedAttempt = deferred(); + mockUpdate.mockReturnValueOnce(submittedAttempt.promise); + const { manager } = createManager(); + + manager.reportSubmitted('quote-1', '0xabc', 'tx-1'); + await flush(); + + // The second attempt (for the finalized status) stays pending. + mockUpdate.mockReturnValueOnce( + deferred().promise, + ); + manager.reportFinalised('tx-1', true); + await flush(); + + // Resolve the original Submitted attempt as accepted; the entry now holds + // a newer (FinalizedSuccess) status, so it must be reprocessed. + submittedAttempt.resolve( + new QuoteStatusUpdateWithRetryOutcome( + QuoteStatusFetchWithRetryOutcomeType.Accepted, + ), + ); + await flush(); + + const reportedStatuses = mockUpdate.mock.calls.map( + ([payload]) => payload.newStatus, + ); + expect(reportedStatuses).toContain( + QuoteStatusBackendStatus.FinalizedSuccess, + ); + }); + + it('passes a retry predicate that stops a stale in-flight retry', async () => { + let submittedShouldProceed: (() => boolean) | undefined; + mockUpdate.mockImplementationOnce( + (_data: unknown, options: { retry?: () => boolean }) => { + submittedShouldProceed = options.retry; + return Promise.resolve( + new QuoteStatusUpdateWithRetryOutcome( + QuoteStatusFetchWithRetryOutcomeType.RetryableExhausted, + ), + ); + }, + ); + const { manager } = createManager(); + + manager.reportSubmitted('quote-1', '0xabc', 'tx-1'); + await flush(); + + // While the entry is still Submitted and unacknowledged the retry proceeds. + expect(submittedShouldProceed?.()).toBe(true); + + // Once finalization advances the entry, the SUBMITTED predicate reports + // that the retry should stop (its status is no longer the one to report). + manager.reportFinalised('tx-1', true); + await flush(); + + expect(submittedShouldProceed?.()).toBe(false); + }); + + it('retains the completed entry in the persisted snapshot', async () => { + mockUpdate.mockResolvedValueOnce( + new QuoteStatusUpdateWithRetryOutcome( + QuoteStatusFetchWithRetryOutcomeType.Accepted, + ), + ); + const { manager, onPersistUpdates } = createManager(); + + manager.reportSubmitted('quote-1', '0xabc', 'tx-1'); + await flush(); + mockUpdate.mockResolvedValueOnce( + new QuoteStatusUpdateWithRetryOutcome( + QuoteStatusFetchWithRetryOutcomeType.Accepted, + ), + ); + manager.reportFinalised('tx-1', true); + await flush(); + + const lastSnapshot = onPersistUpdates.mock.calls.at(-1)?.[0]; + expect(lastSnapshot).toMatchObject({ + 'quote-1:0xabc': expect.objectContaining({ + status: QuoteStatusState.Completed, + }), + }); + }); + + it('does not call getStatus after a FinalizedSuccess update is accepted', async () => { + mockUpdate + .mockResolvedValueOnce( + new QuoteStatusUpdateWithRetryOutcome( + QuoteStatusFetchWithRetryOutcomeType.Accepted, + ), + ) + .mockResolvedValueOnce( + new QuoteStatusUpdateWithRetryOutcome( + QuoteStatusFetchWithRetryOutcomeType.Accepted, + ), + ); + const { manager } = createManager(); + + manager.reportSubmitted('quote-1', '0xabc', 'tx-1'); + await flush(); + manager.reportFinalised('tx-1', true); + await flush(); + + expect(mockGetQuoteStatusWithRetry).not.toHaveBeenCalled(); + }); + + it('does not call getStatus after a FinalizedFailed update is accepted', async () => { + mockUpdate + .mockResolvedValueOnce( + new QuoteStatusUpdateWithRetryOutcome( + QuoteStatusFetchWithRetryOutcomeType.Accepted, + ), + ) + .mockResolvedValueOnce( + new QuoteStatusUpdateWithRetryOutcome( + QuoteStatusFetchWithRetryOutcomeType.Accepted, + ), + ); + const { manager } = createManager(); + + manager.reportSubmitted('quote-1', '0xabc', 'tx-1'); + await flush(); + manager.reportFinalised('tx-1', false); + await flush(); + + expect(mockGetQuoteStatusWithRetry).not.toHaveBeenCalled(); + }); + + it('does not call getStatus when a non-finalized update is accepted', async () => { + mockUpdate.mockResolvedValueOnce( + new QuoteStatusUpdateWithRetryOutcome( + QuoteStatusFetchWithRetryOutcomeType.Accepted, + ), + ); + const { manager } = createManager(); + + manager.reportSubmitted('quote-1', '0xabc', 'tx-1'); + await flush(); + + expect(mockGetQuoteStatusWithRetry).not.toHaveBeenCalled(); + }); + }); + + describe('handleNonRetryableUpdateStatusError', () => { + /** + * Resolves the next update attempt with a non-retryable outcome. + * + * @param response - The backend error response to attach. + */ + function resolveNonRetryableOnce( + response: QuoteStatusUpdateResponse, + ): void { + mockUpdate.mockResolvedValueOnce( + new QuoteStatusUpdateWithRetryOutcome( + QuoteStatusFetchWithRetryOutcomeType.NonRetryable, + response, + ), + ); + } + + it('drops the entry when the backend is already finalized', async () => { + resolveNonRetryableOnce({ + statusCode: 400, + message: 'invalid transition', + type: QuoteStatusUpdateBackendErrorType.InvalidStatusTransaction, + currentStatus: QuoteStatusBackendStatus.FinalizedSuccess, + newStatus: QuoteStatusBackendStatus.Submitted, + } as QuoteStatusUpdateResponse); + const { manager, onError } = createManager(); + + manager.reportSubmitted('quote-1', '0xabc', 'tx-1'); + await flush(); + mockUpdate.mockClear(); + + await jest.advanceTimersByTimeAsync(UPDATE_INTERVAL_MS * 2); + + expect(onError).not.toHaveBeenCalled(); + expect(mockUpdate).not.toHaveBeenCalled(); + }); + + it('reconciles local state to the backend status on a mismatch', async () => { + resolveNonRetryableOnce({ + statusCode: 400, + message: 'mismatch', + type: QuoteStatusUpdateBackendErrorType.QuoteStatusOnChainMismatch, + currentStatus: QuoteStatusBackendStatus.FinalizedSuccess, + newStatus: QuoteStatusBackendStatus.Submitted, + } as QuoteStatusUpdateResponse); + const { manager } = createManager(); + + manager.reportSubmitted('quote-1', '0xabc', 'tx-1'); + await flush(); + + const reportedStatuses = mockUpdate.mock.calls.map( + ([payload]) => payload.newStatus, + ); + expect(reportedStatuses).toContain( + QuoteStatusBackendStatus.FinalizedSuccess, + ); + }); + + it('surfaces an error but completes (retains) when the local finalized status differs from the backend', async () => { + const { manager, onError, onPersistUpdates } = createManager(); + manager.reportSubmitted('quote-1', '0xabc', 'tx-1'); + await flush(); + manager.reportFinalised('tx-1', false); + await flush(); + + resolveNonRetryableOnce({ + statusCode: 400, + message: 'mismatch', + type: QuoteStatusUpdateBackendErrorType.QuoteStatusOnChainMismatch, + currentStatus: QuoteStatusBackendStatus.FinalizedSuccess, + newStatus: QuoteStatusBackendStatus.FinalizedFailed, + } as QuoteStatusUpdateResponse); + onError.mockClear(); + + await jest.advanceTimersByTimeAsync(UPDATE_INTERVAL_MS); + await flush(); + + expect(onError).toHaveBeenCalledTimes(1); + expect(onError.mock.calls[0][0].message).toContain( + 'cannot transition from "FinalizedFailed" to "FinalizedSuccess"', + ); + // The backend is terminal, so the entry converges to Completed (kept) + // rather than being abandoned to Expired. + const lastSnapshot = onPersistUpdates.mock.calls.at(-1)?.[0]; + expect(lastSnapshot).toMatchObject({ + 'quote-1:0xabc': expect.objectContaining({ + status: QuoteStatusState.Completed, + }), + }); + }); + + it('keeps retrying finalized status when a racing mismatch reports the same finalized status', async () => { + // Reproduces the EVM 7702 race: `SUBMITTED` and `FINALIZED_SUCCESS` are + // reported back-to-back, so the in-flight `SUBMITTED` request can return a + // mismatch carrying the already-finalized status while the entry is locally + // FinalizedSuccess. The entry must stay FinalizedSuccess so retries for the + // finalized update can continue. + const submittedAttempt = deferred(); + mockUpdate.mockReturnValueOnce(submittedAttempt.promise); + const { manager, onError, onPersistUpdates } = createManager(); + + manager.reportSubmitted('quote-1', '0xabc', 'tx-1'); + await flush(); + + // Finalization advances the entry to FinalizedSuccess while the SUBMITTED + // request is still in flight. Keep its FINALIZED_SUCCESS attempt pending. + mockUpdate.mockReturnValueOnce( + deferred().promise, + ); + manager.reportFinalised('tx-1', true); + await flush(); + onError.mockClear(); + + // The racing SUBMITTED request resolves with a mismatch reporting the + // backend is already FinalizedSuccess. + submittedAttempt.resolve( + new QuoteStatusUpdateWithRetryOutcome( + QuoteStatusFetchWithRetryOutcomeType.NonRetryable, + { + statusCode: 400, + message: 'mismatch', + type: QuoteStatusUpdateBackendErrorType.QuoteStatusOnChainMismatch, + currentStatus: QuoteStatusBackendStatus.FinalizedSuccess, + newStatus: QuoteStatusBackendStatus.Submitted, + } as QuoteStatusUpdateResponse, + ), + ); + await flush(); + + expect(onError).not.toHaveBeenCalled(); + const lastSnapshot = onPersistUpdates.mock.calls.at(-1)?.[0]; + expect(lastSnapshot).toMatchObject({ + 'quote-1:0xabc': expect.objectContaining({ + status: QuoteStatusState.FinalizedSuccess, + }), + }); + }); + + it('evicts and surfaces an error for an unreconcilable non-retryable error', async () => { + resolveNonRetryableOnce({ + statusCode: 404, + message: 'quote not found', + type: QuoteStatusUpdateBackendErrorType.QuoteNotFound, + } as QuoteStatusUpdateResponse); + const { manager, onError } = createManager(); + + manager.reportSubmitted('quote-1', '0xabc', 'tx-1', 'eip155:1'); + await flush(); + + expect(onError).toHaveBeenCalledTimes(1); + expect(onError.mock.calls[0][0].message).toContain( + 'abandoning entry due to non-retryable error', + ); + expect(onError.mock.calls[0][0].details).toStrictEqual({ + quoteId: 'quote-1', + errorType: QuoteStatusUpdateBackendErrorType.QuoteNotFound, + txMetaId: 'tx-1', + srcTxHash: '0xabc', + srcChainId: 'eip155:1', + }); + }); + + it('abandons the entry when a mismatch reports a non-finalized current status', async () => { + resolveNonRetryableOnce({ + statusCode: 400, + message: 'mismatch', + type: QuoteStatusUpdateBackendErrorType.QuoteStatusOnChainMismatch, + currentStatus: QuoteStatusBackendStatus.Submitted, + newStatus: QuoteStatusBackendStatus.Submitted, + } as QuoteStatusUpdateResponse); + const { manager, onError } = createManager(); + + manager.reportSubmitted('quote-1', '0xabc', 'tx-1'); + await flush(); + + expect(onError).toHaveBeenCalledTimes(1); + expect(onError.mock.calls[0][0].message).toContain( + 'abandoning entry due to non-retryable error', + ); + }); + + it('drops the entry when an invalid transition reports a failed backend status', async () => { + resolveNonRetryableOnce({ + statusCode: 400, + message: 'invalid transition', + type: QuoteStatusUpdateBackendErrorType.InvalidStatusTransaction, + currentStatus: QuoteStatusBackendStatus.FinalizedFailed, + newStatus: QuoteStatusBackendStatus.Submitted, + } as QuoteStatusUpdateResponse); + const { manager, onError } = createManager(); + + manager.reportSubmitted('quote-1', '0xabc', 'tx-1'); + await flush(); + mockUpdate.mockClear(); + + await jest.advanceTimersByTimeAsync(UPDATE_INTERVAL_MS * 2); + + expect(onError).not.toHaveBeenCalled(); + expect(mockUpdate).not.toHaveBeenCalled(); + }); + }); + + describe('edge cases', () => { + it('ignores an unrecognized retry outcome type', async () => { + mockUpdate.mockResolvedValueOnce( + new QuoteStatusUpdateWithRetryOutcome( + 'unknown' as QuoteStatusFetchWithRetryOutcomeType, + ), + ); + const { manager, onError } = createManager(); + + manager.reportSubmitted('quote-1', '0xabc', 'tx-1'); + await flush(); + + expect(onError).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/packages/bridge-status-controller/src/quote-status-manager/quotes-status-manager.ts b/packages/bridge-status-controller/src/quote-status-manager/quotes-status-manager.ts new file mode 100644 index 00000000000..a3451005ab7 --- /dev/null +++ b/packages/bridge-status-controller/src/quote-status-manager/quotes-status-manager.ts @@ -0,0 +1,764 @@ +import { TransactionStatus } from '@metamask/transaction-controller'; + +import { BridgeClientId, BridgeStatusControllerMessenger } from '../types.js'; +import { + getTransactionMetaById, + hasNestedSwapTransactions, + isCrossChainTx, +} from '../utils/transaction.js'; +import { + QuoteStatusState, + QuoteStatusStateToBackendStatus, + QuoteStatusUpdateBackendErrorType, + QuoteStatusBackendStatus, + QuoteStatusFetchWithRetryOutcomeType, +} from './constants.js'; +import { QuoteStatusUpdateError } from './errors.js'; +import { QuoteStatusApiService } from './quote-status-api-service.js'; +import { QuoteStatusEntryStore } from './quote-status-entry-store.js'; +import { QuoteStatusGetWithRetryOutcome } from './quote-status-get-with-retry-outcome.js'; +import { QuoteStatusStateFsm } from './quote-status-state-fsm.js'; +import { QuoteStatusUpdateWithRetryOutcome } from './quote-status-update-with-retry-outcome.js'; +import { QuoteStatusPersistEntry, QuoteStatusRuntimeEntry } from './types.js'; + +/** + * Tracks bridge/swap quotes through their lifecycle and keeps the backend in + * sync with the latest known status of each quote. + * + * Quotes are reported via {@link reportSubmitted} and {@link reportFinalised}, + * stored as runtime entries, and pushed to the backend. Updates that fail in a + * retryable way are retried on a periodic timer until each entry reaches a + * terminal state, at which point it is evicted and the timer stops once the + * store is empty. + */ +export class QuoteStatusManager { + readonly #messenger: BridgeStatusControllerMessenger; + + readonly #quoteStatusApiService: QuoteStatusApiService; + + readonly #quoteStatusEntryStore: QuoteStatusEntryStore; + + readonly #isEnabled: (() => boolean) | undefined; + + readonly #onError: ((error: QuoteStatusUpdateError) => void) | undefined; + + readonly #updateIntervalMs: number; + + /** + * Handle for the periodic retry timer. While running, every + * {@link QUOTE_STATUS_UPDATE_RETRY_INTERVAL_MS} all entries that have not yet + * reached a terminal state (Completed/Expired) are re-processed. It is started + * lazily when there is work to do and stopped once the store is empty. + */ + #retryIntervalId: ReturnType | null = null; + + /** + * Creates a new manager and immediately processes any persisted entries. + * + * @param options - Constructor options. + * @param options.messenger - Messenger used to communicate with the backend + * API service. + * @param options.clientId - Identifier of the client making the requests. + * @param options.clientProduct - Name of the client product making the + * requests. + * @param options.clientVersion - Optional version of the client product. + * @param options.apiBaseUrl - Base URL of the quote status backend API. + * @param options.onPersistUpdates - Callback invoked to persist entry updates. + * @param options.onError - Optional callback invoked when a non-recoverable + * error occurs. + * @param options.isEnabled - Optional predicate gating whether the manager + * performs any work. + * @param options.entryTtlMs - Time-to-live, in milliseconds, after which a + * tracked entry is evicted. + * @param options.updateIntervalMs - How often the manager re-processes entries that + * have not yet reached a terminal state + * @param options.initialData - Persisted entries to rehydrate on startup. + */ + constructor({ + messenger, + clientId, + clientProduct, + clientVersion, + apiBaseUrl, + onError, + isEnabled, + onPersistUpdates, + entryTtlMs, + updateIntervalMs, + initialData, + }: { + messenger: BridgeStatusControllerMessenger; + clientId: BridgeClientId; + clientProduct: string; + clientVersion?: string; + apiBaseUrl: string; + onPersistUpdates: ( + updates: Record, + ) => void; + onError?: (error: QuoteStatusUpdateError) => void; + isEnabled?: () => boolean; + entryTtlMs: number; + updateIntervalMs: number; + initialData: Record; + }) { + this.#isEnabled = isEnabled; + this.#onError = onError; + this.#updateIntervalMs = updateIntervalMs; + this.#messenger = messenger; + + this.#quoteStatusApiService = new QuoteStatusApiService({ + messenger, + clientId, + clientProduct, + clientVersion, + apiBaseUrl, + onError, + }); + + this.#quoteStatusEntryStore = new QuoteStatusEntryStore({ + onPersistUpdates, + entryTtlMs, + initial: initialData, + }); + } + + /** + * Whether quote-status tracking and backend sync are currently active. + * + * Reflects the latest value returned by the optional `isEnabled` predicate + * supplied at construction time. When no predicate was provided, this is + * always `false` and all public methods that gate on enablement no-op. + * + * @returns `true` when the `isEnabled` predicate returns a truthy value. + */ + get enabled(): boolean { + return Boolean(this.#isEnabled?.()); + } + + /** + * Reports that a previously submitted quote has finalized on-chain. + * + * Looks up the tracked entry by its transaction metadata id, transitions it to + * the appropriate terminal state, and processes the update. No-ops when the + * manager is disabled, and surfaces an error when the entry is missing or + * cannot transition to the finalized state. + * + * A single 7702/nested batch transaction submits multiple quotes under one + * `txMetaId`, so every entry sharing that id is finalized together. + * + * @param txMetaId - Transaction metadata id of the finalized quote(s). + * @param success - Whether the transaction finalized successfully. + * @param srcChainId - Optional source-chain id, forwarded to error reporting + * to aid debugging when no matching entry is found. + * @param srcTxHash - Optional source-chain transaction hash, forwarded to + * error reporting to aid debugging when no matching entry is found. + */ + reportFinalised( + txMetaId: string, + success: boolean, + srcChainId?: string | number, + srcTxHash?: string, + ): void { + if (!this.#isEnabled?.()) { + return; + } + + const entries = this.#quoteStatusEntryStore.getAllByTxMetaId(txMetaId); + + if (entries.length === 0) { + this.#onError?.( + new QuoteStatusUpdateError( + 'reporting finalization status but entry was not found', + { quoteId: '', txMetaId, srcChainId, srcTxHash }, + ), + ); + return; + } + + const nextState = success + ? QuoteStatusState.FinalizedSuccess + : QuoteStatusState.FinalizedFailed; + + let hasEntryToProcess = false; + + for (const entry of entries) { + if (!entry.status.canTransitionTo(nextState)) { + // This is expected, there are race conditions where + // reportFinalized can be called twice. If the second + // call fails due to the first completed sucesfully + // backend will report that we cannot transition outside + // a final state, which is correct and we can safely skip + // this entry. + continue; + } + + entry.status.transitionTo(nextState); + hasEntryToProcess = true; + } + + if (!hasEntryToProcess) { + return; + } + + this.#ensureRetryTimerRunning(); + + for (const entry of entries) { + if (entry.status.state === nextState) { + this.#processEntry(entry); + } + } + } + + /** + * Reports that a quote has been submitted on-chain and begins tracking it. + * + * Creates a new entry in the `Submitted` state, starts the retry timer, and + * processes the initial status update. No-ops when the manager is disabled, + * and surfaces an error if the entry cannot be retrieved after being stored. + * + * @param quoteId - Identifier of the submitted quote. + * @param srcTxHash - Hash of the source-chain transaction for the quote. + * @param txMetaId - Optional transaction metadata id used to correlate + * finalization reports. + * @param srcChainId - Optional source-chain id of the quote, retained on the + * entry to enrich error reporting. + */ + reportSubmitted( + quoteId: string, + srcTxHash: string, + txMetaId?: string, + srcChainId?: string | number, + ): void { + if (!this.#isEnabled?.()) { + return; + } + + // Once a quote has advanced past `Submitted` (finalized or terminal), it is + // done: reporting `SUBMITTED` again would be rejected by the backend as an + // invalid transition. Retained terminal entries let us recognize and drop + // these late/duplicate submissions instead of looping on a 400. + const isQuoteAlreadyTracked = this.#quoteStatusEntryStore + .getByQuoteId(quoteId) + .some((entry) => entry.status.state !== QuoteStatusState.Submitted); + if (isQuoteAlreadyTracked) { + return; + } + + const entryKey = QuoteStatusEntryStore.hash({ + quoteId, + srcTxHash, + }); + + const entry = this.#quoteStatusEntryStore.put(entryKey, { + quoteId, + srcTxHash, + txMetaId, + srcChainId, + status: new QuoteStatusStateFsm(QuoteStatusState.Submitted), + }); + + this.#ensureRetryTimerRunning(); + this.#processEntry(entry); + } + + /** + * Tears down the manager by stopping the retry timer and clearing all tracked + * entries. + */ + destroy(): void { + this.#stopRetryTimer(); + this.#quoteStatusEntryStore.clear(); + } + + /** + * Reconciles quote-status entries whose finalization was missed while the + * client was closed. Called once during initialization. + * + * Swap/bridge source transactions report their final status through the + * `TransactionController:transactionStatusUpdated` subscription + * ({@link #onTransactionConfirmed}/{@link #onTransactionFailed}). When a source + * transaction reaches a terminal state while the client is not running, that + * event is never re-emitted on the next startup, so the persisted entry would + * remain `Submitted` until it expires via TTL. This replays the missed terminal + * event for both swaps and bridges: a confirmed source transaction is reported + * as a finalized success and a failed/dropped one as a finalized failure. + * + * `rejected` is ignored because the transaction was never broadcast. + */ + init(): void { + this.#processInitial(); + + for (const entry of this.#quoteStatusEntryStore.values()) { + // Only entries still awaiting finalization need catching up. Entries + // already in a finalized state are re-sent by `processInitial()`. + if ( + entry.status.state !== QuoteStatusState.Submitted || + !entry.txMetaId + ) { + continue; + } + + const txMeta = getTransactionMetaById(this.#messenger, entry.txMetaId); + if (!txMeta) { + continue; + } + + // Reconcile swaps and bridges alike: a terminal source transaction + // finalizes the quote status. `hasNestedSwapTransactions` also covers + // batch/7702 swaps whose type may still read as `batch` rather than `swap`. + const isCrossChainTrade = + (txMeta.type !== undefined && isCrossChainTx(txMeta.type)) || + hasNestedSwapTransactions(txMeta); + if (!isCrossChainTrade) { + continue; + } + + if (txMeta.status === TransactionStatus.confirmed) { + this.reportFinalised( + entry.txMetaId, + true, + entry.srcChainId, + entry.srcTxHash, + ); + } else if ( + txMeta.status === TransactionStatus.failed || + txMeta.status === TransactionStatus.dropped + ) { + this.reportFinalised( + entry.txMetaId, + false, + entry.srcChainId, + entry.srcTxHash, + ); + } + } + } + + /** + * Fetches the current quote status from the backend with automatic retries. + * + * Unlike {@link reportSubmitted} and {@link reportFinalised}, this is a + * read-only query and does not mutate tracked entries. Returns `null` when + * the manager is disabled ({@link enabled} is `false`). + * + * @param quoteId - Identifier of the quote whose status should be fetched. + * @param options - Retry configuration. + * @param options.maxRetries - Maximum number of retries after the initial attempt. + * @param options.delayMsBetweenRetries - Delay in milliseconds between attempts. + * @returns The quote status outcome, or `undefined` when the manager is disabled. + */ + async getStatus( + quoteId: string, + options: { + maxRetries?: number; + delayMsBetweenRetries?: number; + } = { + maxRetries: 0, + delayMsBetweenRetries: 1000, + }, + ): Promise { + if (!this.#isEnabled?.()) { + return undefined; + } + + const response = this.#quoteStatusApiService + .getQuoteStatusWithRetry( + { + quoteId, + }, + { + maxRetries: options.maxRetries ?? 0, + delayMsBetweenRetries: options.delayMsBetweenRetries ?? 1000, + }, + ) + // Errors already reported by #onError handlers of `getQuoteStatusWithRetry()` + .catch(() => undefined); + + return response; + } + + /** + * Processes every entry rehydrated from persisted data on startup and starts + * the retry timer if any entries still require further updates. + */ + #processInitial(): void { + for (const entry of this.#quoteStatusEntryStore.values()) { + this.#processEntry(entry); + } + + // Terminal entries (Completed/Expired) are retained, so the store is never + // empty. Only start the retry timer when there is an entry whose status + // still needs to be reported; otherwise it would tick forever doing nothing. + if (this.#hasPendingUpdates()) { + this.#ensureRetryTimerRunning(); + } + } + + /** + * Starts the periodic retry timer if it is not already running. + * + * The timer re-processes every non-terminal entry on each tick. It is + * idempotent so callers can invoke it freely whenever new work is enqueued. + */ + #ensureRetryTimerRunning(): void { + if (this.#retryIntervalId !== null) { + return; + } + + this.#retryIntervalId = setInterval( + () => this.#processRetries(), + this.#updateIntervalMs, + ); + } + + /** + * Stops the periodic retry timer if it is running. + */ + #stopRetryTimer(): void { + if (this.#retryIntervalId !== null) { + clearInterval(this.#retryIntervalId); + this.#retryIntervalId = null; + } + } + + /** + * Retry tick: re-processes every entry. Reading `values()` first transitions + * TTL-expired entries to `Expired` (keeping them), and `#processEntry` no-ops + * for terminal/acknowledged entries. When no entry has a status left to report + * the timer stops until new work is enqueued. + */ + #processRetries(): void { + if (!this.#isEnabled?.()) { + return; + } + + // Snapshot first: `#processEntry` can mutate the store (e.g. transitioning an + // accepted entry to a terminal state), so iterating a live iterator would be + // unsafe. + const entries = [...this.#quoteStatusEntryStore.values()]; + + if (!this.#hasPendingUpdates()) { + this.#stopRetryTimer(); + return; + } + + for (const entry of entries) { + this.#processEntry(entry); + } + } + + /** + * Returns whether any tracked entry still has a status that needs to be + * reported to the backend. + * + * Terminal entries (`Completed`/`Expired`) and entries whose current status + * has already been acknowledged are excluded, so this is the signal used to + * decide whether the retry timer has any work left to do. + * + * @returns `true` when at least one entry has an unreported status. + */ + #hasPendingUpdates(): boolean { + for (const entry of this.#quoteStatusEntryStore.values()) { + const { state } = entry.status; + const isTerminal = + state === QuoteStatusState.Completed || + state === QuoteStatusState.Expired; + if (!isTerminal && entry.acknowledgedState !== state) { + return true; + } + } + + return false; + } + + /** + * Stops the retry timer when there are no entries left whose status needs + * reporting. Since terminal entries are retained, the store is never empty, so + * the timer's idle condition is "no pending updates" rather than "no entries". + */ + #stopRetryTimerIfIdle(): void { + if (!this.#hasPendingUpdates()) { + this.#stopRetryTimer(); + } + } + + /** + * Pushes a single entry's current status to the backend and reconciles the + * local state with the request outcome. + * + * Terminal entries are kept but no longer reported. Accepted finalized updates + * advance the entry to `Completed` (kept so duplicate reports are rejected); + * accepted non-final updates (e.g. `Submitted`) are kept tracked so a later + * {@link reportFinalised} can find them, while being flagged so the retry loop + * stops re-sending the acknowledged status. Updates whose status advanced + * mid-flight are reprocessed, retryable failures are left for the next retry + * tick, and non-retryable failures are delegated to + * {@link #handleNonRetryableUpdateStatusError}. + * + * @param entry - The runtime entry to process. + */ + #processEntry(entry: QuoteStatusRuntimeEntry): void { + // The backend already accepted the entry's current status, so there is + // nothing new to report. The entry is kept tracked (e.g. a `Submitted` + // quote awaiting finalization) until its status advances past the + // acknowledged one, at which point it is reprocessed. + if (entry.acknowledgedState === entry.status.state) { + return; + } + + const sentStatus = entry.status.state; + const sentStatusBackend = QuoteStatusStateToBackendStatus[sentStatus]; + + // Terminal states (`Completed`/`Expired`) have no backend status to report. + // The entry is retained so future interactions with the quote are rejected. + if (sentStatusBackend === null) { + this.#stopRetryTimerIfIdle(); + return; + } + + // Re-checked before each retry attempt so an in-flight retry stops early when + // the entry has since advanced, been acknowledged, become terminal, or + // expired (a later `reportFinalised` re-triggers processing). This avoids + // firing a request the backend would reject (e.g. `SUBMITTED` after + // finalization). + const retry = (): boolean => { + const live = this.#quoteStatusEntryStore.get( + QuoteStatusEntryStore.hash(entry), + ); + return Boolean( + live && + live.status.state === sentStatus && + live.acknowledgedState !== sentStatus, + ); + }; + + this.#quoteStatusApiService + .updateQuoteStatusWithRetry( + { + quoteId: entry.quoteId, + srcTxHash: entry.srcTxHash, + newStatus: sentStatusBackend, + }, + { + maxRetries: 5, + delayMsBetweenRetries: 3000, + retry, + }, + ) + .then((outcome) => { + switch (outcome.type) { + case QuoteStatusFetchWithRetryOutcomeType.Accepted: { + const current = this.#quoteStatusEntryStore.get( + QuoteStatusEntryStore.hash(entry), + ); + // The entry can only be absent if the store was cleared (e.g. via + // `destroy`) while the request was in flight; nothing left to do. + if (!current) { + return undefined; + } + if (current.status.state !== sentStatus) { + // The status advanced mid-flight; report the newer status. + this.#processEntry(current); + return undefined; + } + if ( + sentStatus === QuoteStatusState.FinalizedSuccess || + sentStatus === QuoteStatusState.FinalizedFailed + ) { + // A finalized status was accepted; advance to the terminal + // `Completed` state and keep the entry so any later duplicate + // `reportSubmitted` for this quote is rejected instead of looping. + this.#markCompleted(current); + + return undefined; + } + // A non-final status (e.g. `Submitted`) was accepted. The quote is + // not done yet: it still needs to be finalized via a later + // `reportFinalised`, which looks the entry up by `txMetaId`. Keep + // the entry tracked and record the acknowledgement so the retry + // loop stops re-sending the already-accepted status. + current.acknowledgedState = sentStatus; + this.#quoteStatusEntryStore.update(current); + this.#stopRetryTimerIfIdle(); + return undefined; + } + case QuoteStatusFetchWithRetryOutcomeType.NonRetryable: + this.#handleNonRetryableUpdateStatusError(entry, outcome); + return undefined; + case QuoteStatusFetchWithRetryOutcomeType.Interrupted: + this.#processEntry(entry); + return undefined; + case QuoteStatusFetchWithRetryOutcomeType.RetryableExhausted: + entry.lastAttemptAt = Date.now(); + this.#quoteStatusEntryStore.update(entry); + return undefined; + default: + return undefined; + } + }) + .catch(() => { + entry.lastAttemptAt = Date.now(); + this.#quoteStatusEntryStore.update(entry); + }); + } + + /** + * Advances an entry to the terminal `Completed` state and stops the retry + * timer if no work remains. The entry is kept in the store so later duplicate + * reports for the same quote are recognized and rejected. + * + * @param entry - The runtime entry to complete. + * @param finalizedState - Optional finalized state the backend reports as + * current. When provided and reachable, the entry is first advanced through it + * so the FSM's forward-only path (`Submitted -> FinalizedX -> Completed`) is + * respected before reaching `Completed`. + */ + #markCompleted( + entry: QuoteStatusRuntimeEntry, + finalizedState?: QuoteStatusState, + ): void { + if (finalizedState && entry.status.canTransitionTo(finalizedState)) { + entry.status.transitionTo(finalizedState); + } + + if (entry.status.canTransitionTo(QuoteStatusState.Completed)) { + entry.status.transitionTo(QuoteStatusState.Completed); + } + + this.#stopRetryTimerIfIdle(); + } + + /** + * Advances an entry to the terminal `Expired` state (abandoning it) and stops + * the retry timer if no work remains. The entry is kept in the store so later + * interactions with the same quote are rejected. + * + * @param entry - The runtime entry to abandon. + */ + #markExpired(entry: QuoteStatusRuntimeEntry): void { + if (entry.status.canTransitionTo(QuoteStatusState.Expired)) { + entry.status.transitionTo(QuoteStatusState.Expired); + } + + this.#stopRetryTimerIfIdle(); + } + + /** + * Reconciles local state in response to a non-retryable backend error. + * + * When the backend reports a terminal status, the entry is either advanced to + * match and reprocessed (if it is behind) or converged to `Completed` (if it + * is already finalized), surfacing an error only for a genuine status + * discrepancy. Other non-retryable errors that cannot be reconciled mark the + * entry `Expired` (abandoned). Entries are always kept in the store so future + * interactions with the quote are rejected rather than looping. + * + * @param entry - The runtime entry whose update failed. + * @param outcome - The non-retryable outcome returned by the API service, + * including the backend response used for reconciliation. + */ + #handleNonRetryableUpdateStatusError( + entry: QuoteStatusRuntimeEntry, + outcome: QuoteStatusUpdateWithRetryOutcome, + ): void { + const { response } = outcome; + + const backendFinalizedToState: Partial< + Record + > = { + [QuoteStatusBackendStatus.FinalizedSuccess]: + QuoteStatusState.FinalizedSuccess, + [QuoteStatusBackendStatus.FinalizedFailed]: + QuoteStatusState.FinalizedFailed, + }; + + // The transition we requested is invalid because the backend is already in a + // terminal state (e.g. we re-sent `SUBMITTED` after finalization). There is + // nothing left to report, so advance the entry to `Completed` and keep it. + if ( + response?.type === + QuoteStatusUpdateBackendErrorType.InvalidStatusTransaction && + (response.currentStatus === QuoteStatusBackendStatus.FinalizedSuccess || + response.currentStatus === QuoteStatusBackendStatus.FinalizedFailed) + ) { + this.#markCompleted( + entry, + backendFinalizedToState[response.currentStatus], + ); + return; + } + + // For mismatch errors the backend reports the status it currently has, which + // lets us reconcile our local state. The discriminant check also narrows + // `response` to the variant carrying `currentStatus`. + if ( + response?.type === + QuoteStatusUpdateBackendErrorType.InvalidStatusTransaction || + response?.type === + QuoteStatusUpdateBackendErrorType.QuoteStatusOnChainMismatch + ) { + const nextState = backendFinalizedToState[response.currentStatus]; + + if (nextState) { + // We are behind the backend's terminal status; advance our local state + // and reprocess so the correct finalization status is reported. + if (entry.status.canTransitionTo(nextState)) { + entry.status.transitionTo(nextState); + this.#processEntry(entry); + return; + } + + // The backend reports a finalized status but we cannot transition there. + // If we already hold that same finalized state, this can be a stale + // `SUBMITTED` response racing with an in-flight finalized update. Do not + // complete in this case: keep the entry pending so the finalized update + // can continue retrying on transient backend errors such as + // `CONCURRENT_UPDATE`. + if (entry.status.state === nextState) { + return; + } + + // Any other non-transitionable finalized mismatch indicates we've reached + // (or passed) a terminal state but disagree with the backend's final + // status. Converge to `Completed` (kept) rather than expiring. + if ( + entry.status.state !== QuoteStatusState.Completed && + entry.status.state !== QuoteStatusState.Expired + ) { + // Genuine discrepancy between our finalized status and the backend's + // (e.g. we observed the opposite outcome); surface it for visibility, + // but still complete the entry instead of looping or abandoning it. + this.#onError?.( + new QuoteStatusUpdateError( + `reporting finalization status but entry cannot transition from "${entry.status.state}" to "${nextState}"`, + { + quoteId: entry.quoteId, + txMetaId: entry.txMetaId, + srcTxHash: entry.srcTxHash, + srcChainId: entry.srcChainId, + }, + ), + ); + } + this.#markCompleted(entry); + return; + } + } + + // Any non-retryable error we could not reconcile means we cannot make + // progress on this entry, so abandon it rather than leaving it stuck. + this.#markExpired(entry); + this.#onError?.( + new QuoteStatusUpdateError( + `abandoning entry due to non-retryable error`, + { + quoteId: entry.quoteId, + errorType: response?.type, + txMetaId: entry.txMetaId, + srcTxHash: entry.srcTxHash, + srcChainId: entry.srcChainId, + }, + ), + ); + } +} diff --git a/packages/bridge-status-controller/src/quote-status-manager/types.ts b/packages/bridge-status-controller/src/quote-status-manager/types.ts new file mode 100644 index 00000000000..e8f7a06cf91 --- /dev/null +++ b/packages/bridge-status-controller/src/quote-status-manager/types.ts @@ -0,0 +1,205 @@ +import { BridgeClientId } from '@metamask/bridge-controller'; +import { Infer } from '@metamask/superstruct'; + +import { BridgeStatusControllerMessenger } from '../types.js'; +import { + QuoteStatusState, + QuoteStatusUpdateBackendErrorType, +} from './constants.js'; +import { QuoteStatusGetError, QuoteStatusUpdateError } from './errors.js'; +import { QuoteStatusStateFsm } from './quote-status-state-fsm.js'; +import { + QuoteStatusGetResponseSchema, + QuoteStatusUpdateResponseSchema, +} from './validators.js'; + +/** + * Persisted queue entry describing a single quote status update attempt. + */ +export type QuoteStatusPersistEntry = { + /** + * Unique quote identifier. + */ + quoteId: string; + + /** + * Source transaction hash used to correlate updates. + */ + srcTxHash: string; + + /** + * Current persisted status lifecycle value. + */ + status: QuoteStatusState; + + /** + * Timestamp in milliseconds when the entry was first created. + */ + createdAt: number; + + /** + * Timestamp in milliseconds for the most recent attempt. + */ + lastAttemptAt: number; + + /** + * Optional transaction metadata identifier assigned after submission. + */ + txMetaId?: string; + + /** + * Optional source-chain id of the quote, retained to enrich error reporting. + */ + srcChainId?: string | number; + + /** + * The lifecycle status most recently accepted (2xx) by the backend, if any. + * + * Used to avoid redundantly re-sending a status the backend has already + * acknowledged. In particular, a `Submitted` entry stays tracked while it + * awaits finalization, and this flag stops the retry loop from re-sending the + * already-accepted `SUBMITTED` update (which the backend would reject as an + * invalid/duplicate transition). It is implicitly superseded once the status + * advances past it (e.g. via {@link QuoteStatusUpdateManager.reportFinalised}). + */ + acknowledgedState?: QuoteStatusState; +}; + +/** + * In-memory queue entry with an FSM instance for status transitions. + */ +export type QuoteStatusRuntimeEntry = Omit< + QuoteStatusPersistEntry, + 'status' +> & { + /** + * Runtime status FSM used to validate and apply transitions. + */ + status: QuoteStatusStateFsm; +}; + +/** + * Validated non-`2xx` response payload from the quote update status API. + */ +export type QuoteStatusUpdateResponse = Infer< + typeof QuoteStatusUpdateResponseSchema +>; + +/** + * Validated non-`2xx` response payload from the quote get status API. + */ +export type QuoteStatusGetResponse = Infer; + +/** + * Options required to create quote status API service instances. + */ +export type QuoteStatusApiServiceOptions = { + /** + * Messenger used to retrieve the authentication token. + */ + messenger: BridgeStatusControllerMessenger; + + /** + * Bridge client identifier used for request headers. + */ + clientId: BridgeClientId; + + /** + * Product name sent as the `x-metamask-clientproduct` header. + */ + clientProduct: string; + + /** + * Optional client version sent as the `x-metamask-clientversion` header. + */ + clientVersion?: string; + + /** + * Base URL for the quote status API. + */ + apiBaseUrl: string; + + /** + * Optional callback invoked when an unexpected error response shape is returned. + */ + onError?: (error: QuoteStatusUpdateError | QuoteStatusGetError) => void; +}; + +/** + * Context information attached to a {@link QuoteStatusUpdateError}. + */ +export type QuoteStatusUpdateErrorDetails = { + /** + * Unique quote identifier associated with the error. + */ + quoteId: string; + + /** + * Optional error type used to categorize known quote update failures. + */ + errorType?: QuoteStatusUpdateBackendErrorType; + + /** + * Optional transaction metadata id associated with the error, used to + * correlate the failure with a specific transaction in error reporting. + */ + txMetaId?: string; + + /** + * Optional source-chain transaction hash associated with the error, included + * to aid debugging in error reporting. + */ + srcTxHash?: string; + + /** + * Optional source-chain id associated with the error, included to aid + * debugging in error reporting. + */ + srcChainId?: string | number; +}; + +/** + * Context information attached to a {@link QuoteStatusGetError}. + */ +export type QuoteStatusGetErrorDetails = { + /** + * Unique quote identifier associated with the error. + */ + quoteId: string; + + validationFailures?: string[]; +}; + +/** + * Construction options for quote status entry store instances. + */ +export type QuoteStatusEntryStoreOptions = { + /** + * Callback used to persist the current in-memory queue snapshot. + */ + onPersistUpdates: (updates: Record) => void; + /** + * Entry time-to-live in milliseconds before automatic eviction. + */ + entryTtlMs: number; + + /** + * Optional initial persisted entries used to seed the store. + */ + initial?: Record; +}; + +/** + * Event payload emitted whenever the FSM state changes. + */ +export type QuoteStatusStateUpdateEvent = { + previousState: QuoteStatusState; + nextState: QuoteStatusState; +}; + +/** + * Listener signature for quote-status state changes. + */ +export type QuoteStatusStateUpdateListener = ( + event: QuoteStatusStateUpdateEvent, +) => void; diff --git a/packages/bridge-status-controller/src/quote-status-manager/utils.test.ts b/packages/bridge-status-controller/src/quote-status-manager/utils.test.ts new file mode 100644 index 00000000000..a56488da059 --- /dev/null +++ b/packages/bridge-status-controller/src/quote-status-manager/utils.test.ts @@ -0,0 +1,42 @@ +import { sleep } from './utils.js'; + +describe('sleep', () => { + beforeEach(() => { + jest.useFakeTimers(); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + it('resolves after the given delay', async () => { + const onResolved = jest.fn(); + const promise = sleep(1000).then(onResolved); + + await jest.advanceTimersByTimeAsync(999); + expect(onResolved).not.toHaveBeenCalled(); + + await jest.advanceTimersByTimeAsync(1); + await promise; + expect(onResolved).toHaveBeenCalledTimes(1); + }); + + it('resolves with undefined', async () => { + const promise = sleep(0); + + await jest.advanceTimersByTimeAsync(0); + + expect(await promise).toBeUndefined(); + }); + + it('schedules the timeout with the provided delay', async () => { + const setTimeoutSpy = jest.spyOn(globalThis, 'setTimeout'); + + const promise = sleep(1234); + + expect(setTimeoutSpy).toHaveBeenCalledWith(expect.any(Function), 1234); + + await jest.advanceTimersByTimeAsync(1234); + await promise; + }); +}); diff --git a/packages/bridge-status-controller/src/quote-status-manager/utils.ts b/packages/bridge-status-controller/src/quote-status-manager/utils.ts new file mode 100644 index 00000000000..5cbdc22bd52 --- /dev/null +++ b/packages/bridge-status-controller/src/quote-status-manager/utils.ts @@ -0,0 +1,7 @@ +/** + * Returns a promise that resolves after the given number of milliseconds. + * + * @param ms - The number of milliseconds to wait before resolving. + */ +export const sleep = (ms: number): Promise => + new Promise((resolve) => setTimeout(resolve, ms)); diff --git a/packages/bridge-status-controller/src/quote-status-manager/validators.test.ts b/packages/bridge-status-controller/src/quote-status-manager/validators.test.ts new file mode 100644 index 00000000000..17f4629f8f5 --- /dev/null +++ b/packages/bridge-status-controller/src/quote-status-manager/validators.test.ts @@ -0,0 +1,101 @@ +import { StatusTypes } from '@metamask/bridge-controller'; + +import { + QuoteStatusBackendStatus, + QuoteStatusUpdateBackendErrorType, +} from './constants.js'; +import { + validateQuoteStatusGetResponse, + validateQuoteStatusUpdateResponse, +} from './validators.js'; + +describe('quote-status validators', () => { + describe('validateQuoteStatusUpdateResponse', () => { + it('accepts a valid base error response', () => { + const response = { + statusCode: 404, + message: 'quote not found', + type: QuoteStatusUpdateBackendErrorType.QuoteNotFound, + }; + + expect(() => validateQuoteStatusUpdateResponse(response)).not.toThrow(); + }); + + it('accepts a valid on-chain mismatch response', () => { + const response = { + statusCode: 400, + message: 'status mismatch', + type: QuoteStatusUpdateBackendErrorType.QuoteStatusOnChainMismatch, + currentStatus: QuoteStatusBackendStatus.Submitted, + newStatus: QuoteStatusBackendStatus.FinalizedSuccess, + }; + + expect(() => validateQuoteStatusUpdateResponse(response)).not.toThrow(); + }); + + it('throws for mismatch type without current/new status', () => { + const response = { + statusCode: 400, + message: 'status mismatch', + type: QuoteStatusUpdateBackendErrorType.QuoteStatusOnChainMismatch, + }; + + expect(() => validateQuoteStatusUpdateResponse(response)).toThrow( + 'Expected the value to satisfy a union of', + ); + }); + + it('throws for unsupported update error type', () => { + const response = { + statusCode: 400, + message: 'unsupported type', + type: 'NOT_A_REAL_TYPE', + }; + + expect(() => validateQuoteStatusUpdateResponse(response)).toThrow( + 'Expected the value to satisfy a union of', + ); + }); + }); + + describe('validateQuoteStatusGetResponse', () => { + it('accepts an empty response', () => { + expect(() => validateQuoteStatusGetResponse({})).not.toThrow(); + }); + + it('accepts a valid response with submitted transaction status', () => { + const response = { + submittedTx: { + status: StatusTypes.SUBMITTED, + srcChain: { + chainId: 1, + }, + }, + }; + + expect(() => validateQuoteStatusGetResponse(response)).not.toThrow(); + }); + + it('throws when submittedTx is present without status', () => { + const response = { + submittedTx: {}, + }; + + expect(() => validateQuoteStatusGetResponse(response)).toThrow( + 'At path: submittedTx.status', + ); + }); + + it('throws when submitted transaction status is invalid', () => { + const response = { + submittedTx: { + status: 'NOT_A_STATUS_TYPE', + }, + }; + + expect(() => validateQuoteStatusGetResponse(response)).toThrow( + 'At path: submittedTx.status', + ); + }); + }); +}); diff --git a/packages/bridge-status-controller/src/quote-status-manager/validators.ts b/packages/bridge-status-controller/src/quote-status-manager/validators.ts new file mode 100644 index 00000000000..43f35870e61 --- /dev/null +++ b/packages/bridge-status-controller/src/quote-status-manager/validators.ts @@ -0,0 +1,62 @@ +import { + string, + number, + enums, + union, + type, + assert, + optional, +} from '@metamask/superstruct'; + +import { StatusResponseSchema } from '../utils/validators.js'; +import { + BaseQuoteStatusUpdateErrorTypes, + QuoteStatusUpdateBackendOnChainMismatchTypes, + QuoteStatusBackendValues, +} from './constants.js'; +import { QuoteStatusGetResponse, QuoteStatusUpdateResponse } from './types.js'; + +const QuoteStatusUpdateResponseWithCurrentStatusSchema = type({ + statusCode: number(), + message: string(), + type: enums(QuoteStatusUpdateBackendOnChainMismatchTypes), + currentStatus: enums(QuoteStatusBackendValues), + newStatus: enums(QuoteStatusBackendValues), +}); + +const QuoteStatusUpdateResponseBaseSchema = type({ + statusCode: number(), + message: string(), + type: enums(BaseQuoteStatusUpdateErrorTypes), +}); + +export const QuoteStatusUpdateResponseSchema = union([ + QuoteStatusUpdateResponseWithCurrentStatusSchema, + QuoteStatusUpdateResponseBaseSchema, +]); + +export function validateQuoteStatusUpdateResponse( + data: unknown, +): asserts data is QuoteStatusUpdateResponse { + assert(data, QuoteStatusUpdateResponseSchema); +} + +/** + * **Note**: This struct is big and not all fields are used atm. + * For that reason we have decided to only include the fields we + * consume in production. In the future, once we need it to be strongly type, + * we will refactor. + */ +export const QuoteStatusGetResponseSchema = type({ + /** + * Submitted transaction: StatusResponseDto with at least srcChain (chainId + txHash). + * Prefilled by updateQuoteStatus; replaced with full provider status by getQuoteStatus. + */ + submittedTx: optional(StatusResponseSchema), +}); + +export function validateQuoteStatusGetResponse( + data: unknown, +): asserts data is QuoteStatusGetResponse { + assert(data, QuoteStatusGetResponseSchema); +} diff --git a/packages/bridge-status-controller/src/strategy/batch-sell-strategy.ts b/packages/bridge-status-controller/src/strategy/batch-sell-strategy.ts new file mode 100644 index 00000000000..b86530ae6c6 --- /dev/null +++ b/packages/bridge-status-controller/src/strategy/batch-sell-strategy.ts @@ -0,0 +1,167 @@ +import { BatchSellTradesResponse, TxData } from '@metamask/bridge-controller'; +import { + TransactionMeta, + TransactionType, +} from '@metamask/transaction-controller'; + +import { QuoteAndTxMetadata } from '../types.js'; +import { + findAllTransactionsInBatch, + getAddTransactionBatchParams, + hasNestedSwapTransactions, + is7702Tx, + isTradeTx, + shouldDisable7702, + toQuoteAndTxMetadataBatch, +} from '../utils/transaction.js'; +import { SubmitStep } from './types.js'; +import type { SubmitStrategyParams, SubmitStepResult } from './types.js'; + +const getHistoryKeyForQuote = ({ + quoteResponse: { quoteId, quote }, +}: QuoteAndTxMetadata): string => quoteId ?? quote.requestId; + +/** + * Submits batch-sell transactions to the TransactionController + * + * @param args - The parameters for the transaction + * @yields The approvalMeta and tradeMeta for the first batch sell transaction + */ +export async function* submitBatchSellHandler( + args: SubmitStrategyParams, +): AsyncGenerator { + const { + requireApproval, + quoteResponses, + messenger, + addTransactionBatchFn, + isDelegatedAccount, + batchSellTrades, + batchId: batchIdParam, + } = args; + + const tradeData = toQuoteAndTxMetadataBatch({ + quoteResponses, + batchSellTrades, + }); + + const { gasIncluded7702, gasIncluded, gasSponsored } = batchSellTrades; + + const gasFeeToken = tradeData.find( + ({ type }) => type === TransactionType.tokenMethodTransfer, + )?.tx.to; + + const transactionParams = await getAddTransactionBatchParams({ + messenger, + tradeData, + requireApproval, + isDelegatedAccount, + // Tx success/failure is independent of other txs in the batch + atomic: false, + disable7702: shouldDisable7702( + gasIncluded7702, + gasIncluded, + isDelegatedAccount, + ), + isGasFeeSponsored: gasSponsored, + isGasFeeIncluded: Boolean(gasIncluded7702), + batchId: batchIdParam, + skipInitialGasEstimate: gasIncluded7702 + ? isDelegatedAccount + : Boolean(gasFeeToken), + excludeNativeTokenForFee: !gasFeeToken, + }); + + // Submit the batch to the TransactionController + const { batchId } = await addTransactionBatchFn(transactionParams); + + // Find all batch transaction metas and add them to history + const allTradesInBatch = findAllTransactionsInBatch({ + messenger, + batchId, + tradeData, + }).filter( + (metadata): metadata is QuoteAndTxMetadata & { txMeta: TransactionMeta } => + isTradeTx(metadata.type) && metadata.txMeta !== undefined, + ); + + // This is either the delegation tx or the first STX swap in the batch + const firstTradeWithMetadata = allTradesInBatch.find( + ({ txMeta }) => + txMeta?.type && + (isTradeTx(txMeta.type) || hasNestedSwapTransactions(txMeta)), + ); + const firstTradeMeta = firstTradeWithMetadata?.txMeta; + if (!firstTradeMeta) { + throw new Error( + 'Failed to add BatchSell trade to history: txMeta not found', + ); + } + + yield { + type: SubmitStep.SetTradeMeta, + payload: { + tradeMeta: firstTradeMeta, + }, + }; + + // Each quote must be reported to the reconsiler service (through QuoteStatusManager) with the hash of the tx that + // actually executed it. So the number of distinct txs the batch produced + // decides how quotes are tracked: + // - one tx (atomic 7702 batch): all quotes share the single hash. + // - many txs (e.g. STX/sendBundle): each quote has its own hash. + // `is7702Tx` only means the account is delegated (true even for many-tx + // batches), so count the unique txs instead. + const isSingleBatchTx = + new Set(allTradesInBatch.map(({ txMeta }) => txMeta.id)).size === 1; + + // Nested/7702 batch + if ( + isSingleBatchTx && + (is7702Tx(firstTradeMeta) || hasNestedSwapTransactions(firstTradeMeta)) + ) { + const quoteIds = Array.from( + new Set(allTradesInBatch.map(getHistoryKeyForQuote)), + ); + + // Create 1 history item for the parent tx, keyed by the txMeta.id + yield { + type: SubmitStep.AddHistoryItem, + payload: { + historyKey: firstTradeMeta.id, + quoteResponse: firstTradeWithMetadata.quoteResponse, + batchSellData: batchSellTrades, + quoteIds, + bridgeTxMeta: firstTradeMeta, + }, + }; + // Then create a new history item for each nested trade, keyed by quoteId/requestId + for (const tradeWithMetadata of allTradesInBatch) { + const { quoteResponse } = tradeWithMetadata; + + yield { + type: SubmitStep.AddHistoryItem, + payload: { + historyKey: getHistoryKeyForQuote(tradeWithMetadata), + quoteResponse, + batchSellData: batchSellTrades, + }, + }; + } + } else { + // Each trade has its own txMeta if not submitted via 7702 + // Create a new history item for each one, keyed by txMeta.id + // Note that the approvalTxId is not tracked in history + for (const { txMeta, quoteResponse } of allTradesInBatch) { + yield { + type: SubmitStep.AddHistoryItem, + payload: { + historyKey: txMeta.id, + quoteResponse, + batchSellData: batchSellTrades, + bridgeTxMeta: txMeta, + }, + }; + } + } +} diff --git a/packages/bridge-status-controller/src/strategy/batch-strategy.ts b/packages/bridge-status-controller/src/strategy/batch-strategy.ts new file mode 100644 index 00000000000..bede6f8c892 --- /dev/null +++ b/packages/bridge-status-controller/src/strategy/batch-strategy.ts @@ -0,0 +1,99 @@ +import type { TxData } from '@metamask/bridge-controller'; + +import { + findAllTransactionsInBatch, + getAddTransactionBatchParams, + isApprovalTx, + isTradeTx, + shouldDisable7702, + toQuoteAndTxMetadata, +} from '../utils/transaction.js'; +import { SubmitStep } from './types.js'; +import type { SubmitStrategyParams, SubmitStepResult } from './types.js'; + +/** + * Submits batched EVM transactions to the TransactionController + * + * @param args - The parameters for the transaction + * @yields The approvalMeta and tradeMeta for the batched transaction + */ +export async function* submitBatchHandler( + args: SubmitStrategyParams, +): AsyncGenerator { + const { + requireApproval, + quoteResponses: [quoteResponse], + messenger, + isBridgeTx, + addTransactionBatchFn, + isDelegatedAccount, + } = args; + + const tradeData = toQuoteAndTxMetadata({ + quoteResponse, + isBridgeTx, + }); + + const transactionParams = await getAddTransactionBatchParams({ + tradeData, + requireApproval, + isDelegatedAccount, + messenger, + atomic: true, + disable7702: shouldDisable7702( + quoteResponse.quote.gasIncluded7702, + quoteResponse.quote.gasIncluded, + isDelegatedAccount, + ), + isGasFeeSponsored: Boolean(quoteResponse.quote.gasSponsored), + isGasFeeIncluded: Boolean(quoteResponse.quote.gasIncluded7702), + }); + + const { batchId } = await addTransactionBatchFn(transactionParams); + + const quoteAndTxMetas = findAllTransactionsInBatch({ + messenger, + batchId, + tradeData, + }); + + yield { + type: SubmitStep.UpdateBatchTransactions, + payload: { + quoteAndTxMetas, + }, + }; + + const tradeMeta = quoteAndTxMetas.find( + ({ type, txMeta }) => isTradeTx(type) && txMeta, + )?.txMeta; + + const approvalMeta = quoteAndTxMetas.find( + ({ type, txMeta }) => isApprovalTx(type) && txMeta, + )?.txMeta; + + if (!tradeMeta) { + throw new Error( + 'Failed to update cross-chain swap transaction batch: tradeMeta not found', + ); + } + + yield { + type: SubmitStep.SetTradeMeta, + payload: { tradeMeta }, + }; + + yield { + type: SubmitStep.AddHistoryItem, + payload: { + historyKey: tradeMeta.id, + approvalTxId: approvalMeta?.id, + bridgeTxMeta: { + id: tradeMeta.id, + hash: tradeMeta.hash, + batchId: tradeMeta.batchId, + }, + quoteResponse, + }, + }; +} diff --git a/packages/bridge-status-controller/src/strategy/evm-strategy.ts b/packages/bridge-status-controller/src/strategy/evm-strategy.ts new file mode 100644 index 00000000000..97f4bf54f75 --- /dev/null +++ b/packages/bridge-status-controller/src/strategy/evm-strategy.ts @@ -0,0 +1,212 @@ +/* eslint-disable consistent-return */ +/* eslint-disable @typescript-eslint/explicit-function-return-type */ +import { formatChainIdToHex, isEvmTxData } from '@metamask/bridge-controller'; +import type { TxData } from '@metamask/bridge-controller'; +import { + TransactionMeta, + TransactionType, +} from '@metamask/transaction-controller'; + +import { BridgeStatusControllerMessenger } from '../types.js'; +import { getAccountByAddress } from '../utils/accounts.js'; +import { getNetworkClientIdByChainId } from '../utils/network.js'; +import { getApprovalTraceParams } from '../utils/trace.js'; +import { + addTransaction, + generateActionId, + handleApprovalDelay, + handleMobileHardwareWalletDelay, + toTransactionParams, + waitForTxConfirmation, +} from '../utils/transaction.js'; +import { SubmitStep } from './types.js'; +import type { SubmitStrategyParams, SubmitStepResult } from './types.js'; + +/** + * Submits a single tx to the TransactionController and returns the txMetaId + * + * @param args - The parameters for the transaction + * @param args.transactionType - The type of transaction to submit + * @param args.trade - The trade data to confirm + * @param args.requireApproval - Whether to require approval for the transaction + * @param args.txFee - Optional gas fee parameters from the quote (used when gasIncluded is true) + * @param args.txFee.maxFeePerGas - The maximum fee per gas from the quote + * @param args.txFee.maxPriorityFeePerGas - The maximum priority fee per gas from the quote + * @param args.actionId - Optional actionId for pre-submission history (if not provided, one is generated) + * @param args.messenger - The messenger to use for the transaction + * @returns The transaction meta + */ +export const handleSingleTx = async ({ + messenger, + trade, + transactionType, + requireApproval = false, + txFee, + // Use provided actionId (for pre-submission history) or generate one + actionId = generateActionId(), +}: { + messenger: BridgeStatusControllerMessenger; + transactionType: TransactionType; + trade: TxData; + requireApproval?: boolean; + txFee?: { maxFeePerGas: string; maxPriorityFeePerGas: string }; + actionId?: string; +}): Promise => { + const selectedAccount = getAccountByAddress(messenger, trade.from); + if (!selectedAccount) { + throw new Error( + 'Failed to submit cross-chain swap transaction: unknown account in trade data', + ); + } + const hexChainId = formatChainIdToHex(trade.chainId); + const networkClientId = getNetworkClientIdByChainId(messenger, hexChainId); + + const requestOptions = { + actionId, + networkClientId, + requireApproval, + type: transactionType, + origin: 'metamask', + isInternal: true, + }; + + const transactionParamsWithMaxGas = await toTransactionParams( + messenger, + trade, + networkClientId, + hexChainId, + txFee, + ); + + return await addTransaction( + messenger, + { ...transactionParamsWithMaxGas, from: trade.from }, + requestOptions, + ); +}; + +/** + * Submits the approval and resetApproval transactions through the TransactionController. + * If there is a resetApproval, it will be submitted first. + * But only the approval's txMetaId will be returned. + * + * @param args - The parameters for the submission flow + * + * @returns The approvalTxId of the approval transaction + */ +const approve = async (args: SubmitStrategyParams) => { + const { + quoteResponses: [quoteResponse], + isBridgeTx, + } = args; + const { approval, resetApproval } = quoteResponse; + if (!approval || !isEvmTxData(approval)) { + return undefined; + } + + const transactionType = isBridgeTx + ? TransactionType.bridgeApproval + : TransactionType.swapApproval; + + if (resetApproval) { + await handleSingleTx({ + ...args, + transactionType, + trade: resetApproval, + }); + } + + if (approval) { + const approvalTxMeta = await handleSingleTx({ + ...args, + transactionType, + trade: approval, + }); + return approvalTxMeta?.id; + } +}; + +export const handleEvmApprovals = async (args: SubmitStrategyParams) => + await args.traceFn( + getApprovalTraceParams(args.quoteResponses[0], args.isStxEnabled), + async () => await approve(args), + ); + +/** + * Sequentially submits EVM resetApproval, approval and trade transactions through the TransactionController. + * + * @param args - The parameters for the transaction + * @yields Data for updating the BridgeStatusController + */ +export async function* submitEvmHandler( + args: SubmitStrategyParams, +): AsyncGenerator { + const { + quoteResponses: [quoteResponse], + requireApproval, + isBridgeTx, + } = args; + + // Submit resetApproval and approval transactions if present + const approvalTxId = await handleEvmApprovals(args); + + // Delay after approval + if (approvalTxId) { + await handleApprovalDelay(quoteResponse.quote.srcChainId); + } + // Hardware-wallet delay first (Ledger second-prompt spacing), then wait for + // on-chain approval confirmation so swap gas estimation runs after allowance is set. + await handleMobileHardwareWalletDelay(requireApproval); + if (requireApproval && approvalTxId) { + await waitForTxConfirmation(args.messenger, approvalTxId); + } + + // Generate trade actionId for pre-submission history + const actionId = generateActionId(); + + // Add pre-submission history keyed by actionId + // This ensures we have quote data available if transaction fails during submission + yield { + type: SubmitStep.AddHistoryItem, + payload: { + historyKey: actionId, + approvalTxId, + actionId, + quoteResponse, + }, + }; + + const transactionType = isBridgeTx + ? TransactionType.bridge + : TransactionType.swap; + + const tradeMeta = await handleSingleTx({ + ...args, + transactionType, + trade: quoteResponse.trade, + // TODO figure out if this is needed + // Pass txFee when gasIncluded is true to use the quote's gas fees + // instead of re-estimating (which would fail for max native token swaps) + txFee: quoteResponse.quote.gasIncluded + ? quoteResponse.quote.feeData.txFee + : undefined, + actionId, + }); + + // Use the tradeMeta's id as history key + yield { + type: SubmitStep.RekeyHistoryItem, + payload: { + oldHistoryKey: actionId, + newHistoryKey: tradeMeta.id, + tradeMeta, + }, + }; + + yield { + type: SubmitStep.SetTradeMeta, + payload: { + tradeMeta, + }, + }; +} diff --git a/packages/bridge-status-controller/src/strategy/index.ts b/packages/bridge-status-controller/src/strategy/index.ts new file mode 100644 index 00000000000..22b8ed10f68 --- /dev/null +++ b/packages/bridge-status-controller/src/strategy/index.ts @@ -0,0 +1,117 @@ +/* eslint-disable @typescript-eslint/prefer-nullish-coalescing */ +import { + BatchSellTradesResponse, + BitcoinTradeData, + ChainId, + isBitcoinTrade, + isEvmTxData, + isNonEvmChainId, + isStellarTrade, + isTronTrade, + StellarTradeData, + Trade, + TronTradeData, + TxData, +} from '@metamask/bridge-controller'; + +import { submitBatchSellHandler } from './batch-sell-strategy.js'; +import { submitBatchHandler } from './batch-strategy.js'; +import { submitEvmHandler as defaultSubmitHandler } from './evm-strategy.js'; +import { submitIntentHandler } from './intent-strategy.js'; +import { submitNonEvmHandler } from './non-evm-strategy.js'; +import type { SubmitStrategyParams, SubmitStepResult } from './types.js'; + +const validateParams = < + TxDataType extends + | BitcoinTradeData + | StellarTradeData + | TronTradeData + | string + | TxData, +>( + params: SubmitStrategyParams, +): params is SubmitStrategyParams => { + const txs = params.quoteResponses + .flatMap((quoteResponse) => [ + quoteResponse.trade, + quoteResponse.approval, + quoteResponse.resetApproval, + ]) + .filter((tx): tx is TxDataType => tx !== undefined); + + // Assumes all quotes are for the same chain + switch (params.quoteResponses[0].quote.srcChainId) { + case ChainId.SOLANA: + return txs.every((tx) => typeof tx === 'string'); + case ChainId.BTC: + return txs.every(isBitcoinTrade); + case ChainId.STELLAR: + return txs.every((tx) => typeof tx === 'string' || isStellarTrade(tx)); + case ChainId.TRON: + return txs.every(isTronTrade); + default: + return txs.every(isEvmTxData); + } +}; + +const validateBatchSellParams = ( + params: SubmitStrategyParams, +): params is SubmitStrategyParams => + // A BatchSell payload containing at least 1 trade is considered valid + Boolean(params.batchSellTrades) && params.quoteResponses.length >= 1; + +/** + * Selects the appropriate submit strategy based on the quote parameters then executes it + * + * @param params - The parameters for the transaction + * @returns An async generator that yields results from each step of the submit flow. The yielded + * results are used to update the BridgeStatusController state and emit events. + */ +const executeSubmitStrategy = ( + params: SubmitStrategyParams, +): AsyncGenerator => { + const { + quoteResponses: [quoteResponse], + isStxEnabled, + isDelegatedAccount, + } = params; + + // Non-EVM transactions + if (isNonEvmChainId(quoteResponse.quote.srcChainId)) { + if (!validateParams(params)) { + throw new Error( + 'Failed to submit cross-chain swap transaction: trade is not a non-EVM transaction', + ); + } + return submitNonEvmHandler(params); + } + + // EVM transactions + if (!validateParams(params)) { + throw new Error( + 'Failed to submit cross-chain swap transaction: trade is not an EVM transaction', + ); + } + + // Intent transactions + if (quoteResponse.quote.intent) { + return submitIntentHandler(params); + } + + // Batch sell transactions + if (validateBatchSellParams(params)) { + return submitBatchSellHandler(params); + } + + // Batched transactions + const shouldBatchTxs = + isStxEnabled || quoteResponse.quote.gasIncluded7702 || isDelegatedAccount; + if (shouldBatchTxs) { + return submitBatchHandler(params); + } + + // Non-stx/gasless EVM transactions + return defaultSubmitHandler(params); +}; + +export default executeSubmitStrategy; diff --git a/packages/bridge-status-controller/src/strategy/intent-strategy.ts b/packages/bridge-status-controller/src/strategy/intent-strategy.ts new file mode 100644 index 00000000000..19a95b830bd --- /dev/null +++ b/packages/bridge-status-controller/src/strategy/intent-strategy.ts @@ -0,0 +1,207 @@ +/* eslint-disable @typescript-eslint/explicit-function-return-type */ +import { + formatChainIdToHex, + isEvmTxData, + TxData, +} from '@metamask/bridge-controller'; +import { TransactionType } from '@metamask/transaction-controller'; + +import { getJwt } from '../utils/authentication.js'; +import { + getIntentFromQuote, + mapIntentOrderStatusToTransactionStatus, + postSubmitOrder, +} from '../utils/intent-api.js'; +import { signTypedMessage } from '../utils/keyring.js'; +import { getNetworkClientIdByChainId } from '../utils/network.js'; +import { + addSyntheticTransaction, + waitForTxConfirmation, +} from '../utils/transaction.js'; +import { handleEvmApprovals } from './evm-strategy.js'; +import { SubmitStrategyParams, SubmitStepResult, SubmitStep } from './types.js'; + +/** + * Submits a synthetic EVM transaction to the TransactionController in order to display the intent order's + * status in theclients, before the actual transaction is finalized on chain. The resulting transaction + * is only available locally and is not submitted to the chain. + * + * @param orderUid - The order uid of the intent transaction + * @param args - The parameters for the transaction + * @returns The tradeMeta for the synthetic transaction + */ +const handleSyntheticTx = async ( + orderUid: string, + args: SubmitStrategyParams, +) => { + const { + quoteResponses: [quoteResponse], + messenger, + isBridgeTx, + selectedAccount, + } = args; + const { + quote: { srcChainId }, + } = quoteResponse; + + // Determine transaction type: swap for same-chain, bridge for cross-chain + const transactionType = isBridgeTx + ? /* c8 ignore start */ + TransactionType.bridge + : /* c8 ignore end */ + TransactionType.swap; + + const networkClientId = getNetworkClientIdByChainId(messenger, srcChainId); + + // This is a synthetic transaction whose purpose is to be able + // to track the order status via the history + if (!isEvmTxData(quoteResponse.trade)) { + throw new Error('Failed to submit intent: trade is not an EVM transaction'); + } + const intent = getIntentFromQuote(quoteResponse); + // This is a synthetic transaction whose purpose is to be able + // to track the order status via the history + /** + * @deprecated use trade data from quote response instead + */ + const intentTransactionParams = { + chainId: formatChainIdToHex(srcChainId), + from: selectedAccount.address, + to: + intent.settlementContract ?? '0x9008D19f58AAbd9eD0D60971565AA8510560ab41', // Default settlement contract + data: `0x${orderUid?.slice(-8)}`, // Use last 8 chars of orderUid to make each transaction unique + value: '0x0', + gas: '0x5208', // Minimal gas for display purposes + gasPrice: '0x3b9aca00', // 1 Gwei - will be converted to EIP-1559 fees if network supports it + }; + + const initialTxMeta = await addSyntheticTransaction( + messenger, + intentTransactionParams, + { + requireApproval: false, + networkClientId, + type: transactionType, + }, + ); + return initialTxMeta; +}; + +/** + * Submits batched EVM transactions to the TransactionController + * + * @param args - The parameters for the transaction + * @param args.quoteResponse - The quote response + * @param args.messenger - The messenger + * @param args.selectedAccount - The selected account + * @param args.traceFn - The trace function + * @param args.isBridgeTx - Whether the transaction is a bridge transaction + * @returns The approvalTxId and tradeMeta for the non-EVM transaction + */ +const handleSubmitIntent = async (args: SubmitStrategyParams) => { + const { + quoteResponses: [quoteResponse], + messenger, + selectedAccount, + clientId, + fetchFn, + bridgeApiBaseUrl, + } = args; + const { srcChainId, requestId } = quoteResponse.quote; + + const intent = getIntentFromQuote(quoteResponse); + const signature = await signTypedMessage({ + messenger, + accountAddress: selectedAccount.address, + typedData: intent.typedData, + }); + + const { id: orderUid, status } = await postSubmitOrder({ + params: { + srcChainId, + quoteId: requestId, + signature, + order: intent.order, + userAddress: selectedAccount.address, + aggregatorId: intent.protocol, + }, + clientId, + jwt: await getJwt(messenger), + fetchFn, + bridgeApiBaseUrl, + }); + + return { + orderUid, + orderStatus: status, + }; +}; + +/** + * Submits an approval tx to the TransactionController, + * posts an intent order to the bridge-api, + * and creates a synthetic transaction in the TransactionController + * + * @param args - The parameters for the transaction + * @param args.quoteResponse - The quote response + * @param args.messenger - The messenger + * @param args.selectedAccount - The selected account + * @param args.traceFn - The trace function + * @param args.isBridgeTx - Whether the transaction is a bridge transaction + * @yields The approvalTxId and tradeMeta for the intent transaction + */ +export async function* submitIntentHandler( + args: SubmitStrategyParams, +): AsyncGenerator { + // TODO handle STX/batch approvals + const approvalTxId = await handleEvmApprovals(args); + approvalTxId && (await waitForTxConfirmation(args.messenger, approvalTxId)); + + // TODO add to history after approval tx is confirmed + + // Submit the intent order to the bridge-api + const { orderUid, orderStatus } = await handleSubmitIntent(args); + + // Initialize a transaction in the TransactionController + const syntheticTxMeta = await handleSyntheticTx(orderUid, { + ...args, + requireApproval: false, + isStxEnabled: false, + }); + + // Use synthetic transaction metadata + translated intent order status as the tradeMeta + yield { + type: SubmitStep.SetTradeMeta, + payload: { + tradeMeta: { + ...syntheticTxMeta, + // Map intent order status to TransactionController status + status: mapIntentOrderStatusToTransactionStatus(orderStatus), + }, + }, + }; + + // Update txHistory with synthetic txMeta and order id + yield { + type: SubmitStep.AddHistoryItem, + payload: { + // Use orderId as the history key for intent transactions + historyKey: orderUid, + bridgeTxMeta: { + id: syntheticTxMeta?.id, + }, + approvalTxId, + // Keep original txId for TransactionController updates + originalTransactionId: syntheticTxMeta?.id, + quoteResponse: args.quoteResponses[0], + }, + }; + + // Start polling using the orderId as the history key + yield { + type: SubmitStep.StartPolling, + payload: { + historyKey: orderUid, + }, + }; +} diff --git a/packages/bridge-status-controller/src/strategy/non-evm-strategy.ts b/packages/bridge-status-controller/src/strategy/non-evm-strategy.ts new file mode 100644 index 00000000000..cda6d5c2ad4 --- /dev/null +++ b/packages/bridge-status-controller/src/strategy/non-evm-strategy.ts @@ -0,0 +1,121 @@ +/* eslint-disable @typescript-eslint/explicit-function-return-type */ +import { isTronChainId } from '@metamask/bridge-controller'; +import type { + BitcoinTradeData, + StellarTradeData, + TronTradeData, + TxData, +} from '@metamask/bridge-controller'; + +import { handleNonEvmTx } from '../utils/snaps.js'; +import { getApprovalTraceParams } from '../utils/trace.js'; +import { handleApprovalDelay } from '../utils/transaction.js'; +import { SubmitStep } from './types.js'; +import type { SubmitStrategyParams, SubmitStepResult } from './types.js'; + +/** + * Submits the approval transaction for a non-EVM transaction if present + * + * @param args - The parameters for the transaction + * @returns The tx id of the approval transaction + */ +const handleTronApproval = async ( + args: SubmitStrategyParams< + TronTradeData | BitcoinTradeData | StellarTradeData | string | TxData + >, +) => { + const { + quoteResponses: [quoteResponse], + traceFn, + } = args; + + const approvalTxId = await traceFn( + getApprovalTraceParams(quoteResponse, false), + async () => { + if (quoteResponse.approval) { + const txMeta = await handleNonEvmTx( + args.messenger, + quoteResponse.approval, + quoteResponse, + args.selectedAccount, + ); + return txMeta.id; + } + return undefined; + }, + ); + + if (approvalTxId) { + // Add delay after approval similar to EVM flow + await handleApprovalDelay(quoteResponse.quote.srcChainId); + return approvalTxId; + } + return undefined; +}; + +/** + * Submits Solana, Bitcoin, or Tron transactions to the snap controller + * + * @param args - The parameters for the transaction + * @param args.quoteResponse - The quote response + * @param args.messenger - The messenger + * @param args.selectedAccount - The selected account + * @param args.traceFn - The trace function + * @param args.isBridgeTx - Whether the transaction is a bridge transaction + * @yields The approvalTxId and tradeMeta for the non-EVM transaction + */ +export async function* submitNonEvmHandler( + args: SubmitStrategyParams< + BitcoinTradeData | StellarTradeData | TronTradeData | string | TxData + >, +): AsyncGenerator { + const { + quoteResponses: [quoteResponse], + isBridgeTx, + } = args; + + const approvalTxId = await handleTronApproval(args); + + // TODO bridge-status should update history with actionId if approvalTxId is present + + const tradeMeta = await handleNonEvmTx( + args.messenger, + quoteResponse.trade, + quoteResponse, + args.selectedAccount, + ); + + yield { + type: SubmitStep.SetTradeMeta, + payload: { tradeMeta }, + }; + + yield { + type: SubmitStep.AddHistoryItem, + payload: { + historyKey: tradeMeta.id, + approvalTxId, + bridgeTxMeta: { + id: tradeMeta.id, + hash: tradeMeta.hash, + }, + quoteResponse, + }, + }; + + yield { + type: SubmitStep.StartPolling, + payload: { + historyKey: tradeMeta.id, + }, + }; + + if (!isTronChainId(quoteResponse.quote.srcChainId) && !isBridgeTx) { + yield { + type: SubmitStep.PublishCompletedEvent, + payload: { + historyKey: tradeMeta.id, + }, + }; + } +} diff --git a/packages/bridge-status-controller/src/strategy/types.ts b/packages/bridge-status-controller/src/strategy/types.ts new file mode 100644 index 00000000000..00e7f39ea28 --- /dev/null +++ b/packages/bridge-status-controller/src/strategy/types.ts @@ -0,0 +1,150 @@ +import type { AccountsControllerState } from '@metamask/accounts-controller'; +import type { + BatchSellTradesResponse, + BridgeClientId, + QuoteMetadata, + QuoteResponseV1, + Trade, + TxData, +} from '@metamask/bridge-controller'; +import type { TraceCallback } from '@metamask/controller-utils'; +import type { + TransactionController, + TransactionMeta, +} from '@metamask/transaction-controller'; +import type { Hex } from '@metamask/utils'; + +import type { + BridgeStatusControllerMessenger, + FetchFunction, + QuoteAndTxMetadata, + StartPollingForBridgeTxStatusArgs, +} from '../types.js'; + +export enum SubmitStep { + /** + * Adds quote and submission data to BridgeStatusController's `txHistory` + */ + AddHistoryItem = 'addHistoryItem', + /** + * Rekeys the history item keyed by the old history key to the new history key, + * and merges in the tradeMeta's id and hash + */ + RekeyHistoryItem = 'rekeyHistoryItem', + /** + * Triggers polling for the transaction's status + */ + StartPolling = 'startPolling', + /** + * Publishes the Unified SwapBridge Completed metrics event + */ + PublishCompletedEvent = 'publishCompletedEvent', + /** + * Sets the tradeMeta returned to the client after submission + */ + SetTradeMeta = 'setTradeMeta', + /** + * Updates the transaction type of batch transactions to swap/bridge/swapApproval/bridgeApproval + * for display purposes. + */ + UpdateBatchTransactions = 'updateBatchTransactions', +} + +/** + * Any possible result returned by steps in a submission strategy. These can be returned in any order. + */ +export type SubmitStepResult = + | { + type: SubmitStep.AddHistoryItem; + payload: Pick< + StartPollingForBridgeTxStatusArgs, + 'approvalTxId' | 'bridgeTxMeta' | 'originalTransactionId' | 'actionId' + > & { + historyKey: string; + quoteResponse: QuoteResponseV1 & QuoteMetadata; + batchSellData?: BatchSellTradesResponse; + quoteIds?: string[]; + }; + } + | { + type: SubmitStep.RekeyHistoryItem; + payload: { + /** Usually the actionId of the preceeding `approval` transaction */ + oldHistoryKey: string; + /** Usually the txMeta.id of the `trade` transaction */ + newHistoryKey: string; + /** The {@link TransactionMeta} for the `trade` transaction after it has been submitted successfully */ + tradeMeta: TransactionMeta; + }; + } + | { + type: SubmitStep.StartPolling; + payload: { + /** The `txHistory` key of the transaction to start polling for */ + historyKey: string; + }; + } + | { + type: SubmitStep.PublishCompletedEvent; + payload: { + /** The `txHistory` key of the transaction that has been submitted successfully */ + historyKey: string; + }; + } + | { + type: SubmitStep.SetTradeMeta; + /** The {@link TransactionMeta} for the transaction that has been submitted successfully */ + payload: { + tradeMeta: TransactionMeta; + }; + } + | { + type: SubmitStep.UpdateBatchTransactions; + payload: { + quoteAndTxMetas: QuoteAndTxMetadata[]; + }; + }; + +/** + * The parameters for the submission flow + */ +export type SubmitStrategyParams< + TradeType extends Trade = TxData, + BatchSellTradesResponseType extends + | BatchSellTradesResponse + | undefined + | null = BatchSellTradesResponse | undefined | null, +> = { + /** + * The response from obtainGaslessBatch API containing submittable transactions and their fees + */ + batchSellTrades: BatchSellTradesResponseType; + /** + * The function to add a transaction batch to the {@link TransactionControllers} + */ + addTransactionBatchFn: TransactionController['addTransactionBatch']; + isBridgeTx: boolean; + isDelegatedAccount: boolean; + /** + * Whether the STX is enabled in the wallet. Does not necessarily mean that + * STX will be used to submit the transaction. + */ + isStxEnabled: boolean; + messenger: BridgeStatusControllerMessenger; + quoteResponses: (QuoteResponseV1 & QuoteMetadata)[]; + /** + * Set to true so hardware wallets get prompted for approval on mobile + */ + requireApproval: boolean; + selectedAccount: AccountsControllerState['internalAccounts']['accounts'][string]; + traceFn: TraceCallback; + // Used for intent transactions + fetchFn: FetchFunction; + clientId: BridgeClientId; + bridgeApiBaseUrl: string; + /** + * The batch ID of the transaction batch passed to the addTransactionBatchFn + * This is only used for batch-sell transactions. + */ + batchId?: Hex; +}; diff --git a/packages/bridge-status-controller/src/types.ts b/packages/bridge-status-controller/src/types.ts new file mode 100644 index 00000000000..368c873fa85 --- /dev/null +++ b/packages/bridge-status-controller/src/types.ts @@ -0,0 +1,409 @@ +import type { AccountsControllerGetAccountByAddressAction } from '@metamask/accounts-controller'; +import type { + ControllerGetStateAction, + ControllerStateChangeEvent, +} from '@metamask/base-controller'; +import type { + ChainId, + FeatureId, + QuoteMetadata, + QuoteResponseV1, + MetaMetricsSwapsEventSource, + SimulatedGasFeeLimits, + TxData, + TxFeeGasLimits, + BridgeControllerTrackUnifiedSwapBridgeEventAction, + BridgeControllerStopPollingForQuotesAction, + BatchSellTradesResponse, + BridgeControllerGetStateAction, + InputPrimaryDenomination, +} from '@metamask/bridge-controller'; +import type { KeyringControllerSignTypedMessageAction } from '@metamask/keyring-controller'; +import type { Messenger } from '@metamask/messenger'; +import type { + NetworkControllerFindNetworkClientIdByChainIdAction, + NetworkControllerGetNetworkClientByIdAction, + NetworkControllerGetStateAction, +} from '@metamask/network-controller'; +import type { AuthenticationControllerGetBearerTokenAction } from '@metamask/profile-sync-controller/auth'; +import type { RemoteFeatureFlagControllerGetStateAction } from '@metamask/remote-feature-flag-controller'; +import type { SnapControllerHandleRequestAction } from '@metamask/snaps-controllers'; +import type { Infer } from '@metamask/superstruct'; +import type { + TransactionControllerAddTransactionAction, + TransactionControllerEstimateGasFeeAction, + TransactionControllerGetStateAction, + TransactionControllerIsAtomicBatchSupportedAction, + TransactionControllerTransactionStatusUpdatedEvent, + TransactionControllerTransactionSubmittedEvent, + TransactionControllerUpdateTransactionAction, + TransactionMeta, + TransactionType, +} from '@metamask/transaction-controller'; +import type { CaipAssetType } from '@metamask/utils'; + +import type { BridgeStatusControllerMethodActions } from './bridge-status-controller-method-action-types.js'; +import { BRIDGE_STATUS_CONTROLLER_NAME } from './constants.js'; +import { QuoteStatusState } from './quote-status-manager/constants.js'; +import { StatusResponseSchema } from './utils/validators.js'; + +// All fields need to be types not interfaces, same with their children fields +// o/w you get a type error + +export enum BridgeClientId { + EXTENSION = 'extension', + MOBILE = 'mobile', +} + +export type FetchFunction = ( + input: RequestInfo | URL, + init?: RequestInit, +) => Promise; + +/** + * These fields are specific to Solana transactions and can likely be infered from TransactionMeta + * + * @deprecated these should be removed eventually + */ +export type SolanaTransactionMeta = { + isSolana: boolean; + isBridgeTx: boolean; +}; + +export type StatusRequest = { + bridgeId: string; // lifi, socket, squid + srcTxHash?: string; // lifi, socket, squid, might be undefined for STX + bridge: string; // lifi, socket, squid + srcChainId: ChainId; // lifi, socket, squid + destChainId: ChainId; // lifi, socket, squid + quote?: QuoteResponseV1['quote']; // squid + refuel?: boolean; // lifi +}; + +export type StatusRequestDto = Omit< + StatusRequest, + 'quote' | 'srcChainId' | 'destChainId' | 'refuel' +> & { + srcChainId: string; // lifi, socket, squid + destChainId: string; // lifi, socket, squid + requestId?: string; + refuel?: string; // lifi +}; + +export type StatusRequestWithSrcTxHash = StatusRequest & { + srcTxHash: string; +}; + +export enum BridgeId { + HOP = 'hop', + CELER = 'celer', + CELERCIRCLE = 'celercircle', + CONNEXT = 'connext', + POLYGON = 'polygon', + AVALANCHE = 'avalanche', + MULTICHAIN = 'multichain', + AXELAR = 'axelar', + ACROSS = 'across', + STARGATE = 'stargate', + RELAY = 'relay', + MAYAN = 'mayan', +} + +export type StatusResponse = Infer; + +export type RefuelStatusResponse = object & StatusResponse; + +/** + * This type ties together the quote, its tx params and the submitted txMeta. + * Each trade/approval will have its own QuoteAndTxMetadata object. + */ +export type QuoteAndTxMetadata = { + type: TransactionType; + quoteResponse: QuoteResponseV1 & QuoteMetadata; + /** + * The approval or trade object from the quote response + */ + tx: TxData; + assetsFiatValues?: { sending?: string; receiving?: string }; + /** + * The simulated gas fee limits for the transaction provided by the bridge-api + */ + txFee?: SimulatedGasFeeLimits | TxFeeGasLimits; + /** + * Transaction metadata from the TransactionController after submission + */ + txMeta?: TransactionMeta; +}; + +export type BridgeHistoryItem = { + txMetaId?: string; // Optional: not available pre-submission or on sync failure + actionId?: string; // Only for non-batch EVM transactions + /** + * @deprecated the txMeta or orderUid should be used instead + */ + originalTransactionId?: string; // Keep original transaction ID for intent transactions + batchId?: string; + /** + * This is defined when the history item is for a batch sell transaction + */ + batchSellData?: BatchSellTradesResponse; + /** + * This is defined when the history item corresponds to the 7702 batch's delegation tx. + * It contains the list of quoteIds for the BatchSell quotes that are part of the 7702 batch. + * Each quote can be retrieved from txHistory as `txHistory[quoteId]`. + * + * On single swaps/bridges this value is an empty array, or absent on history items + * persisted before this field was introduced. + */ + quoteIds?: string[]; + quote: QuoteResponseV1['quote']; + /** + * This is the the quote id used on single swaps/bridges. On batch sell, it is set + * as the first item of `quoteIds`. + * + * This value is absent on history items persisted before this field was introduced. + */ + quoteId?: string; + reportedSubmittedTxHash?: string; + status: StatusResponse; + startTime: number; // timestamp in ms + estimatedProcessingTimeInSeconds: number; + slippagePercentage: number; + /** + * Whether the user explicitly overrode the default slippage setting. + * Optional for history items created before this field was persisted. + */ + customSlippage?: boolean; + completionTime?: number; // timestamp in ms + pricingData?: { + /** + * The actual amount sent by user in non-atomic decimal form + */ + amountSent: string; + amountSentInUsd?: string; + quotedGasInUsd?: string; + quotedGasAmount?: string; + quotedReturnInUsd?: string; + quotedRefuelSrcAmountInUsd?: string; + quotedRefuelDestAmountInUsd?: string; + }; + initialDestAssetBalance?: string; + targetContractAddress?: string; + account: string; + hasApprovalTx: boolean; + approvalTxId?: string; + featureId?: FeatureId; + isStxEnabled?: boolean; + /** + * The location/entry point from which the user initiated the swap or bridge. + * Used to attribute swaps to specific flows (e.g. Trending Explore). + */ + location?: MetaMetricsSwapsEventSource; + /** + * Legacy A/B test metrics context (`ab_tests`) kept for backward compatibility. + * Keys are test names, values are variant names (e.g. { token_details_layout: 'treatment' }). + */ + abTests?: Record; + /** + * New A/B test metrics context (`active_ab_tests`) that replaces `ab_tests`. + * Kept separate so migration can run both payloads in parallel. + * This field is an array of test objects. + */ + activeAbTests?: { key: string; value: string }[]; + /** + * Attempts tracking for exponential backoff on failed fetches. + * We track the number of attempts and the last attempt time for each txMetaId that has failed at least once + */ + attempts?: { + counter: number; + lastAttemptTime: number; // timestamp in ms + }; + /** + * Client-supplied security classification for the destination token at the + * time the swap/bridge was submitted. Persisted so post-submit analytics + * events (Completed, Failed, StatusValidationFailed) can include + * `token_security_type_destination`. `null` when no security data was + * available for the destination token. + */ + tokenSecurityTypeDestination?: string | null; + /** + * The denomination shown as the primary source amount input when the + * swap/bridge was submitted. + */ + inputPrimaryDenomination?: InputPrimaryDenomination; +}; + +/** + * @deprecated Use the separate action types instead (e.g. + * `BridgeStatusControllerStartPollingForBridgeTxStatusAction`). + */ +export enum BridgeStatusAction { + StartPollingForBridgeTxStatus = 'StartPollingForBridgeTxStatus', + WipeBridgeStatus = 'WipeBridgeStatus', + GetState = 'GetState', + ResetState = 'ResetState', + SubmitTx = 'SubmitTx', + SubmitIntent = 'SubmitIntent', + RestartPollingForFailedAttempts = 'RestartPollingForFailedAttempts', + GetBridgeHistoryItemByTxMetaId = 'GetBridgeHistoryItemByTxMetaId', +} + +export type TokenAmountValuesSerialized = { + amount: string; + valueInCurrency: string | null; + usd: string | null; +}; + +export type QuoteMetadataSerialized = { + gasFee: TokenAmountValuesSerialized; + /** + * The total network fee for the bridge transaction + * estimatedGasFees + relayerFees + */ + totalNetworkFee: TokenAmountValuesSerialized; + /** + * The total max network fee for the bridge transaction + * maxGasFees + relayerFees + */ + totalMaxNetworkFee: TokenAmountValuesSerialized; + toTokenAmount: TokenAmountValuesSerialized; + /** + * The adjusted return for the bridge transaction + * destTokenAmount - totalNetworkFee + */ + adjustedReturn: Omit; + /** + * The actual amount sent by user in non-atomic decimal form + * srcTokenAmount + metabridgeFee + */ + sentAmount: TokenAmountValuesSerialized; + swapRate: string; // destTokenAmount / sentAmount + /** + * The cost of the bridge transaction + * sentAmount - adjustedReturn + */ + cost: Omit; +}; + +export type StartPollingForBridgeTxStatusArgs = { + bridgeTxMeta?: Pick; + actionId?: string; + batchSellData?: BridgeHistoryItem['batchSellData']; + quoteIds?: BridgeHistoryItem['quoteIds']; + /** + * @deprecated the txMeta or orderUid should be used instead + */ + originalTransactionId?: string; + quoteResponse: QuoteResponseV1 & QuoteMetadata; + startTime: BridgeHistoryItem['startTime']; + slippagePercentage: BridgeHistoryItem['slippagePercentage']; + customSlippage?: BridgeHistoryItem['customSlippage']; + initialDestAssetBalance?: BridgeHistoryItem['initialDestAssetBalance']; + targetContractAddress?: BridgeHistoryItem['targetContractAddress']; + approvalTxId?: BridgeHistoryItem['approvalTxId']; + isStxEnabled?: BridgeHistoryItem['isStxEnabled']; + location: MetaMetricsSwapsEventSource; + // Legacy field for `ab_tests` metrics payload. + abTests?: BridgeHistoryItem['abTests']; + // New field for `active_ab_tests` metrics payload. + activeAbTests?: BridgeHistoryItem['activeAbTests']; + accountAddress: string; + // Client-supplied destination token security classification, persisted on + // the history item for post-submit analytics events. + tokenSecurityTypeDestination?: BridgeHistoryItem['tokenSecurityTypeDestination']; + // Primary denomination at submission time, persisted for post-submit analytics. + inputPrimaryDenomination?: BridgeHistoryItem['inputPrimaryDenomination']; +}; + +/** + * Chrome: The BigNumber values are automatically serialized to strings when sent to the background + * Firefox: The BigNumber values are not serialized to strings when sent to the background, + * so we force the ui to do it manually, by using StartPollingForBridgeTxStatusArgsSerialized type on the startPollingForBridgeTxStatus action + */ +export type StartPollingForBridgeTxStatusArgsSerialized = Omit< + StartPollingForBridgeTxStatusArgs, + 'quoteResponse' +> & { + quoteResponse: QuoteResponseV1 & QuoteMetadata; +}; + +export type SourceChainTxMetaId = string; + +export type QuoteStatusPersistEntry = { + quoteId: string; + srcTxHash: string; + status: QuoteStatusState; + createdAt: number; + lastAttemptAt: number; + txMetaId?: string; +}; + +export type BridgeStatusControllerState = { + txHistory: Record; + quoteUpdateStatusStore: Record; +}; + +// Actions +export type BridgeStatusControllerGetStateAction = ControllerGetStateAction< + typeof BRIDGE_STATUS_CONTROLLER_NAME, + BridgeStatusControllerState +>; + +export type BridgeStatusControllerActions = + | BridgeStatusControllerGetStateAction + | BridgeStatusControllerMethodActions; + +// Events +export type BridgeStatusControllerStateChangeEvent = ControllerStateChangeEvent< + typeof BRIDGE_STATUS_CONTROLLER_NAME, + BridgeStatusControllerState +>; +/** + * This event is published when the destination bridge transaction is completed + * The payload is the asset received on the destination chain + */ +export type BridgeStatusControllerDestinationTransactionCompletedEvent = { + type: 'BridgeStatusController:destinationTransactionCompleted'; + payload: [CaipAssetType]; +}; + +export type BridgeStatusControllerEvents = + | BridgeStatusControllerStateChangeEvent + | BridgeStatusControllerDestinationTransactionCompletedEvent; + +/** + * The external actions available to the BridgeStatusController. + */ +type AllowedActions = + | NetworkControllerFindNetworkClientIdByChainIdAction + | NetworkControllerGetStateAction + | NetworkControllerGetNetworkClientByIdAction + | RemoteFeatureFlagControllerGetStateAction + | SnapControllerHandleRequestAction + | TransactionControllerGetStateAction + | TransactionControllerUpdateTransactionAction + | TransactionControllerAddTransactionAction + | TransactionControllerEstimateGasFeeAction + | TransactionControllerIsAtomicBatchSupportedAction + | BridgeControllerTrackUnifiedSwapBridgeEventAction + | BridgeControllerStopPollingForQuotesAction + | BridgeControllerGetStateAction + | AccountsControllerGetAccountByAddressAction + | AuthenticationControllerGetBearerTokenAction + | KeyringControllerSignTypedMessageAction; + +/** + * The external events available to the BridgeStatusController. + */ +type AllowedEvents = + | TransactionControllerTransactionStatusUpdatedEvent + | TransactionControllerTransactionSubmittedEvent; + +/** + * The messenger for the BridgeStatusController. + */ +export type BridgeStatusControllerMessenger = Messenger< + typeof BRIDGE_STATUS_CONTROLLER_NAME, + BridgeStatusControllerActions | AllowedActions, + BridgeStatusControllerEvents | AllowedEvents +>; diff --git a/packages/bridge-status-controller/src/utils/accounts.ts b/packages/bridge-status-controller/src/utils/accounts.ts new file mode 100644 index 00000000000..01b56075ad6 --- /dev/null +++ b/packages/bridge-status-controller/src/utils/accounts.ts @@ -0,0 +1,9 @@ +/* eslint-disable @typescript-eslint/explicit-function-return-type */ +import { BridgeStatusControllerMessenger } from '../types.js'; + +export const getAccountByAddress = ( + messenger: BridgeStatusControllerMessenger, + address: string, +) => { + return messenger.call('AccountsController:getAccountByAddress', address); +}; diff --git a/packages/bridge-status-controller/src/utils/authentication.ts b/packages/bridge-status-controller/src/utils/authentication.ts new file mode 100644 index 00000000000..9c78fb8349b --- /dev/null +++ b/packages/bridge-status-controller/src/utils/authentication.ts @@ -0,0 +1,15 @@ +import type { BridgeStatusControllerMessenger } from '../types.js'; + +export const getJwt = async ( + messenger: BridgeStatusControllerMessenger, +): Promise => { + try { + const token = await messenger.call( + 'AuthenticationController:getBearerToken', + ); + return token; + } catch (error) { + console.error('Error getting JWT token for bridge-api request', error); + return undefined; + } +}; diff --git a/packages/bridge-status-controller/src/utils/bridge-status.test.ts b/packages/bridge-status-controller/src/utils/bridge-status.test.ts new file mode 100644 index 00000000000..83471d4abc0 --- /dev/null +++ b/packages/bridge-status-controller/src/utils/bridge-status.test.ts @@ -0,0 +1,458 @@ +import { BRIDGE_PROD_API_BASE_URL, REFRESH_INTERVAL_MS } from '../constants.js'; +import { QuoteStatusFetchWithRetryOutcomeType } from '../quote-status-manager/constants.js'; +import { QuoteStatusGetError } from '../quote-status-manager/errors.js'; +import { QuoteStatusGetWithRetryOutcome } from '../quote-status-manager/quote-status-get-with-retry-outcome.js'; +import type { QuoteStatusManager } from '../quote-status-manager/quotes-status-manager.js'; +import { BridgeClientId } from '../types.js'; +import type { StatusRequestWithSrcTxHash, FetchFunction } from '../types.js'; +import { + fetchBridgeQuoteStatus, + fetchBridgeTxStatus, + getBridgeStatusUrl, + getStatusRequestDto, + shouldSkipFetchDueToFetchFailures, +} from './bridge-status.js'; + +describe('utils', () => { + const mockStatusRequest: StatusRequestWithSrcTxHash = { + bridgeId: 'socket', + srcTxHash: '0x123', + bridge: 'socket', + srcChainId: 1, + destChainId: 137, + refuel: false, + quote: { + requestId: 'req-123', + bridgeId: 'socket', + bridges: ['socket'], + srcChainId: 1, + destChainId: 137, + srcAsset: { + chainId: 1, + address: '0x123', + symbol: 'ETH', + name: 'Ether', + decimals: 18, + icon: undefined, + assetId: 'eip155:1/erc20:0x123', + }, + srcTokenAmount: '', + destAsset: { + chainId: 137, + address: '0x456', + symbol: 'USDC', + name: 'USD Coin', + decimals: 6, + icon: undefined, + assetId: 'eip155:137/erc20:0x456', + }, + destTokenAmount: '', + minDestTokenAmount: '', + feeData: { + metabridge: { + amount: '100', + asset: { + chainId: 1, + address: '0x123', + symbol: 'ETH', + name: 'Ether', + decimals: 18, + icon: 'eth.jpeg', + assetId: 'eip155:1/erc20:0x123', + }, + }, + }, + steps: [], + }, + }; + + const mockValidResponse = { + status: 'PENDING', + srcChain: { + chainId: 1, + txHash: '0x123', + amount: '991250000000000', + token: { + address: '0x0000000000000000000000000000000000000000', + assetId: 'eip155:1/erc20:0x0000000000000000000000000000000000000000', + chainId: 1, + symbol: 'ETH', + decimals: 18, + name: 'ETH', + coinKey: 'ETH', + logoURI: + 'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png', + priceUSD: '2518.47', + icon: 'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png', + }, + }, + destChain: { + chainId: 137, + token: {}, + }, + }; + + describe('fetchBridgeTxStatus', () => { + const mockClientId = BridgeClientId.EXTENSION; + + it('should successfully fetch and validate bridge transaction status', async () => { + const mockFetch: FetchFunction = jest + .fn() + .mockResolvedValue(mockValidResponse); + + const result = await fetchBridgeTxStatus( + mockStatusRequest, + mockClientId, + 'AUTH_TOKEN', + mockFetch, + BRIDGE_PROD_API_BASE_URL, + ); + + // Verify the fetch was called with correct parameters + expect(mockFetch).toHaveBeenCalledWith( + expect.stringContaining(getBridgeStatusUrl(BRIDGE_PROD_API_BASE_URL)), + { + headers: { + 'X-Client-Id': mockClientId, + Authorization: 'Bearer AUTH_TOKEN', + }, + }, + ); + + // Verify URL contains all required parameters + const callUrl = (mockFetch as jest.Mock).mock.calls[0][0]; + expect(callUrl).toContain(`bridgeId=${mockStatusRequest.bridgeId}`); + expect(callUrl).toContain(`srcTxHash=${mockStatusRequest.srcTxHash}`); + expect(callUrl).toContain( + `requestId=${mockStatusRequest.quote?.requestId}`, + ); + + // Verify responsev + expect(result.status).toStrictEqual(mockValidResponse); + expect(result.validationFailures).toStrictEqual([]); + }); + + it('should validate invalid bridge transaction status', async () => { + const mockInvalidResponse = { + ...mockValidResponse, + status: 'INVALID', + }; + const mockFetch: FetchFunction = jest + .fn() + .mockResolvedValue(mockInvalidResponse); + + const result = await fetchBridgeTxStatus( + mockStatusRequest, + mockClientId, + 'AUTH_TOKEN', + mockFetch, + BRIDGE_PROD_API_BASE_URL, + ); + + // Verify the fetch was called with correct parameters + expect(mockFetch).toHaveBeenCalledWith( + expect.stringContaining(getBridgeStatusUrl(BRIDGE_PROD_API_BASE_URL)), + { + headers: { + 'X-Client-Id': mockClientId, + Authorization: 'Bearer AUTH_TOKEN', + }, + }, + ); + + // Verify URL contains all required parameters + const callUrl = (mockFetch as jest.Mock).mock.calls[0][0]; + expect(callUrl).toContain(`bridgeId=${mockStatusRequest.bridgeId}`); + expect(callUrl).toContain(`srcTxHash=${mockStatusRequest.srcTxHash}`); + expect(callUrl).toContain( + `requestId=${mockStatusRequest.quote?.requestId}`, + ); + + // Verify response + expect(result.status).toStrictEqual(mockInvalidResponse); + expect(result.validationFailures).toMatchInlineSnapshot(` + [ + "socket|status", + ] + `); + }); + + it('should throw error when response validation fails', async () => { + const invalidResponse = { + invalid: 'response', + }; + + const mockFetch: FetchFunction = jest + .fn() + .mockResolvedValue(invalidResponse); + + const result = await fetchBridgeTxStatus( + mockStatusRequest, + mockClientId, + 'AUTH_TOKEN', + mockFetch, + BRIDGE_PROD_API_BASE_URL, + ); + + expect(result.status).toStrictEqual(invalidResponse); + expect(result.validationFailures).toMatchInlineSnapshot( + ['socket|status', 'socket|srcChain'], + ` + [ + "socket|status", + "socket|srcChain", + ] + `, + ); + }); + + it('should handle fetch errors', async () => { + const mockFetch: FetchFunction = jest + .fn() + .mockRejectedValue(new Error('Network error')); + + await expect( + fetchBridgeTxStatus( + mockStatusRequest, + mockClientId, + 'AUTH_TOKEN', + mockFetch, + BRIDGE_PROD_API_BASE_URL, + ), + ).rejects.toThrow('Network error'); + }); + }); + + describe('fetchBridgeQuoteStatus', () => { + const mockQuoteId = 'quote-1'; + + /** + * Builds a mock `QuoteStatusManager` whose `getStatus` method resolves + * with the given outcome. + * + * @param outcome - The outcome `getStatus` should resolve with. + * @returns An object with the mocked manager and its `getStatus` spy. + */ + function createMockQuoteStatusManager( + outcome: QuoteStatusGetWithRetryOutcome | undefined, + ): { + quoteStatusManager: QuoteStatusManager; + getStatus: jest.Mock; + } { + const getStatus = jest.fn().mockResolvedValue(outcome); + return { + quoteStatusManager: { getStatus } as unknown as QuoteStatusManager, + getStatus, + }; + } + + it('returns the submitted tx status when the manager reports it', async () => { + const { quoteStatusManager, getStatus } = createMockQuoteStatusManager( + new QuoteStatusGetWithRetryOutcome( + QuoteStatusFetchWithRetryOutcomeType.Accepted, + { submittedTx: mockValidResponse as never }, + ), + ); + + const result = await fetchBridgeQuoteStatus( + quoteStatusManager, + mockQuoteId, + ); + + expect(getStatus).toHaveBeenCalledWith(mockQuoteId); + expect(result).toStrictEqual({ + status: mockValidResponse, + validationFailures: [], + }); + }); + + it('returns validation failures from the outcome error alongside the submitted tx status', async () => { + const { quoteStatusManager } = createMockQuoteStatusManager( + new QuoteStatusGetWithRetryOutcome( + QuoteStatusFetchWithRetryOutcomeType.Accepted, + { submittedTx: mockValidResponse as never }, + new QuoteStatusGetError('unexpected response shape', { + quoteId: mockQuoteId, + validationFailures: ['socket|status'], + }), + ), + ); + + const result = await fetchBridgeQuoteStatus( + quoteStatusManager, + mockQuoteId, + ); + + expect(result).toStrictEqual({ + status: mockValidResponse, + validationFailures: ['socket|status'], + }); + }); + + it('returns null when the manager is disabled (resolves undefined)', async () => { + const { quoteStatusManager } = createMockQuoteStatusManager(undefined); + + const result = await fetchBridgeQuoteStatus( + quoteStatusManager, + mockQuoteId, + ); + + expect(result).toBeNull(); + }); + + it('returns null when the outcome has no response', async () => { + const { quoteStatusManager } = createMockQuoteStatusManager( + new QuoteStatusGetWithRetryOutcome( + QuoteStatusFetchWithRetryOutcomeType.NonRetryable, + undefined, + new QuoteStatusGetError('request error', { + quoteId: mockQuoteId, + }), + ), + ); + + const result = await fetchBridgeQuoteStatus( + quoteStatusManager, + mockQuoteId, + ); + + expect(result).toBeNull(); + }); + + it('returns null when the response has no submittedTx yet', async () => { + const { quoteStatusManager } = createMockQuoteStatusManager( + new QuoteStatusGetWithRetryOutcome( + QuoteStatusFetchWithRetryOutcomeType.Accepted, + {}, + ), + ); + + const result = await fetchBridgeQuoteStatus( + quoteStatusManager, + mockQuoteId, + ); + + expect(result).toBeNull(); + }); + }); + + describe('getStatusRequestDto', () => { + it('should handle status request with quote', () => { + const result = getStatusRequestDto(mockStatusRequest); + + expect(result).toStrictEqual({ + bridgeId: 'socket', + srcTxHash: '0x123', + bridge: 'socket', + srcChainId: '1', + destChainId: '137', + refuel: 'false', + requestId: 'req-123', + }); + }); + + it('should handle status request without quote', () => { + const statusRequestWithoutQuote = { + ...mockStatusRequest, + quote: undefined, + }; + + const result = getStatusRequestDto(statusRequestWithoutQuote); + + expect(result).toStrictEqual({ + bridgeId: 'socket', + srcTxHash: '0x123', + bridge: 'socket', + srcChainId: '1', + destChainId: '137', + refuel: 'false', + }); + expect(result).not.toHaveProperty('requestId'); + }); + }); + + describe('shouldSkipFetchDueToFetchFailures', () => { + const mockCurrentTime = 1_000_000; // Fixed timestamp for testing + let dateNowSpy: jest.SpyInstance; + + beforeEach(() => { + dateNowSpy = jest.spyOn(Date, 'now').mockReturnValue(mockCurrentTime); + }); + + afterEach(() => { + dateNowSpy.mockRestore(); + }); + + it('should return false if attempts is undefined', () => { + const result = shouldSkipFetchDueToFetchFailures(undefined); + expect(result).toBe(false); + }); + + it('should return false if enough time has passed since last attempt', () => { + // For counter = 1, backoff delay = REFRESH_INTERVAL_MS * 2^(1-1) = 10 seconds + const backoffDelay = REFRESH_INTERVAL_MS; // 10 seconds = 10,000ms + const lastAttemptTime = mockCurrentTime - backoffDelay - 1000; // 1 second past the backoff delay + + const attempts = { + counter: 1, + lastAttemptTime, + }; + + const result = shouldSkipFetchDueToFetchFailures(attempts); + expect(result).toBe(false); + }); + + it('should return true if not enough time has passed since last attempt', () => { + // For counter = 1, backoff delay = REFRESH_INTERVAL_MS * 2^(1-1) = 10 seconds + const backoffDelay = REFRESH_INTERVAL_MS; // 10 seconds = 10,000ms + const lastAttemptTime = mockCurrentTime - backoffDelay + 1000; // 1 second before the backoff delay elapses + + const attempts = { + counter: 1, + lastAttemptTime, + }; + + const result = shouldSkipFetchDueToFetchFailures(attempts); + expect(result).toBe(true); + }); + + it('should calculate correct exponential backoff for different attempt counters', () => { + // Test counter = 2: backoff delay = REFRESH_INTERVAL_MS * 2^(2-1) = 20 seconds + const backoffDelay2 = REFRESH_INTERVAL_MS * 2; // 20 seconds = 20,000ms + const lastAttemptTime2 = mockCurrentTime - backoffDelay2 + 5000; // 5 seconds before delay elapses + + const attempts2 = { + counter: 2, + lastAttemptTime: lastAttemptTime2, + }; + + expect(shouldSkipFetchDueToFetchFailures(attempts2)).toBe(true); + + // Test counter = 3: backoff delay = REFRESH_INTERVAL_MS * 2^(3-1) = 40 seconds + const backoffDelay3 = REFRESH_INTERVAL_MS * 4; // 40 seconds = 40,000ms + const lastAttemptTime3 = mockCurrentTime - backoffDelay3 - 1000; // 1 second past delay + + const attempts3 = { + counter: 3, + lastAttemptTime: lastAttemptTime3, + }; + + expect(shouldSkipFetchDueToFetchFailures(attempts3)).toBe(false); + }); + + it('should handle edge case where time since last attempt equals backoff delay', () => { + // For counter = 1, backoff delay = REFRESH_INTERVAL_MS * 2^(1-1) = 10 seconds + const backoffDelay = REFRESH_INTERVAL_MS; + const lastAttemptTime = mockCurrentTime - backoffDelay; // Exactly at the backoff delay + + const attempts = { + counter: 1, + lastAttemptTime, + }; + + // When time since last attempt equals backoff delay, it should not skip (return false) + const result = shouldSkipFetchDueToFetchFailures(attempts); + expect(result).toBe(false); + }); + }); +}); diff --git a/packages/bridge-status-controller/src/utils/bridge-status.ts b/packages/bridge-status-controller/src/utils/bridge-status.ts new file mode 100644 index 00000000000..c15bf33cd39 --- /dev/null +++ b/packages/bridge-status-controller/src/utils/bridge-status.ts @@ -0,0 +1,218 @@ +import { + getClientHeaders, + isNonEvmChainId, + StatusTypes, +} from '@metamask/bridge-controller'; +import type { QuoteResponseV1 } from '@metamask/bridge-controller'; +import type { Provider } from '@metamask/network-controller'; +import { StructError } from '@metamask/superstruct'; + +import { REFRESH_INTERVAL_MS } from '../constants.js'; +import { QuoteStatusManager } from '../quote-status-manager/quotes-status-manager.js'; +import type { + StatusResponse, + StatusRequestWithSrcTxHash, + StatusRequestDto, + FetchFunction, + BridgeHistoryItem, + StatusRequest, + BridgeStatusControllerMessenger, +} from '../types.js'; +import { isHistoryItemTooOld } from './history.js'; +import { getNetworkClientByChainId } from './network.js'; +import { validateBridgeStatusResponse } from './validators.js'; + +export const getBridgeStatusUrl = (bridgeApiBaseUrl: string): string => + `${bridgeApiBaseUrl}/getTxStatus`; + +export const getStatusRequestDto = ( + statusRequest: StatusRequestWithSrcTxHash, +): StatusRequestDto => { + const { quote, ...statusRequestNoQuote } = statusRequest; + + const statusRequestNoQuoteFormatted = Object.fromEntries( + Object.entries(statusRequestNoQuote).map(([key, value]) => [ + key, + value.toString(), + ]), + ) as unknown as Omit; + + const requestId: { requestId: string } | Record = + quote?.requestId ? { requestId: quote.requestId } : {}; + + return { + ...statusRequestNoQuoteFormatted, + ...requestId, + }; +}; + +export const fetchBridgeQuoteStatus = async ( + quoteStatusManager: QuoteStatusManager, + quoteId: string, +): Promise<{ status: StatusResponse; validationFailures: string[] } | null> => { + const response = await quoteStatusManager.getStatus(quoteId); + + const status = response?.response?.submittedTx; + + if (!status) { + return null; + } + + return { + status, + validationFailures: response.error?.details?.validationFailures ?? [], + }; +}; + +export const fetchBridgeTxStatus = async ( + statusRequest: StatusRequestWithSrcTxHash, + clientId: string, + jwt: string | undefined, + fetchFn: FetchFunction, + bridgeApiBaseUrl: string, +): Promise<{ status: StatusResponse; validationFailures: string[] }> => { + const statusRequestDto = getStatusRequestDto(statusRequest); + const params = new URLSearchParams(statusRequestDto); + + // Fetch + const url = `${getBridgeStatusUrl(bridgeApiBaseUrl)}?${params.toString()}`; + + const rawTxStatus: unknown = await fetchFn(url, { + headers: getClientHeaders({ clientId, jwt }), + }); + + const validationFailures: string[] = []; + + try { + validateBridgeStatusResponse(rawTxStatus); + } catch (error) { + // Build validation failure event properties + if (error instanceof StructError) { + error.failures().forEach(({ path }) => { + const aggregatorId = + (rawTxStatus as StatusResponse)?.bridge ?? + (statusRequest.bridge || statusRequest.bridgeId) ?? + ('unknown' as string); + const pathString = path?.join('.') || 'unknown'; + validationFailures.push([aggregatorId, pathString].join('|')); + }); + } + } + return { + status: rawTxStatus as StatusResponse, + validationFailures, + }; +}; + +export const getStatusRequestWithSrcTxHash = ( + quote: QuoteResponseV1['quote'], + srcTxHash: string, +): StatusRequestWithSrcTxHash => { + const { bridgeId, bridges, srcChainId, destChainId, refuel } = quote; + return { + bridgeId, + srcTxHash, + bridge: bridges[0], + srcChainId, + destChainId, + quote, + refuel: Boolean(refuel), + }; +}; + +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) { + // Calculate exponential backoff delay: base interval * 2^(attempts-1) + const backoffDelay = + REFRESH_INTERVAL_MS * Math.pow(2, attempts.counter - 1); + const timeSinceLastAttempt = Date.now() - attempts.lastAttemptTime; + + if (timeSinceLastAttempt < backoffDelay) { + // Not enough time has passed, skip this fetch + return true; + } + } + return false; +}; + +/* + * Checks if a pending history item is older than 2 days and does not have a valid tx hash + * + * @param messenger - The messenger to use to get the transaction meta by hash or id + * @param historyItem - The history item to check + * + * @returns true if the src tx hash is valid or we should still wait for it, false otherwise + */ +export const shouldWaitForFinalBridgeStatus = async ( + messenger: BridgeStatusControllerMessenger, + historyItem: BridgeHistoryItem, +): Promise => { + // Keep waiting for status if the history is not pending or is not old enough yet + if ( + !( + isHistoryItemTooOld(messenger, historyItem) && + [StatusTypes.PENDING, StatusTypes.UNKNOWN].includes( + historyItem.status.status, + ) + ) + ) { + return true; + } + + if (isNonEvmChainId(historyItem.quote.srcChainId)) { + return false; + } + + let provider: Provider; + try { + provider = getNetworkClientByChainId( + messenger, + historyItem.quote.srcChainId, + ); + } catch { + // This happens when the network is disabled while the tx is pending + return true; + } + + if (!historyItem.status.srcChain.txHash) { + return false; + } + + // Otherwise check if the tx has been mined on chain + return provider + .request({ + method: 'eth_getTransactionReceipt', + params: [historyItem.status.srcChain.txHash], + }) + .then((txReceipt) => { + if (txReceipt) { + return true; + } + return false; + }) + .catch(() => { + return false; + }); +}; + +/** + * @deprecated Use getStatusRequestWithSrcTxHash instead + * @param quoteResponse - The quote response to get the status request parameters from + * @returns The status request parameters + */ +export const getStatusRequestParams = ( + quoteResponse: QuoteResponseV1, +): StatusRequest => { + return { + bridgeId: quoteResponse.quote.bridgeId, + bridge: quoteResponse.quote.bridges[0], + srcChainId: quoteResponse.quote.srcChainId, + destChainId: quoteResponse.quote.destChainId, + quote: quoteResponse.quote, + refuel: Boolean(quoteResponse.quote.refuel), + }; +}; diff --git a/packages/bridge-status-controller/src/utils/bridge.ts b/packages/bridge-status-controller/src/utils/bridge.ts new file mode 100644 index 00000000000..6e9a1154262 --- /dev/null +++ b/packages/bridge-status-controller/src/utils/bridge.ts @@ -0,0 +1,41 @@ +import { + AbortReason, + UnifiedSwapBridgeEventName, + BatchSellTradesResponse, + RequiredEventContextFromClient, +} from '@metamask/bridge-controller'; + +import { BridgeStatusControllerMessenger } from '../types.js'; + +export const stopPollingForQuotes = ( + messenger: BridgeStatusControllerMessenger, + metricsContext?: RequiredEventContextFromClient[UnifiedSwapBridgeEventName.QuotesReceived], +): void => { + messenger.call( + 'BridgeController:stopPollingForQuotes', + AbortReason.TransactionSubmitted, + metricsContext, + ); +}; + +export const getBatchSellTrades = ( + messenger: BridgeStatusControllerMessenger, +): BatchSellTradesResponse | null => { + return messenger.call('BridgeController:getState').batchSellTrades; +}; + +export const trackMetricsEvent = ({ + messenger, + eventName, + properties, +}: { + messenger: BridgeStatusControllerMessenger; + eventName: UnifiedSwapBridgeEventName; + properties: RequiredEventContextFromClient[UnifiedSwapBridgeEventName]; +}): void => { + messenger.call( + 'BridgeController:trackUnifiedSwapBridgeEvent', + eventName, + properties, + ); +}; diff --git a/packages/bridge-status-controller/src/utils/feature-flags.ts b/packages/bridge-status-controller/src/utils/feature-flags.ts new file mode 100644 index 00000000000..19452e9251b --- /dev/null +++ b/packages/bridge-status-controller/src/utils/feature-flags.ts @@ -0,0 +1,14 @@ +import { getBridgeFeatureFlags } from '@metamask/bridge-controller'; + +import { DEFAULT_MAX_PENDING_HISTORY_ITEM_AGE_MS } from '../constants.js'; +import { BridgeStatusControllerMessenger } from '../types.js'; + +export const getMaxPendingHistoryItemAgeMs = ( + messenger: BridgeStatusControllerMessenger, +): number => { + const bridgeFeatureFlags = getBridgeFeatureFlags(messenger); + return ( + bridgeFeatureFlags.maxPendingHistoryItemAgeMs ?? + DEFAULT_MAX_PENDING_HISTORY_ITEM_AGE_MS + ); +}; diff --git a/packages/bridge-status-controller/src/utils/gas.ts b/packages/bridge-status-controller/src/utils/gas.ts new file mode 100644 index 00000000000..6e5a8a9d0fa --- /dev/null +++ b/packages/bridge-status-controller/src/utils/gas.ts @@ -0,0 +1,56 @@ +/* eslint-disable @typescript-eslint/explicit-function-return-type */ +import type { TokenAmountValues } from '@metamask/bridge-controller'; +import type { TransactionReceipt } from '@metamask/transaction-controller'; +import { BigNumber } from 'bignumber.js'; + +import type { BridgeHistoryItem } from '../types.js'; + +const calcGasInHexWei = (gasLimit?: string, gasPrice?: string) => { + return gasLimit && gasPrice + ? new BigNumber(gasLimit, 16).times(new BigNumber(gasPrice, 16)) + : null; +}; + +/** + * Calculate the effective gas used for a transaction and its approval tx + * + * @param bridgeHistoryItem - The bridge history item + * @param bridgeHistoryItem.pricingData - pricing data from the submitted quote + * @param txReceipt - tx receipt from the txMeta + * @param approvalTxReceipt - tx receipt from the approvalTxMeta + * @returns The actual gas used for the transaction in Wei and its value in USD + */ +export const calcActualGasUsed = ( + { pricingData }: BridgeHistoryItem, + txReceipt?: TransactionReceipt, + approvalTxReceipt?: TransactionReceipt, +): Omit | null => { + const usdExchangeRate = + pricingData?.quotedGasInUsd && pricingData?.quotedGasAmount + ? new BigNumber(pricingData?.quotedGasInUsd).div( + pricingData.quotedGasAmount, + ) + : null; + + const actualGasInHexWei = calcGasInHexWei( + txReceipt?.gasUsed, + txReceipt?.effectiveGasPrice, + )?.plus( + calcGasInHexWei( + approvalTxReceipt?.gasUsed, + approvalTxReceipt?.effectiveGasPrice, + ) ?? 0, + ); + + const actualGasInDecEth = actualGasInHexWei + ?.div(new BigNumber(10).pow(18)) + .toString(10); + + return actualGasInHexWei && actualGasInDecEth + ? { + amount: actualGasInHexWei.toString(10), + usd: + usdExchangeRate?.multipliedBy(actualGasInDecEth).toString(10) ?? '0', + } + : null; +}; diff --git a/packages/bridge-status-controller/src/utils/history.test.ts b/packages/bridge-status-controller/src/utils/history.test.ts new file mode 100644 index 00000000000..ca8371fcb60 --- /dev/null +++ b/packages/bridge-status-controller/src/utils/history.test.ts @@ -0,0 +1,176 @@ +import { StatusTypes } from '@metamask/bridge-controller'; +import type { Quote } from '@metamask/bridge-controller'; + +import type { + BridgeStatusControllerState, + BridgeHistoryItem, + StartPollingForBridgeTxStatusArgsSerialized, + StatusResponse, +} from '../types.js'; +import { + getHistoryKey, + getInitialHistoryItem, + rekeyHistoryItemInState, +} from './history.js'; + +describe('History Utils', () => { + describe('rekeyHistoryItemInState', () => { + const makeState = ( + overrides?: Partial, + ): BridgeStatusControllerState => + ({ + txHistory: {}, + ...overrides, + }) as BridgeStatusControllerState; + + it('returns false when history item missing', () => { + const state = makeState(); + const result = rekeyHistoryItemInState(state, 'missing', 'tx1', { + id: 'tx1', + hash: '0xhash', + }); + expect(result).toBe(false); + }); + + it('rekeys and preserves srcTxHash', () => { + const state = makeState({ + txHistory: { + action1: { + txMetaId: undefined, + actionId: 'action1', + originalTransactionId: undefined, + quote: { srcChainId: 1, destChainId: 10 } as Quote, + status: { + status: StatusTypes.SUBMITTED, + srcChain: { chainId: 1, txHash: '0xold' }, + } as StatusResponse, + account: '0xaccount', + estimatedProcessingTimeInSeconds: 1, + slippagePercentage: 0, + hasApprovalTx: false, + } as BridgeHistoryItem, + }, + }); + + const result = rekeyHistoryItemInState(state, 'action1', 'tx1', { + id: 'tx1', + hash: '0xnew', + }); + + expect(result).toBe(true); + expect(state.txHistory.action1).toBeUndefined(); + expect(state.txHistory.tx1.status.srcChain.txHash).toBe('0xnew'); + }); + + it('uses existing srcTxHash when txMeta hash is missing', () => { + const state = makeState({ + txHistory: { + action1: { + txMetaId: undefined, + actionId: 'action1', + originalTransactionId: undefined, + quote: { srcChainId: 1, destChainId: 10 } as Quote, + status: { + status: StatusTypes.SUBMITTED, + srcChain: { chainId: 1, txHash: '0xold' }, + } as StatusResponse, + account: '0xaccount', + estimatedProcessingTimeInSeconds: 1, + slippagePercentage: 0, + hasApprovalTx: false, + } as BridgeHistoryItem, + }, + }); + + const result = rekeyHistoryItemInState(state, 'action1', 'tx1', { + id: 'tx1', + }); + + expect(result).toBe(true); + expect(state.txHistory.tx1.status.srcChain.txHash).toBe('0xold'); + }); + }); + + describe('getHistoryKey', () => { + it('returns actionId when both actionId and bridgeTxMetaId are provided', () => { + expect(getHistoryKey('action-123', 'tx-456')).toBe('action-123'); + }); + + it('returns bridgeTxMetaId when only bridgeTxMetaId is provided', () => { + expect(getHistoryKey(undefined, 'tx-456')).toBe('tx-456'); + }); + + it('returns actionId when only actionId is provided', () => { + expect(getHistoryKey('action-123', undefined)).toBe('action-123'); + }); + + it('throws error when neither actionId nor bridgeTxMetaId is provided', () => { + expect(() => getHistoryKey(undefined, undefined)).toThrow( + 'Cannot add tx to history: either actionId, bridgeTxMeta.id, or syntheticTransactionId must be provided', + ); + }); + }); + + describe('getInitialHistoryItem', () => { + const baseArgs = { + bridgeTxMeta: { id: 'tx1', hash: '0xhash' }, + quoteResponse: { + ...{ + quote: { srcChainId: 1, destChainId: 10 }, + estimatedProcessingTimeInSeconds: 60, + }, + ...{ + sentAmount: { amount: '1', usd: '2' }, + gasFee: { effective: { amount: '0.001', usd: '3' } }, + toTokenAmount: { amount: '1', usd: '4' }, + }, + }, + startTime: 1, + slippagePercentage: 0, + accountAddress: '0xaccount', + isStxEnabled: false, + } as unknown as StartPollingForBridgeTxStatusArgsSerialized; + + it('omits tokenSecurityTypeDestination when not provided', () => { + const txHistoryItem = getInitialHistoryItem(baseArgs); + expect( + Object.prototype.hasOwnProperty.call( + txHistoryItem, + 'tokenSecurityTypeDestination', + ), + ).toBe(false); + }); + + it('persists a non-null tokenSecurityTypeDestination', () => { + const txHistoryItem = getInitialHistoryItem({ + ...baseArgs, + tokenSecurityTypeDestination: 'Malicious', + }); + expect(txHistoryItem.tokenSecurityTypeDestination).toBe('Malicious'); + }); + + it('persists a null tokenSecurityTypeDestination', () => { + const txHistoryItem = getInitialHistoryItem({ + ...baseArgs, + tokenSecurityTypeDestination: null, + }); + expect(txHistoryItem.tokenSecurityTypeDestination).toBeNull(); + }); + + it('persists customSlippage when provided', () => { + const txHistoryItem = getInitialHistoryItem({ + ...baseArgs, + customSlippage: true, + }); + expect(txHistoryItem.customSlippage).toBe(true); + }); + + it('persists inputPrimaryDenomination when provided', () => { + const txHistoryItem = getInitialHistoryItem({ + ...baseArgs, + inputPrimaryDenomination: 'fiat_value', + }); + expect(txHistoryItem.inputPrimaryDenomination).toBe('fiat_value'); + }); + }); +}); diff --git a/packages/bridge-status-controller/src/utils/history.ts b/packages/bridge-status-controller/src/utils/history.ts new file mode 100644 index 00000000000..c16d5f4f1b1 --- /dev/null +++ b/packages/bridge-status-controller/src/utils/history.ts @@ -0,0 +1,298 @@ +import { + StatusTypes, + isCrossChain, + isNonEvmChainId, + isTronChainId, +} from '@metamask/bridge-controller'; +import type { TransactionMeta } from '@metamask/transaction-controller'; + +import type { + BridgeHistoryItem, + BridgeStatusControllerMessenger, + BridgeStatusControllerState, + StartPollingForBridgeTxStatusArgsSerialized, +} from '../types.js'; +import { getMaxPendingHistoryItemAgeMs } from './feature-flags.js'; + +const updateHistoryItem = ( + oldHistoryItem: BridgeHistoryItem, + txMeta: { id: string; hash?: string }, +): Partial => { + return { + ...oldHistoryItem, + txMetaId: txMeta.id, + originalTransactionId: oldHistoryItem.originalTransactionId ?? txMeta.id, + status: { + ...oldHistoryItem.status, + srcChain: { + ...oldHistoryItem.status.srcChain, + txHash: txMeta.hash ?? oldHistoryItem.status.srcChain?.txHash, + }, + }, + }; +}; + +export const rekeyHistoryItemInState = ( + state: BridgeStatusControllerState, + oldKey: string, + newKey: string, + txMeta: { id: string; hash?: string }, +): boolean => { + const historyItem = state.txHistory[oldKey]; + if (!historyItem) { + return false; + } + + state.txHistory[newKey] = { + ...historyItem, + ...updateHistoryItem(historyItem, txMeta), + }; + delete state.txHistory[oldKey]; + return true; +}; + +export const isBatchSellHistoryItem = ( + historyItem: BridgeHistoryItem, +): boolean => Boolean(historyItem?.batchSellData); + +/** + * Returns the history entry that matches the txMeta by id, actionId, batchId, or txHash + * + * @param txHistory - The transaction history + * @param txMeta - The transaction meta + * @returns The history entry that matches the txMeta + */ +export const getMatchingHistoryEntryForTxMeta = ( + txHistory: BridgeStatusControllerState['txHistory'], + txMeta: TransactionMeta, +): [string, BridgeHistoryItem] | undefined => { + const historyEntries = Object.entries(txHistory); + + return historyEntries.find(([key, value]) => { + const { + txMetaId, + actionId, + batchId, + status: { + srcChain: { txHash }, + }, + } = value; + return ( + key === txMeta.id || + key === txMeta.actionId || + txMetaId === txMeta.id || + (actionId ? actionId === txMeta.actionId : false) || + // When the batch is not atomic (BatchSell), ignore batchId matching to prevent txs + // in the batch from getting marked complete/failed too early if one fails + // Multiple BatchSell STX trades may have the same batchId + (Boolean(batchId) && + !isBatchSellHistoryItem(value) && + batchId === txMeta.batchId) || + (txHash ? txHash.toLowerCase() === txMeta.hash?.toLowerCase() : false) + ); + }); +}; + +/** + * Returns the history entry whose approvalTxId matches the approval transaction + * + * @param txHistory - The transaction history + * @param txMeta - The transaction meta + * @returns The history entry that matches the txMeta + */ +export const getMatchingHistoryEntryForApprovalTxMeta = ( + txHistory: BridgeStatusControllerState['txHistory'], + txMeta: TransactionMeta, +): [string, BridgeHistoryItem] | undefined => { + const historyEntries = Object.entries(txHistory); + + return historyEntries.find(([_, value]) => + value.approvalTxId ? value.approvalTxId === txMeta.id : false, + ); +}; + +/** + * Returns the BatchSell history items in the same batch as the provided tx hash. + * + * @param txHistory - The bridge status controller's history to search for matching history items + * @param txHashOrId - the hash or txMeta.id of a single trade in a BatchSell + * @returns The matching history items for the tx hash and a boolean indicating if it's a 7702 batch. + * @example + * getBatchSellHistoryItemsForTxHash(txHistory, id) + * If id is the hash or txMetaId of a BatchSell trade, it will return the history items for + * the trade and all other trades in the same batch. + */ +export const getBatchSellHistoryItemsForTxHash = ( + txHistory: BridgeStatusControllerState['txHistory'], + txHashOrId?: string, +): { historyItems: BridgeHistoryItem[]; is7702Batch: boolean } => { + const historyItems = Object.values(txHistory); + + if (!txHashOrId) { + return { + historyItems: [], + is7702Batch: false, + }; + } + + /** + * Either a delegation tx or a single STX BatchSell trade + */ + const parentHistoryItem = historyItems.find( + ({ status, txMetaId }) => + status.srcChain.txHash?.toLowerCase() === txHashOrId.toLowerCase() || + txMetaId === txHashOrId, + ); + + // Match by batchId or by quoteId + const matchingHistoryItems = + parentHistoryItem?.quoteIds?.map((quoteId) => txHistory[quoteId]) ?? + historyItems.filter( + ({ batchId }) => + batchId && + parentHistoryItem?.batchId && + batchId === parentHistoryItem.batchId, + ); + + return { + historyItems: matchingHistoryItems.filter((item) => item !== undefined), + is7702Batch: + Boolean(parentHistoryItem) && + Boolean(parentHistoryItem?.quoteIds?.length), + }; +}; + +/** + * Determines the key to use for storing a bridge history item. + * Uses actionId for pre-submission tracking, or bridgeTxMetaId for post-submission. + * + * @deprecated specify an explicit history key instead + * @param actionId - The action ID used for pre-submission tracking + * @param bridgeTxMetaId - The transaction meta ID from bridgeTxMeta + * @param syntheticTransactionId - The transactionId of the intent's placeholder transaction + * @returns The key to use for the history item + * @throws Error if neither actionId nor bridgeTxMetaId is provided + */ +export function getHistoryKey( + actionId: string | undefined, + bridgeTxMetaId: string | undefined, + syntheticTransactionId?: string, +): string { + const historyKey = actionId ?? bridgeTxMetaId ?? syntheticTransactionId; + if (!historyKey) { + throw new Error( + 'Cannot add tx to history: either actionId, bridgeTxMeta.id, or syntheticTransactionId must be provided', + ); + } + return historyKey; +} + +export const getInitialHistoryItem = ( + args: StartPollingForBridgeTxStatusArgsSerialized, +): BridgeHistoryItem => { + const { + bridgeTxMeta, + quoteResponse, + startTime, + slippagePercentage, + customSlippage, + initialDestAssetBalance, + targetContractAddress, + approvalTxId, + isStxEnabled, + location, + abTests, + activeAbTests, + accountAddress: selectedAddress, + originalTransactionId, + actionId, + tokenSecurityTypeDestination, + inputPrimaryDenomination, + batchSellData, + quoteIds, + } = args; + + // Write all non-status fields to state so we can reference the quote in Activity list without the Bridge API + // We know it's in progress but not the exact status yet + const txHistoryItem: BridgeHistoryItem = { + txMetaId: bridgeTxMeta?.id, + actionId, + originalTransactionId: originalTransactionId ?? bridgeTxMeta?.id, // Keep original for intent transactions + batchId: bridgeTxMeta?.batchId, + quote: quoteResponse.quote, + quoteId: quoteResponse.quoteId, + startTime, + estimatedProcessingTimeInSeconds: + quoteResponse.estimatedProcessingTimeInSeconds, + slippagePercentage, + ...(customSlippage !== undefined && { customSlippage }), + pricingData: { + amountSent: quoteResponse?.sentAmount?.amount ?? '0', + amountSentInUsd: quoteResponse?.sentAmount?.usd ?? undefined, + quotedGasInUsd: quoteResponse?.gasFee?.total?.usd ?? undefined, + quotedReturnInUsd: quoteResponse?.toTokenAmount?.usd ?? undefined, + quotedGasAmount: quoteResponse?.gasFee?.total?.amount ?? undefined, + }, + initialDestAssetBalance, + targetContractAddress, + account: selectedAddress, + status: { + // We always have a PENDING status when we start polling for a tx, don't need the Bridge API for that + // Also we know the bare minimum fields for status at this point in time + status: StatusTypes.PENDING, + srcChain: { + chainId: quoteResponse.quote.srcChainId, + // We don't set the initial tx hash for STX transactions because they return a hash on submission + // but it is not finalized until confirmation on chain + txHash: + isNonEvmChainId(quoteResponse.quote.srcChainId) || !isStxEnabled + ? bridgeTxMeta?.hash + : undefined, + }, + }, + hasApprovalTx: Boolean(quoteResponse.approval), + approvalTxId, + isStxEnabled: Boolean(isStxEnabled), + featureId: quoteResponse.featureId, + location, + ...(abTests && { abTests }), + ...(activeAbTests && { activeAbTests }), + ...(tokenSecurityTypeDestination !== undefined && { + tokenSecurityTypeDestination, + }), + ...(inputPrimaryDenomination !== undefined && { + inputPrimaryDenomination, + }), + }; + + if (batchSellData) { + txHistoryItem.batchSellData = batchSellData; + } + if (quoteIds) { + txHistoryItem.quoteIds = quoteIds; + } + + return txHistoryItem; +}; + +export const shouldPollHistoryItem = ( + historyItem: BridgeHistoryItem, +): boolean => { + const isIntent = Boolean(historyItem?.quote?.intent); + const isBridgeTx = isCrossChain( + historyItem.quote.srcChainId, + historyItem.quote.destChainId, + ); + const isTronTx = isTronChainId(historyItem.quote.srcChainId); + + return [isBridgeTx, isIntent, isTronTx].some(Boolean); +}; + +export const isHistoryItemTooOld = ( + messenger: BridgeStatusControllerMessenger, + historyItem: BridgeHistoryItem, +): boolean => { + const maxPendingHistoryItemAgeMs = getMaxPendingHistoryItemAgeMs(messenger); + + return Date.now() - historyItem.startTime > maxPendingHistoryItemAgeMs; +}; diff --git a/packages/bridge-status-controller/src/utils/intent-api.test.ts b/packages/bridge-status-controller/src/utils/intent-api.test.ts new file mode 100644 index 00000000000..cb22db0b77c --- /dev/null +++ b/packages/bridge-status-controller/src/utils/intent-api.test.ts @@ -0,0 +1,380 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import { BridgeClientId, StatusTypes } from '@metamask/bridge-controller'; +import { TransactionStatus } from '@metamask/transaction-controller'; + +import type { FetchFunction } from '../types.js'; +import { + getIntentFromQuote, + IntentApiImpl, + mapIntentOrderStatusToTransactionStatus, + postSubmitOrder, + translateIntentOrderToBridgeStatus, +} from './intent-api.js'; +import type { IntentSubmissionParams } from './intent-api.js'; +import { IntentOrderStatus } from './validators.js'; + +describe('IntentApiImpl', () => { + const baseUrl = 'https://example.com/api'; + const clientId = BridgeClientId.MOBILE; + + const makeParams = (): IntentSubmissionParams => ({ + srcChainId: 1, + quoteId: 'quote-123', + signature: '0xsig', + order: { some: 'payload' }, + userAddress: '0xabc', + aggregatorId: 'agg-1', + }); + + const makeFetchMock = (): any => + jest.fn, Parameters>(); + + const makeGetJwtMock = (): (() => Promise) => + jest.fn().mockResolvedValue(undefined); + + const validIntentOrderResponse = { + id: 'order-1', + status: IntentOrderStatus.SUBMITTED, + metadata: {}, + }; + + it('submitIntent calls POST /submitOrder with JSON body and returns response', async () => { + const fetchFn = makeFetchMock().mockResolvedValue(validIntentOrderResponse); + + const params = makeParams(); + const result = await postSubmitOrder({ + params, + clientId, + jwt: undefined, + fetchFn, + bridgeApiBaseUrl: baseUrl, + }); + + expect(result).toStrictEqual(validIntentOrderResponse); + expect(fetchFn).toHaveBeenCalledTimes(1); + expect(fetchFn).toHaveBeenCalledWith(`${baseUrl}/submitOrder`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-Client-Id': clientId, + }, + body: JSON.stringify(params), + }); + }); + + it('submitIntent rethrows Errors with a prefixed message', async () => { + const fetchFn = makeFetchMock().mockRejectedValue(new Error('boom')); + await expect( + postSubmitOrder({ + params: makeParams(), + clientId, + jwt: undefined, + fetchFn, + bridgeApiBaseUrl: baseUrl, + }), + ).rejects.toThrow('Failed to submit intent: boom'); + }); + + it('submitIntent throws generic error when rejection is not an Error', async () => { + const fetchFn = makeFetchMock().mockRejectedValue('boom'); + await expect( + postSubmitOrder({ + params: makeParams(), + clientId, + jwt: undefined, + fetchFn, + bridgeApiBaseUrl: baseUrl, + }), + ).rejects.toThrow('Failed to submit intent'); + }); + + it('getOrderStatus calls GET /getOrderStatus with encoded query params and returns response', async () => { + const fetchFn = makeFetchMock().mockResolvedValue(validIntentOrderResponse); + const api = new IntentApiImpl(baseUrl, fetchFn, makeGetJwtMock()); + + const orderId = 'order-1'; + const aggregatorId = 'My Agg/With Spaces'; + const srcChainId = 10; + + const result = await api.getOrderStatus( + orderId, + aggregatorId, + srcChainId, + clientId, + ); + + expect(result).toStrictEqual(validIntentOrderResponse); + expect(fetchFn).toHaveBeenCalledTimes(1); + + const expectedEndpoint = + `${baseUrl}/getOrderStatus` + + `?orderId=${orderId}` + + `&aggregatorId=${encodeURIComponent(aggregatorId)}` + + `&srcChainId=${srcChainId}`; + + expect(fetchFn).toHaveBeenCalledWith(expectedEndpoint, { + method: 'GET', + headers: { + 'X-Client-Id': clientId, + }, + }); + }); + + it('getOrderStatus rethrows Errors with a prefixed message', async () => { + const fetchFn = makeFetchMock().mockRejectedValue(new Error('nope')); + const api = new IntentApiImpl(baseUrl, fetchFn, makeGetJwtMock()); + + await expect(api.getOrderStatus('o', 'a', 1, clientId)).rejects.toThrow( + 'Failed to get order status: nope', + ); + }); + + it('getOrderStatus throws generic error when rejection is not an Error', async () => { + const fetchFn = makeFetchMock().mockRejectedValue({ message: 'nope' }); + const api = new IntentApiImpl(baseUrl, fetchFn, makeGetJwtMock()); + + await expect(api.getOrderStatus('o', 'a', 1, clientId)).rejects.toThrow( + 'Failed to get order status', + ); + }); + + it('submitIntent throws when response fails validation', async () => { + const fetchFn = makeFetchMock().mockResolvedValue({ + foo: 'bar', // invalid IntentOrder shape + } as any); + + await expect( + postSubmitOrder({ + params: makeParams(), + clientId, + jwt: undefined, + fetchFn, + bridgeApiBaseUrl: baseUrl, + }), + ).rejects.toThrow('Failed to submit intent: Invalid submitOrder response'); + }); + + it('getOrderStatus throws when response fails validation', async () => { + const fetchFn = makeFetchMock().mockResolvedValue({ + foo: 'bar', // invalid IntentOrder shape + } as any); + + const api = new IntentApiImpl(baseUrl, fetchFn, makeGetJwtMock()); + + await expect( + api.getOrderStatus('order-1', 'agg', 1, clientId), + ).rejects.toThrow( + 'Failed to get order status: Invalid getOrderStatus response', + ); + }); + + describe('translateIntentOrderToBridgeStatus', () => { + it('maps completed intent to COMPLETE and confirmed transaction status', () => { + const translation = translateIntentOrderToBridgeStatus( + { + id: 'order-1', + status: IntentOrderStatus.COMPLETED, + txHash: '0xhash1', + metadata: { txHashes: ['0xhash1', '0xhash2'] }, + }, + 1, + ); + + expect(translation.status).toStrictEqual({ + status: StatusTypes.COMPLETE, + srcChain: { + chainId: 1, + txHash: '0xhash1', + }, + }); + expect(translation.transactionStatus).toBe(TransactionStatus.confirmed); + }); + + it('maps cancelled intent to FAILED and falls back to metadata tx hash', () => { + const translation = translateIntentOrderToBridgeStatus( + { + id: 'order-2', + status: IntentOrderStatus.CANCELLED, + metadata: { txHashes: '0xmetadatahash' }, + }, + 10, + '0xfallback', + ); + + expect(translation.status.status).toBe(StatusTypes.FAILED); + expect(translation.status.srcChain).toStrictEqual({ + chainId: 10, + txHash: '0xfallback', + }); + expect(translation.transactionStatus).toBe(TransactionStatus.failed); + }); + it('prefers txHash when metadata is empty and returns empty hashes when none exist', () => { + const withTxHash = translateIntentOrderToBridgeStatus( + { + id: 'order-3', + status: IntentOrderStatus.SUBMITTED, + txHash: '0xonlyhash', + metadata: { txHashes: [] }, + }, + 1, + ); + + expect(withTxHash.status.srcChain.txHash).toBe('0xonlyhash'); + + const withoutHashes = translateIntentOrderToBridgeStatus( + { + id: 'order-4', + status: IntentOrderStatus.SUBMITTED, + metadata: { txHashes: '' }, + }, + 1, + ); + + expect(withoutHashes.status.status).toBe(StatusTypes.SUBMITTED); + + const emptyMetadataWithTxHash = translateIntentOrderToBridgeStatus( + { + id: 'order-5', + status: IntentOrderStatus.SUBMITTED, + txHash: '0xfallbackhash', + metadata: { txHashes: '' }, + }, + 1, + ); + + expect(emptyMetadataWithTxHash.status.srcChain.txHash).toBe( + '0xfallbackhash', + ); + }); + + it('uses fallbackTxHash for txHash when intentOrder.txHash is absent', () => { + const translation = translateIntentOrderToBridgeStatus( + { + id: 'order-fallback', + status: IntentOrderStatus.SUBMITTED, + metadata: {}, + }, + 1, + '0xfallback', + ); + + expect(translation.txHash).toBe('0xfallback'); + expect(translation.status.srcChain.txHash).toBe('0xfallback'); + }); + + it('returns undefined txHash when neither intentOrder.txHash nor fallback exist', () => { + const translation = translateIntentOrderToBridgeStatus( + { + id: 'order-nohash', + status: IntentOrderStatus.SUBMITTED, + metadata: {}, + }, + 1, + ); + + expect(translation.txHash).toBeUndefined(); + expect(translation.status.srcChain.txHash).toBeUndefined(); + }); + + it('maps confirmed intent to COMPLETE status', () => { + const translation = translateIntentOrderToBridgeStatus( + { + id: 'order-confirmed', + status: IntentOrderStatus.CONFIRMED, + txHash: '0xhash', + metadata: {}, + }, + 1, + ); + expect(translation.status.status).toBe(StatusTypes.COMPLETE); + }); + + it('maps failed and expired intents to FAILED status', () => { + const failed = translateIntentOrderToBridgeStatus( + { + id: 'order-failed', + status: IntentOrderStatus.FAILED, + metadata: {}, + }, + 1, + '0xfallback', + ); + expect(failed.status.status).toBe(StatusTypes.FAILED); + + const expired = translateIntentOrderToBridgeStatus( + { + id: 'order-expired', + status: IntentOrderStatus.EXPIRED, + metadata: {}, + }, + 1, + ); + expect(expired.status.status).toBe(StatusTypes.FAILED); + }); + }); + + describe('mapIntentOrderStatusToTransactionStatus', () => { + it('maps CONFIRMED and COMPLETED to confirmed', () => { + expect( + mapIntentOrderStatusToTransactionStatus(IntentOrderStatus.CONFIRMED), + ).toBe(TransactionStatus.confirmed); + expect( + mapIntentOrderStatusToTransactionStatus(IntentOrderStatus.COMPLETED), + ).toBe(TransactionStatus.confirmed); + }); + + it('maps FAILED, EXPIRED and CANCELLED to failed', () => { + expect( + mapIntentOrderStatusToTransactionStatus(IntentOrderStatus.FAILED), + ).toBe(TransactionStatus.failed); + expect( + mapIntentOrderStatusToTransactionStatus(IntentOrderStatus.EXPIRED), + ).toBe(TransactionStatus.failed); + expect( + mapIntentOrderStatusToTransactionStatus(IntentOrderStatus.CANCELLED), + ).toBe(TransactionStatus.failed); + }); + }); + + describe('getIntentFromQuote', () => { + it('returns intent when present in quote response', () => { + const mockIntent = { protocol: 'cowswap', order: { some: 'data' } }; + const quoteResponse = { + quote: { + intent: mockIntent, + srcChainId: 1, + destChainId: 1, + }, + } as never; + + expect(getIntentFromQuote(quoteResponse)).toBe(mockIntent); + }); + + it('throws error when intent is missing from quote', () => { + const quoteResponse = { + quote: { + srcChainId: 1, + destChainId: 1, + }, + } as never; + + expect(() => getIntentFromQuote(quoteResponse)).toThrow( + 'submitIntent: missing intent data', + ); + }); + + it('throws error when intent is undefined', () => { + const quoteResponse = { + quote: { + intent: undefined, + srcChainId: 1, + destChainId: 1, + }, + } as never; + + expect(() => getIntentFromQuote(quoteResponse)).toThrow( + 'submitIntent: missing intent data', + ); + }); + }); +}); diff --git a/packages/bridge-status-controller/src/utils/intent-api.ts b/packages/bridge-status-controller/src/utils/intent-api.ts new file mode 100644 index 00000000000..8831ff0423b --- /dev/null +++ b/packages/bridge-status-controller/src/utils/intent-api.ts @@ -0,0 +1,194 @@ +import { + BridgeClientId, + ChainId, + getClientHeaders, + Intent, + QuoteResponseV1, + StatusTypes, +} from '@metamask/bridge-controller'; +import { TransactionStatus } from '@metamask/transaction-controller'; + +import type { FetchFunction, StatusResponse } from '../types.js'; +import { + IntentStatusResponse, + IntentOrderStatus, + validateIntentStatusResponse, +} from './validators.js'; + +export type IntentSubmissionParams = { + srcChainId: ChainId; + quoteId: string; + signature: string; + order: unknown; + userAddress: string; + aggregatorId: string; +}; + +export type IntentApi = { + getOrderStatus( + orderId: string, + aggregatorId: string, + srcChainId: ChainId, + clientId: BridgeClientId, + ): Promise; +}; + +export type GetJwtFn = () => Promise; + +export class IntentApiImpl implements IntentApi { + readonly #baseUrl: string; + + readonly #fetchFn: FetchFunction; + + readonly #getJwt: GetJwtFn; + + constructor(baseUrl: string, fetchFn: FetchFunction, getJwt: GetJwtFn) { + this.#baseUrl = baseUrl; + this.#fetchFn = fetchFn; + this.#getJwt = getJwt; + } + + async getOrderStatus( + orderId: string, + aggregatorId: string, + srcChainId: ChainId, + clientId: BridgeClientId, + ): Promise { + const endpoint = `${this.#baseUrl}/getOrderStatus?orderId=${orderId}&aggregatorId=${encodeURIComponent(aggregatorId)}&srcChainId=${srcChainId}`; + try { + const jwt = await this.#getJwt(); + const response = await this.#fetchFn(endpoint, { + method: 'GET', + headers: getClientHeaders({ clientId, jwt }), + }); + if (!validateIntentStatusResponse(response)) { + throw new Error('Invalid getOrderStatus response'); + } + return response; + } catch (error: unknown) { + if (error instanceof Error) { + throw new Error(`Failed to get order status: ${error.message}`); + } + throw new Error('Failed to get order status'); + } + } +} + +export type IntentBridgeStatus = { + status: StatusResponse; + txHash?: string; + transactionStatus: TransactionStatus; +}; + +export const translateIntentOrderToBridgeStatus = ( + intentOrder: IntentStatusResponse, + srcChainId: number, + fallbackTxHash?: string, +): IntentBridgeStatus => { + let statusType: StatusTypes; + switch (intentOrder.status) { + case IntentOrderStatus.CONFIRMED: + case IntentOrderStatus.COMPLETED: + statusType = StatusTypes.COMPLETE; + break; + case IntentOrderStatus.FAILED: + case IntentOrderStatus.EXPIRED: + case IntentOrderStatus.CANCELLED: + statusType = StatusTypes.FAILED; + break; + case IntentOrderStatus.PENDING: + statusType = StatusTypes.PENDING; + break; + case IntentOrderStatus.SUBMITTED: + statusType = StatusTypes.SUBMITTED; + break; + default: + statusType = StatusTypes.UNKNOWN; + } + + const txHash = intentOrder.txHash ?? fallbackTxHash; + const status: StatusResponse = { + status: statusType, + srcChain: { + chainId: srcChainId, + txHash, + }, + }; + + return { + status, + txHash, + transactionStatus: mapIntentOrderStatusToTransactionStatus( + intentOrder.status, + ), + }; +}; + +export function mapIntentOrderStatusToTransactionStatus( + intentStatus: IntentOrderStatus, +): TransactionStatus { + switch (intentStatus) { + case IntentOrderStatus.PENDING: + case IntentOrderStatus.SUBMITTED: + return TransactionStatus.submitted; + case IntentOrderStatus.CONFIRMED: + case IntentOrderStatus.COMPLETED: + return TransactionStatus.confirmed; + case IntentOrderStatus.FAILED: + case IntentOrderStatus.EXPIRED: + case IntentOrderStatus.CANCELLED: + return TransactionStatus.failed; + default: + return TransactionStatus.submitted; + } +} + +/** + * Extracts and validates the intent data from a quote response. + * + * @param quoteResponse - The quote response that may contain intent data + * @returns The intent data from the quote + * @throws Error if the quote does not contain intent data + */ +export function getIntentFromQuote(quoteResponse: QuoteResponseV1): Intent { + const { intent } = quoteResponse.quote; + if (!intent) { + throw new Error('submitIntent: missing intent data'); + } + return intent; +} + +export const postSubmitOrder = async ({ + params, + clientId, + jwt, + fetchFn, + bridgeApiBaseUrl, +}: { + params: IntentSubmissionParams; + clientId: BridgeClientId; + jwt: string | undefined; + fetchFn: FetchFunction; + bridgeApiBaseUrl: string; +}): Promise => { + const endpoint = `${bridgeApiBaseUrl}/submitOrder`; + try { + const response = await fetchFn(endpoint, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + ...getClientHeaders({ clientId, jwt }), + }, + body: JSON.stringify(params), + }); + if (!validateIntentStatusResponse(response)) { + throw new Error('Invalid submitOrder response'); + } + return response; + } catch (error: unknown) { + if (error instanceof Error) { + throw new Error(`Failed to submit intent: ${error.message}`); + } + throw new Error('Failed to submit intent'); + } +}; diff --git a/packages/bridge-status-controller/src/utils/keyring.ts b/packages/bridge-status-controller/src/utils/keyring.ts new file mode 100644 index 00000000000..0eeba547e1b --- /dev/null +++ b/packages/bridge-status-controller/src/utils/keyring.ts @@ -0,0 +1,23 @@ +import type { Intent } from '@metamask/bridge-controller'; +import { SignTypedDataVersion } from '@metamask/keyring-controller'; + +import type { BridgeStatusControllerMessenger } from '../types.js'; + +export const signTypedMessage = async ({ + messenger, + accountAddress, + typedData, +}: { + messenger: BridgeStatusControllerMessenger; + accountAddress: string; + typedData: Intent['typedData']; +}): Promise => { + return await messenger.call( + 'KeyringController:signTypedMessage', + { + from: accountAddress, + data: typedData, + }, + SignTypedDataVersion.V4, + ); +}; diff --git a/packages/bridge-status-controller/src/utils/metrics.test.ts b/packages/bridge-status-controller/src/utils/metrics.test.ts new file mode 100644 index 00000000000..fa4202586d7 --- /dev/null +++ b/packages/bridge-status-controller/src/utils/metrics.test.ts @@ -0,0 +1,1241 @@ +import { + StatusTypes, + FeeType, + ActionTypes, + FeatureId, + getQuotesReceivedProperties, + MetaMetricsSwapsEventSource, +} from '@metamask/bridge-controller'; +import { + MetricsSwapType, + MetricsActionType, +} from '@metamask/bridge-controller'; +import type { + TransactionMeta, + TransactionError, +} from '@metamask/transaction-controller'; +import { TransactionType } from '@metamask/transaction-controller'; +import { TransactionStatus } from '@metamask/transaction-controller'; + +import type { BridgeHistoryItem } from '../types.js'; +import { + getTxStatusesFromHistory, + getFinalizedTxProperties, + getRequestParamFromHistory, + getTradeDataFromHistory, + getRequestMetadataFromHistory, + getEVMTxPropertiesFromTransactionMeta, + getPreConfirmationPropertiesFromQuote, +} from './metrics.js'; + +describe('metrics utils', () => { + const mockHistoryItem: BridgeHistoryItem = { + txMetaId: 'test-tx-id', + quote: { + srcChainId: 42161, + destChainId: 10, + srcAsset: { + symbol: 'ETH', + address: '0x0000000000000000000000000000000000000000', + assetId: 'eip155:42161/slip44:60', + chainId: 42161, + name: 'Ethereum', + decimals: 18, + }, + destAsset: { + symbol: 'ETH', + address: '0x0000000000000000000000000000000000000000', + assetId: 'eip155:10/slip44:60', + chainId: 10, + name: 'Ethereum', + decimals: 18, + }, + bridgeId: 'across', + requestId: 'test-request-id', + srcTokenAmount: '1000000000000000000', + destTokenAmount: '990000000000000000', + minDestTokenAmount: '940000000000000000', + feeData: { + [FeeType.METABRIDGE]: { + amount: '10000000000000000', + asset: { + symbol: 'ETH', + address: '0x0000000000000000000000000000000000000000', + assetId: 'eip155:42161/slip44:60', + chainId: 42161, + name: 'Ethereum', + decimals: 18, + }, + }, + }, + bridges: ['across'], + steps: [ + { + action: ActionTypes.BRIDGE, + protocol: { + name: 'across', + displayName: 'Across', + icon: 'across-icon', + }, + srcAmount: '1000000000000000000', + destAmount: '990000000000000000', + srcAsset: { + symbol: 'ETH', + address: '0x0000000000000000000000000000000000000000', + assetId: 'eip155:42161/slip44:60', + chainId: 42161, + name: 'Ethereum', + decimals: 18, + }, + destAsset: { + symbol: 'ETH', + address: '0x0000000000000000000000000000000000000000', + assetId: 'eip155:10/slip44:60', + chainId: 10, + name: 'Ethereum', + decimals: 18, + }, + srcChainId: 42161, + destChainId: 10, + }, + ], + }, + startTime: 1000, + completionTime: 2000, + estimatedProcessingTimeInSeconds: 900, + slippagePercentage: 0.5, + account: '0xaccount1', + targetContractAddress: '0xtarget', + pricingData: { + amountSent: '1.234', + amountSentInUsd: '2000', + quotedGasInUsd: '2.54739', + quotedReturnInUsd: '1980', + quotedGasAmount: '0.00055', + }, + status: { + status: StatusTypes.COMPLETE, + srcChain: { + chainId: 42161, + txHash: '0xsrcHash', + }, + destChain: { + chainId: 10, + txHash: '0xdestHash', + amount: '880000000000000000', + }, + }, + hasApprovalTx: false, + isStxEnabled: false, + }; + + describe('getTxStatusesFromHistory', () => { + it('should return correct statuses for a completed transaction', () => { + const result = getTxStatusesFromHistory(mockHistoryItem); + expect(result).toStrictEqual({ + source_transaction: StatusTypes.COMPLETE, + destination_transaction: StatusTypes.COMPLETE, + approval_transaction: undefined, + allowance_reset_transaction: undefined, + }); + }); + + it('should return correct statuses for a pending transaction', () => { + const pendingHistoryItem = { + ...mockHistoryItem, + status: { + status: StatusTypes.PENDING, + srcChain: { + chainId: 42161, + txHash: '0xsrcHash', + }, + }, + }; + const result = getTxStatusesFromHistory(pendingHistoryItem); + expect(result).toStrictEqual({ + source_transaction: StatusTypes.COMPLETE, + destination_transaction: StatusTypes.PENDING, + approval_transaction: undefined, + allowance_reset_transaction: undefined, + }); + }); + + it('should return correct statuses for a failed transaction', () => { + const failedHistoryItem = { + ...mockHistoryItem, + status: { + status: StatusTypes.FAILED, + srcChain: { + chainId: 42161, + txHash: '0xsrcHash', + }, + }, + }; + const result = getTxStatusesFromHistory(failedHistoryItem); + expect(result).toStrictEqual({ + source_transaction: StatusTypes.COMPLETE, + destination_transaction: StatusTypes.FAILED, + approval_transaction: undefined, + allowance_reset_transaction: undefined, + }); + }); + + it('should include approval transaction status when hasApprovalTx is true', () => { + const historyWithApproval = { + ...mockHistoryItem, + hasApprovalTx: true, + }; + const result = getTxStatusesFromHistory(historyWithApproval); + expect(result.approval_transaction).toBe(StatusTypes.COMPLETE); + }); + + it('should handle transaction with no source transaction hash', () => { + const noSrcTxHistoryItem = { + ...mockHistoryItem, + status: { + status: StatusTypes.PENDING, + srcChain: { + chainId: 42161, + txHash: undefined, + }, + }, + }; + const result = getTxStatusesFromHistory(noSrcTxHistoryItem); + expect(result.source_transaction).toBe(StatusTypes.PENDING); + }); + + it('should handle transaction with no destination chain', () => { + const noDestChainHistoryItem = { + ...mockHistoryItem, + status: { + status: StatusTypes.PENDING, + srcChain: { + chainId: 42161, + txHash: '0xsrcHash', + }, + }, + }; + const result = getTxStatusesFromHistory(noDestChainHistoryItem); + expect(result.destination_transaction).toBe(StatusTypes.PENDING); + }); + + it('should handle transaction with unknown status', () => { + const unknownStatusHistoryItem = { + ...mockHistoryItem, + status: { + status: 'UNKNOWN' as StatusTypes, + srcChain: { + chainId: 42161, + txHash: '0xsrcHash', + }, + }, + }; + const result = getTxStatusesFromHistory(unknownStatusHistoryItem); + expect(result.destination_transaction).toBe('PENDING'); + }); + }); + + describe('getFinalizedTxProperties', () => { + it('should calculate correct time and ratios for EVM bridge tx', () => { + const result = getFinalizedTxProperties( + { + ...mockHistoryItem, + pricingData: { + amountSent: '3', + amountSentInUsd: '2.999439', + quotedGasInUsd: '0.00023762029936118124', + quotedReturnInUsd: '2.89114367789257129', + quotedGasAmount: '5.1901652883e-8', + }, + }, + { + type: TransactionType.bridge, + txReceipt: { + gasUsed: '0x2c92a', + effectiveGasPrice: '0x1880a', + }, + } as never, + ); + expect(result).toMatchInlineSnapshot(` + { + "actual_time_minutes": 0.016666666666666666, + "quote_vs_execution_ratio": 1.1251337476231986, + "quoted_vs_used_gas_ratio": 2.8325818363563227, + "usd_actual_gas": "0.0000838882380418152", + "usd_actual_return": 2.5696, + } + `); + }); + + it('should calculate correct time and ratios for swap to ETH tx', () => { + const result = getFinalizedTxProperties( + { + ...mockHistoryItem, + account: '0x30e8ccad5a980bdf30447f8c2c48e70989d9d294', + quote: { + ...mockHistoryItem.quote, + destTokenAmount: '635621722151236', + destAsset: { + ...mockHistoryItem.quote.destAsset, + address: '0x0000000000000000000000000000000000000000', + decimals: 18, + }, + }, + pricingData: { + amountSent: '3', + amountSentInUsd: '2.999439', + quotedGasInUsd: '0.00034411818110125904', + quotedReturnInUsd: '2.91005421809056075408', + quotedGasAmount: '7.5163201268e-8', + }, + startTime: 1755199230447 - 60000, + }, + { + type: TransactionType.swap, + time: 1755199230447, + postTxBalance: '0x10879421cc05e3', + preTxBalance: '0xe39c0e2d7de7e', + txReceipt: { gasUsed: '0x57b05', effectiveGasPrice: '0x1880a' }, + } as never, + ); + + expect(result).toMatchInlineSnapshot(` + { + "actual_time_minutes": 1, + "quote_vs_execution_ratio": 0.9801662314040546, + "quoted_vs_used_gas_ratio": 2.0851258834973363, + "usd_actual_gas": "0.00016503472707560328", + "usd_actual_return": 2.968939476645719, + } + `); + }); + + it('should calculate correct time and ratios for swap to ETH tx, using txMeta.time', () => { + const result = getFinalizedTxProperties( + { + ...mockHistoryItem, + account: '0x30e8ccad5a980bdf30447f8c2c48e70989d9d294', + quote: { + ...mockHistoryItem.quote, + destTokenAmount: '635621722151236', + destAsset: { + ...mockHistoryItem.quote.destAsset, + address: '0x0000000000000000000000000000000000000000', + decimals: 18, + }, + }, + pricingData: { + amountSent: '3', + amountSentInUsd: '2.999439', + quotedGasInUsd: '0.00034411818110125904', + quotedReturnInUsd: '2.91005421809056075408', + quotedGasAmount: '7.5163201268e-8', + }, + startTime: 1755199230447 - 60000, + }, + { + type: TransactionType.swap, + postTxBalance: '0x10879421cc05e3', + preTxBalance: '0xe39c0e2d7de7e', + txReceipt: { gasUsed: '0x57b05', effectiveGasPrice: '0x1880a' }, + time: 1755199230447, + } as never, + ); + + expect(result).toMatchInlineSnapshot(` + { + "actual_time_minutes": 1, + "quote_vs_execution_ratio": 0.9801662314040546, + "quoted_vs_used_gas_ratio": 2.0851258834973363, + "usd_actual_gas": "0.00016503472707560328", + "usd_actual_return": 2.968939476645719, + } + `); + }); + + it('should calculate correct time and ratios for swap to ERC0 tx', () => { + const result = getFinalizedTxProperties( + { + ...mockHistoryItem, + account: '0x30e8ccad5a980bdf30447f8c2c48e70989d9d294', + quote: { + ...mockHistoryItem.quote, + destTokenAmount: '8902512', + destAsset: { + ...mockHistoryItem.quote.destAsset, + address: '0x0b2c639c533813f4aa9d7837caf62653d097ff85', + decimals: 6, + }, + }, + pricingData: { + amountSent: '0.002', + amountSentInUsd: '9.15656', + quotedGasInUsd: '0.00021894522672048096', + quotedReturnInUsd: '8.900847230256', + quotedGasAmount: '4.7822594232e-8', + }, + }, + { + type: TransactionType.swap, + txReceipt: { + logs: [ + { + address: '0x0b2c639c533813f4aa9d7837caf62653d097ff85', + data: '0x00000000000000000000000000000000000000000000000000000000008a9d24', + topics: [ + '0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef', + '0x0000000000000000000000009a13f98cb987694c9f086b1f5eb990eea8264ec3', + '0x0000000000000000000000000a2854fbbd9b3ef66f17d47284e7f899b9509330', + ], + }, + { + address: '0x0b2c639c533813f4aa9d7837caf62653d097ff85', + data: '0x00000000000000000000000000000000000000000000000000000000008a9d24', + topics: [ + '0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef', + '0x0000000000000000000000000a2854fbbd9b3ef66f17d47284e7f899b9509330', + '0x00000000000000000000000030e8ccad5a980bdf30447f8c2c48e70989d9d294', + ], + }, + ], + gasUsed: '0x2c92a', + effectiveGasPrice: '0x1880a', + }, + } as never, + ); + + expect(result).toMatchInlineSnapshot(` + { + "actual_time_minutes": 0, + "quote_vs_execution_ratio": 0.9799999911934969, + "quoted_vs_used_gas_ratio": 2.6099633492283485, + "usd_actual_gas": "0.0000838882380418152", + "usd_actual_return": 9.082497255348, + } + `); + }); + + it('should calculate correct time and ratios for swap to ERC0 tx, incomplete pricingData', () => { + const result = getFinalizedTxProperties( + { + ...mockHistoryItem, + account: '0x30e8ccad5a980bdf30447f8c2c48e70989d9d294', + quote: { + ...mockHistoryItem.quote, + destTokenAmount: '8902512', + destAsset: { + ...mockHistoryItem.quote.destAsset, + address: '0x0b2c639c533813f4aa9d7837caf62653d097ff85', + decimals: 6, + }, + }, + pricingData: { + amountSent: '0.002', + amountSentInUsd: '9.15656', + quotedGasInUsd: '0.00021894522672048096', + quotedGasAmount: '4.7822594232e-8', + }, + }, + { + type: TransactionType.swap, + txReceipt: { + logs: [ + { + address: '0x0b2c639c533813f4aa9d7837caf62653d097ff85', + data: '0x00000000000000000000000000000000000000000000000000000000008a9d24', + topics: [ + '0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef', + '0x0000000000000000000000009a13f98cb987694c9f086b1f5eb990eea8264ec3', + '0x0000000000000000000000000a2854fbbd9b3ef66f17d47284e7f899b9509330', + ], + }, + { + address: '0x0b2c639c533813f4aa9d7837caf62653d097ff85', + data: '0x00000000000000000000000000000000000000000000000000000000008a9d24', + topics: [ + '0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef', + '0x0000000000000000000000000a2854fbbd9b3ef66f17d47284e7f899b9509330', + '0x00000000000000000000000030e8ccad5a980bdf30447f8c2c48e70989d9d294', + ], + }, + ], + gasUsed: '0x2c92a', + effectiveGasPrice: '0x1880a', + }, + } as never, + ); + + expect(result).toMatchInlineSnapshot(` + { + "actual_time_minutes": 0, + "quote_vs_execution_ratio": 0, + "quoted_vs_used_gas_ratio": 2.6099633492283485, + "usd_actual_gas": "0.0000838882380418152", + "usd_actual_return": 0, + } + `); + }); + + it('should calculate correct time and ratios for swap to ETH tx, missing preTxBalance', () => { + const result = getFinalizedTxProperties( + { + ...mockHistoryItem, + account: '0x30e8ccad5a980bdf30447f8c2c48e70989d9d294', + quote: { + ...mockHistoryItem.quote, + destTokenAmount: '635621722151236', + destAsset: { + ...mockHistoryItem.quote.destAsset, + address: '0x0000000000000000000000000000000000000000', + decimals: 18, + }, + }, + pricingData: { + amountSent: '3', + amountSentInUsd: '2.999439', + quotedGasInUsd: '0.00034411818110125904', + quotedReturnInUsd: '2.91005421809056075408', + quotedGasAmount: '7.5163201268e-8', + }, + }, + { + type: TransactionType.swap, + postTxBalance: '0x10879421cc05e3', + txReceipt: { gasUsed: '0x57b05', effectiveGasPrice: '0x1880a' }, + } as never, + ); + + expect(result).toMatchInlineSnapshot(` + { + "actual_time_minutes": 0, + "quote_vs_execution_ratio": 1, + "quoted_vs_used_gas_ratio": 2.0851258834973363, + "usd_actual_gas": "0.00016503472707560328", + "usd_actual_return": 2.910054218090561, + } + `); + }); + + it('should calculate correct time and ratios for swap to ERC0 tx with 0x0 status', () => { + const result = getFinalizedTxProperties( + { + ...mockHistoryItem, + account: '0x30e8ccad5a980bdf30447f8c2c48e70989d9d294', + quote: { + ...mockHistoryItem.quote, + destTokenAmount: '8902512', + destAsset: { + ...mockHistoryItem.quote.destAsset, + address: '0x0b2c639c533813f4aa9d7837caf62653d097ff85', + decimals: 6, + }, + }, + pricingData: { + amountSent: '0.002', + amountSentInUsd: '9.15656', + quotedGasInUsd: '0.00021894522672048096', + quotedReturnInUsd: '8.900847230256', + quotedGasAmount: '4.7822594232e-8', + }, + }, + { + type: TransactionType.swap, + txReceipt: { + status: '0x0', + logs: [ + { + address: '0x0b2c639c533813f4aa9d7837caf62653d097ff85', + data: '0x00000000000000000000000000000000000000000000000000000000008a9d24', + topics: [ + '0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef', + '0x0000000000000000000000009a13f98cb987694c9f086b1f5eb990eea8264ec3', + '0x0000000000000000000000000a2854fbbd9b3ef66f17d47284e7f899b9509330', + ], + }, + { + address: '0x0b2c639c533813f4aa9d7837caf62653d097ff85', + data: '0x00000000000000000000000000000000000000000000000000000000008a9d24', + topics: [ + '0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef', + '0x0000000000000000000000000a2854fbbd9b3ef66f17d47284e7f899b9509330', + '0x00000000000000000000000030e8ccad5a980bdf30447f8c2c48e70989d9d294', + ], + }, + ], + gasUsed: '0x2c92a', + effectiveGasPrice: '0x1880a', + }, + } as never, + ); + + expect(result).toMatchInlineSnapshot(` + { + "actual_time_minutes": 0, + "quote_vs_execution_ratio": 0, + "quoted_vs_used_gas_ratio": 2.6099633492283485, + "usd_actual_gas": "0.0000838882380418152", + "usd_actual_return": 0, + } + `); + }); + + it('should calculate correct time and ratios for swap to ERC0 tx with incomplete log data', () => { + const result = getFinalizedTxProperties( + { + ...mockHistoryItem, + account: '0x30e8ccad5a980bdf30447f8c2c48e70989d9d294', + quote: { + ...mockHistoryItem.quote, + destTokenAmount: '8902512', + destAsset: { + ...mockHistoryItem.quote.destAsset, + address: '0x0b2c639c533813f4aa9d7837caf62653d097ff85', + decimals: 6, + }, + }, + pricingData: { + amountSent: '0.002', + amountSentInUsd: '9.15656', + quotedGasInUsd: '0.00021894522672048096', + quotedReturnInUsd: '8.900847230256', + quotedGasAmount: '4.7822594232e-8', + }, + }, + { + type: TransactionType.swap, + txReceipt: { + logs: [], + gasUsed: '0x2c92a', + effectiveGasPrice: '0x1880a', + }, + } as never, + ); + + expect(result).toMatchInlineSnapshot(` + { + "actual_time_minutes": 0, + "quote_vs_execution_ratio": 0, + "quoted_vs_used_gas_ratio": 2.6099633492283485, + "usd_actual_gas": "0.0000838882380418152", + "usd_actual_return": 0, + } + `); + }); + + it('should calculate correct time and ratios for swap tx without txMeta', () => { + const result = getFinalizedTxProperties( + { + ...mockHistoryItem, + account: '0x30e8ccad5a980bdf30447f8c2c48e70989d9d294', + quote: { + ...mockHistoryItem.quote, + destTokenAmount: '8902512', + destAsset: { + ...mockHistoryItem.quote.destAsset, + address: '0x0b2c639c533813f4aa9d7837caf62653d097ff85', + decimals: 6, + }, + }, + pricingData: { + amountSent: '0.002', + amountSentInUsd: '9.15656', + quotedGasInUsd: '0.00021894522672048096', + quotedReturnInUsd: '8.900847230256', + quotedGasAmount: '4.7822594232e-8', + }, + }, + { type: TransactionType.swap } as never, + ); + + expect(result).toMatchInlineSnapshot(` + { + "actual_time_minutes": 0, + "quote_vs_execution_ratio": 0, + "quoted_vs_used_gas_ratio": 0, + "usd_actual_gas": 0, + "usd_actual_return": 0, + } + `); + }); + + it('should calculate correct time and ratios for Solana tx', () => { + const result = getFinalizedTxProperties({ + ...mockHistoryItem, + pricingData: { + amountSent: '3', + amountSentInUsd: '2.999439', + quotedGasInUsd: '0.00023762029936118124', + quotedReturnInUsd: '2.89114367789257129', + quotedGasAmount: '5.1901652883e-8', + }, + }); + expect(result).toMatchInlineSnapshot(` + { + "actual_time_minutes": 0.016666666666666666, + "quote_vs_execution_ratio": 1.1251337476231986, + "quoted_vs_used_gas_ratio": 0, + "usd_actual_gas": 0, + "usd_actual_return": 2.5696, + } + `); + }); + + it('should handle missing completion time', () => { + const incompleteHistoryItem = { + ...mockHistoryItem, + completionTime: undefined, + }; + const result = getFinalizedTxProperties(incompleteHistoryItem); + expect(result.actual_time_minutes).toBe(0); + }); + + it('should handle missing start time', () => { + const noStartTimeHistoryItem = { + ...mockHistoryItem, + startTime: undefined, + }; + const result = getFinalizedTxProperties(noStartTimeHistoryItem); + expect(result.actual_time_minutes).toBe(0); + }); + + it('should handle missing pricing data', () => { + const noPricingDataHistoryItem: BridgeHistoryItem = { + ...mockHistoryItem, + pricingData: { + amountSent: '1.234', + amountSentInUsd: '0', + quotedGasInUsd: '0', + quotedReturnInUsd: '0', + }, + }; + const result = getFinalizedTxProperties(noPricingDataHistoryItem); + expect(result.usd_actual_return).toBe(0); + expect(result.usd_actual_gas).toBe(0); + }); + + it('should handle missing quoted return in USD', () => { + const noQuotedReturnHistoryItem: BridgeHistoryItem = { + ...mockHistoryItem, + pricingData: { + amountSent: '1.234', + amountSentInUsd: '2000', + quotedGasInUsd: '10', + quotedReturnInUsd: '0', + }, + }; + const result = getFinalizedTxProperties(noQuotedReturnHistoryItem); + expect(result.usd_actual_return).toBe(0); + }); + + it('should handle missing quoted gas in USD', () => { + const noQuotedGasHistoryItem: BridgeHistoryItem = { + ...mockHistoryItem, + pricingData: { + amountSent: '1.234', + amountSentInUsd: '2000', + quotedGasInUsd: '0', + quotedReturnInUsd: '1980', + }, + }; + const result = getFinalizedTxProperties(noQuotedGasHistoryItem); + expect(result.usd_actual_gas).toBe(0); + }); + }); + + describe('getRequestParamFromHistory', () => { + it('should return correct request parameters', () => { + const result = getRequestParamFromHistory(mockHistoryItem); + expect(result).toStrictEqual({ + chain_id_source: 'eip155:42161', + token_symbol_source: 'ETH', + token_address_source: 'eip155:42161/slip44:60', + chain_id_destination: 'eip155:10', + token_symbol_destination: 'ETH', + token_address_destination: 'eip155:10/slip44:60', + token_security_type_destination: null, + }); + }); + + it('passes through tokenSecurityTypeDestination when present on the history item', () => { + const result = getRequestParamFromHistory({ + ...mockHistoryItem, + tokenSecurityTypeDestination: 'Malicious', + }); + expect(result.token_security_type_destination).toBe('Malicious'); + }); + + it('should handle different token symbols', () => { + const differentTokensHistoryItem: BridgeHistoryItem = { + ...mockHistoryItem, + quote: { + ...mockHistoryItem.quote, + srcAsset: { + ...mockHistoryItem.quote.srcAsset, + symbol: 'USDC', + assetId: + 'eip155:42161/erc20:0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48' as const, + }, + destAsset: { + ...mockHistoryItem.quote.destAsset, + symbol: 'USDT', + assetId: + 'eip155:10/erc20:0x94b008aa00579c1307b0ef2c499ad98a8ce58e58' as const, + }, + }, + }; + const result = getRequestParamFromHistory(differentTokensHistoryItem); + expect(result.token_symbol_source).toBe('USDC'); + expect(result.token_symbol_destination).toBe('USDT'); + expect(result.token_address_source).toBe( + 'eip155:42161/erc20:0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48', + ); + expect(result.token_address_destination).toBe( + 'eip155:10/erc20:0x94b008aa00579c1307b0ef2c499ad98a8ce58e58', + ); + }); + }); + + describe('getTradeDataFromHistory', () => { + it('should return correct trade data', () => { + const result = getTradeDataFromHistory(mockHistoryItem); + expect(result).toMatchInlineSnapshot(` + { + "gas_included": false, + "gas_included_7702": false, + "provider": "across_across", + "quoted_time_minutes": 15, + "usd_quoted_gas": 2.54739, + "usd_quoted_return": 1980, + } + `); + }); + + it('should handle missing pricing data', () => { + const noPricingDataHistoryItem: BridgeHistoryItem = { + ...mockHistoryItem, + pricingData: { + amountSent: '1.234', + amountSentInUsd: '0', + quotedGasInUsd: '0', + quotedReturnInUsd: '0', + }, + }; + const result = getTradeDataFromHistory(noPricingDataHistoryItem); + expect(result.usd_quoted_gas).toBe(0); + expect(result.usd_quoted_return).toBe(0); + }); + + it('should handle missing quoted gas in USD', () => { + const noQuotedGasHistoryItem: BridgeHistoryItem = { + ...mockHistoryItem, + pricingData: { + amountSent: '1.234', + amountSentInUsd: '2000', + quotedGasInUsd: '0', + quotedReturnInUsd: '1980', + }, + }; + const result = getTradeDataFromHistory(noQuotedGasHistoryItem); + expect(result.usd_quoted_gas).toBe(0); + }); + + it('should handle missing quoted return in USD', () => { + const noQuotedReturnHistoryItem: BridgeHistoryItem = { + ...mockHistoryItem, + pricingData: { + amountSent: '1.234', + amountSentInUsd: '2000', + quotedGasInUsd: '10', + quotedReturnInUsd: '0', + }, + }; + const result = getTradeDataFromHistory(noQuotedReturnHistoryItem); + expect(result.usd_quoted_return).toBe(0); + }); + + it('should handle different bridge providers', () => { + const differentProviderHistoryItem: BridgeHistoryItem = { + ...mockHistoryItem, + quote: { + ...mockHistoryItem.quote, + bridgeId: 'stargate', + steps: [ + { + ...mockHistoryItem.quote.steps[0], + protocol: { + name: 'stargate', + displayName: 'Stargate', + icon: 'stargate-icon', + }, + }, + ], + }, + }; + const result = getTradeDataFromHistory(differentProviderHistoryItem); + expect(result.provider).toBe('stargate_across'); + }); + }); + + describe('getRequestMetadataFromHistory', () => { + it('should return correct request metadata', () => { + const result = getRequestMetadataFromHistory(mockHistoryItem); + expect(result).toStrictEqual({ + slippage_limit: 0.5, + custom_slippage: false, + security_warnings: [], + usd_amount_source: 2000, + swap_type: 'crosschain', + is_hardware_wallet: false, + account_hardware_type: null, + stx_enabled: false, + }); + }); + + it('should handle hardware wallet account', () => { + const hardwareWalletAccount = { + id: 'test-account', + type: 'eip155:eoa' as const, + address: '0xaccount1', + options: {}, + metadata: { + name: 'Test Account', + importTime: 1234567890, + keyring: { + type: 'Ledger Hardware', + }, + }, + scopes: [], + methods: [], + }; + const result = getRequestMetadataFromHistory( + mockHistoryItem, + hardwareWalletAccount, + ); + expect(result.is_hardware_wallet).toBe(true); + expect(result.account_hardware_type).toBe('Ledger'); + }); + + it('should keep Lattice accounts as Lattice', () => { + const latticeAccount = { + id: 'test-account', + type: 'eip155:eoa' as const, + address: '0xaccount1', + options: {}, + metadata: { + name: 'Test Account', + importTime: 1234567890, + keyring: { + type: 'Lattice Hardware', + }, + }, + scopes: [], + methods: [], + }; + + const result = getRequestMetadataFromHistory( + mockHistoryItem, + latticeAccount, + ); + expect(result.is_hardware_wallet).toBe(true); + expect(result.account_hardware_type).toBe('Lattice'); + }); + + it('should handle missing pricing data', () => { + const noPricingDataHistoryItem: BridgeHistoryItem = { + ...mockHistoryItem, + pricingData: { + amountSent: '1.234', + amountSentInUsd: '0', + quotedGasInUsd: '0', + quotedReturnInUsd: '0', + }, + }; + const result = getRequestMetadataFromHistory(noPricingDataHistoryItem); + expect(result.usd_amount_source).toBe(0); + }); + + it('should handle missing amount sent in USD', () => { + const noAmountSentHistoryItem: BridgeHistoryItem = { + ...mockHistoryItem, + pricingData: { + amountSent: '1.234', + amountSentInUsd: '0', + quotedGasInUsd: '10', + quotedReturnInUsd: '1980', + }, + }; + const result = getRequestMetadataFromHistory(noAmountSentHistoryItem); + expect(result.usd_amount_source).toBe(0); + }); + + it('should handle different slippage percentages', () => { + const defaultSlippageHistoryItem: BridgeHistoryItem = { + ...mockHistoryItem, + slippagePercentage: 0.1, + }; + const result = getRequestMetadataFromHistory(defaultSlippageHistoryItem); + expect(result.slippage_limit).toBe(0.1); + expect(result.custom_slippage).toBe(false); + }); + + it('should use the persisted custom slippage value', () => { + expect( + getRequestMetadataFromHistory({ + ...mockHistoryItem, + customSlippage: true, + }).custom_slippage, + ).toBe(true); + + expect( + getRequestMetadataFromHistory({ + ...mockHistoryItem, + customSlippage: false, + }).custom_slippage, + ).toBe(false); + }); + + it('should preserve an explicit Auto slippage override', () => { + const result = getRequestMetadataFromHistory({ + ...mockHistoryItem, + slippagePercentage: 0, + customSlippage: true, + }); + + expect(result.slippage_limit).toBe(0); + expect(result.custom_slippage).toBe(true); + }); + + it('should preserve value-based slippage fallback for batch sell history', () => { + const result = getRequestMetadataFromHistory({ + ...mockHistoryItem, + featureId: FeatureId.BATCH_SELL, + slippagePercentage: 0, + }); + + expect(result.custom_slippage).toBe(true); + }); + + it('should preserve legacy slippage inference for Quick Buy history', () => { + const result = getRequestMetadataFromHistory({ + ...mockHistoryItem, + featureId: FeatureId.QUICK_BUY_FOLLOW_TRADING, + }); + + expect(result.custom_slippage).toBe(true); + }); + + it('should handle STX enabled', () => { + const stxEnabledHistoryItem: BridgeHistoryItem = { + ...mockHistoryItem, + isStxEnabled: true, + }; + const result = getRequestMetadataFromHistory(stxEnabledHistoryItem); + expect(result.stx_enabled).toBe(true); + }); + + it('should handle different swap types', () => { + // Same chain swap + const sameChainHistoryItem: BridgeHistoryItem = { + ...mockHistoryItem, + quote: { + ...mockHistoryItem.quote, + srcChainId: 1, + destChainId: 1, + }, + }; + const sameChainResult = + getRequestMetadataFromHistory(sameChainHistoryItem); + expect(sameChainResult.swap_type).toBe('single_chain'); + + // Cross chain swap (already tested in the main test) + expect(mockHistoryItem.quote.srcChainId).not.toBe( + mockHistoryItem.quote.destChainId, + ); + }); + }); + + describe('getPreConfirmationPropertiesFromQuote', () => { + it('should include both ab_tests and active_ab_tests when both sets are provided', () => { + const abTests = { token_details_layout: 'treatment' }; + const activeAbTests = [ + { key: 'bridge_quote_sorting', value: 'variant_b' }, + ]; + const result = getPreConfirmationPropertiesFromQuote( + { + ...{ + quote: mockHistoryItem.quote, + estimatedProcessingTimeInSeconds: 900, + }, + ...{ + adjustedReturn: { usd: '1980' }, + sentAmount: { usd: '2000' }, + gasFee: { effective: { usd: '2.54739' } }, + }, + } as never, + false, + null, + MetaMetricsSwapsEventSource.MainView, + abTests, + activeAbTests, + ); + + expect(result).toStrictEqual( + expect.objectContaining({ + ab_tests: abTests, + active_ab_tests: activeAbTests, + }), + ); + }); + + it('should use the explicit slippage context when provided', () => { + const result = getPreConfirmationPropertiesFromQuote( + { + quote: mockHistoryItem.quote, + estimatedProcessingTimeInSeconds: 900, + adjustedReturn: { usd: '1980' }, + sentAmount: { usd: '2000' }, + gasFee: { effective: { usd: '2.54739' } }, + } as never, + false, + null, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + { + ...getQuotesReceivedProperties(null), + custom_slippage: true, + slippage_limit: 3.5, + } as never, + ); + + expect(result.custom_slippage).toBe(true); + expect(result.slippage_limit).toBe(3.5); + }); + }); + + describe('getEVMSwapTxPropertiesFromTransactionMeta', () => { + const mockTransactionMeta: TransactionMeta = { + id: 'test-tx-id', + networkClientId: 'test-network', + status: 'submitted' as TransactionStatus, + time: 1234567890, + txParams: { + from: '0x123', + to: '0x456', + value: '0x0', + }, + chainId: '0x1', + sourceTokenSymbol: 'ETH', + destinationTokenSymbol: 'USDC', + sourceTokenAddress: '0x0000000000000000000000000000000000000000', + destinationTokenAddress: '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48', + type: TransactionType.swap, + }; + + it('should return correct properties for a successful swap transaction', () => { + const result = getEVMTxPropertiesFromTransactionMeta(mockTransactionMeta); + expect(result).toStrictEqual({ + error_message: 'Transaction submitted', + chain_id_source: 'eip155:1', + chain_id_destination: 'eip155:1', + token_symbol_source: 'ETH', + token_symbol_destination: 'USDC', + usd_amount_source: 0, + source_transaction: 'COMPLETE', + stx_enabled: false, + token_address_source: 'eip155:1/slip44:60', + token_address_destination: + 'eip155:1/erc20:0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48', + token_security_type_destination: null, + custom_slippage: false, + is_hardware_wallet: false, + account_hardware_type: null, + swap_type: MetricsSwapType.SINGLE, + security_warnings: [], + slippage_limit: 0, + price_impact: 0, + usd_quoted_gas: 0, + gas_included: false, + gas_included_7702: false, + quoted_time_minutes: 0, + usd_quoted_return: 0, + provider: '', + actual_time_minutes: 0, + quote_vs_execution_ratio: 0, + quoted_vs_used_gas_ratio: 0, + usd_actual_return: 0, + usd_actual_gas: 0, + action_type: MetricsActionType.SWAPBRIDGE_V1, + }); + }); + + it('should handle failed transaction with error message', () => { + const failedTransactionMeta: TransactionMeta = { + ...mockTransactionMeta, + status: TransactionStatus.failed, + error: { + message: 'Error message', + name: 'Error', + } as TransactionError, + }; + const result = getEVMTxPropertiesFromTransactionMeta( + failedTransactionMeta, + ); + expect(result.error_message).toBe('Transaction failed. Error message'); + expect(result.source_transaction).toBe('FAILED'); + }); + + it('should handle missing token symbols', () => { + const noSymbolsTransactionMeta: TransactionMeta = { + ...mockTransactionMeta, + sourceTokenSymbol: undefined, + destinationTokenSymbol: undefined, + }; + const result = getEVMTxPropertiesFromTransactionMeta( + noSymbolsTransactionMeta, + ); + expect(result.token_symbol_source).toBe(''); + expect(result.token_symbol_destination).toBe(''); + }); + + it('should handle missing token addresses', () => { + const noAddressesTransactionMeta: TransactionMeta = { + ...mockTransactionMeta, + sourceTokenAddress: undefined, + destinationTokenAddress: undefined, + }; + const result = getEVMTxPropertiesFromTransactionMeta( + noAddressesTransactionMeta, + ); + expect(result.token_address_source).toBe('eip155:1/slip44:60'); + expect(result.token_address_destination).toBe('eip155:1/slip44:60'); + }); + + it('should handle invalid token addresses', () => { + const noAddressesTransactionMeta: TransactionMeta = { + ...mockTransactionMeta, + sourceTokenAddress: 'fsdxfs', + destinationTokenAddress: 'fsdxfs', + }; + const result = getEVMTxPropertiesFromTransactionMeta( + noAddressesTransactionMeta, + ); + expect(result.token_address_source).toBe(''); + expect(result.token_address_destination).toBe(''); + }); + + it('should handle crosschain swap type', () => { + const crosschainTransactionMeta: TransactionMeta = { + ...mockTransactionMeta, + type: TransactionType.swap, + }; + const result = getEVMTxPropertiesFromTransactionMeta( + crosschainTransactionMeta, + ); + expect(result.swap_type).toBe(MetricsSwapType.SINGLE); + }); + }); +}); diff --git a/packages/bridge-status-controller/src/utils/metrics.ts b/packages/bridge-status-controller/src/utils/metrics.ts new file mode 100644 index 00000000000..071b5abcf17 --- /dev/null +++ b/packages/bridge-status-controller/src/utils/metrics.ts @@ -0,0 +1,371 @@ +/* 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, + getAccountHardwareType, + formatChainIdToHex, + isEthUsdt, + formatChainIdToCaip, + formatProviderLabel, + isCustomSlippage, + getSwapType, + formatAddressToAssetId, + MetricsActionType, + MetricsSwapType, + MetaMetricsSwapsEventSource, + FeatureId, + UnifiedSwapBridgeEventName, +} from '@metamask/bridge-controller'; +import type { + AccountHardwareType, + QuoteFetchData, + QuoteMetadata, + QuoteResponseV1, + TxStatusData, + RequestParams, + TradeData, + RequestMetadata, + BatchSellTradesResponse, + RequiredEventContextFromClient, +} from '@metamask/bridge-controller'; +import { + TransactionStatus, + TransactionType, +} from '@metamask/transaction-controller'; +import type { TransactionMeta } from '@metamask/transaction-controller'; +import type { CaipAssetType, Hex } from '@metamask/utils'; +import { BigNumber } from 'bignumber.js'; + +import type { BridgeHistoryItem } from '../types.js'; +import { calcActualGasUsed } from './gas.js'; +import { + getActualBridgeReceivedAmount, + getActualSwapReceivedAmount, +} from './swap-received-amount.js'; + +export const getTxStatusesFromHistory = ({ + status, + hasApprovalTx, + approvalTxId, + quote, +}: BridgeHistoryItem): TxStatusData => { + const source_transaction = status.srcChain.txHash + ? StatusTypes.COMPLETE + : StatusTypes.PENDING; + const destination_transaction = status.destChain?.txHash + ? status.status + : StatusTypes.PENDING; + + const hexChainId = formatChainIdToHex(quote.srcChainId); + const isEthUsdtTx = isEthUsdt(hexChainId, quote.srcAsset.address); + const allowance_reset_transaction = status.srcChain.txHash + ? StatusTypes.COMPLETE + : undefined; + const approval_transaction = status.srcChain.txHash + ? StatusTypes.COMPLETE + : StatusTypes.PENDING; + + return { + allowance_reset_transaction: isEthUsdtTx + ? allowance_reset_transaction + : undefined, + approval_transaction: + hasApprovalTx || approvalTxId ? approval_transaction : undefined, + source_transaction, + destination_transaction: + status.status === StatusTypes.FAILED + ? StatusTypes.FAILED + : destination_transaction, + }; +}; + +/** + * Calculate the properties for a finalized transaction event based on the txHistory + * and txMeta + * + * @param historyItem - The bridge history item + * @param txMeta - The transaction meta from the TransactionController + * @param approvalTxMeta - The approval transaction meta from the TransactionController + * @returns The properties for the finalized transaction + */ +export const getFinalizedTxProperties = ( + historyItem: BridgeHistoryItem, + txMeta?: TransactionMeta, + approvalTxMeta?: TransactionMeta, +) => { + const startTime = + approvalTxMeta?.submittedTime ?? + txMeta?.submittedTime ?? + historyItem.startTime; + const completionTime = + txMeta?.type === TransactionType.swap + ? txMeta?.time + : historyItem.completionTime; + + const actualGas = calcActualGasUsed( + historyItem, + txMeta?.txReceipt, + approvalTxMeta?.txReceipt, + ); + + const actualReturn = + txMeta?.type === TransactionType.swap + ? getActualSwapReceivedAmount(historyItem, actualGas, txMeta) + : getActualBridgeReceivedAmount(historyItem); + + const quotedVsUsedGasRatio = + historyItem.pricingData?.quotedGasAmount && actualGas?.amount + ? new BigNumber(historyItem.pricingData.quotedGasAmount) + .multipliedBy(new BigNumber(10).pow(18)) + .div(actualGas.amount) + .toNumber() + : 0; + + const quoteVsExecutionRatio = + historyItem.pricingData?.quotedReturnInUsd && actualReturn?.usd + ? new BigNumber(historyItem.pricingData.quotedReturnInUsd) + .div(actualReturn.usd) + .toNumber() + : 0; + + return { + actual_time_minutes: + completionTime && startTime ? (completionTime - startTime) / 60000 : 0, + usd_actual_return: Number(actualReturn?.usd ?? 0), + usd_actual_gas: actualGas?.usd ?? 0, + quote_vs_execution_ratio: quoteVsExecutionRatio, + quoted_vs_used_gas_ratio: quotedVsUsedGasRatio, + }; +}; + +export const getRequestParamFromHistory = ( + historyItem: BridgeHistoryItem, +): RequestParams => { + return { + chain_id_source: formatChainIdToCaip(historyItem.quote.srcChainId), + token_symbol_source: historyItem.quote.srcAsset.symbol, + token_address_source: historyItem.quote.srcAsset.assetId, + chain_id_destination: formatChainIdToCaip(historyItem.quote.destChainId), + token_symbol_destination: historyItem.quote.destAsset.symbol, + token_address_destination: historyItem.quote.destAsset.assetId, + token_security_type_destination: + historyItem.tokenSecurityTypeDestination ?? null, + }; +}; + +export const getTradeDataFromQuote = ( + quoteResponse: QuoteResponseV1 & QuoteMetadata, + batchSellTrades?: BatchSellTradesResponse | null, +): TradeData => { + return { + usd_quoted_gas: Number(quoteResponse.gasFee?.total?.usd ?? 0), + gas_included: + quoteResponse.quote.gasIncluded ?? batchSellTrades?.gasIncluded ?? false, + gas_included_7702: + quoteResponse.quote.gasIncluded7702 ?? + batchSellTrades?.gasIncluded7702 ?? + false, + provider: formatProviderLabel(quoteResponse.quote), + quoted_time_minutes: Number( + quoteResponse.estimatedProcessingTimeInSeconds / 60, + ), + usd_quoted_return: Number(quoteResponse?.adjustedReturn?.usd ?? 0), + }; +}; + +export const getPriceImpactFromQuote = ( + quote: QuoteResponseV1['quote'], +): Pick => { + return { price_impact: Number(quote.priceData?.priceImpact ?? '0') }; +}; + +/** + * Before the tx is confirmed, its data is not available in txHistory + * The quote is used to populate event properties before confirmation + * + * @param quoteResponse - The quote response + * @param isStxEnabled - Whether smart transactions are enabled on the client, for example the getSmartTransactionsEnabled selector value from the extension + * @param accountHardwareType - The hardware wallet type used to submit the tx, or null if not 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) + * @param abTests - Legacy A/B test context for `ab_tests` (backward compatibility) + * @param activeAbTests - New A/B test context for `active_ab_tests` (migration target) + * @param tokenSecurityTypeDestination - The security classification of the destination token, supplied by the client (e.g. from token security/scanning data). Pass `null` when no security data is available. + * @param batchSellTrades - The batch sell trades response + * @param batchId - The batch ID of the transaction batch. + * @param quotesReceivedContext - The client context captured when quotes were received. + * @returns The properties for the pre-confirmation event + */ +export const getPreConfirmationPropertiesFromQuote = ( + quoteResponse: QuoteResponseV1 & QuoteMetadata, + isStxEnabled: boolean, + accountHardwareType: AccountHardwareType, + location?: MetaMetricsSwapsEventSource, + abTests?: Record, + activeAbTests?: { key: string; value: string }[], + tokenSecurityTypeDestination?: string | null, + batchSellTrades?: BatchSellTradesResponse | null, + batchId?: Hex, + quotesReceivedContext?: RequiredEventContextFromClient[UnifiedSwapBridgeEventName.QuotesReceived], +) => { + const { quote } = quoteResponse; + return { + ...getPriceImpactFromQuote(quote), + ...getTradeDataFromQuote(quoteResponse, batchSellTrades), + chain_id_source: formatChainIdToCaip(quote.srcChainId), + token_symbol_source: quote.srcAsset.symbol, + token_address_source: quote.srcAsset.assetId, + chain_id_destination: formatChainIdToCaip(quote.destChainId), + token_symbol_destination: quote.destAsset.symbol, + token_address_destination: quote.destAsset.assetId, + token_security_type_destination: tokenSecurityTypeDestination ?? null, + account_hardware_type: accountHardwareType, + is_hardware_wallet: accountHardwareType !== null, + swap_type: getSwapType( + quoteResponse.quote.srcChainId, + quoteResponse.quote.destChainId, + ), + usd_amount_source: Number(quoteResponse?.sentAmount?.usd ?? 0), + stx_enabled: isStxEnabled, + action_type: MetricsActionType.SWAPBRIDGE_V1, + slippage_limit: + quotesReceivedContext?.slippage_limit ?? quote.slippage ?? 0, + custom_slippage: quotesReceivedContext?.custom_slippage ?? false, + location, + ...(abTests && + Object.keys(abTests).length > 0 && { + ab_tests: abTests, + }), + ...(activeAbTests && + activeAbTests.length > 0 && { + active_ab_tests: activeAbTests, + }), + ...(batchId ? { batch_id: batchId } : {}), + feature_id: quoteResponse.featureId ?? FeatureId.UNIFIED_SWAP_BRIDGE, + }; +}; + +export const getTradeDataFromHistory = ( + historyItem: BridgeHistoryItem, +): TradeData => { + return { + usd_quoted_gas: Number(historyItem.pricingData?.quotedGasInUsd ?? 0), + gas_included: historyItem.quote.gasIncluded ?? false, + gas_included_7702: historyItem.quote.gasIncluded7702 ?? false, + provider: formatProviderLabel(historyItem.quote), + quoted_time_minutes: Number( + historyItem.estimatedProcessingTimeInSeconds / 60, + ), + usd_quoted_return: Number(historyItem.pricingData?.quotedReturnInUsd ?? 0), + }; +}; + +export const getRequestMetadataFromHistory = ( + historyItem: BridgeHistoryItem, + account?: AccountsControllerState['internalAccounts']['accounts'][string], +): RequestMetadata => { + const { + quote, + slippagePercentage, + isStxEnabled, + customSlippage, + batchSellData, + featureId, + } = historyItem; + const accountHardwareType = getAccountHardwareType(account); + const isBatchSell = + Boolean(batchSellData) || featureId === FeatureId.BATCH_SELL; + const isUnifiedSwapBridge = + featureId === undefined || featureId === FeatureId.UNIFIED_SWAP_BRIDGE; + let inferredCustomSlippage = false; + if (isBatchSell) { + inferredCustomSlippage = isCustomSlippage(slippagePercentage ?? 0); + } else if (!isUnifiedSwapBridge) { + inferredCustomSlippage = isCustomSlippage(slippagePercentage); + } + + return { + slippage_limit: slippagePercentage ?? 0, + custom_slippage: customSlippage ?? inferredCustomSlippage, + usd_amount_source: Number(historyItem.pricingData?.amountSentInUsd ?? 0), + swap_type: getSwapType(quote.srcChainId, quote.destChainId), + account_hardware_type: accountHardwareType, + is_hardware_wallet: accountHardwareType !== null, + stx_enabled: isStxEnabled ?? false, + security_warnings: [], + }; +}; + +/** + * Get the properties for a swap transaction that is not in the txHistory + * + * @param transactionMeta - The transaction meta + * @param account - The account that submitted the transaction + * @returns The properties for the swap transaction + */ +export const getEVMTxPropertiesFromTransactionMeta = ( + transactionMeta: TransactionMeta, + account?: AccountsControllerState['internalAccounts']['accounts'][string], +) => { + const accountHardwareType = getAccountHardwareType(account); + + return { + source_transaction: [ + TransactionStatus.failed, + TransactionStatus.dropped, + TransactionStatus.rejected, + ].includes(transactionMeta.status) + ? StatusTypes.FAILED + : StatusTypes.COMPLETE, + error_message: [ + `Transaction ${transactionMeta.status}`, + transactionMeta.error?.message, + ] + .filter(Boolean) + .join('. '), + chain_id_source: formatChainIdToCaip(transactionMeta.chainId), + chain_id_destination: formatChainIdToCaip(transactionMeta.chainId), + token_symbol_source: transactionMeta.sourceTokenSymbol ?? '', + token_symbol_destination: transactionMeta.destinationTokenSymbol ?? '', + usd_amount_source: 0, + slippage_limit: 0, + stx_enabled: false, + token_address_source: + formatAddressToAssetId( + transactionMeta.sourceTokenAddress ?? '', + transactionMeta.chainId, + ) ?? ('' as CaipAssetType), + token_address_destination: + formatAddressToAssetId( + transactionMeta.destinationTokenAddress ?? '', + transactionMeta.chainId, + ) ?? ('' as CaipAssetType), + token_security_type_destination: null, + custom_slippage: false, + account_hardware_type: accountHardwareType, + is_hardware_wallet: accountHardwareType !== null, + swap_type: + transactionMeta.type && + [TransactionType.swap, TransactionType.swapApproval].includes( + transactionMeta.type, + ) + ? MetricsSwapType.SINGLE + : MetricsSwapType.CROSSCHAIN, + security_warnings: [], + price_impact: 0, + usd_quoted_gas: 0, + gas_included: false, + gas_included_7702: false, + quoted_time_minutes: 0, + usd_quoted_return: 0, + provider: '' as `${string}_${string}`, + actual_time_minutes: 0, + quote_vs_execution_ratio: 0, + quoted_vs_used_gas_ratio: 0, + usd_actual_return: 0, + usd_actual_gas: 0, + action_type: MetricsActionType.SWAPBRIDGE_V1, + ...(transactionMeta.batchId ? { batch_id: transactionMeta.batchId } : {}), + }; +}; diff --git a/packages/bridge-status-controller/src/utils/network.ts b/packages/bridge-status-controller/src/utils/network.ts new file mode 100644 index 00000000000..e0ef393d277 --- /dev/null +++ b/packages/bridge-status-controller/src/utils/network.ts @@ -0,0 +1,43 @@ +/* eslint-disable @typescript-eslint/explicit-function-return-type */ +import { formatChainIdToHex } from '@metamask/bridge-controller'; +import type { GenericQuoteRequest } from '@metamask/bridge-controller'; +import type { NetworkClient } from '@metamask/network-controller'; + +import type { BridgeStatusControllerMessenger } from '../types.js'; + +export const getSelectedChainId = ( + messenger: BridgeStatusControllerMessenger, +) => { + const { selectedNetworkClientId } = messenger.call( + 'NetworkController:getState', + ); + const networkClient = messenger.call( + 'NetworkController:getNetworkClientById', + selectedNetworkClientId, + ); + return networkClient.configuration.chainId; +}; + +export const getNetworkClientIdByChainId = ( + messenger: BridgeStatusControllerMessenger, + chainId: GenericQuoteRequest['srcChainId'], +) => { + const hexChainId = formatChainIdToHex(chainId); + return messenger.call( + 'NetworkController:findNetworkClientIdByChainId', + hexChainId, + ); +}; + +export const getNetworkClientByChainId = ( + messenger: BridgeStatusControllerMessenger, + chainId: GenericQuoteRequest['srcChainId'], +): NetworkClient['provider'] => { + const networkClientId = getNetworkClientIdByChainId(messenger, chainId); + + const networkClient = messenger.call( + 'NetworkController:getNetworkClientById', + networkClientId, + ); + return networkClient.provider; +}; diff --git a/packages/bridge-status-controller/src/utils/snaps.test.ts b/packages/bridge-status-controller/src/utils/snaps.test.ts new file mode 100644 index 00000000000..56a0a5d1a8a --- /dev/null +++ b/packages/bridge-status-controller/src/utils/snaps.test.ts @@ -0,0 +1,358 @@ +import { ChainId } from '@metamask/bridge-controller'; +/* eslint-disable consistent-return */ +import { v4 as uuid } from 'uuid'; + +import { BridgeStatusControllerMessenger } from '../types.js'; +import { createClientTransactionRequest, handleNonEvmTx } from './snaps.js'; + +jest.mock('uuid', () => ({ + v4: jest.fn(), +})); + +describe('Snaps Utils', () => { + beforeEach(() => { + jest.clearAllMocks(); + (uuid as jest.Mock).mockReturnValue('test-uuid-1234'); + }); + + describe('handleNonEvmTx', () => { + it.each([ + { + snapResponse: { + result: { + signature: 'solanaSignature123', + }, + }, + label: 'result.signature', + }, + { + snapResponse: { + result: { + txid: 'solanaSignature123', + }, + }, + label: 'result.txid', + }, + { + snapResponse: { + result: { + hash: 'solanaSignature123', + }, + }, + label: 'result.hash', + }, + { + snapResponse: { + result: { + txHash: 'solanaSignature123', + }, + }, + label: 'result.txHash', + }, + { + snapResponse: { + transactionId: 'solanaSignature123', + }, + label: 'transactionId', + }, + ])( + 'should submit a non-EVM transaction ({label})', + async ({ snapResponse }) => { + const snapId = 'test-snap-id'; + const transaction = 'base64-encoded-transaction'; + const accountId = 'test-account-id'; + + const mockCall = jest.fn((...args: unknown[]) => { + const [action] = args; + if (action === 'SnapController:handleRequest') { + return Promise.resolve(snapResponse); + } + }); + const messenger = { + call: (...args: unknown[]) => mockCall(...args), + } as unknown as BridgeStatusControllerMessenger; + const { time, ...result } = await handleNonEvmTx( + messenger, + transaction, + { + ...{ + quote: { + srcChainId: ChainId.SOLANA, + srcAsset: { symbol: 'SOL' }, + destAsset: { symbol: 'MATIC' }, + }, + }, + ...{ + sentAmount: { + amount: '1000000000', + }, + }, + } as never, + { id: accountId, metadata: { snap: { id: snapId } } } as never, + ); + + expect(mockCall.mock.calls).toMatchInlineSnapshot(` + [ + [ + "SnapController:handleRequest", + { + "handler": "onClientRequest", + "origin": "metamask", + "request": { + "id": "test-uuid-1234", + "jsonrpc": "2.0", + "method": "signAndSendTransaction", + "params": { + "accountId": "test-account-id", + "scope": "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp", + "transaction": "base64-encoded-transaction", + }, + }, + "snapId": "test-snap-id", + }, + ], + ] + `); + expect(result).toMatchInlineSnapshot(` + { + "approvalTxId": undefined, + "chainId": "0x416edef1601be", + "destinationChainId": "0x1", + "destinationTokenAddress": undefined, + "destinationTokenAmount": undefined, + "destinationTokenDecimals": undefined, + "destinationTokenSymbol": "MATIC", + "hash": "solanaSignature123", + "id": "solanaSignature123", + "isBridgeTx": false, + "isSolana": true, + "networkClientId": "test-snap-id", + "origin": "test-snap-id", + "sourceTokenAddress": undefined, + "sourceTokenAmount": undefined, + "sourceTokenDecimals": undefined, + "sourceTokenSymbol": "SOL", + "status": "submitted", + "swapTokenValue": "1000000000", + "txParams": { + "data": "base64-encoded-transaction", + "from": undefined, + }, + "type": "swap", + } + `); + }, + ); + + it('should submit a non-EVM transaction (no result in response)', async () => { + const snapId = 'test-snap-id'; + const transaction = 'base64-encoded-transaction'; + const accountId = 'test-account-id'; + + const mockCall = jest.fn((...args: unknown[]) => { + const [action] = args; + if (action === 'SnapController:handleRequest') { + return Promise.resolve(undefined); + } + }); + const messenger = { + call: (...args: unknown[]) => mockCall(...args), + } as unknown as BridgeStatusControllerMessenger; + const { time, ...result } = await handleNonEvmTx( + messenger, + transaction, + { + ...{ + quote: { + srcChainId: ChainId.SOLANA, + srcAsset: { symbol: 'SOL' }, + destAsset: { symbol: 'MATIC' }, + }, + }, + ...{ + sentAmount: { + amount: '1000000000', + }, + }, + } as never, + { id: accountId, metadata: { snap: { id: snapId } } } as never, + ); + + expect(mockCall.mock.calls).toMatchInlineSnapshot(` + [ + [ + "SnapController:handleRequest", + { + "handler": "onClientRequest", + "origin": "metamask", + "request": { + "id": "test-uuid-1234", + "jsonrpc": "2.0", + "method": "signAndSendTransaction", + "params": { + "accountId": "test-account-id", + "scope": "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp", + "transaction": "base64-encoded-transaction", + }, + }, + "snapId": "test-snap-id", + }, + ], + ] + `); + expect(result).toMatchInlineSnapshot(` + { + "approvalTxId": undefined, + "chainId": "0x416edef1601be", + "destinationChainId": "0x1", + "destinationTokenAddress": undefined, + "destinationTokenAmount": undefined, + "destinationTokenDecimals": undefined, + "destinationTokenSymbol": "MATIC", + "hash": undefined, + "id": "test-uuid-1234", + "isBridgeTx": false, + "isSolana": true, + "networkClientId": "test-snap-id", + "origin": "test-snap-id", + "sourceTokenAddress": undefined, + "sourceTokenAmount": undefined, + "sourceTokenDecimals": undefined, + "sourceTokenSymbol": "SOL", + "status": "submitted", + "swapTokenValue": "1000000000", + "txParams": { + "data": "base64-encoded-transaction", + "from": undefined, + }, + "type": "swap", + } + `); + }); + }); + + describe('createClientTransactionRequest', () => { + it('should create a proper request without options', () => { + const snapId = 'test-snap-id'; + const transaction = 'base64-encoded-transaction'; + const scope = 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp' as const; + const accountId = 'test-account-id'; + + const result = createClientTransactionRequest( + snapId, + transaction, + scope, + accountId, + ); + + expect(result.snapId).toBe(snapId); + expect(result.origin).toBe('metamask'); + expect(result.handler).toBe('onClientRequest'); + expect(result.request.id).toBe('test-uuid-1234'); + expect(result.request.jsonrpc).toBe('2.0'); + expect(result.request.method).toBe('signAndSendTransaction'); + expect(result.request.params.transaction).toBe(transaction); + expect(result.request.params.scope).toBe(scope); + expect(result.request.params.accountId).toBe(accountId); + expect(result.request.params).not.toHaveProperty('options'); + }); + + it('should create a proper request with options', () => { + const snapId = 'test-snap-id'; + const transaction = 'base64-encoded-transaction'; + const scope = 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp' as const; + const accountId = 'test-account-id'; + const options = { + skipPreflight: true, + maxRetries: 3, + }; + + const result = createClientTransactionRequest( + snapId, + transaction, + scope, + accountId, + options, + ); + + expect(result.snapId).toBe(snapId); + expect(result.origin).toBe('metamask'); + expect(result.handler).toBe('onClientRequest'); + expect(result.request.id).toBe('test-uuid-1234'); + expect(result.request.jsonrpc).toBe('2.0'); + expect(result.request.method).toBe('signAndSendTransaction'); + expect(result.request.params.transaction).toBe(transaction); + expect(result.request.params.scope).toBe(scope); + expect(result.request.params.accountId).toBe(accountId); + expect(result.request.params.options).toStrictEqual(options); + }); + + it('should handle different chain scopes', () => { + const snapId = 'test-snap-id'; + const transaction = 'base64-encoded-transaction'; + const tronScope = 'tron:0x2b6653dc' as const; + const accountId = 'test-account-id'; + + const result = createClientTransactionRequest( + snapId, + transaction, + tronScope, + accountId, + ); + + expect(result.request.params.scope).toBe(tronScope); + }); + + it('should not include options key when options is undefined', () => { + const snapId = 'test-snap-id'; + const transaction = 'base64-encoded-transaction'; + const scope = 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp' as const; + const accountId = 'test-account-id'; + + const result = createClientTransactionRequest( + snapId, + transaction, + scope, + accountId, + undefined, + ); + + expect(result.request.params).not.toHaveProperty('options'); + }); + + it('should not include options key when options is null', () => { + const snapId = 'test-snap-id'; + const transaction = 'base64-encoded-transaction'; + const scope = 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp' as const; + const accountId = 'test-account-id'; + + const result = createClientTransactionRequest( + snapId, + transaction, + scope, + accountId, + null as unknown as Record, + ); + + expect(result.request.params).not.toHaveProperty('options'); + }); + + it('should include options key when options is empty object', () => { + const snapId = 'test-snap-id'; + const transaction = 'base64-encoded-transaction'; + const scope = 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp' as const; + const accountId = 'test-account-id'; + + const result = createClientTransactionRequest( + snapId, + transaction, + scope, + accountId, + {}, + ); + + expect(result.request.params).toHaveProperty('options'); + expect(result.request.params.options).toStrictEqual({}); + }); + }); +}); diff --git a/packages/bridge-status-controller/src/utils/snaps.ts b/packages/bridge-status-controller/src/utils/snaps.ts new file mode 100644 index 00000000000..1117a074cd5 --- /dev/null +++ b/packages/bridge-status-controller/src/utils/snaps.ts @@ -0,0 +1,298 @@ +/* eslint-disable @typescript-eslint/explicit-function-return-type */ +import type { AccountsControllerState } from '@metamask/accounts-controller'; +import type { + QuoteMetadata, + QuoteResponseV1, + Trade, +} from '@metamask/bridge-controller'; +import { + extractTradeData, + formatChainIdToCaip, + formatChainIdToHex, + isCrossChain, + isStellarTrade, + isTronTrade, +} from '@metamask/bridge-controller'; +import { SnapController } from '@metamask/snaps-controllers'; +import { + CHAIN_IDS, + TransactionStatus, + TransactionType, +} from '@metamask/transaction-controller'; +import type { TransactionMeta } from '@metamask/transaction-controller'; +import type { CaipAssetType, CaipChainId, Hex } from '@metamask/utils'; +import { v4 as uuid } from 'uuid'; + +import type { + BridgeStatusControllerMessenger, + SolanaTransactionMeta, +} from '../types.js'; + +/** + * Creates a client request object for signing and sending a transaction + * Works for Solana, BTC, Tron, and other non-EVM networks + * + * @param snapId - The snap ID to send the request to + * @param transaction - The base64 encoded transaction string + * @param scope - The CAIP-2 chain scope + * @param accountId - The account ID + * @param options - Optional network-specific options + * @returns The snap request object + */ +export const createClientTransactionRequest = ( + snapId: string, + transaction: string, + scope: CaipChainId, + accountId: string, + options?: Record, +) => { + return { + // TODO: remove 'as never' typing. + snapId: snapId as never, + origin: 'metamask', + handler: 'onClientRequest' as never, + request: { + id: uuid(), + jsonrpc: '2.0', + method: 'signAndSendTransaction', + params: { + transaction, + scope, + accountId, + ...(options && { options }), + }, + }, + }; +}; + +/** + * Creates a request to sign and send a transaction for non-EVM chains + * Uses the new unified ClientRequest:signAndSendTransaction interface + * + * @param trade - The trade data + * @param srcChainId - The source chain ID + * @param accountId - The account ID + * @param snapId - The snap ID + * @param sourceAssetId - The source asset ID + * @param destAssetId - The destination asset ID + * @returns The snap request object for signing and sending transaction + */ +export const getClientRequest = ( + trade: Trade, + srcChainId: number, + accountId: AccountsControllerState['internalAccounts']['accounts'][string]['id'], + snapId: string, + sourceAssetId?: CaipAssetType, + destAssetId?: CaipAssetType, +): Parameters[0] => { + const scope = formatChainIdToCaip(srcChainId); + + const transaction = extractTradeData(trade); + + let options: Record | undefined; + + // Only Stellar trades expect asset IDs in the request options. Passing them + // for other non-EVM chains (e.g. Bitcoin) breaks strict snap request + // validation and prevents the transaction from being broadcast. + if (isStellarTrade(trade)) { + if (sourceAssetId !== undefined || destAssetId !== undefined) { + options = { + ...(sourceAssetId !== undefined && { + sourceAssetId, + }), + ...(destAssetId !== undefined && { + destAssetId, + }), + }; + } + } + + if (isTronTrade(trade)) { + // Tron trades need the visible flag and contract type to be included in the request options + options = { + visible: trade.visible, + type: trade.raw_data?.contract?.[0]?.type, + }; + } + + return createClientTransactionRequest( + snapId, + transaction, + scope, + accountId, + options, + ); +}; + +export const getTxMetaFields = ( + quoteResponse: Omit, 'approval' | 'trade'> & + QuoteMetadata, + approvalTxId?: string, +): Omit< + TransactionMeta, + 'networkClientId' | 'status' | 'time' | 'txParams' | 'id' | 'chainId' +> => { + // Handle destination chain ID - should always be convertible for EVM destinations + let destinationChainId; + try { + destinationChainId = formatChainIdToHex(quoteResponse.quote.destChainId); + } catch { + // Fallback for non-EVM destination (shouldn't happen for BTC->EVM) + destinationChainId = CHAIN_IDS.MAINNET; // Default to mainnet + } + + return { + destinationChainId, + sourceTokenAmount: quoteResponse.quote.srcTokenAmount, + sourceTokenSymbol: quoteResponse.quote.srcAsset.symbol, + sourceTokenDecimals: quoteResponse.quote.srcAsset.decimals, + sourceTokenAddress: quoteResponse.quote.srcAsset.address, + + destinationTokenAmount: quoteResponse.quote.destTokenAmount, + destinationTokenSymbol: quoteResponse.quote.destAsset.symbol, + destinationTokenDecimals: quoteResponse.quote.destAsset.decimals, + destinationTokenAddress: quoteResponse.quote.destAsset.address, + + // chainId is now excluded from this function and handled by the caller + approvalTxId, + // this is the decimal (non atomic) amount (not USD value) of source token to swap + swapTokenValue: quoteResponse?.sentAmount?.amount, + }; +}; + +/** + * Handles the response from non-EVM transaction submission + * Works with the new unified ClientRequest:signAndSendTransaction interface + * Supports Solana, Bitcoin, and other non-EVM chains + * + * @param snapResponse - The response from the snap after transaction submission + * @param trade - The non-evm trade or approval data + * @param quoteResponse - The quote response containing trade details and metadata + * @param selectedAccount - The selected account information + * @returns The transaction metadata including non-EVM specific fields + */ +export const handleNonEvmTxResponse = ( + snapResponse: + | string + | { transactionId: string } // New unified interface response + | { result: Record } + | { signature: string }, + trade: Trade, + quoteResponse: Omit, 'trade' | 'approval'> & + QuoteMetadata, + selectedAccount: AccountsControllerState['internalAccounts']['accounts'][string], +): TransactionMeta & SolanaTransactionMeta => { + const selectedAccountAddress = selectedAccount.address; + const snapId = selectedAccount.metadata.snap?.id; + let hash; + // Handle different response formats + if (typeof snapResponse === 'string') { + hash = snapResponse; + } else if (snapResponse && typeof snapResponse === 'object') { + // Check for new unified interface response format first + if ('transactionId' in snapResponse && snapResponse.transactionId) { + hash = snapResponse.transactionId; + } else if ( + 'result' in snapResponse && + snapResponse.result && + typeof snapResponse.result === 'object' + ) { + // Try to extract signature from common locations in response object + hash = + snapResponse.result.signature || + snapResponse.result.txid || + snapResponse.result.hash || + snapResponse.result.txHash; + } else if ( + 'signature' in snapResponse && + snapResponse.signature && + typeof snapResponse.signature === 'string' + ) { + hash = snapResponse.signature; + } + } + + const isBridgeTx = isCrossChain( + quoteResponse.quote.srcChainId, + quoteResponse.quote.destChainId, + ); + + let hexChainId: Hex; + try { + hexChainId = formatChainIdToHex(quoteResponse.quote.srcChainId); + } catch { + hexChainId = '0x1'; + } + // Extract the transaction data for storage + const tradeData = extractTradeData(trade); + + // Create a transaction meta object with bridge-specific fields + return { + ...getTxMetaFields(quoteResponse), + time: Date.now(), + id: hash ?? uuid(), + chainId: hexChainId, + networkClientId: snapId ?? 'mainnet', + txParams: { from: selectedAccountAddress, data: tradeData }, + type: isBridgeTx ? TransactionType.bridge : TransactionType.swap, + status: TransactionStatus.submitted, + hash, // Add the transaction signature as hash + origin: snapId, + // Add an explicit flag to mark this as a non-EVM transaction + isSolana: true, // TODO deprecate this and use chainId to detect non-EVM chains + isBridgeTx, + }; +}; + +/** + * Submits the transaction to the snap using the new unified ClientRequest interface + * Works for all non-EVM chains (Solana, BTC, Tron) + * This adds an approval tx to the ApprovalsController in the background + * The client needs to handle the approval tx by redirecting to the confirmation page with the approvalTxId in the URL + * + * @param messenger - The BridgeStatusControllerMessenger instance + * @param trade - The trade data (can be approval or main trade) + * @param quoteResponse - The quote response containing metadata + * @param selectedAccount - The account to submit the transaction for + * @returns The transaction meta + */ +export const handleNonEvmTx = async ( + messenger: BridgeStatusControllerMessenger, + trade: Trade, + quoteResponse: QuoteResponseV1 & QuoteMetadata, + selectedAccount: AccountsControllerState['internalAccounts']['accounts'][string], +): Promise => { + if (!selectedAccount.metadata?.snap?.id) { + throw new Error( + 'Failed to submit cross-chain swap transaction: undefined snap id', + ); + } + + const request = getClientRequest( + trade, + quoteResponse.quote.srcChainId, + selectedAccount.id, + selectedAccount.metadata?.snap?.id, + quoteResponse.quote.srcAsset.assetId, + quoteResponse.quote.destAsset.assetId, + ); + const requestResponse = (await messenger.call( + 'SnapController:handleRequest', + request, + )) as + | string + | { transactionId: string } + | { result: Record } + | { signature: string }; + + const txMeta = handleNonEvmTxResponse( + requestResponse, + trade, + quoteResponse, + selectedAccount, + ); + + // TODO remove this eventually, just returning it now to match extension behavior + // OR if the snap can propagate the snapRequestId or keyringReqId to the ApprovalsController, this can return the approvalTxId instead and clients won't need to subscribe to the ApprovalsController state to redirect + return txMeta; +}; diff --git a/packages/bridge-status-controller/src/utils/swap-received-amount.ts b/packages/bridge-status-controller/src/utils/swap-received-amount.ts new file mode 100644 index 00000000000..9827fb41c74 --- /dev/null +++ b/packages/bridge-status-controller/src/utils/swap-received-amount.ts @@ -0,0 +1,135 @@ +import type { TokenAmountValues } from '@metamask/bridge-controller'; +import { isNativeAddress } from '@metamask/bridge-controller'; +import type { TransactionMeta } from '@metamask/transaction-controller'; +import { BigNumber } from 'bignumber.js'; + +import type { BridgeHistoryItem } from '../types.js'; + +const getReceivedNativeAmount = ( + historyItem: BridgeHistoryItem, + actualGas: Omit | null, + txMeta: TransactionMeta, +) => { + const { preTxBalance, postTxBalance } = txMeta; + + if (!preTxBalance || !postTxBalance || preTxBalance === postTxBalance) { + // If preTxBalance and postTxBalance are equal, postTxBalance hasn't been updated on time + // because of the RPC provider delay, so we return an estimated receiving amount instead. + return new BigNumber(historyItem.quote.destTokenAmount) + .div(new BigNumber(10).pow(historyItem.quote.destAsset.decimals)) + .toString(10); + } + + return actualGas && postTxBalance && preTxBalance + ? new BigNumber(postTxBalance, 16) + .minus(preTxBalance, 16) + .minus(actualGas.amount) + .div(10 ** historyItem.quote.destAsset.decimals) + : null; +}; + +const getReceivedERC20Amount = ( + historyItem: BridgeHistoryItem, + txMeta: TransactionMeta, +) => { + const { txReceipt } = txMeta; + if (!txReceipt?.logs || txReceipt.status === '0x0') { + return null; + } + const { account: accountAddress, quote } = historyItem; + + const TOKEN_TRANSFER_LOG_TOPIC_HASH = + '0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef'; + + const tokenTransferLog = txReceipt.logs.find((txReceiptLog) => { + const isTokenTransfer = txReceiptLog.topics?.[0]?.startsWith( + TOKEN_TRANSFER_LOG_TOPIC_HASH, + ); + const isTransferFromGivenToken = + txReceiptLog.address?.toLowerCase() === + quote.destAsset.address?.toLowerCase(); + const isTransferFromGivenAddress = + txReceiptLog.topics?.[2] && + (txReceiptLog.topics[2] === accountAddress || + txReceiptLog.topics[2].match(accountAddress?.slice(2))); + + return ( + isTokenTransfer && isTransferFromGivenToken && isTransferFromGivenAddress + ); + }); + + if (tokenTransferLog?.data) { + return new BigNumber(tokenTransferLog.data, 16).div( + new BigNumber(10).pow(quote.destAsset.decimals), + ); + } + + return null; +}; + +/** + * Calculate the amount received after a swap transaction based on the txMeta + * + * @param historyItem - The bridge history item + * @param actualGas - The actual gas used for the transaction + * @param txMeta - The transaction meta from the TransactionController + * @returns The actual amount received for the swap transaction + */ +export const getActualSwapReceivedAmount = ( + historyItem: BridgeHistoryItem, + actualGas: Omit | null, + txMeta?: TransactionMeta, +) => { + const { pricingData } = historyItem; + const quotedReturnAmount = historyItem.quote.destTokenAmount; + + if (!txMeta?.txReceipt) { + return null; + } + + const actualReturnAmount = isNativeAddress( + historyItem.quote.destAsset.address, + ) + ? getReceivedNativeAmount(historyItem, actualGas, txMeta) + : getReceivedERC20Amount(historyItem, txMeta); + + const returnUsdExchangeRate = + pricingData?.quotedReturnInUsd && quotedReturnAmount + ? new BigNumber(pricingData.quotedReturnInUsd) + .div(quotedReturnAmount) + .multipliedBy(10 ** historyItem.quote.destAsset.decimals) + : null; + + return { + amount: actualReturnAmount, + usd: + actualReturnAmount && returnUsdExchangeRate + ? returnUsdExchangeRate.multipliedBy(actualReturnAmount) + : null, + }; +}; + +/** + * Calculate the amount received after a bridge transaction based on the getTxStatus's + * amount field + * + * @param historyItem - The bridge history item + * @returns The actual amount received for the bridge transaction + */ +export const getActualBridgeReceivedAmount = ( + historyItem: BridgeHistoryItem, +): Omit | null => { + const { quote, pricingData, status } = historyItem; + + const usdExchangeRate = pricingData?.quotedReturnInUsd + ? new BigNumber(pricingData.quotedReturnInUsd).div(quote.destTokenAmount) + : null; + + const actualAmount = status.destChain?.amount; + return actualAmount && usdExchangeRate + ? { + amount: actualAmount, + usd: usdExchangeRate.multipliedBy(actualAmount).toString(10), + } + : null; +}; diff --git a/packages/bridge-status-controller/src/utils/trace.ts b/packages/bridge-status-controller/src/utils/trace.ts new file mode 100644 index 00000000000..95800264e92 --- /dev/null +++ b/packages/bridge-status-controller/src/utils/trace.ts @@ -0,0 +1,87 @@ +/* eslint-disable @typescript-eslint/explicit-function-return-type */ +import { + formatChainIdToCaip, + formatProviderLabel, + FeatureId, + getSwapType, + isCrossChain, + QuoteResponseV1, +} from '@metamask/bridge-controller'; + +import { TraceName } from '../constants.js'; +import type { BridgeHistoryItem } from '../types.js'; + +export type SwapOperationResult = 'success' | 'error'; +export type SwapOperationTerminalStage = 'source' | 'destination'; + +export const getTraceParams = ( + quoteResponse: QuoteResponseV1, + isStxEnabled: boolean, +) => { + return { + name: isCrossChain( + quoteResponse.quote.srcChainId, + quoteResponse.quote.destChainId, + ) + ? TraceName.BridgeTransactionCompleted + : TraceName.SwapTransactionCompleted, + data: { + srcChainId: formatChainIdToCaip(quoteResponse.quote.srcChainId), + stxEnabled: isStxEnabled, + feature_id: quoteResponse.featureId ?? FeatureId.UNIFIED_SWAP_BRIDGE, + }, + }; +}; + +export const getApprovalTraceParams = ( + quoteResponse: QuoteResponseV1, + isStxEnabled: boolean, +) => { + return { + name: isCrossChain( + quoteResponse.quote.srcChainId, + quoteResponse.quote.destChainId, + ) + ? TraceName.BridgeTransactionApprovalCompleted + : TraceName.SwapTransactionApprovalCompleted, + data: { + srcChainId: formatChainIdToCaip(quoteResponse.quote.srcChainId), + stxEnabled: isStxEnabled, + feature_id: quoteResponse.featureId ?? FeatureId.UNIFIED_SWAP_BRIDGE, + }, + }; +}; + +export const getSwapOperationCompletedTraceParams = ( + historyItem: BridgeHistoryItem, + historyKey: string, + result: SwapOperationResult, + terminalStage: SwapOperationTerminalStage, +) => { + const quoteId = historyItem.quoteId ?? historyItem.quote.requestId; + const sourceTransactionHash = historyItem.status.srcChain.txHash; + const destinationTransactionHash = historyItem.status.destChain?.txHash; + + return { + name: TraceName.SwapOperationCompleted, + startTime: historyItem.startTime, + data: { + srcChainId: formatChainIdToCaip(historyItem.quote.srcChainId), + destChainId: formatChainIdToCaip(historyItem.quote.destChainId), + feature_id: historyItem.featureId ?? FeatureId.UNIFIED_SWAP_BRIDGE, + provider: formatProviderLabel(historyItem.quote), + swap_type: getSwapType( + historyItem.quote.srcChainId, + historyItem.quote.destChainId, + ), + terminal_stage: terminalStage, + transaction_id: historyItem.txMetaId ?? historyKey, + result, + ...(quoteId ? { quote_id: quoteId } : {}), + ...(sourceTransactionHash ? { src_tx_hash: sourceTransactionHash } : {}), + ...(destinationTransactionHash + ? { dest_tx_hash: destinationTransactionHash } + : {}), + }, + }; +}; diff --git a/packages/bridge-status-controller/src/utils/transaction.test.ts b/packages/bridge-status-controller/src/utils/transaction.test.ts new file mode 100644 index 00000000000..7b9923e2a9f --- /dev/null +++ b/packages/bridge-status-controller/src/utils/transaction.test.ts @@ -0,0 +1,2809 @@ +/* eslint-disable @typescript-eslint/explicit-function-return-type */ +import { + ChainId, + FeeType, + formatChainIdToCaip, + formatChainIdToHex, + getNativeAssetForChainId, +} from '@metamask/bridge-controller'; +import type { + QuoteMetadata, + QuoteResponseV1, + TxData, +} from '@metamask/bridge-controller'; +import { + GasFeeEstimateType, + TransactionStatus, + TransactionType, +} from '@metamask/transaction-controller'; +import type { TransactionMeta } from '@metamask/transaction-controller'; + +import { APPROVAL_DELAY_MS } from '../constants.js'; +import type { + BridgeStatusControllerMessenger, + QuoteAndTxMetadata, +} from '../types.js'; +import { getStatusRequestParams } from './bridge-status.js'; +import * as snaps from './snaps.js'; +import { + handleApprovalDelay, + handleMobileHardwareWalletDelay, + getAddTransactionBatchParams, + toQuoteAndTxMetadata, + waitForTxConfirmation, + isTradeTx, + findAllTransactionsInBatch, + isApprovalTx, + updateTransactionsInBatch, +} from './transaction.js'; + +describe('Bridge Status Controller Transaction Utils', () => { + describe('waitForTxConfirmation', () => { + it('resolves when confirmed', async () => { + const messenger = { + call: jest.fn(() => ({ + transactions: [ + { + id: 'tx1', + status: TransactionStatus.confirmed, + } as TransactionMeta, + ], + })), + } as unknown as BridgeStatusControllerMessenger; + + const promise = waitForTxConfirmation(messenger, 'tx1', { + timeoutMs: 10, + pollMs: 1, + }); + expect(await promise).toStrictEqual( + expect.objectContaining({ id: 'tx1' }), + ); + }); + + it('throws when rejected', async () => { + const messenger = { + call: jest.fn(() => ({ + transactions: [ + { + id: 'tx1', + status: TransactionStatus.rejected, + } as TransactionMeta, + ], + })), + } as unknown as BridgeStatusControllerMessenger; + + const promise = waitForTxConfirmation(messenger, 'tx1', { + timeoutMs: 10, + pollMs: 1, + }); + expect(await promise.catch((error) => error)).toStrictEqual( + expect.objectContaining({ + message: expect.stringMatching(/did not confirm/iu), + }), + ); + }); + + it('times out when status never changes', async () => { + jest.useFakeTimers(); + const messenger = { + call: jest.fn(() => ({ + transactions: [ + { + id: 'tx1', + status: TransactionStatus.submitted, + } as TransactionMeta, + ], + })), + } as unknown as BridgeStatusControllerMessenger; + const nowSpy = jest.spyOn(Date, 'now'); + let now = 0; + nowSpy.mockImplementation(() => now); + + const promise = waitForTxConfirmation(messenger, 'tx1', { + timeoutMs: 5, + pollMs: 1, + }); + + now = 10; + jest.advanceTimersByTime(1); + await Promise.resolve(); + + expect(await promise.catch((error) => error)).toStrictEqual( + expect.objectContaining({ + message: expect.stringMatching(/Timed out/iu), + }), + ); + + nowSpy.mockRestore(); + jest.useRealTimers(); + }); + }); + + describe('getStatusRequestParams', () => { + it('should extract status request parameters from a quote response', () => { + const mockQuoteResponse: QuoteResponseV1 = { + quote: { + bridgeId: 'bridge1', + bridges: ['bridge1'], + srcChainId: ChainId.ETH, + destChainId: ChainId.POLYGON, + srcTokenAmount: '1000000000000000000', + destTokenAmount: '2000000000000000000', + srcAsset: { + address: '0x0000000000000000000000000000000000000000', + decimals: 18, + symbol: 'ETH', + }, + destAsset: { + address: '0x0000000000000000000000000000000000000000', + decimals: 18, + symbol: 'MATIC', + }, + steps: ['step1'], + feeData: { + [FeeType.METABRIDGE]: { + amount: '100000000000000000', + }, + }, + refuel: false, + }, + estimatedProcessingTimeInSeconds: 300, + trade: { + value: '0x0', + gasLimit: 21000, + }, + } as never; + + const result = getStatusRequestParams(mockQuoteResponse); + + expect(result).toStrictEqual({ + bridgeId: 'bridge1', + bridge: 'bridge1', + srcChainId: ChainId.ETH, + destChainId: ChainId.POLYGON, + quote: mockQuoteResponse.quote, + refuel: false, + }); + }); + + it('should handle quote with refuel flag set to true', () => { + const mockQuoteResponse: QuoteResponseV1 = { + quote: { + bridgeId: 'bridge1', + bridges: ['bridge1'], + srcChainId: ChainId.ETH, + destChainId: ChainId.POLYGON, + srcTokenAmount: '1000000000000000000', + destTokenAmount: '2000000000000000000', + srcAsset: { + address: '0x0000000000000000000000000000000000000000', + decimals: 18, + symbol: 'ETH', + }, + destAsset: { + address: '0x0000000000000000000000000000000000000000', + decimals: 18, + symbol: 'MATIC', + }, + steps: ['step1'], + feeData: { + [FeeType.METABRIDGE]: { + amount: '100000000000000000', + }, + }, + refuel: true, + }, + estimatedProcessingTimeInSeconds: 300, + trade: { + value: '0x0', + gasLimit: '21000', + }, + approval: { + gasLimit: '46000', + }, + } as never; + + const result = getStatusRequestParams(mockQuoteResponse); + + expect(result.refuel).toBe(true); + }); + + it('should handle quote with multiple bridges', () => { + const mockQuoteResponse: QuoteResponseV1 = { + quote: { + bridgeId: 'bridge1', + bridges: ['bridge1', 'bridge2'], + srcChainId: ChainId.ETH, + destChainId: ChainId.POLYGON, + srcTokenAmount: '1000000000000000000', + destTokenAmount: '2000000000000000000', + srcAsset: { + address: '0x0000000000000000000000000000000000000000', + decimals: 18, + symbol: 'ETH', + }, + destAsset: { + address: '0x0000000000000000000000000000000000000000', + decimals: 18, + symbol: 'MATIC', + }, + steps: ['step1'], + feeData: { + [FeeType.METABRIDGE]: { + amount: '100000000000000000', + }, + }, + refuel: false, + }, + estimatedProcessingTimeInSeconds: 300, + trade: { + value: '0x0', + gasLimit: '21000', + }, + approval: { + gasLimit: '46000', + }, + } as never; + + const result = getStatusRequestParams(mockQuoteResponse); + + expect(result.bridge).toBe('bridge1'); // Should take the first bridge + }); + }); + + describe('getTxMetaFields', () => { + it('should extract transaction meta fields from a quote response', () => { + const mockQuoteResponse: QuoteResponseV1 & QuoteMetadata = { + quote: { + bridgeId: 'bridge1', + bridges: ['bridge1'], + srcChainId: ChainId.ETH, + destChainId: ChainId.POLYGON, + srcTokenAmount: '1000000000000000000', + destTokenAmount: '2000000000000000000', + srcAsset: { + address: '0x0000000000000000000000000000000000000000', + decimals: 18, + symbol: 'ETH', + }, + destAsset: { + address: '0x0000000000000000000000000000000000000000', + decimals: 18, + symbol: 'MATIC', + }, + steps: ['step1'], + feeData: { + [FeeType.METABRIDGE]: { + amount: '100000000000000000', + }, + }, + }, + estimatedProcessingTimeInSeconds: 300, + trade: { + value: '0x0', + gasLimit: '21000', + }, + approval: { + gasLimit: '46000', + }, + // QuoteMetadata fields + sentAmount: { + amount: '1.0', + valueInCurrency: '1800', + usd: '1800', + }, + toTokenAmount: { + amount: '2.0', + valueInCurrency: '3600', + usd: '3600', + }, + minToTokenAmount: { + amount: '1.9', + valueInCurrency: '3420', + usd: '3420', + }, + swapRate: '2.0', + totalNetworkFee: { + amount: '0.1', + valueInCurrency: '180', + usd: '180', + }, + totalMaxNetworkFee: { + amount: '0.15', + valueInCurrency: '270', + usd: '270', + }, + gasFee: { + amount: '0.05', + valueInCurrency: '90', + usd: '90', + }, + adjustedReturn: { + valueInCurrency: '3420', + usd: '3420', + }, + cost: { + valueInCurrency: '0.1', + usd: '0.1', + }, + } as never; + + const result = snaps.getTxMetaFields(mockQuoteResponse); + + expect(result).toStrictEqual({ + destinationChainId: formatChainIdToHex(ChainId.POLYGON), + sourceTokenAmount: '1000000000000000000', + sourceTokenSymbol: 'ETH', + sourceTokenDecimals: 18, + sourceTokenAddress: '0x0000000000000000000000000000000000000000', + destinationTokenAmount: '2000000000000000000', + destinationTokenSymbol: 'MATIC', + destinationTokenDecimals: 18, + destinationTokenAddress: '0x0000000000000000000000000000000000000000', + approvalTxId: undefined, + swapTokenValue: '1.0', + }); + }); + + it('should include approvalTxId when provided', () => { + const mockQuoteResponse: QuoteResponseV1 & QuoteMetadata = { + quote: { + bridgeId: 'bridge1', + bridges: ['bridge1'], + srcChainId: ChainId.ETH, + destChainId: ChainId.POLYGON, + srcTokenAmount: '1000000000000000000', + destTokenAmount: '2000000000000000000', + srcAsset: { + address: '0x0000000000000000000000000000000000000000', + decimals: 18, + symbol: 'ETH', + }, + destAsset: { + address: '0x0000000000000000000000000000000000000000', + decimals: 18, + symbol: 'MATIC', + }, + steps: ['step1'], + feeData: { + [FeeType.METABRIDGE]: { + amount: '100000000000000000', + }, + }, + }, + estimatedProcessingTimeInSeconds: 300, + trade: { + value: '0x0', + gasLimit: '21000', + }, + approval: { + gasLimit: '46000', + }, + // QuoteMetadata fields + sentAmount: { + amount: '1.0', + valueInCurrency: '1800', + usd: '1800', + }, + toTokenAmount: { + amount: '2.0', + valueInCurrency: '3600', + usd: '3600', + }, + minToTokenAmount: { + amount: '1.9', + valueInCurrency: '3420', + usd: '3420', + }, + swapRate: '2.0', + totalNetworkFee: { + amount: '0.1', + valueInCurrency: '180', + usd: '180', + }, + totalMaxNetworkFee: { + amount: '0.15', + valueInCurrency: '270', + usd: '270', + }, + gasFee: { + amount: '0.05', + valueInCurrency: '90', + usd: '90', + }, + adjustedReturn: { + valueInCurrency: '3420', + usd: '3420', + }, + cost: { + valueInCurrency: '0.1', + usd: '0.1', + }, + } as never; + + const approvalTxId = '0x1234567890abcdef'; + const result = snaps.getTxMetaFields(mockQuoteResponse, approvalTxId); + + expect(result.approvalTxId).toBe(approvalTxId); + }); + + it('should use fallback chain ID for non-EVM destination chains', () => { + const mockQuoteResponse = { + quote: { + bridgeId: 'bridge1', + bridges: ['bridge1'], + srcChainId: ChainId.ETH, + destChainId: 'bip122:000000000019d6689c085ae165831e93', // Bitcoin CAIP format + srcTokenAmount: '1000000000000000000', + destTokenAmount: '100000', // satoshis + srcAsset: { + address: '0x0000000000000000000000000000000000000000', + decimals: 18, + symbol: 'ETH', + }, + destAsset: { + address: 'bc1qxxx', + decimals: 8, + symbol: 'BTC', + }, + steps: ['step1'], + feeData: { + [FeeType.METABRIDGE]: { + amount: '100000000000000000', + }, + }, + }, + estimatedProcessingTimeInSeconds: 300, + trade: { + value: '0x0', + gasLimit: '21000', + }, + // QuoteMetadata fields + sentAmount: { + amount: '1.0', + valueInCurrency: '3000', + usd: '3000', + }, + toTokenAmount: { + amount: '0.001', + valueInCurrency: '3000', + usd: '3000', + }, + minToTokenAmount: { + amount: '0.00095', + valueInCurrency: '2850', + usd: '2850', + }, + swapRate: '0.001', + totalNetworkFee: { + amount: '0.01', + valueInCurrency: '30', + usd: '30', + }, + totalMaxNetworkFee: { + amount: '0.015', + valueInCurrency: '45', + usd: '45', + }, + gasFee: { + amount: '0.01', + valueInCurrency: '30', + usd: '30', + }, + adjustedReturn: { + valueInCurrency: '2970', + usd: '2970', + }, + cost: { + valueInCurrency: '30', + usd: '30', + }, + }; + + const result = snaps.getTxMetaFields(mockQuoteResponse as never); + + // Should use fallback mainnet chain ID when CAIP format can't be converted to hex + expect(result.destinationChainId).toBe('0x1'); + expect(result.destinationTokenSymbol).toBe('BTC'); + expect(result.destinationTokenDecimals).toBe(8); + }); + }); + + const snapId = 'snapId123'; + const selectedAccountAddress = 'solanaAccountAddress123'; + const mockSolanaAccount = { + metadata: { + snap: { id: snapId }, + }, + options: { scope: formatChainIdToCaip(ChainId.SOLANA) }, + id: 'test-account-id', + address: selectedAccountAddress, + } as never; + + describe('handleNonEvmTxResponse', () => { + it('should handle string response format', () => { + const mockQuoteResponse: QuoteResponseV1 & QuoteMetadata = { + quote: { + bridgeId: 'bridge1', + bridges: ['bridge1'], + srcChainId: ChainId.SOLANA, + destChainId: ChainId.POLYGON, + srcTokenAmount: '1000000000', + destTokenAmount: '2000000000000000000', + minDestTokenAmount: '1900000000000000000', + srcAsset: { + address: 'solanaNativeAddress', + decimals: 9, + symbol: 'SOL', + }, + destAsset: { + address: '0x0000000000000000000000000000000000000000', + decimals: 18, + symbol: 'MATIC', + }, + steps: ['step1'], + feeData: { + [FeeType.METABRIDGE]: { + amount: '100000000', + }, + }, + }, + estimatedProcessingTimeInSeconds: 300, + trade: 'ABCD', + nonEvmFeesInNative: '5000', + // QuoteMetadata fields + sentAmount: { + amount: '1.0', + valueInCurrency: '100', + usd: '100', + }, + toTokenAmount: { + amount: '2.0', + valueInCurrency: '3600', + usd: '3600', + }, + minToTokenAmount: { + amount: '1.9', + valueInCurrency: '3420', + usd: '3420', + }, + swapRate: '2.0', + totalNetworkFee: { + amount: '0.1', + valueInCurrency: '10', + usd: '10', + }, + totalMaxNetworkFee: { + amount: '0.15', + valueInCurrency: '15', + usd: '15', + }, + gasFee: { + amount: '0.05', + valueInCurrency: '5', + usd: '5', + }, + adjustedReturn: { + valueInCurrency: '3585', + usd: '3585', + }, + cost: { + valueInCurrency: '0.1', + usd: '0.1', + }, + } as never; + + const signature = 'solanaSignature123'; + + const result = snaps.handleNonEvmTxResponse( + signature, + mockQuoteResponse.trade, + mockQuoteResponse, + { + metadata: { + snap: { id: undefined }, + }, + options: { scope: formatChainIdToCaip(ChainId.SOLANA) }, + id: 'test-account-id', + address: selectedAccountAddress, + } as never, + ); + + expect(result).toMatchObject({ + id: expect.any(String), + chainId: formatChainIdToHex(ChainId.SOLANA), + txParams: { from: selectedAccountAddress }, + type: TransactionType.bridge, + status: TransactionStatus.submitted, + hash: signature, + isSolana: true, + isBridgeTx: true, + origin: undefined, + destinationChainId: formatChainIdToHex(ChainId.POLYGON), + sourceTokenAmount: '1000000000', + sourceTokenSymbol: 'SOL', + sourceTokenDecimals: 9, + sourceTokenAddress: 'solanaNativeAddress', + destinationTokenAmount: '2000000000000000000', + destinationTokenSymbol: 'MATIC', + destinationTokenDecimals: 18, + destinationTokenAddress: '0x0000000000000000000000000000000000000000', + swapTokenValue: '1.0', + }); + }); + + it('should handle object response format with signature', () => { + const mockQuoteResponse: QuoteResponseV1 & QuoteMetadata = { + quote: { + bridgeId: 'bridge1', + bridges: ['bridge1'], + srcChainId: ChainId.SOLANA, + destChainId: ChainId.POLYGON, + srcTokenAmount: '1000000000', + destTokenAmount: '2000000000000000000', + minDestTokenAmount: '1900000000000000000', + srcAsset: { + address: 'solanaNativeAddress', + decimals: 9, + symbol: 'SOL', + }, + destAsset: { + address: '0x0000000000000000000000000000000000000000', + decimals: 18, + symbol: 'MATIC', + }, + steps: ['step1'], + feeData: { + [FeeType.METABRIDGE]: { + amount: '100000000', + }, + }, + }, + estimatedProcessingTimeInSeconds: 300, + trade: 'ABCD', + nonEvmFeesInNative: '5000', + // QuoteMetadata fields + sentAmount: { + amount: '1.0', + valueInCurrency: '100', + usd: '100', + }, + toTokenAmount: { + amount: '2.0', + valueInCurrency: '3600', + usd: '3600', + }, + minToTokenAmount: { + amount: '1.9', + valueInCurrency: '3420', + usd: '3420', + }, + swapRate: '2.0', + totalNetworkFee: { + amount: '0.1', + valueInCurrency: '10', + usd: '10', + }, + totalMaxNetworkFee: { + amount: '0.15', + valueInCurrency: '15', + usd: '15', + }, + gasFee: { + amount: '0.05', + valueInCurrency: '5', + usd: '5', + }, + adjustedReturn: { + valueInCurrency: '3585', + usd: '3585', + }, + cost: { + valueInCurrency: '0.1', + usd: '0.1', + }, + } as never; + + const snapResponse = { + result: { + signature: 'solanaSignature123', + }, + }; + + const result = snaps.handleNonEvmTxResponse( + snapResponse, + mockQuoteResponse.trade, + mockQuoteResponse, + mockSolanaAccount, + ); + + expect(result.hash).toBe('solanaSignature123'); + }); + + it('should handle onClientRequest response format with signature', () => { + const mockQuoteResponse: QuoteResponseV1 & QuoteMetadata = { + quote: { + bridgeId: 'bridge1', + bridges: ['bridge1'], + srcChainId: ChainId.SOLANA, + destChainId: ChainId.SOLANA, + srcTokenAmount: '1000000000', + destTokenAmount: '2000000000000000000', + minDestTokenAmount: '1900000000000000000', + srcAsset: { + address: 'solanaNativeAddress', + decimals: 9, + symbol: 'SOL', + }, + destAsset: { + address: '0x0000000000000000000000000000000000000000', + decimals: 18, + symbol: 'MATIC', + }, + steps: ['step1'], + feeData: { + [FeeType.METABRIDGE]: { + amount: '100000000', + }, + }, + }, + estimatedProcessingTimeInSeconds: 300, + trade: 'ABCD', + nonEvmFeesInNative: '5000', + // QuoteMetadata fields + sentAmount: { + amount: '1.0', + valueInCurrency: '100', + usd: '100', + }, + toTokenAmount: { + amount: '2.0', + valueInCurrency: '3600', + usd: '3600', + }, + minToTokenAmount: { + amount: '1.9', + valueInCurrency: '3420', + usd: '3420', + }, + swapRate: '2.0', + totalNetworkFee: { + amount: '0.1', + valueInCurrency: '10', + usd: '10', + }, + totalMaxNetworkFee: { + amount: '0.15', + valueInCurrency: '15', + usd: '15', + }, + gasFee: { + amount: '0.05', + valueInCurrency: '5', + usd: '5', + }, + adjustedReturn: { + valueInCurrency: '3585', + usd: '3585', + }, + cost: { + valueInCurrency: '0.1', + usd: '0.1', + }, + } as never; + + const snapResponse = { + signature: 'solanaSignature123', + }; + + const result = snaps.handleNonEvmTxResponse( + snapResponse, + mockQuoteResponse.trade, + mockQuoteResponse, + mockSolanaAccount, + ); + + expect(result.hash).toBe('solanaSignature123'); + expect(result.type).toBe(TransactionType.swap); + }); + + it('should handle object response format with txid', () => { + const mockQuoteResponse: QuoteResponseV1 & QuoteMetadata = { + quote: { + bridgeId: 'bridge1', + bridges: ['bridge1'], + srcChainId: ChainId.SOLANA, + destChainId: ChainId.POLYGON, + srcTokenAmount: '1000000000', + destTokenAmount: '2000000000000000000', + minDestTokenAmount: '1900000000000000000', + srcAsset: { + address: 'solanaNativeAddress', + decimals: 9, + symbol: 'SOL', + }, + destAsset: { + address: '0x0000000000000000000000000000000000000000', + decimals: 18, + symbol: 'MATIC', + }, + steps: ['step1'], + feeData: { + [FeeType.METABRIDGE]: { + amount: '100000000', + }, + }, + }, + estimatedProcessingTimeInSeconds: 300, + trade: 'ABCD', + nonEvmFeesInNative: '5000', + // QuoteMetadata fields + sentAmount: { + amount: '1.0', + valueInCurrency: '100', + usd: '100', + }, + toTokenAmount: { + amount: '2.0', + valueInCurrency: '3600', + usd: '3600', + }, + minToTokenAmount: { + amount: '1.9', + valueInCurrency: '3420', + usd: '3420', + }, + swapRate: '2.0', + totalNetworkFee: { + amount: '0.1', + valueInCurrency: '10', + usd: '10', + }, + totalMaxNetworkFee: { + amount: '0.15', + valueInCurrency: '15', + usd: '15', + }, + gasFee: { + amount: '0.05', + valueInCurrency: '5', + usd: '5', + }, + adjustedReturn: { + valueInCurrency: '3585', + usd: '3585', + }, + cost: { + valueInCurrency: '0.1', + usd: '0.1', + }, + } as never; + + const snapResponse = { + result: { + txid: 'solanaTxId123', + }, + }; + + const result = snaps.handleNonEvmTxResponse( + snapResponse, + mockQuoteResponse.trade, + mockQuoteResponse, + mockSolanaAccount, + ); + + expect(result.hash).toBe('solanaTxId123'); + }); + + it('should handle object response format with hash', () => { + const mockQuoteResponse: QuoteResponseV1 & QuoteMetadata = { + quote: { + bridgeId: 'bridge1', + bridges: ['bridge1'], + srcChainId: ChainId.SOLANA, + destChainId: ChainId.POLYGON, + srcTokenAmount: '1000000000', + destTokenAmount: '2000000000000000000', + minDestTokenAmount: '1900000000000000000', + srcAsset: { + address: 'solanaNativeAddress', + decimals: 9, + symbol: 'SOL', + }, + destAsset: { + address: '0x0000000000000000000000000000000000000000', + decimals: 18, + symbol: 'MATIC', + }, + steps: ['step1'], + feeData: { + [FeeType.METABRIDGE]: { + amount: '100000000', + }, + }, + }, + estimatedProcessingTimeInSeconds: 300, + trade: 'ABCD', + nonEvmFeesInNative: '5000', + // QuoteMetadata fields + sentAmount: { + amount: '1.0', + valueInCurrency: '100', + usd: '100', + }, + toTokenAmount: { + amount: '2.0', + valueInCurrency: '3600', + usd: '3600', + }, + minToTokenAmount: { + amount: '1.9', + valueInCurrency: '3420', + usd: '3420', + }, + swapRate: '2.0', + totalNetworkFee: { + amount: '0.1', + valueInCurrency: '10', + usd: '10', + }, + totalMaxNetworkFee: { + amount: '0.15', + valueInCurrency: '15', + usd: '15', + }, + gasFee: { + amount: '0.05', + valueInCurrency: '5', + usd: '5', + }, + adjustedReturn: { + valueInCurrency: '3585', + usd: '3585', + }, + cost: { + valueInCurrency: '0.1', + usd: '0.1', + }, + } as never; + + const snapResponse = { + result: { + hash: 'solanaHash123', + }, + }; + + const result = snaps.handleNonEvmTxResponse( + snapResponse, + mockQuoteResponse.trade, + mockQuoteResponse, + mockSolanaAccount, + ); + + expect(result.hash).toBe('solanaHash123'); + }); + + it('should handle object response format with txHash', () => { + const mockQuoteResponse: QuoteResponseV1 & QuoteMetadata = { + quote: { + bridgeId: 'bridge1', + bridges: ['bridge1'], + srcChainId: ChainId.SOLANA, + destChainId: ChainId.POLYGON, + srcTokenAmount: '1000000000', + destTokenAmount: '2000000000000000000', + minDestTokenAmount: '1900000000000000000', + srcAsset: { + address: 'solanaNativeAddress', + decimals: 9, + symbol: 'SOL', + }, + destAsset: { + address: '0x0000000000000000000000000000000000000000', + decimals: 18, + symbol: 'MATIC', + }, + steps: ['step1'], + feeData: { + [FeeType.METABRIDGE]: { + amount: '100000000', + }, + }, + }, + estimatedProcessingTimeInSeconds: 300, + trade: 'ABCD', + nonEvmFeesInNative: '5000', + // QuoteMetadata fields + sentAmount: { + amount: '1.0', + valueInCurrency: '100', + usd: '100', + }, + toTokenAmount: { + amount: '2.0', + valueInCurrency: '3600', + usd: '3600', + }, + minToTokenAmount: { + amount: '1.9', + valueInCurrency: '3420', + usd: '3420', + }, + swapRate: '2.0', + totalNetworkFee: { + amount: '0.1', + valueInCurrency: '10', + usd: '10', + }, + totalMaxNetworkFee: { + amount: '0.15', + valueInCurrency: '15', + usd: '15', + }, + gasFee: { + amount: '0.05', + valueInCurrency: '5', + usd: '5', + }, + adjustedReturn: { + valueInCurrency: '3585', + usd: '3585', + }, + cost: { + valueInCurrency: '0.1', + usd: '0.1', + }, + } as never; + + const snapResponse = { + result: { + txHash: 'solanaTxHash123', + }, + }; + + const result = snaps.handleNonEvmTxResponse( + snapResponse, + mockQuoteResponse.trade, + mockQuoteResponse, + mockSolanaAccount, + ); + + expect(result.hash).toBe('solanaTxHash123'); + }); + + it('should handle new unified interface response with transactionId', () => { + const mockQuoteResponse: QuoteResponseV1 & QuoteMetadata = { + quote: { + bridgeId: 'bridge1', + bridges: ['bridge1'], + srcChainId: ChainId.SOLANA, + destChainId: ChainId.POLYGON, + srcTokenAmount: '1000000000', + destTokenAmount: '2000000000000000000', + minDestTokenAmount: '1900000000000000000', + srcAsset: { + address: 'solanaNativeAddress', + decimals: 9, + symbol: 'SOL', + }, + destAsset: { + address: '0x0000000000000000000000000000000000000000', + decimals: 18, + symbol: 'MATIC', + }, + steps: ['step1'], + feeData: { + [FeeType.METABRIDGE]: { + amount: '100000000', + }, + }, + }, + estimatedProcessingTimeInSeconds: 300, + trade: 'ABCD', + nonEvmFeesInNative: '5000', + // QuoteMetadata fields + sentAmount: { + amount: '1.0', + valueInCurrency: '100', + usd: '100', + }, + toTokenAmount: { + amount: '2.0', + valueInCurrency: '3600', + usd: '3600', + }, + minToTokenAmount: { + amount: '1.9', + valueInCurrency: '3420', + usd: '3420', + }, + swapRate: '2.0', + totalNetworkFee: { + amount: '0.1', + valueInCurrency: '10', + usd: '10', + }, + totalMaxNetworkFee: { + amount: '0.15', + valueInCurrency: '15', + usd: '15', + }, + gasFee: { + amount: '0.05', + valueInCurrency: '5', + usd: '5', + }, + adjustedReturn: { + valueInCurrency: '3585', + usd: '3585', + }, + cost: { + valueInCurrency: '0.1', + usd: '0.1', + }, + } as never; + + const snapResponse = { transactionId: 'new-unified-tx-id-123' }; + + const result = snaps.handleNonEvmTxResponse( + snapResponse, + mockQuoteResponse.trade, + mockQuoteResponse, + mockSolanaAccount, + ); + + expect(result.hash).toBe('new-unified-tx-id-123'); + expect(result.chainId).toBe(formatChainIdToHex(ChainId.SOLANA)); + expect(result.type).toBe(TransactionType.bridge); + expect(result.status).toBe(TransactionStatus.submitted); + expect(result.destinationTokenAmount).toBe('2000000000000000000'); + expect(result.destinationTokenSymbol).toBe('MATIC'); + expect(result.destinationTokenDecimals).toBe(18); + expect(result.destinationTokenAddress).toBe( + '0x0000000000000000000000000000000000000000', + ); + expect(result.swapTokenValue).toBe('1.0'); + expect(result.isSolana).toBe(true); + expect(result.isBridgeTx).toBe(true); + }); + + it('should handle empty or invalid response', () => { + const mockQuoteResponse: QuoteResponseV1 & QuoteMetadata = { + quote: { + bridgeId: 'bridge1', + bridges: ['bridge1'], + srcChainId: ChainId.SOLANA, + destChainId: ChainId.POLYGON, + srcTokenAmount: '1000000000', + destTokenAmount: '2000000000000000000', + minDestTokenAmount: '1900000000000000000', + srcAsset: { + address: 'solanaNativeAddress', + decimals: 9, + symbol: 'SOL', + }, + destAsset: { + address: '0x0000000000000000000000000000000000000000', + decimals: 18, + symbol: 'MATIC', + }, + steps: ['step1'], + feeData: { + [FeeType.METABRIDGE]: { + amount: '100000000', + }, + }, + }, + estimatedProcessingTimeInSeconds: 300, + trade: 'ABCD', + nonEvmFeesInNative: '5000', + // QuoteMetadata fields + sentAmount: { + amount: '1.0', + valueInCurrency: '100', + usd: '100', + }, + toTokenAmount: { + amount: '2.0', + valueInCurrency: '3600', + usd: '3600', + }, + minToTokenAmount: { + amount: '1.9', + valueInCurrency: '3420', + usd: '3420', + }, + swapRate: '2.0', + totalNetworkFee: { + amount: '0.1', + valueInCurrency: '10', + usd: '10', + }, + totalMaxNetworkFee: { + amount: '0.15', + valueInCurrency: '15', + usd: '15', + }, + gasFee: { + amount: '0.05', + valueInCurrency: '5', + usd: '5', + }, + adjustedReturn: { + valueInCurrency: '3585', + usd: '3585', + }, + cost: { + valueInCurrency: '0.1', + usd: '0.1', + }, + } as never; + + const snapResponse = { result: {} } as { result: Record }; + + const result = snaps.handleNonEvmTxResponse( + snapResponse, + mockQuoteResponse.trade, + mockQuoteResponse, + mockSolanaAccount, + ); + + expect(result.hash).toBeUndefined(); + }); + + it('should handle Bitcoin transaction with PSBT and non-EVM chain ID', () => { + const mockBitcoinQuote = { + quote: { + bridgeId: 'bridge1', + bridges: ['bridge1'], + srcChainId: 'bip122:000000000019d6689c085ae165831e93', + destChainId: ChainId.ETH, + srcTokenAmount: '100000', + destTokenAmount: '1000000000000000000', + minDestTokenAmount: '950000000000000000', + srcAsset: { + address: 'bc1qxxx', + decimals: 8, + symbol: 'BTC', + }, + destAsset: { + address: '0x0000000000000000000000000000000000000000', + decimals: 18, + symbol: 'ETH', + }, + steps: ['step1'], + feeData: { + [FeeType.METABRIDGE]: { + amount: '500', + }, + }, + }, + estimatedProcessingTimeInSeconds: 600, + trade: { + unsignedPsbtBase64: 'cHNidP8BAH0CAAAAAe...', + inputsToSign: [], + }, + // QuoteMetadata fields + sentAmount: { + amount: '0.001', + valueInCurrency: '60', + usd: '60', + }, + toTokenAmount: { + amount: '1.0', + valueInCurrency: '3000', + usd: '3000', + }, + minToTokenAmount: { + amount: '0.95', + valueInCurrency: '2850', + usd: '2850', + }, + swapRate: '1000', + totalNetworkFee: { + amount: '0.00005', + valueInCurrency: '3', + usd: '3', + }, + totalMaxNetworkFee: { + amount: '0.00007', + valueInCurrency: '4.2', + usd: '4.2', + }, + gasFee: { + amount: '0.00005', + valueInCurrency: '3', + usd: '3', + }, + adjustedReturn: { + valueInCurrency: '2997', + usd: '2997', + }, + cost: { + valueInCurrency: '3', + usd: '3', + }, + }; + + const snapResponse = { transactionId: 'btc_tx_123' }; + + const result = snaps.handleNonEvmTxResponse( + snapResponse, + mockBitcoinQuote.trade, + mockBitcoinQuote as never, + mockSolanaAccount, + ); + + // Should use fallback chain ID (0x1 - Ethereum mainnet) when Bitcoin CAIP format can't be converted + expect(result.chainId).toBe('0x1'); + expect(result.hash).toBe('btc_tx_123'); + expect(result.type).toBe(TransactionType.bridge); + expect(result.sourceTokenSymbol).toBe('BTC'); + expect(result.destinationTokenSymbol).toBe('ETH'); + expect(result.isBridgeTx).toBe(true); + }); + }); + + describe('handleApprovalDelay', () => { + beforeEach(() => { + jest.useFakeTimers(); + jest.clearAllMocks(); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + it('should delay when source chain is Linea', async () => { + // Create a minimal mock quote response with Linea as the source chain + const mockQuoteResponse = { + quote: { + srcChainId: ChainId.LINEA, + // Other required properties with minimal values + requestId: 'test-request-id', + srcAsset: { address: '0x123', symbol: 'ETH', decimals: 18 }, + srcTokenAmount: '1000000000000000000', + destChainId: ChainId.ETH, + destAsset: { address: '0x456', symbol: 'ETH', decimals: 18 }, + destTokenAmount: '1000000000000000000', + bridgeId: 'test-bridge', + bridges: ['test-bridge'], + steps: [], + feeData: {}, + }, + // Required properties for QuoteResponseV1 + trade: {} as TxData, + estimatedProcessingTimeInSeconds: 60, + } as unknown as QuoteResponseV1; + + // Create a promise that will resolve after the delay + const delayPromise = handleApprovalDelay( + mockQuoteResponse.quote.srcChainId, + ); + + // Verify that the timer was set with the correct delay + expect(jest.getTimerCount()).toBe(1); + + // Fast-forward the timer + jest.advanceTimersByTime(APPROVAL_DELAY_MS); + + // Wait for the promise to resolve + await delayPromise; + + // Verify that the timer was cleared + expect(jest.getTimerCount()).toBe(0); + }); + + it('should delay when source chain is Base', async () => { + // Create a minimal mock quote response with Base as the source chain + const mockQuoteResponse = { + quote: { + srcChainId: ChainId.BASE, + // Other required properties with minimal values + requestId: 'test-request-id', + srcAsset: { address: '0x123', symbol: 'ETH', decimals: 18 }, + srcTokenAmount: '1000000000000000000', + destChainId: ChainId.ETH, + destAsset: { address: '0x456', symbol: 'ETH', decimals: 18 }, + destTokenAmount: '1000000000000000000', + bridgeId: 'test-bridge', + bridges: ['test-bridge'], + steps: [], + feeData: {}, + }, + // Required properties for QuoteResponseV1 + trade: {} as TxData, + estimatedProcessingTimeInSeconds: 60, + } as unknown as QuoteResponseV1; + + // Create a promise that will resolve after the delay + const delayPromise = handleApprovalDelay( + mockQuoteResponse.quote.srcChainId, + ); + + // Verify that the timer was set with the correct delay + expect(jest.getTimerCount()).toBe(1); + + // Fast-forward the timer + jest.advanceTimersByTime(APPROVAL_DELAY_MS); + + // Wait for the promise to resolve + await delayPromise; + + // Verify that the timer was cleared + expect(jest.getTimerCount()).toBe(0); + }); + + it('should not delay when source chain is not Linea or Base', async () => { + // Create a minimal mock quote response with a non-Linea/Base source chain + const mockQuoteResponse = { + quote: { + srcChainId: ChainId.ETH, + // Other required properties with minimal values + requestId: 'test-request-id', + srcAsset: { address: '0x123', symbol: 'ETH', decimals: 18 }, + srcTokenAmount: '1000000000000000000', + destChainId: ChainId.LINEA, + destAsset: { address: '0x456', symbol: 'ETH', decimals: 18 }, + destTokenAmount: '1000000000000000000', + bridgeId: 'test-bridge', + bridges: ['test-bridge'], + steps: [], + feeData: {}, + }, + // Required properties for QuoteResponseV1 + trade: {} as TxData, + estimatedProcessingTimeInSeconds: 60, + } as unknown as QuoteResponseV1; + + // Create a promise that will resolve after the delay + const delayPromise = handleApprovalDelay( + mockQuoteResponse.quote.srcChainId, + ); + + // Verify that no timer was set + expect(jest.getTimerCount()).toBe(0); + + // Wait for the promise to resolve + await delayPromise; + + // Verify that no timer was set + expect(jest.getTimerCount()).toBe(0); + }); + }); + + describe('handleMobileHardwareWalletDelay', () => { + beforeEach(() => { + jest.useFakeTimers(); + jest.clearAllMocks(); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + it('should delay when requireApproval is true', async () => { + // Create a promise that will resolve after the delay + const delayPromise = handleMobileHardwareWalletDelay(true); + + // Verify that the timer was set with the correct delay (1000ms) + expect(jest.getTimerCount()).toBe(1); + + // Fast-forward the timer by 1000ms + jest.advanceTimersByTime(1000); + + // Wait for the promise to resolve + await delayPromise; + + // Verify that the timer was cleared + expect(jest.getTimerCount()).toBe(0); + }); + + it('should not delay when requireApproval is false', async () => { + // Create a promise that will resolve without delay + const delayPromise = handleMobileHardwareWalletDelay(false); + + // Verify that no timer was set + expect(jest.getTimerCount()).toBe(0); + + // Wait for the promise to resolve + await delayPromise; + + // Verify that no timer was set + expect(jest.getTimerCount()).toBe(0); + }); + }); + + describe('getClientRequest', () => { + it('should generate a valid client request', () => { + const mockQuoteResponse: Omit, 'approval'> & + QuoteMetadata = { + quote: { + bridgeId: 'bridge1', + bridges: ['bridge1'], + srcChainId: ChainId.SOLANA, + destChainId: ChainId.POLYGON, + srcTokenAmount: '1000000000', + destTokenAmount: '2000000000000000000', + minDestTokenAmount: '1900000000000000000', + srcAsset: { + address: 'solanaNativeAddress', + decimals: 9, + symbol: 'SOL', + }, + destAsset: { + address: '0x0000000000000000000000000000000000000000', + decimals: 18, + symbol: 'MATIC', + }, + steps: ['step1'], + feeData: { + [FeeType.METABRIDGE]: { + amount: '100000000', + }, + }, + }, + estimatedProcessingTimeInSeconds: 300, + trade: 'ABCD', + // QuoteMetadata fields + sentAmount: { + amount: '1.0', + valueInCurrency: '100', + usd: '100', + }, + toTokenAmount: { + amount: '2.0', + valueInCurrency: '3600', + usd: '3600', + }, + minToTokenAmount: { + amount: '1.9', + valueInCurrency: '3420', + usd: '3420', + }, + swapRate: '2.0', + totalNetworkFee: { + amount: '0.1', + valueInCurrency: '10', + usd: '10', + }, + totalMaxNetworkFee: { + amount: '0.15', + valueInCurrency: '15', + usd: '15', + }, + gasFee: { + amount: '0.05', + valueInCurrency: '5', + usd: '5', + }, + adjustedReturn: { + valueInCurrency: '3585', + usd: '3585', + }, + cost: { + valueInCurrency: '0.1', + usd: '0.1', + }, + } as never; + + const mockAccount = { + id: 'test-account-id', + address: '0x123456', + metadata: { + snap: { id: 'test-snap-id' }, + }, + }; + + const result = snaps.getClientRequest( + mockQuoteResponse.trade, + mockQuoteResponse.quote.srcChainId, + mockAccount.id, + mockAccount.metadata.snap.id, + ); + + expect(result).toMatchObject({ + origin: 'metamask', + snapId: 'test-snap-id', + handler: 'onClientRequest', + request: { + id: expect.any(String), + jsonrpc: '2.0', + method: 'signAndSendTransaction', + params: { + transaction: 'ABCD', + scope: 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp', + accountId: 'test-account-id', + }, + }, + }); + }); + + it('should include Tron options when trade is Tron', () => { + const createClientRequestSpy = jest + .spyOn(snaps, 'getClientRequest') + .mockReturnValue({ mocked: true } as never); + + const tronTrade = { + raw_data_hex: 'abcdef', + raw_data: { + contract: [{ type: 'TransferContract' }], + }, + visible: true, + } as never; + + const mockAccount = { + id: 'test-account-id', + metadata: { + snap: { id: 'test-snap-id' }, + }, + }; + + const result = snaps.getClientRequest( + tronTrade, + ChainId.TRON, + mockAccount.id, + mockAccount.metadata.snap.id, + ); + + expect(result).toStrictEqual({ mocked: true }); + expect(createClientRequestSpy.mock.calls).toMatchInlineSnapshot(` + [ + [ + { + "raw_data": { + "contract": [ + { + "type": "TransferContract", + }, + ], + }, + "raw_data_hex": "abcdef", + "visible": true, + }, + 728126428, + "test-account-id", + "test-snap-id", + ], + ] + `); + + createClientRequestSpy.mockRestore(); + }); + + it('should include Stellar source and destination asset IDs as options when trade is Stellar', () => { + const stellarTrade = { + xdrBase64: 'AAAABg==', + } as never; + + const mockAccount = { + id: 'test-account-id', + metadata: { + snap: { id: 'test-snap-id' }, + }, + }; + + const sourceAssetId = getNativeAssetForChainId(ChainId.STELLAR).assetId; + const destAssetId = getNativeAssetForChainId(ChainId.ETH).assetId; + + const result = snaps.getClientRequest( + stellarTrade, + ChainId.STELLAR, + mockAccount.id, + mockAccount.metadata.snap.id, + sourceAssetId, + destAssetId, + ); + + expect(result).toMatchObject({ + origin: 'metamask', + snapId: 'test-snap-id', + handler: 'onClientRequest', + request: { + id: expect.any(String), + jsonrpc: '2.0', + method: 'signAndSendTransaction', + params: { + transaction: 'AAAABg==', + scope: formatChainIdToCaip(ChainId.STELLAR), + accountId: 'test-account-id', + options: { + sourceAssetId, + destAssetId, + }, + }, + }, + }); + }); + + it('should omit destAssetId option for Stellar trades when destination asset ID is not provided', () => { + const stellarTrade = { + xdr: 'AAAABg==', + } as never; + + const mockAccount = { + id: 'test-account-id', + metadata: { + snap: { id: 'test-snap-id' }, + }, + }; + + const sourceAssetId = getNativeAssetForChainId(ChainId.STELLAR).assetId; + + const result = snaps.getClientRequest( + stellarTrade, + ChainId.STELLAR, + mockAccount.id, + mockAccount.metadata.snap.id, + sourceAssetId, + ); + + expect(result).toMatchObject({ + request: { + params: { + options: { + sourceAssetId, + }, + }, + }, + }); + expect( + (result.request.params as { options: Record }).options, + ).not.toHaveProperty('destAssetId'); + }); + + it('should not include asset ID options for Bitcoin trades even when asset IDs are provided', () => { + const bitcoinTrade = { + unsignedPsbtBase64: 'AAAABg==', + } as never; + + const mockAccount = { + id: 'test-account-id', + metadata: { + snap: { id: 'test-snap-id' }, + }, + }; + + const sourceAssetId = getNativeAssetForChainId(ChainId.BTC).assetId; + const destAssetId = getNativeAssetForChainId(ChainId.ETH).assetId; + + const result = snaps.getClientRequest( + bitcoinTrade, + ChainId.BTC, + mockAccount.id, + mockAccount.metadata.snap.id, + sourceAssetId, + destAssetId, + ); + + expect(result.request.params).not.toHaveProperty('options'); + }); + + it('should not include asset ID options for Solana trades even when asset IDs are provided', () => { + const solanaTrade = 'ABCD' as never; + + const mockAccount = { + id: 'test-account-id', + metadata: { + snap: { id: 'test-snap-id' }, + }, + }; + + const sourceAssetId = getNativeAssetForChainId(ChainId.SOLANA).assetId; + const destAssetId = getNativeAssetForChainId(ChainId.ETH).assetId; + + const result = snaps.getClientRequest( + solanaTrade, + ChainId.SOLANA, + mockAccount.id, + mockAccount.metadata.snap.id, + sourceAssetId, + destAssetId, + ); + + expect(result.request.params).not.toHaveProperty('options'); + }); + }); + + describe('getAddTransactionBatchParams', () => { + let mockMessagingSystem: BridgeStatusControllerMessenger; + const mockAccount = { + id: 'test-account-id', + address: '0xUserAddress', + metadata: { + keyring: { type: 'simple' }, + }, + }; + + const createMockQuoteResponse = ( + overrides: { + gasIncluded?: boolean; + gasIncluded7702?: boolean; + includeApproval?: boolean; + includeResetApproval?: boolean; + } = {}, + ): QuoteResponseV1 & QuoteMetadata => + ({ + quote: { + bridgeId: 'bridge1', + bridges: ['bridge1'], + srcChainId: ChainId.ETH, + destChainId: ChainId.POLYGON, + srcTokenAmount: '1000000000000000000', + destTokenAmount: '2000000000000000000', + srcAsset: { + address: '0x0000000000000000000000000000000000000000', + decimals: 18, + symbol: 'ETH', + }, + destAsset: { + address: '0x0000000000000000000000000000000000000000', + decimals: 18, + symbol: 'MATIC', + }, + steps: ['step1'], + feeData: { + [FeeType.METABRIDGE]: { + amount: '100000000000000000', + }, + ...(overrides.gasIncluded7702 || overrides.gasIncluded + ? { + txFee: { + maxFeePerGas: '50000000000000000', + maxPriorityFeePerGas: '50000000000000000', + }, + } + : {}), + }, + gasIncluded: overrides.gasIncluded ?? false, + gasIncluded7702: overrides.gasIncluded7702 ?? false, + }, + estimatedProcessingTimeInSeconds: 300, + trade: { + value: '0x1000', + gasLimit: 21000, + to: '0xBridgeContract', + data: '0xbridgeData', + from: '0xUserAddress', + chainId: ChainId.ETH, + }, + ...(overrides.includeApproval && { + approval: { + to: '0xTokenContract', + data: '0xapprovalData', + from: '0xUserAddress', + chainId: ChainId.ETH, + }, + }), + ...(overrides.includeResetApproval && { + resetApproval: { + to: '0xTokenContract', + data: '0xresetData', + from: '0xUserAddress', + chainId: ChainId.ETH, + }, + }), + sentAmount: { + amount: '1.0', + valueInCurrency: '100', + usd: '100', + }, + toTokenAmount: { + amount: '2.0', + valueInCurrency: '200', + usd: '200', + }, + }) as never; + + const createMockMessagingSystem = ( + estimateGasFeeOverrides: Record = { estimates: {} }, + ) => + ({ + call: jest.fn().mockImplementation((method: string) => { + if (method === 'AccountsController:getAccountByAddress') { + return mockAccount; + } + if (method === 'NetworkController:getNetworkConfiguration') { + return { + chainId: '0x1', + rpcUrl: 'https://mainnet.infura.io/v3/API_KEY', + }; + } + if (method === 'TransactionController:estimateGasFee') { + return estimateGasFeeOverrides; + } + return undefined; + }), + }) as unknown as BridgeStatusControllerMessenger; + + beforeEach(() => { + jest.clearAllMocks(); + mockMessagingSystem = createMockMessagingSystem(); + }); + + it('should handle gasIncluded7702 flag set to true', async () => { + const mockQuoteResponse = createMockQuoteResponse({ + gasIncluded7702: true, + includeApproval: true, + }); + + const tradeData = toQuoteAndTxMetadata({ + quoteResponse: mockQuoteResponse, + isBridgeTx: true, + }); + + const result = await getAddTransactionBatchParams({ + tradeData, + requireApproval: false, + messenger: mockMessagingSystem, + disable7702: false, + isGasFeeSponsored: false, + isGasFeeIncluded: true, + atomic: true, + }); + + expect(result.disable7702).toBe(false); + expect(result.isGasFeeIncluded).toBe(true); + + // Should use txFee for gas calculation when gasIncluded7702 is true + expect(result.transactions).toHaveLength(2); + expect(result.transactions[0].type).toBe(TransactionType.bridgeApproval); + expect(result.transactions[1].type).toBe(TransactionType.bridge); + expect(result.transactions[1].params).toMatchInlineSnapshot(` + { + "data": "0xbridgeData", + "from": "0xUserAddress", + "gas": "0x5208", + "maxFeePerGas": "0xb1a2bc2ec50000", + "maxPriorityFeePerGas": "0xb1a2bc2ec50000", + "to": "0xBridgeContract", + "value": "0x1000", + } + `); + }); + + it('should handle gasIncluded7702 flag set to false', async () => { + const mockQuoteResponse = createMockQuoteResponse({ + gasIncluded7702: false, + }); + + const tradeData = toQuoteAndTxMetadata({ + quoteResponse: mockQuoteResponse, + isBridgeTx: false, + }); + const result = await getAddTransactionBatchParams({ + messenger: mockMessagingSystem, + tradeData, + disable7702: true, + isGasFeeSponsored: false, + isGasFeeIncluded: false, + atomic: true, + }); + + expect(result.disable7702).toBe(true); + expect(result.isGasFeeIncluded).toBe(false); + + // Should not use txFee for gas calculation when both gasIncluded and gasIncluded7702 are false + expect(result.transactions).toHaveLength(1); + expect(result.transactions[0].type).toBe(TransactionType.swap); + expect(result.transactions[0].params).toMatchInlineSnapshot(` + { + "data": "0xbridgeData", + "from": "0xUserAddress", + "gas": "0x5208", + "maxFeePerGas": undefined, + "maxPriorityFeePerGas": undefined, + "to": "0xBridgeContract", + "value": "0x1000", + } + `); + }); + + it('uses swap approval when approval provided and isBridgeTx is false', async () => { + const mockQuoteResponse = createMockQuoteResponse({ + includeApproval: true, + }); + + const tradeData = toQuoteAndTxMetadata({ + quoteResponse: mockQuoteResponse, + isBridgeTx: false, + }); + const result = await getAddTransactionBatchParams({ + messenger: mockMessagingSystem, + tradeData, + }); + + expect(result.transactions).toHaveLength(2); + expect(result.transactions[0].type).toBe(TransactionType.swapApproval); + expect(result.transactions[1].type).toBe(TransactionType.swap); + expect(result.transactions[1].params).toMatchInlineSnapshot(` + { + "data": "0xbridgeData", + "from": "0xUserAddress", + "gas": "0x5208", + "maxFeePerGas": undefined, + "maxPriorityFeePerGas": undefined, + "to": "0xBridgeContract", + "value": "0x1000", + } + `); + }); + + it('uses swap approval type for resetApproval when isBridgeTx is false', async () => { + const mockQuoteResponse = createMockQuoteResponse({ + includeResetApproval: true, + }); + + const tradeData = toQuoteAndTxMetadata({ + quoteResponse: mockQuoteResponse, + isBridgeTx: false, + }); + const result = await getAddTransactionBatchParams({ + messenger: mockMessagingSystem, + tradeData, + }); + + expect(result.transactions).toHaveLength(2); + expect(result.transactions[0].type).toBe(TransactionType.swapApproval); + expect(result.transactions[1].type).toBe(TransactionType.swap); + }); + + it('should handle gasIncluded with gasIncluded7702', async () => { + const mockQuoteResponse = createMockQuoteResponse({ + gasIncluded: true, + gasIncluded7702: false, + includeResetApproval: true, + }); + + const tradeData = toQuoteAndTxMetadata({ + quoteResponse: mockQuoteResponse, + isBridgeTx: true, + }); + + const result = await getAddTransactionBatchParams({ + messenger: mockMessagingSystem, + tradeData, + disable7702: true, + isGasFeeSponsored: false, + isGasFeeIncluded: false, + atomic: true, + }); + + expect(result.disable7702).toBe(true); + expect(result.isGasFeeIncluded).toBe(false); + + // Should use txFee for gas calculation when gasIncluded is true + expect(result.transactions).toHaveLength(2); + expect(result.transactions[0].type).toBe(TransactionType.bridgeApproval); + expect(result.transactions[1].type).toBe(TransactionType.bridge); + expect(result.transactions[1].params).toMatchInlineSnapshot(` + { + "data": "0xbridgeData", + "from": "0xUserAddress", + "gas": "0x5208", + "maxFeePerGas": "0xb1a2bc2ec50000", + "maxPriorityFeePerGas": "0xb1a2bc2ec50000", + "to": "0xBridgeContract", + "value": "0x1000", + } + `); + }); + + it('should set isGasFeeIncluded to false and set disable7702 to true when gasIncluded7702 is undefined', async () => { + const mockQuoteResponse = createMockQuoteResponse({ + gasIncluded7702: undefined, + }); + + const tradeData = toQuoteAndTxMetadata({ + quoteResponse: mockQuoteResponse, + isBridgeTx: false, + }); + const result = await getAddTransactionBatchParams({ + messenger: mockMessagingSystem, + tradeData, + disable7702: true, + isGasFeeSponsored: false, + isGasFeeIncluded: false, + atomic: true, + }); + + expect(result.isGasFeeIncluded).toBe(false); + expect(result.disable7702).toBe(true); + expect(result).toMatchInlineSnapshot(` + { + "atomic": true, + "disable7702": true, + "from": "0xUserAddress", + "isGasFeeIncluded": false, + "isGasFeeSponsored": false, + "isInternal": true, + "networkClientId": undefined, + "origin": "metamask", + "requireApproval": false, + "transactions": [ + { + "assetsFiatValues": { + "receiving": "200", + "sending": "100", + }, + "params": { + "data": "0xbridgeData", + "from": "0xUserAddress", + "gas": "0x5208", + "maxFeePerGas": undefined, + "maxPriorityFeePerGas": undefined, + "to": "0xBridgeContract", + "value": "0x1000", + }, + "type": "swap", + }, + ], + } + `); + }); + + it('should set isGasFeeIncluded to true and disable7702 to false when gasIncluded7702 is true', async () => { + const mockQuoteResponse = createMockQuoteResponse({ + gasIncluded7702: true, + }); + + const tradeData = toQuoteAndTxMetadata({ + quoteResponse: mockQuoteResponse, + isBridgeTx: false, + }); + const result = await getAddTransactionBatchParams({ + messenger: mockMessagingSystem, + tradeData, + disable7702: false, + isGasFeeSponsored: false, + isGasFeeIncluded: true, + atomic: true, + }); + + expect(result.isGasFeeIncluded).toBe(true); + expect(result.disable7702).toBe(false); + expect(result).toMatchInlineSnapshot(` + { + "atomic": true, + "disable7702": false, + "from": "0xUserAddress", + "isGasFeeIncluded": true, + "isGasFeeSponsored": false, + "isInternal": true, + "networkClientId": undefined, + "origin": "metamask", + "requireApproval": false, + "transactions": [ + { + "assetsFiatValues": { + "receiving": "200", + "sending": "100", + }, + "params": { + "data": "0xbridgeData", + "from": "0xUserAddress", + "gas": "0x5208", + "maxFeePerGas": "0xb1a2bc2ec50000", + "maxPriorityFeePerGas": "0xb1a2bc2ec50000", + "to": "0xBridgeContract", + "value": "0x1000", + }, + "type": "swap", + }, + ], + } + `); + }); + + it('should set isGasFeeIncluded to false and disable7702 to true when gasIncluded7702 is false', async () => { + const mockQuoteResponse = createMockQuoteResponse({ + gasIncluded7702: false, + }); + + const tradeData = toQuoteAndTxMetadata({ + quoteResponse: mockQuoteResponse, + isBridgeTx: false, + }); + const result = await getAddTransactionBatchParams({ + messenger: mockMessagingSystem, + tradeData, + disable7702: true, + isGasFeeIncluded: false, + isGasFeeSponsored: false, + atomic: true, + }); + + expect(result.isGasFeeIncluded).toBe(false); + expect(result.disable7702).toBe(true); + expect(result).toMatchInlineSnapshot(` + { + "atomic": true, + "disable7702": true, + "from": "0xUserAddress", + "isGasFeeIncluded": false, + "isGasFeeSponsored": false, + "isInternal": true, + "networkClientId": undefined, + "origin": "metamask", + "requireApproval": false, + "transactions": [ + { + "assetsFiatValues": { + "receiving": "200", + "sending": "100", + }, + "params": { + "data": "0xbridgeData", + "from": "0xUserAddress", + "gas": "0x5208", + "maxFeePerGas": undefined, + "maxPriorityFeePerGas": undefined, + "to": "0xBridgeContract", + "value": "0x1000", + }, + "type": "swap", + }, + ], + } + `); + }); + + it('should enable 7702 but include gas fields when isDelegatedAccount is true and gasIncluded7702 is false', async () => { + const mockQuoteResponse = createMockQuoteResponse({ + gasIncluded7702: false, + }); + + const mockMessenger = createMockMessagingSystem({ + estimates: { + type: GasFeeEstimateType.FeeMarket, + medium: { + maxFeePerGas: '0xabc', + maxPriorityFeePerGas: '0xdef', + }, + }, + }); + const callSpy = jest.spyOn(mockMessenger, 'call'); + + const tradeData = toQuoteAndTxMetadata({ + quoteResponse: mockQuoteResponse, + isBridgeTx: true, + }); + const result = await getAddTransactionBatchParams({ + messenger: mockMessenger, + isDelegatedAccount: true, + tradeData, + disable7702: false, + isGasFeeSponsored: Boolean(mockQuoteResponse.quote.gasSponsored), + isGasFeeIncluded: Boolean(mockQuoteResponse.quote.gasIncluded7702), + }); + + // 7702 should be enabled for delegated accounts + expect(result.disable7702).toBe(false); + // Gas is NOT sponsored + expect(result.isGasFeeIncluded).toBe(false); + expect(callSpy.mock.calls).toMatchInlineSnapshot(` + [ + [ + "AccountsController:getAccountByAddress", + "0xUserAddress", + ], + [ + "NetworkController:findNetworkClientIdByChainId", + "0x1", + ], + [ + "TransactionController:estimateGasFee", + { + "chainId": "0x1", + "networkClientId": undefined, + "transactionParams": { + "data": "0xbridgeData", + "from": "0xUserAddress", + "gas": "0x5208", + "to": "0xBridgeContract", + "value": "0x1000", + }, + }, + ], + ] + `); + // Transaction params should include gas fields + expect(result.transactions).toHaveLength(1); + // TxFee values from the estimateGasFee call + expect(result.transactions[0].params).toMatchInlineSnapshot(` + { + "data": "0xbridgeData", + "from": "0xUserAddress", + "gas": "0x5208", + "maxFeePerGas": "0xabc", + "maxPriorityFeePerGas": "0xdef", + "to": "0xBridgeContract", + "value": "0x1000", + } + `); + }); + + it('should enable 7702 and omit gas fields when isDelegatedAccount is true and gasIncluded7702 is true', async () => { + const mockQuoteResponse = createMockQuoteResponse({ + gasIncluded7702: true, + }); + + const callSpy = jest.spyOn(mockMessagingSystem, 'call'); + + const tradeData = toQuoteAndTxMetadata({ + quoteResponse: mockQuoteResponse, + isBridgeTx: true, + }); + + const result = await getAddTransactionBatchParams({ + tradeData, + messenger: mockMessagingSystem, + isDelegatedAccount: true, + disable7702: false, + isGasFeeSponsored: Boolean(mockQuoteResponse.quote.gasSponsored), + isGasFeeIncluded: Boolean(mockQuoteResponse.quote.gasIncluded7702), + }); + + // 7702 should be enabled + expect(result.disable7702).toBe(false); + // Gas IS sponsored + expect(result.isGasFeeIncluded).toBe(true); + // Gas estimation should NOT have been called (skipped because gas is sponsored) + expect( + callSpy.mock.calls.filter( + ([action]) => action === 'TransactionController:estimateGasFee', + ), + ).toHaveLength(0); + // Transaction params should NOT include gas fields + expect(result.transactions).toHaveLength(1); + // These are the txFee values from the quote response + expect(result.transactions[0].params).toMatchInlineSnapshot(` + { + "data": "0xbridgeData", + "from": "0xUserAddress", + "gas": "0x5208", + "maxFeePerGas": "0xb1a2bc2ec50000", + "maxPriorityFeePerGas": "0xb1a2bc2ec50000", + "to": "0xBridgeContract", + "value": "0x1000", + } + `); + }); + }); + + describe('findAndUpdateTransactionsInBatch', () => { + const batchId = 'test-batch-id'; + + const findAndUpdateTransactionsInBatch = ({ + messenger, + batchId: inputBatchId, + tradeData, + }: { + messenger: BridgeStatusControllerMessenger; + batchId: string; + tradeData: QuoteAndTxMetadata[]; + }) => { + const quoteAndTxMetas = findAllTransactionsInBatch({ + messenger, + batchId: inputBatchId, + tradeData, + }); + + const updatedQuoteAndTxMetas = updateTransactionsInBatch({ + messenger, + allTradesWithMetadata: quoteAndTxMetas, + }); + + return { + approvalMeta: updatedQuoteAndTxMetas.find( + ({ type, txMeta }) => isApprovalTx(type) && txMeta, + )?.txMeta, + tradeMeta: updatedQuoteAndTxMetas.find( + ({ type, txMeta }) => isTradeTx(type) && txMeta, + )?.txMeta, + }; + }; + const createMockTransaction = (overrides: { + id: string; + batchId?: string; + data?: string; + authorizationList?: string[]; + delegationAddress?: string; + type?: TransactionType; + }) => ({ + id: overrides.id, + batchId: overrides.batchId ?? batchId, + txParams: { + data: overrides.data ?? '0xdefaultData', + ...(overrides.authorizationList && { + authorizationList: overrides.authorizationList, + }), + }, + ...(overrides.delegationAddress && { + delegationAddress: overrides.delegationAddress, + }), + ...(overrides.type && { type: overrides.type }), + }); + + // Helper function to create mock messaging system with transactions + const createMockMessagingSystemWithTxs = ( + txs: ReturnType[], + ) => { + return { + call: jest.fn((method: string, ..._args: unknown[]) => { + if (method === 'TransactionController:getState') { + return { transactions: txs }; + } + if (method === 'TransactionController:updateTransaction') { + return { + transactionMeta: { id: 'tx1', type: TransactionType.swap }, + }; + } + return undefined; + }), + } as unknown as BridgeStatusControllerMessenger; + }; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('should update transaction types for 7702 swap transactions', () => { + const txs = [ + createMockTransaction({ + id: 'tx1', + data: '0xbatchExecuteData', + authorizationList: ['0xAuth1'], // 7702 transaction + type: TransactionType.batch, + }), + createMockTransaction({ + id: 'tx2', + data: '0xapprovalData', + }), + ]; + const mockMessagingSystem = createMockMessagingSystemWithTxs(txs); + const callSpy = jest.spyOn(mockMessagingSystem, 'call'); + + const tradeData = [ + { + tx: { data: '0xswapData' }, + type: TransactionType.swap, + }, + { + tx: { data: '0xapprovalData' }, + type: TransactionType.swapApproval, + }, + ] as unknown as QuoteAndTxMetadata[]; + + findAndUpdateTransactionsInBatch({ + messenger: mockMessagingSystem, + batchId, + tradeData, + }); + + expect( + callSpy.mock.calls.filter( + ([action]) => action === 'TransactionController:updateTransaction', + ), + ).toMatchInlineSnapshot(` + [ + [ + "TransactionController:updateTransaction", + { + "batchId": "test-batch-id", + "id": "tx1", + "txParams": { + "authorizationList": [ + "0xAuth1", + ], + "data": "0xbatchExecuteData", + }, + "type": "swap", + }, + "Update tx type to swap", + ], + [ + "TransactionController:updateTransaction", + { + "batchId": "test-batch-id", + "id": "tx2", + "txParams": { + "data": "0xapprovalData", + }, + "type": "swapApproval", + }, + "Update tx type to swapApproval", + ], + ] + `); + }); + + it('should handle 7702 transactions with delegationAddress', () => { + const txs = [ + createMockTransaction({ + id: 'tx1', + data: '0xbatchData', + delegationAddress: '0xDelegationAddress', // 7702 transaction marker + type: TransactionType.batch, + }), + ]; + + const mockMessenger = createMockMessagingSystemWithTxs(txs); + const callSpy = jest.spyOn(mockMessenger, 'call'); + const tradeData = [ + { + tx: { data: '0xswapData' }, + type: TransactionType.swap, + }, + ] as unknown as QuoteAndTxMetadata[]; + + findAndUpdateTransactionsInBatch({ + messenger: mockMessenger as unknown as BridgeStatusControllerMessenger, + batchId, + tradeData, + }); + + // Should identify and update 7702 transaction with delegationAddress + expect( + callSpy.mock.calls.find( + ([action]) => action === 'TransactionController:updateTransaction', + ), + ).toMatchInlineSnapshot(` + [ + "TransactionController:updateTransaction", + { + "batchId": "test-batch-id", + "delegationAddress": "0xDelegationAddress", + "id": "tx1", + "txParams": { + "data": "0xbatchData", + }, + "type": "swap", + }, + "Update tx type to swap", + ] + `); + }); + + it('should handle 7702 approval transactions', () => { + const txs = [ + createMockTransaction({ + id: 'tx1', + data: '0xapprovalData', + authorizationList: ['0xAuth1'], // 7702 transaction + }), + ]; + + const mockMessenger = createMockMessagingSystemWithTxs(txs); + const callSpy = jest.spyOn(mockMessenger, 'call'); + + const tradeData = [ + { + tx: { data: '0xapprovalData' }, + type: TransactionType.swapApproval, + }, + ] as unknown as QuoteAndTxMetadata[]; + findAndUpdateTransactionsInBatch({ + messenger: mockMessenger as unknown as BridgeStatusControllerMessenger, + batchId, + tradeData, + }); + + // Should match 7702 approval transaction by data + expect( + callSpy.mock.calls.filter( + (call) => call[0] === 'TransactionController:updateTransaction', + ), + ).toMatchInlineSnapshot(` + [ + [ + "TransactionController:updateTransaction", + { + "batchId": "test-batch-id", + "id": "tx1", + "txParams": { + "authorizationList": [ + "0xAuth1", + ], + "data": "0xapprovalData", + }, + "type": "swapApproval", + }, + "Update tx type to swapApproval", + ], + ] + `); + }); + + it('should handle non-7702 transactions normally', () => { + const txs = [ + createMockTransaction({ + id: 'tx1', + data: '0xswapData', + }), + createMockTransaction({ + id: 'tx2', + data: '0xapprovalData', + }), + ]; + + const mockMessenger = createMockMessagingSystemWithTxs(txs); + const callSpy = jest.spyOn(mockMessenger, 'call'); + const tradeData = [ + { + tx: { data: '0xswapData' }, + type: TransactionType.bridge, + }, + { + tx: { data: '0xapprovalData' }, + type: TransactionType.bridgeApproval, + }, + ] as unknown as QuoteAndTxMetadata[]; + + findAndUpdateTransactionsInBatch({ + messenger: mockMessenger as unknown as BridgeStatusControllerMessenger, + batchId, + tradeData, + }); + + // Should update regular transactions by matching data + expect( + callSpy.mock.calls.filter( + (call) => call[0] === 'TransactionController:updateTransaction', + ), + ).toMatchInlineSnapshot(` + [ + [ + "TransactionController:updateTransaction", + { + "batchId": "test-batch-id", + "id": "tx1", + "txParams": { + "data": "0xswapData", + }, + "type": "bridge", + }, + "Update tx type to bridge", + ], + [ + "TransactionController:updateTransaction", + { + "batchId": "test-batch-id", + "id": "tx2", + "txParams": { + "data": "0xapprovalData", + }, + "type": "bridgeApproval", + }, + "Update tx type to bridgeApproval", + ], + ] + `); + }); + + it('should not update transactions without matching batchId', () => { + const txs = [ + createMockTransaction({ + id: 'tx1', + batchId: 'different-batch-id', + data: '0xswapData', + }), + ]; + + const mockMessagingSystem = createMockMessagingSystemWithTxs(txs); + const callSpy = jest.spyOn(mockMessagingSystem, 'call'); + const tradeData = [ + { + tx: { data: '0xswapData' }, + type: TransactionType.swap, + }, + ] as unknown as QuoteAndTxMetadata[]; + + findAndUpdateTransactionsInBatch({ + messenger: mockMessagingSystem, + batchId, + tradeData, + }); + + // Should not update transactions with different batchId + expect( + callSpy.mock.calls.filter( + (call) => call[0] === 'TransactionController:updateTransaction', + ), + ).toHaveLength(0); + }); + + it('should handle 7702 bridge transactions', () => { + const txs = [ + createMockTransaction({ + id: 'tx1', + data: '0xbatchData', + authorizationList: ['0xAuth1'], + type: TransactionType.batch, + }), + ]; + + const mockMessagingSystem = createMockMessagingSystemWithTxs(txs); + + const tradeData = [ + { + tx: { data: '0xbridgeData' }, + type: TransactionType.bridge, + }, + ] as unknown as QuoteAndTxMetadata[]; + + // Test with bridge transaction — should match batch type for 7702 + const result = findAndUpdateTransactionsInBatch({ + messenger: mockMessagingSystem, + batchId, + tradeData, + }); + + // Should match since 7702 bridge transactions use batch type + expect(mockMessagingSystem.call).toHaveBeenCalledWith( + 'TransactionController:updateTransaction', + { + batchId, + id: 'tx1', + txParams: { + authorizationList: ['0xAuth1'], + data: '0xbatchData', + }, + type: TransactionType.bridge, + }, + 'Update tx type to bridge', + ); + expect(result.tradeMeta).toStrictEqual( + expect.objectContaining({ id: 'tx1', type: TransactionType.bridge }), + ); + }); + + it('should handle 7702 bridgeApproval transactions by matching data', () => { + const txs = [ + createMockTransaction({ + id: 'tx1', + data: '0xapprovalData', + authorizationList: ['0xAuth1'], + type: TransactionType.batch, + }), + ]; + + const mockMessagingSystem = createMockMessagingSystemWithTxs( + txs, + ) as unknown as BridgeStatusControllerMessenger; + + const tradeData = [ + { + tx: { data: '0xapprovalData' }, + type: TransactionType.bridgeApproval, + }, + ] as unknown as QuoteAndTxMetadata[]; + + const result = findAndUpdateTransactionsInBatch({ + messenger: mockMessagingSystem, + batchId, + tradeData, + }); + + expect(mockMessagingSystem.call).toHaveBeenCalledWith( + 'TransactionController:updateTransaction', + { + batchId, + id: 'tx1', + txParams: { + authorizationList: ['0xAuth1'], + data: '0xapprovalData', + }, + type: TransactionType.bridgeApproval, + }, + 'Update tx type to bridgeApproval', + ); + expect(result.approvalMeta).toStrictEqual( + expect.objectContaining({ + id: 'tx1', + type: TransactionType.bridgeApproval, + }), + ); + }); + }); +}); diff --git a/packages/bridge-status-controller/src/utils/transaction.ts b/packages/bridge-status-controller/src/utils/transaction.ts new file mode 100644 index 00000000000..922e5ff1f6c --- /dev/null +++ b/packages/bridge-status-controller/src/utils/transaction.ts @@ -0,0 +1,600 @@ +/* eslint-disable @typescript-eslint/explicit-function-return-type */ +import { + ChainId, + formatChainIdToHex, + BRIDGE_PREFERRED_GAS_ESTIMATE, + isEvmTxData, + FeeType, + BatchSellTransactionType, +} from '@metamask/bridge-controller'; +import type { + BatchSellTradesResponse, + QuoteMetadata, + QuoteResponseV1, + SimulatedGasFeeLimits, + Trade, + TxData, + TxFeeGasLimits, +} from '@metamask/bridge-controller'; +import { toHex } from '@metamask/controller-utils'; +import { + GasFeeEstimateType, + TransactionStatus, + TransactionType, +} from '@metamask/transaction-controller'; +import type { + IsAtomicBatchSupportedResultEntry, + TransactionController, + TransactionMeta, + TransactionBatchSingleRequest, + BatchTransactionParams, +} from '@metamask/transaction-controller'; +import { createProjectLogger, isStrictHexString } from '@metamask/utils'; +import type { Hex } from '@metamask/utils'; + +import { APPROVAL_DELAY_MS } from '../constants.js'; +import type { BridgeStatusControllerMessenger } from '../types.js'; +import type { QuoteAndTxMetadata } from '../types.js'; +import { getAccountByAddress } from './accounts.js'; +import { getNetworkClientIdByChainId } from './network.js'; + +export const isApprovalTx = (type: TransactionType) => + type === TransactionType.bridgeApproval || + type === TransactionType.swapApproval; +export const isTradeTx = (type: TransactionType) => + type === TransactionType.bridge || type === TransactionType.swap; +export const isCrossChainTx = (type: TransactionType) => + isTradeTx(type) || isApprovalTx(type); + +/** + * For 7702 delegated transactions, check for delegation-specific fields + * These transactions might have authorizationList or delegationAddress + * + * @param tx - The transaction meta + * @returns Whether the transaction is a 7702 transaction + */ +export const is7702Tx = (tx: TransactionMeta) => { + return ( + (Array.isArray(tx.txParams.authorizationList) && + tx.txParams.authorizationList.length > 0) || + Boolean(tx.delegationAddress) + ); +}; + +export const shouldDisable7702 = ( + gasIncluded7702: boolean = false, + gasIncluded: boolean = false, + isDelegatedAccount: boolean = false, +): boolean => { + // Enable 7702 batching when the quote includes gasless 7702 support + if (gasIncluded7702) { + return false; + } + // Enable batching when the account is already delegated (to avoid the in-flight transaction limit for delegated accounts) + // For gasless transactions with STX/sendBundle we keep disabling 7702 + if (isDelegatedAccount && !gasIncluded) { + return false; + } + /** + * Explicitly return default instead of falsy value (see TransactionBatchRequest.disable7702) + */ + return true; +}; + +export const hasNestedSwapTransactions = (txMeta: TransactionMeta) => { + return Boolean( + txMeta?.nestedTransactions?.some((tx) => tx.type === TransactionType.swap), + ); +}; + +export const getGasFeeEstimates = async ( + messenger: BridgeStatusControllerMessenger, + args: Parameters[0], +) => { + const { estimates } = await messenger.call( + 'TransactionController:estimateGasFee', + args, + ); + + if (estimates?.type === GasFeeEstimateType.FeeMarket) { + return estimates[BRIDGE_PREFERRED_GAS_ESTIMATE]; + } + + return undefined; +}; + +export const getTransactions = (messenger: BridgeStatusControllerMessenger) => { + return messenger.call('TransactionController:getState').transactions ?? []; +}; + +export const getTransactionMetaById = ( + messenger: BridgeStatusControllerMessenger, + txId?: string, +) => { + return getTransactions(messenger).find( + (tx: TransactionMeta) => tx.id === txId, + ); +}; + +export const getTransactionMetaByHash = ( + messenger: BridgeStatusControllerMessenger, + txHash?: string, +) => { + return getTransactions(messenger).find( + (tx: TransactionMeta) => tx.hash?.toLowerCase() === txHash?.toLowerCase(), + ); +}; + +export const updateTransaction = ( + messenger: BridgeStatusControllerMessenger, + txMeta: TransactionMeta, + txMetaUpdates: Partial, + note: string, +) => { + return messenger.call( + 'TransactionController:updateTransaction', + { ...txMeta, ...txMetaUpdates }, + note, + ); +}; + +export const checkIsDelegatedAccount = async ( + messenger: BridgeStatusControllerMessenger, + fromAddress: Hex, + chainIds: Hex[], +): Promise => { + try { + const atomicBatchSupport = await messenger.call( + 'TransactionController:isAtomicBatchSupported', + { + address: fromAddress, + chainIds, + }, + ); + return atomicBatchSupport.some( + (entry: IsAtomicBatchSupportedResultEntry) => + entry.isSupported && entry.delegationAddress, + ); + } catch { + return false; + } +}; + +const waitForHashAndReturnFinalTxMeta = async ( + messenger: BridgeStatusControllerMessenger, + hashPromise?: Awaited< + ReturnType + >['result'], +): Promise => { + const txHash = await hashPromise; + const finalTransactionMeta = getTransactionMetaByHash(messenger, txHash); + if (!finalTransactionMeta) { + throw new Error( + 'Failed to submit cross-chain swap tx: txMeta for txHash was not found', + ); + } + return finalTransactionMeta; +}; + +export const addTransaction = async ( + messenger: BridgeStatusControllerMessenger, + ...args: Parameters +) => { + const { result } = await messenger.call( + 'TransactionController:addTransaction', + ...args, + ); + return await waitForHashAndReturnFinalTxMeta(messenger, result); +}; + +export const generateActionId = () => (Date.now() + Math.random()).toString(); + +/** + * Adds a synthetic transaction to the TransactionController to display pending intent orders in the UI + * + * @param messenger - The messenger to use for the transaction + * @param args - The arguments for the transaction + * @returns The transaction meta + */ +export const addSyntheticTransaction = async ( + messenger: BridgeStatusControllerMessenger, + ...args: Parameters +) => { + const { transactionMeta } = await messenger.call( + 'TransactionController:addTransaction', + args[0], + { + origin: 'metamask', + actionId: generateActionId(), + isStateOnly: true, + isInternal: true, + ...args[1], + }, + ); + return transactionMeta; +}; + +export const handleApprovalDelay = async ( + srcChainId: QuoteResponseV1['quote']['srcChainId'], +) => { + if ([ChainId.LINEA, ChainId.BASE].includes(srcChainId)) { + const debugLog = createProjectLogger('bridge'); + debugLog( + 'Delaying submitting bridge tx to make Linea and Base confirmation more likely', + ); + const waitPromise = new Promise((resolve) => + setTimeout(resolve, APPROVAL_DELAY_MS), + ); + await waitPromise; + } +}; + +/** + * Adds a delay for hardware wallet transactions on mobile to fix an issue + * where the Ledger does not get prompted for the 2nd approval. + * Extension does not have this issue. + * + * @param requireApproval - Whether the delay should be applied + */ +export const handleMobileHardwareWalletDelay = async ( + requireApproval: boolean, +) => { + if (requireApproval) { + const mobileHardwareWalletDelay = new Promise((resolve) => + setTimeout(resolve, 1000), + ); + await mobileHardwareWalletDelay; + } +}; + +/** + * Waits until a given transaction (by id) reaches confirmed/finalized status or fails/times out. + * + * @deprecated use addTransaction util + * @param messenger - the BridgeStatusControllerMessenger + * @param txId - the transaction ID + * @param options - the options for the timeout and poll + * @param options.timeoutMs - the timeout in milliseconds + * @param options.pollMs - the poll interval in milliseconds + * @returns the transaction meta + */ +export const waitForTxConfirmation = async ( + messenger: BridgeStatusControllerMessenger, + txId: string, + { + timeoutMs = 5 * 60_000, + pollMs = 3_000, + }: { timeoutMs?: number; pollMs?: number } = {}, +): Promise => { + const start = Date.now(); + while (true) { + const meta = getTransactionMetaById(messenger, txId); + + if (meta) { + if (meta.status === TransactionStatus.confirmed) { + return meta; + } + if ( + meta.status === TransactionStatus.failed || + meta.status === TransactionStatus.dropped || + meta.status === TransactionStatus.rejected + ) { + throw new Error('Approval transaction did not confirm'); + } + } + + if (Date.now() - start > timeoutMs) { + throw new Error('Timed out waiting for approval confirmation'); + } + + await new Promise((resolve) => setTimeout(resolve, pollMs)); + } +}; + +export const toQuoteAndTxMetadata = ({ + quoteResponse, + isBridgeTx, +}: { + quoteResponse: QuoteResponseV1 & QuoteMetadata; + isBridgeTx: boolean; +}): Omit[] => { + const tradeData: QuoteAndTxMetadata[] = []; + + const approvalTxType = isBridgeTx + ? TransactionType.bridgeApproval + : TransactionType.swapApproval; + + if (quoteResponse.resetApproval) { + tradeData.push({ + quoteResponse, + tx: quoteResponse.resetApproval, + type: approvalTxType, + txFee: quoteResponse.quote.feeData[FeeType.TX_FEE], + }); + } + if (quoteResponse.approval && isEvmTxData(quoteResponse.approval)) { + tradeData.push({ + quoteResponse, + tx: quoteResponse.approval, + type: approvalTxType, + txFee: quoteResponse.quote.feeData[FeeType.TX_FEE], + }); + } + tradeData.push({ + quoteResponse, + tx: quoteResponse.trade as TxData, + type: isBridgeTx ? TransactionType.bridge : TransactionType.swap, + assetsFiatValues: { + sending: quoteResponse.sentAmount?.valueInCurrency?.toString(), + receiving: quoteResponse.toTokenAmount?.valueInCurrency?.toString(), + }, + txFee: quoteResponse.quote.feeData[FeeType.TX_FEE], + }); + + return tradeData; +}; + +/** + * Build the trade+quote metadata array for the batch sell transaction + * This ties together the quote, the tx params and the txMeta after submission + * + * @param options - The options for the batch sell transaction + * @param options.quoteResponses - The quote responses for the batch sell transaction + * @param options.batchSellTrades - The batch sell trades for the batch sell transaction + * @returns The trade+quote metadata array for the batch sell transaction + */ +export const toQuoteAndTxMetadataBatch = ({ + quoteResponses, + batchSellTrades, +}: { + quoteResponses: (QuoteResponseV1 & QuoteMetadata)[]; + batchSellTrades: BatchSellTradesResponse; +}): Omit[] => { + const tradeData: QuoteAndTxMetadata[] = []; + + const { + transactions, + gasIncluded7702, + gasIncluded, + gasSponsored = false, + } = batchSellTrades; + + for (const transaction of transactions) { + const { type, maxFeePerGas, maxPriorityFeePerGas, ...tx } = transaction; + // Match the trade or approval tx data with the quote response + const matchingQuoteResponse = + quoteResponses.find( + ({ approval, trade }) => + trade?.data.toLowerCase() === tx.data.toLowerCase() || + approval?.data.toLowerCase() === tx.data.toLowerCase(), + ) ?? quoteResponses[0]; + + // Include gasIncluded and gasIncluded7702 from the gasless batch + const normalizedQuote = { + ...matchingQuoteResponse, + quote: { + ...matchingQuoteResponse.quote, + gasIncluded, + gasIncluded7702, + gasSponsored, + }, + }; + + const commonTradeData = { + tx, + quoteResponse: normalizedQuote, + txFee: { maxFeePerGas, maxPriorityFeePerGas }, + }; + + if (type === BatchSellTransactionType.TRADE) { + tradeData.push({ + ...commonTradeData, + type: TransactionType.swap, + assetsFiatValues: { + sending: + matchingQuoteResponse.sentAmount?.valueInCurrency?.toString(), + receiving: + matchingQuoteResponse.toTokenAmount?.valueInCurrency?.toString(), + }, + }); + } else { + tradeData.push({ + ...commonTradeData, + type: + type === BatchSellTransactionType.APPROVAL + ? TransactionType.swapApproval + : TransactionType.tokenMethodTransfer, + }); + } + } + + return tradeData; +}; + +/** + * Appends the gas fee estimates for a transaction and normalizes the trade data + * + * @param messenger - The messenger for the gas fee estimates + * @param trade - the trade data to append gas fees to + * @param trade.chainId - ignored, use chainId instead + * @param trade.gasLimit - the gas limit to use for the gas fee estimates + * @param networkClientId - the network client ID to use for the gas fee estimates + * @param chainId - the chain ID to use for the gas fee estimates + * @param simulatedGasFeeLimits - either the txFee from the quote or the simulated gas fee limits for the batch sell + * @returns The gas fee estimates for the transaction + */ +export const toTransactionParams = async ( + messenger: BridgeStatusControllerMessenger, + { chainId: tradeChainId, gasLimit, ...trade }: TxData, + networkClientId: string, + chainId: Hex, + simulatedGasFeeLimits?: SimulatedGasFeeLimits | TxFeeGasLimits, +): Promise => { + const transactionParams = { + data: trade.data, + to: trade.to, + from: trade.from, + value: trade.value, + // Only add gas if it's truthy + gas: gasLimit ? toHex(gasLimit) : undefined, + }; + + // Use bridge-api's provided gas fee estimates + if (simulatedGasFeeLimits) { + return { + ...transactionParams, + // Sometimes estimates are hex, somethings numeric strings + maxFeePerGas: isStrictHexString(simulatedGasFeeLimits.maxFeePerGas) + ? simulatedGasFeeLimits.maxFeePerGas + : toHex(simulatedGasFeeLimits.maxFeePerGas), + maxPriorityFeePerGas: isStrictHexString( + simulatedGasFeeLimits.maxPriorityFeePerGas, + ) + ? simulatedGasFeeLimits.maxPriorityFeePerGas + : toHex(simulatedGasFeeLimits.maxPriorityFeePerGas), + }; + } + + // Get transaction's 1559 gas fee estimates + const gasFeeEstimates = await getGasFeeEstimates(messenger, { + transactionParams, + networkClientId, + chainId, + }); + + return { + ...transactionParams, + maxFeePerGas: gasFeeEstimates?.maxFeePerGas, + maxPriorityFeePerGas: gasFeeEstimates?.maxPriorityFeePerGas, + }; +}; + +export const getAddTransactionBatchParams = async ({ + messenger, + tradeData, + requireApproval = false, + isDelegatedAccount, + ...addTransactionBatchParams +}: Partial[0]> & { + messenger: BridgeStatusControllerMessenger; + tradeData: QuoteAndTxMetadata[]; + requireApproval?: boolean; + isDelegatedAccount?: boolean; +}): Promise[0]> => { + const trade = tradeData[0].tx; + const selectedAccount = getAccountByAddress(messenger, trade.from); + if (!selectedAccount) { + throw new Error( + 'Failed to submit cross-chain swap batch transaction: unknown account in trade data', + ); + } + const hexChainId = formatChainIdToHex(trade.chainId); + const networkClientId = getNetworkClientIdByChainId(messenger, hexChainId); + + const transactions: TransactionBatchSingleRequest[] = await Promise.all( + tradeData.map(async ({ tx, txFee, assetsFiatValues, type }) => ({ + params: await toTransactionParams( + messenger, + tx, + networkClientId, + hexChainId, + txFee, + ), + assetsFiatValues, + type, + })), + ); + + return { + networkClientId, + requireApproval, + origin: 'metamask', + from: selectedAccount.address as Hex, + isInternal: true, + transactions, + ...addTransactionBatchParams, + }; +}; + +export const findAllTransactionsInBatch = ({ + messenger, + batchId, + tradeData, +}: { + messenger: BridgeStatusControllerMessenger; + batchId: string; + tradeData: QuoteAndTxMetadata[]; +}): QuoteAndTxMetadata[] => { + // Filter for transactions with batchId + const txs = getTransactions(messenger).filter( + (tx: TransactionMeta) => tx.batchId === batchId, + ); + + return tradeData.map((tradeWithMetadata) => { + const { tx, type } = tradeWithMetadata; + return { + ...tradeWithMetadata, + txMeta: txs.find((txMeta: TransactionMeta) => { + if (is7702Tx(txMeta)) { + // For 7702 transactions, we need to match based on transaction type + // since the data field might be different (batch execute call) + if (isTradeTx(type) && txMeta.type === TransactionType.batch) { + return true; + } + // Also check if it's an approval transaction for 7702 + if (isApprovalTx(type) && txMeta.txParams.data === tx.data) { + return true; + } + } + // Default matching logic for non-7702 transactions + if (txMeta.txParams.data === tx.data) { + return true; + } + return false; + }), + }; + }); +}; + +/** + * This is a workaround to update the tx type after submission. Batch txs are submitted with + * the "batch" type, but we need to update to swap/bridge for display purposes. + * + * @param params - The parameters for the transaction search + * @param params.messenger - The messenger to use for the transaction + * @param params.allTradesWithMetadata - The quote, tx data and type for each transaction in the batch + * @returns A list of transaction metas for each trade in the batch] + * + * @example + * [ + * {...tradeData[0], tradeMeta: TransactionMeta} + * {...tradeData[1], tradeMeta: TransactionMeta} + * {...tradeData[2], tradeMeta: TransactionMeta} + * {...tradeData[3], tradeMeta: TransactionMeta} + * ] + */ +export const updateTransactionsInBatch = ({ + messenger, + allTradesWithMetadata, +}: { + messenger: BridgeStatusControllerMessenger; + allTradesWithMetadata: QuoteAndTxMetadata[]; +}) => { + return allTradesWithMetadata.map((tradeWithMetadata) => { + const { txMeta, type } = tradeWithMetadata; + + if (txMeta) { + // Update the tx type from batch to swap/bridge + updateTransaction( + messenger, + txMeta, + { type }, + `Update tx type to ${type}`, + ); + const updatedTx = { ...txMeta, type }; + return { ...tradeWithMetadata, txMeta: updatedTx, type }; + } + + return tradeWithMetadata; + }); +}; diff --git a/packages/bridge-status-controller/src/utils/validators.test.ts b/packages/bridge-status-controller/src/utils/validators.test.ts new file mode 100644 index 00000000000..ab842f35bb2 --- /dev/null +++ b/packages/bridge-status-controller/src/utils/validators.test.ts @@ -0,0 +1,327 @@ +import { validateBridgeStatusResponse } from './validators.js'; + +const BridgeTxStatusResponses = { + STATUS_PENDING_VALID: { + status: 'PENDING', + bridge: 'across', + srcChain: { + chainId: 42161, + txHash: + '0x76a65e4cea35d8732f0e3250faed00ba764ad5a0e7c51cb1bafbc9d76ac0b325', + amount: '991250000000000', + token: { + address: '0x0000000000000000000000000000000000000000', + assetId: + 'eip155:42161/erc20:0x82af49447d8a07e3bd95bd0d56f35241523fbab1', + chainId: 42161, + symbol: 'ETH', + decimals: 18, + name: 'ETH', + coinKey: 'ETH', + logoURI: + 'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png', + priceUSD: '2550.12', + icon: 'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png', + }, + }, + destChain: { + chainId: 10, + token: {}, + }, + }, + STATUS_PENDING_VALID_MISSING_FIELDS: { + status: 'PENDING', + srcChain: { + chainId: 42161, + txHash: + '0x5cbda572c686a5a57fe62735325e408f9164f77a4787df29ce13edef765adaa9', + }, + }, + STATUS_PENDING_VALID_MISSING_FIELDS_2: { + status: 'PENDING', + bridge: 'hop', + srcChain: { + chainId: 42161, + txHash: + '0x5cbda572c686a5a57fe62735325e408f9164f77a4787df29ce13edef765adaa9', + amount: '991250000000000', + token: { + chainId: 42161, + assetId: + 'eip155:42161/erc20:0x82af49447d8a07e3bd95bd0d56f35241523fbab1', + address: '0xeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee', + symbol: 'ETH', + name: 'Ethereum', + decimals: 18, + icon: 'https://media.socket.tech/tokens/all/ETH', + logoURI: 'https://media.socket.tech/tokens/all/ETH', + chainAgnosticId: null, + }, + }, + }, + STATUS_PENDING_INVALID_MISSING_FIELDS: { + status: 'PENDING', + bridge: 'across', + srcChain: { + chainId: 42161, + txHash: + '0x76a65e4cea35d8732f0e3250faed00ba764ad5a0e7c51cb1bafbc9d76ac0b325', + amount: '991250000000000', + token: { + address: '0x0000000000000000000000000000000000000000', + assetId: + 'eip155:42161/erc20:0x82af49447d8a07e3bd95bd0d56f35241523fbab1', + chainId: 42161, + symbol: 'ETH', + decimals: 18, + name: 'ETH', + coinKey: 'ETH', + logoURI: + 'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png', + priceUSD: '2550.12', + icon: 'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png', + }, + }, + destChain: { + token: {}, + }, + }, + STATUS_COMPLETE_VALID: { + status: 'COMPLETE', + isExpectedToken: true, + bridge: 'across', + srcChain: { + chainId: 10, + txHash: + '0x9fdc426692aba1f81e145834602ed59ed331054e5b91a09a673cb12d4b4f6a33', + amount: '4956250000000000', + token: { + address: '0x0000000000000000000000000000000000000000', + assetId: 'eip155:10/erc20:0x4200000000000000000000000000000000000006', + chainId: 10, + symbol: 'ETH', + decimals: 18, + name: 'ETH', + coinKey: 'ETH', + logoURI: + 'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png', + priceUSD: '2649.21', + icon: 'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png', + }, + }, + destChain: { + chainId: 42161, + txHash: + '0x3a494e672717f9b1f2b64a48a19985842d82d0747400fccebebc7a4e99c8eaab', + amount: '4926701727965948', + token: { + address: '0x0000000000000000000000000000000000000000', + assetId: 'eip155:8453/erc20:0x4200000000000000000000000000000000000006', + chainId: 42161, + symbol: 'ETH', + decimals: 18, + name: 'ETH', + coinKey: 'ETH', + logoURI: + 'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png', + priceUSD: '2648.72', + icon: 'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png', + }, + }, + }, + STATUS_COMPLETE_VALID_MISSING_FIELDS: { + status: 'COMPLETE', + bridge: 'across', + srcChain: { + chainId: 10, + txHash: + '0x9fdc426692aba1f81e145834602ed59ed331054e5b91a09a673cb12d4b4f6a33', + amount: '4956250000000000', + token: { + address: '0x0000000000000000000000000000000000000000', + assetId: 'eip155:10/erc20:0x4200000000000000000000000000000000000006', + chainId: 10, + symbol: 'ETH', + decimals: 18, + name: 'ETH', + coinKey: 'ETH', + logoURI: + 'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png', + priceUSD: '2649.21', + icon: 'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png', + }, + }, + destChain: { + chainId: 42161, + txHash: + '0x3a494e672717f9b1f2b64a48a19985842d82d0747400fccebebc7a4e99c8eaab', + amount: '4926701727965948', + token: { + assetId: 'eip155:8453/erc20:0x4200000000000000000000000000000000000006', + address: '0x0000000000000000000000000000000000000000', + chainId: 42161, + symbol: 'ETH', + decimals: 18, + name: 'ETH', + coinKey: 'ETH', + logoURI: + 'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png', + priceUSD: '2648.72', + icon: 'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png', + }, + }, + }, + STATUS_COMPLETE_VALID_MISSING_FIELDS_2: { + status: 'COMPLETE', + isExpectedToken: false, + bridge: 'across', + srcChain: { + chainId: 10, + txHash: + '0x4c57876fad21fb5149af5a58a4aba2ca9d6b212014505dd733b75667ca4f0f2b', + amount: '991250000000000', + token: { + chainId: 10, + assetId: 'eip155:10/erc20:0x4200000000000000000000000000000000000006', + address: '0x4200000000000000000000000000000000000006', + symbol: 'WETH', + name: 'Wrapped Ether', + decimals: 18, + icon: 'https://media.socket.tech/tokens/all/WETH', + // logoURI: 'https://media.socket.tech/tokens/all/WETH', + // chainAgnosticId: 'ETH', + }, + }, + destChain: { + chainId: 8453, + txHash: + '0x60c4cad7c3eb14c7b3ace40cd4015b90927dadacbdc8673f404bea6a5603844b', + amount: '988339336750062', + token: { + chainId: 8453, + assetId: 'eip155:8453/erc20:0x4200000000000000000000000000000000000006', + address: '0x4200000000000000000000000000000000000006', + symbol: 'WETH', + name: 'Wrapped Ether', + decimals: 18, + icon: null, + // logoURI: null, + // chainAgnosticId: null, + }, + }, + }, + STATUS_COMPLETE_INVALID_MISSING_FIELDS: { + status: 'COMPLETE', + isExpectedToken: true, + bridge: 'across', + }, + STATUS_FAILED_VALID: { + status: 'FAILED', + bridge: 'across', + srcChain: { + chainId: 42161, + txHash: + '0x4c57876fad21fb5149af5a58a4aba2ca9d6b212014505dd733b75667ca4f0f2b', + token: {}, + }, + }, + STATUS_SQUID_VALID: { + status: 'COMPLETE', + isExpectedToken: true, + bridge: 'axelar', + srcChain: { + chainId: 10, + txHash: + '0x9fdc426692aba1f81e145834602ed59ed331054e5b91a09a673cb12d4b4f6a33', + }, + destChain: { + chainId: 42161, + txHash: + '0x3a494e672717f9b1f2b64a48a19985842d82d0747400fccebebc7a4e99c8eaab', + }, + }, +}; + +describe('validators', () => { + describe('bridgeStatusValidator', () => { + it.each([ + { + input: BridgeTxStatusResponses.STATUS_PENDING_VALID, + description: 'valid pending bridge status', + }, + { + input: BridgeTxStatusResponses.STATUS_PENDING_VALID_MISSING_FIELDS, + description: 'valid pending bridge status missing fields', + }, + { + input: BridgeTxStatusResponses.STATUS_PENDING_VALID_MISSING_FIELDS_2, + description: 'valid pending bridge status missing fields 2', + }, + { + input: BridgeTxStatusResponses.STATUS_COMPLETE_VALID, + description: 'valid complete bridge status', + }, + { + input: BridgeTxStatusResponses.STATUS_COMPLETE_VALID_MISSING_FIELDS_2, + description: 'complete bridge status with missing fields 2', + }, + { + input: BridgeTxStatusResponses.STATUS_COMPLETE_VALID_MISSING_FIELDS, + description: 'complete bridge status with missing fields', + }, + { + input: BridgeTxStatusResponses.STATUS_FAILED_VALID, + description: 'valid failed bridge status', + }, + { + input: BridgeTxStatusResponses.STATUS_SQUID_VALID, + description: 'valid squid bridge status', + }, + { + input: { + status: 'COMPLETE', + srcChain: { + chainId: 1151111081099710, + txHash: + '33LfknAQsrLC1WzmNybkZWUtuGANRFHNupsQ1YLCnjXGXxbBE93BbVTeKLLdE7Sz3WUdxnFW5HQhPuUayrXyqWky', + }, + }, + description: 'placeholder complete swap status', + }, + ])( + 'should not throw for valid response for $description', + ({ input }: { input: unknown }) => { + expect(() => validateBridgeStatusResponse(input)).not.toThrow(); + }, + ); + + it.each([ + { + input: BridgeTxStatusResponses.STATUS_PENDING_INVALID_MISSING_FIELDS, + description: 'pending bridge status with missing fields', + }, + { + input: BridgeTxStatusResponses.STATUS_COMPLETE_INVALID_MISSING_FIELDS, + description: 'complete bridge status with missing fields', + }, + { + input: undefined, + description: 'undefined', + }, + { + input: null, + description: 'null', + }, + { + description: 'empty object', + input: {}, + }, + ])( + 'should throw for invalid response for $description', + ({ input }: { input: unknown }) => { + // eslint-disable-next-line jest/require-to-throw-message + expect(() => validateBridgeStatusResponse(input)).toThrow(); + }, + ); + }); +}); diff --git a/packages/bridge-status-controller/src/utils/validators.ts b/packages/bridge-status-controller/src/utils/validators.ts new file mode 100644 index 00000000000..5437597498f --- /dev/null +++ b/packages/bridge-status-controller/src/utils/validators.ts @@ -0,0 +1,88 @@ +import { StatusTypes, BridgeAssetSchema } from '@metamask/bridge-controller'; +import type { Infer } from '@metamask/superstruct'; +import { + string, + boolean, + number, + optional, + enums, + union, + type, + assert, + array, + is, +} from '@metamask/superstruct'; + +const ChainIdSchema = number(); + +const EmptyObjectSchema = type({}); + +const SrcChainStatusSchema = type({ + chainId: ChainIdSchema, + /** + * The txHash of the transaction on the source chain. + * This might be undefined for smart transactions (STX) + */ + txHash: optional(string()), + /** + * The atomic amount of the token sent minus fees on the source chain + */ + amount: optional(string()), + token: optional(union([EmptyObjectSchema, BridgeAssetSchema])), +}); + +const DestChainStatusSchema = type({ + chainId: ChainIdSchema, + txHash: optional(string()), + /** + * The atomic amount of the token received on the destination chain + */ + amount: optional(string()), + token: optional(union([EmptyObjectSchema, BridgeAssetSchema])), +}); + +const RefuelStatusResponseSchema = type({}); + +export const StatusResponseSchema = type({ + status: enums(Object.values(StatusTypes)), + srcChain: SrcChainStatusSchema, + destChain: optional(DestChainStatusSchema), + bridge: optional(string()), + isExpectedToken: optional(boolean()), + isUnrecognizedRouterAddress: optional(boolean()), + refuel: optional(RefuelStatusResponseSchema), +}); + +export const validateBridgeStatusResponse = ( + data: unknown, +): data is Infer => { + assert(data, StatusResponseSchema); + return true; +}; + +export enum IntentOrderStatus { + PENDING = 'pending', + SUBMITTED = 'submitted', + CONFIRMED = 'confirmed', + COMPLETED = 'completed', + FAILED = 'failed', + CANCELLED = 'cancelled', + EXPIRED = 'expired', +} + +const IntentStatusResponseSchema = type({ + id: string(), + status: enums(Object.values(IntentOrderStatus)), + txHash: optional(string()), + metadata: type({ + txHashes: optional(union([array(string()), string()])), + }), +}); + +export type IntentStatusResponse = Infer; + +export const validateIntentStatusResponse = ( + data: unknown, +): data is IntentStatusResponse => { + return is(data, IntentStatusResponseSchema); +}; diff --git a/packages/bridge-status-controller/test/mock-batch-sell-erc20-erc20.ts b/packages/bridge-status-controller/test/mock-batch-sell-erc20-erc20.ts new file mode 100644 index 00000000000..3dbce387b4e --- /dev/null +++ b/packages/bridge-status-controller/test/mock-batch-sell-erc20-erc20.ts @@ -0,0 +1,307 @@ +import { + BatchSellTransactionType, + getNativeAssetForChainId, + MetaMetricsSwapsEventSource, + BatchSellTradesResponse, + Quote, + QuoteResponse, + StatusTypes, + TxData, + FeatureId, +} from '@metamask/bridge-controller'; +import { toHex } from '@metamask/controller-utils'; +import { + TransactionMeta, + TransactionStatus, + TransactionType, +} from '@metamask/transaction-controller'; + +import { BridgeHistoryItem } from '../src/index.js'; + +export const mockBatchSellErc20Erc20: QuoteResponse[] = [ + { + featureId: FeatureId.BATCH_SELL, + quote: { + requestId: '90ae8e69-f03a-4cf6-bab7-ed4e3431eb37', + srcChainId: 10, + srcAsset: { + chainId: 10, + address: '0x0b2C639c533813f4Aa9D7837CAf62653d097Ff85', + assetId: 'eip155:10/erc20:0x0b2c639c533813f4aa9d7837caf62653d097ff85', + symbol: 'USDC', + name: 'USD Coin', + decimals: 6, + icon: 'https://media.socket.tech/tokens/all/USDC', + }, + srcTokenAmount: '14000000', + destChainId: 10, + destAsset: { + chainId: 10, + address: '0x3c499c542cef5e3811e1192ce70d8cc03d5c3359', + assetId: 'eip155:10/erc20:0x3c499c542cef5e3811e1192ce70d8cc03d5c3359', + symbol: 'USDC', + name: 'Native USD Coin (POS)', + decimals: 6, + icon: 'https://media.socket.tech/tokens/all/USDC', + }, + destTokenAmount: '13984280', + minDestTokenAmount: '13700000', + feeData: { + metabridge: { + amount: '0', + asset: { + chainId: 10, + address: '0x0b2C639c533813f4Aa9D7837CAf62653d097Ff85', + assetId: + 'eip155:10/erc20:0x0b2c639c533813f4aa9d7837caf62653d097ff85', + symbol: 'USDC', + name: 'USD Coin', + decimals: 6, + icon: 'https://media.socket.tech/tokens/all/USDC', + }, + }, + }, + bridgeId: 'socket', + bridges: ['across'], + steps: [], + }, + approval: { + chainId: 10, + to: '0x0b2c639c533813f4aa9d7837caf62653d097ff85', + from: '0x141d32a89a1e0a5ef360034a2f60a4b917c18838', + value: '0x00', + data: '0x095ea7b3000000000000000000000000b90357f2b86dbfd59c3502215d4060f71df8ca0e0000000000000000000000000000000000000000000000000000000000d59f80', + gasLimit: 61865, + }, + trade: { + chainId: 10, + to: '0xB90357f2b86dbfD59c3502215d4060f71DF8ca0e', + from: '0x141d32a89a1e0a5ef360034a2f60a4b917c18838', + value: '0x038d7ea4c68000', + data: '0x3ce33bff00000000000000000000000000000000000000000000000000000000000000800000000000000000000000000b2c639c533813f4aa9d7837caf62653d097ff850000000000000000000000000000000000000000000000000000000000d59f8000000000000000000000000000000000000000000000000000000000000000c0000000000000000000000000000000000000000000000000000000000000000f736f636b6574416461707465725632000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000005e00000000000000000000000003a23f943181408eac424116af7b7790c94cb97a50000000000000000000000003a23f943181408eac424116af7b7790c94cb97a500000000000000000000000000000000000000000000000000000000000000890000000000000000000000000b2c639c533813f4aa9d7837caf62653d097ff850000000000000000000000003c499c542cef5e3811e1192ce70d8cc03d5c33590000000000000000000000000000000000000000000000000000000000d59f8000000000000000000000000000000000000000000000000000000000000001400000000000000000000000000000000000000000000000000000000000000000000000000000000000000000716a8b9dd056055c84b7a2ba0a016099465a518700000000000000000000000000000000000000000000000000000000000004a0c3540448000000000000000000000000000000000000000000000000000000000000004000000000000000000000000000000000000000000000000000000000000000a000000000000000000000000000000000000000000000000000000000000000020000000000000000000000000000000000000000000000000000000000000005000000000000000000000000000000000000000000000000000000000000019d0000000000000000000000000000000000000000000000000000000000000002000000000000000000000000000000000000000000000000000000000000004000000000000000000000000000000000000000000000000000000000000001000000000000000000000000000000000000000000000000000000000000000084ad69fa4f00000000000000000000000000000000000000000000000000038d7ea4c68000000000000000000000000000141d32a89a1e0a5ef360034a2f60a4b917c1883800000000000000000000000000000000000000000000000000000000000000890000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000284792ebcb90000000000000000000000000000000000000000000000000000000000d59f80000000000000000000000000000000000000000000000000000000000000004000000000000000000000000000000000000000000000000000000000000000c00000000000000000000000000000000000000000000000000000000000000120000000000000000000000000000000000000000000000000000000000000018000000000000000000000000000000000000000000000000000000000000001e0000000000000000000000000000000000000000000000000000000000000454000000000000000000000000000000000000000000000000000000000000000c40000000000000000000000000000000000000000000000000000000000000002000000000000000000000000141d32a89a1e0a5ef360034a2f60a4b917c18838000000000000000000000000141d32a89a1e0a5ef360034a2f60a4b917c1883800000000000000000000000000000000000000000000000000000000000000020000000000000000000000000b2c639c533813f4aa9d7837caf62653d097ff850000000000000000000000003c499c542cef5e3811e1192ce70d8cc03d5c335900000000000000000000000000000000000000000000000000000000000000020000000000000000000000000000000000000000000000000000000000d55a40000000000000000000000000000000000000000000000000000000000000008900000000000000000000000000000000000000000000000000000000000000020000000000000000000000000000000000000000000000000000000067041c47000000000000000000000000000000000000000000000000000000006704704d00000000000000000000000000000000000000000000000000000000d00dfeeddeadbeef765753be7f7a64d5509974b0d678e1e3149b02f42c7402906f9888136205038026f20b3f6df2899044cab41d632bc7a6c35debd40516df85de6f194aeb05b72cb9ea4d5ce0f7c56c91a79536331112f1a846dc641c', + gasLimit: 287227, + }, + estimatedProcessingTimeInSeconds: 60, + }, + { + featureId: FeatureId.BATCH_SELL, + quote: { + requestId: '0b6caac9-456d-47e6-8982-1945ae81ae82', + srcChainId: 10, + srcAsset: { + chainId: 10, + address: '0x0b2C639c533813f4Aa9D7837CAf62653d097Ff81', + assetId: 'eip155:10/erc20:0x0b2c639c533813f4aa9d7837caf62653d097ff81', + symbol: 'USDT', + name: 'Tether USD', + decimals: 6, + icon: 'https://media.socket.tech/tokens/all/USDT', + }, + srcTokenAmount: '14000000', + destChainId: 10, + destAsset: { + chainId: 10, + address: '0x3c499c542cef5e3811e1192ce70d8cc03d5c3359', + assetId: 'eip155:10/erc20:0x3c499c542cef5e3811e1192ce70d8cc03d5c3359', + symbol: 'USDC', + name: 'Native USD Coin (POS)', + decimals: 6, + icon: 'https://media.socket.tech/tokens/all/USDC', + }, + destTokenAmount: '13800000', + minDestTokenAmount: '13530000', + feeData: { + metabridge: { + amount: '0', + asset: { + chainId: 10, + address: '0x0b2C639c533813f4Aa9D7837CAf62653d097Ff81', + assetId: + 'eip155:10/erc20:0x0b2c639c533813f4aa9d7837caf62653d097ff81', + symbol: 'USDC', + name: 'USD Coin', + decimals: 6, + icon: 'https://media.socket.tech/tokens/all/USDC', + }, + }, + }, + bridgeId: 'socket', + bridges: ['celercircle'], + steps: [], + }, + approval: { + chainId: 10, + to: '0x0b2c639c533813f4aa9d7837caf62653d097ff85', + from: '0x141d32a89a1e0a5ef360034a2f60a4b917c18838', + value: '0x00', + data: '0x095ea7b3000000000000000000000000b90357f2b86dbfd59c3502215d4060f71df8ca0e0000000000000000000000000000000000000000000000000000000000d59f80', + gasLimit: 61865, + }, + trade: { + chainId: 10, + to: '0xB90357f2b86dbfD59c3502215d4060f71DF8ca0e', + from: '0x141d32a89a1e0a5ef360034a2f60a4b917c18838', + value: '0x038d7ea4c68000', + data: '0x3ce33bff00000000000000000000000000000000000000000000000000000000000000800000000000000000000000000b2c639c533813f4aa9d7837caf62653d097ff850000000000000000000000000000000000000000000000000000000000d59f8000000000000000000000000000000000000000000000000000000000000000c0000000000000000000000000000000000000000000000000000000000000000f736f636b6574416461707465725632000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000004400000000000000000000000003a23f943181408eac424116af7b7790c94cb97a50000000000000000000000003a23f943181408eac424116af7b7790c94cb97a500000000000000000000000000000000000000000000000000000000000000890000000000000000000000000b2c639c533813f4aa9d7837caf62653d097ff850000000000000000000000003c499c542cef5e3811e1192ce70d8cc03d5c33590000000000000000000000000000000000000000000000000000000000d59f8000000000000000000000000000000000000000000000000000000000000001400000000000000000000000000000000000000000000000000000000000000000000000000000000000000000716a8b9dd056055c84b7a2ba0a016099465a518700000000000000000000000000000000000000000000000000000000000002e4c3540448000000000000000000000000000000000000000000000000000000000000004000000000000000000000000000000000000000000000000000000000000000a000000000000000000000000000000000000000000000000000000000000000020000000000000000000000000000000000000000000000000000000000000005000000000000000000000000000000000000000000000000000000000000018c0000000000000000000000000000000000000000000000000000000000000002000000000000000000000000000000000000000000000000000000000000004000000000000000000000000000000000000000000000000000000000000001000000000000000000000000000000000000000000000000000000000000000084ad69fa4f00000000000000000000000000000000000000000000000000038d7ea4c68000000000000000000000000000141d32a89a1e0a5ef360034a2f60a4b917c18838000000000000000000000000000000000000000000000000000000000000008900000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000e4b7dfe9d00000000000000000000000000000000000000000000000000000000000d59f8000000000000000000000000000000000000000000000000000000000000000c4000000000000000000000000141d32a89a1e0a5ef360034a2f60a4b917c188380000000000000000000000000b2c639c533813f4aa9d7837caf62653d097ff85000000000000000000000000000000000000000000000000000000000000008900000000000000000000000000000000000000000000000000000000000000070000000000000000000000000000000000000000000000000000000000030d400000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000138bc5930d51a475e4669db259f69e61ca33803675e76540f062a76af8cbaef4672c9926e56d6a8c29a263de3ee8f734ad760461c448f82fdccdd8c2360fffba1b', + gasLimit: 343079, + }, + estimatedProcessingTimeInSeconds: 1560, + }, +]; + +export const mockBatchSellTradesErc20Erc20: BatchSellTradesResponse = { + transactions: mockBatchSellErc20Erc20.flatMap(({ trade, approval }) => + [ + { + ...(trade as TxData), + type: BatchSellTransactionType.TRADE, + maxFeePerGas: '0x154a94', + maxPriorityFeePerGas: '0xf4241', + } as const, + approval + ? ({ + ...(approval as TxData), + type: BatchSellTransactionType.APPROVAL, + maxFeePerGas: '0x2', + maxPriorityFeePerGas: '0x1', + } as const) + : undefined, + ].filter((tx) => tx !== undefined), + ), + fee: { + amount: '100', + asset: getNativeAssetForChainId(10), + }, +}; + +export const getTxMetasForBatch = ({ + batchId, + is7702, +}: { + batchId: `0x${string}`; + is7702: boolean; +}): TransactionMeta[] => { + let date = Date.now(); + + if (is7702) { + return [ + { + batchId, + id: date.toString(16), + hash: `0x${date.toString(16)}hash`, + time: date, + status: TransactionStatus.submitted, + type: TransactionType.batch, + chainId: '0xa', + batchTransactionsOptions: { + disable7702: !is7702, + }, + txParams: { + from: '0xaccount1', + to: '0xbridgeContract', + value: '0x0', + data: '0xdata', + chainId: '0xa', + gasLimit: '0x5208', + authorizationList: [ + { + address: '0xupgradeAddress', + chainId: '0xa', + nonce: '0x1', + r: '0xr', + s: '0xs', + yParity: '0x1', + }, + ], + }, + delegationAddress: '0xdelegationAddress', + nestedTransactions: mockBatchSellTradesErc20Erc20.transactions.map( + ({ gasLimit, ...trade }) => ({ + ...trade, + chainId: '0xa', + gas: toHex(Number(gasLimit)), + type: + // eslint-disable-next-line no-nested-ternary + trade.type === BatchSellTransactionType.TRADE + ? TransactionType.swap + : trade.type === BatchSellTransactionType.APPROVAL + ? TransactionType.swapApproval + : TransactionType.tokenMethodTransfer, + }), + ), + networkClientId: 'test-network-client-id', + } as const, + ]; + } + + return mockBatchSellTradesErc20Erc20.transactions.map((trade) => { + date += 1; + return { + batchId, + id: date.toString(16), + hash: `0x${date.toString(16)}hash`, + time: date, + status: TransactionStatus.submitted, + type: + // eslint-disable-next-line no-nested-ternary + trade.type === BatchSellTransactionType.TRADE + ? TransactionType.swap + : trade.type === BatchSellTransactionType.APPROVAL + ? TransactionType.swapApproval + : TransactionType.tokenMethodTransfer, + chainId: '0xa', + batchTransactionsOptions: { + disable7702: !is7702, + }, + txParams: { + ...trade, + chainId: '0xa', + gasLimit: '0x5208', + }, + networkClientId: 'test-network-client-id', + } as const; + }); +}; + +export const getHistoryItem = ( + params: Partial, +): BridgeHistoryItem => { + const { isStxEnabled, batchSellData, txMetaId, quote, quoteIds, featureId } = + params; + + return { + account: '0xaccount1', + actionId: undefined, + batchId: '0xBatchId1', + featureId, + hasApprovalTx: true, + isStxEnabled, + initialDestAssetBalance: undefined, + location: MetaMetricsSwapsEventSource.Unknown, + originalTransactionId: txMetaId, + slippagePercentage: 0, + startTime: 1779922719705, + targetContractAddress: undefined, + txMetaId, + approvalTxId: undefined, + estimatedProcessingTimeInSeconds: 60, + pricingData: { + amountSent: '0', + amountSentInUsd: '100', + quotedGasAmount: undefined, + quotedGasInUsd: undefined, + quotedReturnInUsd: '101', + }, + status: { + srcChain: { + chainId: 10, + txHash: isStxEnabled ? undefined : `0x${txMetaId}hash`, + }, + status: StatusTypes.PENDING, + }, + batchSellData, + quote: quote as Quote, + quoteId: undefined, + ...(quoteIds ? { quoteIds } : {}), + }; +}; diff --git a/packages/bridge-status-controller/tsconfig.build.json b/packages/bridge-status-controller/tsconfig.build.json new file mode 100644 index 00000000000..ab2a3480ee6 --- /dev/null +++ b/packages/bridge-status-controller/tsconfig.build.json @@ -0,0 +1,44 @@ +{ + "extends": "../../tsconfig.packages.build.json", + "compilerOptions": { + "baseUrl": "./", + "outDir": "./dist", + "rootDir": "./src" + }, + "references": [ + { + "path": "../accounts-controller/tsconfig.build.json" + }, + { + "path": "../base-controller/tsconfig.build.json" + }, + { + "path": "../bridge-controller/tsconfig.build.json" + }, + { + "path": "../controller-utils/tsconfig.build.json" + }, + { + "path": "../messenger/tsconfig.build.json" + }, + { + "path": "../network-controller/tsconfig.build.json" + }, + { + "path": "../gas-fee-controller/tsconfig.build.json" + }, + { + "path": "../polling-controller/tsconfig.build.json" + }, + { + "path": "../transaction-controller/tsconfig.build.json" + }, + { + "path": "../keyring-controller/tsconfig.build.json" + }, + { + "path": "../profile-sync-controller/tsconfig.build.json" + } + ], + "include": ["../../types", "./src"] +} diff --git a/packages/bridge-status-controller/tsconfig.json b/packages/bridge-status-controller/tsconfig.json new file mode 100644 index 00000000000..eb76ca03cd4 --- /dev/null +++ b/packages/bridge-status-controller/tsconfig.json @@ -0,0 +1,43 @@ +{ + "extends": "../../tsconfig.packages.json", + "compilerOptions": { + "baseUrl": "./", + "resolveJsonModule": true + }, + "references": [ + { + "path": "../accounts-controller" + }, + { + "path": "../base-controller" + }, + { + "path": "../bridge-controller" + }, + { + "path": "../controller-utils" + }, + { + "path": "../messenger" + }, + { + "path": "../network-controller" + }, + { + "path": "../polling-controller" + }, + { + "path": "../transaction-controller" + }, + { + "path": "../profile-sync-controller" + }, + { + "path": "../gas-fee-controller" + }, + { + "path": "../keyring-controller" + } + ], + "include": ["../../types", "./src", "./test"] +} diff --git a/packages/bridge-status-controller/typedoc.json b/packages/bridge-status-controller/typedoc.json new file mode 100644 index 00000000000..c9da015dbf8 --- /dev/null +++ b/packages/bridge-status-controller/typedoc.json @@ -0,0 +1,7 @@ +{ + "entryPoints": ["./src/index.ts"], + "excludePrivate": true, + "hideGenerator": true, + "out": "docs", + "tsconfig": "./tsconfig.build.json" +} diff --git a/packages/build-utils/CHANGELOG.md b/packages/build-utils/CHANGELOG.md new file mode 100644 index 00000000000..2713ab8934e --- /dev/null +++ b/packages/build-utils/CHANGELOG.md @@ -0,0 +1,104 @@ +# 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] + +### Changed + +- Bump `@metamask/utils` from `^11.8.1` to `^11.11.0` ([#7511](https://github.com/MetaMask/core/pull/7511), [#9074](https://github.com/MetaMask/core/pull/9074)) + +## [3.0.4] + +### Changed + +- Bump `@metamask/utils` from `^11.2.0` to `^11.8.1` ([#6054](https://github.com/MetaMask/core/pull/6054), [#6588](https://github.com/MetaMask/core/pull/6588), [#6708](https://github.com/MetaMask/core/pull/6708)) + +## [3.0.3] + +### Changed + +- Bump `@metamask/utils` from `^10.0.0` to `^11.1.0` ([#5080](https://github.com/MetaMask/core/pull/5080)), ([#5223](https://github.com/MetaMask/core/pull/5223)) + +## [3.0.2] + +### Changed + +- Bump `@metamask/utils` from `^9.1.0` to `^10.0.0` ([#4831](https://github.com/MetaMask/core/pull/4831)) + +## [3.0.1] + +### Changed + +- Bump `@metamask/utils` from `^8.3.0` to `^9.1.0` ([#4516](https://github.com/MetaMask/core/pull/4516), [#4529](https://github.com/MetaMask/core/pull/4529)) +- Bump `@metamask/rpc-errors` from `^6.2.1` to `^6.3.1` ([#4516](https://github.com/MetaMask/core/pull/4516)) +- Bump TypeScript from `~4.9.5` to `~5.2.2` and set `moduleResolution` option to `Node16` ([#3645](https://github.com/MetaMask/core/pull/3645), [#4576](https://github.com/MetaMask/core/pull/4576), [#4584](https://github.com/MetaMask/core/pull/4584)) + +### Fixed + +- Produce and export ESM-compatible TypeScript type declaration files in addition to CommonJS-compatible declaration files ([#4648](https://github.com/MetaMask/core/pull/4648)) + - Previously, this package shipped with only one variant of type declaration + files, and these files were only CommonJS-compatible, and the `exports` + field in `package.json` linked to these files. This is an anti-pattern and + was rightfully flagged by the + ["Are the Types Wrong?"](https://arethetypeswrong.github.io/) tool as + ["masquerading as CJS"](https://github.com/arethetypeswrong/arethetypeswrong.github.io/blob/main/docs/problems/FalseCJS.md). + All of the ATTW checks now pass. +- Remove chunk files ([#4648](https://github.com/MetaMask/core/pull/4648)). + - Previously, the build tool we used to generate JavaScript files extracted + common code to "chunk" files. While this was intended to make this package + more tree-shakeable, it also made debugging more difficult for our + development teams. These chunk files are no longer present. + +## [3.0.0] + +### Changed + +- **BREAKING:** Bump minimum Node version to 18.18 ([#3611](https://github.com/MetaMask/core/pull/3611)) +- Bump `@metamask/base-controller` to `^6.0.0` ([#4352](https://github.com/MetaMask/core/pull/4352)) + +## [2.0.1] + +### Fixed + +- Fix `types` field in `package.json` ([#4047](https://github.com/MetaMask/core/pull/4047)) + +## [2.0.0] + +### Added + +- **BREAKING**: Add ESM build ([#3998](https://github.com/MetaMask/core/pull/3998)) + - It's no longer possible to import files from `./dist` directly. + +## [1.0.2] + +### Changed + +- Bump `@metamask/utils` to `^8.3.0` ([#3769](https://github.com/MetaMask/core/pull/3769)) + +## [1.0.1] + +### Fixed + +- Fix broken URL in `README.md` ([#3599](https://github.com/MetaMask/core/pull/3599)) + +## [1.0.0] + +### Added + +- Initial release ([#3577](https://github.com/MetaMask/core/pull/3577) [#3588](https://github.com/MetaMask/core/pull/3588)) + +[Unreleased]: https://github.com/MetaMask/core/compare/@metamask/build-utils@3.0.4...HEAD +[3.0.4]: https://github.com/MetaMask/core/compare/@metamask/build-utils@3.0.3...@metamask/build-utils@3.0.4 +[3.0.3]: https://github.com/MetaMask/core/compare/@metamask/build-utils@3.0.2...@metamask/build-utils@3.0.3 +[3.0.2]: https://github.com/MetaMask/core/compare/@metamask/build-utils@3.0.1...@metamask/build-utils@3.0.2 +[3.0.1]: https://github.com/MetaMask/core/compare/@metamask/build-utils@3.0.0...@metamask/build-utils@3.0.1 +[3.0.0]: https://github.com/MetaMask/core/compare/@metamask/build-utils@2.0.1...@metamask/build-utils@3.0.0 +[2.0.1]: https://github.com/MetaMask/core/compare/@metamask/build-utils@2.0.0...@metamask/build-utils@2.0.1 +[2.0.0]: https://github.com/MetaMask/core/compare/@metamask/build-utils@1.0.2...@metamask/build-utils@2.0.0 +[1.0.2]: https://github.com/MetaMask/core/compare/@metamask/build-utils@1.0.1...@metamask/build-utils@1.0.2 +[1.0.1]: https://github.com/MetaMask/core/compare/@metamask/build-utils@1.0.0...@metamask/build-utils@1.0.1 +[1.0.0]: https://github.com/MetaMask/core/releases/tag/@metamask/build-utils@1.0.0 diff --git a/packages/build-utils/LICENSE b/packages/build-utils/LICENSE new file mode 100644 index 00000000000..e3e71d8cf71 --- /dev/null +++ b/packages/build-utils/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2023 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/build-utils/README.md b/packages/build-utils/README.md new file mode 100644 index 00000000000..9c4f97a2c92 --- /dev/null +++ b/packages/build-utils/README.md @@ -0,0 +1,21 @@ +# `@metamask/build-utils` + +Utilities for building MetaMask applications. + +## Installation + +`yarn add @metamask/build-utils` + +or + +`npm install @metamask/build-utils` + +## Usage + +### `/transforms` + +See [the transforms readme](https://github.com/MetaMask/core/blob/main/packages/build-utils/src/transforms/README.md). + +## 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/build-utils/jest.config.js b/packages/build-utils/jest.config.js new file mode 100644 index 00000000000..ca084133399 --- /dev/null +++ b/packages/build-utils/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/build-utils/package.json b/packages/build-utils/package.json new file mode 100644 index 00000000000..d0ecfb261b0 --- /dev/null +++ b/packages/build-utils/package.json @@ -0,0 +1,74 @@ +{ + "name": "@metamask/build-utils", + "version": "3.0.4", + "description": "Utilities for building MetaMask applications", + "keywords": [ + "Ethereum", + "MetaMask" + ], + "homepage": "https://github.com/MetaMask/core/tree/main/packages/build-utils#readme", + "bugs": { + "url": "https://github.com/MetaMask/core/issues" + }, + "license": "MIT", + "repository": { + "type": "git", + "url": "https://github.com/MetaMask/core.git" + }, + "files": [ + "dist/" + ], + "sideEffects": false, + "main": "./dist/index.cjs", + "types": "./dist/index.d.cts", + "exports": { + ".": { + "import": { + "types": "./dist/index.d.mts", + "default": "./dist/index.mjs" + }, + "require": { + "types": "./dist/index.d.cts", + "default": "./dist/index.cjs" + } + }, + "./package.json": "./package.json" + }, + "publishConfig": { + "access": "public", + "registry": "https://registry.npmjs.org/" + }, + "scripts": { + "build": "ts-bridge --project tsconfig.build.json --verbose --clean --no-references", + "build:all": "ts-bridge --project tsconfig.build.json --verbose --clean", + "build:docs": "typedoc", + "changelog:update": "../../scripts/update-changelog.sh @metamask/build-utils", + "changelog:validate": "../../scripts/validate-changelog.sh @metamask/build-utils", + "lint:tsconfigs": "tsx ../../scripts/lint-tsconfigs/lint-tsconfigs.mts", + "lint:tsconfigs:fix": "tsx ../../scripts/lint-tsconfigs/lint-tsconfigs.mts --fix", + "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/utils": "^11.11.0", + "@types/eslint": "^8.44.7" + }, + "devDependencies": { + "@metamask/auto-changelog": "^6.1.0", + "@ts-bridge/cli": "^0.6.4", + "@types/jest": "^30.0.0", + "deepmerge": "^4.2.2", + "jest": "^30.4.2", + "ts-jest": "^29.4.11", + "tsx": "^4.20.5", + "typedoc": "^0.25.13", + "typedoc-plugin-missing-exports": "^2.0.0", + "typescript": "~5.3.3" + }, + "engines": { + "node": "^18.18 || >=20" + } +} diff --git a/packages/build-utils/src/index.ts b/packages/build-utils/src/index.ts new file mode 100644 index 00000000000..294d4375e69 --- /dev/null +++ b/packages/build-utils/src/index.ts @@ -0,0 +1,3 @@ +export type { FeatureLabels } from './transforms/remove-fenced-code.js'; +export { removeFencedCode } from './transforms/remove-fenced-code.js'; +export { lintTransformedFile } from './transforms/utils.js'; diff --git a/packages/build-utils/src/transforms/README.md b/packages/build-utils/src/transforms/README.md new file mode 100644 index 00000000000..b7f10d60069 --- /dev/null +++ b/packages/build-utils/src/transforms/README.md @@ -0,0 +1,198 @@ +# Source file transforms + +This directory contains home-grown transforms for the build systems of the MetaMask applications. + +## Remove Fenced Code + +> `./remove-fenced-code.ts` + +### Usage + +Let's imagine you've added some fences to your source code. + +```typescript +this.store.updateStructure({ + /** ..., */ + GasFeeController: this.gasFeeController, + TokenListController: this.tokenListController, + ///: BEGIN:ONLY_INCLUDE_IF(snaps) + SnapController: this.snapController, + ///: END:ONLY_INCLUDE_IF +}); +``` + +The transform should be applied on your raw source files as they are committed to +your repository, before anything else (e.g. Babel, `tsc`, etc.) parses or modifies them. + +```typescript +import { + FeatureLabels, + removeFencedCode, + lintTransformedFile, +} from '@metamask/build-utils'; + +// Let's imagine this function exists in your build system and is called immediately +// after your source files are read from disk. +async function applyTransforms( + filePath: string, + fileContent: string, + features: FeatureLabels, + shouldLintTransformedFiles: boolean = true, +): string { + const [newFileContent, wasModified] = removeFencedCode( + filePath, + fileContent, + features, + ); + + // You may choose to disable linting during e.g. dev builds since lint failures cause + // an error to be thrown. + if (wasModified && shouldLintTransformedFiles) { + // You probably only need a singleton ESLint instance for your linting purposes. + // See the lintTransformedFile documentation for important notes about usage. + const eslintInstance = getESLintInstance(); + await lintTransformedFile(eslintInstance, filePath, newFileContent); + } + return newFileContent; +} + +// Then, in the relevant part of your build process... + +const features: FeatureLabels = { + active: new Set(['foo']), // Fences with these features will be included. + all: new Set(['snaps', 'foo' /** etc. */]), // All extant features must be listed here. +}; + +const transformedFile = await applyTransforms( + filePath, + fileContent, + features, + shouldLintTransformedFiles, +); + +// Do something with the results. +// continueBuildProcess(transformedFile); +``` + +After the transform has been applied as above, the example source code will look like this: + +```typescript +this.store.updateStructure({ + /** ..., */ + GasFeeController: this.gasFeeController, + TokenListController: this.tokenListController, +}); +``` + +### Overview + +When creating builds that support different features, it is desirable to exclude +unsupported features, files, and dependencies at build time. Undesired files and +dependencies can be excluded wholesale, but the _use_ of undesired modules in +files that should otherwise be included – i.e. import statements and references +to those imports – cannot. + +To support the exclusion of the use of undesired modules at build time, we +introduce the concept of code fencing to our build system. Our code fencing +syntax amounts to a tiny DSL, which is specified below. + +The transform expects to receive the contents of individual files as a single string, +which it will parse in order to identify any code fences. If any fences that should not +be included in the current build are found, the fences and the lines that they wrap +are deleted. An error is thrown if a malformed fence is identified. + +For example, the following fenced code: + +```javascript +this.store.updateStructure({ + ..., + GasFeeController: this.gasFeeController, + TokenListController: this.tokenListController, + ///: BEGIN:ONLY_INCLUDE_IF(snaps) + SnapController: this.snapController, + ///: END:ONLY_INCLUDE_IF +}); +``` + +Is transformed as follows if the current build should not include the `snaps` feature: + +```javascript +this.store.updateStructure({ + ..., + GasFeeController: this.gasFeeController, + TokenListController: this.tokenListController, +}); +``` + +Note that multiple features can be specified by separating them with +commands inside the parameter parentheses: + +```javascript +///: BEGIN:ONLY_INCLUDE_IF(build-beta,build-flask) +``` + +### Code Fencing Syntax + +> In the specification, angle brackets, `< >`, indicate required tokens, while +> straight brackets, `[ ]`, indicate optional tokens. +> +> Alphabetical characters identify the name and purpose of a token. All other +> characters, including parentheses, `( )`, are literals. + +A fence line is a single-line JavaScript comment, optionally surrounded by +whitespace, in the following format: + +```text +///: :[(parameters)] + +|__| |________________________________| + | | + | | +sentinel directive +``` + +The first part of a fence line is the **sentinel** which is always the string +"`///:`". If the first four non-whitespace characters of a line are not exactly the +**sentinel** the line will be ignored by the parser. The **sentinel** must be +succeeded by a single space character, or parsing will fail. + +The remainder of the fence line is called the **directive** +The directive consists of a **terminus** **command** and **parameters** + +- The **terminus** is one of the strings `BEGIN` and `END`. It must be followed by + a single colon, `:`. +- The **command** is a string of uppercase alphabetical characters, optionally + including underscores, `_`. The possible commands are listed later in this + specification. +- The **parameters** are a string of comma-separated RegEx `\w` strings. The parameters + string must be parenthesized, only specified for `BEGIN` directives, and valid for its + command. + +A valid code fence consists of two fence lines surrounding one or more lines of +non-fence lines. The first fence line must consist of a `BEGIN` directive, and +the second an `END` directive. The command of both directives must be the same, +and the parameters (if any) must be valid for the command. Nesting is not intended +to be supported, and may produce undefined behavior. + +If an invalid fence is detected, parsing will fail, and the transform will throw +an error. + +### Commands + +#### `ONLY_INCLUDE_IF` + +This, the only command defined so far, is used to exclude lines of code depending +on flags provided to the current build process. If a particular set of lines should +only be included in e.g. the beta build type, they should be wrapped as follows: + +```javascript +///: BEGIN:ONLY_INCLUDE_IF(build-beta) +console.log('I am only included in beta builds.'); +///: END:ONLY_INCLUDE_IF +``` + +At build time, the fences and the fenced lines will be removed if the `build-beta` +flag is not provided to the transform. + +The parameters must be provided as a comma-separated list of features that are +valid per the consumer's build system. diff --git a/packages/build-utils/src/transforms/remove-fenced-code.test.ts b/packages/build-utils/src/transforms/remove-fenced-code.test.ts new file mode 100644 index 00000000000..b370df74a1b --- /dev/null +++ b/packages/build-utils/src/transforms/remove-fenced-code.test.ts @@ -0,0 +1,721 @@ +import { removeFencedCode } from '../index.js'; +import type { FeatureLabels } from '../index.js'; +import { + DirectiveCommand, + multiSplice, + validateCommand, +} from './remove-fenced-code.js'; + +const FEATURE_A = 'feature-a'; +const FEATURE_B = 'feature-b'; +const FEATURE_C = 'feature-c'; + +const getFeatures = ({ + all, + active, +}: FeatureLabels): { + all: Set; + active: Set; +} => ({ + all: new Set(all), + active: new Set(active), +}); + +const getFencedCode = (...params: string[]): string => + `///: BEGIN:ONLY_INCLUDE_IF(${params.join(',')}) +Conditionally_Included +///: END:ONLY_INCLUDE_IF +`; + +const getUnfencedCode = (): string => ` +Always included +Always included +Always included +`; + +const join = (...args: string[]): string => args.join('\n'); + +describe('build transforms', () => { + describe('removeFencedCode', () => { + const mockFileName = 'file.js'; + + it('transforms file consisting of single fence pair', () => { + expect( + removeFencedCode( + mockFileName, + getFencedCode(FEATURE_A), + getFeatures({ + active: new Set([FEATURE_B]), + all: new Set([FEATURE_B, FEATURE_A]), + }), + ), + ).toStrictEqual(['', true]); + }); + + ( + [ + [ + join( + getFencedCode(FEATURE_A), + getUnfencedCode(), + getFencedCode(FEATURE_C), + ), + join('', getUnfencedCode(), ''), + ], + + [ + join( + getFencedCode(FEATURE_A), + getFencedCode(FEATURE_B), + getFencedCode(FEATURE_C), + ), + join('', getFencedCode(FEATURE_B), ''), + ], + + [ + join( + getFencedCode(FEATURE_A), + getUnfencedCode(), + getFencedCode(FEATURE_B), + getFencedCode(FEATURE_C), + getUnfencedCode(), + getFencedCode(FEATURE_A), + getFencedCode(FEATURE_B), + ), + join( + '', + getUnfencedCode(), + getFencedCode(FEATURE_B), + '', + getUnfencedCode(), + '', + getFencedCode(FEATURE_B), + ), + ], + + [ + join( + getUnfencedCode(), + getFencedCode(FEATURE_A), + getFencedCode(FEATURE_B), + getFencedCode(FEATURE_B), + getFencedCode(FEATURE_C), + getFencedCode(FEATURE_C), + getUnfencedCode(), + getFencedCode(FEATURE_A), + getFencedCode(FEATURE_A), + getFencedCode(FEATURE_B), + ), + join( + getUnfencedCode(), + '', + getFencedCode(FEATURE_B), + getFencedCode(FEATURE_B), + '', + '', + getUnfencedCode(), + '', + '', + getFencedCode(FEATURE_B), + ), + ], + ] as const + ).forEach(([input, expected], i) => { + it(`removes multiple fences from file ${i}`, () => { + expect( + removeFencedCode( + mockFileName, + input, + getFeatures({ + active: new Set([FEATURE_B]), + all: new Set([FEATURE_A, FEATURE_B, FEATURE_C]), + }), + ), + ).toStrictEqual([expected, true]); + }); + }); + + ( + [ + [ + [FEATURE_A], + join( + getFencedCode(FEATURE_A, FEATURE_B), + getUnfencedCode(), + getFencedCode(FEATURE_C), + ), + join(getFencedCode(FEATURE_A, FEATURE_B), getUnfencedCode(), ''), + true, + ], + + [ + [FEATURE_A], + join( + getFencedCode(FEATURE_A, FEATURE_B), + getUnfencedCode(), + getFencedCode(FEATURE_C, FEATURE_B), + ), + join(getFencedCode(FEATURE_A, FEATURE_B), getUnfencedCode(), ''), + true, + ], + + [ + [FEATURE_B], + join( + getFencedCode(FEATURE_A, FEATURE_B, FEATURE_C), + getUnfencedCode(), + getFencedCode(FEATURE_C), + ), + join( + getFencedCode(FEATURE_A, FEATURE_B, FEATURE_C), + getUnfencedCode(), + '', + ), + true, + ], + + [ + [FEATURE_B], + join( + getFencedCode(FEATURE_A, FEATURE_B, FEATURE_C), + getUnfencedCode(), + getFencedCode(FEATURE_C, FEATURE_A), + ), + join( + getFencedCode(FEATURE_A, FEATURE_B, FEATURE_C), + getUnfencedCode(), + '', + ), + true, + ], + + [ + [FEATURE_A, FEATURE_B], + join( + getFencedCode(FEATURE_A, FEATURE_B, FEATURE_C), + getUnfencedCode(), + getFencedCode(FEATURE_C), + ), + join( + getFencedCode(FEATURE_A, FEATURE_B, FEATURE_C), + getUnfencedCode(), + '', + ), + true, + ], + + [ + [FEATURE_A, FEATURE_B, FEATURE_C], + join( + getFencedCode(FEATURE_A, FEATURE_B, FEATURE_C), + getUnfencedCode(), + getFencedCode(FEATURE_C), + ), + join( + getFencedCode(FEATURE_A, FEATURE_B, FEATURE_C), + getUnfencedCode(), + getFencedCode(FEATURE_C), + ), + false, + ], + ] as const + ).forEach(([activeFeatures, input, expected, modified], i) => { + it(`removes or keeps multi-parameter fences ${i}`, () => { + expect( + removeFencedCode( + mockFileName, + input, + getFeatures({ + active: new Set(activeFeatures), + all: new Set([FEATURE_A, FEATURE_B, FEATURE_C]), + }), + ), + ).toStrictEqual([expected, modified]); + }); + }); + + [ + getFencedCode(FEATURE_A), + + join( + getFencedCode(FEATURE_A), + getUnfencedCode(), + getFencedCode(FEATURE_C), + ), + + join(getUnfencedCode(), getFencedCode(FEATURE_C)), + ].forEach((input, i) => { + it(`does not transform files with only inactive fences ${i}`, () => { + expect( + removeFencedCode( + mockFileName, + input, + getFeatures({ + active: new Set([FEATURE_A, FEATURE_C]), + all: new Set([FEATURE_A, FEATURE_C]), + }), + ), + ).toStrictEqual([input, false]); + }); + }); + + it('ignores sentinels preceded by non-whitespace', () => { + const validBeginDirective = '///: BEGIN:ONLY_INCLUDE_IF(feature-b)\n'; + const ignoredLines = [ + `a ${validBeginDirective}`, + `2 ${validBeginDirective}`, + `@ ${validBeginDirective}`, + ]; + + ignoredLines.forEach((ignoredLine) => { + // These inputs will be transformed + expect( + removeFencedCode( + mockFileName, + getFencedCode(FEATURE_A).concat(ignoredLine), + getFeatures({ + active: new Set([FEATURE_B]), + all: new Set([FEATURE_B, FEATURE_A]), + }), + ), + ).toStrictEqual([ignoredLine, true]); + + const modifiedInputWithoutFences = + getUnfencedCode().concat(ignoredLine); + + // These inputs will not be transformed + expect( + removeFencedCode( + mockFileName, + modifiedInputWithoutFences, + getFeatures({ + active: new Set([FEATURE_B]), + all: new Set([FEATURE_B]), + }), + ), + ).toStrictEqual([modifiedInputWithoutFences, false]); + }); + }); + + // Invalid inputs + it('rejects empty fences', () => { + const jsComment = '// A comment\n'; + + const emptyFence = getFencedCode(FEATURE_B) + .split('\n') + .filter((line) => line.startsWith('///:')) + .map((line) => `${line}\n`) + .join(''); + + const emptyFenceWithPrefix = jsComment.concat(emptyFence); + const emptyFenceWithSuffix = emptyFence.concat(jsComment); + const emptyFenceSurrounded = emptyFenceWithPrefix.concat(jsComment); + + const inputs = [ + emptyFence, + emptyFenceWithPrefix, + emptyFenceWithSuffix, + emptyFenceSurrounded, + ]; + + inputs.forEach((input) => { + expect(() => + removeFencedCode( + mockFileName, + input, + getFeatures({ + active: new Set([FEATURE_B]), + all: new Set([FEATURE_B]), + }), + ), + ).toThrow( + `Empty fence found in file "${mockFileName}":\n${emptyFence}`, + ); + }); + }); + + it('rejects sentinels not followed by a single space and a multi-character alphabetical string', () => { + // Matches the sentinel and terminus component of the first line + // beginning with "///: TERMINUS" + const fenceSentinelAndTerminusRegex = /^\/\/\/: \w+/mu; + + const replacements = [ + '///:BEGIN', + '///:XBEGIN', + '///:_BEGIN', + '///:B', + '///:_', + '///: ', + '///: B', + '///:', + ]; + + replacements.forEach((replacement) => { + expect(() => + removeFencedCode( + mockFileName, + getFencedCode(FEATURE_B).replace( + fenceSentinelAndTerminusRegex, + replacement, + ), + getFeatures({ + active: new Set([FEATURE_B]), + all: new Set([FEATURE_B]), + }), + ), + ).toThrow( + /Fence sentinel must be followed by a single space and an alphabetical string of two or more characters.$/u, + ); + }); + }); + + it('rejects malformed BEGIN directives', () => { + // This is the first line of the minimal input template + const directiveString = '///: BEGIN:ONLY_INCLUDE_IF(feature-b)'; + + const replacements = [ + // Invalid terminus + '///: BE_GIN:BEGIN:ONLY_INCLUDE_IF(feature-b)', + '///: BE6IN:BEGIN:ONLY_INCLUDE_IF(feature-b)', + '///: BEGIN7:BEGIN:ONLY_INCLUDE_IF(feature-b)', + '///: BeGIN:ONLY_INCLUDE_IF(feature-b)', + '///: BE3:BEGIN:ONLY_INCLUDE_IF(feature-b)', + '///: BEG-IN:BEGIN:ONLY_INCLUDE_IF(feature-b)', + '///: BEG N:BEGIN:ONLY_INCLUDE_IF(feature-b)', + + // Invalid commands + '///: BEGIN:ONLY-INCLUDE_IF(flask)', + '///: BEGIN:ONLY_INCLUDE:IF(flask)', + '///: BEGIN:ONL6_INCLUDE_IF(flask)', + '///: BEGIN:ONLY_IN@LUDE_IF(flask)', + '///: BEGIN:ONLy_INCLUDE_IF(feature-b)', + '///: BEGIN:ONLY INCLUDE_IF(flask)', + + // Invalid parameters + '///: BEGIN:ONLY_INCLUDE_IF(,flask)', + '///: BEGIN:ONLY_INCLUDE_IF(feature-b,)', + '///: BEGIN:ONLY_INCLUDE_IF(feature-b,,main)', + '///: BEGIN:ONLY_INCLUDE_IF(,)', + '///: BEGIN:ONLY_INCLUDE_IF()', + '///: BEGIN:ONLY_INCLUDE_IF( )', + '///: BEGIN:ONLY_INCLUDE_IF(feature-b]', + '///: BEGIN:ONLY_INCLUDE_IF[flask)', + '///: BEGIN:ONLY_INCLUDE_IF(feature-b.main)', + '///: BEGIN:ONLY_INCLUDE_IF(feature-b,@)', + '///: BEGIN:ONLY_INCLUDE_IF(fla k)', + + // Stuff after the directive + '///: BEGIN:ONLY_INCLUDE_IF(feature-b) A', + '///: BEGIN:ONLY_INCLUDE_IF(feature-b) 9', + '///: BEGIN:ONLY_INCLUDE_IF(feature-b)A', + '///: BEGIN:ONLY_INCLUDE_IF(feature-b)9', + '///: BEGIN:ONLY_INCLUDE_IF(feature-b)_', + '///: BEGIN:ONLY_INCLUDE_IF(feature-b))', + ]; + + replacements.forEach((replacement) => { + expect(() => + removeFencedCode( + mockFileName, + getFencedCode(FEATURE_B).replace(directiveString, replacement), + getFeatures({ + active: new Set([FEATURE_B]), + all: new Set([FEATURE_B]), + }), + ), + ).toThrow( + new RegExp( + `${replacement.replace( + /([()[\]])/gu, + '\\$1', + )}":\nFailed to parse fence directive.$`, + 'u', + ), + ); + }); + }); + + it('rejects malformed END directives', () => { + // This is the last line of the minimal input template + const directiveString = '///: END:ONLY_INCLUDE_IF'; + + const replacements = [ + // Invalid terminus + '///: ENx:ONLY_INCLUDE_IF', + '///: EN3:ONLY_INCLUDE_IF', + '///: EN_:ONLY_INCLUDE_IF', + '///: EN :ONLY_INCLUDE_IF', + '///: EN::ONLY_INCLUDE_IF', + + // Invalid commands + '///: END:ONLY-INCLUDE_IF', + '///: END::ONLY_INCLUDE_IN', + '///: END:ONLY_INCLUDE:IF', + '///: END:ONL6_INCLUDE_IF', + '///: END:ONLY_IN@LUDE_IF', + '///: END:ONLy_INCLUDE_IF', + '///: END:ONLY INCLUDE_IF', + + // Stuff after the directive + '///: END:ONLY_INCLUDE_IF A', + '///: END:ONLY_INCLUDE_IF 9', + '///: END:ONLY_INCLUDE_IF _', + ]; + + replacements.forEach((replacement) => { + expect(() => + removeFencedCode( + mockFileName, + getFencedCode(FEATURE_B).replace(directiveString, replacement), + getFeatures({ + active: new Set([FEATURE_B]), + all: new Set([FEATURE_B]), + }), + ), + ).toThrow( + new RegExp( + `${replacement}":\nFailed to parse fence directive.$`, + 'u', + ), + ); + }); + }); + + it('rejects files with uneven number of fence lines', () => { + const additions = [ + '///: BEGIN:ONLY_INCLUDE_IF(feature-b)', + '///: END:ONLY_INCLUDE_IF', + ]; + + additions.forEach((addition) => { + expect(() => + removeFencedCode( + mockFileName, + getFencedCode(FEATURE_B).concat(addition), + getFeatures({ + active: new Set([FEATURE_B]), + all: new Set([FEATURE_B]), + }), + ), + ).toThrow( + /A valid fence consists of two fence lines, but the file contains an uneven number, "3", of fence lines.$/u, + ); + }); + }); + + it('rejects invalid terminuses', () => { + const testCases = [ + ['BEGIN', ['KAPLAR', 'FLASK', 'FOO']], + ['END', ['KAPLAR', 'FOO', 'BAR']], + ] as const; + + testCases.forEach(([validTerminus, replacements]) => { + replacements.forEach((replacement) => { + expect(() => + removeFencedCode( + mockFileName, + getFencedCode(FEATURE_B).replace(validTerminus, replacement), + getFeatures({ + active: new Set([FEATURE_B]), + all: new Set([FEATURE_B]), + }), + ), + ).toThrow( + new RegExp( + `Line contains invalid directive terminus "${replacement}".$`, + 'u', + ), + ); + }); + }); + }); + + it('rejects invalid commands', () => { + const testCases = [ + [/ONLY_INCLUDE_IF\(/mu, ['ONLY_KEEP_IF(', 'FLASK(', 'FOO(']], + [/ONLY_INCLUDE_IF$/mu, ['ONLY_KEEP_IF', 'FLASK', 'FOO']], + ] as const; + + testCases.forEach(([validCommand, replacements]) => { + replacements.forEach((replacement) => { + expect(() => + removeFencedCode( + mockFileName, + getFencedCode(FEATURE_B).replace(validCommand, replacement), + getFeatures({ + active: new Set([FEATURE_B]), + all: new Set([FEATURE_B]), + }), + ), + ).toThrow( + new RegExp( + `Line contains invalid directive command "${replacement.replace( + '(', + '', + )}".$`, + 'u', + ), + ); + }); + }); + }); + + it('rejects invalid command parameters', () => { + const testCases = [ + ['bar', ['bar', 'feature-b,bar', 'feature-b,feature-c,feature-a,bar']], + ['Foo', ['Foo', 'feature-b,Foo', 'feature-b,feature-c,feature-a,Foo']], + [ + 'b3ta', + ['b3ta', 'feature-b,b3ta', 'feature-b,feature-c,feature-a,b3ta'], + ], + [ + 'bEta', + ['bEta', 'feature-b,bEta', 'feature-b,feature-c,feature-a,bEta'], + ], + ] as const; + + testCases.forEach(([invalidParam, replacements]) => { + replacements.forEach((replacement) => { + expect(() => + removeFencedCode( + mockFileName, + getFencedCode(replacement), + getFeatures({ + active: new Set([FEATURE_B]), + all: new Set([FEATURE_B, FEATURE_A, FEATURE_C]), + }), + ), + ).toThrow( + new RegExp( + `"${invalidParam}" is not a declared build feature.$`, + 'u', + ), + ); + }); + }); + + // Should fail for empty params + expect(() => + removeFencedCode( + mockFileName, + getFencedCode('').replace('()', ''), + getFeatures({ + active: new Set([FEATURE_B]), + all: new Set([FEATURE_B]), + }), + ), + ).toThrow( + 'Invalid code fence parameters in file "file.js":\nNo parameters specified.', + ); + }); + + it('rejects directive pairs with wrong terminus order', () => { + // We need more than one directive pair for this test + const input = getFencedCode(FEATURE_B).concat(getFencedCode(FEATURE_C)); + + const expectedBeginError = + 'The first directive of a pair must be a "BEGIN" directive.'; + const expectedEndError = + 'The second directive of a pair must be an "END" directive.'; + const testCases = [ + [ + 'BEGIN:ONLY_INCLUDE_IF(feature-b)', + 'END:ONLY_INCLUDE_IF', + expectedBeginError, + ], + [ + /END:ONLY_INCLUDE_IF/mu, + 'BEGIN:ONLY_INCLUDE_IF(feature-a)', + expectedEndError, + ], + [ + 'BEGIN:ONLY_INCLUDE_IF(feature-c)', + 'END:ONLY_INCLUDE_IF', + expectedBeginError, + ], + ] as const; + + testCases.forEach(([target, replacement, expectedError]) => { + expect(() => + removeFencedCode( + mockFileName, + input.replace(target, replacement), + getFeatures({ + active: new Set([FEATURE_B]), + all: new Set([FEATURE_B]), + }), + ), + ).toThrow(expectedError); + }); + }); + + it('ignores files with inline source maps', () => { + // This is so that there isn't an unnecessary second execution of + // removeFencedCode with a transpiled version of the same file + const input = getFencedCode('foo').concat( + '\n//# sourceMappingURL=as32e32wcwc2234f2ew32cnin4243f4nv9nsdoivnxzoivnd', + ); + expect( + removeFencedCode( + mockFileName, + input, + getFeatures({ + active: new Set([FEATURE_A]), + all: new Set([FEATURE_A]), + }), + ), + ).toStrictEqual([input, false]); + }); + + // We can't do this until there's more than one command + it.todo('rejects directive pairs with mismatched commands'); + }); + + describe('multiSplice', () => { + it('throws if the indices array is empty or of odd length', () => { + [[], [1], [1, 2, 3]].forEach((invalidInput) => { + expect(() => multiSplice('foobar', invalidInput)).toThrow( + 'Expected non-empty, even-length array.', + ); + }); + }); + + it('throws if the indices array contains non-integer or negative numbers', () => { + [ + [1.2, 2], + [3, -1], + ].forEach((invalidInput) => { + expect(() => multiSplice('foobar', invalidInput)).toThrow( + 'Expected array of non-negative integers.', + ); + }); + }); + }); + + describe('validateCommand', () => { + it('throws if the parameters are invalid', () => { + [null, undefined, []].forEach((invalidInput) => { + expect(() => + validateCommand( + DirectiveCommand.ONLY_INCLUDE_IF, + // TODO: Replace `any` with type + // eslint-disable-next-line @typescript-eslint/no-explicit-any + invalidInput as any, + 'file.js', + // TODO: Replace `any` with type + // eslint-disable-next-line @typescript-eslint/no-explicit-any + {} as any, + ), + ).toThrow('No parameters specified.'); + }); + }); + + it('throws if the command is unrecognized', () => { + // TODO: Replace `any` with type + // eslint-disable-next-line @typescript-eslint/no-explicit-any + expect(() => validateCommand('foobar', [], 'file.js', {} as any)).toThrow( + 'Unrecognized command "foobar".', + ); + }); + }); +}); diff --git a/packages/build-utils/src/transforms/remove-fenced-code.ts b/packages/build-utils/src/transforms/remove-fenced-code.ts new file mode 100644 index 00000000000..9c95fbc64f4 --- /dev/null +++ b/packages/build-utils/src/transforms/remove-fenced-code.ts @@ -0,0 +1,484 @@ +import { hasProperty } from '@metamask/utils'; + +/** + * Two sets of feature labels, where: + * - `active` is the set of labels that are active for the current build. + * - `all` is the set of all labels that are declared in the codebase. + * + * For `ONLY_INCLUDE_IF` fences, the code fence removal transform will + * include the fenced code if any of the specified labels are active. See + * {@link removeFencedCode} for details. + */ +export type FeatureLabels = { + active: ReadonlySet; + all: ReadonlySet; +}; + +enum DirectiveTerminus { + BEGIN = 'BEGIN', + END = 'END', +} + +export enum DirectiveCommand { + // TODO: This should be `OnlyIncludeIf`, but we need to preserve + // backwards-compatibility for now. + // eslint-disable-next-line @typescript-eslint/naming-convention + ONLY_INCLUDE_IF = 'ONLY_INCLUDE_IF', +} + +// Matches lines starting with "///:", and any preceding whitespace, except +// newlines. We except newlines to avoid eating blank lines preceding a fenced +// line. +// Double-negative RegEx credit: https://stackoverflow.com/a/3469155 +const linesWithFenceRegex = /^[^\S\r\n]*\/\/\/:.*$/gmu; + +// Matches the first "///:" in a string, and any preceding whitespace +const fenceSentinelRegex = /^\s*\/\/\/:/u; + +// Breaks a fence directive into its constituent components. +// At this stage of parsing, we are looking for one of: +// - TERMINUS:COMMAND(PARAMS) +// - TERMINUS:COMMAND +const directiveParsingRegex = + /^([A-Z]+):([A-Z_]+)(?:\(((?:\w[-\w]*,)*\w[-\w]*)\))?$/u; + +/** + * Removes fenced code from the given JavaScript source string. "Fenced code" + * includes the entire fence lines, including their trailing newlines, and the + * lines that they surround. + * + * A valid fence consists of two well-formed fence lines, separated by one or + * more lines that should be excluded. The first line must contain a `BEGIN` + * directive, and the second most contain an `END` directive. Both directives + * must specify the same command. + * + * Here's an example of a valid fence: + * + * ```javascript + * ///: BEGIN:ONLY_INCLUDE_IF(build-flask) + * console.log('I am Flask.'); + * ///: END:ONLY_INCLUDE_IF + * ``` + * + * For details, please see the documentation. + * + * @param filePath - The path to the file being transformed. + * @param fileContent - The contents of the file being transformed. + * @param featureLabels - FeatureLabels that are currently active. + * @returns A tuple of the post-transform file contents and a boolean indicating + * whether they were modified. + */ +export function removeFencedCode( + filePath: string, + fileContent: string, + featureLabels: FeatureLabels, +): [string, boolean] { + // Do not modify the file if we detect an inline sourcemap. For reasons + // yet to be determined, the transform receives every file twice while in + // watch mode, the second after Babel has transpiled the file. Babel adds + // inline source maps to the file, something we will never do in our own + // source files, so we use the existence of inline source maps to determine + // whether we should ignore the file. + if (/^\/\/# sourceMappingURL=/gmu.test(fileContent)) { + return [fileContent, false]; + } + + // If we didn't match any lines, return the unmodified file contents. + const matchedLines = [...fileContent.matchAll(linesWithFenceRegex)]; + + if (matchedLines.length === 0) { + return [fileContent, false]; + } + + // Parse fence lines + const parsedDirectives = matchedLines.map((matchArray) => { + const line = matchArray[0]; + + /* istanbul ignore next: should be impossible */ + if ( + matchArray.index === undefined || + !line || + !fenceSentinelRegex.test(line) + ) { + throw new Error( + getInvalidFenceLineMessage( + filePath, + line ?? '', + `Fence sentinel may only appear at the start of a line, optionally preceded by whitespace.`, + ), + ); + } + + // Store the start and end indices of each line + // Increment the end index by 1 to including the trailing newline when + // performing string operations. + const indices: [number, number] = [ + matchArray.index, + matchArray.index + line.length + 1, + ]; + + const lineWithoutSentinel = line.replace(fenceSentinelRegex, ''); + if (!/^ \w\w+/u.test(lineWithoutSentinel)) { + throw new Error( + getInvalidFenceLineMessage( + filePath, + line, + `Fence sentinel must be followed by a single space and an alphabetical string of two or more characters.`, + ), + ); + } + + const directiveMatches = lineWithoutSentinel + .trim() + .match(directiveParsingRegex); + + if (!directiveMatches) { + throw new Error( + getInvalidFenceLineMessage( + filePath, + line, + `Failed to parse fence directive.`, + ), + ); + } + + // The first element of a RegEx match array is the input. + // Typecast: If there's a match, the expected elements must exist. + const [, terminus, command, parameters] = directiveMatches as [ + string, + string, + string, + string, + ]; + + if (!isValidTerminus(terminus)) { + throw new Error( + getInvalidFenceLineMessage( + filePath, + line, + `Line contains invalid directive terminus "${terminus}".`, + ), + ); + } + + if (!isValidCommand(command)) { + throw new Error( + getInvalidFenceLineMessage( + filePath, + line, + `Line contains invalid directive command "${command}".`, + ), + ); + } + + if (terminus === DirectiveTerminus.BEGIN) { + if (!parameters) { + throw new Error( + getInvalidParamsMessage(filePath, `No parameters specified.`), + ); + } + + return { + command, + indices, + line, + parameters: parameters.split(','), + terminus, + }; + } + return { command, indices, line, terminus }; + }); + + if (parsedDirectives.length % 2 !== 0) { + throw new Error( + getInvalidFenceStructureMessage( + filePath, + `A valid fence consists of two fence lines, but the file contains an uneven number, "${parsedDirectives.length}", of fence lines.`, + ), + ); + } + + // The below for-loop iterates over the parsed fence directives and performs + // the following work: + // - Ensures that the array of parsed directives consists of valid directive + // pairs, as specified in the documentation. + // - For each directive pair, determines whether their fenced lines should be + // removed for the current build, and if so, stores the indices we will use + // to splice the file content string. + + const splicingIndices: number[] = []; + let shouldSplice = false; + let currentCommand: string; + + parsedDirectives.forEach((directive, i) => { + const { line, indices, terminus, command } = directive; + + if (i % 2 === 0) { + if (terminus !== DirectiveTerminus.BEGIN) { + throw new Error( + getInvalidFencePairMessage( + filePath, + line, + `The first directive of a pair must be a "BEGIN" directive.`, + ), + ); + } + + const { parameters } = directive; + currentCommand = command; + validateCommand(command, parameters, filePath, featureLabels); + + const blockIsActive = parameters.some((param) => + featureLabels.active.has(param), + ); + + if (blockIsActive) { + shouldSplice = false; + } else { + shouldSplice = true; + + // Add start index of BEGIN directive line to splicing indices + splicingIndices.push(indices[0]); + } + } else { + if (terminus !== DirectiveTerminus.END) { + throw new Error( + getInvalidFencePairMessage( + filePath, + line, + `The second directive of a pair must be an "END" directive.`, + ), + ); + } + + /* istanbul ignore next: impossible until there's more than one command */ + if (command !== currentCommand) { + throw new Error( + getInvalidFencePairMessage( + filePath, + line, + `Expected "END" directive to have command "${currentCommand}" but found "${command}".`, + ), + ); + } + + // Forbid empty fences + const { line: previousLine, indices: previousIndices } = + // We're only in this case if i > 0, so this will always be defined. + parsedDirectives[i - 1]; + if (fileContent.substring(previousIndices[1], indices[0]).trim() === '') { + throw new Error( + `Empty fence found in file "${filePath}":\n${previousLine}\n${line}\n`, + ); + } + + if (shouldSplice) { + // Add end index of END directive line to splicing indices + splicingIndices.push(indices[1]); + } + } + }); + + // This indicates that the present build type should include all fenced code, + // and so we just returned the unmodified file contents. + if (splicingIndices.length === 0) { + return [fileContent, false]; + } + + /* istanbul ignore next: should be impossible */ + if (splicingIndices.length % 2 !== 0) { + throw new Error( + `Internal error while transforming file "${filePath}":\nCollected an uneven number of splicing indices: "${splicingIndices.length}"`, + ); + } + + return [multiSplice(fileContent, splicingIndices), true]; +} + +/** + * Returns a copy of the given string, without the character ranges specified + * by the splicing indices array. + * + * The splicing indices must be a non-empty, even-length array of non-negative + * integers, specifying the character ranges to remove from the given string, as + * follows: + * + * `[ start, end, start, end, start, end, ... ]` + * + * Throws if the array is not an even-length array of non-negative integers. + * + * @param toSplice - The string to splice. + * @param splicingIndices - Indices to splice at. + * @returns The spliced string. + */ +export function multiSplice( + toSplice: string, + splicingIndices: number[], +): string { + if (splicingIndices.length === 0 || splicingIndices.length % 2 !== 0) { + throw new Error('Expected non-empty, even-length array.'); + } + if (splicingIndices.some((index) => !Number.isInteger(index) || index < 0)) { + throw new Error('Expected array of non-negative integers.'); + } + + const retainedSubstrings: string[] = []; + + // Get the first part to be included + // The substring() call returns an empty string if splicingIndices[0] is 0, + // which is exactly what we want in that case. + retainedSubstrings.push(toSplice.substring(0, splicingIndices[0])); + + // This loop gets us all parts of the string that should be retained, except + // the first and the last. + // It iterates over all "end" indices of the array except the last one, and + // pushes the substring between each "end" index and the next "begin" index + // to the array of retained substrings. + if (splicingIndices.length > 2) { + // Note the boundary index of "splicingIndices.length - 1". This loop must + // not iterate over the last element of the array, which is handled outside + // of this loop. + for (let i = 1; i < splicingIndices.length - 1; i += 2) { + retainedSubstrings.push( + // splicingIndices[i] refers to an element between the first and last + // elements of the array, and will always be defined. + toSplice.substring(splicingIndices[i], splicingIndices[i + 1]), + ); + } + } + + // Get the last part to be included + retainedSubstrings.push( + // The last element of a non-empty array will always be defined. + toSplice.substring(splicingIndices[splicingIndices.length - 1]), + ); + return retainedSubstrings.join(''); +} + +/** + * Gets an invalid fence line error message. + * + * @param filePath - The path to the file that caused the error. + * @param line - The contents of the line with the error. + * @param details - An explanation of the error. + * @returns The error message. + */ +function getInvalidFenceLineMessage( + filePath: string, + line: string, + details: string, +): string { + return `Invalid fence line in file "${filePath}": "${line}":\n${details}`; +} + +/** + * Gets an invalid fence structure error message. + * + * @param filePath - The path to the file that caused the error. + * @param details - An explanation of the error. + * @returns The error message. + */ +function getInvalidFenceStructureMessage( + filePath: string, + details: string, +): string { + return `Invalid fence structure in file "${filePath}":\n${details}`; +} + +/** + * Gets an invalid fence pair error message. + * + * @param filePath - The path to the file that caused the error. + * @param line - The contents of the line with the error. + * @param details - An explanation of the error. + * @returns The error message. + */ +function getInvalidFencePairMessage( + filePath: string, + line: string, + details: string, +): string { + return `Invalid fence pair in file "${filePath}" due to line "${line}":\n${details}`; +} + +/** + * Gets an invalid command params error message. + * + * @param filePath - The path to the file that caused the error. + * @param details - An explanation of the error. + * @param command - The command of the directive with the invalid parameters, if known. + * @returns The error message. + */ +function getInvalidParamsMessage( + filePath: string, + details: string, + command?: string, +): string { + return `Invalid code fence parameters in file "${filePath}"${ + command ? `for command "${command}"` : '' + }:\n${details}`; +} + +/** + * Checks whether the given terminus string is valid, i.e. one of `BEGIN` or `END`. + * + * @param terminus - The terminus string to validate. + * @returns Whether the string is a valid terminus string. + */ +function isValidTerminus(terminus: string): terminus is DirectiveTerminus { + return hasProperty(DirectiveTerminus, terminus); +} + +/** + * Checks whether the given command string is valid. + * + * @param command - The command string to validate. + * @returns Whether the string is a valid command string. + */ +function isValidCommand(command: string): command is DirectiveCommand { + return hasProperty(DirectiveCommand, command); +} + +/** + * Validates the specified command. Throws if validation fails. + * + * @param command - The command to validate. + * @param params - The parameters of the command. + * @param filePath - The path of the current file. + * @param featureLabels - The possible feature labels. + */ +export function validateCommand( + command: unknown, + params: string[], + filePath: string, + featureLabels: FeatureLabels, +): asserts command is DirectiveCommand { + switch (command) { + case DirectiveCommand.ONLY_INCLUDE_IF: + if (!params || params.length === 0) { + throw new Error( + getInvalidParamsMessage( + filePath, + `No parameters specified.`, + DirectiveCommand.ONLY_INCLUDE_IF, + ), + ); + } + + for (const param of params) { + if (!featureLabels.all.has(param)) { + throw new Error( + getInvalidParamsMessage( + filePath, + `"${param}" is not a declared build feature.`, + DirectiveCommand.ONLY_INCLUDE_IF, + ), + ); + } + } + break; + + default: + throw new Error(`Unrecognized command "${String(command)}".`); + } +} diff --git a/packages/build-utils/src/transforms/utils.test.ts b/packages/build-utils/src/transforms/utils.test.ts new file mode 100644 index 00000000000..874b64c12d8 --- /dev/null +++ b/packages/build-utils/src/transforms/utils.test.ts @@ -0,0 +1,68 @@ +import { lintTransformedFile } from '../index.js'; + +describe('transform utils', () => { + describe('lintTransformedFile', () => { + // TODO: Replace `any` with type + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const mockESLint: any = { + lintText: jest.fn(), + }; + + it('returns if linting passes with no errors', async () => { + mockESLint.lintText.mockImplementationOnce(async () => + Promise.resolve([{ errorCount: 0 }]), + ); + + expect( + await lintTransformedFile(mockESLint, 'file.js', '/* JavaScript */'), + ).toBeUndefined(); + }); + + it('throws if the file is ignored by ESLint', async () => { + mockESLint.lintText.mockImplementationOnce(async () => + Promise.resolve([]), + ); + + await expect(async () => + lintTransformedFile(mockESLint, 'file.js', '/* JavaScript */'), + ).rejects.toThrow( + /Transformed file "file\.js" appears to be ignored by ESLint\.$/u, + ); + }); + + it('throws if linting produced any errors', async () => { + const ruleId = 'some-eslint-rule'; + const message = 'You violated the rule!'; + + mockESLint.lintText.mockImplementationOnce(async () => + Promise.resolve([ + { errorCount: 1, messages: [{ message, ruleId, severity: 2 }] }, + ]), + ); + + await expect(async () => + lintTransformedFile(mockESLint, 'file.js', '/* JavaScript */'), + ).rejects.toThrow( + /Lint errors encountered for transformed file "file\.js":\n\n {4}some-eslint-rule\n {4}You violated the rule!\n\n$/u, + ); + }); + + // Contrived case for coverage purposes + it('handles missing rule ids', async () => { + const ruleId = null; + const message = 'You violated the rule!'; + + mockESLint.lintText.mockImplementationOnce(async () => + Promise.resolve([ + { errorCount: 1, messages: [{ message, ruleId, severity: 2 }] }, + ]), + ); + + await expect(async () => + lintTransformedFile(mockESLint, 'file.js', '/* JavaScript */'), + ).rejects.toThrow( + /Lint errors encountered for transformed file "file\.js":\n\n {4}\n {4}You violated the rule!\n\n$/u, + ); + }); + }); +}); diff --git a/packages/build-utils/src/transforms/utils.ts b/packages/build-utils/src/transforms/utils.ts new file mode 100644 index 00000000000..1a4f2439ffb --- /dev/null +++ b/packages/build-utils/src/transforms/utils.ts @@ -0,0 +1,62 @@ +import type { ESLint } from 'eslint'; + +// Four spaces +const TAB = ' '; + +/** + * Lints a transformed file by invoking ESLint programmatically on the string + * file contents. The path to the file must be specified so that the repository + * ESLint config can be applied properly. + * + * **ATTN:** See the `eslintInstance` parameter documentation for important usage + * information. + * + * An error is thrown if linting produced any errors, or if the file is ignored + * by ESLint. Files linted by this function must not be ignored by ESLint. + * + * @param eslintInstance - The ESLint instance to use for linting. This instance + * needs to be initialized with the options `{ baseConfig, useEslintrc: false}`, + * where `baseConfig` is the desired ESLint configuration for linting. If using + * your project's regular `.eslintrc` file, you may need to modify certain rules + * for linting to pass after code fences are removed. Stylistic rules are + * particularly likely to cause problems. + * @param filePath - The path to the file. + * @param fileContent - The file content. + * @returns Returns `undefined` or throws an error if linting produced + * any errors, or if the linted file is ignored. + */ +export async function lintTransformedFile( + eslintInstance: ESLint, + filePath: string, + fileContent: string, +): Promise { + const lintResult = ( + await eslintInstance.lintText(fileContent, { filePath, warnIgnored: false }) + )[0]; + + // This indicates that the file is ignored, which should never be the case for + // a transformed file. + if (lintResult === undefined) { + throw new Error( + `MetaMask build: Transformed file "${filePath}" appears to be ignored by ESLint.`, + ); + } + + // This is the success case + if (lintResult.errorCount === 0) { + return; + } + + // Errors are stored in the messages array, and their "severity" is 2 + const errorsString = lintResult.messages + .filter(({ severity }) => severity === 2) + .reduce((allErrors, { message, ruleId }) => { + return allErrors.concat( + `${TAB}${ruleId ?? ''}\n${TAB}${message}\n\n`, + ); + }, ''); + + throw new Error( + `MetaMask build: Lint errors encountered for transformed file "${filePath}":\n\n${errorsString}`, + ); +} diff --git a/packages/build-utils/tsconfig.build.json b/packages/build-utils/tsconfig.build.json new file mode 100644 index 00000000000..0df910b2151 --- /dev/null +++ b/packages/build-utils/tsconfig.build.json @@ -0,0 +1,9 @@ +{ + "extends": "../../tsconfig.packages.build.json", + "compilerOptions": { + "baseUrl": "./", + "outDir": "./dist", + "rootDir": "./src" + }, + "include": ["../../types", "./src"] +} diff --git a/packages/build-utils/tsconfig.json b/packages/build-utils/tsconfig.json new file mode 100644 index 00000000000..ee9de925a21 --- /dev/null +++ b/packages/build-utils/tsconfig.json @@ -0,0 +1,7 @@ +{ + "extends": "../../tsconfig.packages.json", + "compilerOptions": { + "baseUrl": "./" + }, + "include": ["../../types", "./src"] +} diff --git a/packages/build-utils/tsconfig.lint.json b/packages/build-utils/tsconfig.lint.json new file mode 100644 index 00000000000..fb65dcfce34 --- /dev/null +++ b/packages/build-utils/tsconfig.lint.json @@ -0,0 +1,8 @@ +{ + "extends": ["./tsconfig.json", "../../tsconfig.packages.lint.json"], + "compilerOptions": { + "outDir": "./.tsc-lint-cache", + "tsBuildInfoFile": "./.tsc-lint-cache/tsconfig.tsbuildinfo" + }, + "references": [] +} diff --git a/packages/build-utils/typedoc.json b/packages/build-utils/typedoc.json new file mode 100644 index 00000000000..c9da015dbf8 --- /dev/null +++ b/packages/build-utils/typedoc.json @@ -0,0 +1,7 @@ +{ + "entryPoints": ["./src/index.ts"], + "excludePrivate": true, + "hideGenerator": true, + "out": "docs", + "tsconfig": "./tsconfig.build.json" +} diff --git a/packages/chain-agnostic-permission/CHANGELOG.md b/packages/chain-agnostic-permission/CHANGELOG.md new file mode 100644 index 00000000000..4633de82679 --- /dev/null +++ b/packages/chain-agnostic-permission/CHANGELOG.md @@ -0,0 +1,255 @@ +# 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.7.0] + +### Added + +- Add `getSessionProperties` async function that hydrates the persisted session properties of a CAIP-25 caveat value with an `eip155Capabilities` record, mapping each permitted EVM account address to its per-chain capabilities resolved from the provided `getCapabilities` hook ([#9294](https://github.com/MetaMask/core/pull/9294)) + - `getCapabilities` has the signature `(params: { address: string }) => Promise>>`. + +### Changed + +- Bump `@metamask/controller-utils` from `^12.2.0` to `^12.3.0` ([#9218](https://github.com/MetaMask/core/pull/9218)) + +## [1.6.2] + +### Changed + +- Bump `@metamask/api-specs` from `^0.14.0` to `^0.15.0` ([#9096](https://github.com/MetaMask/core/pull/9096)) +- Bump `@metamask/utils` from `^11.9.0` to `^11.11.0` ([#9074](https://github.com/MetaMask/core/pull/9074)) +- Bump `@metamask/controller-utils` from `^12.0.0` to `^12.2.0` ([#8774](https://github.com/MetaMask/core/pull/8774), [#9058](https://github.com/MetaMask/core/pull/9058), [#9083](https://github.com/MetaMask/core/pull/9083)) + +## [1.6.1] + +### Changed + +- Bump `@metamask/controller-utils` from `^11.20.0` to `^12.0.0` ([#8755](https://github.com/MetaMask/core/pull/8755)) +- Bump `@metamask/permission-controller` from `^13.1.0` to `^13.1.1` ([#8755](https://github.com/MetaMask/core/pull/8755)) + +## [1.6.0] + +### Added + +- Add `Eip1193Compatible` property in `KnownSessionProperties` enum to support EIP-1193-style connections established through `connect-evm` ([#8731](https://github.com/MetaMask/core/pull/8731)) +- Set `sessionProperties: { 'eip1193-compatible': true }` in `getCaip25PermissionFromLegacyPermissions` so that legacy EIP-1193 permission requests are tagged as EIP-1193-compatible ([#8731](https://github.com/MetaMask/core/pull/8731)) + +### Changed + +- Bump `@metamask/permission-controller` from `^12.2.1` to `^13.1.0` ([#8317](https://github.com/MetaMask/core/pull/8317), [#8661](https://github.com/MetaMask/core/pull/8661), [#8722](https://github.com/MetaMask/core/pull/8722)) +- Bump `@metamask/controller-utils` from `^11.19.0` to `^11.20.0` ([#8344](https://github.com/MetaMask/core/pull/8344)) + +## [1.5.0] + +### Added + +- Add optional `sortAccountIdsByLastSelected` parameter to `getSessionScopes` function to enable custom account ordering within session scopes ([#8255](https://github.com/MetaMask/core/pull/8255)) + +### Changed + +- Bump `@metamask/permission-controller` from `^12.2.0` to `^12.2.1` ([#8225](https://github.com/MetaMask/core/pull/8225)) +- Bump `@metamask/controller-utils` from `^11.17.0` to `^11.19.0` ([#7583](https://github.com/MetaMask/core/pull/7583), [#7995](https://github.com/MetaMask/core/pull/7995)) + +## [1.4.0] + +### Added + +- Add `Bip122AccountChangedNotifications` property in `KnownSessionProperties` enum ([#7537](https://github.com/MetaMask/core/pull/7537)) + +### Changed + +- Remove `@metamask/network-controller` dependency ([#7561](https://github.com/MetaMask/core/pull/7561)) +- Upgrade `@metamask/utils` from `^11.8.1` to `^11.9.0` ([#7511](https://github.com/MetaMask/core/pull/7511)) +- Bump `@metamask/network-controller` from `^27.0.0` to `^27.1.0` ([#7534](https://github.com/MetaMask/core/pull/7534)) +- Bump `@metamask/controller-utils` from `^11.16.0` to `^11.17.0` ([#7534](https://github.com/MetaMask/core/pull/7534)) +- Bump `@metamask/permission-controller` from `^12.1.1` to `^12.2.0` ([#7559](https://github.com/MetaMask/core/pull/7559)) + +## [1.3.0] + +### Added + +- Add `TronAccountChangedNotifications` property in `KnownSessionProperties` enum ([#7304](https://github.com/MetaMask/core/pull/7304)) + +### Changed + +- Bump `@metamask/network-controller` from `^26.0.0` to `^27.0.0` ([#7202](https://github.com/MetaMask/core/pull/7202), [#7258](https://github.com/MetaMask/core/pull/7258)) +- Bump `@metamask/controller-utils` from `^11.15.0` to `^11.16.0` ([#7202](https://github.com/MetaMask/core/pull/7202)) +- Bump `@metamask/permission-controller` from `^12.1.0` to `^12.1.1` ([#6988](https://github.com/MetaMask/core/pull/6988), [#7202](https://github.com/MetaMask/core/pull/7202)) + +## [1.2.2] + +### Changed + +- Bump `@metamask/network-controller` from `^24.3.1` to `^25.0.0` ([#6962](https://github.com/MetaMask/core/pull/6962)) +- Bump `@metamask/permission-controller` from `^11.1.1` to `^12.0.0` ([#6962](https://github.com/MetaMask/core/pull/6962)) + +## [1.2.1] + +### Changed + +- Bump `@metamask/network-controller` from `^24.2.1` to `^24.3.1` ([#6845](https://github.com/MetaMask/core/pull/6845), [#6883](https://github.com/MetaMask/core/pull/6883), [#6940](https://github.com/MetaMask/core/pull/6940)) +- Bump `@metamask/permission-controller` from `^11.1.0` to `^11.1.1` ([#6940](https://github.com/MetaMask/core/pull/6940)) + +## [1.2.0] + +### Changed + +- Bump `@metamask/utils` from `^11.4.2` to `^11.8.1` ([#6588](https://github.com/MetaMask/core/pull/6588), [#6708](https://github.com/MetaMask/core/pull/6708)) +- Bump `@metamask/controller-utils` from `^11.12.0` to `^11.14.1` ([#6620](https://github.com/MetaMask/core/pull/6620), [#6629](https://github.com/MetaMask/core/pull/6629), [#6807](https://github.com/MetaMask/core/pull/6807)) +- Add return type annotation to `getCaip25PermissionFromLegacyPermissions` to make its return output assignable to `RequestedPermissions` ([#6382](https://github.com/MetaMask/core/pull/6382)) +- Bump `@metamask/network-controller` from `^24.1.0` to `^24.2.1` ([#6678](https://github.com/MetaMask/core/pull/6678), [#6807](https://github.com/MetaMask/core/pull/6807)) +- Bump `@metamask/permission-controller` from `^11.0.6` to `^11.1.0` ([#6807](https://github.com/MetaMask/core/pull/6807)) + +## [1.1.1] + +### Changed + +- Bump `@metamask/controller-utils` from `^11.11.0` to `^11.12.0` ([#6303](https://github.com/MetaMask/core/pull/6303)) +- Bump `@metamask/network-controller` from `^24.0.1` to `^24.1.0` ([#6303](https://github.com/MetaMask/core/pull/6303)) +- Bump accounts related packages ([#6309](https://github.com/MetaMask/core/pull/6309)) + - Bump `@metamask/keyring-internal-api` from `^8.0.0` to `^8.1.0` + +## [1.1.0] + +### Added + +- Added `getCaip25PermissionFromLegacyPermissions` and `requestPermittedChainsPermissionIncremental` misc functions. ([#6225](https://github.com/MetaMask/core/pull/6225)) + +### Changed + +- Bump `@metamask/controller-utils` from `^11.10.0` to `^11.11.0` ([#6069](https://github.com/MetaMask/core/pull/6069)) +- Bump `@metamask/network-controller` from `^24.0.0` to `^24.0.1` ([#6148](https://github.com/MetaMask/core/pull/6148)) +- Bump `@metamask/utils` from `^11.2.0` to `^11.4.2` ([#6054](https://github.com/MetaMask/core/pull/6054)) + +## [1.0.0] + +### Changed + +- This package is now considered stable ([#6013](https://github.com/MetaMask/core/pull/6013)) + +## [0.8.0] + +### Changed + +- `isInternalAccountInPermittedAccountIds` now returns `false` when passed an `InternalAccount` in which `scopes` is `undefined` ([#6000](https://github.com/MetaMask/core/pull/6000)) +- Bump `@metamask/network-controller` to `^24.0.0` ([#5999](https://github.com/MetaMask/core/pull/5999)) + +## [0.7.1] + +### Changed + +- Bump `@metamask/keyring-internal-api` to `^6.2.0` ([#5871](https://github.com/MetaMask/core/pull/5871)) +- Bump `@metamask/controller-utils` to `^11.10.0` ([#5935](https://github.com/MetaMask/core/pull/5935)) +- Bump `@metamask/network-controller` to `^23.6.0` ([#5935](https://github.com/MetaMask/core/pull/5935), [#5882](https://github.com/MetaMask/core/pull/5882)) +- Change `caip25CaveatBuilder` to list unsupported scopes in the unsupported scopes error ([#5806](https://github.com/MetaMask/core/pull/5806)) + +### Fixed + +- Fix `isInternalAccountInPermittedAccountIds` and `isCaipAccountIdInPermittedAccountIds` to correctly handle comparison against `permittedAccounts` values of the `wallet::
` format ([#5980](https://github.com/MetaMask/core/pull/5980)) + +## [0.7.0] + +### Changed + +- Bump `@metamask/api-specs` to `^0.14.0` ([#5817](https://github.com/MetaMask/core/pull/5817)) +- Bump `@metamask/network-controller` to `^23.5.0` ([#5765](https://github.com/MetaMask/core/pull/5765), [#5812](https://github.com/MetaMask/core/pull/5812)) +- Bump `@metamask/controller-utils` to `^11.8.0` ([#5765](https://github.com/MetaMask/core/pull/5765), [#5812](https://github.com/MetaMask/core/pull/5812)) + +## [0.6.0] + +### Changed + +- Fix `getAllNamespacesFromCaip25CaveatValue` to return the reference instead of full scope when passed in values are `wallet` namespaced ([#5759](https://github.com/MetaMask/core/pull/5759)) +- Bump `@metamask/network-controller` to `^23.3.0` ([#5789](https://github.com/MetaMask/core/pull/5789)) + +## [0.5.0] + +### Added + +- Added `getCaipAccountIdsFromCaip25CaveatValue`, `isInternalAccountInPermittedAccountIds`, and `isCaipAccountIdInPermittedAccountIds` account id functions. ([#5609](https://github.com/MetaMask/core/pull/5609)) +- Added `getAllScopesFromCaip25CaveatValue`, `getAllWalletNamespacesFromCaip25CaveatValue`, `getAllScopesFromPermission`, `getAllScopesFromCaip25CaveatValue`, and `isNamespaceInScopesObject` + scope functions. ([#5609](https://github.com/MetaMask/core/pull/5609)) +- Added `getCaip25CaveatFromPermission` misc functions. ([#5609](https://github.com/MetaMask/core/pull/5609)) + +### Changed + +- **BREAKING:** Renamed `setPermittedAccounts` to `setNonSCACaipAccountIdsInCaip25CaveatValue`. ([#5609](https://github.com/MetaMask/core/pull/5609)) +- **BREAKING:** Renamed `setPermittedChainIds` to `setChainIdinCaip25CaveatValue`. ([#5609](https://github.com/MetaMask/core/pull/5609)) +- **BREAKING:** Renamed `addPermittedChainId` to `addCaipChainIdInCaip25CaveatValue`. ([#5609](https://github.com/MetaMask/core/pull/5609)) +- Bump `@metamask/controller-utils` to `^11.7.0` ([#5583](https://github.com/MetaMask/core/pull/5583)) +- Bump `@metamask/network-controller` to `^23.2.0` ([#5583](https://github.com/MetaMask/core/pull/5583)) + +## [0.4.0] + +### Added + +- Add and Export `isKnownSessionPropertyValue` validation utility function ([#5647](https://github.com/MetaMask/core/pull/5647)) +- Add and Export `getCaipAccountIdsFromScopesObjects` filtering utility function ([#5647](https://github.com/MetaMask/core/pull/5647)) +- Add and Export `getAllScopesFromScopesObjects` filtering utility function ([#5647](https://github.com/MetaMask/core/pull/5647)) +- Add and Export `getSupportedScopeObjects` filtering utility function ([#5647](https://github.com/MetaMask/core/pull/5647)) + +## [0.3.0] + +### Added + +- Export `KnownSessionProperties` enum ([#5522](https://github.com/MetaMask/core/pull/5522)) +- Add more chain agnostic utility functions for interfacing w/ caip25 permission ([#5536](https://github.com/MetaMask/core/pull/5536)) + - New `setPermittedAccounts` function that allows setting accounts for any CAIP namespace, not just EVM scopes. + - New `addPermittedChainId` and `setPermittedChainIds` functions for managing permitted chains across any CAIP namespace. + - New `generateCaip25Caveat` function to generate a valid `endowment:caip25` permission caveat from given accounts and chains of any CAIP namespace. + - New `isWalletScope` utility function to detect wallet-related scopes. + +### Changed + +- **BREAKING:** An error is now thrown in the caveat validator when a `caip25:endowment` permission caveat has no scopes in either `requiredScopes` or `optionalScopes` ([#5548](https://github.com/MetaMask/core/pull/5548)) + +## [0.2.0] + +### Added + +- Add validation for session properties in CAIP-25 caveat ([#5491](https://github.com/MetaMask/core/pull/5491)) +- Add `KnownSessionProperties` enum with initial `SolanaAccountChangedNotifications` property ([#5491](https://github.com/MetaMask/core/pull/5491)) +- Add `isSupportedSessionProperty` function to validate session properties ([#5491](https://github.com/MetaMask/core/pull/5491)) +- Add `getPermittedAccountsForScopes` helper function to get permitted accounts for specific scopes ([#5491](https://github.com/MetaMask/core/pull/5491)) +- Update merger function to properly merge session properties ([#5491](https://github.com/MetaMask/core/pull/5491)) + +### Changed + +- **BREAKING:** Updated `Caip25CaveatValue` type to make `sessionProperties` a required field instead of optional ([#5491](https://github.com/MetaMask/core/pull/5491)) +- Bump `@metamask/network-controller` to `^23.1.0` ([#5507](https://github.com/MetaMask/core/pull/5507), [#5518](https://github.com/MetaMask/core/pull/5518)) + +## [0.1.0] + +### Added + +- Initial release + +[Unreleased]: https://github.com/MetaMask/core/compare/@metamask/chain-agnostic-permission@1.7.0...HEAD +[1.7.0]: https://github.com/MetaMask/core/compare/@metamask/chain-agnostic-permission@1.6.2...@metamask/chain-agnostic-permission@1.7.0 +[1.6.2]: https://github.com/MetaMask/core/compare/@metamask/chain-agnostic-permission@1.6.1...@metamask/chain-agnostic-permission@1.6.2 +[1.6.1]: https://github.com/MetaMask/core/compare/@metamask/chain-agnostic-permission@1.6.0...@metamask/chain-agnostic-permission@1.6.1 +[1.6.0]: https://github.com/MetaMask/core/compare/@metamask/chain-agnostic-permission@1.5.0...@metamask/chain-agnostic-permission@1.6.0 +[1.5.0]: https://github.com/MetaMask/core/compare/@metamask/chain-agnostic-permission@1.4.0...@metamask/chain-agnostic-permission@1.5.0 +[1.4.0]: https://github.com/MetaMask/core/compare/@metamask/chain-agnostic-permission@1.3.0...@metamask/chain-agnostic-permission@1.4.0 +[1.3.0]: https://github.com/MetaMask/core/compare/@metamask/chain-agnostic-permission@1.2.2...@metamask/chain-agnostic-permission@1.3.0 +[1.2.2]: https://github.com/MetaMask/core/compare/@metamask/chain-agnostic-permission@1.2.1...@metamask/chain-agnostic-permission@1.2.2 +[1.2.1]: https://github.com/MetaMask/core/compare/@metamask/chain-agnostic-permission@1.2.0...@metamask/chain-agnostic-permission@1.2.1 +[1.2.0]: https://github.com/MetaMask/core/compare/@metamask/chain-agnostic-permission@1.1.1...@metamask/chain-agnostic-permission@1.2.0 +[1.1.1]: https://github.com/MetaMask/core/compare/@metamask/chain-agnostic-permission@1.1.0...@metamask/chain-agnostic-permission@1.1.1 +[1.1.0]: https://github.com/MetaMask/core/compare/@metamask/chain-agnostic-permission@1.0.0...@metamask/chain-agnostic-permission@1.1.0 +[1.0.0]: https://github.com/MetaMask/core/compare/@metamask/chain-agnostic-permission@0.8.0...@metamask/chain-agnostic-permission@1.0.0 +[0.8.0]: https://github.com/MetaMask/core/compare/@metamask/chain-agnostic-permission@0.7.1...@metamask/chain-agnostic-permission@0.8.0 +[0.7.1]: https://github.com/MetaMask/core/compare/@metamask/chain-agnostic-permission@0.7.0...@metamask/chain-agnostic-permission@0.7.1 +[0.7.0]: https://github.com/MetaMask/core/compare/@metamask/chain-agnostic-permission@0.6.0...@metamask/chain-agnostic-permission@0.7.0 +[0.6.0]: https://github.com/MetaMask/core/compare/@metamask/chain-agnostic-permission@0.5.0...@metamask/chain-agnostic-permission@0.6.0 +[0.5.0]: https://github.com/MetaMask/core/compare/@metamask/chain-agnostic-permission@0.4.0...@metamask/chain-agnostic-permission@0.5.0 +[0.4.0]: https://github.com/MetaMask/core/compare/@metamask/chain-agnostic-permission@0.3.0...@metamask/chain-agnostic-permission@0.4.0 +[0.3.0]: https://github.com/MetaMask/core/compare/@metamask/chain-agnostic-permission@0.2.0...@metamask/chain-agnostic-permission@0.3.0 +[0.2.0]: https://github.com/MetaMask/core/compare/@metamask/chain-agnostic-permission@0.1.0...@metamask/chain-agnostic-permission@0.2.0 +[0.1.0]: https://github.com/MetaMask/core/releases/tag/@metamask/chain-agnostic-permission@0.1.0 diff --git a/packages/chain-agnostic-permission/LICENSE b/packages/chain-agnostic-permission/LICENSE new file mode 100644 index 00000000000..c259cd7ebcf --- /dev/null +++ b/packages/chain-agnostic-permission/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2025 MetaMask + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packages/chain-agnostic-permission/README.md b/packages/chain-agnostic-permission/README.md new file mode 100644 index 00000000000..c4234476b8c --- /dev/null +++ b/packages/chain-agnostic-permission/README.md @@ -0,0 +1,15 @@ +# `@metamask/chain-agnostic-permission` + +Defines an endowment type permission designed to persist the account and chain components of a [CAIP-25](https://github.com/ChainAgnostic/CAIPs/blob/main/CAIPs/caip-25.md) request. This package also includes adapters and utility functions for interfacing with this permission. + +## Installation + +`yarn add @metamask/chain-agnostic-permission` + +or + +`npm install @metamask/chain-agnostic-permission` + +## 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/chain-agnostic-permission/jest.config.js b/packages/chain-agnostic-permission/jest.config.js new file mode 100644 index 00000000000..3ecd657de92 --- /dev/null +++ b/packages/chain-agnostic-permission/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: 98.2, + functions: 100, + lines: 100, + statements: 100, + }, + }, +}); diff --git a/packages/chain-agnostic-permission/package.json b/packages/chain-agnostic-permission/package.json new file mode 100644 index 00000000000..7d3daad684a --- /dev/null +++ b/packages/chain-agnostic-permission/package.json @@ -0,0 +1,79 @@ +{ + "name": "@metamask/chain-agnostic-permission", + "version": "1.7.0", + "description": "Defines a CAIP-25 based endowment permission and helpers for interfacing with it", + "keywords": [ + "Ethereum", + "MetaMask" + ], + "homepage": "https://github.com/MetaMask/core/tree/main/packages/chain-agnostic-permission#readme", + "bugs": { + "url": "https://github.com/MetaMask/core/issues" + }, + "license": "MIT", + "repository": { + "type": "git", + "url": "https://github.com/MetaMask/core.git" + }, + "files": [ + "dist/" + ], + "sideEffects": false, + "main": "./dist/index.cjs", + "types": "./dist/index.d.cts", + "exports": { + ".": { + "import": { + "types": "./dist/index.d.mts", + "default": "./dist/index.mjs" + }, + "require": { + "types": "./dist/index.d.cts", + "default": "./dist/index.cjs" + } + }, + "./package.json": "./package.json" + }, + "publishConfig": { + "access": "public", + "registry": "https://registry.npmjs.org/" + }, + "scripts": { + "build": "ts-bridge --project tsconfig.build.json --verbose --clean --no-references", + "build:all": "ts-bridge --project tsconfig.build.json --verbose --clean", + "build:docs": "typedoc", + "changelog:update": "../../scripts/update-changelog.sh @metamask/chain-agnostic-permission", + "changelog:validate": "../../scripts/validate-changelog.sh @metamask/chain-agnostic-permission", + "lint:tsconfigs": "tsx ../../scripts/lint-tsconfigs/lint-tsconfigs.mts", + "lint:tsconfigs:fix": "tsx ../../scripts/lint-tsconfigs/lint-tsconfigs.mts --fix", + "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/api-specs": "^0.15.0", + "@metamask/controller-utils": "^12.3.0", + "@metamask/permission-controller": "^13.1.1", + "@metamask/rpc-errors": "^7.0.2", + "@metamask/utils": "^11.11.0", + "lodash": "^4.17.21" + }, + "devDependencies": { + "@metamask/auto-changelog": "^6.1.0", + "@metamask/keyring-internal-api": "^12.0.0", + "@ts-bridge/cli": "^0.6.4", + "@types/jest": "^30.0.0", + "deepmerge": "^4.2.2", + "jest": "^30.4.2", + "ts-jest": "^29.4.11", + "tsx": "^4.20.5", + "typedoc": "^0.25.13", + "typedoc-plugin-missing-exports": "^2.0.0", + "typescript": "~5.3.3" + }, + "engines": { + "node": "^18.18 || >=20" + } +} diff --git a/packages/chain-agnostic-permission/src/caip25Permission.test.ts b/packages/chain-agnostic-permission/src/caip25Permission.test.ts new file mode 100644 index 00000000000..b8fb923934f --- /dev/null +++ b/packages/chain-agnostic-permission/src/caip25Permission.test.ts @@ -0,0 +1,2378 @@ +import { + CaveatMutatorOperation, + PermissionType, +} from '@metamask/permission-controller'; +import type { + SubjectPermissions, + ExtractPermission, + PermissionSpecificationConstraint, + CaveatSpecificationConstraint, +} from '@metamask/permission-controller'; +import { pick } from 'lodash'; + +import type { Caip25CaveatValue } from './caip25Permission.js'; +import { + Caip25CaveatType, + caip25EndowmentBuilder, + Caip25EndowmentPermissionName, + Caip25CaveatMutators, + createCaip25Caveat, + caip25CaveatBuilder, + diffScopesForCaip25CaveatValue, + generateCaip25Caveat, + getCaip25CaveatFromPermission, + getCaip25PermissionFromLegacyPermissions, + requestPermittedChainsPermissionIncremental, +} from './caip25Permission.js'; +import { CaveatTypes, PermissionKeys } from './constants.js'; +import { KnownSessionProperties } from './scope/constants.js'; +import * as ScopeSupported from './scope/supported.js'; + +jest.mock('./scope/supported', () => ({ + ...jest.requireActual('./scope/supported'), + isSupportedScopeString: jest.fn(), + isSupportedAccount: jest.fn(), +})); +const MockScopeSupported = jest.mocked(ScopeSupported); + +const { removeAccount, removeScope } = Caip25CaveatMutators[Caip25CaveatType]; + +const mockRequestPermissionsIncremental = jest.fn(); +const mockGrantPermissionsIncremental = jest.fn(); + +describe('caip25EndowmentBuilder', () => { + describe('specificationBuilder', () => { + it('builds the expected permission specification', () => { + const specification = caip25EndowmentBuilder.specificationBuilder({ + methodHooks: { + findNetworkClientIdByChainId: jest.fn(), + listAccounts: jest.fn(), + }, + }); + expect(specification).toStrictEqual({ + permissionType: PermissionType.Endowment, + targetName: Caip25EndowmentPermissionName, + endowmentGetter: expect.any(Function), + allowedCaveats: [Caip25CaveatType], + validator: expect.any(Function), + }); + + expect(specification.endowmentGetter()).toBeNull(); + }); + }); + + describe('createCaip25Caveat', () => { + it('builds the caveat', () => { + expect( + createCaip25Caveat({ + requiredScopes: {}, + optionalScopes: {}, + sessionProperties: {}, + isMultichainOrigin: true, + }), + ).toStrictEqual({ + type: Caip25CaveatType, + value: { + requiredScopes: {}, + optionalScopes: {}, + sessionProperties: {}, + isMultichainOrigin: true, + }, + }); + }); + }); + + describe('Caip25CaveatMutators.authorizedScopes', () => { + describe('removeScope', () => { + it('updates the caveat with the given scope removed from requiredScopes if it is present', () => { + const caveatValue = { + requiredScopes: { + 'eip155:1': { + accounts: [], + }, + }, + optionalScopes: { + 'eip155:5': { + accounts: [], + }, + }, + sessionProperties: {}, + isMultichainOrigin: true, + }; + const result = removeScope(caveatValue, 'eip155:1'); + expect(result).toStrictEqual({ + operation: CaveatMutatorOperation.UpdateValue, + value: { + requiredScopes: {}, + optionalScopes: { + 'eip155:5': { + accounts: [], + }, + }, + sessionProperties: {}, + isMultichainOrigin: true, + }, + }); + }); + + it('updates the caveat with the given scope removed from optionalScopes if it is present', () => { + const caveatValue = { + requiredScopes: { + 'eip155:1': { + accounts: [], + }, + }, + optionalScopes: { + 'eip155:5': { + accounts: [], + }, + }, + sessionProperties: {}, + isMultichainOrigin: true, + }; + const result = removeScope(caveatValue, 'eip155:5'); + expect(result).toStrictEqual({ + operation: CaveatMutatorOperation.UpdateValue, + value: { + requiredScopes: { + 'eip155:1': { + accounts: [], + }, + }, + optionalScopes: {}, + sessionProperties: {}, + isMultichainOrigin: true, + }, + }); + }); + + it('updates the caveat with the given scope removed from requiredScopes and optionalScopes if it is present', () => { + const caveatValue = { + requiredScopes: { + 'eip155:1': { + accounts: [], + }, + 'eip155:5': { + accounts: [], + }, + }, + optionalScopes: { + 'eip155:5': { + accounts: [], + }, + }, + sessionProperties: {}, + isMultichainOrigin: true, + }; + const result = removeScope(caveatValue, 'eip155:5'); + expect(result).toStrictEqual({ + operation: CaveatMutatorOperation.UpdateValue, + value: { + requiredScopes: { + 'eip155:1': { + accounts: [], + }, + }, + optionalScopes: {}, + sessionProperties: {}, + isMultichainOrigin: true, + }, + }); + }); + + it('revokes the permission if the only non wallet scope is removed', () => { + const caveatValue = { + requiredScopes: {}, + optionalScopes: { + 'eip155:5': { + accounts: [], + }, + 'wallet:eip155': { + accounts: [], + }, + wallet: { + accounts: [], + }, + }, + sessionProperties: {}, + isMultichainOrigin: true, + }; + const result = removeScope(caveatValue, 'eip155:5'); + expect(result).toStrictEqual({ + operation: CaveatMutatorOperation.RevokePermission, + }); + }); + + it('does nothing if the target scope does not exist but the permission only has wallet scopes', () => { + const caveatValue = { + requiredScopes: {}, + optionalScopes: { + 'wallet:eip155': { + accounts: [], + }, + wallet: { + accounts: [], + }, + }, + sessionProperties: {}, + isMultichainOrigin: true, + }; + const result = removeScope(caveatValue, 'eip155:5'); + expect(result).toStrictEqual({ + operation: CaveatMutatorOperation.Noop, + }); + }); + + it('does nothing if the given scope is not found in either requiredScopes or optionalScopes', () => { + const caveatValue = { + requiredScopes: { + 'eip155:1': { + accounts: [], + }, + }, + optionalScopes: { + 'eip155:5': { + accounts: [], + }, + }, + sessionProperties: {}, + isMultichainOrigin: true, + }; + const result = removeScope(caveatValue, 'eip155:2'); + expect(result).toStrictEqual({ + operation: CaveatMutatorOperation.Noop, + }); + }); + }); + + describe('removeAccount', () => { + it('updates the caveat with the given account removed from requiredScopes if it is present', () => { + const caveatValue: Caip25CaveatValue = { + requiredScopes: { + 'eip155:1': { + accounts: ['eip155:1:0x1', 'eip155:1:0x2'], + }, + }, + optionalScopes: {}, + sessionProperties: {}, + isMultichainOrigin: true, + }; + const result = removeAccount(caveatValue, '0x1'); + expect(result).toStrictEqual({ + operation: CaveatMutatorOperation.UpdateValue, + value: { + requiredScopes: { + 'eip155:1': { + accounts: ['eip155:1:0x2'], + }, + }, + optionalScopes: {}, + sessionProperties: {}, + isMultichainOrigin: true, + }, + }); + }); + + it('updates the caveat with the given account removed from optionalScopes if it is present', () => { + const caveatValue: Caip25CaveatValue = { + requiredScopes: {}, + optionalScopes: { + 'eip155:1': { + accounts: ['eip155:1:0x1', 'eip155:1:0x2'], + }, + }, + sessionProperties: {}, + isMultichainOrigin: true, + }; + const result = removeAccount(caveatValue, '0x1'); + expect(result).toStrictEqual({ + operation: CaveatMutatorOperation.UpdateValue, + value: { + requiredScopes: {}, + optionalScopes: { + 'eip155:1': { + accounts: ['eip155:1:0x2'], + }, + }, + sessionProperties: {}, + isMultichainOrigin: true, + }, + }); + }); + + it('updates the caveat with the given account removed from requiredScopes and optionalScopes if it is present', () => { + const caveatValue: Caip25CaveatValue = { + requiredScopes: { + 'eip155:1': { + accounts: ['eip155:1:0x1', 'eip155:1:0x2'], + }, + 'eip155:2': { + accounts: ['eip155:2:0x1', 'eip155:2:0x2'], + }, + }, + optionalScopes: { + 'eip155:3': { + accounts: ['eip155:3:0x1', 'eip155:3:0x2'], + }, + }, + sessionProperties: {}, + isMultichainOrigin: true, + }; + const result = removeAccount(caveatValue, '0x1'); + expect(result).toStrictEqual({ + operation: CaveatMutatorOperation.UpdateValue, + value: { + requiredScopes: { + 'eip155:1': { + accounts: ['eip155:1:0x2'], + }, + 'eip155:2': { + accounts: ['eip155:2:0x2'], + }, + }, + optionalScopes: { + 'eip155:3': { + accounts: ['eip155:3:0x2'], + }, + }, + sessionProperties: {}, + isMultichainOrigin: true, + }, + }); + }); + + it('revokes the permission if the only account is removed', () => { + const caveatValue: Caip25CaveatValue = { + requiredScopes: {}, + optionalScopes: { + 'eip155:1': { + accounts: ['eip155:1:0x1'], + }, + }, + sessionProperties: {}, + isMultichainOrigin: true, + }; + const result = removeAccount(caveatValue, '0x1'); + expect(result).toStrictEqual({ + operation: CaveatMutatorOperation.RevokePermission, + }); + }); + + it('updates the permission with the target account removed if the target account does exist and `wallet:eip155` is the only scope with remaining accounts after', () => { + const caveatValue: Caip25CaveatValue = { + requiredScopes: {}, + optionalScopes: { + 'eip155:1': { + accounts: ['eip155:1:0x1'], + }, + 'wallet:eip155': { + accounts: ['wallet:eip155:0x1', 'wallet:eip155:0x2'], + }, + }, + sessionProperties: {}, + isMultichainOrigin: true, + }; + const result = removeAccount(caveatValue, '0x1'); + expect(result).toStrictEqual({ + operation: CaveatMutatorOperation.UpdateValue, + value: { + requiredScopes: {}, + optionalScopes: { + 'eip155:1': { + accounts: [], + }, + 'wallet:eip155': { + accounts: ['wallet:eip155:0x2'], + }, + }, + sessionProperties: {}, + isMultichainOrigin: true, + }, + }); + }); + + it('does nothing if the target account does not exist but the permission already has no accounts', () => { + const caveatValue: Caip25CaveatValue = { + requiredScopes: {}, + optionalScopes: { + 'eip155:1': { + accounts: [], + }, + }, + sessionProperties: {}, + isMultichainOrigin: true, + }; + const result = removeAccount(caveatValue, '0x1'); + expect(result).toStrictEqual({ + operation: CaveatMutatorOperation.Noop, + }); + }); + + it('does nothing if the given account is not found in either requiredScopes or optionalScopes', () => { + const caveatValue: Caip25CaveatValue = { + requiredScopes: { + 'eip155:1': { + accounts: ['eip155:1:0x1', 'eip155:1:0x2'], + }, + }, + optionalScopes: { + 'eip155:5': { + accounts: [], + }, + }, + sessionProperties: {}, + isMultichainOrigin: true, + }; + const result = removeAccount(caveatValue, '0x3'); + expect(result).toStrictEqual({ + operation: CaveatMutatorOperation.Noop, + }); + }); + }); + }); + + describe('permission validator', () => { + const { validator } = caip25EndowmentBuilder.specificationBuilder({}); + + it('throws an error if there is not exactly one caveat', () => { + expect(() => { + validator({ + caveats: [ + { + type: 'caveatType', + value: {}, + }, + { + type: 'caveatType', + value: {}, + }, + ], + date: 1234, + id: '1', + invoker: 'test.com', + parentCapability: Caip25EndowmentPermissionName, + }); + }).toThrow( + new Error( + `${Caip25EndowmentPermissionName} error: Invalid caveats. There must be a single caveat of type "${Caip25CaveatType}".`, + ), + ); + + expect(() => { + validator({ + // @ts-expect-error Intentionally invalid input + caveats: [], + date: 1234, + id: '1', + invoker: 'test.com', + parentCapability: Caip25EndowmentPermissionName, + }); + }).toThrow( + new Error( + `${Caip25EndowmentPermissionName} error: Invalid caveats. There must be a single caveat of type "${Caip25CaveatType}".`, + ), + ); + }); + + it('throws an error if there is no CAIP-25 caveat', () => { + expect(() => { + validator({ + caveats: [ + { + type: 'NotCaip25Caveat', + value: {}, + }, + ], + date: 1234, + id: '1', + invoker: 'test.com', + parentCapability: Caip25EndowmentPermissionName, + }); + }).toThrow( + new Error( + `${Caip25EndowmentPermissionName} error: Invalid caveats. There must be a single caveat of type "${Caip25CaveatType}".`, + ), + ); + }); + }); +}); + +describe('caip25CaveatBuilder', () => { + const findNetworkClientIdByChainId = jest.fn(); + const listAccounts = jest.fn(); + const isNonEvmScopeSupported = jest.fn(); + const getNonEvmAccountAddresses = jest.fn(); + const { validator, merger } = caip25CaveatBuilder({ + findNetworkClientIdByChainId, + listAccounts, + isNonEvmScopeSupported, + getNonEvmAccountAddresses, + }); + + it('throws an error if the CAIP-25 caveat is malformed', () => { + expect(() => { + validator({ + type: Caip25CaveatType, + value: { + missingRequiredScopes: {}, + optionalScopes: {}, + sessionProperties: {}, + isMultichainOrigin: true, + }, + }); + }).toThrow( + new Error( + `${Caip25EndowmentPermissionName} error: Received invalid value for caveat of type "${Caip25CaveatType}".`, + ), + ); + + expect(() => { + validator({ + type: Caip25CaveatType, + value: { + requiredScopes: {}, + sessionProperties: {}, + isMultichainOrigin: true, + }, + }); + }).toThrow( + new Error( + `${Caip25EndowmentPermissionName} error: Received invalid value for caveat of type "${Caip25CaveatType}".`, + ), + ); + + expect(() => { + validator({ + type: Caip25CaveatType, + value: { + requiredScopes: {}, + optionalScopes: {}, + sessionProperties: {}, + isMultichainOrigin: 'NotABoolean', + }, + }); + }).toThrow( + new Error( + `${Caip25EndowmentPermissionName} error: Received invalid value for caveat of type "${Caip25CaveatType}".`, + ), + ); + + expect(() => { + validator({ + type: Caip25CaveatType, + value: { + requiredScopes: {}, + optionalScopes: {}, + isMultichainOrigin: true, + }, + }); + }).toThrow( + new Error( + `${Caip25EndowmentPermissionName} error: Received invalid value for caveat of type "${Caip25CaveatType}".`, + ), + ); + }); + + it('throws an error if there are unknown session properties', () => { + expect(() => { + validator({ + type: Caip25CaveatType, + value: { + requiredScopes: {}, + optionalScopes: {}, + sessionProperties: { + unknownProperty: 'unknownValue', + }, + isMultichainOrigin: true, + }, + }); + }).toThrow( + new Error( + `${Caip25EndowmentPermissionName} error: Received unknown session property(s) for caveat of type "${Caip25CaveatType}".`, + ), + ); + }); + + it('asserts the internal required scopeStrings are supported', () => { + MockScopeSupported.isSupportedScopeString.mockReturnValue(true); + + try { + validator({ + type: Caip25CaveatType, + value: { + requiredScopes: { + 'eip155:1': { + accounts: [], + }, + 'bip122:000000000019d6689c085ae165831e93': { + accounts: [], + }, + }, + optionalScopes: { + 'eip155:5': { + accounts: [], + }, + 'bip122:12a765e31ffd4059bada1e25190f6e98': { + accounts: [], + }, + }, + sessionProperties: {}, + isMultichainOrigin: true, + }, + }); + } catch { + // noop + } + expect(MockScopeSupported.isSupportedScopeString).toHaveBeenCalledWith( + 'eip155:1', + { + isEvmChainIdSupported: expect.any(Function), + isNonEvmScopeSupported: expect.any(Function), + }, + ); + expect(MockScopeSupported.isSupportedScopeString).toHaveBeenCalledWith( + 'bip122:000000000019d6689c085ae165831e93', + { + isEvmChainIdSupported: expect.any(Function), + isNonEvmScopeSupported: expect.any(Function), + }, + ); + + MockScopeSupported.isSupportedScopeString.mock.calls[0][1].isEvmChainIdSupported( + '0x1', + ); + expect(findNetworkClientIdByChainId).toHaveBeenCalledWith('0x1'); + }); + + it('asserts the internal optional scopeStrings are supported', () => { + MockScopeSupported.isSupportedScopeString.mockReturnValue(true); + + try { + validator({ + type: Caip25CaveatType, + value: { + requiredScopes: { + 'eip155:1': { + accounts: [], + }, + 'bip122:000000000019d6689c085ae165831e93': { + accounts: [], + }, + }, + optionalScopes: { + 'eip155:5': { + accounts: [], + }, + 'bip122:12a765e31ffd4059bada1e25190f6e98': { + accounts: [], + }, + }, + sessionProperties: {}, + isMultichainOrigin: true, + }, + }); + } catch { + // noop + } + + expect(MockScopeSupported.isSupportedScopeString).toHaveBeenCalledWith( + 'eip155:5', + { + isEvmChainIdSupported: expect.any(Function), + isNonEvmScopeSupported: expect.any(Function), + }, + ); + expect(MockScopeSupported.isSupportedScopeString).toHaveBeenCalledWith( + 'bip122:12a765e31ffd4059bada1e25190f6e98', + { + isEvmChainIdSupported: expect.any(Function), + isNonEvmScopeSupported: expect.any(Function), + }, + ); + + MockScopeSupported.isSupportedScopeString.mock.calls[1][1].isEvmChainIdSupported( + '0x5', + ); + expect(findNetworkClientIdByChainId).toHaveBeenCalledWith('0x5'); + }); + + it('does not throw if unable to find a network client for the evm chainId', () => { + findNetworkClientIdByChainId.mockImplementation(() => { + throw new Error('unable to find network client'); + }); + try { + validator({ + type: Caip25CaveatType, + value: { + requiredScopes: { + 'eip155:1': { + accounts: [], + }, + }, + optionalScopes: { + 'eip155:5': { + accounts: [], + }, + }, + sessionProperties: {}, + isMultichainOrigin: true, + }, + }); + } catch { + // noop + } + + expect( + MockScopeSupported.isSupportedScopeString.mock.calls[0][1].isEvmChainIdSupported( + '0x1', + ), + ).toBe(false); + expect(findNetworkClientIdByChainId).toHaveBeenCalledWith('0x1'); + }); + + it('throws if not all scopeStrings are supported', () => { + expect(() => { + validator({ + type: Caip25CaveatType, + value: { + requiredScopes: { + 'eip155:1': { + accounts: [], + }, + 'bip122:000000000019d6689c085ae165831e93': { + accounts: [], + }, + }, + optionalScopes: { + 'eip155:5': { + accounts: [], + }, + 'bip122:12a765e31ffd4059bada1e25190f6e98': { + accounts: [], + }, + }, + sessionProperties: {}, + isMultichainOrigin: true, + }, + }); + }).toThrow( + new Error( + `${Caip25EndowmentPermissionName} error: Received scopeString value(s): eip155:1, bip122:000000000019d6689c085ae165831e93, eip155:5, bip122:12a765e31ffd4059bada1e25190f6e98 for caveat of type "${Caip25CaveatType}" that are not supported by the wallet.`, + ), + ); + }); + + it('asserts the required accounts are supported', () => { + MockScopeSupported.isSupportedScopeString.mockReturnValue(true); + MockScopeSupported.isSupportedAccount.mockReturnValue(true); + + try { + validator({ + type: Caip25CaveatType, + value: { + requiredScopes: { + 'eip155:1': { + accounts: ['eip155:1:0xdead'], + }, + 'bip122:000000000019d6689c085ae165831e93': { + accounts: ['bip122:000000000019d6689c085ae165831e93:123'], + }, + }, + optionalScopes: { + 'eip155:5': { + accounts: ['eip155:5:0xbeef'], + }, + 'bip122:12a765e31ffd4059bada1e25190f6e98': { + accounts: ['bip122:12a765e31ffd4059bada1e25190f6e98:456'], + }, + }, + sessionProperties: {}, + isMultichainOrigin: true, + }, + }); + } catch { + // noop + } + expect(MockScopeSupported.isSupportedAccount).toHaveBeenCalledWith( + 'eip155:1:0xdead', + { + getEvmInternalAccounts: expect.any(Function), + getNonEvmAccountAddresses: expect.any(Function), + }, + ); + expect(MockScopeSupported.isSupportedAccount).toHaveBeenCalledWith( + 'bip122:000000000019d6689c085ae165831e93:123', + { + getEvmInternalAccounts: expect.any(Function), + getNonEvmAccountAddresses: expect.any(Function), + }, + ); + }); + + it('asserts the optional accounts are supported', () => { + MockScopeSupported.isSupportedScopeString.mockReturnValue(true); + MockScopeSupported.isSupportedAccount.mockReturnValue(true); + + try { + validator({ + type: Caip25CaveatType, + value: { + requiredScopes: { + 'eip155:1': { + accounts: ['eip155:1:0xdead'], + }, + 'bip122:000000000019d6689c085ae165831e93': { + accounts: ['bip122:000000000019d6689c085ae165831e93:123'], + }, + }, + optionalScopes: { + 'eip155:5': { + accounts: ['eip155:5:0xbeef'], + }, + 'bip122:12a765e31ffd4059bada1e25190f6e98': { + accounts: ['bip122:12a765e31ffd4059bada1e25190f6e98:456'], + }, + }, + sessionProperties: {}, + isMultichainOrigin: true, + }, + }); + } catch { + // noop + } + expect(MockScopeSupported.isSupportedAccount).toHaveBeenCalledWith( + 'eip155:5:0xbeef', + { + getEvmInternalAccounts: expect.any(Function), + getNonEvmAccountAddresses: expect.any(Function), + }, + ); + expect(MockScopeSupported.isSupportedAccount).toHaveBeenCalledWith( + 'bip122:000000000019d6689c085ae165831e93:123', + { + getEvmInternalAccounts: expect.any(Function), + getNonEvmAccountAddresses: expect.any(Function), + }, + ); + }); + + it('throws if the accounts specified in the internal scopeObjects are not supported', () => { + MockScopeSupported.isSupportedScopeString.mockReturnValue(true); + + expect(() => { + validator({ + type: Caip25CaveatType, + value: { + requiredScopes: { + 'eip155:1': { + accounts: ['eip155:1:0xdead'], + }, + }, + optionalScopes: { + 'eip155:5': { + accounts: ['eip155:5:0xbeef'], + }, + }, + sessionProperties: {}, + isMultichainOrigin: true, + }, + }); + }).toThrow( + new Error( + `${Caip25EndowmentPermissionName} error: Received account value(s) for caveat of type "${Caip25CaveatType}" that are not supported by the wallet.`, + ), + ); + }); + + it('does not throw if the CAIP-25 caveat value is valid', () => { + MockScopeSupported.isSupportedScopeString.mockReturnValue(true); + MockScopeSupported.isSupportedAccount.mockReturnValue(true); + + expect( + validator({ + type: Caip25CaveatType, + value: { + requiredScopes: { + 'eip155:1': { + accounts: ['eip155:1:0xdead'], + }, + 'bip122:000000000019d6689c085ae165831e93': { + accounts: ['bip122:000000000019d6689c085ae165831e93:123'], + }, + }, + optionalScopes: { + 'eip155:5': { + accounts: ['eip155:5:0xbeef'], + }, + 'bip122:12a765e31ffd4059bada1e25190f6e98': { + accounts: ['bip122:12a765e31ffd4059bada1e25190f6e98:456'], + }, + }, + sessionProperties: {}, + isMultichainOrigin: true, + }, + }), + ).toBeUndefined(); + }); + + it('throws an error if both requiredScopes and optionalScopes are empty', () => { + expect(() => { + validator({ + type: Caip25CaveatType, + value: { + requiredScopes: {}, + optionalScopes: {}, + sessionProperties: {}, + isMultichainOrigin: true, + }, + }); + }).toThrow( + new Error( + `${Caip25EndowmentPermissionName} error: Received no scopes for caveat of type "${Caip25CaveatType}".`, + ), + ); + }); + + describe('permission merger', () => { + describe('incremental request an existing scope (requiredScopes), and 2 whole new scopes (optionalScopes) with accounts', () => { + it('should return merged scope with previously existing chain and accounts, plus new requested chains with new accounts', () => { + const initLeftValue: Caip25CaveatValue = { + requiredScopes: { + 'eip155:1': { + accounts: ['eip155:1:0xdead'], + }, + }, + optionalScopes: {}, + sessionProperties: {}, + isMultichainOrigin: false, + }; + + const rightValue: Caip25CaveatValue = { + requiredScopes: {}, + optionalScopes: { + 'eip155:1': { + accounts: ['eip155:1:0xdead', 'eip155:1:0xbadd'], + }, + 'eip155:10': { + accounts: ['eip155:10:0xbeef', 'eip155:10:0xbadd'], + }, + 'eip155:426161': { + accounts: [ + 'eip155:426161:0xdead', + 'eip155:426161:0xbeef', + 'eip155:426161:0xbadd', + ], + }, + }, + sessionProperties: {}, + isMultichainOrigin: false, + }; + + const expectedMergedValue: Caip25CaveatValue = { + requiredScopes: { + 'eip155:1': { accounts: ['eip155:1:0xdead'] }, + }, + optionalScopes: { + 'eip155:1': { accounts: ['eip155:1:0xdead', 'eip155:1:0xbadd'] }, + 'eip155:10': { + accounts: ['eip155:10:0xbeef', 'eip155:10:0xbadd'], + }, + 'eip155:426161': { + accounts: [ + 'eip155:426161:0xdead', + 'eip155:426161:0xbeef', + 'eip155:426161:0xbadd', + ], + }, + }, + sessionProperties: {}, + isMultichainOrigin: false, + }; + const expectedDiff: Caip25CaveatValue = { + requiredScopes: {}, + optionalScopes: { + 'eip155:1': { accounts: ['eip155:1:0xdead', 'eip155:1:0xbadd'] }, + 'eip155:10': { + accounts: ['eip155:10:0xbeef', 'eip155:10:0xbadd'], + }, + 'eip155:426161': { + accounts: [ + 'eip155:426161:0xdead', + 'eip155:426161:0xbeef', + 'eip155:426161:0xbadd', + ], + }, + }, + sessionProperties: {}, + isMultichainOrigin: false, + }; + const [newValue, diff] = merger(initLeftValue, rightValue); + + expect(newValue).toStrictEqual( + expect.objectContaining(expectedMergedValue), + ); + expect(diff).toStrictEqual(expect.objectContaining(expectedDiff)); + }); + }); + describe('incremental request an existing scope with session properties', () => { + it('should return merged scope with previously existing chain and accounts, plus new requested chains with new accounts and merged session properties', () => { + const initLeftValue: Caip25CaveatValue = { + requiredScopes: {}, + optionalScopes: { + 'eip155:1': { + accounts: ['eip155:1:0xdead'], + }, + }, + sessionProperties: { + [KnownSessionProperties.SolanaAccountChangedNotifications]: true, + }, + isMultichainOrigin: true, + }; + + const rightValue: Caip25CaveatValue = { + requiredScopes: {}, + optionalScopes: { + 'eip155:1': { + accounts: [ + 'eip155:1:0xbadd', + 'eip155:1:0xbeef', + 'eip155:1:0xdead', + ], + }, + }, + sessionProperties: { + [KnownSessionProperties.SolanaAccountChangedNotifications]: false, + otherProperty: 'otherValue', + }, + isMultichainOrigin: true, + }; + + const expectedMergedValue: Caip25CaveatValue = { + requiredScopes: {}, + optionalScopes: { + 'eip155:1': { + accounts: [ + 'eip155:1:0xdead', + 'eip155:1:0xbadd', + 'eip155:1:0xbeef', + ], + }, + }, + sessionProperties: { + [KnownSessionProperties.SolanaAccountChangedNotifications]: false, + otherProperty: 'otherValue', + }, + isMultichainOrigin: true, + }; + + const [newValue] = merger(initLeftValue, rightValue); + + expect(newValue).toStrictEqual( + expect.objectContaining(expectedMergedValue), + ); + }); + }); + }); +}); + +describe('diffScopesForCaip25CaveatValue', () => { + describe('incremental request existing optional scope with a new account', () => { + it('should return scope with existing chain and new requested account', () => { + const leftValue: Caip25CaveatValue = { + optionalScopes: { + 'eip155:1': { + accounts: ['eip155:1:0xdead'], + }, + }, + requiredScopes: {}, + sessionProperties: {}, + isMultichainOrigin: false, + }; + + const mergedValue: Caip25CaveatValue = { + optionalScopes: { + 'eip155:1': { + accounts: ['eip155:1:0xdead', 'eip155:1:0xbeef'], + }, + }, + requiredScopes: {}, + sessionProperties: {}, + isMultichainOrigin: false, + }; + + const expectedDiff: Caip25CaveatValue = { + optionalScopes: { + 'eip155:1': { + accounts: ['eip155:1:0xbeef'], + }, + }, + isMultichainOrigin: false, + requiredScopes: {}, + sessionProperties: {}, + }; + + const diff = diffScopesForCaip25CaveatValue( + leftValue, + mergedValue, + 'optionalScopes', + ); + + expect(diff).toStrictEqual(expectedDiff); + }); + }); + + describe('incremental request a whole new optional scope without accounts', () => { + it('should return scope with new requested chain and no accounts', () => { + const leftValue: Caip25CaveatValue = { + optionalScopes: { + 'eip155:1': { + accounts: ['eip155:1:0xdead'], + }, + }, + requiredScopes: {}, + sessionProperties: {}, + isMultichainOrigin: false, + }; + + const mergedValue: Caip25CaveatValue = { + optionalScopes: { + 'eip155:1': { + accounts: ['eip155:1:0xdead'], + }, + 'eip155:10': { + accounts: [], + }, + }, + requiredScopes: {}, + sessionProperties: {}, + isMultichainOrigin: false, + }; + + const expectedDiff: Caip25CaveatValue = { + optionalScopes: { + 'eip155:10': { + accounts: [], + }, + }, + isMultichainOrigin: false, + requiredScopes: {}, + sessionProperties: {}, + }; + + const diff = diffScopesForCaip25CaveatValue( + leftValue, + mergedValue, + 'optionalScopes', + ); + + expect(diff).toStrictEqual(expectedDiff); + }); + }); + + describe('incremental request a whole new optional scope with accounts', () => { + it('should return scope with new requested chain and new account', () => { + const leftValue: Caip25CaveatValue = { + optionalScopes: { + 'eip155:1': { + accounts: ['eip155:1:0xdead'], + }, + }, + requiredScopes: {}, + sessionProperties: {}, + isMultichainOrigin: false, + }; + + const mergedValue: Caip25CaveatValue = { + optionalScopes: { + 'eip155:1': { + accounts: ['eip155:1:0xdead'], + }, + 'eip155:10': { + accounts: ['eip155:10:0xbeef'], + }, + }, + requiredScopes: {}, + sessionProperties: {}, + isMultichainOrigin: false, + }; + + const expectedDiff: Caip25CaveatValue = { + optionalScopes: { + 'eip155:10': { + accounts: ['eip155:10:0xbeef'], + }, + }, + isMultichainOrigin: false, + requiredScopes: {}, + sessionProperties: {}, + }; + + const diff = diffScopesForCaip25CaveatValue( + leftValue, + mergedValue, + 'optionalScopes', + ); + + expect(diff).toStrictEqual(expectedDiff); + }); + }); + + describe('incremental request an existing optional scope with new accounts, and whole new optional scope with accounts', () => { + it('should return scope with previously existing chain and accounts, plus new requested chain with new accounts', () => { + const leftValue: Caip25CaveatValue = { + optionalScopes: { + 'eip155:1': { + accounts: ['eip155:1:0xdead'], + }, + }, + requiredScopes: {}, + sessionProperties: {}, + isMultichainOrigin: false, + }; + + const mergedValue: Caip25CaveatValue = { + optionalScopes: { + 'eip155:1': { + accounts: ['eip155:1:0xdead', 'eip155:1:0xbeef'], + }, + 'eip155:10': { + accounts: ['eip155:10:0xdead', 'eip155:10:0xbeef'], + }, + }, + requiredScopes: {}, + sessionProperties: {}, + isMultichainOrigin: false, + }; + + const expectedDiff: Caip25CaveatValue = { + optionalScopes: { + 'eip155:1': { + accounts: ['eip155:1:0xbeef'], + }, + 'eip155:10': { + accounts: ['eip155:10:0xdead', 'eip155:10:0xbeef'], + }, + }, + isMultichainOrigin: false, + requiredScopes: {}, + sessionProperties: {}, + }; + + const diff = diffScopesForCaip25CaveatValue( + leftValue, + mergedValue, + 'optionalScopes', + ); + + expect(diff).toStrictEqual(expectedDiff); + }); + }); + + describe('incremental request existing required scope with a new account', () => { + it('should return scope with existing chain and new requested account', () => { + const leftValue: Caip25CaveatValue = { + requiredScopes: { + 'eip155:1': { + accounts: ['eip155:1:0xdead'], + }, + }, + optionalScopes: {}, + sessionProperties: {}, + isMultichainOrigin: false, + }; + + const mergedValue: Caip25CaveatValue = { + requiredScopes: { + 'eip155:1': { + accounts: ['eip155:1:0xdead', 'eip155:1:0xbeef'], + }, + }, + optionalScopes: {}, + sessionProperties: {}, + isMultichainOrigin: false, + }; + + const expectedDiff: Caip25CaveatValue = { + requiredScopes: { + 'eip155:1': { + accounts: ['eip155:1:0xbeef'], + }, + }, + isMultichainOrigin: false, + optionalScopes: {}, + sessionProperties: {}, + }; + + const diff = diffScopesForCaip25CaveatValue( + leftValue, + mergedValue, + 'requiredScopes', + ); + + expect(diff).toStrictEqual(expectedDiff); + }); + }); + + describe('incremental request a whole new required scope without accounts', () => { + it('should return scope with new requested chain and no accounts', () => { + const leftValue: Caip25CaveatValue = { + requiredScopes: { + 'eip155:1': { + accounts: ['eip155:1:0xdead'], + }, + }, + optionalScopes: {}, + sessionProperties: {}, + isMultichainOrigin: false, + }; + + const mergedValue: Caip25CaveatValue = { + requiredScopes: { + 'eip155:1': { + accounts: ['eip155:1:0xdead'], + }, + 'eip155:10': { + accounts: [], + }, + }, + optionalScopes: {}, + sessionProperties: {}, + isMultichainOrigin: false, + }; + + const expectedDiff: Caip25CaveatValue = { + requiredScopes: { + 'eip155:10': { + accounts: [], + }, + }, + isMultichainOrigin: false, + optionalScopes: {}, + sessionProperties: {}, + }; + + const diff = diffScopesForCaip25CaveatValue( + leftValue, + mergedValue, + 'requiredScopes', + ); + + expect(diff).toStrictEqual(expectedDiff); + }); + }); + + describe('incremental request a whole new required scope with accounts', () => { + it('should return scope with new requested chain and new account', () => { + const leftValue: Caip25CaveatValue = { + requiredScopes: { + 'eip155:1': { + accounts: ['eip155:1:0xdead'], + }, + }, + optionalScopes: {}, + sessionProperties: {}, + isMultichainOrigin: false, + }; + + const mergedValue: Caip25CaveatValue = { + requiredScopes: { + 'eip155:1': { + accounts: ['eip155:1:0xdead'], + }, + 'eip155:10': { + accounts: ['eip155:10:0xbeef'], + }, + }, + optionalScopes: {}, + sessionProperties: {}, + isMultichainOrigin: false, + }; + + const expectedDiff: Caip25CaveatValue = { + requiredScopes: { + 'eip155:10': { + accounts: ['eip155:10:0xbeef'], + }, + }, + isMultichainOrigin: false, + optionalScopes: {}, + sessionProperties: {}, + }; + + const diff = diffScopesForCaip25CaveatValue( + leftValue, + mergedValue, + 'requiredScopes', + ); + + expect(diff).toStrictEqual(expectedDiff); + }); + }); + + describe('incremental request an existing required scope with new accounts, and whole new required scope with accounts', () => { + it('should return scope with previously existing chain and accounts, plus new requested chain with new accounts', () => { + const leftValue: Caip25CaveatValue = { + requiredScopes: { + 'eip155:1': { + accounts: ['eip155:1:0xdead'], + }, + }, + optionalScopes: {}, + sessionProperties: {}, + isMultichainOrigin: false, + }; + + const mergedValue: Caip25CaveatValue = { + requiredScopes: { + 'eip155:1': { + accounts: ['eip155:1:0xdead', 'eip155:1:0xbeef'], + }, + 'eip155:10': { + accounts: ['eip155:10:0xdead', 'eip155:10:0xbeef'], + }, + }, + optionalScopes: {}, + sessionProperties: {}, + isMultichainOrigin: false, + }; + + const expectedDiff: Caip25CaveatValue = { + requiredScopes: { + 'eip155:1': { + accounts: ['eip155:1:0xbeef'], + }, + 'eip155:10': { + accounts: ['eip155:10:0xdead', 'eip155:10:0xbeef'], + }, + }, + isMultichainOrigin: false, + optionalScopes: {}, + sessionProperties: {}, + }; + + const diff = diffScopesForCaip25CaveatValue( + leftValue, + mergedValue, + 'requiredScopes', + ); + + expect(diff).toStrictEqual(expectedDiff); + }); + }); +}); + +describe('generateCaip25Caveat', () => { + it('should generate a CAIP-25 caveat', () => { + const caveat = generateCaip25Caveat( + { + requiredScopes: { 'eip155:1': { accounts: ['eip155:1:0xdead'] } }, + optionalScopes: {}, + sessionProperties: {}, + isMultichainOrigin: false, + }, + ['eip155:1:0xdead'], + ['eip155:1'], + ); + + expect(caveat).toStrictEqual({ + [Caip25EndowmentPermissionName]: { + caveats: [ + { + type: Caip25CaveatType, + value: { + requiredScopes: { 'eip155:1': { accounts: ['eip155:1:0xdead'] } }, + optionalScopes: {}, + sessionProperties: {}, + isMultichainOrigin: false, + }, + }, + ], + }, + }); + }); + + it('should handle multiple accounts across different chains', () => { + const caveat = generateCaip25Caveat( + { + requiredScopes: { + 'eip155:1': { accounts: ['eip155:1:0xdead'] }, + 'eip155:5': { accounts: ['eip155:5:0xbeef'] }, + }, + optionalScopes: { + 'eip155:10': { accounts: ['eip155:10:0xabc'] }, + }, + sessionProperties: {}, + isMultichainOrigin: false, + }, + ['eip155:1:0x123', 'eip155:5:0x456', 'eip155:10:0x789'], + ['eip155:1', 'eip155:5', 'eip155:10'], + ); + + expect(caveat).toStrictEqual({ + [Caip25EndowmentPermissionName]: { + caveats: [ + { + type: Caip25CaveatType, + value: { + requiredScopes: { + 'eip155:1': { + accounts: [ + 'eip155:1:0x123', + 'eip155:1:0x456', + 'eip155:1:0x789', + ], + }, + 'eip155:5': { + accounts: [ + 'eip155:5:0x123', + 'eip155:5:0x456', + 'eip155:5:0x789', + ], + }, + }, + optionalScopes: { + 'eip155:10': { + accounts: [ + 'eip155:10:0x123', + 'eip155:10:0x456', + 'eip155:10:0x789', + ], + }, + }, + sessionProperties: {}, + isMultichainOrigin: false, + }, + }, + ], + }, + }); + }); + + it('should handle empty accounts list', () => { + const caveat = generateCaip25Caveat( + { + requiredScopes: { 'eip155:1': { accounts: ['eip155:1:0xdead'] } }, + optionalScopes: { 'eip155:5': { accounts: ['eip155:5:0xbeef'] } }, + sessionProperties: {}, + isMultichainOrigin: false, + }, + [], + ['eip155:1', 'eip155:5'], + ); + + expect(caveat).toStrictEqual({ + [Caip25EndowmentPermissionName]: { + caveats: [ + { + type: Caip25CaveatType, + value: { + requiredScopes: { 'eip155:1': { accounts: [] } }, + optionalScopes: { 'eip155:5': { accounts: [] } }, + sessionProperties: {}, + isMultichainOrigin: false, + }, + }, + ], + }, + }); + }); + + it('should handle wallet scopes correctly', () => { + const caveat = generateCaip25Caveat( + { + requiredScopes: {}, + optionalScopes: { + 'wallet:eip155': { accounts: ['wallet:eip155:0xdead'] }, + wallet: { accounts: [] }, + }, + sessionProperties: {}, + isMultichainOrigin: true, + }, + ['wallet:eip155:0x123'], + ['eip155:1', 'eip155:5'], + ); + + expect(caveat).toStrictEqual({ + [Caip25EndowmentPermissionName]: { + caveats: [ + { + type: Caip25CaveatType, + value: { + requiredScopes: {}, + optionalScopes: { + 'wallet:eip155': { accounts: ['wallet:eip155:0x123'] }, + wallet: { accounts: [] }, + 'eip155:1': { accounts: [] }, + 'eip155:5': { accounts: [] }, + }, + sessionProperties: {}, + isMultichainOrigin: true, + }, + }, + ], + }, + }); + }); + + it('should preserve session properties', () => { + const sessionProperties = { + [KnownSessionProperties.SolanaAccountChangedNotifications]: true, + }; + + const caveat = generateCaip25Caveat( + { + requiredScopes: { 'eip155:1': { accounts: ['eip155:1:0xdead'] } }, + optionalScopes: {}, + sessionProperties, + isMultichainOrigin: true, + }, + ['eip155:1:0x123'], + ['eip155:1'], + ); + + expect(caveat).toStrictEqual({ + [Caip25EndowmentPermissionName]: { + caveats: [ + { + type: Caip25CaveatType, + value: { + requiredScopes: { 'eip155:1': { accounts: ['eip155:1:0x123'] } }, + optionalScopes: {}, + sessionProperties, + isMultichainOrigin: true, + }, + }, + ], + }, + }); + }); + + it('should handle non-EVM chains correctly', () => { + const caveat = generateCaip25Caveat( + { + requiredScopes: { + 'eip155:1': { accounts: ['eip155:1:0xdead'] }, + 'solana:4sGjMW1sUnHzSxGspuhpqLDx6wiyjNtZ': { + accounts: ['solana:4sGjMW1sUnHzSxGspuhpqLDx6wiyjNtZ:oldPubkey'], + }, + }, + optionalScopes: {}, + sessionProperties: {}, + isMultichainOrigin: true, + }, + ['eip155:1:0x123', 'solana:4sGjMW1sUnHzSxGspuhpqLDx6wiyjNtZ:newPubkey'], + ['eip155:1', 'solana:4sGjMW1sUnHzSxGspuhpqLDx6wiyjNtZ'], + ); + + expect(caveat).toStrictEqual({ + [Caip25EndowmentPermissionName]: { + caveats: [ + { + type: Caip25CaveatType, + value: { + requiredScopes: { + 'eip155:1': { accounts: ['eip155:1:0x123'] }, + 'solana:4sGjMW1sUnHzSxGspuhpqLDx6wiyjNtZ': { + accounts: [ + 'solana:4sGjMW1sUnHzSxGspuhpqLDx6wiyjNtZ:newPubkey', + ], + }, + }, + optionalScopes: {}, + sessionProperties: {}, + isMultichainOrigin: true, + }, + }, + ], + }, + }); + }); + + it('should add new chains to optionalScopes when they are not in requiredScopes', () => { + const caveat = generateCaip25Caveat( + { + requiredScopes: { 'eip155:1': { accounts: ['eip155:1:0xdead'] } }, + optionalScopes: {}, + sessionProperties: {}, + isMultichainOrigin: false, + }, + ['eip155:1:0x123', 'eip155:5:0x456'], + ['eip155:1', 'eip155:5', 'eip155:10'], + ); + + expect(caveat).toStrictEqual({ + [Caip25EndowmentPermissionName]: { + caveats: [ + { + type: Caip25CaveatType, + value: { + requiredScopes: { + 'eip155:1': { accounts: ['eip155:1:0x123', 'eip155:1:0x456'] }, + }, + optionalScopes: { + 'eip155:5': { accounts: ['eip155:5:0x123', 'eip155:5:0x456'] }, + 'eip155:10': { + accounts: ['eip155:10:0x123', 'eip155:10:0x456'], + }, + }, + sessionProperties: {}, + isMultichainOrigin: false, + }, + }, + ], + }, + }); + }); + + describe('getCaip25CaveatFromPermission', () => { + it('returns the caip 25 caveat when the caveat exists', () => { + const caveat = { + type: Caip25CaveatType, + value: { + requiredScopes: {}, + optionalScopes: { + 'eip155:1': { + accounts: [], + }, + }, + sessionProperties: {}, + isMultichainOrigin: false, + }, + }; + const result = getCaip25CaveatFromPermission({ + caveats: [ + { + type: 'other', + value: 'foo', + }, + caveat, + ], + }); + + expect(result).toStrictEqual(caveat); + }); + + it('returns undefined when the caveat does not exist', () => { + const result = getCaip25CaveatFromPermission({ + caveats: [ + { + type: 'other', + value: 'foo', + }, + ], + }); + + expect(result).toBeUndefined(); + }); + + it('returns undefined when the permission is undefined', () => { + const result = getCaip25CaveatFromPermission(); + + expect(result).toBeUndefined(); + }); + }); +}); + +describe('requestPermittedChainsPermissionIncremental', () => { + it('requests permittedChains approval if autoApprove: false', async () => { + const subjectPermissions: Partial< + SubjectPermissions< + ExtractPermission< + PermissionSpecificationConstraint, + CaveatSpecificationConstraint + > + > + > = { + [Caip25EndowmentPermissionName]: { + id: 'id', + date: 1, + invoker: 'origin', + parentCapability: PermissionKeys.permittedChains, + caveats: [ + { + type: Caip25CaveatType, + value: { + requiredScopes: {}, + optionalScopes: { 'eip155:1': { accounts: [] } }, + isMultichainOrigin: false, + sessionProperties: {}, + }, + }, + ], + }, + }; + + const expectedCaip25Permission = { + [Caip25EndowmentPermissionName]: pick( + subjectPermissions[Caip25EndowmentPermissionName], + 'caveats', + ), + }; + + mockRequestPermissionsIncremental.mockResolvedValue([ + subjectPermissions, + { id: 'id', origin: 'origin' }, + ]); + + await requestPermittedChainsPermissionIncremental({ + origin: 'test.com', + chainId: '0x1', + autoApprove: false, + hooks: { + requestPermissionsIncremental: mockRequestPermissionsIncremental, + grantPermissionsIncremental: mockGrantPermissionsIncremental, + }, + }); + + expect(mockRequestPermissionsIncremental).toHaveBeenCalledWith( + { origin: 'test.com' }, + expectedCaip25Permission, + undefined, // undefined metadata + ); + }); + + it('throws if permittedChains approval is rejected', async () => { + mockRequestPermissionsIncremental.mockRejectedValue( + new Error('approval rejected'), + ); + + await expect(() => + requestPermittedChainsPermissionIncremental({ + origin: 'test.com', + chainId: '0x1', + autoApprove: false, + hooks: { + requestPermissionsIncremental: mockRequestPermissionsIncremental, + grantPermissionsIncremental: mockGrantPermissionsIncremental, + }, + }), + ).rejects.toThrow(new Error('approval rejected')); + }); + + it('grants permittedChains approval if autoApprove: true', async () => { + const subjectPermissions: Partial< + SubjectPermissions< + ExtractPermission< + PermissionSpecificationConstraint, + CaveatSpecificationConstraint + > + > + > = { + [Caip25EndowmentPermissionName]: { + id: 'id', + date: 1, + invoker: 'origin', + parentCapability: PermissionKeys.permittedChains, + caveats: [ + { + type: Caip25CaveatType, + value: { + requiredScopes: {}, + optionalScopes: { 'eip155:1': { accounts: [] } }, + isMultichainOrigin: false, + sessionProperties: {}, + }, + }, + ], + }, + }; + + const expectedCaip25Permission = { + [Caip25EndowmentPermissionName]: pick( + subjectPermissions[Caip25EndowmentPermissionName], + 'caveats', + ), + }; + + mockGrantPermissionsIncremental.mockReturnValue(subjectPermissions); + + await requestPermittedChainsPermissionIncremental({ + origin: 'test.com', + chainId: '0x1', + autoApprove: true, + hooks: { + requestPermissionsIncremental: mockRequestPermissionsIncremental, + grantPermissionsIncremental: mockGrantPermissionsIncremental, + }, + }); + + expect(mockGrantPermissionsIncremental).toHaveBeenCalledWith({ + subject: { origin: 'test.com' }, + approvedPermissions: expectedCaip25Permission, + }); + }); + + it('throws if autoApprove: true and granting permittedChains throws', async () => { + mockGrantPermissionsIncremental.mockImplementation(() => { + throw new Error('Invalid merged permissions for subject "test.com"'); + }); + + await expect(() => + requestPermittedChainsPermissionIncremental({ + origin: 'test.com', + chainId: '0x1', + autoApprove: true, + hooks: { + requestPermissionsIncremental: mockRequestPermissionsIncremental, + grantPermissionsIncremental: mockGrantPermissionsIncremental, + }, + }), + ).rejects.toThrow( + new Error('Invalid merged permissions for subject "test.com"'), + ); + }); + + it('passes metadata to requestPermissionsIncremental when metadata is provided', async () => { + const subjectPermissions: Partial< + SubjectPermissions< + ExtractPermission< + PermissionSpecificationConstraint, + CaveatSpecificationConstraint + > + > + > = { + [Caip25EndowmentPermissionName]: { + id: 'id', + date: 1, + invoker: 'origin', + parentCapability: PermissionKeys.permittedChains, + caveats: [ + { + type: Caip25CaveatType, + value: { + requiredScopes: {}, + optionalScopes: { 'eip155:1': { accounts: [] } }, + isMultichainOrigin: false, + sessionProperties: {}, + }, + }, + ], + }, + }; + + const expectedCaip25Permission = { + [Caip25EndowmentPermissionName]: pick( + subjectPermissions[Caip25EndowmentPermissionName], + 'caveats', + ), + }; + + const metadata = { options: { someOption: 'testValue' } }; + + mockRequestPermissionsIncremental.mockResolvedValue([ + subjectPermissions, + { id: 'id', origin: 'origin' }, + ]); + + await requestPermittedChainsPermissionIncremental({ + origin: 'test.com', + chainId: '0x1', + autoApprove: false, + metadata, + hooks: { + requestPermissionsIncremental: mockRequestPermissionsIncremental, + grantPermissionsIncremental: mockGrantPermissionsIncremental, + }, + }); + + expect(mockRequestPermissionsIncremental).toHaveBeenCalledWith( + { origin: 'test.com' }, + expectedCaip25Permission, + { metadata }, + ); + }); +}); + +describe('getCaip25PermissionFromLegacyPermissions', () => { + it('returns valid CAIP-25 permissions', async () => { + const permissions = getCaip25PermissionFromLegacyPermissions({}); + + expect(permissions).toStrictEqual( + expect.objectContaining({ + [Caip25EndowmentPermissionName]: { + caveats: [ + { + type: Caip25CaveatType, + value: { + requiredScopes: {}, + optionalScopes: { + 'wallet:eip155': { + accounts: [], + }, + }, + isMultichainOrigin: false, + sessionProperties: { + 'eip1193-compatible': true, + }, + }, + }, + ], + }, + }), + ); + }); + + it('returns approval from the PermissionsController for eth_accounts and permittedChains when only eth_accounts is specified in params', async () => { + const permissions = getCaip25PermissionFromLegacyPermissions({ + [PermissionKeys.eth_accounts]: { + caveats: [ + { + type: CaveatTypes.restrictReturnedAccounts, + value: ['0x0000000000000000000000000000000000000001'], + }, + ], + }, + }); + + expect(permissions).toStrictEqual( + expect.objectContaining({ + [Caip25EndowmentPermissionName]: { + caveats: [ + { + type: Caip25CaveatType, + value: { + requiredScopes: {}, + optionalScopes: { + 'wallet:eip155': { + accounts: [ + 'wallet:eip155:0x0000000000000000000000000000000000000001', + ], + }, + }, + isMultichainOrigin: false, + sessionProperties: { + 'eip1193-compatible': true, + }, + }, + }, + ], + }, + }), + ); + }); + + it('returns approval from the PermissionsController for eth_accounts and permittedChains when only permittedChains is specified in params', async () => { + const permissions = getCaip25PermissionFromLegacyPermissions({ + [PermissionKeys.permittedChains]: { + caveats: [ + { + type: CaveatTypes.restrictNetworkSwitching, + value: ['0x64'], + }, + ], + }, + }); + + expect(permissions).toStrictEqual( + expect.objectContaining({ + [Caip25EndowmentPermissionName]: { + caveats: [ + { + type: Caip25CaveatType, + value: { + requiredScopes: {}, + optionalScopes: { + 'wallet:eip155': { + accounts: [], + }, + 'eip155:100': { + accounts: [], + }, + }, + isMultichainOrigin: false, + sessionProperties: { + 'eip1193-compatible': true, + }, + }, + }, + ], + }, + }), + ); + }); + + it('returns approval from the PermissionsController for eth_accounts and permittedChains when both are specified in params', async () => { + const permissions = getCaip25PermissionFromLegacyPermissions({ + [PermissionKeys.eth_accounts]: { + caveats: [ + { + type: CaveatTypes.restrictReturnedAccounts, + value: ['0x0000000000000000000000000000000000000001'], + }, + ], + }, + [PermissionKeys.permittedChains]: { + caveats: [ + { + type: CaveatTypes.restrictNetworkSwitching, + value: ['0x64'], + }, + ], + }, + }); + + expect(permissions).toStrictEqual( + expect.objectContaining({ + [Caip25EndowmentPermissionName]: { + caveats: [ + { + type: Caip25CaveatType, + value: { + requiredScopes: {}, + optionalScopes: { + 'wallet:eip155': { + accounts: [ + 'wallet:eip155:0x0000000000000000000000000000000000000001', + ], + }, + 'eip155:100': { + accounts: [ + 'eip155:100:0x0000000000000000000000000000000000000001', + ], + }, + }, + isMultichainOrigin: false, + sessionProperties: { + 'eip1193-compatible': true, + }, + }, + }, + ], + }, + }), + ); + }); + + it('returns approval from the PermissionsController for only eth_accounts when only eth_accounts is specified in params', async () => { + const permissions = getCaip25PermissionFromLegacyPermissions({ + [PermissionKeys.eth_accounts]: { + caveats: [ + { + type: CaveatTypes.restrictReturnedAccounts, + value: ['0x0000000000000000000000000000000000000001'], + }, + ], + }, + }); + + expect(permissions).toStrictEqual( + expect.objectContaining({ + [Caip25EndowmentPermissionName]: { + caveats: [ + { + type: Caip25CaveatType, + value: { + requiredScopes: {}, + optionalScopes: { + 'wallet:eip155': { + accounts: [ + 'wallet:eip155:0x0000000000000000000000000000000000000001', + ], + }, + }, + isMultichainOrigin: false, + sessionProperties: { + 'eip1193-compatible': true, + }, + }, + }, + ], + }, + }), + ); + }); + + it('returns approval from the PermissionsController for only eth_accounts when only permittedChains is specified in params', async () => { + const permissions = getCaip25PermissionFromLegacyPermissions({ + [PermissionKeys.permittedChains]: { + caveats: [ + { + type: CaveatTypes.restrictNetworkSwitching, + value: ['0x64'], + }, + ], + }, + }); + + expect(permissions).toStrictEqual( + expect.objectContaining({ + [Caip25EndowmentPermissionName]: { + caveats: [ + { + type: Caip25CaveatType, + value: { + requiredScopes: {}, + optionalScopes: { + 'eip155:100': { + accounts: [], + }, + 'wallet:eip155': { + accounts: [], + }, + }, + isMultichainOrigin: false, + sessionProperties: { + 'eip1193-compatible': true, + }, + }, + }, + ], + }, + }), + ); + }); + + it('returns approval from the PermissionsController for eth_accounts and permittedChains when both eth_accounts and permittedChains are specified in params', async () => { + const permissions = getCaip25PermissionFromLegacyPermissions({ + [PermissionKeys.eth_accounts]: { + caveats: [ + { + type: CaveatTypes.restrictReturnedAccounts, + value: ['0x0000000000000000000000000000000000000001'], + }, + ], + }, + [PermissionKeys.permittedChains]: { + caveats: [ + { + type: CaveatTypes.restrictNetworkSwitching, + value: ['0x64'], + }, + ], + }, + }); + + expect(permissions).toStrictEqual( + expect.objectContaining({ + [Caip25EndowmentPermissionName]: { + caveats: [ + { + type: Caip25CaveatType, + value: { + requiredScopes: {}, + optionalScopes: { + 'eip155:100': { + accounts: [ + 'eip155:100:0x0000000000000000000000000000000000000001', + ], + }, + 'wallet:eip155': { + accounts: [ + 'wallet:eip155:0x0000000000000000000000000000000000000001', + ], + }, + }, + isMultichainOrigin: false, + sessionProperties: { + 'eip1193-compatible': true, + }, + }, + }, + ], + }, + }), + ); + }); + + it('returns CAIP-25 approval with accounts and chainIds specified from `eth_accounts` and `endowment:permittedChains` permissions caveats', async () => { + const permissions = getCaip25PermissionFromLegacyPermissions({ + [PermissionKeys.eth_accounts]: { + caveats: [ + { + type: 'restrictReturnedAccounts', + value: ['0xdeadbeef'], + }, + ], + }, + [PermissionKeys.permittedChains]: { + caveats: [ + { + type: 'restrictNetworkSwitching', + value: ['0x1', '0x5'], + }, + ], + }, + }); + + expect(permissions).toStrictEqual( + expect.objectContaining({ + [Caip25EndowmentPermissionName]: { + caveats: [ + { + type: Caip25CaveatType, + value: { + requiredScopes: {}, + optionalScopes: { + 'wallet:eip155': { + accounts: ['wallet:eip155:0xdeadbeef'], + }, + 'eip155:1': { + accounts: ['eip155:1:0xdeadbeef'], + }, + 'eip155:5': { + accounts: ['eip155:5:0xdeadbeef'], + }, + }, + isMultichainOrigin: false, + sessionProperties: { + 'eip1193-compatible': true, + }, + }, + }, + ], + }, + }), + ); + }); + + it('returns CAIP-25 approval with approved accounts for the `wallet:eip155` scope', async () => { + const permissions = getCaip25PermissionFromLegacyPermissions({ + [PermissionKeys.eth_accounts]: { + caveats: [ + { + type: 'restrictReturnedAccounts', + value: ['0xdeadbeef'], + }, + ], + }, + [PermissionKeys.permittedChains]: { + caveats: [ + { + type: 'restrictNetworkSwitching', + value: ['0x1', '0x5'], + }, + ], + }, + }); + + expect(permissions).toStrictEqual( + expect.objectContaining({ + [Caip25EndowmentPermissionName]: { + caveats: [ + { + type: Caip25CaveatType, + value: { + requiredScopes: {}, + optionalScopes: { + 'eip155:1': { + accounts: ['eip155:1:0xdeadbeef'], + }, + 'eip155:5': { + accounts: ['eip155:5:0xdeadbeef'], + }, + 'wallet:eip155': { + accounts: ['wallet:eip155:0xdeadbeef'], + }, + }, + isMultichainOrigin: false, + sessionProperties: { + 'eip1193-compatible': true, + }, + }, + }, + ], + }, + }), + ); + }); +}); diff --git a/packages/chain-agnostic-permission/src/caip25Permission.ts b/packages/chain-agnostic-permission/src/caip25Permission.ts new file mode 100644 index 00000000000..1b3959e7f2b --- /dev/null +++ b/packages/chain-agnostic-permission/src/caip25Permission.ts @@ -0,0 +1,765 @@ +import type { + PermissionSpecificationBuilder, + EndowmentGetterParams, + ValidPermissionSpecification, + PermissionValidatorConstraint, + PermissionConstraint, + EndowmentCaveatSpecificationConstraint, +} from '@metamask/permission-controller'; +import { + CaveatMutatorOperation, + PermissionType, +} from '@metamask/permission-controller'; +import { + hasProperty, + KnownCaipNamespace, + parseCaipAccountId, + isObject, +} from '@metamask/utils'; +import type { + CaipAccountId, + CaipChainId, + Json, + Hex, + NonEmptyArray, +} from '@metamask/utils'; +import { cloneDeep, isEqual, pick } from 'lodash'; + +import { CaveatTypes, PermissionKeys } from './constants.js'; +import { + setEthAccounts, + setNonSCACaipAccountIdsInCaip25CaveatValue, +} from './operators/caip-permission-operator-accounts.js'; +import { + setChainIdsInCaip25CaveatValue, + setPermittedEthChainIds, +} from './operators/caip-permission-operator-permittedChains.js'; +import { assertIsInternalScopesObject } from './scope/assert.js'; +import { KnownSessionProperties } from './scope/constants.js'; +import { + isSupportedAccount, + isSupportedScopeString, + isSupportedSessionProperty, +} from './scope/supported.js'; +import { mergeInternalScopes } from './scope/transform.js'; +import { parseScopeString } from './scope/types.js'; +import type { + ExternalScopeString, + InternalScopeObject, + InternalScopesObject, +} from './scope/types.js'; + +/** + * The CAIP-25 permission caveat value. + * This permission contains the required and optional scopes and session properties from the [CAIP-25](https://github.com/ChainAgnostic/CAIPs/blob/main/CAIPs/caip-25.md) request that initiated the permission session. + * It also contains a boolean (isMultichainOrigin) indicating if the permission session is multichain, which may be needed to determine implicit permissioning. + */ +export type Caip25CaveatValue = { + requiredScopes: InternalScopesObject; + optionalScopes: InternalScopesObject; + sessionProperties: Record; + isMultichainOrigin: boolean; +}; + +/** + * The name of the CAIP-25 permission caveat. + */ +export const Caip25CaveatType = 'authorizedScopes'; + +/** + * The target name of the CAIP-25 endowment permission. + */ +export const Caip25EndowmentPermissionName = 'endowment:caip25'; + +/** + * Creates a CAIP-25 permission caveat. + * + * @param value - The CAIP-25 permission caveat value. + * @returns The CAIP-25 permission caveat (now including the type). + */ +export const createCaip25Caveat = (value: Caip25CaveatValue) => { + return { + type: Caip25CaveatType, + value, + }; +}; + +type Caip25EndowmentCaveatSpecificationBuilderOptions = { + findNetworkClientIdByChainId: (chainId: Hex) => string; + listAccounts: () => { type: string; address: Hex }[]; + isNonEvmScopeSupported: (scope: CaipChainId) => boolean; + getNonEvmAccountAddresses: (scope: CaipChainId) => string[]; +}; + +/** + * Calculates the difference between two provided CAIP-25 permission caveat values, but only considering a single scope property at a time. + * + * @param originalValue - The existing CAIP-25 permission caveat value. + * @param mergedValue - The result from merging existing and incoming CAIP-25 permission caveat values. + * @param scopeToDiff - The required or optional scopes from the [CAIP-25](https://github.com/ChainAgnostic/CAIPs/blob/main/CAIPs/caip-25.md) request. + * @returns The difference between original and merged CAIP-25 permission caveat values. + */ +export function diffScopesForCaip25CaveatValue( + originalValue: Caip25CaveatValue, + mergedValue: Caip25CaveatValue, + scopeToDiff: 'optionalScopes' | 'requiredScopes', +): Caip25CaveatValue { + const diff = cloneDeep(originalValue); + + const mergedScopeToDiff = mergedValue[scopeToDiff]; + for (const [scopeString, mergedScopeObject] of Object.entries( + mergedScopeToDiff, + )) { + const internalScopeString = scopeString as keyof typeof mergedScopeToDiff; + const originalScopeObject = diff[scopeToDiff][internalScopeString]; + + if (originalScopeObject) { + const newAccounts = mergedScopeObject.accounts.filter( + (account) => !originalScopeObject?.accounts.includes(account), + ); + if (newAccounts.length > 0) { + diff[scopeToDiff][internalScopeString] = { + accounts: newAccounts, + }; + continue; + } + delete diff[scopeToDiff][internalScopeString]; + } else { + diff[scopeToDiff][internalScopeString] = mergedScopeObject; + } + } + + return diff; +} + +/** + * Checks if every account in the given scopes object is supported. + * + * @param scopesObject - The scopes object to iterate over. + * @param listAccounts - The hook for getting internalAccount objects for all evm accounts. + * @param getNonEvmAccountAddresses - The hook that returns the supported CAIP-10 account addresses for a non EVM scope. + * addresses. + * @returns True if every account in the scopes object is supported, false otherwise. + */ +function isEveryAccountInScopesObjectSupported( + scopesObject: InternalScopesObject, + listAccounts: () => { type: string; address: Hex }[], + getNonEvmAccountAddresses: (scope: CaipChainId) => string[], +) { + return Object.values(scopesObject).every((scopeObject) => + scopeObject.accounts.every((account) => + isSupportedAccount(account, { + getEvmInternalAccounts: listAccounts, + getNonEvmAccountAddresses, + }), + ), + ); +} + +/** + * Helper that returns a `authorizedScopes` CAIP-25 caveat specification + * that can be passed into the PermissionController constructor. + * + * @param options - The specification builder options. + * @param options.findNetworkClientIdByChainId - The hook for getting the networkClientId that serves a chainId. + * @param options.listAccounts - The hook for getting internalAccount objects for all evm accounts. + * @param options.isNonEvmScopeSupported - The hook that determines if an non EVM scopeString is supported. + * @param options.getNonEvmAccountAddresses - The hook that returns the supported CAIP-10 account addresses for a non EVM scope. + * @returns The specification for the `caip25` caveat. + */ +export const caip25CaveatBuilder = ({ + findNetworkClientIdByChainId, + listAccounts, + isNonEvmScopeSupported, + getNonEvmAccountAddresses, +}: Caip25EndowmentCaveatSpecificationBuilderOptions): EndowmentCaveatSpecificationConstraint & + Required< + Pick + > => { + return { + type: Caip25CaveatType, + validator: ( + caveat: { type: typeof Caip25CaveatType; value: unknown }, + _origin?: string, + _target?: string, + ) => { + if ( + !caveat.value || + !hasProperty(caveat.value, 'requiredScopes') || + !hasProperty(caveat.value, 'optionalScopes') || + !hasProperty(caveat.value, 'isMultichainOrigin') || + !hasProperty(caveat.value, 'sessionProperties') || + typeof caveat.value.isMultichainOrigin !== 'boolean' || + !isObject(caveat.value.sessionProperties) + ) { + throw new Error( + `${Caip25EndowmentPermissionName} error: Received invalid value for caveat of type "${Caip25CaveatType}".`, + ); + } + + const { requiredScopes, optionalScopes, sessionProperties } = + caveat.value; + + const allSessionPropertiesSupported = Object.keys( + sessionProperties, + ).every((sessionProperty) => isSupportedSessionProperty(sessionProperty)); + + if (!allSessionPropertiesSupported) { + throw new Error( + `${Caip25EndowmentPermissionName} error: Received unknown session property(s) for caveat of type "${Caip25CaveatType}".`, + ); + } + + assertIsInternalScopesObject(requiredScopes); + assertIsInternalScopesObject(optionalScopes); + + if ( + Object.keys(requiredScopes).length === 0 && + Object.keys(optionalScopes).length === 0 + ) { + throw new Error( + `${Caip25EndowmentPermissionName} error: Received no scopes for caveat of type "${Caip25CaveatType}".`, + ); + } + + const isEvmChainIdSupported = (chainId: Hex) => { + try { + findNetworkClientIdByChainId(chainId); + return true; + } catch { + return false; + } + }; + + const unsupportedScopes = Object.keys({ + ...requiredScopes, + ...optionalScopes, + }).filter( + (scopeString) => + !isSupportedScopeString(scopeString, { + isEvmChainIdSupported, + isNonEvmScopeSupported, + }), + ); + + if (unsupportedScopes.length > 0) { + throw new Error( + `${Caip25EndowmentPermissionName} error: Received scopeString value(s): ${unsupportedScopes.join(', ')} for caveat of type "${Caip25CaveatType}" that are not supported by the wallet.`, + ); + } + + const allRequiredAccountsSupported = + isEveryAccountInScopesObjectSupported( + requiredScopes, + listAccounts, + getNonEvmAccountAddresses, + ); + const allOptionalAccountsSupported = + isEveryAccountInScopesObjectSupported( + optionalScopes, + listAccounts, + getNonEvmAccountAddresses, + ); + if (!allRequiredAccountsSupported || !allOptionalAccountsSupported) { + throw new Error( + `${Caip25EndowmentPermissionName} error: Received account value(s) for caveat of type "${Caip25CaveatType}" that are not supported by the wallet.`, + ); + } + }, + merger: ( + leftValue: Caip25CaveatValue, + rightValue: Caip25CaveatValue, + ): [Caip25CaveatValue, Caip25CaveatValue] => { + const mergedRequiredScopes = mergeInternalScopes( + leftValue.requiredScopes, + rightValue.requiredScopes, + ); + const mergedOptionalScopes = mergeInternalScopes( + leftValue.optionalScopes, + rightValue.optionalScopes, + ); + + const mergedSessionProperties = { + ...leftValue.sessionProperties, + ...rightValue.sessionProperties, + }; + + const mergedValue: Caip25CaveatValue = { + requiredScopes: mergedRequiredScopes, + optionalScopes: mergedOptionalScopes, + sessionProperties: mergedSessionProperties, + isMultichainOrigin: leftValue.isMultichainOrigin, + }; + + const partialDiff = diffScopesForCaip25CaveatValue( + leftValue, + mergedValue, + 'requiredScopes', + ); + + const diff = diffScopesForCaip25CaveatValue( + partialDiff, + mergedValue, + 'optionalScopes', + ); + + return [mergedValue, diff]; + }, + }; +}; + +type Caip25EndowmentSpecification = ValidPermissionSpecification<{ + permissionType: PermissionType.Endowment; + targetName: typeof Caip25EndowmentPermissionName; + endowmentGetter: (_options?: EndowmentGetterParams) => null; + validator: PermissionValidatorConstraint; + allowedCaveats: Readonly> | null; +}>; + +/** + * Helper that returns a `endowment:caip25` specification that + * can be passed into the PermissionController constructor. + * + * @returns The specification for the `caip25` endowment. + */ +const specificationBuilder: PermissionSpecificationBuilder< + PermissionType.Endowment, + Record, + Caip25EndowmentSpecification +> = () => { + return { + permissionType: PermissionType.Endowment, + targetName: Caip25EndowmentPermissionName, + allowedCaveats: [Caip25CaveatType], + endowmentGetter: (_getterOptions?: EndowmentGetterParams) => null, + validator: (permission: PermissionConstraint) => { + if ( + permission.caveats?.length !== 1 || + permission.caveats?.[0]?.type !== Caip25CaveatType + ) { + throw new Error( + `${Caip25EndowmentPermissionName} error: Invalid caveats. There must be a single caveat of type "${Caip25CaveatType}".`, + ); + } + }, + }; +}; + +/** + * The `caip25` endowment specification builder. Passed to the + * `PermissionController` for constructing and validating the + * `endowment:caip25` permission. + */ +export const caip25EndowmentBuilder = Object.freeze({ + targetName: Caip25EndowmentPermissionName, + specificationBuilder, +} as const); + +/** + * Factories that construct caveat mutator functions that are passed to + * PermissionController.updatePermissionsByCaveat. + */ +export const Caip25CaveatMutators = { + [Caip25CaveatType]: { + removeScope, + removeAccount, + }, +}; + +/** + * Removes the account from the scope object. + * + * @param targetAddress - The address to remove from the scope object. + * @returns A function that removes the account from the scope object. + */ +function removeAccountFilterFn(targetAddress: string) { + return (account: CaipAccountId) => { + const parsed = parseCaipAccountId(account); + return parsed.address !== targetAddress; + }; +} + +/** + * Removes the account from the scope object. + * + * @param scopeObject - The scope object to remove the account from. + * @param targetAddress - The address to remove from the scope object. + */ +function removeAccountFromScopeObject( + scopeObject: InternalScopeObject, + targetAddress: string, +) { + if (scopeObject.accounts) { + scopeObject.accounts = scopeObject.accounts.filter( + removeAccountFilterFn(targetAddress), + ); + } +} + +/** + * Removes the target account from the scope object. + * + * @param caip25CaveatValue - The CAIP-25 permission caveat value from which to remove the account (across all chain scopes). + * @param targetAddress - The address to remove from the scope object. Not a CAIP-10 formatted address because it will be removed across each chain scope. + * @returns The updated scope object. + */ +function removeAccount( + caip25CaveatValue: Caip25CaveatValue, + targetAddress: Hex, +) { + const updatedCaveatValue = cloneDeep(caip25CaveatValue); + + [ + updatedCaveatValue.requiredScopes, + updatedCaveatValue.optionalScopes, + ].forEach((scopes) => { + Object.entries(scopes).forEach(([, scopeObject]) => { + removeAccountFromScopeObject(scopeObject, targetAddress); + }); + }); + + const noChange = isEqual(updatedCaveatValue, caip25CaveatValue); + + if (noChange) { + return { + operation: CaveatMutatorOperation.Noop, + }; + } + + const hasAccounts = [ + ...Object.values(updatedCaveatValue.requiredScopes), + ...Object.values(updatedCaveatValue.optionalScopes), + ].some(({ accounts }) => accounts.length > 0); + + if (hasAccounts) { + return { + operation: CaveatMutatorOperation.UpdateValue, + value: updatedCaveatValue, + }; + } + + return { + operation: CaveatMutatorOperation.RevokePermission, + }; +} + +/** + * Removes the target scope from the value arrays of the given + * `endowment:caip25` caveat. No-ops if the target scopeString is not in + * the existing scopes. + * + * @param caip25CaveatValue - The CAIP-25 permission caveat value to remove the scope from. + * @param targetScopeString - The scope that is being removed. + * @returns The updated CAIP-25 permission caveat value. + */ +function removeScope( + caip25CaveatValue: Caip25CaveatValue, + targetScopeString: ExternalScopeString, +) { + const newRequiredScopes = Object.entries( + caip25CaveatValue.requiredScopes, + ).filter(([scope]) => scope !== targetScopeString); + const newOptionalScopes = Object.entries( + caip25CaveatValue.optionalScopes, + ).filter(([scope]) => { + return scope !== targetScopeString; + }); + + const requiredScopesRemoved = + newRequiredScopes.length !== + Object.keys(caip25CaveatValue.requiredScopes).length; + const optionalScopesRemoved = + newOptionalScopes.length !== + Object.keys(caip25CaveatValue.optionalScopes).length; + + if (!requiredScopesRemoved && !optionalScopesRemoved) { + return { + operation: CaveatMutatorOperation.Noop, + }; + } + + const updatedCaveatValue = { + ...caip25CaveatValue, + requiredScopes: Object.fromEntries(newRequiredScopes), + optionalScopes: Object.fromEntries(newOptionalScopes), + }; + + const hasNonWalletScopes = [...newRequiredScopes, ...newOptionalScopes].some( + ([scopeString]) => { + const { namespace } = parseScopeString(scopeString); + return namespace !== KnownCaipNamespace.Wallet; + }, + ); + + if (hasNonWalletScopes) { + return { + operation: CaveatMutatorOperation.UpdateValue, + value: updatedCaveatValue, + }; + } + + return { + operation: CaveatMutatorOperation.RevokePermission, + }; +} + +/** + * Modifies the requested CAIP-25 permissions object after UI confirmation. + * + * @param caip25CaveatValue - The requested CAIP-25 caveat value to modify. + * @param accountAddresses - The list of permitted eth addresses. + * @param chainIds - The list of permitted eth chainIds. + * @returns The updated CAIP-25 caveat value with the permitted accounts and chainIds set. + */ +export const generateCaip25Caveat = ( + caip25CaveatValue: Caip25CaveatValue, + accountAddresses: CaipAccountId[], + chainIds: CaipChainId[], +): { + [Caip25EndowmentPermissionName]: { + caveats: [{ type: string; value: Caip25CaveatValue }]; + }; +} => { + const caveatValueWithChains = setChainIdsInCaip25CaveatValue( + caip25CaveatValue, + chainIds, + ); + + const caveatValueWithAccounts = setNonSCACaipAccountIdsInCaip25CaveatValue( + caveatValueWithChains, + accountAddresses, + ); + + return { + [Caip25EndowmentPermissionName]: { + caveats: [ + { + type: Caip25CaveatType, + value: caveatValueWithAccounts, + }, + ], + }, + }; +}; + +/** + * Helper to get the CAIP-25 caveat from a permission + * + * @param [caip25Permission] - The CAIP-25 permission object + * @param caip25Permission.caveats - The caveats of the CAIP-25 permission + * @returns The CAIP-25 caveat or undefined if not found + */ +export function getCaip25CaveatFromPermission(caip25Permission?: { + caveats: ( + | { + type: string; + value: unknown; + } + | { + type: typeof Caip25CaveatType; + value: Caip25CaveatValue; + } + )[]; +}) { + return caip25Permission?.caveats.find( + (caveat) => caveat.type === (Caip25CaveatType as string), + ) as + | { + type: typeof Caip25CaveatType; + value: Caip25CaveatValue; + } + | undefined; +} + +/** + * Requests user approval for the CAIP-25 permission + * and returns a granted permissions object. + * + * @param requestedPermissions - The legacy permissions to request approval for. + * @param requestedPermissions.caveats - The legacy caveats processed by the function. + * - `restrictReturnedAccounts`: Restricts which Ethereum accounts can be accessed + * - `restrictNetworkSwitching`: Restricts which blockchain networks can be used + * @returns The converted CAIP-25 permission object. + */ +export const getCaip25PermissionFromLegacyPermissions = + (requestedPermissions?: { + [PermissionKeys.eth_accounts]?: { + caveats?: { + type: keyof typeof CaveatTypes; + value: Hex[]; + }[]; + }; + [PermissionKeys.permittedChains]?: { + caveats?: { + type: keyof typeof CaveatTypes; + value: Hex[]; + }[]; + }; + }): { + [Caip25EndowmentPermissionName]: { + caveats: NonEmptyArray<{ + type: typeof Caip25CaveatType; + value: typeof caveatValueWithAccountsAndChains; + }>; + }; + } => { + const permissions = pick(requestedPermissions, [ + PermissionKeys.eth_accounts, + PermissionKeys.permittedChains, + ]); + + if (!permissions[PermissionKeys.eth_accounts]) { + permissions[PermissionKeys.eth_accounts] = {}; + } + + if (!permissions[PermissionKeys.permittedChains]) { + permissions[PermissionKeys.permittedChains] = {}; + } + + const requestedAccounts = + permissions[PermissionKeys.eth_accounts]?.caveats?.find( + (caveat) => caveat.type === CaveatTypes.restrictReturnedAccounts, + )?.value ?? []; + + const requestedChains = + permissions[PermissionKeys.permittedChains]?.caveats?.find( + (caveat) => caveat.type === CaveatTypes.restrictNetworkSwitching, + )?.value ?? []; + + const newCaveatValue = { + requiredScopes: {}, + optionalScopes: { + 'wallet:eip155': { + accounts: [], + }, + }, + sessionProperties: { + [KnownSessionProperties.Eip1193Compatible]: true, + }, + isMultichainOrigin: false, + }; + + const caveatValueWithChains = setPermittedEthChainIds( + newCaveatValue, + requestedChains, + ); + + const caveatValueWithAccountsAndChains = setEthAccounts( + caveatValueWithChains, + requestedAccounts, + ); + + return { + [Caip25EndowmentPermissionName]: { + caveats: [ + { + type: Caip25CaveatType, + value: caveatValueWithAccountsAndChains, + }, + ], + }, + }; + }; + +/** + * Requests incremental permittedChains permission for the specified origin. + * and updates the existing CAIP-25 permission. + * Allows for granting without prompting for user approval which + * would be used as part of flows like `wallet_addEthereumChain` + * requests where the addition of the network and the permitting + * of the chain are combined into one approval. + * + * @param options - The options object + * @param options.origin - The origin to request approval for. + * @param options.chainId - The chainId to add to the existing permittedChains. + * @param options.autoApprove - If the chain should be granted without prompting for user approval. + * @param options.metadata - Request data for the approval. + * @param options.metadata.options - Additional metadata about the permission request. + * @param options.hooks - Permission controller hooks for incremental operations. + * @param options.hooks.requestPermissionsIncremental - Initiates an incremental permission request that prompts for user approval. + * Incremental permission requests allow the caller to replace existing and/or add brand new permissions and caveats for the specified subject. + * @param options.hooks.grantPermissionsIncremental - Incrementally grants approved permissions to the specified subject without prompting for user approval. + * Every permission and caveat is stringently validated and an error is thrown if validation fails. + */ +export const requestPermittedChainsPermissionIncremental = async ({ + origin, + chainId, + autoApprove, + hooks, + metadata, +}: { + origin: string; + chainId: Hex; + autoApprove: boolean; + hooks: { + requestPermissionsIncremental: ( + subject: { origin: string }, + requestedPermissions: Record< + string, + { caveats: { type: string; value: unknown }[] } + >, + options?: { metadata?: Record }, + ) => Promise< + | [ + Partial>, + { data?: Record; id: string; origin: string }, + ] + | [] + >; + grantPermissionsIncremental: (params: { + subject: { origin: string }; + approvedPermissions: Record< + string, + { caveats: { type: string; value: unknown }[] } + >; + requestData?: Record; + }) => Partial>; + }; + metadata?: { options: Record }; +}) => { + const caveatValueWithChains = setPermittedEthChainIds( + { + requiredScopes: {}, + optionalScopes: {}, + sessionProperties: {}, + isMultichainOrigin: false, + }, + [chainId], + ); + + if (!autoApprove) { + let options; + if (metadata) { + options = { metadata }; + } + await hooks.requestPermissionsIncremental( + { origin }, + { + [Caip25EndowmentPermissionName]: { + caveats: [ + { + type: Caip25CaveatType, + value: caveatValueWithChains, + }, + ], + }, + }, + options, + ); + return; + } + + hooks.grantPermissionsIncremental({ + subject: { origin }, + approvedPermissions: { + [Caip25EndowmentPermissionName]: { + caveats: [ + { + type: Caip25CaveatType, + value: caveatValueWithChains, + }, + ], + }, + }, + }); +}; diff --git a/packages/chain-agnostic-permission/src/constants.ts b/packages/chain-agnostic-permission/src/constants.ts new file mode 100644 index 00000000000..382db59186f --- /dev/null +++ b/packages/chain-agnostic-permission/src/constants.ts @@ -0,0 +1,13 @@ +export const CaveatTypes = Object.freeze({ + restrictReturnedAccounts: 'restrictReturnedAccounts', + restrictNetworkSwitching: 'restrictNetworkSwitching', +}); + +/** + * The "keys" of permissions recognized by the PermissionController. + * Permission keys and names have distinct meanings in the permission system. + */ +export const PermissionKeys = Object.freeze({ + eth_accounts: 'eth_accounts', + permittedChains: 'endowment:permitted-chains', +}); diff --git a/packages/chain-agnostic-permission/src/index.test.ts b/packages/chain-agnostic-permission/src/index.test.ts new file mode 100644 index 00000000000..ed3f1140af1 --- /dev/null +++ b/packages/chain-agnostic-permission/src/index.test.ts @@ -0,0 +1,60 @@ +import * as allExports from './index.js'; + +describe('@metamask/chain-agnostic-permission', () => { + it('has expected JavaScript exports', () => { + expect(Object.keys(allExports)).toMatchInlineSnapshot(` + [ + "getEthAccounts", + "setEthAccounts", + "setNonSCACaipAccountIdsInCaip25CaveatValue", + "getCaipAccountIdsFromScopesObjects", + "getCaipAccountIdsFromCaip25CaveatValue", + "isInternalAccountInPermittedAccountIds", + "isCaipAccountIdInPermittedAccountIds", + "getPermittedEthChainIds", + "addPermittedEthChainId", + "setPermittedEthChainIds", + "setChainIdsInCaip25CaveatValue", + "addCaipChainIdInCaip25CaveatValue", + "getAllNamespacesFromCaip25CaveatValue", + "getAllScopesFromPermission", + "getAllScopesFromCaip25CaveatValue", + "getAllScopesFromScopesObjects", + "getInternalScopesObject", + "getSessionScopes", + "getSessionProperties", + "getPermittedAccountsForScopes", + "validateAndNormalizeScopes", + "bucketScopes", + "isNamespaceInScopesObject", + "assertIsInternalScopeString", + "KnownWalletRpcMethods", + "KnownRpcMethods", + "KnownWalletNamespaceRpcMethods", + "KnownNotifications", + "KnownWalletScopeString", + "isKnownSessionPropertyValue", + "getSupportedScopeObjects", + "parseScopeString", + "getUniqueArrayItems", + "normalizeScope", + "mergeScopeObject", + "mergeNormalizedScopes", + "mergeInternalScopes", + "normalizeAndMergeScopes", + "caip25CaveatBuilder", + "Caip25CaveatType", + "createCaip25Caveat", + "Caip25EndowmentPermissionName", + "caip25EndowmentBuilder", + "Caip25CaveatMutators", + "generateCaip25Caveat", + "getCaip25CaveatFromPermission", + "getCaip25PermissionFromLegacyPermissions", + "requestPermittedChainsPermissionIncremental", + "KnownSessionProperties", + "Caip25Errors", + ] + `); + }); +}); diff --git a/packages/chain-agnostic-permission/src/index.ts b/packages/chain-agnostic-permission/src/index.ts new file mode 100644 index 00000000000..bf4f3744db8 --- /dev/null +++ b/packages/chain-agnostic-permission/src/index.ts @@ -0,0 +1,79 @@ +export { + getEthAccounts, + setEthAccounts, + setNonSCACaipAccountIdsInCaip25CaveatValue, + getCaipAccountIdsFromScopesObjects, + getCaipAccountIdsFromCaip25CaveatValue, + isInternalAccountInPermittedAccountIds, + isCaipAccountIdInPermittedAccountIds, +} from './operators/caip-permission-operator-accounts.js'; +export { + getPermittedEthChainIds, + addPermittedEthChainId, + setPermittedEthChainIds, + setChainIdsInCaip25CaveatValue, + addCaipChainIdInCaip25CaveatValue, + getAllNamespacesFromCaip25CaveatValue, + getAllScopesFromPermission, + getAllScopesFromCaip25CaveatValue, + getAllScopesFromScopesObjects, +} from './operators/caip-permission-operator-permittedChains.js'; +export { + getInternalScopesObject, + getSessionScopes, + getSessionProperties, + getPermittedAccountsForScopes, +} from './operators/caip-permission-operator-session-scopes.js'; +export type { Caip25Authorization } from './scope/authorization.js'; +export { + validateAndNormalizeScopes, + bucketScopes, + isNamespaceInScopesObject, +} from './scope/authorization.js'; +export { assertIsInternalScopeString } from './scope/assert.js'; +export { + KnownWalletRpcMethods, + KnownRpcMethods, + KnownWalletNamespaceRpcMethods, + KnownNotifications, + KnownWalletScopeString, + isKnownSessionPropertyValue, +} from './scope/constants.js'; +export { getSupportedScopeObjects } from './scope/filter.js'; +export type { + ExternalScopeString, + ExternalScopeObject, + ExternalScopesObject, + InternalScopeString, + InternalScopeObject, + InternalScopesObject, + NormalizedScopeObject, + NormalizedScopesObject, + ScopedProperties, + NonWalletKnownCaipNamespace, +} from './scope/types.js'; +export { parseScopeString } from './scope/types.js'; +export { + getUniqueArrayItems, + normalizeScope, + mergeScopeObject, + mergeNormalizedScopes, + mergeInternalScopes, + normalizeAndMergeScopes, +} from './scope/transform.js'; + +export type { Caip25CaveatValue } from './caip25Permission.js'; +export { + caip25CaveatBuilder, + Caip25CaveatType, + createCaip25Caveat, + Caip25EndowmentPermissionName, + caip25EndowmentBuilder, + Caip25CaveatMutators, + generateCaip25Caveat, + getCaip25CaveatFromPermission, + getCaip25PermissionFromLegacyPermissions, + requestPermittedChainsPermissionIncremental, +} from './caip25Permission.js'; +export { KnownSessionProperties } from './scope/constants.js'; +export { Caip25Errors } from './scope/errors.js'; diff --git a/packages/chain-agnostic-permission/src/operators/caip-permission-operator-accounts.test.ts b/packages/chain-agnostic-permission/src/operators/caip-permission-operator-accounts.test.ts new file mode 100644 index 00000000000..216dc401e55 --- /dev/null +++ b/packages/chain-agnostic-permission/src/operators/caip-permission-operator-accounts.test.ts @@ -0,0 +1,758 @@ +import type { CaipAccountId } from '@metamask/utils'; + +import type { Caip25CaveatValue } from '../caip25Permission.js'; +import type { InternalScopesObject } from '../scope/types.js'; +import { + getEthAccounts, + setEthAccounts, + setNonSCACaipAccountIdsInCaip25CaveatValue, + getCaipAccountIdsFromScopesObjects, + getCaipAccountIdsFromCaip25CaveatValue, + isCaipAccountIdInPermittedAccountIds, + isInternalAccountInPermittedAccountIds, +} from './caip-permission-operator-accounts.js'; + +describe('CAIP-25 eth_accounts adapters', () => { + describe('getEthAccounts', () => { + it('returns an empty array if the required scopes are empty', () => { + const ethAccounts = getEthAccounts({ + requiredScopes: {}, + optionalScopes: {}, + }); + expect(ethAccounts).toStrictEqual([]); + }); + it('returns an empty array if the scope objects have no accounts', () => { + const ethAccounts = getEthAccounts({ + requiredScopes: { + 'eip155:1': { accounts: [] }, + 'eip155:2': { accounts: [] }, + }, + optionalScopes: {}, + }); + expect(ethAccounts).toStrictEqual([]); + }); + it('returns an empty array if the scope objects have no eth accounts', () => { + const ethAccounts = getEthAccounts({ + requiredScopes: { + 'bip122:000000000019d6689c085ae165831e93': { + accounts: [ + 'bip122:000000000019d6689c085ae165831e93:128Lkh3S7CkDTBZ8W7BbpsN3YYizJMp8p6', + ], + }, + }, + optionalScopes: {}, + }); + expect(ethAccounts).toStrictEqual([]); + }); + + it('returns the unique set of EIP155 accounts from the CAIP-25 caveat value', () => { + const ethAccounts = getEthAccounts({ + requiredScopes: { + 'eip155:1': { + accounts: ['eip155:1:0x1', 'eip155:1:0x2'], + }, + 'eip155:5': { + accounts: ['eip155:5:0x2', 'eip155:1:0x3'], + }, + 'bip122:000000000019d6689c085ae165831e93': { + accounts: [ + 'bip122:000000000019d6689c085ae165831e93:128Lkh3S7CkDTBZ8W7BbpsN3YYizJMp8p6', + ], + }, + }, + optionalScopes: { + 'eip155:1': { + accounts: ['eip155:1:0x1', 'eip155:1:0x4'], + }, + 'eip155:10': { + accounts: [], + }, + 'eip155:100': { + accounts: ['eip155:100:0x100'], + }, + 'wallet:eip155': { + accounts: ['wallet:eip155:0x5'], + }, + }, + }); + + expect(ethAccounts).toStrictEqual([ + '0x1', + '0x2', + '0x3', + '0x4', + '0x100', + '0x5', + ]); + }); + }); + + describe('setEthAccounts', () => { + it('returns a CAIP-25 caveat value with all EIP-155 scopeObject.accounts set to CAIP-10 account addresses formed from the accounts param', () => { + const input: Caip25CaveatValue = { + requiredScopes: { + 'eip155:1': { + accounts: ['eip155:1:0x1', 'eip155:1:0x2'], + }, + 'eip155:5': { + accounts: ['eip155:5:0x2', 'eip155:1:0x3'], + }, + 'bip122:000000000019d6689c085ae165831e93': { + accounts: [ + 'bip122:000000000019d6689c085ae165831e93:128Lkh3S7CkDTBZ8W7BbpsN3YYizJMp8p6', + ], + }, + }, + optionalScopes: { + 'eip155:1': { + accounts: ['eip155:1:0x1', 'eip155:1:0x4'], + }, + 'eip155:10': { + accounts: [], + }, + 'eip155:100': { + accounts: ['eip155:100:0x100'], + }, + 'wallet:eip155': { + accounts: [], + }, + wallet: { + accounts: [], + }, + }, + sessionProperties: {}, + isMultichainOrigin: false, + }; + + const result = setEthAccounts(input, ['0x1', '0x2', '0x3']); + expect(result).toStrictEqual({ + requiredScopes: { + 'eip155:1': { + accounts: ['eip155:1:0x1', 'eip155:1:0x2', 'eip155:1:0x3'], + }, + 'eip155:5': { + accounts: ['eip155:5:0x1', 'eip155:5:0x2', 'eip155:5:0x3'], + }, + 'bip122:000000000019d6689c085ae165831e93': { + accounts: [ + 'bip122:000000000019d6689c085ae165831e93:128Lkh3S7CkDTBZ8W7BbpsN3YYizJMp8p6', + ], + }, + }, + optionalScopes: { + 'eip155:1': { + accounts: ['eip155:1:0x1', 'eip155:1:0x2', 'eip155:1:0x3'], + }, + 'eip155:10': { + accounts: ['eip155:10:0x1', 'eip155:10:0x2', 'eip155:10:0x3'], + }, + 'eip155:100': { + accounts: ['eip155:100:0x1', 'eip155:100:0x2', 'eip155:100:0x3'], + }, + 'wallet:eip155': { + accounts: [ + 'wallet:eip155:0x1', + 'wallet:eip155:0x2', + 'wallet:eip155:0x3', + ], + }, + wallet: { + accounts: [], + }, + }, + sessionProperties: {}, + isMultichainOrigin: false, + }); + }); + + it('does not modify the input CAIP-25 caveat value object in place', () => { + const input: Caip25CaveatValue = { + requiredScopes: { + 'eip155:1': { + accounts: [], + }, + }, + optionalScopes: {}, + sessionProperties: {}, + isMultichainOrigin: false, + }; + + const result = setEthAccounts(input, ['0x1', '0x2', '0x3']); + expect(input).toStrictEqual({ + requiredScopes: { + 'eip155:1': { + accounts: [], + }, + }, + optionalScopes: {}, + sessionProperties: {}, + isMultichainOrigin: false, + }); + expect(input).not.toStrictEqual(result); + }); + }); + + describe('setNonSCACaipAccountIdsInCaip25CaveatValue', () => { + it('returns a CAIP-25 caveat value with all scopeObject.accounts set to accounts provided', () => { + const input: Caip25CaveatValue = { + requiredScopes: { + 'eip155:1': { + accounts: ['eip155:1:0x1', 'eip155:1:0x2'], + }, + 'bip122:000000000019d6689c085ae165831e93': { + accounts: ['bip122:000000000019d6689c085ae165831e93:abc123'], + }, + }, + optionalScopes: { + 'eip155:5': { + accounts: ['eip155:5:0x3'], + }, + wallet: { + accounts: [], + }, + }, + sessionProperties: {}, + isMultichainOrigin: false, + }; + + const permittedAccounts: CaipAccountId[] = [ + 'eip155:1:0xabc', + 'eip155:5:0xabc', + 'bip122:000000000019d6689c085ae165831e93:xyz789', + ]; + + const result = setNonSCACaipAccountIdsInCaip25CaveatValue( + input, + permittedAccounts, + ); + + expect(result).toStrictEqual({ + requiredScopes: { + 'eip155:1': { + accounts: ['eip155:1:0xabc'], + }, + 'bip122:000000000019d6689c085ae165831e93': { + accounts: ['bip122:000000000019d6689c085ae165831e93:xyz789'], + }, + }, + optionalScopes: { + 'eip155:5': { + accounts: ['eip155:5:0xabc'], + }, + wallet: { + accounts: [], + }, + }, + sessionProperties: {}, + isMultichainOrigin: false, + }); + }); + + it('does not modify the input CAIP-25 caveat value object', () => { + const input: Caip25CaveatValue = { + requiredScopes: { + 'eip155:1': { + accounts: ['eip155:1:0x1'], + }, + }, + optionalScopes: {}, + sessionProperties: {}, + isMultichainOrigin: false, + }; + + const result = setNonSCACaipAccountIdsInCaip25CaveatValue(input, [ + 'eip155:1:0xabc', + ] as CaipAccountId[]); + + expect(input).toStrictEqual({ + requiredScopes: { + 'eip155:1': { + accounts: ['eip155:1:0x1'], + }, + }, + optionalScopes: {}, + sessionProperties: {}, + isMultichainOrigin: false, + }); + expect(input).not.toStrictEqual(result); + }); + + it('handles empty accounts array', () => { + const input: Caip25CaveatValue = { + requiredScopes: { + 'eip155:1': { + accounts: ['eip155:1:0x1'], + }, + }, + optionalScopes: {}, + sessionProperties: {}, + isMultichainOrigin: false, + }; + + const result = setNonSCACaipAccountIdsInCaip25CaveatValue(input, []); + + expect(result).toStrictEqual({ + requiredScopes: { + 'eip155:1': { + accounts: [], + }, + }, + optionalScopes: {}, + sessionProperties: {}, + isMultichainOrigin: false, + }); + }); + + it('handles different CAIP namespaces in the accounts array', () => { + const input: Caip25CaveatValue = { + requiredScopes: { + 'eip155:1': { + accounts: [], + }, + 'solana:4sGjMW1sUnHzSxGspuhpqLDx6wiyjNtZ': { + accounts: [], + }, + }, + optionalScopes: {}, + sessionProperties: {}, + isMultichainOrigin: false, + }; + + const result = setNonSCACaipAccountIdsInCaip25CaveatValue(input, [ + 'eip155:1:0xabc', + 'solana:4sGjMW1sUnHzSxGspuhpqLDx6wiyjNtZ:pubkey123', + ]); + + expect(result).toStrictEqual({ + requiredScopes: { + 'eip155:1': { + accounts: ['eip155:1:0xabc'], + }, + 'solana:4sGjMW1sUnHzSxGspuhpqLDx6wiyjNtZ': { + accounts: ['solana:4sGjMW1sUnHzSxGspuhpqLDx6wiyjNtZ:pubkey123'], + }, + }, + optionalScopes: {}, + sessionProperties: {}, + isMultichainOrigin: false, + }); + }); + + it('adds accounts for scopes with matching namespaces including for accounts where the fully chainId scope does not exist in the caveat', () => { + const input: Caip25CaveatValue = { + requiredScopes: { + 'eip155:1': { + accounts: ['eip155:1:0x1'], + }, + }, + optionalScopes: { + 'eip155:5': { + accounts: [], + }, + }, + sessionProperties: {}, + isMultichainOrigin: false, + }; + + const result = setNonSCACaipAccountIdsInCaip25CaveatValue(input, [ + 'eip155:1:0xabc', + 'eip155:5:0xdef', + 'eip155:137:0xghi', + ]); + + expect(result).toStrictEqual({ + requiredScopes: { + 'eip155:1': { + accounts: ['eip155:1:0xabc', 'eip155:1:0xdef', 'eip155:1:0xghi'], + }, + }, + optionalScopes: { + 'eip155:5': { + accounts: ['eip155:5:0xabc', 'eip155:5:0xdef', 'eip155:5:0xghi'], + }, + }, + sessionProperties: {}, + isMultichainOrigin: false, + }); + }); + }); + + describe('getCaipAccountIdsFromScopesObjects', () => { + it('returns all unique account IDs from multiple scopes objects', () => { + const scopesObjects = [ + { + 'eip155:1': { + accounts: [ + 'eip155:1:0x1234567890123456789012345678901234567890', + 'eip155:1:0x2345678901234567890123456789012345678901', + ], + }, + }, + { + 'eip155:5': { + accounts: [ + 'eip155:5:0x1234567890123456789012345678901234567890', + 'eip155:5:0x3456789012345678901234567890123456789012', + ], + }, + }, + { + 'bip122:000000000019d6689c085ae165831e93': { + accounts: [ + 'bip122:000000000019d6689c085ae165831e93:128Lkh3S7CkDTBZ8W7BbpsN3YYizJMp8p6', + ], + }, + }, + ] as InternalScopesObject[]; + + const result = getCaipAccountIdsFromScopesObjects(scopesObjects); + + expect(result).toStrictEqual([ + 'eip155:1:0x1234567890123456789012345678901234567890', + 'eip155:1:0x2345678901234567890123456789012345678901', + 'eip155:5:0x1234567890123456789012345678901234567890', + 'eip155:5:0x3456789012345678901234567890123456789012', + 'bip122:000000000019d6689c085ae165831e93:128Lkh3S7CkDTBZ8W7BbpsN3YYizJMp8p6', + ]); + }); + + it('returns an empty array if all the scopes objects are empty', () => { + const result = getCaipAccountIdsFromScopesObjects([ + {}, + {}, + ] as InternalScopesObject[]); + expect(result).toStrictEqual([]); + }); + + it('returns an empty array if the array of scopes objects is empty', () => { + const result = getCaipAccountIdsFromScopesObjects( + [] as InternalScopesObject[], + ); + expect(result).toStrictEqual([]); + }); + + it('eliminates duplicate accounts across different scopes objects', () => { + const scopesObjects = [ + { + 'eip155:1': { + accounts: ['eip155:1:0x1234567890123456789012345678901234567890'], + }, + 'eip155:5': { + accounts: ['eip155:5:0x3456789012345678901234567890123456789012'], + }, + }, + { + 'eip155:5': { + accounts: ['eip155:5:0x3456789012345678901234567890123456789012'], + }, + }, + ] as InternalScopesObject[]; + + const result = getCaipAccountIdsFromScopesObjects(scopesObjects); + expect(result).toStrictEqual([ + 'eip155:1:0x1234567890123456789012345678901234567890', + 'eip155:5:0x3456789012345678901234567890123456789012', + ]); + }); + }); + + describe('getCaipAccountIdsFromCaip25CaveatValue', () => { + it('returns all unique account IDs from both required and optional scopes', () => { + const caveatValue: Caip25CaveatValue = { + requiredScopes: { + 'eip155:1': { + accounts: [ + 'eip155:1:0x1234567890123456789012345678901234567890', + 'eip155:1:0x2345678901234567890123456789012345678901', + ], + }, + 'bip122:000000000019d6689c085ae165831e93': { + accounts: [ + 'bip122:000000000019d6689c085ae165831e93:128Lkh3S7CkDTBZ8W7BbpsN3YYizJMp8p6', + ], + }, + } as InternalScopesObject, + optionalScopes: { + 'eip155:5': { + accounts: [ + 'eip155:5:0x1234567890123456789012345678901234567890', + 'eip155:5:0x3456789012345678901234567890123456789012', + ], + }, + wallet: { + accounts: [], + }, + } as InternalScopesObject, + sessionProperties: {}, + isMultichainOrigin: false, + }; + + const result = getCaipAccountIdsFromCaip25CaveatValue(caveatValue); + + expect(result).toStrictEqual([ + 'eip155:1:0x1234567890123456789012345678901234567890', + 'eip155:1:0x2345678901234567890123456789012345678901', + 'bip122:000000000019d6689c085ae165831e93:128Lkh3S7CkDTBZ8W7BbpsN3YYizJMp8p6', + 'eip155:5:0x1234567890123456789012345678901234567890', + 'eip155:5:0x3456789012345678901234567890123456789012', + ]); + }); + + it('returns an empty array if there are no accounts in any scopes', () => { + const caveatValue: Caip25CaveatValue = { + requiredScopes: { + 'eip155:1': { accounts: [] }, + } as InternalScopesObject, + optionalScopes: { + 'eip155:5': { accounts: [] }, + } as InternalScopesObject, + sessionProperties: {}, + isMultichainOrigin: false, + }; + + const result = getCaipAccountIdsFromCaip25CaveatValue(caveatValue); + expect(result).toStrictEqual([]); + }); + + it('returns an empty array if both required and optional scopes are empty', () => { + const caveatValue: Caip25CaveatValue = { + requiredScopes: {} as InternalScopesObject, + optionalScopes: {} as InternalScopesObject, + sessionProperties: {}, + isMultichainOrigin: false, + }; + + const result = getCaipAccountIdsFromCaip25CaveatValue(caveatValue); + expect(result).toStrictEqual([]); + }); + + it('eliminates duplicate accounts across required and optional scopes', () => { + const caveatValue: Caip25CaveatValue = { + requiredScopes: { + 'eip155:1': { + accounts: ['eip155:1:0x1234567890123456789012345678901234567890'], + }, + 'eip155:5': { + accounts: ['eip155:5:0x3456789012345678901234567890123456789012'], + }, + } as InternalScopesObject, + optionalScopes: { + 'eip155:5': { + accounts: ['eip155:5:0x3456789012345678901234567890123456789012'], + }, + } as InternalScopesObject, + sessionProperties: {}, + isMultichainOrigin: false, + }; + + const result = getCaipAccountIdsFromCaip25CaveatValue(caveatValue); + expect(result).toStrictEqual([ + 'eip155:1:0x1234567890123456789012345678901234567890', + 'eip155:5:0x3456789012345678901234567890123456789012', + ]); + }); + }); + + describe('isInternalAccountInPermittedAccountIds', () => { + it('returns false if the internal account has no scopes', () => { + const result = isInternalAccountInPermittedAccountIds( + // @ts-expect-error partial internal account + { + scopes: [], + address: '0xdeadbeef', + }, + [], + ); + expect(result).toBe(false); + }); + + it('returns false if internal account does not have a scopes property', () => { + const result = isInternalAccountInPermittedAccountIds( + // @ts-expect-error partial internal account + { + address: '0xdeadbeef', + }, + [], + ); + expect(result).toBe(false); + }); + + it('returns false if there are no permitted account ids', () => { + const result = isInternalAccountInPermittedAccountIds( + // @ts-expect-error partial internal account + { + scopes: ['eip155:0'], + address: '0xdeadbeef', + }, + [], + ); + expect(result).toBe(false); + }); + + it('returns false if there are no exact matching namespaces', () => { + const result = isInternalAccountInPermittedAccountIds( + // @ts-expect-error partial internal account + { + scopes: ['eip155:1'], + address: '0xdeadbeef', + }, + ['solana:1:0xdeadbeef'], + ); + expect(result).toBe(false); + }); + + it('returns true if there are exact matching permitted account ids', () => { + const result = isInternalAccountInPermittedAccountIds( + // @ts-expect-error partial internal account + { + scopes: ['eip155:1'], + address: '0xdeadbeef', + }, + ['eip155:1:0xdeadbeef'], + ); + expect(result).toBe(true); + }); + + it('returns true if there are exact matching evm references but mismatched address casing', () => { + const result = isInternalAccountInPermittedAccountIds( + // @ts-expect-error partial internal account + { + scopes: ['eip155:1'], + address: '0xdeadbeef', + }, + ['eip155:1:0xdeadBEEF'], + ); + expect(result).toBe(true); + }); + + it('returns false if there are exact matching non-evm references but mismatched address casing', () => { + const result = isInternalAccountInPermittedAccountIds( + // @ts-expect-error partial internal account + { + scopes: ['solana:0'], + address: '0xdeadbeef', + }, + ['solana:1:0xdeadbeef'], + ); + expect(result).toBe(false); + }); + + it('returns true if there are null reference matching evm references', () => { + const result = isInternalAccountInPermittedAccountIds( + // @ts-expect-error partial internal account + { + scopes: ['eip155:0'], + address: '0xdeadbeef', + }, + ['eip155:1:0xdeadbeef'], + ); + expect(result).toBe(true); + }); + + it('returns false if there are no exact matching non-evm references', () => { + const result = isInternalAccountInPermittedAccountIds( + // @ts-expect-error partial internal account + { + scopes: ['solana:0'], + address: '0xdeadbeef', + }, + ['solana:1:0xdeadbeef'], + ); + expect(result).toBe(false); + }); + + it('returns true if a wallet:eip155 namespaced address is permitted and a matching (case insensitive) internal account with eip155:0 scope exists', () => { + const result = isInternalAccountInPermittedAccountIds( + // @ts-expect-error partial internal account + { + scopes: ['eip155:0'], + address: '0xDeAdBeEf', + }, + ['wallet:eip155:0xdeadbeef'], + ); + expect(result).toBe(true); + }); + + it('returns true if a wallet: namespaced account is permitted and a matching (case sensitive) internal account with solana namespaced scope exists', () => { + const result = isInternalAccountInPermittedAccountIds( + // @ts-expect-error partial internal account + { + scopes: ['solana:0'], + address: 'abC123', + }, + ['wallet:solana:abC123'], + ); + expect(result).toBe(true); + }); + + it('returns false if a wallet: namespaced account is permitted and a matching (case sensitive) internal account with same address but different namespace', () => { + const result = isInternalAccountInPermittedAccountIds( + // @ts-expect-error partial internal account + { + scopes: ['solana:0'], + address: 'abC123', + }, + ['wallet:notsolana:abC123'], + ); + expect(result).toBe(false); + }); + }); + + describe('isCaipAccountIdInPermittedAccountIds', () => { + it('returns false if there are no permitted account ids', () => { + const result = isCaipAccountIdInPermittedAccountIds( + 'eip155:1:0xdeadbeef', + [], + ); + expect(result).toBe(false); + }); + + it('returns false if there are no exact matching namespaces', () => { + const result = isCaipAccountIdInPermittedAccountIds( + 'eip155:1:0xdeadbeef', + ['solana:1:0xdeadbeef'], + ); + expect(result).toBe(false); + }); + + it('returns true if there are exact matching permitted account ids', () => { + const result = isCaipAccountIdInPermittedAccountIds( + 'eip155:1:0xdeadbeef', + ['eip155:1:0xdeadbeef'], + ); + expect(result).toBe(true); + }); + + it('returns true if there are exact matching evm references but mismatched address casing', () => { + const result = isCaipAccountIdInPermittedAccountIds( + 'eip155:1:0xdeadbeef', + ['eip155:1:0xdeadBEEF'], + ); + expect(result).toBe(true); + }); + + it('returns false if there are exact matching non-evm references but mismatched address casing', () => { + const result = isCaipAccountIdInPermittedAccountIds( + 'solana:1:0xdeadbeef', + ['solana:1:0xdeadBEEF'], + ); + expect(result).toBe(false); + }); + + it('returns true if there are null reference matching evm references', () => { + const result = isCaipAccountIdInPermittedAccountIds( + 'eip155:0:0xdeadbeef', + ['eip155:1:0xdeadbeef'], + ); + expect(result).toBe(true); + }); + + it('returns false if there are no exact matching non-evm references', () => { + const result = isCaipAccountIdInPermittedAccountIds( + 'solana:0:0xdeadbeef', + ['solana:1:0xdeadbeef'], + ); + expect(result).toBe(false); + }); + }); +}); diff --git a/packages/chain-agnostic-permission/src/operators/caip-permission-operator-accounts.ts b/packages/chain-agnostic-permission/src/operators/caip-permission-operator-accounts.ts new file mode 100644 index 00000000000..281daa5e874 --- /dev/null +++ b/packages/chain-agnostic-permission/src/operators/caip-permission-operator-accounts.ts @@ -0,0 +1,415 @@ +import { isEqualCaseInsensitive } from '@metamask/controller-utils'; +import type { InternalAccount } from '@metamask/keyring-internal-api'; +import { + assertIsStrictHexString, + KnownCaipNamespace, + parseCaipAccountId, +} from '@metamask/utils'; +import type { + CaipAccountAddress, + CaipAccountId, + CaipNamespace, + CaipReference, + Hex, +} from '@metamask/utils'; + +import type { Caip25CaveatValue } from '../caip25Permission.js'; +import { KnownWalletScopeString } from '../scope/constants.js'; +import { getUniqueArrayItems } from '../scope/transform.js'; +import type { + InternalScopeString, + InternalScopesObject, +} from '../scope/types.js'; +import { parseScopeString } from '../scope/types.js'; + +/* + * + * + * EVM SPECIFIC GETTERS AND SETTERS + * + * + */ + +/** + * + * Checks if a scope string is either an EIP155 or wallet namespaced scope string. + * + * @param scopeString - The scope string to check. + * @returns True if the scope string is an EIP155 or wallet namespaced scope string, false otherwise. + */ +const isEip155ScopeString = (scopeString: InternalScopeString) => { + const { namespace } = parseScopeString(scopeString); + + return ( + namespace === KnownCaipNamespace.Eip155 || + scopeString === KnownWalletScopeString.Eip155 + ); +}; + +/** + * Gets the Ethereum (EIP155 namespaced) accounts from internal scopes. + * + * @param scopes - The internal scopes from which to get the Ethereum accounts. + * @returns An array of Ethereum accounts. + */ +const getEthAccountsFromScopes = (scopes: InternalScopesObject) => { + const ethAccounts: Hex[] = []; + + Object.entries(scopes).forEach(([_, { accounts }]) => { + accounts?.forEach((account) => { + const { address, chainId } = parseCaipAccountId(account); + + if (isEip155ScopeString(chainId)) { + // This address should always be a valid Hex string because + // it's an EIP155/Ethereum account + assertIsStrictHexString(address); + ethAccounts.push(address); + } + }); + }); + + return ethAccounts; +}; + +/** + * Gets the Ethereum (EIP155 namespaced) accounts from the required and optional scopes. + * + * @param caip25CaveatValue - The CAIP-25 caveat value to get the Ethereum accounts from. + * @returns An array of Ethereum accounts. + */ +export const getEthAccounts = ( + caip25CaveatValue: Pick< + Caip25CaveatValue, + 'requiredScopes' | 'optionalScopes' + >, +): Hex[] => { + const { requiredScopes, optionalScopes } = caip25CaveatValue; + + const ethAccounts: Hex[] = [ + ...getEthAccountsFromScopes(requiredScopes), + ...getEthAccountsFromScopes(optionalScopes), + ]; + + return getUniqueArrayItems(ethAccounts); +}; + +/** + * Sets the Ethereum (EIP155 namespaced) accounts for the given scopes object. + * + * @param scopesObject - The scopes object to set the Ethereum accounts for. + * @param accounts - The Ethereum accounts to set. + * @returns The updated scopes object with the Ethereum accounts set. + */ +const setEthAccountsForScopesObject = ( + scopesObject: InternalScopesObject, + accounts: Hex[], +) => { + const updatedScopesObject: InternalScopesObject = {}; + Object.entries(scopesObject).forEach(([key, scopeObject]) => { + // Cast needed because index type is returned as `string` by `Object.entries` + const scopeString = key as keyof typeof scopesObject; + const isWalletNamespace = scopeString === KnownCaipNamespace.Wallet; + const { namespace, reference } = parseScopeString(scopeString); + if (!isEip155ScopeString(scopeString) && !isWalletNamespace) { + updatedScopesObject[scopeString] = scopeObject; + return; + } + + let caipAccounts: CaipAccountId[] = []; + if (namespace && reference) { + caipAccounts = accounts.map( + (account) => `${namespace}:${reference}:${account}`, + ); + } + + updatedScopesObject[scopeString] = { + ...scopeObject, + accounts: caipAccounts, + }; + }); + + return updatedScopesObject; +}; + +/** + * Sets the Ethereum (EIP155 namespaced) accounts for the given CAIP-25 caveat value. + * We set the same accounts for all the scopes that are EIP155 or Wallet namespaced because + * we do not provide UI/UX flows for selecting different accounts across different chains. + * + * @param caip25CaveatValue - The CAIP-25 caveat value to set the Ethereum accounts for. + * @param accounts - The Ethereum accounts to set. + * @returns The updated CAIP-25 caveat value with the Ethereum accounts set. + */ +export const setEthAccounts = ( + caip25CaveatValue: Caip25CaveatValue, + accounts: Hex[], +): Caip25CaveatValue => { + return { + ...caip25CaveatValue, + requiredScopes: setEthAccountsForScopesObject( + caip25CaveatValue.requiredScopes, + accounts, + ), + optionalScopes: setEthAccountsForScopesObject( + caip25CaveatValue.optionalScopes, + accounts, + ), + }; +}; + +/* + * + * + * GENERALIZED GETTERS AND SETTERS + * + * + */ + +/** + * + * Getters + * + */ + +/** + * Gets all accounts from an array of scopes objects + * This extracts all account IDs from both required and optional scopes + * and returns a unique set. + * + * @param scopesObjects - The scopes objects to extract accounts from + * @returns Array of unique account IDs + */ +export function getCaipAccountIdsFromScopesObjects( + scopesObjects: InternalScopesObject[], +): CaipAccountId[] { + const allAccounts = new Set(); + + for (const scopeObject of scopesObjects) { + for (const { accounts } of Object.values(scopeObject)) { + for (const account of accounts) { + allAccounts.add(account); + } + } + } + + return Array.from(allAccounts); +} + +/** + * Gets all permitted accounts from a CAIP-25 caveat + * This extracts all account IDs from both required and optional scopes + * and returns a unique set. + * + * @param caip25CaveatValue - The CAIP-25 caveat value to extract accounts from + * @returns Array of unique account IDs + */ +export function getCaipAccountIdsFromCaip25CaveatValue( + caip25CaveatValue: Caip25CaveatValue, +): CaipAccountId[] { + return getCaipAccountIdsFromScopesObjects([ + caip25CaveatValue.requiredScopes, + caip25CaveatValue.optionalScopes, + ]); +} + +/** + * + * Setters + * + */ + +/** + * Sets the CAIP account IDs to scopes with matching namespaces in the given scopes object. + * This function should not be used with Smart Contract Accounts (SCA) because + * it adds the same account ID to all the scopes that have the same namespace. + * + * @param scopesObject - The scopes object to set the CAIP account IDs for. + * @param accounts - The CAIP account IDs to add to the appropriate scopes. + * @returns The updated scopes object with the CAIP account IDs set. + */ +const setNonSCACaipAccountIdsInScopesObject = ( + scopesObject: InternalScopesObject, + accounts: CaipAccountId[], +) => { + const accountsByNamespace = new Map>(); + + for (const account of accounts) { + const { + chain: { namespace }, + address, + } = parseCaipAccountId(account); + + if (!accountsByNamespace.has(namespace)) { + accountsByNamespace.set(namespace, new Set()); + } + + accountsByNamespace.get(namespace)?.add(address); + } + + const updatedScopesObject: InternalScopesObject = {}; + + for (const [scopeString, scopeObject] of Object.entries(scopesObject)) { + const { namespace, reference } = parseScopeString(scopeString); + + let caipAccounts: CaipAccountId[] = []; + + if (namespace && reference && accountsByNamespace.has(namespace)) { + const addressSet = accountsByNamespace.get(namespace); + if (addressSet) { + caipAccounts = Array.from(addressSet).map( + (address) => `${namespace}:${reference}:${address}` as const, + ); + } + } + + updatedScopesObject[scopeString as keyof typeof scopesObject] = { + ...scopeObject, + accounts: getUniqueArrayItems(caipAccounts), + }; + } + + return updatedScopesObject; +}; + +/** + * Sets the permitted accounts to scopes with matching namespaces in the given CAIP-25 caveat value. + * This function should not be used with Smart Contract Accounts (SCA) because + * it adds the same account ID to all scopes that have the same namespace as the account. + * + * @param caip25CaveatValue - The CAIP-25 caveat value to set the permitted accounts for. + * @param accounts - The permitted accounts to add to the appropriate scopes. + * @returns The updated CAIP-25 caveat value with the permitted accounts set. + */ +export const setNonSCACaipAccountIdsInCaip25CaveatValue = ( + caip25CaveatValue: Caip25CaveatValue, + accounts: CaipAccountId[], +): Caip25CaveatValue => { + return { + ...caip25CaveatValue, + requiredScopes: setNonSCACaipAccountIdsInScopesObject( + caip25CaveatValue.requiredScopes, + accounts, + ), + optionalScopes: setNonSCACaipAccountIdsInScopesObject( + caip25CaveatValue.optionalScopes, + accounts, + ), + }; +}; + +/** + * Checks if an address and list of parsed scopes are connected to any of + * the permitted accounts based on scope matching + * + * @param address - The CAIP account address to check against permitted accounts + * @param parsedAccountScopes - The list of parsed CAIP chain ID to check against permitted accounts + * @param permittedAccounts - Array of CAIP account IDs that are permitted + * @returns True if the address and any account scope is connected to any permitted account + */ +function isAddressWithParsedScopesInPermittedAccountIds( + address: CaipAccountAddress, + parsedAccountScopes: { + namespace?: CaipNamespace; + reference?: CaipReference; + }[], + permittedAccounts: CaipAccountId[], +) { + if (!address || !parsedAccountScopes.length || !permittedAccounts.length) { + return false; + } + + return permittedAccounts.some((account) => { + const parsedPermittedAccount = parseCaipAccountId(account); + + return parsedAccountScopes.some(({ namespace, reference }) => { + if ( + namespace !== parsedPermittedAccount.chain.namespace && + parsedPermittedAccount.chain.namespace !== KnownCaipNamespace.Wallet + ) { + return false; + } + + // handle wallet::
case where namespaces are mismatched but addresses match + // i.e. wallet:notSolana:12389812309123 and solana:0:12389812309123 + if ( + parsedPermittedAccount.chain.namespace === KnownCaipNamespace.Wallet && + namespace !== parsedPermittedAccount.chain.reference + ) { + return false; + } + + // handle eip155:0 case and insensitive evm address comparison + if (namespace === KnownCaipNamespace.Eip155) { + return ( + (reference === '0' || + reference === parsedPermittedAccount.chain.reference) && + isEqualCaseInsensitive(address, parsedPermittedAccount.address) + ); + } + + // handle wallet::
case + if ( + parsedPermittedAccount.chain.namespace === KnownCaipNamespace.Wallet + ) { + return address === parsedPermittedAccount.address; + } + + return ( + reference === parsedPermittedAccount.chain.reference && + address === parsedPermittedAccount.address + ); + }); + }); +} + +/** + * Checks if an internal account is connected to any of the permitted accounts + * based on scope matching + * + * @param internalAccount - The internal account to check against permitted accounts + * @param permittedAccounts - Array of CAIP account IDs that are permitted + * @returns True if the account is connected to any permitted account + */ +export function isInternalAccountInPermittedAccountIds( + internalAccount: InternalAccount, + permittedAccounts: CaipAccountId[], +): boolean { + // temporary fix for the issue where the internal account has no scopes and or scopes is undefined + // TODO: remove this once the bug is fixed (tracked here: https://github.com/MetaMask/accounts-planning/issues/941) + // there is currently a bug where an account associated with a snap can fail to add scopes to the internal account in time + // before we attempt to access this state + if (!internalAccount?.scopes?.length) { + return false; + } + + const parsedInteralAccountScopes = internalAccount.scopes.map((scope) => { + return parseScopeString(scope); + }); + + return isAddressWithParsedScopesInPermittedAccountIds( + internalAccount.address, + parsedInteralAccountScopes, + permittedAccounts, + ); +} + +/** + * Checks if an CAIP account ID is connected to any of the permitted accounts + * based on scope matching + * + * @param accountId - The CAIP account ID to check against permitted accounts + * @param permittedAccounts - Array of CAIP account IDs that are permitted + * @returns True if the account is connected to any permitted account + */ +export function isCaipAccountIdInPermittedAccountIds( + accountId: CaipAccountId, + permittedAccounts: CaipAccountId[], +): boolean { + const { address, chain } = parseCaipAccountId(accountId); + + return isAddressWithParsedScopesInPermittedAccountIds( + address, + [chain], + permittedAccounts, + ); +} diff --git a/packages/chain-agnostic-permission/src/operators/caip-permission-operator-permittedChains.test.ts b/packages/chain-agnostic-permission/src/operators/caip-permission-operator-permittedChains.test.ts new file mode 100644 index 00000000000..a14c5802ee8 --- /dev/null +++ b/packages/chain-agnostic-permission/src/operators/caip-permission-operator-permittedChains.test.ts @@ -0,0 +1,788 @@ +import type { Caip25CaveatValue } from '../caip25Permission.js'; +import { Caip25CaveatType } from '../caip25Permission.js'; +import { + addPermittedEthChainId, + getPermittedEthChainIds, + setPermittedEthChainIds, + addCaipChainIdInCaip25CaveatValue, + setChainIdsInCaip25CaveatValue, + getAllScopesFromScopesObjects, + getAllScopesFromCaip25CaveatValue, + getAllNamespacesFromCaip25CaveatValue, + getAllScopesFromPermission, +} from './caip-permission-operator-permittedChains.js'; + +describe('CAIP-25 permittedChains adapters', () => { + describe('getPermittedEthChainIds', () => { + it('returns the unique set of EIP155 chainIds in hexadecimal format from the CAIP-25 caveat value', () => { + const ethChainIds = getPermittedEthChainIds({ + requiredScopes: { + 'eip155:1': { + accounts: ['eip155:1:0x1', 'eip155:1:0x2'], + }, + 'eip155:5': { + accounts: ['eip155:5:0x2', 'eip155:1:0x3'], + }, + 'bip122:000000000019d6689c085ae165831e93': { + accounts: [ + 'bip122:000000000019d6689c085ae165831e93:128Lkh3S7CkDTBZ8W7BbpsN3YYizJMp8p6', + ], + }, + }, + optionalScopes: { + 'eip155:1': { + accounts: ['eip155:1:0x1', 'eip155:1:0x4'], + }, + 'eip155:10': { + accounts: [], + }, + 'eip155:100': { + accounts: ['eip155:100:0x100'], + }, + }, + }); + + expect(ethChainIds).toStrictEqual(['0x1', '0x5', '0xa', '0x64']); + }); + }); + + describe('addPermittedEthChainId', () => { + it('returns a version of the caveat value with a new optional scope for the chainId if it does not already exist in required or optional scopes', () => { + const result = addPermittedEthChainId( + { + requiredScopes: { + 'eip155:1': { + accounts: ['eip155:1:0x1', 'eip155:1:0x2'], + }, + }, + optionalScopes: { + 'eip155:100': { + accounts: ['eip155:100:0x100'], + }, + 'wallet:eip155': { + accounts: [], + }, + }, + sessionProperties: {}, + isMultichainOrigin: false, + }, + '0x65', + ); + + expect(result).toStrictEqual({ + requiredScopes: { + 'eip155:1': { + accounts: ['eip155:1:0x1', 'eip155:1:0x2'], + }, + }, + optionalScopes: { + 'eip155:100': { + accounts: ['eip155:100:0x100'], + }, + 'eip155:101': { + accounts: [], + }, + 'wallet:eip155': { + accounts: [], + }, + }, + sessionProperties: {}, + isMultichainOrigin: false, + }); + }); + + it('does not modify the input CAIP-25 caveat value object', () => { + const input: Caip25CaveatValue = { + requiredScopes: { + 'eip155:1': { + accounts: ['eip155:1:0x1', 'eip155:1:0x2'], + }, + }, + optionalScopes: {}, + sessionProperties: {}, + isMultichainOrigin: false, + }; + + const result = addPermittedEthChainId(input, '0x65'); + + expect(input).toStrictEqual({ + requiredScopes: { + 'eip155:1': { + accounts: ['eip155:1:0x1', 'eip155:1:0x2'], + }, + }, + optionalScopes: {}, + sessionProperties: {}, + isMultichainOrigin: false, + }); + expect(input).not.toStrictEqual(result); + }); + + it('does not add an optional scope for the chainId if already exists in the required scopes', () => { + const input: Caip25CaveatValue = { + requiredScopes: { + 'eip155:1': { + accounts: ['eip155:1:0x1', 'eip155:1:0x2'], + }, + }, + optionalScopes: { + 'eip155:100': { + accounts: ['eip155:100:0x100'], + }, + }, + sessionProperties: {}, + isMultichainOrigin: false, + }; + const result = addPermittedEthChainId(input, '0x1'); + + expect(result).toStrictEqual(input); + }); + + it('does not add an optional scope for the chainId if already exists in the optional scopes', () => { + const input: Caip25CaveatValue = { + requiredScopes: { + 'eip155:1': { + accounts: ['eip155:1:0x1', 'eip155:1:0x2'], + }, + }, + optionalScopes: { + 'eip155:100': { + accounts: ['eip155:100:0x100'], + }, + }, + sessionProperties: {}, + isMultichainOrigin: false, + }; + const result = addPermittedEthChainId(input, '0x64'); // 0x64 === 100 + + expect(result).toStrictEqual(input); + }); + }); + + describe('setPermittedEthChainIds', () => { + it('returns a CAIP-25 caveat value with EIP-155 scopes missing from the chainIds array removed', () => { + const result = setPermittedEthChainIds( + { + requiredScopes: { + 'eip155:1': { + accounts: ['eip155:1:0x1', 'eip155:1:0x2'], + }, + 'bip122:000000000019d6689c085ae165831e93': { + accounts: [], + }, + }, + optionalScopes: { + wallet: { + accounts: [], + }, + 'eip155:1': { + accounts: [], + }, + 'eip155:100': { + accounts: ['eip155:100:0x100'], + }, + }, + sessionProperties: {}, + isMultichainOrigin: false, + }, + ['0x1'], + ); + + expect(result).toStrictEqual({ + requiredScopes: { + 'eip155:1': { + accounts: ['eip155:1:0x1', 'eip155:1:0x2'], + }, + 'bip122:000000000019d6689c085ae165831e93': { + accounts: [], + }, + }, + optionalScopes: { + wallet: { + accounts: [], + }, + 'eip155:1': { + accounts: [], + }, + }, + sessionProperties: {}, + isMultichainOrigin: false, + }); + }); + + it('returns a CAIP-25 caveat value with optional scopes added for missing chainIds', () => { + const result = setPermittedEthChainIds( + { + requiredScopes: { + 'eip155:1': { + accounts: ['eip155:1:0x1', 'eip155:1:0x2'], + }, + }, + optionalScopes: { + 'eip155:1': { + accounts: [], + }, + 'eip155:100': { + accounts: ['eip155:100:0x100'], + }, + }, + sessionProperties: {}, + isMultichainOrigin: false, + }, + ['0x1', '0x64', '0x65'], + ); + + expect(result).toStrictEqual({ + requiredScopes: { + 'eip155:1': { + accounts: ['eip155:1:0x1', 'eip155:1:0x2'], + }, + }, + optionalScopes: { + 'eip155:1': { + accounts: [], + }, + 'eip155:100': { + accounts: ['eip155:100:0x100'], + }, + 'eip155:101': { + accounts: [], + }, + }, + sessionProperties: {}, + isMultichainOrigin: false, + }); + }); + + it('does not modify the input CAIP-25 caveat value object', () => { + const input: Caip25CaveatValue = { + requiredScopes: { + 'eip155:1': { + accounts: ['eip155:1:0x1', 'eip155:1:0x2'], + }, + }, + optionalScopes: {}, + sessionProperties: {}, + isMultichainOrigin: false, + }; + + const result = setPermittedEthChainIds(input, ['0x1', '0x2', '0x3']); + + expect(input).toStrictEqual({ + requiredScopes: { + 'eip155:1': { + accounts: ['eip155:1:0x1', 'eip155:1:0x2'], + }, + }, + optionalScopes: {}, + sessionProperties: {}, + isMultichainOrigin: false, + }); + expect(input).not.toStrictEqual(result); + }); + }); + + describe('addCaipChainIdInCaip25CaveatValue', () => { + it('returns a version of the caveat value with a new optional scope for the passed chainId if it does not already exist in required or optional scopes', () => { + const result = addCaipChainIdInCaip25CaveatValue( + { + requiredScopes: { + 'eip155:1': { + accounts: ['eip155:1:0x1', 'eip155:1:0x2'], + }, + }, + optionalScopes: { + 'eip155:100': { + accounts: ['eip155:100:0x100'], + }, + 'wallet:eip155': { + accounts: [], + }, + }, + sessionProperties: {}, + isMultichainOrigin: false, + }, + 'bip122:000000000019d6689c085ae165831e93', + ); + + expect(result).toStrictEqual({ + requiredScopes: { + 'eip155:1': { + accounts: ['eip155:1:0x1', 'eip155:1:0x2'], + }, + }, + optionalScopes: { + 'eip155:100': { + accounts: ['eip155:100:0x100'], + }, + 'wallet:eip155': { + accounts: [], + }, + 'bip122:000000000019d6689c085ae165831e93': { + accounts: [], + }, + }, + sessionProperties: {}, + isMultichainOrigin: false, + }); + }); + + it('does not modify the input CAIP-25 caveat value object', () => { + const input: Caip25CaveatValue = { + requiredScopes: { + 'eip155:1': { + accounts: ['eip155:1:0x1', 'eip155:1:0x2'], + }, + }, + optionalScopes: {}, + sessionProperties: {}, + isMultichainOrigin: false, + }; + + const result = addCaipChainIdInCaip25CaveatValue( + input, + 'bip122:000000000019d6689c085ae165831e93', + ); + + expect(input).toStrictEqual({ + requiredScopes: { + 'eip155:1': { + accounts: ['eip155:1:0x1', 'eip155:1:0x2'], + }, + }, + optionalScopes: {}, + sessionProperties: {}, + isMultichainOrigin: false, + }); + expect(input).not.toStrictEqual(result); + }); + + it('does not add an optional scope for the chainId if already exists in the required scopes', () => { + const existingScope = 'eip155:1'; + const input: Caip25CaveatValue = { + requiredScopes: { + [existingScope]: { + accounts: ['eip155:1:0x1', 'eip155:1:0x2'], + }, + }, + optionalScopes: {}, + sessionProperties: {}, + isMultichainOrigin: false, + }; + + const result = addCaipChainIdInCaip25CaveatValue(input, existingScope); + + expect(result).toStrictEqual(input); + }); + + it('does not add an optional scope for the chainId if already exists in the optional scopes', () => { + const existingScope = 'eip155:1'; + const input: Caip25CaveatValue = { + requiredScopes: {}, + optionalScopes: { + [existingScope]: { + accounts: [], + }, + }, + sessionProperties: {}, + isMultichainOrigin: false, + }; + + const result = addCaipChainIdInCaip25CaveatValue(input, existingScope); + + expect(result).toStrictEqual(input); + }); + }); + + describe('setChainIdsInCaip25CaveatValue', () => { + it('returns a CAIP-25 caveat value with non-wallet scopes missing from the chainIds array removed', () => { + const result = setChainIdsInCaip25CaveatValue( + { + requiredScopes: { + 'eip155:1': { + accounts: ['eip155:1:0x1', 'eip155:1:0x2'], + }, + 'bip122:000000000019d6689c085ae165831e93': { + accounts: [], + }, + 'eip155:100': { + accounts: ['eip155:100:0x100'], + }, + }, + optionalScopes: { + wallet: { + accounts: [], + }, + 'wallet:eip155': { + accounts: [], + }, + 'wallet:bip122': { + accounts: [], + }, + 'eip155:5': { + accounts: [], + }, + }, + sessionProperties: {}, + isMultichainOrigin: false, + }, + ['eip155:1', 'eip155:5'], + ); + + expect(result).toStrictEqual({ + requiredScopes: { + 'eip155:1': { + accounts: ['eip155:1:0x1', 'eip155:1:0x2'], + }, + }, + optionalScopes: { + wallet: { + accounts: [], + }, + 'eip155:5': { + accounts: [], + }, + 'wallet:bip122': { + accounts: [], + }, + 'wallet:eip155': { + accounts: [], + }, + }, + sessionProperties: {}, + isMultichainOrigin: false, + }); + }); + + it('returns a CAIP-25 caveat value with optional scopes added for missing chainIds', () => { + const result = setChainIdsInCaip25CaveatValue( + { + requiredScopes: { + 'eip155:1': { + accounts: ['eip155:1:0x1', 'eip155:1:0x2'], + }, + }, + optionalScopes: {}, + sessionProperties: {}, + isMultichainOrigin: false, + }, + ['eip155:1', 'bip122:000000000019d6689c085ae165831e93'], + ); + + expect(result).toStrictEqual({ + requiredScopes: { + 'eip155:1': { + accounts: ['eip155:1:0x1', 'eip155:1:0x2'], + }, + }, + optionalScopes: { + 'bip122:000000000019d6689c085ae165831e93': { + accounts: [], + }, + }, + sessionProperties: {}, + isMultichainOrigin: false, + }); + }); + + it('preserves wallet namespace scopes when setting permitted chainIds', () => { + const result = setChainIdsInCaip25CaveatValue( + { + requiredScopes: {}, + optionalScopes: { + wallet: { + accounts: [], + }, + 'wallet:eip155': { + accounts: ['wallet:eip155:0xabc'], + }, + }, + sessionProperties: {}, + isMultichainOrigin: false, + }, + ['eip155:1', 'eip155:5'], + ); + + expect(result).toStrictEqual({ + requiredScopes: {}, + optionalScopes: { + wallet: { + accounts: [], + }, + 'wallet:eip155': { + accounts: ['wallet:eip155:0xabc'], + }, + 'eip155:1': { + accounts: [], + }, + 'eip155:5': { + accounts: [], + }, + }, + sessionProperties: {}, + isMultichainOrigin: false, + }); + }); + + it('does not modify the input CAIP-25 caveat value object', () => { + const input: Caip25CaveatValue = { + requiredScopes: { + 'eip155:1': { + accounts: ['eip155:1:0x1', 'eip155:1:0x2'], + }, + }, + optionalScopes: {}, + sessionProperties: {}, + isMultichainOrigin: false, + }; + + const result = setChainIdsInCaip25CaveatValue(input, [ + 'eip155:1', + 'eip155:2', + ]); + + expect(input).toStrictEqual({ + requiredScopes: { + 'eip155:1': { + accounts: ['eip155:1:0x1', 'eip155:1:0x2'], + }, + }, + optionalScopes: {}, + sessionProperties: {}, + isMultichainOrigin: false, + }); + expect(input).not.toStrictEqual(result); + }); + }); + + describe('getAllScopesFromScopesObjects', () => { + it('returns all unique scopes from multiple scope objects as an array', () => { + const result = getAllScopesFromScopesObjects([ + { + 'eip155:1': { + accounts: ['eip155:1:0x1234567890123456789012345678901234567890'], + }, + 'eip155:5': { accounts: [] }, + }, + { + 'eip155:1': { + accounts: ['eip155:1:0x2345678901234567890123456789012345678901'], + }, + 'bip122:000000000019d6689c085ae165831e93': { accounts: [] }, + }, + { + wallet: { accounts: [] }, + }, + ]); + + expect(result).toStrictEqual([ + 'eip155:1', + 'eip155:5', + 'bip122:000000000019d6689c085ae165831e93', + 'wallet', + ]); + }); + + it('returns an empty array when given empty scope objects', () => { + const result = getAllScopesFromScopesObjects([{}, {}]); + expect(result).toStrictEqual([]); + }); + + it('returns an empty array when given an empty array', () => { + const result = getAllScopesFromScopesObjects([]); + expect(result).toStrictEqual([]); + }); + }); + + describe('getAllScopesFromCaip25CaveatValue', () => { + it('returns all unique scopes from both required and optional scopes', () => { + const result = getAllScopesFromCaip25CaveatValue({ + requiredScopes: { + 'eip155:1': { + accounts: ['eip155:1:0x1234567890123456789012345678901234567890'], + }, + 'eip155:5': { accounts: [] }, + }, + optionalScopes: { + 'eip155:1': { + accounts: ['eip155:1:0x2345678901234567890123456789012345678901'], + }, + 'bip122:000000000019d6689c085ae165831e93': { accounts: [] }, + wallet: { accounts: [] }, + }, + sessionProperties: {}, + isMultichainOrigin: false, + }); + + expect(result).toStrictEqual([ + 'eip155:1', + 'eip155:5', + 'bip122:000000000019d6689c085ae165831e93', + 'wallet', + ]); + }); + + it('returns an empty array when given empty scope objects', () => { + const result = getAllScopesFromCaip25CaveatValue({ + requiredScopes: {}, + optionalScopes: {}, + sessionProperties: {}, + isMultichainOrigin: false, + }); + expect(result).toStrictEqual([]); + }); + + it('returns only required scopes when optional scopes is empty', () => { + const result = getAllScopesFromCaip25CaveatValue({ + requiredScopes: { + 'eip155:1': { + accounts: ['eip155:1:0x1234567890123456789012345678901234567890'], + }, + }, + optionalScopes: {}, + sessionProperties: {}, + isMultichainOrigin: false, + }); + expect(result).toStrictEqual(['eip155:1']); + }); + + it('returns only optional scopes when required scopes is empty', () => { + const result = getAllScopesFromCaip25CaveatValue({ + requiredScopes: {}, + optionalScopes: { + 'eip155:1': { + accounts: ['eip155:1:0x1234567890123456789012345678901234567890'], + }, + }, + sessionProperties: {}, + isMultichainOrigin: false, + }); + expect(result).toStrictEqual(['eip155:1']); + }); + }); + + describe('getAllNamespacesFromCaip25CaveatValue', () => { + it('returns all unique namespaces from both required and optional scopes', () => { + const result = getAllNamespacesFromCaip25CaveatValue({ + requiredScopes: { + 'eip155:1': { + accounts: ['eip155:1:0x1234567890123456789012345678901234567890'], + }, + 'bip122:000000000019d6689c085ae165831e93': { accounts: [] }, + }, + optionalScopes: { + 'eip155:10': { + accounts: ['eip155:10:0x1234567890123456789012345678901234567890'], + }, + 'solana:xyz': { accounts: [] }, + wallet: { accounts: [] }, + }, + sessionProperties: {}, + isMultichainOrigin: false, + }); + + expect(result).toStrictEqual(['eip155', 'bip122', 'solana', 'wallet']); + }); + + it('returns only reference for `wallet:` type scopes', () => { + const result = getAllNamespacesFromCaip25CaveatValue({ + requiredScopes: { + 'wallet:eip155': { accounts: [] }, + 'wallet:bip122': { accounts: [] }, + }, + optionalScopes: {}, + sessionProperties: {}, + isMultichainOrigin: false, + }); + + expect(result).toStrictEqual(['eip155', 'bip122']); + }); + + it('returns an empty array when given empty scope objects', () => { + const result = getAllNamespacesFromCaip25CaveatValue({ + requiredScopes: {}, + optionalScopes: {}, + sessionProperties: {}, + isMultichainOrigin: false, + }); + expect(result).toStrictEqual([]); + }); + }); + + describe('getAllScopesFromPermission', () => { + it('returns all scopes from a permission with a CAIP-25 caveat', () => { + const permission = { + caveats: [ + { + type: Caip25CaveatType, + value: { + requiredScopes: { + 'eip155:1': { + accounts: [ + 'eip155:1:0x1234567890123456789012345678901234567890', + ], + }, + 'eip155:5': { + accounts: [], + }, + }, + optionalScopes: { + 'eip155:10': { + accounts: [], + }, + 'bip122:000000000019d6689c085ae165831e93': { + accounts: [], + }, + wallet: { + accounts: [], + }, + }, + sessionProperties: {}, + isMultichainOrigin: false, + }, + }, + ], + } as { caveats: { type: string; value: Caip25CaveatValue }[] }; + + const result = getAllScopesFromPermission(permission); + + expect(result).toStrictEqual([ + 'eip155:1', + 'eip155:5', + 'eip155:10', + 'bip122:000000000019d6689c085ae165831e93', + 'wallet', + ]); + }); + + it('returns an empty array when the permission has no CAIP-25 caveat', () => { + const permission = { + caveats: [ + { + type: 'otherCaveatType', + value: { + requiredScopes: {}, + optionalScopes: {}, + sessionProperties: {}, + isMultichainOrigin: false, + }, + }, + ], + } as { caveats: { type: string; value: Caip25CaveatValue }[] }; + + const result = getAllScopesFromPermission(permission); + + expect(result).toStrictEqual([]); + }); + + it('returns an empty array when the permission has no caveats', () => { + const permission = { + caveats: [], + }; + + const result = getAllScopesFromPermission(permission); + + expect(result).toStrictEqual([]); + }); + }); +}); diff --git a/packages/chain-agnostic-permission/src/operators/caip-permission-operator-permittedChains.ts b/packages/chain-agnostic-permission/src/operators/caip-permission-operator-permittedChains.ts new file mode 100644 index 00000000000..b44f53debc5 --- /dev/null +++ b/packages/chain-agnostic-permission/src/operators/caip-permission-operator-permittedChains.ts @@ -0,0 +1,340 @@ +import { toHex } from '@metamask/controller-utils'; +import type { Hex, CaipChainId, CaipNamespace } from '@metamask/utils'; +import { hexToBigInt, KnownCaipNamespace } from '@metamask/utils'; + +import { Caip25CaveatType } from '../caip25Permission.js'; +import type { Caip25CaveatValue } from '../caip25Permission.js'; +import { getUniqueArrayItems } from '../scope/transform.js'; +import type { + InternalScopesObject, + InternalScopeString, +} from '../scope/types.js'; +import { isWalletScope, parseScopeString } from '../scope/types.js'; + +/* + * + * + * EVM SPECIFIC GETTERS AND SETTERS + * + * + */ + +/** + * Gets the Ethereum (EIP155 namespaced) chainIDs from internal scopes. + * + * @param scopes - The internal scopes from which to get the Ethereum chainIDs. + * @returns An array of Ethereum chainIDs. + */ +const getPermittedEthChainIdsFromScopes = (scopes: InternalScopesObject) => { + const ethChainIds: Hex[] = []; + + Object.keys(scopes).forEach((scopeString) => { + const { namespace, reference } = parseScopeString(scopeString); + if (namespace === KnownCaipNamespace.Eip155 && reference) { + ethChainIds.push(toHex(reference)); + } + }); + + return ethChainIds; +}; + +/** + * Gets the Ethereum (EIP155 namespaced) chainIDs from the required and optional scopes. + * + * @param caip25CaveatValue - The CAIP-25 caveat value from which to get the Ethereum chainIDs. + * @returns An array of Ethereum chainIDs. + */ +export const getPermittedEthChainIds = ( + caip25CaveatValue: Pick< + Caip25CaveatValue, + 'requiredScopes' | 'optionalScopes' + >, +) => { + const { requiredScopes, optionalScopes } = caip25CaveatValue; + + const ethChainIds: Hex[] = [ + ...getPermittedEthChainIdsFromScopes(requiredScopes), + ...getPermittedEthChainIdsFromScopes(optionalScopes), + ]; + + return getUniqueArrayItems(ethChainIds); +}; + +/** + * Adds an Ethereum (EIP155 namespaced) chainID to the optional scopes if it is not already present + * in either the pre-existing required or optional scopes. + * + * @param caip25CaveatValue - The CAIP-25 caveat value to add the Ethereum chainID to. + * @param chainId - The Ethereum chainID to add. + * @returns The updated CAIP-25 caveat value with the added Ethereum chainID. + */ +export const addPermittedEthChainId = ( + caip25CaveatValue: Caip25CaveatValue, + chainId: Hex, +): Caip25CaveatValue => { + const scopeString = `eip155:${hexToBigInt(chainId).toString(10)}`; + if ( + Object.keys(caip25CaveatValue.requiredScopes).includes(scopeString) || + Object.keys(caip25CaveatValue.optionalScopes).includes(scopeString) + ) { + return caip25CaveatValue; + } + + return { + ...caip25CaveatValue, + optionalScopes: { + ...caip25CaveatValue.optionalScopes, + [scopeString]: { + accounts: [], + }, + }, + }; +}; + +/** + * Filters the scopes object to only include: + * - Scopes without references (e.g. "wallet:") + * - EIP155 scopes for the given chainIDs + * - Non EIP155 scopes (e.g. "bip122:" or any other non ethereum namespaces) + * + * @param scopesObject - The scopes object to filter. + * @param chainIds - The chainIDs to filter EIP155 scopes by. + * @returns The filtered scopes object. + */ +const filterEthScopesObjectByChainId = ( + scopesObject: InternalScopesObject, + chainIds: Hex[], +): InternalScopesObject => { + const updatedScopesObject: InternalScopesObject = {}; + + Object.entries(scopesObject).forEach(([key, scopeObject]) => { + // Cast needed because index type is returned as `string` by `Object.entries` + const scopeString = key as keyof typeof scopesObject; + const { namespace, reference } = parseScopeString(scopeString); + if (!reference) { + updatedScopesObject[scopeString] = scopeObject; + return; + } + if (namespace === KnownCaipNamespace.Eip155) { + const chainId = toHex(reference); + if (chainIds.includes(chainId)) { + updatedScopesObject[scopeString] = scopeObject; + } + } else { + updatedScopesObject[scopeString] = scopeObject; + } + }); + + return updatedScopesObject; +}; + +/** + * Sets the permitted Ethereum (EIP155 namespaced) chainIDs for the required and optional scopes. + * + * @param caip25CaveatValue - The CAIP-25 caveat value to set the permitted Ethereum chainIDs for. + * @param chainIds - The Ethereum chainIDs to set as permitted. + * @returns The updated CAIP-25 caveat value with the permitted Ethereum chainIDs. + */ +export const setPermittedEthChainIds = ( + caip25CaveatValue: Caip25CaveatValue, + chainIds: Hex[], +): Caip25CaveatValue => { + let updatedCaveatValue: Caip25CaveatValue = { + ...caip25CaveatValue, + requiredScopes: filterEthScopesObjectByChainId( + caip25CaveatValue.requiredScopes, + chainIds, + ), + optionalScopes: filterEthScopesObjectByChainId( + caip25CaveatValue.optionalScopes, + chainIds, + ), + }; + + chainIds.forEach((chainId) => { + updatedCaveatValue = addPermittedEthChainId(updatedCaveatValue, chainId); + }); + + return updatedCaveatValue; +}; + +/* + * + * + * GENERALIZED GETTERS AND SETTERS + * + * + */ + +/* + * + * GETTERS + * + */ + +/** + * Gets all scopes from a CAIP-25 caveat value + * + * @param scopesObjects - The scopes objects to get the scopes from. + * @returns An array of InternalScopeStrings. + */ +export function getAllScopesFromScopesObjects( + scopesObjects: InternalScopesObject[], +): InternalScopeString[] { + const scopeSet = new Set(); + + for (const scopeObject of scopesObjects) { + for (const key of Object.keys(scopeObject)) { + scopeSet.add(key as InternalScopeString); + } + } + + return Array.from(scopeSet); +} + +/** + * Gets all scopes (chain IDs) from a CAIP-25 caveat + * This extracts all scopes from both required and optional scopes + * and returns a unique set. + * + * @param caip25CaveatValue - The CAIP-25 caveat value to extract scopes from + * @returns Array of unique scope strings (chain IDs) + */ +export function getAllScopesFromCaip25CaveatValue( + caip25CaveatValue: Caip25CaveatValue, +): CaipChainId[] { + return getAllScopesFromScopesObjects([ + caip25CaveatValue.requiredScopes, + caip25CaveatValue.optionalScopes, + ]) as CaipChainId[]; +} + +/** + * Gets all non-wallet namespaces from a CAIP-25 caveat value + * This extracts all namespaces from both required and optional scopes + * and returns a unique set. + * + * @param caip25CaveatValue - The CAIP-25 caveat value to extract namespaces from + * @returns Array of unique namespace strings + */ +export function getAllNamespacesFromCaip25CaveatValue( + caip25CaveatValue: Caip25CaveatValue, +): CaipNamespace[] { + const allScopes = getAllScopesFromCaip25CaveatValue(caip25CaveatValue); + const namespaceSet = new Set(); + + for (const scope of allScopes) { + const { namespace, reference } = parseScopeString(scope); + if (namespace === KnownCaipNamespace.Wallet) { + namespaceSet.add(reference ?? namespace); + } else if (namespace) { + namespaceSet.add(namespace); + } + } + + return Array.from(namespaceSet); +} + +/** + * Gets all scopes (chain IDs) from a CAIP-25 permission + * This extracts all scopes from both required and optional scopes + * and returns a unique set. + * + * @param caip25Permission - The CAIP-25 permission object + * @param caip25Permission.caveats - The caveats of the CAIP-25 permission + * @returns Array of unique scope strings (chain IDs) + */ +export function getAllScopesFromPermission(caip25Permission: { + caveats: { + type: string; + value: Caip25CaveatValue; + }[]; +}): CaipChainId[] { + const caip25Caveat = caip25Permission.caveats.find( + (caveat) => caveat.type === Caip25CaveatType, + ); + if (!caip25Caveat) { + return []; + } + + return getAllScopesFromCaip25CaveatValue(caip25Caveat.value); +} + +/* + * + * SETTERS + * + */ + +/** + * Adds a chainID to the optional scopes if it is not already present + * in either the pre-existing required or optional scopes. + * + * @param caip25CaveatValue - The CAIP-25 caveat value to add the chainID to. + * @param chainId - The chainID to add. + * @returns The updated CAIP-25 caveat value with the added chainID. + */ +export const addCaipChainIdInCaip25CaveatValue = ( + caip25CaveatValue: Caip25CaveatValue, + chainId: CaipChainId, +): Caip25CaveatValue => { + if ( + caip25CaveatValue.requiredScopes[chainId] || + caip25CaveatValue.optionalScopes[chainId] + ) { + return caip25CaveatValue; + } + + return { + ...caip25CaveatValue, + optionalScopes: { + ...caip25CaveatValue.optionalScopes, + [chainId]: { + accounts: [], + }, + }, + }; +}; + +/** + * Sets the CAIP-2 chainIds for the required and optional scopes. + * If the caip25CaveatValue contains chainIds not in the chainIds array arg they are filtered out + * + * @param caip25CaveatValue - The CAIP-25 caveat value to set the permitted CAIP-2 chainIDs for. + * @param chainIds - The CAIP-2 chainIDs to set. + * @returns The updated CAIP-25 caveat value with the CAIP-2 chainIDs. + */ +export const setChainIdsInCaip25CaveatValue = ( + caip25CaveatValue: Caip25CaveatValue, + chainIds: CaipChainId[], +): Caip25CaveatValue => { + const chainIdSet = new Set(chainIds); + const result: Caip25CaveatValue = { + requiredScopes: {}, + optionalScopes: {}, + sessionProperties: caip25CaveatValue.sessionProperties, + isMultichainOrigin: caip25CaveatValue.isMultichainOrigin, + }; + + for (const [key, value] of Object.entries(caip25CaveatValue.requiredScopes)) { + const scopeString = key as keyof typeof caip25CaveatValue.requiredScopes; + if (isWalletScope(scopeString) || chainIdSet.has(scopeString)) { + result.requiredScopes[scopeString] = value; + } + } + + for (const [key, value] of Object.entries(caip25CaveatValue.optionalScopes)) { + const scopeString = key as keyof typeof caip25CaveatValue.optionalScopes; + if (isWalletScope(scopeString) || chainIdSet.has(scopeString)) { + result.optionalScopes[scopeString] = value; + } + } + + for (const chainId of chainIds) { + if (!result.requiredScopes[chainId] && !result.optionalScopes[chainId]) { + result.optionalScopes[chainId] = { accounts: [] }; + } + } + + return result; +}; diff --git a/packages/chain-agnostic-permission/src/operators/caip-permission-operator-session-scopes.test.ts b/packages/chain-agnostic-permission/src/operators/caip-permission-operator-session-scopes.test.ts new file mode 100644 index 00000000000..cbdc893698c --- /dev/null +++ b/packages/chain-agnostic-permission/src/operators/caip-permission-operator-session-scopes.test.ts @@ -0,0 +1,553 @@ +import type { CaipAccountId } from '@metamask/utils'; + +import { + KnownNotifications, + KnownRpcMethods, + KnownWalletNamespaceRpcMethods, + KnownWalletRpcMethods, +} from '../scope/constants.js'; +import { + getInternalScopesObject, + getPermittedAccountsForScopes, + getSessionProperties, + getSessionScopes, +} from './caip-permission-operator-session-scopes.js'; + +describe('CAIP-25 session scopes adapters', () => { + describe('getInternalScopesObject', () => { + it('returns an InternalScopesObject with only the accounts from each NormalizedScopeObject', () => { + const result = getInternalScopesObject({ + 'wallet:eip155': { + methods: ['foo', 'bar'], + notifications: ['baz'], + accounts: ['wallet:eip155:0xdead'], + }, + 'eip155:1': { + methods: ['eth_call'], + notifications: ['eth_subscription'], + accounts: ['eip155:1:0xdead', 'eip155:1:0xbeef'], + }, + }); + + expect(result).toStrictEqual({ + 'wallet:eip155': { + accounts: ['wallet:eip155:0xdead'], + }, + 'eip155:1': { + accounts: ['eip155:1:0xdead', 'eip155:1:0xbeef'], + }, + }); + }); + }); + + describe('getSessionScopes', () => { + const getNonEvmSupportedMethods = jest.fn(); + const mockSortAccountIdsByLastSelected = jest.fn(); + + it('returns a NormalizedScopesObject for the wallet scope', () => { + const result = getSessionScopes( + { + requiredScopes: {}, + optionalScopes: { + wallet: { + accounts: [], + }, + }, + sessionProperties: {}, + }, + { + getNonEvmSupportedMethods, + }, + ); + + expect(result).toStrictEqual({ + wallet: { + methods: KnownWalletRpcMethods, + notifications: [], + accounts: [], + }, + }); + }); + + it('returns a NormalizedScopesObject for the wallet:eip155 scope', () => { + const result = getSessionScopes( + { + requiredScopes: {}, + optionalScopes: { + 'wallet:eip155': { + accounts: ['wallet:eip155:0xdeadbeef'], + }, + }, + sessionProperties: {}, + }, + { + getNonEvmSupportedMethods, + }, + ); + + expect(result).toStrictEqual({ + 'wallet:eip155': { + methods: KnownWalletNamespaceRpcMethods.eip155, + notifications: [], + accounts: ['wallet:eip155:0xdeadbeef'], + }, + }); + }); + + it('gets methods from getNonEvmSupportedMethods for scope with wallet namespace and non-evm reference', () => { + getNonEvmSupportedMethods.mockReturnValue(['nonEvmMethod']); + + getSessionScopes( + { + requiredScopes: {}, + optionalScopes: { + 'wallet:foobar': { + accounts: ['wallet:foobar:0xdeadbeef'], + }, + }, + sessionProperties: {}, + }, + { + getNonEvmSupportedMethods, + }, + ); + + expect(getNonEvmSupportedMethods).toHaveBeenCalledWith('wallet:foobar'); + }); + + it('returns a NormalizedScopesObject with methods from getNonEvmSupportedMethods and empty notifications for scope with wallet namespace and non-evm reference', () => { + getNonEvmSupportedMethods.mockReturnValue(['nonEvmMethod']); + + const result = getSessionScopes( + { + requiredScopes: {}, + optionalScopes: { + 'wallet:foobar': { + accounts: ['wallet:foobar:0xdeadbeef'], + }, + }, + sessionProperties: {}, + }, + { + getNonEvmSupportedMethods, + }, + ); + + expect(result).toStrictEqual({ + 'wallet:foobar': { + methods: ['nonEvmMethod'], + notifications: [], + accounts: ['wallet:foobar:0xdeadbeef'], + }, + }); + }); + + it('gets methods from getNonEvmSupportedMethods for non-evm (not `eip155`, `wallet` or `wallet:eip155`) scopes', () => { + getNonEvmSupportedMethods.mockReturnValue(['nonEvmMethod']); + + getSessionScopes( + { + requiredScopes: {}, + optionalScopes: { + 'foo:1': { + accounts: ['foo:1:0xdeadbeef'], + }, + }, + sessionProperties: {}, + }, + { + getNonEvmSupportedMethods, + }, + ); + + expect(getNonEvmSupportedMethods).toHaveBeenCalledWith('foo:1'); + }); + + it('returns a NormalizedScopesObject with methods from getNonEvmSupportedMethods and empty notifications for scope non-evm namespace', () => { + getNonEvmSupportedMethods.mockReturnValue(['nonEvmMethod']); + + const result = getSessionScopes( + { + requiredScopes: {}, + optionalScopes: { + 'foo:1': { + accounts: ['foo:1:0xdeadbeef'], + }, + }, + sessionProperties: {}, + }, + { + getNonEvmSupportedMethods, + }, + ); + + expect(result).toStrictEqual({ + 'foo:1': { + methods: ['nonEvmMethod'], + notifications: [], + accounts: ['foo:1:0xdeadbeef'], + }, + }); + }); + + it('returns a NormalizedScopesObject for a eip155 namespaced scope', () => { + const result = getSessionScopes( + { + requiredScopes: {}, + optionalScopes: { + 'eip155:1': { + accounts: ['eip155:1:0xdeadbeef'], + }, + }, + sessionProperties: {}, + }, + { + getNonEvmSupportedMethods, + }, + ); + + expect(result).toStrictEqual({ + 'eip155:1': { + methods: KnownRpcMethods.eip155, + notifications: KnownNotifications.eip155, + accounts: ['eip155:1:0xdeadbeef'], + }, + }); + }); + + it('sorts accounts using sortAccountIdsByLastSelected when provided', () => { + const unsortedAccounts: CaipAccountId[] = [ + 'eip155:1:0xbeef', + 'eip155:1:0xdead', + ]; + const sortedAccounts: CaipAccountId[] = [ + 'eip155:1:0xdead', + 'eip155:1:0xbeef', + ]; + + mockSortAccountIdsByLastSelected.mockReturnValue(sortedAccounts); + + const result = getSessionScopes( + { + requiredScopes: { + 'eip155:1': { + accounts: unsortedAccounts, + }, + }, + optionalScopes: {}, + sessionProperties: {}, + }, + { + getNonEvmSupportedMethods, + sortAccountIdsByLastSelected: mockSortAccountIdsByLastSelected, + }, + ); + + expect(mockSortAccountIdsByLastSelected).toHaveBeenCalledWith( + unsortedAccounts, + ); + expect(result).toStrictEqual({ + 'eip155:1': { + methods: KnownRpcMethods.eip155, + notifications: KnownNotifications.eip155, + accounts: sortedAccounts, + }, + }); + }); + + it('does not sort accounts when sortAccountIdsByLastSelected is not provided', () => { + const accounts: CaipAccountId[] = ['eip155:1:0xbeef', 'eip155:1:0xdead']; + + const result = getSessionScopes( + { + requiredScopes: { + 'eip155:1': { + accounts, + }, + }, + optionalScopes: {}, + sessionProperties: {}, + }, + { + getNonEvmSupportedMethods, + }, + ); + + expect(mockSortAccountIdsByLastSelected).not.toHaveBeenCalled(); + expect(result).toStrictEqual({ + 'eip155:1': { + methods: KnownRpcMethods.eip155, + notifications: KnownNotifications.eip155, + accounts, // Original order preserved + }, + }); + }); + + it('sorts accounts in both required and optional scopes', () => { + const unsortedAccounts1: CaipAccountId[] = [ + 'eip155:1:0xbeef', + 'eip155:1:0xdead', + ]; + const unsortedAccounts2: CaipAccountId[] = [ + 'eip155:137:0xcafe', + 'eip155:137:0xbabe', + ]; + const sortedAccounts1: CaipAccountId[] = [ + 'eip155:1:0xdead', + 'eip155:1:0xbeef', + ]; + const sortedAccounts2: CaipAccountId[] = [ + 'eip155:137:0xbabe', + 'eip155:137:0xcafe', + ]; + + mockSortAccountIdsByLastSelected + .mockReturnValueOnce(sortedAccounts1) + .mockReturnValueOnce(sortedAccounts2); + + const result = getSessionScopes( + { + requiredScopes: { + 'eip155:1': { + accounts: unsortedAccounts1, + }, + }, + optionalScopes: { + 'eip155:137': { + accounts: unsortedAccounts2, + }, + }, + sessionProperties: {}, + }, + { + getNonEvmSupportedMethods, + sortAccountIdsByLastSelected: mockSortAccountIdsByLastSelected, + }, + ); + + expect(mockSortAccountIdsByLastSelected).toHaveBeenCalledTimes(2); + expect(mockSortAccountIdsByLastSelected).toHaveBeenNthCalledWith( + 1, + unsortedAccounts1, + ); + expect(mockSortAccountIdsByLastSelected).toHaveBeenNthCalledWith( + 2, + unsortedAccounts2, + ); + expect(result).toStrictEqual({ + 'eip155:1': { + methods: KnownRpcMethods.eip155, + notifications: KnownNotifications.eip155, + accounts: sortedAccounts1, + }, + 'eip155:137': { + methods: KnownRpcMethods.eip155, + notifications: KnownNotifications.eip155, + accounts: sortedAccounts2, + }, + }); + }); + }); + + describe('getSessionProperties', () => { + it('returns the persisted session properties merged with an empty eip155Capabilities record when there are no permitted accounts', async () => { + const getCapabilities = jest.fn(); + + const result = await getSessionProperties( + { + requiredScopes: {}, + optionalScopes: {}, + sessionProperties: { 'eip1193-compatible': true }, + }, + { + getCapabilities, + }, + ); + + expect(getCapabilities).not.toHaveBeenCalled(); + expect(result).toStrictEqual({ + 'eip1193-compatible': true, + eip155Capabilities: {}, + }); + }); + + it('calls getCapabilities with each unique permitted EVM address', async () => { + const getCapabilities = jest + .fn() + .mockResolvedValue({ '0x1': { atomic: { status: 'supported' } } }); + + await getSessionProperties( + { + requiredScopes: { + 'eip155:1': { + accounts: ['eip155:1:0xdead'], + }, + }, + optionalScopes: { + 'eip155:137': { + accounts: ['eip155:137:0xdead', 'eip155:137:0xbeef'], + }, + }, + sessionProperties: {}, + }, + { + getCapabilities, + }, + ); + + expect(getCapabilities).toHaveBeenCalledTimes(2); + expect(getCapabilities).toHaveBeenCalledWith({ address: '0xdead' }); + expect(getCapabilities).toHaveBeenCalledWith({ address: '0xbeef' }); + }); + + it('returns the session properties with an eip155Capabilities record keyed by address', async () => { + const getCapabilities = jest.fn().mockResolvedValue({ + '0x1': { atomic: { status: 'supported' } }, + }); + + const result = await getSessionProperties( + { + requiredScopes: { + 'eip155:1': { + accounts: ['eip155:1:0xdead'], + }, + }, + optionalScopes: {}, + sessionProperties: { expiry: '2025-01-01T00:00:00.000Z' }, + }, + { + getCapabilities, + }, + ); + + expect(result).toStrictEqual({ + expiry: '2025-01-01T00:00:00.000Z', + eip155Capabilities: { + '0xdead': { + '0x1': { atomic: { status: 'supported' } }, + }, + }, + }); + }); + + it('does not call getCapabilities for non-EVM accounts', async () => { + const getCapabilities = jest.fn(); + + const result = await getSessionProperties( + { + requiredScopes: {}, + optionalScopes: { + 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp': { + accounts: [ + 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp:DdpL8XNK9hSn8m6ycGAQvBwHJVgVz9eL5tWGtZ8L', + ], + }, + }, + sessionProperties: {}, + }, + { + getCapabilities, + }, + ); + + expect(getCapabilities).not.toHaveBeenCalled(); + expect(result).toStrictEqual({ eip155Capabilities: {} }); + }); + + it('logs an error and omits the address when getCapabilities rejects', async () => { + const consoleErrorSpy = jest + .spyOn(console, 'error') + .mockImplementation(() => undefined); + const error = new Error('failed'); + const getCapabilities = jest + .fn() + .mockResolvedValueOnce({ '0x1': { atomic: { status: 'supported' } } }) + .mockRejectedValueOnce(error); + + const result = await getSessionProperties( + { + requiredScopes: { + 'eip155:1': { + accounts: ['eip155:1:0xdead'], + }, + }, + optionalScopes: { + 'eip155:137': { + accounts: ['eip155:137:0xbeef'], + }, + }, + sessionProperties: {}, + }, + { + getCapabilities, + }, + ); + + expect(result).toStrictEqual({ + eip155Capabilities: { + '0xdead': { '0x1': { atomic: { status: 'supported' } } }, + }, + }); + expect(consoleErrorSpy).toHaveBeenCalledWith( + `Error getting capabilities for address 0xbeef: ${error}`, + ); + + consoleErrorSpy.mockRestore(); + }); + }); + + describe('getPermittedAccountsForScopes', () => { + it('returns an array of permitted accounts for a given scope', () => { + const result = getPermittedAccountsForScopes( + { + requiredScopes: {}, + optionalScopes: { + 'wallet:eip155': { + accounts: ['wallet:eip155:0xdeadbeef'], + }, + }, + }, + ['wallet:eip155'], + ); + + expect(result).toStrictEqual(['wallet:eip155:0xdeadbeef']); + }); + + it('returns an empty array if the scope does not exist', () => { + const result = getPermittedAccountsForScopes( + { requiredScopes: {}, optionalScopes: {} }, + ['wallet:eip155'], + ); + expect(result).toStrictEqual([]); + }); + + it('returns an empty array if the scope does not have any accounts', () => { + const result = getPermittedAccountsForScopes( + { + requiredScopes: { + 'wallet:eip155': { + accounts: [], + }, + }, + optionalScopes: {}, + }, + ['wallet:eip155'], + ); + expect(result).toStrictEqual([]); + }); + }); + it('returns an array of permitted accounts for multiple scopes and deduplicates accounts', () => { + const result = getPermittedAccountsForScopes( + { + requiredScopes: { + 'wallet:eip155': { accounts: ['wallet:eip155:0xdeadbeef'] }, + }, + optionalScopes: { + 'wallet:eip155': { accounts: ['wallet:eip155:0xdeadbeef'] }, + }, + }, + ['wallet:eip155'], + ); + expect(result).toStrictEqual(['wallet:eip155:0xdeadbeef']); + }); +}); diff --git a/packages/chain-agnostic-permission/src/operators/caip-permission-operator-session-scopes.ts b/packages/chain-agnostic-permission/src/operators/caip-permission-operator-session-scopes.ts new file mode 100644 index 00000000000..c2ca2b7d8da --- /dev/null +++ b/packages/chain-agnostic-permission/src/operators/caip-permission-operator-session-scopes.ts @@ -0,0 +1,220 @@ +import { isCaipChainId, KnownCaipNamespace } from '@metamask/utils'; +import type { CaipAccountId, CaipChainId, Hex, Json } from '@metamask/utils'; + +import type { Caip25CaveatValue } from '../caip25Permission.js'; +import { + KnownNotifications, + KnownRpcMethods, + KnownWalletNamespaceRpcMethods, + KnownWalletRpcMethods, +} from '../scope/constants.js'; +import { mergeNormalizedScopes } from '../scope/transform.js'; +import type { + InternalScopesObject, + NormalizedScopesObject, +} from '../scope/types.js'; +import { parseScopeString } from '../scope/types.js'; +import { getEthAccounts } from './caip-permission-operator-accounts.js'; + +/** + * Converts an NormalizedScopesObject to a InternalScopesObject. + * + * @param normalizedScopesObject - The NormalizedScopesObject to convert. + * @returns An InternalScopesObject. + */ +export const getInternalScopesObject = ( + normalizedScopesObject: NormalizedScopesObject, +) => { + const internalScopes: InternalScopesObject = {}; + + Object.entries(normalizedScopesObject).forEach( + ([_scopeString, { accounts }]) => { + const scopeString = _scopeString as keyof typeof normalizedScopesObject; + + internalScopes[scopeString] = { + accounts, + }; + }, + ); + + return internalScopes; +}; + +/** + * Converts an InternalScopesObject to a NormalizedScopesObject. + * + * @param internalScopesObject - The InternalScopesObject to convert. + * @param hooks - An object containing the following properties: + * @param hooks.getNonEvmSupportedMethods - A function that returns the supported methods for a non EVM scope. + * @returns A NormalizedScopesObject. + */ +const getNormalizedScopesObject = ( + internalScopesObject: InternalScopesObject, + { + getNonEvmSupportedMethods, + }: { + getNonEvmSupportedMethods: (scope: CaipChainId) => string[]; + }, +) => { + const normalizedScopes: NormalizedScopesObject = {}; + + Object.entries(internalScopesObject).forEach( + ([_scopeString, { accounts }]) => { + const scopeString = _scopeString as keyof typeof internalScopesObject; + const { namespace, reference } = parseScopeString(scopeString); + let methods: string[] = []; + let notifications: string[] = []; + + if ( + scopeString === KnownCaipNamespace.Wallet || + namespace === KnownCaipNamespace.Wallet + ) { + if (reference === KnownCaipNamespace.Eip155) { + methods = KnownWalletNamespaceRpcMethods[reference]; + } else if (isCaipChainId(scopeString)) { + methods = getNonEvmSupportedMethods(scopeString); + } else { + methods = KnownWalletRpcMethods; + } + } else if (namespace === KnownCaipNamespace.Eip155) { + methods = KnownRpcMethods[namespace]; + notifications = KnownNotifications[namespace]; + } else { + methods = getNonEvmSupportedMethods(scopeString); + notifications = []; + } + + normalizedScopes[scopeString] = { + methods, + notifications, + accounts, + }; + }, + ); + + return normalizedScopes; +}; + +/** + * Takes the scopes from an endowment:caip25 permission caveat value, + * hydrates them with supported methods and notifications, and returns a NormalizedScopesObject. + * + * @param caip25CaveatValue - The CAIP-25 CaveatValue to convert. + * @param hooks - An object containing the following properties: + * @param hooks.getNonEvmSupportedMethods - A function that returns the supported methods for a non EVM scope. + * @param [hooks.sortAccountIdsByLastSelected] - Optional function that accepts an array of CaipAccountId and returns an array of CaipAccountId sorted by last selected. + * @returns A NormalizedScopesObject. + */ +export const getSessionScopes = ( + caip25CaveatValue: Pick< + Caip25CaveatValue, + 'requiredScopes' | 'optionalScopes' + >, + { + getNonEvmSupportedMethods, + sortAccountIdsByLastSelected, + }: { + getNonEvmSupportedMethods: (scope: CaipChainId) => string[]; + sortAccountIdsByLastSelected?: ( + accounts: CaipAccountId[], + ) => CaipAccountId[]; + }, +) => { + const mergedScopes = mergeNormalizedScopes( + getNormalizedScopesObject(caip25CaveatValue.requiredScopes, { + getNonEvmSupportedMethods, + }), + getNormalizedScopesObject(caip25CaveatValue.optionalScopes, { + getNonEvmSupportedMethods, + }), + ); + + if (sortAccountIdsByLastSelected) { + Object.keys(mergedScopes).forEach((scopeString) => { + const scope = scopeString as keyof typeof mergedScopes; + const scopeObject = mergedScopes[scope]; + if (scopeObject) { + scopeObject.accounts = sortAccountIdsByLastSelected( + scopeObject.accounts, + ); + } + }); + } + + return mergedScopes; +}; + +/** + * Builds the session properties for an endowment:caip25 permission caveat value, + * hydrating the persisted session properties with an `eip155Capabilities` record + * that maps each permitted EVM account address to its per-chain capabilities. + * + * @param caip25CaveatValue - The CAIP-25 CaveatValue to get the session properties from. + * @param hooks - An object containing the following properties: + * @param hooks.getCapabilities - A function that resolves the per-chain capabilities for a given address. + * @returns A promise that resolves to the session properties merged with an `eip155Capabilities` record keyed by account address. + */ +export const getSessionProperties = async ( + caip25CaveatValue: Pick< + Caip25CaveatValue, + 'requiredScopes' | 'optionalScopes' | 'sessionProperties' + >, + { + getCapabilities, + }: { + getCapabilities: (params: { + address: string; + }) => Promise>>; + }, +): Promise> => { + const addresses = getEthAccounts(caip25CaveatValue); + + const eip155Capabilities: Record> = {}; + + await Promise.all( + addresses.map(async (address) => { + try { + eip155Capabilities[address] = await getCapabilities({ address }); + } catch (error) { + console.error( + `Error getting capabilities for address ${address}: ${String(error)}`, + ); + } + }), + ); + + return { + ...caip25CaveatValue.sessionProperties, + eip155Capabilities, + }; +}; + +/** + * Get the permitted accounts for the given scopes. + * + * @param caip25CaveatValue - The CAIP-25 CaveatValue to get the permitted accounts for + * @param scopes - The scopes to get the permitted accounts for + * @returns An array of permitted accounts + */ +export const getPermittedAccountsForScopes = ( + caip25CaveatValue: Pick< + Caip25CaveatValue, + 'requiredScopes' | 'optionalScopes' + >, + scopes: CaipChainId[], +): CaipAccountId[] => { + const scopeAccounts: CaipAccountId[] = []; + + scopes.forEach((scope) => { + const requiredScope = caip25CaveatValue.requiredScopes[scope]; + const optionalScope = caip25CaveatValue.optionalScopes[scope]; + if (requiredScope) { + scopeAccounts.push(...requiredScope.accounts); + } + + if (optionalScope) { + scopeAccounts.push(...optionalScope.accounts); + } + }); + return [...new Set(scopeAccounts)]; +}; diff --git a/packages/chain-agnostic-permission/src/scope/assert.test.ts b/packages/chain-agnostic-permission/src/scope/assert.test.ts new file mode 100644 index 00000000000..2f011a59d88 --- /dev/null +++ b/packages/chain-agnostic-permission/src/scope/assert.test.ts @@ -0,0 +1,627 @@ +import * as Utils from '@metamask/utils'; + +import { + assertScopeSupported, + assertScopesSupported, + assertIsExternalScopesObject, + assertIsInternalScopesObject, + assertIsInternalScopeString, +} from './assert.js'; +import { Caip25Errors } from './errors.js'; +import * as Supported from './supported.js'; +import type { NormalizedScopeObject } from './types.js'; + +jest.mock('./supported', () => ({ + isSupportedScopeString: jest.fn(), + isSupportedNotification: jest.fn(), + isSupportedMethod: jest.fn(), +})); + +jest.mock('@metamask/utils', () => ({ + ...jest.requireActual('@metamask/utils'), + isCaipChainId: jest.fn(), + isCaipReference: jest.fn(), + isCaipAccountId: jest.fn(), +})); + +const MockSupported = jest.mocked(Supported); +const MockUtils = jest.mocked(Utils); + +const validScopeObject: NormalizedScopeObject = { + methods: [], + notifications: [], + accounts: [], +}; + +describe('Scope Assert', () => { + beforeEach(() => { + MockUtils.isCaipChainId.mockImplementation(() => true); + MockUtils.isCaipReference.mockImplementation(() => true); + MockUtils.isCaipAccountId.mockImplementation(() => true); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + describe('assertScopeSupported', () => { + const isEvmChainIdSupported = jest.fn(); + const isNonEvmScopeSupported = jest.fn(); + const getNonEvmSupportedMethods = jest.fn(); + + describe('scopeString', () => { + it('checks if the scopeString is supported', () => { + try { + assertScopeSupported('scopeString', validScopeObject, { + isEvmChainIdSupported, + isNonEvmScopeSupported, + getNonEvmSupportedMethods, + }); + } catch { + // noop + } + expect(MockSupported.isSupportedScopeString).toHaveBeenCalledWith( + 'scopeString', + { isEvmChainIdSupported, isNonEvmScopeSupported }, + ); + }); + + it('throws an error if the scopeString is not supported', () => { + MockSupported.isSupportedScopeString.mockReturnValue(false); + expect(() => { + assertScopeSupported('scopeString', validScopeObject, { + isEvmChainIdSupported, + isNonEvmScopeSupported, + getNonEvmSupportedMethods, + }); + }).toThrow(Caip25Errors.requestedChainsNotSupportedError()); + }); + }); + + describe('scopeObject', () => { + beforeEach(() => { + MockSupported.isSupportedScopeString.mockReturnValue(true); + }); + + it('checks if the methods are supported', () => { + try { + assertScopeSupported( + 'scopeString', + { + ...validScopeObject, + methods: ['eth_chainId'], + }, + { + isEvmChainIdSupported, + isNonEvmScopeSupported, + getNonEvmSupportedMethods, + }, + ); + } catch { + // noop + } + + expect(MockSupported.isSupportedMethod).toHaveBeenCalledWith( + 'scopeString', + 'eth_chainId', + { + getNonEvmSupportedMethods, + }, + ); + }); + + it('throws an error if there are unsupported methods', () => { + MockSupported.isSupportedMethod.mockReturnValue(false); + expect(() => { + assertScopeSupported( + 'scopeString', + { + ...validScopeObject, + methods: ['eth_chainId'], + }, + { + isEvmChainIdSupported, + isNonEvmScopeSupported, + getNonEvmSupportedMethods, + }, + ); + }).toThrow(Caip25Errors.requestedMethodsNotSupportedError()); + }); + + it('checks if the notifications are supported', () => { + MockSupported.isSupportedMethod.mockReturnValue(true); + try { + assertScopeSupported( + 'scopeString', + { + ...validScopeObject, + notifications: ['chainChanged'], + }, + { + isEvmChainIdSupported, + isNonEvmScopeSupported, + getNonEvmSupportedMethods, + }, + ); + } catch { + // noop + } + + expect(MockSupported.isSupportedNotification).toHaveBeenCalledWith( + 'scopeString', + 'chainChanged', + ); + }); + + it('throws an error if there are unsupported notifications', () => { + MockSupported.isSupportedMethod.mockReturnValue(true); + MockSupported.isSupportedNotification.mockReturnValue(false); + expect(() => { + assertScopeSupported( + 'scopeString', + { + ...validScopeObject, + notifications: ['chainChanged'], + }, + { + isEvmChainIdSupported, + isNonEvmScopeSupported, + getNonEvmSupportedMethods, + }, + ); + }).toThrow(Caip25Errors.requestedNotificationsNotSupportedError()); + }); + + it('does not throw if the scopeObject is valid', () => { + MockSupported.isSupportedMethod.mockReturnValue(true); + MockSupported.isSupportedNotification.mockReturnValue(true); + expect( + assertScopeSupported( + 'scopeString', + { + ...validScopeObject, + methods: ['eth_chainId'], + notifications: ['chainChanged'], + accounts: ['eip155:1:0xdeadbeef'], + }, + { + isEvmChainIdSupported, + isNonEvmScopeSupported, + getNonEvmSupportedMethods, + }, + ), + ).toBeUndefined(); + }); + }); + }); + + describe('assertScopesSupported', () => { + const isEvmChainIdSupported = jest.fn(); + const isNonEvmScopeSupported = jest.fn(); + const getNonEvmSupportedMethods = jest.fn(); + + it('does not throw an error if no scopes are defined', () => { + expect( + assertScopesSupported( + {}, + { + isEvmChainIdSupported, + isNonEvmScopeSupported, + getNonEvmSupportedMethods, + }, + ), + ).toBeUndefined(); + }); + + it('throws an error if any scope is invalid', () => { + MockSupported.isSupportedScopeString.mockReturnValue(false); + + expect(() => { + assertScopesSupported( + { + 'eip155:1': validScopeObject, + }, + { + isEvmChainIdSupported, + isNonEvmScopeSupported, + getNonEvmSupportedMethods, + }, + ); + }).toThrow(Caip25Errors.requestedChainsNotSupportedError()); + }); + + it('does not throw an error if all scopes are valid', () => { + MockSupported.isSupportedScopeString.mockReturnValue(true); + + expect( + assertScopesSupported( + { + 'eip155:1': validScopeObject, + 'eip155:2': validScopeObject, + }, + { + isEvmChainIdSupported, + isNonEvmScopeSupported, + getNonEvmSupportedMethods, + }, + ), + ).toBeUndefined(); + }); + }); + + describe('assertIsExternalScopesObject', () => { + it('does not throw if passed obj is a valid ExternalScopesObject with all valid properties', () => { + const obj = { + 'eip155:1': { + references: ['reference1', 'reference2'], + accounts: ['eip155:1:0x1234'], + methods: ['method1', 'method2'], + notifications: ['notification1'], + rpcDocuments: ['doc1'], + rpcEndpoints: ['endpoint1'], + }, + }; + expect(() => assertIsExternalScopesObject(obj)).not.toThrow(); + }); + + it('does not throw if passed obj is a valid ExternalScopesObject with some optional properties missing', () => { + const obj = { + accounts: ['eip155:1:0x1234'], + methods: ['method1'], + }; + expect(() => assertIsExternalScopesObject(obj)).not.toThrow(); + }); + + it('throws an error if passed obj is not an object', () => { + expect(() => assertIsExternalScopesObject(null)).toThrow( + 'ExternalScopesObject must be an object', + ); + expect(() => assertIsExternalScopesObject(123)).toThrow( + 'ExternalScopesObject must be an object', + ); + expect(() => assertIsExternalScopesObject('string')).toThrow( + 'ExternalScopesObject must be an object', + ); + }); + + it('throws and error if passed an object with an ExternalScopeObject value that is not an object', () => { + expect(() => assertIsExternalScopesObject({ 'eip155:1': 123 })).toThrow( + 'ExternalScopeObject must be an object', + ); + }); + + it('throws an error if passed an object with a key that is not a valid ExternalScopeString', () => { + MockUtils.isCaipChainId.mockReturnValue(false); + + expect(() => + assertIsExternalScopesObject({ 'invalid-scope-string': {} }), + ).toThrow('scopeString is not a valid ExternalScopeString'); + }); + + it('throws an error if passed an object with an ExternalScopeObject with a references property that is not an array', () => { + const invalidExternalScopeObject = { + 'eip155:1': { + references: 'not-an-array', + accounts: ['eip155:1:0x1234'], + methods: ['method1', 'method2'], + notifications: ['notification1'], + rpcDocuments: ['doc1'], + rpcEndpoints: ['endpoint1'], + }, + }; + expect(() => + assertIsExternalScopesObject(invalidExternalScopeObject), + ).toThrow( + 'ExternalScopeObject.references must be an array of CaipReference', + ); + }); + + it('throws an error if references contains invalid CaipReference', () => { + const invalidExternalScopeObject = { + 'eip155:1': { + references: ['invalidRef'], + accounts: ['eip155:1:0x1234'], + methods: ['method1', 'method2'], + notifications: ['notification1'], + rpcDocuments: ['doc1'], + rpcEndpoints: ['endpoint1'], + }, + }; + jest + .spyOn(Utils, 'isCaipReference') + .mockImplementation((ref) => ref !== 'invalidRef'); + + expect(() => + assertIsExternalScopesObject(invalidExternalScopeObject), + ).toThrow( + 'ExternalScopeObject.references must be an array of CaipReference', + ); + }); + + it('throws an error if passed an object with an ExternalScopeObject with an accounts property that is not an array', () => { + const invalidExternalScopeObject = { + 'eip155:1': { + references: ['reference1', 'reference2'], + accounts: 'not-an-array', + methods: ['method1', 'method2'], + notifications: ['notification1'], + rpcDocuments: ['doc1'], + rpcEndpoints: ['endpoint1'], + }, + }; + expect(() => + assertIsExternalScopesObject(invalidExternalScopeObject), + ).toThrow( + 'ExternalScopeObject.accounts must be an array of CaipAccountId', + ); + }); + + it('throws an error if accounts contains invalid CaipAccountId', () => { + const invalidExternalScopeObject = { + 'eip155:1': { + references: ['reference1', 'reference2'], + accounts: ['eip155:1:0x1234', 'invalidAccount'], + methods: ['method1', 'method2'], + notifications: ['notification1'], + rpcDocuments: ['doc1'], + rpcEndpoints: ['endpoint1'], + }, + }; + MockUtils.isCaipAccountId.mockImplementation( + (id) => id !== 'invalidAccount', + ); + expect(() => + assertIsExternalScopesObject(invalidExternalScopeObject), + ).toThrow( + 'ExternalScopeObject.accounts must be an array of CaipAccountId', + ); + }); + + it('throws an error if passed an object with an ExternalScopeObject with a methods property that is not an array', () => { + const invalidExternalScopeObject = { + 'eip155:1': { + references: ['reference1', 'reference2'], + accounts: ['eip155:1:0x1234'], + methods: 'not-an-array', + notifications: ['notification1'], + rpcDocuments: ['doc1'], + rpcEndpoints: ['endpoint1'], + }, + }; + expect(() => + assertIsExternalScopesObject(invalidExternalScopeObject), + ).toThrow('ExternalScopeObject.methods must be an array of strings'); + }); + + it('throws an error if methods contains non-string elements', () => { + const invalidExternalScopeObject = { + 'eip155:1': { + references: ['reference1', 'reference2'], + accounts: ['eip155:1:0x1234'], + methods: ['method1', 123], + notifications: ['notification1'], + rpcDocuments: ['doc1'], + rpcEndpoints: ['endpoint1'], + }, + }; + expect(() => + assertIsExternalScopesObject(invalidExternalScopeObject), + ).toThrow('ExternalScopeObject.methods must be an array of strings'); + }); + + it('throws an error if passed an object with an ExternalScopeObject with a notifications property that is not an array', () => { + const invalidExternalScopeObject = { + 'eip155:1': { + references: ['reference1', 'reference2'], + accounts: ['eip155:1:0x1234'], + methods: ['method1', 'method2'], + notifications: 'not-an-array', + rpcDocuments: ['doc1'], + rpcEndpoints: ['endpoint1'], + }, + }; + expect(() => + assertIsExternalScopesObject(invalidExternalScopeObject), + ).toThrow( + 'ExternalScopeObject.notifications must be an array of strings', + ); + }); + + it('throws an error if notifications contains non-string elements', () => { + const invalidExternalScopeObject = { + 'eip155:1': { + references: ['reference1', 'reference2'], + accounts: ['eip155:1:0x1234'], + methods: ['method1', 'method2'], + notifications: ['notification1', false], + rpcDocuments: ['doc1'], + rpcEndpoints: ['endpoint1'], + }, + }; + expect(() => + assertIsExternalScopesObject(invalidExternalScopeObject), + ).toThrow( + 'ExternalScopeObject.notifications must be an array of strings', + ); + }); + + it('throws an error if passed an object with an ExternalScopeObject with a rpcDocuments property that is not an array', () => { + const invalidExternalScopeObject = { + 'eip155:1': { + references: ['reference1', 'reference2'], + accounts: ['eip155:1:0x1234'], + methods: ['method1', 'method2'], + notifications: ['notification1'], + rpcDocuments: 'not-an-array', + rpcEndpoints: ['endpoint1'], + }, + }; + expect(() => + assertIsExternalScopesObject(invalidExternalScopeObject), + ).toThrow('ExternalScopeObject.rpcDocuments must be an array of strings'); + }); + + it('throws an error if rpcDocuments contains non-string elements', () => { + const invalidExternalScopeObject = { + 'eip155:1': { + references: ['reference1', 'reference2'], + accounts: ['eip155:1:0x1234'], + methods: ['method1', 'method2'], + notifications: ['notification1'], + rpcDocuments: ['doc1', 456], + rpcEndpoints: ['endpoint1'], + }, + }; + expect(() => + assertIsExternalScopesObject(invalidExternalScopeObject), + ).toThrow('ExternalScopeObject.rpcDocuments must be an array of strings'); + }); + + it('throws an error if passed an object with an ExternalScopeObject with a rpcEndpoints property that is not an array', () => { + const invalidExternalScopeObject = { + 'eip155:1': { + references: ['reference1', 'reference2'], + accounts: ['eip155:1:0x1234'], + methods: ['method1', 'method2'], + notifications: ['notification1'], + rpcDocuments: ['doc1'], + rpcEndpoints: 'not-an-array', + }, + }; + expect(() => + assertIsExternalScopesObject(invalidExternalScopeObject), + ).toThrow('ExternalScopeObject.rpcEndpoints must be an array of strings'); + }); + + it('throws an error if passed an object with an ExternalScopeObject with a rpcEndpoints property that contains non-string elements', () => { + const invalidExternalScopeObject = { + 'eip155:1': { + references: ['reference1', 'reference2'], + accounts: ['eip155:1:0x1234'], + methods: ['method1', 'method2'], + notifications: ['notification1'], + rpcDocuments: ['doc1'], + rpcEndpoints: ['endpoint1', null], + }, + }; + expect(() => + assertIsExternalScopesObject(invalidExternalScopeObject), + ).toThrow('ExternalScopeObject.rpcEndpoints must be an array of strings'); + }); + }); + + describe('assertIsInternalScopeString', () => { + it('throws an error if the value is not a string', () => { + expect(() => assertIsInternalScopeString({})).toThrow( + 'scopeString is not a valid InternalScopeString', + ); + expect(() => assertIsInternalScopeString(123)).toThrow( + 'scopeString is not a valid InternalScopeString', + ); + expect(() => assertIsInternalScopeString(undefined)).toThrow( + 'scopeString is not a valid InternalScopeString', + ); + expect(() => assertIsInternalScopeString(null)).toThrow( + 'scopeString is not a valid InternalScopeString', + ); + }); + + it("does not throw an error if the value is 'wallet'", () => { + expect(assertIsInternalScopeString('wallet')).toBeUndefined(); + expect(MockUtils.isCaipChainId).not.toHaveBeenCalled(); + }); + + it('does not throw an error if the value is a valid CAIP-2 Chain ID', () => { + MockUtils.isCaipChainId.mockReturnValue(true); + + expect(assertIsInternalScopeString('scopeString')).toBeUndefined(); + expect(MockUtils.isCaipChainId).toHaveBeenCalledWith('scopeString'); + }); + + it('throws an error if the value is not a valid CAIP-2 Chain ID', () => { + MockUtils.isCaipChainId.mockReturnValue(false); + + expect(() => assertIsInternalScopeString('scopeString')).toThrow( + 'scopeString is not a valid InternalScopeString', + ); + expect(MockUtils.isCaipChainId).toHaveBeenCalledWith('scopeString'); + }); + }); + + describe('assertIsInternalScopesObject', () => { + it('does not throw if passed obj is a valid InternalScopesObject with all valid properties', () => { + const obj = { + 'eip155:1': { + accounts: ['eip155:1:0x1234'], + }, + }; + expect(() => assertIsInternalScopesObject(obj)).not.toThrow(); + }); + + it('throws an error if passed obj is not an object', () => { + expect(() => assertIsInternalScopesObject(null)).toThrow( + 'InternalScopesObject must be an object', + ); + expect(() => assertIsInternalScopesObject(123)).toThrow( + 'InternalScopesObject must be an object', + ); + expect(() => assertIsInternalScopesObject('string')).toThrow( + 'InternalScopesObject must be an object', + ); + }); + + it('throws an error if passed an object with an InternalScopeObject value that is not an object', () => { + expect(() => assertIsInternalScopesObject({ 'eip155:1': 123 })).toThrow( + 'InternalScopeObject must be an object', + ); + }); + + it('throws an error if passed an object with a key that is not a valid InternalScopeString', () => { + MockUtils.isCaipChainId.mockReturnValue(false); + + expect(() => + assertIsInternalScopesObject({ 'invalid-scope-string': {} }), + ).toThrow('scopeString is not a valid InternalScopeString'); + }); + + it('throws an error if passed an object with an InternalScopeObject without an accounts property', () => { + const invalidInternalScopeObject = { + 'eip155:1': {}, + }; + expect(() => + assertIsInternalScopesObject(invalidInternalScopeObject), + ).toThrow( + 'InternalScopeObject.accounts must be an array of CaipAccountId', + ); + }); + + it('throws an error if passed an object with an InternalScopeObject with an accounts property that is not an array', () => { + const invalidInternalScopeObject = { + 'eip155:1': { + accounts: 'not-an-array', + }, + }; + expect(() => + assertIsInternalScopesObject(invalidInternalScopeObject), + ).toThrow( + 'InternalScopeObject.accounts must be an array of CaipAccountId', + ); + }); + + it('throws an error if accounts contains invalid CaipAccountId', () => { + const invalidInternalScopeObject = { + 'eip155:1': { + accounts: ['eip155:1:0x1234', 'invalidAccount'], + }, + }; + MockUtils.isCaipAccountId.mockImplementation( + (id) => id !== 'invalidAccount', + ); + expect(() => + assertIsInternalScopesObject(invalidInternalScopeObject), + ).toThrow( + 'InternalScopeObject.accounts must be an array of CaipAccountId', + ); + }); + }); +}); diff --git a/packages/chain-agnostic-permission/src/scope/assert.ts b/packages/chain-agnostic-permission/src/scope/assert.ts new file mode 100644 index 00000000000..b5afee94bb5 --- /dev/null +++ b/packages/chain-agnostic-permission/src/scope/assert.ts @@ -0,0 +1,277 @@ +import { + hasProperty, + isCaipAccountId, + isCaipChainId, + isCaipNamespace, + isCaipReference, + KnownCaipNamespace, +} from '@metamask/utils'; +import type { CaipChainId, Hex } from '@metamask/utils'; + +import { Caip25Errors } from './errors.js'; +import { + isSupportedMethod, + isSupportedNotification, + isSupportedScopeString, +} from './supported.js'; +import type { + ExternalScopeObject, + ExternalScopesObject, + ExternalScopeString, + InternalScopeObject, + InternalScopesObject, + InternalScopeString, + NormalizedScopeObject, + NormalizedScopesObject, +} from './types.js'; + +/** + * Asserts that a scope string and its associated scope object are supported. + * + * @param scopeString - The scope string against which to assert support. + * @param scopeObject - The scope object against which to assert support. + * @param hooks - An object containing the following properties: + * @param hooks.isEvmChainIdSupported - A predicate that determines if an EVM chainID is supported. + * @param hooks.isNonEvmScopeSupported - A predicate that determines if an non EVM scopeString is supported. + * @param hooks.getNonEvmSupportedMethods - A function that returns the supported methods for a non EVM scope. + */ +export const assertScopeSupported = ( + scopeString: string, + scopeObject: NormalizedScopeObject, + { + isEvmChainIdSupported, + isNonEvmScopeSupported, + getNonEvmSupportedMethods, + }: { + isEvmChainIdSupported: (chainId: Hex) => boolean; + isNonEvmScopeSupported: (scope: CaipChainId) => boolean; + getNonEvmSupportedMethods: (scope: CaipChainId) => string[]; + }, +) => { + const { methods, notifications } = scopeObject; + if ( + !isSupportedScopeString(scopeString, { + isEvmChainIdSupported, + isNonEvmScopeSupported, + }) + ) { + throw Caip25Errors.requestedChainsNotSupportedError(); + } + + const allMethodsSupported = methods.every((method) => + isSupportedMethod(scopeString, method, { getNonEvmSupportedMethods }), + ); + + if (!allMethodsSupported) { + throw Caip25Errors.requestedMethodsNotSupportedError(); + } + + if ( + notifications && + !notifications.every((notification) => + isSupportedNotification(scopeString, notification), + ) + ) { + throw Caip25Errors.requestedNotificationsNotSupportedError(); + } +}; + +/** + * Asserts that all scope strings and their associated scope objects are supported. + * + * @param scopes - The scopes object against which to assert support. + * @param hooks - An object containing the following properties: + * @param hooks.isEvmChainIdSupported - A predicate that determines if an EVM chainID is supported. + * @param hooks.isNonEvmScopeSupported - A predicate that determines if an non EVM scopeString is supported. + * @param hooks.getNonEvmSupportedMethods - A function that returns the supported methods for a non EVM scope. + */ +export const assertScopesSupported = ( + scopes: NormalizedScopesObject, + { + isEvmChainIdSupported, + isNonEvmScopeSupported, + getNonEvmSupportedMethods, + }: { + isEvmChainIdSupported: (chainId: Hex) => boolean; + isNonEvmScopeSupported: (scope: CaipChainId) => boolean; + getNonEvmSupportedMethods: (scope: CaipChainId) => string[]; + }, +) => { + for (const [scopeString, scopeObject] of Object.entries(scopes)) { + assertScopeSupported(scopeString, scopeObject, { + isEvmChainIdSupported, + isNonEvmScopeSupported, + getNonEvmSupportedMethods, + }); + } +}; +/** + * Asserts that an object is a valid ExternalScopeObject. + * + * @param obj - The object to assert. + */ +function assertIsExternalScopeObject( + obj: unknown, +): asserts obj is ExternalScopeObject { + if (typeof obj !== 'object' || obj === null) { + throw new Error('ExternalScopeObject must be an object'); + } + + if (hasProperty(obj, 'references')) { + if ( + !Array.isArray(obj.references) || + !obj.references.every(isCaipReference) + ) { + throw new Error( + 'ExternalScopeObject.references must be an array of CaipReference', + ); + } + } + + if (hasProperty(obj, 'accounts')) { + if (!Array.isArray(obj.accounts) || !obj.accounts.every(isCaipAccountId)) { + throw new Error( + 'ExternalScopeObject.accounts must be an array of CaipAccountId', + ); + } + } + + if (hasProperty(obj, 'methods')) { + if ( + !Array.isArray(obj.methods) || + !obj.methods.every((method) => typeof method === 'string') + ) { + throw new Error( + 'ExternalScopeObject.methods must be an array of strings', + ); + } + } + + if (hasProperty(obj, 'notifications')) { + if ( + !Array.isArray(obj.notifications) || + !obj.notifications.every( + (notification) => typeof notification === 'string', + ) + ) { + throw new Error( + 'ExternalScopeObject.notifications must be an array of strings', + ); + } + } + + if (hasProperty(obj, 'rpcDocuments')) { + if ( + !Array.isArray(obj.rpcDocuments) || + !obj.rpcDocuments.every((doc) => typeof doc === 'string') + ) { + throw new Error( + 'ExternalScopeObject.rpcDocuments must be an array of strings', + ); + } + } + + if (hasProperty(obj, 'rpcEndpoints')) { + if ( + !Array.isArray(obj.rpcEndpoints) || + !obj.rpcEndpoints.every((endpoint) => typeof endpoint === 'string') + ) { + throw new Error( + 'ExternalScopeObject.rpcEndpoints must be an array of strings', + ); + } + } +} + +/** + * Asserts that a scope string is a valid ExternalScopeString. + * + * @param scopeString - The scope string to assert. + */ +function assertIsExternalScopeString( + scopeString: unknown, +): asserts scopeString is ExternalScopeString { + if ( + typeof scopeString !== 'string' || + (!isCaipNamespace(scopeString) && !isCaipChainId(scopeString)) + ) { + throw new Error('scopeString is not a valid ExternalScopeString'); + } +} + +/** + * Asserts that an object is a valid ExternalScopesObject. + * + * @param obj - The object to assert. + */ +export function assertIsExternalScopesObject( + obj: unknown, +): asserts obj is ExternalScopesObject { + if (typeof obj !== 'object' || obj === null) { + throw new Error('ExternalScopesObject must be an object'); + } + + for (const [scopeString, scopeObject] of Object.entries(obj)) { + assertIsExternalScopeString(scopeString); + assertIsExternalScopeObject(scopeObject); + } +} + +/** + * Asserts that an object is a valid InternalScopeObject. + * + * @param obj - The object to assert. + */ +function assertIsInternalScopeObject( + obj: unknown, +): asserts obj is InternalScopeObject { + if (typeof obj !== 'object' || obj === null) { + throw new Error('InternalScopeObject must be an object'); + } + + if ( + !hasProperty(obj, 'accounts') || + !Array.isArray(obj.accounts) || + !obj.accounts.every(isCaipAccountId) + ) { + throw new Error( + 'InternalScopeObject.accounts must be an array of CaipAccountId', + ); + } +} + +/** + * Asserts that a scope string is a valid InternalScopeString. + * + * @param scopeString - The scope string to assert. + */ +export function assertIsInternalScopeString( + scopeString: unknown, +): asserts scopeString is InternalScopeString { + if ( + typeof scopeString !== 'string' || + // `InternalScopeString` is defined as either `KnownCaipNamespace.Wallet` or + // `CaipChainId`, so our conditions intentionally match the type. + (scopeString !== KnownCaipNamespace.Wallet && !isCaipChainId(scopeString)) + ) { + throw new Error('scopeString is not a valid InternalScopeString'); + } +} + +/** + * Asserts that an object is a valid InternalScopesObject. + * + * @param obj - The object to assert. + */ +export function assertIsInternalScopesObject( + obj: unknown, +): asserts obj is InternalScopesObject { + if (typeof obj !== 'object' || obj === null) { + throw new Error('InternalScopesObject must be an object'); + } + + for (const [scopeString, scopeObject] of Object.entries(obj)) { + assertIsInternalScopeString(scopeString); + assertIsInternalScopeObject(scopeObject); + } +} diff --git a/packages/chain-agnostic-permission/src/scope/authorization.test.ts b/packages/chain-agnostic-permission/src/scope/authorization.test.ts new file mode 100644 index 00000000000..a2d7d650951 --- /dev/null +++ b/packages/chain-agnostic-permission/src/scope/authorization.test.ts @@ -0,0 +1,265 @@ +import { + bucketScopes, + isNamespaceInScopesObject, + validateAndNormalizeScopes, +} from './authorization.js'; +import * as Filter from './filter.js'; +import * as Transform from './transform.js'; +import type { ExternalScopeObject } from './types.js'; +import * as Validation from './validation.js'; + +jest.mock('./filter', () => ({ + bucketScopesBySupport: jest.fn(), +})); +const MockFilter = jest.mocked(Filter); + +jest.mock('./validation', () => ({ + getValidScopes: jest.fn(), +})); +const MockValidation = jest.mocked(Validation); + +jest.mock('./transform', () => ({ + normalizeAndMergeScopes: jest.fn(), +})); +const MockTransform = jest.mocked(Transform); + +const validScopeObject: ExternalScopeObject = { + methods: [], + notifications: [], +}; + +describe('Scope Authorization', () => { + describe('validateAndNormalizeScopes', () => { + it('validates the scopes', () => { + MockValidation.getValidScopes.mockReturnValue({ + validRequiredScopes: {}, + validOptionalScopes: {}, + }); + validateAndNormalizeScopes( + { + 'eip155:1': validScopeObject, + }, + { + 'eip155:5': validScopeObject, + }, + ); + expect(MockValidation.getValidScopes).toHaveBeenCalledWith( + { + 'eip155:1': validScopeObject, + }, + { + 'eip155:5': validScopeObject, + }, + ); + }); + + it('normalizes and merges the validated scopes', () => { + MockValidation.getValidScopes.mockReturnValue({ + validRequiredScopes: { + 'eip155:1': validScopeObject, + }, + validOptionalScopes: { + 'eip155:5': validScopeObject, + }, + }); + + validateAndNormalizeScopes({}, {}); + expect(MockTransform.normalizeAndMergeScopes).toHaveBeenCalledWith({ + 'eip155:1': validScopeObject, + }); + expect(MockTransform.normalizeAndMergeScopes).toHaveBeenCalledWith({ + 'eip155:5': validScopeObject, + }); + }); + + it('returns the normalized and merged scopes', () => { + MockValidation.getValidScopes.mockReturnValue({ + validRequiredScopes: { + 'eip155:1': validScopeObject, + }, + validOptionalScopes: { + 'eip155:5': validScopeObject, + }, + }); + MockTransform.normalizeAndMergeScopes.mockImplementation((value) => ({ + ...value, + transformed: true, + })); + + expect(validateAndNormalizeScopes({}, {})).toStrictEqual({ + normalizedRequiredScopes: { + 'eip155:1': validScopeObject, + transformed: true, + }, + normalizedOptionalScopes: { + 'eip155:5': validScopeObject, + transformed: true, + }, + }); + }); + }); + + describe('bucketScopes', () => { + const isEvmChainIdSupported = jest.fn(); + const isEvmChainIdSupportable = jest.fn(); + const isNonEvmScopeSupported = jest.fn(); + const getNonEvmSupportedMethods = jest.fn(); + + beforeEach(() => { + let callCount = 0; + MockFilter.bucketScopesBySupport.mockImplementation(() => { + callCount += 1; + return { + supportedScopes: { + 'mock:A': { + methods: [`mock_method_${callCount}`], + notifications: [], + accounts: [], + }, + }, + unsupportedScopes: { + 'mock:B': { + methods: [`mock_method_${callCount}`], + notifications: [], + accounts: [], + }, + }, + }; + }); + }); + + it('buckets the scopes by supported', () => { + bucketScopes( + { + wallet: { + methods: [], + notifications: [], + accounts: [], + }, + }, + { + isEvmChainIdSupported, + isEvmChainIdSupportable, + isNonEvmScopeSupported, + getNonEvmSupportedMethods, + }, + ); + + expect(MockFilter.bucketScopesBySupport).toHaveBeenCalledWith( + { + wallet: { + methods: [], + notifications: [], + accounts: [], + }, + }, + { + isEvmChainIdSupported, + isNonEvmScopeSupported, + getNonEvmSupportedMethods, + }, + ); + }); + + it('buckets the maybe supportable scopes', () => { + bucketScopes( + { + wallet: { + methods: [], + notifications: [], + accounts: [], + }, + }, + { + isEvmChainIdSupported, + isEvmChainIdSupportable, + isNonEvmScopeSupported, + getNonEvmSupportedMethods, + }, + ); + + expect(MockFilter.bucketScopesBySupport).toHaveBeenCalledWith( + { + 'mock:B': { + methods: [`mock_method_1`], + notifications: [], + accounts: [], + }, + }, + { + isEvmChainIdSupported: isEvmChainIdSupportable, + isNonEvmScopeSupported, + getNonEvmSupportedMethods, + }, + ); + }); + + it('returns the bucketed scopes', () => { + expect( + bucketScopes( + { + wallet: { + methods: [], + notifications: [], + accounts: [], + }, + }, + { + isEvmChainIdSupported, + isEvmChainIdSupportable, + isNonEvmScopeSupported, + getNonEvmSupportedMethods, + }, + ), + ).toStrictEqual({ + supportedScopes: { + 'mock:A': { + methods: [`mock_method_1`], + notifications: [], + accounts: [], + }, + }, + supportableScopes: { + 'mock:A': { + methods: [`mock_method_2`], + notifications: [], + accounts: [], + }, + }, + unsupportableScopes: { + 'mock:B': { + methods: [`mock_method_2`], + notifications: [], + accounts: [], + }, + }, + }); + }); + }); + + describe('isNamespaceInScopesObject', () => { + it('returns true if the namespace is in the scopes object', () => { + expect( + isNamespaceInScopesObject( + { + 'eip155:1': { methods: [], notifications: [], accounts: [] }, + 'solana:1': { methods: [], notifications: [], accounts: [] }, + }, + 'eip155', + ), + ).toBe(true); + }); + + it('returns false if the namespace is not in the scopes object', () => { + expect( + isNamespaceInScopesObject( + { + 'eip155:1': { methods: [], notifications: [], accounts: [] }, + 'eip155:5': { methods: [], notifications: [], accounts: [] }, + }, + 'solana', + ), + ).toBe(false); + }); + }); +}); diff --git a/packages/chain-agnostic-permission/src/scope/authorization.ts b/packages/chain-agnostic-permission/src/scope/authorization.ts new file mode 100644 index 00000000000..8f29ff57de0 --- /dev/null +++ b/packages/chain-agnostic-permission/src/scope/authorization.ts @@ -0,0 +1,122 @@ +import type { CaipChainId, CaipNamespace, Hex, Json } from '@metamask/utils'; + +import { bucketScopesBySupport } from './filter.js'; +import { normalizeAndMergeScopes } from './transform.js'; +import type { + ExternalScopesObject, + ExternalScopeString, + NormalizedScopesObject, +} from './types.js'; +import { parseScopeString } from './types.js'; +import { getValidScopes } from './validation.js'; +/** + * Represents the parameters of a [CAIP-25](https://chainagnostic.org/CAIPs/caip-25) request. + */ +export type Caip25Authorization = ( + | { + requiredScopes: ExternalScopesObject; + optionalScopes?: ExternalScopesObject; + } + | { + requiredScopes?: ExternalScopesObject; + optionalScopes: ExternalScopesObject; + } +) & { + sessionProperties?: Record; + scopedProperties?: Record; +}; + +/** + * Validates and normalizes a set of scopes according to the [CAIP-217](https://chainagnostic.org/CAIPs/caip-217) spec. + * + * @param requiredScopes - The required scopes to validate and normalize. + * @param optionalScopes - The optional scopes to validate and normalize. + * @returns An object containing the normalized required scopes and normalized optional scopes. + */ +export const validateAndNormalizeScopes = ( + requiredScopes: ExternalScopesObject, + optionalScopes: ExternalScopesObject, +): { + normalizedRequiredScopes: NormalizedScopesObject; + normalizedOptionalScopes: NormalizedScopesObject; +} => { + const { validRequiredScopes, validOptionalScopes } = getValidScopes( + requiredScopes, + optionalScopes, + ); + + const normalizedRequiredScopes = normalizeAndMergeScopes(validRequiredScopes); + const normalizedOptionalScopes = normalizeAndMergeScopes(validOptionalScopes); + + return { + normalizedRequiredScopes, + normalizedOptionalScopes, + }; +}; + +/** + * Groups a NormalizedScopesObject into three separate + * NormalizedScopesObjects for supported scopes, + * supportable scopes, and unsupportable scopes. + * + * @param scopes - The NormalizedScopesObject to group. + * @param hooks - The hooks. + * @param hooks.isEvmChainIdSupported - A helper that returns true if an eth chainId is currently supported by the wallet. + * @param hooks.isEvmChainIdSupportable - A helper that returns true if an eth chainId could be supported by the wallet. + * @param hooks.isNonEvmScopeSupported - A predicate that determines if an non EVM scopeString is supported. + * @param hooks.getNonEvmSupportedMethods - A function that returns the supported methods for a non EVM scope. + * @returns an object with three NormalizedScopesObjects separated by support. + */ +export const bucketScopes = ( + scopes: NormalizedScopesObject, + { + isEvmChainIdSupported, + isEvmChainIdSupportable, + isNonEvmScopeSupported, + getNonEvmSupportedMethods, + }: { + isEvmChainIdSupported: (chainId: Hex) => boolean; + isEvmChainIdSupportable: (chainId: Hex) => boolean; + isNonEvmScopeSupported: (scope: CaipChainId) => boolean; + getNonEvmSupportedMethods: (scope: CaipChainId) => string[]; + }, +): { + supportedScopes: NormalizedScopesObject; + supportableScopes: NormalizedScopesObject; + unsupportableScopes: NormalizedScopesObject; +} => { + const { supportedScopes, unsupportedScopes: maybeSupportableScopes } = + bucketScopesBySupport(scopes, { + isEvmChainIdSupported, + isNonEvmScopeSupported, + getNonEvmSupportedMethods, + }); + + const { + supportedScopes: supportableScopes, + unsupportedScopes: unsupportableScopes, + } = bucketScopesBySupport(maybeSupportableScopes, { + isEvmChainIdSupported: isEvmChainIdSupportable, + isNonEvmScopeSupported, + getNonEvmSupportedMethods, + }); + + return { supportedScopes, supportableScopes, unsupportableScopes }; +}; + +/** + * Checks if a given CAIP namespace is present in a NormalizedScopesObject. + * + * @param scopesObject - The NormalizedScopesObject to check. + * @param caipNamespace - The CAIP namespace to check for. + * @returns true if the CAIP namespace is present in the NormalizedScopesObject, false otherwise. + */ +export function isNamespaceInScopesObject( + scopesObject: NormalizedScopesObject, + caipNamespace: CaipNamespace, +) { + return Object.keys(scopesObject).some((scope) => { + const { namespace } = parseScopeString(scope); + return namespace === caipNamespace; + }); +} diff --git a/packages/chain-agnostic-permission/src/scope/constants.test.ts b/packages/chain-agnostic-permission/src/scope/constants.test.ts new file mode 100644 index 00000000000..cd0d09c407f --- /dev/null +++ b/packages/chain-agnostic-permission/src/scope/constants.test.ts @@ -0,0 +1,95 @@ +import { + KnownRpcMethods, + KnownSessionProperties, + isKnownSessionPropertyValue, +} from './constants.js'; + +describe('KnownRpcMethods', () => { + it('should match the snapshot', () => { + expect(KnownRpcMethods).toMatchInlineSnapshot(` + { + "bip122": [], + "eip155": [ + "personal_sign", + "eth_signTypedData_v4", + "wallet_watchAsset", + "wallet_sendCalls", + "wallet_getCallsStatus", + "wallet_getCapabilities", + "wallet_requestExecutionPermissions", + "wallet_getGrantedExecutionPermissions", + "wallet_getSupportedExecutionPermissions", + "eth_sendTransaction", + "eth_decrypt", + "eth_getEncryptionPublicKey", + "web3_clientVersion", + "eth_subscribe", + "eth_unsubscribe", + "eth_blockNumber", + "eth_call", + "eth_chainId", + "eth_estimateGas", + "eth_feeHistory", + "eth_gasPrice", + "eth_getBalance", + "eth_getBlockByHash", + "eth_getBlockByNumber", + "eth_getBlockTransactionCountByHash", + "eth_getBlockTransactionCountByNumber", + "eth_getCode", + "eth_getFilterChanges", + "eth_getFilterLogs", + "eth_getLogs", + "eth_getProof", + "eth_getStorageAt", + "eth_getTransactionByBlockHashAndIndex", + "eth_getTransactionByBlockNumberAndIndex", + "eth_getTransactionByHash", + "eth_getTransactionCount", + "eth_getTransactionReceipt", + "eth_getUncleCountByBlockHash", + "eth_getUncleCountByBlockNumber", + "eth_newBlockFilter", + "eth_newFilter", + "eth_newPendingTransactionFilter", + "eth_sendRawTransaction", + "eth_syncing", + "eth_uninstallFilter", + ], + "solana": [], + "tron": [], + } + `); + }); +}); + +describe('KnownSessionProperties', () => { + it('should match the snapshot', () => { + expect(KnownSessionProperties).toMatchInlineSnapshot(` + { + "Bip122AccountChangedNotifications": "bip122_accountChanged_notifications", + "Eip1193Compatible": "eip1193-compatible", + "SolanaAccountChangedNotifications": "solana_accountChanged_notifications", + "TronAccountChangedNotifications": "tron_accountChanged_notifications", + } + `); + }); +}); + +describe('isKnownSessionPropertyValue', () => { + it('should return true for known session property values', () => { + expect(isKnownSessionPropertyValue('eip1193-compatible')).toBe(true); + expect( + isKnownSessionPropertyValue('solana_accountChanged_notifications'), + ).toBe(true); + expect( + isKnownSessionPropertyValue('tron_accountChanged_notifications'), + ).toBe(true); + expect( + isKnownSessionPropertyValue('bip122_accountChanged_notifications'), + ).toBe(true); + }); + it('should return false for unknown session property values', () => { + expect(isKnownSessionPropertyValue('unknown_session_property')).toBe(false); + }); +}); diff --git a/packages/chain-agnostic-permission/src/scope/constants.ts b/packages/chain-agnostic-permission/src/scope/constants.ts new file mode 100644 index 00000000000..cc6f6a5977b --- /dev/null +++ b/packages/chain-agnostic-permission/src/scope/constants.ts @@ -0,0 +1,119 @@ +import MetaMaskOpenRPCDocument from '@metamask/api-specs'; + +import type { NonWalletKnownCaipNamespace } from './types.js'; + +/** + * ScopeStrings for offchain methods that are not specific to a chainId but are specific to a CAIP namespace. + */ +export enum KnownWalletScopeString { + Eip155 = 'wallet:eip155', +} + +/** + * Regexes defining how references must be formed for non-wallet known CAIP namespaces + */ +export const CaipReferenceRegexes: Record = + { + eip155: /^(0|[1-9][0-9]*)$/u, + bip122: /.*/u, + solana: /.*/u, + tron: /.*/u, + }; + +/** + * Methods that do not belong exclusively to any CAIP namespace. + */ +export const KnownWalletRpcMethods: string[] = [ + 'wallet_registerOnboarding', + 'wallet_scanQRCode', +]; + +/** + * Methods that belong to the `wallet:eip155` scope. + */ +const WalletEip155Methods = ['wallet_addEthereumChain']; + +/** + * Methods that are only supported via the EIP-1193 API. + */ +export const Eip1193OnlyMethods = [ + 'wallet_switchEthereumChain', + 'wallet_getPermissions', + 'wallet_requestPermissions', + 'wallet_revokePermissions', + 'eth_requestAccounts', + 'eth_accounts', + 'eth_coinbase', + 'net_version', + 'metamask_logWeb3ShimUsage', + 'metamask_getProviderState', + 'metamask_sendDomainMetadata', + 'wallet_registerOnboarding', +]; + +/** + * All MetaMask methods, except for ones we have specified in the constants above. + */ +const Eip155Methods = MetaMaskOpenRPCDocument.methods + .map(({ name }: { name: string }) => name) + .filter((method: string) => !WalletEip155Methods.includes(method)) + .filter((method: string) => !KnownWalletRpcMethods.includes(method)) + .filter((method: string) => !Eip1193OnlyMethods.includes(method)); + +/** + * Methods by ecosystem that are chain specific. + */ +export const KnownRpcMethods: Record = { + eip155: Eip155Methods, + bip122: [], + solana: [], + tron: [], +}; + +/** + * Methods for CAIP namespaces that aren't chain specific. + */ +export const KnownWalletNamespaceRpcMethods: Record< + NonWalletKnownCaipNamespace, + string[] +> = { + eip155: WalletEip155Methods, + bip122: [], + solana: [], + tron: [], +}; + +/** + * Notifications for known CAIP namespaces. + */ +export const KnownNotifications: Record = + { + eip155: ['eth_subscription'], + bip122: [], + solana: [], + tron: [], + }; + +/** + * Session properties for known CAIP namespaces. + */ +export enum KnownSessionProperties { + Eip1193Compatible = 'eip1193-compatible', + SolanaAccountChangedNotifications = 'solana_accountChanged_notifications', + TronAccountChangedNotifications = 'tron_accountChanged_notifications', + Bip122AccountChangedNotifications = 'bip122_accountChanged_notifications', +} + +/** + * Checks if a given value is a known session property. + * + * @param value - The value to check. + * @returns `true` if the value is a known session property, otherwise `false`. + */ +export function isKnownSessionPropertyValue( + value: string, +): value is KnownSessionProperties { + return Object.values(KnownSessionProperties).includes( + value as KnownSessionProperties, + ); +} diff --git a/packages/chain-agnostic-permission/src/scope/errors.test.ts b/packages/chain-agnostic-permission/src/scope/errors.test.ts new file mode 100644 index 00000000000..afe56f5747d --- /dev/null +++ b/packages/chain-agnostic-permission/src/scope/errors.test.ts @@ -0,0 +1,40 @@ +import { Caip25Errors } from './errors.js'; + +describe('Caip25Errors', () => { + it('requestedChainsNotSupportedError', () => { + expect(Caip25Errors.requestedChainsNotSupportedError().message).toBe( + 'Requested chains are not supported', + ); + expect(Caip25Errors.requestedChainsNotSupportedError().code).toBe(5100); + }); + + it('requestedMethodsNotSupportedError', () => { + expect(Caip25Errors.requestedMethodsNotSupportedError().message).toBe( + 'Requested methods are not supported', + ); + expect(Caip25Errors.requestedMethodsNotSupportedError().code).toBe(5101); + }); + + it('requestedNotificationsNotSupportedError', () => { + expect(Caip25Errors.requestedNotificationsNotSupportedError().message).toBe( + 'Requested notifications are not supported', + ); + expect(Caip25Errors.requestedNotificationsNotSupportedError().code).toBe( + 5102, + ); + }); + + it('unknownMethodsRequestedError', () => { + expect(Caip25Errors.unknownMethodsRequestedError().message).toBe( + 'Unknown method(s) requested', + ); + expect(Caip25Errors.unknownMethodsRequestedError().code).toBe(5201); + }); + + it('unknownNotificationsRequestedError', () => { + expect(Caip25Errors.unknownNotificationsRequestedError().message).toBe( + 'Unknown notification(s) requested', + ); + expect(Caip25Errors.unknownNotificationsRequestedError().code).toBe(5202); + }); +}); diff --git a/packages/chain-agnostic-permission/src/scope/errors.ts b/packages/chain-agnostic-permission/src/scope/errors.ts new file mode 100644 index 00000000000..a82c95cafbd --- /dev/null +++ b/packages/chain-agnostic-permission/src/scope/errors.ts @@ -0,0 +1,53 @@ +import { JsonRpcError } from '@metamask/rpc-errors'; + +/** + * CAIP25 Errors. + */ +export const Caip25Errors = { + /** + * Thrown when chains requested in a CAIP-25 `wallet_createSession` call are not supported by the wallet. + * Defined in [CAIP-25 error codes section](https://github.com/ChainAgnostic/CAIPs/blob/main/CAIPs/caip-25.md#trusted-failure-codes). + * + * @returns A new JsonRpcError instance. + */ + requestedChainsNotSupportedError: () => + new JsonRpcError(5100, 'Requested chains are not supported'), + + /** + * Thrown when methods requested in a CAIP-25 `wallet_createSession` call are not supported by the wallet. + * Defined in [CAIP-25 error codes section](https://github.com/ChainAgnostic/CAIPs/blob/main/CAIPs/caip-25.md#trusted-failure-codes). + * TODO: consider throwing the more generic version of this error (UNKNOWN_METHODS_REQUESTED_ERROR) unless in a DevMode build of the wallet + * + * @returns A new JsonRpcError instance. + */ + requestedMethodsNotSupportedError: () => + new JsonRpcError(5101, 'Requested methods are not supported'), + + /** + * Thrown when notifications requested in a CAIP-25 `wallet_createSession` call are not supported by the wallet. + * Defined in [CAIP-25 error codes section](https://github.com/ChainAgnostic/CAIPs/blob/main/CAIPs/caip-25.md#trusted-failure-codes). + * TODO: consider throwing the more generic version of this error (UNKNOWN_NOTIFICATIONS_REQUESTED_ERROR) unless in a DevMode build of the wallet + * + * @returns A new JsonRpcError instance. + */ + requestedNotificationsNotSupportedError: () => + new JsonRpcError(5102, 'Requested notifications are not supported'), + + /** + * Thrown when methods requested in a CAIP-25 `wallet_createSession` call are not supported by the wallet. + * Defined in [CAIP-25 error codes section](https://github.com/ChainAgnostic/CAIPs/blob/main/CAIPs/caip-25.md#trusted-failure-codes). + * + * @returns A new JsonRpcError instance. + */ + unknownMethodsRequestedError: () => + new JsonRpcError(5201, 'Unknown method(s) requested'), + + /** + * Thrown when notifications requested in a CAIP-25 `wallet_createSession` call are not supported by the wallet. + * Defined in [CAIP-25 error codes section](https://github.com/ChainAgnostic/CAIPs/blob/main/CAIPs/caip-25.md#trusted-failure-codes). + * + * @returns A new JsonRpcError instance. + */ + unknownNotificationsRequestedError: () => + new JsonRpcError(5202, 'Unknown notification(s) requested'), +}; diff --git a/packages/chain-agnostic-permission/src/scope/filter.test.ts b/packages/chain-agnostic-permission/src/scope/filter.test.ts new file mode 100644 index 00000000000..d70a20d04f6 --- /dev/null +++ b/packages/chain-agnostic-permission/src/scope/filter.test.ts @@ -0,0 +1,333 @@ +import * as Assert from './assert.js'; +import { bucketScopesBySupport, getSupportedScopeObjects } from './filter.js'; +import * as Supported from './supported.js'; + +jest.mock('./assert', () => ({ + ...jest.requireActual('./assert'), + assertScopeSupported: jest.fn(), +})); +const MockAssert = jest.mocked(Assert); + +jest.mock('./supported', () => ({ + ...jest.requireActual('./supported'), + isSupportedMethod: jest.fn(), + isSupportedNotification: jest.fn(), +})); +const MockSupported = jest.mocked(Supported); + +describe('filter', () => { + describe('bucketScopesBySupport', () => { + const isEvmChainIdSupported = jest.fn(); + const isNonEvmScopeSupported = jest.fn(); + const getNonEvmSupportedMethods = jest.fn(); + + it('checks if each scope is supported', () => { + bucketScopesBySupport( + { + 'eip155:1': { + methods: ['a'], + notifications: [], + accounts: [], + }, + 'eip155:5': { + methods: ['b'], + notifications: [], + accounts: [], + }, + }, + { + isEvmChainIdSupported, + isNonEvmScopeSupported, + getNonEvmSupportedMethods, + }, + ); + + expect(MockAssert.assertScopeSupported).toHaveBeenCalledWith( + 'eip155:1', + { + methods: ['a'], + notifications: [], + accounts: [], + }, + { + isEvmChainIdSupported, + isNonEvmScopeSupported, + getNonEvmSupportedMethods, + }, + ); + expect(MockAssert.assertScopeSupported).toHaveBeenCalledWith( + 'eip155:5', + { + methods: ['b'], + notifications: [], + accounts: [], + }, + { + isEvmChainIdSupported, + isNonEvmScopeSupported, + getNonEvmSupportedMethods, + }, + ); + }); + + it('returns supported and unsupported scopes', () => { + MockAssert.assertScopeSupported.mockImplementation((scopeString) => { + if (scopeString === 'eip155:1') { + throw new Error('scope not supported'); + } + }); + + expect( + bucketScopesBySupport( + { + 'eip155:1': { + methods: ['a'], + notifications: [], + accounts: [], + }, + 'eip155:5': { + methods: ['b'], + notifications: [], + accounts: [], + }, + }, + { + isEvmChainIdSupported, + isNonEvmScopeSupported, + getNonEvmSupportedMethods, + }, + ), + ).toStrictEqual({ + supportedScopes: { + 'eip155:5': { + methods: ['b'], + notifications: [], + accounts: [], + }, + }, + unsupportedScopes: { + 'eip155:1': { + methods: ['a'], + notifications: [], + accounts: [], + }, + }, + }); + }); + }); + + describe('getSupportedScopeObjects', () => { + const getNonEvmSupportedMethods = jest.fn(); + + it('checks if each scopeObject method is supported', () => { + getSupportedScopeObjects( + { + 'eip155:1': { + methods: ['method1', 'method2'], + notifications: [], + accounts: [], + }, + 'eip155:5': { + methods: ['methodA', 'methodB'], + notifications: [], + accounts: [], + }, + }, + { + getNonEvmSupportedMethods, + }, + ); + + expect(MockSupported.isSupportedMethod).toHaveBeenCalledTimes(4); + expect(MockSupported.isSupportedMethod).toHaveBeenCalledWith( + 'eip155:1', + 'method1', + { + getNonEvmSupportedMethods, + }, + ); + expect(MockSupported.isSupportedMethod).toHaveBeenCalledWith( + 'eip155:1', + 'method2', + { + getNonEvmSupportedMethods, + }, + ); + expect(MockSupported.isSupportedMethod).toHaveBeenCalledWith( + 'eip155:5', + 'methodA', + { + getNonEvmSupportedMethods, + }, + ); + expect(MockSupported.isSupportedMethod).toHaveBeenCalledWith( + 'eip155:5', + 'methodB', + { + getNonEvmSupportedMethods, + }, + ); + }); + + it('returns only supported methods', () => { + MockSupported.isSupportedMethod.mockImplementation( + (scopeString, method) => { + if (scopeString === 'eip155:1' && method === 'method1') { + return false; + } + if (scopeString === 'eip155:5' && method === 'methodB') { + return false; + } + return true; + }, + ); + + const result = getSupportedScopeObjects( + { + 'eip155:1': { + methods: ['method1', 'method2'], + notifications: [], + accounts: [], + }, + 'eip155:5': { + methods: ['methodA', 'methodB'], + notifications: [], + accounts: [], + }, + }, + { + getNonEvmSupportedMethods, + }, + ); + + expect(result).toStrictEqual({ + 'eip155:1': { + methods: ['method2'], + notifications: [], + accounts: [], + }, + 'eip155:5': { + methods: ['methodA'], + notifications: [], + accounts: [], + }, + }); + }); + + it('checks if each scopeObject notification is supported', () => { + getSupportedScopeObjects( + { + 'eip155:1': { + methods: [], + notifications: ['notification1', 'notification2'], + accounts: [], + }, + 'eip155:5': { + methods: [], + notifications: ['notificationA', 'notificationB'], + accounts: [], + }, + }, + { + getNonEvmSupportedMethods, + }, + ); + + expect(MockSupported.isSupportedNotification).toHaveBeenCalledTimes(4); + expect(MockSupported.isSupportedNotification).toHaveBeenCalledWith( + 'eip155:1', + 'notification1', + ); + expect(MockSupported.isSupportedNotification).toHaveBeenCalledWith( + 'eip155:1', + 'notification2', + ); + expect(MockSupported.isSupportedNotification).toHaveBeenCalledWith( + 'eip155:5', + 'notificationA', + ); + expect(MockSupported.isSupportedNotification).toHaveBeenCalledWith( + 'eip155:5', + 'notificationB', + ); + }); + + it('returns only supported notifications', () => { + MockSupported.isSupportedNotification.mockImplementation( + (scopeString, notification) => { + if (scopeString === 'eip155:1' && notification === 'notification1') { + return false; + } + if (scopeString === 'eip155:5' && notification === 'notificationB') { + return false; + } + return true; + }, + ); + + const result = getSupportedScopeObjects( + { + 'eip155:1': { + methods: [], + notifications: ['notification1', 'notification2'], + accounts: [], + }, + 'eip155:5': { + methods: [], + notifications: ['notificationA', 'notificationB'], + accounts: [], + }, + }, + { + getNonEvmSupportedMethods, + }, + ); + + expect(result).toStrictEqual({ + 'eip155:1': { + methods: [], + notifications: ['notification2'], + accounts: [], + }, + 'eip155:5': { + methods: [], + notifications: ['notificationA'], + accounts: [], + }, + }); + }); + + it('does not modify accounts', () => { + const result = getSupportedScopeObjects( + { + 'eip155:1': { + methods: [], + notifications: [], + accounts: ['eip155:1:0xdeadbeef'], + }, + 'eip155:5': { + methods: [], + notifications: [], + accounts: ['eip155:5:0xdeadbeef'], + }, + }, + { + getNonEvmSupportedMethods, + }, + ); + + expect(result).toStrictEqual({ + 'eip155:1': { + methods: [], + notifications: [], + accounts: ['eip155:1:0xdeadbeef'], + }, + 'eip155:5': { + methods: [], + notifications: [], + accounts: ['eip155:5:0xdeadbeef'], + }, + }); + }); + }); +}); diff --git a/packages/chain-agnostic-permission/src/scope/filter.ts b/packages/chain-agnostic-permission/src/scope/filter.ts new file mode 100644 index 00000000000..41c14e20f58 --- /dev/null +++ b/packages/chain-agnostic-permission/src/scope/filter.ts @@ -0,0 +1,120 @@ +import type { CaipChainId, Hex } from '@metamask/utils'; + +import { assertIsInternalScopeString, assertScopeSupported } from './assert.js'; +import { isSupportedMethod, isSupportedNotification } from './supported.js'; +import type { + InternalScopeString, + NormalizedScopeObject, + NormalizedScopesObject, +} from './types.js'; + +/** + * Groups a NormalizedScopesObject into two separate + * NormalizedScopesObject with supported scopes in one + * and unsupported scopes in the other. + * + * @param scopes - The NormalizedScopesObject to group. + * @param hooks - An object containing the following properties: + * @param hooks.isEvmChainIdSupported - A predicate that determines if an EVM chainID is supported. + * @param hooks.isNonEvmScopeSupported - A predicate that determines if an non EVM scopeString is supported. + * @param hooks.getNonEvmSupportedMethods - A function that returns the supported methods for a non EVM scope. + * @returns The supported and unsupported scopes. + */ +export const bucketScopesBySupport = ( + scopes: NormalizedScopesObject, + { + isEvmChainIdSupported, + isNonEvmScopeSupported, + getNonEvmSupportedMethods, + }: { + isEvmChainIdSupported: (chainId: Hex) => boolean; + isNonEvmScopeSupported: (scope: CaipChainId) => boolean; + getNonEvmSupportedMethods: (scope: CaipChainId) => string[]; + }, +) => { + const supportedScopes: NormalizedScopesObject = {}; + const unsupportedScopes: NormalizedScopesObject = {}; + + for (const [scopeString, scopeObject] of Object.entries(scopes)) { + assertIsInternalScopeString(scopeString); + try { + assertScopeSupported(scopeString, scopeObject, { + isEvmChainIdSupported, + isNonEvmScopeSupported, + getNonEvmSupportedMethods, + }); + supportedScopes[scopeString] = scopeObject; + } catch { + unsupportedScopes[scopeString] = scopeObject; + } + } + + return { supportedScopes, unsupportedScopes }; +}; + +/** + * Returns a NormalizedScopeObject with + * unsupported methods and notifications removed. + * + * @param scopeString - The InternalScopeString for the scopeObject. + * @param scopeObject - The NormalizedScopeObject to filter. + * @param hooks - An object containing the following properties: + * @param hooks.getNonEvmSupportedMethods - A function that returns the supported methods for a non EVM scope. + * @returns a NormalizedScopeObject with only methods and notifications that are currently supported. + */ +const getSupportedScopeObject = ( + scopeString: InternalScopeString, + scopeObject: NormalizedScopeObject, + { + getNonEvmSupportedMethods, + }: { + getNonEvmSupportedMethods: (scope: CaipChainId) => string[]; + }, +) => { + const { methods, notifications } = scopeObject; + + const supportedMethods = methods.filter((method) => + isSupportedMethod(scopeString, method, { getNonEvmSupportedMethods }), + ); + + const supportedNotifications = notifications.filter((notification) => + isSupportedNotification(scopeString, notification), + ); + + return { + ...scopeObject, + methods: supportedMethods, + notifications: supportedNotifications, + }; +}; + +/** + * Returns a NormalizedScopesObject with + * unsupported methods and notifications removed from scopeObjects. + * + * @param scopes - The NormalizedScopesObject to filter. + * @param hooks - An object containing the following properties: + * @param hooks.getNonEvmSupportedMethods - A function that returns the supported methods for a non EVM scope. + * @returns a NormalizedScopesObject with only methods, and notifications that are currently supported. + */ +export const getSupportedScopeObjects = ( + scopes: NormalizedScopesObject, + { + getNonEvmSupportedMethods, + }: { + getNonEvmSupportedMethods: (scope: CaipChainId) => string[]; + }, +) => { + const filteredScopesObject: NormalizedScopesObject = {}; + + for (const [scopeString, scopeObject] of Object.entries(scopes)) { + assertIsInternalScopeString(scopeString); + filteredScopesObject[scopeString] = getSupportedScopeObject( + scopeString, + scopeObject, + { getNonEvmSupportedMethods }, + ); + } + + return filteredScopesObject; +}; diff --git a/packages/chain-agnostic-permission/src/scope/supported.test.ts b/packages/chain-agnostic-permission/src/scope/supported.test.ts new file mode 100644 index 00000000000..f596c383513 --- /dev/null +++ b/packages/chain-agnostic-permission/src/scope/supported.test.ts @@ -0,0 +1,522 @@ +import { + KnownNotifications, + KnownRpcMethods, + KnownSessionProperties, + KnownWalletNamespaceRpcMethods, + KnownWalletRpcMethods, +} from './constants.js'; +import { + isSupportedAccount, + isSupportedMethod, + isSupportedNotification, + isSupportedScopeString, + isSupportedSessionProperty, +} from './supported.js'; + +describe('Scope Support', () => { + describe('isSupportedNotification', () => { + it.each(Object.entries(KnownNotifications))( + 'returns true for each %s scope method', + (scopeString: string, notifications: string[]) => { + notifications.forEach((notification) => { + expect(isSupportedNotification(scopeString, notification)).toBe(true); + }); + }, + ); + + it('returns false otherwise', () => { + expect(isSupportedNotification('eip155', 'anything else')).toBe(false); + expect(isSupportedNotification('', '')).toBe(false); + }); + + it('returns false for unknown namespaces', () => { + expect(isSupportedNotification('unknown', 'anything else')).toBe(false); + }); + + it('returns false for wallet namespace', () => { + expect(isSupportedNotification('wallet', 'anything else')).toBe(false); + }); + }); + + describe('isSupportedMethod', () => { + const getNonEvmSupportedMethods = jest.fn(); + + beforeEach(() => { + getNonEvmSupportedMethods.mockReturnValue([]); + }); + + it('returns true for each eip155 scoped method', () => { + KnownRpcMethods.eip155.forEach((method) => { + expect( + isSupportedMethod(`eip155:1`, method, { getNonEvmSupportedMethods }), + ).toBe(true); + }); + }); + + it('returns true for each wallet scoped method', () => { + KnownWalletRpcMethods.forEach((method) => { + expect( + isSupportedMethod('wallet', method, { getNonEvmSupportedMethods }), + ).toBe(true); + }); + }); + + it('returns true for each wallet:eip155 scoped method', () => { + KnownWalletNamespaceRpcMethods.eip155.forEach((method) => { + expect( + isSupportedMethod(`wallet:eip155`, method, { + getNonEvmSupportedMethods, + }), + ).toBe(true); + }); + }); + + it('gets the supported method list from isSupportedNonEvmMethod for non-evm wallet scoped methods', () => { + isSupportedMethod(`wallet:nonevm`, 'nonEvmMethod', { + getNonEvmSupportedMethods, + }); + expect(getNonEvmSupportedMethods).toHaveBeenCalledWith('wallet:nonevm'); + }); + + it('returns true for non-evm wallet scoped methods if they are returned by isSupportedNonEvmMethod', () => { + getNonEvmSupportedMethods.mockReturnValue(['foo', 'bar', 'nonEvmMethod']); + + expect( + isSupportedMethod(`wallet:nonevm`, 'nonEvmMethod', { + getNonEvmSupportedMethods, + }), + ).toBe(true); + }); + + it('returns false for non-evm wallet scoped methods if they are not returned by isSupportedNonEvmMethod', () => { + getNonEvmSupportedMethods.mockReturnValue(['foo', 'bar', 'nonEvmMethod']); + + expect( + isSupportedMethod(`wallet:nonevm`, 'unsupportedMethod', { + getNonEvmSupportedMethods, + }), + ).toBe(false); + }); + + it('gets the supported method list from isSupportedNonEvmMethod for non-evm scoped methods', () => { + isSupportedMethod(`nonevm:123`, 'nonEvmMethod', { + getNonEvmSupportedMethods, + }); + expect(getNonEvmSupportedMethods).toHaveBeenCalledWith('nonevm:123'); + }); + + it('returns true for non-evm scoped methods if they are returned by isSupportedNonEvmMethod', () => { + getNonEvmSupportedMethods.mockReturnValue(['foo', 'bar', 'nonEvmMethod']); + + expect( + isSupportedMethod(`nonevm:123`, 'nonEvmMethod', { + getNonEvmSupportedMethods, + }), + ).toBe(true); + }); + + it('returns false for non-evm scoped methods if they are not returned by isSupportedNonEvmMethod', () => { + getNonEvmSupportedMethods.mockReturnValue(['foo', 'bar', 'nonEvmMethod']); + + expect( + isSupportedMethod(`nonevm:123`, 'unsupportedMethod', { + getNonEvmSupportedMethods, + }), + ).toBe(false); + }); + + it('returns false otherwise', () => { + expect( + isSupportedMethod('eip155', 'anything else', { + getNonEvmSupportedMethods, + }), + ).toBe(false); + expect( + isSupportedMethod('wallet:wallet', 'anything else', { + getNonEvmSupportedMethods, + }), + ).toBe(false); + expect(isSupportedMethod('', '', { getNonEvmSupportedMethods })).toBe( + false, + ); + }); + }); + + describe('isSupportedScopeString', () => { + const isEvmChainIdSupported = jest.fn(); + const isNonEvmScopeSupported = jest.fn(); + + it('returns true for the wallet namespace', () => { + expect( + isSupportedScopeString('wallet', { + isEvmChainIdSupported, + isNonEvmScopeSupported, + }), + ).toBe(true); + }); + + it('calls isNonEvmScopeSupported for the wallet namespace with a non-evm reference', () => { + isSupportedScopeString('wallet:someref', { + isEvmChainIdSupported, + isNonEvmScopeSupported, + }); + + expect(isNonEvmScopeSupported).toHaveBeenCalledWith('wallet:someref'); + }); + + it('returns true for the wallet namespace when a non-evm reference is included if isNonEvmScopeSupported returns true', () => { + isNonEvmScopeSupported.mockReturnValue(true); + expect( + isSupportedScopeString('wallet:someref', { + isEvmChainIdSupported, + isNonEvmScopeSupported, + }), + ).toBe(true); + }); + it('returns false for the wallet namespace when a non-evm reference is included if isNonEvmScopeSupported returns false', () => { + isNonEvmScopeSupported.mockReturnValue(false); + expect( + isSupportedScopeString('wallet:someref', { + isEvmChainIdSupported, + isNonEvmScopeSupported, + }), + ).toBe(false); + }); + + it('returns true for the ethereum namespace', () => { + expect( + isSupportedScopeString('eip155', { + isEvmChainIdSupported, + isNonEvmScopeSupported, + }), + ).toBe(true); + }); + + it('returns true for the wallet namespace with eip155 reference', () => { + expect( + isSupportedScopeString('wallet:eip155', { + isEvmChainIdSupported, + isNonEvmScopeSupported, + }), + ).toBe(true); + }); + + it('returns true for the ethereum namespace when a network client exists for the reference', () => { + isEvmChainIdSupported.mockReturnValue(true); + expect( + isSupportedScopeString('eip155:1', { + isEvmChainIdSupported, + isNonEvmScopeSupported, + }), + ).toBe(true); + }); + + it('returns false for the ethereum namespace when a network client does not exist for the reference', () => { + isEvmChainIdSupported.mockReturnValue(false); + expect( + isSupportedScopeString('eip155:1', { + isEvmChainIdSupported, + isNonEvmScopeSupported, + }), + ).toBe(false); + }); + + it('returns false for the ethereum namespace when the reference is malformed', () => { + isEvmChainIdSupported.mockReturnValue(true); + expect( + isSupportedScopeString('eip155:01', { + isEvmChainIdSupported, + isNonEvmScopeSupported, + }), + ).toBe(false); + expect( + isSupportedScopeString('eip155:1e1', { + isEvmChainIdSupported, + isNonEvmScopeSupported, + }), + ).toBe(false); + }); + + it('returns false for non-evm namespace without a reference', () => { + expect( + isSupportedScopeString('nonevm', { + isEvmChainIdSupported, + isNonEvmScopeSupported, + }), + ).toBe(false); + }); + + it('calls isNonEvmScopeSupported for non-evm namespace', () => { + isSupportedScopeString('nonevm:someref', { + isEvmChainIdSupported, + isNonEvmScopeSupported, + }); + + expect(isNonEvmScopeSupported).toHaveBeenCalledWith('nonevm:someref'); + }); + + it('returns true for non-evm namespace if isNonEvmScopeSupported returns true', () => { + isNonEvmScopeSupported.mockReturnValue(true); + expect( + isSupportedScopeString('nonevm:someref', { + isEvmChainIdSupported, + isNonEvmScopeSupported, + }), + ).toBe(true); + }); + it('returns false for non-evm namespace if isNonEvmScopeSupported returns false', () => { + isNonEvmScopeSupported.mockReturnValue(false); + expect( + isSupportedScopeString('nonevm:someref', { + isEvmChainIdSupported, + isNonEvmScopeSupported, + }), + ).toBe(false); + }); + }); + + describe('isSupportedAccount', () => { + const getEvmInternalAccounts = jest.fn(); + const getNonEvmAccountAddresses = jest.fn(); + + beforeEach(() => { + getEvmInternalAccounts.mockReturnValue([]); + getNonEvmAccountAddresses.mockReturnValue([]); + }); + + it('returns true if eoa account matching eip155 namespaced address exists', () => { + getEvmInternalAccounts.mockReturnValue([ + { + type: 'eip155:eoa', + address: '0xdeadbeef', + }, + ]); + expect( + isSupportedAccount('eip155:1:0xdeadbeef', { + getEvmInternalAccounts, + getNonEvmAccountAddresses, + }), + ).toBe(true); + }); + + it('returns true if eoa account matching eip155 namespaced address with different casing exists', () => { + getEvmInternalAccounts.mockReturnValue([ + { + type: 'eip155:eoa', + address: '0xdeadBEEF', + }, + ]); + expect( + isSupportedAccount('eip155:1:0xDEADbeef', { + getEvmInternalAccounts, + getNonEvmAccountAddresses, + }), + ).toBe(true); + }); + + it('returns true if erc4337 account matching eip155 namespaced address exists', () => { + getEvmInternalAccounts.mockReturnValue([ + { + type: 'eip155:erc4337', + address: '0xdeadbeef', + }, + ]); + expect( + isSupportedAccount('eip155:1:0xdeadbeef', { + getEvmInternalAccounts, + getNonEvmAccountAddresses, + }), + ).toBe(true); + }); + + it('returns true if erc4337 account matching eip155 namespaced address with different casing exists', () => { + getEvmInternalAccounts.mockReturnValue([ + { + type: 'eip155:erc4337', + address: '0xdeadBEEF', + }, + ]); + expect( + isSupportedAccount('eip155:1:0xDEADbeef', { + getEvmInternalAccounts, + getNonEvmAccountAddresses, + }), + ).toBe(true); + }); + + it('returns false if neither eoa or erc4337 account matching eip155 namespaced address exists', () => { + getEvmInternalAccounts.mockReturnValue([ + { + type: 'other', + address: '0xdeadbeef', + }, + ]); + expect( + isSupportedAccount('eip155:1:0xdeadbeef', { + getEvmInternalAccounts, + getNonEvmAccountAddresses, + }), + ).toBe(false); + }); + + it('returns true if eoa account matching wallet:eip155 address exists', () => { + getEvmInternalAccounts.mockReturnValue([ + { + type: 'eip155:eoa', + address: '0xdeadbeef', + }, + ]); + expect( + isSupportedAccount('wallet:eip155:0xdeadbeef', { + getEvmInternalAccounts, + getNonEvmAccountAddresses, + }), + ).toBe(true); + }); + + it('returns true if eoa account matching wallet:eip155 address with different casing exists', () => { + getEvmInternalAccounts.mockReturnValue([ + { + type: 'eip155:eoa', + address: '0xdeadBEEF', + }, + ]); + expect( + isSupportedAccount('wallet:eip155:0xDEADbeef', { + getEvmInternalAccounts, + getNonEvmAccountAddresses, + }), + ).toBe(true); + }); + + it('returns true if erc4337 account matching wallet:eip155 address exists', () => { + getEvmInternalAccounts.mockReturnValue([ + { + type: 'eip155:erc4337', + address: '0xdeadbeef', + }, + ]); + expect( + isSupportedAccount('wallet:eip155:0xdeadbeef', { + getEvmInternalAccounts, + getNonEvmAccountAddresses, + }), + ).toBe(true); + }); + + it('returns true if erc4337 account matching wallet:eip155 address with different casing exists', () => { + getEvmInternalAccounts.mockReturnValue([ + { + type: 'eip155:erc4337', + address: '0xdeadBEEF', + }, + ]); + expect( + isSupportedAccount('wallet:eip155:0xDEADbeef', { + getEvmInternalAccounts, + getNonEvmAccountAddresses, + }), + ).toBe(true); + }); + + it('returns false if neither eoa or erc4337 account matching wallet:eip155 address exists', () => { + getEvmInternalAccounts.mockReturnValue([ + { + type: 'other', + address: '0xdeadbeef', + }, + ]); + expect( + isSupportedAccount('wallet:eip155:0xdeadbeef', { + getEvmInternalAccounts, + getNonEvmAccountAddresses, + }), + ).toBe(false); + }); + + it('gets the non-evm account addresses for the scope if wallet namespace with non-evm reference', () => { + isSupportedAccount('wallet:nonevm:0xdeadbeef', { + getEvmInternalAccounts, + getNonEvmAccountAddresses, + }); + + expect(getNonEvmAccountAddresses).toHaveBeenCalledWith('wallet:nonevm'); + }); + + it('returns false if wallet namespace with non-evm reference and account is not returned by getNonEvmAccountAddresses', () => { + getNonEvmAccountAddresses.mockReturnValue(['wallet:other:123']); + expect( + isSupportedAccount('wallet:nonevm:0xdeadbeef', { + getEvmInternalAccounts, + getNonEvmAccountAddresses, + }), + ).toBe(false); + }); + + it('returns true if wallet namespace with non-evm reference and account is returned by getNonEvmAccountAddresses', () => { + getNonEvmAccountAddresses.mockReturnValue(['wallet:nonevm:0xdeadbeef']); + expect( + isSupportedAccount('wallet:nonevm:0xdeadbeef', { + getEvmInternalAccounts, + getNonEvmAccountAddresses, + }), + ).toBe(true); + }); + + it('gets the non-evm account addresses for the scope if non-evm namespace', () => { + isSupportedAccount('foo:bar:0xdeadbeef', { + getEvmInternalAccounts, + getNonEvmAccountAddresses, + }); + + expect(getNonEvmAccountAddresses).toHaveBeenCalledWith('foo:bar'); + }); + + it('returns false if non-evm namespace and account is not returned by getNonEvmAccountAddresses', () => { + getNonEvmAccountAddresses.mockReturnValue(['wallet:other:123']); + expect( + isSupportedAccount('foo:bar:0xdeadbeef', { + getEvmInternalAccounts, + getNonEvmAccountAddresses, + }), + ).toBe(false); + }); + + it('returns true if non-evm namespace and account is returned by getNonEvmAccountAddresses', () => { + getNonEvmAccountAddresses.mockReturnValue(['foo:bar:0xdeadbeef']); + expect( + isSupportedAccount('foo:bar:0xdeadbeef', { + getEvmInternalAccounts, + getNonEvmAccountAddresses, + }), + ).toBe(true); + }); + }); + + describe('isSupportedSessionProperty', () => { + it('returns true for the session property', () => { + expect( + isSupportedSessionProperty(KnownSessionProperties.Eip1193Compatible), + ).toBe(true); + expect( + isSupportedSessionProperty( + KnownSessionProperties.SolanaAccountChangedNotifications, + ), + ).toBe(true); + expect( + isSupportedSessionProperty( + KnownSessionProperties.TronAccountChangedNotifications, + ), + ).toBe(true); + expect( + isSupportedSessionProperty( + KnownSessionProperties.Bip122AccountChangedNotifications, + ), + ).toBe(true); + }); + + it('returns false for the session property', () => { + expect(isSupportedSessionProperty('foo')).toBe(false); + }); + }); +}); diff --git a/packages/chain-agnostic-permission/src/scope/supported.ts b/packages/chain-agnostic-permission/src/scope/supported.ts new file mode 100644 index 00000000000..0b86cc9fdca --- /dev/null +++ b/packages/chain-agnostic-permission/src/scope/supported.ts @@ -0,0 +1,188 @@ +import { toHex, isEqualCaseInsensitive } from '@metamask/controller-utils'; +import type { CaipAccountId, CaipChainId, Hex } from '@metamask/utils'; +import { + isCaipChainId, + KnownCaipNamespace, + parseCaipAccountId, +} from '@metamask/utils'; + +import { + CaipReferenceRegexes, + KnownNotifications, + KnownRpcMethods, + KnownSessionProperties, + KnownWalletNamespaceRpcMethods, + KnownWalletRpcMethods, +} from './constants.js'; +import type { ExternalScopeString } from './types.js'; +import { parseScopeString } from './types.js'; + +/** + * Determines if a scope string is supported. + * + * @param scopeString - The scope string to check. + * @param hooks - An object containing the following properties: + * @param hooks.isEvmChainIdSupported - A predicate that determines if an EVM chainID is supported. + * @param hooks.isNonEvmScopeSupported - A predicate that determines if an non EVM scopeString is supported. + * @returns A boolean indicating if the scope string is supported. + */ +export const isSupportedScopeString = ( + scopeString: string, + { + isEvmChainIdSupported, + isNonEvmScopeSupported, + }: { + isEvmChainIdSupported: (chainId: Hex) => boolean; + isNonEvmScopeSupported: (scope: CaipChainId) => boolean; + }, +) => { + const { namespace, reference } = parseScopeString(scopeString); + + switch (namespace) { + case KnownCaipNamespace.Wallet: + if ( + isCaipChainId(scopeString) && + reference !== KnownCaipNamespace.Eip155 + ) { + return isNonEvmScopeSupported(scopeString); + } + return true; + case KnownCaipNamespace.Eip155: + return ( + !reference || + (CaipReferenceRegexes.eip155.test(reference) && + isEvmChainIdSupported(toHex(reference))) + ); + default: + return isCaipChainId(scopeString) + ? isNonEvmScopeSupported(scopeString) + : false; + } +}; + +/** + * Determines if an account is supported by the wallet (i.e. on a keyring known to the wallet). + * + * @param account - The CAIP account ID to check. + * @param hooks - An object containing the following properties: + * @param hooks.getEvmInternalAccounts - A function that returns the EVM internal accounts. + * @param hooks.getNonEvmAccountAddresses - A function that returns the supported CAIP-10 account addresses for a non EVM scope. + * @returns A boolean indicating if the account is supported by the wallet. + */ +export const isSupportedAccount = ( + account: CaipAccountId, + { + getEvmInternalAccounts, + getNonEvmAccountAddresses, + }: { + getEvmInternalAccounts: () => { type: string; address: Hex }[]; + getNonEvmAccountAddresses: (scope: CaipChainId) => string[]; + }, +) => { + const { + address, + chainId, + chain: { namespace, reference }, + } = parseCaipAccountId(account); + + const isSupportedEip155Account = () => + getEvmInternalAccounts().some( + (internalAccount) => + ['eip155:eoa', 'eip155:erc4337'].includes(internalAccount.type) && + isEqualCaseInsensitive(address, internalAccount.address), + ); + + const isSupportedNonEvmAccount = () => + getNonEvmAccountAddresses(chainId).includes(account); + + switch (namespace) { + case KnownCaipNamespace.Wallet: + if (reference === KnownCaipNamespace.Eip155) { + return isSupportedEip155Account(); + } + return isSupportedNonEvmAccount(); + case KnownCaipNamespace.Eip155: + return isSupportedEip155Account(); + default: + return isSupportedNonEvmAccount(); + } +}; + +/** + * Determines if a method is supported by the wallet. + * + * @param scopeString - The scope string to check. + * @param method - The method to check. + * @param hooks - An object containing the following properties: + * @param hooks.getNonEvmSupportedMethods - A function that returns the supported methods for a non EVM scope. + * @returns A boolean indicating if the method is supported by the wallet. + */ +export const isSupportedMethod = ( + scopeString: ExternalScopeString, + method: string, + { + getNonEvmSupportedMethods, + }: { + getNonEvmSupportedMethods: (scope: CaipChainId) => string[]; + }, +): boolean => { + const { namespace, reference } = parseScopeString(scopeString); + + if (!namespace) { + return false; + } + + const isSupportedNonEvmMethod = () => + isCaipChainId(scopeString) && + getNonEvmSupportedMethods(scopeString).includes(method); + + if (namespace === KnownCaipNamespace.Wallet) { + if (!reference) { + return KnownWalletRpcMethods.includes(method); + } + + if (reference === KnownCaipNamespace.Eip155) { + return KnownWalletNamespaceRpcMethods[reference].includes(method); + } + + return isSupportedNonEvmMethod(); + } + + if (namespace === KnownCaipNamespace.Eip155) { + return KnownRpcMethods[namespace].includes(method); + } + + return isSupportedNonEvmMethod(); +}; + +/** + * Determines if a notification is supported by the wallet. + * + * @param scopeString - The scope string to check. + * @param notification - The notification to check. + * @returns A boolean indicating if the notification is supported by the wallet. + */ +export const isSupportedNotification = ( + scopeString: ExternalScopeString, + notification: string, +): boolean => { + const { namespace } = parseScopeString(scopeString); + + if (namespace === KnownCaipNamespace.Eip155) { + return KnownNotifications[namespace].includes(notification); + } + + return false; +}; + +/** + * Determines if a session property is supported by the wallet. + * + * @param property - The property to check. + * @returns A boolean indicating if the property is supported by the wallet. + */ +export const isSupportedSessionProperty = (property: string): boolean => { + return Object.values(KnownSessionProperties).includes( + property as KnownSessionProperties, + ); +}; diff --git a/packages/chain-agnostic-permission/src/scope/transform.test.ts b/packages/chain-agnostic-permission/src/scope/transform.test.ts new file mode 100644 index 00000000000..1041f84d99b --- /dev/null +++ b/packages/chain-agnostic-permission/src/scope/transform.test.ts @@ -0,0 +1,537 @@ +import { + normalizeScope, + mergeNormalizedScopes, + mergeInternalScopes, + mergeScopeObject, + normalizeAndMergeScopes, +} from './transform.js'; +import type { + ExternalScopeObject, + NormalizedScopeObject, + InternalScopesObject, +} from './types.js'; + +const externalScopeObject: ExternalScopeObject = { + methods: [], + notifications: [], +}; + +const validScopeObject: NormalizedScopeObject = { + methods: [], + notifications: [], + accounts: [], +}; + +describe('Scope Transform', () => { + describe('normalizeScope', () => { + describe('scopeString is chain scoped', () => { + it('returns the scope with empty accounts array when accounts are not defined', () => { + expect(normalizeScope('eip155:1', externalScopeObject)).toStrictEqual({ + 'eip155:1': { + ...externalScopeObject, + accounts: [], + }, + }); + }); + + it('returns the scope unchanged when accounts are defined', () => { + expect( + normalizeScope('eip155:1', { ...externalScopeObject, accounts: [] }), + ).toStrictEqual({ + 'eip155:1': { + ...externalScopeObject, + accounts: [], + }, + }); + }); + }); + + describe('scopeString is namespace scoped', () => { + it('returns the scope as is when `references` is not defined', () => { + expect(normalizeScope('eip155', validScopeObject)).toStrictEqual({ + eip155: validScopeObject, + }); + }); + + it('returns one scope per `references` element with `references` excluded from the scopeObject', () => { + expect( + normalizeScope('eip155', { + ...validScopeObject, + references: ['1', '5', '64'], + }), + ).toStrictEqual({ + 'eip155:1': validScopeObject, + 'eip155:5': validScopeObject, + 'eip155:64': validScopeObject, + }); + }); + + it('returns one deep cloned scope per `references` element', () => { + const normalizedScopes = normalizeScope('eip155', { + ...validScopeObject, + references: ['1', '5'], + }); + + expect(normalizedScopes['eip155:1']).not.toBe( + normalizedScopes['eip155:5'], + ); + expect(normalizedScopes['eip155:1'].methods).not.toBe( + normalizedScopes['eip155:5'].methods, + ); + }); + + it('returns the scope as is when `references` is an empty array', () => { + expect( + normalizeScope('eip155', { ...validScopeObject, references: [] }), + ).toStrictEqual({ + eip155: validScopeObject, + }); + }); + }); + }); + + describe('mergeScopeObject', () => { + it('returns an object with the unique set of methods', () => { + expect( + mergeScopeObject( + { + ...validScopeObject, + methods: ['a', 'b', 'c'], + }, + { + ...validScopeObject, + methods: ['b', 'c', 'd'], + }, + ), + ).toStrictEqual({ + ...validScopeObject, + methods: ['a', 'b', 'c', 'd'], + }); + }); + + it('returns an object with the unique set of notifications', () => { + expect( + mergeScopeObject( + { + ...validScopeObject, + notifications: ['a', 'b', 'c'], + }, + { + ...validScopeObject, + notifications: ['b', 'c', 'd'], + }, + ), + ).toStrictEqual({ + ...validScopeObject, + notifications: ['a', 'b', 'c', 'd'], + }); + }); + + it('returns an object with the unique set of accounts', () => { + expect( + mergeScopeObject( + { + ...validScopeObject, + accounts: ['eip155:1:a', 'eip155:1:b', 'eip155:1:c'], + }, + { + ...validScopeObject, + accounts: ['eip155:1:b', 'eip155:1:c', 'eip155:1:d'], + }, + ), + ).toStrictEqual({ + ...validScopeObject, + accounts: ['eip155:1:a', 'eip155:1:b', 'eip155:1:c', 'eip155:1:d'], + }); + + expect( + mergeScopeObject( + { + ...validScopeObject, + accounts: ['eip155:1:a', 'eip155:1:b', 'eip155:1:c'], + }, + { + ...validScopeObject, + }, + ), + ).toStrictEqual({ + ...validScopeObject, + accounts: ['eip155:1:a', 'eip155:1:b', 'eip155:1:c'], + }); + }); + + it('returns an object with the unique set of rpcDocuments', () => { + expect( + mergeScopeObject( + { + ...validScopeObject, + rpcDocuments: ['a', 'b', 'c'], + }, + { + ...validScopeObject, + rpcDocuments: ['b', 'c', 'd'], + }, + ), + ).toStrictEqual({ + ...validScopeObject, + rpcDocuments: ['a', 'b', 'c', 'd'], + }); + + expect( + mergeScopeObject( + { + ...validScopeObject, + rpcDocuments: ['a', 'b', 'c'], + }, + { + ...validScopeObject, + }, + ), + ).toStrictEqual({ + ...validScopeObject, + rpcDocuments: ['a', 'b', 'c'], + }); + + expect( + mergeScopeObject( + { + ...validScopeObject, + }, + { + ...validScopeObject, + rpcDocuments: ['a', 'b', 'c'], + }, + ), + ).toStrictEqual({ + ...validScopeObject, + rpcDocuments: ['a', 'b', 'c'], + }); + }); + + it('returns an object with the unique set of rpcEndpoints', () => { + expect( + mergeScopeObject( + { + ...validScopeObject, + rpcEndpoints: ['a', 'b', 'c'], + }, + { + ...validScopeObject, + rpcEndpoints: ['b', 'c', 'd'], + }, + ), + ).toStrictEqual({ + ...validScopeObject, + rpcEndpoints: ['a', 'b', 'c', 'd'], + }); + + expect( + mergeScopeObject( + { + ...validScopeObject, + rpcEndpoints: ['a', 'b', 'c'], + }, + { + ...validScopeObject, + }, + ), + ).toStrictEqual({ + ...validScopeObject, + rpcEndpoints: ['a', 'b', 'c'], + }); + + expect( + mergeScopeObject( + { + ...validScopeObject, + }, + { + ...validScopeObject, + rpcEndpoints: ['a', 'b', 'c'], + }, + ), + ).toStrictEqual({ + ...validScopeObject, + rpcEndpoints: ['a', 'b', 'c'], + }); + }); + }); + + describe('mergeInternalScopes', () => { + describe('incremental request existing scope with a new account', () => { + it('should return merged scope with existing chain and both accounts', () => { + const leftValue: InternalScopesObject = { + 'eip155:1': { + accounts: ['eip155:1:0xdead'], + }, + }; + + const rightValue: InternalScopesObject = { + 'eip155:1': { + accounts: ['eip155:1:0xbeef'], + }, + }; + + const expectedMergedValue: InternalScopesObject = { + 'eip155:1': { accounts: ['eip155:1:0xdead', 'eip155:1:0xbeef'] }, + }; + + const mergedValue = mergeInternalScopes(leftValue, rightValue); + + expect(mergedValue).toStrictEqual(expectedMergedValue); + }); + }); + + describe('incremental request a whole new scope without accounts', () => { + it('should return merged scope with previously existing chain and accounts, plus new requested chain with no accounts', () => { + const leftValue: InternalScopesObject = { + 'eip155:1': { + accounts: ['eip155:1:0xdead'], + }, + }; + + const rightValue: InternalScopesObject = { + 'eip155:10': { + accounts: [], + }, + }; + + const expectedMergedValue: InternalScopesObject = { + 'eip155:1': { accounts: ['eip155:1:0xdead'] }, + 'eip155:10': { + accounts: [], + }, + }; + + const mergedValue = mergeInternalScopes(leftValue, rightValue); + + expect(mergedValue).toStrictEqual(expectedMergedValue); + }); + }); + + describe('incremental request a whole new scope with accounts', () => { + it('should return merged scope with previously existing chain and accounts, plus new requested chain with new account', () => { + const leftValue: InternalScopesObject = { + 'eip155:1': { + accounts: ['eip155:1:0xdead'], + }, + }; + + const rightValue: InternalScopesObject = { + 'eip155:10': { + accounts: ['eip155:10:0xbeef'], + }, + }; + + const expectedMergedValue: InternalScopesObject = { + 'eip155:1': { accounts: ['eip155:1:0xdead'] }, + 'eip155:10': { accounts: ['eip155:10:0xbeef'] }, + }; + + const mergedValue = mergeInternalScopes(leftValue, rightValue); + + expect(mergedValue).toStrictEqual(expectedMergedValue); + }); + }); + + describe('incremental request an existing scope with new accounts, and whole new scope with accounts', () => { + it('should return merged scope with previously existing chain and accounts, plus new requested chain with new accounts', () => { + const leftValue: InternalScopesObject = { + 'eip155:1': { + accounts: ['eip155:1:0xdead'], + }, + }; + + const rightValue: InternalScopesObject = { + 'eip155:1': { + accounts: ['eip155:1:0xdead', 'eip155:1:0xbeef'], + }, + 'eip155:10': { + accounts: ['eip155:10:0xdead', 'eip155:10:0xbeef'], + }, + }; + + const expectedMergedValue: InternalScopesObject = { + 'eip155:1': { accounts: ['eip155:1:0xdead', 'eip155:1:0xbeef'] }, + 'eip155:10': { + accounts: ['eip155:10:0xdead', 'eip155:10:0xbeef'], + }, + }; + + const mergedValue = mergeInternalScopes(leftValue, rightValue); + + expect(mergedValue).toStrictEqual(expectedMergedValue); + }); + }); + + describe('incremental request an existing scope with new accounts, and 2 whole new scope with accounts', () => { + it('should return merged scope with previously existing chain and accounts, plus new requested chains with new accounts', () => { + const leftValue: InternalScopesObject = { + 'eip155:1': { + accounts: ['eip155:1:0xdead'], + }, + }; + + const rightValue: InternalScopesObject = { + 'eip155:1': { + accounts: ['eip155:1:0xdead', 'eip155:1:0xbadd'], + }, + 'eip155:10': { + accounts: ['eip155:10:0xbeef', 'eip155:10:0xbadd'], + }, + 'eip155:426161': { + accounts: [ + 'eip155:426161:0xdead', + 'eip155:426161:0xbeef', + 'eip155:426161:0xbadd', + ], + }, + }; + + const expectedMergedValue: InternalScopesObject = { + 'eip155:1': { accounts: ['eip155:1:0xdead', 'eip155:1:0xbadd'] }, + 'eip155:10': { + accounts: ['eip155:10:0xbeef', 'eip155:10:0xbadd'], + }, + 'eip155:426161': { + accounts: [ + 'eip155:426161:0xdead', + 'eip155:426161:0xbeef', + 'eip155:426161:0xbadd', + ], + }, + }; + + const mergedValue = mergeInternalScopes(leftValue, rightValue); + + expect(mergedValue).toStrictEqual(expectedMergedValue); + }); + }); + }); + + describe('mergeNormalizedScopes', () => { + it('merges the scopeObjects with matching scopeString', () => { + expect( + mergeNormalizedScopes( + { + 'eip155:1': { + methods: ['a', 'b', 'c'], + notifications: ['foo'], + accounts: [], + }, + }, + { + 'eip155:1': { + methods: ['c', 'd'], + notifications: ['bar'], + accounts: [], + }, + }, + ), + ).toStrictEqual({ + 'eip155:1': { + methods: ['a', 'b', 'c', 'd'], + notifications: ['foo', 'bar'], + accounts: [], + }, + }); + }); + + it('preserves the scopeObjects with no matching scopeString', () => { + expect( + mergeNormalizedScopes( + { + 'eip155:1': { + methods: ['a', 'b', 'c'], + notifications: ['foo'], + accounts: [], + }, + }, + { + 'eip155:2': { + methods: ['c', 'd'], + notifications: ['bar'], + accounts: [], + }, + 'eip155:3': { + methods: [], + notifications: [], + accounts: [], + }, + }, + ), + ).toStrictEqual({ + 'eip155:1': { + methods: ['a', 'b', 'c'], + notifications: ['foo'], + accounts: [], + }, + 'eip155:2': { + methods: ['c', 'd'], + notifications: ['bar'], + accounts: [], + }, + 'eip155:3': { + methods: [], + notifications: [], + accounts: [], + }, + }); + }); + it('returns an empty object when no scopes are provided', () => { + expect(mergeNormalizedScopes({}, {})).toStrictEqual({}); + }); + + it('returns an unchanged scope when two identical scopeObjects are provided', () => { + expect( + mergeNormalizedScopes( + { 'eip155:1': validScopeObject }, + { 'eip155:1': validScopeObject }, + ), + ).toStrictEqual({ 'eip155:1': validScopeObject }); + }); + }); + + describe('normalizeAndMergeScopes', () => { + it('normalizes scopes and merges any overlapping scopeStrings', () => { + expect( + normalizeAndMergeScopes({ + eip155: { + ...validScopeObject, + methods: ['a', 'b'], + references: ['1', '5'], + }, + 'eip155:1': { + ...validScopeObject, + methods: ['b', 'c', 'd'], + }, + }), + ).toStrictEqual({ + 'eip155:1': { + ...validScopeObject, + methods: ['a', 'b', 'c', 'd'], + }, + 'eip155:5': { + ...validScopeObject, + methods: ['a', 'b'], + }, + }); + }); + it('returns an empty object when no scopes are provided', () => { + expect(normalizeAndMergeScopes({})).toStrictEqual({}); + }); + it('return an unchanged scope when scopeObjects are already normalized (i.e. none contain references to flatten)', () => { + expect( + normalizeAndMergeScopes({ + 'eip155:1': validScopeObject, + 'eip155:2': validScopeObject, + 'eip155:3': validScopeObject, + }), + ).toStrictEqual({ + 'eip155:1': validScopeObject, + 'eip155:2': validScopeObject, + 'eip155:3': validScopeObject, + }); + }); + }); +}); diff --git a/packages/chain-agnostic-permission/src/scope/transform.ts b/packages/chain-agnostic-permission/src/scope/transform.ts new file mode 100644 index 00000000000..cb364d513b3 --- /dev/null +++ b/packages/chain-agnostic-permission/src/scope/transform.ts @@ -0,0 +1,187 @@ +import type { CaipReference } from '@metamask/utils'; +import { cloneDeep } from 'lodash'; + +import type { + ExternalScopeObject, + ExternalScopesObject, + InternalScopesObject, + NormalizedScopeObject, + NormalizedScopesObject, +} from './types.js'; +import { parseScopeString } from './types.js'; + +/** + * Returns a list of unique items + * + * @param list - The list of items to filter + * @returns A list of unique items + */ +export const getUniqueArrayItems = (list: Value[]): Value[] => { + return Array.from(new Set(list)); +}; + +/** + * Normalizes a ScopeString and ExternalScopeObject into a separate + * InternalScopeString and NormalizedScopeObject for each reference in the `references` + * value if defined and adds an empty `accounts` array if not defined. + * + * @param scopeString - The string representing the scope + * @param externalScopeObject - The object that defines the scope + * @returns a map of caipChainId to ScopeObjects + */ +export const normalizeScope = ( + scopeString: string, + externalScopeObject: ExternalScopeObject, +): NormalizedScopesObject => { + const { references, ...scopeObject } = externalScopeObject; + const { namespace, reference } = parseScopeString(scopeString); + + const normalizedScopeObject: NormalizedScopeObject = { + accounts: [], + ...scopeObject, + }; + + const shouldFlatten = + namespace && + !reference && + references !== undefined && + references.length > 0; + + if (shouldFlatten) { + return Object.fromEntries( + references.map((ref: CaipReference) => [ + `${namespace}:${ref}`, + cloneDeep(normalizedScopeObject), + ]), + ); + } + return { [scopeString]: normalizedScopeObject }; +}; + +/** + * Merges two NormalizedScopeObjects + * + * @param scopeObjectA - The first scope object to merge. + * @param scopeObjectB - The second scope object to merge. + * @returns The merged scope object. + */ +export const mergeScopeObject = ( + scopeObjectA: NormalizedScopeObject, + scopeObjectB: NormalizedScopeObject, +) => { + const mergedScopeObject: NormalizedScopeObject = { + methods: getUniqueArrayItems([ + ...scopeObjectA.methods, + ...scopeObjectB.methods, + ]), + notifications: getUniqueArrayItems([ + ...scopeObjectA.notifications, + ...scopeObjectB.notifications, + ]), + accounts: getUniqueArrayItems([ + ...scopeObjectA.accounts, + ...scopeObjectB.accounts, + ]), + }; + + if (scopeObjectA.rpcDocuments || scopeObjectB.rpcDocuments) { + mergedScopeObject.rpcDocuments = getUniqueArrayItems([ + ...(scopeObjectA.rpcDocuments ?? []), + ...(scopeObjectB.rpcDocuments ?? []), + ]); + } + + if (scopeObjectA.rpcEndpoints || scopeObjectB.rpcEndpoints) { + mergedScopeObject.rpcEndpoints = getUniqueArrayItems([ + ...(scopeObjectA.rpcEndpoints ?? []), + ...(scopeObjectB.rpcEndpoints ?? []), + ]); + } + + return mergedScopeObject; +}; + +/** + * Merges two NormalizedScopeObjects + * + * @param scopeA - The first normalized scope object to merge. + * @param scopeB - The second normalized scope object to merge. + * @returns The merged normalized scope object from the [CAIP-25](https://github.com/ChainAgnostic/CAIPs/blob/main/CAIPs/caip-25.md) request. + */ +export const mergeNormalizedScopes = ( + scopeA: NormalizedScopesObject, + scopeB: NormalizedScopesObject, +): NormalizedScopesObject => { + const scope: NormalizedScopesObject = {}; + + Object.entries(scopeA).forEach(([_scopeString, scopeObjectA]) => { + // Cast needed because index type is returned as `string` by `Object.entries` + const scopeString = _scopeString as keyof typeof scopeA; + const scopeObjectB = scopeB[scopeString]; + + scope[scopeString] = scopeObjectB + ? mergeScopeObject(scopeObjectA, scopeObjectB) + : scopeObjectA; + }); + + Object.entries(scopeB).forEach(([_scopeString, scopeObjectB]) => { + // Cast needed because index type is returned as `string` by `Object.entries` + const scopeString = _scopeString as keyof typeof scopeB; + const scopeObjectA = scopeA[scopeString]; + + if (!scopeObjectA) { + scope[scopeString] = scopeObjectB; + } + }); + + return scope; +}; + +/** + * Merges two InternalScopeObjects + * + * @param scopeA - The first internal scope object to merge. + * @param scopeB - The second internal scope object to merge. + * @returns The merged internal scope object from the [CAIP-25](https://github.com/ChainAgnostic/CAIPs/blob/main/CAIPs/caip-25.md) request. + */ +export const mergeInternalScopes = ( + scopeA: InternalScopesObject, + scopeB: InternalScopesObject, +): InternalScopesObject => { + const resultScope = cloneDeep(scopeA); + + Object.entries(scopeB).forEach(([scopeString, rightScopeObject]) => { + const internalScopeString = scopeString as keyof typeof scopeB; + const leftRequiredScopeObject = resultScope[internalScopeString]; + if (!leftRequiredScopeObject) { + resultScope[internalScopeString] = rightScopeObject; + } else { + resultScope[internalScopeString] = { + accounts: getUniqueArrayItems([ + ...leftRequiredScopeObject.accounts, + ...rightScopeObject.accounts, + ]), + }; + } + }); + + return resultScope; +}; + +/** + * Normalizes and merges a set of ExternalScopesObjects into a NormalizedScopesObject (i.e. a set of NormalizedScopeObjects where references are flattened). + * + * @param scopes - The external scopes to normalize and merge. + * @returns The normalized and merged scopes. + */ +export const normalizeAndMergeScopes = ( + scopes: ExternalScopesObject, +): NormalizedScopesObject => { + let mergedScopes: NormalizedScopesObject = {}; + Object.keys(scopes).forEach((scopeString) => { + const normalizedScopes = normalizeScope(scopeString, scopes[scopeString]); + mergedScopes = mergeNormalizedScopes(mergedScopes, normalizedScopes); + }); + + return mergedScopes; +}; diff --git a/packages/chain-agnostic-permission/src/scope/types.test.ts b/packages/chain-agnostic-permission/src/scope/types.test.ts new file mode 100644 index 00000000000..a7a16a15a7a --- /dev/null +++ b/packages/chain-agnostic-permission/src/scope/types.test.ts @@ -0,0 +1,23 @@ +import { parseScopeString } from './types.js'; + +describe('Scope', () => { + describe('parseScopeString', () => { + it('returns only the namespace if scopeString is namespace', () => { + expect(parseScopeString('abc')).toStrictEqual({ namespace: 'abc' }); + }); + + it('returns the namespace and reference if scopeString is a CAIP chain ID', () => { + expect(parseScopeString('abc:foo')).toStrictEqual({ + namespace: 'abc', + reference: 'foo', + }); + }); + + it('returns empty object if scopeString is invalid', () => { + expect(parseScopeString('')).toStrictEqual({}); + expect(parseScopeString('a:')).toStrictEqual({}); + expect(parseScopeString(':b')).toStrictEqual({}); + expect(parseScopeString('a:b:c')).toStrictEqual({}); + }); + }); +}); diff --git a/packages/chain-agnostic-permission/src/scope/types.ts b/packages/chain-agnostic-permission/src/scope/types.ts new file mode 100644 index 00000000000..24c2c036c27 --- /dev/null +++ b/packages/chain-agnostic-permission/src/scope/types.ts @@ -0,0 +1,141 @@ +import { + isCaipNamespace, + isCaipChainId, + parseCaipChainId, + KnownCaipNamespace, +} from '@metamask/utils'; +import type { + CaipChainId, + CaipReference, + CaipAccountId, + CaipNamespace, + Json, +} from '@metamask/utils'; + +/** + * Represents a `scopeString` as defined in [CAIP-217](https://chainagnostic.org/CAIPs/caip-217). + */ +export type ExternalScopeString = CaipChainId | CaipNamespace; +/** + * Represents a `scopeObject` as defined in [CAIP-217](https://chainagnostic.org/CAIPs/caip-217). + */ +export type ExternalScopeObject = Omit & { + references?: CaipReference[]; + accounts?: CaipAccountId[]; +}; +/** + * Represents a `scope` as defined in [CAIP-217](https://chainagnostic.org/CAIPs/caip-217). + * TODO update the language in CAIP-217 to use "scope" instead of "scopeObject" for this full record type. + */ +export type ExternalScopesObject = Record< + ExternalScopeString, + ExternalScopeObject +>; + +/** + * Represents a `scopeString` as defined in + * [CAIP-217](https://chainagnostic.org/CAIPs/caip-217), with the exception that + * CAIP namespaces without a reference (aside from "wallet") are disallowed for our internal representations of CAIP-25 session scopes + */ +export type InternalScopeString = CaipChainId | KnownCaipNamespace.Wallet; + +/** + * A trimmed down version of a [CAIP-217](https://chainagnostic.org/CAIPs/caip-217) defined scopeObject that is stored in a `endowment:caip25` permission. + * The only property from the original CAIP-25 scopeObject that we use for permissioning is `accounts`. + */ +export type InternalScopeObject = { + accounts: CaipAccountId[]; +}; + +/** + * A trimmed down version of a [CAIP-217](https://chainagnostic.org/CAIPs/caip-217) scope that is stored in a `endowment:caip25` permission. + * Accounts arrays are mapped to CAIP-2 chainIds. These are currently the only properties used by the permission system. + */ +export type InternalScopesObject = Record & { + [KnownCaipNamespace.Wallet]?: InternalScopeObject; +}; + +/** + * Represents a `scopeObject` as defined in + * [CAIP-217](https://chainagnostic.org/CAIPs/caip-217), with the exception that + * we resolve the `references` property into a scopeObject per reference and + * assign an empty array to the `accounts` property if not already defined + * to more easily perform support checks for `wallet_createSession` requests. + * Also used as the return type for `wallet_createSession` and `wallet_sessionChanged`. + */ +export type NormalizedScopeObject = { + methods: string[]; + notifications: string[]; + accounts: CaipAccountId[]; + rpcDocuments?: string[]; + rpcEndpoints?: string[]; +}; +/** + * Represents a keyed `scopeObject` as defined in + * [CAIP-217](https://chainagnostic.org/CAIPs/caip-217), with the exception that + * we resolve the `references` property into a scopeObject per reference and + * assign an empty array to the `accounts` property if not already defined + * to more easily perform support checks for `wallet_createSession` requests. + * Also used as the return type for `wallet_createSession` and `wallet_sessionChanged`. + */ +export type NormalizedScopesObject = Record< + CaipChainId, + NormalizedScopeObject +> & { + [KnownCaipNamespace.Wallet]?: NormalizedScopeObject; +}; + +export type ScopedProperties = Record> & { + [KnownCaipNamespace.Wallet]?: Record; +}; + +/** + * Parses a scope string into a namespace and reference. + * + * @param scopeString - The scope string to parse. + * @returns An object containing the namespace and reference. + */ +export const parseScopeString = ( + scopeString: string, +): { + namespace?: string; + reference?: string; +} => { + if (isCaipNamespace(scopeString)) { + return { + namespace: scopeString, + }; + } + if (isCaipChainId(scopeString)) { + return parseCaipChainId(scopeString); + } + + return {}; +}; + +/** + * CAIP namespaces excluding "wallet" currently supported by/known to the wallet. + */ +export type NonWalletKnownCaipNamespace = + // NOTE: Using explicit enum values to avoid having breaking change when + // `KnownCaipNamespace` is updated with new namespaces that we don't yet + // support. + | KnownCaipNamespace.Eip155 + | KnownCaipNamespace.Bip122 + | KnownCaipNamespace.Solana + | KnownCaipNamespace.Tron; + +/** + * Checks if a scope string is either a 'wallet' scope or a 'wallet:*' scope. + * + * @param scopeString - The scope string to check. + * @returns True if the scope string is a wallet scope, false otherwise. + */ +export const isWalletScope = ( + scopeString: string, +): scopeString is + | KnownCaipNamespace.Wallet + | `${KnownCaipNamespace.Wallet}:${string}` => { + const { namespace } = parseScopeString(scopeString); + return namespace === KnownCaipNamespace.Wallet; +}; diff --git a/packages/chain-agnostic-permission/src/scope/validation.test.ts b/packages/chain-agnostic-permission/src/scope/validation.test.ts new file mode 100644 index 00000000000..c79ee002772 --- /dev/null +++ b/packages/chain-agnostic-permission/src/scope/validation.test.ts @@ -0,0 +1,179 @@ +import type { ExternalScopeObject } from './types.js'; +import { isValidScope, getValidScopes } from './validation.js'; + +const validScopeString = 'eip155:1'; +const validScopeObject: ExternalScopeObject = { + methods: [], + notifications: [], +}; + +describe('Scope Validation', () => { + describe('isValidScope', () => { + it('returns false when the scopeString is neither a CAIP namespace or CAIP chainId', () => { + expect( + isValidScope('not a namespace or a caip chain id', validScopeObject), + ).toBe(false); + }); + + it('returns true when the scopeString is "wallet" and the scopeObject does not contain references', () => { + expect(isValidScope('wallet', validScopeObject)).toBe(true); + }); + + it('returns true when the scopeString is a valid CAIP chainId and the scopeObject is valid', () => { + expect(isValidScope('eip155:1', validScopeObject)).toBe(true); + }); + + it('returns false when the scopeString is a valid CAIP namespace but references are invalid CAIP references', () => { + expect( + isValidScope('eip155', { + ...validScopeObject, + references: ['@'], + }), + ).toBe(false); + }); + + it('returns false when the scopeString is a CAIP chainId but references is defined', () => { + expect( + isValidScope('eip155:1', { + ...validScopeObject, + references: [], + }), + ).toBe(false); + }); + + it('returns false when the scopeString is a valid CAIP namespace (other than "wallet") but references is an empty array', () => { + expect( + isValidScope('eip155', { ...validScopeObject, references: [] }), + ).toBe(false); + }); + + it('returns false when the scopeString is a valid CAIP namespace (other than "wallet") but references is undefined', () => { + expect(isValidScope('eip155', validScopeObject)).toBe(false); + }); + + it('returns false when methods contains empty string', () => { + expect( + isValidScope(validScopeString, { + ...validScopeObject, + methods: [''], + }), + ).toBe(false); + }); + + it('returns false when methods contains non-string', () => { + expect( + isValidScope(validScopeString, { + ...validScopeObject, + // @ts-expect-error Intentionally invalid input + methods: [{ foo: 'bar' }], + }), + ).toBe(false); + }); + + it('returns true when methods contains only strings', () => { + expect( + isValidScope(validScopeString, { + ...validScopeObject, + methods: ['method1', 'method2'], + }), + ).toBe(true); + }); + + it('returns false when notifications contains empty string', () => { + expect( + isValidScope(validScopeString, { + ...validScopeObject, + notifications: [''], + }), + ).toBe(false); + }); + + it('returns false when notifications contains non-string', () => { + expect( + isValidScope(validScopeString, { + ...validScopeObject, + // @ts-expect-error Intentionally invalid input + notifications: [{ foo: 'bar' }], + }), + ).toBe(false); + }); + + it('returns false when unexpected properties are defined', () => { + expect( + isValidScope(validScopeString, { + ...validScopeObject, + // @ts-expect-error Intentionally invalid input + unexpectedParam: 'foobar', + }), + ).toBe(false); + }); + + it('returns true when only expected properties are defined', () => { + expect( + isValidScope(validScopeString, { + methods: [], + notifications: [], + accounts: [], + rpcDocuments: [], + rpcEndpoints: [], + }), + ).toBe(true); + + expect( + isValidScope('eip155', { + ...validScopeObject, + references: ['1'], + }), + ).toBe(true); + }); + }); + + describe('getValidScopes', () => { + const validScopeObjectWithAccounts = { + ...validScopeObject, + accounts: [], + }; + + it('does not throw an error if required scopes are defined but none are valid', () => { + expect( + getValidScopes( + // @ts-expect-error Intentionally invalid input + { 'eip155:1': {} }, + undefined, + ), + ).toStrictEqual({ validRequiredScopes: {}, validOptionalScopes: {} }); + }); + + it('does not throw an error if optional scopes are defined but none are valid', () => { + expect( + getValidScopes(undefined, { + // @ts-expect-error Intentionally invalid input + 'eip155:1': {}, + }), + ).toStrictEqual({ validRequiredScopes: {}, validOptionalScopes: {} }); + }); + + it('returns the valid required and optional scopes', () => { + expect( + getValidScopes( + { + 'eip155:1': validScopeObjectWithAccounts, + // @ts-expect-error Intentionally invalid input + 'eip155:64': {}, + }, + { + 'eip155:2': {}, + 'eip155:5': validScopeObjectWithAccounts, + }, + ), + ).toStrictEqual({ + validRequiredScopes: { + 'eip155:1': validScopeObjectWithAccounts, + }, + validOptionalScopes: { + 'eip155:5': validScopeObjectWithAccounts, + }, + }); + }); + }); +}); diff --git a/packages/chain-agnostic-permission/src/scope/validation.ts b/packages/chain-agnostic-permission/src/scope/validation.ts new file mode 100644 index 00000000000..2f8629c2c45 --- /dev/null +++ b/packages/chain-agnostic-permission/src/scope/validation.ts @@ -0,0 +1,131 @@ +import { isCaipReference } from '@metamask/utils'; + +import type { + ExternalScopeString, + ExternalScopeObject, + ExternalScopesObject, +} from './types.js'; +import { parseScopeString } from './types.js'; + +/** + * Validates a scope object according to the [CAIP-217](https://chainagnostic.org/CAIPs/caip-217) spec. + * + * @param scopeString - The scope string to validate. + * @param scopeObject - The scope object to validate. + * @returns A boolean indicating if the scope object is valid according to the [CAIP-217](https://chainagnostic.org/CAIPs/caip-217) spec. + */ +export const isValidScope = ( + scopeString: ExternalScopeString, + scopeObject: ExternalScopeObject, +): boolean => { + const { namespace, reference } = parseScopeString(scopeString); + + // Namespace is required + if (!namespace) { + return false; + } + + const { + references, + methods, + notifications, + accounts, + rpcDocuments, + rpcEndpoints, + ...extraProperties + } = scopeObject; + + // Methods and notifications are required + if (!methods || !notifications) { + return false; + } + + // For namespaces other than 'wallet', either reference or non-empty references array must be present + if ( + namespace !== 'wallet' && + !reference && + (!references || references.length === 0) + ) { + return false; + } + + // If references are present, reference must be absent and all references must be valid + if (references) { + if (reference) { + return false; + } + + const areReferencesValid = references.every((nestedReference) => + isCaipReference(nestedReference), + ); + + if (!areReferencesValid) { + return false; + } + } + + const areMethodsValid = methods.every( + (method) => typeof method === 'string' && method.trim() !== '', + ); + + if (!areMethodsValid) { + return false; + } + + const areNotificationsValid = notifications.every( + (notification) => + typeof notification === 'string' && notification.trim() !== '', + ); + + if (!areNotificationsValid) { + return false; + } + + // Ensure no unexpected properties are present in the scope object + if (Object.keys(extraProperties).length > 0) { + return false; + } + + return true; +}; + +/** + * Filters out invalid scopes and returns valid sets of required and optional scopes according to the [CAIP-217](https://chainagnostic.org/CAIPs/caip-217) spec. + * + * @param requiredScopes - The required scopes to validate. + * @param optionalScopes - The optional scopes to validate. + * @returns An object containing valid required scopes and optional scopes. + */ +export const getValidScopes = ( + requiredScopes?: ExternalScopesObject, + optionalScopes?: ExternalScopesObject, +) => { + const validRequiredScopes: ExternalScopesObject = {}; + for (const [scopeString, scopeObject] of Object.entries( + requiredScopes || {}, + )) { + if (isValidScope(scopeString, scopeObject)) { + validRequiredScopes[scopeString] = { + accounts: [], + ...scopeObject, + }; + } + } + + const validOptionalScopes: ExternalScopesObject = {}; + for (const [scopeString, scopeObject] of Object.entries( + optionalScopes || {}, + )) { + if (isValidScope(scopeString, scopeObject)) { + validOptionalScopes[scopeString] = { + accounts: [], + ...scopeObject, + }; + } + } + + return { + validRequiredScopes, + validOptionalScopes, + }; +}; diff --git a/packages/chain-agnostic-permission/tsconfig.build.json b/packages/chain-agnostic-permission/tsconfig.build.json new file mode 100644 index 00000000000..1a85d24557d --- /dev/null +++ b/packages/chain-agnostic-permission/tsconfig.build.json @@ -0,0 +1,18 @@ +{ + "extends": "../../tsconfig.packages.build.json", + "compilerOptions": { + "baseUrl": "./", + "outDir": "./dist", + "resolveJsonModule": true, + "rootDir": "./src" + }, + "references": [ + { + "path": "../controller-utils/tsconfig.build.json" + }, + { + "path": "../permission-controller/tsconfig.build.json" + } + ], + "include": ["../../types", "./src"] +} diff --git a/packages/chain-agnostic-permission/tsconfig.json b/packages/chain-agnostic-permission/tsconfig.json new file mode 100644 index 00000000000..9474a899b23 --- /dev/null +++ b/packages/chain-agnostic-permission/tsconfig.json @@ -0,0 +1,17 @@ +{ + "extends": "../../tsconfig.packages.json", + "compilerOptions": { + "baseUrl": "./", + "resolveJsonModule": true, + "rootDir": "../.." + }, + "references": [ + { + "path": "../controller-utils" + }, + { + "path": "../permission-controller" + } + ], + "include": ["../../types", "./src"] +} diff --git a/packages/chain-agnostic-permission/typedoc.json b/packages/chain-agnostic-permission/typedoc.json new file mode 100644 index 00000000000..c9da015dbf8 --- /dev/null +++ b/packages/chain-agnostic-permission/typedoc.json @@ -0,0 +1,7 @@ +{ + "entryPoints": ["./src/index.ts"], + "excludePrivate": true, + "hideGenerator": true, + "out": "docs", + "tsconfig": "./tsconfig.build.json" +} diff --git a/packages/chomp-api-service/CHANGELOG.md b/packages/chomp-api-service/CHANGELOG.md new file mode 100644 index 00000000000..f2137cda3b4 --- /dev/null +++ b/packages/chomp-api-service/CHANGELOG.md @@ -0,0 +1,75 @@ +# 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] + +## [4.0.1] + +### Changed + +- Bump `@metamask/superstruct` from `^3.1.0` to `^3.4.1` ([#9754](https://github.com/MetaMask/core/pull/9754)) +- Bump `@tanstack/query-core` from `^4.43.0` to `^5.62.16` ([#9712](https://github.com/MetaMask/core/pull/9712)) +- Bump `@metamask/base-data-service` from `^0.1.3` to `^1.0.0` ([#9972](https://github.com/MetaMask/core/pull/9972)) + +## [4.0.0] + +### Added + +- Add `getAssociatedAddresses` method, exposed as the `ChompApiService:getAssociatedAddresses` messenger action, which fetches the active address associations of the authenticated profile via `GET /v1/auth/address` ([#9387](https://github.com/MetaMask/core/pull/9387)) + - Also adds the `ProfileAddressEntry` type describing each returned entry and the `ChompApiServiceGetAssociatedAddressesAction` type + - Returned addresses are parsed into canonical lowercase form, entries are guaranteed to have `status: 'active'`, and results are never served from cache + - The query cache key is scoped to the authenticated profile via a SHA-256 digest of the bearer token, so concurrent calls only share an in-flight request when they are for the same profile and one profile's associations are never cached under another's key + +### Changed + +- **BREAKING:** `associateAddress` now throws an `HttpError` on a 409 response instead of returning the parsed body ([#9387](https://github.com/MetaMask/core/pull/9387)) + - A 409 from `POST /v1/auth/address` indicates the address is associated with a _different_ profile; the previous handling attempted to parse the error body as an association result and failed with a confusing validation error. An address already associated with the authenticated profile is reported via a 201 response with `status: 'active'`, which is unchanged. +- Bump `@metamask/utils` from `^11.9.0` to `^11.11.0` ([#9074](https://github.com/MetaMask/core/pull/9074)) +- Bump `@metamask/controller-utils` from `^12.0.0` to `^12.3.0` ([#8774](https://github.com/MetaMask/core/pull/8774), [#9058](https://github.com/MetaMask/core/pull/9058), [#9083](https://github.com/MetaMask/core/pull/9083), [#9218](https://github.com/MetaMask/core/pull/9218)) +- Bump `@metamask/base-data-service` from `^0.1.2` to `^0.1.3` ([#8799](https://github.com/MetaMask/core/pull/8799)) +- Bump `@metamask/messenger` from `^1.2.0` to `^2.0.0` ([#9392](https://github.com/MetaMask/core/pull/9392)) + +## [3.1.0] + +### Changed + +- `ChompApiService` no longer retries HTTP requests that fail with a 4xx response (other than 429), since those responses indicate the request itself is at fault and will not be resolved by re-issuing it. 5xx, 429, and non-HTTP errors (network/timeout) continue to be retried. Consumers can still override this by passing a `retryFilterPolicy` via `policyOptions`. ([#8621](https://github.com/MetaMask/core/pull/8621)) + +## [3.0.1] + +### Changed + +- Bump `@metamask/base-data-service` from `^0.1.1` to `^0.1.2` ([#8755](https://github.com/MetaMask/core/pull/8755)) +- Bump `@metamask/controller-utils` from `^11.20.0` to `^12.0.0` ([#8755](https://github.com/MetaMask/core/pull/8755)) + +## [3.0.0] + +### Changed + +- **BREAKING:** update types and methods of chomp-api-service to properly reflect the API ([#8635](https://github.com/MetaMask/core/pull/8635)) +- Bump `@metamask/messenger` from `^1.1.1` to `^1.2.0` ([#8632](https://github.com/MetaMask/core/pull/8632)) + +## [2.0.0] + +### Changed + +- **BREAKING:** Change `AssociateAddressParams.timestamp` type from `string` to `number`. ([#8610](https://github.com/MetaMask/core/pull/8610)) + +## [1.0.0] + +### Added + +- Add `ChompApiService` ([#8413](https://github.com/MetaMask/core/pull/8413)) + +[Unreleased]: https://github.com/MetaMask/core/compare/@metamask/chomp-api-service@4.0.1...HEAD +[4.0.1]: https://github.com/MetaMask/core/compare/@metamask/chomp-api-service@4.0.0...@metamask/chomp-api-service@4.0.1 +[4.0.0]: https://github.com/MetaMask/core/compare/@metamask/chomp-api-service@3.1.0...@metamask/chomp-api-service@4.0.0 +[3.1.0]: https://github.com/MetaMask/core/compare/@metamask/chomp-api-service@3.0.1...@metamask/chomp-api-service@3.1.0 +[3.0.1]: https://github.com/MetaMask/core/compare/@metamask/chomp-api-service@3.0.0...@metamask/chomp-api-service@3.0.1 +[3.0.0]: https://github.com/MetaMask/core/compare/@metamask/chomp-api-service@2.0.0...@metamask/chomp-api-service@3.0.0 +[2.0.0]: https://github.com/MetaMask/core/compare/@metamask/chomp-api-service@1.0.0...@metamask/chomp-api-service@2.0.0 +[1.0.0]: https://github.com/MetaMask/core/releases/tag/@metamask/chomp-api-service@1.0.0 diff --git a/packages/chomp-api-service/LICENSE b/packages/chomp-api-service/LICENSE new file mode 100644 index 00000000000..fe29e78e0fe --- /dev/null +++ b/packages/chomp-api-service/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/chomp-api-service/README.md b/packages/chomp-api-service/README.md new file mode 100644 index 00000000000..d1b2943f487 --- /dev/null +++ b/packages/chomp-api-service/README.md @@ -0,0 +1,15 @@ +# `@metamask/chomp-api-service` + +Chomp API data service. + +## Installation + +`yarn add @metamask/chomp-api-service` + +or + +`npm install @metamask/chomp-api-service` + +## 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/chomp-api-service/jest.config.js b/packages/chomp-api-service/jest.config.js new file mode 100644 index 00000000000..ca084133399 --- /dev/null +++ b/packages/chomp-api-service/jest.config.js @@ -0,0 +1,26 @@ +/* + * For a detailed explanation regarding each configuration property and type check, visit: + * https://jestjs.io/docs/configuration + */ + +const merge = require('deepmerge'); +const path = require('path'); + +const baseConfig = require('../../jest.config.packages'); + +const displayName = path.basename(__dirname); + +module.exports = merge(baseConfig, { + // The display name when running multiple projects + displayName, + + // An object that configures minimum threshold enforcement for coverage results + coverageThreshold: { + global: { + branches: 100, + functions: 100, + lines: 100, + statements: 100, + }, + }, +}); diff --git a/packages/chomp-api-service/package.json b/packages/chomp-api-service/package.json new file mode 100644 index 00000000000..003af46a053 --- /dev/null +++ b/packages/chomp-api-service/package.json @@ -0,0 +1,79 @@ +{ + "name": "@metamask/chomp-api-service", + "version": "4.0.1", + "description": "Data service for the Chomp API", + "keywords": [ + "Ethereum", + "MetaMask" + ], + "homepage": "https://github.com/MetaMask/core/tree/main/packages/chomp-api-service#readme", + "bugs": { + "url": "https://github.com/MetaMask/core/issues" + }, + "license": "MIT", + "repository": { + "type": "git", + "url": "https://github.com/MetaMask/core.git" + }, + "files": [ + "dist/" + ], + "sideEffects": false, + "main": "./dist/index.cjs", + "types": "./dist/index.d.cts", + "exports": { + ".": { + "import": { + "types": "./dist/index.d.mts", + "default": "./dist/index.mjs" + }, + "require": { + "types": "./dist/index.d.cts", + "default": "./dist/index.cjs" + } + }, + "./package.json": "./package.json" + }, + "publishConfig": { + "access": "public", + "registry": "https://registry.npmjs.org/" + }, + "scripts": { + "build": "ts-bridge --project tsconfig.build.json --verbose --clean --no-references", + "build:all": "ts-bridge --project tsconfig.build.json --verbose --clean", + "build:docs": "typedoc", + "changelog:update": "../../scripts/update-changelog.sh @metamask/chomp-api-service", + "changelog:validate": "../../scripts/validate-changelog.sh @metamask/chomp-api-service", + "lint:tsconfigs": "tsx ../../scripts/lint-tsconfigs/lint-tsconfigs.mts", + "lint:tsconfigs:fix": "tsx ../../scripts/lint-tsconfigs/lint-tsconfigs.mts --fix", + "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-data-service": "^1.0.0", + "@metamask/controller-utils": "^12.3.0", + "@metamask/messenger": "^2.0.0", + "@metamask/superstruct": "^3.4.1", + "@metamask/utils": "^11.11.0", + "@tanstack/query-core": "^5.62.16" + }, + "devDependencies": { + "@metamask/auto-changelog": "^6.1.0", + "@ts-bridge/cli": "^0.6.4", + "@types/jest": "^30.0.0", + "deepmerge": "^4.2.2", + "jest": "^30.4.2", + "nock": "^13.3.1", + "ts-jest": "^29.4.11", + "tsx": "^4.20.5", + "typedoc": "^0.25.13", + "typedoc-plugin-missing-exports": "^2.0.0", + "typescript": "~5.3.3" + }, + "engines": { + "node": "^18.18 || >=20" + } +} diff --git a/packages/chomp-api-service/src/chomp-api-service-method-action-types.ts b/packages/chomp-api-service/src/chomp-api-service-method-action-types.ts new file mode 100644 index 00000000000..26c863b0d9b --- /dev/null +++ b/packages/chomp-api-service/src/chomp-api-service-method-action-types.ts @@ -0,0 +1,152 @@ +/** + * This file is auto generated. + * Do not edit manually. + */ + +import type { ChompApiService } from './chomp-api-service.js'; + +/** + * Associates an address with a CHOMP profile. + * + * POST /v1/auth/address + * + * @param params - The association params containing signature, timestamp, + * and address. + * @returns The profile association result: `status: 'created'` for a new + * association, `status: 'active'` when the address was already associated + * with the authenticated profile. Throws on 409, which indicates the + * address is associated with a different profile. + */ +export type ChompApiServiceAssociateAddressAction = { + type: `ChompApiService:associateAddress`; + handler: ChompApiService['associateAddress']; +}; + +/** + * Fetches the addresses associated with the authenticated profile. + * + * GET /v1/auth/address + * + * The result is scoped to the authenticated profile and consumers use it to + * decide whether an association already exists, so it is always fetched + * fresh (`staleTime: 0`, `cacheTime: 0`) and the query key is scoped to the + * profile via a digest of the bearer token — concurrent calls only share a + * request when they are for the same profile. + * + * @returns The active address associations; empty array if none exist. + * Addresses are lowercased. + */ +export type ChompApiServiceGetAssociatedAddressesAction = { + type: `ChompApiService:getAssociatedAddresses`; + handler: ChompApiService['getAssociatedAddresses']; +}; + +/** + * Creates an account upgrade request. + * + * POST /v1/account-upgrade + * + * @param params - The upgrade params containing signature components and + * chain details. + * @returns The upgrade result. + */ +export type ChompApiServiceCreateUpgradeAction = { + type: `ChompApiService:createUpgrade`; + handler: ChompApiService['createUpgrade']; +}; + +/** + * Fetches all EIP-7702 upgrade authorizations for a given address (one per + * chain). + * + * GET /v1/account-upgrade/:address + * + * @param address - The address to look up. + * @returns The upgrade entries; empty array if none exist. + */ +export type ChompApiServiceGetUpgradesAction = { + type: `ChompApiService:getUpgrades`; + handler: ChompApiService['getUpgrades']; +}; + +/** + * Verifies a delegation signature. + * + * POST /v1/intent/verify-delegation + * + * @param params - The delegation verification params. + * @returns The verification result including validity and optional errors. + */ +export type ChompApiServiceVerifyDelegationAction = { + type: `ChompApiService:verifyDelegation`; + handler: ChompApiService['verifyDelegation']; +}; + +/** + * Submits one or more intents to the CHOMP API. + * + * POST /v1/intent + * + * @param intents - The array of intents to submit. + * @returns The array of intent responses. + */ +export type ChompApiServiceCreateIntentsAction = { + type: `ChompApiService:createIntents`; + handler: ChompApiService['createIntents']; +}; + +/** + * Fetches intents associated with a given address. + * + * GET /v1/intent/account/:address + * + * @param address - The address to look up intents for. + * @returns The array of intents for the address. + */ +export type ChompApiServiceGetIntentsByAddressAction = { + type: `ChompApiService:getIntentsByAddress`; + handler: ChompApiService['getIntentsByAddress']; +}; + +/** + * Creates a withdrawal for card spend flows. + * + * POST /v1/withdrawal + * + * @param params - The withdrawal params containing chainId, amount + * (decimal or hex string), and account address. + * @returns The withdrawal result. + */ +export type ChompApiServiceCreateWithdrawalAction = { + type: `ChompApiService:createWithdrawal`; + handler: ChompApiService['createWithdrawal']; +}; + +/** + * Retrieves service details including delegation redeemer addresses and DeFi + * contract details for signing delegations for auto-deposit functionality. + * + * GET /v1/chomp + * + * @param chainIds - Array of chain IDs (0x-prefixed hex strings) to retrieve + * details for. + * @returns The service details for the requested chains. + */ +export type ChompApiServiceGetServiceDetailsAction = { + type: `ChompApiService:getServiceDetails`; + handler: ChompApiService['getServiceDetails']; +}; + +/** + * Union of all ChompApiService action types. + */ +export type ChompApiServiceMethodActions = + | ChompApiServiceAssociateAddressAction + | ChompApiServiceGetAssociatedAddressesAction + | ChompApiServiceCreateUpgradeAction + | ChompApiServiceGetUpgradesAction + | ChompApiServiceVerifyDelegationAction + | ChompApiServiceCreateIntentsAction + | ChompApiServiceGetIntentsByAddressAction + | ChompApiServiceCreateWithdrawalAction + | ChompApiServiceGetServiceDetailsAction; diff --git a/packages/chomp-api-service/src/chomp-api-service.test.ts b/packages/chomp-api-service/src/chomp-api-service.test.ts new file mode 100644 index 00000000000..c80b6ba7b5c --- /dev/null +++ b/packages/chomp-api-service/src/chomp-api-service.test.ts @@ -0,0 +1,883 @@ +import { DEFAULT_MAX_RETRIES, handleAll } 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 { ChompApiServiceMessenger } from './chomp-api-service.js'; +import { ChompApiService } from './chomp-api-service.js'; + +const BASE_URL = 'https://api.chomp.example.com'; +const MOCK_TOKEN = 'mock-jwt-token'; + +describe('ChompApiService', () => { + describe('associateAddress', () => { + const associateParams = { + signature: '0x123' as const, + timestamp: 1735689600, + address: '0xabc' as const, + }; + + it('sends a POST with auth headers and returns the response on 201', async () => { + nock(BASE_URL) + .post('/v1/auth/address', associateParams) + .matchHeader('Authorization', `Bearer ${MOCK_TOKEN}`) + .matchHeader('Content-Type', 'application/json') + .reply(201, { + profileId: 'p1', + address: '0xabc', + status: 'created', + }); + const { rootMessenger } = createService(); + + const result = await rootMessenger.call( + 'ChompApiService:associateAddress', + associateParams, + ); + + expect(result).toStrictEqual({ + profileId: 'p1', + address: '0xabc', + status: 'created', + }); + }); + + it('returns the response when the address is already associated with the profile', async () => { + nock(BASE_URL).post('/v1/auth/address').reply(201, { + address: '0xabc', + status: 'active', + }); + const { service } = createService(); + + const result = await service.associateAddress(associateParams); + + expect(result).toStrictEqual({ + address: '0xabc', + status: 'active', + }); + }); + + it('throws when the address is associated with another profile (409)', async () => { + nock(BASE_URL).post('/v1/auth/address').reply(409, { + statusCode: 409, + message: 'Address is already associated with another profile', + }); + const { service } = createService(); + + await expect(service.associateAddress(associateParams)).rejects.toThrow( + "POST /v1/auth/address failed with status '409'", + ); + }); + + it('throws on non-OK status', async () => { + nock(BASE_URL) + .post('/v1/auth/address') + .times(DEFAULT_MAX_RETRIES + 1) + .reply(500); + const { service } = createService(); + + await expect(service.associateAddress(associateParams)).rejects.toThrow( + "POST /v1/auth/address failed with status '500'", + ); + }); + + it('throws on malformed response', async () => { + nock(BASE_URL) + .post('/v1/auth/address') + .reply(201, JSON.stringify({ missing: 'fields' })); + const { service } = createService(); + + await expect(service.associateAddress(associateParams)).rejects.toThrow( + 'At path: address', + ); + }); + }); + + describe('getAssociatedAddresses', () => { + const addressEntry = { + profileId: 'p1', + address: '0xabc', + status: 'active', + }; + + it('sends a GET with auth headers and returns the address entries', async () => { + nock(BASE_URL) + .get('/v1/auth/address') + .matchHeader('Authorization', `Bearer ${MOCK_TOKEN}`) + .reply(200, [addressEntry]); + const { rootMessenger } = createService(); + + const result = await rootMessenger.call( + 'ChompApiService:getAssociatedAddresses', + ); + + expect(result).toStrictEqual([addressEntry]); + }); + + it('returns an empty array when no addresses are associated', async () => { + nock(BASE_URL).get('/v1/auth/address').reply(200, []); + const { service } = createService(); + + const result = await service.getAssociatedAddresses(); + + expect(result).toStrictEqual([]); + }); + + it('lowercases returned addresses', async () => { + nock(BASE_URL) + .get('/v1/auth/address') + .reply(200, [ + { + profileId: 'p1', + address: '0xABCdef1234567890ABCdef1234567890ABCdef12', + status: 'active', + }, + ]); + const { service } = createService(); + + const result = await service.getAssociatedAddresses(); + + expect(result).toStrictEqual([ + { + profileId: 'p1', + address: '0xabcdef1234567890abcdef1234567890abcdef12', + status: 'active', + }, + ]); + }); + + it('rejects entries with a non-active status', async () => { + nock(BASE_URL) + .get('/v1/auth/address') + .reply(200, [{ profileId: 'p1', address: '0xabc', status: 'deleted' }]); + const { service } = createService(); + + await expect(service.getAssociatedAddresses()).rejects.toThrow( + 'At path: 0.status', + ); + }); + + it('does not serve results from cache', async () => { + nock(BASE_URL).get('/v1/auth/address').reply(200, []); + nock(BASE_URL).get('/v1/auth/address').reply(200, [addressEntry]); + const { service } = createService(); + + const first = await service.getAssociatedAddresses(); + const second = await service.getAssociatedAddresses(); + + expect(first).toStrictEqual([]); + expect(second).toStrictEqual([addressEntry]); + }); + + it('does not share an in-flight request across different bearer tokens', async () => { + const tokens = ['profile-a-token', 'profile-b-token']; + const { service } = createService({ + getBearerToken: async () => tokens.shift() ?? 'exhausted', + }); + // The first profile's request is still in flight when the second + // profile's request is issued; the second must not be deduplicated + // onto the first, or it would receive the first profile's addresses. + nock(BASE_URL) + .get('/v1/auth/address') + .matchHeader('Authorization', 'Bearer profile-a-token') + .delay(100) + .reply(200, []); + nock(BASE_URL) + .get('/v1/auth/address') + .matchHeader('Authorization', 'Bearer profile-b-token') + .reply(200, [addressEntry]); + + const [first, second] = await Promise.all([ + service.getAssociatedAddresses(), + service.getAssociatedAddresses(), + ]); + + expect(first).toStrictEqual([]); + expect(second).toStrictEqual([addressEntry]); + }); + + it('shares an in-flight request across calls with the same bearer token', async () => { + // A single interceptor: both concurrent same-profile calls must be + // served by one HTTP request. + nock(BASE_URL) + .get('/v1/auth/address') + .delay(100) + .reply(200, [addressEntry]); + const { service } = createService(); + + const [first, second] = await Promise.all([ + service.getAssociatedAddresses(), + service.getAssociatedAddresses(), + ]); + + expect(first).toStrictEqual([addressEntry]); + expect(second).toStrictEqual([addressEntry]); + }); + + it('does not leak the bearer token through cache update events', async () => { + nock(BASE_URL).get('/v1/auth/address').reply(200, [addressEntry]); + const { service, messenger } = createService(); + const publishSpy = jest.spyOn(messenger, 'publish'); + + await service.getAssociatedAddresses(); + + expect(publishSpy).toHaveBeenCalled(); + expect(JSON.stringify(publishSpy.mock.calls)).not.toContain(MOCK_TOKEN); + }); + + it('evicts the result from the cache once the call settles', async () => { + nock(BASE_URL).get('/v1/auth/address').reply(200, [addressEntry]); + const { service, messenger } = createService(); + const publishSpy = jest.spyOn(messenger, 'publish'); + + await service.getAssociatedAddresses(); + // Eviction (`cacheTime: 0`) is scheduled on a macrotask; let it run. + await new Promise((resolve) => setTimeout(resolve, 0)); + + expect(publishSpy).toHaveBeenCalledWith( + 'ChompApiService:cacheUpdated', + expect.objectContaining({ type: 'removed' }), + ); + }); + + it('throws on non-OK status', async () => { + nock(BASE_URL) + .get('/v1/auth/address') + .times(DEFAULT_MAX_RETRIES + 1) + .reply(500); + const { service } = createService(); + + await expect(service.getAssociatedAddresses()).rejects.toThrow( + "GET /v1/auth/address failed with status '500'", + ); + }); + + it('throws on malformed response', async () => { + nock(BASE_URL) + .get('/v1/auth/address') + .reply(200, JSON.stringify([{ bad: 'data' }])); + const { service } = createService(); + + await expect(service.getAssociatedAddresses()).rejects.toThrow( + 'At path: 0.profileId', + ); + }); + }); + + describe('createUpgrade', () => { + const upgradeParams = { + r: '0x1' as const, + s: '0x2' as const, + v: 27, + yParity: 0, + address: '0xabc' as const, + chainId: '1', + nonce: '0', + }; + + const upgradeResponse = { + signerAddress: '0xdef', + address: '0xabc', + chainId: '0xa4b1', + nonce: '0x0', + status: 'pending', + createdAt: '2026-01-01T00:00:00Z', + }; + + it('sends a POST with auth headers and returns the response', async () => { + nock(BASE_URL) + .post('/v1/account-upgrade', upgradeParams) + .matchHeader('Authorization', `Bearer ${MOCK_TOKEN}`) + .reply(200, upgradeResponse); + const { rootMessenger } = createService(); + + const result = await rootMessenger.call( + 'ChompApiService:createUpgrade', + upgradeParams, + ); + + expect(result).toStrictEqual(upgradeResponse); + }); + + it('throws on non-OK status', async () => { + nock(BASE_URL) + .post('/v1/account-upgrade') + .times(DEFAULT_MAX_RETRIES + 1) + .reply(500); + const { service } = createService(); + + await expect(service.createUpgrade(upgradeParams)).rejects.toThrow( + "POST /v1/account-upgrade failed with status '500'", + ); + }); + + it('throws on malformed response', async () => { + nock(BASE_URL) + .post('/v1/account-upgrade') + .reply(200, JSON.stringify({ bad: 'data' })); + const { service } = createService(); + + await expect(service.createUpgrade(upgradeParams)).rejects.toThrow( + 'At path: signerAddress -- Expected a string', + ); + }); + }); + + describe('getUpgrades', () => { + const upgradeEntry = { + signerAddress: '0xdef', + chainId: '0xa4b1', + nonce: '0x0', + authorization: { + r: '0x1', + s: '0x2', + v: 27, + yParity: 0, + address: '0xabc', + chainId: '0xa4b1', + nonce: '0x0', + }, + status: 'pending', + createdAt: '2026-01-01T00:00:00Z', + }; + + it('sends a GET with auth headers and returns the upgrade entries', async () => { + nock(BASE_URL) + .get('/v1/account-upgrade/0xabc') + .matchHeader('Authorization', `Bearer ${MOCK_TOKEN}`) + .reply(200, [upgradeEntry]); + const { rootMessenger } = createService(); + + const result = await rootMessenger.call( + 'ChompApiService:getUpgrades', + '0xabc', + ); + + expect(result).toStrictEqual([upgradeEntry]); + }); + + it('returns an empty array when no upgrades exist', async () => { + nock(BASE_URL).get('/v1/account-upgrade/0xabc').reply(200, []); + const { service } = createService(); + + const result = await service.getUpgrades('0xabc'); + + expect(result).toStrictEqual([]); + }); + + it('throws on non-OK status', async () => { + nock(BASE_URL) + .get('/v1/account-upgrade/0xabc') + .times(DEFAULT_MAX_RETRIES + 1) + .reply(500); + const { service } = createService(); + + await expect(service.getUpgrades('0xabc')).rejects.toThrow( + "Get upgrades request failed with status '500'", + ); + }); + + it('throws on malformed response', async () => { + nock(BASE_URL) + .get('/v1/account-upgrade/0xabc') + .reply(200, JSON.stringify([{ bad: 'data' }])); + const { service } = createService(); + + await expect(service.getUpgrades('0xabc')).rejects.toThrow( + 'At path: 0.signerAddress -- Expected a string', + ); + }); + }); + + describe('verifyDelegation', () => { + const delegationParams = { + signedDelegation: { + delegate: '0x1' as const, + delegator: '0x2' as const, + authority: '0x3' as const, + caveats: [], + salt: '0x4' as const, + signature: '0x5' as const, + }, + chainId: '0x1' as const, + }; + + it('sends a POST with auth headers and returns the response', async () => { + nock(BASE_URL) + .post('/v1/intent/verify-delegation', delegationParams) + .matchHeader('Authorization', `Bearer ${MOCK_TOKEN}`) + .reply(200, { valid: true, delegationHash: '0xabc123' }); + const { rootMessenger } = createService(); + + const result = await rootMessenger.call( + 'ChompApiService:verifyDelegation', + delegationParams, + ); + + expect(result).toStrictEqual({ + valid: true, + delegationHash: '0xabc123', + }); + }); + + it('returns errors when delegation is invalid', async () => { + nock(BASE_URL) + .post('/v1/intent/verify-delegation') + .reply(200, { valid: false, errors: ['bad signature'] }); + const { service } = createService(); + + const result = await service.verifyDelegation(delegationParams); + + expect(result).toStrictEqual({ + valid: false, + errors: ['bad signature'], + }); + }); + + it('throws on non-OK status', async () => { + nock(BASE_URL).post('/v1/intent/verify-delegation').reply(400); + const { service } = createService(); + + await expect(service.verifyDelegation(delegationParams)).rejects.toThrow( + "POST /v1/intent/verify-delegation failed with status '400'", + ); + }); + + it('throws on malformed response', async () => { + nock(BASE_URL) + .post('/v1/intent/verify-delegation') + .reply(200, JSON.stringify({ bad: 'data' })); + const { service } = createService(); + + await expect(service.verifyDelegation(delegationParams)).rejects.toThrow( + 'At path: valid -- Expected a value of type `boolean`', + ); + }); + }); + + describe('createIntents', () => { + const intentParams = [ + { + account: '0xabc' as const, + delegationHash: '0xdef' as const, + chainId: '0x1' as const, + metadata: { + allowance: '0xff' as const, + tokenSymbol: 'USDC', + tokenAddress: '0x123' as const, + type: 'cash-deposit' as const, + }, + }, + ]; + + const intentResponse = [ + { + delegationHash: '0xdef', + metadata: { + allowance: '0xff', + tokenSymbol: 'USDC', + tokenAddress: '0x123', + type: 'cash-deposit', + }, + createdAt: '2026-01-01T00:00:00Z', + }, + ]; + + it('sends a POST with auth headers and returns the response array', async () => { + nock(BASE_URL) + .post('/v1/intent', intentParams) + .matchHeader('Authorization', `Bearer ${MOCK_TOKEN}`) + .reply(201, intentResponse); + const { rootMessenger } = createService(); + + const result = await rootMessenger.call( + 'ChompApiService:createIntents', + intentParams, + ); + + expect(result).toStrictEqual(intentResponse); + }); + + it('throws on non-OK status', async () => { + nock(BASE_URL).post('/v1/intent').reply(409); + const { service } = createService(); + + await expect(service.createIntents(intentParams)).rejects.toThrow( + "POST /v1/intent failed with status '409'", + ); + }); + + it('throws on malformed response', async () => { + nock(BASE_URL) + .post('/v1/intent') + .reply(201, JSON.stringify([{ bad: 'data' }])); + const { service } = createService(); + + await expect(service.createIntents(intentParams)).rejects.toThrow( + 'At path: 0.delegationHash -- Expected a string', + ); + }); + }); + + describe('getIntentsByAddress', () => { + const intentsResponse = [ + { + account: '0xabc', + delegationHash: '0xdef', + chainId: '0x1', + status: 'active', + metadata: { + allowance: '0xff', + tokenAddress: '0x123', + tokenSymbol: 'USDC', + type: 'cash-deposit', + }, + }, + ]; + + it('sends a GET with auth headers and returns the intents array', async () => { + nock(BASE_URL) + .get('/v1/intent/account/0xabc') + .matchHeader('Authorization', `Bearer ${MOCK_TOKEN}`) + .reply(200, intentsResponse); + const { rootMessenger } = createService(); + + const result = await rootMessenger.call( + 'ChompApiService:getIntentsByAddress', + '0xabc', + ); + + expect(result).toStrictEqual(intentsResponse); + }); + + it('returns an empty array when no intents exist', async () => { + nock(BASE_URL).get('/v1/intent/account/0xabc').reply(200, []); + const { service } = createService(); + + const result = await service.getIntentsByAddress('0xabc'); + + expect(result).toStrictEqual([]); + }); + + it('throws on non-OK status', async () => { + nock(BASE_URL) + .get('/v1/intent/account/0xabc') + .times(DEFAULT_MAX_RETRIES + 1) + .reply(500); + const { service } = createService(); + + await expect(service.getIntentsByAddress('0xabc')).rejects.toThrow( + "Get intents request failed with status '500'", + ); + }); + + it('throws on malformed response', async () => { + nock(BASE_URL) + .get('/v1/intent/account/0xabc') + .reply(200, JSON.stringify([{ bad: 'data' }])); + const { service } = createService(); + + await expect(service.getIntentsByAddress('0xabc')).rejects.toThrow( + 'At path: 0.account -- Expected a string, but received: undefined', + ); + }); + }); + + describe('createWithdrawal', () => { + const withdrawalParams = { + chainId: '0x1' as const, + amount: '1000000', + account: '0xabc' as const, + }; + + it('sends a POST with auth headers and returns the response', async () => { + nock(BASE_URL) + .post('/v1/withdrawal', withdrawalParams) + .matchHeader('Authorization', `Bearer ${MOCK_TOKEN}`) + .reply(200, { success: true }); + const { rootMessenger } = createService(); + + const result = await rootMessenger.call( + 'ChompApiService:createWithdrawal', + withdrawalParams, + ); + + expect(result).toStrictEqual({ success: true }); + }); + + it('throws on non-OK status', async () => { + nock(BASE_URL).post('/v1/withdrawal').reply(400); + const { service } = createService(); + + await expect(service.createWithdrawal(withdrawalParams)).rejects.toThrow( + "POST /v1/withdrawal failed with status '400'", + ); + }); + + it('throws on malformed response', async () => { + nock(BASE_URL) + .post('/v1/withdrawal') + .reply(200, JSON.stringify({ success: false })); + const { service } = createService(); + + await expect(service.createWithdrawal(withdrawalParams)).rejects.toThrow( + 'At path: success -- Expected the literal `true`', + ); + }); + }); + + describe('getServiceDetails', () => { + const serviceDetailsResponse = { + auth: { + message: 'CHOMP Authentication ', + }, + chains: { + '0xa4b1': { + autoDepositDelegate: '0xb4827a2a066cd2ef88560efdf063dd05c6c41cc7', + protocol: { + vedaProtocol: { + supportedTokens: [ + { + tokenAddress: '0xaf88d065e77c8cC2239327C5EDb3A432268e5831', + tokenDecimals: 6, + }, + ], + adapterAddress: '0x4839b1BA117BdFFA986FCfA4E5fE6b9027b8f8B1', + intentTypes: ['cash-deposit', 'cash-withdrawal'], + }, + }, + }, + }, + }; + + it('sends a GET with auth headers and chainId query param and returns the response', async () => { + nock(BASE_URL) + .get('/v1/chomp') + .query({ chainId: '0xa4b1' }) + .matchHeader('Authorization', `Bearer ${MOCK_TOKEN}`) + .reply(200, serviceDetailsResponse); + const { rootMessenger } = createService(); + + const result = await rootMessenger.call( + 'ChompApiService:getServiceDetails', + ['0xa4b1'], + ); + + expect(result).toStrictEqual(serviceDetailsResponse); + }); + + it('supports multiple chain IDs as a comma-separated query param', async () => { + nock(BASE_URL) + .get('/v1/chomp') + .query({ chainId: '0xa4b1,0x1' }) + .matchHeader('Authorization', `Bearer ${MOCK_TOKEN}`) + .reply(200, serviceDetailsResponse); + const { service } = createService(); + + const result = await service.getServiceDetails(['0xa4b1', '0x1']); + + expect(result).toStrictEqual(serviceDetailsResponse); + }); + + it('throws on non-OK status', async () => { + nock(BASE_URL).get('/v1/chomp').query({ chainId: '0xa4b1' }).reply(400); + const { service } = createService(); + + await expect(service.getServiceDetails(['0xa4b1'])).rejects.toThrow( + "GET /v1/chomp failed with status '400'", + ); + }); + + it('throws on malformed response', async () => { + nock(BASE_URL) + .get('/v1/chomp') + .query({ chainId: '0xa4b1' }) + .reply(200, JSON.stringify({ bad: 'data' })); + const { service } = createService(); + + await expect(service.getServiceDetails(['0xa4b1'])).rejects.toThrow( + 'At path: auth -- Expected an object', + ); + }); + }); + + describe('retry policy', () => { + const upgradeParams = { + r: '0x1' as const, + s: '0x2' as const, + v: 27, + yParity: 0, + address: '0xabc' as const, + chainId: '1', + nonce: '0', + }; + + it('retries 5xx responses up to the default retry limit', async () => { + let attempts = 0; + nock(BASE_URL) + .post('/v1/account-upgrade') + .times(DEFAULT_MAX_RETRIES + 1) + .reply(() => { + attempts += 1; + return [500]; + }); + const { service } = createService(); + + await expect(service.createUpgrade(upgradeParams)).rejects.toThrow( + "POST /v1/account-upgrade failed with status '500'", + ); + expect(attempts).toBe(DEFAULT_MAX_RETRIES + 1); + }); + + it.each([400, 401, 403, 404, 409, 422])( + 'does not retry %i responses', + async (status) => { + let attempts = 0; + nock(BASE_URL) + .post('/v1/account-upgrade') + .times(DEFAULT_MAX_RETRIES + 1) + .reply(() => { + attempts += 1; + return [status]; + }); + const { service } = createService(); + + await expect(service.createUpgrade(upgradeParams)).rejects.toThrow( + `POST /v1/account-upgrade failed with status '${status}'`, + ); + expect(attempts).toBe(1); + }, + ); + + it('retries 429 responses alongside 5xx (rate-limit is transient)', async () => { + let attempts = 0; + nock(BASE_URL) + .post('/v1/account-upgrade') + .times(DEFAULT_MAX_RETRIES + 1) + .reply(() => { + attempts += 1; + return [429]; + }); + const { service } = createService(); + + await expect(service.createUpgrade(upgradeParams)).rejects.toThrow( + "POST /v1/account-upgrade failed with status '429'", + ); + expect(attempts).toBe(DEFAULT_MAX_RETRIES + 1); + }); + + it('retries non-HTTP errors (e.g. network failures)', async () => { + const scope = nock(BASE_URL) + .post('/v1/account-upgrade') + .times(DEFAULT_MAX_RETRIES + 1) + .replyWithError('network down'); + const { service } = createService(); + + await expect(service.createUpgrade(upgradeParams)).rejects.toThrow( + 'network down', + ); + expect(scope.isDone()).toBe(true); + }); + + it('lets consumer-supplied policyOptions override the default retryFilterPolicy', async () => { + let attempts = 0; + nock(BASE_URL) + .post('/v1/account-upgrade') + .times(DEFAULT_MAX_RETRIES + 1) + .reply(() => { + attempts += 1; + return [409]; + }); + const { service } = createService({ + options: { policyOptions: { retryFilterPolicy: handleAll } }, + }); + + await expect(service.createUpgrade(upgradeParams)).rejects.toThrow( + "POST /v1/account-upgrade failed with status '409'", + ); + expect(attempts).toBe(DEFAULT_MAX_RETRIES + 1); + }); + }); +}); + +/** + * 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 createRootMessenger(): RootMessenger { + return new Messenger({ namespace: MOCK_ANY_NAMESPACE }); +} + +/** + * Constructs the messenger for the service under test. + * + * @param rootMessenger - The root messenger, with all external actions and + * events required by the controller's messenger. + * @returns The service-specific messenger. + */ +function createServiceMessenger( + rootMessenger: RootMessenger, +): ChompApiServiceMessenger { + return new Messenger({ + namespace: 'ChompApiService', + 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`). + * @param args.getBearerToken - The handler for the + * `AuthenticationController:getBearerToken` action. Defaults to returning + * `MOCK_TOKEN`. + * @returns The new service, root messenger, and service messenger. + */ +function createService({ + options = {}, + getBearerToken = async (): Promise => MOCK_TOKEN, +}: { + options?: Partial[0]>; + getBearerToken?: () => Promise; +} = {}): { + service: ChompApiService; + rootMessenger: RootMessenger; + messenger: ChompApiServiceMessenger; +} { + const rootMessenger = createRootMessenger(); + rootMessenger.registerActionHandler( + 'AuthenticationController:getBearerToken', + getBearerToken, + ); + const messenger = createServiceMessenger(rootMessenger); + rootMessenger.delegate({ + messenger, + actions: ['AuthenticationController:getBearerToken'], + events: [], + }); + const service = new ChompApiService({ + baseUrl: BASE_URL, + messenger, + ...options, + }); + + return { service, rootMessenger, messenger }; +} diff --git a/packages/chomp-api-service/src/chomp-api-service.ts b/packages/chomp-api-service/src/chomp-api-service.ts new file mode 100644 index 00000000000..d3348fd7a6a --- /dev/null +++ b/packages/chomp-api-service/src/chomp-api-service.ts @@ -0,0 +1,695 @@ +import { BaseDataService } from '@metamask/base-data-service'; +import type { + DataServiceCacheUpdatedEvent, + DataServiceGranularCacheUpdatedEvent, + DataServiceInvalidateQueriesAction, +} from '@metamask/base-data-service'; +import type { CreateServicePolicyOptions } from '@metamask/controller-utils'; +import { handleWhen, HttpError } from '@metamask/controller-utils'; +import type { Messenger } from '@metamask/messenger'; +import { + array, + boolean, + coerce, + create, + enums, + literal, + number, + optional, + record, + string, + type, +} from '@metamask/superstruct'; +import type { Hex } from '@metamask/utils'; +import { + bytesToHex, + sha256, + stringToBytes, + StrictHexStruct, +} from '@metamask/utils'; +import type { QueryClientConfig } from '@tanstack/query-core'; + +import type { ChompApiServiceMethodActions } from './chomp-api-service-method-action-types.js'; +import type { + AssociateAddressParams, + AssociateAddressResponse, + ProfileAddressEntry, + CreateUpgradeParams, + CreateUpgradeResponse, + UpgradeEntry, + CreateWithdrawalParams, + CreateWithdrawalResponse, + IntentEntry, + SendIntentParams, + SendIntentResponse, + ServiceDetailsResponse, + VerifyDelegationParams, + VerifyDelegationResponse, +} from './types.js'; + +// === GENERAL === + +/** + * The name of the {@link ChompApiService}, used to namespace the service's + * actions and events. + */ +export const serviceName = 'ChompApiService'; + +// === MESSENGER === + +/** + * All of the methods within {@link ChompApiService} that are exposed via the + * messenger. + */ +const MESSENGER_EXPOSED_METHODS = [ + 'associateAddress', + 'getAssociatedAddresses', + 'createUpgrade', + 'getUpgrades', + 'verifyDelegation', + 'createIntents', + 'getIntentsByAddress', + 'createWithdrawal', + 'getServiceDetails', +] as const; + +/** + * Invalidates cached queries for {@link ChompApiService}. + */ +export type ChompApiServiceInvalidateQueriesAction = + DataServiceInvalidateQueriesAction; + +/** + * Actions that {@link ChompApiService} exposes to other consumers. + */ +export type ChompApiServiceActions = + | ChompApiServiceMethodActions + | ChompApiServiceInvalidateQueriesAction; + +/** + * Actions from other messengers that {@link ChompApiService} calls. + */ +type AllowedActions = { + type: 'AuthenticationController:getBearerToken'; + handler: (entropySourceId?: string) => Promise; +}; + +/** + * Published when {@link ChompApiService}'s cache is updated. + */ +export type ChompApiServiceCacheUpdatedEvent = DataServiceCacheUpdatedEvent< + typeof serviceName +>; + +/** + * Published when a key within {@link ChompApiService}'s cache is updated. + */ +export type ChompApiServiceGranularCacheUpdatedEvent = + DataServiceGranularCacheUpdatedEvent; + +/** + * Events that {@link ChompApiService} exposes to other consumers. + */ +export type ChompApiServiceEvents = + | ChompApiServiceCacheUpdatedEvent + | ChompApiServiceGranularCacheUpdatedEvent; + +/** + * Events from other messengers that {@link ChompApiService} subscribes to. + */ +type AllowedEvents = never; + +/** + * The messenger which is restricted to actions and events accessed by + * {@link ChompApiService}. + */ +export type ChompApiServiceMessenger = Messenger< + typeof serviceName, + ChompApiServiceActions | AllowedActions, + ChompApiServiceEvents | AllowedEvents +>; + +// === RESPONSE VALIDATION === + +const AssociateAddressResponseStruct = type({ + profileId: optional(string()), + address: StrictHexStruct, + status: enums(['active', 'created']), +}); + +/** + * Parses addresses into canonical lowercase form. CHOMP stores and returns + * addresses lowercased, but `StrictHexStruct` alone accepts any casing, so + * this makes the canonical form a guarantee of the parsed data rather than a + * convention consumers must each remember. + */ +const LowercaseHexAddressStruct = coerce(StrictHexStruct, string(), (value) => + value.toLowerCase(), +); + +const ProfileAddressEntryArrayStruct = array( + type({ + profileId: string(), + address: LowercaseHexAddressStruct, + status: enums(['active']), + }), +); + +const AccountUpgradeStatusStruct = enums(['pending', 'upgraded']); + +const AuthorizationDataStruct = type({ + r: StrictHexStruct, + s: StrictHexStruct, + v: number(), + yParity: number(), + address: StrictHexStruct, + chainId: StrictHexStruct, + nonce: StrictHexStruct, +}); + +const CreateUpgradeResponseStruct = type({ + signerAddress: StrictHexStruct, + address: StrictHexStruct, + chainId: StrictHexStruct, + nonce: StrictHexStruct, + status: AccountUpgradeStatusStruct, + createdAt: string(), +}); + +const UpgradeEntryArrayStruct = array( + type({ + signerAddress: StrictHexStruct, + chainId: StrictHexStruct, + nonce: StrictHexStruct, + authorization: AuthorizationDataStruct, + status: AccountUpgradeStatusStruct, + createdAt: string(), + }), +); + +const VerifyDelegationResponseStruct = type({ + valid: boolean(), + delegationHash: optional(StrictHexStruct), + errors: optional(array(string())), +}); + +const SendIntentResponseArrayStruct = array( + type({ + delegationHash: StrictHexStruct, + metadata: type({ + allowance: StrictHexStruct, + tokenSymbol: string(), + tokenAddress: StrictHexStruct, + type: enums(['cash-deposit', 'cash-withdrawal']), + }), + createdAt: string(), + }), +); + +const IntentEntryArrayStruct = array( + type({ + account: StrictHexStruct, + delegationHash: StrictHexStruct, + chainId: StrictHexStruct, + status: enums(['active', 'revoked']), + metadata: type({ + allowance: StrictHexStruct, + tokenAddress: StrictHexStruct, + tokenSymbol: string(), + type: enums(['cash-deposit', 'cash-withdrawal']), + }), + }), +); + +const CreateWithdrawalResponseStruct = type({ + success: literal(true), +}); + +const ServiceDetailsProtocolStruct = type({ + supportedTokens: array( + type({ + tokenAddress: StrictHexStruct, + tokenDecimals: number(), + }), + ), + adapterAddress: StrictHexStruct, + intentTypes: array(enums(['cash-deposit', 'cash-withdrawal'])), +}); + +const ServiceDetailsResponseStruct = type({ + auth: type({ + message: string(), + }), + chains: record( + StrictHexStruct, + type({ + autoDepositDelegate: StrictHexStruct, + protocol: record(string(), ServiceDetailsProtocolStruct), + }), + ), +}); + +// === RETRY POLICY === + +/** + * Determines whether an error from a CHOMP API call is worth retrying. + * + * 4xx responses (e.g. 409 "already exists", 400 validation, 401/403 auth) are + * caused by the request itself and will not be resolved by re-issuing the same + * request, so they bypass the retry loop. 429 is treated as transient and + * retried alongside 5xx server errors. Non-HTTP errors (network/timeout) fall + * through to the default "retry" behaviour. + * + * @param error - The error thrown by the query function. + * @returns `true` when the error is worth retrying. + */ +function isRetryableError(error: unknown): boolean { + if (error instanceof HttpError) { + if (error.httpStatus === 429) { + return true; + } + return error.httpStatus < 400 || error.httpStatus >= 500; + } + return true; +} + +const DEFAULT_POLICY_OPTIONS: CreateServicePolicyOptions = { + retryFilterPolicy: handleWhen(isRetryableError), +}; + +// === SERVICE DEFINITION === + +/** + * This service is responsible for communicating with the CHOMP API. + * + * All requests are authenticated via JWT Bearer tokens obtained from the + * `AuthenticationController:getBearerToken` messenger action. + */ +export class ChompApiService extends BaseDataService< + typeof serviceName, + ChompApiServiceMessenger +> { + readonly #baseUrl: string; + + /** + * Constructs a new ChompApiService. + * + * @param args - The constructor arguments. + * @param args.messenger - The messenger suited for this service. + * @param args.baseUrl - The base URL of the CHOMP API. + * @param args.queryClientConfig - Configuration for the underlying TanStack + * Query client. + * @param args.policyOptions - Options to pass to `createServicePolicy`. + */ + constructor({ + messenger, + baseUrl, + queryClientConfig = {}, + policyOptions = {}, + }: { + messenger: ChompApiServiceMessenger; + baseUrl: string; + queryClientConfig?: QueryClientConfig; + policyOptions?: CreateServicePolicyOptions; + }) { + super({ + name: serviceName, + messenger, + queryClientConfig, + policyOptions: { ...DEFAULT_POLICY_OPTIONS, ...policyOptions }, + }); + + this.#baseUrl = baseUrl; + + this.messenger.registerMethodActionHandlers( + this, + MESSENGER_EXPOSED_METHODS, + ); + } + + /** + * Builds the standard headers for a CHOMP API request authenticated with + * the given bearer token. + * + * @param token - The bearer token to authenticate with. + * @returns Headers including Authorization and Content-Type. + */ + #headersForToken(token: string): Record { + return { + Authorization: `Bearer ${token}`, + 'Content-Type': 'application/json', + }; + } + + /** + * Builds the standard headers for an authenticated CHOMP API request. + * + * @returns Headers including Authorization and Content-Type. + */ + async #authHeaders(): Promise> { + const token = await this.messenger.call( + 'AuthenticationController:getBearerToken', + ); + return this.#headersForToken(token); + } + + /** + * Associates an address with a CHOMP profile. + * + * POST /v1/auth/address + * + * @param params - The association params containing signature, timestamp, + * and address. + * @returns The profile association result: `status: 'created'` for a new + * association, `status: 'active'` when the address was already associated + * with the authenticated profile. Throws on 409, which indicates the + * address is associated with a different profile. + */ + async associateAddress( + params: AssociateAddressParams, + ): Promise { + const jsonResponse = await this.fetchQuery({ + queryKey: [`${this.name}:associateAddress`, params], + staleTime: 0, + queryFn: async () => { + const headers = await this.#authHeaders(); + const response = await fetch( + new URL('/v1/auth/address', this.#baseUrl), + { + method: 'POST', + headers, + body: JSON.stringify(params), + }, + ); + + if (!response.ok) { + throw new HttpError( + response.status, + `POST /v1/auth/address failed with status '${response.status}'`, + ); + } + + return response.json(); + }, + }); + + return create(jsonResponse, AssociateAddressResponseStruct); + } + + /** + * Fetches the addresses associated with the authenticated profile. + * + * GET /v1/auth/address + * + * The result is scoped to the authenticated profile and consumers use it + * to decide whether an association already exists, so it is always fetched + * fresh (`staleTime: 0`) and evicted as soon as the call settles + * (`gcTime: 0`). The query key carries a SHA-256 digest of the bearer + * token — the same token the request is made with — so concurrent calls + * only share an in-flight request when they are for the same profile. The + * digest, not the token, is used because query keys leave the service via + * the `cacheUpdated` messenger events. + * + * @returns The active address associations; empty array if none exist. + * Addresses are lowercased. + */ + async getAssociatedAddresses(): Promise { + const token = await this.messenger.call( + 'AuthenticationController:getBearerToken', + ); + const profileKey = bytesToHex(await sha256(stringToBytes(token))); + + const jsonResponse = await this.fetchQuery({ + queryKey: [`${this.name}:getAssociatedAddresses`, profileKey], + staleTime: 0, + gcTime: 0, + queryFn: async () => { + const response = await fetch( + new URL('/v1/auth/address', this.#baseUrl), + { headers: this.#headersForToken(token) }, + ); + + if (!response.ok) { + throw new HttpError( + response.status, + `GET /v1/auth/address failed with status '${response.status}'`, + ); + } + + return response.json(); + }, + }); + + return create(jsonResponse, ProfileAddressEntryArrayStruct); + } + + /** + * Creates an account upgrade request. + * + * POST /v1/account-upgrade + * + * @param params - The upgrade params containing signature components and + * chain details. + * @returns The upgrade result. + */ + async createUpgrade( + params: CreateUpgradeParams, + ): Promise { + const jsonResponse = await this.fetchQuery({ + queryKey: [`${this.name}:createUpgrade`, params], + staleTime: 0, + queryFn: async () => { + const headers = await this.#authHeaders(); + const response = await fetch( + new URL('/v1/account-upgrade', this.#baseUrl), + { + method: 'POST', + headers, + body: JSON.stringify(params), + }, + ); + + if (!response.ok) { + throw new HttpError( + response.status, + `POST /v1/account-upgrade failed with status '${response.status}'`, + ); + } + + return response.json(); + }, + }); + + return create(jsonResponse, CreateUpgradeResponseStruct); + } + + /** + * Fetches all EIP-7702 upgrade authorizations for a given address (one per + * chain). + * + * GET /v1/account-upgrade/:address + * + * @param address - The address to look up. + * @returns The upgrade entries; empty array if none exist. + */ + async getUpgrades(address: Hex): Promise { + const jsonResponse = await this.fetchQuery({ + queryKey: [`${this.name}:getUpgrades`, address], + queryFn: async () => { + const headers = await this.#authHeaders(); + const response = await fetch( + new URL(`/v1/account-upgrade/${address}`, this.#baseUrl), + { headers }, + ); + + if (!response.ok) { + throw new HttpError( + response.status, + `Get upgrades request failed with status '${response.status}'`, + ); + } + + return response.json(); + }, + }); + + return create(jsonResponse, UpgradeEntryArrayStruct); + } + + /** + * Verifies a delegation signature. + * + * POST /v1/intent/verify-delegation + * + * @param params - The delegation verification params. + * @returns The verification result including validity and optional errors. + */ + async verifyDelegation( + params: VerifyDelegationParams, + ): Promise { + const jsonResponse = await this.fetchQuery({ + queryKey: [`${this.name}:verifyDelegation`, params], + staleTime: 0, + queryFn: async () => { + const headers = await this.#authHeaders(); + const response = await fetch( + new URL('/v1/intent/verify-delegation', this.#baseUrl), + { + method: 'POST', + headers, + body: JSON.stringify(params), + }, + ); + + if (!response.ok) { + throw new HttpError( + response.status, + `POST /v1/intent/verify-delegation failed with status '${response.status}'`, + ); + } + + return response.json(); + }, + }); + + return create(jsonResponse, VerifyDelegationResponseStruct); + } + + /** + * Submits one or more intents to the CHOMP API. + * + * POST /v1/intent + * + * @param intents - The array of intents to submit. + * @returns The array of intent responses. + */ + async createIntents( + intents: SendIntentParams[], + ): Promise { + const jsonResponse = await this.fetchQuery({ + queryKey: [`${this.name}:createIntents`, intents], + staleTime: 0, + queryFn: async () => { + const headers = await this.#authHeaders(); + const response = await fetch(new URL('/v1/intent', this.#baseUrl), { + method: 'POST', + headers, + body: JSON.stringify(intents), + }); + + if (!response.ok) { + throw new HttpError( + response.status, + `POST /v1/intent failed with status '${response.status}'`, + ); + } + + return response.json(); + }, + }); + + return create(jsonResponse, SendIntentResponseArrayStruct); + } + + /** + * Fetches intents associated with a given address. + * + * GET /v1/intent/account/:address + * + * @param address - The address to look up intents for. + * @returns The array of intents for the address. + */ + async getIntentsByAddress(address: Hex): Promise { + const jsonResponse = await this.fetchQuery({ + queryKey: [`${this.name}:getIntentsByAddress`, address], + queryFn: async () => { + const headers = await this.#authHeaders(); + const response = await fetch( + new URL(`/v1/intent/account/${address}`, this.#baseUrl), + { headers }, + ); + + if (!response.ok) { + throw new HttpError( + response.status, + `Get intents request failed with status '${response.status}'`, + ); + } + + return response.json(); + }, + }); + + return create(jsonResponse, IntentEntryArrayStruct); + } + + /** + * Creates a withdrawal for card spend flows. + * + * POST /v1/withdrawal + * + * @param params - The withdrawal params containing chainId, amount + * (decimal or hex string), and account address. + * @returns The withdrawal result. + */ + async createWithdrawal( + params: CreateWithdrawalParams, + ): Promise { + const jsonResponse = await this.fetchQuery({ + queryKey: [`${this.name}:createWithdrawal`, params], + staleTime: 0, + queryFn: async () => { + const headers = await this.#authHeaders(); + const response = await fetch(new URL('/v1/withdrawal', this.#baseUrl), { + method: 'POST', + headers, + body: JSON.stringify(params), + }); + + if (!response.ok) { + throw new HttpError( + response.status, + `POST /v1/withdrawal failed with status '${response.status}'`, + ); + } + + return response.json(); + }, + }); + + return create(jsonResponse, CreateWithdrawalResponseStruct); + } + + /** + * Retrieves service details including delegation redeemer addresses and DeFi + * contract details for signing delegations for auto-deposit functionality. + * + * GET /v1/chomp + * + * @param chainIds - Array of chain IDs (0x-prefixed hex strings) to retrieve + * details for. + * @returns The service details for the requested chains. + */ + async getServiceDetails(chainIds: Hex[]): Promise { + const jsonResponse = await this.fetchQuery({ + queryKey: [`${this.name}:getServiceDetails`, chainIds], + queryFn: async () => { + const headers = await this.#authHeaders(); + const url = new URL('/v1/chomp', this.#baseUrl); + url.searchParams.set('chainId', chainIds.join(',')); + const response = await fetch(url, { headers }); + + if (!response.ok) { + throw new HttpError( + response.status, + `GET /v1/chomp failed with status '${response.status}'`, + ); + } + + return response.json(); + }, + }); + + return create(jsonResponse, ServiceDetailsResponseStruct); + } +} diff --git a/packages/chomp-api-service/src/index.ts b/packages/chomp-api-service/src/index.ts new file mode 100644 index 00000000000..ab5b8faa26f --- /dev/null +++ b/packages/chomp-api-service/src/index.ts @@ -0,0 +1,45 @@ +export { ChompApiService } from './chomp-api-service.js'; +export type { + ChompApiServiceMessenger, + ChompApiServiceActions, + ChompApiServiceEvents, + ChompApiServiceInvalidateQueriesAction, + ChompApiServiceCacheUpdatedEvent, + ChompApiServiceGranularCacheUpdatedEvent, +} from './chomp-api-service.js'; +export type { + ChompApiServiceAssociateAddressAction, + ChompApiServiceGetAssociatedAddressesAction, + ChompApiServiceCreateUpgradeAction, + ChompApiServiceGetUpgradesAction, + ChompApiServiceVerifyDelegationAction, + ChompApiServiceCreateIntentsAction, + ChompApiServiceGetIntentsByAddressAction, + ChompApiServiceCreateWithdrawalAction, + ChompApiServiceGetServiceDetailsAction, +} from './chomp-api-service-method-action-types.js'; +export type { + AccountUpgradeStatus, + AssociateAddressParams, + AssociateAddressResponse, + AuthorizationData, + CreateUpgradeParams, + CreateUpgradeResponse, + CreateWithdrawalParams, + CreateWithdrawalResponse, + DelegationCaveat, + UpgradeEntry, + IntentEntry, + IntentMetadataParams, + IntentMetadataResponse, + ProfileAddressEntry, + SendIntentParams, + SendIntentResponse, + ServiceDetailsChain, + ServiceDetailsProtocol, + ServiceDetailsResponse, + ServiceDetailsSupportedToken, + SignedDelegation, + VerifyDelegationParams, + VerifyDelegationResponse, +} from './types.js'; diff --git a/packages/chomp-api-service/src/types.ts b/packages/chomp-api-service/src/types.ts new file mode 100644 index 00000000000..e4dba142593 --- /dev/null +++ b/packages/chomp-api-service/src/types.ts @@ -0,0 +1,192 @@ +import type { Hex } from '@metamask/utils'; + +// === COMMON TYPES === + +export type DelegationCaveat = { + enforcer: Hex; + terms: Hex; + args: Hex; +}; + +export type SignedDelegation = { + delegate: Hex; + delegator: Hex; + authority: Hex; + caveats: DelegationCaveat[]; + salt: Hex; + signature: Hex; +}; + +// === PARAMS TYPES === + +export type AssociateAddressParams = { + signature: Hex; + timestamp: number; + address: Hex; +}; + +export type CreateUpgradeParams = { + r: Hex; + s: Hex; + v: number; + yParity: number; + address: Hex; + chainId: string; + nonce: string; +}; + +export type VerifyDelegationParams = { + signedDelegation: SignedDelegation; + chainId: Hex; +}; + +export type IntentMetadataParams = { + allowance: Hex; + tokenSymbol: string; + tokenAddress: Hex; + type: 'cash-deposit' | 'cash-withdrawal'; +}; + +export type SendIntentParams = { + account: Hex; + delegationHash: Hex; + chainId: Hex; + metadata: IntentMetadataParams; +}; + +export type CreateWithdrawalParams = { + chainId: Hex; + /** Decimal integer or 0x-prefixed hex string representing the amount. */ + amount: string; + account: Hex; +}; + +// === RESPONSE TYPES === + +/** + * Returned by POST /v1/auth/address. + * + * `profileId` is only included when the address was newly associated + * (`status: 'created'`). When the address was already associated with the + * authenticated profile (`status: 'active'`), only `address` is returned. + * Both cases respond with 201; an address associated with a different + * profile responds with 409, which is surfaced as an error. + */ +export type AssociateAddressResponse = { + profileId?: string; + address: Hex; + status: 'active' | 'created'; +}; + +/** + * One entry returned by GET /v1/auth/address. The endpoint returns an array + * of these — the active address associations of the authenticated profile + * (the API filters out soft-deleted associations, so `status` is always + * `'active'`). Addresses are lowercased. + */ +export type ProfileAddressEntry = { + profileId: string; + address: Hex; + status: 'active'; +}; + +export type AccountUpgradeStatus = 'pending' | 'upgraded'; + +export type AuthorizationData = { + r: Hex; + s: Hex; + v: number; + yParity: number; + address: Hex; + chainId: Hex; + nonce: Hex; +}; + +/** + * Returned by POST /v1/account-upgrade. + */ +export type CreateUpgradeResponse = { + signerAddress: Hex; + address: Hex; + chainId: Hex; + nonce: Hex; + status: AccountUpgradeStatus; + createdAt: string; +}; + +/** + * One entry returned by GET /v1/account-upgrade/:address. The endpoint returns + * an array of these (one per chain). + */ +export type UpgradeEntry = { + signerAddress: Hex; + chainId: Hex; + nonce: Hex; + authorization: AuthorizationData; + status: AccountUpgradeStatus; + createdAt: string; +}; + +export type VerifyDelegationResponse = { + valid: boolean; + delegationHash?: Hex; + errors?: string[]; +}; + +export type IntentMetadataResponse = { + allowance: Hex; + tokenSymbol: string; + tokenAddress: Hex; + type: 'cash-deposit' | 'cash-withdrawal'; +}; + +export type SendIntentResponse = { + delegationHash: Hex; + metadata: IntentMetadataResponse; + createdAt: string; +}; + +/** + * The shape returned by GET /v1/intent/account/:address for each intent. + */ +export type IntentEntry = { + account: Hex; + delegationHash: Hex; + chainId: Hex; + status: 'active' | 'revoked'; + metadata: { + allowance: Hex; + tokenAddress: Hex; + tokenSymbol: string; + type: 'cash-deposit' | 'cash-withdrawal'; + }; +}; + +export type CreateWithdrawalResponse = { + success: true; +}; + +// === SERVICE DETAILS TYPES === + +export type ServiceDetailsSupportedToken = { + tokenAddress: Hex; + tokenDecimals: number; +}; + +export type ServiceDetailsProtocol = { + supportedTokens: ServiceDetailsSupportedToken[]; + adapterAddress: Hex; + intentTypes: ('cash-deposit' | 'cash-withdrawal')[]; +}; + +export type ServiceDetailsChain = { + autoDepositDelegate: Hex; + protocol: Record; +}; + +export type ServiceDetailsResponse = { + auth: { + message: string; + }; + chains: Record; +}; diff --git a/packages/chomp-api-service/tsconfig.build.json b/packages/chomp-api-service/tsconfig.build.json new file mode 100644 index 00000000000..c468e8dd1f5 --- /dev/null +++ b/packages/chomp-api-service/tsconfig.build.json @@ -0,0 +1,14 @@ +{ + "extends": "../../tsconfig.packages.build.json", + "compilerOptions": { + "baseUrl": "./", + "outDir": "./dist", + "rootDir": "./src" + }, + "references": [ + { "path": "../messenger/tsconfig.build.json" }, + { "path": "../controller-utils/tsconfig.build.json" }, + { "path": "../base-data-service/tsconfig.build.json" } + ], + "include": ["../../types", "./src"] +} diff --git a/packages/chomp-api-service/tsconfig.json b/packages/chomp-api-service/tsconfig.json new file mode 100644 index 00000000000..203994e1c3c --- /dev/null +++ b/packages/chomp-api-service/tsconfig.json @@ -0,0 +1,12 @@ +{ + "extends": "../../tsconfig.packages.json", + "compilerOptions": { + "baseUrl": "./" + }, + "references": [ + { "path": "../messenger" }, + { "path": "../controller-utils" }, + { "path": "../base-data-service" } + ], + "include": ["../../types", "./src"] +} diff --git a/packages/chomp-api-service/typedoc.json b/packages/chomp-api-service/typedoc.json new file mode 100644 index 00000000000..c9da015dbf8 --- /dev/null +++ b/packages/chomp-api-service/typedoc.json @@ -0,0 +1,7 @@ +{ + "entryPoints": ["./src/index.ts"], + "excludePrivate": true, + "hideGenerator": true, + "out": "docs", + "tsconfig": "./tsconfig.build.json" +} diff --git a/packages/claims-controller/CHANGELOG.md b/packages/claims-controller/CHANGELOG.md new file mode 100644 index 00000000000..8588ed60bd3 --- /dev/null +++ b/packages/claims-controller/CHANGELOG.md @@ -0,0 +1,187 @@ +# Changelog + +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [Unreleased] + +## [0.6.1] + +### Changed + +- Bump `@tanstack/query-core` from `^4.43.0` to `^5.62.16` ([#9712](https://github.com/MetaMask/core/pull/9712)) +- Bump `@metamask/base-data-service` from `^0.1.3` to `^1.0.0` ([#9972](https://github.com/MetaMask/core/pull/9972)) + +## [0.6.0] + +### Added + +- Export `ClaimsControllerOptions` and `ClaimsServiceConfig` types ([#9588](https://github.com/MetaMask/core/pull/9588)) +- Add `ClaimsService-method-action-types.ts` with generated method action types for `ClaimsService` ([#9588](https://github.com/MetaMask/core/pull/9588)) +- Add `ClaimsService:invalidateQueries` action and `ClaimsService:cacheUpdated` events via `BaseDataService` ([#9588](https://github.com/MetaMask/core/pull/9588)) +- Add `@metamask/base-data-service` `^0.1.3`, `@metamask/superstruct` `^3.1.0`, and `@tanstack/query-core` `^4.43.0` as dependencies ([#9588](https://github.com/MetaMask/core/pull/9588)) + +### Changed + +- Migrate `ClaimsService` to `BaseDataService` with TanStack Query caching, circuit-breaker policy support, and response validation ([#9588](https://github.com/MetaMask/core/pull/9588)) +- `ClaimsServiceConfig.fetchFunction` is optional and defaults to `globalThis.fetch` ([#9588](https://github.com/MetaMask/core/pull/9588)) +- Bump `@metamask/keyring-controller` from `^27.1.0` to `^27.1.1` ([#9791](https://github.com/MetaMask/core/pull/9791)) + +## [0.5.4] + +### Changed + +- Bump `@metamask/utils` from `^11.9.0` to `^11.11.0` ([#9074](https://github.com/MetaMask/core/pull/9074)) +- Bump `@metamask/controller-utils` from `^12.1.1` to `^12.3.0` ([#9083](https://github.com/MetaMask/core/pull/9083), [#9218](https://github.com/MetaMask/core/pull/9218)) +- Bump `@metamask/profile-sync-controller` from `^28.1.1` to `^29.0.0` ([#9119](https://github.com/MetaMask/core/pull/9119), [#9463](https://github.com/MetaMask/core/pull/9463), [#9779](https://github.com/MetaMask/core/pull/9779)) +- Bump `@metamask/keyring-controller` from `^27.0.0` to `^27.1.0` ([#9129](https://github.com/MetaMask/core/pull/9129)) +- Bump `@metamask/messenger` from `^1.2.0` to `^2.0.0` ([#9392](https://github.com/MetaMask/core/pull/9392)) + +## [0.5.3] + +### Changed + +- Bump `@metamask/controller-utils` from `^12.1.0` to `^12.1.1` ([#9058](https://github.com/MetaMask/core/pull/9058)) +- Bump `@metamask/keyring-controller` from `^26.0.0` to `^27.0.0` ([#9058](https://github.com/MetaMask/core/pull/9058)) + +## [0.5.2] + +### Changed + +- Bump `@metamask/controller-utils` from `^12.0.0` to `^12.1.0` ([#8774](https://github.com/MetaMask/core/pull/8774)) +- Bump `@metamask/profile-sync-controller` from `^28.0.2` to `^28.1.1` ([#8783](https://github.com/MetaMask/core/pull/8783), [#8912](https://github.com/MetaMask/core/pull/8912)) +- Bump `@metamask/keyring-controller` from `^25.5.0` to `^26.0.0` ([#8912](https://github.com/MetaMask/core/pull/8912)) + +## [0.5.1] + +### Changed + +- Bump `@metamask/keyring-controller` from `^25.1.1` to `^25.5.0` ([#8363](https://github.com/MetaMask/core/pull/8363), [#8634](https://github.com/MetaMask/core/pull/8634), [#8665](https://github.com/MetaMask/core/pull/8665), [#8722](https://github.com/MetaMask/core/pull/8722)) +- Bump `@metamask/profile-sync-controller` from `^28.0.1` to `^28.0.2` ([#8325](https://github.com/MetaMask/core/pull/8325)) +- Bump `@metamask/controller-utils` from `^11.19.0` to `^12.0.0` ([#8344](https://github.com/MetaMask/core/pull/8344), [#8755](https://github.com/MetaMask/core/pull/8755)) +- Bump `@metamask/messenger` from `^1.0.0` to `^1.2.0` ([#8364](https://github.com/MetaMask/core/pull/8364), [#8373](https://github.com/MetaMask/core/pull/8373), [#8632](https://github.com/MetaMask/core/pull/8632)) +- Bump `@metamask/base-controller` from `^9.0.1` to `^9.1.0` ([#8457](https://github.com/MetaMask/core/pull/8457)) + +## [0.5.0] + +### Added + +- Expose all public `ClaimsController` methods through its messenger ([#8219](https://github.com/MetaMask/core/pull/8219)) + - The following actions are now available: + - `ClaimsController:fetchClaimsConfigurations` + - `ClaimsController:getSubmitClaimConfig` + - `ClaimsController:generateClaimSignature` + - `ClaimsController:getClaims` + - `ClaimsController:saveOrUpdateClaimDraft` + - `ClaimsController:getClaimDrafts` + - `ClaimsController:deleteClaimDraft` + - `ClaimsController:deleteAllClaimDrafts` + - `ClaimsController:clearState` + - Corresponding action types are now exported (e.g. `ClaimsControllerGetClaimsAction`) + +### Changed + +- Bump `@metamask/base-controller` from `^9.0.0` to `^9.0.1` ([#8317](https://github.com/MetaMask/core/pull/8317)) +- Bump `@metamask/messenger` from `^0.3.0` to `^1.0.0` ([#8317](https://github.com/MetaMask/core/pull/8317)) +- Update dependencies ([#8236](https://github.com/MetaMask/core/pull/8236), [#8317](https://github.com/MetaMask/core/pull/8317)) + - Add `@metamask/keyring-controller` ^25.1.1 + - Add `@metamask/profile-sync-controller` ^28.0.1 + +### Fixed + +- Fix type of actions union within `ClaimsControllerMessenger` and `ClaimsServiceMessenger` not to be `any` ([#8236](https://github.com/MetaMask/core/pull/8236)) + - This was fixed by the addition of the dependencies above. + +## [0.4.3] + +### Changed + +- Bump `@metamask/profile-sync-controller` from `^27.0.0` to `^28.0.0` ([#7849](https://github.com/MetaMask/core/pull/7849), [#8162](https://github.com/MetaMask/core/pull/8162)) +- Bump `@metamask/controller-utils` from `^11.18.0` to `^11.19.0` ([#7995](https://github.com/MetaMask/core/pull/7995)) + +## [0.4.2] + +### Added + +- Added new public method, `clearState` to clear/reset the claims controller state. ([#7780](https://github.com/MetaMask/core/pull/7780)) + +### Changed + +- Bump `@metamask/controller-utils` from `^11.17.0` to `^11.18.0` ([#7583](https://github.com/MetaMask/core/pull/7583)) + +## [0.4.1] + +### Changed + +- Replaced global `console` logs with `ModuleLogger`. ([#7569](https://github.com/MetaMask/core/pull/7569)) + +## [0.4.0] + +### Added + +- Capture claims error and report to sentry using `Messenger.captureException` method from `@metamask/messenger`. ([#7553](https://github.com/MetaMask/core/pull/7553)) + +### Changed + +- Upgrade `@metamask/utils` from `^11.8.1` to `^11.9.0` ([#7511](https://github.com/MetaMask/core/pull/7511)) +- Bump `@metamask/controller-utils` from `^11.16.0` to `^11.17.0` ([#7534](https://github.com/MetaMask/core/pull/7534)) + +## [0.3.1] + +### Added + +- Added `updatedAt` field to the claims draft. ([#7523](https://github.com/MetaMask/core/pull/7523)) + +## [0.3.0] + +### Added + +- Added claims draft to controller and persist in the state as `drafts`. ([#7456](https://github.com/MetaMask/core/pull/7456)) +- Added public methods (CRUD) with relate to the `ClaimDraft`. ([#7456](https://github.com/MetaMask/core/pull/7456)) + +### Changed + +- Bump `@metamask/controller-utils` from `^11.15.0` to `^11.16.0` ([#7202](https://github.com/MetaMask/core/pull/7202)) + +## [0.2.0] + +### Added + +- Added new public method, `fetchClaimsConfigurations` to fetch the claims configuration from the Claims backend. ([#7109](https://github.com/MetaMask/core/pull/7109)) +- Added new states fields, `claimsConfigurations` to the controller state. ([#7109](https://github.com/MetaMask/core/pull/7109)) + - `validSubmissionWindowDays` - number of days the claim is valid for submission. + - `supportedNetworks` - supported networks for the claim submission. +- Exported `CreateClaimRequest` and `SubmitClaimConfig` types from the controller. ([#7109](https://github.com/MetaMask/core/pull/7109)) + +## [0.1.0] + +### Added + +- Added new `@metamask/claims-controller` package to handle shield subscription claims logics. ([#7072](https://github.com/MetaMask/core/pull/7072)) +- Implementation of `ClaimsController`. ([#7072](https://github.com/MetaMask/core/pull/7072)) + - `getSubmitClaimConfig`: Generate configurations required for the claim submission. + - `generateClaimSignature`: Generate signature for the claim submission. +- Implementation of Data-Service, `ClaimsService`. ([#7072](https://github.com/MetaMask/core/pull/7072)) + - `getClaims`: fetch list of users' claims from the backend. + - `getClaimById`: fetch single claim by id. + - `generateMessageForClaimSignature`: generate message to sign for the claim signature. + - `verifyClaimSignature`: verify claim signature produced by user. + +[Unreleased]: https://github.com/MetaMask/core/compare/@metamask/claims-controller@0.6.1...HEAD +[0.6.1]: https://github.com/MetaMask/core/compare/@metamask/claims-controller@0.6.0...@metamask/claims-controller@0.6.1 +[0.6.0]: https://github.com/MetaMask/core/compare/@metamask/claims-controller@0.5.4...@metamask/claims-controller@0.6.0 +[0.5.4]: https://github.com/MetaMask/core/compare/@metamask/claims-controller@0.5.3...@metamask/claims-controller@0.5.4 +[0.5.3]: https://github.com/MetaMask/core/compare/@metamask/claims-controller@0.5.2...@metamask/claims-controller@0.5.3 +[0.5.2]: https://github.com/MetaMask/core/compare/@metamask/claims-controller@0.5.1...@metamask/claims-controller@0.5.2 +[0.5.1]: https://github.com/MetaMask/core/compare/@metamask/claims-controller@0.5.0...@metamask/claims-controller@0.5.1 +[0.5.0]: https://github.com/MetaMask/core/compare/@metamask/claims-controller@0.4.3...@metamask/claims-controller@0.5.0 +[0.4.3]: https://github.com/MetaMask/core/compare/@metamask/claims-controller@0.4.2...@metamask/claims-controller@0.4.3 +[0.4.2]: https://github.com/MetaMask/core/compare/@metamask/claims-controller@0.4.1...@metamask/claims-controller@0.4.2 +[0.4.1]: https://github.com/MetaMask/core/compare/@metamask/claims-controller@0.4.0...@metamask/claims-controller@0.4.1 +[0.4.0]: https://github.com/MetaMask/core/compare/@metamask/claims-controller@0.3.1...@metamask/claims-controller@0.4.0 +[0.3.1]: https://github.com/MetaMask/core/compare/@metamask/claims-controller@0.3.0...@metamask/claims-controller@0.3.1 +[0.3.0]: https://github.com/MetaMask/core/compare/@metamask/claims-controller@0.2.0...@metamask/claims-controller@0.3.0 +[0.2.0]: https://github.com/MetaMask/core/compare/@metamask/claims-controller@0.1.0...@metamask/claims-controller@0.2.0 +[0.1.0]: https://github.com/MetaMask/core/releases/tag/@metamask/claims-controller@0.1.0 diff --git a/packages/claims-controller/LICENSE b/packages/claims-controller/LICENSE new file mode 100644 index 00000000000..c259cd7ebcf --- /dev/null +++ b/packages/claims-controller/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2025 MetaMask + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packages/claims-controller/README.md b/packages/claims-controller/README.md new file mode 100644 index 00000000000..d410c01ce0e --- /dev/null +++ b/packages/claims-controller/README.md @@ -0,0 +1,15 @@ +# `@metamask/claims-controller` + +Controller handling shield subscription claims logic + +## Installation + +`yarn add @metamask/claims-controller` + +or + +`npm install @metamask/claims-controller` + +## Contributing + +This package is part of a monorepo. Instructions for contributing can be found in the [monorepo README](https://github.com/MetaMask/core#readme). diff --git a/packages/claims-controller/jest.config.js b/packages/claims-controller/jest.config.js new file mode 100644 index 00000000000..ca084133399 --- /dev/null +++ b/packages/claims-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/claims-controller/package.json b/packages/claims-controller/package.json new file mode 100644 index 00000000000..e800b72505d --- /dev/null +++ b/packages/claims-controller/package.json @@ -0,0 +1,83 @@ +{ + "name": "@metamask/claims-controller", + "version": "0.6.1", + "description": "Controller handling shield subscription claims logic", + "keywords": [ + "Ethereum", + "MetaMask" + ], + "homepage": "https://github.com/MetaMask/core/tree/main/packages/claims-controller#readme", + "bugs": { + "url": "https://github.com/MetaMask/core/issues" + }, + "license": "MIT", + "repository": { + "type": "git", + "url": "https://github.com/MetaMask/core.git" + }, + "files": [ + "dist/" + ], + "sideEffects": false, + "main": "./dist/index.cjs", + "types": "./dist/index.d.cts", + "exports": { + ".": { + "import": { + "types": "./dist/index.d.mts", + "default": "./dist/index.mjs" + }, + "require": { + "types": "./dist/index.d.cts", + "default": "./dist/index.cjs" + } + }, + "./package.json": "./package.json" + }, + "publishConfig": { + "access": "public", + "registry": "https://registry.npmjs.org/" + }, + "scripts": { + "build": "ts-bridge --project tsconfig.build.json --verbose --clean --no-references", + "build:all": "ts-bridge --project tsconfig.build.json --verbose --clean", + "build:docs": "typedoc", + "changelog:update": "../../scripts/update-changelog.sh @metamask/claims-controller", + "changelog:validate": "../../scripts/validate-changelog.sh @metamask/claims-controller", + "lint:tsconfigs": "tsx ../../scripts/lint-tsconfigs/lint-tsconfigs.mts", + "lint:tsconfigs:fix": "tsx ../../scripts/lint-tsconfigs/lint-tsconfigs.mts --fix", + "messenger-action-types:check": "tsx ../../packages/messenger-cli/src/cli.ts --formatter oxfmt --esm --check", + "messenger-action-types:generate": "tsx ../../packages/messenger-cli/src/cli.ts --formatter oxfmt --esm --generate", + "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.1.0", + "@metamask/base-data-service": "^1.0.0", + "@metamask/controller-utils": "^12.3.0", + "@metamask/keyring-controller": "^27.1.1", + "@metamask/messenger": "^2.0.0", + "@metamask/profile-sync-controller": "^29.0.0", + "@metamask/superstruct": "^3.4.1", + "@metamask/utils": "^11.11.0", + "@tanstack/query-core": "^5.62.16" + }, + "devDependencies": { + "@metamask/auto-changelog": "^6.1.0", + "@ts-bridge/cli": "^0.6.4", + "@types/jest": "^30.0.0", + "deepmerge": "^4.2.2", + "jest": "^30.4.2", + "ts-jest": "^29.4.11", + "tsx": "^4.20.5", + "typedoc": "^0.25.13", + "typedoc-plugin-missing-exports": "^2.0.0", + "typescript": "~5.3.3" + }, + "engines": { + "node": "^18.18 || >=20" + } +} diff --git a/packages/claims-controller/src/ClaimsController-method-action-types.ts b/packages/claims-controller/src/ClaimsController-method-action-types.ts new file mode 100644 index 00000000000..4cf63e746c7 --- /dev/null +++ b/packages/claims-controller/src/ClaimsController-method-action-types.ts @@ -0,0 +1,112 @@ +/** + * This file is auto generated. + * Do not edit manually. + */ + +import type { ClaimsController } from './ClaimsController.js'; + +/** + * Fetch the required configurations for the claims service. + * + * @returns The required configurations for the claims service. + */ +export type ClaimsControllerFetchClaimsConfigurationsAction = { + type: `ClaimsController:fetchClaimsConfigurations`; + handler: ClaimsController['fetchClaimsConfigurations']; +}; + +/** + * Get required config for submitting a claim. + * + * @param claim - The claim request to get the required config for. + * @returns The required config for submitting the claim. + */ +export type ClaimsControllerGetSubmitClaimConfigAction = { + type: `ClaimsController:getSubmitClaimConfig`; + handler: ClaimsController['getSubmitClaimConfig']; +}; + +/** + * Generate a signature for a claim. + * + * @param chainId - The chain id of the claim. + * @param walletAddress - The impacted wallet address of the claim. + * @returns The signature for the claim. + */ +export type ClaimsControllerGenerateClaimSignatureAction = { + type: `ClaimsController:generateClaimSignature`; + handler: ClaimsController['generateClaimSignature']; +}; + +/** + * Get the list of claims for the current user. + * + * @returns The list of claims for the current user. + */ +export type ClaimsControllerGetClaimsAction = { + type: `ClaimsController:getClaims`; + handler: ClaimsController['getClaims']; +}; + +/** + * Save a claim draft to the state. + * If the draft name is not provided, a default name will be generated. + * If the draft with the same id already exists, it will be updated. + * + * @param draft - The draft to save. + * @returns The saved draft. + */ +export type ClaimsControllerSaveOrUpdateClaimDraftAction = { + type: `ClaimsController:saveOrUpdateClaimDraft`; + handler: ClaimsController['saveOrUpdateClaimDraft']; +}; + +/** + * Get the list of claim drafts. + * + * @returns The list of claim drafts. + */ +export type ClaimsControllerGetClaimDraftsAction = { + type: `ClaimsController:getClaimDrafts`; + handler: ClaimsController['getClaimDrafts']; +}; + +/** + * Delete a claim draft from the state. + * + * @param draftId - The ID of the draft to delete. + */ +export type ClaimsControllerDeleteClaimDraftAction = { + type: `ClaimsController:deleteClaimDraft`; + handler: ClaimsController['deleteClaimDraft']; +}; + +/** + * Delete all claim drafts from the state. + */ +export type ClaimsControllerDeleteAllClaimDraftsAction = { + type: `ClaimsController:deleteAllClaimDrafts`; + handler: ClaimsController['deleteAllClaimDrafts']; +}; + +/** + * Clears the claims state and resets to default values. + */ +export type ClaimsControllerClearStateAction = { + type: `ClaimsController:clearState`; + handler: ClaimsController['clearState']; +}; + +/** + * Union of all ClaimsController action types. + */ +export type ClaimsControllerMethodActions = + | ClaimsControllerFetchClaimsConfigurationsAction + | ClaimsControllerGetSubmitClaimConfigAction + | ClaimsControllerGenerateClaimSignatureAction + | ClaimsControllerGetClaimsAction + | ClaimsControllerSaveOrUpdateClaimDraftAction + | ClaimsControllerGetClaimDraftsAction + | ClaimsControllerDeleteClaimDraftAction + | ClaimsControllerDeleteAllClaimDraftsAction + | ClaimsControllerClearStateAction; diff --git a/packages/claims-controller/src/ClaimsController.test.ts b/packages/claims-controller/src/ClaimsController.test.ts new file mode 100644 index 00000000000..e4e1ab9dfbf --- /dev/null +++ b/packages/claims-controller/src/ClaimsController.test.ts @@ -0,0 +1,435 @@ +import { toHex } from '@metamask/controller-utils'; + +import { createMockClaimsControllerMessenger } from '../tests/mocks/messenger.js'; +import type { WithControllerArgs } from '../tests/types.js'; +import { + ClaimsController, + getDefaultClaimsControllerState, +} from './ClaimsController.js'; +import { ClaimsControllerErrorMessages, ClaimStatusEnum } from './constants.js'; +import type { + Claim, + ClaimDraft, + ClaimsConfigurationsResponse, + CreateClaimRequest, +} from './types.js'; + +const mockClaimServiceRequestHeaders = jest.fn(); +const mockClaimServiceGetClaimsApiUrl = jest.fn(); +const mockClaimServiceGenerateMessageForClaimSignature = jest.fn(); +const mockKeyringControllerSignPersonalMessage = jest.fn(); +const mockClaimsServiceGetClaims = jest.fn(); +const mockClaimsServiceFetchClaimsConfigurations = jest.fn(); + +const MOCK_CLAIM_1: Claim = { + id: 'mock-claim-1', + shortId: 'mock-claim-1', + status: ClaimStatusEnum.CREATED, + createdAt: '2021-01-01', + updatedAt: '2021-01-01', + chainId: '0x1', + email: 'test@test.com', + impactedWalletAddress: '0x123', + impactedTxHash: '0x123', + reimbursementWalletAddress: '0x456', + description: 'test description', + signature: '0xdeadbeef', +}; +const MOCK_CLAIM_2: Claim = { + id: 'mock-claim-2', + shortId: 'mock-claim-2', + status: ClaimStatusEnum.CREATED, + createdAt: '2021-01-01', + updatedAt: '2021-01-01', + chainId: '0x1', + email: 'test2@test.com', + impactedWalletAddress: '0x789', + impactedTxHash: '0x789', + reimbursementWalletAddress: '0x012', + description: 'test description 2', + signature: '0xdeadbeef', +}; + +/** + * Builds a controller based on the given options and calls the given function with that controller. + * + * @param args - Either a function, or an options bag + a function. + * @returns Whatever the callback returns. + */ +async function withController( + ...args: WithControllerArgs +): Promise { + const [{ ...rest }, fn] = args.length === 2 ? args : [{}, args[0]]; + const { messenger, rootMessenger } = createMockClaimsControllerMessenger({ + mockClaimServiceRequestHeaders, + mockClaimServiceGetClaimsApiUrl, + mockClaimServiceGenerateMessageForClaimSignature, + mockKeyringControllerSignPersonalMessage, + mockClaimsServiceGetClaims, + mockClaimsServiceFetchClaimsConfigurations, + }); + + const controller = new ClaimsController({ + messenger, + ...rest, + }); + + return await fn({ + controller, + initialState: controller.state, + messenger, + rootMessenger, + }); +} + +describe('ClaimsController', () => { + describe('constructor', () => { + it('should be defined', () => { + expect(ClaimsController).toBeDefined(); + }); + }); + + describe('fetchClaimsConfigurations', () => { + const MOCK_CONFIGURATIONS_RESPONSE: ClaimsConfigurationsResponse = { + validSubmissionWindowDays: 21, + networks: [1, 5, 11155111], + }; + + beforeEach(() => { + jest.resetAllMocks(); + + mockClaimsServiceFetchClaimsConfigurations.mockResolvedValueOnce( + MOCK_CONFIGURATIONS_RESPONSE, + ); + }); + + it('should fetch claims configurations successfully', async () => { + await withController(async ({ controller, rootMessenger }) => { + const initialState = controller.state; + const configurations = await rootMessenger.call( + 'ClaimsController:fetchClaimsConfigurations', + ); + expect(configurations).toBeDefined(); + + const expectedConfigurations = { + validSubmissionWindowDays: + MOCK_CONFIGURATIONS_RESPONSE.validSubmissionWindowDays, + supportedNetworks: MOCK_CONFIGURATIONS_RESPONSE.networks.map( + (network) => toHex(network), + ), + }; + + expect(configurations).toStrictEqual(expectedConfigurations); + expect(controller.state).not.toBe(initialState); + expect( + controller.state.claimsConfigurations.validSubmissionWindowDays, + ).toBe(MOCK_CONFIGURATIONS_RESPONSE.validSubmissionWindowDays); + expect( + controller.state.claimsConfigurations.supportedNetworks, + ).toStrictEqual(expectedConfigurations.supportedNetworks); + }); + }); + }); + + describe('getSubmitClaimConfig', () => { + const MOCK_CLAIM: CreateClaimRequest = { + chainId: '0x1', + email: 'test@test.com', + impactedWalletAddress: '0x123', + impactedTxHash: '0x123', + reimbursementWalletAddress: '0x456', + description: 'test description', + signature: + '0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef12', + }; + const MOCK_CLAIM_API = 'https://claims-api.test.com'; + const MOCK_HEADERS = { + Authorization: 'Bearer test-token', + }; + + beforeEach(() => { + jest.resetAllMocks(); + + mockClaimServiceRequestHeaders.mockResolvedValueOnce(MOCK_HEADERS); + mockClaimServiceGetClaimsApiUrl.mockReturnValueOnce(MOCK_CLAIM_API); + }); + + it('should be able to generate valid submit claim config', async () => { + await withController(async ({ rootMessenger }) => { + const submitClaimConfig = await rootMessenger.call( + 'ClaimsController:getSubmitClaimConfig', + MOCK_CLAIM, + ); + + expect(mockClaimServiceRequestHeaders).toHaveBeenCalledTimes(1); + expect(mockClaimServiceGetClaimsApiUrl).toHaveBeenCalledTimes(1); + + expect(submitClaimConfig).toBeDefined(); + expect(submitClaimConfig.headers).toStrictEqual(MOCK_HEADERS); + expect(submitClaimConfig.method).toBe('POST'); + expect(submitClaimConfig.url).toBe(`${MOCK_CLAIM_API}/claims`); + }); + }); + + it('should throw an error if the claim is already submitted', async () => { + await withController( + { + state: { + claims: [ + { + ...MOCK_CLAIM, + status: ClaimStatusEnum.SUBMITTED, + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + id: 'mock-claim-1', + shortId: 'mock-claim-1', + }, + ], + }, + }, + async ({ rootMessenger }) => { + await expect( + rootMessenger.call( + 'ClaimsController:getSubmitClaimConfig', + MOCK_CLAIM, + ), + ).rejects.toThrow( + ClaimsControllerErrorMessages.CLAIM_ALREADY_SUBMITTED, + ); + }, + ); + }); + }); + + describe('generateClaimSignature', () => { + const MOCK_WALLET_ADDRESS = '0x88069b650422308bf8b472bEaF790189f3f28309'; + const MOCK_SIWE_MESSAGE = + 'metamask.io wants you to sign in with your Ethereum account:\n0x88069b650422308bf8b472bEaF790189f3f28309\n\nSign in to MetaMask Shield Claims API\n\nURI: https://metamask.io\nVersion: 1\nChain ID: 1\nNonce: B4Y8k8lGdMml0nrqk\nIssued At: 2025-11-06T16:38:08.073Z\nExpiration Time: 2025-11-06T17:38:08.073Z'; + const MOCK_CLAIM_SIGNATURE = '0xdeadbeef'; + + beforeEach(() => { + jest.resetAllMocks(); + + mockClaimServiceGenerateMessageForClaimSignature.mockResolvedValueOnce({ + message: MOCK_SIWE_MESSAGE, + nonce: 'B4Y8k8lGdMml0nrqk', + }); + mockKeyringControllerSignPersonalMessage.mockResolvedValueOnce( + MOCK_CLAIM_SIGNATURE, + ); + }); + + it('should generate a message and signature successfully', async () => { + await withController(async ({ rootMessenger }) => { + const signature = await rootMessenger.call( + 'ClaimsController:generateClaimSignature', + 1, + MOCK_WALLET_ADDRESS, + ); + expect(signature).toBe(MOCK_CLAIM_SIGNATURE); + expect( + mockClaimServiceGenerateMessageForClaimSignature, + ).toHaveBeenCalledWith(1, MOCK_WALLET_ADDRESS); + }); + }); + + it('should throw an error if claims API response with invalid SIWE message', async () => { + await withController(async ({ rootMessenger }) => { + mockClaimServiceGenerateMessageForClaimSignature.mockRestore(); + mockClaimServiceGenerateMessageForClaimSignature.mockResolvedValueOnce({ + message: 'invalid SIWE message', + nonce: 'B4Y8k8lGdMml0nrqk', + }); + await expect( + rootMessenger.call( + 'ClaimsController:generateClaimSignature', + 1, + MOCK_WALLET_ADDRESS, + ), + ).rejects.toThrow( + ClaimsControllerErrorMessages.INVALID_SIGNATURE_MESSAGE, + ); + }); + }); + }); + + describe('getClaims', () => { + it('should be able to get the list of claims', async () => { + await withController(async ({ controller, rootMessenger }) => { + mockClaimsServiceGetClaims.mockResolvedValueOnce([ + MOCK_CLAIM_1, + MOCK_CLAIM_2, + ]); + const claims = await rootMessenger.call('ClaimsController:getClaims'); + expect(claims).toBeDefined(); + expect(claims).toStrictEqual([MOCK_CLAIM_1, MOCK_CLAIM_2]); + expect(mockClaimsServiceGetClaims).toHaveBeenCalledTimes(1); + expect(controller.state.claims).toStrictEqual([ + MOCK_CLAIM_1, + MOCK_CLAIM_2, + ]); + }); + }); + }); + + describe('Claims Drafts', () => { + const MOCK_DRAFT: Omit = { + chainId: '0x1', + email: 'test@test.com', + impactedWalletAddress: '0x123', + impactedTxHash: '0x123', + reimbursementWalletAddress: '0x456', + description: 'test description', + updatedAt: '2025-12-17T06:10:32.213Z', + }; + const MOCK_CLAIM_DRAFTS: ClaimDraft[] = [ + { + draftId: 'mock-draft-1', + chainId: '0x1', + email: 'test@test.com', + impactedWalletAddress: '0x123', + impactedTxHash: '0x123', + reimbursementWalletAddress: '0x456', + description: 'test description', + updatedAt: '2025-12-17T06:10:32.213Z', + }, + { + draftId: 'mock-draft-2', + chainId: '0x1', + email: 'test2@test.com', + impactedWalletAddress: '0x789', + impactedTxHash: '0x789', + reimbursementWalletAddress: '0x012', + description: 'test description 2', + updatedAt: '2025-12-17T06:10:32.213Z', + }, + ]; + + it('should be able to save a claim draft', async () => { + await withController(async ({ controller, rootMessenger }) => { + const initialState = controller.state; + rootMessenger.call( + 'ClaimsController:saveOrUpdateClaimDraft', + MOCK_DRAFT, + ); + const updatedState = controller.state; + expect(updatedState).not.toBe(initialState); + expect(updatedState.drafts).toHaveLength(1); + expect(updatedState.drafts[0].draftId).toBeDefined(); + expect(updatedState.drafts[0]).toMatchObject({ + ...MOCK_DRAFT, + updatedAt: expect.any(String), + }); + expect(updatedState.drafts[0].draftId).toBeDefined(); + }); + }); + + it('should be able to get the list of claim drafts', async () => { + await withController( + { + state: { + drafts: MOCK_CLAIM_DRAFTS, + }, + }, + async ({ rootMessenger }) => { + const claimDrafts = rootMessenger.call( + 'ClaimsController:getClaimDrafts', + ); + expect(claimDrafts).toBeDefined(); + expect(claimDrafts).toStrictEqual(MOCK_CLAIM_DRAFTS); + }, + ); + }); + + it('should be able to update a claim draft', async () => { + await withController( + { + state: { + drafts: MOCK_CLAIM_DRAFTS, + }, + }, + async ({ controller, rootMessenger }) => { + rootMessenger.call('ClaimsController:saveOrUpdateClaimDraft', { + draftId: 'mock-draft-1', + chainId: '0x1', + email: 'test@test.com', + impactedWalletAddress: '0x123', + impactedTxHash: '0x123', + reimbursementWalletAddress: '0x456', + description: 'test description updated', + }); + const updatedState = controller.state; + expect(updatedState.drafts[0].description).toBe( + 'test description updated', + ); + }, + ); + }); + + it('should be able to delete a claim draft', async () => { + await withController( + { + state: { + drafts: MOCK_CLAIM_DRAFTS, + }, + }, + async ({ controller, rootMessenger }) => { + const initialState = controller.state; + expect(initialState.drafts).toHaveLength(2); + rootMessenger.call( + 'ClaimsController:deleteClaimDraft', + 'mock-draft-1', + ); + const updatedState = controller.state; + expect(updatedState.drafts).toHaveLength(1); + expect(updatedState.drafts[0].draftId).toBe('mock-draft-2'); + }, + ); + }); + + it('should be able to delete all claim drafts', async () => { + await withController( + { + state: { + drafts: MOCK_CLAIM_DRAFTS, + }, + }, + async ({ controller, rootMessenger }) => { + const initialState = controller.state; + expect(initialState.drafts).toHaveLength(2); + rootMessenger.call('ClaimsController:deleteAllClaimDrafts'); + const updatedState = controller.state; + expect(updatedState.drafts).toHaveLength(0); + }, + ); + }); + }); + + describe('clearState', () => { + it('should reset state to default values', async () => { + await withController( + { + state: { + claims: [MOCK_CLAIM_1, MOCK_CLAIM_2], + drafts: [MOCK_CLAIM_1, MOCK_CLAIM_2].map((claim) => ({ + draftId: claim.id, + ...claim, + })), + }, + }, + async ({ controller, rootMessenger }) => { + expect(controller.state.claims).toHaveLength(2); + expect(controller.state.drafts).toHaveLength(2); + + rootMessenger.call('ClaimsController:clearState'); + + expect(controller.state).toStrictEqual( + getDefaultClaimsControllerState(), + ); + expect(controller.state.claims).toHaveLength(0); + expect(controller.state.drafts).toHaveLength(0); + }, + ); + }); + }); +}); diff --git a/packages/claims-controller/src/ClaimsController.ts b/packages/claims-controller/src/ClaimsController.ts new file mode 100644 index 00000000000..faea44d8279 --- /dev/null +++ b/packages/claims-controller/src/ClaimsController.ts @@ -0,0 +1,338 @@ +import type { + ControllerGetStateAction, + ControllerStateChangeEvent, + StateMetadata, +} from '@metamask/base-controller'; +import { BaseController } from '@metamask/base-controller'; +import { detectSIWE, toHex } from '@metamask/controller-utils'; +import type { KeyringControllerSignPersonalMessageAction } from '@metamask/keyring-controller'; +import type { Messenger } from '@metamask/messenger'; +import { bytesToHex, stringToBytes } from '@metamask/utils'; + +import type { ClaimsControllerMethodActions } from './ClaimsController-method-action-types.js'; +import type { + ClaimsServiceFetchClaimsConfigurationsAction, + ClaimsServiceGenerateMessageForClaimSignatureAction, + ClaimsServiceGetClaimByIdAction, + ClaimsServiceGetClaimsAction, + ClaimsServiceGetClaimsApiUrlAction, + ClaimsServiceGetRequestHeadersAction, +} from './ClaimsService-method-action-types.js'; +import { + ClaimsControllerErrorMessages, + CONTROLLER_NAME, + DEFAULT_CLAIMS_CONFIGURATIONS, + SERVICE_NAME, +} from './constants.js'; +import type { + Claim, + ClaimDraft, + ClaimsConfigurations, + ClaimsControllerState, + CreateClaimRequest, + SubmitClaimConfig, +} from './types.js'; + +export type ClaimsControllerGetStateAction = ControllerGetStateAction< + typeof CONTROLLER_NAME, + ClaimsControllerState +>; + +export type ClaimsControllerActions = + | ClaimsControllerGetStateAction + | ClaimsControllerMethodActions; + +export type AllowedActions = + | ClaimsServiceFetchClaimsConfigurationsAction + | ClaimsServiceGetClaimsAction + | ClaimsServiceGetClaimByIdAction + | ClaimsServiceGetRequestHeadersAction + | ClaimsServiceGetClaimsApiUrlAction + | ClaimsServiceGenerateMessageForClaimSignatureAction + | ClaimsServiceGetClaimsAction + | KeyringControllerSignPersonalMessageAction; + +export type ClaimsControllerStateChangeEvent = ControllerStateChangeEvent< + typeof CONTROLLER_NAME, + ClaimsControllerState +>; + +export type ClaimsControllerMessenger = Messenger< + typeof CONTROLLER_NAME, + ClaimsControllerActions | AllowedActions, + ClaimsControllerStateChangeEvent +>; + +export type ClaimsControllerOptions = { + messenger: ClaimsControllerMessenger; + state?: Partial; +}; + +const ClaimsControllerStateMetadata: StateMetadata = { + claims: { + includeInStateLogs: false, + persist: true, + includeInDebugSnapshot: false, + usedInUi: true, + }, + claimsConfigurations: { + includeInStateLogs: true, + persist: true, + includeInDebugSnapshot: true, + usedInUi: true, + }, + drafts: { + includeInStateLogs: false, + persist: true, + includeInDebugSnapshot: false, + usedInUi: true, + }, +}; + +/** + * Get an initial default state for the controller. + * + * @returns The initial default controller state. + */ +export function getDefaultClaimsControllerState(): ClaimsControllerState { + return { + claimsConfigurations: DEFAULT_CLAIMS_CONFIGURATIONS, + claims: [], + drafts: [], + }; +} + +const MESSENGER_EXPOSED_METHODS = [ + 'fetchClaimsConfigurations', + 'getSubmitClaimConfig', + 'generateClaimSignature', + 'getClaims', + 'saveOrUpdateClaimDraft', + 'getClaimDrafts', + 'deleteClaimDraft', + 'deleteAllClaimDrafts', + 'clearState', +] as const; + +export class ClaimsController extends BaseController< + typeof CONTROLLER_NAME, + ClaimsControllerState, + ClaimsControllerMessenger +> { + constructor({ messenger, state }: ClaimsControllerOptions) { + super({ + messenger, + metadata: ClaimsControllerStateMetadata, + name: CONTROLLER_NAME, + state: { ...getDefaultClaimsControllerState(), ...state }, + }); + + this.messenger.registerMethodActionHandlers( + this, + MESSENGER_EXPOSED_METHODS, + ); + } + + /** + * Fetch the required configurations for the claims service. + * + * @returns The required configurations for the claims service. + */ + async fetchClaimsConfigurations(): Promise { + const configurations = await this.messenger.call( + `${SERVICE_NAME}:fetchClaimsConfigurations`, + ); + + const supportedNetworks = configurations.networks.map((network) => + toHex(network), + ); + const claimsConfigurations = { + validSubmissionWindowDays: configurations.validSubmissionWindowDays, + supportedNetworks, + }; + + this.update((state) => { + state.claimsConfigurations = claimsConfigurations; + }); + return claimsConfigurations; + } + + /** + * Get required config for submitting a claim. + * + * @param claim - The claim request to get the required config for. + * @returns The required config for submitting the claim. + */ + async getSubmitClaimConfig( + claim: CreateClaimRequest, + ): Promise { + // Validate the claim before submitting it. + this.#validateSubmitClaimRequest(claim); + + const headers = await this.messenger.call( + `${SERVICE_NAME}:getRequestHeaders`, + ); + const baseUrl = this.messenger.call(`${SERVICE_NAME}:getClaimsApiUrl`); + const url = `${baseUrl}/claims`; + + return { + data: claim, + headers, + method: 'POST', + url, + }; + } + + /** + * Generate a signature for a claim. + * + * @param chainId - The chain id of the claim. + * @param walletAddress - The impacted wallet address of the claim. + * @returns The signature for the claim. + */ + async generateClaimSignature( + chainId: number, + walletAddress: `0x${string}`, + ): Promise { + // generate the message to be signed + const { message } = await this.messenger.call( + `${SERVICE_NAME}:generateMessageForClaimSignature`, + chainId, + walletAddress, + ); + + // generate and parse the SIWE message + const messageBytes = stringToBytes(message); + const messageHex = bytesToHex(messageBytes); + const siwe = detectSIWE({ data: messageHex }); + if (!siwe.isSIWEMessage) { + throw new Error(ClaimsControllerErrorMessages.INVALID_SIGNATURE_MESSAGE); + } + + // sign the message + const signature = await this.messenger.call( + 'KeyringController:signPersonalMessage', + { + data: message, + from: walletAddress, + siwe, + }, + ); + + return signature; + } + + /** + * Get the list of claims for the current user. + * + * @returns The list of claims for the current user. + */ + async getClaims(): Promise { + const claims = await this.messenger.call(`${SERVICE_NAME}:getClaims`); + this.update((state) => { + state.claims = claims; + }); + return claims; + } + + /** + * Save a claim draft to the state. + * If the draft name is not provided, a default name will be generated. + * If the draft with the same id already exists, it will be updated. + * + * @param draft - The draft to save. + * @returns The saved draft. + */ + saveOrUpdateClaimDraft(draft: Partial): ClaimDraft { + const { drafts } = this.state; + + const isExistingDraft = drafts.some( + (existingDraft) => + draft.draftId && existingDraft.draftId === draft.draftId, + ); + + if (isExistingDraft) { + const updatedAt = new Date().toISOString(); + this.update((state) => { + state.drafts = state.drafts.map((existingDraft) => + existingDraft.draftId === draft.draftId + ? { + ...existingDraft, + ...draft, + updatedAt, + } + : existingDraft, + ); + }); + return { ...draft, updatedAt } as ClaimDraft; + } + + // generate a new draft id, name and add it to the state + const draftId = `draft-${Date.now()}`; + + const newDraft: ClaimDraft = { + ...draft, + draftId, + updatedAt: new Date().toISOString(), + }; + + this.update((state) => { + state.drafts.push(newDraft); + }); + + return newDraft; + } + + /** + * Get the list of claim drafts. + * + * @returns The list of claim drafts. + */ + getClaimDrafts(): ClaimDraft[] { + return this.state.drafts; + } + + /** + * Delete a claim draft from the state. + * + * @param draftId - The ID of the draft to delete. + */ + deleteClaimDraft(draftId: string): void { + this.update((state) => { + state.drafts = state.drafts.filter((draft) => draft.draftId !== draftId); + }); + } + + /** + * Delete all claim drafts from the state. + */ + deleteAllClaimDrafts(): void { + this.update((state) => { + state.drafts = []; + }); + } + + /** + * Clears the claims state and resets to default values. + */ + clearState(): void { + this.update(() => { + return getDefaultClaimsControllerState(); + }); + } + + /** + * Validate the claim before submitting it. + * + * @param claim - The claim to validate. + */ + #validateSubmitClaimRequest(claim: CreateClaimRequest): void { + const { claims: existingClaims } = this.state; + const isClaimAlreadySubmitted = existingClaims.some( + (existingClaim) => existingClaim.impactedTxHash === claim.impactedTxHash, + ); + if (isClaimAlreadySubmitted) { + throw new Error(ClaimsControllerErrorMessages.CLAIM_ALREADY_SUBMITTED); + } + } +} diff --git a/packages/claims-controller/src/ClaimsService-method-action-types.ts b/packages/claims-controller/src/ClaimsService-method-action-types.ts new file mode 100644 index 00000000000..1fe4b2b9ce3 --- /dev/null +++ b/packages/claims-controller/src/ClaimsService-method-action-types.ts @@ -0,0 +1,80 @@ +/** + * This file is auto generated. + * Do not edit manually. + */ + +import type { ClaimsService } from './ClaimsService.js'; + +/** + * Fetch required configurations for the claims service. + * + * @returns The required configurations for the claims service. + */ +export type ClaimsServiceFetchClaimsConfigurationsAction = { + type: `ClaimsService:fetchClaimsConfigurations`; + handler: ClaimsService['fetchClaimsConfigurations']; +}; + +/** + * Get the claims for the current user. + * + * @returns The claims for the current user. + */ +export type ClaimsServiceGetClaimsAction = { + type: `ClaimsService:getClaims`; + handler: ClaimsService['getClaims']; +}; + +/** + * Get the claim by id. + * + * @param id - The id of the claim to get. + * @returns The claim by id. + */ +export type ClaimsServiceGetClaimByIdAction = { + type: `ClaimsService:getClaimById`; + handler: ClaimsService['getClaimById']; +}; + +/** + * Generate a message to be signed by the user for the claim request. + * + * @param chainId - The chain id of the claim. + * @param walletAddress - The impacted wallet address of the claim. + * @returns The message for the claim signature. + */ +export type ClaimsServiceGenerateMessageForClaimSignatureAction = { + type: `ClaimsService:generateMessageForClaimSignature`; + handler: ClaimsService['generateMessageForClaimSignature']; +}; + +/** + * Create the headers for the current request. + * + * @returns The headers for the current request. + */ +export type ClaimsServiceGetRequestHeadersAction = { + type: `ClaimsService:getRequestHeaders`; + handler: ClaimsService['getRequestHeaders']; +}; + +/** + * Get the URL for the claims API for the current environment. + * + * @returns The URL for the claims API for the current environment. + */ +export type ClaimsServiceGetClaimsApiUrlAction = { + type: `ClaimsService:getClaimsApiUrl`; + handler: ClaimsService['getClaimsApiUrl']; +}; + +/** + * Union of all ClaimsService action types. + */ +export type ClaimsServiceMethodActions = + | ClaimsServiceFetchClaimsConfigurationsAction + | ClaimsServiceGetClaimsAction + | ClaimsServiceGetClaimByIdAction + | ClaimsServiceGenerateMessageForClaimSignatureAction + | ClaimsServiceGetRequestHeadersAction + | ClaimsServiceGetClaimsApiUrlAction; diff --git a/packages/claims-controller/src/ClaimsService-structs.ts b/packages/claims-controller/src/ClaimsService-structs.ts new file mode 100644 index 00000000000..ae98c5fd31a --- /dev/null +++ b/packages/claims-controller/src/ClaimsService-structs.ts @@ -0,0 +1,45 @@ +import { + array, + enums, + integer, + optional, + string, + type, +} from '@metamask/superstruct'; + +import { ClaimStatusEnum } from './constants.js'; + +const HexStringStruct = string(); + +const AttachmentStruct = type({ + publicUrl: string(), + contentType: string(), + originalname: string(), +}); + +export const ClaimStruct = type({ + id: string(), + shortId: string(), + chainId: string(), + email: string(), + impactedWalletAddress: HexStringStruct, + impactedTxHash: HexStringStruct, + reimbursementWalletAddress: HexStringStruct, + description: string(), + signature: HexStringStruct, + attachments: optional(array(AttachmentStruct)), + status: enums(Object.values(ClaimStatusEnum)), + createdAt: string(), + updatedAt: string(), + intercomId: optional(string()), +}); + +export const ClaimsConfigurationsResponseStruct = type({ + validSubmissionWindowDays: integer(), + networks: array(integer()), +}); + +export const GenerateSignatureMessageResponseStruct = type({ + message: string(), + nonce: string(), +}); diff --git a/packages/claims-controller/src/ClaimsService.test.ts b/packages/claims-controller/src/ClaimsService.test.ts new file mode 100644 index 00000000000..4b8352b94ac --- /dev/null +++ b/packages/claims-controller/src/ClaimsService.test.ts @@ -0,0 +1,930 @@ +import { createMockClaimsServiceMessenger } from '../tests/mocks/messenger.js'; +import { ClaimsService } from './ClaimsService.js'; +import { + CLAIMS_API_URL_MAP, + ClaimsServiceErrorMessages, + ClaimStatusEnum, + Env, +} from './constants.js'; +import type { + Claim, + ClaimsConfigurationsResponse, + GenerateSignatureMessageResponse, +} from './types.js'; +import { createSentryError } from './utils.js'; + +const mockAuthenticationControllerGetBearerToken = jest.fn(); +const mockAuthenticationControllerGetSessionProfile = jest.fn(); +const mockFetchFunction = jest.fn(); +const mockCaptureException = jest.fn(); + +const MOCK_SESSION_PROFILE = { + identifierId: 'identifier-1', + profileId: 'profile-1', + canonicalProfileId: 'canonical-profile-1', + metaMetricsId: 'metametrics-1', +}; + +/** + * Create a mock claims service. + * + * @param env - The environment to use for the mock claims service. Defaults to Env.DEV. + * @returns A mock claims service and its messenger. + */ +function createMockClaimsService(env: Env = Env.DEV): ClaimsService { + const { messenger } = createMockClaimsServiceMessenger( + mockAuthenticationControllerGetBearerToken, + mockAuthenticationControllerGetSessionProfile, + mockCaptureException, + ); + return new ClaimsService({ + env, + messenger, + fetchFunction: mockFetchFunction, + captureException: mockCaptureException, + }); +} + +describe('ClaimsService', () => { + const MOCK_CLAIM_1: Claim = { + id: 'mock-claim-1', + shortId: 'mock-claim-1', + status: ClaimStatusEnum.CREATED, + createdAt: '2021-01-01', + updatedAt: '2021-01-01', + chainId: '0x1', + email: 'test@test.com', + impactedWalletAddress: '0x123', + impactedTxHash: '0x123', + reimbursementWalletAddress: '0x456', + description: 'test description', + signature: '0xdeadbeef', + }; + const MOCK_CLAIM_2: Claim = { + id: 'mock-claim-2', + shortId: 'mock-claim-2', + status: ClaimStatusEnum.CREATED, + createdAt: '2021-01-01', + updatedAt: '2021-01-01', + chainId: '0x1', + email: 'test2@test.com', + impactedWalletAddress: '0x789', + impactedTxHash: '0x789', + reimbursementWalletAddress: '0x012', + description: 'test description 2', + signature: '0xdeadbeef', + }; + + describe('constructor', () => { + it('should be defined', () => { + expect(ClaimsService).toBeDefined(); + }); + + it('should create instance with valid config', () => { + const { messenger } = createMockClaimsServiceMessenger( + jest.fn(), + jest.fn(), + jest.fn(), + ); + const service = new ClaimsService({ + env: Env.DEV, + messenger, + fetchFunction: jest.fn(), + }); + + expect(service).toBeInstanceOf(ClaimsService); + }); + + it('defaults fetchFunction to globalThis.fetch when omitted', async () => { + const MOCK_CONFIGURATIONS: ClaimsConfigurationsResponse = { + validSubmissionWindowDays: 21, + networks: [1, 5, 11155111], + }; + const fetchSpy = jest.spyOn(globalThis, 'fetch').mockResolvedValue({ + ok: true, + json: jest.fn().mockResolvedValue(MOCK_CONFIGURATIONS), + } as unknown as Response); + + try { + const { messenger } = createMockClaimsServiceMessenger( + jest.fn().mockResolvedValue('test-token'), + jest.fn().mockResolvedValue(MOCK_SESSION_PROFILE), + jest.fn(), + ); + const service = new ClaimsService({ + env: Env.DEV, + messenger, + }); + + await service.fetchClaimsConfigurations(); + + expect(fetchSpy).toHaveBeenCalledWith( + `${CLAIMS_API_URL_MAP[Env.DEV]}/configurations`, + expect.anything(), + ); + } finally { + fetchSpy.mockRestore(); + } + }); + }); + + describe('fetchClaimsConfigurations', () => { + const MOCK_CONFIGURATIONS: ClaimsConfigurationsResponse = { + validSubmissionWindowDays: 21, + networks: [1, 5, 11155111], + }; + + beforeEach(() => { + jest.resetAllMocks(); + + mockAuthenticationControllerGetBearerToken.mockResolvedValueOnce( + 'test-token', + ); + mockAuthenticationControllerGetSessionProfile.mockResolvedValueOnce( + MOCK_SESSION_PROFILE, + ); + mockFetchFunction.mockResolvedValueOnce({ + ok: true, + json: jest.fn().mockResolvedValueOnce(MOCK_CONFIGURATIONS), + }); + }); + + it('should fetch claims configurations successfully', async () => { + const service = createMockClaimsService(); + + const configurations = await service.fetchClaimsConfigurations(); + + expect(mockAuthenticationControllerGetBearerToken).toHaveBeenCalledTimes( + 1, + ); + expect( + mockAuthenticationControllerGetSessionProfile, + ).toHaveBeenCalledTimes(1); + expect(mockFetchFunction).toHaveBeenCalledTimes(1); + expect(mockFetchFunction).toHaveBeenCalledWith( + `${CLAIMS_API_URL_MAP[Env.DEV]}/configurations`, + { + headers: { + Authorization: 'Bearer test-token', + }, + }, + ); + expect(configurations).toStrictEqual(MOCK_CONFIGURATIONS); + }); + + it('should throw error if fetch fails', async () => { + mockFetchFunction.mockRestore(); + + mockFetchFunction.mockResolvedValueOnce({ + ok: false, + json: jest.fn().mockResolvedValueOnce(null), + }); + + const service = createMockClaimsService(); + + await expect(service.fetchClaimsConfigurations()).rejects.toThrow( + ClaimsServiceErrorMessages.FAILED_TO_FETCH_CONFIGURATIONS, + ); + }); + + it('accepts responses with unrecognized fields', async () => { + mockFetchFunction.mockRestore(); + mockAuthenticationControllerGetBearerToken.mockResolvedValueOnce( + 'test-token', + ); + mockAuthenticationControllerGetSessionProfile.mockResolvedValueOnce( + MOCK_SESSION_PROFILE, + ); + + const responseWithExtraFields = { + ...MOCK_CONFIGURATIONS, + newApiField: 'additive', + }; + mockFetchFunction.mockResolvedValueOnce({ + ok: true, + json: jest.fn().mockResolvedValueOnce(responseWithExtraFields), + }); + + const service = createMockClaimsService(); + + expect(await service.fetchClaimsConfigurations()).toStrictEqual( + responseWithExtraFields, + ); + }); + }); + + describe('getClaims', () => { + beforeEach(() => { + jest.resetAllMocks(); + + mockAuthenticationControllerGetBearerToken.mockResolvedValueOnce( + 'test-token', + ); + mockAuthenticationControllerGetSessionProfile.mockResolvedValueOnce( + MOCK_SESSION_PROFILE, + ); + mockFetchFunction.mockResolvedValueOnce({ + ok: true, + json: jest.fn().mockResolvedValueOnce([MOCK_CLAIM_1, MOCK_CLAIM_2]), + }); + }); + + it('should fetch claims successfully', async () => { + const service = createMockClaimsService(); + + const claims = await service.getClaims(); + + expect(mockAuthenticationControllerGetBearerToken).toHaveBeenCalledTimes( + 1, + ); + expect( + mockAuthenticationControllerGetSessionProfile, + ).toHaveBeenCalledTimes(1); + expect(mockFetchFunction).toHaveBeenCalledTimes(1); + expect(mockFetchFunction).toHaveBeenCalledWith( + `${CLAIMS_API_URL_MAP[Env.DEV]}/claims`, + { + headers: { + Authorization: 'Bearer test-token', + }, + }, + ); + + expect(claims).toStrictEqual([MOCK_CLAIM_1, MOCK_CLAIM_2]); + }); + + it('should throw error if fetch fails', async () => { + mockFetchFunction.mockRestore(); + + mockFetchFunction.mockResolvedValueOnce({ + ok: false, + json: jest.fn().mockResolvedValueOnce(null), + }); + + const service = createMockClaimsService(); + + await expect(service.getClaims()).rejects.toThrow( + ClaimsServiceErrorMessages.FAILED_TO_GET_CLAIMS, + ); + }); + + it('accepts claims with unrecognized fields', async () => { + mockFetchFunction.mockRestore(); + mockAuthenticationControllerGetBearerToken.mockResolvedValueOnce( + 'test-token', + ); + mockAuthenticationControllerGetSessionProfile.mockResolvedValueOnce( + MOCK_SESSION_PROFILE, + ); + + const claimWithExtraFields = { + ...MOCK_CLAIM_1, + newApiField: 'additive', + attachments: [ + { + publicUrl: 'https://example.com/file.png', + contentType: 'image/png', + originalname: 'file.png', + newAttachmentField: true, + }, + ], + }; + mockFetchFunction.mockResolvedValueOnce({ + ok: true, + json: jest.fn().mockResolvedValueOnce([claimWithExtraFields]), + }); + + const service = createMockClaimsService(); + + expect(await service.getClaims()).toStrictEqual([claimWithExtraFields]); + }); + }); + + describe('getClaimById', () => { + beforeEach(() => { + jest.resetAllMocks(); + + mockAuthenticationControllerGetBearerToken.mockResolvedValueOnce( + 'test-token', + ); + mockAuthenticationControllerGetSessionProfile.mockResolvedValueOnce( + MOCK_SESSION_PROFILE, + ); + mockFetchFunction.mockResolvedValueOnce({ + ok: true, + json: jest.fn().mockResolvedValueOnce(MOCK_CLAIM_1), + }); + }); + + it('should fetch claim by id successfully', async () => { + const service = createMockClaimsService(); + + const claim = await service.getClaimById('1'); + + expect(mockAuthenticationControllerGetBearerToken).toHaveBeenCalledTimes( + 1, + ); + expect( + mockAuthenticationControllerGetSessionProfile, + ).toHaveBeenCalledTimes(1); + expect(mockFetchFunction).toHaveBeenCalledTimes(1); + expect(mockFetchFunction).toHaveBeenCalledWith( + `${CLAIMS_API_URL_MAP[Env.DEV]}/claims/byId/1`, + { + headers: { + Authorization: 'Bearer test-token', + }, + }, + ); + + expect(claim).toStrictEqual(MOCK_CLAIM_1); + }); + + it('should throw error if fetch fails', async () => { + mockFetchFunction.mockRestore(); + + mockFetchFunction.mockResolvedValueOnce({ + ok: false, + json: jest.fn().mockResolvedValueOnce(null), + }); + + const service = createMockClaimsService(); + + await expect(service.getClaimById('1')).rejects.toThrow( + ClaimsServiceErrorMessages.FAILED_TO_GET_CLAIM_BY_ID, + ); + }); + + it('accepts a claim with unrecognized fields', async () => { + mockFetchFunction.mockRestore(); + mockAuthenticationControllerGetBearerToken.mockResolvedValueOnce( + 'test-token', + ); + mockAuthenticationControllerGetSessionProfile.mockResolvedValueOnce( + MOCK_SESSION_PROFILE, + ); + + const claimWithExtraFields = { + ...MOCK_CLAIM_1, + newApiField: 'additive', + }; + mockFetchFunction.mockResolvedValueOnce({ + ok: true, + json: jest.fn().mockResolvedValueOnce(claimWithExtraFields), + }); + + const service = createMockClaimsService(); + + expect(await service.getClaimById('1')).toStrictEqual( + claimWithExtraFields, + ); + }); + + it('should handle fetch error and capture exception', async () => { + mockFetchFunction.mockRestore(); + + mockFetchFunction.mockRejectedValueOnce(new Error('Fetch error')); + + const service = createMockClaimsService(); + + await expect(service.getClaimById('1')).rejects.toThrow( + ClaimsServiceErrorMessages.FAILED_TO_GET_CLAIM_BY_ID, + ); + + expect(mockCaptureException).toHaveBeenCalledWith( + createSentryError( + ClaimsServiceErrorMessages.FAILED_TO_GET_CLAIM_BY_ID, + new Error('Fetch error'), + ), + ); + }); + }); + + describe('generateMessageForClaimSignature', () => { + const MOCK_MESSAGE: GenerateSignatureMessageResponse = { + message: 'test message', + nonce: 'test nonce', + }; + + beforeEach(() => { + jest.resetAllMocks(); + + mockAuthenticationControllerGetBearerToken.mockResolvedValueOnce( + 'test-token', + ); + mockAuthenticationControllerGetSessionProfile.mockResolvedValueOnce( + MOCK_SESSION_PROFILE, + ); + mockFetchFunction.mockResolvedValueOnce({ + ok: true, + json: jest.fn().mockResolvedValueOnce({ + message: 'test message', + nonce: 'test nonce', + }), + }); + }); + + it('should generate message for claim signature successfully', async () => { + const service = createMockClaimsService(); + + const message = await service.generateMessageForClaimSignature( + 1, + '0x123', + ); + + expect(mockAuthenticationControllerGetBearerToken).toHaveBeenCalledTimes( + 1, + ); + expect( + mockAuthenticationControllerGetSessionProfile, + ).not.toHaveBeenCalled(); + expect(mockFetchFunction).toHaveBeenCalledTimes(1); + expect(mockFetchFunction).toHaveBeenCalledWith( + `${CLAIMS_API_URL_MAP[Env.DEV]}/signature/generateMessage`, + { + headers: { + Authorization: 'Bearer test-token', + 'Content-Type': 'application/json', + }, + method: 'POST', + body: JSON.stringify({ + chainId: 1, + walletAddress: '0x123', + }), + }, + ); + + expect(message).toStrictEqual(MOCK_MESSAGE); + }); + + it('should throw error if fetch fails', async () => { + mockFetchFunction.mockRestore(); + + mockFetchFunction.mockResolvedValueOnce({ + ok: false, + status: 500, + json: jest.fn().mockResolvedValueOnce(null), + }); + + const service = createMockClaimsService(); + + await expect( + service.generateMessageForClaimSignature(1, '0x123'), + ).rejects.toThrow( + ClaimsServiceErrorMessages.SIGNATURE_MESSAGE_GENERATION_FAILED, + ); + + expect(mockCaptureException).toHaveBeenCalledWith( + createSentryError( + ClaimsServiceErrorMessages.SIGNATURE_MESSAGE_GENERATION_FAILED, + new Error('error: Unknown error, statusCode: 500'), + ), + ); + }); + + it('accepts responses with unrecognized fields', async () => { + mockFetchFunction.mockRestore(); + mockAuthenticationControllerGetBearerToken.mockResolvedValueOnce( + 'test-token', + ); + + const responseWithExtraFields = { + ...MOCK_MESSAGE, + newApiField: 'additive', + }; + mockFetchFunction.mockResolvedValueOnce({ + ok: true, + json: jest.fn().mockResolvedValueOnce(responseWithExtraFields), + }); + + const service = createMockClaimsService(); + + expect( + await service.generateMessageForClaimSignature(1, '0x123'), + ).toStrictEqual(responseWithExtraFields); + }); + }); + + describe('caching', () => { + const MOCK_CONFIGURATIONS: ClaimsConfigurationsResponse = { + validSubmissionWindowDays: 21, + networks: [1, 5, 11155111], + }; + + beforeEach(() => { + jest.resetAllMocks(); + }); + + it('deduplicates cached GET requests', async () => { + mockAuthenticationControllerGetBearerToken.mockResolvedValue( + 'test-token', + ); + mockAuthenticationControllerGetSessionProfile.mockResolvedValue( + MOCK_SESSION_PROFILE, + ); + mockFetchFunction.mockResolvedValue({ + ok: true, + json: jest.fn().mockResolvedValue(MOCK_CONFIGURATIONS), + }); + + const service = createMockClaimsService(); + + await service.fetchClaimsConfigurations(); + await service.fetchClaimsConfigurations(); + + expect(mockFetchFunction).toHaveBeenCalledTimes(1); + }); + + it('does not cache signature message POST requests', async () => { + mockAuthenticationControllerGetBearerToken.mockResolvedValue( + 'test-token', + ); + mockFetchFunction.mockResolvedValue({ + ok: true, + json: jest.fn().mockResolvedValue({ + message: 'test message', + nonce: 'test nonce', + }), + }); + + const service = createMockClaimsService(); + + await service.generateMessageForClaimSignature(1, '0x123'); + await service.generateMessageForClaimSignature(1, '0x123'); + + expect(mockFetchFunction).toHaveBeenCalledTimes(2); + }); + + it('does not deduplicate concurrent signature message POST requests', async () => { + mockAuthenticationControllerGetBearerToken.mockResolvedValue( + 'test-token', + ); + + mockFetchFunction.mockImplementation(async () => { + await new Promise((resolve) => { + setTimeout(resolve, 50); + }); + return { + ok: true, + json: jest.fn().mockResolvedValue({ + message: `test message ${mockFetchFunction.mock.calls.length}`, + nonce: `test nonce ${mockFetchFunction.mock.calls.length}`, + }), + }; + }); + + const service = createMockClaimsService(); + + await Promise.all([ + service.generateMessageForClaimSignature(1, '0x123'), + service.generateMessageForClaimSignature(1, '0x123'), + ]); + + expect(mockFetchFunction).toHaveBeenCalledTimes(2); + }); + + it('does not serve cached claims across different profile ids', async () => { + const profiles = [ + { ...MOCK_SESSION_PROFILE, profileId: 'profile-a' }, + { ...MOCK_SESSION_PROFILE, profileId: 'profile-b' }, + ]; + mockAuthenticationControllerGetBearerToken.mockResolvedValue( + 'same-token', + ); + mockAuthenticationControllerGetSessionProfile.mockImplementation( + async () => profiles.shift() ?? MOCK_SESSION_PROFILE, + ); + mockFetchFunction + .mockResolvedValueOnce({ + ok: true, + json: jest.fn().mockResolvedValue([MOCK_CLAIM_1]), + }) + .mockResolvedValueOnce({ + ok: true, + json: jest.fn().mockResolvedValue([MOCK_CLAIM_2]), + }); + + const service = createMockClaimsService(); + + const first = await service.getClaims(); + const second = await service.getClaims(); + + expect(first).toStrictEqual([MOCK_CLAIM_1]); + expect(second).toStrictEqual([MOCK_CLAIM_2]); + expect(mockFetchFunction).toHaveBeenCalledTimes(2); + }); + + it('does not share an in-flight getClaims request across different profile ids', async () => { + const profiles = [ + { ...MOCK_SESSION_PROFILE, profileId: 'profile-a' }, + { ...MOCK_SESSION_PROFILE, profileId: 'profile-b' }, + ]; + mockAuthenticationControllerGetBearerToken.mockResolvedValue( + 'same-token', + ); + mockAuthenticationControllerGetSessionProfile.mockImplementation( + async () => profiles.shift() ?? MOCK_SESSION_PROFILE, + ); + let fetchCount = 0; + mockFetchFunction.mockImplementation(async () => { + const index = fetchCount; + fetchCount += 1; + await new Promise((resolve) => { + setTimeout(resolve, 50); + }); + return { + ok: true, + json: jest + .fn() + .mockResolvedValue(index === 0 ? [MOCK_CLAIM_1] : [MOCK_CLAIM_2]), + }; + }); + + const service = createMockClaimsService(); + + const [first, second] = await Promise.all([ + service.getClaims(), + service.getClaims(), + ]); + + expect(first).toStrictEqual([MOCK_CLAIM_1]); + expect(second).toStrictEqual([MOCK_CLAIM_2]); + expect(mockFetchFunction).toHaveBeenCalledTimes(2); + }); + + it('reuses cached configurations across bearer token refreshes for the same profile', async () => { + mockAuthenticationControllerGetBearerToken + .mockResolvedValueOnce('token-1') + .mockResolvedValueOnce('token-2'); + mockAuthenticationControllerGetSessionProfile.mockResolvedValue( + MOCK_SESSION_PROFILE, + ); + mockFetchFunction.mockResolvedValue({ + ok: true, + json: jest.fn().mockResolvedValue(MOCK_CONFIGURATIONS), + }); + + const service = createMockClaimsService(); + + await service.fetchClaimsConfigurations(); + await service.fetchClaimsConfigurations(); + + expect(mockFetchFunction).toHaveBeenCalledTimes(1); + }); + + it('does not leak the bearer token through cache update events', async () => { + mockAuthenticationControllerGetBearerToken.mockResolvedValue( + 'sensitive-token', + ); + mockAuthenticationControllerGetSessionProfile.mockResolvedValue( + MOCK_SESSION_PROFILE, + ); + mockFetchFunction.mockResolvedValue({ + ok: true, + json: jest.fn().mockResolvedValue([MOCK_CLAIM_1]), + }); + + const { messenger } = createMockClaimsServiceMessenger( + mockAuthenticationControllerGetBearerToken, + mockAuthenticationControllerGetSessionProfile, + mockCaptureException, + ); + const publishSpy = jest.spyOn(messenger, 'publish'); + const service = new ClaimsService({ + env: Env.DEV, + messenger, + fetchFunction: mockFetchFunction, + }); + + await service.getClaims(); + + expect(publishSpy).toHaveBeenCalled(); + expect(JSON.stringify(publishSpy.mock.calls)).not.toContain( + 'sensitive-token', + ); + }); + + it('always fetches fresh claims on repeated calls (staleTime: 0)', async () => { + mockAuthenticationControllerGetBearerToken.mockResolvedValue( + 'test-token', + ); + mockAuthenticationControllerGetSessionProfile.mockResolvedValue( + MOCK_SESSION_PROFILE, + ); + mockFetchFunction + .mockResolvedValueOnce({ + ok: true, + json: jest.fn().mockResolvedValue([MOCK_CLAIM_1]), + }) + .mockResolvedValueOnce({ + ok: true, + json: jest.fn().mockResolvedValue([MOCK_CLAIM_1, MOCK_CLAIM_2]), + }); + + const service = createMockClaimsService(); + + const first = await service.getClaims(); + const second = await service.getClaims(); + + expect(first).toStrictEqual([MOCK_CLAIM_1]); + expect(second).toStrictEqual([MOCK_CLAIM_1, MOCK_CLAIM_2]); + expect(mockFetchFunction).toHaveBeenCalledTimes(2); + }); + + it('always fetches fresh claim-by-id on repeated calls (staleTime: 0)', async () => { + const updatedClaim = { + ...MOCK_CLAIM_1, + status: ClaimStatusEnum.SUBMITTED, + }; + mockAuthenticationControllerGetBearerToken.mockResolvedValue( + 'test-token', + ); + mockAuthenticationControllerGetSessionProfile.mockResolvedValue( + MOCK_SESSION_PROFILE, + ); + mockFetchFunction + .mockResolvedValueOnce({ + ok: true, + json: jest.fn().mockResolvedValue(MOCK_CLAIM_1), + }) + .mockResolvedValueOnce({ + ok: true, + json: jest.fn().mockResolvedValue(updatedClaim), + }); + + const service = createMockClaimsService(); + + const first = await service.getClaimById('mock-claim-1'); + const second = await service.getClaimById('mock-claim-1'); + + expect(first).toStrictEqual(MOCK_CLAIM_1); + expect(second).toStrictEqual(updatedClaim); + expect(mockFetchFunction).toHaveBeenCalledTimes(2); + }); + + it('does not cache malformed GET responses', async () => { + mockAuthenticationControllerGetBearerToken.mockResolvedValue( + 'test-token', + ); + mockAuthenticationControllerGetSessionProfile.mockResolvedValue( + MOCK_SESSION_PROFILE, + ); + mockFetchFunction + .mockResolvedValueOnce({ + ok: true, + json: jest.fn().mockResolvedValue({ invalid: true }), + }) + .mockResolvedValueOnce({ + ok: true, + json: jest.fn().mockResolvedValue(MOCK_CONFIGURATIONS), + }); + + const service = createMockClaimsService(); + + await expect(service.fetchClaimsConfigurations()).rejects.toThrow( + ClaimsServiceErrorMessages.FAILED_TO_FETCH_CONFIGURATIONS, + ); + + const configurations = await service.fetchClaimsConfigurations(); + + expect(configurations).toStrictEqual(MOCK_CONFIGURATIONS); + expect(mockFetchFunction).toHaveBeenCalledTimes(2); + }); + + it('publishes cacheUpdated events for cached GET requests', async () => { + mockAuthenticationControllerGetBearerToken.mockResolvedValue( + 'test-token', + ); + mockAuthenticationControllerGetSessionProfile.mockResolvedValue( + MOCK_SESSION_PROFILE, + ); + mockFetchFunction.mockResolvedValue({ + ok: true, + json: jest.fn().mockResolvedValue(MOCK_CONFIGURATIONS), + }); + + const { messenger } = createMockClaimsServiceMessenger( + mockAuthenticationControllerGetBearerToken, + mockAuthenticationControllerGetSessionProfile, + mockCaptureException, + ); + const publishSpy = jest.spyOn(messenger, 'publish'); + const service = new ClaimsService({ + env: Env.DEV, + messenger, + fetchFunction: mockFetchFunction, + }); + + await service.fetchClaimsConfigurations(); + + expect(publishSpy).toHaveBeenCalledWith( + 'ClaimsService:cacheUpdated', + expect.objectContaining({ + type: 'updated', + }), + ); + }); + }); + + describe('response validation', () => { + beforeEach(() => { + jest.resetAllMocks(); + mockAuthenticationControllerGetBearerToken.mockResolvedValue( + 'test-token', + ); + mockAuthenticationControllerGetSessionProfile.mockResolvedValue( + MOCK_SESSION_PROFILE, + ); + }); + + it('throws when configurations response is malformed', async () => { + mockFetchFunction.mockResolvedValue({ + ok: true, + json: jest.fn().mockResolvedValue({ invalid: true }), + }); + + const service = createMockClaimsService(); + + await expect(service.fetchClaimsConfigurations()).rejects.toThrow( + ClaimsServiceErrorMessages.FAILED_TO_FETCH_CONFIGURATIONS, + ); + }); + + it('throws when claims response is malformed', async () => { + mockFetchFunction.mockResolvedValue({ + ok: true, + json: jest.fn().mockResolvedValue([{ invalid: true }]), + }); + + const service = createMockClaimsService(); + + await expect(service.getClaims()).rejects.toThrow( + ClaimsServiceErrorMessages.FAILED_TO_GET_CLAIMS, + ); + }); + }); + + describe('captureException', () => { + it('falls back to messenger.captureException when config captureException is omitted', async () => { + jest.resetAllMocks(); + mockAuthenticationControllerGetBearerToken.mockResolvedValue( + 'test-token', + ); + mockAuthenticationControllerGetSessionProfile.mockResolvedValue( + MOCK_SESSION_PROFILE, + ); + mockFetchFunction.mockRejectedValue(new Error('Fetch error')); + + const { messenger } = createMockClaimsServiceMessenger( + mockAuthenticationControllerGetBearerToken, + mockAuthenticationControllerGetSessionProfile, + mockCaptureException, + ); + const service = new ClaimsService({ + env: Env.DEV, + messenger, + fetchFunction: mockFetchFunction, + }); + + await expect(service.getClaimById('1')).rejects.toThrow( + ClaimsServiceErrorMessages.FAILED_TO_GET_CLAIM_BY_ID, + ); + + expect(mockCaptureException).toHaveBeenCalledWith( + createSentryError( + ClaimsServiceErrorMessages.FAILED_TO_GET_CLAIM_BY_ID, + new Error('Fetch error'), + ), + ); + }); + + it('ignores errors thrown by captureException', async () => { + jest.resetAllMocks(); + mockAuthenticationControllerGetBearerToken.mockResolvedValue( + 'test-token', + ); + mockAuthenticationControllerGetSessionProfile.mockResolvedValue( + MOCK_SESSION_PROFILE, + ); + mockFetchFunction.mockRejectedValue(new Error('Fetch error')); + + const { messenger } = createMockClaimsServiceMessenger( + mockAuthenticationControllerGetBearerToken, + mockAuthenticationControllerGetSessionProfile, + jest.fn(), + ); + const service = new ClaimsService({ + env: Env.DEV, + messenger, + fetchFunction: mockFetchFunction, + captureException: (_error: Error): void => { + throw new Error('capture failed'); + }, + }); + + await expect(service.getClaimById('1')).rejects.toThrow( + ClaimsServiceErrorMessages.FAILED_TO_GET_CLAIM_BY_ID, + ); + }); + }); +}); diff --git a/packages/claims-controller/src/ClaimsService.ts b/packages/claims-controller/src/ClaimsService.ts new file mode 100644 index 00000000000..a5dc41c02ce --- /dev/null +++ b/packages/claims-controller/src/ClaimsService.ts @@ -0,0 +1,400 @@ +import { BaseDataService } from '@metamask/base-data-service'; +import type { + DataServiceCacheUpdatedEvent, + DataServiceGranularCacheUpdatedEvent, + DataServiceInvalidateQueriesAction, +} from '@metamask/base-data-service'; +import type { CreateServicePolicyOptions } from '@metamask/controller-utils'; +import type { Messenger } from '@metamask/messenger'; +import type { AuthenticationController } from '@metamask/profile-sync-controller'; +import { array, validate } from '@metamask/superstruct'; +import type { Struct } from '@metamask/superstruct'; +import type { Hex } from '@metamask/utils'; +import type { QueryClientConfig } from '@tanstack/query-core'; + +import type { ClaimsServiceMethodActions } from './ClaimsService-method-action-types.js'; +import { + ClaimStruct, + ClaimsConfigurationsResponseStruct, + GenerateSignatureMessageResponseStruct, +} from './ClaimsService-structs.js'; +import { + CLAIMS_API_URL_MAP, + ClaimsServiceErrorMessages, + SERVICE_NAME, +} from './constants.js'; +import type { Env } from './constants.js'; +import { createModuleLogger, projectLogger } from './logger.js'; +import type { + Claim, + ClaimsConfigurationsResponse, + GenerateSignatureMessageResponse, +} from './types.js'; +import { createSentryError, getErrorFromResponse } from './utils.js'; + +const MESSENGER_EXPOSED_METHODS = [ + 'fetchClaimsConfigurations', + 'getClaims', + 'getClaimById', + 'getRequestHeaders', + 'getClaimsApiUrl', + 'generateMessageForClaimSignature', +] as const; + +const DEFAULT_POLICY_OPTIONS: CreateServicePolicyOptions = { + maxRetries: 0, +}; + +/** + * Invalidates cached queries for {@link ClaimsService}. + */ +export type ClaimsServiceInvalidateQueriesAction = + DataServiceInvalidateQueriesAction; + +/** + * Actions that {@link ClaimsService} exposes to other consumers. + */ +export type ClaimsServiceActions = + | ClaimsServiceMethodActions + | ClaimsServiceInvalidateQueriesAction; + +/** + * Actions from other messengers that {@link ClaimsService} calls. + */ +export type AllowedActions = + | AuthenticationController.AuthenticationControllerGetBearerTokenAction + | AuthenticationController.AuthenticationControllerGetSessionProfileAction; + +/** + * Published when {@link ClaimsService}'s cache is updated. + */ +export type ClaimsServiceCacheUpdatedEvent = DataServiceCacheUpdatedEvent< + typeof SERVICE_NAME +>; + +/** + * Published when a key within {@link ClaimsService}'s cache is updated. + */ +export type ClaimsServiceGranularCacheUpdatedEvent = + DataServiceGranularCacheUpdatedEvent; + +/** + * Events that {@link ClaimsService} exposes to other consumers. + */ +export type ClaimsServiceEvents = + | ClaimsServiceCacheUpdatedEvent + | ClaimsServiceGranularCacheUpdatedEvent; + +/** + * Events from other messengers that {@link ClaimsService} subscribes to. + */ +type AllowedEvents = never; + +/** + * The messenger which is restricted to actions and events accessed by + * {@link ClaimsService}. + */ +export type ClaimsServiceMessenger = Messenger< + typeof SERVICE_NAME, + ClaimsServiceActions | AllowedActions, + ClaimsServiceEvents | AllowedEvents +>; + +export type ClaimsServiceConfig = { + env: Env; + messenger: ClaimsServiceMessenger; + /** + * The `fetch` function to use for requests. Defaults to the global `fetch`. + */ + fetchFunction?: typeof fetch; + captureException?: (error: Error) => void; + queryClientConfig?: QueryClientConfig; + policyOptions?: CreateServicePolicyOptions; +}; + +const log = createModuleLogger(projectLogger, 'ClaimsService'); + +/** + * This service is responsible for communicating with the Claims API. + * + * All requests are authenticated via JWT Bearer tokens obtained from the + * `AuthenticationController:getBearerToken` messenger action. Cached GET + * queries are scoped by `profileId` from + * `AuthenticationController:getSessionProfile`. + */ +export class ClaimsService extends BaseDataService< + typeof SERVICE_NAME, + ClaimsServiceMessenger +> { + readonly #env: Env; + + readonly #fetch: typeof fetch; + + readonly #captureException?: (error: Error) => void; + + constructor({ + env, + messenger, + fetchFunction = globalThis.fetch, + captureException: captureExceptionFn, + queryClientConfig = {}, + policyOptions = {}, + }: ClaimsServiceConfig) { + super({ + name: SERVICE_NAME, + messenger, + queryClientConfig, + policyOptions: { ...DEFAULT_POLICY_OPTIONS, ...policyOptions }, + }); + + this.#env = env; + this.#fetch = fetchFunction; + this.#captureException = (error: Error): void => { + try { + (captureExceptionFn ?? messenger.captureException)?.(error); + } catch { + // ignore error thrown when calling captureException + } + }; + + this.messenger.registerMethodActionHandlers( + this, + MESSENGER_EXPOSED_METHODS, + ); + } + + /** + * Fetch required configurations for the claims service. + * + * @returns The required configurations for the claims service. + */ + async fetchClaimsConfigurations(): Promise { + try { + const { bearerToken, profileId } = await this.#getAuthContext(); + + return await this.fetchQuery({ + queryKey: [`${this.name}:fetchClaimsConfigurations`, profileId], + queryFn: async () => { + const url = `${this.getClaimsApiUrl()}/configurations`; + const response = await this.#fetch(url, { + headers: this.#headersForToken(bearerToken), + }); + + if (!response.ok) { + throw await getErrorFromResponse(response); + } + + const configurations = await response.json(); + + return this.#validateResponse( + configurations, + ClaimsConfigurationsResponseStruct, + ClaimsServiceErrorMessages.FAILED_TO_FETCH_CONFIGURATIONS, + ); + }, + }); + } catch (error) { + return this.#handleError( + 'fetchClaimsConfigurations', + ClaimsServiceErrorMessages.FAILED_TO_FETCH_CONFIGURATIONS, + error, + ); + } + } + + /** + * Get the claims for the current user. + * + * @returns The claims for the current user. + */ + async getClaims(): Promise { + try { + const { bearerToken, profileId } = await this.#getAuthContext(); + + return await this.fetchQuery({ + queryKey: [`${this.name}:getClaims`, profileId], + // TODO: Restore default staleTime once claim reads are invalidated + // after a successful external submit (not in getSubmitClaimConfig). + staleTime: 0, + queryFn: async () => { + const url = `${this.getClaimsApiUrl()}/claims`; + const response = await this.#fetch(url, { + headers: this.#headersForToken(bearerToken), + }); + + if (!response.ok) { + throw await getErrorFromResponse(response); + } + + const claims = await response.json(); + + return this.#validateResponse( + claims, + array(ClaimStruct), + ClaimsServiceErrorMessages.FAILED_TO_GET_CLAIMS, + ); + }, + }); + } catch (error) { + return this.#handleError( + 'getClaims', + ClaimsServiceErrorMessages.FAILED_TO_GET_CLAIMS, + error, + ); + } + } + + /** + * Get the claim by id. + * + * @param id - The id of the claim to get. + * @returns The claim by id. + */ + async getClaimById(id: string): Promise { + try { + const { bearerToken, profileId } = await this.#getAuthContext(); + + return await this.fetchQuery({ + queryKey: [`${this.name}:getClaimById`, id, profileId], + // TODO: Restore default staleTime once claim reads are invalidated + // after a successful external submit (not in getSubmitClaimConfig). + staleTime: 0, + queryFn: async () => { + const url = `${this.getClaimsApiUrl()}/claims/byId/${id}`; + const response = await this.#fetch(url, { + headers: this.#headersForToken(bearerToken), + }); + + if (!response.ok) { + throw await getErrorFromResponse(response); + } + + const claim = await response.json(); + + return this.#validateResponse( + claim, + ClaimStruct, + ClaimsServiceErrorMessages.FAILED_TO_GET_CLAIM_BY_ID, + ); + }, + }); + } catch (error) { + return this.#handleError( + 'getClaimById', + ClaimsServiceErrorMessages.FAILED_TO_GET_CLAIM_BY_ID, + error, + ); + } + } + + /** + * Generate a message to be signed by the user for the claim request. + * + * @param chainId - The chain id of the claim. + * @param walletAddress - The impacted wallet address of the claim. + * @returns The message for the claim signature. + */ + async generateMessageForClaimSignature( + chainId: number, + walletAddress: Hex, + ): Promise { + try { + const headers = await this.getRequestHeaders(); + const url = `${this.getClaimsApiUrl()}/signature/generateMessage`; + const response = await this.#fetch(url, { + method: 'POST', + headers: { + ...headers, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + chainId, + walletAddress, + }), + }); + + if (!response.ok) { + throw await getErrorFromResponse(response); + } + + const message = await response.json(); + + return this.#validateResponse( + message, + GenerateSignatureMessageResponseStruct, + ClaimsServiceErrorMessages.SIGNATURE_MESSAGE_GENERATION_FAILED, + ); + } catch (error) { + return this.#handleError( + 'generateMessageForClaimSignature', + ClaimsServiceErrorMessages.SIGNATURE_MESSAGE_GENERATION_FAILED, + error, + ); + } + } + + /** + * Create the headers for the current request. + * + * @returns The headers for the current request. + */ + async getRequestHeaders(): Promise> { + const bearerToken = await this.messenger.call( + 'AuthenticationController:getBearerToken', + ); + return this.#headersForToken(bearerToken); + } + + async #getAuthContext(): Promise<{ + bearerToken: string; + profileId: string; + }> { + const [bearerToken, sessionProfile] = await Promise.all([ + this.messenger.call('AuthenticationController:getBearerToken'), + this.messenger.call('AuthenticationController:getSessionProfile'), + ]); + + return { + bearerToken, + profileId: sessionProfile.profileId, + }; + } + + #headersForToken(token: string): Record { + return { + Authorization: `Bearer ${token}`, + }; + } + + /** + * Get the URL for the claims API for the current environment. + * + * @returns The URL for the claims API for the current environment. + */ + getClaimsApiUrl(): string { + return `${CLAIMS_API_URL_MAP[this.#env]}`; + } + + #validateResponse( + responseData: unknown, + struct: Struct, + errorMessage: string, + ): TValidated { + const [error, validatedResponseData] = validate(responseData, struct); + if (error) { + throw new Error(`${errorMessage}: ${error.message}`); + } + + return validatedResponseData; + } + + #handleError( + methodName: string, + errorMessage: string, + error: unknown, + ): never { + log(methodName, error); + this.#captureException?.(createSentryError(errorMessage, error as Error)); + throw new Error(errorMessage); + } +} diff --git a/packages/claims-controller/src/constants.ts b/packages/claims-controller/src/constants.ts new file mode 100644 index 00000000000..f69da6e3f94 --- /dev/null +++ b/packages/claims-controller/src/constants.ts @@ -0,0 +1,63 @@ +import { BuiltInNetworkName, ChainId } from '@metamask/controller-utils'; + +export const CONTROLLER_NAME = 'ClaimsController'; + +export const SERVICE_NAME = 'ClaimsService'; + +export enum Env { + DEV = 'dev', + UAT = 'uat', + PRD = 'prd', +} + +export enum ClaimStatusEnum { + // created but not yet submitted to Intercom + CREATED = 'created', + // submitted to Intercom + SUBMITTED = 'submitted', + // in progress by Intercom + // eslint-disable-next-line @typescript-eslint/naming-convention -- to match the API response format + IN_PROGRESS = 'in_progress', + // waiting for customer reply + // eslint-disable-next-line @typescript-eslint/naming-convention -- to match the API response format + WAITING_FOR_CUSTOMER = 'waiting_for_customer', + // approved by Intercom + APPROVED = 'approved', + // rejected by Intercom + REJECTED = 'rejected', + // unknown status + UNKNOWN = 'unknown', +} + +export const CLAIMS_API_URL_MAP: Record = { + [Env.DEV]: 'https://claims.dev-api.cx.metamask.io', + [Env.UAT]: 'https://claims.uat-api.cx.metamask.io', + [Env.PRD]: 'https://claims.api.cx.metamask.io', +}; + +export const ClaimsControllerErrorMessages = { + CLAIM_ALREADY_SUBMITTED: 'Claim already submitted', + INVALID_CLAIM_SIGNATURE: 'Invalid claim signature', + INVALID_SIGNATURE_MESSAGE: 'Invalid signature message', +}; + +export const ClaimsServiceErrorMessages = { + FAILED_TO_FETCH_CONFIGURATIONS: 'Failed to fetch claims configurations', + FAILED_TO_GET_CLAIMS: 'Failed to get claims', + FAILED_TO_GET_CLAIM_BY_ID: 'Failed to get claim by id', + SIGNATURE_MESSAGE_GENERATION_FAILED: + 'Failed to generate message for claim signature', + CLAIM_SIGNATURE_VERIFICATION_REQUEST_FAILED: + 'Failed to verify claim signature', +}; + +/** + * Default claims configurations. + */ +export const DEFAULT_CLAIMS_CONFIGURATIONS = { + validSubmissionWindowDays: 21, + supportedNetworks: [ + ChainId[BuiltInNetworkName.Mainnet], + ChainId[BuiltInNetworkName.LineaMainnet], + ], +}; diff --git a/packages/claims-controller/src/index.ts b/packages/claims-controller/src/index.ts new file mode 100644 index 00000000000..622e8b3d7bc --- /dev/null +++ b/packages/claims-controller/src/index.ts @@ -0,0 +1,64 @@ +export { + ClaimsController, + getDefaultClaimsControllerState, +} from './ClaimsController.js'; + +export type { + ClaimsControllerGetStateAction, + ClaimsControllerActions, + ClaimsControllerStateChangeEvent, + ClaimsControllerMessenger, + ClaimsControllerOptions, +} from './ClaimsController.js'; + +export type { + ClaimsControllerFetchClaimsConfigurationsAction, + ClaimsControllerGetSubmitClaimConfigAction, + ClaimsControllerGenerateClaimSignatureAction, + ClaimsControllerGetClaimsAction, + ClaimsControllerSaveOrUpdateClaimDraftAction, + ClaimsControllerGetClaimDraftsAction, + ClaimsControllerDeleteClaimDraftAction, + ClaimsControllerDeleteAllClaimDraftsAction, + ClaimsControllerClearStateAction, +} from './ClaimsController-method-action-types.js'; + +export type { + Claim, + ClaimsControllerState, + Attachment, + ClaimsConfigurations, + CreateClaimRequest, + SubmitClaimConfig, + ClaimDraft, +} from './types.js'; + +export { ClaimsService } from './ClaimsService.js'; + +export type { + ClaimsServiceFetchClaimsConfigurationsAction, + ClaimsServiceGetClaimsAction, + ClaimsServiceGetRequestHeadersAction, + ClaimsServiceGetClaimsApiUrlAction, + ClaimsServiceGetClaimByIdAction, + ClaimsServiceGenerateMessageForClaimSignatureAction, +} from './ClaimsService-method-action-types.js'; + +export type { + ClaimsServiceActions, + ClaimsServiceConfig, + ClaimsServiceMessenger, + ClaimsServiceInvalidateQueriesAction, + ClaimsServiceCacheUpdatedEvent, + ClaimsServiceGranularCacheUpdatedEvent, + ClaimsServiceEvents, +} from './ClaimsService.js'; + +export { + ClaimStatusEnum, + Env, + ClaimsControllerErrorMessages, + DEFAULT_CLAIMS_CONFIGURATIONS, + ClaimsServiceErrorMessages, + CLAIMS_API_URL_MAP, +} from './constants.js'; diff --git a/packages/claims-controller/src/logger.ts b/packages/claims-controller/src/logger.ts new file mode 100644 index 00000000000..a122df6b96c --- /dev/null +++ b/packages/claims-controller/src/logger.ts @@ -0,0 +1,7 @@ +import { createProjectLogger, createModuleLogger } from '@metamask/utils'; + +import { CONTROLLER_NAME } from './constants.js'; + +export const projectLogger = createProjectLogger(CONTROLLER_NAME); + +export { createModuleLogger }; diff --git a/packages/claims-controller/src/types.ts b/packages/claims-controller/src/types.ts new file mode 100644 index 00000000000..9b03109d544 --- /dev/null +++ b/packages/claims-controller/src/types.ts @@ -0,0 +1,108 @@ +import type { Hex } from '@metamask/utils'; + +import type { ClaimStatusEnum } from './constants.js'; + +export type Attachment = { + publicUrl: string; + contentType: string; + originalname: string; +}; + +export type ClaimsConfigurations = { + /** + * The number of days the claim is valid for submission. + */ + validSubmissionWindowDays: number; + + /** + * List of supported chain IDs in hexadecimal format. + */ + supportedNetworks: `0x${string}`[]; +}; + +export type ClaimsConfigurationsResponse = Omit< + ClaimsConfigurations, + 'supportedNetworks' +> & { + /** + * List of supported chain IDs. + * Claims API response for `supportedNetworks` field (in decimal format). + */ + networks: number[]; +}; + +export type Claim = { + id: string; + shortId: string; + chainId: string; + email: string; + impactedWalletAddress: Hex; + impactedTxHash: Hex; + reimbursementWalletAddress: Hex; + description: string; + signature: Hex; + attachments?: Attachment[]; + status: ClaimStatusEnum; + createdAt: string; + updatedAt: string; + intercomId?: string; +}; + +export type ClaimDraft = Partial< + Omit< + Claim, + 'id' | 'shortId' | 'createdAt' | 'intercomId' | 'status' | 'attachments' + > +> & { + /** + * The draft ID. + */ + draftId: string; +}; + +export type CreateClaimRequest = Omit< + Claim, + 'id' | 'shortId' | 'createdAt' | 'updatedAt' | 'intercomId' | 'status' +>; + +export type ClaimsControllerState = { + /** + * List of claims. + */ + claims: Claim[]; + + /** + * The claims configurations. + * This is used to store the claims configurations fetched from the backend. + */ + claimsConfigurations: ClaimsConfigurations; + + /** + * List of claim drafts before submission. + */ + drafts: ClaimDraft[]; +}; + +export type SubmitClaimConfig = { + /** + * The sanitized and validated data to be submitted. + */ + data: CreateClaimRequest; + /** + * The headers to be used in the request. + */ + headers: Record; + /** + * The HTTP method to submit. + */ + method: 'POST'; + /** + * The URL to submit the claim to. + */ + url: string; +}; + +export type GenerateSignatureMessageResponse = { + message: string; + nonce: string; +}; diff --git a/packages/claims-controller/src/utils.test.ts b/packages/claims-controller/src/utils.test.ts new file mode 100644 index 00000000000..6013b070060 --- /dev/null +++ b/packages/claims-controller/src/utils.test.ts @@ -0,0 +1,119 @@ +import { getErrorFromResponse, createSentryError } from './utils.js'; + +describe('getErrorFromResponse', () => { + it('returns error with message from JSON response', async () => { + const response = { + status: 400, + headers: { + get: jest.fn().mockReturnValue('application/json'), + }, + json: jest.fn().mockResolvedValue({ error: 'Bad request' }), + } as unknown as Response; + + const error = await getErrorFromResponse(response); + + expect(error.message).toBe('error: Bad request, statusCode: 400'); + }); + + it('returns error with message from JSON response when message is present', async () => { + const response = { + status: 400, + headers: { + get: jest.fn().mockReturnValue('application/json'), + }, + json: jest.fn().mockResolvedValue({ message: 'Bad request' }), + } as unknown as Response; + + const error = await getErrorFromResponse(response); + expect(error.message).toBe('error: Bad request, statusCode: 400'); + }); + + it('returns unknown error when JSON response has no error or message', async () => { + const response = { + status: 400, + headers: { + get: jest.fn().mockReturnValue('application/json'), + }, + json: jest.fn().mockResolvedValue({}), + } as unknown as Response; + + const error = await getErrorFromResponse(response); + expect(error.message).toBe('error: Unknown error, statusCode: 400'); + }); + + it('returns error with message from text/plain response', async () => { + const response = { + status: 400, + headers: { + get: jest.fn().mockReturnValue('text/plain'), + }, + text: jest.fn().mockResolvedValue('Plain text error'), + } as unknown as Response; + + const error = await getErrorFromResponse(response); + expect(error.message).toBe('error: Plain text error, statusCode: 400'); + }); + + it('returns error with data property when content-type is unknown', async () => { + const response = { + status: 400, + headers: { + get: jest.fn().mockReturnValue('application/octet-stream'), + }, + data: 'Some data error', + } as unknown as Response; + + const error = await getErrorFromResponse(response); + expect(error.message).toBe('error: Some data error, statusCode: 400'); + }); + + it('returns unknown error when content-type is unknown and no data property', async () => { + const response = { + status: 400, + headers: { + get: jest.fn().mockReturnValue('application/octet-stream'), + }, + } as unknown as Response; + + const error = await getErrorFromResponse(response); + expect(error.message).toBe('error: Unknown error, statusCode: 400'); + }); + + it('returns generic HTTP error when JSON parsing fails', async () => { + const response = { + status: 500, + headers: { + get: jest.fn().mockReturnValue('application/json'), + }, + json: jest.fn().mockRejectedValue(new Error('Invalid JSON')), + } as unknown as Response; + + const error = await getErrorFromResponse(response); + + expect(error.message).toBe('HTTP 500 error'); + }); + + it('returns generic HTTP error when text parsing fails', async () => { + const response = { + status: 500, + headers: { + get: jest.fn().mockReturnValue('text/plain'), + }, + text: jest.fn().mockRejectedValue(new Error('Read error')), + } as unknown as Response; + + const error = await getErrorFromResponse(response); + + expect(error.message).toBe('HTTP 500 error'); + }); +}); + +describe('createSentryError', () => { + it('creates error with message and cause', () => { + const cause = new Error('Original error'); + const error = createSentryError('Something went wrong', cause); + + expect(error.message).toBe('Something went wrong'); + expect((error as Error & { cause: Error }).cause).toBe(cause); + }); +}); diff --git a/packages/claims-controller/src/utils.ts b/packages/claims-controller/src/utils.ts new file mode 100644 index 00000000000..0f75df69cfe --- /dev/null +++ b/packages/claims-controller/src/utils.ts @@ -0,0 +1,47 @@ +/** + * Get an error from a response. + * + * @param response - The response to get an error from. + * @returns An error. + */ +export async function getErrorFromResponse(response: Response): Promise { + const contentType = response.headers?.get('content-type'); + const statusCode = response.status; + try { + if (contentType?.includes('application/json')) { + const json = await response.json(); + const errorMessage = json?.error ?? json?.message ?? 'Unknown error'; + const networkError = `error: ${errorMessage}, statusCode: ${statusCode}`; + return new Error(networkError); + } else if (contentType?.includes('text/plain')) { + const text = await response.text(); + const networkError = `error: ${text}, statusCode: ${statusCode}`; + return new Error(networkError); + } + + const error = + 'data' in response && typeof response.data === 'string' + ? response.data + : 'Unknown error'; + const networkError = `error: ${error}, statusCode: ${statusCode}`; + return new Error(networkError); + } catch { + return new Error(`HTTP ${statusCode} error`); + } +} + +/** + * Creates an error instance with a readable message and the root cause. + * + * @param message - The error message to create a Sentry error from. + * @param cause - The inner error to create a Sentry error from. + * @returns A Sentry error. + */ +export function createSentryError(message: string, cause: Error): Error { + const sentryError = new Error(message) as Error & { + cause: Error; + }; + sentryError.cause = cause; + + return sentryError; +} diff --git a/packages/claims-controller/tests/mocks/messenger.ts b/packages/claims-controller/tests/mocks/messenger.ts new file mode 100644 index 00000000000..fb9ae772892 --- /dev/null +++ b/packages/claims-controller/tests/mocks/messenger.ts @@ -0,0 +1,177 @@ +import { Messenger, MOCK_ANY_NAMESPACE } from '@metamask/messenger'; +import type { + MessengerActions, + MessengerEvents, + MockAnyNamespace, +} from '@metamask/messenger'; + +import { CONTROLLER_NAME, SERVICE_NAME } from '../../src/constants.js'; +import type { + ClaimsServiceMessenger, + ClaimsControllerMessenger, +} from '../../src/index.js'; + +type AllShieldControllerActions = MessengerActions; + +type AllShieldControllerEvents = MessengerEvents; + +export type RootControllerMessenger = Messenger< + MockAnyNamespace, + AllShieldControllerActions, + AllShieldControllerEvents +>; + +/** + * Create a mock messenger. + * + * @param params - The parameters for the mock messenger. + * @param params.mockClaimServiceRequestHeaders - A mock function for the claim service request headers. + * @param params.mockClaimServiceGetClaimsApiUrl - A mock function for the claim service get claims API URL. + * @param params.mockClaimServiceGenerateMessageForClaimSignature - A mock function for the claim service generate message for claim signature. + * @param params.mockKeyringControllerSignPersonalMessage - A mock function for the keyring controller sign personal message. + * @param params.mockClaimsServiceGetClaims - A mock function for the claim service get claims. + * @param params.mockClaimsServiceFetchClaimsConfigurations - A mock function for the claim service fetch claims configurations. + * @returns A mock messenger. + */ +export function createMockClaimsControllerMessenger({ + mockClaimServiceRequestHeaders, + mockClaimServiceGetClaimsApiUrl, + mockClaimServiceGenerateMessageForClaimSignature, + mockKeyringControllerSignPersonalMessage, + mockClaimsServiceGetClaims, + mockClaimsServiceFetchClaimsConfigurations, +}: { + mockClaimServiceRequestHeaders: jest.Mock; + mockClaimServiceGetClaimsApiUrl: jest.Mock; + mockClaimServiceGenerateMessageForClaimSignature: jest.Mock; + mockKeyringControllerSignPersonalMessage: jest.Mock; + mockClaimsServiceGetClaims: jest.Mock; + mockClaimsServiceFetchClaimsConfigurations: jest.Mock; +}): { + rootMessenger: RootControllerMessenger; + messenger: ClaimsControllerMessenger; +} { + const rootMessenger = new Messenger< + MockAnyNamespace, + AllShieldControllerActions, + AllShieldControllerEvents + >({ + namespace: MOCK_ANY_NAMESPACE, + }); + + rootMessenger.registerActionHandler( + `${SERVICE_NAME}:fetchClaimsConfigurations`, + mockClaimsServiceFetchClaimsConfigurations, + ); + rootMessenger.registerActionHandler( + `${SERVICE_NAME}:getRequestHeaders`, + mockClaimServiceRequestHeaders, + ); + rootMessenger.registerActionHandler( + `${SERVICE_NAME}:getClaimsApiUrl`, + mockClaimServiceGetClaimsApiUrl, + ); + rootMessenger.registerActionHandler( + `${SERVICE_NAME}:generateMessageForClaimSignature`, + mockClaimServiceGenerateMessageForClaimSignature, + ); + rootMessenger.registerActionHandler( + 'KeyringController:signPersonalMessage', + mockKeyringControllerSignPersonalMessage, + ); + rootMessenger.registerActionHandler( + `${SERVICE_NAME}:getClaims`, + mockClaimsServiceGetClaims, + ); + + const messenger = new Messenger< + typeof CONTROLLER_NAME, + AllShieldControllerActions, + AllShieldControllerEvents, + RootControllerMessenger + >({ + namespace: CONTROLLER_NAME, + parent: rootMessenger, + }); + rootMessenger.delegate({ + messenger, + events: [], + actions: [ + `${SERVICE_NAME}:fetchClaimsConfigurations`, + `${SERVICE_NAME}:getRequestHeaders`, + `${SERVICE_NAME}:getClaimsApiUrl`, + `${SERVICE_NAME}:generateMessageForClaimSignature`, + `${SERVICE_NAME}:getClaims`, + 'KeyringController:signPersonalMessage', + ], + }); + + return { + rootMessenger, + messenger, + }; +} + +type AllServiceActions = MessengerActions; +type AllServiceEvents = MessengerEvents; + +export type RootServiceMessenger = Messenger< + MockAnyNamespace, + AllServiceActions, + AllServiceEvents +>; + +/** + * Create a mock messenger for the claims service. + * + * @param mockAuthenticationControllerGetBearerToken - A mock function for the authentication controller get bearer token. + * @param mockAuthenticationControllerGetSessionProfile - A mock function for the authentication controller get session profile. + * @param mockCaptureException - A mock function for the capture exception. + * @returns A mock messenger for the claims service. + */ +export function createMockClaimsServiceMessenger( + mockAuthenticationControllerGetBearerToken: jest.Mock, + mockAuthenticationControllerGetSessionProfile: jest.Mock, + mockCaptureException: jest.Mock, +): { + rootMessenger: RootServiceMessenger; + messenger: ClaimsServiceMessenger; +} { + const rootMessenger = new Messenger({ + namespace: MOCK_ANY_NAMESPACE, + }); + + rootMessenger.registerActionHandler( + 'AuthenticationController:getBearerToken', + mockAuthenticationControllerGetBearerToken, + ); + rootMessenger.registerActionHandler( + 'AuthenticationController:getSessionProfile', + mockAuthenticationControllerGetSessionProfile, + ); + + const messenger = new Messenger< + typeof SERVICE_NAME, + AllServiceActions, + AllServiceEvents, + RootServiceMessenger + >({ + namespace: SERVICE_NAME, + parent: rootMessenger, + captureException: mockCaptureException, + }); + + rootMessenger.delegate({ + messenger, + events: [], + actions: [ + 'AuthenticationController:getBearerToken', + 'AuthenticationController:getSessionProfile', + ], + }); + + return { + rootMessenger, + messenger, + }; +} diff --git a/packages/claims-controller/tests/types.ts b/packages/claims-controller/tests/types.ts new file mode 100644 index 00000000000..060136fbe67 --- /dev/null +++ b/packages/claims-controller/tests/types.ts @@ -0,0 +1,23 @@ +import type { + ClaimsController, + ClaimsControllerMessenger, + ClaimsControllerOptions, +} from '../src/ClaimsController.js'; +import type { ClaimsControllerState } from '../src/types.js'; +import type { RootControllerMessenger } from './mocks/messenger.js'; + +/** + * Helper function to create controller with options. + */ +type WithControllerCallback = (params: { + controller: ClaimsController; + initialState: ClaimsControllerState; + messenger: ClaimsControllerMessenger; + rootMessenger: RootControllerMessenger; +}) => Promise | ReturnValue; + +export type WithControllerOptions = Partial; + +export type WithControllerArgs = + | [WithControllerCallback] + | [WithControllerOptions, WithControllerCallback]; diff --git a/packages/claims-controller/tsconfig.build.json b/packages/claims-controller/tsconfig.build.json new file mode 100644 index 00000000000..c10496fd666 --- /dev/null +++ b/packages/claims-controller/tsconfig.build.json @@ -0,0 +1,29 @@ +{ + "extends": "../../tsconfig.packages.build.json", + "compilerOptions": { + "baseUrl": "./", + "outDir": "./dist", + "rootDir": "./src" + }, + "references": [ + { + "path": "../base-controller/tsconfig.build.json" + }, + { + "path": "../base-data-service/tsconfig.build.json" + }, + { + "path": "../messenger/tsconfig.build.json" + }, + { + "path": "../profile-sync-controller/tsconfig.build.json" + }, + { + "path": "../keyring-controller/tsconfig.build.json" + }, + { + "path": "../controller-utils/tsconfig.build.json" + } + ], + "include": ["../../types", "./src"] +} diff --git a/packages/claims-controller/tsconfig.json b/packages/claims-controller/tsconfig.json new file mode 100644 index 00000000000..275aa9962a7 --- /dev/null +++ b/packages/claims-controller/tsconfig.json @@ -0,0 +1,27 @@ +{ + "extends": "../../tsconfig.packages.json", + "compilerOptions": { + "baseUrl": "./" + }, + "references": [ + { + "path": "../base-controller" + }, + { + "path": "../base-data-service" + }, + { + "path": "../messenger" + }, + { + "path": "../profile-sync-controller" + }, + { + "path": "../keyring-controller" + }, + { + "path": "../controller-utils" + } + ], + "include": ["../../types", "./src", "./tests"] +} diff --git a/packages/claims-controller/typedoc.json b/packages/claims-controller/typedoc.json new file mode 100644 index 00000000000..c9da015dbf8 --- /dev/null +++ b/packages/claims-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/client-controller/CHANGELOG.md b/packages/client-controller/CHANGELOG.md new file mode 100644 index 00000000000..45b53576c1f --- /dev/null +++ b/packages/client-controller/CHANGELOG.md @@ -0,0 +1,36 @@ +# 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] + +### Changed + +- Bump `@metamask/messenger` from `^1.0.0` to `^2.0.0` ([#8364](https://github.com/MetaMask/core/pull/8364), [#8373](https://github.com/MetaMask/core/pull/8373), [#8632](https://github.com/MetaMask/core/pull/8632), [#9392](https://github.com/MetaMask/core/pull/9392)) +- Bump `@metamask/base-controller` from `^9.0.1` to `^9.1.0` ([#8457](https://github.com/MetaMask/core/pull/8457)) + +## [1.0.1] + +### Changed + +- Bump `@metamask/base-controller` from `^9.0.0` to `^9.0.1` ([#8317](https://github.com/MetaMask/core/pull/8317)) +- Bump `@metamask/messenger` from `^0.3.0` to `^1.0.0` ([#8317](https://github.com/MetaMask/core/pull/8317)) + +## [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.1...HEAD +[1.0.1]: https://github.com/MetaMask/core/compare/@metamask/client-controller@1.0.0...@metamask/client-controller@1.0.1 +[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..6dc63fee15b --- /dev/null +++ b/packages/client-controller/package.json @@ -0,0 +1,77 @@ +{ + "name": "@metamask/client-controller", + "version": "1.0.1", + "description": "Client-level state for MetaMask (e.g. whether a UI window is open)", + "keywords": [ + "Ethereum", + "MetaMask" + ], + "homepage": "https://github.com/MetaMask/core/tree/main/packages/client-controller#readme", + "bugs": { + "url": "https://github.com/MetaMask/core/issues" + }, + "license": "MIT", + "repository": { + "type": "git", + "url": "https://github.com/MetaMask/core.git" + }, + "files": [ + "dist/" + ], + "sideEffects": false, + "main": "./dist/index.cjs", + "types": "./dist/index.d.cts", + "exports": { + ".": { + "import": { + "types": "./dist/index.d.mts", + "default": "./dist/index.mjs" + }, + "require": { + "types": "./dist/index.d.cts", + "default": "./dist/index.cjs" + } + }, + "./package.json": "./package.json" + }, + "publishConfig": { + "access": "public", + "registry": "https://registry.npmjs.org/" + }, + "scripts": { + "build": "ts-bridge --project tsconfig.build.json --verbose --clean --no-references", + "build:all": "ts-bridge --project tsconfig.build.json --verbose --clean", + "build:docs": "typedoc", + "changelog:update": "../../scripts/update-changelog.sh @metamask/client-controller", + "changelog:validate": "../../scripts/validate-changelog.sh @metamask/client-controller", + "lint:tsconfigs": "tsx ../../scripts/lint-tsconfigs/lint-tsconfigs.mts", + "lint:tsconfigs:fix": "tsx ../../scripts/lint-tsconfigs/lint-tsconfigs.mts --fix", + "messenger-action-types:check": "tsx ../../packages/messenger-cli/src/cli.ts --formatter oxfmt --esm --check", + "messenger-action-types:generate": "tsx ../../packages/messenger-cli/src/cli.ts --formatter oxfmt --esm --generate", + "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.1.0", + "@metamask/messenger": "^2.0.0" + }, + "devDependencies": { + "@metamask/auto-changelog": "^6.1.0", + "@ts-bridge/cli": "^0.6.4", + "@types/jest": "^30.0.0", + "deepmerge": "^4.2.2", + "jest": "^30.4.2", + "jest-environment-jsdom": "^30.4.1", + "ts-jest": "^29.4.11", + "tsx": "^4.20.5", + "typedoc": "^0.25.13", + "typedoc-plugin-missing-exports": "^2.0.0", + "typescript": "~5.3.3" + }, + "engines": { + "node": "^18.18 || >=20" + } +} diff --git a/packages/client-controller/src/ClientController-method-action-types.ts b/packages/client-controller/src/ClientController-method-action-types.ts new file mode 100644 index 00000000000..16932305f2d --- /dev/null +++ b/packages/client-controller/src/ClientController-method-action-types.ts @@ -0,0 +1,25 @@ +/** + * This file is auto generated. + * Do not edit manually. + */ + +import type { ClientController } from './ClientController.js'; + +/** + * 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..ffc378261c8 --- /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.js'; +import { + ClientController, + controllerName, + getDefaultClientControllerState, +} from './ClientController.js'; +import { clientControllerSelectors } from './selectors.js'; + +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..507a4fd18f0 --- /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.js'; + +// === 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..86aa4d5f54c --- /dev/null +++ b/packages/client-controller/src/index.ts @@ -0,0 +1,16 @@ +export { + ClientController, + getDefaultClientControllerState, +} from './ClientController.js'; +export { clientControllerSelectors } from './selectors.js'; + +export type { + ClientControllerState, + ClientControllerOptions, + ClientControllerGetStateAction, + ClientControllerActions, + ClientControllerStateChangeEvent, + ClientControllerEvents, + ClientControllerMessenger, +} from './ClientController.js'; +export type { ClientControllerSetUiOpenAction } from './ClientController-method-action-types.js'; diff --git a/packages/client-controller/src/selectors.ts b/packages/client-controller/src/selectors.ts new file mode 100644 index 00000000000..132152a782c --- /dev/null +++ b/packages/client-controller/src/selectors.ts @@ -0,0 +1,18 @@ +import type { ClientControllerState } from './ClientController.js'; + +/** + * 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..95274927209 --- /dev/null +++ b/packages/client-controller/tsconfig.json @@ -0,0 +1,15 @@ +{ + "extends": "../../tsconfig.packages.json", + "compilerOptions": { + "baseUrl": "." + }, + "include": ["src"], + "references": [ + { + "path": "../base-controller" + }, + { + "path": "../messenger" + } + ] +} diff --git a/packages/client-controller/tsconfig.lint.json b/packages/client-controller/tsconfig.lint.json new file mode 100644 index 00000000000..c19124f864f --- /dev/null +++ b/packages/client-controller/tsconfig.lint.json @@ -0,0 +1,15 @@ +{ + "extends": ["./tsconfig.json", "../../tsconfig.packages.lint.json"], + "compilerOptions": { + "outDir": "./.tsc-lint-cache", + "tsBuildInfoFile": "./.tsc-lint-cache/tsconfig.tsbuildinfo" + }, + "references": [ + { + "path": "../base-controller/tsconfig.lint.json" + }, + { + "path": "../messenger/tsconfig.lint.json" + } + ] +} 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/client-utils/CHANGELOG.md b/packages/client-utils/CHANGELOG.md new file mode 100644 index 00000000000..fa74999e3a4 --- /dev/null +++ b/packages/client-utils/CHANGELOG.md @@ -0,0 +1,167 @@ +# 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] + +### Changed + +- Bump `@metamask/transaction-controller` from `^69.6.0` to `^69.6.1` ([#9969](https://github.com/MetaMask/core/pull/9969)) + +## [2.1.1] + +### Changed + +- Bump `@metamask/core-backend` from `^8.1.2` to `^9.0.0` ([#9960](https://github.com/MetaMask/core/pull/9960)) +- Bump `@metamask/transaction-controller` from `^69.5.2` to `^69.6.0` ([#9960](https://github.com/MetaMask/core/pull/9960)) + +## [2.1.0] + +### Added + +- Add `stake` / `unstake` activity kinds and a `PerpsOrderKind` type covering every perps order kind ([#9916](https://github.com/MetaMask/core/pull/9916)) +- Add `ActivityItem` variants for staking, prediction, and perps activity kinds that previously had no matching data shape ([#9916](https://github.com/MetaMask/core/pull/9916)) + +### Changed + +- Bump `@metamask/core-backend` from `^8.1.1` to `^8.1.2` ([#9886](https://github.com/MetaMask/core/pull/9886)) + +## [2.0.2] + +### Changed + +- Fall back to a zero-address ERC-20 CAIP-19 asset id (`eip155:/erc20:0x000…000`) in `resolveNativeAssetId` and `getNativeAsset` when an EVM native has no SLIP-44 coin type (previously `undefined`) ([#9833](https://github.com/MetaMask/core/pull/9833)) + - `resolveNativeAssetId` consults chainlist via `getNativeAsset` before that fallback so symbol-less calls stay aligned with `getNativeAsset` + - Non-EVM chains still return `undefined` when no slip44 entry is found +- Bump `@metamask/transaction-controller` from `^69.5.0` to `^69.5.2` ([#9798](https://github.com/MetaMask/core/pull/9798), [#9823](https://github.com/MetaMask/core/pull/9823)) + +## [2.0.1] + +### Changed + +- Bump `@metamask/keyring-api` from `^23.7.0` to `^24.0.0` ([#9754](https://github.com/MetaMask/core/pull/9754)) + +## [2.0.0] + +### Changed + +- **BREAKING:** Make `chainId` required on `rampBuy` / `rampSell` `ActivityItem` variants, matching every other activity kind. `mapRampsOrder` now returns `null` when no CAIP chain id can be resolved from the order (empty or unparseable `network`, missing `cryptoCurrency.chainId` / `assetId`), instead of emitting an item with an undefined `chainId` ([#9777](https://github.com/MetaMask/core/pull/9777)) +- Bump `@metamask/transaction-controller` from `^69.4.0` to `^69.5.0` ([#9780](https://github.com/MetaMask/core/pull/9780)) +- Bump `@metamask/core-backend` from `^8.1.0` to `^8.1.1` ([#9779](https://github.com/MetaMask/core/pull/9779)) + +## [1.6.0] + +### Added + +- Add `eth-chainlist` dependency for chain-native slip44/symbol lookup ([#9729](https://github.com/MetaMask/core/pull/9729)) + +### Changed + +- Resolve native fee/token metadata from `eth-chainlist` (chainId → slip44/symbol), falling back to `@metamask/slip44` by symbol when chainlist omits `slip44` ([#9729](https://github.com/MetaMask/core/pull/9729)) + - API network fees no longer scrape native symbol from `valueTransfers` + - STANDARD sends with empty `valueTransfers` synthesize a native token from `tx.value` +- Bump `@metamask/core-backend` from `^8.0.0` to `^8.1.0` ([#9735](https://github.com/MetaMask/core/pull/9735)) +- Bump `@metamask/transaction-controller` from `^69.3.0` to `^69.4.0` ([#9735](https://github.com/MetaMask/core/pull/9735)) + +### Fixed + +- Prefer the subject's fungible `from` movement when mapping keyring send activity, so multi-party Solana txs (e.g. bridge source legs) no longer surface another address's token as the sent asset ([#9749](https://github.com/MetaMask/core/pull/9749)) + +## [1.5.0] + +### Added + +- Add `@metamask/slip44` dependency for native token symbol lookup ([#9701](https://github.com/MetaMask/core/pull/9701)) + +### Changed + +- Restore native `assetId` on activity tokens and network fees when a symbol is available, using `@metamask/slip44` symbol lookup instead of the removed chain registry ([#9701](https://github.com/MetaMask/core/pull/9701)) + - Native tokens from indexed value transfers use the transfer symbol + - Local native tokens and fees include slip44 `assetId` only when a native symbol is already present on the mapped data + - API network fees derive the symbol from native value transfers when present + - `assetId` is still omitted when no symbol is available (for example ERC-20-only transactions with no native transfer) + +## [1.4.0] + +### Added + +- Add `mapRampsOrder` for mapping ramps buy/sell orders into the shared activity item shape, and add `rampBuy`/`rampSell` to `ActivityKind` and `ActivityItem` ([#9650](https://github.com/MetaMask/core/pull/9650)) + +## [1.3.1] + +### Changed + +- Bump `@metamask/core-backend` from `^7.0.0` to `^8.0.0` ([#9693](https://github.com/MetaMask/core/pull/9693)) +- Bump `@metamask/transaction-controller` from `^69.2.1` to `^69.3.0` ([#9693](https://github.com/MetaMask/core/pull/9693)) + +## [1.3.0] + +### Added + +- Add optional `assetType` (`'native' | 'erc20' | 'erc721' | 'erc1155'`) on `TokenAmount` and `Fee` so clients can resolve icons when `assetId` is absent ([#9671](https://github.com/MetaMask/core/pull/9671)) + +### Changed + +- Stop inventing native token metadata in activity mappers ([#9671](https://github.com/MetaMask/core/pull/9671)) + - Remove the hardcoded `nativeAssetsByCaipChainId` lookup and the `STANDARD` assume-native fallback + - `formatAddressToAssetId` returns `undefined` for native sentinel addresses instead of `erc20:0x0` + - Network fees and native tokens no longer invent `symbol` / slip44 `assetId` +- Bump `@metamask/keyring-api` from `^23.5.0` to `^23.7.0` ([#9676](https://github.com/MetaMask/core/pull/9676)) + +## [1.2.1] + +### Changed + +- Bump `@metamask/transaction-controller` from `^69.0.0` to `^69.2.1` ([#9568](https://github.com/MetaMask/core/pull/9568), [#9589](https://github.com/MetaMask/core/pull/9589), [#9593](https://github.com/MetaMask/core/pull/9593)) +- Bump `@metamask/core-backend` from `^6.5.0` to `^7.0.0` ([#9593](https://github.com/MetaMask/core/pull/9593)) + +## [1.2.0] + +### Added + +- Add `createFormatters` factory with shared display formatters (`formatNumber`, `formatCurrency`, `formatCurrencyCompact`, `formatCurrencyWithMinThreshold`, `formatCurrencyTokenPrice`, `formatToken`, `formatTokenQuantity`, `formatTokenAmount`, `formatPercentWithMinThreshold`, `formatCompact`, `formatDateTime`) ([#9504](https://github.com/MetaMask/core/pull/9504)) + +## [1.1.0] + +### Added + +- Map `assetActivation` and `assetDeactivation` activity types in transaction activity mappers ([#9440](https://github.com/MetaMask/core/pull/9440)) + +### Changed + +- Bump `@metamask/transaction-controller` from `^68.3.0` to `^69.0.0` ([#9456](https://github.com/MetaMask/core/pull/9456), [#9470](https://github.com/MetaMask/core/pull/9470)) + +## [1.0.0] + +### Added + +- Initial release of the `@metamask/client-utils` package for functions and utilities shared across MetaMask clients (extension and mobile) ([#9375](https://github.com/MetaMask/core/pull/9375)) +- Add transaction activity mappers and shared activity types ([#9376](https://github.com/MetaMask/core/pull/9376)) + - `mapApiTransaction` for mapping EVM API transactions to activity items + - `mapKeyringTransaction` for mapping keyring transactions to activity items + - `mapLocalTransaction` for mapping local transaction groups to activity items + - Shared activity types (`ActivityItem`, `ActivityKind`, `Status`, etc.) + +### Changed + +- Bump `@metamask/transaction-controller` from `^68.2.2` to `^68.3.0` ([#9421](https://github.com/MetaMask/core/pull/9421)) +- Bump `@metamask/keyring-api` from `^23.3.0` to `^23.5.0` ([#9390](https://github.com/MetaMask/core/pull/9390)) + +[Unreleased]: https://github.com/MetaMask/core/compare/@metamask/client-utils@2.1.1...HEAD +[2.1.1]: https://github.com/MetaMask/core/compare/@metamask/client-utils@2.1.0...@metamask/client-utils@2.1.1 +[2.1.0]: https://github.com/MetaMask/core/compare/@metamask/client-utils@2.0.2...@metamask/client-utils@2.1.0 +[2.0.2]: https://github.com/MetaMask/core/compare/@metamask/client-utils@2.0.1...@metamask/client-utils@2.0.2 +[2.0.1]: https://github.com/MetaMask/core/compare/@metamask/client-utils@2.0.0...@metamask/client-utils@2.0.1 +[2.0.0]: https://github.com/MetaMask/core/compare/@metamask/client-utils@1.6.0...@metamask/client-utils@2.0.0 +[1.6.0]: https://github.com/MetaMask/core/compare/@metamask/client-utils@1.5.0...@metamask/client-utils@1.6.0 +[1.5.0]: https://github.com/MetaMask/core/compare/@metamask/client-utils@1.4.0...@metamask/client-utils@1.5.0 +[1.4.0]: https://github.com/MetaMask/core/compare/@metamask/client-utils@1.3.1...@metamask/client-utils@1.4.0 +[1.3.1]: https://github.com/MetaMask/core/compare/@metamask/client-utils@1.3.0...@metamask/client-utils@1.3.1 +[1.3.0]: https://github.com/MetaMask/core/compare/@metamask/client-utils@1.2.1...@metamask/client-utils@1.3.0 +[1.2.1]: https://github.com/MetaMask/core/compare/@metamask/client-utils@1.2.0...@metamask/client-utils@1.2.1 +[1.2.0]: https://github.com/MetaMask/core/compare/@metamask/client-utils@1.1.0...@metamask/client-utils@1.2.0 +[1.1.0]: https://github.com/MetaMask/core/compare/@metamask/client-utils@1.0.0...@metamask/client-utils@1.1.0 +[1.0.0]: https://github.com/MetaMask/core/releases/tag/@metamask/client-utils@1.0.0 diff --git a/packages/client-utils/LICENSE b/packages/client-utils/LICENSE new file mode 100644 index 00000000000..9ec4f4514ea --- /dev/null +++ b/packages/client-utils/LICENSE @@ -0,0 +1,6 @@ +This project is licensed under either of + + * MIT license ([LICENSE.MIT](LICENSE.MIT)) + * Apache License, Version 2.0 ([LICENSE.APACHE2](LICENSE.APACHE2)) + +at your option. diff --git a/packages/client-utils/LICENSE.APACHE2 b/packages/client-utils/LICENSE.APACHE2 new file mode 100644 index 00000000000..e6e77b08909 --- /dev/null +++ b/packages/client-utils/LICENSE.APACHE2 @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/packages/client-utils/LICENSE.MIT b/packages/client-utils/LICENSE.MIT new file mode 100644 index 00000000000..fe29e78e0fe --- /dev/null +++ b/packages/client-utils/LICENSE.MIT @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 MetaMask + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packages/client-utils/README.md b/packages/client-utils/README.md new file mode 100644 index 00000000000..ed211db145d --- /dev/null +++ b/packages/client-utils/README.md @@ -0,0 +1,19 @@ +# `@metamask/client-utils` + +Shared functions and utilities used across MetaMask clients (extension and mobile). + +## Installation + +`yarn add @metamask/client-utils` + +or + +`npm install @metamask/client-utils` + +## Activity mappers + +Pure mappers that normalize API, keyring, and local transaction shapes into a shared `ActivityItem`. See [src/mappers/README.md](./src/mappers/README.md). + +## 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-utils/jest.config.js b/packages/client-utils/jest.config.js new file mode 100644 index 00000000000..d6ba5b87a98 --- /dev/null +++ b/packages/client-utils/jest.config.js @@ -0,0 +1,32 @@ +/* + * 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, + + // Test fixtures and shared test helpers are not part of the package surface. + coveragePathIgnorePatterns: [ + ...baseConfig.coveragePathIgnorePatterns, + '.*/test/.*', + ], + + // An object that configures minimum threshold enforcement for coverage results + coverageThreshold: { + global: { + branches: 99.82, + functions: 100, + lines: 100, + statements: 100, + }, + }, +}); diff --git a/packages/client-utils/package.json b/packages/client-utils/package.json new file mode 100644 index 00000000000..df9d046fc23 --- /dev/null +++ b/packages/client-utils/package.json @@ -0,0 +1,83 @@ +{ + "name": "@metamask/client-utils", + "version": "2.1.1", + "description": "Shared functions and utilities used across MetaMask clients (extension and mobile)", + "keywords": [ + "Ethereum", + "MetaMask" + ], + "homepage": "https://github.com/MetaMask/core/tree/main/packages/client-utils#readme", + "bugs": { + "url": "https://github.com/MetaMask/core/issues" + }, + "license": "(MIT OR Apache-2.0)", + "repository": { + "type": "git", + "url": "https://github.com/MetaMask/core.git" + }, + "files": [ + "dist/" + ], + "sideEffects": false, + "main": "./dist/index.cjs", + "types": "./dist/index.d.cts", + "exports": { + ".": { + "import": { + "types": "./dist/index.d.mts", + "default": "./dist/index.mjs" + }, + "require": { + "types": "./dist/index.d.cts", + "default": "./dist/index.cjs" + } + }, + "./package.json": "./package.json" + }, + "publishConfig": { + "access": "public", + "registry": "https://registry.npmjs.org/" + }, + "scripts": { + "build": "ts-bridge --project tsconfig.build.json --verbose --clean --no-references", + "build:all": "ts-bridge --project tsconfig.build.json --verbose --clean", + "build:docs": "typedoc", + "changelog:update": "../../scripts/update-changelog.sh @metamask/client-utils", + "changelog:validate": "../../scripts/validate-changelog.sh @metamask/client-utils", + "lint:tsconfigs": "tsx ../../scripts/lint-tsconfigs/lint-tsconfigs.mts", + "lint:tsconfigs:fix": "tsx ../../scripts/lint-tsconfigs/lint-tsconfigs.mts --fix", + "messenger-action-types:check": "tsx ../../packages/messenger-cli/src/cli.ts --formatter oxfmt --esm --check", + "messenger-action-types:generate": "tsx ../../packages/messenger-cli/src/cli.ts --formatter oxfmt --esm --generate", + "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/contract-metadata": "^2.4.0", + "@metamask/controller-utils": "^12.3.0", + "@metamask/core-backend": "^9.0.0", + "@metamask/keyring-api": "^24.0.0", + "@metamask/slip44": "^4.3.0", + "@metamask/transaction-controller": "^69.6.1", + "@metamask/utils": "^11.11.0", + "eth-chainlist": "^0.0.795" + }, + "devDependencies": { + "@ethersproject/abi": "^5.7.0", + "@metamask/auto-changelog": "^6.1.0", + "@ts-bridge/cli": "^0.6.4", + "@types/jest": "^30.0.0", + "deepmerge": "^4.2.2", + "jest": "^30.4.2", + "ts-jest": "^29.4.11", + "tsx": "^4.20.5", + "typedoc": "^0.25.13", + "typedoc-plugin-missing-exports": "^2.0.0", + "typescript": "~5.3.3" + }, + "engines": { + "node": "^18.18 || >=20" + } +} diff --git a/packages/client-utils/src/formatters/create-formatters.test.ts b/packages/client-utils/src/formatters/create-formatters.test.ts new file mode 100644 index 00000000000..39eea130789 --- /dev/null +++ b/packages/client-utils/src/formatters/create-formatters.test.ts @@ -0,0 +1,349 @@ +import { createFormatters } from './create-formatters.js'; + +const locale = 'en-US'; + +const invalidValues = [ + Number.NaN, + Number.POSITIVE_INFINITY, + Number.NEGATIVE_INFINITY, +]; + +describe('createFormatters', () => { + it('uses the fallback locale when none is provided', () => { + const { formatCurrency } = createFormatters({}); + expect(formatCurrency(1, 'USD')).toBe('$1.00'); + }); +}); + +describe('formatNumber', () => { + const { formatNumber } = createFormatters({ locale }); + + it('formats a basic integer', () => { + expect(formatNumber(1234)).toBe('1,234'); + }); + + it('respects fraction digit options', () => { + expect( + formatNumber(1.2345, { + minimumFractionDigits: 2, + maximumFractionDigits: 2, + }), + ).toBe('1.23'); + }); + + it('returns empty string for invalid number', () => { + expect(formatNumber(NaN)).toBe(''); + }); +}); + +describe('formatCurrency', () => { + const { formatCurrency } = createFormatters({ locale }); + + const testCases = [ + { value: 1_234.56, expected: '$1,234.56' }, + { value: 0, expected: '$0.00' }, + { value: -42.5, expected: '-$42.50' }, + ]; + + it('formats values correctly', () => { + testCases.forEach(({ value, expected }) => { + expect(formatCurrency(value, 'USD')).toBe(expected); + }); + }); + + it('handles invalid values', () => { + invalidValues.forEach((input) => { + expect(formatCurrency(input, 'USD')).toBe(''); + }); + }); + + it('formats values correctly with different locale', () => { + const { formatCurrency: formatCurrencyGB } = createFormatters({ + locale: 'en-GB', + }); + expect(formatCurrencyGB(1234.56, 'GBP')).toBe('£1,234.56'); + }); + + it('falls back to two-decimal format when given an invalid currency code (RangeError)', () => { + // An invalid currency code causes Intl.NumberFormat to throw RangeError; + // the implementation falls back to a plain decimal format. + expect(() => formatCurrency(1, 'INVALID_CURRENCY')).not.toThrow(); + expect(formatCurrency(1, 'INVALID_CURRENCY')).toBe('1.00'); + }); + + it('re-throws non-RangeError errors from Intl.NumberFormat', () => { + const original = Intl.NumberFormat; + const typeError = new TypeError('unexpected'); + Intl.NumberFormat = jest.fn().mockImplementation(() => { + throw typeError; + }) as unknown as typeof Intl.NumberFormat; + + // Use a unique locale so the cache doesn't short-circuit the constructor call. + const { formatCurrency: formatFresh } = createFormatters({ + locale: 'zz-ZZ-rethrow', + }); + + try { + expect(() => formatFresh(1, 'USD')).toThrow(typeError); + } finally { + Intl.NumberFormat = original; + } + }); +}); + +describe('formatCurrencyWithMinThreshold', () => { + const { formatCurrencyWithMinThreshold } = createFormatters({ locale }); + + const testCases = [ + { value: 0, expected: '$0.00' }, + + // Values below minimum threshold + { value: 0.000001, expected: '<$0.01' }, + { value: 0.001, expected: '<$0.01' }, + { value: -0.001, expected: '<$0.01' }, + + // Values at and above minimum threshold + { value: 0.01, expected: '$0.01' }, + { value: 0.1, expected: '$0.10' }, + { value: 1, expected: '$1.00' }, + { value: -0.01, expected: '-$0.01' }, + { value: -1, expected: '-$1.00' }, + { value: -100, expected: '-$100.00' }, + { value: 1_000, expected: '$1,000.00' }, + { value: 1_000_000, expected: '$1,000,000.00' }, + ]; + + it('formats values correctly', () => { + testCases.forEach(({ value, expected }) => { + expect(formatCurrencyWithMinThreshold(value, 'USD')).toBe(expected); + }); + }); + + it('handles invalid values', () => { + invalidValues.forEach((input) => { + expect(formatCurrencyWithMinThreshold(input, 'USD')).toBe(''); + }); + }); +}); + +describe('formatCurrencyTokenPrice', () => { + const { formatCurrencyTokenPrice } = createFormatters({ locale }); + + const testCases = [ + { value: 0, expected: '$0.00' }, + + // Values below minimum threshold + { value: 0.000000001, expected: '<$0.00000001' }, + { value: -0.000000001, expected: '<$0.00000001' }, + + // Values above minimum threshold but less than 1 + { value: 0.0000123, expected: '$0.0000123' }, + { value: 0.001, expected: '$0.00100' }, + { value: 0.999, expected: '$0.999' }, + + // Values at and above 1 but less than 1,000,000 + { value: 1, expected: '$1.00' }, + { value: -1, expected: '-$1.00' }, + { value: -500, expected: '-$500.00' }, + + // Values 1,000,000 and above + { value: 1_000_000, expected: '$1.00M' }, + { value: -2_000_000, expected: '-$2.00M' }, + ]; + + it('formats values correctly', () => { + testCases.forEach(({ value, expected }) => { + expect(formatCurrencyTokenPrice(value, 'USD')).toBe(expected); + }); + }); + + it('handles invalid values', () => { + invalidValues.forEach((input) => { + expect(formatCurrencyTokenPrice(input, 'USD')).toBe(''); + }); + }); +}); + +describe('formatToken', () => { + const { formatToken } = createFormatters({ locale }); + + const testCases = [ + { value: 1.234, symbol: 'ETH', expected: '1.234 ETH' }, + { value: 0, symbol: 'USDC', expected: '0 USDC' }, + { value: 1_000, symbol: 'DAI', expected: '1,000 DAI' }, + ]; + + it('formats token values', () => { + testCases.forEach(({ value, symbol, expected }) => { + expect(formatToken(value, symbol)).toBe(expected); + }); + }); + + it('handles invalid values', () => { + invalidValues.forEach((input) => { + expect(formatToken(input, 'ETH')).toBe(''); + }); + }); +}); + +describe('formatTokenQuantity', () => { + const { formatTokenQuantity } = createFormatters({ locale }); + + const testCases = [ + { value: 0, symbol: 'ETH', expected: '0 ETH' }, + + // Values below minimum threshold + { value: 0.000000001, symbol: 'ETH', expected: '<0.00001 ETH' }, + { value: -0.000000001, symbol: 'ETH', expected: '<0.00001 ETH' }, + { value: 0.0000005, symbol: 'USDC', expected: '<0.00001 USDC' }, + + // Values above minimum threshold but less than 1 + { value: 0.00001, symbol: 'ETH', expected: '0.0000100 ETH' }, + { value: 0.001234, symbol: 'BTC', expected: '0.00123 BTC' }, + { value: 0.123456, symbol: 'USDC', expected: '0.123 USDC' }, + + // Values 1 and above but less than 1,000,000 + { value: 1, symbol: 'ETH', expected: '1 ETH' }, + { value: -1, symbol: 'ETH', expected: '-1 ETH' }, + { value: -25.5, symbol: 'ETH', expected: '-25.5 ETH' }, + { value: 1.2345678, symbol: 'BTC', expected: '1.235 BTC' }, + { value: 123.45678, symbol: 'USDC', expected: '123.457 USDC' }, + { value: 999_999, symbol: 'DAI', expected: '999,999 DAI' }, + + // Values 1,000,000 and above + { value: 1_000_000, symbol: 'ETH', expected: '1.00M ETH' }, + { value: -1_500_000, symbol: 'ETH', expected: '-1.50M ETH' }, + { value: 1_234_567, symbol: 'BTC', expected: '1.23M BTC' }, + { value: 1_000_000_000, symbol: 'USDC', expected: '1.00B USDC' }, + ]; + + it('formats token quantities correctly', () => { + testCases.forEach(({ value, symbol, expected }) => { + expect(formatTokenQuantity(value, symbol)).toBe(expected); + }); + }); + + it('handles invalid values', () => { + invalidValues.forEach((input) => { + expect(formatTokenQuantity(input, 'ETH')).toBe(''); + }); + }); +}); + +describe('formatTokenAmount', () => { + const { formatTokenAmount } = createFormatters({ locale }); + + const testCases = [ + // Zero: no trailing decimal + { value: 0, symbol: 'ETH', expected: '0 ETH' }, + + // Values below minimum threshold + { value: 0.000000001, symbol: 'ETH', expected: '<0.00001 ETH' }, + { value: -0.000000001, symbol: 'ETH', expected: '<0.00001 ETH' }, + + // Values above threshold but less than 1 (1-4 significant digits) + { value: 0.5, symbol: 'ETH', expected: '0.5 ETH' }, + { value: 0.1234, symbol: 'BTC', expected: '0.1234 BTC' }, + + // Values 1 and above but less than 1,000,000 (no trailing zeros) + { value: 1, symbol: 'ETH', expected: '1 ETH' }, + { value: 1.5, symbol: 'ETH', expected: '1.5 ETH' }, + { value: 1.2345678, symbol: 'BTC', expected: '1.2346 BTC' }, + { value: 999_999, symbol: 'DAI', expected: '999,999 DAI' }, + + // Values 1,000,000 and above (compact, no trailing zeros) + { value: 1_000_000, symbol: 'ETH', expected: '1M ETH' }, + { value: 1_500_000, symbol: 'ETH', expected: '1.5M ETH' }, + { value: 1_234_567, symbol: 'BTC', expected: '1.23M BTC' }, + ]; + + it('formats token amounts without trailing zeros', () => { + testCases.forEach(({ value, symbol, expected }) => { + expect(formatTokenAmount(value, symbol)).toBe(expected); + }); + }); + + it('handles invalid values', () => { + invalidValues.forEach((input) => { + expect(formatTokenAmount(input, 'ETH')).toBe(''); + }); + }); +}); + +describe('formatPercentWithMinThreshold', () => { + const { formatPercentWithMinThreshold } = createFormatters({ locale }); + + it('formats zero as 0.00%', () => { + expect(formatPercentWithMinThreshold(0)).toBe('0.00%'); + }); + + it('clamps small positive values to 0.01%', () => { + // 0.00001 ratio = 0.001% which is below 0.01% floor + expect(formatPercentWithMinThreshold(0.00001)).toBe('0.01%'); + }); + + it('clamps small negative values to -0.01%', () => { + expect(formatPercentWithMinThreshold(-0.00001)).toBe('-0.01%'); + }); + + it('formats values at or above threshold normally', () => { + // 0.1234 ratio = 12.34% + expect(formatPercentWithMinThreshold(0.1234)).toBe('12.34%'); + }); + + it('formats negative values correctly', () => { + expect(formatPercentWithMinThreshold(-0.05)).toBe('-5.00%'); + }); + + it('returns empty string for invalid values', () => { + invalidValues.forEach((input) => { + expect(formatPercentWithMinThreshold(input)).toBe(''); + }); + }); +}); + +describe('formatCompact', () => { + const { formatCompact } = createFormatters({ locale }); + + it('formats large numbers in compact notation', () => { + expect(formatCompact(1_000)).toBe('1.00K'); + expect(formatCompact(1_500_000)).toBe('1.50M'); + }); + + it('formats small numbers with two decimal places', () => { + expect(formatCompact(1.5)).toBe('1.50'); + }); + + it('returns empty string for invalid values', () => { + invalidValues.forEach((input) => { + expect(formatCompact(input)).toBe(''); + }); + }); +}); + +describe('formatDateTime', () => { + const { formatDateTime } = createFormatters({ locale }); + + it('formats a timestamp as a localized date+time string', () => { + // March 15, 2024, 2:30 PM UTC + const timestamp = new Date('2024-03-15T14:30:00Z').getTime(); + const result = formatDateTime(timestamp); + expect(result).toMatch(/Mar/u); + expect(result).toMatch(/15/u); + expect(result).toMatch(/2024/u); + }); + + it('returns empty string for falsy timestamp', () => { + expect(formatDateTime(0)).toBe(''); + expect(formatDateTime('')).toBe(''); + }); + + it('returns empty string for an invalid date string', () => { + expect(formatDateTime('not-a-date')).toBe(''); + }); + + it('accepts string timestamps', () => { + const result = formatDateTime('2024-03-15T14:30:00Z'); + expect(result).toMatch(/2024/u); + }); +}); diff --git a/packages/client-utils/src/formatters/create-formatters.ts b/packages/client-utils/src/formatters/create-formatters.ts new file mode 100644 index 00000000000..69a2bea3e21 --- /dev/null +++ b/packages/client-utils/src/formatters/create-formatters.ts @@ -0,0 +1,383 @@ +const FALLBACK_LOCALE = 'en'; + +const twoDecimals = { + minimumFractionDigits: 2, + maximumFractionDigits: 2, +}; + +const oneSignificantDigit = { + minimumSignificantDigits: 1, + maximumSignificantDigits: 1, +}; + +const threeSignificantDigits = { + minimumSignificantDigits: 3, + maximumSignificantDigits: 3, +}; + +const numberFormatCache: Record = {}; +const dateTimeFormatCache: Record = {}; + +type Value = number | bigint | `${number}`; + +function getCachedNumberFormat( + locale: string, + options: Intl.NumberFormatOptions, +): Intl.NumberFormat { + const key = `${locale}_${JSON.stringify(options)}`; + + let format = numberFormatCache[key]; + + if (format) { + return format; + } + + try { + format = new Intl.NumberFormat(locale, options); + } catch (error) { + if (error instanceof RangeError) { + format = new Intl.NumberFormat(locale, twoDecimals); + } else { + throw error; + } + } + + numberFormatCache[key] = format; + return format; +} + +function getCachedDateTimeFormat( + locale: string, + options: Intl.DateTimeFormatOptions, +): Intl.DateTimeFormat { + const key = `${locale}_${JSON.stringify(options)}`; + if (!dateTimeFormatCache[key]) { + dateTimeFormatCache[key] = new Intl.DateTimeFormat(locale, options); + } + return dateTimeFormatCache[key]; +} + +function formatNumber( + config: { locale: string }, + value: Value, + options: Intl.NumberFormatOptions = {}, +): string { + if (!Number.isFinite(Number(value))) { + return ''; + } + + const numberFormat = getCachedNumberFormat(config.locale, options); + + // @ts-expect-error Remove this comment once TypeScript is updated to 5.5+ + return numberFormat.format(value); +} + +function formatCurrency( + config: { locale: string }, + value: Value, + currency: Intl.NumberFormatOptions['currency'], + options: Intl.NumberFormatOptions = {}, +): string { + if (!Number.isFinite(Number(value))) { + return ''; + } + + const numberFormat = getCachedNumberFormat(config.locale, { + style: 'currency', + currency, + ...options, + }); + + // @ts-expect-error Remove this comment once TypeScript is updated to 5.5+ + return numberFormat.format(value); +} + +function formatCurrencyCompact( + config: { locale: string }, + value: Value, + currency: Intl.NumberFormatOptions['currency'], +): string { + return formatCurrency(config, value, currency, { + notation: 'compact', + ...twoDecimals, + }); +} + +function formatCurrencyWithMinThreshold( + config: { locale: string }, + value: Value, + currency: Intl.NumberFormatOptions['currency'], +): string { + const minThreshold = 0.01; + const number = Number(value); + const absoluteValue = Math.abs(number); + + if (!Number.isFinite(number)) { + return ''; + } + + if (number === 0) { + return formatCurrency(config, 0, currency); + } + + if (absoluteValue < minThreshold) { + const formattedMin = formatCurrency(config, minThreshold, currency); + return `<${formattedMin}`; + } + + return formatCurrency(config, number, currency); +} + +function formatCurrencyTokenPrice( + config: { locale: string }, + value: Value, + currency: Intl.NumberFormatOptions['currency'], +): string { + const minThreshold = 0.00000001; + const number = Number(value); + const absoluteValue = Math.abs(number); + + if (!Number.isFinite(number)) { + return ''; + } + + if (number === 0) { + return formatCurrency(config, 0, currency); + } + + if (absoluteValue < minThreshold) { + return `<${formatCurrency(config, minThreshold, currency, oneSignificantDigit)}`; + } + + if (absoluteValue < 1) { + return formatCurrency(config, number, currency, threeSignificantDigits); + } + + if (absoluteValue < 1_000_000) { + return formatCurrency(config, number, currency); + } + + return formatCurrencyCompact(config, number, currency); +} + +function formatToken( + config: { locale: string }, + value: Value, + symbol: string, + options: Intl.NumberFormatOptions = {}, +): string { + if (!Number.isFinite(Number(value))) { + return ''; + } + + const numberFormat = getCachedNumberFormat(config.locale, { + style: 'decimal', + ...options, + }); + + // @ts-expect-error Remove this comment once TypeScript is updated to 5.5+ + const formattedNumber = numberFormat.format(value); + + return `${formattedNumber} ${symbol}`; +} + +function formatTokenQuantity( + config: { locale: string }, + value: Value, + symbol: string, +): string { + const minThreshold = 0.00001; + const number = Number(value); + const absoluteValue = Math.abs(number); + + if (!Number.isFinite(number)) { + return ''; + } + + if (number === 0) { + return formatToken(config, 0, symbol); + } + + if (absoluteValue < minThreshold) { + return `<${formatToken(config, minThreshold, symbol, oneSignificantDigit)}`; + } + + if (absoluteValue < 1) { + return formatToken(config, number, symbol, threeSignificantDigits); + } + + if (absoluteValue < 1_000_000) { + return formatToken(config, number, symbol); + } + + return formatToken(config, number, symbol, { + notation: 'compact', + ...twoDecimals, + }); +} + +// Format token quantity without trailing zeros. +function formatTokenAmount( + config: { locale: string }, + value: Value, + symbol: string, +): string { + const minThreshold = 0.00001; + const number = Number(value); + const absoluteValue = Math.abs(number); + + if (!Number.isFinite(number)) { + return ''; + } + + if (number === 0) { + return formatToken(config, 0, symbol, { maximumFractionDigits: 0 }); + } + + if (absoluteValue < minThreshold) { + return `<${formatToken(config, minThreshold, symbol, { + minimumSignificantDigits: 1, + maximumSignificantDigits: 1, + })}`; + } + + if (absoluteValue < 1) { + return formatToken(config, number, symbol, { + minimumSignificantDigits: 1, + maximumSignificantDigits: 4, + }); + } + + if (absoluteValue < 1_000_000) { + return formatToken(config, number, symbol, { + minimumFractionDigits: 0, + maximumFractionDigits: 4, + }); + } + + return formatToken(config, number, symbol, { + notation: 'compact', + minimumFractionDigits: 0, + maximumFractionDigits: 2, + }); +} + +function formatPercentWithMinThreshold( + config: { locale: string }, + value: Value, + options: Intl.NumberFormatOptions = {}, +): string { + const minThreshold = 0.0001; // 0.01% + const number = Number(value); + + if (!Number.isFinite(number)) { + return ''; + } + + const clamped = + number === 0 + ? 0 + : Math.sign(number) * Math.max(Math.abs(number), minThreshold); + + return formatNumber(config, clamped, { + style: 'percent', + maximumFractionDigits: 2, + minimumFractionDigits: 2, + ...options, + }); +} + +function formatCompact(config: { locale: string }, value: Value): string { + return formatNumber(config, value, { + notation: 'compact', + minimumFractionDigits: 2, + maximumFractionDigits: 2, + }); +} + +function formatDateTime( + config: { locale: string }, + timestamp: string | number, + options?: Intl.DateTimeFormatOptions, +): string { + if (!timestamp) { + return ''; + } + const date = new Date(timestamp); + if (isNaN(date.getTime())) { + return ''; + } + return getCachedDateTimeFormat(config.locale, { + month: 'short', + day: 'numeric', + year: 'numeric', + hour: 'numeric', + minute: 'numeric', + hour12: true, + ...options, + }).format(date); +} + +export type Formatters = { + formatNumber: (value: Value, options?: Intl.NumberFormatOptions) => string; + formatCurrency: ( + value: Value, + currency: Intl.NumberFormatOptions['currency'], + options?: Intl.NumberFormatOptions, + ) => string; + formatCurrencyCompact: ( + value: Value, + currency: Intl.NumberFormatOptions['currency'], + ) => string; + formatCurrencyWithMinThreshold: ( + value: Value, + currency: Intl.NumberFormatOptions['currency'], + ) => string; + formatCurrencyTokenPrice: ( + value: Value, + currency: Intl.NumberFormatOptions['currency'], + ) => string; + formatToken: ( + value: Value, + symbol: string, + options?: Intl.NumberFormatOptions, + ) => string; + formatTokenQuantity: (value: Value, symbol: string) => string; + formatTokenAmount: (value: Value, symbol: string) => string; + formatPercentWithMinThreshold: ( + value: Value, + options?: Intl.NumberFormatOptions, + ) => string; + formatCompact: (value: Value) => string; + formatDateTime: ( + timestamp: string | number, + options?: Intl.DateTimeFormatOptions, + ) => string; +}; + +export function createFormatters({ + locale = FALLBACK_LOCALE, +}: { + locale?: string; +}): Formatters { + const config = { locale }; + return { + formatNumber: formatNumber.bind(null, config), + formatCurrency: formatCurrency.bind(null, config), + formatCurrencyCompact: formatCurrencyCompact.bind(null, config), + formatCurrencyWithMinThreshold: formatCurrencyWithMinThreshold.bind( + null, + config, + ), + formatCurrencyTokenPrice: formatCurrencyTokenPrice.bind(null, config), + formatToken: formatToken.bind(null, config), + formatTokenQuantity: formatTokenQuantity.bind(null, config), + formatTokenAmount: formatTokenAmount.bind(null, config), + formatPercentWithMinThreshold: formatPercentWithMinThreshold.bind( + null, + config, + ), + formatCompact: formatCompact.bind(null, config), + formatDateTime: formatDateTime.bind(null, config), + }; +} diff --git a/packages/client-utils/src/index.ts b/packages/client-utils/src/index.ts new file mode 100644 index 00000000000..7bb52fca2a8 --- /dev/null +++ b/packages/client-utils/src/index.ts @@ -0,0 +1,10 @@ +export { createFormatters } from './formatters/create-formatters.js'; +export type { Formatters } from './formatters/create-formatters.js'; + +export { mapApiTransaction } from './mappers/api-transaction-mapper.js'; +export { mapKeyringTransaction } from './mappers/keyring-transaction-mapper.js'; +export { mapLocalTransaction } from './mappers/local-transaction-mapper.js'; +export { mapRampsOrder } from './mappers/ramps-order-mapper.js'; +export type { RampsOrderLike } from './mappers/ramps-order-mapper.js'; + +export type * from './types.js'; diff --git a/packages/client-utils/src/mappers/README.md b/packages/client-utils/src/mappers/README.md new file mode 100644 index 00000000000..de0b6d0f9a2 --- /dev/null +++ b/packages/client-utils/src/mappers/README.md @@ -0,0 +1,111 @@ +# Activity Mappers + +These mappers normalize different shapes from various data sources, currently: + +- EVM transactions from the Metamask Account Transactions REST endpoint +- Non-EVM transactions +- Local transaction state + +Each mapper is a pure function that returns the shared `ActivityItem` shape consumed by MetaMask clients (extension and mobile) for activity lists and transaction details. + +Ultimately, the goal is for the MetaMask API to provide both EVM and non-EVM data in a shape closer to what the UI needs. Until then, we adapt these different data sources. + +> ### A note on local transaction mapping +> +> Mapping local transaction state is only meant to support rendering while a transaction is pending and has not been indexed by the API, or in special cases where we want to enrich the UI with local-only data. +> +> Do **not** rely on it for primary rendering. Activity opened on another instance will not be consistent, since that instance will not have the local-only state. + +--- + +## Table of contents + +- [Architecture](#architecture) +- [Mappers](#mappers) + - [EVM transactions: API mapper](#evm-transactions-api-mapper) + - [Non-EVM transactions: keyring mapper](#non-evm-transactions-keyring-mapper) + - [Local state: TransactionController mapper](#local-state-transactioncontroller-mapper) +- [Where mappers are used](#where-mappers-are-used) +- [Adding a new activity type](#adding-a-new-activity-type) +- [Adding a new mapper / data source](#adding-a-new-mapper--data-source) + +--- + +## Architecture + +```mermaid +flowchart LR + api["EVM transactions
V1TransactionByHashResponse"] --> apiMapper["mapApiTransaction"] + nonEvm["Non-EVM transactions
@metamask/keyring-api"] --> nonEvmMapper["mapKeyringTransaction"] + local["Local state
(TransactionController)"] --> localMapper["mapLocalTransaction"] + + apiMapper --> items["ActivityItem"] + nonEvmMapper --> items + localMapper --> items + + items --> list["Activity list"] + items --> details["Transaction details"] + items --> toast["Transaction toast"] +``` + +1. **Single output type** — every mapper returns `ActivityItem` +2. **Pure functions** — mappers do not touch Redux or client stores. Clients fetch state before calling these functions + +--- + +## Mappers + +### EVM transactions: API mapper + +File: `api-transaction-mapper.ts` + +Input: `V1TransactionByHashResponse` from `@metamask/core-backend` + +The Accounts API classifies each transaction with a `transactionCategory`. The mapper further classifies and maps each item to the UI-facing activity kind. + +Notes: + +- Backend API improvements are ongoing +- Native tokens and network fees include slip44 `assetId` when a symbol is available (for example from an indexed native value transfer) +- When no symbol is available, mappers still set `assetType: 'native'` but omit `assetId` — clients should resolve icons from chain metadata + +--- + +### Non-EVM transactions: keyring mapper + +File: `keyring-transaction-mapper.ts` + +Input: `Transaction` from `@metamask/keyring-api` + +Notes: + +The mapper is chain-agnostic. Clients should patch missing / `UNKNOWN` asset units from `AssetsController` (or equivalent) metadata before calling it when needed. + +--- + +### Local state: TransactionController mapper + +File: `local-transaction-mapper.ts` + +Input: a `TransactionGroup` from `helpers/transactions.ts` — the shape the EVM `TransactionController` produces after grouping by nonce (`initialTransaction`, `primaryTransaction`, plus cancel/retry siblings), optionally enriched by the client (`sourceToken`, `destinationToken`, fees, etc.) + +This mapper only classifies `ActivityKind`. It is a stand-in until the indexed API picks up the transaction; clients should defer accurate token/amount/asset details to the API mapper on refetch. + +--- + +## Adding a new activity type + +1. **Define the kind** in [`../types.ts`](../types.ts): add the literal to `ActivityKind`, add a matching `ActivityData<…>` variant with that kind's fields. +2. **Emit it** from one or more mappers. +3. **Render it** in each client’s activity row / details templates. + +--- + +## Adding a new mapper / data source + +Use a new mapper when a source has its own data model and can't be reasonably squeezed through one of the three existing mappers. + +1. Create a mapper file with a pure function returning a single `ActivityItem`. +2. Export it from [`../index.ts`](../index.ts). +3. Wire the client to read the data source and call the mapper once per item, then include the result in the client's activity list dedupe path. +4. Update this README. diff --git a/packages/client-utils/src/mappers/api-transaction-mapper.test.ts b/packages/client-utils/src/mappers/api-transaction-mapper.test.ts new file mode 100644 index 00000000000..e1a85dea10e --- /dev/null +++ b/packages/client-utils/src/mappers/api-transaction-mapper.test.ts @@ -0,0 +1,1177 @@ +import { apiTransactionFixtures } from '../../test/fixtures/api-transactions.js'; +import { mapApiTransaction } from './api-transaction-mapper.js'; +import { formatAddressToAssetId } from './helpers/caip.js'; + +// Mock known-token lookup with the deterministic test table in `test/`. +jest.mock('./helpers/token-metadata', () => ({ + getKnownTokenMetadata: jest.requireActual('../../test/test-helpers') + .getKnownTokenMetadata, +})); + +const { + subjectAddress, + baseUsdc, + mainnetUsdc, + baseAaveUsdc, + baseRecipientAddress, + lineaMusd, + lineaSenderAddress, + bscContractCallerAddress, + bscUniversalRouter, + polygonRecipientAddress, + wethContractAddress, + zeroAddress, + nftRecipientAddress, + nftBuyerAddress, + nftSellerAddress, +} = apiTransactionFixtures.addresses; + +describe('mapApiTransaction', () => { + it('maps an ERC-20 transfer sent by the account to a Send activity', () => { + const item = mapApiTransaction( + apiTransactionFixtures.mapArgs.mapsAnErc20TransferSent, + ); + + expect(item).toMatchObject({ + type: 'send', + chainId: 'eip155:8453', + status: 'success', + timestamp: 1778593067000, + data: { + from: subjectAddress, + to: baseRecipientAddress, + token: { + direction: 'out', + symbol: 'USDC', + assetId: formatAddressToAssetId(baseUsdc, 'eip155:8453'), + }, + }, + }); + }); + + it('maps an ERC-20 transfer with an incidental receive transfer to a Send activity', () => { + const { transaction } = + apiTransactionFixtures.mapArgs.mapsAnErc20TransferWith; + const aaveLineaUsdc = transaction.to; + const senderAddress = transaction.from; + const recipientAddress = transaction.valueTransfers?.[1]?.to; + + const item = mapApiTransaction( + apiTransactionFixtures.mapArgs.mapsAnErc20TransferWith, + ); + + expect(item).toMatchObject({ + type: 'send', + chainId: 'eip155:59144', + status: 'success', + timestamp: 1778074371000, + hash: transaction.hash, + data: { + from: senderAddress, + to: recipientAddress, + token: { + direction: 'out', + amount: '419402', + decimals: 6, + symbol: 'aLinUSDC', + assetId: formatAddressToAssetId(aaveLineaUsdc, 'eip155:59144'), + }, + }, + }); + }); + + it('maps a native value contract call without method data to a Send activity', () => { + const item = mapApiTransaction( + apiTransactionFixtures.mapArgs.mapsANativeValueContractCall, + ); + + expect(item).toMatchObject({ + type: 'send', + chainId: 'eip155:137', + status: 'success', + timestamp: 1779218832000, + hash: '0x64d2f26c261178252fcad9dbb665cf40337b827a582066553dd6634eaeea9f0a', + data: { + from: subjectAddress, + to: polygonRecipientAddress, + token: { + amount: '100000000000000000', + decimals: 18, + direction: 'out', + symbol: 'MATIC', + assetType: 'native', + assetId: 'eip155:137/slip44:966', + }, + }, + }); + }); + + it('maps an approval without value transfers to an Approve spending cap activity with token metadata', () => { + const item = mapApiTransaction( + apiTransactionFixtures.mapArgs.mapsAnApprovalWithoutValueTransfers, + ); + + expect(item).toMatchObject({ + type: 'approveSpendingCap', + chainId: 'eip155:8453', + status: 'success', + timestamp: 1779888027000, + hash: '0x91f89897197afcc09ad98ec4282366fd7938d8a9609e4fc2a0aa2d070664bc27', + data: { + token: { + direction: 'out', + symbol: 'USDC', + decimals: 6, + assetId: formatAddressToAssetId(baseUsdc, 'eip155:8453'), + }, + }, + }); + }); + + it('falls back to value transfer contract address when approval to is invalid', () => { + const item = mapApiTransaction( + apiTransactionFixtures.mapArgs.fallsBackToValueTransferContract, + ); + + expect(item).toMatchObject({ + type: 'approveSpendingCap', + chainId: 'eip155:59144', + data: { + token: { + direction: 'out', + symbol: 'mUSD', + assetId: formatAddressToAssetId(lineaMusd, 'eip155:59144'), + }, + }, + }); + }); + + it('maps an approval with neither a valid to nor a contract transfer to an assetId-less spending cap', () => { + const item = mapApiTransaction( + apiTransactionFixtures.mapArgs.mapsAnApprovalWithNeitherA, + ); + + expect(item).toMatchObject({ + type: 'approveSpendingCap', + chainId: 'eip155:59144', + data: { + token: undefined, + }, + }); + }); + + it('maps an ERC-20 transfer received by the account to a Receive activity', () => { + const item = mapApiTransaction( + apiTransactionFixtures.mapArgs.mapsAnErc20TransferReceived, + ); + + expect(item).toMatchObject({ + type: 'receive', + chainId: 'eip155:59144', + status: 'success', + timestamp: 1777983327000, + data: { + from: lineaSenderAddress, + to: subjectAddress, + token: { + direction: 'in', + symbol: 'mUSD', + assetId: formatAddressToAssetId(lineaMusd, 'eip155:59144'), + }, + }, + }); + }); + + it('maps an exchange transaction without a received token to a Swap activity with no destination', () => { + const item = mapApiTransaction( + apiTransactionFixtures.mapArgs.mapsAnExchangeTransactionWithoutA, + ); + + expect(item).toMatchObject({ + type: 'swap', + chainId: 'eip155:59144', + status: 'success', + timestamp: 1778003873000, + data: { + sourceToken: { + direction: 'out', + symbol: 'mUSD', + }, + }, + }); + }); + + it('maps an exchange transaction with an internal ETH receive transfer to a Swap activity with a native destination token', () => { + const item = mapApiTransaction( + apiTransactionFixtures.mapArgs.mapsAnExchangeTransactionWithAn, + ); + + expect(item).toMatchObject({ + type: 'swap', + chainId: 'eip155:59144', + status: 'success', + timestamp: 1779930229000, + hash: '0x80b974d5834e1047a78332369de3d4b988f0237ff8a418c9464217e55c542f2f', + data: { + sourceToken: { + amount: '10000', + decimals: 6, + direction: 'out', + assetId: formatAddressToAssetId(lineaMusd, 'eip155:59144'), + symbol: 'mUSD', + }, + destinationToken: { + amount: '4894004361763', + decimals: 18, + direction: 'in', + symbol: 'ETH', + assetType: 'native', + assetId: 'eip155:59144/slip44:60', + }, + }, + }); + }); + + it('maps the LiFi Linea USDC to ETH exchange to a Swap activity', () => { + const lineaUsdc = '0x176211869ca2b568f2a7d4ee941e073a821ee1ff'; + + const item = mapApiTransaction( + apiTransactionFixtures.mapArgs.mapsTheLifiLineaUsdcTo, + ); + + expect(item).toMatchObject({ + type: 'swap', + chainId: 'eip155:59144', + status: 'success', + timestamp: new Date('2026-01-16T21:09:00.000Z').getTime(), + hash: '0x3ac43e7c4a1a4421304ada43b41acec4d71ad90abfa418e97e92540a26eef0a2', + data: { + sourceToken: { + amount: '7934205', + decimals: 6, + direction: 'out', + assetId: formatAddressToAssetId(lineaUsdc, 'eip155:59144'), + symbol: 'USDC', + }, + destinationToken: { + amount: '2388594176642019', + decimals: 18, + direction: 'in', + symbol: 'ETH', + assetType: 'native', + assetId: 'eip155:59144/slip44:60', + }, + fees: [ + { + type: 'base', + amount: '11794061214463', + decimals: 18, + assetType: 'native', + symbol: 'ETH', + assetId: 'eip155:59144/slip44:60', + }, + ], + }, + }); + }); + + it('maps an NFT sale with received native ETH to a Sell activity', () => { + const item = mapApiTransaction( + apiTransactionFixtures.mapArgs.mapsAnNftSaleWithReceived, + ); + + expect(item).toMatchObject({ + type: 'nftSell', + chainId: 'eip155:1', + status: 'success', + timestamp: 1771884263000, + data: { + from: subjectAddress, + to: nftRecipientAddress, + token: { + direction: 'out', + symbol: 'BAE', + }, + paymentToken: { + direction: 'in', + symbol: 'ETH', + assetType: 'native', + assetId: 'eip155:1/slip44:60', + }, + }, + }); + }); + + it('maps an OpenSea NFT sale paid in WETH to a Sell activity', () => { + const sellerAddress = apiTransactionFixtures.addresses.openseaSellerAddress; + + const item = mapApiTransaction( + apiTransactionFixtures.mapArgs.mapsAnOpenseaNftSalePaid, + ); + + expect(item).toMatchObject({ + type: 'nftSell', + chainId: 'eip155:8453', + status: 'success', + timestamp: 1768427429000, + hash: '0x0e7f29fa4af73f3708a7383a2fa8d0e09f6c6bf8a176bccf3a6b3259e2886bae', + data: { + from: sellerAddress, + to: '0xbaf3ad6542f932cc0e0b54983e82e0cfb7c5a5a1', + token: { + direction: 'out', + // name takes precedence over symbol for NFTs + symbol: 'The Warplets', + }, + paymentToken: { + direction: 'in', + symbol: 'WETH', + }, + }, + }); + }); + + it('maps a plain NFT send with no payment to a Send activity', () => { + const item = mapApiTransaction( + apiTransactionFixtures.mapArgs.mapsAPlainNftSendWith, + ); + + expect(item).toMatchObject({ + type: 'send', + chainId: 'eip155:1', + data: { + token: { + direction: 'out', + symbol: 'BAE', + }, + }, + }); + }); + + it('maps an NFT purchase', () => { + const item = mapApiTransaction( + apiTransactionFixtures.mapArgs.mapsAnNftPurchase, + ); + + expect(item).toMatchObject({ + type: 'nftBuy', + chainId: 'eip155:1', + status: 'success', + timestamp: 1780601507000, + hash: '0x8719dadd883779624845106e61fd94af234411c30d73184a72f4daf1425c4595', + data: { + from: '0x107b2e855528f344556f8c766a6187326a2c2fa6', + to: nftBuyerAddress, + token: { + direction: 'in', + symbol: 'FLUF World: Scenes and Sounds', + }, + paymentToken: { + direction: 'out', + symbol: 'ETH', + assetType: 'native', + assetId: 'eip155:1/slip44:60', + }, + }, + }); + }); + + it('maps an NFT purchase paid in WETH (ERC-20) to an nftBuy activity', () => { + const item = mapApiTransaction( + apiTransactionFixtures.mapArgs.mapsAnNftPurchasePaidIn, + ); + + expect(item).toMatchObject({ + type: 'nftBuy', + data: { + token: { + direction: 'in', + symbol: 'FLUF World: Scenes and Sounds', + }, + paymentToken: { + direction: 'out', + symbol: 'WETH', + }, + }, + }); + }); + + it('maps an NFT transfer received without payment to a Receive activity', () => { + const item = mapApiTransaction( + apiTransactionFixtures.mapArgs.mapsAnNftTransferReceivedWithout, + ); + + expect(item).toMatchObject({ + type: 'receive', + data: { + from: nftSellerAddress, + to: subjectAddress, + token: { direction: 'in', symbol: 'FLUF World' }, + }, + }); + }); + + it('maps an inbound NFT with an unrelated native send to a Receive activity', () => { + const item = mapApiTransaction( + apiTransactionFixtures.mapArgs.mapsAnNftReceiveWithUnrelatedNativeSend, + ); + + expect(item).toMatchObject({ + type: 'receive', + data: { + from: nftSellerAddress, + to: nftBuyerAddress, + token: { direction: 'in', symbol: 'FLUF World' }, + }, + }); + expect(item.type).not.toBe('nftBuy'); + }); + + it('maps a plain NFT send (no NFT exchange, no payment) to a Send activity', () => { + const item = mapApiTransaction( + apiTransactionFixtures.mapArgs.mapsAPlainNftSendNo, + ); + + expect(item).toMatchObject({ + type: 'send', + data: { + from: subjectAddress, + to: nftRecipientAddress, + token: { direction: 'out', symbol: 'BAE' }, + }, + }); + }); + + it('maps an NFT mint transfer to an nftMint activity without assetId', () => { + const item = mapApiTransaction( + apiTransactionFixtures.mapArgs.mapsAnNftMintTransferTo, + ); + + expect(item).toMatchObject({ + type: 'nftMint', + chainId: 'eip155:59144', + status: 'success', + timestamp: 1778682863000, + hash: '0x25805d4ae16935e6fa92add9dcee97db0127749d4244032a79489098a880210c', + data: { + from: zeroAddress, + to: subjectAddress, + token: { + direction: 'in', + symbol: 'TDN', + }, + }, + }); + }); + + it('maps an Aave supply contract call to a Lending deposit activity', () => { + const item = mapApiTransaction( + apiTransactionFixtures.mapArgs.mapsAnAaveSupplyContractCall, + ); + + expect(item).toMatchObject({ + type: 'lendingDeposit', + chainId: 'eip155:8453', + status: 'success', + timestamp: 1778643089000, + hash: '0x08d14578168f22001e95503469c63613bd9f3d3f60e81dbbf204fbd21f484bd9', + data: { + sourceToken: { + amount: '100000', + decimals: 6, + direction: 'out', + symbol: 'USDC', + assetId: formatAddressToAssetId(baseUsdc, 'eip155:8453'), + }, + destinationToken: { + amount: '99999', + decimals: 6, + direction: 'in', + symbol: 'aBasUSDC', + assetId: formatAddressToAssetId(baseAaveUsdc, 'eip155:8453'), + }, + }, + }); + }); + + it('maps an Aave withdraw with a known method id to a Lending withdrawal activity', () => { + const item = mapApiTransaction( + apiTransactionFixtures.mapArgs.mapsAnAaveWithdrawWithA, + ); + + expect(item).toMatchObject({ + type: 'lendingWithdrawal', + chainId: 'eip155:8453', + status: 'success', + timestamp: 1779893234000, + hash: '0x26f4911467b538702c0945e4ec5e303de44c0c1c174897141d1b548ea3161795', + data: { + sourceToken: { + amount: '100000', + decimals: 6, + direction: 'out', + symbol: 'aBasUSDC', + assetId: formatAddressToAssetId(baseAaveUsdc, 'eip155:8453'), + }, + destinationToken: { + amount: '200000', + decimals: 6, + direction: 'in', + symbol: 'USDC', + assetId: formatAddressToAssetId(baseUsdc, 'eip155:8453'), + }, + }, + }); + }); + + it('maps a DEPOSIT without an inbound transfer to a deposit activity', () => { + const item = mapApiTransaction( + apiTransactionFixtures.mapArgs.mapsADepositWithoutAnInbound, + ); + + expect(item).toMatchObject({ + type: 'deposit', + chainId: 'eip155:1', + status: 'success', + timestamp: 1778593067000, + hash: '0xabc123deposit00000000000000000000000000000000000000000000000001', + data: { + token: { + amount: '1000000000000000000', + decimals: 18, + direction: 'out', + symbol: 'ETH', + assetType: 'native', + assetId: 'eip155:1/slip44:60', + }, + }, + }); + }); + + // Captures the real Lido stETH stake response. NOTE: the backend returns this + // as `CONTRACT_CALL` with the Lido submit method id (part of `supplyMethodIds`), + // so `mapApiTransaction` currently returns `lendingDeposit`. `mapLocalTransaction` + // maps the same stake (TransactionType.stakingDeposit) to `deposit`, so the two + // mappers disagree on this transaction. + it('maps a real Lido stake (CONTRACT_CALL + supply method id) to a lendingDeposit activity', () => { + const item = mapApiTransaction( + apiTransactionFixtures.mapArgs.mapsALidoStakeToA, + ); + + expect(item).toMatchObject({ + type: 'lendingDeposit', + chainId: 'eip155:1', + status: 'success', + hash: '0xd8ca1456ed6305ec3d9c058f28a1ba48eb335ffcffd7d7c4321d3169c29e6a07', + data: { + from: subjectAddress, + sourceToken: { + direction: 'out', + symbol: 'ETH', + amount: '1000000000000', + assetType: 'native', + assetId: 'eip155:1/slip44:60', + }, + destinationToken: { + direction: 'in', + symbol: 'stETH', + amount: '999999999999', + }, + }, + }); + }); + + it('maps a WETH deposit to a Wrap activity', () => { + const item = mapApiTransaction( + apiTransactionFixtures.mapArgs.mapsAWethDepositToA, + ); + + expect(item).toMatchObject({ + type: 'wrap', + chainId: 'eip155:1', + status: 'success', + timestamp: 1779975743000, + hash: '0x6e448f5b8cf55534507770c1cb90ba14e723d03b4a46b4919a5847eb8d13b7b5', + data: { + sourceToken: { + amount: '1000000000000', + decimals: 18, + direction: 'out', + symbol: 'ETH', + assetType: 'native', + assetId: 'eip155:1/slip44:60', + }, + destinationToken: { + amount: '1000000000000', + decimals: 18, + direction: 'in', + symbol: 'WETH', + assetId: formatAddressToAssetId(wethContractAddress, 'eip155:1'), + }, + }, + }); + }); + + it('maps an Aave supply contract call with an uppercase method id to a Lending deposit activity', () => { + const { transaction, ...rest } = + apiTransactionFixtures.mapArgs.mapsAnAaveSupplyContractCall; + const item = mapApiTransaction({ + ...rest, + transaction: { + ...transaction, + methodId: transaction.methodId?.toUpperCase(), + }, + }); + + expect(item.type).toBe('lendingDeposit'); + }); + + it('maps an Aave withdraw with an uppercase method id to a Lending withdrawal activity', () => { + const { transaction, ...rest } = + apiTransactionFixtures.mapArgs.mapsAnAaveWithdrawWithA; + const item = mapApiTransaction({ + ...rest, + transaction: { + ...transaction, + methodId: transaction.methodId?.toUpperCase(), + }, + }); + + expect(item.type).toBe('lendingWithdrawal'); + }); + + it('maps a WETH deposit with an uppercase method id to a Wrap activity', () => { + const { transaction, ...rest } = + apiTransactionFixtures.mapArgs.mapsAWethDepositToA; + const item = mapApiTransaction({ + ...rest, + transaction: { + ...transaction, + methodId: transaction.methodId?.toUpperCase(), + }, + }); + + expect(item.type).toBe('wrap'); + }); + + it('maps a WETH withdrawal to an Unwrap activity', () => { + const item = mapApiTransaction( + apiTransactionFixtures.mapArgs.mapsAWethWithdrawalToAn, + ); + + expect(item).toMatchObject({ + type: 'unwrap', + chainId: 'eip155:1', + status: 'success', + timestamp: 1779977700000, + hash: '0x8f2a1c9e4b7d30651234567890abcdef1234567890abcdef1234567890abcdef', + data: { + sourceToken: { + amount: '1000000000000', + decimals: 18, + direction: 'out', + symbol: 'WETH', + assetId: formatAddressToAssetId(wethContractAddress, 'eip155:1'), + }, + destinationToken: { + amount: '1000000000000', + decimals: 18, + direction: 'in', + symbol: 'ETH', + assetType: 'native', + assetId: 'eip155:1/slip44:60', + }, + }, + }); + }); + + it('maps a MetaMask mUSD bonus claim to a Claim mUSD bonus activity', () => { + const item = mapApiTransaction( + apiTransactionFixtures.mapArgs.mapsAMetamaskMusdBonusClaim, + ); + + expect(item).toMatchObject({ + type: 'claimMusdBonus', + chainId: 'eip155:59144', + status: 'success', + timestamp: 1778633325000, + hash: '0x875ded271a40278391fca5d71892231afd0cb9592f31bdf3b7c949906cb982c4', + data: { + from: subjectAddress, + token: { + direction: 'in', + symbol: 'mUSD', + assetId: formatAddressToAssetId(lineaMusd, 'eip155:59144'), + }, + }, + }); + }); + + it('maps a generic CLAIM with a received token to a claim activity', () => { + const item = mapApiTransaction( + apiTransactionFixtures.mapArgs.mapsAGenericClaimWithA, + ); + + expect(item).toMatchObject({ + type: 'claim', + data: { + from: subjectAddress, + token: { direction: 'in', symbol: 'mUSD', amount: '5' }, + }, + }); + }); + + it('maps a generic CLAIM with only a sent token to an outbound claim activity', () => { + const item = mapApiTransaction( + apiTransactionFixtures.mapArgs.mapsAGenericClaimWithOnly, + ); + + expect(item).toMatchObject({ + type: 'claim', + data: { token: { direction: 'out', symbol: 'mUSD' } }, + }); + }); + + it('maps a bridge withdraw to a Bridge activity', () => { + const item = mapApiTransaction( + apiTransactionFixtures.mapArgs.mapsABridgeWithdrawToA, + ); + + expect(item).toMatchObject({ + type: 'bridge', + chainId: 'eip155:8453', + status: 'success', + timestamp: 1779941611000, + hash: '0x9f81163d00374094411f44732738c6dea194551e4500bde9fd7ee60319aac766', + data: { + fees: [ + { + amount: String(BigInt('0x24405') * BigInt('0x6fc23ac1d')), + decimals: 18, + type: 'base', + assetType: 'native', + }, + ], + sourceToken: { + amount: '100000', + decimals: 6, + direction: 'out', + symbol: 'USDC', + assetId: formatAddressToAssetId(baseUsdc, 'eip155:8453'), + }, + }, + }); + }); + + it('maps an unrecognized transaction category to a contract interaction activity', () => { + const item = mapApiTransaction( + apiTransactionFixtures.mapArgs.mapsAnUnrecognizedTransactionCategoryTo, + ); + + expect(item).toMatchObject({ + type: 'contractInteraction', + chainId: 'eip155:56', + status: 'success', + timestamp: 1778601880000, + data: { + from: bscContractCallerAddress, + methodId: '0x174dea71', + to: bscUniversalRouter, + transactionCategory: 'CONTRACT_CALL', + transactionProtocol: 'GENERIC', + token: { + direction: 'out', + symbol: 'BNB', + }, + }, + }); + }); + + it('maps a contract interaction with no value transfers without a token', () => { + const item = mapApiTransaction( + apiTransactionFixtures.mapArgs.mapsAContractCallWithNoTransfers, + ); + + expect(item.type).toBe('contractInteraction'); + const token = + item.type === 'contractInteraction' ? item.data.token : 'unset'; + expect(token).toBeUndefined(); + }); + + it('maps the reported generic contract call to a contract interaction with its token amount', () => { + const item = mapApiTransaction( + apiTransactionFixtures.mapArgs.mapsTheReportedGenericContractCall, + ); + + expect(item).toMatchObject({ + type: 'contractInteraction', + chainId: 'eip155:1', + status: 'success', + timestamp: 1777642787000, + hash: '0xd206cc6c16974409bae072ce4cd1559743041af40c2bae84775a0bbb4dff5fee', + data: { + from: subjectAddress, + methodId: '0xe9ae5c53', + to: subjectAddress, + transactionCategory: 'CONTRACT_CALL', + transactionProtocol: undefined, + token: { + amount: '580060', + assetId: formatAddressToAssetId(mainnetUsdc, 'eip155:1'), + decimals: 6, + direction: 'out', + symbol: 'USDC', + }, + }, + }); + }); + + it('maps a contract call CONTRACT_CALL swap (differing symbols) to a Swap activity', () => { + const item = mapApiTransaction( + apiTransactionFixtures.mapArgs.mapsAContractCallContractCall, + ); + + expect(item).toMatchObject({ + type: 'swap', + data: { + sourceToken: { direction: 'out', symbol: 'USDC' }, + destinationToken: { direction: 'in', symbol: 'DAI' }, + }, + }); + }); + + it('maps a failed transaction to a failed activity item', () => { + const item = mapApiTransaction( + apiTransactionFixtures.mapArgs.mapsAFailedTransactionToA, + ); + + expect(item.status).toBe('failed'); + }); + + it('maps a Standard transaction on a chain outside the swaps registry without throwing', () => { + expect(() => + mapApiTransaction( + apiTransactionFixtures.mapArgs.mapsAStandardTransactionOnA, + ), + ).not.toThrow(); + + const item = mapApiTransaction( + apiTransactionFixtures.mapArgs.mapsAStandardTransactionOnA, + ); + + expect(item.type).toBe('send'); + expect(item.chainId).toBe('eip155:4657'); + }); + + it('maps an APPROVE with only an inbound transfer (revoke) to an inbound spending cap', () => { + const item = mapApiTransaction( + apiTransactionFixtures.mapArgs.mapsAnApproveWithOnlyAn, + ); + + expect(item).toMatchObject({ + type: 'approveSpendingCap', + data: { + token: { + direction: 'in', + assetId: formatAddressToAssetId(mainnetUsdc, 'eip155:1'), + }, + }, + }); + }); + + it('maps an APPROVE for a known token to a spending cap with token metadata', () => { + const mainnetUsdt = '0xdac17f958d2ee523a2206206994597c13d831ec7'; + const item = mapApiTransaction( + apiTransactionFixtures.mapArgs.mapsAnApproveForAKnown, + ); + + expect(item).toMatchObject({ + type: 'approveSpendingCap', + data: { + token: { + direction: 'out', + symbol: 'USDT', + decimals: 6, + assetId: formatAddressToAssetId(mainnetUsdt, 'eip155:1'), + }, + }, + }); + }); + + it('does not map a withdraw without a known method id to a lending withdrawal', () => { + const { transaction, subjectAddress: fixtureSubjectAddress } = + apiTransactionFixtures.mapArgs.mapsAnAaveWithdrawWithA; + const item = mapApiTransaction({ + subjectAddress: fixtureSubjectAddress, + transaction: { + ...transaction, + methodId: undefined, + }, + }); + + expect(item.type).not.toBe('lendingWithdrawal'); + }); + + it('does not map a deposit without a wrap method id to a wrap activity', () => { + const { transaction, subjectAddress: fixtureSubjectAddress } = + apiTransactionFixtures.mapArgs.mapsAWethDepositToA; + const item = mapApiTransaction({ + subjectAddress: fixtureSubjectAddress, + transaction: { + ...transaction, + methodId: undefined, + }, + }); + + expect(item.type).not.toBe('wrap'); + }); + + it('does not map a wrap when `to` is not the chain wrapped-native contract', () => { + const { transaction, subjectAddress: fixtureSubjectAddress } = + apiTransactionFixtures.mapArgs.mapsAWethDepositToA; + const item = mapApiTransaction({ + subjectAddress: fixtureSubjectAddress, + transaction: { + ...transaction, + to: '0x1111111111111111111111111111111111111111', + }, + }); + + expect(item.type).not.toBe('wrap'); + }); + + it('does not map a wrap on a chain without a known wrapped-native contract', () => { + const { transaction, subjectAddress: fixtureSubjectAddress } = + apiTransactionFixtures.mapArgs.mapsAWethDepositToA; + const item = mapApiTransaction({ + subjectAddress: fixtureSubjectAddress, + transaction: { + ...transaction, + chainId: 999999, + }, + }); + + expect(item.type).not.toBe('wrap'); + }); + + it('maps an unrecognized category with only an inbound transfer to a contract interaction with an inbound token', () => { + const item = mapApiTransaction( + apiTransactionFixtures.mapArgs.mapsAnUnrecognizedCategoryWithOnly, + ); + + expect(item).toMatchObject({ + type: 'contractInteraction', + data: { + token: { + direction: 'in', + amount: '12345', + symbol: 'USDC', + }, + }, + }); + }); + + it('maps a zero-value STANDARD send with empty valueTransfers to a native send with native fees', () => { + const item = mapApiTransaction( + apiTransactionFixtures.mapArgs.mapsAZeroValueStandardSendWithoutTransfers, + ); + + expect(item).toMatchObject({ + type: 'send', + chainId: 'eip155:1', + status: 'success', + hash: '0x062497f6874582f5d14e65510606a849d6fe8d0ea468c12907452e235c6b5201', + data: { + from: subjectAddress, + to: apiTransactionFixtures.addresses.zeroValueSendRecipient, + token: { + direction: 'out', + amount: '0', + decimals: 18, + symbol: 'ETH', + assetType: 'native', + assetId: 'eip155:1/slip44:60', + }, + fees: [ + { + type: 'base', + amount: '45127371870000', + decimals: 18, + assetType: 'native', + symbol: 'ETH', + assetId: 'eip155:1/slip44:60', + }, + ], + }, + }); + }); + + it('maps a STANDARD receive with empty valueTransfers to a native receive', () => { + const item = mapApiTransaction( + apiTransactionFixtures.mapArgs.mapsAStandardInboundWithoutTransfers, + ); + + expect(item).toMatchObject({ + type: 'receive', + chainId: 'eip155:1', + data: { + to: subjectAddress, + token: { + direction: 'in', + amount: '1000000000000000000', + decimals: 18, + symbol: 'ETH', + assetType: 'native', + assetId: 'eip155:1/slip44:60', + }, + }, + }); + }); + + it('maps an Across USDT exchange with no native valueTransfers to a swap with native fees', () => { + const item = mapApiTransaction( + apiTransactionFixtures.mapArgs.mapsAnAcrossUsdtExchangeWithNativeFee, + ); + + expect(item).toMatchObject({ + type: 'swap', + chainId: 'eip155:42161', + status: 'success', + hash: '0x34bbaa01262f2e9221913316f4548a4b5981e05ee338ea586fc267a6868f9526', + data: { + sourceToken: { + direction: 'out', + amount: '1199957', + decimals: 6, + symbol: 'USDT', + assetId: formatAddressToAssetId( + apiTransactionFixtures.addresses.arbitrumUsdt, + 'eip155:42161', + ), + assetType: 'erc20', + }, + fees: [ + { + type: 'base', + amount: '8570086182000', + decimals: 18, + assetType: 'native', + symbol: 'ETH', + assetId: 'eip155:42161/slip44:60', + }, + ], + }, + }); + }); + + it('maps a TRANSFER with empty valueTransfers without synthesizing a native token', () => { + const item = mapApiTransaction({ + subjectAddress, + transaction: { + hash: '0xtransferwithouttransfers', + chainId: 1, + timestamp: '2026-07-29T22:19:25.000Z', + isError: false, + transactionCategory: 'TRANSFER', + from: subjectAddress, + to: baseRecipientAddress, + value: '0', + valueTransfers: [], + gasUsed: 21000, + effectiveGasPrice: '1', + }, + }); + + expect(item).toMatchObject({ + type: 'send', + data: { + token: undefined, + }, + }); + }); + + it('keeps an ERC-20 transfer without assetId when tx.to cannot be encoded', () => { + const item = mapApiTransaction({ + subjectAddress, + transaction: { + hash: '0xerc20withoutassetid', + chainId: 1, + timestamp: '2026-07-29T22:19:25.000Z', + isError: false, + transactionCategory: 'TRANSFER', + from: subjectAddress, + to: zeroAddress, + value: '0', + valueTransfers: [ + { + from: subjectAddress, + to: baseRecipientAddress, + transferType: 'erc20', + symbol: 'USDC', + amount: '1', + decimal: 6, + }, + ], + gasUsed: 21000, + effectiveGasPrice: '1', + }, + }); + + expect(item).toMatchObject({ + type: 'send', + data: { + token: { + direction: 'out', + symbol: 'USDC', + amount: '1', + assetType: 'erc20', + }, + }, + }); + expect(item).toMatchObject({ + data: { + token: expect.not.objectContaining({ assetId: expect.anything() }), + }, + }); + }); + + it('classifies a receive when tx.to is the subject even if a sent transfer exists', () => { + const item = mapApiTransaction({ + subjectAddress, + transaction: { + hash: '0xreceivethroughto', + chainId: 1, + timestamp: '2026-07-29T22:19:25.000Z', + isError: false, + transactionCategory: 'TRANSFER', + from: lineaSenderAddress, + to: subjectAddress, + value: '0', + valueTransfers: [ + { + from: subjectAddress, + to: baseRecipientAddress, + transferType: 'erc20', + symbol: 'USDC', + amount: '1', + decimal: 6, + contractAddress: mainnetUsdc, + }, + { + from: lineaSenderAddress, + to: subjectAddress, + transferType: 'erc20', + symbol: 'USDT', + amount: '2', + decimal: 6, + contractAddress: mainnetUsdc, + }, + ], + gasUsed: 21000, + effectiveGasPrice: '1', + }, + }); + + expect(item.type).toBe('receive'); + }); +}); diff --git a/packages/client-utils/src/mappers/api-transaction-mapper.ts b/packages/client-utils/src/mappers/api-transaction-mapper.ts new file mode 100644 index 00000000000..7169eb363e5 --- /dev/null +++ b/packages/client-utils/src/mappers/api-transaction-mapper.ts @@ -0,0 +1,412 @@ +import { + isEqualCaseInsensitive as equalsIgnoreCase, + isValidHexAddress, +} from '@metamask/controller-utils'; +import type { V1TransactionByHashResponse } from '@metamask/core-backend'; +import { KnownCaipNamespace, toCaipChainId } from '@metamask/utils'; + +import type { + ActivityItem, + Status, + TokenAmount, + ValueTransfer, +} from '../types.js'; +import { + nativeTokenAddress, + supplyMethodIds, + swapsWrappedTokensAddresses, + withdrawMethodIds, + wrapMethodIds, +} from './constants.js'; +import { formatAddressToAssetId, getNativeAsset } from './helpers/caip.js'; +import { + getFees, + getNftPaymentTransfer, + getTokenAmountFromTransfer, + getTokenMetadataFromKnownToken, + parseValueTransfers, +} from './helpers/transactions.js'; + +/** + * Maps an indexed API transaction into the shared activity item shape. + * + * @param options - The mapping options. + * @param options.transaction - The indexed API transaction to map. + * @param options.subjectAddress - The account the activity is being mapped for. + * @returns The normalized activity item. + */ +export function mapApiTransaction({ + transaction, + subjectAddress, +}: { + transaction: V1TransactionByHashResponse; + subjectAddress: string; +}): ActivityItem { + const { hash, transactionCategory, valueTransfers, from, methodId } = + transaction; + const normalizedMethodId = methodId?.toLowerCase() ?? ''; + const status: Status = transaction.isError ? 'failed' : 'success'; + const timestamp = new Date(transaction.timestamp).getTime(); + const chainId = toCaipChainId( + KnownCaipNamespace.Eip155, + transaction.chainId.toString(), + ); + const getToken = ( + transfer: ValueTransfer | undefined, + direction: TokenAmount['direction'], + ): TokenAmount | undefined => + getTokenAmountFromTransfer(transfer, direction, chainId); + + const { + sentTransfer, + receivedTransfer, + sentNativeTransfer, + sentNftTransfer, + receivedNftTransfer, + } = parseValueTransfers(valueTransfers, subjectAddress); + + const common = { chainId, status, timestamp, hash }; + + if (transactionCategory === 'SWAP' || transactionCategory === 'EXCHANGE') { + return { + type: 'swap', + ...common, + data: { + sourceToken: getToken(sentTransfer, 'out'), + destinationToken: getToken(receivedTransfer, 'in'), + fees: getFees(transaction), + from, + }, + }; + } + + if (transactionCategory === 'APPROVE') { + // Note: Categorize REVOKE in the backend + const direction = receivedTransfer && !sentTransfer ? 'in' : 'out'; + const valueTransferContractAddress = valueTransfers?.find( + ({ contractAddress, transferType }) => + contractAddress && + transferType !== 'normal' && + transferType !== 'internal', + )?.contractAddress; + const contractAddress = + (isValidHexAddress(transaction.to, { allowNonPrefixed: false }) + ? transaction.to + : undefined) ?? + (valueTransferContractAddress && + isValidHexAddress(valueTransferContractAddress, { + allowNonPrefixed: false, + }) + ? valueTransferContractAddress + : undefined); + const assetId = contractAddress + ? formatAddressToAssetId(contractAddress, chainId) + : undefined; + const token = + getTokenMetadataFromKnownToken(contractAddress, direction, chainId) ?? + (assetId ? { direction, assetId } : undefined); + + return { + type: 'approveSpendingCap', + ...common, + data: { + from, + token, + fees: getFees(transaction), + }, + }; + } + + // Note: Categorize NFT in the backend + const isNftExchange = transactionCategory === 'NFT_EXCHANGE'; + + if (receivedNftTransfer) { + if (receivedNftTransfer.from === nativeTokenAddress) { + return { + type: 'nftMint', + ...common, + data: { + from: receivedNftTransfer.from, + to: receivedNftTransfer.to, + token: getToken(receivedNftTransfer, 'in'), + }, + }; + } + + const purchasePaymentTransfer = getNftPaymentTransfer({ + side: 'buy', + sentTransfer, + sentNativeTransfer, + nftCounterparty: receivedNftTransfer.from, + transactionTo: transaction.to, + subjectAddress, + }); + + if (isNftExchange || purchasePaymentTransfer) { + return { + type: 'nftBuy', + ...common, + data: { + from: receivedNftTransfer.from, + to: receivedNftTransfer.to, + token: getToken(receivedNftTransfer, 'in'), + paymentToken: getToken(purchasePaymentTransfer, 'out'), + }, + }; + } + + return { + type: 'receive', + ...common, + data: { + from: receivedNftTransfer.from, + to: receivedNftTransfer.to, + token: getToken(receivedNftTransfer, 'in'), + }, + }; + } + + if (sentNftTransfer) { + const saleProceedsTransfer = getNftPaymentTransfer({ + side: 'sell', + receivedTransfer, + nftCounterparty: sentNftTransfer.to, + transactionFrom: from, + subjectAddress, + }); + + if (isNftExchange || saleProceedsTransfer) { + return { + type: 'nftSell', + ...common, + data: { + from: sentNftTransfer.from, + to: sentNftTransfer.to, + token: getToken(sentNftTransfer, 'out'), + paymentToken: getToken(saleProceedsTransfer, 'in'), + }, + }; + } + + return { + type: 'send', + ...common, + data: { + from: sentNftTransfer.from, + to: sentNftTransfer.to, + token: getToken(sentNftTransfer, 'out'), + }, + }; + } + + const hasNativeTransferWithoutMethod = + transactionCategory === 'CONTRACT_CALL' && + !methodId && + valueTransfers?.some(({ transferType }) => transferType === 'normal'); + + if ( + transactionCategory === 'TRANSFER' || + transactionCategory === 'STANDARD' || + hasNativeTransferWithoutMethod + ) { + const isReceive = + Boolean(receivedTransfer && !sentTransfer) || + (equalsIgnoreCase(transaction.to, subjectAddress) && + !equalsIgnoreCase(from, subjectAddress)); + + const transfer = isReceive ? receivedTransfer : sentTransfer; + const direction: TokenAmount['direction'] = isReceive ? 'in' : 'out'; + let token = getToken(transfer, direction); + + if (!token) { + // Zero-value sends can omit valueTransfers + if (transactionCategory === 'STANDARD') { + const nativeAsset = getNativeAsset(chainId); + if (nativeAsset) { + token = { + symbol: nativeAsset.symbol, + decimals: nativeAsset.decimals, + assetId: nativeAsset.assetId, + amount: transaction.value, + direction, + assetType: 'native', + }; + } + } + } else if ( + !token.assetId && + transfer?.transferType !== 'normal' && + transfer?.transferType !== 'internal' + ) { + // ERC-20 transfer missing contractAddress — fall back to tx.to. + const assetId = formatAddressToAssetId(transaction.to, chainId); + if (assetId) { + token = { ...token, assetId }; + } + } + + return { + type: isReceive ? 'receive' : 'send', + ...common, + data: { + from: transfer?.from ?? from, + to: transfer?.to ?? transaction.to, + token, + fees: getFees(transaction), + }, + }; + } + + if (transactionCategory === 'CLAIM_BONUS') { + return { + type: 'claimMusdBonus', + ...common, + data: { + from, + token: getToken(receivedTransfer, 'in'), + }, + }; + } + + if (transactionCategory === 'CLAIM') { + return { + type: 'claim', + ...common, + data: { + from, + token: getToken( + receivedTransfer ?? sentTransfer, + receivedTransfer ? 'in' : 'out', + ), + }, + }; + } + + if (transactionCategory === 'BRIDGE_WITHDRAW') { + return { + type: 'bridge', + ...common, + data: { + from, + sourceToken: getToken(sentTransfer, 'out'), + fees: getFees(transaction), + }, + }; + } + + if ( + transactionCategory === 'WITHDRAW' && + withdrawMethodIds.has(normalizedMethodId) + ) { + return { + type: 'lendingWithdrawal', + ...common, + data: { + from, + sourceToken: getToken(sentTransfer, 'out'), + destinationToken: getToken(receivedTransfer, 'in'), + fees: getFees(transaction), + }, + }; + } + + // Note: Categorize Deposit/Stake in the backend + if (sentTransfer && supplyMethodIds.has(normalizedMethodId)) { + return { + type: 'lendingDeposit', + ...common, + data: { + from, + sourceToken: getToken(sentTransfer, 'out'), + destinationToken: getToken(receivedTransfer, 'in'), + fees: getFees(transaction), + }, + }; + } + + const wrappedNativeAddress = + swapsWrappedTokensAddresses[ + `0x${transaction.chainId.toString( + 16, + )}` as keyof typeof swapsWrappedTokensAddresses + ]; + + if ( + receivedTransfer && + wrapMethodIds.has(normalizedMethodId) && + wrappedNativeAddress && + equalsIgnoreCase(transaction.to, wrappedNativeAddress) + ) { + return { + type: 'wrap', + ...common, + data: { + from, + sourceToken: getToken(sentTransfer, 'out'), + destinationToken: getToken(receivedTransfer, 'in'), + fees: getFees(transaction), + }, + }; + } + + if (transactionCategory === 'UNWRAP') { + return { + type: 'unwrap', + ...common, + data: { + from, + sourceToken: getToken(sentTransfer, 'out'), + destinationToken: getToken(receivedTransfer, 'in'), + fees: getFees(transaction), + }, + }; + } + + // Note: Categorize these Swaps in the backend + if ( + transactionCategory === 'CONTRACT_CALL' && + sentTransfer?.symbol && + receivedTransfer?.symbol && + sentTransfer.symbol !== receivedTransfer.symbol + ) { + return { + type: 'swap', + ...common, + data: { + from, + sourceToken: getToken(sentTransfer, 'out'), + destinationToken: getToken(receivedTransfer, 'in'), + fees: getFees(transaction), + }, + }; + } + + if (transactionCategory === 'DEPOSIT') { + return { + type: 'deposit', + ...common, + data: { + from, + token: getToken(sentTransfer, 'out'), + }, + }; + } + + const token = getToken( + sentTransfer ?? receivedTransfer, + sentTransfer ? 'out' : 'in', + ); + + return { + type: 'contractInteraction', + ...common, + data: { + methodId, + from, + to: transaction.to, + transactionCategory, + transactionProtocol: transaction.transactionProtocol, + ...(token ? { token } : {}), + }, + }; +} diff --git a/packages/client-utils/src/mappers/constants.ts b/packages/client-utils/src/mappers/constants.ts new file mode 100644 index 00000000000..fa8e7409fe8 --- /dev/null +++ b/packages/client-utils/src/mappers/constants.ts @@ -0,0 +1,53 @@ +// Known method IDs for supply/deposit calls +const aaveSupplyMethodId = '0x617ba037'; +const lidoSubmitMethodId = '0xa1903eab'; +const lidoDepositMethodId = '0x8a99b4f2'; // MM staking contract Lido deposit +const rocketPoolDepositMethodId = '0xfa4bbb71'; // MM staking contract RP deposit + +export const supplyMethodIds = new Set([ + aaveSupplyMethodId, + lidoSubmitMethodId, + lidoDepositMethodId, + rocketPoolDepositMethodId, +]); + +// Known method IDs for withdraw calls +const aaveWithdrawMethodId = '0x69328dec'; +const lidoClaimWithdrawMethodId = '0xf8444436'; +const rocketPoolBurnMethodId = '0x42966c68'; + +export const withdrawMethodIds = new Set([ + aaveWithdrawMethodId, + lidoClaimWithdrawMethodId, + rocketPoolBurnMethodId, +]); + +export const wrapMethodIds = new Set(['0xd0e30db0']); +export const unwrapMethodIds = new Set(['0x2e1a7d4d']); + +export const permit2ApproveMethodId = '0x87517c45'; + +export const tokenTransferLogTopicHash = + '0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef'; + +export const nativeTokenAddress = '0x0000000000000000000000000000000000000000'; + +export const nativeTokenDecimals = 18; + +export const swapsWrappedTokensAddresses = { + '0x1': '0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2', + '0x539': '0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2', + '0x38': '0xbb4cdb9cbd36b01bd1cbaebf2de08d9173bc095c', + '0x89': '0x0d500b1d8e8ef31e21c99d1db9a6444d3adf1270', + '0x5': '0xB4FBF271143F4FBf7B91A5ded31805e42b2208d6', + '0xa86a': '0xb31f66aa3c1e785363f0875a1b74e27b85fd66c7', + '0xa': '0x4200000000000000000000000000000000000006', + '0xa4b1': '0x82af49447d8a07e3bd95bd0d56f35241523fbab1', + '0x144': '0x5AEa5775959fBC2557Cc8789bC1bf90A239D9a91', + '0xe708': '0xe5D7C2a44FfDDf6b295A15c148167daaAf5Cf34f', + '0x2105': '0x4200000000000000000000000000000000000006', + '0x531': '0xe30fedd158a2e3b13e9badaeabafc5516e95e8c7', + '0x8f': '0x3bd359c1119da7da1d913d1c4d2b7c461115433a', + '0x3e7': '0x5555555555555555555555555555555555555555', + '0x10e6': '0x4200000000000000000000000000000000000006', +} as const; diff --git a/packages/client-utils/src/mappers/helpers/caip.test.ts b/packages/client-utils/src/mappers/helpers/caip.test.ts new file mode 100644 index 00000000000..500d41c3e45 --- /dev/null +++ b/packages/client-utils/src/mappers/helpers/caip.test.ts @@ -0,0 +1,272 @@ +import { getChainById } from 'eth-chainlist'; + +import { + formatAddressToAssetId, + formatChainIdToCaip, + getNativeAsset, + resolveNativeAssetId, +} from './caip.js'; + +jest.mock('eth-chainlist', () => ({ + getChainById: jest.fn(), +})); + +const mockGetChainById = jest.mocked(getChainById); + +describe('caip helpers', () => { + beforeEach(() => { + mockGetChainById.mockReset(); + }); + + describe('formatChainIdToCaip', () => { + it('formats numeric chain ids', () => { + expect(formatChainIdToCaip(1)).toBe('eip155:1'); + }); + + it('returns caip chain ids unchanged', () => { + expect(formatChainIdToCaip('eip155:8453')).toBe('eip155:8453'); + }); + + it('formats hex chain ids', () => { + expect(formatChainIdToCaip('0x1')).toBe('eip155:1'); + }); + + it('returns undefined for invalid hex chain ids', () => { + expect(formatChainIdToCaip('0xzzzz')).toBeUndefined(); + }); + + it('formats decimal string chain ids', () => { + expect(formatChainIdToCaip('8453')).toBe('eip155:8453'); + }); + + it('returns undefined for invalid decimal chain ids', () => { + expect(formatChainIdToCaip('not-a-number')).toBeUndefined(); + }); + + it('returns undefined for an empty chain id instead of eip155:0', () => { + expect(formatChainIdToCaip('')).toBeUndefined(); + }); + }); + + describe('formatAddressToAssetId', () => { + it('returns caip asset ids unchanged', () => { + expect( + formatAddressToAssetId( + 'eip155:1/erc20:0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48', + ), + ).toBe('eip155:1/erc20:0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48'); + }); + + it('encodes erc20 contract addresses', () => { + expect( + formatAddressToAssetId( + '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48', + 'eip155:1', + ), + ).toBe('eip155:1/erc20:0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48'); + }); + + it('returns undefined for native sentinel addresses instead of erc20:0x0', () => { + expect( + formatAddressToAssetId( + '0x0000000000000000000000000000000000000000', + 'eip155:1', + ), + ).toBeUndefined(); + expect(formatAddressToAssetId('0x0', 'eip155:4663')).toBeUndefined(); + }); + + it('returns undefined when chain id is omitted', () => { + expect( + formatAddressToAssetId('0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48'), + ).toBeUndefined(); + }); + + it('returns undefined for invalid addresses', () => { + expect( + formatAddressToAssetId('not-an-address', 'eip155:1'), + ).toBeUndefined(); + }); + + it('returns undefined when the chain id cannot be normalized', () => { + expect( + formatAddressToAssetId( + '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48', + '0xzzzz', + ), + ).toBeUndefined(); + }); + }); + + describe('resolveNativeAssetId', () => { + it('resolves ETH via slip44', () => { + expect(resolveNativeAssetId('eip155:1', 'ETH')).toBe( + 'eip155:1/slip44:60', + ); + }); + + it('resolves POL via the MATIC slip44 entry', () => { + expect(resolveNativeAssetId('eip155:137', 'POL')).toBe( + 'eip155:137/slip44:966', + ); + }); + + it('resolves when symbol is missing but the chain has slip44', () => { + mockGetChainById.mockReturnValue({ + slip44: 60, + nativeCurrency: { name: 'Ether', symbol: 'ETH', decimals: 18 }, + } as ReturnType); + + expect(resolveNativeAssetId('eip155:8453', undefined)).toBe( + 'eip155:8453/slip44:60', + ); + }); + + it('falls back to the zero-address erc20 form when symbol and chainlist both miss slip44', () => { + mockGetChainById.mockReturnValue({ + nativeCurrency: { name: 'Chiliz', symbol: 'CHZ', decimals: 18 }, + } as ReturnType); + + expect(resolveNativeAssetId('eip155:88888', 'CHZ')).toBe( + 'eip155:88888/erc20:0x0000000000000000000000000000000000000000', + ); + expect(resolveNativeAssetId('eip155:88888', undefined)).toBe( + 'eip155:88888/erc20:0x0000000000000000000000000000000000000000', + ); + }); + + it('falls back to the zero-address erc20 form when the chain is absent from chainlist', () => { + mockGetChainById.mockReturnValue(undefined); + + expect(resolveNativeAssetId('eip155:4663', undefined)).toBe( + 'eip155:4663/erc20:0x0000000000000000000000000000000000000000', + ); + }); + + it('returns undefined for non-eip155 chains without a slip44 hit', () => { + expect( + resolveNativeAssetId('solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp', 'NOPE'), + ).toBeUndefined(); + expect( + resolveNativeAssetId( + 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp', + undefined, + ), + ).toBeUndefined(); + }); + + it('returns undefined when the chain id cannot be normalized', () => { + expect(resolveNativeAssetId('0xzzzz', 'ETH')).toBeUndefined(); + }); + + it('returns undefined when chain id is missing', () => { + expect(resolveNativeAssetId(undefined, 'ETH')).toBeUndefined(); + expect(resolveNativeAssetId(undefined, undefined)).toBeUndefined(); + }); + }); + + describe('getNativeAsset', () => { + it('resolves native asset from chainlist slip44', () => { + mockGetChainById.mockReturnValue({ + slip44: 60, + nativeCurrency: { name: 'Ether', symbol: 'ETH', decimals: 18 }, + } as ReturnType); + + expect(getNativeAsset('eip155:1')).toStrictEqual({ + symbol: 'ETH', + decimals: 18, + assetId: 'eip155:1/slip44:60', + }); + }); + + it('falls back to symbol lookup when chainlist omits slip44', () => { + mockGetChainById.mockReturnValue({ + nativeCurrency: { name: 'Ether', symbol: 'ETH', decimals: 18 }, + } as ReturnType); + + expect(getNativeAsset('eip155:42161')).toStrictEqual({ + symbol: 'ETH', + decimals: 18, + assetId: 'eip155:42161/slip44:60', + }); + }); + + it('ignores chainlist testnet slip44:1 and uses the native symbol coin type', () => { + mockGetChainById.mockReturnValue({ + slip44: 1, + nativeCurrency: { name: 'Ether', symbol: 'ETH', decimals: 18 }, + } as ReturnType); + + expect(getNativeAsset('eip155:11155111')).toStrictEqual({ + symbol: 'ETH', + decimals: 18, + assetId: 'eip155:11155111/slip44:60', + }); + }); + + it('prefers chainlist slip44 over the slip44 registry symbol mapping', () => { + mockGetChainById.mockReturnValue({ + slip44: 9005, + nativeCurrency: { name: 'Avalanche', symbol: 'AVAX', decimals: 18 }, + } as ReturnType); + + expect(getNativeAsset('eip155:43114')).toStrictEqual({ + symbol: 'AVAX', + decimals: 18, + assetId: 'eip155:43114/slip44:9005', + }); + }); + + it('returns undefined when the chain is unknown', () => { + mockGetChainById.mockReturnValue(undefined); + + expect(getNativeAsset('eip155:999999991')).toBeUndefined(); + }); + + it('returns undefined for non-eip155 chain ids', () => { + expect( + getNativeAsset('solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp'), + ).toBeUndefined(); + expect(mockGetChainById).not.toHaveBeenCalled(); + }); + + it('returns undefined when chainlist omits native currency symbol', () => { + mockGetChainById.mockReturnValue({ + slip44: 60, + nativeCurrency: { name: 'Ether', decimals: 18 }, + } as ReturnType); + + expect(getNativeAsset('eip155:1')).toBeUndefined(); + }); + + it('defaults decimals when chainlist omits native currency decimals', () => { + mockGetChainById.mockReturnValue({ + slip44: 60, + nativeCurrency: { name: 'Ether', symbol: 'ETH' }, + } as ReturnType); + + expect(getNativeAsset('eip155:1')).toStrictEqual({ + symbol: 'ETH', + decimals: 18, + assetId: 'eip155:1/slip44:60', + }); + }); + + it('falls back to the zero-address erc20 assetId when slip44 and symbol lookup both fail', () => { + mockGetChainById.mockReturnValue({ + nativeCurrency: { + name: 'Chiliz', + symbol: 'CHZ', + decimals: 18, + }, + } as ReturnType); + + expect(getNativeAsset('eip155:88888')).toStrictEqual({ + symbol: 'CHZ', + decimals: 18, + assetId: + 'eip155:88888/erc20:0x0000000000000000000000000000000000000000', + }); + }); + }); +}); diff --git a/packages/client-utils/src/mappers/helpers/caip.ts b/packages/client-utils/src/mappers/helpers/caip.ts new file mode 100644 index 00000000000..982ab51b91f --- /dev/null +++ b/packages/client-utils/src/mappers/helpers/caip.ts @@ -0,0 +1,192 @@ +import { toChecksumHexAddress } from '@metamask/controller-utils'; +// @ts-expect-error: No type definitions for '@metamask/slip44' +import slip44data from '@metamask/slip44'; +import type { CaipAssetType, CaipChainId, Hex } from '@metamask/utils'; +import { + isCaipAssetType, + isStrictHexString, + KnownCaipNamespace, + parseCaipChainId, + toCaipAssetType, +} from '@metamask/utils'; +import { getChainById } from 'eth-chainlist'; + +import { nativeTokenAddress, nativeTokenDecimals } from '../constants.js'; + +const slip44BySymbol = ((): Map => { + const coinTypeBySymbol = new Map(); + + for (const [coinType, entry] of Object.entries( + slip44data as Record, + )) { + const normalizedSymbol = entry.symbol.toUpperCase(); + + if (!coinTypeBySymbol.has(normalizedSymbol)) { + coinTypeBySymbol.set(normalizedSymbol, coinType); + } + } + + return coinTypeBySymbol; +})(); + +function getCoinType(symbol: string): string | undefined { + const normalizedSymbol = symbol.toUpperCase(); + return ( + slip44BySymbol.get(normalizedSymbol) ?? + (normalizedSymbol === 'POL' ? slip44BySymbol.get('MATIC') : undefined) + ); +} + +/** + * Normalizes a hex, decimal, numeric, or CAIP chain id to its CAIP-2 form. + * Only EVM (eip155) chains are normalized here; CAIP ids are returned as-is. + * + * @param chainId - Hex (`0x1`), numeric, decimal string, or CAIP chain id. + * @returns The CAIP-2 chain id, or `undefined` when it can't be normalized. + */ +export function formatChainIdToCaip( + chainId: string | number, +): CaipChainId | undefined { + if (typeof chainId === 'number') { + return `eip155:${chainId}`; + } + + if (chainId.includes(':')) { + return chainId as CaipChainId; + } + + if (chainId.startsWith('0x')) { + const reference = Number.parseInt(chainId, 16); + return Number.isNaN(reference) ? undefined : `eip155:${reference}`; + } + + if (chainId === '') { + return undefined; + } + + const reference = Number(chainId); + return Number.isNaN(reference) ? undefined : `eip155:${reference}`; +} + +export function resolveNativeAssetId( + chainId: string | number | undefined, + symbol: string | undefined, +): CaipAssetType | undefined { + if (chainId === undefined) { + return undefined; + } + + const caipChainId = formatChainIdToCaip(chainId); + + if (!caipChainId) { + return undefined; + } + + const { namespace, reference } = parseCaipChainId(caipChainId); + const assetReference = symbol ? getCoinType(symbol) : undefined; + + if (assetReference) { + return toCaipAssetType(namespace, reference, 'slip44', assetReference); + } + + if (namespace === KnownCaipNamespace.Eip155) { + return ( + getNativeAsset(caipChainId)?.assetId ?? + toCaipAssetType(namespace, reference, 'erc20', nativeTokenAddress) + ); + } + + return undefined; +} + +/** + * Resolves EVM native symbol, decimals, and CAIP asset id for a chain. + * Prefers eth-chainlist slip44 except testnet coin type 1, then falls back to + * `@metamask/slip44` by native symbol. + * + * @param chainId - CAIP-2 chain id (eip155 only). + * @returns Native asset metadata, or undefined when it cannot be resolved. + */ +export function getNativeAsset(chainId: CaipChainId): + | { + symbol: string; + decimals: number; + assetId: CaipAssetType; + } + | undefined { + const { namespace, reference } = parseCaipChainId(chainId); + if (namespace !== KnownCaipNamespace.Eip155) { + return undefined; + } + + const chain = getChainById(Number(reference)); + if (!chain) { + return undefined; + } + + const { nativeCurrency, slip44 } = chain; + if (!nativeCurrency?.symbol) { + return undefined; + } + + const slip44TestnetCoinType = 1; + const assetReference = + typeof slip44 === 'number' && slip44 !== slip44TestnetCoinType + ? String(slip44) + : getCoinType(nativeCurrency.symbol); + + const assetId = assetReference + ? toCaipAssetType(namespace, reference, 'slip44', assetReference) + : toCaipAssetType(namespace, reference, 'erc20', nativeTokenAddress); + + return { + symbol: nativeCurrency.symbol, + decimals: nativeCurrency.decimals ?? nativeTokenDecimals, + assetId, + }; +} + +function isNativeAddress(address: string): boolean { + const normalized = address.toLowerCase(); + return ( + normalized === nativeTokenAddress || + normalized === '0x0' || + /^0x0+$/u.test(normalized) + ); +} + +/** + * Encodes an EVM token address + chain id into a CAIP-19 asset id. + * + * @param address - Hex contract address, native sentinel, or CAIP asset id. + * @param chainId - CAIP-2 or hex chain id. + * @returns The CAIP-19 asset id, or `undefined` when it can't be encoded. + */ +export function formatAddressToAssetId( + address: Hex | CaipAssetType | string, + chainId?: CaipChainId | Hex, +): CaipAssetType | undefined { + if (isCaipAssetType(address)) { + return address; + } + + const caipChainId = chainId ? formatChainIdToCaip(chainId) : undefined; + + if (!caipChainId) { + return undefined; + } + + if (isNativeAddress(address)) { + return undefined; + } + + const checksummedAddress = toChecksumHexAddress(address); + + if (!isStrictHexString(checksummedAddress)) { + return undefined; + } + + const { namespace, reference } = parseCaipChainId(caipChainId); + + return toCaipAssetType(namespace, reference, 'erc20', checksummedAddress); +} diff --git a/packages/client-utils/src/mappers/helpers/token-metadata.test.ts b/packages/client-utils/src/mappers/helpers/token-metadata.test.ts new file mode 100644 index 00000000000..f0d6cfe6989 --- /dev/null +++ b/packages/client-utils/src/mappers/helpers/token-metadata.test.ts @@ -0,0 +1,38 @@ +import { getKnownTokenMetadata } from './token-metadata.js'; + +describe('getKnownTokenMetadata', () => { + it('returns undefined when the contract address is missing', () => { + expect(getKnownTokenMetadata('eip155:1')).toBeUndefined(); + }); + + it('returns undefined for non-mainnet chains', () => { + expect( + getKnownTokenMetadata( + 'eip155:8453', + '0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913', + ), + ).toBeUndefined(); + }); + + it('returns undefined for unknown mainnet tokens', () => { + expect( + getKnownTokenMetadata( + 'eip155:1', + '0x1111111111111111111111111111111111111111', + ), + ).toBeUndefined(); + }); + + it('returns metadata for a known mainnet token', () => { + expect( + getKnownTokenMetadata( + 'eip155:1', + '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48', + ), + ).toMatchObject({ + symbol: 'USDC', + decimals: 6, + assetId: 'eip155:1/erc20:0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48', + }); + }); +}); diff --git a/packages/client-utils/src/mappers/helpers/token-metadata.ts b/packages/client-utils/src/mappers/helpers/token-metadata.ts new file mode 100644 index 00000000000..1bf72cefaba --- /dev/null +++ b/packages/client-utils/src/mappers/helpers/token-metadata.ts @@ -0,0 +1,49 @@ +import contractMap from '@metamask/contract-metadata'; +import { toChecksumHexAddress } from '@metamask/controller-utils'; +import type { CaipChainId, Hex } from '@metamask/utils'; + +import { formatAddressToAssetId } from './caip.js'; + +export type KnownTokenMetadata = { + symbol?: string; + decimals?: number; + assetId?: string; +}; + +type ContractMetadataEntry = { + name?: string; + symbol?: string; + decimals?: number; + erc20?: boolean; +}; + +const mainnetTokens = contractMap as Record; + +const mainnetAssetIdPrefix = 'eip155:1/'; + +export function getKnownTokenMetadata( + chainId: CaipChainId | Hex, + contractAddress?: string, +): KnownTokenMetadata | undefined { + if (!contractAddress) { + return undefined; + } + + const assetId = formatAddressToAssetId(contractAddress, chainId); + + if (!assetId?.startsWith(mainnetAssetIdPrefix)) { + return undefined; + } + + const entry = mainnetTokens[toChecksumHexAddress(contractAddress)]; + + if (!entry) { + return undefined; + } + + return { + symbol: entry.symbol, + decimals: entry.decimals, + assetId, + }; +} diff --git a/packages/client-utils/src/mappers/helpers/transactions.test.ts b/packages/client-utils/src/mappers/helpers/transactions.test.ts new file mode 100644 index 00000000000..a060a5fde3d --- /dev/null +++ b/packages/client-utils/src/mappers/helpers/transactions.test.ts @@ -0,0 +1,530 @@ +import * as tokenMetadata from './token-metadata.js'; +import { + getFees, + getLocalTransactionFees, + getLocalTransactionStatus, + getNftPaymentTransfer, + getTokenAmountFromTransfer, + getTokenMetadataFromKnownToken, + isNftStandard, + parseValueTransfers, +} from './transactions.js'; + +describe('transaction helpers', () => { + afterEach(() => { + jest.restoreAllMocks(); + }); + + describe('isNftStandard', () => { + it('detects nft transfer types', () => { + expect(isNftStandard('erc721')).toBe(true); + expect(isNftStandard('erc1155')).toBe(true); + expect(isNftStandard('erc20')).toBe(false); + }); + }); + + describe('getNftPaymentTransfer', () => { + it('returns a buy-side native payment transfer', () => { + expect( + getNftPaymentTransfer({ + side: 'buy', + sentNativeTransfer: { + from: '0x0000000000000000000000000000000000000001', + to: '0x0000000000000000000000000000000000000002', + transferType: 'normal', + symbol: 'ETH', + amount: 1, + }, + nftCounterparty: '0x0000000000000000000000000000000000000002', + subjectAddress: '0x0000000000000000000000000000000000000001', + }), + ).toMatchObject({ symbol: 'ETH' }); + }); + + it('returns undefined for sell-side transfers that do not match the counterparty', () => { + expect( + getNftPaymentTransfer({ + side: 'sell', + receivedTransfer: { + from: '0x0000000000000000000000000000000000000001', + to: '0x0000000000000000000000000000000000000002', + transferType: 'erc20', + symbol: 'USDC', + amount: 1, + }, + nftCounterparty: '0x0000000000000000000000000000000000000003', + subjectAddress: '0x0000000000000000000000000000000000000004', + }), + ).toBeUndefined(); + }); + + it('returns sell-side payment when the sender matches the transaction from address', () => { + expect( + getNftPaymentTransfer({ + side: 'sell', + receivedTransfer: { + from: '0x0000000000000000000000000000000000000003', + to: '0x0000000000000000000000000000000000000004', + transferType: 'erc20', + symbol: 'USDC', + amount: 1, + }, + nftCounterparty: '0x0000000000000000000000000000000000000005', + transactionFrom: '0x0000000000000000000000000000000000000003', + subjectAddress: '0x0000000000000000000000000000000000000004', + }), + ).toMatchObject({ symbol: 'USDC' }); + }); + }); + + describe('parseValueTransfers', () => { + it('prefers a received transfer with a different symbol', () => { + const result = parseValueTransfers( + [ + { + from: '0x0000000000000000000000000000000000000001', + to: '0x0000000000000000000000000000000000000002', + symbol: 'ETH', + transferType: 'normal', + }, + { + from: '0x0000000000000000000000000000000000000002', + to: '0x0000000000000000000000000000000000000001', + symbol: 'USDC', + transferType: 'erc20', + }, + ], + '0x0000000000000000000000000000000000000001', + ); + + expect(result.receivedTransfer?.symbol).toBe('USDC'); + }); + }); + + describe('getTokenAmountFromTransfer', () => { + it('returns token metadata without a symbol when only the amount is present', () => { + expect( + getTokenAmountFromTransfer( + { + from: '0x1', + to: '0x2', + transferType: 'erc20', + amount: 1, + }, + 'out', + 'eip155:1', + ), + ).toMatchObject({ + direction: 'out', + amount: '1', + }); + }); + + it('returns token metadata with decimals when they are present', () => { + expect( + getTokenAmountFromTransfer( + { + from: '0x1', + to: '0x2', + transferType: 'erc20', + symbol: 'USDC', + amount: 1, + decimal: 6, + }, + 'out', + 'eip155:1', + ), + ).toStrictEqual({ + direction: 'out', + amount: '1', + symbol: 'USDC', + decimals: 6, + assetType: 'erc20', + }); + }); + + it('marks native transfers with type native and assetId', () => { + expect( + getTokenAmountFromTransfer( + { + from: '0x1', + to: '0x2', + transferType: 'normal', + symbol: 'POL', + amount: '1000000000000000000', + decimal: 18, + }, + 'out', + 'eip155:137', + ), + ).toStrictEqual({ + direction: 'out', + amount: '1000000000000000000', + symbol: 'POL', + decimals: 18, + assetType: 'native', + assetId: 'eip155:137/slip44:966', + }); + }); + + it('returns token metadata without decimals when they are omitted', () => { + expect( + getTokenAmountFromTransfer( + { + from: '0x1', + to: '0x2', + transferType: 'erc20', + symbol: 'USDC', + amount: 1, + }, + 'out', + 'eip155:1', + ), + ).toMatchObject({ + direction: 'out', + amount: '1', + symbol: 'USDC', + }); + }); + + it('returns undefined when the transfer has no symbol or amount', () => { + expect( + getTokenAmountFromTransfer( + { + from: '0x1', + to: '0x2', + transferType: 'erc20', + }, + 'out', + 'eip155:1', + ), + ).toBeUndefined(); + }); + }); + + describe('getTokenMetadataFromKnownToken', () => { + it('returns metadata without a symbol when it is missing', () => { + jest.spyOn(tokenMetadata, 'getKnownTokenMetadata').mockReturnValue({ + decimals: 18, + assetId: 'eip155:1/erc20:0x1111111111111111111111111111111111111111', + }); + + expect( + getTokenMetadataFromKnownToken( + '0x1111111111111111111111111111111111111111', + 'out', + 'eip155:1', + ), + ).toStrictEqual({ + direction: 'out', + decimals: 18, + assetId: 'eip155:1/erc20:0x1111111111111111111111111111111111111111', + assetType: 'erc20', + }); + }); + + it('returns partial metadata when some fields are missing', () => { + jest.spyOn(tokenMetadata, 'getKnownTokenMetadata').mockReturnValue({ + symbol: 'TKN', + }); + + expect( + getTokenMetadataFromKnownToken( + '0x1111111111111111111111111111111111111111', + 'out', + 'eip155:1', + ), + ).toMatchObject({ + direction: 'out', + symbol: 'TKN', + }); + }); + + it('returns undefined for unknown tokens', () => { + expect( + getTokenMetadataFromKnownToken( + '0x1111111111111111111111111111111111111111', + 'out', + 'eip155:1', + ), + ).toBeUndefined(); + }); + }); + + describe('getLocalTransactionFees', () => { + it('resolves fee assetId via ETH symbol when chainlist only has testnet slip44:1', () => { + // 0x539 = Geth Testnet (1337); chainlist slip44 is 1, which we skip. + expect( + getLocalTransactionFees({ + primaryTransaction: { + chainId: '0x539', + txParams: {}, + txReceipt: { + gasUsed: '0x1', + effectiveGasPrice: '0x2', + }, + }, + } as Parameters[0]), + ).toStrictEqual([ + { + type: 'base', + amount: '2', + decimals: 18, + assetType: 'native', + symbol: 'ETH', + assetId: 'eip155:1337/slip44:60', + }, + ]); + }); + + it('returns native fee assetId when nativeAssetSymbol is on the group', () => { + expect( + getLocalTransactionFees({ + nativeAssetSymbol: 'ETH', + primaryTransaction: { + chainId: '0x1', + txParams: {}, + txReceipt: { + gasUsed: '0x1', + effectiveGasPrice: '0x2', + }, + }, + } as Parameters[0]), + ).toStrictEqual([ + { + type: 'base', + amount: '2', + decimals: 18, + assetType: 'native', + symbol: 'ETH', + assetId: 'eip155:1/slip44:60', + }, + ]); + }); + + it('returns undefined when gas fields are missing', () => { + expect( + getLocalTransactionFees({ + primaryTransaction: { + chainId: '0x1', + txParams: {}, + }, + } as Parameters[0]), + ).toBeUndefined(); + }); + + it('returns undefined when the chain id cannot be normalized', () => { + expect( + getLocalTransactionFees({ + primaryTransaction: { + chainId: '0xzzzz', + txParams: {}, + txReceipt: { + gasUsed: '0x1', + effectiveGasPrice: '0x2', + }, + }, + } as Parameters[0]), + ).toBeUndefined(); + }); + + it('resolves fee assetId from nativeAssetSymbol when the chain is unknown', () => { + expect( + getLocalTransactionFees({ + nativeAssetSymbol: 'ETH', + primaryTransaction: { + chainId: '0x3b9ac9f7', + txParams: {}, + txReceipt: { + gasUsed: '0x1', + effectiveGasPrice: '0x2', + }, + }, + } as Parameters[0]), + ).toStrictEqual([ + { + type: 'base', + amount: '2', + decimals: 18, + assetType: 'native', + symbol: 'ETH', + assetId: 'eip155:999999991/slip44:60', + }, + ]); + }); + + it('falls back to the zero-address native assetId when the chain is unknown and the symbol has no slip44', () => { + expect( + getLocalTransactionFees({ + nativeAssetSymbol: 'NOTACOIN', + primaryTransaction: { + chainId: '0x3b9ac9f7', + txParams: {}, + txReceipt: { + gasUsed: '0x1', + effectiveGasPrice: '0x2', + }, + }, + } as Parameters[0]), + ).toStrictEqual([ + { + type: 'base', + amount: '2', + decimals: 18, + assetType: 'native', + symbol: 'NOTACOIN', + assetId: + 'eip155:999999991/erc20:0x0000000000000000000000000000000000000000', + }, + ]); + }); + }); + + describe('getFees', () => { + it('returns network fees with native symbol and assetId from the chain id', () => { + expect( + getFees({ + chainId: 1, + gasUsed: '0x2', + effectiveGasPrice: '0x3', + } as Parameters[0]), + ).toStrictEqual([ + { + type: 'base', + amount: '6', + decimals: 18, + assetType: 'native', + symbol: 'ETH', + assetId: 'eip155:1/slip44:60', + }, + ]); + }); + + it('returns native fee assetId from native value transfers', () => { + expect( + getFees({ + chainId: 59144, + gasUsed: 341413, + effectiveGasPrice: '34544851', + valueTransfers: [ + { + from: '0xa', + to: '0xb', + amount: '1', + decimal: 18, + contractAddress: '0x0', + symbol: 'ETH', + name: 'Ether', + transferType: 'internal', + }, + ], + } as Parameters[0]), + ).toStrictEqual([ + { + type: 'base', + amount: String(341413n * 34544851n), + decimals: 18, + assetType: 'native', + symbol: 'ETH', + assetId: 'eip155:59144/slip44:60', + }, + ]); + }); + + it('returns undefined when the chain id cannot be normalized', () => { + expect( + getFees({ + chainId: '0xzzzz', + gasUsed: '0x2', + effectiveGasPrice: '0x3', + } as Parameters[0]), + ).toBeUndefined(); + }); + + it('returns fee amount without symbol or assetId when the chain is unknown', () => { + expect( + getFees({ + chainId: 999999991, + gasUsed: '0x2', + effectiveGasPrice: '0x3', + } as Parameters[0]), + ).toStrictEqual([ + { + type: 'base', + amount: '6', + decimals: 18, + assetType: 'native', + }, + ]); + }); + + it('uses ETH slip44:60 for Sepolia fees instead of chainlist testnet coin type 1', () => { + expect( + getFees({ + chainId: 11155111, + gasUsed: '0x2', + effectiveGasPrice: '0x3', + } as Parameters[0]), + ).toStrictEqual([ + { + type: 'base', + amount: '6', + decimals: 18, + assetType: 'native', + symbol: 'ETH', + assetId: 'eip155:11155111/slip44:60', + }, + ]); + }); + + it('uses chainlist Avalanche slip44:9005 for fees instead of registry AVAX 9000', () => { + expect( + getFees({ + chainId: 43114, + gasUsed: '0x2', + effectiveGasPrice: '0x3', + } as Parameters[0]), + ).toStrictEqual([ + { + type: 'base', + amount: '6', + decimals: 18, + assetType: 'native', + symbol: 'AVAX', + assetId: 'eip155:43114/slip44:9005', + }, + ]); + }); + + it('keeps wei decimals for fees when chainlist nativeCurrency.decimals is not 18', () => { + expect( + getFees({ + chainId: 4160, + gasUsed: '21000', + effectiveGasPrice: '1000000000', + } as Parameters[0]), + ).toMatchObject({ + 0: { + amount: '21000000000000', + decimals: 18, + symbol: 'ALGO', + }, + }); + }); + }); + + describe('getLocalTransactionStatus', () => { + it('maps cancelled transaction groups to failed', () => { + expect( + getLocalTransactionStatus({ + primaryTransaction: { + status: 'cancelled', + }, + initialTransaction: { + status: 'cancelled', + }, + } as Parameters[0]), + ).toBe('failed'); + }); + }); +}); diff --git a/packages/client-utils/src/mappers/helpers/transactions.ts b/packages/client-utils/src/mappers/helpers/transactions.ts new file mode 100644 index 00000000000..e44aba7ca2d --- /dev/null +++ b/packages/client-utils/src/mappers/helpers/transactions.ts @@ -0,0 +1,432 @@ +import { + ERC721, + ERC1155, + isEqualCaseInsensitive as equalsIgnoreCase, +} from '@metamask/controller-utils'; +import type { V1TransactionByHashResponse } from '@metamask/core-backend'; +import type { TransactionMeta } from '@metamask/transaction-controller'; +import { + TransactionStatus, + TransactionType, +} from '@metamask/transaction-controller'; +import type { CaipChainId, Hex } from '@metamask/utils'; + +import type { + AssetType, + Fee, + Status, + TokenAmount, + ValueTransfer, +} from '../../types.js'; +import { nativeTokenDecimals } from '../constants.js'; +import { + formatAddressToAssetId, + formatChainIdToCaip, + getNativeAsset, + resolveNativeAssetId, +} from './caip.js'; +import { getKnownTokenMetadata } from './token-metadata.js'; + +// Adds optional `isSmartTransaction` to `TransactionMeta`. +export type TransactionGroup = { + hasCancelled: boolean; + hasRetried: boolean; + initialTransaction: TransactionMeta & { isSmartTransaction?: boolean }; + nonce: Hex; + primaryTransaction: TransactionMeta; + transactions: TransactionMeta[]; +}; + +function calculateNetworkFee( + gasUsed: string | number | undefined, + gasPrice: string | number | undefined, +): string | undefined { + if (gasUsed === undefined || gasPrice === undefined) { + return undefined; + } + + try { + return String(BigInt(gasUsed) * BigInt(gasPrice)); + } catch { + return undefined; + } +} + +function toNetworkFee( + amount: string, + chainId: CaipChainId, + symbol?: string, +): Fee { + const nativeAsset = getNativeAsset(chainId); + + if (nativeAsset) { + return { + type: 'base', + amount, + decimals: nativeTokenDecimals, + assetType: 'native', + symbol: symbol ?? nativeAsset.symbol, + assetId: nativeAsset.assetId, + }; + } + + const assetId = symbol ? resolveNativeAssetId(chainId, symbol) : undefined; + + return { + type: 'base', + amount, + decimals: nativeTokenDecimals, + assetType: 'native', + ...(symbol ? { symbol } : {}), + ...(assetId ? { assetId } : {}), + }; +} + +function getAssetTypeFromTransferType( + transferType: string | undefined, +): AssetType | undefined { + if (transferType === 'normal' || transferType === 'internal') { + return 'native'; + } + + if (transferType === 'erc20') { + return 'erc20'; + } + + if (transferType === ERC721.toLowerCase() || transferType === 'erc721') { + return 'erc721'; + } + + if (transferType === ERC1155.toLowerCase() || transferType === 'erc1155') { + return 'erc1155'; + } + + return undefined; +} + +function getNetworkFee( + transaction: V1TransactionByHashResponse, +): Fee | undefined { + const chainId = formatChainIdToCaip(transaction.chainId); + + if (!chainId) { + return undefined; + } + + const amount = calculateNetworkFee( + transaction.gasUsed, + transaction.effectiveGasPrice, + ); + + if (!amount) { + return undefined; + } + + return toNetworkFee(amount, chainId); +} + +export function getFees( + transaction: V1TransactionByHashResponse, +): Fee[] | undefined { + const networkFee = getNetworkFee(transaction); + + return networkFee ? [networkFee] : undefined; +} + +export function getLocalTransactionFees( + transactionGroup: Pick & { + nativeAssetSymbol?: string; + }, +): Fee[] | undefined { + const { primaryTransaction, nativeAssetSymbol } = transactionGroup; + const chainId = formatChainIdToCaip(primaryTransaction.chainId); + + if (!chainId) { + return undefined; + } + + const amount = calculateNetworkFee( + primaryTransaction.txReceipt?.gasUsed, + primaryTransaction.txReceipt?.effectiveGasPrice ?? + primaryTransaction.txParams?.gasPrice, + ); + + if (!amount) { + return undefined; + } + + return [toNetworkFee(amount, chainId, nativeAssetSymbol)]; +} + +const inProgressTransactionStatuses = [ + TransactionStatus.unapproved, + TransactionStatus.approved, + TransactionStatus.signed, + TransactionStatus.submitted, +]; + +const transactionGroupCancelledStatus = 'cancelled'; + +const smartTransactionStatus = { + cancelled: 'cancelled', + pending: 'pending', + success: 'success', +} as const; + +function getTransactionStatusKey( + transaction: TransactionGroup['primaryTransaction'], +): string { + const { type, status } = transaction; + const receiptStatus = transaction.txReceipt?.status; + + if (receiptStatus === '0x0') { + return TransactionStatus.failed; + } + + if ( + status === TransactionStatus.confirmed && + type === TransactionType.cancel + ) { + return transactionGroupCancelledStatus; + } + + return transaction.status; +} + +export function getLocalTransactionStatus({ + primaryTransaction, + initialTransaction, +}: { + primaryTransaction: TransactionGroup['primaryTransaction']; + initialTransaction: TransactionGroup['initialTransaction']; +}): Status { + if (initialTransaction.isSmartTransaction) { + const smartStatus = initialTransaction.status as string | undefined; + + if (smartStatus === smartTransactionStatus.pending) { + return 'pending'; + } + + if (smartStatus === smartTransactionStatus.success) { + return 'success'; + } + + if (smartStatus === smartTransactionStatus.cancelled) { + return 'failed'; + } + + return 'pending'; + } + + const statusKey = getTransactionStatusKey(primaryTransaction); + + if (statusKey === TransactionStatus.confirmed) { + return 'success'; + } + + if ( + statusKey === TransactionStatus.cancelled || + statusKey === transactionGroupCancelledStatus || + statusKey === TransactionStatus.dropped || + statusKey === TransactionStatus.failed || + statusKey === TransactionStatus.rejected + ) { + return 'failed'; + } + + if ( + inProgressTransactionStatuses.includes( + statusKey as (typeof inProgressTransactionStatuses)[number], + ) + ) { + return 'pending'; + } + + return 'pending'; +} + +export function isNftStandard(value?: string): boolean { + return value === ERC721.toLowerCase() || value === ERC1155.toLowerCase(); +} + +export function getNftPaymentTransfer({ + side, + sentTransfer, + receivedTransfer, + sentNativeTransfer, + nftCounterparty, + transactionFrom, + transactionTo, + subjectAddress, +}: { + side: 'buy' | 'sell'; + sentTransfer?: ValueTransfer; + receivedTransfer?: ValueTransfer; + sentNativeTransfer?: ValueTransfer; + nftCounterparty: string; + transactionFrom?: string; + transactionTo?: string; + subjectAddress: string; +}): ValueTransfer | undefined { + const isFungible = (transfer?: ValueTransfer): boolean => + Boolean(transfer && !isNftStandard(transfer.transferType)); + + if (side === 'buy') { + for (const transfer of [sentNativeTransfer, sentTransfer]) { + if (!transfer || !isFungible(transfer)) { + continue; + } + + // Only count a payment that goes to the NFT counterparty (direct sale) or + // to the contract being called (marketplace/router). This avoids treating + // an unrelated native send in the same transaction as the NFT payment. + if ( + equalsIgnoreCase(transfer.to, nftCounterparty) || + equalsIgnoreCase(transfer.to, transactionTo as string) + ) { + return transfer; + } + } + + return undefined; + } + + if (!receivedTransfer || !isFungible(receivedTransfer)) { + return undefined; + } + + if ( + equalsIgnoreCase(receivedTransfer.from, nftCounterparty) || + (transactionFrom && + !equalsIgnoreCase(transactionFrom, subjectAddress) && + equalsIgnoreCase(receivedTransfer.from, transactionFrom)) + ) { + return receivedTransfer; + } + + return undefined; +} + +const resolveAssetId = ( + chainId: CaipChainId, + contractAddress: string | undefined, +): string | undefined => { + if (!contractAddress) { + return undefined; + } + + return formatAddressToAssetId(contractAddress, chainId); +}; + +/** + * Resolves the user's primary send and receive legs from indexed value transfers. + * Prefers a receive whose symbol differs from the sent leg so dust does not win. + * + * @param valueTransfers - Indexed value transfers from the Accounts API. + * @param subjectAddress - The account address to match transfers against. + * @returns The primary sent and received transfers for the account. + */ +export function parseValueTransfers( + valueTransfers: ValueTransfer[] | undefined, + subjectAddress: string, +): { + sentTransfer: ValueTransfer | undefined; + receivedTransfer: ValueTransfer | undefined; + sentNativeTransfer: ValueTransfer | undefined; + sentNftTransfer: ValueTransfer | undefined; + receivedNftTransfer: ValueTransfer | undefined; +} { + const sent = valueTransfers?.filter(({ from }) => + equalsIgnoreCase(from, subjectAddress), + ); + const received = valueTransfers?.filter(({ to }) => + equalsIgnoreCase(to, subjectAddress), + ); + + const sentTransfer = sent?.[0]; + + const receivedTransfer = + received?.find(({ symbol }) => symbol !== sentTransfer?.symbol) ?? + received?.[0]; + + const sentNativeTransfer = sent?.find( + ({ transferType }) => transferType === 'normal', + ); + + const sentNftTransfer = sent?.find(({ transferType }) => + isNftStandard(transferType), + ); + const receivedNftTransfer = received?.find(({ transferType }) => + isNftStandard(transferType), + ); + + return { + sentTransfer, + receivedTransfer, + sentNativeTransfer, + sentNftTransfer, + receivedNftTransfer, + }; +} + +export function getTokenAmountFromTransfer( + transfer: ValueTransfer | undefined, + direction: TokenAmount['direction'], + chainId: CaipChainId, +): TokenAmount | undefined { + if (!transfer) { + return undefined; + } + + const { transferType, amount } = transfer; + const isNftTransfer = isNftStandard(transferType); + const symbol = isNftTransfer + ? transfer.name || transfer.symbol + : transfer.symbol; + + if (!symbol && amount === undefined) { + return undefined; + } + + const hasTransferAmount = + !isNftTransfer && amount !== null && amount !== undefined; + const assetType = getAssetTypeFromTransferType(transferType); + + let assetId: string | undefined; + if (assetType === 'native') { + assetId = resolveNativeAssetId(chainId, symbol); + } else if (transfer && !isNftTransfer) { + assetId = resolveAssetId(chainId, transfer.contractAddress); + } + + return { + direction, + ...(hasTransferAmount ? { amount: String(amount) } : {}), + ...(transfer.decimal === undefined ? {} : { decimals: transfer.decimal }), + ...(symbol ? { symbol } : {}), + ...(assetId ? { assetId } : {}), + ...(assetType ? { assetType } : {}), + }; +} + +export function getTokenMetadataFromKnownToken( + contractAddress: string | undefined, + direction: TokenAmount['direction'], + chainId: CaipChainId, +): TokenAmount | undefined { + const tokenMetadata = getKnownTokenMetadata(chainId, contractAddress); + + if (!tokenMetadata) { + return undefined; + } + + return { + direction, + assetType: 'erc20', + ...(tokenMetadata.symbol ? { symbol: tokenMetadata.symbol } : {}), + ...(tokenMetadata.decimals === undefined + ? {} + : { decimals: tokenMetadata.decimals }), + ...(tokenMetadata.assetId ? { assetId: tokenMetadata.assetId } : {}), + }; +} diff --git a/packages/client-utils/src/mappers/keyring-transaction-mapper.test.ts b/packages/client-utils/src/mappers/keyring-transaction-mapper.test.ts new file mode 100644 index 00000000000..89f8de81d1f --- /dev/null +++ b/packages/client-utils/src/mappers/keyring-transaction-mapper.test.ts @@ -0,0 +1,321 @@ +import { SolScope } from '@metamask/keyring-api'; + +import { keyringTransactionFixtures } from '../../test/fixtures/keyring-transactions.js'; +import { mapKeyringTransaction } from './keyring-transaction-mapper.js'; + +describe('mapKeyringTransaction', () => { + it('maps keyring send transactions with token amount data', () => { + const item = mapKeyringTransaction( + keyringTransactionFixtures.mapArgs.sendWithToken, + ); + + expect(item).toMatchObject({ + type: 'send', + chainId: SolScope.Mainnet, + status: 'success', + timestamp: 1716367781000, + hash: 'send-id', + data: { + from: 'from-address', + to: 'to-address', + token: { + amount: '2.5', + assetId: `${SolScope.Mainnet}/token:usdc`, + direction: 'out', + symbol: 'USDC', + }, + }, + }); + }); + + it('maps keyring swap transactions with source and destination token amounts', () => { + const item = mapKeyringTransaction(keyringTransactionFixtures.mapArgs.swap); + + expect(item).toMatchObject({ + type: 'swap', + chainId: SolScope.Mainnet, + status: 'pending', + timestamp: 1716367781000, + hash: 'swap-id', + data: { + sourceToken: { + amount: '1', + assetId: `${SolScope.Mainnet}/slip44:501`, + direction: 'out', + symbol: 'SOL', + }, + destinationToken: { + amount: '100', + assetId: `${SolScope.Mainnet}/token:usdc`, + direction: 'in', + symbol: 'USDC', + }, + }, + }); + }); + + it('maps a keyring receive transaction to a receive activity item', () => { + const item = mapKeyringTransaction( + keyringTransactionFixtures.mapArgs.receive, + ); + + expect(item).toMatchObject({ + type: 'receive', + chainId: SolScope.Mainnet, + status: 'success', + hash: 'receive-id', + data: { + from: 'sender-address', + to: 'me-address', + token: { + amount: '7', + direction: 'in', + symbol: 'USDC', + }, + }, + }); + }); + + it('maps an unknown keyring transaction type to a contract interaction', () => { + const item = mapKeyringTransaction( + keyringTransactionFixtures.mapArgs.unknownContractInteraction, + ); + + expect(item).toMatchObject({ + type: 'contractInteraction', + chainId: SolScope.Mainnet, + status: 'failed', + hash: 'unknown-id', + data: { + from: 'from-address', + to: 'to-address', + fees: [ + { + type: 'base', + amount: '0.0001', + symbol: 'SOL', + assetId: `${SolScope.Mainnet}/slip44:501`, + }, + ], + }, + }); + }); + + it('maps token approve with amount ≤15 digits to approveSpendingCap preserving the amount', () => { + const item = mapKeyringTransaction( + keyringTransactionFixtures.mapArgs.approveFifteenDigits, + ); + + expect(item).toMatchObject({ + type: 'approveSpendingCap', + chainId: SolScope.Mainnet, + status: 'success', + timestamp: 1716367781000, + hash: 'approve-id', + data: { + from: 'owner-address', + token: { + amount: '999999999999999', + assetId: `${SolScope.Mainnet}/token:usdc`, + direction: 'out', + symbol: 'USDC', + }, + }, + }); + }); + + it('strips token amount for approve with >15 digit integer part (uint256.max)', () => { + const item = mapKeyringTransaction( + keyringTransactionFixtures.mapArgs.approveUint256Max, + ); + + expect(item).toMatchObject({ + type: 'approveSpendingCap', + hash: 'unlimited-approve-id', + data: { + token: { + assetId: `${SolScope.Mainnet}/token:usdc`, + symbol: 'USDC', + direction: 'out', + amount: undefined, + }, + }, + }); + }); + + it('returns the approve token unchanged when no amount is present', () => { + const item = mapKeyringTransaction( + keyringTransactionFixtures.mapArgs.approveNoAmount, + ); + + expect(item).toMatchObject({ + type: 'approveSpendingCap', + data: { from: 'owner-address', token: undefined }, + }); + }); + + it('strips token amount when integer part has exactly 16 digits (boundary)', () => { + const item = mapKeyringTransaction( + keyringTransactionFixtures.mapArgs.approveSixteenDigits, + ); + + expect(item).toMatchObject({ + type: 'approveSpendingCap', + data: { + token: { + amount: undefined, + }, + }, + }); + }); + + it('falls back to an empty address when a movement list is empty', () => { + const item = mapKeyringTransaction( + keyringTransactionFixtures.mapArgs.emptyMovements, + ); + + expect(item).toMatchObject({ + type: 'contractInteraction', + data: { from: '', to: '' }, + }); + }); + + it('maps a missing timestamp to a zero timestamp', () => { + const item = mapKeyringTransaction( + keyringTransactionFixtures.mapArgs.noTimestamp, + ); + + expect(item.timestamp).toBe(0); + }); + + it('skips non-fungible fees', () => { + const item = mapKeyringTransaction( + keyringTransactionFixtures.mapArgs.nonFungibleFee, + ); + + expect(item).toMatchObject({ type: 'send', data: { fees: [] } }); + }); + + it('maps bitcoin send from account address and to output address', () => { + const item = mapKeyringTransaction( + keyringTransactionFixtures.mapArgs.bitcoinSend, + ); + + expect(item).toMatchObject({ + type: 'send', + data: { + from: 'bc1qcj8v4ft5uvt59jjrxd856a48xegclwne78h0ye', + to: 'bc1qc5tzsfpd3zjecma6529kanjtug69rf58mtfxmu', + token: { + amount: '0.000003', + direction: 'out', + symbol: 'BTC', + }, + }, + }); + }); + + it('uses the subject outflow for a Solana bridge send', () => { + const item = mapKeyringTransaction( + keyringTransactionFixtures.mapArgs.solanaBridgeSendWithForeignUsdc, + ); + + expect(item).toMatchObject({ + type: 'send', + chainId: SolScope.Mainnet, + status: 'success', + hash: '3Tph6Faw2YMshJt7pkCaCbTHTX4mYJLE6h72DR6Q4uDta9HmrNCfReXuDDPKUCbCxn7NUNALvgNjii19fKdgWBfA', + data: { + from: keyringTransactionFixtures.addresses.solanaSubject, + to: keyringTransactionFixtures.addresses.solanaCounterparty, + token: { + amount: '0.00531264', + assetId: `${SolScope.Mainnet}/slip44:501`, + direction: 'out', + symbol: 'SOL', + }, + }, + }); + }); + + it('maps trustline approve TokenApprove to assetActivation', () => { + const item = mapKeyringTransaction( + keyringTransactionFixtures.mapArgs.trustlineApprove, + ); + + expect(item).toMatchObject({ + type: 'assetActivation', + chainId: 'stellar:pubnet', + status: 'success', + timestamp: 1716367781000, + hash: 'trustline-approve-id', + data: { + from: 'owner-address', + token: { + amount: undefined, + symbol: 'USDC', + direction: 'out', + }, + }, + }); + }); + + it('returns the trustline activation token unchanged when no amount is present', () => { + const item = mapKeyringTransaction( + keyringTransactionFixtures.mapArgs.trustlineApproveNoAmount, + ); + + expect(item).toMatchObject({ + type: 'assetActivation', + data: { from: 'owner-address', token: undefined }, + }); + }); + + it('maps trustline disapprove TokenDisapprove to assetDeactivation', () => { + const item = mapKeyringTransaction( + keyringTransactionFixtures.mapArgs.trustlineDisapprove, + ); + + expect(item).toMatchObject({ + type: 'assetDeactivation', + chainId: 'stellar:pubnet', + status: 'success', + timestamp: 1716367781000, + hash: 'trustline-disapprove-id', + data: { + from: 'owner-address', + token: { + amount: undefined, + symbol: 'USDC', + direction: 'out', + }, + }, + }); + }); + + it('returns the trustline deactivation token unchanged when no amount is present', () => { + const item = mapKeyringTransaction( + keyringTransactionFixtures.mapArgs.trustlineDisapproveNoAmount, + ); + + expect(item).toMatchObject({ + type: 'assetDeactivation', + data: { from: 'owner-address', token: undefined }, + }); + }); + + it('maps a non-trustline TokenDisapprove to a contract interaction', () => { + const item = mapKeyringTransaction( + keyringTransactionFixtures.mapArgs.disapproveNonTrustline, + ); + + expect(item).toMatchObject({ + type: 'contractInteraction', + chainId: SolScope.Mainnet, + data: { + from: 'owner-address', + to: 'spender-address', + }, + }); + }); +}); diff --git a/packages/client-utils/src/mappers/keyring-transaction-mapper.ts b/packages/client-utils/src/mappers/keyring-transaction-mapper.ts new file mode 100644 index 00000000000..35c6e62025f --- /dev/null +++ b/packages/client-utils/src/mappers/keyring-transaction-mapper.ts @@ -0,0 +1,255 @@ +import type { Transaction as KeyringTransaction } from '@metamask/keyring-api'; +import { + TransactionStatus, + TransactionType as KeyringTransactionType, +} from '@metamask/keyring-api'; + +import type { Fee, ActivityItem, Status, TokenAmount } from '../types.js'; + +type Movement = KeyringTransaction['from'][number]; +type KeyringFee = KeyringTransaction['fees'][number]; +type FungibleAsset = Extract< + NonNullable, + { fungible: true } +>; + +/** + * Custom labels for non-EVM transactions. + * + * The labels are used to map the transaction type to the title in the activity list and dialog. + * The labels are defined in the `transaction.details.typeLabel` property. + * For details: {@link https://github.com/MetaMask/metamask-extension/pull/38040} + */ +export enum CustomTransactionTypeLabel { + // Token requires one off approve to receive + TrustlineApprove = 'trustline-approve', + // Token requires revoke the approve to stop receiving + TrustlineDisapprove = 'trustline-disapprove', +} + +function hasTrustlineTypeLabel( + details: KeyringTransaction['details'], +): boolean { + // A flag to indicate if the transaction is a trustline type. + return [ + String(CustomTransactionTypeLabel.TrustlineApprove), + String(CustomTransactionTypeLabel.TrustlineDisapprove), + ].includes(details?.typeLabel ?? ''); +} + +function mapStatus(status: KeyringTransaction['status']): Status { + switch (status) { + case TransactionStatus.Confirmed: + return 'success'; + case TransactionStatus.Failed: + return 'failed'; + case TransactionStatus.Submitted: + case TransactionStatus.Unconfirmed: + default: + return 'pending'; + } +} + +function getAddress(movements: Movement[]): string { + return movements[0]?.address ?? ''; +} + +function hasFungibleAsset( + movement: Movement, +): movement is Movement & { asset: FungibleAsset } { + return movement.asset?.fungible === true; +} + +function getToken( + movements: Movement[], + direction: TokenAmount['direction'], +): TokenAmount | undefined { + const movement = movements.find(hasFungibleAsset); + + if (!movement) { + return undefined; + } + + return { + amount: movement.asset.amount, + symbol: movement.asset.unit, + assetId: movement.asset.type, + direction, + }; +} + +function getFee(fee: KeyringFee): Fee | undefined { + const { asset } = fee; + + if (!asset.fungible) { + return undefined; + } + + return { + type: fee.type, + amount: asset.amount, + symbol: asset.unit, + assetId: asset.type, + }; +} + +function getFees(transaction: KeyringTransaction): Fee[] { + return transaction.fees.flatMap((fee) => { + const mappedFee = getFee(fee); + + return mappedFee ? [mappedFee] : []; + }); +} + +const approveAmountMaxIntegerDigits = 15; + +/** + * Maps a keyring transaction into the shared activity item shape. + * + * @param options - The mapping options. + * @param options.transaction - The keyring transaction to map. + * @param options.subjectAddress - Account address used for send/receive attribution. + * @returns The normalized activity item. + */ +export function mapKeyringTransaction({ + transaction, + subjectAddress, +}: { + transaction: KeyringTransaction; + subjectAddress?: string; +}): ActivityItem { + const { type, id } = transaction; + const status = mapStatus(transaction.status); + const timestamp = transaction.timestamp ? transaction.timestamp * 1000 : 0; + const chainId = transaction.chain; + + const from = + type === KeyringTransactionType.Send && subjectAddress + ? subjectAddress + : getAddress(transaction.from); + + const to = + type === KeyringTransactionType.Receive && subjectAddress + ? subjectAddress + : getAddress(transaction.to); + + const fees = getFees(transaction); + const common = { chainId, status, timestamp, hash: id }; + + switch (type) { + case KeyringTransactionType.Send: { + const fromSubject = subjectAddress + ? transaction.from.filter(({ address }) => address === subjectAddress) + : transaction.from; + const fromToken = getToken(fromSubject, 'out'); + const token = + !fromToken && chainId.startsWith('bip122:') + ? getToken(transaction.to, 'out') + : fromToken; + + return { + type: 'send', + ...common, + data: { + from, + to, + token, + fees, + }, + }; + } + + case KeyringTransactionType.Receive: + return { + type: 'receive', + ...common, + data: { + from, + to, + token: getToken(transaction.to, 'in'), + fees, + }, + }; + + case KeyringTransactionType.Swap: + return { + type: 'swap', + ...common, + data: { + from, + destinationToken: getToken(transaction.to, 'in'), + sourceToken: getToken(transaction.from, 'out'), + fees, + }, + }; + + case KeyringTransactionType.TokenApprove: { + const rawToken = getToken(transaction.from, 'out'); + + if (hasTrustlineTypeLabel(transaction.details)) { + return { + type: 'assetActivation', + ...common, + data: { + from, + token: rawToken ? { ...rawToken, amount: undefined } : rawToken, + fees, + }, + }; + } + + const isUnlimited = + rawToken?.amount !== undefined && + rawToken.amount.split('.')[0].length > approveAmountMaxIntegerDigits; + + return { + type: 'approveSpendingCap', + ...common, + data: { + from, + token: rawToken + ? { ...rawToken, amount: isUnlimited ? undefined : rawToken.amount } + : rawToken, + fees, + }, + }; + } + + case KeyringTransactionType.TokenDisapprove: { + if (!hasTrustlineTypeLabel(transaction.details)) { + return { + type: 'contractInteraction', + ...common, + data: { + from, + to, + fees, + }, + }; + } + + const rawToken = getToken(transaction.from, 'out'); + + return { + type: 'assetDeactivation', + ...common, + data: { + from, + token: rawToken ? { ...rawToken, amount: undefined } : rawToken, + fees, + }, + }; + } + + default: + return { + type: 'contractInteraction', + ...common, + data: { + from, + to, + fees, + }, + }; + } +} diff --git a/packages/client-utils/src/mappers/local-transaction-mapper.test.ts b/packages/client-utils/src/mappers/local-transaction-mapper.test.ts new file mode 100644 index 00000000000..dca2aed95bb --- /dev/null +++ b/packages/client-utils/src/mappers/local-transaction-mapper.test.ts @@ -0,0 +1,1188 @@ +import { localTransactionFixtures } from '../../test/fixtures/local-transactions.js'; +import { formatAddressToAssetId } from './helpers/caip.js'; +import { mapLocalTransaction } from './local-transaction-mapper.js'; + +jest.mock('./helpers/token-metadata', () => ({ + getKnownTokenMetadata: jest.requireActual('../../test/test-helpers') + .getKnownTokenMetadata, +})); + +const { + from, + to, + baseUsdc, + lineaDai, + lineaMusd, + wethContractAddress, + mainnetUsdt, +} = localTransactionFixtures.addresses; + +describe('mapLocalTransaction', () => { + it('maps a pending native send to a Send activity', () => { + const item = mapLocalTransaction( + localTransactionFixtures.mapInputs.mapsAPendingNativeSendTo, + ); + expect(item).toStrictEqual({ + type: 'send', + chainId: 'eip155:1', + status: 'pending', + timestamp: 1716367781000, + hash: '0xsend', + data: { + from, + to, + token: { + amount: '0x1', + decimals: 18, + direction: 'out', + assetType: 'native', + assetId: 'eip155:1/slip44:60', + }, + }, + }); + }); + it('maps a native send on an unknown chain without a ticker to a Send with amount and a zero-address native assetId', () => { + const item = mapLocalTransaction( + localTransactionFixtures.mapInputs.mapsANativeSendOnAn, + ); + expect(item).toStrictEqual({ + type: 'send', + chainId: 'eip155:1338', + status: 'success', + timestamp: 1779392463306, + hash: '0xnonative', + data: { + from, + to, + token: { + amount: '0xde0b6b3a7640000', + decimals: 18, + direction: 'out', + assetType: 'native', + assetId: + 'eip155:1338/erc20:0x0000000000000000000000000000000000000000', + }, + }, + }); + }); + it('maps a native send with ticker but missing value to a token with symbol only', () => { + const base = localTransactionFixtures.mapInputs.mapsAPendingNativeSendTo; + const item = mapLocalTransaction({ + ...base, + nativeAssetSymbol: 'ETH', + initialTransaction: { + ...base.initialTransaction, + txParams: { from, to }, + }, + primaryTransaction: { + ...base.primaryTransaction, + txParams: { from, to }, + }, + }); + expect(item).toMatchObject({ + type: 'send', + data: { + token: { + direction: 'out', + assetType: 'native', + decimals: 18, + symbol: 'ETH', + }, + }, + }); + expect( + item.type === 'send' ? item.data.token?.amount : 'unset', + ).toBeUndefined(); + }); + it('maps a native send with ticker but invalid value to a token with symbol only', () => { + const base = localTransactionFixtures.mapInputs.mapsAPendingNativeSendTo; + const item = mapLocalTransaction({ + ...base, + nativeAssetSymbol: 'ETH', + initialTransaction: { + ...base.initialTransaction, + txParams: { from, to, value: 'not-a-hex-amount' }, + }, + primaryTransaction: { + ...base.primaryTransaction, + txParams: { from, to, value: 'not-a-hex-amount' }, + }, + }); + expect(item).toMatchObject({ + type: 'send', + data: { + token: { + direction: 'out', + assetType: 'native', + decimals: 18, + symbol: 'ETH', + }, + }, + }); + expect( + item.type === 'send' ? item.data.token?.amount : 'unset', + ).toBeUndefined(); + }); + it('maps a custom network native send without bridge native asset metadata', () => { + const item = mapLocalTransaction( + localTransactionFixtures.mapInputs.mapsACustomNetworkNativeSend, + ); + expect(item).toStrictEqual({ + type: 'send', + chainId: 'eip155:1338', + status: 'success', + timestamp: 1779392463306, + hash: '0xcustomsend', + data: { + from, + to, + token: { + amount: '0xde0b6b3a7640000', + decimals: 18, + direction: 'out', + symbol: 'ETH', + assetType: 'native', + assetId: 'eip155:1338/slip44:60', + }, + }, + }); + }); + it('maps a USDC transfer with transferInformation', () => { + const item = mapLocalTransaction( + localTransactionFixtures.mapInputs + .mapsAUsdcTransferWithTransferinformation, + ); + expect(item).toStrictEqual({ + type: 'send', + chainId: 'eip155:1', + status: 'pending', + timestamp: 1716367781000, + hash: '0xtokensend', + data: { + from, + to: localTransactionFixtures.addresses.mainnetUsdc, + token: { + amount: '20000', + assetId: 'eip155:1/erc20:0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48', + decimals: 6, + direction: 'out', + symbol: 'USDC', + }, + }, + }); + }); + it('maps a USDT transfer without transferInformation', () => { + const item = mapLocalTransaction( + localTransactionFixtures.mapInputs + .mapsAUsdtTransferWithoutTransferinformation, + ); + expect(item).toMatchObject({ + type: 'send', + chainId: 'eip155:1', + data: { + from, + to: localTransactionFixtures.addresses.mainnetUsdt, + token: { + assetId: 'eip155:1/erc20:0xdAC17F958D2ee523a2206206994597C13D831ec7', + direction: 'out', + }, + }, + }); + }); + it('leaves unknown token transfer symbols blank', () => { + const item = mapLocalTransaction( + localTransactionFixtures.mapInputs.leavesUnknownTokenTransferSymbolsBlank, + ); + expect(item.type).toBe('send'); + if (item.type !== 'send') { + throw new Error(`Expected send item, got ${item.type}`); + } + + expect(item.data.token).toStrictEqual({ + assetId: 'eip155:1/erc20:0x1111111111111111111111111111111111111111', + direction: 'out', + }); + }); + it('falls back to the txParams to when transfer data lacks a recipient', () => { + const item = mapLocalTransaction( + localTransactionFixtures.mapInputs.fallsBackToTheTxparamsTo, + ); + expect(item).toMatchObject({ + type: 'send', + data: { to: mainnetUsdt }, + }); + }); + it('uses the original transaction type and primary transaction status', () => { + const item = mapLocalTransaction( + localTransactionFixtures.mapInputs.usesTheOriginalTransactionTypeAnd, + ); + expect(item).toStrictEqual({ + type: 'approveSpendingCap', + chainId: 'eip155:59144', + status: 'pending', + timestamp: 1716367881000, + hash: '0xretry', + data: { + from, + token: { + assetId: + 'eip155:59144/erc20:0x239FD4B0c4DB49Fa8660E65B97619D43D0E0A79d', + decimals: 0, + direction: 'out', + symbol: 'TDN', + }, + }, + }); + }); + it('maps a Permit2 approve to an approve spending cap without the Permit2 contract as the token', () => { + const item = mapLocalTransaction( + localTransactionFixtures.mapInputs.mapsAPermit2Approve, + ); + expect(item.type).toBe('approveSpendingCap'); + const token = + item.type === 'approveSpendingCap' ? item.data.token : 'unset'; + expect(token).toBeUndefined(); + }); + it('falls back to transferInformation when txParams.to is not a valid address', () => { + const item = mapLocalTransaction( + localTransactionFixtures.mapInputs + .fallsBackToTransferinformationWhenTxparams, + ); + expect(item).toMatchObject({ + type: 'approveSpendingCap', + data: { + token: { + direction: 'out', + symbol: 'mUSD', + assetId: formatAddressToAssetId(lineaMusd, 'eip155:59144'), + }, + }, + }); + }); + it('omits the approved amount for a token approve (mirrors the API path)', () => { + const item = mapLocalTransaction( + localTransactionFixtures.mapInputs.omitsTheApprovedAmountForA, + ); + expect(item).toMatchObject({ + type: 'approveSpendingCap', + data: { + token: { + direction: 'out', + symbol: 'mUSD', + assetId: formatAddressToAssetId(lineaMusd, 'eip155:59144'), + }, + }, + }); + expect( + item.type === 'approveSpendingCap' ? item.data.token?.amount : 'unset', + ).toBeUndefined(); + }); + it('maps a zero-amount token approve to a revoke spending cap', () => { + const item = mapLocalTransaction( + localTransactionFixtures.mapInputs.mapsAZeroAmountTokenApprove, + ); + expect(item.type).toBe('approveSpendingCap'); + }); + it('maps a setApprovalForAll group type to an approve spending cap', () => { + const item = mapLocalTransaction( + localTransactionFixtures.mapInputs.mapsASetapprovalforallGroupTypeTo, + ); + expect(item).toMatchObject({ + type: 'approveSpendingCap', + data: { + token: { + direction: 'out', + assetId: formatAddressToAssetId(baseUsdc, 'eip155:8453'), + }, + }, + }); + }); + it('maps an increaseAllowance to an increase spending cap', () => { + const item = mapLocalTransaction( + localTransactionFixtures.mapInputs.mapsAnIncreaseallowanceToAnIncrease, + ); + expect(item).toMatchObject({ + type: 'increaseSpendingCap', + data: { + token: { + direction: 'out', + assetId: formatAddressToAssetId(baseUsdc, 'eip155:8453'), + }, + }, + }); + }); + it('maps an explicit lendingDeposit type', () => { + const item = mapLocalTransaction( + localTransactionFixtures.mapInputs.mapsAnExplicitLendingdepositType, + ); + expect(item).toMatchObject({ type: 'lendingDeposit', data: { from } }); + }); + it('maps a stakingDeposit type to a deposit activity', () => { + const item = mapLocalTransaction( + localTransactionFixtures.mapInputs.mapsAStakingdepositTypeToA, + ); + expect(item).toMatchObject({ + type: 'deposit', + data: { from }, + }); + }); + it('maps an incoming token transfer to a Receive activity', () => { + const item = mapLocalTransaction( + localTransactionFixtures.mapInputs.mapsAnIncomingTokenTransferTo, + ); + expect(item).toMatchObject({ + type: 'receive', + data: { + token: { direction: 'in', symbol: 'USDC', amount: '100000' }, + }, + }); + }); + it('maps an incoming native transfer without nativeAssetSymbol to a Receive with the native assetId', () => { + const item = mapLocalTransaction( + localTransactionFixtures.mapInputs.mapsAnIncomingNativeTransferTo, + ); + expect(item).toMatchObject({ + type: 'receive', + data: { + token: { + direction: 'in', + assetType: 'native', + }, + }, + }); + expect(item.type === 'receive' ? item.data.token?.assetId : 'unset').toBe( + 'eip155:1/slip44:60', + ); + }); + it('maps an mUSD conversion to a Convert activity', () => { + const item = mapLocalTransaction( + localTransactionFixtures.mapInputs.mapsAnMusdConversionToA, + ); + expect(item).toStrictEqual({ + type: 'convert', + chainId: 'eip155:59144', + status: 'success', + timestamp: 1779805800000, + hash: '0xmusdconversion', + data: { + from, + sourceToken: { + assetId: formatAddressToAssetId(lineaDai, 'eip155:59144'), + decimals: 18, + direction: 'out', + symbol: 'DAI', + }, + destinationToken: { + amount: '100099', + assetId: formatAddressToAssetId(lineaMusd, 'eip155:59144'), + decimals: 6, + direction: 'in', + symbol: 'mUSD', + }, + }, + }); + }); + it('maps a Perps withdrawal local transaction to a Perps withdraw funds activity', () => { + const item = mapLocalTransaction( + localTransactionFixtures.mapInputs.mapsAPerpsWithdrawalLocalTransaction, + ); + expect(item).toMatchObject({ + type: 'perpsWithdraw', + chainId: 'eip155:42161', + status: 'success', + timestamp: 1780690942752, + hash: '0xd5dbb4421d123fd16d16485c394a68b5a28d9b5da9d9973554258a9fd2e9ebf6', + data: { + fiat: { + amount: '0.714705', + }, + networkFee: { + amount: '0', + }, + token: { + assetId: formatAddressToAssetId( + '0xaf88d065e77c8cc2239327c5edb3a432268e5831', + 'eip155:42161', + ), + direction: 'out', + }, + }, + }); + }); + it('maps a Perps deposit local transaction to a Perps add funds activity', () => { + const item = mapLocalTransaction( + localTransactionFixtures.mapInputs.mapsAPerpsDepositLocalTransaction, + ); + expect(item).toMatchObject({ + type: 'perpsAddFunds', + chainId: 'eip155:42161', + status: 'success', + timestamp: 1781185241609, + hash: '0x3073fa67020abb1931ed043d7a8b6b020aa1004c9d0dd9ebd43ca5b9c10e9503', + data: { + fiat: { + amount: '1.000169', + }, + networkFee: { + amount: '0.04143764111397638042', + }, + token: { + assetId: formatAddressToAssetId( + '0xaf88d065e77c8cc2239327c5edb3a432268e5831', + 'eip155:42161', + ), + direction: 'out', + }, + }, + }); + }); + it('maps a perps deposit without a target address to a tokenless add funds activity', () => { + const item = mapLocalTransaction( + localTransactionFixtures.mapInputs.mapsAPerpsDepositWithoutA, + ); + expect(item).toMatchObject({ + type: 'perpsAddFunds', + data: { token: undefined, fiat: undefined, networkFee: undefined }, + }); + }); + it('maps an Aave supply contract interaction to a Lending deposit activity', () => { + const item = mapLocalTransaction( + localTransactionFixtures.mapInputs.mapsAnAaveSupplyContractInteraction, + ); + expect(item).toStrictEqual({ + type: 'lendingDeposit', + chainId: 'eip155:8453', + status: 'success', + timestamp: 1779892154611, + hash: '0x093844dd6200984f0e27d3c3a76b7a63b360bfb2136213237d693afd2cd69740', + data: { + from, + }, + }); + }); + it('maps a native-asset Lido stake contract interaction to a Lending deposit activity', () => { + const item = mapLocalTransaction( + localTransactionFixtures.mapInputs + .mapsALidoNativeStakeContractInteraction, + ); + expect(item).toStrictEqual({ + type: 'lendingDeposit', + chainId: 'eip155:1', + status: 'success', + timestamp: 1782912963672, + hash: '0xd8ca1456ed6305ec3d9c058f28a1ba48eb335ffcffd7d7c4321d3169c29e6a07', + data: { + from, + }, + }); + }); + it('maps a withdraw contract interaction from the received token transfer', () => { + const item = mapLocalTransaction( + localTransactionFixtures.mapInputs.mapsAWithdrawContractInteractionFrom, + ); + expect(item).toStrictEqual({ + type: 'lendingWithdrawal', + chainId: 'eip155:8453', + status: 'success', + timestamp: 1779912434153, + hash: '0x26f4911467b538702c0945e4ec5e303de44c0c1c174897141d1b548ea3161795', + data: { + from, + destinationToken: { + amount: '200000', + assetId: formatAddressToAssetId(baseUsdc, 'eip155:8453'), + decimals: 6, + direction: 'in', + symbol: 'USDC', + }, + }, + }); + }); + it('sets no destination token amount when the received transfer log data is not a valid amount', () => { + const base = + localTransactionFixtures.mapInputs.mapsAWithdrawContractInteractionFrom; + const item = mapLocalTransaction({ + ...base, + initialTransaction: { + ...base.initialTransaction, + txReceipt: { + logs: (base.initialTransaction.txReceipt?.logs ?? []).map((log) => ({ + ...log, + data: 'not-a-hex-amount', + })), + }, + }, + }); + expect(item.type).toBe('lendingWithdrawal'); + expect( + item.type === 'lendingWithdrawal' + ? item.data.destinationToken?.amount + : 'unset', + ).toBeUndefined(); + }); + it('maps a withdraw contract interaction without a matching log to no destination token', () => { + const item = mapLocalTransaction( + localTransactionFixtures.mapInputs + .mapsAWithdrawContractInteractionWithout, + ); + expect(item).toMatchObject({ + type: 'lendingWithdrawal', + data: { from, destinationToken: undefined }, + }); + }); + it('maps bridge history token data to a local swap', () => { + const item = mapLocalTransaction( + localTransactionFixtures.mapInputs.mapsBridgeHistoryTokenDataTo, + ); + expect(item).toMatchObject({ + type: 'swap', + chainId: 'eip155:1', + status: 'success', + timestamp: 1779392463306, + hash: '0xbridgeswap', + data: { + from, + sourceToken: { + amount: '10000000000000', + assetId: 'eip155:1/slip44:60', + decimals: 18, + direction: 'out', + symbol: 'ETH', + }, + destinationToken: { + amount: '19546', + assetId: 'eip155:1/erc20:0xACa92e438df0B2401fF60Da7E4337B687a2435dA', + decimals: 6, + direction: 'in', + symbol: 'MUSD', + }, + }, + }); + }); + it('maps a swap without a destination token to a swap with only a source', () => { + const item = mapLocalTransaction( + localTransactionFixtures.mapInputs.mapsASwapWithoutADestination, + ); + expect(item).toMatchObject({ + type: 'swap', + data: { from }, + }); + }); + it('uses a bridge history activity status override', () => { + const item = mapLocalTransaction( + localTransactionFixtures.mapInputs.usesABridgeHistoryActivityStatus, + ); + expect(item.status).toBe('failed'); + }); + it('maps a local bridge network fee from the transaction receipt', () => { + const item = mapLocalTransaction( + localTransactionFixtures.mapInputs.mapsALocalBridgeNetworkFee, + ); + expect(item).toMatchObject({ + type: 'bridge', + data: { + fees: [ + { + type: 'base', + amount: String(BigInt('0x24405') * BigInt('0x6fc23ac1d')), + decimals: 18, + assetType: 'native', + }, + ], + }, + }); + }); + it('maps swap metadata token symbols to a Swap activity', () => { + const item = mapLocalTransaction( + localTransactionFixtures.mapInputs.mapsSwapMetadataTokenSymbolsTo, + ); + expect(item).toMatchObject({ + type: 'swap', + chainId: 'eip155:8453', + status: 'success', + timestamp: 1716367781000, + hash: '0xswap', + data: { from }, + }); + }); + it('uses native source symbol for a legacy swap with native value and no source metadata', () => { + const item = mapLocalTransaction( + localTransactionFixtures.mapInputs.usesNativeSourceSymbolForA, + ); + expect(item).toMatchObject({ + type: 'swap', + data: { from }, + }); + }); + it('maps a legacy swap with an invalid native value without throwing', () => { + const item = mapLocalTransaction( + localTransactionFixtures.mapInputs.mapsALegacySwapWithAn, + ); + expect(item).toMatchObject({ + type: 'swap', + data: { from }, + }); + }); + it('maps a WETH9 deposit contract interaction to a Wrap activity with a native source amount and the native assetId when nativeAssetSymbol is omitted', () => { + const item = mapLocalTransaction( + localTransactionFixtures.mapInputs.mapsAWeth9DepositContractInteraction, + ); + expect(item).toStrictEqual({ + type: 'wrap', + chainId: 'eip155:1', + status: 'success', + timestamp: 1716367781000, + hash: '0xwrap', + data: { + from, + sourceToken: { + amount: '0x3782dace9d900000', + decimals: 18, + direction: 'out', + assetType: 'native', + assetId: 'eip155:1/slip44:60', + }, + destinationToken: { + amount: '0x3782dace9d900000', + assetId: formatAddressToAssetId(wethContractAddress, 'eip155:1'), + decimals: 18, + direction: 'in', + }, + }, + }); + }); + it('treats a WETH9 deposit with zero native value as a contract interaction', () => { + const item = mapLocalTransaction( + localTransactionFixtures.mapInputs.treatsAWeth9DepositWithZero, + ); + expect(item.type).toBe('contractInteraction'); + }); + it('maps a WETH9 withdraw contract interaction to an Unwrap activity with a native destination amount but no symbol when nativeAssetSymbol is omitted', () => { + const unwrapAmount = '1000000000000000000'; + const base = + localTransactionFixtures.mapInputs.mapsAWeth9WithdrawContractInteraction; + const item = mapLocalTransaction({ + ...base, + initialTransaction: { + ...base.initialTransaction, + txParams: { + from, + to: wethContractAddress, + data: base.initialTransaction.txParams.data, + }, + }, + primaryTransaction: { + ...base.primaryTransaction, + txParams: { + from, + to: wethContractAddress, + data: base.primaryTransaction.txParams.data, + }, + }, + }); + expect(item).toStrictEqual({ + type: 'unwrap', + chainId: 'eip155:1', + status: 'success', + timestamp: 1716367781000, + hash: '0xunwrap', + data: { + from, + sourceToken: { + amount: unwrapAmount, + assetId: formatAddressToAssetId(wethContractAddress, 'eip155:1'), + decimals: 18, + direction: 'out', + }, + destinationToken: { + amount: unwrapAmount, + decimals: 18, + direction: 'in', + assetType: 'native', + }, + }, + }); + }); + it('maps a WETH9 unwrap with malformed amount data to an unwrap without a destination token when nativeAssetSymbol is omitted', () => { + const item = mapLocalTransaction( + localTransactionFixtures.mapInputs.mapsAWeth9UnwrapWithMalformed, + ); + expect(item).toMatchObject({ + type: 'unwrap', + data: { + destinationToken: undefined, + }, + }); + }); + it('maps a native value contract interaction with amount, no symbol, and the chainlist native assetId when nativeAssetSymbol is omitted', () => { + const item = mapLocalTransaction( + localTransactionFixtures.mapInputs.mapsANativeValueContractInteraction, + ); + expect(item).toStrictEqual({ + type: 'contractInteraction', + chainId: 'eip155:1', + status: 'success', + timestamp: 1716367781000, + hash: '0xcontract', + data: { + from, + to, + token: { + amount: '0x3782dace9d900000', + decimals: 18, + direction: 'out', + assetType: 'native', + assetId: 'eip155:1/slip44:60', + }, + methodId: '0xd0e30db0', + }, + }); + }); + it('maps a zero-value contract interaction without a token', () => { + const item = mapLocalTransaction( + localTransactionFixtures.mapInputs.mapsAZeroValueContractInteraction, + ); + expect(item).toMatchObject({ + type: 'contractInteraction', + data: { from, to, methodId: '0x12345678' }, + }); + expect( + item.type === 'contractInteraction' ? item.data.token : undefined, + ).toBeUndefined(); + }); + it('maps a contract interaction without a value to a tokenless interaction', () => { + const item = mapLocalTransaction( + localTransactionFixtures.mapInputs.mapsAContractInteractionWithoutA, + ); + expect(item.type).toBe('contractInteraction'); + }); + it('maps a contract interaction with an invalid value without throwing', () => { + expect(() => + mapLocalTransaction( + localTransactionFixtures.mapInputs.mapsAContractInteractionWithAn, + ), + ).not.toThrow(); + expect( + mapLocalTransaction( + localTransactionFixtures.mapInputs.mapsAContractInteractionWithAn, + ).type, + ).toBe('contractInteraction'); + }); + it('maps a local contract interaction with an incoming NFT simulation change to an NFT buy', () => { + const item = mapLocalTransaction( + localTransactionFixtures.mapInputs.mapsALocalContractInteractionWith, + ); + expect(item).toStrictEqual({ + type: 'nftBuy', + chainId: 'eip155:1', + status: 'success', + timestamp: 1780606867763, + hash: '0x2fda37c5b591c30367649c3c317621429bb5c59ff6a77b0a8cd48b56897168bc', + data: { + from, + }, + }); + }); + it('maps a smart transaction status to a pending activity', () => { + expect( + mapLocalTransaction( + localTransactionFixtures.mapInputs.mapsASmartTransactionStatusTo, + ).status, + ).toBe('pending'); + }); + it('maps a successful smart transaction to a success activity', () => { + expect( + mapLocalTransaction( + localTransactionFixtures.mapInputs.mapsASuccessfulSmartTransactionTo, + ).status, + ).toBe('success'); + }); + it('maps a cancelled smart transaction to a failed activity', () => { + expect( + mapLocalTransaction( + localTransactionFixtures.mapInputs.mapsACancelledSmartTransactionTo, + ).status, + ).toBe('failed'); + }); + it('maps an unknown smart transaction status to a pending activity', () => { + expect( + mapLocalTransaction( + localTransactionFixtures.mapInputs.mapsAnUnknownSmartTransactionStatus, + ).status, + ).toBe('pending'); + }); + it('maps a failed transaction receipt status to a failed activity', () => { + expect( + mapLocalTransaction( + localTransactionFixtures.mapInputs.mapsAFailedTransactionReceiptStatus, + ).status, + ).toBe('failed'); + }); + it('maps a cancelled transaction group to a failed activity', () => { + expect( + mapLocalTransaction( + localTransactionFixtures.mapInputs.mapsACancelledTransactionGroupTo, + ).status, + ).toBe('failed'); + }); + it('maps a dropped transaction to a failed activity', () => { + expect( + mapLocalTransaction( + localTransactionFixtures.mapInputs.mapsADroppedTransactionToA, + ).status, + ).toBe('failed'); + }); + it('maps an unapproved transaction to a pending activity', () => { + expect( + mapLocalTransaction( + localTransactionFixtures.mapInputs.mapsAnUnapprovedTransactionToA, + ).status, + ).toBe('pending'); + }); + it('maps a status outside the known set to a pending activity', () => { + expect( + mapLocalTransaction( + localTransactionFixtures.mapInputs.mapsAStatusOutsideTheKnown, + ).status, + ).toBe('pending'); + }); + it('uses precomputed fees from the transaction group when present', () => { + const fees = [{ type: 'base', amount: '7', symbol: 'ETH' }]; + const item = mapLocalTransaction( + localTransactionFixtures.mapInputs.usesPrecomputedFeesFromTheTransaction, + ); + expect(item).toMatchObject({ type: 'bridge', data: { fees } }); + }); + it('maps a local bridge fee using txParams gasPrice when no receipt price is present', () => { + const item = mapLocalTransaction( + localTransactionFixtures.mapInputs.mapsALocalBridgeFeeUsing, + ); + expect(item).toMatchObject({ + type: 'bridge', + data: { + fees: [ + { + type: 'base', + amount: String(BigInt('0x100') * BigInt('0x10')), + decimals: 18, + assetType: 'native', + }, + ], + }, + }); + }); + it('maps a local bridge with an invalid fee input to no fees', () => { + const item = mapLocalTransaction( + localTransactionFixtures.mapInputs.mapsALocalBridgeWithAn, + ); + expect(item).toMatchObject({ type: 'bridge', data: { fees: undefined } }); + }); + it('maps a token transfer without a contract address to a tokenless send', () => { + const item = mapLocalTransaction( + localTransactionFixtures.mapInputs.mapsATokenTransferWithoutA, + ); + expect(item.type).toBe('send'); + expect(item.type === 'send' ? item.data.token : 'unset').toBeUndefined(); + }); + it('maps a WETH9 unwrap with non-hex amount data to an unwrap without a destination token when nativeAssetSymbol is omitted', () => { + const item = mapLocalTransaction( + localTransactionFixtures.mapInputs.mapsAWeth9UnwrapWithNon, + ); + expect(item).toMatchObject({ + type: 'unwrap', + data: { + destinationToken: undefined, + }, + }); + expect( + item.type === 'unwrap' ? item.data.sourceToken?.amount : 'unset', + ).toBeUndefined(); + }); + it('falls back to initial transaction id and empty addresses when fields are missing', () => { + const item = mapLocalTransaction( + localTransactionFixtures.mapInputs.fallsBackToInitialTransactionId, + ); + expect(item).toMatchObject({ + type: 'send', + hash: 'primary-fallback-id', + timestamp: 1716367781000, + data: { + from: '', + to: '', + token: undefined, + }, + }); + }); + it('handles token transfers on a chain without a wrapped-native token entry', () => { + const item = mapLocalTransaction( + localTransactionFixtures.mapInputs.handlesTokenTransfersOnAChain, + ); + expect(item).toMatchObject({ + type: 'send', + chainId: 'eip155:1338', + data: { token: { direction: 'out' } }, + }); + }); + it('maps a token transfer with no calldata to a tokenless send', () => { + const item = mapLocalTransaction( + localTransactionFixtures.mapInputs.mapsATokenTransferWithNo, + ); + expect(item.type).toBe('send'); + }); + it('wraps native value on a chain without canonical native asset metadata', () => { + const item = mapLocalTransaction( + localTransactionFixtures.mapInputs.wrapsNativeValueOnAChain, + ); + expect(item).toMatchObject({ + type: 'wrap', + chainId: 'eip155:1337', + data: { + destinationToken: { + direction: 'in', + decimals: 18, + amount: '0x3782dace9d900000', + }, + }, + }); + }); + it('defaults missing txParams fields when txParams is omitted', () => { + const base = localTransactionFixtures.mapInputs.mapsAnMusdConversionWithNo; + const item = mapLocalTransaction({ + ...base, + initialTransaction: { + chainId: base.initialTransaction.chainId, + id: base.initialTransaction.id, + hash: base.initialTransaction.hash, + status: base.initialTransaction.status, + time: base.initialTransaction.time, + type: base.initialTransaction.type, + }, + primaryTransaction: base.primaryTransaction, + }); + + expect(item).toMatchObject({ + type: 'convert', + data: { from: '', destinationToken: undefined }, + }); + }); + it('maps an mUSD conversion for an unknown destination token without optional metadata fields', () => { + const base = localTransactionFixtures.mapInputs.mapsAnMusdConversionWithNo; + const item = mapLocalTransaction({ + ...base, + initialTransaction: { + ...base.initialTransaction, + chainId: '0x53a', + txParams: { + from, + to: 'not-an-address', + }, + }, + primaryTransaction: { + ...base.primaryTransaction, + chainId: '0x53a', + txParams: { + from, + to: 'not-an-address', + }, + }, + }); + + expect(item).toMatchObject({ + type: 'convert', + data: { + destinationToken: { + direction: 'in', + }, + }, + }); + expect( + item.type === 'convert' ? item.data.destinationToken : undefined, + ).toStrictEqual({ direction: 'in' }); + }); + it('maps an mUSD conversion with transferInformation amount to convert decimals from transferInformation', () => { + const base = localTransactionFixtures.mapInputs.mapsAnMusdConversionToA; + const item = mapLocalTransaction({ + ...base, + initialTransaction: { + ...base.initialTransaction, + transferInformation: { + amount: '100000', + decimals: 6, + }, + }, + }); + + expect(item).toMatchObject({ + type: 'convert', + data: { + destinationToken: { + direction: 'in', + amount: '100000', + decimals: 6, + }, + }, + }); + }); + it('maps an mUSD conversion with no calldata to a convert without a destination amount', () => { + const item = mapLocalTransaction( + localTransactionFixtures.mapInputs.mapsAnMusdConversionWithNo, + ); + expect(item).toMatchObject({ + type: 'convert', + data: { destinationToken: { direction: 'in', symbol: 'mUSD' } }, + }); + expect( + item.type === 'convert' ? item.data.destinationToken?.amount : 'unset', + ).toBeUndefined(); + }); + it('maps an mUSD conversion with invalid calldata amount to a convert without a destination amount', () => { + const base = localTransactionFixtures.mapInputs.mapsAnMusdConversionToA; + const invalidAmountData = `0x${'0'.repeat(72)}zz${'0'.repeat(64)}`; + const item = mapLocalTransaction({ + ...base, + initialTransaction: { + ...base.initialTransaction, + txParams: { + ...base.initialTransaction.txParams, + data: invalidAmountData, + }, + }, + primaryTransaction: { + ...base.primaryTransaction, + txParams: { + ...base.primaryTransaction.txParams, + data: invalidAmountData, + }, + }, + }); + + expect(item).toMatchObject({ + type: 'convert', + data: { destinationToken: { direction: 'in', symbol: 'mUSD' } }, + }); + expect( + item.type === 'convert' ? item.data.destinationToken?.amount : 'unset', + ).toBeUndefined(); + }); + it('maps an mUSD conversion without a destination contract to a convert without a destination token', () => { + const base = localTransactionFixtures.mapInputs.mapsAnMusdConversionWithNo; + const item = mapLocalTransaction({ + ...base, + initialTransaction: { + ...base.initialTransaction, + txParams: { from }, + }, + primaryTransaction: { + ...base.primaryTransaction, + txParams: { from }, + }, + }); + + expect(item).toMatchObject({ + type: 'convert', + data: { destinationToken: undefined }, + }); + }); + it('maps a token approve with no calldata to an approve spending cap', () => { + const item = mapLocalTransaction( + localTransactionFixtures.mapInputs.mapsATokenApproveWithNo, + ); + expect(item).toMatchObject({ + type: 'approveSpendingCap', + data: { token: { direction: 'out' } }, + }); + }); + it('ignores withdraw logs that have no recipient topic', () => { + const item = mapLocalTransaction( + localTransactionFixtures.mapInputs.ignoresWithdrawLogsThatHaveNo, + ); + expect(item).toMatchObject({ + type: 'lendingWithdrawal', + data: { destinationToken: undefined }, + }); + }); + it('maps a token transferFrom to a Send activity', () => { + expect( + mapLocalTransaction( + localTransactionFixtures.mapInputs.mapsATokenTransferfromToA, + ).type, + ).toBe('send'); + }); + it('maps a safeTransferFrom to a Send activity', () => { + expect( + mapLocalTransaction( + localTransactionFixtures.mapInputs.mapsASafetransferfromToASend, + ).type, + ).toBe('send'); + }); + it('maps a swapAndSend to a swap activity', () => { + expect( + mapLocalTransaction( + localTransactionFixtures.mapInputs.mapsASwapandsendToASwap, + ).type, + ).toBe('swap'); + }); + it('maps a perpsDepositAndOrder to an add funds activity', () => { + expect( + mapLocalTransaction( + localTransactionFixtures.mapInputs.mapsAPerpsdepositandorderToAnAdd, + ).type, + ).toBe('perpsAddFunds'); + }); + it('maps a bridgeApproval to an approve spending cap', () => { + expect( + mapLocalTransaction( + localTransactionFixtures.mapInputs.mapsABridgeapprovalToAnApprove, + ).type, + ).toBe('approveSpendingCap'); + }); + it('maps a shieldSubscriptionApprove to an approve spending cap', () => { + expect( + mapLocalTransaction( + localTransactionFixtures.mapInputs + .mapsAShieldsubscriptionapproveToAnApprove, + ).type, + ).toBe('approveSpendingCap'); + }); + it('maps a tokenMethodSetApprovalForAll to an approve spending cap', () => { + expect( + mapLocalTransaction( + localTransactionFixtures.mapInputs + .mapsATokenmethodsetapprovalforallToAnApprove, + ).type, + ).toBe('approveSpendingCap'); + }); + it('omits the assetId when the token contract address cannot be encoded', () => { + const item = mapLocalTransaction( + localTransactionFixtures.mapInputs.omitsTheAssetidWhenTheToken, + ); + expect(item.type).toBe('send'); + expect(item.type === 'send' ? item.data.token : undefined).toStrictEqual({ + direction: 'out', + }); + }); + it('ignores withdraw logs that omit topics entirely', () => { + const item = mapLocalTransaction( + localTransactionFixtures.mapInputs.ignoresWithdrawLogsThatOmitTopics, + ); + expect(item).toMatchObject({ + type: 'lendingWithdrawal', + data: { destinationToken: undefined }, + }); + }); + it('maps musdClaim to claimMusdBonus with from address', () => { + const item = mapLocalTransaction( + localTransactionFixtures.mapInputs.mapsMusdclaimToClaimmusdbonusWithFrom, + ); + expect(item).toMatchObject({ + type: 'claimMusdBonus', + chainId: 'eip155:59144', + status: 'pending', + timestamp: 1778633325000, + hash: '0xmusdclaim', + data: { + from, + }, + }); + }); +}); diff --git a/packages/client-utils/src/mappers/local-transaction-mapper.ts b/packages/client-utils/src/mappers/local-transaction-mapper.ts new file mode 100644 index 00000000000..569f4fb4c33 --- /dev/null +++ b/packages/client-utils/src/mappers/local-transaction-mapper.ts @@ -0,0 +1,566 @@ +import { isEqualCaseInsensitive as equalsIgnoreCase } from '@metamask/controller-utils'; +import { TransactionType } from '@metamask/transaction-controller'; +import { KnownCaipNamespace, toCaipChainId } from '@metamask/utils'; + +import type { Fee, ActivityItem, TokenAmount } from '../types.js'; +import { + permit2ApproveMethodId, + swapsWrappedTokensAddresses, + supplyMethodIds, + tokenTransferLogTopicHash, + unwrapMethodIds, + withdrawMethodIds, + wrapMethodIds, +} from './constants.js'; +import { + formatAddressToAssetId, + resolveNativeAssetId, +} from './helpers/caip.js'; +import { getKnownTokenMetadata } from './helpers/token-metadata.js'; +import { + getLocalTransactionFees, + getLocalTransactionStatus, + isNftStandard, +} from './helpers/transactions.js'; +import type { TransactionGroup } from './helpers/transactions.js'; + +const evmNativeDecimals = 18; + +/** + * Maps a local TransactionController group into the shared activity item shape. + * + * @param transactionGroup - The transaction group to map, optionally enriched by the client. + * @returns The normalized activity item. + */ +export function mapLocalTransaction( + transactionGroup: TransactionGroup & { + sourceToken?: TokenAmount; + destinationToken?: TokenAmount; + nativeAssetSymbol?: string; + contractTokenMetadata?: { symbol?: string; decimals?: number }; + activityStatus?: ActivityItem['status']; + fees?: Fee[]; + }, +): ActivityItem { + const { initialTransaction, primaryTransaction } = transactionGroup; + const chainId = toCaipChainId( + KnownCaipNamespace.Eip155, + Number.parseInt(initialTransaction.chainId, 16).toString(), + ); + const nativeSymbol = transactionGroup.nativeAssetSymbol; + const fees = + transactionGroup.fees ?? getLocalTransactionFees(transactionGroup); + const { + transferInformation, + type: transactionType, + simulationData, + txReceipt, + metamaskPay, + txParams: { from = '', to = '', data: txData, value: txValue } = {}, + } = initialTransaction; + const { + time: primaryTime, + hash: primaryHash, + id: primaryId, + } = primaryTransaction; + const methodId = txData?.slice(0, 10); + // Permit2 approvals use the Permit2 contract as `to`, not the approved token. + // Keep this mapper thin: still classify the activity as an approve, but omit + // the token rather than surfacing the wrong one — the API mapper provides + // accurate token data. + const isPermit2Approve = methodId === permit2ApproveMethodId; + const tokenContractAddress = isPermit2Approve + ? undefined + : (transferInformation?.contractAddress ?? (to || undefined)); + + const getNativeToken = ( + transaction: TransactionGroup['initialTransaction'], + direction: TokenAmount['direction'], + ): TokenAmount | undefined => { + const rawAmount = transaction.txParams.value; + let amount: string | undefined; + if (rawAmount) { + try { + amount = BigInt(rawAmount) > 0n ? rawAmount : undefined; + } catch { + amount = undefined; + } + } + + if (!amount && nativeSymbol === undefined) { + return undefined; + } + + const assetId = resolveNativeAssetId(chainId, nativeSymbol); + + return { + direction, + assetType: 'native', + decimals: evmNativeDecimals, + ...(amount ? { amount } : {}), + ...(nativeSymbol === undefined ? {} : { symbol: nativeSymbol }), + ...(assetId ? { assetId } : {}), + }; + }; + + const getContractTokenFromTransaction = ({ + contractAddress, + direction, + transaction, + }: { + contractAddress?: string; + direction: TokenAmount['direction']; + transaction: TransactionGroup['initialTransaction']; + }): TokenAmount | undefined => { + if (!contractAddress) { + return undefined; + } + + const symbol = + transaction.transferInformation?.symbol ?? + transactionGroup.contractTokenMetadata?.symbol; + const decimals = + transaction.transferInformation?.decimals ?? + transactionGroup.contractTokenMetadata?.decimals; + const amount = transaction.transferInformation?.amount; + const assetId = formatAddressToAssetId(contractAddress, chainId); + + return { + direction, + ...(symbol ? { symbol } : {}), + ...(assetId ? { assetId } : {}), + ...(amount ? { amount } : {}), + ...(decimals === undefined ? {} : { decimals }), + }; + }; + + const getContractTokenWithKnownMetadata = ({ + amount, + contractAddress, + direction, + transaction, + }: { + amount?: string; + contractAddress?: string; + direction: TokenAmount['direction']; + transaction: TransactionGroup['initialTransaction']; + }): TokenAmount | undefined => { + if (!contractAddress) { + return undefined; + } + + const tokenMetadata = getKnownTokenMetadata(chainId, contractAddress); + + const isWrappedNativeToken = equalsIgnoreCase( + contractAddress, + swapsWrappedTokensAddresses[ + initialTransaction.chainId as keyof typeof swapsWrappedTokensAddresses + ] || '', + ); + const wrappedNativeTokenDecimals = isWrappedNativeToken + ? evmNativeDecimals + : undefined; + + const decimals = + transaction.transferInformation?.amount === undefined + ? (tokenMetadata?.decimals ?? + transactionGroup.contractTokenMetadata?.decimals ?? + wrappedNativeTokenDecimals) + : transaction.transferInformation.decimals; + const tokenAmount = transaction.transferInformation?.amount ?? amount; + const symbol = + transaction.transferInformation?.symbol ?? + tokenMetadata?.symbol ?? + transactionGroup.contractTokenMetadata?.symbol; + const assetId = formatAddressToAssetId(contractAddress, chainId); + + return { + direction, + ...(symbol ? { symbol } : {}), + ...(assetId ? { assetId } : {}), + ...(tokenAmount ? { amount: tokenAmount } : {}), + ...(decimals === undefined ? {} : { decimals }), + }; + }; + + const status = + transactionGroup.activityStatus ?? + getLocalTransactionStatus({ + primaryTransaction, + initialTransaction, + }); + const timestamp = primaryTime ?? initialTransaction.time; + const hash = primaryHash ?? initialTransaction.hash ?? primaryId; + const common = { chainId, status, timestamp, hash }; + + switch (transactionType) { + case TransactionType.simpleSend: { + return { + type: 'send', + ...common, + data: { + from, + to, + token: getNativeToken(initialTransaction, 'out'), + }, + }; + } + + case TransactionType.swap: + case TransactionType.swapAndSend: + case TransactionType.bridge: { + const { sourceToken, destinationToken } = transactionGroup; + + return { + type: transactionType === TransactionType.bridge ? 'bridge' : 'swap', + ...common, + data: { + from, + sourceToken, + destinationToken, + fees, + }, + }; + } + + case TransactionType.tokenMethodSafeTransferFrom: + case TransactionType.tokenMethodTransfer: + case TransactionType.tokenMethodTransferFrom: { + return { + type: 'send', + ...common, + data: { + from, + to, + token: getContractTokenFromTransaction({ + transaction: initialTransaction, + direction: 'out', + contractAddress: tokenContractAddress, + }), + }, + }; + } + + case TransactionType.lendingDeposit: + case TransactionType.stakingDeposit: + return { + type: + transactionType === TransactionType.stakingDeposit + ? 'deposit' + : 'lendingDeposit', + ...common, + data: { + from, + }, + }; + + case TransactionType.incoming: { + return { + type: 'receive', + ...common, + data: { + from, + to, + token: transferInformation?.contractAddress + ? getContractTokenFromTransaction({ + transaction: initialTransaction, + direction: 'in', + contractAddress: transferInformation.contractAddress, + }) + : getNativeToken(initialTransaction, 'in'), + }, + }; + } + case TransactionType.musdClaim: + return { + type: 'claimMusdBonus', + ...common, + data: { + from, + }, + }; + + case TransactionType.musdConversion: { + let conversionAmount: string | undefined; + + if (txData && txData.length >= 138) { + try { + conversionAmount = BigInt(`0x${txData.slice(74, 138)}`).toString(); + } catch { + conversionAmount = undefined; + } + } + + return { + type: 'convert', + ...common, + data: { + from, + sourceToken: transactionGroup.sourceToken, + destinationToken: getContractTokenWithKnownMetadata({ + amount: conversionAmount, + transaction: initialTransaction, + direction: 'in', + contractAddress: to, + }), + }, + }; + } + + case TransactionType.bridgeApproval: + case TransactionType.shieldSubscriptionApprove: + case TransactionType.swapApproval: + case TransactionType.tokenMethodSetApprovalForAll: + case TransactionType.tokenMethodApprove: + case TransactionType.tokenMethodIncreaseAllowance: { + const approvalToken = getContractTokenFromTransaction({ + transaction: initialTransaction, + direction: 'out', + contractAddress: tokenContractAddress, + }); + + return { + type: + transactionType === TransactionType.tokenMethodIncreaseAllowance + ? 'increaseSpendingCap' + : 'approveSpendingCap', + ...common, + data: { + from, + token: approvalToken, + }, + }; + } + + case TransactionType.perpsDeposit: + case TransactionType.perpsDepositAndOrder: + case TransactionType.perpsWithdraw: { + const token = to + ? { + direction: 'out' as const, + assetId: formatAddressToAssetId(to, chainId), + } + : undefined; + + const fiat = metamaskPay?.targetFiat + ? { amount: metamaskPay.targetFiat } + : undefined; + const networkFee = + typeof metamaskPay?.networkFeeFiat === 'string' + ? { amount: metamaskPay.networkFeeFiat } + : undefined; + + return { + type: + transactionType === TransactionType.perpsWithdraw + ? 'perpsWithdraw' + : 'perpsAddFunds', + ...common, + data: { + from, + token, + fiat, + networkFee, + }, + }; + } + + default: { + const isSupplyContractInteraction = + transactionType === TransactionType.contractInteraction && + methodId && + supplyMethodIds.has(methodId.toLowerCase()); + const isWithdrawContractInteraction = + transactionType === TransactionType.contractInteraction && + methodId && + withdrawMethodIds.has(methodId.toLowerCase()); + + let hasNativeValue = false; + + try { + hasNativeValue = BigInt(txValue ?? '0') > 0n; + } catch { + hasNativeValue = false; + } + + // Confirm an outflow before labelling a supply, mirroring the API mapper's + // `sentTransfer` guard: native stakes (e.g. Lido) decrease the native + // balance, while ERC-20 supplies (e.g. Aave) show a token decrease. + const isSupply = + isSupplyContractInteraction && + (simulationData?.nativeBalanceChange?.isDecrease === true || + simulationData?.tokenBalanceChanges?.some( + ({ isDecrease, standard }) => isDecrease && standard === 'erc20', + ) === true); + const incomingNftBalanceChange = + transactionType === TransactionType.contractInteraction && + simulationData?.tokenBalanceChanges?.find( + ({ isDecrease, standard }) => !isDecrease && isNftStandard(standard), + ); + + if (incomingNftBalanceChange && hasNativeValue) { + // Keep this mapper thin: classify the activity as an NFT buy and let the + // API mapper provide the token/payment details. + return { + type: 'nftBuy', + ...common, + data: { + from, + }, + }; + } + + if (isSupply) { + return { + type: 'lendingDeposit', + ...common, + data: { + from, + }, + }; + } + + // lending withdrawal - applies to Earn features only + if (isWithdrawContractInteraction) { + const fromAddress = from.toLowerCase(); + const receivedTokenLog = (txReceipt?.logs ?? []).find( + ({ topics: [eventTopic, , logTo] = [] }) => { + const toAddress = logTo + ? `0x${logTo.slice(-40)}`.toLowerCase() + : undefined; + + return ( + eventTopic?.toLowerCase() === tokenTransferLogTopicHash && + toAddress === fromAddress + ); + }, + ); + let receivedAmount: string | undefined; + + if (receivedTokenLog) { + try { + receivedAmount = BigInt(String(receivedTokenLog.data)).toString(); + } catch { + receivedAmount = undefined; + } + } + + const destinationToken = receivedTokenLog + ? getContractTokenWithKnownMetadata({ + amount: receivedAmount, + transaction: initialTransaction, + direction: 'in', + contractAddress: receivedTokenLog.address, + }) + : undefined; + + return { + type: 'lendingWithdrawal', + ...common, + data: { + from, + destinationToken, + }, + }; + } + + // wrap and unwrap + if (transactionType === TransactionType.contractInteraction && methodId) { + const wrappedTokenAddress = + swapsWrappedTokensAddresses[ + initialTransaction.chainId as keyof typeof swapsWrappedTokensAddresses + ]; + + if (wrappedTokenAddress && equalsIgnoreCase(to, wrappedTokenAddress)) { + const normalizedMethodId = methodId.toLowerCase(); + + if (wrapMethodIds.has(normalizedMethodId)) { + try { + if (txValue && BigInt(txValue) > 0n) { + return { + type: 'wrap', + ...common, + data: { + from, + sourceToken: getNativeToken(initialTransaction, 'out'), + destinationToken: getContractTokenWithKnownMetadata({ + amount: txValue, + transaction: initialTransaction, + direction: 'in', + contractAddress: wrappedTokenAddress, + }), + }, + }; + } + } catch { + // Invalid native value — fall through. + } + } + + if (unwrapMethodIds.has(normalizedMethodId)) { + let unwrapAmount: string | undefined; + + if (txData && txData.length >= 74) { + try { + unwrapAmount = BigInt(`0x${txData.slice(10, 74)}`).toString(); + } catch { + unwrapAmount = undefined; + } + } + + const nativeToken = getNativeToken(initialTransaction, 'in'); + + return { + type: 'unwrap', + ...common, + data: { + from, + sourceToken: getContractTokenWithKnownMetadata({ + amount: unwrapAmount, + transaction: initialTransaction, + direction: 'out', + contractAddress: wrappedTokenAddress, + }), + destinationToken: unwrapAmount + ? { + ...(nativeToken ?? { + direction: 'in' as const, + assetType: 'native' as const, + decimals: evmNativeDecimals, + }), + amount: unwrapAmount, + } + : nativeToken, + }, + }; + } + } + } + + const token = ((): TokenAmount | undefined => { + if (txValue === undefined || txValue === '') { + return undefined; + } + + try { + return BigInt(txValue) > 0n + ? getNativeToken(initialTransaction, 'out') + : undefined; + } catch { + return undefined; + } + })(); + + return { + type: 'contractInteraction', + ...common, + data: { + from, + to, + ...(token ? { token } : {}), + methodId, + }, + }; + } + } +} diff --git a/packages/client-utils/src/mappers/ramps-order-mapper.test.ts b/packages/client-utils/src/mappers/ramps-order-mapper.test.ts new file mode 100644 index 00000000000..75ec0765c3f --- /dev/null +++ b/packages/client-utils/src/mappers/ramps-order-mapper.test.ts @@ -0,0 +1,264 @@ +import type { RampsOrderLike } from './ramps-order-mapper.js'; +import { mapRampsOrder } from './ramps-order-mapper.js'; + +const baseOrder: RampsOrderLike = { + provider: { id: 'transak', name: 'Transak' }, + cryptoAmount: '0.05', + fiatAmount: 100, + cryptoCurrency: { + assetId: 'eip155:1/slip44:60', + symbol: 'ETH', + decimals: 18, + }, + fiatCurrency: { symbol: 'USD' }, + providerOrderId: 'order-123', + providerOrderLink: 'https://transak.com/orders/order-123', + createdAt: 1716367781000, + totalFeesFiat: 2.5, + txHash: '0xabc', + walletAddress: '0xwallet', + status: 'COMPLETED', + network: { chainId: '1' }, + statusDescription: 'Your purchase was successful!', + orderType: 'buy', + paymentDetails: [{ fiatCurrency: 'USD', paymentMethod: 'card', fields: [] }], +}; + +describe('mapRampsOrder', () => { + it('maps a completed buy order to a rampBuy activity item', () => { + const item = mapRampsOrder(baseOrder); + + expect(item).toMatchObject({ + type: 'rampBuy', + chainId: 'eip155:1', + status: 'success', + timestamp: 1716367781000, + hash: '0xabc', + data: { + from: '0xwallet', + fiat: { amount: '100', currency: 'USD' }, + token: { + amount: '0.05', + symbol: 'ETH', + assetId: 'eip155:1/slip44:60', + direction: 'in', + }, + fees: [{ type: 'total', amount: '2.5', symbol: 'USD' }], + provider: { + id: 'transak', + name: 'Transak', + orderLink: 'https://transak.com/orders/order-123', + }, + statusDescription: 'Your purchase was successful!', + paymentDetails: [ + { fiatCurrency: 'USD', paymentMethod: 'card', fields: [] }, + ], + id: 'order-123', + }, + }); + }); + + it('passes through an already-CAIP-formatted network chainId unchanged', () => { + const item = mapRampsOrder({ + ...baseOrder, + network: { chainId: 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp' }, + }); + + expect(item?.chainId).toBe('solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp'); + }); + + it('maps a sell order to a rampSell activity item with an outbound token direction', () => { + const item = mapRampsOrder({ ...baseOrder, orderType: 'sell' }); + + expect(item).toMatchObject({ + type: 'rampSell', + data: { token: { direction: 'out' } }, + }); + }); + + it('maps an uppercase BUY orderType (the real V2 API shape) to a rampBuy activity item', () => { + const item = mapRampsOrder({ ...baseOrder, orderType: 'BUY' }); + + expect(item).toMatchObject({ + type: 'rampBuy', + data: { token: { direction: 'in' } }, + }); + }); + + it('maps an uppercase SELL orderType (the real V2 API shape) to a rampSell activity item', () => { + const item = mapRampsOrder({ ...baseOrder, orderType: 'SELL' }); + + expect(item).toMatchObject({ + type: 'rampSell', + data: { token: { direction: 'out' } }, + }); + }); + + it('maps an empty txHash to an undefined hash while keeping the provider order id', () => { + const item = mapRampsOrder({ ...baseOrder, txHash: '', status: 'PENDING' }); + + expect(item?.hash).toBeUndefined(); + expect(item?.type === 'rampBuy' ? item.data.id : 'unset').toBe('order-123'); + expect(item?.status).toBe('pending'); + }); + + it('hides a precreated stub order with an empty chain id instead of mapping eip155:0', () => { + const item = mapRampsOrder({ + ...baseOrder, + network: { chainId: '' }, + cryptoCurrency: undefined, + }); + + expect(item).toBeNull(); + }); + + it('falls through an unparseable network name to cryptoCurrency.chainId', () => { + // Coinbase (and other generic providers) return network as a free-form + // name string while still attaching a CAIP cryptoCurrency.chainId. + const item = mapRampsOrder({ + ...baseOrder, + network: 'ethereum', + cryptoCurrency: { + assetId: 'eip155:1/slip44:60', + chainId: 'eip155:1', + symbol: 'ETH', + decimals: 18, + }, + }); + + expect(item?.chainId).toBe('eip155:1'); + }); + + it('falls through an unparseable network name to cryptoCurrency.assetId', () => { + const item = mapRampsOrder({ + ...baseOrder, + network: 'ethereum', + cryptoCurrency: { assetId: 'eip155:1/slip44:60', symbol: 'ETH' }, + }); + + expect(item?.chainId).toBe('eip155:1'); + }); + + it('hides an order when cryptoCurrency.assetId has no valid chain segment', () => { + const item = mapRampsOrder({ + ...baseOrder, + network: 'ethereum', + cryptoCurrency: { assetId: 'not-an-asset-id', symbol: 'ETH' }, + }); + + expect(item).toBeNull(); + }); + + it('hides an order when network is an unparseable name and crypto currency has no chain', () => { + const item = mapRampsOrder({ + ...baseOrder, + network: 'ethereum', + cryptoCurrency: undefined, + }); + + expect(item).toBeNull(); + }); + + it.each(['0x', '0x0000'])( + 'treats placeholder txHash %s as missing while keeping the order id', + (txHash) => { + const item = mapRampsOrder({ ...baseOrder, txHash }); + + expect(item?.hash).toBeUndefined(); + expect(item?.type === 'rampBuy' ? item.data.id : 'unset').toBe( + 'order-123', + ); + }, + ); + + it.each([ + ['CREATED', 'pending'], + ['PENDING', 'pending'], + ['COMPLETED', 'success'], + ['FAILED', 'failed'], + ['CANCELLED', 'cancelled'], + ] as const)( + 'maps RampsOrderStatus %s to Status %s', + (rampsStatus, expectedStatus) => { + const item = mapRampsOrder({ ...baseOrder, status: rampsStatus }); + + expect(item?.status).toBe(expectedStatus); + }, + ); + + it.each(['UNKNOWN', 'ID_EXPIRED', 'PRECREATED'] as const)( + 'hides orders with RampsOrderStatus %s from the activity list', + (rampsStatus) => { + const item = mapRampsOrder({ ...baseOrder, status: rampsStatus }); + + expect(item).toBeNull(); + }, + ); + + it('hides orders excluded from purchases', () => { + const item = mapRampsOrder({ ...baseOrder, excludeFromPurchases: true }); + + expect(item).toBeNull(); + }); + + it.each(['DEPOSIT', 'deposit'] as const)( + 'maps an orderType of %s to a rampBuy activity item', + (orderType) => { + const item = mapRampsOrder({ ...baseOrder, orderType }); + + expect(item).toMatchObject({ type: 'rampBuy' }); + }, + ); + + it('prefers the canonical order id over providerOrderId when present', () => { + const item = mapRampsOrder({ + ...baseOrder, + id: 'transak/orders/canonical-id', + }); + + expect(item).toMatchObject({ + data: { id: 'transak/orders/canonical-id' }, + }); + }); + + it('does not report a decimals field on the token amount, since cryptoAmount is already human-formatted', () => { + const item = mapRampsOrder(baseOrder); + + expect(item).toMatchObject({ + data: { token: { amount: '0.05', symbol: 'ETH' } }, + }); + expect( + item?.type === 'rampBuy' ? item.data.token : undefined, + ).not.toHaveProperty('decimals'); + }); + + it('degrades gracefully when optional fields are missing', () => { + const minimalOrder: RampsOrderLike = { + cryptoAmount: '0.05', + fiatAmount: 100, + providerOrderId: 'order-456', + providerOrderLink: '', + createdAt: 1716367781000, + totalFeesFiat: 0, + txHash: '', + walletAddress: '0xwallet', + status: 'CREATED', + network: { chainId: '1' }, + orderType: 'buy', + }; + + expect(() => mapRampsOrder(minimalOrder)).not.toThrow(); + + const item = mapRampsOrder(minimalOrder); + + expect(item).toMatchObject({ + type: 'rampBuy', + data: { + fiat: { amount: '100', currency: undefined }, + token: undefined, + provider: { id: undefined, name: undefined }, + paymentDetails: undefined, + }, + }); + }); +}); diff --git a/packages/client-utils/src/mappers/ramps-order-mapper.ts b/packages/client-utils/src/mappers/ramps-order-mapper.ts new file mode 100644 index 00000000000..8794d31d53d --- /dev/null +++ b/packages/client-utils/src/mappers/ramps-order-mapper.ts @@ -0,0 +1,214 @@ +import type { CaipChainId } from '@metamask/utils'; +import { isCaipChainId } from '@metamask/utils'; + +import type { + ActivityItem, + Fee, + FiatAmount, + RampOrderPaymentDetail, + Status, + TokenAmount, +} from '../types.js'; +import { formatChainIdToCaip } from './helpers/caip.js'; + +/** + * Extracts the CAIP-2 chain id from a CAIP-19 asset id + * (`eip155:1/slip44:60` → `eip155:1`). + * + * @param assetId - CAIP-19 asset id. + * @returns The CAIP-2 chain id, or `undefined` when it can't be extracted. + */ +function caipChainIdFromAssetId( + assetId: string | undefined, +): CaipChainId | undefined { + if (!assetId) { + return undefined; + } + const slash = assetId.indexOf('/'); + const chainPart = slash === -1 ? assetId : assetId.slice(0, slash); + return isCaipChainId(chainPart) ? chainPart : undefined; +} + +type RampsOrderStatusLike = + | 'UNKNOWN' + | 'PRECREATED' + | 'CREATED' + | 'PENDING' + | 'FAILED' + | 'COMPLETED' + | 'CANCELLED' + | 'ID_EXPIRED'; + +/** + * The subset of `RampsOrder` (from `@metamask/ramps-controller`) that this + * mapper depends on. Redeclared locally rather than imported to keep + * `client-utils` free of a dependency on `ramps-controller`. + */ +export type RampsOrderLike = { + id?: string; + provider?: { id?: string; name?: string }; + cryptoAmount: string | number; + fiatAmount: number; + cryptoCurrency?: { + assetId?: string; + chainId?: string; + symbol: string; + decimals?: number; + }; + fiatCurrency?: { symbol: string }; + providerOrderId: string; + providerOrderLink: string; + createdAt: number; + totalFeesFiat: number; + txHash: string; + walletAddress: string; + status: RampsOrderStatusLike; + // Declared as an object, but generic providers (e.g. Coinbase) actually send + // a free-form network name string at runtime — see `resolveRampsOrderChainId`. + network: { chainId: string } | string; + statusDescription?: string; + orderType: string; + excludeFromPurchases?: boolean; + paymentDetails?: RampOrderPaymentDetail[]; +}; + +function mapStatus(status: RampsOrderStatusLike): Status { + switch (status) { + case 'COMPLETED': + return 'success'; + case 'FAILED': + return 'failed'; + case 'CANCELLED': + return 'cancelled'; + default: + return 'pending'; + } +} + +// `UNKNOWN`, `ID_EXPIRED`, and `PRECREATED` represent background checkout +// attempts (e.g. precreated orders that never matched a real order, or whose +// id expired before the provider assigned one) that the user never knowingly +// initiated as a distinct order and shouldn't see in their history. +const HIDDEN_STATUSES = new Set([ + 'UNKNOWN', + 'ID_EXPIRED', + 'PRECREATED', +]); + +/** + * Resolves the CAIP-2 chain id for a ramps order. + * + * Tries each source in order and falls through when a value is present but + * unparseable (e.g. Coinbase's network name string `"ethereum"`). Generic + * providers often return a free-form network name while still attaching a + * real CAIP `cryptoCurrency.chainId` / `assetId`. + * + * Precedence: `network.chainId` (object) → `network` (string) → + * `cryptoCurrency.chainId` → chain segment of `cryptoCurrency.assetId`. + * + * @param order - The ramps order to resolve a chain id for. + * @returns The CAIP-2 chain id, or `undefined` when it can't be resolved. + */ +function resolveRampsOrderChainId( + order: RampsOrderLike, +): ReturnType { + const { network } = order; + const networkChainId = + typeof network === 'string' ? network : network.chainId; + + return ( + formatChainIdToCaip(networkChainId) ?? + (order.cryptoCurrency?.chainId + ? formatChainIdToCaip(order.cryptoCurrency.chainId) + : undefined) ?? + caipChainIdFromAssetId(order.cryptoCurrency?.assetId) + ); +} + +/** + * Returns true when `txHash` looks like a real on-chain hash. Provider + * placeholders such as `""`, `"0x"`, and all-zero hashes must not be used as + * Activity row keys — they collide across orders whose hash isn't set yet. + * + * @param txHash - The order's raw `txHash` value. + * @returns `true` when the hash looks like a real on-chain hash. + */ +function isPlausibleRampTxHash(txHash: string): boolean { + const normalized = txHash.trim().toLowerCase(); + return normalized !== '' && !/^0x0*$/u.test(normalized); +} + +/** + * Maps a ramps order into the shared activity item shape. + * + * @param order - The ramps order to map. + * @returns The normalized activity item, or `null` if the order's status + * should not be surfaced in the activity list, or if no CAIP chain id can be + * resolved from the order. + */ +export function mapRampsOrder(order: RampsOrderLike): ActivityItem | null { + if (HIDDEN_STATUSES.has(order.status) || order.excludeFromPurchases) { + return null; + } + + const chainId = resolveRampsOrderChainId(order); + if (!chainId) { + return null; + } + + // The V2 API returns `orderType` uppercased (e.g. `'BUY'`); normalize since + // some call sites (e.g. locally-created stub orders) use lowercase. Transak + // deposits (`'DEPOSIT'`) are a buy variant, not a sell. + const normalizedOrderType = order.orderType.toUpperCase(); + const isBuy = + normalizedOrderType === 'BUY' || normalizedOrderType === 'DEPOSIT'; + const direction: TokenAmount['direction'] = isBuy ? 'in' : 'out'; + + // `cryptoAmount`/`fiatAmount` are already human-formatted by the API, unlike + // `TokenAmount.decimals` elsewhere in this package, which signals a raw + // on-chain amount that still needs scaling. Omit `decimals` so clients don't + // wrongly re-scale an already-human amount. + const token: TokenAmount | undefined = order.cryptoCurrency + ? { + amount: String(order.cryptoAmount), + symbol: order.cryptoCurrency.symbol, + assetId: order.cryptoCurrency.assetId, + direction, + } + : undefined; + + const fiat: FiatAmount = { + amount: String(order.fiatAmount), + currency: order.fiatCurrency?.symbol, + }; + + const fees: Fee[] = [ + { + type: 'total', + amount: String(order.totalFeesFiat), + symbol: order.fiatCurrency?.symbol, + }, + ]; + + return { + type: isBuy ? 'rampBuy' : 'rampSell', + chainId, + status: mapStatus(order.status), + timestamp: order.createdAt, + hash: isPlausibleRampTxHash(order.txHash) ? order.txHash : undefined, + data: { + from: order.walletAddress, + fiat, + token, + fees, + provider: { + id: order.provider?.id, + name: order.provider?.name, + orderLink: order.providerOrderLink, + }, + statusDescription: order.statusDescription, + paymentDetails: order.paymentDetails, + id: order.id ?? order.providerOrderId, + }, + }; +} diff --git a/packages/client-utils/src/types.ts b/packages/client-utils/src/types.ts new file mode 100644 index 00000000000..c305e255e05 --- /dev/null +++ b/packages/client-utils/src/types.ts @@ -0,0 +1,250 @@ +import type { ValueTransfer as _ValueTransfer } from '@metamask/core-backend'; +import type { CaipChainId } from '@metamask/utils'; + +export type PerpsOrderKind = + | 'marketShort' + | 'stopMarketCloseShort' + | 'marketCloseShort' + | 'limitShort' + | 'limitCloseShort' + | 'marketLong' + | 'stopMarketCloseLong' + | 'marketCloseLong' + | 'limitLong' + | 'limitCloseLong'; + +export type ActivityKind = + | 'receive' + | 'sell' + | 'buy' + | 'deposit' + | 'swap' + | 'claim' + | 'claimMusdBonus' + | 'send' + | 'wrap' + | 'unwrap' + | 'approveSpendingCap' + | 'revokeSpendingCap' + | 'increaseSpendingCap' + | 'contractInteraction' + | 'contractDeployment' + | 'bridge' + | 'convert' + | 'nftBuy' + | 'nftMint' + | 'nftSell' + | 'smartAccountUpgrade' + | 'lendingDeposit' + | 'lendingWithdrawal' + | 'stake' + | 'unstake' + | 'predictionsAddFunds' + | 'predictionsWithdrawFunds' + | 'predictionClaimWinnings' + | 'predictionCashedOut' + | 'predictionPlaced' + | 'perpsAddFunds' + | 'perpsWithdraw' + | 'perpsOpenLong' + | 'perpsCloseLong' + | 'perpsCloseLongLiquidated' + | 'perpsCloseLongStopLoss' + | 'perpsOpenShort' + | 'perpsCloseShort' + | 'perpsCloseShortLiquidated' + | 'perpsCloseShortStopLoss' + | 'perpsPaidFundingFees' + | 'perpsReceivedFundingFees' + | 'perpsCloseShortTakeProfit' + | 'perpsCloseLongTakeProfit' + | PerpsOrderKind + | 'assetActivation' + | 'assetDeactivation' + | 'rampBuy' + | 'rampSell'; + +export type Status = 'pending' | 'success' | 'failed' | 'cancelled'; + +export type AssetType = 'native' | 'erc20' | 'erc721' | 'erc1155'; + +export type TokenAmount = { + amount?: string; + decimals?: number; + symbol?: string; + assetId?: string; + assetType?: AssetType; + direction: 'in' | 'out'; +}; + +export type FiatAmount = { + amount: string; + currency?: string; +}; + +export type Fee = { + type: string; + amount?: string; + decimals?: number; + symbol?: string; + assetId?: string; + assetType?: AssetType; +}; + +type ActivityData = { + type: Type; + chainId: CaipChainId; + status: Status; + timestamp: number; + hash?: string; + data: Data; +}; + +/** + * Bank transfer instruction fields attached to a ramp order by providers + * that require manual payment (e.g. SEPA, wire transfer). + */ +export type RampOrderPaymentDetail = { + fiatCurrency: string; + paymentMethod: string; + fields: { name: string; id: string; value: string }[]; +}; + +export type ActivityItem = + | ActivityData< + 'approveSpendingCap' | 'revokeSpendingCap' | 'increaseSpendingCap', + { + from?: string; + token?: TokenAmount; + fees?: Fee[]; + } + > + | ActivityData< + 'assetActivation' | 'assetDeactivation', + { + from?: string; + token?: TokenAmount; + fees?: Fee[]; + } + > + | ActivityData< + 'send' | 'receive', + { + from: string; + to: string; + token?: TokenAmount; + fees?: Fee[]; + } + > + | ActivityData< + 'nftBuy' | 'nftMint' | 'nftSell', + { + from?: string; + to?: string; + token?: TokenAmount; + paymentToken?: TokenAmount; + } + > + | ActivityData< + | 'swap' + | 'bridge' + | 'convert' + | 'lendingDeposit' + | 'lendingWithdrawal' + | 'wrap' + | 'unwrap', + { + from?: string; + sourceToken?: TokenAmount; + destinationToken?: TokenAmount; + fees?: Fee[]; + } + > + | ActivityData< + 'buy' | 'claim' | 'deposit' | 'claimMusdBonus', + { + from?: string; + token?: TokenAmount; + } + > + | ActivityData< + 'perpsAddFunds' | 'perpsWithdraw', + { + from?: string; + fiat?: FiatAmount; + networkFee?: FiatAmount; + token?: TokenAmount; + } + > + | ActivityData< + | 'stake' + | 'unstake' + | 'sell' + | 'contractDeployment' + | 'smartAccountUpgrade' + | 'predictionsAddFunds' + | 'predictionsWithdrawFunds' + | 'predictionClaimWinnings' + | 'predictionCashedOut' + | 'predictionPlaced' + | 'perpsOpenLong' + | 'perpsCloseLong' + | 'perpsCloseLongLiquidated' + | 'perpsCloseLongStopLoss' + | 'perpsOpenShort' + | 'perpsCloseShort' + | 'perpsCloseShortLiquidated' + | 'perpsCloseShortStopLoss' + | 'perpsPaidFundingFees' + | 'perpsReceivedFundingFees' + | 'perpsCloseShortTakeProfit' + | 'perpsCloseLongTakeProfit' + | PerpsOrderKind, + { + from?: string; + to?: string; + token?: TokenAmount; + sourceToken?: TokenAmount; + destinationToken?: TokenAmount; + fees?: Fee[]; + } + > + | ActivityData< + 'contractInteraction', + { + from: string; + to: string; + token?: TokenAmount; + fees?: Fee[]; + methodId?: string; + transactionCategory?: string; + transactionProtocol?: string; + } + > + | ActivityData< + 'rampBuy' | 'rampSell', + { + from?: string; + fiat?: FiatAmount; + token?: TokenAmount; + fees?: Fee[]; + provider?: { + id?: string; + name?: string; + orderLink?: string; + }; + statusDescription?: string; + paymentDetails?: RampOrderPaymentDetail[]; + // Stable identifier for orders that may not have a hash yet (e.g. a + // ramp order pending fiat settlement, where `hash` is empty until it + // settles on-chain). Lives in `data` as a ramp-specific property. + id?: string; + } + >; + +// Note: Update core-backend +export type ValueTransfer = _ValueTransfer & { + contractAddress: string; + symbol: string; + name: string; +}; diff --git a/packages/client-utils/test/fixtures/api-transactions.ts b/packages/client-utils/test/fixtures/api-transactions.ts new file mode 100644 index 00000000000..7162d89e6a0 --- /dev/null +++ b/packages/client-utils/test/fixtures/api-transactions.ts @@ -0,0 +1,1273 @@ +import type { V1TransactionByHashResponse } from '@metamask/core-backend'; + +const addresses = { + subjectAddress: '0x9bed78535d6a03a955f1504aadba974d9a29e292', + baseUsdc: '0x833589fcd6edb6e08f4c7c32d4f71b54bda02913', + mainnetUsdc: '0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48', + baseAaveUsdc: '0x4e65fe4dba92790696d040ac24aa414708f5c0ab', + baseAavePool: '0xa238dd80c259a72e81d7e4664a9801593f98d1c5', + baseRecipientAddress: '0x6fdb1e9d93c1279177b00baaf44524055455e92e', + lineaMusd: '0xaca92e438df0b2401ff60da7e4337b687a2435da', + lineaSenderAddress: '0xf70da97812cb96acdf810712aa562db8dfa3dbef', + exchangeRecipient: '0x3913a8aca88c946284abbe7ab2ed671c6603de20', + metamaskBonusContract: '0x3ef3d8ba38ebe18db133cec108f4d14ce00dd9ae', + bscContractCallerAddress: '0xf70da97812cb96acdf810712aa562db8dfa3dbef', + bscUniversalRouter: '0xca11bde05977b3631167028862be2a173976ca11', + bscRecipientAddress: '0xb92fe925dc43a0ecde6c8b1a2709c170ec4fff4f', + polygonRecipientAddress: '0x2cd071562a1688b3e9f31be39c92aa140a1acc94', + wethContractAddress: '0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2', + zeroAddress: '0x0000000000000000000000000000000000000000', + aggregatorAddress: '0x0a2854fbbd9b3ef66f17d47284e7f899b9509330', + nftRecipientAddress: '0x4f5243ceea96cee1da0fdb89c756d0e999439424', + nftBuyerAddress: '0x699e414873f56c7bb60e54ad63d3bb7b283874df', + nftSellerAddress: '0x107b2e855528f344556f8c766a6187326a2c2fa6', + openseaSellerAddress: '0xe321bd63cde8ea046b382f82964575f2a5586474', + lifiSwapperAddress: '0xe321bd63cde8ea046b382f82964575f2a5586474', + nftSaleBuyerAddress: '0x78c87da124bb36a914ff1c0f2d642f47870c997c', + mainnetUsdt: '0xdac17f958d2ee523a2206206994597c13d831ec7', + nftContractAddress: '0x239fd4b0c4db49fa8660e65b97619d43d0e0a79d', + stakingContractAddress: '0x00000000219ab540356cbb839cbe05303d7705fa', + lidoStEth: '0xae7ab96520de3a18e5e111b5eaab095312d7fe84', + zeroValueSendRecipient: '0x0c54fccd2e384b4bb6f2e405bf5cbc15a017aafb', + arbitrumUsdt: '0xfd086bc7cd5c481dcc9c85ebe478a1c0b69fcbb9', + acrossExchangeRecipient: '0x7a70cb77a12fa2dc3fa1bc1dcbca4c79db71a289', +} as const; + +const transactions = { + nftPurchaseErc1155: { + hash: '0x8719dadd883779624845106e61fd94af234411c30d73184a72f4daf1425c4595', + timestamp: '2026-06-04T19:31:47.000Z', + chainId: 1, + accountId: 'eip155:1:0x699e414873f56c7bb60e54ad63d3bb7b283874df', + blockNumber: 25246129, + blockHash: + '0x3f6354ab0733a6f760eadbcaadc8502aa70a334ea12a2253a64cb15dc002a06d', + gas: 209197, + gasUsed: 137209, + gasPrice: '2488839994', + effectiveGasPrice: '2488839994', + nonce: 70, + cumulativeGasUsed: 2529723, + methodId: '0x00000000', + value: '89992880000000', + to: '0x0000000000000068f116a894984e2db1123eb395', + from: '0x699e414873f56c7bb60e54ad63d3bb7b283874df', + isError: false, + valueTransfers: [ + { + from: '0x107b2e855528f344556f8c766a6187326a2c2fa6', + to: '0x699e414873f56c7bb60e54ad63d3bb7b283874df', + amount: 1, + tokenId: '57', + contractAddress: '0x6fad73936527d2a82aea5384d252462941b44042', + symbol: '', + name: 'FLUF World: Scenes and Sounds', + transferType: 'erc1155', + }, + { + from: '0x699e414873f56c7bb60e54ad63d3bb7b283874df', + to: '0x0000000000000068f116a894984e2db1123eb395', + amount: '89992880000000', + decimal: 18, + symbol: 'ETH', + name: 'Ether', + transferType: 'normal', + }, + ], + logs: [], + transactionType: 'GENERIC_CONTRACT_CALL', + transactionCategory: 'CONTRACT_CALL', + readable: 'Unidentified Transaction', + readableExtended: 'Unidentified Transaction', + }, + lineaAaveUsdcSendWithRebaseCredit: { + hash: '0xdbd832950b40d6242f99176e8f263473670e6a39ddb00580dfcda67772cc6aae', + timestamp: '2026-05-06T13:32:51.000Z', + chainId: 59144, + accountId: 'eip155:59144:0x699e414873f56c7bb60e54ad63d3bb7b283874df', + blockNumber: 30529877, + blockHash: + '0xdc81c3e6834e5697830aa6cb4bc68ea744f38d72718e080f948c9c2c8d17fb51', + gas: 120484, + gasUsed: 118377, + gasPrice: '50000011', + effectiveGasPrice: '50000011', + nonce: 43, + cumulativeGasUsed: 139377, + methodId: '0xa9059cbb', + value: '0', + to: '0x374d7860c4f2f604de0191298dd393703cce84f3', + from: '0x699e414873f56c7bb60e54ad63d3bb7b283874df', + isError: false, + valueTransfers: [ + { + from: '0x0000000000000000000000000000000000000000', + to: '0x699e414873f56c7bb60e54ad63d3bb7b283874df', + amount: '13344', + decimal: 6, + contractAddress: '0x374d7860c4f2f604de0191298dd393703cce84f3', + symbol: 'aLinUSDC', + name: 'Aave Linea USDC', + transferType: 'erc20', + iconUrl: + 'https://static.cx.metamask.io/api/v1/tokenIcons/59144/0x374d7860c4f2f604de0191298dd393703cce84f3.png', + }, + { + from: '0x699e414873f56c7bb60e54ad63d3bb7b283874df', + to: '0xed8799cd90c48f62d0b4f1bb00876b03f0b71c91', + amount: '419402', + decimal: 6, + contractAddress: '0x374d7860c4f2f604de0191298dd393703cce84f3', + symbol: 'aLinUSDC', + name: 'Aave Linea USDC', + transferType: 'erc20', + iconUrl: + 'https://static.cx.metamask.io/api/v1/tokenIcons/59144/0x374d7860c4f2f604de0191298dd393703cce84f3.png', + }, + ], + logs: [], + transactionProtocol: 'ERC_20', + transactionCategory: 'TRANSFER', + transactionType: 'ERC_20_TRANSFER', + readable: 'Sent aLinUSDC', + readableExtended: 'Sent 0.4194 aLinUSDC', + }, + lifiLineaUsdcEthExchange: { + hash: '0x3ac43e7c4a1a4421304ada43b41acec4d71ad90abfa418e97e92540a26eef0a2', + timestamp: '2026-01-16T21:09:00.000Z', + chainId: 59144, + from: '0xe321bd63cde8ea046b382f82964575f2a5586474', + to: '0xde1e598b81620773454588b85d6b5d4eec32573e', + methodId: '0x2c57e884', + value: '0', + isError: false, + gasUsed: 341413, + effectiveGasPrice: '34544851', + transactionCategory: 'EXCHANGE', + transactionType: 'LIFI_EXCHANGE', + transactionProtocol: 'LIFI', + valueTransfers: [ + { + from: '0xe5d7c2a44ffddf6b295a15c148167daaaf5cf34f', + to: '0x0a354845411cc1212cfb33acc6a52fcd4a80e3ae', + amount: '2388594176642019', + decimal: 18, + symbol: 'ETH', + transferType: 'internal', + }, + { + from: '0x0a354845411cc1212cfb33acc6a52fcd4a80e3ae', + to: '0xde1e598b81620773454588b85d6b5d4eec32573e', + amount: '2388594176642019', + decimal: 18, + symbol: 'ETH', + transferType: 'internal', + }, + { + from: '0xde1e598b81620773454588b85d6b5d4eec32573e', + to: '0xe321bd63cde8ea046b382f82964575f2a5586474', + amount: '2388594176642019', + decimal: 18, + symbol: 'ETH', + transferType: 'internal', + }, + { + from: '0xe321bd63cde8ea046b382f82964575f2a5586474', + to: '0xde1e598b81620773454588b85d6b5d4eec32573e', + amount: '7934205', + decimal: 6, + contractAddress: '0x176211869ca2b568f2a7d4ee941e073a821ee1ff', + symbol: 'USDC', + transferType: 'erc20', + }, + { + from: '0xde1e598b81620773454588b85d6b5d4eec32573e', + to: '0xa4a24bdd4608d7dfc496950850f9763b674f0db2', + amount: '87275', + decimal: 6, + contractAddress: '0x176211869ca2b568f2a7d4ee941e073a821ee1ff', + symbol: 'USDC', + transferType: 'erc20', + }, + { + from: '0xde1e598b81620773454588b85d6b5d4eec32573e', + to: '0x0a354845411cc1212cfb33acc6a52fcd4a80e3ae', + amount: '7846929', + decimal: 6, + contractAddress: '0x176211869ca2b568f2a7d4ee941e073a821ee1ff', + symbol: 'USDC', + transferType: 'erc20', + }, + { + from: '0x2848973568af7c89dac4321cb3c270a49ed242cc', + to: '0xf359e3e29b20041511a48aa257ecf5a56951a3da', + amount: '7846976', + decimal: 6, + contractAddress: '0xa219439258ca9da29e9cc4ce5596924745e12b93', + symbol: 'USDT', + transferType: 'erc20', + }, + { + from: '0x0a354845411cc1212cfb33acc6a52fcd4a80e3ae', + to: '0x2848973568af7c89dac4321cb3c270a49ed242cc', + amount: '7846929', + decimal: 6, + contractAddress: '0x176211869ca2b568f2a7d4ee941e073a821ee1ff', + symbol: 'USDC', + transferType: 'erc20', + }, + { + from: '0xf359e3e29b20041511a48aa257ecf5a56951a3da', + to: '0x0a354845411cc1212cfb33acc6a52fcd4a80e3ae', + amount: '2388594176642019', + decimal: 18, + contractAddress: '0xe5d7c2a44ffddf6b295a15c148167daaaf5cf34f', + symbol: 'WETH', + transferType: 'erc20', + }, + { + from: '0x0a354845411cc1212cfb33acc6a52fcd4a80e3ae', + to: '0x0000000000000000000000000000000000000000', + amount: '2388594176642019', + decimal: 18, + contractAddress: '0xe5d7c2a44ffddf6b295a15c148167daaaf5cf34f', + symbol: 'WETH', + transferType: 'erc20', + }, + { + from: '0xde1e598b81620773454588b85d6b5d4eec32573e', + to: '0xe321bd63cde8ea046b382f82964575f2a5586474', + amount: '1', + decimal: 6, + contractAddress: '0x176211869ca2b568f2a7d4ee941e073a821ee1ff', + symbol: 'USDC', + transferType: 'erc20', + }, + ], + logs: [], + }, + openseaNftSaleWeth: { + hash: '0x0e7f29fa4af73f3708a7383a2fa8d0e09f6c6bf8a176bccf3a6b3259e2886bae', + timestamp: '2026-01-14T21:50:29.000Z', + chainId: 8453, + accountId: 'eip155:8453:0xe321bd63cde8ea046b382f82964575f2a5586474', + blockNumber: 40819041, + blockHash: + '0xb01189d473edf852620d176a4abe46a999a3ce58d3e4ea3dca0125811421b8cf', + gas: 287257, + gasUsed: 180293, + gasPrice: '2878406', + effectiveGasPrice: '2878406', + nonce: 731, + cumulativeGasUsed: 7133440, + methodId: '0xe7acab24', + value: '0', + to: '0x0000000000000068f116a894984e2db1123eb395', + from: '0xe321bd63cde8ea046b382f82964575f2a5586474', + isError: false, + valueTransfers: [ + { + from: '0xbaf3ad6542f932cc0e0b54983e82e0cfb7c5a5a1', + to: '0xe321bd63cde8ea046b382f82964575f2a5586474', + amount: '1600000000000000', + decimal: 18, + contractAddress: '0x4200000000000000000000000000000000000006', + symbol: 'WETH', + name: 'Wrapped Ether', + transferType: 'erc20', + iconUrl: + 'https://static.cx.metamask.io/api/v1/tokenIcons/8453/0x4200000000000000000000000000000000000006.png', + }, + { + from: '0xe321bd63cde8ea046b382f82964575f2a5586474', + to: '0xbaf3ad6542f932cc0e0b54983e82e0cfb7c5a5a1', + tokenId: '327437', + symbol: 'WPLT', + name: 'The Warplets', + contractAddress: '0x699727f9e01a822efdcf7333073f0461e5914b4e', + transferType: 'erc721', + iconUrl: + 'https://static.cx.metamask.io/api/v1/tokenIcons/8453/0x699727f9e01a822efdcf7333073f0461e5914b4e.png', + }, + { + from: '0xe321bd63cde8ea046b382f82964575f2a5586474', + to: '0x0000a26b00c1f0df003000390027140000faa719', + amount: '16000000000000', + decimal: 18, + contractAddress: '0x4200000000000000000000000000000000000006', + symbol: 'WETH', + name: 'Wrapped Ether', + transferType: 'erc20', + iconUrl: + 'https://static.cx.metamask.io/api/v1/tokenIcons/8453/0x4200000000000000000000000000000000000006.png', + }, + ], + logs: [], + toAddressName: 'OPENSEA_SEAPORT', + transactionProtocol: 'OPENSEA', + transactionCategory: 'NFT_EXCHANGE', + transactionType: 'OPENSEA_V1.6_NFT_EXCHANGE', + readable: 'Exchanged NFT', + readableExtended: 'Exchanged NFT', + }, + + mapsAnErc20TransferSent: { + timestamp: '2026-05-12T13:37:47.000Z', + chainId: 8453, + from: addresses.subjectAddress, + to: addresses.baseUsdc, + transactionCategory: 'TRANSFER', + valueTransfers: [ + { + from: addresses.subjectAddress, + to: addresses.baseRecipientAddress, + symbol: 'USDC', + }, + ], + }, + mapsANativeValueContractCall: { + hash: '0x64d2f26c261178252fcad9dbb665cf40337b827a582066553dd6634eaeea9f0a', + timestamp: '2026-05-19T19:27:12.000Z', + chainId: 137, + from: addresses.subjectAddress, + to: addresses.polygonRecipientAddress, + methodId: null, + transactionCategory: 'CONTRACT_CALL', + transactionType: 'GENERIC_CONTRACT_CALL', + value: '100000000000000000', + valueTransfers: [ + { + from: addresses.subjectAddress, + to: addresses.polygonRecipientAddress, + amount: '100000000000000000', + decimal: 18, + symbol: 'MATIC', + transferType: 'normal', + }, + ], + }, + mapsAnApprovalWithoutValueTransfers: { + hash: '0x91f89897197afcc09ad98ec4282366fd7938d8a9609e4fc2a0aa2d070664bc27', + timestamp: '2026-05-27T13:20:27.000Z', + chainId: 8453, + methodId: '0x095ea7b3', + value: '0', + to: addresses.baseUsdc, + from: addresses.subjectAddress, + isError: false, + valueTransfers: [], + logs: [], + transactionProtocol: 'ERC_20', + transactionCategory: 'APPROVE', + transactionType: 'ERC_20_APPROVE', + }, + fallsBackToValueTransferContract: { + hash: '0x91f89897197afcc09ad98ec4282366fd7938d8a9609e4fc2a0aa2d070664bc27', + timestamp: '2026-05-27T13:20:27.000Z', + chainId: 59144, + methodId: '0x095ea7b3', + value: '0', + to: '0x23', + from: addresses.subjectAddress, + isError: false, + valueTransfers: [ + { + contractAddress: addresses.lineaMusd, + symbol: 'mUSD', + decimal: 18, + transferType: 'erc20', + }, + ], + transactionCategory: 'APPROVE', + transactionType: 'ERC_20_APPROVE', + }, + mapsAnApprovalWithNeitherA: { + hash: '0xapprovenoaddr', + timestamp: '2026-05-27T13:20:27.000Z', + chainId: 59144, + methodId: '0x095ea7b3', + value: '0', + to: '0x23', + from: addresses.subjectAddress, + isError: false, + valueTransfers: [], + transactionCategory: 'APPROVE', + transactionType: 'ERC_20_APPROVE', + }, + mapsAnErc20TransferReceived: { + timestamp: '2026-05-05T12:15:27.000Z', + chainId: 59144, + from: addresses.lineaSenderAddress, + to: addresses.lineaMusd, + transactionCategory: 'TRANSFER', + valueTransfers: [ + { + from: addresses.lineaSenderAddress, + to: addresses.subjectAddress, + symbol: 'mUSD', + }, + ], + }, + mapsAnExchangeTransactionWithoutA: { + timestamp: '2026-05-05T17:57:53.000Z', + chainId: 59144, + from: addresses.subjectAddress, + to: addresses.subjectAddress, + transactionCategory: 'EXCHANGE', + valueTransfers: [ + { + from: addresses.subjectAddress, + to: addresses.exchangeRecipient, + symbol: 'mUSD', + }, + ], + }, + mapsAnExchangeTransactionWithAn: { + hash: '0x80b974d5834e1047a78332369de3d4b988f0237ff8a418c9464217e55c542f2f', + timestamp: '2026-05-28T01:03:49.000Z', + chainId: 59144, + methodId: '0xe9ae5c53', + value: '0', + to: addresses.subjectAddress, + from: addresses.subjectAddress, + isError: false, + transactionCategory: 'EXCHANGE', + valueTransfers: [ + { + from: addresses.aggregatorAddress, + to: addresses.subjectAddress, + amount: '4894004361763', + decimal: 18, + symbol: 'ETH', + name: 'Ether', + transferType: 'internal', + }, + { + from: addresses.subjectAddress, + to: addresses.aggregatorAddress, + amount: '10000', + decimal: 6, + contractAddress: addresses.lineaMusd, + symbol: 'mUSD', + name: 'MetaMask USD', + transferType: 'erc20', + }, + ], + logs: [], + }, + mapsAnNftSaleWithReceived: { + timestamp: '2026-02-23T22:04:23.000Z', + chainId: 1, + from: addresses.nftSaleBuyerAddress, + to: addresses.subjectAddress, + transactionCategory: 'TRANSFER', + valueTransfers: [ + { + from: addresses.subjectAddress, + to: addresses.nftRecipientAddress, + amount: 1, + tokenId: '984', + symbol: 'BAE', + transferType: 'erc1155', + }, + { + from: addresses.nftSaleBuyerAddress, + to: addresses.subjectAddress, + amount: '1000000000000000', + decimal: 18, + symbol: 'ETH', + transferType: 'normal', + }, + ], + }, + mapsAPlainNftSendWith: { + timestamp: '2026-02-23T22:04:23.000Z', + chainId: 1, + from: addresses.subjectAddress, + to: addresses.nftRecipientAddress, + transactionCategory: 'TRANSFER', + valueTransfers: [ + { + from: addresses.subjectAddress, + to: addresses.nftRecipientAddress, + amount: 1, + tokenId: '984', + symbol: 'BAE', + transferType: 'erc1155', + }, + ], + }, + mapsAnNftPurchasePaidIn: { + timestamp: '2026-06-04T19:31:47.000Z', + chainId: 1, + from: addresses.nftBuyerAddress, + to: '0x0000000000000068f116a894984e2db1123eb395', + transactionCategory: 'CONTRACT_CALL', + valueTransfers: [ + { + from: addresses.nftSellerAddress, + to: addresses.nftBuyerAddress, + amount: 1, + tokenId: '57', + contractAddress: '0x6fad73936527d2a82aea5384d252462941b44042', + name: 'FLUF World: Scenes and Sounds', + transferType: 'erc1155', + }, + { + from: addresses.nftBuyerAddress, + to: addresses.nftSellerAddress, + amount: '89992880000000', + decimal: 18, + symbol: 'WETH', + contractAddress: '0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2', + transferType: 'erc20', + }, + ], + }, + mapsAnNftTransferReceivedWithout: { + timestamp: '2026-06-04T19:31:47.000Z', + chainId: 1, + from: addresses.nftSellerAddress, + to: addresses.subjectAddress, + transactionCategory: 'TRANSFER', + valueTransfers: [ + { + from: addresses.nftSellerAddress, + to: addresses.subjectAddress, + amount: 1, + tokenId: '57', + contractAddress: '0x6fad73936527d2a82aea5384d252462941b44042', + name: 'FLUF World', + transferType: 'erc1155', + }, + ], + }, + nftReceiveWithUnrelatedNativeSend: { + timestamp: '2026-06-04T19:31:47.000Z', + chainId: 1, + from: addresses.nftBuyerAddress, + to: '0x0000000000000068f116a894984e2db1123eb395', + transactionCategory: 'CONTRACT_CALL', + valueTransfers: [ + { + from: addresses.nftSellerAddress, + to: addresses.nftBuyerAddress, + amount: 1, + tokenId: '57', + contractAddress: '0x6fad73936527d2a82aea5384d252462941b44042', + name: 'FLUF World', + transferType: 'erc1155', + }, + { + from: addresses.nftBuyerAddress, + to: '0x1111111111111111111111111111111111111111', + amount: '89992880000000', + decimal: 18, + symbol: 'ETH', + name: 'Ether', + transferType: 'normal', + }, + ], + }, + mapsAPlainNftSendNo: { + timestamp: '2026-02-23T22:04:23.000Z', + chainId: 1, + from: addresses.subjectAddress, + to: addresses.nftRecipientAddress, + transactionCategory: 'CONTRACT_CALL', + methodId: '0x12345678', + valueTransfers: [ + { + from: addresses.subjectAddress, + to: addresses.nftRecipientAddress, + amount: 1, + tokenId: '984', + contractAddress: '0x239fd4b0c4db49fa8660e65b97619d43d0e0a79d', + symbol: 'BAE', + transferType: 'erc721', + }, + ], + }, + mapsAnNftMintTransferTo: { + hash: '0x25805d4ae16935e6fa92add9dcee97db0127749d4244032a79489098a880210c', + timestamp: '2026-05-13T14:34:23.000Z', + chainId: 59144, + from: addresses.zeroAddress, + to: addresses.subjectAddress, + transactionCategory: 'TRANSFER', + valueTransfers: [ + { + from: addresses.zeroAddress, + to: addresses.subjectAddress, + contractAddress: addresses.nftContractAddress, + tokenId: '1', + symbol: 'TDN', + transferType: 'erc721', + }, + ], + }, + mapsAnAaveSupplyContractCall: { + hash: '0x08d14578168f22001e95503469c63613bd9f3d3f60e81dbbf204fbd21f484bd9', + timestamp: '2026-05-13T03:31:29.000Z', + chainId: 8453, + from: addresses.subjectAddress, + to: addresses.baseAavePool, + methodId: '0x617ba037', + transactionCategory: 'CONTRACT_CALL', + transactionType: 'GENERIC_CONTRACT_CALL', + valueTransfers: [ + { + from: '0x0000000000000000000000000000000000000000', + to: addresses.subjectAddress, + amount: '99999', + decimal: 6, + contractAddress: addresses.baseAaveUsdc, + symbol: 'aBasUSDC', + }, + { + from: addresses.subjectAddress, + to: addresses.baseAaveUsdc, + amount: '100000', + decimal: 6, + contractAddress: addresses.baseUsdc, + symbol: 'USDC', + }, + ], + }, + mapsAnAaveWithdrawWithA: { + hash: '0x26f4911467b538702c0945e4ec5e303de44c0c1c174897141d1b548ea3161795', + timestamp: '2026-05-27T14:47:14.000Z', + chainId: 8453, + from: addresses.subjectAddress, + to: addresses.baseAavePool, + methodId: '0x69328dec', + transactionCategory: 'WITHDRAW', + transactionType: 'GENERIC_CONTRACT_CALL', + valueTransfers: [ + { + from: addresses.subjectAddress, + to: addresses.baseAaveUsdc, + amount: '100000', + decimal: 6, + contractAddress: addresses.baseAaveUsdc, + symbol: 'aBasUSDC', + }, + { + from: addresses.baseAavePool, + to: addresses.subjectAddress, + amount: '200000', + decimal: 6, + contractAddress: addresses.baseUsdc, + symbol: 'USDC', + }, + ], + }, + mapsADepositWithoutAnInbound: { + hash: '0xabc123deposit00000000000000000000000000000000000000000000000001', + timestamp: '2026-05-12T13:37:47.000Z', + chainId: 1, + from: addresses.subjectAddress, + to: addresses.stakingContractAddress, + transactionCategory: 'DEPOSIT', + valueTransfers: [ + { + from: addresses.subjectAddress, + to: addresses.stakingContractAddress, + amount: '1000000000000000000', + decimal: 18, + symbol: 'ETH', + transferType: 'normal', + }, + ], + }, + // Real Lido stETH stake response captured from the transactions API. + mapsALidoStakeToA: { + hash: '0xd8ca1456ed6305ec3d9c058f28a1ba48eb335ffcffd7d7c4321d3169c29e6a07', + timestamp: '2026-07-01T13:36:23.000Z', + chainId: 1, + accountId: 'eip155:1:0x9bed78535d6a03a955f1504aadba974d9a29e292', + blockNumber: 25438003, + blockHash: + '0x536c1a1e521c3bc41efac41b418efd9e2bba49bf71eec4b06c5428e36691e1f4', + gas: 131071, + gasUsed: 76670, + gasPrice: '2371985355', + effectiveGasPrice: '2371985355', + nonce: 224, + cumulativeGasUsed: 18700942, + methodId: '0xa1903eab', + value: '1000000000000', + to: addresses.lidoStEth, + from: addresses.subjectAddress, + isError: false, + valueTransfers: [ + { + from: '0x0000000000000000000000000000000000000000', + to: addresses.subjectAddress, + amount: '999999999999', + decimal: 18, + contractAddress: addresses.lidoStEth, + symbol: 'stETH', + name: 'Liquid staked Ether 2.0', + transferType: 'erc20', + }, + { + from: addresses.subjectAddress, + to: addresses.lidoStEth, + amount: '1000000000000', + decimal: 18, + symbol: 'ETH', + name: 'Ether', + transferType: 'normal', + }, + ], + logs: [], + transactionType: 'GENERIC_CONTRACT_CALL', + transactionCategory: 'CONTRACT_CALL', + readable: 'Unidentified Transaction', + readableExtended: 'Unidentified Transaction', + }, + mapsAWethDepositToA: { + hash: '0x6e448f5b8cf55534507770c1cb90ba14e723d03b4a46b4919a5847eb8d13b7b5', + timestamp: '2026-05-28T13:42:23.000Z', + chainId: 1, + from: addresses.subjectAddress, + to: addresses.wethContractAddress, + methodId: '0xd0e30db0', + transactionCategory: 'DEPOSIT', + transactionProtocol: 'WETH', + transactionType: 'WETH_DEPOSIT', + valueTransfers: [ + { + from: '0x0000000000000000000000000000000000000000', + to: addresses.subjectAddress, + amount: '1000000000000', + decimal: 18, + contractAddress: addresses.wethContractAddress, + symbol: 'WETH', + transferType: 'erc20', + }, + { + from: addresses.subjectAddress, + to: addresses.wethContractAddress, + amount: '1000000000000', + decimal: 18, + symbol: 'ETH', + transferType: 'normal', + }, + ], + }, + mapsAWethWithdrawalToAn: { + hash: '0x8f2a1c9e4b7d30651234567890abcdef1234567890abcdef1234567890abcdef', + timestamp: '2026-05-28T14:15:00.000Z', + chainId: 1, + from: addresses.subjectAddress, + to: addresses.wethContractAddress, + methodId: '0x2e1a7d4d', + transactionCategory: 'UNWRAP', + transactionProtocol: 'WETH', + transactionType: 'WETH_WITHDRAW', + valueTransfers: [ + { + from: addresses.subjectAddress, + to: addresses.wethContractAddress, + amount: '1000000000000', + decimal: 18, + contractAddress: addresses.wethContractAddress, + symbol: 'WETH', + transferType: 'erc20', + }, + { + from: addresses.wethContractAddress, + to: addresses.subjectAddress, + amount: '1000000000000', + decimal: 18, + symbol: 'ETH', + transferType: 'normal', + }, + ], + }, + mapsAMetamaskMusdBonusClaim: { + hash: '0x875ded271a40278391fca5d71892231afd0cb9592f31bdf3b7c949906cb982c4', + timestamp: '2026-05-13T00:48:45.000Z', + chainId: 59144, + from: addresses.subjectAddress, + to: addresses.metamaskBonusContract, + transactionCategory: 'CLAIM_BONUS', + valueTransfers: [ + { + from: addresses.metamaskBonusContract, + to: addresses.subjectAddress, + contractAddress: addresses.lineaMusd, + symbol: 'mUSD', + }, + ], + }, + mapsAGenericClaimWithA: { + hash: '0xclaim', + timestamp: '2026-05-13T00:48:45.000Z', + chainId: 59144, + from: addresses.subjectAddress, + to: addresses.metamaskBonusContract, + transactionCategory: 'CLAIM', + valueTransfers: [ + { + from: addresses.metamaskBonusContract, + to: addresses.subjectAddress, + amount: '5', + decimal: 6, + contractAddress: addresses.lineaMusd, + symbol: 'mUSD', + }, + ], + }, + mapsAGenericClaimWithOnly: { + hash: '0xclaimout', + timestamp: '2026-05-13T00:48:45.000Z', + chainId: 59144, + from: addresses.subjectAddress, + to: addresses.metamaskBonusContract, + transactionCategory: 'CLAIM', + valueTransfers: [ + { + from: addresses.subjectAddress, + to: addresses.metamaskBonusContract, + amount: '5', + decimal: 6, + contractAddress: addresses.lineaMusd, + symbol: 'mUSD', + }, + ], + }, + mapsABridgeWithdrawToA: { + hash: '0x9f81163d00374094411f44732738c6dea194551e4500bde9fd7ee60319aac766', + timestamp: '2026-05-28T04:13:31.000Z', + chainId: 8453, + gasUsed: '0x24405', + effectiveGasPrice: '0x6fc23ac1d', + methodId: '0xe9ae5c53', + value: '0', + to: '0x9bed78535d6a03a955f1504aadba974d9a29e292', + from: '0x9bed78535d6a03a955f1504aadba974d9a29e292', + isError: false, + valueTransfers: [ + { + from: '0x9bed78535d6a03a955f1504aadba974d9a29e292', + to: '0xa5c1ce365ddb5a91ff466774ec4bdf8f97cb9f55', + amount: '100000', + decimal: 6, + contractAddress: '0x833589fcd6edb6e08f4c7c32d4f71b54bda02913', + symbol: 'USDC', + name: 'USD Coin', + transferType: 'erc20', + }, + ], + logs: [], + transactionCategory: 'BRIDGE_WITHDRAW', + transactionProtocol: 'ACROSS', + transactionType: 'ACROSS_BRIDGE_WITHDRAW', + }, + mapsAnUnrecognizedTransactionCategoryTo: { + timestamp: '2026-05-12T16:04:40.000Z', + chainId: 56, + from: addresses.bscContractCallerAddress, + to: addresses.bscUniversalRouter, + methodId: '0x174dea71', + transactionCategory: 'CONTRACT_CALL', + transactionProtocol: 'GENERIC', + transactionType: 'GENERIC_CONTRACT_CALL', + valueTransfers: [ + { + from: addresses.subjectAddress, + to: addresses.bscRecipientAddress, + symbol: 'BNB', + }, + ], + }, + mapsTheReportedGenericContractCall: { + hash: '0xd206cc6c16974409bae072ce4cd1559743041af40c2bae84775a0bbb4dff5fee', + timestamp: '2026-05-01T13:39:47.000Z', + chainId: 1, + from: addresses.subjectAddress, + to: addresses.subjectAddress, + methodId: '0xe9ae5c53', + value: '0', + transactionCategory: 'CONTRACT_CALL', + transactionType: 'GENERIC_CONTRACT_CALL', + valueTransfers: [ + { + from: addresses.subjectAddress, + to: '0x4cd00e387622c35bddb9b4c962c136462338bc31', + amount: '580060', + decimal: 6, + contractAddress: addresses.mainnetUsdc, + symbol: 'USDC', + name: 'USD Coin', + transferType: 'erc20', + }, + ], + }, + mapsAContractCallContractCall: { + hash: '0xcontractswap', + timestamp: '2026-05-01T13:39:47.000Z', + chainId: 1, + from: addresses.subjectAddress, + to: '0xrouter', + methodId: '0xabcdef12', + value: '0', + transactionCategory: 'CONTRACT_CALL', + transactionType: 'GENERIC_CONTRACT_CALL', + valueTransfers: [ + { + from: addresses.subjectAddress, + to: '0xrouter', + amount: '580060', + decimal: 6, + contractAddress: addresses.mainnetUsdc, + symbol: 'USDC', + transferType: 'erc20', + }, + { + from: '0xrouter', + to: addresses.subjectAddress, + amount: '1000000000000000', + decimal: 18, + symbol: 'DAI', + transferType: 'erc20', + }, + ], + }, + mapsAFailedTransactionToA: { + hash: '0xfailed', + timestamp: '2026-05-12T13:37:47.000Z', + chainId: 8453, + from: addresses.subjectAddress, + to: addresses.baseRecipientAddress, + isError: true, + transactionCategory: 'TRANSFER', + valueTransfers: [ + { + from: addresses.subjectAddress, + to: addresses.baseRecipientAddress, + symbol: 'USDC', + }, + ], + }, + mapsAStandardTransactionOnA: { + timestamp: '2026-05-12T13:37:47.000Z', + chainId: 4657, + from: addresses.subjectAddress, + to: addresses.baseRecipientAddress, + transactionCategory: 'STANDARD', + value: '1000000000000000000', + valueTransfers: [], + }, + mapsAnApproveWithOnlyAn: { + hash: '0xrevoke', + timestamp: '2026-05-12T13:37:47.000Z', + chainId: 1, + from: '0x1111111111111111111111111111111111111111', + to: addresses.mainnetUsdc, + transactionCategory: 'APPROVE', + valueTransfers: [ + { + from: '0x1111111111111111111111111111111111111111', + to: addresses.subjectAddress, + contractAddress: addresses.mainnetUsdc, + transferType: 'erc20', + symbol: 'USDC', + }, + ], + }, + mapsAnApproveForAKnown: { + hash: '0xknownapprove', + timestamp: '2026-05-12T13:37:47.000Z', + chainId: 1, + from: addresses.subjectAddress, + to: addresses.mainnetUsdt, + transactionCategory: 'APPROVE', + valueTransfers: [], + }, + mapsAnUnrecognizedCategoryWithOnly: { + hash: '0xmystery', + timestamp: '2026-05-12T13:37:47.000Z', + chainId: 1, + from: '0x1111111111111111111111111111111111111111', + to: '0x2222222222222222222222222222222222222222', + methodId: '0x12345678', + transactionCategory: 'MYSTERY', + valueTransfers: [ + { + from: '0x2222222222222222222222222222222222222222', + to: addresses.subjectAddress, + amount: '12345', + decimal: 6, + contractAddress: addresses.mainnetUsdc, + symbol: 'USDC', + transferType: 'erc20', + }, + ], + }, + mapsAContractCallWithNoTransfers: { + hash: '0xnotransfers', + timestamp: '2026-05-12T13:37:47.000Z', + chainId: 1, + from: addresses.subjectAddress, + to: '0x2222222222222222222222222222222222222222', + methodId: '0xdeadbeef', + transactionCategory: 'CONTRACT_CALL', + valueTransfers: [], + }, + mapsAZeroValueStandardSendWithoutTransfers: { + hash: '0x062497f6874582f5d14e65510606a849d6fe8d0ea468c12907452e235c6b5201', + timestamp: '2026-07-29T01:22:23.000Z', + chainId: 1, + accountId: 'eip155:1:0x9bed78535d6a03a955f1504aadba974d9a29e292', + blockNumber: 25635173, + blockHash: + '0x58761bcd4278bd5ddf326648b294101b6e6e00c3a7e7ca4235212cb4ef4b00cb', + gas: 21000, + gasUsed: 21000, + gasPrice: '2148922470', + effectiveGasPrice: '2148922470', + nonce: 244, + cumulativeGasUsed: 1003926, + methodId: null, + value: '0', + to: addresses.zeroValueSendRecipient, + from: addresses.subjectAddress, + isError: false, + valueTransfers: [], + logs: [], + transactionType: 'STANDARD', + transactionCategory: 'STANDARD', + readable: 'Sent', + readableExtended: 'Sent', + }, + mapsAStandardInboundWithoutTransfers: { + hash: '0xstandardinboundwithouttransfers', + timestamp: '2026-05-12T13:37:47.000Z', + chainId: 1, + from: '0x1111111111111111111111111111111111111111', + to: addresses.subjectAddress, + transactionCategory: 'STANDARD', + value: '1000000000000000000', + valueTransfers: [], + isError: false, + gasUsed: 21000, + effectiveGasPrice: '1', + }, + mapsAnAcrossUsdtExchangeWithNativeFee: { + hash: '0x34bbaa01262f2e9221913316f4548a4b5981e05ee338ea586fc267a6868f9526', + timestamp: '2026-07-28T10:29:49.000Z', + chainId: 42161, + accountId: 'eip155:42161:0x9bed78535d6a03a955f1504aadba974d9a29e292', + blockNumber: 488572152, + blockHash: + '0xf9742fb12fa16b42fb0dbc043bfe3bc0437448cab7da0419d425e29cf01f6dfd', + gas: 649231, + gasUsed: 424977, + gasPrice: '20166000', + effectiveGasPrice: '20166000', + nonce: 108, + cumulativeGasUsed: 424977, + methodId: '0xe9ae5c53', + value: '0', + to: addresses.subjectAddress, + from: addresses.subjectAddress, + isError: false, + valueTransfers: [ + { + from: addresses.subjectAddress, + to: addresses.acrossExchangeRecipient, + amount: '1199957', + decimal: 6, + contractAddress: addresses.arbitrumUsdt, + symbol: 'USDT', + name: 'Tether USD', + transferType: 'erc20', + }, + ], + logs: [], + transactionCategory: 'EXCHANGE', + transactionProtocol: 'ACROSS', + transactionType: 'ACROSS_EXCHANGE', + readable: 'Swapped USDT for', + readableExtended: 'Swapped 1.2 USDT for', + }, +} as const satisfies Record; + +const mapArgs = { + mapsAnErc20TransferSent: { + subjectAddress: addresses.subjectAddress, + transaction: transactions.mapsAnErc20TransferSent, + }, + mapsAnErc20TransferWith: { + get subjectAddress() { + return transactions.lineaAaveUsdcSendWithRebaseCredit.from as string; + }, + transaction: transactions.lineaAaveUsdcSendWithRebaseCredit, + }, + mapsANativeValueContractCall: { + subjectAddress: addresses.subjectAddress, + transaction: transactions.mapsANativeValueContractCall, + }, + mapsAnApprovalWithoutValueTransfers: { + subjectAddress: addresses.subjectAddress, + transaction: transactions.mapsAnApprovalWithoutValueTransfers, + }, + fallsBackToValueTransferContract: { + subjectAddress: addresses.subjectAddress, + transaction: transactions.fallsBackToValueTransferContract, + }, + mapsAnApprovalWithNeitherA: { + subjectAddress: addresses.subjectAddress, + transaction: transactions.mapsAnApprovalWithNeitherA, + }, + mapsAnErc20TransferReceived: { + subjectAddress: addresses.subjectAddress, + transaction: transactions.mapsAnErc20TransferReceived, + }, + mapsAnExchangeTransactionWithoutA: { + subjectAddress: addresses.subjectAddress, + transaction: transactions.mapsAnExchangeTransactionWithoutA, + }, + mapsAnExchangeTransactionWithAn: { + subjectAddress: addresses.subjectAddress, + transaction: transactions.mapsAnExchangeTransactionWithAn, + }, + mapsTheLifiLineaUsdcTo: { + subjectAddress: addresses.lifiSwapperAddress, + transaction: transactions.lifiLineaUsdcEthExchange, + }, + mapsAnNftSaleWithReceived: { + subjectAddress: addresses.subjectAddress, + transaction: transactions.mapsAnNftSaleWithReceived, + }, + mapsAnOpenseaNftSalePaid: { + subjectAddress: addresses.openseaSellerAddress, + transaction: transactions.openseaNftSaleWeth, + }, + mapsAPlainNftSendWith: { + subjectAddress: addresses.subjectAddress, + transaction: transactions.mapsAPlainNftSendWith, + }, + mapsAnNftPurchase: { + subjectAddress: addresses.nftBuyerAddress, + transaction: transactions.nftPurchaseErc1155, + }, + mapsAnNftPurchasePaidIn: { + subjectAddress: addresses.nftBuyerAddress, + transaction: transactions.mapsAnNftPurchasePaidIn, + }, + mapsAnNftTransferReceivedWithout: { + subjectAddress: addresses.subjectAddress, + transaction: transactions.mapsAnNftTransferReceivedWithout, + }, + mapsAnNftReceiveWithUnrelatedNativeSend: { + subjectAddress: addresses.nftBuyerAddress, + transaction: transactions.nftReceiveWithUnrelatedNativeSend, + }, + mapsAPlainNftSendNo: { + subjectAddress: addresses.subjectAddress, + transaction: transactions.mapsAPlainNftSendNo, + }, + mapsAnNftMintTransferTo: { + subjectAddress: addresses.subjectAddress, + transaction: transactions.mapsAnNftMintTransferTo, + }, + mapsAnAaveSupplyContractCall: { + subjectAddress: addresses.subjectAddress, + transaction: transactions.mapsAnAaveSupplyContractCall, + }, + mapsAnAaveWithdrawWithA: { + subjectAddress: addresses.subjectAddress, + transaction: transactions.mapsAnAaveWithdrawWithA, + }, + mapsADepositWithoutAnInbound: { + subjectAddress: addresses.subjectAddress, + transaction: transactions.mapsADepositWithoutAnInbound, + }, + mapsALidoStakeToA: { + subjectAddress: addresses.subjectAddress, + transaction: transactions.mapsALidoStakeToA, + }, + mapsAWethDepositToA: { + subjectAddress: addresses.subjectAddress, + transaction: transactions.mapsAWethDepositToA, + }, + mapsAWethWithdrawalToAn: { + subjectAddress: addresses.subjectAddress, + transaction: transactions.mapsAWethWithdrawalToAn, + }, + mapsAMetamaskMusdBonusClaim: { + subjectAddress: addresses.subjectAddress, + transaction: transactions.mapsAMetamaskMusdBonusClaim, + }, + mapsAGenericClaimWithA: { + subjectAddress: addresses.subjectAddress, + transaction: transactions.mapsAGenericClaimWithA, + }, + mapsAGenericClaimWithOnly: { + subjectAddress: addresses.subjectAddress, + transaction: transactions.mapsAGenericClaimWithOnly, + }, + mapsABridgeWithdrawToA: { + subjectAddress: addresses.subjectAddress, + transaction: transactions.mapsABridgeWithdrawToA, + }, + mapsAnUnrecognizedTransactionCategoryTo: { + subjectAddress: addresses.subjectAddress, + transaction: transactions.mapsAnUnrecognizedTransactionCategoryTo, + }, + mapsTheReportedGenericContractCall: { + subjectAddress: addresses.subjectAddress, + transaction: transactions.mapsTheReportedGenericContractCall, + }, + mapsAContractCallContractCall: { + subjectAddress: addresses.subjectAddress, + transaction: transactions.mapsAContractCallContractCall, + }, + mapsAFailedTransactionToA: { + subjectAddress: addresses.subjectAddress, + transaction: transactions.mapsAFailedTransactionToA, + }, + mapsAStandardTransactionOnA: { + subjectAddress: addresses.subjectAddress, + transaction: transactions.mapsAStandardTransactionOnA, + }, + mapsAnApproveWithOnlyAn: { + subjectAddress: addresses.subjectAddress, + transaction: transactions.mapsAnApproveWithOnlyAn, + }, + mapsAnApproveForAKnown: { + subjectAddress: addresses.subjectAddress, + transaction: transactions.mapsAnApproveForAKnown, + }, + mapsAnUnrecognizedCategoryWithOnly: { + subjectAddress: addresses.subjectAddress, + transaction: transactions.mapsAnUnrecognizedCategoryWithOnly, + }, + mapsAContractCallWithNoTransfers: { + subjectAddress: addresses.subjectAddress, + transaction: transactions.mapsAContractCallWithNoTransfers, + }, + mapsAZeroValueStandardSendWithoutTransfers: { + subjectAddress: addresses.subjectAddress, + transaction: transactions.mapsAZeroValueStandardSendWithoutTransfers, + }, + mapsAStandardInboundWithoutTransfers: { + subjectAddress: addresses.subjectAddress, + transaction: transactions.mapsAStandardInboundWithoutTransfers, + }, + mapsAnAcrossUsdtExchangeWithNativeFee: { + subjectAddress: addresses.subjectAddress, + transaction: transactions.mapsAnAcrossUsdtExchangeWithNativeFee, + }, +} as const; + +export const apiTransactionFixtures = { + addresses, + transactions, + mapArgs, +}; diff --git a/packages/client-utils/test/fixtures/keyring-transactions.ts b/packages/client-utils/test/fixtures/keyring-transactions.ts new file mode 100644 index 00000000000..a9b6db56ffc --- /dev/null +++ b/packages/client-utils/test/fixtures/keyring-transactions.ts @@ -0,0 +1,516 @@ +import { + BtcScope, + SolScope, + TransactionStatus, + TransactionType, +} from '@metamask/keyring-api'; + +import { CustomTransactionTypeLabel } from '../../src/mappers/keyring-transaction-mapper.js'; + +const accountId = '00000000-0000-4000-8000-000000000000'; +const stellarUsdcAsset = `stellar:pubnet/asset:USDC-GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN`; + +export const keyringTransactionFixtures = { + addresses: { + fromAddress: 'from-address', + toAddress: 'to-address', + meAddress: 'me-address', + ownerAddress: 'owner-address', + spenderAddress: 'spender-address', + senderAddress: 'sender-address', + bitcoinSubject: 'bc1qcj8v4ft5uvt59jjrxd856a48xegclwne78h0ye', + bitcoinOutput: 'bc1qc5tzsfpd3zjecma6529kanjtug69rf58mtfxmu', + solanaSubject: 'EsEduLCwNdAbJZ2oTr1wB1ymQw76NuwswWwG6imzQN7H', + solanaCounterparty: '8ekCy2jHHUbW2yeNGFWYJT9Hm9FW7SvZcZK66dSZCDiF', + }, + constants: { + uint256Max: + '115792089237316195423570985008687907853269984665640564039457584007913129639935', + }, + mapArgs: { + sendWithToken: { + transaction: { + id: 'send-id', + chain: SolScope.Mainnet, + account: accountId, + status: TransactionStatus.Confirmed, + timestamp: 1716367781, + type: TransactionType.Send, + from: [ + { + address: 'from-address', + asset: { + fungible: true, + type: `${SolScope.Mainnet}/token:usdc`, + unit: 'USDC', + amount: '2.5', + }, + }, + ], + to: [{ address: 'to-address', asset: null }], + fees: [], + events: [], + } as never, + }, + swap: { + transaction: { + id: 'swap-id', + chain: SolScope.Mainnet, + account: accountId, + status: TransactionStatus.Submitted, + timestamp: 1716367781, + type: TransactionType.Swap, + from: [ + { + address: 'from-address', + asset: { + fungible: true, + type: `${SolScope.Mainnet}/slip44:501`, + unit: 'SOL', + amount: '1', + }, + }, + ], + to: [ + { + address: 'to-address', + asset: { + fungible: true, + type: `${SolScope.Mainnet}/token:usdc`, + unit: 'USDC', + amount: '100', + }, + }, + ], + fees: [], + events: [], + } as never, + }, + receive: { + subjectAddress: 'me-address', + transaction: { + id: 'receive-id', + chain: SolScope.Mainnet, + account: accountId, + status: TransactionStatus.Confirmed, + timestamp: 1716367781, + type: TransactionType.Receive, + from: [{ address: 'sender-address', asset: null }], + to: [ + { + address: 'me-address', + asset: { + fungible: true, + type: `${SolScope.Mainnet}/token:usdc`, + unit: 'USDC', + amount: '7', + }, + }, + ], + fees: [], + events: [], + } as never, + }, + unknownContractInteraction: { + transaction: { + id: 'unknown-id', + chain: SolScope.Mainnet, + account: accountId, + status: TransactionStatus.Failed, + timestamp: 1716367781, + type: TransactionType.Unknown, + from: [{ address: 'from-address', asset: null }], + to: [{ address: 'to-address', asset: null }], + fees: [ + { + type: 'base', + asset: { + fungible: true, + type: `${SolScope.Mainnet}/slip44:501`, + unit: 'SOL', + amount: '0.0001', + }, + }, + ], + events: [], + } as never, + }, + approveFifteenDigits: { + transaction: { + id: 'approve-id', + chain: SolScope.Mainnet, + account: accountId, + status: TransactionStatus.Confirmed, + timestamp: 1716367781, + type: TransactionType.TokenApprove, + from: [ + { + address: 'owner-address', + asset: { + fungible: true, + type: `${SolScope.Mainnet}/token:usdc`, + unit: 'USDC', + amount: '999999999999999', + }, + }, + ], + to: [{ address: 'spender-address', asset: null }], + fees: [], + events: [], + } as never, + }, + approveUint256Max: { + transaction: { + id: 'unlimited-approve-id', + chain: SolScope.Mainnet, + account: accountId, + status: TransactionStatus.Confirmed, + timestamp: 1716367781, + type: TransactionType.TokenApprove, + from: [ + { + address: 'owner-address', + asset: { + fungible: true, + type: `${SolScope.Mainnet}/token:usdc`, + unit: 'USDC', + amount: + '115792089237316195423570985008687907853269984665640564039457584007913129639935', + }, + }, + ], + to: [{ address: 'spender-address', asset: null }], + fees: [], + events: [], + } as never, + }, + approveNoAmount: { + transaction: { + id: 'approve-no-amount-id', + chain: SolScope.Mainnet, + account: accountId, + status: TransactionStatus.Confirmed, + timestamp: 1716367781, + type: TransactionType.TokenApprove, + from: [{ address: 'owner-address', asset: null }], + to: [{ address: 'spender-address', asset: null }], + fees: [], + events: [], + } as never, + }, + approveSixteenDigits: { + transaction: { + id: 'boundary-approve-id', + chain: SolScope.Mainnet, + account: accountId, + status: TransactionStatus.Confirmed, + timestamp: 1716367781, + type: TransactionType.TokenApprove, + from: [ + { + address: 'owner-address', + asset: { + fungible: true, + type: `${SolScope.Mainnet}/token:usdc`, + unit: 'USDC', + amount: '1000000000000000', + }, + }, + ], + to: [{ address: 'spender-address', asset: null }], + fees: [], + events: [], + } as never, + }, + emptyMovements: { + transaction: { + id: 'empty-movements-id', + chain: SolScope.Mainnet, + account: accountId, + status: TransactionStatus.Confirmed, + timestamp: 1716367781, + type: TransactionType.Unknown, + from: [], + to: [], + fees: [], + events: [], + } as never, + }, + noTimestamp: { + transaction: { + id: 'no-timestamp-id', + chain: SolScope.Mainnet, + account: accountId, + status: TransactionStatus.Confirmed, + timestamp: null, + type: TransactionType.Send, + from: [{ address: 'from-address', asset: null }], + to: [{ address: 'to-address', asset: null }], + fees: [], + events: [], + } as never, + }, + nonFungibleFee: { + transaction: { + id: 'nonfungible-fee-id', + chain: SolScope.Mainnet, + account: accountId, + status: TransactionStatus.Confirmed, + timestamp: 1716367781, + type: TransactionType.Send, + from: [{ address: 'from-address', asset: null }], + to: [{ address: 'to-address', asset: null }], + fees: [ + { + type: 'base', + asset: { fungible: false, id: `${SolScope.Mainnet}/nft:1` }, + }, + ], + events: [], + } as never, + }, + bitcoinSend: { + subjectAddress: 'bc1qcj8v4ft5uvt59jjrxd856a48xegclwne78h0ye', + transaction: { + id: '9a2098cdeb6dcd2d89b9d8993b5f5b2d97a49f91b63aba0ae6d525e6532a64b6', + chain: BtcScope.Mainnet, + account: accountId, + status: TransactionStatus.Confirmed, + timestamp: 1716367781, + type: TransactionType.Send, + from: [], + to: [ + { + address: 'bc1qc5tzsfpd3zjecma6529kanjtug69rf58mtfxmu', + asset: { + fungible: true, + type: `${BtcScope.Mainnet}/slip44:0`, + unit: 'BTC', + amount: '0.000003', + }, + }, + ], + fees: [], + events: [], + } as never, + }, + trustlineApprove: { + transaction: { + id: 'trustline-approve-id', + chain: 'stellar:pubnet', + account: accountId, + status: TransactionStatus.Confirmed, + timestamp: 1716367781, + type: TransactionType.TokenApprove, + details: { + typeLabel: CustomTransactionTypeLabel.TrustlineApprove, + }, + from: [ + { + address: 'owner-address', + asset: { + fungible: true, + type: stellarUsdcAsset, + unit: 'USDC', + amount: '0', + }, + }, + ], + to: [{ address: 'issuer-address', asset: null }], + fees: [], + events: [], + } as never, + }, + trustlineApproveNoAmount: { + transaction: { + id: 'trustline-approve-no-amount-id', + chain: 'stellar:pubnet', + account: accountId, + status: TransactionStatus.Confirmed, + timestamp: 1716367781, + type: TransactionType.TokenApprove, + details: { + typeLabel: CustomTransactionTypeLabel.TrustlineApprove, + }, + from: [{ address: 'owner-address', asset: null }], + to: [{ address: 'issuer-address', asset: null }], + fees: [], + events: [], + } as never, + }, + trustlineDisapprove: { + transaction: { + id: 'trustline-disapprove-id', + chain: 'stellar:pubnet', + account: accountId, + status: TransactionStatus.Confirmed, + timestamp: 1716367781, + type: TransactionType.TokenDisapprove, + details: { + typeLabel: CustomTransactionTypeLabel.TrustlineDisapprove, + }, + from: [ + { + address: 'owner-address', + asset: { + fungible: true, + type: stellarUsdcAsset, + unit: 'USDC', + amount: '0', + }, + }, + ], + to: [{ address: 'issuer-address', asset: null }], + fees: [], + events: [], + } as never, + }, + trustlineDisapproveNoAmount: { + transaction: { + id: 'trustline-disapprove-no-amount-id', + chain: 'stellar:pubnet', + account: accountId, + status: TransactionStatus.Confirmed, + timestamp: 1716367781, + type: TransactionType.TokenDisapprove, + details: { + typeLabel: CustomTransactionTypeLabel.TrustlineDisapprove, + }, + from: [{ address: 'owner-address', asset: null }], + to: [{ address: 'issuer-address', asset: null }], + fees: [], + events: [], + } as never, + }, + disapproveNonTrustline: { + transaction: { + id: 'plain-disapprove-id', + chain: SolScope.Mainnet, + account: accountId, + status: TransactionStatus.Confirmed, + timestamp: 1716367781, + type: TransactionType.TokenDisapprove, + from: [{ address: 'owner-address', asset: null }], + to: [{ address: 'spender-address', asset: null }], + fees: [], + events: [], + } as never, + }, + // MultichainTransactionsController payload for a Solana→Base bridge source + // leg. First fungible in `from` is another party's USDC; the subject's + // outflow is SOL. + solanaBridgeSendWithForeignUsdc: { + subjectAddress: 'EsEduLCwNdAbJZ2oTr1wB1ymQw76NuwswWwG6imzQN7H', + transaction: { + account: '625152a4-4667-4328-99bd-b38db43658f5', + chain: SolScope.Mainnet, + events: [ + { + status: TransactionStatus.Confirmed, + timestamp: 1784776645, + }, + ], + fees: [ + { + asset: { + amount: '0.000005', + fungible: true, + type: `${SolScope.Mainnet}/slip44:501`, + unit: 'SOL', + }, + type: 'base', + }, + { + asset: { + amount: '0.000050564', + fungible: true, + type: `${SolScope.Mainnet}/slip44:501`, + unit: 'SOL', + }, + type: 'priority', + }, + ], + from: [ + { + address: '8ekCy2jHHUbW2yeNGFWYJT9Hm9FW7SvZcZK66dSZCDiF', + asset: { + amount: '0.069181', + fungible: true, + type: `${SolScope.Mainnet}/token:EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v`, + unit: 'USDC', + }, + }, + { + address: 'EsEduLCwNdAbJZ2oTr1wB1ymQw76NuwswWwG6imzQN7H', + asset: { + amount: '0.00531264', + fungible: true, + type: `${SolScope.Mainnet}/slip44:501`, + unit: 'SOL', + }, + }, + ], + id: '3Tph6Faw2YMshJt7pkCaCbTHTX4mYJLE6h72DR6Q4uDta9HmrNCfReXuDDPKUCbCxn7NUNALvgNjii19fKdgWBfA', + status: TransactionStatus.Confirmed, + timestamp: 1784776645, + to: [ + { + address: '8ekCy2jHHUbW2yeNGFWYJT9Hm9FW7SvZcZK66dSZCDiF', + asset: { + amount: '0.000892125', + fungible: true, + type: `${SolScope.Mainnet}/token:So11111111111111111111111111111111111111112`, + unit: '', + }, + }, + { + address: '2Gr2S7Nk7nbXzWkzGD1FSqvgHqdPnyN2rLJWsQnenP3w', + asset: { + amount: '0.069181', + fungible: true, + type: `${SolScope.Mainnet}/token:EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v`, + unit: 'USDC', + }, + }, + { + address: '2Gr2S7Nk7nbXzWkzGD1FSqvgHqdPnyN2rLJWsQnenP3w', + asset: { + amount: '0.00237336', + fungible: true, + type: `${SolScope.Mainnet}/slip44:501`, + unit: 'SOL', + }, + }, + { + address: '47YRE7eLAdYzvGqSH1XLg2o8xUtywk7sS5BKv1oR4Y7i', + asset: { + amount: '0.000007875', + fungible: true, + type: `${SolScope.Mainnet}/slip44:501`, + unit: 'SOL', + }, + }, + { + address: 'AyJKchXeuZgW8ZQ2uC2EvgPJXE9bKJRYmWs9gt3Ug8JP', + asset: { + amount: '0.00203928', + fungible: true, + type: `${SolScope.Mainnet}/slip44:501`, + unit: 'SOL', + }, + }, + { + address: '5pVN5XZB8cYBjNLFrsBCPWkCQBan5K5Mq2dWGzwPgGJV', + asset: { + amount: '0.000892125', + fungible: true, + type: `${SolScope.Mainnet}/slip44:501`, + unit: 'SOL', + }, + }, + ], + type: TransactionType.Send, + } as never, + }, + }, +}; diff --git a/packages/client-utils/test/fixtures/local-transactions.ts b/packages/client-utils/test/fixtures/local-transactions.ts new file mode 100644 index 00000000000..5204ac25429 --- /dev/null +++ b/packages/client-utils/test/fixtures/local-transactions.ts @@ -0,0 +1,2755 @@ +import { + TransactionStatus, + TransactionType, +} from '@metamask/transaction-controller'; + +import { formatAddressToAssetId } from '../../src/mappers/helpers/caip.js'; +import { + buildApproveTransactionData, + buildPermit2ApproveTransactionData, + encodeMerklClaimCalldata, +} from '../test-helpers.js'; + +const chainIds = { + mainnet: '0x1', + base: '0x2105', + arbitrum: '0xa4b1', + lineaMainnet: '0xe708', +} as const; + +const addresses = { + from: '0x9bed78535d6a03a955f1504aadba974d9a29e292', + to: '0x80181d3ba89220cdb80234fc7aa19d5cc56229cc', + baseUsdc: '0x833589fcd6edb6e08f4c7c32d4f71b54bda02913', + baseAavePool: '0xa238dd80c259a72e81d7e4664a9801593f98d1c5', + lineaDai: '0x4AF15ec2A0BD43Db75dd04E62FAA3B8EF36b00d5', + lineaMusd: '0xaca92e438df0b2401ff60da7e4337b687a2435da', + merklDistributor: '0x3Ef3D8bA38EBe18DB133cEc108f4D14CE00Dd9Ae', + wethContractAddress: '0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2', + permit2Address: '0x000000000022D473030F116dDEE9FD8b9aFE764ad8', + spender: '0x80181d3ba89220cdb80234fc7aa19d5cc56229cc', + mainnetUsdc: '0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48', + mainnetUsdt: '0xdac17f958d2ee523a2206206994597c13d831ec7', + unknownTokenContract: '0x1111111111111111111111111111111111111111', + lidoStEth: '0xae7ab96520de3a18e5e111b5eaab095312d7fe84', +} as const; + +const unwrapAmount = '1000000000000000000'; +const unwrapAmountHex = BigInt(unwrapAmount).toString(16).padStart(64, '0'); + +const nftPurchaseErc1155Transaction = { + chainId: chainIds.mainnet, + id: 'nft-buy-id', + hash: '0x2fda37c5b591c30367649c3c317621429bb5c59ff6a77b0a8cd48b56897168bc', + status: TransactionStatus.confirmed, + time: 1780606867763, + type: TransactionType.contractInteraction, + txParams: { + from: '0x9bed78535d6a03a955f1504aadba974d9a29e292', + to: '0x0000000000000068f116a894984e2db1123eb395', + value: '0x51d91a3da280', + data: '0x00000000', + }, + simulationData: { + nativeBalanceChange: { + previousBalance: '0x49bfcb2d8362e', + newBalance: '0x44a23989a93ae', + difference: '0x51d91a3da280', + isDecrease: true, + }, + tokenBalanceChanges: [ + { + address: '0x6fad73936527d2a82aea5384d252462941b44042', + standard: 'erc1155', + id: '0x39', + previousBalance: '0x0', + newBalance: '0x1', + difference: '0x1', + isDecrease: false, + }, + ], + }, +}; + +const perpsWithdrawTransaction = { + actionId: 1780690942749.6296, + batchTransactions: [], + batchTransactionsOptions: {}, + chainId: chainIds.arbitrum, + customNonceValue: '', + defaultGasEstimates: { + estimateType: 'medium', + gas: '0xce91', + maxFeePerGas: '0x3a430a0', + maxPriorityFeePerGas: '0x0', + }, + delegationAddress: '0x63c0c19a282a1b52b07dd5a65b58948a07dae32b', + gasFeeEstimatesLoaded: true, + gasFeeTokens: [], + gasLimitNoBuffer: '0xac24', + hash: '0xd5dbb4421d123fd16d16485c394a68b5a28d9b5da9d9973554258a9fd2e9ebf6', + id: '427ad200-611c-11f1-960a-af7f25501f42', + isFirstTimeInteraction: false, + isGasFeeSponsored: false, + isGasFeeTokenIgnoredIfBalance: false, + isIntentComplete: true, + isInternal: true, + metamaskPay: { + bridgeFeeFiat: '0.28429', + chainId: chainIds.mainnet, + isPostQuote: true, + networkFeeFiat: '0', + sourceHash: + '0xc01843d173d62145c192043d0c69ec02048100b70ed9401763e0ef2432d9fb30', + targetFiat: '0.714705', + tokenAddress: '0xacA92E438df0B2401fF60dA7E4337B687a2435DA', + totalFiat: '1.28429', + }, + nestedTransactions: undefined, + networkClientId: 'arbitrum-mainnet', + origin: 'metamask', + originalGasEstimate: '0xce91', + r: '0x80a65112995ff66a0c1a65aed4b26ab0c2db34dc1e08af9618c957bc49878858', + rawTx: + '0x02f8ad82a4b138808403a476f082ce9194af88d065e77c8cc2239327c5edb3a432268e583180b844a9059cbb0000000000000000000000009bed78535d6a03a955f1504aadba974d9a29e29200000000000000000000000000000000000000000000000000000000000f4240c080a080a65112995ff66a0c1a65aed4b26ab0c2db34dc1e08af9618c957bc49878858a06456697c1016f9265f308dd92c707a62611f4891067795cc676571e316a15c36', + requiredTransactionIds: undefined, + s: '0x6456697c1016f9265f308dd92c707a62611f4891067795cc676571e316a15c36', + status: TransactionStatus.confirmed, + submittedTime: 1780690964536, + time: 1780690942752, + txParams: { + data: '0xa9059cbb0000000000000000000000009bed78535d6a03a955f1504aadba974d9a29e29200000000000000000000000000000000000000000000000000000000000f4240', + from: '0x9bed78535d6a03a955f1504aadba974d9a29e292', + gas: '0xce91', + gasLimit: '0xce91', + maxFeePerGas: '0x3a476f0', + maxPriorityFeePerGas: '0x0', + to: '0xaf88d065e77c8cc2239327c5edb3a432268e5831', + type: '0x2', + value: '0x0', + }, + txReceipt: undefined, + type: TransactionType.perpsWithdraw, + userEditedGasLimit: false, + userFeeLevel: 'medium', + v: '0x0', + verifiedOnBlockchain: false, +}; + +const lineaMusd = '0xacA92E438df0B2401fF60dA7E4337B687a2435DA'; +const arbitrumUsdc = '0xaf88d065e77c8cc2239327c5edb3a432268e5831'; + +const perpsDepositTransaction = { + batchTransactions: [], + batchTransactionsOptions: {}, + chainId: chainIds.arbitrum, + customNonceValue: '', + delegationAddress: '0x63c0c19a282a1b52b07dd5a65b58948a07dae32b', + gasFeeEstimatesLoaded: true, + gasFeeTokens: [], + hash: '0x3073fa67020abb1931ed043d7a8b6b020aa1004c9d0dd9ebd43ca5b9c10e9503', + id: '238ebf90-659b-11f1-982b-71194b583664', + isGasFeeSponsored: false, + isGasFeeTokenIgnoredIfBalance: false, + isIntentComplete: true, + isInternal: true, + metamaskPay: { + bridgeFeeFiat: '0.001054', + chainId: chainIds.mainnet, + networkFeeFiat: '0.04143764111397638042', + sourceHash: + '0xc93326d28bfe73508ed6bbb9333835a0c6ee4e1b954205390eee0f2956b8c2f6', + targetFiat: '1.000169', + tokenAddress: lineaMusd, + totalFiat: '1.04266064111397638042', + }, + networkClientId: 'arbitrum-mainnet', + origin: 'metamask', + r: '0xd3ecffa0be63e8a61e487c727ce36a760f0c0a61291b2817cbe9dc55598e45bf', + rawTx: + '0x02f8ae82a4b139808403938700830186a094af88d065e77c8cc2239327c5edb3a432268e583180b844a9059cbb0000000000000000000000002df1c51e09aecf9cacb7bc98cb1742757f163df700000000000000000000000000000000000000000000000000000000000f42e9c080a0d3ecffa0be63e8a61e487c727ce36a760f0c0a61291b2817cbe9dc55598e45bfa013410ef68186a2782a10749ff54e39d6f014ed78a307cd4a344621fc2fac8441', + requiredTransactionIds: ['276012e0-659b-11f1-982b-71194b583664'], + s: '0x13410ef68186a2782a10749ff54e39d6f014ed78a307cd4a344621fc2fac8441', + status: TransactionStatus.confirmed, + submittedTime: 1781185247980, + time: 1781185241609, + txParams: { + data: '0xa9059cbb0000000000000000000000002df1c51e09aecf9cacb7bc98cb1742757f163df700000000000000000000000000000000000000000000000000000000000f42e9', + from: '0x9bed78535d6a03a955f1504aadba974d9a29e292', + gas: '0x186a0', + gasLimit: '0x186a0', + maxFeePerGas: '0x3938700', + maxPriorityFeePerGas: '0x0', + to: arbitrumUsdc, + type: '0x2', + value: '0x0', + }, + type: TransactionType.perpsDeposit, + userEditedGasLimit: false, + v: '0x0', + verifiedOnBlockchain: false, +}; + +const transactionGroups = { + // ERC-1155 purchase local state before API metadata is available. + nftPurchaseErc1155: { + transactionGroup: { + hasCancelled: false, + hasRetried: false, + initialTransaction: nftPurchaseErc1155Transaction, + nonce: '0xd8', + primaryTransaction: nftPurchaseErc1155Transaction, + transactions: [nftPurchaseErc1155Transaction], + }, + }, + perpsWithdraw: { + // Local-only Perps withdrawal from state. + transactionGroup: { + initialTransaction: perpsWithdrawTransaction, + primaryTransaction: perpsWithdrawTransaction, + transactions: [perpsWithdrawTransaction], + }, + }, + perpsDeposit: { + // Local-only Perps deposit from state. + transactionGroup: { + initialTransaction: perpsDepositTransaction, + primaryTransaction: perpsDepositTransaction, + transactions: [perpsDepositTransaction], + }, + }, +}; + +const mapInputs = { + mapsAPendingNativeSendTo: { + hasCancelled: false, + hasRetried: false, + initialTransaction: { + chainId: chainIds.mainnet, + id: 'send-id', + hash: '0xsend', + status: TransactionStatus.submitted, + time: 1716367781000, + type: TransactionType.simpleSend, + txParams: { + from: addresses.from, + to: addresses.to, + value: '0x1', + }, + }, + nonce: '0x1', + primaryTransaction: { + chainId: chainIds.mainnet, + id: 'send-id', + hash: '0xsend', + status: TransactionStatus.submitted, + time: 1716367781000, + type: TransactionType.simpleSend, + txParams: { + from: addresses.from, + to: addresses.to, + value: '0x1', + }, + }, + transactions: [ + { + chainId: chainIds.mainnet, + id: 'send-id', + hash: '0xsend', + status: TransactionStatus.submitted, + time: 1716367781000, + type: TransactionType.simpleSend, + txParams: { + from: addresses.from, + to: addresses.to, + value: '0x1', + }, + }, + ], + }, + mapsANativeSendOnAn: { + initialTransaction: { + chainId: '0x53a', + id: 'no-native-id', + hash: '0xnonative', + status: TransactionStatus.confirmed, + time: 1779392463306, + type: TransactionType.simpleSend, + txParams: { + from: addresses.from, + to: addresses.to, + value: '0xde0b6b3a7640000', + }, + }, + primaryTransaction: { + chainId: '0x53a', + id: 'no-native-id', + hash: '0xnonative', + status: TransactionStatus.confirmed, + time: 1779392463306, + type: TransactionType.simpleSend, + txParams: { + from: addresses.from, + to: addresses.to, + value: '0xde0b6b3a7640000', + }, + }, + }, + mapsACustomNetworkNativeSend: { + hasCancelled: false, + hasRetried: false, + initialTransaction: { + chainId: '0x53a', + id: 'custom-send-id', + hash: '0xcustomsend', + status: TransactionStatus.confirmed, + time: 1779392463306, + type: TransactionType.simpleSend, + txParams: { + from: addresses.from, + to: addresses.to, + value: '0xde0b6b3a7640000', + }, + }, + nativeAssetSymbol: 'ETH', + nonce: '0x1', + primaryTransaction: { + chainId: '0x53a', + id: 'custom-send-id', + hash: '0xcustomsend', + status: TransactionStatus.confirmed, + time: 1779392463306, + type: TransactionType.simpleSend, + txParams: { + from: addresses.from, + to: addresses.to, + value: '0xde0b6b3a7640000', + }, + }, + transactions: [ + { + chainId: '0x53a', + id: 'custom-send-id', + hash: '0xcustomsend', + status: TransactionStatus.confirmed, + time: 1779392463306, + type: TransactionType.simpleSend, + txParams: { + from: addresses.from, + to: addresses.to, + value: '0xde0b6b3a7640000', + }, + }, + ], + }, + mapsAUsdcTransferWithTransferinformation: { + hasCancelled: false, + hasRetried: false, + initialTransaction: { + chainId: chainIds.mainnet, + id: 'token-send-id', + hash: '0xtokensend', + status: TransactionStatus.submitted, + time: 1716367781000, + transferInformation: { + amount: '20000', + contractAddress: addresses.mainnetUsdc, + decimals: 6, + symbol: 'USDC', + }, + type: TransactionType.tokenMethodTransfer, + txParams: { + from: addresses.from, + to: addresses.mainnetUsdc, + data: '0xa9059cbb00000000000000000000000050a9d56c2b8ba9a5c7f2c08c3d26e0499f23a7060000000000000000000000000000000000000000000000000000000000004e20', + }, + }, + nonce: '0x1', + primaryTransaction: { + chainId: chainIds.mainnet, + id: 'token-send-id', + hash: '0xtokensend', + status: TransactionStatus.submitted, + time: 1716367781000, + transferInformation: { + amount: '20000', + contractAddress: addresses.mainnetUsdc, + decimals: 6, + symbol: 'USDC', + }, + type: TransactionType.tokenMethodTransfer, + txParams: { + from: addresses.from, + to: addresses.mainnetUsdc, + data: '0xa9059cbb00000000000000000000000050a9d56c2b8ba9a5c7f2c08c3d26e0499f23a7060000000000000000000000000000000000000000000000000000000000004e20', + }, + }, + transactions: [ + { + chainId: chainIds.mainnet, + id: 'token-send-id', + hash: '0xtokensend', + status: TransactionStatus.submitted, + time: 1716367781000, + transferInformation: { + amount: '20000', + contractAddress: addresses.mainnetUsdc, + decimals: 6, + symbol: 'USDC', + }, + type: TransactionType.tokenMethodTransfer, + txParams: { + from: addresses.from, + to: addresses.mainnetUsdc, + data: '0xa9059cbb00000000000000000000000050a9d56c2b8ba9a5c7f2c08c3d26e0499f23a7060000000000000000000000000000000000000000000000000000000000004e20', + }, + }, + ], + }, + mapsAUsdtTransferWithoutTransferinformation: { + hasCancelled: false, + hasRetried: false, + initialTransaction: { + chainId: chainIds.mainnet, + id: 'usdt-send-id', + hash: '0x41f675c4a384e5064b1d9620934b0ff5e8a84f5c84530a25d025e27fb784d303', + status: TransactionStatus.confirmed, + time: 1779392463306, + type: TransactionType.tokenMethodTransfer, + txParams: { + from: addresses.from, + to: addresses.mainnetUsdt, + value: '0x0', + data: '0xa9059cbb000000000000000000000000a6372edd08c857870f9c245a17ee6895307957d500000000000000000000000000000000000000000000000000000000000186a0', + }, + }, + nonce: '0x1', + primaryTransaction: { + chainId: chainIds.mainnet, + id: 'usdt-send-id', + hash: '0x41f675c4a384e5064b1d9620934b0ff5e8a84f5c84530a25d025e27fb784d303', + status: TransactionStatus.confirmed, + time: 1779392463306, + type: TransactionType.tokenMethodTransfer, + txParams: { + from: addresses.from, + to: addresses.mainnetUsdt, + value: '0x0', + data: '0xa9059cbb000000000000000000000000a6372edd08c857870f9c245a17ee6895307957d500000000000000000000000000000000000000000000000000000000000186a0', + }, + }, + transactions: [ + { + chainId: chainIds.mainnet, + id: 'usdt-send-id', + hash: '0x41f675c4a384e5064b1d9620934b0ff5e8a84f5c84530a25d025e27fb784d303', + status: TransactionStatus.confirmed, + time: 1779392463306, + type: TransactionType.tokenMethodTransfer, + txParams: { + from: addresses.from, + to: addresses.mainnetUsdt, + value: '0x0', + data: '0xa9059cbb000000000000000000000000a6372edd08c857870f9c245a17ee6895307957d500000000000000000000000000000000000000000000000000000000000186a0', + }, + }, + ], + }, + leavesUnknownTokenTransferSymbolsBlank: { + hasCancelled: false, + hasRetried: false, + initialTransaction: { + chainId: chainIds.mainnet, + id: 'unknown-token-send-id', + hash: '0xunknown', + status: TransactionStatus.confirmed, + time: 1779392463306, + type: TransactionType.tokenMethodTransfer, + txParams: { + from: addresses.from, + to: addresses.unknownTokenContract, + value: '0x0', + data: '0xa9059cbb000000000000000000000000a6372edd08c857870f9c245a17ee6895307957d500000000000000000000000000000000000000000000000000000000000186a0', + }, + }, + nonce: '0x1', + primaryTransaction: { + chainId: chainIds.mainnet, + id: 'unknown-token-send-id', + hash: '0xunknown', + status: TransactionStatus.confirmed, + time: 1779392463306, + type: TransactionType.tokenMethodTransfer, + txParams: { + from: addresses.from, + to: addresses.unknownTokenContract, + value: '0x0', + data: '0xa9059cbb000000000000000000000000a6372edd08c857870f9c245a17ee6895307957d500000000000000000000000000000000000000000000000000000000000186a0', + }, + }, + transactions: [ + { + chainId: chainIds.mainnet, + id: 'unknown-token-send-id', + hash: '0xunknown', + status: TransactionStatus.confirmed, + time: 1779392463306, + type: TransactionType.tokenMethodTransfer, + txParams: { + from: addresses.from, + to: addresses.unknownTokenContract, + value: '0x0', + data: '0xa9059cbb000000000000000000000000a6372edd08c857870f9c245a17ee6895307957d500000000000000000000000000000000000000000000000000000000000186a0', + }, + }, + ], + }, + fallsBackToTheTxparamsTo: { + initialTransaction: { + chainId: chainIds.mainnet, + id: 'no-recipient-id', + hash: '0xnorecipient', + status: TransactionStatus.confirmed, + time: 1779392463306, + type: TransactionType.tokenMethodTransfer, + txParams: { + from: addresses.from, + to: addresses.mainnetUsdt, + value: '0x0', + data: '0xdeadbeef', + }, + }, + primaryTransaction: { + chainId: chainIds.mainnet, + id: 'no-recipient-id', + hash: '0xnorecipient', + status: TransactionStatus.confirmed, + time: 1779392463306, + type: TransactionType.tokenMethodTransfer, + txParams: { + from: addresses.from, + to: addresses.mainnetUsdt, + value: '0x0', + data: '0xdeadbeef', + }, + }, + }, + usesTheOriginalTransactionTypeAnd: { + hasCancelled: false, + hasRetried: true, + initialTransaction: { + chainId: chainIds.lineaMainnet, + id: 'approve-id', + hash: '0xapprove', + status: TransactionStatus.submitted, + time: 1716367781000, + transferInformation: { + contractAddress: '0x239fd4b0c4db49fa8660e65b97619d43d0e0a79d', + decimals: 0, + symbol: 'TDN', + }, + type: TransactionType.tokenMethodApprove, + txParams: { + from: addresses.from, + to: '0x239fd4b0c4db49fa8660e65b97619d43d0e0a79d', + data: '0xa22cb465', + }, + }, + nonce: '0x2', + primaryTransaction: { + chainId: chainIds.lineaMainnet, + id: 'retry-id', + hash: '0xretry', + status: TransactionStatus.approved, + time: 1716367881000, + transferInformation: { + contractAddress: '0x239fd4b0c4db49fa8660e65b97619d43d0e0a79d', + decimals: 0, + symbol: 'TDN', + }, + type: TransactionType.retry, + txParams: { + from: addresses.from, + to: '0x239fd4b0c4db49fa8660e65b97619d43d0e0a79d', + data: '0xa22cb465', + }, + }, + transactions: [ + { + chainId: chainIds.lineaMainnet, + id: 'approve-id', + hash: '0xapprove', + status: TransactionStatus.submitted, + time: 1716367781000, + transferInformation: { + contractAddress: '0x239fd4b0c4db49fa8660e65b97619d43d0e0a79d', + decimals: 0, + symbol: 'TDN', + }, + type: TransactionType.tokenMethodApprove, + txParams: { + from: addresses.from, + to: '0x239fd4b0c4db49fa8660e65b97619d43d0e0a79d', + data: '0xa22cb465', + }, + }, + { + chainId: chainIds.lineaMainnet, + id: 'retry-id', + hash: '0xretry', + status: TransactionStatus.approved, + time: 1716367881000, + transferInformation: { + contractAddress: '0x239fd4b0c4db49fa8660e65b97619d43d0e0a79d', + decimals: 0, + symbol: 'TDN', + }, + type: TransactionType.retry, + txParams: { + from: addresses.from, + to: '0x239fd4b0c4db49fa8660e65b97619d43d0e0a79d', + data: '0xa22cb465', + }, + }, + ], + }, + mapsAPermit2Approve: { + hasCancelled: false, + hasRetried: false, + initialTransaction: { + chainId: chainIds.lineaMainnet, + id: 'permit2-approve-id', + hash: '0xpermit2approve', + status: TransactionStatus.confirmed, + time: 1716367781000, + type: TransactionType.tokenMethodApprove, + txParams: { + from: addresses.from, + to: addresses.permit2Address, + data: buildPermit2ApproveTransactionData( + addresses.lineaMusd, + addresses.spender, + 1000, + 123, + ), + }, + }, + nonce: '0x4', + primaryTransaction: { + chainId: chainIds.lineaMainnet, + id: 'permit2-approve-id', + hash: '0xpermit2approve', + status: TransactionStatus.confirmed, + time: 1716367781000, + type: TransactionType.tokenMethodApprove, + txParams: { + from: addresses.from, + to: addresses.permit2Address, + data: buildPermit2ApproveTransactionData( + addresses.lineaMusd, + addresses.spender, + 1000, + 123, + ), + }, + }, + transactions: [ + { + chainId: chainIds.lineaMainnet, + id: 'permit2-approve-id', + hash: '0xpermit2approve', + status: TransactionStatus.confirmed, + time: 1716367781000, + type: TransactionType.tokenMethodApprove, + txParams: { + from: addresses.from, + to: addresses.permit2Address, + data: buildPermit2ApproveTransactionData( + addresses.lineaMusd, + addresses.spender, + 1000, + 123, + ), + }, + }, + ], + }, + fallsBackToTransferinformationWhenTxparams: { + hasCancelled: false, + hasRetried: false, + initialTransaction: { + chainId: chainIds.lineaMainnet, + id: 'invalid-to-approve-id', + hash: '0xinvalidtoapprove', + status: TransactionStatus.confirmed, + time: 1716367781000, + transferInformation: { + contractAddress: addresses.lineaMusd, + decimals: 18, + symbol: 'mUSD', + }, + type: TransactionType.tokenMethodApprove, + txParams: { + from: addresses.from, + to: '0x23', + data: buildApproveTransactionData(addresses.spender, 1000), + }, + }, + nonce: '0x5', + primaryTransaction: { + chainId: chainIds.lineaMainnet, + id: 'invalid-to-approve-id', + hash: '0xinvalidtoapprove', + status: TransactionStatus.confirmed, + time: 1716367781000, + transferInformation: { + contractAddress: addresses.lineaMusd, + decimals: 18, + symbol: 'mUSD', + }, + type: TransactionType.tokenMethodApprove, + txParams: { + from: addresses.from, + to: '0x23', + data: buildApproveTransactionData(addresses.spender, 1000), + }, + }, + transactions: [ + { + chainId: chainIds.lineaMainnet, + id: 'invalid-to-approve-id', + hash: '0xinvalidtoapprove', + status: TransactionStatus.confirmed, + time: 1716367781000, + transferInformation: { + contractAddress: addresses.lineaMusd, + decimals: 18, + symbol: 'mUSD', + }, + type: TransactionType.tokenMethodApprove, + txParams: { + from: addresses.from, + to: '0x23', + data: buildApproveTransactionData(addresses.spender, 1000), + }, + }, + ], + }, + omitsTheApprovedAmountForA: { + hasCancelled: false, + hasRetried: false, + initialTransaction: { + chainId: chainIds.lineaMainnet, + id: 'approve-amount-id', + hash: '0xapproveamount', + status: TransactionStatus.confirmed, + time: 1716367781000, + type: TransactionType.tokenMethodApprove, + txParams: { + from: addresses.from, + to: addresses.lineaMusd, + data: buildApproveTransactionData(addresses.spender, 1000), + }, + }, + nonce: '0x8', + primaryTransaction: { + chainId: chainIds.lineaMainnet, + id: 'approve-amount-id', + hash: '0xapproveamount', + status: TransactionStatus.confirmed, + time: 1716367781000, + type: TransactionType.tokenMethodApprove, + txParams: { + from: addresses.from, + to: addresses.lineaMusd, + data: buildApproveTransactionData(addresses.spender, 1000), + }, + }, + transactions: [ + { + chainId: chainIds.lineaMainnet, + id: 'approve-amount-id', + hash: '0xapproveamount', + status: TransactionStatus.confirmed, + time: 1716367781000, + type: TransactionType.tokenMethodApprove, + txParams: { + from: addresses.from, + to: addresses.lineaMusd, + data: buildApproveTransactionData(addresses.spender, 1000), + }, + }, + ], + contractTokenMetadata: { symbol: 'mUSD', decimals: 18 }, + }, + mapsAZeroAmountTokenApprove: { + initialTransaction: { + chainId: chainIds.lineaMainnet, + id: 'revoke-id', + hash: '0xrevoke', + status: TransactionStatus.confirmed, + time: 1716367781000, + type: TransactionType.tokenMethodApprove, + txParams: { + from: addresses.from, + to: addresses.lineaMusd, + data: buildApproveTransactionData(addresses.spender, 0), + }, + }, + primaryTransaction: { + chainId: chainIds.lineaMainnet, + id: 'revoke-id', + hash: '0xrevoke', + status: TransactionStatus.confirmed, + time: 1716367781000, + type: TransactionType.tokenMethodApprove, + txParams: { + from: addresses.from, + to: addresses.lineaMusd, + data: buildApproveTransactionData(addresses.spender, 0), + }, + }, + }, + mapsASetapprovalforallGroupTypeTo: { + initialTransaction: { + chainId: chainIds.base, + id: 'set-approval-id', + hash: '0xsetapproval', + status: TransactionStatus.confirmed, + time: 1716367781000, + type: TransactionType.swapApproval, + txParams: { + from: addresses.from, + to: addresses.baseUsdc, + data: '0xa22cb465', + }, + }, + primaryTransaction: { + chainId: chainIds.base, + id: 'set-approval-id', + hash: '0xsetapproval', + status: TransactionStatus.confirmed, + time: 1716367781000, + type: TransactionType.swapApproval, + txParams: { + from: addresses.from, + to: addresses.baseUsdc, + data: '0xa22cb465', + }, + }, + }, + mapsAnIncreaseallowanceToAnIncrease: { + initialTransaction: { + chainId: chainIds.base, + id: 'increase-id', + hash: '0xincrease', + status: TransactionStatus.confirmed, + time: 1716367781000, + type: TransactionType.tokenMethodIncreaseAllowance, + txParams: { + from: addresses.from, + to: addresses.baseUsdc, + data: '0x39509351', + }, + }, + primaryTransaction: { + chainId: chainIds.base, + id: 'increase-id', + hash: '0xincrease', + status: TransactionStatus.confirmed, + time: 1716367781000, + type: TransactionType.tokenMethodIncreaseAllowance, + txParams: { + from: addresses.from, + to: addresses.baseUsdc, + data: '0x39509351', + }, + }, + }, + mapsAnExplicitLendingdepositType: { + initialTransaction: { + chainId: chainIds.base, + id: 'lending-deposit-id', + hash: '0xlendingdeposit', + status: TransactionStatus.confirmed, + time: 1716367781000, + type: TransactionType.lendingDeposit, + txParams: { from: addresses.from, to: addresses.baseAavePool }, + }, + primaryTransaction: { + chainId: chainIds.base, + id: 'lending-deposit-id', + hash: '0xlendingdeposit', + status: TransactionStatus.confirmed, + time: 1716367781000, + type: TransactionType.lendingDeposit, + txParams: { from: addresses.from, to: addresses.baseAavePool }, + }, + }, + mapsAStakingdepositTypeToA: { + initialTransaction: { + chainId: chainIds.base, + id: 'staking-deposit-id', + hash: '0xstaking', + status: TransactionStatus.confirmed, + time: 1716367781000, + type: TransactionType.stakingDeposit, + txParams: { from: addresses.from, to: addresses.baseUsdc }, + }, + primaryTransaction: { + chainId: chainIds.base, + id: 'staking-deposit-id', + hash: '0xstaking', + status: TransactionStatus.confirmed, + time: 1716367781000, + type: TransactionType.stakingDeposit, + txParams: { from: addresses.from, to: addresses.baseUsdc }, + }, + }, + mapsAnIncomingTokenTransferTo: { + initialTransaction: { + chainId: chainIds.base, + id: 'incoming-token-id', + hash: '0xincomingtoken', + status: TransactionStatus.confirmed, + time: 1716367781000, + type: TransactionType.incoming, + transferInformation: { + amount: '100000', + contractAddress: addresses.baseUsdc, + decimals: 6, + symbol: 'USDC', + }, + txParams: { from: addresses.from, to: addresses.from }, + }, + primaryTransaction: { + chainId: chainIds.base, + id: 'incoming-token-id', + hash: '0xincomingtoken', + status: TransactionStatus.confirmed, + time: 1716367781000, + type: TransactionType.incoming, + transferInformation: { + amount: '100000', + contractAddress: addresses.baseUsdc, + decimals: 6, + symbol: 'USDC', + }, + txParams: { from: addresses.from, to: addresses.from }, + }, + }, + mapsAnIncomingNativeTransferTo: { + initialTransaction: { + chainId: chainIds.mainnet, + id: 'incoming-native-id', + hash: '0xincomingnative', + status: TransactionStatus.confirmed, + time: 1716367781000, + type: TransactionType.incoming, + txParams: { from: addresses.from, to: addresses.from, value: '0x1' }, + }, + primaryTransaction: { + chainId: chainIds.mainnet, + id: 'incoming-native-id', + hash: '0xincomingnative', + status: TransactionStatus.confirmed, + time: 1716367781000, + type: TransactionType.incoming, + txParams: { from: addresses.from, to: addresses.from, value: '0x1' }, + }, + }, + mapsAnMusdConversionToA: { + hasCancelled: false, + hasRetried: false, + initialTransaction: { + chainId: chainIds.lineaMainnet, + id: 'musd-conversion-id', + hash: '0xmusdconversion', + status: TransactionStatus.confirmed, + time: 1779805800000, + type: TransactionType.musdConversion, + txParams: { + from: addresses.from, + to: addresses.lineaMusd, + value: '0x0', + data: '0xa9059cbb0000000000000000000000009bed78535d6a03a955f1504aadba974d9a29e2920000000000000000000000000000000000000000000000000000000000018703', + }, + }, + nonce: '0x3', + primaryTransaction: { + chainId: chainIds.lineaMainnet, + id: 'musd-conversion-id', + hash: '0xmusdconversion', + status: TransactionStatus.confirmed, + time: 1779805800000, + type: TransactionType.musdConversion, + txParams: { + from: addresses.from, + to: addresses.lineaMusd, + value: '0x0', + data: '0xa9059cbb0000000000000000000000009bed78535d6a03a955f1504aadba974d9a29e2920000000000000000000000000000000000000000000000000000000000018703', + }, + }, + transactions: [ + { + chainId: chainIds.lineaMainnet, + id: 'musd-conversion-id', + hash: '0xmusdconversion', + status: TransactionStatus.confirmed, + time: 1779805800000, + type: TransactionType.musdConversion, + txParams: { + from: addresses.from, + to: addresses.lineaMusd, + value: '0x0', + data: '0xa9059cbb0000000000000000000000009bed78535d6a03a955f1504aadba974d9a29e2920000000000000000000000000000000000000000000000000000000000018703', + }, + }, + ], + sourceToken: { + assetId: formatAddressToAssetId(addresses.lineaDai, 'eip155:59144'), + decimals: 18, + direction: 'out', + symbol: 'DAI', + }, + }, + mapsAPerpsWithdrawalLocalTransaction: + transactionGroups.perpsWithdraw.transactionGroup, + mapsAPerpsDepositLocalTransaction: + transactionGroups.perpsDeposit.transactionGroup, + mapsAPerpsDepositWithoutA: { + initialTransaction: { + chainId: chainIds.arbitrum, + id: 'perps-no-target-id', + hash: '0xperpsnotarget', + status: TransactionStatus.confirmed, + time: 1781185241609, + type: TransactionType.perpsDeposit, + txParams: { from: addresses.from, to: '' }, + }, + primaryTransaction: { + chainId: chainIds.arbitrum, + id: 'perps-no-target-id', + hash: '0xperpsnotarget', + status: TransactionStatus.confirmed, + time: 1781185241609, + type: TransactionType.perpsDeposit, + txParams: { from: addresses.from, to: '' }, + }, + }, + mapsAnAaveSupplyContractInteraction: { + hasCancelled: false, + hasRetried: false, + initialTransaction: { + chainId: chainIds.base, + id: 'aave-supply-id', + hash: '0x093844dd6200984f0e27d3c3a76b7a63b360bfb2136213237d693afd2cd69740', + status: TransactionStatus.confirmed, + time: 1779892154611, + type: TransactionType.contractInteraction, + txParams: { + from: addresses.from, + to: addresses.baseAavePool, + value: '0x0', + data: '0x617ba037000000000000000000000000833589fcd6edb6e08f4c7c32d4f71b54bda0291300000000000000000000000000000000000000000000000000000000000186a00000000000000000000000009bed78535d6a03a955f1504aadba974d9a29e2920000000000000000000000000000000000000000000000000000000000000000', + }, + simulationData: { + tokenBalanceChanges: [ + { + address: addresses.baseUsdc, + difference: '0x186a0', + isDecrease: true, + newBalance: '0x11284f', + previousBalance: '0x12aeef', + standard: 'erc20', + }, + { + address: '0x4e65fe4dba92790696d040ac24aa414708f5c0ab', + difference: '0x1869f', + isDecrease: false, + newBalance: '0x65101', + previousBalance: '0x4ca62', + standard: 'erc20', + }, + ], + }, + }, + nonce: '0x210', + primaryTransaction: { + chainId: chainIds.base, + id: 'aave-supply-id', + hash: '0x093844dd6200984f0e27d3c3a76b7a63b360bfb2136213237d693afd2cd69740', + status: TransactionStatus.confirmed, + time: 1779892154611, + type: TransactionType.contractInteraction, + txParams: { + from: addresses.from, + to: addresses.baseAavePool, + value: '0x0', + data: '0x617ba037000000000000000000000000833589fcd6edb6e08f4c7c32d4f71b54bda0291300000000000000000000000000000000000000000000000000000000000186a00000000000000000000000009bed78535d6a03a955f1504aadba974d9a29e2920000000000000000000000000000000000000000000000000000000000000000', + }, + simulationData: { + tokenBalanceChanges: [ + { + address: addresses.baseUsdc, + difference: '0x186a0', + isDecrease: true, + newBalance: '0x11284f', + previousBalance: '0x12aeef', + standard: 'erc20', + }, + { + address: '0x4e65fe4dba92790696d040ac24aa414708f5c0ab', + difference: '0x1869f', + isDecrease: false, + newBalance: '0x65101', + previousBalance: '0x4ca62', + standard: 'erc20', + }, + ], + }, + }, + transactions: [ + { + chainId: chainIds.base, + id: 'aave-supply-id', + hash: '0x093844dd6200984f0e27d3c3a76b7a63b360bfb2136213237d693afd2cd69740', + status: TransactionStatus.confirmed, + time: 1779892154611, + type: TransactionType.contractInteraction, + txParams: { + from: addresses.from, + to: addresses.baseAavePool, + value: '0x0', + data: '0x617ba037000000000000000000000000833589fcd6edb6e08f4c7c32d4f71b54bda0291300000000000000000000000000000000000000000000000000000000000186a00000000000000000000000009bed78535d6a03a955f1504aadba974d9a29e2920000000000000000000000000000000000000000000000000000000000000000', + }, + simulationData: { + tokenBalanceChanges: [ + { + address: addresses.baseUsdc, + difference: '0x186a0', + isDecrease: true, + newBalance: '0x11284f', + previousBalance: '0x12aeef', + standard: 'erc20', + }, + { + address: '0x4e65fe4dba92790696d040ac24aa414708f5c0ab', + difference: '0x1869f', + isDecrease: false, + newBalance: '0x65101', + previousBalance: '0x4ca62', + standard: 'erc20', + }, + ], + }, + }, + ], + }, + // Real Lido stETH stake: native ETH supplied (recorded in `nativeBalanceChange`), + // only the received stETH appears in `tokenBalanceChanges` as an erc20 increase. + mapsALidoNativeStakeContractInteraction: { + hasCancelled: false, + hasRetried: false, + initialTransaction: { + chainId: chainIds.mainnet, + id: 'lido-stake-id', + hash: '0xd8ca1456ed6305ec3d9c058f28a1ba48eb335ffcffd7d7c4321d3169c29e6a07', + status: TransactionStatus.confirmed, + time: 1782912963672, + type: TransactionType.contractInteraction, + txParams: { + from: addresses.from, + to: addresses.lidoStEth, + value: '0xe8d4a51000', + data: '0xa1903eab00000000000000000000000011d00000000000000000000000000000000011d0', + }, + simulationData: { + nativeBalanceChange: { + difference: '0xe8d4a51000', + isDecrease: true, + newBalance: '0x2caa8ba791077', + previousBalance: '0x2cb918f1e2077', + }, + tokenBalanceChanges: [ + { + address: addresses.lidoStEth, + difference: '0xe8d4a50fff', + isDecrease: false, + newBalance: '0xe8d4a50fff', + previousBalance: '0x0', + standard: 'erc20', + }, + ], + }, + }, + nonce: '0xe0', + primaryTransaction: { + chainId: chainIds.mainnet, + id: 'lido-stake-id', + hash: '0xd8ca1456ed6305ec3d9c058f28a1ba48eb335ffcffd7d7c4321d3169c29e6a07', + status: TransactionStatus.confirmed, + time: 1782912963672, + type: TransactionType.contractInteraction, + txParams: { + from: addresses.from, + to: addresses.lidoStEth, + value: '0xe8d4a51000', + data: '0xa1903eab00000000000000000000000011d00000000000000000000000000000000011d0', + }, + }, + }, + mapsAWithdrawContractInteractionFrom: { + initialTransaction: { + chainId: chainIds.base, + hash: '0x26f4911467b538702c0945e4ec5e303de44c0c1c174897141d1b548ea3161795', + status: TransactionStatus.confirmed, + time: 1779912434153, + type: TransactionType.contractInteraction, + txParams: { + from: addresses.from, + to: addresses.baseAavePool, + data: '0x69328dec000000000000000000000000833589fcd6edb6e08f4c7c32d4f71b54bda029130000000000000000000000000000000000000000000000000000000000030d400000000000000000000000009bed78535d6a03a955f1504aadba974d9a29e292', + }, + txReceipt: { + logs: [ + { + address: addresses.baseUsdc, + data: '0x0000000000000000000000000000000000000000000000000000000000030d40', + topics: [ + '0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef', + '0x0000000000000000000000004e65fe4dba92790696d040ac24aa414708f5c0ab', + '0x0000000000000000000000009bed78535d6a03a955f1504aadba974d9a29e292', + ], + }, + ], + }, + }, + primaryTransaction: { + chainId: chainIds.base, + hash: '0x26f4911467b538702c0945e4ec5e303de44c0c1c174897141d1b548ea3161795', + status: TransactionStatus.confirmed, + time: 1779912434153, + type: TransactionType.contractInteraction, + txParams: { + from: addresses.from, + to: addresses.baseAavePool, + data: '0x69328dec000000000000000000000000833589fcd6edb6e08f4c7c32d4f71b54bda029130000000000000000000000000000000000000000000000000000000000030d400000000000000000000000009bed78535d6a03a955f1504aadba974d9a29e292', + }, + txReceipt: { + logs: [ + { + address: addresses.baseUsdc, + data: '0x0000000000000000000000000000000000000000000000000000000000030d40', + topics: [ + '0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef', + '0x0000000000000000000000004e65fe4dba92790696d040ac24aa414708f5c0ab', + '0x0000000000000000000000009bed78535d6a03a955f1504aadba974d9a29e292', + ], + }, + ], + }, + }, + }, + mapsAWithdrawContractInteractionWithout: { + initialTransaction: { + chainId: chainIds.base, + hash: '0xwithdrawnolog', + status: TransactionStatus.confirmed, + time: 1779912434153, + type: TransactionType.contractInteraction, + txParams: { + from: addresses.from, + to: addresses.baseAavePool, + data: '0x69328dec', + }, + }, + primaryTransaction: { + chainId: chainIds.base, + hash: '0xwithdrawnolog', + status: TransactionStatus.confirmed, + time: 1779912434153, + type: TransactionType.contractInteraction, + txParams: { + from: addresses.from, + to: addresses.baseAavePool, + data: '0x69328dec', + }, + }, + }, + mapsBridgeHistoryTokenDataTo: { + hasCancelled: false, + hasRetried: false, + initialTransaction: { + chainId: chainIds.mainnet, + id: 'bridge-swap-id', + hash: '0xbridgeswap', + status: TransactionStatus.confirmed, + time: 1779392463306, + type: TransactionType.swap, + txParams: { + from: addresses.from, + to: '0xaca92e438df0b2401ff60da7e4337b687a2435da', + value: '0x0', + }, + }, + nonce: '0x3', + primaryTransaction: { + chainId: chainIds.mainnet, + id: 'bridge-swap-id', + hash: '0xbridgeswap', + status: TransactionStatus.confirmed, + time: 1779392463306, + type: TransactionType.swap, + txParams: { + from: addresses.from, + to: '0xaca92e438df0b2401ff60da7e4337b687a2435da', + value: '0x0', + }, + }, + transactions: [ + { + chainId: chainIds.mainnet, + id: 'bridge-swap-id', + hash: '0xbridgeswap', + status: TransactionStatus.confirmed, + time: 1779392463306, + type: TransactionType.swap, + txParams: { + from: addresses.from, + to: '0xaca92e438df0b2401ff60da7e4337b687a2435da', + value: '0x0', + }, + }, + ], + sourceToken: { + amount: '10000000000000', + assetId: 'eip155:1/slip44:60', + decimals: 18, + direction: 'out', + symbol: 'ETH', + }, + destinationToken: { + amount: '19546', + assetId: 'eip155:1/erc20:0xACa92e438df0B2401fF60Da7E4337B687a2435dA', + decimals: 6, + direction: 'in', + symbol: 'MUSD', + }, + }, + mapsASwapWithoutADestination: { + initialTransaction: { + chainId: chainIds.base, + id: 'swap-incomplete-id', + hash: '0xswapincomplete', + status: TransactionStatus.confirmed, + time: 1716367781000, + swapMetaData: { + token_from: 'ETH', + }, + type: TransactionType.swap, + txParams: { + from: addresses.from, + to: addresses.baseUsdc, + value: '0x1', + }, + }, + primaryTransaction: { + chainId: chainIds.base, + id: 'swap-incomplete-id', + hash: '0xswapincomplete', + status: TransactionStatus.confirmed, + time: 1716367781000, + swapMetaData: { + token_from: 'ETH', + }, + type: TransactionType.swap, + txParams: { + from: addresses.from, + to: addresses.baseUsdc, + value: '0x1', + }, + }, + }, + usesABridgeHistoryActivityStatus: { + hasCancelled: false, + hasRetried: false, + initialTransaction: { + chainId: chainIds.mainnet, + id: 'bridge-id', + hash: '0xbridge', + status: TransactionStatus.confirmed, + time: 1779392463306, + type: TransactionType.bridge, + txParams: { + from: addresses.from, + to: addresses.to, + value: '0x0', + }, + }, + nonce: '0x3', + primaryTransaction: { + chainId: chainIds.mainnet, + id: 'bridge-id', + hash: '0xbridge', + status: TransactionStatus.confirmed, + time: 1779392463306, + type: TransactionType.bridge, + txParams: { + from: addresses.from, + to: addresses.to, + value: '0x0', + }, + }, + transactions: [ + { + chainId: chainIds.mainnet, + id: 'bridge-id', + hash: '0xbridge', + status: TransactionStatus.confirmed, + time: 1779392463306, + type: TransactionType.bridge, + txParams: { + from: addresses.from, + to: addresses.to, + value: '0x0', + }, + }, + ], + activityStatus: 'failed', + }, + mapsALocalBridgeNetworkFee: { + hasCancelled: false, + hasRetried: false, + initialTransaction: { + chainId: chainIds.arbitrum, + id: 'bridge-fee-id', + hash: '0xbridgefee', + status: TransactionStatus.confirmed, + time: 1779392463306, + type: TransactionType.bridge, + txParams: { + from: addresses.from, + to: addresses.to, + value: '0x0', + }, + txReceipt: { + gasUsed: '0x24405', + effectiveGasPrice: '0x6fc23ac1d', + }, + }, + nonce: '0x3', + primaryTransaction: { + chainId: chainIds.arbitrum, + id: 'bridge-fee-id', + hash: '0xbridgefee', + status: TransactionStatus.confirmed, + time: 1779392463306, + type: TransactionType.bridge, + txParams: { + from: addresses.from, + to: addresses.to, + value: '0x0', + }, + txReceipt: { + gasUsed: '0x24405', + effectiveGasPrice: '0x6fc23ac1d', + }, + }, + transactions: [ + { + chainId: chainIds.arbitrum, + id: 'bridge-fee-id', + hash: '0xbridgefee', + status: TransactionStatus.confirmed, + time: 1779392463306, + type: TransactionType.bridge, + txParams: { + from: addresses.from, + to: addresses.to, + value: '0x0', + }, + txReceipt: { + gasUsed: '0x24405', + effectiveGasPrice: '0x6fc23ac1d', + }, + }, + ], + sourceToken: { + amount: '99130000000000', + assetId: 'eip155:42161/slip44:60', + decimals: 18, + direction: 'out', + symbol: 'ETH', + }, + destinationToken: { + amount: '141592', + assetId: + 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/spl-token:EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v', + decimals: 6, + direction: 'in', + symbol: 'USDC', + }, + }, + mapsSwapMetadataTokenSymbolsTo: { + hasCancelled: false, + hasRetried: false, + initialTransaction: { + chainId: chainIds.base, + id: 'swap-id', + hash: '0xswap', + status: TransactionStatus.confirmed, + time: 1716367781000, + swapMetaData: { + token_from: 'ETH', + + token_to: 'USDC', + }, + type: TransactionType.swap, + txParams: { + from: addresses.from, + to: '0x9dda6ef3d919c9bc8885d5560999a3640431e8e6', + value: '0x246139ca8000', + }, + }, + nonce: '0x3', + primaryTransaction: { + chainId: chainIds.base, + id: 'swap-id', + hash: '0xswap', + status: TransactionStatus.confirmed, + time: 1716367781000, + swapMetaData: { + token_from: 'ETH', + + token_to: 'USDC', + }, + type: TransactionType.swap, + txParams: { + from: addresses.from, + to: '0x9dda6ef3d919c9bc8885d5560999a3640431e8e6', + value: '0x246139ca8000', + }, + }, + transactions: [ + { + chainId: chainIds.base, + id: 'swap-id', + hash: '0xswap', + status: TransactionStatus.confirmed, + time: 1716367781000, + swapMetaData: { + token_from: 'ETH', + + token_to: 'USDC', + }, + type: TransactionType.swap, + txParams: { + from: addresses.from, + to: '0x9dda6ef3d919c9bc8885d5560999a3640431e8e6', + value: '0x246139ca8000', + }, + }, + ], + }, + usesNativeSourceSymbolForA: { + initialTransaction: { + chainId: chainIds.base, + id: 'legacy-native-swap-id', + hash: '0xlegacynativeswap', + status: TransactionStatus.confirmed, + time: 1716367781000, + swapMetaData: { + token_to: 'USDC', + }, + type: TransactionType.swap, + txParams: { + from: addresses.from, + to: '0x9dda6ef3d919c9bc8885d5560999a3640431e8e6', + value: '0x246139ca8000', + }, + }, + primaryTransaction: { + chainId: chainIds.base, + id: 'legacy-native-swap-id', + hash: '0xlegacynativeswap', + status: TransactionStatus.confirmed, + time: 1716367781000, + swapMetaData: { + token_to: 'USDC', + }, + type: TransactionType.swap, + txParams: { + from: addresses.from, + to: '0x9dda6ef3d919c9bc8885d5560999a3640431e8e6', + value: '0x246139ca8000', + }, + }, + }, + mapsALegacySwapWithAn: { + initialTransaction: { + chainId: chainIds.base, + id: 'legacy-bad-value-id', + hash: '0xlegacybadvalue', + status: TransactionStatus.confirmed, + time: 1716367781000, + swapMetaData: { + token_to: 'USDC', + }, + type: TransactionType.swap, + txParams: { + from: addresses.from, + to: '0x9dda6ef3d919c9bc8885d5560999a3640431e8e6', + value: 'not-a-number', + }, + }, + primaryTransaction: { + chainId: chainIds.base, + id: 'legacy-bad-value-id', + hash: '0xlegacybadvalue', + status: TransactionStatus.confirmed, + time: 1716367781000, + swapMetaData: { + token_to: 'USDC', + }, + type: TransactionType.swap, + txParams: { + from: addresses.from, + to: '0x9dda6ef3d919c9bc8885d5560999a3640431e8e6', + value: 'not-a-number', + }, + }, + }, + mapsAWeth9DepositContractInteraction: { + hasCancelled: false, + hasRetried: false, + initialTransaction: { + chainId: chainIds.mainnet, + id: 'wrap-id', + hash: '0xwrap', + status: TransactionStatus.confirmed, + time: 1716367781000, + type: TransactionType.contractInteraction, + txParams: { + from: addresses.from, + to: addresses.wethContractAddress, + value: '0x3782dace9d900000', + data: '0xd0e30db0', + }, + }, + nonce: '0x4', + primaryTransaction: { + chainId: chainIds.mainnet, + id: 'wrap-id', + hash: '0xwrap', + status: TransactionStatus.confirmed, + time: 1716367781000, + type: TransactionType.contractInteraction, + txParams: { + from: addresses.from, + to: addresses.wethContractAddress, + value: '0x3782dace9d900000', + data: '0xd0e30db0', + }, + }, + transactions: [ + { + chainId: chainIds.mainnet, + id: 'wrap-id', + hash: '0xwrap', + status: TransactionStatus.confirmed, + time: 1716367781000, + type: TransactionType.contractInteraction, + txParams: { + from: addresses.from, + to: addresses.wethContractAddress, + value: '0x3782dace9d900000', + data: '0xd0e30db0', + }, + }, + ], + }, + treatsAWeth9DepositWithZero: { + initialTransaction: { + chainId: chainIds.mainnet, + id: 'wrap-zero-id', + hash: '0xwrapzero', + status: TransactionStatus.confirmed, + time: 1716367781000, + type: TransactionType.contractInteraction, + txParams: { + from: addresses.from, + to: addresses.wethContractAddress, + value: '0x0', + data: '0xd0e30db0', + }, + }, + primaryTransaction: { + chainId: chainIds.mainnet, + id: 'wrap-zero-id', + hash: '0xwrapzero', + status: TransactionStatus.confirmed, + time: 1716367781000, + type: TransactionType.contractInteraction, + txParams: { + from: addresses.from, + to: addresses.wethContractAddress, + value: '0x0', + data: '0xd0e30db0', + }, + }, + }, + mapsAWeth9WithdrawContractInteraction: { + hasCancelled: false, + hasRetried: false, + initialTransaction: { + chainId: chainIds.mainnet, + id: 'unwrap-id', + hash: '0xunwrap', + status: TransactionStatus.confirmed, + time: 1716367781000, + type: TransactionType.contractInteraction, + txParams: { + from: addresses.from, + to: addresses.wethContractAddress, + value: '0x0', + data: `0x2e1a7d4d${unwrapAmountHex}`, + }, + }, + nonce: '0x5', + primaryTransaction: { + chainId: chainIds.mainnet, + id: 'unwrap-id', + hash: '0xunwrap', + status: TransactionStatus.confirmed, + time: 1716367781000, + type: TransactionType.contractInteraction, + txParams: { + from: addresses.from, + to: addresses.wethContractAddress, + value: '0x0', + data: `0x2e1a7d4d${unwrapAmountHex}`, + }, + }, + transactions: [ + { + chainId: chainIds.mainnet, + id: 'unwrap-id', + hash: '0xunwrap', + status: TransactionStatus.confirmed, + time: 1716367781000, + type: TransactionType.contractInteraction, + txParams: { + from: addresses.from, + to: addresses.wethContractAddress, + value: '0x0', + data: `0x2e1a7d4d${unwrapAmountHex}`, + }, + }, + ], + }, + mapsAWeth9UnwrapWithMalformed: { + initialTransaction: { + chainId: chainIds.mainnet, + id: 'unwrap-bad-id', + hash: '0xunwrapbad', + status: TransactionStatus.confirmed, + time: 1716367781000, + type: TransactionType.contractInteraction, + txParams: { + from: addresses.from, + to: addresses.wethContractAddress, + value: '0x0', + data: '0x2e1a7d4d', + }, + }, + primaryTransaction: { + chainId: chainIds.mainnet, + id: 'unwrap-bad-id', + hash: '0xunwrapbad', + status: TransactionStatus.confirmed, + time: 1716367781000, + type: TransactionType.contractInteraction, + txParams: { + from: addresses.from, + to: addresses.wethContractAddress, + value: '0x0', + data: '0x2e1a7d4d', + }, + }, + }, + mapsANativeValueContractInteraction: { + hasCancelled: false, + hasRetried: false, + initialTransaction: { + chainId: chainIds.mainnet, + id: 'contract-interaction-id', + hash: '0xcontract', + status: TransactionStatus.confirmed, + time: 1716367781000, + type: TransactionType.contractInteraction, + txParams: { + from: addresses.from, + to: addresses.to, + value: '0x3782dace9d900000', + data: '0xd0e30db0', + }, + }, + nonce: '0x4', + primaryTransaction: { + chainId: chainIds.mainnet, + id: 'contract-interaction-id', + hash: '0xcontract', + status: TransactionStatus.confirmed, + time: 1716367781000, + type: TransactionType.contractInteraction, + txParams: { + from: addresses.from, + to: addresses.to, + value: '0x3782dace9d900000', + data: '0xd0e30db0', + }, + }, + transactions: [ + { + chainId: chainIds.mainnet, + id: 'contract-interaction-id', + hash: '0xcontract', + status: TransactionStatus.confirmed, + time: 1716367781000, + type: TransactionType.contractInteraction, + txParams: { + from: addresses.from, + to: addresses.to, + value: '0x3782dace9d900000', + data: '0xd0e30db0', + }, + }, + ], + }, + mapsAZeroValueContractInteraction: { + initialTransaction: { + chainId: chainIds.mainnet, + id: 'contract-no-token-id', + hash: '0xcontractnotoken', + status: TransactionStatus.confirmed, + time: 1716367781000, + type: TransactionType.contractInteraction, + txParams: { + from: addresses.from, + to: addresses.to, + value: '0x0', + data: '0x12345678', + }, + }, + primaryTransaction: { + chainId: chainIds.mainnet, + id: 'contract-no-token-id', + hash: '0xcontractnotoken', + status: TransactionStatus.confirmed, + time: 1716367781000, + type: TransactionType.contractInteraction, + txParams: { + from: addresses.from, + to: addresses.to, + value: '0x0', + data: '0x12345678', + }, + }, + }, + mapsAContractInteractionWithoutA: { + initialTransaction: { + chainId: chainIds.mainnet, + id: 'contract-no-value-id', + hash: '0xcontractnovalue', + status: TransactionStatus.confirmed, + time: 1716367781000, + type: TransactionType.contractInteraction, + txParams: { + from: addresses.from, + to: addresses.to, + data: '0x12345678', + }, + }, + primaryTransaction: { + chainId: chainIds.mainnet, + id: 'contract-no-value-id', + hash: '0xcontractnovalue', + status: TransactionStatus.confirmed, + time: 1716367781000, + type: TransactionType.contractInteraction, + txParams: { + from: addresses.from, + to: addresses.to, + data: '0x12345678', + }, + }, + }, + mapsAContractInteractionWithAn: { + initialTransaction: { + chainId: chainIds.mainnet, + id: 'contract-bad-value-id', + hash: '0xcontractbadvalue', + status: TransactionStatus.confirmed, + time: 1716367781000, + type: TransactionType.contractInteraction, + txParams: { + from: addresses.from, + to: addresses.to, + value: 'not-a-number', + data: '0x12345678', + }, + }, + primaryTransaction: { + chainId: chainIds.mainnet, + id: 'contract-bad-value-id', + hash: '0xcontractbadvalue', + status: TransactionStatus.confirmed, + time: 1716367781000, + type: TransactionType.contractInteraction, + txParams: { + from: addresses.from, + to: addresses.to, + value: 'not-a-number', + data: '0x12345678', + }, + }, + }, + mapsALocalContractInteractionWith: + transactionGroups.nftPurchaseErc1155.transactionGroup, + mapsASmartTransactionStatusTo: { + initialTransaction: { + chainId: chainIds.mainnet, + id: 'stx-id', + hash: '0xstx', + status: 'pending', + isSmartTransaction: true, + time: 1716367781000, + type: TransactionType.simpleSend, + txParams: { from: addresses.from, to: addresses.to, value: '0x1' }, + }, + primaryTransaction: { + chainId: chainIds.mainnet, + id: 'stx-id', + hash: '0xstx', + status: 'pending', + isSmartTransaction: true, + time: 1716367781000, + type: TransactionType.simpleSend, + txParams: { from: addresses.from, to: addresses.to, value: '0x1' }, + }, + }, + mapsASuccessfulSmartTransactionTo: { + initialTransaction: { + chainId: chainIds.mainnet, + id: 'stx-success-id', + hash: '0xstxsuccess', + status: 'success', + isSmartTransaction: true, + time: 1716367781000, + type: TransactionType.simpleSend, + txParams: { from: addresses.from, to: addresses.to, value: '0x1' }, + }, + primaryTransaction: { + chainId: chainIds.mainnet, + id: 'stx-success-id', + hash: '0xstxsuccess', + status: 'success', + isSmartTransaction: true, + time: 1716367781000, + type: TransactionType.simpleSend, + txParams: { from: addresses.from, to: addresses.to, value: '0x1' }, + }, + }, + mapsACancelledSmartTransactionTo: { + initialTransaction: { + chainId: chainIds.mainnet, + id: 'stx-cancel-id', + hash: '0xstxcancel', + status: 'cancelled', + isSmartTransaction: true, + time: 1716367781000, + type: TransactionType.simpleSend, + txParams: { from: addresses.from, to: addresses.to, value: '0x1' }, + }, + primaryTransaction: { + chainId: chainIds.mainnet, + id: 'stx-cancel-id', + hash: '0xstxcancel', + status: 'cancelled', + isSmartTransaction: true, + time: 1716367781000, + type: TransactionType.simpleSend, + txParams: { from: addresses.from, to: addresses.to, value: '0x1' }, + }, + }, + mapsAnUnknownSmartTransactionStatus: { + initialTransaction: { + chainId: chainIds.mainnet, + id: 'stx-unknown-id', + hash: '0xstxunknown', + status: 'mystery', + isSmartTransaction: true, + time: 1716367781000, + type: TransactionType.simpleSend, + txParams: { from: addresses.from, to: addresses.to, value: '0x1' }, + }, + primaryTransaction: { + chainId: chainIds.mainnet, + id: 'stx-unknown-id', + hash: '0xstxunknown', + status: 'mystery', + isSmartTransaction: true, + time: 1716367781000, + type: TransactionType.simpleSend, + txParams: { from: addresses.from, to: addresses.to, value: '0x1' }, + }, + }, + mapsAFailedTransactionReceiptStatus: { + initialTransaction: { + chainId: chainIds.mainnet, + id: 'receipt-failed-id', + hash: '0xreceiptfailed', + status: TransactionStatus.confirmed, + time: 1716367781000, + type: TransactionType.simpleSend, + txParams: { from: addresses.from, to: addresses.to, value: '0x1' }, + txReceipt: { status: '0x0' }, + }, + primaryTransaction: { + chainId: chainIds.mainnet, + id: 'receipt-failed-id', + hash: '0xreceiptfailed', + status: TransactionStatus.confirmed, + time: 1716367781000, + type: TransactionType.simpleSend, + txParams: { from: addresses.from, to: addresses.to, value: '0x1' }, + txReceipt: { status: '0x0' }, + }, + }, + mapsACancelledTransactionGroupTo: { + initialTransaction: { + chainId: chainIds.mainnet, + id: 'cancel-id', + hash: '0xcancel', + status: TransactionStatus.confirmed, + time: 1716367781000, + type: TransactionType.cancel, + txParams: { from: addresses.from, to: addresses.to, value: '0x1' }, + }, + primaryTransaction: { + chainId: chainIds.mainnet, + id: 'cancel-id', + hash: '0xcancel', + status: TransactionStatus.confirmed, + time: 1716367781000, + type: TransactionType.cancel, + txParams: { from: addresses.from, to: addresses.to, value: '0x1' }, + }, + }, + mapsADroppedTransactionToA: { + initialTransaction: { + chainId: chainIds.mainnet, + id: 'dropped-id', + hash: '0xdropped', + status: TransactionStatus.dropped, + time: 1716367781000, + type: TransactionType.simpleSend, + txParams: { from: addresses.from, to: addresses.to, value: '0x1' }, + }, + primaryTransaction: { + chainId: chainIds.mainnet, + id: 'dropped-id', + hash: '0xdropped', + status: TransactionStatus.dropped, + time: 1716367781000, + type: TransactionType.simpleSend, + txParams: { from: addresses.from, to: addresses.to, value: '0x1' }, + }, + }, + mapsAnUnapprovedTransactionToA: { + initialTransaction: { + chainId: chainIds.mainnet, + id: 'unapproved-id', + hash: '0xunapproved', + status: TransactionStatus.unapproved, + time: 1716367781000, + type: TransactionType.simpleSend, + txParams: { from: addresses.from, to: addresses.to, value: '0x1' }, + }, + primaryTransaction: { + chainId: chainIds.mainnet, + id: 'unapproved-id', + hash: '0xunapproved', + status: TransactionStatus.unapproved, + time: 1716367781000, + type: TransactionType.simpleSend, + txParams: { from: addresses.from, to: addresses.to, value: '0x1' }, + }, + }, + mapsAStatusOutsideTheKnown: { + initialTransaction: { + chainId: chainIds.mainnet, + id: 'weird-status-id', + hash: '0xweird', + status: 'weird-status', + time: 1716367781000, + type: TransactionType.simpleSend, + txParams: { from: addresses.from, to: addresses.to, value: '0x1' }, + }, + primaryTransaction: { + chainId: chainIds.mainnet, + id: 'weird-status-id', + hash: '0xweird', + status: 'weird-status', + time: 1716367781000, + type: TransactionType.simpleSend, + txParams: { from: addresses.from, to: addresses.to, value: '0x1' }, + }, + }, + usesPrecomputedFeesFromTheTransaction: { + initialTransaction: { + chainId: chainIds.mainnet, + id: 'fees-id', + hash: '0xfees', + status: TransactionStatus.confirmed, + time: 1716367781000, + type: TransactionType.bridge, + txParams: { from: addresses.from, to: addresses.to, value: '0x0' }, + }, + primaryTransaction: { + chainId: chainIds.mainnet, + id: 'fees-id', + hash: '0xfees', + status: TransactionStatus.confirmed, + time: 1716367781000, + type: TransactionType.bridge, + txParams: { from: addresses.from, to: addresses.to, value: '0x0' }, + }, + fees: [{ type: 'base', amount: '7', symbol: 'ETH' }], + }, + mapsALocalBridgeFeeUsing: { + initialTransaction: { + chainId: chainIds.mainnet, + id: 'bridge-gasprice-id', + hash: '0xbridgegasprice', + status: TransactionStatus.confirmed, + time: 1716367781000, + type: TransactionType.bridge, + txParams: { + from: addresses.from, + to: addresses.to, + value: '0x0', + gasPrice: '0x10', + }, + txReceipt: { gasUsed: '0x100' }, + }, + primaryTransaction: { + chainId: chainIds.mainnet, + id: 'bridge-gasprice-id', + hash: '0xbridgegasprice', + status: TransactionStatus.confirmed, + time: 1716367781000, + type: TransactionType.bridge, + txParams: { + from: addresses.from, + to: addresses.to, + value: '0x0', + gasPrice: '0x10', + }, + txReceipt: { gasUsed: '0x100' }, + }, + }, + mapsALocalBridgeWithAn: { + initialTransaction: { + chainId: chainIds.mainnet, + id: 'bridge-bad-fee-id', + hash: '0xbridgebadfee', + status: TransactionStatus.confirmed, + time: 1716367781000, + type: TransactionType.bridge, + txParams: { from: addresses.from, to: addresses.to, value: '0x0' }, + txReceipt: { gasUsed: 'nope', effectiveGasPrice: '0x10' }, + }, + primaryTransaction: { + chainId: chainIds.mainnet, + id: 'bridge-bad-fee-id', + hash: '0xbridgebadfee', + status: TransactionStatus.confirmed, + time: 1716367781000, + type: TransactionType.bridge, + txParams: { from: addresses.from, to: addresses.to, value: '0x0' }, + txReceipt: { gasUsed: 'nope', effectiveGasPrice: '0x10' }, + }, + }, + mapsATokenTransferWithoutA: { + initialTransaction: { + chainId: chainIds.mainnet, + id: 'no-contract-id', + hash: '0xnocontract', + status: TransactionStatus.confirmed, + time: 1716367781000, + type: TransactionType.tokenMethodTransfer, + txParams: { + from: addresses.from, + data: '0xdeadbeef', + }, + }, + primaryTransaction: { + chainId: chainIds.mainnet, + id: 'no-contract-id', + hash: '0xnocontract', + status: TransactionStatus.confirmed, + time: 1716367781000, + type: TransactionType.tokenMethodTransfer, + txParams: { + from: addresses.from, + data: '0xdeadbeef', + }, + }, + }, + mapsAWeth9UnwrapWithNon: { + initialTransaction: { + chainId: chainIds.mainnet, + id: 'unwrap-nonhex-id', + hash: '0xunwrapnonhex', + status: TransactionStatus.confirmed, + time: 1716367781000, + type: TransactionType.contractInteraction, + txParams: { + from: addresses.from, + to: addresses.wethContractAddress, + value: '0x0', + data: `0x2e1a7d4d${'z'.repeat(64)}`, + }, + }, + primaryTransaction: { + chainId: chainIds.mainnet, + id: 'unwrap-nonhex-id', + hash: '0xunwrapnonhex', + status: TransactionStatus.confirmed, + time: 1716367781000, + type: TransactionType.contractInteraction, + txParams: { + from: addresses.from, + to: addresses.wethContractAddress, + value: '0x0', + data: `0x2e1a7d4d${'z'.repeat(64)}`, + }, + }, + }, + fallsBackToInitialTransactionId: { + initialTransaction: { + chainId: chainIds.mainnet, + id: 'fallback-id', + status: TransactionStatus.confirmed, + time: 1716367781000, + type: TransactionType.simpleSend, + txParams: {}, + }, + primaryTransaction: { + chainId: chainIds.mainnet, + id: 'primary-fallback-id', + status: TransactionStatus.confirmed, + type: TransactionType.simpleSend, + txParams: {}, + }, + }, + handlesTokenTransfersOnAChain: { + initialTransaction: { + chainId: '0x53a', + id: 'unlisted-chain-id', + hash: '0xunlistedchain', + status: TransactionStatus.confirmed, + time: 1716367781000, + type: TransactionType.tokenMethodTransfer, + txParams: { + from: addresses.from, + to: '0x1234567890123456789012345678901234567890', + data: '0xa9059cbb000000000000000000000000a6372edd08c857870f9c245a17ee6895307957d500000000000000000000000000000000000000000000000000000000000186a0', + }, + }, + primaryTransaction: { + chainId: '0x53a', + id: 'unlisted-chain-id', + hash: '0xunlistedchain', + status: TransactionStatus.confirmed, + time: 1716367781000, + type: TransactionType.tokenMethodTransfer, + txParams: { + from: addresses.from, + to: '0x1234567890123456789012345678901234567890', + data: '0xa9059cbb000000000000000000000000a6372edd08c857870f9c245a17ee6895307957d500000000000000000000000000000000000000000000000000000000000186a0', + }, + }, + }, + mapsATokenTransferWithNo: { + initialTransaction: { + chainId: chainIds.mainnet, + id: 'no-data-transfer-id', + hash: '0xnodatatransfer', + status: TransactionStatus.confirmed, + time: 1716367781000, + type: TransactionType.tokenMethodTransfer, + txParams: { from: addresses.from }, + }, + primaryTransaction: { + chainId: chainIds.mainnet, + id: 'no-data-transfer-id', + hash: '0xnodatatransfer', + status: TransactionStatus.confirmed, + time: 1716367781000, + type: TransactionType.tokenMethodTransfer, + txParams: { from: addresses.from }, + }, + }, + wrapsNativeValueOnAChain: { + initialTransaction: { + chainId: '0x539', + id: 'localhost-wrap-id', + hash: '0xlocalhostwrap', + status: TransactionStatus.confirmed, + time: 1716367781000, + type: TransactionType.contractInteraction, + txParams: { + from: addresses.from, + to: addresses.wethContractAddress, + value: '0x3782dace9d900000', + data: '0xd0e30db0', + }, + }, + primaryTransaction: { + chainId: '0x539', + id: 'localhost-wrap-id', + hash: '0xlocalhostwrap', + status: TransactionStatus.confirmed, + time: 1716367781000, + type: TransactionType.contractInteraction, + txParams: { + from: addresses.from, + to: addresses.wethContractAddress, + value: '0x3782dace9d900000', + data: '0xd0e30db0', + }, + }, + }, + mapsAnMusdConversionWithNo: { + initialTransaction: { + chainId: chainIds.lineaMainnet, + id: 'musd-conversion-no-data-id', + hash: '0xmusdconversionnodata', + status: TransactionStatus.confirmed, + time: 1779805800000, + type: TransactionType.musdConversion, + txParams: { + from: addresses.from, + to: addresses.lineaMusd, + }, + }, + primaryTransaction: { + chainId: chainIds.lineaMainnet, + id: 'musd-conversion-no-data-id', + hash: '0xmusdconversionnodata', + status: TransactionStatus.confirmed, + time: 1779805800000, + type: TransactionType.musdConversion, + txParams: { + from: addresses.from, + to: addresses.lineaMusd, + }, + }, + }, + mapsATokenApproveWithNo: { + initialTransaction: { + chainId: chainIds.lineaMainnet, + id: 'approve-no-data-id', + hash: '0xapprovenodata', + status: TransactionStatus.confirmed, + time: 1716367781000, + type: TransactionType.tokenMethodApprove, + txParams: { from: addresses.from, to: addresses.lineaMusd }, + }, + primaryTransaction: { + chainId: chainIds.lineaMainnet, + id: 'approve-no-data-id', + hash: '0xapprovenodata', + status: TransactionStatus.confirmed, + time: 1716367781000, + type: TransactionType.tokenMethodApprove, + txParams: { from: addresses.from, to: addresses.lineaMusd }, + }, + }, + ignoresWithdrawLogsThatHaveNo: { + initialTransaction: { + chainId: chainIds.base, + hash: '0xwithdrawnotopic', + status: TransactionStatus.confirmed, + time: 1779912434153, + type: TransactionType.contractInteraction, + txParams: { + from: addresses.from, + to: addresses.baseAavePool, + data: '0x69328dec', + }, + txReceipt: { + logs: [ + { + address: addresses.baseUsdc, + data: '0x0000000000000000000000000000000000000000000000000000000000030d40', + topics: [ + '0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef', + ], + }, + ], + }, + }, + primaryTransaction: { + chainId: chainIds.base, + hash: '0xwithdrawnotopic', + status: TransactionStatus.confirmed, + time: 1779912434153, + type: TransactionType.contractInteraction, + txParams: { + from: addresses.from, + to: addresses.baseAavePool, + data: '0x69328dec', + }, + txReceipt: { + logs: [ + { + address: addresses.baseUsdc, + data: '0x0000000000000000000000000000000000000000000000000000000000030d40', + topics: [ + '0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef', + ], + }, + ], + }, + }, + }, + mapsATokenTransferfromToA: { + initialTransaction: { + chainId: chainIds.mainnet, + id: 'transfer-from-id', + hash: '0xtransferfrom', + status: TransactionStatus.confirmed, + time: 1716367781000, + type: TransactionType.tokenMethodTransferFrom, + txParams: { + from: addresses.from, + to: '0xdac17f958d2ee523a2206206994597c13d831ec7', + value: '0x0', + data: '0x23b872dd0000000000000000000000009bed78535d6a03a955f1504aadba974d9a29e29200000000000000000000000050a9d56c2b8ba9a5c7f2c08c3d26e0499f23a70600000000000000000000000000000000000000000000000000000000000186a0', + }, + }, + primaryTransaction: { + chainId: chainIds.mainnet, + id: 'transfer-from-id', + hash: '0xtransferfrom', + status: TransactionStatus.confirmed, + time: 1716367781000, + type: TransactionType.tokenMethodTransferFrom, + txParams: { + from: addresses.from, + to: '0xdac17f958d2ee523a2206206994597c13d831ec7', + value: '0x0', + data: '0x23b872dd0000000000000000000000009bed78535d6a03a955f1504aadba974d9a29e29200000000000000000000000050a9d56c2b8ba9a5c7f2c08c3d26e0499f23a70600000000000000000000000000000000000000000000000000000000000186a0', + }, + }, + }, + mapsASafetransferfromToASend: { + initialTransaction: { + chainId: chainIds.mainnet, + id: 'safe-transfer-from-id', + hash: '0xsafetransferfrom', + status: TransactionStatus.confirmed, + time: 1716367781000, + type: TransactionType.tokenMethodSafeTransferFrom, + txParams: { from: addresses.from, to: addresses.to, data: '0x42842e0e' }, + }, + primaryTransaction: { + chainId: chainIds.mainnet, + id: 'safe-transfer-from-id', + hash: '0xsafetransferfrom', + status: TransactionStatus.confirmed, + time: 1716367781000, + type: TransactionType.tokenMethodSafeTransferFrom, + txParams: { from: addresses.from, to: addresses.to, data: '0x42842e0e' }, + }, + }, + mapsASwapandsendToASwap: { + initialTransaction: { + chainId: chainIds.base, + id: 'swap-and-send-id', + hash: '0xswapandsend', + status: TransactionStatus.confirmed, + time: 1716367781000, + swapMetaData: { + token_from: 'ETH', + + token_to: 'USDC', + }, + type: TransactionType.swapAndSend, + txParams: { from: addresses.from, to: addresses.baseUsdc, value: '0x1' }, + }, + primaryTransaction: { + chainId: chainIds.base, + id: 'swap-and-send-id', + hash: '0xswapandsend', + status: TransactionStatus.confirmed, + time: 1716367781000, + swapMetaData: { + token_from: 'ETH', + + token_to: 'USDC', + }, + type: TransactionType.swapAndSend, + txParams: { from: addresses.from, to: addresses.baseUsdc, value: '0x1' }, + }, + }, + mapsAPerpsdepositandorderToAnAdd: { + initialTransaction: { + chainId: chainIds.arbitrum, + id: 'perps-deposit-and-order-id', + hash: '0xperpsdepositandorder', + status: TransactionStatus.confirmed, + time: 1781185241609, + type: TransactionType.perpsDepositAndOrder, + txParams: { from: addresses.from, to: addresses.baseUsdc }, + }, + primaryTransaction: { + chainId: chainIds.arbitrum, + id: 'perps-deposit-and-order-id', + hash: '0xperpsdepositandorder', + status: TransactionStatus.confirmed, + time: 1781185241609, + type: TransactionType.perpsDepositAndOrder, + txParams: { from: addresses.from, to: addresses.baseUsdc }, + }, + }, + mapsABridgeapprovalToAnApprove: { + initialTransaction: { + chainId: chainIds.base, + id: 'bridge-approval-id', + hash: '0xbridgeapproval', + status: TransactionStatus.confirmed, + time: 1716367781000, + type: TransactionType.bridgeApproval, + txParams: { + from: addresses.from, + to: addresses.baseUsdc, + data: '0x095ea7b3', + }, + }, + primaryTransaction: { + chainId: chainIds.base, + id: 'bridge-approval-id', + hash: '0xbridgeapproval', + status: TransactionStatus.confirmed, + time: 1716367781000, + type: TransactionType.bridgeApproval, + txParams: { + from: addresses.from, + to: addresses.baseUsdc, + data: '0x095ea7b3', + }, + }, + }, + mapsAShieldsubscriptionapproveToAnApprove: { + initialTransaction: { + chainId: chainIds.base, + id: 'shield-approve-id', + hash: '0xshieldapprove', + status: TransactionStatus.confirmed, + time: 1716367781000, + type: TransactionType.shieldSubscriptionApprove, + txParams: { + from: addresses.from, + to: addresses.baseUsdc, + data: '0x095ea7b3', + }, + }, + primaryTransaction: { + chainId: chainIds.base, + id: 'shield-approve-id', + hash: '0xshieldapprove', + status: TransactionStatus.confirmed, + time: 1716367781000, + type: TransactionType.shieldSubscriptionApprove, + txParams: { + from: addresses.from, + to: addresses.baseUsdc, + data: '0x095ea7b3', + }, + }, + }, + mapsATokenmethodsetapprovalforallToAnApprove: { + initialTransaction: { + chainId: chainIds.base, + id: 'set-approval-for-all-id', + hash: '0xsetapprovalforall', + status: TransactionStatus.confirmed, + time: 1716367781000, + type: TransactionType.tokenMethodSetApprovalForAll, + txParams: { + from: addresses.from, + to: addresses.baseUsdc, + data: '0xa22cb465', + }, + }, + primaryTransaction: { + chainId: chainIds.base, + id: 'set-approval-for-all-id', + hash: '0xsetapprovalforall', + status: TransactionStatus.confirmed, + time: 1716367781000, + type: TransactionType.tokenMethodSetApprovalForAll, + txParams: { + from: addresses.from, + to: addresses.baseUsdc, + data: '0xa22cb465', + }, + }, + }, + omitsTheAssetidWhenTheToken: { + initialTransaction: { + chainId: chainIds.mainnet, + id: 'unencodable-token-id', + hash: '0xunencodable', + status: TransactionStatus.confirmed, + time: 1716367781000, + type: TransactionType.tokenMethodTransfer, + txParams: { + from: addresses.from, + to: '0xZZ', + data: '0xa9059cbb000000000000000000000000a6372edd08c857870f9c245a17ee6895307957d500000000000000000000000000000000000000000000000000000000000186a0', + }, + }, + primaryTransaction: { + chainId: chainIds.mainnet, + id: 'unencodable-token-id', + hash: '0xunencodable', + status: TransactionStatus.confirmed, + time: 1716367781000, + type: TransactionType.tokenMethodTransfer, + txParams: { + from: addresses.from, + to: '0xZZ', + data: '0xa9059cbb000000000000000000000000a6372edd08c857870f9c245a17ee6895307957d500000000000000000000000000000000000000000000000000000000000186a0', + }, + }, + }, + ignoresWithdrawLogsThatOmitTopics: { + initialTransaction: { + chainId: chainIds.base, + hash: '0xwithdrawnotopics', + status: TransactionStatus.confirmed, + time: 1779912434153, + type: TransactionType.contractInteraction, + txParams: { + from: addresses.from, + to: addresses.baseAavePool, + data: '0x69328dec', + }, + txReceipt: { + logs: [ + { + address: addresses.baseUsdc, + data: '0x0000000000000000000000000000000000000000000000000000000000030d40', + }, + ], + }, + }, + primaryTransaction: { + chainId: chainIds.base, + hash: '0xwithdrawnotopics', + status: TransactionStatus.confirmed, + time: 1779912434153, + type: TransactionType.contractInteraction, + txParams: { + from: addresses.from, + to: addresses.baseAavePool, + data: '0x69328dec', + }, + txReceipt: { + logs: [ + { + address: addresses.baseUsdc, + data: '0x0000000000000000000000000000000000000000000000000000000000030d40', + }, + ], + }, + }, + }, + mapsMusdclaimToClaimmusdbonusWithFrom: { + hasCancelled: false, + hasRetried: false, + initialTransaction: { + chainId: chainIds.lineaMainnet, + id: 'musd-claim-id', + hash: '0xmusdclaim', + status: TransactionStatus.submitted, + time: 1778633325000, + type: TransactionType.musdClaim, + txParams: { + from: addresses.from, + to: addresses.merklDistributor, + value: '0x0', + data: encodeMerklClaimCalldata({ + users: [addresses.from], + tokens: [addresses.lineaMusd], + amounts: ['5000000'], + proofs: [[]], + }), + }, + }, + nonce: '0x1', + primaryTransaction: { + chainId: chainIds.lineaMainnet, + id: 'musd-claim-id', + hash: '0xmusdclaim', + status: TransactionStatus.submitted, + time: 1778633325000, + type: TransactionType.musdClaim, + txParams: { + from: addresses.from, + to: addresses.merklDistributor, + value: '0x0', + data: encodeMerklClaimCalldata({ + users: [addresses.from], + tokens: [addresses.lineaMusd], + amounts: ['5000000'], + proofs: [[]], + }), + }, + }, + transactions: [ + { + chainId: chainIds.lineaMainnet, + id: 'musd-claim-id', + hash: '0xmusdclaim', + status: TransactionStatus.submitted, + time: 1778633325000, + type: TransactionType.musdClaim, + txParams: { + from: addresses.from, + to: addresses.merklDistributor, + value: '0x0', + data: encodeMerklClaimCalldata({ + users: [addresses.from], + tokens: [addresses.lineaMusd], + amounts: ['5000000'], + proofs: [[]], + }), + }, + }, + ], + }, +} as const; + +export const localTransactionFixtures = { + chainIds, + addresses, + transactionGroups, + mapInputs, +}; diff --git a/packages/client-utils/test/test-helpers.ts b/packages/client-utils/test/test-helpers.ts new file mode 100644 index 00000000000..49c1095b1dc --- /dev/null +++ b/packages/client-utils/test/test-helpers.ts @@ -0,0 +1,125 @@ +import { Interface } from '@ethersproject/abi'; +import type { CaipChainId, Hex } from '@metamask/utils'; + +import { formatAddressToAssetId } from '../src/mappers/helpers/caip.js'; +import type { KnownTokenMetadata } from '../src/mappers/helpers/token-metadata.js'; + +const knownTokens: Record< + string, + Record +> = { + 'eip155:1': { + '0xdac17f958d2ee523a2206206994597c13d831ec7': { + symbol: 'USDT', + decimals: 6, + }, + }, + 'eip155:8453': { + '0x833589fcd6edb6e08f4c7c32d4f71b54bda02913': { + symbol: 'USDC', + decimals: 6, + }, + }, + 'eip155:59144': { + '0xaca92e438df0b2401ff60da7e4337b687a2435da': { + symbol: 'mUSD', + decimals: 6, + }, + }, +}; + +/** + * Mock of the package's `getKnownTokenMetadata`. Returns metadata plus the + * encoded CAIP asset id for the small set of tokens the adapter tests rely on. + * + * @param chainId - CAIP-2 (or hex) chain id. + * @param contractAddress - The token contract address. + * @returns The known token metadata, or `undefined`. + */ +export function getKnownTokenMetadata( + chainId: CaipChainId | Hex, + contractAddress?: string, +): KnownTokenMetadata | undefined { + if (!contractAddress) { + return undefined; + } + + const entry = knownTokens[chainId]?.[contractAddress.toLowerCase()]; + + if (!entry) { + return undefined; + } + + const assetId = formatAddressToAssetId(contractAddress, chainId); + + return { ...entry, ...(assetId ? { assetId } : {}) }; +} + +/** + * Encodes ERC-20 `approve(spender, amountOrTokenId)` calldata. + * + * @param address - The spender address. + * @param amountOrTokenId - The approved amount or token id. + * @returns The encoded calldata. + */ +export function buildApproveTransactionData( + address: string, + amountOrTokenId: number, +): Hex { + return new Interface([ + 'function approve(address spender, uint256 amountOrTokenId)', + ]).encodeFunctionData('approve', [address, amountOrTokenId]) as Hex; +} + +/** + * Encodes Permit2 `approve(token, spender, amount, nonce)` calldata. + * + * @param token - The token contract address. + * @param spender - The spender address. + * @param amount - The approved amount. + * @param expiration - The approval expiration / nonce. + * @returns The encoded calldata. + */ +export function buildPermit2ApproveTransactionData( + token: string, + spender: string, + amount: number, + expiration: number, +): Hex { + return new Interface([ + 'function approve(address token, address spender, uint160 amount, uint48 nonce)', + ]).encodeFunctionData('approve', [token, spender, amount, expiration]) as Hex; +} + +const merklClaimAbi = [ + 'function claim(address[] calldata users, address[] calldata tokens, uint256[] calldata amounts, bytes32[][] calldata proofs)', +]; + +/** + * Encodes Merkl `claim(...)` calldata used to exercise the mUSD claim path. + * + * @param args - The claim arguments. + * @param args.users - The claiming user addresses. + * @param args.tokens - The claimed token addresses. + * @param args.amounts - The claimed amounts. + * @param args.proofs - The Merkle proofs. + * @returns The encoded calldata. + */ +export function encodeMerklClaimCalldata({ + users, + tokens, + amounts, + proofs, +}: { + users: string[]; + tokens: string[]; + amounts: string[]; + proofs: string[][]; +}): Hex { + return new Interface(merklClaimAbi).encodeFunctionData('claim', [ + users, + tokens, + amounts, + proofs, + ]) as Hex; +} diff --git a/packages/client-utils/tsconfig.build.json b/packages/client-utils/tsconfig.build.json new file mode 100644 index 00000000000..b0425c280fe --- /dev/null +++ b/packages/client-utils/tsconfig.build.json @@ -0,0 +1,14 @@ +{ + "extends": "../../tsconfig.packages.build.json", + "compilerOptions": { + "baseUrl": "./", + "outDir": "./dist", + "rootDir": "./src" + }, + "references": [ + { "path": "../controller-utils/tsconfig.build.json" }, + { "path": "../core-backend/tsconfig.build.json" }, + { "path": "../transaction-controller/tsconfig.build.json" } + ], + "include": ["../../types", "./src"] +} diff --git a/packages/client-utils/tsconfig.json b/packages/client-utils/tsconfig.json new file mode 100644 index 00000000000..bdb409ea64f --- /dev/null +++ b/packages/client-utils/tsconfig.json @@ -0,0 +1,12 @@ +{ + "extends": "../../tsconfig.packages.json", + "compilerOptions": { + "baseUrl": "./" + }, + "references": [ + { "path": "../controller-utils" }, + { "path": "../core-backend" }, + { "path": "../transaction-controller" } + ], + "include": ["../../types", "./src", "./test"] +} diff --git a/packages/client-utils/typedoc.json b/packages/client-utils/typedoc.json new file mode 100644 index 00000000000..c9da015dbf8 --- /dev/null +++ b/packages/client-utils/typedoc.json @@ -0,0 +1,7 @@ +{ + "entryPoints": ["./src/index.ts"], + "excludePrivate": true, + "hideGenerator": true, + "out": "docs", + "tsconfig": "./tsconfig.build.json" +} diff --git a/packages/compliance-controller/CHANGELOG.md b/packages/compliance-controller/CHANGELOG.md new file mode 100644 index 00000000000..dcac21a245e --- /dev/null +++ b/packages/compliance-controller/CHANGELOG.md @@ -0,0 +1,87 @@ +# 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] + +### Changed + +- Bump `@metamask/utils` from `^11.9.0` to `^11.11.0` ([#9074](https://github.com/MetaMask/core/pull/9074)) +- Bump `@metamask/controller-utils` from `^12.1.0` to `^12.3.0` ([#9058](https://github.com/MetaMask/core/pull/9058), [#9083](https://github.com/MetaMask/core/pull/9083), [#9218](https://github.com/MetaMask/core/pull/9218)) +- Bump `@metamask/messenger` from `^1.2.0` to `^2.0.0` ([#9392](https://github.com/MetaMask/core/pull/9392)) +- Bump `@metamask/superstruct` from `^3.1.0` to `^3.4.1` ([#9754](https://github.com/MetaMask/core/pull/9754)) + +## [2.1.0] + +### Added + +- Add `ComplianceService` support for an explicit Compliance API URL ([#8820](https://github.com/MetaMask/core/pull/8820)) +- Add `selectAreAnyWalletsBlocked` ([#8820](https://github.com/MetaMask/core/pull/8820)) + +### Changed + +- Bump `@metamask/controller-utils` from `^12.0.0` to `^12.1.0` ([#8774](https://github.com/MetaMask/core/pull/8774)) + +### Fixed + +- Match EVM address casing consistently when reading cached wallet compliance statuses ([#8820](https://github.com/MetaMask/core/pull/8820)) + +## [2.0.1] + +### Changed + +- Bump `@metamask/messenger` from `^1.1.0` to `^1.2.0` ([#8373](https://github.com/MetaMask/core/pull/8373), [#8632](https://github.com/MetaMask/core/pull/8632)) +- Bump `@metamask/base-controller` from `^9.0.1` to `^9.1.0` ([#8457](https://github.com/MetaMask/core/pull/8457)) +- Bump `@metamask/controller-utils` from `^11.20.0` to `^12.0.0` ([#8755](https://github.com/MetaMask/core/pull/8755)) + +## [2.0.0] + +### Changed + +- **BREAKING:** Remove proactive bulk-fetch pattern from `ComplianceController` and `ComplianceService` ([#8365](https://github.com/MetaMask/core/pull/8365)) + - `ComplianceControllerState` no longer includes `blockedWallets` or `blockedWalletsLastFetched`. Consumers storing persisted state must drop these fields on migration. + - The `init()` and `updateBlockedWallets()` controller methods have been removed. Consumers should remove any calls to these methods. + - The `blockedWalletsRefreshInterval` constructor option has been removed. + - The `updateBlockedWallets()` service method and its `GET /v1/blocked-wallets` endpoint integration have been removed. + - `ComplianceControllerInitAction`, `ComplianceControllerUpdateBlockedWalletsAction`, and `ComplianceServiceUpdateBlockedWalletsAction` types have been removed from the public API. + - The `BlockedWalletsInfo` type has been removed from the public API. + - `checkWalletCompliance` and `checkWalletsCompliance` now fall back to the per-address `walletComplianceStatusMap` cache when the API is unavailable, re-throwing only if no cached result exists for a requested address. + - `selectIsWalletBlocked` now reads solely from `walletComplianceStatusMap` rather than also checking a cached full blocklist. +- Bump `@metamask/controller-utils` from `^11.19.0` to `^11.20.0` ([#8344](https://github.com/MetaMask/core/pull/8344)) +- Bump `@metamask/messenger` from `^1.0.0` to `^1.1.0` ([#8364](https://github.com/MetaMask/core/pull/8364)) + +## [1.0.2] + +### Changed + +- Bump `@metamask/base-controller` from `^9.0.0` to `^9.0.1` ([#8317](https://github.com/MetaMask/core/pull/8317)) +- Bump `@metamask/messenger` from `^0.3.0` to `^1.0.0` ([#8317](https://github.com/MetaMask/core/pull/8317)) + +## [1.0.1] + +### Fixed + +- Fix package to include files, which were accidentally omitted in 1.0.0 ([#8016](https://github.com/MetaMask/core/pull/8016)) + +## [1.0.0] [DEPRECATED] + +### 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. + +### Changed + +- Bump `@metamask/controller-utils` from `^11.18.0` to `^11.19.0` ([#7995](https://github.com/MetaMask/core/pull/7995)) + +[Unreleased]: https://github.com/MetaMask/core/compare/@metamask/compliance-controller@2.1.0...HEAD +[2.1.0]: https://github.com/MetaMask/core/compare/@metamask/compliance-controller@2.0.1...@metamask/compliance-controller@2.1.0 +[2.0.1]: https://github.com/MetaMask/core/compare/@metamask/compliance-controller@2.0.0...@metamask/compliance-controller@2.0.1 +[2.0.0]: https://github.com/MetaMask/core/compare/@metamask/compliance-controller@1.0.2...@metamask/compliance-controller@2.0.0 +[1.0.2]: https://github.com/MetaMask/core/compare/@metamask/compliance-controller@1.0.1...@metamask/compliance-controller@1.0.2 +[1.0.1]: https://github.com/MetaMask/core/compare/@metamask/compliance-controller@1.0.0...@metamask/compliance-controller@1.0.1 +[1.0.0]: https://github.com/MetaMask/core/releases/tag/@metamask/compliance-controller@1.0.0 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..641e060e86d --- /dev/null +++ b/packages/compliance-controller/README.md @@ -0,0 +1,77 @@ +# `@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. + +## 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, + apiUrl: 'https://compliance.api.cx.metamask.io', +}); + +// 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...', +]); +``` + +## 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..e9dfa2e978e --- /dev/null +++ b/packages/compliance-controller/package.json @@ -0,0 +1,81 @@ +{ + "name": "@metamask/compliance-controller", + "version": "2.1.0", + "description": "Manages OFAC compliance checks for wallet addresses", + "keywords": [ + "Ethereum", + "MetaMask" + ], + "homepage": "https://github.com/MetaMask/core/tree/main/packages/compliance-controller#readme", + "bugs": { + "url": "https://github.com/MetaMask/core/issues" + }, + "license": "MIT", + "repository": { + "type": "git", + "url": "https://github.com/MetaMask/core.git" + }, + "files": [ + "dist/" + ], + "sideEffects": false, + "main": "./dist/index.cjs", + "types": "./dist/index.d.cts", + "exports": { + ".": { + "import": { + "types": "./dist/index.d.mts", + "default": "./dist/index.mjs" + }, + "require": { + "types": "./dist/index.d.cts", + "default": "./dist/index.cjs" + } + }, + "./package.json": "./package.json" + }, + "publishConfig": { + "access": "public", + "registry": "https://registry.npmjs.org/" + }, + "scripts": { + "build": "ts-bridge --project tsconfig.build.json --verbose --clean --no-references", + "build:all": "ts-bridge --project tsconfig.build.json --verbose --clean", + "build:docs": "typedoc", + "changelog:update": "../../scripts/update-changelog.sh @metamask/compliance-controller", + "changelog:validate": "../../scripts/validate-changelog.sh @metamask/compliance-controller", + "lint:tsconfigs": "tsx ../../scripts/lint-tsconfigs/lint-tsconfigs.mts", + "lint:tsconfigs:fix": "tsx ../../scripts/lint-tsconfigs/lint-tsconfigs.mts --fix", + "messenger-action-types:check": "tsx ../../packages/messenger-cli/src/cli.ts --formatter oxfmt --esm --check", + "messenger-action-types:generate": "tsx ../../packages/messenger-cli/src/cli.ts --formatter oxfmt --esm --generate", + "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.1.0", + "@metamask/controller-utils": "^12.3.0", + "@metamask/messenger": "^2.0.0", + "@metamask/superstruct": "^3.4.1", + "@metamask/utils": "^11.11.0", + "reselect": "^5.1.1" + }, + "devDependencies": { + "@metamask/auto-changelog": "^6.1.0", + "@ts-bridge/cli": "^0.6.4", + "@types/jest": "^30.0.0", + "deepmerge": "^4.2.2", + "jest": "^30.4.2", + "nock": "^13.3.1", + "ts-jest": "^29.4.11", + "tsx": "^4.20.5", + "typedoc": "^0.25.13", + "typedoc-plugin-missing-exports": "^2.0.0", + "typescript": "~5.3.3" + }, + "engines": { + "node": "^18.18 || >=20" + } +} diff --git a/packages/compliance-controller/src/ComplianceController-method-action-types.ts b/packages/compliance-controller/src/ComplianceController-method-action-types.ts new file mode 100644 index 00000000000..78e9fd43062 --- /dev/null +++ b/packages/compliance-controller/src/ComplianceController-method-action-types.ts @@ -0,0 +1,51 @@ +/** + * This file is auto generated. + * Do not edit manually. + */ + +import type { ComplianceController } from './ComplianceController.js'; + +/** + * Checks compliance status for a single wallet address via the API and + * persists the result to state. If the API call fails and a previously + * cached result exists for the address, the cached result is returned as a + * fallback. If no cached result exists, the error is re-thrown. + * + * @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. If the API call fails and every requested + * address has a previously cached result, those cached results are returned + * as a fallback. If any address lacks a cached result, the error is + * re-thrown. + * + * @param addresses - The wallet addresses to check. + * @returns The compliance statuses of the wallets. + */ +export type ComplianceControllerCheckWalletsComplianceAction = { + type: `ComplianceController:checkWalletsCompliance`; + handler: ComplianceController['checkWalletsCompliance']; +}; + +/** + * Clears all compliance data from state. + */ +export type ComplianceControllerClearComplianceStateAction = { + type: `ComplianceController:clearComplianceState`; + handler: ComplianceController['clearComplianceState']; +}; + +/** + * Union of all ComplianceController action types. + */ +export type ComplianceControllerMethodActions = + | ComplianceControllerCheckWalletComplianceAction + | ComplianceControllerCheckWalletsComplianceAction + | 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..72f276bf094 --- /dev/null +++ b/packages/compliance-controller/src/ComplianceController.test.ts @@ -0,0 +1,710 @@ +import { deriveStateFromMetadata } from '@metamask/base-controller'; +import { toChecksumHexAddress } from '@metamask/controller-utils'; +import { Messenger, MOCK_ANY_NAMESPACE } from '@metamask/messenger'; +import type { + MockAnyNamespace, + MessengerActions, + MessengerEvents, +} from '@metamask/messenger'; + +import { ComplianceController } from './ComplianceController.js'; +import type { ComplianceControllerMessenger } from './ComplianceController.js'; +import { + selectAreAnyWalletsBlocked, + selectIsWalletBlocked, +} from './selectors.js'; + +const LOWERCASE_EVM_ADDRESS = '0x4e1ff7229bddaf0a73df183a88d9c3a04cc975e0'; +const CHECKSUM_EVM_ADDRESS = toChecksumHexAddress(LOWERCASE_EVM_ADDRESS); + +describe('ComplianceController', () => { + describe('constructor', () => { + it('accepts initial state', async () => { + const givenState = { + walletComplianceStatusMap: { + '0xABC123': { + address: '0xABC123', + blocked: true, + checkedAt: '2026-01-01T00:00:00.000Z', + }, + }, + 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(` + { + "lastCheckedAt": null, + "walletComplianceStatusMap": {}, + } + `); + }); + }); + }); + + describe('selectIsWalletBlocked', () => { + it('returns true if the wallet was checked and found blocked', async () => { + await withController( + { + options: { + state: { + walletComplianceStatusMap: { + '0xBLOCKED': { + address: '0xBLOCKED', + blocked: true, + checkedAt: '2026-01-01T00:00:00.000Z', + }, + }, + }, + }, + }, + ({ controller }) => { + expect(selectIsWalletBlocked('0xBLOCKED')(controller.state)).toBe( + true, + ); + }, + ); + }); + + it('returns false if the wallet is not in the 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 true for an EVM address with different casing than the cached key', async () => { + await withController( + { + options: { + state: { + walletComplianceStatusMap: { + [LOWERCASE_EVM_ADDRESS]: { + address: LOWERCASE_EVM_ADDRESS, + blocked: true, + checkedAt: '2026-01-01T00:00:00.000Z', + }, + }, + }, + }, + }, + ({ controller }) => { + expect( + selectIsWalletBlocked(CHECKSUM_EVM_ADDRESS)(controller.state), + ).toBe(true); + }, + ); + }); + + it('does not use case-insensitive matching for non-EVM addresses', async () => { + await withController( + { + options: { + state: { + walletComplianceStatusMap: { + SolanaAddress: { + address: 'SolanaAddress', + blocked: true, + checkedAt: '2026-01-01T00:00:00.000Z', + }, + }, + }, + }, + }, + ({ controller }) => { + expect(selectIsWalletBlocked('solanaaddress')(controller.state)).toBe( + false, + ); + }, + ); + }); + }); + + describe('selectAreAnyWalletsBlocked', () => { + it('returns true if any cached wallet is blocked', async () => { + await withController( + { + options: { + state: { + walletComplianceStatusMap: { + [LOWERCASE_EVM_ADDRESS]: { + address: LOWERCASE_EVM_ADDRESS, + blocked: true, + checkedAt: '2026-01-01T00:00:00.000Z', + }, + }, + }, + }, + }, + ({ controller }) => { + expect( + selectAreAnyWalletsBlocked(['0xUNKNOWN', CHECKSUM_EVM_ADDRESS])( + controller.state, + ), + ).toBe(true); + }, + ); + }); + + it('returns false if no cached wallet is blocked', async () => { + await withController(({ controller }) => { + expect(selectAreAnyWalletsBlocked([])(controller.state)).toBe(false); + expect( + selectAreAnyWalletsBlocked([LOWERCASE_EVM_ADDRESS])(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'); + }); + }); + + it('returns the cached result if the API call fails and a cached entry exists', async () => { + const cached = { + address: '0xABC123', + blocked: true, + checkedAt: '2026-01-01T00:00:00.000Z', + }; + + await withController( + { + options: { + state: { walletComplianceStatusMap: { '0xABC123': cached } }, + }, + }, + async ({ rootMessenger }) => { + rootMessenger.registerActionHandler( + 'ComplianceService:checkWalletCompliance', + async () => { + throw new Error('API unavailable'); + }, + ); + + const result = await rootMessenger.call( + 'ComplianceController:checkWalletCompliance', + '0xABC123', + ); + + expect(result).toStrictEqual(cached); + }, + ); + }); + + it('returns an EVM cached result if the API call fails and only the address casing differs', async () => { + const cached = { + address: LOWERCASE_EVM_ADDRESS, + blocked: true, + checkedAt: '2026-01-01T00:00:00.000Z', + }; + + await withController( + { + options: { + state: { + walletComplianceStatusMap: { [LOWERCASE_EVM_ADDRESS]: cached }, + }, + }, + }, + async ({ rootMessenger }) => { + rootMessenger.registerActionHandler( + 'ComplianceService:checkWalletCompliance', + async () => { + throw new Error('API unavailable'); + }, + ); + + const result = await rootMessenger.call( + 'ComplianceController:checkWalletCompliance', + CHECKSUM_EVM_ADDRESS, + ); + + expect(result).toStrictEqual(cached); + }, + ); + }); + + it('re-throws the error if the API call fails and no cached entry exists', async () => { + await withController(async ({ rootMessenger }) => { + rootMessenger.registerActionHandler( + 'ComplianceService:checkWalletCompliance', + async () => { + throw new Error('API unavailable'); + }, + ); + + await expect( + rootMessenger.call( + 'ComplianceController:checkWalletCompliance', + '0xNEW', + ), + ).rejects.toThrow('API unavailable'); + }); + }); + }); + + describe('checkWalletCompliance', () => { + beforeEach(() => { + jest.useFakeTimers().setSystemTime(new Date('2026-02-01')); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + it('does the same thing as the messenger action', async () => { + await withController(async ({ controller, rootMessenger }) => { + rootMessenger.registerActionHandler( + 'ComplianceService:checkWalletCompliance', + async (address) => ({ + address, + blocked: false, + }), + ); + + const result = await controller.checkWalletCompliance('0xABC123'); + + expect(result).toStrictEqual({ + address: '0xABC123', + blocked: false, + checkedAt: '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', + }, + }); + }); + }); + + it('returns cached results for all addresses if the API call fails and all are cached', async () => { + const cachedSafe = { + address: '0xSAFE', + blocked: false, + checkedAt: '2026-01-01T00:00:00.000Z', + }; + const cachedBlocked = { + address: '0xBLOCKED', + blocked: true, + checkedAt: '2026-01-01T00:00:00.000Z', + }; + + await withController( + { + options: { + state: { + walletComplianceStatusMap: { + '0xSAFE': cachedSafe, + '0xBLOCKED': cachedBlocked, + }, + }, + }, + }, + async ({ rootMessenger }) => { + rootMessenger.registerActionHandler( + 'ComplianceService:checkWalletsCompliance', + async () => { + throw new Error('API unavailable'); + }, + ); + + const result = await rootMessenger.call( + 'ComplianceController:checkWalletsCompliance', + ['0xSAFE', '0xBLOCKED'], + ); + + expect(result).toStrictEqual([cachedSafe, cachedBlocked]); + }, + ); + }); + + it('returns cached EVM results for all addresses if only address casing differs', async () => { + const cached = { + address: LOWERCASE_EVM_ADDRESS, + blocked: true, + checkedAt: '2026-01-01T00:00:00.000Z', + }; + + await withController( + { + options: { + state: { + walletComplianceStatusMap: { [LOWERCASE_EVM_ADDRESS]: cached }, + }, + }, + }, + async ({ rootMessenger }) => { + rootMessenger.registerActionHandler( + 'ComplianceService:checkWalletsCompliance', + async () => { + throw new Error('API unavailable'); + }, + ); + + const result = await rootMessenger.call( + 'ComplianceController:checkWalletsCompliance', + [CHECKSUM_EVM_ADDRESS], + ); + + expect(result).toStrictEqual([cached]); + }, + ); + }); + + it('re-throws the error if the API call fails and any address has no cached entry', async () => { + const cached = { + address: '0xSAFE', + blocked: false, + checkedAt: '2026-01-01T00:00:00.000Z', + }; + + await withController( + { + options: { + state: { + walletComplianceStatusMap: { '0xSAFE': cached }, + }, + }, + }, + async ({ rootMessenger }) => { + rootMessenger.registerActionHandler( + 'ComplianceService:checkWalletsCompliance', + async () => { + throw new Error('API unavailable'); + }, + ); + + await expect( + rootMessenger.call('ComplianceController:checkWalletsCompliance', [ + '0xSAFE', + '0xNEW', + ]), + ).rejects.toThrow('API unavailable'); + }, + ); + }); + }); + + 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', + }, + }, + lastCheckedAt: '2026-01-01T00:00:00.000Z', + }; + + await withController( + { options: { state: givenState } }, + ({ controller, rootMessenger }) => { + rootMessenger.call('ComplianceController:clearComplianceState'); + + expect(controller.state).toStrictEqual({ + walletComplianceStatusMap: {}, + 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', + }, + }, + lastCheckedAt: '2026-01-01T00:00:00.000Z', + }; + + await withController( + { options: { state: givenState } }, + ({ controller }) => { + controller.clearComplianceState(); + + expect(controller.state).toStrictEqual({ + walletComplianceStatusMap: {}, + 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(` + { + "lastCheckedAt": null, + } + `); + }); + }); + + it('persists expected state', async () => { + await withController(({ controller }) => { + expect( + deriveStateFromMetadata( + controller.state, + controller.metadata, + 'persist', + ), + ).toMatchInlineSnapshot(` + { + "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', + ], + 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..720441dde30 --- /dev/null +++ b/packages/compliance-controller/src/ComplianceController.ts @@ -0,0 +1,285 @@ +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.js'; +import type { + ComplianceServiceCheckWalletComplianceAction, + ComplianceServiceCheckWalletsComplianceAction, +} from './ComplianceService-method-action-types.js'; +import type { WalletComplianceStatus } from './types.js'; +import { getWalletComplianceStatus } from './utils.js'; + +// === 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'; + +// === STATE === + +/** + * Describes the shape of the state object for {@link ComplianceController}. + */ +export type ComplianceControllerState = { + /** + * A map of wallet addresses to their compliance check results, used as a + * fallback cache when the API is unavailable. + */ + walletComplianceStatusMap: Record; + + /** + * 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, + }, + 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: {}, + lastCheckedAt: null, + }; +} + +// === MESSENGER === + +const MESSENGER_EXPOSED_METHODS = [ + 'checkWalletCompliance', + 'checkWalletsCompliance', + '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 = + | ComplianceServiceCheckWalletComplianceAction + | ComplianceServiceCheckWalletsComplianceAction; + +/** + * 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 performs on-demand compliance checks via the API and caches results + * per address in state. Cached results serve as a fallback if the API is + * unavailable for a subsequent check on the same address. + */ +export class ComplianceController extends BaseController< + typeof controllerName, + ComplianceControllerState, + ComplianceControllerMessenger +> { + /** + * 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. + */ + constructor({ + messenger, + state, + }: { + messenger: ComplianceControllerMessenger; + state?: Partial; + }) { + super({ + messenger, + metadata: complianceControllerMetadata, + name: controllerName, + state: { + ...getDefaultComplianceControllerState(), + ...state, + }, + }); + + this.messenger.registerMethodActionHandlers( + this, + MESSENGER_EXPOSED_METHODS, + ); + } + + /** + * Checks compliance status for a single wallet address via the API and + * persists the result to state. If the API call fails and a previously + * cached result exists for the address, the cached result is returned as a + * fallback. If no cached result exists, the error is re-thrown. + * + * @param address - The wallet address to check. + * @returns The compliance status of the wallet. + */ + async checkWalletCompliance( + address: string, + ): Promise { + try { + 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; + } catch (error) { + const cached = getWalletComplianceStatus( + this.state.walletComplianceStatusMap, + address, + ); + if (cached) { + return cached; + } + throw error; + } + } + + /** + * Checks compliance status for multiple wallet addresses via the API and + * persists the results to state. If the API call fails and every requested + * address has a previously cached result, those cached results are returned + * as a fallback. If any address lacks a cached result, the error is + * re-thrown. + * + * @param addresses - The wallet addresses to check. + * @returns The compliance statuses of the wallets. + */ + async checkWalletsCompliance( + addresses: string[], + ): Promise { + try { + 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; + } catch (error) { + const cachedStatuses = addresses.map((address) => + getWalletComplianceStatus( + this.state.walletComplianceStatusMap, + address, + ), + ); + if ( + cachedStatuses.every((status): status is WalletComplianceStatus => + Boolean(status), + ) + ) { + return cachedStatuses; + } + throw error; + } + } + + /** + * Clears all compliance data from state. + */ + clearComplianceState(): void { + this.update((draftState) => { + draftState.walletComplianceStatusMap = {}; + draftState.lastCheckedAt = null; + }); + } +} 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..72948599d51 --- /dev/null +++ b/packages/compliance-controller/src/ComplianceService-method-action-types.ts @@ -0,0 +1,35 @@ +/** + * This file is auto generated. + * Do not edit manually. + */ + +import type { ComplianceService } from './ComplianceService.js'; + +/** + * 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']; +}; + +/** + * Union of all ComplianceService action types. + */ +export type ComplianceServiceMethodActions = + | ComplianceServiceCheckWalletComplianceAction + | ComplianceServiceCheckWalletsComplianceAction; diff --git a/packages/compliance-controller/src/ComplianceService.test.ts b/packages/compliance-controller/src/ComplianceService.test.ts new file mode 100644 index 00000000000..378c2ba6b6d --- /dev/null +++ b/packages/compliance-controller/src/ComplianceService.test.ts @@ -0,0 +1,419 @@ +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.js'; +import { ComplianceService } from './ComplianceService.js'; + +const MOCK_API_URL = 'https://compliance.dev-api.cx.metamask.io'; +const MOCK_PRODUCTION_API_URL = 'https://compliance.api.cx.metamask.io'; +const MOCK_CONFIGURED_API_URL = 'https://configured-compliance.example.com'; + +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), + }); + }); + + it('uses the configured API URL when provided', async () => { + nock(MOCK_CONFIGURED_API_URL).get('/v1/wallet/0xABC123').reply(200, { + address: '0xABC123', + blocked: false, + }); + const { rootMessenger } = getService({ + options: { apiUrl: MOCK_CONFIGURED_API_URL, env: 'development' }, + }); + + const result = await rootMessenger.call( + 'ComplianceService:checkWalletCompliance', + '0xABC123', + ); + + expect(result).toStrictEqual({ + address: '0xABC123', + blocked: false, + }); + }); + + it('preserves path components in the configured API URL', async () => { + nock(MOCK_CONFIGURED_API_URL) + .get('/compliance/v1/wallet/0xABC123') + .reply(200, { + address: '0xABC123', + blocked: false, + }); + const { rootMessenger } = getService({ + options: { apiUrl: `${MOCK_CONFIGURED_API_URL}/compliance` }, + }); + + const result = await rootMessenger.call( + 'ComplianceService:checkWalletCompliance', + '0xABC123', + ); + + expect(result).toStrictEqual({ + address: '0xABC123', + blocked: false, + }); + }); + + it('defaults to the production API URL when no API URL or environment is provided', async () => { + nock(MOCK_PRODUCTION_API_URL).get('/v1/wallet/0xABC123').reply(200, { + address: '0xABC123', + blocked: false, + }); + const { rootMessenger } = getService({ useDefaultEnvironment: true }); + + const result = await rootMessenger.call( + 'ComplianceService:checkWalletCompliance', + '0xABC123', + ); + + expect(result).toStrictEqual({ + address: '0xABC123', + blocked: false, + }); + }); + + it('throws if the configured API URL is invalid', () => { + expect(() => + getService({ options: { apiUrl: 'not-a-valid-url' } }), + ).toThrow('Invalid Compliance API URL: not-a-valid-url'); + }); + + it('throws if the configured API URL includes a query string or fragment', () => { + expect(() => + getService({ + options: { apiUrl: `${MOCK_CONFIGURED_API_URL}?foo=bar` }, + }), + ).toThrow( + `Invalid Compliance API URL: ${MOCK_CONFIGURED_API_URL}?foo=bar. Query strings and fragments are not supported.`, + ); + expect(() => + getService({ + options: { apiUrl: `${MOCK_CONFIGURED_API_URL}#anchor` }, + }), + ).toThrow( + `Invalid Compliance API URL: ${MOCK_CONFIGURED_API_URL}#anchor. Query strings and fragments are not supported.`, + ); + }); + }); + + 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('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 }]); + }); + }); +}); + +/** + * 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`). + * @param args.useDefaultEnvironment - Whether to omit the default test + * environment and use the service constructor default. + * @returns The new service, root messenger, and service messenger. + */ +function getService({ + options = {}, + useDefaultEnvironment = false, +}: { + options?: Partial[0]>; + useDefaultEnvironment?: boolean; +} = {}): { + service: ComplianceService; + rootMessenger: RootMessenger; + messenger: ComplianceServiceMessenger; +} { + const rootMessenger = getRootMessenger(); + const messenger = getMessenger(rootMessenger); + const service = new ComplianceService({ + fetch, + messenger, + ...(useDefaultEnvironment ? {} : { env: 'development' as const }), + ...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..473da277132 --- /dev/null +++ b/packages/compliance-controller/src/ComplianceService.ts @@ -0,0 +1,367 @@ +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, object, string } from '@metamask/superstruct'; +import type { IDisposable } from 'cockatiel'; + +import type { ComplianceServiceMethodActions } from './ComplianceService-method-action-types.js'; + +// === 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', +}; + +export type ComplianceServiceOptions = { + messenger: ComplianceServiceMessenger; + fetch: typeof fetch; + /** + * Explicit Compliance API URL. Prefer this for application builds so API + * endpoints can be managed by build configuration. Path components are + * preserved as a base path for Compliance API routes. + */ + apiUrl?: string; + /** + * Fallback environment used when `apiUrl` is not provided. + */ + env?: ComplianceServiceEnvironment; + policyOptions?: CreateServicePolicyOptions; +}; + +// === MESSENGER === + +const MESSENGER_EXPOSED_METHODS = [ + 'checkWalletCompliance', + 'checkWalletsCompliance', +] 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 +>; + +// === 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, + * apiUrl: 'https://compliance.api.cx.metamask.io', + * }); + * + * // 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.apiUrl - The explicit Compliance API URL. + * @param args.env - The fallback environment to use for the Compliance API + * when `apiUrl` is not provided. + * @param args.policyOptions - Options to pass to `createServicePolicy`, which + * is used to wrap each request. See {@link CreateServicePolicyOptions}. + */ + constructor({ + messenger, + fetch: fetchFunction, + apiUrl, + env = 'production', + policyOptions = {}, + }: ComplianceServiceOptions) { + this.name = serviceName; + this.#messenger = messenger; + this.#fetch = fetchFunction; + this.#complianceApiUrl = getComplianceApiUrl({ apiUrl, 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', + ); + } +} + +function getComplianceApiUrl({ + apiUrl, + env, +}: { + apiUrl?: string; + env: ComplianceServiceEnvironment; +}): string { + if (apiUrl === undefined) { + return COMPLIANCE_API_URLS[env]; + } + + let url: URL; + try { + url = new URL(apiUrl); + } catch { + throw new Error(`Invalid Compliance API URL: ${apiUrl}`); + } + + if (url.search || url.hash) { + throw new Error( + `Invalid Compliance API URL: ${apiUrl}. Query strings and fragments are not supported.`, + ); + } + if (!url.pathname.endsWith('/')) { + url.pathname = `${url.pathname}/`; + } + return url.href; +} + +/** + * 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..c28a9c5e41e --- /dev/null +++ b/packages/compliance-controller/src/index.ts @@ -0,0 +1,34 @@ +export type { + ComplianceServiceActions, + ComplianceServiceEnvironment, + ComplianceServiceEvents, + ComplianceServiceMessenger, + ComplianceServiceOptions, +} from './ComplianceService.js'; +export type { + ComplianceServiceCheckWalletComplianceAction, + ComplianceServiceCheckWalletsComplianceAction, +} from './ComplianceService-method-action-types.js'; +export { ComplianceService } from './ComplianceService.js'; +export type { + ComplianceControllerActions, + ComplianceControllerEvents, + ComplianceControllerGetStateAction, + ComplianceControllerMessenger, + ComplianceControllerState, + ComplianceControllerStateChangeEvent, +} from './ComplianceController.js'; +export type { + ComplianceControllerCheckWalletComplianceAction, + ComplianceControllerCheckWalletsComplianceAction, + ComplianceControllerClearComplianceStateAction, +} from './ComplianceController-method-action-types.js'; +export { + ComplianceController, + getDefaultComplianceControllerState, +} from './ComplianceController.js'; +export { + selectAreAnyWalletsBlocked, + selectIsWalletBlocked, +} from './selectors.js'; +export type { WalletComplianceStatus } from './types.js'; diff --git a/packages/compliance-controller/src/selectors.ts b/packages/compliance-controller/src/selectors.ts new file mode 100644 index 00000000000..5d5660535ed --- /dev/null +++ b/packages/compliance-controller/src/selectors.ts @@ -0,0 +1,43 @@ +import { createSelector } from 'reselect'; + +import type { ComplianceControllerState } from './ComplianceController.js'; +import { getWalletComplianceStatus } from './utils.js'; + +const selectWalletComplianceStatusMap = ( + state: ComplianceControllerState, +): ComplianceControllerState['walletComplianceStatusMap'] => + state.walletComplianceStatusMap; + +/** + * Creates a selector that returns whether a wallet address is blocked, based + * on the per-address compliance status cache. + * + * @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( + [selectWalletComplianceStatusMap], + (statusMap): boolean => + getWalletComplianceStatus(statusMap, address)?.blocked ?? false, + ); + +/** + * Creates a selector that returns whether any wallet address is blocked, based + * on the per-address compliance status cache. + * + * @param addresses - The wallet addresses to check. + * @returns A selector that takes `ComplianceControllerState` and returns + * `true` if any wallet is blocked, `false` otherwise. + */ +export const selectAreAnyWalletsBlocked = ( + addresses: string[], +): ((state: ComplianceControllerState) => boolean) => + createSelector([selectWalletComplianceStatusMap], (statusMap): boolean => + addresses.some( + (address) => getWalletComplianceStatus(statusMap, address)?.blocked, + ), + ); diff --git a/packages/compliance-controller/src/types.ts b/packages/compliance-controller/src/types.ts new file mode 100644 index 00000000000..31cc5bb0ff3 --- /dev/null +++ b/packages/compliance-controller/src/types.ts @@ -0,0 +1,19 @@ +/** + * 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; +}; diff --git a/packages/compliance-controller/src/utils.ts b/packages/compliance-controller/src/utils.ts new file mode 100644 index 00000000000..cfc996186c0 --- /dev/null +++ b/packages/compliance-controller/src/utils.ts @@ -0,0 +1,25 @@ +import { + isEqualCaseInsensitive, + isValidHexAddress, +} from '@metamask/controller-utils'; + +import type { WalletComplianceStatus } from './types.js'; + +export const getWalletComplianceStatus = ( + statusMap: Record, + address: string, +): WalletComplianceStatus | undefined => { + const exactMatch = statusMap[address]; + + if (exactMatch || !isValidHexAddress(address, { allowNonPrefixed: false })) { + return exactMatch; + } + + const matchingAddress = Object.keys(statusMap).find( + (cachedAddress) => + isValidHexAddress(cachedAddress, { allowNonPrefixed: false }) && + isEqualCaseInsensitive(cachedAddress, address), + ); + + return matchingAddress ? statusMap[matchingAddress] : undefined; +}; 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/CHANGELOG.md b/packages/composable-controller/CHANGELOG.md index ca14172c275..bc5c4a8e41f 100644 --- a/packages/composable-controller/CHANGELOG.md +++ b/packages/composable-controller/CHANGELOG.md @@ -1,4 +1,5 @@ # 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/), @@ -6,38 +7,254 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Changed + +- Bump `@metamask/json-rpc-engine` from `^10.2.4` to `^10.3.0` ([#8661](https://github.com/MetaMask/core/pull/8661)) +- Bump `@metamask/messenger` from `^1.0.0` to `^2.0.0` ([#8364](https://github.com/MetaMask/core/pull/8364), [#8373](https://github.com/MetaMask/core/pull/8373), [#8632](https://github.com/MetaMask/core/pull/8632), [#9392](https://github.com/MetaMask/core/pull/9392)) +- Bump `@metamask/base-controller` from `^9.0.1` to `^9.1.0` ([#8457](https://github.com/MetaMask/core/pull/8457)) + +## [12.0.1] + +### Changed + +- Bump `@metamask/base-controller` from `^9.0.0` to `^9.0.1` ([#8317](https://github.com/MetaMask/core/pull/8317)) +- Bump `@metamask/messenger` from `^0.3.0` to `^1.0.0` ([#8317](https://github.com/MetaMask/core/pull/8317)) + +## [12.0.0] + +### Changed + +- **BREAKING:** Migrate `ComposableController` to new `Messenger` from `@metamask/messenger` ([#6710](https://github.com/MetaMask/core/pull/6710)) + - Previously, the controller accepted a `RestrictedMessenger` instance from `@metamask/base-controller`. +- **BREAKING:** Metadata property `anonymous` renamed to `includeInDebugSnapshot` ([#6710](https://github.com/MetaMask/core/pull/6710)) +- Bump `@metamask/base-controller` from `^8.4.2` to `^9.0.0` ([#6962](https://github.com/MetaMask/core/pull/6962)) + +### Fixed + +- Resolve incompatibility of `ChildControllerStateChangeEvents` type with `BaseController` (when used in the `Events` type argument of `ComposableControllerMessenger`) by removing unnecessary nested logic from definition ([#6904](https://github.com/MetaMask/core/pull/6904)) + - Also update generic parameter names `ControllerName` and `ControllerState` to `ChildControllerName`, `ChildControllerState` for reduced ambiguity. + +## [11.1.1] + +### Changed + +- Bump `@metamask/base-controller` from `^8.4.1` to `^8.4.2` ([#6917](https://github.com/MetaMask/core/pull/6917)) + +## [11.1.0] + +### Added + +- Add two new controller state metadata properties: `includeInStateLogs` and `usedInUi` ([#6525](https://github.com/MetaMask/core/pull/6525)) + +### Changed + +- Bump `@metamask/base-controller` from `^8.0.0` to `^8.4.1` ([#5722](https://github.com/MetaMask/core/pull/5722), [#6284](https://github.com/MetaMask/core/pull/6284), [#6355](https://github.com/MetaMask/core/pull/6355), [#6465](https://github.com/MetaMask/core/pull/6465), [#6632](https://github.com/MetaMask/core/pull/6632),[#6807](https://github.com/MetaMask/core/pull/6807)) + +## [11.0.0] + +### Changed + +- **BREAKING:** Re-define `ComposableControllerStateConstraint` type using `StateConstraint` instead of `LegacyControllerStateConstraint` ([#5018](https://github.com/MetaMask/core/pull/5018/)) +- **BREAKING:** Constrain the `ComposableControllerState` generic argument for the `ComposableController` class using `ComposableControllerStateConstraint` instead of `LegacyComposableControllerStateConstraint` ([#5018](https://github.com/MetaMask/core/pull/5018/)) +- Bump `@metamask/base-controller` from `^7.0.2` to `^8.0.0` ([#5079](https://github.com/MetaMask/core/pull/5079)), ([#5135](https://github.com/MetaMask/core/pull/5135)), ([#5305](https://github.com/MetaMask/core/pull/5305)) +- Bump `@metamask/json-rpc-engine` from `^10.0.1` to `^10.0.3` ([#5082](https://github.com/MetaMask/core/pull/5082)), ([#5272](https://github.com/MetaMask/core/pull/5272)) + +## [10.0.0] + +### Changed + +- **BREAKING:** `ComposableController` constructor option `controllers` and generic type argument `ChildControllers` are re-defined from an array of controller instances to an object that maps controller names to controller instances ([#4968](https://github.com/MetaMask/core/pull/4968)) +- **BREAKING:** `ComposableController` class field objects `state` and `metadata` exclude child controllers that do not extend from `BaseController` or `BaseControllerV1`. Any non-controller entries that are passed into the constructor will be removed automatically ([#4968](https://github.com/MetaMask/core/pull/4968)) +- Bump devDependency `@metamask/json-rpc-engine` from `^9.0.3` to `^10.0.1` ([#4798](https://github.com/MetaMask/core/pull/4798), [#4862](https://github.com/MetaMask/core/pull/4862)) + +### Fixed + +- **BREAKING:** `ComposableController` class field object `metadata` now assigns the `StateMetadataProperty`-type object `{ persist: true, anonymous: true }` to each child controller name ([#4968](https://github.com/MetaMask/core/pull/4968)) + - Previously, V2 child controllers were erroneously assigned their own metadata object. This issue was introduced in `@metamask/base-controller@6.0.0`. + +## [9.0.1] + +### Fixed + +- Produce and export ESM-compatible TypeScript type declaration files in addition to CommonJS-compatible declaration files ([#4648](https://github.com/MetaMask/core/pull/4648)) + - Previously, this package shipped with only one variant of type declaration + files, and these files were only CommonJS-compatible, and the `exports` + field in `package.json` linked to these files. This is an anti-pattern and + was rightfully flagged by the + ["Are the Types Wrong?"](https://arethetypeswrong.github.io/) tool as + ["masquerading as CJS"](https://github.com/arethetypeswrong/arethetypeswrong.github.io/blob/main/docs/problems/FalseCJS.md). + All of the ATTW checks now pass. +- Remove chunk files ([#4648](https://github.com/MetaMask/core/pull/4648)). + - Previously, the build tool we used to generate JavaScript files extracted + common code to "chunk" files. While this was intended to make this package + more tree-shakeable, it also made debugging more difficult for our + development teams. These chunk files are no longer present. + +## [9.0.0] + +### Changed + +- Bump `@metamask/base-controller` from `^6.0.3` to `^7.0.0` ([#4643](https://github.com/MetaMask/core/pull/4643)) + +### Removed + +- **BREAKING:** Remove exports for types `LegacyControllerStateConstraint`, `RestrictedControllerMessengerConstraint`, and type guard functions `isBaseController`, `isBaseControllerV1` ([#4467](https://github.com/MetaMask/core/pull/4467)) + - These have been migrated to `@metamask/base-controller@7.0.0`. + +## [8.0.0] + +### Changed + +- **BREAKING:** Add two required generic parameters to the `ComposableController` class: `ComposedControllerState` (constrained by `LegacyComposableControllerStateConstraint`) and `ChildControllers` (constrained by `ControllerInstance`) ([#4467](https://github.com/MetaMask/core/pull/4467)) +- **BREAKING:** The type guard `isBaseController` now validates that the input has an object-type property named `metadata` in addition to its existing checks ([#4467](https://github.com/MetaMask/core/pull/4467)) +- **BREAKING:** The type guard `isBaseControllerV1` now validates that the input has object-type properties `config`, `state`, and function-type property `subscribe`, in addition to its existing checks ([#4467](https://github.com/MetaMask/core/pull/4467)) +- **BREAKING:** Narrow `LegacyControllerStateConstraint` type from `BaseState | StateConstraint` to `BaseState & object | StateConstraint` ([#4467](https://github.com/MetaMask/core/pull/4467)) +- Add an optional generic parameter `ControllerName` to the `RestrictedControllerMessengerConstraint` type, which extends `string` and defaults to `string` ([#4467](https://github.com/MetaMask/core/pull/4467)) +- Bump `@metamask/base-controller` from `~6.0.0` to `~6.0.3` ([#4517](https://github.com/MetaMask/core/pull/4517), [#4544](https://github.com/MetaMask/core/pull/4544), [#4625](https://github.com/MetaMask/core/pull/4625)) +- Bump `typescript` from `~4.9.5` to `~5.2.2` and set `module{,Resolution}` options to `Node16` ([#3645](https://github.com/MetaMask/core/pull/3645), [#4576](https://github.com/MetaMask/core/pull/4576), [#4584](https://github.com/MetaMask/core/pull/4584)) + +### Fixed + +- **BREAKING:** The `ComposableController` class raises a type error if a non-controller with no `state` property is passed into the `ChildControllers` generic parameter or the `controllers` constructor option ([#4467](https://github.com/MetaMask/core/pull/4467)) + - Previously, a runtime error was thrown at class instantiation with no type-level enforcement. +- When the `ComposableController` class is instantiated, its messenger now attempts to subscribe to all child controller `stateChange` events that are included in the messenger's events allowlist ([#4467](https://github.com/MetaMask/core/pull/4467)) + - This was always the expected behavior, but a bug introduced in `@metamask/composable-controller@6.0.0` caused `stateChange` event subscriptions to fail. +- `isBaseController` and `isBaseControllerV1` no longer return false negatives ([#4467](https://github.com/MetaMask/core/pull/4467)) + - The `instanceof` operator is no longer used to validate that the input is a subclass of `BaseController` or `BaseControllerV1`. +- The `ChildControllerStateChangeEvents` type checks that the child controller's state extends from the `StateConstraintV1` type instead of from `Record` ([#4467](https://github.com/MetaMask/core/pull/4467)) + - V1 controllers define their state types using the `interface` keyword, which are incompatible with `Record` by default. This resulted in `ChildControllerStateChangeEvents` failing to generate `stateChange` events for V1 controllers and returning `never`. + +## [7.0.0] + +### Changed + +- **BREAKING:** Bump minimum Node version to 18.18 ([#3611](https://github.com/MetaMask/core/pull/3611)) +- Bump `@metamask/base-controller` to `^6.0.0` ([#4352](https://github.com/MetaMask/core/pull/4352)) +- Bump `@metamask/json-rpc-engine` to `^9.0.0` ([#4352](https://github.com/MetaMask/core/pull/4352)) + +## [6.0.2] + +### Added + +- Adds and exports new types: ([#3952](https://github.com/MetaMask/core/pull/3952)) + - `RestrictedControllerMessengerConstraint`, which is the narrowest supertype of all controller-messenger instances. + - `LegacyControllerStateConstraint`, a universal supertype for the controller state object, encompassing both BaseControllerV1 and BaseControllerV2 state. + - `ComposableControllerStateConstraint`, the narrowest supertype for the composable controller state object. + +### Changed + +- **BREAKING:** The `ComposableController` class is now a generic class that expects one generic argument `ComposableControllerState` ([#3952](https://github.com/MetaMask/core/pull/3952)). + - **BREAKING:** For the `ComposableController` class to be typed correctly, any of its child controllers that extend `BaseControllerV1` must have an overridden `name` property that is defined using the `as const` assertion. +- **BREAKING:** The types `ComposableControllerStateChangeEvent`, `ComposableControllerEvents`, `ComposableControllerMessenger` are now generic types that expect one generic argument `ComposableControllerState` ([#3952](https://github.com/MetaMask/core/pull/3952)). +- Bump `@metamask/json-rpc-engine` to `^8.0.2` ([#4234](https://github.com/MetaMask/core/pull/4234)) +- Bump `@metamask/base-controller` to `^5.0.2` ([#4232](https://github.com/MetaMask/core/pull/4232)) + +## [6.0.1] + +### Fixed + +- Fix `types` field in `package.json` ([#4047](https://github.com/MetaMask/core/pull/4047)) + +## [6.0.0] + +### Added + +- **BREAKING**: Add ESM build ([#3998](https://github.com/MetaMask/core/pull/3998)) + - It's no longer possible to import files from `./dist` directly. +- Add and export functions `isBaseControllerV1` and `isBaseController`, which are type guards for validating controller instances ([#3904](https://github.com/MetaMask/core/pull/3904)) +- `ComposableController` now accommodates `BaseControllerV1` controllers that use a messenger (specifically, which have a `messagingSystem` property which is an instance of `RestrictedControllerMessenger`), by subscribing to the `stateChange` event of the messenger instead of using the `subscribe` method on the controller ([#3964](https://github.com/MetaMask/core/pull/3964)) + +### Changed + +- **BREAKING:** Passing a non-controller into `controllers` constructor option now throws an error ([#3904](https://github.com/MetaMask/core/pull/3904)) +- **BREAKING:** The `AllowedAction` parameter of the `ComposableControllerMessenger` type is narrowed from `string` to `never`, as `ComposableController` does not use any external controller actions ([#3904](https://github.com/MetaMask/core/pull/3904)) +- **BREAKING:** Bump `@metamask/base-controller` to `^5.0.0` ([#4039](https://github.com/MetaMask/core/pull/4039)) + - This version has a number of breaking changes. See the changelog for more. +- Relax `payload` in `ComposableControllerStateChangeEvent` to use `Record` rather than `Record` ([#3949](https://github.com/MetaMask/core/pull/3949)) +- Bump `@metamask/json-rpc-engine` to `^8.0.0` ([#4039](https://github.com/MetaMask/core/pull/4039)) + +### Removed + +- **BREAKING:** Remove `flatState` getter from `ComposableController` ([#3877](https://github.com/MetaMask/core/pull/3877)) + - This method was confusing to use in practice. Consumers should use the `ComposableController` state directly. +- **BREAKING:** Remove `ControllerList` as an exported type ([#3904](https://github.com/MetaMask/core/pull/3904)) + - There is no replacement. + +## [5.0.1] + +### Changed + +- Bump `@metamask/base-controller` to `^4.1.1` ([#3760](https://github.com/MetaMask/core/pull/3760), [#3821](https://github.com/MetaMask/core/pull/3821)) + +## [5.0.0] + +### Added + +- Add types `ComposableControllerState`, `ComposableControllerStateChangeEvent`, `ComposableControllerEvents`, `ComposableControllerMessenger` ([#3590](https://github.com/MetaMask/core/pull/3590)) + +### Changed + +- **BREAKING:** `ComposableController` is upgraded to extend `BaseControllerV2` ([#3590](https://github.com/MetaMask/core/pull/3590)) + - The constructor now expects an options object with required properties `controllers` and `messenger` as its only argument. + - `ComposableController` no longer has a `subscribe` method. Instead, listeners for `ComposableController` events must be registered to the controller messenger that generated the restricted messenger assigned to the instance's `messagingSystem` class field. + - Any getters for `ComposableController` state that access the internal class field directly should be refactored to instead use listeners that are subscribed to `ComposableControllerStateChangeEvent`. +- Bump `@metamask/base-controller` to `^4.0.1` ([#3695](https://github.com/MetaMask/core/pull/3695)) + +## [4.0.0] + +### Changed + +- **BREAKING:** Bump `@metamask/base-controller` to ^4.0.0 ([#2063](https://github.com/MetaMask/core/pull/2063)) + - This is breaking because the type of the `messenger` has backward-incompatible changes. See the changelog for this package for more. + ## [3.0.3] + ### Changed + - Bump dependency on `@metamask/base-controller` to ^3.2.3 ([#1747](https://github.com/MetaMask/core/pull/1747)) ## [3.0.2] + ### Changed + - Update TypeScript to v4.8.x ([#1718](https://github.com/MetaMask/core/pull/1718)) ## [3.0.1] + ### Changed + - Bump dependency on `@metamask/base-controller` to ^3.2.1 ## [3.0.0] + ### Changed + - **BREAKING:** Bump to Node 16 ([#1262](https://github.com/MetaMask/core/pull/1262)) ## [2.0.0] + ### Removed + - **BREAKING:** Remove `isomorphic-fetch` ([#1106](https://github.com/MetaMask/controllers/pull/1106)) - Consumers must now import `isomorphic-fetch` or another polyfill themselves if they are running in an environment without `fetch` ## [1.0.2] + ### Changed + - Rename this repository to `core` ([#1031](https://github.com/MetaMask/controllers/pull/1031)) -- Update `@metamask/controller-utils` package ([#1041](https://github.com/MetaMask/controllers/pull/1041)) +- Update `@metamask/controller-utils` package ([#1041](https://github.com/MetaMask/controllers/pull/1041)) ## [1.0.1] + ### Changed + - Relax dependency on `@metamask/controller-utils` (use `^` instead of `~`) ([#998](https://github.com/MetaMask/core/pull/998)) ## [1.0.0] + ### Added + - Initial release - As a result of converting our shared controllers repo into a monorepo ([#831](https://github.com/MetaMask/core/pull/831)), we've created this package from select parts of [`@metamask/controllers` v33.0.0](https://github.com/MetaMask/core/tree/v33.0.0), namely: - `src/ComposableController.ts` @@ -45,7 +262,23 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 All changes listed after this point were applied to this package following the monorepo conversion. -[Unreleased]: https://github.com/MetaMask/core/compare/@metamask/composable-controller@3.0.3...HEAD +[Unreleased]: https://github.com/MetaMask/core/compare/@metamask/composable-controller@12.0.1...HEAD +[12.0.1]: https://github.com/MetaMask/core/compare/@metamask/composable-controller@12.0.0...@metamask/composable-controller@12.0.1 +[12.0.0]: https://github.com/MetaMask/core/compare/@metamask/composable-controller@11.1.1...@metamask/composable-controller@12.0.0 +[11.1.1]: https://github.com/MetaMask/core/compare/@metamask/composable-controller@11.1.0...@metamask/composable-controller@11.1.1 +[11.1.0]: https://github.com/MetaMask/core/compare/@metamask/composable-controller@11.0.0...@metamask/composable-controller@11.1.0 +[11.0.0]: https://github.com/MetaMask/core/compare/@metamask/composable-controller@10.0.0...@metamask/composable-controller@11.0.0 +[10.0.0]: https://github.com/MetaMask/core/compare/@metamask/composable-controller@9.0.1...@metamask/composable-controller@10.0.0 +[9.0.1]: https://github.com/MetaMask/core/compare/@metamask/composable-controller@9.0.0...@metamask/composable-controller@9.0.1 +[9.0.0]: https://github.com/MetaMask/core/compare/@metamask/composable-controller@8.0.0...@metamask/composable-controller@9.0.0 +[8.0.0]: https://github.com/MetaMask/core/compare/@metamask/composable-controller@7.0.0...@metamask/composable-controller@8.0.0 +[7.0.0]: https://github.com/MetaMask/core/compare/@metamask/composable-controller@6.0.2...@metamask/composable-controller@7.0.0 +[6.0.2]: https://github.com/MetaMask/core/compare/@metamask/composable-controller@6.0.1...@metamask/composable-controller@6.0.2 +[6.0.1]: https://github.com/MetaMask/core/compare/@metamask/composable-controller@6.0.0...@metamask/composable-controller@6.0.1 +[6.0.0]: https://github.com/MetaMask/core/compare/@metamask/composable-controller@5.0.1...@metamask/composable-controller@6.0.0 +[5.0.1]: https://github.com/MetaMask/core/compare/@metamask/composable-controller@5.0.0...@metamask/composable-controller@5.0.1 +[5.0.0]: https://github.com/MetaMask/core/compare/@metamask/composable-controller@4.0.0...@metamask/composable-controller@5.0.0 +[4.0.0]: https://github.com/MetaMask/core/compare/@metamask/composable-controller@3.0.3...@metamask/composable-controller@4.0.0 [3.0.3]: https://github.com/MetaMask/core/compare/@metamask/composable-controller@3.0.2...@metamask/composable-controller@3.0.3 [3.0.2]: https://github.com/MetaMask/core/compare/@metamask/composable-controller@3.0.1...@metamask/composable-controller@3.0.2 [3.0.1]: https://github.com/MetaMask/core/compare/@metamask/composable-controller@3.0.0...@metamask/composable-controller@3.0.1 diff --git a/packages/composable-controller/LICENSE b/packages/composable-controller/LICENSE index ddfbecf9020..bbed2e24b91 100644 --- a/packages/composable-controller/LICENSE +++ b/packages/composable-controller/LICENSE @@ -18,3 +18,4 @@ 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/composable-controller/package.json b/packages/composable-controller/package.json index ec87db2da8c..95e8c1cce5c 100644 --- a/packages/composable-controller/package.json +++ b/packages/composable-controller/package.json @@ -1,53 +1,76 @@ { "name": "@metamask/composable-controller", - "version": "3.0.3", + "version": "12.0.1", "description": "Consolidates the state from multiple controllers into one", "keywords": [ - "MetaMask", - "Ethereum" + "Ethereum", + "MetaMask" ], "homepage": "https://github.com/MetaMask/core/tree/main/packages/composable-controller#readme", "bugs": { "url": "https://github.com/MetaMask/core/issues" }, + "license": "MIT", "repository": { "type": "git", "url": "https://github.com/MetaMask/core.git" }, - "license": "MIT", - "main": "./dist/index.js", - "types": "./dist/index.d.ts", "files": [ "dist/" ], + "sideEffects": false, + "main": "./dist/index.cjs", + "types": "./dist/index.d.cts", + "exports": { + ".": { + "import": { + "types": "./dist/index.d.mts", + "default": "./dist/index.mjs" + }, + "require": { + "types": "./dist/index.d.cts", + "default": "./dist/index.cjs" + } + }, + "./package.json": "./package.json" + }, + "publishConfig": { + "access": "public", + "registry": "https://registry.npmjs.org/" + }, "scripts": { + "build": "ts-bridge --project tsconfig.build.json --verbose --clean --no-references", + "build:all": "ts-bridge --project tsconfig.build.json --verbose --clean", "build:docs": "typedoc", + "changelog:update": "../../scripts/update-changelog.sh @metamask/composable-controller", "changelog:validate": "../../scripts/validate-changelog.sh @metamask/composable-controller", - "publish:preview": "yarn npm publish --tag preview", - "test": "jest", - "test:clean": "jest --clearCache", - "test:watch": "jest --watch" + "lint:tsconfigs": "tsx ../../scripts/lint-tsconfigs/lint-tsconfigs.mts", + "lint:tsconfigs:fix": "tsx ../../scripts/lint-tsconfigs/lint-tsconfigs.mts --fix", + "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": "^3.2.3" + "@metamask/base-controller": "^9.1.0", + "@metamask/messenger": "^2.0.0" }, "devDependencies": { - "@metamask/auto-changelog": "^3.1.0", - "@types/jest": "^27.4.1", + "@metamask/auto-changelog": "^6.1.0", + "@metamask/json-rpc-engine": "^10.5.0", + "@ts-bridge/cli": "^0.6.4", + "@types/jest": "^30.0.0", "deepmerge": "^4.2.2", "immer": "^9.0.6", - "jest": "^27.5.1", - "sinon": "^9.2.4", - "ts-jest": "^27.1.4", - "typedoc": "^0.24.8", + "jest": "^30.4.2", + "ts-jest": "^29.4.11", + "tsx": "^4.20.5", + "typedoc": "^0.25.13", "typedoc-plugin-missing-exports": "^2.0.0", - "typescript": "~4.8.4" + "typescript": "~5.3.3" }, "engines": { - "node": ">=16.0.0" - }, - "publishConfig": { - "access": "public", - "registry": "https://registry.npmjs.org/" + "node": "^18.18 || >=20" } } diff --git a/packages/composable-controller/src/ComposableController.test.ts b/packages/composable-controller/src/ComposableController.test.ts index bbc416aabff..7cf0e9c4678 100644 --- a/packages/composable-controller/src/ComposableController.test.ts +++ b/packages/composable-controller/src/ComposableController.test.ts @@ -1,435 +1,731 @@ -import type { - BaseState, - RestrictedControllerMessenger, -} from '@metamask/base-controller'; import { BaseController, - BaseControllerV2, - ControllerMessenger, + deriveStateFromMetadata, +} from '@metamask/base-controller'; +import type { + ControllerStateChangeEvent, + ControllerGetStateAction, + StateConstraint, } from '@metamask/base-controller'; +import { JsonRpcEngine } from '@metamask/json-rpc-engine'; +import { MOCK_ANY_NAMESPACE, Messenger } from '@metamask/messenger'; +import type { + MessengerActions, + MessengerEvents, + MockAnyNamespace, +} from '@metamask/messenger'; import type { Patch } from 'immer'; -import * as sinon from 'sinon'; -import { ComposableController } from './ComposableController'; +import type { + ChildControllerStateChangeEvents, + ComposableControllerActions, + ComposableControllerEvents, +} from './ComposableController.js'; +import { + ComposableController, + INVALID_CONTROLLER_ERROR, +} from './ComposableController.js'; -// Mock BaseControllerV2 classes +// Mock BaseController classes + +type RootMessenger = Messenger< + MockAnyNamespace, + MessengerActions | MessengerActions, + MessengerEvents | MessengerEvents +>; type FooControllerState = { foo: string; }; +type FooControllerAction = ControllerGetStateAction< + 'FooController', + FooControllerState +>; type FooControllerEvent = { type: `FooController:stateChange`; payload: [FooControllerState, Patch[]]; }; -type FooMessenger = RestrictedControllerMessenger< +type FooMessenger = Messenger< 'FooController', - never, - FooControllerEvent, - string, - never + FooControllerAction, + FooControllerEvent | QuzControllerEvent, + RootMessenger >; const fooControllerStateMetadata = { foo: { persist: true, - anonymous: true, + includeInDebugSnapshot: true, + usedInUi: false, + includeInStateLogs: false, }, }; -class FooController extends BaseControllerV2< +class FooController extends BaseController< 'FooController', FooControllerState, FooMessenger > { - constructor(messagingSystem: FooMessenger) { + constructor(messenger: FooMessenger) { super({ - messenger: messagingSystem, + messenger, metadata: fooControllerStateMetadata, name: 'FooController', state: { foo: 'foo' }, }); } - updateFoo(foo: string) { + updateFoo(foo: string): void { super.update((state) => { state.foo = foo; }); } } -// Mock BaseController classes +type QuzControllerState = { + quz: string; +}; +type QuzControllerAction = ControllerGetStateAction< + 'QuzController', + QuzControllerState +>; +type QuzControllerEvent = { + type: `QuzController:stateChange`; + payload: [QuzControllerState, Patch[]]; +}; -interface BarControllerState extends BaseState { - bar: string; -} -class BarController extends BaseController { - defaultState = { - bar: 'bar', - }; +type QuzMessenger = Messenger< + 'QuzController', + QuzControllerAction, + QuzControllerEvent, + RootMessenger +>; - override name = 'BarController'; +const quzControllerStateMetadata = { + quz: { + persist: true, + includeInDebugSnapshot: true, + usedInUi: false, + includeInStateLogs: false, + }, +}; - constructor() { - super(); - this.initialize(); +class QuzController extends BaseController< + 'QuzController', + QuzControllerState, + QuzMessenger +> { + constructor(messenger: QuzMessenger) { + super({ + messenger, + metadata: quzControllerStateMetadata, + name: 'QuzController', + state: { quz: 'quz' }, + }); } - updateBar(bar: string) { - super.update({ bar }); + updateQuz(quz: string): void { + super.update((state) => { + state.quz = quz; + }); } } -interface BazControllerState extends BaseState { - baz: string; -} -class BazController extends BaseController { - defaultState = { - baz: 'baz', - }; - - override name = 'BazController'; +type ComposableControllerMessenger = Messenger< + 'ComposableController', + ControllerGetStateAction<'ComposableController', State>, + | ControllerStateChangeEvent<'ComposableController', State> + | FooControllerEvent, + RootMessenger +>; - constructor() { - super(); - this.initialize(); - } -} +type ControllersMap = { + /* eslint-disable @typescript-eslint/naming-convention */ + FooController: FooController; + QuzController: QuzController; + /* eslint-enable @typescript-eslint/naming-convention */ +}; describe('ComposableController', () => { - afterEach(() => { - sinon.restore(); - }); - describe('BaseController', () => { it('should compose controller state', () => { - const composableMessenger = new ControllerMessenger().getRestricted< - 'ComposableController', - never, - never - >({ name: 'ComposableController' }); - const controller = new ComposableController( - [new BarController(), new BazController()], - composableMessenger, - ); - - expect(controller.state).toStrictEqual({ - BarController: { bar: 'bar' }, - BazController: { baz: 'baz' }, + type ComposableControllerState = { + /* eslint-disable @typescript-eslint/naming-convention */ + QuzController: QuzControllerState; + FooController: FooControllerState; + /* eslint-enable @typescript-eslint/naming-convention */ + }; + const messenger: RootMessenger = new Messenger({ + namespace: MOCK_ANY_NAMESPACE, }); - }); - - it('should compose flat controller state', () => { - const composableMessenger = new ControllerMessenger().getRestricted< - 'ComposableController', - never, - never - >({ name: 'ComposableController' }); - const controller = new ComposableController( - [new BarController(), new BazController()], - composableMessenger, - ); - - expect(controller.flatState).toStrictEqual({ - bar: 'bar', - baz: 'baz', + const fooMessenger: FooMessenger = new Messenger({ + namespace: 'FooController', + parent: messenger, }); - }); - - it('should notify listeners of nested state change', () => { - const composableMessenger = new ControllerMessenger().getRestricted< - 'ComposableController', - never, - never - >({ name: 'ComposableController' }); - const barController = new BarController(); - const controller = new ComposableController( - [barController], - composableMessenger, - ); - const listener = sinon.stub(); - controller.subscribe(listener); - - barController.updateBar('something different'); - - expect(listener.calledOnce).toBe(true); - expect(listener.getCall(0).args[0]).toStrictEqual({ - BarController: { - bar: 'something different', - }, + messenger.delegate({ + messenger: fooMessenger, + events: ['QuzController:stateChange'], }); - }); - }); - - describe('BaseControllerV2', () => { - it('should compose controller state', () => { - const controllerMessenger = new ControllerMessenger< - never, - FooControllerEvent - >(); - const fooControllerMessenger = controllerMessenger.getRestricted< - 'FooController', - never, - never - >({ - name: 'FooController', + const quzMessenger: QuzMessenger = new Messenger({ + namespace: 'QuzController', + parent: messenger, }); - const fooController = new FooController(fooControllerMessenger); + const fooController = new FooController(fooMessenger); + const quzController = new QuzController(quzMessenger); - const composableControllerMessenger = controllerMessenger.getRestricted< + const composableControllerMessenger = new Messenger< 'ComposableController', never, - 'FooController:stateChange' + FooControllerEvent | QuzControllerEvent, + RootMessenger >({ - name: 'ComposableController', - allowedEvents: ['FooController:stateChange'], + namespace: 'ComposableController', + parent: messenger, }); - const composableController = new ComposableController( - [fooController], - composableControllerMessenger, - ); - expect(composableController.state).toStrictEqual({ - FooController: { foo: 'foo' }, - }); - }); - - it('should compose flat controller state', () => { - const controllerMessenger = new ControllerMessenger< - never, - FooControllerEvent - >(); - const fooControllerMessenger = controllerMessenger.getRestricted< - 'FooController', - never, - never - >({ - name: 'FooController', + messenger.delegate({ + messenger: composableControllerMessenger, + events: ['FooController:stateChange', 'QuzController:stateChange'], }); - const fooController = new FooController(fooControllerMessenger); - const composableControllerMessenger = controllerMessenger.getRestricted< - 'ComposableController', - never, - 'FooController:stateChange' + const composableController = new ComposableController< + ComposableControllerState, + Pick >({ - name: 'ComposableController', - allowedEvents: ['FooController:stateChange'], + controllers: { + FooController: fooController, + QuzController: quzController, + }, + messenger: composableControllerMessenger, }); - const composableController = new ComposableController( - [fooController], - composableControllerMessenger, - ); - expect(composableController.flatState).toStrictEqual({ - foo: 'foo', + expect(composableController.state).toStrictEqual({ + FooController: { foo: 'foo' }, + QuzController: { quz: 'quz' }, }); }); it('should notify listeners of nested state change', () => { - const controllerMessenger = new ControllerMessenger< - never, - FooControllerEvent - >(); - const fooControllerMessenger = controllerMessenger.getRestricted< + type ComposableControllerState = { + /* eslint-disable @typescript-eslint/naming-convention */ + FooController: FooControllerState; + /* eslint-enable @typescript-eslint/naming-convention */ + }; + const messenger = new Messenger< + MockAnyNamespace, + | FooControllerAction + | ComposableControllerActions, + | FooControllerEvent + | ComposableControllerEvents + >({ + namespace: MOCK_ANY_NAMESPACE, + }); + const fooControllerMessenger = new Messenger< 'FooController', - never, - never + FooControllerAction, + FooControllerEvent, + typeof messenger >({ - name: 'FooController', + namespace: 'FooController', + parent: messenger, }); const fooController = new FooController(fooControllerMessenger); - const composableControllerMessenger = controllerMessenger.getRestricted< - 'ComposableController', - never, - 'FooController:stateChange' + const composableControllerMessenger: ComposableControllerMessenger = + new Messenger({ + namespace: 'ComposableController', + parent: messenger, + }); + messenger.delegate({ + messenger: composableControllerMessenger, + events: ['FooController:stateChange'], + }); + // eslint-disable-next-line no-new + new ComposableController< + ComposableControllerState, + Pick >({ - name: 'ComposableController', - allowedEvents: ['FooController:stateChange'], + controllers: { + FooController: fooController, + }, + messenger: composableControllerMessenger, }); - const composableController = new ComposableController( - [fooController], - composableControllerMessenger, - ); - const listener = sinon.stub(); - composableController.subscribe(listener); - fooController.updateFoo('bar'); + 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: 'bar', + foo: 'qux', }, }); }); }); - describe('Mixed BaseController and BaseControllerV2', () => { - it('should compose controller state', () => { - const barController = new BarController(); - const controllerMessenger = new ControllerMessenger< - never, - FooControllerEvent - >(); - const fooControllerMessenger = controllerMessenger.getRestricted< + it('should notify listeners of BaseController state change', () => { + type ComposableControllerState = { + /* eslint-disable @typescript-eslint/naming-convention */ + QuzController: QuzControllerState; + FooController: FooControllerState; + /* eslint-enable @typescript-eslint/naming-convention */ + }; + const messenger = new Messenger< + MockAnyNamespace, + | ComposableControllerActions + | QuzControllerAction + | FooControllerAction, + | ComposableControllerEvents + | ChildControllerStateChangeEvents + >({ namespace: MOCK_ANY_NAMESPACE }); + const quzControllerMessenger = new Messenger< + 'QuzController', + QuzControllerAction, + QuzControllerEvent, + typeof messenger + >({ + namespace: 'QuzController', + parent: messenger, + }); + const quzController = new QuzController(quzControllerMessenger); + const fooControllerMessenger = new Messenger< + 'FooController', + FooControllerAction, + FooControllerEvent, + typeof messenger + >({ + namespace: 'FooController', + parent: messenger, + }); + const fooController = new FooController(fooControllerMessenger); + const composableControllerMessenger = new Messenger< + 'ComposableController', + ComposableControllerActions, + | ComposableControllerEvents + | FooControllerEvent + | QuzControllerEvent, + typeof messenger + >({ + namespace: 'ComposableController', + parent: messenger, + }); + messenger.delegate({ + messenger: composableControllerMessenger, + events: ['QuzController:stateChange', 'FooController:stateChange'], + }); + // eslint-disable-next-line no-new + new ComposableController< + ComposableControllerState, + Pick + >({ + controllers: { + QuzController: quzController, + FooController: fooController, + }, + messenger: composableControllerMessenger, + }); + + const listener = jest.fn(); + messenger.subscribe('ComposableController:stateChange', listener); + fooController.updateFoo('qux'); + + expect(listener).toHaveBeenCalledTimes(1); + expect(listener.mock.calls[0][0]).toStrictEqual({ + QuzController: { + quz: 'quz', + }, + FooController: { + foo: 'qux', + }, + }); + }); + + it('should not throw if child state change event subscription fails', () => { + type ComposableControllerState = { + /* eslint-disable @typescript-eslint/naming-convention */ + FooController: FooControllerState; + /* eslint-enable @typescript-eslint/naming-convention */ + }; + const messenger = new Messenger< + MockAnyNamespace, + | ComposableControllerActions + | FooControllerAction, + ComposableControllerEvents | FooControllerEvent + >({ namespace: MOCK_ANY_NAMESPACE }); + const fooControllerMessenger = new Messenger< + 'FooController', + FooControllerAction, + FooControllerEvent, + typeof messenger + >({ + namespace: 'FooController', + parent: messenger, + }); + const fooController = new FooController(fooControllerMessenger); + const composableControllerMessenger = new Messenger< + 'ComposableController', + ComposableControllerActions, + | ComposableControllerEvents + | FooControllerEvent, + typeof messenger + >({ + namespace: 'ComposableController', + parent: messenger, + }); + messenger.delegate({ + messenger: composableControllerMessenger, + events: ['FooController:stateChange'], + }); + jest + .spyOn(composableControllerMessenger, 'subscribe') + .mockImplementation(() => { + throw new Error(); + }); + expect( + () => + new ComposableController({ + controllers: { + FooController: fooController, + }, + messenger: composableControllerMessenger, + }), + ).not.toThrow(); + }); + + it('should throw if controller messenger not provided', () => { + const messenger = new Messenger< + MockAnyNamespace, + QuzControllerAction | FooControllerAction, + QuzControllerEvent | FooControllerEvent + >({ namespace: MOCK_ANY_NAMESPACE }); + const quzControllerMessenger = new Messenger< + 'QuzController', + QuzControllerAction, + QuzControllerEvent, + typeof messenger + >({ + namespace: 'QuzController', + parent: messenger, + }); + const quzController = new QuzController(quzControllerMessenger); + const fooControllerMessenger = new Messenger< + 'FooController', + FooControllerAction, + FooControllerEvent, + typeof messenger + >({ + namespace: 'FooController', + parent: messenger, + }); + const fooController = new FooController(fooControllerMessenger); + expect( + () => + // @ts-expect-error - Suppressing type error to test for runtime error handling + new ComposableController({ + controllers: { + QuzController: quzController, + FooController: fooController, + }, + }), + ).toThrow('Messaging system is required'); + }); + + it('should throw if composing a controller that does not extend from BaseController', () => { + type ComposableControllerState = { + /* eslint-disable @typescript-eslint/naming-convention */ + FooController: FooControllerState; + /* eslint-enable @typescript-eslint/naming-convention */ + }; + const notController = new JsonRpcEngine(); + const messenger = new Messenger< + MockAnyNamespace, + | ComposableControllerActions + | FooControllerAction, + ComposableControllerEvents | FooControllerEvent + >({ namespace: MOCK_ANY_NAMESPACE }); + const fooControllerMessenger = new Messenger< + 'FooController', + FooControllerAction, + FooControllerEvent, + typeof messenger + >({ + namespace: 'FooController', + parent: messenger, + }); + const fooController = new FooController(fooControllerMessenger); + const composableControllerMessenger = new Messenger< + 'ComposableController', + ComposableControllerActions, + | ComposableControllerEvents + | FooControllerEvent, + typeof messenger + >({ + namespace: 'ComposableController', + parent: messenger, + }); + messenger.delegate({ + messenger: composableControllerMessenger, + events: ['FooController:stateChange'], + }); + expect( + () => + new ComposableController< + /* eslint-disable @typescript-eslint/naming-convention */ + // @ts-expect-error - Suppressing type error to test for runtime error handling + ComposableControllerState & { + JsonRpcEngine: Record; + }, + { + JsonRpcEngine: typeof notController; + FooController: FooController; + } + /* eslint-enable @typescript-eslint/naming-convention */ + >({ + controllers: { + JsonRpcEngine: notController, + FooController: fooController, + }, + messenger: composableControllerMessenger, + }), + ).toThrow(INVALID_CONTROLLER_ERROR); + }); + + describe('metadata', () => { + it('includes expected state in debug snapshots', () => { + type ComposableControllerState = { + /* eslint-disable @typescript-eslint/naming-convention */ + FooController: FooControllerState; + /* eslint-enable @typescript-eslint/naming-convention */ + }; + const messenger = new Messenger< + MockAnyNamespace, + | ComposableControllerActions + | FooControllerAction, + | ComposableControllerEvents + | FooControllerEvent + >({ namespace: MOCK_ANY_NAMESPACE }); + const fooControllerMessenger = new Messenger< 'FooController', - never, - never + FooControllerAction, + FooControllerEvent, + typeof messenger >({ - name: 'FooController', + namespace: 'FooController', + parent: messenger, }); const fooController = new FooController(fooControllerMessenger); - const composableControllerMessenger = controllerMessenger.getRestricted< + const composableControllerMessenger = new Messenger< 'ComposableController', - never, - 'FooController:stateChange' + ComposableControllerActions, + | ComposableControllerEvents + | FooControllerEvent, + typeof messenger >({ - name: 'ComposableController', - allowedEvents: ['FooController:stateChange'], + namespace: 'ComposableController', + parent: messenger, }); - const composableController = new ComposableController( - [barController, fooController], - composableControllerMessenger, - ); - expect(composableController.state).toStrictEqual({ - BarController: { bar: 'bar' }, - FooController: { foo: 'foo' }, + messenger.delegate({ + messenger: composableControllerMessenger, + events: ['FooController:stateChange'], + }); + const controller = new ComposableController< + ComposableControllerState, + Pick + >({ + controllers: { + FooController: fooController, + }, + messenger: composableControllerMessenger, }); + + expect( + deriveStateFromMetadata( + controller.state, + controller.metadata, + 'includeInDebugSnapshot', + ), + ).toMatchInlineSnapshot(` + { + "FooController": { + "foo": "foo", + }, + } + `); }); - it('should compose flat controller state', () => { - const barController = new BarController(); - const controllerMessenger = new ControllerMessenger< - never, - FooControllerEvent - >(); - const fooControllerMessenger = controllerMessenger.getRestricted< + it('includes expected state in state logs', () => { + type ComposableControllerState = { + /* eslint-disable @typescript-eslint/naming-convention */ + FooController: FooControllerState; + /* eslint-enable @typescript-eslint/naming-convention */ + }; + const messenger = new Messenger< + MockAnyNamespace, + | ComposableControllerActions + | FooControllerAction, + | ComposableControllerEvents + | FooControllerEvent + >({ namespace: MOCK_ANY_NAMESPACE }); + const fooControllerMessenger = new Messenger< 'FooController', - never, - never + FooControllerAction, + FooControllerEvent, + typeof messenger >({ - name: 'FooController', + namespace: 'FooController', + parent: messenger, }); const fooController = new FooController(fooControllerMessenger); - const composableControllerMessenger = controllerMessenger.getRestricted< + const composableControllerMessenger = new Messenger< 'ComposableController', - never, - 'FooController:stateChange' + ComposableControllerActions, + | ComposableControllerEvents + | FooControllerEvent, + typeof messenger >({ - name: 'ComposableController', - allowedEvents: ['FooController:stateChange'], + namespace: 'ComposableController', + parent: messenger, }); - const composableController = new ComposableController( - [barController, fooController], - composableControllerMessenger, - ); - expect(composableController.flatState).toStrictEqual({ - bar: 'bar', - foo: 'foo', + messenger.delegate({ + messenger: composableControllerMessenger, + events: ['FooController:stateChange'], }); + const controller = new ComposableController< + ComposableControllerState, + Pick + >({ + controllers: { + FooController: fooController, + }, + messenger: composableControllerMessenger, + }); + + expect( + deriveStateFromMetadata( + controller.state, + controller.metadata, + 'includeInStateLogs', + ), + ).toMatchInlineSnapshot(`{}`); }); - it('should notify listeners of BaseController state change', () => { - const barController = new BarController(); - const controllerMessenger = new ControllerMessenger< - never, - FooControllerEvent - >(); - const fooControllerMessenger = controllerMessenger.getRestricted< + it('persists expected state', () => { + type ComposableControllerState = { + /* eslint-disable @typescript-eslint/naming-convention */ + FooController: FooControllerState; + /* eslint-enable @typescript-eslint/naming-convention */ + }; + const messenger = new Messenger< + MockAnyNamespace, + | ComposableControllerActions + | FooControllerAction, + | ComposableControllerEvents + | FooControllerEvent + >({ namespace: MOCK_ANY_NAMESPACE }); + const fooControllerMessenger = new Messenger< 'FooController', - never, - never + FooControllerAction, + FooControllerEvent, + typeof messenger >({ - name: 'FooController', + namespace: 'FooController', + parent: messenger, }); const fooController = new FooController(fooControllerMessenger); - const composableControllerMessenger = controllerMessenger.getRestricted< + const composableControllerMessenger = new Messenger< 'ComposableController', - never, - 'FooController:stateChange' + ComposableControllerActions, + | ComposableControllerEvents + | FooControllerEvent, + typeof messenger >({ - name: 'ComposableController', - allowedEvents: ['FooController:stateChange'], + namespace: 'ComposableController', + parent: messenger, }); - const composableController = new ComposableController( - [barController, fooController], - composableControllerMessenger, - ); - - const listener = sinon.stub(); - composableController.subscribe(listener); - barController.updateBar('foo'); - - expect(listener.calledOnce).toBe(true); - expect(listener.getCall(0).args[0]).toStrictEqual({ - BarController: { - bar: 'foo', - }, - FooController: { - foo: 'foo', + messenger.delegate({ + messenger: composableControllerMessenger, + events: ['FooController:stateChange'], + }); + const controller = new ComposableController< + ComposableControllerState, + Pick + >({ + controllers: { + FooController: fooController, }, + messenger: composableControllerMessenger, }); + + expect( + deriveStateFromMetadata( + controller.state, + controller.metadata, + 'persist', + ), + ).toMatchInlineSnapshot(` + { + "FooController": { + "foo": "foo", + }, + } + `); }); - it('should notify listeners of BaseControllerV2 state change', () => { - const barController = new BarController(); - const controllerMessenger = new ControllerMessenger< - never, - FooControllerEvent - >(); - const fooControllerMessenger = controllerMessenger.getRestricted< + it('exposes expected state to UI', () => { + type ComposableControllerState = { + /* eslint-disable @typescript-eslint/naming-convention */ + FooController: FooControllerState; + /* eslint-enable @typescript-eslint/naming-convention */ + }; + const messenger = new Messenger< + MockAnyNamespace, + | ComposableControllerActions + | FooControllerAction, + | ComposableControllerEvents + | FooControllerEvent + >({ namespace: MOCK_ANY_NAMESPACE }); + const fooControllerMessenger = new Messenger< 'FooController', - never, - never + FooControllerAction, + FooControllerEvent, + typeof messenger >({ - name: 'FooController', + namespace: 'FooController', + parent: messenger, }); const fooController = new FooController(fooControllerMessenger); - const composableControllerMessenger = controllerMessenger.getRestricted< + const composableControllerMessenger = new Messenger< 'ComposableController', - never, - 'FooController:stateChange' + ComposableControllerActions, + | ComposableControllerEvents + | FooControllerEvent, + typeof messenger >({ - name: 'ComposableController', - allowedEvents: ['FooController:stateChange'], + namespace: 'ComposableController', + parent: messenger, }); - const composableController = new ComposableController( - [barController, fooController], - composableControllerMessenger, - ); - - const listener = sinon.stub(); - composableController.subscribe(listener); - fooController.updateFoo('bar'); - - expect(listener.calledOnce).toBe(true); - expect(listener.getCall(0).args[0]).toStrictEqual({ - BarController: { - bar: 'bar', - }, - FooController: { - foo: 'bar', - }, + messenger.delegate({ + messenger: composableControllerMessenger, + events: ['FooController:stateChange'], }); - }); - - it('should throw if controller messenger not provided', () => { - const barController = new BarController(); - const controllerMessenger = new ControllerMessenger< - never, - FooControllerEvent - >(); - const fooControllerMessenger = controllerMessenger.getRestricted< - 'FooController', - never, - never + const controller = new ComposableController< + ComposableControllerState, + Pick >({ - name: 'FooController', + controllers: { + FooController: fooController, + }, + messenger: composableControllerMessenger, }); - const fooController = new FooController(fooControllerMessenger); + expect( - () => new ComposableController([barController, fooController]), - ).toThrow( - 'Messaging system required if any BaseControllerV2 controllers are used', - ); + deriveStateFromMetadata( + controller.state, + controller.metadata, + 'usedInUi', + ), + ).toMatchInlineSnapshot(`{}`); }); }); }); diff --git a/packages/composable-controller/src/ComposableController.ts b/packages/composable-controller/src/ComposableController.ts index fb5eec1d148..594e10e41f3 100644 --- a/packages/composable-controller/src/ComposableController.ts +++ b/packages/composable-controller/src/ComposableController.ts @@ -1,91 +1,224 @@ -import type { RestrictedControllerMessenger } from '@metamask/base-controller'; +import type { + StateConstraint, + StateMetadata, + StateMetadataConstraint, + ControllerStateChangeEvent, + ControllerGetStateAction, + BaseControllerInstance as ControllerInstance, +} from '@metamask/base-controller'; import { BaseController } from '@metamask/base-controller'; +import type { Messenger } from '@metamask/messenger'; + +export const controllerName = 'ComposableController'; + +export const INVALID_CONTROLLER_ERROR = + 'Invalid controller: controller must inherit from `BaseController`.'; + +/** + * The narrowest supertype for the composable controller state object. + */ +export type ComposableControllerStateConstraint = { + [controllerName: string]: StateConstraint; +}; /** - * List of child controller instances + * The `getState` action type for the {@link ComposableControllerMessenger}. * - * This type encompasses controllers based up either BaseController or - * BaseControllerV2. The BaseControllerV2 type can't be included directly - * because the generic parameters it expects require knowing the exact state - * shape, so instead we look for an object with the BaseControllerV2 properties - * that we use in the ComposableController (name and state). + * @template ComposableControllerState - A type object that maps controller names to their state types. */ -export type ControllerList = ( - | BaseController - | { name: string; state: Record } -)[]; +export type ComposableControllerGetStateAction< + ComposableControllerState extends ComposableControllerStateConstraint, +> = ControllerGetStateAction; -export type ComposableControllerRestrictedMessenger = - RestrictedControllerMessenger<'ComposableController', never, any, never, any>; +/** + * The `stateChange` event type for the {@link ComposableControllerMessenger}. + * + * @template ComposableControllerState - A type object that maps controller names to their state types. + */ +export type ComposableControllerStateChangeEvent< + ComposableControllerState extends ComposableControllerStateConstraint, +> = ControllerStateChangeEvent< + typeof controllerName, + ComposableControllerState +>; + +/** + * A union type of internal event types available to the {@link ComposableControllerMessenger}. + * + * @template ComposableControllerState - A type object that maps controller names to their state types. + */ +export type ComposableControllerEvents< + ComposableControllerState extends ComposableControllerStateConstraint, +> = ComposableControllerStateChangeEvent; /** - * Controller that can be used to compose multiple controllers together. + * A union type of action types available to the {@link ComposableControllerMessenger}. + * + * @template ComposableControllerState - A type object that maps controller names to their state types. */ -export class ComposableController extends BaseController { - private readonly controllers: ControllerList = []; +export type ComposableControllerActions< + ComposableControllerState extends ComposableControllerStateConstraint, +> = ComposableControllerGetStateAction; - private readonly messagingSystem?: ComposableControllerRestrictedMessenger; +/** + * A utility type that extracts controllers from the {@link ComposableControllerState} type, + * and derives a union type of all of their corresponding `stateChange` events. + * + * @template ComposableControllerState - A type object that maps controller names to their state types. + */ +export type ChildControllerStateChangeEvents< + ComposableControllerState extends ComposableControllerStateConstraint, +> = + ComposableControllerState extends Record< + infer ChildControllerName extends string, + infer ChildControllerState extends StateConstraint + > + ? ControllerStateChangeEvent + : never; - /** - * Name of this controller used during composition - */ - override name = 'ComposableController'; +/** + * A union type of external event types available to the {@link ComposableControllerMessenger}. + * + * @template ComposableControllerState - A type object that maps controller names to their state types. + */ +export type AllowedEvents< + ComposableControllerState extends ComposableControllerStateConstraint, +> = ChildControllerStateChangeEvents; + +/** + * The messenger of the {@link ComposableController}. + * + * @template ComposableControllerState - A type object that maps controller names to their state types. + */ +export type ComposableControllerMessenger< + ComposableControllerState extends ComposableControllerStateConstraint, +> = Messenger< + typeof controllerName, + ComposableControllerActions, + | ComposableControllerEvents + | AllowedEvents +>; +/** + * Controller that composes multiple child controllers and maintains up-to-date composed state. + * + * @template ComposableControllerState - A type object containing the names and state types of the child controllers. + * @template ChildControllersMap - A type object that specifies the child controllers which are used to instantiate the {@link ComposableController}. + */ +export class ComposableController< + ComposableControllerState extends ComposableControllerStateConstraint, + ChildControllersMap extends Record< + keyof ComposableControllerState, + ControllerInstance + >, +> extends BaseController< + typeof controllerName, + ComposableControllerState, + ComposableControllerMessenger +> { /** * Creates a ComposableController instance. * - * @param controllers - Map of names to controller instances. - * @param messenger - The controller messaging system, used for communicating with BaseControllerV2 controllers. + * @param options - Initial options used to configure this controller + * @param options.controllers - An object that contains child controllers keyed by their names. + * @param options.messenger - A controller messenger. */ - constructor( - controllers: ControllerList, - messenger?: ComposableControllerRestrictedMessenger, - ) { - super( - undefined, - controllers.reduce((state, controller) => { - state[controller.name] = controller.state; - return state; - }, {} as any), - ); - this.initialize(); - this.controllers = controllers; - this.messagingSystem = messenger; - this.controllers.forEach((controller) => { - const { name } = controller; - if ((controller as BaseController).subscribe !== undefined) { - (controller as BaseController).subscribe((state) => { - this.update({ [name]: state }); - }); - } else if (this.messagingSystem) { - (this.messagingSystem.subscribe as any)( - `${name}:stateChange`, - (state: any) => { - this.update({ [name]: state }); - }, - ); - } else { - throw new Error( - `Messaging system required if any BaseControllerV2 controllers are used`, - ); - } + constructor({ + controllers, + messenger, + }: { + controllers: ChildControllersMap; + messenger: ComposableControllerMessenger; + }) { + if (messenger === undefined) { + throw new Error(`Messaging system is required`); + } + + super({ + name: controllerName, + // This reduce operation intentionally reuses its output object. This provides a significant performance benefit over returning a new object on each iteration. + metadata: Object.keys(controllers).reduce< + StateMetadata + >((metadata, name) => { + (metadata as StateMetadataConstraint)[name] = { + includeInStateLogs: false, + persist: true, + includeInDebugSnapshot: true, + usedInUi: false, + }; + return metadata; + }, {} as never), + // This reduce operation intentionally reuses its output object. This provides a significant performance benefit over returning a new object on each iteration. + state: Object.values(controllers).reduce( + (state, controller) => { + // Type assertion is necessary for property assignment to a generic type. This does not pollute or widen the type of the asserted variable. + (state as ComposableControllerStateConstraint)[controller.name] = + controller.state; + return state; + }, + {} as never, + ), + messenger, + }); + + Object.values(controllers).forEach((controller) => { + this.#updateChildController(controller); }); } /** - * Flat state representation, one that isn't keyed - * of controller name. Instead, all child controller state is merged - * together into a single, flat object. + * Constructor helper that subscribes to child controller state changes. * - * @returns Merged state representation of all child controllers. + * @param controller - Controller instance to update */ - get flatState() { - let flatState = {}; - for (const controller of this.controllers) { - flatState = { ...flatState, ...controller.state }; + #updateChildController(controller: ControllerInstance): void { + const { name } = controller; + if (!isBaseController(controller)) { + try { + delete this.metadata[name]; + delete this.state[name]; + // eslint-disable-next-line no-empty + } catch {} + throw new Error(`${name} - ${INVALID_CONTROLLER_ERROR}`); + } + try { + this.messenger.subscribe< + // The type intersection with "ComposableController:stateChange" is added by one of the `Messenger.subscribe` overloads, but that constraint is unnecessary here, + // since this method only subscribes the messenger to child controller `stateChange` events. + // @ts-expect-error "Type '`${string}:stateChange`' is not assignable to parameter of type '"ComposableController:stateChange" & ChildControllerStateChangeEvents["type"]'." + ChildControllerStateChangeEvents['type'] + >(`${name}:stateChange`, (childState: StateConstraint) => { + this.update((state) => { + // Type assertion is necessary for property assignment to a generic type. This does not pollute or widen the type of the asserted variable. + // @ts-expect-error "Type instantiation is excessively deep" + (state as ComposableControllerStateConstraint)[name] = childState; + }); + }); + } catch (error: unknown) { + console.error(`${name} - ${String(error)}`); } - return flatState; } } +/** + * Determines if the given controller is an instance of `BaseController` + * + * @param controller - Controller instance to check + * @returns True if the controller is an instance of `BaseController` + */ +function isBaseController( + controller: unknown, +): controller is ControllerInstance { + return ( + typeof controller === 'object' && + controller !== null && + 'name' in controller && + typeof controller.name === 'string' && + 'state' in controller && + typeof controller.state === 'object' && + 'metadata' in controller && + typeof controller.metadata === 'object' + ); +} + export default ComposableController; diff --git a/packages/composable-controller/src/index.ts b/packages/composable-controller/src/index.ts index b1a9e5a4b14..6d2000e7113 100644 --- a/packages/composable-controller/src/index.ts +++ b/packages/composable-controller/src/index.ts @@ -1 +1,7 @@ -export * from './ComposableController'; +export type { + ComposableControllerStateConstraint, + ComposableControllerStateChangeEvent, + ComposableControllerEvents, + ComposableControllerMessenger, +} from './ComposableController.js'; +export { ComposableController } from './ComposableController.js'; diff --git a/packages/composable-controller/tsconfig.build.json b/packages/composable-controller/tsconfig.build.json index 218c76b2cd5..a736c8ceb17 100644 --- a/packages/composable-controller/tsconfig.build.json +++ b/packages/composable-controller/tsconfig.build.json @@ -6,23 +6,14 @@ "rootDir": "./src" }, "references": [ - { - "path": "../address-book-controller/tsconfig.build.json" - }, - { - "path": "../assets-controllers/tsconfig.build.json" - }, { "path": "../base-controller/tsconfig.build.json" }, { - "path": "../ens-controller/tsconfig.build.json" - }, - { - "path": "../network-controller/tsconfig.build.json" + "path": "../messenger/tsconfig.build.json" }, { - "path": "../preferences-controller/tsconfig.build.json" + "path": "../json-rpc-engine/tsconfig.build.json" } ], "include": ["../../types", "./src"] diff --git a/packages/composable-controller/tsconfig.json b/packages/composable-controller/tsconfig.json index 372239daaac..0d608a82545 100644 --- a/packages/composable-controller/tsconfig.json +++ b/packages/composable-controller/tsconfig.json @@ -4,23 +4,14 @@ "baseUrl": "./" }, "references": [ - { - "path": "../address-book-controller" - }, - { - "path": "../assets-controllers" - }, { "path": "../base-controller" }, { - "path": "../ens-controller" - }, - { - "path": "../network-controller" + "path": "../messenger" }, { - "path": "../preferences-controller" + "path": "../json-rpc-engine" } ], "include": ["../../types", "./src"] diff --git a/packages/config-registry-controller/CHANGELOG.md b/packages/config-registry-controller/CHANGELOG.md new file mode 100644 index 00000000000..9cef9e04ed0 --- /dev/null +++ b/packages/config-registry-controller/CHANGELOG.md @@ -0,0 +1,175 @@ +# 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] + +### Changed + +- Bump `@metamask/remote-feature-flag-controller` from `^6.0.0` to `^6.1.0` ([#9980](https://github.com/MetaMask/core/pull/9980)) + +## [3.1.0] + +### Added + +- Add optional `isAutoEnabled?: boolean` property to `RegistryNetworkConfig.config` ([#9879](https://github.com/MetaMask/core/pull/9879)) +- Add `selectEvmAutoEnabledNetworksChainIds` selector to retrieve the CAIP-2 chain IDs of all EVM networks that are auto-enabled ([#9879](https://github.com/MetaMask/core/pull/9879)) + +### Changed + +- Bump `@metamask/remote-feature-flag-controller` from `^5.0.0` to `^6.0.0` ([#9945](https://github.com/MetaMask/core/pull/9945)) + +## [3.0.0] + +### Added + +- Add `ConfigRegistryApiEnv` enum to select the API environment for the service ([#9918](https://github.com/MetaMask/core/pull/9918)) + +### Changed + +- **BREAKING:** The `env` optional constructor option type is now `ConfigRegistryApiEnv` ([#9918](https://github.com/MetaMask/core/pull/9918)) + - Previously, constructor options were reusing the `SDK.Env` enum from `@metamask/profile-sync-controller`. +- Bump `@metamask/superstruct` from `^3.1.0` to `^3.4.1` ([#9754](https://github.com/MetaMask/core/pull/9754)) +- Bump `@metamask/keyring-controller` from `^27.1.0` to `^27.1.1` ([#9791](https://github.com/MetaMask/core/pull/9791)) + +## [2.0.1] + +### Changed + +- Bump `@metamask/profile-sync-controller` from `^28.3.0` to `^29.0.0` ([#9779](https://github.com/MetaMask/core/pull/9779)) + +## [2.0.0] + +### Added + +- Add optional `contracts` property to `RegistryNetworkConfig` ([#9717](https://github.com/MetaMask/core/pull/9717)) + - The `contracts` property is a record of known contract addresses for the network, keyed by contract name. + - Currently, the only supported contract is `multicall3`. + +### Removed + +- **BREAKING:** Remove `ConfigRegistryControllerMethodActions` from exported types ([#9717](https://github.com/MetaMask/core/pull/9717)) + - One of the following action types can be used instead: + - `ConfigRegistryControllerStartPollingAction` + - `ConfigRegistryControllerStopPollingAction` + - `ConfigRegistryControllerGetNetworkConfigByCaip2ChainIdAction` + +## [1.0.1] + +### Changed + +- Bump `@metamask/polling-controller` from `^16.0.8` to `^16.0.9` ([#9735](https://github.com/MetaMask/core/pull/9735)) +- Bump `@metamask/remote-feature-flag-controller` from `^4.2.2` to `^5.0.0` ([#9735](https://github.com/MetaMask/core/pull/9735)) + +## [1.0.0] + +### Added + +- Add `ConfigRegistryControllerStateChangedEvent` (`ConfigRegistryController:stateChanged`) to the controller's events ([#9595](https://github.com/MetaMask/core/pull/9595)) +- Add `ConfigRegistryController.getNetworkConfigByCaip2ChainId` method to retrieve a network config by its CAIP-2 chain ID ([#9597](https://github.com/MetaMask/core/pull/9597), [#9606](https://github.com/MetaMask/core/pull/9606)) + - The method returns the network config if found, or `undefined` if not found. + - The method is also accessible via the controller's messenger as `ConfigRegistryController:getNetworkConfigByCaip2ChainId`. + +### Changed + +- **BREAKING:** `ConfigRegistryControllerState.configs.networks` is now a `Record` instead of a `Record` ([#9606](https://github.com/MetaMask/core/pull/9606)) +- Bump `@metamask/utils` from `^11.9.0` to `^11.11.0` ([#9074](https://github.com/MetaMask/core/pull/9074)) +- Bump `@metamask/controller-utils` from `^12.1.1` to `^12.3.0` ([#9083](https://github.com/MetaMask/core/pull/9083), [#9218](https://github.com/MetaMask/core/pull/9218)) +- Bump `@metamask/profile-sync-controller` from `^28.1.1` to `^28.3.0` ([#9119](https://github.com/MetaMask/core/pull/9119), [#9463](https://github.com/MetaMask/core/pull/9463)) +- Bump `@metamask/keyring-controller` from `^27.0.0` to `^27.1.0` ([#9129](https://github.com/MetaMask/core/pull/9129)) +- Bump `@metamask/polling-controller` from `^16.0.6` to `^16.0.8` ([#9218](https://github.com/MetaMask/core/pull/9218), [#9349](https://github.com/MetaMask/core/pull/9349)) +- Bump `@metamask/messenger` from `^1.2.0` to `^2.0.0` ([#9392](https://github.com/MetaMask/core/pull/9392)) + +### Removed + +- **BREAKING:** Removed `ConfigRegistryControllerStateChangeEvent` type in favor of `ConfigRegistryControllerStateChangedEvent` ([#9595](https://github.com/MetaMask/core/pull/9595)) + +## [0.4.1] + +### Changed + +- Bump `@metamask/remote-feature-flag-controller` from `^4.2.1` to `^4.2.2` ([#8986](https://github.com/MetaMask/core/pull/8986)) +- Bump `@metamask/controller-utils` from `^12.1.0` to `^12.1.1` ([#9058](https://github.com/MetaMask/core/pull/9058)) +- Bump `@metamask/keyring-controller` from `^26.0.0` to `^27.0.0` ([#9058](https://github.com/MetaMask/core/pull/9058)) + +## [0.4.0] + +### Changed + +- **BREAKING:** `RegistryNetworkConfigSchema.assets.native.coingeckoCoinId` is now optional ([#8970](https://github.com/MetaMask/core/pull/8970)) + - The controller now accepts chains with no `assets.native.coingeckoCoinId` property in their configuration. + +## [0.3.2] + +### Changed + +- Bump `@metamask/controller-utils` from `^12.0.0` to `^12.1.0` ([#8774](https://github.com/MetaMask/core/pull/8774)) +- Bump `@metamask/profile-sync-controller` from `^28.0.2` to `^28.1.1` ([#8783](https://github.com/MetaMask/core/pull/8783), [#8912](https://github.com/MetaMask/core/pull/8912)) +- Bump `@metamask/polling-controller` from `^16.0.5` to `^16.0.6` ([#8834](https://github.com/MetaMask/core/pull/8834)) +- Bump `@metamask/keyring-controller` from `^25.5.0` to `^26.0.0` ([#8912](https://github.com/MetaMask/core/pull/8912)) + +## [0.3.1] + +### Changed + +- Bump `@metamask/keyring-controller` from `^25.3.0` to `^25.5.0` ([#8665](https://github.com/MetaMask/core/pull/8665), [#8722](https://github.com/MetaMask/core/pull/8722)) +- Bump `@metamask/controller-utils` from `^11.20.0` to `^12.0.0` ([#8755](https://github.com/MetaMask/core/pull/8755)) +- Bump `@metamask/polling-controller` from `^16.0.4` to `^16.0.5` ([#8755](https://github.com/MetaMask/core/pull/8755)) +- Bump `@metamask/remote-feature-flag-controller` from `^4.2.0` to `^4.2.1` ([#8755](https://github.com/MetaMask/core/pull/8755)) + +## [0.3.0] + +### Changed + +- Bump `@metamask/keyring-controller` from `^25.1.1` to `^25.3.0` ([#8363](https://github.com/MetaMask/core/pull/8363), [#8634](https://github.com/MetaMask/core/pull/8634)) +- Bump `@metamask/profile-sync-controller` from `^28.0.1` to `^28.0.2` ([#8325](https://github.com/MetaMask/core/pull/8325)) +- Bump `@metamask/controller-utils` from `^11.19.0` to `^11.20.0` ([#8344](https://github.com/MetaMask/core/pull/8344)) +- Bump `@metamask/messenger` from `^1.0.0` to `^1.2.0` ([#8364](https://github.com/MetaMask/core/pull/8364), [#8373](https://github.com/MetaMask/core/pull/8373), [#8632](https://github.com/MetaMask/core/pull/8632)) +- Bump `@metamask/base-controller` from `^9.0.1` to `^9.1.0` ([#8457](https://github.com/MetaMask/core/pull/8457)) + +### Fixed + +- `ConfigRegistryApiService` now accepts chains with no `assets.listUrl` property ([#8624](https://github.com/MetaMask/core/pull/8624)) + +## [0.2.0] + +### Changed + +- **BREAKING:** `ConfigRegistryControllerMessenger` now requires `KeyringController:getState` action to be allowed ([#8230](https://github.com/MetaMask/core/pull/8230)) +- Bump `@metamask/base-controller` from `^9.0.0` to `^9.0.1` ([#8317](https://github.com/MetaMask/core/pull/8317)) +- Bump `@metamask/keyring-controller` from `^25.1.0` to `^25.1.1` ([#8317](https://github.com/MetaMask/core/pull/8317)) +- Bump `@metamask/messenger` from `^0.3.0` to `^1.0.0` ([#8317](https://github.com/MetaMask/core/pull/8317)) +- Bump `@metamask/polling-controller` from `^16.0.3` to `^16.0.4` ([#8317](https://github.com/MetaMask/core/pull/8317)) +- Bump `@metamask/profile-sync-controller` from `^28.0.0` to `^28.0.1` ([#8317](https://github.com/MetaMask/core/pull/8317)) +- Bump `@metamask/remote-feature-flag-controller` from `^4.1.0` to `^4.2.0` ([#8317](https://github.com/MetaMask/core/pull/8317)) + +## [0.1.1] + +### Changed + +- Bump `@metamask/profile-sync-controller` from `^27.1.0` to `^28.0.0` ([#8162](https://github.com/MetaMask/core/pull/8162)) + +## [0.1.0] + +### Added + +- Initial release ([#7668](https://github.com/MetaMask/core/pull/7668), [#7809](https://github.com/MetaMask/core/pull/7809)) + +[Unreleased]: https://github.com/MetaMask/core/compare/@metamask/config-registry-controller@3.1.0...HEAD +[3.1.0]: https://github.com/MetaMask/core/compare/@metamask/config-registry-controller@3.0.0...@metamask/config-registry-controller@3.1.0 +[3.0.0]: https://github.com/MetaMask/core/compare/@metamask/config-registry-controller@2.0.1...@metamask/config-registry-controller@3.0.0 +[2.0.1]: https://github.com/MetaMask/core/compare/@metamask/config-registry-controller@2.0.0...@metamask/config-registry-controller@2.0.1 +[2.0.0]: https://github.com/MetaMask/core/compare/@metamask/config-registry-controller@1.0.1...@metamask/config-registry-controller@2.0.0 +[1.0.1]: https://github.com/MetaMask/core/compare/@metamask/config-registry-controller@1.0.0...@metamask/config-registry-controller@1.0.1 +[1.0.0]: https://github.com/MetaMask/core/compare/@metamask/config-registry-controller@0.4.1...@metamask/config-registry-controller@1.0.0 +[0.4.1]: https://github.com/MetaMask/core/compare/@metamask/config-registry-controller@0.4.0...@metamask/config-registry-controller@0.4.1 +[0.4.0]: https://github.com/MetaMask/core/compare/@metamask/config-registry-controller@0.3.2...@metamask/config-registry-controller@0.4.0 +[0.3.2]: https://github.com/MetaMask/core/compare/@metamask/config-registry-controller@0.3.1...@metamask/config-registry-controller@0.3.2 +[0.3.1]: https://github.com/MetaMask/core/compare/@metamask/config-registry-controller@0.3.0...@metamask/config-registry-controller@0.3.1 +[0.3.0]: https://github.com/MetaMask/core/compare/@metamask/config-registry-controller@0.2.0...@metamask/config-registry-controller@0.3.0 +[0.2.0]: https://github.com/MetaMask/core/compare/@metamask/config-registry-controller@0.1.1...@metamask/config-registry-controller@0.2.0 +[0.1.1]: https://github.com/MetaMask/core/compare/@metamask/config-registry-controller@0.1.0...@metamask/config-registry-controller@0.1.1 +[0.1.0]: https://github.com/MetaMask/core/releases/tag/@metamask/config-registry-controller@0.1.0 diff --git a/packages/config-registry-controller/LICENSE b/packages/config-registry-controller/LICENSE new file mode 100644 index 00000000000..fe29e78e0fe --- /dev/null +++ b/packages/config-registry-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/config-registry-controller/README.md b/packages/config-registry-controller/README.md new file mode 100644 index 00000000000..1f849943f62 --- /dev/null +++ b/packages/config-registry-controller/README.md @@ -0,0 +1,15 @@ +# `@metamask/config-registry-controller` + +Manages configuration registry for MetaMask + +## Installation + +`yarn add @metamask/config-registry-controller` + +or + +`npm install @metamask/config-registry-controller` + +## Contributing + +This package is part of a monorepo. Instructions for contributing can be found in the [monorepo README](https://github.com/MetaMask/core#readme). diff --git a/packages/config-registry-controller/jest.config.js b/packages/config-registry-controller/jest.config.js new file mode 100644 index 00000000000..ca084133399 --- /dev/null +++ b/packages/config-registry-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/config-registry-controller/package.json b/packages/config-registry-controller/package.json new file mode 100644 index 00000000000..a3e840dd86a --- /dev/null +++ b/packages/config-registry-controller/package.json @@ -0,0 +1,86 @@ +{ + "name": "@metamask/config-registry-controller", + "version": "3.1.0", + "description": "Manages configuration registry for MetaMask", + "keywords": [ + "Ethereum", + "MetaMask" + ], + "homepage": "https://github.com/MetaMask/core/tree/main/packages/config-registry-controller#readme", + "bugs": { + "url": "https://github.com/MetaMask/core/issues" + }, + "license": "MIT", + "repository": { + "type": "git", + "url": "https://github.com/MetaMask/core.git" + }, + "files": [ + "dist/" + ], + "sideEffects": false, + "main": "./dist/index.cjs", + "types": "./dist/index.d.cts", + "exports": { + ".": { + "import": { + "types": "./dist/index.d.mts", + "default": "./dist/index.mjs" + }, + "require": { + "types": "./dist/index.d.cts", + "default": "./dist/index.cjs" + } + }, + "./package.json": "./package.json" + }, + "publishConfig": { + "access": "public", + "registry": "https://registry.npmjs.org/" + }, + "scripts": { + "build": "ts-bridge --project tsconfig.build.json --verbose --clean --no-references", + "build:all": "ts-bridge --project tsconfig.build.json --verbose --clean", + "build:docs": "typedoc", + "changelog:update": "../../scripts/update-changelog.sh @metamask/config-registry-controller", + "changelog:validate": "../../scripts/validate-changelog.sh @metamask/config-registry-controller", + "lint:tsconfigs": "tsx ../../scripts/lint-tsconfigs/lint-tsconfigs.mts", + "lint:tsconfigs:fix": "tsx ../../scripts/lint-tsconfigs/lint-tsconfigs.mts --fix", + "messenger-action-types:check": "tsx ../../packages/messenger-cli/src/cli.ts --formatter oxfmt --esm --check", + "messenger-action-types:generate": "tsx ../../packages/messenger-cli/src/cli.ts --formatter oxfmt --esm --generate", + "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.1.0", + "@metamask/controller-utils": "^12.3.0", + "@metamask/keyring-controller": "^27.1.1", + "@metamask/messenger": "^2.0.0", + "@metamask/polling-controller": "^16.0.9", + "@metamask/remote-feature-flag-controller": "^6.1.0", + "@metamask/superstruct": "^3.4.1", + "@metamask/utils": "^11.11.0", + "reselect": "^5.1.1" + }, + "devDependencies": { + "@lavamoat/allow-scripts": "^3.0.4", + "@metamask/auto-changelog": "^6.1.0", + "@ts-bridge/cli": "^0.6.4", + "@types/jest": "^30.0.0", + "deepmerge": "^4.2.2", + "jest": "^30.4.2", + "nock": "^13.3.1", + "ts-jest": "^29.4.11", + "tsx": "^4.20.5", + "typedoc": "^0.25.13", + "typedoc-plugin-missing-exports": "^2.0.0", + "typescript": "~5.3.3" + }, + "engines": { + "node": "^18.18 || >=20" + } +} diff --git a/packages/config-registry-controller/src/ConfigRegistryController-method-action-types.ts b/packages/config-registry-controller/src/ConfigRegistryController-method-action-types.ts new file mode 100644 index 00000000000..8db316ca8e5 --- /dev/null +++ b/packages/config-registry-controller/src/ConfigRegistryController-method-action-types.ts @@ -0,0 +1,38 @@ +/** + * This file is auto generated. + * Do not edit manually. + */ + +import type { ConfigRegistryController } from './ConfigRegistryController.js'; + +/** + * Get the network configuration for a given CAIP-2 chain ID. + * + * @param caip2ChainId - The CAIP-2 chain ID (e.g., "eip155:1"). + * @returns The network configuration if found, otherwise undefined. + */ +export type ConfigRegistryControllerGetNetworkConfigByCaip2ChainIdAction = { + type: `ConfigRegistryController:getNetworkConfigByCaip2ChainId`; + handler: ConfigRegistryController['getNetworkConfigByCaip2ChainId']; +}; + +/** + * Stop all polling. + */ +export type ConfigRegistryControllerStopPollingAction = { + type: `ConfigRegistryController:stopPolling`; + handler: ConfigRegistryController['stopPolling']; +}; + +export type ConfigRegistryControllerStartPollingAction = { + type: `ConfigRegistryController:startPolling`; + handler: ConfigRegistryController['startPolling']; +}; + +/** + * Union of all ConfigRegistryController action types. + */ +export type ConfigRegistryControllerMethodActions = + | ConfigRegistryControllerGetNetworkConfigByCaip2ChainIdAction + | ConfigRegistryControllerStopPollingAction + | ConfigRegistryControllerStartPollingAction; diff --git a/packages/config-registry-controller/src/ConfigRegistryController.test.ts b/packages/config-registry-controller/src/ConfigRegistryController.test.ts new file mode 100644 index 00000000000..d737bd04c4a --- /dev/null +++ b/packages/config-registry-controller/src/ConfigRegistryController.test.ts @@ -0,0 +1,1650 @@ +import type { + MockAnyNamespace, + MessengerActions, + MessengerEvents, +} from '@metamask/messenger'; +import { Messenger, MOCK_ANY_NAMESPACE } from '@metamask/messenger'; + +import { createMockNetworkConfig } from '../tests/helpers.js'; +import type { RegistryNetworkConfig } from './config-registry-api-service/types.js'; +import type { FetchConfigResult } from './config-registry-api-service/types.js'; +import type { ConfigRegistryControllerMessenger } from './ConfigRegistryController.js'; +import { + ConfigRegistryController, + DEFAULT_POLLING_INTERVAL, +} from './ConfigRegistryController.js'; +import { selectFeaturedNetworks, selectNetworks } from './selectors.js'; + +const namespace = 'ConfigRegistryController' as const; + +type AllActions = MessengerActions; + +type AllEvents = MessengerEvents; + +type RootMessenger = Messenger; + +/** + * Constructs a messenger for ConfigRegistryController. + * + * @returns A controller messenger and root messenger. + */ +function getConfigRegistryControllerMessenger(): { + messenger: ConfigRegistryControllerMessenger; + rootMessenger: RootMessenger; +} { + const rootMessenger: RootMessenger = new Messenger({ + namespace: MOCK_ANY_NAMESPACE, + captureException: jest.fn(), + }); + + const configRegistryControllerMessenger: ConfigRegistryControllerMessenger = + new Messenger< + typeof namespace, + AllActions, + AllEvents, + typeof rootMessenger + >({ + namespace, + parent: rootMessenger, + }); + + rootMessenger.delegate({ + messenger: configRegistryControllerMessenger, + actions: [ + 'RemoteFeatureFlagController:getState', + 'ConfigRegistryApiService:fetchConfig', + 'KeyringController:getState', + ], + events: [ + 'KeyringController:unlock', + 'KeyringController:lock', + 'RemoteFeatureFlagController:stateChange', + ], + }); + + return { messenger: configRegistryControllerMessenger, rootMessenger }; +} + +const MOCK_FALLBACK_CONFIG: Record = { + 'fallback-key': createMockNetworkConfig({ + chainId: 'eip155:2', + name: 'Fallback Network', + }), +}; + +/** + * Builds a mock API service fetch handler. + * + * @param overrides - Optional overrides object containing fetchConfig implementation. + * @param overrides.fetchConfig - Optional fetchConfig function override. + * @returns A handler function for the fetchConfig action. + */ +function buildMockApiServiceHandler(overrides?: { + fetchConfig?: (options?: { etag?: string }) => Promise; +}): (options?: { etag?: string }) => Promise { + const defaultFetchConfig = async (): Promise => { + return { + data: { + data: { + version: '1', + timestamp: Date.now(), + chains: [], + }, + }, + modified: true, + }; + }; + + return overrides?.fetchConfig ?? defaultFetchConfig; +} + +type WithControllerCallback = (args: { + controller: ConfigRegistryController; + rootMessenger: RootMessenger; + messenger: ConfigRegistryControllerMessenger; + mockApiServiceHandler: jest.Mock; + mockRemoteFeatureFlagGetState: jest.Mock; + mockKeyringControllerGetState: jest.Mock; +}) => Promise | ReturnValue; + +type WithControllerOptions = { + options?: Partial[0]>; +}; + +async function withController( + ...args: + | [WithControllerCallback] + | [WithControllerOptions, WithControllerCallback] +): Promise { + const [{ options = {} }, testFunction] = + args.length === 2 ? args : [{}, args[0]]; + + jest.useFakeTimers(); + const { messenger, rootMessenger } = getConfigRegistryControllerMessenger(); + const mockApiServiceHandler = jest.fn(buildMockApiServiceHandler()); + + rootMessenger.registerActionHandler( + 'ConfigRegistryApiService:fetchConfig', + mockApiServiceHandler, + ); + + const mockRemoteFeatureFlagGetState = jest.fn().mockReturnValue({ + remoteFeatureFlags: { + configRegistryApiEnabled: true, + }, + cacheTimestamp: Date.now(), + }); + + const mockKeyringControllerGetState = jest.fn().mockReturnValue({ + isUnlocked: true, + }); + + rootMessenger.registerActionHandler( + 'RemoteFeatureFlagController:getState', + mockRemoteFeatureFlagGetState, + ); + + rootMessenger.registerActionHandler( + 'KeyringController:getState', + mockKeyringControllerGetState, + ); + + const controller = new ConfigRegistryController({ + messenger, + ...options, + }); + + try { + return await testFunction({ + controller, + rootMessenger, + messenger, + mockApiServiceHandler, + mockRemoteFeatureFlagGetState, + mockKeyringControllerGetState, + }); + } finally { + controller.stopAllPolling(); + jest.useRealTimers(); + mockApiServiceHandler.mockReset(); + } +} + +describe('ConfigRegistryController', () => { + describe('constructor', () => { + it('sets default state', async () => { + await withController(({ controller }) => { + expect(controller.state).toStrictEqual({ + configs: { networks: {} }, + version: null, + lastFetched: null, + etag: null, + }); + }); + }); + + it('sets initial state when provided', async () => { + const initialNetworks: Record = { + 'test-key': createMockNetworkConfig({ + chainId: 'eip155:1', + name: 'Test Network', + }), + }; + const initialState = { + configs: { networks: initialNetworks }, + version: 'v1.0.0', + lastFetched: 1234567890, + }; + + await withController( + { options: { state: initialState } }, + ({ controller }) => { + expect(controller.state.configs.networks).toStrictEqual( + initialNetworks, + ); + expect(controller.state.version).toBe('v1.0.0'); + expect(controller.state.lastFetched).toBe(1234567890); + }, + ); + }); + + it('sets custom polling interval', async () => { + const customInterval = 5000; + await withController( + { options: { pollingInterval: customInterval } }, + ({ controller }) => { + expect(controller.getIntervalLength()).toBe(customInterval); + }, + ); + }); + + it('sets fallback config', async () => { + await withController( + { options: { fallbackConfig: MOCK_FALLBACK_CONFIG } }, + ({ controller }) => { + expect(controller.state.configs).toStrictEqual({ + networks: MOCK_FALLBACK_CONFIG, + }); + }, + ); + }); + }); + + describe('polling', () => { + it('hits the config registry API when polling is started', async () => { + await withController(async ({ rootMessenger, mockApiServiceHandler }) => { + rootMessenger.call('ConfigRegistryController:startPolling', null); + await jest.advanceTimersByTimeAsync(0); + expect(mockApiServiceHandler).toHaveBeenCalledTimes(1); + }); + }); + + it('polls at specified interval', async () => { + const pollingInterval = 1000; + await withController( + { options: { pollingInterval } }, + async ({ rootMessenger, mockApiServiceHandler }) => { + rootMessenger.call('ConfigRegistryController:startPolling', null); + await jest.advanceTimersByTimeAsync(0); + mockApiServiceHandler.mockClear(); + await jest.advanceTimersByTimeAsync(pollingInterval); + expect(mockApiServiceHandler).toHaveBeenCalledTimes(1); + }, + ); + }); + + it('does not hit the config registry API periodically when polling is stopped', async () => { + await withController(async ({ rootMessenger, mockApiServiceHandler }) => { + rootMessenger.call('ConfigRegistryController:startPolling', null); + await jest.advanceTimersByTimeAsync(0); + mockApiServiceHandler.mockClear(); + rootMessenger.call('ConfigRegistryController:stopPolling'); + await jest.advanceTimersByTimeAsync(DEFAULT_POLLING_INTERVAL); + expect(mockApiServiceHandler).not.toHaveBeenCalled(); + }); + }); + + it('uses fallback config when no configs exist', async () => { + await withController( + { options: { fallbackConfig: MOCK_FALLBACK_CONFIG } }, + async ({ + controller, + rootMessenger, + mockRemoteFeatureFlagGetState, + mockApiServiceHandler, + }) => { + mockRemoteFeatureFlagGetState.mockReturnValue({ + remoteFeatureFlags: { + configRegistryApiEnabled: true, + }, + cacheTimestamp: Date.now(), + }); + + mockApiServiceHandler.mockRejectedValue(new Error('Network error')); + + rootMessenger.call('ConfigRegistryController:startPolling', null); + await jest.advanceTimersByTimeAsync(0); + + expect(rootMessenger.captureException).toHaveBeenCalledWith( + expect.objectContaining({ message: 'Network error' }), + ); + expect(controller.state.configs).toStrictEqual({ + networks: MOCK_FALLBACK_CONFIG, + }); + }, + ); + }); + + it('keeps existing configs when fetch fails and configs already exist', async () => { + const existingNetworks: Record = { + 'existing-key': createMockNetworkConfig({ + chainId: 'eip155:3', + name: 'Existing Network', + }), + }; + const existingConfigs = { networks: existingNetworks }; + + await withController( + { + options: { + state: { configs: existingConfigs }, + fallbackConfig: MOCK_FALLBACK_CONFIG, + }, + }, + async ({ + controller, + rootMessenger, + mockRemoteFeatureFlagGetState, + mockApiServiceHandler, + }) => { + mockRemoteFeatureFlagGetState.mockReturnValue({ + remoteFeatureFlags: { + configRegistryApiEnabled: true, + }, + cacheTimestamp: Date.now(), + }); + + mockApiServiceHandler.mockRejectedValue(new Error('Network error')); + + rootMessenger.call('ConfigRegistryController:startPolling', null); + await jest.advanceTimersByTimeAsync(0); + + expect(rootMessenger.captureException).toHaveBeenCalledWith( + expect.objectContaining({ message: 'Network error' }), + ); + expect(controller.state.configs.networks).toStrictEqual( + existingNetworks, + ); + }, + ); + }); + + it('handles errors during polling', async () => { + await withController( + { options: { fallbackConfig: MOCK_FALLBACK_CONFIG } }, + async ({ controller, rootMessenger, mockApiServiceHandler }) => { + mockApiServiceHandler.mockRejectedValue(new Error('Network error')); + + rootMessenger.call('ConfigRegistryController:startPolling', null); + await jest.advanceTimersByTimeAsync(0); + + expect(rootMessenger.captureException).toHaveBeenCalledWith( + expect.objectContaining({ message: 'Network error' }), + ); + expect(controller.state.configs).toStrictEqual({ + networks: MOCK_FALLBACK_CONFIG, + }); + }, + ); + }); + + it('handles unmodified response and updates lastFetched and etag', async () => { + await withController( + async ({ + controller, + rootMessenger, + mockRemoteFeatureFlagGetState, + mockApiServiceHandler, + }) => { + mockRemoteFeatureFlagGetState.mockReturnValue({ + remoteFeatureFlags: { + configRegistryApiEnabled: true, + }, + cacheTimestamp: Date.now(), + }); + + mockApiServiceHandler.mockResolvedValue({ + modified: false, + etag: '"test-etag"', + }); + + const beforeTimestamp = Date.now(); + rootMessenger.call('ConfigRegistryController:startPolling', null); + await jest.advanceTimersByTimeAsync(0); + const afterTimestamp = Date.now(); + + expect(controller.state.etag).toBe('"test-etag"'); + expect(controller.state.lastFetched).not.toBeNull(); + expect(controller.state.lastFetched).toBeGreaterThanOrEqual( + beforeTimestamp, + ); + expect(controller.state.lastFetched).toBeLessThanOrEqual( + afterTimestamp, + ); + }, + ); + }); + + it('handles unmodified response and preserves existing etag when not provided', async () => { + await withController( + { + options: { + state: { + etag: '"existing-etag"', + }, + }, + }, + async ({ + controller, + rootMessenger, + mockRemoteFeatureFlagGetState, + mockApiServiceHandler, + }) => { + mockRemoteFeatureFlagGetState.mockReturnValue({ + remoteFeatureFlags: { + configRegistryApiEnabled: true, + }, + cacheTimestamp: Date.now(), + }); + + mockApiServiceHandler.mockResolvedValue({ + modified: false, + }); + + const beforeTimestamp = Date.now(); + rootMessenger.call('ConfigRegistryController:startPolling', null); + await jest.advanceTimersByTimeAsync(0); + const afterTimestamp = Date.now(); + + expect(controller.state.etag).toBe('"existing-etag"'); + expect(controller.state.lastFetched).not.toBeNull(); + expect(controller.state.lastFetched).toBeGreaterThanOrEqual( + beforeTimestamp, + ); + expect(controller.state.lastFetched).toBeLessThanOrEqual( + afterTimestamp, + ); + }, + ); + }); + + it('handles unmodified response and sets etag to null when explicitly null', async () => { + await withController( + { + options: { + state: { + etag: '"existing-etag"', + }, + }, + }, + async ({ + controller, + rootMessenger, + mockRemoteFeatureFlagGetState, + mockApiServiceHandler, + }) => { + mockRemoteFeatureFlagGetState.mockReturnValue({ + remoteFeatureFlags: { + configRegistryApiEnabled: true, + }, + cacheTimestamp: Date.now(), + }); + + mockApiServiceHandler.mockResolvedValue({ + modified: false, + etag: null, + }); + + const beforeTimestamp = Date.now(); + rootMessenger.call('ConfigRegistryController:startPolling', null); + await jest.advanceTimersByTimeAsync(0); + const afterTimestamp = Date.now(); + + expect(controller.state.etag).toBeNull(); + expect(controller.state.lastFetched).not.toBeNull(); + expect(controller.state.lastFetched).toBeGreaterThanOrEqual( + beforeTimestamp, + ); + expect(controller.state.lastFetched).toBeLessThanOrEqual( + afterTimestamp, + ); + }, + ); + }); + + it('handles validation error from service', async () => { + await withController( + async ({ + rootMessenger, + mockRemoteFeatureFlagGetState, + mockApiServiceHandler, + }) => { + mockRemoteFeatureFlagGetState.mockReturnValue({ + remoteFeatureFlags: { + configRegistryApiEnabled: true, + }, + cacheTimestamp: Date.now(), + }); + + const validationError = new Error( + 'Validation error from superstruct', + ); + mockApiServiceHandler.mockRejectedValue(validationError); + + rootMessenger.call('ConfigRegistryController:startPolling', null); + await jest.advanceTimersByTimeAsync(0); + + expect(rootMessenger.captureException).toHaveBeenCalledWith( + expect.objectContaining({ + message: 'Validation error from superstruct', + }), + ); + }, + ); + }); + + it('handles validation error when result.data is missing', async () => { + await withController( + async ({ + rootMessenger, + mockRemoteFeatureFlagGetState, + mockApiServiceHandler, + }) => { + mockRemoteFeatureFlagGetState.mockReturnValue({ + remoteFeatureFlags: { + configRegistryApiEnabled: true, + }, + cacheTimestamp: Date.now(), + }); + + const validationError = new Error( + 'Validation error: data is missing', + ); + mockApiServiceHandler.mockRejectedValue(validationError); + + rootMessenger.call('ConfigRegistryController:startPolling', null); + await jest.advanceTimersByTimeAsync(0); + + expect(rootMessenger.captureException).toHaveBeenCalledWith( + expect.objectContaining({ + message: 'Validation error: data is missing', + }), + ); + }, + ); + }); + + it('handles validation error when result.data.chains is not an array', async () => { + await withController( + async ({ + rootMessenger, + mockRemoteFeatureFlagGetState, + mockApiServiceHandler, + }) => { + mockRemoteFeatureFlagGetState.mockReturnValue({ + remoteFeatureFlags: { + configRegistryApiEnabled: true, + }, + cacheTimestamp: Date.now(), + }); + + const validationError = new Error( + 'Validation error: data.chains is not an array', + ); + mockApiServiceHandler.mockRejectedValue(validationError); + + rootMessenger.call('ConfigRegistryController:startPolling', null); + await jest.advanceTimersByTimeAsync(0); + + expect(rootMessenger.captureException).toHaveBeenCalledWith( + expect.objectContaining({ + message: 'Validation error: data.chains is not an array', + }), + ); + }, + ); + }); + + it('handles validation error when result.data.version is not a string', async () => { + await withController( + async ({ + rootMessenger, + mockRemoteFeatureFlagGetState, + mockApiServiceHandler, + }) => { + mockRemoteFeatureFlagGetState.mockReturnValue({ + remoteFeatureFlags: { + configRegistryApiEnabled: true, + }, + cacheTimestamp: Date.now(), + }); + + const validationError = new Error( + 'Validation error: data.version is not a string', + ); + mockApiServiceHandler.mockRejectedValue(validationError); + + rootMessenger.call('ConfigRegistryController:startPolling', null); + await jest.advanceTimersByTimeAsync(0); + + expect(rootMessenger.captureException).toHaveBeenCalledWith( + expect.objectContaining({ + message: 'Validation error: data.version is not a string', + }), + ); + }, + ); + }); + + it('proceeds with fetch when lastFetched is null', async () => { + await withController( + { + options: { + state: { + lastFetched: null, + }, + }, + }, + async ({ + controller, + rootMessenger, + mockRemoteFeatureFlagGetState, + mockApiServiceHandler, + }) => { + mockRemoteFeatureFlagGetState.mockReturnValue({ + remoteFeatureFlags: { + configRegistryApiEnabled: true, + }, + cacheTimestamp: Date.now(), + }); + + mockApiServiceHandler.mockResolvedValue({ + data: { + data: { + version: '1.0.0', + timestamp: Date.now(), + chains: [ + createMockNetworkConfig({ + chainId: 'eip155:1', + name: 'Ethereum Mainnet', + }), + ], + }, + }, + etag: '"test-etag"', + modified: true, + }); + + rootMessenger.call('ConfigRegistryController:startPolling', null); + await jest.advanceTimersByTimeAsync(0); + + expect(mockApiServiceHandler).toHaveBeenCalled(); + expect(controller.state.lastFetched).not.toBeNull(); + }, + ); + }); + + it('proceeds with fetch when enough time has passed since lastFetched', async () => { + const now = Date.now(); + const oldTimestamp = now - DEFAULT_POLLING_INTERVAL - 1000; + await withController( + { + options: { + state: { + lastFetched: oldTimestamp, + }, + }, + }, + async ({ + controller, + rootMessenger, + mockRemoteFeatureFlagGetState, + mockApiServiceHandler, + }) => { + jest.spyOn(Date, 'now').mockReturnValue(now); + + mockRemoteFeatureFlagGetState.mockReturnValue({ + remoteFeatureFlags: { + configRegistryApiEnabled: true, + }, + cacheTimestamp: now, + }); + + mockApiServiceHandler.mockResolvedValue({ + data: { + data: { + version: '1.0.0', + timestamp: Date.now(), + chains: [ + createMockNetworkConfig({ + chainId: 'eip155:1', + name: 'Ethereum Mainnet', + }), + ], + }, + }, + etag: '"test-etag"', + modified: true, + }); + + rootMessenger.call('ConfigRegistryController:startPolling', null); + await jest.advanceTimersByTimeAsync(0); + + expect(mockApiServiceHandler).toHaveBeenCalled(); + expect(controller.state.lastFetched).not.toBe(oldTimestamp); + + jest.restoreAllMocks(); + }, + ); + }); + + it('handles non-Error exceptions', async () => { + await withController( + { options: { fallbackConfig: MOCK_FALLBACK_CONFIG } }, + async ({ + controller, + rootMessenger, + mockRemoteFeatureFlagGetState, + mockApiServiceHandler, + }) => { + mockRemoteFeatureFlagGetState.mockReturnValue({ + remoteFeatureFlags: { + configRegistryApiEnabled: true, + }, + cacheTimestamp: Date.now(), + }); + + mockApiServiceHandler.mockRejectedValue('String error'); + + rootMessenger.call('ConfigRegistryController:startPolling', null); + await jest.advanceTimersByTimeAsync(0); + + expect(rootMessenger.captureException).toHaveBeenCalledWith( + expect.objectContaining({ message: 'String error' }), + ); + expect(controller.state.configs).toStrictEqual({ + networks: MOCK_FALLBACK_CONFIG, + }); + }, + ); + }); + + it('handles error when state.configs is null', async () => { + await withController( + { + options: { + fallbackConfig: MOCK_FALLBACK_CONFIG, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + state: { configs: null as any }, + }, + }, + async ({ + controller, + rootMessenger, + mockRemoteFeatureFlagGetState, + mockApiServiceHandler, + }) => { + mockRemoteFeatureFlagGetState.mockReturnValue({ + remoteFeatureFlags: { + configRegistryApiEnabled: true, + }, + cacheTimestamp: Date.now(), + }); + + mockApiServiceHandler.mockRejectedValue(new Error('Network error')); + + rootMessenger.call('ConfigRegistryController:startPolling', null); + await jest.advanceTimersByTimeAsync(0); + + expect(rootMessenger.captureException).toHaveBeenCalledWith( + expect.objectContaining({ message: 'Network error' }), + ); + expect(controller.state.configs).toStrictEqual({ + networks: MOCK_FALLBACK_CONFIG, + }); + }, + ); + }); + + it('works via messenger actions', async () => { + await withController(async ({ rootMessenger, mockApiServiceHandler }) => { + const token = rootMessenger.call( + 'ConfigRegistryController:startPolling', + null, + ); + expect(typeof token).toBe('string'); + + await jest.advanceTimersByTimeAsync(0); + expect(mockApiServiceHandler).toHaveBeenCalledTimes(1); + + rootMessenger.call('ConfigRegistryController:stopPolling'); + await jest.advanceTimersByTimeAsync(DEFAULT_POLLING_INTERVAL); + expect(mockApiServiceHandler).toHaveBeenCalledTimes(1); + }); + }); + }); + + describe('state persistence', () => { + it('persists version', async () => { + await withController( + { options: { state: { version: 'v1.0.0' } } }, + ({ controller }) => { + expect(controller.state.version).toBe('v1.0.0'); + }, + ); + }); + + it('persists lastFetched', async () => { + const timestamp = Date.now(); + await withController( + { options: { state: { lastFetched: timestamp } } }, + ({ controller }) => { + expect(controller.state.lastFetched).toBe(timestamp); + }, + ); + }); + }); + + describe('startPolling', () => { + it('returns a polling token string', async () => { + await withController(({ rootMessenger }) => { + const token = rootMessenger.call( + 'ConfigRegistryController:startPolling', + null, + ); + expect(typeof token).toBe('string'); + expect(token.length).toBeGreaterThan(0); + }); + }); + + it('returns a polling token string when called without input', async () => { + await withController(({ rootMessenger }) => { + const token = rootMessenger.call( + 'ConfigRegistryController:startPolling', + null, + ); + expect(typeof token).toBe('string'); + expect(token.length).toBeGreaterThan(0); + }); + }); + + it('proceeds immediately when lastFetched is null', async () => { + await withController( + { + options: { + state: { + lastFetched: null, + }, + }, + }, + async ({ rootMessenger, mockApiServiceHandler }) => { + rootMessenger.call('ConfigRegistryController:startPolling', null); + + await jest.advanceTimersByTimeAsync(0); + expect(mockApiServiceHandler).toHaveBeenCalledTimes(1); + }, + ); + }); + + it('proceeds immediately when lastFetched is old enough', async () => { + const pollingInterval = 10000; + const now = Date.now(); + const oldTimestamp = now - pollingInterval - 1000; + await withController( + { + options: { + pollingInterval, + state: { + lastFetched: oldTimestamp, + }, + }, + }, + async ({ rootMessenger, mockApiServiceHandler }) => { + jest.spyOn(Date, 'now').mockReturnValue(now); + rootMessenger.call('ConfigRegistryController:startPolling', null); + + await jest.advanceTimersByTimeAsync(1); + expect(mockApiServiceHandler).toHaveBeenCalledTimes(1); + }, + ); + }); + + it('proceeds immediately when lastFetched is exactly at polling interval', async () => { + const pollingInterval = 10000; + const now = Date.now(); + const exactTimestamp = now - pollingInterval - 1; + await withController( + { + options: { + pollingInterval, + state: { + lastFetched: exactTimestamp, + }, + }, + }, + async ({ rootMessenger, mockApiServiceHandler }) => { + jest.spyOn(Date, 'now').mockReturnValue(now); + rootMessenger.call('ConfigRegistryController:startPolling', null); + + await jest.advanceTimersByTimeAsync(1); + expect(mockApiServiceHandler).toHaveBeenCalledTimes(1); + }, + ); + }); + + it('clears existing timeout when startPolling is called multiple times', async () => { + const pollingInterval = 10000; + const recentTimestamp = Date.now() - 2000; + await withController( + { + options: { + pollingInterval, + state: { + lastFetched: recentTimestamp, + }, + }, + }, + async ({ rootMessenger, mockApiServiceHandler }) => { + rootMessenger.call('ConfigRegistryController:startPolling', null); + await jest.advanceTimersByTimeAsync(0); + const callsAfterFirst = mockApiServiceHandler.mock.calls.length; + + rootMessenger.call('ConfigRegistryController:startPolling', null); + await jest.advanceTimersByTimeAsync(0); + expect(mockApiServiceHandler).toHaveBeenCalledTimes(callsAfterFirst); + }, + ); + }); + }); + + describe('getNetworkConfigByCaip2ChainId', () => { + it('returns the correct network config for a given CAIP-2 chainId', async () => { + const mockNetworkConfig = createMockNetworkConfig({ + chainId: 'eip155:1', + name: 'Ethereum Mainnet', + }); + await withController( + { + options: { + state: { + configs: { + networks: { + 'eip155:1': createMockNetworkConfig({ + chainId: 'eip155:1', + name: 'Ethereum Mainnet', + }), + }, + }, + }, + }, + }, + async ({ controller }) => { + const networkConfig = + controller.getNetworkConfigByCaip2ChainId('eip155:1'); + + expect(networkConfig).toStrictEqual(mockNetworkConfig); + }, + ); + }); + + it('returns undefined for a non-existent CAIP-2 chainId', async () => { + await withController(async ({ controller }) => { + const networkConfig = + controller.getNetworkConfigByCaip2ChainId('eip155:9999'); + + expect(networkConfig).toBeUndefined(); + }); + }); + }); + + describe('feature flag', () => { + it('uses API when feature flag is enabled', async () => { + await withController( + async ({ + controller, + rootMessenger, + mockRemoteFeatureFlagGetState, + mockApiServiceHandler, + }) => { + mockRemoteFeatureFlagGetState.mockReturnValue({ + remoteFeatureFlags: { + configRegistryApiEnabled: true, + }, + cacheTimestamp: Date.now(), + }); + + const mockChains = [ + createMockNetworkConfig({ + chainId: 'eip155:1', + name: 'Ethereum Mainnet', + }), + ]; + + const fetchConfigSpy = jest.fn().mockResolvedValue({ + data: { + data: { + version: '1.0.0', + timestamp: Date.now(), + chains: mockChains, + }, + }, + modified: true, + etag: 'test-etag', + }); + + mockApiServiceHandler.mockImplementation(fetchConfigSpy); + + rootMessenger.call('ConfigRegistryController:startPolling', null); + await jest.advanceTimersByTimeAsync(0); + + expect(fetchConfigSpy).toHaveBeenCalled(); + expect(controller.state.configs.networks['eip155:1']).toBeDefined(); + expect(controller.state.version).toBe('1.0.0'); + }, + ); + }); + + it('stores all networks in state; selectFeaturedNetworks filters for default list', async () => { + await withController( + async ({ + controller, + rootMessenger, + mockRemoteFeatureFlagGetState, + mockApiServiceHandler, + }) => { + mockRemoteFeatureFlagGetState.mockReturnValue({ + remoteFeatureFlags: { + configRegistryApiEnabled: true, + }, + cacheTimestamp: Date.now(), + }); + + const mockChains = [ + createMockNetworkConfig({ + chainId: 'eip155:1', + name: 'Ethereum Mainnet', + config: { isTestnet: false, isFeatured: true, isActive: true }, + }), + createMockNetworkConfig({ + chainId: 'eip155:5', + name: 'Goerli', + config: { isTestnet: true, isFeatured: true, isActive: true }, + }), + createMockNetworkConfig({ + chainId: 'eip155:10', + name: 'Optimism', + config: { isTestnet: false, isFeatured: false, isActive: true }, + }), + createMockNetworkConfig({ + chainId: 'eip155:137', + name: 'Polygon', + config: { isTestnet: false, isFeatured: true, isActive: false }, + }), + ]; + + const fetchConfigSpy = jest.fn().mockResolvedValue({ + data: { + data: { + version: '1.0.0', + timestamp: Date.now(), + chains: mockChains, + }, + }, + modified: true, + etag: 'test-etag', + }); + + mockApiServiceHandler.mockImplementation(fetchConfigSpy); + + rootMessenger.call('ConfigRegistryController:startPolling', null); + await jest.advanceTimersByTimeAsync(0); + + // All networks stored in state + const allNetworks = selectNetworks(controller.state); + expect(allNetworks['eip155:1']).toBeDefined(); + expect(allNetworks['eip155:5']).toBeDefined(); + expect(allNetworks['eip155:10']).toBeDefined(); + expect(allNetworks['eip155:137']).toBeDefined(); + expect(Object.keys(allNetworks)).toHaveLength(4); + + // selectFeaturedNetworks returns only featured, active, non-testnet + const featuredNetworks = selectFeaturedNetworks(controller.state); + expect(featuredNetworks['eip155:1']).toBeDefined(); + expect(featuredNetworks['eip155:5']).toBeUndefined(); + expect(featuredNetworks['eip155:10']).toBeUndefined(); + expect(featuredNetworks['eip155:137']).toBeUndefined(); + expect(Object.keys(featuredNetworks)).toHaveLength(1); + }, + ); + }); + + it('handles duplicate chainIds by keeping highest priority network and logging warning', async () => { + await withController( + async ({ + controller, + rootMessenger, + mockRemoteFeatureFlagGetState, + mockApiServiceHandler, + }) => { + mockRemoteFeatureFlagGetState.mockReturnValue({ + remoteFeatureFlags: { + configRegistryApiEnabled: true, + }, + cacheTimestamp: Date.now(), + }); + + // Mock API response with duplicate chainIds (last occurrence wins) + const mockChains = [ + createMockNetworkConfig({ + chainId: 'eip155:1', + name: 'Ethereum Mainnet (Low Priority)', + config: { priority: 10 }, + rpcProviders: { + default: { + url: 'https://mainnet.infura.io/v3/{infuraProjectId}', + type: 'infura', + networkClientId: 'mainnet', + }, + fallbacks: [], + }, + }), + createMockNetworkConfig({ + chainId: 'eip155:1', + name: 'Ethereum Mainnet (High Priority)', + config: { priority: 0 }, + rpcProviders: { + default: { + url: 'https://mainnet.alchemy.io/v2/{alchemyApiKey}', + type: 'alchemy', + networkClientId: 'mainnet-alchemy', + }, + fallbacks: [], + }, + }), + createMockNetworkConfig({ + chainId: 'eip155:137', + name: 'Polygon', + }), + ]; + + mockApiServiceHandler.mockResolvedValue({ + data: { + data: { + version: '1.0.0', + timestamp: Date.now(), + chains: mockChains, + }, + }, + modified: true, + etag: 'test-etag', + }); + + rootMessenger.call('ConfigRegistryController:startPolling', null); + await jest.advanceTimersByTimeAsync(0); + + // Last occurrence overwrites (no grouping/priority) + expect(controller.state.configs.networks['eip155:1']).toBeDefined(); + expect(controller.state.configs.networks['eip155:1']?.name).toBe( + 'Ethereum Mainnet (High Priority)', + ); + expect( + controller.state.configs.networks['eip155:1']?.rpcProviders.default + .type, + ).toBe('alchemy'); + + expect(controller.state.configs.networks['eip155:137']).toBeDefined(); + }, + ); + }); + + it('handles duplicate chainIds by keeping last occurrence', async () => { + await withController( + async ({ + controller, + rootMessenger, + mockRemoteFeatureFlagGetState, + mockApiServiceHandler, + }) => { + mockRemoteFeatureFlagGetState.mockReturnValue({ + remoteFeatureFlags: { + configRegistryApiEnabled: true, + }, + cacheTimestamp: Date.now(), + }); + + // Mock API response with duplicate chainIds having same priority + const mockChains = [ + createMockNetworkConfig({ + chainId: 'eip155:1', + name: 'Ethereum Mainnet (First)', + config: { priority: 5 }, + }), + createMockNetworkConfig({ + chainId: 'eip155:1', + name: 'Ethereum Mainnet (Second)', + config: { priority: 5 }, + rpcProviders: { + default: { + url: 'https://mainnet.alchemy.io/v2/{alchemyApiKey}', + type: 'alchemy', + networkClientId: 'mainnet-alchemy', + }, + fallbacks: [], + }, + }), + ]; + + mockApiServiceHandler.mockResolvedValue({ + data: { + data: { + version: '1.0.0', + timestamp: Date.now(), + chains: mockChains, + }, + }, + modified: true, + etag: 'test-etag', + }); + + rootMessenger.call('ConfigRegistryController:startPolling', null); + await jest.advanceTimersByTimeAsync(0); + + // Last occurrence overwrites + expect(controller.state.configs.networks['eip155:1']).toBeDefined(); + expect(controller.state.configs.networks['eip155:1']?.name).toBe( + 'Ethereum Mainnet (Second)', + ); + }, + ); + }); + }); + + describe('KeyringController event listeners', () => { + describe('when KeyringController:unlock event is published', () => { + it('starts polling if the feature flag is enabled', async () => { + await withController( + async ({ + controller, + rootMessenger, + mockRemoteFeatureFlagGetState, + }) => { + mockRemoteFeatureFlagGetState.mockReturnValue({ + remoteFeatureFlags: { + configRegistryApiEnabled: true, + }, + }); + const startPollingSpy = jest.spyOn(controller, 'startPolling'); + + rootMessenger.publish('KeyringController:unlock'); + + expect(startPollingSpy).toHaveBeenCalledTimes(1); + }, + ); + }); + + it('does not start polling if the feature flag is disabled', async () => { + await withController( + async ({ + controller, + rootMessenger, + mockRemoteFeatureFlagGetState, + }) => { + mockRemoteFeatureFlagGetState.mockReturnValue({ + remoteFeatureFlags: { + configRegistryApiEnabled: false, + }, + }); + const startPollingSpy = jest.spyOn(controller, 'startPolling'); + + rootMessenger.publish('KeyringController:unlock'); + + expect(startPollingSpy).not.toHaveBeenCalled(); + }, + ); + }); + + it('does not start the polling if the feature flag is invalid', async () => { + await withController( + async ({ + controller, + rootMessenger, + mockRemoteFeatureFlagGetState, + }) => { + mockRemoteFeatureFlagGetState.mockReturnValue({ + remoteFeatureFlags: { + configRegistryApiEnabled: 'invalid_value', + }, + }); + const startPollingSpy = jest.spyOn(controller, 'startPolling'); + + rootMessenger.publish('KeyringController:unlock'); + + expect(startPollingSpy).not.toHaveBeenCalled(); + }, + ); + }); + }); + + it('stops polling when KeyringController:lock event is published', async () => { + await withController(async ({ rootMessenger, mockApiServiceHandler }) => { + rootMessenger.call('ConfigRegistryController:startPolling', null); + await jest.advanceTimersByTimeAsync(0); + const callsAfterUnlock = mockApiServiceHandler.mock.calls.length; + + rootMessenger.publish('KeyringController:lock'); + + await jest.advanceTimersByTimeAsync(DEFAULT_POLLING_INTERVAL); + expect(mockApiServiceHandler).toHaveBeenCalledTimes(callsAfterUnlock); + }); + }); + + it('calls startPolling with default parameter when called without arguments', async () => { + await withController(async ({ rootMessenger, mockApiServiceHandler }) => { + rootMessenger.call('ConfigRegistryController:startPolling', null); + + await jest.advanceTimersByTimeAsync(0); + expect(mockApiServiceHandler).toHaveBeenCalledTimes(1); + }); + }); + }); + + describe('RemoteFeatureFlagController:stateChange', () => { + it('starts polling when flag becomes enabled and keyring is unlocked', async () => { + await withController( + async ({ + rootMessenger, + mockRemoteFeatureFlagGetState, + mockKeyringControllerGetState, + mockApiServiceHandler, + }) => { + mockRemoteFeatureFlagGetState.mockReturnValue({ + remoteFeatureFlags: { configRegistryApiEnabled: true }, + cacheTimestamp: Date.now(), + }); + mockKeyringControllerGetState.mockReturnValue({ + isUnlocked: true, + }); + + rootMessenger.publish( + 'RemoteFeatureFlagController:stateChange', + { + remoteFeatureFlags: { configRegistryApiEnabled: true }, + cacheTimestamp: Date.now(), + }, + [], + ); + + await jest.advanceTimersByTimeAsync(0); + expect(mockApiServiceHandler).toHaveBeenCalledTimes(1); + }, + ); + }); + + it('does not start polling when the old flag value is `false` and the new flag value is invalid', async () => { + await withController( + async ({ + controller, + rootMessenger, + mockRemoteFeatureFlagGetState, + mockKeyringControllerGetState, + }) => { + mockRemoteFeatureFlagGetState.mockReturnValue({ + remoteFeatureFlags: { configRegistryApiEnabled: false }, + cacheTimestamp: Date.now(), + }); + mockKeyringControllerGetState.mockReturnValue({ + isUnlocked: true, + }); + const startPollingSpy = jest.spyOn(controller, 'startPolling'); + + rootMessenger.publish( + 'RemoteFeatureFlagController:stateChange', + { + remoteFeatureFlags: { configRegistryApiEnabled: 'invalid_value' }, + cacheTimestamp: Date.now(), + }, + [], + ); + + expect(startPollingSpy).not.toHaveBeenCalled(); + }, + ); + }); + + it('stops polling when the old flag value is `true` and the new flag value is invalid', async () => { + await withController( + async ({ + controller, + rootMessenger, + mockRemoteFeatureFlagGetState, + mockKeyringControllerGetState, + }) => { + mockRemoteFeatureFlagGetState.mockReturnValue({ + remoteFeatureFlags: { configRegistryApiEnabled: true }, + cacheTimestamp: Date.now(), + }); + mockKeyringControllerGetState.mockReturnValue({ + isUnlocked: true, + }); + const stopAllPollingSpy = jest.spyOn(controller, 'stopAllPolling'); + + rootMessenger.publish( + 'RemoteFeatureFlagController:stateChange', + { + remoteFeatureFlags: { configRegistryApiEnabled: 'invalid_value' }, + cacheTimestamp: Date.now(), + }, + [], + ); + + expect(stopAllPollingSpy).toHaveBeenCalled(); + }, + ); + }); + + it('does not start polling when keyring is locked and flag becomes enabled', async () => { + await withController( + async ({ + rootMessenger, + mockRemoteFeatureFlagGetState, + mockKeyringControllerGetState, + mockApiServiceHandler, + }) => { + mockRemoteFeatureFlagGetState.mockReturnValue({ + remoteFeatureFlags: { configRegistryApiEnabled: false }, + cacheTimestamp: Date.now(), + }); + mockKeyringControllerGetState.mockReturnValue({ + isUnlocked: false, + }); + + rootMessenger.publish( + 'RemoteFeatureFlagController:stateChange', + { + remoteFeatureFlags: { configRegistryApiEnabled: true }, + cacheTimestamp: Date.now(), + }, + [], + ); + + await jest.advanceTimersByTimeAsync(DEFAULT_POLLING_INTERVAL); + expect(mockApiServiceHandler).not.toHaveBeenCalled(); + }, + ); + }); + + it('stops polling when flag becomes disabled', async () => { + await withController( + async ({ + rootMessenger, + mockRemoteFeatureFlagGetState, + mockKeyringControllerGetState, + mockApiServiceHandler, + }) => { + rootMessenger.call('ConfigRegistryController:startPolling', null); + await jest.advanceTimersByTimeAsync(0); + const callsAfterStart = mockApiServiceHandler.mock.calls.length; + mockRemoteFeatureFlagGetState.mockReturnValue({ + remoteFeatureFlags: { configRegistryApiEnabled: false }, + cacheTimestamp: Date.now(), + }); + mockKeyringControllerGetState.mockReturnValue({ + isUnlocked: true, + }); + + rootMessenger.publish( + 'RemoteFeatureFlagController:stateChange', + { + remoteFeatureFlags: { configRegistryApiEnabled: false }, + cacheTimestamp: Date.now(), + }, + [], + ); + + await jest.advanceTimersByTimeAsync(DEFAULT_POLLING_INTERVAL); + expect(mockApiServiceHandler).toHaveBeenCalledTimes(callsAfterStart); + }, + ); + }); + }); + + describe('stopAllPolling', () => { + it('clears pending delayed poll timeout when stopping', async () => { + const pollingInterval = 10000; + const recentTimestamp = Date.now() - 2000; + await withController( + { + options: { + pollingInterval, + state: { + lastFetched: recentTimestamp, + }, + }, + }, + async ({ rootMessenger, mockApiServiceHandler }) => { + rootMessenger.call('ConfigRegistryController:startPolling', null); + + await jest.advanceTimersByTimeAsync(0); + const callsAfterFirstAdvance = + mockApiServiceHandler.mock.calls.length; + + rootMessenger.call('ConfigRegistryController:stopPolling'); + + await jest.advanceTimersByTimeAsync(pollingInterval); + expect(mockApiServiceHandler).toHaveBeenCalledTimes( + callsAfterFirstAdvance, + ); + }, + ); + }); + + it('handles clearing timeout when no timeout exists', async () => { + await withController(({ rootMessenger }) => { + // Should not throw when stopping without a pending timeout + expect(() => + rootMessenger.call('ConfigRegistryController:stopPolling'), + ).not.toThrow(); + }); + }); + + it('stops all polling when called without token (backward compatible)', async () => { + await withController(async ({ rootMessenger, mockApiServiceHandler }) => { + rootMessenger.call('ConfigRegistryController:startPolling', null); + rootMessenger.call('ConfigRegistryController:startPolling', null); + + await jest.advanceTimersByTimeAsync(0); + expect(mockApiServiceHandler).toHaveBeenCalledTimes(1); + mockApiServiceHandler.mockClear(); + + rootMessenger.call('ConfigRegistryController:stopPolling'); + + await jest.advanceTimersByTimeAsync(DEFAULT_POLLING_INTERVAL); + expect(mockApiServiceHandler).not.toHaveBeenCalled(); + }); + }); + + it('works via messenger action with token', async () => { + await withController(async ({ rootMessenger, mockApiServiceHandler }) => { + const token = rootMessenger.call( + 'ConfigRegistryController:startPolling', + null, + ); + expect(typeof token).toBe('string'); + + await jest.advanceTimersByTimeAsync(0); + expect(mockApiServiceHandler).toHaveBeenCalledTimes(1); + mockApiServiceHandler.mockClear(); + + rootMessenger.call('ConfigRegistryController:stopPolling'); + await jest.advanceTimersByTimeAsync(DEFAULT_POLLING_INTERVAL); + expect(mockApiServiceHandler).not.toHaveBeenCalled(); + }); + }); + + it('works via messenger action without token (backward compatible)', async () => { + await withController(async ({ rootMessenger, mockApiServiceHandler }) => { + const token = rootMessenger.call( + 'ConfigRegistryController:startPolling', + null, + ); + expect(typeof token).toBe('string'); + + await jest.advanceTimersByTimeAsync(0); + expect(mockApiServiceHandler).toHaveBeenCalledTimes(1); + mockApiServiceHandler.mockClear(); + + rootMessenger.call('ConfigRegistryController:stopPolling'); + await jest.advanceTimersByTimeAsync(DEFAULT_POLLING_INTERVAL); + expect(mockApiServiceHandler).not.toHaveBeenCalled(); + }); + }); + }); + + describe('stopPollingByPollingToken', () => { + it('stops delayed poll using placeholder token', async () => { + const pollingInterval = 10000; + const recentTimestamp = Date.now() - 2000; + await withController( + { + options: { + pollingInterval, + state: { + lastFetched: recentTimestamp, + }, + }, + }, + async ({ controller, mockApiServiceHandler }) => { + const token = controller.startPolling(null); + + await jest.advanceTimersByTimeAsync(0); + const callsAfterFirstAdvance = + mockApiServiceHandler.mock.calls.length; + + controller.stopPollingByPollingToken(token); + + await jest.advanceTimersByTimeAsync(pollingInterval); + expect(mockApiServiceHandler).toHaveBeenCalledTimes( + callsAfterFirstAdvance, + ); + }, + ); + }); + + it('stops delayed poll using placeholder token after timeout fires', async () => { + const pollingInterval = 10000; + const recentTimestamp = Date.now() - 2000; + await withController( + { + options: { + pollingInterval, + state: { + lastFetched: recentTimestamp, + }, + }, + }, + async ({ controller, mockApiServiceHandler }) => { + const token = controller.startPolling(null); + + await jest.advanceTimersByTimeAsync(pollingInterval); + expect(mockApiServiceHandler).toHaveBeenCalledTimes(2); + mockApiServiceHandler.mockClear(); + + controller.stopPollingByPollingToken(token); + + await jest.advanceTimersByTimeAsync(pollingInterval); + expect(mockApiServiceHandler).not.toHaveBeenCalled(); + }, + ); + }); + + it('stops specific polling session when called with token', async () => { + await withController(async ({ controller, mockApiServiceHandler }) => { + const tokenA = controller.startPolling(null); + await jest.advanceTimersByTimeAsync(0); + expect(mockApiServiceHandler).toHaveBeenCalledTimes(1); + mockApiServiceHandler.mockClear(); + + const tokenB = controller.startPolling(null); + await jest.advanceTimersByTimeAsync(0); + expect(mockApiServiceHandler).not.toHaveBeenCalled(); + mockApiServiceHandler.mockClear(); + + controller.stopPollingByPollingToken(tokenA); + controller.stopPollingByPollingToken(tokenB); + + await jest.advanceTimersByTimeAsync(DEFAULT_POLLING_INTERVAL); + expect(mockApiServiceHandler).not.toHaveBeenCalled(); + }); + }); + }); +}); diff --git a/packages/config-registry-controller/src/ConfigRegistryController.ts b/packages/config-registry-controller/src/ConfigRegistryController.ts new file mode 100644 index 00000000000..3348a65a833 --- /dev/null +++ b/packages/config-registry-controller/src/ConfigRegistryController.ts @@ -0,0 +1,345 @@ +import type { + ControllerGetStateAction, + ControllerStateChangedEvent, + StateMetadata, +} from '@metamask/base-controller'; +import type { + KeyringControllerGetStateAction, + KeyringControllerLockEvent, + KeyringControllerUnlockEvent, +} from '@metamask/keyring-controller'; +import type { Messenger } from '@metamask/messenger'; +import { StaticIntervalPollingController } from '@metamask/polling-controller'; +import type { RemoteFeatureFlagControllerGetStateAction } from '@metamask/remote-feature-flag-controller'; +import type { RemoteFeatureFlagControllerStateChangeEvent } from '@metamask/remote-feature-flag-controller'; +import { CaipChainId, Duration, inMilliseconds, Json } from '@metamask/utils'; + +import type { ConfigRegistryApiServiceFetchConfigAction } from './config-registry-api-service/config-registry-api-service-method-action-types.js'; +import type { RegistryNetworkConfig } from './config-registry-api-service/types.js'; +import type { ConfigRegistryControllerMethodActions } from './ConfigRegistryController-method-action-types.js'; + +const controllerName = 'ConfigRegistryController'; + +export const DEFAULT_POLLING_INTERVAL = inMilliseconds(1, Duration.Day); + +const FEATURE_FLAG_KEY = 'configRegistryApiEnabled'; + +/** + * State for the ConfigRegistryController. + * + * Tracks network configurations fetched from the config registry API, + * along with metadata about the fetch status and caching. + */ +export type ConfigRegistryControllerState = { + /** + * Network configurations organized by chain ID. + * Stores the full API response including isFeatured, isTestnet, etc. + * Use selectors (e.g. selectFeaturedNetworks) to filter when needed. + */ + configs: { + networks: Record; + }; + /** + * Semantic version string of the configuration data from the API. + * Indicates the version/schema of the configuration structure itself + * (e.g., "v1.0.0", "1.0.0"). + * This is different from `etag` which is used for HTTP cache validation. + */ + version: string | null; + /** + * Timestamp (milliseconds since epoch) of when the configuration + * was last successfully fetched from the API. + */ + lastFetched: number | null; + /** + * HTTP entity tag (ETag) used for cache validation. + * Sent as `If-None-Match` header in subsequent requests to check + * if the content has changed. If the server returns 304 Not Modified, + * the full response body is not downloaded, improving efficiency. + * This is different from `version` which is a semantic version string + * indicating the schema/version of the configuration data itself. + */ + etag: string | null; +}; + +const stateMetadata = { + configs: { + persist: true, + includeInStateLogs: false, + includeInDebugSnapshot: true, + usedInUi: true, + }, + version: { + persist: true, + includeInStateLogs: true, + includeInDebugSnapshot: true, + usedInUi: false, + }, + lastFetched: { + persist: true, + includeInStateLogs: true, + includeInDebugSnapshot: true, + usedInUi: false, + }, + etag: { + persist: true, + includeInStateLogs: false, + includeInDebugSnapshot: false, + usedInUi: false, + }, +} satisfies StateMetadata; + +/** + * Default fallback configuration when no configs are available. + */ +const DEFAULT_FALLBACK_CONFIG: Record = {}; + +const MESSENGER_EXPOSED_METHODS = [ + 'startPolling', + 'stopPolling', + 'getNetworkConfigByCaip2ChainId', +] as const; + +/** + * Published when the state of {@link ConfigRegistryController} changes. + */ +export type ConfigRegistryControllerStateChangedEvent = + ControllerStateChangedEvent< + typeof controllerName, + ConfigRegistryControllerState + >; + +/** + * Retrieves the state of the {@link ConfigRegistryController}. + */ +export type ConfigRegistryControllerGetStateAction = ControllerGetStateAction< + typeof controllerName, + ConfigRegistryControllerState +>; + +/** + * Actions that {@link ConfigRegistryControllerMessenger} exposes to other consumers. + */ +export type ConfigRegistryControllerActions = + | ConfigRegistryControllerGetStateAction + | ConfigRegistryControllerMethodActions; + +/** + * Actions from other messengers that {@link ConfigRegistryControllerMessenger} + * calls. + */ +type AllowedActions = + | KeyringControllerGetStateAction + | RemoteFeatureFlagControllerGetStateAction + | ConfigRegistryApiServiceFetchConfigAction; + +/** + * Events that {@link ConfigRegistryControllerMessenger} exposes to other consumers. + */ +export type ConfigRegistryControllerEvents = + ConfigRegistryControllerStateChangedEvent; + +/** + * Events from other messengers that {@link ConfigRegistryControllerMessenger} + * subscribes to. + */ +type AllowedEvents = + | KeyringControllerUnlockEvent + | KeyringControllerLockEvent + | RemoteFeatureFlagControllerStateChangeEvent; + +/** + * The messenger restricted to actions and events accessed by + * {@link ConfigRegistryController}. + */ +export type ConfigRegistryControllerMessenger = Messenger< + typeof controllerName, + ConfigRegistryControllerActions | AllowedActions, + ConfigRegistryControllerEvents | AllowedEvents +>; + +export type ConfigRegistryControllerOptions = { + messenger: ConfigRegistryControllerMessenger; + state?: Partial; + pollingInterval?: number; + fallbackConfig?: Record; +}; + +export class ConfigRegistryController extends StaticIntervalPollingController()< + typeof controllerName, + ConfigRegistryControllerState, + ConfigRegistryControllerMessenger +> { + /** + * @param options - The controller options. + * @param options.messenger - The controller messenger. Must have + * `ConfigRegistryApiService:fetchConfig` action handler registered + * (e.g. by instantiating {@link ConfigRegistryApiService} with the same + * messenger). + * @param options.state - Initial state. + * @param options.pollingInterval - Polling interval in milliseconds. + * @param options.fallbackConfig - Fallback configuration. + */ + constructor({ + messenger, + state = {}, + pollingInterval = DEFAULT_POLLING_INTERVAL, + fallbackConfig = DEFAULT_FALLBACK_CONFIG, + }: ConfigRegistryControllerOptions) { + super({ + name: controllerName, + metadata: stateMetadata, + messenger, + state: { + configs: { + networks: state.configs?.networks ?? { ...fallbackConfig }, + }, + version: state.version ?? null, + lastFetched: state.lastFetched ?? null, + etag: state.etag ?? null, + }, + }); + + this.setIntervalLength(pollingInterval); + + this.messenger.registerMethodActionHandlers( + this, + MESSENGER_EXPOSED_METHODS, + ); + this.#setupEventListeners(); + } + + /** + * Get the network configuration for a given CAIP-2 chain ID. + * + * @param caip2ChainId - The CAIP-2 chain ID (e.g., "eip155:1"). + * @returns The network configuration if found, otherwise undefined. + */ + getNetworkConfigByCaip2ChainId( + caip2ChainId: CaipChainId, + ): RegistryNetworkConfig | undefined { + return this.state.configs.networks[caip2ChainId]; + } + + async _executePoll(_input: null): Promise { + try { + const result = await this.messenger.call( + 'ConfigRegistryApiService:fetchConfig', + { + etag: this.state.etag ?? undefined, + }, + ); + + if (!result.modified) { + this.update((state) => { + state.lastFetched = Date.now(); + if (result.etag !== undefined) { + state.etag = result.etag ?? null; + } + }); + return; + } + + const apiChains = result.data.data.chains; + const newConfigs: Record = {}; + // duplicate chainIds from API response are not expected + apiChains.forEach((chainConfig) => { + const { chainId } = chainConfig; + newConfigs[chainId] = chainConfig; + }); + + this.update((state) => { + state.configs.networks = newConfigs; + state.version = result.data.data.version; + state.lastFetched = Date.now(); + state.etag = result.etag ?? null; + }); + } catch (error) { + const errorInstance = + error instanceof Error ? error : new Error(String(error)); + + this.messenger.captureException?.(errorInstance); + } + } + + /** + * Stop all polling. + */ + stopPolling(): void { + // This is a wrapper around `super.stopAllPolling()` for backwards + // compatibility, while allowing this method to be exposed via the messenger + // using the `MESSENGER_EXPOSED_METHODS` array. + super.stopAllPolling(); + } + + /** + * Setup messenger event listeners necessary for the controller lifecycle + */ + #setupEventListeners(): void { + this.messenger.subscribe( + 'KeyringController:unlock', + this.#onUnlock.bind(this), + ); + + this.messenger.subscribe('KeyringController:lock', this.#onLock.bind(this)); + + this.messenger.subscribe( + 'RemoteFeatureFlagController:stateChange', + this.#onFeatureFlagChange.bind(this), + (stateSelector) => stateSelector.remoteFeatureFlags[FEATURE_FLAG_KEY], + ); + } + + /** + * Handle wallet unlock event by starting polling if the config registry API is enabled. + */ + #onUnlock(): void { + if (this.#isFeatureFlagEnabled()) { + this.startPolling(null); + } + } + + /** + * Handle wallet lock event by stopping all polling to prevent unnecessary API calls. + */ + #onLock(): void { + this.stopAllPolling(); + } + + /** + * Handle changes to the config registry API feature flag by + * starting or stopping polling accordingly. + * + * @param featureFlagValue - The new value of the feature flag. + */ + #onFeatureFlagChange(featureFlagValue: Json): void { + const { isUnlocked } = this.messenger.call('KeyringController:getState'); + + if ( + typeof featureFlagValue === 'boolean' && + featureFlagValue && + isUnlocked + ) { + this.startPolling(null); + } else { + this.stopAllPolling(); + } + } + + /** + * Get the current status of the config registry feature flag. + * + * @returns Whether the config registry API is enabled. + */ + #isFeatureFlagEnabled(): boolean { + const featureFlagValue = this.messenger.call( + 'RemoteFeatureFlagController:getState', + ).remoteFeatureFlags[FEATURE_FLAG_KEY]; + + if (typeof featureFlagValue !== 'boolean') { + return false; + } + + return featureFlagValue; + } +} diff --git a/packages/config-registry-controller/src/config-registry-api-service/config-registry-api-service-method-action-types.ts b/packages/config-registry-controller/src/config-registry-api-service/config-registry-api-service-method-action-types.ts new file mode 100644 index 00000000000..5f2f15c4025 --- /dev/null +++ b/packages/config-registry-controller/src/config-registry-api-service/config-registry-api-service-method-action-types.ts @@ -0,0 +1,17 @@ +/** + * This file is auto generated. + * Do not edit manually. + */ + +import type { ConfigRegistryApiService } from './config-registry-api-service.js'; + +export type ConfigRegistryApiServiceFetchConfigAction = { + type: `ConfigRegistryApiService:fetchConfig`; + handler: ConfigRegistryApiService['fetchConfig']; +}; + +/** + * Union of all ConfigRegistryApiService action types. + */ +export type ConfigRegistryApiServiceMethodActions = + ConfigRegistryApiServiceFetchConfigAction; diff --git a/packages/config-registry-controller/src/config-registry-api-service/config-registry-api-service.test.ts b/packages/config-registry-controller/src/config-registry-api-service/config-registry-api-service.test.ts new file mode 100644 index 00000000000..eb44697c0ff --- /dev/null +++ b/packages/config-registry-controller/src/config-registry-api-service/config-registry-api-service.test.ts @@ -0,0 +1,410 @@ +import nock from 'nock'; + +import { createMockNetworkConfig } from '../../tests/helpers.js'; +import { + ConfigRegistryApiEnv, + ConfigRegistryApiService, +} from './config-registry-api-service.js'; +import type { + ConfigRegistryApiServiceMessenger, + ConfigRegistryApiServiceOptions, +} from './config-registry-api-service.js'; +import type { RegistryConfigApiResponse } from './types.js'; + +function createMockServiceMessenger(): ConfigRegistryApiServiceMessenger { + return { + registerMethodActionHandlers: jest.fn(), + } as unknown as ConfigRegistryApiServiceMessenger; +} + +function createService( + overrides: Partial> = {}, +): ConfigRegistryApiService { + return new ConfigRegistryApiService({ + ...overrides, + messenger: createMockServiceMessenger(), + }); +} + +const CONFIG_PATH = '/v1/config/networks'; +const UAT_ORIGIN = 'https://client-config.uat-api.cx.metamask.io'; +const DEV_ORIGIN = 'https://client-config.dev-api.cx.metamask.io'; +const PRD_ORIGIN = 'https://client-config.api.cx.metamask.io'; + +const MOCK_API_RESPONSE: RegistryConfigApiResponse = { + data: { + version: '"24952800ba9dafbc5e2c91f57f386d28"', + timestamp: 1761829548000, + chains: [createMockNetworkConfig()], + }, +}; + +describe('ConfigRegistryApiService', () => { + describe('fetchConfig', () => { + describe('URL by env', () => { + it('uses UAT URL when env is UAT', async () => { + const scope = nock(UAT_ORIGIN) + .get(CONFIG_PATH) + .reply(200, MOCK_API_RESPONSE); + + const service = createService({ env: ConfigRegistryApiEnv.UAT }); + await service.fetchConfig(); + expect(scope.isDone()).toBe(true); + }); + + it('uses DEV URL when env is DEV', async () => { + const scope = nock(DEV_ORIGIN) + .get(CONFIG_PATH) + .reply(200, MOCK_API_RESPONSE); + + const service = createService({ env: ConfigRegistryApiEnv.DEV }); + await service.fetchConfig(); + expect(scope.isDone()).toBe(true); + }); + + it('uses PRD URL when env is PRD', async () => { + const scope = nock(PRD_ORIGIN) + .get(CONFIG_PATH) + .reply(200, MOCK_API_RESPONSE); + + const service = createService({ env: ConfigRegistryApiEnv.PRD }); + await service.fetchConfig(); + expect(scope.isDone()).toBe(true); + }); + + it('defaults to UAT environment', async () => { + const scope = nock(UAT_ORIGIN) + .get(CONFIG_PATH) + .reply(200, MOCK_API_RESPONSE); + + const service = createService(); + + await service.fetchConfig(); + + expect(scope.isDone()).toBe(true); + }); + }); + + it('fetches config from API successfully', async () => { + const scope = nock(UAT_ORIGIN) + .get(CONFIG_PATH) + .reply(200, MOCK_API_RESPONSE, { + ETag: '"test-etag-123"', + }); + + const service = createService(); + const result = await service.fetchConfig(); + + expect(result).toMatchObject({ + modified: true, + etag: '"test-etag-123"', + data: MOCK_API_RESPONSE, + }); + expect(scope.isDone()).toBe(true); + }); + + it('fetches config from API without ETag header', async () => { + const scope = nock(UAT_ORIGIN) + .get(CONFIG_PATH) + .reply(200, MOCK_API_RESPONSE); + + const service = createService(); + const result = await service.fetchConfig(); + + expect(result).toMatchObject({ modified: true, data: MOCK_API_RESPONSE }); + expect(result.etag).toBeUndefined(); + expect(scope.isDone()).toBe(true); + }); + + it('handles 304 Not Modified response', async () => { + const etag = '"test-etag-123"'; + const scope = nock(UAT_ORIGIN) + .get(CONFIG_PATH) + .matchHeader('If-None-Match', etag) + .reply(304); + + const service = createService(); + const result = await service.fetchConfig({ etag }); + + expect(result.modified).toBe(false); + expect(result.data).toBeUndefined(); + expect(scope.isDone()).toBe(true); + }); + + it('returns cached data when 304 is received and service has prior successful response', async () => { + const etag = '"test-etag-123"'; + const firstScope = nock(UAT_ORIGIN) + .get(CONFIG_PATH) + .reply(200, MOCK_API_RESPONSE, { ETag: etag }); + + const service = createService(); + await service.fetchConfig(); + expect(firstScope.isDone()).toBe(true); + + const secondScope = nock(UAT_ORIGIN) + .get(CONFIG_PATH) + .matchHeader('If-None-Match', etag) + .reply(304); + + const result = await service.fetchConfig({ etag }); + + expect(result.modified).toBe(false); + expect(result.data).toStrictEqual(MOCK_API_RESPONSE); + expect(secondScope.isDone()).toBe(true); + }); + + it('handles 304 Not Modified response without ETag header', async () => { + const scope = nock(UAT_ORIGIN).get(CONFIG_PATH).reply(304); + + const service = createService(); + const result = await service.fetchConfig(); + + expect(result.modified).toBe(false); + expect(result.etag).toBeUndefined(); + expect(scope.isDone()).toBe(true); + }); + + it('includes If-None-Match header when etag is provided', async () => { + const etag = '"test-etag-123"'; + const scope = nock(UAT_ORIGIN) + .get(CONFIG_PATH) + .matchHeader('If-None-Match', etag) + .reply(200, MOCK_API_RESPONSE); + + const service = createService(); + await service.fetchConfig({ etag }); + + expect(scope.isDone()).toBe(true); + }); + + it('does not include If-None-Match header when etag is undefined', async () => { + const scope = nock(UAT_ORIGIN) + .get(CONFIG_PATH) + .matchHeader('If-None-Match', (val) => val === undefined) + .reply(200, MOCK_API_RESPONSE); + + const service = createService(); + await service.fetchConfig({ etag: undefined }); + + expect(scope.isDone()).toBe(true); + }); + + it('handles fetchConfig called with undefined options', async () => { + const scope = nock(UAT_ORIGIN) + .get(CONFIG_PATH) + .reply(200, MOCK_API_RESPONSE); + + const service = createService(); + await service.fetchConfig(undefined); + + expect(scope.isDone()).toBe(true); + }); + + it('throws error on invalid response structure', async () => { + const invalidResponse = { invalid: 'data' }; + const scope = nock(UAT_ORIGIN) + .get(CONFIG_PATH) + .reply(200, invalidResponse); + + const service = createService(); + + await expect(service.fetchConfig()).rejects.toMatchObject( + expect.objectContaining({ message: expect.any(String) }), + ); + expect(scope.isDone()).toBe(true); + }); + + it('throws error when response body is null', async () => { + const scope = nock(UAT_ORIGIN).get(CONFIG_PATH).reply(200, 'null'); + + const service = createService(); + + await expect(service.fetchConfig()).rejects.toMatchObject( + expect.objectContaining({ message: expect.any(String) }), + ); + expect(scope.isDone()).toBe(true); + }); + + it('throws error when data is null', async () => { + const scope = nock(UAT_ORIGIN) + .get(CONFIG_PATH) + .reply(200, { data: null }); + + const service = createService(); + + await expect(service.fetchConfig()).rejects.toMatchObject( + expect.objectContaining({ message: expect.any(String) }), + ); + expect(scope.isDone()).toBe(true); + }); + + it('throws error when data.chains is not an array', async () => { + const scope = nock(UAT_ORIGIN) + .get(CONFIG_PATH) + .reply(200, { + data: { version: '1', timestamp: 0, chains: 'not-an-array' }, + }); + + const service = createService(); + + await expect(service.fetchConfig()).rejects.toMatchObject( + expect.objectContaining({ message: expect.any(String) }), + ); + expect(scope.isDone()).toBe(true); + }); + + it('throws error on HTTP error status', async () => { + const scope = nock(UAT_ORIGIN) + .get(CONFIG_PATH) + .reply(500, 'Internal Server Error'); + + const service = createService({ + policyOptions: { maxRetries: 0 }, + }); + + await expect(service.fetchConfig()).rejects.toMatchObject( + expect.objectContaining({ + message: 'Failed to fetch config: 500 Internal Server Error', + }), + ); + expect(scope.isDone()).toBe(true); + }); + + it('handles network errors', async () => { + const customFetch = jest + .fn() + .mockRejectedValue(new Error('Network connection failed')); + + const service = createService({ + fetch: customFetch, + }); + + await expect(service.fetchConfig()).rejects.toMatchObject( + expect.objectContaining({ message: 'Network connection failed' }), + ); + }); + + it('retries on failure', async () => { + nock(UAT_ORIGIN).get(CONFIG_PATH).replyWithError('Network error'); + nock(UAT_ORIGIN).get(CONFIG_PATH).replyWithError('Network error'); + const successScope = nock(UAT_ORIGIN) + .get(CONFIG_PATH) + .reply(200, MOCK_API_RESPONSE); + + const service = createService({ + policyOptions: { maxRetries: 2 }, + }); + + const result = await service.fetchConfig(); + + expect(result).toMatchObject({ modified: true, data: MOCK_API_RESPONSE }); + expect(successScope.isDone()).toBe(true); + }); + }); + + describe('onRetry', () => { + it('registers and returns a disposable', () => { + const service = createService(); + const listener = jest.fn(); + const disposable = service.onRetry(listener); + expect(disposable).toHaveProperty('dispose'); + expect(typeof disposable.dispose).toBe('function'); + }); + }); + + describe('onBreak', () => { + beforeEach(() => { + jest.useFakeTimers({ doNotFake: ['nextTick', 'queueMicrotask'] }); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + it('registers and calls onBreak handler', async () => { + const maximumConsecutiveFailures = 3; + const retries = 0; + + for (let i = 0; i < maximumConsecutiveFailures; i++) { + nock(UAT_ORIGIN).get(CONFIG_PATH).replyWithError('Network error'); + } + + const onBreakHandler = jest.fn(); + const service = createService({ + policyOptions: { + maxRetries: retries, + maxConsecutiveFailures: maximumConsecutiveFailures, + circuitBreakDuration: 10000, + }, + }); + + service.onBreak(onBreakHandler); + service.onRetry(() => { + jest.advanceTimersToNextTimer(); + }); + + for (let i = 0; i < maximumConsecutiveFailures; i++) { + await expect(service.fetchConfig()).rejects.toMatchObject( + expect.objectContaining({ message: expect.any(String) }), + ); + } + + const finalPromise = service.fetchConfig(); + await expect(finalPromise).rejects.toMatchObject( + expect.objectContaining({ message: expect.any(String) }), + ); + expect(onBreakHandler).toHaveBeenCalled(); + }); + }); + + describe('onDegraded', () => { + beforeEach(() => { + jest.useFakeTimers({ doNotFake: ['nextTick', 'queueMicrotask'] }); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + it('calls onDegraded handler when service becomes degraded', async () => { + const degradedThreshold = 2000; // 2 seconds + nock(UAT_ORIGIN) + .get(CONFIG_PATH) + .reply(200, () => { + jest.advanceTimersByTime(degradedThreshold + 100); + return MOCK_API_RESPONSE; + }); + + const service = createService({ + policyOptions: { degradedThreshold, maxRetries: 0 }, + }); + const onDegradedHandler = jest.fn(); + service.onDegraded(onDegradedHandler); + + await service.fetchConfig(); + + expect(onDegradedHandler).toHaveBeenCalled(); + }); + }); + + describe('custom fetch function', () => { + it('uses custom fetch function when provided', async () => { + const customFetch = jest.fn().mockResolvedValue( + // eslint-disable-next-line no-restricted-globals + new Response(JSON.stringify(MOCK_API_RESPONSE), { + status: 200, + headers: { ETag: '"custom-etag"' }, + }), + ); + + const service = createService({ + fetch: customFetch, + }); + + const result = await service.fetchConfig(); + + expect(customFetch).toHaveBeenCalled(); + expect(result).toMatchObject({ modified: true, data: MOCK_API_RESPONSE }); + }); + }); +}); diff --git a/packages/config-registry-controller/src/config-registry-api-service/config-registry-api-service.ts b/packages/config-registry-controller/src/config-registry-api-service/config-registry-api-service.ts new file mode 100644 index 00000000000..e6fce4d49a9 --- /dev/null +++ b/packages/config-registry-controller/src/config-registry-api-service/config-registry-api-service.ts @@ -0,0 +1,242 @@ +import { createServicePolicy, HttpError } from '@metamask/controller-utils'; +import type { + CreateServicePolicyOptions, + ServicePolicy, +} from '@metamask/controller-utils'; +import type { Messenger } from '@metamask/messenger'; +import type { IDisposable } from 'cockatiel'; + +import type { ConfigRegistryApiServiceMethodActions } from './config-registry-api-service-method-action-types.js'; +import type { + FetchConfigOptions, + FetchConfigResult, + RegistryConfigApiResponse, +} from './types.js'; +import { validateRegistryConfigApiResponse } from './types.js'; + +const ENDPOINT_PATH = '/config/networks'; + +export enum ConfigRegistryApiEnv { + DEV = 'dev', + UAT = 'uat', + PRD = 'prod', +} + +/** + * The name of the {@link ConfigRegistryApiService}, used to namespace the + * service's actions and events. + */ +export const serviceName = 'ConfigRegistryApiService'; + +// === MESSENGER === + +const MESSENGER_EXPOSED_METHODS = ['fetchConfig'] as const; + +/** + * Actions that {@link ConfigRegistryApiService} exposes to other consumers. + */ +export type ConfigRegistryApiServiceActions = + ConfigRegistryApiServiceMethodActions; + +/** + * Actions from other messengers that {@link ConfigRegistryApiServiceMessenger} calls. + */ +type AllowedActions = never; + +/** + * Events that {@link ConfigRegistryApiService} exposes to other consumers. + */ +export type ConfigRegistryApiServiceEvents = never; + +/** + * Events from other messengers that {@link ConfigRegistryApiService} subscribes to. + */ +type AllowedEvents = never; + +/** + * The messenger which is restricted to actions and events accessed by + * {@link ConfigRegistryApiService}. + */ +export type ConfigRegistryApiServiceMessenger = Messenger< + typeof serviceName, + ConfigRegistryApiServiceActions | AllowedActions, + ConfigRegistryApiServiceEvents | AllowedEvents +>; + +// === SERVICE DEFINITION === + +/** + * Returns the base URL for the config registry API for the given environment. + * + * @param env - The environment to get the URL for. + * @returns The base URL for the environment. + */ +function getConfigRegistryUrl(env: ConfigRegistryApiEnv): string { + const envPrefix = env === ConfigRegistryApiEnv.PRD ? '' : `${env}-`; + return `https://client-config.${envPrefix}api.cx.metamask.io/v1${ENDPOINT_PATH}`; +} + +export type ConfigRegistryApiServiceOptions = { + /** + * The messenger suited for this service. Required so the service can be used + * independently and register its actions. + */ + messenger: ConfigRegistryApiServiceMessenger; + env?: ConfigRegistryApiEnv; + fetch?: typeof fetch; + /** + * Options to pass to `createServicePolicy`, which wraps each request. + * See {@link CreateServicePolicyOptions}. + */ + policyOptions?: CreateServicePolicyOptions; +}; + +export class ConfigRegistryApiService { + readonly name: typeof serviceName; + + readonly #messenger: ConfigRegistryApiServiceMessenger; + + readonly #policy: ServicePolicy; + + readonly #url: string; + + readonly #fetch: typeof fetch; + + /** Cached response from the last successful fetch. Used when server returns 304. */ + #cachedResponse: RegistryConfigApiResponse | null = null; + + /** + * Construct a Config Registry API Service. + * + * @param options - The options for constructing the service. + * @param options.messenger - The messenger suited for this service. + * @param options.env - The environment to determine the correct API endpoints. Defaults to UAT. + * @param options.fetch - Custom fetch function for testing or custom implementations. Defaults to the global fetch. + * @param options.policyOptions - Options to pass to `createServicePolicy`, which wraps each request. See {@link CreateServicePolicyOptions}. + */ + constructor({ + messenger, + env = ConfigRegistryApiEnv.UAT, + fetch: customFetch = globalThis.fetch, + policyOptions = {}, + }: ConfigRegistryApiServiceOptions) { + this.name = serviceName; + this.#messenger = messenger; + this.#url = getConfigRegistryUrl(env); + this.#fetch = customFetch; + + 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. Primarily useful in tests where timers are being + * mocked. + * + * @param listener - The handler to be called. + * @returns An object that can be used to unregister the handler. See + * {@link CockatielEvent}. + * @see {@link createServicePolicy} + */ + onRetry(listener: Parameters[0]): 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 args - The arguments passed to the underlying policy's onBreak method + * (e.g. the listener to be called). + * @returns An object that can be used to unregister the handler. See + * {@link CockatielEvent}. + * @see {@link createServicePolicy} + */ + onBreak( + ...args: Parameters + ): ReturnType { + return this.#policy.onBreak(...args); + } + + /** + * Registers a handler that will be called under one of two circumstances: + * + * 1. After a set number of retries prove that requests to the API + * consistently result in one of the following failures: + * 1. A connection initiation error + * 2. A connection reset error + * 3. A timeout error + * 4. A non-JSON response + * 5. A 502, 503, or 504 response + * 2. After a successful request is made to the API, but the response takes + * longer than a set duration to return. + * + * @param args - The arguments passed to the underlying policy's onDegraded + * method (e.g. the listener to be called). + * @returns An object that can be used to unregister the handler. See + * {@link CockatielEvent}. + */ + onDegraded( + ...args: Parameters + ): ReturnType { + return this.#policy.onDegraded(...args); + } + + async fetchConfig( + options: FetchConfigOptions = {}, + ): Promise { + const headers: HeadersInit = { + 'Cache-Control': 'no-cache', + }; + + if (options.etag) { + headers['If-None-Match'] = options.etag; + } + + const response = await this.#policy.execute(async () => { + const res = await this.#fetch(this.#url, { + headers, + }); + + if (res.status === 304) { + return res; + } + + if (!res.ok) { + throw new HttpError( + res.status, + `Failed to fetch config: ${res.status} ${res.statusText}`, + ); + } + + return res; + }); + + if (response.status === 304) { + const etag = response.headers.get('ETag') ?? undefined; + return { + modified: false, + etag, + ...(this.#cachedResponse !== null && { data: this.#cachedResponse }), + }; + } + + const etag = response.headers.get('ETag') ?? undefined; + const jsonData = await response.json(); + + validateRegistryConfigApiResponse(jsonData); + + this.#cachedResponse = jsonData; + + return { + data: jsonData, + etag, + modified: true, + }; + } +} diff --git a/packages/config-registry-controller/src/config-registry-api-service/filters.test.ts b/packages/config-registry-controller/src/config-registry-api-service/filters.test.ts new file mode 100644 index 00000000000..b81120efd16 --- /dev/null +++ b/packages/config-registry-controller/src/config-registry-api-service/filters.test.ts @@ -0,0 +1,97 @@ +import { createMockNetworkConfig } from '../../tests/helpers.js'; +import { filterNetworks } from './filters.js'; +import type { RegistryNetworkConfig } from './types.js'; + +describe('filters', () => { + describe('filterNetworks', () => { + const networks: RegistryNetworkConfig[] = [ + createMockNetworkConfig({ + config: { + isFeatured: true, + isTestnet: false, + isActive: true, + isDeprecated: false, + isDefault: true, + }, + }), + createMockNetworkConfig({ + chainId: 'eip155:5', + config: { + isFeatured: false, + isTestnet: true, + isActive: true, + isDeprecated: false, + isDefault: false, + }, + }), + createMockNetworkConfig({ + chainId: 'eip155:42', + config: { + isFeatured: true, + isTestnet: false, + isActive: false, + isDeprecated: true, + isDefault: false, + }, + }), + ]; + + it('returns all networks when no filters applied', () => { + const result = filterNetworks(networks); + + expect(result).toHaveLength(3); + }); + + it('filters by isFeatured', () => { + const result = filterNetworks(networks, { isFeatured: true }); + + expect(result).toHaveLength(2); + expect(result.every((network) => network.config.isFeatured)).toBe(true); + }); + + it('filters by isTestnet', () => { + const result = filterNetworks(networks, { isTestnet: true }); + + expect(result).toHaveLength(1); + expect(result[0].chainId).toBe('eip155:5'); + }); + + it('filters by isActive', () => { + const result = filterNetworks(networks, { isActive: true }); + + expect(result).toHaveLength(2); + expect(result.every((network) => network.config.isActive)).toBe(true); + }); + + it('filters by isDeprecated', () => { + const result = filterNetworks(networks, { isDeprecated: true }); + + expect(result).toHaveLength(1); + expect(result[0].chainId).toBe('eip155:42'); + }); + + it('filters by isDefault', () => { + const result = filterNetworks(networks, { isDefault: true }); + + expect(result).toHaveLength(1); + expect(result[0].chainId).toBe('eip155:1'); + }); + + it('filters by multiple criteria (requiring all filters to match)', () => { + const result = filterNetworks(networks, { + isFeatured: true, + isActive: true, + isTestnet: false, + }); + + expect(result).toHaveLength(1); + expect(result[0].chainId).toBe('eip155:1'); + }); + + it('returns empty array for empty input', () => { + const result = filterNetworks([]); + + expect(result).toStrictEqual([]); + }); + }); +}); diff --git a/packages/config-registry-controller/src/config-registry-api-service/filters.ts b/packages/config-registry-controller/src/config-registry-api-service/filters.ts new file mode 100644 index 00000000000..e0add5261f0 --- /dev/null +++ b/packages/config-registry-controller/src/config-registry-api-service/filters.ts @@ -0,0 +1,41 @@ +import type { RegistryNetworkConfig } from './types.js'; + +export type NetworkFilterOptions = { + isFeatured?: boolean; + isTestnet?: boolean; + isActive?: boolean; + isDeprecated?: boolean; + isDefault?: boolean; +}; + +const FILTER_KEYS = [ + 'isFeatured', + 'isTestnet', + 'isActive', + 'isDeprecated', + 'isDefault', +] as const satisfies (keyof NetworkFilterOptions)[]; + +/** + * @param networks - Array of chain configurations to filter. + * @param options - Filter options (matched against config.*). + * @returns Filtered array of chain configurations. + */ +export function filterNetworks( + networks: RegistryNetworkConfig[], + options: NetworkFilterOptions = {}, +): RegistryNetworkConfig[] { + return networks.filter((network) => { + const { config } = network; + for (const key of FILTER_KEYS) { + const optionValue = options[key]; + if ( + optionValue !== undefined && + config[key as keyof typeof config] !== optionValue + ) { + return false; + } + } + return true; + }); +} diff --git a/packages/config-registry-controller/src/config-registry-api-service/types.ts b/packages/config-registry-controller/src/config-registry-api-service/types.ts new file mode 100644 index 00000000000..18343ffba07 --- /dev/null +++ b/packages/config-registry-controller/src/config-registry-api-service/types.ts @@ -0,0 +1,117 @@ +import type { Infer } from '@metamask/superstruct'; +import { + array, + assert, + boolean, + number, + optional, + string, + type, +} from '@metamask/superstruct'; +import { CaipChainIdStruct, StrictHexStruct } from '@metamask/utils'; + +const AssetSchema = type({ + assetId: string(), + imageUrl: string(), + name: string(), + symbol: string(), + decimals: number(), + coingeckoCoinId: optional(string()), +}); + +const AssetsSchema = type({ + listUrl: optional(string()), + native: AssetSchema, + governance: optional(AssetSchema), +}); + +const RpcProviderSchema = type({ + url: string(), + type: string(), + networkClientId: string(), +}); + +const RpcProvidersSchema = type({ + default: RpcProviderSchema, + fallbacks: array(string()), +}); + +const BlockExplorerUrlsSchema = type({ + default: string(), + fallbacks: array(string()), +}); + +const ChainConfigSchema = type({ + isActive: boolean(), + isTestnet: boolean(), + isDefault: boolean(), + isFeatured: boolean(), + isDeprecated: boolean(), + isDeletable: boolean(), + isAutoEnabled: optional(boolean()), + priority: number(), +}); + +const NetworkContractsSchema = type({ + multicall3: optional(StrictHexStruct), +}); + +/** + * Schema for a single chain in the CAIP-2 config registry API response. + * chainId is in CAIP-2 format (e.g. "eip155:1", "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp"). + */ +export const RegistryNetworkConfigSchema = type({ + chainId: CaipChainIdStruct, + name: string(), + imageUrl: string(), + coingeckoPlatformId: string(), + geckoTerminalPlatformId: optional(string()), + assets: AssetsSchema, + rpcProviders: RpcProvidersSchema, + blockExplorerUrls: BlockExplorerUrlsSchema, + config: ChainConfigSchema, + contracts: optional(NetworkContractsSchema), +}); + +/** + * Top-level API response shape. Uses `data.chains` (CAIP-2) and `data.version`. + */ +export const RegistryConfigApiResponseSchema = type({ + data: type({ + version: string(), + timestamp: number(), + chains: array(RegistryNetworkConfigSchema), + }), +}); + +export type RegistryNetworkConfig = Infer; + +export type RegistryConfigApiResponse = Infer< + typeof RegistryConfigApiResponseSchema +>; + +export function validateRegistryConfigApiResponse( + data: unknown, +): asserts data is RegistryConfigApiResponse { + assert(data, RegistryConfigApiResponseSchema); +} + +export type FetchConfigOptions = { + etag?: string; +}; + +export type FetchConfigResult = + | { + modified: false; + etag?: string; + /** + * Cached data from the service when available (e.g. after a previous + * successful fetch). Omitted when the service has no cache yet. + */ + data?: RegistryConfigApiResponse; + } + | { + modified: true; + data: RegistryConfigApiResponse; + etag?: string; + }; diff --git a/packages/config-registry-controller/src/index.ts b/packages/config-registry-controller/src/index.ts new file mode 100644 index 00000000000..4e9cd1096fa --- /dev/null +++ b/packages/config-registry-controller/src/index.ts @@ -0,0 +1,45 @@ +export type { + ConfigRegistryControllerState, + ConfigRegistryControllerOptions, + ConfigRegistryControllerActions, + ConfigRegistryControllerGetStateAction, + ConfigRegistryControllerStateChangedEvent, + ConfigRegistryControllerEvents, + ConfigRegistryControllerMessenger, +} from './ConfigRegistryController.js'; +export type { + ConfigRegistryControllerStartPollingAction, + ConfigRegistryControllerStopPollingAction, + ConfigRegistryControllerGetNetworkConfigByCaip2ChainIdAction, +} from './ConfigRegistryController-method-action-types.js'; +export { + ConfigRegistryController, + DEFAULT_POLLING_INTERVAL, +} from './ConfigRegistryController.js'; +export { + selectFeaturedNetworks, + selectNetworks, + selectEvmAutoEnabledNetworksChainIds, +} from './selectors.js'; +export type { + FetchConfigOptions, + FetchConfigResult, + RegistryNetworkConfig, + RegistryConfigApiResponse, +} from './config-registry-api-service/types.js'; +export type { + ConfigRegistryApiServiceOptions, + ConfigRegistryApiServiceActions, + ConfigRegistryApiServiceEvents, + ConfigRegistryApiServiceMessenger, +} from './config-registry-api-service/config-registry-api-service.js'; +export type { + ConfigRegistryApiServiceFetchConfigAction, + ConfigRegistryApiServiceMethodActions, +} from './config-registry-api-service/config-registry-api-service-method-action-types.js'; +export type { NetworkFilterOptions } from './config-registry-api-service/filters.js'; +export { + ConfigRegistryApiService, + ConfigRegistryApiEnv, +} from './config-registry-api-service/config-registry-api-service.js'; +export { filterNetworks } from './config-registry-api-service/filters.js'; diff --git a/packages/config-registry-controller/src/selectors.test.ts b/packages/config-registry-controller/src/selectors.test.ts new file mode 100644 index 00000000000..536d6412411 --- /dev/null +++ b/packages/config-registry-controller/src/selectors.test.ts @@ -0,0 +1,182 @@ +import { createMockNetworkConfig } from '../tests/helpers.js'; +import { ConfigRegistryControllerState } from './ConfigRegistryController.js'; +import { + selectEvmAutoEnabledNetworksChainIds, + selectFeaturedNetworks, + selectNetworks, +} from './selectors.js'; + +describe('selectors', () => { + describe('selectNetworks', () => { + it('returns all networks from state', () => { + const networks = { + 'eip155:1': createMockNetworkConfig({ chainId: 'eip155:1' }), + 'eip155:137': createMockNetworkConfig({ + chainId: 'eip155:137', + name: 'Polygon', + }), + }; + const state = { + configs: { networks }, + version: '1.0.0', + lastFetched: Date.now(), + etag: null, + }; + + expect(selectNetworks(state)).toBe(networks); + expect(selectNetworks(state)).toStrictEqual(networks); + }); + }); + + describe('selectFeaturedNetworks', () => { + it('returns only featured, active, non-testnet networks', () => { + const networks = { + 'eip155:1': createMockNetworkConfig({ + chainId: 'eip155:1', + config: { isFeatured: true, isActive: true, isTestnet: false }, + }), + 'eip155:5': createMockNetworkConfig({ + chainId: 'eip155:5', + name: 'Goerli', + config: { isFeatured: true, isActive: true, isTestnet: true }, + }), + 'eip155:10': createMockNetworkConfig({ + chainId: 'eip155:10', + name: 'Optimism', + config: { isFeatured: false, isActive: true, isTestnet: false }, + }), + 'eip155:137': createMockNetworkConfig({ + chainId: 'eip155:137', + name: 'Polygon', + config: { isFeatured: true, isActive: false, isTestnet: false }, + }), + }; + const state = { + configs: { networks }, + version: '1.0.0', + lastFetched: Date.now(), + etag: null, + }; + + const featured = selectFeaturedNetworks(state); + expect(Object.keys(featured)).toHaveLength(1); + expect(featured['eip155:1']).toBeDefined(); + expect(featured['eip155:5']).toBeUndefined(); + expect(featured['eip155:10']).toBeUndefined(); + expect(featured['eip155:137']).toBeUndefined(); + }); + + it('returns empty object when no networks match', () => { + const networks = { + 'eip155:5': createMockNetworkConfig({ + chainId: 'eip155:5', + config: { isTestnet: true }, + }), + }; + const state = { + configs: { networks }, + version: '1.0.0', + lastFetched: Date.now(), + etag: null, + }; + + const featured = selectFeaturedNetworks(state); + expect(Object.keys(featured)).toHaveLength(0); + }); + }); + + describe('selectEvmAutoEnabledNetworksChainIds', () => { + it('returns the list of CAIP-2 chain IDs for auto-enabled EVM networks', () => { + const state: ConfigRegistryControllerState = { + configs: { + networks: { + 'eip155:1': createMockNetworkConfig({ + chainId: 'eip155:1', + config: { + isAutoEnabled: true, + isActive: true, + isDeprecated: false, + }, + }), + 'eip155:3': createMockNetworkConfig({ + chainId: 'eip155:3', + config: { + isAutoEnabled: false, + isActive: true, + isDeprecated: false, + }, + }), + 'eip155:4': createMockNetworkConfig({ + chainId: 'eip155:4', + config: { + isAutoEnabled: true, + isActive: false, + isDeprecated: false, + }, + }), + 'eip155:5': createMockNetworkConfig({ + chainId: 'eip155:5', + config: { + isAutoEnabled: true, + isActive: true, + isDeprecated: true, + }, + }), + 'eip155:6': createMockNetworkConfig({ + chainId: 'eip155:6', + config: { + isAutoEnabled: true, + isActive: true, + isDeprecated: false, + }, + }), + 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp': createMockNetworkConfig({ + chainId: 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp', + config: { + isAutoEnabled: true, + isActive: true, + isDeprecated: false, + }, + }), + }, + }, + version: '1.0.0', + lastFetched: Date.now(), + etag: null, + }; + + const result = selectEvmAutoEnabledNetworksChainIds(state); + expect(result).toStrictEqual(['eip155:1', 'eip155:6']); + }); + + it('returns the same array reference when the chain IDs have not changed', () => { + const networks = { + 'eip155:1': createMockNetworkConfig({ + chainId: 'eip155:1', + config: { isAutoEnabled: true, isActive: true, isDeprecated: false }, + }), + }; + const state: ConfigRegistryControllerState = { + configs: { networks }, + version: '1.0.0', + lastFetched: 1, + etag: null, + }; + + const first = selectEvmAutoEnabledNetworksChainIds(state); + // Unrelated state change, same `networks` object. + const second = selectEvmAutoEnabledNetworksChainIds({ + ...state, + lastFetched: 2, + }); + // New `networks` object with the same auto-enabled chain IDs. + const third = selectEvmAutoEnabledNetworksChainIds({ + ...state, + configs: { networks: { ...networks } }, + }); + + expect(second).toBe(first); + expect(third).toBe(first); + }); + }); +}); diff --git a/packages/config-registry-controller/src/selectors.ts b/packages/config-registry-controller/src/selectors.ts new file mode 100644 index 00000000000..030fc10b875 --- /dev/null +++ b/packages/config-registry-controller/src/selectors.ts @@ -0,0 +1,69 @@ +import { CaipChainId, KnownCaipNamespace } from '@metamask/utils'; +import { createSelector } from 'reselect'; + +import { filterNetworks } from './config-registry-api-service/filters.js'; +import type { RegistryNetworkConfig } from './config-registry-api-service/types.js'; +import type { ConfigRegistryControllerState } from './ConfigRegistryController.js'; + +/** + * Base selector to get all networks from the controller state. + * + * @param state - The ConfigRegistryController state + * @returns All network configurations keyed by chain ID + */ +export const selectNetworks = ( + state: ConfigRegistryControllerState, +): Record => state.configs.networks; + +/** + * Selector to get featured, active, non-testnet networks. + * Use this for the default network list (e.g. main network picker). + * + * @param state - The ConfigRegistryController state + * @returns Filtered network configurations keyed by chain ID + */ +export const selectFeaturedNetworks = createSelector( + selectNetworks, + (networks): Record => { + const networkArray = Object.values(networks); + const filtered = filterNetworks(networkArray, { + isFeatured: true, + isActive: true, + isTestnet: false, + }); + const result: Record = {}; + filtered.forEach((config) => { + result[config.chainId] = config; + }); + return result; + }, +); + +/** + * Returns the list of CAIP-2 chain IDs for networks that are auto-enabled in the + * config registry. + * + * @param state - The config registry controller state. + * @returns The list of CAIP-2 chain IDs for auto-enabled networks. + */ +export const selectEvmAutoEnabledNetworksChainIds = createSelector( + selectNetworks, + (networks): CaipChainId[] => + Object.values(networks) + .filter( + ({ chainId, config }) => + chainId.startsWith(KnownCaipNamespace.Eip155) && + config.isAutoEnabled && + config.isActive && + !config.isDeprecated, + ) + .map((config) => config.chainId), + { + // Messenger selector subscriptions only skip work when the result is + // referentially equal, so keep the previous array when the IDs are the same. + memoizeOptions: { + resultEqualityCheck: (a: CaipChainId[], b: CaipChainId[]) => + a.length === b.length && a.every((chainId, i) => chainId === b[i]), + }, + }, +); diff --git a/packages/config-registry-controller/tests/helpers.ts b/packages/config-registry-controller/tests/helpers.ts new file mode 100644 index 00000000000..38e9544d46c --- /dev/null +++ b/packages/config-registry-controller/tests/helpers.ts @@ -0,0 +1,70 @@ +import type { RegistryNetworkConfig } from '../src/config-registry-api-service/types.js'; + +/** + * Creates a mock RegistryNetworkConfig (CAIP-2 chain) for testing. + * + * @param overrides - Optional properties to override in the default config. + * @returns A mock RegistryNetworkConfig object. + */ +const DEFAULT_CHAIN_CONFIG = { + isActive: true, + isTestnet: false, + isDefault: true, + isFeatured: true, + isDeprecated: false, + isDeletable: false, + priority: 0, +} as const; + +/** Overrides for createMockNetworkConfig; config can be partial. */ +export type MockNetworkConfigOverrides = Partial< + Omit +> & { + config?: Partial; +}; + +export function createMockNetworkConfig( + overrides: MockNetworkConfigOverrides = {}, +): RegistryNetworkConfig { + const base: RegistryNetworkConfig = { + chainId: 'eip155:1', + name: 'Ethereum Mainnet', + imageUrl: + 'https://token.api.cx.metamask.io/assets/networkLogos/ethereum.svg', + coingeckoPlatformId: 'ethereum', + geckoTerminalPlatformId: 'eth', + assets: { + listUrl: 'https://tokens.api.cx.metamask.io/v3/chains/eip155:1/assets', + native: { + assetId: 'eip155:1/slip44:60', + imageUrl: + 'https://static.cx.metamask.io/api/v2/tokenIcons/assets/eip155/1/slip44/60.png', + name: 'Ether', + symbol: 'ETH', + decimals: 18, + coingeckoCoinId: 'ethereum', + }, + }, + rpcProviders: { + default: { + url: 'https://mainnet.infura.io/v3/{infuraProjectId}', + type: 'infura', + networkClientId: 'mainnet', + }, + fallbacks: [], + }, + blockExplorerUrls: { + default: 'https://etherscan.io', + fallbacks: [], + }, + config: { ...DEFAULT_CHAIN_CONFIG }, + }; + const { config: configOverride, ...rest } = overrides; + return { + ...base, + ...rest, + config: configOverride + ? { ...DEFAULT_CHAIN_CONFIG, ...configOverride } + : base.config, + }; +} diff --git a/packages/config-registry-controller/tsconfig.build.json b/packages/config-registry-controller/tsconfig.build.json new file mode 100644 index 00000000000..6edbd4404a9 --- /dev/null +++ b/packages/config-registry-controller/tsconfig.build.json @@ -0,0 +1,18 @@ +{ + "extends": "../../tsconfig.packages.build.json", + "compilerOptions": { + "baseUrl": "./", + "outDir": "./dist", + "rootDir": "./src" + }, + "exclude": ["**/*.test.ts"], + "references": [ + { "path": "../base-controller/tsconfig.build.json" }, + { "path": "../controller-utils/tsconfig.build.json" }, + { "path": "../keyring-controller/tsconfig.build.json" }, + { "path": "../messenger/tsconfig.build.json" }, + { "path": "../polling-controller/tsconfig.build.json" }, + { "path": "../remote-feature-flag-controller/tsconfig.build.json" } + ], + "include": ["../../types", "./src"] +} diff --git a/packages/config-registry-controller/tsconfig.json b/packages/config-registry-controller/tsconfig.json new file mode 100644 index 00000000000..c4258e1ff1f --- /dev/null +++ b/packages/config-registry-controller/tsconfig.json @@ -0,0 +1,15 @@ +{ + "extends": "../../tsconfig.packages.json", + "compilerOptions": { + "baseUrl": "./" + }, + "references": [ + { "path": "../base-controller" }, + { "path": "../controller-utils" }, + { "path": "../keyring-controller" }, + { "path": "../messenger" }, + { "path": "../polling-controller" }, + { "path": "../remote-feature-flag-controller" } + ], + "include": ["../../types", "./src", "./tests"] +} diff --git a/packages/config-registry-controller/typedoc.json b/packages/config-registry-controller/typedoc.json new file mode 100644 index 00000000000..c9da015dbf8 --- /dev/null +++ b/packages/config-registry-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/connectivity-controller/CHANGELOG.md b/packages/connectivity-controller/CHANGELOG.md new file mode 100644 index 00000000000..b35813866c8 --- /dev/null +++ b/packages/connectivity-controller/CHANGELOG.md @@ -0,0 +1,50 @@ +# Changelog + +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [Unreleased] + +## [0.3.0] + +### Added + +- Add `connectivityControllerSelectors` with `selectConnectivityStatus` and `selectIsOffline` selectors ([#7701](https://github.com/MetaMask/core/pull/7701)) + - `selectConnectivityStatus` returns the current connectivity status from the controller state + - `selectIsOffline` is a memoized selector that returns `true` when the device is offline + +### Changed + +- Bump `@metamask/messenger` from `^1.0.0` to `^2.0.0` ([#8364](https://github.com/MetaMask/core/pull/8364), [#8373](https://github.com/MetaMask/core/pull/8373), [#8632](https://github.com/MetaMask/core/pull/8632), [#9392](https://github.com/MetaMask/core/pull/9392)) +- Bump `@metamask/base-controller` from `^9.0.1` to `^9.1.0` ([#8457](https://github.com/MetaMask/core/pull/8457)) + +## [0.2.0] + +### Added + +- Add `init` method to asynchronously fetch and set the initial connectivity status from the adapter ([#7679](https://github.com/MetaMask/core/pull/7679)) + - The controller now initializes with a default state (online) and requires calling `init()` to fetch the actual status + - This method can be called through the messenger action `ConnectivityController:init` +- Add `setConnectivityStatus` method to manually set connectivity status ([#7676](https://github.com/MetaMask/core/pull/7676)) + - The method is exposed as a messenger action `ConnectivityController:setConnectivityStatus` + +### Changed + +- **BREAKING:** `ConnectivityAdapter.getStatus()` must now return a `Promise` (async) ([#7679](https://github.com/MetaMask/core/pull/7679)) + - Adapter implementations must update their `getStatus()` method to return a Promise + - This change enables asynchronous initialization of the controller via the `init()` method +- Bump `@metamask/base-controller` from `^9.0.0` to `^9.0.1` ([#8317](https://github.com/MetaMask/core/pull/8317)) +- Bump `@metamask/messenger` from `^0.3.0` to `^1.0.0` ([#8317](https://github.com/MetaMask/core/pull/8317)) + +## [0.1.0] + +### Added + +- Initial release ([#7623](https://github.com/MetaMask/core/pull/7623)) + +[Unreleased]: https://github.com/MetaMask/core/compare/@metamask/connectivity-controller@0.3.0...HEAD +[0.3.0]: https://github.com/MetaMask/core/compare/@metamask/connectivity-controller@0.2.0...@metamask/connectivity-controller@0.3.0 +[0.2.0]: https://github.com/MetaMask/core/compare/@metamask/connectivity-controller@0.1.0...@metamask/connectivity-controller@0.2.0 +[0.1.0]: https://github.com/MetaMask/core/releases/tag/@metamask/connectivity-controller@0.1.0 diff --git a/packages/connectivity-controller/LICENSE b/packages/connectivity-controller/LICENSE new file mode 100644 index 00000000000..fe29e78e0fe --- /dev/null +++ b/packages/connectivity-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/connectivity-controller/README.md b/packages/connectivity-controller/README.md new file mode 100644 index 00000000000..fb6eabf17b6 --- /dev/null +++ b/packages/connectivity-controller/README.md @@ -0,0 +1,15 @@ +# `@metamask/connectivity-controller` + +ConnectivityController stores the device's internet connectivity status. + +## Installation + +`yarn add @metamask/connectivity-controller` + +or + +`npm install @metamask/connectivity-controller` + +## Contributing + +This package is part of a monorepo. Instructions for contributing can be found in the [monorepo README](https://github.com/MetaMask/core#readme). diff --git a/packages/connectivity-controller/jest.config.js b/packages/connectivity-controller/jest.config.js new file mode 100644 index 00000000000..ca084133399 --- /dev/null +++ b/packages/connectivity-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/connectivity-controller/package.json b/packages/connectivity-controller/package.json new file mode 100644 index 00000000000..eff0dab23f5 --- /dev/null +++ b/packages/connectivity-controller/package.json @@ -0,0 +1,77 @@ +{ + "name": "@metamask/connectivity-controller", + "version": "0.3.0", + "description": "ConnectivityController stores the device's internet connectivity status", + "keywords": [ + "Ethereum", + "MetaMask" + ], + "homepage": "https://github.com/MetaMask/core/tree/main/packages/connectivity-controller#readme", + "bugs": { + "url": "https://github.com/MetaMask/core/issues" + }, + "license": "MIT", + "repository": { + "type": "git", + "url": "https://github.com/MetaMask/core.git" + }, + "files": [ + "dist/" + ], + "sideEffects": false, + "main": "./dist/index.cjs", + "types": "./dist/index.d.cts", + "exports": { + ".": { + "import": { + "types": "./dist/index.d.mts", + "default": "./dist/index.mjs" + }, + "require": { + "types": "./dist/index.d.cts", + "default": "./dist/index.cjs" + } + }, + "./package.json": "./package.json" + }, + "publishConfig": { + "access": "public", + "registry": "https://registry.npmjs.org/" + }, + "scripts": { + "build": "ts-bridge --project tsconfig.build.json --verbose --clean --no-references", + "build:all": "ts-bridge --project tsconfig.build.json --verbose --clean", + "build:docs": "typedoc", + "changelog:update": "../../scripts/update-changelog.sh @metamask/connectivity-controller", + "changelog:validate": "../../scripts/validate-changelog.sh @metamask/connectivity-controller", + "lint:tsconfigs": "tsx ../../scripts/lint-tsconfigs/lint-tsconfigs.mts", + "lint:tsconfigs:fix": "tsx ../../scripts/lint-tsconfigs/lint-tsconfigs.mts --fix", + "messenger-action-types:check": "tsx ../../packages/messenger-cli/src/cli.ts --formatter oxfmt --esm --check", + "messenger-action-types:generate": "tsx ../../packages/messenger-cli/src/cli.ts --formatter oxfmt --esm --generate", + "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.1.0", + "@metamask/messenger": "^2.0.0", + "reselect": "^5.1.1" + }, + "devDependencies": { + "@metamask/auto-changelog": "^6.1.0", + "@ts-bridge/cli": "^0.6.4", + "@types/jest": "^30.0.0", + "deepmerge": "^4.2.2", + "jest": "^30.4.2", + "ts-jest": "^29.4.11", + "tsx": "^4.20.5", + "typedoc": "^0.25.13", + "typedoc-plugin-missing-exports": "^2.0.0", + "typescript": "~5.3.3" + }, + "engines": { + "node": "^18.18 || >=20" + } +} diff --git a/packages/connectivity-controller/src/ConnectivityController-method-action-types.ts b/packages/connectivity-controller/src/ConnectivityController-method-action-types.ts new file mode 100644 index 00000000000..ddc961f0a57 --- /dev/null +++ b/packages/connectivity-controller/src/ConnectivityController-method-action-types.ts @@ -0,0 +1,31 @@ +/** + * This file is auto generated. + * Do not edit manually. + */ + +import type { ConnectivityController } from './ConnectivityController.js'; + +/** + * Initializes the controller by fetching the initial connectivity status. + */ +export type ConnectivityControllerInitAction = { + type: `ConnectivityController:init`; + handler: ConnectivityController['init']; +}; + +/** + * Sets the connectivity status. + * + * @param status - The connectivity status to set. + */ +export type ConnectivityControllerSetConnectivityStatusAction = { + type: `ConnectivityController:setConnectivityStatus`; + handler: ConnectivityController['setConnectivityStatus']; +}; + +/** + * Union of all ConnectivityController action types. + */ +export type ConnectivityControllerMethodActions = + | ConnectivityControllerInitAction + | ConnectivityControllerSetConnectivityStatusAction; diff --git a/packages/connectivity-controller/src/ConnectivityController.test.ts b/packages/connectivity-controller/src/ConnectivityController.test.ts new file mode 100644 index 00000000000..0c5acee09c9 --- /dev/null +++ b/packages/connectivity-controller/src/ConnectivityController.test.ts @@ -0,0 +1,392 @@ +import { deriveStateFromMetadata } from '@metamask/base-controller'; +import { Messenger, MOCK_ANY_NAMESPACE } from '@metamask/messenger'; +import type { + MockAnyNamespace, + MessengerActions, + MessengerEvents, +} from '@metamask/messenger'; + +import type { ConnectivityControllerMessenger } from './ConnectivityController.js'; +import { ConnectivityController } from './ConnectivityController.js'; +import { CONNECTIVITY_STATUSES } from './types.js'; +import type { ConnectivityAdapter, ConnectivityStatus } from './types.js'; + +describe('ConnectivityController', () => { + describe('constructor', () => { + it('initializes with default state (online)', async () => { + const mockAdapter: ConnectivityAdapter = { + getStatus: jest.fn().mockResolvedValue(CONNECTIVITY_STATUSES.Online), + onConnectivityChange: jest.fn(), + destroy: jest.fn(), + }; + + await withController( + { options: { connectivityAdapter: mockAdapter } }, + ({ controller }) => { + expect(controller.state.connectivityStatus).toBe( + CONNECTIVITY_STATUSES.Online, + ); + expect(mockAdapter.getStatus).not.toHaveBeenCalled(); + }, + ); + }); + + it('subscribes to connectivity changes from adapter', async () => { + const mockAdapter: ConnectivityAdapter = { + getStatus: jest.fn().mockResolvedValue(CONNECTIVITY_STATUSES.Online), + onConnectivityChange: jest.fn(), + destroy: jest.fn(), + }; + + await withController( + { options: { connectivityAdapter: mockAdapter } }, + () => { + expect(mockAdapter.onConnectivityChange).toHaveBeenCalledTimes(1); + expect(mockAdapter.onConnectivityChange).toHaveBeenCalledWith( + expect.any(Function), + ); + }, + ); + }); + }); + + describe('metadata', () => { + it('includes expected state in debug snapshots', async () => { + await withController(({ controller }) => { + expect( + deriveStateFromMetadata( + controller.state, + controller.metadata, + 'includeInDebugSnapshot', + ), + ).toMatchInlineSnapshot(` + { + "connectivityStatus": "online", + } + `); + }); + }); + + it('includes expected state in state logs', async () => { + await withController(({ controller }) => { + expect( + deriveStateFromMetadata( + controller.state, + controller.metadata, + 'includeInStateLogs', + ), + ).toMatchInlineSnapshot(` + { + "connectivityStatus": "online", + } + `); + }); + }); + + it('persists expected state', async () => { + await withController(({ controller }) => { + expect( + deriveStateFromMetadata( + controller.state, + controller.metadata, + 'persist', + ), + ).toMatchInlineSnapshot(`{}`); + }); + }); + + it('exposes expected state to UI', async () => { + await withController(({ controller }) => { + expect( + deriveStateFromMetadata( + controller.state, + controller.metadata, + 'usedInUi', + ), + ).toMatchInlineSnapshot(` + { + "connectivityStatus": "online", + } + `); + }); + }); + }); + + describe('init', () => { + it('fetches initial status from adapter and updates state', async () => { + const mockAdapter: ConnectivityAdapter = { + getStatus: jest.fn().mockResolvedValue(CONNECTIVITY_STATUSES.Offline), + onConnectivityChange: jest.fn(), + destroy: jest.fn(), + }; + + await withController( + { options: { connectivityAdapter: mockAdapter } }, + async ({ rootMessenger, controller }) => { + expect(controller.state.connectivityStatus).toBe( + CONNECTIVITY_STATUSES.Online, + ); + + await rootMessenger.call('ConnectivityController:init'); + + expect(mockAdapter.getStatus).toHaveBeenCalledTimes(1); + expect(controller.state.connectivityStatus).toBe( + CONNECTIVITY_STATUSES.Offline, + ); + }, + ); + }); + + it('can be called multiple times to refresh status', async () => { + const mockAdapter: ConnectivityAdapter = { + getStatus: jest + .fn() + .mockResolvedValueOnce(CONNECTIVITY_STATUSES.Online) + .mockResolvedValueOnce(CONNECTIVITY_STATUSES.Offline), + onConnectivityChange: jest.fn(), + destroy: jest.fn(), + }; + + await withController( + { options: { connectivityAdapter: mockAdapter } }, + async ({ rootMessenger, controller }) => { + await rootMessenger.call('ConnectivityController:init'); + expect(controller.state.connectivityStatus).toBe( + CONNECTIVITY_STATUSES.Online, + ); + + await rootMessenger.call('ConnectivityController:init'); + expect(controller.state.connectivityStatus).toBe( + CONNECTIVITY_STATUSES.Offline, + ); + + expect(mockAdapter.getStatus).toHaveBeenCalledTimes(2); + }, + ); + }); + }); + + describe('when connectivity changes via the adapter', () => { + it('updates state when service reports offline', async () => { + let onConnectivityChangeCallback: ( + connectivityStatus: ConnectivityStatus, + ) => void; + const mockAdapter: ConnectivityAdapter = { + getStatus: jest.fn().mockResolvedValue(CONNECTIVITY_STATUSES.Online), + onConnectivityChange( + callback: (connectivityStatus: ConnectivityStatus) => void, + ) { + onConnectivityChangeCallback = callback; + }, + destroy: jest.fn(), + }; + await withController( + { options: { connectivityAdapter: mockAdapter } }, + ({ controller }) => { + expect(controller.state.connectivityStatus).toBe( + CONNECTIVITY_STATUSES.Online, + ); + // Simulate service reporting offline + onConnectivityChangeCallback(CONNECTIVITY_STATUSES.Offline); + expect(controller.state.connectivityStatus).toBe( + CONNECTIVITY_STATUSES.Offline, + ); + }, + ); + }); + + it('updates state when service reports online', async () => { + let onConnectivityChangeCallback: ( + connectivityStatus: ConnectivityStatus, + ) => void; + const mockAdapter: ConnectivityAdapter = { + getStatus: jest.fn().mockResolvedValue(CONNECTIVITY_STATUSES.Offline), + onConnectivityChange( + callback: (connectivityStatus: ConnectivityStatus) => void, + ) { + onConnectivityChangeCallback = callback; + }, + destroy: jest.fn(), + }; + await withController( + { options: { connectivityAdapter: mockAdapter } }, + ({ controller }) => { + expect(controller.state.connectivityStatus).toBe( + CONNECTIVITY_STATUSES.Online, + ); + // Simulate service reporting online + onConnectivityChangeCallback(CONNECTIVITY_STATUSES.Online); + expect(controller.state.connectivityStatus).toBe( + CONNECTIVITY_STATUSES.Online, + ); + }, + ); + }); + }); + + describe('setConnectivityStatus', () => { + it('updates state when called directly', async () => { + await withController(({ controller }) => { + expect(controller.state.connectivityStatus).toBe( + CONNECTIVITY_STATUSES.Online, + ); + + controller.setConnectivityStatus(CONNECTIVITY_STATUSES.Offline); + + expect(controller.state.connectivityStatus).toBe( + CONNECTIVITY_STATUSES.Offline, + ); + }); + }); + + it('updates state when called via messenger action', async () => { + await withController(({ rootMessenger, controller }) => { + expect(controller.state.connectivityStatus).toBe( + CONNECTIVITY_STATUSES.Online, + ); + + rootMessenger.call( + 'ConnectivityController:setConnectivityStatus', + CONNECTIVITY_STATUSES.Offline, + ); + + expect(controller.state.connectivityStatus).toBe( + CONNECTIVITY_STATUSES.Offline, + ); + }); + }); + + it('can change status from offline to online via direct call', async () => { + await withController(({ controller }) => { + // Start with default state (online) + expect(controller.state.connectivityStatus).toBe( + CONNECTIVITY_STATUSES.Online, + ); + + // Change to offline + controller.setConnectivityStatus(CONNECTIVITY_STATUSES.Offline); + expect(controller.state.connectivityStatus).toBe( + CONNECTIVITY_STATUSES.Offline, + ); + + // Change back to online + controller.setConnectivityStatus(CONNECTIVITY_STATUSES.Online); + expect(controller.state.connectivityStatus).toBe( + CONNECTIVITY_STATUSES.Online, + ); + }); + }); + + it('can change status from offline to online via messenger action', async () => { + await withController(({ rootMessenger, controller }) => { + // Start with default state (online) + expect(controller.state.connectivityStatus).toBe( + CONNECTIVITY_STATUSES.Online, + ); + + // Change to offline + rootMessenger.call( + 'ConnectivityController:setConnectivityStatus', + CONNECTIVITY_STATUSES.Offline, + ); + expect(controller.state.connectivityStatus).toBe( + CONNECTIVITY_STATUSES.Offline, + ); + + // Change back to online + rootMessenger.call( + 'ConnectivityController:setConnectivityStatus', + CONNECTIVITY_STATUSES.Online, + ); + expect(controller.state.connectivityStatus).toBe( + CONNECTIVITY_STATUSES.Online, + ); + }); + }); + }); +}); + +/** + * 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: ConnectivityController; + rootMessenger: RootMessenger; + controllerMessenger: ConnectivityControllerMessenger; +}) => Promise | ReturnValue; + +/** + * The options that `withController` takes. + */ +type WithControllerOptions = { + options: Partial[0]>; +}; + +/** + * Constructs the messenger populated with all external actions and events + * required by the controller under test. + * + * @returns The root messenger. + */ +function getRootMessenger(): RootMessenger { + return new Messenger({ namespace: MOCK_ANY_NAMESPACE }); +} + +/** + * Constructs the messenger for the controller under test. + * + * @param rootMessenger - The root messenger, with all external actions and + * events required by the controller's messenger. + * @returns The controller-specific messenger. + */ +function getMessenger( + rootMessenger: RootMessenger, +): ConnectivityControllerMessenger { + return new Messenger({ + namespace: 'ConnectivityController', + parent: rootMessenger, + }); +} + +/** + * Wrap tests for the controller under test by ensuring that the controller is + * created ahead of time and then safely destroyed afterward as needed. + * + * @param args - Either a function, or an options bag + a function. The options + * bag contains arguments for the controller constructor. All constructor + * arguments are optional and will be filled in with defaults in as needed + * (including `messenger` and `connectivityAdapter`). The function is called + * with the instantiated 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 controllerMessenger = getMessenger(rootMessenger); + const defaultAdapter: ConnectivityAdapter = { + getStatus: jest.fn().mockResolvedValue(CONNECTIVITY_STATUSES.Online), + onConnectivityChange: jest.fn(), + destroy: jest.fn(), + }; + const controller = new ConnectivityController({ + messenger: controllerMessenger, + connectivityAdapter: defaultAdapter, + ...options, + }); + return await testFunction({ controller, rootMessenger, controllerMessenger }); +} diff --git a/packages/connectivity-controller/src/ConnectivityController.ts b/packages/connectivity-controller/src/ConnectivityController.ts new file mode 100644 index 00000000000..dd55086ed43 --- /dev/null +++ b/packages/connectivity-controller/src/ConnectivityController.ts @@ -0,0 +1,193 @@ +import type { + ControllerGetStateAction, + ControllerStateChangeEvent, + StateMetadata, +} from '@metamask/base-controller'; +import { BaseController } from '@metamask/base-controller'; +import type { Messenger } from '@metamask/messenger'; + +import { ConnectivityControllerMethodActions } from './ConnectivityController-method-action-types.js'; +import { CONNECTIVITY_STATUSES } from './types.js'; +import type { ConnectivityAdapter, ConnectivityStatus } from './types.js'; + +/** + * The name of the {@link ConnectivityController}, 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 = 'ConnectivityController'; + +/** + * State for the {@link ConnectivityController}. + */ +export type ConnectivityControllerState = { + /** + * The current device connectivity status. + * Named with 'connectivity' prefix to avoid conflicts when state is flattened in Redux. + */ + connectivityStatus: ConnectivityStatus; +}; + +/** + * The metadata for each property in {@link ConnectivityControllerState}. + */ +const connectivityControllerMetadata = { + connectivityStatus: { + persist: false, + includeInDebugSnapshot: true, + includeInStateLogs: true, + usedInUi: true, + }, +} satisfies StateMetadata; + +/** + * Constructs the default {@link ConnectivityController} 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 ConnectivityController} state. + */ +export function getDefaultConnectivityControllerState(): ConnectivityControllerState { + return { + connectivityStatus: CONNECTIVITY_STATUSES.Online, + }; +} + +const MESSENGER_EXPOSED_METHODS = ['init', 'setConnectivityStatus'] as const; + +/** + * Retrieves the state of the {@link ConnectivityController}. + */ +export type ConnectivityControllerGetStateAction = ControllerGetStateAction< + typeof controllerName, + ConnectivityControllerState +>; + +/** + * Actions that {@link ConnectivityControllerMessenger} exposes to other consumers. + */ +export type ConnectivityControllerActions = + | ConnectivityControllerGetStateAction + | ConnectivityControllerMethodActions; + +/** + * Actions from other messengers that {@link ConnectivityControllerMessenger} calls. + */ +type AllowedActions = never; + +/** + * Published when the state of {@link ConnectivityController} changes. + */ +export type ConnectivityControllerStateChangeEvent = ControllerStateChangeEvent< + typeof controllerName, + ConnectivityControllerState +>; + +/** + * Events that {@link ConnectivityControllerMessenger} exposes to other consumers. + */ +export type ConnectivityControllerEvents = + ConnectivityControllerStateChangeEvent; + +/** + * Events from other messengers that {@link ConnectivityControllerMessenger} subscribes + * to. + */ +type AllowedEvents = never; + +/** + * The messenger restricted to actions and events accessed by + * {@link ConnectivityController}. + */ +export type ConnectivityControllerMessenger = Messenger< + typeof controllerName, + ConnectivityControllerActions | AllowedActions, + ConnectivityControllerEvents | AllowedEvents +>; + +/** + * Options for constructing the {@link ConnectivityController}. + */ +export type ConnectivityControllerOptions = { + /** + * The messenger for inter-controller communication. + */ + messenger: ConnectivityControllerMessenger; + + /** + * Connectivity adapter for platform-specific detection. + */ + connectivityAdapter: ConnectivityAdapter; +}; + +/** + * ConnectivityController stores the device's internet connectivity status. + * + * This controller is platform-agnostic and designed to be used across different + * MetaMask clients (extension, mobile). It requires a `ConnectivityAdapter` to + * be injected, which provides platform-specific connectivity detection. + * + * The controller subscribes to the adapter's `onConnectivityChange` callback + * and updates its state accordingly. All connectivity updates flow through + * the adapter, ensuring a single source of truth. + * + * This controller provides a centralized state for connectivity status, + * enabling the UI and other controllers to adapt when the user goes offline. + */ +export class ConnectivityController extends BaseController< + typeof controllerName, + ConnectivityControllerState, + ConnectivityControllerMessenger +> { + readonly #connectivityAdapter: ConnectivityAdapter; + + /** + * Constructs a new {@link ConnectivityController}. + * + * @param args - The arguments to this controller. + * @param args.messenger - The messenger suited for this controller. + * @param args.connectivityAdapter - The connectivity adapter to use. + */ + constructor({ + messenger, + connectivityAdapter, + }: ConnectivityControllerOptions) { + super({ + messenger, + metadata: connectivityControllerMetadata, + name: controllerName, + state: getDefaultConnectivityControllerState(), + }); + + this.#connectivityAdapter = connectivityAdapter; + + this.#connectivityAdapter.onConnectivityChange( + this.setConnectivityStatus.bind(this), + ); + + this.messenger.registerMethodActionHandlers( + this, + MESSENGER_EXPOSED_METHODS, + ); + } + + /** + * Initializes the controller by fetching the initial connectivity status. + */ + async init(): Promise { + const initialStatus = await this.#connectivityAdapter.getStatus(); + this.setConnectivityStatus(initialStatus); + } + + /** + * Sets the connectivity status. + * + * @param status - The connectivity status to set. + */ + setConnectivityStatus(status: ConnectivityStatus): void { + this.update((draftState) => { + draftState.connectivityStatus = status; + }); + } +} diff --git a/packages/connectivity-controller/src/index.ts b/packages/connectivity-controller/src/index.ts new file mode 100644 index 00000000000..3d6fdbd428c --- /dev/null +++ b/packages/connectivity-controller/src/index.ts @@ -0,0 +1,16 @@ +export type { + ConnectivityControllerState, + ConnectivityControllerGetStateAction, + ConnectivityControllerActions, + ConnectivityControllerStateChangeEvent, + ConnectivityControllerEvents, + ConnectivityControllerMessenger, +} from './ConnectivityController.js'; +export type { ConnectivityControllerSetConnectivityStatusAction } from './ConnectivityController-method-action-types.js'; +export type { ConnectivityAdapter, ConnectivityStatus } from './types.js'; +export { CONNECTIVITY_STATUSES } from './types.js'; +export { + ConnectivityController, + getDefaultConnectivityControllerState, +} from './ConnectivityController.js'; +export { connectivityControllerSelectors } from './selectors.js'; diff --git a/packages/connectivity-controller/src/selectors.test.ts b/packages/connectivity-controller/src/selectors.test.ts new file mode 100644 index 00000000000..80a363ded62 --- /dev/null +++ b/packages/connectivity-controller/src/selectors.test.ts @@ -0,0 +1,51 @@ +import type { ConnectivityControllerState } from './ConnectivityController.js'; +import { connectivityControllerSelectors } from './selectors.js'; +import { CONNECTIVITY_STATUSES } from './types.js'; + +describe('connectivityControllerSelectors', () => { + describe('selectConnectivityStatus', () => { + it('returns Online when connectivityStatus is Online', () => { + const state: ConnectivityControllerState = { + connectivityStatus: CONNECTIVITY_STATUSES.Online, + }; + + const result = + connectivityControllerSelectors.selectConnectivityStatus(state); + + expect(result).toBe(CONNECTIVITY_STATUSES.Online); + }); + + it('returns Offline when connectivityStatus is Offline', () => { + const state: ConnectivityControllerState = { + connectivityStatus: CONNECTIVITY_STATUSES.Offline, + }; + + const result = + connectivityControllerSelectors.selectConnectivityStatus(state); + + expect(result).toBe(CONNECTIVITY_STATUSES.Offline); + }); + }); + + describe('selectIsOffline', () => { + it('returns false when connectivityStatus is Online', () => { + const state: ConnectivityControllerState = { + connectivityStatus: CONNECTIVITY_STATUSES.Online, + }; + + const result = connectivityControllerSelectors.selectIsOffline(state); + + expect(result).toBe(false); + }); + + it('returns true when connectivityStatus is Offline', () => { + const state: ConnectivityControllerState = { + connectivityStatus: CONNECTIVITY_STATUSES.Offline, + }; + + const result = connectivityControllerSelectors.selectIsOffline(state); + + expect(result).toBe(true); + }); + }); +}); diff --git a/packages/connectivity-controller/src/selectors.ts b/packages/connectivity-controller/src/selectors.ts new file mode 100644 index 00000000000..d8b442e6b76 --- /dev/null +++ b/packages/connectivity-controller/src/selectors.ts @@ -0,0 +1,35 @@ +import { createSelector } from 'reselect'; + +import type { ConnectivityControllerState } from './ConnectivityController.js'; +import { CONNECTIVITY_STATUSES } from './types.js'; +import type { ConnectivityStatus } from './types.js'; + +/** + * Selects the connectivity status from the controller state. + * + * @param state - The controller state + * @returns The connectivity status + */ +const selectConnectivityStatus = ( + state: ConnectivityControllerState, +): ConnectivityStatus => state.connectivityStatus; + +/** + * Selects whether the device is offline. + * + * @param state - The controller state + * @returns Whether the device is offline + */ +const selectIsOffline = createSelector( + [selectConnectivityStatus], + (connectivityStatus) => connectivityStatus === CONNECTIVITY_STATUSES.Offline, +); + +/** + * Selectors for the ConnectivityController state. + * These can be used with Redux or directly with controller state. + */ +export const connectivityControllerSelectors = { + selectConnectivityStatus, + selectIsOffline, +}; diff --git a/packages/connectivity-controller/src/types.ts b/packages/connectivity-controller/src/types.ts new file mode 100644 index 00000000000..468dc6dd2e8 --- /dev/null +++ b/packages/connectivity-controller/src/types.ts @@ -0,0 +1,37 @@ +/** + * Connectivity status constants. + * Used to represent whether the device has internet connectivity. + */ +export const CONNECTIVITY_STATUSES = { + Online: 'online', + Offline: 'offline', +} as const; + +export type ConnectivityStatus = + (typeof CONNECTIVITY_STATUSES)[keyof typeof CONNECTIVITY_STATUSES]; + +/** + * Adapter interface for platform-specific connectivity detection. + * Each platform (extension, mobile) implements this interface using + * platform-specific APIs to detect internet connectivity. + */ +export type ConnectivityAdapter = { + /** + * Returns a promise that resolves to the current connectivity status. + * + * @returns A promise that resolves to 'online' if the device is online, 'offline' otherwise. + */ + getStatus(): Promise; + + /** + * Registers a callback to be called when connectivity status changes. + * + * @param callback - Function called with 'online' when online, 'offline' when offline. + */ + onConnectivityChange(callback: (status: ConnectivityStatus) => void): void; + + /** + * Cleans up any resources (event listeners, subscriptions). + */ + destroy(): void; +}; diff --git a/packages/connectivity-controller/tsconfig.build.json b/packages/connectivity-controller/tsconfig.build.json new file mode 100644 index 00000000000..931c4d6594b --- /dev/null +++ b/packages/connectivity-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/connectivity-controller/tsconfig.json b/packages/connectivity-controller/tsconfig.json new file mode 100644 index 00000000000..68c3ddfc2cd --- /dev/null +++ b/packages/connectivity-controller/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../../tsconfig.packages.json", + "compilerOptions": { + "baseUrl": "./" + }, + "references": [{ "path": "../base-controller" }, { "path": "../messenger" }], + "include": ["../../types", "./src"] +} diff --git a/packages/connectivity-controller/typedoc.json b/packages/connectivity-controller/typedoc.json new file mode 100644 index 00000000000..c9da015dbf8 --- /dev/null +++ b/packages/connectivity-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/controller-utils/CHANGELOG.md b/packages/controller-utils/CHANGELOG.md index 99151c0527e..487e484d820 100644 --- a/packages/controller-utils/CHANGELOG.md +++ b/packages/controller-utils/CHANGELOG.md @@ -1,4 +1,5 @@ # 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/), @@ -6,64 +7,596 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- Add optional `startTime` to `TraceRequest` to allow backdating a span's start time ([#9315](https://github.com/MetaMask/core/pull/9315)) + +### Deprecated + +- Deprecate `createServicePolicy` and related symbols ([#9418](https://github.com/MetaMask/core/pull/9418)) + - Deprecated functions: + `createServicePolicy` + - Deprecated constants: + - `DEFAULT_CIRCUIT_BREAK_DURATION` + - `DEFAULT_DEGRADED_THRESHOLD` + - `DEFAULT_MAX_CONSECUTIVE_FAILURES` + - `DEFAULT_MAX_RETRIES` + - Deprecated types: + - `CreateServicePolicyOptions` + - `ServicePolicy` + - Deprecated re-exports from `cockatiel`: + - `BrokenCircuitError` + - `CircuitState` + - `CockatielEventEmitter` + - `CockatielEvent` + - `CockatielFailureReason` + - `ConstantBackoff` + - `ExponentialBackoff` + - `handleAll` + - `handleWhen` + - These symbols will be removed in a future major version. Please use equivalent implementations from `@metamask/base-data-service` going forward. + +## [12.3.0] + +### Added + +- Allow overriding `isServiceFailure` in `createServicePolicy` ([#9123](https://github.com/MetaMask/core/pull/9123)) + +### Changed + +- Bump `@metamask/utils` from `^11.9.0` to `^11.11.0` ([#9074](https://github.com/MetaMask/core/pull/9074)) + +## [12.2.0] + +### Added + +- Add `encodeFunctionData` to improve ABI encoding speed for addresses ([#9057](https://github.com/MetaMask/core/pull/9057)) + +## [12.1.1] + +### Changed + +- Update `NetworkNickname` to match latest client overrides ([#9005](https://github.com/MetaMask/core/pull/9005)) + +## [12.1.0] + +### Added + +- Add `DEFAULT_INFURA_NETWORKS` with Infura network names to be enabled by default ([#8767](https://github.com/MetaMask/core/pull/8767)) + +## [12.0.0] + +### Changed + +- **BREAKING:** The `ServicePolicy` type's `onDegraded` event now emits `{ duration: number }` instead of `void` when the service succeeds but takes longer than the `degradedThreshold` ([#8455](https://github.com/MetaMask/core/pull/8455)) + - `void` has been removed from the event's type union. Listeners that checked for `undefined` data should now check for the `duration` property instead. + - The event still emits a `FailureReason` when retries are exhausted. +- Update `normalizeEnsName` regex to allow ENS names with 3 or more characters (previously required 7 or more) ([#8510](https://github.com/MetaMask/core/pull/8510)) +- Update default Sei Mainnet block explorer URL from `seitrace.com` to `seiscan.io` ([#8545](https://github.com/MetaMask/core/pull/8545)) +- Update `BUILT_IN_NETWORKS`, `InfuraNetworkType`, `ChainId`, `NetworksTicker`, `BlockExplorerUrl`, `NetworkNickname` to include missing Infura networks ([#8680](https://github.com/MetaMask/core/pull/8680), [#8713](https://github.com/MetaMask/core/pull/8713)) + +## [11.20.0] + +### Added + +- Add `CHAIN_IDS_WITH_NO_NATIVE_TOKEN` with Tempo chains in `constants.ts` ([#8336](https://github.com/MetaMask/core/pull/8336)) + +## [11.19.0] + +### Added + +- Add `megaeth-mainnet` to `BUILT_IN_NETWORKS` ([#7994](https://github.com/MetaMask/core/pull/7994)) + +## [11.18.0] + +### Changed + +- Upgrade `@metamask/utils` from `^11.8.1` to `^11.9.0` ([#7511](https://github.com/MetaMask/core/pull/7511)) +- Update MegaETH Testnet "v2" RPC constants ([#7566](https://github.com/MetaMask/core/pull/7566)) + - Change RPC endpoint from `https://timothy.megaeth.com/rpc` to `https://carrot.megaeth.com/rpc` + +## [11.17.0] + +### Added + +- Add MegaETH Testnet "v2" to various constants, enums, and types ([#7272](https://github.com/MetaMask/core/pull/7272)) + - Add `megaeth-testnet-v2` to `BUILT_IN_NETWORKS` + - Add `megaeth-testnet-v2` to `BUILT_IN_CUSTOM_NETWORKS_RPC` + - Add `MegaETHTestnetV2` to `BuiltInNetworkName` enum + - Add `megaeth-testnet-v2` to `ChainId` type + - Add `MegaETHTestnetV2` to `NetworksTicker` enum + - Add `MegaETHTestnetV2` to `BlockExplorerUrl` quasi-enum + - Add `MegaETHTestnetV2` to `NetworkNickname` quasi-enum + +### Deprecated + +- Deprecate references to MegaETH Testnet "v1" in favor of "v2" ([#7272](https://github.com/MetaMask/core/pull/7272)) + - Deprecate `BUILT_IN_CUSTOM_NETWORKS_RPC["megaeth-testnet"]` + - Deprecate `CustomNetworkType["megaeth-testnet"]` + - Deprecate `BuiltInNetworkName["megaeth-testnet"]` + - Deprecate `ChainId["megaeth-testnet"]` + - Deprecate `NetworksTicker["megaeth-testnet"]` + - Deprecate `BlockExplorerUrl["megaeth-testnet"]` + - Deprecate `NetworkNickname["megaeth-testnet"]` + +## [11.16.0] + +### Added + +- Add `getCircuitState` method to `ServicePolicy` ([#7164](https://github.com/MetaMask/core/pull/7164)) + - This can be used when working with a chain of services to know whether a service's underlying circuit is open or closed. +- Add `onAvailable` method to `ServicePolicy` ([#7164](https://github.com/MetaMask/core/pull/7164)) + - This can be used to listen for the initial successful execution of the service, or the first successful execution after the service becomes degraded or the circuit breaks. +- Add `reset` method to `ServicePolicy` ([#7164](https://github.com/MetaMask/core/pull/7164)) + - This can be used when working with a chain of services to reset the state of the circuit breaker policy (e.g. if a primary recovers and we want to reset the failovers). +- Export `CockatielEventEmitter` and `CockatielFailureReason` from Cockatiel ([#7164](https://github.com/MetaMask/core/pull/7164)) + - These can be used to further transform types for event emitters/listeners. + +## [11.15.0] + +### Added + +- Arbitrum, BSC, Optimism, Polygon, and Sei networks to Infura networks ([#6972](https://github.com/MetaMask/core/pull/6972)) + - Add `arbitrum-one`, `bsc-mainnet`, `optimism-mainnet`, `polygon-mainnet`, `sei-mainnet` to `BUILT_IN_NETWORKS` + - Add `arbitrum-one`, `bsc-mainnet`, `optimism-mainnet`, `polygon-mainnet`, `sei-mainnet` to `InfuraNetworkType` + - Add `ArbitrumOne`, `BscMainnet`, `OptimismMainnet`, `PolygonMainnet`, `SeiMainnet` to `BuiltInNetworkName` enum + - Add corresponding chain IDs to `ChainId` type (0xa4b1, 0x38, 0xa, 0x89, 0x531) + - Add `ETG`, `BNB`, `ETH`, `POL`, `SEI` tickers to `NetworksTicker` enum + - Add block explorer URLs to `BlockExplorerUrl` quasi-enum + - Add network nicknames to `NetworkNickname` quasi-enum + +## [11.14.1] + +### Changed + +- Bump `@metamask/utils` from `^11.8.0` to `^11.8.1` ([#6708](https://github.com/MetaMask/core/pull/6708)) + +## [11.14.0] + +### Added + +- Export `NETWORKS_BYPASSING_VALIDATION` constant globally . ([#6627](https://github.com/MetaMask/core/pull/6627)) + +## [11.13.0] + +### Added + +- Add constant `NETWORKS_BYPASSING_VALIDATION` to allow clients to ignore warning messages for specific networks. ([#6557](https://github.com/MetaMask/core/pull/6557)) +- Add `circuitBreakDuration` to the object returned by `createServicePolicy` ([#6423](https://github.com/MetaMask/core/pull/6423)) + - This is the amount of time that the underlying circuit breaker policy will pause execution of the input function while the circuit is broken. +- Add `getRemainingCircuitOpenDuration` to the object returned by `createServicePolicy` ([#6423](https://github.com/MetaMask/core/pull/6423)) + - This returns the amount of time after which the underlying circuit breaker policy will resume execution of the input function after the circuit reopens. + +### Changed + +- Bump `@metamask/utils` from `^11.4.2` to `^11.8.0` ([#6588](https://github.com/MetaMask/core/pull/6588)) + +## [11.12.0] + +### Added + +- Update `onDegraded` property in `ServicePolicy` so that the event listener payload may be an object with either an `error` or `value` property, which can be used to access the error produced by the last request when the maximum number of retries is exceeded ([#6188](https://github.com/MetaMask/core/pull/6188)) + - The payload will be empty (i.e. the object will be `undefined`) if the degraded event merely represents a slow request. + - `ServicePolicy` is the type returned by `createServicePolicy`. + - **NOTE:** Although `error` and `value` are new, optional properties, this change makes an inadvertent breaking change to the signature of the event listener due to how TypeScript compares function types. We have conciously decided not to re-release this change under a major version, so be advised. + +## [11.11.0] + +### Added + +- Add convenience variables for calculating the number of milliseconds in a higher unit of time + - `SECOND` / `SECONDS` + - `MINUTE` / `MINUTES` + - `HOUR` / `HOURS` + - `DAY` / `DAYS` + +### Changed + +- Bump `@metamask/utils` from `^11.2.0` to `^11.4.2` ([#6054](https://github.com/MetaMask/core/pull/6054)) +- Improve performance of `isValidHexAddress` and `toChecksumHexAddress` ([#6054](https://github.com/MetaMask/core/pull/6054)) + - Replace `ethereumjs-util` lib with faster `@metamask/utils` functions + - Memoize `isValidHexAddress` and `toChecksumHexAddress` functions +- Update `createServicePolicy` to reduce circuit break duration from 30 minutes to 2 minutes ([#6015](https://github.com/MetaMask/core/pull/6015)) + - When hitting an API, this reduces the default duration for which requests to the API are paused when perceived to be unavailable + +## [11.10.0] + +### Added + +- Add `TransactionBatch` in approval types enum ([#5793](https://github.com/MetaMask/core/pull/5793)) +- Add Base network to default networks ([#5902](https://github.com/MetaMask/core/pull/5902)) + - Add `base-mainnet` to `BUILT_IN_NETWORKS` + - Add `base-mainnet` to `InfuraNetworkType` + - Add `BaseMainnet` to `BuiltInNetworkName` enum + - Add `base-mainnet` to `ChainId` type + - Add `BaseMainnet` to `NetworksTicker` enum + - Add `BaseMainnet` to `BlockExplorerUrl` quasi-enum + - Add `BaseMainnet` to `NetworkNickname` quasi-enum + +## [11.9.0] + +### Added + +- Add `HttpError` class for errors representing non-200 HTTP responses ([#5809](https://github.com/MetaMask/core/pull/5809)) + +### Changed + +- Improved circuit breaker behavior to no longer consider HTTP 4XX responses as service failures ([#5798](https://github.com/MetaMask/core/pull/5798), [#5809](https://github.com/MetaMask/core/pull/5809)) + - Changed from using `handleAll` to `handleWhen(isServiceFailure)` in circuit breaker policy + - This ensures that expected error responses (like 405 Method Not Allowed and 429 Rate Limited) don't trigger the circuit breaker + +## [11.8.0] + +### Added + +- Add Monad Testnet to various constants, enums, and types ([#5724](https://github.com/MetaMask/core/pull/5724)) + - Add `monad-testnet` to `BUILT_IN_NETWORKS` + - Add `monad-testnet` and `megaeth-testnet` to `BUILT_IN_CUSTOM_NETWORKS_RPC` + - Add `MonadTestnet` to `BuiltInNetworkName` enum + - Add `monad-testnet` to `ChainId` type + - Add `MonadTestnet` to `NetworksTicker` enum + - Add `MonadTestnet` to `BlockExplorerUrl` quasi-enum + - Add `MonadTestnet` to `NetworkNickname` quasi-enum + +## [11.7.0] + +### Added + +- Re-export `ConstantBackoff` and `ExponentialBackoff` from `cockatiel` ([#5492](https://github.com/MetaMask/core/pull/5492)) + - These can be used to customize service policies +- Add optional `backoff` option to `createServicePolicy` ([#5492](https://github.com/MetaMask/core/pull/5492)) + - This is mainly useful in tests to force the backoff strategy to be constant rather than exponential +- Add `BUILT_IN_CUSTOM_NETWORKS_RPC`, which includes MegaETH ([#5495](https://github.com/MetaMask/core/pull/5495)) +- Add `CustomNetworkType` quasi-enum and type, which includes MegaETH ([#5495](https://github.com/MetaMask/core/pull/5495)) +- Add `BuiltInNetworkType` type union, which encompasses all Infura and custom network types ([#5495](https://github.com/MetaMask/core/pull/5495)) + +### Changed + +- Add MegaETH Testnet to various constants, enums, and types ([#5495](https://github.com/MetaMask/core/pull/5495)) + - Add `MEGAETH_TESTNET` to `TESTNET_TICKER_SYMBOLS` + - Add `megaeth-testnet` to `BUILT_IN_NETWORKS` + - Add `MegaETHTestnet` to `BuiltInNetworkName` enum + - Add `megaeth-testnet` to `ChainId` type + - Add `MegaETHTestnet` to `NetworksTicker` enum + - Add `MegaETHTestnet` to `BlockExplorerUrl` quasi-enum + - Add `MegaETHTestnet` to `NetworkNickname` quasi-enum +- `CHAIN_ID_TO_ETHERS_NETWORK_NAME_MAP` is now typed as `Record` rather than `Record` ([#5495](https://github.com/MetaMask/core/pull/5495)) +- `NetworkType` quasi-enum now includes all keys/values from `CustomNetworkType` ([#5495](https://github.com/MetaMask/core/pull/5495)) + +## [11.6.0] + +### Changed + +- Bump `@ethereumjs/util` from `^8.1.0` to `^9.1.0` ([#5347](https://github.com/MetaMask/core/pull/5347)) +- Bump `@metamask/utils` from `^11.1.0` to `^11.2.0` ([#5301](https://github.com/MetaMask/core/pull/5301)) + +## [11.5.0] + +### Added + +- Add utility function `createServicePolicy` for reducing boilerplate for service classes ([#5141](https://github.com/MetaMask/core/pull/5141), [#5154](https://github.com/MetaMask/core/pull/5154), [#5143](https://github.com/MetaMask/core/pull/5143), [#5149](https://github.com/MetaMask/core/pull/5149), [#5188](https://github.com/MetaMask/core/pull/5188), [#5192](https://github.com/MetaMask/core/pull/5192), [#5225](https://github.com/MetaMask/core/pull/5225)) + - Export constants `DEFAULT_CIRCUIT_BREAK_DURATION`, `DEFAULT_DEGRADED_THRESHOLD`, `DEFAULT_MAX_CONSECUTIVE_FAILURES`, and `DEFAULT_MAX_RETRIES` + - Export types `ServicePolicy` and `CreateServicePolicyOptions` + - Re-export `BrokenCircuitError`, `CircuitState`, `handleAll`, and `handleWhen` from `cockatiel` + - Export `CockatielEvent` type, an alias of the `Event` type from `cockatiel` + +### Changed + +- Bump `@metamask/utils` from `^11.0.1` to `^11.1.0` ([#5223](https://github.com/MetaMask/core/pull/5223)) + +## [11.4.5] + +### Changed + +- Bump `@metamask/utils` from `^10.0.0` to `^11.0.1` ([#5080](https://github.com/MetaMask/core/pull/5080)) + +## [11.4.4] + +### Fixed + +- Make implicit peer dependencies explicit ([#4974](https://github.com/MetaMask/core/pull/4974)) + - Add the following packages as peer dependencies of this package to satisfy peer dependency requirements from other dependencies: + - `@babel/runtime@^7.0.0` (required by `@metamask/ethjs-unit`) + - These dependencies really should be present in projects that consume this package (e.g. MetaMask clients), and this change ensures that they now are. + - Furthermore, we are assuming that clients already use these dependencies, since otherwise it would be impossible to consume this package in its entirety or even create a working build. Hence, the addition of these peer dependencies is really a formality and should not be breaking. +- Correct ESM-compatible build so that imports of the following packages that re-export other modules via `export *` are no longer corrupted: ([#5011](https://github.com/MetaMask/core/pull/5011)) + - `bn.js` + - `eth-ens-namehash` + - `fast-deep-equal` + +## [11.4.3] + +### Changed + +- The `NetworkNickname` for mainnet is now `Ethereum Mainnet` instead of `Mainnet`. And the display name for Linea is now `Linea` instead of `Linea Mainnet`. ([#4865](https://github.com/MetaMask/core/pull/4865)) + +## [11.4.2] + +### Changed + +- Move BigNumber.js from devDependencies to dependencies ([#4873](https://github.com/MetaMask/core/pull/4873)) + +## [11.4.1] + +### Changed + +- Bump `@metamask/utils` from `^9.1.0` to `^10.0.0` ([#4831](https://github.com/MetaMask/core/pull/4831)) + +## [11.4.0] + +### Added + +- Add `isEqualCaseInsensitive` function for case-insensitive string comparison ([#4811](https://github.com/MetaMask/core/pull/4811)) + +## [11.3.0] + +### Added + +- Add types `TraceContext`, `TraceRequest`, `TraceCallback` ([#4655](https://github.com/MetaMask/core/pull/4655)) + - Migrated from `@metamask/transaction-controller@36.2.0`. + +### Fixed + +- Produce and export ESM-compatible TypeScript type declaration files in addition to CommonJS-compatible declaration files ([#4648](https://github.com/MetaMask/core/pull/4648)) + - Previously, this package shipped with only one variant of type declaration + files, and these files were only CommonJS-compatible, and the `exports` + field in `package.json` linked to these files. This is an anti-pattern and + was rightfully flagged by the + ["Are the Types Wrong?"](https://arethetypeswrong.github.io/) tool as + ["masquerading as CJS"](https://github.com/arethetypeswrong/arethetypeswrong.github.io/blob/main/docs/problems/FalseCJS.md). + All of the ATTW checks now pass. +- Remove chunk files ([#4648](https://github.com/MetaMask/core/pull/4648)) + - Previously, the build tool we used to generate JavaScript files extracted + common code to "chunk" files. While this was intended to make this package + more tree-shakeable, it also made debugging more difficult for our + development teams. These chunk files are no longer present. + +## [11.2.0] + +### Added + +- Add `BlockExplorerUrl` object and type for looking up the block explorer URL of any Infura network ([#4268](https://github.com/MetaMask/core/pull/4268)) +- Add `NetworkNickname` object and type for looking up the common nickname for any Infura network ([#4268](https://github.com/MetaMask/core/pull/4268)) +- Add `Partialize` type for making select keys in an object type optional ([#4268](https://github.com/MetaMask/core/pull/4268)) +- `toHex` now supports converting a `bigint` into a hex string ([#4268](https://github.com/MetaMask/core/pull/4268)) + +## [11.1.0] + +### Added + +- Add default snap dialog to ApprovalType ([#4630](https://github.com/MetaMask/core/pull/4630)) + +## [11.0.2] + +### Changed + +- Bump TypeScript version to `~5.0.4` and set `moduleResolution` option to `Node16` ([#3645](https://github.com/MetaMask/core/pull/3645)) +- Bump `@metamask/utils` from `^9.0.0` to `^9.1.0` ([#4529](https://github.com/MetaMask/core/pull/4529)) + +## [11.0.1] + +### Changed + +- Bump `@metamask/rpc-errors` from `6.2.1` to `^6.3.1` ([#4516](https://github.com/MetaMask/core/pull/4516)) +- Bump `@metamask/utils` from `^8.3.0` to `^9.0.0` ([#4516](https://github.com/MetaMask/core/pull/4516)) + +## [11.0.0] + +### Added + +- Add `NFT_API_VERSION` and `NFT_API_TIMEOUT` constants ([#4312](https://github.com/MetaMask/core/pull/4312)) + +### Changed + +- **BREAKING:** Bump minimum Node version to 18.18 ([#3611](https://github.com/MetaMask/core/pull/3611)) + +### Removed + +- **BREAKING:** Remove `EthSign` from `ApprovalType` ([#4319](https://github.com/MetaMask/core/pull/4319)) + - This represented an `eth_sign` approval, but support for that RPC method is being removed, so this is no longer needed. + +## [10.0.0] + +### Changed + +- **BREAKING:** Changed price and token API endpoints from `*.metafi.codefi.network` to `*.api.cx.metamask.io` ([#4301](https://github.com/MetaMask/core/pull/4301)) + +## [9.1.0] + +### Added + +- Export new constant for the NFT API's url ([#4030](https://github.com/MetaMask/core/pull/4030)) +- Add support for wider range of SIWE messages ([#4141](https://github.com/MetaMask/core/pull/4141)) + +### Changed + +- Bump TypeScript version to ~4.9.5 ([#4084](https://github.com/MetaMask/core/pull/4084)) + +### Fixed + +- Add guards against prototype-polluting assignments ([#4041](https://github.com/MetaMask/core/pull/4041)) + +## [9.0.2] + +### Fixed + +- Allow `toChecksumHexAddress` to take and handle non-string inputs again, which was removed in 8.0.4 ([#4046](https://github.com/MetaMask/core/pull/4046)) + +## [9.0.1] + +### Fixed + +- Fix `types` field in `package.json` ([#4047](https://github.com/MetaMask/core/pull/4047)) + +## [9.0.0] + +### Added + +- **BREAKING**: Add ESM build ([#3998](https://github.com/MetaMask/core/pull/3998)) + - It's no longer possible to import files from `./dist` directly. +- Add support for Linea Sepolia to various constants, types, and type guards ([#3995](https://github.com/MetaMask/core/pull/3995)) + - Add `LINEA_SEPOLIA` to `TESTNET_TICKER_SYMBOLS` constant + - Add `0xe705` to `CHAIN_ID_TO_ETHERS_NETWORK_NAME_MAP` constant + - Add `linea-sepolia` to `BUILT_IN_NETWORKS` constant and `InfuraNetworkType`, `NetworkType`, `ChainId`, and `NetworksTicker` types + - Add `LineaSepolia` to `BuiltInNetworkName` enum + - `isNetworkType` and `isInfuraNetworkType` now return `true` when given "linea-sepolia" + +### Changed + +- Update `normalizeEnsName` so that it does not attempt to normalize `"."` ([#4006](https://github.com/MetaMask/core/pull/4006)) +- Move `bn.js` from `devDependencies` to `dependencies` ([#4023](https://github.com/MetaMask/core/pull/4023)) + +### Fixed + +- **BREAKING**: Narrow argument type for `BNToHex` and `fractionBN` from `any` to `BN` to enhance type safety ([#3975](https://github.com/MetaMask/core/pull/3975)) +- **BREAKING**: Narrow argument type for `logOrRethrowError` from `any` to `unknown` to enhance type safety ([#3975](https://github.com/MetaMask/core/pull/3975)) +- **BREAKING**: Narrow argument type for `isNetworkType` from `any` to `string` to enhance type safety ([#3975](https://github.com/MetaMask/core/pull/3975)) + +## [8.0.4] + +### Changed + +- Replace `ethereumjs-util` with `@ethereumjs/util` ([#3943](https://github.com/MetaMask/core/pull/3943)) + +## [8.0.3] + +### Changed + +- Bump `@metamask/ethjs-unit` to `^0.3.0` ([#3897](https://github.com/MetaMask/core/pull/3897)) + +## [8.0.2] + +### Changed + +- Bump `@metamask/utils` to `^8.3.0` ([#3769](https://github.com/MetaMask/core/pull/3769)) + +## [8.0.1] + +### Changed + +- There are no consumer-facing changes to this package. This version is a part of a synchronized release across all packages in our monorepo. + +## [8.0.0] + +### Changed + +- **BREAKING**: `OPENSEA_PROXY_URL` now points to OpenSea's v2 API. `OPENSEA_API_URL` + `OPENSEA_TEST_API_URL` have been removed ([#3654](https://github.com/MetaMask/core/pull/3654)) + +## [7.0.0] + +### Changed + +- **BREAKING:** Make `safelyExecute` generic so they preserve types ([#3629](https://github.com/MetaMask/core/pull/3629)) +- Update `successfulFetch` so that a URL instance can now be passed to it ([#3600](https://github.com/MetaMask/core/pull/3600)) +- Update `handleFetch` so that a URL instance can now be passed to it ([#3600](https://github.com/MetaMask/core/pull/3600)) + +## [6.1.0] + +### Added + +- Add `isInfuraNetworkType` type guard for `InfuraNetworkType` ([#2055](https://github.com/MetaMask/core/pull/2055)) + +### Fixed + +- Restore missing dependency `eth-query`([#3578](https://github.com/MetaMask/core/pull/3578)) + - This was mistakenly recategorized as a devDependency in v6.0.0 + +## [6.0.0] + +### Changed + +- **BREAKING:** Bump `@metamask/eth-query` to ^4.0.0 ([#2028](https://github.com/MetaMask/core/pull/2028)) + - This affects `query`: the `sendAsync` method on the given EthQuery must now have a narrower type +- Bump `@metamask/utils` from ^8.1.0 to ^8.2.0 ([#1957](https://github.com/MetaMask/core/pull/1957)) +- Change `BUILT_IN_NETWORKS` so that `rpc` entry now has a dummy `ticker` ([#1794](https://github.com/MetaMask/core/pull/1794)) +- Replace `ethjs-unit` ^0.1.6 with `@metamask/ethjs-unit` ^0.2.1 ([#2064](https://github.com/MetaMask/core/pull/2064)) + +### Fixed + +- Move `@metamask/eth-query` from a development dependency to a runtime dependency ([#1815](https://github.com/MetaMask/core/pull/1815)) + ## [5.0.2] + ### Changed + - Bump dependency on `@metamask/utils` to ^8.1.0 ([#1639](https://github.com/MetaMask/core/pull/1639)) - Move `eth-rpc-errors@^4.0.2` dependency to `@metamask/rpc-errors@^6.0.2` ([#1743](https://github.com/MetaMask/core/pull/1743)) ### Fixed + - Update linea goerli explorer url ([#1666](https://github.com/MetaMask/core/pull/1666)) ## [5.0.1] + ### Changed + - Update TypeScript to v4.8.x ([#1718](https://github.com/MetaMask/core/pull/1718)) ## [5.0.0] + ### Changed + - **BREAKING**: Rename `NETWORK_ID_TO_ETHERS_NETWORK_NAME_MAP` to `CHAIN_ID_TO_ETHERS_NETWORK_NAME_MAP` ([#1633](https://github.com/MetaMask/core/pull/1633)) - Change it to a map of `Hex` chain ID to `BuiltInNetworkName` ### Removed + - **BREAKING**: Remove `NetworkId` constant and type ([#1633](https://github.com/MetaMask/core/pull/1633)) ## [4.3.2] + ### Changed + - There are no consumer-facing changes to this package. This version is a part of a synchronized release across all packages in our monorepo. ## [4.3.1] + ### Changed + - Replace `eth-query` ^2.1.2 with `@metamask/eth-query` ^3.0.1 ([#1546](https://github.com/MetaMask/core/pull/1546)) ## [4.3.0] + ### Changed + - Update `@metamask/utils` to `^6.2.0` ([#1514](https://github.com/MetaMask/core/pull/1514)) - Remove unnecessary `babel-runtime` dependency ([#1504](https://github.com/MetaMask/core/pull/1504)) ## [4.2.0] + ### Added + - Add support for Linea networks ([#1423](https://github.com/MetaMask/core/pull/1423)) - Add `LINEA_GOERLI` to `TESTNET_TICKER_SYMBOLS` map - Add `linea-goerli` and `linea-mainnet` to `BUILT_IN_NETWORKS` map, as well as `NetworkType`, `InfuraNetworkType`, `ChainId`, and `NetworkId `enums - Add `LineaGoerli` and `LineaMainnet` to `BuiltInNetworkName` enum ## [4.1.0] + ### Added + - Add approval types for result pages ([#1442](https://github.com/MetaMask/core/pull/1442)) ## [4.0.1] + ### Changed + - Add dependencies `eth-query` and `babel-runtime` ([#1447](https://github.com/MetaMask/core/pull/1447)) ### Fixed + - Fix bug where query function failed to call built-in EthQuery methods ([#1447](https://github.com/MetaMask/core/pull/1447)) ## [4.0.0] + ### Added + - Add constants `BuiltInNetwork` and `ChainId` ([#1354](https://github.com/MetaMask/core/pull/1354)) - Add Aurora network to the `ChainId` constant ([#1327](https://github.com/MetaMask/core/pull/1327)) - Add `InfuraNetworkType` enum ([#1264](https://github.com/MetaMask/core/pull/1264)) ### Changed + - **BREAKING:** Bump to Node 16 ([#1262](https://github.com/MetaMask/core/pull/1262)) - **BREAKING:** The `isSafeChainId` chain ID parameter is now type `Hex` rather than `number` ([#1367](https://github.com/MetaMask/core/pull/1367)) - **BREAKING:** The `ChainId` enum and the `GANACHE_CHAIN_ID` constant are now formatted as 0x-prefixed hex strings rather than as decimal strings. ([#1367](https://github.com/MetaMask/core/pull/1367)) @@ -72,6 +605,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Bump @metamask/utils from 5.0.1 to 5.0.2 ([#1271](https://github.com/MetaMask/core/pull/1271)) ### Removed + - **BREAKING:** Remove `Json` type ([#1370](https://github.com/MetaMask/core/pull/1370)) - **BREAKING:** Remove `NetworksChainId` constant ([#1354](https://github.com/MetaMask/core/pull/1354)) - Use the new `ChainId` constant or the pre-existing `NetworkId` constant instead @@ -84,52 +618,70 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - We didn't discover this until many releases later, which is why this happened in a minor release ## [3.4.0] [DEPRECATED] + ### Added + - add WalletConnect in approval type ([#1240](https://github.com/MetaMask/core/pull/1240)) ## [3.3.0] [DEPRECATED] + ### Added + - Add Sign-in-with-Ethereum origin validation ([#1163](https://github.com/MetaMask/core/pull/1163)) - Add `NetworkId` enum and `NETWORK_ID_TO_ETHERS_NETWORK_NAME_MAP` constant that includes entries for each built-in Infura network ([#1170](https://github.com/MetaMask/core/pull/1170)) ## [3.2.0] [DEPRECATED] + ### Added + - Add `ORIGIN_METAMASK` constant ([#1166](https://github.com/MetaMask/core/pull/1166)) - Add `ApprovalType` enum ([#1174](https://github.com/MetaMask/core/pull/1174)) ### Changed + - Improve return type of `toHex` ([#1195](https://github.com/MetaMask/core/pull/1195)) ## [3.1.0] [DEPRECATED] + ### Added + - Add SIWE detection support for PersonalMessageManager ([#1139](https://github.com/MetaMask/core/pull/1139)) - Add `NetworkType` ([#1132](https://github.com/MetaMask/core/pull/1132)) - Add `isSafeChainId` ([#1064](https://github.com/MetaMask/core/pull/1064)) ### Removed + - **BREAKING:** Remove constants `MAINNET` and `TESTNET_TICKER_SYMBOLS` ([#1132](https://github.com/MetaMask/core/pull/1132)) - We didn't discover this until many releases later, which is why this happened in a minor release ## [3.0.0] + ### Removed + - **BREAKING:** Remove `isomorphic-fetch` ([#1106](https://github.com/MetaMask/controllers/pull/1106)) - Consumers must now import `isomorphic-fetch` or another polyfill themselves if they are running in an environment without `fetch` ## [2.0.0] + ### Added + - Add Sepolia-related constants ([#1041](https://github.com/MetaMask/controllers/pull/1041)) - Update `getBuyURL` function to return Sepolia faucet for Sepolia network ([#1041](https://github.com/MetaMask/controllers/pull/1041)) ### Changed + - **BREAKING:**: Migrate from `metaswap` to `metafi` subdomain for OpenSea proxy ([#1060](https://github.com/MetaMask/core/pull/1060)) - Rename this repository to `core` ([#1031](https://github.com/MetaMask/controllers/pull/1031)) ### Removed + - **BREAKING:** Remove all constants associated with Ropsten, Rinkeby, and Kovan ([#1041](https://github.com/MetaMask/controllers/pull/1041)) - **BREAKING:** Remove support for Ropsten, Rinkeby, and Kovan from `getBuyUrl` function ([#1041](https://github.com/MetaMask/controllers/pull/1041)) ## [1.0.0] + ### Added + - Initial release - As a result of converting our shared controllers repo into a monorepo ([#831](https://github.com/MetaMask/core/pull/831)), we've created this package from select parts of [`@metamask/controllers` v33.0.0](https://github.com/MetaMask/core/tree/v33.0.0), namely: - `src/constants.ts` (but see below) @@ -167,7 +719,54 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 All changes listed after this point were applied to this package following the monorepo conversion. -[Unreleased]: https://github.com/MetaMask/core/compare/@metamask/controller-utils@5.0.2...HEAD +[Unreleased]: https://github.com/MetaMask/core/compare/@metamask/controller-utils@12.3.0...HEAD +[12.3.0]: https://github.com/MetaMask/core/compare/@metamask/controller-utils@12.2.0...@metamask/controller-utils@12.3.0 +[12.2.0]: https://github.com/MetaMask/core/compare/@metamask/controller-utils@12.1.1...@metamask/controller-utils@12.2.0 +[12.1.1]: https://github.com/MetaMask/core/compare/@metamask/controller-utils@12.1.0...@metamask/controller-utils@12.1.1 +[12.1.0]: https://github.com/MetaMask/core/compare/@metamask/controller-utils@12.0.0...@metamask/controller-utils@12.1.0 +[12.0.0]: https://github.com/MetaMask/core/compare/@metamask/controller-utils@11.20.0...@metamask/controller-utils@12.0.0 +[11.20.0]: https://github.com/MetaMask/core/compare/@metamask/controller-utils@11.19.0...@metamask/controller-utils@11.20.0 +[11.19.0]: https://github.com/MetaMask/core/compare/@metamask/controller-utils@11.18.0...@metamask/controller-utils@11.19.0 +[11.18.0]: https://github.com/MetaMask/core/compare/@metamask/controller-utils@11.17.0...@metamask/controller-utils@11.18.0 +[11.17.0]: https://github.com/MetaMask/core/compare/@metamask/controller-utils@11.16.0...@metamask/controller-utils@11.17.0 +[11.16.0]: https://github.com/MetaMask/core/compare/@metamask/controller-utils@11.15.0...@metamask/controller-utils@11.16.0 +[11.15.0]: https://github.com/MetaMask/core/compare/@metamask/controller-utils@11.14.1...@metamask/controller-utils@11.15.0 +[11.14.1]: https://github.com/MetaMask/core/compare/@metamask/controller-utils@11.14.0...@metamask/controller-utils@11.14.1 +[11.14.0]: https://github.com/MetaMask/core/compare/@metamask/controller-utils@11.13.0...@metamask/controller-utils@11.14.0 +[11.13.0]: https://github.com/MetaMask/core/compare/@metamask/controller-utils@11.12.0...@metamask/controller-utils@11.13.0 +[11.12.0]: https://github.com/MetaMask/core/compare/@metamask/controller-utils@11.11.0...@metamask/controller-utils@11.12.0 +[11.11.0]: https://github.com/MetaMask/core/compare/@metamask/controller-utils@11.10.0...@metamask/controller-utils@11.11.0 +[11.10.0]: https://github.com/MetaMask/core/compare/@metamask/controller-utils@11.9.0...@metamask/controller-utils@11.10.0 +[11.9.0]: https://github.com/MetaMask/core/compare/@metamask/controller-utils@11.8.0...@metamask/controller-utils@11.9.0 +[11.8.0]: https://github.com/MetaMask/core/compare/@metamask/controller-utils@11.7.0...@metamask/controller-utils@11.8.0 +[11.7.0]: https://github.com/MetaMask/core/compare/@metamask/controller-utils@11.6.0...@metamask/controller-utils@11.7.0 +[11.6.0]: https://github.com/MetaMask/core/compare/@metamask/controller-utils@11.5.0...@metamask/controller-utils@11.6.0 +[11.5.0]: https://github.com/MetaMask/core/compare/@metamask/controller-utils@11.4.5...@metamask/controller-utils@11.5.0 +[11.4.5]: https://github.com/MetaMask/core/compare/@metamask/controller-utils@11.4.4...@metamask/controller-utils@11.4.5 +[11.4.4]: https://github.com/MetaMask/core/compare/@metamask/controller-utils@11.4.3...@metamask/controller-utils@11.4.4 +[11.4.3]: https://github.com/MetaMask/core/compare/@metamask/controller-utils@11.4.2...@metamask/controller-utils@11.4.3 +[11.4.2]: https://github.com/MetaMask/core/compare/@metamask/controller-utils@11.4.1...@metamask/controller-utils@11.4.2 +[11.4.1]: https://github.com/MetaMask/core/compare/@metamask/controller-utils@11.4.0...@metamask/controller-utils@11.4.1 +[11.4.0]: https://github.com/MetaMask/core/compare/@metamask/controller-utils@11.3.0...@metamask/controller-utils@11.4.0 +[11.3.0]: https://github.com/MetaMask/core/compare/@metamask/controller-utils@11.2.0...@metamask/controller-utils@11.3.0 +[11.2.0]: https://github.com/MetaMask/core/compare/@metamask/controller-utils@11.1.0...@metamask/controller-utils@11.2.0 +[11.1.0]: https://github.com/MetaMask/core/compare/@metamask/controller-utils@11.0.2...@metamask/controller-utils@11.1.0 +[11.0.2]: https://github.com/MetaMask/core/compare/@metamask/controller-utils@11.0.1...@metamask/controller-utils@11.0.2 +[11.0.1]: https://github.com/MetaMask/core/compare/@metamask/controller-utils@11.0.0...@metamask/controller-utils@11.0.1 +[11.0.0]: https://github.com/MetaMask/core/compare/@metamask/controller-utils@10.0.0...@metamask/controller-utils@11.0.0 +[10.0.0]: https://github.com/MetaMask/core/compare/@metamask/controller-utils@9.1.0...@metamask/controller-utils@10.0.0 +[9.1.0]: https://github.com/MetaMask/core/compare/@metamask/controller-utils@9.0.2...@metamask/controller-utils@9.1.0 +[9.0.2]: https://github.com/MetaMask/core/compare/@metamask/controller-utils@9.0.1...@metamask/controller-utils@9.0.2 +[9.0.1]: https://github.com/MetaMask/core/compare/@metamask/controller-utils@9.0.0...@metamask/controller-utils@9.0.1 +[9.0.0]: https://github.com/MetaMask/core/compare/@metamask/controller-utils@8.0.4...@metamask/controller-utils@9.0.0 +[8.0.4]: https://github.com/MetaMask/core/compare/@metamask/controller-utils@8.0.3...@metamask/controller-utils@8.0.4 +[8.0.3]: https://github.com/MetaMask/core/compare/@metamask/controller-utils@8.0.2...@metamask/controller-utils@8.0.3 +[8.0.2]: https://github.com/MetaMask/core/compare/@metamask/controller-utils@8.0.1...@metamask/controller-utils@8.0.2 +[8.0.1]: https://github.com/MetaMask/core/compare/@metamask/controller-utils@8.0.0...@metamask/controller-utils@8.0.1 +[8.0.0]: https://github.com/MetaMask/core/compare/@metamask/controller-utils@7.0.0...@metamask/controller-utils@8.0.0 +[7.0.0]: https://github.com/MetaMask/core/compare/@metamask/controller-utils@6.1.0...@metamask/controller-utils@7.0.0 +[6.1.0]: https://github.com/MetaMask/core/compare/@metamask/controller-utils@6.0.0...@metamask/controller-utils@6.1.0 +[6.0.0]: https://github.com/MetaMask/core/compare/@metamask/controller-utils@5.0.2...@metamask/controller-utils@6.0.0 [5.0.2]: https://github.com/MetaMask/core/compare/@metamask/controller-utils@5.0.1...@metamask/controller-utils@5.0.2 [5.0.1]: https://github.com/MetaMask/core/compare/@metamask/controller-utils@5.0.0...@metamask/controller-utils@5.0.1 [5.0.0]: https://github.com/MetaMask/core/compare/@metamask/controller-utils@4.3.2...@metamask/controller-utils@5.0.0 diff --git a/packages/controller-utils/LICENSE b/packages/controller-utils/LICENSE index ddfbecf9020..bbed2e24b91 100644 --- a/packages/controller-utils/LICENSE +++ b/packages/controller-utils/LICENSE @@ -18,3 +18,4 @@ 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/controller-utils/jest.config.js b/packages/controller-utils/jest.config.js index c469db2238e..f7ca96dbc15 100644 --- a/packages/controller-utils/jest.config.js +++ b/packages/controller-utils/jest.config.js @@ -17,13 +17,13 @@ module.exports = merge(baseConfig, { // An object that configures minimum threshold enforcement for coverage results coverageThreshold: { global: { - branches: 68.05, - functions: 80.55, - lines: 69.82, - statements: 70.17, + branches: 86.41, + functions: 78.37, + lines: 91.02, + statements: 91.11, }, }, // We rely on `window` to make requests - testEnvironment: 'jsdom', + testEnvironment: '/jest.environment.js', }); diff --git a/packages/controller-utils/jest.environment.js b/packages/controller-utils/jest.environment.js new file mode 100644 index 00000000000..35f9c47b2ad --- /dev/null +++ b/packages/controller-utils/jest.environment.js @@ -0,0 +1,19 @@ +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 TestEnvironment { + async setup() { + await super.setup(); + if (typeof this.global.TextEncoder === 'undefined') { + // Needed for the JSDOM environment. + // eslint-disable-next-line no-shadow, n/prefer-global/text-encoder, n/prefer-global/text-decoder + const { TextEncoder, TextDecoder } = require('util'); + this.global.TextEncoder = TextEncoder; + this.global.TextDecoder = TextDecoder; + this.global.ArrayBuffer = ArrayBuffer; + this.global.Uint8Array = Uint8Array; + } + } +}; diff --git a/packages/controller-utils/package.json b/packages/controller-utils/package.json index 2326d71ff3c..bd5cd75ee2d 100644 --- a/packages/controller-utils/package.json +++ b/packages/controller-utils/package.json @@ -1,58 +1,91 @@ { "name": "@metamask/controller-utils", - "version": "5.0.2", + "version": "12.3.0", "description": "Data and convenience functions shared by multiple packages", "keywords": [ - "MetaMask", - "Ethereum" + "Ethereum", + "MetaMask" ], "homepage": "https://github.com/MetaMask/core/tree/main/packages/controller-utils#readme", "bugs": { "url": "https://github.com/MetaMask/core/issues" }, + "license": "MIT", "repository": { "type": "git", "url": "https://github.com/MetaMask/core.git" }, - "license": "MIT", - "main": "./dist/index.js", - "types": "./dist/index.d.ts", "files": [ "dist/" ], + "sideEffects": false, + "main": "./dist/index.cjs", + "types": "./dist/index.d.cts", + "exports": { + ".": { + "import": { + "types": "./dist/index.d.mts", + "default": "./dist/index.mjs" + }, + "require": { + "types": "./dist/index.d.cts", + "default": "./dist/index.cjs" + } + }, + "./package.json": "./package.json" + }, + "publishConfig": { + "access": "public", + "registry": "https://registry.npmjs.org/" + }, "scripts": { + "build": "ts-bridge --project tsconfig.build.json --verbose --clean --no-references", + "build:all": "ts-bridge --project tsconfig.build.json --verbose --clean", "build:docs": "typedoc", + "changelog:update": "../../scripts/update-changelog.sh @metamask/controller-utils", "changelog:validate": "../../scripts/validate-changelog.sh @metamask/controller-utils", - "publish:preview": "yarn npm publish --tag preview", - "test": "jest", - "test:clean": "jest --clearCache", - "test:watch": "jest --watch" + "lint:tsconfigs": "tsx ../../scripts/lint-tsconfigs/lint-tsconfigs.mts", + "lint:tsconfigs:fix": "tsx ../../scripts/lint-tsconfigs/lint-tsconfigs.mts --fix", + "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/utils": "^8.1.0", - "@spruceid/siwe-parser": "1.1.3", + "@ethersproject/abi": "^5.7.0", + "@metamask/eth-query": "^4.0.0", + "@metamask/ethjs-unit": "^0.3.0", + "@metamask/utils": "^11.11.0", + "@spruceid/siwe-parser": "2.1.0", + "@types/bn.js": "^5.1.5", + "bignumber.js": "^9.1.2", + "bn.js": "^5.2.1", + "cockatiel": "^3.1.2", "eth-ens-namehash": "^2.0.8", - "ethereumjs-util": "^7.0.10", - "ethjs-unit": "^0.1.6", - "fast-deep-equal": "^3.1.3" + "fast-deep-equal": "^3.1.3", + "lodash": "^4.17.21" }, "devDependencies": { - "@metamask/auto-changelog": "^3.1.0", - "@metamask/eth-query": "^3.0.1", - "@types/jest": "^27.4.1", + "@babel/runtime": "^7.23.9", + "@metamask/auto-changelog": "^6.1.0", + "@ts-bridge/cli": "^0.6.4", + "@types/jest": "^30.0.0", + "@types/lodash": "^4.14.191", "deepmerge": "^4.2.2", - "jest": "^27.5.1", + "jest": "^30.4.2", + "jest-environment-jsdom": "^30.4.1", "nock": "^13.3.1", - "ts-jest": "^27.1.4", - "typedoc": "^0.24.8", + "ts-jest": "^29.4.11", + "tsx": "^4.20.5", + "typedoc": "^0.25.13", "typedoc-plugin-missing-exports": "^2.0.0", - "typescript": "~4.8.4" + "typescript": "~5.3.3" }, - "engines": { - "node": ">=16.0.0" + "peerDependencies": { + "@babel/runtime": "^7.0.0" }, - "publishConfig": { - "access": "public", - "registry": "https://registry.npmjs.org/" + "engines": { + "node": "^18.18 || >=20" } } diff --git a/packages/controller-utils/src/abi.test.ts b/packages/controller-utils/src/abi.test.ts new file mode 100644 index 00000000000..deaea972621 --- /dev/null +++ b/packages/controller-utils/src/abi.test.ts @@ -0,0 +1,77 @@ +import { Interface } from '@ethersproject/abi'; + +import { encodeFunctionData } from './abi.js'; + +const ERC20_ABI = [ + { + constant: true, + inputs: [{ name: '_owner', type: 'address' }], + name: 'balanceOf', + outputs: [{ name: 'balance', type: 'uint256' }], + stateMutability: 'view', + type: 'function', + }, + { + constant: false, + inputs: [ + { name: '_to', type: 'address' }, + { name: '_value', type: 'uint256' }, + ], + name: 'transfer', + outputs: [{ name: '', type: 'bool' }], + stateMutability: 'nonpayable', + type: 'function', + }, +] as const; + +const ACCOUNT_ADDRESS = '0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2'; + +describe('encodeFunctionData', () => { + describe('with the ERC-20 ABI', () => { + const erc20Interface = new Interface(ERC20_ABI); + + it('encodes a function with a single address parameter', () => { + const result = encodeFunctionData(erc20Interface, 'balanceOf', [ + ACCOUNT_ADDRESS, + ]); + + expect(result).toBe( + erc20Interface.encodeFunctionData('balanceOf', [ACCOUNT_ADDRESS]), + ); + }); + + it('encodes a function with multiple parameters', () => { + const result = encodeFunctionData(erc20Interface, 'transfer', [ + ACCOUNT_ADDRESS, + '1000000000000000000', + ]); + + expect(result).toBe( + erc20Interface.encodeFunctionData('transfer', [ + ACCOUNT_ADDRESS, + '1000000000000000000', + ]), + ); + }); + + it('encodes addresses that are not checksummed', () => { + const lowercaseAddress = ACCOUNT_ADDRESS.toLowerCase(); + + const result = encodeFunctionData(erc20Interface, 'balanceOf', [ + lowercaseAddress, + ]); + + expect(result).toBe( + erc20Interface.encodeFunctionData('balanceOf', [ACCOUNT_ADDRESS]), + ); + }); + + it('throws when the function does not exist', () => { + expect(() => + encodeFunctionData(erc20Interface, 'nonExistentFunction', []), + ).toThrow( + 'no matching function (argument="name", value="nonExistentFunction", code=INVALID_ARGUMENT, version=abi/5.7.0)', + ); + }); + }); +}); diff --git a/packages/controller-utils/src/abi.ts b/packages/controller-utils/src/abi.ts new file mode 100644 index 00000000000..8eb118bcb44 --- /dev/null +++ b/packages/controller-utils/src/abi.ts @@ -0,0 +1,82 @@ +import { AbiCoder, Interface, ParamType } from '@ethersproject/abi'; +import { + concatBytes, + hexToBytes, + bytesToHex, + getChecksumAddress, + add0x, +} from '@metamask/utils'; +import type { Hex } from '@metamask/utils'; + +// Ethers does not export these unfortunately. +type Coder = ReturnType; +type Writer = Parameters[0]; +type Reader = Parameters[0]; + +// FastAddressCoder that skips checksumming addresses when encoding and uses the memoized `getChecksumAddress` for decoding. +class FastAddressCoder implements Coder { + name = 'address'; + + type = 'address'; + + dynamic = false; + + localName: string; + + constructor(localName: string) { + this.localName = localName; + } + + encode(writer: Writer, value: string): number { + return writer.writeValue(value); + } + + decode(reader: Reader): unknown { + const value = reader.readValue(); + const paddedHex = value.toHexString().slice(2).padStart(40); + return getChecksumAddress(add0x(paddedHex)); + } + + defaultValue(): string { + return '0x0000000000000000000000000000000000000000'; + } + + /* istanbul ignore next */ + _throwError(_message: string, _value: unknown): void { + throw new Error('Method not implemented.'); + } +} + +class FastAbiCoder extends AbiCoder { + _getCoder(param: ParamType): Coder { + if (param.type === 'address') { + return new FastAddressCoder(param.name); + } + return super._getCoder(param); + } +} + +const fastAbiCoder = new FastAbiCoder(); + +/** + * Encode the data required for a function call. + * + * Note: This uses `@ethersproject/abi` under the hood, but for improved + * performance, does not verify checksums of addresses. Make sure addresses + * passed to this function are valid and checksummed if necessary. + * + * @param abi - The ABI instance. + * @param functionName - The function name. + * @param values - The parameters to encode. + * @returns The encoded data for the function call in hexadecimal. + */ +export function encodeFunctionData( + abi: Interface, + functionName: string, + values: unknown[], +): Hex { + const func = abi.getFunction(functionName); + const sigHash = hexToBytes(abi.getSighash(func)); + const encodedParams = fastAbiCoder.encode(func.inputs, values); + return bytesToHex(concatBytes([sigHash, encodedParams])); +} diff --git a/packages/controller-utils/src/constants.ts b/packages/controller-utils/src/constants.ts index 09d60c8453d..85967701bf4 100644 --- a/packages/controller-utils/src/constants.ts +++ b/packages/controller-utils/src/constants.ts @@ -3,7 +3,8 @@ import { NetworksTicker, ChainId, BuiltInNetworkName, -} from './types'; + BlockExplorerUrl, +} from './types.js'; export const RPC = 'rpc'; export const FALL_BACK_VS_CURRENCY = 'ETH'; @@ -47,6 +48,25 @@ export const TESTNET_TICKER_SYMBOLS = { GOERLI: 'GoerliETH', SEPOLIA: 'SepoliaETH', LINEA_GOERLI: 'LineaETH', + LINEA_SEPOLIA: 'LineaETH', + MEGAETH_TESTNET: 'MegaETH', + MEGAETH_TESTNET_V2: 'MegaETH', +}; + +/** + * Map of all built-in custom networks to their RPC endpoints. + */ +export const BUILT_IN_CUSTOM_NETWORKS_RPC = { + /** + * @deprecated Please use `megaeth-testnet` instead. + */ + MEGAETH_TESTNET: 'https://carrot.megaeth.com/rpc', + /** + * @deprecated Please use `megaeth-testnet-v2` instead. + */ + 'megaeth-testnet': 'https://carrot.megaeth.com/rpc', + 'megaeth-testnet-v2': 'https://carrot.megaeth.com/rpc', + 'monad-testnet': 'https://testnet-rpc.monad.xyz', }; /** @@ -57,35 +77,133 @@ export const BUILT_IN_NETWORKS = { chainId: ChainId.goerli, ticker: NetworksTicker.goerli, rpcPrefs: { - blockExplorerUrl: `https://${NetworkType.goerli}.etherscan.io`, + blockExplorerUrl: BlockExplorerUrl.goerli, }, }, [NetworkType.sepolia]: { chainId: ChainId.sepolia, ticker: NetworksTicker.sepolia, rpcPrefs: { - blockExplorerUrl: `https://${NetworkType.sepolia}.etherscan.io`, + blockExplorerUrl: BlockExplorerUrl.sepolia, }, }, [NetworkType.mainnet]: { chainId: ChainId.mainnet, ticker: NetworksTicker.mainnet, rpcPrefs: { - blockExplorerUrl: 'https://etherscan.io', + blockExplorerUrl: BlockExplorerUrl.mainnet, }, }, [NetworkType['linea-goerli']]: { chainId: ChainId['linea-goerli'], ticker: NetworksTicker['linea-goerli'], rpcPrefs: { - blockExplorerUrl: 'https://goerli.lineascan.build', + blockExplorerUrl: BlockExplorerUrl['linea-goerli'], + }, + }, + [NetworkType['linea-sepolia']]: { + chainId: ChainId['linea-sepolia'], + ticker: NetworksTicker['linea-sepolia'], + rpcPrefs: { + blockExplorerUrl: BlockExplorerUrl['linea-sepolia'], }, }, [NetworkType['linea-mainnet']]: { chainId: ChainId['linea-mainnet'], ticker: NetworksTicker['linea-mainnet'], rpcPrefs: { - blockExplorerUrl: 'https://lineascan.build', + blockExplorerUrl: BlockExplorerUrl['linea-mainnet'], + }, + }, + [NetworkType['megaeth-testnet']]: { + chainId: ChainId['megaeth-testnet'], + ticker: NetworksTicker['megaeth-testnet'], + rpcPrefs: { + blockExplorerUrl: BlockExplorerUrl['megaeth-testnet'], + }, + }, + [NetworkType['megaeth-testnet-v2']]: { + chainId: ChainId['megaeth-testnet-v2'], + ticker: NetworksTicker['megaeth-testnet-v2'], + rpcPrefs: { + blockExplorerUrl: BlockExplorerUrl['megaeth-testnet-v2'], + }, + }, + [NetworkType['monad-testnet']]: { + chainId: ChainId['monad-testnet'], + ticker: NetworksTicker['monad-testnet'], + rpcPrefs: { + blockExplorerUrl: BlockExplorerUrl['monad-testnet'], + }, + }, + [NetworkType['base-mainnet']]: { + chainId: ChainId['base-mainnet'], + ticker: NetworksTicker['base-mainnet'], + rpcPrefs: { + blockExplorerUrl: BlockExplorerUrl['base-mainnet'], + }, + }, + [NetworkType['arbitrum-mainnet']]: { + chainId: ChainId['arbitrum-mainnet'], + ticker: NetworksTicker['arbitrum-mainnet'], + rpcPrefs: { + blockExplorerUrl: BlockExplorerUrl['arbitrum-mainnet'], + }, + }, + [NetworkType['bsc-mainnet']]: { + chainId: ChainId['bsc-mainnet'], + ticker: NetworksTicker['bsc-mainnet'], + rpcPrefs: { + blockExplorerUrl: BlockExplorerUrl['bsc-mainnet'], + }, + }, + [NetworkType['optimism-mainnet']]: { + chainId: ChainId['optimism-mainnet'], + ticker: NetworksTicker['optimism-mainnet'], + rpcPrefs: { + blockExplorerUrl: BlockExplorerUrl['optimism-mainnet'], + }, + }, + [NetworkType['polygon-mainnet']]: { + chainId: ChainId['polygon-mainnet'], + ticker: NetworksTicker['polygon-mainnet'], + rpcPrefs: { + blockExplorerUrl: BlockExplorerUrl['polygon-mainnet'], + }, + }, + [NetworkType['sei-mainnet']]: { + chainId: ChainId['sei-mainnet'], + ticker: NetworksTicker['sei-mainnet'], + rpcPrefs: { + blockExplorerUrl: BlockExplorerUrl['sei-mainnet'], + }, + }, + [NetworkType['monad-mainnet']]: { + chainId: ChainId['monad-mainnet'], + ticker: NetworksTicker['monad-mainnet'], + rpcPrefs: { + blockExplorerUrl: BlockExplorerUrl['monad-mainnet'], + }, + }, + [NetworkType['zksync-mainnet']]: { + chainId: ChainId['zksync-mainnet'], + ticker: NetworksTicker['zksync-mainnet'], + rpcPrefs: { + blockExplorerUrl: BlockExplorerUrl['zksync-mainnet'], + }, + }, + [NetworkType['megaeth-mainnet']]: { + chainId: ChainId['megaeth-mainnet'], + ticker: NetworksTicker['megaeth-mainnet'], + rpcPrefs: { + blockExplorerUrl: BlockExplorerUrl['megaeth-mainnet'], + }, + }, + [NetworkType['avalanche-mainnet']]: { + chainId: ChainId['avalanche-mainnet'], + ticker: NetworksTicker['avalanche-mainnet'], + rpcPrefs: { + blockExplorerUrl: BlockExplorerUrl['avalanche-mainnet'], }, }, [NetworkType.rpc]: { @@ -96,11 +214,31 @@ export const BUILT_IN_NETWORKS = { }, } as const; +/** + * When a user adds a custom network to MetaMask, we perform some basic + * validations on the network. For instance, usually a network cannot share the + * same chain as another. In some cases, however, we want to allow networks that + * would normally be invalid. This mapping contains networks that should bypass + * validation. + */ +export const NETWORKS_BYPASSING_VALIDATION = { + // HyperEVM uses the same chain ID as Wanchain + '0x3e7': { + name: 'HyperEVM', + symbol: 'HYPE', + rpcUrl: 'https://rpc.hyperliquid.xyz', + }, +}; + // APIs export const OPENSEA_PROXY_URL = - 'https://proxy.metafi.codefi.network/opensea/v1/api/v1'; -export const OPENSEA_API_URL = 'https://api.opensea.io/api/v1'; -export const OPENSEA_TEST_API_URL = 'https://testnets-api.opensea.io/api/v1'; + 'https://proxy.api.cx.metamask.io/opensea/v1/api/v2'; + +export const NFT_API_BASE_URL = 'https://nft.api.cx.metamask.io'; + +export const NFT_API_VERSION = '1'; + +export const NFT_API_TIMEOUT = 15000; // Default origin for controllers export const ORIGIN_METAMASK = 'metamask'; @@ -115,7 +253,6 @@ export enum ApprovalType { ConnectAccounts = 'connect_accounts', EthDecrypt = 'eth_decrypt', EthGetEncryptionPublicKey = 'eth_getEncryptionPublicKey', - EthSign = 'eth_sign', EthSignTypedData = 'eth_signTypedData', PersonalSign = 'personal_sign', ResultError = 'result_error', @@ -123,22 +260,80 @@ export enum ApprovalType { SnapDialogAlert = 'snap_dialog:alert', SnapDialogConfirmation = 'snap_dialog:confirmation', SnapDialogPrompt = 'snap_dialog:prompt', + SnapDialogDefault = 'snap_dialog', SwitchEthereumChain = 'wallet_switchEthereumChain', Transaction = 'transaction', + TransactionBatch = 'transaction_batch', Unlock = 'unlock', WalletConnect = 'wallet_connect', WalletRequestPermissions = 'wallet_requestPermissions', WatchAsset = 'wallet_watchAsset', } +/** + * Mapping of chain IDs to their network names for ENS functionality. + * Note: MegaETH-testnet is intentionally excluded from this mapping as it doesn't support ENS. + */ export const CHAIN_ID_TO_ETHERS_NETWORK_NAME_MAP: Record< - ChainId, + string, BuiltInNetworkName > = { [ChainId.goerli]: BuiltInNetworkName.Goerli, [ChainId.sepolia]: BuiltInNetworkName.Sepolia, [ChainId.mainnet]: BuiltInNetworkName.Mainnet, [ChainId['linea-goerli']]: BuiltInNetworkName.LineaGoerli, + [ChainId['linea-sepolia']]: BuiltInNetworkName.LineaSepolia, [ChainId['linea-mainnet']]: BuiltInNetworkName.LineaMainnet, [ChainId.aurora]: BuiltInNetworkName.Aurora, }; + +/** + * The number of milliseconds in a second. + */ +export const SECOND = 1000; + +/** + * The number of milliseconds in a second. + */ +export const SECONDS = SECOND; + +/** + * The number of milliseconds in a minute. + */ +export const MINUTE = SECONDS * 60; + +/** + * The number of milliseconds in a minute. + */ +export const MINUTES = MINUTE; + +/** + * The number of milliseconds in a hour. + */ +export const HOUR = MINUTES * 60; + +/** + * The number of milliseconds in a hour. + */ +export const HOURS = HOUR; + +/** + * The number of milliseconds in a day. + */ +export const DAY = HOURS * 24; + +/** + * The number of milliseconds in a day. + */ +export const DAYS = DAY; + +/** + * Special "EVM-ish" chains with no native tokens. + * Created for Tempo, but can be extended to others. + * - For hidding the native token from the token list and Send list. + * - For excluding the native token from the total wallet value calculation. + */ +export const CHAIN_IDS_WITH_NO_NATIVE_TOKEN = [ + 'eip155:42431', // Tempo Testnet + 'eip155:4217', // Tempo Mainnet +] as const; diff --git a/packages/controller-utils/src/create-service-policy.test.ts b/packages/controller-utils/src/create-service-policy.test.ts new file mode 100644 index 00000000000..0c98fa7798a --- /dev/null +++ b/packages/controller-utils/src/create-service-policy.test.ts @@ -0,0 +1,964 @@ +import { CircuitState, ConstantBackoff, handleWhen } from 'cockatiel'; + +import { + createServicePolicy, + DEFAULT_CIRCUIT_BREAK_DURATION, + DEFAULT_DEGRADED_THRESHOLD, + DEFAULT_MAX_CONSECUTIVE_FAILURES, + DEFAULT_MAX_RETRIES, + ServicePolicy, +} from './create-service-policy.js'; + +describe('createServicePolicy', () => { + beforeEach(() => { + jest.useFakeTimers({ doNotFake: ['nextTick', 'queueMicrotask'] }); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + describe('execute', () => { + describe('when the service succeeds at least on the first attempt', () => { + it('returns what the service returns', async () => { + const policy = createServicePolicy(); + const result = await policy.execute(() => ({ some: 'data' })); + expect(result).toStrictEqual({ some: 'data' }); + }); + + it('fires onAvailable on the first successful execution and not again on subsequent successful executions', async () => { + const mockService = jest.fn(); + const onAvailableListener = jest.fn(); + const policy = createServicePolicy(); + policy.onAvailable(onAvailableListener); + + await policy.execute(mockService); + await policy.execute(mockService); + await policy.execute(mockService); + + expect(onAvailableListener).toHaveBeenCalledTimes(1); + }); + + it('does not fire onDegraded when the service responds within the degraded threshold', async () => { + const onDegradedListener = jest.fn(); + const policy = createServicePolicy(); + policy.onDegraded(onDegradedListener); + + await policy.execute(jest.fn()); + + expect(onDegradedListener).not.toHaveBeenCalled(); + }); + + it('fires onDegraded when the service takes longer than the degraded threshold', async () => { + const degradedThreshold = 2_000; + const delay = degradedThreshold + 1; + const mockService = jest.fn( + () => + new Promise((resolve) => setTimeout(() => resolve(), delay)), + ); + const onDegradedListener = jest.fn(); + const policy = createServicePolicy({ + degradedThreshold, + }); + policy.onDegraded(onDegradedListener); + + const promise = policy.execute(mockService); + jest.advanceTimersByTime(delay); + await promise; + + expect(onDegradedListener).toHaveBeenCalledTimes(1); + }); + + it('does not fire onAvailable when the service takes longer than the degraded threshold', async () => { + const degradedThreshold = 2_000; + const delay = degradedThreshold + 1; + const mockService = jest.fn( + () => + new Promise((resolve) => setTimeout(() => resolve(), delay)), + ); + const onAvailableListener = jest.fn(); + const policy = createServicePolicy({ + degradedThreshold, + }); + policy.onAvailable(onAvailableListener); + + const promise = policy.execute(mockService); + jest.advanceTimersByTime(delay); + await promise; + + expect(onAvailableListener).not.toHaveBeenCalled(); + }); + + it('uses the default degraded threshold when none is provided', async () => { + const delay = DEFAULT_DEGRADED_THRESHOLD + 1; + const mockService = jest.fn( + () => + new Promise((resolve) => setTimeout(() => resolve(), delay)), + ); + const onDegradedListener = jest.fn(); + const policy = createServicePolicy(); + policy.onDegraded(onDegradedListener); + + const promise = policy.execute(mockService); + jest.advanceTimersByTime(delay); + await promise; + + expect(onDegradedListener).toHaveBeenCalledTimes(1); + }); + + it('does not fire onBreak', async () => { + const onBreakListener = jest.fn(); + const policy = createServicePolicy(); + policy.onBreak(onBreakListener); + + await policy.execute(jest.fn()); + + expect(onBreakListener).not.toHaveBeenCalled(); + }); + }); + + describe('when the service throws an error which has an httpStatus property', () => { + it('treats errors with httpStatus >= 500 as service failures, making them circuit-breakable', async () => { + const error = Object.assign(new Error('server error'), { + httpStatus: 500, + }); + const mockService = createErroringService({ error }); + const onBreakListener = jest.fn(); + const policy = createServicePolicyForTestingRetries({ + breakAfterFirstExecution: true, + }); + policy.onBreak(onBreakListener); + + await ignoreRejection(policy.execute(mockService)); + + expect(onBreakListener).toHaveBeenCalledTimes(1); + }); + + it('treats errors with httpStatus < 500 as non-service failures, making them non-circuit-breakable', async () => { + const error = Object.assign(new Error('client error'), { + httpStatus: 404, + }); + const mockService = createErroringService({ error }); + const onBreakListener = jest.fn(); + const policy = createServicePolicyForTestingRetries({ + breakAfterFirstExecution: true, + }); + policy.onBreak(onBreakListener); + + await ignoreRejection(policy.execute(mockService)); + + expect(onBreakListener).not.toHaveBeenCalled(); + }); + }); + + describe('when the service always throws', () => { + describe.each([ + { + desc: `using a default maxRetries (of ${DEFAULT_MAX_RETRIES})`, + maxRetries: DEFAULT_MAX_RETRIES, + options: {}, + }, + { + desc: 'using a custom maxRetries', + maxRetries: 5, + options: { maxRetries: 5 }, + }, + ])('using $desc', ({ maxRetries, options }) => { + it(`calls the service maxRetries + 1 times`, async () => { + const mockService = createErroringService(); + const policy = createServicePolicyForTestingRetries({ options }); + + await ignoreRejection(policy.execute(mockService)); + + expect(mockService).toHaveBeenCalledTimes(maxRetries + 1); + }); + + it('fires onRetry once per retry', async () => { + const mockService = createErroringService(); + const onRetryListener = jest.fn().mockImplementation(() => { + jest.advanceTimersToNextTimer(); + }); + const policy = createServicePolicyForTestingRetries({ + options, + onRetryListener, + }); + + await ignoreRejection(policy.execute(mockService)); + + expect(onRetryListener).toHaveBeenCalledTimes(maxRetries); + }); + }); + + describe('when a single retry round does not break the circuit', () => { + // Setting the number of attempts (maxRetries + 1) less than the + // maximum number of consecutive failures causes the circuit to stay + // closed even after calling `.execute` once + const maxRetries = 2; + const maxConsecutiveFailures = 4; + + it('throws the original error', async () => { + const error = new Error('failure'); + const mockService = createErroringService({ error }); + const policy = createServicePolicyForTestingRetries({ + options: { + maxRetries, + maxConsecutiveFailures, + }, + }); + + await expect(policy.execute(mockService)).rejects.toThrow(error); + }); + + it('does not fire onAvailable', async () => { + const mockService = createErroringService(); + const onAvailableListener = jest.fn(); + const policy = createServicePolicyForTestingRetries({ + options: { + maxRetries, + maxConsecutiveFailures, + }, + }); + policy.onAvailable(onAvailableListener); + + await ignoreRejection(policy.execute(mockService)); + + expect(onAvailableListener).not.toHaveBeenCalled(); + }); + + it('fires onDegraded with the error', async () => { + const error = new Error('failure'); + const mockService = createErroringService({ error }); + const onDegradedListener = jest.fn(); + const policy = createServicePolicyForTestingRetries({ + options: { + maxRetries, + maxConsecutiveFailures, + }, + }); + policy.onDegraded(onDegradedListener); + + await ignoreRejection(policy.execute(mockService)); + + expect(onDegradedListener).toHaveBeenCalledTimes(1); + expect(onDegradedListener).toHaveBeenCalledWith({ error }); + }); + + it('does not fire onBreak', async () => { + const mockService = createErroringService(); + const onBreakListener = jest.fn(); + const policy = createServicePolicyForTestingRetries({ + options: { + maxRetries, + maxConsecutiveFailures, + }, + }); + policy.onBreak(onBreakListener); + + await ignoreRejection(policy.execute(mockService)); + + expect(onBreakListener).not.toHaveBeenCalled(); + }); + }); + + describe('when a single retry round breaks the circuit after the last attempt', () => { + // Setting maxConsecutiveFailures equal to maxRetries + 1 causes the + // circuit to open after calling `.execute` only once + const maxRetries = 2; + const maxConsecutiveFailures = 3; + + it('throws the original error', async () => { + const error = new Error('failure'); + const mockService = createErroringService({ error }); + const policy = createServicePolicyForTestingRetries({ + options: { + maxRetries, + maxConsecutiveFailures, + }, + }); + + await expect(policy.execute(mockService)).rejects.toThrow(error); + }); + + it('does not fire onAvailable', async () => { + const mockService = createErroringService(); + const onAvailableListener = jest.fn(); + const policy = createServicePolicyForTestingRetries({ + options: { + maxRetries, + maxConsecutiveFailures, + }, + }); + policy.onAvailable(onAvailableListener); + + await ignoreRejection(policy.execute(mockService)); + + expect(onAvailableListener).not.toHaveBeenCalled(); + }); + + it('does not fire onDegraded', async () => { + const mockService = createErroringService(); + const onDegradedListener = jest.fn(); + const policy = createServicePolicyForTestingRetries({ + options: { + maxRetries, + maxConsecutiveFailures, + }, + }); + policy.onDegraded(onDegradedListener); + + await ignoreRejection(policy.execute(mockService)); + + expect(onDegradedListener).not.toHaveBeenCalled(); + }); + + it('fires onBreak', async () => { + const mockService = createErroringService(); + const onBreakListener = jest.fn(); + const policy = createServicePolicyForTestingRetries({ + options: { + maxRetries, + maxConsecutiveFailures, + }, + }); + policy.onBreak(onBreakListener); + + await ignoreRejection(policy.execute(mockService)); + + expect(onBreakListener).toHaveBeenCalledTimes(1); + }); + + it('throws BrokenCircuitError on the next service execution', async () => { + const mockService = createErroringService(); + const policy = createServicePolicyForTestingRetries({ + options: { + maxRetries, + maxConsecutiveFailures, + }, + }); + + await ignoreRejection(policy.execute(mockService)); + + await expect(policy.execute(mockService)).rejects.toThrow( + 'Execution prevented because the circuit breaker is open', + ); + }); + }); + + describe('when a single retry round breaks the circuit before reaching the max number of retries', () => { + // Setting the number of attempts (maxRetries + 1) greater than the + // maximum number of consecutive failures causes the circuit to break + // before the last attempt is reached + const maxRetries = 3; + const maxConsecutiveFailures = 3; + + it('throws BrokenCircuitError', async () => { + const mockService = createErroringService(); + const policy = createServicePolicyForTestingRetries({ + options: { + maxRetries, + maxConsecutiveFailures, + }, + }); + + await expect(policy.execute(mockService)).rejects.toThrow( + 'Execution prevented because the circuit breaker is open', + ); + }); + + it('does not fire onAvailable', async () => { + const mockService = createErroringService(); + const onAvailableListener = jest.fn(); + const policy = createServicePolicyForTestingRetries({ + options: { + maxRetries, + maxConsecutiveFailures, + }, + }); + policy.onAvailable(onAvailableListener); + + await ignoreRejection(policy.execute(mockService)); + + expect(onAvailableListener).not.toHaveBeenCalled(); + }); + + it('does not fire onDegraded', async () => { + const mockService = createErroringService(); + const onDegradedListener = jest.fn(); + const policy = createServicePolicyForTestingRetries({ + options: { + maxRetries, + maxConsecutiveFailures, + }, + }); + policy.onDegraded(onDegradedListener); + + await ignoreRejection(policy.execute(mockService)); + + expect(onDegradedListener).not.toHaveBeenCalled(); + }); + + it('fires onBreak', async () => { + const mockService = createErroringService(); + const onBreakListener = jest.fn(); + const policy = createServicePolicyForTestingRetries({ + options: { + maxRetries, + maxConsecutiveFailures, + }, + }); + policy.onBreak(onBreakListener); + + await ignoreRejection(policy.execute(mockService)); + + expect(onBreakListener).toHaveBeenCalledTimes(1); + }); + }); + }); + + describe('when the service throws at first but succeeds on the final attempt', () => { + it('returns the eventual successful result from the service', async () => { + const mockService = createErroringService({ + failUntilNthAttempt: DEFAULT_MAX_RETRIES + 1, + }); + const policy = createServicePolicyForTestingRetries(); + + const result = await policy.execute(mockService); + + expect(result).toStrictEqual({ some: 'data' }); + }); + + it('fires onAvailable on the first successful (fast) execution and not again on subsequent successful (fast) executions', async () => { + const mockService = createErroringService({ + failUntilNthAttempt: DEFAULT_MAX_RETRIES + 1, + }); + const onAvailableListener = jest.fn(); + const policy = createServicePolicyForTestingRetries(); + policy.onAvailable(onAvailableListener); + + await policy.execute(mockService); + await policy.execute(() => { + // dummy function + }); + + expect(onAvailableListener).toHaveBeenCalledTimes(1); + }); + + it('does not fire onDegraded if the final attempt takes less time than the degraded threshold', async () => { + const mockService = createErroringService({ + failUntilNthAttempt: DEFAULT_MAX_RETRIES + 1, + }); + const onDegradedListener = jest.fn(); + const policy = createServicePolicyForTestingRetries(); + policy.onDegraded(onDegradedListener); + + await policy.execute(mockService); + + expect(onDegradedListener).not.toHaveBeenCalled(); + }); + + it('fires onDegraded when the final attempt takes longer than the degraded threshold', async () => { + const degradedThreshold = 2_000; + const delay = degradedThreshold + 1; + let attempts = 0; + const mockService = jest.fn( + () => + new Promise<{ some: string }>((resolve, reject) => { + attempts += 1; + if (attempts === 1 + DEFAULT_MAX_RETRIES) { + setTimeout(() => resolve({ some: 'data' }), delay); + } else { + reject(new Error('failure')); + } + }), + ); + const onDegradedListener = jest.fn(); + const policy = createServicePolicyForTestingRetries({ + options: { + degradedThreshold, + }, + }); + policy.onDegraded(onDegradedListener); + + const promise = policy.execute(mockService); + await jest.runAllTimersAsync(); + await promise; + + expect(onDegradedListener).toHaveBeenCalledTimes(1); + }); + + it('does not fire onAvailable when the final attempt takes longer than the degraded threshold', async () => { + const degradedThreshold = 2_000; + const delay = degradedThreshold + 1; + let attempts = 0; + const mockService = jest.fn( + () => + new Promise<{ some: string }>((resolve, reject) => { + attempts += 1; + if (attempts === 1 + DEFAULT_MAX_RETRIES) { + setTimeout(() => resolve({ some: 'data' }), delay); + } else { + reject(new Error('failure')); + } + }), + ); + const onAvailableListener = jest.fn(); + const policy = createServicePolicyForTestingRetries({ + options: { + degradedThreshold, + }, + }); + policy.onAvailable(onAvailableListener); + + const promise = policy.execute(mockService); + await jest.runAllTimersAsync(); + await promise; + + expect(onAvailableListener).not.toHaveBeenCalled(); + }); + }); + + describe('when the service fails enough times to break the circuit, then the circuit break duration elapses', () => { + it('returns what the service returns if it then succeeds', async () => { + // Setup + const circuitBreakDuration = 5_000; + let attempts = 0; + const mockService = jest.fn(() => { + attempts += 1; + if (attempts > DEFAULT_MAX_CONSECUTIVE_FAILURES) { + return { some: 'data' }; + } + throw new Error('failure'); + }); + const policy = createServicePolicyForTestingRetries({ + options: { + circuitBreakDuration, + }, + }); + + // Drive the circuit open, then advance past the circuit break duration + await ignoreRejection(policy.execute(mockService)); + await ignoreRejection(policy.execute(mockService)); + await ignoreRejection(policy.execute(mockService)); + jest.advanceTimersByTime(circuitBreakDuration); + + const result = await policy.execute(mockService); + expect(result).toStrictEqual({ some: 'data' }); + }); + + it('uses the default circuit break duration when none is provided', async () => { + // Setup + let attempts = 0; + const mockService = jest.fn(() => { + attempts += 1; + if (attempts > DEFAULT_MAX_CONSECUTIVE_FAILURES) { + return { some: 'data' }; + } + throw new Error('failure'); + }); + const policy = createServicePolicyForTestingRetries(); + + // Drive the circuit open, then advance past the circuit break duration + await ignoreRejection(policy.execute(mockService)); + await ignoreRejection(policy.execute(mockService)); + await ignoreRejection(policy.execute(mockService)); + jest.advanceTimersByTime(DEFAULT_CIRCUIT_BREAK_DURATION); + + const result = await policy.execute(mockService); + expect(result).toStrictEqual({ some: 'data' }); + }); + + it('fires onAvailable again after the circuit recovers', async () => { + // Setup + const circuitBreakDuration = 5_000; + let attempts = 0; + const mockService = jest.fn(() => { + attempts += 1; + if ( + attempts === 1 || + attempts > DEFAULT_MAX_CONSECUTIVE_FAILURES + 1 + ) { + return { some: 'data' }; + } + throw new Error('failure'); + }); + const onAvailableListener = jest.fn(); + const policy = createServicePolicyForTestingRetries({ + options: { + circuitBreakDuration, + }, + }); + policy.onAvailable(onAvailableListener); + + // Check that onAvailable fires at first for completeness + await policy.execute(mockService); + expect(onAvailableListener).toHaveBeenCalledTimes(1); + + // Drive the circuit open + await ignoreRejection(policy.execute(mockService)); + await ignoreRejection(policy.execute(mockService)); + await ignoreRejection(policy.execute(mockService)); + + // Recover + jest.advanceTimersByTime(circuitBreakDuration); + await policy.execute(mockService); + expect(onAvailableListener).toHaveBeenCalledTimes(2); + }); + + it('does not fire onAvailable again if the circuit recovers but then the service fails', async () => { + // Setup + const circuitBreakDuration = 5_000; + let attempts = 0; + const mockService = jest.fn(() => { + attempts += 1; + if (attempts === 1) { + return { some: 'data' }; + } + throw new Error('failure'); + }); + const onAvailableListener = jest.fn(); + const policy = createServicePolicyForTestingRetries({ + options: { + circuitBreakDuration, + }, + }); + policy.onAvailable(onAvailableListener); + + // Establish baseline + await policy.execute(mockService); + expect(onAvailableListener).toHaveBeenCalledTimes(1); + + // Drive the circuit open + await ignoreRejection(policy.execute(mockService)); + await ignoreRejection(policy.execute(mockService)); + await ignoreRejection(policy.execute(mockService)); + + // Recover + jest.advanceTimersByTime(circuitBreakDuration); + await ignoreRejection(policy.execute(mockService)); + expect(onAvailableListener).toHaveBeenCalledTimes(1); + }); + }); + + describe('using a custom retryFilterPolicy', () => { + it('throws the error immediately without retrying if retryFilterPolicy filters the error out', async () => { + const error = new Error('failure'); + const mockService = jest.fn(() => { + throw error; + }); + const policy = createServicePolicyForTestingRetries({ + options: { + retryFilterPolicy: handleWhen( + (caughtError) => caughtError.message !== 'failure', + ), + }, + }); + + await expect(policy.execute(mockService)).rejects.toThrow(error); + expect(mockService).toHaveBeenCalledTimes(1); + }); + + it('does not fire onRetry, onBreak, onDegraded, or onAvailable if retryFilterPolicy filters the error out', async () => { + const error = new Error('failure'); + const mockService = jest.fn(() => { + throw error; + }); + const onRetryListener = jest.fn(); + const onBreakListener = jest.fn(); + const onDegradedListener = jest.fn(); + const onAvailableListener = jest.fn(); + const policy = createServicePolicyForTestingRetries({ + options: { + retryFilterPolicy: handleWhen( + (caughtError) => caughtError.message !== 'failure', + ), + }, + }); + policy.onRetry(onRetryListener); + policy.onBreak(onBreakListener); + policy.onDegraded(onDegradedListener); + policy.onAvailable(onAvailableListener); + + await ignoreRejection(policy.execute(mockService)); + + expect(onRetryListener).not.toHaveBeenCalled(); + expect(onBreakListener).not.toHaveBeenCalled(); + expect(onDegradedListener).not.toHaveBeenCalled(); + expect(onAvailableListener).not.toHaveBeenCalled(); + }); + + it('throws the error after retrying if retryFilterPolicy filters the error in', async () => { + const error = new Error('failure'); + const mockService = jest.fn(() => { + throw error; + }); + const policy = createServicePolicyForTestingRetries({ + options: { + retryFilterPolicy: handleWhen( + (caughtError) => caughtError.message === 'failure', + ), + }, + }); + + await expect(policy.execute(mockService)).rejects.toThrow(error); + expect(mockService).toHaveBeenCalledTimes(DEFAULT_MAX_RETRIES + 1); + }); + + it('fires onRetry if retryFilterPolicy filters the error in', async () => { + const error = new Error('failure'); + const mockService = jest.fn(() => { + throw error; + }); + const onRetryListener = jest.fn(); + const policy = createServicePolicyForTestingRetries({ + options: { + retryFilterPolicy: handleWhen( + (caughtError) => caughtError.message === 'failure', + ), + }, + }); + policy.onRetry(onRetryListener); + + await ignoreRejection(policy.execute(mockService)); + expect(onRetryListener).toHaveBeenCalled(); + }); + }); + + describe('using a custom isServiceFailure predicate', () => { + it('opens the circuit when the predicate treats the error as a service failure', async () => { + const error = new Error('failure'); + const mockService = createErroringService({ error }); + const onBreakListener = jest.fn(); + const policy = createServicePolicyForTestingRetries({ + options: { + isServiceFailure: () => true, + }, + breakAfterFirstExecution: true, + }); + policy.onBreak(onBreakListener); + + await ignoreRejection(policy.execute(mockService)); + + expect(onBreakListener).toHaveBeenCalledTimes(1); + expect(onBreakListener).toHaveBeenCalledWith({ error }); + }); + + it('never opens the circuit when the predicate does not treat the error as a service failure', async () => { + const error = new Error('failure'); + const mockService = createErroringService({ error }); + const onBreakListener = jest.fn(); + const policy = createServicePolicyForTestingRetries({ + options: { + isServiceFailure: () => false, + }, + breakAfterFirstExecution: true, + }); + policy.onBreak(onBreakListener); + + await ignoreRejection(policy.execute(mockService)); + + expect(onBreakListener).not.toHaveBeenCalled(); + }); + }); + }); + + describe('getRemainingCircuitOpenDuration', () => { + it('returns null when the circuit is closed', () => { + const policy = createServicePolicyForTestingRetries(); + expect(policy.getRemainingCircuitOpenDuration()).toBeNull(); + }); + + it('returns the milliseconds remaining before the circuit transitions to half-open', async () => { + const policy = createServicePolicyForTestingRetries(); + + // Drive the circuit open + await ignoreRejection(policy.execute(createErroringService())); + await ignoreRejection(policy.execute(createErroringService())); + await ignoreRejection(policy.execute(createErroringService())); + jest.advanceTimersByTime(1_000); + + expect(policy.getRemainingCircuitOpenDuration()).toBe( + DEFAULT_CIRCUIT_BREAK_DURATION - 1_000, + ); + }); + }); + + describe('getCircuitState', () => { + it('tracks circuit state transitions: Closed → Open → HalfOpen → Open', async () => { + // Establish initial state + const policy = createServicePolicyForTestingRetries(); + expect(policy.getCircuitState()).toBe(CircuitState.Closed); + + // Drive the circuit open + await ignoreRejection(policy.execute(createErroringService())); + await ignoreRejection(policy.execute(createErroringService())); + await ignoreRejection(policy.execute(createErroringService())); + expect(policy.getCircuitState()).toBe(CircuitState.Open); + + // Advance to half-open + jest.advanceTimersByTime(DEFAULT_CIRCUIT_BREAK_DURATION); + const promise = ignoreRejection(policy.execute(createErroringService())); + expect(policy.getCircuitState()).toBe(CircuitState.HalfOpen); + await promise; + expect(policy.getCircuitState()).toBe(CircuitState.Open); + }); + }); + + describe('reset', () => { + it('transitions the circuit from Open to Closed', async () => { + const policy = createServicePolicyForTestingRetries(); + // Drive the circuit open + await ignoreRejection(policy.execute(createErroringService())); + await ignoreRejection(policy.execute(createErroringService())); + await ignoreRejection(policy.execute(createErroringService())); + expect(policy.getCircuitState()).toBe(CircuitState.Open); + + policy.reset(); + + expect(policy.getCircuitState()).toBe(CircuitState.Closed); + }); + + it('allows the service to succeed after the circuit was open', async () => { + let attempts = 0; + const mockService = jest.fn(() => { + attempts += 1; + if (attempts > DEFAULT_MAX_CONSECUTIVE_FAILURES) { + return { some: 'data' }; + } + throw new Error('failure'); + }); + const policy = createServicePolicyForTestingRetries(); + // Drive the circuit open + await ignoreRejection(policy.execute(mockService)); + await ignoreRejection(policy.execute(mockService)); + await ignoreRejection(policy.execute(mockService)); + + policy.reset(); + + expect(await policy.execute(mockService)).toStrictEqual({ some: 'data' }); + }); + + it('allows the service to fail again without throwing BrokenCircuitError', async () => { + const service = createErroringService(); + const policy = createServicePolicyForTestingRetries(); + // Drive the circuit open + await ignoreRejection(policy.execute(service)); + await ignoreRejection(policy.execute(service)); + await ignoreRejection(policy.execute(service)); + + policy.reset(); + + await expect(policy.execute(service)).rejects.toThrow('failure'); + }); + + it('fires onAvailable again after reset when the service succeeds', async () => { + // Setup + let attempts = 0; + const mockService = jest.fn(() => { + attempts += 1; + if (attempts === 1 || attempts > DEFAULT_MAX_CONSECUTIVE_FAILURES + 1) { + return { some: 'data' }; + } + throw new Error('failure'); + }); + const onAvailableListener = jest.fn(); + const policy = createServicePolicyForTestingRetries(); + policy.onAvailable(onAvailableListener); + + // Establish baseline + await policy.execute(mockService); + expect(onAvailableListener).toHaveBeenCalledTimes(1); + + // Drive the circuit open + await ignoreRejection(policy.execute(mockService)); + await ignoreRejection(policy.execute(mockService)); + await ignoreRejection(policy.execute(mockService)); + + // Reset + policy.reset(); + await policy.execute(mockService); + expect(onAvailableListener).toHaveBeenCalledTimes(2); + }); + }); +}); + +/** + * Some tests involve a rejected promise that is not necessarily the focus of + * the test. In these cases we don't want to ignore the error in case the + * promise _isn't_ rejected, but we don't want to highlight the assertion, + * either. + * + * @param promise - A promise that rejects. + */ +async function ignoreRejection(promise: Promise): Promise { + await expect(promise).rejects.toThrow(expect.any(Error)); +} + +/** + * Builds a service policy that takes care of some boilerplate when testing + * retries by: + * + * - using a zero-delay constant backoff so tests do not need to account for + * jitter when advancing timers + * - advancing timers automatically whenever retries occur + * - allowing for the service to break on first execution + * + * @param args - The arguments. + * @param args.options - Any additional options to pass to `createServicePolicy`. + * @param args.onRetryListener - The onRetry callback to register. + * @returns The service policy. + * @param args.breakAfterFirstExecution - Assuming that the service always + * error, causes the policy's circuit to break the first time the service is + * executed. + */ +function createServicePolicyForTestingRetries({ + options, + onRetryListener = (): ReturnType[0]> => + jest.advanceTimersToNextTimer(), + breakAfterFirstExecution = false, +}: { + options?: Parameters[0]; + onRetryListener?: Parameters[0]; + breakAfterFirstExecution?: boolean; +} = {}): ReturnType { + const policy = createServicePolicy({ + backoff: new ConstantBackoff(0), + ...(breakAfterFirstExecution + ? { maxRetries: 0, maxConsecutiveFailures: 1 } + : {}), + ...options, + }); + policy.onRetry(onRetryListener); + return policy; +} + +/** + * Builds a mock service that throws `error` on every call before some number of + * attempts, then returns `result`. + * + * @param options - Options. + * @param options.failUntilNthAttempt - The 1-based attempt number at which the + * service should start succeeding (default: Infinity — never succeeds). + * @param options.error - The error to throw on failure (default: `new + * Error('failure')`). + * @param options.result - The value to return on success (default: `{ some: + * 'data' }`). + * @returns A Jest mock function. + */ +function createErroringService({ + failUntilNthAttempt = Infinity, + error = new Error('failure'), + result = { some: 'data' }, +}: { + failUntilNthAttempt?: number; + error?: Error; + result?: unknown; +} = {}): jest.Mock { + let attempts = 0; + return jest.fn(() => { + attempts += 1; + if (attempts >= failUntilNthAttempt) { + return result; + } + throw error; + }); +} diff --git a/packages/controller-utils/src/create-service-policy.ts b/packages/controller-utils/src/create-service-policy.ts new file mode 100644 index 00000000000..17e2bf3bae9 --- /dev/null +++ b/packages/controller-utils/src/create-service-policy.ts @@ -0,0 +1,479 @@ +import { + BrokenCircuitError, + CircuitState, + EventEmitter as CockatielEventEmitter, + ConsecutiveBreaker, + ExponentialBackoff, + ConstantBackoff, + circuitBreaker, + handleAll, + handleWhen, + retry, + wrap, +} from 'cockatiel'; +import type { + CircuitBreakerPolicy, + Event as CockatielEvent, + FailureReason, + IBackoffFactory, + IPolicy, + Policy, + RetryPolicy, +} from 'cockatiel'; + +export { + /** + * @deprecated This is deprecated and will be removed in a future major + * version. Please use the equivalent type from `@metamask/base-data-service`. + */ + BrokenCircuitError, + /** + * @deprecated This is deprecated and will be removed in a future major + * version. Please use the equivalent type from `@metamask/base-data-service`. + */ + CockatielEventEmitter, + /** + * @deprecated This is deprecated and will be removed in a future major + * version. Please use the equivalent type from `@metamask/base-data-service`. + */ + CircuitState, + /** + * @deprecated This is deprecated and will be removed in a future major + * version. Please use the equivalent type from `@metamask/base-data-service`. + */ + ConstantBackoff, + /** + * @deprecated This is deprecated and will be removed in a future major + * version. Please use the equivalent type from `@metamask/base-data-service`. + */ + ExponentialBackoff, + /** + * @deprecated This is deprecated and will be removed in a future major + * version. Please use the equivalent function from + * `@metamask/base-data-service`. + */ + handleAll, + /** + * @deprecated This is deprecated and will be removed in a future major + * version. Please use the equivalent function from + * `@metamask/base-data-service`. + */ + handleWhen, +}; + +export type { + /** + * @deprecated This is deprecated and will be removed in a future major + * version. Please use the equivalent type from `@metamask/base-data-service`. + */ + CockatielEvent, + /** + * @deprecated This is deprecated and will be removed in a future major + * version. Please use the equivalent type from `@metamask/base-data-service`. + */ + FailureReason as CockatielFailureReason, +}; + +/** + * The options for `createServicePolicy`. + * + * @deprecated This is deprecated and will be removed in a future major version. + * Please use the equivalent type from `@metamask/base-data-service`. + */ +export type CreateServicePolicyOptions = { + /** + * The backoff strategy to use. Mainly useful for testing so that a constant + * backoff can be used when mocking timers. Defaults to an instance of + * ExponentialBackoff. + */ + backoff?: IBackoffFactory; + /** + * The length of time (in milliseconds) to pause retries of the action after + * the number of failures reaches `maxConsecutiveFailures`. + */ + circuitBreakDuration?: number; + /** + * The length of time (in milliseconds) that governs when the service is + * regarded as degraded (affecting when `onDegraded` is called). + */ + degradedThreshold?: number; + /** + * Predicate function for when an error should be considered a service failure. + */ + isServiceFailure?: (error: unknown) => boolean; + /** + * The maximum number of times that the service is allowed to fail before + * pausing further retries. + */ + maxConsecutiveFailures?: number; + /** + * The maximum number of times that a failing service should be re-invoked + * before giving up. + */ + maxRetries?: number; + /** + * The policy used to control when the service should be retried based on + * either the result of the service or an error that it throws. For instance, + * you could use this to retry only certain errors. See `handleWhen` and + * friends from Cockatiel for more. + */ + retryFilterPolicy?: Policy; +}; + +/** + * The service policy object. + * + * @deprecated This is deprecated and will be removed in a future major + * version. Please use the equivalent type from `@metamask/base-data-service`. + */ +export type ServicePolicy = IPolicy & { + /** + * The Cockatiel circuit breaker policy that the service policy uses + * internally. + */ + circuitBreakerPolicy: CircuitBreakerPolicy; + /** + * The amount of time to pause requests to the service if the number of + * maximum consecutive failures is reached. + */ + circuitBreakDuration: number; + /** + * @returns The state of the underlying circuit. + */ + getCircuitState: () => CircuitState; + /** + * If the circuit is open and ongoing requests are paused, returns the number + * of milliseconds before the requests will be attempted again. If the circuit + * is not open, returns null. + */ + getRemainingCircuitOpenDuration: () => number | null; + /** + * Resets the internal circuit breaker policy (if it is open, it will now be + * closed). + */ + reset: () => void; + /** + * The Cockatiel retry policy that the service policy uses internally. + */ + retryPolicy: RetryPolicy; + /** + * A function which is called when the number of times that the service fails + * in a row meets the set maximum number of consecutive failures. + */ + onBreak: CircuitBreakerPolicy['onBreak']; + /** + * A function which is called in two circumstances: 1) when the service + * succeeds before the maximum number of consecutive failures is reached, but + * takes more time than the `degradedThreshold` to run, or 2) if the service + * never succeeds before the retry policy gives up and before the maximum + * number of consecutive failures has been reached. + */ + onDegraded: CockatielEvent | { duration: number }>; + /** + * A function which is called when the service succeeds for the first time, + * or when the service fails enough times to cause the circuit to break and + * then recovers. + */ + onAvailable: CockatielEvent; + /** + * A function which will be called by the retry policy each time the service + * fails and the policy kicks off a timer to re-run the service. This is + * primarily useful in tests where we are mocking timers. + */ + onRetry: RetryPolicy['onRetry']; +}; + +/** + * Parts of the circuit breaker's internal and external state as necessary in + * order to compute the time remaining before the circuit will reopen. + */ +type InternalCircuitState = + | { + state: CircuitState.Open; + openedAt: number; + } + | { state: Exclude }; + +/** + * Availability statuses that the service can be in. + * + * Used to keep track of whether the `onAvailable` event should be fired. + */ +const AVAILABILITY_STATUSES = { + Available: 'available', + Degraded: 'degraded', + Unavailable: 'unavailable', + Unknown: 'unknown', +} as const; + +/** + * Availability statuses that the service can be in. + * + * Used to keep track of whether the `onAvailable` event should be fired. + */ +type AvailabilityStatus = + (typeof AVAILABILITY_STATUSES)[keyof typeof AVAILABILITY_STATUSES]; + +/** + * The maximum number of times that a failing service should be re-run before + * giving up. + * + * @deprecated This is deprecated and will be removed in a future major version. + * Please use the equivalent variable from `@metamask/base-data-service`. + */ +export const DEFAULT_MAX_RETRIES = 3; + +/** + * The maximum number of times that the service is allowed to fail before + * pausing further retries. This is set to a value such that if given a + * service that continually fails, the policy needs to be executed 3 times + * before further retries are paused. + * + * @deprecated This is deprecated and will be removed in a future major version. + * Please use the equivalent variable from `@metamask/base-data-service`. + */ +export const DEFAULT_MAX_CONSECUTIVE_FAILURES = (1 + DEFAULT_MAX_RETRIES) * 3; + +/** + * The default length of time (in milliseconds) to temporarily pause retries of + * the service after enough consecutive failures. + * + * @deprecated This is deprecated and will be removed in a future major version. + * Please use the equivalent variable from `@metamask/base-data-service`. + */ +export const DEFAULT_CIRCUIT_BREAK_DURATION = 30 * 60 * 1000; + +/** + * The default length of time (in milliseconds) that governs when the service is + * regarded as degraded (affecting when `onDegraded` is called). + * + * @deprecated This is deprecated and will be removed in a future major version. + * Please use the equivalent variable from `@metamask/base-data-service`. + */ +export const DEFAULT_DEGRADED_THRESHOLD = 5_000; + +const defaultIsServiceFailure = (error: unknown): boolean => { + if ( + typeof error === 'object' && + error !== null && + 'httpStatus' in error && + typeof error.httpStatus === 'number' + ) { + return error.httpStatus >= 500; + } + + // If the error is not an object, or doesn't have a numeric httpStatus + // property, consider it a service failure (e.g., network errors, timeouts, + // etc.) + return true; +}; + +/** + * The circuit breaker policy inside of the Cockatiel library exposes some of + * its state, but not all of it. Notably, the time that the circuit opened is + * not publicly accessible. So we have to record this ourselves. + * + * This function therefore allows us to obtain the circuit breaker state that we + * wish we could access. + * + * @param state - The public state of a circuit breaker policy. + * @returns if the circuit is open, the state of the circuit breaker policy plus + * the time that it opened, otherwise just the circuit state. + */ +function getInternalCircuitState(state: CircuitState): InternalCircuitState { + if (state === CircuitState.Open) { + return { state, openedAt: Date.now() }; + } + return { state }; +} + +/** + * Constructs an object exposing an `execute` method which, given a function — + * hereafter called the "service" — will retry that service with ever increasing + * delays until it succeeds. If the policy detects too many consecutive + * failures, it will block further retries until a designated time period has + * passed; this particular behavior is primarily designed for services that wrap + * API calls so as not to make needless HTTP requests when the API is down and + * to be able to recover when the API comes back up. In addition, hooks allow + * for responding to certain events, one of which can be used to detect when an + * HTTP request is performing slowly. + * + * Internally, this function makes use of the retry and circuit breaker policies + * from the [Cockatiel](https://www.npmjs.com/package/cockatiel) library; see + * there for more. + * + * @param options - The options to this function. See + * {@link CreateServicePolicyOptions}. + * @returns The service policy. + * @example + * This function is designed to be used in the context of a service class like + * this: + * ``` ts + * class Service { + * constructor() { + * this.#policy = createServicePolicy({ + * maxRetries: 3, + * retryFilterPolicy: handleWhen((error) => { + * return error.message.includes('oops'); + * }), + * maxConsecutiveFailures: 3, + * circuitBreakDuration: 5000, + * degradedThreshold: 2000, + * onBreak: () => { + * console.log('Circuit broke'); + * }, + * onDegraded: () => { + * console.log('Service is degraded'); + * }, + * }); + * } + * + * async fetch() { + * return await this.#policy.execute(async () => { + * const response = await fetch('https://some/url'); + * return await response.json(); + * }); + * } + * } + * ``` + * + * @deprecated This is deprecated and will be removed in a future major version. + * Please use the equivalent function from `@metamask/base-data-service`. + */ +export function createServicePolicy( + options: CreateServicePolicyOptions = {}, +): ServicePolicy { + const { + maxRetries = DEFAULT_MAX_RETRIES, + retryFilterPolicy = handleAll, + maxConsecutiveFailures = DEFAULT_MAX_CONSECUTIVE_FAILURES, + circuitBreakDuration = DEFAULT_CIRCUIT_BREAK_DURATION, + degradedThreshold = DEFAULT_DEGRADED_THRESHOLD, + backoff = new ExponentialBackoff(), + isServiceFailure = defaultIsServiceFailure, + } = options; + + let availabilityStatus: AvailabilityStatus = AVAILABILITY_STATUSES.Unknown; + + const retryPolicy = retry(retryFilterPolicy, { + // Note that although the option here is called "max attempts", it's really + // maximum number of *retries* (attempts past the initial attempt). + maxAttempts: maxRetries, + // Retries of the service will be executed following ever increasing delays, + // determined by a backoff formula. + backoff, + }); + const onRetry = retryPolicy.onRetry.bind(retryPolicy); + + const consecutiveBreaker = new ConsecutiveBreaker(maxConsecutiveFailures); + const circuitBreakerPolicy = circuitBreaker(handleWhen(isServiceFailure), { + // While the circuit is open, any additional invocations of the service + // passed to the policy (either via automatic retries or by manually + // executing the policy again) will result in a BrokenCircuitError. This + // will remain the case until `circuitBreakDuration` passes, after which the + // service will be allowed to run again. If the service succeeds, the + // circuit will close, otherwise it will remain open. + halfOpenAfter: circuitBreakDuration, + breaker: consecutiveBreaker, + }); + + let internalCircuitState: InternalCircuitState = getInternalCircuitState( + circuitBreakerPolicy.state, + ); + circuitBreakerPolicy.onStateChange((state) => { + internalCircuitState = getInternalCircuitState(state); + }); + + circuitBreakerPolicy.onBreak(() => { + availabilityStatus = AVAILABILITY_STATUSES.Unavailable; + }); + const onBreak = circuitBreakerPolicy.onBreak.bind(circuitBreakerPolicy); + + const onDegradedEventEmitter = new CockatielEventEmitter< + FailureReason | { duration: number } + >(); + const onDegraded = onDegradedEventEmitter.addListener; + + const onAvailableEventEmitter = new CockatielEventEmitter(); + const onAvailable = onAvailableEventEmitter.addListener; + + retryPolicy.onGiveUp((data) => { + if (circuitBreakerPolicy.state === CircuitState.Closed) { + availabilityStatus = AVAILABILITY_STATUSES.Degraded; + onDegradedEventEmitter.emit(data); + } + }); + retryPolicy.onSuccess(({ duration }) => { + if (circuitBreakerPolicy.state === CircuitState.Closed) { + if (duration > degradedThreshold) { + availabilityStatus = AVAILABILITY_STATUSES.Degraded; + onDegradedEventEmitter.emit({ duration }); + } else if (availabilityStatus !== AVAILABILITY_STATUSES.Available) { + availabilityStatus = AVAILABILITY_STATUSES.Available; + onAvailableEventEmitter.emit(); + } + } + }); + + // Every time the retry policy makes an attempt, it executes the circuit + // breaker policy, which executes the service. + // + // Calling: + // + // policy.execute(() => { + // // do what the service does + // }) + // + // is equivalent to: + // + // retryPolicy.execute(() => { + // circuitBreakerPolicy.execute(() => { + // // do what the service does + // }); + // }); + // + // So if the retry policy succeeds or fails, it is because the circuit breaker + // policy succeeded or failed. And if there are any event listeners registered + // on the retry policy, by the time they are called, the state of the circuit + // breaker will have already changed. + const policy = wrap(retryPolicy, circuitBreakerPolicy); + + const getRemainingCircuitOpenDuration = (): number | null => { + if (internalCircuitState.state === CircuitState.Open) { + return internalCircuitState.openedAt + circuitBreakDuration - Date.now(); + } + return null; + }; + + const getCircuitState = (): CircuitState => { + return circuitBreakerPolicy.state; + }; + + const reset = (): void => { + // Set the state of the policy to "isolated" regardless of its current state + const { dispose } = circuitBreakerPolicy.isolate(); + // Reset the state to "closed" + dispose(); + + // Reset the counter on the breaker as well + consecutiveBreaker.success(); + + // Re-initialize the availability status so that if the service is executed + // successfully, onAvailable listeners will be called again + availabilityStatus = AVAILABILITY_STATUSES.Unknown; + }; + + return { + ...policy, + circuitBreakerPolicy, + circuitBreakDuration, + getCircuitState, + getRemainingCircuitOpenDuration, + reset, + retryPolicy, + onBreak, + onDegraded, + onAvailable, + onRetry, + }; +} diff --git a/packages/controller-utils/src/index.test.ts b/packages/controller-utils/src/index.test.ts new file mode 100644 index 00000000000..85751e24303 --- /dev/null +++ b/packages/controller-utils/src/index.test.ts @@ -0,0 +1,101 @@ +import * as allExports from './index.js'; + +describe('@metamask/controller-utils', () => { + it('has expected JavaScript exports', () => { + expect(Object.keys(allExports)).toMatchInlineSnapshot(` + [ + "encodeFunctionData", + "BrokenCircuitError", + "CircuitState", + "CockatielEventEmitter", + "ConstantBackoff", + "DEFAULT_CIRCUIT_BREAK_DURATION", + "DEFAULT_DEGRADED_THRESHOLD", + "DEFAULT_MAX_CONSECUTIVE_FAILURES", + "DEFAULT_MAX_RETRIES", + "ExponentialBackoff", + "createServicePolicy", + "handleAll", + "handleWhen", + "RPC", + "FALL_BACK_VS_CURRENCY", + "IPFS_DEFAULT_GATEWAY_URL", + "GANACHE_CHAIN_ID", + "MAX_SAFE_CHAIN_ID", + "ERC721", + "ERC1155", + "ERC20", + "ERC721_INTERFACE_ID", + "ERC721_METADATA_INTERFACE_ID", + "ERC721_ENUMERABLE_INTERFACE_ID", + "ERC1155_INTERFACE_ID", + "ERC1155_METADATA_URI_INTERFACE_ID", + "ERC1155_TOKEN_RECEIVER_INTERFACE_ID", + "GWEI", + "ASSET_TYPES", + "TESTNET_TICKER_SYMBOLS", + "BUILT_IN_CUSTOM_NETWORKS_RPC", + "BUILT_IN_NETWORKS", + "OPENSEA_PROXY_URL", + "NFT_API_BASE_URL", + "NFT_API_VERSION", + "NFT_API_TIMEOUT", + "ORIGIN_METAMASK", + "ApprovalType", + "CHAIN_ID_TO_ETHERS_NETWORK_NAME_MAP", + "SECOND", + "SECONDS", + "MINUTE", + "MINUTES", + "HOUR", + "HOURS", + "DAY", + "DAYS", + "NETWORKS_BYPASSING_VALIDATION", + "CHAIN_IDS_WITH_NO_NATIVE_TOKEN", + "BNToHex", + "convertHexToDecimal", + "fetchWithErrorHandling", + "fractionBN", + "fromHex", + "getBuyURL", + "gweiDecToWEIBN", + "handleFetch", + "hexToBN", + "hexToText", + "HttpError", + "isNonEmptyArray", + "isPlainObject", + "isSafeChainId", + "isSafeDynamicKey", + "isSmartContractCode", + "isValidJson", + "isValidHexAddress", + "normalizeEnsName", + "query", + "safelyExecute", + "safelyExecuteWithTimeout", + "successfulFetch", + "timeoutFetch", + "toChecksumHexAddress", + "toHex", + "weiHexToGweiDec", + "isEqualCaseInsensitive", + "InfuraNetworkType", + "DEFAULT_INFURA_NETWORKS", + "CustomNetworkType", + "NetworkType", + "isNetworkType", + "isInfuraNetworkType", + "BuiltInNetworkName", + "ChainId", + "NetworksTicker", + "BlockExplorerUrl", + "NetworkNickname", + "parseDomainParts", + "isValidSIWEOrigin", + "detectSIWE", + ] + `); + }); +}); diff --git a/packages/controller-utils/src/index.ts b/packages/controller-utils/src/index.ts index 14a22e8e00e..bd0061e5415 100644 --- a/packages/controller-utils/src/index.ts +++ b/packages/controller-utils/src/index.ts @@ -1,4 +1,92 @@ -export * from './constants'; -export * from './util'; -export * from './types'; -export * from './siwe'; +export { encodeFunctionData } from './abi.js'; +export { + BrokenCircuitError, + CircuitState, + CockatielEventEmitter, + ConstantBackoff, + DEFAULT_CIRCUIT_BREAK_DURATION, + DEFAULT_DEGRADED_THRESHOLD, + DEFAULT_MAX_CONSECUTIVE_FAILURES, + DEFAULT_MAX_RETRIES, + ExponentialBackoff, + createServicePolicy, + handleAll, + handleWhen, +} from './create-service-policy.js'; +export type { + CockatielEvent, + CreateServicePolicyOptions, + CockatielFailureReason, + ServicePolicy, +} from './create-service-policy.js'; +export { + RPC, + FALL_BACK_VS_CURRENCY, + IPFS_DEFAULT_GATEWAY_URL, + GANACHE_CHAIN_ID, + MAX_SAFE_CHAIN_ID, + ERC721, + ERC1155, + ERC20, + ERC721_INTERFACE_ID, + ERC721_METADATA_INTERFACE_ID, + ERC721_ENUMERABLE_INTERFACE_ID, + ERC1155_INTERFACE_ID, + ERC1155_METADATA_URI_INTERFACE_ID, + ERC1155_TOKEN_RECEIVER_INTERFACE_ID, + GWEI, + ASSET_TYPES, + TESTNET_TICKER_SYMBOLS, + BUILT_IN_CUSTOM_NETWORKS_RPC, + BUILT_IN_NETWORKS, + OPENSEA_PROXY_URL, + NFT_API_BASE_URL, + NFT_API_VERSION, + NFT_API_TIMEOUT, + ORIGIN_METAMASK, + ApprovalType, + CHAIN_ID_TO_ETHERS_NETWORK_NAME_MAP, + SECOND, + SECONDS, + MINUTE, + MINUTES, + HOUR, + HOURS, + DAY, + DAYS, + NETWORKS_BYPASSING_VALIDATION, + CHAIN_IDS_WITH_NO_NATIVE_TOKEN, +} from './constants.js'; +export type { NonEmptyArray } from './util.js'; +export { + BNToHex, + convertHexToDecimal, + fetchWithErrorHandling, + fractionBN, + fromHex, + getBuyURL, + gweiDecToWEIBN, + handleFetch, + hexToBN, + hexToText, + HttpError, + isNonEmptyArray, + isPlainObject, + isSafeChainId, + isSafeDynamicKey, + isSmartContractCode, + isValidJson, + isValidHexAddress, + normalizeEnsName, + query, + safelyExecute, + safelyExecuteWithTimeout, + successfulFetch, + timeoutFetch, + toChecksumHexAddress, + toHex, + weiHexToGweiDec, + isEqualCaseInsensitive, +} from './util.js'; +export * from './types.js'; +export * from './siwe.js'; diff --git a/packages/controller-utils/src/siwe.test.ts b/packages/controller-utils/src/siwe.test.ts index f7305d34d04..466fdd3844a 100644 --- a/packages/controller-utils/src/siwe.test.ts +++ b/packages/controller-utils/src/siwe.test.ts @@ -1,28 +1,33 @@ import { ParsedMessage } from '@spruceid/siwe-parser'; -import { detectSIWE, isValidSIWEOrigin } from './siwe'; +import { detectSIWE, isValidSIWEOrigin } from './siwe.js'; -const mockedParsedMessage = { - domain: 'example.eth', - address: '0x0000000', -}; - -jest.mock('@spruceid/siwe-parser'); +const siweMessage = + 'example.com wants you to sign in with your Ethereum account:\n0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2\n\n\nURI: https://example.com/login\nVersion: 1\nChain ID: 1\nNonce: 32891756\nIssued At: 2021-09-30T16:25:24Z'; +const parsedMessage = new ParsedMessage(siweMessage); describe('siwe', () => { describe('detectSIWE', () => { - const parsedMessageMock = ParsedMessage as any; + const textAsHex = (string: string): string => { + return Buffer.from(string, 'utf8').toString('hex'); + }; + it('returns an object with isSIWEMessage set to true and parsedMessage', () => { - parsedMessageMock.mockReturnValue(mockedParsedMessage); - const result = detectSIWE({ data: '0xVALIDDATA' }); + const result = detectSIWE({ data: textAsHex(siweMessage) }); + expect(result.isSIWEMessage).toBe(true); + expect(result.parsedMessage).toStrictEqual(parsedMessage); + }); + + it('returns an object with isSIWEMessage set to true and parsedMessage when scheme is provided', () => { + const messageWithScheme = `https://${siweMessage}`; + const parsedMessageWithScheme = new ParsedMessage(messageWithScheme); + const result = detectSIWE({ data: textAsHex(messageWithScheme) }); + expect(result.isSIWEMessage).toBe(true); - expect(result.parsedMessage).toBe(mockedParsedMessage); + expect(result.parsedMessage).toStrictEqual(parsedMessageWithScheme); }); it('returns an object with isSIWEMessage set to false and parsedMessage set to null', () => { - parsedMessageMock.mockImplementation(() => { - throw new Error('Invalid SIWE message'); - }); const result = detectSIWE({ data: '0xINVALIDDATA' }); expect(result.isSIWEMessage).toBe(false); expect(result.parsedMessage).toBeNull(); @@ -30,20 +35,6 @@ describe('siwe', () => { }); describe('isValidSIWEOrigin', () => { - const msg = { - domain: 'example.com', - address: '0x0', - statement: '', - uri: 'https://example.com', - version: '1', - chainId: 1, - nonce: '', - issuedAt: '', - expirationTime: null, - notBefore: null, - requestId: 'foo', - resources: [], - }; const checks = [ { name: 'identical domain', @@ -249,12 +240,12 @@ describe('siwe', () => { origin, })}`, () => { const result = isValidSIWEOrigin({ - from: '0x0', + from: '0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2', origin, siwe: { isSIWEMessage: true, parsedMessage: { - ...msg, + ...parsedMessage, domain, }, }, diff --git a/packages/controller-utils/src/siwe.ts b/packages/controller-utils/src/siwe.ts index ced594725d8..aee9b1bee62 100644 --- a/packages/controller-utils/src/siwe.ts +++ b/packages/controller-utils/src/siwe.ts @@ -1,37 +1,41 @@ +import { remove0x } from '@metamask/utils'; import { ParsedMessage } from '@spruceid/siwe-parser'; -import { isHexPrefixed } from 'ethereumjs-util'; -import { projectLogger, createModuleLogger } from './logger'; +import { projectLogger, createModuleLogger } from './logger.js'; const log = createModuleLogger(projectLogger, 'detect-siwe'); /** * This function strips the hex prefix from a string if it has one. + * If the input is not a string, return it unmodified. * * @param str - The string to check * @returns The string without the hex prefix */ -function stripHexPrefix(str: string) { +function safeStripHexPrefix(str: string): string { if (typeof str !== 'string') { return str; } - return isHexPrefixed(str) ? str.slice(2) : str; + return remove0x(str); } /** * This function converts a hex string to text if it's not a 32 byte hex string. * - * @param hex - The hex string to convert to text + * @param hexValue - The hex string to convert to text * @returns The text representation of the hex string */ -function msgHexToText(hex: string): string { +function msgHexToText(hexValue: string): string { try { - const stripped = stripHexPrefix(hex); + const stripped = safeStripHexPrefix(hexValue); + // TODO: Use `@metamask/utils` version of this function to avoid Buffer + // usage here. + // eslint-disable-next-line no-restricted-globals const buff = Buffer.from(stripped, 'hex'); - return buff.length === 32 ? hex : buff.toString('utf8'); - } catch (e) { - log(e); - return hex; + return buff.length === 32 ? hexValue : buff.toString('utf8'); + } catch (error) { + log(error); + return hexValue; } } @@ -39,16 +43,23 @@ function msgHexToText(hex: string): string { * @type WrappedSIWERequest * * Sign-In With Ethereum (SIWE)(EIP-4361) message with request metadata - * @property {string} from - Subject account address - * @property {string} origin - The RFC 3986 originating authority of the signing request, including scheme - * @property {ParsedMessage} siwe - The data parsed from the message + * + * @property from - Subject account address + * @property origin - The RFC 3986 originating authority of the signing request, including scheme + * @property siwe - The data parsed from the message */ +// This interface was created before this ESLint rule was added. +// Convert to a `type` in a future major version. +// eslint-disable-next-line @typescript-eslint/consistent-type-definitions export interface WrappedSIWERequest { from: string; origin: string; siwe: SIWEMessage; } +// This interface was created before this ESLint rule was added. +// Convert to a `type` in a future major version. +// eslint-disable-next-line @typescript-eslint/consistent-type-definitions interface DomainParts { username?: string; hostname: string; @@ -127,8 +138,8 @@ export const isValidSIWEOrigin = (req: WrappedSIWERequest): boolean => { } return true; - } catch (e) { - log(e); + } catch (error) { + log(error); return false; } }; @@ -165,7 +176,7 @@ export const detectSIWE = (msgParams: { data: string }): SIWEMessage => { isSIWEMessage: true, parsedMessage, }; - } catch (error) { + } catch { // ignore error, it's not a valid SIWE message return { isSIWEMessage: false, diff --git a/packages/controller-utils/src/types.test.ts b/packages/controller-utils/src/types.test.ts index ddac15c4999..d024e1dbdf0 100644 --- a/packages/controller-utils/src/types.test.ts +++ b/packages/controller-utils/src/types.test.ts @@ -1,8 +1,10 @@ -import { isNetworkType, NetworkType } from './types'; +import { isNetworkType, NetworkType } from './types.js'; describe('types', () => { it('isNetworkType', () => { + // @ts-expect-error We are intentionally passing bad input. expect(isNetworkType({})).toBe(false); + // @ts-expect-error We are intentionally passing bad input. expect(isNetworkType(1)).toBe(false); expect(isNetworkType('test')).toBe(false); expect(isNetworkType('mainnet')).toBe(true); @@ -10,6 +12,7 @@ describe('types', () => { expect(isNetworkType(NetworkType.goerli)).toBe(true); expect(isNetworkType(NetworkType.sepolia)).toBe(true); expect(isNetworkType(NetworkType['linea-goerli'])).toBe(true); + expect(isNetworkType(NetworkType['linea-sepolia'])).toBe(true); expect(isNetworkType(NetworkType['linea-mainnet'])).toBe(true); expect(isNetworkType(NetworkType.rpc)).toBe(true); }); diff --git a/packages/controller-utils/src/types.ts b/packages/controller-utils/src/types.ts index 3c660031188..dd42bf28903 100644 --- a/packages/controller-utils/src/types.ts +++ b/packages/controller-utils/src/types.ts @@ -6,17 +6,69 @@ export const InfuraNetworkType = { goerli: 'goerli', sepolia: 'sepolia', 'linea-goerli': 'linea-goerli', + 'linea-sepolia': 'linea-sepolia', 'linea-mainnet': 'linea-mainnet', + 'base-mainnet': 'base-mainnet', + 'arbitrum-mainnet': 'arbitrum-mainnet', + 'bsc-mainnet': 'bsc-mainnet', + 'optimism-mainnet': 'optimism-mainnet', + 'polygon-mainnet': 'polygon-mainnet', + 'sei-mainnet': 'sei-mainnet', + 'monad-mainnet': 'monad-mainnet', + 'megaeth-mainnet': 'megaeth-mainnet', + 'avalanche-mainnet': 'avalanche-mainnet', + 'zksync-mainnet': 'zksync-mainnet', } as const; export type InfuraNetworkType = (typeof InfuraNetworkType)[keyof typeof InfuraNetworkType]; +/** + * The default set of Infura networks to include in the wallet. + * + * This is a subset of the full list of {@link InfuraNetworkType}, and can be used to determine + * which Infura networks to enable by default. + */ +export const DEFAULT_INFURA_NETWORKS = [ + InfuraNetworkType.mainnet, + InfuraNetworkType.goerli, + InfuraNetworkType.sepolia, + InfuraNetworkType['linea-goerli'], + InfuraNetworkType['linea-sepolia'], + InfuraNetworkType['linea-mainnet'], + InfuraNetworkType['base-mainnet'], + InfuraNetworkType['arbitrum-mainnet'], + InfuraNetworkType['bsc-mainnet'], + InfuraNetworkType['optimism-mainnet'], + InfuraNetworkType['polygon-mainnet'], + InfuraNetworkType['monad-mainnet'], +] as const satisfies InfuraNetworkType[]; + +/** + * Custom network types that are not part of Infura. + */ +export const CustomNetworkType = { + /** + * @deprecated `megaeth-testnet` is migrated to `megaeth-testnet-v2`. + */ + 'megaeth-testnet': 'megaeth-testnet', + 'megaeth-testnet-v2': 'megaeth-testnet-v2', + 'monad-testnet': 'monad-testnet', +} as const; +export type CustomNetworkType = + (typeof CustomNetworkType)[keyof typeof CustomNetworkType]; + +/** + * Network types supported including both Infura networks and other networks. + */ +export type BuiltInNetworkType = InfuraNetworkType | CustomNetworkType; + /** * The "network type"; either the name of a built-in network, or "rpc" for custom networks. */ export const NetworkType = { ...InfuraNetworkType, + ...CustomNetworkType, rpc: 'rpc', } as const; @@ -28,8 +80,22 @@ export type NetworkType = (typeof NetworkType)[keyof typeof NetworkType]; * @param val - the value to check whether it is NetworkType or not. * @returns boolean indicating whether or not the argument is NetworkType. */ -export function isNetworkType(val: any): val is NetworkType { - return Object.values(NetworkType).includes(val); +export function isNetworkType(val: string): val is NetworkType { + return Object.values(NetworkType).includes(val as NetworkType); +} + +/** + * A type guard to determine whether the input is an InfuraNetworkType. + * + * @param value - The value to check. + * @returns True if the given value is within the InfuraNetworkType enum, + * false otherwise. + */ +export function isInfuraNetworkType( + value: unknown, +): value is InfuraNetworkType { + const infuraNetworkTypes: unknown[] = Object.keys(InfuraNetworkType); + return infuraNetworkTypes.includes(value); } /** @@ -42,8 +108,25 @@ export enum BuiltInNetworkName { Goerli = 'goerli', Sepolia = 'sepolia', LineaGoerli = 'linea-goerli', + LineaSepolia = 'linea-sepolia', LineaMainnet = 'linea-mainnet', Aurora = 'aurora', + /** + * @deprecated `MegaETHTestnet` is migrated to `MegaETHTestnetV2`. + */ + MegaETHTestnet = 'megaeth-testnet', + MegaETHTestnetV2 = 'megaeth-testnet-v2', + MonadTestnet = 'monad-testnet', + BaseMainnet = 'base-mainnet', + ArbitrumOne = 'arbitrum-mainnet', + BscMainnet = 'bsc-mainnet', + OptimismMainnet = 'optimism-mainnet', + PolygonMainnet = 'polygon-mainnet', + SeiMainnet = 'sei-mainnet', + MegaETHMainnet = 'megaeth-mainnet', + MonadMainnet = 'monad-mainnet', + AvalancheMainnet = 'avalanche-mainnet', + ZksyncMainnet = 'zksync-mainnet', } /** @@ -57,15 +140,169 @@ export const ChainId = { [BuiltInNetworkName.Sepolia]: '0xaa36a7', // toHex(11155111) [BuiltInNetworkName.Aurora]: '0x4e454152', // toHex(1313161554) [BuiltInNetworkName.LineaGoerli]: '0xe704', // toHex(59140) + [BuiltInNetworkName.LineaSepolia]: '0xe705', // toHex(59141) [BuiltInNetworkName.LineaMainnet]: '0xe708', // toHex(59144) + /** + * @deprecated `MegaETHTestnet` is migrated to `MegaETHTestnetV2`. + */ + [BuiltInNetworkName.MegaETHTestnet]: '0x18c6', // toHex(6342) + [BuiltInNetworkName.MegaETHTestnetV2]: '0x18c7', // toHex(6343) + [BuiltInNetworkName.MonadTestnet]: '0x279f', // toHex(10143) + [BuiltInNetworkName.BaseMainnet]: '0x2105', // toHex(8453) + [BuiltInNetworkName.ArbitrumOne]: '0xa4b1', // toHex(42161) + [BuiltInNetworkName.BscMainnet]: '0x38', // toHex(56) + [BuiltInNetworkName.OptimismMainnet]: '0xa', // toHex(10) + [BuiltInNetworkName.PolygonMainnet]: '0x89', // toHex(137) + [BuiltInNetworkName.SeiMainnet]: '0x531', // toHex(1329) + [BuiltInNetworkName.MegaETHMainnet]: '0x10e6', // toHex(4326) + [BuiltInNetworkName.MonadMainnet]: '0x8f', // toHex(143) + [BuiltInNetworkName.AvalancheMainnet]: '0xa86a', // toHex(43114) + [BuiltInNetworkName.ZksyncMainnet]: '0x144', // toHex(324) } as const; export type ChainId = (typeof ChainId)[keyof typeof ChainId]; +/* eslint-disable @typescript-eslint/naming-convention */ export enum NetworksTicker { mainnet = 'ETH', goerli = 'GoerliETH', sepolia = 'SepoliaETH', 'linea-goerli' = 'LineaETH', + // eslint-disable-next-line @typescript-eslint/no-duplicate-enum-values + 'linea-sepolia' = 'LineaETH', + // eslint-disable-next-line @typescript-eslint/no-duplicate-enum-values 'linea-mainnet' = 'ETH', + /** + * @deprecated `megaeth-testnet` is migrated to `megaeth-testnet-v2`. + */ + 'megaeth-testnet' = 'MegaETH', + // eslint-disable-next-line @typescript-eslint/no-duplicate-enum-values + 'megaeth-testnet-v2' = 'MegaETH', + 'monad-testnet' = 'MON', + // eslint-disable-next-line @typescript-eslint/no-duplicate-enum-values + 'base-mainnet' = 'ETH', + // eslint-disable-next-line @typescript-eslint/no-duplicate-enum-values + 'arbitrum-mainnet' = 'ETH', + 'bsc-mainnet' = 'BNB', + // eslint-disable-next-line @typescript-eslint/no-duplicate-enum-values + 'optimism-mainnet' = 'ETH', + 'polygon-mainnet' = 'POL', + 'sei-mainnet' = 'SEI', + // eslint-disable-next-line @typescript-eslint/no-duplicate-enum-values + 'megaeth-mainnet' = 'ETH', + // eslint-disable-next-line @typescript-eslint/no-duplicate-enum-values + 'monad-mainnet' = 'MON', + 'avalanche-mainnet' = 'AVAX', + // eslint-disable-next-line @typescript-eslint/no-duplicate-enum-values + 'zksync-mainnet' = 'ETH', rpc = '', } +/* eslint-enable @typescript-eslint/naming-convention */ + +export const BlockExplorerUrl = { + [BuiltInNetworkName.Mainnet]: 'https://etherscan.io', + [BuiltInNetworkName.Goerli]: 'https://goerli.etherscan.io', + [BuiltInNetworkName.Sepolia]: 'https://sepolia.etherscan.io', + [BuiltInNetworkName.LineaGoerli]: 'https://goerli.lineascan.build', + [BuiltInNetworkName.LineaSepolia]: 'https://sepolia.lineascan.build', + [BuiltInNetworkName.LineaMainnet]: 'https://lineascan.build', + /** + * @deprecated `MegaETHTestnet` is migrated to `MegaETHTestnetV2`. + */ + [BuiltInNetworkName.MegaETHTestnet]: 'https://megaexplorer.xyz', + [BuiltInNetworkName.MegaETHTestnetV2]: + 'https://megaeth-testnet-v2.blockscout.com', + [BuiltInNetworkName.MonadTestnet]: 'https://testnet.monadexplorer.com', + [BuiltInNetworkName.BaseMainnet]: 'https://basescan.org', + [BuiltInNetworkName.ArbitrumOne]: 'https://arbiscan.io', + [BuiltInNetworkName.BscMainnet]: 'https://bscscan.com', + [BuiltInNetworkName.OptimismMainnet]: 'https://optimistic.etherscan.io', + [BuiltInNetworkName.PolygonMainnet]: 'https://polygonscan.com', + [BuiltInNetworkName.SeiMainnet]: 'https://seiscan.io', + [BuiltInNetworkName.MegaETHMainnet]: 'https://megaeth.blockscout.com', + [BuiltInNetworkName.MonadMainnet]: 'https://monadscan.com', + [BuiltInNetworkName.AvalancheMainnet]: 'https://snowtrace.io', + [BuiltInNetworkName.ZksyncMainnet]: 'https://explorer.zksync.io', +} as const satisfies Record; +export type BlockExplorerUrl = + (typeof BlockExplorerUrl)[keyof typeof BlockExplorerUrl]; + +export const NetworkNickname = { + [BuiltInNetworkName.Mainnet]: 'Ethereum', + [BuiltInNetworkName.Goerli]: 'Goerli', + [BuiltInNetworkName.Sepolia]: 'Sepolia', + [BuiltInNetworkName.LineaGoerli]: 'Linea Goerli', + [BuiltInNetworkName.LineaSepolia]: 'Linea Sepolia', + [BuiltInNetworkName.LineaMainnet]: 'Linea', + /** + * @deprecated `MegaETHTestnet` is migrated to `MegaETHTestnetV2`. + */ + [BuiltInNetworkName.MegaETHTestnet]: 'Mega Testnet', + [BuiltInNetworkName.MegaETHTestnetV2]: 'MegaETH Testnet', + [BuiltInNetworkName.MonadTestnet]: 'Monad Testnet', + [BuiltInNetworkName.BaseMainnet]: 'Base', + [BuiltInNetworkName.ArbitrumOne]: 'Arbitrum', + [BuiltInNetworkName.BscMainnet]: 'BNB Chain', + [BuiltInNetworkName.OptimismMainnet]: 'OP', + [BuiltInNetworkName.PolygonMainnet]: 'Polygon', + [BuiltInNetworkName.SeiMainnet]: 'Sei Mainnet', + [BuiltInNetworkName.MegaETHMainnet]: 'MegaETH Mainnet', + [BuiltInNetworkName.MonadMainnet]: 'Monad', + [BuiltInNetworkName.AvalancheMainnet]: 'Avalanche Mainnet', + [BuiltInNetworkName.ZksyncMainnet]: 'ZKsync Era', +} as const satisfies Record; +export type NetworkNickname = + (typeof NetworkNickname)[keyof typeof NetworkNickname]; + +/** + * Makes a selection of keys in a Record optional. + * + * @template Type - The Record that you want to operate on. + * @template Key - The union of keys you want to make optional. + */ +// TODO: Move to @metamask/utils +export type Partialize = Omit & + Partial>; + +/** A context in which to execute a trace, in order to generate nested timings. */ +export type TraceContext = unknown; + +/** Request to trace an operation. */ +export type TraceRequest = { + /** Additional data to include in the trace. */ + data?: Record; + + /** Name of the operation. */ + name: string; + + /** + * Unique identifier for the trace. + * Required if starting a trace and not providing a callback. + */ + id?: string; + + /** Trace context in which to execute the operation. */ + parentContext?: TraceContext; + + /** Additional tags to include in the trace to filter results. */ + tags?: Record; + + /** + * Override the start time of the trace, in milliseconds. + * Useful to backdate a span when the traced work has already completed. + */ + startTime?: number; +}; + +/** Callback that traces the performance of an operation. */ +export type TraceCallback = ( + /** Request to trace the performance of an operation. */ + request: TraceRequest, + + /** + * Callback to trace. + * Thrown errors will not be caught, but the trace will still be recorded. + * + * @param context - The context in which the operation is running. + */ + fn?: (context?: TraceContext) => ReturnType, +) => Promise; diff --git a/packages/controller-utils/src/util.test.ts b/packages/controller-utils/src/util.test.ts index 37e1f3d3d8b..e3346ea473f 100644 --- a/packages/controller-utils/src/util.test.ts +++ b/packages/controller-utils/src/util.test.ts @@ -1,25 +1,53 @@ -import { BN } from 'ethereumjs-util'; +import EthQuery from '@metamask/eth-query'; +import { assert, JsonRpcParams } from '@metamask/utils'; +import { BigNumber } from 'bignumber.js'; +import BN from 'bn.js'; import nock from 'nock'; -import { MAX_SAFE_CHAIN_ID } from './constants'; -import * as util from './util'; +import { FakeProvider } from '../../../tests/fake-provider.js'; +import { MAX_SAFE_CHAIN_ID } from './constants.js'; +import * as util from './util.js'; + +type EverythingButNull = + | string + | number + | boolean + | object + | symbol + | undefined; + +type SendAsyncCallback = ( + ...args: + | [error: EverythingButNull, result: undefined] + | [error: null, result: Result] +) => void; const VALID = '4e1fF7229BDdAf0A73DF183a88d9c3a04cc975e0'; const SOME_API = 'https://someapi.com'; const SOME_FAILING_API = 'https://somefailingapi.com'; describe('util', () => { + it('isSafeDynamicKey', () => { + expect(util.isSafeDynamicKey(util.toHex(MAX_SAFE_CHAIN_ID))).toBe(true); + expect(util.isSafeDynamicKey('')).toBe(true); + for (const badKey of util.PROTOTYPE_POLLUTION_BLOCKLIST) { + expect(util.isSafeDynamicKey(badKey)).toBe(false); + } + // @ts-expect-error - ensure that non-string input return false. + expect(util.isSafeDynamicKey(null)).toBe(false); + }); it('isSafeChainId', () => { expect(util.isSafeChainId(util.toHex(MAX_SAFE_CHAIN_ID + 1))).toBe(false); expect(util.isSafeChainId(util.toHex(MAX_SAFE_CHAIN_ID))).toBe(true); expect(util.isSafeChainId(util.toHex(0))).toBe(false); expect(util.isSafeChainId('0xinvalid')).toBe(false); - // @ts-expect-error - ensure that string args return false. + // @ts-expect-error - ensure that non-string args return false. expect(util.isSafeChainId('test')).toBe(false); }); it('bNToHex', () => { expect(util.BNToHex(new BN('1337'))).toBe('0x539'); + expect(util.BNToHex(new BigNumber('1337'))).toBe('0x539'); }); it('fractionBN', () => { @@ -63,6 +91,10 @@ describe('util', () => { expect(util.toHex(new BN(4919))).toBe('0x1337'); }); + it('converts a bigint to a string prepended with "0x"', () => { + expect(util.toHex(4919n)).toBe('0x1337'); + }); + it('parses a string as a number in decimal format and converts it to a hex string prepended with "0x"', () => { expect(util.toHex('4919')).toBe('0x1337'); }); @@ -129,7 +161,7 @@ describe('util', () => { 123456000000600, ); expect(util.gweiDecToWEIBN(1.000000016025).toNumber()).toBe(1000000016); - expect(util.gweiDecToWEIBN(1.0000000160000028).toNumber()).toBe( + expect(util.gweiDecToWEIBN('1.0000000160000028').toNumber()).toBe( 1000000016, ); expect(util.gweiDecToWEIBN(1.000000016522).toNumber()).toBe(1000000017); @@ -260,7 +292,9 @@ describe('util', () => { it('should resolve', async () => { const response = await util.safelyExecuteWithTimeout(() => { - return new Promise((res) => setTimeout(() => res('response'), 200)); + return new Promise((resolve) => + setTimeout(() => resolve('response'), 200), + ); }); expect(response).toBe('response'); }); @@ -268,20 +302,90 @@ describe('util', () => { it('should timeout', async () => { expect( await util.safelyExecuteWithTimeout(() => { - return new Promise((res) => setTimeout(res, 800)); + return new Promise((resolve) => setTimeout(resolve, 800)); }), ).toBeUndefined(); }); }); describe('toChecksumHexAddress', () => { - const fullAddress = `0x${VALID}`; - it('should return address for valid address', () => { - expect(util.toChecksumHexAddress(fullAddress)).toBe(fullAddress); + it('should return an 0x-prefixed checksum address untouched', () => { + const address = '0x4e1fF7229BDdAf0A73DF183a88d9c3a04cc975e0'; + expect(util.toChecksumHexAddress(address)).toBe(address); + }); + + it('should prefix a non-0x-prefixed checksum address with 0x', () => { + expect( + util.toChecksumHexAddress('4e1fF7229BDdAf0A73DF183a88d9c3a04cc975e0'), + ).toBe('0x4e1fF7229BDdAf0A73DF183a88d9c3a04cc975e0'); + }); + + it('should convert a non-checksum address to a checksum address', () => { + expect( + util.toChecksumHexAddress('0x4e1ff7229bddaf0a73df183a88d9c3a04cc975e0'), + ).toBe('0x4e1fF7229BDdAf0A73DF183a88d9c3a04cc975e0'); + }); + + it('should return "0x" if given an empty string', () => { + expect(util.toChecksumHexAddress('')).toBe('0x'); + }); + + it('should return the input untouched if it is undefined', () => { + expect(util.toChecksumHexAddress(undefined)).toBeUndefined(); }); - it('should return address for non prefix address', () => { - expect(util.toChecksumHexAddress(VALID)).toBe(fullAddress); + it('should return the input untouched if it is null', () => { + expect(util.toChecksumHexAddress(null)).toBeNull(); + }); + + it('should return the address untouched if it is not a valid hex address', () => { + expect(util.toChecksumHexAddress('0x1')).toBe('0x1'); + }); + + it('should memoize results for same input', () => { + const testAddress = '4e1ff7229bddaf0a73df183a88d9c3a04cc975e0'; + + // Call the function multiple times with the same input + const result1 = util.toChecksumHexAddress(testAddress); + const result2 = util.toChecksumHexAddress(testAddress); + const result3 = util.toChecksumHexAddress(testAddress); + + // All results should be identical + expect(result1).toBe('0x4e1fF7229BDdAf0A73DF183a88d9c3a04cc975e0'); + expect(result2).toBe(result1); + expect(result3).toBe(result1); + }); + + it('should return different results for different inputs but still memoize each', () => { + const testAddress1 = '4e1ff7229bddaf0a73df183a88d9c3a04cc975e0'; + const testAddress2 = '742d35cc6ba4c0a2b7e8b4c0b1b0c2b2b2b2b2b2'; + + // Call with first address multiple times + const result1a = util.toChecksumHexAddress(testAddress1); + const result1b = util.toChecksumHexAddress(testAddress1); + + // Call with second address multiple times + const result2a = util.toChecksumHexAddress(testAddress2); + const result2b = util.toChecksumHexAddress(testAddress2); + + // Results for same address should be identical + expect(result1b).toBe(result1a); + expect(result2b).toBe(result2a); + + // Results for different addresses should be different + expect(result1a).not.toBe(result2a); + }); + + it('should memoize based on complete argument signature', () => { + const testAddress = '4e1ff7229bddaf0a73df183a88d9c3a04cc975e0'; + + // Call with string argument + const result1 = util.toChecksumHexAddress(testAddress); + const result2 = util.toChecksumHexAddress(testAddress); + + // Both should be memoized and return the same result + expect(result2).toBe(result1); + expect(result1).toBe('0x4e1fF7229BDdAf0A73DF183a88d9c3a04cc975e0'); }); }); @@ -299,6 +403,83 @@ describe('util', () => { false, ); }); + + it('should memoize results for same input', () => { + const validAddress = '4e1fF7229BDdAf0A73DF183a88d9c3a04cc975e0'; + + // Call the function multiple times with the same input + const result1 = util.isValidHexAddress(validAddress); + const result2 = util.isValidHexAddress(validAddress); + const result3 = util.isValidHexAddress(validAddress); + + // All results should be identical + expect(result1).toBe(true); + expect(result2).toBe(result1); + expect(result3).toBe(result1); + }); + + it('should memoize results for same input with options', () => { + const validAddress = '4e1fF7229BDdAf0A73DF183a88d9c3a04cc975e0'; + const options = { allowNonPrefixed: true }; + + // Call the function multiple times with the same input and options + const result1 = util.isValidHexAddress(validAddress, options); + const result2 = util.isValidHexAddress(validAddress, options); + const result3 = util.isValidHexAddress(validAddress, options); + + // All results should be identical + expect(result1).toBe(true); + expect(result2).toBe(result1); + expect(result3).toBe(result1); + }); + + it('should return different results for different option combinations', () => { + const addressWithoutPrefix = '4e1fF7229BDdAf0A73DF183a88d9c3a04cc975e0'; + + // Call with different options + const result1 = util.isValidHexAddress(addressWithoutPrefix, { + allowNonPrefixed: true, + }); + const result2 = util.isValidHexAddress(addressWithoutPrefix, { + allowNonPrefixed: false, + }); + + // Should return different results for different options + expect(result1).toBe(true); + expect(result2).toBe(false); + + // But calling again with same options should return memoized results + const result1Again = util.isValidHexAddress(addressWithoutPrefix, { + allowNonPrefixed: true, + }); + const result2Again = util.isValidHexAddress(addressWithoutPrefix, { + allowNonPrefixed: false, + }); + + expect(result1Again).toBe(result1); + expect(result2Again).toBe(result2); + }); + + it('should handle memoization with different address inputs', () => { + const validAddress = '4e1fF7229BDdAf0A73DF183a88d9c3a04cc975e0'; + const invalidAddress = '0x00'; + + // Call with valid address multiple times + const validResult1 = util.isValidHexAddress(validAddress); + const validResult2 = util.isValidHexAddress(validAddress); + + // Call with invalid address multiple times + const invalidResult1 = util.isValidHexAddress(invalidAddress); + const invalidResult2 = util.isValidHexAddress(invalidAddress); + + // Results for same address should be identical + expect(validResult2).toBe(validResult1); + expect(invalidResult2).toBe(invalidResult1); + + // Results should be correct + expect(validResult1).toBe(true); + expect(invalidResult1).toBe(false); + }); }); it('messageHexToString', () => { @@ -317,6 +498,26 @@ describe('util', () => { expect(toSmartContract4).toBe(true); }); + describe('HttpError', () => { + it('stores the status as an instance variable', () => { + const httpError = new util.HttpError(500); + + expect(httpError.httpStatus).toBe(500); + }); + + it('has the expected default message', () => { + const httpError = new util.HttpError(500); + + expect(httpError.message).toBe(`Fetch failed with status '500'`); + }); + + it('allows setting a custom message', () => { + const httpError = new util.HttpError(500, 'custom message'); + + expect(httpError.message).toBe('custom message'); + }); + }); + describe('successfulFetch', () => { beforeEach(() => { nock(SOME_API).get(/.+/u).reply(200, { foo: 'bar' }).persist(); @@ -334,6 +535,12 @@ describe('util', () => { `Fetch failed with status '500' for request '${SOME_FAILING_API}'`, ); }); + + it('throws an HttpError', async () => { + await expect(util.successfulFetch(SOME_FAILING_API)).rejects.toThrow( + util.HttpError, + ); + }); }); describe('timeoutFetch', () => { @@ -393,7 +600,7 @@ describe('util', () => { expect(invalid).toBeNull(); invalid = util.normalizeEnsName('@metamask.eth'); expect(invalid).toBeNull(); - invalid = util.normalizeEnsName('foobar.eth'); + invalid = util.normalizeEnsName('fo.eth'); expect(invalid).toBeNull(); }); @@ -410,9 +617,9 @@ describe('util', () => { expect(invalid).toBeNull(); }); - it('should return null with invalid 2LD and valid 3LD', async () => { - const invalid = util.normalizeEnsName('foo.barbaz.eth'); - expect(invalid).toBeNull(); + it('should normalize with valid 3LD', async () => { + const valid = util.normalizeEnsName('foo.barbaz.eth'); + expect(valid).toBe('foo.barbaz.eth'); }); it('should return null with invalid TLD', async () => { @@ -436,57 +643,76 @@ describe('util', () => { describe('query', () => { describe('when the given method exists directly on the EthQuery', () => { it('should call the method on the EthQuery and, if it is successful, return a promise that resolves to the result', async () => { - class EthQuery { - getBlockByHash(blockId: any, cb: any) { - cb(null, { id: blockId }); + class MockEthQuery extends EthQuery { + getBlockByHash( + blockId: unknown, + callback: (error: Error | null, value: unknown) => void, + ): void { + callback(null, { id: blockId }); } } - // @ts-expect-error Mock eth query does not fulfill type requirements - const result = await util.query(new EthQuery(), 'getBlockByHash', [ - '0x1234', - ]); + const result = await util.query( + new MockEthQuery(new FakeProvider()), + 'getBlockByHash', + ['0x1234'], + ); expect(result).toStrictEqual({ id: '0x1234' }); }); it('should call the method on the EthQuery and, if it errors, return a promise that is rejected with the error', async () => { - class EthQuery { - getBlockByHash(_blockId: any, cb: any) { - cb(new Error('uh oh'), null); + class MockEthQuery extends EthQuery { + getBlockByHash( + _blockId: unknown, + callback: (error: Error | null, value: unknown) => void, + ): void { + callback(new Error('uh oh'), null); } } await expect( - // @ts-expect-error Mock eth query does not fulfill type requirements - util.query(new EthQuery(), 'getBlockByHash', ['0x1234']), + util.query(new MockEthQuery(new FakeProvider()), 'getBlockByHash', [ + '0x1234', + ]), ).rejects.toThrow('uh oh'); }); }); describe('when the given method does not exist directly on the EthQuery', () => { it('should use sendAsync to call the RPC endpoint and, if it is successful, return a promise that resolves to the result', async () => { - class EthQuery { - sendAsync({ method, params }: any, cb: any) { + class MockEthQuery extends EthQuery { + sendAsync( + { method, params }: { method?: string; params?: JsonRpcParams }, + callback: SendAsyncCallback, + ): void { if (method === 'eth_getBlockByHash') { - return cb(null, { id: params[0] }); + assert(Array.isArray(params)); + return callback(null, { id: params[0] } as Result); } throw new Error(`Unsupported method ${method}`); } } - // @ts-expect-error Mock eth query does not fulfill type requirements - const result = await util.query(new EthQuery(), 'eth_getBlockByHash', [ - '0x1234', - ]); + const result = await util.query( + new MockEthQuery(new FakeProvider()), + 'eth_getBlockByHash', + ['0x1234'], + ); expect(result).toStrictEqual({ id: '0x1234' }); }); it('should use sendAsync to call the RPC endpoint and, if it errors, return a promise that is rejected with the error', async () => { - class EthQuery { - sendAsync(_args: any, cb: any) { - cb(new Error('uh oh'), null); + class MockEthQuery extends EthQuery { + sendAsync( + _args: unknown, + callback: SendAsyncCallback, + ): void { + callback(new Error('uh oh'), undefined); } } await expect( - // @ts-expect-error Mock eth query does not fulfill type requirements - util.query(new EthQuery(), 'eth_getBlockByHash', ['0x1234']), + util.query( + new MockEthQuery(new FakeProvider()), + 'eth_getBlockByHash', + ['0x1234'], + ), ).rejects.toThrow('uh oh'); }); }); @@ -528,7 +754,7 @@ describe('util', () => { it('returns true for objects', () => { expect(util.isPlainObject({ foo: 'bar' })).toBe(true); - expect(util.isPlainObject({ foo: 'bar', test: { num: 5 } })).toBe(true); + expect(util.isPlainObject({ foo: 'bar', test: { value: 5 } })).toBe(true); }); }); @@ -556,7 +782,37 @@ describe('util', () => { }); it('returns true for valid JSON', () => { - expect(util.isValidJson({ foo: 'bar', test: { num: 5 } })).toBe(true); + expect(util.isValidJson({ foo: 'bar', test: { value: 5 } })).toBe(true); }); }); }); + +describe('isEqualCaseInsensitive', () => { + it('returns false for non-string values', () => { + // @ts-expect-error Invalid type for testing purposes + expect(util.isEqualCaseInsensitive(null, null)).toBe(false); + // @ts-expect-error Invalid type for testing purposes + expect(util.isEqualCaseInsensitive(5, 5)).toBe(false); + // @ts-expect-error Invalid type for testing purposes + expect(util.isEqualCaseInsensitive(null, 'test')).toBe(false); + // @ts-expect-error Invalid type for testing purposes + expect(util.isEqualCaseInsensitive('test', null)).toBe(false); + // @ts-expect-error Invalid type for testing purposes + expect(util.isEqualCaseInsensitive(5, 'test')).toBe(false); + // @ts-expect-error Invalid type for testing purposes + expect(util.isEqualCaseInsensitive('test', 5)).toBe(false); + }); + + it('returns false for strings that are not equal', () => { + expect(util.isEqualCaseInsensitive('test', 'test1')).toBe(false); + expect(util.isEqualCaseInsensitive('test1', 'test')).toBe(false); + }); + + it('returns true for strings that are equal', () => { + expect(util.isEqualCaseInsensitive('test', 'TEST')).toBe(true); + expect(util.isEqualCaseInsensitive('test', 'test')).toBe(true); + expect(util.isEqualCaseInsensitive('TEST', 'TEST')).toBe(true); + expect(util.isEqualCaseInsensitive('test', 'Test')).toBe(true); + expect(util.isEqualCaseInsensitive('Test', 'test')).toBe(true); + }); +}); diff --git a/packages/controller-utils/src/util.ts b/packages/controller-utils/src/util.ts index 7e89f77ae53..71a0f8c5430 100644 --- a/packages/controller-utils/src/util.ts +++ b/packages/controller-utils/src/util.ts @@ -1,22 +1,46 @@ import type EthQuery from '@metamask/eth-query'; +import { fromWei, toWei } from '@metamask/ethjs-unit'; import type { Hex, Json } from '@metamask/utils'; -import { isStrictHexString } from '@metamask/utils'; -import ensNamehash from 'eth-ens-namehash'; import { - addHexPrefix, - isValidAddress, + isStrictHexString, + add0x, isHexString, - BN, - toChecksumAddress, - stripHexPrefix, -} from 'ethereumjs-util'; -import { fromWei, toWei } from 'ethjs-unit'; + remove0x, + getChecksumAddress, + isHexChecksumAddress, +} from '@metamask/utils'; +import type { BigNumber } from 'bignumber.js'; +import BN from 'bn.js'; +import ensNamehash from 'eth-ens-namehash'; import deepEqual from 'fast-deep-equal'; +import { memoize } from 'lodash'; + +import { MAX_SAFE_CHAIN_ID } from './constants.js'; -import { MAX_SAFE_CHAIN_ID } from './constants'; +export type { BigNumber }; const TIMEOUT_ERROR = new Error('timeout'); +export const PROTOTYPE_POLLUTION_BLOCKLIST = [ + '__proto__', + 'constructor', + 'prototype', +] as const; + +/** + * Checks whether a dynamic property key could be used in + * a [prototype pollution attack](https://portswigger.net/web-security/prototype-pollution). + * + * @param key - The dynamic key to validate. + * @returns Whether the given dynamic key is safe to use. + */ +export function isSafeDynamicKey(key: string): boolean { + return ( + typeof key === 'string' && + !PROTOTYPE_POLLUTION_BLOCKLIST.some((blockedKey) => key === blockedKey) + ); +} + /** * Checks whether the given number primitive chain ID is safe. * Because some cryptographic libraries we use expect the chain ID to be a @@ -29,7 +53,10 @@ export function isSafeChainId(chainId: Hex): boolean { if (!isHexString(chainId)) { return false; } - const decimalChainId = Number.parseInt(chainId); + const decimalChainId = Number.parseInt( + chainId, + isStrictHexString(chainId) ? 16 : 10, + ); return ( Number.isSafeInteger(decimalChainId) && decimalChainId > 0 && @@ -37,13 +64,15 @@ export function isSafeChainId(chainId: Hex): boolean { ); } /** - * Converts a BN object to a hex string with a '0x' prefix. + * Converts a BN or BigNumber object to a hex string with a '0x' prefix. * - * @param inputBn - BN instance to convert to a hex string. + * @param inputBn - BN|BigNumber instance to convert to a hex string. * @returns A '0x'-prefixed hex string. */ -export function BNToHex(inputBn: any) { - return addHexPrefix(inputBn.toString(16)); +// TODO: Fix naming convention. +// eslint-disable-next-line @typescript-eslint/naming-convention +export function BNToHex(inputBn: BN | BigNumber): Hex { + return add0x(inputBn.toString(16)); } /** @@ -55,10 +84,10 @@ export function BNToHex(inputBn: any) { * @returns Product of the multiplication. */ export function fractionBN( - targetBN: any, + targetBN: BN, numerator: number | string, denominator: number | string, -) { +): BN { const numBN = new BN(numerator); const denomBN = new BN(denominator); return targetBN.mul(numBN).div(denomBN); @@ -67,15 +96,15 @@ export function fractionBN( /** * Used to convert a base-10 number from GWEI to WEI. Can handle numbers with decimal parts. * - * @param n - The base 10 number to convert to WEI. + * @param value - The base 10 number to convert to WEI. * @returns The number in WEI, as a BN. */ -export function gweiDecToWEIBN(n: number | string) { - if (Number.isNaN(n)) { +export function gweiDecToWEIBN(value: number | string): BN { + if (Number.isNaN(value)) { return new BN(0); } - const parts = n.toString().split('.'); + const parts = value.toString().split('.'); const wholePart = parts[0] || '0'; let decimalPart = parts[1] || ''; @@ -103,12 +132,12 @@ export function gweiDecToWEIBN(n: number | string) { /** * Used to convert values from wei hex format to dec gwei format. * - * @param hex - The value in hex wei. + * @param hexValue - The value in hex wei. * @returns The value in dec gwei as string. */ -export function weiHexToGweiDec(hex: string) { - const hexWei = new BN(stripHexPrefix(hex), 16); - return fromWei(hexWei, 'gwei').toString(10); +export function weiHexToGweiDec(hexValue: string): string { + const hexWei = new BN(remove0x(hexValue), 16); + return fromWei(hexWei, 'gwei'); } /** @@ -142,24 +171,27 @@ export function getBuyURL( * @param inputHex - Number represented as a hex string. * @returns A BN instance. */ -export function hexToBN(inputHex: string) { - return inputHex ? new BN(stripHexPrefix(inputHex), 16) : new BN(0); +export function hexToBN(inputHex: string): BN { + return inputHex ? new BN(remove0x(inputHex), 16) : new BN(0); } /** * A helper function that converts hex data to human readable string. * - * @param hex - The hex string to convert to string. + * @param hexValue - The hex string to convert to string. * @returns A human readable string conversion. */ -export function hexToText(hex: string) { +export function hexToText(hexValue: string): string { try { - const stripped = stripHexPrefix(hex); + const stripped = remove0x(hexValue); + // TODO: Use `@metamask/utils` version of this function to avoid use of + // Buffer. + // eslint-disable-next-line no-restricted-globals const buff = Buffer.from(stripped, 'hex'); return buff.toString('utf8'); - } catch (e) { + } catch { /* istanbul ignore next */ - return hex; + return hexValue; } } @@ -183,13 +215,14 @@ export function fromHex(value: string | BN): BN { * @param value - An integer, an integer encoded as a base-10 string, or a BN. * @returns The integer encoded as a hex string. */ -export function toHex(value: number | string | BN): Hex { +export function toHex(value: number | bigint | string | BN): Hex { if (typeof value === 'string' && isStrictHexString(value)) { return value; } - const hexString = BN.isBN(value) - ? value.toString(16) - : new BN(value.toString(), 10).toString(16); + const hexString = + BN.isBN(value) || typeof value === 'bigint' + ? value.toString(16) + : new BN(value.toString(), 10).toString(16); return `0x${hexString}`; } @@ -198,15 +231,16 @@ export function toHex(value: number | string | BN): Hex { * * @param operation - Function returning a Promise. * @param logError - Determines if the error should be logged. + * @template Result - Type of the result of the async operation * @returns Promise resolving to the result of the async operation. */ -export async function safelyExecute( - operation: () => Promise, +export async function safelyExecute( + operation: () => Promise, logError = false, -) { +): Promise { try { return await operation(); - } catch (error: any) { + } catch (error) { /* istanbul ignore next */ if (logError) { console.error(error); @@ -221,17 +255,18 @@ export async function safelyExecute( * @param operation - Function returning a Promise. * @param logError - Determines if the error should be logged. * @param timeout - Timeout to fail the operation. + * @template Result - Type of the result of the async operation * @returns Promise resolving to the result of the async operation. */ -export async function safelyExecuteWithTimeout( - operation: () => Promise, +export async function safelyExecuteWithTimeout( + operation: () => Promise, logError = false, timeout = 500, -) { +): Promise { try { return await Promise.race([ operation(), - new Promise((_, reject) => + new Promise((_resolve, reject) => setTimeout(() => { reject(TIMEOUT_ERROR); }, timeout), @@ -247,55 +282,116 @@ export async function safelyExecuteWithTimeout( } /** - * Convert an address to a checksummed hexidecimal address. + * Convert an address to a checksummed hexadecimal address. * * @param address - The address to convert. - * @returns A 0x-prefixed hexidecimal checksummed address. + * @returns The address in 0x-prefixed hexadecimal checksummed form if it is valid. */ -export function toChecksumHexAddress(address: string) { - const hexPrefixed = addHexPrefix(address); +function toChecksumHexAddressUnmemoized(address: string): string; + +/** + * Convert an address to a checksummed hexadecimal address. + * + * Note that this particular overload does nothing. + * + * @param address - A value that is not a string (e.g. `undefined` or `null`). + * @returns The `address` untouched. + * @deprecated This overload is designed to gracefully handle an invalid input + * and is only present for backward compatibility. It may be removed in a future + * major version. Please pass a string to `toChecksumHexAddress` instead. + */ +function toChecksumHexAddressUnmemoized(address: Type): Type; + +function toChecksumHexAddressUnmemoized(address: unknown): unknown { + if (typeof address !== 'string') { + // Mimic behavior of `addHexPrefix` from `ethereumjs-util` (which this + // function was previously using) for backward compatibility. + return address; + } + + const hexPrefixed = add0x(address); + if (!isHexString(hexPrefixed)) { - // Version 5.1 of ethereumjs-utils would have returned '0xY' for input 'y' + // Version 5.1 of ethereumjs-util would have returned '0xY' for input 'y' // but we shouldn't waste effort trying to change case on a clearly invalid // string. Instead just return the hex prefixed original string which most // closely mimics the original behavior. return hexPrefixed; } - return toChecksumAddress(hexPrefixed); + + try { + return getChecksumAddress(hexPrefixed); + } catch (error) { + // This is necessary for backward compatibility with the old behavior of + // `ethereumjs-util` which would return the original string if the address + // was invalid. + if (error instanceof Error && error.message === 'Invalid hex address.') { + return hexPrefixed; + } + throw error; + } } /** - * Validates that the input is a hex address. This utility method is a thin - * wrapper around ethereumjs-util.isValidAddress, with the exception that it - * by default will return true for hex strings that meet the length requirement - * of a hex address, but are not prefixed with `0x`. + * Convert an address to a checksummed hexadecimal address. * - * @param possibleAddress - Input parameter to check against. - * @param options - The validation options. - * @param options.allowNonPrefixed - If true will allow addresses without `0x` prefix.` - * @returns Whether or not the input is a valid hex address. + * @param address - The address to convert. For backward compatibility reasons, + * this can be anything, even a non-hex string with an 0x prefix, but that usage + * is deprecated. Please use a valid hex string (with or without the `0x` + * prefix). + * @returns A 0x-prefixed checksummed version of `address` if it is a valid hex + * string, or the address as given otherwise. */ -export function isValidHexAddress( +export const toChecksumHexAddress: { + (address: string): string; + (address: Type): Type; +} = memoize(toChecksumHexAddressUnmemoized); + +function isValidHexAddressUnmemoized( possibleAddress: string, { allowNonPrefixed = true } = {}, -) { +): boolean { const addressToCheck = allowNonPrefixed - ? addHexPrefix(possibleAddress) + ? add0x(possibleAddress) : possibleAddress; - if (!isHexString(addressToCheck)) { + if (!isStrictHexString(addressToCheck)) { return false; } - return isValidAddress(addressToCheck); + // We used to rely on `isValidAddress` from `@ethereumjs/util` which allows + // for upper-case characters too. So we preserve this behavior and use our + // faster and memoized validation function instead. + return isHexChecksumAddress(addressToCheck); } +/** + * Validates that the input is a hex address. This utility method is a thin + * wrapper around `isValidHexAddress` from `@metamask/utils`, with the exception + * that it may return true for non-0x-prefixed hex strings (depending on the + * option below). + * + * @param possibleAddress - Input parameter to check against. + * @param options - The validation options. + * @param options.allowNonPrefixed - If true will regard addresses without a + * `0x` prefix as valid. + * @returns Whether or not the input is a valid hex address. + */ +export const isValidHexAddress: ( + possibleAddress: string, + options?: { allowNonPrefixed?: boolean }, +) => boolean = memoize( + isValidHexAddressUnmemoized, + (possibleAddress, { allowNonPrefixed = true } = {}) => + `${possibleAddress}-${allowNonPrefixed}`, +); + /** * Returns whether the given code corresponds to a smart contract. * * @param code - The potential smart contract code. * @returns Whether the code was smart contract code or not. */ -export function isSmartContractCode(code: string) { +export function isSmartContractCode(code: string): boolean { /* istanbul ignore if */ if (!code) { return false; @@ -305,6 +401,24 @@ export function isSmartContractCode(code: string) { return smartContractCode; } +/** + * An error representing a non-200 HTTP response. + */ +export class HttpError extends Error { + public httpStatus: number; + + /** + * Construct an HTTP error. + * + * @param status - The HTTP response status. + * @param message - The error message. + */ + constructor(status: number, message?: string) { + super(message ?? `Fetch failed with status '${status}'`); + this.httpStatus = status; + } +} + /** * Execute fetch and verify that the response was successful. * @@ -312,11 +426,17 @@ export function isSmartContractCode(code: string) { * @param options - Fetch options. * @returns The fetch response. */ -export async function successfulFetch(request: string, options?: RequestInit) { +export async function successfulFetch( + request: URL | RequestInfo, + options?: RequestInit, +): Promise { const response = await fetch(request, options); if (!response.ok) { - throw new Error( - `Fetch failed with status '${response.status}' for request '${request}'`, + throw new HttpError( + response.status, + // TODO: Replace `String` with more specific conversion. + // eslint-disable-next-line @typescript-eslint/no-base-to-string + `Fetch failed with status '${response.status}' for request '${String(request)}'`, ); } return response; @@ -329,7 +449,12 @@ export async function successfulFetch(request: string, options?: RequestInit) { * @param options - The fetch options. * @returns The fetch response JSON data. */ -export async function handleFetch(request: string, options?: RequestInit) { +export async function handleFetch( + request: URL | RequestInfo, + options?: RequestInit, + // TODO: Replace `any` with more specific type. + // eslint-disable-next-line @typescript-eslint/no-explicit-any +): Promise { const response = await successfulFetch(request, options); const object = await response.json(); return object; @@ -355,13 +480,15 @@ export async function fetchWithErrorHandling({ options?: RequestInit; timeout?: number; errorCodesToCatch?: number[]; -}) { + // TODO: Replace `any` with more specific type. + // eslint-disable-next-line @typescript-eslint/no-explicit-any +}): Promise { let result; try { if (timeout) { result = Promise.race([ await handleFetch(url, options), - new Promise((_, reject) => + new Promise((_resolve, reject) => setTimeout(() => { reject(TIMEOUT_ERROR); }, timeout), @@ -370,8 +497,8 @@ export async function fetchWithErrorHandling({ } else { result = await handleFetch(url, options); } - } catch (e) { - logOrRethrowError(e, errorCodesToCatch); + } catch (error) { + logOrRethrowError(error, errorCodesToCatch); } return result; } @@ -391,7 +518,7 @@ export async function timeoutFetch( ): Promise { return Promise.race([ successfulFetch(url, options), - new Promise((_, reject) => + new Promise((_resolve, reject) => setTimeout(() => { reject(TIMEOUT_ERROR); }, timeout), @@ -406,15 +533,18 @@ export async function timeoutFetch( * @returns The normalized ENS name string. */ export function normalizeEnsName(ensName: string): string | null { + // `.` refers to the registry root contract + if (ensName === '.') { + return ensName; + } if (ensName && typeof ensName === 'string') { try { const normalized = ensNamehash.normalize(ensName.trim()); // this regex is only sufficient with the above call to ensNamehash.normalize - // TODO: change 7 in regex to 3 when shorter ENS domains are live - if (normalized.match(/^(([\w\d-]+)\.)*[\w\d-]{7,}\.(eth|test)$/u)) { + if (normalized.match(/^(([\w\d-]+)\.)*[\w\d-]{3,}\.(eth|test)$/u)) { return normalized; } - } catch (_) { + } catch { // do nothing } } @@ -432,11 +562,18 @@ export function normalizeEnsName(ensName: string): string | null { export function query( ethQuery: EthQuery, method: string, + // TODO: Replace `any` with type + // eslint-disable-next-line @typescript-eslint/no-explicit-any args: any[] = [], + // TODO: Replace `any` with type + // eslint-disable-next-line @typescript-eslint/no-explicit-any ): Promise { return new Promise((resolve, reject) => { - const cb = (error: unknown, result: unknown) => { + const callback = (error: unknown, result: unknown): void => { if (error) { + // We don't control the error object returned by eth-query, so + // we can't guarantee it's an instance of Error. + // eslint-disable-next-line @typescript-eslint/prefer-promise-reject-errors reject(error); return; } @@ -446,9 +583,9 @@ export function query( // Using `in` rather than `hasProperty` so that we look up the prototype // chain for the method. if (method in ethQuery && typeof ethQuery[method] === 'function') { - ethQuery[method](...args, cb); + ethQuery[method](...args, callback); } else { - ethQuery.sendAsync({ method, params: args }, cb); + ethQuery.sendAsync({ method, params: args }, callback); } }); } @@ -462,7 +599,7 @@ export function query( export const convertHexToDecimal = ( value: string | undefined = '0x0', ): number => { - if (isHexString(value)) { + if (isStrictHexString(value)) { return parseInt(value, 16); } @@ -486,7 +623,7 @@ export function isPlainObject(value: unknown): value is PlainObject { * * @template T - The non-empty array member type. */ -export type NonEmptyArray = [T, ...T[]]; +export type NonEmptyArray = [Type, ...Type[]]; /** * Type guard for {@link NonEmptyArray}. @@ -495,7 +632,9 @@ export type NonEmptyArray = [T, ...T[]]; * @param value - The value to check. * @returns Whether the value is a non-empty array. */ -export function isNonEmptyArray(value: T[]): value is NonEmptyArray { +export function isNonEmptyArray( + value: Type[], +): value is NonEmptyArray { return Array.isArray(value) && value.length > 0; } @@ -508,7 +647,7 @@ export function isNonEmptyArray(value: T[]): value is NonEmptyArray { export function isValidJson(value: unknown): value is Json { try { return deepEqual(value, JSON.parse(JSON.stringify(value))); - } catch (_) { + } catch { return false; } } @@ -519,23 +658,44 @@ export function isValidJson(value: unknown): value is Json { * @param error - Caught error that we should either rethrow or log to console * @param codesToCatch - array of error codes for errors we want to catch and log in a particular context */ -function logOrRethrowError(error: any, codesToCatch: number[] = []) { +function logOrRethrowError(error: unknown, codesToCatch: number[] = []): void { if (!error) { return; } - const includesErrorCodeToCatch = codesToCatch.some((code) => - error.message?.includes(`Fetch failed with status '${code}'`), - ); + if (error instanceof Error) { + const includesErrorCodeToCatch = codesToCatch.some((code) => + error.message.includes(`Fetch failed with status '${code}'`), + ); - if ( - error instanceof Error && - (includesErrorCodeToCatch || - error.message?.includes('Failed to fetch') || - error === TIMEOUT_ERROR) - ) { - console.error(error); + if ( + includesErrorCodeToCatch || + error.message.includes('Failed to fetch') || + error === TIMEOUT_ERROR + ) { + console.error(error); + } else { + throw error; + } } else { + // eslint-disable-next-line @typescript-eslint/only-throw-error throw error; } } + +/** + * Checks if two strings are equal, ignoring case. + * + * @param value1 - The first string to compare. + * @param value2 - The second string to compare. + * @returns `true` if the strings are equal, ignoring case; otherwise, `false`. + */ +export function isEqualCaseInsensitive( + value1: string, + value2: string, +): boolean { + if (typeof value1 !== 'string' || typeof value2 !== 'string') { + return false; + } + return value1.toLowerCase() === value2.toLowerCase(); +} diff --git a/packages/controller-utils/tsconfig.build.json b/packages/controller-utils/tsconfig.build.json index 7316a1e8b3d..0df910b2151 100644 --- a/packages/controller-utils/tsconfig.build.json +++ b/packages/controller-utils/tsconfig.build.json @@ -2,7 +2,6 @@ "extends": "../../tsconfig.packages.build.json", "compilerOptions": { "baseUrl": "./", - "lib": ["ES2017", "DOM"], "outDir": "./dist", "rootDir": "./src" }, diff --git a/packages/controller-utils/tsconfig.json b/packages/controller-utils/tsconfig.json index 0e3a9262726..ee9de925a21 100644 --- a/packages/controller-utils/tsconfig.json +++ b/packages/controller-utils/tsconfig.json @@ -1,8 +1,7 @@ { "extends": "../../tsconfig.packages.json", "compilerOptions": { - "baseUrl": "./", - "lib": ["ES2017", "DOM"] + "baseUrl": "./" }, "include": ["../../types", "./src"] } diff --git a/packages/core-backend/CHANGELOG.md b/packages/core-backend/CHANGELOG.md new file mode 100644 index 00000000000..639ebb9299d --- /dev/null +++ b/packages/core-backend/CHANGELOG.md @@ -0,0 +1,432 @@ +# 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] + +### Changed + +- Bump `@metamask/remote-feature-flag-controller` from `^6.0.0` to `^6.1.0` ([#9980](https://github.com/MetaMask/core/pull/9980)) + +## [9.0.0] + +### Changed + +- **BREAKING:** Align the Accounts API v6 balance response types with the flat `/v6/multiaccount/balances` response: `V6BalancesResponse.accounts` is replaced by `balances`, `V6BalanceItem` now includes `accountId`, `object`, and `type`, `V6BalanceMetadata.protocolIconUrl` is optional, `processingDefiPositions` is now an optional response-level array of CAIP-10 account IDs, and `V6AccountBalancesEntry` is removed ([#9911](https://github.com/MetaMask/core/pull/9911)) +- Bump `@metamask/remote-feature-flag-controller` from `^5.0.0` to `^6.0.0` ([#9945](https://github.com/MetaMask/core/pull/9945)) + +## [8.1.2] + +### Changed + +- Bump `@metamask/account-tree-controller` from `^7.6.0` to `^8.0.0` ([#9791](https://github.com/MetaMask/core/pull/9791), [#9886](https://github.com/MetaMask/core/pull/9886)) +- Bump `@metamask/keyring-controller` from `^27.1.0` to `^27.1.1` ([#9791](https://github.com/MetaMask/core/pull/9791)) + +## [8.1.1] + +### Changed + +- Bump `@metamask/account-tree-controller` from `^7.5.5` to `^7.6.0` ([#9779](https://github.com/MetaMask/core/pull/9779)) +- Bump `@metamask/profile-sync-controller` from `^28.3.0` to `^29.0.0` ([#9779](https://github.com/MetaMask/core/pull/9779)) + +### Fixed + +- `OHLCVService` now flushes grace-period channels when subscribing to a different asset/interval, retries failed WebSocket unsubscribes with backoff before forcing reconnection, and only removes channel tracking after a successful unsubscribe ([#9678](https://github.com/MetaMask/core/pull/9678)) + +## [8.1.0] + +### Changed + +- `AccountActivityService` now debounces chain status updates instead of publishing one event per notification ([#9700](https://github.com/MetaMask/core/pull/9700)) +- Bump `@metamask/remote-feature-flag-controller` from `^4.2.2` to `^5.0.0` ([#9735](https://github.com/MetaMask/core/pull/9735)) + +## [8.0.0] + +### Fixed + +- **BREAKING:** Align `V6_DEFI_POSITION_TYPES` (and inferred `V6DeFiPositionType`) with Accounts API / Zerion wallet fungible position types: `deposit`, `loan`, `locked`, `staked`, `reward`, `wallet`, `investment` ([#9683](https://github.com/MetaMask/core/pull/9683)) + +## [7.0.0] + +### Added + +- Export `V6_DEFI_POSITION_TYPES` and inferred `V6DeFiPositionType`, and type `V6BalanceMetadata.positionType` with the Accounts API v6 DeFi position module values (`deposit`, `lending`, `yield`, `liquidity_pool`, `staked`, `leveraged_farming`, `nft_staked`, `farming`, `locked`, `vesting`, `rewards`, `investment`) ([#9557](https://github.com/MetaMask/core/pull/9557)) +- Add `groupId` to `V6BalanceMetadata` to match Accounts API v6 DeFi metadata ([#9557](https://github.com/MetaMask/core/pull/9557)) + +### Changed + +- **BREAKING:** Rename `V6BalanceMetadata.protocolName` to `productName` to match the Accounts API v6 DeFi metadata field ([#9557](https://github.com/MetaMask/core/pull/9557)) +- **BREAKING:** `AccountActivityService` now determines which non-EVM chains to subscribe to from remote feature flags instead of a bundled list ([#9379](https://github.com/MetaMask/core/pull/9379)) + - The `AccountActivityServiceMessenger` now requires the following delegate actions and events: + - `RemoteFeatureFlagController:getState` action and `RemoteFeatureFlagController:stateChange` event + - `AccountTreeController:getAccountsFromSelectedAccountGroup` action and `AccountTreeController:selectedAccountGroupChange` event + - EVM (`eip155`) subscriptions are always enabled. Solana, Tron, and Stellar subscriptions are enabled when the corresponding `networkAssetsSnapsMigrationSolana` / `networkAssetsSnapsMigrationTron` / `networkAssetsSnapsMigrationStellar` feature flag has `stage >= 1`, and disabled otherwise. + - Accounts whose scopes do not match an enabled chain are no longer subscribed at all. + - When the enabled chains change via a feature flag update while the websocket is connected, the service automatically resubscribes the selected account. +- Add `@metamask/remote-feature-flag-controller` `^4.2.2` as a dependency ([#9379](https://github.com/MetaMask/core/pull/9379)) +- Bump `@metamask/messenger` from `^1.2.0` to `^2.0.0` ([#9392](https://github.com/MetaMask/core/pull/9392)) +- Bump `@metamask/profile-sync-controller` from `^28.2.0` to `^28.3.0` ([#9463](https://github.com/MetaMask/core/pull/9463)) + +### Removed + +- **BREAKING:** `AccountActivityService.subscribe` and `AccountActivityService.unsubscribe` methods have been removed ([#9531](https://github.com/MetaMask/core/pull/9531)) + +### Fixed + +- `AccountActivityService` subscribes to all supported scopes for a given account ([#9379](https://github.com/MetaMask/core/pull/9379)) + +## [6.5.0] + +### Added + +- Add `AccountsApiClient` support for the Accounts API `/v6/multiaccount/balances` endpoint via `fetchV6MultiAccountBalances` / `getV6MultiAccountBalancesQueryOptions`, returning token balances plus optional DeFi positions and spot prices (new types `V6BalancesResponse`, `V6AccountBalancesEntry`, `V6BalanceItem`, `V6BalanceMetadata`, `V6TokenMetadata`, `V6VsCurrency`) ([#9302](https://github.com/MetaMask/core/pull/9302)) + +### Changed + +- Bump `@metamask/accounts-controller` from `^39.0.3` to `^39.0.4` ([#9349](https://github.com/MetaMask/core/pull/9349)) + +## [6.4.0] + +### Changed + +- Bump `@metamask/utils` from `^11.9.0` to `^11.11.0` ([#9074](https://github.com/MetaMask/core/pull/9074)) +- Bump `@metamask/controller-utils` from `^12.1.1` to `^12.3.0` ([#9083](https://github.com/MetaMask/core/pull/9083), [#9218](https://github.com/MetaMask/core/pull/9218)) +- Bump `@metamask/profile-sync-controller` from `^28.1.1` to `^28.2.0` ([#9119](https://github.com/MetaMask/core/pull/9119)) +- Bump `@metamask/keyring-controller` from `^27.0.0` to `^27.1.0` ([#9129](https://github.com/MetaMask/core/pull/9129)) +- Bump `@metamask/accounts-controller` from `^39.0.1` to `^39.0.3` ([#9218](https://github.com/MetaMask/core/pull/9218), [#9231](https://github.com/MetaMask/core/pull/9231)) + +### Fixed + +- `BackendWebSocketService` routes account-activity notifications when subscribed channels use chain wildcard `0` but the server sends a specific chain id, falls back to channel-based subscription lookup when `subscriptionId` is stale, and normalizes nested notification payloads ([#9273](https://github.com/MetaMask/core/pull/9273)) + +## [6.3.3] + +### Changed + +- Bump `@metamask/accounts-controller` from `^39.0.0` to `^39.0.1` ([#9058](https://github.com/MetaMask/core/pull/9058)) +- Bump `@metamask/controller-utils` from `^12.1.0` to `^12.1.1` ([#9058](https://github.com/MetaMask/core/pull/9058)) +- Bump `@metamask/keyring-controller` from `^26.0.0` to `^27.0.0` ([#9058](https://github.com/MetaMask/core/pull/9058)) + +## [6.3.2] + +### Changed + +- Bump `@metamask/accounts-controller` from `^38.1.2` to `^39.0.0` ([#8999](https://github.com/MetaMask/core/pull/8999)) + +## [6.3.1] + +### Changed + +- Bump `@metamask/keyring-controller` from `^25.5.0` to `^26.0.0` ([#8912](https://github.com/MetaMask/core/pull/8912)) +- Bump `@metamask/accounts-controller` from `^38.1.1` to `^38.1.2` ([#8912](https://github.com/MetaMask/core/pull/8912)) +- Bump `@metamask/profile-sync-controller` from `^28.1.0` to `^28.1.1` ([#8912](https://github.com/MetaMask/core/pull/8912)) + +## [6.3.0] + +### Added + +- Add `OHLCVService` for real-time OHLCV (candlestick) data streaming via WebSocket ([#8695](https://github.com/MetaMask/core/pull/8695)) + - Wraps `BackendWebSocketService` through the messenger pattern to provide subscribe/unsubscribe semantics for market-data OHLCV channels + - Includes reference counting, grace-period unsubscribe, idempotency checks, chain-status forwarding, and automatic resubscription on reconnect +- Export new types `OHLCVBar`, `OHLCVSubscriptionOptions`, `OHLCVSystemNotificationData`, `OHLCVServiceOptions`, `OHLCVServiceActions`, `OHLCVServiceAllowedActions`, `OHLCVServiceBarUpdatedEvent`, `OHLCVServiceChainStatusChangedEvent`, `OHLCVServiceSubscriptionErrorEvent`, `OHLCVServiceEvents`, `OHLCVServiceAllowedEvents`, and `OHLCVServiceMessenger` ([#8695](https://github.com/MetaMask/core/pull/8695)) +- Export new constants `OHLCV_SERVICE_ALLOWED_ACTIONS` and `OHLCV_SERVICE_ALLOWED_EVENTS` for configuring the messenger ([#8695](https://github.com/MetaMask/core/pull/8695)) + +### Changed + +- Bump `@metamask/accounts-controller` from `^38.1.0` to `^38.1.1` ([#8774](https://github.com/MetaMask/core/pull/8774)) +- Bump `@metamask/controller-utils` from `^12.0.0` to `^12.1.0` ([#8774](https://github.com/MetaMask/core/pull/8774)) +- Bump `@metamask/profile-sync-controller` from `^28.0.2` to `^28.1.0` ([#8783](https://github.com/MetaMask/core/pull/8783)) + +### Fixed + +- Update HTTP headers from `X-Client-Product`/`X-Client-Version` to `x-metamask-clientproduct`/`x-metamask-clientversion` ([#8798](https://github.com/MetaMask/core/pull/8798)) +- Remove default `clientVersion` value of `1.0.0`; the `x-metamask-clientversion` header is now only sent when `clientVersion` is explicitly provided ([#8798](https://github.com/MetaMask/core/pull/8798)) + +## [6.2.2] + +### Changed + +- Bump `@metamask/accounts-controller` from `^37.1.0` to `^38.1.0` ([#8325](https://github.com/MetaMask/core/pull/8325), [#8363](https://github.com/MetaMask/core/pull/8363), [#8665](https://github.com/MetaMask/core/pull/8665), [#8755](https://github.com/MetaMask/core/pull/8755)) +- Bump `@metamask/keyring-controller` from `^25.1.1` to `^25.5.0` ([#8363](https://github.com/MetaMask/core/pull/8363), [#8634](https://github.com/MetaMask/core/pull/8634), [#8665](https://github.com/MetaMask/core/pull/8665), [#8722](https://github.com/MetaMask/core/pull/8722)) +- Bump `@metamask/profile-sync-controller` from `^28.0.1` to `^28.0.2` ([#8325](https://github.com/MetaMask/core/pull/8325)) +- Bump `@metamask/controller-utils` from `^11.19.0` to `^12.0.0` ([#8344](https://github.com/MetaMask/core/pull/8344), [#8755](https://github.com/MetaMask/core/pull/8755)) +- Bump `@metamask/messenger` from `^1.0.0` to `^1.2.0` ([#8364](https://github.com/MetaMask/core/pull/8364), [#8373](https://github.com/MetaMask/core/pull/8373), [#8632](https://github.com/MetaMask/core/pull/8632)) + +## [6.2.1] + +### Changed + +- Bump `@metamask/accounts-controller` from `^37.0.0` to `^37.1.0` ([#8317](https://github.com/MetaMask/core/pull/8317)) +- Bump `@metamask/keyring-controller` from `^25.1.0` to `^25.1.1` ([#8317](https://github.com/MetaMask/core/pull/8317)) +- Bump `@metamask/messenger` from `^0.3.0` to `^1.0.0` ([#8317](https://github.com/MetaMask/core/pull/8317)) +- Bump `@metamask/profile-sync-controller` from `^28.0.0` to `^28.0.1` ([#8317](https://github.com/MetaMask/core/pull/8317)) + +## [6.2.0] + +### Added + +- Add `includeOccurrences` option to `V3AssetsQueryOptions` so the tokens v3 assets API can be called with `includeOccurrences: true` to return token list occurrence counts in the response ([#8227](https://github.com/MetaMask/core/pull/8227)) + +## [6.1.1] + +### Changed + +- Bump `@metamask/profile-sync-controller` from `^27.1.0` to `^28.0.0` ([#8162](https://github.com/MetaMask/core/pull/8162)) + +## [6.1.0] + +### Added + +- Add `includeAggregators` option to `V3AssetsQueryOptions` so the tokens v3 assets API can be called with `includeAggregators: true` to return DEX/aggregator integrations in the response ([#8021](https://github.com/MetaMask/core/pull/8021)) +- Add `includeTokenSecurityData` option to `fetchV3TrendingTokens` and `getV3TrendingTokensQueryOptions` to request token security data from the API ([#8106](https://github.com/MetaMask/core/pull/8106)) +- Export new types `TokenSecurityData`, `TokenSecurityFeature`, `TokenSecurityHolder`, `TokenSecurityMarket`, `TokenSecurityFees`, `TokenSecurityFinancialStats`, and `TokenSecurityMetadata` ([#8106](https://github.com/MetaMask/core/pull/8106)) +- Add `getV4MultiAccountTransactionsInfiniteQueryOptions` for paginated transaction queries with `useInfiniteQuery` ([#8002](https://github.com/MetaMask/core/pull/8002)) + +### Changed + +- Bump `@metamask/accounts-controller` from `^36.0.0` to `^37.0.0` ([#7996](https://github.com/MetaMask/core/pull/7996), [#8140](https://github.com/MetaMask/core/pull/8140)) +- Bump `@metamask/controller-utils` from `^11.18.0` to `^11.19.0` ([#7995](https://github.com/MetaMask/core/pull/7995)) + +## [6.0.0] + +### 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] + +### Added + +- Add `ApiPlatformClient` for unified access to MetaMask backend APIs with TanStack Query caching ([#7658](https://github.com/MetaMask/core/pull/7658), [#7735](https://github.com/MetaMask/core/pull/7735), [#7686](https://github.com/MetaMask/core/pull/7686)) + - Automatic request deduplication and intelligent caching + - Automatic retries with exponential backoff for transient failures + - Support for Accounts API, Price API, Token API, and Tokens API endpoints + - Export helper functions `shouldRetry` and `calculateRetryDelay` for custom retry logic + - Export API types for external consumers + +### 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)) + - The dependencies moved are: + - `@metamask/accounts-controller` (^35.0.2) + - `@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. + - Note, however, that the versions specified in the client's `package.json` always "win", and you are expected to keep them up to date so as not to break controller and service intercommunication. +- Bump `@metamask/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)) + +## [5.0.0] + +### Changed + +- Bump `@metamask/profile-sync-controller` from `^26.0.0` to `^27.0.0` ([#7202](https://github.com/MetaMask/core/pull/7202)) +- Bump `@metamask/controller-utils` from `^11.15.0` to `^11.16.0` ([#7202](https://github.com/MetaMask/core/pull/7202)) +- **BREAKING:** Bump `@metamask/keyring-controller` from `^24.0.0` to `^25.0.0` ([#7202](https://github.com/MetaMask/core/pull/7202)) +- **BREAKING:** Bump `@metamask/accounts-controller` from `^34.0.0` to `^35.0.0` ([#7202](https://github.com/MetaMask/core/pull/7202)) + +## [4.1.0] + +### Changed + +- Improve WebSocket connection lifecycle tracing in `BackendWebSocketService` ([#7101](https://github.com/MetaMask/core/pull/7101)) + - WebSocket connection duration is now properly reflected in trace span duration instead of only in custom data + - Trace all disconnections (both manual and unexpected) to provide complete connection lifecycle visibility in traces + - Omit `connectionDuration_ms` from disconnection traces when connection never established (onClose without onOpen) +- Update `BackendWebSocketService` default exponential backoff options for reconnection ([#7101](https://github.com/MetaMask/core/pull/7101)) + - Increase default `reconnectDelay` from 500 milliseconds to 10 seconds + - Increase default `maxReconnectDelay` from 30 seconds to 60 seconds +- Simplify WebSocket disconnection code in `BackendWebSocketService` ([#7101](https://github.com/MetaMask/core/pull/7101)) + - Centralize all disconnection logic in `ws.onclose` handler for single source of truth + - Centralize all state changes within `#establishConnection` method - state transitions only occur in `onopen` (CONNECTING → CONNECTED) and `onclose` (any state → DISCONNECTED) + - Add `MANUAL_DISCONNECT_CODE` (4999) and `MANUAL_DISCONNECT_REASON` constants to distinguish manual from unexpected disconnects +- Bump `@ts-bridge/cli` from `^0.6.1` to `^0.6.4` ([#7039](https://github.com/MetaMask/core/pull/7039)) + +### Removed + +- Remove `BackendWebSocketService Channel Message` trace as it provided no useful performance insights ([#7101](https://github.com/MetaMask/core/pull/7101)) + +## [4.0.0] + +### Changed + +- **BREAKING:** Use new `Messenger` from `@metamask/messenger` ([#6823](https://github.com/MetaMask/core/pull/6823)) + - Previously, `AccountActivityService` and `BackendWebSocketService` accepted a `RestrictedMessenger` instance from `@metamask/base-controller`. +- **BREAKING:** Metadata property `anonymous` renamed to `includeInDebugSnapshot` ([#6823](https://github.com/MetaMask/core/pull/6823)) +- **BREAKING:** Bump `@metamask/accounts-controller` from `^33.0.0` to `^34.0.0` ([#6962](https://github.com/MetaMask/core/pull/6962)) +- **BREAKING:** Bump `@metamask/keyring-controller` from `^23.0.0` to `^24.0.0` ([#6962](https://github.com/MetaMask/core/pull/6962)) +- Bump `@metamask/profile-sync-controller` from `^25.1.2` to `^26.0.0` ([#6962](https://github.com/MetaMask/core/pull/6962)) + +### Removed + +- **BREAKING:** Remove exported type aliases and constants that were specific to controller messenger integration ([#6823](https://github.com/MetaMask/core/pull/6823)) + - Removed type exports: `BackendWebSocketServiceAllowedActions`, `BackendWebSocketServiceAllowedEvents`, `AccountActivityServiceAllowedActions`, `AccountActivityServiceAllowedEvents` + - Removed constant exports: `ACCOUNT_ACTIVITY_SERVICE_ALLOWED_ACTIONS`, `ACCOUNT_ACTIVITY_SERVICE_ALLOWED_EVENTS` + - These types and constants were internal implementation details that should not have been exposed. Consumers should use the service-specific messenger types directly. +- Bump `@metamask/profile-sync-controller` from `^25.1.1` to `^25.1.2` ([#6940](https://github.com/MetaMask/core/pull/6940)) + +## [3.0.0] + +### Added + +- Add `forceReconnection()` method to `BackendWebSocketService` for controlled subscription state cleanup ([#6861](https://github.com/MetaMask/core/pull/6861)) + - Performs a controlled disconnect-then-reconnect sequence with exponential backoff + - Useful for recovering from subscription/unsubscription issues and cleaning up orphaned subscriptions + - Add `BackendWebSocketService:forceReconnection` messenger action +- Add stable connection timer to prevent rapid reconnection loops ([#6861](https://github.com/MetaMask/core/pull/6861)) + - Connection must stay stable for 10 seconds before resetting reconnect attempts + - Prevents issues when server accepts connection then immediately closes it + +### Changed + +- Bump `@metamask/base-controller` from `^8.4.1` to `^8.4.2` ([#6917](https://github.com/MetaMask/core/pull/6917)) +- Update `AccountActivityService` to use new `forceReconnection()` method instead of manually calling disconnect/connect ([#6861](https://github.com/MetaMask/core/pull/6861)) +- **BREAKING:** Update allowed actions for `AccountActivityService` messenger: remove `BackendWebSocketService:disconnect`, add `BackendWebSocketService:forceReconnection` ([#6861](https://github.com/MetaMask/core/pull/6861)) +- Improve reconnection scheduling in `BackendWebSocketService` to be idempotent ([#6861](https://github.com/MetaMask/core/pull/6861)) + - Prevents duplicate reconnection timers and inflated attempt counters + - Scheduler checks if reconnect is already scheduled before creating new timer +- Improve error handling in `BackendWebSocketService.connect()` ([#6861](https://github.com/MetaMask/core/pull/6861)) + - Always schedule reconnect on connection failure (exponential backoff prevents aggressive retries) + - Remove redundant schedule calls from error paths +- Update `BackendWebSocketService.disconnect()` to reset reconnect attempts counter ([#6861](https://github.com/MetaMask/core/pull/6861)) +- Update `BackendWebSocketService.disconnect()` return type from `Promise` to `void` ([#6861](https://github.com/MetaMask/core/pull/6861)) +- Improve logging throughout `BackendWebSocketService` for better debugging ([#6861](https://github.com/MetaMask/core/pull/6861)) + +### Fixed + +- Fix potential race condition in `BackendWebSocketService.connect()` that could bypass exponential backoff when reconnect is already scheduled ([#6861](https://github.com/MetaMask/core/pull/6861)) +- Fix memory leak from orphaned timers when multiple reconnects are scheduled ([#6861](https://github.com/MetaMask/core/pull/6861)) +- Fix issue where reconnect attempts counter could grow unnecessarily with duplicate scheduled reconnects ([#6861](https://github.com/MetaMask/core/pull/6861)) + +## [2.1.0] + +### Added + +- Add optional `traceFn` parameter to `AccountActivityService` constructor for performance tracing integration ([#6842](https://github.com/MetaMask/core/pull/6842)) + - Enables tracing of transaction message receipt with elapsed time from transaction timestamp to message arrival + - Trace captures `chain`, `status`, and `elapsed_ms` for monitoring transaction delivery latency + +### Fixed + +- Fix race condition in `BackendWebSocketService.connect()` that could create multiple concurrent WebSocket connections when called simultaneously from multiple event sources (e.g., `KeyringController:unlock`, `AuthenticationController:stateChange`, and `MetaMaskController.isClientOpen`) ([#6842](https://github.com/MetaMask/core/pull/6842)) + - Connection promise is now set synchronously before any async operations to prevent duplicate connections + +## [2.0.0] + +### Added + +- **BREAKING:** Add required argument `channelType` to `BackendWebSocketService.subscribe` method ([#6819](https://github.com/MetaMask/core/pull/6819)) + - Add `channelType` to argument of the `BackendWebSocketService:subscribe` messenger action + - Add `channelType` to `WebSocketSubscription` type +- **BREAKING**: Update `Asset` type definition: add required `decimals` field for proper token amount formatting ([#6819](https://github.com/MetaMask/core/pull/6819)) +- Add optional `traceFn` parameter to `BackendWebSocketService` constructor for performance tracing integration (e.g., Sentry) ([#6819](https://github.com/MetaMask/core/pull/6819)) + - Enables tracing of WebSocket operations including connect, disconnect methods + - Trace function receives operation metadata and callback to wrap for performance monitoring +- Add optional `timestamp` property to `ServerNotificationMessage` and `SystemNoticationData` types ([#6819](https://github.com/MetaMask/core/pull/6819)) +- Add optional `timestamp` property to `AccountActivityService:statusChanged` event and corresponding event type ([#6819](https://github.com/MetaMask/core/pull/6819)) + +### Changed + +- **BREAKING:** Update `BackendWebSocketService` to automatically manage WebSocket connections based on wallet lock state ([#6819](https://github.com/MetaMask/core/pull/6819)) + - `KeyringController:lock` and `KeyringController:unlock` are now required events in the `BackendWebSocketService` messenger +- **BREAKING**: Update `Transaction` type definition: rename `hash` field to `id` for consistency with backend API ([#6819](https://github.com/MetaMask/core/pull/6819)) +- **BREAKING:** Add peer dependency on `@metamask/keyring-controller` (^23.0.0) ([#6819](https://github.com/MetaMask/core/pull/6819)) +- Update `BackendWebSocketService` to simplify reconnection logic: auto-reconnect on any unexpected disconnect (not just code 1000), stay disconnected when manually disconnecting via `disconnect` ([#6819](https://github.com/MetaMask/core/pull/6819)) +- Improve error handling in `BackendWebSocketService.connect()` to properly rethrow errors to callers ([#6819](https://github.com/MetaMask/core/pull/6819)) +- Update `AccountActivityService` to replace API-based chain support detection with system notification-driven chain tracking ([#6819](https://github.com/MetaMask/core/pull/6819)) + - Instead of hardcoding a list of supported chains, assume that the backend has the list + - When receiving a system notification, capture the backend-tracked status of each chain instead of assuming it is up or down + - Flush all tracked chains as 'down' on disconnect/error (instead of using hardcoded list) +- Update documentation in `README.md` to reflect new connection management model and chain tracking behavior ([#6819](https://github.com/MetaMask/core/pull/6819)) + - Add "WebSocket Connection Management" section explaining connection requirements and behavior + - Update sequence diagram to show system notification-driven chain status flow + - Update key flow characteristics to reflect internal chain tracking mechanism + +### Removed + +- **BREAKING**: Remove `getSupportedChains` method from `AccountActivityService` ([#6819](https://github.com/MetaMask/core/pull/6819)) + +## [1.0.1] + +### Changed + +- Bump `@metamask/base-controller` from `^8.4.0` to `^8.4.1` ([#6807](https://github.com/MetaMask/core/pull/6807)) +- Bump `@metamask/controller-utils` from `^11.14.0` to `^11.14.1` ([#6807](https://github.com/MetaMask/core/pull/6807)) +- Bump `@metamask/profile-sync-controller` from `^25.1.0` to `^25.1.1` ([#6810](https://github.com/MetaMask/core/pull/6810)) + +## [1.0.0] + +### Added + +- **Initial release of `@metamask/core-backend` package** - Core backend services for MetaMask serving as the data layer between Backend services and Frontend applications ([#6722](https://github.com/MetaMask/core/pull/6722)) +- **BackendWebSocketService** - WebSocket client providing authenticated real-time data delivery with: + - Connection management and automatic reconnection with exponential backoff + - Message routing and subscription management + - Authentication integration with `AuthenticationController` + - Type-safe messenger-based API for controller integration +- **AccountActivityService** - High-level service for monitoring account activity with: + - Real-time account activity monitoring via WebSocket subscriptions + - Balance update notifications for integration with `TokenBalancesController` + - Chain status change notifications for dynamic polling coordination + - Account subscription management with automatic cleanup +- **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@9.0.0...HEAD +[9.0.0]: https://github.com/MetaMask/core/compare/@metamask/core-backend@8.1.2...@metamask/core-backend@9.0.0 +[8.1.2]: https://github.com/MetaMask/core/compare/@metamask/core-backend@8.1.1...@metamask/core-backend@8.1.2 +[8.1.1]: https://github.com/MetaMask/core/compare/@metamask/core-backend@8.1.0...@metamask/core-backend@8.1.1 +[8.1.0]: https://github.com/MetaMask/core/compare/@metamask/core-backend@8.0.0...@metamask/core-backend@8.1.0 +[8.0.0]: https://github.com/MetaMask/core/compare/@metamask/core-backend@7.0.0...@metamask/core-backend@8.0.0 +[7.0.0]: https://github.com/MetaMask/core/compare/@metamask/core-backend@6.5.0...@metamask/core-backend@7.0.0 +[6.5.0]: https://github.com/MetaMask/core/compare/@metamask/core-backend@6.4.0...@metamask/core-backend@6.5.0 +[6.4.0]: https://github.com/MetaMask/core/compare/@metamask/core-backend@6.3.3...@metamask/core-backend@6.4.0 +[6.3.3]: https://github.com/MetaMask/core/compare/@metamask/core-backend@6.3.2...@metamask/core-backend@6.3.3 +[6.3.2]: https://github.com/MetaMask/core/compare/@metamask/core-backend@6.3.1...@metamask/core-backend@6.3.2 +[6.3.1]: https://github.com/MetaMask/core/compare/@metamask/core-backend@6.3.0...@metamask/core-backend@6.3.1 +[6.3.0]: https://github.com/MetaMask/core/compare/@metamask/core-backend@6.2.2...@metamask/core-backend@6.3.0 +[6.2.2]: https://github.com/MetaMask/core/compare/@metamask/core-backend@6.2.1...@metamask/core-backend@6.2.2 +[6.2.1]: https://github.com/MetaMask/core/compare/@metamask/core-backend@6.2.0...@metamask/core-backend@6.2.1 +[6.2.0]: https://github.com/MetaMask/core/compare/@metamask/core-backend@6.1.1...@metamask/core-backend@6.2.0 +[6.1.1]: https://github.com/MetaMask/core/compare/@metamask/core-backend@6.1.0...@metamask/core-backend@6.1.1 +[6.1.0]: https://github.com/MetaMask/core/compare/@metamask/core-backend@6.0.0...@metamask/core-backend@6.1.0 +[6.0.0]: https://github.com/MetaMask/core/compare/@metamask/core-backend@5.1.1...@metamask/core-backend@6.0.0 +[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 +[4.0.0]: https://github.com/MetaMask/core/compare/@metamask/core-backend@3.0.0...@metamask/core-backend@4.0.0 +[3.0.0]: https://github.com/MetaMask/core/compare/@metamask/core-backend@2.1.0...@metamask/core-backend@3.0.0 +[2.1.0]: https://github.com/MetaMask/core/compare/@metamask/core-backend@2.0.0...@metamask/core-backend@2.1.0 +[2.0.0]: https://github.com/MetaMask/core/compare/@metamask/core-backend@1.0.1...@metamask/core-backend@2.0.0 +[1.0.1]: https://github.com/MetaMask/core/compare/@metamask/core-backend@1.0.0...@metamask/core-backend@1.0.1 +[1.0.0]: https://github.com/MetaMask/core/releases/tag/@metamask/core-backend@1.0.0 diff --git a/packages/core-backend/LICENSE b/packages/core-backend/LICENSE new file mode 100644 index 00000000000..c259cd7ebcf --- /dev/null +++ b/packages/core-backend/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2025 MetaMask + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packages/core-backend/README.md b/packages/core-backend/README.md new file mode 100644 index 00000000000..c42d3a7aaf9 --- /dev/null +++ b/packages/core-backend/README.md @@ -0,0 +1,657 @@ +# `@metamask/core-backend` + +Core backend services for MetaMask, serving as the data layer between Backend services (REST APIs, WebSocket services) and Frontend applications (Extension, Mobile). Provides authenticated real-time data delivery including account activity monitoring, price updates, and WebSocket connection management with type-safe controller integration. + +## Table of Contents + +- [`@metamask/core-backend`](#metamaskcore-backend) + - [Table of Contents](#table-of-contents) + - [Installation](#installation) + - [Quick Start](#quick-start) + - [Basic Usage](#basic-usage) + - [Integration with Controllers](#integration-with-controllers) + - [Architecture \& Design](#architecture--design) + - [Layered Architecture](#layered-architecture) + - [Dependencies Structure](#dependencies-structure) + - [Data Flow](#data-flow) + - [Sequence Diagram: Real-time Account Activity Flow](#sequence-diagram-real-time-account-activity-flow) + - [Key Flow Characteristics](#key-flow-characteristics) + - [WebSocket Connection Management](#websocket-connection-management) + - [Connection Requirements](#connection-requirements) + - [Connection Behavior](#connection-behavior) + - [HTTP API](#http-api) + - [Overview](#overview) + - [Features](#features) + - [Quick Start](#quick-start-1) + - [API Clients](#api-clients) + - [AccountsApiClient](#accountsapiclient) + - [PricesApiClient](#pricesapiclient) + - [TokenApiClient](#tokenapiclient) + - [TokensApiClient](#tokensapiclient) + - [Configuration](#configuration) + - [Cache Management](#cache-management) + - [API Reference](#api-reference) + - [BackendWebSocketService](#backendwebsocketservice) + - [Constructor Options](#constructor-options) + - [Methods](#methods) + - [AccountActivityService](#accountactivityservice) + - [Constructor Options](#constructor-options-1) + - [Methods](#methods-1) + - [Events Published](#events-published) + +## Installation + +```bash +yarn add @metamask/core-backend +``` + +or + +```bash +npm install @metamask/core-backend +``` + +## Quick Start + +### Basic Usage + +**WebSocket for Real-time Updates:** + +```typescript +import { + BackendWebSocketService, + AccountActivityService, +} from '@metamask/core-backend'; + +// Initialize Backend WebSocket service +const backendWebSocketService = new BackendWebSocketService({ + messenger: backendWebSocketServiceMessenger, + url: 'wss://api.metamask.io/ws', + timeout: 15000, + requestTimeout: 20000, +}); + +// Initialize Account Activity service +const accountActivityService = new AccountActivityService({ + messenger: accountActivityMessenger, +}); + +// Connect and subscribe to account activity +await backendWebSocketService.connect(); +await accountActivityService.subscribe({ + address: 'eip155:0:0x742d35cc6634c0532925a3b8d40c4e0e2c6e4e6', +}); + +// Listen for real-time updates +messenger.subscribe('AccountActivityService:transactionUpdated', (tx) => { + console.log('New transaction:', tx); +}); + +messenger.subscribe( + 'AccountActivityService:balanceUpdated', + ({ address, updates }) => { + console.log(`Balance updated for ${address}:`, updates); + }, +); +``` + +**HTTP API for REST Requests:** + +```typescript +import { ApiPlatformClient } from '@metamask/core-backend'; + +// Create API client +const apiClient = new ApiPlatformClient({ + clientProduct: 'metamask-extension', + getBearerToken: async () => authController.getBearerToken(), +}); + +// Fetch data with automatic caching and deduplication +const balances = await apiClient.accounts.fetchV5MultiAccountBalances([ + 'eip155:1:0x742d35cc6634c0532925a3b8d40c4e0e2c6e4e6', +]); + +const prices = await apiClient.prices.fetchV3SpotPrices([ + 'eip155:1/slip44:60', // ETH + 'eip155:1/erc20:0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48', // USDC +]); +``` + +### Integration with Controllers + +```typescript +// Coordinate with TokenBalancesController for fallback polling +messenger.subscribe( + 'BackendWebSocketService:connectionStateChanged', + (info) => { + if (info.state === 'CONNECTED') { + // Reduce polling when WebSocket is active + messenger.call( + 'TokenBalancesController:updateChainPollingConfigs', + { '0x1': { interval: 600000 } }, // 10 min backup polling + { immediateUpdate: false }, + ); + } else { + // Increase polling when WebSocket is down + const defaultInterval = messenger.call( + 'TokenBalancesController:getDefaultPollingInterval', + ); + messenger.call( + 'TokenBalancesController:updateChainPollingConfigs', + { '0x1': { interval: defaultInterval } }, + { immediateUpdate: true }, + ); + } + }, +); + +// Listen for account changes and manage subscriptions +messenger.subscribe( + 'AccountsController:selectedAccountChange', + async (selectedAccount) => { + if (selectedAccount) { + await accountActivityService.subscribe({ + address: selectedAccount.address, + }); + } + }, +); +``` + +## Architecture & Design + +### Layered Architecture + +```mermaid +graph TD + subgraph "FRONTEND" + subgraph "Presentation Layer" + FE[Frontend Applications
MetaMask Extension, Mobile, etc.] + end + + subgraph "Integration Layer" + IL[Controllers, State Management, UI] + end + + subgraph "Data layer (core-backend)" + subgraph "Domain Services" + AAS[AccountActivityService] + PUS[PriceUpdateService
future] + CS[Custom Services...] + end + + subgraph "Transport Layer" + WSS[WebSocketService
• Connection management
• Automatic reconnection
• Message routing
• Subscription management] + HTTP[HTTP API Clients
• REST API calls
• Automatic caching
• Request deduplication
• Retry with backoff] + end + end + end + + subgraph "BACKEND" + BS[Backend Services
REST APIs, WebSocket Services, etc.] + end + + %% Flow connections + FE --> IL + IL --> AAS + IL --> PUS + IL --> CS + AAS --> WSS + AAS --> HTTP + PUS --> WSS + PUS --> HTTP + CS --> WSS + CS --> HTTP + WSS <--> BS + HTTP <--> BS + + %% Styling + classDef frontend fill:#e1f5fe + classDef backend fill:#f3e5f5 + classDef service fill:#e8f5e8 + classDef transport fill:#fff3e0 + + class FE,IL frontend + class BS backend + class AAS,PUS,CS service + class WSS,HTTP transport +``` + +### Dependencies Structure + +```mermaid +graph BT + %% External Controllers + AC["AccountsController
(Auto-generated types)"] + AuthC["AuthenticationController
(Auto-generated types)"] + TBC["TokenBalancesController
(External Integration)"] + + %% Core Services + AA["AccountActivityService"] + WS["BackendWebSocketService"] + + %% Dependencies & Type Imports + AC -.->|"Import types
(DRY)" | AA + AuthC -.->|"Import types
(DRY)" | WS + WS -->|"Messenger calls"| AA + AA -.->|"Event publishing"| TBC + + %% Styling + classDef core fill:#f3e5f5 + classDef integration fill:#fff3e0 + classDef controller fill:#e8f5e8 + + class WS,AA core + class TBC integration + class AC,AuthC controller +``` + +### Data Flow + +#### Sequence Diagram: Real-time Account Activity Flow + +```mermaid +sequenceDiagram + participant TBC as TokenBalancesController + participant AA as AccountActivityService + participant WS as BackendWebSocketService + participant HTTP as HTTP Services
(APIs & RPC) + participant Backend as WebSocket Endpoint
(Backend) + + Note over TBC,Backend: Initial Setup + TBC->>HTTP: Initial balance fetch via HTTP
(first request for current state) + + WS->>Backend: WebSocket connection request + Backend->>WS: Connection established + WS->>AA: WebSocket connection status notification
(BackendWebSocketService:connectionStateChanged)
{state: 'CONNECTED'} + + AA->>AA: call('AccountsController:getSelectedAccount') + AA->>WS: subscribe({channels, callback}) + WS->>Backend: {event: 'subscribe', channels: ['account-activity.v1.eip155:0:0x123...']} + Backend->>WS: {event: 'subscribe-response', subscriptionId: 'sub-456'} + + Note over WS,Backend: System notification sent automatically upon subscription + Backend->>WS: {event: 'system-notification', data: {chainIds: ['eip155:1', 'eip155:137', ...], status: 'up'}} + WS->>AA: System notification received + AA->>AA: Track chains as 'up' internally + AA->>TBC: Chain availability notification
(AccountActivityService:statusChanged)
{chainIds: ['0x1', '0x89', ...], status: 'up'} + TBC->>TBC: Increase polling interval from 20s to 10min
(.updateChainPollingConfigs({0x89: 600000})) + + Note over TBC,Backend: User Account Change + + par StatusChanged Event + TBC->>HTTP: Fetch balances for new account
(fill transition gap) + and Account Subscription + AA->>AA: User switched to different account
(AccountsController:selectedAccountChange) + AA->>WS: subscribe (new account) + WS->>Backend: {event: 'subscribe', channels: ['account-activity.v1.eip155:0:0x456...']} + Backend->>WS: {event: 'subscribe-response', subscriptionId: 'sub-789'} + AA->>WS: unsubscribe (previous account) + WS->>Backend: {event: 'unsubscribe', subscriptionId: 'sub-456'} + Backend->>WS: {event: 'unsubscribe-response'} + end + + + Note over TBC,Backend: Real-time Data Flow + + Backend->>WS: {event: 'notification', channel: 'account-activity.v1.eip155:0:0x123...',
data: {address, tx, updates}} + WS->>AA: Direct callback routing + AA->>AA: Validate & process AccountActivityMessage + + par Balance Update + AA->>TBC: Real-time balance change notification
(AccountActivityService:balanceUpdated)
{address, chain, updates} + TBC->>TBC: Update balance state directly
(or fallback poll if error) + and Transaction and Activity Update (Not yet implemented) + AA->>AA: Process transaction data
(AccountActivityService:transactionUpdated)
{tx: Transaction} + Note right of AA: Future: Forward to TransactionController
for transaction state management
(pending → confirmed → finalized) + end + + Note over TBC,Backend: System Notifications + + Backend->>WS: {event: 'system-notification', data: {chainIds: ['eip155:137'], status: 'down'}} + WS->>AA: System notification received + AA->>AA: Process chain status change + AA->>TBC: Chain status notification
(AccountActivityService:statusChanged)
{chainIds: ['eip155:137'], status: 'down'} + TBC->>TBC: Decrease polling interval from 10min to 20s
(.updateChainPollingConfigs({0x89: 20000})) + TBC->>HTTP: Fetch balances immediately + + Backend->>WS: {event: 'system-notification', data: {chainIds: ['eip155:137'], status: 'up'}} + WS->>AA: System notification received + AA->>AA: Process chain status change + AA->>TBC: Chain status notification
(AccountActivityService:statusChanged)
{chainIds: ['eip155:137'], status: 'up'} + TBC->>TBC: Increase polling interval from 20s to 10min
(.updateChainPollingConfigs({0x89: 600000})) + + Note over TBC,Backend: Connection Health Management + + Backend-->>WS: Connection lost + WS->>AA: WebSocket connection status notification
(BackendWebSocketService:connectionStateChanged)
{state: 'DISCONNECTED'} + AA->>AA: Mark all tracked chains as 'down'
(flush internal tracking set) + AA->>TBC: Chain status notification for all tracked chains
(AccountActivityService:statusChanged)
{chainIds: ['0x1', '0x89', ...], status: 'down'} + TBC->>TBC: Decrease polling interval from 10min to 20s
(.updateChainPollingConfigs({0x89: 20000})) + TBC->>HTTP: Fetch balances immediately + WS->>WS: Automatic reconnection
with exponential backoff + WS->>Backend: Reconnection successful + + Note over AA,Backend: Restart initial setup - resubscribe and get fresh chain status + AA->>WS: subscribe (same account, new subscription) + WS->>Backend: {event: 'subscribe', channels: ['account-activity.v1.eip155:0:0x123...']} + Backend->>WS: {event: 'subscribe-response', subscriptionId: 'sub-999'} + Backend->>WS: {event: 'system-notification', data: {chainIds: [...], status: 'up'}} + WS->>AA: System notification received + AA->>AA: Track chains as 'up' again + AA->>TBC: Chain availability notification
(AccountActivityService:statusChanged)
{chainIds: [...], status: 'up'} + TBC->>TBC: Increase polling interval back to 10min +``` + +#### Key Flow Characteristics + +1. **Initial Setup**: BackendWebSocketService establishes connection, then AccountActivityService subscribes to selected account. Backend automatically sends a system notification with all chains that are currently up. AccountActivityService tracks these chains internally and notifies TokenBalancesController, which increases polling interval to 5 min +2. **Chain Status Tracking**: AccountActivityService maintains an internal set of chains that are 'up' based on system notifications. On disconnect, it marks all tracked chains as 'down' before clearing the set +3. **System Notifications**: Backend automatically sends chain status updates (up/down) upon subscription and when status changes. AccountActivityService forwards these to TokenBalancesController, which adjusts polling intervals (up: 5min, down: 30s + immediate fetch) +4. **User Account Changes**: When users switch accounts, AccountActivityService unsubscribes from old account and subscribes to new account. Backend sends fresh system notification with current chain status for the new account +5. **Connection Resilience**: On reconnection, AccountActivityService resubscribes to selected account and receives fresh chain status via system notification. Automatic reconnection with exponential backoff +6. **Real-time Updates**: Backend pushes data through: Backend → BackendWebSocketService → AccountActivityService → TokenBalancesController (+ future TransactionController integration) +7. **Parallel Processing**: Transaction and balance updates processed simultaneously - AccountActivityService publishes both transactionUpdated (future) and balanceUpdated events in parallel +8. **Direct Balance Processing**: Real-time balance updates bypass HTTP polling and update TokenBalancesController state directly + +## WebSocket Connection Management + +### Connection Requirements + +The WebSocket connects when **ALL 3 conditions are true**: + +1. ✅ **Feature enabled** - `isEnabled()` callback returns `true` (feature flag) +2. ✅ **User signed in** - `AuthenticationController.isSignedIn = true` +3. ✅ **Wallet unlocked** - `KeyringController.isUnlocked = true` + +**Plus:** Platform code must call `connect()` when app opens/foregrounds and `disconnect()` when app closes/backgrounds. + +### Connection Behavior + +**Idempotent `connect()`:** + +- Safe to call multiple times - validates conditions and returns early if already connected +- Multiple rapid calls reuse the same connection promise (no duplicate connections) +- No debouncing needed - handled automatically + +**Auto-Reconnect:** + +- ✅ **Unexpected disconnects** (network issues, server restart) → Auto-reconnect +- ❌ **Manual disconnects** (app backgrounds, wallet locks, user signs out) → Stay disconnected + +## HTTP API + +### Overview + +The HTTP API provides type-safe clients for accessing MetaMask backend REST APIs. It uses `@tanstack/query-core` for intelligent caching, request deduplication, and automatic retries. + +**Available APIs:** + +| API | Base URL | Purpose | +| ------------ | ----------------------------- | ---------------------------------------------- | +| **Accounts** | `accounts.api.cx.metamask.io` | Balances, transactions, NFTs, token discovery | +| **Prices** | `price.api.cx.metamask.io` | Spot prices, exchange rates, historical prices | +| **Token** | `token.api.cx.metamask.io` | Token metadata, trending, top gainers | +| **Tokens** | `tokens.api.cx.metamask.io` | Bulk asset operations, supported networks | + +### Features + +- ✅ **Automatic request deduplication** - Identical concurrent requests share a single network call +- ✅ **Intelligent caching** - Configurable stale times per data type (prices: 30s, balances: 1min, networks: 30min) +- ✅ **Automatic retries** - Exponential backoff with jitter, skips 4xx errors (except 429, 408) +- ✅ **Type safety** - Full TypeScript support with response types +- ✅ **Bearer token caching** - Auth tokens cached for 5 minutes +- ✅ **Unified client** - Single entry point or individual API clients + +### Quick Start + +```typescript +import { + ApiPlatformClient, + createApiPlatformClient, +} from '@metamask/core-backend'; + +// Create unified client +const client = new ApiPlatformClient({ + clientProduct: 'metamask-extension', + clientVersion: '12.0.0', + getBearerToken: async () => authController.getBearerToken(), +}); + +// Access API methods through sub-clients +const networks = await client.accounts.fetchV2SupportedNetworks(); +const balances = await client.accounts.fetchV5MultiAccountBalances([ + 'eip155:1:0x742d35cc6634c0532925a3b8d40c4e0e2c6e4e6', +]); +const prices = await client.prices.fetchV3SpotPrices([ + 'eip155:1/erc20:0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48', +]); +const tokenList = await client.token.fetchTokenList(1); +const assets = await client.tokens.fetchV3Assets([ + 'eip155:1/erc20:0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48', +]); +``` + +Or use individual clients: + +```typescript +import { AccountsApiClient, PricesApiClient } from '@metamask/core-backend'; + +const accountsClient = new AccountsApiClient({ + clientProduct: 'metamask-extension', +}); + +const pricesClient = new PricesApiClient({ + clientProduct: 'metamask-extension', + getBearerToken: async () => token, +}); +``` + +### 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(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 | +| `fetchV6MultiAccountBalances(accountIds, queryOptions?, options?)` | Get balances + DeFi positions + spot prices 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(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(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(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 + +```typescript +type ApiPlatformClientOptions = { + /** Client product identifier (e.g., 'metamask-extension', 'metamask-mobile') */ + clientProduct: string; + /** Optional client version (default: '1.0.0') */ + clientVersion?: string; + /** Function to get bearer token for authenticated requests */ + getBearerToken?: () => Promise; + /** Optional custom QueryClient instance for shared caching */ + queryClient?: QueryClient; +}; +``` + +**Default Stale Times:** + +| Data Type | Stale Time | +| ------------------ | ---------- | +| Prices | 30 seconds | +| Balances | 1 minute | +| Transactions | 30 seconds | +| Networks | 10 minutes | +| Supported Networks | 30 minutes | +| Token Metadata | 5 minutes | +| Token List | 10 minutes | +| Exchange Rates | 5 minutes | +| Trending | 2 minutes | +| Auth Token | 5 minutes | + +**Override Stale Time:** + +```typescript +// Use custom stale time for specific request +const balances = await client.accounts.fetchV5MultiAccountBalances( + accountIds, + { networks: ['eip155:1'] }, + { staleTime: 10000 }, // 10 seconds +); +``` + +### Cache Management + +```typescript +// Invalidate all caches +await client.invalidateAll(); + +// Invalidate auth token (on logout) +await client.invalidateAuthToken(); + +// Domain-specific invalidation +await client.accounts.invalidateBalances(); +await client.prices.invalidatePrices(); +await client.tokens.invalidateTokens(); + +// Clear all cached data +client.clear(); + +// Check if query is fetching +const isFetching = client.isFetching(['accounts', 'balances']); + +// Access cached data directly +const cached = client.getCachedData(['accounts', 'balances', 'v5', { ... }]); + +// Set cached data +client.setCachedData(queryKey, data); + +// Access underlying QueryClient for advanced usage +const queryClient = client.queryClient; +``` + +## API Reference + +### BackendWebSocketService + +The core WebSocket client providing connection management, authentication, and message routing. + +#### Constructor Options + +```typescript +interface BackendWebSocketServiceOptions { + messenger: BackendWebSocketServiceMessenger; + url: string; + timeout?: number; + reconnectDelay?: number; + maxReconnectDelay?: number; + requestTimeout?: number; + enableAuthentication?: boolean; + enabledCallback?: () => boolean; +} +``` + +#### Methods + +- `connect(): Promise` - Establish authenticated WebSocket connection +- `disconnect(): Promise` - Close WebSocket connection +- `subscribe(options: SubscriptionOptions): Promise` - Subscribe to channels +- `sendRequest(message: ClientRequestMessage): Promise` - Send request/response messages +- `channelHasSubscription(channel: string): boolean` - Check subscription status +- `findSubscriptionsByChannelPrefix(prefix: string): SubscriptionInfo[]` - Find subscriptions by prefix +- `getConnectionInfo(): WebSocketConnectionInfo` - Get detailed connection state + +### AccountActivityService + +High-level service for monitoring account activity using WebSocket data. + +#### Constructor Options + +```typescript +interface AccountActivityServiceOptions { + messenger: AccountActivityServiceMessenger; + subscriptionNamespace?: string; +} +``` + +#### Methods + +- `subscribe(subscription: SubscriptionOptions): Promise` - Subscribe to account activity +- `unsubscribe(subscription: SubscriptionOptions): Promise` - Unsubscribe from account activity + +#### Events Published + +- `AccountActivityService:balanceUpdated` - Real-time balance changes +- `AccountActivityService:transactionUpdated` - Transaction status updates +- `AccountActivityService:statusChanged` - Chain/service status changes diff --git a/packages/core-backend/docs/real-time-balance-updates-flow.md b/packages/core-backend/docs/real-time-balance-updates-flow.md new file mode 100644 index 00000000000..3884643ab74 --- /dev/null +++ b/packages/core-backend/docs/real-time-balance-updates-flow.md @@ -0,0 +1,253 @@ +# Real-Time Balance Updates and Status Management Flow + +This document describes the architecture and flow for real-time balance updates and WebSocket status management in MetaMask Core, specifically focusing on the `AccountActivityService:balanceUpdated` and `AccountActivityService:statusChanged` events. + +## Overview + +The system provides real-time balance updates and intelligent polling management through a multi-layered architecture that combines WebSocket streaming with fallback HTTP polling. The key components work together to ensure users receive timely balance updates while optimizing network usage and battery consumption. + +## Architecture Components + +### 1. BackendWebSocketService + +- **Purpose**: Low-level WebSocket connection management +- **Responsibilities**: + - Maintains WebSocket connection with automatic reconnection + - Handles subscription management + - Routes incoming messages to registered callbacks + - Publishes connection state changes + +### 2. AccountActivityService + +- **Purpose**: High-level account activity monitoring +- **Responsibilities**: + - Subscribes to selected account activity + - Processes transaction and balance updates + - Emits `balanceUpdated` and `statusChanged` events + - Manages chain status based on WebSocket connectivity and system notifications + +### 3. TokenBalancesController + +- **Purpose**: Token balance state management and intelligent polling +- **Responsibilities**: + - Maintains token balance state for all accounts + - Implements per-chain configurable polling intervals + - Responds to real-time balance updates from AccountActivityService + - Dynamically adjusts polling based on WebSocket availability + - Imports newly detected tokens via TokenDetectionController + +## Event Flow + +### Balance Update Flow + +``` +┌─────────────────────────────────────────────────────────────────────────┐ +│ BALANCE UPDATE FLOW │ +└─────────────────────────────────────────────────────────────────────────┘ + +1. WebSocket receives account activity message + ↓ +2. BackendWebSocketService routes message to registered callback + ↓ +3. AccountActivityService processes AccountActivityMessage + { + address: "0x123...", + tx: { hash: "0x...", chain: "eip155:1", status: "completed", ... }, + updates: [ + { + asset: { fungible: true, type: "eip155:1/erc20:0x...", unit: "USDT" }, + postBalance: { amount: "1254.75" }, + transfers: [{ from: "0x...", to: "0x...", amount: "500.00" }] + } + ] + } + ↓ +4. AccountActivityService publishes separate events: + - AccountActivityService:transactionUpdated (transaction data) + - AccountActivityService:balanceUpdated (balance updates) + ↓ +5. TokenBalancesController receives balanceUpdated event + ↓ +6. TokenBalancesController processes balance updates: + a. Parses CAIP chain ID (e.g., "eip155:1" → "0x1") + b. Parses asset types: + - ERC20 tokens: "eip155:1/erc20:0x..." → token address + - Native tokens: "eip155:1/slip44:60" → zero address + c. Validates addresses and checksums them + d. Checks if tokens are tracked (imported or detected) + ↓ +7. For tracked tokens: + - Updates tokenBalances state immediately + - Updates AccountTrackerController for native balances + ↓ +8. For untracked ERC20 tokens: + - Queues tokens for import via TokenDetectionController + - Triggers fallback polling to fetch newly imported token balances + ↓ +9. On errors: + - Falls back to HTTP polling for the affected chain +``` + +### Status Change Flow + +The system manages chain status through two primary mechanisms: + +#### A. WebSocket Connection State Changes + +``` +┌─────────────────────────────────────────────────────────────────────────┐ +│ WEBSOCKET CONNECTION STATUS FLOW │ +└─────────────────────────────────────────────────────────────────────────┘ + +1. BackendWebSocketService detects connection state change + (CONNECTING → CONNECTED | DISCONNECTED | ERROR) + ↓ +2. BackendWebSocketService publishes: + BackendWebSocketService:connectionStateChanged + ↓ +3. AccountActivityService receives connection state change + ↓ +4. AccountActivityService determines affected chains: + - Fetches list of supported chains from backend API + - Example: ["eip155:1", "eip155:137", "eip155:56"] + ↓ +5. AccountActivityService publishes status based on connection state: + + IF state === CONNECTED: + → Publishes: statusChanged { chainIds: [...], status: 'up' } + → Triggers resubscription to selected account + + IF state === DISCONNECTED || ERROR: + → Publishes: statusChanged { chainIds: [...], status: 'down' } + ↓ +6. TokenBalancesController receives statusChanged event + ↓ +7. TokenBalancesController applies debouncing (5 second window) + - Accumulates status changes to prevent excessive updates + - Latest status wins for each chain + ↓ +8. After debounce period, processes accumulated changes: + - Converts CAIP format to hex (e.g., "eip155:1" → "0x1") + - Calculates new polling intervals: + * status = 'down' → Uses default interval (30 seconds) + * status = 'up' → Uses extended interval (5 minutes) + ↓ +9. Adds jitter delay (0 to default interval) + - Prevents synchronized requests across instances + ↓ +10. Updates chain polling configurations + - Triggers immediate balance fetch + - Restarts polling with new intervals +``` + +#### B. System Notifications (Per-Chain Status) + +``` +┌─────────────────────────────────────────────────────────────────────────┐ +│ SYSTEM NOTIFICATION STATUS FLOW │ +└─────────────────────────────────────────────────────────────────────────┘ + +1. WebSocket receives system notification message + { + type: 'system', + chainIds: ['eip155:1'], // Specific affected chains + status: 'down' // or 'up' + } + ↓ +2. BackendWebSocketService routes to AccountActivityService + ↓ +3. AccountActivityService validates notification: + - Ensures chainIds array is present and valid + - Ensures status is present + ↓ +4. AccountActivityService publishes delta update: + AccountActivityService:statusChanged + { + chainIds: ['eip155:1'], // Only affected chains + status: 'down' + } + ↓ +5. TokenBalancesController processes (same as WebSocket flow above) +``` + +#### Status Change Event Format + +```typescript +// Event published by AccountActivityService +AccountActivityService:statusChanged +Payload: { + chainIds: string[]; // Array of CAIP chain IDs (e.g., ["eip155:1", "eip155:137"]) + status: 'up' | 'down'; // Connection status +} +``` + +## Polling Strategy + +The TokenBalancesController implements intelligent polling that adapts based on WebSocket availability: + +### Polling Intervals + +| Scenario | Interval | Reason | +| ----------------------------------------- | -------------------- | --------------------------------------------------- | +| WebSocket Connected (`status: 'up'`) | 5 minutes | Real-time updates available, polling is backup only | +| WebSocket Disconnected (`status: 'down'`) | 30 seconds (default) | Primary update mechanism, needs faster polling | +| Per-chain custom configuration | Configurable | Allows fine-tuning per chain requirements | + +### Debouncing Strategy + +To prevent excessive HTTP calls during unstable connections: + +1. **Accumulation Window**: 5 seconds + + - All status changes within this window are accumulated + - Latest status wins for each chain + +2. **Jitter Addition**: Random delay (0 to default interval) + + - Prevents synchronized requests across multiple instances + - Reduces backend load spikes + +3. **Batch Processing**: After debounce + jitter + - All accumulated changes applied at once + - Single polling configuration update + - Immediate balance fetch triggered + +### Per-Chain Polling Configuration + +TokenBalancesController supports per-chain polling intervals: + +```typescript +// Configure custom intervals for specific chains +tokenBalancesController.updateChainPollingConfigs({ + '0x1': { interval: 30000 }, // Ethereum: 30 seconds (default) + '0x89': { interval: 15000 }, // Polygon: 15 seconds (faster) + '0xa4b1': { interval: 60000 }, // Arbitrum: 1 minute (slower) +}); +``` + +## Token Discovery Flow + +When balance updates include previously unknown tokens: + +``` +1. TokenBalancesController receives balance update for unknown token + ↓ +2. Checks if token is tracked (in allTokens or allIgnoredTokens) + ↓ +3. If NOT tracked: + a. Queues token for import + b. Calls TokenDetectionController:addDetectedTokensViaWs + c. Token is added to detected tokens list + ↓ +4. Triggers balance fetch for the chain + ↓ +5. New token balance is fetched and state is updated +``` + +## References + +- [`TokenBalancesController.ts`](../packages/assets-controllers/src/TokenBalancesController.ts) - Main controller implementation +- [`AccountActivityService.ts`](../packages/core-backend/src/AccountActivityService.ts) - Account activity monitoring +- [`BackendWebSocketService.ts`](../packages/core-backend/src/BackendWebSocketService.ts) - WebSocket connection management +- [`types.ts`](../packages/core-backend/src/types.ts) - Type definitions +- [Core Backend README](../packages/core-backend/README.md) - Package overview diff --git a/packages/core-backend/jest.config.js b/packages/core-backend/jest.config.js new file mode 100644 index 00000000000..638112ed628 --- /dev/null +++ b/packages/core-backend/jest.config.js @@ -0,0 +1,30 @@ +/* + * 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, + + // Use jsdom for BackendWebSocketService tests + testEnvironment: 'jsdom', + testEnvironmentOptions: {}, + + // An object that configures minimum threshold enforcement for coverage results + coverageThreshold: { + global: { + branches: 98.89, + functions: 99.27, + lines: 99.78, + statements: 99.78, + }, + }, +}); diff --git a/packages/core-backend/package.json b/packages/core-backend/package.json new file mode 100644 index 00000000000..707d1ba5310 --- /dev/null +++ b/packages/core-backend/package.json @@ -0,0 +1,86 @@ +{ + "name": "@metamask/core-backend", + "version": "9.0.0", + "description": "Core backend services for MetaMask", + "keywords": [ + "Ethereum", + "MetaMask" + ], + "homepage": "https://github.com/MetaMask/core/tree/main/packages/core-backend#readme", + "bugs": { + "url": "https://github.com/MetaMask/core/issues" + }, + "license": "MIT", + "repository": { + "type": "git", + "url": "https://github.com/MetaMask/core.git" + }, + "files": [ + "dist/" + ], + "sideEffects": false, + "main": "./dist/index.cjs", + "types": "./dist/index.d.cts", + "exports": { + ".": { + "import": { + "types": "./dist/index.d.mts", + "default": "./dist/index.mjs" + }, + "require": { + "types": "./dist/index.d.cts", + "default": "./dist/index.cjs" + } + }, + "./package.json": "./package.json" + }, + "publishConfig": { + "access": "public", + "registry": "https://registry.npmjs.org/" + }, + "scripts": { + "build": "ts-bridge --project tsconfig.build.json --verbose --clean --no-references", + "build:all": "ts-bridge --project tsconfig.build.json --verbose --clean", + "build:docs": "typedoc", + "changelog:update": "../../scripts/update-changelog.sh @metamask/core-backend", + "changelog:validate": "../../scripts/validate-changelog.sh @metamask/core-backend", + "lint:tsconfigs": "tsx ../../scripts/lint-tsconfigs/lint-tsconfigs.mts", + "lint:tsconfigs:fix": "tsx ../../scripts/lint-tsconfigs/lint-tsconfigs.mts --fix", + "messenger-action-types:check": "tsx ../../packages/messenger-cli/src/cli.ts --formatter oxfmt --esm --check", + "messenger-action-types:generate": "tsx ../../packages/messenger-cli/src/cli.ts --formatter oxfmt --esm --generate", + "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/account-tree-controller": "^8.0.0", + "@metamask/controller-utils": "^12.3.0", + "@metamask/keyring-controller": "^27.1.1", + "@metamask/messenger": "^2.0.0", + "@metamask/profile-sync-controller": "^29.0.0", + "@metamask/remote-feature-flag-controller": "^6.1.0", + "@metamask/utils": "^11.11.0", + "@tanstack/query-core": "^5.62.16", + "async-mutex": "^0.5.0", + "cockatiel": "^3.1.2", + "uuid": "^8.3.2" + }, + "devDependencies": { + "@metamask/auto-changelog": "^6.1.0", + "@ts-bridge/cli": "^0.6.4", + "@types/jest": "^30.0.0", + "deepmerge": "^4.2.2", + "jest": "^30.4.2", + "jest-environment-jsdom": "^30.4.1", + "ts-jest": "^29.4.11", + "tsx": "^4.20.5", + "typedoc": "^0.25.13", + "typedoc-plugin-missing-exports": "^2.0.0", + "typescript": "~5.3.3" + }, + "engines": { + "node": "^18.18 || >=20" + } +} diff --git a/packages/core-backend/src/ApiPlatformClientService-method-action-types.ts b/packages/core-backend/src/ApiPlatformClientService-method-action-types.ts new file mode 100644 index 00000000000..4ff7a560e78 --- /dev/null +++ b/packages/core-backend/src/ApiPlatformClientService-method-action-types.ts @@ -0,0 +1,24 @@ +/** + * This file is auto generated. + * Do not edit manually. + */ + +import type { ApiPlatformClientService } from './ApiPlatformClientService.js'; + +/** + * 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..abb2a5fa372 --- /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.js'; +import type { ApiPlatformClientServiceMessenger } from './ApiPlatformClientService.js'; + +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..b90f6afec16 --- /dev/null +++ b/packages/core-backend/src/ApiPlatformClientService.ts @@ -0,0 +1,127 @@ +import type { Messenger } from '@metamask/messenger'; + +import { ApiPlatformClient } from './api/index.js'; +import type { ApiPlatformClientOptions } from './api/index.js'; +import type { ApiPlatformClientServiceMethodActions } from './ApiPlatformClientService-method-action-types.js'; + +// === 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/ApiPlatformClient.test.ts b/packages/core-backend/src/api/ApiPlatformClient.test.ts new file mode 100644 index 00000000000..de2a9761559 --- /dev/null +++ b/packages/core-backend/src/api/ApiPlatformClient.test.ts @@ -0,0 +1,702 @@ +/** + * ApiPlatformClient Tests - Core client functionality. + * + * Tests for: constructor, factory function, HTTP headers/errors, + * cache management, query keys, constants, caching behavior, retry behavior, + * URL parameter handling, and helper functions. + * + * See individual test files for endpoint-specific tests: + * - api/accounts-client.test.ts + * - api/prices-client.test.ts + * - api/token-client.test.ts + * - api/tokens-client.test.ts + */ + +import { QueryClient } from '@tanstack/query-core'; + +import { + ApiPlatformClient, + createApiPlatformClient, + API_URLS, + STALE_TIMES, + GC_TIMES, + RETRY_CONFIG, + HttpError, + shouldRetry, + calculateRetryDelay, +} from './index.js'; +import { + mockFetch, + createMockResponse, + setupTestEnvironment, +} from './test-utils.js'; + +describe('ApiPlatformClient', () => { + let client: ApiPlatformClient; + + beforeEach(() => { + ({ client } = setupTestEnvironment()); + }); + + // =========================================================================== + // CONSTRUCTOR TESTS + // =========================================================================== + describe('constructor', () => { + it('creates instance with required options', () => { + const instance = new ApiPlatformClient({ + clientProduct: 'metamask-extension', + }); + expect(instance).toBeInstanceOf(ApiPlatformClient); + }); + + it('creates instance with all options', () => { + const customQueryClient = new QueryClient(); + const getBearerToken = jest.fn().mockResolvedValue('test-token'); + + const instance = new ApiPlatformClient({ + clientProduct: 'metamask-extension', + clientVersion: '11.0.0', + getBearerToken, + queryClient: customQueryClient, + }); + + expect(instance).toBeInstanceOf(ApiPlatformClient); + expect(instance.queryClient).toBe(customQueryClient); + }); + + it('shares the same QueryClient across all sub-clients', () => { + const instance = new ApiPlatformClient({ + clientProduct: 'test-client', + }); + + // All sub-clients should share the same QueryClient instance + expect(instance.accounts.queryClient).toBe(instance.queryClient); + expect(instance.prices.queryClient).toBe(instance.queryClient); + expect(instance.token.queryClient).toBe(instance.queryClient); + expect(instance.tokens.queryClient).toBe(instance.queryClient); + }); + + it('shares provided QueryClient across all sub-clients', () => { + const customQueryClient = new QueryClient(); + const instance = new ApiPlatformClient({ + clientProduct: 'test-client', + queryClient: customQueryClient, + }); + + // All sub-clients should use the provided QueryClient + expect(instance.queryClient).toBe(customQueryClient); + expect(instance.accounts.queryClient).toBe(customQueryClient); + expect(instance.prices.queryClient).toBe(customQueryClient); + expect(instance.token.queryClient).toBe(customQueryClient); + expect(instance.tokens.queryClient).toBe(customQueryClient); + }); + + it('omits clientversion header when not provided', async () => { + const instance = new ApiPlatformClient({ + clientProduct: 'test-client', + queryClient: new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }), + }); + + mockFetch.mockResolvedValueOnce( + createMockResponse({ supportedNetworks: [1, 137] }), + ); + + await instance.accounts.fetchV1SupportedNetworks(); + + expect(mockFetch).toHaveBeenCalledWith( + expect.any(String), + expect.objectContaining({ + headers: expect.not.objectContaining({ + 'x-metamask-clientversion': expect.any(String), + }), + }), + ); + }); + }); + + // =========================================================================== + // FACTORY FUNCTION TESTS + // =========================================================================== + describe('createApiPlatformClient', () => { + it('creates ApiPlatformClient instance', () => { + const instance = createApiPlatformClient({ + clientProduct: 'test-client', + }); + expect(instance).toBeInstanceOf(ApiPlatformClient); + }); + }); + + // =========================================================================== + // HTTP HEADERS TESTS + // =========================================================================== + describe('HTTP headers', () => { + it('includes required headers in requests', async () => { + mockFetch.mockResolvedValueOnce( + createMockResponse({ supportedNetworks: [1] }), + ); + + await client.accounts.fetchV1SupportedNetworks(); + + expect(mockFetch).toHaveBeenCalledWith( + expect.any(String), + expect.objectContaining({ + method: 'GET', + headers: { + 'Content-Type': 'application/json', + 'x-metamask-clientproduct': 'test-client', + 'x-metamask-clientversion': '1.0.0', + }, + }), + ); + }); + + it('includes bearer token when getBearerToken is provided', async () => { + const getBearerToken = jest.fn().mockResolvedValue('my-auth-token'); + const authClient = new ApiPlatformClient({ + clientProduct: 'test-client', + getBearerToken, + queryClient: new QueryClient({ + defaultOptions: { queries: { retry: false, gcTime: 0 } }, + }), + }); + + mockFetch.mockResolvedValueOnce( + createMockResponse({ supportedNetworks: [1] }), + ); + + await authClient.accounts.fetchV1SupportedNetworks(); + + expect(getBearerToken).toHaveBeenCalled(); + expect(mockFetch).toHaveBeenCalledWith( + expect.any(String), + expect.objectContaining({ + headers: expect.objectContaining({ + Authorization: 'Bearer my-auth-token', + }), + }), + ); + }); + + it('does not include Authorization header when getBearerToken returns undefined', async () => { + const getBearerToken = jest.fn().mockResolvedValue(undefined); + const authClient = new ApiPlatformClient({ + clientProduct: 'test-client', + getBearerToken, + queryClient: new QueryClient({ + defaultOptions: { queries: { retry: false, gcTime: 0 } }, + }), + }); + + mockFetch.mockResolvedValueOnce( + createMockResponse({ supportedNetworks: [1] }), + ); + + await authClient.accounts.fetchV1SupportedNetworks(); + + const calledHeaders = mockFetch.mock.calls[0]?.[1]?.headers as Record< + string, + string + >; + expect(calledHeaders.Authorization).toBeUndefined(); + }); + }); + + // =========================================================================== + // HTTP ERROR HANDLING TESTS + // =========================================================================== + describe('HTTP error handling', () => { + it('throws HttpError on non-ok response', async () => { + mockFetch.mockResolvedValueOnce( + createMockResponse({ error: 'Not found' }, 404, 'Not Found'), + ); + + await expect(client.accounts.fetchV1SupportedNetworks()).rejects.toThrow( + HttpError, + ); + }); + + it('httpError contains correct properties', async () => { + mockFetch.mockResolvedValueOnce( + createMockResponse( + { error: 'Server error' }, + 500, + 'Internal Server Error', + ), + ); + + const error = await client.accounts + .fetchV1SupportedNetworks() + .catch((caughtError: unknown) => caughtError); + + expect(error).toBeInstanceOf(HttpError); + expect(error).toMatchObject({ + status: 500, + statusText: 'Internal Server Error', + message: 'HTTP 500: Internal Server Error', + }); + }); + }); + + // =========================================================================== + // CACHE MANAGEMENT TESTS + // =========================================================================== + describe('Cache Management', () => { + it('gets and sets cached data', () => { + const queryKey = ['accounts', 'v1SupportedNetworks']; + const testData = { supportedNetworks: [1, 137] }; + + client.setCachedData(queryKey, testData); + const cachedData = client.getCachedData(queryKey); + + expect(cachedData).toStrictEqual(testData); + }); + + it('returns undefined for uncached data', () => { + const queryKey = ['accounts', 'v1SupportedNetworks']; + const cachedData = client.getCachedData(queryKey); + + expect(cachedData).toBeUndefined(); + }); + + it('resets auth token cache (completely removes it)', async () => { + const queryKey = ['auth', 'bearerToken']; + client.setCachedData(queryKey, 'test-token'); + + await client.invalidateAuthToken(); + + // resetQueries removes the query from cache entirely + const cachedData = client.queryClient.getQueryData(queryKey); + expect(cachedData).toBeUndefined(); + }); + + it('invalidates balances cache via accounts client', async () => { + const queryKey = ['accounts', 'balances', 'v2', { address: '0x123' }]; + client.setCachedData(queryKey, { + count: 1, + balances: [], + unprocessedNetworks: [], + }); + + await client.accounts.invalidateBalances(); + + const queryState = client.queryClient.getQueryState(queryKey); + expect(queryState?.isInvalidated).toBe(true); + }); + + it('invalidates prices cache via prices client', async () => { + const queryKey = ['prices', 'v1SupportedNetworks']; + client.setCachedData(queryKey, { + fullSupport: [], + partialSupport: [], + }); + + await client.prices.invalidatePrices(); + + const queryState = client.queryClient.getQueryState(queryKey); + expect(queryState?.isInvalidated).toBe(true); + }); + + it('invalidates tokens cache via tokens client', async () => { + const queryKey = ['tokens', 'v1SupportedNetworks']; + client.setCachedData(queryKey, { fullSupport: [] }); + + await client.tokens.invalidateTokens(); + + const queryState = client.queryClient.getQueryState(queryKey); + expect(queryState?.isInvalidated).toBe(true); + }); + + it('invalidates accounts cache via accounts client', async () => { + const queryKey = ['accounts', 'v1SupportedNetworks']; + client.setCachedData(queryKey, { supportedNetworks: [] }); + + await client.accounts.invalidateAccounts(); + + const queryState = client.queryClient.getQueryState(queryKey); + expect(queryState?.isInvalidated).toBe(true); + }); + + it('invalidates all caches', async () => { + const accountsKey = ['accounts', 'v1SupportedNetworks']; + const pricesKey = ['prices', 'v1SupportedNetworks']; + const tokensKey = ['tokens', 'v1SupportedNetworks']; + + client.setCachedData(accountsKey, {}); + client.setCachedData(pricesKey, {}); + client.setCachedData(tokensKey, {}); + + await client.invalidateAll(); + + expect(client.queryClient.getQueryState(accountsKey)?.isInvalidated).toBe( + true, + ); + expect(client.queryClient.getQueryState(pricesKey)?.isInvalidated).toBe( + true, + ); + expect(client.queryClient.getQueryState(tokensKey)?.isInvalidated).toBe( + true, + ); + }); + + it('clears all cached data', () => { + const accountsKey = ['accounts', 'v1SupportedNetworks']; + const pricesKey = ['prices', 'v1SupportedNetworks']; + + client.setCachedData(accountsKey, {}); + client.setCachedData(pricesKey, {}); + + client.clear(); + + expect(client.getCachedData(accountsKey)).toBeUndefined(); + expect(client.getCachedData(pricesKey)).toBeUndefined(); + }); + + it('checks if query is fetching', () => { + const queryKey = ['accounts', 'v1SupportedNetworks']; + expect(client.isFetching(queryKey)).toBe(false); + }); + + it('exposes queryClient for advanced usage', () => { + expect(client.queryClient).toBeInstanceOf(QueryClient); + }); + + it('shares QueryClient across all sub-clients', () => { + // All sub-clients should share the same QueryClient + expect(client.accounts.queryClient).toBe(client.queryClient); + expect(client.prices.queryClient).toBe(client.queryClient); + expect(client.token.queryClient).toBe(client.queryClient); + expect(client.tokens.queryClient).toBe(client.queryClient); + }); + }); + + // =========================================================================== + // CONSTANTS TESTS + // =========================================================================== + describe('Constants', () => { + it('exports API URLs', () => { + expect(API_URLS.ACCOUNTS).toBe('https://accounts.api.cx.metamask.io'); + expect(API_URLS.PRICES).toBe('https://price.api.cx.metamask.io'); + expect(API_URLS.TOKEN).toBe('https://token.api.cx.metamask.io'); + expect(API_URLS.TOKENS).toBe('https://tokens.api.cx.metamask.io'); + }); + + it('exports stale times', () => { + expect(STALE_TIMES.PRICES).toBe(30 * 1000); + expect(STALE_TIMES.BALANCES).toBe(60 * 1000); + expect(STALE_TIMES.SUPPORTED_NETWORKS).toBe(30 * 60 * 1000); + }); + + it('exports GC times', () => { + expect(GC_TIMES.DEFAULT).toBe(5 * 60 * 1000); + expect(GC_TIMES.EXTENDED).toBe(30 * 60 * 1000); + expect(GC_TIMES.SHORT).toBe(2 * 60 * 1000); + }); + }); + + // =========================================================================== + // CACHING BEHAVIOR TESTS + // =========================================================================== + describe('Caching Behavior', () => { + it('returns cached data on subsequent calls', async () => { + const mockResponse = { supportedNetworks: [1, 137] }; + mockFetch.mockResolvedValueOnce(createMockResponse(mockResponse)); + + const result1 = await client.accounts.fetchV1SupportedNetworks(); + expect(mockFetch).toHaveBeenCalledTimes(1); + + const result2 = await client.accounts.fetchV1SupportedNetworks(); + expect(mockFetch).toHaveBeenCalledTimes(1); + expect(result2).toStrictEqual(result1); + }); + + it('deduplicates concurrent requests', async () => { + const mockResponse = { supportedNetworks: [1] }; + mockFetch.mockResolvedValue(createMockResponse(mockResponse)); + + const promises = [ + client.accounts.fetchV1SupportedNetworks(), + client.accounts.fetchV1SupportedNetworks(), + client.accounts.fetchV1SupportedNetworks(), + ]; + + await Promise.all(promises); + + expect(mockFetch).toHaveBeenCalledTimes(1); + }); + }); + + // =========================================================================== + // RETRY BEHAVIOR TESTS + // =========================================================================== + describe('Retry Behavior', () => { + it('uses default retry configuration when no custom QueryClient provided', () => { + const defaultClient = new ApiPlatformClient({ + clientProduct: 'test-client', + }); + + expect(defaultClient.queryClient).toBeInstanceOf(QueryClient); + const defaultOptions = defaultClient.queryClient.getDefaultOptions(); + expect(defaultOptions.queries?.retry).toBeDefined(); + }); + + it('retries on 5xx errors up to MAX_RETRIES using shouldRetry', async () => { + // Use actual shouldRetry function (not a hardcoded number) to test real behavior + const retryClient = new ApiPlatformClient({ + clientProduct: 'test-client', + queryClient: new QueryClient({ + defaultOptions: { + queries: { + retry: shouldRetry, + retryDelay: (): number => 0, // No delay for tests + gcTime: 0, + staleTime: 0, + }, + }, + }), + }); + + // With MAX_RETRIES=3: 1 initial + 3 retries = 4 total attempts + mockFetch + .mockResolvedValueOnce( + createMockResponse({}, 500, 'Internal Server Error'), + ) + .mockResolvedValueOnce(createMockResponse({}, 502, 'Bad Gateway')) + .mockResolvedValueOnce( + createMockResponse({}, 503, 'Service Unavailable'), + ) + .mockResolvedValueOnce(createMockResponse({ supportedNetworks: [1] })); + + const result = await retryClient.accounts.fetchV1SupportedNetworks(); + + expect(result).toStrictEqual({ supportedNetworks: [1] }); + // 4 total attempts = 1 initial + MAX_RETRIES (3) retries + expect(mockFetch).toHaveBeenCalledTimes(RETRY_CONFIG.MAX_RETRIES + 1); + }); + + it('retries on 429 rate limit errors using shouldRetry', async () => { + const retryClient = new ApiPlatformClient({ + clientProduct: 'test-client', + queryClient: new QueryClient({ + defaultOptions: { + queries: { + retry: shouldRetry, + retryDelay: (): number => 0, + gcTime: 0, + staleTime: 0, + }, + }, + }), + }); + + mockFetch + .mockResolvedValueOnce(createMockResponse({}, 429, 'Too Many Requests')) + .mockResolvedValueOnce(createMockResponse({ supportedNetworks: [1] })); + + const result = await retryClient.accounts.fetchV1SupportedNetworks(); + + expect(result).toStrictEqual({ supportedNetworks: [1] }); + expect(mockFetch).toHaveBeenCalledTimes(2); + }); + + it('retries on 408 timeout errors using shouldRetry', async () => { + const retryClient = new ApiPlatformClient({ + clientProduct: 'test-client', + queryClient: new QueryClient({ + defaultOptions: { + queries: { + retry: shouldRetry, + retryDelay: (): number => 0, + gcTime: 0, + staleTime: 0, + }, + }, + }), + }); + + mockFetch + .mockResolvedValueOnce(createMockResponse({}, 408, 'Request Timeout')) + .mockResolvedValueOnce(createMockResponse({ supportedNetworks: [1] })); + + const result = await retryClient.accounts.fetchV1SupportedNetworks(); + + expect(result).toStrictEqual({ supportedNetworks: [1] }); + expect(mockFetch).toHaveBeenCalledTimes(2); + }); + + it('does not retry on 4xx errors (except 429/408) using shouldRetry', async () => { + const retryClient = new ApiPlatformClient({ + clientProduct: 'test-client', + queryClient: new QueryClient({ + defaultOptions: { + queries: { + retry: shouldRetry, + retryDelay: (): number => 0, + gcTime: 0, + staleTime: 0, + }, + }, + }), + }); + + mockFetch.mockResolvedValueOnce(createMockResponse({}, 404, 'Not Found')); + + await expect( + retryClient.accounts.fetchV1SupportedNetworks(), + ).rejects.toThrow(HttpError); + // Should NOT retry on 404, so only 1 attempt + expect(mockFetch).toHaveBeenCalledTimes(1); + }); + }); + + // =========================================================================== + // URL PARAMETER HANDLING TESTS + // =========================================================================== + describe('URL Parameter Handling', () => { + it('handles array parameters correctly', async () => { + mockFetch.mockResolvedValueOnce( + createMockResponse({ count: 0, balances: [], unprocessedNetworks: [] }), + ); + + await client.accounts.fetchV5MultiAccountBalances(['id1', 'id2'], { + networks: ['eip155:1', 'eip155:137'], + }); + + const calledUrl = mockFetch.mock.calls[0]?.[0] as string; + expect(calledUrl).toContain('accountIds=id1%2Cid2'); + expect(calledUrl).toContain('networks=eip155%3A1%2Ceip155%3A137'); + }); + + it('handles undefined parameters by not including them', async () => { + mockFetch.mockResolvedValueOnce( + createMockResponse({ count: 0, balances: [], unprocessedNetworks: [] }), + ); + + await client.accounts.fetchV2Balances('0x123', { networks: undefined }); + + const calledUrl = mockFetch.mock.calls[0]?.[0] as string; + expect(calledUrl).not.toContain('networks='); + }); + + it('handles boolean parameters correctly', async () => { + mockFetch.mockResolvedValueOnce(createMockResponse([])); + + await client.token.fetchTokenList(1, { + includeIconUrl: true, + includeOccurrences: false, + }); + + const calledUrl = mockFetch.mock.calls[0]?.[0] as string; + expect(calledUrl).toContain('includeIconUrl=true'); + expect(calledUrl).toContain('includeOccurrences=false'); + }); + }); + + // =========================================================================== + // HELPER FUNCTIONS TESTS + // =========================================================================== + describe('Helper Functions', () => { + describe('shouldRetry', () => { + it('allows retries up to MAX_RETRIES (3 retries = 4 total attempts)', () => { + const error = new Error('test'); + // With MAX_RETRIES=3, we allow retries for failureCount 1, 2, 3 + expect(shouldRetry(1, error)).toBe(true); // 1st retry + expect(shouldRetry(2, error)).toBe(true); // 2nd retry + expect(shouldRetry(3, error)).toBe(true); // 3rd retry (MAX_RETRIES) + expect(shouldRetry(4, error)).toBe(false); // exceeds MAX_RETRIES + expect(shouldRetry(5, error)).toBe(false); + }); + + it('returns false for 4xx errors except 429 and 408', () => { + const error400 = Object.assign(new Error('Bad Request'), { + status: 400, + }); + const error401 = Object.assign(new Error('Unauthorized'), { + status: 401, + }); + const error403 = Object.assign(new Error('Forbidden'), { status: 403 }); + const error404 = Object.assign(new Error('Not Found'), { status: 404 }); + + expect(shouldRetry(0, error400)).toBe(false); + expect(shouldRetry(0, error401)).toBe(false); + expect(shouldRetry(0, error403)).toBe(false); + expect(shouldRetry(0, error404)).toBe(false); + }); + + it('returns true for 429 rate limit errors', () => { + const error429 = Object.assign(new Error('Too Many Requests'), { + status: 429, + }); + expect(shouldRetry(0, error429)).toBe(true); + }); + + it('returns true for 408 timeout errors', () => { + const error408 = Object.assign(new Error('Request Timeout'), { + status: 408, + }); + expect(shouldRetry(0, error408)).toBe(true); + }); + + it('returns true for 5xx server errors', () => { + const error500 = Object.assign(new Error('Internal Server Error'), { + status: 500, + }); + const error502 = Object.assign(new Error('Bad Gateway'), { + status: 502, + }); + const error503 = Object.assign(new Error('Service Unavailable'), { + status: 503, + }); + + expect(shouldRetry(0, error500)).toBe(true); + expect(shouldRetry(0, error502)).toBe(true); + expect(shouldRetry(0, error503)).toBe(true); + }); + + it('returns true for non-Error objects', () => { + expect(shouldRetry(0, 'string error')).toBe(true); + expect(shouldRetry(0, { message: 'object error' })).toBe(true); + expect(shouldRetry(0, null)).toBe(true); + expect(shouldRetry(0, undefined)).toBe(true); + }); + + it('returns true for errors without status property', () => { + const errorWithoutStatus = new Error('Network error'); + expect(shouldRetry(0, errorWithoutStatus)).toBe(true); + }); + }); + + describe('calculateRetryDelay', () => { + it('returns a value between half and full delay for attempt 0', () => { + const delay = calculateRetryDelay(0); + expect(delay).toBeGreaterThanOrEqual(500); + expect(delay).toBeLessThanOrEqual(1000); + }); + + it('increases delay exponentially with attempt index', () => { + const delays0: number[] = []; + const delays1: number[] = []; + const delays2: number[] = []; + + for (let i = 0; i < 10; i++) { + delays0.push(calculateRetryDelay(0)); + delays1.push(calculateRetryDelay(1)); + delays2.push(calculateRetryDelay(2)); + } + + const avgDelay0 = delays0.reduce((a, b) => a + b, 0) / delays0.length; + const avgDelay1 = delays1.reduce((a, b) => a + b, 0) / delays1.length; + const avgDelay2 = delays2.reduce((a, b) => a + b, 0) / delays2.length; + + expect(avgDelay1).toBeGreaterThan(avgDelay0); + expect(avgDelay2).toBeGreaterThan(avgDelay1); + }); + + it('caps delay at MAX_DELAY (5000ms)', () => { + const delay = calculateRetryDelay(20); + expect(delay).toBeLessThanOrEqual(RETRY_CONFIG.MAX_DELAY); + }); + }); + }); +}); diff --git a/packages/core-backend/src/api/ApiPlatformClient.ts b/packages/core-backend/src/api/ApiPlatformClient.ts new file mode 100644 index 00000000000..89187351fbb --- /dev/null +++ b/packages/core-backend/src/api/ApiPlatformClient.ts @@ -0,0 +1,208 @@ +/** + * ApiPlatformClient - MetaMask API Platform Client + * + * A comprehensive API client that uses @tanstack/query-core directly for: + * - Automatic request deduplication + * - Intelligent caching + * - Automatic retries with exponential backoff + * + * Provides unified access to all MetaMask backend APIs: + * - Accounts API (accounts.api.cx.metamask.io) + * - Price API (price.api.cx.metamask.io) + * - Token API (token.api.cx.metamask.io) + * - Tokens API (tokens.api.cx.metamask.io) + * + * @example + * ```typescript + * const client = new ApiPlatformClient({ + * clientProduct: 'metamask-extension', + * getBearerToken: async () => token, + * }); + * + * // Access API methods through sub-clients + * const networks = await client.accounts.fetchV2SupportedNetworks(); + * const balances = await client.accounts.fetchV5MultiAccountBalances(accountIds); + * const prices = await client.prices.fetchV3SpotPrices(assetIds); + * const tokenList = await client.token.fetchTokenList(1); + * const assets = await client.tokens.fetchV3Assets(assetIds); + * + * // Cache management + * await client.invalidateAll(); // Invalidate all caches + * await client.invalidateAuthToken(); // Invalidate auth token + * await client.accounts.invalidateBalances(); // Domain-specific via sub-client + * await client.prices.invalidatePrices(); // Domain-specific via sub-client + * ``` + */ + +import { QueryClient } from '@tanstack/query-core'; +import type { QueryKey } from '@tanstack/query-core'; + +// Import API clients from subfolders +import { AccountsApiClient } from './accounts/index.js'; +import { authQueryKeys } from './base-client.js'; +import { PricesApiClient } from './prices/index.js'; +import { + STALE_TIMES, + GC_TIMES, + shouldRetry, + calculateRetryDelay, +} from './shared-types.js'; +import type { ApiPlatformClientOptions } from './shared-types.js'; +import { TokenApiClient } from './token/index.js'; +import { TokensApiClient } from './tokens/index.js'; + +// ============================================================================ +// UNIFIED API CLIENT +// ============================================================================ + +/** + * MetaMask API Platform Client with TanStack Query caching. + * Provides cached access to all MetaMask backend APIs through a unified interface. + * + * Access API methods through the sub-clients: + * - `client.accounts` - Accounts API (balances, transactions, NFTs, etc.) + * - `client.prices` - Prices API (spot prices, exchange rates, historical prices) + * - `client.token` - Token API (token metadata, trending, top gainers) + * - `client.tokens` - Tokens API (bulk asset operations, supported networks) + */ +export class ApiPlatformClient { + /** + * Accounts API client. + * Provides methods for balances, transactions, relationships, NFTs, and token discovery. + */ + readonly accounts: AccountsApiClient; + + /** + * Prices API client. + * Provides methods for spot prices, exchange rates, and historical prices. + */ + readonly prices: PricesApiClient; + + /** + * Token API client. + * Provides methods for token metadata, networks, trending tokens, and top assets. + */ + readonly token: TokenApiClient; + + /** + * Tokens API client. + * Provides methods for bulk asset operations and supported networks. + */ + readonly tokens: TokensApiClient; + + /** + * Shared QueryClient instance used by all sub-clients. + */ + readonly #sharedQueryClient: QueryClient; + + constructor(options: ApiPlatformClientOptions) { + // Create or use provided QueryClient - shared by all sub-clients + this.#sharedQueryClient = + options.queryClient ?? + new QueryClient({ + defaultOptions: { + queries: { + staleTime: STALE_TIMES.DEFAULT, + gcTime: GC_TIMES.DEFAULT, + retry: shouldRetry, + retryDelay: calculateRetryDelay, + refetchOnWindowFocus: false, + networkMode: 'always', + }, + }, + }); + + // Pass the shared QueryClient to all sub-clients + const sharedOptions: ApiPlatformClientOptions = { + ...options, + queryClient: this.#sharedQueryClient, + }; + + this.accounts = new AccountsApiClient(sharedOptions); + this.prices = new PricesApiClient(sharedOptions); + this.token = new TokenApiClient(sharedOptions); + this.tokens = new TokensApiClient(sharedOptions); + } + + // ========================================================================== + // CACHE MANAGEMENT (operates on shared QueryClient) + // ========================================================================== + + /** + * Get the underlying QueryClient (for advanced usage). + * + * @returns The underlying QueryClient instance. + */ + get queryClient(): QueryClient { + return this.#sharedQueryClient; + } + + /** + * Get cached data for a query key. + * + * @param queryKey - The query key to look up. + * @returns The cached data or undefined. + */ + getCachedData(queryKey: QueryKey): CachedData | undefined { + return this.#sharedQueryClient.getQueryData(queryKey); + } + + /** + * Set cached data for a query key. + * + * @param queryKey - The query key to set data for. + * @param data - The data to cache. + */ + setCachedData(queryKey: QueryKey, data: CachedData): void { + this.#sharedQueryClient.setQueryData(queryKey, data); + } + + /** + * Check if a query is currently fetching. + * + * @param queryKey - The query key to check. + * @returns True if the query is currently fetching. + */ + isFetching(queryKey: QueryKey): boolean { + return this.#sharedQueryClient.isFetching({ queryKey }) > 0; + } + + /** + * Clear all cached data across all sub-clients. + */ + clear(): void { + this.#sharedQueryClient.clear(); + } + + /** + * Invalidate all queries across all sub-clients. + */ + async invalidateAll(): Promise { + await this.#sharedQueryClient.invalidateQueries(); + } + + /** + * Invalidate the cached auth token. + * Call this when the user logs out or the token expires. + * + * Uses resetQueries() instead of invalidateQueries() to completely remove + * the cached value, ensuring the next request fetches a fresh token immediately. + */ + async invalidateAuthToken(): Promise { + await this.#sharedQueryClient.resetQueries({ + queryKey: authQueryKeys.bearerToken(), + }); + } +} + +/** + * Factory function to create an ApiPlatformClient. + * + * @param options - Configuration options for the client. + * @returns A new ApiPlatformClient instance. + */ +export function createApiPlatformClient( + options: ApiPlatformClientOptions, +): ApiPlatformClient { + return new ApiPlatformClient(options); +} diff --git a/packages/core-backend/src/api/accounts/client.test.ts b/packages/core-backend/src/api/accounts/client.test.ts new file mode 100644 index 00000000000..606eff39a3d --- /dev/null +++ b/packages/core-backend/src/api/accounts/client.test.ts @@ -0,0 +1,1060 @@ +/** + * Accounts API Client Tests - accounts.api.cx.metamask.io + */ + +import type { ApiPlatformClient } from '../ApiPlatformClient.js'; +import { API_URLS, HttpError } from '../shared-types.js'; +import { + mockFetch, + createMockResponse, + setupTestEnvironment, +} from '../test-utils.js'; +import type { + V1SupportedNetworksResponse, + V2SupportedNetworksResponse, + V2BalancesResponse, + V5BalancesResponse, + V6BalancesResponse, +} from './types.js'; + +describe('AccountsApiClient', () => { + let client: ApiPlatformClient; + + beforeEach(() => { + ({ client } = setupTestEnvironment()); + }); + + describe('Supported Networks', () => { + it('fetches v1 supported networks', async () => { + const mockResponse: V1SupportedNetworksResponse = { + supportedNetworks: [1, 137, 56, 43114], + }; + mockFetch.mockResolvedValueOnce(createMockResponse(mockResponse)); + + const result = await client.accounts.fetchV1SupportedNetworks(); + + expect(result).toStrictEqual(mockResponse); + expect(mockFetch).toHaveBeenCalledWith( + `${API_URLS.ACCOUNTS}/v1/supportedNetworks`, + expect.any(Object), + ); + }); + + it('fetches v2 supported networks', async () => { + const mockResponse: V2SupportedNetworksResponse = { + fullSupport: [1, 137], + partialSupport: { balances: [56] }, + }; + mockFetch.mockResolvedValueOnce(createMockResponse(mockResponse)); + + const result = await client.accounts.fetchV2SupportedNetworks(); + + expect(result).toStrictEqual(mockResponse); + expect(mockFetch).toHaveBeenCalledWith( + `${API_URLS.ACCOUNTS}/v2/supportedNetworks`, + expect.any(Object), + ); + }); + }); + + describe('Active Networks', () => { + it('fetches v2 active networks with accountIds', async () => { + const mockResponse = { activeNetworks: ['eip155:1', 'eip155:137'] }; + mockFetch.mockResolvedValueOnce(createMockResponse(mockResponse)); + + const accountIds = ['eip155:1:0x123', 'eip155:137:0x456']; + const result = await client.accounts.fetchV2ActiveNetworks(accountIds); + + expect(result).toStrictEqual(mockResponse); + expect(mockFetch).toHaveBeenCalledWith( + expect.stringContaining('/v2/activeNetworks'), + expect.any(Object), + ); + }); + + it('fetches v2 active networks with filter options', async () => { + const mockResponse = { activeNetworks: ['eip155:1'] }; + mockFetch.mockResolvedValueOnce(createMockResponse(mockResponse)); + + const result = await client.accounts.fetchV2ActiveNetworks( + ['eip155:1:0x123'], + { + filterMMListTokens: true, + networks: ['eip155:1'], + }, + ); + + 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', () => { + it('fetches v2 balances for single address', async () => { + const mockResponse: V2BalancesResponse = { + count: 2, + balances: [ + { + object: 'token', + address: '0x0', + symbol: 'ETH', + name: 'Ethereum', + decimals: 18, + chainId: 1, + balance: '1000000000000000000', + }, + ], + unprocessedNetworks: [], + }; + mockFetch.mockResolvedValueOnce(createMockResponse(mockResponse)); + + const result = await client.accounts.fetchV2Balances('0x123abc'); + + expect(result).toStrictEqual(mockResponse); + expect(mockFetch).toHaveBeenCalledWith( + expect.stringContaining('/v2/accounts/0x123abc/balances'), + expect.any(Object), + ); + }); + + it('fetches v2 balances with network filter', async () => { + const mockResponse: V2BalancesResponse = { + count: 1, + balances: [], + unprocessedNetworks: [], + }; + mockFetch.mockResolvedValueOnce(createMockResponse(mockResponse)); + + await client.accounts.fetchV2Balances('0x123abc', { networks: [1, 137] }); + + const calledUrl = mockFetch.mock.calls[0]?.[0] as string; + 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, + unprocessedNetworks: [], + balances: [ + { + object: 'token', + symbol: 'ETH', + name: 'Ethereum', + type: 'native', + decimals: 18, + assetId: 'eip155:1/slip44:60', + balance: '1000000000000000000', + accountId: 'eip155:1:0x123', + }, + ], + }; + mockFetch.mockResolvedValueOnce(createMockResponse(mockResponse)); + + const result = await client.accounts.fetchV5MultiAccountBalances([ + 'eip155:1:0x123', + 'eip155:137:0x456', + ]); + + expect(result).toStrictEqual(mockResponse); + expect(mockFetch).toHaveBeenCalledWith( + expect.stringContaining('/v5/multiaccount/balances'), + expect.any(Object), + ); + }); + + it('fetches v2 balances with additional options', async () => { + const mockResponse: V2BalancesResponse = { + count: 1, + balances: [], + unprocessedNetworks: [], + }; + mockFetch.mockResolvedValueOnce(createMockResponse(mockResponse)); + + await client.accounts.fetchV2Balances('0x123abc', { + networks: [1, 137], + filterSupportedTokens: true, + includeTokenAddresses: ['0xtoken1', '0xtoken2'], + includeStakedAssets: true, + }); + + const calledUrl = mockFetch.mock.calls[0]?.[0] as string; + expect(calledUrl).toContain('/v2/accounts/0x123abc/balances'); + expect(calledUrl).toContain('filterSupportedTokens=true'); + expect(calledUrl).toContain('includeStakedAssets=true'); + }); + + it('fetches v4 multi-account balances', async () => { + const mockResponse = { + count: 2, + balances: [ + { + object: 'token', + address: '0x0', + symbol: 'ETH', + name: 'Ethereum', + decimals: 18, + chainId: 1, + balance: '1000000000000000000', + accountAddress: '0x123', + }, + ], + unprocessedNetworks: [], + }; + mockFetch.mockResolvedValueOnce(createMockResponse(mockResponse)); + + const result = await client.accounts.fetchV4MultiAccountBalances( + ['0x123', '0x456'], + { networks: [1, 137] }, + ); + + expect(result).toStrictEqual(mockResponse); + expect(mockFetch).toHaveBeenCalledWith( + expect.stringContaining('/v4/multiaccount/balances'), + 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(); + }); + + it('fetches v6 multi-account balances with token and defi rows', async () => { + const mockResponse: V6BalancesResponse = { + unprocessedNetworks: ['eip155:1329'], + unprocessedIncludeAssetIds: ['eip155:1/erc20:0xabc'], + balances: [ + { + accountId: 'eip155:1:0x123', + object: 'token', + type: 'erc20', + assetId: 'eip155:1/erc20:0xc02aaa39', + name: 'Wrapped Ether', + symbol: 'WETH', + decimals: 18, + balance: '0.283549083429656057', + price: '2119.66', + }, + { + accountId: 'eip155:1:0x123', + object: 'token', + type: 'erc20', + assetId: + 'stellar:pubnet/asset:USDC-GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN', + name: 'USD Coin', + symbol: 'USDC', + decimals: 7, + balance: '10.5', + metadata: { + limit: '9223372036854775807', + authorized: true, + }, + }, + { + accountId: 'eip155:1:0x123', + object: 'defi', + type: 'erc20', + assetId: 'eip155:1/erc20:0x4fef9d74', + name: 'MetaMask Swaps', + symbol: 'MMS', + decimals: 18, + balance: '1.0', + metadata: { + protocolId: 'metamask', + productName: 'MetaMask Swaps', + groupId: 'group-1', + description: 'MetaMask Swaps on ethereum', + protocolUrl: 'https://metamask.io/', + protocolIconUrl: 'https://example.com/icon.jpg', + positionType: 'deposit', + poolAddress: '0x4fef9d741011476750a243ac70b9789a63dd47df', + }, + }, + ], + }; + mockFetch.mockResolvedValueOnce(createMockResponse(mockResponse)); + + const result = await client.accounts.fetchV6MultiAccountBalances( + ['eip155:1:0x123'], + { + includeDeFiBalances: true, + includePrices: true, + vsCurrency: 'usd', + includeAssetIds: ['eip155:1/erc20:0xabc'], + }, + ); + + expect(result).toStrictEqual(mockResponse); + const calledUrl = mockFetch.mock.calls[0]?.[0] as string; + expect(calledUrl).toContain('/v6/multiaccount/balances'); + expect(calledUrl).toContain('includeDeFiBalances=true'); + expect(calledUrl).toContain('includePrices=true'); + expect(calledUrl).toContain('vsCurrency=usd'); + }); + + it('returns empty balances for empty accountIds in v6', async () => { + const result = await client.accounts.fetchV6MultiAccountBalances([]); + + expect(result).toStrictEqual({ + unprocessedNetworks: [], + unprocessedIncludeAssetIds: [], + balances: [], + }); + expect(mockFetch).not.toHaveBeenCalled(); + }); + }); + + describe('Transactions', () => { + it('fetches transaction by hash', async () => { + const mockResponse = { + hash: '0xabc', + timestamp: '2024-01-01T00:00:00Z', + chainId: 1, + blockNumber: 12345, + blockHash: '0xdef', + gas: 21000, + gasUsed: 21000, + gasPrice: '20000000000', + effectiveGasPrice: '20000000000', + nonce: 0, + cumulativeGasUsed: 21000, + value: '1000000000000000000', + to: '0x456', + from: '0x123', + }; + mockFetch.mockResolvedValueOnce(createMockResponse(mockResponse)); + + const result = await client.accounts.fetchV1TransactionByHash(1, '0xabc'); + + expect(result).toStrictEqual(mockResponse); + expect(mockFetch).toHaveBeenCalledWith( + expect.stringContaining('/v1/networks/1/transactions/0xabc'), + expect.any(Object), + ); + }); + + it('fetches account transactions', async () => { + const mockResponse = { + data: [], + pageInfo: { count: 0, hasNextPage: false }, + }; + mockFetch.mockResolvedValueOnce(createMockResponse(mockResponse)); + + const result = await client.accounts.fetchV1AccountTransactions('0x123'); + + expect(result).toStrictEqual(mockResponse); + expect(mockFetch).toHaveBeenCalledWith( + expect.stringContaining('/v1/accounts/0x123/transactions'), + expect.any(Object), + ); + }); + + it('fetches v4 multi-account transactions', async () => { + const mockResponse = { + unprocessedNetworks: [], + pageInfo: { count: 2, hasNextPage: false }, + data: [ + { + hash: '0xabc123', + timestamp: '2024-01-01T00:00:00Z', + chainId: 1, + blockNumber: 12345, + blockHash: '0xdef', + gas: 21000, + gasUsed: 21000, + gasPrice: '20000000000', + effectiveGasPrice: '20000000000', + nonce: 0, + cumulativeGasUsed: 21000, + value: '1000000000000000000', + to: '0x456', + from: '0x123', + }, + ], + }; + mockFetch.mockResolvedValueOnce(createMockResponse(mockResponse)); + + const result = await client.accounts.fetchV4MultiAccountTransactions( + ['eip155:1:0x123', 'eip155:137:0x456'], + { + networks: ['eip155:1'], + sortDirection: 'DESC', + includeLogs: true, + includeTxMetadata: true, + }, + ); + + expect(result).toStrictEqual(mockResponse); + expect(mockFetch).toHaveBeenCalledWith( + expect.stringContaining('/v4/multiaccount/transactions'), + expect.any(Object), + ); + }); + + 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: [], + pageInfo: { count: 0, hasNextPage: false }, + }; + mockFetch.mockResolvedValueOnce(createMockResponse(mockResponse)); + + const result = await client.accounts.fetchV1AccountTransactions('0x123', { + cursor: 'cursor123', + sortDirection: 'DESC', + }); + + 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('getV4MultiAccountTransactionsInfiniteQueryOptions', () => { + it('returns a queryKey', () => { + const result = + client.accounts.getV4MultiAccountTransactionsInfiniteQueryOptions({ + accountAddresses: ['eip155:0:0xabc'], + networks: ['eip155:1', 'eip155:137'], + sortDirection: 'DESC', + limit: 25, + }); + + expect(result.queryKey).toStrictEqual([ + 'accounts', + 'transactions', + 'v4MultiAccount', + { + accountAddresses: ['eip155:0:0xabc'], + networks: ['eip155:1', 'eip155:137'], + startTimestamp: undefined, + endTimestamp: undefined, + limit: 25, + sortDirection: 'DESC', + includeLogs: undefined, + includeTxMetadata: undefined, + maxLogsPerTx: undefined, + lang: undefined, + }, + ]); + }); + + it('sorts accountAddresses in the queryKey for stability', () => { + const result = + client.accounts.getV4MultiAccountTransactionsInfiniteQueryOptions({ + accountAddresses: ['eip155:0:0xzzz', 'eip155:0:0xaaa'], + }); + + const keyObj = result.queryKey[3]; + expect(keyObj).toMatchObject({ + accountAddresses: ['eip155:0:0xaaa', 'eip155:0:0xzzz'], + }); + }); + + it('sorts networks in the queryKey for stability', () => { + const result = + client.accounts.getV4MultiAccountTransactionsInfiniteQueryOptions({ + accountAddresses: ['eip155:0:0xabc'], + networks: ['eip155:137', 'eip155:1'], + }); + + const keyObj = result.queryKey[3]; + expect(keyObj).toMatchObject({ + networks: ['eip155:1', 'eip155:137'], + }); + }); + + it('uses STALE_TIMES.TRANSACTIONS and GC_TIMES.DEFAULT by default', () => { + const result = + client.accounts.getV4MultiAccountTransactionsInfiniteQueryOptions({ + accountAddresses: ['eip155:0:0xabc'], + }); + + expect(result.staleTime).toBe(30 * 1000); + expect(result.gcTime).toBe(5 * 60 * 1000); + }); + + it('allows overriding staleTime and gcTime via options', () => { + const result = + client.accounts.getV4MultiAccountTransactionsInfiniteQueryOptions( + { accountAddresses: ['eip155:0:0xabc'] }, + { staleTime: 60_000, gcTime: 120_000 }, + ); + + expect(result.staleTime).toBe(60_000); + expect(result.gcTime).toBe(120_000); + }); + + it('queryFn fetches paginated transactions and getNextPageParam uses endCursor', async () => { + const mockResponse = { + unprocessedNetworks: [], + pageInfo: { + count: 1, + hasNextPage: true, + endCursor: 'cursor-page-2', + }, + data: [ + { + hash: '0xabc123', + timestamp: '2024-01-01T00:00:00Z', + chainId: 1, + blockNumber: 12345, + blockHash: '0xdef', + gas: 21000, + gasUsed: 21000, + gasPrice: '20000000000', + effectiveGasPrice: '20000000000', + nonce: 0, + cumulativeGasUsed: 21000, + value: '1000000000000000000', + to: '0x456', + from: '0x123', + }, + ], + }; + mockFetch.mockResolvedValueOnce(createMockResponse(mockResponse)); + + const queryOptions = + client.accounts.getV4MultiAccountTransactionsInfiniteQueryOptions( + { + accountAddresses: ['eip155:1:0x123'], + networks: ['eip155:1'], + sortDirection: 'DESC', + includeLogs: true, + includeTxMetadata: true, + maxLogsPerTx: 10, + lang: 'en', + }, + { initialPageParam: 'cursor-page-1' }, + ); + + expect(typeof queryOptions.queryFn).toBe('function'); + expect(queryOptions.getNextPageParam).toBeDefined(); + + const page = await queryOptions.queryFn({ + pageParam: 'cursor-page-1', + signal: undefined, + }); + expect(page).toStrictEqual(mockResponse); + expect(mockFetch).toHaveBeenCalledWith( + expect.stringContaining('/v4/multiaccount/transactions'), + expect.objectContaining({ + method: 'GET', + signal: undefined, + }), + ); + expect(mockFetch.mock.calls[0]?.[0]).toContain('cursor=cursor-page-1'); + expect(mockFetch.mock.calls[0]?.[0]).toContain( + 'accountAddresses=eip155%3A1%3A0x123', + ); + + expect(queryOptions.getNextPageParam?.(mockResponse)).toBe( + 'cursor-page-2', + ); + expect( + queryOptions.getNextPageParam?.({ + ...mockResponse, + pageInfo: { count: 1, hasNextPage: false }, + }), + ).toBeUndefined(); + }); + }); + }); + + describe('Relationships', () => { + it('fetches account relationship', async () => { + const mockResponse = { + txHash: + '0x4f0ad5dc4b74ad8192d4f7a3f0865719e4f1f168eadc1054bfc3f6a31c963bbe', + chainId: 1, + count: 1, + data: { + hash: '0x4f0ad5dc4b74ad8192d4f7a3f0865719e4f1f168eadc1054bfc3f6a31c963bbe', + timestamp: '2023-07-18T16:32:47.000Z', + chainId: 1, + blockNumber: 17721322, + blockHash: + '0xf6043d4135fdeed008ce6292b1ee270341153a3f81380d92de4c83e4b81eb4cb', + gas: 267118, + gasUsed: 178079, + gasPrice: '53514828218', + effectiveGasPrice: '53514828218', + nonce: 1384, + cumulativeGasUsed: 6305501, + methodId: '0x5a9ef341', + value: '125000000000000000', + to: '0xc5e9ddebb09cd64dfacab4011a0d5cedaf7c9bdb', + from: '0x1db3439a222c519ab44bb1144fc28167b4fa6ee6', + isError: false, + valueTransfers: [ + { + from: '0x1db3439a222c519ab44bb1144fc28167b4fa6ee6', + to: '0xc5e9ddebb09cd64dfacab4011a0d5cedaf7c9bdb', + amount: '125000000000000000', + decimal: 18, + transferType: 'normal', + }, + ], + logs: [], + transactionType: 'GENERIC_CONTRACT_CALL', + transactionCategory: 'CONTRACT_CALL', + readable: 'Unidentified Transaction', + textFunctionSignature: 'reapplySubmission(string,string)', + }, + }; + mockFetch.mockResolvedValueOnce(createMockResponse(mockResponse)); + + const result = await client.accounts.fetchV1AccountRelationship( + 1, + '0x123', + '0x456', + ); + + expect(result).toStrictEqual(mockResponse); + expect(mockFetch).toHaveBeenCalledWith( + expect.stringContaining( + '/v1/networks/1/accounts/0x123/relationships/0x456', + ), + expect.any(Object), + ); + }); + + it('handles relationship error response gracefully', async () => { + mockFetch.mockResolvedValueOnce( + createMockResponse({ error: 'Not found' }, 404, 'Not Found'), + ); + + await expect( + client.accounts.fetchV1AccountRelationship(1, '0x123', '0x456'), + ).rejects.toThrow(HttpError); + }); + + it('throws when relationship fetch fails with body error', async () => { + mockFetch.mockResolvedValueOnce( + createMockResponse( + { + error: { + code: 'RELATIONSHIP_NOT_FOUND', + message: 'No relationship exists', + }, + }, + 404, + 'Not Found', + ), + ); + + await expect( + client.accounts.fetchV1AccountRelationship(1, '0x123', '0x456'), + ).rejects.toThrow(HttpError); + }); + }); + + describe('NFTs', () => { + it('fetches account NFTs', async () => { + const mockResponse = { + data: [ + { + tokenId: '1', + contractAddress: '0xnft', + chainId: 1, + name: 'Test NFT', + }, + ], + pageInfo: { count: 1, hasNextPage: false }, + }; + mockFetch.mockResolvedValueOnce(createMockResponse(mockResponse)); + + const result = await client.accounts.fetchV2AccountNfts('0x123'); + + expect(result).toStrictEqual(mockResponse); + expect(mockFetch).toHaveBeenCalledWith( + expect.stringContaining('/v2/accounts/0x123/nfts'), + expect.any(Object), + ); + }); + + it('fetches account NFTs with cursor but no networks', async () => { + const mockResponse = { + data: [], + pageInfo: { count: 0, hasNextPage: false }, + }; + mockFetch.mockResolvedValueOnce(createMockResponse(mockResponse)); + + const result = await client.accounts.fetchV2AccountNfts('0x123', { + cursor: 'abc123', + }); + + 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', () => { + it('fetches account tokens', async () => { + const mockResponse = { + data: [ + { + address: '0xtoken', + chainId: 1, + symbol: 'TKN', + name: 'Test Token', + decimals: 18, + }, + ], + }; + mockFetch.mockResolvedValueOnce(createMockResponse(mockResponse)); + + const result = await client.accounts.fetchV2AccountTokens('0x123'); + + expect(result).toStrictEqual(mockResponse); + expect(mockFetch).toHaveBeenCalledWith( + expect.stringContaining('/v2/accounts/0x123/tokens'), + expect.any(Object), + ); + }); + + it('fetches account tokens with empty options', async () => { + const mockResponse = { data: [] }; + mockFetch.mockResolvedValueOnce(createMockResponse(mockResponse)); + + const result = await client.accounts.fetchV2AccountTokens('0x123', {}); + + 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('getV6MultiAccountBalancesQueryOptions includes sorted networks, includeAssetIds and excludeAssetIds in queryKey when provided', () => { + const options = client.accounts.getV6MultiAccountBalancesQueryOptions( + ['eip155:1:0xzzz', 'eip155:1:0xaaa'], + { + networks: ['eip155:137', 'eip155:1'], + includeAssetIds: ['eip155:1/erc20:0xddd', 'eip155:1/erc20:0xccc'], + excludeAssetIds: ['eip155:1/erc20:0xbbb', 'eip155:1/erc20:0xaaa'], + includeDeFiBalances: true, + }, + ); + expect(options.queryKey).toStrictEqual([ + 'accounts', + 'balances', + 'v6', + { + accountIds: ['eip155:1:0xaaa', 'eip155:1:0xzzz'], + options: { + networks: ['eip155:1', 'eip155:137'], + includeAssetIds: ['eip155:1/erc20:0xccc', 'eip155:1/erc20:0xddd'], + excludeAssetIds: ['eip155:1/erc20:0xaaa', 'eip155:1/erc20:0xbbb'], + includeDeFiBalances: true, + }, + }, + ]); + }); + + 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('getV6MultiAccountBalancesQueryOptions queryFn returns empty result for empty accountIds without calling fetch', async () => { + const options = client.accounts.getV6MultiAccountBalancesQueryOptions([]); + 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({ + unprocessedNetworks: [], + unprocessedIncludeAssetIds: [], + balances: [], + }); + 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 new file mode 100644 index 00000000000..75a60dd892a --- /dev/null +++ b/packages/core-backend/src/api/accounts/client.ts @@ -0,0 +1,1269 @@ +/** + * Accounts API Client - accounts.api.cx.metamask.io + * + * Handles all account-related API calls including: + * - Supported networks + * - Active networks + * - Balances (v2, v4, v5) + * - Transactions + * - Relationships + * - NFTs + * - Token discovery + */ + +import type { + FetchInfiniteQueryOptions, + FetchQueryOptions, + QueryFunctionContext, +} from '@tanstack/query-core'; + +import { + BaseApiClient, + API_URLS, + STALE_TIMES, + GC_TIMES, +} from '../base-client.js'; +import { getQueryOptionsOverrides } from '../shared-types.js'; +import type { FetchOptions } from '../shared-types.js'; +import type { + V1SupportedNetworksResponse, + V2SupportedNetworksResponse, + V2ActiveNetworksResponse, + V2BalancesResponse, + V4BalancesResponse, + V5BalancesResponse, + V6BalancesResponse, + V6VsCurrency, + V1TransactionByHashResponse, + V1AccountTransactionsResponse, + V4MultiAccountTransactionsResponse, + V1AccountRelationshipResult, + V2NftsResponse, + V2TokensResponse, +} from './types.js'; + +/** + * Accounts API Client. + * Provides methods for interacting with the Accounts API. + */ +export class AccountsApiClient extends BaseApiClient { + // ========================================================================== + // CACHE MANAGEMENT + // ========================================================================== + + /** + * Invalidate all balance queries. + */ + async invalidateBalances(): Promise { + await this.queryClient.invalidateQueries({ + queryKey: ['accounts', 'balances'], + }); + } + + /** + * Invalidate all account queries. + */ + async invalidateAccounts(): Promise { + await this.queryClient.invalidateQueries({ + queryKey: ['accounts'], + }); + } + + // ========================================================================== + // SUPPORTED NETWORKS + // ========================================================================== + + /** + * Returns the TanStack Query options object for v1 supported networks. + * + * @param options - Fetch options including cache settings. + * @returns TanStack Query options for use with useQuery, useSuspenseQuery, etc. + */ + getV1SupportedNetworksQueryOptions( + options?: FetchOptions, + ): FetchQueryOptions { + return { + queryKey: ['accounts', 'v1SupportedNetworks'], + queryFn: ({ signal }: QueryFunctionContext) => + this.fetch( + API_URLS.ACCOUNTS, + '/v1/supportedNetworks', + { signal }, + ), + ...getQueryOptionsOverrides(options), + staleTime: options?.staleTime ?? STALE_TIMES.SUPPORTED_NETWORKS, + gcTime: options?.gcTime ?? GC_TIMES.EXTENDED, + }; + } + + /** + * Get list of supported networks (v1 endpoint). + * + * @param options - Fetch options including cache settings. + * @returns The list of supported networks. + */ + async fetchV1SupportedNetworks( + options?: FetchOptions, + ): 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( + API_URLS.ACCOUNTS, + '/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), + ); + } + + // ========================================================================== + // ACTIVE NETWORKS + // ========================================================================== + + /** + * 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 TanStack Query options for use with useQuery, useSuspenseQuery, etc. + */ + getV2ActiveNetworksQueryOptions( + accountIds: string[], + queryOptions?: { filterMMListTokens?: boolean; networks?: string[] }, + options?: FetchOptions, + ): FetchQueryOptions { + return { + queryKey: [ + 'accounts', + 'v2ActiveNetworks', + { + accountIds: [...accountIds].sort(), + options: queryOptions && { + ...queryOptions, + networks: + queryOptions.networks && [...queryOptions.networks].sort(), + }, + }, + ], + queryFn: async ({ + signal, + }: QueryFunctionContext): Promise => { + if (accountIds.length === 0) { + return { activeNetworks: [] }; + } + return this.fetch( + API_URLS.ACCOUNTS, + '/v2/activeNetworks', + { + signal, + params: { + accountIds, + filterMMListTokens: queryOptions?.filterMMListTokens, + networks: queryOptions?.networks, + }, + }, + ); + }, + ...getQueryOptionsOverrides(options), + staleTime: options?.staleTime ?? STALE_TIMES.NETWORKS, + gcTime: options?.gcTime ?? GC_TIMES.DEFAULT, + }; + } + + /** + * Get active networks by CAIP-10 account IDs (v2 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 options - Fetch options including cache settings. + * @returns The active networks response. + */ + async fetchV2ActiveNetworks( + accountIds: string[], + queryOptions?: { filterMMListTokens?: boolean; networks?: string[] }, + options?: FetchOptions, + ): Promise { + if (accountIds.length === 0) { + return { activeNetworks: [] }; + } + return this.queryClient.fetchQuery( + this.getV2ActiveNetworksQueryOptions(accountIds, queryOptions, options), + ); + } + + // ========================================================================== + // BALANCES + // ========================================================================== + + /** + * Returns the TanStack Query options object for v2 balances. + * + * @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 TanStack Query options for use with useQuery, useSuspenseQuery, etc. + */ + getV2BalancesQueryOptions( + address: string, + queryOptions?: { + networks?: number[]; + filterSupportedTokens?: boolean; + includeTokenAddresses?: string[]; + includeStakedAssets?: boolean; + }, + options?: FetchOptions, + ): FetchQueryOptions { + return { + queryKey: [ + 'accounts', + 'balances', + 'v2', + { + address, + options: queryOptions && { + ...queryOptions, + networks: + queryOptions.networks && [...queryOptions.networks].sort(), + includeTokenAddresses: + queryOptions.includeTokenAddresses && + [...queryOptions.includeTokenAddresses].sort(), + }, + }, + ], + queryFn: async ({ + signal, + }: QueryFunctionContext): Promise => { + if (address === '') { + return { count: 0, balances: [], unprocessedNetworks: [] }; + } + return this.fetch( + API_URLS.ACCOUNTS, + `/v2/accounts/${address}/balances`, + { + signal, + params: { + networks: queryOptions?.networks, + filterSupportedTokens: queryOptions?.filterSupportedTokens, + includeTokenAddresses: queryOptions?.includeTokenAddresses, + includeStakedAssets: queryOptions?.includeStakedAssets, + }, + }, + ); + }, + ...getQueryOptionsOverrides(options), + staleTime: options?.staleTime ?? STALE_TIMES.BALANCES, + gcTime: options?.gcTime ?? GC_TIMES.DEFAULT, + }; + } + + /** + * 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 TanStack Query options for use with useQuery, useSuspenseQuery, etc. + */ + getV4MultiAccountBalancesQueryOptions( + accountAddresses: string[], + queryOptions?: { networks?: number[] }, + options?: FetchOptions, + ): FetchQueryOptions { + return { + queryKey: [ + 'accounts', + 'balances', + 'v4', + { + accountAddresses: [...accountAddresses].sort(), + options: queryOptions && { + ...queryOptions, + networks: + queryOptions.networks && [...queryOptions.networks].sort(), + }, + }, + ], + queryFn: async ({ + signal, + }: QueryFunctionContext): Promise => { + if (accountAddresses.length === 0) { + return { count: 0, balances: [], unprocessedNetworks: [] }; + } + return this.fetch( + API_URLS.ACCOUNTS, + '/v4/multiaccount/balances', + { + signal, + params: { + accountAddresses, + networks: queryOptions?.networks, + }, + }, + ); + }, + ...getQueryOptionsOverrides(options), + staleTime: options?.staleTime ?? STALE_TIMES.BALANCES, + gcTime: options?.gcTime ?? GC_TIMES.DEFAULT, + }; + } + + /** + * 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. + * @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 TanStack Query options for use with useQuery, useSuspenseQuery, etc. + */ + getV5MultiAccountBalancesQueryOptions( + accountIds: string[], + queryOptions?: { + filterMMListTokens?: boolean; + networks?: string[]; + includeStakedAssets?: boolean; + }, + options?: FetchOptions, + ): FetchQueryOptions { + return { + queryKey: [ + 'accounts', + 'balances', + 'v5', + { + accountIds: [...accountIds].sort(), + options: queryOptions && { + ...queryOptions, + networks: + queryOptions.networks && [...queryOptions.networks].sort(), + }, + }, + ], + queryFn: async ({ + signal, + }: QueryFunctionContext): Promise => { + if (accountIds.length === 0) { + return { count: 0, unprocessedNetworks: [], balances: [] }; + } + return this.fetch( + API_URLS.ACCOUNTS, + '/v5/multiaccount/balances', + { + signal, + params: { + accountIds, + networks: queryOptions?.networks, + filterMMListTokens: queryOptions?.filterMMListTokens, + 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, + ), + ); + } + + /** + * Returns the TanStack Query options object for v6 multi-account balances. + * The v6 endpoint returns token balances and, optionally, DeFi positions and + * spot prices. + * + * @param accountIds - Array of CAIP-10 account IDs. + * @param queryOptions - Query filter options. + * @param queryOptions.networks - Comma-separated CAIP-2 chain IDs to filter by. + * @param queryOptions.filterSupportedTokens - Whether to filter the assets to only tokens existing in the Token API. + * @param queryOptions.startTimestamp - Start timestamp (epoch) from which to return results. + * @param queryOptions.endTimestamp - End timestamp (epoch) for which to return results. + * @param queryOptions.includeLabels - Whether to include asset metadata labels in the response. + * @param queryOptions.includeCanonicalHead - Whether to include the canonical head asset ID in the response. + * @param queryOptions.includeDeFiBalances - Whether to include DeFi positions (token balances are always returned). + * @param queryOptions.forceFetchDeFiPositions - Whether to fetch DeFi positions for all accounts, skipping the cached non-DeFi-user check. + * @param queryOptions.includePrices - Whether to include spot prices for each token and DeFi position asset. + * @param queryOptions.vsCurrency - Quote currency for spot prices when `includePrices` is true (default `usd`). + * @param queryOptions.includeAssetIds - ERC-20 CAIP-19 asset IDs to confirm detection for; undetected IDs are returned in `unprocessedIncludeAssetIds`. + * @param queryOptions.excludeAssetIds - ERC-20 CAIP-19 asset IDs to exclude from balance results. + * @param options - Fetch options including cache settings. + * @returns TanStack Query options for use with useQuery, useSuspenseQuery, etc. + */ + getV6MultiAccountBalancesQueryOptions( + accountIds: string[], + queryOptions?: { + networks?: string[]; + filterSupportedTokens?: boolean; + startTimestamp?: number; + endTimestamp?: number; + includeLabels?: boolean; + includeCanonicalHead?: boolean; + includeDeFiBalances?: boolean; + forceFetchDeFiPositions?: boolean; + includePrices?: boolean; + vsCurrency?: V6VsCurrency; + includeAssetIds?: string[]; + excludeAssetIds?: string[]; + }, + options?: FetchOptions, + ): FetchQueryOptions { + return { + queryKey: [ + 'accounts', + 'balances', + 'v6', + { + accountIds: [...accountIds].sort(), + options: queryOptions && { + ...queryOptions, + networks: + queryOptions.networks && [...queryOptions.networks].sort(), + includeAssetIds: + queryOptions.includeAssetIds && + [...queryOptions.includeAssetIds].sort(), + excludeAssetIds: + queryOptions.excludeAssetIds && + [...queryOptions.excludeAssetIds].sort(), + }, + }, + ], + queryFn: async ({ + signal, + }: QueryFunctionContext): Promise => { + if (accountIds.length === 0) { + return { + unprocessedNetworks: [], + unprocessedIncludeAssetIds: [], + balances: [], + }; + } + return this.fetch( + API_URLS.ACCOUNTS, + '/v6/multiaccount/balances', + { + signal, + params: { + accountIds, + networks: queryOptions?.networks, + filterSupportedTokens: queryOptions?.filterSupportedTokens, + startTimestamp: queryOptions?.startTimestamp, + endTimestamp: queryOptions?.endTimestamp, + includeLabels: queryOptions?.includeLabels, + includeCanonicalHead: queryOptions?.includeCanonicalHead, + includeDeFiBalances: queryOptions?.includeDeFiBalances, + forceFetchDeFiPositions: queryOptions?.forceFetchDeFiPositions, + includePrices: queryOptions?.includePrices, + vsCurrency: queryOptions?.vsCurrency, + includeAssetIds: queryOptions?.includeAssetIds, + excludeAssetIds: queryOptions?.excludeAssetIds, + }, + }, + ); + }, + ...getQueryOptionsOverrides(options), + staleTime: options?.staleTime ?? STALE_TIMES.BALANCES, + gcTime: options?.gcTime ?? GC_TIMES.DEFAULT, + }; + } + + /** + * Get balances and DeFi positions for multiple accounts using CAIP-10 IDs + * (v6 endpoint). + * + * @param accountIds - Array of CAIP-10 account IDs. + * @param queryOptions - Query filter options. + * @param queryOptions.networks - Comma-separated CAIP-2 chain IDs to filter by. + * @param queryOptions.filterSupportedTokens - Whether to filter the assets to only tokens existing in the Token API. + * @param queryOptions.startTimestamp - Start timestamp (epoch) from which to return results. + * @param queryOptions.endTimestamp - End timestamp (epoch) for which to return results. + * @param queryOptions.includeLabels - Whether to include asset metadata labels in the response. + * @param queryOptions.includeCanonicalHead - Whether to include the canonical head asset ID in the response. + * @param queryOptions.includeDeFiBalances - Whether to include DeFi positions (token balances are always returned). + * @param queryOptions.forceFetchDeFiPositions - Whether to fetch DeFi positions for all accounts, skipping the cached non-DeFi-user check. + * @param queryOptions.includePrices - Whether to include spot prices for each token and DeFi position asset. + * @param queryOptions.vsCurrency - Quote currency for spot prices when `includePrices` is true (default `usd`). + * @param queryOptions.includeAssetIds - ERC-20 CAIP-19 asset IDs to confirm detection for; undetected IDs are returned in `unprocessedIncludeAssetIds`. + * @param queryOptions.excludeAssetIds - ERC-20 CAIP-19 asset IDs to exclude from balance results. + * @param options - Fetch options including cache settings. + * @returns The multi-account balances and DeFi positions response. + */ + async fetchV6MultiAccountBalances( + accountIds: string[], + queryOptions?: { + networks?: string[]; + filterSupportedTokens?: boolean; + startTimestamp?: number; + endTimestamp?: number; + includeLabels?: boolean; + includeCanonicalHead?: boolean; + includeDeFiBalances?: boolean; + forceFetchDeFiPositions?: boolean; + includePrices?: boolean; + vsCurrency?: V6VsCurrency; + includeAssetIds?: string[]; + excludeAssetIds?: string[]; + }, + options?: FetchOptions, + ): Promise { + if (accountIds.length === 0) { + return { + unprocessedNetworks: [], + unprocessedIncludeAssetIds: [], + balances: [], + }; + } + return this.queryClient.fetchQuery( + this.getV6MultiAccountBalancesQueryOptions( + accountIds, + queryOptions, + options, + ), + ); + } + + // ========================================================================== + // TRANSACTIONS + // ========================================================================== + + /** + * Returns the TanStack Query options object for v1 transaction by hash. + * + * @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 TanStack Query options for use with useQuery, useSuspenseQuery, etc. + */ + getV1TransactionByHashQueryOptions( + chainId: number, + txHash: string, + queryOptions?: { + includeLogs?: boolean; + includeValueTransfers?: boolean; + includeTxMetadata?: boolean; + lang?: string; + }, + options?: FetchOptions, + ): FetchQueryOptions { + return { + queryKey: [ + 'accounts', + 'transactions', + 'v1ByHash', + { chainId, txHash, options: queryOptions }, + ], + queryFn: ({ signal }: QueryFunctionContext) => + this.fetch( + API_URLS.ACCOUNTS, + `/v1/networks/${chainId}/transactions/${txHash}`, + { + signal, + params: { + includeLogs: queryOptions?.includeLogs, + includeValueTransfers: queryOptions?.includeValueTransfers, + includeTxMetadata: queryOptions?.includeTxMetadata, + lang: queryOptions?.lang, + }, + }, + ), + ...getQueryOptionsOverrides(options), + staleTime: options?.staleTime ?? STALE_TIMES.TRANSACTIONS, + gcTime: options?.gcTime ?? GC_TIMES.DEFAULT, + }; + } + + /** + * 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. + * @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 TanStack Query options for use with useQuery, useInfiniteQuery, useSuspenseQuery, etc. + */ + getV1AccountTransactionsQueryOptions( + address: string, + queryOptions?: { + chainIds?: string[]; + cursor?: string; + startTimestamp?: number; + endTimestamp?: number; + sortDirection?: 'ASC' | 'DESC'; + }, + options?: FetchOptions, + ): FetchQueryOptions { + return { + queryKey: [ + 'accounts', + 'transactions', + 'v1Account', + { + address, + options: queryOptions && { + ...queryOptions, + chainIds: + queryOptions.chainIds && [...queryOptions.chainIds].sort(), + }, + }, + ], + queryFn: async ({ + signal, + }: QueryFunctionContext): Promise => { + if (address === '') { + return { data: [], pageInfo: { count: 0, hasNextPage: false } }; + } + return this.fetch( + API_URLS.ACCOUNTS, + `/v1/accounts/${address}/transactions`, + { + signal, + params: { + networks: queryOptions?.chainIds, + cursor: queryOptions?.cursor, + startTimestamp: queryOptions?.startTimestamp, + endTimestamp: queryOptions?.endTimestamp, + sortDirection: queryOptions?.sortDirection, + }, + }, + ); + }, + ...getQueryOptionsOverrides(options), + staleTime: options?.staleTime ?? STALE_TIMES.TRANSACTIONS, + gcTime: options?.gcTime ?? GC_TIMES.DEFAULT, + }; + } + + /** + * Get account transactions (v1 endpoint). + * + * @param address - The account address. + * @param queryOptions - Query filter options. + * @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.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 Query options object compatible with fetchQuery/useQuery/useInfiniteQuery. + */ + getV4MultiAccountTransactionsQueryOptions( + 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, + ): FetchQueryOptions { + return { + queryKey: [ + 'accounts', + 'transactions', + 'v4MultiAccount', + { + accountAddresses: [...accountAddresses].sort(), + options: queryOptions && { + ...queryOptions, + networks: + queryOptions.networks && [...queryOptions.networks].sort(), + }, + }, + ], + queryFn: ({ signal }: QueryFunctionContext) => + this.fetch( + API_URLS.ACCOUNTS, + '/v4/multiaccount/transactions', + { + signal, + params: { + 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, + includeTxMetadata: queryOptions?.includeTxMetadata, + maxLogsPerTx: queryOptions?.maxLogsPerTx, + lang: queryOptions?.lang, + }, + }, + ), + ...getQueryOptionsOverrides(options), + staleTime: options?.staleTime ?? STALE_TIMES.TRANSACTIONS, + gcTime: options?.gcTime ?? GC_TIMES.DEFAULT, + }; + } + + /** + * Returns TanStack Query options for v4 multi-account transactions, + * designed for use with `useInfiniteQuery`. + * + * @param params - API endpoint parameters (excluding pagination cursors). + * @param params.accountAddresses - Array of CAIP-10 account addresses. + * @param params.networks - CAIP-2 network IDs to filter by. + * @param params.startTimestamp - Start timestamp (epoch). + * @param params.endTimestamp - End timestamp (epoch). + * @param params.limit - Max transactions per page (default 50). + * @param params.sortDirection - Sort direction (ASC/DESC). + * @param params.includeLogs - Whether to include logs. + * @param params.includeTxMetadata - Whether to include transaction metadata. + * @param params.maxLogsPerTx - Max logs per transaction. + * @param params.lang - Language for transaction category (default "en"). + * @param options - Fetch options including cache settings. + * @returns Options object compatible with `useInfiniteQuery`. + */ + getV4MultiAccountTransactionsInfiniteQueryOptions( + params: { + accountAddresses: string[]; + networks?: string[]; + startTimestamp?: number; + endTimestamp?: number; + limit?: number; + sortDirection?: 'ASC' | 'DESC'; + includeLogs?: boolean; + includeTxMetadata?: boolean; + maxLogsPerTx?: number; + lang?: string; + }, + options?: FetchOptions, + ): FetchInfiniteQueryOptions< + V4MultiAccountTransactionsResponse, + Error, + V4MultiAccountTransactionsResponse, + readonly unknown[], + string | undefined + > { + return { + queryKey: [ + 'accounts', + 'transactions', + 'v4MultiAccount', + { + accountAddresses: [...params.accountAddresses].sort(), + networks: params.networks && [...params.networks].sort(), + startTimestamp: params.startTimestamp, + endTimestamp: params.endTimestamp, + limit: params.limit, + sortDirection: params.sortDirection, + includeLogs: params.includeLogs, + includeTxMetadata: params.includeTxMetadata, + maxLogsPerTx: params.maxLogsPerTx, + lang: params.lang, + }, + ] as const, + queryFn: ({ + pageParam, + signal, + }: { + pageParam?: string; + signal?: AbortSignal; + }) => + this.fetch( + API_URLS.ACCOUNTS, + '/v4/multiaccount/transactions', + { + signal, + params: { + accountAddresses: params.accountAddresses, + networks: params.networks, + startTimestamp: params.startTimestamp, + endTimestamp: params.endTimestamp, + cursor: pageParam, + limit: params.limit, + sortDirection: params.sortDirection, + includeLogs: params.includeLogs, + includeTxMetadata: params.includeTxMetadata, + maxLogsPerTx: params.maxLogsPerTx, + lang: params.lang, + }, + }, + ), + getNextPageParam: ({ pageInfo }: V4MultiAccountTransactionsResponse) => + pageInfo.hasNextPage ? pageInfo.endCursor : undefined, + initialPageParam: options?.initialPageParam, + ...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). + * + * @param chainId - The chain ID. + * @param from - The from address. + * @param to - The to address. + * @param options - Fetch options including cache settings. + * @returns The account relationship result. + */ + async fetchV1AccountRelationship( + chainId: number, + from: string, + to: string, + options?: FetchOptions, + ): Promise { + return this.queryClient.fetchQuery( + this.getV1AccountRelationshipQueryOptions(chainId, from, to, options), + ); + } + + // ========================================================================== + // NFTs + // ========================================================================== + + /** + * 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 TanStack Query options for use with useQuery, useSuspenseQuery, etc. + */ + getV2AccountNftsQueryOptions( + address: string, + queryOptions?: { networks?: number[]; cursor?: string }, + options?: FetchOptions, + ): FetchQueryOptions { + return { + queryKey: [ + 'accounts', + 'v2Nfts', + { + address, + options: queryOptions && { + ...queryOptions, + networks: + queryOptions.networks && [...queryOptions.networks].sort(), + }, + }, + ], + queryFn: async ({ + signal, + }: QueryFunctionContext): Promise => { + if (address === '') { + return { data: [], pageInfo: { count: 0, hasNextPage: false } }; + } + return this.fetch( + API_URLS.ACCOUNTS, + `/v2/accounts/${address}/nfts`, + { + signal, + params: { + networks: queryOptions?.networks, + 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), + ); + } + + // ========================================================================== + // TOKEN DISCOVERY + // ========================================================================== + + /** + * 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 TanStack Query options for use with useQuery, useSuspenseQuery, etc. + */ + getV2AccountTokensQueryOptions( + address: string, + queryOptions?: { networks?: number[] }, + options?: FetchOptions, + ): FetchQueryOptions { + return { + queryKey: [ + 'accounts', + 'v2Tokens', + { + address, + options: queryOptions && { + ...queryOptions, + networks: + queryOptions.networks && [...queryOptions.networks].sort(), + }, + }, + ], + 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/accounts/index.ts b/packages/core-backend/src/api/accounts/index.ts new file mode 100644 index 00000000000..07a445044cb --- /dev/null +++ b/packages/core-backend/src/api/accounts/index.ts @@ -0,0 +1,31 @@ +/** + * Accounts API barrel export. + */ + +export { AccountsApiClient } from './client.js'; +export { V6_DEFI_POSITION_TYPES } from './types.js'; +export type { + V5BalanceItem, + V5BalancesResponse, + V2BalanceItem, + V2BalancesResponse, + V4BalancesResponse, + V6VsCurrency, + V6DeFiPositionType, + V6BalanceMetadata, + V6TokenMetadata, + V6BalanceItem, + V6BalancesResponse, + V1SupportedNetworksResponse, + V2SupportedNetworksResponse, + V2ActiveNetworksResponse, + V1TransactionByHashResponse, + V1AccountTransactionsResponse, + V4MultiAccountTransactionsResponse, + ValueTransfer, + V1AccountRelationshipResult, + NftItem, + V2NftsResponse, + TokenDiscoveryItem, + V2TokensResponse, +} from './types.js'; diff --git a/packages/core-backend/src/api/accounts/types.ts b/packages/core-backend/src/api/accounts/types.ts new file mode 100644 index 00000000000..bf789a277d6 --- /dev/null +++ b/packages/core-backend/src/api/accounts/types.ts @@ -0,0 +1,342 @@ +/** + * Accounts API types for the API Platform Client. + * API: accounts.api.cx.metamask.io + */ + +import type { PageInfo } from '../shared-types.js'; + +// ============================================================================ +// BALANCE TYPES +// ============================================================================ + +/** V5 Balance Item from Accounts API */ +export type V5BalanceItem = { + object: 'token'; + symbol: string; + name: string; + type: 'native' | 'erc20'; + decimals: number; + assetId: string; + balance: string; + accountId: string; +}; + +/** V5 Multi-account balances response */ +export type V5BalancesResponse = { + count: number; + unprocessedNetworks: string[]; + balances: V5BalanceItem[]; +}; + +/** V2 Balance item */ +export type V2BalanceItem = { + object: string; + type?: string; + timestamp?: string; + address: string; + symbol: string; + name: string; + decimals: number; + chainId: number; + balance: string; + accountAddress?: string; +}; + +/** V2 Balances response */ +export type V2BalancesResponse = { + count: number; + balances: V2BalanceItem[]; + unprocessedNetworks: number[]; +}; + +/** V4 Multi-account balances response */ +export type V4BalancesResponse = { + count: number; + balances: V2BalanceItem[]; + unprocessedNetworks: number[]; +}; + +/** + * Quote currency accepted by the v6 balances endpoint when `includePrices` is + * true. A superset of {@link SupportedCurrency} (adds e.g. `sol`, `xdr`, + * `xag`, `xau`, `bits`, `sats`). Defaults to `usd`. + */ +export type V6VsCurrency = string; + +/** + * Possible `positionType` values on DeFi rows in the v6 balances response. + * Categorizes the protocol module where the position is held. + * Link here: https://developers.zerion.io/api-reference/wallets/get-wallet-fungible-positions#parameter-filter-position-types + */ +export const V6_DEFI_POSITION_TYPES = [ + 'deposit', + 'loan', + 'locked', + 'staked', + 'reward', + 'wallet', + 'investment', +] as const; + +/** + * The specific module or functionality within a DeFi protocol where a position + * is held. + */ +export type V6DeFiPositionType = (typeof V6_DEFI_POSITION_TYPES)[number]; + +/** + * DeFi protocol metadata attached to an `object: defi` row in the v6 balances + * response (`BalanceMetadataV3ResponseDto`). + */ +export type V6BalanceMetadata = { + protocolId: string; + productName: string; + description: string; + protocolUrl: string; + protocolIconUrl?: string; + positionType: V6DeFiPositionType; + poolAddress: string; + groupId: string; +}; + +/** + * Token-level metadata attached to an `object: token` row in the v6 balances + * response, e.g. Stellar trustline metadata. Additional keys may be present. + */ +export type V6TokenMetadata = { + /** Stellar trustline limit. */ + limit?: string; + /** Whether the Stellar trustline is authorized. */ + authorized?: boolean; + [key: string]: unknown; +}; + +/** + * A single balance row in the v6 balances response (`BalanceV3ResponseDto`). + * `object: token` rows are token balances (and may carry + * {@link V6TokenMetadata}, e.g. Stellar trustline info). `object: defi` rows + * are flat DeFi positions and include {@link V6BalanceMetadata}. + */ +export type V6BalanceItem = { + accountId: string; + object: 'token' | 'defi'; + /** Asset standard reported by the network (for example `native` or `erc20`). */ + type: string; + assetId: string; + name: string; + symbol: string; + decimals: number; + balance: string; + /** Spot price in the requested `vsCurrency`. Present when `includePrices` is true. */ + price?: string; + /** Asset metadata labels. Present when `includeLabels` is true. */ + labels?: string[]; + /** Canonical head asset ID. Present when `includeCanonicalHead` is true. */ + canonicalHead?: string; + /** + * DeFi protocol metadata for `object: defi` rows; token-level metadata such + * as Stellar trustline info (e.g. `limit`, `authorized`) for `object: token` + * rows. + */ + metadata?: V6BalanceMetadata | V6TokenMetadata; +}; + +/** + * V6 multi-account balances response (`MultiAccountBalancesV3ResponseDto`). + */ +export type V6BalancesResponse = { + /** CAIP-2 networks that could not be processed for this request. */ + unprocessedNetworks: string[]; + /** + * ERC-20 IDs from `includeAssetIds` that were not detected on any requested + * account, plus other IDs that still need a client fallback flow. + */ + unprocessedIncludeAssetIds: string[]; + /** Flat token and DeFi balance rows. */ + balances: V6BalanceItem[]; + /** + * CAIP-10 account IDs whose DeFi positions are still being indexed upstream; + * poll again shortly. DeFi balance rows for these accounts are omitted from + * `balances` until indexing completes. + */ + processingDefiPositions?: string[]; +}; + +// ============================================================================ +// SUPPORTED NETWORKS TYPES +// ============================================================================ + +/** V1 Supported networks response */ +export type V1SupportedNetworksResponse = { + supportedNetworks: number[]; +}; + +/** V2 Supported networks response */ +export type V2SupportedNetworksResponse = { + fullSupport: number[]; + partialSupport: { + balances: number[]; + }; +}; + +/** Active networks response */ +export type V2ActiveNetworksResponse = { + activeNetworks: string[]; +}; + +// ============================================================================ +// TRANSACTION TYPES +// ============================================================================ + +/** Transaction by hash response */ +export type V1TransactionByHashResponse = { + hash: string; + timestamp: string; + chainId: number; + blockNumber: number; + blockHash: string; + gas: number; + gasUsed: number; + gasPrice: string; + effectiveGasPrice: string; + nonce: number; + cumulativeGasUsed: number; + methodId?: string; + value: string; + to: string; + from: string; + isError?: boolean; + valueTransfers?: { + from: string; + to: string; + amount: string; + decimal: number; + contractAddress: string; + symbol: string; + name: string; + transferType: string; + }[]; + logs?: { + data: string; + topics: string[]; + address: string; + logIndex: number; + }[]; + transactionType?: string; + transactionCategory?: string; + transactionProtocol?: string; +}; + +/** Account transactions response */ +export type V1AccountTransactionsResponse = { + data: V1TransactionByHashResponse[]; + pageInfo: PageInfo; +}; + +/** V4 Multi-account transactions response */ +export type V4MultiAccountTransactionsResponse = { + unprocessedNetworks: string[]; + pageInfo: { + count: number; + hasNextPage: boolean; + endCursor?: string; + }; + data: V1TransactionByHashResponse[]; +}; + +// ============================================================================ +// RELATIONSHIP TYPES +// ============================================================================ + +/** + * Value transfer within a transaction + */ +export type ValueTransfer = { + from: string; + to: string; + amount: string; + decimal: number; + transferType: string; +}; + +/** + * Account address relationship result from v1 endpoint + */ +export type V1AccountRelationshipResult = { + /** Transaction hash of the relationship */ + txHash?: string; + /** Chain ID */ + chainId?: number; + /** Number of interactions */ + count?: number; + /** Transaction data details */ + data?: { + hash: string; + timestamp: string; + chainId: number; + blockNumber: number; + blockHash: string; + gas: number; + gasUsed: number; + gasPrice: string; + effectiveGasPrice: string; + nonce: number; + cumulativeGasUsed: number; + methodId: string; + value: string; + to: string; + from: string; + isError: boolean; + valueTransfers: ValueTransfer[]; + logs: unknown[]; + transactionType: string; + transactionCategory: string; + readable: string; + textFunctionSignature: string; + }; + /** Error information when relationship lookup fails */ + error?: { + code: string; + message: string; + }; +}; + +// ============================================================================ +// NFT TYPES +// ============================================================================ + +/** NFT item */ +export type NftItem = { + tokenId: string; + contractAddress: string; + chainId: number; + name?: string; + description?: string; + imageUrl?: string; + attributes?: Record[]; +}; + +/** NFTs response */ +export type V2NftsResponse = { + data: NftItem[]; + pageInfo: PageInfo; +}; + +// ============================================================================ +// TOKEN DISCOVERY TYPES +// ============================================================================ + +/** Token discovery item */ +export type TokenDiscoveryItem = { + address: string; + chainId: number; + symbol: string; + name: string; + decimals: number; + balance?: string; +}; + +/** Tokens response */ +export type V2TokensResponse = { + data: TokenDiscoveryItem[]; +}; diff --git a/packages/core-backend/src/api/base-client.test.ts b/packages/core-backend/src/api/base-client.test.ts new file mode 100644 index 00000000000..c7951618f2b --- /dev/null +++ b/packages/core-backend/src/api/base-client.test.ts @@ -0,0 +1,63 @@ +/** + * Base API Client Tests + */ + +import { QueryClient } from '@tanstack/query-core'; + +import { AccountsApiClient } from './accounts/index.js'; +import { authQueryKeys } from './base-client.js'; + +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 + const client = new AccountsApiClient({ + clientProduct: 'test-product', + }); + + // Verify a QueryClient was created + expect(client.queryClient).toBeInstanceOf(QueryClient); + }); + + it('uses provided QueryClient when given', () => { + const providedQueryClient = new QueryClient(); + + const client = new AccountsApiClient({ + clientProduct: 'test-product', + queryClient: providedQueryClient, + }); + + expect(client.queryClient).toBe(providedQueryClient); + }); + + it('uses default client version when none provided', () => { + const client = new AccountsApiClient({ + clientProduct: 'test-product', + }); + + // The default version is '1.0.0' - we can verify this indirectly + // by checking the client was created successfully + expect(client.queryClient).toBeInstanceOf(QueryClient); + }); + }); +}); diff --git a/packages/core-backend/src/api/base-client.ts b/packages/core-backend/src/api/base-client.ts new file mode 100644 index 00000000000..db8ff3fbe1b --- /dev/null +++ b/packages/core-backend/src/api/base-client.ts @@ -0,0 +1,186 @@ +/** + * Base API Client - Shared HTTP functionality for all API clients. + */ + +import { QueryClient } from '@tanstack/query-core'; +import type { QueryKey } from '@tanstack/query-core'; + +import { + API_URLS, + STALE_TIMES, + GC_TIMES, + calculateRetryDelay, + shouldRetry, + HttpError, +} from './shared-types.js'; +import type { ApiPlatformClientOptions } from './shared-types.js'; + +// Auth query keys - shared for token management across clients +export const authQueryKeys = { + bearerToken: (): QueryKey => ['auth', 'bearerToken'], +} as const; + +export type { ApiPlatformClientOptions }; + +/** + * Internal fetch options for HTTP requests. + */ +export type InternalFetchOptions = { + signal?: AbortSignal; + params?: Record< + string, + string | string[] | number | number[] | boolean | undefined + >; +}; + +/** + * Base API Client with shared HTTP and caching functionality. + * Extended by all specific API clients. + */ +export class BaseApiClient { + protected readonly clientProduct: string; + + protected readonly clientVersion?: string; + + protected readonly getBearerToken?: () => Promise; + + readonly #queryClientInstance: QueryClient; + + /** + * Get the underlying QueryClient instance. + * Exposed for cache management operations. + * + * @returns The QueryClient instance. + */ + get queryClient(): QueryClient { + return this.#queryClientInstance; + } + + /** + * Invalidate the cached auth token. + * Call this when the user logs out or the token expires. + * + * Uses resetQueries() instead of invalidateQueries() to completely remove + * the cached value, ensuring the next request fetches a fresh token immediately. + */ + async invalidateAuthToken(): Promise { + await this.#queryClientInstance.resetQueries({ + queryKey: authQueryKeys.bearerToken(), + }); + } + + constructor(options: ApiPlatformClientOptions) { + this.clientProduct = options.clientProduct; + this.clientVersion = options.clientVersion; + this.getBearerToken = options.getBearerToken; + + this.#queryClientInstance = + options.queryClient ?? + new QueryClient({ + defaultOptions: { + queries: { + staleTime: STALE_TIMES.DEFAULT, + gcTime: GC_TIMES.DEFAULT, + retry: shouldRetry, + retryDelay: calculateRetryDelay, + refetchOnWindowFocus: false, + networkMode: 'always', + }, + }, + }); + } + + /** + * Internal HTTP fetch method with authentication and error handling. + * + * @param baseUrl - The base URL for the API. + * @param path - The API endpoint path. + * @param options - Optional fetch configuration. + * @returns The parsed JSON response. + */ + protected async fetch( + baseUrl: string, + path: string, + options?: InternalFetchOptions, + ): Promise { + const url = new URL(path, baseUrl); + + if (options?.params) { + for (const [key, value] of Object.entries(options.params)) { + if (value === undefined) { + continue; + } + if (Array.isArray(value)) { + // Convert array values (including number[]) to comma-separated string + url.searchParams.set(key, value.map(String).join(',')); + } else { + url.searchParams.set(key, String(value)); + } + } + } + + const headers: Record = { + 'Content-Type': 'application/json', + 'x-metamask-clientproduct': this.clientProduct, + }; + + if (this.clientVersion) { + headers['x-metamask-clientversion'] = this.clientVersion; + } + + // Get bearer token using fetchQuery for automatic deduplication + if (this.getBearerToken) { + const queryKey = authQueryKeys.bearerToken(); + const { getBearerToken } = this; + + try { + // fetchQuery handles caching and deduplicates concurrent requests + const token = await this.#queryClientInstance.fetchQuery({ + queryKey, + queryFn: async () => { + const result = await getBearerToken(); + // Throw if no token - prevents caching null/undefined + // so subsequent requests can retry (e.g., after user logs in) + if (!result) { + throw new Error('No bearer token available'); + } + return result; + }, + staleTime: STALE_TIMES.AUTH_TOKEN, + retry: false, // Don't retry auth failures + }); + + headers.Authorization = `Bearer ${token}`; + } catch { + // No token available - continue without auth header + } + } + + const response = await fetch(url.toString(), { + method: 'GET', + headers, + signal: options?.signal, + }); + + if (!response.ok) { + let body: unknown; + try { + body = await response.json(); + } catch { + // Response body is not JSON or is empty, leave body as undefined + } + throw new HttpError( + `HTTP ${response.status}: ${response.statusText}`, + response.status, + response.statusText, + url.toString(), + body, + ); + } + + return response.json() as Promise; + } +} + +// Re-export constants for use by API clients +export { API_URLS, STALE_TIMES, GC_TIMES, HttpError }; diff --git a/packages/core-backend/src/api/index.ts b/packages/core-backend/src/api/index.ts new file mode 100644 index 00000000000..3bcf99fd2e4 --- /dev/null +++ b/packages/core-backend/src/api/index.ts @@ -0,0 +1,95 @@ +/** + * API barrel export. + * Re-exports all types and clients from the API folder. + */ + +// Shared types and utilities +export type { + PageInfo, + SupportedCurrency, + MarketDataDetails, + ApiPlatformClientOptions, + FetchOptions, +} from './shared-types.js'; +export { + API_URLS, + STALE_TIMES, + GC_TIMES, + RETRY_CONFIG, + calculateRetryDelay, + getQueryOptionsOverrides, + shouldRetry, + HttpError, +} from './shared-types.js'; + +// Accounts API +export { AccountsApiClient, V6_DEFI_POSITION_TYPES } from './accounts/index.js'; +export type { + V5BalanceItem, + V5BalancesResponse, + V2BalanceItem, + V2BalancesResponse, + V4BalancesResponse, + V6VsCurrency, + V6DeFiPositionType, + V6BalanceMetadata, + V6TokenMetadata, + V6BalanceItem, + V6BalancesResponse, + V1SupportedNetworksResponse, + V2SupportedNetworksResponse, + V2ActiveNetworksResponse, + V1TransactionByHashResponse, + V1AccountTransactionsResponse, + V4MultiAccountTransactionsResponse, + ValueTransfer, + V1AccountRelationshipResult, + NftItem, + V2NftsResponse, + TokenDiscoveryItem, + V2TokensResponse, +} from './accounts/index.js'; + +// Prices API +export { PricesApiClient } from './prices/index.js'; +export type { + V3SpotPricesResponse, + CoinGeckoSpotPrice, + ExchangeRateInfo, + V1ExchangeRatesResponse, + PriceSupportedNetworksResponse, + V1HistoricalPricesResponse, + V3HistoricalPricesResponse, +} from './prices/index.js'; + +// Token API +export { TokenApiClient } from './token/index.js'; +export type { + TokenMetadata, + V1TokenDescriptionResponse, + NetworkInfo, + TopAsset, + TrendingSortBy, + TrendingToken, + TopGainersSortOption, + TrendingSortOption, + V1SuggestedOccurrenceFloorsResponse, +} from './token/index.js'; + +// Tokens API +export { TokensApiClient } from './tokens/index.js'; +export type { + V1TokenSupportedNetworksResponse, + V2TokenSupportedNetworksResponse, + V3AssetResponse, +} from './tokens/index.js'; + +// Base client +export { BaseApiClient } from './base-client.js'; +export type { InternalFetchOptions } from './base-client.js'; + +// API Platform Client (unified client) +export { + ApiPlatformClient, + createApiPlatformClient, +} from './ApiPlatformClient.js'; diff --git a/packages/core-backend/src/api/prices/client.test.ts b/packages/core-backend/src/api/prices/client.test.ts new file mode 100644 index 00000000000..237d4ed5a5a --- /dev/null +++ b/packages/core-backend/src/api/prices/client.test.ts @@ -0,0 +1,674 @@ +/** + * Prices API Client Tests - price.api.cx.metamask.io + */ + +import type { ApiPlatformClient } from '../ApiPlatformClient.js'; +import { API_URLS } from '../shared-types.js'; +import type { FetchOptions } from '../shared-types.js'; +import { + createMockResponse, + mockFetch, + setupTestEnvironment, +} from '../test-utils.js'; +import type { + PriceSupportedNetworksResponse, + V1ExchangeRatesResponse, + V3SpotPricesResponse, +} from './types.js'; + +describe('PricesApiClient', () => { + let client: ApiPlatformClient; + + beforeEach(() => { + ({ client } = setupTestEnvironment()); + }); + + describe('Supported Networks', () => { + it('fetches price v1 supported networks', async () => { + const mockResponse = { + fullSupport: ['0x1', '0x89'], + partialSupport: ['0x38'], + }; + mockFetch.mockResolvedValueOnce(createMockResponse(mockResponse)); + + const result = await client.prices.fetchPriceV1SupportedNetworks(); + + expect(result).toStrictEqual(mockResponse); + expect(mockFetch).toHaveBeenCalledWith( + `${API_URLS.PRICES}/v1/supportedNetworks`, + expect.any(Object), + ); + }); + + it('fetches price v2 supported networks', async () => { + const mockResponse = { + fullSupport: ['eip155:1', 'eip155:137'], + partialSupport: ['eip155:56'], + }; + mockFetch.mockResolvedValueOnce(createMockResponse(mockResponse)); + + const result = await client.prices.fetchPriceV2SupportedNetworks(); + + expect(result).toStrictEqual(mockResponse); + expect(mockFetch).toHaveBeenCalledWith( + `${API_URLS.PRICES}/v2/supportedNetworks`, + expect.any(Object), + ); + }); + }); + + describe('Exchange Rates', () => { + it('fetches exchange rates for base currency', async () => { + const mockResponse: V1ExchangeRatesResponse = { + USD: { + name: 'US Dollar', + ticker: 'USD', + value: 1, + currencyType: 'fiat', + }, + EUR: { name: 'Euro', ticker: 'EUR', value: 0.85, currencyType: 'fiat' }, + }; + mockFetch.mockResolvedValueOnce(createMockResponse(mockResponse)); + + const result = await client.prices.fetchV1ExchangeRates('USD'); + + expect(result).toStrictEqual(mockResponse); + expect(mockFetch).toHaveBeenCalledWith( + expect.stringContaining('/v1/exchange-rates'), + expect.any(Object), + ); + }); + + 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: { + name: 'US Dollar', + ticker: 'USD', + value: 1, + currencyType: 'fiat', + }, + }; + mockFetch.mockResolvedValueOnce(createMockResponse(mockResponse)); + + const result = await client.prices.fetchV1FiatExchangeRates(); + + expect(result).toStrictEqual(mockResponse); + expect(mockFetch).toHaveBeenCalledWith( + expect.stringContaining('/v1/exchange-rates/fiat'), + expect.any(Object), + ); + }); + + it('fetches crypto exchange rates', async () => { + const mockResponse: V1ExchangeRatesResponse = { + BTC: { + name: 'Bitcoin', + ticker: 'BTC', + value: 45000, + currencyType: 'crypto', + }, + }; + mockFetch.mockResolvedValueOnce(createMockResponse(mockResponse)); + + const result = await client.prices.fetchV1CryptoExchangeRates(); + + expect(result).toStrictEqual(mockResponse); + expect(mockFetch).toHaveBeenCalledWith( + expect.stringContaining('/v1/exchange-rates/crypto'), + expect.any(Object), + ); + }); + }); + + describe('Spot Prices', () => { + it('fetches v1 spot prices by coin IDs', async () => { + const mockResponse = { + ethereum: { id: 'ethereum', price: 2500 }, + bitcoin: { id: 'bitcoin', price: 45000 }, + }; + mockFetch.mockResolvedValueOnce(createMockResponse(mockResponse)); + + const result = await client.prices.fetchV1SpotPricesByCoinIds([ + 'ethereum', + 'bitcoin', + ]); + + expect(result).toStrictEqual(mockResponse); + }); + + it('returns empty object for empty coinIds array', async () => { + const result = await client.prices.fetchV1SpotPricesByCoinIds([]); + + expect(result).toStrictEqual({}); + expect(mockFetch).not.toHaveBeenCalled(); + }); + + it('fetches v3 spot prices by asset IDs', async () => { + const mockResponse: V3SpotPricesResponse = { + 'eip155:1/slip44:60': { price: 2500, pricePercentChange1d: 2.5 }, + 'eip155:137/slip44:60': { price: 0.85, pricePercentChange1d: -1.2 }, + }; + mockFetch.mockResolvedValueOnce(createMockResponse(mockResponse)); + + const result = await client.prices.fetchV3SpotPrices([ + 'eip155:1/slip44:60', + 'eip155:137/slip44:60', + ]); + + expect(result).toStrictEqual(mockResponse); + expect(mockFetch).toHaveBeenCalledWith( + expect.stringContaining('/v3/spot-prices'), + expect.any(Object), + ); + }); + + it('returns empty object for empty assetIds array in v3 spot prices', async () => { + const result = await client.prices.fetchV3SpotPrices([]); + + expect(result).toStrictEqual({}); + expect(mockFetch).not.toHaveBeenCalled(); + }); + + it('fetches v1 token prices by chain', async () => { + const mockResponse = { + '0xtoken1': { usd: 1.5 }, + '0xtoken2': { usd: 2.0 }, + }; + mockFetch.mockResolvedValueOnce(createMockResponse(mockResponse)); + + const result = await client.prices.fetchV1TokenPrices('0x1', [ + '0xtoken1', + '0xtoken2', + ]); + + expect(result).toStrictEqual(mockResponse); + expect(mockFetch).toHaveBeenCalledWith( + expect.stringContaining('/v1/chains/1/spot-prices'), + expect.any(Object), + ); + }); + + it('returns empty object for empty tokenAddresses in v1 token prices', async () => { + const result = await client.prices.fetchV1TokenPrices('0x1', []); + + expect(result).toStrictEqual({}); + expect(mockFetch).not.toHaveBeenCalled(); + }); + + it('fetchV1TokenPrice throws on request error', async () => { + mockFetch.mockResolvedValueOnce( + createMockResponse({ error: 'Not found' }, 404, 'Not Found'), + ); + + await expect( + client.prices.fetchV1TokenPrice('0x1', '0xtoken', 'usd'), + ).rejects.toThrow(Error); + }); + + it('fetches single token price successfully', async () => { + const mockResponse = { + price: 2500, + currency: 'usd', + priceChange1d: 50, + pricePercentChange1d: 2.0, + }; + mockFetch.mockResolvedValueOnce(createMockResponse(mockResponse)); + + const result = await client.prices.fetchV1TokenPrice( + '0x1', + '0xtoken', + 'usd', + ); + + expect(result).toStrictEqual(mockResponse); + expect(mockFetch).toHaveBeenCalledWith( + expect.stringContaining('/v1/chains/1/spot-prices/0xtoken'), + expect.any(Object), + ); + }); + + it('fetches v1 spot price by coin ID', async () => { + const mockResponse = { + id: 'ethereum', + price: 2500, + marketCap: 300000000000, + totalVolume: 15000000000, + }; + mockFetch.mockResolvedValueOnce(createMockResponse(mockResponse)); + + const result = await client.prices.fetchV1SpotPriceByCoinId( + 'ethereum', + 'usd', + ); + + expect(result).toStrictEqual(mockResponse); + expect(mockFetch).toHaveBeenCalledWith( + expect.stringContaining('/v1/spot-prices/ethereum'), + expect.any(Object), + ); + }); + + it('fetches v2 spot prices', async () => { + const mockResponse = { + '0xtoken1': { + price: 1.5, + currency: 'usd', + priceChange1d: 0.05, + pricePercentChange1d: 3.5, + }, + '0xtoken2': { + price: 2.0, + currency: 'usd', + priceChange1d: -0.1, + pricePercentChange1d: -4.8, + }, + }; + mockFetch.mockResolvedValueOnce(createMockResponse(mockResponse)); + + const result = await client.prices.fetchV2SpotPrices( + '0x1', + ['0xtoken1', '0xtoken2'], + { + currency: 'usd', + includeMarketData: true, + }, + ); + + expect(result).toStrictEqual(mockResponse); + expect(mockFetch).toHaveBeenCalledWith( + expect.stringContaining('/v2/chains/1/spot-prices'), + expect.any(Object), + ); + }); + + it('returns empty object for empty tokenAddresses in v2 spot prices', async () => { + const result = await client.prices.fetchV2SpotPrices('0x1', []); + + expect(result).toStrictEqual({}); + expect(mockFetch).not.toHaveBeenCalled(); + }); + }); + + describe('Historical Prices', () => { + it('fetches historical prices by coin ID', async () => { + const mockResponse = { + prices: [[1704067200000, 2500]], + }; + mockFetch.mockResolvedValueOnce(createMockResponse(mockResponse)); + + const result = await client.prices.fetchV1HistoricalPricesByCoinId( + 'ethereum', + { + currency: 'usd', + timePeriod: '7d', + }, + ); + + expect(result).toStrictEqual(mockResponse); + expect(mockFetch).toHaveBeenCalledWith( + expect.stringContaining('/v1/historical-prices/ethereum'), + expect.any(Object), + ); + }); + + it('fetches v3 historical prices', async () => { + const mockResponse = { + prices: [[1704067200000, 2500]], + marketCaps: [[1704067200000, 300000000000]], + }; + mockFetch.mockResolvedValueOnce(createMockResponse(mockResponse)); + + const result = await client.prices.fetchV3HistoricalPrices( + 'eip155:1', + 'slip44:60', + { currency: 'usd', timePeriod: '7d' }, + ); + + expect(result).toStrictEqual(mockResponse); + expect(mockFetch).toHaveBeenCalledWith( + expect.stringContaining('/v3/historical-prices/eip155:1/slip44:60'), + expect.any(Object), + ); + }); + + it('fetches historical prices by token addresses', async () => { + const mockResponse = { + prices: [ + [1704067200000, 1.5], + [1704153600000, 1.6], + ], + }; + mockFetch.mockResolvedValueOnce(createMockResponse(mockResponse)); + + const result = + await client.prices.fetchV1HistoricalPricesByTokenAddresses( + '0x1', + ['0xtoken1', '0xtoken2'], + { + currency: 'usd', + timePeriod: '7d', + from: 1704067200, + to: 1704672000, + }, + ); + + expect(result).toStrictEqual(mockResponse); + expect(mockFetch).toHaveBeenCalledWith( + expect.stringContaining('/v1/chains/1/historical-prices'), + expect.any(Object), + ); + }); + + it('fetches v1 historical prices for single token', async () => { + const mockResponse = { + prices: [ + [1704067200000, 2500], + [1704153600000, 2550], + ], + }; + mockFetch.mockResolvedValueOnce(createMockResponse(mockResponse)); + + const result = await client.prices.fetchV1HistoricalPrices( + '0x1', + '0xtoken', + { + currency: 'usd', + timeRange: '30d', + }, + ); + + expect(result).toStrictEqual(mockResponse); + expect(mockFetch).toHaveBeenCalledWith( + expect.stringContaining('/v1/chains/1/historical-prices/0xtoken'), + expect.any(Object), + ); + }); + + it('fetches historical price graph by coin ID', async () => { + const mockResponse = { + prices: [ + [1704067200000, 2500], + [1704153600000, 2550], + ], + }; + mockFetch.mockResolvedValueOnce(createMockResponse(mockResponse)); + + const result = await client.prices.fetchV1HistoricalPriceGraphByCoinId( + 'ethereum', + { + currency: 'usd', + includeOHLC: true, + }, + ); + + expect(result).toStrictEqual(mockResponse); + expect(mockFetch).toHaveBeenCalledWith( + expect.stringContaining('/v1/historical-prices-graph/ethereum'), + expect.any(Object), + ); + }); + + it('fetches historical price graph by token address', async () => { + const mockResponse = { + prices: [ + [1704067200000, 1.5], + [1704153600000, 1.6], + ], + }; + mockFetch.mockResolvedValueOnce(createMockResponse(mockResponse)); + + const result = + await client.prices.fetchV1HistoricalPriceGraphByTokenAddress( + '0x1', + '0xtoken', + { currency: 'usd', includeOHLC: false }, + ); + + expect(result).toStrictEqual(mockResponse); + expect(mockFetch).toHaveBeenCalledWith( + expect.stringContaining('/v1/chains/1/historical-prices-graph/0xtoken'), + expect.any(Object), + ); + }); + }); + + describe('Default Parameter Values', () => { + it('uses default currency for fetchV1SpotPriceByCoinId', async () => { + const mockResponse = { ethereum: { usd: 2500 } }; + mockFetch.mockResolvedValueOnce(createMockResponse(mockResponse)); + + await client.prices.fetchV1SpotPriceByCoinId('ethereum'); + + const calledUrl = mockFetch.mock.calls[0]?.[0] as string; + expect(calledUrl).toContain('vsCurrency=usd'); + }); + + it('uses custom currency for fetchV1SpotPriceByCoinId', async () => { + const mockResponse = { ethereum: { eth: 1 } }; + mockFetch.mockResolvedValueOnce(createMockResponse(mockResponse)); + + await client.prices.fetchV1SpotPriceByCoinId('ethereum', 'eth'); + + const calledUrl = mockFetch.mock.calls[0]?.[0] as string; + expect(calledUrl).toContain('vsCurrency=eth'); + }); + + it('uses default currency for fetchV1TokenPrice', async () => { + const mockResponse = { price: 1.5 }; + mockFetch.mockResolvedValueOnce(createMockResponse(mockResponse)); + + await client.prices.fetchV1TokenPrice('0x1', '0xtoken'); + + const calledUrl = mockFetch.mock.calls[0]?.[0] as string; + expect(calledUrl).toContain('vsCurrency=usd'); + }); + + it('uses custom currency for fetchV1TokenPrice', async () => { + const mockResponse = { price: 0.0006 }; + mockFetch.mockResolvedValueOnce(createMockResponse(mockResponse)); + + await client.prices.fetchV1TokenPrice('0x1', '0xtoken', 'eth'); + + const calledUrl = mockFetch.mock.calls[0]?.[0] as string; + expect(calledUrl).toContain('vsCurrency=eth'); + }); + + it('uses default options for fetchV2SpotPrices', async () => { + const mockResponse = { '0xtoken': { price: 1.5 } }; + mockFetch.mockResolvedValueOnce(createMockResponse(mockResponse)); + + await client.prices.fetchV2SpotPrices('0x1', ['0xtoken']); + + const calledUrl = mockFetch.mock.calls[0]?.[0] as string; + expect(calledUrl).toContain('vsCurrency=usd'); + expect(calledUrl).toContain('includeMarketData=true'); + }); + + it('uses default options for fetchV1HistoricalPrices', async () => { + const mockResponse = { prices: [[1704067200000, 1.5]] }; + mockFetch.mockResolvedValueOnce(createMockResponse(mockResponse)); + + await client.prices.fetchV1HistoricalPrices('0x1', '0xtoken'); + + const calledUrl = mockFetch.mock.calls[0]?.[0] as string; + expect(calledUrl).toContain('vsCurrency=usd'); + expect(calledUrl).toContain('timePeriod=7d'); + }); + + it('uses default options for fetchV1HistoricalPriceGraphByCoinId', async () => { + const mockResponse = { prices: [[1704067200000, 1.5]] }; + mockFetch.mockResolvedValueOnce(createMockResponse(mockResponse)); + + await client.prices.fetchV1HistoricalPriceGraphByCoinId('ethereum'); + + const calledUrl = mockFetch.mock.calls[0]?.[0] as string; + expect(calledUrl).toContain('vsCurrency=usd'); + expect(calledUrl).toContain('includeOHLC=false'); + }); + + it('uses default options for fetchV1HistoricalPriceGraphByTokenAddress', async () => { + const mockResponse = { prices: [[1704067200000, 1.5]] }; + mockFetch.mockResolvedValueOnce(createMockResponse(mockResponse)); + + await client.prices.fetchV1HistoricalPriceGraphByTokenAddress( + '0x1', + '0xtoken', + ); + + const calledUrl = mockFetch.mock.calls[0]?.[0] as string; + expect(calledUrl).toContain('vsCurrency=usd'); + 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 new file mode 100644 index 00000000000..f8cc86fa993 --- /dev/null +++ b/packages/core-backend/src/api/prices/client.ts @@ -0,0 +1,1196 @@ +/** + * Prices API Client - price.api.cx.metamask.io + * + * Handles all price-related API calls including: + * - Supported networks + * - Exchange rates + * - Spot prices (v1, v2, v3) + * - Historical prices + * - Price graphs + */ + +import type { + FetchQueryOptions, + QueryFunctionContext, +} from '@tanstack/query-core'; + +import { + BaseApiClient, + API_URLS, + STALE_TIMES, + GC_TIMES, +} from '../base-client.js'; +import { getQueryOptionsOverrides } from '../shared-types.js'; +import type { + FetchOptions, + MarketDataDetails, + SupportedCurrency, +} from '../shared-types.js'; +import type { + CoinGeckoSpotPrice, + V1ExchangeRatesResponse, + PriceSupportedNetworksResponse, + V1HistoricalPricesResponse, + V3SpotPricesResponse, + V3HistoricalPricesResponse, +} from './types.js'; + +/** + * Prices API Client. + * Provides methods for interacting with the Price API. + */ +export class PricesApiClient extends BaseApiClient { + // ========================================================================== + // CACHE MANAGEMENT + // ========================================================================== + + /** + * Invalidate all price queries. + */ + async invalidatePrices(): Promise { + await this.queryClient.invalidateQueries({ + queryKey: ['prices'], + }); + } + + // ========================================================================== + // SUPPORTED NETWORKS + // ========================================================================== + + /** + * Returns the TanStack Query options object for price v1 supported networks. + * + * @param options - Fetch options including cache settings. + * @returns TanStack Query options for use with useQuery, useSuspenseQuery, etc. + */ + getPriceV1SupportedNetworksQueryOptions( + options?: FetchOptions, + ): FetchQueryOptions { + return { + queryKey: ['prices', 'v1SupportedNetworks'], + queryFn: ({ signal }: QueryFunctionContext) => + this.fetch( + API_URLS.PRICES, + '/v1/supportedNetworks', + { signal }, + ), + ...getQueryOptionsOverrides(options), + staleTime: options?.staleTime ?? STALE_TIMES.SUPPORTED_NETWORKS, + gcTime: options?.gcTime ?? GC_TIMES.EXTENDED, + }; + } + + /** + * Get price supported networks (v1 endpoint). + * + * @param options - Fetch options including cache settings. + * @returns The supported networks response. + */ + async fetchPriceV1SupportedNetworks( + options?: FetchOptions, + ): Promise { + 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( + API_URLS.PRICES, + '/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), + ); + } + + // ========================================================================== + // EXCHANGE RATES + // ========================================================================== + + /** + * Returns the TanStack Query options object for v1 exchange rates. + * + * @param baseCurrency - The base currency code. + * @param options - Fetch options including cache settings. + * @returns TanStack Query options for use with useQuery, useSuspenseQuery, etc. + */ + getV1ExchangeRatesQueryOptions( + baseCurrency: string, + options?: FetchOptions, + ): FetchQueryOptions { + return { + queryKey: ['prices', 'v1ExchangeRates', baseCurrency], + 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 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 fetchV1ExchangeRates( + baseCurrency: string, + options?: FetchOptions, + ): Promise { + 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( + API_URLS.PRICES, + '/v1/exchange-rates/fiat', + { signal }, + ), + ...getQueryOptionsOverrides(options), + staleTime: options?.staleTime ?? STALE_TIMES.EXCHANGE_RATES, + gcTime: options?.gcTime ?? GC_TIMES.DEFAULT, + }; + } + + /** + * Get fiat exchange rates (v1 endpoint). + * + * @param options - Fetch options including cache settings. + * @returns The exchange rates response. + */ + async fetchV1FiatExchangeRates( + options?: FetchOptions, + ): Promise { + 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( + API_URLS.PRICES, + '/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), + ); + } + + // ========================================================================== + // V1 SPOT PRICES (CoinGecko ID based) + // ========================================================================== + + /** + * 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 TanStack Query options for use with useQuery, useSuspenseQuery, etc. + */ + getV1SpotPricesByCoinIdsQueryOptions( + coinIds: string[], + options?: FetchOptions, + ): FetchQueryOptions> { + return { + queryKey: [ + 'prices', + 'v1SpotPricesByCoinIds', + { coinIds: [...coinIds].sort() }, + ], + 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 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 TanStack Query options for use with useQuery, useSuspenseQuery, etc. + */ + getV1SpotPriceByCoinIdQueryOptions( + coinId: string, + currency: SupportedCurrency = 'usd', + options?: FetchOptions, + ): FetchQueryOptions { + return { + queryKey: ['prices', 'v1SpotPriceByCoinId', coinId, currency], + 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), + ); + } + + // ========================================================================== + // V1 SPOT PRICES (Token Address based) + // ========================================================================== + + /** + * Returns the TanStack Query options object for v1 token prices. + * + * @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 TanStack Query options for use with useQuery, useSuspenseQuery, etc. + */ + getV1TokenPricesQueryOptions( + chainId: string, + tokenAddresses: string[], + queryOptions?: { + currency?: SupportedCurrency; + includeMarketData?: boolean; + }, + options?: FetchOptions, + ): FetchQueryOptions>> { + const chainIdDecimal = parseInt(chainId, 16); + const currency = queryOptions?.currency ?? 'usd'; + return { + queryKey: [ + 'prices', + 'v1TokenPrices', + { + chainId, + tokenAddresses: [...tokenAddresses].sort(), + currency, + includeMarketData: queryOptions?.includeMarketData, + }, + ], + queryFn: async ({ + signal, + }: QueryFunctionContext): Promise< + Record> + > => { + if (chainId === '' || tokenAddresses.length === 0) { + return {}; + } + return this.fetch>>( + API_URLS.PRICES, + `/v1/chains/${chainIdDecimal}/spot-prices`, + { + signal, + params: { + tokenAddresses, + vsCurrency: currency, + 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, + }; + } + + /** + * Get spot price for a single token (v1 endpoint). + * + * @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 The market data. + */ + async fetchV1TokenPrice( + chainId: string, + tokenAddress: string, + currency: SupportedCurrency = 'usd', + options?: FetchOptions, + ): Promise { + return this.queryClient.fetchQuery( + this.getV1TokenPriceQueryOptions( + chainId, + tokenAddress, + currency, + options, + ), + ); + } + + // ========================================================================== + // V2 SPOT PRICES + // ========================================================================== + + /** + * Returns the TanStack Query options object for v2 spot prices. + * + * @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 TanStack Query options for use with useQuery, useSuspenseQuery, etc. + */ + getV2SpotPricesQueryOptions( + chainId: string, + tokenAddresses: string[], + queryOptions?: { + currency?: SupportedCurrency; + includeMarketData?: boolean; + }, + options?: FetchOptions, + ): FetchQueryOptions> { + const chainIdDecimal = parseInt(chainId, 16); + const currency = queryOptions?.currency ?? 'usd'; + const includeMarketData = queryOptions?.includeMarketData ?? true; + return { + queryKey: [ + 'prices', + 'v2SpotPrices', + { + chainId, + tokenAddresses: [...tokenAddresses].sort(), + currency, + includeMarketData, + }, + ], + queryFn: async ({ + signal, + }: QueryFunctionContext): Promise> => { + if (chainId === '' || tokenAddresses.length === 0) { + return {}; + } + return this.fetch>( + API_URLS.PRICES, + `/v2/chains/${chainIdDecimal}/spot-prices`, + { + signal, + params: { + tokenAddresses, + vsCurrency: currency, + 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, + ), + ); + } + + // ========================================================================== + // V3 SPOT PRICES (CAIP-19 based) + // ========================================================================== + + /** + * Returns the TanStack Query options object for v3 spot prices. + * + * @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 TanStack Query options for use with useQuery, useSuspenseQuery, etc. + */ + getV3SpotPricesQueryOptions( + assetIds: string[], + queryOptions?: { + currency?: SupportedCurrency; + includeMarketData?: boolean; + cacheOnly?: boolean; + }, + options?: FetchOptions, + ): FetchQueryOptions { + const currency = queryOptions?.currency ?? 'usd'; + const includeMarketData = queryOptions?.includeMarketData ?? true; + const cacheOnly = queryOptions?.cacheOnly ?? false; + return { + queryKey: [ + 'prices', + 'v3SpotPrices', + { + assetIds: [...assetIds].sort(), + 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), + ); + } + + // ========================================================================== + // V1 HISTORICAL PRICES + // ========================================================================== + + /** + * Returns the TanStack Query options object for v1 historical prices by coin ID. + * + * @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 TanStack Query options for use with useQuery, useSuspenseQuery, etc. + */ + getV1HistoricalPricesByCoinIdQueryOptions( + coinId: string, + queryOptions?: { + currency?: SupportedCurrency; + timePeriod?: string; + from?: number; + to?: number; + }, + options?: FetchOptions, + ): FetchQueryOptions { + return { + queryKey: ['prices', 'v1HistoricalByCoinId', coinId, queryOptions], + queryFn: ({ signal }: QueryFunctionContext) => + this.fetch( + API_URLS.PRICES, + `/v1/historical-prices/${coinId}`, + { + signal, + params: { + vsCurrency: queryOptions?.currency, + timePeriod: queryOptions?.timePeriod, + from: queryOptions?.from, + to: queryOptions?.to, + }, + }, + ), + ...getQueryOptionsOverrides(options), + staleTime: options?.staleTime ?? STALE_TIMES.PRICES, + gcTime: options?.gcTime ?? GC_TIMES.DEFAULT, + }; + } + + /** + * 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. + * @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 TanStack Query options for use with useQuery, useSuspenseQuery, etc. + */ + getV1HistoricalPricesByTokenAddressesQueryOptions( + chainId: string, + tokenAddresses: string[], + queryOptions?: { + currency?: SupportedCurrency; + timePeriod?: string; + from?: number; + to?: number; + }, + options?: FetchOptions, + ): FetchQueryOptions { + const chainIdDecimal = parseInt(chainId, 16); + return { + queryKey: [ + 'prices', + 'v1HistoricalByTokenAddresses', + { + chainId, + tokenAddresses: [...tokenAddresses].sort(), + options: queryOptions, + }, + ], + queryFn: ({ signal }: QueryFunctionContext) => + this.fetch( + API_URLS.PRICES, + `/v1/chains/${chainIdDecimal}/historical-prices`, + { + signal, + params: { + tokenAddresses, + vsCurrency: queryOptions?.currency, + timePeriod: queryOptions?.timePeriod, + from: queryOptions?.from, + to: queryOptions?.to, + }, + }, + ), + ...getQueryOptionsOverrides(options), + staleTime: options?.staleTime ?? STALE_TIMES.PRICES, + gcTime: options?.gcTime ?? GC_TIMES.DEFAULT, + }; + } + + /** + * 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. + * @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 TanStack Query options for use with useQuery, useSuspenseQuery, etc. + */ + getV1HistoricalPricesQueryOptions( + chainId: string, + tokenAddress: string, + queryOptions?: { currency?: SupportedCurrency; timeRange?: string }, + options?: FetchOptions, + ): FetchQueryOptions { + const chainIdDecimal = parseInt(chainId, 16); + const currency = queryOptions?.currency ?? 'usd'; + const timeRange = queryOptions?.timeRange ?? '7d'; + return { + queryKey: [ + 'prices', + 'v1Historical', + chainId, + tokenAddress, + currency, + timeRange, + ], + queryFn: ({ signal }: QueryFunctionContext) => + this.fetch( + API_URLS.PRICES, + `/v1/chains/${chainIdDecimal}/historical-prices/${tokenAddress}`, + { + signal, + params: { + vsCurrency: currency, + timePeriod: timeRange, + }, + }, + ), + ...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, + ), + ); + } + + // ========================================================================== + // V3 HISTORICAL PRICES + // ========================================================================== + + /** + * 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. + * @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 TanStack Query options for use with useQuery, useSuspenseQuery, etc. + */ + getV3HistoricalPricesQueryOptions( + chainId: string, + assetType: string, + queryOptions?: { + currency?: SupportedCurrency; + timePeriod?: string; + from?: number; + to?: number; + interval?: '5m' | 'hourly' | 'daily'; + }, + options?: FetchOptions, + ): FetchQueryOptions { + return { + queryKey: ['prices', 'v3Historical', chainId, assetType, queryOptions], + queryFn: ({ signal }: QueryFunctionContext) => + this.fetch( + API_URLS.PRICES, + `/v3/historical-prices/${chainId}/${assetType}`, + { + signal, + params: { + vsCurrency: queryOptions?.currency, + timePeriod: queryOptions?.timePeriod, + from: queryOptions?.from, + to: queryOptions?.to, + interval: queryOptions?.interval, + }, + }, + ), + ...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, + ), + ); + } + + // ========================================================================== + // V1 HISTORICAL PRICE GRAPH + // ========================================================================== + + /** + * 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 TanStack Query options for use with useQuery, useSuspenseQuery, etc. + */ + getV1HistoricalPriceGraphByCoinIdQueryOptions( + coinId: string, + queryOptions?: { currency?: SupportedCurrency; includeOHLC?: boolean }, + options?: FetchOptions, + ): FetchQueryOptions { + const currency = queryOptions?.currency ?? 'usd'; + const includeOHLC = queryOptions?.includeOHLC ?? false; + return { + queryKey: ['prices', 'v1GraphByCoinId', coinId, currency, includeOHLC], + queryFn: ({ signal }: QueryFunctionContext) => + this.fetch( + API_URLS.PRICES, + `/v1/historical-prices-graph/${coinId}`, + { + signal, + params: { + vsCurrency: currency, + includeOHLC, + }, + }, + ), + ...getQueryOptionsOverrides(options), + staleTime: options?.staleTime ?? STALE_TIMES.PRICES, + gcTime: options?.gcTime ?? GC_TIMES.DEFAULT, + }; + } + + /** + * 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. + * @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 TanStack Query options for use with useQuery, useSuspenseQuery, etc. + */ + getV1HistoricalPriceGraphByTokenAddressQueryOptions( + chainId: string, + tokenAddress: string, + queryOptions?: { currency?: SupportedCurrency; includeOHLC?: boolean }, + options?: FetchOptions, + ): FetchQueryOptions { + const chainIdDecimal = parseInt(chainId, 16); + const currency = queryOptions?.currency ?? 'usd'; + const includeOHLC = queryOptions?.includeOHLC ?? false; + return { + queryKey: [ + 'prices', + 'v1GraphByTokenAddress', + chainId, + tokenAddress, + currency, + includeOHLC, + ], + queryFn: ({ signal }: QueryFunctionContext) => + this.fetch( + API_URLS.PRICES, + `/v1/chains/${chainIdDecimal}/historical-prices-graph/${tokenAddress}`, + { + signal, + params: { + vsCurrency: currency, + includeOHLC, + }, + }, + ), + ...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/prices/index.ts b/packages/core-backend/src/api/prices/index.ts new file mode 100644 index 00000000000..f8f24383a96 --- /dev/null +++ b/packages/core-backend/src/api/prices/index.ts @@ -0,0 +1,14 @@ +/** + * Prices API barrel export. + */ + +export { PricesApiClient } from './client.js'; +export type { + V3SpotPricesResponse, + CoinGeckoSpotPrice, + ExchangeRateInfo, + V1ExchangeRatesResponse, + PriceSupportedNetworksResponse, + V1HistoricalPricesResponse, + V3HistoricalPricesResponse, +} from './types.js'; diff --git a/packages/core-backend/src/api/prices/types.ts b/packages/core-backend/src/api/prices/types.ts new file mode 100644 index 00000000000..ee3c5420eab --- /dev/null +++ b/packages/core-backend/src/api/prices/types.ts @@ -0,0 +1,86 @@ +/** + * Prices API types for the API Platform Client. + * API: price.api.cx.metamask.io + */ + +// ============================================================================ +// SPOT PRICES TYPES +// ============================================================================ + +/** V3 Spot prices response */ +export type V3SpotPricesResponse = Record< + string, + { + price: number; + pricePercentChange1d?: number; + marketCap?: number; + totalVolume?: number; + } | null +>; + +/** CoinGecko spot price */ +export type CoinGeckoSpotPrice = { + id: string; + price: number; + marketCap?: number; + allTimeHigh?: number; + allTimeLow?: number; + totalVolume?: number; + high1d?: number; + low1d?: number; + circulatingSupply?: number; + dilutedMarketCap?: number; + marketCapPercentChange1d?: number; + priceChange1d?: number; + pricePercentChange1h?: number; + pricePercentChange1d?: number; + pricePercentChange7d?: number; + pricePercentChange14d?: number; + pricePercentChange30d?: number; + pricePercentChange200d?: number; + pricePercentChange1y?: number; +}; + +// ============================================================================ +// EXCHANGE RATES TYPES +// ============================================================================ + +/** Exchange rate info */ +export type ExchangeRateInfo = { + name: string; + ticker: string; + value: number; + currencyType: 'crypto' | 'fiat'; +}; + +/** Exchange rates response */ +export type V1ExchangeRatesResponse = { + [currency: string]: ExchangeRateInfo; +}; + +// ============================================================================ +// SUPPORTED NETWORKS TYPES +// ============================================================================ + +/** Price supported networks response */ +export type PriceSupportedNetworksResponse = { + fullSupport: string[]; + partialSupport: string[]; +}; + +// ============================================================================ +// HISTORICAL PRICES TYPES +// ============================================================================ + +/** V1 Historical prices response */ +export type V1HistoricalPricesResponse = { + /** Array of price data points as [timestamp, price] tuples */ + prices: [number, number][]; +}; + +/** V3 Historical prices response */ +export type V3HistoricalPricesResponse = { + prices: [number, number][]; + marketCaps?: [number, number][]; + totalVolumes?: [number, number][]; +}; diff --git a/packages/core-backend/src/api/shared-types.ts b/packages/core-backend/src/api/shared-types.ts new file mode 100644 index 00000000000..72ea886b1a5 --- /dev/null +++ b/packages/core-backend/src/api/shared-types.ts @@ -0,0 +1,300 @@ +/** + * Shared types, constants, and utilities for the API Platform Client. + */ + +import type { FetchQueryOptions, QueryClient } from '@tanstack/query-core'; + +// ============================================================================ +// SHARED TYPES +// ============================================================================ + +/** + * Pagination info for paginated responses + */ +export type PageInfo = { + count: number; + hasNextPage: boolean; + cursor?: string; +}; + +/** + * Supported currencies for Price API + */ +export type SupportedCurrency = + // Crypto + | 'btc' + | 'eth' + | 'ltc' + | 'bch' + | 'bnb' + | 'eos' + | 'xrp' + | 'xlm' + | 'link' + | 'dot' + | 'yfi' + // Fiat + | 'usd' + | 'aed' + | 'ars' + | 'aud' + | 'bdt' + | 'bhd' + | 'bmd' + | 'brl' + | 'cad' + | 'chf' + | 'clp' + | 'cny' + | 'czk' + | 'dkk' + | 'eur' + | 'gbp' + | 'gel' + | 'hkd' + | 'huf' + | 'idr' + | 'ils' + | 'inr' + | 'jpy' + | 'krw' + | 'kwd' + | 'lkr' + | 'mmk' + | 'mxn' + | 'myr' + | 'ngn' + | 'nok' + | 'nzd' + | 'php' + | 'pkr' + | 'pln' + | 'rub' + | 'sar' + | 'sek' + | 'sgd' + | 'thb' + | 'try' + | 'twd' + | 'uah' + | 'vef' + | 'vnd' + | 'zar'; + +/** + * Market data details from Price API spot-prices endpoint + */ +export type MarketDataDetails = { + /** Current price in the requested currency */ + price: number; + /** Currency code (e.g., 'ETH', 'USD') */ + currency: string; + /** 24h price change amount */ + priceChange1d: number; + /** 24h price change percentage */ + pricePercentChange1d: number; + /** 1h price change percentage */ + pricePercentChange1h: number; + /** 7d price change percentage */ + pricePercentChange7d: number; + /** 14d price change percentage */ + pricePercentChange14d: number; + /** 30d price change percentage */ + pricePercentChange30d: number; + /** 200d price change percentage */ + pricePercentChange200d: number; + /** 1y price change percentage */ + pricePercentChange1y: number; + /** Market capitalization */ + marketCap: number; + /** Market cap 24h change percentage */ + marketCapPercentChange1d: number; + /** All-time high price */ + allTimeHigh: number; + /** All-time low price */ + allTimeLow: number; + /** 24h high price */ + high1d: number; + /** 24h low price */ + low1d: number; + /** Total trading volume */ + totalVolume: number; + /** Circulating supply */ + circulatingSupply: number; + /** Diluted market cap */ + dilutedMarketCap: number; +}; + +// ============================================================================ +// CLIENT OPTIONS +// ============================================================================ + +export type ApiPlatformClientOptions = { + /** Client product identifier (e.g., 'metamask-extension') */ + clientProduct: string; + /** Optional client version */ + clientVersion?: string; + /** Function to get bearer token for authenticated requests */ + getBearerToken?: () => Promise; + /** Optional custom QueryClient instance */ + 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). */ + staleTime?: number; + /** 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 +// ============================================================================ + +/** API Base URLs */ +export const API_URLS = { + ACCOUNTS: 'https://accounts.api.cx.metamask.io', + PRICES: 'https://price.api.cx.metamask.io', + TOKEN: 'https://token.api.cx.metamask.io', + TOKENS: 'https://tokens.api.cx.metamask.io', +} as const; + +/** Stale times for different data types (ms) */ +export const STALE_TIMES = { + AUTH_TOKEN: 5 * 60 * 1000, // 5 minutes - cache the auth token + PRICES: 30 * 1000, // 30 seconds + BALANCES: 60 * 1000, // 1 minute + NETWORKS: 10 * 60 * 1000, // 10 minutes + SUPPORTED_NETWORKS: 30 * 60 * 1000, // 30 minutes + TOKEN_METADATA: 5 * 60 * 1000, // 5 minutes + TOKEN_LIST: 10 * 60 * 1000, // 10 minutes + EXCHANGE_RATES: 5 * 60 * 1000, // 5 minutes + TRENDING: 2 * 60 * 1000, // 2 minutes + TRANSACTIONS: 30 * 1000, // 30 seconds + DEFAULT: 30 * 1000, // 30 seconds +} as const; + +/** Garbage collection times (ms) */ +export const GC_TIMES = { + DEFAULT: 5 * 60 * 1000, // 5 minutes + EXTENDED: 30 * 60 * 1000, // 30 minutes + SHORT: 2 * 60 * 1000, // 2 minutes +} as const; + +/** Retry configuration */ +export const RETRY_CONFIG = { + MAX_RETRIES: 3, + BASE_DELAY: 1000, + MAX_DELAY: 5_000, +} as const; + +// ============================================================================ +// HELPER FUNCTIONS +// ============================================================================ + +/** + * Calculate retry delay with exponential backoff and jitter. + * + * @param attemptIndex - The current retry attempt (0-indexed). + * @returns The delay in milliseconds before the next retry. + */ +export function calculateRetryDelay(attemptIndex: number): number { + const delay = Math.min( + RETRY_CONFIG.BASE_DELAY * 2 ** attemptIndex, + RETRY_CONFIG.MAX_DELAY, + ); + return delay / 2 + Math.random() * (delay / 2); +} + +/** + * Determine if a failed request should be retried. + * + * @param failureCount - The number of failures so far (1 = first failure). + * @param error - The error from the failed request. + * @returns True if the request should be retried, false otherwise. + */ +export function shouldRetry(failureCount: number, error: unknown): boolean { + // Allow up to MAX_RETRIES retries (e.g., MAX_RETRIES=3 means 4 total attempts) + if (failureCount > RETRY_CONFIG.MAX_RETRIES) { + return false; + } + + if (error instanceof Error && 'status' in error) { + const { status } = error as { status: number }; + // Don't retry 4xx except 429 (rate limit) and 408 (timeout) + if (status >= 400 && status < 500 && status !== 429 && status !== 408) { + return false; + } + } + return true; +} + +// ============================================================================ +// HTTP ERROR +// ============================================================================ + +export class HttpError extends Error { + readonly status: number; + + readonly statusText: string; + + readonly url: string; + + readonly body: unknown; + + constructor( + message: string, + status: number, + statusText: string, + url: string, + body?: unknown, + ) { + super(message); + this.name = 'HttpError'; + this.status = status; + this.statusText = statusText; + this.url = url; + this.body = body; + } +} diff --git a/packages/core-backend/src/api/test-utils.ts b/packages/core-backend/src/api/test-utils.ts new file mode 100644 index 00000000000..cb6a6f6529d --- /dev/null +++ b/packages/core-backend/src/api/test-utils.ts @@ -0,0 +1,74 @@ +/** + * Shared test utilities for API client tests. + */ + +import { QueryClient } from '@tanstack/query-core'; + +import { ApiPlatformClient } from './ApiPlatformClient.js'; + +// Mock fetch globally +export const mockFetch = jest.fn(); +(globalThis as typeof globalThis & { fetch: jest.Mock }).fetch = mockFetch; + +/** + * Helper to create a mock Response. + * + * @param data - The response data to return from json(). + * @param status - HTTP status code. + * @param statusText - HTTP status text. + * @returns A mocked Response object. + */ +export const createMockResponse = ( + data: ResponseData, + status = 200, + statusText = 'OK', +): Response => + ({ + ok: status >= 200 && status < 300, + status, + statusText, + json: jest.fn().mockResolvedValue(data), + headers: { get: jest.fn() }, + redirected: false, + type: 'basic', + url: '', + clone: jest.fn(), + body: null, + bodyUsed: false, + arrayBuffer: jest.fn(), + blob: jest.fn(), + formData: jest.fn(), + text: jest.fn(), + }) as unknown as Response; + +/** + * Creates a fresh ApiPlatformClient for testing with disabled caching/retry. + * + * @returns A new ApiPlatformClient instance configured for testing. + */ +export function createTestClient(): ApiPlatformClient { + return new ApiPlatformClient({ + clientProduct: 'test-client', + clientVersion: '1.0.0', + queryClient: new QueryClient({ + defaultOptions: { + queries: { + retry: false, + gcTime: 0, + staleTime: 0, + }, + }, + }), + }); +} + +/** + * Setup function to be called in beforeEach. + * + * @returns An object containing a fresh test client. + */ +export function setupTestEnvironment(): { client: ApiPlatformClient } { + jest.clearAllMocks(); + mockFetch.mockReset(); + return { client: createTestClient() }; +} diff --git a/packages/core-backend/src/api/token/client.test.ts b/packages/core-backend/src/api/token/client.test.ts new file mode 100644 index 00000000000..9d8e95a9e22 --- /dev/null +++ b/packages/core-backend/src/api/token/client.test.ts @@ -0,0 +1,350 @@ +/** + * Token API Client Tests - token.api.cx.metamask.io + */ + +import type { ApiPlatformClient } from '../ApiPlatformClient.js'; +import { API_URLS } from '../shared-types.js'; +import { + mockFetch, + createMockResponse, + setupTestEnvironment, +} from '../test-utils.js'; +import type { NetworkInfo, TokenMetadata } from './types.js'; + +describe('TokenApiClient', () => { + let client: ApiPlatformClient; + + beforeEach(() => { + ({ client } = setupTestEnvironment()); + }); + + describe('Cache Management', () => { + it('invalidates token API cache', async () => { + const queryKey = ['token', 'networks']; + client.setCachedData(queryKey, []); + + await client.token.invalidateToken(); + + const queryState = client.queryClient.getQueryState(queryKey); + expect(queryState?.isInvalidated).toBe(true); + }); + + it('does not invalidate tokens API cache', async () => { + const tokenKey = ['token', 'networks']; + const tokensKey = ['tokens', 'v1SupportedNetworks']; + client.setCachedData(tokenKey, []); + client.setCachedData(tokensKey, {}); + + await client.token.invalidateToken(); + + // Token API cache should be invalidated + expect(client.queryClient.getQueryState(tokenKey)?.isInvalidated).toBe( + true, + ); + // Tokens API cache should NOT be invalidated + expect(client.queryClient.getQueryState(tokensKey)?.isInvalidated).toBe( + false, + ); + }); + }); + + describe('Networks', () => { + it('fetches all networks', async () => { + const mockResponse: NetworkInfo[] = [ + { + active: true, + chainId: 1, + chainName: 'Ethereum', + nativeCurrency: { + name: 'Ether', + symbol: 'ETH', + decimals: 18, + address: '0x0', + }, + }, + ]; + mockFetch.mockResolvedValueOnce(createMockResponse(mockResponse)); + + const result = await client.token.fetchNetworks(); + + expect(result).toStrictEqual(mockResponse); + expect(mockFetch).toHaveBeenCalledWith( + `${API_URLS.TOKEN}/networks`, + expect.any(Object), + ); + }); + + it('fetches network by chain ID', async () => { + const mockResponse: NetworkInfo = { + active: true, + chainId: 1, + chainName: 'Ethereum', + nativeCurrency: { + name: 'Ether', + symbol: 'ETH', + decimals: 18, + address: '0x0', + }, + }; + mockFetch.mockResolvedValueOnce(createMockResponse(mockResponse)); + + const result = await client.token.fetchNetworkByChainId(1); + + expect(result).toStrictEqual(mockResponse); + expect(mockFetch).toHaveBeenCalledWith( + `${API_URLS.TOKEN}/networks/1`, + expect.any(Object), + ); + }); + }); + + describe('Token List', () => { + it('fetches token list for chain', async () => { + const mockResponse: TokenMetadata[] = [ + { + address: '0xtoken', + symbol: 'TKN', + decimals: 18, + name: 'Test Token', + }, + ]; + mockFetch.mockResolvedValueOnce(createMockResponse(mockResponse)); + + const result = await client.token.fetchTokenList(1); + + expect(result).toStrictEqual(mockResponse); + expect(mockFetch).toHaveBeenCalledWith( + expect.stringContaining('/tokens/1'), + expect.any(Object), + ); + }); + + it('fetches token list with include options', async () => { + const mockResponse: TokenMetadata[] = []; + mockFetch.mockResolvedValueOnce(createMockResponse(mockResponse)); + + await client.token.fetchTokenList(1, { + includeIconUrl: true, + includeOccurrences: true, + }); + + const calledUrl = mockFetch.mock.calls[0]?.[0] as string; + expect(calledUrl).toContain('includeIconUrl=true'); + expect(calledUrl).toContain('includeOccurrences=true'); + }); + }); + + describe('Token Metadata', () => { + it('fetches v1 token metadata', async () => { + const mockResponse: TokenMetadata = { + address: '0xtoken', + symbol: 'TKN', + decimals: 18, + name: 'Test Token', + }; + mockFetch.mockResolvedValueOnce(createMockResponse(mockResponse)); + + const result = await client.token.fetchV1TokenMetadata(1, '0xtoken'); + + expect(result).toStrictEqual(mockResponse); + }); + + it('returns undefined on token metadata error', async () => { + mockFetch.mockResolvedValueOnce( + createMockResponse({ error: 'Not found' }, 404, 'Not Found'), + ); + + const result = await client.token.fetchV1TokenMetadata(1, '0xtoken'); + + expect(result).toBeUndefined(); + }); + + it('fetches token description', async () => { + const mockResponse = { description: 'A test token for testing' }; + mockFetch.mockResolvedValueOnce(createMockResponse(mockResponse)); + + const result = await client.token.fetchTokenDescription(1, '0xtoken'); + + expect(result).toStrictEqual(mockResponse); + }); + + it('returns undefined on token description error', async () => { + mockFetch.mockResolvedValueOnce( + createMockResponse({ error: 'Not found' }, 404, 'Not Found'), + ); + + const result = await client.token.fetchTokenDescription(1, '0xtoken'); + + expect(result).toBeUndefined(); + }); + }); + + describe('Trending & Top Tokens', () => { + it('fetches v3 trending tokens', async () => { + const mockResponse = [ + { address: '0xtrending', symbol: 'TRD', chainId: 1 }, + ]; + mockFetch.mockResolvedValueOnce(createMockResponse(mockResponse)); + + const result = await client.token.fetchV3TrendingTokens(['1', '137']); + + expect(result).toStrictEqual(mockResponse); + expect(mockFetch).toHaveBeenCalledWith( + expect.stringContaining('/v3/tokens/trending'), + expect.any(Object), + ); + }); + + it('passes includeTokenSecurityData param when fetching v3 trending tokens', async () => { + const mockResponse = [ + { + assetId: 'eip155:1/erc20:0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2', + name: 'Wrapped Ether', + symbol: 'WETH', + decimals: 18, + price: '2076.8761460147', + aggregatedUsdVolume: 563290706.83, + marketCap: 338433.56, + labels: ['blue_chip'], + priceChangePct: { + m5: '0', + m15: '0.195', + m30: '0.706', + h1: '3.39', + h6: '6.26', + h24: '6.7', + }, + securityData: { + resultType: 'Verified', + maliciousScore: '0.0', + fees: { + transfer: 0, + transferFeeMaxAmount: null, + buy: 0, + sell: 0, + }, + features: [ + { + featureId: 'HIGH_REPUTATION_TOKEN', + type: 'Benign', + description: 'Token with verified high reputation', + }, + { + featureId: 'VERIFIED_CONTRACT', + type: 'Info', + description: 'The token contract is verified', + }, + ], + financialStats: { + supply: 2.0555493268851862e24, + topHolders: [ + { + label: 'contract', + name: null, + address: '0xf04a5cc80b1e94c69b48f5ee68a08cd2f09a7c3e', + holdingPercentage: 21.962, + }, + ], + holdersCount: 2877494, + tradeVolume24h: 801557137, + lockedLiquidityPct: 0, + markets: [ + { + marketType: 'AMM', + marketName: 'uniswap_v3', + pairName: 'WETH / USDC', + reserveUSD: 94676995.1127, + }, + ], + }, + metadata: { + externalLinks: { + homepage: 'https://ethereum.org/en/wrapped-eth', + twitterPage: null, + telegramChannelId: null, + }, + }, + created: '2017-12-12T11:17:35', + }, + }, + ]; + mockFetch.mockResolvedValueOnce(createMockResponse(mockResponse)); + + const result = await client.token.fetchV3TrendingTokens(['eip155:1'], { + includeTokenSecurityData: true, + }); + + expect(result).toStrictEqual(mockResponse); + expect(result[0].securityData?.resultType).toBe('Verified'); + expect(result[0].securityData?.maliciousScore).toBe('0.0'); + expect(result[0].securityData?.financialStats.holdersCount).toBe(2877494); + const calledUrl = mockFetch.mock.calls[0]?.[0] as string; + expect(calledUrl).toContain('includeTokenSecurityData=true'); + }); + + it('fetches v3 top gainers', async () => { + const mockResponse = [ + { address: '0xgainer', symbol: 'GAIN', chainId: 1 }, + ]; + mockFetch.mockResolvedValueOnce(createMockResponse(mockResponse)); + + const result = await client.token.fetchV3TopGainers(['1'], { + sort: 'h24_price_change_percentage_desc', + }); + + expect(result).toStrictEqual(mockResponse); + expect(mockFetch).toHaveBeenCalledWith( + expect.stringContaining('/v3/tokens/top-gainers'), + expect.any(Object), + ); + }); + + it('fetches v3 popular tokens', async () => { + const mockResponse = [ + { address: '0xpopular', symbol: 'POP', chainId: 1 }, + ]; + mockFetch.mockResolvedValueOnce(createMockResponse(mockResponse)); + + const result = await client.token.fetchV3PopularTokens(['1']); + + expect(result).toStrictEqual(mockResponse); + expect(mockFetch).toHaveBeenCalledWith( + expect.stringContaining('/v3/tokens/popular'), + expect.any(Object), + ); + }); + }); + + describe('Top Assets', () => { + it('fetches top assets for chain', async () => { + const mockResponse = [ + { address: '0xtop', symbol: 'TOP' }, + { address: '0x2nd', symbol: 'SEC' }, + ]; + mockFetch.mockResolvedValueOnce(createMockResponse(mockResponse)); + + const result = await client.token.fetchTopAssets(1); + + expect(result).toStrictEqual(mockResponse); + expect(mockFetch).toHaveBeenCalledWith( + expect.stringContaining('/topAssets/1'), + expect.any(Object), + ); + }); + }); + + describe('Utility', () => { + it('fetches suggested occurrence floors', async () => { + const mockResponse = { '1': 3, '137': 2, '56': 2 }; + mockFetch.mockResolvedValueOnce(createMockResponse(mockResponse)); + + const result = await client.token.fetchV1SuggestedOccurrenceFloors(); + + expect(result).toStrictEqual(mockResponse); + expect(mockFetch).toHaveBeenCalledWith( + expect.stringContaining('/v1/suggestedOccurrenceFloors'), + expect.any(Object), + ); + }); + }); +}); diff --git a/packages/core-backend/src/api/token/client.ts b/packages/core-backend/src/api/token/client.ts new file mode 100644 index 00000000000..520a9602209 --- /dev/null +++ b/packages/core-backend/src/api/token/client.ts @@ -0,0 +1,741 @@ +/** + * Token API Client - token.api.cx.metamask.io + * + * Handles all token-related API calls including: + * - Networks + * - Token lists + * - Token metadata + * - Token descriptions + * - Trending tokens + * - Top gainers/popular tokens + * - Top assets + * - Occurrence floors + */ + +import type { + FetchQueryOptions, + QueryFunctionContext, +} from '@tanstack/query-core'; + +import { + BaseApiClient, + API_URLS, + STALE_TIMES, + GC_TIMES, +} from '../base-client.js'; +import { getQueryOptionsOverrides } from '../shared-types.js'; +import type { FetchOptions } from '../shared-types.js'; +import type { + TokenMetadata, + V1TokenDescriptionResponse, + NetworkInfo, + TopAsset, + TrendingToken, + TrendingSortOption, + TopGainersSortOption, + V1SuggestedOccurrenceFloorsResponse, +} from './types.js'; + +/** + * Token API Client. + * Provides methods for interacting with the Token API. + */ +export class TokenApiClient extends BaseApiClient { + // ========================================================================== + // CACHE MANAGEMENT + // ========================================================================== + + /** + * Invalidate all token API queries. + * Note: This only invalidates queries from token.api.cx.metamask.io, + * not from tokens.api.cx.metamask.io (use TokensApiClient.invalidateTokens() for that). + */ + async invalidateToken(): Promise { + await this.queryClient.invalidateQueries({ + queryKey: ['token'], + }); + } + + // ========================================================================== + // NETWORKS + // ========================================================================== + + /** + * Returns the TanStack Query options object for networks. + * + * @param options - Fetch options including cache settings. + * @returns TanStack Query options for use with useQuery, useSuspenseQuery, etc. + */ + 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 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 TanStack Query options for use with useQuery, useSuspenseQuery, etc. + */ + getNetworkByChainIdQueryOptions( + chainId: number, + options?: FetchOptions, + ): 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. + * + * @param chainId - The chain ID. + * @param queryOptions - Query options. + * @param queryOptions.includeTokenFees - Include token fees data. + * @param queryOptions.includeAssetType - Include asset type data. + * @param queryOptions.includeAggregators - Include aggregators data. + * @param queryOptions.includeERC20Permit - Include ERC20 permit data. + * @param queryOptions.includeOccurrences - Include occurrences data. + * @param queryOptions.includeStorage - Include storage data. + * @param queryOptions.includeIconUrl - Include icon URL. + * @param queryOptions.includeAddress - Include address. + * @param queryOptions.includeName - Include name. + * @param options - Fetch options including cache settings. + * @returns Array of token metadata. + */ + async fetchTokenList( + chainId: number, + queryOptions?: { + includeTokenFees?: boolean; + includeAssetType?: boolean; + includeAggregators?: boolean; + includeERC20Permit?: boolean; + includeOccurrences?: boolean; + includeStorage?: boolean; + includeIconUrl?: boolean; + includeAddress?: boolean; + includeName?: boolean; + }, + options?: FetchOptions, + ): Promise { + 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, + 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_METADATA, + gcTime: options?.gcTime ?? GC_TIMES.EXTENDED, + }; + } + + /** + * Get token metadata by address. + * + * @param chainId - The chain ID. + * @param tokenAddress - The token address. + * @param queryOptions - Query options. + * @param queryOptions.includeTokenFees - Include token fees data. + * @param queryOptions.includeAssetType - Include asset type data. + * @param queryOptions.includeAggregators - Include aggregators data. + * @param queryOptions.includeERC20Permit - Include ERC20 permit data. + * @param queryOptions.includeOccurrences - Include occurrences data. + * @param queryOptions.includeStorage - Include storage data. + * @param queryOptions.includeIconUrl - Include icon URL. + * @param queryOptions.includeAddress - Include address. + * @param queryOptions.includeName - Include name. + * @param options - Fetch options including cache settings. + * @returns The token metadata or undefined. + */ + async fetchV1TokenMetadata( + 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, + ): Promise { + try { + 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. + * + * @param chainId - The chain ID. + * @param tokenAddress - The token address. + * @param options - Fetch options including cache settings. + * @returns The token description or undefined. + */ + async fetchTokenDescription( + chainId: number, + tokenAddress: string, + options?: FetchOptions, + ): Promise { + try { + return await this.queryClient.fetchQuery( + this.getTokenDescriptionQueryOptions(chainId, tokenAddress, options), + ); + } catch { + return undefined; + } + } + + // ========================================================================== + // TRENDING & TOP TOKENS + // ========================================================================== + + /** + * Returns the TanStack Query options object for v3 trending tokens. + * + * @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 queryOptions.includeTokenSecurityData - Whether to include token security data. + * @param options - Fetch options including cache settings. + * @returns TanStack Query options for use with useQuery, useSuspenseQuery, etc. + */ + getV3TrendingTokensQueryOptions( + chainIds: string[], + queryOptions?: { + sortBy?: TrendingSortOption; + minLiquidity?: number; + minVolume24hUsd?: number; + maxVolume24hUsd?: number; + minMarketCap?: number; + maxMarketCap?: number; + includeTokenSecurityData?: boolean; + }, + options?: FetchOptions, + ): FetchQueryOptions { + return { + queryKey: [ + 'token', + 'v3Trending', + { chainIds: [...chainIds].sort(), options: queryOptions }, + ], + queryFn: ({ signal }: QueryFunctionContext) => + this.fetch(API_URLS.TOKEN, '/v3/tokens/trending', { + signal, + params: { + chainIds, + sort: queryOptions?.sortBy, + minLiquidity: queryOptions?.minLiquidity, + minVolume24hUsd: queryOptions?.minVolume24hUsd, + maxVolume24hUsd: queryOptions?.maxVolume24hUsd, + minMarketCap: queryOptions?.minMarketCap, + maxMarketCap: queryOptions?.maxMarketCap, + includeTokenSecurityData: queryOptions?.includeTokenSecurityData, + }, + }), + ...getQueryOptionsOverrides(options), + staleTime: options?.staleTime ?? STALE_TIMES.TRENDING, + gcTime: options?.gcTime ?? GC_TIMES.SHORT, + }; + } + + /** + * 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 queryOptions.includeTokenSecurityData - Whether to include token security data. + * @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; + includeTokenSecurityData?: boolean; + }, + 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. + * @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. + * @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. + */ + getV3TopGainersQueryOptions( + chainIds: string[], + queryOptions?: { + sort?: TopGainersSortOption; + blockRegion?: 'global' | 'us'; + minLiquidity?: number; + minVolume24hUsd?: number; + maxVolume24hUsd?: number; + minMarketCap?: number; + maxMarketCap?: number; + }, + options?: FetchOptions, + ): FetchQueryOptions { + return { + queryKey: [ + 'token', + 'v3TopGainers', + { chainIds: [...chainIds].sort(), options: queryOptions }, + ], + queryFn: ({ signal }: QueryFunctionContext) => + this.fetch(API_URLS.TOKEN, '/v3/tokens/top-gainers', { + signal, + params: { + chainIds, + sort: queryOptions?.sort, + blockRegion: queryOptions?.blockRegion, + minLiquidity: queryOptions?.minLiquidity, + minVolume24hUsd: queryOptions?.minVolume24hUsd, + maxVolume24hUsd: queryOptions?.maxVolume24hUsd, + minMarketCap: queryOptions?.minMarketCap, + maxMarketCap: queryOptions?.maxMarketCap, + }, + }), + ...getQueryOptionsOverrides(options), + staleTime: options?.staleTime ?? STALE_TIMES.TRENDING, + gcTime: options?.gcTime ?? GC_TIMES.SHORT, + }; + } + + /** + * 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. + * @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 top gainer tokens. + */ + async fetchV3TopGainers( + chainIds: string[], + queryOptions?: { + sort?: TopGainersSortOption; + blockRegion?: 'global' | 'us'; + minLiquidity?: number; + minVolume24hUsd?: number; + maxVolume24hUsd?: number; + minMarketCap?: number; + maxMarketCap?: number; + }, + options?: FetchOptions, + ): Promise { + 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', + { chainIds: [...chainIds].sort(), options: queryOptions }, + ], + queryFn: ({ signal }: QueryFunctionContext) => + this.fetch(API_URLS.TOKEN, '/v3/tokens/popular', { + signal, + params: { + chainIds, + blockRegion: queryOptions?.blockRegion, + minLiquidity: queryOptions?.minLiquidity, + minVolume24hUsd: queryOptions?.minVolume24hUsd, + maxVolume24hUsd: queryOptions?.maxVolume24hUsd, + minMarketCap: queryOptions?.minMarketCap, + 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), + ); + } + + // ========================================================================== + // TOP ASSETS + // ========================================================================== + + /** + * Returns the TanStack Query options object for top assets. + * + * @param chainId - The chain ID. + * @param options - Fetch options including cache settings. + * @returns TanStack Query options for use with useQuery, useSuspenseQuery, etc. + */ + getTopAssetsQueryOptions( + chainId: number, + options?: FetchOptions, + ): 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), + ); + } + + // ========================================================================== + // UTILITY + // ========================================================================== + + /** + * Returns the TanStack Query options object for v1 suggested occurrence floors. + * + * @param options - Fetch options including cache settings. + * @returns TanStack Query options for use with useQuery, useSuspenseQuery, etc. + */ + getV1SuggestedOccurrenceFloorsQueryOptions( + options?: FetchOptions, + ): FetchQueryOptions { + return { + queryKey: ['token', 'v1SuggestedOccurrenceFloors'], + queryFn: ({ signal }: QueryFunctionContext) => + this.fetch( + API_URLS.TOKEN, + '/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/token/index.ts b/packages/core-backend/src/api/token/index.ts new file mode 100644 index 00000000000..c141ba43739 --- /dev/null +++ b/packages/core-backend/src/api/token/index.ts @@ -0,0 +1,23 @@ +/** + * Token API barrel export. + */ + +export { TokenApiClient } from './client.js'; +export type { + TokenMetadata, + V1TokenDescriptionResponse, + NetworkInfo, + TopAsset, + TrendingSortBy, + TrendingToken, + TopGainersSortOption, + TrendingSortOption, + V1SuggestedOccurrenceFloorsResponse, + TokenSecurityData, + TokenSecurityFeature, + TokenSecurityHolder, + TokenSecurityMarket, + TokenSecurityFees, + TokenSecurityFinancialStats, + TokenSecurityMetadata, +} from './types.js'; diff --git a/packages/core-backend/src/api/token/types.ts b/packages/core-backend/src/api/token/types.ts new file mode 100644 index 00000000000..9bc97607614 --- /dev/null +++ b/packages/core-backend/src/api/token/types.ts @@ -0,0 +1,179 @@ +/** + * Token API types for the API Platform Client. + * API: token.api.cx.metamask.io + */ + +// ============================================================================ +// TOKEN METADATA TYPES +// ============================================================================ + +/** + * Token metadata from Token API v1 /tokens/{chainId} endpoint + */ +export type TokenMetadata = { + address: string; + symbol: string; + decimals: number; + name: string; + iconUrl?: string; + aggregators?: string[]; + occurrences?: number; +}; + +/** Token description response */ +export type V1TokenDescriptionResponse = { + description: string; +}; + +// ============================================================================ +// NETWORK TYPES +// ============================================================================ + +/** Network info */ +export type NetworkInfo = { + active: boolean; + chainId: number; + chainName: string; + nativeCurrency: { + name: string; + symbol: string; + decimals: number; + address: string; + }; + iconUrl?: string; + blockExplorerUrl?: string; + networkType?: string; + tokenSources?: string[]; +}; + +// ============================================================================ +// TOP ASSETS TYPES +// ============================================================================ + +/** Top asset */ +export type TopAsset = { + address: string; + symbol: string; +}; + +// ============================================================================ +// TRENDING TOKENS TYPES +// ============================================================================ + +/** + * Sort options for trending tokens (v3) + */ +export type TrendingSortBy = + | 'm5_trending' + | 'h1_trending' + | 'h6_trending' + | 'h24_trending'; + +// ============================================================================ +// TOKEN SECURITY TYPES +// ============================================================================ + +export type TokenSecurityFeature = { + featureId: string; + type: string; + description: string; +}; + +export type TokenSecurityHolder = { + label: string; + name: string | null; + address: string; + holdingPercentage: number; +}; + +export type TokenSecurityMarket = { + marketType: string; + marketName: string; + pairName: string; + reserveUSD: number; +}; + +export type TokenSecurityFees = { + transfer: number; + transferFeeMaxAmount: number | null; + buy: number; + sell: number | null; +}; + +export type TokenSecurityFinancialStats = { + supply: number; + topHolders: TokenSecurityHolder[]; + holdersCount: number; + tradeVolume24h: number | null; + lockedLiquidityPct: number | null; + markets: TokenSecurityMarket[]; +}; + +export type TokenSecurityMetadata = { + externalLinks: { + homepage: string | null; + twitterPage: string | null; + telegramChannelId: string | null; + }; +}; + +export type TokenSecurityData = { + resultType: string; + maliciousScore: string; + fees: TokenSecurityFees; + features: TokenSecurityFeature[]; + financialStats: TokenSecurityFinancialStats; + metadata: TokenSecurityMetadata; + created: string; +}; + +/** + * Trending token data from Token API v3 /tokens/trending endpoint + */ +export type TrendingToken = { + assetId: string; + name: string; + symbol: string; + decimals: number; + price: string; + aggregatedUsdVolume: number; + marketCap: number; + priceChangePct?: { + m5?: string; + m15?: string; + m30?: string; + h1?: string; + h6?: string; + h24?: string; + }; + labels?: string[]; + /** Optional security data for tokens when includeTokenSecurityData is true */ + securityData?: TokenSecurityData; +}; + +/** Top gainers sort options */ +export type TopGainersSortOption = + | 'm5_price_change_percentage_desc' + | 'h1_price_change_percentage_desc' + | 'h6_price_change_percentage_desc' + | 'h24_price_change_percentage_desc' + | 'm5_price_change_percentage_asc' + | 'h1_price_change_percentage_asc' + | 'h6_price_change_percentage_asc' + | 'h24_price_change_percentage_asc'; + +/** Trending sort options */ +export type TrendingSortOption = + | 'm5_trending' + | 'h1_trending' + | 'h6_trending' + | 'h24_trending'; + +// ============================================================================ +// UTILITY TYPES +// ============================================================================ + +/** Suggested occurrence floors response */ +export type V1SuggestedOccurrenceFloorsResponse = { + [chainId: string]: number; +}; diff --git a/packages/core-backend/src/api/tokens/client.test.ts b/packages/core-backend/src/api/tokens/client.test.ts new file mode 100644 index 00000000000..a619505b6eb --- /dev/null +++ b/packages/core-backend/src/api/tokens/client.test.ts @@ -0,0 +1,132 @@ +/** + * Tokens API Client Tests - tokens.api.cx.metamask.io + */ + +import type { ApiPlatformClient } from '../ApiPlatformClient.js'; +import { API_URLS } from '../shared-types.js'; +import { + mockFetch, + createMockResponse, + setupTestEnvironment, +} from '../test-utils.js'; + +describe('TokensApiClient', () => { + let client: ApiPlatformClient; + + beforeEach(() => { + ({ client } = setupTestEnvironment()); + }); + + describe('Cache Management', () => { + it('invalidates tokens API cache', async () => { + const queryKey = ['tokens', 'v1SupportedNetworks']; + client.setCachedData(queryKey, {}); + + await client.tokens.invalidateTokens(); + + const queryState = client.queryClient.getQueryState(queryKey); + expect(queryState?.isInvalidated).toBe(true); + }); + + it('does not invalidate token API cache', async () => { + const tokensKey = ['tokens', 'v1SupportedNetworks']; + const tokenKey = ['token', 'networks']; + client.setCachedData(tokensKey, {}); + client.setCachedData(tokenKey, []); + + await client.tokens.invalidateTokens(); + + // Tokens API cache should be invalidated + expect(client.queryClient.getQueryState(tokensKey)?.isInvalidated).toBe( + true, + ); + // Token API cache should NOT be invalidated + expect(client.queryClient.getQueryState(tokenKey)?.isInvalidated).toBe( + false, + ); + }); + }); + + describe('Supported Networks', () => { + it('fetches token v1 supported networks', async () => { + const mockResponse = { fullSupport: ['0x1', '0x89'] }; + mockFetch.mockResolvedValueOnce(createMockResponse(mockResponse)); + + const result = await client.tokens.fetchTokenV1SupportedNetworks(); + + expect(result).toStrictEqual(mockResponse); + expect(mockFetch).toHaveBeenCalledWith( + `${API_URLS.TOKENS}/v1/supportedNetworks`, + expect.any(Object), + ); + }); + + it('fetches token v2 supported networks', async () => { + const mockResponse = { + fullSupport: ['eip155:1'], + partialSupport: ['eip155:56'], + }; + mockFetch.mockResolvedValueOnce(createMockResponse(mockResponse)); + + const result = await client.tokens.fetchTokenV2SupportedNetworks(); + + expect(result).toStrictEqual(mockResponse); + }); + }); + + describe('V3 Assets', () => { + it('fetches v3 assets by IDs', async () => { + const mockResponse = [ + { + assetId: 'eip155:1/erc20:0xtoken', + name: 'Test Token', + symbol: 'TKN', + decimals: 18, + iconUrl: 'https://example.com/icon.png', + coingeckoId: 'test-token', + occurrences: 5, + aggregators: ['metamask'], + labels: ['defi'], + erc20Permit: true, + fees: { avgFee: 0, maxFee: 0, minFee: 0 }, + honeypotStatus: { honeypotIs: false }, + storage: { balance: 1, approval: 2 }, + isContractVerified: true, + }, + ]; + mockFetch.mockResolvedValueOnce(createMockResponse(mockResponse)); + + const result = await client.tokens.fetchV3Assets([ + 'eip155:1/erc20:0xtoken', + ]); + + expect(result).toStrictEqual(mockResponse); + expect(mockFetch).toHaveBeenCalledWith( + expect.stringContaining('/v3/assets'), + 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 new file mode 100644 index 00000000000..37d5d3ea589 --- /dev/null +++ b/packages/core-backend/src/api/tokens/client.ts @@ -0,0 +1,189 @@ +/** + * Tokens API Client - tokens.api.cx.metamask.io + * + * Handles bulk token operations including: + * - Supported networks (v1, v2) + * - V3 Assets + */ + +import type { + FetchQueryOptions, + QueryFunctionContext, +} from '@tanstack/query-core'; + +import { + BaseApiClient, + API_URLS, + STALE_TIMES, + GC_TIMES, +} from '../base-client.js'; +import { getQueryOptionsOverrides } from '../shared-types.js'; +import type { FetchOptions } from '../shared-types.js'; +import type { + V1TokenSupportedNetworksResponse, + V2TokenSupportedNetworksResponse, + V3AssetResponse, + V3AssetsQueryOptions, +} from './types.js'; + +/** + * Tokens API Client. + * Provides methods for interacting with the Tokens API. + */ +export class TokensApiClient extends BaseApiClient { + // ========================================================================== + // CACHE MANAGEMENT + // ========================================================================== + + /** + * Invalidate all token queries. + */ + async invalidateTokens(): Promise { + await this.queryClient.invalidateQueries({ + queryKey: ['tokens'], + }); + } + + // ========================================================================== + // SUPPORTED NETWORKS + // ========================================================================== + + /** + * Returns the TanStack Query options object for token v1 supported networks. + * + * @param options - Fetch options including cache settings. + * @returns TanStack Query options for use with useQuery, useSuspenseQuery, etc. + */ + getTokenV1SupportedNetworksQueryOptions( + options?: FetchOptions, + ): FetchQueryOptions { + return { + queryKey: ['tokens', 'v1SupportedNetworks'], + queryFn: ({ signal }: QueryFunctionContext) => + this.fetch( + API_URLS.TOKENS, + '/v1/supportedNetworks', + { signal }, + ), + ...getQueryOptionsOverrides(options), + staleTime: options?.staleTime ?? STALE_TIMES.SUPPORTED_NETWORKS, + gcTime: options?.gcTime ?? GC_TIMES.EXTENDED, + }; + } + + /** + * Get token supported networks (v1 endpoint). + * + * @param options - Fetch options including cache settings. + * @returns The supported networks response. + */ + async fetchTokenV1SupportedNetworks( + options?: FetchOptions, + ): 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( + API_URLS.TOKENS, + '/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), + ); + } + + // ========================================================================== + // V3 ASSETS + // ========================================================================== + + /** + * Returns the TanStack Query options object for v3 assets. + * + * @param assetIds - Array of CAIP-19 asset IDs. + * @param queryOptions - API query options (filters, etc.). + * @param fetchOptions - Fetch options including cache settings. + * @returns TanStack Query options for use with useQuery, useSuspenseQuery, etc. + */ + getV3AssetsQueryOptions( + assetIds: string[], + queryOptions?: V3AssetsQueryOptions, + fetchOptions?: FetchOptions, + ): FetchQueryOptions { + return { + queryKey: [ + 'tokens', + 'v3Assets', + { assetIds: [...assetIds].sort(), ...queryOptions }, + ], + 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/api/tokens/index.ts b/packages/core-backend/src/api/tokens/index.ts new file mode 100644 index 00000000000..1b4207f79b3 --- /dev/null +++ b/packages/core-backend/src/api/tokens/index.ts @@ -0,0 +1,15 @@ +/** + * Tokens API barrel export. + */ + +export { TokensApiClient } from './client.js'; +export type { + V1TokenSupportedNetworksResponse, + V2TokenSupportedNetworksResponse, + V3AssetResponse, + V3AssetsQueryOptions, + V3AssetFees, + V3AssetHoneypotStatus, + V3AssetStorage, + V3AssetDescription, +} from './types.js'; diff --git a/packages/core-backend/src/api/tokens/types.ts b/packages/core-backend/src/api/tokens/types.ts new file mode 100644 index 00000000000..d13385eb3cb --- /dev/null +++ b/packages/core-backend/src/api/tokens/types.ts @@ -0,0 +1,106 @@ +/** + * Tokens API types for the API Platform Client. + * API: tokens.api.cx.metamask.io + */ + +// ============================================================================ +// SUPPORTED NETWORKS TYPES +// ============================================================================ + +/** Token supported networks response (v1) */ +export type V1TokenSupportedNetworksResponse = { + fullSupport: string[]; +}; + +/** Token supported networks response (v2) - includes partial support */ +export type V2TokenSupportedNetworksResponse = { + fullSupport: string[]; + partialSupport: string[]; +}; + +// ============================================================================ +// ASSET TYPES +// ============================================================================ + +/** Query options for V3 Assets endpoint */ +export type V3AssetsQueryOptions = { + /** Include icon URL in response */ + includeIconUrl?: boolean; + /** Include market data in response */ + includeMarketData?: boolean; + /** Include metadata in response */ + includeMetadata?: boolean; + /** Include labels in response */ + includeLabels?: boolean; + /** Include RWA data in response */ + includeRwaData?: boolean; + /** Include DEX/aggregator integrations in response */ + includeAggregators?: boolean; + /** Include token list occurrences in response */ + includeOccurrences?: boolean; +}; + +// ============================================================================ +// V3 ASSET RESPONSE TYPES +// ============================================================================ + +/** Fee information for an asset */ +export type V3AssetFees = { + avgFee: number; + maxFee: number; + minFee: number; +}; + +/** Honeypot detection status */ +export type V3AssetHoneypotStatus = { + honeypotIs: boolean; + goPlus?: boolean; +}; + +/** Storage slot information for the contract */ +export type V3AssetStorage = { + balance: number; + approval: number; +}; + +/** Localized description */ +export type V3AssetDescription = { + en: string; +}; + +/** + * Asset response from V3 Assets endpoint. + * All fields are stored in state (FungibleAssetMetadata). + */ +export type V3AssetResponse = { + /** CAIP-19 asset ID (e.g., "eip155:1/erc20:0x...") */ + assetId: string; + /** Asset display name */ + name: string; + /** Asset symbol */ + symbol: string; + /** Decimal places */ + decimals: number; + /** Icon URL (maps to `image` in state) */ + iconUrl?: string; + /** CoinGecko ID for price lookups */ + coingeckoId?: string; + /** Number of token list occurrences */ + occurrences?: number; + /** DEX/aggregator integrations */ + aggregators?: string[]; + /** Asset labels/tags (e.g., "stable_coin") */ + labels?: string[]; + /** Whether the token supports ERC-20 permit */ + erc20Permit?: boolean; + /** Fee information */ + fees?: V3AssetFees; + /** Honeypot detection status */ + honeypotStatus?: V3AssetHoneypotStatus; + /** Storage slot information */ + storage?: V3AssetStorage; + /** Whether the contract is verified */ + isContractVerified?: boolean; + /** Localized description (maps to metadata.description in state) */ + description?: V3AssetDescription; +}; diff --git a/packages/core-backend/src/index.ts b/packages/core-backend/src/index.ts new file mode 100644 index 00000000000..98afbedb096 --- /dev/null +++ b/packages/core-backend/src/index.ts @@ -0,0 +1,191 @@ +// Core Backend Package Exports + +// ============================================================================ +// BACKEND WEBSOCKET SERVICE +// ============================================================================ + +export { + BackendWebSocketService, + getCloseReason, + WebSocketState, + WebSocketEventType, +} from './ws/BackendWebSocketService.js'; + +export type { + BackendWebSocketServiceOptions, + ClientRequestMessage, + ServerResponseMessage, + ServerNotificationMessage, + WebSocketMessage, + ChannelCallback, + WebSocketSubscription, + WebSocketConnectionInfo, + BackendWebSocketServiceActions, + BackendWebSocketServiceConnectionStateChangedEvent, + BackendWebSocketServiceEvents, + BackendWebSocketServiceMessenger, +} from './ws/BackendWebSocketService.js'; + +// ============================================================================ +// ACCOUNT ACTIVITY SERVICE +// ============================================================================ + +export { + AccountActivityService, + ACCOUNT_ACTIVITY_SERVICE_ALLOWED_ACTIONS, + ACCOUNT_ACTIVITY_SERVICE_ALLOWED_EVENTS, +} from './ws/AccountActivityService.js'; + +export type { + SystemNotificationData, + SubscriptionOptions, + AccountActivityServiceOptions, + AccountActivityServiceActions, + AllowedActions as AccountActivityServiceAllowedActions, + AccountActivityServiceTransactionUpdatedEvent, + AccountActivityServiceBalanceUpdatedEvent, + AccountActivityServiceSubscriptionErrorEvent, + AccountActivityServiceStatusChangedEvent, + AccountActivityServiceEvents, + AllowedEvents as AccountActivityServiceAllowedEvents, + AccountActivityServiceMessenger, +} from './ws/AccountActivityService.js'; + +// ============================================================================ +// SHARED TYPES +// ============================================================================ + +export type { + Transaction, + Asset, + Balance, + Transfer, + BalanceUpdate, + AccountActivityMessage, +} from './types.js'; + +// ============================================================================ +// API PLATFORM CLIENT SERVICE +// ============================================================================ + +export { + ApiPlatformClientService, + apiPlatformClientServiceName, +} from './ApiPlatformClientService.js'; + +export type { + ApiPlatformClientServiceOptions, + ApiPlatformClientServiceActions, + ApiPlatformClientServiceEvents, + ApiPlatformClientServiceMessenger, +} from './ApiPlatformClientService.js'; + +// ============================================================================ +// OHLCV SERVICE +// ============================================================================ + +export { + OHLCVService, + OHLCV_SERVICE_ALLOWED_ACTIONS, + OHLCV_SERVICE_ALLOWED_EVENTS, +} from './ws/ohlcv/index.js'; + +export type { + OHLCVBar, + OHLCVSubscriptionOptions, + OHLCVSystemNotificationData, + OHLCVServiceOptions, + OHLCVServiceActions, + OHLCVServiceAllowedActions, + OHLCVServiceBarUpdatedEvent, + OHLCVServiceChainStatusChangedEvent, + OHLCVServiceSubscriptionErrorEvent, + OHLCVServiceEvents, + OHLCVServiceAllowedEvents, + OHLCVServiceMessenger, +} from './ws/ohlcv/index.js'; + +// ============================================================================ +// API PLATFORM CLIENT +// ============================================================================ + +export { + ApiPlatformClient, + createApiPlatformClient, + // Individual API clients + AccountsApiClient, + PricesApiClient, + TokenApiClient, + TokensApiClient, + // Constants + API_URLS, + STALE_TIMES, + GC_TIMES, + V6_DEFI_POSITION_TYPES, + // Helpers + calculateRetryDelay, + getQueryOptionsOverrides, + shouldRetry, + // Errors + HttpError, +} from './api/index.js'; + +// ============================================================================ +// API PLATFORM CLIENT TYPES +// ============================================================================ + +export type { + // Client options + ApiPlatformClientOptions, + FetchOptions, + // Shared types + PageInfo, + SupportedCurrency, + MarketDataDetails, + // Accounts API types + V5BalanceItem, + V5BalancesResponse, + V2BalanceItem, + V2BalancesResponse, + V4BalancesResponse, + V6VsCurrency, + V6DeFiPositionType, + V6BalanceMetadata, + V6TokenMetadata, + V6BalanceItem, + V6BalancesResponse, + V1SupportedNetworksResponse, + V2SupportedNetworksResponse, + V2ActiveNetworksResponse, + V1TransactionByHashResponse, + V1AccountTransactionsResponse, + V4MultiAccountTransactionsResponse, + ValueTransfer, + V1AccountRelationshipResult, + NftItem, + V2NftsResponse, + TokenDiscoveryItem, + V2TokensResponse, + // Prices API types + V3SpotPricesResponse, + CoinGeckoSpotPrice, + ExchangeRateInfo, + V1ExchangeRatesResponse, + PriceSupportedNetworksResponse, + V1HistoricalPricesResponse, + V3HistoricalPricesResponse, + // Token API types + TokenMetadata, + V1TokenDescriptionResponse, + NetworkInfo, + TopAsset, + TrendingSortBy, + TrendingToken, + TopGainersSortOption, + TrendingSortOption, + V1SuggestedOccurrenceFloorsResponse, + // Tokens API types + V1TokenSupportedNetworksResponse, + V2TokenSupportedNetworksResponse, + V3AssetResponse, +} from './api/index.js'; diff --git a/packages/core-backend/src/logger.ts b/packages/core-backend/src/logger.ts new file mode 100644 index 00000000000..18cbb8f4dd0 --- /dev/null +++ b/packages/core-backend/src/logger.ts @@ -0,0 +1,5 @@ +import { createProjectLogger, createModuleLogger } from '@metamask/utils'; + +export const projectLogger = createProjectLogger('core-backend'); + +export { createModuleLogger }; diff --git a/packages/core-backend/src/types.ts b/packages/core-backend/src/types.ts new file mode 100644 index 00000000000..9d82cbe526b --- /dev/null +++ b/packages/core-backend/src/types.ts @@ -0,0 +1,77 @@ +/** + * Basic transaction information + */ +export type Transaction = { + /** Transaction ID/hash */ + id: string; + /** Chain identifier in CAIP-2 format (e.g., "eip155:1") */ + chain: string; + /** Transaction status */ + status: string; + /** Timestamp when the transaction was processed */ + timestamp: number; + /** Address that initiated the transaction */ + from: string; + /** Address that received the transaction */ + to: string; +}; + +/** + * Asset information for balance updates + */ +export type Asset = { + /** Whether the asset is fungible */ + fungible: boolean; + /** Asset type in CAIP format (e.g., "eip155:1/erc20:0x...") */ + type: string; + /** Asset unit/symbol (e.g., "USDT", "ETH") */ + unit: string; + /** Number of decimal places for the asset */ + decimals: number; +}; + +/** + * Balance information + */ +export type Balance = { + /** Balance amount as string */ + amount: string; + /** Optional error message */ + error?: string; +}; + +/** + * Transfer information + */ +export type Transfer = { + /** Address sending the transfer */ + from: string; + /** Address receiving the transfer */ + to: string; + /** Transfer amount as string */ + amount: string; +}; + +/** + * Balance update information for a specific asset + */ +export type BalanceUpdate = { + /** Asset information */ + asset: Asset; + /** Post-transaction balance */ + postBalance: Balance; + /** List of transfers for this asset */ + transfers: Transfer[]; +}; + +/** + * Complete transaction/balance update message + */ +export type AccountActivityMessage = { + /** Account address */ + address: string; + /** Transaction information */ + tx: Transaction; + /** Array of balance updates for different assets */ + updates: BalanceUpdate[]; +}; diff --git a/packages/core-backend/src/ws/AccountActivityService.test.ts b/packages/core-backend/src/ws/AccountActivityService.test.ts new file mode 100644 index 00000000000..9c827e6792e --- /dev/null +++ b/packages/core-backend/src/ws/AccountActivityService.test.ts @@ -0,0 +1,1512 @@ +import type { InternalAccount } from '@metamask/keyring-internal-api'; +import { Messenger, MOCK_ANY_NAMESPACE } from '@metamask/messenger'; +import type { + MessengerActions, + MessengerEvents, + MockAnyNamespace, +} from '@metamask/messenger'; +import type { Hex, Json } from '@metamask/utils'; + +import { flushPromises } from '../../../../tests/helpers.js'; +import type { Transaction, BalanceUpdate } from '../types.js'; +import type { AccountActivityMessage } from '../types.js'; +import { AccountActivityService } from './AccountActivityService.js'; +import type { AccountActivityServiceMessenger } from './AccountActivityService.js'; +import type { ServerNotificationMessage } from './BackendWebSocketService.js'; +import { WebSocketState } from './BackendWebSocketService.js'; + +type AllAccountActivityServiceActions = + MessengerActions; + +type AllAccountActivityServiceEvents = + MessengerEvents; + +type RootMessenger = Messenger< + MockAnyNamespace, + AllAccountActivityServiceActions, + AllAccountActivityServiceEvents +>; + +type TestMocks = { + getAccountsFromSelectedAccountGroup: jest.Mock; + connect: jest.Mock; + subscribe: jest.Mock; + channelHasSubscription: jest.Mock; + getSubscriptionsByChannel: jest.Mock; + findSubscriptionsByChannelPrefix: jest.Mock; + forceReconnection: jest.Mock; + addChannelCallback: jest.Mock; + removeChannelCallback: jest.Mock; + getConnectionInfo: jest.Mock; + getFeatureFlagState: jest.Mock; +}; + +// Helper function for completing async operations +const completeAsyncOperations = async (timeoutMs = 0): Promise => { + await flushPromises(); + // Allow nested async operations to complete + if (timeoutMs > 0) { + await new Promise((resolve) => setTimeout(resolve, timeoutMs)); + } + await flushPromises(); +}; + +// Mock function to create test accounts +const createMockInternalAccount = (overrides: { + address: string; + scopes?: InternalAccount['scopes']; + options?: InternalAccount['options']; +}): InternalAccount => ({ + address: overrides.address.toLowerCase() as Hex, + id: `test-account-${overrides.address.slice(-6)}`, + metadata: { + name: 'Test Account', + importTime: Date.now(), + keyring: { + type: 'HD Key Tree', + }, + }, + options: overrides.options ?? {}, + methods: [], + type: 'eip155:eoa', + scopes: overrides.scopes ?? ['eip155:1'], // Required scopes property +}); + +/** + * Builds a RemoteFeatureFlagController state with the Solana migration flag + * at the given stage (or with no migration flags when undefined) + * + * @param stage - The migration stage for the Solana flag + * @returns A RemoteFeatureFlagController state object + */ +const featureFlagState = ( + stage: number | undefined, +): { + remoteFeatureFlags: Record; + cacheTimestamp: number; +} => ({ + remoteFeatureFlags: + stage === undefined ? {} : { networkAssetsSnapsMigrationSolana: { stage } }, + cacheTimestamp: 0, +}); + +/** + * Creates and returns a root messenger for testing + * + * @returns A messenger instance + */ +function getRootMessenger(): RootMessenger { + return new Messenger({ + namespace: MOCK_ANY_NAMESPACE, + }); +} + +/** + * Creates a real messenger with registered mock actions for testing + * Each call creates a completely independent messenger to ensure test isolation + * + * @returns Object containing the messenger and mock action functions + */ +const getMessenger = (): { + rootMessenger: RootMessenger; + messenger: AccountActivityServiceMessenger; + mocks: TestMocks; +} => { + // Use any types for the root messenger to avoid complex type constraints in tests + // Create a unique root messenger for each test + const rootMessenger = getRootMessenger(); + const messenger: AccountActivityServiceMessenger = new Messenger< + 'AccountActivityService', + AllAccountActivityServiceActions, + AllAccountActivityServiceEvents, + RootMessenger + >({ + namespace: 'AccountActivityService', + parent: rootMessenger, + }); + + rootMessenger.delegate({ + actions: [ + 'AccountTreeController:getAccountsFromSelectedAccountGroup', + 'BackendWebSocketService:connect', + 'BackendWebSocketService:forceReconnection', + 'BackendWebSocketService:subscribe', + 'BackendWebSocketService:getConnectionInfo', + 'BackendWebSocketService:channelHasSubscription', + 'BackendWebSocketService:getSubscriptionsByChannel', + 'BackendWebSocketService:findSubscriptionsByChannelPrefix', + 'BackendWebSocketService:addChannelCallback', + 'BackendWebSocketService:removeChannelCallback', + 'RemoteFeatureFlagController:getState', + ], + events: [ + 'AccountTreeController:selectedAccountGroupChange', + 'BackendWebSocketService:connectionStateChanged', + // eslint-disable-next-line no-restricted-syntax + 'RemoteFeatureFlagController:stateChange', + ], + messenger, + }); + + // Create mock action handlers + const mockGetAccountsFromSelectedAccountGroup = jest.fn(); + const mockConnect = jest.fn(); + const mockForceReconnection = jest.fn(); + const mockSubscribe = jest.fn(); + const mockChannelHasSubscription = jest.fn(); + const mockGetSubscriptionsByChannel = jest.fn(); + const mockFindSubscriptionsByChannelPrefix = jest.fn().mockReturnValue([]); + const mockAddChannelCallback = jest.fn(); + const mockRemoveChannelCallback = jest.fn(); + const mockGetConnectionInfo = jest.fn().mockReturnValue({ + state: WebSocketState.CONNECTED, + }); + // Solana enabled by default so tests exercise the multichain path + const mockGetFeatureFlagState = jest.fn().mockReturnValue({ + remoteFeatureFlags: { + networkAssetsSnapsMigrationSolana: { stage: 1 }, + }, + cacheTimestamp: 0, + }); + + // Register all action handlers + rootMessenger.registerActionHandler( + 'AccountTreeController:getAccountsFromSelectedAccountGroup', + mockGetAccountsFromSelectedAccountGroup, + ); + rootMessenger.registerActionHandler( + 'BackendWebSocketService:connect', + mockConnect, + ); + rootMessenger.registerActionHandler( + 'BackendWebSocketService:forceReconnection', + mockForceReconnection, + ); + rootMessenger.registerActionHandler( + 'BackendWebSocketService:subscribe', + mockSubscribe, + ); + rootMessenger.registerActionHandler( + 'BackendWebSocketService:channelHasSubscription', + mockChannelHasSubscription, + ); + rootMessenger.registerActionHandler( + 'BackendWebSocketService:getSubscriptionsByChannel', + mockGetSubscriptionsByChannel, + ); + rootMessenger.registerActionHandler( + 'BackendWebSocketService:findSubscriptionsByChannelPrefix', + mockFindSubscriptionsByChannelPrefix, + ); + rootMessenger.registerActionHandler( + 'BackendWebSocketService:addChannelCallback', + mockAddChannelCallback, + ); + rootMessenger.registerActionHandler( + 'BackendWebSocketService:removeChannelCallback', + mockRemoveChannelCallback, + ); + rootMessenger.registerActionHandler( + 'BackendWebSocketService:getConnectionInfo', + mockGetConnectionInfo, + ); + rootMessenger.registerActionHandler( + 'RemoteFeatureFlagController:getState', + mockGetFeatureFlagState, + ); + + return { + rootMessenger, + messenger, + mocks: { + getAccountsFromSelectedAccountGroup: + mockGetAccountsFromSelectedAccountGroup, + connect: mockConnect, + forceReconnection: mockForceReconnection, + subscribe: mockSubscribe, + channelHasSubscription: mockChannelHasSubscription, + getSubscriptionsByChannel: mockGetSubscriptionsByChannel, + findSubscriptionsByChannelPrefix: mockFindSubscriptionsByChannelPrefix, + addChannelCallback: mockAddChannelCallback, + removeChannelCallback: mockRemoveChannelCallback, + getConnectionInfo: mockGetConnectionInfo, + getFeatureFlagState: mockGetFeatureFlagState, + }, + }; +}; + +/** + * Creates an independent AccountActivityService with its own messenger for tests that need isolation + * This is the primary way to create service instances in tests to ensure proper isolation + * + * @param options - Optional configuration for service creation + * @param options.subscriptionNamespace - Custom subscription namespace + * @returns Object containing the service, messenger, root messenger, and mock functions + */ +const createIndependentService = (options?: { + subscriptionNamespace?: string; +}): { + service: AccountActivityService; + messenger: AccountActivityServiceMessenger; + rootMessenger: RootMessenger; + mocks: TestMocks; + destroy: () => void; +} => { + const { subscriptionNamespace } = options ?? {}; + + const messengerSetup = getMessenger(); + + const service = new AccountActivityService({ + messenger: messengerSetup.messenger, + subscriptionNamespace, + }); + + return { + service, + messenger: messengerSetup.messenger, + rootMessenger: messengerSetup.rootMessenger, + mocks: messengerSetup.mocks, + // Convenience cleanup method + destroy: (): void => { + service.destroy(); + }, + }; +}; + +/** + * Creates a service setup for testing that includes common test account setup + * + * @param accountAddress - Address for the test account + * @returns Object containing the service, messenger, mocks, and mock account + */ +const createServiceWithTestAccount = ( + accountAddress: string = '0x1234567890123456789012345678901234567890', +): { + service: AccountActivityService; + messenger: AccountActivityServiceMessenger; + rootMessenger: RootMessenger; + mocks: TestMocks; + destroy: () => void; + mockSelectedAccounts: InternalAccount[]; +} => { + const serviceSetup = createIndependentService(); + + // Create mock selected account + const mockSelectedAccounts: InternalAccount[] = [ + { + id: 'test-account-1', + address: accountAddress as Hex, + metadata: { + name: 'Test Account', + importTime: Date.now(), + keyring: { type: 'HD Key Tree' }, + }, + options: {}, + methods: [], + scopes: ['eip155:1'], + type: 'eip155:eoa', + }, + ]; + + // Setup account-related mock implementations + serviceSetup.mocks.getAccountsFromSelectedAccountGroup.mockReturnValue( + mockSelectedAccounts, + ); + + return { + ...serviceSetup, + mockSelectedAccounts, + }; +}; + +/** + * Test configuration options for withService + */ +type WithServiceOptions = { + subscriptionNamespace?: string; + accountAddress?: string; +}; + +/** + * The callback that `withService` calls. + */ +type WithServiceCallback = (payload: { + service: AccountActivityService; + messenger: AccountActivityServiceMessenger; + rootMessenger: RootMessenger; + mocks: TestMocks; + mockSelectedAccounts: InternalAccount[]; + destroy: () => void; +}) => Promise | ReturnValue; + +/** + * Helper function to extract the system notification callback from messenger calls + * + * @param mocks - The mocks object from withService + * @param mocks.addChannelCallback - Mock function for adding channel callbacks + * @returns The system notification callback function + */ +const getSystemNotificationCallback = (mocks: { + addChannelCallback: jest.Mock; +}): ((notification: ServerNotificationMessage) => void) => { + const systemCallbackCall = mocks.addChannelCallback.mock.calls.find( + (call: unknown[]) => + call[0] && + typeof call[0] === 'object' && + 'channelName' in call[0] && + call[0].channelName === 'system-notifications.v1.account-activity.v1', + ); + + if (!systemCallbackCall) { + throw new Error('systemCallbackCall is undefined'); + } + + const callbackOptions = systemCallbackCall[0] as { + callback: (notification: ServerNotificationMessage) => void; + }; + return callbackOptions.callback; +}; + +/** + * Wrap tests for the AccountActivityService by ensuring that the service 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 service constructor. All constructor + * arguments are optional and will be filled in with defaults as needed + * (including `messenger`). The function is called with the new + * service, root messenger, and service messenger. + * @returns The same return value as the given function. + */ +async function withService( + ...args: + | [WithServiceCallback] + | [WithServiceOptions, WithServiceCallback] +): Promise { + const [{ subscriptionNamespace, accountAddress }, testFunction] = + args.length === 2 + ? args + : [ + { + subscriptionNamespace: undefined, + accountAddress: undefined, + }, + args[0], + ]; + + const setup = accountAddress + ? createServiceWithTestAccount(accountAddress) + : createIndependentService({ subscriptionNamespace }); + + try { + return await testFunction({ + service: setup.service, + messenger: setup.messenger, + rootMessenger: setup.rootMessenger, + mocks: setup.mocks, + mockSelectedAccounts: + 'mockSelectedAccounts' in setup + ? (setup.mockSelectedAccounts as InternalAccount[]) + : [], + destroy: setup.destroy, + }); + } finally { + setup.destroy(); + } +} + +describe('AccountActivityService', () => { + // ============================================================================= + // CONSTRUCTOR TESTS + // ============================================================================= + describe('constructor', () => { + it('should create AccountActivityService with comprehensive initialization and verify service properties', async () => { + await withService(async ({ service, messenger, mocks }) => { + expect(service).toBeInstanceOf(AccountActivityService); + expect(service.name).toBe('AccountActivityService'); + + // Status changed event is only published when WebSocket connects + const statusChangedEventListener = jest.fn(); + messenger.subscribe( + 'AccountActivityService:statusChanged', + statusChangedEventListener, + ); + expect(statusChangedEventListener).not.toHaveBeenCalled(); + + // Verify system notification callback was registered + expect(mocks.addChannelCallback).toHaveBeenCalledWith({ + channelName: 'system-notifications.v1.account-activity.v1', + callback: expect.any(Function), + }); + }); + + // Test custom namespace separately + await withService( + { subscriptionNamespace: 'custom-activity.v2' }, + async ({ service, mocks }) => { + expect(service).toBeInstanceOf(AccountActivityService); + expect(service.name).toBe('AccountActivityService'); + + // Verify custom namespace was used in system notification callback + expect(mocks.addChannelCallback).toHaveBeenCalledWith({ + channelName: 'system-notifications.v1.custom-activity.v2', + callback: expect.any(Function), + }); + }, + ); + }); + }); + + // ============================================================================= + // EVENT HANDLERS TESTS + // ============================================================================= + describe('event handlers', () => { + describe('handleSystemNotification', () => { + it('should handle invalid system notifications by throwing error for missing required fields', async () => { + await withService(async ({ mocks }) => { + const systemCallback = getSystemNotificationCallback(mocks); + + // Simulate invalid system notification + const invalidNotification = { + event: 'system-notification', + channel: 'system', + data: { invalid: true }, // Missing required fields + timestamp: Date.now(), + }; + + // The callback should throw an error for invalid data + expect(() => systemCallback(invalidNotification)).toThrow( + 'Invalid system notification data: missing chainIds or status', + ); + }); + }); + + it('should track chains as up and down based on system notifications', async () => { + jest.useFakeTimers(); + // Remove the random jitter so the debounce delay is exactly the base + // window; jitter is covered separately below. + jest.spyOn(Math, 'random').mockReturnValue(0); + try { + await withService(async ({ messenger, mocks }) => { + const statusChangedEventListener = jest.fn(); + messenger.subscribe( + 'AccountActivityService:statusChanged', + statusChangedEventListener, + ); + const systemCallback = getSystemNotificationCallback(mocks); + + // Simulate chains coming up + systemCallback({ + event: 'system-notification', + channel: 'system-notifications.v1.account-activity.v1', + data: { + chainIds: ['eip155:1', 'eip155:137'], + status: 'up', + }, + timestamp: 1760344704595, + }); + + jest.advanceTimersByTime(1000); + + expect(statusChangedEventListener).toHaveBeenCalledWith({ + chainIds: ['eip155:1', 'eip155:137'], + status: 'up', + }); + + // Simulate one chain going down + systemCallback({ + event: 'system-notification', + channel: 'system-notifications.v1.account-activity.v1', + data: { + chainIds: ['eip155:137'], + status: 'down', + }, + timestamp: 1760344704696, + }); + + jest.advanceTimersByTime(1000); + + expect(statusChangedEventListener).toHaveBeenCalledWith({ + chainIds: ['eip155:137'], + status: 'down', + }); + }); + } finally { + jest.useRealTimers(); + } + }); + + it('accumulates notifications and publishes one batched event per status after the debounce window', async () => { + jest.useFakeTimers(); + // Remove the random jitter so the debounce delay is exactly the base + // window; jitter is covered separately below. + jest.spyOn(Math, 'random').mockReturnValue(0); + try { + await withService(async ({ messenger, mocks }) => { + const statusChangedEventListener = jest.fn(); + messenger.subscribe( + 'AccountActivityService:statusChanged', + statusChangedEventListener, + ); + const systemCallback = getSystemNotificationCallback(mocks); + + // Burst of notifications within the debounce window + systemCallback({ + event: 'system-notification', + channel: 'system-notifications.v1.account-activity.v1', + data: { chainIds: ['eip155:1', 'eip155:137'], status: 'up' }, + timestamp: 1000, + }); + systemCallback({ + event: 'system-notification', + channel: 'system-notifications.v1.account-activity.v1', + data: { chainIds: ['eip155:56'], status: 'up' }, + timestamp: 2000, + }); + // eip155:137 flips to down before the window elapses + systemCallback({ + event: 'system-notification', + channel: 'system-notifications.v1.account-activity.v1', + data: { chainIds: ['eip155:137'], status: 'down' }, + timestamp: 3000, + }); + + // Nothing published yet - still within the debounce window + expect(statusChangedEventListener).not.toHaveBeenCalled(); + + jest.advanceTimersByTime(1000); + + // One batched 'up' event and one batched 'down' event. + expect(statusChangedEventListener).toHaveBeenCalledTimes(2); + expect(statusChangedEventListener).toHaveBeenCalledWith({ + chainIds: ['eip155:1', 'eip155:56'], + status: 'up', + }); + expect(statusChangedEventListener).toHaveBeenCalledWith({ + chainIds: ['eip155:137'], + status: 'down', + }); + }); + } finally { + jest.useRealTimers(); + } + }); + + it('resets the debounce timer on each notification', async () => { + jest.useFakeTimers(); + // Remove the random jitter so the debounce delay is exactly the base + // window; jitter is covered separately below. + jest.spyOn(Math, 'random').mockReturnValue(0); + try { + await withService(async ({ messenger, mocks }) => { + const statusChangedEventListener = jest.fn(); + messenger.subscribe( + 'AccountActivityService:statusChanged', + statusChangedEventListener, + ); + const systemCallback = getSystemNotificationCallback(mocks); + + systemCallback({ + event: 'system-notification', + channel: 'system-notifications.v1.account-activity.v1', + data: { chainIds: ['eip155:1'], status: 'up' }, + timestamp: 1000, + }); + + // A second notification part-way through the window resets the + // timer, pushing the flush back another full window. + jest.advanceTimersByTime(500); + systemCallback({ + event: 'system-notification', + channel: 'system-notifications.v1.account-activity.v1', + data: { chainIds: ['eip155:137'], status: 'up' }, + timestamp: 1500, + }); + + // 1000ms after the first notification, but only 500ms after the + // second: not flushed yet because the timer was reset. + jest.advanceTimersByTime(500); + expect(statusChangedEventListener).not.toHaveBeenCalled(); + + // A full window after the last notification flushes both chains. + jest.advanceTimersByTime(500); + expect(statusChangedEventListener).toHaveBeenCalledTimes(1); + expect(statusChangedEventListener).toHaveBeenCalledWith({ + chainIds: ['eip155:1', 'eip155:137'], + status: 'up', + }); + }); + } finally { + jest.useRealTimers(); + } + }); + + it('adds random jitter on top of the debounce window before publishing', async () => { + jest.useFakeTimers(); + // Mid-range jitter: 1000ms debounce + 0.5 * 1000ms jitter = 1500ms. + jest.spyOn(Math, 'random').mockReturnValue(0.5); + try { + await withService(async ({ messenger, mocks }) => { + const statusChangedEventListener = jest.fn(); + messenger.subscribe( + 'AccountActivityService:statusChanged', + statusChangedEventListener, + ); + const systemCallback = getSystemNotificationCallback(mocks); + + systemCallback({ + event: 'system-notification', + channel: 'system-notifications.v1.account-activity.v1', + data: { chainIds: ['eip155:1'], status: 'up' }, + timestamp: 1000, + }); + + // Not yet flushed at the base debounce window: jitter delays it. + jest.advanceTimersByTime(1000); + expect(statusChangedEventListener).not.toHaveBeenCalled(); + + // Flushed once the jittered delay elapses. + jest.advanceTimersByTime(500); + expect(statusChangedEventListener).toHaveBeenCalledTimes(1); + expect(statusChangedEventListener).toHaveBeenCalledWith({ + chainIds: ['eip155:1'], + status: 'up', + }); + }); + } finally { + jest.useRealTimers(); + } + }); + + it('drops buffered status changes when the WebSocket disconnects before the window elapses', async () => { + jest.useFakeTimers(); + // Remove the random jitter so the debounce delay is exactly the base + // window; jitter is covered separately below. + jest.spyOn(Math, 'random').mockReturnValue(0); + try { + await withService(async ({ messenger, rootMessenger, mocks }) => { + const statusChangedEventListener = jest.fn(); + messenger.subscribe( + 'AccountActivityService:statusChanged', + statusChangedEventListener, + ); + mocks.getAccountsFromSelectedAccountGroup.mockReturnValue([]); + const systemCallback = getSystemNotificationCallback(mocks); + + systemCallback({ + event: 'system-notification', + channel: 'system-notifications.v1.account-activity.v1', + data: { chainIds: ['eip155:1', 'eip155:137'], status: 'up' }, + timestamp: 1000, + }); + + // Disconnect before the debounce window elapses. + rootMessenger.publish( + 'BackendWebSocketService:connectionStateChanged', + { + state: WebSocketState.DISCONNECTED, + url: 'ws://test', + reconnectAttempts: 0, + timeout: 10000, + reconnectDelay: 500, + maxReconnectDelay: 5000, + requestTimeout: 30000, + }, + ); + + // The buffered 'up' event is dropped; only the 'down' flush for + // the tracked chains is published. + expect(statusChangedEventListener).toHaveBeenCalledTimes(1); + expect(statusChangedEventListener).toHaveBeenCalledWith({ + chainIds: ['eip155:1', 'eip155:137'], + status: 'down', + timestamp: expect.any(Number), + }); + + // Advancing past the window must not emit the stale 'up' event. + jest.advanceTimersByTime(1000); + expect(statusChangedEventListener).toHaveBeenCalledTimes(1); + }); + } finally { + jest.useRealTimers(); + } + }); + + it('publishes down on disconnect for chains whose buffered down was still pending', async () => { + jest.useFakeTimers(); + // Remove the random jitter so the debounce delay is exactly the base + // window; jitter is covered separately below. + jest.spyOn(Math, 'random').mockReturnValue(0); + try { + await withService(async ({ messenger, rootMessenger, mocks }) => { + const statusChangedEventListener = jest.fn(); + messenger.subscribe( + 'AccountActivityService:statusChanged', + statusChangedEventListener, + ); + mocks.getAccountsFromSelectedAccountGroup.mockReturnValue([]); + const systemCallback = getSystemNotificationCallback(mocks); + + // Both chains come up and the window elapses, so consumers have + // been told they are up. + systemCallback({ + event: 'system-notification', + channel: 'system-notifications.v1.account-activity.v1', + data: { chainIds: ['eip155:1', 'eip155:137'], status: 'up' }, + timestamp: 1000, + }); + jest.advanceTimersByTime(1000); + expect(statusChangedEventListener).toHaveBeenCalledWith({ + chainIds: ['eip155:1', 'eip155:137'], + status: 'up', + }); + + // eip155:137 goes down, but the WebSocket disconnects before the + // debounce window elapses. + systemCallback({ + event: 'system-notification', + channel: 'system-notifications.v1.account-activity.v1', + data: { chainIds: ['eip155:137'], status: 'down' }, + timestamp: 2000, + }); + rootMessenger.publish( + 'BackendWebSocketService:connectionStateChanged', + { + state: WebSocketState.DISCONNECTED, + url: 'ws://test', + reconnectAttempts: 0, + timeout: 10000, + reconnectDelay: 500, + maxReconnectDelay: 5000, + requestTimeout: 30000, + }, + ); + + // The disconnect flush must include the chain whose buffered + // 'down' was still pending, not just the chains still tracked + // as up. + expect(statusChangedEventListener).toHaveBeenCalledTimes(2); + expect(statusChangedEventListener).toHaveBeenLastCalledWith({ + chainIds: ['eip155:1', 'eip155:137'], + status: 'down', + timestamp: expect.any(Number), + }); + + // Advancing past the window must not emit anything further. + jest.advanceTimersByTime(1000); + expect(statusChangedEventListener).toHaveBeenCalledTimes(2); + }); + } finally { + jest.useRealTimers(); + } + }); + + it('cancels a pending debounced flush when the service is destroyed', async () => { + jest.useFakeTimers(); + // Remove the random jitter so the debounce delay is exactly the base + // window; jitter is covered separately below. + jest.spyOn(Math, 'random').mockReturnValue(0); + try { + await withService(async ({ service, messenger, mocks }) => { + const statusChangedEventListener = jest.fn(); + messenger.subscribe( + 'AccountActivityService:statusChanged', + statusChangedEventListener, + ); + const systemCallback = getSystemNotificationCallback(mocks); + + systemCallback({ + event: 'system-notification', + channel: 'system-notifications.v1.account-activity.v1', + data: { chainIds: ['eip155:1'], status: 'up' }, + timestamp: 1000, + }); + + // Destroying before the window elapses cancels the pending flush. + service.destroy(); + + jest.advanceTimersByTime(1000); + expect(statusChangedEventListener).not.toHaveBeenCalled(); + }); + } finally { + jest.useRealTimers(); + } + }); + }); + + describe('handleWebSocketStateChange', () => { + it('should handle WebSocket ERROR state by publishing tracked chains as down', async () => { + await withService(async ({ messenger, rootMessenger, mocks }) => { + const statusChangedEventListener = jest.fn(); + messenger.subscribe( + 'AccountActivityService:statusChanged', + statusChangedEventListener, + ); + + mocks.getAccountsFromSelectedAccountGroup.mockReturnValue([]); + + // First, simulate receiving a system notification with chains up + const systemCallback = getSystemNotificationCallback(mocks); + systemCallback({ + event: 'system-notification', + channel: 'system-notifications.v1.account-activity.v1', + data: { + chainIds: ['eip155:1', 'eip155:137', 'eip155:56'], + status: 'up', + }, + timestamp: 1760344704595, + }); + + // Publish WebSocket DISCONNECTED state event - should flush tracked chains as down + rootMessenger.publish( + 'BackendWebSocketService:connectionStateChanged', + { + state: WebSocketState.DISCONNECTED, + url: 'ws://test', + reconnectAttempts: 2, + timeout: 10000, + reconnectDelay: 500, + maxReconnectDelay: 5000, + requestTimeout: 30000, + }, + ); + await completeAsyncOperations(100); + + // Verify that the DISCONNECTED state triggered the status change for tracked chains + expect(statusChangedEventListener).toHaveBeenCalledWith({ + chainIds: ['eip155:1', 'eip155:137', 'eip155:56'], + status: 'down', + timestamp: expect.any(Number), + }); + }); + }); + + it('should not publish status change on disconnect when no chains are tracked', async () => { + await withService(async ({ messenger, rootMessenger, mocks }) => { + const statusChangedEventListener = jest.fn(); + messenger.subscribe( + 'AccountActivityService:statusChanged', + statusChangedEventListener, + ); + + mocks.getAccountsFromSelectedAccountGroup.mockReturnValue([]); + + // Publish WebSocket DISCONNECTED state event without any tracked chains + rootMessenger.publish( + 'BackendWebSocketService:connectionStateChanged', + { + state: WebSocketState.DISCONNECTED, + url: 'ws://test', + reconnectAttempts: 2, + timeout: 10000, + reconnectDelay: 500, + maxReconnectDelay: 5000, + requestTimeout: 30000, + }, + ); + await completeAsyncOperations(100); + + // Verify that no status change was published since no chains were tracked + expect(statusChangedEventListener).not.toHaveBeenCalled(); + }); + }); + }); + + describe('handleSelectedAccountChange', () => { + it('should handle valid account scope conversion by processing account change events without errors', async () => { + await withService(async ({ service, rootMessenger }) => { + // Publish valid account change event + rootMessenger.publish( + 'AccountTreeController:selectedAccountGroupChange', + '', + '', + ); + + // Verify service remains functional after processing valid account + expect(service).toBeInstanceOf(AccountActivityService); + expect(service.name).toBe('AccountActivityService'); + }); + }); + + it('does not subscribe accounts whose scopes are not supported', async () => { + await withService(async ({ mocks, rootMessenger }) => { + const unknownAccount = createMockInternalAccount({ + address: 'UnknownChainAddress456def', + }); + unknownAccount.scopes = ['bitcoin:mainnet', 'unknown:chain']; + + // Publish account change event - will be picked up by controller subscription + rootMessenger.publish( + 'AccountTreeController:selectedAccountGroupChange', + '', + '', + ); + // Wait for async handler to complete + await completeAsyncOperations(); + + expect(mocks.subscribe).not.toHaveBeenCalled(); + }); + }); + + it('subscribes to chains beyond EVM and Solana when their migration flag is active', async () => { + await withService(async ({ mocks, rootMessenger }) => { + mocks.getFeatureFlagState.mockReturnValue({ + remoteFeatureFlags: { + networkAssetsSnapsMigrationTron: { stage: 1 }, + }, + cacheTimestamp: 0, + }); + mocks.subscribe.mockResolvedValue({ + subscriptionId: 'tron-sub-123', + unsubscribe: jest.fn(), + }); + const evmAccount = createMockInternalAccount({ + address: '0xEvmAddress123abc', + scopes: ['eip155:1'], + options: { + entropy: { + type: 'mnemonic', + id: '0xentropy1', + groupIndex: 0, + derivationPath: "m/44'/60'/0'/0/0", + }, + }, + }); + const tronAccount = createMockInternalAccount({ + address: 'TronAddress123abc', + scopes: ['tron:1234'], + options: { + entropy: { + type: 'mnemonic', + id: '0xentropy1', + groupIndex: 0, + derivationPath: "m/44'/195'/0'/0/0", + }, + }, + }); + mocks.getAccountsFromSelectedAccountGroup.mockReturnValue([ + evmAccount, + tronAccount, + ]); + + rootMessenger.publish( + 'AccountTreeController:selectedAccountGroupChange', + '', + '', + ); + await completeAsyncOperations(); + + expect(mocks.subscribe).toHaveBeenCalledWith( + expect.objectContaining({ + channels: [ + 'account-activity.v1.eip155:0:0xevmaddress123abc', + 'account-activity.v1.tron:0:tronaddress123abc', + ], + }), + ); + }); + }); + + it('forces reconnection when an error is thrown during subscription', async () => { + await withService(async ({ mocks, rootMessenger }) => { + mocks.subscribe.mockRejectedValue(new Error('Subscription failed')); + mocks.getAccountsFromSelectedAccountGroup.mockReturnValue([ + createMockInternalAccount({ + address: '0xEvmAddress123abc', + }), + ]); + + rootMessenger.publish( + 'AccountTreeController:selectedAccountGroupChange', + '', + '', + ); + await completeAsyncOperations(); + + expect(mocks.forceReconnection).toHaveBeenCalled(); + }); + }); + + it('subscribes to all accounts from the same entropy and group index', async () => { + await withService(async ({ mocks, rootMessenger }) => { + // Create two accounts with the same entropy and group index + const account1 = createMockInternalAccount({ + address: '0xaccount1', + options: { + entropy: { + type: 'mnemonic', + id: '0xentropy1', + groupIndex: 0, + derivationPath: "m/44'/60'/0'/0/0", + }, + }, + }); + const account2 = createMockInternalAccount({ + address: '0xaccount2', + options: { + entropy: { + type: 'mnemonic', + id: '0xentropy1', + groupIndex: 0, + derivationPath: "m/44'/60'/0'/0/1", + }, + }, + }); + mocks.getAccountsFromSelectedAccountGroup.mockReturnValue([ + account1, + account2, + ]); + mocks.subscribe.mockResolvedValue({ + subscriptionId: 'sub-789', + unsubscribe: jest.fn(), + }); + + // Publish account change event for account1 + rootMessenger.publish( + 'AccountTreeController:selectedAccountGroupChange', + '', + '', + ); + // Wait for async handler to complete + await completeAsyncOperations(); + + // Verify that subscribe was called for both accounts with the same entropy and group index + expect(mocks.subscribe).toHaveBeenCalledTimes(1); + expect(mocks.subscribe).toHaveBeenCalledWith( + expect.objectContaining({ + channels: [ + 'account-activity.v1.eip155:0:0xaccount1', + 'account-activity.v1.eip155:0:0xaccount2', + ], + }), + ); + }); + }); + + it('handles the selected account activity messages by processing transactions and balance updates and publishing events', async () => { + await withService( + { accountAddress: '0x1234567890123456789012345678901234567890' }, + async ({ rootMessenger, mocks, messenger, mockSelectedAccounts }) => { + let capturedCallback: ( + notification: ServerNotificationMessage, + ) => void = jest.fn(); + // Mock the subscribe call to capture the callback + mocks.subscribe.mockImplementation((options) => { + // Capture the callback from the subscription options + capturedCallback = options.callback; + return Promise.resolve({ + subscriptionId: 'sub-123', + unsubscribe: () => Promise.resolve(), + }); + }); + mocks.getAccountsFromSelectedAccountGroup.mockReturnValue( + mockSelectedAccounts, + ); + + rootMessenger.publish( + 'AccountTreeController:selectedAccountGroupChange', + '', + '', + ); + // Wait for async handler to complete + await completeAsyncOperations(); + + // Simulate receiving account activity message + const activityMessage: AccountActivityMessage = { + address: '0x1234567890123456789012345678901234567890', + tx: { + id: '0xabc123', + chain: 'eip155:1', + status: 'confirmed', + timestamp: Date.now(), + from: '0x1234567890123456789012345678901234567890', + to: '0x9876543210987654321098765432109876543210', + }, + updates: [ + { + asset: { + fungible: true, + type: 'eip155:1/slip44:60', + unit: 'ETH', + decimals: 18, + }, + postBalance: { + amount: '1000000000000000000', // 1 ETH + }, + transfers: [ + { + from: '0x1234567890123456789012345678901234567890', + to: '0x9876543210987654321098765432109876543210', + amount: '500000000000000000', // 0.5 ETH + }, + ], + }, + ], + }; + + const notificationMessage = { + event: 'notification', + subscriptionId: 'sub-123', + channel: + 'account-activity.v1.eip155:1:0x1234567890123456789012345678901234567890', + data: activityMessage, + timestamp: 1760344704595, + }; + + // Subscribe to events to verify they are published + const receivedTransactionEvents: Transaction[] = []; + const receivedBalanceEvents: { + address: string; + chain: string; + updates: BalanceUpdate[]; + }[] = []; + + messenger.subscribe( + 'AccountActivityService:transactionUpdated', + (data) => { + receivedTransactionEvents.push(data); + }, + ); + + messenger.subscribe( + 'AccountActivityService:balanceUpdated', + (data) => { + receivedBalanceEvents.push(data); + }, + ); + + // Call the captured callback + capturedCallback(notificationMessage); + + // Should receive transaction and balance events + expect(receivedTransactionEvents).toHaveLength(1); + expect(receivedTransactionEvents[0]).toStrictEqual( + activityMessage.tx, + ); + + expect(receivedBalanceEvents).toHaveLength(1); + expect(receivedBalanceEvents[0]).toStrictEqual({ + address: '0x1234567890123456789012345678901234567890', + chain: 'eip155:1', + updates: activityMessage.updates, + }); + }, + ); + }); + + it('does not subscribe to Solana channels when the Solana migration flag is not set', async () => { + await withService(async ({ mocks, rootMessenger }) => { + mocks.getFeatureFlagState.mockReturnValue({ + remoteFeatureFlags: {}, + cacheTimestamp: 0, + }); + const solanaAccount = createMockInternalAccount({ + address: 'SolanaAddress123abc', + scopes: ['solana:mainnet-beta'], + options: { + entropy: { + type: 'mnemonic', + id: '0xentropy1', + groupIndex: 0, + derivationPath: "m/44'/501'/0'/0'", + }, + }, + }); + mocks.getAccountsFromSelectedAccountGroup.mockReturnValue([ + solanaAccount, + ]); + + rootMessenger.publish( + 'AccountTreeController:selectedAccountGroupChange', + '', + '', + ); + await completeAsyncOperations(); + + expect(mocks.subscribe).not.toHaveBeenCalled(); + }); + }); + + it('does not subscribe to Solana channels when the Solana migration flag stage is 0', async () => { + await withService(async ({ mocks, rootMessenger }) => { + mocks.getFeatureFlagState.mockReturnValue({ + remoteFeatureFlags: { + networkAssetsSnapsMigrationSolana: { stage: 0 }, + }, + cacheTimestamp: 0, + }); + const solanaAccount = createMockInternalAccount({ + address: 'SolanaAddress123abc', + scopes: ['solana:mainnet-beta'], + }); + mocks.getAccountsFromSelectedAccountGroup.mockReturnValue([ + solanaAccount, + ]); + + rootMessenger.publish( + 'AccountTreeController:selectedAccountGroupChange', + '', + '', + ); + await completeAsyncOperations(); + + expect(mocks.subscribe).not.toHaveBeenCalled(); + }); + }); + + it('should handle WebSocket connection when no selected account exists by attempting to get selected account', async () => { + await withService(async ({ rootMessenger, mocks }) => { + mocks.getAccountsFromSelectedAccountGroup.mockReturnValue([]); + + // Publish WebSocket connection event - will be picked up by controller subscription + rootMessenger.publish( + 'BackendWebSocketService:connectionStateChanged', + { + state: WebSocketState.CONNECTED, + url: 'ws://test', + reconnectAttempts: 0, + timeout: 10000, + reconnectDelay: 500, + maxReconnectDelay: 5000, + requestTimeout: 30000, + }, + ); + // Wait for async handler to complete + await completeAsyncOperations(); + + // Should attempt to get selected account even when none exists + expect( + mocks.getAccountsFromSelectedAccountGroup, + ).toHaveBeenCalledTimes(1); + }); + }); + + it('should skip resubscription when already subscribed to new account by not calling subscribe again', async () => { + await withService( + { accountAddress: '0x123abc' }, + async ({ mocks, rootMessenger }) => { + // Set up mocks + mocks.getAccountsFromSelectedAccountGroup.mockReturnValue([ + createMockInternalAccount({ address: '0x123abc' }), + ]); + mocks.channelHasSubscription.mockReturnValue(true); // Already subscribed + mocks.subscribe.mockResolvedValue({ + unsubscribe: jest.fn(), + }); + + // Publish account change event on root messenger + rootMessenger.publish( + 'AccountTreeController:selectedAccountGroupChange', + '', + '', + ); + await completeAsyncOperations(); + + // Verify that subscribe was not called since already subscribed + expect(mocks.subscribe).not.toHaveBeenCalled(); + }, + ); + }); + + it('should handle errors during account change processing by gracefully handling unsubscribe failures', async () => { + await withService( + { accountAddress: '0x123abc' }, + async ({ service, mocks, rootMessenger }) => { + // Set up mocks to cause an error in the unsubscribe step + mocks.getAccountsFromSelectedAccountGroup.mockReturnValue([ + createMockInternalAccount({ address: '0x123abc' }), + ]); + mocks.channelHasSubscription.mockReturnValue(false); + mocks.findSubscriptionsByChannelPrefix.mockReturnValue([ + { + unsubscribe: jest + .fn() + .mockRejectedValue(new Error('Unsubscribe failed')), + }, + ]); + mocks.subscribe.mockResolvedValue({ + unsubscribe: jest.fn(), + }); + + // Publish account change event on root messenger + rootMessenger.publish( + 'AccountTreeController:selectedAccountGroupChange', + '', + '', + ); + await completeAsyncOperations(); + + // Verify service handled the error gracefully and remains functional + expect(service).toBeInstanceOf(AccountActivityService); + expect(service.name).toBe('AccountActivityService'); + + // Verify unsubscribe was attempted despite failure + expect(mocks.findSubscriptionsByChannelPrefix).toHaveBeenCalled(); + }, + ); + }); + + it('should resubscribe to selected account when WebSocket connects', async () => { + await withService( + { accountAddress: '0x123abc' }, + async ({ mocks, rootMessenger }) => { + // Set up mocks + const testAccount = createMockInternalAccount({ + address: '0x123abc', + }); + mocks.getAccountsFromSelectedAccountGroup.mockReturnValue([ + testAccount, + ]); + + // Publish WebSocket connection event + rootMessenger.publish( + 'BackendWebSocketService:connectionStateChanged', + { + state: WebSocketState.CONNECTED, + url: 'ws://test', + reconnectAttempts: 0, + timeout: 10000, + reconnectDelay: 500, + maxReconnectDelay: 5000, + requestTimeout: 30000, + }, + ); + await completeAsyncOperations(); + + // Verify it resubscribed to the selected account + expect(mocks.subscribe).toHaveBeenCalledWith({ + channelType: 'account-activity.v1', + channels: ['account-activity.v1.eip155:0:0x123abc'], + callback: expect.any(Function), + }); + }, + ); + }); + }); + + describe('handleFeatureFlagsStateChange', () => { + it('resubscribes with the new chains when the enabled chains change', async () => { + await withService(async ({ mocks, rootMessenger }) => { + const solanaAccount = createMockInternalAccount({ + address: 'SolanaAddress123abc', + }); + solanaAccount.scopes = ['solana:mainnet-beta']; + mocks.getAccountsFromSelectedAccountGroup.mockReturnValue([ + solanaAccount, + ]); + mocks.getFeatureFlagState.mockReturnValue(featureFlagState(0)); + mocks.subscribe.mockResolvedValue({ + subscriptionId: 'sub-123', + unsubscribe: jest.fn(), + }); + + // Connect with the Solana flag off: no subscription + rootMessenger.publish( + 'BackendWebSocketService:connectionStateChanged', + { + state: WebSocketState.CONNECTED, + url: 'ws://test', + reconnectAttempts: 0, + timeout: 10000, + reconnectDelay: 500, + maxReconnectDelay: 5000, + requestTimeout: 30000, + }, + ); + await completeAsyncOperations(); + expect(mocks.subscribe).not.toHaveBeenCalled(); + + // Flag flips to stage 1: service resubscribes with the Solana channel + mocks.getFeatureFlagState.mockReturnValue(featureFlagState(1)); + rootMessenger.publish( + 'RemoteFeatureFlagController:stateChange', + featureFlagState(1), + [], + ); + await completeAsyncOperations(); + + expect(mocks.subscribe).toHaveBeenCalledWith( + expect.objectContaining({ + channels: ['account-activity.v1.solana:0:solanaaddress123abc'], + }), + ); + }); + }); + + it('does not resubscribe when a change in unrelated feature flags leaves the enabled chains unchanged', async () => { + await withService(async ({ mocks, rootMessenger }) => { + const evmAccount = createMockInternalAccount({ + address: '0xevmaccount', + }); + mocks.getAccountsFromSelectedAccountGroup.mockReturnValue([ + evmAccount, + ]); + mocks.subscribe.mockResolvedValue({ + subscriptionId: 'sub-123', + unsubscribe: jest.fn(), + }); + + // Prime the selector cache with an initial publish + rootMessenger.publish( + 'RemoteFeatureFlagController:stateChange', + featureFlagState(1), + [], + ); + await completeAsyncOperations(); + mocks.subscribe.mockClear(); + mocks.findSubscriptionsByChannelPrefix.mockClear(); + + // An unrelated flag changes while the migration flags are the same + const newState = featureFlagState(1); + newState.remoteFeatureFlags.someUnrelatedFlag = true; + rootMessenger.publish( + 'RemoteFeatureFlagController:stateChange', + newState, + [], + ); + await completeAsyncOperations(); + + expect(mocks.subscribe).not.toHaveBeenCalled(); + expect(mocks.findSubscriptionsByChannelPrefix).not.toHaveBeenCalled(); + }); + }); + + it('does not resubscribe on flag change when the websocket is not connected', async () => { + await withService(async ({ mocks, rootMessenger }) => { + mocks.getConnectionInfo.mockReturnValue({ + state: WebSocketState.DISCONNECTED, + }); + + rootMessenger.publish( + 'RemoteFeatureFlagController:stateChange', + featureFlagState(1), + [], + ); + await completeAsyncOperations(); + + expect(mocks.subscribe).not.toHaveBeenCalled(); + expect(mocks.findSubscriptionsByChannelPrefix).not.toHaveBeenCalled(); + }); + }); + + it('handles errors during flag change handling gracefully', async () => { + await withService(async ({ mocks, rootMessenger }) => { + mocks.getConnectionInfo.mockImplementation(() => { + throw new Error('Connection info unavailable'); + }); + + expect(() => + rootMessenger.publish( + 'RemoteFeatureFlagController:stateChange', + featureFlagState(1), + [], + ), + ).not.toThrow(); + await completeAsyncOperations(); + + expect(mocks.subscribe).not.toHaveBeenCalled(); + }); + }); + }); + }); +}); diff --git a/packages/core-backend/src/ws/AccountActivityService.ts b/packages/core-backend/src/ws/AccountActivityService.ts new file mode 100644 index 00000000000..c74ff42ce25 --- /dev/null +++ b/packages/core-backend/src/ws/AccountActivityService.ts @@ -0,0 +1,716 @@ +/** + * Account Activity Service for monitoring account transactions and balance changes + * + * This service subscribes to account activity and receives all transactions + * and balance updates for those accounts via the comprehensive AccountActivityMessage format. + */ + +import type { + AccountTreeControllerSelectedAccountGroupChangeEvent, + AccountTreeControllerGetAccountsFromSelectedAccountGroupAction, +} from '@metamask/account-tree-controller'; +import type { TraceCallback } from '@metamask/controller-utils'; +import type { InternalAccount } from '@metamask/keyring-internal-api'; +import type { Messenger } from '@metamask/messenger'; +import type { + FeatureFlags, + RemoteFeatureFlagControllerGetStateAction, + RemoteFeatureFlagControllerStateChangeEvent, +} from '@metamask/remote-feature-flag-controller'; +import { isObject } from '@metamask/utils'; + +import { projectLogger, createModuleLogger } from '../logger.js'; +import type { + Transaction, + AccountActivityMessage, + BalanceUpdate, +} from '../types.js'; +import type { BackendWebSocketServiceMethodActions } from './BackendWebSocketService-method-action-types.js'; +import type { + WebSocketConnectionInfo, + BackendWebSocketServiceConnectionStateChangedEvent, + ServerNotificationMessage, +} from './BackendWebSocketService.js'; +import { WebSocketState } from './BackendWebSocketService.js'; + +// ============================================================================= +// Types and Constants +// ============================================================================= + +/** + * System notification data for chain status updates + */ +export type SystemNotificationData = { + /** Array of chain IDs affected (e.g., ['eip155:137', 'eip155:1']) */ + chainIds: string[]; + /** Status of the chains: 'down' or 'up' */ + status: 'down' | 'up'; + /** Timestamp of the notification */ + timestamp?: number; +}; + +const SERVICE_NAME = 'AccountActivityService'; + +const log = createModuleLogger(projectLogger, SERVICE_NAME); + +const MESSENGER_EXPOSED_METHODS = [] as const; + +const SUBSCRIPTION_NAMESPACE = 'account-activity.v1'; + +// Window (in ms) over which consecutive system notifications are accumulated +// before a single batched `statusChanged` event is published. Coalescing bursts +// of chain up/down notifications avoids flooding consumers. +const STATUS_CHANGE_DEBOUNCE_MS = 1000; + +// Maximum random jitter (in ms) added to the debounce window before publishing. +// Spreading the publish across a random delay prevents many clients reacting to +// the same system notification from publishing in lockstep (a thundering herd +// on downstream consumers). +const STATUS_CHANGE_JITTER_MS = 1000; + +// EVM subscriptions are always enabled. +const ALWAYS_SUPPORTED_CHAIN_PREFIXES = ['eip155'] as const; + +// Non-EVM chains are gated behind the +// per-network snaps-migration remote feature flags: a chain is +// enabled when its flag payload has `stage >= 1`. +const CHAIN_PREFIX_FEATURE_FLAGS = { + solana: 'networkAssetsSnapsMigrationSolana', + tron: 'networkAssetsSnapsMigrationTron', + stellar: 'networkAssetsSnapsMigrationStellar', +} as const; + +/** + * Account subscription options + */ +export type SubscriptionOptions = { + /** + * Array of addresses to subscribe to, each in CAIP-10 format (e.g., "eip155:0:0x1234..." or "solana:0:ABC123...") + */ + addresses: string[]; +}; + +/** + * Configuration options for the account activity service + */ +export type AccountActivityServiceOptions = { + /** Custom subscription namespace (default: 'account-activity.v1') */ + subscriptionNamespace?: string; + /** Optional callback to trace performance of account activity operations (default: no-op) */ + traceFn?: TraceCallback; +}; + +// ============================================================================= +// Action and Event Types +// ============================================================================= + +// Action types for the messaging system +export type AccountActivityServiceActions = never; + +// Allowed actions that AccountActivityService can call on other controllers +export const ACCOUNT_ACTIVITY_SERVICE_ALLOWED_ACTIONS = [ + 'AccountTreeController:getAccountsFromSelectedAccountGroup', + 'BackendWebSocketService:connect', + 'BackendWebSocketService:forceReconnection', + 'BackendWebSocketService:subscribe', + 'BackendWebSocketService:getConnectionInfo', + 'BackendWebSocketService:channelHasSubscription', + 'BackendWebSocketService:getSubscriptionsByChannel', + 'BackendWebSocketService:findSubscriptionsByChannelPrefix', + 'BackendWebSocketService:addChannelCallback', + 'BackendWebSocketService:removeChannelCallback', + 'RemoteFeatureFlagController:getState', +] as const; + +// Allowed events that AccountActivityService can listen to +export const ACCOUNT_ACTIVITY_SERVICE_ALLOWED_EVENTS = [ + 'AccountTreeController:selectedAccountGroupChange', + 'BackendWebSocketService:connectionStateChanged', + 'RemoteFeatureFlagController:stateChange', +] as const; + +export type AllowedActions = + | AccountTreeControllerGetAccountsFromSelectedAccountGroupAction + | BackendWebSocketServiceMethodActions + | RemoteFeatureFlagControllerGetStateAction; + +// Event types for the messaging system + +export type AccountActivityServiceTransactionUpdatedEvent = { + type: `AccountActivityService:transactionUpdated`; + payload: [Transaction]; +}; + +export type AccountActivityServiceBalanceUpdatedEvent = { + type: `AccountActivityService:balanceUpdated`; + payload: [{ address: string; chain: string; updates: BalanceUpdate[] }]; +}; + +export type AccountActivityServiceSubscriptionErrorEvent = { + type: `AccountActivityService:subscriptionError`; + payload: [{ addresses: string[]; error: string; operation: string }]; +}; + +export type AccountActivityServiceStatusChangedEvent = { + type: `AccountActivityService:statusChanged`; + payload: [ + { + chainIds: string[]; + status: 'up' | 'down'; + timestamp?: number; + }, + ]; +}; + +export type AccountActivityServiceEvents = + | AccountActivityServiceTransactionUpdatedEvent + | AccountActivityServiceBalanceUpdatedEvent + | AccountActivityServiceSubscriptionErrorEvent + | AccountActivityServiceStatusChangedEvent; + +export type AllowedEvents = + | AccountTreeControllerSelectedAccountGroupChangeEvent + | BackendWebSocketServiceConnectionStateChangedEvent + | RemoteFeatureFlagControllerStateChangeEvent; + +export type AccountActivityServiceMessenger = Messenger< + typeof SERVICE_NAME, + AccountActivityServiceActions | AllowedActions, + AccountActivityServiceEvents | AllowedEvents +>; + +// ============================================================================= +// Main Service Class +// ============================================================================= + +/** + * High-performance service for real-time account activity monitoring using optimized + * WebSocket subscriptions with direct callback routing. Automatically subscribes to + * the currently selected account and switches subscriptions when the selected account changes. + * Receives transactions and balance updates using the comprehensive AccountActivityMessage format. + * + * Performance Features: + * - Direct callback routing (no EventEmitter overhead) + * - Minimal subscription tracking (no duplication with BackendWebSocketService) + * - Optimized cleanup for mobile environments + * - Single-account subscription (only selected account) + * - Comprehensive balance updates with transfer tracking + * + * Architecture: + * - Uses messenger pattern to communicate with BackendWebSocketService + * - AccountActivityService tracks channel-to-subscriptionId mappings via messenger calls + * - Automatically subscribes to selected account on initialization + * - Switches subscriptions when selected account changes + * - No direct dependency on BackendWebSocketService (uses messenger instead) + * + * @example + * ```typescript + * const service = new AccountActivityService({ + * messenger: activityMessenger, + * }); + * + * // Service automatically subscribes to the currently selected account + * // When user switches accounts, service automatically resubscribes + * + * // All transactions and balance updates are received via optimized + * // WebSocket callbacks and processed with zero-allocation routing + * // Balance updates include comprehensive transfer details and post-transaction balances + * ``` + */ +export class AccountActivityService { + /** + * The name of the service. + */ + readonly name = SERVICE_NAME; + + readonly #messenger: AccountActivityServiceMessenger; + + readonly #options: Required>; + + readonly #trace: TraceCallback; + + // Track chains that are currently up (based on system notifications) + readonly #chainsUp: Set = new Set(); + + // Debouncing for rapid status changes: buffers the latest status per chain + // and coalesces bursts of system notifications into fewer downstream + // `statusChanged` events. + readonly #statusChangeDebouncer: { + timer: NodeJS.Timeout | null; + pendingChanges: Map; + } = { + timer: null, + pendingChanges: new Map(), + }; + + // ============================================================================= + // Constructor and Initialization + // ============================================================================= + + /** + * Creates a new Account Activity service instance + * + * @param options - Configuration options including messenger + */ + constructor( + options: AccountActivityServiceOptions & { + messenger: AccountActivityServiceMessenger; + }, + ) { + this.#messenger = options.messenger; + + // Set configuration with defaults + this.#options = { + subscriptionNamespace: + options.subscriptionNamespace ?? SUBSCRIPTION_NAMESPACE, + }; + + // Default to no-op trace function to keep core platform-agnostic + this.#trace = + options.traceFn ?? + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (((_request: any, fn?: any) => fn?.()) as TraceCallback); + + this.#messenger.registerMethodActionHandlers( + this, + MESSENGER_EXPOSED_METHODS, + ); + this.#messenger.subscribe( + 'AccountTreeController:selectedAccountGroupChange', + // Promise result intentionally not awaited + // eslint-disable-next-line @typescript-eslint/no-misused-promises + async () => await this.#handleSelectedAccountChange(), + ); + this.#messenger.subscribe( + 'BackendWebSocketService:connectionStateChanged', + // Promise result intentionally not awaited + // eslint-disable-next-line @typescript-eslint/no-misused-promises + (connectionInfo: WebSocketConnectionInfo) => + this.#handleWebSocketStateChange(connectionInfo), + ); + this.#messenger.subscribe( + // eslint-disable-next-line no-restricted-syntax + 'RemoteFeatureFlagController:stateChange', + // Promise result intentionally not awaited + // eslint-disable-next-line @typescript-eslint/no-misused-promises + async () => await this.#handleFeatureFlagsStateChange(), + // Only react to changes in the set of enabled chain prefixes. The + // messenger compares selector results with strict equality, so the + // selector must return a primitive rather than a fresh object. + (state) => + this.#getSupportedChainPrefixes(state.remoteFeatureFlags).join(','), + ); + this.#messenger.call('BackendWebSocketService:addChannelCallback', { + channelName: `system-notifications.v1.${this.#options.subscriptionNamespace}`, + callback: (notification: ServerNotificationMessage) => + this.#handleSystemNotification(notification), + }); + } + + /** + * Subscribe to account activity (transactions and balance updates) + * Addresses should be in CAIP-10 format (e.g., "eip155:0:0x1234..." or "solana:0:ABC123...") + * + * @param subscription - The subscription configuration + * @param subscription.addresses - Array of addresses to subscribe to, each in CAIP-10 format + * or an `addresses` array for batch subscription + */ + async #subscribe({ addresses }: SubscriptionOptions): Promise { + try { + await this.#messenger.call('BackendWebSocketService:connect'); + + // Derive new subscriptions to be created from the provided addresses, + // filtering out any channels that already have an active subscription + const channels = addresses + .map((address) => `${this.#options.subscriptionNamespace}.${address}`) + .filter( + (channel) => + !this.#messenger.call( + 'BackendWebSocketService:channelHasSubscription', + channel, + ), + ); + + if (channels.length === 0) { + return; + } + + // Create subscription using the proper subscribe method (this will be stored in WebSocketService's internal tracking) + await this.#messenger.call('BackendWebSocketService:subscribe', { + channels, + channelType: this.#options.subscriptionNamespace, // e.g., 'account-activity.v1' + callback: (notification: ServerNotificationMessage) => { + this.#handleAccountActivityUpdate( + notification.data as AccountActivityMessage, + ); + }, + }); + } catch (error) { + log('Subscription failed, forcing reconnection', { error }); + await this.#forceReconnection(); + } + } + + /** + * Handle account activity updates (transactions + balance changes) + * Processes the comprehensive AccountActivityMessage format with detailed balance updates and transfers + * + * @param payload - The account activity message containing transaction and balance updates + * @example AccountActivityMessage format handling: + * Input: { + * address: "0xd14b52362b5b777ffa754c666ddec6722aaeee08", + * tx: { id: "0x1cde...", chain: "eip155:8453", status: "confirmed", timestamp: 1760099871, ... }, + * updates: [{ + * asset: { fungible: true, type: "eip155:8453/erc20:0x833...", unit: "USDC", decimals: 6 }, + * postBalance: { amount: "0xc350" }, + * transfers: [{ from: "0x7b07...", to: "0xd14b...", amount: "0x2710" }] + * }] + * } + * Output: Transaction and balance updates published separately + */ + #handleAccountActivityUpdate(payload: AccountActivityMessage): void { + const { address, tx, updates } = payload; + + // Calculate time elapsed between transaction time and message receipt + const txTimestampMs = tx.timestamp * 1000; // Convert Unix timestamp (seconds) to milliseconds + const elapsedMs = Date.now() - txTimestampMs; + + log('Handling account activity update', { + address, + updateCount: updates.length, + elapsedMs, + }); + + // Trace message receipt with latency from transaction time to now + // Promise result intentionally not awaited + // eslint-disable-next-line @typescript-eslint/no-floating-promises + this.#trace( + { + name: `${SERVICE_NAME} Transaction Message`, + data: { + chain: tx.chain, + status: tx.status, + elapsed_ms: elapsedMs, + }, + tags: { + service: SERVICE_NAME, + notification_type: this.#options.subscriptionNamespace, + }, + }, + () => { + // Process transaction update + this.#messenger.publish( + `AccountActivityService:transactionUpdated`, + tx, + ); + + // Publish comprehensive balance updates with transfer details + this.#messenger.publish(`AccountActivityService:balanceUpdated`, { + address, + chain: tx.chain, + updates, + }); + }, + ); + } + + /** + * Handle selected account change event + */ + async #handleSelectedAccountChange(): Promise { + const selectedAccounts = this.#messenger.call( + 'AccountTreeController:getAccountsFromSelectedAccountGroup', + ); + + try { + // First, unsubscribe from all current account activity subscriptions to avoid multiple subscriptions + await this.#unsubscribeFromAllAccountActivity(); + + // Subscribe to the new selected accounts in CAIP-10 format + await this.#subscribe({ + addresses: this.#convertToCaip10Addresses(selectedAccounts), + }); + } catch (error) { + log('Account change failed', { error }); + } + } + + /** + * Handle system notification for chain status changes + * Publishes only the status change (delta) for affected chains + * + * @param notification - Server notification message containing chain status updates and timestamp + */ + #handleSystemNotification(notification: ServerNotificationMessage): void { + const data = notification.data as SystemNotificationData; + + // Validate required fields + if (!data.chainIds || !Array.isArray(data.chainIds) || !data.status) { + throw new Error( + 'Invalid system notification data: missing chainIds or status', + ); + } + + if (data.status === 'up') { + for (const chainId of data.chainIds) { + this.#chainsUp.add(chainId); + } + } else { + for (const chainId of data.chainIds) { + this.#chainsUp.delete(chainId); + } + } + + for (const chainId of data.chainIds) { + this.#statusChangeDebouncer.pendingChanges.set(chainId, data.status); + } + + if (this.#statusChangeDebouncer.timer) { + clearTimeout(this.#statusChangeDebouncer.timer); + } + + // Debounce window plus a random jitter, so clients reacting to the same + // system notification don't publish in lockstep. + const delay = + STATUS_CHANGE_DEBOUNCE_MS + Math.random() * STATUS_CHANGE_JITTER_MS; + + this.#statusChangeDebouncer.timer = setTimeout(() => { + this.#processAccumulatedStatusChanges(); + }, delay); + + log(`WebSocket status change - Buffered chains as ${data.status}`, { + count: data.chainIds.length, + chains: data.chainIds, + status: data.status, + }); + } + + /** + * Publish the buffered status changes as batched `statusChanged` events, one + * per status (`up`/`down`), then reset the buffer and timer. + */ + #processAccumulatedStatusChanges(): void { + const changes = Array.from( + this.#statusChangeDebouncer.pendingChanges.entries(), + ); + this.#statusChangeDebouncer.pendingChanges.clear(); + this.#statusChangeDebouncer.timer = null; + + // Group buffered chains by their latest status. A chain can only appear in + // one group because the buffer keeps a single entry per chain. + const grouped: Record<'up' | 'down', string[]> = { up: [], down: [] }; + for (const [chainId, status] of changes) { + grouped[status].push(chainId); + } + + for (const status of ['up', 'down'] as const) { + const chainIds = grouped[status]; + if (chainIds.length === 0) { + continue; + } + + this.#messenger.publish(`AccountActivityService:statusChanged`, { + chainIds, + status, + }); + + log(`WebSocket status change - Published batched chains as ${status}`, { + count: chainIds.length, + chains: chainIds, + status, + }); + } + } + + /** + * Handle WebSocket connection state changes for fallback polling and resubscription + * + * @param connectionInfo - WebSocket connection state information + */ + async #handleWebSocketStateChange( + connectionInfo: WebSocketConnectionInfo, + ): Promise { + const { state } = connectionInfo; + + if (state === WebSocketState.CONNECTED) { + // WebSocket connected - resubscribe to selected account + // The system notification will automatically provide the list of chains that are up + await this.#subscribeToSelectedAccount(); + } else if (state === WebSocketState.DISCONNECTED) { + if (this.#statusChangeDebouncer.timer) { + clearTimeout(this.#statusChangeDebouncer.timer); + this.#statusChangeDebouncer.timer = null; + } + + const chainsToMarkDown = new Set(this.#chainsUp); + for (const chainId of this.#statusChangeDebouncer.pendingChanges.keys()) { + chainsToMarkDown.add(chainId); + } + this.#statusChangeDebouncer.pendingChanges.clear(); + + if (chainsToMarkDown.size > 0) { + const chainIds = Array.from(chainsToMarkDown); + this.#messenger.publish(`AccountActivityService:statusChanged`, { + chainIds, + status: 'down', + timestamp: Date.now(), + }); + + log('WebSocket disconnection - Published tracked chains as down', { + count: chainIds.length, + chains: chainIds, + }); + + // Clear the tracking set since all chains are now down + this.#chainsUp.clear(); + } + } + } + + // ============================================================================= + // Private Methods - Subscription Management + // ============================================================================= + + /** + * Subscribe to the currently selected account only + */ + async #subscribeToSelectedAccount(): Promise { + const selectedAccounts = this.#messenger.call( + 'AccountTreeController:getAccountsFromSelectedAccountGroup', + ); + + await this.#subscribe({ + addresses: this.#convertToCaip10Addresses(selectedAccounts), + }); + } + + /** + * Unsubscribe from all account activity subscriptions for this service + * Finds all channels matching the service's namespace and unsubscribes from them + */ + async #unsubscribeFromAllAccountActivity(): Promise { + const accountActivitySubscriptions = this.#messenger.call( + 'BackendWebSocketService:findSubscriptionsByChannelPrefix', + this.#options.subscriptionNamespace, + ); + + // Unsubscribe from all matching subscriptions + for (const subscription of accountActivitySubscriptions) { + await subscription.unsubscribe(); + } + } + + // ============================================================================= + // Private Methods - Utility Functions + // ============================================================================= + + /** + * Convert a list of InternalAccount addresses to CAIP-10 format, using the first + * supported chain prefix matching the account's scopes + * + * @param accounts - The internal accounts to convert + * @returns The CAIP-10 formatted addresses (e.g. [`eip155:0:address`], meaning + * all chains of that namespace), or an empty array if none of the account's + * scopes are supported + */ + #convertToCaip10Addresses(accounts: InternalAccount[]): string[] { + const supportedChainPrefixes = this.#getSupportedChainPrefixes(); + return accounts.reduce((result, account) => { + const accountPrefix = supportedChainPrefixes.find((prefix) => + account.scopes.some((scope) => scope.startsWith(`${prefix}:`)), + ); + + if (!accountPrefix) { + // Skip unsupported accounts + return result; + } + + result.push(`${accountPrefix}:0:${account.address}`); + return result; + }, []); + } + + /** + * Get the chain prefixes currently enabled for subscriptions: EVM is always + * enabled, while other chains are gated behind their per-network remote + * feature flag (enabled when the flag payload has `stage >= 1`). + * + * @param remoteFeatureFlags - The remote feature flags state to check for enabled chains. + * @returns An array of enabled CAIP-2 namespace prefixes (e.g. `['eip155', 'solana']`) + */ + #getSupportedChainPrefixes( + remoteFeatureFlags: FeatureFlags = this.#messenger.call( + 'RemoteFeatureFlagController:getState', + ).remoteFeatureFlags, + ): string[] { + const prefixes: string[] = [...ALWAYS_SUPPORTED_CHAIN_PREFIXES]; + for (const [prefix, flagName] of Object.entries( + CHAIN_PREFIX_FEATURE_FLAGS, + )) { + const flagValue = remoteFeatureFlags[flagName]; + if ( + isObject(flagValue) && + typeof flagValue.stage === 'number' && + flagValue.stage >= 1 + ) { + prefixes.push(prefix); + } + } + return prefixes; + } + + /** + * Handle remote feature flag changes: if the set of enabled chain prefixes + * changed while connected, resubscribe the selected account so new chains + * are picked up and disabled ones are dropped. + */ + async #handleFeatureFlagsStateChange(): Promise { + try { + const { state } = this.#messenger.call( + 'BackendWebSocketService:getConnectionInfo', + ); + if (state !== WebSocketState.CONNECTED) { + // Not connected: the next connection will subscribe with fresh flags + return; + } + + await this.#unsubscribeFromAllAccountActivity(); + await this.#subscribeToSelectedAccount(); + } catch (error) { + log('Feature flag change handling failed', { error }); + } + } + + /** + * Force WebSocket reconnection to clean up subscription state + */ + async #forceReconnection(): Promise { + log('Forcing WebSocket reconnection to clean up subscription state'); + + // Use the dedicated forceReconnection method which performs a controlled + // disconnect-then-connect sequence to clean up subscription state + await this.#messenger.call('BackendWebSocketService:forceReconnection'); + } + + // ============================================================================= + // Public Methods - Cleanup + // ============================================================================= + + /** + * Destroy the service and clean up all resources + * Optimized for fast cleanup during service destruction or mobile app termination + */ + destroy(): void { + // Cancel any pending batched status-change flush + if (this.#statusChangeDebouncer.timer) { + clearTimeout(this.#statusChangeDebouncer.timer); + this.#statusChangeDebouncer.timer = null; + } + + // Clean up system notification callback + this.#messenger.call( + 'BackendWebSocketService:removeChannelCallback', + `system-notifications.v1.${this.#options.subscriptionNamespace}`, + ); + } +} diff --git a/packages/core-backend/src/ws/BackendWebSocketService-method-action-types.ts b/packages/core-backend/src/ws/BackendWebSocketService-method-action-types.ts new file mode 100644 index 00000000000..2390ec62dcc --- /dev/null +++ b/packages/core-backend/src/ws/BackendWebSocketService-method-action-types.ts @@ -0,0 +1,279 @@ +/** + * This file is auto generated. + * Do not edit manually. + */ + +import type { BackendWebSocketService } from './BackendWebSocketService.js'; + +/** + * Establishes WebSocket connection with smart reconnection behavior + * + * Connection Requirements (all must be true): + * 1. Feature enabled (isEnabled() = true) + * 2. Wallet unlocked (checked by getBearerToken) + * 3. User signed in (checked by getBearerToken) + * + * Platform code should call this when app opens/foregrounds. + * Automatically called on KeyringController:unlock event. + * + * @returns Promise that resolves when connection is established + */ +export type BackendWebSocketServiceConnectAction = { + type: `BackendWebSocketService:connect`; + handler: BackendWebSocketService['connect']; +}; + +/** + * Closes WebSocket connection + */ +export type BackendWebSocketServiceDisconnectAction = { + type: `BackendWebSocketService:disconnect`; + handler: BackendWebSocketService['disconnect']; +}; + +/** + * Forces a WebSocket reconnection to clean up subscription state + * + * This method is useful when subscription state may be out of sync and needs to be reset. + * It performs a controlled disconnect-then-reconnect sequence: + * - Disconnects cleanly to trigger subscription cleanup + * - Schedules reconnection with exponential backoff to prevent rapid loops + * - All subscriptions will be cleaned up automatically on disconnect + * + * Use cases: + * - Recovering from subscription/unsubscription issues + * - Cleaning up orphaned subscriptions + * - Forcing a fresh subscription state + * + * @returns Promise that resolves when disconnection is complete (reconnection is scheduled) + */ +export type BackendWebSocketServiceForceReconnectionAction = { + type: `BackendWebSocketService:forceReconnection`; + handler: BackendWebSocketService['forceReconnection']; +}; + +/** + * Sends a message through the WebSocket (fire-and-forget, no response expected) + * + * This is a low-level method for sending messages without waiting for a response. + * Most consumers should use `sendRequest()` instead, which handles request-response + * correlation and provides proper error handling with timeouts. + * + * Use this method only when: + * - You don't need a response from the server + * - You're implementing custom message protocols + * - You need fine-grained control over message timing + * + * @param message - The message to send + * @throws Error if WebSocket is not connected or send fails + * + * @see sendRequest for request-response pattern with automatic correlation + */ +export type BackendWebSocketServiceSendMessageAction = { + type: `BackendWebSocketService:sendMessage`; + handler: BackendWebSocketService['sendMessage']; +}; + +/** + * Sends a request and waits for a correlated response (recommended for most use cases) + * + * This is the recommended high-level method for request-response communication. + * It automatically handles: + * - Request ID generation and correlation + * - Response matching with timeout protection + * - Automatic reconnection on timeout + * - Proper cleanup of pending requests + * + * @param message - The request message (can include optional requestId for testing) + * @returns Promise that resolves with the response data + * @throws Error if WebSocket is not connected, request times out, or response indicates failure + * + * @see sendMessage for fire-and-forget messaging without response handling + */ +export type BackendWebSocketServiceSendRequestAction = { + type: `BackendWebSocketService:sendRequest`; + handler: BackendWebSocketService['sendRequest']; +}; + +/** + * Gets current connection information + * + * @returns Current connection status and details + */ +export type BackendWebSocketServiceGetConnectionInfoAction = { + type: `BackendWebSocketService:getConnectionInfo`; + handler: BackendWebSocketService['getConnectionInfo']; +}; + +/** + * Gets all subscription information for a specific channel + * + * @param channel - The channel name to look up + * @returns Array of subscription details for all subscriptions containing the channel + */ +export type BackendWebSocketServiceGetSubscriptionsByChannelAction = { + type: `BackendWebSocketService:getSubscriptionsByChannel`; + handler: BackendWebSocketService['getSubscriptionsByChannel']; +}; + +/** + * Checks if a channel has a subscription + * + * @param channel - The channel name to check + * @returns True if the channel has a subscription, false otherwise + */ +export type BackendWebSocketServiceChannelHasSubscriptionAction = { + type: `BackendWebSocketService:channelHasSubscription`; + handler: BackendWebSocketService['channelHasSubscription']; +}; + +/** + * Finds all subscriptions that have channels starting with the specified prefix + * + * @param channelPrefix - The channel prefix to search for (e.g., "account-activity.v1") + * @returns Array of subscription info for matching subscriptions + */ +export type BackendWebSocketServiceFindSubscriptionsByChannelPrefixAction = { + type: `BackendWebSocketService:findSubscriptionsByChannelPrefix`; + handler: BackendWebSocketService['findSubscriptionsByChannelPrefix']; +}; + +/** + * Register a callback for specific channels (local callback only, no server subscription) + * + * **Key Difference from `subscribe()`:** + * - `addChannelCallback()`: Registers a local callback without creating a server-side subscription. + * The callback triggers on ANY message matching the channel name, regardless of subscriptionId. + * Useful for system-wide notifications or when you don't control the subscription lifecycle. + * + * - `subscribe()`: Creates a proper server-side subscription with a subscriptionId. + * The callback only triggers for messages with the matching subscriptionId. + * Includes proper lifecycle management (unsubscribe, automatic cleanup on disconnect). + * + * **When to use `addChannelCallback()`:** + * - Listening to system-wide notifications (e.g., 'system-notifications.v1') + * - Monitoring channels where subscriptions are managed elsewhere + * - Debug/logging scenarios where you want to observe all channel messages + * + * **When to use `subscribe()` instead:** + * - Creating new subscriptions that need server-side registration + * - When you need proper cleanup via unsubscribe + * - Most application use cases (recommended approach) + * + * @param options - Channel callback configuration + * @param options.channelName - Channel name to match exactly + * @param options.callback - Function to call when channel matches + * + * @example + * ```typescript + * // Listen to system notifications (no server subscription needed) + * webSocketService.addChannelCallback({ + * channelName: 'system-notifications.v1', + * callback: (notification) => { + * console.log('System notification:', notification.data); + * } + * }); + * + * // For account-specific subscriptions, use subscribe() instead: + * // const sub = await webSocketService.subscribe({ + * // channels: ['account-activity.v1.eip155:0:0x1234...'], + * // callback: (notification) => { ... } + * // }); + * ``` + * + * @see subscribe for creating proper server-side subscriptions with lifecycle management + */ +export type BackendWebSocketServiceAddChannelCallbackAction = { + type: `BackendWebSocketService:addChannelCallback`; + handler: BackendWebSocketService['addChannelCallback']; +}; + +/** + * Remove a channel callback + * + * @param channelName - The channel name returned from addChannelCallback + * @returns True if callback was found and removed, false otherwise + */ +export type BackendWebSocketServiceRemoveChannelCallbackAction = { + type: `BackendWebSocketService:removeChannelCallback`; + handler: BackendWebSocketService['removeChannelCallback']; +}; + +/** + * Get all registered channel callbacks (for debugging) + * + * @returns Array of all registered channel callbacks + */ +export type BackendWebSocketServiceGetChannelCallbacksAction = { + type: `BackendWebSocketService:getChannelCallbacks`; + handler: BackendWebSocketService['getChannelCallbacks']; +}; + +/** + * Create and manage a subscription with server-side registration (recommended for most use cases) + * + * This is the recommended subscription API for high-level services. It creates a proper + * server-side subscription and routes notifications based on subscriptionId. + * + * **Key Features:** + * - Creates server-side subscription with unique subscriptionId + * - Callback triggered only for messages with matching subscriptionId + * - Automatic lifecycle management (cleanup on disconnect) + * - Includes unsubscribe method for proper cleanup + * - Request-response pattern with error handling + * + * **When to use `subscribe()`:** + * - Creating new subscriptions (account activity, price updates, etc.) + * - When you need proper cleanup/unsubscribe functionality + * - Most application use cases + * + * **When to use `addChannelCallback()` instead:** + * - System-wide notifications without server-side subscription + * - Observing channels managed elsewhere + * - Debug/logging scenarios + * + * @param options - Subscription configuration + * @param options.channels - Array of channel names to subscribe to + * @param options.callback - Callback function for handling notifications + * @param options.requestId - Optional request ID for testing (will generate UUID if not provided) + * @param options.channelType - Channel type identifier + * @returns Subscription object with unsubscribe method + * + * @example + * ```typescript + * // AccountActivityService usage + * const subscription = await webSocketService.subscribe({ + * channels: ['account-activity.v1.eip155:0:0x1234...'], + * callback: (notification) => { + * this.handleAccountActivity(notification.data); + * } + * }); + * + * // Later, clean up + * await subscription.unsubscribe(); + * ``` + * + * @see addChannelCallback for local callbacks without server-side subscription + */ +export type BackendWebSocketServiceSubscribeAction = { + type: `BackendWebSocketService:subscribe`; + handler: BackendWebSocketService['subscribe']; +}; + +/** + * Union of all BackendWebSocketService action types. + */ +export type BackendWebSocketServiceMethodActions = + | BackendWebSocketServiceConnectAction + | BackendWebSocketServiceDisconnectAction + | BackendWebSocketServiceForceReconnectionAction + | BackendWebSocketServiceSendMessageAction + | BackendWebSocketServiceSendRequestAction + | BackendWebSocketServiceGetConnectionInfoAction + | BackendWebSocketServiceGetSubscriptionsByChannelAction + | BackendWebSocketServiceChannelHasSubscriptionAction + | BackendWebSocketServiceFindSubscriptionsByChannelPrefixAction + | BackendWebSocketServiceAddChannelCallbackAction + | BackendWebSocketServiceRemoveChannelCallbackAction + | BackendWebSocketServiceGetChannelCallbacksAction + | BackendWebSocketServiceSubscribeAction; diff --git a/packages/core-backend/src/ws/BackendWebSocketService.test.ts b/packages/core-backend/src/ws/BackendWebSocketService.test.ts new file mode 100644 index 00000000000..ef9d73927f6 --- /dev/null +++ b/packages/core-backend/src/ws/BackendWebSocketService.test.ts @@ -0,0 +1,2616 @@ +import { Messenger, MOCK_ANY_NAMESPACE } from '@metamask/messenger'; +import type { + MessengerActions, + MessengerEvents, + MockAnyNamespace, +} from '@metamask/messenger'; + +import { flushPromises } from '../../../../tests/helpers.js'; +import { + BackendWebSocketService, + getCloseReason, + WebSocketState, + WebSocketSubscription, +} from './BackendWebSocketService.js'; +import type { + BackendWebSocketServiceOptions, + BackendWebSocketServiceMessenger, + ServerNotificationMessage, +} from './BackendWebSocketService.js'; + +// ===================================================== +// TYPES +// ===================================================== + +// Type for global object with WebSocket mock +type GlobalWithWebSocket = typeof global & { lastWebSocket: MockWebSocket }; + +type AllBackendWebSocketServiceActions = + MessengerActions; + +type AllBackendWebSocketServiceEvents = + MessengerEvents; + +type RootMessenger = Messenger< + MockAnyNamespace, + AllBackendWebSocketServiceActions, + AllBackendWebSocketServiceEvents +>; + +// ===================================================== +// MOCK WEBSOCKET CLASS +// ===================================================== + +/** + * Mock WebSocket implementation for testing + * Provides controlled WebSocket behavior with immediate connection control + */ +class MockWebSocket extends EventTarget { + // WebSocket state constants + /* eslint-disable @typescript-eslint/naming-convention */ + public static readonly CONNECTING = 0; + + public static readonly OPEN = 1; + + public static readonly CLOSING = 2; + + public static readonly CLOSED = 3; + /* eslint-enable @typescript-eslint/naming-convention */ + + // Track total instances created for testing + static #instanceCount = 0; + + public static getInstanceCount(): number { + return MockWebSocket.#instanceCount; + } + + public static resetInstanceCount(): void { + MockWebSocket.#instanceCount = 0; + } + + // WebSocket properties + public readyState: number = MockWebSocket.CONNECTING; + + public url: string; + + // Event handlers + // eslint-disable-next-line n/no-unsupported-features/node-builtins + public onclose: ((event: CloseEvent) => void) | null = null; + + public onmessage: ((event: MessageEvent) => void) | null = null; + + public onerror: ((event: Event) => void) | null = null; + + // Mock methods for testing + public close: jest.Mock = jest.fn(); + + public send: jest.Mock = jest.fn(); + + // Test utilities + #lastSentMessage: string | null = null; + + get lastSentMessage(): string | null { + return this.#lastSentMessage; + } + + #openTriggered = false; + + #onOpen: ((event: Event) => void) | null = null; + + public autoConnect: boolean = true; + + constructor( + url: string, + { autoConnect = true }: { autoConnect?: boolean } = {}, + ) { + super(); + MockWebSocket.#instanceCount += 1; + this.url = url; + // TypeScript has issues with jest.spyOn on WebSocket methods, so using direct assignment + // Store reference to simulateClose for use in close() + const simulateCloseFn = this.simulateClose.bind(this); + // eslint-disable-next-line jest/prefer-spy-on + this.close = jest.fn().mockImplementation((code = 1000, reason = '') => { + // When close() is called, trigger the close event to simulate real WebSocket behavior + simulateCloseFn(code, reason); + }); + // eslint-disable-next-line jest/prefer-spy-on + this.send = jest.fn().mockImplementation((data: string) => { + this.#lastSentMessage = data; + }); + this.autoConnect = autoConnect; + (global as GlobalWithWebSocket).lastWebSocket = this; + } + + set onopen(handler: ((event: Event) => void) | null) { + this.#onOpen = handler; + if ( + handler && + !this.#openTriggered && + this.readyState === MockWebSocket.CONNECTING && + this.autoConnect + ) { + // Trigger immediately to ensure connection completes + this.triggerOpen(); + } + } + + get onopen(): ((event: Event) => void) | null { + return this.#onOpen; + } + + public triggerOpen(): void { + if ( + !this.#openTriggered && + this.#onOpen && + this.readyState === MockWebSocket.CONNECTING + ) { + this.#openTriggered = true; + this.readyState = MockWebSocket.OPEN; + const event = new Event('open'); + this.#onOpen(event); + this.dispatchEvent(event); + } + } + + public simulateClose(code = 1000, reason = ''): void { + this.readyState = MockWebSocket.CLOSED; + // eslint-disable-next-line n/no-unsupported-features/node-builtins, no-restricted-globals + const event = new CloseEvent('close', { code, reason }); + this.onclose?.(event); + this.dispatchEvent(event); + } + + public simulateMessage(data: string | object): void { + const messageData = typeof data === 'string' ? data : JSON.stringify(data); + const event = new MessageEvent('message', { data: messageData }); + + if (this.onmessage) { + this.onmessage(event); + } + + this.dispatchEvent(event); + } + + public simulateRawMessage(data: unknown): void { + const event = new MessageEvent('message', { data: data as string }); + + if (this.onmessage) { + this.onmessage(event); + } + + this.dispatchEvent(event); + } + + public simulateError(): void { + const event = new Event('error'); + this.onerror?.(event); + this.dispatchEvent(event); + } + + public getLastSentMessage(): string | null { + return this.#lastSentMessage; + } +} + +// ===================================================== +// TEST UTILITIES & MOCKS +// ===================================================== + +/** + * Creates and returns a root messenger for testing + * + * @returns A messenger instance + */ +function getRootMessenger(): RootMessenger { + return new Messenger({ + namespace: MOCK_ANY_NAMESPACE, + }); +} + +/** + * Creates a real messenger with registered mock actions for testing + * Each call creates a completely independent messenger to ensure test isolation + * + * @returns Object containing the messenger and mock action functions + */ +const getMessenger = (): { + rootMessenger: RootMessenger; + messenger: BackendWebSocketServiceMessenger; + mocks: { getBearerToken: jest.Mock }; +} => { + // Create a unique root messenger for each test + const rootMessenger = getRootMessenger(); + const messenger = new Messenger< + 'BackendWebSocketService', + AllBackendWebSocketServiceActions, + AllBackendWebSocketServiceEvents, + RootMessenger + >({ + namespace: 'BackendWebSocketService', + parent: rootMessenger, + }); + rootMessenger.delegate({ + actions: ['AuthenticationController:getBearerToken'], + events: [ + // eslint-disable-next-line no-restricted-syntax -- AuthenticationController messenger types still expose stateChange + 'AuthenticationController:stateChange', + 'KeyringController:lock', + 'KeyringController:unlock', + ], + messenger, + }); + + // Create mock action handlers + const mockGetBearerToken = jest.fn().mockResolvedValue('valid-default-token'); + + // Register all action handlers + rootMessenger.registerActionHandler( + 'AuthenticationController:getBearerToken', + mockGetBearerToken, + ); + + return { + rootMessenger, + messenger, + mocks: { + getBearerToken: mockGetBearerToken, + }, + }; +}; + +// ===================================================== +// TEST CONSTANTS & DATA +// ===================================================== + +const TEST_CONSTANTS = { + WS_URL: 'ws://localhost:8080', + TEST_CHANNEL: 'test-channel', + SUBSCRIPTION_ID: 'sub-123', + TIMEOUT_MS: 100, + RECONNECT_DELAY: 50, +} as const; + +/** + * Helper to create a properly formatted WebSocket response message + * + * @param requestId - The request ID to match with the response + * @param data - The response data payload + * @returns Formatted WebSocket response message + */ +const createResponseMessage = ( + requestId: string, + data: Record, +): { id: string; data: Record } => ({ + id: requestId, + data: { + requestId, + ...data, + }, +}); + +// Setup function following TokenBalancesController pattern +// ===================================================== +// TEST SETUP HELPER +// ===================================================== + +/** + * Test configuration options + */ +type TestSetupOptions = { + options?: Partial; + mockWebSocketOptions?: { autoConnect?: boolean }; +}; + +/** + * Test setup return value with all necessary test utilities + */ +type TestSetup = { + service: BackendWebSocketService; + messenger: BackendWebSocketServiceMessenger; + rootMessenger: RootMessenger; + mocks: { + getBearerToken: jest.Mock; + }; + completeAsyncOperations: (advanceMs?: number) => Promise; + getMockWebSocket: () => MockWebSocket; + cleanup: () => void; +}; + +/** + * The callback that `withService` calls. + */ +type WithServiceCallback = (payload: { + service: BackendWebSocketService; + messenger: BackendWebSocketServiceMessenger; + rootMessenger: RootMessenger; + mocks: { + getBearerToken: jest.Mock; + }; + completeAsyncOperations: (advanceMs?: number) => Promise; + getMockWebSocket: () => MockWebSocket; +}) => Promise | ReturnValue; + +/** + * Create a fresh BackendWebSocketService instance with mocked dependencies for testing. + * Follows the TokenBalancesController test pattern for complete test isolation. + * + * @param config - Test configuration options + * @param config.options - WebSocket service configuration options + * @param config.mockWebSocketOptions - Mock WebSocket configuration options + * @returns Test utilities and cleanup function + */ +const setupBackendWebSocketService = ({ + options, + mockWebSocketOptions, +}: TestSetupOptions = {}): TestSetup => { + // Setup fake timers to control all async operations + jest.useFakeTimers(); + + // Create real messenger with registered actions + const messengerSetup = getMessenger(); + const { rootMessenger, messenger, mocks } = messengerSetup; + + // Default test options (shorter timeouts for faster tests) + const defaultOptions = { + url: TEST_CONSTANTS.WS_URL, + timeout: TEST_CONSTANTS.TIMEOUT_MS, + reconnectDelay: TEST_CONSTANTS.RECONNECT_DELAY, + maxReconnectDelay: TEST_CONSTANTS.TIMEOUT_MS, + requestTimeout: TEST_CONSTANTS.TIMEOUT_MS, + }; + + // Create custom MockWebSocket class for this test + class TestMockWebSocket extends MockWebSocket { + constructor(url: string) { + super(url, mockWebSocketOptions); + } + } + + // Replace global WebSocket for this test + // eslint-disable-next-line n/no-unsupported-features/node-builtins + global.WebSocket = TestMockWebSocket as unknown as typeof WebSocket; + + const service = new BackendWebSocketService({ + messenger, + ...defaultOptions, + ...options, + }); + + const completeAsyncOperations = async (advanceMs = 10): Promise => { + await flushPromises(); + if (advanceMs > 0) { + jest.advanceTimersByTime(advanceMs); + } + await flushPromises(); + }; + + const getMockWebSocket = (): MockWebSocket => { + return (global as GlobalWithWebSocket).lastWebSocket; + }; + + return { + service, + messenger, + rootMessenger, + mocks, + completeAsyncOperations, + getMockWebSocket, + cleanup: (): void => { + service?.destroy(); + jest.useRealTimers(); + jest.restoreAllMocks(); + }, + }; +}; + +/** + * Wrap tests for the BackendWebSocketService by ensuring that the service 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 service constructor. All constructor + * arguments are optional and will be filled in with defaults as needed + * (including `messenger`). The function is called with the new + * service, root messenger, and service messenger. + * @returns The same return value as the given function. + */ +async function withService( + ...args: + | [WithServiceCallback] + | [TestSetupOptions, WithServiceCallback] +): Promise { + const [{ options = {}, mockWebSocketOptions = {} }, testFunction] = + args.length === 2 ? args : [{}, args[0]]; + + MockWebSocket.resetInstanceCount(); + + const setup = setupBackendWebSocketService({ options, mockWebSocketOptions }); + + try { + return await testFunction({ + service: setup.service, + messenger: setup.messenger, + rootMessenger: setup.rootMessenger, + mocks: setup.mocks, + completeAsyncOperations: setup.completeAsyncOperations, + getMockWebSocket: setup.getMockWebSocket, + }); + } finally { + setup.cleanup(); + } +} + +/** + * Helper to create a subscription with predictable response + * + * @param service - The WebSocket service + * @param mockWs - Mock WebSocket instance + * @param options - Subscription options + * @param options.channels - Channels to subscribe to + * @param options.callback - Callback function + * @param options.requestId - Request ID + * @param options.subscriptionId - Subscription ID + * @param options.channelType - Channel type identifier + * @returns Promise with subscription + */ +const createSubscription = async ( + service: BackendWebSocketService, + mockWs: MockWebSocket, + options: { + channels: string[]; + callback: jest.Mock; + requestId: string; + subscriptionId?: string; + channelType?: string; + }, +): Promise => { + const { + channels, + callback, + requestId, + subscriptionId = 'test-sub', + channelType = 'test-channel.v1', + } = options; + + const subscriptionPromise = service.subscribe({ + channels, + channelType, + callback, + requestId, + }); + + const responseMessage = createResponseMessage(requestId, { + subscriptionId, + successful: channels, + failed: [], + }); + mockWs.simulateMessage(responseMessage); + + return subscriptionPromise; +}; + +// ===================================================== +// WEBSOCKETSERVICE TESTS +// ===================================================== + +describe('BackendWebSocketService', () => { + // ===================================================== + // CONSTRUCTOR TESTS + // ===================================================== + describe('constructor', () => { + it('should create a BackendWebSocketService instance with custom options', async () => { + await withService( + { + options: { + url: 'wss://custom.example.com', + timeout: 5000, + }, + mockWebSocketOptions: { autoConnect: false }, + }, + async ({ service }) => { + expect(service).toBeInstanceOf(BackendWebSocketService); + expect(service.getConnectionInfo().url).toBe( + 'wss://custom.example.com', + ); + }, + ); + }); + + it('should apply default values for options not provided', async () => { + await withService( + { + options: { + url: 'ws://test.example.com', + timeout: undefined, + reconnectDelay: undefined, + maxReconnectDelay: undefined, + requestTimeout: undefined, + }, + }, + async ({ service }) => { + expect(service.getConnectionInfo().url).toBe('ws://test.example.com'); + expect(service.getConnectionInfo().timeout).toBe(10000); + expect(service.getConnectionInfo().reconnectDelay).toBe(10000); + expect(service.getConnectionInfo().maxReconnectDelay).toBe(60000); + expect(service.getConnectionInfo().requestTimeout).toBe(30000); + expect(service).toBeInstanceOf(BackendWebSocketService); + }, + ); + }); + }); + + // ===================================================== + // CONNECTION LIFECYCLE TESTS + // ===================================================== + describe('connection lifecycle - connect / disconnect', () => { + it('should establish WebSocket connection and set state to CONNECTED, publishing state change event', async () => { + await withService(async ({ service, messenger }) => { + const connectionStateChangedListener = jest.fn(); + messenger.subscribe( + 'BackendWebSocketService:connectionStateChanged', + connectionStateChangedListener, + ); + + await service.connect(); + + const connectionInfo = service.getConnectionInfo(); + expect(connectionInfo.state).toBe(WebSocketState.CONNECTED); + expect(connectionInfo.reconnectAttempts).toBe(0); + expect(connectionInfo.url).toBe('ws://localhost:8080'); + + expect(connectionStateChangedListener).toHaveBeenCalledWith( + expect.objectContaining({ + state: WebSocketState.CONNECTED, + reconnectAttempts: 0, + }), + ); + }); + }); + + it('should prevent race condition when multiple concurrent connect() calls are made', async () => { + await withService(async ({ service }) => { + // Simulate multiple concurrent connect() calls (as would happen from + // KeyringController:unlock, AuthenticationController:stateChange, and + // MetaMaskController.isClientOpen all firing at once) + const connectPromises = [ + service.connect(), + service.connect(), + service.connect(), + ]; + + // Wait for all promises to resolve + await Promise.all(connectPromises); + + // Verify only ONE WebSocket connection was created + expect(MockWebSocket.getInstanceCount()).toBe(1); + + // Verify service is in CONNECTED state + const connectionInfo = service.getConnectionInfo(); + expect(connectionInfo.state).toBe(WebSocketState.CONNECTED); + }); + }); + + it('should handle rapid sequential connect() calls after promise clears without creating duplicates', async () => { + await withService(async ({ service }) => { + // First connection + await service.connect(); + expect(MockWebSocket.getInstanceCount()).toBe(1); + expect(service.getConnectionInfo().state).toBe( + WebSocketState.CONNECTED, + ); + + // Multiple calls after connection is established should not create new connections + await Promise.all([ + service.connect(), + service.connect(), + service.connect(), + ]); + + // Should still be only 1 connection + expect(MockWebSocket.getInstanceCount()).toBe(1); + expect(service.getConnectionInfo().state).toBe( + WebSocketState.CONNECTED, + ); + }); + }); + + it('should handle interleaved connect() calls during async getBearerToken without duplicates', async () => { + await withService( + { mockWebSocketOptions: { autoConnect: false } }, + async ({ service, getMockWebSocket, mocks }) => { + // Make getBearerToken async to simulate the race window + let getBearerTokenResolve: ((value: string) => void) | null = null; + mocks.getBearerToken.mockImplementation(() => { + return new Promise((resolve) => { + getBearerTokenResolve = resolve; + }); + }); + + // Start first connect (will wait on getBearerToken) + const connect1 = service.connect(); + + // Immediately start second connect (should wait for first) + const connect2 = service.connect(); + + // Immediately start third connect (should also wait) + const connect3 = service.connect(); + + // Now resolve the getBearerToken + expect(getBearerTokenResolve).not.toBeNull(); + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + getBearerTokenResolve!('test-token'); + + // Wait a tick for the token resolution to propagate + await flushPromises(); + + // Manually trigger WebSocket open + getMockWebSocket().triggerOpen(); + + // Wait for all connections to complete + await Promise.all([connect1, connect2, connect3]); + + // Should only have created ONE WebSocket + expect(MockWebSocket.getInstanceCount()).toBe(1); + expect(service.getConnectionInfo().state).toBe( + WebSocketState.CONNECTED, + ); + }, + ); + }); + + it('should reject sendMessage and sendRequest operations when WebSocket is disconnected', async () => { + await withService( + { mockWebSocketOptions: { autoConnect: false } }, + async ({ service }) => { + expect(service.getConnectionInfo().state).toBe( + WebSocketState.DISCONNECTED, + ); + + expect(() => + service.sendMessage({ event: 'test', data: { requestId: 'test' } }), + ).toThrow('Cannot send message: WebSocket is disconnected'); + await expect( + service.sendRequest({ event: 'test', data: {} }), + ).rejects.toThrow('Cannot send request: WebSocket is disconnected'); + await expect( + service.subscribe({ + channels: ['test'], + channelType: 'test.v1', + callback: jest.fn(), + }), + ).rejects.toThrow( + 'Cannot create subscription(s) test: WebSocket is disconnected', + ); + }, + ); + }); + + it('should handle request timeout by clearing pending requests and forcing WebSocket reconnection', async () => { + await withService( + { options: { requestTimeout: 200 } }, + async ({ service, getMockWebSocket }) => { + await service.connect(); + const mockWs = getMockWebSocket(); + const closeSpy = jest.spyOn(mockWs, 'close'); + + const requestPromise = service.sendRequest({ + event: 'timeout-test', + data: { requestId: 'timeout-req-1', method: 'test', params: {} }, + }); + + jest.advanceTimersByTime(201); + + await expect(requestPromise).rejects.toThrow( + 'Request timeout after 200ms', + ); + expect(closeSpy).toHaveBeenCalledWith( + 3000, + 'Request timeout - forcing reconnect', + ); + }, + ); + }); + + it('should handle abnormal WebSocket close by triggering reconnection', async () => { + await withService( + async ({ service, getMockWebSocket, completeAsyncOperations }) => { + // Mock Math.random to make Cockatiel's jitter deterministic + jest.spyOn(Math, 'random').mockReturnValue(0); + + await service.connect(); + expect(service.getConnectionInfo().state).toBe( + WebSocketState.CONNECTED, + ); + expect(service.getConnectionInfo().reconnectAttempts).toBe(0); + + const mockWs = getMockWebSocket(); + + // Simulate abnormal closure (should trigger reconnection) + mockWs.simulateClose(1006, 'Abnormal closure'); + await completeAsyncOperations(0); + + // Service should transition to DISCONNECTED + expect(service.getConnectionInfo().state).toBe( + WebSocketState.DISCONNECTED, + ); + + // Advance time to trigger reconnection attempt + await completeAsyncOperations(100); + + // Service should have successfully reconnected + expect(service.getConnectionInfo().state).toBe( + WebSocketState.CONNECTED, + ); + // reconnectAttempts will be 1 until stable connection timer (10s) resets it + expect(service.getConnectionInfo().reconnectAttempts).toBe(1); + }, + ); + }); + + it('should disconnect WebSocket connection and set state to DISCONNECTED when connected', async () => { + await withService(async ({ service, getMockWebSocket }) => { + await service.connect(); + expect(service.getConnectionInfo().state).toBe( + WebSocketState.CONNECTED, + ); + + const mockWs = getMockWebSocket(); + + // Mock the close method to simulate the close event + mockWs.close.mockImplementation( + (code = 1000, reason = 'Normal closure') => { + mockWs.simulateClose(code, reason); + }, + ); + + service.disconnect(); + + const connectionInfo = service.getConnectionInfo(); + expect(connectionInfo.state).toBe(WebSocketState.DISCONNECTED); + expect(connectionInfo.url).toBe('ws://localhost:8080'); // URL persists after disconnect + expect(connectionInfo.reconnectAttempts).toBe(0); + }); + }); + + it('should remain in CONNECTED state when trying to connect again', async () => { + await withService(async ({ service }) => { + await service.connect(); + expect(service.getConnectionInfo().state).toBe( + WebSocketState.CONNECTED, + ); + + // Connect again + await service.connect(); + expect(service.getConnectionInfo().state).toBe( + WebSocketState.CONNECTED, + ); + }); + }); + + it('should wait for existing connection promise when already connecting', async () => { + await withService( + { mockWebSocketOptions: { autoConnect: false } }, + async ({ service, getMockWebSocket, completeAsyncOperations }) => { + const firstConnect = service.connect(); + await completeAsyncOperations(10); + + // Start second connection while first is connecting + const secondConnect = service.connect(); + + // Complete the connection + const mockWs = getMockWebSocket(); + mockWs.triggerOpen(); + + await Promise.all([firstConnect, secondConnect]); + expect(service.getConnectionInfo().state).toBe( + WebSocketState.CONNECTED, + ); + }, + ); + }); + + it('should remain in DISCONNECTED state when trying to disconnect again', async () => { + await withService(async ({ service }) => { + expect(service.getConnectionInfo().state).toBe( + WebSocketState.DISCONNECTED, + ); + + // Disconnect when already disconnected + service.disconnect(); + expect(service.getConnectionInfo().state).toBe( + WebSocketState.DISCONNECTED, + ); + }); + }); + + it('should handle unexpected disconnect with empty reason by using default close reason', async () => { + await withService( + async ({ service, getMockWebSocket, completeAsyncOperations }) => { + await service.connect(); + + const mockWs = getMockWebSocket(); + // Simulate unexpected disconnect with empty reason string + // This triggers the getCloseReason fallback in the trace call + mockWs.simulateClose(1006, ''); + + await completeAsyncOperations(0); + + // Verify state changed to disconnected (trace was called) + expect(service.getConnectionInfo().state).toBe( + WebSocketState.DISCONNECTED, + ); + }, + ); + }); + + it('should handle unexpected disconnect with custom reason', async () => { + await withService( + async ({ service, getMockWebSocket, completeAsyncOperations }) => { + await service.connect(); + + const mockWs = getMockWebSocket(); + // Simulate unexpected disconnect with custom reason string + // This uses event.reason directly in the trace call + mockWs.simulateClose(1006, 'Custom unexpected disconnect reason'); + + await completeAsyncOperations(0); + + // Verify state changed to disconnected (trace was called) + expect(service.getConnectionInfo().state).toBe( + WebSocketState.DISCONNECTED, + ); + }, + ); + }); + + it('should skip connect when reconnect timer is already scheduled', async () => { + await withService( + async ({ service, getMockWebSocket, completeAsyncOperations }) => { + // Mock Math.random to make Cockatiel's jitter deterministic + jest.spyOn(Math, 'random').mockReturnValue(0); + + // Connect successfully first + await service.connect(); + + const mockWs = getMockWebSocket(); + + // Simulate unexpected close to trigger scheduleReconnect + mockWs.simulateClose(1006, 'Abnormal closure'); + await completeAsyncOperations(0); + + // Verify reconnect timer is scheduled + const attemptsBefore = service.getConnectionInfo().reconnectAttempts; + expect(attemptsBefore).toBeGreaterThan(0); + + // Now try to connect again while reconnect timer is scheduled + // This should return early without doing anything + await service.connect(); + + // Attempts should be unchanged since connect returned early + expect(service.getConnectionInfo().reconnectAttempts).toBe( + attemptsBefore, + ); + }, + ); + }); + + // Temporarily disabled due to intermittent failures + it('should handle connection timeout', async () => { + await withService( + { + options: { timeout: 100 }, + mockWebSocketOptions: { autoConnect: false }, + }, + async ({ service, completeAsyncOperations }) => { + // Mock Math.random to make Cockatiel's jitter deterministic + jest.spyOn(Math, 'random').mockReturnValue(0); + + // eslint-disable-next-line @typescript-eslint/no-floating-promises + service.connect(); + + // Advance time past the timeout + await completeAsyncOperations(101); + + // Should have transitioned to DISCONNECTED state after timeout + expect(service.getConnectionInfo().state).toBe( + WebSocketState.DISCONNECTED, + ); + }, + ); + }); + + it('should reset reconnect attempts after stable connection', async () => { + await withService( + async ({ service, getMockWebSocket, completeAsyncOperations }) => { + // Mock Math.random to make Cockatiel's jitter deterministic + jest.spyOn(Math, 'random').mockReturnValue(0); + + // Connect successfully + await service.connect(); + + // Close connection to trigger reconnect + const mockWs = getMockWebSocket(); + mockWs.simulateClose(1006, 'Test close'); + await completeAsyncOperations(10); + + // Reconnect (this increments attempts to 1) + // With Math.random() = 0, Cockatiel's jitter will give consistent delays + await completeAsyncOperations(700); + + expect(service.getConnectionInfo().reconnectAttempts).toBe(1); + + // Wait for stable connection timer (10 seconds + buffer) + await completeAsyncOperations(10050); + + // Attempts should now be reset to 0 + expect(service.getConnectionInfo().reconnectAttempts).toBe(0); + }, + ); + }); + + it('should handle WebSocket onclose during connection phase', async () => { + await withService( + { mockWebSocketOptions: { autoConnect: false } }, + async ({ service, getMockWebSocket, completeAsyncOperations }) => { + // Mock Math.random to make Cockatiel's jitter deterministic + jest.spyOn(Math, 'random').mockReturnValue(0); + + // eslint-disable-next-line @typescript-eslint/no-floating-promises + service.connect(); + await completeAsyncOperations(10); + + // Verify we're in CONNECTING state + const mockWs = getMockWebSocket(); + expect(service.getConnectionInfo().state).toBe( + WebSocketState.CONNECTING, + ); + + // Close during connection phase + mockWs.simulateClose(1006, 'Connection failed'); + await completeAsyncOperations(0); + + // Should schedule reconnect and be in DISCONNECTED state + expect(service.getConnectionInfo().state).toBe( + WebSocketState.DISCONNECTED, + ); + }, + ); + }); + + // Temporarily disabled due to intermittent failures + it('should resolve connection promise when manual disconnect occurs during CONNECTING phase', async () => { + await withService( + { mockWebSocketOptions: { autoConnect: false } }, + async ({ service, getMockWebSocket, completeAsyncOperations }) => { + // Start connection (don't await it) + const connectPromise = service.connect(); + await completeAsyncOperations(0); + + // Get the WebSocket instance and verify CONNECTING state + const mockWs = getMockWebSocket(); + expect(service.getConnectionInfo().state).toBe( + WebSocketState.CONNECTING, + ); + + // Simulate a manual disconnect by closing with the manual disconnect code + mockWs.simulateClose(4999, 'Internal: Manual disconnect'); + await completeAsyncOperations(0); + + // The connection promise should resolve (not reject) because it was a manual disconnect + expect(await connectPromise).toBeUndefined(); + + // Should be in DISCONNECTED state + expect(service.getConnectionInfo().state).toBe( + WebSocketState.DISCONNECTED, + ); + + // Verify no reconnection was scheduled (attempts should remain 0) + expect(service.getConnectionInfo().reconnectAttempts).toBe(0); + + // Advance time to ensure no delayed reconnection attempt + await completeAsyncOperations(5000); + expect(service.getConnectionInfo().reconnectAttempts).toBe(0); + }, + ); + }); + + // Temporarily disabled due to intermittent failures + // eslint-disable-next-line jest/no-disabled-tests + it.skip('should clear connection timeout when timeout occurs then close fires', async () => { + await withService( + { + options: { timeout: 100 }, + mockWebSocketOptions: { autoConnect: false }, + }, + async ({ service, getMockWebSocket, completeAsyncOperations }) => { + // Mock Math.random to make Cockatiel's jitter deterministic + jest.spyOn(Math, 'random').mockReturnValue(0); + + // Start connection (this sets connectionTimeout) + // eslint-disable-next-line @typescript-eslint/no-floating-promises + service.connect(); + await completeAsyncOperations(10); + + const mockWs = getMockWebSocket(); + expect(service.getConnectionInfo().state).toBe( + WebSocketState.CONNECTING, + ); + + // Let timeout fire (closes WebSocket and sets state to DISCONNECTED) + // Advance time past timeout but before reconnect would fire + await completeAsyncOperations(100); + + // State should be DISCONNECTED after timeout + expect(service.getConnectionInfo().state).toBe( + WebSocketState.DISCONNECTED, + ); + + // Now manually trigger close event + // Since state is DISCONNECTED, onclose will return early due to idempotency guard + mockWs.simulateClose(1006, 'Close after timeout'); + await completeAsyncOperations(0); + + // State should still be DISCONNECTED + expect(service.getConnectionInfo().state).toBe( + WebSocketState.DISCONNECTED, + ); + }, + ); + }); + + it('should not schedule multiple reconnects when scheduleReconnect called multiple times', async () => { + await withService( + async ({ service, getMockWebSocket, completeAsyncOperations }) => { + // Mock Math.random to make Cockatiel's jitter deterministic + jest.spyOn(Math, 'random').mockReturnValue(0); + + await service.connect(); + expect(service.getConnectionInfo().state).toBe( + WebSocketState.CONNECTED, + ); + + const mockWs = getMockWebSocket(); + + // First close to trigger scheduleReconnect + mockWs.simulateClose(1006, 'Connection lost'); + await completeAsyncOperations(0); + + const attemptsBefore = service.getConnectionInfo().reconnectAttempts; + expect(attemptsBefore).toBeGreaterThan(0); + + // Second close should trigger scheduleReconnect again, + // but it should return early since timer already exists + mockWs.simulateClose(1006, 'Connection lost again'); + await completeAsyncOperations(0); + + // Attempts should not have increased again due to idempotency + expect(service.getConnectionInfo().reconnectAttempts).toBe( + attemptsBefore, + ); + }, + ); + }); + }); + + // ===================================================== + // FORCE RECONNECTION TESTS + // ===================================================== + describe('forceReconnection', () => { + it('should force reconnection and schedule connect', async () => { + await withService( + async ({ service, getMockWebSocket, completeAsyncOperations }) => { + // Mock Math.random to make Cockatiel's jitter deterministic + jest.spyOn(Math, 'random').mockReturnValue(0); + + await service.connect(); + expect(service.getConnectionInfo().state).toBe( + WebSocketState.CONNECTED, + ); + + const mockWs = getMockWebSocket(); + mockWs.close.mockImplementation( + (code = 1000, reason = 'Normal closure') => { + mockWs.simulateClose(code, reason); + }, + ); + + // Force reconnection + await service.forceReconnection(); + await completeAsyncOperations(0); + + // Should be disconnected after forceReconnection + expect(service.getConnectionInfo().state).toBe( + WebSocketState.DISCONNECTED, + ); + + // Should have scheduled a reconnect (attempts incremented) + expect(service.getConnectionInfo().reconnectAttempts).toBe(1); + }, + ); + }); + + it('should skip forceReconnection when reconnect timer is already scheduled', async () => { + await withService( + { mockWebSocketOptions: { autoConnect: false } }, + async ({ service, getMockWebSocket, completeAsyncOperations }) => { + // Mock Math.random to make Cockatiel's jitter deterministic + jest.spyOn(Math, 'random').mockReturnValue(0); + + // Trigger a connection failure to schedule a reconnect + // eslint-disable-next-line @typescript-eslint/no-floating-promises + service.connect(); + await completeAsyncOperations(10); + + const mockWs = getMockWebSocket(); + // Simulate connection failure (error is always followed by close in real WebSocket) + mockWs.simulateClose(1006, 'Connection failed'); + await completeAsyncOperations(0); + + const attemptsBefore = service.getConnectionInfo().reconnectAttempts; + // Should be 1 after the failure + expect(attemptsBefore).toBe(1); + + // Try to force reconnection while timer is already scheduled + await service.forceReconnection(); + + // Should have returned early, attempts unchanged + expect(service.getConnectionInfo().reconnectAttempts).toBe( + attemptsBefore, + ); + }, + ); + }); + + // Temporarily disabled due to intermittent failures + // eslint-disable-next-line jest/no-disabled-tests + it.skip('should clear reconnect timer when feature is disabled', async () => { + let isEnabled = true; + await withService( + { + options: { + isEnabled: () => isEnabled, + }, + mockWebSocketOptions: { autoConnect: false }, + }, + async ({ service, getMockWebSocket, completeAsyncOperations }) => { + // Mock Math.random to make Cockatiel's jitter deterministic + jest.spyOn(Math, 'random').mockReturnValue(0); + + // Trigger a connection failure to schedule a reconnect + // eslint-disable-next-line @typescript-eslint/no-floating-promises + service.connect(); + await completeAsyncOperations(10); + + const mockWs = getMockWebSocket(); + // Simulate connection failure to trigger reconnect timer + mockWs.simulateClose(1006, 'Connection failed'); + await completeAsyncOperations(0); + + // Verify reconnect timer is scheduled + expect(service.getConnectionInfo().reconnectAttempts).toBe(1); + + // Now disable the feature + isEnabled = false; + + // Try to connect again with feature disabled + await service.connect(); + + // Reconnect attempts should be reset to 0 (timers cleared) + expect(service.getConnectionInfo().reconnectAttempts).toBe(0); + + // Advance time to ensure the old reconnect timer doesn't fire + await completeAsyncOperations(10000); + + // Should still be 0, confirming timer was cleared + expect(service.getConnectionInfo().reconnectAttempts).toBe(0); + }, + ); + }); + + // Temporarily disabled due to intermittent failures + // eslint-disable-next-line jest/no-disabled-tests + it.skip('should include connectionDuration_ms in trace when connection was established', async () => { + const mockTraceFn = jest.fn((_request, fn) => fn?.()); + await withService( + { + options: { traceFn: mockTraceFn }, + }, + async ({ service, getMockWebSocket, completeAsyncOperations }) => { + // Connect and let it establish + await service.connect(); + await completeAsyncOperations(10); + + expect(service.getConnectionInfo().state).toBe( + WebSocketState.CONNECTED, + ); + + // Clear previous trace calls to focus on disconnection trace + mockTraceFn.mockClear(); + + // Trigger unexpected close after connection was established + const mockWs = getMockWebSocket(); + mockWs.simulateClose(1006, 'Abnormal closure'); + await completeAsyncOperations(10); + + // Find the Disconnection trace call + const disconnectionTrace = mockTraceFn.mock.calls.find( + (call) => call[0]?.name === 'BackendWebSocketService Disconnection', + ); + + expect(disconnectionTrace).toBeDefined(); + expect(disconnectionTrace?.[0]?.data).toHaveProperty( + 'connectionDuration_ms', + ); + expect( + disconnectionTrace?.[0]?.data?.connectionDuration_ms, + ).toBeGreaterThan(0); + }, + ); + }); + + // Temporarily disabled due to intermittent failures + // eslint-disable-next-line jest/no-disabled-tests + it.skip('should omit connectionDuration_ms in trace when connection never established', async () => { + const mockTraceFn = jest.fn((_request, fn) => fn?.()); + await withService( + { + options: { traceFn: mockTraceFn }, + mockWebSocketOptions: { autoConnect: false }, + }, + async ({ service, getMockWebSocket, completeAsyncOperations }) => { + // Start connecting + // eslint-disable-next-line @typescript-eslint/no-floating-promises + service.connect(); + await completeAsyncOperations(0); + + expect(service.getConnectionInfo().state).toBe( + WebSocketState.CONNECTING, + ); + + // Manually disconnect before connection establishes + // This will set state to DISCONNECTING and close the WebSocket + service.disconnect(); + await completeAsyncOperations(0); + + // Clear trace calls from connection attempt + mockTraceFn.mockClear(); + + // Simulate the close event after disconnect (state is now DISCONNECTING, not CONNECTING) + // This will trigger #handleClose with connectedAt = 0 + const mockWs = getMockWebSocket(); + mockWs.simulateClose(1000, 'Normal closure'); + await completeAsyncOperations(10); + + // Find the Disconnection trace call + const disconnectionTrace = mockTraceFn.mock.calls.find( + (call) => call[0]?.name === 'BackendWebSocketService Disconnection', + ); + + // Trace should exist but should NOT have connectionDuration_ms + expect(disconnectionTrace).toBeDefined(); + expect(disconnectionTrace?.[0]?.data).not.toHaveProperty( + 'connectionDuration_ms', + ); + }, + ); + }); + }); + + // ===================================================== + // SUBSCRIPTION TESTS + // ===================================================== + describe('subscribe', () => { + it('should subscribe to WebSocket channels and return subscription with unsubscribe function', async () => { + await withService(async ({ service, getMockWebSocket }) => { + await service.connect(); + const mockCallback = jest.fn(); + const mockWs = getMockWebSocket(); + + const subscription = await createSubscription(service, mockWs, { + channels: [TEST_CONSTANTS.TEST_CHANNEL], + callback: mockCallback, + requestId: 'test-subscribe-success', + subscriptionId: TEST_CONSTANTS.SUBSCRIPTION_ID, + }); + + expect(subscription.subscriptionId).toBe( + TEST_CONSTANTS.SUBSCRIPTION_ID, + ); + expect(typeof subscription.unsubscribe).toBe('function'); + }); + }); + + it('should handle various error scenarios including connection failures and invalid responses', async () => { + await withService(async ({ service, getMockWebSocket }) => { + await service.connect(); + const mockWs = getMockWebSocket(); + + // Test subscription failure scenario + const callback = jest.fn(); + + // Create subscription request - Use predictable request ID + const testRequestId = 'test-error-branch-scenarios'; + const subscriptionPromise = service.subscribe({ + channels: ['test-channel-error'], + channelType: 'test-channel-error.v1', + callback, + requestId: testRequestId, + }); + + // Simulate response with failure - no waiting needed! + mockWs.simulateMessage({ + id: testRequestId, + data: { + requestId: testRequestId, + subscriptionId: 'error-sub', + successful: [], + failed: ['test-channel-error'], + }, + }); + + // Should reject due to failed channels + await expect(subscriptionPromise).rejects.toThrow( + 'Request failed: test-channel-error', + ); + }); + }); + + it('should handle unsubscribe errors and connection errors gracefully without throwing', async () => { + await withService(async ({ service, getMockWebSocket }) => { + await service.connect(); + const mockWs = getMockWebSocket(); + + const mockCallback = jest.fn(); + const subscription = await createSubscription(service, mockWs, { + channels: ['test-channel'], + callback: mockCallback, + requestId: 'test-subscription-unsub-error', + subscriptionId: 'unsub-error-test', + }); + + // Mock sendRequest to throw error during unsubscribe + jest.spyOn(service, 'sendRequest').mockImplementation(() => { + return Promise.reject(new Error('Unsubscribe failed')); + }); + + await expect(subscription.unsubscribe()).rejects.toThrow( + 'Unsubscribe failed', + ); + }); + }); + + it('should throw error when subscription response is missing required subscription ID field', async () => { + await withService(async ({ service, getMockWebSocket }) => { + await service.connect(); + const mockWs = getMockWebSocket(); + + const subscriptionPromise = service.subscribe({ + channels: ['invalid-test'], + channelType: 'invalid-test.v1', + callback: jest.fn(), + requestId: 'test-missing-subscription-id', + }); + + // Send response without subscriptionId + mockWs.simulateMessage({ + id: 'test-missing-subscription-id', + data: { + requestId: 'test-missing-subscription-id', + successful: ['invalid-test'], + failed: [], + }, + }); + + await expect(subscriptionPromise).rejects.toThrow( + 'Invalid subscription response: missing subscription ID', + ); + }); + }); + + it('should return false when checking for non-existent channel subscription', async () => { + await withService(async ({ service }) => { + expect(service.channelHasSubscription('non-existent')).toBe(false); + }); + }); + + it('should return true when checking for existing channel subscription and return false when checking for non-existent channel subscription', async () => { + await withService(async ({ service, getMockWebSocket }) => { + await service.connect(); + const mockWs = getMockWebSocket(); + const mockCallback = jest.fn(); + + const subscribePromise = service.subscribe({ + channels: ['test-channel'], + channelType: 'test-channel.v1', + callback: mockCallback, + requestId: 'test-sub-id', + }); + + mockWs.simulateMessage({ + id: 'test-sub-id', + data: { + requestId: 'test-sub-id', + subscriptionId: 'sub-123', + successful: ['test-channel'], + failed: [], + }, + }); + + const subscription = await subscribePromise; + + expect(service.channelHasSubscription('test-channel')).toBe(true); + + // Test unsubscribe cleanup + const unsubscribePromise = subscription.unsubscribe('unsub-req-id'); + + mockWs.simulateMessage({ + id: 'unsub-req-id', + data: { + requestId: 'unsub-req-id', + successful: ['test-channel'], + failed: [], + }, + }); + + await unsubscribePromise; + + // Subscription should be cleaned up + expect(service.channelHasSubscription('test-channel')).toBe(false); + }); + }); + + it('should retrieve subscription by channel name from internal subscription storage', async () => { + await withService(async ({ service, getMockWebSocket }) => { + await service.connect(); + const mockCallback = jest.fn(); + const mockWs = getMockWebSocket(); + + await createSubscription(service, mockWs, { + channels: ['test-channel'], + callback: mockCallback, + requestId: 'test-notification-handling', + subscriptionId: 'sub-123', + }); + + const subscriptions = service.getSubscriptionsByChannel('test-channel'); + expect(subscriptions).toHaveLength(1); + expect(subscriptions[0].subscriptionId).toBe('sub-123'); + expect(service.getSubscriptionsByChannel('nonexistent')).toHaveLength( + 0, + ); + }); + }); + + it('should find all subscriptions matching a channel prefix pattern', async () => { + await withService(async ({ service, getMockWebSocket }) => { + await service.connect(); + const mockWs = getMockWebSocket(); + const callback = jest.fn(); + + await createSubscription(service, mockWs, { + channels: ['account-activity.v1.address1', 'other-prefix.v1.test'], + callback, + requestId: 'test-prefix-sub', + subscriptionId: 'sub-1', + }); + + const matches = + service.findSubscriptionsByChannelPrefix('account-activity'); + expect(matches).toHaveLength(1); + expect(matches[0].subscriptionId).toBe('sub-1'); + expect( + service.findSubscriptionsByChannelPrefix('non-existent'), + ).toStrictEqual([]); + }); + }); + }); + + // ===================================================== + // MESSAGE HANDLING TESTS + // ===================================================== + describe('message handling', () => { + it('should silently ignore invalid JSON messages and trigger parseMessage error handling', async () => { + await withService(async ({ service, getMockWebSocket }) => { + await service.connect(); + const mockWs = getMockWebSocket(); + + const channelCallback = jest.fn(); + service.addChannelCallback({ + channelName: 'test-channel', + callback: channelCallback, + }); + + const subscriptionCallback = jest.fn(); + await createSubscription(service, mockWs, { + channels: ['test-channel'], + callback: subscriptionCallback, + requestId: 'test-parse-message-invalid-json', + subscriptionId: 'test-sub-123', + }); + + channelCallback.mockClear(); + subscriptionCallback.mockClear(); + + const invalidJsonMessages = [ + 'invalid json string', + '{ incomplete json', + '{ "malformed": json }', + 'not json at all', + '{ "unclosed": "quote }', + '{ "trailing": "comma", }', + 'random text with { brackets', + ]; + + for (const invalidJson of invalidJsonMessages) { + const invalidEvent = new MessageEvent('message', { + data: invalidJson, + }); + mockWs.onmessage?.(invalidEvent); + } + + expect(channelCallback).not.toHaveBeenCalled(); + expect(subscriptionCallback).not.toHaveBeenCalled(); + expect(service.getConnectionInfo().state).toBe( + WebSocketState.CONNECTED, + ); + + const validNotification = { + event: 'notification', + subscriptionId: 'test-sub-123', + channel: 'test-channel', + data: { message: 'valid notification after invalid json' }, + timestamp: 1760344704595, + }; + mockWs.simulateMessage(validNotification); + + expect(subscriptionCallback).toHaveBeenCalledTimes(1); + expect(subscriptionCallback).toHaveBeenCalledWith(validNotification); + }); + }); + + it('should not process duplicate messages that have both subscriptionId and channel fields', async () => { + await withService(async ({ service, getMockWebSocket }) => { + await service.connect(); + + const subscriptionCallback = jest.fn(); + const channelCallback = jest.fn(); + const mockWs = getMockWebSocket(); + + // Set up subscription callback + await createSubscription(service, mockWs, { + channels: ['test-channel'], + callback: subscriptionCallback, + requestId: 'test-duplicate-handling-subscribe', + subscriptionId: 'sub-123', + }); + + // Set up channel callback for the same channel + service.addChannelCallback({ + channelName: 'test-channel', + callback: channelCallback, + }); + + // Clear any previous calls + subscriptionCallback.mockClear(); + channelCallback.mockClear(); + + // Send a notification with BOTH subscriptionId and channel + const notificationWithBoth = { + event: 'notification', + subscriptionId: 'sub-123', + channel: 'test-channel', + data: { message: 'test notification with both properties' }, + timestamp: 1760344704595, + }; + mockWs.simulateMessage(notificationWithBoth); + + // The subscription callback should be called (has subscriptionId) + expect(subscriptionCallback).toHaveBeenCalledTimes(1); + expect(subscriptionCallback).toHaveBeenCalledWith(notificationWithBoth); + + // The channel callback should NOT be called (prevented by return statement) + expect(channelCallback).not.toHaveBeenCalled(); + + // Clear calls for next test + subscriptionCallback.mockClear(); + channelCallback.mockClear(); + + // Send a notification with ONLY channel (no subscriptionId) + const notificationChannelOnly = { + event: 'notification', + channel: 'test-channel', + data: { message: 'test notification with channel only' }, + timestamp: 1760344704696, + }; + mockWs.simulateMessage(notificationChannelOnly); + + // The subscription callback should NOT be called (no subscriptionId) + expect(subscriptionCallback).not.toHaveBeenCalled(); + + // The channel callback should be called (has channel) + expect(channelCallback).toHaveBeenCalledTimes(1); + expect(channelCallback).toHaveBeenCalledWith(notificationChannelOnly); + }); + }); + + it('should properly clear all pending requests and their timeouts during WebSocket disconnect', async () => { + await withService(async ({ service }) => { + await service.connect(); + + const requestPromise = service.sendRequest({ + event: 'test-request', + data: { test: true }, + }); + + service.disconnect(); + + await expect(requestPromise).rejects.toThrow( + 'WebSocket connection closed: 4999 Internal: Manual disconnect', + ); + }); + }); + + it('should handle WebSocket send errors by calling error handler and logging the error', async () => { + await withService(async ({ service, getMockWebSocket }) => { + await service.connect(); + const mockWs = getMockWebSocket(); + + // Mock send to throw error + mockWs.send.mockImplementation(() => { + throw new Error('Send failed'); + }); + + const testMessage = { + event: 'test-event', + data: { + requestId: 'test-req-1', + type: 'test', + payload: { key: 'value' }, + }, + }; + + // Should handle error and call error handler + expect(() => service.sendMessage(testMessage)).toThrow('Send failed'); + }); + }); + + it('should handle channel callback management comprehensively including add, remove, and get operations', async () => { + await withService( + { mockWebSocketOptions: { autoConnect: false } }, + async ({ service }) => { + const originalCallback = jest.fn(); + const duplicateCallback = jest.fn(); + + // Add channel callback first time + service.addChannelCallback({ + channelName: 'test-channel-duplicate', + callback: originalCallback, + }); + + expect(service.getChannelCallbacks()).toHaveLength(1); + + // Add same channel callback again - should replace the existing one + service.addChannelCallback({ + channelName: 'test-channel-duplicate', + callback: duplicateCallback, + }); + + expect(service.getChannelCallbacks()).toHaveLength(1); + + // Add different channel callback + service.addChannelCallback({ + channelName: 'different-channel', + callback: jest.fn(), + }); + + expect(service.getChannelCallbacks()).toHaveLength(2); + + // Remove callback - should return true + expect(service.removeChannelCallback('test-channel-duplicate')).toBe( + true, + ); + expect(service.getChannelCallbacks()).toHaveLength(1); + + // Try to remove non-existent callback - should return false + expect(service.removeChannelCallback('non-existent-channel')).toBe( + false, + ); + }, + ); + }); + + it('should handle server responses for non-existent requests gracefully', async () => { + await withService(async ({ service, getMockWebSocket }) => { + await service.connect(); + const mockWs = getMockWebSocket(); + + // Send response for non-existent request + mockWs.simulateMessage({ + event: 'response', + data: { + requestId: 'non-existent-request', + result: 'test', + }, + }); + + // Should not crash + expect(service.getConnectionInfo().state).toBe( + WebSocketState.CONNECTED, + ); + }); + }); + + it('should handle channel messages when no callbacks are registered', async () => { + await withService(async ({ service, getMockWebSocket }) => { + await service.connect(); + const mockWs = getMockWebSocket(); + + // Send channel message with no callbacks registered + mockWs.simulateMessage({ + event: 'notification', + channel: 'test-channel', + data: { test: 'data' }, + timestamp: 1760344704595, + }); + + // Should not crash + expect(service.getConnectionInfo().state).toBe( + WebSocketState.CONNECTED, + ); + }); + }); + + it('should handle subscription notifications with null subscriptionId', async () => { + await withService(async ({ service, getMockWebSocket }) => { + await service.connect(); + const mockWs = getMockWebSocket(); + + const channelCallback = jest.fn(); + service.addChannelCallback({ + channelName: 'test-channel', + callback: channelCallback, + }); + + // Send notification with null subscriptionId + const notification = { + event: 'notification', + channel: 'test-channel', + subscriptionId: null, + data: { test: 'data' }, + timestamp: 1760344704595, + }; + + mockWs.simulateMessage(notification); + + // Should fall through to channel callback + expect(channelCallback).toHaveBeenCalledWith(notification); + }); + }); + + it('should handle notifications with unknown subscriptionId by falling through to channel callback', async () => { + await withService(async ({ service, getMockWebSocket }) => { + await service.connect(); + const mockWs = getMockWebSocket(); + + const channelCallback = jest.fn(); + service.addChannelCallback({ + channelName: 'test-channel', + callback: channelCallback, + }); + + // Send notification with subscriptionId that doesn't exist in subscriptions map + const notification = { + event: 'notification', + channel: 'test-channel', + subscriptionId: 'non-existent-sub-id', + data: { test: 'data' }, + timestamp: 1760344704595, + }; + + mockWs.simulateMessage(notification); + + // Should fall through to channel callback since subscription doesn't exist + expect(channelCallback).toHaveBeenCalledWith(notification); + }); + }); + + it('should route account-activity notifications to wildcard channel callbacks', async () => { + await withService(async ({ service, getMockWebSocket }) => { + await service.connect(); + const mockWs = getMockWebSocket(); + const channelCallback = jest.fn(); + const subscribedChannel = + 'account-activity.v1.eip155:0:0x1234567890123456789012345678901234567890'; + const notificationChannel = + 'account-activity.v1.eip155:42161:0x1234567890123456789012345678901234567890'; + + service.addChannelCallback({ + channelName: subscribedChannel, + callback: channelCallback, + }); + + const notification = { + event: 'notification', + channel: notificationChannel, + subscriptionId: 'stale-subscription-id', + data: { address: '0x1234567890123456789012345678901234567890' }, + timestamp: 1760344704595, + }; + + mockWs.simulateMessage(notification); + + expect(channelCallback).toHaveBeenCalledWith(notification); + }); + }); + + it('should route account-activity notifications to subscriptions by channel when subscriptionId is stale', async () => { + await withService(async ({ service, getMockWebSocket }) => { + await service.connect(); + const mockWs = getMockWebSocket(); + const subscriptionCallback = jest.fn(); + const subscribedChannel = + 'account-activity.v1.eip155:0:0x1234567890123456789012345678901234567890'; + const notificationChannel = + 'account-activity.v1.eip155:42161:0x1234567890123456789012345678901234567890'; + + await createSubscription(service, mockWs, { + channels: [subscribedChannel], + callback: subscriptionCallback, + requestId: 'test-wildcard-subscribe', + subscriptionId: 'sub-123', + channelType: 'account-activity.v1', + }); + + subscriptionCallback.mockClear(); + + const notification = { + event: 'notification', + channel: notificationChannel, + subscriptionId: 'stale-server-subscription-id', + data: { address: '0x1234567890123456789012345678901234567890' }, + timestamp: 1760344704595, + }; + + mockWs.simulateMessage(notification); + + expect(subscriptionCallback).toHaveBeenCalledWith(notification); + }); + }); + + it('should normalize nested account-activity notifications before routing', async () => { + await withService(async ({ service, getMockWebSocket }) => { + await service.connect(); + const mockWs = getMockWebSocket(); + const channelCallback = jest.fn(); + const subscribedChannel = + 'account-activity.v1.eip155:0:0x1234567890123456789012345678901234567890'; + const notificationChannel = + 'account-activity.v1.eip155:42161:0x1234567890123456789012345678901234567890'; + + service.addChannelCallback({ + channelName: subscribedChannel, + callback: channelCallback, + }); + + mockWs.simulateMessage({ + event: 'data', + timestamp: 1760344704595, + data: { + channel: notificationChannel, + subscriptionId: 'stale-subscription-id', + message: { address: '0x1234567890123456789012345678901234567890' }, + }, + }); + + expect(channelCallback).toHaveBeenCalledWith( + expect.objectContaining({ + channel: notificationChannel, + data: { address: '0x1234567890123456789012345678901234567890' }, + }), + ); + }); + }); + + it('should normalize nested account-activity notifications using activity payload', async () => { + await withService(async ({ service, getMockWebSocket }) => { + await service.connect(); + const mockWs = getMockWebSocket(); + const channelCallback = jest.fn(); + const subscribedChannel = + 'account-activity.v1.eip155:0:0x1234567890123456789012345678901234567890'; + const notificationChannel = + 'account-activity.v1.eip155:42161:0x1234567890123456789012345678901234567890'; + + service.addChannelCallback({ + channelName: subscribedChannel, + callback: channelCallback, + }); + + mockWs.simulateMessage({ + event: 'data', + timestamp: 1760344704595, + data: { + channel: notificationChannel, + subscriptionId: 'stale-subscription-id', + activity: { address: '0x1234567890123456789012345678901234567890' }, + }, + }); + + expect(channelCallback).toHaveBeenCalledWith( + expect.objectContaining({ + channel: notificationChannel, + data: { address: '0x1234567890123456789012345678901234567890' }, + }), + ); + }); + }); + + it('should normalize nested account-activity notifications using nested timestamp and scalar payload', async () => { + await withService(async ({ service, getMockWebSocket }) => { + await service.connect(); + const mockWs = getMockWebSocket(); + const channelCallback = jest.fn(); + const subscribedChannel = + 'account-activity.v1.eip155:0:0x1234567890123456789012345678901234567890'; + const notificationChannel = + 'account-activity.v1.eip155:42161:0x1234567890123456789012345678901234567890'; + + service.addChannelCallback({ + channelName: subscribedChannel, + callback: channelCallback, + }); + + mockWs.simulateMessage({ + event: 'data', + data: { + channel: notificationChannel, + timestamp: 1760344704595, + payload: 'scalar-payload', + }, + }); + + expect(channelCallback).toHaveBeenCalledWith( + expect.objectContaining({ + channel: notificationChannel, + timestamp: 1760344704595, + data: { + channel: notificationChannel, + timestamp: 1760344704595, + payload: 'scalar-payload', + }, + }), + ); + }); + }); + + it('should preserve non-0x account addresses when parsing account-activity channels', async () => { + await withService(async ({ service, getMockWebSocket }) => { + await service.connect(); + const mockWs = getMockWebSocket(); + const channelCallback = jest.fn(); + const address = 'AbCdEf123456789'; + const subscribedChannel = `account-activity.v1.eip155:0:${address}`; + const notificationChannel = `account-activity.v1.eip155:42161:${address}`; + + service.addChannelCallback({ + channelName: subscribedChannel, + callback: channelCallback, + }); + + mockWs.simulateMessage({ + event: 'notification', + channel: notificationChannel, + subscriptionId: 'stale-subscription-id', + data: { address }, + timestamp: 1760344704595, + }); + + expect(channelCallback).toHaveBeenCalledTimes(1); + }); + }); + + it('should stringify non-string WebSocket message payloads before parsing', async () => { + await withService(async ({ service, getMockWebSocket }) => { + await service.connect(); + const mockWs = getMockWebSocket(); + const channelCallback = jest.fn(); + + service.addChannelCallback({ + channelName: 'test-channel', + callback: channelCallback, + }); + + mockWs.simulateRawMessage({ + toString() { + return JSON.stringify({ + channel: 'test-channel', + event: 'notification', + data: { value: 1 }, + timestamp: 1760344704595, + }); + }, + }); + + expect(channelCallback).toHaveBeenCalledWith( + expect.objectContaining({ + channel: 'test-channel', + data: { value: 1 }, + }), + ); + }); + }); + + it('should default nested notification timestamps when nested timestamp is not numeric', async () => { + await withService(async ({ service, getMockWebSocket }) => { + await service.connect(); + const mockWs = getMockWebSocket(); + const channelCallback = jest.fn(); + const notificationChannel = + 'account-activity.v1.eip155:42161:0x1234567890123456789012345678901234567890'; + + service.addChannelCallback({ + channelName: + 'account-activity.v1.eip155:0:0x1234567890123456789012345678901234567890', + callback: channelCallback, + }); + + const nowSpy = jest.spyOn(Date, 'now').mockReturnValue(1760344704999); + + mockWs.simulateMessage({ + event: 'data', + data: { + channel: notificationChannel, + timestamp: 'not-a-number', + payload: { address: '0x1234567890123456789012345678901234567890' }, + }, + }); + + expect(channelCallback).toHaveBeenCalledWith( + expect.objectContaining({ + channel: notificationChannel, + timestamp: 1760344704999, + }), + ); + + nowSpy.mockRestore(); + }); + }); + + it('should leave messages unchanged when nested data has no channel', async () => { + await withService(async ({ service, getMockWebSocket }) => { + await service.connect(); + const mockWs = getMockWebSocket(); + const channelCallback = jest.fn(); + + service.addChannelCallback({ + channelName: 'test-channel', + callback: channelCallback, + }); + + mockWs.simulateMessage({ + event: 'data', + data: { foo: 'bar' }, + }); + + expect(channelCallback).not.toHaveBeenCalled(); + }); + }); + + it('should leave messages unchanged when nested data is not an object', async () => { + await withService(async ({ service, getMockWebSocket }) => { + await service.connect(); + const mockWs = getMockWebSocket(); + const channelCallback = jest.fn(); + + service.addChannelCallback({ + channelName: 'test-channel', + callback: channelCallback, + }); + + mockWs.simulateMessage({ + event: 'data', + data: 'not-an-object', + }); + + expect(channelCallback).not.toHaveBeenCalled(); + }); + }); + + it('should route subscription notifications via channel fallback when subscriptionId is stale but channel matches exactly', async () => { + await withService(async ({ service, getMockWebSocket }) => { + await service.connect(); + const mockWs = getMockWebSocket(); + const subscriptionCallback = jest.fn(); + const channel = + 'account-activity.v1.eip155:0:0x1234567890123456789012345678901234567890'; + + await createSubscription(service, mockWs, { + channels: [channel], + callback: subscriptionCallback, + requestId: 'test-exact-channel-subscribe', + subscriptionId: 'sub-exact', + channelType: 'account-activity.v1', + }); + + subscriptionCallback.mockClear(); + + const notification = { + event: 'notification', + channel, + subscriptionId: 'stale-subscription-id', + data: { address: '0x1234567890123456789012345678901234567890' }, + timestamp: 1760344704595, + }; + + mockWs.simulateMessage(notification); + + expect(subscriptionCallback).toHaveBeenCalledWith(notification); + }); + }); + + it('should not wildcard-match account-activity channels with different chain refs', async () => { + await withService(async ({ service, getMockWebSocket }) => { + await service.connect(); + const mockWs = getMockWebSocket(); + const channelCallback = jest.fn(); + + service.addChannelCallback({ + channelName: + 'account-activity.v1.eip155:42161:0x1234567890123456789012345678901234567890', + callback: channelCallback, + }); + + mockWs.simulateMessage({ + event: 'notification', + channel: + 'account-activity.v1.eip155:137:0x1234567890123456789012345678901234567890', + subscriptionId: 'stale-subscription-id', + data: { address: '0x1234567890123456789012345678901234567890' }, + timestamp: 1760344704595, + }); + + expect(channelCallback).not.toHaveBeenCalled(); + }); + }); + + it('should not match subscriptions when channel format cannot be parsed', async () => { + await withService(async ({ service, getMockWebSocket }) => { + await service.connect(); + const mockWs = getMockWebSocket(); + const subscriptionCallback = jest.fn(); + + await createSubscription(service, mockWs, { + channels: ['legacy-channel.v1.test-topic'], + callback: subscriptionCallback, + requestId: 'test-unparseable-channel-subscribe', + subscriptionId: 'sub-unparseable', + channelType: 'legacy-channel.v1', + }); + + subscriptionCallback.mockClear(); + + mockWs.simulateMessage({ + event: 'notification', + channel: + 'account-activity.v1.eip155:42161:0x1234567890123456789012345678901234567890', + subscriptionId: 'stale-subscription-id', + data: { address: '0x1234567890123456789012345678901234567890' }, + timestamp: 1760344704595, + }); + + expect(subscriptionCallback).not.toHaveBeenCalled(); + }); + }); + + it('should treat server responses with requestId as non-subscription messages for non-account-activity channels', async () => { + await withService(async ({ service, getMockWebSocket }) => { + await service.connect(); + const mockWs = getMockWebSocket(); + const channelCallback = jest.fn(); + + service.addChannelCallback({ + channelName: 'market-data.v1.test', + callback: channelCallback, + }); + + mockWs.simulateMessage({ + event: 'notification', + channel: 'market-data.v1.test', + subscriptionId: 'sub-market-data', + data: { requestId: 'orphaned-request-id', price: '100' }, + timestamp: 1760344704595, + }); + + expect(channelCallback).not.toHaveBeenCalled(); + }); + }); + + it('should not wildcard-match account-activity channels with different addresses', async () => { + await withService(async ({ service, getMockWebSocket }) => { + await service.connect(); + const mockWs = getMockWebSocket(); + const channelCallback = jest.fn(); + + service.addChannelCallback({ + channelName: + 'account-activity.v1.eip155:0:0x1234567890123456789012345678901234567890', + callback: channelCallback, + }); + + mockWs.simulateMessage({ + event: 'notification', + channel: + 'account-activity.v1.eip155:42161:0xabcdefabcdefabcdefabcdefabcdefabcdefabcd', + subscriptionId: 'stale-subscription-id', + data: { address: '0xabcdefabcdefabcdefabcdefabcdefabcdefabcd' }, + timestamp: 1760344704595, + }); + + expect(channelCallback).not.toHaveBeenCalled(); + }); + }); + + it('should route account-activity notifications that include requestId in data', async () => { + await withService(async ({ service, getMockWebSocket }) => { + await service.connect(); + const mockWs = getMockWebSocket(); + const subscriptionCallback = jest.fn(); + const channel = + 'account-activity.v1.eip155:0:0x1234567890123456789012345678901234567890'; + + await createSubscription(service, mockWs, { + channels: [channel], + callback: subscriptionCallback, + requestId: 'test-request-id-in-data-subscribe', + subscriptionId: 'sub-with-request-id', + channelType: 'account-activity.v1', + }); + + subscriptionCallback.mockClear(); + + const notification = { + event: 'notification', + channel, + subscriptionId: 'sub-with-request-id', + data: { + requestId: 'orphaned-request-id', + address: '0x1234567890123456789012345678901234567890', + }, + timestamp: 1760344704595, + }; + + mockWs.simulateMessage(notification); + + expect(subscriptionCallback).toHaveBeenCalledWith(notification); + }); + }); + + it('should skip subscription notifications when the matched subscription has no callback', async () => { + await withService(async ({ service, getMockWebSocket }) => { + await service.connect(); + const mockWs = getMockWebSocket(); + const channel = + 'account-activity.v1.eip155:0:0x1234567890123456789012345678901234567890'; + const subscriptionId = 'sub-no-callback'; + const requestId = 'test-no-callback-subscribe'; + + const subscriptionPromise = service.subscribe({ + channels: [channel], + channelType: 'account-activity.v1', + callback: undefined as unknown as ( + notification: ServerNotificationMessage, + ) => void, + requestId, + }); + + mockWs.simulateMessage( + createResponseMessage(requestId, { + subscriptionId, + successful: [channel], + failed: [], + }), + ); + + await subscriptionPromise; + + const channelCallback = jest.fn(); + service.addChannelCallback({ + channelName: channel, + callback: channelCallback, + }); + + mockWs.simulateMessage({ + event: 'notification', + channel, + subscriptionId, + data: { address: '0x1234567890123456789012345678901234567890' }, + timestamp: 1760344704595, + }); + + expect(channelCallback).toHaveBeenCalled(); + }); + }); + + it('should handle sendRequest errors when sendMessage fails', async () => { + await withService(async ({ service }) => { + await service.connect(); + + jest.spyOn(service, 'sendMessage').mockImplementation(() => { + throw new Error('Send failed'); + }); + + await expect( + service.sendRequest({ event: 'test', data: {} }), + ).rejects.toThrow('Send failed'); + }); + }); + + it('should handle sendRequest errors with non-Error objects', async () => { + await withService(async ({ service }) => { + await service.connect(); + + jest.spyOn(service, 'sendMessage').mockImplementation(() => { + // eslint-disable-next-line @typescript-eslint/only-throw-error + throw 'String error'; + }); + + await expect( + service.sendRequest({ event: 'test', data: {} }), + ).rejects.toThrow('String error'); + }); + }); + }); + + describe('authentication flows', () => { + it('should handle authentication state changes by disconnecting WebSocket when user signs out', async () => { + await withService({ options: {} }, async ({ service, rootMessenger }) => { + // Start with signed in state by publishing event + rootMessenger.publish( + 'AuthenticationController:stateChange', + { isSignedIn: true }, + [], + ); + + // Trigger a failed connection to increment reconnection attempts + try { + await service.connect(); + } catch { + // Expected to fail + } + + // Simulate user signing out (wallet locked OR signed out) by publishing event + rootMessenger.publish( + 'AuthenticationController:stateChange', + { isSignedIn: false }, + [], + ); + + // Assert that reconnection attempts were reset to 0 when user signs out + expect(service.getConnectionInfo().reconnectAttempts).toBe(0); + }); + }); + + it('should trigger connection when KeyringController:unlock event is published', async () => { + await withService(async ({ service, rootMessenger }) => { + const connectSpy = jest.spyOn(service, 'connect'); + + rootMessenger.publish('KeyringController:unlock'); + + expect(connectSpy).toHaveBeenCalled(); + }); + }); + + it('should trigger disconnection when KeyringController:lock event is published', async () => { + await withService(async ({ service, rootMessenger }) => { + await service.connect(); + const disconnectSpy = jest.spyOn(service, 'disconnect'); + + rootMessenger.publish('KeyringController:lock'); + + expect(disconnectSpy).toHaveBeenCalled(); + }); + }); + + it('should handle getBearerToken error during connection by scheduling reconnect', async () => { + await withService( + { + options: {}, + mockWebSocketOptions: { autoConnect: false }, + }, + async ({ service, mocks }) => { + const authError = new Error('Auth error'); + mocks.getBearerToken.mockRejectedValueOnce(authError); + + // connect() will catch the error and schedule reconnect (not throw) + await service.connect(); + + // Initial state should be DISCONNECTED since connection failed + expect(service.getConnectionInfo().state).toBe( + WebSocketState.DISCONNECTED, + ); + expect(mocks.getBearerToken).toHaveBeenCalled(); + + // Verify reconnect was scheduled (attempts should be incremented) + expect(service.getConnectionInfo().reconnectAttempts).toBeGreaterThan( + 0, + ); + }, + ); + }); + + it('should handle null bearer token by scheduling reconnect', async () => { + await withService( + { + options: {}, + mockWebSocketOptions: { autoConnect: false }, + }, + async ({ service, mocks }) => { + // Return null to simulate user not signed in + mocks.getBearerToken.mockResolvedValueOnce(null); + + // connect() will catch the authentication error and schedule reconnect + await service.connect(); + + // Should be in DISCONNECTED state + expect(service.getConnectionInfo().state).toBe( + WebSocketState.DISCONNECTED, + ); + expect(mocks.getBearerToken).toHaveBeenCalled(); + + // Verify reconnect was scheduled + expect(service.getConnectionInfo().reconnectAttempts).toBeGreaterThan( + 0, + ); + }, + ); + }); + }); + + // ===================================================== + // ENABLED CALLBACK TESTS + // ===================================================== + describe('enabledCallback functionality', () => { + it('should respect enabledCallback returning false during connection by rejecting with disabled error', async () => { + const mockEnabledCallback = jest.fn().mockReturnValue(false); + await withService( + { + options: { + isEnabled: mockEnabledCallback, + }, + mockWebSocketOptions: { autoConnect: false }, + }, + async ({ service }) => { + // Attempt to connect when disabled - should return early + await service.connect(); + + // Verify enabledCallback was consulted + expect(mockEnabledCallback).toHaveBeenCalled(); + + // Should remain disconnected when callback returns false + expect(service.getConnectionInfo().state).toBe( + WebSocketState.DISCONNECTED, + ); + + // Reconnection attempts should be cleared (reset to 0) + expect(service.getConnectionInfo().reconnectAttempts).toBe(0); + }, + ); + }); + + it('should stop reconnection when isEnabled returns false during scheduled reconnect', async () => { + const mockEnabledCallback = jest.fn().mockReturnValue(true); + await withService( + { + options: { + isEnabled: mockEnabledCallback, + reconnectDelay: 50, + }, + }, + async ({ service, getMockWebSocket, completeAsyncOperations }) => { + // Mock Math.random to make Cockatiel's jitter deterministic + jest.spyOn(Math, 'random').mockReturnValue(0); + + await service.connect(); + const mockWs = getMockWebSocket(); + + mockEnabledCallback.mockClear(); + + // Simulate connection loss + mockWs.simulateClose(1006, 'Connection lost'); + await completeAsyncOperations(0); + + expect(service.getConnectionInfo().reconnectAttempts).toBe(1); + + // Disable the service + mockEnabledCallback.mockReturnValue(false); + + // Advance time to trigger reconnection check + await completeAsyncOperations(70); + + // Should have checked isEnabled and stopped reconnection + expect(mockEnabledCallback).toHaveBeenCalled(); + expect(service.getConnectionInfo().reconnectAttempts).toBe(0); + expect(service.getConnectionInfo().state).toBe( + WebSocketState.DISCONNECTED, + ); + }, + ); + }); + }); + + // ===================================================== + // LIFECYCLE AND CLEANUP TESTS + // ===================================================== + describe('destroy', () => { + it('should clean up all resources including timers, subscriptions, and pending requests on destroy', async () => { + await withService(async ({ service, getMockWebSocket }) => { + await service.connect(); + const mockWs = getMockWebSocket(); + + // Create a subscription + const mockCallback = jest.fn(); + await createSubscription(service, mockWs, { + channels: ['test-cleanup'], + callback: mockCallback, + requestId: 'cleanup-test', + subscriptionId: 'cleanup-sub', + }); + + // Start a request (to create pending request) + const requestPromise = service.sendRequest({ + event: 'test-event', + data: {}, + }); + + // Destroy the service + service.destroy(); + + // Pending request should be rejected + await expect(requestPromise).rejects.toThrow( + 'WebSocket connection closed: 4999 Internal: Manual disconnect', + ); + + // Verify service is in disconnected state + expect(service.getConnectionInfo().state).toBe( + WebSocketState.DISCONNECTED, + ); + }); + }); + }); + + // ===================================================== + // UTILITY FUNCTIONS + // ===================================================== + describe('getCloseReason utility', () => { + it('should map WebSocket close codes to human-readable descriptions', () => { + // Test all close codes to verify proper close reason descriptions + const closeCodeTests = [ + { code: 1000, expected: 'Normal Closure' }, + { code: 1001, expected: 'Going Away' }, + { code: 1002, expected: 'Protocol Error' }, + { code: 1003, expected: 'Unsupported Data' }, + { code: 1004, expected: 'Reserved' }, + { code: 1005, expected: 'No Status Received' }, + { code: 1006, expected: 'Abnormal Closure' }, + { code: 1007, expected: 'Invalid frame payload data' }, + { code: 1008, expected: 'Policy Violation' }, + { code: 1009, expected: 'Message Too Big' }, + { code: 1010, expected: 'Mandatory Extension' }, + { code: 1011, expected: 'Internal Server Error' }, + { code: 1012, expected: 'Service Restart' }, + { code: 1013, expected: 'Try Again Later' }, + { code: 1014, expected: 'Bad Gateway' }, + { code: 1015, expected: 'TLS Handshake' }, + { code: 3500, expected: 'Library/Framework Error' }, // 3000-3999 range + { code: 4500, expected: 'Application Error' }, // 4000-4999 range + { code: 9999, expected: 'Unknown' }, // default case + ]; + + closeCodeTests.forEach(({ code, expected }) => { + const result = getCloseReason(code); + expect(result).toBe(expected); + }); + }); + }); +}); diff --git a/packages/core-backend/src/ws/BackendWebSocketService.ts b/packages/core-backend/src/ws/BackendWebSocketService.ts new file mode 100644 index 00000000000..b66c7ed1463 --- /dev/null +++ b/packages/core-backend/src/ws/BackendWebSocketService.ts @@ -0,0 +1,1601 @@ +import type { TraceCallback } from '@metamask/controller-utils'; +import { ExponentialBackoff } from '@metamask/controller-utils'; +import type { + KeyringControllerLockEvent, + KeyringControllerUnlockEvent, +} from '@metamask/keyring-controller'; +import type { Messenger } from '@metamask/messenger'; +import type { AuthenticationController } from '@metamask/profile-sync-controller'; +import { getErrorMessage } from '@metamask/utils'; +import { v4 as uuidV4 } from 'uuid'; + +import { projectLogger, createModuleLogger } from '../logger.js'; +import type { BackendWebSocketServiceMethodActions } from './BackendWebSocketService-method-action-types.js'; + +const SERVICE_NAME = 'BackendWebSocketService' as const; + +const log = createModuleLogger(projectLogger, SERVICE_NAME); + +function isAccountActivityChannel(channel: string): boolean { + return channel.includes('account-activity'); +} + +const ACCOUNT_ACTIVITY_CHANNEL_REGEX = + /^account-activity\.v1\.([^:]+):([^:]+):(.+)$/u; + +type ParsedAccountActivityChannel = { + namespace: string; + chainRef: string; + address: string; +}; + +/** + * Parse an account-activity channel name into namespace, chain reference, and address. + * + * @param channel - Channel name (e.g. account-activity.v1.eip155:42161:0xabc...). + * @returns Parsed components, or null when the channel is not account-activity format. + */ +function parseAccountActivityChannel( + channel: string, +): ParsedAccountActivityChannel | null { + const match = ACCOUNT_ACTIVITY_CHANNEL_REGEX.exec(channel); + if (!match) { + return null; + } + + const [, namespace, chainRef, address] = match; + return { + namespace, + chainRef, + address: address.startsWith('0x') ? address.toLowerCase() : address, + }; +} + +/** + * Whether a notification channel matches a subscribed channel. + * Subscriptions use chain ref `0` (all chains); notifications often use a specific chain id. + * + * @param subscribedChannel - Channel registered at subscribe / addChannelCallback time. + * @param notificationChannel - Channel from the server notification. + * @returns True when the notification should route to the subscribed channel. + */ +function accountActivityChannelsMatch( + subscribedChannel: string, + notificationChannel: string, +): boolean { + if (subscribedChannel === notificationChannel) { + return true; + } + + const subscribed = parseAccountActivityChannel(subscribedChannel); + const notification = parseAccountActivityChannel(notificationChannel); + if (!subscribed || !notification) { + return false; + } + + return ( + subscribed.namespace === notification.namespace && + subscribed.address === notification.address && + (subscribed.chainRef === '0' || + subscribed.chainRef === notification.chainRef) + ); +} + +/** + * Promote nested channel/subscription fields to the top level when the server + * wraps notifications inside `data`. + * + * @param message - Parsed WebSocket message. + * @returns Normalized message for routing. + */ +function normalizeIncomingMessage(message: WebSocketMessage): WebSocketMessage { + const topLevel = message as Partial & + Record; + + if (typeof topLevel.channel === 'string') { + return message; + } + + const nestedData = topLevel.data; + if (!nestedData || typeof nestedData !== 'object') { + return message; + } + + const nested = nestedData; + if (typeof nested.channel !== 'string') { + return message; + } + + const payload = + nested.data ?? nested.payload ?? nested.message ?? nested.activity; + + return { + ...topLevel, + channel: nested.channel, + subscriptionId: + topLevel.subscriptionId ?? (nested.subscriptionId as string | undefined), + timestamp: + topLevel.timestamp ?? + (typeof nested.timestamp === 'number' ? nested.timestamp : undefined) ?? + Date.now(), + data: payload && typeof payload === 'object' ? payload : nestedData, + } as WebSocketMessage; +} + +// WebSocket close codes and reasons for internal operations +const MANUAL_DISCONNECT_CODE = 4999 as const; +const MANUAL_DISCONNECT_REASON = 'Internal: Manual disconnect' as const; +const FORCE_RECONNECT_CODE = 4998 as const; +const FORCE_RECONNECT_REASON = 'Internal: Force reconnect' as const; + +const MESSENGER_EXPOSED_METHODS = [ + 'connect', + 'disconnect', + 'forceReconnection', + 'sendMessage', + 'sendRequest', + 'subscribe', + 'getConnectionInfo', + 'getSubscriptionsByChannel', + 'channelHasSubscription', + 'findSubscriptionsByChannelPrefix', + 'addChannelCallback', + 'removeChannelCallback', + 'getChannelCallbacks', +] as const; + +/** + * Gets human-readable close reason from RFC 6455 close code + * + * @param code - WebSocket close code + * @returns Human-readable close reason + */ +export function getCloseReason(code: number): string { + switch (code) { + case 1000: + return 'Normal Closure'; + case 1001: + return 'Going Away'; + case 1002: + return 'Protocol Error'; + case 1003: + return 'Unsupported Data'; + case 1004: + return 'Reserved'; + case 1005: + return 'No Status Received'; + case 1006: + return 'Abnormal Closure'; + case 1007: + return 'Invalid frame payload data'; + case 1008: + return 'Policy Violation'; + case 1009: + return 'Message Too Big'; + case 1010: + return 'Mandatory Extension'; + case 1011: + return 'Internal Server Error'; + case 1012: + return 'Service Restart'; + case 1013: + return 'Try Again Later'; + case 1014: + return 'Bad Gateway'; + case 1015: + return 'TLS Handshake'; + default: + if (code >= 3000 && code <= 3999) { + return 'Library/Framework Error'; + } + if (code >= 4000 && code <= 4999) { + return 'Application Error'; + } + return 'Unknown'; + } +} + +/** + * WebSocket connection states + */ +export enum WebSocketState { + CONNECTING = 'connecting', + CONNECTED = 'connected', + /** @deprecated This value is no longer used internally and will be removed in a future major release */ + DISCONNECTING = 'disconnecting', + DISCONNECTED = 'disconnected', + /** @deprecated TThis value is no longer used internally and will be removed in a future major release */ + ERROR = 'error', +} + +/** + * WebSocket event types + */ +export enum WebSocketEventType { + CONNECTED = 'connected', + DISCONNECTED = 'disconnected', + MESSAGE = 'message', + ERROR = 'error', + RECONNECTING = 'reconnecting', + RECONNECTED = 'reconnected', +} + +/** + * Configuration options for the WebSocket service + */ +export type BackendWebSocketServiceOptions = { + /** The WebSocket URL to connect to */ + url: string; + + /** The messenger for inter-service communication */ + messenger: BackendWebSocketServiceMessenger; + + /** Connection timeout in milliseconds (default: 10000) */ + timeout?: number; + + /** Initial reconnection delay in milliseconds (default: 10000) */ + reconnectDelay?: number; + + /** Maximum reconnection delay in milliseconds (default: 60000) */ + maxReconnectDelay?: number; + + /** Request timeout in milliseconds (default: 30000) */ + requestTimeout?: number; + + /** Optional callback to determine if connection should be enabled (default: always enabled) */ + isEnabled?: () => boolean; + + /** Optional callback to trace performance of WebSocket operations (default: no-op) */ + traceFn?: TraceCallback; +}; + +/** + * Client Request message + * Used when client sends a request to the server + */ +export type ClientRequestMessage = { + event: string; + data: { + requestId: string; + channels?: string[]; + [key: string]: unknown; + }; +}; + +/** + * Server Response message + * Used when server responds to a client request + */ +export type ServerResponseMessage = { + event: string; + data: { + requestId: string; + subscriptionId?: string; + succeeded?: string[]; + failed?: string[]; + [key: string]: unknown; + }; +}; + +/** + * Server Notification message + * Used when server sends unsolicited data to client + * subscriptionId is optional for system-wide notifications + */ +export type ServerNotificationMessage = { + event: string; + subscriptionId?: string; + channel: string; + data: Record; + timestamp: number; +}; + +/** + * Union type for all WebSocket messages + */ +export type WebSocketMessage = + | ClientRequestMessage + | ServerResponseMessage + | ServerNotificationMessage; + +/** + * Channel-based callback configuration + */ +export type ChannelCallback = { + /** Channel name to match (also serves as the unique identifier) */ + channelName: string; + /** Callback function */ + callback: (notification: ServerNotificationMessage) => void; +}; + +/** + * Unified WebSocket subscription object used for both internal storage and external API + */ +export type WebSocketSubscription = { + /** The subscription ID from the server */ + subscriptionId: string; + /** Channel names for this subscription */ + channels: string[]; + /** Channel type with version (e.g., 'account-activity.v1') extracted from first channel */ + channelType: string; + /** Callback function for handling notifications (optional for external use) */ + callback?: (notification: ServerNotificationMessage) => void; + /** Function to unsubscribe and clean up */ + unsubscribe: (requestId?: string) => Promise; +}; + +/** + * WebSocket connection info + */ +export type WebSocketConnectionInfo = { + state: WebSocketState; + url: string; + reconnectAttempts: number; + timeout: number; + reconnectDelay: number; + maxReconnectDelay: number; + requestTimeout: number; + connectedAt?: number; +}; + +// Action types for the messaging system - using generated method actions +export type BackendWebSocketServiceActions = + BackendWebSocketServiceMethodActions; + +type AllowedActions = + AuthenticationController.AuthenticationControllerGetBearerTokenAction; + +// Event types for WebSocket connection state changes +export type BackendWebSocketServiceConnectionStateChangedEvent = { + type: 'BackendWebSocketService:connectionStateChanged'; + payload: [WebSocketConnectionInfo]; +}; + +type AllowedEvents = + | AuthenticationController.AuthenticationControllerStateChangeEvent + | KeyringControllerLockEvent + | KeyringControllerUnlockEvent; + +export type BackendWebSocketServiceEvents = + BackendWebSocketServiceConnectionStateChangedEvent; + +export type BackendWebSocketServiceMessenger = Messenger< + typeof SERVICE_NAME, + BackendWebSocketServiceActions | AllowedActions, + BackendWebSocketServiceEvents | AllowedEvents +>; + +/** + * WebSocket Service with automatic reconnection, session management and direct callback routing + * + * Connection Management: + * - Automatically subscribes to AuthenticationController:stateChange (sign in/out) + * - Automatically subscribes to KeyringController:lock/unlock events + * - Idempotent connect() function safe for multiple rapid calls + * - Auto-reconnects on unexpected disconnects (manualDisconnect = false) + * + * Platform Responsibilities: + * - Call connect() when app opens/foregrounds + * - Call disconnect() when app closes/backgrounds + * - Provide isEnabled() callback (feature flag) + * - Call destroy() on app termination + * + * Real-Time Performance Optimizations: + * - Fast path message routing (zero allocations) + * - Production mode removes try-catch overhead + * - Optimized JSON parsing with fail-fast + * - Direct callback routing bypasses event emitters + * - Memory cleanup and resource management + */ +export class BackendWebSocketService { + /** + * The name of the service. + */ + readonly name = SERVICE_NAME; + + readonly #messenger: BackendWebSocketServiceMessenger; + + readonly #options: Required< + Omit + >; + + readonly #isEnabled: (() => boolean) | undefined; + + readonly #trace: TraceCallback; + + #ws: WebSocket | undefined; + + #state: WebSocketState = WebSocketState.DISCONNECTED; + + #reconnectAttempts = 0; + + #reconnectTimer: NodeJS.Timeout | null = null; + + #connectionTimeout: NodeJS.Timeout | null = null; + + #stableConnectionTimer: NodeJS.Timeout | null = null; + + // Track the current connection promise to handle concurrent connection attempts + #connectionPromise: Promise | null = null; + + readonly #pendingRequests = new Map< + string, + { + resolve: (value: unknown) => void; + reject: (error: Error) => void; + timeout: NodeJS.Timeout; + } + >(); + + #connectedAt: number = 0; + + // Simplified subscription storage (single flat map) + // Key: subscription ID string (e.g., 'sub_abc123def456') + // Value: WebSocketSubscription object with channels, callback and metadata + readonly #subscriptions = new Map(); + + // Channel-based callback storage + // Key: channel name (serves as unique identifier) + // Value: ChannelCallback configuration + readonly #channelCallbacks = new Map(); + + // Backoff instance for reconnection delays (reset on stable connection) + #backoff!: ReturnType['next']>; + + // ============================================================================= + // 1. CONSTRUCTOR & INITIALIZATION + // ============================================================================= + + /** + * Creates a new WebSocket service instance + * + * @param options - Configuration options for the WebSocket service + */ + constructor(options: BackendWebSocketServiceOptions) { + this.#messenger = options.messenger; + this.#isEnabled = options.isEnabled; + // Default to no-op trace function to keep core platform-agnostic + this.#trace = + options.traceFn ?? + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (((_request: any, fn?: any) => fn?.()) as TraceCallback); + + this.#options = { + url: options.url, + timeout: options.timeout ?? 10000, + reconnectDelay: options.reconnectDelay ?? 10000, + maxReconnectDelay: options.maxReconnectDelay ?? 60000, + requestTimeout: options.requestTimeout ?? 30000, + }; + + // Initialize backoff for reconnection delays + this.#newBackoff(); + + // Subscribe to authentication and keyring controller events + this.#subscribeEvents(); + + // Register action handlers using the method actions pattern + this.#messenger.registerMethodActionHandlers( + this, + MESSENGER_EXPOSED_METHODS, + ); + } + + /** + * Setup event handling for authentication and wallet lock state + * + * Three event sources trigger connection/disconnection: + * 1. AuthenticationController:stateChange (sign in/out) + * 2. KeyringController:unlock (wallet unlocked) + * 3. KeyringController:lock (wallet locked) + * + * All connect() calls are idempotent and validate all requirements. + */ + #subscribeEvents(): void { + // Subscribe to authentication state changes (sign in/out) + this.#messenger.subscribe( + 'AuthenticationController:stateChange', + (state: AuthenticationController.AuthenticationControllerState) => { + if (state.isSignedIn) { + // eslint-disable-next-line @typescript-eslint/no-floating-promises + this.connect(); + } else { + this.disconnect(); + } + }, + (state) => ({ isSignedIn: state.isSignedIn }), + ); + + // Subscribe to wallet unlock event + this.#messenger.subscribe('KeyringController:unlock', () => { + // eslint-disable-next-line @typescript-eslint/no-floating-promises + this.connect(); + }); + + // Subscribe to wallet lock event + this.#messenger.subscribe('KeyringController:lock', () => { + this.disconnect(); + }); + } + + // ============================================================================= + // 2. PUBLIC API METHODS + // ============================================================================= + + /** + * Establishes WebSocket connection with smart reconnection behavior + * + * Connection Requirements (all must be true): + * 1. Feature enabled (isEnabled() = true) + * 2. Wallet unlocked (checked by getBearerToken) + * 3. User signed in (checked by getBearerToken) + * + * Platform code should call this when app opens/foregrounds. + * Automatically called on KeyringController:unlock event. + * + * @returns Promise that resolves when connection is established + */ + async connect(): Promise { + // Priority 1: Check if feature is enabled via callback (feature flag check) + // If feature is disabled, stop all connection attempts + if (this.#isEnabled && !this.#isEnabled()) { + // Clear any pending reconnection attempts since feature is disabled + this.#clearTimers(); + this.#reconnectAttempts = 0; + return; + } + + // If already connected, return immediately + if (this.#state === WebSocketState.CONNECTED) { + return; + } + + // If already connecting, wait for the existing connection attempt to complete + if (this.#connectionPromise) { + await this.#connectionPromise; + return; + } + + // If a reconnect is already scheduled, defer to it to avoid bypassing exponential backoff + // This prevents rapid loops when server accepts then immediately closes connections + if (this.#reconnectTimer) { + return; + } + + // Create and store the connection promise IMMEDIATELY (before any async operations) + // This ensures subsequent connect() calls will wait for this promise instead of creating new connections + this.#connectionPromise = (async (): Promise => { + // Priority 2: Check authentication requirements (signed in) + let bearerToken: string; + try { + const token = await this.#messenger.call( + 'AuthenticationController:getBearerToken', + ); + if (!token) { + throw new Error('Authentication required: user not signed in'); + } + bearerToken = token; + } catch (error) { + log('Failed to check authentication requirements', { error }); + throw error; + } + + // Establish the actual WebSocket connection + await this.#establishConnection(bearerToken); + })(); + + try { + await this.#connectionPromise; + } catch { + // Always schedule reconnect on any failure + // Exponential backoff will prevent aggressive retries + this.#scheduleReconnect(); + } finally { + // Clear the connection promise when done (success or failure) + this.#connectionPromise = null; + } + } + + /** + * Closes WebSocket connection + */ + disconnect(): void { + if (this.#state === WebSocketState.DISCONNECTED || !this.#ws) { + return; + } + + // Close WebSocket with manual disconnect code and reason + this.#ws.close(MANUAL_DISCONNECT_CODE, MANUAL_DISCONNECT_REASON); + + log('WebSocket manually disconnected'); + } + + /** + * Forces a WebSocket reconnection to clean up subscription state + * + * This method is useful when subscription state may be out of sync and needs to be reset. + * It performs a controlled disconnect-then-reconnect sequence: + * - Disconnects cleanly to trigger subscription cleanup + * - Schedules reconnection with exponential backoff to prevent rapid loops + * - All subscriptions will be cleaned up automatically on disconnect + * + * Use cases: + * - Recovering from subscription/unsubscription issues + * - Cleaning up orphaned subscriptions + * - Forcing a fresh subscription state + * + * @returns Promise that resolves when disconnection is complete (reconnection is scheduled) + */ + async forceReconnection(): Promise { + if (this.#state === WebSocketState.DISCONNECTED || !this.#ws) { + log('WebSocket already disconnected, scheduling reconnect'); + this.#scheduleReconnect(); + return; + } + + log('Forcing WebSocket reconnection to clean up subscription state'); + + // This ensures ws.onclose will schedule a reconnect (not treat it as manual disconnect) + this.#ws.close(FORCE_RECONNECT_CODE, FORCE_RECONNECT_REASON); + } + + /** + * Sends a message through the WebSocket (fire-and-forget, no response expected) + * + * This is a low-level method for sending messages without waiting for a response. + * Most consumers should use `sendRequest()` instead, which handles request-response + * correlation and provides proper error handling with timeouts. + * + * Use this method only when: + * - You don't need a response from the server + * - You're implementing custom message protocols + * - You need fine-grained control over message timing + * + * @param message - The message to send + * @throws Error if WebSocket is not connected or send fails + * + * @see sendRequest for request-response pattern with automatic correlation + */ + sendMessage(message: ClientRequestMessage): void { + if (this.#state !== WebSocketState.CONNECTED || !this.#ws) { + throw new Error(`Cannot send message: WebSocket is ${this.#state}`); + } + + try { + this.#ws.send(JSON.stringify(message)); + } catch (error) { + const errorMessage = getErrorMessage(error); + this.#handleError(new Error(errorMessage)); + throw new Error(errorMessage); + } + } + + /** + * Sends a request and waits for a correlated response (recommended for most use cases) + * + * This is the recommended high-level method for request-response communication. + * It automatically handles: + * - Request ID generation and correlation + * - Response matching with timeout protection + * - Automatic reconnection on timeout + * - Proper cleanup of pending requests + * + * @param message - The request message (can include optional requestId for testing) + * @returns Promise that resolves with the response data + * @throws Error if WebSocket is not connected, request times out, or response indicates failure + * + * @see sendMessage for fire-and-forget messaging without response handling + */ + async sendRequest( + message: Omit & { + data?: Omit & { + requestId?: string; + }; + }, + ): Promise { + if (this.#state !== WebSocketState.CONNECTED) { + throw new Error(`Cannot send request: WebSocket is ${this.#state}`); + } + + // Use provided requestId if available, otherwise generate a new one + const requestId = message.data?.requestId ?? uuidV4(); + const requestMessage: ClientRequestMessage = { + event: message.event, + data: { + ...message.data, + requestId, // Set after spread to ensure it's not overwritten by undefined + }, + }; + + return new Promise((resolve, reject) => { + const timeout = setTimeout(() => { + this.#pendingRequests.delete(requestId); + log('Request timeout - triggering reconnection', { + timeout: this.#options.requestTimeout, + }); + + // Trigger reconnection on request timeout as it may indicate stale connection + if (this.#state === WebSocketState.CONNECTED && this.#ws) { + // Force close the current connection to trigger reconnection logic + this.#ws.close(3000, 'Request timeout - forcing reconnect'); + } + + reject( + new Error(`Request timeout after ${this.#options.requestTimeout}ms`), + ); + }, this.#options.requestTimeout); + + // Store in pending requests for response correlation + this.#pendingRequests.set(requestId, { + resolve: resolve as (value: unknown) => void, + reject, + timeout, + }); + + // Send the request + try { + this.sendMessage(requestMessage); + } catch (error) { + this.#pendingRequests.delete(requestId); + clearTimeout(timeout); + reject(error instanceof Error ? error : new Error(String(error))); + } + }); + } + + /** + * Gets current connection information + * + * @returns Current connection status and details + */ + getConnectionInfo(): WebSocketConnectionInfo { + return { + state: this.#state, + url: this.#options.url, + timeout: this.#options.timeout, + reconnectDelay: this.#options.reconnectDelay, + maxReconnectDelay: this.#options.maxReconnectDelay, + requestTimeout: this.#options.requestTimeout, + reconnectAttempts: this.#reconnectAttempts, + connectedAt: this.#connectedAt, + }; + } + + /** + * Gets all subscription information for a specific channel + * + * @param channel - The channel name to look up + * @returns Array of subscription details for all subscriptions containing the channel + */ + getSubscriptionsByChannel(channel: string): WebSocketSubscription[] { + const matchingSubscriptions: WebSocketSubscription[] = []; + for (const [subscriptionId, subscription] of this.#subscriptions) { + if (subscription.channels.includes(channel)) { + matchingSubscriptions.push({ + subscriptionId, + channels: subscription.channels, + channelType: subscription.channelType, + unsubscribe: subscription.unsubscribe, + }); + } + } + return matchingSubscriptions; + } + + /** + * Checks if a channel has a subscription + * + * @param channel - The channel name to check + * @returns True if the channel has a subscription, false otherwise + */ + channelHasSubscription(channel: string): boolean { + for (const subscription of this.#subscriptions.values()) { + if (subscription.channels.includes(channel)) { + return true; + } + } + return false; + } + + /** + * Finds all subscriptions that have channels starting with the specified prefix + * + * @param channelPrefix - The channel prefix to search for (e.g., "account-activity.v1") + * @returns Array of subscription info for matching subscriptions + */ + findSubscriptionsByChannelPrefix( + channelPrefix: string, + ): WebSocketSubscription[] { + const matchingSubscriptions: WebSocketSubscription[] = []; + + for (const [subscriptionId, subscription] of this.#subscriptions) { + // Check if any channel in this subscription starts with the prefix + const hasMatchingChannel = subscription.channels.some((channel) => + channel.startsWith(channelPrefix), + ); + + if (hasMatchingChannel) { + matchingSubscriptions.push({ + subscriptionId, + channels: subscription.channels, + channelType: subscription.channelType, + unsubscribe: subscription.unsubscribe, + }); + } + } + + return matchingSubscriptions; + } + + /** + * Register a callback for specific channels (local callback only, no server subscription) + * + * **Key Difference from `subscribe()`:** + * - `addChannelCallback()`: Registers a local callback without creating a server-side subscription. + * The callback triggers on ANY message matching the channel name, regardless of subscriptionId. + * Useful for system-wide notifications or when you don't control the subscription lifecycle. + * + * - `subscribe()`: Creates a proper server-side subscription with a subscriptionId. + * The callback only triggers for messages with the matching subscriptionId. + * Includes proper lifecycle management (unsubscribe, automatic cleanup on disconnect). + * + * **When to use `addChannelCallback()`:** + * - Listening to system-wide notifications (e.g., 'system-notifications.v1') + * - Monitoring channels where subscriptions are managed elsewhere + * - Debug/logging scenarios where you want to observe all channel messages + * + * **When to use `subscribe()` instead:** + * - Creating new subscriptions that need server-side registration + * - When you need proper cleanup via unsubscribe + * - Most application use cases (recommended approach) + * + * @param options - Channel callback configuration + * @param options.channelName - Channel name to match exactly + * @param options.callback - Function to call when channel matches + * + * @example + * ```typescript + * // Listen to system notifications (no server subscription needed) + * webSocketService.addChannelCallback({ + * channelName: 'system-notifications.v1', + * callback: (notification) => { + * console.log('System notification:', notification.data); + * } + * }); + * + * // For account-specific subscriptions, use subscribe() instead: + * // const sub = await webSocketService.subscribe({ + * // channels: ['account-activity.v1.eip155:0:0x1234...'], + * // callback: (notification) => { ... } + * // }); + * ``` + * + * @see subscribe for creating proper server-side subscriptions with lifecycle management + */ + addChannelCallback(options: { + channelName: string; + callback: (notification: ServerNotificationMessage) => void; + }): void { + const channelCallback: ChannelCallback = { + channelName: options.channelName, + callback: options.callback, + }; + + // Check if callback already exists for this channel + if (this.#channelCallbacks.has(options.channelName)) { + return; + } + + this.#channelCallbacks.set(options.channelName, channelCallback); + } + + /** + * Remove a channel callback + * + * @param channelName - The channel name returned from addChannelCallback + * @returns True if callback was found and removed, false otherwise + */ + removeChannelCallback(channelName: string): boolean { + return this.#channelCallbacks.delete(channelName); + } + + /** + * Get all registered channel callbacks (for debugging) + * + * @returns Array of all registered channel callbacks + */ + getChannelCallbacks(): ChannelCallback[] { + return Array.from(this.#channelCallbacks.values()); + } + + /** + * Destroy the service and clean up resources + * Called when service is being destroyed or app is terminating + */ + destroy(): void { + // Always clear timers first to prevent reconnection attempts after destruction + // This handles the case where destroy() is called while DISCONNECTED with a pending reconnect timer + this.#clearTimers(); + + // Reset reconnect attempts to prevent any future reconnection logic + this.#reconnectAttempts = 0; + + // Disconnect the WebSocket if connected (will be no-op if already disconnected) + this.disconnect(); + } + + /** + * Create and manage a subscription with server-side registration (recommended for most use cases) + * + * This is the recommended subscription API for high-level services. It creates a proper + * server-side subscription and routes notifications based on subscriptionId. + * + * **Key Features:** + * - Creates server-side subscription with unique subscriptionId + * - Callback triggered only for messages with matching subscriptionId + * - Automatic lifecycle management (cleanup on disconnect) + * - Includes unsubscribe method for proper cleanup + * - Request-response pattern with error handling + * + * **When to use `subscribe()`:** + * - Creating new subscriptions (account activity, price updates, etc.) + * - When you need proper cleanup/unsubscribe functionality + * - Most application use cases + * + * **When to use `addChannelCallback()` instead:** + * - System-wide notifications without server-side subscription + * - Observing channels managed elsewhere + * - Debug/logging scenarios + * + * @param options - Subscription configuration + * @param options.channels - Array of channel names to subscribe to + * @param options.callback - Callback function for handling notifications + * @param options.requestId - Optional request ID for testing (will generate UUID if not provided) + * @param options.channelType - Channel type identifier + * @returns Subscription object with unsubscribe method + * + * @example + * ```typescript + * // AccountActivityService usage + * const subscription = await webSocketService.subscribe({ + * channels: ['account-activity.v1.eip155:0:0x1234...'], + * callback: (notification) => { + * this.handleAccountActivity(notification.data); + * } + * }); + * + * // Later, clean up + * await subscription.unsubscribe(); + * ``` + * + * @see addChannelCallback for local callbacks without server-side subscription + */ + async subscribe(options: { + /** Channel names to subscribe to */ + channels: string[]; + /** Channel type with version (e.g., 'account-activity.v1') for tracing and monitoring */ + channelType: string; + /** Handler for incoming notifications */ + callback: (notification: ServerNotificationMessage) => void; + /** Optional request ID for testing (will generate UUID if not provided) */ + requestId?: string; + }): Promise { + const { channels, channelType, callback, requestId } = options; + + if (this.#state !== WebSocketState.CONNECTED) { + throw new Error( + `Cannot create subscription(s) ${channels.join(', ')}: WebSocket is ${this.#state}`, + ); + } + + // Send subscription request and wait for response + const subscriptionResponse = await this.sendRequest({ + event: 'subscribe', + data: { channels, requestId }, + }); + + if (!subscriptionResponse?.subscriptionId) { + throw new Error('Invalid subscription response: missing subscription ID'); + } + + const { subscriptionId } = subscriptionResponse; + + // Create unsubscribe function + const unsubscribe = async (unsubRequestId?: string): Promise => { + // Send unsubscribe request first + await this.sendRequest({ + event: 'unsubscribe', + data: { + subscription: subscriptionId, + channels, + requestId: unsubRequestId, + }, + }); + + // Clean up subscription mapping + this.#subscriptions.delete(subscriptionId); + }; + + const subscription = { + subscriptionId, + channels: [...channels], + channelType, + unsubscribe, + }; + + // Store subscription with subscription ID as key + this.#subscriptions.set(subscriptionId, { + subscriptionId, + channels: [...channels], // Store copy of channels + channelType, + callback, + unsubscribe, + }); + + return subscription; + } + + // ============================================================================= + // 3. CONNECTION MANAGEMENT (PRIVATE) + // ============================================================================= + + /** + * Builds an authenticated WebSocket URL with bearer token as query parameter. + * Uses query parameter for WebSocket authentication since native WebSocket + * doesn't support custom headers during handshake. + * + * @param bearerToken - The bearer token to use for authentication + * @returns The authenticated WebSocket URL + */ + #buildAuthenticatedUrl(bearerToken: string): string { + const baseUrl = this.#options.url; + + // Add token as query parameter to the WebSocket URL + const url = new URL(baseUrl); + url.searchParams.set('token', bearerToken); + + return url.toString(); + } + + /** + * Establishes the actual WebSocket connection + * + * @param bearerToken - The bearer token to use for authentication + * @returns Promise that resolves when connection is established + */ + async #establishConnection(bearerToken: string): Promise { + const wsUrl = this.#buildAuthenticatedUrl(bearerToken); + + // Transition to CONNECTING state before creating WebSocket + this.#setState(WebSocketState.CONNECTING); + return this.#trace( + { + name: `${SERVICE_NAME} Connection`, + data: { + reconnectAttempt: this.#reconnectAttempts, + }, + tags: { + service: SERVICE_NAME, + }, + }, + () => { + return new Promise((resolve, reject) => { + // eslint-disable-next-line no-restricted-globals + const ws = new WebSocket(wsUrl); + this.#connectionTimeout = setTimeout(() => { + log('WebSocket connection timeout - forcing close', { + timeout: this.#options.timeout, + }); + // Close the WebSocket - onclose will handle rejection and state change + ws.close(); + }, this.#options.timeout); + + ws.onopen = (): void => { + if (this.#connectionTimeout) { + clearTimeout(this.#connectionTimeout); + this.#connectionTimeout = null; + } + + this.#ws = ws; + this.#setState(WebSocketState.CONNECTED); + this.#connectedAt = Date.now(); + + // Only reset after connection stays stable for a period (10 seconds) + // This prevents rapid reconnect loops when server accepts then immediately closes + this.#stableConnectionTimer = setTimeout(() => { + this.#stableConnectionTimer = null; + this.#reconnectAttempts = 0; + // Create new backoff sequence for fresh start on next disconnect + this.#newBackoff(); + log('Connection stable - reset reconnect attempts and backoff'); + }, 10000); + + resolve(); + }; + + ws.onclose = (event: CloseEvent): void => { + log('WebSocket onclose event triggered', { + code: event.code, + reason: event.reason || getCloseReason(event.code), + wasClean: event.wasClean, + }); + + // Guard against duplicate close events + if (this.#state === WebSocketState.DISCONNECTED) { + return; + } + + // Detect if this is a manual disconnect or service cleanup based on close code + const isManualDisconnect = + event.code === MANUAL_DISCONNECT_CODE && + event.reason === MANUAL_DISCONNECT_REASON; + + // If connection hasn't been established yet, handle the connection promise + if (this.#state === WebSocketState.CONNECTING) { + if (isManualDisconnect) { + // Manual disconnect during connection - resolve to prevent reconnection + resolve(); + } else { + // Failed connection attempt - reject to trigger reconnection + reject( + new Error( + `WebSocket connection closed during connection: ${event.code} ${event.reason}`, + ), + ); + } + } + + // Calculate connection duration before we clear state (only if we were connected) + const connectionDurationMs = + this.#connectedAt > 0 ? Date.now() - this.#connectedAt : 0; + + // Clear all timers + this.#clearTimers(); + + // Clear WebSocket reference to allow garbage collection + this.#ws = undefined; + + // Clear connection tracking + this.#connectionPromise = null; + this.#connectedAt = 0; + + this.#clearPendingRequests( + new Error( + `WebSocket connection closed: ${event.code} ${event.reason || getCloseReason(event.code)}`, + ), + ); + this.#clearSubscriptions(); + + // Update state to disconnected + this.#setState(WebSocketState.DISCONNECTED); + + // Check if this was a manual disconnect + if (isManualDisconnect) { + // Manual disconnect - reset attempts and don't reconnect + this.#reconnectAttempts = 0; + } else { + // Unexpected disconnect - schedule reconnection + this.#scheduleReconnect(); + } + + // eslint-disable-next-line @typescript-eslint/no-floating-promises + this.#trace({ + name: `${SERVICE_NAME} Disconnection`, + data: { + code: event.code, + reason: event.reason || getCloseReason(event.code), + wasClean: event.wasClean, + reconnectAttempts: this.#reconnectAttempts, + ...(connectionDurationMs > 0 && { + connectionDuration_ms: connectionDurationMs, + }), + }, + tags: { + service: SERVICE_NAME, + }, + }); + }; + + // Set up message handler immediately - no need to wait for connection + ws.onmessage = (event: MessageEvent): void => { + try { + const rawData = + typeof event.data === 'string' + ? event.data + : String(event.data); + const message = normalizeIncomingMessage( + this.#parseMessage(rawData), + ); + this.#handleMessage(message); + } catch { + // Silently ignore invalid JSON messages + } + }; + }); + }, + ); + } + + // ============================================================================= + // 4. MESSAGE HANDLING (PRIVATE) + // ============================================================================= + + /** + * Handles incoming WebSocket messages + * + * @param message - The WebSocket message to handle + */ + #handleMessage(message: WebSocketMessage): void { + const isServerResponse = this.#isServerResponse(message); + const isSubscriptionNotification = + this.#isSubscriptionNotification(message); + const isChannelMessage = this.#isChannelMessage(message); + + // Handle server responses (correlated with requests) first + if (isServerResponse) { + const maybeNotification = message as Partial; + if ( + typeof maybeNotification.channel !== 'string' || + !isAccountActivityChannel(maybeNotification.channel) + ) { + this.#handleServerResponse(message); + return; + } + } + + // Handle subscription notifications with valid subscriptionId + if (isSubscriptionNotification) { + const notificationMsg = message as ServerNotificationMessage; + if (this.#handleSubscriptionNotification(notificationMsg)) { + return; + } + } + + // Trigger channel callbacks for any message with a channel property + if (isChannelMessage) { + this.#handleChannelMessage(message); + } + } + + /** + * Checks if a message is a server response (correlated with client requests) + * + * @param message - The message to check + * @returns True if the message is a server response + */ + #isServerResponse( + message: WebSocketMessage, + ): message is ServerResponseMessage { + return ( + 'data' in message && + message.data && + typeof message.data === 'object' && + 'requestId' in message.data + ); + } + + /** + * Checks if a message is a subscription notification (has subscriptionId) + * + * @param message - The message to check + * @returns True if the message is a subscription notification with subscriptionId + */ + #isSubscriptionNotification(message: WebSocketMessage): boolean { + if (!('subscriptionId' in message)) { + return false; + } + + if (this.#isServerResponse(message)) { + const maybeNotification = message as Partial; + return ( + typeof maybeNotification.channel === 'string' && + isAccountActivityChannel(maybeNotification.channel) + ); + } + + return true; + } + + /** + * Checks if a message has a channel property (system or subscription notification) + * + * @param message - The message to check + * @returns True if the message has a channel property + */ + #isChannelMessage( + message: WebSocketMessage, + ): message is ServerNotificationMessage { + return 'channel' in message; + } + + /** + * Handles server response messages (correlated with client requests) + * + * @param message - The server response message to handle + */ + #handleServerResponse(message: ServerResponseMessage): void { + const { requestId } = message.data; + + const request = this.#pendingRequests.get(requestId); + if (!request) { + return; + } + + this.#pendingRequests.delete(requestId); + clearTimeout(request.timeout); + + // Check if the response indicates failure + if (message.data.failed && message.data.failed.length > 0) { + request.reject( + new Error(`Request failed: ${message.data.failed.join(', ')}`), + ); + } else { + request.resolve(message.data); + } + } + + /** + * Handles messages with channel properties by triggering channel callbacks + * + * @param message - The message with channel property to handle + */ + #handleChannelMessage(message: ServerNotificationMessage): void { + const callback = this.#resolveChannelCallback(message.channel); + callback?.(message); + } + + /** + * Resolve a channel callback by exact name or account-activity wildcard (chain ref 0). + * + * @param channel - Notification channel from the server. + * @returns Matching callback, if registered. + */ + #resolveChannelCallback( + channel: string, + ): ((notification: ServerNotificationMessage) => void) | undefined { + const exactMatch = this.#channelCallbacks.get(channel); + if (exactMatch) { + return exactMatch.callback; + } + + if (!isAccountActivityChannel(channel)) { + return undefined; + } + + for (const [registeredChannel, channelCallback] of this.#channelCallbacks) { + if (accountActivityChannelsMatch(registeredChannel, channel)) { + return channelCallback.callback; + } + } + + return undefined; + } + + /** + * Find a subscription whose channels match the notification (including chain wildcard). + * + * @param channel - Notification channel from the server. + * @returns Matching subscription entry, if any. + */ + #findSubscriptionForAccountActivityChannel( + channel: string, + ): WebSocketSubscription | undefined { + for (const subscription of this.#subscriptions.values()) { + if ( + subscription.channels.some((subscribedChannel) => + accountActivityChannelsMatch(subscribedChannel, channel), + ) + ) { + return subscription; + } + } + + return undefined; + } + + /** + * Handles server notifications with subscription IDs + * + * @param message - The server notification message to handle + * @returns True if the message was handled, false if it should fall through to channel handling + */ + #handleSubscriptionNotification(message: ServerNotificationMessage): boolean { + const { subscriptionId, timestamp, channel } = message; + + // Only handle if subscriptionId is defined and not null (allows "0" as valid ID) + if (subscriptionId !== null && subscriptionId !== undefined) { + let subscription = this.#subscriptions.get(subscriptionId); + if (!subscription && channel) { + subscription = this.#findSubscriptionForAccountActivityChannel(channel); + } + + if (!subscription) { + return false; + } + + const activeSubscription = subscription; + + if (!activeSubscription.callback) { + return false; + } + + // Calculate notification latency: time from server sent to client received + const receivedAt = Date.now(); + const latency = receivedAt - timestamp; + + // Trace notification processing wi th latency data + // Use stored channelType instead of parsing each time + // Promise result intentionally not awaited + // eslint-disable-next-line @typescript-eslint/no-floating-promises + this.#trace( + { + name: `${SERVICE_NAME} Notification`, + data: { + channel, + latency_ms: latency, + subscriptionId, + }, + tags: { + service: SERVICE_NAME, + notification_type: activeSubscription.channelType, + }, + }, + () => { + activeSubscription.callback?.(message); + }, + ); + return true; + } + + return false; + } + + /** + * Parse WebSocket message data + * + * @param data - The raw message data to parse + * @returns Parsed message + */ + #parseMessage(data: string): WebSocketMessage { + return JSON.parse(data); + } + + // ============================================================================= + // 5. EVENT HANDLERS (PRIVATE) + // ============================================================================= + + /** + * Handles WebSocket errors + * + * @param _error - Error that occurred (unused) + */ + #handleError(_error: Error): void { + // Placeholder for future error handling logic + } + + // ============================================================================= + // 6. STATE MANAGEMENT (PRIVATE) + // ============================================================================= + + /** + * Schedules a connection attempt with exponential backoff and jitter + * + * This method is used for automatic reconnection with Cockatiel's exponential backoff: + * - Prevents duplicate reconnection timers (idempotent) + * - Applies exponential backoff with jitter based on previous failures + * - Jitter uses decorrelated formula to prevent thundering herd problem + * - Used ONLY for automatic retries, not user-initiated actions + * + * Call this from: + * - connect() catch block (on connection failure) + * - ws.onclose handler (on unexpected disconnect) + * + * For user-initiated actions (sign in, unlock), call connect() directly instead. + * + * If a reconnect is already scheduled, this is a no-op to prevent: + * - Orphaned timers (memory leak) + * - Inflated reconnect attempts counter + * - Prematurely long delays + */ + #scheduleReconnect(): void { + // If a reconnect is already scheduled, don't schedule another one + if (this.#reconnectTimer) { + return; + } + + // Increment attempts BEFORE calculating delay so backoff grows properly + this.#reconnectAttempts += 1; + + // Use Cockatiel's exponential backoff to get delay with jitter + const delay = this.#backoff.duration; + + // Progress to next backoff state for future reconnect attempts + // Pass attempt number as context (though ExponentialBackoff doesn't use it) + this.#backoff = this.#backoff.next({ attempt: this.#reconnectAttempts }); + + log('Scheduling reconnect', { + attempt: this.#reconnectAttempts, + delay_ms: delay, + }); + + this.#reconnectTimer = setTimeout(() => { + // Clear timer reference first + this.#reconnectTimer = null; + + // Check if connection is still enabled before reconnecting + if (this.#isEnabled && !this.#isEnabled()) { + this.#reconnectAttempts = 0; + // Create new backoff sequence when disabled + this.#newBackoff(); + return; + } + + // eslint-disable-next-line @typescript-eslint/no-floating-promises + this.connect(); + }, delay); + } + + /** + * Creates a new exponential backoff sequence + */ + #newBackoff(): void { + this.#backoff = new ExponentialBackoff({ + initialDelay: this.#options.reconnectDelay, + maxDelay: this.#options.maxReconnectDelay, + }).next(); + } + + #clearTimers(): void { + if (this.#reconnectTimer) { + clearTimeout(this.#reconnectTimer); + this.#reconnectTimer = null; + } + if (this.#connectionTimeout) { + clearTimeout(this.#connectionTimeout); + this.#connectionTimeout = null; + } + if (this.#stableConnectionTimer) { + clearTimeout(this.#stableConnectionTimer); + this.#stableConnectionTimer = null; + } + } + + /** + * Clears all pending requests and rejects them with the given error + * + * @param error - Error to reject with + */ + #clearPendingRequests(error: Error): void { + for (const [, request] of this.#pendingRequests) { + clearTimeout(request.timeout); + request.reject(error); + } + this.#pendingRequests.clear(); + } + + /** + * Clears all active subscriptions + */ + #clearSubscriptions(): void { + this.#subscriptions.clear(); + } + + /** + * Sets the connection state and emits state change events + * + * @param newState - The new WebSocket state + */ + #setState(newState: WebSocketState): void { + const oldState = this.#state; + this.#state = newState; + + if (oldState !== newState) { + // Publish connection state change event + // Messenger handles listener errors internally, no need for try-catch + this.#messenger.publish( + 'BackendWebSocketService:connectionStateChanged', + this.getConnectionInfo(), + ); + } + } +} diff --git a/packages/core-backend/src/ws/ohlcv/OHLCVService-method-action-types.ts b/packages/core-backend/src/ws/ohlcv/OHLCVService-method-action-types.ts new file mode 100644 index 00000000000..26b5e564ef5 --- /dev/null +++ b/packages/core-backend/src/ws/ohlcv/OHLCVService-method-action-types.ts @@ -0,0 +1,40 @@ +/** + * This file is auto generated. + * Do not edit manually. + */ + +import type { OHLCVService } from './OHLCVService.js'; + +/** + * Subscribe to an OHLCV channel. If this is the first subscriber for the + * given asset/interval/currency combination a WebSocket subscription is + * created. Additional calls for the same combination only bump the reference + * count. + * + * @param options - The subscription parameters. + * @returns A promise that resolves once the subscription is established. + */ +export type OHLCVServiceSubscribeAction = { + type: `OHLCVService:subscribe`; + handler: OHLCVService['subscribe']; +}; + +/** + * Unsubscribe from an OHLCV channel. Decrements the reference count and, + * when it reaches zero, starts a grace-period timer before actually + * unsubscribing from the WebSocket to absorb rapid navigation patterns. + * + * @param options - The subscription parameters to unsubscribe from. + * @returns A promise that resolves once the unsubscription is processed. + */ +export type OHLCVServiceUnsubscribeAction = { + type: `OHLCVService:unsubscribe`; + handler: OHLCVService['unsubscribe']; +}; + +/** + * Union of all OHLCVService action types. + */ +export type OHLCVServiceMethodActions = + | OHLCVServiceSubscribeAction + | OHLCVServiceUnsubscribeAction; diff --git a/packages/core-backend/src/ws/ohlcv/OHLCVService.test.ts b/packages/core-backend/src/ws/ohlcv/OHLCVService.test.ts new file mode 100644 index 00000000000..96f35efc3ff --- /dev/null +++ b/packages/core-backend/src/ws/ohlcv/OHLCVService.test.ts @@ -0,0 +1,1566 @@ +import { Messenger, MOCK_ANY_NAMESPACE } from '@metamask/messenger'; +import type { + MessengerActions, + MessengerEvents, + MockAnyNamespace, +} from '@metamask/messenger'; + +import { flushPromises } from '../../../../../tests/helpers.js'; +import type { ServerNotificationMessage } from '../BackendWebSocketService.js'; +import { WebSocketState } from '../BackendWebSocketService.js'; +import { OHLCVService } from './OHLCVService.js'; +import type { OHLCVServiceMessenger } from './OHLCVService.js'; +import type { OHLCVSubscriptionOptions } from './types.js'; + +// ============================================================================= +// Test Helpers +// ============================================================================= + +type AllOHLCVServiceActions = MessengerActions; +type AllOHLCVServiceEvents = MessengerEvents; + +type RootMessenger = Messenger< + MockAnyNamespace, + AllOHLCVServiceActions, + AllOHLCVServiceEvents +>; + +const completeAsyncOperations = async (timeoutMs = 0): Promise => { + // Multiple rounds are needed because the channel lock chains promises + // through .then(), requiring several microtask ticks to fully settle. + for (let i = 0; i < 5; i++) { + await flushPromises(); + } + if (timeoutMs > 0) { + await new Promise((resolve) => setTimeout(resolve, timeoutMs)); + } + await flushPromises(); +}; + +function getRootMessenger(): RootMessenger { + return new Messenger({ namespace: MOCK_ANY_NAMESPACE }); +} + +const getMessenger = (): { + rootMessenger: RootMessenger; + messenger: OHLCVServiceMessenger; + mocks: { + connect: jest.Mock; + subscribe: jest.Mock; + channelHasSubscription: jest.Mock; + getSubscriptionsByChannel: jest.Mock; + findSubscriptionsByChannelPrefix: jest.Mock; + forceReconnection: jest.Mock; + addChannelCallback: jest.Mock; + removeChannelCallback: jest.Mock; + getConnectionInfo: jest.Mock; + }; +} => { + const rootMessenger = getRootMessenger(); + const messenger: OHLCVServiceMessenger = new Messenger< + 'OHLCVService', + AllOHLCVServiceActions, + AllOHLCVServiceEvents, + RootMessenger + >({ + namespace: 'OHLCVService', + parent: rootMessenger, + }); + + rootMessenger.delegate({ + actions: [ + 'BackendWebSocketService:connect', + 'BackendWebSocketService:forceReconnection', + 'BackendWebSocketService:subscribe', + 'BackendWebSocketService:getConnectionInfo', + 'BackendWebSocketService:channelHasSubscription', + 'BackendWebSocketService:getSubscriptionsByChannel', + 'BackendWebSocketService:findSubscriptionsByChannelPrefix', + 'BackendWebSocketService:addChannelCallback', + 'BackendWebSocketService:removeChannelCallback', + ], + events: ['BackendWebSocketService:connectionStateChanged'], + messenger, + }); + + const mockConnect = jest.fn(); + const mockForceReconnection = jest.fn().mockResolvedValue(undefined); + const mockSubscribe = jest.fn(); + const mockChannelHasSubscription = jest.fn().mockReturnValue(false); + const mockGetSubscriptionsByChannel = jest.fn().mockReturnValue([]); + const mockFindSubscriptionsByChannelPrefix = jest.fn().mockReturnValue([]); + const mockAddChannelCallback = jest.fn(); + const mockRemoveChannelCallback = jest.fn(); + const mockGetConnectionInfo = jest.fn(); + + rootMessenger.registerActionHandler( + 'BackendWebSocketService:connect', + mockConnect, + ); + rootMessenger.registerActionHandler( + 'BackendWebSocketService:forceReconnection', + mockForceReconnection, + ); + rootMessenger.registerActionHandler( + 'BackendWebSocketService:subscribe', + mockSubscribe, + ); + rootMessenger.registerActionHandler( + 'BackendWebSocketService:channelHasSubscription', + mockChannelHasSubscription, + ); + rootMessenger.registerActionHandler( + 'BackendWebSocketService:getSubscriptionsByChannel', + mockGetSubscriptionsByChannel, + ); + rootMessenger.registerActionHandler( + 'BackendWebSocketService:findSubscriptionsByChannelPrefix', + mockFindSubscriptionsByChannelPrefix, + ); + rootMessenger.registerActionHandler( + 'BackendWebSocketService:addChannelCallback', + mockAddChannelCallback, + ); + rootMessenger.registerActionHandler( + 'BackendWebSocketService:removeChannelCallback', + mockRemoveChannelCallback, + ); + rootMessenger.registerActionHandler( + 'BackendWebSocketService:getConnectionInfo', + mockGetConnectionInfo, + ); + + return { + rootMessenger, + messenger, + mocks: { + connect: mockConnect, + subscribe: mockSubscribe, + channelHasSubscription: mockChannelHasSubscription, + getSubscriptionsByChannel: mockGetSubscriptionsByChannel, + findSubscriptionsByChannelPrefix: mockFindSubscriptionsByChannelPrefix, + forceReconnection: mockForceReconnection, + addChannelCallback: mockAddChannelCallback, + removeChannelCallback: mockRemoveChannelCallback, + getConnectionInfo: mockGetConnectionInfo, + }, + }; +}; + +type WithServiceCallback = (payload: { + service: OHLCVService; + messenger: OHLCVServiceMessenger; + rootMessenger: RootMessenger; + mocks: ReturnType['mocks']; + destroy: () => void; +}) => Promise | R; + +async function withService(fn: WithServiceCallback): Promise { + const setup = getMessenger(); + const service = new OHLCVService({ messenger: setup.messenger }); + service.init(); + + try { + return await fn({ + service, + messenger: setup.messenger, + rootMessenger: setup.rootMessenger, + mocks: setup.mocks, + destroy: () => service.destroy(), + }); + } finally { + service.destroy(); + } +} + +const getSystemNotificationCallback = (mocks: { + addChannelCallback: jest.Mock; +}): ((notification: ServerNotificationMessage) => void) => { + const call = mocks.addChannelCallback.mock.calls.find( + (c: unknown[]) => + c[0] && + typeof c[0] === 'object' && + 'channelName' in c[0] && + (c[0] as { channelName: string }).channelName === + 'system-notifications.v1.market-data.v1', + ); + + if (!call) { + throw new Error('system notification callback not registered'); + } + + return (call[0] as { callback: (n: ServerNotificationMessage) => void }) + .callback; +}; + +// ============================================================================= +// Shared Constants +// ============================================================================= + +const SUB_OPTS: OHLCVSubscriptionOptions = { + assetId: 'eip155:8453/erc20:0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913', + interval: '1m', + currency: 'usd', +}; + +const EXPECTED_CHANNEL = + 'market-data.v1.eip155:8453/erc20:0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913.1m.usd'; + +const BASE_CONNECTION_INFO = { + url: 'ws://test', + timeout: 10000, + reconnectDelay: 500, + maxReconnectDelay: 5000, + requestTimeout: 30000, +}; + +// ============================================================================= +// Tests +// ============================================================================= + +describe('OHLCVService', () => { + beforeEach(() => { + jest.useFakeTimers(); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + // =========================================================================== + // Constructor + // =========================================================================== + + describe('constructor', () => { + it('should register method action handlers and system-notifications callback', async () => { + await withService(async ({ service, mocks }) => { + expect(service).toBeInstanceOf(OHLCVService); + expect(service.name).toBe('OHLCVService'); + + expect(mocks.addChannelCallback).toHaveBeenCalledWith({ + channelName: 'system-notifications.v1.market-data.v1', + callback: expect.any(Function), + }); + }); + }); + }); + + // =========================================================================== + // Subscribe + // =========================================================================== + + describe('subscribe', () => { + it('should connect and create a WebSocket subscription for a new channel', async () => { + await withService(async ({ service, mocks }) => { + await service.subscribe(SUB_OPTS); + + expect(mocks.connect).toHaveBeenCalledTimes(1); + expect(mocks.channelHasSubscription).toHaveBeenCalledWith( + EXPECTED_CHANNEL, + ); + expect(mocks.subscribe).toHaveBeenCalledWith({ + channels: [EXPECTED_CHANNEL], + channelType: 'market-data.v1', + callback: expect.any(Function), + }); + }); + }); + + it('should skip WS subscribe if the channel already has a subscription', async () => { + await withService(async ({ service, mocks }) => { + mocks.channelHasSubscription.mockReturnValue(true); + + await service.subscribe(SUB_OPTS); + + expect(mocks.subscribe).not.toHaveBeenCalled(); + }); + }); + + it('should increment refCount on duplicate subscribe without WS traffic', async () => { + await withService(async ({ service, mocks }) => { + await service.subscribe(SUB_OPTS); + mocks.subscribe.mockClear(); + mocks.connect.mockClear(); + + await service.subscribe(SUB_OPTS); + + expect(mocks.connect).not.toHaveBeenCalled(); + expect(mocks.subscribe).not.toHaveBeenCalled(); + }); + }); + + it('should publish subscriptionError when subscribe fails', async () => { + await withService(async ({ service, mocks, messenger }) => { + mocks.connect.mockRejectedValueOnce(new Error('connection failed')); + + const errorListener = jest.fn(); + messenger.subscribe('OHLCVService:subscriptionError', errorListener); + + await service.subscribe(SUB_OPTS); + + expect(errorListener).toHaveBeenCalledWith({ + channel: EXPECTED_CHANNEL, + error: expect.stringContaining('connection failed'), + operation: 'subscribe', + }); + expect(mocks.forceReconnection).not.toHaveBeenCalled(); + }); + }); + + it('should publish barUpdated events when WebSocket delivers data', async () => { + await withService(async ({ service, mocks, messenger }) => { + let capturedCallback: (n: ServerNotificationMessage) => void = + jest.fn(); + + mocks.subscribe.mockImplementation((opts) => { + capturedCallback = opts.callback; + return Promise.resolve(); + }); + + await service.subscribe(SUB_OPTS); + + const barListener = jest.fn(); + messenger.subscribe('OHLCVService:barUpdated', barListener); + + capturedCallback({ + event: 'data', + subscriptionId: 'sub-1', + timestamp: 1776364071003, + channel: EXPECTED_CHANNEL, + data: { + timestamp: 1776364020, + open: 74.099, + high: 74.1, + low: 74.083, + close: 74.099, + volume: 5806.43, + }, + } as ServerNotificationMessage); + + expect(barListener).toHaveBeenCalledWith({ + channel: EXPECTED_CHANNEL, + bar: { + timestamp: 1776364020, + open: 74.099, + high: 74.1, + low: 74.083, + close: 74.099, + volume: 5806.43, + }, + }); + }); + }); + }); + + // =========================================================================== + // Unsubscribe + // =========================================================================== + + describe('unsubscribe', () => { + it('should be a no-op if channel was never subscribed', async () => { + await withService(async ({ service, mocks }) => { + await service.unsubscribe(SUB_OPTS); + + expect(mocks.getSubscriptionsByChannel).not.toHaveBeenCalled(); + }); + }); + + it('should decrement refCount without unsubscribing when other consumers remain', async () => { + await withService(async ({ service, mocks }) => { + await service.subscribe(SUB_OPTS); + await service.subscribe(SUB_OPTS); + + await service.unsubscribe(SUB_OPTS); + + // No timer should have been started, no WS unsubscribe + jest.advanceTimersByTime(5000); + await completeAsyncOperations(); + expect(mocks.getSubscriptionsByChannel).not.toHaveBeenCalled(); + }); + }); + + it('should start a grace-period timer and unsubscribe after expiry', async () => { + await withService(async ({ service, mocks }) => { + const mockUnsub = jest.fn(); + mocks.getSubscriptionsByChannel.mockReturnValue([ + { unsubscribe: mockUnsub }, + ]); + + await service.subscribe(SUB_OPTS); + await service.unsubscribe(SUB_OPTS); + + // Before grace period expires — still subscribed + expect(mockUnsub).not.toHaveBeenCalled(); + + jest.advanceTimersByTime(3000); + await completeAsyncOperations(); + + expect(mocks.getSubscriptionsByChannel).toHaveBeenCalledWith( + EXPECTED_CHANNEL, + ); + expect(mockUnsub).toHaveBeenCalledTimes(1); + }); + }); + }); + + // =========================================================================== + // Grace Period — Re-subscribe During Grace + // =========================================================================== + + describe('grace period', () => { + it('should cancel grace-period timer if re-subscribed before expiry', async () => { + await withService(async ({ service, mocks }) => { + const mockUnsub = jest.fn(); + mocks.getSubscriptionsByChannel.mockReturnValue([ + { unsubscribe: mockUnsub }, + ]); + + await service.subscribe(SUB_OPTS); + await service.unsubscribe(SUB_OPTS); + + // WS subscription still exists (no disconnect happened) + mocks.channelHasSubscription.mockReturnValue(true); + + // Re-subscribe during grace period + jest.advanceTimersByTime(1000); + mocks.subscribe.mockClear(); + mocks.connect.mockClear(); + await service.subscribe(SUB_OPTS); + + // Should NOT have called connect/subscribe again — subscription is still alive + expect(mocks.connect).not.toHaveBeenCalled(); + expect(mocks.subscribe).not.toHaveBeenCalled(); + + // Advance past original grace period — should NOT unsubscribe + jest.advanceTimersByTime(5000); + await completeAsyncOperations(); + expect(mockUnsub).not.toHaveBeenCalled(); + }); + }); + + it('should flush other grace channels when re-subscribing to the same channel during grace', async () => { + const opts1h: OHLCVSubscriptionOptions = { + ...SUB_OPTS, + interval: '1h', + }; + const channel1h = + 'market-data.v1.eip155:8453/erc20:0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913.1h.usd'; + + await withService(async ({ service, mocks }) => { + const mockUnsub1m = jest.fn().mockResolvedValue(undefined); + const mockUnsub1h = jest.fn().mockResolvedValue(undefined); + + mocks.getSubscriptionsByChannel.mockImplementation( + (channel: string) => { + if (channel === EXPECTED_CHANNEL) { + throw new Error('flush unsub failed'); + } + if (channel === channel1h) { + return [{ unsubscribe: mockUnsub1h }]; + } + return [{ unsubscribe: mockUnsub1m }]; + }, + ); + + await service.subscribe(SUB_OPTS); + await service.unsubscribe(SUB_OPTS); + await service.subscribe(opts1h); + await service.unsubscribe(opts1h); + + mocks.getSubscriptionsByChannel.mockClear(); + mockUnsub1h.mockClear(); + mocks.channelHasSubscription.mockReturnValue(true); + mocks.connect.mockClear(); + mocks.subscribe.mockClear(); + + await service.subscribe(opts1h); + + expect(mocks.getSubscriptionsByChannel).toHaveBeenCalledWith( + EXPECTED_CHANNEL, + ); + expect(mockUnsub1h).not.toHaveBeenCalled(); + expect(mocks.connect).not.toHaveBeenCalled(); + expect(mocks.subscribe).not.toHaveBeenCalled(); + }); + }); + + it('should flush the old channel immediately when subscribing to a different interval', async () => { + const opts1m = SUB_OPTS; + const opts1h: OHLCVSubscriptionOptions = { + ...SUB_OPTS, + interval: '1h', + }; + const channel1h = + 'market-data.v1.eip155:8453/erc20:0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913.1h.usd'; + + await withService(async ({ service, mocks }) => { + const mockUnsub = jest.fn().mockResolvedValue(undefined); + mocks.getSubscriptionsByChannel.mockReturnValue([ + { unsubscribe: mockUnsub }, + ]); + + await service.subscribe(opts1m); + await service.unsubscribe(opts1m); + await service.subscribe(opts1h); + + expect(mockUnsub).toHaveBeenCalledTimes(1); + expect(mocks.getSubscriptionsByChannel).toHaveBeenCalledWith( + EXPECTED_CHANNEL, + ); + expect(mocks.subscribe).toHaveBeenCalledWith( + expect.objectContaining({ + channels: [channel1h], + }), + ); + + jest.advanceTimersByTime(3000); + await completeAsyncOperations(); + + expect(mockUnsub).toHaveBeenCalledTimes(1); + }); + }); + it('should schedule unsubscribe retry when flush fails during channel switch', async () => { + const opts1h: OHLCVSubscriptionOptions = { + ...SUB_OPTS, + interval: '1h', + }; + + await withService(async ({ service, mocks, messenger }) => { + const errorListener = jest.fn(); + messenger.subscribe('OHLCVService:subscriptionError', errorListener); + + mocks.getSubscriptionsByChannel.mockImplementation( + (channel: string) => { + if (channel === EXPECTED_CHANNEL) { + throw new Error('flush unsub failed'); + } + return [{ unsubscribe: jest.fn().mockResolvedValue(undefined) }]; + }, + ); + + await service.subscribe(SUB_OPTS); + await service.unsubscribe(SUB_OPTS); + await service.subscribe(opts1h); + + expect(errorListener).toHaveBeenCalledWith({ + channel: EXPECTED_CHANNEL, + error: expect.stringContaining('flush unsub failed'), + operation: 'unsubscribe', + }); + + jest.advanceTimersByTime(1000); + await completeAsyncOperations(); + + expect( + mocks.getSubscriptionsByChannel.mock.calls.length, + ).toBeGreaterThan(1); + expect(mocks.forceReconnection).not.toHaveBeenCalled(); + }); + }); + + it('should skip grace-period unsubscribe when a new subscriber arrives first', async () => { + await withService(async ({ service, mocks }) => { + const mockUnsub = jest.fn().mockResolvedValue(undefined); + mocks.getSubscriptionsByChannel.mockReturnValue([ + { unsubscribe: mockUnsub }, + ]); + + await service.subscribe(SUB_OPTS); + await service.unsubscribe(SUB_OPTS); + + let releaseConnect!: () => void; + mocks.connect.mockImplementation( + () => + new Promise((resolve) => { + releaseConnect = resolve; + }), + ); + mocks.channelHasSubscription.mockReturnValue(true); + + const subscribePromise = service.subscribe(SUB_OPTS); + + jest.advanceTimersByTime(3000); + await flushPromises(); + + releaseConnect(); + mocks.connect.mockResolvedValue(undefined); + await subscribePromise; + await completeAsyncOperations(); + + expect(mockUnsub).not.toHaveBeenCalled(); + }); + }); + }); + + // =========================================================================== + // Unsubscribe retry + // =========================================================================== + + describe('unsubscribe retry', () => { + it('should succeed on a later retry without forcing reconnection', async () => { + await withService(async ({ service, mocks }) => { + const mockUnsub = jest + .fn() + .mockRejectedValueOnce(new Error('transient')) + .mockResolvedValue(undefined); + + mocks.getSubscriptionsByChannel.mockReturnValue([ + { unsubscribe: mockUnsub }, + ]); + + await service.subscribe(SUB_OPTS); + await service.unsubscribe(SUB_OPTS); + + jest.advanceTimersByTime(3000); + await completeAsyncOperations(); + + jest.advanceTimersByTime(1000); + await completeAsyncOperations(); + + expect(mockUnsub).toHaveBeenCalledTimes(2); + expect(mocks.forceReconnection).not.toHaveBeenCalled(); + }); + }); + + it('should not retry after destroy aborts in-flight unsubscribe', async () => { + await withService(async ({ service, mocks }) => { + let releaseUnsub!: (error?: Error) => void; + mocks.getSubscriptionsByChannel.mockReturnValue([ + { + unsubscribe: (): Promise => + new Promise((_resolve, reject) => { + releaseUnsub = reject; + }), + }, + ]); + + await service.subscribe(SUB_OPTS); + await service.unsubscribe(SUB_OPTS); + + jest.advanceTimersByTime(3000); + await flushPromises(); + + service.destroy(); + releaseUnsub(new Error('ws gone')); + await completeAsyncOperations(); + + jest.advanceTimersByTime(1000); + await completeAsyncOperations(); + + expect(mocks.getSubscriptionsByChannel).toHaveBeenCalledTimes(1); + expect(mocks.forceReconnection).not.toHaveBeenCalled(); + }); + }); + + it('should force reconnection when unsubscribe retries are exhausted', async () => { + await withService(async ({ service, mocks }) => { + mocks.getSubscriptionsByChannel.mockImplementation(() => { + throw new Error('ws gone'); + }); + mocks.forceReconnection.mockRejectedValue( + new Error('reconnect failed'), + ); + + await service.subscribe(SUB_OPTS); + await service.unsubscribe(SUB_OPTS); + + jest.advanceTimersByTime(3000); + await completeAsyncOperations(); + jest.advanceTimersByTime(1000); + await completeAsyncOperations(); + jest.advanceTimersByTime(2000); + await completeAsyncOperations(); + jest.advanceTimersByTime(4000); + await completeAsyncOperations(); + + expect(mocks.forceReconnection).toHaveBeenCalledTimes(1); + }); + }); + + it('should abort in-flight unsubscribe retry when destroy is called during backoff', async () => { + await withService(async ({ service, mocks }) => { + mocks.getSubscriptionsByChannel.mockImplementation(() => { + throw new Error('ws gone'); + }); + + await service.subscribe(SUB_OPTS); + await service.unsubscribe(SUB_OPTS); + + jest.advanceTimersByTime(3000); + await completeAsyncOperations(); + + service.destroy(); + + jest.advanceTimersByTime(10000); + await completeAsyncOperations(); + + expect(mocks.forceReconnection).not.toHaveBeenCalled(); + }); + }); + + it('should not resurrect a channel or force reconnection when destroy races the grace-period unsubscribe', async () => { + await withService(async ({ service, mocks }) => { + mocks.getSubscriptionsByChannel.mockImplementation(() => { + throw new Error('ws gone'); + }); + + await service.subscribe(SUB_OPTS); + await service.unsubscribe(SUB_OPTS); + + // Grace timer fires: #performUnsubscribe starts and parks on the mutex. + jest.advanceTimersByTime(3000); + + // destroy() runs while #performUnsubscribe is still awaiting the mutex, + // clearing the channel map before the retry is scheduled. + service.destroy(); + await completeAsyncOperations(); + + // Drive every backoff step. A resurrected retry loop would exhaust its + // backoff and force a reconnection on the shared WebSocket. + jest.advanceTimersByTime(1000); + await completeAsyncOperations(); + jest.advanceTimersByTime(2000); + await completeAsyncOperations(); + jest.advanceTimersByTime(4000); + await completeAsyncOperations(); + + expect(mocks.forceReconnection).not.toHaveBeenCalled(); + }); + }); + }); + + // =========================================================================== + // Reference Counting + // =========================================================================== + + describe('reference counting', () => { + it('should share a single WS subscription across multiple consumers', async () => { + await withService(async ({ service, mocks }) => { + await service.subscribe(SUB_OPTS); + await service.subscribe(SUB_OPTS); + await service.subscribe(SUB_OPTS); + + // Only one WS subscribe call + expect(mocks.subscribe).toHaveBeenCalledTimes(1); + + // Unsubscribe twice — refCount goes from 3 → 1 + await service.unsubscribe(SUB_OPTS); + await service.unsubscribe(SUB_OPTS); + + jest.advanceTimersByTime(5000); + await completeAsyncOperations(); + + // Still has one consumer — no WS unsubscribe + expect(mocks.getSubscriptionsByChannel).not.toHaveBeenCalled(); + }); + }); + + it('should unsubscribe from WS when all consumers leave and grace expires', async () => { + await withService(async ({ service, mocks }) => { + const mockUnsub = jest.fn(); + mocks.getSubscriptionsByChannel.mockReturnValue([ + { unsubscribe: mockUnsub }, + ]); + + await service.subscribe(SUB_OPTS); + await service.subscribe(SUB_OPTS); + + await service.unsubscribe(SUB_OPTS); + await service.unsubscribe(SUB_OPTS); + + jest.advanceTimersByTime(3000); + await completeAsyncOperations(); + + expect(mockUnsub).toHaveBeenCalledTimes(1); + }); + }); + }); + + // =========================================================================== + // Race Condition — Per-Channel Locking + // =========================================================================== + + describe('per-channel locking', () => { + it('should serialize concurrent subscribes so refCount is correct', async () => { + await withService(async ({ service, mocks }) => { + let connectResolve!: () => void; + mocks.connect.mockImplementation( + () => + new Promise((resolve) => { + connectResolve = resolve; + }), + ); + + const p1 = service.subscribe(SUB_OPTS); + // Let the microtask tick so `connect` is called and `connectResolve` is assigned + await flushPromises(); + + const p2 = service.subscribe(SUB_OPTS); + + // p1 is waiting on connect, p2 is queued behind it via the lock + connectResolve(); + mocks.connect.mockResolvedValue(undefined); + await p1; + await p2; + + expect(mocks.subscribe).toHaveBeenCalledTimes(1); + + const mockUnsub = jest.fn(); + mocks.getSubscriptionsByChannel.mockReturnValue([ + { unsubscribe: mockUnsub }, + ]); + + // refCount must be 2 — first unsubscribe drops it to 1, no grace timer + await service.unsubscribe(SUB_OPTS); + jest.advanceTimersByTime(5000); + await completeAsyncOperations(); + expect(mockUnsub).not.toHaveBeenCalled(); + + // Second unsubscribe drops refCount to 0 → grace timer → WS unsubscribe + await service.unsubscribe(SUB_OPTS); + jest.advanceTimersByTime(3000); + await completeAsyncOperations(); + expect(mockUnsub).toHaveBeenCalledTimes(1); + }); + }); + + it('should serialize concurrent subscribe + unsubscribe so refCount never corrupts', async () => { + await withService(async ({ service, mocks }) => { + let connectResolve!: () => void; + mocks.connect.mockImplementation( + () => + new Promise((resolve) => { + connectResolve = resolve; + }), + ); + + const pSub = service.subscribe(SUB_OPTS); + await flushPromises(); + + const pUnsub = service.unsubscribe(SUB_OPTS); + + connectResolve(); + await pSub; + await pUnsub; + + // After subscribe then unsubscribe, refCount is 0 → grace timer starts + // Advance past grace period + const mockUnsub = jest.fn(); + mocks.getSubscriptionsByChannel.mockReturnValue([ + { unsubscribe: mockUnsub }, + ]); + + jest.advanceTimersByTime(3000); + await completeAsyncOperations(); + + expect(mockUnsub).toHaveBeenCalledTimes(1); + }); + }); + + it('should create a fresh WS subscription when subscribe races with grace-period unsubscribe', async () => { + await withService(async ({ service, mocks }) => { + let unsubResolve!: () => void; + const mockUnsub = jest.fn( + () => + new Promise((resolve) => { + unsubResolve = resolve; + }), + ); + mocks.getSubscriptionsByChannel.mockReturnValue([ + { unsubscribe: mockUnsub }, + ]); + + await service.subscribe(SUB_OPTS); + await service.unsubscribe(SUB_OPTS); + + jest.advanceTimersByTime(3000); + await flushPromises(); + + const subscribePromise = service.subscribe(SUB_OPTS); + unsubResolve(); + await subscribePromise; + + expect(mocks.subscribe).toHaveBeenCalledTimes(2); + }); + }); + }); + + // =========================================================================== + // Reconnect Resilience + // =========================================================================== + + describe('reconnect', () => { + it('should resubscribe active channels on WebSocket CONNECTED', async () => { + await withService(async ({ service, mocks, rootMessenger }) => { + await service.subscribe(SUB_OPTS); + mocks.subscribe.mockClear(); + mocks.channelHasSubscription.mockReturnValue(false); + + rootMessenger.publish( + 'BackendWebSocketService:connectionStateChanged', + { + ...BASE_CONNECTION_INFO, + state: WebSocketState.CONNECTED, + connectedAt: Date.now(), + reconnectAttempts: 0, + }, + ); + await completeAsyncOperations(); + + expect(mocks.subscribe).toHaveBeenCalledWith({ + channels: [EXPECTED_CHANNEL], + channelType: 'market-data.v1', + callback: expect.any(Function), + }); + }); + }); + + it('should skip resubscribe if channel already has a subscription after reconnect', async () => { + await withService(async ({ service, mocks, rootMessenger }) => { + await service.subscribe(SUB_OPTS); + mocks.subscribe.mockClear(); + mocks.channelHasSubscription.mockReturnValue(true); + + rootMessenger.publish( + 'BackendWebSocketService:connectionStateChanged', + { + ...BASE_CONNECTION_INFO, + state: WebSocketState.CONNECTED, + connectedAt: Date.now(), + reconnectAttempts: 0, + }, + ); + await completeAsyncOperations(); + + expect(mocks.subscribe).not.toHaveBeenCalled(); + }); + }); + + it('should not resubscribe channels in grace period (refCount === 0)', async () => { + await withService(async ({ service, mocks, rootMessenger }) => { + await service.subscribe(SUB_OPTS); + await service.unsubscribe(SUB_OPTS); + + // Channel is now in grace period (refCount === 0, timer running) + mocks.subscribe.mockClear(); + mocks.channelHasSubscription.mockReturnValue(false); + + rootMessenger.publish( + 'BackendWebSocketService:connectionStateChanged', + { + ...BASE_CONNECTION_INFO, + state: WebSocketState.CONNECTED, + connectedAt: Date.now(), + reconnectAttempts: 0, + }, + ); + await completeAsyncOperations(); + + expect(mocks.subscribe).not.toHaveBeenCalled(); + }); + }); + + it('should recreate WS subscription when re-subscribing during grace period after disconnect', async () => { + await withService( + async ({ service, mocks, messenger, rootMessenger }) => { + // 1. Subscribe — creates WS subscription, refCount = 1 + await service.subscribe(SUB_OPTS); + + // 2. Unsubscribe — refCount = 0, grace-period timer starts + await service.unsubscribe(SUB_OPTS); + + // 3. Disconnect — BackendWebSocketService clears all server-side + // subscriptions. channelHasSubscription now returns false. + mocks.channelHasSubscription.mockReturnValue(false); + rootMessenger.publish( + 'BackendWebSocketService:connectionStateChanged', + { + ...BASE_CONNECTION_INFO, + state: WebSocketState.DISCONNECTED, + connectedAt: undefined, + reconnectAttempts: 0, + }, + ); + await completeAsyncOperations(); + + // 4. Reconnect — resubscribeActiveChannels skips this channel + // because refCount is 0 (correct behaviour). + mocks.subscribe.mockClear(); + mocks.connect.mockClear(); + rootMessenger.publish( + 'BackendWebSocketService:connectionStateChanged', + { + ...BASE_CONNECTION_INFO, + state: WebSocketState.CONNECTED, + connectedAt: Date.now(), + reconnectAttempts: 1, + }, + ); + await completeAsyncOperations(); + expect(mocks.subscribe).not.toHaveBeenCalled(); + + // 5. User re-subscribes BEFORE grace timer fires. + // The grace-period branch cancels the timer and bumps refCount, + // but the underlying WS subscription no longer exists. + // The fix must detect this and create a fresh WS subscription. + mocks.subscribe.mockClear(); + mocks.connect.mockClear(); + await service.subscribe(SUB_OPTS); + + expect(mocks.connect).toHaveBeenCalledTimes(1); + expect(mocks.subscribe).toHaveBeenCalledWith({ + channels: [EXPECTED_CHANNEL], + channelType: 'market-data.v1', + callback: expect.any(Function), + }); + + // 6. Verify bar updates are delivered through the new subscription. + const capturedCallback = mocks.subscribe.mock.calls[0][0].callback; + const barListener = jest.fn(); + messenger.subscribe('OHLCVService:barUpdated', barListener); + + capturedCallback({ + data: { + timestamp: 200, + open: 10, + high: 20, + low: 5, + close: 15, + volume: 1000, + }, + timestamp: Date.now(), + }); + + expect(barListener).toHaveBeenCalledWith({ + channel: EXPECTED_CHANNEL, + bar: { + timestamp: 200, + open: 10, + high: 20, + low: 5, + close: 15, + volume: 1000, + }, + }); + }, + ); + }); + + it('should deliver bar updates via resubscribed channel callback', async () => { + await withService( + async ({ service, mocks, messenger, rootMessenger }) => { + await service.subscribe(SUB_OPTS); + mocks.subscribe.mockClear(); + mocks.channelHasSubscription.mockReturnValue(false); + + rootMessenger.publish( + 'BackendWebSocketService:connectionStateChanged', + { + ...BASE_CONNECTION_INFO, + state: WebSocketState.CONNECTED, + connectedAt: Date.now(), + reconnectAttempts: 0, + }, + ); + await completeAsyncOperations(); + + const resubscribeCallback = mocks.subscribe.mock.calls[0][0].callback; + const barListener = jest.fn(); + messenger.subscribe('OHLCVService:barUpdated', barListener); + + resubscribeCallback({ + data: { + timestamp: 100, + open: 1, + high: 2, + low: 0.5, + close: 1.5, + volume: 999, + }, + timestamp: Date.now(), + }); + + expect(barListener).toHaveBeenCalledWith({ + channel: EXPECTED_CHANNEL, + bar: { + timestamp: 100, + open: 1, + high: 2, + low: 0.5, + close: 1.5, + volume: 999, + }, + }); + }, + ); + }); + + it('should publish chainStatusChanged down on DISCONNECTED', async () => { + await withService(async ({ mocks, messenger, rootMessenger }) => { + const statusListener = jest.fn(); + messenger.subscribe('OHLCVService:chainStatusChanged', statusListener); + + // Simulate a system notification marking a chain as up + const systemCallback = getSystemNotificationCallback(mocks); + systemCallback({ + event: 'system-notification', + channel: 'system-notifications.v1.market-data.v1', + data: { chainIds: ['eip155:8453'], status: 'up' }, + timestamp: Date.now(), + } as ServerNotificationMessage); + + statusListener.mockClear(); + + rootMessenger.publish( + 'BackendWebSocketService:connectionStateChanged', + { + ...BASE_CONNECTION_INFO, + state: WebSocketState.DISCONNECTED, + connectedAt: undefined, + reconnectAttempts: 0, + }, + ); + await completeAsyncOperations(); + + expect(statusListener).toHaveBeenCalledWith( + expect.objectContaining({ + chainIds: ['eip155:8453'], + status: 'down', + }), + ); + }); + }); + + it('should not publish chainStatusChanged down when no chains are tracked', async () => { + await withService(async ({ messenger, rootMessenger }) => { + const statusListener = jest.fn(); + messenger.subscribe('OHLCVService:chainStatusChanged', statusListener); + + rootMessenger.publish( + 'BackendWebSocketService:connectionStateChanged', + { + ...BASE_CONNECTION_INFO, + state: WebSocketState.DISCONNECTED, + connectedAt: undefined, + reconnectAttempts: 0, + }, + ); + await completeAsyncOperations(); + + expect(statusListener).not.toHaveBeenCalled(); + }); + }); + + it('should ignore non-terminal connection states', async () => { + await withService(async ({ service, mocks, rootMessenger }) => { + await service.subscribe(SUB_OPTS); + mocks.connect.mockClear(); + mocks.subscribe.mockClear(); + + rootMessenger.publish( + 'BackendWebSocketService:connectionStateChanged', + { + ...BASE_CONNECTION_INFO, + state: WebSocketState.CONNECTING, + }, + ); + await completeAsyncOperations(); + + expect(mocks.connect).not.toHaveBeenCalled(); + expect(mocks.subscribe).not.toHaveBeenCalled(); + }); + }); + }); + + // =========================================================================== + // System Notifications + // =========================================================================== + + describe('system notifications', () => { + it('should forward chain-down notifications via chainStatusChanged event', async () => { + await withService(async ({ mocks, messenger }) => { + const statusListener = jest.fn(); + messenger.subscribe('OHLCVService:chainStatusChanged', statusListener); + + const systemCallback = getSystemNotificationCallback(mocks); + systemCallback({ + event: 'system-notification', + channel: 'system-notifications.v1.market-data.v1', + data: { chainIds: ['eip155:8453'], status: 'down' }, + timestamp: 1776364071003, + } as ServerNotificationMessage); + + expect(statusListener).toHaveBeenCalledWith({ + chainIds: ['eip155:8453'], + status: 'down', + timestamp: 1776364071003, + }); + }); + }); + + it('should forward chain-up notifications', async () => { + await withService(async ({ mocks, messenger }) => { + const statusListener = jest.fn(); + messenger.subscribe('OHLCVService:chainStatusChanged', statusListener); + + const systemCallback = getSystemNotificationCallback(mocks); + systemCallback({ + event: 'system-notification', + channel: 'system-notifications.v1.market-data.v1', + data: { chainIds: ['eip155:1', 'eip155:137'], status: 'up' }, + timestamp: 1776364071003, + } as ServerNotificationMessage); + + expect(statusListener).toHaveBeenCalledWith({ + chainIds: ['eip155:1', 'eip155:137'], + status: 'up', + timestamp: 1776364071003, + }); + }); + }); + + it('should throw on invalid system notification data', async () => { + await withService(async ({ mocks }) => { + const systemCallback = getSystemNotificationCallback(mocks); + + expect(() => + systemCallback({ + event: 'system-notification', + channel: 'system-notifications.v1.market-data.v1', + data: { invalid: true }, + timestamp: Date.now(), + } as unknown as ServerNotificationMessage), + ).toThrow('Invalid system notification data'); + }); + }); + }); + + // =========================================================================== + // Error Paths + // =========================================================================== + + describe('error paths', () => { + it('should publish subscriptionError and retry when unsubscribe fails', async () => { + await withService(async ({ service, mocks, messenger }) => { + mocks.getSubscriptionsByChannel.mockImplementation(() => { + throw new Error('ws gone'); + }); + + const errorListener = jest.fn(); + messenger.subscribe('OHLCVService:subscriptionError', errorListener); + + await service.subscribe(SUB_OPTS); + await service.unsubscribe(SUB_OPTS); + + jest.advanceTimersByTime(3000); + await completeAsyncOperations(); + + expect(errorListener).toHaveBeenCalledWith({ + channel: EXPECTED_CHANNEL, + error: expect.stringContaining('ws gone'), + operation: 'unsubscribe', + }); + expect(mocks.forceReconnection).not.toHaveBeenCalled(); + + jest.advanceTimersByTime(1000); + await completeAsyncOperations(); + expect(mocks.getSubscriptionsByChannel).toHaveBeenCalledTimes(2); + + jest.advanceTimersByTime(2000); + await completeAsyncOperations(); + expect(mocks.getSubscriptionsByChannel).toHaveBeenCalledTimes(3); + + jest.advanceTimersByTime(4000); + await completeAsyncOperations(); + expect(mocks.getSubscriptionsByChannel).toHaveBeenCalledTimes(4); + expect(mocks.forceReconnection).toHaveBeenCalledTimes(1); + }); + }); + + it('should cancel unsubscribe retry when the same channel is subscribed again', async () => { + await withService(async ({ service, mocks }) => { + mocks.getSubscriptionsByChannel.mockImplementation(() => { + throw new Error('ws gone'); + }); + mocks.channelHasSubscription.mockReturnValue(true); + + await service.subscribe(SUB_OPTS); + await service.unsubscribe(SUB_OPTS); + + jest.advanceTimersByTime(3000); + await completeAsyncOperations(); + + mocks.subscribe.mockClear(); + mocks.connect.mockClear(); + await service.subscribe(SUB_OPTS); + + expect(mocks.connect).not.toHaveBeenCalled(); + expect(mocks.subscribe).not.toHaveBeenCalled(); + + jest.advanceTimersByTime(7000); + await completeAsyncOperations(); + expect(mocks.forceReconnection).not.toHaveBeenCalled(); + }); + }); + + it('should flush other grace channels when reusing a channel pending unsubscribe retry', async () => { + const opts1h: OHLCVSubscriptionOptions = { + ...SUB_OPTS, + interval: '1h', + }; + const channel1h = + 'market-data.v1.eip155:8453/erc20:0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913.1h.usd'; + + await withService(async ({ service, mocks }) => { + const mockUnsub1h = jest.fn().mockResolvedValue(undefined); + + mocks.getSubscriptionsByChannel.mockImplementation(() => { + throw new Error('ws gone'); + }); + + await service.subscribe(SUB_OPTS); + await service.unsubscribe(SUB_OPTS); + + jest.advanceTimersByTime(3000); + await completeAsyncOperations(); + + mocks.getSubscriptionsByChannel.mockImplementation( + (channel: string) => { + if (channel === channel1h) { + return [{ unsubscribe: mockUnsub1h }]; + } + throw new Error('ws gone'); + }, + ); + + await service.subscribe(opts1h); + await service.unsubscribe(opts1h); + + mocks.getSubscriptionsByChannel.mockClear(); + mockUnsub1h.mockClear(); + mocks.channelHasSubscription.mockReturnValue(true); + mocks.connect.mockClear(); + mocks.subscribe.mockClear(); + + await service.subscribe(SUB_OPTS); + + expect(mocks.getSubscriptionsByChannel).toHaveBeenCalledWith(channel1h); + expect(mockUnsub1h).toHaveBeenCalledTimes(1); + expect(mocks.connect).not.toHaveBeenCalled(); + expect(mocks.subscribe).not.toHaveBeenCalled(); + }); + }); + + it('should recreate WS subscription when retry is cancelled but server subscription is gone', async () => { + await withService(async ({ service, mocks }) => { + mocks.getSubscriptionsByChannel.mockImplementation(() => { + throw new Error('ws gone'); + }); + + await service.subscribe(SUB_OPTS); + await service.unsubscribe(SUB_OPTS); + + jest.advanceTimersByTime(3000); + await completeAsyncOperations(); + + mocks.channelHasSubscription.mockReturnValue(false); + mocks.getSubscriptionsByChannel.mockReturnValue([ + { unsubscribe: jest.fn().mockResolvedValue(undefined) }, + ]); + mocks.subscribe.mockClear(); + mocks.connect.mockClear(); + + await service.subscribe(SUB_OPTS); + + expect(mocks.connect).toHaveBeenCalledTimes(1); + expect(mocks.subscribe).toHaveBeenCalledTimes(1); + }); + }); + + it('should delete failed-cleanup channels on reconnect', async () => { + await withService(async ({ service, mocks, rootMessenger }) => { + mocks.getSubscriptionsByChannel.mockImplementation(() => { + throw new Error('ws gone'); + }); + + await service.subscribe(SUB_OPTS); + await service.unsubscribe(SUB_OPTS); + + jest.advanceTimersByTime(3000); + await completeAsyncOperations(); + + mocks.subscribe.mockClear(); + rootMessenger.publish( + 'BackendWebSocketService:connectionStateChanged', + { + ...BASE_CONNECTION_INFO, + state: WebSocketState.CONNECTED, + connectedAt: Date.now(), + reconnectAttempts: 1, + }, + ); + await completeAsyncOperations(); + + expect(mocks.subscribe).not.toHaveBeenCalled(); + }); + }); + + it('should clean up channel entry when subscribe fails during grace-period fall-through so subsequent subscribes work', async () => { + await withService(async ({ service, mocks, messenger }) => { + // 1. Subscribe — creates WS subscription, refCount = 1 + await service.subscribe(SUB_OPTS); + + // 2. Unsubscribe — refCount = 0, grace-period timer starts + await service.unsubscribe(SUB_OPTS); + + // 3. Disconnect — channelHasSubscription returns false + mocks.channelHasSubscription.mockReturnValue(false); + + // 4. Re-subscribe during grace period — grace branch detects WS + // subscription is gone and falls through to the try block. + // Make connect() throw to simulate a network failure. + mocks.connect.mockRejectedValueOnce(new Error('network down')); + mocks.subscribe.mockClear(); + mocks.connect.mockClear(); + + const errorListener = jest.fn(); + messenger.subscribe('OHLCVService:subscriptionError', errorListener); + + await service.subscribe(SUB_OPTS); + + expect(errorListener).toHaveBeenCalledWith({ + channel: EXPECTED_CHANNEL, + error: expect.stringContaining('network down'), + operation: 'subscribe', + }); + expect(mocks.forceReconnection).not.toHaveBeenCalled(); + + // 5. Now the critical assertion: a subsequent subscribe() must NOT + // silently increment a stale refCount. It must attempt a fresh + // WS subscription. + mocks.connect.mockResolvedValue(undefined); + mocks.subscribe.mockClear(); + + await service.subscribe(SUB_OPTS); + + expect(mocks.subscribe).toHaveBeenCalledWith({ + channels: [EXPECTED_CHANNEL], + channelType: 'market-data.v1', + callback: expect.any(Function), + }); + }); + }); + + it('should log and continue when resubscription fails for a channel', async () => { + await withService(async ({ service, mocks, rootMessenger }) => { + await service.subscribe(SUB_OPTS); + mocks.subscribe.mockClear(); + mocks.channelHasSubscription.mockReturnValue(false); + mocks.subscribe.mockRejectedValueOnce(new Error('resubscribe fail')); + + rootMessenger.publish( + 'BackendWebSocketService:connectionStateChanged', + { + ...BASE_CONNECTION_INFO, + state: WebSocketState.CONNECTED, + connectedAt: Date.now(), + reconnectAttempts: 1, + }, + ); + await completeAsyncOperations(); + + // Should have attempted but failed silently + expect(mocks.subscribe).toHaveBeenCalledTimes(1); + }); + }); + }); + + // =========================================================================== + // Reconnect + Concurrent Mutation Safety + // =========================================================================== + + describe('resubscribe holds mutex to prevent concurrent mutation', () => { + it('should block unsubscribe until resubscription completes, preventing orphaned WS subscriptions', async () => { + await withService(async ({ service, mocks, rootMessenger }) => { + await service.subscribe(SUB_OPTS); + mocks.subscribe.mockClear(); + mocks.channelHasSubscription.mockReturnValue(false); + + // Make the WS subscribe during reconnect take time so we can + // attempt a concurrent unsubscribe while it's in progress. + let resubResolve!: () => void; + mocks.subscribe.mockImplementation( + () => + new Promise((resolve) => { + resubResolve = resolve; + }), + ); + + // Trigger reconnect — this calls #resubscribeActiveChannels which + // now holds the mutex across the entire loop. + rootMessenger.publish( + 'BackendWebSocketService:connectionStateChanged', + { + ...BASE_CONNECTION_INFO, + state: WebSocketState.CONNECTED, + connectedAt: Date.now(), + reconnectAttempts: 1, + }, + ); + await flushPromises(); + + // Concurrent unsubscribe — must queue behind the mutex. + const unsubPromise = service.unsubscribe(SUB_OPTS); + + // The unsubscribe hasn't run yet because the mutex is held. + // Complete the WS resubscription. + resubResolve(); + await flushPromises(); + await unsubPromise; + + // refCount was 1 at reconnect time; after resubscribe completes + // the queued unsubscribe drops it to 0 and starts the grace timer. + const mockUnsub = jest.fn(); + mocks.getSubscriptionsByChannel.mockReturnValue([ + { unsubscribe: mockUnsub }, + ]); + + jest.advanceTimersByTime(3000); + await completeAsyncOperations(); + + // The grace-period unsubscribe fires cleanly — no orphaned subscription. + expect(mockUnsub).toHaveBeenCalledTimes(1); + }); + }); + }); + + // =========================================================================== + // Destroy + // =========================================================================== + + describe('destroy', () => { + it('should clear grace-period and retry timers and remove channel callback', async () => { + await withService(async ({ service, mocks }) => { + const mockUnsub = jest.fn(); + mocks.getSubscriptionsByChannel.mockReturnValue([ + { unsubscribe: mockUnsub }, + ]); + + await service.subscribe(SUB_OPTS); + await service.unsubscribe(SUB_OPTS); + + service.destroy(); + + jest.advanceTimersByTime(10000); + await completeAsyncOperations(); + + expect(mockUnsub).not.toHaveBeenCalled(); + expect(mocks.forceReconnection).not.toHaveBeenCalled(); + + expect(mocks.removeChannelCallback).toHaveBeenCalledWith( + 'system-notifications.v1.market-data.v1', + ); + }); + }); + }); +}); diff --git a/packages/core-backend/src/ws/ohlcv/OHLCVService.ts b/packages/core-backend/src/ws/ohlcv/OHLCVService.ts new file mode 100644 index 00000000000..75235c35751 --- /dev/null +++ b/packages/core-backend/src/ws/ohlcv/OHLCVService.ts @@ -0,0 +1,690 @@ +/** + * OHLCV Service for real-time candlestick data streaming via WebSocket. + * + * Wraps {@link BackendWebSocketService} through the messenger pattern to + * provide subscribe/unsubscribe semantics for OHLCV market-data channels. + * Includes reference counting, grace-period unsubscribe, idempotency checks, + * chain-status forwarding, and automatic resubscription on reconnect. + */ + +import type { + TraceCallback, + TraceContext, + TraceRequest, +} from '@metamask/controller-utils'; +import type { Messenger } from '@metamask/messenger'; +import { Mutex } from 'async-mutex'; +import { handleAll, IterableBackoff, retry } from 'cockatiel'; + +import { projectLogger, createModuleLogger } from '../../logger.js'; +import type { BackendWebSocketServiceMethodActions } from '../BackendWebSocketService-method-action-types.js'; +import type { + WebSocketConnectionInfo, + BackendWebSocketServiceConnectionStateChangedEvent, + ServerNotificationMessage, +} from '../BackendWebSocketService.js'; +import { WebSocketState } from '../BackendWebSocketService.js'; +import type { OHLCVServiceMethodActions } from './OHLCVService-method-action-types.js'; +import type { OHLCVBar, OHLCVSubscriptionOptions } from './types.js'; + +// ============================================================================= +// Constants +// ============================================================================= + +const SERVICE_NAME = 'OHLCVService'; + +const log = createModuleLogger(projectLogger, SERVICE_NAME); + +const MESSENGER_EXPOSED_METHODS = ['subscribe', 'unsubscribe'] as const; + +const SUBSCRIPTION_NAMESPACE = 'market-data.v1'; + +const SYSTEM_NOTIFICATIONS_CHANNEL = `system-notifications.v1.${SUBSCRIPTION_NAMESPACE}`; + +/** Delay before actually unsubscribing from a channel after refCount reaches 0. */ +const GRACE_PERIOD_MS = 3_000; + +/** Backoff delays between failed WebSocket unsubscribe attempts. */ +const UNSUB_RETRY_DELAYS_MS = [1_000, 2_000, 4_000] as const; + +const unsubRetryPolicy = retry(handleAll, { + // Cockatiel stops retrying once the failure index reaches `maxAttempts`, so + // `length` here yields one initial attempt plus three delayed retries (4 total). + maxAttempts: UNSUB_RETRY_DELAYS_MS.length, + backoff: new IterableBackoff([...UNSUB_RETRY_DELAYS_MS]), +}); + +// ============================================================================= +// Types — Channel Tracking +// ============================================================================= + +type ChannelEntry = { + refCount: number; + gracePeriodTimer?: ReturnType; + retryAbort?: AbortController; +}; + +// ============================================================================= +// Types — System Notifications +// ============================================================================= + +/** + * System notification data for chain status updates on market-data channels. + */ +export type OHLCVSystemNotificationData = { + chainIds: string[]; + status: 'down' | 'up'; + timestamp?: number; +}; + +// ============================================================================= +// Types — Service Options +// ============================================================================= + +/** + * Configuration options for the OHLCV service. + */ +export type OHLCVServiceOptions = { + /** Optional callback to trace performance of OHLCV operations (default: no-op) */ + traceFn?: TraceCallback; +}; + +// ============================================================================= +// Action and Event Types +// ============================================================================= + +export type OHLCVServiceActions = OHLCVServiceMethodActions; + +export const OHLCV_SERVICE_ALLOWED_ACTIONS = [ + 'BackendWebSocketService:connect', + 'BackendWebSocketService:forceReconnection', + 'BackendWebSocketService:subscribe', + 'BackendWebSocketService:getConnectionInfo', + 'BackendWebSocketService:channelHasSubscription', + 'BackendWebSocketService:getSubscriptionsByChannel', + 'BackendWebSocketService:findSubscriptionsByChannelPrefix', + 'BackendWebSocketService:addChannelCallback', + 'BackendWebSocketService:removeChannelCallback', +] as const; + +export const OHLCV_SERVICE_ALLOWED_EVENTS = [ + 'BackendWebSocketService:connectionStateChanged', +] as const; + +export type AllowedActions = BackendWebSocketServiceMethodActions; + +// Events published by OHLCVService + +export type OHLCVServiceBarUpdatedEvent = { + type: `OHLCVService:barUpdated`; + payload: [{ channel: string; bar: OHLCVBar }]; +}; + +export type OHLCVServiceChainStatusChangedEvent = { + type: `OHLCVService:chainStatusChanged`; + payload: [{ chainIds: string[]; status: 'up' | 'down'; timestamp?: number }]; +}; + +export type OHLCVServiceSubscriptionErrorEvent = { + type: `OHLCVService:subscriptionError`; + payload: [{ channel: string; error: string; operation: string }]; +}; + +export type OHLCVServiceEvents = + | OHLCVServiceBarUpdatedEvent + | OHLCVServiceChainStatusChangedEvent + | OHLCVServiceSubscriptionErrorEvent; + +export type AllowedEvents = BackendWebSocketServiceConnectionStateChangedEvent; + +export type OHLCVServiceMessenger = Messenger< + typeof SERVICE_NAME, + OHLCVServiceActions | AllowedActions, + OHLCVServiceEvents | AllowedEvents +>; + +// ============================================================================= +// Main Service Class +// ============================================================================= + +/** + * Service for real-time OHLCV candlestick streaming via the backend WebSocket + * gateway. Communicates with {@link BackendWebSocketService} exclusively + * through the messenger — no direct import of the class. + * + * Features: + * - Reference counting: multiple UI consumers share one WebSocket subscription + * - Grace-period unsubscribe: reuses same-channel subs on rapid back navigation + * - Grace-period flush: immediately unsubscribes other channels on navigation + * - Unsubscribe retry: retries failed unsubs with backoff before force reconnect + * - Idempotency: duplicate subscribe calls for the same channel are no-ops + * - Reconnect resilience: resubscribes all active channels on reconnect + * - Chain-status forwarding: listens to system-notifications for chain up/down + * + */ +export class OHLCVService { + readonly name = SERVICE_NAME; + + readonly #messenger: OHLCVServiceMessenger; + + readonly #trace: TraceCallback; + + readonly #channels = new Map(); + + readonly #mutex = new Mutex(); + + readonly #chainsUp = new Set(); + + // ============================================================================= + // Constructor + // ============================================================================= + + constructor( + options: OHLCVServiceOptions & { messenger: OHLCVServiceMessenger }, + ) { + this.#messenger = options.messenger; + + this.#trace = + options.traceFn ?? + ((( + _request: TraceRequest, + fn?: (context?: TraceContext) => Result, + ) => fn?.()) as TraceCallback); + + this.#messenger.registerMethodActionHandlers( + this, + MESSENGER_EXPOSED_METHODS, + ); + + this.#messenger.subscribe( + 'BackendWebSocketService:connectionStateChanged', + // eslint-disable-next-line @typescript-eslint/no-misused-promises + (connectionInfo: WebSocketConnectionInfo) => + this.#handleWebSocketStateChange(connectionInfo), + ); + } + + /** + * Register the system-notifications channel callback. + */ + init(): void { + log('OHLCV-WS: Initializing — registering system-notifications callback'); + this.#messenger.call('BackendWebSocketService:addChannelCallback', { + channelName: SYSTEM_NOTIFICATIONS_CHANNEL, + callback: (notification: ServerNotificationMessage) => + this.#handleSystemNotification(notification), + }); + } + + // ============================================================================= + // Public — Subscribe / Unsubscribe + // ============================================================================= + + /** + * Subscribe to an OHLCV channel. If this is the first subscriber for the + * given asset/interval/currency combination a WebSocket subscription is + * created. Additional calls for the same combination only bump the reference + * count. + * + * @param options - The subscription parameters. + * @returns A promise that resolves once the subscription is established. + */ + async subscribe(options: OHLCVSubscriptionOptions): Promise { + const channel = this.#buildChannel(options); + const releaseLock = await this.#mutex.acquire(); + try { + await this.#subscribeInner(channel); + } finally { + releaseLock(); + } + } + + async #subscribeInner(channel: string): Promise { + const entry = this.#channels.get(channel); + + if (entry?.retryAbort) { + entry.retryAbort.abort(); + entry.retryAbort = undefined; + entry.refCount = 1; + + if ( + this.#messenger.call( + 'BackendWebSocketService:channelHasSubscription', + channel, + ) + ) { + await this.#flushOtherChannels(channel); + log('OHLCV-WS: Cancelled unsubscribe retry — reusing WS subscription', { + channel, + }); + return; + } + // WS subscription was lost — fall through to recreate it. + } else if (entry?.gracePeriodTimer) { + clearTimeout(entry.gracePeriodTimer); + entry.gracePeriodTimer = undefined; + log('OHLCV-WS: Cancelled grace-period unsubscribe', { + channel, + }); + + if ( + this.#messenger.call( + 'BackendWebSocketService:channelHasSubscription', + channel, + ) + ) { + await this.#flushOtherChannels(channel); + entry.refCount += 1; + log('OHLCV-WS: WS subscription still alive, bumped refCount', { + channel, + refCount: entry.refCount, + }); + return; + } + // WS subscription was lost (e.g. after disconnect/reconnect) — fall + // through to recreate it. refCount is bumped only after success below. + } else if (entry && entry.refCount > 0) { + entry.refCount += 1; + return; + } + + await this.#flushOtherChannels(channel); + + try { + await this.#messenger.call('BackendWebSocketService:connect'); + + if ( + this.#messenger.call( + 'BackendWebSocketService:channelHasSubscription', + channel, + ) + ) { + log( + 'OHLCV-WS: Channel already has WS subscription (idempotency), skipping', + { + channel, + }, + ); + this.#channels.set(channel, { refCount: 1 }); + return; + } + + await this.#messenger.call('BackendWebSocketService:subscribe', { + channels: [channel], + channelType: SUBSCRIPTION_NAMESPACE, + callback: (notification: ServerNotificationMessage) => { + this.#handleBarUpdate(channel, notification); + }, + }); + + this.#channels.set(channel, { refCount: 1 }); + log('OHLCV-WS: Subscribe succeeded — new WS subscription created', { + channel, + }); + } catch (error) { + log('OHLCV-WS: Subscription failed', { channel, error }); + this.#channels.delete(channel); + this.#messenger.publish('OHLCVService:subscriptionError', { + channel, + error: String(error), + operation: 'subscribe', + }); + } + } + + /** + * Unsubscribe from an OHLCV channel. Decrements the reference count and, + * when it reaches zero, starts a grace-period timer before actually + * unsubscribing from the WebSocket to absorb rapid navigation patterns. + * + * @param options - The subscription parameters to unsubscribe from. + * @returns A promise that resolves once the unsubscription is processed. + */ + async unsubscribe(options: OHLCVSubscriptionOptions): Promise { + const channel = this.#buildChannel(options); + const releaseLock = await this.#mutex.acquire(); + try { + await this.#unsubscribeInner(channel); + } finally { + releaseLock(); + } + } + + async #unsubscribeInner(channel: string): Promise { + const entry = this.#channels.get(channel); + + if (!entry || entry.refCount <= 0) { + return; + } + + entry.refCount -= 1; + + if (entry.refCount > 0) { + return; + } + + entry.gracePeriodTimer = setTimeout(() => { + entry.gracePeriodTimer = undefined; + this.#performUnsubscribe(channel).catch(() => { + // no-op: retry scheduling and force-reconnection are handled internally + }); + }, GRACE_PERIOD_MS); + } + + // ============================================================================= + // Private — WebSocket Subscription Helpers + // ============================================================================= + + /** + * Immediately unsubscribe other channels in grace or failed-cleanup state. + * Called while the subscribe mutex is held before opening a new channel. + * + * @param exceptChannel - Channel being subscribed; excluded from flush. + */ + async #flushOtherChannels(exceptChannel: string): Promise { + for (const [channel, channelEntry] of this.#channels.entries()) { + if (channel === exceptChannel || channelEntry.refCount > 0) { + continue; + } + + this.#clearChannelTimers(channelEntry); + log('OHLCV-WS: Flushing grace-period channel before new subscribe', { + flushedChannel: channel, + newChannel: exceptChannel, + }); + + const success = await this.#unsubscribeChannelOnServer(channel); + if (success) { + this.#channels.delete(channel); + } else { + this.#scheduleUnsubscribeRetry(channel); + } + } + } + + #clearChannelTimers(entry: ChannelEntry): void { + if (entry.gracePeriodTimer) { + clearTimeout(entry.gracePeriodTimer); + entry.gracePeriodTimer = undefined; + } + entry.retryAbort?.abort(); + entry.retryAbort = undefined; + } + + async #unsubscribeChannelOnServer(channel: string): Promise { + try { + const subscriptions = this.#messenger.call( + 'BackendWebSocketService:getSubscriptionsByChannel', + channel, + ); + + for (const sub of subscriptions) { + await sub.unsubscribe(); + } + return true; + } catch (error) { + log('OHLCV-WS: Unsubscription failed', { channel, error }); + this.#messenger.publish('OHLCVService:subscriptionError', { + channel, + error: String(error), + operation: 'unsubscribe', + }); + return false; + } + } + + #scheduleUnsubscribeRetry(channel: string): void { + const entry = this.#channels.get(channel); + if (!entry) { + // The channel was removed (e.g. destroy() or reconnect cleanup) while the + // unsubscribe was in flight. Do not resurrect it or start a new retry loop + // with an AbortController that teardown can no longer cancel — that could + // force a reconnection on the shared WebSocket after teardown. + return; + } + + entry.retryAbort?.abort(); + entry.retryAbort = new AbortController(); + + const { signal } = entry.retryAbort; + + // eslint-disable-next-line @typescript-eslint/no-floating-promises + this.#runUnsubRetryLoop(channel, signal); + } + + async #runUnsubRetryLoop( + channel: string, + signal: AbortSignal, + ): Promise { + try { + await unsubRetryPolicy.execute(async () => { + const releaseLock = await this.#mutex.acquire(); + try { + const current = this.#channels.get(channel); + if (current && current.refCount > 0) { + return; + } + + const success = await this.#unsubscribeChannelOnServer(channel); + if (!success) { + throw new Error('unsubscribe failed'); + } + + this.#channels.delete(channel); + log('OHLCV-WS: WS unsubscribe completed', { channel }); + } finally { + releaseLock(); + } + }, signal); + } catch { + if (signal.aborted) { + return; + } + + log('OHLCV-WS: Unsubscribe retries exhausted — forcing reconnection', { + channel, + }); + this.#channels.delete(channel); + // Last resort: reconnects the shared BackendWebSocketService instance + // (AccountActivityService and other consumers share this connection). + // They resubscribe on CONNECTED; OHLCV only resubscribes refCount > 0. + await this.#messenger + .call('BackendWebSocketService:forceReconnection') + .catch(() => { + // no-op + }); + } + } + + async #performUnsubscribe(channel: string): Promise { + const releaseLock = await this.#mutex.acquire(); + try { + const entry = this.#channels.get(channel); + if (entry && entry.refCount > 0) { + log( + 'OHLCV-WS: Skipping unsubscribe — new subscriber arrived while queued', + { channel, refCount: entry.refCount }, + ); + return; + } + + log('OHLCV-WS: Grace period expired — performing actual WS unsubscribe', { + channel, + }); + + this.#clearChannelTimers(this.#channels.get(channel) ?? { refCount: 0 }); + } finally { + releaseLock(); + } + + this.#scheduleUnsubscribeRetry(channel); + } + + /** + * Resubscribe all channels that were active before a disconnect. + * Called when WebSocket transitions to CONNECTED. + */ + async #resubscribeActiveChannels(): Promise { + const releaseLock = await this.#mutex.acquire(); + try { + const channelCount = this.#channels.size; + log('OHLCV-WS: Resubscribing active channels after reconnect', { + count: channelCount, + }); + + for (const [channel, entry] of [...this.#channels.entries()]) { + if (entry.refCount === 0) { + this.#clearChannelTimers(entry); + this.#channels.delete(channel); + continue; + } + + try { + if ( + this.#messenger.call( + 'BackendWebSocketService:channelHasSubscription', + channel, + ) + ) { + log( + 'OHLCV-WS: Channel already subscribed on server, skipping resubscribe', + { + channel, + }, + ); + continue; + } + + await this.#messenger.call('BackendWebSocketService:subscribe', { + channels: [channel], + channelType: SUBSCRIPTION_NAMESPACE, + callback: (notification: ServerNotificationMessage) => { + this.#handleBarUpdate(channel, notification); + }, + }); + log('OHLCV-WS: Resubscription succeeded', { channel }); + } catch (error) { + log('OHLCV-WS: Resubscription failed for channel', { + channel, + error, + }); + } + } + } finally { + releaseLock(); + } + } + + // ============================================================================= + // Private — Message Handlers + // ============================================================================= + + #handleBarUpdate( + channel: string, + notification: ServerNotificationMessage, + ): void { + const bar = notification.data as OHLCVBar; + + // eslint-disable-next-line @typescript-eslint/no-floating-promises + this.#trace( + { + name: `${SERVICE_NAME} Bar Update`, + data: { channel, timestamp: bar.timestamp }, + tags: { service: SERVICE_NAME }, + }, + () => { + this.#messenger.publish('OHLCVService:barUpdated', { channel, bar }); + }, + ); + } + + #handleSystemNotification(notification: ServerNotificationMessage): void { + const data = notification.data as OHLCVSystemNotificationData; + const { timestamp } = notification; + + if (!data.chainIds || !Array.isArray(data.chainIds) || !data.status) { + throw new Error( + 'Invalid system notification data: missing chainIds or status', + ); + } + + if (data.status === 'up') { + for (const chainId of data.chainIds) { + this.#chainsUp.add(chainId); + } + } else { + for (const chainId of data.chainIds) { + this.#chainsUp.delete(chainId); + } + } + + this.#messenger.publish('OHLCVService:chainStatusChanged', { + chainIds: data.chainIds, + status: data.status, + timestamp, + }); + + log(`OHLCV-WS: Chain status change: ${data.status}`, { + chains: data.chainIds, + status: data.status, + }); + } + + async #handleWebSocketStateChange( + connectionInfo: WebSocketConnectionInfo, + ): Promise { + const { state } = connectionInfo; + + if (state === WebSocketState.CONNECTED) { + await this.#resubscribeActiveChannels(); + } else if (state === WebSocketState.DISCONNECTED) { + const chainsToMarkDown = Array.from(this.#chainsUp); + + if (chainsToMarkDown.length > 0) { + this.#messenger.publish('OHLCVService:chainStatusChanged', { + chainIds: chainsToMarkDown, + status: 'down', + timestamp: Date.now(), + }); + + log( + 'OHLCV-WS: WebSocket disconnection — marked tracked chains as down', + { + count: chainsToMarkDown.length, + chains: chainsToMarkDown, + }, + ); + + this.#chainsUp.clear(); + } + } + } + + // ============================================================================= + // Private — Utility + // ============================================================================= + + #buildChannel(options: OHLCVSubscriptionOptions): string { + return `${SUBSCRIPTION_NAMESPACE}.${options.assetId}.${options.interval}.${options.currency}`; + } + + // ============================================================================= + // Public — Cleanup + // ============================================================================= + + /** + * Destroy the service and clean up all resources. + */ + destroy(): void { + for (const entry of this.#channels.values()) { + this.#clearChannelTimers(entry); + } + this.#channels.clear(); + this.#chainsUp.clear(); + + this.#messenger.call( + 'BackendWebSocketService:removeChannelCallback', + SYSTEM_NOTIFICATIONS_CHANNEL, + ); + } +} diff --git a/packages/core-backend/src/ws/ohlcv/index.ts b/packages/core-backend/src/ws/ohlcv/index.ts new file mode 100644 index 00000000000..7f99a9178c9 --- /dev/null +++ b/packages/core-backend/src/ws/ohlcv/index.ts @@ -0,0 +1,18 @@ +export { OHLCVService } from './OHLCVService.js'; +export { + OHLCV_SERVICE_ALLOWED_ACTIONS, + OHLCV_SERVICE_ALLOWED_EVENTS, +} from './OHLCVService.js'; +export type { + OHLCVSystemNotificationData, + OHLCVServiceOptions, + OHLCVServiceActions, + AllowedActions as OHLCVServiceAllowedActions, + OHLCVServiceBarUpdatedEvent, + OHLCVServiceChainStatusChangedEvent, + OHLCVServiceSubscriptionErrorEvent, + OHLCVServiceEvents, + AllowedEvents as OHLCVServiceAllowedEvents, + OHLCVServiceMessenger, +} from './OHLCVService.js'; +export type { OHLCVBar, OHLCVSubscriptionOptions } from './types.js'; diff --git a/packages/core-backend/src/ws/ohlcv/types.ts b/packages/core-backend/src/ws/ohlcv/types.ts new file mode 100644 index 00000000000..b8e64caf336 --- /dev/null +++ b/packages/core-backend/src/ws/ohlcv/types.ts @@ -0,0 +1,33 @@ +/** + * OHLCV WebSocket streaming types for real-time candlestick data. + */ + +/** + * A single OHLCV candlestick bar received from the market-data WebSocket stream. + */ +export type OHLCVBar = { + /** Unix timestamp (seconds) of the candle open */ + timestamp: number; + /** Opening price */ + open: number; + /** Highest price during the candle period */ + high: number; + /** Lowest price during the candle period */ + low: number; + /** Closing price (latest) */ + close: number; + /** Trading volume during the candle period */ + volume: number; +}; + +/** + * Options for subscribing to an OHLCV channel. + */ +export type OHLCVSubscriptionOptions = { + /** CAIP-19 asset identifier, e.g. "eip155:8453/erc20:0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913" */ + assetId: string; + /** Candle interval, e.g. "1m", "5m", "15m", "1h", "4h", "1d" */ + interval: string; + /** Fiat currency code, e.g. "usd", "eur" */ + currency: string; +}; diff --git a/packages/core-backend/tsconfig.build.json b/packages/core-backend/tsconfig.build.json new file mode 100644 index 00000000000..5a82d4afcf6 --- /dev/null +++ b/packages/core-backend/tsconfig.build.json @@ -0,0 +1,17 @@ +{ + "extends": "../../tsconfig.packages.build.json", + "compilerOptions": { + "baseUrl": "./", + "outDir": "./dist", + "rootDir": "./src" + }, + "references": [ + { "path": "../account-tree-controller/tsconfig.build.json" }, + { "path": "../controller-utils/tsconfig.build.json" }, + { "path": "../keyring-controller/tsconfig.build.json" }, + { "path": "../messenger/tsconfig.build.json" }, + { "path": "../profile-sync-controller/tsconfig.build.json" }, + { "path": "../remote-feature-flag-controller/tsconfig.build.json" } + ], + "include": ["../../types", "./src"] +} diff --git a/packages/core-backend/tsconfig.json b/packages/core-backend/tsconfig.json new file mode 100644 index 00000000000..455ac3eeec5 --- /dev/null +++ b/packages/core-backend/tsconfig.json @@ -0,0 +1,29 @@ +{ + "extends": "../../tsconfig.packages.json", + "compilerOptions": { + "baseUrl": "./", + "outDir": "./dist", + "rootDir": "./src" + }, + "references": [ + { + "path": "../account-tree-controller" + }, + { + "path": "../controller-utils" + }, + { + "path": "../keyring-controller" + }, + { + "path": "../messenger" + }, + { + "path": "../profile-sync-controller" + }, + { + "path": "../remote-feature-flag-controller" + } + ], + "include": ["../../types", "./src"] +} diff --git a/packages/core-backend/typedoc.json b/packages/core-backend/typedoc.json new file mode 100644 index 00000000000..c9da015dbf8 --- /dev/null +++ b/packages/core-backend/typedoc.json @@ -0,0 +1,7 @@ +{ + "entryPoints": ["./src/index.ts"], + "excludePrivate": true, + "hideGenerator": true, + "out": "docs", + "tsconfig": "./tsconfig.build.json" +} diff --git a/packages/delegation-controller/CHANGELOG.md b/packages/delegation-controller/CHANGELOG.md new file mode 100644 index 00000000000..ec91ee644db --- /dev/null +++ b/packages/delegation-controller/CHANGELOG.md @@ -0,0 +1,175 @@ +# 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] + +### Changed + +- Bump `@metamask/utils` from `^11.9.0` to `^11.11.0` ([#9074](https://github.com/MetaMask/core/pull/9074)) +- Bump `@metamask/keyring-controller` from `^27.0.0` to `^27.1.1` ([#9129](https://github.com/MetaMask/core/pull/9129), [#9791](https://github.com/MetaMask/core/pull/9791)) +- Bump `@metamask/messenger` from `^1.2.0` to `^2.0.0` ([#9392](https://github.com/MetaMask/core/pull/9392)) + +## [3.0.2] + +### Changed + +- Bump `@metamask/keyring-controller` from `^26.0.0` to `^27.0.0` ([#9058](https://github.com/MetaMask/core/pull/9058)) + +## [3.0.1] + +### Changed + +- Bump `@metamask/base-controller` from `^9.0.1` to `^9.1.0` ([#8457](https://github.com/MetaMask/core/pull/8457)) +- Bump `@metamask/messenger` from `^1.1.1` to `^1.2.0` ([#8632](https://github.com/MetaMask/core/pull/8632)) +- Bump `@metamask/keyring-controller` from `^25.2.0` to `^26.0.0` ([#8634](https://github.com/MetaMask/core/pull/8634), [#8665](https://github.com/MetaMask/core/pull/8665), [#8722](https://github.com/MetaMask/core/pull/8722), [#8912](https://github.com/MetaMask/core/pull/8912)) + +## [3.0.0] + +### Changed + +- Bump `@metamask/keyring-controller` from `^25.1.1` to `^25.2.0` ([#8363](https://github.com/MetaMask/core/pull/8363)) +- Bump `@metamask/messenger` from `^1.0.0` to `^1.1.1` ([#8364](https://github.com/MetaMask/core/pull/8364), [#8373](https://github.com/MetaMask/core/pull/8373)) + +### Removed + +- **BREAKING:** Remove persisted `delegations` state ([#8330](https://github.com/MetaMask/core/pull/8330)) + - `store`, `list`, `retrieve`, `chain`, and `delete` methods (and related messenger action types) + - `DelegationEntry` type export + - Remove dependency on `@metamask/accounts-controller` - callers no longer need to delegate the `AccountsController:getSelectedAccount` action to the `DelegationController` messenger + +## [2.1.0] + +### Added + +- Export `DelegationControllerGetStateAction` type ([#8205](https://github.com/MetaMask/core/pull/8205)) + +### Changed + +- Bump `@metamask/accounts-controller` from `^37.0.0` to `^37.1.0` ([#8317](https://github.com/MetaMask/core/pull/8317)) +- Bump `@metamask/base-controller` from `^9.0.0` to `^9.0.1` ([#8317](https://github.com/MetaMask/core/pull/8317)) +- Bump `@metamask/keyring-controller` from `^25.1.0` to `^25.1.1` ([#8317](https://github.com/MetaMask/core/pull/8317)) +- Bump `@metamask/messenger` from `^0.3.0` to `^1.0.0` ([#8317](https://github.com/MetaMask/core/pull/8317)) + +## [2.0.2] + +### Changed + +- Bump `@metamask/accounts-controller` from `^36.0.0` to `^37.0.0` ([#7996](https://github.com/MetaMask/core/pull/7996)), ([#8140](https://github.com/MetaMask/core/pull/8140)) + +## [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)), ([#7897](https://github.com/MetaMask/core/pull/7897)) + - The dependencies moved are: + - `@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. + - Note, however, that the versions specified in the client's `package.json` always "win", and you are expected to keep them up to date so as not to break controller and service intercommunication. + +## [2.0.0] + +### Changed + +- **BREAKING:** Bump `@metamask/keyring-controller` from `^24.0.0` to `^25.0.0` ([#7202](https://github.com/MetaMask/core/pull/7202)) +- **BREAKING:** Bump `@metamask/accounts-controller` from `^34.0.0` to `^35.0.0` ([#7202](https://github.com/MetaMask/core/pull/7202)) + +## [1.0.0] + +### Changed + +- **BREAKING:** Use new `Messenger` from `@metamask/messenger` ([#6459](https://github.com/MetaMask/core/pull/6459)) + - Previously, `DelegationController` accepted a `RestrictedMessenger` instance from `@metamask/base-controller`. +- **BREAKING:** Metadata property `anonymous` renamed to `includeInDebugSnapshot` ([#6459](https://github.com/MetaMask/core/pull/6459)) +- **BREAKING:** Bump `@metamask/accounts-controller` from `^33.0.0` to `^34.0.0` ([#6962](https://github.com/MetaMask/core/pull/6962)) +- **BREAKING:** Bump `@metamask/keyring-controller` from `^23.0.0` to `^24.0.0` ([#6962](https://github.com/MetaMask/core/pull/6962)) +- Bump `@metamask/base-controller` from `^8.4.2` to `^9.0.0` ([#6962](https://github.com/MetaMask/core/pull/6962)) + +## [0.8.1] + +### Changed + +- Bump `@metamask/base-controller` from `^8.4.1` to `^8.4.2` ([#6917](https://github.com/MetaMask/core/pull/6917)) + +## [0.8.0] + +### Added + +- Add two new controller state metadata properties: `includeInStateLogs` and `usedInUi` ([#6531](https://github.com/MetaMask/core/pull/6531)) + +### Changed + +- Bump `@metamask/base-controller` from `^8.1.0` to `^8.4.1` ([#6355](https://github.com/MetaMask/core/pull/6355), [#6465](https://github.com/MetaMask/core/pull/6465), [#6632](https://github.com/MetaMask/core/pull/6632), [#6807](https://github.com/MetaMask/core/pull/6807)) +- Bump `@metamask/utils` from `^11.4.2` to `^11.8.1` ([#6588](https://github.com/MetaMask/core/pull/6588), [#6708](https://github.com/MetaMask/core/pull/6708)) + +## [0.7.0] + +### Changed + +- **BREAKING:** Bump peer dependency `@metamask/accounts-controller` from `^32.0.0` to `^33.0.0` ([#6345](https://github.com/MetaMask/core/pull/6345)) +- **BREAKING:** Bump peer dependency `@metamask/keyring-controller` from `^22.0.0` to `^23.0.0` ([#6345](https://github.com/MetaMask/core/pull/6345)) +- Bump `@metamask/base-controller` from `^8.0.1` to `^8.1.0` ([#6284](https://github.com/MetaMask/core/pull/6284)) + +## [0.6.0] + +### Changed + +- **BREAKING:** Bump peer dependency `@metamask/accounts-controller` to `^32.0.0` ([#6171](https://github.com/MetaMask/core/pull/6171)) +- Bump `@metamask/utils` from `^11.2.0` to `^11.4.2` ([#6054](https://github.com/MetaMask/core/pull/6054)) + +## [0.5.0] + +### Changed + +- **BREAKING:** Bump peer dependency `@metamask/accounts-controller` to `^31.0.0` ([#5999](https://github.com/MetaMask/core/pull/5999)) + +## [0.4.0] + +### Changed + +- **BREAKING:** bump `@metamask/accounts-controller` peer dependency to `^30.0.0` ([#5888](https://github.com/MetaMask/core/pull/5888)) + +## [0.3.0] + +### Changed + +- **BREAKING:** bump `@metamask/keyring-controller` peer dependency to `^22.0.0` ([#5802](https://github.com/MetaMask/core/pull/5802)) +- **BREAKING:** bump `@metamask/accounts-controller` peer dependency to `^29.0.0` ([#5802](https://github.com/MetaMask/core/pull/5802)) + +## [0.2.0] + +### Changed + +- **BREAKING:** Bump peer dependency `@metamask/accounts-controller` to `^28.0.0` ([#5763](https://github.com/MetaMask/core/pull/5763)) +- Bump `@metamask/base-controller` from `^8.0.0` to `^8.0.1` ([#5722](https://github.com/MetaMask/core/pull/5722)) + +## [0.1.0] + +### Added + +- Initial release ([#5592](https://github.com/MetaMask/core/pull/5592)) + +[Unreleased]: https://github.com/MetaMask/core/compare/@metamask/delegation-controller@3.0.2...HEAD +[3.0.2]: https://github.com/MetaMask/core/compare/@metamask/delegation-controller@3.0.1...@metamask/delegation-controller@3.0.2 +[3.0.1]: https://github.com/MetaMask/core/compare/@metamask/delegation-controller@3.0.0...@metamask/delegation-controller@3.0.1 +[3.0.0]: https://github.com/MetaMask/core/compare/@metamask/delegation-controller@2.1.0...@metamask/delegation-controller@3.0.0 +[2.1.0]: https://github.com/MetaMask/core/compare/@metamask/delegation-controller@2.0.2...@metamask/delegation-controller@2.1.0 +[2.0.2]: https://github.com/MetaMask/core/compare/@metamask/delegation-controller@2.0.1...@metamask/delegation-controller@2.0.2 +[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 +[0.8.0]: https://github.com/MetaMask/core/compare/@metamask/delegation-controller@0.7.0...@metamask/delegation-controller@0.8.0 +[0.7.0]: https://github.com/MetaMask/core/compare/@metamask/delegation-controller@0.6.0...@metamask/delegation-controller@0.7.0 +[0.6.0]: https://github.com/MetaMask/core/compare/@metamask/delegation-controller@0.5.0...@metamask/delegation-controller@0.6.0 +[0.5.0]: https://github.com/MetaMask/core/compare/@metamask/delegation-controller@0.4.0...@metamask/delegation-controller@0.5.0 +[0.4.0]: https://github.com/MetaMask/core/compare/@metamask/delegation-controller@0.3.0...@metamask/delegation-controller@0.4.0 +[0.3.0]: https://github.com/MetaMask/core/compare/@metamask/delegation-controller@0.2.0...@metamask/delegation-controller@0.3.0 +[0.2.0]: https://github.com/MetaMask/core/compare/@metamask/delegation-controller@0.1.0...@metamask/delegation-controller@0.2.0 +[0.1.0]: https://github.com/MetaMask/core/releases/tag/@metamask/delegation-controller@0.1.0 diff --git a/packages/delegation-controller/LICENSE b/packages/delegation-controller/LICENSE new file mode 100644 index 00000000000..c259cd7ebcf --- /dev/null +++ b/packages/delegation-controller/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2025 MetaMask + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packages/delegation-controller/README.md b/packages/delegation-controller/README.md new file mode 100644 index 00000000000..a35e4e97734 --- /dev/null +++ b/packages/delegation-controller/README.md @@ -0,0 +1,15 @@ +# `@metamask/delegation-controller` + +Signs delegations via the keyring using the Delegation Framework typed-data format. + +## Installation + +`yarn add @metamask/delegation-controller` + +or + +`npm install @metamask/delegation-controller` + +## Contributing + +This package is part of a monorepo. Instructions for contributing can be found in the [monorepo README](https://github.com/MetaMask/core#readme). diff --git a/packages/delegation-controller/jest.config.js b/packages/delegation-controller/jest.config.js new file mode 100644 index 00000000000..ca084133399 --- /dev/null +++ b/packages/delegation-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/delegation-controller/package.json b/packages/delegation-controller/package.json new file mode 100644 index 00000000000..2aba87d6072 --- /dev/null +++ b/packages/delegation-controller/package.json @@ -0,0 +1,78 @@ +{ + "name": "@metamask/delegation-controller", + "version": "3.0.2", + "description": "Manages delegations for MetaMask", + "keywords": [ + "Ethereum", + "MetaMask" + ], + "homepage": "https://github.com/MetaMask/core/tree/main/packages/delegation-controller#readme", + "bugs": { + "url": "https://github.com/MetaMask/core/issues" + }, + "license": "MIT", + "repository": { + "type": "git", + "url": "https://github.com/MetaMask/core.git" + }, + "files": [ + "dist/" + ], + "sideEffects": false, + "main": "./dist/index.cjs", + "types": "./dist/index.d.cts", + "exports": { + ".": { + "import": { + "types": "./dist/index.d.mts", + "default": "./dist/index.mjs" + }, + "require": { + "types": "./dist/index.d.cts", + "default": "./dist/index.cjs" + } + }, + "./package.json": "./package.json" + }, + "publishConfig": { + "access": "public", + "registry": "https://registry.npmjs.org/" + }, + "scripts": { + "build": "ts-bridge --project tsconfig.build.json --verbose --clean --no-references", + "build:all": "ts-bridge --project tsconfig.build.json --verbose --clean", + "build:docs": "typedoc", + "changelog:update": "../../scripts/update-changelog.sh @metamask/delegation-controller", + "changelog:validate": "../../scripts/validate-changelog.sh @metamask/delegation-controller", + "lint:tsconfigs": "tsx ../../scripts/lint-tsconfigs/lint-tsconfigs.mts", + "lint:tsconfigs:fix": "tsx ../../scripts/lint-tsconfigs/lint-tsconfigs.mts --fix", + "messenger-action-types:check": "tsx ../../packages/messenger-cli/src/cli.ts --formatter oxfmt --esm --check", + "messenger-action-types:generate": "tsx ../../packages/messenger-cli/src/cli.ts --formatter oxfmt --esm --generate", + "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.1.0", + "@metamask/keyring-controller": "^27.1.1", + "@metamask/messenger": "^2.0.0", + "@metamask/utils": "^11.11.0" + }, + "devDependencies": { + "@metamask/auto-changelog": "^6.1.0", + "@ts-bridge/cli": "^0.6.4", + "@types/jest": "^30.0.0", + "deepmerge": "^4.2.2", + "jest": "^30.4.2", + "ts-jest": "^29.4.11", + "tsx": "^4.20.5", + "typedoc": "^0.25.13", + "typedoc-plugin-missing-exports": "^2.0.0", + "typescript": "~5.3.3" + }, + "engines": { + "node": "^18.18 || >=20" + } +} diff --git a/packages/delegation-controller/src/DelegationController-method-action-types.ts b/packages/delegation-controller/src/DelegationController-method-action-types.ts new file mode 100644 index 00000000000..18d17f8e296 --- /dev/null +++ b/packages/delegation-controller/src/DelegationController-method-action-types.ts @@ -0,0 +1,25 @@ +/** + * This file is auto generated. + * Do not edit manually. + */ + +import type { DelegationController } from './DelegationController.js'; + +/** + * Signs a delegation. + * + * @param params - The parameters for signing the delegation. + * @param params.delegation - The delegation to sign. + * @param params.chainId - The chainId of the chain to sign the delegation for. + * @returns The signature of the delegation. + */ +export type DelegationControllerSignDelegationAction = { + type: `DelegationController:signDelegation`; + handler: DelegationController['signDelegation']; +}; + +/** + * Union of all DelegationController action types. + */ +export type DelegationControllerMethodActions = + DelegationControllerSignDelegationAction; diff --git a/packages/delegation-controller/src/DelegationController.test.ts b/packages/delegation-controller/src/DelegationController.test.ts new file mode 100644 index 00000000000..1c0a43d7f56 --- /dev/null +++ b/packages/delegation-controller/src/DelegationController.test.ts @@ -0,0 +1,257 @@ +import { deriveStateFromMetadata } from '@metamask/base-controller'; +import { SignTypedDataVersion } from '@metamask/keyring-controller'; +import { Messenger, MOCK_ANY_NAMESPACE } from '@metamask/messenger'; +import type { + MessengerActions, + MessengerEvents, + MockAnyNamespace, +} from '@metamask/messenger'; +import { hexToNumber } from '@metamask/utils'; + +import { ROOT_AUTHORITY } from './constants.js'; +import { + controllerName, + DelegationController, +} from './DelegationController.js'; +import type { + Address, + Delegation, + DelegationControllerMessenger, + DelegationControllerState, + DeleGatorEnvironment, + Hex, +} from './types.js'; +import { toDelegationStruct } from './utils.js'; + +type AllDelegationControllerActions = + MessengerActions; + +type AllDelegationControllerEvents = + MessengerEvents; + +type RootMessenger = Messenger< + MockAnyNamespace, + AllDelegationControllerActions, + AllDelegationControllerEvents +>; + +const FROM_MOCK = '0x2234567890123456789012345678901234567890' as Address; +const SIGNATURE_HASH_MOCK = '0x123ABC'; + +const CHAIN_ID_MOCK = '0xaa36a7'; + +const VERIFYING_CONTRACT_MOCK: Address = + '0x00000000000000000000000000000000000321fde'; + +const DELEGATION_MOCK: Delegation = { + delegator: '0x1234567890123456789012345678901234567890' as Address, + delegate: FROM_MOCK, + authority: ROOT_AUTHORITY, + caveats: [ + { + enforcer: '0x1111111111111111111111111111111111111111', + terms: '0x', + args: '0x', + }, + ], + salt: '0x' as Hex, + signature: '0x', +}; + +class TestDelegationController extends DelegationController { + public testUpdate(updater: (state: DelegationControllerState) => void) { + this.update(updater); + } +} + +/** + * Create a mock messenger instance. + * + * @returns The mock messenger instance plus individual mock functions for each action. + */ +function createMessengerMock() { + const messenger: RootMessenger = new Messenger({ + namespace: MOCK_ANY_NAMESPACE, + }); + + const keyringControllerSignTypedMessageMock = jest.fn(); + + keyringControllerSignTypedMessageMock.mockResolvedValue(SIGNATURE_HASH_MOCK); + + messenger.registerActionHandler( + 'KeyringController:signTypedMessage', + keyringControllerSignTypedMessageMock, + ); + + const delegationControllerMessenger = new Messenger< + 'DelegationController', + AllDelegationControllerActions, + AllDelegationControllerEvents, + RootMessenger + >({ + namespace: controllerName, + parent: messenger, + }); + messenger.delegate({ + messenger: delegationControllerMessenger, + actions: ['KeyringController:signTypedMessage'], + }); + + return { + keyringControllerSignTypedMessageMock, + messenger: delegationControllerMessenger, + rootMessenger: messenger, + }; +} + +/** + * Create a mock getDelegationEnvironment function. + * + * @param _chainId - The chainId to return the environment for. + * @returns The mock environment object. + */ +function getDelegationEnvironmentMock(_chainId: Hex): DeleGatorEnvironment { + return { + DelegationManager: VERIFYING_CONTRACT_MOCK, + EntryPoint: VERIFYING_CONTRACT_MOCK, + SimpleFactory: VERIFYING_CONTRACT_MOCK, + caveatEnforcers: {}, + implementations: {}, + }; +} + +/** + * Create a controller instance for testing. + * + * @param state - The initial state to use for the controller. + * @returns The controller instance plus individual mock functions for each action. + */ +function createController(state?: DelegationControllerState) { + const { messenger, rootMessenger, ...mocks } = createMessengerMock(); + const controller = new TestDelegationController({ + messenger, + state, + getDelegationEnvironment: getDelegationEnvironmentMock, + }); + + return { + controller, + rootMessenger, + ...mocks, + }; +} + +describe(`${controllerName}`, () => { + beforeEach(() => { + jest.resetAllMocks(); + }); + + describe('constructor', () => { + it('initializes with default state', () => { + const { controller } = createController(); + expect(controller.state).toStrictEqual({}); + }); + }); + + describe('sign', () => { + it('signs a delegation message', async () => { + const { rootMessenger, keyringControllerSignTypedMessageMock } = + createController(); + + const signature = await rootMessenger.call( + 'DelegationController:signDelegation', + { + delegation: DELEGATION_MOCK, + chainId: CHAIN_ID_MOCK, + }, + ); + + expect(signature).toBe(SIGNATURE_HASH_MOCK); + expect(keyringControllerSignTypedMessageMock).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ + types: expect.any(Object), + primaryType: 'Delegation', + domain: expect.objectContaining({ + chainId: hexToNumber(CHAIN_ID_MOCK), + name: 'DelegationManager', + version: '1', + verifyingContract: VERIFYING_CONTRACT_MOCK, + }), + message: toDelegationStruct(DELEGATION_MOCK), + }), + from: DELEGATION_MOCK.delegator, + }), + SignTypedDataVersion.V4, + ); + }); + + it('throws if signature fails', async () => { + const { rootMessenger, keyringControllerSignTypedMessageMock } = + createController(); + keyringControllerSignTypedMessageMock.mockRejectedValue( + new Error('Signature failed'), + ); + + await expect( + rootMessenger.call('DelegationController:signDelegation', { + delegation: { + ...DELEGATION_MOCK, + salt: '0x1' as Hex, + }, + chainId: CHAIN_ID_MOCK, + }), + ).rejects.toThrow('Signature failed'); + }); + }); + + describe('metadata', () => { + it('includes expected state in debug snapshots', () => { + const { controller } = createController(); + + expect( + deriveStateFromMetadata( + controller.state, + controller.metadata, + 'includeInDebugSnapshot', + ), + ).toMatchInlineSnapshot(`{}`); + }); + + it('includes expected state in state logs', () => { + const { controller } = createController(); + + expect( + deriveStateFromMetadata( + controller.state, + controller.metadata, + 'includeInStateLogs', + ), + ).toMatchInlineSnapshot(`{}`); + }); + + it('persists expected state', () => { + const { controller } = createController(); + + expect( + deriveStateFromMetadata( + controller.state, + controller.metadata, + 'persist', + ), + ).toMatchInlineSnapshot(`{}`); + }); + + it('includes expected state in UI', () => { + const { controller } = createController(); + + expect( + deriveStateFromMetadata( + controller.state, + controller.metadata, + 'usedInUi', + ), + ).toMatchInlineSnapshot(`{}`); + }); + }); +}); diff --git a/packages/delegation-controller/src/DelegationController.ts b/packages/delegation-controller/src/DelegationController.ts new file mode 100644 index 00000000000..3118641fd0f --- /dev/null +++ b/packages/delegation-controller/src/DelegationController.ts @@ -0,0 +1,114 @@ +import type { StateMetadata } from '@metamask/base-controller'; +import { BaseController } from '@metamask/base-controller'; +import { SignTypedDataVersion } from '@metamask/keyring-controller'; +import { hexToNumber } from '@metamask/utils'; + +import type { + DelegationControllerMessenger, + DelegationControllerState, + DeleGatorEnvironment, + Hex, + UnsignedDelegation, +} from './types.js'; +import { createTypedMessageParams } from './utils.js'; + +export const controllerName = 'DelegationController'; + +const MESSENGER_EXPOSED_METHODS = ['signDelegation'] as const; + +const delegationControllerMetadata = + {} satisfies StateMetadata; + +/** + * Constructs the default {@link DelegationController} 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 DelegationController} state. + */ +function getDefaultDelegationControllerState(): DelegationControllerState { + return {}; +} + +/** + * The {@link DelegationController} class. + * This controller signs delegations via the keyring (typed-data signing). + */ +export class DelegationController extends BaseController< + typeof controllerName, + DelegationControllerState, + DelegationControllerMessenger +> { + readonly #getDelegationEnvironment: (chainId: Hex) => DeleGatorEnvironment; + + /** + * Constructs a new {@link DelegationController} instance. + * + * @param params - The parameters for constructing the controller. + * @param params.messenger - The messenger instance to use for the controller. + * @param params.state - The initial state for the controller. + * @param params.getDelegationEnvironment - A function to get the delegation environment for a given chainId. + */ + constructor({ + messenger, + state, + getDelegationEnvironment, + }: { + messenger: DelegationControllerMessenger; + state?: Partial; + getDelegationEnvironment: (chainId: Hex) => DeleGatorEnvironment; + }) { + super({ + messenger, + metadata: delegationControllerMetadata, + name: controllerName, + state: { + ...getDefaultDelegationControllerState(), + ...state, + }, + }); + this.#getDelegationEnvironment = getDelegationEnvironment; + + this.messenger.registerMethodActionHandlers( + this, + MESSENGER_EXPOSED_METHODS, + ); + } + + /** + * Signs a delegation. + * + * @param params - The parameters for signing the delegation. + * @param params.delegation - The delegation to sign. + * @param params.chainId - The chainId of the chain to sign the delegation for. + * @returns The signature of the delegation. + */ + async signDelegation(params: { + delegation: UnsignedDelegation; + chainId: Hex; + }) { + const { delegation, chainId } = params; + const { DelegationManager } = this.#getDelegationEnvironment(chainId); + + const data = createTypedMessageParams({ + chainId: hexToNumber(chainId), + from: delegation.delegator, + delegation: { + ...delegation, + signature: '0x', + }, + verifyingContract: DelegationManager, + }); + + // TODO:: Replace with `SignatureController:newUnsignedTypedMessage`. + // Waiting on confirmations team to implement this. + const signature: string = await this.messenger.call( + 'KeyringController:signTypedMessage', + data, + SignTypedDataVersion.V4, + ); + + return signature; + } +} diff --git a/packages/delegation-controller/src/constants.ts b/packages/delegation-controller/src/constants.ts new file mode 100644 index 00000000000..1177f1ad590 --- /dev/null +++ b/packages/delegation-controller/src/constants.ts @@ -0,0 +1,30 @@ +import type { Hex } from './types.js'; + +export const ROOT_AUTHORITY = + '0xffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff' as Hex; + +const EIP712Domain = [ + { name: 'name', type: 'string' }, + { name: 'version', type: 'string' }, + { name: 'chainId', type: 'uint256' }, + { name: 'verifyingContract', type: 'address' }, +]; + +const SDK_SIGNABLE_DELEGATION_TYPED_DATA = { + Caveat: [ + { name: 'enforcer', type: 'address' }, + { name: 'terms', type: 'bytes' }, + ], + Delegation: [ + { name: 'delegate', type: 'address' }, + { name: 'delegator', type: 'address' }, + { name: 'authority', type: 'bytes32' }, + { name: 'caveats', type: 'Caveat[]' }, + { name: 'salt', type: 'uint256' }, + ], +} as const; + +export const SIGNABLE_DELEGATION_TYPED_DATA = { + EIP712Domain, + ...SDK_SIGNABLE_DELEGATION_TYPED_DATA, +}; diff --git a/packages/delegation-controller/src/index.ts b/packages/delegation-controller/src/index.ts new file mode 100644 index 00000000000..3e9050f0936 --- /dev/null +++ b/packages/delegation-controller/src/index.ts @@ -0,0 +1,9 @@ +export type { DelegationControllerSignDelegationAction } from './DelegationController-method-action-types.js'; +export type { + DelegationControllerGetStateAction, + DelegationControllerActions, + DelegationControllerEvents, + DelegationControllerMessenger, +} from './types.js'; + +export { DelegationController } from './DelegationController.js'; diff --git a/packages/delegation-controller/src/types.ts b/packages/delegation-controller/src/types.ts new file mode 100644 index 00000000000..9802ca94048 --- /dev/null +++ b/packages/delegation-controller/src/types.ts @@ -0,0 +1,101 @@ +import type { + ControllerGetStateAction, + ControllerStateChangeEvent, +} from '@metamask/base-controller'; +import type { KeyringControllerSignTypedMessageAction } from '@metamask/keyring-controller'; +import type { Messenger } from '@metamask/messenger'; + +import type { DelegationControllerMethodActions } from './DelegationController-method-action-types.js'; +import type { controllerName } from './DelegationController.js'; + +type Hex = `0x${string}`; +type Address = `0x${string}`; + +export type { Address, Hex }; + +/** + * A version agnostic blob of contract addresses required for the DeleGator system to function. + */ +export type DeleGatorEnvironment = { + DelegationManager: Hex; + EntryPoint: Hex; + SimpleFactory: Hex; + implementations: { + [implementation: string]: Hex; + }; + caveatEnforcers: { + [enforcer: string]: Hex; + }; +}; + +/** + * A delegation caveat is a condition that must be met in order for a delegation + * to be valid. The caveat is defined by an enforcer, terms, and arguments. + * + * @see https://docs.gator.metamask.io/concepts/caveat-enforcers + */ +export type Caveat = { + enforcer: Hex; + terms: Hex; + args: Hex; +}; + +/** + * A delegation is a signed statement that gives a delegate permission to + * act on behalf of a delegator. The permissions are defined by a set of caveats. + * The caveats are a set of conditions that must be met in order for the delegation + * to be valid. + * + * @see https://docs.gator.metamask.io/concepts/delegation + */ +export type Delegation = { + /** The address of the delegate. */ + delegate: Hex; + /** The address of the delegator. */ + delegator: Hex; + /** The hash of the parent delegation, or the root authority if this is the root delegation. */ + authority: Hex; + /** The terms of the delegation. */ + caveats: Caveat[]; + /** The salt used to generate the delegation signature. */ + salt: Hex; + /** The signature of the delegation. */ + signature: Hex; +}; + +/** An unsigned delegation is a delegation without a signature. */ +export type UnsignedDelegation = Omit; + +export type DelegationStruct = Omit & { + salt: bigint; +}; + +// Empty controller state (signing-only; no persisted fields). +// eslint-disable-next-line @typescript-eslint/no-empty-object-type +export type DelegationControllerState = {}; + +export type DelegationControllerGetStateAction = ControllerGetStateAction< + typeof controllerName, + DelegationControllerState +>; + +export type DelegationControllerActions = + | DelegationControllerGetStateAction + | DelegationControllerMethodActions; + +export type DelegationControllerStateChangeEvent = ControllerStateChangeEvent< + typeof controllerName, + DelegationControllerState +>; + +export type DelegationControllerEvents = DelegationControllerStateChangeEvent; + +type AllowedActions = KeyringControllerSignTypedMessageAction; + +type AllowedEvents = never; + +export type DelegationControllerMessenger = Messenger< + typeof controllerName, + DelegationControllerActions | AllowedActions, + DelegationControllerEvents | AllowedEvents +>; diff --git a/packages/delegation-controller/src/utils.ts b/packages/delegation-controller/src/utils.ts new file mode 100644 index 00000000000..02da461f9ed --- /dev/null +++ b/packages/delegation-controller/src/utils.ts @@ -0,0 +1,68 @@ +import type { TypedMessageParams } from '@metamask/keyring-controller'; +import { getChecksumAddress } from '@metamask/utils'; + +import { SIGNABLE_DELEGATION_TYPED_DATA } from './constants.js'; +import type { Address, Delegation, DelegationStruct } from './types.js'; + +type CreateTypedMessageParamsOptions = { + chainId: number; + from: Address; + delegation: Delegation; + verifyingContract: Address; +}; + +/** + * Converts a Delegation to a DelegationStruct. + * The DelegationStruct is the format used in the Delegation Framework. + * + * @param delegation the delegation to format + * @returns the formatted delegation + */ +export const toDelegationStruct = ( + delegation: Delegation, +): DelegationStruct => { + const caveats = delegation.caveats.map((caveat) => ({ + enforcer: getChecksumAddress(caveat.enforcer), + terms: caveat.terms, + args: caveat.args, + })); + + const salt = delegation.salt === '0x' ? 0n : BigInt(delegation.salt); + + return { + delegate: getChecksumAddress(delegation.delegate), + delegator: getChecksumAddress(delegation.delegator), + authority: delegation.authority, + caveats, + salt, + signature: delegation.signature, + }; +}; + +/** + * + * @param opts - The options for creating typed message params. + * @returns The typed message params. + */ +export function createTypedMessageParams( + opts: CreateTypedMessageParamsOptions, +): TypedMessageParams { + const { chainId, from, delegation, verifyingContract } = opts; + + const data: TypedMessageParams = { + data: { + types: SIGNABLE_DELEGATION_TYPED_DATA, + primaryType: 'Delegation', + domain: { + chainId, + name: 'DelegationManager', + version: '1', + verifyingContract, + }, + message: toDelegationStruct(delegation), + }, + from, + }; + + return data; +} diff --git a/packages/delegation-controller/tsconfig.build.json b/packages/delegation-controller/tsconfig.build.json new file mode 100644 index 00000000000..b16ce7cfcda --- /dev/null +++ b/packages/delegation-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": "../keyring-controller/tsconfig.build.json" }, + { "path": "../messenger/tsconfig.build.json" } + ], + "include": ["../../types", "./src"] +} diff --git a/packages/delegation-controller/tsconfig.json b/packages/delegation-controller/tsconfig.json new file mode 100644 index 00000000000..775a9ba69b6 --- /dev/null +++ b/packages/delegation-controller/tsconfig.json @@ -0,0 +1,12 @@ +{ + "extends": "../../tsconfig.packages.json", + "compilerOptions": { + "baseUrl": "./" + }, + "references": [ + { "path": "../base-controller" }, + { "path": "../keyring-controller" }, + { "path": "../messenger" } + ], + "include": ["../../types", "./src"] +} diff --git a/packages/delegation-controller/typedoc.json b/packages/delegation-controller/typedoc.json new file mode 100644 index 00000000000..c9da015dbf8 --- /dev/null +++ b/packages/delegation-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/earn-controller/CHANGELOG.md b/packages/earn-controller/CHANGELOG.md new file mode 100644 index 00000000000..032428af6fd --- /dev/null +++ b/packages/earn-controller/CHANGELOG.md @@ -0,0 +1,561 @@ +# 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] + +## [12.2.6] + +### Changed + +- Bump `@metamask/transaction-controller` from `^69.5.2` to `^69.6.0` ([#9960](https://github.com/MetaMask/core/pull/9960)) +- Bump `@metamask/network-controller` from `^35.0.1` to `^36.0.0` ([#9969](https://github.com/MetaMask/core/pull/9969)) + +## [12.2.5] + +### Changed + +- Bump `@metamask/account-tree-controller` from `^7.6.1` to `^8.0.0` ([#9886](https://github.com/MetaMask/core/pull/9886)) + +### Fixed + +- Avoid duplicate `refreshEarnEligibility`/`refreshPooledStakes`/`refreshLendingPositions` calls when `AccountTreeController:selectedAccountGroupChange` fires with an address that was already just refreshed (e.g. immediately after `init()` during startup hydration) ([#9804](https://github.com/MetaMask/core/pull/9804)) +- Only eagerly prefetch pooled staking data for Ethereum Mainnet on startup/network change, no longer also prefetching the Hoodi testnet by default; Hoodi remains fully supported via explicit `chainId` calls ([#9804](https://github.com/MetaMask/core/pull/9804)) + +## [12.2.4] + +### Changed + +- Bump `@metamask/keyring-api` from `^23.7.0` to `^24.0.0` ([#9754](https://github.com/MetaMask/core/pull/9754)) +- Bump `@metamask/account-tree-controller` from `^7.5.5` to `^7.6.1` ([#9779](https://github.com/MetaMask/core/pull/9779), [#9791](https://github.com/MetaMask/core/pull/9791)) +- Bump `@metamask/network-controller` from `^35.0.0` to `^35.0.1` ([#9758](https://github.com/MetaMask/core/pull/9758)) + +## [12.2.3] + +### Changed + +- Bump `@metamask/transaction-controller` from `^69.1.0` to `^69.3.0` ([#9589](https://github.com/MetaMask/core/pull/9589), [#9593](https://github.com/MetaMask/core/pull/9593), [#9693](https://github.com/MetaMask/core/pull/9693)) +- Bump `@metamask/messenger` from `^1.2.0` to `^2.0.0` ([#9392](https://github.com/MetaMask/core/pull/9392)) +- Bump `@metamask/keyring-api` from `^23.3.0` to `^23.7.0` ([#9390](https://github.com/MetaMask/core/pull/9390), [#9676](https://github.com/MetaMask/core/pull/9676)) +- Bump `@metamask/account-tree-controller` from `^7.5.3` to `^7.5.5` ([#9429](https://github.com/MetaMask/core/pull/9429), [#9470](https://github.com/MetaMask/core/pull/9470)) +- Bump `@metamask/network-controller` from `^34.0.0` to `^35.0.0` ([#9735](https://github.com/MetaMask/core/pull/9735)) + +## [12.2.2] + +### Changed + +- Bump `@metamask/account-tree-controller` from `^7.5.2` to `^7.5.3` ([#9231](https://github.com/MetaMask/core/pull/9231)) +- Bump `@metamask/keyring-api` from `^23.1.0` to `^23.3.0` ([#9249](https://github.com/MetaMask/core/pull/9249)) +- Bump `@metamask/network-controller` from `^33.0.0` to `^34.0.0` ([#9349](https://github.com/MetaMask/core/pull/9349)) + +## [12.2.1] + +### Changed + +- Bump `@metamask/controller-utils` from `^12.1.0` to `^12.3.0` ([#9058](https://github.com/MetaMask/core/pull/9058), [#9083](https://github.com/MetaMask/core/pull/9083), [#9218](https://github.com/MetaMask/core/pull/9218)) +- Bump `@metamask/account-tree-controller` from `^7.4.0` to `^7.5.2` ([#8912](https://github.com/MetaMask/core/pull/8912), [#8999](https://github.com/MetaMask/core/pull/8999), [#9058](https://github.com/MetaMask/core/pull/9058)) +- Bump `@metamask/transaction-controller` from `^67.1.0` to `^68.0.0` ([#9089](https://github.com/MetaMask/core/pull/9089)) +- Bump `@metamask/network-controller` from `^32.0.0` to `^33.0.0` ([#9218](https://github.com/MetaMask/core/pull/9218)) + +## [12.2.0] + +### Changed + +- Pass `isInternal: true` to `addTransactionFn` in `executeLendingDeposit`, `executeLendingWithdraw`, and `executeLendingTokenApprove` so lending transactions bypass dapp-origin restrictions ([#8633](https://github.com/MetaMask/core/pull/8633)) +- Bump `@metamask/account-tree-controller` from `^7.3.0` to `^7.4.0` ([#8783](https://github.com/MetaMask/core/pull/8783)) +- Bump `@metamask/transaction-controller` from `^65.3.0` to `^66.0.0` ([#8796](https://github.com/MetaMask/core/pull/8796), [#8848](https://github.com/MetaMask/core/pull/8848)) + +## [12.1.2] + +### Changed + +- Bump `@metamask/network-controller` from `^31.0.0` to `^32.0.0` ([#8765](https://github.com/MetaMask/core/pull/8765), [#8774](https://github.com/MetaMask/core/pull/8774)) +- Bump `@metamask/controller-utils` from `^12.0.0` to `^12.1.0` ([#8774](https://github.com/MetaMask/core/pull/8774)) + +## [12.1.1] + +### Changed + +- Bump `@metamask/account-tree-controller` from `^7.2.0` to `^7.3.0` ([#8722](https://github.com/MetaMask/core/pull/8722)) +- Bump `@metamask/controller-utils` from `^11.20.0` to `^12.0.0` ([#8755](https://github.com/MetaMask/core/pull/8755)) +- Bump `@metamask/network-controller` from `^30.1.0` to `^31.0.0` ([#8755](https://github.com/MetaMask/core/pull/8755)) + +## [12.1.0] + +### Changed + +- Bump `@metamask/base-controller` from `^9.0.1` to `^9.1.0` ([#8457](https://github.com/MetaMask/core/pull/8457)) +- Bump `@metamask/account-tree-controller` from `^7.0.0` to `^7.2.0` ([#8472](https://github.com/MetaMask/core/pull/8472), [#8665](https://github.com/MetaMask/core/pull/8665)) +- Bump `@metamask/keyring-api` from `^21.6.0` to `^23.1.0` ([#8464](https://github.com/MetaMask/core/pull/8464), [#8647](https://github.com/MetaMask/core/pull/8647)) +- Bump `@metamask/transaction-controller` from `^64.4.0` to `^65.0.0` ([#8613](https://github.com/MetaMask/core/pull/8613)) +- Bump `@metamask/messenger` from `^1.1.1` to `^1.2.0` ([#8632](https://github.com/MetaMask/core/pull/8632)) +- Bump `@metamask/network-controller` from `^30.0.1` to `^30.1.0` ([#8636](https://github.com/MetaMask/core/pull/8636)) + +## [12.0.0] + +### Added + +- feat(messenger): add `generate-action-types` CLI tool as subpath export ([#8264](https://github.com/MetaMask/core/pull/8264)) +- Release/895.0.0 ([#8359](https://github.com/MetaMask/core/pull/8359)) + +### Changed + +- feat: extract generate-action-types CLI into @metamask/messenger-cli ([#8378](https://github.com/MetaMask/core/pull/8378)) +- **BREAKING:** `EarnController` constructor no longer accepts `selectedNetworkClientId` and no longer performs async work during construction. Consumers must call `init()` after construction. The messenger must now allow `AccountTreeController:stateChange` and `NetworkController:getState` ([#8421](https://github.com/MetaMask/core/pull/8421)) +- **BREAKING:** `refreshPooledStakingData` and `refreshLendingData` no longer call eligibility checks internally. Eligibility is fetched once during `init()`. Consumers that relied on these methods to keep eligibility state current must call `refreshEarnEligibility` separately ([#8421](https://github.com/MetaMask/core/pull/8421)) +- Bump `@metamask/controller-utils` from `^11.19.0` to `^11.20.0` ([#8344](https://github.com/MetaMask/core/pull/8344)) +- Bump `@metamask/messenger` from `^1.0.0` to `^1.1.1` ([#8364](https://github.com/MetaMask/core/pull/8364), [#8373](https://github.com/MetaMask/core/pull/8373)) +- Bump `@metamask/transaction-controller` from `^64.0.0` to `^64.1.0` ([#8432](https://github.com/MetaMask/core/pull/8432)) + +### Removed + +- **BREAKING:** Removed `EarnController:refreshLendingEligibility` messenger action and `EarnControllerRefreshLendingEligibilityAction` type. Use `EarnController:refreshEarnEligibility` instead ([#8421](https://github.com/MetaMask/core/pull/8421)) + +## [11.2.1] + +### Changed + +- Bump `@metamask/account-tree-controller` from `^6.0.0` to `^7.0.0` ([#8325](https://github.com/MetaMask/core/pull/8325)) + +## [11.2.0] + +### Added + +- Expose public `EarnController` methods through its messenger ([#8173](https://github.com/MetaMask/core/pull/8173)) + - The following actions are now available: + - `EarnController:refreshPooledStakes` + - `EarnController:refreshEarnEligibility` + - `EarnController:refreshPooledStakingVaultMetadata` + - `EarnController:refreshPooledStakingVaultDailyApys` + - `EarnController:refreshPooledStakingVaultApyAverages` + - `EarnController:refreshPooledStakingData` + - `EarnController:refreshLendingMarkets` + - `EarnController:refreshLendingPositions` + - `EarnController:refreshLendingEligibility` + - `EarnController:refreshLendingData` + - `EarnController:refreshTronStakingApy` + - `EarnController:getTronStakingApy` + - `EarnController:getLendingPositionHistory` + - `EarnController:getLendingMarketDailyApysAndAverages` + - `EarnController:executeLendingDeposit` + - `EarnController:executeLendingWithdraw` + - `EarnController:executeLendingTokenApprove` + - `EarnController:getLendingTokenAllowance` + - `EarnController:getLendingTokenMaxWithdraw` + - `EarnController:getLendingTokenMaxDeposit` + - Corresponding action types (e.g. `EarnControllerRefreshPooledStakesAction`) are available as well. + +### Changed + +- Bump `@metamask/base-controller` from `^9.0.0` to `^9.0.1` ([#8317](https://github.com/MetaMask/core/pull/8317)) +- Bump `@metamask/messenger` from `^0.3.0` to `^1.0.0` ([#8317](https://github.com/MetaMask/core/pull/8317)) +- Bump `@metamask/network-controller` from `^30.0.0` to `^30.0.1` ([#8317](https://github.com/MetaMask/core/pull/8317)) +- Bump `@metamask/keyring-api` from `^21.5.0` to `^21.6.0` ([#8259](https://github.com/MetaMask/core/pull/8259)) +- Bump `@metamask/account-tree-controller` from `^5.0.0` to `^6.0.0` ([#8162](https://github.com/MetaMask/core/pull/8162), [#8317](https://github.com/MetaMask/core/pull/8317)) +- Bump `@metamask/transaction-controller` from `^63.0.0` to `^63.1.0` ([#8272](https://github.com/MetaMask/core/pull/8272)) + +## [11.1.2] + +### Changed + +- Bump `@metamask/account-tree-controller` from `^4.1.1` to `^5.0.0` ([#8140](https://github.com/MetaMask/core/pull/8140)) +- Bump `@metamask/transaction-controller` from `^62.20.0` to `^62.21.0` ([#8140](https://github.com/MetaMask/core/pull/8140)) + +## [11.1.1] + +### Changed + +- Bump `@metamask/network-controller` from `^29.0.0` to `^30.0.0` ([#7996](https://github.com/MetaMask/core/pull/7996)) +- 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.1`, ([#7869](https://github.com/MetaMask/core/pull/7869), [#7897](https://github.com/MetaMask/core/pull/7897)) +- Bump `@metamask/controller-utils` from `^11.18.0` to `^11.19.0` ([#7995](https://github.com/MetaMask/core/pull/7995)) + +## [11.1.0] + +### Added + +- Add Tron staking APY support with `tron_staking` state, methods, and selectors ([#7448](https://github.com/MetaMask/core/pull/7448)) + +### Changed + +- Move peer dependencies for controller and service packages to direct dependencies ([#7209](https://github.com/MetaMask/core/pull/7209), [#7258](https://github.com/MetaMask/core/pull/7258), [#7534](https://github.com/MetaMask/core/pull/7534), [#7583](https://github.com/MetaMask/core/pull/7583), [#7604](https://github.com/MetaMask/core/pull/7604), [#7642](https://github.com/MetaMask/core/pull/7642)) + - The dependencies moved are: + - `@metamask/account-tree-controller` (^4.0.0) + - `@metamask/network-controller` (^29.0.0) + - In clients, it is now possible for multiple versions of these packages to exist in the dependency tree. + - For example, this scenario would be valid: a client relies on `@metamask/controller-a` 1.0.0 and `@metamask/controller-b` 1.0.0, and `@metamask/controller-b` depends on `@metamask/controller-a` 1.1.0. + - Note, however, that the versions specified in the client's `package.json` always "win", and you are expected to keep them up to date so as not to break controller and service intercommunication. +- 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)) + +## [11.0.0] + +### Changed + +- Bump `@metamask/controller-utils` from `^11.15.0` to `^11.16.0` ([#7202](https://github.com/MetaMask/core/pull/7202)) +- **BREAKING:** Bump `@metamask/network-controller` from `^25.0.0` to `^26.0.0` ([#7202](https://github.com/MetaMask/core/pull/7202)) +- **BREAKING:** Bump `@metamask/account-tree-controller` from `^3.0.0` to `^4.0.0` ([#7202](https://github.com/MetaMask/core/pull/7202)) + +## [10.0.0] + +### Changed + +- **BREAKING:** Bump `@metamask/account-tree-controller` from `^2.0.0` to `^3.0.0` ([#7100](https://github.com/MetaMask/core/pull/7100)) +- Bump `@metamask/controller-utils` from `^11.14.1` to `^11.15.0` ([#7003](https://github.com/MetaMask/core/pull/7003)) + +## [9.0.0] + +### Changed + +- **BREAKING:** Use new `Messenger` from `@metamask/messenger` ([#6445](https://github.com/MetaMask/core/pull/6445)) + - Previously, `EarnController` accepted a `RestrictedMessenger` instance from `@metamask/base-controller`. +- **BREAKING:** Metadata property `anonymous` renamed to `includeInDebugSnapshot` ([#6445](https://github.com/MetaMask/core/pull/6445)) +- **BREAKING:** Bump `@metamask/account-tree-controller` from `^1.0.0` to `^2.0.0` ([#6962](https://github.com/MetaMask/core/pull/6962)) +- **BREAKING:** Bump `@metamask/network-controller` from `^24.0.0` to `^25.0.0` ([#6962](https://github.com/MetaMask/core/pull/6962)) +- Bump `@metamask/base-controller` from `^8.4.2` to `^9.0.0` ([#6962](https://github.com/MetaMask/core/pull/6962)) + +## [8.0.2] + +### Changed + +- Bump `@metamask/base-controller` from `^8.4.1` to `^8.4.2` ([#6917](https://github.com/MetaMask/core/pull/6917)) +- Bump `@metamask/network-controller` from `^24.2.2` to `^24.3.0` ([#6883](https://github.com/MetaMask/core/pull/6883)) +- Bump `@metamask/transaction-controller` from `^60.7.0` to `^60.8.0` ([#6883](https://github.com/MetaMask/core/pull/6883)) + +## [8.0.1] + +### Changed + +- Bump `@metamask/base-controller` from `^8.4.0` to `^8.4.1` ([#6807](https://github.com/MetaMask/core/pull/6807)) +- Bump `@metamask/controller-utils` from `^11.14.0` to `^11.14.1` ([#6807](https://github.com/MetaMask/core/pull/6807)) + +## [8.0.0] + +### Added + +- Add two new controller state metadata properties: `includeInStateLogs` and `usedInUi` ([#6555](https://github.com/MetaMask/core/pull/6555)) + +### Changed + +- **BREAKING:** Bump peer dependency `@metamask/account-tree-controller` from `^0.12.1` to `^1.0.0` ([#6652](https://github.com/MetaMask/core/pull/6652), [#6676](https://github.com/MetaMask/core/pull/6676)) +- Bump `@metamask/controller-utils` from `^11.12.0` to `^11.14.0` ([#6620](https://github.com/MetaMask/core/pull/6620), [#6629](https://github.com/MetaMask/core/pull/6629)) +- Bump `@metamask/base-controller` from `^8.2.0` to `^8.4.0` ([#6465](https://github.com/MetaMask/core/pull/6465), [#6632](https://github.com/MetaMask/core/pull/6632)) +- Bump `@metamask/keyring-api` from `^20.1.0` to `^21.0.0` ([#6560](https://github.com/MetaMask/core/pull/6560)) + +## [7.0.0] + +### Added + +- Added `@metamask/keyring-api` as a dependency ([#6402](https://github.com/MetaMask/core/pull/6402)) +- Added `@metamask/account-tree-controller` as a dev and peer dependency ([#6402](https://github.com/MetaMask/core/pull/6402)) + +### Changed + +- **BREAKING:** `EarnController` messenger must now allow `AccountTreeController:selectedAccountGroupChange` and `AccountTreeController:getAccountsFromSelectedAccountGroup` for BIP-44 compatibility and must not allow `AccountsController:selectedAccountChange` and `AccountsController:getSelectedAccount` ([#6402](https://github.com/MetaMask/core/pull/6402)) +- `executeLendingDeposit`, `executeLendingWithdraw` and `executeLendingTokenApprove` now throw errors if no selected address is found ([#6402](https://github.com/MetaMask/core/pull/6402)) +- `getLendingTokenAllowance`, `getLendingTokenMaxWithdraw` and `getLendingTokenMaxDeposit` now return `undefined` is no selected address is found ([#6402](https://github.com/MetaMask/core/pull/6402)) +- Bump `@metamask/base-controller` from `^8.1.0` to `^8.2.0` ([#6355](https://github.com/MetaMask/core/pull/6355)) + +### Removed + +- Removed `@metamask/accounts-controller` as a dev and peer dependency ([#6402](https://github.com/MetaMask/core/pull/6402)) + +## [6.0.0] + +### Changed + +- **BREAKING:** Bump peer dependency `@metamask/accounts-controller` from `^32.0.0` to `^33.0.0` ([#6345](https://github.com/MetaMask/core/pull/6345)) +- Bump `@metamask/controller-utils` from `^11.11.0` to `^11.12.0` ([#6303](https://github.com/MetaMask/core/pull/6303)) + +## [5.0.0] + +### Added + +- **BREAKING:** Added mandatory parameter `selectedNetworkClientId` to `EarnController` constructor ([#6153](https://github.com/MetaMask/core/pull/6153)) +- **BREAKING:** Added mandatory `chainId` parameter to `executeLendingTokenApprove`, `executeLendingWithdraw`, `executeLendingDeposit`, `getLendingMarketDailyApysAndAverages` and `getLendingPositionHistory` methods ([#6153](https://github.com/MetaMask/core/pull/6153)) +- **BREAKING:** Changed `refreshPooledStakingVaultDailyApys` to accept an options object with `chainId`, `days`, and `order` properties, where `chainId` is a new option, instead of separate parameters `days` and `order` ([#6153](https://github.com/MetaMask/core/pull/6153)) +- Added optional `chainId` parameter to `refreshPooledStakingVaultApyAverages`, `refreshPooledStakingVaultMetadata` and `refreshPooledStakes` (defaults to Ethereum) ([#6153](https://github.com/MetaMask/core/pull/6153)) + +### Changed + +- **BREAKING:** Removed usages of `NetworkController:getState` for GNS removal. ([#6153](https://github.com/MetaMask/core/pull/6153)) +- **BREAKING:** `EarnController` messenger must now allow `NetworkController:networkDidChange` and must not allow `NetworkController:getState` and `NetworkController:stateChange` ([#6153](https://github.com/MetaMask/core/pull/6153)) +- `refreshPooledStakingData` now refreshes for all supported chains, not just global chain ([#6153](https://github.com/MetaMask/core/pull/6153)) +- Bump `@metamask/base-controller` from `^8.0.1` to `^8.1.0` ([#6284](https://github.com/MetaMask/core/pull/6284)) + +## [4.0.0] + +### Changed + +- **BREAKING:** Bump peer dependency `@metamask/accounts-controller` to `^32.0.0` ([#6171](https://github.com/MetaMask/core/pull/6171)) + +## [3.0.0] + +### Changed + +- **BREAKING:** Removed `chainId` parameter from `refreshPooledStakingVaultMetadata`, `refreshPooledStakingVaultDailyApys`, `refreshPooledStakingVaultApyAverages`, and `refreshPooledStakes` methods. ([#6106](https://github.com/MetaMask/core/pull/6106)) +- Bump `@metamask/controller-utils` from `^11.10.0` to `^11.11.0` ([#6069](https://github.com/MetaMask/core/pull/6069)) + +## [2.0.1] + +### Changed + +- Changes `EarnController.addTransaction` gasLimit logic in several methods such that the param can be set undefined through contract method param `gasOptions.gasLimit` being set to `none` ([#6038](https://github.com/MetaMask/core/pull/6038)) + - `executeLendingDeposit` + - `executeLendingWithdraw` + - `executeLendingTokenApprove` + +## [2.0.0] + +### Changed + +- **BREAKING:** Bump peer dependency `@metamask/accounts-controller` to `^31.0.0` ([#5999](https://github.com/MetaMask/core/pull/5999)) +- **BREAKING:** Bump peer dependency `@metamask/network-controller` to `^24.0.0` ([#5999](https://github.com/MetaMask/core/pull/5999)) + +## [1.1.1] + +### Changed + +- Bump `@metamask/stake-sdk` to `^3.2.1` ([#5972](https://github.com/MetaMask/core/pull/5972)) +- Bump `@metamask/transaction-controller` to `^57.3.0` ([#5954](https://github.com/MetaMask/core/pull/5954)) + +## [1.1.0] + +### Changed + +- Replace hardcoded `"lendingWithdraw"` in `LendingTransactionTypes` with `TransactionType.lendingWithdraw` ([#5936](https://github.com/MetaMask/core/pull/5936)) +- Bump `@metamask/controller-utils` to `^11.10.0` ([#5935](https://github.com/MetaMask/core/pull/5935)) + +## [1.0.0] + +### Added + +- **BREAKING:** Added `addTransactionFn` option to the controller contructor which accepts the `TransactionController` `addTransaction` method ([#5828](https://github.com/MetaMask/core/pull/5828)) +- Added `@ethersproject/bignumber` as a dependency ([#5828](https://github.com/MetaMask/core/pull/5828)) +- Added `reselect` as a dependency ([#5828](https://github.com/MetaMask/core/pull/5828)) +- Added new lending-related types: ([#5828](https://github.com/MetaMask/core/pull/5828)) + - `LendingMarketWithPosition` + - `LendingPositionWithMarket` + - `LendingPositionWithMarketReference` +- Added new lending-related selectors: ([#5828](https://github.com/MetaMask/core/pull/5828)) + - `selectLendingMarkets` + - `selectLendingPositions` + - `selectLendingMarketsWithPosition` + - `selectLendingPositionsByProtocol` + - `selectLendingMarketByProtocolAndTokenAddress` + - `selectLendingMarketForProtocolAndTokenAddress` + - `selectLendingPositionsByChainId` + - `selectLendingMarketsByChainId` + - `selectLendingMarketsByProtocolAndId` + - `selectLendingMarketForProtocolAndId` + - `selectLendingPositionsWithMarket` + - `selectLendingMarketsForChainId` + - `selectIsLendingEligible` + - `selectLendingPositionsByProtocolChainIdMarketId` + - `selectLendingMarketsByTokenAddress` + - `selectLendingMarketsByChainIdAndOutputTokenAddress` + - `selectLendingMarketsByChainIdAndTokenAddress` +- Added exports from `@metamask/stake-sdk`: ([#5828](https://github.com/MetaMask/core/pull/5828)) + - `isSupportedLendingChain` + - `isSupportedPooledStakingChain` + - `CHAIN_ID_TO_AAVE_POOL_CONTRACT` +- Added new lending-related methods to `EarnController`: ([#5828](https://github.com/MetaMask/core/pull/5828)) + - `refreshLendingMarkets` + - `refreshLendingPositions` + - `refreshLendingEligibility` + - `refreshLendingData` + - `getLendingPositionHistory` + - `getLendingMarketDailyApysAndAverages` + - `executeLendingDeposit` + - `executeLendingWithdraw` + - `executeLendingTokenApprove` + - `getLendingTokenAllowance` + - `getLendingTokenMaxWithdraw` + - `getLendingTokenMaxDeposit` +- **BREAKING:** Added `lending` key to the controller state to replace `stablecoin_lending` ([#5828](https://github.com/MetaMask/core/pull/5828)) +- Added optional `env` option which accepts an `EarnEnvironments` enum ([#5828](https://github.com/MetaMask/core/pull/5828)) +- Added async lending state data update on constructor initialization ([#5828](https://github.com/MetaMask/core/pull/5828)) +- Added refresh of lending positions and market data when the network state is updated ([#5828](https://github.com/MetaMask/core/pull/5828)) +- Added refresh of lending positions when the user account address is updated ([#5828](https://github.com/MetaMask/core/pull/5828)) +- Added refresh of lending positions when a transaction matching lending type is confirmed ([#5828](https://github.com/MetaMask/core/pull/5828)) + +### Changed + +- **BREAKING:** Updated `refreshPooledStakingVaultDailyApys` method to take chain id as its first param ([#5828](https://github.com/MetaMask/core/pull/5828)) +- **BREAKING:** bump `@metamask/accounts-controller` peer dependency to `^30.0.0` ([#5888](https://github.com/MetaMask/core/pull/5888)) +- **BREAKING:** updates controller state to allow pooled staking data to be stored per supported chain id ([#5828](https://github.com/MetaMask/core/pull/5828)) +- Updated `refreshPooledStakingData` to refresh pooled staking data for all supported chains ([#5828](https://github.com/MetaMask/core/pull/5828)) +- Updated these methods to take an optional chain id to control which chain data is fetched for ([#5828](https://github.com/MetaMask/core/pull/5828)) + - `refreshPooledStakingVaultMetadata` + - `refreshPooledStakes` + - `refreshPooledStakingVaultDailyApys` + - `refreshPooledStakingVaultApyAverages` +- Updated `refreshStakingEligibility` to update the eligibility in the lending state scope as well pooled staking ([#5828](https://github.com/MetaMask/core/pull/5828)) +- Updated `refreshPooledStakes` method to take an optional chain id to control which chain data is fetched for ([#5828](https://github.com/MetaMask/core/pull/5828)) +- Updated to refresh pooled staking data for all chains when the network state is updated ([#5828](https://github.com/MetaMask/core/pull/5828)) +- Bump `@metamask/controller-utils` to `^11.9.0` ([#5812](https://github.com/MetaMask/core/pull/5812)) +- Bump `@metamask/stake-sdk` dependency to `^3.2.0` ([#5828](https://github.com/MetaMask/core/pull/5828)) + +### Removed + +- **BREAKING:** Removed lending-related types: ([#5828](https://github.com/MetaMask/core/pull/5828)) + - `StablecoinLendingState` + - `StablecoinVault` +- **BREAKING:** Removed `stablecoin_lending` key from the controller state to replace with `lending` ([#5828](https://github.com/MetaMask/core/pull/5828)) + +## [0.15.0] + +### Changed + +- **BREAKING:** bump `@metamask/accounts-controller` peer dependency to `^30.0.0` ([#5888](https://github.com/MetaMask/core/pull/5888)) +- Bump `@metamask/controller-utils` to `^11.9.0` ([#5812](https://github.com/MetaMask/core/pull/5812)) + +## [0.14.0] + +### Changed + +- **BREAKING:** bump `@metamask/accounts-controller` peer dependency to `^29.0.0` ([#5802](https://github.com/MetaMask/core/pull/5802)) +- Bump `@metamask/controller-utils` to `^11.8.0` ([#5765](https://github.com/MetaMask/core/pull/5765)) + +## [0.13.0] + +### Changed + +- **BREAKING:** Bump peer dependency `@metamask/accounts-controller` to `^28.0.0` ([#5763](https://github.com/MetaMask/core/pull/5763)) +- Bump `@metamask/base-controller` from `^8.0.0` to `^8.0.1` ([#5722](https://github.com/MetaMask/core/pull/5722)) + +## [0.12.0] + +### Changed + +- **BREAKING:** Hardcoded Ethereum mainnet as selected chainId ([#5650](https://github.com/MetaMask/core/pull/5650)) + +## [0.11.0] + +### Added + +- Refresh staking data when staking txs are confirmed ([#5607](https://github.com/MetaMask/core/pull/5607)) + +### Changed + +- Bump `@metamask/controller-utils` to `^11.7.0` ([#5583](https://github.com/MetaMask/core/pull/5583)) + +## [0.10.0] + +### Changed + +- **BREAKING:** Updated `EarnController` methods (`refreshPooledStakingData`, `refreshPooledStakes`, and `refreshStakingEligibility`) to use an options bag parameter ([#5537](https://github.com/MetaMask/core/pull/5537)) + +## [0.9.0] + +### Changed + +- **BREAKING:** Bump peer dependency `@metamask/accounts-controller` to `^27.0.0` ([#5507](https://github.com/MetaMask/core/pull/5507)) +- **BREAKING:** Bump peer dependency `@metamask/network-controller` to `^23.0.0` ([#5507](https://github.com/MetaMask/core/pull/5507)) + +## [0.8.0] + +### Changed + +- Updated refreshPooledStakingVaultDailyApys days arg default value to 365 ([#5453](https://github.com/MetaMask/core/pull/5453)) + +## [0.7.0] + +### Changed + +- **BREAKING:** Bump `@metamask/accounts-controller` peer dependency to `^26.0.0` ([#5439](https://github.com/MetaMask/core/pull/5439)) + +## [0.6.0] + +### Changed + +- **BREAKING:** Bump `@metamask/accounts-controller` peer dependency to `^25.0.0` ([#5426](https://github.com/MetaMask/core/pull/5426)) + +## [0.5.0] + +### Added + +- Add pooled staking vault daily apys and vault apy averages to earn controller ([#5368](https://github.com/MetaMask/core/pull/5368)) + +## [0.4.0] + +### Added + +- Add resetCache arg to `refreshPooledStakingData` and `refreshPooledStakes` in EarnController ([#5334](https://github.com/MetaMask/core/pull/5334)) + +## [0.3.0] + +### Changed + +- **BREAKING:** Bump `@metamask/accounts-controller` peer dependency from `^23.0.0` to `^24.0.0` ([#5318](https://github.com/MetaMask/core/pull/5318)) + +## [0.2.1] + +### Changed + +- Bump `@metamask/base-controller` from `^7.1.1` to `^8.0.0` ([#5305](https://github.com/MetaMask/core/pull/5305)) + +## [0.2.0] + +### Changed + +- **BREAKING:** Bump `@metamask/accounts-controller` peer dependency from `^22.0.0` to `^23.0.0` ([#5292](https://github.com/MetaMask/core/pull/5292)) +- Bump `@metamask/controller-utils` dependency from `^11.4.5` to `^11.5.0`([#5272](https://github.com/MetaMask/core/pull/5272)) + +## [0.1.0] + +### Added + +- Initial release ([#5271](https://github.com/MetaMask/core/pull/5271)) + +[Unreleased]: https://github.com/MetaMask/core/compare/@metamask/earn-controller@12.2.6...HEAD +[12.2.6]: https://github.com/MetaMask/core/compare/@metamask/earn-controller@12.2.5...@metamask/earn-controller@12.2.6 +[12.2.5]: https://github.com/MetaMask/core/compare/@metamask/earn-controller@12.2.4...@metamask/earn-controller@12.2.5 +[12.2.4]: https://github.com/MetaMask/core/compare/@metamask/earn-controller@12.2.3...@metamask/earn-controller@12.2.4 +[12.2.3]: https://github.com/MetaMask/core/compare/@metamask/earn-controller@12.2.2...@metamask/earn-controller@12.2.3 +[12.2.2]: https://github.com/MetaMask/core/compare/@metamask/earn-controller@12.2.1...@metamask/earn-controller@12.2.2 +[12.2.1]: https://github.com/MetaMask/core/compare/@metamask/earn-controller@12.2.0...@metamask/earn-controller@12.2.1 +[12.2.0]: https://github.com/MetaMask/core/compare/@metamask/earn-controller@12.1.2...@metamask/earn-controller@12.2.0 +[12.1.2]: https://github.com/MetaMask/core/compare/@metamask/earn-controller@12.1.1...@metamask/earn-controller@12.1.2 +[12.1.1]: https://github.com/MetaMask/core/compare/@metamask/earn-controller@12.1.0...@metamask/earn-controller@12.1.1 +[12.1.0]: https://github.com/MetaMask/core/compare/@metamask/earn-controller@12.0.0...@metamask/earn-controller@12.1.0 +[12.0.0]: https://github.com/MetaMask/core/compare/@metamask/earn-controller@11.2.1...@metamask/earn-controller@12.0.0 +[11.2.1]: https://github.com/MetaMask/core/compare/@metamask/earn-controller@11.2.0...@metamask/earn-controller@11.2.1 +[11.2.0]: https://github.com/MetaMask/core/compare/@metamask/earn-controller@11.1.2...@metamask/earn-controller@11.2.0 +[11.1.2]: https://github.com/MetaMask/core/compare/@metamask/earn-controller@11.1.1...@metamask/earn-controller@11.1.2 +[11.1.1]: https://github.com/MetaMask/core/compare/@metamask/earn-controller@11.1.0...@metamask/earn-controller@11.1.1 +[11.1.0]: https://github.com/MetaMask/core/compare/@metamask/earn-controller@11.0.0...@metamask/earn-controller@11.1.0 +[11.0.0]: https://github.com/MetaMask/core/compare/@metamask/earn-controller@10.0.0...@metamask/earn-controller@11.0.0 +[10.0.0]: https://github.com/MetaMask/core/compare/@metamask/earn-controller@9.0.0...@metamask/earn-controller@10.0.0 +[9.0.0]: https://github.com/MetaMask/core/compare/@metamask/earn-controller@8.0.2...@metamask/earn-controller@9.0.0 +[8.0.2]: https://github.com/MetaMask/core/compare/@metamask/earn-controller@8.0.1...@metamask/earn-controller@8.0.2 +[8.0.1]: https://github.com/MetaMask/core/compare/@metamask/earn-controller@8.0.0...@metamask/earn-controller@8.0.1 +[8.0.0]: https://github.com/MetaMask/core/compare/@metamask/earn-controller@7.0.0...@metamask/earn-controller@8.0.0 +[7.0.0]: https://github.com/MetaMask/core/compare/@metamask/earn-controller@6.0.0...@metamask/earn-controller@7.0.0 +[6.0.0]: https://github.com/MetaMask/core/compare/@metamask/earn-controller@5.0.0...@metamask/earn-controller@6.0.0 +[5.0.0]: https://github.com/MetaMask/core/compare/@metamask/earn-controller@4.0.0...@metamask/earn-controller@5.0.0 +[4.0.0]: https://github.com/MetaMask/core/compare/@metamask/earn-controller@3.0.0...@metamask/earn-controller@4.0.0 +[3.0.0]: https://github.com/MetaMask/core/compare/@metamask/earn-controller@2.0.1...@metamask/earn-controller@3.0.0 +[2.0.1]: https://github.com/MetaMask/core/compare/@metamask/earn-controller@2.0.0...@metamask/earn-controller@2.0.1 +[2.0.0]: https://github.com/MetaMask/core/compare/@metamask/earn-controller@1.1.1...@metamask/earn-controller@2.0.0 +[1.1.1]: https://github.com/MetaMask/core/compare/@metamask/earn-controller@1.1.0...@metamask/earn-controller@1.1.1 +[1.1.0]: https://github.com/MetaMask/core/compare/@metamask/earn-controller@1.0.0...@metamask/earn-controller@1.1.0 +[1.0.0]: https://github.com/MetaMask/core/compare/@metamask/earn-controller@0.15.0...@metamask/earn-controller@1.0.0 +[0.15.0]: https://github.com/MetaMask/core/compare/@metamask/earn-controller@0.14.0...@metamask/earn-controller@0.15.0 +[0.14.0]: https://github.com/MetaMask/core/compare/@metamask/earn-controller@0.13.0...@metamask/earn-controller@0.14.0 +[0.13.0]: https://github.com/MetaMask/core/compare/@metamask/earn-controller@0.12.0...@metamask/earn-controller@0.13.0 +[0.12.0]: https://github.com/MetaMask/core/compare/@metamask/earn-controller@0.11.0...@metamask/earn-controller@0.12.0 +[0.11.0]: https://github.com/MetaMask/core/compare/@metamask/earn-controller@0.10.0...@metamask/earn-controller@0.11.0 +[0.10.0]: https://github.com/MetaMask/core/compare/@metamask/earn-controller@0.9.0...@metamask/earn-controller@0.10.0 +[0.9.0]: https://github.com/MetaMask/core/compare/@metamask/earn-controller@0.8.0...@metamask/earn-controller@0.9.0 +[0.8.0]: https://github.com/MetaMask/core/compare/@metamask/earn-controller@0.7.0...@metamask/earn-controller@0.8.0 +[0.7.0]: https://github.com/MetaMask/core/compare/@metamask/earn-controller@0.6.0...@metamask/earn-controller@0.7.0 +[0.6.0]: https://github.com/MetaMask/core/compare/@metamask/earn-controller@0.5.0...@metamask/earn-controller@0.6.0 +[0.5.0]: https://github.com/MetaMask/core/compare/@metamask/earn-controller@0.4.0...@metamask/earn-controller@0.5.0 +[0.4.0]: https://github.com/MetaMask/core/compare/@metamask/earn-controller@0.3.0...@metamask/earn-controller@0.4.0 +[0.3.0]: https://github.com/MetaMask/core/compare/@metamask/earn-controller@0.2.1...@metamask/earn-controller@0.3.0 +[0.2.1]: https://github.com/MetaMask/core/compare/@metamask/earn-controller@0.2.0...@metamask/earn-controller@0.2.1 +[0.2.0]: https://github.com/MetaMask/core/compare/@metamask/earn-controller@0.1.0...@metamask/earn-controller@0.2.0 +[0.1.0]: https://github.com/MetaMask/core/releases/tag/@metamask/earn-controller@0.1.0 diff --git a/packages/earn-controller/LICENSE b/packages/earn-controller/LICENSE new file mode 100644 index 00000000000..c259cd7ebcf --- /dev/null +++ b/packages/earn-controller/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2025 MetaMask + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packages/earn-controller/README.md b/packages/earn-controller/README.md new file mode 100644 index 00000000000..67da818b7f2 --- /dev/null +++ b/packages/earn-controller/README.md @@ -0,0 +1,15 @@ +# `@metamask/earn-controller` + +Manages state for earning features and coordinates interactions between staking services, SDK integrations, and other controllers to enable users to participate in various earning opportunities. + +## Installation + +`yarn add @metamask/earn-controller` + +or + +`npm install @metamask/earn-controller` + +## Contributing + +This package is part of a monorepo. Instructions for contributing can be found in the [monorepo README](https://github.com/MetaMask/core#readme). diff --git a/packages/earn-controller/jest.config.js b/packages/earn-controller/jest.config.js new file mode 100644 index 00000000000..66bf6e01f38 --- /dev/null +++ b/packages/earn-controller/jest.config.js @@ -0,0 +1,26 @@ +/* + * For a detailed explanation regarding each configuration property and type check, visit: + * https://jestjs.io/docs/configuration + */ + +const merge = require('deepmerge'); +const path = require('path'); + +const baseConfig = require('../../jest.config.packages'); + +const displayName = path.basename(__dirname); + +module.exports = merge(baseConfig, { + // The display name when running multiple projects + displayName, + + // An object that configures minimum threshold enforcement for coverage results + coverageThreshold: { + global: { + branches: 96.52, + functions: 100, + lines: 100, + statements: 100, + }, + }, +}); diff --git a/packages/earn-controller/package.json b/packages/earn-controller/package.json new file mode 100644 index 00000000000..516ce0d067f --- /dev/null +++ b/packages/earn-controller/package.json @@ -0,0 +1,85 @@ +{ + "name": "@metamask/earn-controller", + "version": "12.2.6", + "description": "Manages state for earning features and coordinates interactions between staking services, SDK integrations, and other controllers to enable users to participate in various earning opportunities", + "keywords": [ + "Ethereum", + "MetaMask" + ], + "homepage": "https://github.com/MetaMask/core/tree/main/packages/earn-controller#readme", + "bugs": { + "url": "https://github.com/MetaMask/core/issues" + }, + "license": "MIT", + "repository": { + "type": "git", + "url": "https://github.com/MetaMask/core.git" + }, + "files": [ + "dist/" + ], + "sideEffects": false, + "main": "./dist/index.cjs", + "types": "./dist/index.d.cts", + "exports": { + ".": { + "import": { + "types": "./dist/index.d.mts", + "default": "./dist/index.mjs" + }, + "require": { + "types": "./dist/index.d.cts", + "default": "./dist/index.cjs" + } + }, + "./package.json": "./package.json" + }, + "publishConfig": { + "access": "public", + "registry": "https://registry.npmjs.org/" + }, + "scripts": { + "build": "ts-bridge --project tsconfig.build.json --verbose --clean --no-references", + "build:all": "ts-bridge --project tsconfig.build.json --verbose --clean", + "build:docs": "typedoc", + "changelog:update": "../../scripts/update-changelog.sh @metamask/earn-controller", + "changelog:validate": "../../scripts/validate-changelog.sh @metamask/earn-controller", + "lint:tsconfigs": "tsx ../../scripts/lint-tsconfigs/lint-tsconfigs.mts", + "lint:tsconfigs:fix": "tsx ../../scripts/lint-tsconfigs/lint-tsconfigs.mts --fix", + "messenger-action-types:check": "tsx ../../packages/messenger-cli/src/cli.ts --formatter oxfmt --esm --check", + "messenger-action-types:generate": "tsx ../../packages/messenger-cli/src/cli.ts --formatter oxfmt --esm --generate", + "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": { + "@ethersproject/bignumber": "^5.7.0", + "@ethersproject/providers": "^5.7.0", + "@metamask/account-tree-controller": "^8.0.0", + "@metamask/base-controller": "^9.1.0", + "@metamask/controller-utils": "^12.3.0", + "@metamask/keyring-api": "^24.0.0", + "@metamask/messenger": "^2.0.0", + "@metamask/network-controller": "^36.0.0", + "@metamask/stake-sdk": "^3.2.1", + "reselect": "^5.1.1" + }, + "devDependencies": { + "@metamask/auto-changelog": "^6.1.0", + "@metamask/transaction-controller": "^69.6.1", + "@ts-bridge/cli": "^0.6.4", + "@types/jest": "^30.0.0", + "deepmerge": "^4.2.2", + "jest": "^30.4.2", + "ts-jest": "^29.4.11", + "tsx": "^4.20.5", + "typedoc": "^0.25.13", + "typedoc-plugin-missing-exports": "^2.0.0", + "typescript": "~5.3.3" + }, + "engines": { + "node": "^18.18 || >=20" + } +} diff --git a/packages/earn-controller/src/EarnController-method-action-types.ts b/packages/earn-controller/src/EarnController-method-action-types.ts new file mode 100644 index 00000000000..6d5f29a0620 --- /dev/null +++ b/packages/earn-controller/src/EarnController-method-action-types.ts @@ -0,0 +1,302 @@ +/** + * This file is auto generated. + * Do not edit manually. + */ + +import type { EarnController } from './EarnController.js'; + +/** + * Refreshes the pooled stakes data for the current account. + * Fetches updated stake information including lifetime rewards, assets, and exit requests + * from the staking API service and updates the state. + * + * @param options - Optional arguments + * @param [options.resetCache] - Control whether the BE cache should be invalidated (optional). + * @param [options.address] - The address to refresh pooled stakes for (optional). + * @param [options.chainId] - The chain id to refresh pooled stakes for (optional). + * @returns A promise that resolves when the stakes data has been updated + */ +export type EarnControllerRefreshPooledStakesAction = { + type: `EarnController:refreshPooledStakes`; + handler: EarnController['refreshPooledStakes']; +}; + +/** + * Refreshes the earn eligibility status for the current account. + * Updates the eligibility status in the controller state based on the location and address blocklist for compliance. + * + * Note: Pooled-staking and Lending used the same result since there isn't a need to split these up right now. + * + * @param options - Optional arguments + * @param [options.address] - Address to refresh earn eligibility for (optional). + * @returns A promise that resolves when the eligibility status has been updated + */ +export type EarnControllerRefreshEarnEligibilityAction = { + type: `EarnController:refreshEarnEligibility`; + handler: EarnController['refreshEarnEligibility']; +}; + +/** + * Refreshes pooled staking vault metadata for the current chain. + * Updates the vault metadata in the controller state including APY, capacity, + * fee percentage, total assets, and vault address. + * + * @param [chainId] - The chain id to refresh pooled staking vault metadata for (optional). + * @returns A promise that resolves when the vault metadata has been updated + */ +export type EarnControllerRefreshPooledStakingVaultMetadataAction = { + type: `EarnController:refreshPooledStakingVaultMetadata`; + handler: EarnController['refreshPooledStakingVaultMetadata']; +}; + +/** + * Refreshes pooled staking vault daily apys for the current chain. + * Updates the pooled staking vault daily apys controller state. + * + * @param [options] - The options for refreshing pooled staking vault daily apys. + * @param [options.chainId] - The chain id to refresh pooled staking vault daily apys for (defaults to Ethereum). + * @param [options.days] - The number of days to fetch pooled staking vault daily apys for (defaults to 365). + * @param [options.order] - The order in which to fetch pooled staking vault daily apys. Descending order fetches the latest N days (latest working backwards). Ascending order fetches the oldest N days (oldest working forwards) (defaults to 'desc'). + * @returns A promise that resolves when the pooled staking vault daily apys have been updated. + */ +export type EarnControllerRefreshPooledStakingVaultDailyApysAction = { + type: `EarnController:refreshPooledStakingVaultDailyApys`; + handler: EarnController['refreshPooledStakingVaultDailyApys']; +}; + +/** + * Refreshes pooled staking vault apy averages for the current chain. + * Updates the pooled staking vault apy averages controller state. + * + * @param [chainId] - The chain id to refresh pooled staking vault apy averages for (optional). + * @returns A promise that resolves when the pooled staking vault apy averages have been updated. + */ +export type EarnControllerRefreshPooledStakingVaultApyAveragesAction = { + type: `EarnController:refreshPooledStakingVaultApyAverages`; + handler: EarnController['refreshPooledStakingVaultApyAverages']; +}; + +/** + * Refreshes all pooled staking related data including stakes, eligibility, and vault data. + * This method allows partial success, meaning some data may update while other requests fail. + * All errors are collected and thrown as a single error message. + * + * @param options - Optional arguments + * @param [options.resetCache] - Control whether the BE cache should be invalidated (optional). + * @param [options.address] - The address to refresh pooled stakes for (optional). + * @returns A promise that resolves when all possible data has been updated + * @throws {Error} If any of the refresh operations fail, with concatenated error messages + */ +export type EarnControllerRefreshPooledStakingDataAction = { + type: `EarnController:refreshPooledStakingData`; + handler: EarnController['refreshPooledStakingData']; +}; + +/** + * Refreshes the lending markets data for all chains. + * Updates the lending markets in the controller state. + * + * @returns A promise that resolves when the lending markets have been updated + */ +export type EarnControllerRefreshLendingMarketsAction = { + type: `EarnController:refreshLendingMarkets`; + handler: EarnController['refreshLendingMarkets']; +}; + +/** + * Refreshes the lending positions for the current account. + * Updates the lending positions in the controller state. + * + * @param options - Optional arguments + * @param [options.address] - The address to refresh lending positions for (optional). + * @returns A promise that resolves when the lending positions have been updated + */ +export type EarnControllerRefreshLendingPositionsAction = { + type: `EarnController:refreshLendingPositions`; + handler: EarnController['refreshLendingPositions']; +}; + +/** + * Refreshes all lending related data including markets, positions, and eligibility. + * This method allows partial success, meaning some data may update while other requests fail. + * All errors are collected and thrown as a single error message. + * + * @returns A promise that resolves when all possible data has been updated + * @throws {Error} If any of the refresh operations fail, with concatenated error messages + */ +export type EarnControllerRefreshLendingDataAction = { + type: `EarnController:refreshLendingData`; + handler: EarnController['refreshLendingData']; +}; + +/** + * Refreshes the APY for TRON staking. + * The consumer provides a fetcher function that returns the APY for TRON. + * + * @param apyFetcher - An async function that fetches and returns the APY as a decimal string. + * @returns A promise that resolves when the APY has been updated. + */ +export type EarnControllerRefreshTronStakingApyAction = { + type: `EarnController:refreshTronStakingApy`; + handler: EarnController['refreshTronStakingApy']; +}; + +/** + * Gets the TRON staking APY. + * + * @returns The APY for TRON staking, or undefined if not available. + */ +export type EarnControllerGetTronStakingApyAction = { + type: `EarnController:getTronStakingApy`; + handler: EarnController['getTronStakingApy']; +}; + +/** + * Gets the lending position history for the current account. + * + * @param options - Optional arguments + * @param [options.address] - The address to get lending position history for (optional). + * @param options.chainId - The chain id to get lending position history for. + * @param [options.positionId] - The position id to get lending position history for. + * @param [options.marketId] - The market id to get lending position history for. + * @param [options.marketAddress] - The market address to get lending position history for. + * @param [options.protocol] - The protocol to get lending position history for. + * @param [options.days] - The number of days to get lending position history for (optional). + * @returns A promise that resolves when the lending position history has been updated + */ +export type EarnControllerGetLendingPositionHistoryAction = { + type: `EarnController:getLendingPositionHistory`; + handler: EarnController['getLendingPositionHistory']; +}; + +/** + * Gets the lending market daily apys and averages for the current chain. + * + * @param options - Optional arguments + * @param options.chainId - The chain id to get lending market daily apys and averages for. + * @param [options.protocol] - The protocol to get lending market daily apys and averages for. + * @param [options.marketId] - The market id to get lending market daily apys and averages for. + * @param [options.days] - The number of days to get lending market daily apys and averages for (optional). + * @returns A promise that resolves when the lending market daily apys and averages have been updated + */ +export type EarnControllerGetLendingMarketDailyApysAndAveragesAction = { + type: `EarnController:getLendingMarketDailyApysAndAverages`; + handler: EarnController['getLendingMarketDailyApysAndAverages']; +}; + +/** + * Executes a lending deposit transaction. + * + * @param options - The options for the lending deposit transaction. + * @param options.amount - The amount to deposit. + * @param options.chainId - The chain ID for the lending deposit transaction. + * @param options.protocol - The protocol of the lending market. + * @param options.underlyingTokenAddress - The address of the underlying token. + * @param options.gasOptions - The gas options for the transaction. + * @param options.gasOptions.gasLimit - The gas limit for the transaction. + * @param options.gasOptions.gasBufferPct - The gas buffer percentage for the transaction. + * @param options.txOptions - The transaction options for the transaction. + * @returns A promise that resolves to the transaction hash. + */ +export type EarnControllerExecuteLendingDepositAction = { + type: `EarnController:executeLendingDeposit`; + handler: EarnController['executeLendingDeposit']; +}; + +/** + * Executes a lending withdraw transaction. + * + * @param options - The options for the lending withdraw transaction. + * @param options.amount - The amount to withdraw. + * @param options.chainId - The chain ID for the lending withdraw transaction. + * @param options.protocol - The protocol of the lending market. + * @param options.underlyingTokenAddress - The address of the underlying token. + * @param options.gasOptions - The gas options for the transaction. + * @param options.gasOptions.gasLimit - The gas limit for the transaction. + * @param options.gasOptions.gasBufferPct - The gas buffer percentage for the transaction. + * @param options.txOptions - The transaction options for the transaction. + * @returns A promise that resolves to the transaction hash. + */ +export type EarnControllerExecuteLendingWithdrawAction = { + type: `EarnController:executeLendingWithdraw`; + handler: EarnController['executeLendingWithdraw']; +}; + +/** + * Executes a lending token approve transaction. + * + * @param options - The options for the lending token approve transaction. + * @param options.amount - The amount to approve. + * @param options.chainId - The chain ID for the lending token approve transaction. + * @param options.protocol - The protocol of the lending market. + * @param options.underlyingTokenAddress - The address of the underlying token. + * @param options.gasOptions - The gas options for the transaction. + * @param options.gasOptions.gasLimit - The gas limit for the transaction. + * @param options.gasOptions.gasBufferPct - The gas buffer percentage for the transaction. + * @param options.txOptions - The transaction options for the transaction. + * @returns A promise that resolves to the transaction hash. + */ +export type EarnControllerExecuteLendingTokenApproveAction = { + type: `EarnController:executeLendingTokenApprove`; + handler: EarnController['executeLendingTokenApprove']; +}; + +/** + * Gets the allowance for a lending token. + * + * @param protocol - The protocol of the lending market. + * @param underlyingTokenAddress - The address of the underlying token. + * @returns A promise that resolves to the allowance. + */ +export type EarnControllerGetLendingTokenAllowanceAction = { + type: `EarnController:getLendingTokenAllowance`; + handler: EarnController['getLendingTokenAllowance']; +}; + +/** + * Gets the maximum withdraw amount for a lending token's output token or shares if no output token. + * + * @param protocol - The protocol of the lending market. + * @param underlyingTokenAddress - The address of the underlying token. + * @returns A promise that resolves to the maximum withdraw amount. + */ +export type EarnControllerGetLendingTokenMaxWithdrawAction = { + type: `EarnController:getLendingTokenMaxWithdraw`; + handler: EarnController['getLendingTokenMaxWithdraw']; +}; + +/** + * Gets the maximum deposit amount for a lending token. + * + * @param protocol - The protocol of the lending market. + * @param underlyingTokenAddress - The address of the underlying token. + * @returns A promise that resolves to the maximum deposit amount. + */ +export type EarnControllerGetLendingTokenMaxDepositAction = { + type: `EarnController:getLendingTokenMaxDeposit`; + handler: EarnController['getLendingTokenMaxDeposit']; +}; + +/** + * Union of all EarnController action types. + */ +export type EarnControllerMethodActions = + | EarnControllerRefreshPooledStakesAction + | EarnControllerRefreshEarnEligibilityAction + | EarnControllerRefreshPooledStakingVaultMetadataAction + | EarnControllerRefreshPooledStakingVaultDailyApysAction + | EarnControllerRefreshPooledStakingVaultApyAveragesAction + | EarnControllerRefreshPooledStakingDataAction + | EarnControllerRefreshLendingMarketsAction + | EarnControllerRefreshLendingPositionsAction + | EarnControllerRefreshLendingDataAction + | EarnControllerRefreshTronStakingApyAction + | EarnControllerGetTronStakingApyAction + | EarnControllerGetLendingPositionHistoryAction + | EarnControllerGetLendingMarketDailyApysAndAveragesAction + | EarnControllerExecuteLendingDepositAction + | EarnControllerExecuteLendingWithdrawAction + | EarnControllerExecuteLendingTokenApproveAction + | EarnControllerGetLendingTokenAllowanceAction + | EarnControllerGetLendingTokenMaxWithdrawAction + | EarnControllerGetLendingTokenMaxDepositAction; diff --git a/packages/earn-controller/src/EarnController.test.ts b/packages/earn-controller/src/EarnController.test.ts new file mode 100644 index 00000000000..da256ea0aaf --- /dev/null +++ b/packages/earn-controller/src/EarnController.test.ts @@ -0,0 +1,3280 @@ +import { deriveStateFromMetadata } from '@metamask/base-controller'; +import { toHex } from '@metamask/controller-utils'; +import type { InternalAccount } from '@metamask/keyring-internal-api'; +import { MOCK_ANY_NAMESPACE, Messenger } from '@metamask/messenger'; +import type { + MessengerActions, + MessengerEvents, + MockAnyNamespace, +} from '@metamask/messenger'; +import { getDefaultNetworkControllerState } from '@metamask/network-controller'; +import { + EarnSdk, + EarnApiService, + EarnEnvironments, + ChainId, +} from '@metamask/stake-sdk'; +import type { + PooledStakingApiService, + LendingApiService, + LendingMarket, +} from '@metamask/stake-sdk'; + +import type { TransactionMeta } from '../../transaction-controller/src/index.js'; +import { + TransactionStatus, + TransactionType, +} from '../../transaction-controller/src/index.js'; +import { + EarnController, + DEFAULT_POOLED_STAKING_CHAIN_STATE, +} from './EarnController.js'; +import type { + EarnControllerState, + EarnControllerMessenger, +} from './EarnController.js'; + +type AllEarnControllerActions = MessengerActions; + +type AllEarnControllerEvents = MessengerEvents; + +type RootMessenger = Messenger< + MockAnyNamespace, + AllEarnControllerActions, + AllEarnControllerEvents +>; + +jest.mock('@metamask/stake-sdk', () => ({ + EarnSdk: { + create: jest.fn().mockImplementation(() => ({ + contracts: { + pooledStaking: { + connectSignerOrProvider: jest.fn(), + }, + lending: { + aave: { + '0x123': { + connectSignerOrProvider: jest.fn(), + encodeDepositTransactionData: jest.fn(), + encodeWithdrawTransactionData: jest.fn(), + encodeUnderlyingTokenApproveTransactionData: jest.fn(), + underlyingTokenAllowance: jest.fn(), + maxWithdraw: jest.fn(), + maxDeposit: jest.fn(), + }, + }, + }, + }, + })), + }, + EarnApiService: jest.fn().mockImplementation(() => ({ + pooledStaking: { + getPooledStakes: jest.fn(), + getPooledStakingEligibility: jest.fn(), + getVaultData: jest.fn(), + getVaultDailyApys: jest.fn(), + getVaultApyAverages: jest.fn(), + getUserDailyRewards: jest.fn(), + }, + lending: { + getMarkets: jest.fn(), + getPositions: jest.fn(), + getPositionHistory: jest.fn(), + getHistoricMarketApys: jest.fn(), + }, + })), + ChainId: { + ETHEREUM: 1, + HOODI: 560048, + }, + EarnEnvironments: { + PROD: 'prod', + DEV: 'dev', + }, + isSupportedLendingChain: jest.fn().mockReturnValue(true), + isSupportedPooledStakingChain: jest.fn().mockReturnValue(true), +})); + +/** + * Builds a new instance of the root messenger. + * + * @returns A new instance of the root messenger. + */ +function buildMessenger(): RootMessenger { + return new Messenger({ namespace: MOCK_ANY_NAMESPACE }); +} + +/** + * Constructs the messenger for EarnController. + * + * @param rootMessenger - The root messenger to set as parent. + * @returns The restricted messenger. + */ +function getEarnControllerMessenger( + rootMessenger = buildMessenger(), +): EarnControllerMessenger { + const earnControllerMessenger = new Messenger< + 'EarnController', + AllEarnControllerActions, + AllEarnControllerEvents, + RootMessenger + >({ + namespace: 'EarnController', + parent: rootMessenger, + }); + rootMessenger.delegate({ + messenger: earnControllerMessenger, + actions: [ + 'NetworkController:getState', + 'NetworkController:getNetworkClientById', + 'AccountTreeController:getAccountsFromSelectedAccountGroup', + ], + events: [ + 'NetworkController:networkDidChange', + 'AccountTreeController:selectedAccountGroupChange', + 'AccountTreeController:stateChange', + 'TransactionController:transactionConfirmed', + ], + }); + return earnControllerMessenger; +} + +const mockAccount1Address = '0x1234'; + +const mockAccount2Address = '0xabc'; + +const createMockInternalAccount = ({ + id = '123e4567-e89b-12d3-a456-426614174000', + address = mockAccount1Address, + name = 'Account 1', + importTime = Date.now(), + lastSelected = Date.now(), +}: { + id?: string; + address?: string; + name?: string; + importTime?: number; + lastSelected?: number; +} = {}): InternalAccount => { + return { + id, + address, + options: {}, + methods: [], + type: 'eip155:eoa', + scopes: ['eip155:1'], + metadata: { + name, + keyring: { type: 'HD Key Tree' }, + importTime, + lastSelected, + }, + }; +}; + +const mockInternalAccount1 = createMockInternalAccount(); + +const createMockTransaction = ({ + id = '1', + type = TransactionType.stakingDeposit, + chainId = toHex(1), + networkClientId = 'networkClientIdMock', + time = 123456789, + status = TransactionStatus.confirmed, + txParams = { + gasUsed: '0x5208', + from: mockAccount1Address, + to: mockAccount2Address, + }, +}: Partial = {}): TransactionMeta => { + return { + id, + type, + chainId, + networkClientId, + time, + status, + txParams, + }; +}; + +const mockPooledStakes = { + account: mockAccount1Address, + lifetimeRewards: '100', + assets: '1000', + exitRequests: [], +}; + +const mockVaultMetadata = { + apy: '5.5', + capacity: '1000000', + feePercent: 10, + totalAssets: '500000', + vaultAddress: '0xabcd', +}; + +const mockPooledStakingVaultDailyApys = [ + { + id: 1, + chain_id: 1, + vault_address: '0xabc', + timestamp: '2025-02-19T00:00:00.000Z', + daily_apy: '2.273150114369428540', + created_at: '2025-02-20T01:00:00.686Z', + updated_at: '2025-02-20T01:00:00.686Z', + }, + { + id: 2, + chain_id: 1, + vault_address: '0xabc', + timestamp: '2025-02-18T00:00:00.000Z', + daily_apy: '2.601753752988867146', + created_at: '2025-02-19T01:00:00.460Z', + updated_at: '2025-02-19T01:00:00.460Z', + }, + { + id: 3, + chain_id: 1, + vault_address: '0xabc', + timestamp: '2025-02-17T00:00:00.000Z', + daily_apy: '2.371788704658418308', + created_at: '2025-02-18T01:00:00.579Z', + updated_at: '2025-02-18T01:00:00.579Z', + }, + { + id: 4, + chain_id: 1, + vault_address: '0xabc', + timestamp: '2025-02-16T00:00:00.000Z', + daily_apy: '2.037130166329167644', + created_at: '2025-02-17T01:00:00.368Z', + updated_at: '2025-02-17T01:00:00.368Z', + }, + { + id: 5, + chain_id: 1, + vault_address: '0xabc', + timestamp: '2025-02-15T00:00:00.000Z', + daily_apy: '2.495509141072538330', + created_at: '2025-02-16T01:00:00.737Z', + updated_at: '2025-02-16T01:00:00.737Z', + }, + { + id: 6, + chain_id: 1, + vault_address: '0xabc', + timestamp: '2025-02-14T00:00:00.000Z', + daily_apy: '2.760147959320520741', + created_at: '2025-02-15T01:00:00.521Z', + updated_at: '2025-02-15T01:00:00.521Z', + }, + { + id: 7, + chain_id: 1, + vault_address: '0xabc', + timestamp: '2025-02-13T00:00:00.000Z', + daily_apy: '2.620957696005122124', + created_at: '2025-02-14T01:00:00.438Z', + updated_at: '2025-02-14T01:00:00.438Z', + }, +]; + +const mockPooledStakingVaultApyAverages = { + oneDay: '1.946455943490720299', + oneWeek: '2.55954569442201844857', + oneMonth: '2.62859516898195124747', + threeMonths: '2.8090492487811444633', + sixMonths: '2.68775113174991540575', + oneYear: '2.58279361113012774176', +}; + +const mockLendingMarkets = [ + { + id: '0xe50fa9b3c56ffb159cb0fca61f5c9d750e8128c8', + chainId: 42161, + protocol: 'aave', + name: '0xe50fa9b3c56ffb159cb0fca61f5c9d750e8128c8', + address: '0xe50fa9b3c56ffb159cb0fca61f5c9d750e8128c8', + netSupplyRate: 1.52269127978874, + totalSupplyRate: 1.52269127978874, + rewards: [], + tvlUnderlying: '132942564710249273623333', + underlying: { + address: '0x82af49447d8a07e3bd95bd0d56f35241523fbab1', + chainId: 42161, + }, + outputToken: { + address: '0xe50fa9b3c56ffb159cb0fca61f5c9d750e8128c8', + chainId: 42161, + }, + }, +]; + +const mockLendingPositions = [ + { + id: '0xe6a7d2b7de29167ae4c3864ac0873e6dcd9cb47b-0x078f358208685046a11c85e8ad32895ded33a249-COLLATERAL-0', + chainId: 42161, + market: { + id: '0x078f358208685046a11c85e8ad32895ded33a249', + chainId: 42161, + protocol: 'aave', + name: '0x078f358208685046a11c85e8ad32895ded33a249', + address: '0x078f358208685046a11c85e8ad32895ded33a249', + netSupplyRate: 0.0062858302613958, + totalSupplyRate: 0.0062858302613958, + rewards: [], + tvlUnderlying: '315871357755', + underlying: { + address: '0x2f2a2543b76a4166549f7aab2e75bef0aefc5b0f', + chainId: 42161, + }, + outputToken: { + address: '0x078f358208685046a11c85e8ad32895ded33a249', + chainId: 42161, + }, + }, + assets: '112', + }, +]; + +const mockLendingPositionHistory = { + id: '0xe6a7d2b7de29167ae4c3864ac0873e6dcd9cb47b-0x078f358208685046a11c85e8ad32895ded33a249-COLLATERAL-0', + chainId: 42161, + market: { + id: '0x078f358208685046a11c85e8ad32895ded33a249', + chainId: 42161, + protocol: 'aave', + name: '0x078f358208685046a11c85e8ad32895ded33a249', + address: '0x078f358208685046a11c85e8ad32895ded33a249', + netSupplyRate: 0.0062857984324433, + totalSupplyRate: 0.0062857984324433, + rewards: [], + tvlUnderlying: '315871357702', + underlying: { + address: '0x2f2a2543b76a4166549f7aab2e75bef0aefc5b0f', + chainId: 42161, + }, + outputToken: { + address: '0x078f358208685046a11c85e8ad32895ded33a249', + chainId: 42161, + }, + }, + assets: '112', + historicalAssets: [ + { + timestamp: 1746835200000, + assets: '112', + }, + { + timestamp: 1746921600000, + assets: '112', + }, + { + timestamp: 1747008000000, + assets: '112', + }, + { + timestamp: 1747094400000, + assets: '112', + }, + { + timestamp: 1747180800000, + assets: '112', + }, + { + timestamp: 1747267200000, + assets: '112', + }, + { + timestamp: 1747353600000, + assets: '112', + }, + { + timestamp: 1747440000000, + assets: '112', + }, + { + timestamp: 1747526400000, + assets: '112', + }, + { + timestamp: 1747612800000, + assets: '112', + }, + ], + lifetimeRewards: [ + { + assets: '0', + token: { + address: '0x2f2a2543b76a4166549f7aab2e75bef0aefc5b0f', + chainId: 42161, + }, + }, + ], +}; + +const mockLendingHistoricMarketApys = { + netSupplyRate: 1.52254256433159, + totalSupplyRate: 1.52254256433159, + averageRates: { + sevenDay: { + netSupplyRate: 1.5282690267043, + totalSupplyRate: 1.5282690267043, + }, + thirtyDay: { + netSupplyRate: 1.655312573822, + totalSupplyRate: 1.655312573822, + }, + ninetyDay: { + netSupplyRate: 1.66478947752133, + totalSupplyRate: 1.66478947752133, + }, + }, + historicalRates: [ + { + timestampSeconds: 1747624157, + netSupplyRate: 1.52254256433159, + totalSupplyRate: 1.52254256433159, + timestamp: 1747624157, + }, + { + timestampSeconds: 1747612793, + netSupplyRate: 1.51830167099938, + totalSupplyRate: 1.51830167099938, + timestamp: 1747612793, + }, + { + timestampSeconds: 1747526383, + netSupplyRate: 1.50642775134808, + totalSupplyRate: 1.50642775134808, + timestamp: 1747526383, + }, + { + timestampSeconds: 1747439883, + netSupplyRate: 1.50747341318386, + totalSupplyRate: 1.50747341318386, + timestamp: 1747439883, + }, + { + timestampSeconds: 1747353586, + netSupplyRate: 1.52147411498283, + totalSupplyRate: 1.52147411498283, + timestamp: 1747353586, + }, + { + timestampSeconds: 1747267154, + netSupplyRate: 1.56669403317425, + totalSupplyRate: 1.56669403317425, + timestamp: 1747267154, + }, + { + timestampSeconds: 1747180788, + netSupplyRate: 1.55496963891012, + totalSupplyRate: 1.55496963891012, + timestamp: 1747180788, + }, + { + timestampSeconds: 1747094388, + netSupplyRate: 1.54239001226593, + totalSupplyRate: 1.54239001226593, + timestamp: 1747094388, + }, + { + timestampSeconds: 1747007890, + netSupplyRate: 1.62851420616391, + totalSupplyRate: 1.62851420616391, + timestamp: 1747007890, + }, + { + timestampSeconds: 1746921596, + netSupplyRate: 1.63674498306057, + totalSupplyRate: 1.63674498306057, + timestamp: 1746921596, + }, + { + timestampSeconds: 1746835148, + netSupplyRate: 1.65760227569609, + totalSupplyRate: 1.65760227569609, + timestamp: 1746835148, + }, + { + timestampSeconds: 1746748786, + netSupplyRate: 1.70873310171041, + totalSupplyRate: 1.70873310171041, + timestamp: 1746748786, + }, + { + timestampSeconds: 1746662367, + netSupplyRate: 1.71305288353747, + totalSupplyRate: 1.71305288353747, + timestamp: 1746662367, + }, + { + timestampSeconds: 1746575992, + netSupplyRate: 1.7197743361477, + totalSupplyRate: 1.7197743361477, + timestamp: 1746575992, + }, + { + timestampSeconds: 1746489584, + netSupplyRate: 1.72394345065358, + totalSupplyRate: 1.72394345065358, + timestamp: 1746489584, + }, + { + timestampSeconds: 1746403148, + netSupplyRate: 1.70886379023728, + totalSupplyRate: 1.70886379023728, + timestamp: 1746403148, + }, + { + timestampSeconds: 1746316798, + netSupplyRate: 1.71429159475843, + totalSupplyRate: 1.71429159475843, + timestamp: 1746316798, + }, + { + timestampSeconds: 1746230392, + netSupplyRate: 1.70443639282888, + totalSupplyRate: 1.70443639282888, + timestamp: 1746230392, + }, + { + timestampSeconds: 1746143902, + netSupplyRate: 1.71396513372792, + totalSupplyRate: 1.71396513372792, + timestamp: 1746143902, + }, + { + timestampSeconds: 1746057521, + netSupplyRate: 1.70397653941133, + totalSupplyRate: 1.70397653941133, + timestamp: 1746057521, + }, + { + timestampSeconds: 1745971133, + netSupplyRate: 1.70153685712654, + totalSupplyRate: 1.70153685712654, + timestamp: 1745971133, + }, + { + timestampSeconds: 1745884780, + netSupplyRate: 1.70574057393751, + totalSupplyRate: 1.70574057393751, + timestamp: 1745884780, + }, + { + timestampSeconds: 1745798140, + netSupplyRate: 1.72724368182558, + totalSupplyRate: 1.72724368182558, + timestamp: 1745798140, + }, + { + timestampSeconds: 1745711975, + netSupplyRate: 1.73661877763414, + totalSupplyRate: 1.73661877763414, + timestamp: 1745711975, + }, + { + timestampSeconds: 1745625539, + netSupplyRate: 1.75079606429804, + totalSupplyRate: 1.75079606429804, + timestamp: 1745625539, + }, + { + timestampSeconds: 1745539193, + netSupplyRate: 1.74336098741825, + totalSupplyRate: 1.74336098741825, + timestamp: 1745539193, + }, + { + timestampSeconds: 1745452777, + netSupplyRate: 1.69211471040769, + totalSupplyRate: 1.69211471040769, + timestamp: 1745452777, + }, + { + timestampSeconds: 1745366392, + netSupplyRate: 1.67734591553397, + totalSupplyRate: 1.67734591553397, + timestamp: 1745366392, + }, + { + timestampSeconds: 1745279933, + netSupplyRate: 1.64722901028615, + totalSupplyRate: 1.64722901028615, + timestamp: 1745279933, + }, + { + timestampSeconds: 1745193577, + netSupplyRate: 1.70321874906262, + totalSupplyRate: 1.70321874906262, + timestamp: 1745193577, + }, + ], +}; + +const mockUserDailyRewards = [ + { + dailyRewards: '2852081110008', + timestamp: 1746748800000, + dateStr: '2025-05-09', + }, + { + dailyRewards: '2237606324310', + timestamp: 1746835200000, + dateStr: '2025-05-10', + }, + { + dailyRewards: '2622849212844', + timestamp: 1746921600000, + dateStr: '2025-05-11', + }, + { + dailyRewards: '2760026774104', + timestamp: 1747008000000, + dateStr: '2025-05-12', + }, + { + dailyRewards: '2819318182549', + timestamp: 1747094400000, + dateStr: '2025-05-13', + }, + { + dailyRewards: '3526676051496', + timestamp: 1747180800000, + dateStr: '2025-05-14', + }, + { + dailyRewards: '3328845644827', + timestamp: 1747267200000, + dateStr: '2025-05-15', + }, + { + dailyRewards: '3364955138474', + timestamp: 1747353600000, + dateStr: '2025-05-16', + }, + { + dailyRewards: '2862320970705', + timestamp: 1747440000000, + dateStr: '2025-05-17', + }, + { + dailyRewards: '2999711064948', + timestamp: 1747526400000, + dateStr: '2025-05-18', + }, + { + dailyRewards: '0', + timestamp: 1747612800000, + dateStr: '2025-05-19', + }, +]; + +const setupController = async ({ + options = {}, + + mockGetNetworkClientById = jest.fn(() => ({ + configuration: { chainId: '0x1' }, + provider: { + request: jest.fn(), + on: jest.fn(), + removeListener: jest.fn(), + }, + })), + + mockGetNetworkControllerState = jest.fn(() => ({ + ...getDefaultNetworkControllerState(), + selectedNetworkClientId: '1', + })), + + mockGetAccountsFromSelectedAccountGroup = jest.fn(() => [ + mockInternalAccount1, + ]), + + addTransactionFn = jest.fn(), +}: { + options?: Partial[0]>; + mockGetNetworkClientById?: jest.Mock; + mockGetNetworkControllerState?: jest.Mock; + mockGetAccountsFromSelectedAccountGroup?: jest.Mock; + addTransactionFn?: jest.Mock; +} = {}): Promise<{ controller: EarnController; messenger: RootMessenger }> => { + const messenger = buildMessenger(); + + messenger.registerActionHandler( + 'NetworkController:getState', + mockGetNetworkControllerState, + ); + messenger.registerActionHandler( + 'NetworkController:getNetworkClientById', + mockGetNetworkClientById, + ); + messenger.registerActionHandler( + 'AccountTreeController:getAccountsFromSelectedAccountGroup', + mockGetAccountsFromSelectedAccountGroup, + ); + + const earnControllerMessenger = getEarnControllerMessenger(messenger); + + const controller = new EarnController({ + messenger: earnControllerMessenger, + ...options, + addTransactionFn, + }); + + await controller.init(); + + // Wait for fire-and-forget async operations started by init() to settle. + await new Promise((resolve) => setTimeout(resolve, 0)); + + return { controller, messenger }; +}; + +const EarnApiServiceMock = jest.mocked(EarnApiService); + +type MockedEarnApiService = { + pooledStaking?: Partial>; + lending?: Partial>; +}; + +let mockedEarnApiService: MockedEarnApiService; + +const isSupportedLendingChainMock = jest.requireMock( + '@metamask/stake-sdk', +).isSupportedLendingChain; +const isSupportedPooledStakingChainMock = jest.requireMock( + '@metamask/stake-sdk', +).isSupportedPooledStakingChain; + +describe('EarnController', () => { + beforeEach(() => { + jest.clearAllMocks(); + + isSupportedLendingChainMock.mockReturnValue(true); + isSupportedPooledStakingChainMock.mockReturnValue(true); + // Apply EarnSdk mock before initializing EarnController` + (EarnSdk.create as jest.Mock).mockImplementation(() => ({ + contracts: { + pooledStaking: null, + lending: null, + }, + })); + + mockedEarnApiService = { + pooledStaking: { + getPooledStakes: jest.fn().mockResolvedValue({ + accounts: [mockPooledStakes], + exchangeRate: '1.5', + }), + getPooledStakingEligibility: jest.fn().mockResolvedValue({ + eligible: true, + }), + getVaultData: jest.fn().mockResolvedValue(mockVaultMetadata), + getVaultDailyApys: jest + .fn() + .mockResolvedValue(mockPooledStakingVaultDailyApys), + getVaultApyAverages: jest + .fn() + .mockResolvedValue(mockPooledStakingVaultApyAverages), + getUserDailyRewards: jest.fn().mockResolvedValue(mockUserDailyRewards), + } as Partial>, + lending: { + getMarkets: jest.fn().mockResolvedValue(mockLendingMarkets), + getPositions: jest.fn().mockResolvedValue(mockLendingPositions), + getPositionHistory: jest + .fn() + .mockResolvedValue(mockLendingPositionHistory), + getHistoricMarketApys: jest + .fn() + .mockResolvedValue(mockLendingHistoricMarketApys), + } as Partial>, + }; + + EarnApiServiceMock.mockImplementation( + () => mockedEarnApiService as EarnApiService, + ); + }); + + describe('constructor', () => { + it('properly merges provided state with default state', async () => { + const customState: Partial = { + pooled_staking: { + '0': DEFAULT_POOLED_STAKING_CHAIN_STATE, + isEligible: true, + }, + lastUpdated: 1234567890, + }; + + const { controller } = await setupController({ + options: { state: customState }, + }); + + // Verify that custom state properties are preserved + expect(controller.state.pooled_staking.isEligible).toBe(true); + expect(controller.state.lastUpdated).toBe(1234567890); + expect(controller.state.pooled_staking['0']).toStrictEqual( + DEFAULT_POOLED_STAKING_CHAIN_STATE, + ); + + // Verify that default lending state is still present + expect(controller.state.lending).toBeDefined(); + + // Verify that default tron_staking state is still present + expect(controller.state.tron_staking).toBeNull(); + }); + + it('initializes with null tron_staking state by default', async () => { + const { controller } = await setupController(); + expect(controller.state.tron_staking).toBeNull(); + }); + + it('initializes API service with default environment (PROD)', async () => { + await setupController(); + expect(EarnApiServiceMock).toHaveBeenCalledWith(EarnEnvironments.PROD); + }); + + it('initializes API service with custom environment when provided', async () => { + await setupController({ + options: { env: EarnEnvironments.DEV }, + }); + expect(EarnApiServiceMock).toHaveBeenCalledWith(EarnEnvironments.DEV); + }); + + it('initializes Earn SDK with default environment (PROD)', async () => { + await setupController(); + expect(EarnSdk.create).toHaveBeenCalledWith(expect.any(Object), { + chainId: 1, + env: EarnEnvironments.PROD, + }); + }); + + it('initializes Earn SDK with custom environment when provided', async () => { + await setupController({ + options: { env: EarnEnvironments.DEV }, + }); + expect(EarnSdk.create).toHaveBeenCalledWith(expect.any(Object), { + chainId: 1, + env: EarnEnvironments.DEV, + }); + }); + }); + + describe('init', () => { + it('does not re-run initialization when called again after init has already completed', async () => { + const { controller } = await setupController(); + + // init() was already called once inside setupController; call it again after it settled. + await controller.init(); + await new Promise((resolve) => setTimeout(resolve, 0)); + + // EarnSdk.create and data-fetch calls should not have increased beyond + // the single init() call made during setupController. + expect(EarnSdk.create).toHaveBeenCalledTimes(1); + expect( + mockedEarnApiService?.pooledStaking?.getPooledStakes, + ).toHaveBeenCalledTimes(1); // 1 chain (ETH only) from the first init() + }); + + it('does not re-run initialization when called concurrently before init has completed', async () => { + // Build the controller without calling init() so we can control the race ourselves. + // Reuse the same mock factories that setupController defaults to. + const rootMessenger = buildMessenger(); + + rootMessenger.registerActionHandler( + 'NetworkController:getState', + jest.fn(() => ({ + ...getDefaultNetworkControllerState(), + selectedNetworkClientId: '1', + })), + ); + rootMessenger.registerActionHandler( + 'NetworkController:getNetworkClientById', + jest.fn(() => ({ + configuration: { chainId: toHex(1) }, + provider: { + request: jest.fn(), + on: jest.fn(), + removeListener: jest.fn(), + }, + })) as unknown as jest.Mock, + ); + rootMessenger.registerActionHandler( + 'AccountTreeController:getAccountsFromSelectedAccountGroup', + jest.fn(() => [mockInternalAccount1]), + ); + + const earnControllerMessenger = getEarnControllerMessenger(rootMessenger); + const controller = new EarnController({ + messenger: earnControllerMessenger, + addTransactionFn: jest.fn(), + }); + + // Fire two concurrent init() calls — neither has settled yet. + await Promise.all([controller.init(), controller.init()]); + await new Promise((resolve) => setTimeout(resolve, 0)); + + // SDK should only have been created once despite two concurrent calls. + expect(EarnSdk.create).toHaveBeenCalledTimes(1); + expect( + mockedEarnApiService?.pooledStaking?.getPooledStakes, + ).toHaveBeenCalledTimes(1); // 1 chain (ETH only), not doubled to 2 + }); + + it('allows retry when init fails', async () => { + const rootMessenger = buildMessenger(); + + // First call to NetworkController:getState throws, second succeeds. + const mockGetState = jest + .fn() + .mockImplementationOnce(() => { + throw new Error('NetworkController not ready'); + }) + .mockReturnValue({ + ...getDefaultNetworkControllerState(), + selectedNetworkClientId: '1', + }); + + rootMessenger.registerActionHandler( + 'NetworkController:getState', + mockGetState, + ); + rootMessenger.registerActionHandler( + 'NetworkController:getNetworkClientById', + jest.fn(() => ({ + configuration: { chainId: toHex(1) }, + provider: { + request: jest.fn(), + on: jest.fn(), + removeListener: jest.fn(), + }, + })) as unknown as jest.Mock, + ); + rootMessenger.registerActionHandler( + 'AccountTreeController:getAccountsFromSelectedAccountGroup', + jest.fn(() => [mockInternalAccount1]), + ); + + const earnControllerMessenger = getEarnControllerMessenger(rootMessenger); + const controller = new EarnController({ + messenger: earnControllerMessenger, + addTransactionFn: jest.fn(), + }); + + // First init() should reject and clear #initPromise. + await expect(controller.init()).rejects.toThrow( + 'NetworkController not ready', + ); + + // Second init() should succeed and trigger data fetches. + await controller.init(); + await new Promise((resolve) => setTimeout(resolve, 0)); + + expect(EarnSdk.create).toHaveBeenCalledTimes(1); + expect( + mockedEarnApiService?.pooledStaking?.getPooledStakes, + ).toHaveBeenCalledTimes(1); // 1 chain (ETH only) + }); + + describe('when no EVM account is available at init time', () => { + // Minimal AccountTreeControllerState shape used to trigger the stateChange event + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const mockAccountTreeStateWithGroup: any = { + selectedAccountGroup: 'keyring:test/0', + accountTree: { wallets: {} }, + isAccountTreeSyncingInProgress: false, + hasAccountTreeSyncingSyncedAtLeastOnce: false, + accountGroupsMetadata: {}, + accountWalletsMetadata: {}, + }; + + it('defers portfolio refresh until AccountTreeController:stateChange fires with a non-empty selectedAccountGroup', async () => { + const mockGetAccounts = jest + .fn() + .mockReturnValueOnce([]) // No account during init + .mockReturnValue([mockInternalAccount1]); // Account available after stateChange + + const { messenger } = await setupController({ + mockGetAccountsFromSelectedAccountGroup: mockGetAccounts, + }); + + // No eligibility or staking refresh should have happened during init + expect( + mockedEarnApiService?.pooledStaking?.getPooledStakingEligibility, + ).not.toHaveBeenCalled(); + expect( + mockedEarnApiService?.pooledStaking?.getPooledStakes, + ).not.toHaveBeenCalled(); + + messenger.publish( + 'AccountTreeController:stateChange', + mockAccountTreeStateWithGroup, + [], + ); + await new Promise((resolve) => setTimeout(resolve, 0)); + + expect( + mockedEarnApiService?.pooledStaking?.getPooledStakingEligibility, + ).toHaveBeenCalledWith([mockAccount1Address]); + expect( + mockedEarnApiService?.pooledStaking?.getPooledStakes, + ).toHaveBeenCalled(); + }); + + it('does not trigger portfolio refresh when selectedAccountGroup is empty', async () => { + const { messenger } = await setupController({ + mockGetAccountsFromSelectedAccountGroup: jest.fn(() => []), + }); + + messenger.publish( + 'AccountTreeController:stateChange', + { + ...mockAccountTreeStateWithGroup, + selectedAccountGroup: '', + }, + [], + ); + await new Promise((resolve) => setTimeout(resolve, 0)); + + expect( + mockedEarnApiService?.pooledStaking?.getPooledStakingEligibility, + ).not.toHaveBeenCalled(); + }); + + it('does not trigger portfolio refresh when selectedAccountGroup only contains non-EVM accounts', async () => { + // Always returns no accounts, simulating a non-EVM-only group (e.g. Bitcoin-only) + const mockGetAccounts = jest.fn(() => []); + + const { messenger } = await setupController({ + mockGetAccountsFromSelectedAccountGroup: mockGetAccounts, + }); + + // Publish with a non-empty selectedAccountGroup but no EVM account resolvable + messenger.publish( + 'AccountTreeController:stateChange', + mockAccountTreeStateWithGroup, + [], + ); + await new Promise((resolve) => setTimeout(resolve, 0)); + + expect( + mockedEarnApiService?.pooledStaking?.getPooledStakingEligibility, + ).not.toHaveBeenCalled(); + expect( + mockedEarnApiService?.pooledStaking?.getPooledStakes, + ).not.toHaveBeenCalled(); + }); + + it('stays subscribed and fires when an EVM account eventually appears after a non-EVM-only state change', async () => { + const mockGetAccounts = jest + .fn() + .mockReturnValueOnce([]) // No account during init + .mockReturnValueOnce([]) // Still no EVM account on first stateChange (non-EVM group) + .mockReturnValue([mockInternalAccount1]); // EVM account available on second stateChange + + const { messenger } = await setupController({ + mockGetAccountsFromSelectedAccountGroup: mockGetAccounts, + }); + + // First publish: group is non-empty but still no EVM account — should not refresh + messenger.publish( + 'AccountTreeController:stateChange', + mockAccountTreeStateWithGroup, + [], + ); + await new Promise((resolve) => setTimeout(resolve, 0)); + + expect( + mockedEarnApiService?.pooledStaking?.getPooledStakingEligibility, + ).not.toHaveBeenCalled(); + + // Second publish: EVM account is now available — deferred refresh should fire + messenger.publish( + 'AccountTreeController:stateChange', + mockAccountTreeStateWithGroup, + [], + ); + await new Promise((resolve) => setTimeout(resolve, 0)); + + expect( + mockedEarnApiService?.pooledStaking?.getPooledStakingEligibility, + ).toHaveBeenCalledWith([mockAccount1Address]); + expect( + mockedEarnApiService?.pooledStaking?.getPooledStakes, + ).toHaveBeenCalled(); + }); + + it('unsubscribes after the first non-empty selectedAccountGroup event', async () => { + const mockGetAccounts = jest + .fn() + .mockReturnValueOnce([]) // No account during init + .mockReturnValue([mockInternalAccount1]); // Account available after stateChange + + const { messenger } = await setupController({ + mockGetAccountsFromSelectedAccountGroup: mockGetAccounts, + }); + + // First publish triggers the deferred refresh + messenger.publish( + 'AccountTreeController:stateChange', + mockAccountTreeStateWithGroup, + [], + ); + await new Promise((resolve) => setTimeout(resolve, 0)); + + const eligibilityCallCount = + mockedEarnApiService?.pooledStaking?.getPooledStakingEligibility?.mock + .calls.length ?? 0; + + // Second publish should be ignored – handler was already unsubscribed + messenger.publish( + 'AccountTreeController:stateChange', + mockAccountTreeStateWithGroup, + [], + ); + await new Promise((resolve) => setTimeout(resolve, 0)); + + expect( + mockedEarnApiService?.pooledStaking?.getPooledStakingEligibility, + ).toHaveBeenCalledTimes(eligibilityCallCount); + }); + }); + }); + + describe('SDK initialization', () => { + it('initializes SDK with correct chain ID on construction', async () => { + await setupController(); + expect(EarnSdk.create).toHaveBeenCalledWith(expect.any(Object), { + chainId: 1, + env: EarnEnvironments.PROD, + }); + }); + + it('handles SDK initialization failure gracefully by avoiding known errors', async () => { + const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(); + (EarnSdk.create as jest.Mock).mockImplementationOnce(() => { + throw new Error('Unsupported chainId'); + }); + + // Unsupported chain id should not result in console error statement + await setupController(); + expect(consoleErrorSpy).not.toHaveBeenCalled(); + consoleErrorSpy.mockRestore(); + }); + + it('handles SDK initialization failure gracefully by logging error', async () => { + const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(); + (EarnSdk.create as jest.Mock).mockImplementationOnce(() => { + throw new Error('Network error'); + }); + + // Unexpected error should be logged + await setupController(); + expect(consoleErrorSpy).toHaveBeenCalled(); + consoleErrorSpy.mockRestore(); + }); + + it('reinitializes SDK when network changes', async () => { + const { messenger } = await setupController(); + + messenger.publish('NetworkController:networkDidChange', { + ...getDefaultNetworkControllerState(), + selectedNetworkClientId: '2', + }); + + expect(EarnSdk.create).toHaveBeenCalledTimes(2); + expect( + mockedEarnApiService?.pooledStaking?.getPooledStakes, + ).toHaveBeenCalled(); + }); + + it('reinitializes SDK with correct environment when network changes', async () => { + const { messenger } = await setupController({ + options: { env: EarnEnvironments.DEV }, + mockGetNetworkClientById: jest.fn(() => ({ + configuration: { chainId: '0x2' }, + provider: { + request: jest.fn(), + on: jest.fn(), + removeListener: jest.fn(), + }, + })), + }); + + messenger.publish('NetworkController:networkDidChange', { + ...getDefaultNetworkControllerState(), + selectedNetworkClientId: '2', + }); + + expect(EarnSdk.create).toHaveBeenCalledTimes(2); + expect(EarnSdk.create).toHaveBeenNthCalledWith(2, expect.any(Object), { + chainId: 2, + env: EarnEnvironments.DEV, + }); + }); + + it('does not initialize sdk if the provider is null', async () => { + await setupController({ + mockGetNetworkClientById: jest.fn(() => ({ + provider: null, + configuration: { chainId: '0x1' }, + })), + }); + expect(EarnSdk.create).not.toHaveBeenCalled(); + }); + }); + + describe('Pooled Staking', () => { + describe('refreshPooledStakingData', () => { + it('updates state with fetched staking data', async () => { + const { controller } = await setupController(); + await controller.refreshPooledStakingData(); + + expect(controller.state.pooled_staking).toMatchObject({ + '1': { + pooledStakes: mockPooledStakes, + exchangeRate: '1.5', + vaultMetadata: mockVaultMetadata, + vaultDailyApys: mockPooledStakingVaultDailyApys, + vaultApyAverages: mockPooledStakingVaultApyAverages, + }, + isEligible: true, + }); + expect(controller.state.lastUpdated).toBeDefined(); + }); + + it('does not invalidate cache when refreshing state', async () => { + const { controller } = await setupController(); + await controller.refreshPooledStakingData(); + + expect( + mockedEarnApiService?.pooledStaking?.getPooledStakes, + ).toHaveBeenNthCalledWith( + // First call occurs during setupController() + 2, + [mockAccount1Address], + 1, + false, + ); + }); + + it('invalidates cache when refreshing state', async () => { + const { controller } = await setupController(); + await controller.refreshPooledStakingData({ resetCache: true }); + + expect( + mockedEarnApiService?.pooledStaking?.getPooledStakes, + ).toHaveBeenNthCalledWith( + // First call occurs during setupController() + 2, + [mockAccount1Address], + 1, + true, + ); + }); + + it('refreshes state using options.address', async () => { + const { controller } = await setupController(); + await controller.refreshPooledStakingData({ + address: mockAccount2Address, + }); + + expect( + mockedEarnApiService?.pooledStaking?.getPooledStakes, + ).toHaveBeenNthCalledWith( + // First call occurs during setupController() + 2, + [mockAccount2Address], + 1, + false, + ); + }); + + it('handles API errors gracefully', async () => { + const consoleErrorSpy = jest + .spyOn(console, 'error') + .mockImplementation(); + mockedEarnApiService = { + pooledStaking: { + getPooledStakes: jest.fn().mockImplementation(() => { + throw new Error('API Error getPooledStakes'); + }), + getPooledStakingEligibility: jest.fn().mockImplementation(() => { + throw new Error('API Error getPooledStakingEligibility'); + }), + getVaultData: jest.fn().mockImplementation(() => { + throw new Error('API Error getVaultData'); + }), + getVaultDailyApys: jest.fn().mockImplementation(() => { + throw new Error('API Error getVaultDailyApys'); + }), + getVaultApyAverages: jest.fn().mockImplementation(() => { + throw new Error('API Error getVaultApyAverages'); + }), + } as Partial>, + }; + + EarnApiServiceMock.mockImplementation( + () => mockedEarnApiService as EarnApiService, + ); + + const { controller } = await setupController(); + + await expect(controller.refreshPooledStakingData()).rejects.toThrow( + 'Failed to refresh some staking data: API Error getPooledStakes, API Error getVaultData, API Error getVaultDailyApys, API Error getVaultApyAverages', + ); + expect(consoleErrorSpy).toHaveBeenCalled(); + consoleErrorSpy.mockRestore(); + }); + + // if no account is selected, it should not fetch stakes data but still update vault metadata, vault daily apys and vault apy averages. + it('does not fetch staking data if no account is selected', async () => { + const { controller } = await setupController({ + mockGetAccountsFromSelectedAccountGroup: jest.fn(() => []), + }); + + expect( + mockedEarnApiService?.pooledStaking?.getPooledStakes, + ).not.toHaveBeenCalled(); + + await controller.refreshPooledStakingData(); + expect(controller.state.pooled_staking[1].pooledStakes).toStrictEqual( + DEFAULT_POOLED_STAKING_CHAIN_STATE.pooledStakes, + ); + expect(controller.state.pooled_staking[1].vaultMetadata).toStrictEqual( + mockVaultMetadata, + ); + expect(controller.state.pooled_staking[1].vaultDailyApys).toStrictEqual( + mockPooledStakingVaultDailyApys, + ); + expect( + controller.state.pooled_staking[1].vaultApyAverages, + ).toStrictEqual(mockPooledStakingVaultApyAverages); + expect(controller.state.pooled_staking.isEligible).toBe(false); + }); + }); + + describe('refreshPooledStakes', () => { + it('fetches without resetting cache when resetCache is false', async () => { + const { controller } = await setupController(); + await controller.refreshPooledStakes({ resetCache: false }); + + // Assertion on second call since the first one is part of controller setup. + expect( + mockedEarnApiService?.pooledStaking?.getPooledStakes, + ).toHaveBeenNthCalledWith( + 2, + [mockAccount1Address], + ChainId.ETHEREUM, + false, + ); + }); + + it('fetches without resetting cache when resetCache is undefined', async () => { + const { controller } = await setupController(); + await controller.refreshPooledStakes(); + + // Assertion on second call since the first one is part of controller setup. + expect( + mockedEarnApiService?.pooledStaking?.getPooledStakes, + ).toHaveBeenNthCalledWith( + 2, + [mockAccount1Address], + ChainId.ETHEREUM, + false, + ); + }); + + it('fetches while resetting cache', async () => { + const { controller } = await setupController(); + await controller.refreshPooledStakes({ resetCache: true }); + + // Assertion on second call since the first one is part of controller setup. + expect( + mockedEarnApiService?.pooledStaking?.getPooledStakes, + ).toHaveBeenNthCalledWith( + 2, + [mockAccount1Address], + ChainId.ETHEREUM, + true, + ); + }); + + it('fetches using active account (default)', async () => { + const { controller } = await setupController(); + await controller.refreshPooledStakes(); + + // Assertion on second call since the first one is part of controller setup. + expect( + mockedEarnApiService?.pooledStaking?.getPooledStakes, + ).toHaveBeenNthCalledWith( + 2, + [mockAccount1Address], + ChainId.ETHEREUM, + false, + ); + }); + + it('fetches using options.address override', async () => { + const { controller } = await setupController(); + await controller.refreshPooledStakes({ address: mockAccount2Address }); + + // Assertion on second call since the first one is part of controller setup. + expect( + mockedEarnApiService?.pooledStaking?.getPooledStakes, + ).toHaveBeenNthCalledWith(2, [mockAccount2Address], 1, false); + }); + + it('fetches using Ethereum Mainnet fallback if chainId is not provided', async () => { + const { controller } = await setupController(); + await controller.refreshPooledStakes(); + + // Assertion on second call since the first one is part of controller setup. + expect( + mockedEarnApiService?.pooledStaking?.getPooledStakes, + ).toHaveBeenNthCalledWith( + 2, + [mockAccount1Address], + ChainId.ETHEREUM, + false, + ); + }); + + it('fetches using Ethereum Mainnet fallback if pooled-staking does not support provided chainId', async () => { + isSupportedPooledStakingChainMock.mockReturnValue(false); + const { controller } = await setupController(); + await controller.refreshPooledStakes({ chainId: 2 }); + + // Assertion on second call since the first one is part of controller setup. + expect( + mockedEarnApiService?.pooledStaking?.getPooledStakes, + ).toHaveBeenNthCalledWith( + 2, + [mockAccount1Address], + ChainId.ETHEREUM, + false, + ); + }); + + it("fetches using Ethereum Hoodi if it's the provided chainId", async () => { + const { controller } = await setupController(); + await controller.refreshPooledStakes({ chainId: ChainId.HOODI }); + + // Assertion on second call since the first one is part of controller setup. + expect( + mockedEarnApiService?.pooledStaking?.getPooledStakes, + ).toHaveBeenNthCalledWith( + 2, + [mockAccount1Address], + ChainId.HOODI, + false, + ); + }); + }); + + describe('refreshEarnEligibility', () => { + it('fetches earn eligibility using active account (default)', async () => { + const { controller } = await setupController(); + + await controller.refreshEarnEligibility(); + + // Assertion on second call since the first is part of controller setup. + expect( + mockedEarnApiService?.pooledStaking?.getPooledStakingEligibility, + ).toHaveBeenNthCalledWith(2, [mockAccount1Address]); + }); + + it('fetches earn eligibility using options.address override', async () => { + const { controller } = await setupController(); + await controller.refreshEarnEligibility({ + address: mockAccount2Address, + }); + + // Assertion on second call since the first is part of controller setup. + expect( + mockedEarnApiService?.pooledStaking?.getPooledStakingEligibility, + ).toHaveBeenNthCalledWith(2, [mockAccount2Address]); + }); + + it('returns early without fetching when no address is available', async () => { + const { controller } = await setupController({ + mockGetAccountsFromSelectedAccountGroup: jest.fn(() => []), + }); + + await controller.refreshEarnEligibility(); + + expect( + mockedEarnApiService?.pooledStaking?.getPooledStakingEligibility, + ).not.toHaveBeenCalled(); + }); + }); + + describe('refreshPooledStakingVaultMetadata', () => { + it('refreshes vault metadata', async () => { + const { controller } = await setupController(); + await controller.refreshPooledStakingVaultMetadata(); + + expect( + mockedEarnApiService?.pooledStaking?.getVaultData, + ).toHaveBeenCalledTimes(2); + }); + + it('fetches using Ethereum Mainnet fallback if chainId is not provided', async () => { + const { controller } = await setupController(); + await controller.refreshPooledStakingVaultMetadata(); + + expect( + mockedEarnApiService?.pooledStaking?.getVaultData, + ).toHaveBeenNthCalledWith(2, ChainId.ETHEREUM); + }); + + it('fetches using Ethereum Mainnet fallback if pooled-staking does not support provided chainId', async () => { + isSupportedPooledStakingChainMock.mockReturnValue(false); + const { controller } = await setupController(); + await controller.refreshPooledStakingVaultMetadata(2); + + expect( + mockedEarnApiService?.pooledStaking?.getVaultData, + ).toHaveBeenNthCalledWith(2, ChainId.ETHEREUM); + }); + + it('fetches using Ethereum Hoodi if it is the provided chainId', async () => { + const { controller } = await setupController(); + await controller.refreshPooledStakingVaultMetadata(ChainId.HOODI); + + expect( + mockedEarnApiService?.pooledStaking?.getVaultData, + ).toHaveBeenNthCalledWith(2, ChainId.HOODI); + }); + }); + + describe('refreshPooledStakingVaultDailyApys', () => { + it('refreshes vault daily apys', async () => { + const { controller } = await setupController(); + await controller.refreshPooledStakingVaultDailyApys(); + + expect( + mockedEarnApiService?.pooledStaking?.getVaultDailyApys, + ).toHaveBeenCalledTimes(2); + expect(controller.state.pooled_staking[1].vaultDailyApys).toStrictEqual( + mockPooledStakingVaultDailyApys, + ); + }); + + it('refreshes vault daily apys with custom days', async () => { + const { controller } = await setupController(); + await controller.refreshPooledStakingVaultDailyApys({ + chainId: 1, + days: 180, + order: 'desc', + }); + + expect( + mockedEarnApiService?.pooledStaking?.getVaultDailyApys, + ).toHaveBeenNthCalledWith(2, 1, 180, 'desc'); + expect(controller.state.pooled_staking[1].vaultDailyApys).toStrictEqual( + mockPooledStakingVaultDailyApys, + ); + }); + + it('refreshes vault daily apys with ascending order', async () => { + const { controller } = await setupController(); + await controller.refreshPooledStakingVaultDailyApys({ + chainId: 1, + days: 365, + order: 'asc', + }); + + expect( + mockedEarnApiService?.pooledStaking?.getVaultDailyApys, + ).toHaveBeenNthCalledWith(2, 1, 365, 'asc'); + expect(controller.state.pooled_staking[1].vaultDailyApys).toStrictEqual( + mockPooledStakingVaultDailyApys, + ); + }); + + it('refreshes vault daily apys with custom days and ascending order', async () => { + const { controller } = await setupController(); + await controller.refreshPooledStakingVaultDailyApys({ + chainId: 1, + days: 180, + order: 'asc', + }); + + expect( + mockedEarnApiService?.pooledStaking?.getVaultDailyApys, + ).toHaveBeenNthCalledWith(2, 1, 180, 'asc'); + expect(controller.state.pooled_staking[1].vaultDailyApys).toStrictEqual( + mockPooledStakingVaultDailyApys, + ); + }); + + it("refreshes vault daily apys using Ethereum Mainnet fallback if pooled-staking doesn't support provided chainId", async () => { + isSupportedPooledStakingChainMock.mockReturnValue(false); + const { controller } = await setupController(); + await controller.refreshPooledStakingVaultDailyApys({ chainId: 2 }); + + expect( + mockedEarnApiService?.pooledStaking?.getVaultDailyApys, + ).toHaveBeenNthCalledWith(2, 1, 365, 'desc'); + expect(controller.state.pooled_staking[1].vaultDailyApys).toStrictEqual( + mockPooledStakingVaultDailyApys, + ); + expect(controller.state.pooled_staking[2]).toBeUndefined(); + }); + + it('refreshes vault daily apys using Ethereum Hoodi if it is the provided chainId', async () => { + const { controller } = await setupController(); + await controller.refreshPooledStakingVaultDailyApys({ + chainId: ChainId.HOODI, + }); + + expect( + mockedEarnApiService?.pooledStaking?.getVaultDailyApys, + ).toHaveBeenNthCalledWith(2, ChainId.HOODI, 365, 'desc'); + expect( + controller.state.pooled_staking[ChainId.HOODI].vaultDailyApys, + ).toStrictEqual(mockPooledStakingVaultDailyApys); + }); + + it('uses default chain state when refreshing vault daily apys for uninitialized chain', async () => { + const { controller } = await setupController(); + + // Use a chain ID that's not in the hardcoded #supportedPooledStakingChains array but mock it as supported + // This will trigger the `?? DEFAULT_POOLED_STAKING_CHAIN_STATE` fallback + const uninitializedChainId = 2; + isSupportedPooledStakingChainMock.mockReturnValue(true); + await controller.refreshPooledStakingVaultDailyApys({ + chainId: uninitializedChainId, + }); + + // Verify that the chain state was created using the default state + expect( + controller.state.pooled_staking[uninitializedChainId], + ).toBeDefined(); + expect( + controller.state.pooled_staking[uninitializedChainId].vaultDailyApys, + ).toStrictEqual(mockPooledStakingVaultDailyApys); + // Verify other properties use defaults + expect( + controller.state.pooled_staking[uninitializedChainId].pooledStakes, + ).toStrictEqual(DEFAULT_POOLED_STAKING_CHAIN_STATE.pooledStakes); + expect( + controller.state.pooled_staking[uninitializedChainId].exchangeRate, + ).toStrictEqual(DEFAULT_POOLED_STAKING_CHAIN_STATE.exchangeRate); + }); + }); + + describe('refreshPooledStakingVaultApyAverages', () => { + it('refreshes vault apy averages', async () => { + const { controller } = await setupController(); + await controller.refreshPooledStakingVaultApyAverages(); + + expect( + mockedEarnApiService?.pooledStaking?.getVaultApyAverages, + ).toHaveBeenCalledTimes(2); + expect( + controller.state.pooled_staking[1].vaultApyAverages, + ).toStrictEqual(mockPooledStakingVaultApyAverages); + }); + + it("refreshes vault apy averages using Ethereum Mainnet fallback if pooled-staking doesn't support provided chainId", async () => { + isSupportedPooledStakingChainMock.mockReturnValue(false); + const { controller } = await setupController(); + await controller.refreshPooledStakingVaultApyAverages(2); + + expect( + mockedEarnApiService?.pooledStaking?.getVaultApyAverages, + ).toHaveBeenNthCalledWith(2, 1); + expect( + controller.state.pooled_staking[1].vaultApyAverages, + ).toStrictEqual(mockPooledStakingVaultApyAverages); + expect(controller.state.pooled_staking[2]).toBeUndefined(); + }); + + it('refreshes vault apy averages using Ethereum Hoodi if it is the provided chainId', async () => { + const { controller } = await setupController(); + await controller.refreshPooledStakingVaultApyAverages(ChainId.HOODI); + + expect( + mockedEarnApiService?.pooledStaking?.getVaultApyAverages, + ).toHaveBeenNthCalledWith(2, ChainId.HOODI); + }); + + it('uses default chain state when refreshing vault apy averages for uninitialized chain', async () => { + const { controller } = await setupController(); + + // Use a chain ID that's not in the hardcoded #supportedPooledStakingChains array but mock it as supported + // This will trigger the `?? DEFAULT_POOLED_STAKING_CHAIN_STATE` fallback + const uninitializedChainId = 2; + isSupportedPooledStakingChainMock.mockReturnValue(true); + await controller.refreshPooledStakingVaultApyAverages( + uninitializedChainId, + ); + + // Verify that the chain state was created using the default state + expect( + controller.state.pooled_staking[uninitializedChainId], + ).toBeDefined(); + expect( + controller.state.pooled_staking[uninitializedChainId] + .vaultApyAverages, + ).toStrictEqual(mockPooledStakingVaultApyAverages); + // Verify other properties use defaults + expect( + controller.state.pooled_staking[uninitializedChainId].pooledStakes, + ).toStrictEqual(DEFAULT_POOLED_STAKING_CHAIN_STATE.pooledStakes); + expect( + controller.state.pooled_staking[uninitializedChainId].exchangeRate, + ).toStrictEqual(DEFAULT_POOLED_STAKING_CHAIN_STATE.exchangeRate); + }); + }); + }); + + describe('subscription handlers', () => { + describe('On network change', () => { + it('updates vault data when network changes', async () => { + const { controller, messenger } = await setupController(); + + jest + .spyOn(controller, 'refreshPooledStakingVaultMetadata') + .mockResolvedValue(); + jest + .spyOn(controller, 'refreshPooledStakingVaultDailyApys') + .mockResolvedValue(); + jest + .spyOn(controller, 'refreshPooledStakingVaultApyAverages') + .mockResolvedValue(); + + jest.spyOn(controller, 'refreshPooledStakes').mockResolvedValue(); + + messenger.publish('NetworkController:networkDidChange', { + ...getDefaultNetworkControllerState(), + selectedNetworkClientId: '2', + }); + + expect( + controller.refreshPooledStakingVaultMetadata, + ).toHaveBeenCalledTimes(1); + expect( + controller.refreshPooledStakingVaultDailyApys, + ).toHaveBeenCalledTimes(1); + expect( + controller.refreshPooledStakingVaultApyAverages, + ).toHaveBeenCalledTimes(1); + expect(controller.refreshPooledStakes).toHaveBeenCalledTimes(1); + }); + }); + + describe('On selected account group change', () => { + it('updates earn eligibility, pooled stakes, and lending positions when the resolved address changed', async () => { + // setupController() already runs init() for mockAccount1Address, so + // resolve a different address (mockAccount2Address) on the group + // change to simulate an actual account switch. + const mockGetAccounts = jest + .fn() + .mockReturnValue([mockInternalAccount1]); + const { controller, messenger } = await setupController({ + mockGetAccountsFromSelectedAccountGroup: mockGetAccounts, + }); + + jest.spyOn(controller, 'refreshEarnEligibility').mockResolvedValue(); + jest.spyOn(controller, 'refreshPooledStakes').mockResolvedValue(); + jest.spyOn(controller, 'refreshLendingPositions').mockResolvedValue(); + + mockGetAccounts.mockReturnValue([ + createMockInternalAccount({ address: mockAccount2Address }), + ]); + + messenger.publish( + 'AccountTreeController:selectedAccountGroupChange', + 'keyring:test/0', + '', + ); + + expect(controller.refreshEarnEligibility).toHaveBeenNthCalledWith(1, { + address: mockAccount2Address, + }); + expect(controller.refreshPooledStakes).toHaveBeenNthCalledWith(1, { + address: mockAccount2Address, + }); + expect(controller.refreshLendingPositions).toHaveBeenNthCalledWith(1, { + address: mockAccount2Address, + }); + }); + + it('does not re-fetch when the resolved address is unchanged from the last refresh', async () => { + // setupController() already runs init() -> #refreshEarnPortfolio for + // mockAccount1Address. A selectedAccountGroupChange firing again + // with the same resolved address (e.g. during startup hydration) + // should be a no-op. + const { controller, messenger } = await setupController(); + + jest.spyOn(controller, 'refreshEarnEligibility').mockResolvedValue(); + jest.spyOn(controller, 'refreshPooledStakes').mockResolvedValue(); + jest.spyOn(controller, 'refreshLendingPositions').mockResolvedValue(); + + messenger.publish( + 'AccountTreeController:selectedAccountGroupChange', + 'keyring:test/0', + '', + ); + + expect(controller.refreshEarnEligibility).not.toHaveBeenCalled(); + expect(controller.refreshPooledStakes).not.toHaveBeenCalled(); + expect(controller.refreshLendingPositions).not.toHaveBeenCalled(); + }); + }); + + describe('On transaction confirmed', () => { + let controller: EarnController; + let messenger: RootMessenger; + + beforeEach(async () => { + const earnController = await setupController(); + controller = earnController.controller; + messenger = earnController.messenger; + jest.spyOn(controller, 'refreshPooledStakes').mockResolvedValue(); + jest.spyOn(controller, 'refreshLendingPositions').mockResolvedValue(); + }); + + it('updates pooled stakes for staking deposit transaction type', () => { + const MOCK_CONFIRMED_DEPOSIT_TX = createMockTransaction({ + type: TransactionType.stakingDeposit, + status: TransactionStatus.confirmed, + }); + + messenger.publish( + 'TransactionController:transactionConfirmed', + MOCK_CONFIRMED_DEPOSIT_TX, + ); + + expect(controller.refreshPooledStakes).toHaveBeenNthCalledWith(1, { + address: MOCK_CONFIRMED_DEPOSIT_TX.txParams.from, + resetCache: true, + }); + }); + + it('updates pooled stakes for staking unstake transaction type', () => { + const MOCK_CONFIRMED_UNSTAKE_TX = createMockTransaction({ + type: TransactionType.stakingUnstake, + status: TransactionStatus.confirmed, + }); + + messenger.publish( + 'TransactionController:transactionConfirmed', + MOCK_CONFIRMED_UNSTAKE_TX, + ); + + expect(controller.refreshPooledStakes).toHaveBeenNthCalledWith(1, { + address: MOCK_CONFIRMED_UNSTAKE_TX.txParams.from, + resetCache: true, + }); + }); + + it('updates pooled stakes for staking claim transaction type', () => { + const MOCK_CONFIRMED_CLAIM_TX = createMockTransaction({ + type: TransactionType.stakingClaim, + status: TransactionStatus.confirmed, + }); + + messenger.publish( + 'TransactionController:transactionConfirmed', + MOCK_CONFIRMED_CLAIM_TX, + ); + + expect(controller.refreshPooledStakes).toHaveBeenNthCalledWith(1, { + address: MOCK_CONFIRMED_CLAIM_TX.txParams.from, + resetCache: true, + }); + }); + + it('updates lending positions for lending deposit transaction type', () => { + const MOCK_CONFIRMED_DEPOSIT_TX = createMockTransaction({ + type: TransactionType.lendingDeposit, + status: TransactionStatus.confirmed, + }); + + messenger.publish( + 'TransactionController:transactionConfirmed', + MOCK_CONFIRMED_DEPOSIT_TX, + ); + + expect(controller.refreshLendingPositions).toHaveBeenNthCalledWith(1, { + address: MOCK_CONFIRMED_DEPOSIT_TX.txParams.from, + }); + }); + + it('updates lending positions for lending withdraw transaction type', () => { + const MOCK_CONFIRMED_WITHDRAW_TX = createMockTransaction({ + type: 'lendingWithdraw' as TransactionType, + status: TransactionStatus.confirmed, + }); + + messenger.publish( + 'TransactionController:transactionConfirmed', + MOCK_CONFIRMED_WITHDRAW_TX, + ); + + expect(controller.refreshLendingPositions).toHaveBeenNthCalledWith(1, { + address: MOCK_CONFIRMED_WITHDRAW_TX.txParams.from, + }); + }); + + it('ignores non-staking and non-lending transaction types', () => { + const MOCK_CONFIRMED_SWAP_TX = createMockTransaction({ + type: TransactionType.swap, + status: TransactionStatus.confirmed, + }); + + messenger.publish( + 'TransactionController:transactionConfirmed', + MOCK_CONFIRMED_SWAP_TX, + ); + + expect(controller.refreshPooledStakes).toHaveBeenCalledTimes(0); + expect(controller.refreshLendingPositions).toHaveBeenCalledTimes(0); + }); + }); + }); + + describe('Lending', () => { + describe('refreshLendingPositions', () => { + it('fetches using active account (default)', async () => { + const { controller } = await setupController(); + await controller.refreshLendingPositions(); + + // Assertion on second call since the first is part of controller setup. + expect( + mockedEarnApiService?.lending?.getPositions, + ).toHaveBeenNthCalledWith(2, mockAccount1Address); + }); + + it('fetches using options.address override', async () => { + const { controller } = await setupController(); + await controller.refreshLendingPositions({ + address: mockAccount2Address, + }); + + // Assertion on second call since the first is part of controller setup. + expect( + mockedEarnApiService?.lending?.getPositions, + ).toHaveBeenNthCalledWith(2, mockAccount2Address); + }); + + it('returns early without fetching when no address is available', async () => { + const { controller } = await setupController({ + mockGetAccountsFromSelectedAccountGroup: jest.fn(() => []), + }); + + await controller.refreshLendingPositions(); + + expect( + mockedEarnApiService?.lending?.getPositions, + ).not.toHaveBeenCalled(); + }); + }); + + describe('refreshLendingMarkets', () => { + it('fetches lending markets', async () => { + const { controller } = await setupController(); + await controller.refreshLendingMarkets(); + + // Assertion on second call since the first is part of controller setup. + expect(mockedEarnApiService?.lending?.getMarkets).toHaveBeenCalledTimes( + 2, + ); + }); + }); + + describe('refreshLendingData', () => { + it('refreshes lending data', async () => { + const { controller } = await setupController(); + await controller.refreshLendingData(); + + // Assertion on second call since the first is part of controller setup. + expect(mockedEarnApiService?.lending?.getMarkets).toHaveBeenCalledTimes( + 2, + ); + expect( + mockedEarnApiService?.lending?.getPositions, + ).toHaveBeenCalledTimes(2); + }); + }); + + describe('getLendingPositionHistory', () => { + it('gets lending position history', async () => { + const { controller } = await setupController(); + const mockPositionHistory = [ + { + id: '1', + timestamp: '2024-02-20T00:00:00.000Z', + type: 'deposit', + amount: '100', + }, + ]; + + expect(mockedEarnApiService.lending).toBeDefined(); + + ( + (mockedEarnApiService.lending as LendingApiService) + .getPositionHistory as jest.Mock + ).mockResolvedValue(mockPositionHistory); + + const result = await controller.getLendingPositionHistory({ + chainId: 1, + positionId: '1', + marketId: 'market1', + marketAddress: '0x123', + protocol: 'aave' as LendingMarket['protocol'], + }); + + expect(result).toStrictEqual(mockPositionHistory); + expect( + (mockedEarnApiService.lending as LendingApiService) + .getPositionHistory, + ).toHaveBeenCalledWith( + mockAccount1Address, + 1, + 'aave', + 'market1', + '0x123', + '1', + 730, + ); + }); + + it('returns empty array if no address is provided', async () => { + const { controller } = await setupController({ + mockGetAccountsFromSelectedAccountGroup: jest.fn(() => []), + }); + const result = await controller.getLendingPositionHistory({ + chainId: 1, + positionId: '1', + marketId: 'market1', + marketAddress: '0x123', + protocol: 'aave' as LendingMarket['protocol'], + }); + + expect(result).toStrictEqual([]); + }); + + it('returns empty array when chain is not supported', async () => { + isSupportedLendingChainMock.mockReturnValue(false); + const { controller } = await setupController(); + + const result = await controller.getLendingPositionHistory({ + chainId: 2, + positionId: '1', + marketId: 'market1', + marketAddress: '0x123', + protocol: 'aave' as LendingMarket['protocol'], + }); + + expect(result).toStrictEqual([]); + }); + }); + + describe('getLendingMarketDailyApysAndAverages', () => { + it('gets lending market daily apys and averages', async () => { + const { controller } = await setupController(); + const mockApysAndAverages = { + dailyApys: [ + { + id: 1, + timestamp: '2024-02-20T00:00:00.000Z', + apy: '5.5', + }, + ], + averages: { + oneDay: '5.5', + oneWeek: '5.5', + oneMonth: '5.5', + threeMonths: '5.5', + sixMonths: '5.5', + oneYear: '5.5', + }, + }; + + if (!mockedEarnApiService.lending) { + throw new Error('Lending service not initialized'); + } + + ( + mockedEarnApiService.lending.getHistoricMarketApys as jest.Mock + ).mockResolvedValue(mockApysAndAverages); + + const result = await controller.getLendingMarketDailyApysAndAverages({ + chainId: 1, + protocol: 'aave' as LendingMarket['protocol'], + marketId: 'market1', + }); + + expect(result).toStrictEqual(mockApysAndAverages); + expect( + mockedEarnApiService.lending.getHistoricMarketApys, + ).toHaveBeenCalledWith(1, 'aave', 'market1', 365); + }); + + it('returns undefined when chain is not supported', async () => { + isSupportedLendingChainMock.mockReturnValue(false); + const { controller } = await setupController(); + + const result = await controller.getLendingMarketDailyApysAndAverages({ + chainId: 2, + protocol: 'aave' as LendingMarket['protocol'], + marketId: 'market1', + }); + + expect(result).toBeUndefined(); + }); + }); + + describe('executeLendingDeposit', () => { + it('executes lending deposit transaction', async () => { + const mockTransactionData = { + to: '0x123', + data: '0x456', + value: '0', + gasLimit: 100000, + }; + const mockLendingContract = { + encodeDepositTransactionData: jest + .fn() + .mockResolvedValue(mockTransactionData), + }; + (EarnSdk.create as jest.Mock).mockImplementation(() => ({ + contracts: { + lending: { + aave: { + '0x123': mockLendingContract, + }, + }, + }, + })); + + const addTransactionFn = jest.fn().mockResolvedValue('successfulhash'); + const { controller } = await setupController({ + addTransactionFn, + }); + + const result = await controller.executeLendingDeposit({ + amount: '100', + chainId: '0x1', + protocol: 'aave' as LendingMarket['protocol'], + underlyingTokenAddress: '0x123', + gasOptions: {}, + txOptions: { + networkClientId: '1', + }, + }); + + expect( + mockLendingContract.encodeDepositTransactionData, + ).toHaveBeenCalledWith('100', mockAccount1Address, {}); + expect(result).toBe('successfulhash'); + + expect(addTransactionFn).toHaveBeenCalledWith( + { + ...mockTransactionData, + value: '0', + chainId: '0x1', + gasLimit: toHex(mockTransactionData.gasLimit), + }, + { + networkClientId: '1', + isInternal: true, + }, + ); + }); + + it('executes lending deposit transaction with 0 gasLimit', async () => { + const mockTransactionData = { + to: '0x123', + data: '0x456', + value: '0', + gasLimit: 0, + }; + const mockLendingContract = { + encodeDepositTransactionData: jest + .fn() + .mockResolvedValue(mockTransactionData), + }; + (EarnSdk.create as jest.Mock).mockImplementation(() => ({ + contracts: { + lending: { + aave: { + '0x123': mockLendingContract, + }, + }, + }, + })); + const addTransactionFn = jest.fn().mockResolvedValue('successfulhash'); + + const { controller } = await setupController({ + addTransactionFn, + }); + + const result = await controller.executeLendingDeposit({ + amount: '100', + chainId: '0x1', + protocol: 'aave' as LendingMarket['protocol'], + underlyingTokenAddress: '0x123', + gasOptions: {}, + txOptions: { + networkClientId: '1', + }, + }); + + expect( + mockLendingContract.encodeDepositTransactionData, + ).toHaveBeenCalledWith('100', mockAccount1Address, {}); + expect(result).toBe('successfulhash'); + + expect(addTransactionFn).toHaveBeenCalledWith( + { + ...mockTransactionData, + value: '0', + chainId: '0x1', + gasLimit: undefined, + }, + { + networkClientId: '1', + isInternal: true, + }, + ); + }); + + it('handles error when encodeDepositTransactionData throws', async () => { + const contractError = new Error('Contract Error'); + const mockLendingContract = { + encodeDepositTransactionData: jest + .fn() + .mockRejectedValue(contractError), + }; + + (EarnSdk.create as jest.Mock).mockImplementation(() => ({ + contracts: { + lending: { + aave: { + '0x123': mockLendingContract, + }, + }, + }, + })); + + const { controller } = await setupController(); + + await expect( + controller.executeLendingDeposit({ + amount: '100', + chainId: '0x1', + protocol: 'aave' as LendingMarket['protocol'], + underlyingTokenAddress: '0x123', + gasOptions: {}, + txOptions: { + networkClientId: '1', + }, + }), + ).rejects.toThrow(contractError); + }); + + it('handles transaction data not found', async () => { + const { controller } = await setupController(); + await expect( + controller.executeLendingDeposit({ + amount: '100', + chainId: '0x1', + protocol: 'aave' as LendingMarket['protocol'], + underlyingTokenAddress: '0x123', + gasOptions: {}, + txOptions: { + networkClientId: '1', + }, + }), + ).rejects.toThrow('Transaction data not found'); + }); + + it('handles selected network client id not found', async () => { + const mockTransactionData = { + to: '0x123', + data: '0x456', + value: '0', + gasLimit: 100000, + }; + const mockLendingContract = { + encodeDepositTransactionData: jest + .fn() + .mockResolvedValue(mockTransactionData), + }; + + (EarnSdk.create as jest.Mock).mockImplementation(() => ({ + contracts: { + lending: { + aave: { + '0x123': mockLendingContract, + }, + }, + }, + })); + + const { controller } = await setupController({ + mockGetNetworkControllerState: jest.fn(() => ({ + ...getDefaultNetworkControllerState(), + selectedNetworkClientId: '', + })), + }); + + await expect( + controller.executeLendingDeposit({ + amount: '100', + chainId: '0x1', + protocol: 'aave' as LendingMarket['protocol'], + underlyingTokenAddress: '0x123', + gasOptions: {}, + txOptions: { + networkClientId: '1', + }, + }), + ).rejects.toThrow('Selected network client id not found'); + }); + + it('handles no selected account address found', async () => { + const { controller } = await setupController({ + mockGetAccountsFromSelectedAccountGroup: jest.fn(() => []), + }); + await expect( + controller.executeLendingDeposit({ + amount: '100', + chainId: '0x1', + protocol: 'aave' as LendingMarket['protocol'], + underlyingTokenAddress: '0x123', + gasOptions: {}, + txOptions: { + networkClientId: '1', + }, + }), + ).rejects.toThrow('No EVM-compatible account address found'); + }); + }); + + describe('executeLendingWithdraw', () => { + it('executes lending withdraw transaction', async () => { + const mockTransactionData = { + to: '0x123', + data: '0x456', + value: '0', + gasLimit: 100000, + }; + + const mockLendingContract = { + encodeWithdrawTransactionData: jest + .fn() + .mockResolvedValue(mockTransactionData), + }; + + (EarnSdk.create as jest.Mock).mockImplementation(() => ({ + contracts: { + lending: { + aave: { + '0x123': mockLendingContract, + }, + }, + }, + })); + + const addTransactionFn = jest.fn().mockResolvedValue('successfulhash'); + const { controller } = await setupController({ + addTransactionFn, + }); + + const result = await controller.executeLendingWithdraw({ + amount: '100', + chainId: '0x1', + protocol: 'aave' as LendingMarket['protocol'], + underlyingTokenAddress: '0x123', + gasOptions: {}, + txOptions: { + networkClientId: '1', + }, + }); + + expect( + mockLendingContract.encodeWithdrawTransactionData, + ).toHaveBeenCalledWith('100', mockAccount1Address, {}); + expect(result).toBe('successfulhash'); + expect(addTransactionFn).toHaveBeenCalledWith( + { + ...mockTransactionData, + value: '0', + chainId: '0x1', + gasLimit: toHex(mockTransactionData.gasLimit), + }, + { + networkClientId: '1', + isInternal: true, + }, + ); + }); + + it('executes lending withdraw transaction with 0 gasLimit', async () => { + const mockTransactionData = { + to: '0x123', + data: '0x456', + value: '0', + gasLimit: 0, + }; + + const mockLendingContract = { + encodeWithdrawTransactionData: jest + .fn() + .mockResolvedValue(mockTransactionData), + }; + + (EarnSdk.create as jest.Mock).mockImplementation(() => ({ + contracts: { + lending: { + aave: { + '0x123': mockLendingContract, + }, + }, + }, + })); + + const addTransactionFn = jest.fn().mockResolvedValue('successfulhash'); + const { controller } = await setupController({ + addTransactionFn, + }); + + const result = await controller.executeLendingWithdraw({ + amount: '100', + chainId: '0x1', + protocol: 'aave' as LendingMarket['protocol'], + underlyingTokenAddress: '0x123', + gasOptions: {}, + txOptions: { + networkClientId: '1', + }, + }); + + expect( + mockLendingContract.encodeWithdrawTransactionData, + ).toHaveBeenCalledWith('100', mockAccount1Address, {}); + expect(result).toBe('successfulhash'); + expect(addTransactionFn).toHaveBeenCalledWith( + { + ...mockTransactionData, + value: '0', + chainId: '0x1', + gasLimit: undefined, + }, + { + networkClientId: '1', + isInternal: true, + }, + ); + }); + + it('handles transaction data not found', async () => { + const { controller } = await setupController(); + await expect( + controller.executeLendingWithdraw({ + amount: '100', + chainId: '0x1', + protocol: 'aave' as LendingMarket['protocol'], + underlyingTokenAddress: '0x123', + gasOptions: {}, + txOptions: { + networkClientId: '1', + }, + }), + ).rejects.toThrow('Transaction data not found'); + }); + + it('handles selected network client id not found', async () => { + const mockTransactionData = { + to: '0x123', + data: '0x456', + value: '0', + gasLimit: 100000, + }; + const mockLendingContract = { + encodeWithdrawTransactionData: jest + .fn() + .mockResolvedValue(mockTransactionData), + }; + + (EarnSdk.create as jest.Mock).mockImplementation(() => ({ + contracts: { + lending: { + aave: { + '0x123': mockLendingContract, + }, + }, + }, + })); + + const { controller } = await setupController({ + mockGetNetworkControllerState: jest.fn(() => ({ + ...getDefaultNetworkControllerState(), + selectedNetworkClientId: '', + })), + }); + + await expect( + controller.executeLendingWithdraw({ + amount: '100', + chainId: '0x1', + protocol: 'aave' as LendingMarket['protocol'], + underlyingTokenAddress: '0x123', + gasOptions: {}, + txOptions: { + networkClientId: '1', + }, + }), + ).rejects.toThrow('Selected network client id not found'); + }); + + it('handles no selected account address found', async () => { + const { controller } = await setupController({ + mockGetAccountsFromSelectedAccountGroup: jest.fn(() => []), + }); + await expect( + controller.executeLendingWithdraw({ + amount: '100', + chainId: '0x1', + protocol: 'aave' as LendingMarket['protocol'], + underlyingTokenAddress: '0x123', + gasOptions: {}, + txOptions: { + networkClientId: '1', + }, + }), + ).rejects.toThrow('No EVM-compatible account address found'); + }); + }); + + describe('executeLendingTokenApprove', () => { + it('executes lending token approve transaction', async () => { + const mockTransactionData = { + to: '0x123', + data: '0x456', + value: '0', + gasLimit: 100000, + }; + + const mockLendingContract = { + encodeUnderlyingTokenApproveTransactionData: jest + .fn() + .mockResolvedValue(mockTransactionData), + }; + + (EarnSdk.create as jest.Mock).mockImplementation(() => ({ + contracts: { + lending: { + aave: { + '0x123': mockLendingContract, + }, + }, + }, + })); + + const addTransactionFn = jest.fn().mockResolvedValue('successfulhash'); + const { controller } = await setupController({ + addTransactionFn, + }); + + const result = await controller.executeLendingTokenApprove({ + amount: '100', + chainId: '0x1', + protocol: 'aave' as LendingMarket['protocol'], + underlyingTokenAddress: '0x123', + gasOptions: {}, + txOptions: { + networkClientId: '1', + }, + }); + + expect( + mockLendingContract.encodeUnderlyingTokenApproveTransactionData, + ).toHaveBeenCalledWith('100', mockAccount1Address, {}); + expect(result).toBe('successfulhash'); + expect(addTransactionFn).toHaveBeenCalledWith( + { + ...mockTransactionData, + value: '0', + chainId: '0x1', + gasLimit: toHex(mockTransactionData.gasLimit), + }, + { + networkClientId: '1', + isInternal: true, + }, + ); + }); + + it('executes lending token approve transaction with 0 gasLimit', async () => { + const mockTransactionData = { + to: '0x123', + data: '0x456', + value: '0', + gasLimit: 0, + }; + + const mockLendingContract = { + encodeUnderlyingTokenApproveTransactionData: jest + .fn() + .mockResolvedValue(mockTransactionData), + }; + + (EarnSdk.create as jest.Mock).mockImplementation(() => ({ + contracts: { + lending: { + aave: { + '0x123': mockLendingContract, + }, + }, + }, + })); + + const addTransactionFn = jest.fn().mockResolvedValue('successfulhash'); + const { controller } = await setupController({ + addTransactionFn, + }); + + const result = await controller.executeLendingTokenApprove({ + amount: '100', + chainId: '0x1', + protocol: 'aave' as LendingMarket['protocol'], + underlyingTokenAddress: '0x123', + gasOptions: {}, + txOptions: { + networkClientId: '1', + }, + }); + + expect( + mockLendingContract.encodeUnderlyingTokenApproveTransactionData, + ).toHaveBeenCalledWith('100', mockAccount1Address, {}); + expect(result).toBe('successfulhash'); + expect(addTransactionFn).toHaveBeenCalledWith( + { + ...mockTransactionData, + value: '0', + chainId: '0x1', + gasLimit: undefined, + }, + { + networkClientId: '1', + isInternal: true, + }, + ); + }); + + it('handles transaction data not found', async () => { + const { controller } = await setupController(); + await expect( + controller.executeLendingTokenApprove({ + amount: '100', + chainId: '0x1', + protocol: 'aave' as LendingMarket['protocol'], + underlyingTokenAddress: '0x123', + gasOptions: {}, + txOptions: { + networkClientId: '1', + }, + }), + ).rejects.toThrow('Transaction data not found'); + }); + + it('handles selected network client id not found', async () => { + const mockTransactionData = { + to: '0x123', + data: '0x456', + value: '0', + gasLimit: 100000, + }; + const mockLendingContract = { + encodeUnderlyingTokenApproveTransactionData: jest + .fn() + .mockResolvedValue(mockTransactionData), + }; + + (EarnSdk.create as jest.Mock).mockImplementation(() => ({ + contracts: { + lending: { + aave: { + '0x123': mockLendingContract, + }, + }, + }, + })); + + const { controller } = await setupController({ + mockGetNetworkControllerState: jest.fn(() => ({ + ...getDefaultNetworkControllerState(), + selectedNetworkClientId: '', + })), + }); + + await expect( + controller.executeLendingTokenApprove({ + amount: '100', + chainId: '0x1', + protocol: 'aave' as LendingMarket['protocol'], + underlyingTokenAddress: '0x123', + gasOptions: {}, + txOptions: { + networkClientId: '1', + }, + }), + ).rejects.toThrow('Selected network client id not found'); + }); + + it('handles no selected account address found', async () => { + const { controller } = await setupController({ + mockGetAccountsFromSelectedAccountGroup: jest.fn(() => []), + }); + await expect( + controller.executeLendingTokenApprove({ + amount: '100', + chainId: '0x1', + protocol: 'aave' as LendingMarket['protocol'], + underlyingTokenAddress: '0x123', + gasOptions: {}, + txOptions: { + networkClientId: '1', + }, + }), + ).rejects.toThrow('No EVM-compatible account address found'); + }); + }); + + describe('getLendingTokenAllowance', () => { + it('gets lending token allowance', async () => { + const mockAllowance = '1000'; + + const mockLendingContract = { + underlyingTokenAllowance: jest.fn().mockResolvedValue(mockAllowance), + }; + + (EarnSdk.create as jest.Mock).mockImplementation(() => ({ + contracts: { + lending: { + aave: { + '0x123': mockLendingContract, + }, + }, + }, + })); + + const { controller } = await setupController(); + + const result = await controller.getLendingTokenAllowance( + 'aave' as LendingMarket['protocol'], + '0x123', + ); + + expect( + mockLendingContract.underlyingTokenAllowance, + ).toHaveBeenCalledWith(mockAccount1Address); + expect(result).toBe(mockAllowance); + }); + + it('doesn`t call underlyingTokenAllowance if no account address found', async () => { + const mockLendingContract = { + underlyingTokenAllowance: jest.fn().mockResolvedValue(0), + }; + + (EarnSdk.create as jest.Mock).mockImplementation(() => ({ + contracts: { + lending: { + aave: { + '0x123': mockLendingContract, + }, + }, + }, + })); + + const { controller } = await setupController({ + mockGetAccountsFromSelectedAccountGroup: jest.fn(() => []), + }); + + await controller.getLendingTokenAllowance( + 'aave' as LendingMarket['protocol'], + '0x123', + ); + + expect( + mockLendingContract.underlyingTokenAllowance, + ).not.toHaveBeenCalled(); + }); + }); + + describe('getLendingTokenMaxWithdraw', () => { + it('gets lending token max withdraw', async () => { + const mockMaxWithdraw = '1000'; + + const mockLendingContract = { + maxWithdraw: jest.fn().mockResolvedValue(mockMaxWithdraw), + }; + + (EarnSdk.create as jest.Mock).mockImplementation(() => ({ + contracts: { + lending: { + aave: { + '0x123': mockLendingContract, + }, + }, + }, + })); + + const { controller } = await setupController(); + + const result = await controller.getLendingTokenMaxWithdraw( + 'aave' as LendingMarket['protocol'], + '0x123', + ); + + expect(mockLendingContract.maxWithdraw).toHaveBeenCalledWith( + mockAccount1Address, + ); + expect(result).toBe(mockMaxWithdraw); + }); + + it('doesn`t call maxWithdraw if no account address found', async () => { + const mockLendingContract = { + maxWithdraw: jest.fn().mockResolvedValue(0), + }; + + (EarnSdk.create as jest.Mock).mockImplementation(() => ({ + contracts: { + lending: { + aave: { + '0x123': mockLendingContract, + }, + }, + }, + })); + + const { controller } = await setupController({ + mockGetAccountsFromSelectedAccountGroup: jest.fn(() => []), + }); + + await controller.getLendingTokenMaxWithdraw( + 'aave' as LendingMarket['protocol'], + '0x123', + ); + + expect(mockLendingContract.maxWithdraw).not.toHaveBeenCalled(); + }); + }); + + describe('getLendingTokenMaxDeposit', () => { + it('gets lending token max deposit', async () => { + const mockMaxDeposit = '1000'; + + const mockLendingContract = { + maxDeposit: jest.fn().mockResolvedValue(mockMaxDeposit), + }; + + (EarnSdk.create as jest.Mock).mockImplementation(() => ({ + contracts: { + lending: { + aave: { + '0x123': mockLendingContract, + }, + }, + }, + })); + + const { controller } = await setupController(); + + const result = await controller.getLendingTokenMaxDeposit( + 'aave' as LendingMarket['protocol'], + '0x123', + ); + + expect(mockLendingContract.maxDeposit).toHaveBeenCalledWith( + mockAccount1Address, + ); + expect(result).toBe(mockMaxDeposit); + }); + + it('doesn`t call maxDeposit if no account address found', async () => { + const mockLendingContract = { + maxDeposit: jest.fn().mockResolvedValue(0), + }; + + (EarnSdk.create as jest.Mock).mockImplementation(() => ({ + contracts: { + lending: { + aave: { + '0x123': mockLendingContract, + }, + }, + }, + })); + + const { controller } = await setupController({ + mockGetAccountsFromSelectedAccountGroup: jest.fn(() => []), + }); + + await controller.getLendingTokenMaxDeposit( + 'aave' as LendingMarket['protocol'], + '0x123', + ); + + expect(mockLendingContract.maxDeposit).not.toHaveBeenCalled(); + }); + }); + }); + + describe('TRON Staking', () => { + describe('refreshTronStakingApy', () => { + it('updates state with fetched APY data', async () => { + const { controller } = await setupController(); + const mockApy = '3.35'; + const mockApyFetcher = jest.fn().mockResolvedValue(mockApy); + + await controller.refreshTronStakingApy(mockApyFetcher); + + expect(mockApyFetcher).toHaveBeenCalledTimes(1); + expect(controller.state.tron_staking).toStrictEqual( + expect.objectContaining({ + apy: '3.35', + lastUpdated: expect.any(Number), + }), + ); + }); + + it('overwrites existing APY data', async () => { + const { controller } = await setupController(); + + await controller.refreshTronStakingApy( + jest.fn().mockResolvedValue('3.35'), + ); + + const firstLastUpdated = controller.state.tron_staking?.lastUpdated; + + await new Promise((resolve) => setTimeout(resolve, 10)); + + await controller.refreshTronStakingApy( + jest.fn().mockResolvedValue('4.0'), + ); + + expect(controller.state.tron_staking?.apy).toBe('4.0'); + expect(controller.state.tron_staking?.lastUpdated).toBeGreaterThan( + firstLastUpdated as number, + ); + }); + + it('handles apyFetcher errors', async () => { + const { controller } = await setupController(); + const mockError = new Error('Failed to fetch APY'); + const mockApyFetcher = jest.fn().mockRejectedValue(mockError); + + await expect( + controller.refreshTronStakingApy(mockApyFetcher), + ).rejects.toThrow('Failed to fetch APY'); + + expect(controller.state.tron_staking).toBeNull(); + }); + }); + + describe('getTronStakingApy', () => { + it('returns APY when available', async () => { + const { controller } = await setupController(); + + await controller.refreshTronStakingApy( + jest.fn().mockResolvedValue('3.35'), + ); + + const result = controller.getTronStakingApy(); + expect(result).toBe('3.35'); + }); + + it('returns undefined when not available', async () => { + const { controller } = await setupController(); + + const result = controller.getTronStakingApy(); + expect(result).toBeUndefined(); + }); + }); + }); + + describe('metadata', () => { + it('includes expected state in debug snapshots', async () => { + const { controller } = await setupController(); + + expect( + deriveStateFromMetadata( + controller.state, + controller.metadata, + 'includeInDebugSnapshot', + ), + ).toMatchInlineSnapshot(` + { + "lastUpdated": 0, + } + `); + }); + + it('includes expected state in state logs', async () => { + const { controller } = await setupController(); + + const derivedState = deriveStateFromMetadata( + controller.state, + controller.metadata, + 'includeInStateLogs', + ); + + // Compare `pooled_staking` and `tron_staking` separately to minimize size of snapshot + const { + pooled_staking: derivedPooledStaking, + tron_staking: derivedTronStaking, + ...derivedStateWithoutPooledStaking + } = derivedState; + expect(derivedPooledStaking).toStrictEqual({ + '1': { + pooledStakes: mockPooledStakes, + exchangeRate: '1.5', + vaultMetadata: mockVaultMetadata, + vaultDailyApys: mockPooledStakingVaultDailyApys, + vaultApyAverages: mockPooledStakingVaultApyAverages, + }, + isEligible: true, + }); + expect(derivedTronStaking).toBeNull(); + expect(derivedStateWithoutPooledStaking).toMatchInlineSnapshot(` + { + "lastUpdated": 0, + "lending": { + "isEligible": true, + "markets": [ + { + "address": "0xe50fa9b3c56ffb159cb0fca61f5c9d750e8128c8", + "chainId": 42161, + "id": "0xe50fa9b3c56ffb159cb0fca61f5c9d750e8128c8", + "name": "0xe50fa9b3c56ffb159cb0fca61f5c9d750e8128c8", + "netSupplyRate": 1.52269127978874, + "outputToken": { + "address": "0xe50fa9b3c56ffb159cb0fca61f5c9d750e8128c8", + "chainId": 42161, + }, + "protocol": "aave", + "rewards": [], + "totalSupplyRate": 1.52269127978874, + "tvlUnderlying": "132942564710249273623333", + "underlying": { + "address": "0x82af49447d8a07e3bd95bd0d56f35241523fbab1", + "chainId": 42161, + }, + }, + ], + "positions": [ + { + "assets": "112", + "chainId": 42161, + "id": "0xe6a7d2b7de29167ae4c3864ac0873e6dcd9cb47b-0x078f358208685046a11c85e8ad32895ded33a249-COLLATERAL-0", + "market": { + "address": "0x078f358208685046a11c85e8ad32895ded33a249", + "chainId": 42161, + "id": "0x078f358208685046a11c85e8ad32895ded33a249", + "name": "0x078f358208685046a11c85e8ad32895ded33a249", + "netSupplyRate": 0.0062858302613958, + "outputToken": { + "address": "0x078f358208685046a11c85e8ad32895ded33a249", + "chainId": 42161, + }, + "protocol": "aave", + "rewards": [], + "totalSupplyRate": 0.0062858302613958, + "tvlUnderlying": "315871357755", + "underlying": { + "address": "0x2f2a2543b76a4166549f7aab2e75bef0aefc5b0f", + "chainId": 42161, + }, + }, + "marketAddress": "0x078f358208685046a11c85e8ad32895ded33a249", + "marketId": "0x078f358208685046a11c85e8ad32895ded33a249", + "protocol": "aave", + }, + ], + }, + } + `); + }); + + it('persists expected state', async () => { + const { controller } = await setupController(); + + const derivedState = deriveStateFromMetadata( + controller.state, + controller.metadata, + 'persist', + ); + + // Compare `pooled_staking` and `tron_staking` separately to minimize size of snapshot + const { + pooled_staking: derivedPooledStaking, + tron_staking: derivedTronStaking, + ...derivedStateWithoutPooledStaking + } = derivedState; + expect(derivedPooledStaking).toStrictEqual({ + '1': { + pooledStakes: mockPooledStakes, + exchangeRate: '1.5', + vaultMetadata: mockVaultMetadata, + vaultDailyApys: mockPooledStakingVaultDailyApys, + vaultApyAverages: mockPooledStakingVaultApyAverages, + }, + isEligible: true, + }); + expect(derivedTronStaking).toBeNull(); + expect(derivedStateWithoutPooledStaking).toMatchInlineSnapshot(` + { + "lending": { + "isEligible": true, + "markets": [ + { + "address": "0xe50fa9b3c56ffb159cb0fca61f5c9d750e8128c8", + "chainId": 42161, + "id": "0xe50fa9b3c56ffb159cb0fca61f5c9d750e8128c8", + "name": "0xe50fa9b3c56ffb159cb0fca61f5c9d750e8128c8", + "netSupplyRate": 1.52269127978874, + "outputToken": { + "address": "0xe50fa9b3c56ffb159cb0fca61f5c9d750e8128c8", + "chainId": 42161, + }, + "protocol": "aave", + "rewards": [], + "totalSupplyRate": 1.52269127978874, + "tvlUnderlying": "132942564710249273623333", + "underlying": { + "address": "0x82af49447d8a07e3bd95bd0d56f35241523fbab1", + "chainId": 42161, + }, + }, + ], + "positions": [ + { + "assets": "112", + "chainId": 42161, + "id": "0xe6a7d2b7de29167ae4c3864ac0873e6dcd9cb47b-0x078f358208685046a11c85e8ad32895ded33a249-COLLATERAL-0", + "market": { + "address": "0x078f358208685046a11c85e8ad32895ded33a249", + "chainId": 42161, + "id": "0x078f358208685046a11c85e8ad32895ded33a249", + "name": "0x078f358208685046a11c85e8ad32895ded33a249", + "netSupplyRate": 0.0062858302613958, + "outputToken": { + "address": "0x078f358208685046a11c85e8ad32895ded33a249", + "chainId": 42161, + }, + "protocol": "aave", + "rewards": [], + "totalSupplyRate": 0.0062858302613958, + "tvlUnderlying": "315871357755", + "underlying": { + "address": "0x2f2a2543b76a4166549f7aab2e75bef0aefc5b0f", + "chainId": 42161, + }, + }, + "marketAddress": "0x078f358208685046a11c85e8ad32895ded33a249", + "marketId": "0x078f358208685046a11c85e8ad32895ded33a249", + "protocol": "aave", + }, + ], + }, + } + `); + }); + + it('exposes expected state to UI', async () => { + const { controller } = await setupController(); + + const derivedState = deriveStateFromMetadata( + controller.state, + controller.metadata, + 'usedInUi', + ); + + // Compare `pooled_staking` and `tron_staking` separately to minimize size of snapshot + const { + pooled_staking: derivedPooledStaking, + tron_staking: derivedTronStaking, + ...derivedStateWithoutPooledStaking + } = derivedState; + expect(derivedPooledStaking).toStrictEqual({ + '1': { + pooledStakes: mockPooledStakes, + exchangeRate: '1.5', + vaultMetadata: mockVaultMetadata, + vaultDailyApys: mockPooledStakingVaultDailyApys, + vaultApyAverages: mockPooledStakingVaultApyAverages, + }, + isEligible: true, + }); + expect(derivedTronStaking).toBeNull(); + expect(derivedStateWithoutPooledStaking).toMatchInlineSnapshot(` + { + "lending": { + "isEligible": true, + "markets": [ + { + "address": "0xe50fa9b3c56ffb159cb0fca61f5c9d750e8128c8", + "chainId": 42161, + "id": "0xe50fa9b3c56ffb159cb0fca61f5c9d750e8128c8", + "name": "0xe50fa9b3c56ffb159cb0fca61f5c9d750e8128c8", + "netSupplyRate": 1.52269127978874, + "outputToken": { + "address": "0xe50fa9b3c56ffb159cb0fca61f5c9d750e8128c8", + "chainId": 42161, + }, + "protocol": "aave", + "rewards": [], + "totalSupplyRate": 1.52269127978874, + "tvlUnderlying": "132942564710249273623333", + "underlying": { + "address": "0x82af49447d8a07e3bd95bd0d56f35241523fbab1", + "chainId": 42161, + }, + }, + ], + "positions": [ + { + "assets": "112", + "chainId": 42161, + "id": "0xe6a7d2b7de29167ae4c3864ac0873e6dcd9cb47b-0x078f358208685046a11c85e8ad32895ded33a249-COLLATERAL-0", + "market": { + "address": "0x078f358208685046a11c85e8ad32895ded33a249", + "chainId": 42161, + "id": "0x078f358208685046a11c85e8ad32895ded33a249", + "name": "0x078f358208685046a11c85e8ad32895ded33a249", + "netSupplyRate": 0.0062858302613958, + "outputToken": { + "address": "0x078f358208685046a11c85e8ad32895ded33a249", + "chainId": 42161, + }, + "protocol": "aave", + "rewards": [], + "totalSupplyRate": 0.0062858302613958, + "tvlUnderlying": "315871357755", + "underlying": { + "address": "0x2f2a2543b76a4166549f7aab2e75bef0aefc5b0f", + "chainId": 42161, + }, + }, + "marketAddress": "0x078f358208685046a11c85e8ad32895ded33a249", + "marketId": "0x078f358208685046a11c85e8ad32895ded33a249", + "protocol": "aave", + }, + ], + }, + } + `); + }); + }); +}); diff --git a/packages/earn-controller/src/EarnController.ts b/packages/earn-controller/src/EarnController.ts new file mode 100644 index 00000000000..710328d3f3f --- /dev/null +++ b/packages/earn-controller/src/EarnController.ts @@ -0,0 +1,1319 @@ +import type { BigNumber } from '@ethersproject/bignumber'; +import { Web3Provider } from '@ethersproject/providers'; +import type { + AccountTreeControllerGetAccountsFromSelectedAccountGroupAction, + AccountTreeControllerSelectedAccountGroupChangeEvent, + AccountTreeControllerStateChangeEvent, +} from '@metamask/account-tree-controller'; +import type { + ControllerGetStateAction, + ControllerStateChangeEvent, + StateMetadata, +} from '@metamask/base-controller'; +import { BaseController } from '@metamask/base-controller'; +import { convertHexToDecimal, toHex } from '@metamask/controller-utils'; +import { isEvmAccountType } from '@metamask/keyring-api'; +import type { InternalAccount } from '@metamask/keyring-internal-api'; +import type { Messenger } from '@metamask/messenger'; +import type { + NetworkControllerGetNetworkClientByIdAction, + NetworkControllerGetStateAction, + NetworkControllerNetworkDidChangeEvent, + NetworkState, +} from '@metamask/network-controller'; +import { + EarnSdk, + EarnApiService, + isSupportedLendingChain, + EarnEnvironments, + ChainId, + isSupportedPooledStakingChain, +} from '@metamask/stake-sdk'; +import type { + LendingMarket, + PooledStake, + EarnSdkConfig, + VaultData, + VaultDailyApy, + VaultApyAverages, + LendingPosition, + GasLimitParams, + HistoricLendingMarketApys, +} from '@metamask/stake-sdk'; +import { TransactionType } from '@metamask/transaction-controller'; +import type { + TransactionController, + TransactionControllerTransactionConfirmedEvent, + TransactionMeta, +} from '@metamask/transaction-controller'; + +import type { EarnControllerMethodActions } from './EarnController-method-action-types.js'; +import type { + RefreshEarnEligibilityOptions, + RefreshLendingPositionsOptions, + RefreshPooledStakesOptions, + RefreshPooledStakingDataOptions, + RefreshPooledStakingVaultDailyApysOptions, +} from './types.js'; + +export const controllerName = 'EarnController'; + +export type PooledStakingState = { + [chainId: number]: { + pooledStakes: PooledStake; + exchangeRate: string; + vaultMetadata: VaultData; + vaultDailyApys: VaultDailyApy[]; + vaultApyAverages: VaultApyAverages; + }; + isEligible: boolean; +}; + +export type LendingPositionWithMarket = LendingPosition & { + marketId: string; + marketAddress: string; + protocol: string; +}; + +// extends LendingPosition to include a marketId, marketAddress, and protocol reference +export type LendingPositionWithMarketReference = Omit< + LendingPosition, + 'market' +> & { + marketId: string; + marketAddress: string; + protocol: string; +}; + +export type LendingMarketWithPosition = LendingMarket & { + position: LendingPositionWithMarketReference; +}; + +export type LendingState = { + markets: LendingMarket[]; // list of markets + positions: LendingPositionWithMarketReference[]; // list of positions + isEligible: boolean; +}; + +/** + * State for TRON staking. + */ +export type TronStakingState = { + /** The annual percentage yield as a decimal string (e.g., "3.35" for 3.35%) */ + apy: string; + /** Timestamp of when the APY was last fetched */ + lastUpdated: number; +} | null; + +type StakingTransactionTypes = + | TransactionType.stakingDeposit + | TransactionType.stakingUnstake + | TransactionType.stakingClaim; + +const stakingTransactionTypes = new Set([ + TransactionType.stakingDeposit, + TransactionType.stakingUnstake, + TransactionType.stakingClaim, +]); + +type LendingTransactionTypes = + | TransactionType.lendingDeposit + | TransactionType.lendingWithdraw; + +const lendingTransactionTypes = new Set([ + TransactionType.lendingDeposit, + TransactionType.lendingWithdraw, +]); + +/** + * Metadata for the EarnController. + */ +const earnControllerMetadata: StateMetadata = { + pooled_staking: { + includeInStateLogs: true, + persist: true, + includeInDebugSnapshot: false, + usedInUi: true, + }, + lending: { + includeInStateLogs: true, + persist: true, + includeInDebugSnapshot: false, + usedInUi: true, + }, + tron_staking: { + includeInStateLogs: true, + persist: true, + includeInDebugSnapshot: false, + usedInUi: true, + }, + lastUpdated: { + includeInStateLogs: true, + persist: false, + includeInDebugSnapshot: true, + usedInUi: false, + }, +}; + +// === State Types === +export type EarnControllerState = { + // eslint-disable-next-line @typescript-eslint/naming-convention + pooled_staking: PooledStakingState; + lending: LendingState; + // eslint-disable-next-line @typescript-eslint/naming-convention + tron_staking: TronStakingState; + lastUpdated: number; +}; + +// === Default State === +export const DEFAULT_LENDING_MARKET: LendingMarket = { + id: '', + chainId: 0, + protocol: '' as LendingMarket['protocol'], + name: '', + address: '', + tvlUnderlying: '0', + netSupplyRate: 0, + totalSupplyRate: 0, + underlying: { + address: '', + chainId: 0, + }, + outputToken: { + address: '', + chainId: 0, + }, + rewards: [ + { + token: { + address: '', + chainId: 0, + }, + rate: 0, + }, + ], +}; + +export const DEFAULT_LENDING_POSITION: LendingPositionWithMarketReference = { + id: '', + chainId: 0, + assets: '0', + marketId: '', + marketAddress: '', + protocol: '', +}; + +export const DEFAULT_POOLED_STAKING_VAULT_APY_AVERAGES: VaultApyAverages = { + oneDay: '0', + oneWeek: '0', + oneMonth: '0', + threeMonths: '0', + sixMonths: '0', + oneYear: '0', +}; + +export const DEFAULT_POOLED_STAKING_CHAIN_STATE = { + pooledStakes: { + account: '', + lifetimeRewards: '0', + assets: '0', + exitRequests: [], + }, + exchangeRate: '1', + vaultMetadata: { + apy: '0', + capacity: '0', + feePercent: 0, + totalAssets: '0', + vaultAddress: '0x0000000000000000000000000000000000000000', + }, + vaultDailyApys: [], + vaultApyAverages: DEFAULT_POOLED_STAKING_VAULT_APY_AVERAGES, +}; + +export const DEFAULT_TRON_STAKING_STATE: TronStakingState = null; + +/** + * Gets the default state for the EarnController. + * + * @returns The default EarnController state. + */ +export function getDefaultEarnControllerState(): EarnControllerState { + return { + pooled_staking: { + isEligible: false, + }, + lending: { + markets: [DEFAULT_LENDING_MARKET], + positions: [DEFAULT_LENDING_POSITION], + isEligible: false, + }, + tron_staking: DEFAULT_TRON_STAKING_STATE, + lastUpdated: 0, + }; +} + +// === MESSENGER === + +const MESSENGER_EXPOSED_METHODS = [ + 'refreshPooledStakes', + 'refreshEarnEligibility', + 'refreshPooledStakingVaultMetadata', + 'refreshPooledStakingVaultDailyApys', + 'refreshPooledStakingVaultApyAverages', + 'refreshPooledStakingData', + 'refreshLendingMarkets', + 'refreshLendingPositions', + 'refreshLendingData', + 'refreshTronStakingApy', + 'getTronStakingApy', + 'getLendingPositionHistory', + 'getLendingMarketDailyApysAndAverages', + 'executeLendingDeposit', + 'executeLendingWithdraw', + 'executeLendingTokenApprove', + 'getLendingTokenAllowance', + 'getLendingTokenMaxWithdraw', + 'getLendingTokenMaxDeposit', +] as const; + +/** + * The action which can be used to retrieve the state of the EarnController. + */ +export type EarnControllerGetStateAction = ControllerGetStateAction< + typeof controllerName, + EarnControllerState +>; + +/** + * All actions that EarnController registers, to be called externally. + */ +export type EarnControllerActions = + | EarnControllerGetStateAction + | EarnControllerMethodActions; + +/** + * All actions that EarnController calls internally. + */ +export type AllowedActions = + | NetworkControllerGetStateAction + | NetworkControllerGetNetworkClientByIdAction + | AccountTreeControllerGetAccountsFromSelectedAccountGroupAction; + +/** + * The event that EarnController publishes when updating state. + */ +export type EarnControllerStateChangeEvent = ControllerStateChangeEvent< + typeof controllerName, + EarnControllerState +>; + +/** + * All events that EarnController publishes, to be subscribed to externally. + */ +export type EarnControllerEvents = EarnControllerStateChangeEvent; + +/** + * All events that EarnController subscribes to internally. + */ +export type AllowedEvents = + | AccountTreeControllerStateChangeEvent + | AccountTreeControllerSelectedAccountGroupChangeEvent + | TransactionControllerTransactionConfirmedEvent + | NetworkControllerNetworkDidChangeEvent; + +/** + * The messenger which is restricted to actions and events accessed by + * EarnController. + */ +export type EarnControllerMessenger = Messenger< + typeof controllerName, + EarnControllerActions | AllowedActions, + EarnControllerEvents | AllowedEvents +>; + +// === CONTROLLER DEFINITION === + +/** + * EarnController manages DeFi earning opportunities across different protocols and chains. + */ +export class EarnController extends BaseController< + typeof controllerName, + EarnControllerState, + EarnControllerMessenger +> { + #earnSDK: EarnSdk | null = null; + + #initPromise: Promise | null = null; + + #lastRefreshedAddress: string | undefined; + + readonly #earnApiService: EarnApiService; + + readonly #addTransactionFn: typeof TransactionController.prototype.addTransaction; + + readonly #supportedPooledStakingChains: number[]; + + readonly #env: EarnEnvironments; + + constructor({ + messenger, + state = {}, + addTransactionFn, + env = EarnEnvironments.PROD, + }: { + messenger: EarnControllerMessenger; + state?: Partial; + addTransactionFn: typeof TransactionController.prototype.addTransaction; + env?: EarnEnvironments; + }) { + super({ + name: controllerName, + metadata: earnControllerMetadata, + messenger, + state: { + ...getDefaultEarnControllerState(), + ...state, + }, + }); + + this.#env = env; + + this.#earnApiService = new EarnApiService(this.#env); + + // temporary array of supported chains + // TODO: remove this once we export a supported chains list from the sdk + // from sdk or api to get lending and pooled staking chains + // + // Only eagerly prefetch Ethereum on startup/network-change; Hoodi + // (testnet) is still fully supported on-demand via explicit chainId + // calls (e.g. refreshPooledStakes({ chainId: ChainId.HOODI })), we just + // don't unconditionally fetch it for every user on every unlock. + this.#supportedPooledStakingChains = [ChainId.ETHEREUM]; + + this.#addTransactionFn = addTransactionFn; + + // Listen for network changes + this.messenger.subscribe( + 'NetworkController:networkDidChange', + (networkControllerState: NetworkState) => { + this.#initializeSDK( + networkControllerState.selectedNetworkClientId, + ).catch(console.error); + + // refresh pooled staking data + this.refreshPooledStakingVaultMetadata().catch(console.error); + this.refreshPooledStakingVaultDailyApys().catch(console.error); + this.refreshPooledStakingVaultApyAverages().catch(console.error); + this.refreshPooledStakes().catch(console.error); + + // refresh lending data for all chains + this.refreshLendingMarkets().catch(console.error); + this.refreshLendingPositions().catch(console.error); + }, + ); + + this.messenger.registerMethodActionHandlers( + this, + MESSENGER_EXPOSED_METHODS, + ); + + // Listen for account group changes + this.messenger.subscribe( + 'AccountTreeController:selectedAccountGroupChange', + () => { + const address = this.#getSelectedEvmAccountAddress(); + + // Skip if this account group change didn't actually change the + // resolved address (e.g. it can fire again right after init() + // during startup hydration) - nothing new to fetch. + if (!address || address === this.#lastRefreshedAddress) { + return; + } + + this.#lastRefreshedAddress = address; + + // TODO: temp solution, this will refresh lending eligibility also + // we could have a more general check, as what is happening is a compliance address check + this.refreshEarnEligibility({ address }).catch(console.error); + this.refreshPooledStakes({ address }).catch(console.error); + this.refreshLendingPositions({ address }).catch(console.error); + }, + ); + + // Listen for confirmed staking transactions + this.messenger.subscribe( + 'TransactionController:transactionConfirmed', + (transactionMeta: TransactionMeta) => { + /** + * When we speed up a transaction, we set the type as Retry and we lose + * information about type of transaction that is being set up, so we use + * original type to track that information. + */ + const { type, originalType } = transactionMeta; + + const isStakingTransaction = + stakingTransactionTypes.has(type as StakingTransactionTypes) || + stakingTransactionTypes.has(originalType as StakingTransactionTypes); + + const isLendingTransaction = + lendingTransactionTypes.has(type as LendingTransactionTypes) || + lendingTransactionTypes.has(originalType as LendingTransactionTypes); + + const sender = transactionMeta.txParams.from; + + if (isStakingTransaction) { + this.refreshPooledStakes({ resetCache: true, address: sender }).catch( + console.error, + ); + } + if (isLendingTransaction) { + this.refreshLendingPositions({ address: sender }).catch( + console.error, + ); + } + }, + ); + } + + #refreshEarnPortfolio(address: string): void { + this.#lastRefreshedAddress = address; + this.refreshEarnEligibility({ address }).catch(console.error); + this.refreshPooledStakingData({ address }).catch(console.error); + this.refreshLendingData().catch(console.error); + } + + async init(): Promise { + if (this.#initPromise) { + return this.#initPromise; + } + + this.#initPromise = (async (): Promise => { + await this.#initializeSDK(this.#getSelectedNetworkClientId()); + + const address = this.#getSelectedEvmAccountAddress(); + if (address) { + this.#refreshEarnPortfolio(address); + } else { + // Account tree state is not yet available, so we defer the refresh to when it is. + this.#refreshEarnPortfolioOnAccountReady(); + } + })().catch((error) => { + this.#initPromise = null; + throw error; + }); + + return this.#initPromise; + } + + /** + * Initializes the Earn SDK. + * + * @param networkClientId - The network client id to initialize the Earn SDK for. + */ + async #initializeSDK(networkClientId: string): Promise { + const networkClient = this.messenger.call( + 'NetworkController:getNetworkClientById', + networkClientId, + ); + + if (!networkClient?.provider) { + this.#earnSDK = null; + return; + } + + const provider = new Web3Provider(networkClient.provider); + const { chainId } = networkClient.configuration; + + // Initialize appropriate contracts based on chainId + const config: EarnSdkConfig = { + chainId: convertHexToDecimal(chainId), + env: this.#env, + }; + + try { + this.#earnSDK = await EarnSdk.create(provider, config); + } catch (error) { + this.#earnSDK = null; + // Only log unexpected errors, not unsupported chain errors + if ( + !( + error instanceof Error && + error.message.includes('Unsupported chainId') + ) + ) { + console.error('Earn SDK initialization failed:', error); + } + } + } + + /** + * Gets the selected network client ID from NetworkController's live state. + * + * @returns The selected network client ID. + */ + #getSelectedNetworkClientId(): string { + return this.messenger.call('NetworkController:getState') + .selectedNetworkClientId; + } + + /** + * Gets the EVM account from the selected account group. + * + * @returns The EVM account or undefined if no EVM account is found. + */ + #getSelectedEvmAccount(): InternalAccount | undefined { + return this.messenger + .call('AccountTreeController:getAccountsFromSelectedAccountGroup') + .find((account: InternalAccount) => isEvmAccountType(account.type)); + } + + /** + * Gets the EVM account address from the selected account group. + * + * @returns The EVM account address or undefined if no EVM account is found. + */ + #getSelectedEvmAccountAddress(): string | undefined { + return this.#getSelectedEvmAccount()?.address; + } + + /** + * Sets up a one-time subscription to AccountTreeController:stateChange that + * triggers address-dependent refreshes once both the selected account group + * is populated and an EVM account address is resolvable. Unsubscribes + * only after a refresh is triggered. + * + * This handles the case where EarnController.init() runs before + * AccountTreeController.init() has populated the selected account group. + */ + #refreshEarnPortfolioOnAccountReady(): void { + const handler = ({ + selectedAccountGroup, + }: { + selectedAccountGroup: string; + }): void => { + if (!selectedAccountGroup) { + return; + } + + const address = this.#getSelectedEvmAccountAddress(); + if (!address) { + return; + } + + this.messenger.unsubscribe('AccountTreeController:stateChange', handler); + this.#refreshEarnPortfolio(address); + }; + this.messenger.subscribe('AccountTreeController:stateChange', handler); + } + + /** + * Refreshes the pooled stakes data for the current account. + * Fetches updated stake information including lifetime rewards, assets, and exit requests + * from the staking API service and updates the state. + * + * @param options - Optional arguments + * @param [options.resetCache] - Control whether the BE cache should be invalidated (optional). + * @param [options.address] - The address to refresh pooled stakes for (optional). + * @param [options.chainId] - The chain id to refresh pooled stakes for (optional). + * @returns A promise that resolves when the stakes data has been updated + */ + async refreshPooledStakes({ + resetCache = false, + address, + chainId = ChainId.ETHEREUM, + }: RefreshPooledStakesOptions = {}): Promise { + const addressToUse = address ?? this.#getSelectedEvmAccountAddress(); + + if (!addressToUse) { + return; + } + + const chainIdToUse = isSupportedPooledStakingChain(chainId) + ? chainId + : ChainId.ETHEREUM; + + const { accounts, exchangeRate } = + await this.#earnApiService.pooledStaking.getPooledStakes( + [addressToUse], + chainIdToUse, + resetCache, + ); + + this.update((state) => { + const chainState = + state.pooled_staking[chainIdToUse] ?? + DEFAULT_POOLED_STAKING_CHAIN_STATE; + state.pooled_staking[chainIdToUse] = { + ...chainState, + pooledStakes: accounts[0], + exchangeRate, + }; + }); + } + + /** + * Refreshes the earn eligibility status for the current account. + * Updates the eligibility status in the controller state based on the location and address blocklist for compliance. + * + * Note: Pooled-staking and Lending used the same result since there isn't a need to split these up right now. + * + * @param options - Optional arguments + * @param [options.address] - Address to refresh earn eligibility for (optional). + * @returns A promise that resolves when the eligibility status has been updated + */ + async refreshEarnEligibility({ + address, + }: RefreshEarnEligibilityOptions = {}): Promise { + const addressToCheck = address ?? this.#getSelectedEvmAccountAddress(); + + if (!addressToCheck) { + return; + } + + const { eligible: isEligible } = + await this.#earnApiService.pooledStaking.getPooledStakingEligibility([ + addressToCheck, + ]); + + this.update((state) => { + state.pooled_staking.isEligible = isEligible; + state.lending.isEligible = isEligible; + }); + } + + /** + * Refreshes pooled staking vault metadata for the current chain. + * Updates the vault metadata in the controller state including APY, capacity, + * fee percentage, total assets, and vault address. + * + * @param [chainId] - The chain id to refresh pooled staking vault metadata for (optional). + * @returns A promise that resolves when the vault metadata has been updated + */ + async refreshPooledStakingVaultMetadata( + chainId: number = ChainId.ETHEREUM, + ): Promise { + const chainIdToUse = isSupportedPooledStakingChain(chainId) + ? chainId + : ChainId.ETHEREUM; + + const vaultMetadata = + await this.#earnApiService.pooledStaking.getVaultData(chainIdToUse); + + this.update((state) => { + const chainState = + state.pooled_staking[chainIdToUse] ?? + DEFAULT_POOLED_STAKING_CHAIN_STATE; + state.pooled_staking[chainIdToUse] = { + ...chainState, + vaultMetadata, + }; + }); + } + + /** + * Refreshes pooled staking vault daily apys for the current chain. + * Updates the pooled staking vault daily apys controller state. + * + * @param [options] - The options for refreshing pooled staking vault daily apys. + * @param [options.chainId] - The chain id to refresh pooled staking vault daily apys for (defaults to Ethereum). + * @param [options.days] - The number of days to fetch pooled staking vault daily apys for (defaults to 365). + * @param [options.order] - The order in which to fetch pooled staking vault daily apys. Descending order fetches the latest N days (latest working backwards). Ascending order fetches the oldest N days (oldest working forwards) (defaults to 'desc'). + * @returns A promise that resolves when the pooled staking vault daily apys have been updated. + */ + async refreshPooledStakingVaultDailyApys({ + chainId = ChainId.ETHEREUM, + days = 365, + order = 'desc', + }: RefreshPooledStakingVaultDailyApysOptions = {}): Promise { + const chainIdToUse = isSupportedPooledStakingChain(chainId) + ? chainId + : ChainId.ETHEREUM; + + const vaultDailyApys = + await this.#earnApiService.pooledStaking.getVaultDailyApys( + chainIdToUse, + days, + order, + ); + + this.update((state) => { + const chainState = + state.pooled_staking[chainIdToUse] ?? + DEFAULT_POOLED_STAKING_CHAIN_STATE; + state.pooled_staking[chainIdToUse] = { + ...chainState, + vaultDailyApys, + }; + }); + } + + /** + * Refreshes pooled staking vault apy averages for the current chain. + * Updates the pooled staking vault apy averages controller state. + * + * @param [chainId] - The chain id to refresh pooled staking vault apy averages for (optional). + * @returns A promise that resolves when the pooled staking vault apy averages have been updated. + */ + async refreshPooledStakingVaultApyAverages( + chainId: number = ChainId.ETHEREUM, + ): Promise { + const chainIdToUse = isSupportedPooledStakingChain(chainId) + ? chainId + : ChainId.ETHEREUM; + + const vaultApyAverages = + await this.#earnApiService.pooledStaking.getVaultApyAverages( + chainIdToUse, + ); + + this.update((state) => { + const chainState = + state.pooled_staking[chainIdToUse] ?? + DEFAULT_POOLED_STAKING_CHAIN_STATE; + state.pooled_staking[chainIdToUse] = { + ...chainState, + vaultApyAverages, + }; + }); + } + + /** + * Refreshes all pooled staking related data including stakes, eligibility, and vault data. + * This method allows partial success, meaning some data may update while other requests fail. + * All errors are collected and thrown as a single error message. + * + * @param options - Optional arguments + * @param [options.resetCache] - Control whether the BE cache should be invalidated (optional). + * @param [options.address] - The address to refresh pooled stakes for (optional). + * @returns A promise that resolves when all possible data has been updated + * @throws {Error} If any of the refresh operations fail, with concatenated error messages + */ + async refreshPooledStakingData({ + resetCache, + address, + }: RefreshPooledStakingDataOptions = {}): Promise { + const errors: Error[] = []; + + for (const chainId of this.#supportedPooledStakingChains) { + await Promise.all([ + this.refreshPooledStakes({ resetCache, address, chainId }).catch( + (error) => { + errors.push(error); + }, + ), + this.refreshPooledStakingVaultMetadata(chainId).catch((error) => { + errors.push(error); + }), + this.refreshPooledStakingVaultDailyApys({ chainId }).catch((error) => { + errors.push(error); + }), + this.refreshPooledStakingVaultApyAverages(chainId).catch((error) => { + errors.push(error); + }), + ]); + } + + if (errors.length > 0) { + throw new Error( + `Failed to refresh some staking data: ${errors + .map((error) => error.message) + .join(', ')}`, + ); + } + } + + /** + * Refreshes the lending markets data for all chains. + * Updates the lending markets in the controller state. + * + * @returns A promise that resolves when the lending markets have been updated + */ + async refreshLendingMarkets(): Promise { + const markets = await this.#earnApiService.lending.getMarkets(); + + this.update((state) => { + state.lending.markets = markets; + }); + } + + /** + * Refreshes the lending positions for the current account. + * Updates the lending positions in the controller state. + * + * @param options - Optional arguments + * @param [options.address] - The address to refresh lending positions for (optional). + * @returns A promise that resolves when the lending positions have been updated + */ + async refreshLendingPositions({ + address, + }: RefreshLendingPositionsOptions = {}): Promise { + const addressToUse = address ?? this.#getSelectedEvmAccountAddress(); + + if (!addressToUse) { + return; + } + + // linter complaining about this not being a promise, but it is + // TODO: figure out why this is not seen as a promise + const positions = await Promise.resolve( + this.#earnApiService.lending.getPositions(addressToUse), + ); + + this.update((state) => { + state.lending.positions = positions.map((position) => ({ + ...position, + marketId: position.market.id, + marketAddress: position.market.address, + protocol: position.market.protocol, + })); + }); + } + + /** + * Refreshes all lending related data including markets, positions, and eligibility. + * This method allows partial success, meaning some data may update while other requests fail. + * All errors are collected and thrown as a single error message. + * + * @returns A promise that resolves when all possible data has been updated + * @throws {Error} If any of the refresh operations fail, with concatenated error messages + */ + async refreshLendingData(): Promise { + const errors: Error[] = []; + + await Promise.all([ + this.refreshLendingMarkets().catch((error) => { + errors.push(error); + }), + this.refreshLendingPositions().catch((error) => { + errors.push(error); + }), + ]); + + if (errors.length > 0) { + throw new Error( + `Failed to refresh some lending data: ${errors + .map((error) => error.message) + .join(', ')}`, + ); + } + } + + /** + * Refreshes the APY for TRON staking. + * The consumer provides a fetcher function that returns the APY for TRON. + * + * @param apyFetcher - An async function that fetches and returns the APY as a decimal string. + * @returns A promise that resolves when the APY has been updated. + */ + async refreshTronStakingApy( + apyFetcher: () => Promise, + ): Promise { + const apy = await apyFetcher(); + + this.update((state) => { + state.tron_staking = { + apy, + lastUpdated: Date.now(), + }; + }); + } + + /** + * Gets the TRON staking APY. + * + * @returns The APY for TRON staking, or undefined if not available. + */ + getTronStakingApy(): string | undefined { + return this.state.tron_staking?.apy; + } + + /** + * Gets the lending position history for the current account. + * + * @param options - Optional arguments + * @param [options.address] - The address to get lending position history for (optional). + * @param options.chainId - The chain id to get lending position history for. + * @param [options.positionId] - The position id to get lending position history for. + * @param [options.marketId] - The market id to get lending position history for. + * @param [options.marketAddress] - The market address to get lending position history for. + * @param [options.protocol] - The protocol to get lending position history for. + * @param [options.days] - The number of days to get lending position history for (optional). + * @returns A promise that resolves when the lending position history has been updated + */ + getLendingPositionHistory({ + address, + chainId, + positionId, + marketId, + marketAddress, + protocol, + days = 730, + }: { + address?: string; + chainId: number; + positionId: string; + marketId: string; + marketAddress: string; + protocol: string; + days?: number; + }) { + const addressToUse = address ?? this.#getSelectedEvmAccountAddress(); + + if (!addressToUse || !isSupportedLendingChain(chainId)) { + return []; + } + + return this.#earnApiService.lending.getPositionHistory( + addressToUse, + chainId, + protocol, + marketId, + marketAddress, + positionId, + days, + ); + } + + /** + * Gets the lending market daily apys and averages for the current chain. + * + * @param options - Optional arguments + * @param options.chainId - The chain id to get lending market daily apys and averages for. + * @param [options.protocol] - The protocol to get lending market daily apys and averages for. + * @param [options.marketId] - The market id to get lending market daily apys and averages for. + * @param [options.days] - The number of days to get lending market daily apys and averages for (optional). + * @returns A promise that resolves when the lending market daily apys and averages have been updated + */ + getLendingMarketDailyApysAndAverages({ + chainId, + protocol, + marketId, + days = 365, + }: { + chainId: number; + protocol: string; + marketId: string; + days?: number; + }): Promise | undefined { + if (!isSupportedLendingChain(chainId)) { + return undefined; + } + + return this.#earnApiService.lending.getHistoricMarketApys( + chainId, + protocol, + marketId, + days, + ); + } + + /** + * Executes a lending deposit transaction. + * + * @param options - The options for the lending deposit transaction. + * @param options.amount - The amount to deposit. + * @param options.chainId - The chain ID for the lending deposit transaction. + * @param options.protocol - The protocol of the lending market. + * @param options.underlyingTokenAddress - The address of the underlying token. + * @param options.gasOptions - The gas options for the transaction. + * @param options.gasOptions.gasLimit - The gas limit for the transaction. + * @param options.gasOptions.gasBufferPct - The gas buffer percentage for the transaction. + * @param options.txOptions - The transaction options for the transaction. + * @returns A promise that resolves to the transaction hash. + */ + async executeLendingDeposit({ + amount, + chainId, + protocol, + underlyingTokenAddress, + gasOptions, + txOptions, + }: { + amount: string; + chainId: string; + protocol: LendingMarket['protocol']; + underlyingTokenAddress: string; + gasOptions: { + gasLimit?: GasLimitParams; + gasBufferPct?: number; + }; + txOptions: Parameters< + typeof TransactionController.prototype.addTransaction + >[1]; + }) { + const address = this.#getSelectedEvmAccountAddress(); + + if (!address) { + throw new Error('No EVM-compatible account address found'); + } + + const transactionData = await this.#earnSDK?.contracts?.lending?.[ + protocol + ]?.[underlyingTokenAddress]?.encodeDepositTransactionData( + amount, + address, + gasOptions, + ); + + if (!transactionData) { + throw new Error('Transaction data not found'); + } + + const selectedNetworkClientId = this.#getSelectedNetworkClientId(); + if (!selectedNetworkClientId) { + throw new Error('Selected network client id not found'); + } + + const gasLimit = !transactionData.gasLimit + ? undefined + : toHex(transactionData.gasLimit); + + const txHash = await this.#addTransactionFn( + { + ...transactionData, + value: transactionData.value.toString(), + chainId: toHex(chainId), + gasLimit, + }, + { + ...txOptions, + networkClientId: selectedNetworkClientId, + isInternal: true, + }, + ); + + return txHash; + } + + /** + * Executes a lending withdraw transaction. + * + * @param options - The options for the lending withdraw transaction. + * @param options.amount - The amount to withdraw. + * @param options.chainId - The chain ID for the lending withdraw transaction. + * @param options.protocol - The protocol of the lending market. + * @param options.underlyingTokenAddress - The address of the underlying token. + * @param options.gasOptions - The gas options for the transaction. + * @param options.gasOptions.gasLimit - The gas limit for the transaction. + * @param options.gasOptions.gasBufferPct - The gas buffer percentage for the transaction. + * @param options.txOptions - The transaction options for the transaction. + * @returns A promise that resolves to the transaction hash. + */ + async executeLendingWithdraw({ + amount, + chainId, + protocol, + underlyingTokenAddress, + gasOptions, + txOptions, + }: { + amount: string; + chainId: string; + protocol: LendingMarket['protocol']; + underlyingTokenAddress: string; + gasOptions: { + gasLimit?: GasLimitParams; + gasBufferPct?: number; + }; + txOptions: Parameters< + typeof TransactionController.prototype.addTransaction + >[1]; + }) { + const address = this.#getSelectedEvmAccountAddress(); + + if (!address) { + throw new Error('No EVM-compatible account address found'); + } + + const transactionData = await this.#earnSDK?.contracts?.lending?.[ + protocol + ]?.[underlyingTokenAddress]?.encodeWithdrawTransactionData( + amount, + address, + gasOptions, + ); + + if (!transactionData) { + throw new Error('Transaction data not found'); + } + + const selectedNetworkClientId = this.#getSelectedNetworkClientId(); + if (!selectedNetworkClientId) { + throw new Error('Selected network client id not found'); + } + + const gasLimit = !transactionData.gasLimit + ? undefined + : toHex(transactionData.gasLimit); + + const txHash = await this.#addTransactionFn( + { + ...transactionData, + value: transactionData.value.toString(), + chainId: toHex(chainId), + gasLimit, + }, + { + ...txOptions, + networkClientId: selectedNetworkClientId, + isInternal: true, + }, + ); + + return txHash; + } + + /** + * Executes a lending token approve transaction. + * + * @param options - The options for the lending token approve transaction. + * @param options.amount - The amount to approve. + * @param options.chainId - The chain ID for the lending token approve transaction. + * @param options.protocol - The protocol of the lending market. + * @param options.underlyingTokenAddress - The address of the underlying token. + * @param options.gasOptions - The gas options for the transaction. + * @param options.gasOptions.gasLimit - The gas limit for the transaction. + * @param options.gasOptions.gasBufferPct - The gas buffer percentage for the transaction. + * @param options.txOptions - The transaction options for the transaction. + * @returns A promise that resolves to the transaction hash. + */ + async executeLendingTokenApprove({ + protocol, + amount, + chainId, + underlyingTokenAddress, + gasOptions, + txOptions, + }: { + protocol: LendingMarket['protocol']; + amount: string; + chainId: string; + underlyingTokenAddress: string; + gasOptions: { + gasLimit?: GasLimitParams; + gasBufferPct?: number; + }; + txOptions: Parameters< + typeof TransactionController.prototype.addTransaction + >[1]; + }) { + const address = this.#getSelectedEvmAccountAddress(); + + if (!address) { + throw new Error('No EVM-compatible account address found'); + } + + const transactionData = await this.#earnSDK?.contracts?.lending?.[ + protocol + ]?.[underlyingTokenAddress]?.encodeUnderlyingTokenApproveTransactionData( + amount, + address, + gasOptions, + ); + + if (!transactionData) { + throw new Error('Transaction data not found'); + } + + const selectedNetworkClientId = this.#getSelectedNetworkClientId(); + if (!selectedNetworkClientId) { + throw new Error('Selected network client id not found'); + } + + const gasLimit = !transactionData.gasLimit + ? undefined + : toHex(transactionData.gasLimit); + + const txHash = await this.#addTransactionFn( + { + ...transactionData, + value: transactionData.value.toString(), + chainId: toHex(chainId), + gasLimit, + }, + { + ...txOptions, + networkClientId: selectedNetworkClientId, + isInternal: true, + }, + ); + + return txHash; + } + + /** + * Gets the allowance for a lending token. + * + * @param protocol - The protocol of the lending market. + * @param underlyingTokenAddress - The address of the underlying token. + * @returns A promise that resolves to the allowance. + */ + async getLendingTokenAllowance( + protocol: LendingMarket['protocol'], + underlyingTokenAddress: string, + ): Promise { + const address = this.#getSelectedEvmAccountAddress(); + + if (!address) { + return undefined; + } + + const allowance = + await this.#earnSDK?.contracts?.lending?.[protocol]?.[ + underlyingTokenAddress + ]?.underlyingTokenAllowance(address); + + return allowance; + } + + /** + * Gets the maximum withdraw amount for a lending token's output token or shares if no output token. + * + * @param protocol - The protocol of the lending market. + * @param underlyingTokenAddress - The address of the underlying token. + * @returns A promise that resolves to the maximum withdraw amount. + */ + async getLendingTokenMaxWithdraw( + protocol: LendingMarket['protocol'], + underlyingTokenAddress: string, + ): Promise { + const address = this.#getSelectedEvmAccountAddress(); + + if (!address) { + return undefined; + } + + const maxWithdraw = + await this.#earnSDK?.contracts?.lending?.[protocol]?.[ + underlyingTokenAddress + ]?.maxWithdraw(address); + + return maxWithdraw; + } + + /** + * Gets the maximum deposit amount for a lending token. + * + * @param protocol - The protocol of the lending market. + * @param underlyingTokenAddress - The address of the underlying token. + * @returns A promise that resolves to the maximum deposit amount. + */ + async getLendingTokenMaxDeposit( + protocol: LendingMarket['protocol'], + underlyingTokenAddress: string, + ): Promise { + const address = this.#getSelectedEvmAccountAddress(); + + if (!address) { + return undefined; + } + + const maxDeposit = + await this.#earnSDK?.contracts?.lending?.[protocol]?.[ + underlyingTokenAddress + ]?.maxDeposit(address); + + return maxDeposit; + } +} diff --git a/packages/earn-controller/src/index.ts b/packages/earn-controller/src/index.ts new file mode 100644 index 00000000000..ee1e978106e --- /dev/null +++ b/packages/earn-controller/src/index.ts @@ -0,0 +1,71 @@ +export type { + PooledStakingState, + LendingState, + TronStakingState, + LendingMarketWithPosition, + LendingPositionWithMarket, + LendingPositionWithMarketReference, + EarnControllerState, + EarnControllerGetStateAction, + EarnControllerStateChangeEvent, + EarnControllerActions, + EarnControllerEvents, + EarnControllerMessenger, +} from './EarnController.js'; + +export { + controllerName, + getDefaultEarnControllerState, + DEFAULT_TRON_STAKING_STATE, + EarnController, +} from './EarnController.js'; + +export type { + EarnControllerRefreshPooledStakesAction, + EarnControllerRefreshEarnEligibilityAction, + EarnControllerRefreshPooledStakingVaultMetadataAction, + EarnControllerRefreshPooledStakingVaultDailyApysAction, + EarnControllerRefreshPooledStakingVaultApyAveragesAction, + EarnControllerRefreshPooledStakingDataAction, + EarnControllerRefreshLendingMarketsAction, + EarnControllerRefreshLendingPositionsAction, + EarnControllerRefreshLendingDataAction, + EarnControllerRefreshTronStakingApyAction, + EarnControllerGetTronStakingApyAction, + EarnControllerGetLendingPositionHistoryAction, + EarnControllerGetLendingMarketDailyApysAndAveragesAction, + EarnControllerExecuteLendingDepositAction, + EarnControllerExecuteLendingWithdrawAction, + EarnControllerExecuteLendingTokenApproveAction, + EarnControllerGetLendingTokenAllowanceAction, + EarnControllerGetLendingTokenMaxWithdrawAction, + EarnControllerGetLendingTokenMaxDepositAction, +} from './EarnController-method-action-types.js'; + +export { + selectLendingMarkets, + selectLendingPositions, + selectLendingMarketsWithPosition, + selectLendingPositionsByProtocol, + selectLendingMarketByProtocolAndTokenAddress, + selectLendingMarketForProtocolAndTokenAddress, + selectLendingPositionsByChainId, + selectLendingMarketsByChainId, + selectLendingMarketsByProtocolAndId, + selectLendingMarketForProtocolAndId, + selectLendingPositionsWithMarket, + selectLendingMarketsForChainId, + selectIsLendingEligible, + selectLendingPositionsByProtocolChainIdMarketId, + selectLendingMarketsByTokenAddress, + selectLendingMarketsByChainIdAndOutputTokenAddress, + selectLendingMarketsByChainIdAndTokenAddress, + selectTronStaking, + selectTronStakingApy, +} from './selectors.js'; + +export { + CHAIN_ID_TO_AAVE_POOL_CONTRACT, + isSupportedLendingChain, + isSupportedPooledStakingChain, +} from '@metamask/stake-sdk'; diff --git a/packages/earn-controller/src/selectors.test.ts b/packages/earn-controller/src/selectors.test.ts new file mode 100644 index 00000000000..b0d08de1de7 --- /dev/null +++ b/packages/earn-controller/src/selectors.test.ts @@ -0,0 +1,459 @@ +import type { LendingMarket } from '@metamask/stake-sdk'; + +import type { + EarnControllerState, + LendingPositionWithMarket, +} from './EarnController.js'; +import { + selectLendingMarkets, + selectLendingPositions, + selectLendingMarketsByProtocolAndId, + selectLendingMarketForProtocolAndId, + selectLendingMarketsForChainId, + selectLendingMarketsByChainId, + selectLendingPositionsWithMarket, + selectLendingPositionsByChainId, + selectLendingMarketsWithPosition, + selectLendingPositionsByProtocol, + selectLendingMarketByProtocolAndTokenAddress, + selectLendingMarketForProtocolAndTokenAddress, + selectLendingPositionsByProtocolChainIdMarketId, + selectLendingMarketsByTokenAddress, + selectLendingMarketsByChainIdAndOutputTokenAddress, + selectLendingMarketsByChainIdAndTokenAddress, + selectIsLendingEligible, + selectTronStaking, + selectTronStakingApy, +} from './selectors.js'; + +describe('Earn Controller Selectors', () => { + const mockMarket1: LendingMarket = { + id: 'market1', + protocol: 'aave-v3' as LendingMarket['protocol'], + chainId: 1, + name: 'Market 1', + address: '0x123', + tvlUnderlying: '1000', + netSupplyRate: 5, + totalSupplyRate: 5, + underlying: { + address: '0x123', + chainId: 1, + }, + outputToken: { + address: '0x456', + chainId: 1, + }, + rewards: [ + { + token: { + address: '0x789', + chainId: 1, + }, + rate: 0, + }, + ], + }; + + const mockMarket2: LendingMarket = { + id: 'market2', + protocol: 'compound-v3' as LendingMarket['protocol'], + chainId: 2, + name: 'Market 2', + address: '0x456', + tvlUnderlying: '2000', + netSupplyRate: 6, + totalSupplyRate: 6, + underlying: { + address: '0x456', + chainId: 2, + }, + outputToken: { + address: '0xabc', + chainId: 2, + }, + rewards: [ + { + token: { + address: '0xdef', + chainId: 2, + }, + rate: 0, + }, + ], + }; + + const mockPosition1: LendingPositionWithMarket = { + id: 'position1', + chainId: 1, + assets: '100', + marketId: 'market1', + marketAddress: '0x123', + protocol: 'aave-v3' as LendingMarket['protocol'], + market: mockMarket1, + }; + + const mockPosition2: LendingPositionWithMarket = { + id: 'position2', + chainId: 2, + assets: '200', + marketId: 'market2', + marketAddress: '0x456', + protocol: 'compound-v3' as LendingMarket['protocol'], + market: mockMarket2, + }; + + const mockState: EarnControllerState = { + lending: { + markets: [mockMarket1, mockMarket2], + positions: [mockPosition1, mockPosition2], + isEligible: true, + }, + pooled_staking: { + '0': { + pooledStakes: { + account: '', + lifetimeRewards: '0', + assets: '0', + exitRequests: [], + }, + exchangeRate: '1', + vaultMetadata: { + apy: '0', + capacity: '0', + feePercent: 0, + totalAssets: '0', + vaultAddress: '0x0000000000000000000000000000000000000000', + }, + vaultDailyApys: [], + vaultApyAverages: { + oneDay: '0', + oneWeek: '0', + oneMonth: '0', + threeMonths: '0', + sixMonths: '0', + oneYear: '0', + }, + }, + isEligible: false, + }, + tron_staking: { + apy: '3.35', + lastUpdated: 1718000000000, + }, + lastUpdated: 0, + }; + + describe('selectLendingMarkets', () => { + it('should return all lending markets', () => { + const result = selectLendingMarkets(mockState); + expect(result).toStrictEqual([mockMarket1, mockMarket2]); + }); + }); + + describe('selectLendingPositions', () => { + it('should return all lending positions', () => { + const result = selectLendingPositions(mockState); + expect(result).toStrictEqual([mockPosition1, mockPosition2]); + }); + }); + + describe('selectLendingMarketsByProtocolAndId', () => { + it('should group markets by protocol and id', () => { + const result = selectLendingMarketsByProtocolAndId(mockState); + expect(result).toStrictEqual({ + 'aave-v3': { + market1: mockMarket1, + }, + 'compound-v3': { + market2: mockMarket2, + }, + }); + }); + }); + + describe('selectLendingMarketForProtocolAndId', () => { + it('should return market for given protocol and id', () => { + const result = selectLendingMarketForProtocolAndId( + 'aave-v3', + 'market1', + )(mockState); + expect(result).toStrictEqual(mockMarket1); + const result2 = selectLendingMarketForProtocolAndId( + 'compound-v3', + 'market2', + )(mockState); + expect(result2).toStrictEqual(mockMarket2); + const result3 = selectLendingMarketForProtocolAndId( + 'invalid', + 'invalid', + )(mockState); + expect(result3).toBeUndefined(); + }); + }); + + describe('selectLendingMarketsForChainId', () => { + it('should return markets for given chain id', () => { + const result = selectLendingMarketsForChainId(1)(mockState); + expect(result).toStrictEqual([mockMarket1]); + const result2 = selectLendingMarketsForChainId(2)(mockState); + expect(result2).toStrictEqual([mockMarket2]); + const result3 = selectLendingMarketsForChainId(999)(mockState); + expect(result3).toStrictEqual([]); + }); + }); + + describe('selectLendingMarketsByChainId', () => { + it('should group markets by chain id', () => { + const result = selectLendingMarketsByChainId(mockState); + expect(result).toStrictEqual({ + 1: [mockMarket1], + 2: [mockMarket2], + }); + }); + }); + + describe('selectLendingPositionsWithMarket', () => { + it('should return positions with their associated markets', () => { + const result = selectLendingPositionsWithMarket(mockState); + expect(result).toStrictEqual([mockPosition1, mockPosition2]); + }); + }); + + describe('selectLendingPositionsByChainId', () => { + it('should group positions by chain id', () => { + const result = selectLendingPositionsByChainId(mockState); + expect(result).toStrictEqual({ + 1: [mockPosition1], + 2: [mockPosition2], + }); + }); + }); + + describe('selectLendingMarketsWithPosition', () => { + it('should return markets with their associated positions', () => { + const result = selectLendingMarketsWithPosition(mockState); + expect(result).toHaveLength(2); + expect(result[0]).toStrictEqual({ + ...mockMarket1, + position: mockPosition1, + }); + }); + }); + + describe('selectLendingPositionsByProtocol', () => { + it('should group positions by protocol', () => { + const result = selectLendingPositionsByProtocol(mockState); + expect(result).toStrictEqual({ + 'aave-v3': [mockPosition1], + 'compound-v3': [mockPosition2], + }); + }); + }); + + describe('selectLendingMarketByProtocolAndTokenAddress', () => { + it('should group markets by protocol and token address', () => { + const result = selectLendingMarketByProtocolAndTokenAddress(mockState); + expect(result).toStrictEqual({ + 'aave-v3': { + '0x123': { + ...mockMarket1, + position: mockPosition1, + }, + }, + 'compound-v3': { + '0x456': { + ...mockMarket2, + position: mockPosition2, + }, + }, + }); + }); + }); + + describe('selectLendingMarketForProtocolAndTokenAddress', () => { + it('should return market for given protocol and token address', () => { + const result = selectLendingMarketForProtocolAndTokenAddress( + 'aave-v3', + '0x123', + )(mockState); + expect(result).toStrictEqual({ + ...mockMarket1, + position: mockPosition1, + }); + const result2 = selectLendingMarketForProtocolAndTokenAddress( + 'invalid', + 'invalid', + )(mockState); + expect(result2).toBeUndefined(); + }); + }); + + describe('selectLendingPositionsByProtocolChainIdMarketId', () => { + it('should group positions by protocol, chainId, and marketId', () => { + const result = selectLendingPositionsByProtocolChainIdMarketId(mockState); + expect(result).toStrictEqual({ + 'aave-v3': { + 1: { + market1: mockPosition1, + }, + }, + 'compound-v3': { + 2: { + market2: mockPosition2, + }, + }, + }); + }); + }); + + describe('selectLendingMarketsByTokenAddress', () => { + it('should group markets by token address', () => { + const result = selectLendingMarketsByTokenAddress(mockState); + expect(result).toStrictEqual({ + '0x123': [ + { + ...mockMarket1, + position: mockPosition1, + }, + ], + '0x456': [ + { + ...mockMarket2, + position: mockPosition2, + }, + ], + }); + }); + + it('should handle markets without positions', () => { + const stateWithoutPositions = { + ...mockState, + lending: { + ...mockState.lending, + positions: [], + }, + }; + const result = selectLendingMarketsByTokenAddress(stateWithoutPositions); + expect(result).toStrictEqual({ + '0x123': [ + { + ...mockMarket1, + position: null, + }, + ], + '0x456': [ + { + ...mockMarket2, + position: null, + }, + ], + }); + }); + }); + + describe('selectLendingMarketsByChainIdAndOutputTokenAddress', () => { + it('should group markets by chainId and output token address', () => { + const result = + selectLendingMarketsByChainIdAndOutputTokenAddress(mockState); + expect(result).toStrictEqual({ + 1: { + '0x456': [ + { + ...mockMarket1, + position: mockPosition1, + }, + ], + }, + 2: { + '0xabc': [ + { + ...mockMarket2, + position: mockPosition2, + }, + ], + }, + }); + }); + }); + + describe('selectLendingMarketsByChainIdAndTokenAddress', () => { + it('should group markets by chainId and token address', () => { + const result = selectLendingMarketsByChainIdAndTokenAddress(mockState); + expect(result).toStrictEqual({ + 1: { + '0x123': [ + { + ...mockMarket1, + position: mockPosition1, + }, + ], + }, + 2: { + '0x456': [ + { + ...mockMarket2, + position: mockPosition2, + }, + ], + }, + }); + }); + }); + + describe('selectIsLendingEligible', () => { + it('should return the lending eligibility status', () => { + const result = selectIsLendingEligible(mockState); + expect(result).toBe(true); + }); + + it('should return false when lending is not eligible', () => { + const stateWithIneligibleLending = { + ...mockState, + lending: { + ...mockState.lending, + isEligible: false, + }, + }; + const result = selectIsLendingEligible(stateWithIneligibleLending); + expect(result).toBe(false); + }); + }); + + describe('TRON Staking Selectors', () => { + describe('selectTronStaking', () => { + it('should return the TRON staking state', () => { + const result = selectTronStaking(mockState); + expect(result).toStrictEqual({ + apy: '3.35', + lastUpdated: 1718000000000, + }); + }); + + it('should return null when no TRON staking data exists', () => { + const stateWithoutTronStaking = { + ...mockState, + tron_staking: null, + }; + const result = selectTronStaking(stateWithoutTronStaking); + expect(result).toBeNull(); + }); + }); + + describe('selectTronStakingApy', () => { + it('should return the TRON staking APY', () => { + const result = selectTronStakingApy(mockState); + expect(result).toBe('3.35'); + }); + + it('should return undefined when TRON staking is null', () => { + const stateWithoutTronStaking = { + ...mockState, + tron_staking: null, + }; + const result = selectTronStakingApy(stateWithoutTronStaking); + expect(result).toBeUndefined(); + }); + }); + }); +}); diff --git a/packages/earn-controller/src/selectors.ts b/packages/earn-controller/src/selectors.ts new file mode 100644 index 00000000000..21c400fce14 --- /dev/null +++ b/packages/earn-controller/src/selectors.ts @@ -0,0 +1,224 @@ +import type { LendingMarket } from '@metamask/stake-sdk'; +import { createSelector } from 'reselect'; + +import type { + EarnControllerState, + LendingMarketWithPosition, + LendingPositionWithMarket, + LendingPositionWithMarketReference, +} from './EarnController.js'; + +export const selectLendingMarkets = ( + state: EarnControllerState, +): LendingMarket[] => state.lending.markets; + +export const selectLendingPositions = ( + state: EarnControllerState, +): LendingPositionWithMarketReference[] => state.lending.positions; + +export const selectLendingMarketsForChainId = (chainId: number) => + createSelector(selectLendingMarkets, (markets): LendingMarket[] => + markets.filter((market) => market.chainId === chainId), + ); + +export const selectLendingMarketsByProtocolAndId = createSelector( + selectLendingMarkets, + (markets) => { + return markets.reduce>>( + (acc, market) => { + acc[market.protocol] = acc[market.protocol] || {}; + acc[market.protocol][market.id] = market; + return acc; + }, + {}, + ); + }, +); + +export const selectLendingMarketForProtocolAndId = ( + protocol: string, + id: string, +) => + createSelector( + selectLendingMarketsByProtocolAndId, + (marketsByProtocolAndId): LendingMarket | undefined => + marketsByProtocolAndId?.[protocol]?.[id], + ); + +export const selectLendingMarketsByChainId = createSelector( + selectLendingMarkets, + (markets) => { + return markets.reduce>((acc, market) => { + acc[market.chainId] = acc[market.chainId] || []; + acc[market.chainId].push(market); + return acc; + }, {}); + }, +); + +export const selectLendingPositionsWithMarket = createSelector( + selectLendingPositions, + selectLendingMarketsByProtocolAndId, + (positions, marketsByProtocolAndId): LendingPositionWithMarket[] => { + return positions.map((position) => { + return { + ...position, + market: + marketsByProtocolAndId?.[position.protocol]?.[position.marketId], + }; + }); + }, +); + +export const selectLendingPositionsByChainId = createSelector( + selectLendingPositionsWithMarket, + (positionsWithMarket) => { + return positionsWithMarket.reduce< + Record + >((acc, position) => { + const chainId = position.market?.chainId; + if (chainId) { + acc[chainId] = acc[chainId] || []; + acc[chainId].push(position); + } + return acc; + }, {}); + }, +); + +export const selectLendingPositionsByProtocolChainIdMarketId = createSelector( + selectLendingPositionsWithMarket, + (positionsWithMarket) => + positionsWithMarket.reduce< + Record>> + >((acc, position) => { + acc[position.protocol] ??= {}; + acc[position.protocol][position.chainId] ??= {}; + acc[position.protocol][position.chainId][position.marketId] = position; + return acc; + }, {}), +); + +export const selectLendingMarketsWithPosition = createSelector( + selectLendingPositionsByProtocolChainIdMarketId, + selectLendingMarkets, + (positionsByProtocolChainIdMarketId, lendingMarkets) => + lendingMarkets.map((market) => { + const position = + positionsByProtocolChainIdMarketId?.[market.protocol]?.[ + market.chainId + ]?.[market.id]; + return { + ...market, + position: position || null, + }; + }), +); + +export const selectLendingMarketsByTokenAddress = createSelector( + selectLendingMarketsWithPosition, + (marketsWithPosition) => { + return marketsWithPosition.reduce< + Record + >((acc, market) => { + if (market.underlying?.address) { + acc[market.underlying.address] = acc[market.underlying.address] || []; + acc[market.underlying.address].push(market); + } + return acc; + }, {}); + }, +); + +export const selectLendingPositionsByProtocol = createSelector( + selectLendingPositionsWithMarket, + (positionsWithMarket) => { + return positionsWithMarket.reduce< + Record + >((acc, position) => { + acc[position.protocol] = acc[position.protocol] || []; + acc[position.protocol].push(position); + return acc; + }, {}); + }, +); + +export const selectLendingMarketByProtocolAndTokenAddress = createSelector( + selectLendingMarketsWithPosition, + (marketsWithPosition) => { + return marketsWithPosition.reduce< + Record> + >((acc, market) => { + if (market.underlying?.address) { + acc[market.protocol] = acc[market.protocol] || {}; + acc[market.protocol][market.underlying.address] = market; + } + return acc; + }, {}); + }, +); + +export const selectLendingMarketForProtocolAndTokenAddress = ( + protocol: string, + tokenAddress: string, +) => + createSelector( + selectLendingMarketByProtocolAndTokenAddress, + (marketsByProtocolAndTokenAddress): LendingMarketWithPosition | undefined => + marketsByProtocolAndTokenAddress?.[protocol]?.[tokenAddress], + ); + +export const selectLendingMarketsByChainIdAndOutputTokenAddress = + createSelector(selectLendingMarketsWithPosition, (marketsWithPosition) => + marketsWithPosition.reduce< + Record> + >((acc, market) => { + if (market.outputToken?.address) { + acc[market.chainId] = acc?.[market.chainId] || {}; + acc[market.chainId][market.outputToken.address] = + acc?.[market.chainId]?.[market.outputToken.address] || []; + acc[market.chainId][market.outputToken.address].push(market); + } + return acc; + }, {}), + ); + +export const selectLendingMarketsByChainIdAndTokenAddress = createSelector( + selectLendingMarketsWithPosition, + (marketsWithPosition) => + marketsWithPosition.reduce< + Record> + >((acc, market) => { + if (market.underlying?.address) { + acc[market.chainId] = acc?.[market.chainId] || {}; + acc[market.chainId][market.underlying.address] = + acc?.[market.chainId]?.[market.underlying.address] || []; + acc[market.chainId][market.underlying.address].push(market); + } + return acc; + }, {}), +); + +export const selectIsLendingEligible = (state: EarnControllerState): boolean => + state.lending.isEligible; + +/** + * Selects the TRON staking state. + * + * @param state - The EarnController state. + * @returns The TRON staking state. + */ +export const selectTronStaking = ( + state: EarnControllerState, +): EarnControllerState['tron_staking'] => state.tron_staking; + +/** + * Selects the APY for TRON staking. + * + * @param state - The EarnController state. + * @returns The APY for TRON staking, or undefined if not available. + */ +export const selectTronStakingApy = createSelector( + selectTronStaking, + (tronStaking): string | undefined => tronStaking?.apy, +); diff --git a/packages/earn-controller/src/types.ts b/packages/earn-controller/src/types.ts new file mode 100644 index 00000000000..0b1f5cb6e21 --- /dev/null +++ b/packages/earn-controller/src/types.ts @@ -0,0 +1,24 @@ +export type RefreshEarnEligibilityOptions = { + address?: string; +}; + +export type RefreshPooledStakesOptions = { + resetCache?: boolean; + address?: string; + chainId?: number; +}; + +export type RefreshPooledStakingDataOptions = { + resetCache?: boolean; + address?: string; +}; + +export type RefreshPooledStakingVaultDailyApysOptions = { + chainId?: number; + days?: number; + order?: 'asc' | 'desc'; +}; + +export type RefreshLendingPositionsOptions = { + address?: string; +}; diff --git a/packages/earn-controller/tsconfig.build.json b/packages/earn-controller/tsconfig.build.json new file mode 100644 index 00000000000..58480d51681 --- /dev/null +++ b/packages/earn-controller/tsconfig.build.json @@ -0,0 +1,29 @@ +{ + "extends": "../../tsconfig.packages.build.json", + "compilerOptions": { + "baseUrl": "./", + "outDir": "./dist", + "rootDir": "./src" + }, + "references": [ + { + "path": "../base-controller/tsconfig.build.json" + }, + { + "path": "../network-controller/tsconfig.build.json" + }, + { + "path": "../transaction-controller/tsconfig.build.json" + }, + { + "path": "../account-tree-controller/tsconfig.build.json" + }, + { + "path": "../messenger/tsconfig.build.json" + }, + { + "path": "../controller-utils/tsconfig.build.json" + } + ], + "include": ["../../types", "./src"] +} diff --git a/packages/earn-controller/tsconfig.json b/packages/earn-controller/tsconfig.json new file mode 100644 index 00000000000..e4251e06f59 --- /dev/null +++ b/packages/earn-controller/tsconfig.json @@ -0,0 +1,27 @@ +{ + "extends": "../../tsconfig.packages.json", + "compilerOptions": { + "baseUrl": "./" + }, + "include": ["../../types", "./src"], + "references": [ + { + "path": "../base-controller" + }, + { + "path": "../network-controller" + }, + { + "path": "../transaction-controller" + }, + { + "path": "../account-tree-controller" + }, + { + "path": "../messenger" + }, + { + "path": "../controller-utils" + } + ] +} diff --git a/packages/earn-controller/typedoc.json b/packages/earn-controller/typedoc.json new file mode 100644 index 00000000000..c9da015dbf8 --- /dev/null +++ b/packages/earn-controller/typedoc.json @@ -0,0 +1,7 @@ +{ + "entryPoints": ["./src/index.ts"], + "excludePrivate": true, + "hideGenerator": true, + "out": "docs", + "tsconfig": "./tsconfig.build.json" +} diff --git a/packages/eip-5792-middleware/CHANGELOG.md b/packages/eip-5792-middleware/CHANGELOG.md new file mode 100644 index 00000000000..18e497e5a41 --- /dev/null +++ b/packages/eip-5792-middleware/CHANGELOG.md @@ -0,0 +1,146 @@ +# 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] + +### Changed + +- Bump `@metamask/transaction-controller` from `^69.0.0` to `^69.6.1` ([#9568](https://github.com/MetaMask/core/pull/9568), [#9589](https://github.com/MetaMask/core/pull/9589), [#9593](https://github.com/MetaMask/core/pull/9593), [#9693](https://github.com/MetaMask/core/pull/9693), [#9735](https://github.com/MetaMask/core/pull/9735), [#9780](https://github.com/MetaMask/core/pull/9780), [#9798](https://github.com/MetaMask/core/pull/9798), [#9823](https://github.com/MetaMask/core/pull/9823), [#9960](https://github.com/MetaMask/core/pull/9960), [#9969](https://github.com/MetaMask/core/pull/9969)) +- Bump `@metamask/superstruct` from `^3.1.0` to `^3.4.1` ([#9754](https://github.com/MetaMask/core/pull/9754)) + +## [3.0.5] + +### Changed + +- Bump `@metamask/utils` from `^11.9.0` to `^11.11.0` ([#9074](https://github.com/MetaMask/core/pull/9074)) +- Bump `@metamask/transaction-controller` from `^65.4.0` to `^69.0.0` ([#8848](https://github.com/MetaMask/core/pull/8848), [#8999](https://github.com/MetaMask/core/pull/8999), [#9021](https://github.com/MetaMask/core/pull/9021), [#9027](https://github.com/MetaMask/core/pull/9027), [#9066](https://github.com/MetaMask/core/pull/9066), [#9089](https://github.com/MetaMask/core/pull/9089), [#9177](https://github.com/MetaMask/core/pull/9177), [#9203](https://github.com/MetaMask/core/pull/9203), [#9218](https://github.com/MetaMask/core/pull/9218), [#9253](https://github.com/MetaMask/core/pull/9253), [#9337](https://github.com/MetaMask/core/pull/9337), [#9349](https://github.com/MetaMask/core/pull/9349), [#9421](https://github.com/MetaMask/core/pull/9421), [#9456](https://github.com/MetaMask/core/pull/9456), [#9470](https://github.com/MetaMask/core/pull/9470)) +- Bump `@metamask/messenger` from `^1.2.0` to `^2.0.0` ([#9392](https://github.com/MetaMask/core/pull/9392)) +- Add missing dependency `@metamask/preferences-controller` (`^23.1.0`) ([#8384](https://github.com/MetaMask/core/pull/8384)) + - This is technically not needed, but it is still referenced in the `EIP5792Messenger` messenger type + +## [3.0.4] + +### Changed + +- Bump `@metamask/keyring-controller` from `^25.3.0` to `^25.4.0` ([#8665](https://github.com/MetaMask/core/pull/8665)) +- Bump `@metamask/messenger` from `^1.0.0` to `^1.2.0` ([#8364](https://github.com/MetaMask/core/pull/8364), [#8373](https://github.com/MetaMask/core/pull/8373), [#8632](https://github.com/MetaMask/core/pull/8632)) +- Bump `@metamask/transaction-controller` from `^64.0.0` to `^65.4.0` ([#8432](https://github.com/MetaMask/core/pull/8432), [#8447](https://github.com/MetaMask/core/pull/8447), [#8482](https://github.com/MetaMask/core/pull/8482), [#8585](https://github.com/MetaMask/core/pull/8585), [#8613](https://github.com/MetaMask/core/pull/8613), [#8691](https://github.com/MetaMask/core/pull/8691), [#8722](https://github.com/MetaMask/core/pull/8722), [#8755](https://github.com/MetaMask/core/pull/8755), [#8796](https://github.com/MetaMask/core/pull/8796)) + +## [3.0.3] + +### Changed + +- Bump `@metamask/transaction-controller` from `^63.3.1` to `^64.0.0` ([#8359](https://github.com/MetaMask/core/pull/8359)) + +## [3.0.2] + +### Changed + +- Bump `@metamask/messenger` from `^0.3.0` to `^1.0.0` ([#8317](https://github.com/MetaMask/core/pull/8317)) +- Bump `@metamask/transaction-controller` from `^63.0.0` to `^63.3.1` ([#8272](https://github.com/MetaMask/core/pull/8272), [#8301](https://github.com/MetaMask/core/pull/8301), [#8313](https://github.com/MetaMask/core/pull/8313), [#8317](https://github.com/MetaMask/core/pull/8317)) + +## [3.0.1] + +### Changed + +- Bump `@metamask/transaction-controller` from `^62.19.0` to `^63.0.0` ([#8104](https://github.com/MetaMask/core/pull/8104), [#8140](https://github.com/MetaMask/core/pull/8140), [#8217](https://github.com/MetaMask/core/pull/8217), [#8225](https://github.com/MetaMask/core/pull/8225)) + +## [3.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 + +- Bump `@metamask/transaction-controller` from `^62.7.0` to `^62.19.0` ([#7596](https://github.com/MetaMask/core/pull/7596), [#7602](https://github.com/MetaMask/core/pull/7602), [#7604](https://github.com/MetaMask/core/pull/7604), [#7642](https://github.com/MetaMask/core/pull/7642), [#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), [#7996](https://github.com/MetaMask/core/pull/7996), [#8005](https://github.com/MetaMask/core/pull/8005), [#8031](https://github.com/MetaMask/core/pull/8031)) +- Bump `@metamask/utils` from `^11.8.1` to `^11.9.0` ([#7511](https://github.com/MetaMask/core/pull/7511)) +- **BREAKING:** Replace `getAccounts` hook with `getPermittedAccountsForOrigin` in `walletSendCalls`, `walletGetCapabilities`, and `ProcessSendCallsHooks` ([#7816](https://github.com/MetaMask/core/pull/7816)) + - Consumers must rename the `getAccounts` hook to `getPermittedAccountsForOrigin` and update its signature from `(req: JsonRpcRequest) => Promise` to `() => Promise`. The `req` parameter passed to `walletSendCalls` and `walletGetCapabilities` must now include an `origin` property. + +## [2.1.0] + +### Added + +- Id of JSON RPC request is passed to functions to create batched transaction, id is thus added to transaction meta ([#7415](https://github.com/MetaMask/core/pull/7415)) + +### Changed + +- Bump `@metamask/transaction-controller` from `^61.3.0` to `^62.7.0` ([#7007](https://github.com/MetaMask/core/pull/7007), [#7126](https://github.com/MetaMask/core/pull/7126), [#7153](https://github.com/MetaMask/core/pull/7153), [#7202](https://github.com/MetaMask/core/pull/7202), [#7215](https://github.com/MetaMask/core/pull/7202), [#7220](https://github.com/MetaMask/core/pull/7220), [#7236](https://github.com/MetaMask/core/pull/7236), [#7257](https://github.com/MetaMask/core/pull/7257), [#7289](https://github.com/MetaMask/core/pull/7289), [#7325](https://github.com/MetaMask/core/pull/7325), [#7430](https://github.com/MetaMask/core/pull/7430), [#7494](https://github.com/MetaMask/core/pull/7494)) + +## [2.0.0] + +### Changed + +- **BREAKING:** Update `EIP5792Messenger` type to use new `Messenger` from `@metamask/messenger` ([#6958](https://github.com/MetaMask/core/pull/6958)) + - Previously the `Messenger` type from `@metamask/base-controller` was used, and `@metamask/base-controller` was mistakenly not listed as a dependency. + - The package `@metamask/messenger` has been added as a dependency +- Bump `@metamask/transaction-controller` from `^60.10.0` to `^61.0.0` ([#6962](https://github.com/MetaMask/core/pull/6962)) + +## [1.2.4] + +### Changed + +- Bump `@metamask/transaction-controller` from `^60.7.0` to `^60.10.0` ([#6883](https://github.com/MetaMask/core/pull/6883), [#6888](https://github.com/MetaMask/core/pull/6888), [#6940](https://github.com/MetaMask/core/pull/6940)) + +## [1.2.3] + +### Changed + +- Bump `@metamask/transaction-controller` from `^60.6.1` to `^60.7.0` ([#6841](https://github.com/MetaMask/core/pull/6841)) + +## [1.2.2] + +### Changed + +- Bump `@metamask/transaction-controller` from `^60.6.0` to `^60.6.1` ([#6810](https://github.com/MetaMask/core/pull/6810)) + +## [1.2.1] + +### Changed + +- Bump `@metamask/utils` from `^11.8.0` to `^11.8.1` ([#6708](https://github.com/MetaMask/core/pull/6708)) +- Bump `@metamask/transaction-controller` from `^60.4.0` to `^60.6.0` ([#6708](https://github.com/MetaMask/core/pull/6733), [#6771](https://github.com/MetaMask/core/pull/6771)) +- Remove dependency `@metamask/eth-json-rpc-middleware` ([#6714](https://github.com/MetaMask/core/pull/6714)) + +## [1.2.0] + +### Changed + +- Add `auxiliaryFunds` + `requiredAssets` support defined under [ERC-7682](https://eips.ethereum.org/EIPS/eip-7682) ([#6623](https://github.com/MetaMask/core/pull/6623)) +- Bump `@metamask/transaction-controller` from `^60.2.0` to `^60.4.0` ([#6561](https://github.com/MetaMask/core/pull/6561), [#6641](https://github.com/MetaMask/core/pull/6641)) +- Bump `@metamask/utils` from `^11.4.2` to `^11.8.0` ([#6588](https://github.com/MetaMask/core/pull/6588)) + +## [1.1.0] + +### Added + +- Add and export EIP-5792 RPC method handler middlewares and utility types ([#6477](https://github.com/MetaMask/core/pull/6477)) + +## [1.0.0] + +### Added + +- Initial release ([#6458](https://github.com/MetaMask/core/pull/6458)) + +[Unreleased]: https://github.com/MetaMask/core/compare/@metamask/eip-5792-middleware@3.0.5...HEAD +[3.0.5]: https://github.com/MetaMask/core/compare/@metamask/eip-5792-middleware@3.0.4...@metamask/eip-5792-middleware@3.0.5 +[3.0.4]: https://github.com/MetaMask/core/compare/@metamask/eip-5792-middleware@3.0.3...@metamask/eip-5792-middleware@3.0.4 +[3.0.3]: https://github.com/MetaMask/core/compare/@metamask/eip-5792-middleware@3.0.2...@metamask/eip-5792-middleware@3.0.3 +[3.0.2]: https://github.com/MetaMask/core/compare/@metamask/eip-5792-middleware@3.0.1...@metamask/eip-5792-middleware@3.0.2 +[3.0.1]: https://github.com/MetaMask/core/compare/@metamask/eip-5792-middleware@3.0.0...@metamask/eip-5792-middleware@3.0.1 +[3.0.0]: https://github.com/MetaMask/core/compare/@metamask/eip-5792-middleware@2.1.0...@metamask/eip-5792-middleware@3.0.0 +[2.1.0]: https://github.com/MetaMask/core/compare/@metamask/eip-5792-middleware@2.0.0...@metamask/eip-5792-middleware@2.1.0 +[2.0.0]: https://github.com/MetaMask/core/compare/@metamask/eip-5792-middleware@1.2.4...@metamask/eip-5792-middleware@2.0.0 +[1.2.4]: https://github.com/MetaMask/core/compare/@metamask/eip-5792-middleware@1.2.3...@metamask/eip-5792-middleware@1.2.4 +[1.2.3]: https://github.com/MetaMask/core/compare/@metamask/eip-5792-middleware@1.2.2...@metamask/eip-5792-middleware@1.2.3 +[1.2.2]: https://github.com/MetaMask/core/compare/@metamask/eip-5792-middleware@1.2.1...@metamask/eip-5792-middleware@1.2.2 +[1.2.1]: https://github.com/MetaMask/core/compare/@metamask/eip-5792-middleware@1.2.0...@metamask/eip-5792-middleware@1.2.1 +[1.2.0]: https://github.com/MetaMask/core/compare/@metamask/eip-5792-middleware@1.1.0...@metamask/eip-5792-middleware@1.2.0 +[1.1.0]: https://github.com/MetaMask/core/compare/@metamask/eip-5792-middleware@1.0.0...@metamask/eip-5792-middleware@1.1.0 +[1.0.0]: https://github.com/MetaMask/core/releases/tag/@metamask/eip-5792-middleware@1.0.0 diff --git a/packages/eip-5792-middleware/LICENSE b/packages/eip-5792-middleware/LICENSE new file mode 100644 index 00000000000..c259cd7ebcf --- /dev/null +++ b/packages/eip-5792-middleware/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2025 MetaMask + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packages/eip-5792-middleware/README.md b/packages/eip-5792-middleware/README.md new file mode 100644 index 00000000000..adf8dd0ec58 --- /dev/null +++ b/packages/eip-5792-middleware/README.md @@ -0,0 +1,15 @@ +# `@metamask/eip-5792-middleware` + +Implements the hooks required by the wallet middleware in [eth-json-rpc-middleware](https://github.com/MetaMask/eth-json-rpc-middleware), for JSON-RPC methods for sending multiple calls from the user's wallet and checking their status referenced in [EIP-5792](https://eips.ethereum.org/EIPS/eip-5792). + +## Installation + +`yarn add @metamask/eip-5792-middleware` + +or + +`npm install @metamask/eip-5792-middleware` + +## 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/eip-5792-middleware/jest.config.js b/packages/eip-5792-middleware/jest.config.js new file mode 100644 index 00000000000..729b145b19c --- /dev/null +++ b/packages/eip-5792-middleware/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: 96.18, + functions: 100, + lines: 100, + statements: 100, + }, + }, +}); diff --git a/packages/eip-5792-middleware/package.json b/packages/eip-5792-middleware/package.json new file mode 100644 index 00000000000..0db02dfa9e7 --- /dev/null +++ b/packages/eip-5792-middleware/package.json @@ -0,0 +1,82 @@ +{ + "name": "@metamask/eip-5792-middleware", + "version": "3.0.5", + "description": "Implements the JSON-RPC methods for sending multiple calls from the user's wallet, and checking their status, as referenced in EIP-5792", + "keywords": [ + "Ethereum", + "MetaMask" + ], + "homepage": "https://github.com/MetaMask/core/tree/main/packages/eip-5792-middleware#readme", + "bugs": { + "url": "https://github.com/MetaMask/core/issues" + }, + "license": "MIT", + "repository": { + "type": "git", + "url": "https://github.com/MetaMask/core.git" + }, + "files": [ + "dist/" + ], + "sideEffects": false, + "main": "./dist/index.cjs", + "types": "./dist/index.d.cts", + "exports": { + ".": { + "import": { + "types": "./dist/index.d.mts", + "default": "./dist/index.mjs" + }, + "require": { + "types": "./dist/index.d.cts", + "default": "./dist/index.cjs" + } + }, + "./package.json": "./package.json" + }, + "publishConfig": { + "access": "public", + "registry": "https://registry.npmjs.org/" + }, + "scripts": { + "build": "ts-bridge --project tsconfig.build.json --verbose --clean --no-references", + "build:all": "ts-bridge --project tsconfig.build.json --verbose --clean", + "build:docs": "typedoc", + "changelog:update": "../../scripts/update-changelog.sh @metamask/eip-5792-middleware", + "changelog:validate": "../../scripts/validate-changelog.sh @metamask/eip-5792-middleware", + "lint:tsconfigs": "tsx ../../scripts/lint-tsconfigs/lint-tsconfigs.mts", + "lint:tsconfigs:fix": "tsx ../../scripts/lint-tsconfigs/lint-tsconfigs.mts --fix", + "since-latest-release": "../../scripts/since-latest-release.sh", + "test": "NODE_OPTIONS=--experimental-vm-modules jest --reporters=jest-silent-reporter", + "test:clean": "NODE_OPTIONS=--experimental-vm-modules jest --clearCache", + "test:verbose": "NODE_OPTIONS=--experimental-vm-modules jest --verbose", + "test:watch": "NODE_OPTIONS=--experimental-vm-modules jest --watch" + }, + "dependencies": { + "@metamask/messenger": "^2.0.0", + "@metamask/preferences-controller": "^23.1.0", + "@metamask/superstruct": "^3.4.1", + "@metamask/transaction-controller": "^69.6.1", + "@metamask/utils": "^11.11.0", + "lodash": "^4.17.21", + "uuid": "^8.3.2" + }, + "devDependencies": { + "@metamask/auto-changelog": "^6.1.0", + "@metamask/keyring-controller": "^27.1.1", + "@metamask/rpc-errors": "^7.0.2", + "@ts-bridge/cli": "^0.6.4", + "@types/jest": "^30.0.0", + "deepmerge": "^4.2.2", + "jest": "^30.4.2", + "klona": "^2.0.6", + "ts-jest": "^29.4.11", + "tsx": "^4.20.5", + "typedoc": "^0.25.13", + "typedoc-plugin-missing-exports": "^2.0.0", + "typescript": "~5.3.3" + }, + "engines": { + "node": "^18.18 || >=20" + } +} diff --git a/packages/eip-5792-middleware/src/constants.ts b/packages/eip-5792-middleware/src/constants.ts new file mode 100644 index 00000000000..b6b62f031a2 --- /dev/null +++ b/packages/eip-5792-middleware/src/constants.ts @@ -0,0 +1,40 @@ +import { KeyringTypes } from '@metamask/keyring-controller'; + +export const VERSION = '2.0.0'; + +export const KEYRING_TYPES_SUPPORTING_7702 = [ + KeyringTypes.hd, + KeyringTypes.simple, +]; + +export enum MessageType { + SendTransaction = 'eth_sendTransaction', +} + +export enum SupportedCapabilities { + AuxiliaryFunds = 'auxiliaryFunds', +} + +// To be moved to @metamask/rpc-errors in future. +export enum EIP5792ErrorCode { + UnsupportedNonOptionalCapability = 5700, + UnsupportedChainId = 5710, + UnknownBundleId = 5730, + RejectedUpgrade = 5750, +} + +// To be moved to @metamask/rpc-errors in future. +export enum EIP7682ErrorCode { + UnsupportedAsset = 5771, + UnsupportedChain = 5772, + MalformedRequiredAssets = 5773, +} + +// wallet_getCallStatus +export enum GetCallsStatusCode { + PENDING = 100, + CONFIRMED = 200, + FAILED_OFFCHAIN = 400, + REVERTED = 500, + REVERTED_PARTIAL = 600, +} diff --git a/packages/eip-5792-middleware/src/hooks/getCallsStatus.test.ts b/packages/eip-5792-middleware/src/hooks/getCallsStatus.test.ts new file mode 100644 index 00000000000..ed4ddfa0f03 --- /dev/null +++ b/packages/eip-5792-middleware/src/hooks/getCallsStatus.test.ts @@ -0,0 +1,225 @@ +import { Messenger, MOCK_ANY_NAMESPACE } from '@metamask/messenger'; +import type { MessengerActions, MockAnyNamespace } from '@metamask/messenger'; +import { TransactionStatus } from '@metamask/transaction-controller'; +import type { + TransactionControllerGetStateAction, + TransactionControllerState, +} from '@metamask/transaction-controller'; + +import { GetCallsStatusCode } from '../constants.js'; +import type { EIP5792Messenger } from '../types.js'; +import { getCallsStatus } from './getCallsStatus.js'; + +const CHAIN_ID_MOCK = '0x123'; +const BATCH_ID_MOCK = '0xf3472db2a4134607a17213b7e9ca26e3'; + +const TRANSACTION_META_MOCK = { + batchId: BATCH_ID_MOCK, + chainId: CHAIN_ID_MOCK, + status: TransactionStatus.confirmed, + txReceipt: { + blockHash: '0xabcd', + blockNumber: '0x1234', + gasUsed: '0x4321', + logs: [ + { + address: '0xa123', + data: '0xb123', + topics: ['0xc123'], + }, + { + address: '0xd123', + data: '0xe123', + topics: ['0xf123'], + }, + ], + status: '0x1', + transactionHash: '0xcba', + }, +}; + +type AllActions = MessengerActions; + +type RootMessenger = Messenger; + +describe('EIP-5792', () => { + const getTransactionControllerStateMock: jest.MockedFn< + TransactionControllerGetStateAction['handler'] + > = jest.fn(); + + let rootMessenger: RootMessenger; + + let messenger: Messenger<'EIP5792', AllActions, never, RootMessenger>; + + beforeEach(() => { + jest.resetAllMocks(); + + rootMessenger = new Messenger({ + namespace: MOCK_ANY_NAMESPACE, + }); + + rootMessenger.registerActionHandler( + 'TransactionController:getState', + getTransactionControllerStateMock, + ); + + messenger = new Messenger({ + namespace: 'EIP5792', + parent: rootMessenger, + }); + + rootMessenger.delegate({ + messenger, + actions: ['TransactionController:getState'], + }); + }); + + describe('getCallsStatus', () => { + it('returns result using metadata from transaction controller', () => { + getTransactionControllerStateMock.mockReturnValueOnce({ + transactions: [TRANSACTION_META_MOCK], + } as unknown as TransactionControllerState); + + expect(getCallsStatus(messenger, BATCH_ID_MOCK)).toStrictEqual({ + version: '2.0.0', + id: BATCH_ID_MOCK, + chainId: CHAIN_ID_MOCK, + atomic: true, + status: GetCallsStatusCode.CONFIRMED, + receipts: [ + { + blockNumber: TRANSACTION_META_MOCK.txReceipt.blockNumber, + blockHash: TRANSACTION_META_MOCK.txReceipt.blockHash, + gasUsed: TRANSACTION_META_MOCK.txReceipt.gasUsed, + logs: TRANSACTION_META_MOCK.txReceipt.logs, + status: TRANSACTION_META_MOCK.txReceipt.status, + transactionHash: TRANSACTION_META_MOCK.txReceipt.transactionHash, + }, + ], + }); + }); + + it('ignores additional properties in receipt', () => { + getTransactionControllerStateMock.mockReturnValueOnce({ + transactions: [ + { + ...TRANSACTION_META_MOCK, + txReceipt: { + ...TRANSACTION_META_MOCK.txReceipt, + extra: 'data', + }, + }, + ], + } as unknown as TransactionControllerState); + + const receiptResult = getCallsStatus(messenger, BATCH_ID_MOCK) + ?.receipts?.[0]; + + expect(receiptResult).not.toHaveProperty('extra'); + }); + + it('ignores additional properties in log', () => { + getTransactionControllerStateMock.mockReturnValueOnce({ + transactions: [ + { + ...TRANSACTION_META_MOCK, + txReceipt: { + ...TRANSACTION_META_MOCK.txReceipt, + logs: [ + { + ...TRANSACTION_META_MOCK.txReceipt.logs[0], + extra: 'data', + }, + ], + }, + }, + ], + } as unknown as TransactionControllerState); + + const receiptLog = getCallsStatus(messenger, BATCH_ID_MOCK)?.receipts?.[0] + ?.logs?.[0]; + + expect(receiptLog).not.toHaveProperty('extra'); + }); + + it('returns failed status if transaction status is failed and no hash', () => { + getTransactionControllerStateMock.mockReturnValueOnce({ + transactions: [ + { + ...TRANSACTION_META_MOCK, + status: TransactionStatus.failed, + hash: undefined, + }, + ], + } as unknown as TransactionControllerState); + + expect(getCallsStatus(messenger, BATCH_ID_MOCK)?.status).toStrictEqual( + GetCallsStatusCode.FAILED_OFFCHAIN, + ); + }); + + it('returns reverted status if transaction status is failed and hash', () => { + getTransactionControllerStateMock.mockReturnValueOnce({ + transactions: [ + { + ...TRANSACTION_META_MOCK, + status: TransactionStatus.failed, + hash: '0x123', + }, + ], + } as unknown as TransactionControllerState); + + expect(getCallsStatus(messenger, BATCH_ID_MOCK)?.status).toStrictEqual( + GetCallsStatusCode.REVERTED, + ); + }); + + it('returns reverted status if transaction status is dropped', () => { + getTransactionControllerStateMock.mockReturnValueOnce({ + transactions: [ + { + ...TRANSACTION_META_MOCK, + status: TransactionStatus.dropped, + }, + ], + } as unknown as TransactionControllerState); + + expect(getCallsStatus(messenger, BATCH_ID_MOCK)?.status).toStrictEqual( + GetCallsStatusCode.REVERTED, + ); + }); + + it.each([ + TransactionStatus.approved, + TransactionStatus.signed, + TransactionStatus.submitted, + TransactionStatus.unapproved, + ])( + 'returns pending status if transaction status is %s', + (status: TransactionStatus) => { + getTransactionControllerStateMock.mockReturnValueOnce({ + transactions: [ + { + ...TRANSACTION_META_MOCK, + status, + }, + ], + } as unknown as TransactionControllerState); + + expect(getCallsStatus(messenger, BATCH_ID_MOCK)?.status).toStrictEqual( + GetCallsStatusCode.PENDING, + ); + }, + ); + + it('throws if no transactions found', () => { + getTransactionControllerStateMock.mockReturnValueOnce({ + transactions: [], + } as unknown as TransactionControllerState); + + expect(() => getCallsStatus(messenger, BATCH_ID_MOCK)).toThrow( + `No matching bundle found`, + ); + }); + }); +}); diff --git a/packages/eip-5792-middleware/src/hooks/getCallsStatus.ts b/packages/eip-5792-middleware/src/hooks/getCallsStatus.ts new file mode 100644 index 00000000000..8ec36004036 --- /dev/null +++ b/packages/eip-5792-middleware/src/hooks/getCallsStatus.ts @@ -0,0 +1,91 @@ +import { JsonRpcError } from '@metamask/rpc-errors'; +import type { + Log, + TransactionMeta, + TransactionReceipt, +} from '@metamask/transaction-controller'; +import { TransactionStatus } from '@metamask/transaction-controller'; +import type { Hex } from '@metamask/utils'; + +import { EIP5792ErrorCode, GetCallsStatusCode, VERSION } from '../constants.js'; +import type { EIP5792Messenger, GetCallsStatusResult } from '../types.js'; + +/** + * Retrieves the status of a transaction batch by its ID. + * + * @param messenger - Messenger instance for controller communication. + * @param id - The batch ID to look up (hexadecimal string). + * @returns GetCallsStatusResult containing the batch status, receipts, and metadata. + * @throws JsonRpcError with EIP5792ErrorCode.UnknownBundleId if no matching bundle is found. + */ +export function getCallsStatus( + messenger: EIP5792Messenger, + id: Hex, +): GetCallsStatusResult { + const transactions = messenger + .call('TransactionController:getState') + .transactions.filter((tx) => tx.batchId === id); + + if (!transactions?.length) { + throw new JsonRpcError( + EIP5792ErrorCode.UnknownBundleId, + `No matching bundle found`, + ); + } + + const transaction = transactions[0]; + const { chainId, txReceipt: rawTxReceipt } = transaction; + const status = getStatusCode(transaction); + const txReceipt = rawTxReceipt as Required | undefined; + const logs = (txReceipt?.logs ?? []) as Required[]; + + const receipts: GetCallsStatusResult['receipts'] = txReceipt && [ + { + blockHash: txReceipt.blockHash as Hex, + blockNumber: txReceipt.blockNumber as Hex, + gasUsed: txReceipt.gasUsed as Hex, + logs: logs.map((log: Required & { data: Hex }) => ({ + address: log.address as Hex, + data: log.data, + topics: log.topics as unknown as Hex[], + })), + status: txReceipt.status as '0x0' | '0x1', + transactionHash: txReceipt.transactionHash, + }, + ]; + + return { + version: VERSION, + id, + chainId, + atomic: true, // Always atomic as we currently only support EIP-7702 batches + status, + receipts, + }; +} + +/** + * Maps transaction status to EIP-5792 call status codes. + * + * @param transactionMeta - The transaction metadata containing status and hash information. + * @returns GetCallsStatusCode representing the current status of the transaction. + */ +function getStatusCode(transactionMeta: TransactionMeta) { + const { hash, status } = transactionMeta; + + if (status === TransactionStatus.confirmed) { + return GetCallsStatusCode.CONFIRMED; + } + + if (status === TransactionStatus.failed) { + return hash + ? GetCallsStatusCode.REVERTED + : GetCallsStatusCode.FAILED_OFFCHAIN; + } + + if (status === TransactionStatus.dropped) { + return GetCallsStatusCode.REVERTED; + } + + return GetCallsStatusCode.PENDING; +} diff --git a/packages/eip-5792-middleware/src/hooks/getCapabilities.test.ts b/packages/eip-5792-middleware/src/hooks/getCapabilities.test.ts new file mode 100644 index 00000000000..5ad6c431f07 --- /dev/null +++ b/packages/eip-5792-middleware/src/hooks/getCapabilities.test.ts @@ -0,0 +1,569 @@ +import type { + AccountsControllerGetStateAction, + AccountsControllerState, +} from '@metamask/accounts-controller'; +import { KeyringTypes } from '@metamask/keyring-controller'; +import { MOCK_ANY_NAMESPACE, Messenger } from '@metamask/messenger'; +import type { MockAnyNamespace, MessengerActions } from '@metamask/messenger'; +import type { + PreferencesControllerGetStateAction, + PreferencesState, +} from '@metamask/preferences-controller'; +import type { TransactionController } from '@metamask/transaction-controller'; +import type { Hex } from '@metamask/utils'; + +import type { EIP5792Messenger } from '../types.js'; +import { getCapabilities } from './getCapabilities.js'; + +const CHAIN_ID_MOCK = '0x123'; +const FROM_MOCK = '0xabc123'; +const FROM_MOCK_HARDWARE = '0xdef456'; +const FROM_MOCK_SIMPLE = '0x789abc'; +const DELEGATION_ADDRESS_MOCK = '0x1234567890abcdef1234567890abcdef12345678'; + +type AllActions = MessengerActions; + +type RootMessenger = Messenger; + +describe('EIP-5792', () => { + const isAtomicBatchSupportedMock: jest.MockedFn< + TransactionController['isAtomicBatchSupported'] + > = jest.fn(); + + const getIsSmartTransactionMock: jest.MockedFn<(chainId: Hex) => boolean> = + jest.fn(); + + const isRelaySupportedMock: jest.Mock = jest.fn(); + + const getSendBundleSupportedChainsMock: jest.Mock = jest.fn(); + + const getDismissSmartAccountSuggestionEnabledMock: jest.MockedFn< + () => boolean + > = jest.fn(); + + const getAccountsStateMock: jest.MockedFn< + AccountsControllerGetStateAction['handler'] + > = jest.fn(); + + const getPreferencesStateMock: jest.MockedFn< + PreferencesControllerGetStateAction['handler'] + > = jest.fn(); + + const isAuxiliaryFundsSupportedMock: jest.Mock = jest.fn(); + + let rootMessenger: RootMessenger; + + let messenger: Messenger<'EIP5792', AllActions, never, RootMessenger>; + + const getCapabilitiesHooks = { + getDismissSmartAccountSuggestionEnabled: + getDismissSmartAccountSuggestionEnabledMock, + isAtomicBatchSupported: isAtomicBatchSupportedMock, + getIsSmartTransaction: getIsSmartTransactionMock, + isRelaySupported: isRelaySupportedMock, + getSendBundleSupportedChains: getSendBundleSupportedChainsMock, + isAuxiliaryFundsSupported: isAuxiliaryFundsSupportedMock, + }; + + beforeEach(() => { + jest.resetAllMocks(); + + rootMessenger = new Messenger({ + namespace: MOCK_ANY_NAMESPACE, + }); + + rootMessenger.registerActionHandler( + 'AccountsController:getState', + getAccountsStateMock, + ); + + rootMessenger.registerActionHandler( + 'PreferencesController:getState', + getPreferencesStateMock, + ); + + messenger = new Messenger({ + namespace: 'EIP5792', + parent: rootMessenger, + }); + + rootMessenger.delegate({ + messenger, + actions: [ + 'AccountsController:getState', + 'PreferencesController:getState', + 'NetworkController:getState', + ], + }); + + isAtomicBatchSupportedMock.mockResolvedValue([ + { + chainId: CHAIN_ID_MOCK, + delegationAddress: undefined, + isSupported: false, + upgradeContractAddress: DELEGATION_ADDRESS_MOCK, + }, + ]); + + getAccountsStateMock.mockReturnValue({ + internalAccounts: { + accounts: { + [FROM_MOCK]: { + address: FROM_MOCK, + metadata: { + keyring: { + type: KeyringTypes.hd, + }, + }, + }, + [FROM_MOCK_HARDWARE]: { + address: FROM_MOCK_HARDWARE, + metadata: { + keyring: { + type: KeyringTypes.ledger, + }, + }, + }, + [FROM_MOCK_SIMPLE]: { + address: FROM_MOCK_SIMPLE, + metadata: { + keyring: { + type: KeyringTypes.simple, + }, + }, + }, + }, + }, + } as unknown as AccountsControllerState); + }); + + describe('getCapabilities', () => { + beforeEach(() => { + getPreferencesStateMock.mockReturnValue({ + useTransactionSimulations: true, + } as unknown as PreferencesState); + + isRelaySupportedMock.mockResolvedValue(true); + getSendBundleSupportedChainsMock.mockResolvedValue({ + [CHAIN_ID_MOCK]: true, + }); + }); + + it('includes atomic capability if already upgraded', async () => { + isAtomicBatchSupportedMock.mockResolvedValueOnce([ + { + chainId: CHAIN_ID_MOCK, + delegationAddress: DELEGATION_ADDRESS_MOCK, + isSupported: true, + }, + ]); + + const capabilities = await getCapabilities( + getCapabilitiesHooks, + messenger, + FROM_MOCK, + [CHAIN_ID_MOCK], + ); + + expect(capabilities).toStrictEqual({ + [CHAIN_ID_MOCK]: { + atomic: { + status: 'supported', + }, + alternateGasFees: { + supported: true, + }, + }, + }); + }); + + it('includes atomic capability if not yet upgraded', async () => { + isAtomicBatchSupportedMock.mockResolvedValueOnce([ + { + chainId: CHAIN_ID_MOCK, + delegationAddress: undefined, + isSupported: false, + upgradeContractAddress: DELEGATION_ADDRESS_MOCK, + }, + ]); + + const capabilities = await getCapabilities( + getCapabilitiesHooks, + messenger, + FROM_MOCK, + [CHAIN_ID_MOCK], + ); + + expect(capabilities).toStrictEqual({ + [CHAIN_ID_MOCK]: { + atomic: { + status: 'ready', + }, + }, + }); + }); + + it('includes atomic capability if not yet upgraded and simple keyring', async () => { + isAtomicBatchSupportedMock.mockResolvedValueOnce([ + { + chainId: CHAIN_ID_MOCK, + delegationAddress: undefined, + isSupported: false, + upgradeContractAddress: DELEGATION_ADDRESS_MOCK, + }, + ]); + + const capabilities = await getCapabilities( + getCapabilitiesHooks, + messenger, + FROM_MOCK_SIMPLE, + [CHAIN_ID_MOCK], + ); + + expect(capabilities).toStrictEqual({ + [CHAIN_ID_MOCK]: { + atomic: { + status: 'ready', + }, + }, + }); + }); + + it('does not include atomic capability if chain not supported', async () => { + isAtomicBatchSupportedMock.mockResolvedValueOnce([]); + + const capabilities = await getCapabilities( + getCapabilitiesHooks, + messenger, + FROM_MOCK, + [CHAIN_ID_MOCK], + ); + + expect(capabilities).toStrictEqual({}); + }); + + it('does not include atomic capability if all upgrades disabled', async () => { + isAtomicBatchSupportedMock.mockResolvedValueOnce([ + { + chainId: CHAIN_ID_MOCK, + delegationAddress: undefined, + isSupported: false, + upgradeContractAddress: DELEGATION_ADDRESS_MOCK, + }, + ]); + + getDismissSmartAccountSuggestionEnabledMock.mockReturnValue(true); + + const capabilities = await getCapabilities( + getCapabilitiesHooks, + messenger, + FROM_MOCK, + [CHAIN_ID_MOCK], + ); + + expect(capabilities).toStrictEqual({}); + }); + + it('does not include atomic capability if no upgrade contract address', async () => { + isAtomicBatchSupportedMock.mockResolvedValueOnce([ + { + chainId: CHAIN_ID_MOCK, + delegationAddress: undefined, + isSupported: false, + upgradeContractAddress: undefined, + }, + ]); + + const capabilities = await getCapabilities( + getCapabilitiesHooks, + messenger, + FROM_MOCK, + [CHAIN_ID_MOCK], + ); + + expect(capabilities).toStrictEqual({}); + }); + + it('does not include atomic capability if keyring type not supported', async () => { + isAtomicBatchSupportedMock.mockResolvedValueOnce([ + { + chainId: CHAIN_ID_MOCK, + delegationAddress: undefined, + isSupported: false, + upgradeContractAddress: DELEGATION_ADDRESS_MOCK, + }, + ]); + + const capabilities = await getCapabilities( + getCapabilitiesHooks, + messenger, + FROM_MOCK_HARDWARE, + [CHAIN_ID_MOCK], + ); + + expect(capabilities).toStrictEqual({}); + }); + + it('does not include atomic capability if keyring type not found', async () => { + isAtomicBatchSupportedMock.mockResolvedValueOnce([ + { + chainId: CHAIN_ID_MOCK, + delegationAddress: undefined, + isSupported: false, + upgradeContractAddress: DELEGATION_ADDRESS_MOCK, + }, + ]); + + const capabilities = await getCapabilities( + getCapabilitiesHooks, + messenger, + '0x456', + [CHAIN_ID_MOCK], + ); + + expect(capabilities).toStrictEqual({}); + }); + + it('does not return alternateGasFees if transaction simulations are not enabled', async () => { + getPreferencesStateMock.mockReturnValue({ + useTransactionSimulations: false, + } as unknown as PreferencesState); + isAtomicBatchSupportedMock.mockResolvedValueOnce([ + { + chainId: CHAIN_ID_MOCK, + delegationAddress: DELEGATION_ADDRESS_MOCK, + isSupported: true, + }, + ]); + + const capabilities = await getCapabilities( + getCapabilitiesHooks, + messenger, + FROM_MOCK, + [CHAIN_ID_MOCK], + ); + + expect(capabilities).toStrictEqual({ + [CHAIN_ID_MOCK]: { + atomic: { + status: 'supported', + }, + }, + }); + }); + + it('does not return alternateGasFees if smart transaction are not supported and also not 7702', async () => { + getIsSmartTransactionMock.mockReturnValue(false); + isAtomicBatchSupportedMock.mockResolvedValueOnce([ + { + chainId: CHAIN_ID_MOCK, + delegationAddress: DELEGATION_ADDRESS_MOCK, + isSupported: false, + }, + ]); + + const capabilities = await getCapabilities( + getCapabilitiesHooks, + messenger, + FROM_MOCK, + [CHAIN_ID_MOCK], + ); + + expect(capabilities).toStrictEqual({}); + }); + + it('does not return alternateGasFees if smart transaction are not supported and also 7702 but not relay of transaction', async () => { + getIsSmartTransactionMock.mockReturnValue(false); + isRelaySupportedMock.mockResolvedValue(false); + isAtomicBatchSupportedMock.mockResolvedValueOnce([ + { + chainId: CHAIN_ID_MOCK, + delegationAddress: DELEGATION_ADDRESS_MOCK, + isSupported: true, + }, + ]); + + const capabilities = await getCapabilities( + getCapabilitiesHooks, + messenger, + FROM_MOCK, + [CHAIN_ID_MOCK], + ); + + expect(capabilities).toStrictEqual({ + [CHAIN_ID_MOCK]: { + atomic: { + status: 'supported', + }, + }, + }); + }); + + it('returns alternateGasFees true if send bundle is supported', async () => { + isAtomicBatchSupportedMock.mockResolvedValueOnce([ + { + chainId: CHAIN_ID_MOCK, + delegationAddress: DELEGATION_ADDRESS_MOCK, + isSupported: true, + }, + ]); + getSendBundleSupportedChainsMock.mockResolvedValue({ + [CHAIN_ID_MOCK]: true, + }); + + const capabilities = await getCapabilities( + getCapabilitiesHooks, + messenger, + FROM_MOCK, + [CHAIN_ID_MOCK], + ); + + expect(capabilities).toStrictEqual({ + [CHAIN_ID_MOCK]: { + atomic: { + status: 'supported', + }, + alternateGasFees: { + supported: true, + }, + }, + }); + }); + + it('does not add alternateGasFees property if send bundle is not supported', async () => { + isRelaySupportedMock.mockResolvedValue(false); + getSendBundleSupportedChainsMock.mockResolvedValue({ + [CHAIN_ID_MOCK]: false, + }); + + const capabilities = await getCapabilities( + getCapabilitiesHooks, + messenger, + FROM_MOCK, + [CHAIN_ID_MOCK], + ); + + expect(capabilities).toStrictEqual({ + [CHAIN_ID_MOCK]: { + atomic: { + status: 'ready', + }, + }, + }); + }); + + it('fetches all network configurations when chainIds is undefined', async () => { + const networkConfigurationsMock = { + '0x1': { chainId: '0x1' }, + '0x89': { chainId: '0x89' }, + }; + + rootMessenger.registerActionHandler( + 'NetworkController:getState', + jest.fn().mockReturnValue({ + networkConfigurationsByChainId: networkConfigurationsMock, + }), + ); + + isAtomicBatchSupportedMock.mockResolvedValueOnce([ + { + chainId: '0x1', + delegationAddress: DELEGATION_ADDRESS_MOCK, + isSupported: true, + }, + { + chainId: '0x89', + delegationAddress: undefined, + isSupported: false, + upgradeContractAddress: DELEGATION_ADDRESS_MOCK, + }, + ]); + + const capabilities = await getCapabilities( + getCapabilitiesHooks, + messenger, + FROM_MOCK, + undefined, + ); + + expect(capabilities).toStrictEqual({ + '0x1': { + atomic: { + status: 'supported', + }, + alternateGasFees: { + supported: true, + }, + }, + '0x89': { + atomic: { + status: 'ready', + }, + }, + }); + }); + + it('includes auxiliary funds capability when supported', async () => { + isAtomicBatchSupportedMock.mockResolvedValueOnce([ + { + chainId: CHAIN_ID_MOCK, + delegationAddress: DELEGATION_ADDRESS_MOCK, + isSupported: true, + }, + ]); + + isAuxiliaryFundsSupportedMock.mockReturnValue(true); + + const capabilities = await getCapabilities( + getCapabilitiesHooks, + messenger, + FROM_MOCK, + [CHAIN_ID_MOCK], + ); + + expect(capabilities).toStrictEqual({ + [CHAIN_ID_MOCK]: { + atomic: { + status: 'supported', + }, + alternateGasFees: { + supported: true, + }, + auxiliaryFunds: { + supported: true, + }, + }, + }); + }); + + it('does not include auxiliary funds capability when not supported', async () => { + isAtomicBatchSupportedMock.mockResolvedValueOnce([ + { + chainId: CHAIN_ID_MOCK, + delegationAddress: DELEGATION_ADDRESS_MOCK, + isSupported: true, + }, + ]); + + isAuxiliaryFundsSupportedMock.mockReturnValue(false); + + const capabilities = await getCapabilities( + getCapabilitiesHooks, + messenger, + FROM_MOCK, + [CHAIN_ID_MOCK], + ); + + expect(capabilities).toStrictEqual({ + [CHAIN_ID_MOCK]: { + atomic: { + status: 'supported', + }, + alternateGasFees: { + supported: true, + }, + }, + }); + }); + }); +}); diff --git a/packages/eip-5792-middleware/src/hooks/getCapabilities.ts b/packages/eip-5792-middleware/src/hooks/getCapabilities.ts new file mode 100644 index 00000000000..5af64bc993c --- /dev/null +++ b/packages/eip-5792-middleware/src/hooks/getCapabilities.ts @@ -0,0 +1,198 @@ +import type { + IsAtomicBatchSupportedResult, + IsAtomicBatchSupportedResultEntry, + TransactionController, +} from '@metamask/transaction-controller'; +import type { Hex } from '@metamask/utils'; + +import { KEYRING_TYPES_SUPPORTING_7702 } from '../constants.js'; +import type { EIP5792Messenger, GetCapabilitiesResult } from '../types.js'; +import { getAccountKeyringType } from '../utils.js'; + +/** + * Type definition for required controller hooks and utilities of {@link getCapabilities} + */ +export type GetCapabilitiesHooks = { + /** Function to check if smart account suggestions are disabled */ + getDismissSmartAccountSuggestionEnabled: () => boolean; + /** Function to check if a chain supports smart transactions */ + getIsSmartTransaction: (chainId: Hex) => boolean; + /** Function to check if atomic batching is supported */ + isAtomicBatchSupported: TransactionController['isAtomicBatchSupported']; + /** Function to check if relay is supported on a chain */ + isRelaySupported: (chainId: Hex) => Promise; + /** Function to get chains that support send bundle */ + getSendBundleSupportedChains: ( + chainIds: Hex[], + ) => Promise>; + /** Function to validate if auxiliary funds capability is supported. */ + isAuxiliaryFundsSupported: (chainId: Hex) => boolean; +}; + +/** + * Retrieves the capabilities for atomic transactions on specified chains. + * + * @param hooks - Object containing required controller hooks and utilities. + * @param messenger - Messenger instance for controller communication. + * @param address - The account address to check capabilities for. + * @param chainIds - Array of chain IDs to check capabilities for (if undefined, checks all configured networks). + * @returns Promise resolving to GetCapabilitiesResult mapping chain IDs to their capabilities. + */ +export async function getCapabilities( + hooks: GetCapabilitiesHooks, + messenger: EIP5792Messenger, + address: Hex, + chainIds: Hex[] | undefined, +) { + const { + getDismissSmartAccountSuggestionEnabled, + getIsSmartTransaction, + isAtomicBatchSupported, + isRelaySupported, + getSendBundleSupportedChains, + isAuxiliaryFundsSupported, + } = hooks; + + let chainIdsNormalized = chainIds?.map( + (chainId) => chainId.toLowerCase() as Hex, + ); + + if (!chainIdsNormalized?.length) { + const networkConfigurations = messenger.call( + 'NetworkController:getState', + ).networkConfigurationsByChainId; + chainIdsNormalized = Object.keys(networkConfigurations) as Hex[]; + } + + const batchSupport = await isAtomicBatchSupported({ + address, + chainIds: chainIdsNormalized, + }); + + const alternateGasFeesAcc = await getAlternateGasFeesCapability( + chainIdsNormalized, + batchSupport, + getIsSmartTransaction, + isRelaySupported, + getSendBundleSupportedChains, + messenger, + ); + + return chainIdsNormalized.reduce((acc, chainId) => { + const chainBatchSupport = (batchSupport.find( + ({ chainId: batchChainId }) => batchChainId === chainId, + ) ?? {}) as IsAtomicBatchSupportedResultEntry & { + isRelaySupported: boolean; + }; + + const { delegationAddress, isSupported, upgradeContractAddress } = + chainBatchSupport; + + const isUpgradeDisabled = getDismissSmartAccountSuggestionEnabled(); + let isSupportedAccount = false; + + try { + const keyringType = getAccountKeyringType(address, messenger); + isSupportedAccount = KEYRING_TYPES_SUPPORTING_7702.includes(keyringType); + } catch { + // Intentionally empty + } + + const canUpgrade = + !isUpgradeDisabled && + upgradeContractAddress && + !delegationAddress && + isSupportedAccount; + + if (!isSupported && !canUpgrade) { + return acc; + } + + const status = isSupported ? 'supported' : 'ready'; + const hexChainId = chainId; + + if (acc[hexChainId] === undefined) { + acc[hexChainId] = {}; + } + + acc[hexChainId].atomic = { + status, + }; + + if (isSupportedAccount && isAuxiliaryFundsSupported(chainId)) { + acc[hexChainId].auxiliaryFunds = { + supported: true, + }; + } + + return acc; + }, alternateGasFeesAcc); +} + +/** + * Determines alternate gas fees capability for the specified chains. + * + * @param chainIds - Array of chain IDs to check for alternate gas fees support. + * @param batchSupport - Information about atomic batch support for each chain. + * @param getIsSmartTransaction - Function to check if a chain supports smart transactions. + * @param isRelaySupported - Function to check if relay is supported on a chain. + * @param getSendBundleSupportedChains - Function to get chains that support send bundle. + * @param messenger - Messenger instance for controller communication. + * @returns Promise resolving to GetCapabilitiesResult with alternate gas fees information. + */ +async function getAlternateGasFeesCapability( + chainIds: Hex[], + batchSupport: IsAtomicBatchSupportedResult, + getIsSmartTransaction: (chainId: Hex) => boolean, + isRelaySupported: (chainId: Hex) => Promise, + getSendBundleSupportedChains: ( + chainIds: Hex[], + ) => Promise>, + messenger: EIP5792Messenger, +) { + const simulationEnabled = messenger.call( + 'PreferencesController:getState', + ).useTransactionSimulations; + + const relaySupportedChains = await Promise.all( + batchSupport + .map(({ chainId }) => chainId) + .map((chainId) => isRelaySupported(chainId)), + ); + + const sendBundleSupportedChains = + await getSendBundleSupportedChains(chainIds); + + const updatedBatchSupport = batchSupport.map((support, index) => ({ + ...support, + relaySupportedForChain: relaySupportedChains[index], + })); + + return chainIds.reduce((acc, chainId) => { + const chainBatchSupport = (updatedBatchSupport.find( + ({ chainId: batchChainId }) => batchChainId === chainId, + ) ?? {}) as IsAtomicBatchSupportedResultEntry & { + relaySupportedForChain: boolean; + }; + + const { isSupported = false, relaySupportedForChain } = chainBatchSupport; + + const isSmartTransaction = getIsSmartTransaction(chainId); + const isSendBundleSupported = sendBundleSupportedChains[chainId] ?? false; + + const alternateGasFees = + simulationEnabled && + ((isSmartTransaction && isSendBundleSupported) || + (isSupported && relaySupportedForChain)); + + if (alternateGasFees) { + acc[chainId] = { + alternateGasFees: { + supported: true, + }, + }; + } + + return acc; + }, {}); +} diff --git a/packages/eip-5792-middleware/src/hooks/processSendCalls.test.ts b/packages/eip-5792-middleware/src/hooks/processSendCalls.test.ts new file mode 100644 index 00000000000..5098fc743f9 --- /dev/null +++ b/packages/eip-5792-middleware/src/hooks/processSendCalls.test.ts @@ -0,0 +1,896 @@ +import type { + AccountsControllerGetSelectedAccountAction, + AccountsControllerGetStateAction, + AccountsControllerState, +} from '@metamask/accounts-controller'; +import { KeyringTypes } from '@metamask/keyring-controller'; +import type { InternalAccount } from '@metamask/keyring-internal-api'; +import { Messenger, MOCK_ANY_NAMESPACE } from '@metamask/messenger'; +import type { MessengerActions, MockAnyNamespace } from '@metamask/messenger'; +import type { + AutoManagedNetworkClient, + CustomNetworkClientConfiguration, + NetworkControllerGetNetworkClientByIdAction, +} from '@metamask/network-controller'; +import { providerErrors } from '@metamask/rpc-errors'; +import type { TransactionController } from '@metamask/transaction-controller'; +import type { Hex, JsonRpcRequest } from '@metamask/utils'; + +import { SupportedCapabilities } from '../constants.js'; +import type { + SendCallsPayload, + SendCallsParams, + EIP5792Messenger, +} from '../types.js'; +import { processSendCalls } from './processSendCalls.js'; + +const CHAIN_ID_MOCK = '0x123'; +const CHAIN_ID_2_MOCK = '0xabc'; +const BATCH_ID_MOCK = '0xf3472db2a4134607a17213b7e9ca26e3'; +const NETWORK_CLIENT_ID_MOCK = 'test-client'; +const FROM_MOCK = '0xabc123'; +const FROM_MOCK_HARDWARE = '0xdef456'; +const FROM_MOCK_SIMPLE = '0x789abc'; +const ORIGIN_MOCK = 'test.com'; +const DELEGATION_ADDRESS_MOCK = '0x1234567890abcdef1234567890abcdef12345678'; + +const SEND_CALLS_MOCK: SendCallsPayload = { + version: '2.0.0', + calls: [{ to: '0x123' }, { to: '0x456' }], + chainId: CHAIN_ID_MOCK, + from: FROM_MOCK, + atomicRequired: true, +}; + +const REQUEST_MOCK = { + id: 1, + jsonrpc: '2.0', + method: 'wallet_sendCalls', + networkClientId: NETWORK_CLIENT_ID_MOCK, + origin: ORIGIN_MOCK, + params: [SEND_CALLS_MOCK], +} as JsonRpcRequest & { networkClientId: string }; + +type AllActions = MessengerActions; + +type RootMessenger = Messenger; + +describe('EIP-5792', () => { + const addTransactionBatchMock: jest.MockedFn< + TransactionController['addTransactionBatch'] + > = jest.fn(); + + const addTransactionMock: jest.MockedFn< + TransactionController['addTransaction'] + > = jest.fn(); + + const getNetworkClientByIdMock: jest.MockedFn< + NetworkControllerGetNetworkClientByIdAction['handler'] + > = jest.fn(); + + const getSelectedAccountMock: jest.MockedFn< + AccountsControllerGetSelectedAccountAction['handler'] + > = jest.fn(); + + const isAtomicBatchSupportedMock: jest.MockedFn< + TransactionController['isAtomicBatchSupported'] + > = jest.fn(); + + const validateSecurityMock: jest.MockedFunction< + Parameters[0]['validateSecurity'] + > = jest.fn(); + + const getDismissSmartAccountSuggestionEnabledMock: jest.MockedFn< + () => boolean + > = jest.fn(); + + const getAccountsStateMock: jest.MockedFn< + AccountsControllerGetStateAction['handler'] + > = jest.fn(); + + const isAuxiliaryFundsSupportedMock: jest.Mock = jest.fn(); + + const getPermittedAccountsForOriginMock: jest.MockedFn<() => Promise> = + jest.fn(); + + let rootMessenger: RootMessenger; + + let messenger: Messenger<'EIP5792', AllActions, never, RootMessenger>; + + const sendCallsHooks = { + addTransactionBatch: addTransactionBatchMock, + addTransaction: addTransactionMock, + getDismissSmartAccountSuggestionEnabled: + getDismissSmartAccountSuggestionEnabledMock, + isAtomicBatchSupported: isAtomicBatchSupportedMock, + validateSecurity: validateSecurityMock, + getPermittedAccountsForOrigin: getPermittedAccountsForOriginMock, + isAuxiliaryFundsSupported: isAuxiliaryFundsSupportedMock, + }; + + beforeEach(() => { + jest.resetAllMocks(); + + rootMessenger = new Messenger({ + namespace: MOCK_ANY_NAMESPACE, + }); + + rootMessenger.registerActionHandler( + 'NetworkController:getNetworkClientById', + getNetworkClientByIdMock, + ); + + rootMessenger.registerActionHandler( + 'AccountsController:getState', + getAccountsStateMock, + ); + + messenger = new Messenger({ + namespace: 'EIP5792', + parent: rootMessenger, + }); + + rootMessenger.delegate({ + messenger, + actions: [ + 'AccountsController:getState', + 'PreferencesController:getState', + 'NetworkController:getNetworkClientById', + 'NetworkController:getState', + 'TransactionController:getState', + ], + }); + + getNetworkClientByIdMock.mockReturnValue({ + configuration: { + chainId: CHAIN_ID_MOCK, + }, + } as unknown as AutoManagedNetworkClient); + + addTransactionBatchMock.mockResolvedValue({ + batchId: BATCH_ID_MOCK, + }); + + getDismissSmartAccountSuggestionEnabledMock.mockReturnValue(false); + + isAuxiliaryFundsSupportedMock.mockReturnValue(true); + + getPermittedAccountsForOriginMock.mockResolvedValue([FROM_MOCK] as Hex[]); + + isAtomicBatchSupportedMock.mockResolvedValue([ + { + chainId: CHAIN_ID_MOCK, + delegationAddress: undefined, + isSupported: false, + upgradeContractAddress: DELEGATION_ADDRESS_MOCK, + }, + ]); + + getAccountsStateMock.mockReturnValue({ + internalAccounts: { + accounts: { + [FROM_MOCK]: { + address: FROM_MOCK, + metadata: { + keyring: { + type: KeyringTypes.hd, + }, + }, + }, + [FROM_MOCK_HARDWARE]: { + address: FROM_MOCK_HARDWARE, + metadata: { + keyring: { + type: KeyringTypes.ledger, + }, + }, + }, + [FROM_MOCK_SIMPLE]: { + address: FROM_MOCK_SIMPLE, + metadata: { + keyring: { + type: KeyringTypes.simple, + }, + }, + }, + }, + }, + } as unknown as AccountsControllerState); + }); + + describe('processSendCalls', () => { + it('calls adds transaction batch hook', async () => { + await processSendCalls( + sendCallsHooks, + messenger, + SEND_CALLS_MOCK, + REQUEST_MOCK, + ); + + expect(addTransactionBatchMock).toHaveBeenCalledWith({ + from: SEND_CALLS_MOCK.from, + networkClientId: NETWORK_CLIENT_ID_MOCK, + origin: ORIGIN_MOCK, + requestId: '1', + securityAlertId: expect.any(String), + transactions: [ + { params: SEND_CALLS_MOCK.calls[0] }, + { params: SEND_CALLS_MOCK.calls[1] }, + ], + validateSecurity: expect.any(Function), + }); + }); + + it('calls adds transaction hook if there is only 1 nested transaction', async () => { + await processSendCalls( + sendCallsHooks, + messenger, + { ...SEND_CALLS_MOCK, calls: [{ to: '0x123' }] }, + REQUEST_MOCK, + ); + + expect(addTransactionMock).toHaveBeenCalledWith( + { + from: SEND_CALLS_MOCK.from, + to: '0x123', + type: '0x2', + }, + { + batchId: expect.any(String), + networkClientId: 'test-client', + origin: 'test.com', + requestId: '1', + securityAlertResponse: { + securityAlertId: expect.any(String), + }, + }, + ); + expect(validateSecurityMock).toHaveBeenCalled(); + }); + + it('calls adds transaction batch hook if simple keyring', async () => { + await processSendCalls( + sendCallsHooks, + messenger, + { ...SEND_CALLS_MOCK, from: FROM_MOCK_SIMPLE }, + REQUEST_MOCK, + ); + + expect(addTransactionBatchMock).toHaveBeenCalledTimes(1); + }); + + it('calls adds transaction batch hook with selected permitted account if no `from` param is provided', async () => { + getSelectedAccountMock.mockReturnValue({ + address: SEND_CALLS_MOCK.from, + } as InternalAccount); + + await processSendCalls( + sendCallsHooks, + messenger, + { ...SEND_CALLS_MOCK, from: undefined }, + REQUEST_MOCK, + ); + + expect(addTransactionBatchMock).toHaveBeenCalledWith( + expect.objectContaining({ + from: SEND_CALLS_MOCK.from, + }), + ); + }); + + it('returns batch ID from hook', async () => { + expect( + await processSendCalls( + sendCallsHooks, + messenger, + SEND_CALLS_MOCK, + REQUEST_MOCK, + ), + ).toStrictEqual({ id: BATCH_ID_MOCK }); + }); + + it('throws if version not supported for single nested transaction', async () => { + await expect( + processSendCalls( + sendCallsHooks, + messenger, + { ...SEND_CALLS_MOCK, calls: [{ to: '0x123' }], version: '1.0' }, + REQUEST_MOCK, + ), + ).rejects.toThrow(`Version not supported: Got 1.0, expected 2.0.0`); + }); + + it('throws if version not supported', async () => { + await expect( + processSendCalls( + sendCallsHooks, + messenger, + { ...SEND_CALLS_MOCK, version: '1.0' }, + REQUEST_MOCK, + ), + ).rejects.toThrow(`Version not supported: Got 1.0, expected 2.0.0`); + }); + + it('throws if chain ID does not match network client', async () => { + await expect( + processSendCalls( + sendCallsHooks, + messenger, + { ...SEND_CALLS_MOCK, chainId: CHAIN_ID_2_MOCK }, + REQUEST_MOCK, + ), + ).rejects.toThrow( + `Chain ID must match the dApp selected network: Got ${CHAIN_ID_2_MOCK}, expected ${CHAIN_ID_MOCK}`, + ); + }); + + it('throws if user enabled preference to dismiss option to upgrade account', async () => { + getDismissSmartAccountSuggestionEnabledMock.mockReturnValue(true); + + await expect( + processSendCalls( + sendCallsHooks, + messenger, + SEND_CALLS_MOCK, + REQUEST_MOCK, + ), + ).rejects.toThrow('EIP-7702 upgrade disabled by the user'); + }); + + it('does not throw if user enabled preference to dismiss option to upgrade account for single nested transaction', async () => { + getDismissSmartAccountSuggestionEnabledMock.mockReturnValue(true); + + const result = await processSendCalls( + sendCallsHooks, + messenger, + { ...SEND_CALLS_MOCK, calls: [{ to: '0x123' }] }, + REQUEST_MOCK, + ); + expect(result.id).toBeDefined(); + }); + + it('does not throw if user enabled preference to dismiss option to upgrade account if already upgraded', async () => { + getDismissSmartAccountSuggestionEnabledMock.mockReturnValue(true); + + isAtomicBatchSupportedMock.mockResolvedValueOnce([ + { + chainId: CHAIN_ID_MOCK, + delegationAddress: DELEGATION_ADDRESS_MOCK, + isSupported: true, + }, + ]); + + expect( + await processSendCalls( + sendCallsHooks, + messenger, + SEND_CALLS_MOCK, + REQUEST_MOCK, + ), + ).toBeDefined(); + }); + + it('throws if top-level capability is required', async () => { + await expect( + processSendCalls( + sendCallsHooks, + messenger, + { + ...SEND_CALLS_MOCK, + capabilities: { + test: {}, + test2: { optional: true }, + test3: { optional: false }, + }, + }, + REQUEST_MOCK, + ), + ).rejects.toThrow('Unsupported non-optional capabilities: test, test3'); + }); + + it('throws if top-level capability is required for single nested transaction', async () => { + await expect( + processSendCalls( + sendCallsHooks, + messenger, + { + ...SEND_CALLS_MOCK, + calls: [{ to: '0x123' }], + capabilities: { + test: {}, + test2: { optional: true }, + test3: { optional: false }, + }, + }, + REQUEST_MOCK, + ), + ).rejects.toThrow('Unsupported non-optional capabilities: test, test3'); + }); + + it('throws if call capability is required', async () => { + await expect( + processSendCalls( + sendCallsHooks, + messenger, + { + ...SEND_CALLS_MOCK, + calls: [ + ...SEND_CALLS_MOCK.calls, + { + ...SEND_CALLS_MOCK.calls[0], + capabilities: { + test: {}, + test2: { optional: true }, + test3: { optional: false }, + }, + }, + ], + }, + REQUEST_MOCK, + ), + ).rejects.toThrow('Unsupported non-optional capabilities: test, test3'); + }); + + it('throws if chain does not support EIP-7702', async () => { + isAtomicBatchSupportedMock.mockResolvedValueOnce([]); + + await expect( + processSendCalls( + sendCallsHooks, + messenger, + SEND_CALLS_MOCK, + REQUEST_MOCK, + ), + ).rejects.toThrow(`EIP-7702 not supported on chain: ${CHAIN_ID_MOCK}`); + }); + + it('throws if keyring type not supported', async () => { + await expect( + processSendCalls( + sendCallsHooks, + messenger, + { ...SEND_CALLS_MOCK, from: FROM_MOCK_HARDWARE }, + REQUEST_MOCK, + ), + ).rejects.toThrow(`EIP-7702 upgrade not supported on account`); + }); + + it('throws if keyring type not found', async () => { + await expect( + processSendCalls( + sendCallsHooks, + messenger, + { ...SEND_CALLS_MOCK, from: '0x456' }, + REQUEST_MOCK, + ), + ).rejects.toThrow( + `EIP-7702 upgrade not supported as account type is unknown`, + ); + }); + + it('throws if no `from` param is provided and no accounts are returned by `getPermittedAccountsForOrigin`', async () => { + getPermittedAccountsForOriginMock.mockResolvedValueOnce([]); + + await expect( + processSendCalls( + sendCallsHooks, + messenger, + { ...SEND_CALLS_MOCK, from: undefined }, + REQUEST_MOCK, + ), + ).rejects.toThrow(providerErrors.unauthorized()); + }); + + it('validates auxiliary funds with unsupported account type', async () => { + await expect( + processSendCalls( + sendCallsHooks, + messenger, + { + ...SEND_CALLS_MOCK, + from: FROM_MOCK_HARDWARE, + capabilities: { + auxiliaryFunds: { + optional: false, + requiredAssets: [ + { + address: '0x123', + amount: '0x2', + standard: 'erc20', + }, + { + address: '0x123', + amount: '0x2', + standard: 'erc20', + }, + ], + }, + }, + }, + REQUEST_MOCK, + ), + ).rejects.toThrow( + `Unsupported non-optional capability: ${SupportedCapabilities.AuxiliaryFunds}`, + ); + }); + + it('validates auxiliary funds with unsupported chain', async () => { + isAuxiliaryFundsSupportedMock.mockReturnValue(false); + + await expect( + processSendCalls( + sendCallsHooks, + messenger, + { + ...SEND_CALLS_MOCK, + capabilities: { + auxiliaryFunds: { + optional: false, + requiredAssets: [ + { + address: '0x123' as Hex, + amount: '0x1' as Hex, + standard: 'erc20', + }, + ], + }, + }, + }, + REQUEST_MOCK, + ), + ).rejects.toThrow( + `The wallet no longer supports auxiliary funds on the requested chain: ${CHAIN_ID_MOCK}`, + ); + }); + + it('validates auxiliary funds with unsupported token standard', async () => { + await expect( + processSendCalls( + sendCallsHooks, + messenger, + { + ...SEND_CALLS_MOCK, + capabilities: { + auxiliaryFunds: { + optional: false, + requiredAssets: [ + { + address: '0x123', + amount: '0x1', + standard: 'erc777', + }, + ], + }, + }, + }, + REQUEST_MOCK, + ), + ).rejects.toThrow( + /The requested asset 0x123 is not available through the wallet.*s auxiliary fund system: unsupported token standard erc777/u, + ); + }); + + it('validates call-level auxiliary funds with unsupported token standard', async () => { + await expect( + processSendCalls( + sendCallsHooks, + messenger, + { + ...SEND_CALLS_MOCK, + calls: [ + { + to: '0x123', + capabilities: { + auxiliaryFunds: { + optional: false, + requiredAssets: [ + { + address: '0x456', + amount: '0x1', + standard: 'erc777', + }, + ], + }, + }, + }, + ], + }, + REQUEST_MOCK, + ), + ).rejects.toThrow( + /The requested asset 0x456 is not available through the wallet.*s auxiliary fund system: unsupported token standard erc777/u, + ); + }); + + it('validates auxiliary funds with valid ERC-20 asset', async () => { + const result = await processSendCalls( + sendCallsHooks, + messenger, + { + ...SEND_CALLS_MOCK, + capabilities: { + auxiliaryFunds: { + optional: true, + requiredAssets: [ + { + address: '0x123', + amount: '0x1', + standard: 'erc20', + }, + ], + }, + }, + }, + REQUEST_MOCK, + ); + + expect(result).toBeDefined(); + }); + + it('validates auxiliary funds with no requiredAssets', async () => { + const result = await processSendCalls( + sendCallsHooks, + messenger, + { + ...SEND_CALLS_MOCK, + capabilities: { + auxiliaryFunds: { + optional: true, + }, + }, + }, + REQUEST_MOCK, + ); + + expect(result).toBeDefined(); + }); + + it('validates auxiliary funds with optional false and no requiredAssets', async () => { + const result = await processSendCalls( + sendCallsHooks, + messenger, + { + ...SEND_CALLS_MOCK, + capabilities: { + auxiliaryFunds: { + optional: false, + }, + }, + }, + REQUEST_MOCK, + ); + + expect(result).toBeDefined(); + }); + + it('deduplicates auxiliary funds requiredAssets by address and standard, summing amounts', async () => { + const payload: SendCallsPayload = { + ...SEND_CALLS_MOCK, + capabilities: { + auxiliaryFunds: { + optional: true, + requiredAssets: [ + { + address: '0x123' as Hex, + amount: '0x2' as Hex, + standard: 'erc20', + }, + { + address: '0x123' as Hex, + amount: '0x3' as Hex, + standard: 'erc20', + }, + ], + }, + }, + }; + + const result = await processSendCalls( + sendCallsHooks, + messenger, + payload, + REQUEST_MOCK, + ); + + expect(result).toBeDefined(); + expect(addTransactionBatchMock).toHaveBeenCalledWith( + expect.objectContaining({ + requiredAssets: [ + expect.objectContaining({ + amount: '0x5', + address: '0x123', + standard: 'erc20', + }), + ], + }), + ); + }); + + it('passes requiredAssets to addTransactionBatch', async () => { + const requiredAssets = [ + { + address: '0x123' as Hex, + amount: '0x1' as Hex, + standard: 'erc20', + }, + ]; + + await processSendCalls( + sendCallsHooks, + messenger, + { + ...SEND_CALLS_MOCK, + capabilities: { + auxiliaryFunds: { + optional: true, + requiredAssets, + }, + }, + }, + REQUEST_MOCK, + ); + + expect(addTransactionBatchMock).toHaveBeenCalledWith( + expect.objectContaining({ + requiredAssets, + }), + ); + }); + + it('passes requiredAssets to addTransaction for single call', async () => { + const requiredAssets = [ + { + address: '0x456' as Hex, + amount: '0x2' as Hex, + standard: 'erc20', + }, + ]; + + await processSendCalls( + sendCallsHooks, + messenger, + { + ...SEND_CALLS_MOCK, + calls: [{ to: '0x123' }], + capabilities: { + auxiliaryFunds: { + optional: true, + requiredAssets, + }, + }, + }, + REQUEST_MOCK, + ); + + expect(addTransactionMock).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + requiredAssets, + }), + ); + }); + + it('passes undefined requiredAssets when no auxiliaryFunds capability', async () => { + await processSendCalls( + sendCallsHooks, + messenger, + SEND_CALLS_MOCK, + REQUEST_MOCK, + ); + + expect(addTransactionBatchMock).toHaveBeenCalledWith( + expect.objectContaining({ + requiredAssets: undefined, + }), + ); + }); + + it('collects and deduplicates requiredAssets from individual call capabilities', async () => { + await processSendCalls( + sendCallsHooks, + messenger, + { + ...SEND_CALLS_MOCK, + calls: [ + { + to: '0x123', + capabilities: { + auxiliaryFunds: { + optional: true, + requiredAssets: [ + { + address: '0xAAA' as Hex, + amount: '0x1' as Hex, + standard: 'erc20', + }, + ], + }, + }, + }, + { + to: '0x456', + capabilities: { + auxiliaryFunds: { + optional: true, + requiredAssets: [ + { + address: '0xAAA' as Hex, + amount: '0x2' as Hex, + standard: 'erc20', + }, + ], + }, + }, + }, + ], + }, + REQUEST_MOCK, + ); + + expect(addTransactionBatchMock).toHaveBeenCalledWith( + expect.objectContaining({ + requiredAssets: [ + expect.objectContaining({ + address: '0xAAA', + amount: '0x3', + standard: 'erc20', + }), + ], + }), + ); + }); + + it('combines requiredAssets from top-level and call capabilities', async () => { + await processSendCalls( + sendCallsHooks, + messenger, + { + ...SEND_CALLS_MOCK, + capabilities: { + auxiliaryFunds: { + optional: true, + requiredAssets: [ + { + address: '0xBBB' as Hex, + amount: '0x5' as Hex, + standard: 'erc20', + }, + ], + }, + }, + calls: [ + { + to: '0x123', + capabilities: { + auxiliaryFunds: { + optional: true, + requiredAssets: [ + { + address: '0xBBB' as Hex, + amount: '0x3' as Hex, + standard: 'erc20', + }, + ], + }, + }, + }, + { to: '0x456' }, + ], + }, + REQUEST_MOCK, + ); + + expect(addTransactionBatchMock).toHaveBeenCalledWith( + expect.objectContaining({ + requiredAssets: [ + expect.objectContaining({ + address: '0xBBB', + amount: '0x8', + standard: 'erc20', + }), + ], + }), + ); + }); + }); +}); diff --git a/packages/eip-5792-middleware/src/hooks/processSendCalls.ts b/packages/eip-5792-middleware/src/hooks/processSendCalls.ts new file mode 100644 index 00000000000..9e6d59720f5 --- /dev/null +++ b/packages/eip-5792-middleware/src/hooks/processSendCalls.ts @@ -0,0 +1,648 @@ +import type { KeyringTypes } from '@metamask/keyring-controller'; +import { JsonRpcError, providerErrors, rpcErrors } from '@metamask/rpc-errors'; +import type { + BatchTransactionParams, + IsAtomicBatchSupportedResultEntry, + RequiredAsset, + SecurityAlertResponse, + TransactionController, + ValidateSecurityRequest, +} from '@metamask/transaction-controller'; +import { TransactionEnvelopeType } from '@metamask/transaction-controller'; +import type { Hex, JsonRpcRequest } from '@metamask/utils'; +import { add0x, bytesToHex } from '@metamask/utils'; +import { groupBy } from 'lodash'; +import { parse, v4 as uuid } from 'uuid'; + +import { + EIP5792ErrorCode, + EIP7682ErrorCode, + KEYRING_TYPES_SUPPORTING_7702, + MessageType, + SupportedCapabilities, + VERSION, +} from '../constants.js'; +import type { + EIP5792Messenger, + SendCallsPayload, + SendCallsRequiredAssetsParam, + SendCallsResult, +} from '../types.js'; +import { getAccountKeyringType } from '../utils.js'; + +/** + * Type definition for required controller hooks and utilities of {@link processSendCalls} + */ +export type ProcessSendCallsHooks = { + /** Function to add a batch of transactions atomically */ + addTransactionBatch: TransactionController['addTransactionBatch']; + /** Function to add a single transaction */ + addTransaction: TransactionController['addTransaction']; + /** Function to check if smart account suggestions are disabled */ + getDismissSmartAccountSuggestionEnabled: () => boolean; + /** Function to check if atomic batching is supported for given parameters */ + isAtomicBatchSupported: TransactionController['isAtomicBatchSupported']; + /** Function to validate security for transaction requests */ + validateSecurity: ( + securityAlertId: string, + request: ValidateSecurityRequest, + chainId: Hex, + ) => Promise; + getPermittedAccountsForOrigin: () => Promise; + /** Function to validate if auxiliary funds capability is supported. */ + isAuxiliaryFundsSupported: (chainId: Hex) => boolean; +}; + +/** + * A valid JSON-RPC request object for `wallet_sendCalls`. + */ +export type ProcessSendCallsRequest = JsonRpcRequest & { + /** The identifier for the network client that has been created for this RPC endpoint */ + networkClientId: string; + /** The origin of the RPC request */ + origin?: string; +}; + +/** + * Processes a sendCalls request for EIP-5792 transactions. + * + * @param hooks - Object containing required controller hooks and utilities. + * @param messenger - Messenger instance for controller communication. + * @param params - The sendCalls parameters containing transaction calls and metadata. + * @param req - The original JSON-RPC request. + * @returns Promise resolving to a SendCallsResult containing the batch ID. + */ +export async function processSendCalls( + hooks: ProcessSendCallsHooks, + messenger: EIP5792Messenger, + params: SendCallsPayload, + req: ProcessSendCallsRequest, +): Promise { + const { + addTransactionBatch, + addTransaction, + getDismissSmartAccountSuggestionEnabled, + isAtomicBatchSupported, + validateSecurity: validateSecurityHook, + getPermittedAccountsForOrigin, + isAuxiliaryFundsSupported, + } = hooks; + + const { calls, from: paramFrom } = params; + const { networkClientId, origin } = req; + const transactions = calls.map((call) => ({ params: call })); + + const { chainId } = messenger.call( + 'NetworkController:getNetworkClientById', + networkClientId, + ).configuration; + + // The first account returned by `getPermittedAccountsForOrigin` is the selected account for the origin + const [selectedAccount] = await getPermittedAccountsForOrigin(); + const from = paramFrom ?? selectedAccount; + + if (!from) { + throw providerErrors.unauthorized(); + } + + const securityAlertId = uuid(); + const validateSecurity = validateSecurityHook.bind(null, securityAlertId); + + const requestId = req.id ? String(req.id) : ''; + + let batchId: Hex; + if (Object.keys(transactions).length === 1) { + batchId = await processSingleTransaction({ + addTransaction, + chainId, + from, + messenger, + networkClientId, + origin, + requestId, + securityAlertId, + sendCalls: params, + transactions, + validateSecurity, + isAuxiliaryFundsSupported, + }); + } else { + batchId = await processMultipleTransaction({ + addTransactionBatch, + isAtomicBatchSupported, + chainId, + from, + getDismissSmartAccountSuggestionEnabled, + messenger, + networkClientId, + origin, + requestId, + sendCalls: params, + securityAlertId, + transactions, + validateSecurity, + isAuxiliaryFundsSupported, + }); + } + + return { id: batchId }; +} + +/** + * Processes a single transaction from a sendCalls request. + * + * @param params - Object containing all parameters needed for single transaction processing. + * @param params.addTransaction - Function to add a single transaction. + * @param params.chainId - The chain ID for the transaction. + * @param params.from - The sender address. + * @param params.messenger - Messenger instance for controller communication. + * @param params.networkClientId - The network client ID. + * @param params.origin - The origin of the request (optional). + * @param params.requestId - Unique requestId of the JSON-RPC request from DAPP. + * @param params.securityAlertId - The security alert ID for this transaction. + * @param params.sendCalls - The original sendCalls request. + * @param params.transactions - Array containing the single transaction. + * @param params.validateSecurity - Function to validate security for the transaction. + * @param params.isAuxiliaryFundsSupported - Function to validate if auxiliary funds capability is supported. + * @returns Promise resolving to the generated batch ID for the transaction. + */ +async function processSingleTransaction({ + addTransaction, + chainId, + from, + messenger, + networkClientId, + origin, + requestId, + securityAlertId, + sendCalls, + transactions, + validateSecurity, + isAuxiliaryFundsSupported, +}: { + addTransaction: TransactionController['addTransaction']; + chainId: Hex; + from: Hex; + messenger: EIP5792Messenger; + networkClientId: string; + origin?: string; + requestId?: string; + securityAlertId: string; + sendCalls: SendCallsPayload; + transactions: { params: BatchTransactionParams }[]; + validateSecurity: ( + securityRequest: ValidateSecurityRequest, + chainId: Hex, + ) => void; + isAuxiliaryFundsSupported: (chainId: Hex) => boolean; +}) { + const keyringType = getAccountKeyringType(from, messenger); + + validateSingleSendCall( + sendCalls, + chainId, + keyringType, + isAuxiliaryFundsSupported, + ); + + const txParams = { + from, + ...transactions[0].params, + type: TransactionEnvelopeType.feeMarket, + }; + + const securityRequest: ValidateSecurityRequest = { + method: MessageType.SendTransaction, + params: [txParams], + origin, + }; + validateSecurity(securityRequest, chainId); + + const requiredAssets = dedupeAuxiliaryFundsRequiredAssets(sendCalls); + + const batchId = generateBatchId(); + + await addTransaction(txParams, { + batchId, + networkClientId, + origin, + requestId, + requiredAssets, + securityAlertResponse: { securityAlertId } as SecurityAlertResponse, + }); + return batchId; +} + +/** + * Processes multiple transactions from a sendCalls request as an atomic batch. + * + * @param params - Object containing all parameters needed for multiple transaction processing. + * @param params.addTransactionBatch - Function to add a batch of transactions atomically. + * @param params.isAtomicBatchSupported - Function to check if atomic batching is supported. + * @param params.chainId - The chain ID for the transactions. + * @param params.from - The sender address. + * @param params.getDismissSmartAccountSuggestionEnabled - Function to check if smart account suggestions are disabled. + * @param params.networkClientId - The network client ID. + * @param params.messenger - Messenger instance for controller communication. + * @param params.origin - The origin of the request (optional). + * @param params.requestId - Unique requestId of the JSON-RPC request from DAPP. + * @param params.sendCalls - The original sendCalls request. + * @param params.securityAlertId - The security alert ID for this batch. + * @param params.transactions - Array of transactions to process. + * @param params.validateSecurity - Function to validate security for the transactions. + * @param params.isAuxiliaryFundsSupported - Function to validate if auxiliary funds capability is supported. + * @returns Promise resolving to the generated batch ID for the transaction batch. + */ +async function processMultipleTransaction({ + addTransactionBatch, + isAtomicBatchSupported, + chainId, + from, + getDismissSmartAccountSuggestionEnabled, + networkClientId, + messenger, + origin, + requestId, + sendCalls, + securityAlertId, + transactions, + validateSecurity, + isAuxiliaryFundsSupported, +}: { + addTransactionBatch: TransactionController['addTransactionBatch']; + isAtomicBatchSupported: TransactionController['isAtomicBatchSupported']; + chainId: Hex; + from: Hex; + getDismissSmartAccountSuggestionEnabled: () => boolean; + messenger: EIP5792Messenger; + networkClientId: string; + origin?: string; + requestId?: string; + sendCalls: SendCallsPayload; + securityAlertId: string; + transactions: { params: BatchTransactionParams }[]; + validateSecurity: ( + securityRequest: ValidateSecurityRequest, + chainId: Hex, + ) => Promise; + isAuxiliaryFundsSupported: (chainId: Hex) => boolean; +}) { + const batchSupport = await isAtomicBatchSupported({ + address: from, + chainIds: [chainId], + }); + + const chainBatchSupport = batchSupport?.[0]; + + const keyringType = getAccountKeyringType(from, messenger); + + const dismissSmartAccountSuggestionEnabled = + getDismissSmartAccountSuggestionEnabled(); + + validateSendCalls( + sendCalls, + chainId, + dismissSmartAccountSuggestionEnabled, + chainBatchSupport, + keyringType, + isAuxiliaryFundsSupported, + ); + + const requiredAssets = dedupeAuxiliaryFundsRequiredAssets(sendCalls); + + const result = await addTransactionBatch({ + from, + networkClientId, + origin, + requestId, + requiredAssets, + securityAlertId, + transactions, + validateSecurity, + }); + return result.batchId; +} + +/** + * Generate a transaction batch ID. + * + * @returns A unique batch ID as a hexadecimal string. + */ +function generateBatchId(): Hex { + const idString = uuid(); + const idBytes = new Uint8Array(parse(idString)); + return bytesToHex(idBytes); +} + +/** + * Validates a single sendCalls request. + * + * @param sendCalls - The sendCalls request to validate. + * @param dappChainId - The chain ID that the dApp is connected to. + * @param keyringType - The type of keyring associated with the account. + * @param isAuxiliaryFundsSupported - Function to validate if auxiliary funds capability is supported. + */ +function validateSingleSendCall( + sendCalls: SendCallsPayload, + dappChainId: Hex, + keyringType: KeyringTypes, + isAuxiliaryFundsSupported: (chainId: Hex) => boolean, +) { + validateSendCallsVersion(sendCalls); + validateCapabilities(sendCalls, keyringType, isAuxiliaryFundsSupported); + validateDappChainId(sendCalls, dappChainId); +} + +/** + * Validates a sendCalls request for multiple transactions. + * + * @param sendCalls - The sendCalls request to validate. + * @param dappChainId - The chain ID that the dApp is connected to + * @param dismissSmartAccountSuggestionEnabled - Whether smart account suggestions are disabled. + * @param chainBatchSupport - Information about atomic batch support for the chain. + * @param keyringType - The type of keyring associated with the account. + * @param isAuxiliaryFundsSupported - Function to validate if auxiliary funds capability is supported. + */ +function validateSendCalls( + sendCalls: SendCallsPayload, + dappChainId: Hex, + dismissSmartAccountSuggestionEnabled: boolean, + chainBatchSupport: IsAtomicBatchSupportedResultEntry | undefined, + keyringType: KeyringTypes, + isAuxiliaryFundsSupported: (chainId: Hex) => boolean, +) { + validateSendCallsVersion(sendCalls); + validateSendCallsChainId(sendCalls, dappChainId, chainBatchSupport); + validateCapabilities(sendCalls, keyringType, isAuxiliaryFundsSupported); + validateUpgrade( + dismissSmartAccountSuggestionEnabled, + chainBatchSupport, + keyringType, + ); +} + +/** + * Validates the version of a sendCalls request. + * + * @param sendCalls - The sendCalls request to validate. + * @throws JsonRpcError if the version is not supported. + */ +function validateSendCallsVersion(sendCalls: SendCallsPayload) { + const { version } = sendCalls; + + if (version !== VERSION) { + throw rpcErrors.invalidInput( + `Version not supported: Got ${version}, expected ${VERSION}`, + ); + } +} + +/** + * Validates that the chain ID in the sendCalls request matches the dApp's selected network. + * + * @param sendCalls - The sendCalls request to validate. + * @param dappChainId - The chain ID that the dApp is connected to + * @throws JsonRpcError if the chain IDs don't match + */ +function validateDappChainId(sendCalls: SendCallsPayload, dappChainId: Hex) { + const { chainId: requestChainId } = sendCalls; + + if ( + requestChainId && + requestChainId.toLowerCase() !== dappChainId.toLowerCase() + ) { + throw rpcErrors.invalidParams( + `Chain ID must match the dApp selected network: Got ${requestChainId}, expected ${dappChainId}`, + ); + } +} + +/** + * Validates the chain ID for sendCalls requests with additional EIP-7702 support checks. + * + * @param sendCalls - The sendCalls request to validate. + * @param dappChainId - The chain ID that the dApp is connected to + * @param chainBatchSupport - Information about atomic batch support for the chain + * @throws JsonRpcError if the chain ID doesn't match or EIP-7702 is not supported + */ +function validateSendCallsChainId( + sendCalls: SendCallsPayload, + dappChainId: Hex, + chainBatchSupport: IsAtomicBatchSupportedResultEntry | undefined, +) { + validateDappChainId(sendCalls, dappChainId); + if (!chainBatchSupport) { + throw new JsonRpcError( + EIP5792ErrorCode.UnsupportedChainId, + `EIP-7702 not supported on chain: ${dappChainId}`, + ); + } +} + +/** + * Validates that all required capabilities in the sendCalls request are supported. + * + * @param sendCalls - The sendCalls request to validate. + * @param keyringType - The type of keyring associated with the account. + * @param isAuxiliaryFundsSupported - Function to validate if auxiliary funds capability is supported. + * + * @throws JsonRpcError if unsupported non-optional capabilities are requested. + */ +function validateCapabilities( + sendCalls: SendCallsPayload, + keyringType: KeyringTypes, + isAuxiliaryFundsSupported: (chainId: Hex) => boolean, +) { + const { calls, capabilities, chainId } = sendCalls; + + const requiredTopLevelCapabilities = Object.keys(capabilities ?? {}).filter( + (name) => + // Non optional capabilities other than `auxiliaryFunds` are not supported by the wallet + name !== SupportedCapabilities.AuxiliaryFunds.toString() && + capabilities?.[name].optional !== true, + ); + + const requiredCallCapabilities = calls.flatMap((call) => + Object.keys(call.capabilities ?? {}).filter( + (name) => + name !== SupportedCapabilities.AuxiliaryFunds.toString() && + call.capabilities?.[name].optional !== true, + ), + ); + + const requiredCapabilities = [ + ...requiredTopLevelCapabilities, + ...requiredCallCapabilities, + ]; + + if (requiredCapabilities?.length) { + throw new JsonRpcError( + EIP5792ErrorCode.UnsupportedNonOptionalCapability, + `Unsupported non-optional capabilities: ${requiredCapabilities.join( + ', ', + )}`, + ); + } + + if (capabilities?.auxiliaryFunds) { + validateAuxFundsSupportAndRequiredAssets({ + auxiliaryFunds: capabilities.auxiliaryFunds, + chainId, + keyringType, + isAuxiliaryFundsSupported, + }); + } + + for (const call of calls) { + if (call.capabilities?.auxiliaryFunds) { + validateAuxFundsSupportAndRequiredAssets({ + auxiliaryFunds: call.capabilities.auxiliaryFunds, + chainId, + keyringType, + isAuxiliaryFundsSupported, + }); + } + } +} + +/** + * Validates EIP-7682 optional `requiredAssets` to see if the account and chain are supported, and that param is well-formed. + * + * docs: {@link https://eips.ethereum.org/EIPS/eip-7682#extended-usage-requiredassets-parameter} + * + * @param param - The parameter object. + * @param param.auxiliaryFunds - The auxiliaryFunds param to validate. + * @param param.auxiliaryFunds.optional - Metadata to signal for wallets that support this optional capability, while maintaining compatibility with wallets that do not. + * @param param.auxiliaryFunds.requiredAssets - Metadata that enables a wallets support for `auxiliaryFunds` capability. + * @param param.chainId - The chain ID of the incoming request. + * @param param.keyringType - The type of keyring associated with the account. + * @param param.isAuxiliaryFundsSupported - Function to validate if auxiliary funds capability is supported. + * @throws JsonRpcError if auxiliary funds capability is not supported. + */ +function validateAuxFundsSupportAndRequiredAssets({ + auxiliaryFunds, + chainId, + keyringType, + isAuxiliaryFundsSupported, +}: { + auxiliaryFunds: { + optional?: boolean; + requiredAssets?: SendCallsRequiredAssetsParam[]; + }; + chainId: Hex; + keyringType: KeyringTypes; + isAuxiliaryFundsSupported: (chainId: Hex) => boolean; +}) { + // If we can make use of that capability then we should, but otherwise we can process the request and ignore the capability + // so if the capability is signaled as optional, no validation is required, so we don't block the transaction from happening. + if (auxiliaryFunds.optional) { + return; + } + const isSupportedAccount = + KEYRING_TYPES_SUPPORTING_7702.includes(keyringType); + + if (!isSupportedAccount) { + throw new JsonRpcError( + EIP5792ErrorCode.UnsupportedNonOptionalCapability, + `Unsupported non-optional capability: ${SupportedCapabilities.AuxiliaryFunds}`, + ); + } + + if (!isAuxiliaryFundsSupported(chainId)) { + throw new JsonRpcError( + EIP7682ErrorCode.UnsupportedChain, + `The wallet no longer supports auxiliary funds on the requested chain: ${chainId}`, + ); + } + + if (!auxiliaryFunds?.requiredAssets) { + return; + } + + for (const asset of auxiliaryFunds.requiredAssets) { + if (asset.standard !== 'erc20') { + throw new JsonRpcError( + EIP7682ErrorCode.UnsupportedAsset, + `The requested asset ${asset.address} is not available through the wallet’s auxiliary fund system: unsupported token standard ${asset.standard}`, + ); + } + } +} + +/** + * Validates whether an EIP-7702 upgrade is allowed for the given parameters. + * + * @param dismissSmartAccountSuggestionEnabled - Whether smart account suggestions are disabled. + * @param chainBatchSupport - Information about atomic batch support for the chain. + * @param keyringType - The type of keyring associated with the account. + * @throws JsonRpcError if the upgrade is rejected due to user settings or account type. + */ +function validateUpgrade( + dismissSmartAccountSuggestionEnabled: boolean, + chainBatchSupport: IsAtomicBatchSupportedResultEntry | undefined, + keyringType: KeyringTypes, +) { + if (chainBatchSupport?.delegationAddress) { + return; + } + + if (dismissSmartAccountSuggestionEnabled) { + throw new JsonRpcError( + EIP5792ErrorCode.RejectedUpgrade, + 'EIP-7702 upgrade disabled by the user', + ); + } + + if (!KEYRING_TYPES_SUPPORTING_7702.includes(keyringType)) { + throw new JsonRpcError( + EIP5792ErrorCode.RejectedUpgrade, + 'EIP-7702 upgrade not supported on account', + ); + } +} + +/** + * Collects and deduplicates `auxiliaryFunds` capability `requiredAssets` from + * both top-level capabilities and individual call capabilities. + * + * @param sendCalls - The original sendCalls request. + * @returns The deduplicated required assets array, or undefined if none exist. + */ +function dedupeAuxiliaryFundsRequiredAssets( + sendCalls: SendCallsPayload, +): RequiredAsset[] | undefined { + const rootRequiredAssets = + sendCalls.capabilities?.auxiliaryFunds?.requiredAssets ?? []; + + const callRequiredAssets = sendCalls.calls.flatMap( + (call) => call.capabilities?.auxiliaryFunds?.requiredAssets ?? [], + ); + + const allRequiredAssets = [...rootRequiredAssets, ...callRequiredAssets]; + + if (allRequiredAssets.length === 0) { + return undefined; + } + + const grouped = groupBy( + allRequiredAssets, + (asset) => `${asset.address.toLowerCase()}-${asset.standard}`, + ); + + const deduplicatedAssets = Object.values(grouped).map((group) => { + if (group.length === 1) { + return group[0]; + } + + const totalAmount = group.reduce((sum, asset) => { + return sum + BigInt(asset.amount); + }, 0n); + + return { + ...group[0], + amount: add0x(totalAmount.toString(16)), + }; + }); + + return deduplicatedAssets; +} diff --git a/packages/eip-5792-middleware/src/index.test.ts b/packages/eip-5792-middleware/src/index.test.ts new file mode 100644 index 00000000000..b6e50d854ff --- /dev/null +++ b/packages/eip-5792-middleware/src/index.test.ts @@ -0,0 +1,16 @@ +import * as allExports from './index.js'; + +describe('@metamask/eip-5792-middleware', () => { + it('has expected JavaScript exports', () => { + expect(Object.keys(allExports)).toMatchInlineSnapshot(` + [ + "processSendCalls", + "getCallsStatus", + "getCapabilities", + "walletSendCalls", + "walletGetCallsStatus", + "walletGetCapabilities", + ] + `); + }); +}); diff --git a/packages/eip-5792-middleware/src/index.ts b/packages/eip-5792-middleware/src/index.ts new file mode 100644 index 00000000000..bf283cbd37f --- /dev/null +++ b/packages/eip-5792-middleware/src/index.ts @@ -0,0 +1,27 @@ +export type { + ProcessSendCallsRequest, + ProcessSendCallsHooks, +} from './hooks/processSendCalls.js'; +export { processSendCalls } from './hooks/processSendCalls.js'; +export { getCallsStatus } from './hooks/getCallsStatus.js'; +export { + getCapabilities, + type GetCapabilitiesHooks, +} from './hooks/getCapabilities.js'; +export { walletSendCalls } from './methods/wallet_sendCalls.js'; +export { walletGetCallsStatus } from './methods/wallet_getCallsStatus.js'; +export { walletGetCapabilities } from './methods/wallet_getCapabilities.js'; +export type { EIP5792Messenger } from './types.js'; + +export type { + GetCallsStatusHook, + GetCallsStatusParams, + GetCallsStatusResult, + GetCapabilitiesHook, + GetCapabilitiesParams, + GetCapabilitiesResult, + ProcessSendCallsHook, + SendCallsPayload as SendCalls, + SendCallsParams, + SendCallsResult, +} from './types.js'; diff --git a/packages/eip-5792-middleware/src/methods/wallet_getCallsStatus.test.ts b/packages/eip-5792-middleware/src/methods/wallet_getCallsStatus.test.ts new file mode 100644 index 00000000000..ae9b2c8c8d2 --- /dev/null +++ b/packages/eip-5792-middleware/src/methods/wallet_getCallsStatus.test.ts @@ -0,0 +1,126 @@ +import type { + Hex, + JsonRpcRequest, + PendingJsonRpcResponse, +} from '@metamask/utils'; +import { klona } from 'klona'; + +import type { + GetCallsStatusHook, + GetCallsStatusParams, + GetCallsStatusResult, +} from '../types.js'; +import { walletGetCallsStatus } from './wallet_getCallsStatus.js'; + +const ID_MOCK = '0x12345678'; + +const RECEIPT_MOCK = { + logs: [ + { + address: '0x123abc123abc123abc123abc123abc123abc123a', + data: '0x123abc', + topics: ['0x123abc'], + }, + ], + status: '0x1', + chainId: '0x1', + blockHash: '0x123abc', + blockNumber: '0x1', + gasUsed: '0x1', + transactionHash: '0x123abc', +}; + +const REQUEST_MOCK = { + params: [ID_MOCK], +} as unknown as JsonRpcRequest; + +const RESULT_MOCK = { + version: '1.0', + id: ID_MOCK, + chainId: '0x1', + status: 1, + receipts: [RECEIPT_MOCK, RECEIPT_MOCK], +}; + +describe('wallet_getCallsStatus', () => { + let request: JsonRpcRequest; + let params: GetCallsStatusParams; + let response: PendingJsonRpcResponse; + let getCallsStatusMock: jest.MockedFunction; + + /** + * + * @returns s + */ + async function callMethod() { + return walletGetCallsStatus(request, response, { + getCallsStatus: getCallsStatusMock, + }); + } + + beforeEach(() => { + jest.resetAllMocks(); + + request = klona(REQUEST_MOCK); + params = request.params as GetCallsStatusParams; + response = {} as PendingJsonRpcResponse; + + getCallsStatusMock = jest.fn().mockResolvedValue(RESULT_MOCK); + }); + + it('calls hook', async () => { + await callMethod(); + expect(getCallsStatusMock).toHaveBeenCalledWith(params[0], request); + }); + + it('returns result from hook', async () => { + await callMethod(); + expect(response.result).toStrictEqual(RESULT_MOCK); + }); + + it('throws if no hook', async () => { + await expect( + walletGetCallsStatus(request, response, {}), + ).rejects.toMatchInlineSnapshot(`[Error: Method not supported.]`); + }); + + it('throws if no params', async () => { + request.params = undefined; + + await expect(callMethod()).rejects.toMatchInlineSnapshot(` + [Error: Invalid params + + Expected an array, but received: undefined] + `); + }); + + it('throws if wrong type', async () => { + params[0] = 123 as never; + + await expect(callMethod()).rejects.toMatchInlineSnapshot(` + [Error: Invalid params + + 0 - Expected a string, but received: 123] + `); + }); + + it('throws if address is not hex', async () => { + params[0] = '123' as Hex; + + await expect(callMethod()).rejects.toMatchInlineSnapshot(` + [Error: Invalid params + + 0 - Expected a string matching \`/^0x[0-9a-f]+$/\` but received "123"] + `); + }); + + it('throws if address is empty', async () => { + params[0] = '' as never; + + await expect(callMethod()).rejects.toMatchInlineSnapshot(` + [Error: Invalid params + + 0 - Expected a string matching \`/^0x[0-9a-f]+$/\` but received ""] + `); + }); +}); diff --git a/packages/eip-5792-middleware/src/methods/wallet_getCallsStatus.ts b/packages/eip-5792-middleware/src/methods/wallet_getCallsStatus.ts new file mode 100644 index 00000000000..e3a3bf60a2a --- /dev/null +++ b/packages/eip-5792-middleware/src/methods/wallet_getCallsStatus.ts @@ -0,0 +1,34 @@ +import { rpcErrors } from '@metamask/rpc-errors'; +import type { JsonRpcRequest, PendingJsonRpcResponse } from '@metamask/utils'; + +import { GetCallsStatusStruct } from '../types.js'; +import type { GetCallsStatusHook } from '../types.js'; +import { validateParams } from '../utils.js'; + +/** + * The RPC method handler middleware for `wallet_getCallStatus` + * + * @param req - The JSON RPC request's end callback. + * @param res - The JSON RPC request's pending response object. + * @param hooks - The hooks object. + * @param hooks.getCallsStatus - Function that retrieves the status of a transaction batch by its ID. + */ +export async function walletGetCallsStatus( + req: JsonRpcRequest, + res: PendingJsonRpcResponse, + { + getCallsStatus, + }: { + getCallsStatus?: GetCallsStatusHook; + }, +): Promise { + if (!getCallsStatus) { + throw rpcErrors.methodNotSupported(); + } + + validateParams(req.params, GetCallsStatusStruct); + + const id = req.params[0]; + + res.result = await getCallsStatus(id, req); +} diff --git a/packages/eip-5792-middleware/src/methods/wallet_getCapabilities.test.ts b/packages/eip-5792-middleware/src/methods/wallet_getCapabilities.test.ts new file mode 100644 index 00000000000..01e7cd10655 --- /dev/null +++ b/packages/eip-5792-middleware/src/methods/wallet_getCapabilities.test.ts @@ -0,0 +1,141 @@ +import type { JsonRpcRequest, PendingJsonRpcResponse } from '@metamask/utils'; +import { klona } from 'klona'; + +import type { + GetCapabilitiesHook, + GetCapabilitiesParams, + GetCapabilitiesResult, +} from '../types.js'; +import { walletGetCapabilities } from './wallet_getCapabilities.js'; + +type GetPermittedAccountsForOrigin = () => Promise; + +const ADDRESS_MOCK = '0x123abc123abc123abc123abc123abc123abc123a'; +const CHAIN_ID_MOCK = '0x1'; +const CHAIN_ID_2_MOCK = '0x2'; +const ORIGIN_MOCK = 'https://example.com'; + +const RESULT_MOCK = { + testCapability: { + testKey: 'testValue', + }, +}; + +const REQUEST_MOCK = { + origin: ORIGIN_MOCK, + params: [ADDRESS_MOCK], +}; + +describe('wallet_getCapabilities', () => { + let request: JsonRpcRequest & { origin: string }; + let params: GetCapabilitiesParams; + let response: PendingJsonRpcResponse; + let getPermittedAccountsForOriginMock: jest.MockedFn; + let getCapabilitiesMock: jest.MockedFunction; + + /** + * + * @returns a + */ + async function callMethod() { + return walletGetCapabilities(request, response, { + getPermittedAccountsForOrigin: getPermittedAccountsForOriginMock, + getCapabilities: getCapabilitiesMock, + }); + } + + beforeEach(() => { + jest.resetAllMocks(); + + request = klona(REQUEST_MOCK) as JsonRpcRequest & { origin: string }; + params = request.params as GetCapabilitiesParams; + response = {} as PendingJsonRpcResponse; + + getPermittedAccountsForOriginMock = jest + .fn() + .mockResolvedValue([ADDRESS_MOCK]); + getCapabilitiesMock = jest.fn().mockResolvedValue(RESULT_MOCK); + }); + + it('calls hook', async () => { + await callMethod(); + expect(getCapabilitiesMock).toHaveBeenCalledWith( + params[0], + undefined, + request, + ); + }); + + it('calls hook with chain IDs', async () => { + request.params = [ADDRESS_MOCK, [CHAIN_ID_MOCK, CHAIN_ID_2_MOCK]]; + + await callMethod(); + + expect(getCapabilitiesMock).toHaveBeenCalledWith( + params[0], + [CHAIN_ID_MOCK, CHAIN_ID_2_MOCK], + request, + ); + }); + + it('returns capabilities from hook', async () => { + await callMethod(); + expect(response.result).toStrictEqual(RESULT_MOCK); + }); + + it('throws if no hook', async () => { + await expect( + walletGetCapabilities(request, response, { + getPermittedAccountsForOrigin: getPermittedAccountsForOriginMock, + }), + ).rejects.toMatchInlineSnapshot(`[Error: Method not supported.]`); + }); + + it('throws if no params', async () => { + request.params = undefined; + + await expect(callMethod()).rejects.toMatchInlineSnapshot(` + [Error: Invalid params + + Expected an array, but received: undefined] + `); + }); + + it('throws if wrong type', async () => { + params[0] = 123 as never; + + await expect(callMethod()).rejects.toMatchInlineSnapshot(` + [Error: Invalid params + + 0 - Expected a string, but received: 123] + `); + }); + + it('throws if not hex', async () => { + params[0] = 'test' as never; + + await expect(callMethod()).rejects.toMatchInlineSnapshot(` + [Error: Invalid params + + 0 - Expected a string matching \`/^0x[0-9a-fA-F]{40}$/\` but received "test"] + `); + }); + + it('throws if wrong length', async () => { + params[0] = '0x123' as never; + + await expect(callMethod()).rejects.toMatchInlineSnapshot(` + [Error: Invalid params + + 0 - Expected a string matching \`/^0x[0-9a-fA-F]{40}$/\` but received "0x123"] + `); + }); + + it('throws if from is not in accounts', async () => { + getPermittedAccountsForOriginMock.mockResolvedValueOnce([]); + + await expect(callMethod()).rejects.toMatchInlineSnapshot( + `[Error: The requested account and/or method has not been authorized by the user.]`, + ); + }); +}); diff --git a/packages/eip-5792-middleware/src/methods/wallet_getCapabilities.ts b/packages/eip-5792-middleware/src/methods/wallet_getCapabilities.ts new file mode 100644 index 00000000000..989da9965ff --- /dev/null +++ b/packages/eip-5792-middleware/src/methods/wallet_getCapabilities.ts @@ -0,0 +1,44 @@ +import { rpcErrors } from '@metamask/rpc-errors'; +import type { JsonRpcRequest, PendingJsonRpcResponse } from '@metamask/utils'; + +import { GetCapabilitiesStruct } from '../types.js'; +import type { GetCapabilitiesHook } from '../types.js'; +import { validateAndNormalizeKeyholder, validateParams } from '../utils.js'; + +/** + * The RPC method handler middleware for `wallet_getCapabilities` + * + * @param req - The JSON RPC request's end callback. + * @param res - The JSON RPC request's pending response object. + * @param hooks - The hooks object. + * @param hooks.getPermittedAccountsForOrigin - Function that retrieves permitted accounts for the requester's origin. + * @param hooks.getCapabilities - Function that retrieves the capabilities for atomic transactions on specified chains. + */ +export async function walletGetCapabilities( + req: JsonRpcRequest & { origin: string }, + res: PendingJsonRpcResponse, + { + getPermittedAccountsForOrigin, + getCapabilities, + }: { + getPermittedAccountsForOrigin: () => Promise; + getCapabilities?: GetCapabilitiesHook; + }, +): Promise { + if (!getCapabilities) { + throw rpcErrors.methodNotSupported(); + } + + validateParams(req.params, GetCapabilitiesStruct); + + const address = req.params[0]; + const chainIds = req.params[1]; + + await validateAndNormalizeKeyholder(address, { + getPermittedAccountsForOrigin, + }); + + const capabilities = await getCapabilities(address, chainIds, req); + + res.result = capabilities; +} diff --git a/packages/eip-5792-middleware/src/methods/wallet_sendCalls.test.ts b/packages/eip-5792-middleware/src/methods/wallet_sendCalls.test.ts new file mode 100644 index 00000000000..0b34d1b25fc --- /dev/null +++ b/packages/eip-5792-middleware/src/methods/wallet_sendCalls.test.ts @@ -0,0 +1,218 @@ +import type { JsonRpcRequest, PendingJsonRpcResponse } from '@metamask/utils'; +import { klona } from 'klona'; + +import type { + ProcessSendCallsHook, + SendCallsPayload, + SendCallsParams, +} from '../types.js'; +import { walletSendCalls } from './wallet_sendCalls.js'; + +type GetPermittedAccountsForOrigin = () => Promise; + +const ADDRESS_MOCK = '0x123abc123abc123abc123abc123abc123abc123a'; +const HEX_MOCK = '0x123abc'; +const ID_MOCK = '0x12345678'; +const ORIGIN_MOCK = 'https://example.com'; + +const REQUEST_MOCK = { + origin: ORIGIN_MOCK, + params: [ + { + version: '1.0', + from: ADDRESS_MOCK, + chainId: HEX_MOCK, + atomicRequired: true, + calls: [ + { + to: ADDRESS_MOCK, + data: HEX_MOCK, + value: HEX_MOCK, + }, + ], + }, + ], +} as unknown as JsonRpcRequest & { origin: string }; + +describe('wallet_sendCalls', () => { + let request: JsonRpcRequest & { origin: string }; + let params: SendCallsParams; + let response: PendingJsonRpcResponse; + let getPermittedAccountsForOriginMock: jest.MockedFn; + let processSendCallsMock: jest.MockedFunction; + + /** + * + * @returns a + */ + async function callMethod() { + return walletSendCalls(request, response, { + getPermittedAccountsForOrigin: getPermittedAccountsForOriginMock, + processSendCalls: processSendCallsMock, + }); + } + + beforeEach(() => { + jest.resetAllMocks(); + + request = klona(REQUEST_MOCK); + params = request.params as SendCallsParams; + response = {} as PendingJsonRpcResponse; + + getPermittedAccountsForOriginMock = jest.fn(); + processSendCallsMock = jest.fn(); + + getPermittedAccountsForOriginMock.mockResolvedValue([ADDRESS_MOCK]); + + processSendCallsMock.mockResolvedValue({ + id: ID_MOCK, + }); + }); + + it('calls hook', async () => { + await callMethod(); + expect(processSendCallsMock).toHaveBeenCalledWith(params[0], request); + }); + + it('returns ID from hook', async () => { + await callMethod(); + expect(response.result).toStrictEqual({ id: ID_MOCK }); + }); + + it('supports top-level capabilities', async () => { + params[0].capabilities = { + 'test-capability': { test: 'value', optional: true }, + } as SendCallsPayload['capabilities']; + + await callMethod(); + + expect(processSendCallsMock).toHaveBeenCalledWith(params[0], request); + }); + + it('supports call capabilities', async () => { + params[0].calls[0].capabilities = { + 'test-capability': { test: 'value', optional: false }, + } as SendCallsPayload['capabilities']; + + await callMethod(); + + expect(processSendCallsMock).toHaveBeenCalledWith(params[0], request); + }); + + it('supports custom ID', async () => { + params[0].id = ID_MOCK; + + await callMethod(); + + expect(processSendCallsMock).toHaveBeenCalledWith(params[0], request); + }); + + it('throws if no hook', async () => { + await expect( + walletSendCalls(request, response, { + getPermittedAccountsForOrigin: getPermittedAccountsForOriginMock, + }), + ).rejects.toMatchInlineSnapshot(`[Error: Method not supported.]`); + }); + + it('throws if no params', async () => { + request.params = undefined; + + await expect(callMethod()).rejects.toMatchInlineSnapshot(` + [Error: Invalid params + + Expected an array, but received: undefined] + `); + }); + + it('throws if missing properties', async () => { + params[0].from = undefined as never; + params[0].chainId = undefined as never; + params[0].calls = undefined as never; + params[0].atomicRequired = undefined as never; + + await expect(callMethod()).rejects.toMatchInlineSnapshot(` + [Error: Invalid params + + 0 > chainId - Expected a string, but received: undefined + 0 > atomicRequired - Expected a value of type \`boolean\`, but received: \`undefined\` + 0 > calls - Expected an array value, but received: undefined] + `); + }); + + it('throws if wrong types', async () => { + params[0].id = 123 as never; + params[0].from = '123' as never; + params[0].chainId = 123 as never; + params[0].calls = '123' as never; + params[0].capabilities = '123' as never; + params[0].atomicRequired = 123 as never; + + await expect(callMethod()).rejects.toMatchInlineSnapshot(` + [Error: Invalid params + + 0 > id - Expected a string, but received: 123 + 0 > from - Expected a string matching \`/^0x[0-9a-fA-F]{40}$/\` but received "123" + 0 > chainId - Expected a string, but received: 123 + 0 > atomicRequired - Expected a value of type \`boolean\`, but received: \`123\` + 0 > calls - Expected an array value, but received: "123" + 0 > capabilities - Expected an object, but received: "123"] + `); + }); + + it('throws if calls have wrong types', async () => { + params[0].calls[0].data = 123 as never; + params[0].calls[0].to = 123 as never; + params[0].calls[0].value = 123 as never; + params[0].calls[0].capabilities = '123' as never; + + await expect(callMethod()).rejects.toMatchInlineSnapshot(` + [Error: Invalid params + + 0 > calls > 0 > to - Expected a string, but received: 123 + 0 > calls > 0 > data - Expected a string, but received: 123 + 0 > calls > 0 > value - Expected a string, but received: 123 + 0 > calls > 0 > capabilities - Expected an object, but received: "123"] + `); + }); + + it('throws if not hex', async () => { + params[0].id = '123' as never; + params[0].from = '123' as never; + params[0].chainId = '123' as never; + params[0].calls[0].data = '123' as never; + params[0].calls[0].to = '123' as never; + params[0].calls[0].value = '123' as never; + + await expect(callMethod()).rejects.toMatchInlineSnapshot(` + [Error: Invalid params + + 0 > id - Expected a string matching \`/^0x[0-9a-f]+$/\` but received "123" + 0 > from - Expected a string matching \`/^0x[0-9a-fA-F]{40}$/\` but received "123" + 0 > chainId - Expected a string matching \`/^0x[0-9a-f]+$/\` but received "123" + 0 > calls > 0 > to - Expected a string matching \`/^0x[0-9a-fA-F]{40}$/\` but received "123" + 0 > calls > 0 > data - Expected a string matching \`/^0x[0-9a-f]+$/\` but received "123" + 0 > calls > 0 > value - Expected a string matching \`/^0x[0-9a-f]+$/\` but received "123"] + `); + }); + + it('throws if addresses are wrong length', async () => { + params[0].from = '0x123' as never; + params[0].calls[0].to = '0x123' as never; + + await expect(callMethod()).rejects.toMatchInlineSnapshot(` + [Error: Invalid params + + 0 > from - Expected a string matching \`/^0x[0-9a-fA-F]{40}$/\` but received "0x123" + 0 > calls > 0 > to - Expected a string matching \`/^0x[0-9a-fA-F]{40}$/\` but received "0x123"] + `); + }); + + it('throws if from is not in accounts', async () => { + getPermittedAccountsForOriginMock.mockResolvedValueOnce([]); + + await expect(callMethod()).rejects.toMatchInlineSnapshot( + `[Error: The requested account and/or method has not been authorized by the user.]`, + ); + }); +}); diff --git a/packages/eip-5792-middleware/src/methods/wallet_sendCalls.ts b/packages/eip-5792-middleware/src/methods/wallet_sendCalls.ts new file mode 100644 index 00000000000..e61401afe9d --- /dev/null +++ b/packages/eip-5792-middleware/src/methods/wallet_sendCalls.ts @@ -0,0 +1,48 @@ +import { rpcErrors } from '@metamask/rpc-errors'; +import type { JsonRpcRequest, PendingJsonRpcResponse } from '@metamask/utils'; + +import { SendCallsStruct } from '../types.js'; +import type { ProcessSendCallsHook, SendCallsPayload } from '../types.js'; +import { validateAndNormalizeKeyholder, validateParams } from '../utils.js'; + +/** + * The RPC method handler middleware for `wallet_sendCalls` + * + * @param req - The JSON RPC request's end callback. + * @param res - The JSON RPC request's pending response object. + * @param hooks - The hooks object. + * @param hooks.getPermittedAccountsForOrigin - Function that retrieves permitted accounts for the requester's origin. + * @param hooks.processSendCalls - Function that processes a sendCalls request for EIP-5792 transactions. + */ +export async function walletSendCalls( + req: JsonRpcRequest & { origin: string }, + res: PendingJsonRpcResponse, + { + getPermittedAccountsForOrigin, + processSendCalls, + }: { + getPermittedAccountsForOrigin: () => Promise; + processSendCalls?: ProcessSendCallsHook; + }, +): Promise { + if (!processSendCalls) { + throw rpcErrors.methodNotSupported(); + } + + validateParams(req.params, SendCallsStruct); + + const params = req.params[0]; + + const from = params.from + ? await validateAndNormalizeKeyholder(params.from, { + getPermittedAccountsForOrigin, + }) + : undefined; + + const sendCalls: SendCallsPayload = { + ...params, + from, + }; + + res.result = await processSendCalls(sendCalls, req); +} diff --git a/packages/eip-5792-middleware/src/types.ts b/packages/eip-5792-middleware/src/types.ts new file mode 100644 index 00000000000..819a009014a --- /dev/null +++ b/packages/eip-5792-middleware/src/types.ts @@ -0,0 +1,133 @@ +import type { + AccountsControllerGetSelectedAccountAction, + AccountsControllerGetStateAction, +} from '@metamask/accounts-controller'; +import type { Messenger } from '@metamask/messenger'; +import type { + NetworkControllerGetNetworkClientByIdAction, + NetworkControllerGetStateAction, +} from '@metamask/network-controller'; +import type { PreferencesControllerGetStateAction } from '@metamask/preferences-controller'; +import type { Infer } from '@metamask/superstruct'; +import { + array, + boolean, + nonempty, + object, + optional, + record, + string, + tuple, + type, +} from '@metamask/superstruct'; +import type { TransactionControllerGetStateAction } from '@metamask/transaction-controller'; +import type { Hex, Json, JsonRpcRequest } from '@metamask/utils'; +import { HexChecksumAddressStruct, StrictHexStruct } from '@metamask/utils'; + +type Actions = + | AccountsControllerGetStateAction + | AccountsControllerGetSelectedAccountAction + | NetworkControllerGetNetworkClientByIdAction + | TransactionControllerGetStateAction + | PreferencesControllerGetStateAction + | NetworkControllerGetStateAction; + +export type EIP5792Messenger = Messenger<'EIP5792', Actions>; + +// wallet_getCallStatus +export type GetCallsStatusParams = Infer; + +export type GetCallsStatusResult = { + version: string; + id: Hex; + chainId: Hex; + status: number; + atomic: boolean; + receipts?: { + logs: { + address: Hex; + data: Hex; + topics: Hex[]; + }[]; + status: '0x0' | '0x1'; + blockHash: Hex; + blockNumber: Hex; + gasUsed: Hex; + transactionHash: Hex; + }[]; + capabilities?: Record; +}; + +export type GetCallsStatusHook = ( + id: GetCallsStatusParams[0], + req: JsonRpcRequest, +) => Promise; + +// wallet_getCapabilities +export type GetCapabilitiesParams = Infer; +export type GetCapabilitiesResult = Record>; + +export type GetCapabilitiesHook = ( + address: GetCapabilitiesParams[0], + chainIds: GetCapabilitiesParams[1], + req: JsonRpcRequest, +) => Promise; + +// wallet_sendCalls +export type SendCallsParams = Infer; +export type SendCallsPayload = SendCallsParams[0]; + +export type SendCallsRequiredAssetsParam = Infer; + +export type SendCallsResult = { + id: Hex; + capabilities?: Record; +}; + +export type ProcessSendCallsHook = ( + sendCalls: SendCallsPayload, + req: JsonRpcRequest, +) => Promise; + +// /** Structs **/ +// Even though these aren't actually typescript types, these structs essentially represent +// runtime types, so we keep them in this file. +export const GetCallsStatusStruct = tuple([StrictHexStruct]); + +export const GetCapabilitiesStruct = tuple([ + HexChecksumAddressStruct, + optional(array(StrictHexStruct)), +]); + +const RequiredAssetStruct = type({ + address: nonempty(HexChecksumAddressStruct), + amount: nonempty(StrictHexStruct), + standard: nonempty(string()), +}); + +export const CapabilitiesStruct = record( + string(), + type({ + optional: optional(boolean()), + requiredAssets: optional(array(RequiredAssetStruct)), + }), +); + +export const SendCallsStruct = tuple([ + object({ + version: nonempty(string()), + id: optional(StrictHexStruct), + from: optional(HexChecksumAddressStruct), + chainId: StrictHexStruct, + atomicRequired: boolean(), + calls: array( + object({ + to: optional(HexChecksumAddressStruct), + data: optional(StrictHexStruct), + value: optional(StrictHexStruct), + capabilities: optional(CapabilitiesStruct), + }), + ), + capabilities: optional(CapabilitiesStruct), + }), +]); diff --git a/packages/eip-5792-middleware/src/utils.test.ts b/packages/eip-5792-middleware/src/utils.test.ts new file mode 100644 index 00000000000..5bbfb616537 --- /dev/null +++ b/packages/eip-5792-middleware/src/utils.test.ts @@ -0,0 +1,393 @@ +import { KeyringTypes } from '@metamask/keyring-controller'; +import { JsonRpcError, providerErrors } from '@metamask/rpc-errors'; +import type { StructError } from '@metamask/superstruct'; +import { any, validate } from '@metamask/superstruct'; +import type { Hex } from '@metamask/utils'; + +import { EIP5792ErrorCode } from './constants.js'; +import type { EIP5792Messenger } from './types.js'; +import { + getAccountKeyringType, + validateAndNormalizeKeyholder, + validateParams, +} from './utils.js'; + +jest.mock('@metamask/superstruct', () => ({ + ...jest.requireActual('@metamask/superstruct'), + validate: jest.fn(), +})); + +describe('getAccountKeyringType', () => { + const mockMessenger = { + call: jest.fn(), + } as unknown as EIP5792Messenger; + + const mockAccountAddress = + '0x1234567890123456789012345678901234567890' as Hex; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe('when account is found with valid keyring type', () => { + it('should return the keyring type for HD account', () => { + const mockAccounts = { + 'account-1': { + address: '0x1234567890123456789012345678901234567890', + metadata: { + keyring: { + type: KeyringTypes.hd, + }, + }, + }, + }; + + (mockMessenger.call as jest.Mock).mockReturnValue({ + internalAccounts: { + accounts: mockAccounts, + }, + }); + + const result = getAccountKeyringType(mockAccountAddress, mockMessenger); + + expect(result).toBe(KeyringTypes.hd); + expect(mockMessenger.call).toHaveBeenCalledWith( + 'AccountsController:getState', + ); + }); + + it('should return the keyring type for simple account', () => { + const mockAccounts = { + 'account-1': { + address: '0x1234567890123456789012345678901234567890', + metadata: { + keyring: { + type: KeyringTypes.simple, + }, + }, + }, + }; + + (mockMessenger.call as jest.Mock).mockReturnValue({ + internalAccounts: { + accounts: mockAccounts, + }, + }); + + const result = getAccountKeyringType(mockAccountAddress, mockMessenger); + + expect(result).toBe(KeyringTypes.simple); + }); + + it('should handle case-insensitive address comparison', () => { + const mockAccounts = { + 'account-1': { + address: '0x1234567890123456789012345678901234567890', + metadata: { + keyring: { + type: KeyringTypes.hd, + }, + }, + }, + }; + + (mockMessenger.call as jest.Mock).mockReturnValue({ + internalAccounts: { + accounts: mockAccounts, + }, + }); + + const uppercaseAddress = + '0X1234567890123456789012345678901234567890' as Hex; + const result = getAccountKeyringType(uppercaseAddress, mockMessenger); + + expect(result).toBe(KeyringTypes.hd); + }); + + it('should find account when multiple accounts exist', () => { + const mockAccounts = { + 'account-1': { + address: '0x1111111111111111111111111111111111111111', + metadata: { + keyring: { + type: KeyringTypes.simple, + }, + }, + }, + 'account-2': { + address: '0x1234567890123456789012345678901234567890', + metadata: { + keyring: { + type: KeyringTypes.hd, + }, + }, + }, + 'account-3': { + address: '0x3333333333333333333333333333333333333333', + metadata: { + keyring: { + type: KeyringTypes.simple, + }, + }, + }, + }; + + (mockMessenger.call as jest.Mock).mockReturnValue({ + internalAccounts: { + accounts: mockAccounts, + }, + }); + + const result = getAccountKeyringType(mockAccountAddress, mockMessenger); + + expect(result).toBe(KeyringTypes.hd); + }); + }); + + describe('when account is not found', () => { + it('should throw JsonRpcError with RejectedUpgrade code when account does not exist', () => { + const mockAccounts = { + 'account-1': { + address: '0x1111111111111111111111111111111111111111', + metadata: { + keyring: { + type: KeyringTypes.hd, + }, + }, + }, + }; + + (mockMessenger.call as jest.Mock).mockReturnValue({ + internalAccounts: { + accounts: mockAccounts, + }, + }); + + expect(() => { + getAccountKeyringType(mockAccountAddress, mockMessenger); + }).toThrow(JsonRpcError); + + expect(() => { + getAccountKeyringType(mockAccountAddress, mockMessenger); + }).toThrow('EIP-7702 upgrade not supported as account type is unknown'); + }); + + it('should throw JsonRpcError with RejectedUpgrade code when accounts object is empty', () => { + (mockMessenger.call as jest.Mock).mockReturnValue({ + internalAccounts: { + accounts: {}, + }, + }); + + expect(() => { + getAccountKeyringType(mockAccountAddress, mockMessenger); + }).toThrow(JsonRpcError); + + expect(() => { + getAccountKeyringType(mockAccountAddress, mockMessenger); + }).toThrow('EIP-7702 upgrade not supported as account type is unknown'); + }); + }); + + describe('when account exists but has no keyring type', () => { + it('should throw JsonRpcError when account has no metadata', () => { + const mockAccounts = { + 'account-1': { + address: '0x1234567890123456789012345678901234567890', + }, + }; + + (mockMessenger.call as jest.Mock).mockReturnValue({ + internalAccounts: { + accounts: mockAccounts, + }, + }); + + expect(() => { + getAccountKeyringType(mockAccountAddress, mockMessenger); + }).toThrow(JsonRpcError); + + expect(() => { + getAccountKeyringType(mockAccountAddress, mockMessenger); + }).toThrow('EIP-7702 upgrade not supported as account type is unknown'); + }); + + it('should throw JsonRpcError when account has no keyring metadata', () => { + const mockAccounts = { + 'account-1': { + address: '0x1234567890123456789012345678901234567890', + metadata: {}, + }, + }; + + (mockMessenger.call as jest.Mock).mockReturnValue({ + internalAccounts: { + accounts: mockAccounts, + }, + }); + + expect(() => { + getAccountKeyringType(mockAccountAddress, mockMessenger); + }).toThrow(JsonRpcError); + + expect(() => { + getAccountKeyringType(mockAccountAddress, mockMessenger); + }).toThrow('EIP-7702 upgrade not supported as account type is unknown'); + }); + + it('should throw JsonRpcError when account has no keyring type', () => { + const mockAccounts = { + 'account-1': { + address: '0x1234567890123456789012345678901234567890', + metadata: { + keyring: {}, + }, + }, + }; + + (mockMessenger.call as jest.Mock).mockReturnValue({ + internalAccounts: { + accounts: mockAccounts, + }, + }); + + expect(() => { + getAccountKeyringType(mockAccountAddress, mockMessenger); + }).toThrow(JsonRpcError); + + expect(() => { + getAccountKeyringType(mockAccountAddress, mockMessenger); + }).toThrow('EIP-7702 upgrade not supported as account type is unknown'); + }); + }); + + describe('error handling', () => { + it('should throw JsonRpcError with correct error code', () => { + (mockMessenger.call as jest.Mock).mockReturnValue({ + internalAccounts: { + accounts: {}, + }, + }); + + expect(() => { + getAccountKeyringType(mockAccountAddress, mockMessenger); + }).toThrow(JsonRpcError); + + expect(() => { + getAccountKeyringType(mockAccountAddress, mockMessenger); + }).toThrow( + expect.objectContaining({ + code: EIP5792ErrorCode.RejectedUpgrade, + }), + ); + }); + + it('should throw JsonRpcError with correct error message', () => { + (mockMessenger.call as jest.Mock).mockReturnValue({ + internalAccounts: { + accounts: {}, + }, + }); + + expect(() => { + getAccountKeyringType(mockAccountAddress, mockMessenger); + }).toThrow(JsonRpcError); + + expect(() => { + getAccountKeyringType(mockAccountAddress, mockMessenger); + }).toThrow( + expect.objectContaining({ + message: 'EIP-7702 upgrade not supported as account type is unknown', + }), + ); + }); + }); +}); + +describe('validateAndNormalizeKeyholder', () => { + const ADDRESS_MOCK = '0xABCDabcdABCDabcdABCDabcdABCDabcdABCDabcd'; + + let getPermittedAccountsForOriginMock: jest.MockedFn<() => Promise>; + + beforeEach(() => { + jest.resetAllMocks(); + + getPermittedAccountsForOriginMock = jest + .fn() + .mockResolvedValue([ADDRESS_MOCK]); + }); + + it('returns lowercase address', async () => { + const result = await validateAndNormalizeKeyholder(ADDRESS_MOCK, { + getPermittedAccountsForOrigin: getPermittedAccountsForOriginMock, + }); + + expect(result).toBe(ADDRESS_MOCK.toLowerCase()); + }); + + it('throws if address not returned by get permitted accounts for origin hook', async () => { + getPermittedAccountsForOriginMock.mockResolvedValueOnce([]); + + await expect( + validateAndNormalizeKeyholder(ADDRESS_MOCK, { + getPermittedAccountsForOrigin: getPermittedAccountsForOriginMock, + }), + ).rejects.toThrow(providerErrors.unauthorized()); + }); + + it('throws if address is not string', async () => { + await expect( + validateAndNormalizeKeyholder(123 as never, { + getPermittedAccountsForOrigin: getPermittedAccountsForOriginMock, + }), + ).rejects.toThrow('Invalid parameters: must provide an Ethereum address.'); + }); + + it('throws if address is empty string', async () => { + await expect( + validateAndNormalizeKeyholder('' as never, { + getPermittedAccountsForOrigin: getPermittedAccountsForOriginMock, + }), + ).rejects.toThrow('Invalid parameters: must provide an Ethereum address.'); + }); + + it('throws if address length is not 40', async () => { + await expect( + validateAndNormalizeKeyholder('0x123', { + getPermittedAccountsForOrigin: getPermittedAccountsForOriginMock, + }), + ).rejects.toThrow('Invalid parameters: must provide an Ethereum address.'); + }); +}); + +describe('validateParams', () => { + const validateMock = jest.mocked(validate); + const STRUCT_ERROR_MOCK = { + failures: () => [ + { + path: ['test1', 'test2'], + message: 'test message', + }, + { + path: ['test3'], + message: 'test message 2', + }, + ], + } as StructError; + + it('does now throw if superstruct returns no error', () => { + validateMock.mockReturnValue([undefined, undefined]); + expect(() => validateParams({}, any())).not.toThrow(); + }); + + it('throws if superstruct returns error', () => { + validateMock.mockReturnValue([STRUCT_ERROR_MOCK, undefined]); + + expect(() => validateParams({}, any())).toThrowErrorMatchingInlineSnapshot(` + "Invalid params + + test1 > test2 - test message + test3 - test message 2" + `); + }); +}); diff --git a/packages/eip-5792-middleware/src/utils.ts b/packages/eip-5792-middleware/src/utils.ts new file mode 100644 index 00000000000..f473eddebbe --- /dev/null +++ b/packages/eip-5792-middleware/src/utils.ts @@ -0,0 +1,130 @@ +import type { KeyringTypes } from '@metamask/keyring-controller'; +import { JsonRpcError, providerErrors, rpcErrors } from '@metamask/rpc-errors'; +import type { Struct, StructError } from '@metamask/superstruct'; +import { validate } from '@metamask/superstruct'; +import type { Hex } from '@metamask/utils'; + +import { EIP5792ErrorCode } from './constants.js'; +import type { EIP5792Messenger } from './types.js'; + +/** + * Retrieves the keyring type for a given account address. + * + * @param accountAddress - The account address to look up. + * @param messenger - Messenger instance for controller communication. + * @returns The keyring type associated with the account. + * @throws JsonRpcError if the account type is unknown or not found. + */ +export function getAccountKeyringType( + accountAddress: Hex, + messenger: EIP5792Messenger, +): KeyringTypes { + const { accounts } = messenger.call( + 'AccountsController:getState', + ).internalAccounts; + + const account = Object.values(accounts).find( + (acc) => acc.address.toLowerCase() === accountAddress.toLowerCase(), + ); + + const keyringType = account?.metadata?.keyring?.type; + + if (!keyringType) { + throw new JsonRpcError( + EIP5792ErrorCode.RejectedUpgrade, + 'EIP-7702 upgrade not supported as account type is unknown', + ); + } + + return keyringType as KeyringTypes; +} + +/** + * Validates and normalizes a keyholder address for EIP-5792 operations. + * + * @param address - The Ethereum address to validate and normalize. + * @param options - Configuration object containing the getPermittedAccountsForOrigin function. + * @param options.getPermittedAccountsForOrigin - Function to retrieve permitted accounts for the requester's origin. + * @returns A normalized (lowercase) hex address if valid and authorized. + * @throws JsonRpcError with unauthorized error if the requester doesn't have permission to access the address. + * @throws JsonRpcError with invalid params if the address format is invalid. + */ +export async function validateAndNormalizeKeyholder( + address: Hex, + { + getPermittedAccountsForOrigin, + }: { getPermittedAccountsForOrigin: () => Promise }, +): Promise { + if ( + typeof address === 'string' && + address.length > 0 && + resemblesAddress(address) + ) { + // Ensure that an "unauthorized" error is thrown if the requester + // does not have the `eth_accounts` permission. + const accounts = await getPermittedAccountsForOrigin(); + + const normalizedAccounts: string[] = accounts.map((_address) => + _address.toLowerCase(), + ); + + const normalizedAddress = address.toLowerCase() as Hex; + + if (normalizedAccounts.includes(normalizedAddress)) { + return normalizedAddress; + } + + throw providerErrors.unauthorized(); + } + + throw rpcErrors.invalidParams({ + message: `Invalid parameters: must provide an Ethereum address.`, + }); +} + +/** + * Validates parameters against a Superstruct schema and throws an error if validation fails. + * + * @param value - The value to validate against the struct schema. + * @param struct - The Superstruct schema to validate against. + * @throws JsonRpcError with invalid params if the value doesn't match the struct schema. + */ +export function validateParams( + value: unknown | ParamsType, + struct: Struct, +): asserts value is ParamsType { + const [error] = validate(value, struct); + + if (error) { + throw rpcErrors.invalidParams( + formatValidationError(error, `Invalid params`), + ); + } +} + +/** + * Checks if a string resembles an Ethereum address format. + * + * @param str - The string to check for address-like format. + * @returns True if the string has the correct length for an Ethereum address. + */ +export function resemblesAddress(str: string): boolean { + // hex prefix 2 + 20 bytes + return str.length === 2 + 20 * 2; +} + +/** + * Formats a Superstruct validation error into a human-readable string. + * + * @param error - The Superstruct validation error to format. + * @param message - The base error message to prepend to the formatted details. + * @returns A formatted error message string with validation failure details. + */ +function formatValidationError(error: StructError, message: string): string { + return `${message}\n\n${error + .failures() + .map( + (f) => `${f.path.join(' > ')}${f.path.length ? ' - ' : ''}${f.message}`, + ) + .join('\n')}`; +} diff --git a/packages/eip-5792-middleware/tsconfig.build.json b/packages/eip-5792-middleware/tsconfig.build.json new file mode 100644 index 00000000000..ed2fd35e353 --- /dev/null +++ b/packages/eip-5792-middleware/tsconfig.build.json @@ -0,0 +1,23 @@ +{ + "extends": "../../tsconfig.packages.build.json", + "compilerOptions": { + "baseUrl": "./", + "outDir": "./dist", + "rootDir": "./src" + }, + "references": [ + { + "path": "../transaction-controller/tsconfig.build.json" + }, + { + "path": "../messenger/tsconfig.build.json" + }, + { + "path": "../preferences-controller/tsconfig.build.json" + }, + { + "path": "../keyring-controller/tsconfig.build.json" + } + ], + "include": ["../../types", "./src"] +} diff --git a/packages/eip-5792-middleware/tsconfig.json b/packages/eip-5792-middleware/tsconfig.json new file mode 100644 index 00000000000..f1cf7d31477 --- /dev/null +++ b/packages/eip-5792-middleware/tsconfig.json @@ -0,0 +1,22 @@ +{ + "extends": "../../tsconfig.packages.json", + "compilerOptions": { + "baseUrl": "./", + "rootDir": "../.." + }, + "references": [ + { + "path": "../transaction-controller" + }, + { + "path": "../messenger" + }, + { + "path": "../preferences-controller" + }, + { + "path": "../keyring-controller" + } + ], + "include": ["../../types", "./src"] +} diff --git a/packages/eip-5792-middleware/typedoc.json b/packages/eip-5792-middleware/typedoc.json new file mode 100644 index 00000000000..c9da015dbf8 --- /dev/null +++ b/packages/eip-5792-middleware/typedoc.json @@ -0,0 +1,7 @@ +{ + "entryPoints": ["./src/index.ts"], + "excludePrivate": true, + "hideGenerator": true, + "out": "docs", + "tsconfig": "./tsconfig.build.json" +} diff --git a/packages/eip-7702-internal-rpc-middleware/CHANGELOG.md b/packages/eip-7702-internal-rpc-middleware/CHANGELOG.md new file mode 100644 index 00000000000..3c1591420d2 --- /dev/null +++ b/packages/eip-7702-internal-rpc-middleware/CHANGELOG.md @@ -0,0 +1,36 @@ +# 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] + +### Changed + +- Bump `@metamask/utils` from `^11.9.0` to `^11.11.0` ([#9074](https://github.com/MetaMask/core/pull/9074)) +- Bump `@metamask/controller-utils` from `^12.0.0` to `^12.3.0` ([#8774](https://github.com/MetaMask/core/pull/8774), [#9058](https://github.com/MetaMask/core/pull/9058), [#9083](https://github.com/MetaMask/core/pull/9083), [#9218](https://github.com/MetaMask/core/pull/9218)) +- Bump `@metamask/superstruct` from `^3.1.0` to `^3.4.1` ([#9754](https://github.com/MetaMask/core/pull/9754)) + +## [0.1.1] + +### Changed + +- Upgrade `@metamask/utils` from `^11.8.1` to `^11.9.0` ([#7511](https://github.com/MetaMask/core/pull/7511)) +- Bump `@metamask/controller-utils` from `^11.15.0` to `^12.0.0` ([#7202](https://github.com/MetaMask/core/pull/7202), [#7534](https://github.com/MetaMask/core/pull/7534), [#7583](https://github.com/MetaMask/core/pull/7583), [#7995](https://github.com/MetaMask/core/pull/7995), [#8344](https://github.com/MetaMask/core/pull/8344), [#8755](https://github.com/MetaMask/core/pull/8755)) + +## [0.1.0] + +### Added + +- Initial release of `@metamask/eip-7702-internal-rpc-middleware` ([#6911](https://github.com/MetaMask/core/pull/6911)) +- `wallet_upgradeAccount` JSON-RPC method for upgrading EOA accounts to smart accounts using EIP-7702 ([#6789](https://github.com/MetaMask/core/pull/6789)) +- `wallet_getAccountUpgradeStatus` JSON-RPC method for checking account upgrade status ([#6789](https://github.com/MetaMask/core/pull/6789)) +- Hook-based architecture with `upgradeAccount` and `getAccountUpgradeStatus` hooks ([#6789](https://github.com/MetaMask/core/pull/6789)) +- Comprehensive TypeScript type definitions ([#6789](https://github.com/MetaMask/core/pull/6789)) +- Documentation and examples ([#6789](https://github.com/MetaMask/core/pull/6789)) + +[Unreleased]: https://github.com/MetaMask/core/compare/@metamask/eip-7702-internal-rpc-middleware@0.1.1...HEAD +[0.1.1]: https://github.com/MetaMask/core/compare/@metamask/eip-7702-internal-rpc-middleware@0.1.0...@metamask/eip-7702-internal-rpc-middleware@0.1.1 +[0.1.0]: https://github.com/MetaMask/core/releases/tag/@metamask/eip-7702-internal-rpc-middleware@0.1.0 diff --git a/packages/eip-7702-internal-rpc-middleware/LICENSE b/packages/eip-7702-internal-rpc-middleware/LICENSE new file mode 100644 index 00000000000..c259cd7ebcf --- /dev/null +++ b/packages/eip-7702-internal-rpc-middleware/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2025 MetaMask + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packages/eip-7702-internal-rpc-middleware/README.md b/packages/eip-7702-internal-rpc-middleware/README.md new file mode 100644 index 00000000000..a2a9bbf2f05 --- /dev/null +++ b/packages/eip-7702-internal-rpc-middleware/README.md @@ -0,0 +1,98 @@ +# `@metamask/eip-7702-internal-rpc-middleware` + +Implements internal JSON-RPC methods that support EIP-7702 account upgrade functionality. These methods are internal to MetaMask and not defined in [EIP-7702](https://eips.ethereum.org/EIPS/eip-7702), but provide the necessary infrastructure for EIP-7702 account upgrades. + +## Installation + +`yarn add @metamask/eip-7702-internal-rpc-middleware` + +or + +`npm install @metamask/eip-7702-internal-rpc-middleware` + +## JSON-RPC Methods + +### wallet_upgradeAccount + +Upgrades an EOA account to a smart account using EIP-7702. + +**Parameters:** + +- `account` (string): Address of the EOA to upgrade +- `chainId` (string, optional): Chain ID for the upgrade (defaults to current) + +**Returns:** + +- `transactionHash` (string): Hash of the EIP-7702 authorization transaction +- `upgradedAccount` (string): Address of the upgraded account (same as input) +- `delegatedTo` (string): Address of the contract delegated to + +**Example:** + +```json +{ + "method": "wallet_upgradeAccount", + "params": [ + { + "account": "0x1234567890123456789012345678901234567890", + "chainId": "0x1" + } + ] +} +``` + +### wallet_getAccountUpgradeStatus + +Checks if an account has been upgraded using EIP-7702. + +**Parameters:** + +- `account` (string): Address of the account to check +- `chainId` (string, optional): Chain ID for the check (defaults to current) + +**Returns:** + +- `account` (string): Address of the checked account +- `isUpgraded` (boolean): Whether the account is upgraded +- `upgradedAddress` (string | null): Address to which the account is upgraded (null if not upgraded) +- `chainId` (string): Chain ID where the check was performed + +**Example:** + +```json +{ + "method": "wallet_getAccountUpgradeStatus", + "params": [ + { + "account": "0x1234567890123456789012345678901234567890", + "chainId": "0x1" + } + ] +} +``` + +**Example Response (Upgraded Account):** + +```json +{ + "account": "0x1234567890123456789012345678901234567890", + "isUpgraded": true, + "upgradedAddress": "0xabcdef1234567890abcdef1234567890abcdef12", + "chainId": "0x1" +} +``` + +**Example Response (Non-Upgraded Account):** + +```json +{ + "account": "0x1234567890123456789012345678901234567890", + "isUpgraded": false, + "upgradedAddress": null, + "chainId": "0x1" +} +``` + +## 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/eip-7702-internal-rpc-middleware/jest.config.js b/packages/eip-7702-internal-rpc-middleware/jest.config.js new file mode 100644 index 00000000000..ca084133399 --- /dev/null +++ b/packages/eip-7702-internal-rpc-middleware/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/eip-7702-internal-rpc-middleware/package.json b/packages/eip-7702-internal-rpc-middleware/package.json new file mode 100644 index 00000000000..00178ffe9d4 --- /dev/null +++ b/packages/eip-7702-internal-rpc-middleware/package.json @@ -0,0 +1,76 @@ +{ + "name": "@metamask/eip-7702-internal-rpc-middleware", + "version": "0.1.1", + "description": "Implements internal JSON-RPC methods for EIP-7702 account upgrade functionality", + "keywords": [ + "Ethereum", + "MetaMask" + ], + "homepage": "https://github.com/MetaMask/core/tree/main/packages/eip-7702-internal-rpc-middleware#readme", + "bugs": { + "url": "https://github.com/MetaMask/core/issues" + }, + "license": "MIT", + "repository": { + "type": "git", + "url": "https://github.com/MetaMask/core.git" + }, + "files": [ + "dist/" + ], + "sideEffects": false, + "main": "./dist/index.cjs", + "types": "./dist/index.d.cts", + "exports": { + ".": { + "import": { + "types": "./dist/index.d.mts", + "default": "./dist/index.mjs" + }, + "require": { + "types": "./dist/index.d.cts", + "default": "./dist/index.cjs" + } + }, + "./package.json": "./package.json" + }, + "publishConfig": { + "access": "public", + "registry": "https://registry.npmjs.org/" + }, + "scripts": { + "build": "ts-bridge --project tsconfig.build.json --verbose --clean --no-references", + "build:all": "ts-bridge --project tsconfig.build.json --verbose --clean", + "build:docs": "typedoc", + "changelog:update": "../../scripts/update-changelog.sh @metamask/eip-7702-internal-rpc-middleware", + "changelog:validate": "../../scripts/validate-changelog.sh @metamask/eip-7702-internal-rpc-middleware", + "lint:tsconfigs": "tsx ../../scripts/lint-tsconfigs/lint-tsconfigs.mts", + "lint:tsconfigs:fix": "tsx ../../scripts/lint-tsconfigs/lint-tsconfigs.mts --fix", + "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/controller-utils": "^12.3.0", + "@metamask/rpc-errors": "^7.0.2", + "@metamask/superstruct": "^3.4.1", + "@metamask/utils": "^11.11.0" + }, + "devDependencies": { + "@metamask/auto-changelog": "^6.1.0", + "@ts-bridge/cli": "^0.6.4", + "@types/jest": "^30.0.0", + "deepmerge": "^4.2.2", + "jest": "^30.4.2", + "ts-jest": "^29.4.11", + "tsx": "^4.20.5", + "typedoc": "^0.25.13", + "typedoc-plugin-missing-exports": "^2.0.0", + "typescript": "~5.3.3" + }, + "engines": { + "node": "^18.18 || >=20" + } +} diff --git a/packages/eip-7702-internal-rpc-middleware/src/constants.ts b/packages/eip-7702-internal-rpc-middleware/src/constants.ts new file mode 100644 index 00000000000..5ee9aff4c66 --- /dev/null +++ b/packages/eip-7702-internal-rpc-middleware/src/constants.ts @@ -0,0 +1,7 @@ +// Method names +export const METHOD_NAMES = { + UPGRADE_ACCOUNT: 'wallet_upgradeAccount', + GET_ACCOUNT_UPGRADE_STATUS: 'wallet_getAccountUpgradeStatus', +} as const; + +export const DELEGATION_INDICATOR_PREFIX = '0xef0100'; diff --git a/packages/eip-7702-internal-rpc-middleware/src/index.ts b/packages/eip-7702-internal-rpc-middleware/src/index.ts new file mode 100644 index 00000000000..443b9000b16 --- /dev/null +++ b/packages/eip-7702-internal-rpc-middleware/src/index.ts @@ -0,0 +1,17 @@ +// Method handlers +export { walletUpgradeAccount } from './wallet_upgradeAccount.js'; +export { walletGetAccountUpgradeStatus } from './wallet_getAccountUpgradeStatus.js'; + +// Utilities +export { validateParams, validateAndNormalizeAddress } from './utils.js'; + +// Constants +export { METHOD_NAMES } from './constants.js'; + +// Types +export type { + UpgradeAccountParams, + UpgradeAccountResult, + GetAccountUpgradeStatusParams, + GetAccountUpgradeStatusResult, +} from './types.js'; diff --git a/packages/eip-7702-internal-rpc-middleware/src/types.ts b/packages/eip-7702-internal-rpc-middleware/src/types.ts new file mode 100644 index 00000000000..f65d2333835 --- /dev/null +++ b/packages/eip-7702-internal-rpc-middleware/src/types.ts @@ -0,0 +1,36 @@ +import type { Infer } from '@metamask/superstruct'; +import { object, optional } from '@metamask/superstruct'; +import type { Hex } from '@metamask/utils'; +import { HexChecksumAddressStruct, StrictHexStruct } from '@metamask/utils'; + +// Superstruct validation schemas +export const UpgradeAccountParamsStruct = object({ + account: HexChecksumAddressStruct, + chainId: optional(StrictHexStruct), +}); + +export const GetAccountUpgradeStatusParamsStruct = object({ + account: HexChecksumAddressStruct, + chainId: optional(StrictHexStruct), +}); + +// Type definitions derived from schemas +export type UpgradeAccountParams = Infer; + +export type UpgradeAccountResult = { + transactionHash: Hex; // Hash of the EIP-7702 authorization transaction + upgradedAccount: Hex; // Address of the upgraded account (same as input) + delegatedTo: Hex; // Address of the contract delegated to (determined by wallet) +}; + +export type GetAccountUpgradeStatusParams = Infer< + typeof GetAccountUpgradeStatusParamsStruct +>; + +export type GetAccountUpgradeStatusResult = { + account: Hex; // Address of the checked account + chainId: Hex; // Chain ID where the check was performed + isSupported: boolean; // Whether upgrade to smart account is supported on the chain + isUpgraded: boolean; // Whether the account is upgraded + upgradedAddress: Hex | null; // Address to which the account is upgraded +}; diff --git a/packages/eip-7702-internal-rpc-middleware/src/utils.test.ts b/packages/eip-7702-internal-rpc-middleware/src/utils.test.ts new file mode 100644 index 00000000000..701335f7c42 --- /dev/null +++ b/packages/eip-7702-internal-rpc-middleware/src/utils.test.ts @@ -0,0 +1,179 @@ +import { providerErrors, rpcErrors } from '@metamask/rpc-errors'; +import { object, string, number } from '@metamask/superstruct'; +import type { Hex } from '@metamask/utils'; + +import { validateParams, validateAndNormalizeAddress } from './utils.js'; + +describe('validateParams', () => { + it('does not throw for valid parameters', () => { + const testStruct = object({ + name: string(), + age: number(), + }); + const validValue = { name: 'John', age: 30 }; + + expect(() => validateParams(validValue, testStruct)).not.toThrow(); + }); + + it('throws RPC error with formatted message for invalid parameters', () => { + const testStruct = object({ + name: string(), + age: number(), + }); + const invalidValue = { name: 123, age: 'invalid' }; + + expect(() => validateParams(invalidValue, testStruct)).toThrow( + rpcErrors.invalidParams({ + message: + 'Invalid parameters\n\nname - Expected a string, but received: 123\nage - Expected a number, but received: "invalid"', + }), + ); + }); + + it('formats validation errors with field paths', () => { + const testStruct = object({ + name: string(), + age: number(), + }); + const invalidValue = { name: 123, age: 'invalid' }; + + expect(() => validateParams(invalidValue, testStruct)).toThrow( + rpcErrors.invalidParams({ + message: + 'Invalid parameters\n\nname - Expected a string, but received: 123\nage - Expected a number, but received: "invalid"', + }), + ); + }); + + it('formats validation errors with empty path (root level errors)', () => { + // Test with a struct that expects a string but gets a non-object + const testStruct = string(); + const invalidValue = 123; + + expect(() => validateParams(invalidValue, testStruct)).toThrow( + rpcErrors.invalidParams({ + message: 'Invalid parameters\n\nExpected a string, but received: 123', + }), + ); + }); +}); + +describe('validateAndNormalizeAddress', () => { + const mockOrigin = 'https://example.com'; + + it('validates and normalizes a valid address', async () => { + const validAddress = '0x1234567890123456789012345678901234567890'; + const getPermittedAccountsForOrigin = jest + .fn() + .mockResolvedValue([validAddress]); + + const result = await validateAndNormalizeAddress( + validAddress, + mockOrigin, + getPermittedAccountsForOrigin, + ); + + expect(result).toBe(validAddress.toLowerCase()); + expect(getPermittedAccountsForOrigin).toHaveBeenCalledWith(mockOrigin); + }); + + it('throws error for invalid address format', async () => { + const invalidAddress = '0xinvalid' as unknown as Hex; + const getPermittedAccountsForOrigin = jest.fn(); + + await expect( + validateAndNormalizeAddress( + invalidAddress, + mockOrigin, + getPermittedAccountsForOrigin, + ), + ).rejects.toThrow( + rpcErrors.invalidParams({ + message: 'Invalid parameters: must provide an EVM address.', + }), + ); + }); + + it('throws error for unauthorized account access', async () => { + const address = '0x1234567890123456789012345678901234567890'; + const getPermittedAccountsForOrigin = jest + .fn() + .mockResolvedValue(['0x9999999999999999999999999999999999999999']); + + await expect( + validateAndNormalizeAddress( + address, + mockOrigin, + getPermittedAccountsForOrigin, + ), + ).rejects.toThrow(providerErrors.unauthorized()); + }); + + it('throws error for empty string address', async () => { + const address = '' as unknown as Hex; + const getPermittedAccountsForOrigin = jest.fn(); + + await expect( + validateAndNormalizeAddress( + address, + mockOrigin, + getPermittedAccountsForOrigin, + ), + ).rejects.toThrow( + rpcErrors.invalidParams({ + message: 'Invalid parameters: must provide an EVM address.', + }), + ); + }); + + it('throws error for non-string address', async () => { + const address = 123 as unknown as Hex; + const getPermittedAccountsForOrigin = jest.fn(); + + await expect( + validateAndNormalizeAddress( + address, + mockOrigin, + getPermittedAccountsForOrigin, + ), + ).rejects.toThrow( + rpcErrors.invalidParams({ + message: 'Invalid parameters: must provide an EVM address.', + }), + ); + }); + + it('throws error for null address', async () => { + const address = null as unknown as Hex; + const getPermittedAccountsForOrigin = jest.fn(); + + await expect( + validateAndNormalizeAddress( + address, + mockOrigin, + getPermittedAccountsForOrigin, + ), + ).rejects.toThrow( + rpcErrors.invalidParams({ + message: 'Invalid parameters: must provide an EVM address.', + }), + ); + }); + + it('throws error for undefined address', async () => { + const address = undefined as unknown as Hex; + const getPermittedAccountsForOrigin = jest.fn(); + + await expect( + validateAndNormalizeAddress( + address, + mockOrigin, + getPermittedAccountsForOrigin, + ), + ).rejects.toThrow( + rpcErrors.invalidParams({ + message: 'Invalid parameters: must provide an EVM address.', + }), + ); + }); +}); diff --git a/packages/eip-7702-internal-rpc-middleware/src/utils.ts b/packages/eip-7702-internal-rpc-middleware/src/utils.ts new file mode 100644 index 00000000000..19ab6669bc7 --- /dev/null +++ b/packages/eip-7702-internal-rpc-middleware/src/utils.ts @@ -0,0 +1,83 @@ +import { providerErrors, rpcErrors } from '@metamask/rpc-errors'; +import type { Struct, StructError } from '@metamask/superstruct'; +import { validate } from '@metamask/superstruct'; +import type { Hex } from '@metamask/utils'; +import { isHexAddress } from '@metamask/utils'; + +/** + * Validates address format, checks user eth_accounts permissions. + * + * @param address - The Ethereum address to validate and normalize. + * @param origin - The origin string for permission checking. + * @param getPermittedAccountsForOrigin - Function to retrieve permitted accounts for the origin. + * @returns A normalized (lowercase) hex address if valid and authorized. + * @throws JsonRpcError with unauthorized error if the requester doesn't have permission to access the address. + * @throws JsonRpcError with invalid params if the address format is invalid. + */ +export async function validateAndNormalizeAddress( + address: Hex, + origin: string, + getPermittedAccountsForOrigin: (origin: string) => Promise, +): Promise { + if ( + typeof address !== 'string' || + address.length === 0 || + !isHexAddress(address) + ) { + throw rpcErrors.invalidParams({ + message: `Invalid parameters: must provide an EVM address.`, + }); + } + + // Ensure that an "unauthorized" error is thrown if the requester + // does not have the `eth_accounts` permission. + const accounts = await getPermittedAccountsForOrigin(origin); + + // Validate and convert each account address to normalized Hex + const normalizedAccounts: string[] = accounts.map((accountAddress) => + accountAddress.toLowerCase(), + ); + + if (!normalizedAccounts.includes(address.toLowerCase())) { + throw providerErrors.unauthorized(); + } + + return address; +} + +/** + * Validates parameters against a Superstruct schema and throws an error if validation fails. + * + * @param value - The value to validate against the struct schema. + * @param struct - The Superstruct schema to validate against. + * @throws JsonRpcError with invalid params if the value doesn't match the struct schema. + */ +export function validateParams( + value: unknown | ParamsType, + struct: Struct, +): asserts value is ParamsType { + const [error] = validate(value, struct); + + if (error) { + throw rpcErrors.invalidParams( + formatValidationError(error, 'Invalid parameters'), + ); + } +} + +/** + * Formats a Superstruct validation error into a human-readable string. + * + * @param error - The Superstruct validation error to format. + * @param message - The base error message to prepend to the formatted details. + * @returns A formatted error message string with validation failure details. + */ +function formatValidationError(error: StructError, message: string): string { + return `${message}\n\n${error + .failures() + .map( + (failure) => + `${failure.path.join(' > ')}${failure.path.length ? ' - ' : ''}${failure.message}`, + ) + .join('\n')}`; +} diff --git a/packages/eip-7702-internal-rpc-middleware/src/wallet_getAccountUpgradeStatus.test.ts b/packages/eip-7702-internal-rpc-middleware/src/wallet_getAccountUpgradeStatus.test.ts new file mode 100644 index 00000000000..a7f10663c4f --- /dev/null +++ b/packages/eip-7702-internal-rpc-middleware/src/wallet_getAccountUpgradeStatus.test.ts @@ -0,0 +1,352 @@ +import { rpcErrors } from '@metamask/rpc-errors'; +import type { JsonRpcRequest, PendingJsonRpcResponse } from '@metamask/utils'; + +import type { GetAccountUpgradeStatusParams } from './types.js'; +import { walletGetAccountUpgradeStatus } from './wallet_getAccountUpgradeStatus.js'; + +const TEST_ACCOUNT = '0x1234567890123456789012345678901234567890'; +const NETWORK_CLIENT_ID = 'mainnet'; + +const createTestHooks = (): { + getCode: jest.Mock; + getCurrentChainIdForDomain: jest.Mock; + getSelectedNetworkClientIdForChain: jest.Mock; + getPermittedAccountsForOrigin: jest.Mock; + isEip7702Supported: jest.Mock; +} => { + const getCode = jest.fn(); + const getCurrentChainIdForDomain = jest.fn().mockReturnValue('0x1'); + const getPermittedAccountsForOrigin = jest + .fn() + .mockResolvedValue([TEST_ACCOUNT]); + const getSelectedNetworkClientIdForChain = jest + .fn() + .mockReturnValue(NETWORK_CLIENT_ID); + const isEip7702Supported = jest.fn().mockResolvedValue({ + isSupported: true, + upgradeContractAddress: '0x1234567890123456789012345678901234567890', + }); + + return { + getCode, + getCurrentChainIdForDomain, + getSelectedNetworkClientIdForChain, + getPermittedAccountsForOrigin, + isEip7702Supported, + } as const; +}; + +const createTestRequest = ( + params: GetAccountUpgradeStatusParams = { account: TEST_ACCOUNT }, +): JsonRpcRequest & { origin: string } => ({ + id: 1, + method: 'wallet_getAccountUpgradeStatus', + jsonrpc: '2.0' as const, + origin: 'npm:@metamask/gator-permissions-snap', + params, +}); + +const createTestResponse = (): PendingJsonRpcResponse => ({ + result: null, + id: 1, + jsonrpc: '2.0' as const, +}); + +describe('walletGetAccountUpgradeStatus', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('returns non-upgraded account status with real data flow', async () => { + const hooks = createTestHooks(); + const req = createTestRequest(); + const res = createTestResponse(); + + // Mock getCode to return empty code (non-upgraded account) + hooks.getCode.mockResolvedValue('0x'); + + await walletGetAccountUpgradeStatus(req, res, hooks); + + expect(hooks.getCurrentChainIdForDomain).toHaveBeenCalledWith(req.origin); + expect(hooks.isEip7702Supported).toHaveBeenCalledWith({ + address: TEST_ACCOUNT, + chainId: '0x1', + }); + expect(hooks.getSelectedNetworkClientIdForChain).toHaveBeenCalledWith( + '0x1', + ); + expect(hooks.getCode).toHaveBeenCalledWith(TEST_ACCOUNT, NETWORK_CLIENT_ID); + expect(res.result).toStrictEqual({ + account: TEST_ACCOUNT, + isSupported: true, + isUpgraded: false, + upgradedAddress: null, + chainId: '0x1', + }); + }); + + it('returns upgraded account status with real delegation code', async () => { + const hooks = createTestHooks(); + const req = createTestRequest(); + const res = createTestResponse(); + + // Mock getCode to return valid delegation code (0xef0100 + 40 hex chars for address) + const upgradedAddress = '0xabcdef1234567890abcdef1234567890abcdef12'; + const delegationCode = `0xef0100${upgradedAddress.slice(2)}`; + hooks.getCode.mockResolvedValue(delegationCode); + + await walletGetAccountUpgradeStatus(req, res, hooks); + + expect(hooks.getCurrentChainIdForDomain).toHaveBeenCalledWith(req.origin); + expect(hooks.isEip7702Supported).toHaveBeenCalledWith({ + address: TEST_ACCOUNT, + chainId: '0x1', + }); + expect(hooks.getSelectedNetworkClientIdForChain).toHaveBeenCalledWith( + '0x1', + ); + expect(hooks.getCode).toHaveBeenCalledWith(TEST_ACCOUNT, NETWORK_CLIENT_ID); + expect(res.result).toStrictEqual({ + account: TEST_ACCOUNT, + isSupported: true, + isUpgraded: true, + upgradedAddress, + chainId: '0x1', + }); + }); + + it('works with specific chain ID parameter', async () => { + const hooks = createTestHooks(); + const req = createTestRequest({ + account: TEST_ACCOUNT, + chainId: '0xaa36a7', + }); + const res = createTestResponse(); + + // Mock getCode to return non-delegation code + hooks.getCode.mockResolvedValue('0x1234567890abcdef'); + + await walletGetAccountUpgradeStatus(req, res, hooks); + + expect(hooks.getCurrentChainIdForDomain).not.toHaveBeenCalled(); + expect(hooks.isEip7702Supported).toHaveBeenCalledWith({ + address: TEST_ACCOUNT, + chainId: '0xaa36a7', + }); + expect(hooks.getSelectedNetworkClientIdForChain).toHaveBeenCalledWith( + '0xaa36a7', + ); + expect(hooks.getCode).toHaveBeenCalledWith(TEST_ACCOUNT, NETWORK_CLIENT_ID); + expect(res.result).toStrictEqual({ + account: TEST_ACCOUNT, + isSupported: true, + isUpgraded: false, + upgradedAddress: null, + chainId: '0xaa36a7', + }); + }); + + it('propagates validation errors', async () => { + const hooks = createTestHooks(); + // Create a request with invalid account format to trigger validation error + const req = { + id: 1, + method: 'wallet_getAccountUpgradeStatus', + jsonrpc: '2.0' as const, + origin: 'npm:@metamask/gator-permissions-snap', + params: { account: 'invalid-address' as unknown as `0x${string}` }, + }; + const res = createTestResponse(); + + await expect( + walletGetAccountUpgradeStatus(req, res, hooks), + ).rejects.toThrow('Invalid parameters'); + }); + + it('throws error when current chain ID cannot be determined', async () => { + const hooks = createTestHooks(); + const req = createTestRequest(); // No chainId provided, should use current + const res = createTestResponse(); + + // Mock getCurrentChainIdForDomain to return null + hooks.getCurrentChainIdForDomain.mockReturnValue(null); + + await expect( + walletGetAccountUpgradeStatus(req, res, hooks), + ).rejects.toThrow( + rpcErrors.invalidParams({ + message: + 'Could not determine current chain ID for origin: npm:@metamask/gator-permissions-snap', + }), + ); + }); + + it('calls getSelectedNetworkClientIdForChain with current chain ID when no chainId provided', async () => { + const hooks = createTestHooks(); + const req = createTestRequest(); // No chainId provided, should use current + const res = createTestResponse(); + + // Mock getCode to return empty code (non-upgraded account) + hooks.getCode.mockResolvedValue('0x'); + + await walletGetAccountUpgradeStatus(req, res, hooks); + + expect(hooks.getCurrentChainIdForDomain).toHaveBeenCalledWith(req.origin); + expect(hooks.isEip7702Supported).toHaveBeenCalledWith({ + address: TEST_ACCOUNT, + chainId: '0x1', + }); + expect(hooks.getSelectedNetworkClientIdForChain).toHaveBeenCalledWith( + '0x1', + ); + expect(hooks.getCode).toHaveBeenCalledWith(TEST_ACCOUNT, NETWORK_CLIENT_ID); + expect(res.result).toStrictEqual({ + account: TEST_ACCOUNT, + isSupported: true, + isUpgraded: false, + upgradedAddress: null, + chainId: '0x1', + }); + }); + + it('throws error when network client ID is missing', async () => { + const hooks = createTestHooks(); + const req = createTestRequest({ + account: TEST_ACCOUNT, + chainId: '0x999', + }); + const res = createTestResponse(); + + // Mock getSelectedNetworkClientIdForChain to return null (network not found) + hooks.getSelectedNetworkClientIdForChain.mockReturnValue(null); + + await expect( + walletGetAccountUpgradeStatus(req, res, hooks), + ).rejects.toThrow( + rpcErrors.invalidParams({ + message: 'Network client ID not found for chain ID 0x999', + }), + ); + }); + + it('returns false for delegation code with wrong length', async () => { + const hooks = createTestHooks(); + const req = createTestRequest(); + const res = createTestResponse(); + + // Mock getCode to return delegation code with wrong length + const wrongLengthCode = '0xef0100abcdef'; // Too short + hooks.getCode.mockResolvedValue(wrongLengthCode); + + await walletGetAccountUpgradeStatus(req, res, hooks); + + expect(hooks.getCurrentChainIdForDomain).toHaveBeenCalledWith(req.origin); + expect(hooks.isEip7702Supported).toHaveBeenCalledWith({ + address: TEST_ACCOUNT, + chainId: '0x1', + }); + expect(hooks.getSelectedNetworkClientIdForChain).toHaveBeenCalledWith( + '0x1', + ); + expect(res.result).toStrictEqual({ + account: TEST_ACCOUNT, + isSupported: true, + isUpgraded: false, + upgradedAddress: null, + chainId: '0x1', + }); + }); + + it('propagates non-RPC errors as internal errors', async () => { + const hooks = createTestHooks(); + const req = createTestRequest(); + const res = createTestResponse(); + + // Mock getCode to throw a non-RPC error + hooks.getCode.mockRejectedValue(new Error('Network error')); + + await expect( + walletGetAccountUpgradeStatus(req, res, hooks), + ).rejects.toThrow( + rpcErrors.internal({ + message: 'Failed to get account upgrade status: Network error', + }), + ); + }); + + it('returns early when EIP-7702 is not supported', async () => { + const hooks = createTestHooks(); + const req = createTestRequest(); + const res = createTestResponse(); + + // Mock isEip7702Supported to return false + hooks.isEip7702Supported.mockResolvedValue({ + isSupported: false, + }); + + await walletGetAccountUpgradeStatus(req, res, hooks); + + expect(hooks.getCurrentChainIdForDomain).toHaveBeenCalledWith(req.origin); + expect(hooks.isEip7702Supported).toHaveBeenCalledWith({ + address: TEST_ACCOUNT, + chainId: '0x1', + }); + // Should not call getSelectedNetworkClientIdForChain or getCode when not supported + expect(hooks.getSelectedNetworkClientIdForChain).not.toHaveBeenCalled(); + expect(hooks.getCode).not.toHaveBeenCalled(); + expect(res.result).toStrictEqual({ + account: TEST_ACCOUNT, + isSupported: false, + isUpgraded: false, + upgradedAddress: null, + chainId: '0x1', + }); + }); + + it('returns early when EIP-7702 is not supported with specific chain ID', async () => { + const hooks = createTestHooks(); + const req = createTestRequest({ + account: TEST_ACCOUNT, + chainId: '0xaa36a7', + }); + const res = createTestResponse(); + + // Mock isEip7702Supported to return false + hooks.isEip7702Supported.mockResolvedValue({ + isSupported: false, + }); + + await walletGetAccountUpgradeStatus(req, res, hooks); + + expect(hooks.getCurrentChainIdForDomain).not.toHaveBeenCalled(); + expect(hooks.isEip7702Supported).toHaveBeenCalledWith({ + address: TEST_ACCOUNT, + chainId: '0xaa36a7', + }); + // Should not call getSelectedNetworkClientIdForChain or getCode when not supported + expect(hooks.getSelectedNetworkClientIdForChain).not.toHaveBeenCalled(); + expect(hooks.getCode).not.toHaveBeenCalled(); + expect(res.result).toStrictEqual({ + account: TEST_ACCOUNT, + isSupported: false, + isUpgraded: false, + upgradedAddress: null, + chainId: '0xaa36a7', + }); + }); + + it('handles isEip7702Supported hook errors', async () => { + const hooks = createTestHooks(); + const req = createTestRequest(); + const res = createTestResponse(); + + // Mock isEip7702Supported to throw an error + hooks.isEip7702Supported.mockRejectedValue( + new Error('EIP-7702 check failed'), + ); + + await expect( + walletGetAccountUpgradeStatus(req, res, hooks), + ).rejects.toThrow('EIP-7702 check failed'); + }); +}); diff --git a/packages/eip-7702-internal-rpc-middleware/src/wallet_getAccountUpgradeStatus.ts b/packages/eip-7702-internal-rpc-middleware/src/wallet_getAccountUpgradeStatus.ts new file mode 100644 index 00000000000..c72e6289aa3 --- /dev/null +++ b/packages/eip-7702-internal-rpc-middleware/src/wallet_getAccountUpgradeStatus.ts @@ -0,0 +1,141 @@ +import { JsonRpcError, rpcErrors } from '@metamask/rpc-errors'; +import { getErrorMessage } from '@metamask/utils'; +import type { + JsonRpcRequest, + PendingJsonRpcResponse, + Hex, +} from '@metamask/utils'; + +import { DELEGATION_INDICATOR_PREFIX } from './constants.js'; +import type { GetAccountUpgradeStatusParams } from './types.js'; +import { GetAccountUpgradeStatusParamsStruct } from './types.js'; +import { validateParams, validateAndNormalizeAddress } from './utils.js'; + +export type WalletGetAccountUpgradeStatusHooks = { + getCurrentChainIdForDomain: (origin: string) => Hex | null; + getCode: (address: string, networkClientId: string) => Promise; + getSelectedNetworkClientIdForChain: (chainId: string) => string | null; + getPermittedAccountsForOrigin: (origin: string) => Promise; + isEip7702Supported: (request: { address: string; chainId: Hex }) => Promise<{ + isSupported: boolean; + upgradeContractAddress?: string; + }>; +}; + +const isAccountUpgraded = async ( + address: string, + networkClientId: string, + getCode: (address: string, networkClientId: string) => Promise, +): Promise<{ isUpgraded: boolean; upgradedAddress: Hex | null }> => { + const code = await getCode(address, networkClientId); + if (!code || code === '0x' || code.length <= 2) { + return { isUpgraded: false, upgradedAddress: null }; + } + + if (!code.startsWith(DELEGATION_INDICATOR_PREFIX)) { + return { isUpgraded: false, upgradedAddress: null }; + } + + const expectedLength = DELEGATION_INDICATOR_PREFIX.length + 40; // 0xef0100 + 40 hex chars + if (code.length !== expectedLength) { + return { isUpgraded: false, upgradedAddress: null }; + } + + // Extract the 20-byte address (40 hex characters after the prefix) + const upgradedAddress = `0x${code.slice(8, 48)}` as const; + + return { isUpgraded: true, upgradedAddress }; +}; + +/** + * The RPC method handler middleware for `wallet_getAccountUpgradeStatus` + * + * @param req - The JSON RPC request's end callback. + * @param res - The JSON RPC request's pending response object. + * @param hooks - The hooks required for account upgrade status checking. + */ +export async function walletGetAccountUpgradeStatus( + req: JsonRpcRequest & { origin: string }, + res: PendingJsonRpcResponse, + hooks: WalletGetAccountUpgradeStatusHooks, +): Promise { + const { params, origin } = req; + + // Validate parameters using Superstruct + validateParams(params, GetAccountUpgradeStatusParamsStruct); + + const { account, chainId } = params; + + // Validate and normalize the account address with authorization check + const normalizedAccount = await validateAndNormalizeAddress( + account, + origin, + hooks.getPermittedAccountsForOrigin, + ); + + // Use current chain ID if not provided + let targetChainId: Hex; + if (chainId === undefined) { + const currentChainIdForDomain = hooks.getCurrentChainIdForDomain(origin); + if (!currentChainIdForDomain) { + throw rpcErrors.invalidParams({ + message: `Could not determine current chain ID for origin: ${origin}`, + }); + } + targetChainId = currentChainIdForDomain; + } else { + targetChainId = chainId; + } + + const { isSupported } = await hooks.isEip7702Supported({ + address: normalizedAccount, + chainId: targetChainId, + }); + + if (!isSupported) { + res.result = { + isSupported, + account: normalizedAccount, + isUpgraded: false, + upgradedAddress: null, + chainId: targetChainId, + }; + return; + } + + try { + // Get the network configuration for the target chain + const hexChainId = targetChainId; + const networkClientId = + hooks.getSelectedNetworkClientIdForChain(hexChainId); + + if (!networkClientId) { + throw rpcErrors.invalidParams({ + message: `Network client ID not found for chain ID ${targetChainId}`, + }); + } + + // Check if the account is upgraded using the EIP7702 utils + const { isUpgraded, upgradedAddress } = await isAccountUpgraded( + normalizedAccount, + networkClientId, + hooks.getCode, + ); + + res.result = { + isSupported, + account: normalizedAccount, + isUpgraded, + upgradedAddress, + chainId: targetChainId, + }; + } catch (error) { + // Re-throw RPC errors as-is + if (error instanceof JsonRpcError) { + throw error; + } + throw rpcErrors.internal({ + message: `Failed to get account upgrade status: ${getErrorMessage(error)}`, + }); + } +} diff --git a/packages/eip-7702-internal-rpc-middleware/src/wallet_upgradeAccount.test.ts b/packages/eip-7702-internal-rpc-middleware/src/wallet_upgradeAccount.test.ts new file mode 100644 index 00000000000..03d5339d708 --- /dev/null +++ b/packages/eip-7702-internal-rpc-middleware/src/wallet_upgradeAccount.test.ts @@ -0,0 +1,254 @@ +import { rpcErrors } from '@metamask/rpc-errors'; +import type { JsonRpcRequest, PendingJsonRpcResponse } from '@metamask/utils'; + +import type { UpgradeAccountParams } from './types.js'; +import { walletUpgradeAccount } from './wallet_upgradeAccount.js'; + +const TEST_ACCOUNT = '0x1234567890123456789012345678901234567890'; +const UPGRADE_CONTRACT = '0x0000000000000000000000000000000000000000'; + +const createTestHooks = (): { + upgradeAccount: jest.Mock; + getCurrentChainIdForDomain: jest.Mock; + isEip7702Supported: jest.Mock; + getPermittedAccountsForOrigin: jest.Mock; +} => { + const upgradeAccount = jest.fn(); + const getCurrentChainIdForDomain = jest.fn().mockReturnValue('0x1'); + const getPermittedAccountsForOrigin = jest + .fn() + .mockResolvedValue([TEST_ACCOUNT]); + const isEip7702Supported = jest + .fn() + .mockImplementation( + async ({ chainId }: { address: string; chainId: string }) => { + if (chainId === '0x1' || chainId === '0xaa36a7') { + return { + isSupported: true, + upgradeContractAddress: UPGRADE_CONTRACT, + }; + } + return { + isSupported: false, + }; + }, + ); + + return { + upgradeAccount, + getCurrentChainIdForDomain, + isEip7702Supported, + getPermittedAccountsForOrigin, + } as const; +}; + +const createTestRequest = ( + params: UpgradeAccountParams = { account: TEST_ACCOUNT }, +): JsonRpcRequest & { origin: string } => ({ + id: 1, + method: 'wallet_upgradeAccount', + jsonrpc: '2.0' as const, + origin: 'npm:@metamask/gator-permissions-snap', + params, +}); + +const createTestResponse = (): PendingJsonRpcResponse => ({ + result: null, + id: 1, + jsonrpc: '2.0' as const, +}); + +describe('walletUpgradeAccount', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('successfully upgrades account with current chain ID', async () => { + const hooks = createTestHooks(); + const req = createTestRequest(); + const res = createTestResponse(); + + // Mock successful upgrade + hooks.upgradeAccount.mockResolvedValue({ + transactionHash: '0xabc123def456789', + delegatedTo: UPGRADE_CONTRACT, + }); + + await walletUpgradeAccount(req, res, hooks); + + expect(hooks.getCurrentChainIdForDomain).toHaveBeenCalledWith(req.origin); + expect(hooks.isEip7702Supported).toHaveBeenCalledWith({ + address: TEST_ACCOUNT, + chainId: '0x1', + }); + expect(hooks.upgradeAccount).toHaveBeenCalledWith( + TEST_ACCOUNT, + UPGRADE_CONTRACT, + '0x1', + ); + expect(res.result).toStrictEqual({ + transactionHash: '0xabc123def456789', + upgradedAccount: TEST_ACCOUNT, + delegatedTo: UPGRADE_CONTRACT, + }); + }); + + it('successfully upgrades account with specific chain ID', async () => { + const hooks = createTestHooks(); + const req = createTestRequest({ + account: TEST_ACCOUNT, + chainId: '0xaa36a7', + }); + const res = createTestResponse(); + + // Mock successful upgrade + hooks.upgradeAccount.mockResolvedValue({ + transactionHash: '0xdef456abc123789', + delegatedTo: UPGRADE_CONTRACT, + }); + + await walletUpgradeAccount(req, res, hooks); + + expect(hooks.getCurrentChainIdForDomain).not.toHaveBeenCalled(); + expect(hooks.isEip7702Supported).toHaveBeenCalledWith({ + address: TEST_ACCOUNT, + chainId: '0xaa36a7', + }); + expect(hooks.upgradeAccount).toHaveBeenCalledWith( + TEST_ACCOUNT, + UPGRADE_CONTRACT, + '0xaa36a7', + ); + expect(res.result).toStrictEqual({ + transactionHash: '0xdef456abc123789', + upgradedAccount: TEST_ACCOUNT, + delegatedTo: UPGRADE_CONTRACT, + }); + }); + + it('propagates validation errors', async () => { + const hooks = createTestHooks(); + // Create a request with invalid account format to trigger validation error + const req = { + id: 1, + method: 'wallet_upgradeAccount', + jsonrpc: '2.0' as const, + origin: 'npm:@metamask/gator-permissions-snap', + params: { account: 'invalid-address' as unknown as `0x${string}` }, + }; + const res = createTestResponse(); + + await expect(walletUpgradeAccount(req, res, hooks)).rejects.toThrow( + 'Invalid parameters', + ); + }); + + it('throws error when EIP-7702 is not supported on the chain', async () => { + const hooks = createTestHooks(); + const req = createTestRequest({ + account: TEST_ACCOUNT, + chainId: '0x999', + }); + const res = createTestResponse(); + + // Mock unsupported chain + hooks.isEip7702Supported.mockImplementation( + async (_: { address: string; chainId: string }) => ({ + isSupported: false, + }), + ); + + await expect(walletUpgradeAccount(req, res, hooks)).rejects.toThrow( + rpcErrors.invalidParams({ + message: 'Account upgrade not supported on chain ID 0x999', + }), + ); + }); + + it('throws error when no network configuration is found for origin', async () => { + const hooks = createTestHooks(); + const req = createTestRequest(); + const res = createTestResponse(); + + // Mock no network configuration found + hooks.getCurrentChainIdForDomain.mockReturnValue(null); + + await expect(walletUpgradeAccount(req, res, hooks)).rejects.toThrow( + rpcErrors.invalidParams({ + message: + 'No network configuration found for origin: npm:@metamask/gator-permissions-snap', + }), + ); + }); + + it('propagates upgrade function errors', async () => { + const hooks = createTestHooks(); + const req = createTestRequest(); + const res = createTestResponse(); + + // Mock upgrade function to throw an error + hooks.upgradeAccount.mockRejectedValue(new Error('Upgrade failed')); + + await expect(walletUpgradeAccount(req, res, hooks)).rejects.toThrow( + rpcErrors.internal({ + message: 'Failed to upgrade account: Upgrade failed', + }), + ); + }); + + it('throws error when chain has delegation address but is not supported', async () => { + const hooks = createTestHooks(); + const req = createTestRequest({ + account: TEST_ACCOUNT, + chainId: '0x999', + }); + const res = createTestResponse(); + + // Mock chain with delegation address but not supported + hooks.isEip7702Supported.mockImplementation( + async (_: { address: string; chainId: string }) => ({ + isSupported: false, + upgradeContractAddress: UPGRADE_CONTRACT, + }), + ); + + await expect(walletUpgradeAccount(req, res, hooks)).rejects.toThrow( + rpcErrors.invalidParams({ + message: 'Account upgrade not supported on chain ID 0x999', + }), + ); + }); + + it('handles non-Error objects in error handling', async () => { + const hooks = createTestHooks(); + const req = createTestRequest(); + const res = createTestResponse(); + + // Mock upgrade function to throw a non-Error object + hooks.upgradeAccount.mockRejectedValue('String error'); + + await expect(walletUpgradeAccount(req, res, hooks)).rejects.toThrow( + rpcErrors.internal({ + message: 'Failed to upgrade account: String error', + }), + ); + }); + + it('throws error when upgrade contract address is missing', async () => { + const hooks = createTestHooks(); + const req = createTestRequest(); + const res = createTestResponse(); + + // Mock isEip7702Supported to return supported but without upgradeContractAddress + hooks.isEip7702Supported.mockResolvedValue({ + isSupported: true, + // upgradeContractAddress is undefined + }); + + await expect(walletUpgradeAccount(req, res, hooks)).rejects.toThrow( + rpcErrors.invalidParams({ + message: 'No upgrade contract address available for chain ID 0x1', + }), + ); + }); +}); diff --git a/packages/eip-7702-internal-rpc-middleware/src/wallet_upgradeAccount.ts b/packages/eip-7702-internal-rpc-middleware/src/wallet_upgradeAccount.ts new file mode 100644 index 00000000000..f17f65fe54e --- /dev/null +++ b/packages/eip-7702-internal-rpc-middleware/src/wallet_upgradeAccount.ts @@ -0,0 +1,108 @@ +import { JsonRpcError, rpcErrors } from '@metamask/rpc-errors'; +import type { + JsonRpcRequest, + PendingJsonRpcResponse, + Hex, +} from '@metamask/utils'; + +import type { UpgradeAccountParams } from './types.js'; +import { UpgradeAccountParamsStruct } from './types.js'; +import { validateParams, validateAndNormalizeAddress } from './utils.js'; + +export type WalletUpgradeAccountHooks = { + upgradeAccount: ( + address: string, + upgradeContractAddress: string, + chainId?: Hex, + ) => Promise<{ transactionHash: string; delegatedTo: string }>; + getCurrentChainIdForDomain: (origin: string) => Hex | null; + isEip7702Supported: (request: { address: string; chainId: Hex }) => Promise<{ + isSupported: boolean; + upgradeContractAddress?: string; + }>; + getPermittedAccountsForOrigin: (origin: string) => Promise; +}; + +/** + * The RPC method handler middleware for `wallet_upgradeAccount` + * + * @param req - The JSON RPC request's end callback. + * @param res - The JSON RPC request's pending response object. + * @param hooks - The hooks required for account upgrade functionality. + */ +export async function walletUpgradeAccount( + req: JsonRpcRequest & { origin: string }, + res: PendingJsonRpcResponse, + hooks: WalletUpgradeAccountHooks, +): Promise { + const { params, origin } = req; + + // Validate parameters using Superstruct + validateParams(params, UpgradeAccountParamsStruct); + + const { account, chainId } = params; + + // Validate and normalize the account address with authorization check + const normalizedAccount = await validateAndNormalizeAddress( + account, + origin, + hooks.getPermittedAccountsForOrigin, + ); + + // Use current app selected chain ID if not passed as a param + let targetChainId: Hex; + if (chainId === undefined) { + const currentChainIdForDomain = hooks.getCurrentChainIdForDomain(origin); + if (!currentChainIdForDomain) { + throw rpcErrors.invalidParams({ + message: `No network configuration found for origin: ${origin}`, + }); + } + targetChainId = currentChainIdForDomain; + } else { + targetChainId = chainId; + } + + try { + // Get the EIP7702 network configuration for the target chain + const hexChainId = targetChainId; + const { isSupported, upgradeContractAddress } = + await hooks.isEip7702Supported({ + address: normalizedAccount, + chainId: hexChainId, + }); + + if (!isSupported) { + throw rpcErrors.invalidParams({ + message: `Account upgrade not supported on chain ID ${targetChainId}`, + }); + } + + if (!upgradeContractAddress) { + throw rpcErrors.invalidParams({ + message: `No upgrade contract address available for chain ID ${targetChainId}`, + }); + } + + // Perform the upgrade using existing EIP-7702 functionality + const result = await hooks.upgradeAccount( + normalizedAccount, + upgradeContractAddress, + targetChainId, + ); + + res.result = { + transactionHash: result.transactionHash, + upgradedAccount: normalizedAccount, + delegatedTo: result.delegatedTo, + }; + } catch (error) { + // Re-throw RPC errors as-is + if (error instanceof JsonRpcError) { + throw error; + } + throw rpcErrors.internal({ + message: `Failed to upgrade account: ${error instanceof Error ? error.message : String(error)}`, + }); + } +} diff --git a/packages/eip-7702-internal-rpc-middleware/tsconfig.build.json b/packages/eip-7702-internal-rpc-middleware/tsconfig.build.json new file mode 100644 index 00000000000..1d66e6732a3 --- /dev/null +++ b/packages/eip-7702-internal-rpc-middleware/tsconfig.build.json @@ -0,0 +1,14 @@ +{ + "extends": "../../tsconfig.packages.build.json", + "compilerOptions": { + "baseUrl": "./", + "outDir": "./dist", + "rootDir": "./src" + }, + "references": [ + { + "path": "../controller-utils/tsconfig.build.json" + } + ], + "include": ["../../types", "./src"] +} diff --git a/packages/eip-7702-internal-rpc-middleware/tsconfig.json b/packages/eip-7702-internal-rpc-middleware/tsconfig.json new file mode 100644 index 00000000000..1479cde2c8f --- /dev/null +++ b/packages/eip-7702-internal-rpc-middleware/tsconfig.json @@ -0,0 +1,13 @@ +{ + "extends": "../../tsconfig.packages.json", + "compilerOptions": { + "baseUrl": "./", + "rootDir": "../.." + }, + "references": [ + { + "path": "../controller-utils" + } + ], + "include": ["../../types", "./src"] +} diff --git a/packages/eip-7702-internal-rpc-middleware/typedoc.json b/packages/eip-7702-internal-rpc-middleware/typedoc.json new file mode 100644 index 00000000000..b867251d8d8 --- /dev/null +++ b/packages/eip-7702-internal-rpc-middleware/typedoc.json @@ -0,0 +1,31 @@ +{ + "entryPoints": ["src/index.ts"], + "out": "docs", + "exclude": ["**/*.test.ts"], + "excludeExternals": true, + "excludePrivate": true, + "excludeProtected": true, + "excludeInternal": true, + "readme": "README.md", + "name": "@metamask/eip-7702-internal-rpc-middleware", + "includeVersion": true, + "sort": ["source-order"], + "categorizeByGroup": false, + "defaultCategory": "Other", + "categoryOrder": ["Hooks", "Methods", "Types", "Other"], + "kindSortOrder": [ + "Project", + "Module", + "Namespace", + "Enum", + "Class", + "Interface", + "Type alias", + "Constructor", + "Property", + "Variable", + "Function", + "Accessor", + "Method" + ] +} diff --git a/packages/eip1193-permission-middleware/CHANGELOG.md b/packages/eip1193-permission-middleware/CHANGELOG.md new file mode 100644 index 00000000000..af919cdd3e6 --- /dev/null +++ b/packages/eip1193-permission-middleware/CHANGELOG.md @@ -0,0 +1,87 @@ +# 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] + +### Changed + +- Bump `@metamask/utils` from `^11.9.0` to `^11.11.0` ([#9074](https://github.com/MetaMask/core/pull/9074)) +- Bump `@metamask/controller-utils` from `^12.0.0` to `^12.3.0` ([#8774](https://github.com/MetaMask/core/pull/8774), [#9058](https://github.com/MetaMask/core/pull/9058), [#9083](https://github.com/MetaMask/core/pull/9083), [#9218](https://github.com/MetaMask/core/pull/9218)) +- Bump `@metamask/chain-agnostic-permission` from `^1.6.1` to `^1.6.2` ([#9103](https://github.com/MetaMask/core/pull/9103)) +- Bump `@metamask/chain-agnostic-permission` from `^1.6.2` to `^1.7.0` ([#9399](https://github.com/MetaMask/core/pull/9399)) + +## [2.0.1] + +### Changed + +- Bump `@metamask/chain-agnostic-permission` from `^1.5.0` to `^1.6.1` ([#8749](https://github.com/MetaMask/core/pull/8749), [#8755](https://github.com/MetaMask/core/pull/8755)) +- Bump `@metamask/permission-controller` from `^13.0.0` to `^13.1.1` ([#8722](https://github.com/MetaMask/core/pull/8722), [#8755](https://github.com/MetaMask/core/pull/8755)) +- Bump `@metamask/json-rpc-engine` from `^10.3.0` to `^10.5.0` ([#8746](https://github.com/MetaMask/core/pull/8746), [#8753](https://github.com/MetaMask/core/pull/8753)) +- Bump `@metamask/controller-utils` from `^11.20.0` to `^12.0.0` ([#8755](https://github.com/MetaMask/core/pull/8755)) + +## [2.0.0] + +### Changed + +- **BREAKING:** Consolidate method handlers into a single `methodHandlers` export ([#8583](https://github.com/MetaMask/core/pull/8583)) + - The individual handler exports have been removed. They can still be accessed as properties on the `methodHandlers` export. + - The new handlers follow the format expected by `createMethodMiddleware` from `@metamask/json-rpc-engine@10.3.0`. + - The hook types have been updated to cohere with the corresponding `@metamask/permission-controller` methods. +- Bump `@metamask/json-rpc-engine` from `^10.2.0` to `^10.3.0` ([#7642](https://github.com/MetaMask/core/pull/7642), [#7856](https://github.com/MetaMask/core/pull/7856), [#8078](https://github.com/MetaMask/core/pull/8078), [#8317](https://github.com/MetaMask/core/pull/8317), [#8661](https://github.com/MetaMask/core/pull/8661)) +- Upgrade `@metamask/utils` from `^11.8.1` to `^11.9.0` ([#7511](https://github.com/MetaMask/core/pull/7511)) +- Bump `@metamask/controller-utils` from `^11.16.0` to `^11.20.0` ([#7534](https://github.com/MetaMask/core/pull/7534), [#7583](https://github.com/MetaMask/core/pull/7583), [#7995](https://github.com/MetaMask/core/pull/7995), [#8344](https://github.com/MetaMask/core/pull/8344)) +- Bump `@metamask/permission-controller` from `^12.1.1` to `^13.0.0` ([#7559](https://github.com/MetaMask/core/pull/7559), [#8225](https://github.com/MetaMask/core/pull/8225), [#8317](https://github.com/MetaMask/core/pull/8317), [#8661](https://github.com/MetaMask/core/pull/8661)) +- Bump `@metamask/chain-agnostic-permission` from `^1.3.0` to `^1.5.0` ([#7567](https://github.com/MetaMask/core/pull/7567), [#8290](https://github.com/MetaMask/core/pull/8290)) + +## [1.0.3] + +### Changed + +- Bump `@metamask/json-rpc-engine` from `^10.1.1` to `^10.2.0` ([#7202](https://github.com/MetaMask/core/pull/7202)) +- Bump `@metamask/controller-utils` from `^11.15.0` to `^11.16.0` ([#7202](https://github.com/MetaMask/core/pull/7202)) +- Bump `@metamask/chain-agnostic-permission` from `^1.2.1` to `^1.3.0` ([#6986](https://github.com/MetaMask/core/pull/6986), [#7322](https://github.com/MetaMask/core/pull/7322)) +- Bump `@metamask/permission-controller` from `^12.1.0` to `^12.1.1` ([#6988](https://github.com/MetaMask/core/pull/6988), [#7202](https://github.com/MetaMask/core/pull/7202)) + +## [1.0.2] + +### Changed + +- Bump `@metamask/chain-agnostic-permission` from `^1.2.0` to `^1.2.1` ([#6940](https://github.com/MetaMask/core/pull/6940)) +- Bump `@metamask/permission-controller` from `^11.1.0` to `^12.0.0` ([#6940](https://github.com/MetaMask/core/pull/6940), [#6962](https://github.com/MetaMask/core/pull/6962)) + +## [1.0.1] + +### Changed + +- Bump `@metamask/chain-agnostic-permission` from `1.0.0` to `1.2.0` ([#6241](https://github.com/MetaMask/core/pull/6241), [#6345](https://github.com/MetaMask/core/pull/6345), [#6807](https://github.com/MetaMask/core/pull/6807)) +- Bump `@metamask/controller-utils` from `^11.10.0` to `^11.14.1` ([#6069](https://github.com/MetaMask/core/pull/6069), [#6303](https://github.com/MetaMask/core/pull/6303), [#6620](https://github.com/MetaMask/core/pull/6620), [#6629](https://github.com/MetaMask/core/pull/6629), [#6807](https://github.com/MetaMask/core/pull/6807)) +- Bump `@metamask/utils` from `^11.2.0` to `^11.8.1` ([#6054](https://github.com/MetaMask/core/pull/6054), [#6588](https://github.com/MetaMask/core/pull/6588), [#6708](https://github.com/MetaMask/core/pull/6708)) +- Bump `@metamask/json-rpc-engine` from `^10.0.3` to `^10.1.1` ([#6678](https://github.com/MetaMask/core/pull/6678), [#6807](https://github.com/MetaMask/core/pull/6807)) +- Bump `@metamask/permission-controller` from `^11.0.0` to `^11.1.0` ([#6807](https://github.com/MetaMask/core/pull/6807)) + +## [1.0.0] + +### Changed + +- This release is now considered stable ([#6013](https://github.com/MetaMask/core/pull/6013) +- Bump `@metamask/chain-agnostic-permission` to `^1.0.0` ([#6013](https://github.com/MetaMask/core/pull/6013), [#5550](https://github.com/MetaMask/core/pull/5550), [#5518](https://github.com/MetaMask/core/pull/5518), [#5674](https://github.com/MetaMask/core/pull/5674), [#5715](https://github.com/MetaMask/core/pull/5715), [#5760](https://github.com/MetaMask/core/pull/5760), [#5818](https://github.com/MetaMask/core/pull/5818), [#5583](https://github.com/MetaMask/core/pull/5583), [#5982](https://github.com/MetaMask/core/pull/5982), [#6004](https://github.com/MetaMask/core/pull/6004)) +- Bump `@metamask/controller-utils` to `^11.10.0` ([#5935](https://github.com/MetaMask/core/pull/5935), [#5583](https://github.com/MetaMask/core/pull/5583), [#5765](https://github.com/MetaMask/core/pull/5765), [#5812](https://github.com/MetaMask/core/pull/5812)) + +## [0.1.0] + +### Added + +- Initial release + +[Unreleased]: https://github.com/MetaMask/core/compare/@metamask/eip1193-permission-middleware@2.0.1...HEAD +[2.0.1]: https://github.com/MetaMask/core/compare/@metamask/eip1193-permission-middleware@2.0.0...@metamask/eip1193-permission-middleware@2.0.1 +[2.0.0]: https://github.com/MetaMask/core/compare/@metamask/eip1193-permission-middleware@1.0.3...@metamask/eip1193-permission-middleware@2.0.0 +[1.0.3]: https://github.com/MetaMask/core/compare/@metamask/eip1193-permission-middleware@1.0.2...@metamask/eip1193-permission-middleware@1.0.3 +[1.0.2]: https://github.com/MetaMask/core/compare/@metamask/eip1193-permission-middleware@1.0.1...@metamask/eip1193-permission-middleware@1.0.2 +[1.0.1]: https://github.com/MetaMask/core/compare/@metamask/eip1193-permission-middleware@1.0.0...@metamask/eip1193-permission-middleware@1.0.1 +[1.0.0]: https://github.com/MetaMask/core/compare/@metamask/eip1193-permission-middleware@0.1.0...@metamask/eip1193-permission-middleware@1.0.0 +[0.1.0]: https://github.com/MetaMask/core/releases/tag/@metamask/eip1193-permission-middleware@0.1.0 diff --git a/packages/eip1193-permission-middleware/LICENSE b/packages/eip1193-permission-middleware/LICENSE new file mode 100644 index 00000000000..c259cd7ebcf --- /dev/null +++ b/packages/eip1193-permission-middleware/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2025 MetaMask + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packages/eip1193-permission-middleware/README.md b/packages/eip1193-permission-middleware/README.md new file mode 100644 index 00000000000..89667697839 --- /dev/null +++ b/packages/eip1193-permission-middleware/README.md @@ -0,0 +1,15 @@ +# `@metamask/eip1193-permission-middleware` + +Implements the JSON-RPC methods for managing permissions as referenced in [EIP-2255](https://eips.ethereum.org/EIPS/eip-2255) and [MIP-2](https://github.com/MetaMask/metamask-improvement-proposals/blob/main/MIPs/mip-2.md), but adapted to support [chain-agnostic permission caveats](https://npmjs.com/package/@metamask/chain-agnostic-permission). + +## Installation + +`yarn add @metamask/eip1193-permission-middleware` + +or + +`npm install @metamask/eip1193-permission-middleware` + +## 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/eip1193-permission-middleware/jest.config.js b/packages/eip1193-permission-middleware/jest.config.js new file mode 100644 index 00000000000..ca084133399 --- /dev/null +++ b/packages/eip1193-permission-middleware/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/eip1193-permission-middleware/package.json b/packages/eip1193-permission-middleware/package.json new file mode 100644 index 00000000000..d83f2d94e98 --- /dev/null +++ b/packages/eip1193-permission-middleware/package.json @@ -0,0 +1,79 @@ +{ + "name": "@metamask/eip1193-permission-middleware", + "version": "2.0.1", + "description": "Implements the JSON-RPC methods for managing permissions as referenced in EIP-2255 and MIP-2 and inspired by MIP-5, but supporting chain-agnostic permission caveats in alignment with @metamask/multichain-api-middleware", + "keywords": [ + "Ethereum", + "MetaMask" + ], + "homepage": "https://github.com/MetaMask/core/tree/main/packages/eip1193-permission-middleware#readme", + "bugs": { + "url": "https://github.com/MetaMask/core/issues" + }, + "license": "MIT", + "repository": { + "type": "git", + "url": "https://github.com/MetaMask/core.git" + }, + "files": [ + "dist/" + ], + "sideEffects": false, + "main": "./dist/index.cjs", + "types": "./dist/index.d.cts", + "exports": { + ".": { + "import": { + "types": "./dist/index.d.mts", + "default": "./dist/index.mjs" + }, + "require": { + "types": "./dist/index.d.cts", + "default": "./dist/index.cjs" + } + }, + "./package.json": "./package.json" + }, + "publishConfig": { + "access": "public", + "registry": "https://registry.npmjs.org/" + }, + "scripts": { + "build": "ts-bridge --project tsconfig.build.json --verbose --clean --no-references", + "build:all": "ts-bridge --project tsconfig.build.json --verbose --clean", + "build:docs": "typedoc", + "changelog:update": "../../scripts/update-changelog.sh @metamask/eip1193-permission-middleware", + "changelog:validate": "../../scripts/validate-changelog.sh @metamask/eip1193-permission-middleware", + "lint:tsconfigs": "tsx ../../scripts/lint-tsconfigs/lint-tsconfigs.mts", + "lint:tsconfigs:fix": "tsx ../../scripts/lint-tsconfigs/lint-tsconfigs.mts --fix", + "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/chain-agnostic-permission": "^1.7.0", + "@metamask/controller-utils": "^12.3.0", + "@metamask/json-rpc-engine": "^10.5.0", + "@metamask/permission-controller": "^13.1.1", + "@metamask/utils": "^11.11.0", + "lodash": "^4.17.21" + }, + "devDependencies": { + "@metamask/auto-changelog": "^6.1.0", + "@metamask/rpc-errors": "^7.0.2", + "@ts-bridge/cli": "^0.6.4", + "@types/jest": "^30.0.0", + "deepmerge": "^4.2.2", + "jest": "^30.4.2", + "ts-jest": "^29.4.11", + "tsx": "^4.20.5", + "typedoc": "^0.25.13", + "typedoc-plugin-missing-exports": "^2.0.0", + "typescript": "~5.3.3" + }, + "engines": { + "node": "^18.18 || >=20" + } +} diff --git a/packages/eip1193-permission-middleware/src/index.test.ts b/packages/eip1193-permission-middleware/src/index.test.ts new file mode 100644 index 00000000000..1963bae7b71 --- /dev/null +++ b/packages/eip1193-permission-middleware/src/index.test.ts @@ -0,0 +1,40 @@ +import { createMethodMiddleware } from '@metamask/json-rpc-engine'; + +import * as allExports from './index.js'; +import type { GetPermissionsHooks } from './wallet-getPermissions.js'; +import type { RequestPermissionsHooks } from './wallet-requestPermissions.js'; +import type { RevokePermissionsHooks } from './wallet-revokePermissions.js'; + +type Hooks = GetPermissionsHooks & + RequestPermissionsHooks & + RevokePermissionsHooks; + +/* eslint-disable @typescript-eslint/explicit-function-return-type */ +const makeMockHooks = () => + ({ + getPermissionsForOrigin: () => ({}), + getAccounts: () => ['0x123'], + requestPermissionsForOrigin: () => + Promise.resolve([{}, { id: '1', origin: 'test' }]), + revokePermissionsForOrigin: () => undefined, + getCaip25PermissionFromLegacyPermissionsForOrigin: () => ({}), + }) satisfies Hooks; +/* eslint-enable @typescript-eslint/explicit-function-return-type */ + +describe('@metamask/eip1193-permission-middleware', () => { + it('has expected JavaScript exports', () => { + expect(Object.keys(allExports)).toMatchInlineSnapshot(` + [ + "methodHandlers", + ] + `); + }); + + it('constructs a method middleware from the handlers', () => { + const middleware = createMethodMiddleware({ + handlers: allExports.methodHandlers, + hooks: makeMockHooks(), + }); + expect(middleware).toBeDefined(); + }); +}); diff --git a/packages/eip1193-permission-middleware/src/index.ts b/packages/eip1193-permission-middleware/src/index.ts new file mode 100644 index 00000000000..c84419f78fc --- /dev/null +++ b/packages/eip1193-permission-middleware/src/index.ts @@ -0,0 +1,17 @@ +import { MethodNames } from '@metamask/permission-controller'; + +import { getPermissionsHandler } from './wallet-getPermissions.js'; +import { requestPermissionsHandler } from './wallet-requestPermissions.js'; +import { revokePermissionsHandler } from './wallet-revokePermissions.js'; + +type MethodHandlers = { + [MethodNames.GetPermissions]: typeof getPermissionsHandler; + [MethodNames.RequestPermissions]: typeof requestPermissionsHandler; + [MethodNames.RevokePermissions]: typeof revokePermissionsHandler; +}; + +export const methodHandlers: Readonly = { + [MethodNames.GetPermissions]: getPermissionsHandler, + [MethodNames.RequestPermissions]: requestPermissionsHandler, + [MethodNames.RevokePermissions]: revokePermissionsHandler, +}; diff --git a/packages/eip1193-permission-middleware/src/types.ts b/packages/eip1193-permission-middleware/src/types.ts new file mode 100644 index 00000000000..2092c6e676f --- /dev/null +++ b/packages/eip1193-permission-middleware/src/types.ts @@ -0,0 +1,15 @@ +// There is no logic in this file. +/* istanbul ignore file */ + +export enum CaveatTypes { + RestrictReturnedAccounts = 'restrictReturnedAccounts', + RestrictNetworkSwitching = 'restrictNetworkSwitching', +} + +export enum EndowmentTypes { + PermittedChains = 'endowment:permitted-chains', +} + +export enum RestrictedMethods { + EthAccounts = 'eth_accounts', +} diff --git a/packages/eip1193-permission-middleware/src/wallet-getPermissions.test.ts b/packages/eip1193-permission-middleware/src/wallet-getPermissions.test.ts new file mode 100644 index 00000000000..1866636dcc4 --- /dev/null +++ b/packages/eip1193-permission-middleware/src/wallet-getPermissions.test.ts @@ -0,0 +1,363 @@ +import * as chainAgnosticPermissionModule from '@metamask/chain-agnostic-permission'; +import type { + Json, + JsonRpcRequest, + PendingJsonRpcResponse, +} from '@metamask/utils'; + +import { CaveatTypes, EndowmentTypes, RestrictedMethods } from './types.js'; +import { getPermissionsHandler } from './wallet-getPermissions.js'; + +jest.mock('@metamask/chain-agnostic-permission', () => ({ + ...jest.requireActual('@metamask/chain-agnostic-permission'), + __esModule: true, +})); + +const { Caip25CaveatType, Caip25EndowmentPermissionName } = + chainAgnosticPermissionModule; + +const baseRequest = { + jsonrpc: '2.0' as const, + id: 0, + method: 'wallet_getPermissions', +}; + +const createMockedHandler = () => { + const next = jest.fn(); + const end = jest.fn(); + const getPermissionsForOrigin = jest.fn().mockReturnValue( + Object.freeze({ + [Caip25EndowmentPermissionName]: { + id: '1', + parentCapability: Caip25EndowmentPermissionName, + caveats: [ + { + type: Caip25CaveatType, + value: { + requiredScopes: { + 'eip155:1': { + accounts: ['eip155:1:0x1', 'eip155:1:0x2'], + }, + 'eip155:5': { + accounts: ['eip155:5:0x1', 'eip155:5:0x3'], + }, + }, + optionalScopes: { + 'eip155:1': { + accounts: ['eip155:1:0xdeadbeef'], + }, + }, + }, + }, + ], + }, + otherPermission: { + id: '2', + parentCapability: 'otherPermission', + caveats: [ + { + value: { + foo: 'bar', + }, + }, + ], + }, + }), + ); + const getAccounts = jest.fn().mockReturnValue([]); + const response: PendingJsonRpcResponse = { + jsonrpc: '2.0' as const, + id: 0, + }; + const handler = (request: JsonRpcRequest) => + getPermissionsHandler.implementation(request, response, next, end, { + getPermissionsForOrigin, + getAccounts, + }); + + return { + response, + next, + end, + getPermissionsForOrigin, + getAccounts, + handler, + }; +}; + +describe('getPermissionsHandler', () => { + beforeEach(() => { + jest + .spyOn(chainAgnosticPermissionModule, 'getPermittedEthChainIds') + .mockReturnValue([]); + }); + + it('gets the permissions for the origin', async () => { + const { handler, getPermissionsForOrigin } = createMockedHandler(); + + await handler(baseRequest); + expect(getPermissionsForOrigin).toHaveBeenCalled(); + }); + + it('returns permissions unmodified if no CAIP-25 endowment permission has been granted', async () => { + const { handler, getPermissionsForOrigin, response } = + createMockedHandler(); + + getPermissionsForOrigin.mockReturnValue( + Object.freeze({ + otherPermission: { + id: '1', + parentCapability: 'otherPermission', + caveats: [ + { + value: { + foo: 'bar', + }, + }, + ], + }, + }), + ); + + await handler(baseRequest); + expect(response.result).toStrictEqual([ + { + id: '1', + parentCapability: 'otherPermission', + caveats: [ + { + value: { + foo: 'bar', + }, + }, + ], + }, + ]); + }); + + describe('CAIP-25 endowment permissions has been granted', () => { + it('returns the permissions with the CAIP-25 permission removed', async () => { + const { handler, getAccounts, getPermissionsForOrigin, response } = + createMockedHandler(); + getPermissionsForOrigin.mockReturnValue( + Object.freeze({ + [Caip25EndowmentPermissionName]: { + id: '1', + parentCapability: Caip25EndowmentPermissionName, + caveats: [ + { + type: Caip25CaveatType, + value: { + requiredScopes: {}, + optionalScopes: {}, + }, + }, + ], + }, + otherPermission: { + id: '2', + parentCapability: 'otherPermission', + caveats: [ + { + value: { + foo: 'bar', + }, + }, + ], + }, + }), + ); + getAccounts.mockReturnValue([]); + jest + .spyOn(chainAgnosticPermissionModule, 'getPermittedEthChainIds') + .mockReturnValue([]); + + await handler(baseRequest); + expect(response.result).toStrictEqual([ + { + id: '2', + parentCapability: 'otherPermission', + caveats: [ + { + value: { + foo: 'bar', + }, + }, + ], + }, + ]); + }); + + it('gets the lastSelected sorted permitted eth accounts for the origin', async () => { + const { handler, getAccounts } = createMockedHandler(); + await handler(baseRequest); + expect(getAccounts).toHaveBeenCalledWith({ ignoreLock: true }); + }); + + it('returns the permissions with an eth_accounts permission if some eth accounts are permitted', async () => { + const { handler, getAccounts, response } = createMockedHandler(); + getAccounts.mockReturnValue(['0x1', '0x2', '0x3', '0xdeadbeef']); + + await handler(baseRequest); + expect(response.result).toStrictEqual([ + { + id: '2', + parentCapability: 'otherPermission', + caveats: [ + { + value: { + foo: 'bar', + }, + }, + ], + }, + { + id: '1', + parentCapability: RestrictedMethods.EthAccounts, + caveats: [ + { + type: CaveatTypes.RestrictReturnedAccounts, + value: ['0x1', '0x2', '0x3', '0xdeadbeef'], + }, + ], + }, + ]); + }); + + it('gets the permitted eip155 chainIds from the CAIP-25 caveat value', async () => { + const { handler, getPermissionsForOrigin } = createMockedHandler(); + getPermissionsForOrigin.mockReturnValue( + Object.freeze({ + [Caip25EndowmentPermissionName]: { + id: '1', + parentCapability: Caip25EndowmentPermissionName, + caveats: [ + { + type: Caip25CaveatType, + value: { + requiredScopes: { + 'eip155:1': { + accounts: [], + }, + 'eip155:5': { + accounts: [], + }, + }, + optionalScopes: { + 'eip155:1': { + accounts: [], + }, + }, + }, + }, + ], + }, + otherPermission: { + id: '2', + parentCapability: 'otherPermission', + caveats: [ + { + value: { + foo: 'bar', + }, + }, + ], + }, + }), + ); + await handler(baseRequest); + expect( + chainAgnosticPermissionModule.getPermittedEthChainIds, + ).toHaveBeenCalledWith({ + requiredScopes: { + 'eip155:1': { + accounts: [], + }, + 'eip155:5': { + accounts: [], + }, + }, + optionalScopes: { + 'eip155:1': { + accounts: [], + }, + }, + }); + }); + + it('returns the permissions with a permittedChains permission if some eip155 chainIds are permitted', async () => { + const { handler, response } = createMockedHandler(); + jest + .spyOn(chainAgnosticPermissionModule, 'getPermittedEthChainIds') + .mockReturnValue(['0x1', '0x64']); + + await handler(baseRequest); + expect(response.result).toStrictEqual([ + { + id: '2', + parentCapability: 'otherPermission', + caveats: [ + { + value: { + foo: 'bar', + }, + }, + ], + }, + { + id: '1', + parentCapability: EndowmentTypes.PermittedChains, + caveats: [ + { + type: CaveatTypes.RestrictNetworkSwitching, + value: ['0x1', '0x64'], + }, + ], + }, + ]); + }); + + it('returns the permissions with a eth_accounts and permittedChains permission if some eip155 accounts and chainIds are permitted', async () => { + const { handler, getAccounts, response } = createMockedHandler(); + getAccounts.mockReturnValue(['0x1', '0x2', '0xdeadbeef']); + jest + .spyOn(chainAgnosticPermissionModule, 'getPermittedEthChainIds') + .mockReturnValue(['0x1', '0x64']); + + await handler(baseRequest); + expect(response.result).toStrictEqual([ + { + id: '2', + parentCapability: 'otherPermission', + caveats: [ + { + value: { + foo: 'bar', + }, + }, + ], + }, + { + id: '1', + parentCapability: RestrictedMethods.EthAccounts, + caveats: [ + { + type: CaveatTypes.RestrictReturnedAccounts, + value: ['0x1', '0x2', '0xdeadbeef'], + }, + ], + }, + { + id: '1', + parentCapability: EndowmentTypes.PermittedChains, + caveats: [ + { + type: CaveatTypes.RestrictNetworkSwitching, + value: ['0x1', '0x64'], + }, + ], + }, + ]); + }); + }); +}); diff --git a/packages/eip1193-permission-middleware/src/wallet-getPermissions.ts b/packages/eip1193-permission-middleware/src/wallet-getPermissions.ts new file mode 100644 index 00000000000..4dcc742c971 --- /dev/null +++ b/packages/eip1193-permission-middleware/src/wallet-getPermissions.ts @@ -0,0 +1,107 @@ +import type { Caip25CaveatValue } from '@metamask/chain-agnostic-permission'; +import { + Caip25CaveatType, + Caip25EndowmentPermissionName, + getPermittedEthChainIds, +} from '@metamask/chain-agnostic-permission'; +import type { + JsonRpcEngineEndCallback, + JsonRpcEngineNextCallback, + MethodHandler, +} from '@metamask/json-rpc-engine'; +import type { GenericPermissionController } from '@metamask/permission-controller'; +import type { + Json, + JsonRpcRequest, + PendingJsonRpcResponse, +} from '@metamask/utils'; + +import { CaveatTypes, EndowmentTypes, RestrictedMethods } from './types.js'; + +export type GetPermissionsHooks = { + getPermissionsForOrigin: () => ReturnType< + GenericPermissionController['getPermissions'] + >; + getAccounts: (options?: { ignoreLock?: boolean }) => string[]; +}; + +export type GetPermissionsHandler = MethodHandler< + GetPermissionsHooks, + never, + Json[], + Json, + { origin: string } +>; + +export const getPermissionsHandler = { + implementation: getPermissionsImplementation, + hookNames: { + getPermissionsForOrigin: true, + getAccounts: true, + }, +} satisfies GetPermissionsHandler; + +/** + * Get Permissions implementation to be used in JsonRpcEngine middleware, specifically for `wallet_getPermissions` RPC method. + * It makes use of a CAIP-25 endowment permission returned by `getPermissionsForOrigin` hook, if it exists. + * + * @param _req - The JsonRpcEngine request - unused + * @param res - The JsonRpcEngine result object + * @param _next - JsonRpcEngine next() callback - unused + * @param end - JsonRpcEngine end() callback + * @param options - Method hooks passed to the method implementation + * @param options.getPermissionsForOrigin - The specific method hook needed for this method implementation + * @param options.getAccounts - A hook that returns the permitted eth accounts for the origin sorted by lastSelected. + * @returns A promise that resolves to nothing + */ +async function getPermissionsImplementation( + _req: JsonRpcRequest, + res: PendingJsonRpcResponse, + _next: JsonRpcEngineNextCallback, + end: JsonRpcEngineEndCallback, + { getPermissionsForOrigin, getAccounts }: GetPermissionsHooks, +) { + const permissions = { ...getPermissionsForOrigin() }; + const caip25Endowment = permissions[Caip25EndowmentPermissionName]; + const caip25CaveatValue = caip25Endowment?.caveats?.find( + ({ type }) => type === Caip25CaveatType, + )?.value as Caip25CaveatValue | undefined; + delete permissions[Caip25EndowmentPermissionName]; + + if (caip25CaveatValue) { + // We cannot derive ethAccounts directly from the CAIP-25 permission + // because the accounts will not be in order of lastSelected + const ethAccounts = getAccounts({ ignoreLock: true }); + + if (ethAccounts.length > 0) { + permissions[RestrictedMethods.EthAccounts] = { + ...caip25Endowment, + parentCapability: RestrictedMethods.EthAccounts, + caveats: [ + { + type: CaveatTypes.RestrictReturnedAccounts, + value: ethAccounts, + }, + ], + }; + } + + const ethChainIds = getPermittedEthChainIds(caip25CaveatValue); + + if (ethChainIds.length > 0) { + permissions[EndowmentTypes.PermittedChains] = { + ...caip25Endowment, + parentCapability: EndowmentTypes.PermittedChains, + caveats: [ + { + type: CaveatTypes.RestrictNetworkSwitching, + value: ethChainIds, + }, + ], + }; + } + } + + res.result = Object.values(permissions); + return end(); +} diff --git a/packages/eip1193-permission-middleware/src/wallet-requestPermissions.test.ts b/packages/eip1193-permission-middleware/src/wallet-requestPermissions.test.ts new file mode 100644 index 00000000000..8e1e90e438b --- /dev/null +++ b/packages/eip1193-permission-middleware/src/wallet-requestPermissions.test.ts @@ -0,0 +1,586 @@ +import { + Caip25CaveatType, + Caip25EndowmentPermissionName, +} from '@metamask/chain-agnostic-permission'; +import { invalidParams } from '@metamask/permission-controller'; +import type { RequestedPermissions } from '@metamask/permission-controller'; +import type { JsonRpcRequest, PendingJsonRpcResponse } from '@metamask/utils'; + +import { CaveatTypes, EndowmentTypes, RestrictedMethods } from './types.js'; +import { requestPermissionsHandler } from './wallet-requestPermissions.js'; + +const getBaseRequest = (overrides = {}) => ({ + jsonrpc: '2.0' as const, + id: 0, + method: 'wallet_requestPermissions', + networkClientId: 'mainnet', + origin: 'http://test.com', + params: [ + { + eth_accounts: {}, + }, + ], + ...overrides, +}); + +const createMockedHandler = () => { + const next = jest.fn(); + const end = jest.fn(); + const requestPermissionsForOrigin = jest + .fn() + .mockResolvedValue([{ [Caip25EndowmentPermissionName]: {} }]); + const getAccounts = jest.fn().mockReturnValue([]); + const getCaip25PermissionFromLegacyPermissionsForOrigin = jest + .fn() + .mockReturnValue({}); + + const response: PendingJsonRpcResponse = { + jsonrpc: '2.0' as const, + id: 0, + }; + const handler = (request: unknown) => + requestPermissionsHandler.implementation( + request as JsonRpcRequest<[RequestedPermissions]> & { origin: string }, + response, + next, + end, + { + getAccounts, + requestPermissionsForOrigin, + getCaip25PermissionFromLegacyPermissionsForOrigin, + }, + ); + + return { + response, + next, + end, + getAccounts, + requestPermissionsForOrigin, + getCaip25PermissionFromLegacyPermissionsForOrigin, + handler, + }; +}; + +describe('requestPermissionsHandler', () => { + afterEach(() => { + jest.resetAllMocks(); + }); + + it('returns an error if params is malformed', async () => { + const { handler, end } = createMockedHandler(); + + const malformedRequest = getBaseRequest({ params: [] }); + await handler(malformedRequest); + expect(end).toHaveBeenCalledWith( + invalidParams({ data: { request: malformedRequest } }), + ); + }); + + describe('only other permissions (non CAIP-25 equivalent) requested', () => { + it('requests the permission for the other permissions', async () => { + const { handler, requestPermissionsForOrigin } = createMockedHandler(); + + await handler( + getBaseRequest({ + params: [ + { + otherPermissionA: {}, + otherPermissionB: {}, + }, + ], + }), + ); + + expect(requestPermissionsForOrigin).toHaveBeenCalledWith({ + otherPermissionA: {}, + otherPermissionB: {}, + }); + }); + + it('returns the other permissions that are granted', async () => { + const { handler, requestPermissionsForOrigin, response } = + createMockedHandler(); + + requestPermissionsForOrigin.mockResolvedValue([ + { + otherPermissionA: { foo: 'bar' }, + otherPermissionB: { hello: true }, + }, + ]); + + await handler( + getBaseRequest({ + params: [ + { + otherPermissionA: {}, + otherPermissionB: {}, + }, + ], + }), + ); + + expect(response.result).toStrictEqual([{ foo: 'bar' }, { hello: true }]); + }); + }); + + describe('only CAIP-25 "endowment:caip25" permissions requested', () => { + it('should call "requestPermissionsForOrigin" hook with empty object', async () => { + const { handler, requestPermissionsForOrigin } = createMockedHandler(); + + await handler( + getBaseRequest({ + params: [ + { + [Caip25EndowmentPermissionName]: { + caveats: [ + { + type: Caip25CaveatType, + value: { + requiredScopes: {}, + optionalScopes: { + 'eip155:5': { accounts: ['eip155:5:0xdead'] }, + }, + isMultichainOrigin: false, + }, + }, + ], + }, + }, + ], + }), + ); + + expect(requestPermissionsForOrigin).toHaveBeenCalledWith({}); + }); + }); + + describe('only CAIP-25 equivalent permissions ("eth_accounts" and/or "endowment:permittedChains") requested', () => { + it('requests the CAIP-25 permission using eth_accounts when only eth_accounts is specified in params', async () => { + const mockedRequestedPermissions = { + [Caip25EndowmentPermissionName]: { + caveats: [ + { + type: Caip25CaveatType, + value: { + requiredScopes: {}, + optionalScopes: { + 'wallet:eip155': { accounts: ['wallet:eip155:foo'] }, + }, + isMultichainOrigin: false, + }, + }, + ], + }, + }; + + const { + handler, + getCaip25PermissionFromLegacyPermissionsForOrigin, + requestPermissionsForOrigin, + getAccounts, + } = createMockedHandler(); + getCaip25PermissionFromLegacyPermissionsForOrigin.mockReturnValue( + mockedRequestedPermissions, + ); + requestPermissionsForOrigin.mockResolvedValue([ + mockedRequestedPermissions, + ]); + getAccounts.mockReturnValue(['foo']); + + await handler( + getBaseRequest({ + params: [ + { + [RestrictedMethods.EthAccounts]: { + foo: 'bar', + }, + }, + ], + }), + ); + + expect( + getCaip25PermissionFromLegacyPermissionsForOrigin, + ).toHaveBeenCalledWith({ + [RestrictedMethods.EthAccounts]: { + foo: 'bar', + }, + }); + }); + + it('requests the CAIP-25 permission for permittedChains when only permittedChains is specified in params', async () => { + const mockedRequestedPermissions = { + [Caip25EndowmentPermissionName]: { + caveats: [ + { + type: Caip25CaveatType, + value: { + requiredScopes: {}, + optionalScopes: { + 'eip155:100': { accounts: [] }, + }, + isMultichainOrigin: false, + }, + }, + ], + }, + }; + + const { + handler, + requestPermissionsForOrigin, + getCaip25PermissionFromLegacyPermissionsForOrigin, + } = createMockedHandler(); + + getCaip25PermissionFromLegacyPermissionsForOrigin.mockReturnValue( + mockedRequestedPermissions, + ); + requestPermissionsForOrigin.mockResolvedValue([ + mockedRequestedPermissions, + ]); + + await handler( + getBaseRequest({ + params: [ + { + [EndowmentTypes.PermittedChains]: { + caveats: [ + { + type: CaveatTypes.RestrictNetworkSwitching, + value: ['0x64'], + }, + ], + }, + }, + ], + }), + ); + + expect( + getCaip25PermissionFromLegacyPermissionsForOrigin, + ).toHaveBeenCalledWith({ + [EndowmentTypes.PermittedChains]: { + caveats: [ + { + type: CaveatTypes.RestrictNetworkSwitching, + value: ['0x64'], + }, + ], + }, + }); + }); + + it('requests the CAIP-25 permission for eth_accounts and permittedChains when both are specified in params', async () => { + const mockedRequestedPermissions = { + [Caip25EndowmentPermissionName]: { + caveats: [ + { + type: Caip25CaveatType, + value: { + requiredScopes: {}, + optionalScopes: { + 'eip155:100': { accounts: ['bar'] }, + }, + isMultichainOrigin: false, + }, + }, + ], + }, + }; + + const { + handler, + requestPermissionsForOrigin, + getAccounts, + getCaip25PermissionFromLegacyPermissionsForOrigin, + } = createMockedHandler(); + + requestPermissionsForOrigin.mockResolvedValue([ + mockedRequestedPermissions, + ]); + getAccounts.mockReturnValue(['bar']); + getCaip25PermissionFromLegacyPermissionsForOrigin.mockReturnValue( + mockedRequestedPermissions, + ); + + await handler( + getBaseRequest({ + params: [ + { + [RestrictedMethods.EthAccounts]: { + foo: 'bar', + }, + [EndowmentTypes.PermittedChains]: { + caveats: [ + { + type: CaveatTypes.RestrictNetworkSwitching, + value: ['0x64'], + }, + ], + }, + }, + ], + }), + ); + + expect( + getCaip25PermissionFromLegacyPermissionsForOrigin, + ).toHaveBeenCalledWith({ + [RestrictedMethods.EthAccounts]: { + foo: 'bar', + }, + [EndowmentTypes.PermittedChains]: { + caveats: [ + { + type: CaveatTypes.RestrictNetworkSwitching, + value: ['0x64'], + }, + ], + }, + }); + }); + }); + + describe('CAIP-25 equivalent permissions ("eth_accounts" and/or "endowment:permittedChains") alongside "endowment:caip25" requested', () => { + it('requests the CAIP-25 permission only for eth_accounts and permittedChains when both are specified in params (ignores "endowment:caip25")', async () => { + const mockedRequestedPermissions = { + [Caip25EndowmentPermissionName]: { + caveats: [ + { + type: Caip25CaveatType, + value: { + requiredScopes: {}, + optionalScopes: { + 'eip155:100': { accounts: ['bar'] }, + }, + isMultichainOrigin: false, + }, + }, + ], + }, + }; + + const { + handler, + requestPermissionsForOrigin, + getAccounts, + getCaip25PermissionFromLegacyPermissionsForOrigin, + } = createMockedHandler(); + + requestPermissionsForOrigin.mockResolvedValue([ + mockedRequestedPermissions, + ]); + getAccounts.mockReturnValue(['bar']); + getCaip25PermissionFromLegacyPermissionsForOrigin.mockReturnValue( + mockedRequestedPermissions, + ); + + await handler( + getBaseRequest({ + params: [ + { + [Caip25EndowmentPermissionName]: { + caveats: [ + { + type: Caip25CaveatType, + value: { + requiredScopes: {}, + optionalScopes: { + 'eip155:5': { accounts: ['eip155:5:0xdead'] }, + }, + isMultichainOrigin: false, + }, + }, + ], + }, + [RestrictedMethods.EthAccounts]: { + foo: 'bar', + }, + [EndowmentTypes.PermittedChains]: { + caveats: [ + { + type: CaveatTypes.RestrictNetworkSwitching, + value: ['0x64'], + }, + ], + }, + }, + ], + }), + ); + + expect( + getCaip25PermissionFromLegacyPermissionsForOrigin, + ).toHaveBeenCalledWith({ + [RestrictedMethods.EthAccounts]: { + foo: 'bar', + }, + [EndowmentTypes.PermittedChains]: { + caveats: [ + { + type: CaveatTypes.RestrictNetworkSwitching, + value: ['0x64'], + }, + ], + }, + }); + }); + }); + + describe('both CAIP-25 equivalent and other permissions requested', () => { + describe('both CAIP-25 equivalent permissions and other permissions are approved', () => { + it('returns eth_accounts, permittedChains, and other permissions that were granted', async () => { + const mockedRequestedPermissions = { + otherPermissionA: { foo: 'bar' }, + otherPermissionB: { hello: true }, + [Caip25EndowmentPermissionName]: { + caveats: [ + { + type: Caip25CaveatType, + value: { + requiredScopes: {}, + optionalScopes: { + 'eip155:1': { accounts: ['eip155:1:0xdeadbeef'] }, + 'eip155:5': { accounts: ['eip155:5:0xdeadbeef'] }, + }, + isMultichainOrigin: false, + }, + }, + ], + }, + }; + + const { + handler, + requestPermissionsForOrigin, + getAccounts, + getCaip25PermissionFromLegacyPermissionsForOrigin, + response, + } = createMockedHandler(); + + requestPermissionsForOrigin.mockResolvedValue([ + mockedRequestedPermissions, + ]); + + getAccounts.mockReturnValue(['0xdeadbeef']); + + getCaip25PermissionFromLegacyPermissionsForOrigin.mockReturnValue( + mockedRequestedPermissions, + ); + + await handler( + getBaseRequest({ + params: [ + { + eth_accounts: {}, + 'endowment:permitted-chains': {}, + otherPermissionA: {}, + otherPermissionB: {}, + }, + ], + }), + ); + expect(response.result).toStrictEqual([ + { foo: 'bar' }, + { hello: true }, + { + caveats: [ + { + type: CaveatTypes.RestrictReturnedAccounts, + value: ['0xdeadbeef'], + }, + ], + parentCapability: RestrictedMethods.EthAccounts, + }, + { + caveats: [ + { + type: CaveatTypes.RestrictNetworkSwitching, + value: ['0x1', '0x5'], + }, + ], + parentCapability: EndowmentTypes.PermittedChains, + }, + ]); + }); + }); + + describe('CAIP-25 equivalent permissions are approved, but other permissions are not approved', () => { + it('returns an error that the other permissions were not approved', async () => { + const { handler, requestPermissionsForOrigin } = createMockedHandler(); + requestPermissionsForOrigin.mockRejectedValue( + new Error('other permissions rejected'), + ); + + await expect( + handler( + getBaseRequest({ + params: [ + { + eth_accounts: {}, + 'endowment:permitted-chains': {}, + otherPermissionA: {}, + otherPermissionB: {}, + }, + ], + }), + ), + ).rejects.toThrow('other permissions rejected'); + }); + }); + }); + + describe('no permissions requested', () => { + it('returns an error by requesting empty permissions in params from the PermissionController if no permissions specified', async () => { + const { handler, requestPermissionsForOrigin } = createMockedHandler(); + requestPermissionsForOrigin.mockRejectedValue( + new Error('failed to request unexpected permission'), + ); + + await expect( + handler( + getBaseRequest({ + params: [{}], + }), + ), + ).rejects.toThrow('failed to request unexpected permission'); + }); + + it("returns an error if requestPermissionsForOrigin hook doesn't return a valid CAIP-25 permission", async () => { + const { handler, requestPermissionsForOrigin } = createMockedHandler(); + requestPermissionsForOrigin.mockResolvedValue([{ foo: 'bar' }]); + + await expect( + handler( + getBaseRequest({ + params: [{ eth_accounts: {}, 'endowment:permitted-chains': {} }], + }), + ), + ).rejects.toThrow( + `could not find ${Caip25EndowmentPermissionName} permission.`, + ); + }); + + it('returns an error if requestPermissionsForOrigin hook returns a an invalid CAIP-25 permission (with no CAIP-25 caveat value)', async () => { + const { handler, requestPermissionsForOrigin } = createMockedHandler(); + requestPermissionsForOrigin.mockResolvedValue([ + { + [Caip25EndowmentPermissionName]: { + caveats: [{ type: 'foo', value: 'bar' }], + }, + }, + ]); + + await expect( + handler( + getBaseRequest({ + params: [{ eth_accounts: {}, 'endowment:permitted-chains': {} }], + }), + ), + ).rejects.toThrow( + `could not find ${Caip25CaveatType} in granted ${Caip25EndowmentPermissionName} permission.`, + ); + }); + }); +}); diff --git a/packages/eip1193-permission-middleware/src/wallet-requestPermissions.ts b/packages/eip1193-permission-middleware/src/wallet-requestPermissions.ts new file mode 100644 index 00000000000..d49fee4f791 --- /dev/null +++ b/packages/eip1193-permission-middleware/src/wallet-requestPermissions.ts @@ -0,0 +1,177 @@ +import type { Caip25CaveatValue } from '@metamask/chain-agnostic-permission'; +import { + Caip25CaveatType, + Caip25EndowmentPermissionName, + getPermittedEthChainIds, +} from '@metamask/chain-agnostic-permission'; +import { isPlainObject } from '@metamask/controller-utils'; +import type { + JsonRpcEngineNextCallback, + JsonRpcEngineEndCallback, + MethodHandler, +} from '@metamask/json-rpc-engine'; +import { invalidParams } from '@metamask/permission-controller'; +import type { + Caveat, + GenericPermissionController, + RequestedPermissions, + ValidPermission, +} from '@metamask/permission-controller'; +import type { + Json, + JsonRpcRequest, + PendingJsonRpcResponse, +} from '@metamask/utils'; +import { pick } from 'lodash'; + +import { CaveatTypes, EndowmentTypes, RestrictedMethods } from './types.js'; + +export type RequestPermissionsHooks = { + getAccounts: () => string[]; + requestPermissionsForOrigin: ( + requestedPermissions: RequestedPermissions, + ) => ReturnType; + getCaip25PermissionFromLegacyPermissionsForOrigin: ( + requestedPermissions?: RequestedPermissions, + ) => RequestedPermissions; +}; + +export type RequestPermissionsHandler = MethodHandler< + RequestPermissionsHooks, + never, + [RequestedPermissions], + Json, + { origin: string } +>; + +export const requestPermissionsHandler = { + implementation: requestPermissionsImplementation, + hookNames: { + getAccounts: true, + requestPermissionsForOrigin: true, + getCaip25PermissionFromLegacyPermissionsForOrigin: true, + }, +} satisfies RequestPermissionsHandler; + +type GrantedPermissions = Awaited< + ReturnType +>[0]; + +/** + * Request Permissions implementation to be used in JsonRpcEngine middleware, specifically for `wallet_requestPermissions` RPC method. + * The request object is expected to contain a CAIP-25 endowment permission. + * + * @param req - The JsonRpcEngine request + * @param res - The JsonRpcEngine result object + * @param _next - JsonRpcEngine next() callback - unused + * @param end - JsonRpcEngine end() callback + * @param options - Method hooks passed to the method implementation + * @param options.getAccounts - A hook that returns the permitted eth accounts for the origin sorted by lastSelected. + * @param options.getCaip25PermissionFromLegacyPermissionsForOrigin - A hook that returns a CAIP-25 permission from a legacy `eth_accounts` and `endowment:permitted-chains` permission. + * @param options.requestPermissionsForOrigin - A hook that requests CAIP-25 permissions for the origin. + * @returns Nothing. + */ +async function requestPermissionsImplementation( + req: JsonRpcRequest<[RequestedPermissions]> & { origin: string }, + res: PendingJsonRpcResponse, + _next: JsonRpcEngineNextCallback, + end: JsonRpcEngineEndCallback, + { + getAccounts, + requestPermissionsForOrigin, + getCaip25PermissionFromLegacyPermissionsForOrigin, + }: RequestPermissionsHooks, +) { + const { params } = req; + + if (!Array.isArray(params) || !isPlainObject(params[0])) { + return end(invalidParams({ data: { request: req } })); + } + + let [requestedPermissions] = params; + delete requestedPermissions[Caip25EndowmentPermissionName]; + + const caip25EquivalentPermissions: Partial< + Pick + > = pick(requestedPermissions, [ + RestrictedMethods.EthAccounts, + EndowmentTypes.PermittedChains, + ]); + delete requestedPermissions[RestrictedMethods.EthAccounts]; + delete requestedPermissions[EndowmentTypes.PermittedChains]; + + const hasCaip25EquivalentPermissions = + Object.keys(caip25EquivalentPermissions).length > 0; + + if (hasCaip25EquivalentPermissions) { + const caip25Permission = getCaip25PermissionFromLegacyPermissionsForOrigin( + caip25EquivalentPermissions, + ); + requestedPermissions = { ...requestedPermissions, ...caip25Permission }; + } + + let grantedPermissions: GrantedPermissions = {}; + + const [frozenGrantedPermissions] = + await requestPermissionsForOrigin(requestedPermissions); + + grantedPermissions = { ...frozenGrantedPermissions }; + + if (hasCaip25EquivalentPermissions) { + const caip25Endowment = grantedPermissions[Caip25EndowmentPermissionName]; + + if (!caip25Endowment) { + throw new Error( + `could not find ${Caip25EndowmentPermissionName} permission.`, + ); + } + + const caip25CaveatValue = caip25Endowment.caveats?.find( + ({ type }) => type === Caip25CaveatType, + )?.value as Caip25CaveatValue | undefined; + if (!caip25CaveatValue) { + throw new Error( + `could not find ${Caip25CaveatType} in granted ${Caip25EndowmentPermissionName} permission.`, + ); + } + + delete grantedPermissions[Caip25EndowmentPermissionName]; + // We cannot derive correct eth_accounts value directly from the CAIP-25 permission + // because the accounts will not be in order of lastSelected + const ethAccounts = getAccounts(); + + grantedPermissions[RestrictedMethods.EthAccounts] = { + ...caip25Endowment, + parentCapability: RestrictedMethods.EthAccounts, + caveats: [ + { + type: CaveatTypes.RestrictReturnedAccounts, + value: ethAccounts, + }, + ], + }; + + const ethChainIds = getPermittedEthChainIds(caip25CaveatValue); + + if (ethChainIds.length > 0) { + grantedPermissions[EndowmentTypes.PermittedChains] = { + ...caip25Endowment, + parentCapability: EndowmentTypes.PermittedChains, + caveats: [ + { + type: CaveatTypes.RestrictNetworkSwitching, + value: ethChainIds, + }, + ], + }; + } + } + + res.result = Object.values(grantedPermissions).filter( + ( + permission: ValidPermission> | undefined, + ): permission is ValidPermission> => + permission !== undefined, + ); + return end(); +} diff --git a/packages/eip1193-permission-middleware/src/wallet-revokePermissions.test.ts b/packages/eip1193-permission-middleware/src/wallet-revokePermissions.test.ts new file mode 100644 index 00000000000..0a7f51144cd --- /dev/null +++ b/packages/eip1193-permission-middleware/src/wallet-revokePermissions.test.ts @@ -0,0 +1,153 @@ +import { Caip25EndowmentPermissionName } from '@metamask/chain-agnostic-permission'; +import { invalidParams } from '@metamask/permission-controller'; +import type { + Json, + JsonRpcRequest, + PendingJsonRpcResponse, +} from '@metamask/utils'; + +import { EndowmentTypes, RestrictedMethods } from './types.js'; +import { revokePermissionsHandler } from './wallet-revokePermissions.js'; + +const baseRequest = { + jsonrpc: '2.0' as const, + id: 0, + method: 'wallet_revokePermissions', + params: [ + { + [Caip25EndowmentPermissionName]: {}, + otherPermission: {}, + }, + ], +}; + +const createMockedHandler = () => { + const next = jest.fn(); + const end = jest.fn(); + const revokePermissionsForOrigin = jest.fn(); + + const response: PendingJsonRpcResponse = { + jsonrpc: '2.0' as const, + id: 0, + }; + const handler = (request: JsonRpcRequest) => + revokePermissionsHandler.implementation(request, response, next, end, { + revokePermissionsForOrigin, + }); + + return { + response, + next, + end, + revokePermissionsForOrigin, + handler, + }; +}; + +describe('revokePermissionsHandler', () => { + it('returns an error if params is malformed', () => { + const { handler, end } = createMockedHandler(); + + const malformedRequest = { + ...baseRequest, + params: [], + }; + handler(malformedRequest); + expect(end).toHaveBeenCalledWith( + invalidParams({ data: { request: malformedRequest } }), + ); + }); + + it('returns an error if params are empty', () => { + const { handler, end } = createMockedHandler(); + + const emptyRequest = { + ...baseRequest, + params: [{}], + }; + handler(emptyRequest); + expect(end).toHaveBeenCalledWith( + invalidParams({ data: { request: emptyRequest } }), + ); + }); + + it('returns an error if params only contains the CAIP-25 permission', () => { + const { handler, end } = createMockedHandler(); + + const emptyRequest = { + ...baseRequest, + params: [ + { + [Caip25EndowmentPermissionName]: {}, + }, + ], + }; + handler(emptyRequest); + expect(end).toHaveBeenCalledWith( + invalidParams({ data: { request: emptyRequest } }), + ); + }); + + describe.each([ + [RestrictedMethods.EthAccounts], + [EndowmentTypes.PermittedChains], + ])('%s permission is specified', (permission: string) => { + it('revokes the CAIP-25 endowment permission', () => { + const { handler, revokePermissionsForOrigin } = createMockedHandler(); + + handler({ + ...baseRequest, + params: [ + { + [permission]: {}, + }, + ], + }); + expect(revokePermissionsForOrigin).toHaveBeenCalledWith([ + Caip25EndowmentPermissionName, + ]); + }); + + it('revokes other permissions specified', () => { + const { handler, revokePermissionsForOrigin } = createMockedHandler(); + + handler({ + ...baseRequest, + params: [ + { + [permission]: {}, + otherPermission: {}, + }, + ], + }); + expect(revokePermissionsForOrigin).toHaveBeenCalledWith([ + 'otherPermission', + Caip25EndowmentPermissionName, + ]); + }); + }); + + it('revokes permissions other than eth_accounts, permittedChains, CAIP-25 if specified', () => { + const { handler, revokePermissionsForOrigin } = createMockedHandler(); + + handler({ + ...baseRequest, + params: [ + { + [Caip25EndowmentPermissionName]: {}, + otherPermission: {}, + }, + ], + }); + expect(revokePermissionsForOrigin).toHaveBeenCalledWith([ + 'otherPermission', + ]); + }); + + it('returns null', () => { + const { handler, response } = createMockedHandler(); + + handler(baseRequest); + expect(response.result).toBeNull(); + }); +}); diff --git a/packages/eip1193-permission-middleware/src/wallet-revokePermissions.ts b/packages/eip1193-permission-middleware/src/wallet-revokePermissions.ts new file mode 100644 index 00000000000..96039209937 --- /dev/null +++ b/packages/eip1193-permission-middleware/src/wallet-revokePermissions.ts @@ -0,0 +1,99 @@ +import { Caip25EndowmentPermissionName } from '@metamask/chain-agnostic-permission'; +import type { + JsonRpcEngineNextCallback, + JsonRpcEngineEndCallback, + MethodHandler, +} from '@metamask/json-rpc-engine'; +import { invalidParams } from '@metamask/permission-controller'; +import type { GenericPermissionController } from '@metamask/permission-controller'; +import { isNonEmptyArray } from '@metamask/utils'; +import type { + Json, + JsonRpcRequest, + PendingJsonRpcResponse, +} from '@metamask/utils'; + +import { EndowmentTypes, RestrictedMethods } from './types.js'; + +export type RevokePermissionsHooks = { + revokePermissionsForOrigin: ( + permissionKeys: string[], + ) => ReturnType; +}; + +export type RevokePermissionsHandler = MethodHandler< + RevokePermissionsHooks, + never, + Json[], + Json, + { origin: string } +>; + +export const revokePermissionsHandler = { + implementation: revokePermissionsImplementation, + hookNames: { + revokePermissionsForOrigin: true, + }, +} satisfies RevokePermissionsHandler; + +/** + * Revoke Permissions implementation to be used in JsonRpcEngine middleware. + * + * @param req - The JsonRpcEngine request + * @param res - The JsonRpcEngine result object + * @param _next - JsonRpcEngine next() callback - unused + * @param end - JsonRpcEngine end() callback + * @param options - Method hooks passed to the method implementation + * @param options.revokePermissionsForOrigin - A hook that revokes given permission keys for an origin + * @returns Nothing. + */ +function revokePermissionsImplementation( + req: JsonRpcRequest, + res: PendingJsonRpcResponse, + _next: JsonRpcEngineNextCallback, + end: JsonRpcEngineEndCallback, + { + revokePermissionsForOrigin, + }: { + revokePermissionsForOrigin: (permissionKeys: string[]) => void; + }, +) { + const { params } = req; + + const param = params?.[0]; + + if (!param) { + return end(invalidParams({ data: { request: req } })); + } + + // For now, this API revokes the entire permission key + // even if caveats are specified. + const permissionKeys = Object.keys(param).filter( + (name) => name !== Caip25EndowmentPermissionName, + ); + + if (!isNonEmptyArray(permissionKeys)) { + return end(invalidParams({ data: { request: req } })); + } + + const caip25EquivalentPermissions: string[] = [ + RestrictedMethods.EthAccounts, + EndowmentTypes.PermittedChains, + ]; + const relevantPermissionKeys = permissionKeys.filter( + (name: string) => !caip25EquivalentPermissions.includes(name), + ); + + const shouldRevokeLegacyPermission = + relevantPermissionKeys.length !== permissionKeys.length; + + if (shouldRevokeLegacyPermission) { + relevantPermissionKeys.push(Caip25EndowmentPermissionName); + } + + revokePermissionsForOrigin(relevantPermissionKeys); + + res.result = null; + + return end(); +} diff --git a/packages/eip1193-permission-middleware/tsconfig.build.json b/packages/eip1193-permission-middleware/tsconfig.build.json new file mode 100644 index 00000000000..3dc3db532e0 --- /dev/null +++ b/packages/eip1193-permission-middleware/tsconfig.build.json @@ -0,0 +1,15 @@ +{ + "extends": "../../tsconfig.packages.build.json", + "compilerOptions": { + "baseUrl": "./", + "outDir": "./dist", + "rootDir": "./src" + }, + "references": [ + { "path": "../chain-agnostic-permission/tsconfig.build.json" }, + { "path": "../controller-utils/tsconfig.build.json" }, + { "path": "../json-rpc-engine/tsconfig.build.json" }, + { "path": "../permission-controller/tsconfig.build.json" } + ], + "include": ["../../types", "./src"] +} diff --git a/packages/eip1193-permission-middleware/tsconfig.json b/packages/eip1193-permission-middleware/tsconfig.json new file mode 100644 index 00000000000..1f32e2cb06e --- /dev/null +++ b/packages/eip1193-permission-middleware/tsconfig.json @@ -0,0 +1,14 @@ +{ + "extends": "../../tsconfig.packages.json", + "compilerOptions": { + "baseUrl": "./", + "rootDir": "../.." + }, + "references": [ + { "path": "../chain-agnostic-permission" }, + { "path": "../controller-utils" }, + { "path": "../json-rpc-engine" }, + { "path": "../permission-controller" } + ], + "include": ["../../types", "./src"] +} diff --git a/packages/eip1193-permission-middleware/typedoc.json b/packages/eip1193-permission-middleware/typedoc.json new file mode 100644 index 00000000000..c9da015dbf8 --- /dev/null +++ b/packages/eip1193-permission-middleware/typedoc.json @@ -0,0 +1,7 @@ +{ + "entryPoints": ["./src/index.ts"], + "excludePrivate": true, + "hideGenerator": true, + "out": "docs", + "tsconfig": "./tsconfig.build.json" +} diff --git a/packages/ens-controller/CHANGELOG.md b/packages/ens-controller/CHANGELOG.md deleted file mode 100644 index bfe6c4967b6..00000000000 --- a/packages/ens-controller/CHANGELOG.md +++ /dev/null @@ -1,97 +0,0 @@ -# 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] - -## [6.0.0] -### Changed -- **BREAKING:** Bump dependency and peer dependency on `@metamask/network-controller` to ^15.0.0 - -## [5.0.2] -### Changed -- Bump dependency on `@metamask/utils` to ^8.1.0 ([#1639](https://github.com/MetaMask/core/pull/1639)) -- Bump dependency on `@metamask/base-controller` to ^3.2.3 -- Bump dependency on `@metamask/controller-utils` to ^5.0.2 -- Bump dependency and peer dependency on `@metamask/network-controller` to ^14.0.0 - -## [5.0.1] -### Changed -- Update TypeScript to v4.8.x ([#1718](https://github.com/MetaMask/core/pull/1718)) - -## [5.0.0] -### Changed -- **BREAKING**: Bump peer dependency on `@metamask/network-controller` to ^13.0.0 ([#1633](https://github.com/MetaMask/core/pull/1633)) -- Use `providerConfig.chainId` instead of `providerConfig.networkId` to determine ENS compatability ([#1633](https://github.com/MetaMask/core/pull/1633)) -- Bump dependency on `@metamask/controller-utils` to ^5.0.0 ([#1633](https://github.com/MetaMask/core/pull/1633)) - -## [4.1.1] -### Changed -- Bump dependency on `@metamask/base-controller` to ^3.2.1 -- Bump dependency on `@metamask/controller-utils` to ^4.3.2 -- Bump dependency and peer dependency on `@metamask/network-controller` to ^12.1.2 - -## [4.1.0] -### Changed -- Update `@metamask/utils` to `^6.2.0` ([#1514](https://github.com/MetaMask/core/pull/1514)) - -## [4.0.0] -### Changed -- **BREAKING:** Bump to Node 16 ([#1262](https://github.com/MetaMask/core/pull/1262)) -- **BREAKING:** Add `@metamask/network-controller` as a dependency and peer dependency ([#1367](https://github.com/MetaMask/core/pull/1367), [#1362](https://github.com/MetaMask/core/pull/1362)) -- **BREAKING:** The `ensEntries` state property is now keyed by `Hex` chain ID rather than `string`, and the `chainId` property of each ENS entry is also `Hex` rather than `string`. ([#1367](https://github.com/MetaMask/core/pull/1367)) - - This requires a state migration -- **BREAKING:** The methods `get`, `set`, and `delete` have been updated to accept and return chain IDs as 0x-prefixed hex strings, rather than decimal strings. ([#1367](https://github.com/MetaMask/core/pull/1367)) -- Bump @metamask/utils from 5.0.1 to 5.0.2 ([#1271](https://github.com/MetaMask/core/pull/1271)) - -### Fixed -- Fix ENS controller failure to initialize after switching networks ([#1362](https://github.com/MetaMask/core/pull/1362)) - -## [3.1.0] -### Changed -- Add support for reverse ENS address resolution ([#1170](https://github.com/MetaMask/core/pull/1170)) - - This controller can now resolve a network address to an ENS address. This feature was ported from the extension ENS controller. - -## [3.0.0] -### Changed -- **BREAKING:** Convert the ENS controller to the BaseController v2 API ([#1134](https://github.com/MetaMask/core/pull/1134)) - -## [2.0.0] -### Removed -- **BREAKING:** Remove `isomorphic-fetch` ([#1106](https://github.com/MetaMask/controllers/pull/1106)) - - Consumers must now import `isomorphic-fetch` or another polyfill themselves if they are running in an environment without `fetch` - -## [1.0.2] -### Changed -- Rename this repository to `core` ([#1031](https://github.com/MetaMask/controllers/pull/1031)) -- Update `@metamask/controller-utils` package ([#1041](https://github.com/MetaMask/controllers/pull/1041)) - -## [1.0.1] -### Changed -- Relax dependencies on `@metamask/base-controller` and `@metamask/controller-utils` (use `^` instead of `~`) ([#998](https://github.com/MetaMask/core/pull/998)) - -## [1.0.0] -### Added -- Initial release - - As a result of converting our shared controllers repo into a monorepo ([#831](https://github.com/MetaMask/core/pull/831)), we've created this package from select parts of [`@metamask/controllers` v33.0.0](https://github.com/MetaMask/core/tree/v33.0.0), namely: - - `src/third-party/EnsController.ts` - - `src/third-party/EnsController.test.ts` - - All changes listed after this point were applied to this package following the monorepo conversion. - -[Unreleased]: https://github.com/MetaMask/core/compare/@metamask/ens-controller@6.0.0...HEAD -[6.0.0]: https://github.com/MetaMask/core/compare/@metamask/ens-controller@5.0.2...@metamask/ens-controller@6.0.0 -[5.0.2]: https://github.com/MetaMask/core/compare/@metamask/ens-controller@5.0.1...@metamask/ens-controller@5.0.2 -[5.0.1]: https://github.com/MetaMask/core/compare/@metamask/ens-controller@5.0.0...@metamask/ens-controller@5.0.1 -[5.0.0]: https://github.com/MetaMask/core/compare/@metamask/ens-controller@4.1.1...@metamask/ens-controller@5.0.0 -[4.1.1]: https://github.com/MetaMask/core/compare/@metamask/ens-controller@4.1.0...@metamask/ens-controller@4.1.1 -[4.1.0]: https://github.com/MetaMask/core/compare/@metamask/ens-controller@4.0.0...@metamask/ens-controller@4.1.0 -[4.0.0]: https://github.com/MetaMask/core/compare/@metamask/ens-controller@3.1.0...@metamask/ens-controller@4.0.0 -[3.1.0]: https://github.com/MetaMask/core/compare/@metamask/ens-controller@3.0.0...@metamask/ens-controller@3.1.0 -[3.0.0]: https://github.com/MetaMask/core/compare/@metamask/ens-controller@2.0.0...@metamask/ens-controller@3.0.0 -[2.0.0]: https://github.com/MetaMask/core/compare/@metamask/ens-controller@1.0.2...@metamask/ens-controller@2.0.0 -[1.0.2]: https://github.com/MetaMask/core/compare/@metamask/ens-controller@1.0.1...@metamask/ens-controller@1.0.2 -[1.0.1]: https://github.com/MetaMask/core/compare/@metamask/ens-controller@1.0.0...@metamask/ens-controller@1.0.1 -[1.0.0]: https://github.com/MetaMask/core/releases/tag/@metamask/ens-controller@1.0.0 diff --git a/packages/ens-controller/LICENSE b/packages/ens-controller/LICENSE deleted file mode 100644 index ddfbecf9020..00000000000 --- a/packages/ens-controller/LICENSE +++ /dev/null @@ -1,20 +0,0 @@ -MIT License - -Copyright (c) 2018 MetaMask - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE diff --git a/packages/ens-controller/README.md b/packages/ens-controller/README.md deleted file mode 100644 index 605c10f3c8f..00000000000 --- a/packages/ens-controller/README.md +++ /dev/null @@ -1,15 +0,0 @@ -# `@metamask/ens-controller` - -Maps ENS names to their resolved addresses by chain id. - -## Installation - -`yarn add @metamask/ens-controller` - -or - -`npm install @metamask/ens-controller` - -## Contributing - -This package is part of a monorepo. Instructions for contributing can be found in the [monorepo README](https://github.com/MetaMask/core#readme). diff --git a/packages/ens-controller/package.json b/packages/ens-controller/package.json deleted file mode 100644 index 34463f392df..00000000000 --- a/packages/ens-controller/package.json +++ /dev/null @@ -1,60 +0,0 @@ -{ - "name": "@metamask/ens-controller", - "version": "6.0.0", - "description": "Maps ENS names to their resolved addresses by chain id", - "keywords": [ - "MetaMask", - "Ethereum" - ], - "homepage": "https://github.com/MetaMask/core/tree/main/packages/ens-controller#readme", - "bugs": { - "url": "https://github.com/MetaMask/core/issues" - }, - "repository": { - "type": "git", - "url": "https://github.com/MetaMask/core.git" - }, - "license": "MIT", - "main": "./dist/index.js", - "types": "./dist/index.d.ts", - "files": [ - "dist/" - ], - "scripts": { - "build:docs": "typedoc", - "changelog:validate": "../../scripts/validate-changelog.sh @metamask/ens-controller", - "publish:preview": "yarn npm publish --tag preview", - "test": "jest", - "test:clean": "jest --clearCache", - "test:watch": "jest --watch" - }, - "dependencies": { - "@ethersproject/providers": "^5.7.0", - "@metamask/base-controller": "^3.2.3", - "@metamask/controller-utils": "^5.0.2", - "@metamask/network-controller": "^15.0.0", - "@metamask/utils": "^8.1.0", - "ethereum-ens-network-map": "^1.0.2", - "punycode": "^2.1.1" - }, - "devDependencies": { - "@metamask/auto-changelog": "^3.1.0", - "@types/jest": "^27.4.1", - "deepmerge": "^4.2.2", - "jest": "^27.5.1", - "ts-jest": "^27.1.4", - "typedoc": "^0.24.8", - "typedoc-plugin-missing-exports": "^2.0.0", - "typescript": "~4.8.4" - }, - "peerDependencies": { - "@metamask/network-controller": "^15.0.0" - }, - "engines": { - "node": ">=16.0.0" - }, - "publishConfig": { - "access": "public", - "registry": "https://registry.npmjs.org/" - } -} diff --git a/packages/ens-controller/src/EnsController.test.ts b/packages/ens-controller/src/EnsController.test.ts deleted file mode 100644 index 8cf7332919a..00000000000 --- a/packages/ens-controller/src/EnsController.test.ts +++ /dev/null @@ -1,650 +0,0 @@ -import * as providersModule from '@ethersproject/providers'; -import { ControllerMessenger } from '@metamask/base-controller'; -import { - NetworkType, - NetworksTicker, - toChecksumHexAddress, - toHex, -} from '@metamask/controller-utils'; - -import { EnsController } from './EnsController'; - -jest.mock('@ethersproject/providers', () => { - const originalModule = jest.requireActual('@ethersproject/providers'); - - return { - __esModule: true, - ...originalModule, - }; -}); - -const ZERO_X_ERROR_ADDRESS = '0x'; - -const address1 = '0x32Be343B94f860124dC4fEe278FDCBD38C102D88'; -const address2 = '0xc38bf1ad06ef69f0c04e29dbeb4152b4175f0a8d'; -const address3 = '0x89d24A6b4CcB1B6fAA2625fE562bDD9a23260359'; -const name1 = 'foobarb.eth'; -const name2 = 'bazbarb.eth'; - -const address1Checksum = toChecksumHexAddress(address1); -const address2Checksum = toChecksumHexAddress(address2); -const address3Checksum = toChecksumHexAddress(address3); - -const name = 'EnsController'; - -/** - * Constructs a restricted controller messenger. - * - * @returns A restricted controller messenger. - */ -function getMessenger() { - return new ControllerMessenger().getRestricted({ - name, - }); -} - -/** - * Creates a mock provider. - * - * @returns mock provider - */ -function getProvider() { - return () => Promise.resolve(null); -} - -describe('EnsController', () => { - it('should set default state', () => { - const messenger = getMessenger(); - const controller = new EnsController({ - messenger, - }); - expect(controller.state).toStrictEqual({ - ensEntries: {}, - ensResolutionsByAddress: {}, - }); - }); - - it('should add a new ENS entry and return true', () => { - const messenger = getMessenger(); - const controller = new EnsController({ - messenger, - }); - expect(controller.set(toHex(1), name1, address1)).toBe(true); - expect(controller.state).toStrictEqual({ - ensEntries: { - [toHex(1)]: { - [name1]: { - address: address1Checksum, - chainId: toHex(1), - ensName: name1, - }, - }, - }, - ensResolutionsByAddress: {}, - }); - }); - - it('should clear ensResolutionsByAddress state propery when resetState is called', async () => { - const messenger = getMessenger(); - const controller = new EnsController({ - messenger, - state: { - ensResolutionsByAddress: { - [address1Checksum]: 'peaksignal.eth', - }, - }, - }); - - expect(controller.state).toStrictEqual({ - ensResolutionsByAddress: { - [address1Checksum]: 'peaksignal.eth', - }, - ensEntries: {}, - }); - - controller.resetState(); - - expect(controller.state.ensResolutionsByAddress).toStrictEqual({}); - }); - - it('should clear ensResolutionsByAddress state propery on networkStateChange', async () => { - const messenger = getMessenger(); - const controller = new EnsController({ - messenger, - state: { - ensResolutionsByAddress: { - [address1Checksum]: 'peaksignal.eth', - }, - }, - provider: getProvider(), - onNetworkStateChange: (listener) => { - listener({ - providerConfig: { - chainId: toHex(1), - type: NetworkType.mainnet, - ticker: NetworksTicker.mainnet, - }, - }); - }, - }); - - expect(controller.state.ensResolutionsByAddress).toStrictEqual({}); - }); - - it('should add a new ENS entry with null address and return true', () => { - const messenger = getMessenger(); - const controller = new EnsController({ - messenger, - }); - expect(controller.set(toHex(1), name1, null)).toBe(true); - expect(controller.state).toStrictEqual({ - ensEntries: { - [toHex(1)]: { - [name1]: { - address: null, - chainId: toHex(1), - ensName: name1, - }, - }, - }, - ensResolutionsByAddress: {}, - }); - }); - - it('should update an ENS entry and return true', () => { - const messenger = getMessenger(); - const controller = new EnsController({ - messenger, - }); - expect(controller.set(toHex(1), name1, address1)).toBe(true); - expect(controller.set(toHex(1), name1, address2)).toBe(true); - expect(controller.state).toStrictEqual({ - ensEntries: { - [toHex(1)]: { - [name1]: { - address: address2Checksum, - chainId: toHex(1), - ensName: name1, - }, - }, - }, - ensResolutionsByAddress: {}, - }); - }); - - it('should update an ENS entry with null address and return true', () => { - const messenger = getMessenger(); - const controller = new EnsController({ - messenger, - }); - expect(controller.set(toHex(1), name1, address1)).toBe(true); - expect(controller.set(toHex(1), name1, null)).toBe(true); - expect(controller.state).toStrictEqual({ - ensEntries: { - [toHex(1)]: { - [name1]: { - address: null, - chainId: toHex(1), - ensName: name1, - }, - }, - }, - ensResolutionsByAddress: {}, - }); - }); - - it('should not update an ENS entry if the address is the same (valid address) and return false', () => { - const messenger = getMessenger(); - const controller = new EnsController({ - messenger, - }); - expect(controller.set(toHex(1), name1, address1)).toBe(true); - expect(controller.set(toHex(1), name1, address1)).toBe(false); - expect(controller.state).toStrictEqual({ - ensEntries: { - [toHex(1)]: { - [name1]: { - address: address1Checksum, - chainId: toHex(1), - ensName: name1, - }, - }, - }, - ensResolutionsByAddress: {}, - }); - }); - - it('should not update an ENS entry if the address is the same (null) and return false', () => { - const messenger = getMessenger(); - const controller = new EnsController({ - messenger, - }); - expect(controller.set(toHex(1), name1, null)).toBe(true); - expect(controller.set(toHex(1), name1, null)).toBe(false); - expect(controller.state).toStrictEqual({ - ensEntries: { - [toHex(1)]: { - [name1]: { - address: null, - chainId: toHex(1), - ensName: name1, - }, - }, - }, - ensResolutionsByAddress: {}, - }); - }); - - it('should add multiple ENS entries and update without side effects', () => { - const messenger = getMessenger(); - const controller = new EnsController({ - messenger, - }); - expect(controller.set(toHex(1), name1, address1)).toBe(true); - expect(controller.set(toHex(1), name2, address2)).toBe(true); - expect(controller.set(toHex(2), name1, address1)).toBe(true); - expect(controller.set(toHex(1), name1, address3)).toBe(true); - expect(controller.state).toStrictEqual({ - ensEntries: { - [toHex(1)]: { - [name1]: { - address: address3Checksum, - chainId: toHex(1), - ensName: name1, - }, - [name2]: { - address: address2Checksum, - chainId: toHex(1), - ensName: name2, - }, - }, - [toHex(2)]: { - [name1]: { - address: address1Checksum, - chainId: toHex(2), - ensName: name1, - }, - }, - }, - ensResolutionsByAddress: {}, - }); - }); - - it('should get ENS entry by chainId and ensName', () => { - const messenger = getMessenger(); - const controller = new EnsController({ - messenger, - }); - expect(controller.set(toHex(1), name1, address1)).toBe(true); - expect(controller.get(toHex(1), name1)).toStrictEqual({ - address: address1Checksum, - chainId: toHex(1), - ensName: name1, - }); - }); - - it('should return null when getting nonexistent name', () => { - const messenger = getMessenger(); - const controller = new EnsController({ - messenger, - }); - expect(controller.set(toHex(1), name1, address1)).toBe(true); - expect(controller.get(toHex(1), name2)).toBeNull(); - }); - - it('should return null when getting nonexistent chainId', () => { - const messenger = getMessenger(); - const controller = new EnsController({ - messenger, - }); - expect(controller.set(toHex(1), name1, address1)).toBe(true); - expect(controller.get(toHex(2), name1)).toBeNull(); - }); - - it('should throw on attempt to set invalid ENS entry: chainId', () => { - const messenger = getMessenger(); - const controller = new EnsController({ - messenger, - }); - expect(() => { - // @ts-expect-error Intentionally invalid chain ID - controller.set('a', name1, address1); - }).toThrow( - 'Invalid ENS entry: { chainId:a, ensName:foobarb.eth, address:0x32Be343B94f860124dC4fEe278FDCBD38C102D88}', - ); - expect(controller.state).toStrictEqual({ - ensEntries: {}, - ensResolutionsByAddress: {}, - }); - }); - - it('should throw on attempt to set invalid ENS entry: ENS name', () => { - const messenger = getMessenger(); - const controller = new EnsController({ - messenger, - }); - expect(() => { - controller.set(toHex(1), 'foo.eth', address1); - }).toThrow('Invalid ENS name: foo.eth'); - expect(controller.state).toStrictEqual({ - ensEntries: {}, - ensResolutionsByAddress: {}, - }); - }); - - it('should throw on attempt to set invalid ENS entry: address', () => { - const messenger = getMessenger(); - const controller = new EnsController({ - messenger, - }); - expect(() => { - controller.set(toHex(1), name1, 'foo'); - }).toThrow( - 'Invalid ENS entry: { chainId:0x1, ensName:foobarb.eth, address:foo}', - ); - expect(controller.state).toStrictEqual({ - ensEntries: {}, - ensResolutionsByAddress: {}, - }); - }); - - it('should remove an ENS entry and return true', () => { - const messenger = getMessenger(); - const controller = new EnsController({ - messenger, - }); - expect(controller.set(toHex(1), name1, address1)).toBe(true); - expect(controller.delete(toHex(1), name1)).toBe(true); - expect(controller.state).toStrictEqual({ - ensEntries: {}, - ensResolutionsByAddress: {}, - }); - }); - - it('should return false if an ENS entry was NOT deleted', () => { - const messenger = getMessenger(); - const controller = new EnsController({ - messenger, - }); - controller.set(toHex(1), name1, address1); - expect(controller.delete(toHex(1), 'bar')).toBe(false); - expect(controller.delete(toHex(2), 'bar')).toBe(false); - expect(controller.state).toStrictEqual({ - ensEntries: { - [toHex(1)]: { - [name1]: { - address: address1Checksum, - chainId: toHex(1), - ensName: name1, - }, - }, - }, - ensResolutionsByAddress: {}, - }); - }); - - it('should add multiple ENS entries and remove without side effects', () => { - const messenger = getMessenger(); - const controller = new EnsController({ - messenger, - }); - expect(controller.set(toHex(1), name1, address1)).toBe(true); - expect(controller.set(toHex(1), name2, address2)).toBe(true); - expect(controller.set(toHex(2), name1, address1)).toBe(true); - expect(controller.delete(toHex(1), name1)).toBe(true); - expect(controller.state).toStrictEqual({ - ensEntries: { - [toHex(1)]: { - [name2]: { - address: address2Checksum, - chainId: toHex(1), - ensName: name2, - }, - }, - [toHex(2)]: { - [name1]: { - address: address1Checksum, - chainId: toHex(2), - ensName: name1, - }, - }, - }, - ensResolutionsByAddress: {}, - }); - }); - - it('should clear all ENS entries', () => { - const messenger = getMessenger(); - const controller = new EnsController({ - messenger, - }); - expect(controller.set(toHex(1), name1, address1)).toBe(true); - expect(controller.set(toHex(1), name2, address2)).toBe(true); - expect(controller.set(toHex(2), name1, address1)).toBe(true); - controller.clear(); - expect(controller.state).toStrictEqual({ - ensEntries: {}, - ensResolutionsByAddress: {}, - }); - }); - - describe('reverseResolveName', () => { - it('should return undefined when eth provider is not defined', async () => { - const messenger = getMessenger(); - const ens = new EnsController({ - messenger, - }); - expect(await ens.reverseResolveAddress(address1)).toBeUndefined(); - }); - - it('should return undefined when network is loading', async function () { - const messenger = getMessenger(); - const ens = new EnsController({ - messenger, - provider: getProvider(), - onNetworkStateChange: (listener) => { - listener({ - providerConfig: { - chainId: toHex(1), - type: NetworkType.mainnet, - ticker: NetworksTicker.mainnet, - }, - }); - }, - }); - expect(await ens.reverseResolveAddress(address1)).toBeUndefined(); - }); - - it('should return undefined when network is not ens supported', async function () { - const messenger = getMessenger(); - const ens = new EnsController({ - messenger, - provider: getProvider(), - onNetworkStateChange: (listener) => { - listener({ - providerConfig: { - chainId: toHex(0), - type: NetworkType.mainnet, - ticker: NetworksTicker.mainnet, - }, - }); - }, - }); - expect(await ens.reverseResolveAddress(address1)).toBeUndefined(); - }); - - it('should only resolve an ENS name once', async () => { - const messenger = getMessenger(); - const ethProvider = new providersModule.Web3Provider(getProvider()); - jest.spyOn(ethProvider, 'resolveName').mockResolvedValue(address1); - jest - .spyOn(ethProvider, 'lookupAddress') - .mockResolvedValue('peaksignal.eth'); - jest.spyOn(providersModule, 'Web3Provider').mockReturnValue(ethProvider); - - const ens = new EnsController({ - messenger, - provider: getProvider(), - onNetworkStateChange: (listener) => { - listener({ - providerConfig: { - chainId: toHex(1), - type: NetworkType.mainnet, - ticker: NetworksTicker.mainnet, - }, - }); - }, - }); - - expect(await ens.reverseResolveAddress(address1)).toBe('peaksignal.eth'); - expect(await ens.reverseResolveAddress(address1)).toBe('peaksignal.eth'); - }); - - it('should fail if lookupAddress through an error', async () => { - const messenger = getMessenger(); - const ethProvider = new providersModule.Web3Provider(getProvider()); - jest.spyOn(ethProvider, 'lookupAddress').mockRejectedValue('error'); - jest.spyOn(providersModule, 'Web3Provider').mockReturnValue(ethProvider); - const ens = new EnsController({ - messenger, - provider: getProvider(), - onNetworkStateChange: (listener) => { - listener({ - providerConfig: { - chainId: toHex(1), - type: NetworkType.mainnet, - ticker: NetworksTicker.mainnet, - }, - }); - }, - }); - - expect(await ens.reverseResolveAddress(address1)).toBeUndefined(); - }); - - it('should fail if lookupAddress returns a null value', async () => { - const messenger = getMessenger(); - const ethProvider = new providersModule.Web3Provider(getProvider()); - jest.spyOn(ethProvider, 'lookupAddress').mockResolvedValue(null); - jest.spyOn(providersModule, 'Web3Provider').mockReturnValue(ethProvider); - const ens = new EnsController({ - messenger, - provider: getProvider(), - onNetworkStateChange: (listener) => { - listener({ - providerConfig: { - chainId: toHex(1), - type: NetworkType.mainnet, - ticker: NetworksTicker.mainnet, - }, - }); - }, - }); - - expect(await ens.reverseResolveAddress(address1)).toBeUndefined(); - }); - - it('should fail if resolveName through an error', async () => { - const messenger = getMessenger(); - const ethProvider = new providersModule.Web3Provider(getProvider()); - jest - .spyOn(ethProvider, 'lookupAddress') - .mockResolvedValue('peaksignal.eth'); - jest.spyOn(ethProvider, 'resolveName').mockRejectedValue('error'); - jest.spyOn(providersModule, 'Web3Provider').mockReturnValue(ethProvider); - const ens = new EnsController({ - messenger, - provider: getProvider(), - onNetworkStateChange: (listener) => { - listener({ - providerConfig: { - chainId: toHex(1), - type: NetworkType.mainnet, - ticker: NetworksTicker.mainnet, - }, - }); - }, - }); - - expect(await ens.reverseResolveAddress(address1)).toBeUndefined(); - }); - - it('should fail if resolveName returns a null value', async () => { - const messenger = getMessenger(); - const ethProvider = new providersModule.Web3Provider(getProvider()); - jest.spyOn(ethProvider, 'resolveName').mockResolvedValue(null); - jest - .spyOn(ethProvider, 'lookupAddress') - .mockResolvedValue('peaksignal.eth'); - jest.spyOn(providersModule, 'Web3Provider').mockReturnValue(ethProvider); - const ens = new EnsController({ - messenger, - provider: getProvider(), - onNetworkStateChange: (listener) => { - listener({ - providerConfig: { - chainId: toHex(1), - type: NetworkType.mainnet, - ticker: NetworksTicker.mainnet, - }, - }); - }, - }); - - expect(await ens.reverseResolveAddress(address1)).toBeUndefined(); - }); - - it('should fail if registred address is zero x error address', async () => { - const messenger = getMessenger(); - const ethProvider = new providersModule.Web3Provider(getProvider()); - jest - .spyOn(ethProvider, 'resolveName') - .mockResolvedValue(ZERO_X_ERROR_ADDRESS); - jest - .spyOn(ethProvider, 'lookupAddress') - .mockResolvedValue('peaksignal.eth'); - jest.spyOn(providersModule, 'Web3Provider').mockReturnValue(ethProvider); - const ens = new EnsController({ - messenger, - provider: getProvider(), - onNetworkStateChange: (listener) => { - listener({ - providerConfig: { - chainId: toHex(1), - type: NetworkType.mainnet, - ticker: NetworksTicker.mainnet, - }, - }); - }, - }); - - expect(await ens.reverseResolveAddress(address1)).toBeUndefined(); - }); - - it('should fail if the name is registered to a different address than the reverse resolved', async () => { - const messenger = getMessenger(); - - const ethProvider = new providersModule.Web3Provider(getProvider()); - jest.spyOn(ethProvider, 'resolveName').mockResolvedValue(address2); - jest - .spyOn(ethProvider, 'lookupAddress') - .mockResolvedValue('peaksignal.eth'); - jest.spyOn(providersModule, 'Web3Provider').mockReturnValue(ethProvider); - const ens = new EnsController({ - messenger, - provider: getProvider(), - onNetworkStateChange: (listener) => { - listener({ - providerConfig: { - chainId: toHex(1), - type: NetworkType.mainnet, - ticker: NetworksTicker.mainnet, - }, - }); - }, - }); - - expect(await ens.reverseResolveAddress(address1)).toBeUndefined(); - }); - }); -}); diff --git a/packages/ens-controller/src/EnsController.ts b/packages/ens-controller/src/EnsController.ts deleted file mode 100644 index e65ebcd20fa..00000000000 --- a/packages/ens-controller/src/EnsController.ts +++ /dev/null @@ -1,322 +0,0 @@ -import type { - ExternalProvider, - JsonRpcFetchFunc, -} from '@ethersproject/providers'; -import { Web3Provider } from '@ethersproject/providers'; -import type { RestrictedControllerMessenger } from '@metamask/base-controller'; -import { BaseControllerV2 } from '@metamask/base-controller'; -import type { ChainId } from '@metamask/controller-utils'; -import { - normalizeEnsName, - isValidHexAddress, - toChecksumHexAddress, - CHAIN_ID_TO_ETHERS_NETWORK_NAME_MAP, - convertHexToDecimal, -} from '@metamask/controller-utils'; -import type { NetworkState } from '@metamask/network-controller'; -import type { Hex } from '@metamask/utils'; -import { createProjectLogger } from '@metamask/utils'; -import ensNetworkMap from 'ethereum-ens-network-map'; -import { toASCII } from 'punycode/'; - -const log = createProjectLogger('ens-controller'); - -const name = 'EnsController'; - -/** - * @type EnsEntry - * - * ENS entry representation - * @property chainId - Id of the associated chain - * @property ensName - The ENS name - * @property address - Hex address with the ENS name, or null - */ -export type EnsEntry = { - chainId: Hex; - ensName: string; - address: string | null; -}; - -/** - * @type EnsControllerState - * - * ENS controller state - * @property ensEntries - Object of ENS entry objects - */ -export type EnsControllerState = { - ensEntries: { - [chainId: Hex]: { - [ensName: string]: EnsEntry; - }; - }; - ensResolutionsByAddress: { [key: string]: string }; -}; - -export type EnsControllerMessenger = RestrictedControllerMessenger< - typeof name, - never, - never, - never, - never ->; - -const metadata = { - ensEntries: { persist: true, anonymous: false }, - ensResolutionsByAddress: { persist: true, anonymous: false }, -}; - -const defaultState = { - ensEntries: {}, - ensResolutionsByAddress: {}, -}; - -const ZERO_ADDRESS = '0x0000000000000000000000000000000000000000'; -const ZERO_X_ERROR_ADDRESS = '0x'; - -/** - * Controller that manages a list ENS names and their resolved addresses - * by chainId. A null address indicates an unresolved ENS name. - */ -export class EnsController extends BaseControllerV2< - typeof name, - EnsControllerState, - EnsControllerMessenger -> { - #ethProvider: Web3Provider | null = null; - - /** - * Creates an EnsController instance. - * - * @param options - Constructor options. - * @param options.messenger - A reference to the messaging system. - * @param options.state - Initial state to set on this controller. - * @param options.provider - Provider instance. - * @param options.onNetworkStateChange - Allows registering an event handler for - * when the network controller state updated. - */ - constructor({ - messenger, - state = {}, - provider, - onNetworkStateChange, - }: { - messenger: EnsControllerMessenger; - state?: Partial; - provider?: ExternalProvider | JsonRpcFetchFunc; - onNetworkStateChange?: ( - listener: (networkState: Pick) => void, - ) => void; - }) { - super({ - name, - metadata, - messenger, - state: { - ...defaultState, - ...state, - }, - }); - - if (provider && onNetworkStateChange) { - onNetworkStateChange((networkState) => { - this.resetState(); - const currentChainId = networkState.providerConfig.chainId; - if (this.#getChainEnsSupport(currentChainId)) { - this.#ethProvider = new Web3Provider(provider, { - chainId: convertHexToDecimal(currentChainId), - name: CHAIN_ID_TO_ETHERS_NETWORK_NAME_MAP[ - currentChainId as ChainId - ], - ensAddress: ensNetworkMap[parseInt(currentChainId, 16)], - }); - } else { - this.#ethProvider = null; - } - }); - } - } - - /** - * Clears ensResolutionsByAddress state property. - */ - resetState() { - this.update((currentState) => { - currentState.ensResolutionsByAddress = {}; - }); - } - - /** - * Remove all chain Ids and ENS entries from state. - */ - clear() { - this.update((state) => { - state.ensEntries = {}; - }); - } - - /** - * Delete an ENS entry. - * - * @param chainId - Parent chain of the ENS entry to delete. - * @param ensName - Name of the ENS entry to delete. - * @returns Boolean indicating if the entry was deleted. - */ - delete(chainId: Hex, ensName: string): boolean { - const normalizedEnsName = normalizeEnsName(ensName); - if ( - !normalizedEnsName || - !this.state.ensEntries[chainId] || - !this.state.ensEntries[chainId][normalizedEnsName] - ) { - return false; - } - - this.update((state) => { - delete state.ensEntries[chainId][normalizedEnsName]; - - if (Object.keys(state.ensEntries[chainId]).length === 0) { - delete state.ensEntries[chainId]; - } - }); - return true; - } - - /** - * Retrieve a DNS entry. - * - * @param chainId - Parent chain of the ENS entry to retrieve. - * @param ensName - Name of the ENS entry to retrieve. - * @returns The EnsEntry or null if it does not exist. - */ - get(chainId: Hex, ensName: string): EnsEntry | null { - const normalizedEnsName = normalizeEnsName(ensName); - - // TODO Explicitly handle the case where `normalizedEnsName` is `null` - // eslint-disable-next-line no-implicit-coercion - return !!normalizedEnsName && this.state.ensEntries[chainId] - ? this.state.ensEntries[chainId][normalizedEnsName] || null - : null; - } - - /** - * Add or update an ENS entry by chainId and ensName. - * - * A null address indicates that the ENS name does not resolve. - * - * @param chainId - Id of the associated chain. - * @param ensName - The ENS name. - * @param address - Associated address (or null) to add or update. - * @returns Boolean indicating if the entry was set. - */ - set(chainId: Hex, ensName: string, address: string | null): boolean { - if ( - !Number.isInteger(Number.parseInt(chainId, 10)) || - !ensName || - typeof ensName !== 'string' || - (address && !isValidHexAddress(address)) - ) { - throw new Error( - `Invalid ENS entry: { chainId:${chainId}, ensName:${ensName}, address:${address}}`, - ); - } - - const normalizedEnsName = normalizeEnsName(ensName); - if (!normalizedEnsName) { - throw new Error(`Invalid ENS name: ${ensName}`); - } - - const normalizedAddress = address ? toChecksumHexAddress(address) : null; - const subState = this.state.ensEntries[chainId]; - - if ( - subState?.[normalizedEnsName] && - subState[normalizedEnsName].address === normalizedAddress - ) { - return false; - } - - this.update((state) => { - state.ensEntries = { - ...this.state.ensEntries, - [chainId]: { - ...this.state.ensEntries[chainId], - [normalizedEnsName]: { - address: normalizedAddress, - chainId, - ensName: normalizedEnsName, - }, - }, - }; - }); - return true; - } - - /** - * Check if the chain supports ENS. - * - * @param chainId - chain id. - * @returns Boolean indicating if the chain supports ENS. - */ - #getChainEnsSupport(chainId: string) { - return Boolean(ensNetworkMap[parseInt(chainId, 16)]); - } - - /** - * Resolve ens by address. - * - * @param nonChecksummedAddress - address - * @returns ens resolution - */ - async reverseResolveAddress(nonChecksummedAddress: string) { - if (!this.#ethProvider) { - return undefined; - } - - const address = toChecksumHexAddress(nonChecksummedAddress); - if (this.state.ensResolutionsByAddress[address]) { - return this.state.ensResolutionsByAddress[address]; - } - - let domain: string | null; - try { - domain = await this.#ethProvider.lookupAddress(address); - } catch (error) { - log(error); - return undefined; - } - - if (!domain) { - return undefined; - } - - let registeredAddress: string | null; - try { - registeredAddress = await this.#ethProvider.resolveName(domain); - } catch (error) { - log(error); - return undefined; - } - - if (!registeredAddress) { - return undefined; - } - - if ( - registeredAddress === ZERO_ADDRESS || - registeredAddress === ZERO_X_ERROR_ADDRESS - ) { - return undefined; - } - if (toChecksumHexAddress(registeredAddress) !== address) { - return undefined; - } - - this.update((state) => { - state.ensResolutionsByAddress[address] = toASCII(domain as string); - }); - - return domain; - } -} - -export default EnsController; diff --git a/packages/ens-controller/src/index.ts b/packages/ens-controller/src/index.ts deleted file mode 100644 index 14cbf704a6b..00000000000 --- a/packages/ens-controller/src/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from './EnsController'; diff --git a/packages/ens-controller/tsconfig.build.json b/packages/ens-controller/tsconfig.build.json deleted file mode 100644 index ac0df4920c6..00000000000 --- a/packages/ens-controller/tsconfig.build.json +++ /dev/null @@ -1,14 +0,0 @@ -{ - "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": "../network-controller/tsconfig.build.json" } - ], - "include": ["../../types", "./src"] -} diff --git a/packages/ens-controller/tsconfig.json b/packages/ens-controller/tsconfig.json deleted file mode 100644 index 4bbb0be81b1..00000000000 --- a/packages/ens-controller/tsconfig.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "extends": "../../tsconfig.packages.json", - "compilerOptions": { - "baseUrl": "./" - }, - "references": [ - { "path": "../base-controller" }, - { "path": "../controller-utils" }, - { "path": "../network-controller" } - ], - "include": ["../../types", "./src"] -} diff --git a/packages/eth-block-tracker/CHANGELOG.md b/packages/eth-block-tracker/CHANGELOG.md new file mode 100644 index 00000000000..a3a78df6537 --- /dev/null +++ b/packages/eth-block-tracker/CHANGELOG.md @@ -0,0 +1,53 @@ +# 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] + +### Changed + +- Bump `@metamask/utils` from `^11.9.0` to `^11.11.0` ([#9074](https://github.com/MetaMask/core/pull/9074)) +- Bump `@metamask/json-rpc-engine` from `^10.2.4` to `^10.3.0` ([#8661](https://github.com/MetaMask/core/pull/8661)) +- Bump `@metamask/eth-json-rpc-provider` from `^6.0.0` to `^6.0.1` ([#8317](https://github.com/MetaMask/core/pull/8317)) + +### Fixed + +- Deduplicate concurrent `checkForLatestBlock()` calls so they share a single ongoing request and resolve to the same promise, instead of each issuing its own `eth_blockNumber` request ([#7905](https://github.com/MetaMask/core/pull/7905)) + +## [15.0.1] + +### Changed + +- Upgrade `@metamask/utils` from `^11.8.1` to `^11.9.0` ([#7511](https://github.com/MetaMask/core/pull/7511)) + +## [15.0.0] + +### Added + +- Add `Context` generic parameter to `PollingBlockTracker` ([#7061](https://github.com/MetaMask/core/pull/7061)) + - This enables passing providers with different context types to the block tracker. + +### Changed + +- Bump `@metamask/eth-json-rpc-provider` from `^5.0.1` to `^6.0.0` ([#7202](https://github.com/MetaMask/core/pull/7202)) +- **BREAKING:** Use `InternalProvider` instead of `SafeEventEmitterProvider` ([#6796](https://github.com/MetaMask/core/pull/6796)) + - The block tracker expects a provider with an `InternalProvider` instead of a `SafeEventEmitterProvider`. +- **BREAKING:** Migrate to `JsonRpcEngineV2` ([#7001](https://github.com/MetaMask/core/pull/7001)) + +## [14.0.0] + +### Changed + +- **BREAKING:** Update minimum Node.js version from `^18.16.0` to `^18.18.0` ([#6865](https://github.com/MetaMask/core/pull/6865)) +- This package was migrated from `MetaMask/eth-block-tracker` to the + `MetaMask/core` monorepo ([#6865](https://github.com/MetaMask/core/pull/6865)) + - See [`MetaMask/eth-block-tracker`](https://github.com/MetaMask/eth-block-tracker/blob/main/CHANGELOG.md) + for the original changelog. + +[Unreleased]: https://github.com/MetaMask/core/compare/@metamask/eth-block-tracker@15.0.1...HEAD +[15.0.1]: https://github.com/MetaMask/core/compare/@metamask/eth-block-tracker@15.0.0...@metamask/eth-block-tracker@15.0.1 +[15.0.0]: https://github.com/MetaMask/core/compare/@metamask/eth-block-tracker@14.0.0...@metamask/eth-block-tracker@15.0.0 +[14.0.0]: https://github.com/MetaMask/core/releases/tag/@metamask/eth-block-tracker@14.0.0 diff --git a/packages/eth-block-tracker/LICENSE b/packages/eth-block-tracker/LICENSE new file mode 100644 index 00000000000..bbed2e24b91 --- /dev/null +++ b/packages/eth-block-tracker/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2018 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/eth-block-tracker/README.md b/packages/eth-block-tracker/README.md new file mode 100644 index 00000000000..38078cb302a --- /dev/null +++ b/packages/eth-block-tracker/README.md @@ -0,0 +1,91 @@ +# `@metamask/eth-block-tracker` + +This module walks the Ethereum blockchain, keeping track of the latest block. It uses a web3 provider as a data source and will continuously poll for the next block. + +## Installation + +`yarn add @metamask/eth-block-tracker` + +or + +`npm install @metamask/eth-block-tracker` + +## Usage + +```js +const createInfuraProvider = require('@metamask/eth-json-rpc-infura'); +const { PollingBlockTracker } = require('@metamask/eth-block-tracker'); + +const provider = createInfuraProvider({ + network: 'mainnet', + projectId: process.env.INFURA_PROJECT_ID, +}); +const blockTracker = new PollingBlockTracker({ provider }); + +blockTracker.on('sync', ({ newBlock, oldBlock }) => { + if (oldBlock) { + console.log(`sync #${Number(oldBlock)} -> #${Number(newBlock)}`); + } else { + console.log(`first sync #${Number(newBlock)}`); + } +}); +``` + +## API + +### Methods + +#### new PollingBlockTracker({ provider, pollingInterval, retryTimeout, keepEventLoopActive, usePastBlocks }) + +- Creates a new block tracker with `provider` as a data source and `pollingInterval` (ms) timeout between polling for the latest block. +- If an error is encountered when fetching blocks, it will wait `retryTimeout` (ms) before attempting again. +- If `keepEventLoopActive` is `false`, in Node.js it will [unref the polling timeout](https://nodejs.org/api/timers.html#timers_timeout_unref), allowing the process to exit during the polling interval. Defaults to `true`, meaning the process will be kept alive. +- If `usePastBlocks` is `true`, block numbers less than the current block number can used and emitted. Defaults to `false`, meaning that only block numbers greater than the current block number will be used and emitted. + +#### getCurrentBlock() + +Synchronously returns the current block. May be `null`. + +```js +console.log(blockTracker.getCurrentBlock()); +``` + +#### async getLatestBlock() + +Asynchronously returns the latest block. if not immediately available, it will fetch one. + +#### async checkForLatestBlock() + +Tells the block tracker to ask for a new block immediately, in addition to its normal polling interval. Useful if you received a hint of a new block (e.g. via `tx.blockNumber` from `getTransactionByHash`). Will resolve to the new latest block when done polling. + +### Events + +#### latest + +The `latest` event is emitted for whenever a new latest block is detected. This may mean skipping blocks if there were two created since the last polling period. + +```js +blockTracker.on('latest', (newBlock) => console.log(newBlock)); +``` + +#### sync + +The `sync` event is emitted the same as "latest" but includes the previous block. + +```js +blockTracker.on('sync', ({ newBlock, oldBlock }) => + console.log(newBlock, oldBlock), +); +``` + +#### error + +The `error` event means an error occurred while polling for the latest block. + +```js +blockTracker.on('error', (err) => console.error(err)); +``` + +## 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/eth-block-tracker/jest.config.js b/packages/eth-block-tracker/jest.config.js new file mode 100644 index 00000000000..01a358a1cef --- /dev/null +++ b/packages/eth-block-tracker/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: 98.38, + functions: 100, + lines: 100, + statements: 100, + }, + }, +}); diff --git a/packages/eth-block-tracker/package.json b/packages/eth-block-tracker/package.json new file mode 100644 index 00000000000..bf2d8c49b59 --- /dev/null +++ b/packages/eth-block-tracker/package.json @@ -0,0 +1,80 @@ +{ + "name": "@metamask/eth-block-tracker", + "version": "15.0.1", + "description": "A block tracker for the Ethereum blockchain. Keeps track of the latest block", + "keywords": [ + "Ethereum", + "MetaMask" + ], + "homepage": "https://github.com/MetaMask/core/tree/main/packages/eth-block-tracker#readme", + "bugs": { + "url": "https://github.com/MetaMask/core/issues" + }, + "license": "MIT", + "repository": { + "type": "git", + "url": "https://github.com/MetaMask/core.git" + }, + "files": [ + "dist", + "dist/" + ], + "sideEffects": false, + "main": "./dist/index.cjs", + "module": "./dist/index.mjs", + "types": "./dist/index.d.cts", + "exports": { + ".": { + "import": { + "types": "./dist/index.d.mts", + "default": "./dist/index.mjs" + }, + "require": { + "types": "./dist/index.d.cts", + "default": "./dist/index.cjs" + } + }, + "./package.json": "./package.json" + }, + "publishConfig": { + "access": "public", + "registry": "https://registry.npmjs.org/" + }, + "scripts": { + "build": "ts-bridge --project tsconfig.build.json --verbose --clean --no-references", + "build:all": "ts-bridge --project tsconfig.build.json --verbose --clean", + "build:docs": "typedoc", + "changelog:update": "../../scripts/update-changelog.sh @metamask/eth-block-tracker", + "changelog:validate": "../../scripts/validate-changelog.sh @metamask/eth-block-tracker", + "lint:tsconfigs": "tsx ../../scripts/lint-tsconfigs/lint-tsconfigs.mts", + "lint:tsconfigs:fix": "tsx ../../scripts/lint-tsconfigs/lint-tsconfigs.mts --fix", + "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/eth-json-rpc-provider": "^6.0.1", + "@metamask/safe-event-emitter": "^3.0.0", + "@metamask/utils": "^11.11.0", + "json-rpc-random-id": "^1.0.1" + }, + "devDependencies": { + "@metamask/auto-changelog": "^6.1.0", + "@metamask/json-rpc-engine": "^10.5.0", + "@ts-bridge/cli": "^0.6.4", + "@types/jest": "^30.0.0", + "@types/json-rpc-random-id": "^1.0.1", + "deepmerge": "^4.2.2", + "jest": "^30.4.2", + "ts-jest": "^29.4.11", + "tsx": "^4.20.5", + "typedoc": "^0.25.13", + "typedoc-plugin-missing-exports": "^2.0.0", + "typescript": "~5.3.3" + }, + "engines": { + "node": "^18.18 || >=20" + } +} diff --git a/packages/eth-block-tracker/src/BlockTracker.ts b/packages/eth-block-tracker/src/BlockTracker.ts new file mode 100644 index 00000000000..399f05c3b32 --- /dev/null +++ b/packages/eth-block-tracker/src/BlockTracker.ts @@ -0,0 +1,13 @@ +import type SafeEventEmitter from '@metamask/safe-event-emitter'; + +export type BlockTracker = SafeEventEmitter & { + destroy(): Promise; + + isRunning(): boolean; + + getCurrentBlock(): string | null; + + getLatestBlock(): Promise; + + checkForLatestBlock(): Promise; +}; diff --git a/packages/eth-block-tracker/src/PollingBlockTracker.test.ts b/packages/eth-block-tracker/src/PollingBlockTracker.test.ts new file mode 100644 index 00000000000..1394d2fd6ca --- /dev/null +++ b/packages/eth-block-tracker/src/PollingBlockTracker.test.ts @@ -0,0 +1,3552 @@ +import { createDeferredPromise } from '@metamask/utils'; + +import EMPTY_FUNCTION from '../tests/emptyFunction.js'; +import recordCallsToSetTimeout from '../tests/recordCallsToSetTimeout.js'; +import { withPollingBlockTracker } from '../tests/withBlockTracker.js'; +import { PollingBlockTracker } from './index.js'; + +type Sync = { + oldBlock: string; + newBlock: string; +}; + +const METHODS_TO_ADD_LISTENER = ['on', 'addListener'] as const; +const METHODS_TO_REMOVE_LISTENER = ['off', 'removeListener'] as const; +const originalSetTimeout = setTimeout; + +describe('PollingBlockTracker', () => { + describe('constructor', () => { + it('should throw if given no options', () => { + expect(() => new PollingBlockTracker()).toThrow( + 'PollingBlockTracker - no provider specified.', + ); + }); + + it('should throw if given options but not given a provider', () => { + expect(() => new PollingBlockTracker({})).toThrow( + 'PollingBlockTracker - no provider specified.', + ); + }); + + it('should return a block tracker that is not running', async () => { + recordCallsToSetTimeout(); + + await withPollingBlockTracker(({ blockTracker }) => { + expect(blockTracker.isRunning()).toBe(false); + }); + }); + }); + + describe('destroy', () => { + it('should stop the block tracker if any "latest" and "sync" events were added previously', async () => { + recordCallsToSetTimeout(); + + await withPollingBlockTracker(async ({ blockTracker }) => { + blockTracker.on('latest', EMPTY_FUNCTION); + await new Promise((resolve) => { + blockTracker.on('sync', resolve); + }); + expect(blockTracker.isRunning()).toBe(true); + + await blockTracker.destroy(); + + expect(blockTracker.isRunning()).toBe(false); + }); + }); + + it('should not start a timer to clear the current block number if called after removing all listeners but before enough time passes that the cache would have been cleared', async () => { + const setTimeoutRecorder = recordCallsToSetTimeout(); + const blockTrackerOptions = { + pollingInterval: 100, + blockResetDuration: 200, + }; + + await withPollingBlockTracker( + { + provider: { + stubs: [ + { + methodName: 'eth_blockNumber', + result: '0x0', + }, + ], + }, + blockTracker: blockTrackerOptions, + }, + async ({ blockTracker }) => { + blockTracker.on('latest', EMPTY_FUNCTION); + blockTracker.on('sync', EMPTY_FUNCTION); + await new Promise((resolve) => { + blockTracker.on('_waitingForNextIteration', resolve); + }); + expect(blockTracker.getCurrentBlock()).toBe('0x0'); + blockTracker.removeAllListeners(); + expect( + setTimeoutRecorder.calls.some((call) => { + return call.duration === blockTrackerOptions.blockResetDuration; + }), + ).toBe(true); + + await blockTracker.destroy(); + + expect( + setTimeoutRecorder.calls.some((call) => { + return call.duration === blockTrackerOptions.blockResetDuration; + }), + ).toBe(false); + + await new Promise((resolve) => + originalSetTimeout(resolve, blockTrackerOptions.blockResetDuration), + ); + expect(blockTracker.getCurrentBlock()).toBe('0x0'); + }, + ); + }); + + it('should only clear the current block number if enough time passes after all "latest" and "sync" events are removed', async () => { + const setTimeoutRecorder = recordCallsToSetTimeout(); + const blockTrackerOptions = { + pollingInterval: 100, + blockResetDuration: 200, + }; + + await withPollingBlockTracker( + { + provider: { + stubs: [ + { + methodName: 'eth_blockNumber', + result: '0x0', + }, + ], + }, + blockTracker: blockTrackerOptions, + }, + async ({ blockTracker }) => { + blockTracker.on('latest', EMPTY_FUNCTION); + blockTracker.on('sync', EMPTY_FUNCTION); + await new Promise((resolve) => { + blockTracker.on('_waitingForNextIteration', resolve); + }); + expect(blockTracker.getCurrentBlock()).toBe('0x0'); + blockTracker.removeAllListeners(); + await setTimeoutRecorder.nextMatchingDuration( + blockTrackerOptions.blockResetDuration, + ); + + await blockTracker.destroy(); + + expect(blockTracker.getCurrentBlock()).toBeNull(); + }, + ); + }); + }); + + describe('getLatestBlock', () => { + describe('when the block tracker is not running', () => { + describe('if no other concurrent call exists', () => { + describe('if the latest block number has already been fetched once', () => { + it('returns the block number', async () => { + recordCallsToSetTimeout(); + + await withPollingBlockTracker( + { + provider: { + stubs: [ + { + methodName: 'eth_blockNumber', + result: '0x1', + }, + ], + }, + }, + async ({ blockTracker }) => { + await blockTracker.getLatestBlock(); + const block = await blockTracker.getLatestBlock(); + expect(block).toBe('0x1'); + }, + ); + }); + }); + + describe('if the latest block number has not been fetched yet', () => { + it('does not start the block tracker', async () => { + recordCallsToSetTimeout(); + + await withPollingBlockTracker(async ({ blockTracker }) => { + expect(blockTracker.isRunning()).toBe(false); + await blockTracker.getLatestBlock(); + expect(blockTracker.isRunning()).toBe(false); + }); + }); + + describe('if the latest block number is successfully fetched', () => { + it('returns the fetched latest block number', async () => { + recordCallsToSetTimeout(); + + await withPollingBlockTracker(async ({ blockTracker }) => { + const block = await blockTracker.getLatestBlock(); + expect(block).toBe('0x0'); + }); + }); + + it('should start a timer to clear the current block number later', async () => { + const setTimeoutRecorder = recordCallsToSetTimeout(); + const blockResetDuration = 1000; + + await withPollingBlockTracker( + { + blockTracker: { + blockResetDuration, + }, + provider: { + stubs: [ + { + methodName: 'eth_blockNumber', + result: '0x0', + }, + ], + }, + }, + async ({ blockTracker }) => { + const block = await blockTracker.getLatestBlock(); + expect(block).toBe('0x0'); + await setTimeoutRecorder.nextMatchingDuration( + blockResetDuration, + ); + expect(blockTracker.getCurrentBlock()).toBeNull(); + }, + ); + }); + }); + + describe('if an error occurs while fetching the latest block number', () => { + it('re-throws the error', async () => { + recordCallsToSetTimeout(); + + await withPollingBlockTracker( + { + provider: { + stubs: [ + { + methodName: 'eth_blockNumber', + error: new Error('boom'), + }, + ], + }, + }, + async ({ blockTracker }) => { + await expect(blockTracker.getLatestBlock()).rejects.toThrow( + 'boom', + ); + }, + ); + }); + + it('does not emit "error"', async () => { + recordCallsToSetTimeout(); + + await withPollingBlockTracker( + { + provider: { + stubs: [ + { + methodName: 'eth_blockNumber', + error: new Error('boom'), + }, + ], + }, + }, + async ({ blockTracker }) => { + const errorListener = jest.fn(); + blockTracker.on('error', errorListener); + await expect(blockTracker.getLatestBlock()).rejects.toThrow( + 'boom', + ); + expect(errorListener).not.toHaveBeenCalled(); + }, + ); + }); + }); + }); + }); + + describe('if already called concurrently', () => { + describe('if the latest block number is successfully fetched', () => { + it('returns the block number that the other call returns', async () => { + recordCallsToSetTimeout(); + + await withPollingBlockTracker(async ({ blockTracker }) => { + const promise1 = blockTracker.getLatestBlock(); + const promise2 = blockTracker.getLatestBlock(); + const [block1, block2] = await Promise.all([promise1, promise2]); + expect(block1).toBe(block2); + }); + }); + }); + + describe('if an error occurs while fetching the latest block number', () => { + it('throws the error that the other call throws', async () => { + const thrownError = new Error('boom'); + recordCallsToSetTimeout(); + + await withPollingBlockTracker( + { + provider: { + stubs: [ + { + methodName: 'eth_blockNumber', + error: thrownError, + }, + ], + }, + }, + async ({ blockTracker }) => { + const promise1 = blockTracker.getLatestBlock(); + const promise2 = blockTracker.getLatestBlock(); + await expect(promise1).rejects.toThrow(thrownError); + await expect(promise2).rejects.toThrow(thrownError); + }, + ); + }); + }); + }); + + it('request the latest block number with `skipCache: true` if the block tracker was initialized with `setSkipCacheFlag: true`', async () => { + recordCallsToSetTimeout(); + + await withPollingBlockTracker( + { blockTracker: { setSkipCacheFlag: true } }, + async ({ provider, blockTracker }) => { + jest.spyOn(provider, 'request'); + + await blockTracker.getLatestBlock(); + + expect(provider.request).toHaveBeenCalledWith({ + jsonrpc: '2.0' as const, + id: expect.any(Number), + method: 'eth_blockNumber' as const, + params: [], + skipCache: true, + }); + }, + ); + }); + + it('should not ask for a new block number while the current block number is cached', async () => { + recordCallsToSetTimeout(); + + await withPollingBlockTracker(async ({ provider, blockTracker }) => { + const requestSpy = jest.spyOn(provider, 'request'); + await blockTracker.getLatestBlock(); + await blockTracker.getLatestBlock(); + const requestsForLatestBlock = requestSpy.mock.calls.filter( + (args) => { + return args[0].method === 'eth_blockNumber'; + }, + ); + expect(requestsForLatestBlock).toHaveLength(1); + }); + }); + }); + + describe('when the block tracker is already started', () => { + it('should return a promise that rejects if the request for the block number fails and the block tracker is then stopped', async () => { + recordCallsToSetTimeout({ numAutomaticCalls: 1 }); + + await withPollingBlockTracker( + { + provider: { + stubs: [ + { + methodName: 'eth_blockNumber', + error: new Error('boom'), + }, + ], + }, + }, + async ({ blockTracker }) => { + blockTracker.on('latest', EMPTY_FUNCTION); + + const latestBlockPromise = blockTracker.getLatestBlock(); + + expect(blockTracker.isRunning()).toBe(true); + await blockTracker.destroy(); + await expect(latestBlockPromise).rejects.toThrow( + 'Block tracker destroyed', + ); + expect(blockTracker.isRunning()).toBe(false); + }, + ); + }); + + it('should not retry failed requests after the block tracker is stopped', async () => { + recordCallsToSetTimeout({ numAutomaticCalls: 1 }); + + await withPollingBlockTracker( + { + provider: { + stubs: [ + { + methodName: 'eth_blockNumber', + error: new Error('boom'), + }, + ], + }, + }, + async ({ blockTracker, provider }) => { + blockTracker.on('latest', EMPTY_FUNCTION); + const requestSpy = jest.spyOn(provider, 'request'); + + const latestBlockPromise = blockTracker.getLatestBlock(); + await blockTracker.destroy(); + + await expect(latestBlockPromise).rejects.toThrow( + 'Block tracker destroyed', + ); + expect(requestSpy).toHaveBeenCalledTimes(1); + expect(requestSpy).toHaveBeenCalledWith({ + jsonrpc: '2.0', + id: expect.any(Number), + method: 'eth_blockNumber', + params: [], + }); + }, + ); + }); + + it('should log an error if, while making a request for the latest block number, the provider throws and there is nothing listening to "error"', async () => { + const thrownError = new Error('boom'); + recordCallsToSetTimeout({ numAutomaticCalls: 1 }); + + await withPollingBlockTracker( + { + provider: { + stubs: [ + { + methodName: 'eth_blockNumber', + implementation: (): never => { + throw thrownError; + }, + }, + ], + }, + }, + async ({ blockTracker }) => { + blockTracker.on('latest', EMPTY_FUNCTION); + jest.spyOn(console, 'error').mockImplementation(EMPTY_FUNCTION); + + await expect(blockTracker.getLatestBlock()).rejects.toThrow('boom'); + await new Promise((resolve) => { + blockTracker.on('_waitingForNextIteration', resolve); + }); + + expect(console.error).toHaveBeenCalledWith( + 'Error updating latest block: boom', + ); + }, + ); + }); + + it('should log an error if, while requesting the latest block number, the provider rejects and there is nothing listening to "error"', async () => { + const thrownError = new Error('boom'); + recordCallsToSetTimeout({ numAutomaticCalls: 1 }); + + await withPollingBlockTracker( + { + provider: { + stubs: [ + { + methodName: 'eth_blockNumber', + error: thrownError, + }, + ], + }, + }, + async ({ blockTracker }) => { + blockTracker.on('latest', EMPTY_FUNCTION); + jest.spyOn(console, 'error').mockImplementation(EMPTY_FUNCTION); + + await expect(blockTracker.getLatestBlock()).rejects.toThrow('boom'); + await new Promise((resolve) => { + blockTracker.on('_waitingForNextIteration', resolve); + }); + + expect(console.error).toHaveBeenCalledWith( + 'Error updating latest block: boom', + ); + }, + ); + }); + + it('should update the current block number', async () => { + recordCallsToSetTimeout(); + + await withPollingBlockTracker( + { + provider: { + stubs: [ + { + methodName: 'eth_blockNumber', + result: '0x0', + }, + ], + }, + }, + async ({ blockTracker }) => { + blockTracker.on('latest', EMPTY_FUNCTION); + await blockTracker.getLatestBlock(); + const currentBlockNumber = blockTracker.getCurrentBlock(); + expect(currentBlockNumber).toBe('0x0'); + }, + ); + }); + + it('should not start a timer to clear the current block number later', async () => { + const setTimeoutRecorder = recordCallsToSetTimeout(); + const blockTrackerOptions = { + pollingInterval: 100, + blockResetDuration: 200, + }; + + await withPollingBlockTracker( + { + provider: { + stubs: [ + { + methodName: 'eth_blockNumber', + result: '0x0', + }, + ], + }, + blockTracker: blockTrackerOptions, + }, + async ({ blockTracker }) => { + blockTracker.on('latest', EMPTY_FUNCTION); + await blockTracker.getLatestBlock(); + const currentBlockNumber = blockTracker.getCurrentBlock(); + expect(currentBlockNumber).toBe('0x0'); + + const blockResetTimeouts = setTimeoutRecorder.calls.filter( + (call) => { + return call.duration === blockTrackerOptions.blockResetDuration; + }, + ); + expect(blockResetTimeouts).toHaveLength(0); + }, + ); + }); + + describe('if no other concurrent call exists', () => { + describe('if the latest block number has already been fetched once', () => { + it('returns the block number', async () => { + recordCallsToSetTimeout(); + + await withPollingBlockTracker(async ({ blockTracker }) => { + blockTracker.on('latest', EMPTY_FUNCTION); + await blockTracker.getLatestBlock(); + const block = await blockTracker.getLatestBlock(); + expect(block).toBe('0x0'); + }); + }); + }); + + describe('if the latest block number has not been fetched yet', () => { + describe('if the latest block number is successfully fetched on the next poll iteration', () => { + it('returns the fetched latest block number', async () => { + recordCallsToSetTimeout(); + + await withPollingBlockTracker(async ({ blockTracker }) => { + blockTracker.on('latest', EMPTY_FUNCTION); + const block = await blockTracker.getLatestBlock(); + expect(block).toBe('0x0'); + }); + }); + + it('does not stop the block tracker once complete', async () => { + recordCallsToSetTimeout(); + + await withPollingBlockTracker(async ({ blockTracker }) => { + blockTracker.on('latest', EMPTY_FUNCTION); + await blockTracker.getLatestBlock(); + expect(blockTracker.isRunning()).toBe(true); + }); + }); + }); + + describe('if an error occurs while fetching the latest block number on the next poll iteration', () => { + it('emits "error" if anything is listening to "error"', async () => { + const thrownError = new Error('boom'); + recordCallsToSetTimeout(); + + await withPollingBlockTracker( + { + provider: { + stubs: [ + { + methodName: 'eth_blockNumber', + error: thrownError, + }, + ], + }, + }, + async ({ blockTracker }) => { + const errorListener = jest.fn(); + blockTracker.on('error', errorListener); + blockTracker.on('latest', EMPTY_FUNCTION); + await expect(blockTracker.getLatestBlock()).rejects.toThrow( + 'boom', + ); + expect(errorListener).toHaveBeenCalledWith(thrownError); + }, + ); + }); + + it('logs an error if nothing is listening to "error"', async () => { + const thrownError = new Error('boom'); + recordCallsToSetTimeout(); + + await withPollingBlockTracker( + { + provider: { + stubs: [ + { + methodName: 'eth_blockNumber', + error: thrownError, + }, + ], + }, + }, + async ({ blockTracker }) => { + jest + .spyOn(console, 'error') + .mockImplementation(EMPTY_FUNCTION); + blockTracker.on('latest', EMPTY_FUNCTION); + await expect(blockTracker.getLatestBlock()).rejects.toThrow( + 'boom', + ); + expect(console.error).toHaveBeenCalledWith( + 'Error updating latest block: boom', + ); + }, + ); + }); + + it('does not stop the block tracker once complete', async () => { + const thrownError = new Error('boom'); + recordCallsToSetTimeout(); + + await withPollingBlockTracker( + { + provider: { + stubs: [ + { + methodName: 'eth_blockNumber', + error: thrownError, + }, + ], + }, + }, + async ({ blockTracker }) => { + blockTracker.on('latest', EMPTY_FUNCTION); + try { + await blockTracker.getLatestBlock(); + } catch { + // do nothing + } + expect(blockTracker.isRunning()).toBe(true); + }, + ); + }); + }); + }); + }); + + describe('if already called concurrently', () => { + describe('if the latest block number is successfully fetched on the next poll iteration', () => { + it('returns the block number that the other call returns', async () => { + recordCallsToSetTimeout(); + await withPollingBlockTracker(async ({ blockTracker }) => { + blockTracker.on('latest', EMPTY_FUNCTION); + const promise1 = blockTracker.getLatestBlock(); + const promise2 = blockTracker.getLatestBlock(); + const [block1, block2] = await Promise.all([promise1, promise2]); + expect(block1).toBe(block2); + }); + }); + + it('does not stop the block tracker once complete', async () => { + recordCallsToSetTimeout(); + + await withPollingBlockTracker(async ({ blockTracker }) => { + blockTracker.on('latest', EMPTY_FUNCTION); + await blockTracker.getLatestBlock(); + expect(blockTracker.isRunning()).toBe(true); + }); + }); + }); + + describe('if an error occurs while fetching the latest block number on the next poll iteration', () => { + it('throws the error that the other call throws', async () => { + const thrownError = new Error('boom'); + recordCallsToSetTimeout(); + + await withPollingBlockTracker( + { + provider: { + stubs: [ + { + methodName: 'eth_blockNumber', + error: thrownError, + }, + ], + }, + }, + async ({ blockTracker }) => { + blockTracker.on('latest', EMPTY_FUNCTION); + const promise1 = blockTracker.getLatestBlock(); + const promise2 = blockTracker.getLatestBlock(); + await expect(promise1).rejects.toThrow(thrownError); + await expect(promise2).rejects.toThrow(thrownError); + }, + ); + }); + + it('emits "error" only once if anything is listening to "error"', async () => { + const thrownError = new Error('boom'); + recordCallsToSetTimeout(); + + await withPollingBlockTracker( + { + provider: { + stubs: [ + { + methodName: 'eth_blockNumber', + error: thrownError, + }, + ], + }, + }, + async ({ blockTracker }) => { + const errorListener = jest.fn(); + blockTracker.on('error', errorListener); + blockTracker.on('latest', EMPTY_FUNCTION); + const promise1 = blockTracker.getLatestBlock(); + const promise2 = blockTracker.getLatestBlock(); + await Promise.allSettled([promise1, promise2]); + expect(errorListener).toHaveBeenCalledTimes(1); + }, + ); + }); + + it('logs an error only once if nothing is listening to "error"', async () => { + const thrownError = new Error('boom'); + recordCallsToSetTimeout(); + + await withPollingBlockTracker( + { + provider: { + stubs: [ + { + methodName: 'eth_blockNumber', + error: thrownError, + }, + ], + }, + }, + async ({ blockTracker }) => { + jest.spyOn(console, 'error').mockImplementation(EMPTY_FUNCTION); + blockTracker.on('latest', EMPTY_FUNCTION); + const promise1 = blockTracker.getLatestBlock(); + const promise2 = blockTracker.getLatestBlock(); + await Promise.allSettled([promise1, promise2]); + expect(console.error).toHaveBeenCalledTimes(1); + }, + ); + }); + + it('does not stop the block tracker once complete', async () => { + recordCallsToSetTimeout(); + + await withPollingBlockTracker(async ({ blockTracker }) => { + blockTracker.on('latest', EMPTY_FUNCTION); + await blockTracker.getLatestBlock(); + expect(blockTracker.isRunning()).toBe(true); + }); + }); + }); + }); + + METHODS_TO_ADD_LISTENER.forEach((methodToAddListener) => { + it(`should throw and emit the "error" event (added via \`${methodToAddListener}\`) if, while making the request for the latest block number, the provider throws`, async () => { + const thrownError = new Error('boom'); + recordCallsToSetTimeout({ numAutomaticCalls: 1 }); + + await withPollingBlockTracker( + { + provider: { + stubs: [ + { + methodName: 'eth_blockNumber', + implementation: (): never => { + throw thrownError; + }, + }, + { + methodName: 'eth_blockNumber', + result: '0x0', + }, + ], + }, + }, + async ({ blockTracker }) => { + blockTracker[methodToAddListener]('latest', EMPTY_FUNCTION); + const errorListener = jest.fn(); + expect(blockTracker.isRunning()).toBe(true); + blockTracker[methodToAddListener]('error', errorListener); + await expect(blockTracker.getLatestBlock()).rejects.toThrow( + 'boom', + ); + expect(errorListener).toHaveBeenCalledWith(thrownError); + const latestBlock = await blockTracker.getLatestBlock(); + expect(latestBlock).toBe('0x0'); + }, + ); + }); + + it(`should throw and emit the "error" event (added via \`${methodToAddListener}\`) if, while making the request for the latest block number, the provider rejects with an error`, async () => { + const thrownError = new Error('boom'); + recordCallsToSetTimeout({ numAutomaticCalls: 1 }); + + await withPollingBlockTracker( + { + provider: { + stubs: [ + { + methodName: 'eth_blockNumber', + error: thrownError, + }, + { + methodName: 'eth_blockNumber', + result: '0x0', + }, + ], + }, + }, + async ({ blockTracker }) => { + blockTracker[methodToAddListener]('latest', EMPTY_FUNCTION); + const errorListener = jest.fn(); + expect(blockTracker.isRunning()).toBe(true); + blockTracker[methodToAddListener]('error', errorListener); + await expect(blockTracker.getLatestBlock()).rejects.toThrow( + 'boom', + ); + expect(errorListener).toHaveBeenCalledWith(thrownError); + const latestBlock = await blockTracker.getLatestBlock(); + expect(latestBlock).toBe('0x0'); + }, + ); + }); + }); + + it('should reject pending latest block request if block tracker is stopped before fetch completes on second getLatestBlock call', async () => { + const setTimeoutRecorder = recordCallsToSetTimeout(); + const blockTrackerOptions = { + pollingInterval: 100, + blockResetDuration: 200, + }; + + await withPollingBlockTracker( + { + provider: { + stubs: [ + { + methodName: 'eth_blockNumber', + result: '0x0', + }, + { + methodName: 'eth_blockNumber', + result: '0x0', + }, + ], + }, + blockTracker: blockTrackerOptions, + }, + async ({ blockTracker }) => { + // Step 1: Start the block tracker + blockTracker.on('latest', EMPTY_FUNCTION); + + // Step 2: Wait for the first block update to resolve + await new Promise((resolve) => { + blockTracker.on('sync', resolve); + }); + expect(blockTracker.getCurrentBlock()).toBe('0x0'); + expect(blockTracker.isRunning()).toBe(true); + + // Clear the current block to force a new request for the next getLatestBlock + // When the block tracker stops, there may be two `setTimeout`s in + // play: one to go to the next iteration of the block tracker + // loop, another to expire the current block number cache. We don't + // know which one has been added first, so we have to find it. + blockTracker.removeAllListeners(); + await setTimeoutRecorder.nextMatchingDuration( + blockTrackerOptions.blockResetDuration, + ); + expect(blockTracker.getCurrentBlock()).toBeNull(); + + // Restart the tracker for the second call + blockTracker.on('latest', EMPTY_FUNCTION); + + // Step 3: Immediately after, call getLatestBlock + const secondBlockPromise = blockTracker.getLatestBlock(); + + // Step 4: Immediately after, stop the block tracker + blockTracker.removeAllListeners(); + + // Verify block tracker state + expect(blockTracker.isRunning()).toBe(false); + expect(blockTracker.getCurrentBlock()).toBeNull(); + + // The call to getLatestBlock would then never resolve (should be rejected) + await expect(secondBlockPromise).rejects.toThrow( + 'Block tracker destroyed', + ); + + // Verify that the block reset timeout is set up + expect( + setTimeoutRecorder.calls.some((call) => { + return call.duration === blockTrackerOptions.blockResetDuration; + }), + ).toBe(true); + + // Wait for the block reset timeout to complete + await setTimeoutRecorder.nextMatchingDuration( + blockTrackerOptions.blockResetDuration, + ); + + // Verify that the current block is still null after the timeout + expect(blockTracker.getCurrentBlock()).toBeNull(); + }, + ); + }); + + it('should reject pending latest block request if block tracker is destroyed before fetch completes on second getLatestBlock call', async () => { + const setTimeoutRecorder = recordCallsToSetTimeout(); + const blockTrackerOptions = { + pollingInterval: 100, + blockResetDuration: 200, + }; + + await withPollingBlockTracker( + { + provider: { + stubs: [ + { + methodName: 'eth_blockNumber', + result: '0x0', + }, + { + methodName: 'eth_blockNumber', + result: '0x0', + }, + ], + }, + blockTracker: blockTrackerOptions, + }, + async ({ blockTracker }) => { + // Step 1: Start the block tracker + blockTracker.on('latest', EMPTY_FUNCTION); + + // Step 2: Wait for the first block update to resolve + await new Promise((resolve) => { + blockTracker.on('sync', resolve); + }); + expect(blockTracker.getCurrentBlock()).toBe('0x0'); + expect(blockTracker.isRunning()).toBe(true); + + // Clear the current block to force a new request for the next getLatestBlock + // When the block tracker stops, there may be two `setTimeout`s in + // play: one to go to the next iteration of the block tracker + // loop, another to expire the current block number cache. We don't + // know which one has been added first, so we have to find it. + blockTracker.removeAllListeners(); + await setTimeoutRecorder.nextMatchingDuration( + blockTrackerOptions.blockResetDuration, + ); + expect(blockTracker.getCurrentBlock()).toBeNull(); + + // Restart the tracker for the second call + blockTracker.on('latest', EMPTY_FUNCTION); + + // Step 3: Immediately after, call getLatestBlock + const secondBlockPromise = blockTracker.getLatestBlock(); + + // Step 4: Immediately after, destroy the block tracker + await blockTracker.destroy(); + + // Verify block tracker state + expect(blockTracker.isRunning()).toBe(false); + expect(blockTracker.getCurrentBlock()).toBeNull(); + + // The call to getLatestBlock would then never resolve (should be rejected) + await expect(secondBlockPromise).rejects.toThrow( + 'Block tracker destroyed', + ); + + // Verify that the block reset timeout is set up + expect( + setTimeoutRecorder.calls.some((call) => { + return call.duration === blockTrackerOptions.blockResetDuration; + }), + ).toBe(true); + + // Wait for the block reset timeout to complete + await setTimeoutRecorder.nextMatchingDuration( + blockTrackerOptions.blockResetDuration, + ); + + // Verify that the current block is still null after the timeout + expect(blockTracker.getCurrentBlock()).toBeNull(); + }, + ); + }); + }); + + describe('with useCache: false and a block number is already cached', () => { + describe('when the block tracker is not running', () => { + it('should not fetch a new block even if less than the polling interval time has passed since the last call', async () => { + recordCallsToSetTimeout(); + + await withPollingBlockTracker( + { + provider: { + stubs: [ + { + methodName: 'eth_blockNumber', + result: '0x1', + }, + { + methodName: 'eth_blockNumber', + result: '0x2', + }, + ], + }, + }, + async ({ blockTracker }) => { + await blockTracker.getLatestBlock(); + const block = await blockTracker.getLatestBlock({ + useCache: false, + }); + expect(block).toBe('0x1'); + expect(blockTracker.isRunning()).toBe(false); + }, + ); + }); + + it('should fetch a new block even if more than the polling interval time has passed since the last call', async () => { + const setTimeoutRecorder = recordCallsToSetTimeout(); + const blockTrackerOptions = { + pollingInterval: 100, + blockResetDuration: 200, + }; + + await withPollingBlockTracker( + { + provider: { + stubs: [ + { + methodName: 'eth_blockNumber', + result: '0x1', + }, + { + methodName: 'eth_blockNumber', + result: '0x2', + }, + ], + }, + blockTracker: blockTrackerOptions, + }, + async ({ blockTracker }) => { + await blockTracker.getLatestBlock(); + await setTimeoutRecorder.nextMatchingDuration( + blockTrackerOptions.pollingInterval, + ); + const block = await blockTracker.getLatestBlock({ + useCache: false, + }); + expect(block).toBe('0x2'); + expect(blockTracker.isRunning()).toBe(false); + }, + ); + }); + }); + + describe('when the block tracker is already started', () => { + it('should wait for the next block event', async () => { + const setTimeoutRecorder = recordCallsToSetTimeout(); + + await withPollingBlockTracker( + { + provider: { + stubs: [ + { + methodName: 'eth_blockNumber', + result: '0x1', + }, + { + methodName: 'eth_blockNumber', + result: '0x2', + }, + { + methodName: 'eth_blockNumber', + result: '0x3', + }, + ], + }, + }, + + async ({ blockTracker }) => { + blockTracker.on('latest', EMPTY_FUNCTION); + await new Promise((resolve) => { + blockTracker.once('_waitingForNextIteration', resolve); + }); + + const blockPromise1 = blockTracker.getLatestBlock({ + useCache: false, + }); + const pollingLoopPromise1 = new Promise((resolve) => { + blockTracker.once('_waitingForNextIteration', resolve); + }); + await setTimeoutRecorder.next(); + await pollingLoopPromise1; + const block1 = await blockPromise1; + expect(block1).toBe('0x2'); + + const pollingLoopPromise2 = new Promise((resolve) => { + blockTracker.once('_waitingForNextIteration', resolve); + }); + const blockPromise2 = blockTracker.getLatestBlock({ + useCache: false, + }); + await setTimeoutRecorder.next(); + await pollingLoopPromise2; + const block2 = await blockPromise2; + expect(block2).toBe('0x3'); + }, + ); + }); + + it('should handle concurrent calls', async () => { + const setTimeoutRecorder = recordCallsToSetTimeout(); + + await withPollingBlockTracker( + { + provider: { + stubs: [ + { + methodName: 'eth_blockNumber', + result: '0x1', + }, + { + methodName: 'eth_blockNumber', + result: '0x2', + }, + ], + }, + }, + async ({ blockTracker }) => { + blockTracker.on('latest', EMPTY_FUNCTION); + await new Promise((resolve) => { + blockTracker.once('_waitingForNextIteration', resolve); + }); + + const blockPromise1 = blockTracker.getLatestBlock({ + useCache: false, + }); + const blockPromise2 = blockTracker.getLatestBlock({ + useCache: false, + }); + + const pollingLoopPromise = new Promise((resolve) => { + blockTracker.once('_waitingForNextIteration', resolve); + }); + await setTimeoutRecorder.next(); + await pollingLoopPromise; + + const block1 = await blockPromise1; + const block2 = await blockPromise2; + expect(block1).toBe('0x2'); + expect(block2).toBe('0x2'); + }, + ); + }); + }); + }); + }); + + describe('checkForLatestBlock', () => { + it('should start the block tracker shortly after being called', async () => { + recordCallsToSetTimeout(); + + await withPollingBlockTracker( + { + provider: { + stubs: [ + { + methodName: 'eth_blockNumber', + result: '0x0', + }, + { + methodName: 'eth_blockNumber', + result: '0x1', + }, + ], + }, + }, + async ({ blockTracker }) => { + await blockTracker.checkForLatestBlock(); + await new Promise((resolve) => { + blockTracker.on('latest', resolve); + }); + expect(blockTracker.isRunning()).toBe(true); + }, + ); + }); + + it('should stop the block tracker automatically after its promise is fulfilled', async () => { + recordCallsToSetTimeout(); + + await withPollingBlockTracker(async ({ blockTracker }) => { + await blockTracker.checkForLatestBlock(); + expect(blockTracker.isRunning()).toBe(false); + }); + }); + + it('should return the same promise if called multiple times', async () => { + await withPollingBlockTracker( + { + provider: { + stubs: [ + { + methodName: 'eth_blockNumber', + result: '0x0', + }, + { + methodName: 'eth_blockNumber', + result: '0x1', + }, + ], + }, + }, + async ({ blockTracker }) => { + const promiseToCheckLatestBlock1 = blockTracker.checkForLatestBlock(); + const promiseToCheckLatestBlock2 = blockTracker.checkForLatestBlock(); + + expect(promiseToCheckLatestBlock1).toBe(promiseToCheckLatestBlock2); + }, + ); + }); + + it('should fetch the latest block number', async () => { + recordCallsToSetTimeout(); + + await withPollingBlockTracker( + { + provider: { + stubs: [ + { + methodName: 'eth_blockNumber', + result: '0x0', + }, + ], + }, + }, + async ({ blockTracker }) => { + const latestBlockNumber = await blockTracker.checkForLatestBlock(); + expect(latestBlockNumber).toBe('0x0'); + }, + ); + }); + + it('should update the current block number', async () => { + recordCallsToSetTimeout(); + + await withPollingBlockTracker( + { + provider: { + stubs: [ + { + methodName: 'eth_blockNumber', + result: '0x0', + }, + ], + }, + }, + async ({ blockTracker }) => { + await blockTracker.checkForLatestBlock(); + const currentBlockNumber = blockTracker.getCurrentBlock(); + expect(currentBlockNumber).toBe('0x0'); + }, + ); + }); + + it('request the latest block number with `skipCache: true` if the block tracker was initialized with `setSkipCacheFlag: true`', async () => { + recordCallsToSetTimeout(); + + await withPollingBlockTracker( + { blockTracker: { setSkipCacheFlag: true } }, + async ({ provider, blockTracker }) => { + jest.spyOn(provider, 'request'); + + await blockTracker.checkForLatestBlock(); + + expect(provider.request).toHaveBeenCalledWith({ + jsonrpc: '2.0' as const, + id: expect.any(Number), + method: 'eth_blockNumber' as const, + params: [], + skipCache: true, + }); + }, + ); + }); + + it(`should not emit the "error" event, but should throw instead, if, while making the request for the latest block number, the provider rejects with an error`, async () => { + recordCallsToSetTimeout({ numAutomaticCalls: 1 }); + const thrownError = new Error('boom'); + + await withPollingBlockTracker( + { + provider: { + stubs: [ + { + methodName: 'eth_blockNumber', + error: new Error('boom'), + }, + { + methodName: 'eth_blockNumber', + result: '0x0', + }, + ], + }, + }, + async ({ blockTracker }) => { + const promiseForLatestBlock = blockTracker.checkForLatestBlock(); + await expect(promiseForLatestBlock).rejects.toThrow(thrownError); + }, + ); + }); + + it('should start a timer to clear the current block number later if the block tracker is not running', async () => { + const setTimeoutRecorder = recordCallsToSetTimeout(); + const blockResetDuration = 1000; + + await withPollingBlockTracker( + { + blockTracker: { + blockResetDuration, + }, + provider: { + stubs: [ + { + methodName: 'eth_blockNumber', + result: '0x0', + }, + ], + }, + }, + async ({ blockTracker }) => { + await blockTracker.checkForLatestBlock(); + expect(blockTracker.getCurrentBlock()).toBe('0x0'); + await setTimeoutRecorder.nextMatchingDuration(blockResetDuration); + expect(blockTracker.getCurrentBlock()).toBeNull(); + }, + ); + }); + + it('should not start a timer to clear the current block number later if the block tracker is running', async () => { + const setTimeoutRecorder = recordCallsToSetTimeout(); + const blockResetDuration = 1000; + + await withPollingBlockTracker( + { + blockTracker: { + blockResetDuration, + }, + provider: { + stubs: [ + { + methodName: 'eth_blockNumber', + result: '0x0', + }, + ], + }, + }, + async ({ blockTracker }) => { + blockTracker.on('latest', EMPTY_FUNCTION); + await blockTracker.checkForLatestBlock(); + + const blockResetTimeouts = setTimeoutRecorder.calls.filter((call) => { + return call.duration === blockResetDuration; + }); + expect(blockResetTimeouts).toHaveLength(0); + expect(blockTracker.getCurrentBlock()).toBe('0x0'); + }, + ); + }); + + describe.each([ + ['not initialized with `usePastBlocks`', {}], + ['initialized with `usePastBlocks: false`', { usePastBlocks: false }], + ] as const)( + 'after a block number is cached if the block tracker was %s', + (_description, blockTrackerOptions) => { + it('should return the fetched block number if the fetched block number is greater than the current block number', async () => { + recordCallsToSetTimeout(); + + await withPollingBlockTracker( + { + provider: { + stubs: [ + { + methodName: 'eth_blockNumber', + result: '0x0', + }, + { + methodName: 'eth_blockNumber', + result: '0x1', + }, + ], + }, + blockTracker: blockTrackerOptions, + }, + async ({ blockTracker }) => { + const blockNumber1 = await blockTracker.checkForLatestBlock(); + expect(blockNumber1).toBe('0x0'); + const blockNumber2 = await blockTracker.checkForLatestBlock(); + expect(blockNumber2).toBe('0x1'); + }, + ); + }); + + it('should update the current block number if the fetched block number is greater than the current block number', async () => { + recordCallsToSetTimeout(); + + await withPollingBlockTracker( + { + provider: { + stubs: [ + { + methodName: 'eth_blockNumber', + result: '0x0', + }, + { + methodName: 'eth_blockNumber', + result: '0x1', + }, + ], + }, + blockTracker: blockTrackerOptions, + }, + async ({ blockTracker }) => { + await blockTracker.checkForLatestBlock(); + await blockTracker.checkForLatestBlock(); + const currentBlockNumber = blockTracker.getCurrentBlock(); + expect(currentBlockNumber).toBe('0x1'); + }, + ); + }); + + it('should return the current block number if the fetched block number is less than the current block number', async () => { + recordCallsToSetTimeout(); + + await withPollingBlockTracker( + { + provider: { + stubs: [ + { + methodName: 'eth_blockNumber', + result: '0x1', + }, + { + methodName: 'eth_blockNumber', + result: '0x0', + }, + ], + }, + blockTracker: blockTrackerOptions, + }, + async ({ blockTracker }) => { + const blockNumber1 = await blockTracker.checkForLatestBlock(); + expect(blockNumber1).toBe('0x1'); + const blockNumber2 = await blockTracker.checkForLatestBlock(); + expect(blockNumber2).toBe('0x1'); + }, + ); + }); + + it('should not update the current block number if the fetched block number is less than the current block number', async () => { + recordCallsToSetTimeout(); + + await withPollingBlockTracker( + { + provider: { + stubs: [ + { + methodName: 'eth_blockNumber', + result: '0x1', + }, + { + methodName: 'eth_blockNumber', + result: '0x0', + }, + ], + }, + blockTracker: blockTrackerOptions, + }, + async ({ blockTracker }) => { + await blockTracker.checkForLatestBlock(); + await blockTracker.checkForLatestBlock(); + const currentBlockNumber = blockTracker.getCurrentBlock(); + expect(currentBlockNumber).toBe('0x1'); + }, + ); + }); + }, + ); + + describe('after a block number is cached if the block tracker was initialized with `usePastBlocks: true`', () => { + it('should return the fetched block number if the fetched block number is greater than the current block number', async () => { + recordCallsToSetTimeout(); + + await withPollingBlockTracker( + { + provider: { + stubs: [ + { + methodName: 'eth_blockNumber', + result: '0x0', + }, + { + methodName: 'eth_blockNumber', + result: '0x1', + }, + ], + }, + blockTracker: { usePastBlocks: true }, + }, + async ({ blockTracker }) => { + const blockNumber1 = await blockTracker.checkForLatestBlock(); + expect(blockNumber1).toBe('0x0'); + const blockNumber2 = await blockTracker.checkForLatestBlock(); + expect(blockNumber2).toBe('0x1'); + }, + ); + }); + + it('should update the current block number if the fetched block number is greater than the current block number', async () => { + recordCallsToSetTimeout(); + + await withPollingBlockTracker( + { + provider: { + stubs: [ + { + methodName: 'eth_blockNumber', + result: '0x0', + }, + { + methodName: 'eth_blockNumber', + result: '0x1', + }, + ], + }, + blockTracker: { usePastBlocks: true }, + }, + async ({ blockTracker }) => { + await blockTracker.checkForLatestBlock(); + await blockTracker.checkForLatestBlock(); + const currentBlockNumber = blockTracker.getCurrentBlock(); + expect(currentBlockNumber).toBe('0x1'); + }, + ); + }); + + it('should return the fetched block number if the fetched block number is less than the current block number', async () => { + recordCallsToSetTimeout(); + + await withPollingBlockTracker( + { + provider: { + stubs: [ + { + methodName: 'eth_blockNumber', + result: '0x1', + }, + { + methodName: 'eth_blockNumber', + result: '0x0', + }, + ], + }, + blockTracker: { usePastBlocks: true }, + }, + async ({ blockTracker }) => { + const blockNumber1 = await blockTracker.checkForLatestBlock(); + expect(blockNumber1).toBe('0x1'); + const blockNumber2 = await blockTracker.checkForLatestBlock(); + expect(blockNumber2).toBe('0x0'); + }, + ); + }); + + it('should update the current block number if the fetched block number is less than the current block number', async () => { + recordCallsToSetTimeout(); + + await withPollingBlockTracker( + { + provider: { + stubs: [ + { + methodName: 'eth_blockNumber', + result: '0x1', + }, + { + methodName: 'eth_blockNumber', + result: '0x0', + }, + ], + }, + blockTracker: { usePastBlocks: true }, + }, + async ({ blockTracker }) => { + await blockTracker.checkForLatestBlock(); + await blockTracker.checkForLatestBlock(); + const currentBlockNumber = blockTracker.getCurrentBlock(); + expect(currentBlockNumber).toBe('0x0'); + }, + ); + }); + }); + }); + + METHODS_TO_ADD_LISTENER.forEach((methodToAddListener) => { + describe(`${methodToAddListener}`, () => { + describe('"latest"', () => { + it('should start the block tracker', async () => { + recordCallsToSetTimeout(); + + await withPollingBlockTracker(({ blockTracker }) => { + blockTracker[methodToAddListener]('latest', EMPTY_FUNCTION); + + expect(blockTracker.isRunning()).toBe(true); + }); + }); + + it('should emit "latest" soon afterward', async () => { + recordCallsToSetTimeout(); + + await withPollingBlockTracker( + { + provider: { + stubs: [ + { + methodName: 'eth_blockNumber', + result: '0x0', + }, + ], + }, + }, + async ({ blockTracker }) => { + const latestBlockNumber = await new Promise((resolve) => { + blockTracker[methodToAddListener]('latest', resolve); + }); + expect(latestBlockNumber).toBe('0x0'); + }, + ); + }); + + it('should update the current block number', async () => { + recordCallsToSetTimeout(); + + await withPollingBlockTracker( + { + provider: { + stubs: [ + { + methodName: 'eth_blockNumber', + result: '0x0', + }, + ], + }, + }, + async ({ blockTracker }) => { + await new Promise((resolve) => { + blockTracker[methodToAddListener]('latest', resolve); + }); + const currentBlockNumber = blockTracker.getCurrentBlock(); + expect(currentBlockNumber).toBe('0x0'); + }, + ); + }); + + it('should not prevent Node from exiting when the poll loop is stopped while waiting for the next iteration', async () => { + const setTimeoutRecorder = recordCallsToSetTimeout(); + const blockTrackerOptions = { + pollingInterval: 100, + blockResetDuration: 200, + keepEventLoopActive: false, + }; + + await withPollingBlockTracker( + { + provider: { + stubs: [ + { + methodName: 'eth_blockNumber', + result: '0x0', + }, + { + methodName: 'eth_blockNumber', + result: '0x1', + }, + ], + }, + blockTracker: blockTrackerOptions, + }, + async ({ blockTracker }) => { + blockTracker[methodToAddListener]('latest', EMPTY_FUNCTION); + + await new Promise((resolve) => { + blockTracker.on('_waitingForNextIteration', resolve); + }); + + const nextIterationTimeout = setTimeoutRecorder.calls.find( + (call) => { + return call.duration === blockTrackerOptions.pollingInterval; + }, + ); + expect(nextIterationTimeout).toBeDefined(); + expect(nextIterationTimeout?.timeout.hasRef()).toBe(false); + }, + ); + }); + + it('should re-throw any error out of band that occurs in the listener', async () => { + await withPollingBlockTracker(async ({ blockTracker }) => { + const thrownError = new Error('boom'); + const promiseForCaughtError = new Promise((resolve) => { + recordCallsToSetTimeout({ + numAutomaticCalls: 2, + interceptCallback: (callback, stopPassingThroughCalls) => { + return async (): Promise => { + try { + return await callback(); + } catch (error: unknown) { + resolve(error); + stopPassingThroughCalls(); + return undefined; + } + }; + }, + }); + }); + + blockTracker[methodToAddListener]('latest', () => { + throw thrownError; + }); + + const caughtError = await promiseForCaughtError; + expect(caughtError).toBe(thrownError); + }); + }); + + it('should cause the request for the latest block to be made with `skipCache: true` if the block tracker was initialized with setSkipCacheFlag: true', async () => { + recordCallsToSetTimeout(); + + await withPollingBlockTracker( + { blockTracker: { setSkipCacheFlag: true } }, + async ({ provider, blockTracker }) => { + jest.spyOn(provider, 'request'); + + await new Promise((resolve) => { + blockTracker[methodToAddListener]('latest', resolve); + }); + + expect(provider.request).toHaveBeenCalledWith({ + jsonrpc: '2.0' as const, + id: expect.any(Number), + method: 'eth_blockNumber' as const, + params: [], + skipCache: true, + }); + }, + ); + }); + + it(`should emit the "error" event and should not kill the block tracker if, while making the request for the latest block number, the provider throws`, async () => { + const thrownError = new Error('boom'); + recordCallsToSetTimeout({ numAutomaticCalls: 1 }); + + await withPollingBlockTracker( + { + provider: { + stubs: [ + { + methodName: 'eth_blockNumber', + implementation: (): never => { + throw thrownError; + }, + }, + { + methodName: 'eth_blockNumber', + result: '0x0', + }, + ], + }, + }, + async ({ blockTracker }) => { + const errorListener = jest.fn(); + blockTracker[methodToAddListener]('error', errorListener); + + const promiseForLatestBlock = new Promise((resolve) => { + blockTracker[methodToAddListener]('latest', resolve); + }); + + const latestBlock = await promiseForLatestBlock; + expect(errorListener).toHaveBeenCalledWith(thrownError); + expect(latestBlock).toBe('0x0'); + }, + ); + }); + + it(`should emit the "error" event and should not kill the block tracker if, while making the request for the latest block number, the provider rejects with an error`, async () => { + const thrownError = new Error('boom'); + recordCallsToSetTimeout({ numAutomaticCalls: 1 }); + + await withPollingBlockTracker( + { + provider: { + stubs: [ + { + methodName: 'eth_blockNumber', + error: thrownError, + }, + { + methodName: 'eth_blockNumber', + result: '0x0', + }, + ], + }, + }, + async ({ blockTracker }) => { + const errorListener = jest.fn(); + blockTracker[methodToAddListener]('error', errorListener); + + const promiseForLatestBlock = new Promise((resolve) => { + blockTracker[methodToAddListener]('latest', resolve); + }); + + const latestBlock = await promiseForLatestBlock; + expect(errorListener).toHaveBeenCalledWith(thrownError); + expect(latestBlock).toBe('0x0'); + }, + ); + }); + + it('should log an error if, while making a request for the latest block number, the provider throws and there is nothing listening to "error"', async () => { + const thrownError = new Error('boom'); + recordCallsToSetTimeout({ numAutomaticCalls: 1 }); + + await withPollingBlockTracker( + { + provider: { + stubs: [ + { + methodName: 'eth_blockNumber', + implementation: (): never => { + throw thrownError; + }, + }, + { + methodName: 'eth_blockNumber', + result: '0x0', + }, + ], + }, + }, + async ({ blockTracker }) => { + jest.spyOn(console, 'error').mockImplementation(EMPTY_FUNCTION); + + blockTracker[methodToAddListener]('latest', EMPTY_FUNCTION); + await new Promise((resolve) => { + blockTracker.on('_waitingForNextIteration', resolve); + }); + + expect(console.error).toHaveBeenCalledWith( + 'Error updating latest block: boom', + ); + }, + ); + }); + + it('should log an error if, while making the request for the latest block number, the provider rejects with an error and there is nothing listening to "error"', async () => { + const thrownError = new Error('boom'); + recordCallsToSetTimeout({ numAutomaticCalls: 1 }); + + await withPollingBlockTracker( + { + provider: { + stubs: [ + { + methodName: 'eth_blockNumber', + error: thrownError, + }, + { + methodName: 'eth_blockNumber', + result: '0x0', + }, + ], + }, + }, + async ({ blockTracker }) => { + jest.spyOn(console, 'error').mockImplementation(EMPTY_FUNCTION); + + blockTracker[methodToAddListener]('latest', EMPTY_FUNCTION); + await new Promise((resolve) => { + blockTracker.on('_waitingForNextIteration', resolve); + }); + + expect(console.error).toHaveBeenCalledWith( + 'Error updating latest block: boom', + ); + }, + ); + }); + + describe.each([ + ['not initialized with `usePastBlocks`', {}], + ['initialized with `usePastBlocks: false`', { usePastBlocks: false }], + ] as const)( + 'after a block number is cached if the block tracker was %s', + (_description, blockTrackerOptions) => { + it('should emit "latest" if the fetched block number is greater than the current block number', async () => { + const setTimeoutRecorder = recordCallsToSetTimeout({ + numAutomaticCalls: 1, + }); + + await withPollingBlockTracker( + { + provider: { + stubs: [ + { + methodName: 'eth_blockNumber', + result: '0x0', + }, + { + methodName: 'eth_blockNumber', + result: '0x1', + }, + ], + }, + blockTracker: blockTrackerOptions, + }, + async ({ blockTracker }) => { + const receivedBlockNumbers: string[] = []; + + await new Promise((resolve) => { + setTimeoutRecorder.onNumAutomaticCallsExhausted(resolve); + + blockTracker[methodToAddListener]( + 'latest', + (blockNumber: string) => { + receivedBlockNumbers.push(blockNumber); + }, + ); + }); + + expect(receivedBlockNumbers).toStrictEqual(['0x0', '0x1']); + }, + ); + }); + + it('should not emit "latest" if the fetched block number is less than the current block number', async () => { + const setTimeoutRecorder = recordCallsToSetTimeout({ + numAutomaticCalls: 1, + }); + + await withPollingBlockTracker( + { + provider: { + stubs: [ + { + methodName: 'eth_blockNumber', + result: '0x1', + }, + { + methodName: 'eth_blockNumber', + result: '0x0', + }, + ], + }, + blockTracker: blockTrackerOptions, + }, + async ({ blockTracker }) => { + const receivedBlockNumbers: string[] = []; + + await new Promise((resolve) => { + setTimeoutRecorder.onNumAutomaticCallsExhausted(resolve); + + blockTracker[methodToAddListener]( + 'latest', + (blockNumber: string) => { + receivedBlockNumbers.push(blockNumber); + }, + ); + }); + + expect(receivedBlockNumbers).toStrictEqual(['0x1']); + }, + ); + }); + + it('should not emit "latest" if the fetched block number is the same as the current block number', async () => { + const setTimeoutRecorder = recordCallsToSetTimeout({ + numAutomaticCalls: 1, + }); + + await withPollingBlockTracker( + { + provider: { + stubs: [ + { + methodName: 'eth_blockNumber', + result: '0x0', + }, + { + methodName: 'eth_blockNumber', + result: '0x0', + }, + ], + }, + blockTracker: blockTrackerOptions, + }, + async ({ blockTracker }) => { + const receivedBlockNumbers: string[] = []; + + await new Promise((resolve) => { + setTimeoutRecorder.onNumAutomaticCallsExhausted(resolve); + + blockTracker[methodToAddListener]( + 'latest', + (blockNumber: string) => { + receivedBlockNumbers.push(blockNumber); + }, + ); + }); + + expect(receivedBlockNumbers).toStrictEqual(['0x0']); + }, + ); + }); + }, + ); + + describe('after a block number is cached if the block tracker was initialized with `usePastBlocks: true`', () => { + it('should emit "latest" if the fetched block number is greater than the current block number', async () => { + const setTimeoutRecorder = recordCallsToSetTimeout({ + numAutomaticCalls: 1, + }); + + await withPollingBlockTracker( + { + provider: { + stubs: [ + { + methodName: 'eth_blockNumber', + result: '0x0', + }, + { + methodName: 'eth_blockNumber', + result: '0x1', + }, + ], + }, + blockTracker: { usePastBlocks: true }, + }, + async ({ blockTracker }) => { + const receivedBlockNumbers: string[] = []; + + await new Promise((resolve) => { + setTimeoutRecorder.onNumAutomaticCallsExhausted(resolve); + + blockTracker[methodToAddListener]( + 'latest', + (blockNumber: string) => { + receivedBlockNumbers.push(blockNumber); + }, + ); + }); + + expect(receivedBlockNumbers).toStrictEqual(['0x0', '0x1']); + }, + ); + }); + + it('should emit "latest" if the fetched block number is less than the current block number', async () => { + const setTimeoutRecorder = recordCallsToSetTimeout({ + numAutomaticCalls: 1, + }); + + await withPollingBlockTracker( + { + provider: { + stubs: [ + { + methodName: 'eth_blockNumber', + result: '0x1', + }, + { + methodName: 'eth_blockNumber', + result: '0x0', + }, + ], + }, + blockTracker: { usePastBlocks: true }, + }, + async ({ blockTracker }) => { + const receivedBlockNumbers: string[] = []; + + await new Promise((resolve) => { + setTimeoutRecorder.onNumAutomaticCallsExhausted(resolve); + + blockTracker[methodToAddListener]( + 'latest', + (blockNumber: string) => { + receivedBlockNumbers.push(blockNumber); + }, + ); + }); + + expect(receivedBlockNumbers).toStrictEqual(['0x1', '0x0']); + }, + ); + }); + + it('should not emit "latest" if the fetched block number is the same as the current block number', async () => { + const setTimeoutRecorder = recordCallsToSetTimeout({ + numAutomaticCalls: 1, + }); + + await withPollingBlockTracker( + { + provider: { + stubs: [ + { + methodName: 'eth_blockNumber', + result: '0x0', + }, + { + methodName: 'eth_blockNumber', + result: '0x0', + }, + ], + }, + blockTracker: { usePastBlocks: true }, + }, + async ({ blockTracker }) => { + const receivedBlockNumbers: string[] = []; + + await new Promise((resolve) => { + setTimeoutRecorder.onNumAutomaticCallsExhausted(resolve); + + blockTracker[methodToAddListener]( + 'latest', + (blockNumber: string) => { + receivedBlockNumbers.push(blockNumber); + }, + ); + }); + + expect(receivedBlockNumbers).toStrictEqual(['0x0']); + }, + ); + }); + }); + }); + + describe('"sync"', () => { + it('should start the block tracker', async () => { + recordCallsToSetTimeout(); + + await withPollingBlockTracker(({ blockTracker }) => { + blockTracker[methodToAddListener]('sync', EMPTY_FUNCTION); + + expect(blockTracker.isRunning()).toBe(true); + }); + }); + + it('should emit "sync" soon afterward', async () => { + recordCallsToSetTimeout(); + + await withPollingBlockTracker( + { + provider: { + stubs: [ + { + methodName: 'eth_blockNumber', + result: '0x0', + }, + ], + }, + }, + async ({ blockTracker }) => { + const sync = await new Promise((resolve) => { + blockTracker[methodToAddListener]('sync', resolve); + }); + expect(sync).toStrictEqual({ oldBlock: null, newBlock: '0x0' }); + }, + ); + }); + + it('should update the current block number', async () => { + recordCallsToSetTimeout(); + + await withPollingBlockTracker( + { + provider: { + stubs: [ + { + methodName: 'eth_blockNumber', + result: '0x0', + }, + ], + }, + }, + async ({ blockTracker }) => { + await new Promise((resolve) => { + blockTracker[methodToAddListener]('sync', resolve); + }); + const currentBlockNumber = blockTracker.getCurrentBlock(); + expect(currentBlockNumber).toBe('0x0'); + }, + ); + }); + + it('should not prevent Node from exiting when the poll loop is stopped while waiting for the next iteration', async () => { + const setTimeoutRecorder = recordCallsToSetTimeout(); + const blockTrackerOptions = { + pollingInterval: 100, + blockResetDuration: 200, + keepEventLoopActive: false, + }; + + await withPollingBlockTracker( + { + provider: { + stubs: [ + { + methodName: 'eth_blockNumber', + result: '0x0', + }, + { + methodName: 'eth_blockNumber', + result: '0x1', + }, + ], + }, + blockTracker: blockTrackerOptions, + }, + async ({ blockTracker }) => { + blockTracker[methodToAddListener]('sync', EMPTY_FUNCTION); + + await new Promise((resolve) => { + blockTracker.on('_waitingForNextIteration', resolve); + }); + + const nextIterationTimeout = setTimeoutRecorder.calls.find( + (call) => { + return call.duration === blockTrackerOptions.pollingInterval; + }, + ); + expect(nextIterationTimeout).toBeDefined(); + expect(nextIterationTimeout?.timeout.hasRef()).toBe(false); + }, + ); + }); + + it('should re-throw any error out of band that occurs in the listener', async () => { + await withPollingBlockTracker(async ({ blockTracker }) => { + const thrownError = new Error('boom'); + const promiseForCaughtError = new Promise((resolve) => { + recordCallsToSetTimeout({ + numAutomaticCalls: 2, + interceptCallback: (callback, stopPassingThroughCalls) => { + return async (): Promise => { + try { + return await callback(); + } catch (error: unknown) { + resolve(error); + stopPassingThroughCalls(); + return undefined; + } + }; + }, + }); + }); + + blockTracker[methodToAddListener]('sync', () => { + throw thrownError; + }); + + const caughtError = await promiseForCaughtError; + expect(caughtError).toBe(thrownError); + }); + }); + + it('should cause the request for the latest block to be made with `skipCache: true` if the block tracker was initialized with setSkipCacheFlag: true', async () => { + recordCallsToSetTimeout(); + + await withPollingBlockTracker( + { blockTracker: { setSkipCacheFlag: true } }, + async ({ provider, blockTracker }) => { + jest.spyOn(provider, 'request'); + + await new Promise((resolve) => { + blockTracker[methodToAddListener]('sync', resolve); + }); + + expect(provider.request).toHaveBeenCalledWith({ + jsonrpc: '2.0' as const, + id: expect.any(Number), + method: 'eth_blockNumber' as const, + params: [], + skipCache: true, + }); + }, + ); + }); + + it(`should emit the "error" event and should not kill the block tracker if, while making the request for the latest block number, the provider throws`, async () => { + const thrownError = new Error('boom'); + recordCallsToSetTimeout({ numAutomaticCalls: 1 }); + + await withPollingBlockTracker( + { + provider: { + stubs: [ + { + methodName: 'eth_blockNumber', + implementation: (): never => { + throw thrownError; + }, + }, + { + methodName: 'eth_blockNumber', + result: '0x0', + }, + ], + }, + }, + async ({ blockTracker }) => { + const errorListener = jest.fn(); + blockTracker[methodToAddListener]('error', errorListener); + + const promiseForSync = new Promise((resolve) => { + blockTracker[methodToAddListener]('sync', resolve); + }); + + const sync = await promiseForSync; + expect(errorListener).toHaveBeenCalledWith(thrownError); + expect(sync).toStrictEqual({ oldBlock: null, newBlock: '0x0' }); + }, + ); + }); + + it(`should emit the "error" event and should not kill the block tracker if, while making the request for the latest block number, the provider rejects with an error`, async () => { + const thrownError = new Error('boom'); + recordCallsToSetTimeout({ numAutomaticCalls: 1 }); + + await withPollingBlockTracker( + { + provider: { + stubs: [ + { + methodName: 'eth_blockNumber', + error: new Error('boom'), + }, + { + methodName: 'eth_blockNumber', + result: '0x0', + }, + ], + }, + }, + async ({ blockTracker }) => { + const errorListener = jest.fn(); + blockTracker[methodToAddListener]('error', errorListener); + + const promiseForSync = new Promise((resolve) => { + blockTracker[methodToAddListener]('sync', resolve); + }); + + const sync = await promiseForSync; + expect(errorListener).toHaveBeenCalledWith(thrownError); + expect(sync).toStrictEqual({ oldBlock: null, newBlock: '0x0' }); + }, + ); + }); + + it('should log an error if, while making a request for the latest block number, the provider throws and there is nothing listening to "error"', async () => { + const thrownError = new Error('boom'); + recordCallsToSetTimeout({ numAutomaticCalls: 1 }); + + await withPollingBlockTracker( + { + provider: { + stubs: [ + { + methodName: 'eth_blockNumber', + implementation: (): never => { + throw thrownError; + }, + }, + ], + }, + }, + async ({ blockTracker }) => { + jest.spyOn(console, 'error').mockImplementation(EMPTY_FUNCTION); + + blockTracker[methodToAddListener]('sync', EMPTY_FUNCTION); + await new Promise((resolve) => { + blockTracker.on('_waitingForNextIteration', resolve); + }); + + expect(console.error).toHaveBeenCalledWith( + 'Error updating latest block: boom', + ); + }, + ); + }); + + it('should log an error if, while making the request for the latest block number, the provider rejects with an error and there is nothing listening to "error"', async () => { + const thrownError = new Error('boom'); + recordCallsToSetTimeout({ numAutomaticCalls: 1 }); + + await withPollingBlockTracker( + { + provider: { + stubs: [ + { + methodName: 'eth_blockNumber', + error: thrownError, + }, + ], + }, + }, + async ({ blockTracker }) => { + jest.spyOn(console, 'error').mockImplementation(EMPTY_FUNCTION); + + blockTracker[methodToAddListener]('sync', EMPTY_FUNCTION); + await new Promise((resolve) => { + blockTracker.on('_waitingForNextIteration', resolve); + }); + + expect(console.error).toHaveBeenCalledWith( + 'Error updating latest block: boom', + ); + }, + ); + }); + + describe.each([ + ['not initialized with `usePastBlocks`', {}], + ['initialized with `usePastBlocks: false`', { usePastBlocks: false }], + ] as const)( + 'after a block number is cached if the block tracker was %s', + (_description, blockTrackerOptions) => { + it('should emit "sync" if the fetched block number is greater than the current block number', async () => { + const setTimeoutRecorder = recordCallsToSetTimeout({ + numAutomaticCalls: 1, + }); + + await withPollingBlockTracker( + { + provider: { + stubs: [ + { + methodName: 'eth_blockNumber', + result: '0x0', + }, + { + methodName: 'eth_blockNumber', + result: '0x1', + }, + ], + }, + blockTracker: blockTrackerOptions, + }, + async ({ blockTracker }) => { + const syncs: Sync[] = []; + + await new Promise((resolve) => { + setTimeoutRecorder.onNumAutomaticCallsExhausted(resolve); + + blockTracker[methodToAddListener]('sync', (sync: Sync) => { + syncs.push(sync); + }); + }); + + expect(syncs).toStrictEqual([ + { oldBlock: null, newBlock: '0x0' }, + { oldBlock: '0x0', newBlock: '0x1' }, + ]); + }, + ); + }); + + it('should not emit "sync" if the fetched block number is less than the current block number', async () => { + const setTimeoutRecorder = recordCallsToSetTimeout({ + numAutomaticCalls: 1, + }); + + await withPollingBlockTracker( + { + provider: { + stubs: [ + { + methodName: 'eth_blockNumber', + result: '0x1', + }, + { + methodName: 'eth_blockNumber', + result: '0x0', + }, + ], + }, + blockTracker: blockTrackerOptions, + }, + async ({ blockTracker }) => { + const syncs: Sync[] = []; + + await new Promise((resolve) => { + setTimeoutRecorder.onNumAutomaticCallsExhausted(resolve); + + blockTracker[methodToAddListener]('sync', (sync: Sync) => { + syncs.push(sync); + }); + }); + + expect(syncs).toStrictEqual([ + { oldBlock: null, newBlock: '0x1' }, + ]); + }, + ); + }); + + it('should not emit "sync" if the fetched block number is the same as the current block number', async () => { + const setTimeoutRecorder = recordCallsToSetTimeout({ + numAutomaticCalls: 1, + }); + + await withPollingBlockTracker( + { + provider: { + stubs: [ + { + methodName: 'eth_blockNumber', + result: '0x0', + }, + { + methodName: 'eth_blockNumber', + result: '0x0', + }, + ], + }, + blockTracker: blockTrackerOptions, + }, + async ({ blockTracker }) => { + const syncs: Sync[] = []; + + await new Promise((resolve) => { + setTimeoutRecorder.onNumAutomaticCallsExhausted(resolve); + + blockTracker[methodToAddListener]('sync', (sync: Sync) => { + syncs.push(sync); + }); + }); + + expect(syncs).toStrictEqual([ + { oldBlock: null, newBlock: '0x0' }, + ]); + }, + ); + }); + }, + ); + + describe('after a block number is cached if the block tracker was initialized with `usePastBlocks: true`', () => { + it('should emit "sync" if the fetched block number is greater than the current block number', async () => { + const setTimeoutRecorder = recordCallsToSetTimeout({ + numAutomaticCalls: 1, + }); + + await withPollingBlockTracker( + { + provider: { + stubs: [ + { + methodName: 'eth_blockNumber', + result: '0x0', + }, + { + methodName: 'eth_blockNumber', + result: '0x1', + }, + ], + }, + blockTracker: { usePastBlocks: true }, + }, + async ({ blockTracker }) => { + const syncs: Sync[] = []; + + await new Promise((resolve) => { + setTimeoutRecorder.onNumAutomaticCallsExhausted(resolve); + + blockTracker[methodToAddListener]('sync', (sync: Sync) => { + syncs.push(sync); + }); + }); + + expect(syncs).toStrictEqual([ + { oldBlock: null, newBlock: '0x0' }, + { oldBlock: '0x0', newBlock: '0x1' }, + ]); + }, + ); + }); + + it('should emit "sync" if the fetched block number is less than the current block number', async () => { + const setTimeoutRecorder = recordCallsToSetTimeout({ + numAutomaticCalls: 1, + }); + + await withPollingBlockTracker( + { + provider: { + stubs: [ + { + methodName: 'eth_blockNumber', + result: '0x1', + }, + { + methodName: 'eth_blockNumber', + result: '0x0', + }, + ], + }, + blockTracker: { usePastBlocks: true }, + }, + async ({ blockTracker }) => { + const syncs: Sync[] = []; + + await new Promise((resolve) => { + setTimeoutRecorder.onNumAutomaticCallsExhausted(resolve); + + blockTracker[methodToAddListener]('sync', (sync: Sync) => { + syncs.push(sync); + }); + }); + + expect(syncs).toStrictEqual([ + { oldBlock: null, newBlock: '0x1' }, + { oldBlock: '0x1', newBlock: '0x0' }, + ]); + }, + ); + }); + + it('should not emit "sync" if the fetched block number is the same as the current block number', async () => { + const setTimeoutRecorder = recordCallsToSetTimeout({ + numAutomaticCalls: 1, + }); + + await withPollingBlockTracker( + { + provider: { + stubs: [ + { + methodName: 'eth_blockNumber', + result: '0x0', + }, + { + methodName: 'eth_blockNumber', + result: '0x0', + }, + ], + }, + blockTracker: { usePastBlocks: true }, + }, + async ({ blockTracker }) => { + const syncs: Sync[] = []; + + await new Promise((resolve) => { + setTimeoutRecorder.onNumAutomaticCallsExhausted(resolve); + + blockTracker[methodToAddListener]('sync', (sync: Sync) => { + syncs.push(sync); + }); + }); + + expect(syncs).toStrictEqual([ + { oldBlock: null, newBlock: '0x0' }, + ]); + }, + ); + }); + }); + }); + + describe('some other event', () => { + it('should not start the block tracker', async () => { + recordCallsToSetTimeout(); + + await withPollingBlockTracker(({ blockTracker }) => { + blockTracker[methodToAddListener]('somethingElse', EMPTY_FUNCTION); + + expect(blockTracker.isRunning()).toBe(false); + }); + }); + + it('should not update the current block number', async () => { + recordCallsToSetTimeout(); + + await withPollingBlockTracker( + { + provider: { + stubs: [ + { + methodName: 'eth_blockNumber', + result: '0x0', + }, + ], + }, + }, + async ({ blockTracker }) => { + blockTracker[methodToAddListener]( + 'somethingElse', + EMPTY_FUNCTION, + ); + const currentBlockNumber = blockTracker.getCurrentBlock(); + expect(currentBlockNumber).toBeNull(); + }, + ); + }); + }); + }); + }); + + METHODS_TO_REMOVE_LISTENER.forEach((methodToRemoveListener) => { + describe(`${methodToRemoveListener}`, () => { + describe('"latest"', () => { + it('should stop the block tracker if the last instance of this event is removed', async () => { + recordCallsToSetTimeout(); + + await withPollingBlockTracker(async ({ blockTracker }) => { + const listener1 = EMPTY_FUNCTION; + const { promise: promiseForLatestBlock, resolve: listener2 } = + createDeferredPromise(); + + blockTracker.on('latest', listener1); + blockTracker.on('latest', listener2); + expect(blockTracker.isRunning()).toBe(true); + + await promiseForLatestBlock; + + blockTracker[methodToRemoveListener]('latest', listener1); + blockTracker[methodToRemoveListener]('latest', listener2); + expect(blockTracker.isRunning()).toBe(false); + }); + }); + + it('should clear the current block number some time after the last instance of this event is removed', async () => { + const setTimeoutRecorder = recordCallsToSetTimeout(); + const blockTrackerOptions = { + pollingInterval: 100, + blockResetDuration: 200, + }; + + await withPollingBlockTracker( + { + provider: { + stubs: [ + { + methodName: 'eth_blockNumber', + result: '0x0', + }, + ], + }, + blockTracker: blockTrackerOptions, + }, + async ({ blockTracker }) => { + const listener1 = EMPTY_FUNCTION; + const { promise: promiseForLatestBlock, resolve: listener2 } = + createDeferredPromise(); + + blockTracker.on('latest', listener1); + blockTracker.on('latest', listener2); + await promiseForLatestBlock; + const currentBlockNumber = blockTracker.getCurrentBlock(); + expect(currentBlockNumber).toBe('0x0'); + + blockTracker[methodToRemoveListener]('latest', listener1); + blockTracker[methodToRemoveListener]('latest', listener2); + // When the block tracker stops, there may be two `setTimeout`s in + // play: one to go to the next iteration of the block tracker + // loop, another to expire the current block number cache. We + // don't know which one has been added first, so we have to find + // it. + await setTimeoutRecorder.nextMatchingDuration( + blockTrackerOptions.blockResetDuration, + ); + expect(blockTracker.getCurrentBlock()).toBeNull(); + }, + ); + }); + + it('should cancel polling timeout and prevent multiple synchronize loops', async () => { + const setTimeoutRecorder = recordCallsToSetTimeout(); + + const blockTrackerOptions = { + pollingInterval: 100, + blockResetDuration: 200, + }; + + await withPollingBlockTracker( + { + provider: { + stubs: [ + { + methodName: 'eth_blockNumber', + result: '0x0', + }, + { + methodName: 'eth_blockNumber', + result: '0x1', + }, + { + methodName: 'eth_blockNumber', + result: '0x2', + }, + ], + }, + blockTracker: blockTrackerOptions, + }, + async ({ blockTracker }) => { + const listener = EMPTY_FUNCTION; + + for (let i = 0; i < 3; i++) { + blockTracker.on('latest', listener); + + expect(blockTracker.isRunning()).toBe(true); + + await new Promise((resolve) => { + blockTracker.on('_waitingForNextIteration', resolve); + }); + + blockTracker[methodToRemoveListener]('latest', listener); + + expect(blockTracker.isRunning()).toBe(false); + } + + expect( + setTimeoutRecorder.findCallsMatchingDuration( + blockTrackerOptions.pollingInterval, + ), + ).toHaveLength(0); + }, + ); + }); + }); + + describe('"sync"', () => { + it('should stop the block tracker if the last instance of this event is removed', async () => { + recordCallsToSetTimeout(); + + await withPollingBlockTracker(async ({ blockTracker }) => { + const listener1 = EMPTY_FUNCTION; + const { promise: promiseForLatestBlock, resolve: listener2 } = + createDeferredPromise(); + + blockTracker.on('sync', listener1); + blockTracker.on('sync', listener2); + expect(blockTracker.isRunning()).toBe(true); + + await promiseForLatestBlock; + + blockTracker[methodToRemoveListener]('sync', listener1); + blockTracker[methodToRemoveListener]('sync', listener2); + expect(blockTracker.isRunning()).toBe(false); + }); + }); + + it('should clear the current block number some time after the last instance of this event is removed', async () => { + const setTimeoutRecorder = recordCallsToSetTimeout(); + const blockTrackerOptions = { + pollingInterval: 100, + blockResetDuration: 200, + }; + + await withPollingBlockTracker( + { + provider: { + stubs: [ + { + methodName: 'eth_blockNumber', + result: '0x0', + }, + ], + }, + blockTracker: blockTrackerOptions, + }, + async ({ blockTracker }) => { + const listener1 = EMPTY_FUNCTION; + const { promise: promiseForLatestBlock, resolve: listener2 } = + createDeferredPromise(); + + blockTracker.on('sync', listener1); + blockTracker.on('sync', listener2); + await promiseForLatestBlock; + const currentBlockNumber = blockTracker.getCurrentBlock(); + expect(currentBlockNumber).toBe('0x0'); + + blockTracker[methodToRemoveListener]('sync', listener1); + blockTracker[methodToRemoveListener]('sync', listener2); + // When the block tracker stops, there may be two `setTimeout`s in + // play: one to go to the next iteration of the block tracker + // loop, another to expire the current block number cache. We + // don't know which one has been added first, so we have to find + // it. + await setTimeoutRecorder.nextMatchingDuration( + blockTrackerOptions.blockResetDuration, + ); + expect(blockTracker.getCurrentBlock()).toBeNull(); + }, + ); + }); + }); + + describe('some other event', () => { + it('should not stop the block tracker', async () => { + recordCallsToSetTimeout(); + + await withPollingBlockTracker(async ({ blockTracker }) => { + const { promise: promiseForLatestBlock, resolve: listener1 } = + createDeferredPromise(); + const listener2 = EMPTY_FUNCTION; + + blockTracker.on('latest', listener1); + blockTracker.on('somethingElse', listener2); + expect(blockTracker.isRunning()).toBe(true); + + await promiseForLatestBlock; + + blockTracker[methodToRemoveListener]('somethingElse', listener2); + expect(blockTracker.isRunning()).toBe(true); + }); + }); + }); + }); + }); + + describe('once', () => { + describe('"latest"', () => { + it('should start and then stop the block tracker automatically', async () => { + // We stub 2 calls because PollingBlockTracker#_synchronize will make a + // call (to proceed to the next iteration) and BaseBlockTracker will + // make a call (to reset the current block number when the tracker is + // not running) + recordCallsToSetTimeout({ numAutomaticCalls: 2 }); + + await withPollingBlockTracker(async ({ blockTracker }) => { + await new Promise((resolve) => { + blockTracker.on('_ended', resolve); + blockTracker.once('latest', EMPTY_FUNCTION); + }); + + expect(blockTracker.isRunning()).toBe(false); + }); + }); + + it('should not prevent Node from exiting when the poll loop is stopped while waiting for the next iteration', async () => { + const setTimeoutRecorder = recordCallsToSetTimeout(); + const blockTrackerOptions = { + pollingInterval: 100, + blockResetDuration: 200, + keepEventLoopActive: false, + }; + + await withPollingBlockTracker( + { + provider: { + stubs: [ + { + methodName: 'eth_blockNumber', + result: '0x0', + }, + { + methodName: 'eth_blockNumber', + result: '0x1', + }, + ], + }, + blockTracker: blockTrackerOptions, + }, + async ({ blockTracker }) => { + const { promise, resolve: listener } = createDeferredPromise(); + + blockTracker.once('latest', listener); + + await promise; + + // Once the listener has fired the block tracker should stop, + // meaning there should be no timeouts. + expect( + setTimeoutRecorder.findCallsMatchingDuration( + blockTrackerOptions.pollingInterval, + ), + ).toHaveLength(0); + }, + ); + }); + + it('should set the current block number and then clear it some time afterward', async () => { + const setTimeoutRecorder = recordCallsToSetTimeout(); + const blockTrackerOptions = { + pollingInterval: 100, + blockResetDuration: 200, + }; + + await withPollingBlockTracker( + { + provider: { + stubs: [ + { + methodName: 'eth_blockNumber', + result: '0x0', + }, + ], + }, + blockTracker: blockTrackerOptions, + }, + async ({ blockTracker }) => { + await new Promise((resolve) => { + blockTracker.once('latest', resolve); + }); + expect(blockTracker.getCurrentBlock()).toBe('0x0'); + + // When the block tracker stops, there may be two `setTimeout`s in + // play: one to go to the next iteration of the block tracker + // loop, another to expire the current block number cache. We don't + // know which one has been added first, so we have to find it. + await setTimeoutRecorder.nextMatchingDuration( + blockTrackerOptions.blockResetDuration, + ); + expect(blockTracker.getCurrentBlock()).toBeNull(); + }, + ); + }); + + METHODS_TO_ADD_LISTENER.forEach((methodToAddListener) => { + it(`should emit the "error" event (added via \`${methodToAddListener}\`) and should not throw if, while making the request for the latest block number, the provider throws`, async () => { + const thrownError = new Error('boom'); + recordCallsToSetTimeout({ numAutomaticCalls: 1 }); + + await withPollingBlockTracker( + { + provider: { + stubs: [ + { + methodName: 'eth_blockNumber', + implementation: (): never => { + throw thrownError; + }, + }, + { + methodName: 'eth_blockNumber', + result: '0x0', + }, + ], + }, + }, + async ({ blockTracker }) => { + const errorListener = jest.fn(); + blockTracker[methodToAddListener]('error', errorListener); + + const promiseForLatestBlock = new Promise((resolve) => { + blockTracker.once('latest', resolve); + }); + + const latestBlock = await promiseForLatestBlock; + expect(errorListener).toHaveBeenCalledWith(thrownError); + expect(latestBlock).toBe('0x0'); + }, + ); + }); + + it(`should emit the "error" event (added via \`${methodToAddListener}\`) and should not throw if, while making the request for the latest block number, the provider rejects with an error`, async () => { + const thrownError = new Error('boom'); + recordCallsToSetTimeout({ numAutomaticCalls: 1 }); + + await withPollingBlockTracker( + { + provider: { + stubs: [ + { + methodName: 'eth_blockNumber', + error: thrownError, + }, + { + methodName: 'eth_blockNumber', + result: '0x0', + }, + ], + }, + }, + async ({ blockTracker }) => { + const errorListener = jest.fn(); + blockTracker[methodToAddListener]('error', errorListener); + + const promiseForLatestBlock = new Promise((resolve) => { + blockTracker.once('latest', resolve); + }); + + const latestBlock = await promiseForLatestBlock; + expect(errorListener).toHaveBeenCalledWith(thrownError); + expect(latestBlock).toBe('0x0'); + }, + ); + }); + }); + + it('should log an error if, while making a request for the latest block number, the provider throws and there is nothing listening to "error"', async () => { + const thrownError = new Error('boom'); + recordCallsToSetTimeout({ numAutomaticCalls: 1 }); + + await withPollingBlockTracker( + { + provider: { + stubs: [ + { + methodName: 'eth_blockNumber', + implementation: (): never => { + throw thrownError; + }, + }, + { + methodName: 'eth_blockNumber', + result: '0x0', + }, + ], + }, + }, + async ({ blockTracker }) => { + jest.spyOn(console, 'error').mockImplementation(EMPTY_FUNCTION); + + blockTracker.once('latest', EMPTY_FUNCTION); + await new Promise((resolve) => { + blockTracker.on('_waitingForNextIteration', resolve); + }); + + expect(console.error).toHaveBeenCalledWith( + 'Error updating latest block: boom', + ); + }, + ); + }); + + it('should log an error if, while making the request for the latest block number, the provider rejects with an error and there is nothing listening to "error"', async () => { + const thrownError = new Error('boom'); + recordCallsToSetTimeout({ numAutomaticCalls: 1 }); + + await withPollingBlockTracker( + { + provider: { + stubs: [ + { + methodName: 'eth_blockNumber', + error: thrownError, + }, + { + methodName: 'eth_blockNumber', + result: '0x0', + }, + ], + }, + }, + async ({ blockTracker }) => { + jest.spyOn(console, 'error').mockImplementation(EMPTY_FUNCTION); + + blockTracker.once('latest', EMPTY_FUNCTION); + await new Promise((resolve) => { + blockTracker.on('_waitingForNextIteration', resolve); + }); + + expect(console.error).toHaveBeenCalledWith( + 'Error updating latest block: boom', + ); + }, + ); + }); + }); + + describe('"sync"', () => { + it('should start and then stop the block tracker automatically', async () => { + // We stub 2 calls because PollingBlockTracker#_synchronize will make a call + // (to proceed to the next iteration) and BaseBlockTracker will make a call + // (to reset the current block number when the tracker is not running) + recordCallsToSetTimeout({ numAutomaticCalls: 2 }); + + await withPollingBlockTracker(async ({ blockTracker }) => { + await new Promise((resolve) => { + blockTracker.on('_ended', resolve); + blockTracker.once('sync', EMPTY_FUNCTION); + }); + + expect(blockTracker.isRunning()).toBe(false); + }); + }); + + it('should set the current block number and then clear it some time afterward', async () => { + const setTimeoutRecorder = recordCallsToSetTimeout(); + const blockTrackerOptions = { + pollingInterval: 100, + blockResetDuration: 200, + }; + + await withPollingBlockTracker( + { + provider: { + stubs: [ + { + methodName: 'eth_blockNumber', + result: '0x0', + }, + ], + }, + blockTracker: blockTrackerOptions, + }, + async ({ blockTracker }) => { + await new Promise((resolve) => { + blockTracker.once('sync', resolve); + }); + expect(blockTracker.getCurrentBlock()).toBe('0x0'); + + // When the block tracker stops, there may be two `setTimeout`s in + // play: one to go to the next iteration of the block tracker + // loop, another to expire the current block number cache. We don't + // know which one has been added first, so we have to find it. + await setTimeoutRecorder.nextMatchingDuration( + blockTrackerOptions.blockResetDuration, + ); + expect(blockTracker.getCurrentBlock()).toBeNull(); + }, + ); + }); + + METHODS_TO_ADD_LISTENER.forEach((methodToAddListener) => { + it(`should emit the "error" event (added via \`${methodToAddListener}\`) and should not throw if, while making the request for the latest block number, the provider throws`, async () => { + const thrownError = new Error('boom'); + recordCallsToSetTimeout({ numAutomaticCalls: 1 }); + + await withPollingBlockTracker( + { + provider: { + stubs: [ + { + methodName: 'eth_blockNumber', + implementation: (): never => { + throw thrownError; + }, + }, + { + methodName: 'eth_blockNumber', + result: '0x0', + }, + ], + }, + }, + async ({ blockTracker }) => { + const errorListener = jest.fn(); + blockTracker[methodToAddListener]('error', errorListener); + + const promiseForSync = new Promise((resolve) => { + blockTracker.once('sync', resolve); + }); + + const sync = await promiseForSync; + expect(errorListener).toHaveBeenCalledWith(thrownError); + expect(sync).toStrictEqual({ oldBlock: null, newBlock: '0x0' }); + }, + ); + }); + + it(`should emit the "error" event (added via \`${methodToAddListener}\`) and should not throw if, while making the request for the latest block number, the provider rejects with an error`, async () => { + const thrownError = new Error('boom'); + recordCallsToSetTimeout({ numAutomaticCalls: 1 }); + + await withPollingBlockTracker( + { + provider: { + stubs: [ + { + methodName: 'eth_blockNumber', + error: thrownError, + }, + { + methodName: 'eth_blockNumber', + result: '0x0', + }, + ], + }, + }, + async ({ blockTracker }) => { + const errorListener = jest.fn(); + blockTracker[methodToAddListener]('error', errorListener); + + const promiseForSync = new Promise((resolve) => { + blockTracker.once('sync', resolve); + }); + + const sync = await promiseForSync; + expect(errorListener).toHaveBeenCalledWith(thrownError); + expect(sync).toStrictEqual({ oldBlock: null, newBlock: '0x0' }); + }, + ); + }); + }); + + it('should log an error if, while making a request for the latest block number, the provider throws and there is nothing listening to "error"', async () => { + const thrownError = new Error('boom'); + recordCallsToSetTimeout({ numAutomaticCalls: 1 }); + + await withPollingBlockTracker( + { + provider: { + stubs: [ + { + methodName: 'eth_blockNumber', + implementation: (): never => { + throw thrownError; + }, + }, + { + methodName: 'eth_blockNumber', + result: '0x0', + }, + ], + }, + }, + async ({ blockTracker }) => { + jest.spyOn(console, 'error').mockImplementation(EMPTY_FUNCTION); + + blockTracker.once('sync', EMPTY_FUNCTION); + await new Promise((resolve) => { + blockTracker.on('_waitingForNextIteration', resolve); + }); + + expect(console.error).toHaveBeenCalledWith( + 'Error updating latest block: boom', + ); + }, + ); + }); + + it('should log an error if, while making the request for the latest block number, the provider rejects with an error and there is nothing listening to "error"', async () => { + const thrownError = new Error('boom'); + recordCallsToSetTimeout({ numAutomaticCalls: 1 }); + + await withPollingBlockTracker( + { + provider: { + stubs: [ + { + methodName: 'eth_blockNumber', + error: thrownError, + }, + { + methodName: 'eth_blockNumber', + result: '0x0', + }, + ], + }, + }, + async ({ blockTracker }) => { + jest.spyOn(console, 'error').mockImplementation(EMPTY_FUNCTION); + + blockTracker.once('sync', EMPTY_FUNCTION); + await new Promise((resolve) => { + blockTracker.on('_waitingForNextIteration', resolve); + }); + + expect(console.error).toHaveBeenCalledWith( + 'Error updating latest block: boom', + ); + }, + ); + }); + }); + + describe('some other event', () => { + it('should never start the block tracker', async () => { + // We stub 2 calls because PollingBlockTracker#_synchronize will make a call + // (to proceed to the next iteration) and BaseBlockTracker will make a call + // (to reset the current block number when the tracker is not running) + recordCallsToSetTimeout({ numAutomaticCalls: 2 }); + + await withPollingBlockTracker(async ({ blockTracker }) => { + const listener = jest.fn(); + blockTracker.on('_ended', listener); + blockTracker.once('somethingElse', EMPTY_FUNCTION); + + expect(listener).not.toHaveBeenCalled(); + }); + }); + + it('should never set the current block number', async () => { + recordCallsToSetTimeout({ numAutomaticCalls: 1 }); + + await withPollingBlockTracker( + { + provider: { + stubs: [ + { + methodName: 'eth_blockNumber', + result: '0x0', + }, + ], + }, + }, + async ({ blockTracker }) => { + blockTracker.once('somethingElse', EMPTY_FUNCTION); + expect(blockTracker.getCurrentBlock()).toBeNull(); + }, + ); + }); + }); + }); + + describe('removeAllListeners', () => { + it('should stop the block tracker if any "latest" and "sync" events were added previously', async () => { + recordCallsToSetTimeout(); + + await withPollingBlockTracker(async ({ blockTracker }) => { + blockTracker.on('latest', EMPTY_FUNCTION); + await new Promise((resolve) => { + blockTracker.on('sync', resolve); + }); + expect(blockTracker.isRunning()).toBe(true); + + blockTracker.removeAllListeners(); + expect(blockTracker.isRunning()).toBe(false); + }); + }); + + it('should clear the current block number some time after all "latest" and "sync" events are removed', async () => { + const setTimeoutRecorder = recordCallsToSetTimeout(); + const blockTrackerOptions = { + pollingInterval: 100, + blockResetDuration: 200, + }; + + await withPollingBlockTracker( + { + provider: { + stubs: [ + { + methodName: 'eth_blockNumber', + result: '0x0', + }, + ], + }, + blockTracker: blockTrackerOptions, + }, + async ({ blockTracker }) => { + blockTracker.on('latest', EMPTY_FUNCTION); + await new Promise((resolve) => { + blockTracker.on('sync', resolve); + }); + expect(blockTracker.getCurrentBlock()).toBe('0x0'); + + blockTracker.removeAllListeners(); + // When the block tracker stops, there may be two `setTimeout`s in + // play: one to go to the next iteration of the block tracker + // loop, another to expire the current block number cache. We don't + // know which one has been added first, so we have to find it. + await setTimeoutRecorder.nextMatchingDuration( + blockTrackerOptions.blockResetDuration, + ); + expect(blockTracker.getCurrentBlock()).toBeNull(); + }, + ); + }); + + it('should stop the block tracker when all previously added "latest" and "sync" events are removed specifically', async () => { + recordCallsToSetTimeout(); + + await withPollingBlockTracker(async ({ blockTracker }) => { + await new Promise((resolve) => { + blockTracker.on('latest', EMPTY_FUNCTION); + blockTracker.on('sync', resolve); + }); + expect(blockTracker.isRunning()).toBe(true); + + blockTracker.removeAllListeners('latest'); + expect(blockTracker.isRunning()).toBe(true); + + blockTracker.removeAllListeners('sync'); + expect(blockTracker.isRunning()).toBe(false); + }); + }); + + it('should clear the current block number some time after all "latest" and "sync" events are removed specifically', async () => { + const setTimeoutRecorder = recordCallsToSetTimeout(); + const blockTrackerOptions = { + pollingInterval: 100, + blockResetDuration: 200, + }; + + await withPollingBlockTracker( + { + provider: { + stubs: [ + { + methodName: 'eth_blockNumber', + result: '0x0', + }, + ], + }, + blockTracker: blockTrackerOptions, + }, + async ({ blockTracker }) => { + blockTracker.on('latest', EMPTY_FUNCTION); + await new Promise((resolve) => { + blockTracker.on('sync', resolve); + }); + expect(blockTracker.getCurrentBlock()).toBe('0x0'); + + blockTracker.removeAllListeners('latest'); + blockTracker.removeAllListeners('sync'); + // When the block tracker stops, there may be two `setTimeout`s in + // play: one to go to the next iteration of the block tracker + // loop, another to expire the current block number cache. We don't + // know which one has been added first, so we have to find it. + await setTimeoutRecorder.nextMatchingDuration( + blockTrackerOptions.blockResetDuration, + ); + expect(blockTracker.getCurrentBlock()).toBeNull(); + }, + ); + }); + }); +}); diff --git a/packages/eth-block-tracker/src/PollingBlockTracker.ts b/packages/eth-block-tracker/src/PollingBlockTracker.ts new file mode 100644 index 00000000000..0908262b6a7 --- /dev/null +++ b/packages/eth-block-tracker/src/PollingBlockTracker.ts @@ -0,0 +1,468 @@ +import type { InternalProvider } from '@metamask/eth-json-rpc-provider'; +import type { + ContextConstraint, + MiddlewareContext, +} from '@metamask/json-rpc-engine/v2'; +import SafeEventEmitter from '@metamask/safe-event-emitter'; +import { createDeferredPromise, getErrorMessage } from '@metamask/utils'; +import type { DeferredPromise, JsonRpcRequest } from '@metamask/utils'; +import getCreateRandomId from 'json-rpc-random-id'; + +import type { BlockTracker } from './BlockTracker.js'; +import { projectLogger, createModuleLogger } from './logging-utils.js'; + +const log = createModuleLogger(projectLogger, 'polling-block-tracker'); +const createRandomId = getCreateRandomId(); +const sec = 1000; + +const blockTrackerEvents: (string | symbol)[] = ['sync', 'latest']; + +export type PollingBlockTrackerOptions< + Context extends ContextConstraint = MiddlewareContext, +> = { + provider?: InternalProvider; + pollingInterval?: number; + retryTimeout?: number; + keepEventLoopActive?: boolean; + setSkipCacheFlag?: boolean; + blockResetDuration?: number; + usePastBlocks?: boolean; +}; + +type ExtendedJsonRpcRequest = { + skipCache?: boolean; +} & JsonRpcRequest<[]>; + +type InternalListener = (value: string) => void; + +export class PollingBlockTracker< + Context extends ContextConstraint = MiddlewareContext, +> + extends SafeEventEmitter + implements BlockTracker +{ + #isRunning: boolean; + + readonly #blockResetDuration: number; + + readonly #usePastBlocks: boolean; + + #currentBlock: string | null; + + #blockResetTimeout?: ReturnType; + + #pollingTimeout?: ReturnType; + + readonly #provider: InternalProvider; + + readonly #pollingInterval: number; + + readonly #retryTimeout: number; + + readonly #keepEventLoopActive: boolean; + + readonly #setSkipCacheFlag: boolean; + + readonly #internalEventListeners: InternalListener[] = []; + + #pendingLatestBlock?: Omit, 'resolve'>; + + #pendingFetch?: Omit, 'resolve'>; + + #pendingCheckForLatestBlock?: Promise; + + readonly #onNewListener: (eventName: string | symbol) => void; + + readonly #onRemoveListener: () => void; + + readonly #resetCurrentBlock: () => void; + + constructor(opts: PollingBlockTrackerOptions = {}) { + // parse + validate args + if (!opts.provider) { + throw new Error('PollingBlockTracker - no provider specified.'); + } + + super(); + + // config + this.#blockResetDuration = opts.blockResetDuration ?? 20 * sec; + this.#usePastBlocks = opts.usePastBlocks ?? false; + // state + this.#currentBlock = null; + this.#isRunning = false; + + // bind functions for internal use + this.#onNewListener = this.#onNewListenerUnbound.bind(this); + this.#onRemoveListener = this.#onRemoveListenerUnbound.bind(this); + this.#resetCurrentBlock = this.#resetCurrentBlockUnbound.bind(this); + + // listen for handler changes + this.#setupInternalEvents(); + + // config + this.#provider = opts.provider; + this.#pollingInterval = opts.pollingInterval ?? 20 * sec; + this.#retryTimeout = opts.retryTimeout ?? this.#pollingInterval / 10; + this.#keepEventLoopActive = opts.keepEventLoopActive ?? true; + this.#setSkipCacheFlag = opts.setSkipCacheFlag ?? false; + } + + async destroy(): Promise { + this.#cancelBlockResetTimeout(); + super.removeAllListeners(); + this.#maybeEnd(); + } + + isRunning(): boolean { + return this.#isRunning; + } + + getCurrentBlock(): string | null { + return this.#currentBlock; + } + + async getLatestBlock({ + useCache = true, + }: { useCache?: boolean } = {}): Promise { + // return if available + if (this.#currentBlock && useCache) { + return this.#currentBlock; + } + + if (this.#pendingLatestBlock) { + return await this.#pendingLatestBlock.promise; + } + + const { promise, resolve, reject } = createDeferredPromise({ + suppressUnhandledRejection: true, + }); + this.#pendingLatestBlock = { reject, promise }; + + if (this.#isRunning) { + try { + // If tracker is running, wait for next block with timeout + const onLatestBlock = (value: string): void => { + this.#removeInternalListener(onLatestBlock); + this.removeListener('latest', onLatestBlock); + resolve(value); + }; + + this.#addInternalListener(onLatestBlock); + this.once('latest', onLatestBlock); + + return await promise; + } catch (error) { + reject(error); + throw error; + } finally { + this.#pendingLatestBlock = undefined; + } + } else { + // If tracker isn't running, just fetch directly + try { + const latestBlock = await this.#updateLatestBlock(); + resolve(latestBlock); + return latestBlock; + } catch (error) { + reject(error); + throw error; + } finally { + // We want to rate limit calls to this method if we made a direct fetch + // for the block number because the BlockTracker was not running. We + // achieve this by delaying the unsetting of the #pendingLatestBlock promise. + setTimeout(() => { + this.#pendingLatestBlock = undefined; + }, this.#pollingInterval); + } + } + } + + // Don't allow module consumer to remove our internal event listeners. + removeAllListeners(eventName?: string | symbol): this { + // perform default behavior, preserve fn arity + if (eventName) { + super.removeAllListeners(eventName); + } else { + super.removeAllListeners(); + } + + // re-add internal events + this.#setupInternalEvents(); + // trigger stop check just in case + this.#onRemoveListener(); + + return this; + } + + #setupInternalEvents(): void { + // first remove listeners for idempotence + this.removeListener('newListener', this.#onNewListener); + this.removeListener('removeListener', this.#onRemoveListener); + // then add them + this.on('newListener', this.#onNewListener); + this.on('removeListener', this.#onRemoveListener); + } + + #onNewListenerUnbound(eventName: string | symbol): void { + // `newListener` is called *before* the listener is added + if (blockTrackerEvents.includes(eventName)) { + // TODO: Handle dangling promise + this.#maybeStart(); + } + } + + #onRemoveListenerUnbound(): void { + // `removeListener` is called *after* the listener is removed + if (this.#getBlockTrackerEventCount() > 0) { + return; + } + this.#maybeEnd(); + } + + #maybeStart(): void { + if (this.#isRunning) { + return; + } + + this.#isRunning = true; + // cancel setting latest block to stale + this.#cancelBlockResetTimeout(); + this.#start(); + this.emit('_started'); + } + + #maybeEnd(): void { + if (!this.#isRunning) { + return; + } + + this.#isRunning = false; + this.#setupBlockResetTimeout(); + this.#end(); + this.#rejectPendingLatestBlock(new Error('Block tracker destroyed')); + this.emit('_ended'); + } + + #getBlockTrackerEventCount(): number { + return ( + blockTrackerEvents + .map((eventName) => this.listeners(eventName)) + .flat() + // internal listeners are not included in the count + .filter((listener) => + this.#internalEventListeners.every( + (internalListener) => !Object.is(internalListener, listener), + ), + ).length + ); + } + + #shouldUseNewBlock(newBlock: string): boolean { + const currentBlock = this.#currentBlock; + if (!currentBlock) { + return true; + } + const newBlockInt = hexToInt(newBlock); + const currentBlockInt = hexToInt(currentBlock); + + return ( + (this.#usePastBlocks && newBlockInt < currentBlockInt) || + newBlockInt > currentBlockInt + ); + } + + #newPotentialLatest(newBlock: string): void { + if (!this.#shouldUseNewBlock(newBlock)) { + return; + } + this.#setCurrentBlock(newBlock); + } + + #setCurrentBlock(newBlock: string): void { + const oldBlock = this.#currentBlock; + this.#currentBlock = newBlock; + this.emit('latest', newBlock); + this.emit('sync', { oldBlock, newBlock }); + } + + #setupBlockResetTimeout(): void { + // clear any existing timeout + this.#cancelBlockResetTimeout(); + // clear latest block when stale + this.#blockResetTimeout = setTimeout( + this.#resetCurrentBlock, + this.#blockResetDuration, + ); + + // nodejs - dont hold process open + if (this.#blockResetTimeout.unref) { + this.#blockResetTimeout.unref(); + } + } + + #cancelBlockResetTimeout(): void { + if (this.#blockResetTimeout) { + clearTimeout(this.#blockResetTimeout); + } + } + + #resetCurrentBlockUnbound(): void { + this.#currentBlock = null; + } + + /** + * Checks for the latest block, updates the internal state, and returns the + * value immediately rather than waiting for the next polling interval. + * + * @deprecated Use {@link getLatestBlock} instead. + * @returns A promise that resolves to the latest block number. + */ + checkForLatestBlock(): Promise { + if (!this.#pendingCheckForLatestBlock) { + this.#pendingCheckForLatestBlock = this.#updateLatestBlock() + .then(() => this.getLatestBlock()) + .finally(() => { + this.#pendingCheckForLatestBlock = undefined; + }); + } + return this.#pendingCheckForLatestBlock; + } + + #start(): void { + // Intentionally not awaited as this starts the polling via a timeout chain. + // eslint-disable-next-line @typescript-eslint/no-floating-promises + this.#updateAndQueue(); + } + + #end(): void { + this.#clearPollingTimeout(); + } + + async #updateLatestBlock(): Promise { + // fetch + set latest block + const latestBlock = await this.#fetchLatestBlock(); + this.#newPotentialLatest(latestBlock); + + if (!this.#isRunning) { + // Ensure the one-time update is eventually reset once it's stale + this.#setupBlockResetTimeout(); + } + + // _newPotentialLatest() ensures that this._currentBlock is not null + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + return this.#currentBlock!; + } + + async #fetchLatestBlock(): Promise { + // If there's already a pending fetch, reuse it + if (this.#pendingFetch) { + return await this.#pendingFetch.promise; + } + + // Create a new deferred promise for this request + const { promise, resolve, reject } = createDeferredPromise({ + suppressUnhandledRejection: true, + }); + this.#pendingFetch = { reject, promise }; + + try { + const req: ExtendedJsonRpcRequest = { + jsonrpc: '2.0', + id: createRandomId(), + method: 'eth_blockNumber', + params: [] as [], + }; + if (this.#setSkipCacheFlag) { + req.skipCache = true; + } + + log('Making request', req); + const result = await this.#provider.request<[], string>(req); + log('Got result', result); + resolve(result); + return result; + } catch (error) { + log('Encountered error fetching block', getErrorMessage(error)); + reject(error); + this.#rejectPendingLatestBlock(error); + throw error; + } finally { + this.#pendingFetch = undefined; + } + } + + /** + * The core polling function that runs after each interval. + * Updates the latest block and then queues the next update. + */ + async #updateAndQueue(): Promise { + let interval = this.#pollingInterval; + + try { + await this.#updateLatestBlock(); + } catch (error: unknown) { + try { + this.emit('error', error); + } catch { + console.error(`Error updating latest block: ${getErrorMessage(error)}`); + } + + interval = this.#retryTimeout; + } + + if (!this.#isRunning) { + return; + } + + this.#clearPollingTimeout(); + + const timeoutRef = setTimeout(() => { + // Intentionally not awaited as this just continues the polling loop. + // eslint-disable-next-line @typescript-eslint/no-floating-promises + this.#updateAndQueue(); + }, interval); + + if (timeoutRef.unref && !this.#keepEventLoopActive) { + timeoutRef.unref(); + } + + this.#pollingTimeout = timeoutRef; + + this.emit('_waitingForNextIteration'); + } + + #clearPollingTimeout(): void { + if (this.#pollingTimeout) { + clearTimeout(this.#pollingTimeout); + this.#pollingTimeout = undefined; + } + } + + #addInternalListener(listener: InternalListener): void { + this.#internalEventListeners.push(listener); + } + + #removeInternalListener(listener: InternalListener): void { + this.#internalEventListeners.splice( + this.#internalEventListeners.indexOf(listener), + 1, + ); + } + + #rejectPendingLatestBlock(error: unknown): void { + this.#pendingLatestBlock?.reject(error); + this.#pendingLatestBlock = undefined; + } +} + +/** + * Converts a number represented as a string in hexadecimal format into a native + * number. + * + * @param hexInt - The hex string. + * @returns The number. + */ +function hexToInt(hexInt: string): number { + return Number.parseInt(hexInt, 16); +} diff --git a/packages/eth-block-tracker/src/index.ts b/packages/eth-block-tracker/src/index.ts new file mode 100644 index 00000000000..32370f265de --- /dev/null +++ b/packages/eth-block-tracker/src/index.ts @@ -0,0 +1,2 @@ +export * from './PollingBlockTracker.js'; +export type * from './BlockTracker.js'; diff --git a/packages/eth-block-tracker/src/logging-utils.ts b/packages/eth-block-tracker/src/logging-utils.ts new file mode 100644 index 00000000000..859ca4b9896 --- /dev/null +++ b/packages/eth-block-tracker/src/logging-utils.ts @@ -0,0 +1,5 @@ +import { createProjectLogger, createModuleLogger } from '@metamask/utils'; + +export const projectLogger = createProjectLogger('eth-block-tracker'); + +export { createModuleLogger }; diff --git a/packages/eth-block-tracker/tests/emptyFunction.ts b/packages/eth-block-tracker/tests/emptyFunction.ts new file mode 100644 index 00000000000..b663663b77a --- /dev/null +++ b/packages/eth-block-tracker/tests/emptyFunction.ts @@ -0,0 +1,5 @@ +const EMPTY_FUNCTION = (): void => { + // intentionally left blank +}; + +export default EMPTY_FUNCTION; diff --git a/packages/eth-block-tracker/tests/recordCallsToSetTimeout.ts b/packages/eth-block-tracker/tests/recordCallsToSetTimeout.ts new file mode 100644 index 00000000000..7a0c98b7481 --- /dev/null +++ b/packages/eth-block-tracker/tests/recordCallsToSetTimeout.ts @@ -0,0 +1,221 @@ +import EventEmitter from 'events'; + +import EMPTY_FUNCTION from './emptyFunction.js'; + +type SetTimeoutCallback = () => unknown; + +type SetTimeoutCall = { + callback: SetTimeoutCallback; + duration: number; + timeout: NodeJS.Timeout; +}; + +type InterceptingCallback = ( + callback: SetTimeoutCallback, + stopPassingThroughCalls: () => void, +) => SetTimeoutCallback; + +const originalSetTimeout = setTimeout; + +/** + * A class that provides a mock implementation for `setTimeout` which records + * the callback given so that it can be replayed later. + */ +class SetTimeoutRecorder { + public calls: SetTimeoutCall[]; + + readonly #interceptCallback: InterceptingCallback; + + readonly #events: EventEmitter; + + #numAutomaticCallsRemaining: number; + + constructor({ + numAutomaticCalls = 0, + interceptCallback = (callback): SetTimeoutCallback => callback, + }: { + numAutomaticCalls?: number; + interceptCallback?: InterceptingCallback; + }) { + this.#interceptCallback = interceptCallback; + + this.calls = []; + this.#events = new EventEmitter(); + this.#numAutomaticCallsRemaining = numAutomaticCalls; + } + + /** + * Removes the first `setTimeout` call from the call stack and calls it, or + * waits until one appears. + * + * @returns A promise that resolves when the first `setTimeout` call is + * called. + */ + async next(): Promise { + await new Promise((resolve) => { + if (this.calls.length > 0) { + const call = this.calls.shift() as SetTimeoutCall; + call.callback(); + resolve(); + } else { + this.#events.once('setTimeoutAdded', () => { + const call = this.calls.shift() as SetTimeoutCall; + call.callback(); + resolve(); + }); + } + }); + } + + /** + * Looks for the first `setTimeout` call in the call stack that matches the + * given duration and calls it, removing it from the call stack, or waits + * until such a call appears. + * + * @param duration - The expected duration of a `setTimeout` call. + * @returns A promise that resolves when a `setTimeout` call matching the + * given duration is called. + */ + async nextMatchingDuration(duration: number): Promise { + await new Promise((resolve) => { + const index = this.calls.findIndex((call) => call.duration === duration); + + if (index === -1) { + const listener = (call: SetTimeoutCall, callIndex: number): void => { + if (call.duration === duration) { + this.calls.splice(callIndex, 1); + call.callback(); + this.#events.off('setTimeoutAdded', listener); + resolve(); + } + }; + this.#events.on('setTimeoutAdded', listener); + } else { + const call = this.calls[index]; + this.calls.splice(index, 1); + call.callback(); + resolve(); + } + }); + } + + findCallsMatchingDuration(duration: number): SetTimeoutCall[] { + return this.calls.filter((call) => call.duration === duration); + } + + /** + * Registers a callback that will be called when `setTimeout` is called and + * the expected number of `setTimeout` calls (as specified via + * `numAutomaticCalls`) is exceeded. + * + * @param callback - The callback to register. + */ + onNumAutomaticCallsExhausted(callback: () => void): void { + this.#events.on('numCallsToPassThroughExhausted', callback); + } + + /** + * The function with which to replace the global `setTimeout` function. This + * mock implementation will record the call to `setTimeout`, along with its + * callback and duration, in a call stack, which can be accessed later. + * + * @param callback - The callback associated with a particular `setTimeout` + * call. + * @param duration - The duration associated with a particular `setTimeout` + * call. + * @returns An instance of NodeJS.Timeout which is only supplied to fulfill + * the existing type of `setTimeout` and serves no purpose. + */ + _mockSetTimeoutImplementation = ( + callback: SetTimeoutCallback, + duration: number | undefined = 0, + ): NodeJS.Timeout => { + // We still need `setTimeout` to return some kind of Timeout object, as this + // is what the signature of `setTimeout` demands, and anyway, we need an + // object that has an `unref` method on it. We don't need this timeout to + // do anything, we just need the object, so we need to call the unstubbed + // version of `setTimeout` in order to obtain that. + const timeout = originalSetTimeout(EMPTY_FUNCTION, 0); + const interceptedCallback = this.#interceptCallback( + callback, + this.#stopPassingThroughCalls.bind(this), + ); + const call = { + callback: interceptedCallback, + duration, + timeout, + }; + this.calls.push(call); + + if (this.#numAutomaticCallsRemaining > 0) { + call.callback(); + this.#numAutomaticCallsRemaining -= 1; + } else { + this.#events.emit('numCallsToPassThroughExhausted'); + } + this.#events.emit('setTimeoutAdded'); + return timeout; + }; + + /** + * The function with which to replace the global `clearTimeout` function. This + * mock implementation will find a call to `setTimeout` that returned the + * given Timeout object and remove it from the queue. If no such call has been + * made, then this does nothing. + * + * @param timeout - A Timeout object as returned by `setTimeout`. + */ + _mockClearTimeoutImplementation = ( + timeout?: NodeJS.Timeout | string | number, + ): void => { + const index = this.calls.findIndex((call) => call.timeout === timeout); + + if (index !== -1) { + this.calls.splice(index, 1); + } + }; + + #stopPassingThroughCalls(): void { + this.#numAutomaticCallsRemaining = 0; + } +} + +/** + * Replaces the global `setTimeout` function with one which, upon being called, + * records the callback given to it. The callback may be stored in a queue to be + * called later using `next` / `nextMatchingDuration`, or it may be called + * immediately. + * + * @param options - The options. + * @param options.numAutomaticCalls - By default, it is up to you to manually + * call `setTimeout`s that have been queued. If you know the number of times + * `setTimeout` should be called within a test, however, you may specify that + * here, and each time `setTimeout` is called, its callback will be called + * immediately, up to this many times (default: 0). + * @param options.interceptCallback - A function that can be used to replace a + * callback that is passed to `setTimeout`, allowing you to call it yourself + * (perhaps in a `try`/`catch` block, or something else). + * @returns An object that can be used to interact with calls to `setTimeout`. + */ +export default function recordCallsToSetTimeout({ + numAutomaticCalls = 0, + interceptCallback = (callback): SetTimeoutCallback => callback, +}: { + numAutomaticCalls?: number; + interceptCallback?: InterceptingCallback; +} = {}): SetTimeoutRecorder { + const setTimeoutRecorder = new SetTimeoutRecorder({ + numAutomaticCalls, + interceptCallback, + }); + + jest + .spyOn(globalThis, 'setTimeout') + .mockImplementation(setTimeoutRecorder._mockSetTimeoutImplementation); + + jest + .spyOn(globalThis, 'clearTimeout') + .mockImplementation(setTimeoutRecorder._mockClearTimeoutImplementation); + + return setTimeoutRecorder; +} diff --git a/packages/eth-block-tracker/tests/setupAfterEnv.ts b/packages/eth-block-tracker/tests/setupAfterEnv.ts new file mode 100644 index 00000000000..fe55d266068 --- /dev/null +++ b/packages/eth-block-tracker/tests/setupAfterEnv.ts @@ -0,0 +1,91 @@ +declare global { + // Using `namespace` here is okay because this is how the Jest types are + // defined. + /* eslint-disable-next-line @typescript-eslint/no-namespace */ + namespace jest { + // The generic parameter `R` must match the type specified in the Jest + // types for custom matchers, and this must use an interface to + // properly augment the existing Jest types. + // eslint-disable-next-line @typescript-eslint/naming-convention, @typescript-eslint/consistent-type-definitions + interface Matchers { + toNeverResolve(): Promise; + } + } +} + +// Export something so that TypeScript thinks that we are performing type +// augmentation +export {}; + +const UNRESOLVED = Symbol('timedOut'); +// Store this in case it gets stubbed later +const originalSetTimeout = globalThis.setTimeout; +const TIME_TO_WAIT_UNTIL_UNRESOLVED = 100; + +/** + * 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 + * the promise that is produced by this function resolves first, then the other + * one must be unresolved. + * + * @param duration - How long to wait before resolving the promise returned by + * this function. + * @returns A promise that resolves to a symbol. + */ +const treatUnresolvedAfter = async ( + duration: number, +): Promise => { + return new Promise((resolve) => { + originalSetTimeout(resolve, duration, UNRESOLVED); + }); +}; + +expect.extend({ + /** + * Tests that the given promise is never fulfilled or rejected past a certain + * amount of time (which is the default time that Jest tests wait before + * timing out as configured in the Jest configuration file). + * + * Inspired by . + * + * @param promise - The promise to test. + * @returns The result of the matcher. + */ + async toNeverResolve(promise: Promise) { + if (this.isNot) { + throw new Error( + 'Using `.not.toNeverResolve(...)` is not supported. ' + + 'You probably want to either `await` the promise and test its ' + + 'resolution value or use `.rejects` to test its rejection value instead.', + ); + } + + let resolutionValue: unknown; + let rejectionValue: unknown; + try { + resolutionValue = await Promise.race([ + promise, + treatUnresolvedAfter(TIME_TO_WAIT_UNTIL_UNRESOLVED), + ]); + } catch (error) { + rejectionValue = error; + } + + return resolutionValue === UNRESOLVED + ? { + message: (): string => + `Expected promise to resolve after ${TIME_TO_WAIT_UNTIL_UNRESOLVED}ms, but it did not`, + pass: true, + } + : { + message: (): string => { + return `Expected promise to never resolve after ${TIME_TO_WAIT_UNTIL_UNRESOLVED}ms, but it ${ + rejectionValue + ? `was rejected with ${this.utils.stringify(rejectionValue)}` + : `resolved with ${this.utils.stringify(resolutionValue)}` + }`; + }, + pass: false, + }; + }, +}); diff --git a/packages/eth-block-tracker/tests/withBlockTracker.ts b/packages/eth-block-tracker/tests/withBlockTracker.ts new file mode 100644 index 00000000000..4f78fbfdd60 --- /dev/null +++ b/packages/eth-block-tracker/tests/withBlockTracker.ts @@ -0,0 +1,177 @@ +import { InternalProvider } from '@metamask/eth-json-rpc-provider'; +import { JsonRpcEngine } from '@metamask/json-rpc-engine'; +import type { Json } from '@metamask/utils'; +import util from 'util'; + +import type { PollingBlockTrackerOptions } from '../src/index.js'; +import { PollingBlockTracker } from '../src/index.js'; + +type WithPollingBlockTrackerOptions = { + provider?: FakeProviderOptions; + blockTracker?: PollingBlockTrackerOptions; +}; + +type WithPollingBlockTrackerCallback = (args: { + provider: InternalProvider; + blockTracker: PollingBlockTracker; +}) => void | Promise; + +/** + * An object that allows specifying the behavior of a specific invocation of + * `request`. The `methodName` always identifies the stub, but the behavior + * may be specified multiple ways: `request` can either return a result + * or reject with an error. + * + * methodName - The RPC method to which this stub will be matched. + * + * result - Instructs `request` to return a result. + * + * implementation - Allows overriding `request` entirely. Useful if + * you want it to throw an error. + * + * error - Instructs `request` to return a promise that rejects with + * this error. + */ +type FakeProviderStub = + | { + methodName: string; + result: Json; + } + | { + methodName: string; + implementation: () => void; + } + | { + methodName: string; + error: unknown; + }; + +/** + * The set of options that a new instance of FakeProvider takes. + * + * stubs - A set of objects that allow specifying the behavior + * of specific invocations of `request` matching a `methodName`. + */ +type FakeProviderOptions = { + stubs?: FakeProviderStub[]; +}; + +/** + * Constructs a provider that returns fake responses for the various + * RPC methods that the provider supports can be supplied. + * + * @param options - The options. + * @param options.stubs - A set of objects that allow specifying the behavior + * of specific invocations of `request` matching a `methodName`. + * @returns The fake provider. + */ +function getFakeProvider({ + stubs: initialStubs = [], +}: { + stubs?: FakeProviderStub[]; +} = {}): InternalProvider { + const originalStubs = initialStubs.slice(); + + const stubs = initialStubs.slice(); + if (!stubs.some((stub) => stub.methodName === 'eth_blockNumber')) { + stubs.push({ + methodName: 'eth_blockNumber', + result: '0x0', + }); + } + + if (!stubs.some((stub) => stub.methodName === 'eth_subscribe')) { + stubs.push({ + methodName: 'eth_subscribe', + result: '0x0', + }); + } + + if (!stubs.some((stub) => stub.methodName === 'eth_unsubscribe')) { + stubs.push({ + methodName: 'eth_unsubscribe', + result: true, + }); + } + + const provider = new InternalProvider({ engine: new JsonRpcEngine() }); + jest + .spyOn(provider, 'request') + .mockImplementation(async (eip1193Request): Promise => { + const index = stubs.findIndex( + (stub) => stub.methodName === eip1193Request.method, + ); + + if (index !== -1) { + const stub = stubs[index]; + stubs.splice(index, 1); + if ('implementation' in stub) { + stub.implementation(); + } else if ('result' in stub) { + return stub.result; + } else if ('error' in stub) { + throw stub.error; + } + return null; + } + + throw new Error( + `Could not find any stubs matching "${eip1193Request.method}". Perhaps they've already been called?\n\n` + + 'The original set of stubs were:\n\n' + + `${util.inspect(originalStubs, { depth: null })}\n\n` + + 'Current set of stubs:\n\n' + + `${util.inspect(stubs, { depth: null })}\n\n`, + ); + }); + return provider; +} + +/** + * Calls the given function with a built-in PollingBlockTracker, ensuring that + * all listeners that are on the block tracker are removed and any timers or + * loops that are running within the block tracker are properly stopped. + * + * @param options - Options that allow configuring the block tracker or + * provider. + * @param callback - A callback which will be called with the built block + * tracker. + * @returns The provider and block tracker. + */ +export async function withPollingBlockTracker( + options: WithPollingBlockTrackerOptions, + callback: WithPollingBlockTrackerCallback, +): Promise; +/** + * Calls the given function with a built-in PollingBlockTracker, ensuring that + * all listeners that are on the block tracker are removed and any timers or + * loops that are running within the block tracker are properly stopped. + * + * @param callback - A callback which will be called with the built block + * tracker. + * @returns The provider and block tracker. + */ +export async function withPollingBlockTracker( + callback: WithPollingBlockTrackerCallback, +): Promise; + +export async function withPollingBlockTracker( + ...args: + | [WithPollingBlockTrackerOptions, WithPollingBlockTrackerCallback] + | [WithPollingBlockTrackerCallback] +): Promise { + const [options, callback] = args.length === 2 ? args : [{}, args[0]]; + const provider = + options.provider === undefined + ? getFakeProvider() + : getFakeProvider(options.provider); + const blockTrackerOptions = + options.blockTracker === undefined + ? { provider } + : { + provider, + ...options.blockTracker, + }; + const blockTracker = new PollingBlockTracker(blockTrackerOptions); + const callbackArgs = { provider, blockTracker }; + return await callback(callbackArgs); +} diff --git a/packages/eth-block-tracker/tsconfig.build.json b/packages/eth-block-tracker/tsconfig.build.json new file mode 100644 index 00000000000..f3705629acc --- /dev/null +++ b/packages/eth-block-tracker/tsconfig.build.json @@ -0,0 +1,17 @@ +{ + "extends": "../../tsconfig.packages.build.json", + "compilerOptions": { + "baseUrl": "./", + "outDir": "./dist", + "rootDir": "./src" + }, + "references": [ + { + "path": "../eth-json-rpc-provider/tsconfig.build.json" + }, + { + "path": "../json-rpc-engine/tsconfig.build.json" + } + ], + "include": ["../../types", "./src"] +} diff --git a/packages/eth-block-tracker/tsconfig.json b/packages/eth-block-tracker/tsconfig.json new file mode 100644 index 00000000000..235e8e1ae07 --- /dev/null +++ b/packages/eth-block-tracker/tsconfig.json @@ -0,0 +1,15 @@ +{ + "extends": "../../tsconfig.packages.json", + "compilerOptions": { + "baseUrl": "./" + }, + "references": [ + { + "path": "../eth-json-rpc-provider" + }, + { + "path": "../json-rpc-engine" + } + ], + "include": ["../../types", "./src", "./tests"] +} diff --git a/packages/eth-block-tracker/typedoc.json b/packages/eth-block-tracker/typedoc.json new file mode 100644 index 00000000000..38d60c72307 --- /dev/null +++ b/packages/eth-block-tracker/typedoc.json @@ -0,0 +1,7 @@ +{ + "entryPoints": ["./src/index.ts"], + "tsconfig": "./tsconfig.build.json", + "excludePrivate": true, + "hideGenerator": true, + "out": "docs" +} diff --git a/packages/eth-json-rpc-middleware/CHANGELOG.md b/packages/eth-json-rpc-middleware/CHANGELOG.md new file mode 100644 index 00000000000..e2c9eeeebbd --- /dev/null +++ b/packages/eth-json-rpc-middleware/CHANGELOG.md @@ -0,0 +1,135 @@ +# 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] + +### Changed + +- Bump `@metamask/eth-sig-util` from `^8.2.0` to `^9.0.0` ([#9999](https://github.com/MetaMask/core/pull/9999)) + +## [24.0.1] + +### Changed + +- Bump `@metamask/superstruct` from `^3.1.0` to `^3.4.1` ([#9754](https://github.com/MetaMask/core/pull/9754)) + +### Fixed + +- Accept numeric values for quantity fields in `eth_sendTransaction` / `eth_signTransaction` params ([#9967](https://github.com/MetaMask/core/pull/9967)) + - `chainId` (top-level and in `authorizationList` entries) and `authorizationList` `nonce` / `yParity` now accept both hex strings and numbers, matching the other quantity fields (`gas`, `value`, `nonce`, etc.) and restoring pre-`24.0.0` behavior + +## [24.0.0] + +### Changed + +- **BREAKING:** Add strict validation for `eth_sendTransaction` and `eth_signTransaction` params ([#9482](https://github.com/MetaMask/core/pull/9482)) + - Reject requests whose params do not match the transaction schema (extraneous top-level keys, ill-typed fields such as non-hex `to`/`data`, malformed `accessList` / `authorizationList` entries) or exceed `MAX_TRANSACTION_PARAMS_SIZE_BYTES` when serialized + - Prevents downstream normalization / PPOM WASM from crashing on deeply-nested junk fields or padded payloads and silently bypassing security scans +- Bump `@metamask/utils` from `^11.9.0` to `^11.11.0` ([#9074](https://github.com/MetaMask/core/pull/9074)) +- Bump `@metamask/json-rpc-engine` from `^10.2.4` to `^10.5.0` ([#8661](https://github.com/MetaMask/core/pull/8661), [#8746](https://github.com/MetaMask/core/pull/8746), [#8753](https://github.com/MetaMask/core/pull/8753)) +- Bump `@metamask/message-manager` from `^14.1.1` to `^14.1.2` ([#8755](https://github.com/MetaMask/core/pull/8755)) +- Drop `pify` dependency, which was no longer used in source ([#9064](https://github.com/MetaMask/core/pull/9064)) + +## [23.1.3] + +### Fixed + +- Allow Advanced Permissions `metadata` in signTypedData V4 requests ([#8603](https://github.com/MetaMask/core/pull/8603)) + +## [23.1.2] + +### Changed + +- Add more strict validation for signTypedData V4 requests ([#8526](https://github.com/MetaMask/core/pull/8526)) + +## [23.1.1] + +### Changed + +- Bump `@metamask/eth-json-rpc-provider` from `^6.0.0` to `^6.0.1` ([#8317](https://github.com/MetaMask/core/pull/8317)) +- Bump `@metamask/message-manager` from `^14.1.0` to `^14.1.1` ([#8317](https://github.com/MetaMask/core/pull/8317)) +- Bump `@metamask/json-rpc-engine` from `^10.2.1` to `^10.2.4` ([#7856](https://github.com/MetaMask/core/pull/7856), [#8078](https://github.com/MetaMask/core/pull/8078), [#8317](https://github.com/MetaMask/core/pull/8317)) + +## [23.1.0] + +### Added + +- Add prototype pollution validation for `signTypedData` methods (V1, V3, V4) to block dangerous properties (`__proto__`, `constructor`, `prototype`, etc.) in message data. ([#7732](https://github.com/MetaMask/core/pull/7732)) + +### Changed + +- Bump `@metamask/eth-block-tracker` from `^15.0.0` to `^15.0.1` ([#7642](https://github.com/MetaMask/core/pull/7642)) +- Bump `@metamask/json-rpc-engine` from `^10.2.0` to `^10.2.1` ([#7642](https://github.com/MetaMask/core/pull/7642)) + +## [23.0.0] + +### Added + +- Support for `wallet_getSupportedExecutionPermissions` and `wallet_getGrantedExecutionPermissions` RPC methods ([#7603](https://github.com/MetaMask/core/pull/7603)) + +### Changed + +- Upgrade `@metamask/utils` from `^11.8.1` to `^11.9.0` ([#7511](https://github.com/MetaMask/core/pull/7511)) +- **BREAKING:** Changed `wallet_requestExecutionPermissions` to comply with 7715 spec revisions. + +## [22.0.1] + +### Fixed + +- Include `WalletContext` in EIP-7715 requests ([#7331](https://github.com/MetaMask/core/pull/7331)) + +## [22.0.0] + +### Added + +- Add new function `providerAsMiddlewareV2` for converting an `InternalProvider` into a `JsonRpcEngine` v2-compatible middleware ([#7138](https://github.com/MetaMask/core/pull/7138)) + +### Changed + +- **BREAKING:** Migrate all middleware from `JsonRpcEngine` to `JsonRpcEngineV2` ([#7065](https://github.com/MetaMask/core/pull/7065)) + - To continue using this package with the legacy `JsonRpcEngine`, use the `asLegacyMiddleware` backwards compatibility function. +- **BREAKING:** Change the signatures of hooks for `createWalletMiddleware` ([#7065](https://github.com/MetaMask/core/pull/7065)) + - To wit: + - `getAccounts` takes an origin argument (`string`) instead of a `JsonRpcRequest` + - `processDecryptMessage` and `processEncryptionPublicKey` take a `MessageRequest` from `@metamask/message-manager` instead of `JsonRpcRequest` + - `processPersonalMessage`, `processTransaction`, `processSignTransaction`, `processTypedMessage`, `processTypedMessageV3` and `processTypedMessageV4` take a `context` as the third argument, before any other arguments + - Be advised that request objects are now deeply frozen, and cannot be mutated. +- **BREAKING:** Use `InternalProvider` instead of `SafeEventEmitterProvider` ([#6796](https://github.com/MetaMask/core/pull/6796)) + - Wherever a `SafeEventEmitterProvider` was expected, an `InternalProvider` is now expected instead. +- **BREAKING:** Stop retrying `undefined` results for methods that include a block tag parameter ([#7001](https://github.com/MetaMask/core/pull/7001)) + - The `retryOnEmpty` middleware will now throw an error if it encounters an `undefined` result when dispatching + a request with a later block number than the originally requested block number. + - In practice, this should happen rarely if ever. +- **BREAKING:** Migrate all uses of `interface` to `type` ([#6885](https://github.com/MetaMask/core/pull/6885)) +- Bump `@metamask/message-manager` from `^14.0.0` to `^14.1.0` ([#7202](https://github.com/MetaMask/core/pull/7202)) +- Bump `@metamask/json-rpc-engine` from `^10.1.1` to `^10.2.0` ([#7202](https://github.com/MetaMask/core/pull/7202)) +- Bump `@metamask/eth-json-rpc-provider` from `^5.0.1` to `^6.0.0` ([#7202](https://github.com/MetaMask/core/pull/7202)) +- Bump `@metamask/eth-block-tracker` from `^14.0.0` to `^15.0.0` ([#7202](https://github.com/MetaMask/core/pull/7202)) + +## [21.0.0] + +### Changed + +- **BREAKING:** Increase minimum Node.js version from `^18.16` to `^18.18` ([#6866](https://github.com/MetaMask/core/pull/6866)) +- Bump `@metamask/eth-block-tracker` from `^12.2.1` to `^14.0.0` ([#6866](https://github.com/MetaMask/core/pull/6866), [#6883](https://github.com/MetaMask/core/pull/6883)) +- Bump `@metamask/network-controller` from `^24.2.2` to `^24.3.0` ([#6883](https://github.com/MetaMask/core/pull/6883)) +- This package was migrated from `MetaMask/eth-json-rpc-middleware` to the + `MetaMask/core` monorepo. + - See [`MetaMask/eth-json-rpc-middleware`](https://github.com/MetaMask/eth-json-rpc-middleware/blob/main/CHANGELOG.md) + for the original changelog. + +[Unreleased]: https://github.com/MetaMask/core/compare/@metamask/eth-json-rpc-middleware@24.0.1...HEAD +[24.0.1]: https://github.com/MetaMask/core/compare/@metamask/eth-json-rpc-middleware@24.0.0...@metamask/eth-json-rpc-middleware@24.0.1 +[24.0.0]: https://github.com/MetaMask/core/compare/@metamask/eth-json-rpc-middleware@23.1.3...@metamask/eth-json-rpc-middleware@24.0.0 +[23.1.3]: https://github.com/MetaMask/core/compare/@metamask/eth-json-rpc-middleware@23.1.2...@metamask/eth-json-rpc-middleware@23.1.3 +[23.1.2]: https://github.com/MetaMask/core/compare/@metamask/eth-json-rpc-middleware@23.1.1...@metamask/eth-json-rpc-middleware@23.1.2 +[23.1.1]: https://github.com/MetaMask/core/compare/@metamask/eth-json-rpc-middleware@23.1.0...@metamask/eth-json-rpc-middleware@23.1.1 +[23.1.0]: https://github.com/MetaMask/core/compare/@metamask/eth-json-rpc-middleware@23.0.0...@metamask/eth-json-rpc-middleware@23.1.0 +[23.0.0]: https://github.com/MetaMask/core/compare/@metamask/eth-json-rpc-middleware@22.0.1...@metamask/eth-json-rpc-middleware@23.0.0 +[22.0.1]: https://github.com/MetaMask/core/compare/@metamask/eth-json-rpc-middleware@22.0.0...@metamask/eth-json-rpc-middleware@22.0.1 +[22.0.0]: https://github.com/MetaMask/core/compare/@metamask/eth-json-rpc-middleware@21.0.0...@metamask/eth-json-rpc-middleware@22.0.0 +[21.0.0]: https://github.com/MetaMask/core/releases/tag/@metamask/eth-json-rpc-middleware@21.0.0 diff --git a/merged-packages/json-rpc-middleware-stream/LICENSE b/packages/eth-json-rpc-middleware/LICENSE similarity index 100% rename from merged-packages/json-rpc-middleware-stream/LICENSE rename to packages/eth-json-rpc-middleware/LICENSE diff --git a/packages/eth-json-rpc-middleware/README.md b/packages/eth-json-rpc-middleware/README.md new file mode 100644 index 00000000000..1b34a3db392 --- /dev/null +++ b/packages/eth-json-rpc-middleware/README.md @@ -0,0 +1,23 @@ +# `@metamask/eth-json-rpc-middleware` + +Ethereum-related middleware for [`json-rpc-engine`](https://github.com/MetaMask/json-rpc-engine). + +See tests for usage details. + +## Installation + +`yarn add @metamask/eth-json-rpc-middleware` + +or + +`npm install @metamask/eth-json-rpc-middleware` + +## See also + +- [`@metamask/eth-json-rpc-filters`](https://github.com/MetaMask/eth-json-rpc-filters). +- [`@metamask/eth-json-rpc-infura`](https://github.com/MetaMask/eth-json-rpc-infura). +- [`@metamask/json-rpc-engine`](https://github.com/MetaMask/core/tree/main/packages/json-rpc-engine). + +## 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/eth-json-rpc-middleware/jest.config.js b/packages/eth-json-rpc-middleware/jest.config.js new file mode 100644 index 00000000000..fecbe76e3b3 --- /dev/null +++ b/packages/eth-json-rpc-middleware/jest.config.js @@ -0,0 +1,29 @@ +/* + * 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 array of glob patterns indicating a set of files for which coverage information should be collected + collectCoverageFrom: ['!./src/**/*.test-d.ts'], + + // An object that configures minimum threshold enforcement for coverage results + coverageThreshold: { + global: { + branches: 84.48, + functions: 95.4, + lines: 90.48, + statements: 90.55, + }, + }, +}); diff --git a/packages/eth-json-rpc-middleware/package.json b/packages/eth-json-rpc-middleware/package.json new file mode 100644 index 00000000000..56ee41af879 --- /dev/null +++ b/packages/eth-json-rpc-middleware/package.json @@ -0,0 +1,91 @@ +{ + "name": "@metamask/eth-json-rpc-middleware", + "version": "24.0.1", + "description": "Ethereum-related json-rpc-engine middleware", + "keywords": [ + "Ethereum", + "MetaMask" + ], + "homepage": "https://github.com/MetaMask/core/tree/main/packages/eth-json-rpc-middleware#readme", + "bugs": { + "url": "https://github.com/MetaMask/core/issues" + }, + "license": "ISC", + "repository": { + "type": "git", + "url": "https://github.com/MetaMask/core.git" + }, + "files": [ + "dist/" + ], + "sideEffects": false, + "main": "./dist/index.cjs", + "module": "./dist/index.mjs", + "types": "./dist/index.d.cts", + "exports": { + ".": { + "import": { + "types": "./dist/index.d.mts", + "default": "./dist/index.mjs" + }, + "require": { + "types": "./dist/index.d.cts", + "default": "./dist/index.cjs" + } + }, + "./package.json": "./package.json" + }, + "publishConfig": { + "access": "public", + "registry": "https://registry.npmjs.org/" + }, + "scripts": { + "build": "ts-bridge --project tsconfig.build.json --verbose --clean --no-references", + "build:all": "ts-bridge --project tsconfig.build.json --verbose --clean", + "build:docs": "typedoc", + "changelog:update": "../../scripts/update-changelog.sh @metamask/eth-json-rpc-middleware --tag-prefix-before-package-rename eth-json-rpc-middleware@ --version-before-package-rename 6.1.0", + "changelog:validate": "../../scripts/validate-changelog.sh @metamask/eth-json-rpc-middleware --tag-prefix-before-package-rename eth-json-rpc-middleware@ --version-before-package-rename 6.1.0", + "lint:tsconfigs": "tsx ../../scripts/lint-tsconfigs/lint-tsconfigs.mts", + "lint:tsconfigs:fix": "tsx ../../scripts/lint-tsconfigs/lint-tsconfigs.mts --fix", + "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/eth-block-tracker": "^15.0.1", + "@metamask/eth-json-rpc-provider": "^6.0.1", + "@metamask/eth-sig-util": "^9.0.0", + "@metamask/json-rpc-engine": "^10.5.0", + "@metamask/message-manager": "^14.1.2", + "@metamask/rpc-errors": "^7.0.2", + "@metamask/superstruct": "^3.4.1", + "@metamask/utils": "^11.11.0", + "klona": "^2.0.6", + "safe-stable-stringify": "^2.4.3" + }, + "devDependencies": { + "@metamask/auto-changelog": "^6.1.0", + "@ts-bridge/cli": "^0.6.4", + "@types/deep-freeze-strict": "^1.1.0", + "@types/jest": "^30.0.0", + "deep-freeze-strict": "^1.1.1", + "deepmerge": "^4.2.2", + "jest": "^30.4.2", + "tsx": "^4.20.5", + "typedoc": "^0.25.13", + "typescript": "~5.3.3" + }, + "engines": { + "node": "^18.18 || >=20" + }, + "lavamoat": { + "allowScripts": { + "@lavamoat/preinstall-always-fail": false, + "@metamask/eth-sig-util>ethereumjs-util>ethereum-cryptography>keccak": false, + "@metamask/eth-sig-util>ethereumjs-util>ethereum-cryptography>secp256k1": false, + "eslint-plugin-import-x>unrs-resolver": false + } + } +} diff --git a/packages/eth-json-rpc-middleware/src/block-cache.test.ts b/packages/eth-json-rpc-middleware/src/block-cache.test.ts new file mode 100644 index 00000000000..b327746b4a6 --- /dev/null +++ b/packages/eth-json-rpc-middleware/src/block-cache.test.ts @@ -0,0 +1,393 @@ +import { + JsonRpcEngineV2, + MiddlewareContext, +} from '@metamask/json-rpc-engine/v2'; +import type { Hex, Json } from '@metamask/utils'; + +import { + createProviderAndBlockTracker, + createRequest, + stubProviderRequests, +} from '../test/util/helpers.js'; +import { createBlockCacheMiddleware } from './index.js'; + +describe('block cache middleware', () => { + let provider: ReturnType['provider']; + let blockTracker: ReturnType< + typeof createProviderAndBlockTracker + >['blockTracker']; + + beforeEach(() => { + const providerAndBlockTracker = createProviderAndBlockTracker(); + provider = providerAndBlockTracker.provider; + blockTracker = providerAndBlockTracker.blockTracker; + }); + + afterEach(async () => { + await blockTracker.destroy(); + }); + + it('throws error when no blockTracker is provided', () => { + expect(() => createBlockCacheMiddleware()).toThrow( + 'createBlockCacheMiddleware - No PollingBlockTracker specified', + ); + }); + + describe('request handling', () => { + it('skips caching when request has skipCache flag', async () => { + stubProviderRequests(provider, [ + { + request: { method: 'eth_blockNumber' }, + result: (): Promise<'0x1'> => Promise.resolve('0x1'), + }, + ]); + + let hitCount = 0; + const engine = JsonRpcEngineV2.create({ + middleware: [ + createBlockCacheMiddleware({ blockTracker }), + (): Hex => { + hitCount += 1; + return `0x${hitCount}`; + }, + ], + }); + + const request = createRequest({ + method: 'eth_getBalance', + params: ['0x1234'], + }); + + const context = new MiddlewareContext<{ skipCache?: boolean }>([ + ['skipCache', true], + ]); + + const result1 = await engine.handle(request, { context }); + const result2 = await engine.handle(request, { context }); + + expect(hitCount).toBe(2); + expect(result1).toBe('0x1'); + expect(result2).toBe('0x2'); + }); + + it('skips caching methods with Never strategy', async () => { + stubProviderRequests(provider, [ + { + request: { method: 'eth_blockNumber' }, + result: (): Promise => Promise.resolve('0x1'), + }, + ]); + + let hitCount = 0; + const engine = JsonRpcEngineV2.create({ + middleware: [ + createBlockCacheMiddleware({ blockTracker }), + (): Hex => { + hitCount += 1; + return `0x${hitCount}`; + }, + ], + }); + + // eth_sendTransaction is a method that is not cacheable + const request = createRequest({ + method: 'eth_sendTransaction', + }); + + const result1 = await engine.handle(request); + const result2 = await engine.handle(request); + + expect(hitCount).toBe(2); + expect(result1).toBe('0x1'); + expect(result2).toBe('0x2'); + }); + + it('skips caching requests with pending blockTag', async () => { + stubProviderRequests(provider, [ + { + request: { method: 'eth_blockNumber' }, + result: (): Promise => Promise.resolve('0x1'), + }, + ]); + + let hitCount = 0; + const engine = JsonRpcEngineV2.create({ + middleware: [ + createBlockCacheMiddleware({ blockTracker }), + (): Hex => { + hitCount += 1; + return `0x${hitCount}`; + }, + ], + }); + + const request = createRequest({ + method: 'eth_getBalance', + params: ['0x1234', 'pending'], + }); + + const result1 = await engine.handle(request); + const result2 = await engine.handle(request); + + expect(hitCount).toBe(2); + expect(result1).toBe('0x1'); + expect(result2).toBe('0x2'); + }); + + it('caches requests with cacheable method and valid blockTag', async () => { + const getLatestBlockSpy = jest.spyOn(blockTracker, 'getLatestBlock'); + stubProviderRequests(provider, [ + { + request: { method: 'eth_blockNumber' }, + result: (): Promise => Promise.resolve('0x1'), + }, + ]); + + let hitCount = 0; + const engine = JsonRpcEngineV2.create({ + middleware: [ + createBlockCacheMiddleware({ blockTracker }), + (): Hex => { + hitCount += 1; + return `0x${hitCount}`; + }, + ], + }); + + const request = createRequest({ + method: 'eth_getBalance', + params: ['0x1234', 'latest'], + }); + + const result1 = await engine.handle(request); + const result2 = await engine.handle(request); + + expect(hitCount).toBe(1); + expect(getLatestBlockSpy).toHaveBeenCalledTimes(2); + expect(result1).toBe('0x1'); + expect(result2).toBe('0x1'); + }); + + it('defaults cacheable request block tags to "latest"', async () => { + const getLatestBlockSpy = jest.spyOn(blockTracker, 'getLatestBlock'); + stubProviderRequests(provider, [ + { + request: { method: 'eth_blockNumber' }, + result: (): Promise => Promise.resolve('0x1'), + }, + ]); + + let hitCount = 0; + const engine = JsonRpcEngineV2.create({ + middleware: [ + createBlockCacheMiddleware({ blockTracker }), + (): Hex => { + hitCount += 1; + return `0x${hitCount}`; + }, + ], + }); + + const request = createRequest({ + method: 'eth_getBalance', + params: ['0x1234'], + }); + + const result1 = await engine.handle(request); + const result2 = await engine.handle(request); + + expect(hitCount).toBe(1); + expect(getLatestBlockSpy).toHaveBeenCalledTimes(2); + expect(result1).toBe('0x1'); + expect(result2).toBe('0x1'); + }); + + it('caches requests with "earliest" block tag', async () => { + const getLatestBlockSpy = jest.spyOn(blockTracker, 'getLatestBlock'); + stubProviderRequests(provider, [ + { + request: { method: 'eth_blockNumber' }, + result: (): Promise => Promise.resolve('0x1'), + }, + ]); + + let hitCount = 0; + const engine = JsonRpcEngineV2.create({ + middleware: [ + createBlockCacheMiddleware({ blockTracker }), + (): Hex => { + hitCount += 1; + return `0x${hitCount}`; + }, + ], + }); + + const request = createRequest({ + method: 'eth_getBalance', + params: ['0x1234', 'earliest'], + }); + + const result1 = await engine.handle(request); + const result2 = await engine.handle(request); + + expect(hitCount).toBe(1); + expect(getLatestBlockSpy).not.toHaveBeenCalled(); + expect(result1).toBe('0x1'); + expect(result2).toBe('0x1'); + }); + + it('caches requests with hex block tag', async () => { + const getLatestBlockSpy = jest.spyOn(blockTracker, 'getLatestBlock'); + stubProviderRequests(provider, [ + { + request: { method: 'eth_blockNumber' }, + result: (): Promise => Promise.resolve('0x2'), + }, + ]); + + let hitCount = 0; + const engine = JsonRpcEngineV2.create({ + middleware: [ + createBlockCacheMiddleware({ blockTracker }), + (): Hex => { + hitCount += 1; + return `0x${hitCount}`; + }, + ], + }); + + const request = createRequest({ + method: 'eth_getBalance', + params: ['0x1234', '0x2'], + }); + + const result1 = await engine.handle(request); + const result2 = await engine.handle(request); + + expect(hitCount).toBe(1); + expect(getLatestBlockSpy).not.toHaveBeenCalled(); + expect(result1).toBe('0x1'); + expect(result2).toBe('0x1'); + }); + }); + + describe('cache strategy edge cases', () => { + // `undefined` is also an empty value, but returning that causes the engine to throw + it.each([null, '\u003cnil\u003e'])( + 'skips caching "empty" result values: %s', + async (emptyValue) => { + stubProviderRequests(provider, [ + { + request: { method: 'eth_blockNumber' }, + result: (): Promise => Promise.resolve('0x1'), + }, + ]); + + let hitCount = 0; + const engine = JsonRpcEngineV2.create({ + middleware: [ + createBlockCacheMiddleware({ blockTracker }), + (): string | null => { + hitCount += 1; + return emptyValue; + }, + ], + }); + + const request = createRequest({ + method: 'eth_getBalance', + params: ['0x1234'], + }); + + const result1 = await engine.handle(request); + const result2 = await engine.handle(request); + + expect(hitCount).toBe(2); + expect(result1).toBe(emptyValue); + expect(result2).toBe(emptyValue); + }, + ); + + describe.each(['eth_getTransactionByHash', 'eth_getTransactionReceipt'])( + 'skips caching results for %s without blockHash', + (method) => { + it.each([ + null, + {}, + { blockHash: null }, + { + blockHash: + '0x0000000000000000000000000000000000000000000000000000000000000000', + }, + ] as Json[])('%o', async (expectedResult) => { + stubProviderRequests(provider, [ + { + request: { method: 'eth_blockNumber' }, + result: (): Promise => Promise.resolve('0x1'), + }, + ]); + + let hitCount = 0; + const engine = JsonRpcEngineV2.create({ + middleware: [ + createBlockCacheMiddleware({ blockTracker }), + (): Json => { + hitCount += 1; + return expectedResult; + }, + ], + }); + + const request = createRequest({ + method, + params: ['0x123'], + }); + + const result1 = await engine.handle(request); + const result2 = await engine.handle(request); + + expect(hitCount).toBe(2); + expect(result1).toStrictEqual(expectedResult); + expect(result2).toStrictEqual(expectedResult); + }); + }, + ); + + it('clears old block numbers from cache when handling "latest" requests', async () => { + const getLatestBlockSpy = jest + .spyOn(blockTracker, 'getLatestBlock') + .mockResolvedValueOnce('0x1') + .mockResolvedValueOnce('0x2'); + stubProviderRequests(provider, [ + { + request: { method: 'eth_blockNumber' }, + result: (): Promise => Promise.resolve('0x1'), + }, + ]); + + let hitCount = 0; + const engine = JsonRpcEngineV2.create({ + middleware: [ + createBlockCacheMiddleware({ blockTracker }), + (): Hex => { + hitCount += 1; + return `0x${hitCount}`; + }, + ], + }); + + const request = createRequest({ + method: 'eth_getBalance', + params: ['0x1234', 'latest'], + }); + + const result1 = await engine.handle(request); + const result2 = await engine.handle(request); + + expect(hitCount).toBe(2); + expect(getLatestBlockSpy).toHaveBeenCalledTimes(2); + expect(result1).toBe('0x1'); + expect(result2).toBe('0x2'); + }); + }); +}); diff --git a/packages/eth-json-rpc-middleware/src/block-cache.ts b/packages/eth-json-rpc-middleware/src/block-cache.ts new file mode 100644 index 00000000000..6efc528fb00 --- /dev/null +++ b/packages/eth-json-rpc-middleware/src/block-cache.ts @@ -0,0 +1,232 @@ +import type { PollingBlockTracker } from '@metamask/eth-block-tracker'; +import type { + JsonRpcMiddleware, + MiddlewareContext, +} from '@metamask/json-rpc-engine/v2'; +import type { Json, JsonRpcRequest } from '@metamask/utils'; + +import { projectLogger, createModuleLogger } from './logging-utils.js'; +import type { + Block, + BlockCache, + // eslint-disable-next-line @typescript-eslint/no-shadow + Cache, +} from './types.js'; +import { + cacheIdentifierForRequest, + blockTagForRequest, + cacheTypeForMethod, + canCache, + CacheStrategy, +} from './utils/cache.js'; + +const log = createModuleLogger(projectLogger, 'block-cache'); +// `` comes from https://github.com/ethereum/go-ethereum/issues/16925 +const emptyValues: unknown[] = [undefined, null, '\u003cnil\u003e']; + +type BlockCacheMiddlewareOptions = { + blockTracker?: PollingBlockTracker; +}; + +// +// Cache Strategies +// + +class BlockCacheStrategy { + #cache: Cache; + + constructor() { + this.#cache = {}; + } + + getBlockCache(blockNumberHex: string): BlockCache { + const blockNumber: number = Number.parseInt(blockNumberHex, 16); + let blockCache: BlockCache = this.#cache[blockNumber]; + // create new cache if necesary + if (!blockCache) { + const newCache: BlockCache = {}; + this.#cache[blockNumber] = newCache; + blockCache = newCache; + } + return blockCache; + } + + async get( + request: JsonRpcRequest, + requestedBlockNumber: string, + ): Promise { + // lookup block cache + const blockCache: BlockCache = this.getBlockCache(requestedBlockNumber); + // lookup payload in block cache + const identifier: string | null = cacheIdentifierForRequest(request, true); + return identifier ? blockCache[identifier] : undefined; + } + + async set( + request: JsonRpcRequest, + requestedBlockNumber: string, + result: Block, + ): Promise { + // check if we can cached this result + const canCacheResult: boolean = this.canCacheResult(request, result); + if (!canCacheResult) { + return; + } + + // set the value in the cache + const identifier: string | null = cacheIdentifierForRequest(request, true); + if (!identifier) { + return; + } + const blockCache: BlockCache = this.getBlockCache(requestedBlockNumber); + blockCache[identifier] = result; + } + + canCacheRequest(request: JsonRpcRequest): boolean { + // check request method + if (!canCache(request.method)) { + return false; + } + // check blockTag + const blockTag = blockTagForRequest(request); + + if (blockTag === 'pending') { + return false; + } + // can be cached + return true; + } + + canCacheResult(request: JsonRpcRequest, result: Block): boolean { + // never cache empty values (e.g. undefined) + if (emptyValues.includes(result)) { + return false; + } + + // check if transactions have block reference before caching + if ( + request.method && + ['eth_getTransactionByHash', 'eth_getTransactionReceipt'].includes( + request.method, + ) + ) { + if ( + !result?.blockHash || + result.blockHash === + '0x0000000000000000000000000000000000000000000000000000000000000000' + ) { + return false; + } + } + // otherwise true + return true; + } + + // removes all block caches with block number lower than `oldBlockHex` + clearBefore(oldBlockHex: string): void { + const oldBlockNumber: number = Number.parseInt(oldBlockHex, 16); + // clear old caches + Object.keys(this.#cache) + .map(Number) + .filter((value) => value < oldBlockNumber) + .forEach((value) => delete this.#cache[value]); + } +} + +/** + * Creates a middleware that caches block-related requests. + * + * @param options - The options for the middleware. + * @param options.blockTracker - The block tracker to use. + * @returns The block cache middleware. + */ +export function createBlockCacheMiddleware({ + blockTracker, +}: BlockCacheMiddlewareOptions = {}): JsonRpcMiddleware< + JsonRpcRequest, + Json, + MiddlewareContext<{ skipCache?: boolean }> +> { + if (!blockTracker) { + throw new Error( + 'createBlockCacheMiddleware - No PollingBlockTracker specified', + ); + } + + const blockCache: BlockCacheStrategy = new BlockCacheStrategy(); + const strategies: Record = { + [CacheStrategy.Permanent]: blockCache, + [CacheStrategy.Block]: blockCache, + [CacheStrategy.Fork]: blockCache, + [CacheStrategy.Never]: undefined, + }; + + return async ({ request, next, context }) => { + if (context.get('skipCache')) { + return next(); + } + + const type = cacheTypeForMethod(request.method); + const strategy = strategies[type]; + if (!strategy) { + return next(); + } + + if (!strategy.canCacheRequest(request)) { + return next(); + } + + const requestBlockTag = blockTagForRequest(request); + const blockTag = + requestBlockTag && typeof requestBlockTag === 'string' + ? requestBlockTag + : 'latest'; + + log('blockTag = %o, req = %o', blockTag, request); + + // get exact block number + let requestedBlockNumber: string; + if (blockTag === 'earliest') { + // this just exists for symmetry with "latest" + requestedBlockNumber = '0x00'; + } else if (blockTag === 'latest') { + log('Fetching latest block number to determine cache key'); + const latestBlockNumber = await blockTracker.getLatestBlock(); + + // clear all cache before latest block + log( + 'Clearing values stored under block numbers before %o', + latestBlockNumber, + ); + blockCache.clearBefore(latestBlockNumber); + requestedBlockNumber = latestBlockNumber; + } else { + // we have a hex number + requestedBlockNumber = blockTag; + } + + // end on a hit, continue on a miss + const cacheResult = await strategy.get(request, requestedBlockNumber); + if (cacheResult === undefined) { + // cache miss + // wait for other middleware to handle request + log( + 'No cache stored under block number %o, carrying request forward', + requestedBlockNumber, + ); + const result = await next(); + + // add result to cache + // it's safe to cast res.result as Block, due to runtime type checks + // performed when strategy.set is called + log('Populating cache with', result); + await strategy.set(request, requestedBlockNumber, result as Block); + return result; + } + log( + 'Cache hit, reusing cache result stored under block number %o', + requestedBlockNumber, + ); + return cacheResult; + }; +} diff --git a/packages/eth-json-rpc-middleware/src/block-ref-rewrite.test.ts b/packages/eth-json-rpc-middleware/src/block-ref-rewrite.test.ts new file mode 100644 index 00000000000..4e260f3b3ba --- /dev/null +++ b/packages/eth-json-rpc-middleware/src/block-ref-rewrite.test.ts @@ -0,0 +1,183 @@ +import type { PollingBlockTracker } from '@metamask/eth-block-tracker'; +import { JsonRpcEngineV2 } from '@metamask/json-rpc-engine/v2'; +import type { Json, JsonRpcRequest } from '@metamask/utils'; + +import { + createFinalMiddlewareWithDefaultResult, + createRequest, +} from '../test/util/helpers.js'; +import { createBlockRefRewriteMiddleware } from './block-ref-rewrite.js'; + +const createMockBlockTracker = (): PollingBlockTracker => { + return { + getLatestBlock: jest.fn(), + } as unknown as PollingBlockTracker; +}; + +describe('createBlockRefRewriteMiddleware', () => { + it('throws an error when blockTracker is not provided', () => { + expect(() => { + createBlockRefRewriteMiddleware(); + }).toThrow( + 'BlockRefRewriteMiddleware - mandatory "blockTracker" option is missing.', + ); + }); + + it('skips processing when method does not have a block reference parameter', async () => { + const mockBlockTracker = createMockBlockTracker(); + const getLatestBlockSpy = jest.spyOn(mockBlockTracker, 'getLatestBlock'); + + const engine = JsonRpcEngineV2.create({ + middleware: [ + createBlockRefRewriteMiddleware({ + blockTracker: mockBlockTracker, + }), + createFinalMiddlewareWithDefaultResult(), + ], + }); + + const originalRequest = createRequest({ + method: 'eth_chainId', + }); + + await engine.handle(originalRequest); + + // blockTracker should not be called for methods without block reference + expect(getLatestBlockSpy).not.toHaveBeenCalled(); + }); + + it('skips processing when block reference is not "latest"', async () => { + const mockBlockTracker = createMockBlockTracker(); + const getLatestBlockSpy = jest.spyOn(mockBlockTracker, 'getLatestBlock'); + + const engine = JsonRpcEngineV2.create({ + middleware: [ + createBlockRefRewriteMiddleware({ + blockTracker: mockBlockTracker, + }), + createFinalMiddlewareWithDefaultResult(), + ], + }); + + const originalRequest = createRequest({ + method: 'eth_getBalance', + params: ['0x1234567890abcdef', '0x99'], + }); + + await engine.handle(originalRequest); + + // blockTracker should not be called when block reference is not "latest" + expect(getLatestBlockSpy).not.toHaveBeenCalled(); + }); + + it('rewrites "latest" block reference to actual block number for methods with a block reference parameter', async () => { + const mockBlockTracker = createMockBlockTracker(); + jest + .spyOn(mockBlockTracker, 'getLatestBlock') + .mockResolvedValue('0xabc123'); + + // Mock a middleware that captures the request after modification + let capturedRequest: JsonRpcRequest | undefined; + const engine = JsonRpcEngineV2.create({ + middleware: [ + createBlockRefRewriteMiddleware({ + blockTracker: mockBlockTracker, + }), + async ({ + request, + next, + }): Promise | undefined> => { + capturedRequest = { ...request } as JsonRpcRequest; + return next(); + }, + createFinalMiddlewareWithDefaultResult(), + ], + }); + + const originalRequest = createRequest({ + method: 'eth_getBalance', + params: ['0x1234567890abcdef', 'latest'], + }); + + await engine.handle(originalRequest); + + expect(mockBlockTracker.getLatestBlock).toHaveBeenCalledTimes(1); + expect(capturedRequest?.params).toStrictEqual([ + '0x1234567890abcdef', + '0xabc123', + ]); + }); + + it('treats omitted block reference as "latest" and rewrites it', async () => { + const mockBlockTracker = createMockBlockTracker(); + jest + .spyOn(mockBlockTracker, 'getLatestBlock') + .mockResolvedValue('0x111222'); + + let capturedRequest: JsonRpcRequest | undefined; + const engine = JsonRpcEngineV2.create({ + middleware: [ + createBlockRefRewriteMiddleware({ + blockTracker: mockBlockTracker, + }), + async ({ + request, + next, + }): Promise | undefined> => { + capturedRequest = { ...request } as JsonRpcRequest; + return next(); + }, + createFinalMiddlewareWithDefaultResult(), + ], + }); + + const originalRequest = createRequest({ + method: 'eth_getBalance', + params: ['0x1234567890abcdef'], // No block reference provided (should default to "latest") + }); + + await engine.handle(originalRequest); + + expect(mockBlockTracker.getLatestBlock).toHaveBeenCalled(); + expect(capturedRequest?.params).toStrictEqual([ + '0x1234567890abcdef', + '0x111222', + ]); + }); + + it('handles non-array params gracefully', async () => { + const mockBlockTracker = createMockBlockTracker(); + jest + .spyOn(mockBlockTracker, 'getLatestBlock') + .mockResolvedValue('0xffffff'); + + let capturedRequest: JsonRpcRequest | undefined; + const engine = JsonRpcEngineV2.create({ + middleware: [ + createBlockRefRewriteMiddleware({ + blockTracker: mockBlockTracker, + }), + async ({ + request, + next, + }): Promise | undefined> => { + capturedRequest = { ...request } as JsonRpcRequest; + return next(); + }, + createFinalMiddlewareWithDefaultResult(), + ], + }); + + const originalRequest = createRequest({ + method: 'eth_getBalance', + // @ts-expect-error - Destructive testing + params: null, // Non-array params + }); + + await engine.handle(originalRequest); + + // getLatestBlock is still called but the request is unmodified + expect(mockBlockTracker.getLatestBlock).toHaveBeenCalledTimes(1); + expect(capturedRequest?.params).toBeNull(); + }); +}); diff --git a/packages/eth-json-rpc-middleware/src/block-ref-rewrite.ts b/packages/eth-json-rpc-middleware/src/block-ref-rewrite.ts new file mode 100644 index 00000000000..7ad43ef900d --- /dev/null +++ b/packages/eth-json-rpc-middleware/src/block-ref-rewrite.ts @@ -0,0 +1,61 @@ +import type { PollingBlockTracker } from '@metamask/eth-block-tracker'; +import type { JsonRpcMiddleware } from '@metamask/json-rpc-engine/v2'; +import type { Json, JsonRpcRequest } from '@metamask/utils'; + +import { blockTagParamIndex } from './utils/cache.js'; + +type BlockRefRewriteMiddlewareOptions = { + blockTracker?: PollingBlockTracker; +}; + +/** + * Creates a middleware that rewrites "latest" block references to the known + * latest block number from a block tracker. + * + * @param options - The options for the middleware. + * @param options.blockTracker - The block tracker to use. + * @returns The middleware. + */ +export function createBlockRefRewriteMiddleware({ + blockTracker, +}: BlockRefRewriteMiddlewareOptions = {}): JsonRpcMiddleware< + JsonRpcRequest, + Json +> { + if (!blockTracker) { + throw Error( + 'BlockRefRewriteMiddleware - mandatory "blockTracker" option is missing.', + ); + } + + return async ({ request, next }) => { + const blockRefIndex: number | undefined = blockTagParamIndex( + request.method, + ); + if (blockRefIndex === undefined) { + return next(); + } + + const blockRef: string | undefined = + Array.isArray(request.params) && request.params[blockRefIndex] + ? (request.params[blockRefIndex] as string) + : // omitted blockRef implies "latest" + 'latest'; + + if (blockRef !== 'latest') { + return next(); + } + + // rewrite blockRef to block-tracker's block number + const latestBlockNumber = await blockTracker.getLatestBlock(); + if (Array.isArray(request.params)) { + const params = request.params.slice(); + params[blockRefIndex] = latestBlockNumber; + return next({ + ...request, + params, + }); + } + return next(); + }; +} diff --git a/packages/eth-json-rpc-middleware/src/block-ref.test.ts b/packages/eth-json-rpc-middleware/src/block-ref.test.ts new file mode 100644 index 00000000000..d7451f99c52 --- /dev/null +++ b/packages/eth-json-rpc-middleware/src/block-ref.test.ts @@ -0,0 +1,330 @@ +import { MiddlewareContext } from '@metamask/json-rpc-engine/v2'; + +import { + createMockParamsWithBlockParamAt, + stubProviderRequests, + createStubForBlockNumberRequest, + createStubForGenericRequest, + createFinalMiddlewareWithDefaultResult, + createMockParamsWithoutBlockParamAt, + expectProviderRequestNotToHaveBeenMade, + createProviderAndBlockTracker, + createEngine, + createRequest, +} from '../test/util/helpers.js'; +import { createBlockRefMiddleware } from './index.js'; + +describe('createBlockRefMiddleware', () => { + let provider: ReturnType['provider']; + let blockTracker: ReturnType< + typeof createProviderAndBlockTracker + >['blockTracker']; + + beforeEach(() => { + const providerAndBlockTracker = createProviderAndBlockTracker(); + provider = providerAndBlockTracker.provider; + blockTracker = providerAndBlockTracker.blockTracker; + }); + + afterEach(async () => { + await blockTracker.destroy(); + }); + + // This list corresponds to the list in the `blockTagParamIndex` function + // within `src/utils/cache.ts` + ( + [ + { blockParamIndex: 0, methods: ['eth_getBlockByNumber'] }, + { + blockParamIndex: 1, + methods: [ + 'eth_getBalance', + 'eth_getCode', + 'eth_getTransactionCount', + 'eth_call', + ], + }, + { blockParamIndex: 2, methods: ['eth_getStorageAt'] }, + ] as const + ).forEach(({ blockParamIndex, methods }) => { + methods.forEach((method: string) => { + describe(`when the RPC method is ${method}`, () => { + describe('if the block param is "latest"', () => { + it('makes a direct request through the provider, replacing the block param with the latest block number', async () => { + const engine = createEngine( + createBlockRefMiddleware({ + provider, + blockTracker, + }), + ); + + const request = createRequest({ + method, + params: createMockParamsWithBlockParamAt( + blockParamIndex, + 'latest', + ), + }); + + stubProviderRequests(provider, [ + createStubForBlockNumberRequest('0x100'), + createStubForGenericRequest({ + request: { + ...request, + params: createMockParamsWithBlockParamAt( + blockParamIndex, + '0x100', + ), + }, + result: async () => 'something', + }), + ]); + + const result = await engine.handle(request); + + expect(result).toBe('something'); + }); + + it('does not proceed to the next middleware after making a request through the provider', async () => { + const finalMiddleware = createFinalMiddlewareWithDefaultResult(); + + const engine = createEngine( + createBlockRefMiddleware({ + provider, + blockTracker, + }), + finalMiddleware, + ); + + const request = createRequest({ + method, + params: createMockParamsWithBlockParamAt( + blockParamIndex, + 'latest', + ), + }); + + stubProviderRequests(provider, [ + createStubForBlockNumberRequest('0x100'), + createStubForGenericRequest({ + request: { + ...request, + params: createMockParamsWithBlockParamAt( + blockParamIndex, + '0x100', + ), + }, + result: async () => 'something', + }), + ]); + + await engine.handle(request); + + expect(finalMiddleware).not.toHaveBeenCalled(); + }); + }); + + describe('if no block param is provided', () => { + it('makes a direct request through the provider, replacing the block param with the latest block number', async () => { + const engine = createEngine( + createBlockRefMiddleware({ + provider, + blockTracker, + }), + ); + + const request = createRequest({ + method, + params: createMockParamsWithoutBlockParamAt(blockParamIndex), + }); + + stubProviderRequests(provider, [ + createStubForBlockNumberRequest('0x100'), + createStubForGenericRequest({ + request: { + ...request, + params: createMockParamsWithBlockParamAt( + blockParamIndex, + '0x100', + ), + }, + result: async () => 'something', + }), + ]); + + const result = await engine.handle(request); + + expect(result).toBe('something'); + }); + + it('does not proceed to the next middleware after making a request through the provider', async () => { + const finalMiddleware = createFinalMiddlewareWithDefaultResult(); + + const engine = createEngine( + createBlockRefMiddleware({ + provider, + blockTracker, + }), + finalMiddleware, + ); + + const request = createRequest({ + method, + params: createMockParamsWithoutBlockParamAt(blockParamIndex), + }); + + stubProviderRequests(provider, [ + createStubForBlockNumberRequest('0x100'), + createStubForGenericRequest({ + request: { + ...request, + params: createMockParamsWithBlockParamAt( + blockParamIndex, + '0x100', + ), + }, + result: async () => 'something', + }), + ]); + + await engine.handle(request); + + expect(finalMiddleware).not.toHaveBeenCalled(); + }); + }); + + describe.each(['earliest', 'pending', '0x200'])( + 'if the block param is something other than "latest", like %o', + (blockParam) => { + // Using custom expect helper + // eslint-disable-next-line jest/expect-expect + it('does not make a direct request through the provider', async () => { + const finalMiddleware = createFinalMiddlewareWithDefaultResult(); + + const engine = createEngine( + createBlockRefMiddleware({ + provider, + blockTracker, + }), + finalMiddleware, + ); + + const request = createRequest({ + method, + params: createMockParamsWithBlockParamAt( + blockParamIndex, + blockParam, + ), + }); + + const requestSpy = stubProviderRequests(provider, [ + createStubForBlockNumberRequest('0x100'), + ]); + + await engine.handle(request); + + expectProviderRequestNotToHaveBeenMade(requestSpy, request); + }); + + it('proceeds to the next middleware', async () => { + const finalMiddleware = createFinalMiddlewareWithDefaultResult(); + + const engine = createEngine( + createBlockRefMiddleware({ + provider, + blockTracker, + }), + finalMiddleware, + ); + + stubProviderRequests(provider, [ + createStubForBlockNumberRequest('0x100'), + ]); + + await engine.handle( + createRequest({ + method, + params: createMockParamsWithBlockParamAt( + blockParamIndex, + blockParam, + ), + }), + ); + + expect(finalMiddleware).toHaveBeenCalledWith({ + request: expect.objectContaining({ + params: createMockParamsWithBlockParamAt( + blockParamIndex, + blockParam, + ), + }), + context: expect.any(MiddlewareContext), + next: expect.any(Function), + }); + }); + }, + ); + }); + }); + }); + + describe('when the RPC method does not take a block parameter', () => { + // Using custom expect helper + // eslint-disable-next-line jest/expect-expect + it('does not make a direct request through the provider', async () => { + const finalMiddleware = createFinalMiddlewareWithDefaultResult(); + + const engine = createEngine( + createBlockRefMiddleware({ + provider, + blockTracker, + }), + finalMiddleware, + ); + + const request = createRequest({ + method: 'a_non_block_param_method', + params: ['some value', '0x200'], + }); + + const requestSpy = stubProviderRequests(provider, [ + createStubForBlockNumberRequest('0x100'), + ]); + + await engine.handle(request); + + expectProviderRequestNotToHaveBeenMade(requestSpy, request); + }); + + it('proceeds to the next middleware', async () => { + const finalMiddleware = createFinalMiddlewareWithDefaultResult(); + + const engine = createEngine( + createBlockRefMiddleware({ + provider, + blockTracker, + }), + finalMiddleware, + ); + + stubProviderRequests(provider, [ + createStubForBlockNumberRequest('0x100'), + ]); + + await engine.handle( + createRequest({ + method: 'a_non_block_param_method', + params: ['some value', '0x200'], + }), + ); + + expect(finalMiddleware).toHaveBeenCalledWith({ + request: expect.objectContaining({ + params: ['some value', '0x200'], + }), + context: expect.any(MiddlewareContext), + next: expect.any(Function), + }); + }); + }); +}); diff --git a/packages/eth-json-rpc-middleware/src/block-ref.ts b/packages/eth-json-rpc-middleware/src/block-ref.ts new file mode 100644 index 00000000000..8462b0eb97e --- /dev/null +++ b/packages/eth-json-rpc-middleware/src/block-ref.ts @@ -0,0 +1,77 @@ +import type { PollingBlockTracker } from '@metamask/eth-block-tracker'; +import type { InternalProvider } from '@metamask/eth-json-rpc-provider'; +import type { JsonRpcMiddleware } from '@metamask/json-rpc-engine/v2'; +import type { Json, JsonRpcParams, JsonRpcRequest } from '@metamask/utils'; +import { klona } from 'klona'; + +import { projectLogger, createModuleLogger } from './logging-utils.js'; +import type { Block } from './types.js'; +import { blockTagParamIndex } from './utils/cache.js'; + +type BlockRefMiddlewareOptions = { + blockTracker?: PollingBlockTracker; + provider?: InternalProvider; +}; + +const log = createModuleLogger(projectLogger, 'block-ref'); + +/** + * Creates a middleware that rewrites "latest" block references to the known + * latest block number from a block tracker. + * + * @param options - The options for the middleware. + * @param options.provider - The provider to use. + * @param options.blockTracker - The block tracker to use. + * @returns The middleware. + */ +export function createBlockRefMiddleware({ + provider, + blockTracker, +}: BlockRefMiddlewareOptions = {}): JsonRpcMiddleware { + if (!provider) { + throw Error('BlockRefMiddleware - mandatory "provider" option is missing.'); + } + + if (!blockTracker) { + throw Error( + 'BlockRefMiddleware - mandatory "blockTracker" option is missing.', + ); + } + + return async ({ request, next }) => { + const blockRefIndex = blockTagParamIndex(request.method); + + // skip if method does not include blockRef + if (blockRefIndex === undefined) { + return next(); + } + + const blockRef = Array.isArray(request.params) + ? (request.params[blockRefIndex] ?? 'latest') + : 'latest'; + + // skip if not "latest" + if (blockRef !== 'latest') { + log('blockRef is not "latest", carrying request forward'); + return next(); + } + + // lookup latest block + const latestBlockNumber = await blockTracker.getLatestBlock(); + log( + `blockRef is "latest", setting param ${blockRefIndex} to latest block ${latestBlockNumber}`, + ); + + // create child request with specific block-ref + const childRequest = klona(request); + + if (Array.isArray(childRequest.params)) { + childRequest.params[blockRefIndex] = latestBlockNumber; + } + + // perform child request + log('Performing another request %o', childRequest); + // copy child result onto original response + return await provider.request(childRequest); + }; +} diff --git a/packages/eth-json-rpc-middleware/src/block-tracker-inspector.test.ts b/packages/eth-json-rpc-middleware/src/block-tracker-inspector.test.ts new file mode 100644 index 00000000000..8532922098e --- /dev/null +++ b/packages/eth-json-rpc-middleware/src/block-tracker-inspector.test.ts @@ -0,0 +1,380 @@ +import type { PollingBlockTracker } from '@metamask/eth-block-tracker'; +import { JsonRpcEngineV2 } from '@metamask/json-rpc-engine/v2'; +import { rpcErrors } from '@metamask/rpc-errors'; +import { Hex, Json } from '@metamask/utils'; + +import { + createFinalMiddlewareWithDefaultResult, + createRequest, +} from '../test/util/helpers.js'; +import { createBlockTrackerInspectorMiddleware } from './block-tracker-inspector.js'; + +const createMockBlockTracker = (): PollingBlockTracker => { + return { + getCurrentBlock: jest.fn().mockReturnValue('0x123'), + checkForLatestBlock: jest.fn().mockResolvedValue(undefined), + } as unknown as PollingBlockTracker; +}; + +describe('createBlockTrackerInspectorMiddleware', () => { + describe('method filtering', () => { + it('processes eth_getTransactionByHash requests', async () => { + const mockBlockTracker = createMockBlockTracker(); + const getCurrentBlockSpy = jest.spyOn( + mockBlockTracker, + 'getCurrentBlock', + ); + const checkForLatestBlockSpy = jest.spyOn( + mockBlockTracker, + 'checkForLatestBlock', + ); + + const engine = JsonRpcEngineV2.create({ + middleware: [ + createBlockTrackerInspectorMiddleware({ + blockTracker: mockBlockTracker, + }), + (): { blockNumber: Hex; hash: Hex } => ({ + blockNumber: '0x123', // Same as current block + hash: '0xabc', + }), + ], + }); + + const request = createRequest({ + method: 'eth_getTransactionByHash', + params: ['0xhash'], + }); + + await engine.handle(request); + + expect(getCurrentBlockSpy).toHaveBeenCalledTimes(1); + expect(checkForLatestBlockSpy).not.toHaveBeenCalled(); + }); + + it('processes eth_getTransactionReceipt requests', async () => { + const mockBlockTracker = createMockBlockTracker(); + const getCurrentBlockSpy = jest.spyOn( + mockBlockTracker, + 'getCurrentBlock', + ); + const checkForLatestBlockSpy = jest.spyOn( + mockBlockTracker, + 'checkForLatestBlock', + ); + + const engine = JsonRpcEngineV2.create({ + middleware: [ + createBlockTrackerInspectorMiddleware({ + blockTracker: mockBlockTracker, + }), + (): { blockNumber: Hex; transactionHash: Hex } => ({ + blockNumber: '0x123', // Same as current block + transactionHash: '0xdef', + }), + ], + }); + + const request = createRequest({ + method: 'eth_getTransactionReceipt', + params: ['0xhash'], + }); + + await engine.handle(request); + + expect(getCurrentBlockSpy).toHaveBeenCalledTimes(1); + expect(checkForLatestBlockSpy).not.toHaveBeenCalled(); + }); + + it('skips processing for non-inspected methods', async () => { + const mockBlockTracker = createMockBlockTracker(); + const getCurrentBlockSpy = jest.spyOn( + mockBlockTracker, + 'getCurrentBlock', + ); + const checkForLatestBlockSpy = jest.spyOn( + mockBlockTracker, + 'checkForLatestBlock', + ); + + const engine = JsonRpcEngineV2.create({ + middleware: [ + createBlockTrackerInspectorMiddleware({ + blockTracker: mockBlockTracker, + }), + createFinalMiddlewareWithDefaultResult(), + ], + }); + + const request = createRequest({ + method: 'eth_chainId', // Not in futureBlockRefRequests + }); + + await engine.handle(request); + + expect(getCurrentBlockSpy).not.toHaveBeenCalled(); + expect(checkForLatestBlockSpy).not.toHaveBeenCalled(); + }); + }); + + describe('block tracker update logic', () => { + it('calls checkForLatestBlock when response block number is higher than current', async () => { + const mockBlockTracker = createMockBlockTracker(); + jest.spyOn(mockBlockTracker, 'getCurrentBlock').mockReturnValue('0x100'); + const checkForLatestBlockSpy = jest.spyOn( + mockBlockTracker, + 'checkForLatestBlock', + ); + + const engine = JsonRpcEngineV2.create({ + middleware: [ + createBlockTrackerInspectorMiddleware({ + blockTracker: mockBlockTracker, + }), + (): { blockNumber: Hex; hash: Hex } => ({ + blockNumber: '0x200', // Higher than current block (0x100) + hash: '0xabc', + }), + ], + }); + + const request = createRequest({ + method: 'eth_getTransactionByHash', + params: ['0xhash'], + }); + + await engine.handle(request); + + expect(checkForLatestBlockSpy).toHaveBeenCalledTimes(1); + }); + + it('does not call checkForLatestBlock when response block number equals current', async () => { + const mockBlockTracker = createMockBlockTracker(); + jest.spyOn(mockBlockTracker, 'getCurrentBlock').mockReturnValue('0x100'); + const checkForLatestBlockSpy = jest.spyOn( + mockBlockTracker, + 'checkForLatestBlock', + ); + + const engine = JsonRpcEngineV2.create({ + middleware: [ + createBlockTrackerInspectorMiddleware({ + blockTracker: mockBlockTracker, + }), + (): { blockNumber: Hex; hash: Hex } => ({ + blockNumber: '0x100', // Equals current block (0x100) + hash: '0xabc', + }), + ], + }); + + const request = createRequest({ + method: 'eth_getTransactionByHash', + params: ['0xhash'], + }); + + await engine.handle(request); + + expect(checkForLatestBlockSpy).not.toHaveBeenCalled(); + }); + + it('does not call checkForLatestBlock when response block number is lower than current', async () => { + const mockBlockTracker = createMockBlockTracker(); + jest.spyOn(mockBlockTracker, 'getCurrentBlock').mockReturnValue('0x200'); + const checkForLatestBlockSpy = jest.spyOn( + mockBlockTracker, + 'checkForLatestBlock', + ); + + const engine = JsonRpcEngineV2.create({ + middleware: [ + createBlockTrackerInspectorMiddleware({ + blockTracker: mockBlockTracker, + }), + (): { blockNumber: Hex; hash: Hex } => ({ + blockNumber: '0x100', // Lower than current block (0x200) + hash: '0xabc', + }), + ], + }); + + const request = createRequest({ + method: 'eth_getTransactionByHash', + params: ['0xhash'], + }); + + await engine.handle(request); + + expect(checkForLatestBlockSpy).not.toHaveBeenCalled(); + }); + + it('handles null current block gracefully', async () => { + const mockBlockTracker = createMockBlockTracker(); + const getCurrentBlockSpy = jest + .spyOn(mockBlockTracker, 'getCurrentBlock') + .mockReturnValue(null); + const checkForLatestBlockSpy = jest.spyOn( + mockBlockTracker, + 'checkForLatestBlock', + ); + + const engine = JsonRpcEngineV2.create({ + middleware: [ + createBlockTrackerInspectorMiddleware({ + blockTracker: mockBlockTracker, + }), + (): { blockNumber: Hex; hash: Hex } => ({ + blockNumber: '0x100', + hash: '0xabc', + }), + ], + }); + + const request = createRequest({ + method: 'eth_getTransactionByHash', + params: ['0xhash'], + }); + + await engine.handle(request); + + expect(getCurrentBlockSpy).toHaveBeenCalledTimes(1); + expect(checkForLatestBlockSpy).not.toHaveBeenCalled(); + }); + }); + + describe('edge cases', () => { + it('skips processing for error responses', async () => { + const mockBlockTracker = createMockBlockTracker(); + const getCurrentBlockSpy = jest.spyOn( + mockBlockTracker, + 'getCurrentBlock', + ); + const checkForLatestBlockSpy = jest.spyOn( + mockBlockTracker, + 'checkForLatestBlock', + ); + const engine = JsonRpcEngineV2.create({ + middleware: [ + createBlockTrackerInspectorMiddleware({ + blockTracker: mockBlockTracker, + }), + (): never => { + throw rpcErrors.internal('Internal error'); + }, + ], + }); + + const request = createRequest({ + method: 'eth_getTransactionByHash', + params: ['0xhash'], + }); + + await expect(engine.handle(request)).rejects.toThrow( + rpcErrors.internal('Internal error'), + ); + + expect(getCurrentBlockSpy).not.toHaveBeenCalled(); + expect(checkForLatestBlockSpy).not.toHaveBeenCalled(); + }); + + it.each([ + { result: null, description: 'falsy' }, + { result: 'foo', description: 'string' }, + { result: {}, description: 'object with no blockNumber property' }, + ])( + 'skips processing for result values: $description', + async ({ result }) => { + const mockBlockTracker = createMockBlockTracker(); + const getCurrentBlockSpy = jest + .spyOn(mockBlockTracker, 'getCurrentBlock') + .mockReturnValue('0x100'); + const checkForLatestBlockSpy = jest.spyOn( + mockBlockTracker, + 'checkForLatestBlock', + ); + const engine = JsonRpcEngineV2.create({ + middleware: [ + createBlockTrackerInspectorMiddleware({ + blockTracker: mockBlockTracker, + }), + (): Json => result, + ], + }); + + const request = createRequest({ + method: 'eth_getTransactionByHash', + params: ['0xhash'], + }); + + await engine.handle(request); + + expect(getCurrentBlockSpy).not.toHaveBeenCalled(); + expect(checkForLatestBlockSpy).not.toHaveBeenCalled(); + }, + ); + + it('skips processing for non-string block numbers', async () => { + const mockBlockTracker = createMockBlockTracker(); + const getCurrentBlockSpy = jest + .spyOn(mockBlockTracker, 'getCurrentBlock') + .mockReturnValue('0x100'); + const checkForLatestBlockSpy = jest.spyOn( + mockBlockTracker, + 'checkForLatestBlock', + ); + + const engine = JsonRpcEngineV2.create({ + middleware: [ + createBlockTrackerInspectorMiddleware({ + blockTracker: mockBlockTracker, + }), + (): { blockNumber: number; hash: Hex } => ({ + blockNumber: 123, + hash: '0xabc', + }), + ], + }); + + const request = createRequest({ + method: 'eth_getTransactionByHash', + params: ['0xhash'], + }); + + await engine.handle(request); + + expect(getCurrentBlockSpy).not.toHaveBeenCalled(); + expect(checkForLatestBlockSpy).not.toHaveBeenCalled(); + }); + + it('handles malformed hex block numbers gracefully', async () => { + const mockBlockTracker = createMockBlockTracker(); + jest.spyOn(mockBlockTracker, 'getCurrentBlock').mockReturnValue('0x100'); + const checkForLatestBlockSpy = jest.spyOn( + mockBlockTracker, + 'checkForLatestBlock', + ); + + const engine = JsonRpcEngineV2.create({ + middleware: [ + createBlockTrackerInspectorMiddleware({ + blockTracker: mockBlockTracker, + }), + (): { blockNumber: string; hash: Hex } => ({ + blockNumber: 'not-a-hex-number', + hash: '0xabc', + }), + ], + }); + + const request = createRequest({ + method: 'eth_getTransactionByHash', + params: ['0xhash'], + }); + + await engine.handle(request); + + // parseInt('not-a-hex-number', 16) returns NaN, and NaN > 256 is false + expect(checkForLatestBlockSpy).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/packages/eth-json-rpc-middleware/src/block-tracker-inspector.ts b/packages/eth-json-rpc-middleware/src/block-tracker-inspector.ts new file mode 100644 index 00000000000..ba31dba114e --- /dev/null +++ b/packages/eth-json-rpc-middleware/src/block-tracker-inspector.ts @@ -0,0 +1,77 @@ +import type { PollingBlockTracker } from '@metamask/eth-block-tracker'; +import type { JsonRpcMiddleware } from '@metamask/json-rpc-engine/v2'; +import { hasProperty } from '@metamask/utils'; +import type { Json, JsonRpcRequest } from '@metamask/utils'; + +import { projectLogger, createModuleLogger } from './logging-utils.js'; + +const log = createModuleLogger(projectLogger, 'block-tracker-inspector'); +const futureBlockRefRequests: readonly string[] = [ + 'eth_getTransactionByHash', + 'eth_getTransactionReceipt', +]; + +/** + * Creates a middleware that checks whether response block references are higher than the current block. + * If the block reference is higher, the middleware will make the block tracker check for a new block. + * + * @param options - The options for the middleware. + * @param options.blockTracker - The block tracker to use. + * @returns The middleware. + */ +export function createBlockTrackerInspectorMiddleware({ + blockTracker, +}: { + blockTracker: PollingBlockTracker; +}): JsonRpcMiddleware { + return async ({ request, next }) => { + if (!futureBlockRefRequests.includes(request.method)) { + return next(); + } + const result = await next(); + + const responseBlockNumber = getResultBlockNumber(result); + if (responseBlockNumber) { + log('res.result.blockNumber exists, proceeding. res = %o', result); + + // If number is higher, suggest block-tracker check for a new block + const blockNumber: number = Number.parseInt(responseBlockNumber, 16); + const currentBlockNumber: number = Number.parseInt( + // Typecast: If getCurrentBlock returns null, currentBlockNumber will be NaN, which is fine. + blockTracker.getCurrentBlock() as string, + 16, + ); + + if (blockNumber > currentBlockNumber) { + log( + 'blockNumber from response is greater than current block number, refreshing current block number', + ); + await blockTracker.checkForLatestBlock(); + } + } + return result; + }; +} + +/** + * Extracts the block number from the result. + * + * @param result - The result to extract the block number from. + * @returns The block number, or undefined if the result is not an object with a + * `blockNumber` property. + */ +function getResultBlockNumber( + result: Readonly | undefined, +): string | undefined { + if ( + !result || + typeof result !== 'object' || + !hasProperty(result, 'blockNumber') + ) { + return undefined; + } + + return typeof result.blockNumber === 'string' + ? result.blockNumber + : undefined; +} diff --git a/packages/eth-json-rpc-middleware/src/fetch.test.ts b/packages/eth-json-rpc-middleware/src/fetch.test.ts new file mode 100644 index 00000000000..1a124ff495b --- /dev/null +++ b/packages/eth-json-rpc-middleware/src/fetch.test.ts @@ -0,0 +1,207 @@ +import { + JsonRpcEngineV2, + MiddlewareContext, +} from '@metamask/json-rpc-engine/v2'; +import type { + Json, + JsonRpcParams, + JsonRpcRequest, + JsonRpcResponse, +} from '@metamask/utils'; + +import { createRequest } from '../test/util/helpers.js'; +import { createFetchMiddleware } from './fetch.js'; +import type { AbstractRpcServiceLike } from './types.js'; + +describe('createFetchMiddleware', () => { + it.each([ + [undefined, undefined], + [undefined, 'somedapp.com'], + ['X-Dapp-Origin', undefined], + ['X-Dapp-Origin', 'somedapp.com'], + ])( + 'calls the RPC service with the correct request headers and body with originHttpHeaderKey="%s" and origin="%s"', + async (originHttpHeaderKey, origin) => { + const rpcService = createRpcService(); + const requestSpy = jest.spyOn(rpcService, 'request'); + + const engine = JsonRpcEngineV2.create({ + middleware: [ + createFetchMiddleware({ + rpcService, + options: { + originHttpHeaderKey, + }, + }), + ], + }); + + const context = new MiddlewareContext<{ origin: string }>( + origin ? { origin } : [], + ); + const expectedHeaders = + originHttpHeaderKey && origin ? { [originHttpHeaderKey]: origin } : {}; + + await engine.handle( + createRequest({ + id: 1, + jsonrpc: '2.0', + method: 'eth_chainId', + params: [], + }), + { context }, + ); + + expect(requestSpy).toHaveBeenCalledWith( + { + id: 1, + jsonrpc: '2.0', + method: 'eth_chainId', + params: [], + }, + { + headers: expectedHeaders, + }, + ); + }, + ); + + describe('if the response from the service does not contain an `error` field', () => { + it('returns a successful JSON-RPC response containing the value of the `result` field', async () => { + const rpcService = createRpcService(); + jest.spyOn(rpcService, 'request').mockResolvedValue({ + id: 1, + jsonrpc: '2.0', + result: 'the result', + }); + + const engine = JsonRpcEngineV2.create({ + middleware: [ + createFetchMiddleware({ + rpcService, + }), + ], + }); + const result = await engine.handle( + createRequest({ + method: 'eth_chainId', + params: [], + }), + ); + + expect(result).toBe('the result'); + }); + }); + + describe('if the response from the service contains an `error` field with a standard JSON-RPC error object', () => { + it('returns an unsuccessful JSON-RPC response containing the error, wrapped in an "internal" error', async () => { + const rpcService = createRpcService(); + jest.spyOn(rpcService, 'request').mockResolvedValue({ + id: 1, + jsonrpc: '2.0', + error: { + code: -1000, + message: 'oops', + }, + }); + const engine = JsonRpcEngineV2.create({ + middleware: [ + createFetchMiddleware({ + rpcService, + }), + ], + }); + + await expect( + engine.handle( + createRequest({ + method: 'eth_chainId', + params: [], + }), + ), + ).rejects.toThrow('Internal JSON-RPC error.'); + }); + }); + + describe('if the response from the service contains an `error` field with a non-standard JSON-RPC error object', () => { + it('returns an unsuccessful JSON-RPC response containing the error, wrapped in an "internal" error', async () => { + const rpcService = createRpcService(); + jest.spyOn(rpcService, 'request').mockResolvedValue({ + id: 1, + jsonrpc: '2.0', + error: { + code: -32000, + data: { + foo: 'bar', + }, + message: 'VM Exception while processing transaction: revert', + // @ts-expect-error The `name` property is not strictly part of the + // JSON-RPC error object. + name: 'RuntimeError', + stack: + 'RuntimeError: VM Exception while processing transaction: revert at exactimate (/Users/elliot/code/metamask/metamask-mobile/node_modules/ganache/dist/node/webpack:/Ganache/ethereum/ethereum/lib/src/helpers/gas-estimator.js:257:23)', + }, + }); + const engine = JsonRpcEngineV2.create({ + middleware: [ + createFetchMiddleware({ + rpcService, + }), + ], + }); + + await expect( + engine.handle( + createRequest({ + method: 'eth_chainId', + params: [], + }), + ), + ).rejects.toThrow('Internal JSON-RPC error.'); + }); + }); + + describe('if the request throws', () => { + it('returns an unsuccessful JSON-RPC response containing the error', async () => { + const rpcService = createRpcService(); + jest.spyOn(rpcService, 'request').mockRejectedValue(new Error('oops')); + + const engine = JsonRpcEngineV2.create({ + middleware: [ + createFetchMiddleware({ + rpcService, + }), + ], + }); + + await expect( + engine.handle( + createRequest({ + method: 'eth_chainId', + params: [], + }), + ), + ).rejects.toThrow('oops'); + }); + }); +}); + +/** + * Constructs a fake RPC service for use as a failover in tests. + * + * @returns The fake failover service. + */ +function createRpcService(): AbstractRpcServiceLike { + return { + async request( + jsonRpcRequest: JsonRpcRequest, + _fetchOptions?: RequestInit, + ): Promise> { + return { + id: jsonRpcRequest.id, + jsonrpc: jsonRpcRequest.jsonrpc, + result: 'ok' as Result, + }; + }, + }; +} diff --git a/packages/eth-json-rpc-middleware/src/fetch.ts b/packages/eth-json-rpc-middleware/src/fetch.ts new file mode 100644 index 00000000000..61b8935c476 --- /dev/null +++ b/packages/eth-json-rpc-middleware/src/fetch.ts @@ -0,0 +1,57 @@ +import type { + JsonRpcMiddleware, + MiddlewareContext, +} from '@metamask/json-rpc-engine/v2'; +import { rpcErrors } from '@metamask/rpc-errors'; +import type { Json, JsonRpcRequest } from '@metamask/utils'; + +import type { AbstractRpcServiceLike } from './types.js'; + +/** + * Creates middleware for sending a JSON-RPC request through the given RPC + * service. + * + * @param args - The arguments to this function. + * @param args.rpcService - The RPC service to use. + * @param args.options - Options. + * @param args.options.originHttpHeaderKey - If provided, the origin field for + * each JSON-RPC request will be attached to each outgoing fetch request under + * this header. + * @returns The fetch middleware. + */ +export function createFetchMiddleware({ + rpcService, + options = {}, +}: { + rpcService: AbstractRpcServiceLike; + options?: { + originHttpHeaderKey?: string; + }; +}): JsonRpcMiddleware< + JsonRpcRequest, + Json, + MiddlewareContext<{ origin: string }> +> { + return async ({ request, context }) => { + const origin = context.get('origin'); + const headers = + options.originHttpHeaderKey !== undefined && origin !== undefined + ? { [options.originHttpHeaderKey]: origin } + : {}; + + const jsonRpcResponse = await rpcService.request(request, { + headers, + }); + + // NOTE: We intentionally do not test to see if `jsonRpcResponse.error` is + // strictly a JSON-RPC error response as per + // to account for + // Ganache returning error objects with extra properties such as `name` + if ('error' in jsonRpcResponse) { + throw rpcErrors.internal({ + data: jsonRpcResponse.error, + }); + } + return jsonRpcResponse.result; + }; +} diff --git a/packages/eth-json-rpc-middleware/src/index.test.ts b/packages/eth-json-rpc-middleware/src/index.test.ts new file mode 100644 index 00000000000..e11c3fdabd0 --- /dev/null +++ b/packages/eth-json-rpc-middleware/src/index.test.ts @@ -0,0 +1,303 @@ +import * as indexModule from './index.js'; + +describe('index module', () => { + it('has expected JavaScript exports', () => { + expect(indexModule).toMatchInlineSnapshot(` + { + "GetGrantedExecutionPermissionsResultStruct": Struct { + "coercer": [Function], + "entries": [Function], + "refiner": [Function], + "schema": Struct { + "coercer": [Function], + "entries": [Function], + "refiner": [Function], + "schema": { + "chainId": Struct { + "coercer": [Function], + "entries": [Function], + "refiner": [Function], + "schema": null, + "type": "string", + "validator": [Function], + }, + "context": Struct { + "coercer": [Function], + "entries": [Function], + "refiner": [Function], + "schema": null, + "type": "string", + "validator": [Function], + }, + "delegationManager": Struct { + "coercer": [Function], + "entries": [Function], + "refiner": [Function], + "schema": null, + "type": "string", + "validator": [Function], + }, + "dependencies": Struct { + "coercer": [Function], + "entries": [Function], + "refiner": [Function], + "schema": Struct { + "coercer": [Function], + "entries": [Function], + "refiner": [Function], + "schema": { + "factory": Struct { + "coercer": [Function], + "entries": [Function], + "refiner": [Function], + "schema": null, + "type": "string", + "validator": [Function], + }, + "factoryData": Struct { + "coercer": [Function], + "entries": [Function], + "refiner": [Function], + "schema": null, + "type": "string", + "validator": [Function], + }, + }, + "type": "object", + "validator": [Function], + }, + "type": "array", + "validator": [Function], + }, + "from": Struct { + "coercer": [Function], + "entries": [Function], + "refiner": [Function], + "schema": null, + "type": "string", + "validator": [Function], + }, + "permission": Struct { + "coercer": [Function], + "entries": [Function], + "refiner": [Function], + "schema": { + "data": Struct { + "coercer": [Function], + "entries": [Function], + "refiner": [Function], + "schema": null, + "type": "record", + "validator": [Function], + }, + "isAdjustmentAllowed": Struct { + "coercer": [Function], + "entries": [Function], + "refiner": [Function], + "schema": null, + "type": "boolean", + "validator": [Function], + }, + "type": Struct { + "coercer": [Function], + "entries": [Function], + "refiner": [Function], + "schema": null, + "type": "string", + "validator": [Function], + }, + }, + "type": "object", + "validator": [Function], + }, + "to": Struct { + "coercer": [Function], + "entries": [Function], + "refiner": [Function], + "schema": null, + "type": "string", + "validator": [Function], + }, + }, + "type": "object", + "validator": [Function], + }, + "type": "array", + "validator": [Function], + }, + "GetSupportedExecutionPermissionsResultStruct": Struct { + "coercer": [Function], + "entries": [Function], + "refiner": [Function], + "schema": null, + "type": "record", + "validator": [Function], + }, + "GrantedExecutionPermissionStruct": Struct { + "coercer": [Function], + "entries": [Function], + "refiner": [Function], + "schema": { + "chainId": Struct { + "coercer": [Function], + "entries": [Function], + "refiner": [Function], + "schema": null, + "type": "string", + "validator": [Function], + }, + "context": Struct { + "coercer": [Function], + "entries": [Function], + "refiner": [Function], + "schema": null, + "type": "string", + "validator": [Function], + }, + "delegationManager": Struct { + "coercer": [Function], + "entries": [Function], + "refiner": [Function], + "schema": null, + "type": "string", + "validator": [Function], + }, + "dependencies": Struct { + "coercer": [Function], + "entries": [Function], + "refiner": [Function], + "schema": Struct { + "coercer": [Function], + "entries": [Function], + "refiner": [Function], + "schema": { + "factory": Struct { + "coercer": [Function], + "entries": [Function], + "refiner": [Function], + "schema": null, + "type": "string", + "validator": [Function], + }, + "factoryData": Struct { + "coercer": [Function], + "entries": [Function], + "refiner": [Function], + "schema": null, + "type": "string", + "validator": [Function], + }, + }, + "type": "object", + "validator": [Function], + }, + "type": "array", + "validator": [Function], + }, + "from": Struct { + "coercer": [Function], + "entries": [Function], + "refiner": [Function], + "schema": null, + "type": "string", + "validator": [Function], + }, + "permission": Struct { + "coercer": [Function], + "entries": [Function], + "refiner": [Function], + "schema": { + "data": Struct { + "coercer": [Function], + "entries": [Function], + "refiner": [Function], + "schema": null, + "type": "record", + "validator": [Function], + }, + "isAdjustmentAllowed": Struct { + "coercer": [Function], + "entries": [Function], + "refiner": [Function], + "schema": null, + "type": "boolean", + "validator": [Function], + }, + "type": Struct { + "coercer": [Function], + "entries": [Function], + "refiner": [Function], + "schema": null, + "type": "string", + "validator": [Function], + }, + }, + "type": "object", + "validator": [Function], + }, + "to": Struct { + "coercer": [Function], + "entries": [Function], + "refiner": [Function], + "schema": null, + "type": "string", + "validator": [Function], + }, + }, + "type": "object", + "validator": [Function], + }, + "SupportedExecutionPermissionConfigStruct": Struct { + "coercer": [Function], + "entries": [Function], + "refiner": [Function], + "schema": { + "chainIds": Struct { + "coercer": [Function], + "entries": [Function], + "refiner": [Function], + "schema": Struct { + "coercer": [Function], + "entries": [Function], + "refiner": [Function], + "schema": null, + "type": "string", + "validator": [Function], + }, + "type": "array", + "validator": [Function], + }, + "ruleTypes": Struct { + "coercer": [Function], + "entries": [Function], + "refiner": [Function], + "schema": Struct { + "coercer": [Function], + "entries": [Function], + "refiner": [Function], + "schema": null, + "type": "string", + "validator": [Function], + }, + "type": "array", + "validator": [Function], + }, + }, + "type": "object", + "validator": [Function], + }, + "createBlockCacheMiddleware": [Function], + "createBlockRefMiddleware": [Function], + "createBlockRefRewriteMiddleware": [Function], + "createBlockTrackerInspectorMiddleware": [Function], + "createFetchMiddleware": [Function], + "createInflightCacheMiddleware": [Function], + "createRetryOnEmptyMiddleware": [Function], + "createWalletMiddleware": [Function], + "providerAsMiddleware": [Function], + "providerAsMiddlewareV2": [Function], + "validateTransactionParams": [Function], + } + `); + }); +}); diff --git a/packages/eth-json-rpc-middleware/src/index.ts b/packages/eth-json-rpc-middleware/src/index.ts new file mode 100644 index 00000000000..ad07be25357 --- /dev/null +++ b/packages/eth-json-rpc-middleware/src/index.ts @@ -0,0 +1,39 @@ +export * from './block-cache.js'; +export * from './block-ref-rewrite.js'; +export * from './block-ref.js'; +export * from './block-tracker-inspector.js'; +export { createFetchMiddleware } from './fetch.js'; +export * from './inflight-cache.js'; +export type { + PermissionDependency, + RequestExecutionPermissionsRequestParams, + RequestExecutionPermissionsResult, + ProcessRequestExecutionPermissionsHook, +} from './methods/wallet-request-execution-permissions.js'; +export type { + ProcessRevokeExecutionPermissionHook, + RevokeExecutionPermissionRequestParams, + RevokeExecutionPermissionResult, +} from './methods/wallet-revoke-execution-permission.js'; +export type { + GrantedExecutionPermission, + GetGrantedExecutionPermissionsResult, + ProcessGetGrantedExecutionPermissionsHook, +} from './methods/wallet-get-granted-execution-permissions.js'; +export { + GrantedExecutionPermissionStruct, + GetGrantedExecutionPermissionsResultStruct, +} from './methods/wallet-get-granted-execution-permissions.js'; +export type { + SupportedExecutionPermissionConfig, + GetSupportedExecutionPermissionsResult, + ProcessGetSupportedExecutionPermissionsHook, +} from './methods/wallet-get-supported-execution-permissions.js'; +export { + SupportedExecutionPermissionConfigStruct, + GetSupportedExecutionPermissionsResultStruct, +} from './methods/wallet-get-supported-execution-permissions.js'; +export * from './providerAsMiddleware.js'; +export { validateTransactionParams } from './utils/validation.js'; +export * from './retryOnEmpty.js'; +export * from './wallet.js'; diff --git a/packages/eth-json-rpc-middleware/src/inflight-cache.test.ts b/packages/eth-json-rpc-middleware/src/inflight-cache.test.ts new file mode 100644 index 00000000000..08159a2ee76 --- /dev/null +++ b/packages/eth-json-rpc-middleware/src/inflight-cache.test.ts @@ -0,0 +1,43 @@ +import { JsonRpcEngineV2 } from '@metamask/json-rpc-engine/v2'; + +import { createRequest } from '../test/util/helpers.js'; +import { createInflightCacheMiddleware } from './index.js'; + +describe('inflight cache', () => { + it('should cache an inflight request and only hit provider once', async () => { + let hitCount = 0; + const engine = JsonRpcEngineV2.create({ + middleware: [ + createInflightCacheMiddleware(), + async (): Promise => { + hitCount += 1; + if (hitCount === 1) { + await new Promise((resolve) => setTimeout(resolve, 100)); + } + return true; + }, + ], + }); + + const results = await Promise.all([ + engine.handle( + createRequest({ + id: 1, + method: 'test_blockCache', + params: [], + }), + ), + engine.handle( + createRequest({ + id: 2, + method: 'test_blockCache', + params: [], + }), + ), + ]); + + expect(results[0]).toBe(true); + expect(results[1]).toBe(true); + expect(hitCount).toBe(1); // check result handler was only hit once + }); +}); diff --git a/packages/eth-json-rpc-middleware/src/inflight-cache.ts b/packages/eth-json-rpc-middleware/src/inflight-cache.ts new file mode 100644 index 00000000000..d7c6cc1c0e0 --- /dev/null +++ b/packages/eth-json-rpc-middleware/src/inflight-cache.ts @@ -0,0 +1,126 @@ +import type { + JsonRpcMiddleware, + MiddlewareContext, +} from '@metamask/json-rpc-engine/v2'; +import { createDeferredPromise } from '@metamask/utils'; +import type { Json, JsonRpcRequest } from '@metamask/utils'; + +import { projectLogger, createModuleLogger } from './logging-utils.js'; +import { cacheIdentifierForRequest } from './utils/cache.js'; + +type RequestHandler = [ + (result: Readonly) => void, + (error: unknown) => void, +]; + +type InflightRequest = { + [cacheId: string]: RequestHandler[]; +}; + +const log = createModuleLogger(projectLogger, 'inflight-cache'); + +/** + * Creates a middleware that caches inflight requests. + * If a request is already in flight, the middleware will wait for the request to complete + * and then return the result. + * + * @returns A middleware that caches inflight requests. + */ +export function createInflightCacheMiddleware(): JsonRpcMiddleware< + JsonRpcRequest, + Json, + MiddlewareContext<{ skipCache: boolean }> +> { + const inflightRequests: InflightRequest = {}; + + return async ({ request, context, next }) => { + if (context.get('skipCache')) { + return next(); + } + + const cacheId: string | null = cacheIdentifierForRequest(request); + if (!cacheId) { + log('Request is not cacheable, proceeding. req = %o', request); + return next(); + } + + // check for matching requests + let activeRequestHandlers: RequestHandler[] = inflightRequests[cacheId]; + // if found, wait for the active request to be handled + if (activeRequestHandlers) { + // setup the response listener and wait for it to be called + // it will handle copying the result and request fields + log( + 'Running %i handler(s) for request %o', + activeRequestHandlers.length, + request, + ); + return await createActiveRequestHandler(activeRequestHandlers); + } + + // setup response handler array for subsequent requests + activeRequestHandlers = []; + inflightRequests[cacheId] = activeRequestHandlers; + // allow request to be handled normally + log('Carrying original request forward %o', request); + try { + const result = (await next()) as Readonly; + log( + 'Running %i collected handler(s) for successful request %o', + activeRequestHandlers.length, + request, + ); + runRequestHandlers({ result }, activeRequestHandlers); + return result; + } catch (error) { + log( + 'Running %i collected handler(s) for failed request %o', + activeRequestHandlers.length, + request, + ); + runRequestHandlers({ error }, activeRequestHandlers); + throw error; + } finally { + delete inflightRequests[cacheId]; + } + }; +} + +/** + * Creates a new request handler for the active request. + * + * @param activeRequestHandlers - The active request handlers. + * @returns A promise that resolves to the result of the request. + */ +function createActiveRequestHandler( + activeRequestHandlers: RequestHandler[], +): Promise> { + const { resolve, promise, reject } = createDeferredPromise>(); + activeRequestHandlers.push([ + (result: Readonly): void => resolve(result), + (error: unknown): void => reject(error), + ]); + return promise; +} + +/** + * Runs the request handlers for the given result or error. + * + * @param resultOrError - The result or error of the request. + * @param activeRequestHandlers - The active request handlers. + */ +function runRequestHandlers( + resultOrError: { result: Readonly } | { error: unknown }, + activeRequestHandlers: RequestHandler[], +): void { + // use setTimeout so we can handle the original request first + setTimeout(() => { + activeRequestHandlers.forEach(([onSuccess, onError]) => { + if ('result' in resultOrError) { + onSuccess(resultOrError.result); + } else { + onError(resultOrError.error); + } + }); + }); +} diff --git a/packages/eth-json-rpc-middleware/src/logging-utils.ts b/packages/eth-json-rpc-middleware/src/logging-utils.ts new file mode 100644 index 00000000000..fd98eea17d8 --- /dev/null +++ b/packages/eth-json-rpc-middleware/src/logging-utils.ts @@ -0,0 +1,5 @@ +import { createProjectLogger, createModuleLogger } from '@metamask/utils'; + +export const projectLogger = createProjectLogger('eth-json-rpc-middleware'); + +export { createModuleLogger }; diff --git a/packages/eth-json-rpc-middleware/src/methods/wallet-get-granted-execution-permissions.test.ts b/packages/eth-json-rpc-middleware/src/methods/wallet-get-granted-execution-permissions.test.ts new file mode 100644 index 00000000000..5370ea44a8c --- /dev/null +++ b/packages/eth-json-rpc-middleware/src/methods/wallet-get-granted-execution-permissions.test.ts @@ -0,0 +1,108 @@ +import type { Hex, Json, JsonRpcRequest } from '@metamask/utils'; + +import type { WalletMiddlewareParams } from '../wallet.js'; +import type { + GetGrantedExecutionPermissionsResult, + ProcessGetGrantedExecutionPermissionsHook, +} from './wallet-get-granted-execution-permissions.js'; +import { createWalletGetGrantedExecutionPermissionsHandler } from './wallet-get-granted-execution-permissions.js'; + +const RESULT_MOCK: GetGrantedExecutionPermissionsResult = [ + { + chainId: '0x01' as Hex, + from: '0x5B38Da6a701c568545dCfcB03FcB875f56beddC4' as Hex, + to: '0x016562aA41A8697720ce0943F003141f5dEAe006' as Hex, + permission: { + type: 'native-token-allowance', + isAdjustmentAllowed: true, + data: { + allowance: '0x1DCD65000000', + }, + }, + context: + '0x016562aA41A8697720ce0943F003141f5dEAe0060000771577157715' as Hex, + dependencies: [ + { + factory: '0x1234567890123456789012345678901234567890' as Hex, + factoryData: '0xabcdef' as Hex, + }, + ], + delegationManager: '0xAb8483F64d9C6d1EcF9b849Ae677dD3315835cb2' as Hex, + }, +]; + +const REQUEST_MOCK = { + params: [], +} as unknown as JsonRpcRequest; + +describe('wallet_getGrantedExecutionPermissions', () => { + let request: JsonRpcRequest; + let processGetGrantedExecutionPermissionsMock: jest.MockedFunction; + let context: WalletMiddlewareParams['context']; + + const callMethod = async (): Promise | undefined> => { + const handler = createWalletGetGrantedExecutionPermissionsHandler({ + processGetGrantedExecutionPermissions: + processGetGrantedExecutionPermissionsMock, + }); + return handler({ request, context } as WalletMiddlewareParams); + }; + + beforeEach(() => { + jest.resetAllMocks(); + + request = { ...REQUEST_MOCK }; + + context = new Map([ + ['origin', 'test-origin'], + ]) as WalletMiddlewareParams['context']; + + processGetGrantedExecutionPermissionsMock = jest.fn(); + processGetGrantedExecutionPermissionsMock.mockResolvedValue(RESULT_MOCK); + }); + + it('calls hook', async () => { + await callMethod(); + expect(processGetGrantedExecutionPermissionsMock).toHaveBeenCalledWith( + request, + context, + ); + }); + + it('returns result from hook', async () => { + const result = await callMethod(); + expect(result).toStrictEqual(RESULT_MOCK); + }); + + it('throws if no hook', async () => { + await expect( + createWalletGetGrantedExecutionPermissionsHandler({})({ + request, + } as WalletMiddlewareParams), + ).rejects.toThrow( + 'wallet_getGrantedExecutionPermissions - no middleware configured', + ); + }); + + describe('params validation', () => { + it.each([ + ['undefined', undefined], + ['empty array', []], + ['empty object', {}], + ])('accepts params as %s', async (_description, params) => { + request = { ...REQUEST_MOCK, params } as unknown as JsonRpcRequest; + expect(await callMethod()).toStrictEqual(RESULT_MOCK); + }); + + it.each([ + ['non-empty array', [1]], + ['non-empty object', { foo: 'bar' }], + ['string', 'invalid'], + ['number', 123], + ['null', null], + ])('rejects invalid params: %s', async (_description, params) => { + request = { ...REQUEST_MOCK, params } as unknown as JsonRpcRequest; + await expect(callMethod()).rejects.toThrow(/Invalid params/u); + }); + }); +}); diff --git a/packages/eth-json-rpc-middleware/src/methods/wallet-get-granted-execution-permissions.ts b/packages/eth-json-rpc-middleware/src/methods/wallet-get-granted-execution-permissions.ts new file mode 100644 index 00000000000..06d352aa0fd --- /dev/null +++ b/packages/eth-json-rpc-middleware/src/methods/wallet-get-granted-execution-permissions.ts @@ -0,0 +1,107 @@ +import type { JsonRpcMiddleware } from '@metamask/json-rpc-engine/v2'; +import { rpcErrors } from '@metamask/rpc-errors'; +import type { Infer } from '@metamask/superstruct'; +import { + array, + boolean, + object, + record, + string, + unknown, +} from '@metamask/superstruct'; +import { HexChecksumAddressStruct, StrictHexStruct } from '@metamask/utils'; +import type { Json, JsonRpcRequest } from '@metamask/utils'; + +import { NoParamsStruct } from '../utils/structs.js'; +import { validateParams } from '../utils/validation.js'; +import type { WalletMiddlewareContext } from '../wallet.js'; + +/** + * Superstruct schema for the `wallet_getGrantedExecutionPermissions` request params. + * + * This method expects no parameters. Different JSON-RPC clients may send "no params" + * in different ways (omitted, empty array, or empty object), so we accept all three. + */ +export const GetGrantedExecutionPermissionsParamsStruct = NoParamsStruct; + +const DependencyStruct = object({ + factory: StrictHexStruct, + factoryData: StrictHexStruct, +}); + +const PermissionStruct = object({ + type: string(), + isAdjustmentAllowed: boolean(), + data: record(string(), unknown()), +}); + +/** + * Superstruct schema for a single granted execution permission. + */ +export const GrantedExecutionPermissionStruct = object({ + chainId: StrictHexStruct, + from: HexChecksumAddressStruct, + to: HexChecksumAddressStruct, + permission: PermissionStruct, + context: StrictHexStruct, + dependencies: array(DependencyStruct), + delegationManager: HexChecksumAddressStruct, +}); + +/** + * Represents a single granted execution permission. + */ +export type GrantedExecutionPermission = Infer< + typeof GrantedExecutionPermissionStruct +>; + +/** + * Superstruct schema for the `wallet_getGrantedExecutionPermissions` result. + */ +export const GetGrantedExecutionPermissionsResultStruct = array( + GrantedExecutionPermissionStruct, +); + +/** + * Result type for the `wallet_getGrantedExecutionPermissions` JSON-RPC method. + * Returns an array of all granted permissions that are not yet revoked. + */ +export type GetGrantedExecutionPermissionsResult = Json & + Infer; + +/** + * Hook type for processing the `wallet_getGrantedExecutionPermissions` request. + */ +export type ProcessGetGrantedExecutionPermissionsHook = ( + req: JsonRpcRequest, + context: WalletMiddlewareContext, +) => Promise; + +/** + * Creates a handler for the `wallet_getGrantedExecutionPermissions` JSON-RPC method. + * + * @param options - The options for the handler. + * @param options.processGetGrantedExecutionPermissions - The function to process the + * get granted execution permissions request. + * @returns A JSON-RPC middleware function that handles the + * `wallet_getGrantedExecutionPermissions` JSON-RPC method. + */ +export function createWalletGetGrantedExecutionPermissionsHandler({ + processGetGrantedExecutionPermissions, +}: { + processGetGrantedExecutionPermissions?: ProcessGetGrantedExecutionPermissionsHook; +}): JsonRpcMiddleware { + return async ({ request, context }) => { + if (!processGetGrantedExecutionPermissions) { + throw rpcErrors.methodNotSupported( + 'wallet_getGrantedExecutionPermissions - no middleware configured', + ); + } + + const { params } = request; + + validateParams(params, GetGrantedExecutionPermissionsParamsStruct); + + return await processGetGrantedExecutionPermissions(request, context); + }; +} diff --git a/packages/eth-json-rpc-middleware/src/methods/wallet-get-supported-execution-permissions.test.ts b/packages/eth-json-rpc-middleware/src/methods/wallet-get-supported-execution-permissions.test.ts new file mode 100644 index 00000000000..283db560df3 --- /dev/null +++ b/packages/eth-json-rpc-middleware/src/methods/wallet-get-supported-execution-permissions.test.ts @@ -0,0 +1,99 @@ +import type { Hex, Json, JsonRpcRequest } from '@metamask/utils'; + +import type { WalletMiddlewareParams } from '../wallet.js'; +import type { + GetSupportedExecutionPermissionsResult, + ProcessGetSupportedExecutionPermissionsHook, +} from './wallet-get-supported-execution-permissions.js'; +import { createWalletGetSupportedExecutionPermissionsHandler } from './wallet-get-supported-execution-permissions.js'; + +const RESULT_MOCK: GetSupportedExecutionPermissionsResult = { + 'native-token-allowance': { + chainIds: ['0x123', '0x345'] as Hex[], + ruleTypes: ['expiry', 'redeemer'], + }, + 'erc20-token-allowance': { + chainIds: ['0x123'] as Hex[], + ruleTypes: [], + }, + 'erc721-token-allowance': { + chainIds: ['0x123'] as Hex[], + ruleTypes: ['expiry', 'redeemer'], + }, +}; + +const REQUEST_MOCK = { + params: [], +} as unknown as JsonRpcRequest; + +describe('wallet_getSupportedExecutionPermissions', () => { + let request: JsonRpcRequest; + let processGetSupportedExecutionPermissionsMock: jest.MockedFunction; + let context: WalletMiddlewareParams['context']; + + const callMethod = async (): Promise | undefined> => { + const handler = createWalletGetSupportedExecutionPermissionsHandler({ + processGetSupportedExecutionPermissions: + processGetSupportedExecutionPermissionsMock, + }); + return handler({ request, context } as WalletMiddlewareParams); + }; + + beforeEach(() => { + jest.resetAllMocks(); + + request = { ...REQUEST_MOCK }; + + context = new Map([ + ['origin', 'test-origin'], + ]) as WalletMiddlewareParams['context']; + + processGetSupportedExecutionPermissionsMock = jest.fn(); + processGetSupportedExecutionPermissionsMock.mockResolvedValue(RESULT_MOCK); + }); + + it('calls hook', async () => { + await callMethod(); + expect(processGetSupportedExecutionPermissionsMock).toHaveBeenCalledWith( + request, + context, + ); + }); + + it('returns result from hook', async () => { + const result = await callMethod(); + expect(result).toStrictEqual(RESULT_MOCK); + }); + + it('throws if no hook', async () => { + await expect( + createWalletGetSupportedExecutionPermissionsHandler({})({ + request, + } as WalletMiddlewareParams), + ).rejects.toThrow( + 'wallet_getSupportedExecutionPermissions - no middleware configured', + ); + }); + + describe('params validation', () => { + it.each([ + ['undefined', undefined], + ['empty array', []], + ['empty object', {}], + ])('accepts params as %s', async (_description, params) => { + request = { ...REQUEST_MOCK, params } as unknown as JsonRpcRequest; + expect(await callMethod()).toStrictEqual(RESULT_MOCK); + }); + + it.each([ + ['non-empty array', [1]], + ['non-empty object', { foo: 'bar' }], + ['string', 'invalid'], + ['number', 123], + ['null', null], + ])('rejects invalid params: %s', async (_description, params) => { + request = { ...REQUEST_MOCK, params } as unknown as JsonRpcRequest; + await expect(callMethod()).rejects.toThrow(/Invalid params/u); + }); + }); +}); diff --git a/packages/eth-json-rpc-middleware/src/methods/wallet-get-supported-execution-permissions.ts b/packages/eth-json-rpc-middleware/src/methods/wallet-get-supported-execution-permissions.ts new file mode 100644 index 00000000000..725bb951547 --- /dev/null +++ b/packages/eth-json-rpc-middleware/src/methods/wallet-get-supported-execution-permissions.ts @@ -0,0 +1,85 @@ +import type { JsonRpcMiddleware } from '@metamask/json-rpc-engine/v2'; +import { rpcErrors } from '@metamask/rpc-errors'; +import type { Infer } from '@metamask/superstruct'; +import { array, object, record, string } from '@metamask/superstruct'; +import { StrictHexStruct } from '@metamask/utils'; +import type { Json, JsonRpcRequest } from '@metamask/utils'; + +import { NoParamsStruct } from '../utils/structs.js'; +import { validateParams } from '../utils/validation.js'; +import type { WalletMiddlewareContext } from '../wallet.js'; + +/** + * Superstruct schema for the `wallet_getSupportedExecutionPermissions` request params. + * + * This method expects no parameters. Different JSON-RPC clients may send "no params" + * in different ways (omitted, empty array, or empty object), so we accept all three. + */ +export const GetSupportedExecutionPermissionsParamsStruct = NoParamsStruct; + +/** + * Superstruct schema for a supported permission type configuration. + */ +export const SupportedExecutionPermissionConfigStruct = object({ + chainIds: array(StrictHexStruct), + ruleTypes: array(string()), +}); + +/** + * Represents the supported configuration for a permission type. + */ +export type SupportedExecutionPermissionConfig = Infer< + typeof SupportedExecutionPermissionConfigStruct +>; + +/** + * Superstruct schema for the `wallet_getSupportedExecutionPermissions` result. + */ +export const GetSupportedExecutionPermissionsResultStruct = record( + string(), + SupportedExecutionPermissionConfigStruct, +); + +/** + * Result type for the `wallet_getSupportedExecutionPermissions` JSON-RPC method. + * Returns an object keyed on supported permission types with their configurations. + */ +export type GetSupportedExecutionPermissionsResult = Json & + Infer; + +/** + * Hook type for processing the `wallet_getSupportedExecutionPermissions` request. + */ +export type ProcessGetSupportedExecutionPermissionsHook = ( + req: JsonRpcRequest, + context: WalletMiddlewareContext, +) => Promise; + +/** + * Creates a handler for the `wallet_getSupportedExecutionPermissions` JSON-RPC method. + * + * @param options - The options for the handler. + * @param options.processGetSupportedExecutionPermissions - The function to process the + * get supported execution permissions request. + * @returns A JSON-RPC middleware function that handles the + * `wallet_getSupportedExecutionPermissions` JSON-RPC method. + */ +export function createWalletGetSupportedExecutionPermissionsHandler({ + processGetSupportedExecutionPermissions, +}: { + processGetSupportedExecutionPermissions?: ProcessGetSupportedExecutionPermissionsHook; +}): JsonRpcMiddleware { + return async ({ request, context }) => { + if (!processGetSupportedExecutionPermissions) { + throw rpcErrors.methodNotSupported( + 'wallet_getSupportedExecutionPermissions - no middleware configured', + ); + } + + const { params } = request; + + validateParams(params, GetSupportedExecutionPermissionsParamsStruct); + + return await processGetSupportedExecutionPermissions(request, context); + }; +} diff --git a/packages/eth-json-rpc-middleware/src/methods/wallet-request-execution-permissions.test.ts b/packages/eth-json-rpc-middleware/src/methods/wallet-request-execution-permissions.test.ts new file mode 100644 index 00000000000..9d0e9ac94f5 --- /dev/null +++ b/packages/eth-json-rpc-middleware/src/methods/wallet-request-execution-permissions.test.ts @@ -0,0 +1,187 @@ +import type { Json, JsonRpcRequest } from '@metamask/utils'; +import { klona } from 'klona'; + +import type { WalletMiddlewareParams } from '../wallet.js'; +import type { + ProcessRequestExecutionPermissionsHook, + RequestExecutionPermissionsRequestParams, + RequestExecutionPermissionsResult, +} from './wallet-request-execution-permissions.js'; +import { createWalletRequestExecutionPermissionsHandler } from './wallet-request-execution-permissions.js'; + +const FROM_ADDRESS_MOCK = '0x123abc123abc123abc123abc123abc123abc123A'; +const TO_ADDRESS_MOCK = '0x016562aA41A8697720ce0943F003141f5dEAe006'; +const CHAIN_ID_MOCK = '0x1'; +const CONTEXT_MOCK = '0x123abc'; +const DELEGATION_MANAGER_MOCK = '0xabc123abc123abc123abc123abc123abc123abc1'; +const FACTORY_MOCK = '0xdef456def456def456def456def456def456def4'; +const FACTORY_DATA_MOCK = '0x1234'; + +const REQUEST_MOCK = { + params: [ + { + chainId: CHAIN_ID_MOCK, + from: FROM_ADDRESS_MOCK, + to: TO_ADDRESS_MOCK, + permission: { + type: 'test-permission', + isAdjustmentAllowed: true, + data: { key: 'value' }, + }, + rules: [ + { + type: 'test-rule', + data: { ruleKey: 'ruleValue' }, + }, + ], + }, + ], +} as unknown as JsonRpcRequest; + +const RESULT_MOCK: RequestExecutionPermissionsResult = [ + { + chainId: CHAIN_ID_MOCK, + from: FROM_ADDRESS_MOCK, + to: TO_ADDRESS_MOCK, + permission: { + type: 'test-permission', + isAdjustmentAllowed: true, + data: { key: 'value' }, + }, + rules: [ + { + type: 'test-rule', + data: { ruleKey: 'ruleValue' }, + }, + ], + context: CONTEXT_MOCK, + dependencies: [ + { + factory: FACTORY_MOCK, + factoryData: FACTORY_DATA_MOCK, + }, + ], + delegationManager: DELEGATION_MANAGER_MOCK, + }, +]; + +describe('wallet_requestExecutionPermissions', () => { + let request: JsonRpcRequest; + let params: RequestExecutionPermissionsRequestParams; + let processRequestExecutionPermissionsMock: jest.MockedFunction; + let context: WalletMiddlewareParams['context']; + + const callMethod = async (): Promise | undefined> => { + const handler = createWalletRequestExecutionPermissionsHandler({ + processRequestExecutionPermissions: + processRequestExecutionPermissionsMock, + }); + return handler({ request, context } as WalletMiddlewareParams); + }; + + beforeEach(() => { + jest.resetAllMocks(); + + request = klona(REQUEST_MOCK); + params = request.params as RequestExecutionPermissionsRequestParams; + + context = new Map([ + ['origin', 'test-origin'], + ]) as WalletMiddlewareParams['context']; + + processRequestExecutionPermissionsMock = jest.fn(); + processRequestExecutionPermissionsMock.mockResolvedValue(RESULT_MOCK); + }); + + it('calls hook', async () => { + await callMethod(); + expect(processRequestExecutionPermissionsMock).toHaveBeenCalledWith( + params, + request, + context, + ); + }); + + it('returns result from hook', async () => { + const result = await callMethod(); + expect(result).toStrictEqual(RESULT_MOCK); + }); + + it('supports undefined rules', async () => { + params[0].rules = undefined; + + await callMethod(); + + expect(processRequestExecutionPermissionsMock).toHaveBeenCalledWith( + params, + request, + context, + ); + }); + + it('supports null rules', async () => { + params[0].rules = null as never; + + await callMethod(); + + expect(processRequestExecutionPermissionsMock).toHaveBeenCalledWith( + params, + request, + context, + ); + }); + + it('supports optional from', async () => { + params[0].from = undefined; + + await callMethod(); + + expect(processRequestExecutionPermissionsMock).toHaveBeenCalledWith( + params, + request, + context, + ); + }); + + it('throws if no hook', async () => { + await expect( + createWalletRequestExecutionPermissionsHandler({})({ + request, + } as WalletMiddlewareParams), + ).rejects.toThrow( + `wallet_requestExecutionPermissions - no middleware configured`, + ); + }); + + it('throws if no params', async () => { + request.params = undefined; + + await expect(callMethod()).rejects.toThrow('Invalid params'); + }); + + it('throws if missing properties', async () => { + params[0].chainId = undefined as never; + params[0].to = undefined as never; + params[0].permission = undefined as never; + + await expect(callMethod()).rejects.toThrow('Invalid params'); + }); + + it('throws if wrong types', async () => { + params[0].chainId = 123 as never; + params[0].from = 123 as never; + params[0].to = 123 as never; + params[0].permission = '123' as never; + params[0].rules = [{} as never]; + + await expect(callMethod()).rejects.toThrow('Invalid params'); + }); + + it('throws if not hex', async () => { + params[0].chainId = '123' as never; + params[0].from = '123' as never; + params[0].to = '123' as never; + + await expect(callMethod()).rejects.toThrow('Invalid params'); + }); +}); diff --git a/packages/eth-json-rpc-middleware/src/methods/wallet-request-execution-permissions.ts b/packages/eth-json-rpc-middleware/src/methods/wallet-request-execution-permissions.ts new file mode 100644 index 00000000000..67fb5f748ea --- /dev/null +++ b/packages/eth-json-rpc-middleware/src/methods/wallet-request-execution-permissions.ts @@ -0,0 +1,92 @@ +import type { JsonRpcMiddleware } from '@metamask/json-rpc-engine/v2'; +import { rpcErrors } from '@metamask/rpc-errors'; +import type { Infer } from '@metamask/superstruct'; +import { + array, + boolean, + literal, + object, + optional, + record, + string, + union, + unknown, +} from '@metamask/superstruct'; +import { HexChecksumAddressStruct, StrictHexStruct } from '@metamask/utils'; +import type { Hex, Json, JsonRpcRequest } from '@metamask/utils'; + +import { validateParams } from '../utils/validation.js'; +import type { WalletMiddlewareContext } from '../wallet.js'; + +const PermissionStruct = object({ + type: string(), + isAdjustmentAllowed: boolean(), + data: record(string(), unknown()), +}); + +const RuleStruct = object({ + type: string(), + data: record(string(), unknown()), +}); + +const PermissionRequestStruct = object({ + chainId: StrictHexStruct, + from: optional(HexChecksumAddressStruct), + to: HexChecksumAddressStruct, + permission: PermissionStruct, + rules: optional(union([array(RuleStruct), literal(null)])), +}); + +export const RequestExecutionPermissionsStruct = array(PermissionRequestStruct); + +// RequestExecutionPermissions API types +export type RequestExecutionPermissionsRequestParams = Infer< + typeof RequestExecutionPermissionsStruct +>; + +export type PermissionDependency = { + factory: Hex; + factoryData: Hex; +}; + +export type RequestExecutionPermissionsResult = Json & + (Infer & { + context: Hex; + dependencies: PermissionDependency[]; + delegationManager: Hex; + })[]; + +export type ProcessRequestExecutionPermissionsHook = ( + request: RequestExecutionPermissionsRequestParams, + req: JsonRpcRequest, + context: WalletMiddlewareContext, +) => Promise; + +/** + * Creates a handler for the `wallet_requestExecutionPermissions` JSON-RPC method. + * + * @param options - The options for the handler. + * @param options.processRequestExecutionPermissions - The function to process the + * request execution permissions request. + * @returns A JSON-RPC middleware function that handles the + * `wallet_requestExecutionPermissions` JSON-RPC method. + */ +export function createWalletRequestExecutionPermissionsHandler({ + processRequestExecutionPermissions, +}: { + processRequestExecutionPermissions?: ProcessRequestExecutionPermissionsHook; +}): JsonRpcMiddleware { + return async ({ request, context }) => { + if (!processRequestExecutionPermissions) { + throw rpcErrors.methodNotSupported( + 'wallet_requestExecutionPermissions - no middleware configured', + ); + } + + const { params } = request; + + validateParams(params, RequestExecutionPermissionsStruct); + + return await processRequestExecutionPermissions(params, request, context); + }; +} diff --git a/packages/eth-json-rpc-middleware/src/methods/wallet-revoke-execution-permission.test.ts b/packages/eth-json-rpc-middleware/src/methods/wallet-revoke-execution-permission.test.ts new file mode 100644 index 00000000000..63f88b90807 --- /dev/null +++ b/packages/eth-json-rpc-middleware/src/methods/wallet-revoke-execution-permission.test.ts @@ -0,0 +1,93 @@ +import type { Json, JsonRpcRequest } from '@metamask/utils'; +import { klona } from 'klona'; + +import type { WalletMiddlewareParams } from '../wallet.js'; +import type { + ProcessRevokeExecutionPermissionHook, + RevokeExecutionPermissionRequestParams, +} from './wallet-revoke-execution-permission.js'; +import { createWalletRevokeExecutionPermissionHandler } from './wallet-revoke-execution-permission.js'; + +const HEX_MOCK = '0x123abc'; + +const REQUEST_MOCK = { + params: { + permissionContext: HEX_MOCK, + }, +} as unknown as JsonRpcRequest; + +describe('wallet_revokeExecutionPermission', () => { + let request: JsonRpcRequest; + let params: RevokeExecutionPermissionRequestParams; + let processRevokeExecutionPermissionMock: jest.MockedFunction; + let context: WalletMiddlewareParams['context']; + + const callMethod = async (): Promise | undefined> => { + const handler = createWalletRevokeExecutionPermissionHandler({ + processRevokeExecutionPermission: processRevokeExecutionPermissionMock, + }); + return handler({ request, context } as WalletMiddlewareParams); + }; + + beforeEach(() => { + jest.resetAllMocks(); + + request = klona(REQUEST_MOCK); + params = request.params as RevokeExecutionPermissionRequestParams; + + context = new Map([ + ['origin', 'test-origin'], + ]) as WalletMiddlewareParams['context']; + + processRevokeExecutionPermissionMock = jest.fn(); + processRevokeExecutionPermissionMock.mockResolvedValue({}); + }); + + it('calls hook', async () => { + await callMethod(); + expect(processRevokeExecutionPermissionMock).toHaveBeenCalledWith( + params, + request, + context, + ); + }); + + it('returns result from hook', async () => { + const result = await callMethod(); + expect(result).toStrictEqual({}); + }); + + it('throws if no hook', async () => { + await expect( + createWalletRevokeExecutionPermissionHandler({})({ + request, + } as WalletMiddlewareParams), + ).rejects.toThrow( + 'wallet_revokeExecutionPermission - no middleware configured', + ); + }); + + it('throws if no params', async () => { + (request as JsonRpcRequest).params = undefined; + + await expect(callMethod()).rejects.toThrow('Invalid params'); + }); + + it('throws if missing properties', async () => { + (request as JsonRpcRequest).params = {} as never; + + await expect(callMethod()).rejects.toThrow('Invalid params'); + }); + + it('throws if wrong types', async () => { + params.permissionContext = 123 as never; + + await expect(callMethod()).rejects.toThrow('Invalid params'); + }); + + it('throws if not hex', async () => { + params.permissionContext = '123' as never; + + await expect(callMethod()).rejects.toThrow('Invalid params'); + }); +}); diff --git a/packages/eth-json-rpc-middleware/src/methods/wallet-revoke-execution-permission.ts b/packages/eth-json-rpc-middleware/src/methods/wallet-revoke-execution-permission.ts new file mode 100644 index 00000000000..a5da3aef542 --- /dev/null +++ b/packages/eth-json-rpc-middleware/src/methods/wallet-revoke-execution-permission.ts @@ -0,0 +1,58 @@ +import type { JsonRpcMiddleware } from '@metamask/json-rpc-engine/v2'; +import { rpcErrors } from '@metamask/rpc-errors'; +import type { Infer } from '@metamask/superstruct'; +import { object } from '@metamask/superstruct'; +import { StrictHexStruct } from '@metamask/utils'; +import type { Json, JsonRpcRequest } from '@metamask/utils'; + +import { validateParams } from '../utils/validation.js'; +import type { WalletMiddlewareContext } from '../wallet.js'; + +export const RevokeExecutionPermissionResultStruct = object({}); + +export type RevokeExecutionPermissionResult = Infer< + typeof RevokeExecutionPermissionResultStruct +>; + +export const RevokeExecutionPermissionRequestParamsStruct = object({ + permissionContext: StrictHexStruct, +}); + +export type RevokeExecutionPermissionRequestParams = Infer< + typeof RevokeExecutionPermissionRequestParamsStruct +>; + +export type ProcessRevokeExecutionPermissionHook = ( + request: RevokeExecutionPermissionRequestParams, + req: JsonRpcRequest, + context: WalletMiddlewareContext, +) => Promise; + +/** + * Creates a handler for the `wallet_revokeExecutionPermission` JSON-RPC method. + * + * @param options - The options for the handler. + * @param options.processRevokeExecutionPermission - The function to process the + * revoke execution permission request. + * @returns A JSON-RPC middleware function that handles the + * `wallet_revokeExecutionPermission` JSON-RPC method. + */ +export function createWalletRevokeExecutionPermissionHandler({ + processRevokeExecutionPermission, +}: { + processRevokeExecutionPermission?: ProcessRevokeExecutionPermissionHook; +}): JsonRpcMiddleware { + return async ({ request, context }) => { + if (!processRevokeExecutionPermission) { + throw rpcErrors.methodNotSupported( + 'wallet_revokeExecutionPermission - no middleware configured', + ); + } + + const { params } = request; + + validateParams(params, RevokeExecutionPermissionRequestParamsStruct); + + return await processRevokeExecutionPermission(params, request, context); + }; +} diff --git a/packages/eth-json-rpc-middleware/src/providerAsMiddleware.test.ts b/packages/eth-json-rpc-middleware/src/providerAsMiddleware.test.ts new file mode 100644 index 00000000000..02c60a34f61 --- /dev/null +++ b/packages/eth-json-rpc-middleware/src/providerAsMiddleware.test.ts @@ -0,0 +1,121 @@ +import type { InternalProvider } from '@metamask/eth-json-rpc-provider'; +import { JsonRpcEngine } from '@metamask/json-rpc-engine'; +import { JsonRpcEngineV2 } from '@metamask/json-rpc-engine/v2'; +import type { Json } from '@metamask/utils'; +import { + assertIsJsonRpcFailure, + assertIsJsonRpcSuccess, +} from '@metamask/utils'; + +import { createRequest } from '../test/util/helpers.js'; +import { + providerAsMiddleware, + providerAsMiddlewareV2, +} from './providerAsMiddleware.js'; + +const createMockProvider = (resultOrError: Json | Error): InternalProvider => + ({ + request: + resultOrError instanceof Error + ? jest.fn().mockRejectedValue(resultOrError) + : jest.fn().mockResolvedValue(resultOrError), + }) as unknown as InternalProvider; + +describe('providerAsMiddleware', () => { + it('forwards requests to the provider and returns the result', async () => { + const mockResult = 42; + const mockProvider = createMockProvider(mockResult); + + const engine = new JsonRpcEngine(); + engine.push(providerAsMiddleware(mockProvider)); + + const request = createRequest({ + method: 'eth_chainId', + params: [], + }); + + await new Promise((resolve) => { + engine.handle(request, (error, response) => { + expect(error).toBeNull(); + assertIsJsonRpcSuccess(response); + expect(response.result).toStrictEqual(mockResult); + expect(mockProvider.request).toHaveBeenCalledWith(request); + + resolve(); + }); + }); + }); + + it('forwards errors to the provider and returns the error', async () => { + const mockError = new Error('test'); + const mockProvider = createMockProvider(mockError); + + const engine = new JsonRpcEngine(); + engine.push(providerAsMiddleware(mockProvider)); + + const request = createRequest({ + method: 'eth_chainId', + params: [], + }); + + await new Promise((resolve) => { + engine.handle(request, (error, response) => { + assertIsJsonRpcFailure(response); + expect(error).toBe(mockError); + expect(response.error).toStrictEqual( + expect.objectContaining({ + message: mockError.message, + code: -32603, + data: { + cause: { + message: mockError.message, + stack: expect.any(String), + }, + }, + }), + ); + expect(mockProvider.request).toHaveBeenCalledWith(request); + + resolve(); + }); + }); + }); +}); + +describe('providerAsMiddlewareV2', () => { + it('forwards requests to the provider and returns the result', async () => { + const mockResult = 123; + const mockProvider = createMockProvider(mockResult); + + const engine = JsonRpcEngineV2.create({ + middleware: [providerAsMiddlewareV2(mockProvider)], + }); + + const request = createRequest({ + method: 'eth_chainId', + params: [], + }); + + const result = await engine.handle(request); + + expect(result).toStrictEqual(mockResult); + expect(mockProvider.request).toHaveBeenCalledWith(request); + }); + + it('forwards errors to the provider and returns the error', async () => { + const mockError = new Error('test'); + const mockProvider = createMockProvider(mockError); + + const engine = JsonRpcEngineV2.create({ + middleware: [providerAsMiddlewareV2(mockProvider)], + }); + + const request = createRequest({ + method: 'eth_chainId', + params: [], + }); + + await expect(engine.handle(request)).rejects.toThrow(mockError); + expect(mockProvider.request).toHaveBeenCalledWith(request); + }); +}); diff --git a/packages/eth-json-rpc-middleware/src/providerAsMiddleware.ts b/packages/eth-json-rpc-middleware/src/providerAsMiddleware.ts new file mode 100644 index 00000000000..b2559f6bd0b --- /dev/null +++ b/packages/eth-json-rpc-middleware/src/providerAsMiddleware.ts @@ -0,0 +1,32 @@ +import type { InternalProvider } from '@metamask/eth-json-rpc-provider'; +import { createAsyncMiddleware } from '@metamask/json-rpc-engine'; +import type { JsonRpcMiddleware as LegacyJsonRpcMiddleware } from '@metamask/json-rpc-engine'; +import type { JsonRpcMiddleware } from '@metamask/json-rpc-engine/v2'; +import type { Json, JsonRpcParams, JsonRpcRequest } from '@metamask/utils'; + +/** + * Creates a legacy JSON-RPC middleware that forwards requests to a provider. + * + * @param provider - The provider to forward requests to. + * @returns A legacy JSON-RPC middleware that forwards requests to the provider. + * @deprecated Use {@link providerAsMiddlewareV2} instead. + */ +export function providerAsMiddleware( + provider: InternalProvider, +): LegacyJsonRpcMiddleware { + return createAsyncMiddleware(async (req, res) => { + res.result = await provider.request(req); + }); +} + +/** + * Creates a V2 JSON-RPC middleware that forwards requests to a provider. + * + * @param provider - The provider to forward requests to. + * @returns A V2 JSON-RPC middleware that forwards requests to the provider. + */ +export function providerAsMiddlewareV2( + provider: InternalProvider, +): JsonRpcMiddleware { + return async ({ request }) => provider.request(request); +} diff --git a/packages/eth-json-rpc-middleware/src/retryOnEmpty.test.ts b/packages/eth-json-rpc-middleware/src/retryOnEmpty.test.ts new file mode 100644 index 00000000000..1f4a00f3d0d --- /dev/null +++ b/packages/eth-json-rpc-middleware/src/retryOnEmpty.test.ts @@ -0,0 +1,574 @@ +import { providerErrors, rpcErrors } from '@metamask/rpc-errors'; +import type { Json, JsonRpcParams, JsonRpcRequest } from '@metamask/utils'; + +import type { ProviderRequestStub } from '../test/util/helpers.js'; +import { + createMockParamsWithBlockParamAt, + createMockParamsWithoutBlockParamAt, + createStubForBlockNumberRequest, + expectProviderRequestNotToHaveBeenMade, + requestMatches, + stubProviderRequests, + createProviderAndBlockTracker, + createEngine, + createRequest, + createFinalMiddlewareWithDefaultResult, +} from '../test/util/helpers.js'; +import { createRetryOnEmptyMiddleware } from './index.js'; + +const originalSetTimeout = globalThis.setTimeout; + +describe('createRetryOnEmptyMiddleware', () => { + beforeAll(() => { + jest.useFakeTimers(); + }); + + let provider: ReturnType['provider']; + let blockTracker: ReturnType< + typeof createProviderAndBlockTracker + >['blockTracker']; + + beforeEach(() => { + const providerAndBlockTracker = createProviderAndBlockTracker(); + provider = providerAndBlockTracker.provider; + blockTracker = providerAndBlockTracker.blockTracker; + }); + + afterEach(async () => { + jest.clearAllTimers(); + await blockTracker.destroy(); + }); + + it('throws if not given a provider', () => { + expect(() => createRetryOnEmptyMiddleware()).toThrow( + new Error( + 'RetryOnEmptyMiddleware - mandatory "provider" option is missing.', + ), + ); + }); + + it('throws if not given a block tracker', async () => { + expect(() => createRetryOnEmptyMiddleware({ provider })).toThrow( + new Error( + 'RetryOnEmptyMiddleware - mandatory "blockTracker" option is missing.', + ), + ); + }); + + // This list corresponds to the list in the `blockTagParamIndex` function + // within `cache.ts` + ( + [ + { blockParamIndex: 0, methods: ['eth_getBlockByNumber'] }, + { + blockParamIndex: 1, + methods: [ + 'eth_getBalance', + 'eth_getCode', + 'eth_getTransactionCount', + 'eth_call', + ], + }, + { blockParamIndex: 2, methods: ['eth_getStorageAt'] }, + ] as const + ).forEach(({ blockParamIndex, methods }) => { + methods.forEach((method: string) => { + describe(`${method}`, () => { + it('makes a direct request through the provider, retrying it request up to 10 times and returning the response if it does not have a result of undefined', async () => { + const engine = createEngine( + createRetryOnEmptyMiddleware({ + provider, + blockTracker, + }), + ); + + const blockNumber = '0x0'; + const request = createRequest({ + id: 1, + method, + params: createMockParamsWithBlockParamAt( + blockParamIndex, + blockNumber, + ), + }); + const requestSpy = stubProviderRequests(provider, [ + createStubForBlockNumberRequest(blockNumber), + stubRequestThatFailsThenFinallySucceeds({ + request, + numberOfTimesToFail: 9, + successfulResult: async () => 'something', + }), + ]); + + const resultPromise = engine.handle(request); + await waitForRequestToBeRetried({ + requestSpy, + request, + numberOfTimes: 10, + }); + + expect(await resultPromise).toBe('something'); + }); + + it('returns an error if the request is still unsuccessful after 10 retries', async () => { + const engine = createEngine( + createRetryOnEmptyMiddleware({ + provider, + blockTracker, + }), + ); + + const blockNumber = '0x0'; + const request = createRequest({ + method, + params: createMockParamsWithBlockParamAt( + blockParamIndex, + blockNumber, + ), + }); + const requestSpy = stubProviderRequests(provider, [ + createStubForBlockNumberRequest(blockNumber), + stubGenericRequest({ + request, + result: () => { + throw providerErrors.custom({ code: -1, message: 'oops' }); + }, + remainAfterUse: true, + }), + ]); + + const resultPromise = engine.handle(request); + await waitForRequestToBeRetried({ + requestSpy, + request, + numberOfTimes: 10, + }); + + await expect(resultPromise).rejects.toThrow( + new Error('RetryOnEmptyMiddleware - retries exhausted'), + ); + }); + + it('does not proceed to the next middleware after making a request through the provider', async () => { + const finalMiddleware = createFinalMiddlewareWithDefaultResult(); + + const engine = createEngine( + createRetryOnEmptyMiddleware({ + provider, + blockTracker, + }), + finalMiddleware, + ); + + const blockNumber = '0x0'; + const request = createRequest({ + method, + params: createMockParamsWithBlockParamAt( + blockParamIndex, + blockNumber, + ), + }); + stubProviderRequests(provider, [ + createStubForBlockNumberRequest(blockNumber), + stubGenericRequest({ + request, + result: async () => 'success', + }), + ]); + + await engine.handle(request); + + expect(finalMiddleware).not.toHaveBeenCalled(); + }); + + describe('if the block number in the request params is higher than the latest block number reported by the block tracker', () => { + // Using custom expect helper + // eslint-disable-next-line jest/expect-expect + it('does not make a direct request through the provider', async () => { + const engine = createEngine( + createRetryOnEmptyMiddleware({ + provider, + blockTracker, + }), + ); + + const request = createRequest({ + method, + params: createMockParamsWithBlockParamAt( + blockParamIndex, + '0x100', + ), + }); + const requestSpy = stubProviderRequests(provider, [ + createStubForBlockNumberRequest('0x0'), + ]); + + await engine.handle(request); + + expectProviderRequestNotToHaveBeenMade(requestSpy, request); + }); + + it('proceeds to the next middleware', async () => { + const finalMiddleware = createFinalMiddlewareWithDefaultResult(); + + const engine = createEngine( + createRetryOnEmptyMiddleware({ + provider, + blockTracker, + }), + finalMiddleware, + ); + + const request = createRequest({ + method, + params: createMockParamsWithBlockParamAt( + blockParamIndex, + '0x100', + ), + }); + stubProviderRequests(provider, [ + createStubForBlockNumberRequest('0x0'), + ]); + + await engine.handle(request); + + expect(finalMiddleware).toHaveBeenCalled(); + }); + }); + + describe.each(['1', 'earliest', 'asdlsdfls'])( + 'if the block parameter is not a 0x-prefixed hex number such as %o', + (blockParam) => { + // Using custom expect helper + // eslint-disable-next-line jest/expect-expect + it('does not make a direct request through the provider', async () => { + const engine = createEngine( + createRetryOnEmptyMiddleware({ + provider, + blockTracker, + }), + ); + + const request = createRequest({ + method, + params: createMockParamsWithBlockParamAt( + blockParamIndex, + blockParam, + ), + }); + const requestSpy = stubProviderRequests(provider, [ + createStubForBlockNumberRequest('0x0'), + ]); + + await engine.handle(request); + + expectProviderRequestNotToHaveBeenMade(requestSpy, request); + }); + + it('proceeds to the next middleware', async () => { + const finalMiddleware = createFinalMiddlewareWithDefaultResult(); + + const engine = createEngine( + createRetryOnEmptyMiddleware({ + provider, + blockTracker, + }), + finalMiddleware, + ); + + const request = createRequest({ + method, + params: createMockParamsWithBlockParamAt( + blockParamIndex, + blockParam, + ), + }); + stubProviderRequests(provider, [ + createStubForBlockNumberRequest('0x0'), + ]); + + await engine.handle(request); + + expect(finalMiddleware).toHaveBeenCalled(); + }); + }, + ); + + describe.each(['latest', 'pending'])( + 'if the block parameter is %o', + (blockParam) => { + // Using custom expect helper + // eslint-disable-next-line jest/expect-expect + it('does not make a direct request through the provider', async () => { + const engine = createEngine( + createRetryOnEmptyMiddleware({ + provider, + blockTracker, + }), + ); + + const request = createRequest({ + method, + params: createMockParamsWithBlockParamAt( + blockParamIndex, + blockParam, + ), + }); + const requestSpy = stubProviderRequests(provider, [ + createStubForBlockNumberRequest(), + ]); + + await engine.handle(request); + + expectProviderRequestNotToHaveBeenMade(requestSpy, request); + }); + + it('proceeds to the next middleware', async () => { + const finalMiddleware = createFinalMiddlewareWithDefaultResult(); + + const engine = createEngine( + createRetryOnEmptyMiddleware({ + provider, + blockTracker, + }), + finalMiddleware, + ); + + const request: JsonRpcRequest = { + id: 1, + jsonrpc: '2.0', + method, + params: createMockParamsWithBlockParamAt( + blockParamIndex, + blockParam, + ), + }; + stubProviderRequests(provider, [ + createStubForBlockNumberRequest(), + ]); + + await engine.handle(request); + + expect(finalMiddleware).toHaveBeenCalled(); + }); + }, + ); + + describe('if no block parameter is given', () => { + // Using custom expect helper + // eslint-disable-next-line jest/expect-expect + it('does not make a direct request through the provider', async () => { + const engine = createEngine( + createRetryOnEmptyMiddleware({ + provider, + blockTracker, + }), + ); + + const request = createRequest({ + method, + params: createMockParamsWithoutBlockParamAt(blockParamIndex), + }); + const requestSpy = stubProviderRequests(provider, [ + createStubForBlockNumberRequest(), + ]); + + await engine.handle(request); + + expectProviderRequestNotToHaveBeenMade(requestSpy, request); + }); + + it('proceeds to the next middleware', async () => { + const finalMiddleware = createFinalMiddlewareWithDefaultResult(); + + const engine = createEngine( + createRetryOnEmptyMiddleware({ + provider, + blockTracker, + }), + finalMiddleware, + ); + + const request = createRequest({ + method, + params: createMockParamsWithoutBlockParamAt(blockParamIndex), + }); + stubProviderRequests(provider, [createStubForBlockNumberRequest()]); + + await engine.handle(request); + + expect(finalMiddleware).toHaveBeenCalled(); + }); + }); + }); + }); + }); + + describe('a method that does not take a block parameter', () => { + // Using custom expect helper + // eslint-disable-next-line jest/expect-expect + it('does not make a direct request through the provider', async () => { + const engine = createEngine( + createRetryOnEmptyMiddleware({ + provider, + blockTracker, + }), + ); + + const method = 'a_non_block_param_method'; + const request = createRequest({ method }); + const requestSpy = stubProviderRequests(provider, [ + createStubForBlockNumberRequest(), + ]); + + await engine.handle(request); + + expectProviderRequestNotToHaveBeenMade(requestSpy, request); + }); + + it('proceeds to the next middleware', async () => { + const finalMiddleware = createFinalMiddlewareWithDefaultResult(); + + const engine = createEngine( + createRetryOnEmptyMiddleware({ + provider, + blockTracker, + }), + finalMiddleware, + ); + + const method = 'a_non_block_param_method'; + const request = createRequest({ method }); + + await engine.handle(request); + + expect(finalMiddleware).toHaveBeenCalled(); + }); + }); + + describe('when provider return execution revert error', () => { + it('returns the same error to caller', async () => { + const engine = createEngine( + createRetryOnEmptyMiddleware({ + provider, + blockTracker, + }), + ); + + const request = createRequest({ + method: 'eth_call', + params: createMockParamsWithBlockParamAt(1, '100'), + }); + stubProviderRequests(provider, [ + createStubForBlockNumberRequest(), + { + request, + result: (): never => { + throw rpcErrors.invalidInput('execution reverted'); + }, + }, + ]); + + const resultPromise = engine.handle(request); + await expect(resultPromise).rejects.toThrow( + rpcErrors.invalidInput('execution reverted'), + ); + }); + }); +}); + +/** + * Creates a canned result for a request made to `provider.request`. Intended + * to be used in conjunction with `stubProviderRequests`. Although not strictly + * necessary, it helps to assign a proper type to a request/result pair. + * + * @param requestStub - The request/response pair. + * @returns The request/response pair, properly typed. + */ +function stubGenericRequest( + requestStub: ProviderRequestStub, +): ProviderRequestStub { + return requestStub; +} + +/** + * Creates a canned result for a request made to `provider.request` which + * will error for the first N instances and then succeed on the last instance. + * Intended to be used in conjunction with `stubProviderRequests`. + * + * @param args - The arguments. + * @param args.request - The request matcher for the stub. + * @param args.numberOfTimesToFail - The number of times the request is expected to + * be called until it returns a successful result. + * @param args.successfulResult - The result that `provider.request` will + * return when called past `numberOfTimesToFail`. + * @returns The request/result pair, properly typed. + */ +function stubRequestThatFailsThenFinallySucceeds< + Params extends JsonRpcParams, + Result extends Json, +>({ + request, + numberOfTimesToFail, + successfulResult, +}: { + request: ProviderRequestStub['request']; + numberOfTimesToFail: number; + successfulResult: ProviderRequestStub['result']; +}): ProviderRequestStub { + return stubGenericRequest({ + request, + result: async (callNumber) => { + if (callNumber <= numberOfTimesToFail) { + throw providerErrors.custom({ code: -1, message: 'oops' }); + } + + return await successfulResult(callNumber); + }, + remainAfterUse: true, + }); +} + +/** + * The `retryOnEmpty` middleware, as its name implies, uses the provider to make + * the given request, retrying said request up to 10 times if the result is + * empty before failing. Upon retrying, it will wait a brief time using + * `setTimeout`. Because we are using Jest's fake timers, we have to manually + * trigger the callback passed to `setTimeout` atfter it is called. The problem + * is that we don't know when `setTimeout` will be called while the + * `retryOnEmpty` middleware is running, so we have to wait. We do this by + * recording how many times `provider.request` has been called with the + * request, and when that number goes up, we assume that `setTimeout` has been + * called too and advance through time. We stop the loop when + * `provider.request` has been called the given number of times. + * + * @param args - The arguments. + * @param args.requestSpy - The Jest spy object that represents + * `provider.request`. + * @param args.request - The request object. + * @param args.numberOfTimes - The number of times that we expect + * `provider.request` to be called with `request`. + */ +async function waitForRequestToBeRetried({ + requestSpy, + request, + numberOfTimes, +}: { + requestSpy: jest.SpyInstance; + request: JsonRpcRequest; + numberOfTimes: number; +}): Promise { + let iterationNumber = 1; + + while (iterationNumber <= numberOfTimes) { + await new Promise((resolve) => originalSetTimeout(resolve, 0)); + + if ( + requestSpy.mock.calls.filter((args) => requestMatches(args[0], request)) + .length === iterationNumber + ) { + jest.runAllTimers(); + iterationNumber += 1; + } + } +} diff --git a/packages/eth-json-rpc-middleware/src/retryOnEmpty.ts b/packages/eth-json-rpc-middleware/src/retryOnEmpty.ts new file mode 100644 index 00000000000..d13d528dd7b --- /dev/null +++ b/packages/eth-json-rpc-middleware/src/retryOnEmpty.ts @@ -0,0 +1,148 @@ +import type { PollingBlockTracker } from '@metamask/eth-block-tracker'; +import type { InternalProvider } from '@metamask/eth-json-rpc-provider'; +import type { JsonRpcMiddleware } from '@metamask/json-rpc-engine/v2'; +import type { Json, JsonRpcParams, JsonRpcRequest } from '@metamask/utils'; +import { klona } from 'klona'; + +import { projectLogger, createModuleLogger } from './logging-utils.js'; +import type { Block } from './types.js'; +import { blockTagParamIndex } from './utils/cache.js'; +import { isExecutionRevertedError } from './utils/error.js'; +import { timeout } from './utils/timeout.js'; + +// +// RetryOnEmptyMiddleware will retry any request with an empty response that has +// a numbered block reference at or lower than the blockTracker's latest block. +// Its useful for dealing with load-balanced ethereum JSON RPC +// nodes that are not always in sync with each other. +// + +const log = createModuleLogger(projectLogger, 'retry-on-empty'); +// empty values used to determine if a request should be retried +// `` comes from https://github.com/ethereum/go-ethereum/issues/16925 +const emptyValues = [null, '\u003cnil\u003e']; + +/** + * Creates a middleware that retries requests with empty responses. + * + * @param options - The options for the middleware. + * @param options.provider - The provider to use. + * @param options.blockTracker - The block tracker to use. + * @returns The middleware. + */ +export function createRetryOnEmptyMiddleware({ + provider, + blockTracker, +}: { + provider?: InternalProvider; + blockTracker?: PollingBlockTracker; +} = {}): JsonRpcMiddleware { + if (!provider) { + throw Error( + 'RetryOnEmptyMiddleware - mandatory "provider" option is missing.', + ); + } + + if (!blockTracker) { + throw Error( + 'RetryOnEmptyMiddleware - mandatory "blockTracker" option is missing.', + ); + } + + return async ({ request, next }) => { + const blockRefIndex: number | undefined = blockTagParamIndex( + request.method, + ); + // skip if method does not include blockRef + if (blockRefIndex === undefined) { + return next(); + } + // skip if not exact block references + let blockRef: string | undefined = + Array.isArray(request.params) && request.params[blockRefIndex] + ? (request.params[blockRefIndex] as string) + : undefined; + // omitted blockRef implies "latest" + blockRef ??= 'latest'; + + // skip if non-number block reference + if (['latest', 'pending'].includes(blockRef)) { + return next(); + } + // skip if block refernce is not a valid number + const blockRefNumber: number = Number.parseInt(blockRef.slice(2), 16); + if (Number.isNaN(blockRefNumber)) { + return next(); + } + // lookup latest block + const latestBlockNumberHex: string = await blockTracker.getLatestBlock(); + const latestBlockNumber: number = Number.parseInt( + latestBlockNumberHex.slice(2), + 16, + ); + // skip if request block number is higher than current + if (blockRefNumber > latestBlockNumber) { + log( + 'Requested block number %o is higher than latest block number %o, falling through to original request', + blockRefNumber, + latestBlockNumber, + ); + return next(); + } + + log( + 'Requested block number %o is not higher than latest block number %o, trying request until non-empty response is received', + blockRefNumber, + latestBlockNumber, + ); + + // create child request with specific block-ref + const childRequest = klona(request); + // attempt child request until non-empty response is received + const childResult = await retry(10, async () => { + log('Performing request %o', childRequest); + const attemptResult = await provider.request( + childRequest, + ); + log('Result is %o', attemptResult); + // verify result + const allEmptyValues: unknown[] = emptyValues; + if (allEmptyValues.includes(attemptResult)) { + throw new Error( + `RetryOnEmptyMiddleware - empty result "${JSON.stringify( + attemptResult, + )}" for request "${JSON.stringify(childRequest)}"`, + ); + } + return attemptResult; + }); + log('Copying result %o', childResult); + return childResult; + }; +} + +/** + * Retries an asynchronous function up to a maximum number of times. + * + * @param maxRetries - The maximum number of retries. + * @param asyncFn - The asynchronous function to retry. + * @returns The result of the asynchronous function. + */ +async function retry( + maxRetries: number, + asyncFn: () => Promise, +): Promise { + for (let index = 0; index < maxRetries; index++) { + try { + return await asyncFn(); + } catch (error: unknown) { + if (isExecutionRevertedError(error)) { + throw error as unknown; + } + log('(call %i) Request failed, waiting 1s to retry again...', index + 1); + await timeout(1000); + } + } + log('Retries exhausted'); + throw new Error('RetryOnEmptyMiddleware - retries exhausted'); +} diff --git a/packages/eth-json-rpc-middleware/src/types.ts b/packages/eth-json-rpc-middleware/src/types.ts new file mode 100644 index 00000000000..2c992c8720c --- /dev/null +++ b/packages/eth-json-rpc-middleware/src/types.ts @@ -0,0 +1,32 @@ +import type { + Json, + JsonRpcParams, + JsonRpcRequest, + JsonRpcResponse, +} from '@metamask/utils'; + +export type BlockData = string | string[]; + +export type Block = Record; + +export type BlockCache = Record; + +export type Cache = Record; + +/** + * A copy of the `AbstractRpcService` type in metamask/network-controller`, but + * keeping only the `request` method. + * + * We cannot get `AbstractRpcService` directly from + * `@metamask/network-controller` because relying on this package would create a + * circular dependency. + * + * This type should be accurate as of `@metamask/network-controller` 24.x and + * `@metamask/utils` 11.x. + */ +export type AbstractRpcServiceLike = { + request: ( + jsonRpcRequest: JsonRpcRequest, + fetchOptions?: RequestInit, + ) => Promise>; +}; diff --git a/packages/eth-json-rpc-middleware/src/utils/cache.test.ts b/packages/eth-json-rpc-middleware/src/utils/cache.test.ts new file mode 100644 index 00000000000..57f83d7d3b7 --- /dev/null +++ b/packages/eth-json-rpc-middleware/src/utils/cache.test.ts @@ -0,0 +1,618 @@ +import { + blockTagForRequest, + blockTagParamIndex, + cacheTypeForMethod, + cacheIdentifierForRequest, + canCache, +} from './cache.js'; + +const knownMethods = [ + 'web3_clientVersion', + 'web3_sha3', + 'eth_protocolVersion', + 'eth_getBlockTransactionCountByHash', + 'eth_getUncleCountByBlockHash', + 'eth_getCode', + 'eth_getBlockByHash', + 'eth_getTransactionByHash', + 'eth_getTransactionByBlockHashAndIndex', + 'eth_getTransactionReceipt', + 'eth_getUncleByBlockHashAndIndex', + 'eth_getCompilers', + 'eth_compileLLL', + 'eth_compileSolidity', + 'eth_compileSerpent', + 'shh_version', + 'test_permaCache', + 'eth_getBlockByNumber', + 'eth_getBlockTransactionCountByNumber', + 'eth_getUncleCountByBlockNumber', + 'eth_getTransactionByBlockNumberAndIndex', + 'eth_getUncleByBlockNumberAndIndex', + 'test_forkCache', + 'eth_gasPrice', + 'eth_blockNumber', + 'eth_getBalance', + 'eth_getStorageAt', + 'eth_getTransactionCount', + 'eth_call', + 'eth_estimateGas', + 'eth_getFilterLogs', + 'eth_getLogs', + 'test_blockCache', +]; + +describe('cache utils', () => { + describe('cacheIdentifierForRequest', () => { + it('returns null for an unrecognized method', () => { + const identifier = cacheIdentifierForRequest({ + id: 1, + jsonrpc: '2.0', + method: 'this_method_does_not_exist', + params: [], + }); + + expect(identifier).toBeNull(); + }); + + describe('skipBlockRef disabled', () => { + it('returns cache identifier for request with no params property', () => { + const identifier = cacheIdentifierForRequest({ + id: 1, + jsonrpc: '2.0', + method: 'eth_gasPrice', + }); + + expect(identifier).toMatchInlineSnapshot(`"eth_gasPrice:[]"`); + }); + + it('returns cache identifier for request with empty parameters', () => { + const identifier = cacheIdentifierForRequest({ + id: 1, + jsonrpc: '2.0', + method: 'eth_gasPrice', + params: [], + }); + + expect(identifier).toMatchInlineSnapshot(`"eth_gasPrice:[]"`); + }); + + describe('array parameters', () => { + it('returns cache identifier for request that does not accept any block parameter', () => { + const identifier = cacheIdentifierForRequest({ + id: 1, + jsonrpc: '2.0', + method: 'eth_getBlockByHash', + params: ['0x0000000000000000000000000000000000000000'], + }); + + expect(identifier).toMatchInlineSnapshot( + `"eth_getBlockByHash:["0x0000000000000000000000000000000000000000"]"`, + ); + }); + + it('returns cache identifier for request that includes a block parameter first', () => { + const identifier = cacheIdentifierForRequest({ + id: 1, + jsonrpc: '2.0', + method: 'eth_getBlockByNumber', + params: ['latest'], + }); + + expect(identifier).toMatchInlineSnapshot( + `"eth_getBlockByNumber:["latest"]"`, + ); + }); + + it('returns cache identifier for request that includes a block parameter first with some additional parameter', () => { + const identifier = cacheIdentifierForRequest({ + id: 1, + jsonrpc: '2.0', + method: 'eth_getBlockByNumber', + params: ['latest', true], + }); + + expect(identifier).toMatchInlineSnapshot( + `"eth_getBlockByNumber:["latest",true]"`, + ); + }); + + it('returns cache identifier for request that includes a block parameter last after some additional parameter', () => { + const identifier = cacheIdentifierForRequest( + { + id: 1, + jsonrpc: '2.0', + method: 'eth_getBalance', + params: ['0x0000000000000000000000000000000000000000', 'latest'], + }, + true, + ); + + expect(identifier).toMatchInlineSnapshot( + `"eth_getBalance:["0x0000000000000000000000000000000000000000"]"`, + ); + }); + + it('returns cache identifier for request with a missing block parameter', () => { + const identifier = cacheIdentifierForRequest({ + id: 1, + jsonrpc: '2.0', + method: 'eth_getCode', + params: ['0x0000000000000000000000000000000000000000'], + }); + + expect(identifier).toMatchInlineSnapshot( + `"eth_getCode:["0x0000000000000000000000000000000000000000"]"`, + ); + }); + }); + + describe('object parameters', () => { + it('returns cache identifier for request that does not accept any block parameter', () => { + const identifier = cacheIdentifierForRequest({ + id: 1, + jsonrpc: '2.0', + method: 'eth_getBlockByHash', + // Note that this is not a valid request, this is just to test how this middleware handles object params. + params: { hash: '0x0000000000000000000000000000000000000000' }, + }); + + expect(identifier).toMatchInlineSnapshot( + `"eth_getBlockByHash:{"hash":"0x0000000000000000000000000000000000000000"}"`, + ); + }); + + it('returns cache identifier for request that includes a block parameter first', () => { + const identifier = cacheIdentifierForRequest({ + id: 1, + jsonrpc: '2.0', + method: 'eth_getBlockByNumber', + // Note that this is not a valid request, this is just to test how this middleware handles object params. + params: { block: 'latest' }, + }); + + expect(identifier).toMatchInlineSnapshot( + `"eth_getBlockByNumber:{"block":"latest"}"`, + ); + }); + + it('returns cache identifier for request that includes a block parameter first with some additional parameter', () => { + const identifier = cacheIdentifierForRequest({ + id: 1, + jsonrpc: '2.0', + method: 'eth_getBlockByNumber', + // Note that this is not a valid request, this is just to test how this middleware handles object params. + params: { block: 'latest', showTransactionDetails: true }, + }); + + expect(identifier).toMatchInlineSnapshot( + `"eth_getBlockByNumber:{"block":"latest","showTransactionDetails":true}"`, + ); + }); + + it('returns cache identifier for request that includes a block parameter last after some additional parameter', () => { + const identifier = cacheIdentifierForRequest({ + id: 1, + jsonrpc: '2.0', + method: 'eth_getBalance', + // Note that this is not a valid request, this is just to test how this middleware handles object params. + params: { + address: '0x0000000000000000000000000000000000000000', + block: 'latest', + }, + }); + + expect(identifier).toMatchInlineSnapshot( + `"eth_getBalance:{"address":"0x0000000000000000000000000000000000000000","block":"latest"}"`, + ); + }); + + it('returns cache identifier for request with a missing block parameter', () => { + const identifier = cacheIdentifierForRequest({ + id: 1, + jsonrpc: '2.0', + method: 'eth_getCode', + // Note that this is not a valid request, this is just to test how this middleware handles object params. + params: { data: '0x0000000000000000000000000000000000000000' }, + }); + + expect(identifier).toMatchInlineSnapshot( + `"eth_getCode:{"data":"0x0000000000000000000000000000000000000000"}"`, + ); + }); + }); + }); + + describe('skipBlockRef enabled', () => { + it('returns cache identifier for request with no params property', () => { + const identifier = cacheIdentifierForRequest( + { + id: 1, + jsonrpc: '2.0', + method: 'eth_gasPrice', + }, + true, + ); + + expect(identifier).toMatchInlineSnapshot(`"eth_gasPrice:[]"`); + }); + + it('returns cache identifier for request with empty parameters', () => { + const identifier = cacheIdentifierForRequest( + { + id: 1, + jsonrpc: '2.0', + method: 'eth_gasPrice', + params: [], + }, + true, + ); + + expect(identifier).toMatchInlineSnapshot(`"eth_gasPrice:[]"`); + }); + + describe('array parameters', () => { + it('returns cache identifier for request that does not accept any block parameter', () => { + const identifier = cacheIdentifierForRequest( + { + id: 1, + jsonrpc: '2.0', + method: 'eth_getBlockByHash', + params: ['0x0000000000000000000000000000000000000000'], + }, + true, + ); + + expect(identifier).toMatchInlineSnapshot( + `"eth_getBlockByHash:["0x0000000000000000000000000000000000000000"]"`, + ); + }); + + it('returns cache identifier for request that includes a block parameter first', () => { + const identifier = cacheIdentifierForRequest( + { + id: 1, + jsonrpc: '2.0', + method: 'eth_getBlockByNumber', + params: ['latest'], + }, + true, + ); + + expect(identifier).toMatchInlineSnapshot(`"eth_getBlockByNumber:[]"`); + }); + + it('returns cache identifier for request that includes a block parameter first with some additional parameter', () => { + const identifier = cacheIdentifierForRequest( + { + id: 1, + jsonrpc: '2.0', + method: 'eth_getBlockByNumber', + params: ['latest', true], + }, + true, + ); + + expect(identifier).toMatchInlineSnapshot( + `"eth_getBlockByNumber:[true]"`, + ); + }); + + it('returns cache identifier for request that includes a block parameter last after some additional parameter', () => { + const identifier = cacheIdentifierForRequest( + { + id: 1, + jsonrpc: '2.0', + method: 'eth_getBalance', + params: ['0x0000000000000000000000000000000000000000', 'latest'], + }, + true, + ); + + expect(identifier).toMatchInlineSnapshot( + `"eth_getBalance:["0x0000000000000000000000000000000000000000"]"`, + ); + }); + + it('returns cache identifier for request with a missing block parameter', () => { + const identifier = cacheIdentifierForRequest( + { + id: 1, + jsonrpc: '2.0', + method: 'eth_getCode', + params: ['0x0000000000000000000000000000000000000000'], + }, + true, + ); + + expect(identifier).toMatchInlineSnapshot( + `"eth_getCode:["0x0000000000000000000000000000000000000000"]"`, + ); + }); + }); + + describe('object parameters', () => { + it('returns cache identifier for request that does not accept any block parameter', () => { + const identifier = cacheIdentifierForRequest( + { + id: 1, + jsonrpc: '2.0', + method: 'eth_getBlockByHash', + // Note that this is not a valid request, this is just to test how this middleware handles object params. + params: { hash: '0x0000000000000000000000000000000000000000' }, + }, + true, + ); + + expect(identifier).toMatchInlineSnapshot( + `"eth_getBlockByHash:{"hash":"0x0000000000000000000000000000000000000000"}"`, + ); + }); + + it('returns cache identifier for request that includes a block parameter first', () => { + const identifier = cacheIdentifierForRequest( + { + id: 1, + jsonrpc: '2.0', + method: 'eth_getBlockByNumber', + // Note that this is not a valid request, this is just to test how this middleware handles object params. + params: { block: 'latest' }, + }, + true, + ); + + expect(identifier).toMatchInlineSnapshot( + `"eth_getBlockByNumber:{"block":"latest"}"`, + ); + }); + + it('returns cache identifier for request that includes a block parameter first with some additional parameter', () => { + const identifier = cacheIdentifierForRequest( + { + id: 1, + jsonrpc: '2.0', + method: 'eth_getBlockByNumber', + // Note that this is not a valid request, this is just to test how this middleware handles object params. + params: { block: 'latest', showTransactionDetails: true }, + }, + true, + ); + + expect(identifier).toMatchInlineSnapshot( + `"eth_getBlockByNumber:{"block":"latest","showTransactionDetails":true}"`, + ); + }); + + it('returns cache identifier for request that includes a block parameter last after some additional parameter', () => { + const identifier = cacheIdentifierForRequest( + { + id: 1, + jsonrpc: '2.0', + method: 'eth_getBalance', + // Note that this is not a valid request, this is just to test how this middleware handles object params. + params: { + address: '0x0000000000000000000000000000000000000000', + block: 'latest', + }, + }, + true, + ); + + expect(identifier).toMatchInlineSnapshot( + `"eth_getBalance:{"address":"0x0000000000000000000000000000000000000000","block":"latest"}"`, + ); + }); + + it('returns cache identifier for request with a missing block parameter', () => { + const identifier = cacheIdentifierForRequest( + { + id: 1, + jsonrpc: '2.0', + method: 'eth_getCode', + // Note that this is not a valid request, this is just to test how this middleware handles object params. + params: { data: '0x0000000000000000000000000000000000000000' }, + }, + true, + ); + + expect(identifier).toMatchInlineSnapshot( + `"eth_getCode:{"data":"0x0000000000000000000000000000000000000000"}"`, + ); + }); + }); + }); + }); + + describe('canCache', () => { + for (const method of knownMethods) { + it(`should be able to cache '${method}'`, () => { + expect(canCache(method)).toBe(true); + }); + } + + it('should not be able to cache an unknown method', () => { + expect(canCache('this_method_does_not_exist')).toBe(false); + }); + }); + + describe('blockTagForRequest', () => { + it('should return undefined for a request with no parameters', () => { + const blockTag = blockTagForRequest({ + id: 1, + jsonrpc: '2.0', + method: 'eth_getBlockByNumber', + }); + + expect(blockTag).toBeUndefined(); + }); + + it('should return undefined for an unrecognized method', () => { + const blockTag = blockTagForRequest({ + id: 1, + jsonrpc: '2.0', + method: 'this_method_does_not_exist', + params: ['latest'], + }); + + expect(blockTag).toBeUndefined(); + }); + + it('should return undefined for a method with no block parameter', () => { + const blockTag = blockTagForRequest({ + id: 1, + jsonrpc: '2.0', + method: 'eth_gasPrice', + params: ['latest'], + }); + + expect(blockTag).toBeUndefined(); + }); + + it('should return undefined for a request that has object parameters', () => { + const blockTag = blockTagForRequest({ + id: 1, + jsonrpc: '2.0', + // `eth_getBlockByNumber` chosen because it is recognized as having a block parameter, at + // index 0. It's not a realistic test of this behavior because it doesn't accept params as + // an object, but none of the methods supported by this middleware do. + method: 'eth_getBlockByNumber', + params: { block: 'latest' }, + }); + + expect(blockTag).toBeUndefined(); + }); + + it('should return undefined for a request where the block parameter is missing', () => { + const blockTag = blockTagForRequest({ + id: 1, + jsonrpc: '2.0', + method: 'eth_getTransactionCount', + params: ['0x0000000000000000000000000000000000000000'], + }); + + expect(blockTag).toBeUndefined(); + }); + + it('should return the block parameter', () => { + const blockTag = blockTagForRequest({ + id: 1, + jsonrpc: '2.0', + method: 'eth_getTransactionCount', + params: ['0x0000000000000000000000000000000000000000', 'latest'], + }); + + expect(blockTag).toBe('latest'); + }); + }); + + describe('blockTagParamIndex', () => { + it(`should return expected block index for each known method`, () => { + const blockTagIndexes = knownMethods.reduce< + Record + >((indexes, method) => { + indexes[method] = blockTagParamIndex(method); + return indexes; + }, {}); + + expect(blockTagIndexes).toMatchInlineSnapshot(` + { + "eth_blockNumber": undefined, + "eth_call": 1, + "eth_compileLLL": undefined, + "eth_compileSerpent": undefined, + "eth_compileSolidity": undefined, + "eth_estimateGas": undefined, + "eth_gasPrice": undefined, + "eth_getBalance": 1, + "eth_getBlockByHash": undefined, + "eth_getBlockByNumber": 0, + "eth_getBlockTransactionCountByHash": undefined, + "eth_getBlockTransactionCountByNumber": undefined, + "eth_getCode": 1, + "eth_getCompilers": undefined, + "eth_getFilterLogs": undefined, + "eth_getLogs": undefined, + "eth_getStorageAt": 2, + "eth_getTransactionByBlockHashAndIndex": undefined, + "eth_getTransactionByBlockNumberAndIndex": undefined, + "eth_getTransactionByHash": undefined, + "eth_getTransactionCount": 1, + "eth_getTransactionReceipt": undefined, + "eth_getUncleByBlockHashAndIndex": undefined, + "eth_getUncleByBlockNumberAndIndex": undefined, + "eth_getUncleCountByBlockHash": undefined, + "eth_getUncleCountByBlockNumber": undefined, + "eth_protocolVersion": undefined, + "shh_version": undefined, + "test_blockCache": undefined, + "test_forkCache": undefined, + "test_permaCache": undefined, + "web3_clientVersion": undefined, + "web3_sha3": undefined, + } + `); + }); + + it('should return "undefined" for an unrecognized method', () => { + const index = blockTagParamIndex('this_method_does_not_exist'); + + expect(index).toBeUndefined(); + }); + }); + + describe('cacheTypeForMethod', () => { + it(`should return expected cache type for each known method`, () => { + const cacheTypes = knownMethods.reduce>( + (types, method) => { + types[method] = cacheTypeForMethod(method); + return types; + }, + {}, + ); + + expect(cacheTypes).toMatchInlineSnapshot(` + { + "eth_blockNumber": "block", + "eth_call": "block", + "eth_compileLLL": "perma", + "eth_compileSerpent": "perma", + "eth_compileSolidity": "perma", + "eth_estimateGas": "block", + "eth_gasPrice": "block", + "eth_getBalance": "block", + "eth_getBlockByHash": "perma", + "eth_getBlockByNumber": "fork", + "eth_getBlockTransactionCountByHash": "perma", + "eth_getBlockTransactionCountByNumber": "fork", + "eth_getCode": "perma", + "eth_getCompilers": "perma", + "eth_getFilterLogs": "block", + "eth_getLogs": "block", + "eth_getStorageAt": "block", + "eth_getTransactionByBlockHashAndIndex": "perma", + "eth_getTransactionByBlockNumberAndIndex": "fork", + "eth_getTransactionByHash": "perma", + "eth_getTransactionCount": "block", + "eth_getTransactionReceipt": "perma", + "eth_getUncleByBlockHashAndIndex": "perma", + "eth_getUncleByBlockNumberAndIndex": "fork", + "eth_getUncleCountByBlockHash": "perma", + "eth_getUncleCountByBlockNumber": "fork", + "eth_protocolVersion": "perma", + "shh_version": "perma", + "test_blockCache": "block", + "test_forkCache": "fork", + "test_permaCache": "perma", + "web3_clientVersion": "perma", + "web3_sha3": "perma", + } + `); + }); + + it('should return "never" for an unrecognized method', () => { + const index = cacheTypeForMethod('this_method_does_not_exist'); + + expect(index).toBe('never'); + }); + }); +}); diff --git a/packages/eth-json-rpc-middleware/src/utils/cache.ts b/packages/eth-json-rpc-middleware/src/utils/cache.ts new file mode 100644 index 00000000000..eca7b0640ee --- /dev/null +++ b/packages/eth-json-rpc-middleware/src/utils/cache.ts @@ -0,0 +1,199 @@ +import type { Json, JsonRpcRequest } from '@metamask/utils'; +import { configure } from 'safe-stable-stringify'; + +const stringify = configure({ bigint: false, circularValue: Error }); + +/** + * The cache strategy to use for a given method. + */ +export enum CacheStrategy { + /** + * Cache per-block. + */ + Block = 'block', + /** + * Cache until a chain reorganization occurs. + */ + Fork = 'fork', + /** + * Never cache. + */ + Never = 'never', + /** + * Permanently cache. + */ + Permanent = 'perma', +} + +/** + * Return a cache identifier for the given request. + * + * This identifier should include any request details that might impact the + * response, with the exception of the block parameter if the `skipBlockRef` + * option is set, + * + * If the request cannot be cached, this will return `null`. + * + * @param request - The JSON-RPC request. + * @param skipBlockRef - Skip the block parameter when generating the cache + * identifier. + * @returns The cache identifier for this request, or `null` if it can't be + * cached. + */ +export function cacheIdentifierForRequest( + request: JsonRpcRequest, + skipBlockRef?: boolean, +): string | null { + const simpleParams = skipBlockRef + ? paramsWithoutBlockTag(request) + : (request.params ?? []); + if (canCache(request.method)) { + return `${request.method}:${stringify(simpleParams)}`; + } + return null; +} + +/** + * Return whether a method can be cached or not. + * + * @param method - The method to check. + * @returns Whether the method can be cached. + */ +export function canCache(method: string): boolean { + return cacheTypeForMethod(method) !== CacheStrategy.Never; +} + +/** + * Return the block parameter for the given request, if it has one. + * + * @param request - The JSON-RPC request. + * @returns The block parameter in the given request, or `undefined` if none was found. + */ +export function blockTagForRequest(request: JsonRpcRequest): Json | undefined { + if (!request.params) { + return undefined; + } + const index: number | undefined = blockTagParamIndex(request.method); + + // Block tag param not passed. + if ( + index === undefined || + !Array.isArray(request.params) || + index >= request.params.length + ) { + return undefined; + } + + return request.params[index]; +} + +/** + * Return the request parameters without the block parameter. + * + * @param request - The JSON-RPC request. + * @returns The request parameters with the block parameter removed, if one was found. + */ +function paramsWithoutBlockTag(request: JsonRpcRequest): Json { + if (!request.params) { + return []; + } + const index: number | undefined = blockTagParamIndex(request.method); + + // Block tag param not passed. + if ( + index === undefined || + !Array.isArray(request.params) || + index >= request.params.length + ) { + return request.params; + } + + // eth_getBlockByNumber has the block tag first, then the optional includeTx? param + if (request.method === 'eth_getBlockByNumber') { + return request.params.slice(1); + } + return request.params.slice(0, index); +} + +/** + * Returns the index of the block parameter for the given method. + * + * @param method - A JSON-RPC method. + * @returns The index of the block parameter for that method, or `undefined` if + * there is no known block parameter. + */ +export function blockTagParamIndex(method: string): number | undefined { + switch (method) { + // blockTag is at index 2 + case 'eth_getStorageAt': + return 2; + // blockTag is at index 1 + case 'eth_getBalance': + case 'eth_getCode': + case 'eth_getTransactionCount': + case 'eth_call': + return 1; + // blockTag is at index 0 + case 'eth_getBlockByNumber': + return 0; + // there is no blockTag + default: + return undefined; + } +} + +/** + * Return the cache type used for the given method. + * + * @param method - A JSON-RPC method. + * @returns The cache type to use for that method. + */ +export function cacheTypeForMethod(method: string): CacheStrategy { + switch (method) { + // cache permanently + case 'web3_clientVersion': + case 'web3_sha3': + case 'eth_protocolVersion': + case 'eth_getBlockTransactionCountByHash': + case 'eth_getUncleCountByBlockHash': + case 'eth_getCode': + case 'eth_getBlockByHash': + case 'eth_getTransactionByHash': + case 'eth_getTransactionByBlockHashAndIndex': + case 'eth_getTransactionReceipt': + case 'eth_getUncleByBlockHashAndIndex': + case 'eth_getCompilers': + case 'eth_compileLLL': + case 'eth_compileSolidity': + case 'eth_compileSerpent': + case 'shh_version': + case 'test_permaCache': + return CacheStrategy.Permanent; + + // cache until fork + case 'eth_getBlockByNumber': + case 'eth_getBlockTransactionCountByNumber': + case 'eth_getUncleCountByBlockNumber': + case 'eth_getTransactionByBlockNumberAndIndex': + case 'eth_getUncleByBlockNumberAndIndex': + case 'test_forkCache': + return CacheStrategy.Fork; + + // cache for block + case 'eth_gasPrice': + case 'eth_blockNumber': + case 'eth_getBalance': + case 'eth_getStorageAt': + case 'eth_getTransactionCount': + case 'eth_call': + case 'eth_estimateGas': + case 'eth_getFilterLogs': + case 'eth_getLogs': + case 'test_blockCache': + return CacheStrategy.Block; + + // never cache + default: + return CacheStrategy.Never; + } +} diff --git a/packages/eth-json-rpc-middleware/src/utils/common.test.ts b/packages/eth-json-rpc-middleware/src/utils/common.test.ts new file mode 100644 index 00000000000..38d5c80a8d5 --- /dev/null +++ b/packages/eth-json-rpc-middleware/src/utils/common.test.ts @@ -0,0 +1,19 @@ +import { stripArrayTypeIfPresent } from './common.js'; + +describe('CommonUtils', () => { + describe('stripArrayTypeIfPresent', () => { + it('remove array brackets from the type if present', () => { + expect(stripArrayTypeIfPresent('string[]')).toBe('string'); + expect(stripArrayTypeIfPresent('string[5]')).toBe('string'); + }); + + it('return types which are not array without any change', () => { + expect(stripArrayTypeIfPresent('string')).toBe('string'); + expect(stripArrayTypeIfPresent('string []')).toBe('string []'); + expect( + // @ts-expect-error Intentionally testing invalid input + stripArrayTypeIfPresent(undefined), + ).toBeUndefined(); + }); + }); +}); diff --git a/packages/eth-json-rpc-middleware/src/utils/common.ts b/packages/eth-json-rpc-middleware/src/utils/common.ts new file mode 100644 index 00000000000..ab39558de19 --- /dev/null +++ b/packages/eth-json-rpc-middleware/src/utils/common.ts @@ -0,0 +1,12 @@ +/** + * Function to stripe array brackets if string defining the type has it. + * + * @param typeString - String defining type from which array brackets are required to be removed. + * @returns Parameter string with array brackets [] removed. + */ +export const stripArrayTypeIfPresent = (typeString: string): string => { + if (typeString?.match(/\S\[\d*\]$/u)) { + return typeString.replace(/\[\d*\]$/gu, '').trim(); + } + return typeString; +}; diff --git a/packages/eth-json-rpc-middleware/src/utils/error.test.ts b/packages/eth-json-rpc-middleware/src/utils/error.test.ts new file mode 100644 index 00000000000..ad868fb4fc4 --- /dev/null +++ b/packages/eth-json-rpc-middleware/src/utils/error.test.ts @@ -0,0 +1,36 @@ +import { errorCodes } from '@metamask/rpc-errors'; + +import { isExecutionRevertedError } from './error.js'; + +const executionRevertedError = { + code: errorCodes.rpc.invalidInput, + message: 'execution reverted', +}; + +describe('isExecutionRevertedError', () => { + it('return false if object is not valid JSON RPC error', async () => { + const result = isExecutionRevertedError({ test: 'dummy' }); + expect(result).toBe(false); + }); + + it('return false if error code is not same as errorCodes.rpc.invalidInput', async () => { + const result = isExecutionRevertedError({ + ...executionRevertedError, + code: 123, + }); + expect(result).toBe(false); + }); + + it('return false if error message is not "execution reverted"', async () => { + const result = isExecutionRevertedError({ + ...executionRevertedError, + message: 'test', + }); + expect(result).toBe(false); + }); + + it('return true for correct executionRevertedError', async () => { + const result = isExecutionRevertedError(executionRevertedError); + expect(result).toBe(true); + }); +}); diff --git a/packages/eth-json-rpc-middleware/src/utils/error.ts b/packages/eth-json-rpc-middleware/src/utils/error.ts new file mode 100644 index 00000000000..10da9b81163 --- /dev/null +++ b/packages/eth-json-rpc-middleware/src/utils/error.ts @@ -0,0 +1,20 @@ +import { errorCodes } from '@metamask/rpc-errors'; +import { isJsonRpcError } from '@metamask/utils'; +import type { JsonRpcError } from '@metamask/utils'; + +/** + * Checks if a value is a JSON-RPC error that indicates an execution reverted error. + * + * @param error - The value to check. + * @returns True if the value is a JSON-RPC error that indicates an execution reverted + * error, false otherwise. + */ +export function isExecutionRevertedError( + error: unknown, +): error is JsonRpcError { + return ( + isJsonRpcError(error) && + error.code === errorCodes.rpc.invalidInput && + error.message === 'execution reverted' + ); +} diff --git a/packages/eth-json-rpc-middleware/src/utils/normalize.test.ts b/packages/eth-json-rpc-middleware/src/utils/normalize.test.ts new file mode 100644 index 00000000000..dc489331196 --- /dev/null +++ b/packages/eth-json-rpc-middleware/src/utils/normalize.test.ts @@ -0,0 +1,151 @@ +import { MessageTypes, TypedMessage } from '@metamask/eth-sig-util'; +import deepFreeze from 'deep-freeze-strict'; +import { klona } from 'klona'; + +import { normalizeTypedMessage } from './normalize.js'; + +const MESSAGE_DATA_MOCK = { + types: { + Permit: [ + { + name: 'owner', + type: 'address', + }, + { + name: 'spender', + type: 'address', + }, + { + name: 'value', + type: 'uint256', + }, + { + name: 'nonce', + type: 'uint256', + }, + { + name: 'deadline', + type: 'uint256', + }, + ], + EIP712Domain: [ + { + name: 'name', + type: 'string', + }, + { + name: 'version', + type: 'string', + }, + { + name: 'chainId', + type: 'uint256', + }, + { + name: 'verifyingContract', + type: 'address', + }, + ], + }, + domain: { + name: 'Liquid staked Ether 2.0', + version: '2', + chainId: '0x1', + verifyingContract: '0xae7ab96520de3a18e5e111b5eaab095312d7fe84', + }, + primaryType: 'Permit', + message: { + owner: '0x6d404afe1a6a07aa3cbcbf9fd027671df628ebfc', + spender: '0x63605E53D422C4F1ac0e01390AC59aAf84C44A51', + value: + '115792089237316195423570985008687907853269984665640564039457584007913129639935', + nonce: '0', + deadline: '4482689033', + }, +}; + +describe('normalizeTypedMessage', () => { + const parseNormalizerResult = ( + data: Record, + ): TypedMessage => { + return JSON.parse(normalizeTypedMessage(JSON.stringify(data))); + }; + + it('should normalize verifyingContract address in domain', () => { + const msgMock = { + ...MESSAGE_DATA_MOCK, + domain: { + ...MESSAGE_DATA_MOCK.domain, + verifyingContract: '0Xae7ab96520de3a18e5e111b5eaab095312d7fe84', + }, + }; + const normalizedData = parseNormalizerResult(msgMock); + expect(normalizedData.domain.verifyingContract).toBe( + '0xae7ab96520de3a18e5e111b5eaab095312d7fe84', + ); + }); + + it('should normalize verifyingContract address in readonly data', () => { + const data = klona(MESSAGE_DATA_MOCK); + data.domain.verifyingContract = + '0Xae7ab96520de3a18e5e111b5eaab095312d7fe84'; + deepFreeze(data); + + const normalizedData = parseNormalizerResult(data); + + expect(normalizedData.domain.verifyingContract).toBe( + '0xae7ab96520de3a18e5e111b5eaab095312d7fe84', + ); + }); + + it('should not modify if verifyingContract is already hexadecimal', () => { + const expectedVerifyingContract = + '0xae7ab96520de3a18e5e111b5eaab095312d7fe84'; + const messageDataWithHexAddress = { + ...MESSAGE_DATA_MOCK, + domain: { + ...MESSAGE_DATA_MOCK.domain, + verifyingContract: expectedVerifyingContract, + }, + }; + + const normalizedData = parseNormalizerResult(messageDataWithHexAddress); + + expect(normalizedData.domain.verifyingContract).toBe( + expectedVerifyingContract, + ); + }); + + it('should not modify if verifyingContract is not parsable', () => { + const expectedVerifyingContract = + 'Notparsableaddress1234567890123456789012345678901234567890'; + const messageDataWithHexAddress = { + ...MESSAGE_DATA_MOCK, + domain: { + ...MESSAGE_DATA_MOCK.domain, + verifyingContract: expectedVerifyingContract, + }, + }; + + const normalizedData = parseNormalizerResult(messageDataWithHexAddress); + + expect(normalizedData.domain.verifyingContract).toBe( + expectedVerifyingContract, + ); + }); + + it('should not modify other parts of the message data', () => { + const normalizedData = parseNormalizerResult(MESSAGE_DATA_MOCK); + expect(normalizedData.message).toStrictEqual(MESSAGE_DATA_MOCK.message); + expect(normalizedData.types).toStrictEqual(MESSAGE_DATA_MOCK.types); + expect(normalizedData.primaryType).toStrictEqual( + MESSAGE_DATA_MOCK.primaryType, + ); + }); + + it('should return data as is if not parsable', () => { + expect(normalizeTypedMessage('Not parsable data')).toBe( + 'Not parsable data', + ); + }); +}); diff --git a/packages/eth-json-rpc-middleware/src/utils/normalize.ts b/packages/eth-json-rpc-middleware/src/utils/normalize.ts new file mode 100644 index 00000000000..549af111289 --- /dev/null +++ b/packages/eth-json-rpc-middleware/src/utils/normalize.ts @@ -0,0 +1,69 @@ +import type { Hex } from '@metamask/utils'; + +type EIP712Domain = { + verifyingContract: Hex; +}; + +type SignTypedMessageDataV3V4 = { + types: Record; + domain: EIP712Domain; + primaryType: string; + message: unknown; +}; + +/** + * Normalizes the messageData for the eth_signTypedData + * + * @param messageData - The messageData to normalize. + * @returns The normalized messageData. + */ +export function normalizeTypedMessage(messageData: string): string { + let data; + try { + data = parseTypedMessage(messageData); + } catch { + // Ignore normalization errors and pass the message as is + return messageData; + } + + const { verifyingContract } = data.domain ?? {}; + + if (!verifyingContract) { + return messageData; + } + + return JSON.stringify({ + ...data, + domain: { + ...data.domain, + verifyingContract: normalizeContractAddress(verifyingContract), + }, + }); +} + +/** + * Parses the messageData to obtain the data object for EIP712 normalization + * + * @param data - The messageData to parse. + * @returns The data object for EIP712 normalization. + */ +export function parseTypedMessage(data: string): SignTypedMessageDataV3V4 { + if (typeof data !== 'string') { + return data; + } + + return JSON.parse(data) as unknown as SignTypedMessageDataV3V4; +} + +/** + * Normalizes the address to standard hexadecimal format + * + * @param address - The address to normalize. + * @returns The normalized address. + */ +function normalizeContractAddress(address: Hex): Hex { + if (address.startsWith('0X')) { + return `0x${address.slice(2)}`; + } + return address; +} diff --git a/packages/eth-json-rpc-middleware/src/utils/structs.ts b/packages/eth-json-rpc-middleware/src/utils/structs.ts new file mode 100644 index 00000000000..dccf4565573 --- /dev/null +++ b/packages/eth-json-rpc-middleware/src/utils/structs.ts @@ -0,0 +1,21 @@ +import { define, object, optional, union } from '@metamask/superstruct'; + +/** + * Superstruct schema for an empty array []. + * Validates that the value is an array with zero elements. + */ +export const EmptyArrayStruct = define<[]>('EmptyArray', (value) => + Array.isArray(value) && value.length === 0 ? true : 'Expected an empty array', +); + +/** + * Superstruct schema for JSON-RPC methods that expect no parameters. + * + * Different JSON-RPC clients may send "no params" in different ways: + * - Omitted entirely (undefined) + * - Empty array [] + * - Empty object {} + * + * This struct accepts all three forms for maximum compatibility. + */ +export const NoParamsStruct = optional(union([object({}), EmptyArrayStruct])); diff --git a/packages/eth-json-rpc-middleware/src/utils/timeout.test.ts b/packages/eth-json-rpc-middleware/src/utils/timeout.test.ts new file mode 100644 index 00000000000..91f406dfed1 --- /dev/null +++ b/packages/eth-json-rpc-middleware/src/utils/timeout.test.ts @@ -0,0 +1,67 @@ +import { timeout } from './timeout.js'; + +describe('timeout', () => { + describe('with real timers', () => { + it('does not resolve in the current event loop', async () => { + const winner = await Promise.race([ + (async (): Promise => { + await timeout(0); + return 'timeout'; + })(), + (async (): Promise => { + // nextTick runs immediately at the start of the next event loop + await new Promise((resolve) => process.nextTick(resolve)); + return 'nextTick'; + })(), + ]); + + expect(winner).toBe('nextTick'); + }); + + it('resolves in the next event loop', async () => { + const winner = await Promise.race([ + (async (): Promise => { + await timeout(0); + return 'timeout'; + })(), + (async (): Promise => { + // setImmediate will run all queued functions + await new Promise((resolve) => setImmediate(resolve)); + return 'setImmediate'; + })(), + ]); + + expect(winner).toBe('setImmediate'); + }); + }); + + describe('with fake timers', () => { + beforeAll(() => { + jest.useFakeTimers(); + }); + + afterEach(() => { + jest.clearAllTimers(); + }); + + afterAll(() => { + jest.useRealTimers(); + }); + + it('does not resolve before given duration', async () => { + const promise = timeout(100); + + jest.advanceTimersByTime(50); + + await expect(promise).toNeverResolve(); + }); + + it('resolves after the given duration', async () => { + const promise = timeout(100); + + jest.advanceTimersByTime(100); + + expect(await promise).toBeUndefined(); + }); + }); +}); diff --git a/packages/eth-json-rpc-middleware/src/utils/timeout.ts b/packages/eth-json-rpc-middleware/src/utils/timeout.ts new file mode 100644 index 00000000000..2fe14918cf6 --- /dev/null +++ b/packages/eth-json-rpc-middleware/src/utils/timeout.ts @@ -0,0 +1,9 @@ +/** + * Wait the specified number of milliseconds. + * + * @param duration - The number of milliseconds to wait. + * @returns A promise that resolves after the specified amount of time. + */ +export async function timeout(duration: number): Promise { + return new Promise((resolve) => setTimeout(resolve, duration)); +} diff --git a/packages/eth-json-rpc-middleware/src/utils/validation.test.ts b/packages/eth-json-rpc-middleware/src/utils/validation.test.ts new file mode 100644 index 00000000000..fe19d2ee173 --- /dev/null +++ b/packages/eth-json-rpc-middleware/src/utils/validation.test.ts @@ -0,0 +1,499 @@ +import { MiddlewareContext } from '@metamask/json-rpc-engine/v2'; +import { providerErrors } from '@metamask/rpc-errors'; +import type { StructError } from '@metamask/superstruct'; +import { any, validate } from '@metamask/superstruct'; + +import type { WalletMiddlewareKeyValues } from '../wallet.js'; +import { + MAX_TRANSACTION_PARAMS_SIZE_BYTES, + resemblesAddress, + validateAndNormalizeKeyholder, + validateParams, + validateTransactionParams, + validateTypedMessageKeys, +} from './validation.js'; + +jest.mock('@metamask/superstruct', () => ({ + ...jest.requireActual('@metamask/superstruct'), + validate: jest.fn(), +})); + +const ADDRESS_MOCK = '0xABCDabcdABCDabcdABCDabcdABCDabcdABCDabcd'; +const createContext = (): MiddlewareContext => + new MiddlewareContext([['origin', 'test']]); + +const STRUCT_ERROR_MOCK = { + failures: () => [ + { + path: ['test1', 'test2'], + message: 'test message', + }, + { + path: ['test3'], + message: 'test message 2', + }, + ], +} as StructError; + +describe('Validation Utils', () => { + const validateMock = jest.mocked(validate); + + let getAccountsMock: jest.MockedFn<(origin: string) => Promise>; + + beforeEach(() => { + jest.resetAllMocks(); + + getAccountsMock = jest.fn().mockResolvedValue([ADDRESS_MOCK]); + }); + + describe('validateAndNormalizeKeyholder', () => { + it('returns lowercase address', async () => { + const result = await validateAndNormalizeKeyholder( + ADDRESS_MOCK, + createContext(), + { + getAccounts: getAccountsMock, + }, + ); + + expect(result).toBe(ADDRESS_MOCK.toLowerCase()); + }); + + it('throws if address not returned by get accounts hook', async () => { + getAccountsMock.mockResolvedValueOnce([]); + + await expect( + validateAndNormalizeKeyholder(ADDRESS_MOCK, createContext(), { + getAccounts: getAccountsMock, + }), + ).rejects.toThrow(providerErrors.unauthorized()); + }); + + it('throws if address is not string', async () => { + await expect( + validateAndNormalizeKeyholder(123 as never, createContext(), { + getAccounts: getAccountsMock, + }), + ).rejects.toThrow( + 'Invalid parameters: must provide an Ethereum address.', + ); + }); + + it('throws if address is empty string', async () => { + await expect( + validateAndNormalizeKeyholder('' as never, createContext(), { + getAccounts: getAccountsMock, + }), + ).rejects.toThrow( + 'Invalid parameters: must provide an Ethereum address.', + ); + }); + + it('throws if address length is not 40', async () => { + await expect( + validateAndNormalizeKeyholder('0x123', createContext(), { + getAccounts: getAccountsMock, + }), + ).rejects.toThrow( + 'Invalid parameters: must provide an Ethereum address.', + ); + }); + }); + + describe('resemblesAddress', () => { + it('returns true if valid address', () => { + expect(resemblesAddress(ADDRESS_MOCK)).toBe(true); + }); + + it('returns false if length not correct', () => { + expect(resemblesAddress('0x123')).toBe(false); + }); + }); + + describe('validateParams', () => { + it('does now throw if superstruct returns no error', () => { + validateMock.mockReturnValue([undefined, undefined]); + expect(() => validateParams({}, any())).not.toThrow(); + }); + + it('throws if superstruct returns error', () => { + validateMock.mockReturnValue([STRUCT_ERROR_MOCK, undefined]); + + expect(() => validateParams({}, any())) + .toThrowErrorMatchingInlineSnapshot(` + "Invalid params + + test1 > test2 - test message + test3 - test message 2" + `); + }); + }); + + describe('validateTypedMessageKeys', () => { + it('does not throw for data with only schema-defined keys', () => { + const data = JSON.stringify({ + types: { + EIP712Domain: [{ name: 'name', type: 'string' }], + }, + }); + + expect(() => validateTypedMessageKeys(data)).not.toThrow(); + }); + + it('throws for data with extraneous keys', () => { + const data = JSON.stringify({ + types: { + EIP712Domain: [{ name: 'name', type: 'string' }], + }, + primaryType: 'EIP712Domain', + domain: {}, + message: {}, + extraKey: 'unexpected', + }); + + expect(() => validateTypedMessageKeys(data)).toThrow('Invalid input.'); + }); + + it('throws when data contains only extraneous keys', () => { + const data = JSON.stringify({ + foo: 'bar', + baz: 123, + }); + + expect(() => validateTypedMessageKeys(data)).toThrow('Invalid input.'); + }); + + describe('metadata', () => { + const baseTypedData = { + types: { + EIP712Domain: [{ name: 'name', type: 'string' }], + }, + primaryType: 'EIP712Domain', + domain: {}, + message: {}, + }; + + it('does not throw when metadata has exactly justification and origin as strings', () => { + const data = JSON.stringify({ + ...baseTypedData, + metadata: { + justification: 'Permission to spend tokens', + origin: 'https://example.com', + }, + }); + + expect(() => validateTypedMessageKeys(data)).not.toThrow(); + }); + + it('does not throw when metadata is the only top-level key', () => { + const data = JSON.stringify({ + metadata: { + justification: 'Permission to spend tokens', + origin: 'https://example.com', + }, + }); + + expect(() => validateTypedMessageKeys(data)).not.toThrow(); + }); + + it.each([ + ['null', null], + ['a string', 'not-an-object'], + ['a number', 42], + ['a boolean', true], + ['an array', ['justification', 'origin']], + ])('throws when metadata is %s', (_label, value) => { + const data = JSON.stringify({ + ...baseTypedData, + metadata: value, + }); + + expect(() => validateTypedMessageKeys(data)).toThrow('Invalid input.'); + }); + + it('throws when metadata is missing justification', () => { + const data = JSON.stringify({ + ...baseTypedData, + metadata: { + origin: 'https://example.com', + }, + }); + + expect(() => validateTypedMessageKeys(data)).toThrow('Invalid input.'); + }); + + it('throws when metadata is missing origin', () => { + const data = JSON.stringify({ + ...baseTypedData, + metadata: { + justification: 'Permission to spend tokens', + }, + }); + + expect(() => validateTypedMessageKeys(data)).toThrow('Invalid input.'); + }); + + it('throws when metadata.justification is not a string', () => { + const data = JSON.stringify({ + ...baseTypedData, + metadata: { + justification: 123, + origin: 'https://example.com', + }, + }); + + expect(() => validateTypedMessageKeys(data)).toThrow('Invalid input.'); + }); + + it('throws when metadata.origin is not a string', () => { + const data = JSON.stringify({ + ...baseTypedData, + metadata: { + justification: 'Permission to spend tokens', + origin: 123, + }, + }); + + expect(() => validateTypedMessageKeys(data)).toThrow('Invalid input.'); + }); + + it('throws when metadata has an extraneous third key', () => { + const data = JSON.stringify({ + ...baseTypedData, + metadata: { + justification: 'Permission to spend tokens', + origin: 'https://example.com', + extra: 'unexpected', + }, + }); + + expect(() => validateTypedMessageKeys(data)).toThrow('Invalid input.'); + }); + + it('throws when metadata is an empty object', () => { + const data = JSON.stringify({ + ...baseTypedData, + metadata: {}, + }); + + expect(() => validateTypedMessageKeys(data)).toThrow('Invalid input.'); + }); + }); + }); + + describe('validateTransactionParams', () => { + const VALID_FROM = '0xbe93f9bacbcffc8ee6663f2647917ed7a20a57bb'; + const VALID_TO = '0xdac17f958d2ee523a2206206994597c13d831ec7'; + + beforeEach(() => { + const actual = jest.requireActual<{ + validate: typeof validate; + }>('@metamask/superstruct'); + validateMock.mockImplementation(actual.validate); + }); + + it('does not throw for minimal valid params', () => { + expect(() => + validateTransactionParams({ from: VALID_FROM }), + ).not.toThrow(); + }); + + it('does not throw for the full valid param set', () => { + expect(() => + validateTransactionParams({ + accessList: [ + { + address: VALID_TO, + storageKeys: ['0x00', '0x01'], + }, + ], + authorizationList: [ + { + chainId: '0x1', + address: VALID_TO, + nonce: '0x0', + r: '0x0', + s: '0x0', + yParity: '0x0', + }, + ], + chainId: '0x1', + data: '0x095ea7b3', + from: VALID_FROM, + gas: '0x5208', + gasLimit: '0x5208', + gasPrice: '0x1', + maxFeePerGas: '0x2', + maxPriorityFeePerGas: '0x1', + nonce: '0x0', + to: VALID_TO, + type: '0x2', + value: '0x0', + }), + ).not.toThrow(); + }); + + it('does not throw when quantity fields are numbers', () => { + expect(() => + validateTransactionParams({ + from: VALID_FROM, + to: VALID_TO, + chainId: 4663, + gas: 21000, + gasLimit: 21000, + gasPrice: 1, + maxFeePerGas: 2, + maxPriorityFeePerGas: 1, + nonce: 0, + value: 0, + }), + ).not.toThrow(); + }); + + it('does not throw when authorizationList quantity fields are numbers', () => { + expect(() => + validateTransactionParams({ + from: VALID_FROM, + authorizationList: [ + { + address: VALID_TO, + chainId: 1, + nonce: 0, + r: '0x0', + s: '0x0', + yParity: 0, + }, + ], + }), + ).not.toThrow(); + }); + + it.each([ + ['null', null], + ['undefined', undefined], + ['a string', 'not-an-object'], + ['a number', 42], + ['a boolean', true], + ['an array', [{ from: VALID_FROM }]], + ])('throws when params is %s', (_label, value) => { + expect(() => validateTransactionParams(value)).toThrow(/Invalid params/u); + }); + + it('throws for an extraneous top-level key', () => { + expect(() => + validateTransactionParams({ + from: VALID_FROM, + to: VALID_TO, + extraKey: 'unexpected', + }), + ).toThrow(/Invalid params/u); + }); + + it('throws when params contain an extraneous key with a deeply-nested value', () => { + let junk: Record = {}; + for (let i = 0; i < 1200; i++) { + junk = { b: junk }; + } + + expect(() => + validateTransactionParams({ + from: VALID_FROM, + to: VALID_TO, + value: '0x0', + data: '0x095ea7b3', + test: junk, + }), + ).toThrow(/Invalid params/u); + }); + + it('runs the size check before schema validation', () => { + const stringifySpy = jest.spyOn(JSON, 'stringify'); + + try { + expect(() => + validateTransactionParams({ + from: VALID_FROM, + to: VALID_TO, + extraKey: 'unexpected', + }), + ).toThrow(/Invalid params/u); + + expect(stringifySpy).toHaveBeenCalled(); + } finally { + stringifySpy.mockRestore(); + } + }); + + it('throws when a typed field has the wrong type', () => { + expect(() => + validateTransactionParams({ + from: VALID_FROM, + to: { nested: 'not-an-address' }, + }), + ).toThrow(/Invalid params/u); + }); + + it('throws when `data` is not a hex string', () => { + expect(() => + validateTransactionParams({ + from: VALID_FROM, + data: 1234 as unknown as string, + }), + ).toThrow(/Invalid params/u); + }); + + it('throws when `accessList` entries are malformed', () => { + expect(() => + validateTransactionParams({ + from: VALID_FROM, + accessList: [{ address: 'not-hex', storageKeys: 'not-an-array' }], + }), + ).toThrow(/Invalid params/u); + }); + + it('throws for a data-padding attack that passes the schema', () => { + const padded = `0x${'00'.repeat(MAX_TRANSACTION_PARAMS_SIZE_BYTES)}`; + + expect(() => + validateTransactionParams({ + from: VALID_FROM, + to: VALID_TO, + data: padded, + }), + ).toThrow('Request too large'); + }); + + it('throws for an accessList-padding attack that passes the schema', () => { + const padded = Array.from( + { length: Math.ceil(MAX_TRANSACTION_PARAMS_SIZE_BYTES / 64) }, + () => ({ + address: VALID_TO, + storageKeys: [`0x${'00'.repeat(32)}`], + }), + ); + + expect(() => + validateTransactionParams({ + from: VALID_FROM, + to: VALID_TO, + accessList: padded, + }), + ).toThrow('Request too large'); + }); + + it('does not throw for a legitimate multi-entry accessList well under the size limit', () => { + const entries = Array.from({ length: 16 }, () => ({ + address: VALID_TO, + storageKeys: [`0x${'11'.repeat(32)}`, `0x${'22'.repeat(32)}`], + })); + + expect(() => + validateTransactionParams({ + from: VALID_FROM, + to: VALID_TO, + accessList: entries, + }), + ).not.toThrow(); + }); + }); +}); diff --git a/packages/eth-json-rpc-middleware/src/utils/validation.ts b/packages/eth-json-rpc-middleware/src/utils/validation.ts new file mode 100644 index 00000000000..f5581310641 --- /dev/null +++ b/packages/eth-json-rpc-middleware/src/utils/validation.ts @@ -0,0 +1,319 @@ +import { TYPED_MESSAGE_SCHEMA } from '@metamask/eth-sig-util'; +import { providerErrors, rpcErrors } from '@metamask/rpc-errors'; +import type { Struct, StructError } from '@metamask/superstruct'; +import { + array, + number, + object, + optional, + string, + union, + validate, +} from '@metamask/superstruct'; +import type { Hex } from '@metamask/utils'; + +import type { WalletMiddlewareContext } from '../wallet.js'; +import { parseTypedMessage } from './normalize.js'; + +/** + * Validates and normalizes a keyholder address for transaction- and + * signature-related operations. + * + * @param address - The Ethereum address to validate and normalize. + * @param context - The context of the request. + * @param options - The options for the validation. + * @param options.getAccounts - The function to get the accounts for the origin. + * @returns The normalized address, if valid. Otherwise, throws + * an error + */ +export async function validateAndNormalizeKeyholder( + address: Hex, + context: WalletMiddlewareContext, + { getAccounts }: { getAccounts: (origin: string) => Promise }, +): Promise { + if ( + typeof address === 'string' && + address.length > 0 && + resemblesAddress(address) + ) { + // Ensure that an "unauthorized" error is thrown if the requester + // does not have the `eth_accounts` permission. + const accounts = await getAccounts(context.assertGet('origin')); + + const normalizedAccounts: string[] = accounts.map((_address) => + _address.toLowerCase(), + ); + + const normalizedAddress = address.toLowerCase() as Hex; + + if (normalizedAccounts.includes(normalizedAddress)) { + return normalizedAddress; + } + + throw providerErrors.unauthorized(); + } + + throw rpcErrors.invalidParams({ + message: `Invalid parameters: must provide an Ethereum address.`, + }); +} + +/** + * Validates the parameters of a request against a Superstruct schema. + * Throws a JSON-RPC error if the parameters are invalid. + * + * @param value - The value to validate. + * @param struct - The Superstruct schema to validate against. + * @throws An error if the parameters are invalid. + */ +export function validateParams( + value: unknown | ParamsType, + struct: Struct, +): asserts value is ParamsType { + const [error] = validate(value, struct); + + if (error) { + throw rpcErrors.invalidParams( + formatValidationError(error, `Invalid params`), + ); + } +} + +/** + * Checks if a string resembles an Ethereum address. + * + * @param str - The string to check. + * @returns True if the string resembles an Ethereum address, false otherwise. + */ +export function resemblesAddress(str: string): boolean { + // hex prefix 2 + 20 bytes + return str.length === 2 + 20 * 2; +} + +/** + * Formats a Superstruct validation error into a human-readable string. + * + * @param error - The Superstruct validation error. + * @param message - The base error message to prepend to the formatted details. + * @returns The formatted error. + */ +function formatValidationError(error: StructError, message: string): string { + return `${message}\n\n${error + .failures() + .map( + (failure) => + `${failure.path.join(' > ')}${failure.path.length ? ' - ' : ''}${failure.message}`, + ) + .join('\n')}`; +} + +export const DANGEROUS_PROTOTYPE_PROPERTIES = [ + '__proto__', + 'constructor', + 'prototype', + '__defineGetter__', + '__defineSetter__', + '__lookupGetter__', + '__lookupSetter__', +] as const; + +/** + * Checks if a property name is dangerous for prototype pollution. + * + * @param key - The property name to check + * @returns True if the property name is dangerous + */ +function isDangerousProperty(key: string): boolean { + return (DANGEROUS_PROTOTYPE_PROPERTIES as readonly string[]).includes(key); +} + +/** + * Recursively checks an object for dangerous prototype pollution properties. + * + * @param obj - The object to check + * @throws rpcErrors.invalidInput() if a dangerous property is found + */ +function checkObjectForPrototypePollution(obj: unknown): void { + if (obj === null || obj === undefined) { + return; + } + + if (Array.isArray(obj)) { + for (const item of obj) { + checkObjectForPrototypePollution(item); + } + return; + } + + if (typeof obj === 'object') { + for (const key of Object.getOwnPropertyNames( + obj as Record, + )) { + if (isDangerousProperty(key)) { + throw rpcErrors.invalidInput(); + } + checkObjectForPrototypePollution((obj as Record)[key]); + } + } +} + +/** + * Validates V1 typed data (array format) for prototype pollution attacks. + * V1 format: [{ type: 'string', name: 'fieldName', value: 'data' }, ...] + * + * @param data - The V1 typed data array to validate + * @throws rpcErrors.invalidInput() if prototype pollution is detected + */ +export function validateTypedDataV1ForPrototypePollution( + data: Record[], +): void { + if (!data || !Array.isArray(data)) { + return; + } + + for (const item of data) { + if (item && typeof item === 'object') { + // Only check the 'value' field (the message data) for dangerous properties + if (item.value !== null && typeof item.value === 'object') { + checkObjectForPrototypePollution(item.value); + } + } + } +} + +/** + * Validates V3/V4 typed data (EIP-712 format) for prototype pollution attacks. + * Only checks the message field for dangerous properties. + * + * @param data - The stringified typed data to validate + * @throws rpcErrors.invalidInput() if prototype pollution is detected + */ +export function validateTypedDataForPrototypePollution(data: string): void { + const { message } = parseTypedMessage(data); + + // Check message recursively for dangerous properties + if (message !== undefined) { + checkObjectForPrototypePollution(message); + } +} + +/** + * Validates that EIP-712 typed message data contains only keys defined in + * the TYPED_MESSAGE_SCHEMA from `@metamask/eth-sig-util`. Rejects messages + * with extraneous top-level keys. + * + * @param data - The stringified typed data to validate. + * @throws rpcErrors.invalidInput() if extraneous keys are detected. + */ +export function validateTypedMessageKeys(data: string): void { + const parsedData = parseTypedMessage(data); + const allowedKeys = new Set([ + ...Object.keys(TYPED_MESSAGE_SCHEMA.properties), + 'metadata', + ]); + const hasExtraneousKey = Object.keys(parsedData).some( + (key) => !allowedKeys.has(key), + ); + + if (hasExtraneousKey) { + throw rpcErrors.invalidInput(); + } + + // Advanced Permissions adds `metadata: { justification: string, origin: string }` to eth_signTypedData requests. + // see GatorPermissionsController.decodePermissionFromPermissionContextForOrigin for more details. + const { metadata } = parsedData as { metadata?: unknown }; + if (metadata !== undefined) { + if (typeof metadata !== 'object' || metadata === null) { + throw rpcErrors.invalidInput(); + } + + const { justification, origin } = metadata as { + justification?: unknown; + origin?: unknown; + }; + + if (typeof justification !== 'string' || typeof origin !== 'string') { + throw rpcErrors.invalidInput(); + } + + // we only need to check the keys length, because we already checked the known keys (justification and origin). + if (Object.keys(metadata).length !== 2) { + throw rpcErrors.invalidInput(); + } + } +} + +// Numerical fields accept both hex strings and numbers, as some dapps send +// numbers and `TransactionController` normalizes them downstream. +const QuantityStruct = union([string(), number()]); + +export const TransactionParamsStruct = object({ + accessList: optional( + array(object({ address: string(), storageKeys: array(string()) })), + ), + authorizationList: optional( + array( + object({ + address: string(), + chainId: optional(QuantityStruct), + nonce: optional(QuantityStruct), + r: optional(string()), + s: optional(string()), + yParity: optional(QuantityStruct), + }), + ), + ), + chainId: optional(QuantityStruct), + data: optional(string()), + from: string(), + gas: optional(QuantityStruct), + gasLimit: optional(QuantityStruct), + gasPrice: optional(QuantityStruct), + maxFeePerGas: optional(QuantityStruct), + maxPriorityFeePerGas: optional(QuantityStruct), + nonce: optional(QuantityStruct), + to: optional(string()), + type: optional(string()), + value: optional(QuantityStruct), +}); + +// Upper bound derived from the largest valid eth_sendTransaction payload: +// EIP-3860 caps initcode at 49,152 bytes → hex-encoded in 'data' field ≈ 98 KB of JSON. +// 200 KB is ~2× that ceiling, giving clear headroom above any protocol-legal +// transaction while blocking the padding attacks this cap defends against. +// TODO(CONF-1662): tighten once P99 production data is available. +export const MAX_TRANSACTION_PARAMS_SIZE_BYTES = 200 * 1024; + +/** + * Validates `eth_sendTransaction` / `eth_signTransaction` params against the + * standard transaction schema and rejects payloads whose serialized size + * exceeds `MAX_TRANSACTION_PARAMS_SIZE_BYTES`. + * + * Guards against two attack shapes: + * - Size: valid-shaped but oversized payloads (e.g. `data` padded with + * millions of hex zeros) that exhaust memory in downstream code. Checked + * first via `JSON.stringify` so oversized input is rejected before schema + * work. + * - Structural: extraneous top-level keys or ill-typed fields (e.g. + * `{ from, to, test: { b: { b: ... × 1200 } } }`) that would crash + * downstream normalization / PPOM WASM with `RangeError: Maximum call + * stack size exceeded`, silently bypassing security checks. Superstruct's + * `object()` rejects unknown keys by name without accessing their values, + * so hostile nested subtrees are never traversed by schema validation. + * + * @param params - The transaction params object supplied by the dapp. + * @throws rpcErrors.invalidParams() if params is an array or exceeds the + * serialized size limit. + * @throws rpcErrors.invalidInput() if params fails schema validation + * (wrong type, extraneous top-level key, or malformed nested field). + */ +export function validateTransactionParams(params: unknown): void { + if ( + new TextEncoder().encode(JSON.stringify(params)).byteLength > + MAX_TRANSACTION_PARAMS_SIZE_BYTES + ) { + throw rpcErrors.invalidParams('Request too large'); + } + + validateParams(params, TransactionParamsStruct); +} diff --git a/packages/eth-json-rpc-middleware/src/wallet.test.ts b/packages/eth-json-rpc-middleware/src/wallet.test.ts new file mode 100644 index 00000000000..26240fc50cb --- /dev/null +++ b/packages/eth-json-rpc-middleware/src/wallet.test.ts @@ -0,0 +1,1156 @@ +import { MessageTypes, TypedMessage } from '@metamask/eth-sig-util'; +import { JsonRpcEngineV2 } from '@metamask/json-rpc-engine/v2'; +import type { Json } from '@metamask/utils'; + +import { createHandleParams, createRequest } from '../test/util/helpers.js'; +import type { + MessageParams, + TransactionParams, + TypedMessageParams, + TypedMessageV1Params, +} from './index.js'; +import { createWalletMiddleware } from './index.js'; +import { DANGEROUS_PROTOTYPE_PROPERTIES } from './utils/validation.js'; + +const testAddresses = [ + '0xbe93f9bacbcffc8ee6663f2647917ed7a20a57bb', + '0x1234362ef32bcd26d3dd18ca749378213625ba0b', +]; +const testUnkownAddress = '0xbadbadbadbadbadbadbadbadbadbadbadbadbad6'; +const testTxHash = + '0xceb3240213640d89419829f3e8011d015af7a7ab3b54c14fdf125620ce5b8697'; +const testMsgSig = + '0x68dc980608bceb5f99f691e62c32caccaee05317309015e9454eba1a14c3cd4505d1dd098b8339801239c9bcaac3c4df95569dcf307108b92f68711379be14d81c'; + +describe('wallet', () => { + describe('accounts', () => { + it('returns null for coinbase when no accounts', async () => { + const getAccounts = async (): Promise => []; + const engine = JsonRpcEngineV2.create({ + middleware: [createWalletMiddleware({ getAccounts })], + }); + const coinbaseResult = await engine.handle( + ...createHandleParams({ + method: 'eth_coinbase', + }), + ); + expect(coinbaseResult).toBeNull(); + }); + + it('should return the correct value from getAccounts', async () => { + const getAccounts = async (): Promise => testAddresses.slice(); + const engine = JsonRpcEngineV2.create({ + middleware: [createWalletMiddleware({ getAccounts })], + }); + const coinbaseResult = await engine.handle( + ...createHandleParams({ + method: 'eth_coinbase', + }), + ); + expect(coinbaseResult).toStrictEqual(testAddresses[0]); + }); + + it('should return the correct value from getAccounts with multiple accounts', async () => { + const getAccounts = async (): Promise => + testAddresses.slice(0, 2); + const engine = JsonRpcEngineV2.create({ + middleware: [createWalletMiddleware({ getAccounts })], + }); + const coinbaseResult = await engine.handle( + ...createHandleParams({ + method: 'eth_coinbase', + }), + ); + expect(coinbaseResult).toStrictEqual(testAddresses[0]); + }); + }); + + describe('transactions', () => { + it('processes transaction with valid address', async () => { + const getAccounts = async (): Promise => + testAddresses.slice(0, 2); + const witnessedTxParams: TransactionParams[] = []; + const processTransaction = async ( + _txParams: TransactionParams, + ): Promise => { + witnessedTxParams.push(_txParams); + return testTxHash; + }; + const engine = JsonRpcEngineV2.create({ + middleware: [ + createWalletMiddleware({ getAccounts, processTransaction }), + ], + }); + const txParams = { + from: testAddresses[0], + }; + const payload = { method: 'eth_sendTransaction', params: [txParams] }; + + const sendTxResult = await engine.handle(...createHandleParams(payload)); + expect(sendTxResult).toBeDefined(); + expect(sendTxResult).toStrictEqual(testTxHash); + expect(witnessedTxParams).toHaveLength(1); + expect(witnessedTxParams[0]).toStrictEqual(txParams); + }); + + it('throws when provided an invalid address', async () => { + const getAccounts = async (): Promise => + testAddresses.slice(0, 2); + const witnessedTxParams: TransactionParams[] = []; + const processTransaction = async ( + _txParams: TransactionParams, + ): Promise => { + witnessedTxParams.push(_txParams); + return testTxHash; + }; + const engine = JsonRpcEngineV2.create({ + middleware: [ + createWalletMiddleware({ getAccounts, processTransaction }), + ], + }); + const txParams = { + from: '0x3d', + }; + + const payload = createRequest({ + method: 'eth_sendTransaction', + params: [txParams], + }); + await expect(engine.handle(payload)).rejects.toThrow( + 'Invalid parameters: must provide an Ethereum address.', + ); + }); + + it('throws unauthorized for unknown addresses', async () => { + const getAccounts = async (): Promise => + testAddresses.slice(0, 2); + const witnessedTxParams: TransactionParams[] = []; + const processTransaction = async ( + _txParams: TransactionParams, + ): Promise => { + witnessedTxParams.push(_txParams); + return testTxHash; + }; + const engine = JsonRpcEngineV2.create({ + middleware: [ + createWalletMiddleware({ getAccounts, processTransaction }), + ], + }); + const txParams = { + from: testUnkownAddress, + }; + const payload = { + method: 'eth_sendTransaction', + params: [txParams], + }; + + await expect( + engine.handle(...createHandleParams(payload)), + ).rejects.toThrow( + 'The requested account and/or method has not been authorized by the user.', + ); + }); + + it('throws when params contain an extraneous top-level key', async () => { + const getAccounts = async (): Promise => + testAddresses.slice(0, 2); + const processTransaction = async (): Promise => testTxHash; + const engine = JsonRpcEngineV2.create({ + middleware: [ + createWalletMiddleware({ getAccounts, processTransaction }), + ], + }); + const payload = { + method: 'eth_sendTransaction', + params: [ + { + from: testAddresses[0], + to: testAddresses[1], + extraKey: 'unexpected', + }, + ], + }; + + await expect( + engine.handle(...createHandleParams(payload)), + ).rejects.toThrow(/Invalid params/u); + }); + + it('throws when params contain deeply nested invalid data', async () => { + const getAccounts = async (): Promise => + testAddresses.slice(0, 2); + const processTransaction = async (): Promise => testTxHash; + const engine = JsonRpcEngineV2.create({ + middleware: [ + createWalletMiddleware({ getAccounts, processTransaction }), + ], + }); + + let junk: Json = {}; + for (let i = 0; i < 1200; i++) { + junk = { b: junk }; + } + + const payload = { + method: 'eth_sendTransaction', + params: [ + { + from: testAddresses[0], + to: testAddresses[1], + value: '0x0', + data: '0x095ea7b3', + test: junk, + }, + ] as Json[], + }; + + await expect( + engine.handle(...createHandleParams(payload)), + ).rejects.toThrow(/Invalid params/u); + }); + + it('should not override other request params', async () => { + const getAccounts = async (): Promise => + testAddresses.slice(0, 2); + const witnessedTxParams: TransactionParams[] = []; + const processTransaction = async ( + _txParams: TransactionParams, + ): Promise => { + witnessedTxParams.push(_txParams); + return testTxHash; + }; + const engine = JsonRpcEngineV2.create({ + middleware: [ + createWalletMiddleware({ getAccounts, processTransaction }), + ], + }); + const txParams = { + from: testAddresses[0], + to: testAddresses[1], + }; + const payload = { + method: 'eth_sendTransaction', + params: [txParams], + }; + + await engine.handle(...createHandleParams(payload)); + expect(witnessedTxParams).toHaveLength(1); + expect(witnessedTxParams[0]).toStrictEqual(txParams); + }); + }); + + describe('signTransaction', () => { + it('should process sign transaction when provided a valid address', async () => { + const getAccounts = async (): Promise => + testAddresses.slice(0, 2); + const witnessedTxParams: TransactionParams[] = []; + const processSignTransaction = async ( + _txParams: TransactionParams, + ): Promise => { + witnessedTxParams.push(_txParams); + return testTxHash; + }; + const engine = JsonRpcEngineV2.create({ + middleware: [ + createWalletMiddleware({ getAccounts, processSignTransaction }), + ], + }); + const txParams = { + from: testAddresses[0], + }; + const payload = { method: 'eth_signTransaction', params: [txParams] }; + + expect(await engine.handle(...createHandleParams(payload))).toStrictEqual( + testTxHash, + ); + expect(witnessedTxParams).toHaveLength(1); + expect(witnessedTxParams[0]).toStrictEqual(txParams); + }); + + it('should not override other request params', async () => { + const getAccounts = async (): Promise => + testAddresses.slice(0, 2); + const witnessedTxParams: TransactionParams[] = []; + const processSignTransaction = async ( + _txParams: TransactionParams, + ): Promise => { + witnessedTxParams.push(_txParams); + return testTxHash; + }; + const engine = JsonRpcEngineV2.create({ + middleware: [ + createWalletMiddleware({ getAccounts, processSignTransaction }), + ], + }); + const txParams = { + from: testAddresses[0], + to: testAddresses[1], + }; + const payload = { method: 'eth_signTransaction', params: [txParams] }; + + await engine.handle(...createHandleParams(payload)); + expect(witnessedTxParams).toHaveLength(1); + expect(witnessedTxParams[0]).toStrictEqual(txParams); + }); + + it('should throw when provided invalid address', async () => { + const getAccounts = async (): Promise => + testAddresses.slice(0, 2); + const witnessedTxParams: TransactionParams[] = []; + const processSignTransaction = async ( + _txParams: TransactionParams, + ): Promise => { + witnessedTxParams.push(_txParams); + return testTxHash; + }; + const engine = JsonRpcEngineV2.create({ + middleware: [ + createWalletMiddleware({ getAccounts, processSignTransaction }), + ], + }); + const txParams = { + from: '0x3', + }; + const payload = { method: 'eth_signTransaction', params: [txParams] }; + + await expect( + engine.handle(...createHandleParams(payload)), + ).rejects.toThrow( + 'Invalid parameters: must provide an Ethereum address.', + ); + }); + + it('should throw when provided unknown address', async () => { + const getAccounts = async (): Promise => + testAddresses.slice(0, 2); + const witnessedTxParams: TransactionParams[] = []; + const processSignTransaction = async ( + _txParams: TransactionParams, + ): Promise => { + witnessedTxParams.push(_txParams); + return testTxHash; + }; + const engine = JsonRpcEngineV2.create({ + middleware: [ + createWalletMiddleware({ getAccounts, processSignTransaction }), + ], + }); + const txParams = { + from: testUnkownAddress, + }; + const payload = { method: 'eth_signTransaction', params: [txParams] }; + + await expect( + engine.handle(...createHandleParams(payload)), + ).rejects.toThrow( + 'The requested account and/or method has not been authorized by the user.', + ); + }); + + it('throws when params contain an extraneous top-level key', async () => { + const getAccounts = async (): Promise => + testAddresses.slice(0, 2); + const processSignTransaction = async (): Promise => testTxHash; + const engine = JsonRpcEngineV2.create({ + middleware: [ + createWalletMiddleware({ getAccounts, processSignTransaction }), + ], + }); + const payload = { + method: 'eth_signTransaction', + params: [ + { + from: testAddresses[0], + to: testAddresses[1], + extraKey: 'unexpected', + }, + ], + }; + + await expect( + engine.handle(...createHandleParams(payload)), + ).rejects.toThrow(/Invalid params/u); + }); + + it('throws when params contain deeply nested invalid data', async () => { + const getAccounts = async (): Promise => + testAddresses.slice(0, 2); + const processSignTransaction = async (): Promise => testTxHash; + const engine = JsonRpcEngineV2.create({ + middleware: [ + createWalletMiddleware({ getAccounts, processSignTransaction }), + ], + }); + + let junk: Json = {}; + for (let i = 0; i < 1200; i++) { + junk = { b: junk }; + } + + const payload = { + method: 'eth_signTransaction', + params: [ + { + from: testAddresses[0], + to: testAddresses[1], + value: '0x0', + data: '0x095ea7b3', + test: junk, + }, + ] as Json[], + }; + + await expect( + engine.handle(...createHandleParams(payload)), + ).rejects.toThrow(/Invalid params/u); + }); + }); + + describe('signTypedData', () => { + it('should sign with a valid address', async () => { + const getAccounts = async (): Promise => testAddresses.slice(); + const witnessedMsgParams: TypedMessageV1Params[] = []; + const processTypedMessage = async ( + msgParams: TypedMessageV1Params, + ): Promise => { + witnessedMsgParams.push(msgParams); + return testMsgSig; + }; + const engine = JsonRpcEngineV2.create({ + middleware: [ + createWalletMiddleware({ getAccounts, processTypedMessage }), + ], + }); + const message = [ + { + type: 'string', + name: 'message', + value: 'Hi, Alice!', + }, + ]; + + const payload = { + method: 'eth_signTypedData', + params: [message, testAddresses[0]], + }; + const signMsgResult = await engine.handle(...createHandleParams(payload)); + + expect(signMsgResult).toBeDefined(); + expect(signMsgResult).toStrictEqual(testMsgSig); + expect(witnessedMsgParams).toHaveLength(1); + expect(witnessedMsgParams[0]).toStrictEqual({ + from: testAddresses[0], + data: message, + signatureMethod: 'eth_signTypedData', + version: 'V1', + }); + }); + + it('should throw with invalid address', async () => { + const getAccounts = async (): Promise => testAddresses.slice(); + const witnessedMsgParams: TypedMessageV1Params[] = []; + const processTypedMessage = async ( + msgParams: TypedMessageV1Params, + ): Promise => { + witnessedMsgParams.push(msgParams); + return testMsgSig; + }; + const engine = JsonRpcEngineV2.create({ + middleware: [ + createWalletMiddleware({ getAccounts, processTypedMessage }), + ], + }); + const message = [ + { + type: 'string', + name: 'message', + value: 'Hi, Alice!', + }, + ]; + + const payload = { + method: 'eth_signTypedData', + params: [message, '0x3d'], + }; + await expect( + engine.handle(...createHandleParams(payload)), + ).rejects.toThrow( + new Error('Invalid parameters: must provide an Ethereum address.'), + ); + }); + + it('should throw with unknown address', async () => { + const getAccounts = async (): Promise => testAddresses.slice(); + const witnessedMsgParams: TypedMessageV1Params[] = []; + const processTypedMessage = async ( + msgParams: TypedMessageV1Params, + ): Promise => { + witnessedMsgParams.push(msgParams); + return testMsgSig; + }; + const engine = JsonRpcEngineV2.create({ + middleware: [ + createWalletMiddleware({ getAccounts, processTypedMessage }), + ], + }); + const message = [ + { + type: 'string', + name: 'message', + value: 'Hi, Alice!', + }, + ]; + const payload = { + method: 'eth_signTypedData', + params: [message, testUnkownAddress], + }; + + await expect( + engine.handle(...createHandleParams(payload)), + ).rejects.toThrow( + 'The requested account and/or method has not been authorized by the user.', + ); + }); + }); + + describe('signTypedDataV3', () => { + it('should sign data and normalizes verifyingContract', async () => { + const getAccounts = async (): Promise => testAddresses.slice(); + const witnessedMsgParams: TypedMessageParams[] = []; + const processTypedMessageV3 = async ( + msgParams: TypedMessageParams, + ): Promise => { + witnessedMsgParams.push(msgParams); + // Assume testMsgSig is the expected signature result + return testMsgSig; + }; + const engine = JsonRpcEngineV2.create({ + middleware: [ + createWalletMiddleware({ getAccounts, processTypedMessageV3 }), + ], + }); + + const message = { + types: { + EIP712Domain: [ + { name: 'name', type: 'string' }, + { name: 'version', type: 'string' }, + { name: 'chainId', type: 'uint256' }, + { name: 'verifyingContract', type: 'address' }, + ], + }, + primaryType: 'EIP712Domain', + domain: { + verifyingContract: '0Xae7ab96520de3a18e5e111b5eaab095312d7fe84', + }, + message: {}, + }; + + const stringifiedMessage = JSON.stringify(message); + const expectedStringifiedMessage = JSON.stringify({ + ...message, + domain: { + verifyingContract: '0xae7ab96520de3a18e5e111b5eaab095312d7fe84', + }, + }); + + const payload = { + method: 'eth_signTypedData_v3', + params: [testAddresses[0], stringifiedMessage], // Assuming testAddresses[0] is a valid address from your setup + }; + + const signTypedDataV3Result = await engine.handle( + ...createHandleParams(payload), + ); + + expect(signTypedDataV3Result).toBeDefined(); + expect(signTypedDataV3Result).toStrictEqual(testMsgSig); + expect(witnessedMsgParams).toHaveLength(1); + expect(witnessedMsgParams[0]).toMatchObject({ + from: testAddresses[0], + data: expectedStringifiedMessage, + version: 'V3', + signatureMethod: 'eth_signTypedData_v3', + }); + }); + + it('should throw if verifyingContract is invalid hex value', async () => { + const getAccounts = async (): Promise => testAddresses.slice(); + const witnessedMsgParams: TypedMessageParams[] = []; + const processTypedMessageV3 = async ( + msgParams: TypedMessageParams, + ): Promise => { + witnessedMsgParams.push(msgParams); + // Assume testMsgSig is the expected signature result + return testMsgSig; + }; + const engine = JsonRpcEngineV2.create({ + middleware: [ + createWalletMiddleware({ getAccounts, processTypedMessageV3 }), + ], + }); + + const message = { + types: { + EIP712Domain: [ + { name: 'name', type: 'string' }, + { name: 'version', type: 'string' }, + { name: 'chainId', type: 'uint256' }, + { name: 'verifyingContract', type: 'address' }, + ], + }, + primaryType: 'EIP712Domain', + domain: { + verifyingContract: '917551056842671309452305380979543736893630245704', + }, + message: {}, + }; + + const stringifiedMessage = JSON.stringify(message); + + const payload = { + method: 'eth_signTypedData_v3', + params: [testAddresses[0], stringifiedMessage], // Assuming testAddresses[0] is a valid address from your setup + }; + + await expect( + engine.handle(...createHandleParams(payload)), + ).rejects.toThrow('Invalid input.'); + }); + + it('should not throw if verifyingContract is undefined', async () => { + const getAccounts = async (): Promise => testAddresses.slice(); + const witnessedMsgParams: TypedMessageParams[] = []; + const processTypedMessageV3 = async ( + msgParams: TypedMessageParams, + ): Promise => { + witnessedMsgParams.push(msgParams); + // Assume testMsgSig is the expected signature result + return testMsgSig; + }; + const engine = JsonRpcEngineV2.create({ + middleware: [ + createWalletMiddleware({ getAccounts, processTypedMessageV3 }), + ], + }); + + const message = { + types: { + EIP712Domain: [ + { name: 'name', type: 'string' }, + { name: 'version', type: 'string' }, + { name: 'chainId', type: 'uint256' }, + { name: 'verifyingContract', type: 'address' }, + ], + }, + primaryType: 'EIP712Domain', + message: {}, + }; + + const stringifiedMessage = JSON.stringify(message); + + const payload = { + method: 'eth_signTypedData_v3', + params: [testAddresses[0], stringifiedMessage], // Assuming testAddresses[0] is a valid address from your setup + }; + + const result = await engine.handle(...createHandleParams(payload)); + expect(result).toBe( + '0x68dc980608bceb5f99f691e62c32caccaee05317309015e9454eba1a14c3cd4505d1dd098b8339801239c9bcaac3c4df95569dcf307108b92f68711379be14d81c', + ); + }); + }); + + describe('signTypedDataV4', () => { + const getMsgParams = ( + verifyingContract?: string, + ): TypedMessage => ({ + types: { + EIP712Domain: [ + { name: 'name', type: 'string' }, + { name: 'version', type: 'string' }, + { name: 'chainId', type: 'uint256' }, + { name: 'verifyingContract', type: 'address' }, + ], + Permit: [ + { name: 'owner', type: 'address' }, + { name: 'spender', type: 'address' }, + { name: 'value', type: 'uint256' }, + { name: 'nonce', type: 'uint256' }, + { name: 'deadline', type: 'uint256' }, + ], + }, + primaryType: 'Permit', + domain: { + name: 'MyToken', + version: '1', + verifyingContract: + verifyingContract ?? '0xCcCCccccCCCCcCCCCCCcCcCccCcCCCcCcccccccC', + // TODO: Investigate this further. + // @ts-expect-error: This expects a number, but hex string is used in + // practice. + chainId: '0x1', + }, + message: { + owner: testAddresses[0], + spender: '0x0dcd5d886577d5081b0c52e242ef29e70be3e7bc', + value: 3000, + nonce: 0, + deadline: 50000000000, + }, + }); + + it('should not throw if request is permit with valid hex value for verifyingContract address', async () => { + const getAccounts = async (): Promise => testAddresses.slice(); + const witnessedMsgParams: TypedMessageParams[] = []; + const processTypedMessageV4 = async ( + msgParams: TypedMessageParams, + ): Promise => { + witnessedMsgParams.push(msgParams); + // Assume testMsgSig is the expected signature result + return testMsgSig; + }; + const engine = JsonRpcEngineV2.create({ + middleware: [ + createWalletMiddleware({ getAccounts, processTypedMessageV4 }), + ], + }); + + const payload = { + method: 'eth_signTypedData_v4', + params: [testAddresses[0], JSON.stringify(getMsgParams())], + }; + + const result = await engine.handle(...createHandleParams(payload)); + expect(result).toBe( + '0x68dc980608bceb5f99f691e62c32caccaee05317309015e9454eba1a14c3cd4505d1dd098b8339801239c9bcaac3c4df95569dcf307108b92f68711379be14d81c', + ); + }); + + it('should throw if request is permit with invalid hex value for verifyingContract address', async () => { + const getAccounts = async (): Promise => testAddresses.slice(); + const witnessedMsgParams: TypedMessageParams[] = []; + const processTypedMessageV4 = async ( + msgParams: TypedMessageParams, + ): Promise => { + witnessedMsgParams.push(msgParams); + // Assume testMsgSig is the expected signature result + return testMsgSig; + }; + const engine = JsonRpcEngineV2.create({ + middleware: [ + createWalletMiddleware({ getAccounts, processTypedMessageV4 }), + ], + }); + + const payload = { + method: 'eth_signTypedData_v4', + params: [ + testAddresses[0], + JSON.stringify( + getMsgParams('917551056842671309452305380979543736893630245704'), + ), + ], + }; + + await expect( + engine.handle(...createHandleParams(payload)), + ).rejects.toThrow('Invalid input.'); + }); + + it('should not throw if request is permit with undefined value for verifyingContract address', async () => { + const getAccounts = async (): Promise => testAddresses.slice(); + const witnessedMsgParams: TypedMessageParams[] = []; + const processTypedMessageV4 = async ( + msgParams: TypedMessageParams, + ): Promise => { + witnessedMsgParams.push(msgParams); + // Assume testMsgSig is the expected signature result + return testMsgSig; + }; + const engine = JsonRpcEngineV2.create({ + middleware: [ + createWalletMiddleware({ getAccounts, processTypedMessageV4 }), + ], + }); + + const payload = { + method: 'eth_signTypedData_v4', + params: [testAddresses[0], JSON.stringify(getMsgParams())], + }; + + const result = await engine.handle(...createHandleParams(payload)); + expect(result).toBe( + '0x68dc980608bceb5f99f691e62c32caccaee05317309015e9454eba1a14c3cd4505d1dd098b8339801239c9bcaac3c4df95569dcf307108b92f68711379be14d81c', + ); + }); + + it('should not throw if request is permit with verifyingContract address equal to "cosmos"', async () => { + const getAccounts = async (): Promise => testAddresses.slice(); + const witnessedMsgParams: TypedMessageParams[] = []; + const processTypedMessageV4 = async ( + msgParams: TypedMessageParams, + ): Promise => { + witnessedMsgParams.push(msgParams); + // Assume testMsgSig is the expected signature result + return testMsgSig; + }; + const engine = JsonRpcEngineV2.create({ + middleware: [ + createWalletMiddleware({ getAccounts, processTypedMessageV4 }), + ], + }); + + const payload = { + method: 'eth_signTypedData_v4', + params: [testAddresses[0], JSON.stringify(getMsgParams('cosmos'))], + }; + + const result = await engine.handle(...createHandleParams(payload)); + expect(result).toBe( + '0x68dc980608bceb5f99f691e62c32caccaee05317309015e9454eba1a14c3cd4505d1dd098b8339801239c9bcaac3c4df95569dcf307108b92f68711379be14d81c', + ); + }); + + it('should throw if message does not have types defined', async () => { + const getAccounts = async (): Promise => testAddresses.slice(); + const witnessedMsgParams: TypedMessageParams[] = []; + const processTypedMessageV4 = async ( + msgParams: TypedMessageParams, + ): Promise => { + witnessedMsgParams.push(msgParams); + // Assume testMsgSig is the expected signature result + return testMsgSig; + }; + const engine = JsonRpcEngineV2.create({ + middleware: [ + createWalletMiddleware({ getAccounts, processTypedMessageV4 }), + ], + }); + + const messageParams = getMsgParams(); + const payload = { + method: 'eth_signTypedData_v4', + params: [ + testAddresses[0], + JSON.stringify({ ...messageParams, types: undefined }), + ], + }; + + await expect( + engine.handle(...createHandleParams(payload)), + ).rejects.toThrow('Invalid input.'); + }); + + it('should throw if type of primaryType is not defined', async () => { + const getAccounts = async (): Promise => testAddresses.slice(); + const witnessedMsgParams: TypedMessageParams[] = []; + const processTypedMessageV4 = async ( + msgParams: TypedMessageParams, + ): Promise => { + witnessedMsgParams.push(msgParams); + // Assume testMsgSig is the expected signature result + return testMsgSig; + }; + const engine = JsonRpcEngineV2.create({ + middleware: [ + createWalletMiddleware({ getAccounts, processTypedMessageV4 }), + ], + }); + + const messageParams = getMsgParams(); + const payload = { + method: 'eth_signTypedData_v4', + params: [ + testAddresses[0], + JSON.stringify({ + ...messageParams, + types: { ...messageParams.types, Permit: undefined }, + }), + ], + }; + + await expect( + engine.handle(...createHandleParams(payload)), + ).rejects.toThrow('Invalid input.'); + }); + + it('should throw if message data contains extraneous keys', async () => { + const getAccounts = async (): Promise => testAddresses.slice(); + const processTypedMessageV4 = async (): Promise => testMsgSig; + const engine = JsonRpcEngineV2.create({ + middleware: [ + createWalletMiddleware({ getAccounts, processTypedMessageV4 }), + ], + }); + + const messageParams = getMsgParams(); + const payload = { + method: 'eth_signTypedData_v4', + params: [ + testAddresses[0], + JSON.stringify({ ...messageParams, extraKey: 'unexpected' }), + ], + }; + + await expect( + engine.handle(...createHandleParams(payload)), + ).rejects.toThrow('Invalid input.'); + }); + }); + + describe('sign', () => { + it('should sign with a valid address', async () => { + const getAccounts = async (): Promise => testAddresses.slice(); + const witnessedMsgParams: MessageParams[] = []; + const processPersonalMessage = async ( + msgParams: MessageParams, + ): Promise => { + witnessedMsgParams.push(msgParams); + return testMsgSig; + }; + const engine = JsonRpcEngineV2.create({ + middleware: [ + createWalletMiddleware({ getAccounts, processPersonalMessage }), + ], + }); + + const message = 'haay wuurl'; + const payload = { + method: 'personal_sign', + params: [message, testAddresses[0]], + }; + const signMsgResult = await engine.handle(...createHandleParams(payload)); + + expect(signMsgResult).toBeDefined(); + expect(signMsgResult).toStrictEqual(testMsgSig); + expect(witnessedMsgParams).toHaveLength(1); + expect(witnessedMsgParams[0]).toStrictEqual({ + data: message, + from: testAddresses[0], + signatureMethod: 'personal_sign', + }); + }); + + it('should error when provided invalid address', async () => { + const getAccounts = async (): Promise => testAddresses.slice(); + const witnessedMsgParams: MessageParams[] = []; + const processPersonalMessage = async ( + msgParams: MessageParams, + ): Promise => { + witnessedMsgParams.push(msgParams); + return testMsgSig; + }; + const engine = JsonRpcEngineV2.create({ + middleware: [ + createWalletMiddleware({ getAccounts, processPersonalMessage }), + ], + }); + + const message = 'haay wuurl'; + const payload = { + method: 'personal_sign', + params: [message, '0x3d'], + }; + + await expect( + engine.handle(...createHandleParams(payload)), + ).rejects.toThrow( + new Error('Invalid parameters: must provide an Ethereum address.'), + ); + }); + + it('should error when provided unknown address', async () => { + const getAccounts = async (): Promise => testAddresses.slice(); + const witnessedMsgParams: MessageParams[] = []; + const processPersonalMessage = async ( + msgParams: MessageParams, + ): Promise => { + witnessedMsgParams.push(msgParams); + return testMsgSig; + }; + const engine = JsonRpcEngineV2.create({ + middleware: [ + createWalletMiddleware({ getAccounts, processPersonalMessage }), + ], + }); + + const message = 'haay wuurl'; + const payload = { + method: 'personal_sign', + params: [message, testUnkownAddress], + }; + + await expect( + engine.handle(...createHandleParams(payload)), + ).rejects.toThrow( + 'The requested account and/or method has not been authorized by the user.', + ); + }); + }); + + describe('personalRecover', () => { + it('should recover with "geth kumavis manual recover"', async () => { + const getAccounts = async (): Promise => testAddresses.slice(); + const signParams = { + testLabel: 'geth kumavis manual I recover', + // "hello world" + message: '0x68656c6c6f20776f726c64', + signature: + '0xce909e8ea6851bc36c007a0072d0524b07a3ff8d4e623aca4c71ca8e57250c4d0a3fc38fa8fbaaa81ead4b9f6bd03356b6f8bf18bccad167d78891636e1d69561b', + addressHex: '0xbe93f9bacbcffc8ee6663f2647917ed7a20a57bb', + }; + + const engine = JsonRpcEngineV2.create({ + middleware: [createWalletMiddleware({ getAccounts })], + }); + + const payload = { + method: 'personal_ecRecover', + params: [signParams.message, signParams.signature], + }; + const ecrecoverResult = await engine.handle( + ...createHandleParams(payload), + ); + expect(ecrecoverResult).toBeDefined(); + expect(ecrecoverResult).toStrictEqual(signParams.addressHex); + }); + + it('should recover with "geth kumavis manual recover II"', async () => { + const getAccounts = async (): Promise => testAddresses.slice(); + const signParams = { + testLabel: 'geth kumavis manual II recover', + // message from parity's test - note result is different than what they are testing against + // https://github.com/ethcore/parity/blob/5369a129ae276d38f3490abb18c5093b338246e0/rpc/src/v1/tests/mocked/eth.rs#L301-L317 + message: + '0x0cc175b9c0f1b6a831c399e26977266192eb5ffee6ae2fec3ad71c777531578f', + signature: + '0x9ff8350cc7354b80740a3580d0e0fd4f1f02062040bc06b893d70906f8728bb5163837fd376bf77ce03b55e9bd092b32af60e86abce48f7b8d3539988ee5a9be1c', + addressHex: '0xbe93f9bacbcffc8ee6663f2647917ed7a20a57bb', + }; + + const engine = JsonRpcEngineV2.create({ + middleware: [createWalletMiddleware({ getAccounts })], + }); + + const payload = { + method: 'personal_ecRecover', + params: [signParams.message, signParams.signature], + }; + const ecrecoverResult = await engine.handle( + ...createHandleParams(payload), + ); + expect(ecrecoverResult).toBeDefined(); + expect(ecrecoverResult).toStrictEqual(signParams.addressHex); + }); + }); + + describe('prototype pollution validation', () => { + describe('signTypedData (V1)', () => { + DANGEROUS_PROTOTYPE_PROPERTIES.forEach((dangerousProperty) => { + it(`should throw if value contains nested ${dangerousProperty}`, async () => { + const getAccounts = async (): Promise => + testAddresses.slice(); + const processTypedMessage = async (): Promise => testMsgSig; + const engine = JsonRpcEngineV2.create({ + middleware: [ + createWalletMiddleware({ getAccounts, processTypedMessage }), + ], + }); + + const value = {}; + Object.defineProperty(value, dangerousProperty, { + value: 'malicious', + enumerable: true, + }); + const message = [{ type: 'object', name: 'data', value }]; + const payload = { + method: 'eth_signTypedData', + params: [message, testAddresses[0]], + }; + + await expect( + engine.handle(...createHandleParams(payload)), + ).rejects.toThrow('Invalid input.'); + }); + }); + }); + + describe('signTypedDataV3', () => { + DANGEROUS_PROTOTYPE_PROPERTIES.forEach((dangerousProperty) => { + it(`should throw if message contains ${dangerousProperty}`, async () => { + const getAccounts = async (): Promise => + testAddresses.slice(); + const processTypedMessageV3 = async (): Promise => testMsgSig; + const engine = JsonRpcEngineV2.create({ + middleware: [ + createWalletMiddleware({ getAccounts, processTypedMessageV3 }), + ], + }); + + const msgObj = {}; + Object.defineProperty(msgObj, dangerousProperty, { + value: 'malicious', + enumerable: true, + }); + const message = { + types: { + EIP712Domain: [{ name: 'name', type: 'string' }], + }, + primaryType: 'EIP712Domain', + domain: {}, + message: msgObj, + }; + + const payload = { + method: 'eth_signTypedData_v3', + params: [testAddresses[0], JSON.stringify(message)], + }; + + await expect( + engine.handle(...createHandleParams(payload)), + ).rejects.toThrow('Invalid input.'); + }); + }); + }); + + describe('signTypedDataV4', () => { + DANGEROUS_PROTOTYPE_PROPERTIES.forEach((dangerousProperty) => { + it(`should throw if message contains ${dangerousProperty}`, async () => { + const getAccounts = async (): Promise => + testAddresses.slice(); + const processTypedMessageV4 = async (): Promise => testMsgSig; + const engine = JsonRpcEngineV2.create({ + middleware: [ + createWalletMiddleware({ getAccounts, processTypedMessageV4 }), + ], + }); + + const msgObj = {}; + Object.defineProperty(msgObj, dangerousProperty, { + value: 'malicious', + enumerable: true, + }); + const message = { + types: { + EIP712Domain: [{ name: 'name', type: 'string' }], + Permit: [{ name: 'owner', type: 'address' }], + }, + primaryType: 'Permit', + domain: {}, + message: msgObj, + }; + + const payload = { + method: 'eth_signTypedData_v4', + params: [testAddresses[0], JSON.stringify(message)], + }; + + await expect( + engine.handle(...createHandleParams(payload)), + ).rejects.toThrow('Invalid input.'); + }); + }); + }); + }); +}); diff --git a/packages/eth-json-rpc-middleware/src/wallet.ts b/packages/eth-json-rpc-middleware/src/wallet.ts new file mode 100644 index 00000000000..2d1abf84e76 --- /dev/null +++ b/packages/eth-json-rpc-middleware/src/wallet.ts @@ -0,0 +1,660 @@ +import * as sigUtil from '@metamask/eth-sig-util'; +import type { + JsonRpcMiddleware, + MiddlewareContext, + MiddlewareParams, +} from '@metamask/json-rpc-engine/v2'; +import { createScaffoldMiddleware } from '@metamask/json-rpc-engine/v2'; +import type { MessageRequest } from '@metamask/message-manager'; +import { rpcErrors } from '@metamask/rpc-errors'; +import { isValidHexAddress } from '@metamask/utils'; +import type { JsonRpcRequest, Json, Hex } from '@metamask/utils'; + +import { createWalletGetGrantedExecutionPermissionsHandler } from './methods/wallet-get-granted-execution-permissions.js'; +import type { ProcessGetGrantedExecutionPermissionsHook } from './methods/wallet-get-granted-execution-permissions.js'; +import { createWalletGetSupportedExecutionPermissionsHandler } from './methods/wallet-get-supported-execution-permissions.js'; +import type { ProcessGetSupportedExecutionPermissionsHook } from './methods/wallet-get-supported-execution-permissions.js'; +import { createWalletRequestExecutionPermissionsHandler } from './methods/wallet-request-execution-permissions.js'; +import type { ProcessRequestExecutionPermissionsHook } from './methods/wallet-request-execution-permissions.js'; +import { createWalletRevokeExecutionPermissionHandler } from './methods/wallet-revoke-execution-permission.js'; +import type { ProcessRevokeExecutionPermissionHook } from './methods/wallet-revoke-execution-permission.js'; +import { stripArrayTypeIfPresent } from './utils/common.js'; +import { normalizeTypedMessage, parseTypedMessage } from './utils/normalize.js'; +import { + resemblesAddress, + validateAndNormalizeKeyholder as validateKeyholder, + validateTransactionParams, + validateTypedDataForPrototypePollution, + validateTypedDataV1ForPrototypePollution, + validateTypedMessageKeys, +} from './utils/validation.js'; + +export type TransactionParams = { + from: string; +}; + +export type MessageParams = TransactionParams & { + data: string; + signatureMethod?: string; +}; + +export type TypedMessageParams = MessageParams & { + version: string; +}; + +export type TypedMessageV1Params = Omit & { + data: Record[]; +}; + +export type WalletMiddlewareOptions = { + getAccounts: (origin: string) => Promise; + processDecryptMessage?: ( + msgParams: MessageParams, + req: MessageRequest, + ) => Promise; + processEncryptionPublicKey?: ( + address: string, + req: MessageRequest, + ) => Promise; + processPersonalMessage?: ( + msgParams: MessageParams, + req: JsonRpcRequest, + context: WalletMiddlewareContext, + ) => Promise; + processTransaction?: ( + txParams: TransactionParams, + req: JsonRpcRequest, + context: WalletMiddlewareContext, + ) => Promise; + processSignTransaction?: ( + txParams: TransactionParams, + req: JsonRpcRequest, + context: WalletMiddlewareContext, + ) => Promise; + processTypedMessage?: ( + msgParams: TypedMessageV1Params, + req: JsonRpcRequest, + context: WalletMiddlewareContext, + version: string, + ) => Promise; + processTypedMessageV3?: ( + msgParams: TypedMessageParams, + req: JsonRpcRequest, + context: WalletMiddlewareContext, + version: string, + ) => Promise; + processTypedMessageV4?: ( + msgParams: TypedMessageParams, + req: JsonRpcRequest, + context: WalletMiddlewareContext, + version: string, + ) => Promise; + processRequestExecutionPermissions?: ProcessRequestExecutionPermissionsHook; + processRevokeExecutionPermission?: ProcessRevokeExecutionPermissionHook; + processGetGrantedExecutionPermissions?: ProcessGetGrantedExecutionPermissionsHook; + processGetSupportedExecutionPermissions?: ProcessGetSupportedExecutionPermissionsHook; +}; + +export type WalletMiddlewareKeyValues = { + networkClientId: string; + origin: string; + securityAlertResponse?: Record; + traceContext?: unknown; +}; + +export type WalletMiddlewareContext = + MiddlewareContext; + +export type WalletMiddlewareParams = MiddlewareParams< + JsonRpcRequest, + WalletMiddlewareContext +>; + +/** + * Creates a JSON-RPC middleware that handles "wallet"-related JSON-RPC methods. + * "Wallet" may have had a specific meaning at some point in the distant past, + * but at this point it's just an arbitrary label. + * + * @param options - The options for the middleware. + * @param options.getAccounts - The function to get the accounts for the origin. + * @param options.processDecryptMessage - The function to process the decrypt message request. + * @param options.processEncryptionPublicKey - The function to process the encryption public key request. + * @param options.processPersonalMessage - The function to process the personal message request. + * @param options.processTransaction - The function to process the transaction request. + * @param options.processSignTransaction - The function to process the sign transaction request. + * @param options.processTypedMessage - The function to process the typed message request. + * @param options.processTypedMessageV3 - The function to process the typed message v3 request. + * @param options.processTypedMessageV4 - The function to process the typed message v4 request. + * @param options.processRequestExecutionPermissions - The function to process the request execution permissions request. + * @param options.processRevokeExecutionPermission - The function to process the revoke execution permission request. + * @param options.processGetGrantedExecutionPermissions - The function to process the get granted execution permissions request. + * @param options.processGetSupportedExecutionPermissions - The function to process the get supported execution permissions request. + * @returns A JSON-RPC middleware that handles wallet-related JSON-RPC methods. + */ +export function createWalletMiddleware({ + getAccounts, + processDecryptMessage, + processEncryptionPublicKey, + processPersonalMessage, + processTransaction, + processSignTransaction, + processTypedMessage, + processTypedMessageV3, + processTypedMessageV4, + processRequestExecutionPermissions, + processRevokeExecutionPermission, + processGetGrantedExecutionPermissions, + processGetSupportedExecutionPermissions, +}: WalletMiddlewareOptions): JsonRpcMiddleware< + JsonRpcRequest, + Json, + WalletMiddlewareContext +> { + if (!getAccounts) { + throw new Error('opts.getAccounts is required'); + } + + return createScaffoldMiddleware({ + // account lookups + eth_accounts: lookupAccounts, + eth_coinbase: lookupDefaultAccount, + + // tx signatures + eth_sendTransaction: sendTransaction, + eth_signTransaction: signTransaction, + + // message signatures + eth_signTypedData: signTypedData, + eth_signTypedData_v3: signTypedDataV3, + eth_signTypedData_v4: signTypedDataV4, + personal_sign: personalSign, + eth_getEncryptionPublicKey: encryptionPublicKey, + eth_decrypt: decryptMessage, + personal_ecRecover: personalRecover, + + // EIP-7715 + wallet_requestExecutionPermissions: + createWalletRequestExecutionPermissionsHandler({ + processRequestExecutionPermissions, + }), + wallet_revokeExecutionPermission: + createWalletRevokeExecutionPermissionHandler({ + processRevokeExecutionPermission, + }), + wallet_getGrantedExecutionPermissions: + createWalletGetGrantedExecutionPermissionsHandler({ + processGetGrantedExecutionPermissions, + }), + wallet_getSupportedExecutionPermissions: + createWalletGetSupportedExecutionPermissionsHandler({ + processGetSupportedExecutionPermissions, + }), + }); + + // + // account lookups + // + + /** + * Gets the accounts for the origin. + * + * @param options - Options bag. + * @param options.context - The context of the request. + * @returns The accounts for the origin. + */ + async function lookupAccounts({ + context, + }: WalletMiddlewareParams): Promise { + return await getAccounts(context.assertGet('origin')); + } + + /** + * Gets the default account (i.e. first in the list) for the origin. + * + * @param options - Options bag. + * @param options.context - The context of the request. + * @returns The default account for the origin. + */ + async function lookupDefaultAccount({ + context, + }: WalletMiddlewareParams): Promise { + const accounts = await getAccounts(context.assertGet('origin')); + return accounts[0] || null; + } + + // + // transaction signatures + // + + /** + * Sends a transaction. + * + * @param options - Options bag. + * @param options.request - The request. + * @param options.context - The context of the request. + * @returns The transaction hash. + */ + async function sendTransaction({ + request, + context, + }: WalletMiddlewareParams): Promise { + if (!processTransaction) { + throw rpcErrors.methodNotSupported(); + } + if ( + !request.params || + !Array.isArray(request.params) || + !(request.params.length >= 1) + ) { + throw rpcErrors.invalidInput(); + } + + const params = request.params[0] as TransactionParams | undefined; + validateTransactionParams(params); + const txParams: TransactionParams = { + ...params, + // Not using nullish coalescing, since `params` may be `null`. + // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing + from: await validateAndNormalizeKeyholder(params?.from || '', context), + }; + return await processTransaction(txParams, request, context); + } + + /** + * Signs a transaction. + * + * @param options - Options bag. + * @param options.request - The request. + * @param options.context - The context of the request. + * @returns The signed transaction. + */ + async function signTransaction({ + request, + context, + }: WalletMiddlewareParams): Promise { + if (!processSignTransaction) { + throw rpcErrors.methodNotSupported(); + } + if ( + !request.params || + !Array.isArray(request.params) || + !(request.params.length >= 1) + ) { + throw rpcErrors.invalidInput(); + } + + const params = request.params[0] as TransactionParams | undefined; + validateTransactionParams(params); + const txParams: TransactionParams = { + ...params, + // Not using nullish coalescing, since `params` may be `null`. + // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing + from: await validateAndNormalizeKeyholder(params?.from || '', context), + }; + return await processSignTransaction(txParams, request, context); + } + + // + // message signatures + // + + /** + * Signs a `eth_signTypedData` message. + * + * @param options - Options bag. + * @param options.request - The request. + * @param options.context - The context of the request. + * @returns The signed message. + */ + async function signTypedData({ + request, + context, + }: WalletMiddlewareParams): Promise { + if (!processTypedMessage) { + throw rpcErrors.methodNotSupported(); + } + if ( + !request.params || + !Array.isArray(request.params) || + !(request.params.length >= 2) + ) { + throw rpcErrors.invalidInput(); + } + + const params = request.params as [ + Record[], + string, + Record?, + ]; + const message = params[0]; + const address = await validateAndNormalizeKeyholder(params[1], context); + const version = 'V1'; + validateTypedDataV1ForPrototypePollution(message); + // Not using nullish coalescing, since `params` may be `null`. + // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing + const extraParams = params[2] || {}; + const msgParams: TypedMessageV1Params = { + ...extraParams, + from: address, + data: message, + signatureMethod: 'eth_signTypedData', + version, + }; + + return await processTypedMessage(msgParams, request, context, version); + } + + /** + * Signs a `eth_signTypedData_v3` message. + * + * @param options - Options bag. + * @param options.request - The request. + * @param options.context - The context of the request. + * @returns The signed message. + */ + async function signTypedDataV3({ + request, + context, + }: WalletMiddlewareParams): Promise { + if (!processTypedMessageV3) { + throw rpcErrors.methodNotSupported(); + } + if ( + !request.params || + !Array.isArray(request.params) || + !(request.params.length >= 2) + ) { + throw rpcErrors.invalidInput(); + } + + const params = request.params as [string, string]; + + const address = await validateAndNormalizeKeyholder(params[0], context); + const message = normalizeTypedMessage(params[1]); + validatePrimaryType(message); + validateVerifyingContract(message); + validateTypedDataForPrototypePollution(message); + const version = 'V3'; + const msgParams: TypedMessageParams = { + data: message, + from: address, + version, + signatureMethod: 'eth_signTypedData_v3', + }; + + return await processTypedMessageV3(msgParams, request, context, version); + } + + /** + * Signs a `eth_signTypedData_v4` message. + * + * @param options - Options bag. + * @param options.request - The request. + * @param options.context - The context of the request. + * @returns The signed message. + */ + async function signTypedDataV4({ + request, + context, + }: WalletMiddlewareParams): Promise { + if (!processTypedMessageV4) { + throw rpcErrors.methodNotSupported(); + } + if ( + !request.params || + !Array.isArray(request.params) || + !(request.params.length >= 2) + ) { + throw rpcErrors.invalidInput(); + } + + const params = request.params as [string, string]; + + const address = await validateAndNormalizeKeyholder(params[0], context); + const message = normalizeTypedMessage(params[1]); + validateTypedMessageKeys(message); + validatePrimaryType(message); + validateVerifyingContract(message); + validateTypedDataForPrototypePollution(message); + const version = 'V4'; + const msgParams: TypedMessageParams = { + data: message, + from: address, + version, + signatureMethod: 'eth_signTypedData_v4', + }; + + return await processTypedMessageV4(msgParams, request, context, version); + } + + /** + * Signs a `personal_sign` message. + * + * @param options - Options bag. + * @param options.request - The request. + * @param options.context - The context of the request. + * @returns The signed message. + */ + async function personalSign({ + request, + context, + }: WalletMiddlewareParams): Promise { + if (!processPersonalMessage) { + throw rpcErrors.methodNotSupported(); + } + if ( + !request.params || + !Array.isArray(request.params) || + !(request.params.length >= 2) + ) { + throw rpcErrors.invalidInput(); + } + + const params = request.params as [string, string, TransactionParams?]; + + // process normally + const firstParam = params[0]; + const secondParam = params[1]; + // non-standard "extraParams" to be appended to our "msgParams" obj + // Not using nullish coalescing, since `params` may be `null`. + // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing + const extraParams = params[2] || {}; + + // We initially incorrectly ordered these parameters. + // To gracefully respect users who adopted this API early, + // we are currently gracefully recovering from the wrong param order + // when it is clearly identifiable. + // + // That means when the first param is definitely an address, + // and the second param is definitely not, but is hex. + let address: string, message: string; + if (resemblesAddress(firstParam) && !resemblesAddress(secondParam)) { + address = firstParam; + message = secondParam; + } else { + message = firstParam; + address = secondParam; + } + address = await validateAndNormalizeKeyholder(address, context); + + const msgParams: MessageParams = { + ...extraParams, + from: address, + data: message, + signatureMethod: 'personal_sign', + }; + + return await processPersonalMessage(msgParams, request, context); + } + + /** + * Recovers the signer address from a `personal_sign` message. + * + * @param options - Options bag. + * @param options.request - The request. + * @returns The recovered signer address. + */ + async function personalRecover({ + request, + }: WalletMiddlewareParams): Promise { + if ( + !request.params || + !Array.isArray(request.params) || + !(request.params.length >= 2) + ) { + throw rpcErrors.invalidInput(); + } + + const params = request.params as [string, string]; + const message = params[0]; + const signature = params[1]; + const signerAddress = sigUtil.recoverPersonalSignature({ + data: message, + signature, + }); + + return signerAddress; + } + + /** + * Gets the encryption public key for an address. + * + * @param options - Options bag. + * @param options.request - The request. + * @param options.context - The context of the request. + * @returns The encryption public key. + */ + async function encryptionPublicKey({ + request, + context, + }: WalletMiddlewareParams): Promise { + if (!processEncryptionPublicKey) { + throw rpcErrors.methodNotSupported(); + } + if ( + !request.params || + !Array.isArray(request.params) || + !(request.params.length >= 1) + ) { + throw rpcErrors.invalidInput(); + } + + const params = request.params as [string]; + + const address = await validateAndNormalizeKeyholder(params[0], context); + + return await processEncryptionPublicKey(address, { + id: request.id as string | number, + origin: context.assertGet('origin'), + securityAlertResponse: context.get('securityAlertResponse'), + }); + } + + /** + * Decrypts a message. + * + * @param options - Options bag. + * @param options.request - The request. + * @param options.context - The context of the request. + * @returns The decrypted message. + */ + async function decryptMessage({ + request, + context, + }: WalletMiddlewareParams): Promise { + if (!processDecryptMessage) { + throw rpcErrors.methodNotSupported(); + } + if ( + !request.params || + !Array.isArray(request.params) || + !(request.params.length >= 1) + ) { + throw rpcErrors.invalidInput(); + } + const params = request.params as [string, string, Record?]; + + const ciphertext: string = params[0]; + const address: string = await validateAndNormalizeKeyholder( + params[1], + context, + ); + // Not using nullish coalescing, since `params` may be `null`. + // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing + const extraParams = params[2] || {}; + const msgParams: MessageParams = { + ...extraParams, + from: address, + data: ciphertext, + }; + + return await processDecryptMessage(msgParams, { + id: request.id as string | number, + origin: context.assertGet('origin'), + securityAlertResponse: context.get('securityAlertResponse'), + }); + } + + // + // utility + // + + /** + * Validates the keyholder address, and returns a normalized (i.e. lowercase) + * copy of it. + * + * @param address - The address to validate and normalize. + * @param context - The context of the request. + * @returns The normalized address, if valid. Otherwise, throws + * an error + */ + async function validateAndNormalizeKeyholder( + address: string, + context: WalletMiddlewareContext, + ): Promise { + return validateKeyholder(address as Hex, context, { getAccounts }); + } +} + +/** + * Validates primary of typedSignMessage, to ensure that it's type definition is present in message. + * + * @param data - The data passed in typedSign request. + */ +function validatePrimaryType(data: string): void { + const { primaryType, types } = parseTypedMessage(data); + if (!types) { + throw rpcErrors.invalidInput(); + } + + // Primary type can be an array. + const baseType = stripArrayTypeIfPresent(primaryType); + + // Return if the base type is not defined in the types + const baseTypeDefinitions = types[baseType]; + if (!baseTypeDefinitions) { + throw rpcErrors.invalidInput(); + } +} + +/** + * Validates verifyingContract of typedSignMessage. + * + * @param data - The data passed in typedSign request. + * This function allows the verifyingContract to be either: + * - A valid hex address + * - The string "cosmos" (as it is hard-coded in some Cosmos ecosystem's EVM adapters) + * - An empty string + */ +function validateVerifyingContract(data: string): void { + const { domain: { verifyingContract } = {} } = parseTypedMessage(data); + // Explicit check for cosmos here has been added to address this issue + // https://github.com/MetaMask/eth-json-rpc-middleware/issues/337 + if ( + verifyingContract && + (verifyingContract as string) !== 'cosmos' && + !isValidHexAddress(verifyingContract) + ) { + throw rpcErrors.invalidInput(); + } +} diff --git a/packages/eth-json-rpc-middleware/test/setupAfterEnv.ts b/packages/eth-json-rpc-middleware/test/setupAfterEnv.ts new file mode 100644 index 00000000000..64f03ebcfda --- /dev/null +++ b/packages/eth-json-rpc-middleware/test/setupAfterEnv.ts @@ -0,0 +1,75 @@ +const UNRESOLVED = Symbol('timedOut'); +// Store this in case it gets stubbed later +const originalSetTimeout = global.setTimeout; +const TIME_TO_WAIT_UNTIL_UNRESOLVED = 100; + +/** + * 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 + * the promise that is produced by this function resolves first, then the other + * one must be unresolved. + * + * @param duration - How long to wait before resolving the promise returned by + * this function. + * @returns A promise that resolves to a symbol. + */ +const treatUnresolvedAfter = async ( + duration: number, +): Promise => { + return new Promise((resolve) => { + originalSetTimeout(resolve, duration, UNRESOLVED); + }); +}; + +expect.extend({ + /** + * Tests that the given promise is never fulfilled or rejected past a certain + * amount of time (which is the default time that Jest tests wait before + * timing out as configured in the Jest configuration file). + * + * Inspired by . + * + * @param promise - The promise to test. + * @returns The result of the matcher. + */ + async toNeverResolve(promise: Promise) { + if (this.isNot) { + throw new Error( + 'Using `.not.toNeverResolve(...)` is not supported. ' + + 'You probably want to either `await` the promise and test its ' + + 'resolution value or use `.rejects` to test its rejection value instead.', + ); + } + + let resolutionValue: unknown; + let rejectionValue: unknown; + try { + resolutionValue = await Promise.race([ + promise, + treatUnresolvedAfter(TIME_TO_WAIT_UNTIL_UNRESOLVED), + ]); + } catch (error) { + rejectionValue = error; + } + + return resolutionValue === UNRESOLVED + ? { + message: (): string => + `Expected promise to resolve after ${TIME_TO_WAIT_UNTIL_UNRESOLVED}ms, but it did not`, + pass: true, + } + : { + message: (): string => { + return `Expected promise to never resolve after ${TIME_TO_WAIT_UNTIL_UNRESOLVED}ms, but it ${ + rejectionValue + ? `was rejected with ${JSON.stringify(rejectionValue, null, 2)}` + : `resolved with ${JSON.stringify(resolutionValue, null, 2)}` + }`; + }, + pass: false, + }; + }, +}); + +// Export something so that TypeScript knows to interpret this as a module +export {}; diff --git a/packages/eth-json-rpc-middleware/test/util/helpers.ts b/packages/eth-json-rpc-middleware/test/util/helpers.ts new file mode 100644 index 00000000000..617fbad474e --- /dev/null +++ b/packages/eth-json-rpc-middleware/test/util/helpers.ts @@ -0,0 +1,330 @@ +import { PollingBlockTracker } from '@metamask/eth-block-tracker'; +import { InternalProvider } from '@metamask/eth-json-rpc-provider'; +import { JsonRpcEngine } from '@metamask/json-rpc-engine'; +import { JsonRpcEngineV2 } from '@metamask/json-rpc-engine/v2'; +import type { + JsonRpcMiddleware, + ResultConstraint, +} from '@metamask/json-rpc-engine/v2'; +import type { Json, JsonRpcParams, JsonRpcRequest } from '@metamask/utils'; +import { klona } from 'klona/full'; +import { isDeepStrictEqual } from 'util'; + +import type { WalletMiddlewareKeyValues } from '../../src/wallet.js'; + +export const createRequest = < + Input extends Partial>, + Output extends Input & JsonRpcRequest, +>( + request: Input, +): Output => { + return { + jsonrpc: '2.0', + id: request.id ?? '1', + method: request.method ?? 'test_request', + // Not using nullish coalescing, since `params` may be `null`. + // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing + params: request.params === undefined ? [] : request.params, + } as Output; +}; + +const createHandleOptions = ( + keyValues: Partial = {}, +): { context: WalletMiddlewareKeyValues } => ({ + context: { + networkClientId: 'test-client-id', + origin: 'test-origin', + ...keyValues, + }, +}); + +export const createHandleParams = < + InputReq extends Partial>, + OutputReq extends InputReq & JsonRpcRequest, +>( + request: InputReq, + keyValues: Partial = {}, +): [OutputReq, ReturnType] => [ + createRequest(request), + createHandleOptions(keyValues), +]; + +/** + * An object that can be used to assign a canned result to a request made via + * `provider.request`. + * + * @template Params - The type that represents the request params. + * @template Result - The type that represents the result. + */ +export type ProviderRequestStub< + Params extends JsonRpcParams, + Result extends Json, +> = { + /** + * An object that represents a JsonRpcRequest. Keys such as + * `id` or `jsonrpc` may be omitted if you don't care about them. + */ + request: Partial>; + /** + * A function that returns a result for that request. + * This function takes `callNumber` argument, + * which is the number of times the request has been made + * (counting the first request as 1). This latter argument be used to specify + * different results for different instances of the same request. + */ + result: (callNumber: number) => Promise; + /** + * Usually, when a request is made via + * `provider.request`, the ProviderRequestStub which matches that request is + * removed from the list of stubs, so that if the same request comes through + * again, there will be no matching stub and an error will be thrown. This + * feature is useful for making sure that all requests have canned results. + */ + remainAfterUse?: boolean; +}; + +/** + * Creates a middleware function that ends the request, but not before ensuring + * that the result has been filled with something. Additionally this function + * is a Jest mock function so that you can make assertions on it. + * + * @template Params - The type that represents the request params. + * @template Result - The type that represents the result. + * @returns The created middleware, as a mock function. + */ +export function createFinalMiddlewareWithDefaultResult(): JsonRpcMiddleware { + return jest.fn(async ({ next }) => { + // Not a Node.js callback + // eslint-disable-next-line n/callback-return + const result = await next(); + if (result === undefined) { + return 'default result'; + } + return result; + }); +} + +/** + * Creates a provider and block tracker. The provider is the block tracker's + * provider. + * + * @returns The provider and block tracker. + */ +export function createProviderAndBlockTracker(): { + provider: InternalProvider; + blockTracker: PollingBlockTracker; +} { + const engine = new JsonRpcEngine(); + const provider = new InternalProvider({ engine }); + + const blockTracker = new PollingBlockTracker({ + provider, + }); + + return { provider, blockTracker }; +} + +// An expedient for use with createEngine below. +// eslint-disable-next-line @typescript-eslint/no-explicit-any +type AnyMiddleware = JsonRpcMiddleware, any>; + +/** + * Creates a JSON-RPC engine with the middleware under test and any + * additional middleware. If no other middleware is provided, a final middleware + * that returns a default result is added. + * + * @param middlewareUnderTest - The middleware under test. + * @param otherMiddleware - Any additional middleware. + * @returns The created engine. + */ +export function createEngine( + middlewareUnderTest: AnyMiddleware, + ...otherMiddleware: AnyMiddleware[] +): JsonRpcEngineV2 { + return JsonRpcEngineV2.create({ + middleware: [ + middlewareUnderTest, + ...(otherMiddleware.length === 0 + ? [createFinalMiddlewareWithDefaultResult()] + : otherMiddleware), + ], + }); +} + +/** + * Some JSON-RPC endpoints take a "block" param (example: `eth_blockNumber`) + * which can optionally be left out. Additionally, the endpoint may support some + * number of arguments, although the "block" param will always be last, even if + * it is optional. Given this, this function creates a `params` array for such an + * endpoint with the given "block" param added at the end. + * + * @param blockParamIndex - The index within the `params` array to add the "block" param. + * @param blockParam - The desired "block" param to add. + * @returns The mock params. + */ +export function createMockParamsWithBlockParamAt( + blockParamIndex: number, + blockParam: string, +): string[] { + const params = []; + + for (let i = 0; i < blockParamIndex; i++) { + params.push('some value'); + } + + params.push(blockParam); + return params; +} + +/** + * Some JSON-RPC endpoints take a "block" param (example: `eth_blockNumber`) + * which can optionally be left out. Additionally, the endpoint may support some + * number of arguments, although the "block" param will always be last, even if + * it is optional. Given this, this function creates a mock `params` array for + * such an endpoint, filling it with arbitrary values, but with the "block" + * param missing. + * + * @param blockParamIndex - The index within the `params` array where the "block" param + * would* appear. + * @returns The mock params. + */ +export function createMockParamsWithoutBlockParamAt( + blockParamIndex: number, +): string[] { + const params = []; + + for (let i = 0; i < blockParamIndex; i++) { + params.push('some value'); + } + + return params; +} + +/** + * Creates a canned result for a `eth_blockNumber` request made to + * `provider.request` such that the result will return the given block + * number. Intended to be used in conjunction with `stubProviderRequests`. + * + * @param blockNumber - The block number (default: '0x0'). + * @returns The request/result pair. + */ +export function createStubForBlockNumberRequest( + blockNumber = '0x0', +): ProviderRequestStub { + return { + request: { + method: 'eth_blockNumber', + params: [], + }, + result: async () => blockNumber, + }; +} + +/** + * Creates a canned result for a request made to `provider.request`. Intended + * to be used in conjunction with `stubProviderRequests`. Although not strictly + * necessary, it helps to assign a proper type to a request/result pair. + * + * @template Params - The type that represents the request params. + * @template Result - The type that represents the result. + * @param requestStub - The request/result pair. + * @returns The request/result pair, properly typed. + */ +export function createStubForGenericRequest< + Params extends JsonRpcParams, + Result extends Json, +>( + requestStub: ProviderRequestStub, +): ProviderRequestStub { + return requestStub; +} + +/** + * Asserts that `provider.request` has not been called with the given request + * object (or an object that can matched to that request). + * + * @param requestSpy - The Jest spy object that represents + * `provider.request`. + * @param requestMatcher - An object that can be matched to a request passed to + * `provider.request`. + */ +export function expectProviderRequestNotToHaveBeenMade( + requestSpy: jest.SpyInstance, + requestMatcher: Partial, +): void { + expect( + requestSpy.mock.calls.some((args) => + requestMatches(requestMatcher, args[0]), + ), + ).toBe(false); +} + +/** + * Provides a way to assign specific results to specific requests that are + * made through a provider. When `provider.request` is called, a stub matching + * the request will be looked for; if one is found, it is used and then + * discarded, unless `remainAfterUse` is set for the stub. + * + * @param provider - The provider. + * @param stubs - A series of pairs, where each pair specifies a request object + * — or part of one, at least — and a result for that request. The result + * is actually a function that takes one argument, which is the number of times + * that request has been made (counting the first as 1). + * This latter argument be used to specify different results for different + * instances of the same request. The function should return a result. + * @returns The Jest spy object that represents `provider.request` (so that + * you can make assertions on the method later, if you like). + */ +export function stubProviderRequests< + Params extends JsonRpcParams = JsonRpcParams, + Result extends Json = Json, +>( + provider: InternalProvider, + stubs: ProviderRequestStub[], +): jest.SpyInstance, Parameters> { + const remainingStubs = klona(stubs); + const callNumbersByRequest = new Map, number>(); + return jest.spyOn(provider, 'request').mockImplementation(async (request) => { + const stubIndex = remainingStubs.findIndex((stub) => + requestMatches(stub.request, request), + ); + + if (stubIndex === -1) { + throw new Error(`Unrecognized request ${JSON.stringify(request)}`); + } else { + const stub = remainingStubs[stubIndex]; + const callNumber = callNumbersByRequest.get(stub.request) ?? 1; + + callNumbersByRequest.set(stub.request, callNumber + 1); + + if (!stub.remainAfterUse) { + remainingStubs.splice(stubIndex, 1); + } + + return await stub.result(callNumber); + } + }); +} + +/** + * When using `stubProviderRequests` to list canned results for specific + * requests that are made to `provider.request`, you don't need to provide the + * full request object to go along with the result, but only part of that + * request object. When `provider.request` is then called, we can look up the + * compare the real request object to the request object that was specified to + * find a match. This function is used to do that comparison (and other + * like comparisons). + * + * @param requestMatcher - A partial request object. + * @param request - A real request object. + * @returns True or false depending on whether the partial request object "fits + * inside" the real request object. + */ +export function requestMatches( + requestMatcher: Partial, + request: Partial, +): boolean { + return (Object.keys(requestMatcher) as (keyof typeof requestMatcher)[]).every( + (key) => isDeepStrictEqual(requestMatcher[key], request[key]), + ); +} diff --git a/packages/eth-json-rpc-middleware/tsconfig.build.json b/packages/eth-json-rpc-middleware/tsconfig.build.json new file mode 100644 index 00000000000..58262a318e9 --- /dev/null +++ b/packages/eth-json-rpc-middleware/tsconfig.build.json @@ -0,0 +1,24 @@ +{ + "extends": "../../tsconfig.packages.build.json", + "compilerOptions": { + "baseUrl": "./", + "outDir": "./dist", + "rootDir": "./src" + }, + "references": [ + { + "path": "../eth-block-tracker/tsconfig.build.json" + }, + { + "path": "../eth-json-rpc-provider/tsconfig.build.json" + }, + { + "path": "../json-rpc-engine/tsconfig.build.json" + }, + { + "path": "../message-manager/tsconfig.build.json" + } + ], + "include": ["../../types", "./src"], + "exclude": ["**/*.test.ts", "**/*.test-d.ts"] +} diff --git a/packages/eth-json-rpc-middleware/tsconfig.json b/packages/eth-json-rpc-middleware/tsconfig.json new file mode 100644 index 00000000000..b77e42e3a7c --- /dev/null +++ b/packages/eth-json-rpc-middleware/tsconfig.json @@ -0,0 +1,21 @@ +{ + "extends": "../../tsconfig.packages.json", + "compilerOptions": { + "baseUrl": "./" + }, + "references": [ + { + "path": "../eth-block-tracker" + }, + { + "path": "../eth-json-rpc-provider" + }, + { + "path": "../json-rpc-engine" + }, + { + "path": "../message-manager" + } + ], + "include": ["../../types", "./src", "./test"] +} diff --git a/packages/eth-json-rpc-middleware/typedoc.json b/packages/eth-json-rpc-middleware/typedoc.json new file mode 100644 index 00000000000..c9da015dbf8 --- /dev/null +++ b/packages/eth-json-rpc-middleware/typedoc.json @@ -0,0 +1,7 @@ +{ + "entryPoints": ["./src/index.ts"], + "excludePrivate": true, + "hideGenerator": true, + "out": "docs", + "tsconfig": "./tsconfig.build.json" +} diff --git a/packages/eth-json-rpc-provider/CHANGELOG.md b/packages/eth-json-rpc-provider/CHANGELOG.md index 735b3b0f678..1bd1117d55a 100644 --- a/packages/eth-json-rpc-provider/CHANGELOG.md +++ b/packages/eth-json-rpc-provider/CHANGELOG.md @@ -1,27 +1,226 @@ # 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] + +### Changed + +- Bump `@metamask/utils` from `^11.9.0` to `^11.11.0` ([#9074](https://github.com/MetaMask/core/pull/9074)) +- Bump `@metamask/json-rpc-engine` from `^10.2.4` to `^10.5.0` ([#8661](https://github.com/MetaMask/core/pull/8661), [#8746](https://github.com/MetaMask/core/pull/8746), [#8753](https://github.com/MetaMask/core/pull/8753)) + +## [6.0.1] + +### Changed + +- Bump `@metamask/json-rpc-engine` from `^10.2.0` to `^10.2.4` ([#7642](https://github.com/MetaMask/core/pull/7642), [#7856](https://github.com/MetaMask/core/pull/7856), [#8078](https://github.com/MetaMask/core/pull/8078), [#8317](https://github.com/MetaMask/core/pull/8317)) +- Upgrade `@metamask/utils` from `^11.8.1` to `^11.9.0` ([#7511](https://github.com/MetaMask/core/pull/7511)) + +## [6.0.0] + ### Added + +- Add `providerFromMiddlewareV2` ([#7001](https://github.com/MetaMask/core/pull/7001)) + - This accepts the new middleware from `@metamask/json-rpc-engine/v2`. +- Add `context` option to `InternalProvider.request()` ([#7061](https://github.com/MetaMask/core/pull/7061)) + - Enables passing a `MiddlewareContext` to the JSON-RPC server. + +### Changed + +- Bump `@metamask/json-rpc-engine` from `^10.1.1` to `^10.2.0` ([#7202](https://github.com/MetaMask/core/pull/7202)) +- **BREAKING:** Replace `SafeEventEmitterProvider` with `InternalProvider` ([#6796](https://github.com/MetaMask/core/pull/6796)) + - The new class is behaviorally equivalent to the previous version except it does not extend `SafeEventEmitter`. + - `SafeEventEmitterProvider` is for now still exported as a deprecated alias of `InternalProvider` for backwards compatibility. +- **BREAKING:** Migrate from `JsonRpcEngine` to `JsonRpcEngineV2` ([#7001](https://github.com/MetaMask/core/pull/7001)) + - Legacy `JsonRpcEngine` instances are wrapped in a `JsonRpcEngineV2` internally wherever they appear. + This change should mostly be unobservable. However, due to differences in error handling, this may be breaking for consumers. + +### Deprecated + +- Deprecate `providerFromMiddleware` ([#7001](https://github.com/MetaMask/core/pull/7001)) + - Use `providerFromMiddlewareV2` instead, which supports the new middleware from `@metamask/json-rpc-engine/v2`. + +### Removed + +- **BREAKING:** Remove `providerFromEngine` ([#7001](https://github.com/MetaMask/core/pull/7001)) + - Use `InternalProvider` directly instead. + +## [5.0.1] + +### Changed + +- Bump `@metamask/utils` from `^11.8.0` to `^11.8.1` ([#6708](https://github.com/MetaMask/core/pull/6708)) +- Bump `@metamask/json-rpc-engine` from `^10.1.0` to `^10.1.1` ([#6807](https://github.com/MetaMask/core/pull/6807)) + +## [5.0.0] + +### Changed + +- **BREAKING:** Remove `'data'` event ([#6328](https://github.com/MetaMask/core/pull/6328)) + - This event was forwarding the `'notification'` event from the underlying `JsonRpcEngine`. It was rarely used in practice, and is now removed. +- Bump `@metamask/utils` from `^11.2.0` to `^11.4.2` ([#6054](https://github.com/MetaMask/core/pull/6054)) +- Bump `@metamask/utils` from `^11.4.2` to `^11.8.0` ([#6588](https://github.com/MetaMask/core/pull/6588)) +- Bump `@metamask/json-rpc-engine` from `^10.0.3` to `^10.1.0` ([#6678](https://github.com/MetaMask/core/pull/6678)) + +## [4.1.8] + +### Changed + +- Bump `@metamask/utils` from `^11.0.1` to `^11.1.0` ([#5223](https://github.com/MetaMask/core/pull/5223)) + +## [4.1.7] + +### Changed + +- Bump `@metamask/json-rpc-engine` from `^10.0.1` to `^10.0.2` ([#5082](https://github.com/MetaMask/core/pull/5082)) +- Bump `@metamask/utils` from `^10.0.0` to `^11.0.1` ([#5080](https://github.com/MetaMask/core/pull/5080)) +- Bump `@metamask/rpc-errors` from `^7.0.0` to `^7.0.2` ([#5080](https://github.com/MetaMask/core/pull/5080)) + +## [4.1.6] + +### Changed + +- Bump `@metamask/utils` from `^9.1.0` to `^10.0.0` ([#4831](https://github.com/MetaMask/core/pull/4831)) +- Bump `@metamask/rpc-errors` from `^6.3.1` to `^7.0.0` ([#4769](https://github.com/MetaMask/core/pull/4769)) + +## [4.1.5] + +### Fixed + +- Bump `@metamask/json-rpc-engine` to `^10.0.0` ([#4798](https://github.com/MetaMask/core/pull/4798)) + +## [4.1.4] + +### Fixed + +- Produce and export ESM-compatible TypeScript type declaration files in addition to CommonJS-compatible declaration files ([#4648](https://github.com/MetaMask/core/pull/4648)) + - Previously, this package shipped with only one variant of type declaration + files, and these files were only CommonJS-compatible, and the `exports` + field in `package.json` linked to these files. This is an anti-pattern and + was rightfully flagged by the + ["Are the Types Wrong?"](https://arethetypeswrong.github.io/) tool as + ["masquerading as CJS"](https://github.com/arethetypeswrong/arethetypeswrong.github.io/blob/main/docs/problems/FalseCJS.md). + All of the ATTW checks now pass. +- Remove chunk files ([#4648](https://github.com/MetaMask/core/pull/4648)). + - Previously, the build tool we used to generate JavaScript files extracted + common code to "chunk" files. While this was intended to make this package + more tree-shakeable, it also made debugging more difficult for our + development teams. These chunk files are no longer present. + +## [4.1.3] + +### Changed + +- Bump `typescript` from `~5.0.4` to `~5.2.2` ([#4576](https://github.com/MetaMask/core/pull/4576), [#4584](https://github.com/MetaMask/core/pull/4584)) + +### Fixed + +- Fix SafeEventEmitterProvider invalid default params ([#4603](https://github.com/MetaMask/core/pull/4603)) + +## [4.1.2] + +### Changed + +- Upgrade TypeScript version to `~5.0.4` and set `moduleResolution` option to `Node16` ([#3645](https://github.com/MetaMask/core/pull/3645)) +- Bump `@metamask/json-rpc-engine` from `^9.0.1` to `^9.0.2` ([#4544](https://github.com/MetaMask/core/pull/4544)) +- Bump `@metamask/utils` from `^9.0.0` to `^9.1.0` ([#4529](https://github.com/MetaMask/core/pull/4529)) + +## [4.1.1] + +### Changed + +- Bump `@metamask/json-rpc-engine` to `^9.0.1` ([#4517](https://github.com/MetaMask/core/pull/4517)) +- Bump `@metamask/rpc-errors` to `^6.3.1` ([#4516](https://github.com/MetaMask/core/pull/4516)) +- Bump `@metamask/utils` to `^9.0.0` ([#4516](https://github.com/MetaMask/core/pull/4516)) + +## [4.1.0] + +### Added + +- Make `SafeEventEmitterProvider` EIP-1193 compatible by adding a `request` method ([#4422](https://github.com/MetaMask/core/pull/4422)) + - Now `SafeEventEmitterProvider` is compatible with `@metamask/eth-query`, `@metamask/ethjs-query`, `BrowserProvider` from Ethers v6 and `Web3Provider` from Ethers v5 + +### Deprecated + +- Mark `sendAsync` method as deprecated in favor of `request` method ([#4422](https://github.com/MetaMask/core/pull/4422)) + +## [4.0.0] + +### Changed + +- **BREAKING:** Bump minimum Node version to 18.18 ([#3611](https://github.com/MetaMask/core/pull/3611)) +- Bump `@metamask/json-rpc-engine` to `^9.0.0` ([#4352](https://github.com/MetaMask/core/pull/4352)) + +## [3.0.2] + +### Changed + +- Bump TypeScript version to `~4.9.5` ([#4084](https://github.com/MetaMask/core/pull/4084)) +- Bump `@metamask/json-rpc-engine` to `^8.0.2` ([#4234](https://github.com/MetaMask/core/pull/4234)) + +## [3.0.1] + +### Fixed + +- Fix `types` field in `package.json` ([#4047](https://github.com/MetaMask/core/pull/4047)) + +## [3.0.0] + +### Added + +- **BREAKING**: Add ESM build ([#3998](https://github.com/MetaMask/core/pull/3998)) + - It's no longer possible to import files from `./dist` directly. + +### Changed + +- Bump `@metamask/json-rpc-engine` to `^8.0.0` ([#4039](https://github.com/MetaMask/core/pull/4039)) + +## [2.3.2] + +### Changed + +- Bump `@metamask/utils` to `^8.3.0` ([#3769](https://github.com/MetaMask/core/pull/3769)) +- Bump `@metamask/json-rpc-engine` to `^7.3.2` ([#3821](https://github.com/MetaMask/core/pull/3821)) + +## [2.3.1] + +### Changed + +- Bump `@metamask/json-rpc-engine` to `^7.3.1` ([#3695](https://github.com/MetaMask/core/pull/3695)) + +## [2.3.0] + +### Added + - Migrate `@metamask/eth-json-rpc-provider` into the core monorepo ([#1738](https://github.com/MetaMask/core/pull/1738)) ### Changed -- Export `SafeEventEmitterProvider` as class instead of type ([4c8fea70c5d753b6bb0cf17fc640e2aaed16fd52](https://github.com/MetaMask/core/pull/1738/commits/4c8fea70c5d753b6bb0cf17fc640e2aaed16fd52)) + +- Export `SafeEventEmitterProvider` as class instead of type ([#1738](https://github.com/MetaMask/core/pull/1738)) +- Bump `@metamask/json-rpc-engine` from `^7.1.0` to `^7.2.0` ([#1895](https://github.com/MetaMask/core/pull/1895)) +- Bump `@metamask/utils` from `^8.1.0` to `^8.2.0` ([#1895](https://github.com/MetaMask/core/pull/1895)) +- Bump `@metamask/auto-changelog` from `^3.2.0` to `^3.4.3` ([#1870](https://github.com/MetaMask/core/pull/1870), [#1905](https://github.com/MetaMask/core/pull/1905), [#1997](https://github.com/MetaMask/core/pull/1997)) ## [2.2.0] + ### Changed + - Add missing ISC license information ([#24](https://github.com/MetaMask/eth-json-rpc-provider/pull/24)) ## [2.1.0] + ### Changed + - Bump `@metamask/json-rpc-engine` from `^7.0.0` to `^7.1.0` ([#25](https://github.com/MetaMask/eth-json-rpc-provider/pull/25)) - Bump `@metamask/utils` from `^5.0.1` to `^8.1.0` ([#25](https://github.com/MetaMask/eth-json-rpc-provider/pull/25)) ## [2.0.0] + ### Fixed + - **BREAKING:** Update minimum Node.js version to 16 ([#20](https://github.com/MetaMask/eth-json-rpc-provider/pull/20)) - Switched json-rpc-engine@^6.1.0 -> @metamask/json-rpc-engine@^7.0.0 ([#16](https://github.com/MetaMask/eth-json-rpc-provider/pull/16)) - **BREAKING**: Typescript type updates @@ -32,7 +231,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 Release `v2.0.0` is identical to `v1.0.1` aside from Node.js version requirement imposed by a dependency updates has been made explicit. ## [1.0.1] [RETRACTED] + ### Changed + - **BREAKING:** Update minimum Node.js version to 16 ([#20](https://github.com/MetaMask/eth-json-rpc-provider/pull/20)) - Switched json-rpc-engine@^6.1.0 -> @metamask/json-rpc-engine@^7.0.0 ([#16](https://github.com/MetaMask/eth-json-rpc-provider/pull/16)) - **BREAKING**: Typescript type updates @@ -41,10 +242,32 @@ Release `v2.0.0` is identical to `v1.0.1` aside from Node.js version requirement - Added @metamask/utils@5.0.1 ## [1.0.0] + ### Added + - Initial release, including `providerFromEngine` and `providerFromMiddleware`. -[Unreleased]: https://github.com/MetaMask/core/compare/@metamask/eth-json-rpc-provider@2.2.0...HEAD +[Unreleased]: https://github.com/MetaMask/core/compare/@metamask/eth-json-rpc-provider@6.0.1...HEAD +[6.0.1]: https://github.com/MetaMask/core/compare/@metamask/eth-json-rpc-provider@6.0.0...@metamask/eth-json-rpc-provider@6.0.1 +[6.0.0]: https://github.com/MetaMask/core/compare/@metamask/eth-json-rpc-provider@5.0.1...@metamask/eth-json-rpc-provider@6.0.0 +[5.0.1]: https://github.com/MetaMask/core/compare/@metamask/eth-json-rpc-provider@5.0.0...@metamask/eth-json-rpc-provider@5.0.1 +[5.0.0]: https://github.com/MetaMask/core/compare/@metamask/eth-json-rpc-provider@4.1.8...@metamask/eth-json-rpc-provider@5.0.0 +[4.1.8]: https://github.com/MetaMask/core/compare/@metamask/eth-json-rpc-provider@4.1.7...@metamask/eth-json-rpc-provider@4.1.8 +[4.1.7]: https://github.com/MetaMask/core/compare/@metamask/eth-json-rpc-provider@4.1.6...@metamask/eth-json-rpc-provider@4.1.7 +[4.1.6]: https://github.com/MetaMask/core/compare/@metamask/eth-json-rpc-provider@4.1.5...@metamask/eth-json-rpc-provider@4.1.6 +[4.1.5]: https://github.com/MetaMask/core/compare/@metamask/eth-json-rpc-provider@4.1.4...@metamask/eth-json-rpc-provider@4.1.5 +[4.1.4]: https://github.com/MetaMask/core/compare/@metamask/eth-json-rpc-provider@4.1.3...@metamask/eth-json-rpc-provider@4.1.4 +[4.1.3]: https://github.com/MetaMask/core/compare/@metamask/eth-json-rpc-provider@4.1.2...@metamask/eth-json-rpc-provider@4.1.3 +[4.1.2]: https://github.com/MetaMask/core/compare/@metamask/eth-json-rpc-provider@4.1.1...@metamask/eth-json-rpc-provider@4.1.2 +[4.1.1]: https://github.com/MetaMask/core/compare/@metamask/eth-json-rpc-provider@4.1.0...@metamask/eth-json-rpc-provider@4.1.1 +[4.1.0]: https://github.com/MetaMask/core/compare/@metamask/eth-json-rpc-provider@4.0.0...@metamask/eth-json-rpc-provider@4.1.0 +[4.0.0]: https://github.com/MetaMask/core/compare/@metamask/eth-json-rpc-provider@3.0.2...@metamask/eth-json-rpc-provider@4.0.0 +[3.0.2]: https://github.com/MetaMask/core/compare/@metamask/eth-json-rpc-provider@3.0.1...@metamask/eth-json-rpc-provider@3.0.2 +[3.0.1]: https://github.com/MetaMask/core/compare/@metamask/eth-json-rpc-provider@3.0.0...@metamask/eth-json-rpc-provider@3.0.1 +[3.0.0]: https://github.com/MetaMask/core/compare/@metamask/eth-json-rpc-provider@2.3.2...@metamask/eth-json-rpc-provider@3.0.0 +[2.3.2]: https://github.com/MetaMask/core/compare/@metamask/eth-json-rpc-provider@2.3.1...@metamask/eth-json-rpc-provider@2.3.2 +[2.3.1]: https://github.com/MetaMask/core/compare/@metamask/eth-json-rpc-provider@2.3.0...@metamask/eth-json-rpc-provider@2.3.1 +[2.3.0]: https://github.com/MetaMask/core/compare/@metamask/eth-json-rpc-provider@2.2.0...@metamask/eth-json-rpc-provider@2.3.0 [2.2.0]: https://github.com/MetaMask/core/compare/@metamask/eth-json-rpc-provider@2.1.0...@metamask/eth-json-rpc-provider@2.2.0 [2.1.0]: https://github.com/MetaMask/core/compare/@metamask/eth-json-rpc-provider@2.0.0...@metamask/eth-json-rpc-provider@2.1.0 [2.0.0]: https://github.com/MetaMask/core/compare/@metamask/eth-json-rpc-provider@1.0.1...@metamask/eth-json-rpc-provider@2.0.0 diff --git a/packages/eth-json-rpc-provider/package.json b/packages/eth-json-rpc-provider/package.json index dc8e082ee86..10937f23d58 100644 --- a/packages/eth-json-rpc-provider/package.json +++ b/packages/eth-json-rpc-provider/package.json @@ -1,63 +1,80 @@ { "name": "@metamask/eth-json-rpc-provider", - "version": "2.2.0", + "version": "6.0.1", "description": "Create an Ethereum provider using a JSON-RPC engine or middleware", "keywords": [ - "MetaMask", - "Ethereum" + "Ethereum", + "MetaMask" ], "homepage": "https://github.com/MetaMask/core/tree/main/packages/eth-json-rpc-provider#readme", "bugs": { "url": "https://github.com/MetaMask/core/issues" }, + "license": "ISC", "repository": { "type": "git", "url": "https://github.com/MetaMask/core.git" }, - "license": "ISC", - "main": "./dist/index.js", - "types": "./dist/index.d.ts", "files": [ "dist/" ], + "sideEffects": false, + "main": "./dist/index.cjs", + "types": "./dist/index.d.cts", + "exports": { + ".": { + "import": { + "types": "./dist/index.d.mts", + "default": "./dist/index.mjs" + }, + "require": { + "types": "./dist/index.d.cts", + "default": "./dist/index.cjs" + } + }, + "./package.json": "./package.json" + }, + "publishConfig": { + "access": "public", + "registry": "https://registry.npmjs.org/" + }, "scripts": { - "build": "tsc --project tsconfig.build.json", - "build:clean": "rimraf dist && yarn build", + "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/eth-json-rpc-provider", "changelog:validate": "../../scripts/validate-changelog.sh @metamask/eth-json-rpc-provider", - "lint": "yarn lint:eslint && yarn lint:misc --check && yarn lint:dependencies", - "lint:dependencies": "depcheck", - "lint:eslint": "eslint . --cache --ext js,ts", - "lint:fix": "yarn lint:eslint --fix && yarn lint:misc --write && yarn lint:dependencies", - "lint:misc": "prettier '**/*.json' '**/*.md' '!CHANGELOG.md' '**/*.yml' '!.yarnrc.yml' --ignore-path .gitignore --no-error-on-unmatched-pattern", - "prepack": "./scripts/prepack.sh", - "publish:preview": "yarn npm publish --tag preview", - "test": "jest", - "test:clean": "jest --clearCache", - "test:watch": "jest --watch" + "lint:tsconfigs": "tsx ../../scripts/lint-tsconfigs/lint-tsconfigs.mts", + "lint:tsconfigs:fix": "tsx ../../scripts/lint-tsconfigs/lint-tsconfigs.mts --fix", + "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/json-rpc-engine": "^7.1.1", - "@metamask/safe-event-emitter": "^3.0.0", - "@metamask/utils": "^8.1.0" + "@metamask/json-rpc-engine": "^10.5.0", + "@metamask/rpc-errors": "^7.0.2", + "@metamask/utils": "^11.11.0", + "nanoid": "^3.3.8" }, "devDependencies": { - "@metamask/auto-changelog": "^3.1.0", - "@types/jest": "^27.4.1", + "@ethersproject/providers": "^5.7.0", + "@metamask/auto-changelog": "^6.1.0", + "@metamask/eth-query": "^4.0.0", + "@metamask/ethjs-query": "^0.5.3", + "@ts-bridge/cli": "^0.6.4", + "@types/jest": "^30.0.0", "deepmerge": "^4.2.2", - "depcheck": "^1.4.3", - "jest": "^27.5.1", - "jest-it-up": "^2.0.2", - "ts-jest": "^27.1.4", - "typedoc": "^0.24.8" + "ethers": "^6.12.0", + "jest": "^30.4.2", + "ts-jest": "^29.4.11", + "tsx": "^4.20.5", + "typedoc": "^0.25.13", + "typescript": "~5.3.3" }, - "packageManager": "yarn@3.3.0", "engines": { - "node": ">=16.0.0" - }, - "publishConfig": { - "access": "public", - "registry": "https://registry.npmjs.org/" + "node": "^18.18 || >=20" }, "lavamoat": { "allowScripts": { diff --git a/packages/eth-json-rpc-provider/src/index.test.ts b/packages/eth-json-rpc-provider/src/index.test.ts index e59006be2bd..33492dffeea 100644 --- a/packages/eth-json-rpc-provider/src/index.test.ts +++ b/packages/eth-json-rpc-provider/src/index.test.ts @@ -1,12 +1,13 @@ -import * as allExports from '.'; +import * as allExports from './index.js'; describe('Package exports', () => { it('has expected exports', () => { - expect(Object.keys(allExports)).toMatchInlineSnapshot(` - Array [ + expect(Object.keys(allExports).sort()).toMatchInlineSnapshot(` + [ + "InternalProvider", "SafeEventEmitterProvider", - "providerFromEngine", "providerFromMiddleware", + "providerFromMiddlewareV2", ] `); }); diff --git a/packages/eth-json-rpc-provider/src/index.ts b/packages/eth-json-rpc-provider/src/index.ts index 8eb691ca701..b972bff0db6 100644 --- a/packages/eth-json-rpc-provider/src/index.ts +++ b/packages/eth-json-rpc-provider/src/index.ts @@ -1,3 +1,11 @@ -export * from './provider-from-engine'; -export * from './provider-from-middleware'; -export { SafeEventEmitterProvider } from './safe-event-emitter-provider'; +import { InternalProvider } from './internal-provider.js'; + +export * from './provider-from-middleware.js'; + +/** + * @deprecated Use {@link InternalProvider} instead. + */ +type SafeEventEmitterProvider = InternalProvider; +const SafeEventEmitterProvider = InternalProvider; + +export { InternalProvider, SafeEventEmitterProvider }; diff --git a/packages/eth-json-rpc-provider/src/internal-provider.test.ts b/packages/eth-json-rpc-provider/src/internal-provider.test.ts new file mode 100644 index 00000000000..a3fd57a2b76 --- /dev/null +++ b/packages/eth-json-rpc-provider/src/internal-provider.test.ts @@ -0,0 +1,517 @@ +import { Web3Provider } from '@ethersproject/providers'; +import EthQuery from '@metamask/eth-query'; +import EthJsQuery from '@metamask/ethjs-query'; +import { asV2Middleware, JsonRpcEngine } from '@metamask/json-rpc-engine'; +import type { + JsonRpcMiddleware, + MiddlewareContext, +} from '@metamask/json-rpc-engine/v2'; +import { JsonRpcEngineV2 } from '@metamask/json-rpc-engine/v2'; +import { providerErrors } from '@metamask/rpc-errors'; +import type { JsonRpcRequest, Json } from '@metamask/utils'; +import { BrowserProvider } from 'ethers'; +import { promisify } from 'util'; + +import { + InternalProvider, + convertEip1193RequestToJsonRpcRequest, +} from './internal-provider.js'; + +jest.mock('uuid'); + +type ResultParam = + | Json + | ((req?: JsonRpcRequest, context?: MiddlewareContext) => Json); + +const createLegacyEngine = ( + method: string, + result: ResultParam, +): JsonRpcEngine => { + const engine = new JsonRpcEngine(); + engine.push((req, res, next, end) => { + if (req.method === method) { + res.result = typeof result === 'function' ? result(req) : result; + return end(); + } + return next(); + }); + return engine; +}; + +const createV2Engine = ( + method: string, + result: ResultParam, +): JsonRpcEngineV2 => { + return JsonRpcEngineV2.create>({ + middleware: [ + ({ + request, + next, + context, + }): Json | Promise | undefined> => { + if (request.method === method) { + return typeof result === 'function' + ? result(request as JsonRpcRequest, context) + : result; + } + return next(); + }, + ], + }); +}; + +describe('legacy constructor', () => { + it('can be constructed with an engine', () => { + const provider = new InternalProvider({ + engine: createLegacyEngine('eth_blockNumber', 42), + }); + expect(provider).toBeDefined(); + }); +}); + +describe.each([ + { + createRpcHandler: createLegacyEngine, + name: 'JsonRpcEngine', + }, + { + createRpcHandler: createV2Engine, + name: 'JsonRpcServer', + }, +] as const)('InternalProvider with $name', ({ createRpcHandler }) => { + it('returns the correct block number with @metamask/eth-query', async () => { + const provider = new InternalProvider({ + engine: createRpcHandler('eth_blockNumber', 42), + }); + const ethQuery = new EthQuery(provider); + + ethQuery.sendAsync({ method: 'eth_blockNumber' }, (_error, response) => { + expect(response).toBe(42); + }); + }); + + it('returns the correct block number with @metamask/ethjs-query', async () => { + const provider = new InternalProvider({ + engine: createRpcHandler('eth_blockNumber', 42), + }); + const ethJsQuery = new EthJsQuery(provider); + + const response = await ethJsQuery.blockNumber(); + + expect(response.toNumber()).toBe(42); + }); + + it('returns the correct block number with Web3Provider', async () => { + const provider = new InternalProvider({ + engine: createRpcHandler('eth_blockNumber', 42), + }); + const web3Provider = new Web3Provider(provider); + + const response = await web3Provider.send('eth_blockNumber', []); + + expect(response).toBe(42); + }); + + it('returns the correct block number with BrowserProvider', async () => { + const provider = new InternalProvider({ + engine: createRpcHandler('eth_blockNumber', 42), + }); + const browserProvider = new BrowserProvider(provider); + + const response = await browserProvider.send('eth_blockNumber', []); + + expect(response).toBe(42); + + browserProvider.destroy(); + }); + + describe('request', () => { + it('handles a successful JSON-RPC object request', async () => { + let req: JsonRpcRequest | undefined; + const rpcHandler = createRpcHandler('test', (request) => { + req = request; + return 42; + }); + const provider = new InternalProvider({ engine: rpcHandler }); + const request = { + id: '1', + jsonrpc: '2.0' as const, + method: 'test', + params: { + param1: 'value1', + param2: 'value2', + }, + }; + + const result = await provider.request(request); + + expect(req).toStrictEqual({ + id: expect.any(String), + jsonrpc: '2.0' as const, + method: 'test', + params: { + param1: 'value1', + param2: 'value2', + }, + }); + expect(result).toBe(42); + }); + + it('handles a successful EIP-1193 object request', async () => { + let req: JsonRpcRequest | undefined; + const rpcHandler = createRpcHandler('test', (request) => { + req = request; + return 42; + }); + const provider = new InternalProvider({ engine: rpcHandler }); + const request = { + method: 'test', + params: { + param1: 'value1', + param2: 'value2', + }, + }; + + const result = await provider.request(request); + + expect(req).toStrictEqual({ + id: expect.any(String), + jsonrpc: '2.0' as const, + method: 'test', + params: { + param1: 'value1', + param2: 'value2', + }, + }); + expect(result).toBe(42); + }); + + it('handles a failure with a non-JSON-RPC error', async () => { + const rpcHandler = createRpcHandler('test', () => { + throw providerErrors.custom({ + code: 1001, + message: 'Test error', + data: { cause: 'Test cause' }, + }); + }); + const provider = new InternalProvider({ engine: rpcHandler }); + const request = { + id: '1', + jsonrpc: '2.0' as const, + method: 'test', + }; + + await expect(async () => provider.request(request)).rejects.toThrow( + 'Test error', + ); + }); + + it('handles a failure with a JSON-RPC error', async () => { + const rpcHandler = createRpcHandler('test', () => { + throw new Error('Test error'); + }); + const provider = new InternalProvider({ engine: rpcHandler }); + const request = { + id: '1', + jsonrpc: '2.0' as const, + method: 'test', + }; + + await expect(async () => provider.request(request)).rejects.toThrow( + 'Test error', + ); + }); + }); + + describe('sendAsync', () => { + it('handles a successful JSON-RPC object request', async () => { + let req: JsonRpcRequest | undefined; + const rpcHandler = createRpcHandler('test', (request) => { + req = request; + return 42; + }); + const provider = new InternalProvider({ engine: rpcHandler }); + const promisifiedSendAsync = promisify(provider.sendAsync); + const request = { + id: '1', + jsonrpc: '2.0' as const, + method: 'test', + params: { + param1: 'value1', + param2: 'value2', + }, + }; + + const response = await promisifiedSendAsync(request); + + expect(req).toStrictEqual({ + id: expect.any(String), + jsonrpc: '2.0' as const, + method: 'test', + params: { + param1: 'value1', + param2: 'value2', + }, + }); + expect(response.result).toBe(42); + }); + + it('forwards the context to the JSON-RPC handler', async () => { + const rpcHandler = createRpcHandler('test', (request, context) => { + // @ts-expect-error - Intentional type abuse. + return context?.assertGet('foo') ?? request.foo; + }); + const provider = new InternalProvider({ engine: rpcHandler }); + + const request = { + id: 1, + jsonrpc: '2.0' as const, + method: 'test', + }; + + const result = await provider.request(request, { + context: { + foo: 'bar', + }, + }); + + expect(result).toBe('bar'); + }); + + it('handles a successful EIP-1193 object request', async () => { + let req: JsonRpcRequest | undefined; + const rpcHandler = createRpcHandler('test', (request) => { + req = request; + return 42; + }); + const provider = new InternalProvider({ engine: rpcHandler }); + const promisifiedSendAsync = promisify(provider.sendAsync); + const request = { + method: 'test', + params: { + param1: 'value1', + param2: 'value2', + }, + }; + + const response = await promisifiedSendAsync(request); + + expect(req).toStrictEqual({ + id: expect.any(String), + jsonrpc: '2.0' as const, + method: 'test', + params: { + param1: 'value1', + param2: 'value2', + }, + }); + expect(response.result).toBe(42); + }); + + it('handles a failed request', async () => { + const rpcHandler = createRpcHandler('test', () => { + throw new Error('Test error'); + }); + const provider = new InternalProvider({ engine: rpcHandler }); + const promisifiedSendAsync = promisify(provider.sendAsync); + const request = { + id: '1', + jsonrpc: '2.0' as const, + method: 'test', + }; + + await expect(async () => promisifiedSendAsync(request)).rejects.toThrow( + 'Test error', + ); + }); + + it('handles an error thrown by the JSON-RPC handler', async () => { + let rpcHandler = createRpcHandler('test', () => null); + // Transform the engine into a server so we can mock the "handle" method. + // The "handle" method should never throw, but we should be resilient to it anyway. + rpcHandler = + 'push' in rpcHandler + ? JsonRpcEngineV2.create({ middleware: [asV2Middleware(rpcHandler)] }) + : rpcHandler; + jest + .spyOn(rpcHandler, 'handle') + .mockRejectedValue(new Error('Test error')); + const provider = new InternalProvider({ engine: rpcHandler }); + const promisifiedSendAsync = promisify(provider.sendAsync); + const request = { + id: '1', + jsonrpc: '2.0' as const, + method: 'test', + }; + + await expect(async () => promisifiedSendAsync(request)).rejects.toThrow( + 'Test error', + ); + }); + }); + + describe('send', () => { + it('throws if a callback is not provided', () => { + const rpcHandler = createRpcHandler('test', 42); + const provider = new InternalProvider({ engine: rpcHandler }); + const request = { + id: '1', + jsonrpc: '2.0' as const, + method: 'test', + }; + + // @ts-expect-error - Destructive testing. + expect(() => provider.send(request)).toThrow( + 'Must provide callback to "send" method.', + ); + }); + + it('handles a successful JSON-RPC object request', async () => { + let req: JsonRpcRequest | undefined; + const rpcHandler = createRpcHandler('test', (request) => { + req = request; + return 42; + }); + const provider = new InternalProvider({ engine: rpcHandler }); + const promisifiedSend = promisify(provider.send); + const request = { + id: '1', + jsonrpc: '2.0' as const, + method: 'test', + params: { + param1: 'value1', + param2: 'value2', + }, + }; + + const response = await promisifiedSend(request); + + expect(req).toStrictEqual({ + id: expect.any(String), + jsonrpc: '2.0' as const, + method: 'test', + params: { + param1: 'value1', + param2: 'value2', + }, + }); + expect(response.result).toBe(42); + }); + + it('handles a successful EIP-1193 object request', async () => { + let req: JsonRpcRequest | undefined; + const rpcHandler = createRpcHandler('test', (request) => { + req = request; + return 42; + }); + const provider = new InternalProvider({ engine: rpcHandler }); + const promisifiedSend = promisify(provider.send); + const request = { + method: 'test', + params: { + param1: 'value1', + param2: 'value2', + }, + }; + + const response = await promisifiedSend(request); + + expect(req).toStrictEqual({ + id: expect.any(String), + jsonrpc: '2.0' as const, + method: 'test', + params: { + param1: 'value1', + param2: 'value2', + }, + }); + expect(response.result).toBe(42); + }); + + it('handles a failed request', async () => { + const rpcHandler = createRpcHandler('test', () => { + throw new Error('Test error'); + }); + const provider = new InternalProvider({ engine: rpcHandler }); + const promisifiedSend = promisify(provider.send); + const request = { + id: '1', + jsonrpc: '2.0' as const, + method: 'test', + }; + + await expect(async () => promisifiedSend(request)).rejects.toThrow( + 'Test error', + ); + }); + }); +}); + +describe('convertEip1193RequestToJsonRpcRequest', () => { + it('generates a unique id if id is not provided', () => { + const eip1193Request = { + method: 'test', + params: { param1: 'value1', param2: 'value2' }, + }; + + const jsonRpcRequest = + convertEip1193RequestToJsonRpcRequest(eip1193Request); + + expect(jsonRpcRequest).toStrictEqual({ + id: expect.any(String), + jsonrpc: '2.0', + method: 'test', + params: { param1: 'value1', param2: 'value2' }, + }); + }); + + it('uses the default jsonrpc version if not provided', () => { + const eip1193Request = { + method: 'test', + params: { param1: 'value1', param2: 'value2' }, + }; + + const jsonRpcRequest = + convertEip1193RequestToJsonRpcRequest(eip1193Request); + + expect(jsonRpcRequest).toStrictEqual({ + id: expect.any(String), + jsonrpc: '2.0', + method: 'test', + params: { param1: 'value1', param2: 'value2' }, + }); + }); + + it('uses the provided jsonrpc version if provided', () => { + const eip1193Request = { + jsonrpc: '2.0' as const, + method: 'test', + params: { param1: 'value1', param2: 'value2' }, + }; + + const jsonRpcRequest = + convertEip1193RequestToJsonRpcRequest(eip1193Request); + + expect(jsonRpcRequest).toStrictEqual({ + id: expect.any(String), + jsonrpc: '2.0', + method: 'test', + params: { param1: 'value1', param2: 'value2' }, + }); + }); + + it('uses an empty object as params if not provided', () => { + const eip1193Request = { + method: 'test', + }; + + const jsonRpcRequest = + convertEip1193RequestToJsonRpcRequest(eip1193Request); + + expect(jsonRpcRequest).toStrictEqual({ + id: expect.any(String), + jsonrpc: '2.0', + method: 'test', + }); + }); +}); diff --git a/packages/eth-json-rpc-provider/src/internal-provider.ts b/packages/eth-json-rpc-provider/src/internal-provider.ts new file mode 100644 index 00000000000..2ab2713889a --- /dev/null +++ b/packages/eth-json-rpc-provider/src/internal-provider.ts @@ -0,0 +1,183 @@ +import { asV2Middleware } from '@metamask/json-rpc-engine'; +import type { JsonRpcEngine } from '@metamask/json-rpc-engine'; +import { JsonRpcEngineV2 } from '@metamask/json-rpc-engine/v2'; +import type { + HandleOptions, + ContextConstraint, + MiddlewareContext, +} from '@metamask/json-rpc-engine/v2'; +import type { + Json, + JsonRpcId, + JsonRpcParams, + JsonRpcSuccess, + JsonRpcRequest, + JsonRpcVersion2, +} from '@metamask/utils'; +import { nanoid } from 'nanoid'; + +/** + * A JSON-RPC request conforming to the EIP-1193 specification. + */ +type Eip1193Request = { + id?: JsonRpcId; + jsonrpc?: JsonRpcVersion2; + method: string; + params?: Params; +}; + +type Options< + Request extends JsonRpcRequest = JsonRpcRequest, + Context extends ContextConstraint = MiddlewareContext, +> = { + engine: JsonRpcEngine | JsonRpcEngineV2; +}; + +/** + * An Ethereum provider. + * + * This provider loosely follows conventions that pre-date EIP-1193. + * It is not compliant with any Ethereum provider standard. + */ +export class InternalProvider< + Context extends ContextConstraint = MiddlewareContext, +> { + readonly #engine: JsonRpcEngineV2; + + /** + * Construct a InternalProvider from a JSON-RPC server or legacy engine. + * + * @param options - Options. + * @param options.engine - The JSON-RPC engine used to process requests. + */ + constructor({ engine }: Options) { + this.#engine = + 'push' in engine + ? JsonRpcEngineV2.create({ + middleware: [asV2Middleware(engine)], + }) + : engine; + } + + /** + * Send a provider request asynchronously. + * + * @param eip1193Request - The request to send. + * @param options - The options for the request operation. + * @param options.context - The context to include with the request. + * @returns The JSON-RPC response. + */ + async request( + eip1193Request: Eip1193Request, + options?: HandleOptions, + ): Promise { + const jsonRpcRequest = + convertEip1193RequestToJsonRpcRequest(eip1193Request); + return (await this.#handle(jsonRpcRequest, options)).result; + } + + /** + * Send a provider request asynchronously. + * + * This method serves the same purpose as `request`. It only exists for + * legacy reasons. + * + * @param eip1193Request - The request to send. + * @param callback - A function that is called upon the success or failure of the request. + * @deprecated Use {@link request} instead. This method is retained solely for backwards + * compatibility with certain libraries. + */ + sendAsync = ( + eip1193Request: Eip1193Request, + // Non-polluting `any` that acts like a constraint. + // eslint-disable-next-line @typescript-eslint/no-explicit-any + callback: (error: unknown, providerRes?: any) => void, + ): void => { + const jsonRpcRequest = + convertEip1193RequestToJsonRpcRequest(eip1193Request); + this.#handleWithCallback(jsonRpcRequest, callback); + }; + + /** + * Send a provider request asynchronously. + * + * This method serves the same purpose as `request`. It only exists for + * legacy reasons. + * + * @param eip1193Request - The request to send. + * @param callback - A function that is called upon the success or failure of the request. + * @deprecated Use {@link request} instead. This method is retained solely for backwards + * compatibility with certain libraries. + */ + send = ( + eip1193Request: Eip1193Request, + // TODO: Replace `any` with type + // eslint-disable-next-line @typescript-eslint/no-explicit-any + callback: (error: unknown, providerRes?: any) => void, + ): void => { + if (typeof callback !== 'function') { + throw new Error('Must provide callback to "send" method.'); + } + const jsonRpcRequest = + convertEip1193RequestToJsonRpcRequest(eip1193Request); + this.#handleWithCallback(jsonRpcRequest, callback); + }; + + readonly #handle = async ( + jsonRpcRequest: JsonRpcRequest, + options?: HandleOptions, + ): Promise> => { + const { id, jsonrpc } = jsonRpcRequest; + // The `result` typecast is unsafe, but we need it to preserve the provider's + // public interface, which allows you to unsafely typecast results. + const result = (await this.#engine.handle( + jsonRpcRequest, + options, + )) as unknown as Result; + + return { + id, + jsonrpc, + result, + }; + }; + + readonly #handleWithCallback = ( + jsonRpcRequest: JsonRpcRequest, + callback: (error: unknown, providerRes?: unknown) => void, + ): void => { + /* eslint-disable promise/no-callback-in-promise */ + this.#handle(jsonRpcRequest) + // A resolution will always be a successful response + .then((response) => callback(null, response)) + .catch((error) => { + callback(error); + }); + /* eslint-enable promise/no-callback-in-promise */ + }; +} + +/** + * Convert an EIP-1193 request to a JSON-RPC request. + * + * @param eip1193Request - The EIP-1193 request to convert. + * @returns The JSON-RPC request. + */ +export function convertEip1193RequestToJsonRpcRequest( + eip1193Request: Eip1193Request, +): JsonRpcRequest { + const { id = nanoid(), jsonrpc = '2.0', method, params } = eip1193Request; + + return params + ? { + id, + jsonrpc, + method, + params, + } + : { + id, + jsonrpc, + method, + }; +} diff --git a/packages/eth-json-rpc-provider/src/provider-from-engine.test.ts b/packages/eth-json-rpc-provider/src/provider-from-engine.test.ts deleted file mode 100644 index 0abdc989c3e..00000000000 --- a/packages/eth-json-rpc-provider/src/provider-from-engine.test.ts +++ /dev/null @@ -1,43 +0,0 @@ -import { JsonRpcEngine } from '@metamask/json-rpc-engine'; -import { promisify } from 'util'; - -import { providerFromEngine } from './provider-from-engine'; - -describe('providerFromEngine', () => { - it('handle a successful request', async () => { - const engine = new JsonRpcEngine(); - engine.push((_req, res, _next, end) => { - res.result = 42; - end(); - }); - const provider = providerFromEngine(engine); - const promisifiedSendAsync = promisify(provider.sendAsync); - const exampleRequest = { - id: 1, - jsonrpc: '2.0' as const, - method: 'test', - }; - - const response = await promisifiedSendAsync(exampleRequest); - - expect(response.result).toBe(42); - }); - - it('handle a failed request', async () => { - const engine = new JsonRpcEngine(); - engine.push((_req, _res, _next, _end) => { - throw new Error('Test error'); - }); - const provider = providerFromEngine(engine); - const promisifiedSendAsync = promisify(provider.sendAsync); - const exampleRequest = { - id: 1, - jsonrpc: '2.0' as const, - method: 'test', - }; - - await expect(async () => - promisifiedSendAsync(exampleRequest), - ).rejects.toThrow('Test error'); - }); -}); diff --git a/packages/eth-json-rpc-provider/src/provider-from-engine.ts b/packages/eth-json-rpc-provider/src/provider-from-engine.ts deleted file mode 100644 index 00c62bd543c..00000000000 --- a/packages/eth-json-rpc-provider/src/provider-from-engine.ts +++ /dev/null @@ -1,15 +0,0 @@ -import type { JsonRpcEngine } from '@metamask/json-rpc-engine'; - -import { SafeEventEmitterProvider } from './safe-event-emitter-provider'; - -/** - * Construct an Ethereum provider from the given JSON-RPC engine. - * - * @param engine - The JSON-RPC engine to construct a provider from. - * @returns An Ethereum provider. - */ -export function providerFromEngine( - engine: JsonRpcEngine, -): SafeEventEmitterProvider { - return new SafeEventEmitterProvider({ engine }); -} diff --git a/packages/eth-json-rpc-provider/src/provider-from-middleware.test.ts b/packages/eth-json-rpc-provider/src/provider-from-middleware.test.ts index dc3c9277a6f..ca9955525e6 100644 --- a/packages/eth-json-rpc-provider/src/provider-from-middleware.test.ts +++ b/packages/eth-json-rpc-provider/src/provider-from-middleware.test.ts @@ -1,41 +1,85 @@ -import type { JsonRpcMiddleware } from '@metamask/json-rpc-engine'; -import { promisify } from 'util'; +import type { JsonRpcMiddleware as LegacyJsonRpcMiddleware } from '@metamask/json-rpc-engine'; +import type { JsonRpcMiddleware } from '@metamask/json-rpc-engine/v2'; +import { providerErrors } from '@metamask/rpc-errors'; +import type { Json, JsonRpcParams, JsonRpcRequest } from '@metamask/utils'; -import { providerFromMiddleware } from './provider-from-middleware'; +import { + providerFromMiddleware, + providerFromMiddlewareV2, +} from './provider-from-middleware.js'; describe('providerFromMiddleware', () => { it('handle a successful request', async () => { - const middleware: JsonRpcMiddleware = (_req, res, _next, end) => { + const middleware: LegacyJsonRpcMiddleware = ( + _req, + res, + _next, + end, + ) => { res.result = 42; end(); }; const provider = providerFromMiddleware(middleware); - const promisifiedSendAsync = promisify(provider.sendAsync); const exampleRequest = { id: 1, jsonrpc: '2.0' as const, method: 'test', }; - const response = await promisifiedSendAsync(exampleRequest); + const response = await provider.request(exampleRequest); - expect(response.result).toBe(42); + expect(response).toBe(42); }); it('handle a failed request', async () => { - const middleware = () => { + const provider = providerFromMiddleware((_req, _res, _next, end) => { + end( + providerErrors.custom({ + code: 1001, + message: 'Test error', + }), + ); + }); + const exampleRequest = { + id: 1, + jsonrpc: '2.0' as const, + method: 'test', + }; + + await expect(async () => provider.request(exampleRequest)).rejects.toThrow( + 'Test error', + ); + }); +}); + +describe('providerFromMiddlewareV2', () => { + it('handle a successful request', async () => { + const middleware: JsonRpcMiddleware = () => 42; + const provider = providerFromMiddlewareV2(middleware); + const exampleRequest = { + id: 1, + jsonrpc: '2.0' as const, + method: 'test', + }; + + const response = await provider.request(exampleRequest); + + expect(response).toBe(42); + }); + + it('handle a failed request', async () => { + const middleware: JsonRpcMiddleware = () => { throw new Error('Test error'); }; - const provider = providerFromMiddleware(middleware); - const promisifiedSendAsync = promisify(provider.sendAsync); + const provider = providerFromMiddlewareV2(middleware); const exampleRequest = { id: 1, jsonrpc: '2.0' as const, method: 'test', }; - await expect(async () => - promisifiedSendAsync(exampleRequest), - ).rejects.toThrow('Test error'); + await expect(async () => provider.request(exampleRequest)).rejects.toThrow( + 'Test error', + ); }); }); diff --git a/packages/eth-json-rpc-provider/src/provider-from-middleware.ts b/packages/eth-json-rpc-provider/src/provider-from-middleware.ts index 461af247880..07a0f5fbbf5 100644 --- a/packages/eth-json-rpc-provider/src/provider-from-middleware.ts +++ b/packages/eth-json-rpc-provider/src/provider-from-middleware.ts @@ -1,22 +1,55 @@ -import { JsonRpcEngine } from '@metamask/json-rpc-engine'; -import type { JsonRpcMiddleware } from '@metamask/json-rpc-engine'; -import type { Json, JsonRpcParams } from '@metamask/utils'; +import { asV2Middleware } from '@metamask/json-rpc-engine'; +import type { JsonRpcMiddleware as LegacyJsonRpcMiddleware } from '@metamask/json-rpc-engine'; +import { JsonRpcEngineV2 } from '@metamask/json-rpc-engine/v2'; +import type { + ContextConstraint, + JsonRpcMiddleware, + ResultConstraint, +} from '@metamask/json-rpc-engine/v2'; +import type { Json, JsonRpcParams, JsonRpcRequest } from '@metamask/utils'; -import { providerFromEngine } from './provider-from-engine'; -import type { SafeEventEmitterProvider } from './safe-event-emitter-provider'; +import { InternalProvider } from './internal-provider.js'; /** * Construct an Ethereum provider from the given middleware. * * @param middleware - The middleware to construct a provider from. * @returns An Ethereum provider. + * @deprecated Use `JsonRpcEngineV2` middleware and {@link providerFromMiddlewareV2} instead. */ export function providerFromMiddleware< Params extends JsonRpcParams, Result extends Json, ->(middleware: JsonRpcMiddleware): SafeEventEmitterProvider { - const engine: JsonRpcEngine = new JsonRpcEngine(); - engine.push(middleware); - const provider: SafeEventEmitterProvider = providerFromEngine(engine); - return provider; +>(middleware: LegacyJsonRpcMiddleware): InternalProvider { + return providerFromMiddlewareV2( + // This function is generic on the Params and Result types to match the legacy JsonRpcMiddleware type. + // However, since the V2 JsonRpcMiddleware type is not generic on the Params, we need to elide this + // parameter by upcasting the request type to JsonRpcRequest, or we get an error due to contravariance + // since JsonRpcRequest is not assignable to JsonRpcRequest. + asV2Middleware(middleware) as JsonRpcMiddleware, + ); +} + +/** + * Construct an Ethereum provider from the given middleware. + * + * @param middleware - The middleware to construct a provider from. + * @returns An Ethereum provider. + */ +export function providerFromMiddlewareV2< + Request extends JsonRpcRequest, + Middleware extends JsonRpcMiddleware< + Request, + ResultConstraint, + ContextConstraint + >, +>(middleware: Middleware): InternalProvider { + return new InternalProvider({ + engine: JsonRpcEngineV2.create({ + // This function is generic in order to accept middleware functions with narrower types than + // the plain JsonRpcMiddleware type. However, since InternalProvider is non-generic, + // we need to upcast the middleware to avoid a type error. + middleware: [middleware as JsonRpcMiddleware], + }), + }); } diff --git a/packages/eth-json-rpc-provider/src/safe-event-emitter-provider.test.ts b/packages/eth-json-rpc-provider/src/safe-event-emitter-provider.test.ts deleted file mode 100644 index aabe776010f..00000000000 --- a/packages/eth-json-rpc-provider/src/safe-event-emitter-provider.test.ts +++ /dev/null @@ -1,120 +0,0 @@ -import { JsonRpcEngine } from '@metamask/json-rpc-engine'; -import { promisify } from 'util'; - -import { SafeEventEmitterProvider } from './safe-event-emitter-provider'; - -describe('SafeEventEmitterProvider', () => { - describe('constructor', () => { - it('listens for notifications from provider, emitting them as "data"', async () => { - const engine = new JsonRpcEngine(); - const provider = new SafeEventEmitterProvider({ engine }); - const notificationListener = jest.fn(); - provider.on('data', notificationListener); - - // `json-rpc-engine` v6 does not support JSON-RPC notifications directly, - // so this is the best way to emulate this behavior. - // We should replace this with `await engine.handle(notification)` when we update to v7 - // TODO: v7 is now integrated; fix this - engine.emit('notification', 'test'); - - expect(notificationListener).toHaveBeenCalledWith(null, 'test'); - }); - - it('does not throw if engine does not support events', () => { - const engine = new JsonRpcEngine() as any; - delete engine.on; - - expect(() => new SafeEventEmitterProvider({ engine })).not.toThrow(); - }); - }); - - describe('sendAsync', () => { - it('handles a successful request', async () => { - const engine = new JsonRpcEngine(); - engine.push((_req, res, _next, end) => { - res.result = 42; - end(); - }); - const provider = new SafeEventEmitterProvider({ engine }); - const promisifiedSendAsync = promisify(provider.sendAsync); - const exampleRequest = { - id: 1, - jsonrpc: '2.0' as const, - method: 'test', - }; - - const response = await promisifiedSendAsync(exampleRequest); - - expect(response.result).toBe(42); - }); - - it('handles a failed request', async () => { - const engine = new JsonRpcEngine(); - engine.push((_req, _res, _next, _end) => { - throw new Error('Test error'); - }); - const provider = new SafeEventEmitterProvider({ engine }); - const promisifiedSendAsync = promisify(provider.sendAsync); - const exampleRequest = { - id: 1, - jsonrpc: '2.0' as const, - method: 'test', - }; - - await expect(async () => - promisifiedSendAsync(exampleRequest), - ).rejects.toThrow('Test error'); - }); - }); - - describe('send', () => { - it('throws if a callback is not provided', () => { - const engine = new JsonRpcEngine(); - const provider = new SafeEventEmitterProvider({ engine }); - const exampleRequest = { - id: 1, - jsonrpc: '2.0' as const, - method: 'test', - }; - - expect(() => (provider.send as any)(exampleRequest)).toThrow(''); - }); - - it('handles a successful request', async () => { - const engine = new JsonRpcEngine(); - engine.push((_req, res, _next, end) => { - res.result = 42; - end(); - }); - const provider = new SafeEventEmitterProvider({ engine }); - const promisifiedSend = promisify(provider.send); - const exampleRequest = { - id: 1, - jsonrpc: '2.0' as const, - method: 'test', - }; - - const response = await promisifiedSend(exampleRequest); - - expect(response.result).toBe(42); - }); - - it('handles a failed request', async () => { - const engine = new JsonRpcEngine(); - engine.push((_req, _res, _next, _end) => { - throw new Error('Test error'); - }); - const provider = new SafeEventEmitterProvider({ engine }); - const promisifiedSend = promisify(provider.send); - const exampleRequest = { - id: 1, - jsonrpc: '2.0' as const, - method: 'test', - }; - - await expect(async () => promisifiedSend(exampleRequest)).rejects.toThrow( - 'Test error', - ); - }); - }); -}); diff --git a/packages/eth-json-rpc-provider/src/safe-event-emitter-provider.ts b/packages/eth-json-rpc-provider/src/safe-event-emitter-provider.ts deleted file mode 100644 index fbf2db3ae1a..00000000000 --- a/packages/eth-json-rpc-provider/src/safe-event-emitter-provider.ts +++ /dev/null @@ -1,63 +0,0 @@ -import type { JsonRpcEngine } from '@metamask/json-rpc-engine'; -import SafeEventEmitter from '@metamask/safe-event-emitter'; -import type { JsonRpcRequest } from '@metamask/utils'; - -/** - * An Ethereum provider. - * - * This provider loosely follows conventions that pre-date EIP-1193. - * It is not compliant with any Ethereum provider standard. - */ -export class SafeEventEmitterProvider extends SafeEventEmitter { - #engine: JsonRpcEngine; - - /** - * Construct a SafeEventEmitterProvider from a JSON-RPC engine. - * - * @param options - Options. - * @param options.engine - The JSON-RPC engine used to process requests. - */ - constructor({ engine }: { engine: JsonRpcEngine }) { - super(); - this.#engine = engine; - - if (engine.on) { - engine.on('notification', (message: string) => { - this.emit('data', null, message); - }); - } - } - - /** - * Send a provider request asynchronously. - * - * @param req - The request to send. - * @param callback - A function that is called upon the success or failure of the request. - */ - sendAsync = ( - req: JsonRpcRequest, - callback: (error: unknown, providerRes?: any) => void, - ) => { - this.#engine.handle(req, callback); - }; - - /** - * Send a provider request asynchronously. - * - * This method serves the same purpose as `sendAsync`. It only exists for - * legacy reasons. - * - * @deprecated Use `sendAsync` instead. - * @param req - The request to send. - * @param callback - A function that is called upon the success or failure of the request. - */ - send = ( - req: JsonRpcRequest, - callback: (error: unknown, providerRes?: any) => void, - ) => { - if (typeof callback !== 'function') { - throw new Error('Must provide callback to "send" method.'); - } - this.#engine.handle(req, callback); - }; -} diff --git a/packages/eth-json-rpc-provider/tsconfig.build.json b/packages/eth-json-rpc-provider/tsconfig.build.json index 02a0eea03fe..1c5f260cfdc 100644 --- a/packages/eth-json-rpc-provider/tsconfig.build.json +++ b/packages/eth-json-rpc-provider/tsconfig.build.json @@ -5,6 +5,6 @@ "outDir": "./dist", "rootDir": "./src" }, - "references": [], + "references": [{ "path": "../json-rpc-engine/tsconfig.build.json" }], "include": ["../../types", "./src"] } diff --git a/packages/eth-json-rpc-provider/tsconfig.json b/packages/eth-json-rpc-provider/tsconfig.json index a2659f90e70..4884c90fbc9 100644 --- a/packages/eth-json-rpc-provider/tsconfig.json +++ b/packages/eth-json-rpc-provider/tsconfig.json @@ -9,6 +9,10 @@ "noUncheckedIndexedAccess": true, "target": "es2017" }, - "references": [], + "references": [ + { + "path": "../json-rpc-engine" + } + ], "include": ["../../types", "../../tests", "./src", "./tests"] } diff --git a/packages/foundryup/.gitignore b/packages/foundryup/.gitignore new file mode 100644 index 00000000000..2cc96e207b4 --- /dev/null +++ b/packages/foundryup/.gitignore @@ -0,0 +1 @@ +.metamask \ No newline at end of file diff --git a/packages/foundryup/.yarnrc.yml b/packages/foundryup/.yarnrc.yml new file mode 100644 index 00000000000..4f0649b0716 --- /dev/null +++ b/packages/foundryup/.yarnrc.yml @@ -0,0 +1 @@ +enableGlobalCache: false diff --git a/packages/foundryup/CHANGELOG.md b/packages/foundryup/CHANGELOG.md new file mode 100644 index 00000000000..f6ce1417dc3 --- /dev/null +++ b/packages/foundryup/CHANGELOG.md @@ -0,0 +1,33 @@ +# 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.1] + +### Fixed + +- fix: make anvil symlink relative ([#6202](https://github.com/MetaMask/core/pull/6202)) + +## [1.0.0] + +### Added + +- Initial release of the foundryup package ([#5810](https://github.com/MetaMask/core/pull/5810), [#5909](https://github.com/MetaMask/core/pull/5909)) + - `foundryup` is a cross-platform tool that installs and manages Foundry binaries with MetaMask-specific defaults for use in development and end-to-end testing workflows. Features included: + - CLI tool for managing Foundry binaries in MetaMask's development environment + - Support for downloading and installing `forge`, `anvil`, `cast`, and `chisel` binaries + - Cross-platform support for Linux, macOS, and Windows with both amd64 and arm64 architectures + - Binary integrity verification using SHA-256 checksums + - Intelligent binary installation with automatic symlink creation (falls back to copy if symlink fails) + - Configurable binary caching with local storage support + - Cache management commands for cleaning downloaded binaries + - Automatic version detection and management of Foundry releases + +[Unreleased]: https://github.com/MetaMask/core/compare/@metamask/foundryup@1.0.1...HEAD +[1.0.1]: https://github.com/MetaMask/core/compare/@metamask/foundryup@1.0.0...@metamask/foundryup@1.0.1 +[1.0.0]: https://github.com/MetaMask/core/releases/tag/@metamask/foundryup@1.0.0 diff --git a/packages/foundryup/LICENSE b/packages/foundryup/LICENSE new file mode 100644 index 00000000000..c259cd7ebcf --- /dev/null +++ b/packages/foundryup/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2025 MetaMask + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packages/foundryup/README.md b/packages/foundryup/README.md new file mode 100644 index 00000000000..2cb0912a63d --- /dev/null +++ b/packages/foundryup/README.md @@ -0,0 +1,43 @@ +# `@metamask/foundryup` + +foundryup + +## Installation + +`yarn add @metamask/foundryup` + +or + +`npm install @metamask/foundryup` + +## Usage + +Once installed into a package you can do `yarn bin mm-foundryup`. + +This will install the latest version of Foundry things by default. + +Try `yarn bin mm-foundryup --help` for more options. + +Once you have the binaries installed, you have to figure out how to get to them. + +Probably best to just add each as a `package.json` script: + +```json +"scripts": { + "anvil": "node_modules/.bin/anvil", +} +``` + +Kind of weird, but it seems to work okay. You can probably use `npx anvil` in place of `node_modules/.bin/anvil`, but +getting it to work in all scenarios (cross platform and in CI) wasn't straightforward. `yarn bin anvil` doesn't work +in yarn v4 because it isn't a bin of `@metamask/foundryup`, so yarn pretends it doesn't exist. + +This all needs to work. + +--- + +You can try it here in the monorepo by running `yarn workspace @metamask/foundryup anvil`. + +## 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/foundryup/jest.config.js b/packages/foundryup/jest.config.js new file mode 100644 index 00000000000..3e2689f3405 --- /dev/null +++ b/packages/foundryup/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: 50, + functions: 63.15, + lines: 55.08, + statements: 55.02, + }, + }, +}); diff --git a/packages/foundryup/package.json b/packages/foundryup/package.json new file mode 100644 index 00000000000..4ab1f0c17f3 --- /dev/null +++ b/packages/foundryup/package.json @@ -0,0 +1,76 @@ +{ + "name": "@metamask/foundryup", + "version": "1.0.1", + "description": "foundryup", + "keywords": [ + "Ethereum", + "MetaMask" + ], + "homepage": "https://github.com/MetaMask/core/tree/main/packages/foundryup#readme", + "bugs": { + "url": "https://github.com/MetaMask/core/issues" + }, + "license": "MIT", + "repository": { + "type": "git", + "url": "https://github.com/MetaMask/core.git" + }, + "bin": { + "mm-foundryup": "./dist/cli.mjs" + }, + "files": [ + "dist/" + ], + "sideEffects": false, + "main": "./dist/index.cjs", + "types": "./dist/index.d.cts", + "exports": { + "./package.json": "./package.json" + }, + "publishConfig": { + "access": "public", + "registry": "https://registry.npmjs.org/" + }, + "scripts": { + "anvil": "node_modules/.bin/anvil", + "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/foundryup", + "changelog:validate": "../../scripts/validate-changelog.sh @metamask/foundryup", + "lint:tsconfigs": "tsx ../../scripts/lint-tsconfigs/lint-tsconfigs.mts", + "lint:tsconfigs:fix": "tsx ../../scripts/lint-tsconfigs/lint-tsconfigs.mts --fix", + "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": { + "minipass": "^7.1.2", + "tar": "^7.4.3", + "unzipper": "^0.12.3", + "yargs": "^17.7.2", + "yargs-parser": "^21.1.1" + }, + "devDependencies": { + "@metamask/auto-changelog": "^6.1.0", + "@ts-bridge/cli": "^0.6.4", + "@types/jest": "^30.0.0", + "@types/unzipper": "^0.10.10", + "@types/yargs": "^17.0.32", + "@types/yargs-parser": "^21.0.3", + "deepmerge": "^4.2.2", + "jest": "^30.4.2", + "nock": "^13.3.1", + "ts-jest": "^29.4.11", + "tsx": "^4.20.5", + "typedoc": "^0.25.13", + "typedoc-plugin-missing-exports": "^2.0.0", + "typescript": "~5.3.3", + "yaml": "^2.3.4" + }, + "engines": { + "node": "^18.18 || >=20" + } +} diff --git a/packages/foundryup/src/cli.ts b/packages/foundryup/src/cli.ts new file mode 100644 index 00000000000..39ea8c28750 --- /dev/null +++ b/packages/foundryup/src/cli.ts @@ -0,0 +1,22 @@ +#!/usr/bin/env node + +/** + * CLI entry point for Foundryup. + * + * This script downloads and installs Foundry binaries. + * If an error occurs, it logs the error and exits with code 1. + */ +import { downloadAndInstallFoundryBinaries } from './index.js'; + +/** + * Run the main installation process and handle errors. + */ +downloadAndInstallFoundryBinaries().catch((error) => { + /** + * Log any error that occurs during installation and exit with code 1. + * + * @param error - The error thrown during installation. + */ + console.error('Error:', error); + process.exit(1); +}); diff --git a/packages/foundryup/src/download.ts b/packages/foundryup/src/download.ts new file mode 100644 index 00000000000..d9dac1e9cbc --- /dev/null +++ b/packages/foundryup/src/download.ts @@ -0,0 +1,91 @@ +import { request as httpRequest } from 'node:http'; +import type { IncomingMessage } from 'node:http'; +import { request as httpsRequest } from 'node:https'; +import { Stream } from 'node:stream'; +import { pipeline } from 'node:stream/promises'; + +import type { DownloadOptions } from './types.js'; + +/** + * A PassThrough stream that emits a 'response' event when the HTTP(S) response is available. + */ +class DownloadStream extends Stream.PassThrough { + /** + * Returns a promise that resolves with the HTTP(S) IncomingMessage response. + * + * @returns The HTTP(S) response stream. + */ + async response(): Promise { + return new Promise((resolve, reject) => { + this.once('response', resolve); + this.once('error', reject); + }); + } +} + +/** + * Starts a download from the given URL. + * + * @param url - The URL to download from + * @param options - The download options + * @param redirects - The number of redirects that have occurred + * @returns A stream of the download + */ +export function startDownload( + url: URL, + options: DownloadOptions = {}, + redirects: number = 0, +) { + const MAX_REDIRECTS = options.maxRedirects ?? 5; + const request = url.protocol === 'http:' ? httpRequest : httpsRequest; + const stream = new DownloadStream(); + request(url, options, (response) => { + stream.once('close', () => { + response.destroy(); + }); + + const { statusCode, statusMessage, headers } = response; + // handle redirects + if ( + statusCode && + statusCode >= 300 && + statusCode < 400 && + headers.location + ) { + if (redirects >= MAX_REDIRECTS) { + stream.emit('error', new Error('Too many redirects')); + response.destroy(); + } else { + // note: we don't emit a response until we're done redirecting, because + // handlers only expect it to be emitted once. + pipeline( + startDownload(new URL(headers.location, url), options, redirects + 1) + // emit the response event to the stream + .once('response', stream.emit.bind(stream, 'response')), + stream, + ).catch(stream.emit.bind(stream, 'error')); + response.destroy(); + } + } + + // check for HTTP errors + else if (!statusCode || statusCode < 200 || statusCode >= 300) { + stream.emit( + 'error', + new Error( + `Request to ${url} failed. Status Code: ${statusCode} - ${statusMessage}`, + ), + ); + response.destroy(); + } else { + // resolve with response stream + stream.emit('response', response); + + response.once('error', stream.emit.bind(stream, 'error')); + pipeline(response, stream).catch(stream.emit.bind(stream, 'error')); + } + }) + .once('error', stream.emit.bind(stream, 'error')) + .end(); + return stream; +} diff --git a/packages/foundryup/src/extract.ts b/packages/foundryup/src/extract.ts new file mode 100644 index 00000000000..bc809bd7b58 --- /dev/null +++ b/packages/foundryup/src/extract.ts @@ -0,0 +1,251 @@ +import { Minipass } from 'minipass'; +import { ok } from 'node:assert/strict'; +import { createHash } from 'node:crypto'; +import { createWriteStream } from 'node:fs'; +import { rename, mkdir, rm } from 'node:fs/promises'; +import { Agent as HttpAgent } from 'node:http'; +import { Agent as HttpsAgent } from 'node:https'; +import { join, basename, extname, relative } from 'node:path'; +import { pipeline } from 'node:stream/promises'; +import { extract as extractTar } from 'tar'; +import { Open } from 'unzipper'; +import type { Source, Entry } from 'unzipper'; + +import { startDownload } from './download.js'; +import { Extension } from './types.js'; +import type { Binary } from './types.js'; +import { say } from './utils.js'; + +/** + * Extracts the binaries from the given URL and writes them to the destination. + * + * @param url - The URL of the archive to extract the binaries from + * @param binaries - The list of binaries to extract + * @param dir - The destination directory + * @param checksums - The checksums to verify the binaries against + * @returns The list of binaries extracted + */ + +/** + * Extracts the binaries from the given URL and writes them to the destination. + * + * @param url - The URL of the archive to extract the binaries from + * @param binaries - The list of binaries to extract + * @param dir - The destination directory + * @param checksums - The checksums to verify the binaries against + * @returns The list of binaries extracted + */ +export async function extractFrom( + url: URL, + binaries: Binary[], + dir: string, + checksums: { algorithm: string; binaries: Record } | null, +) { + const extract = url.pathname.toLowerCase().endsWith(Extension.Tar) + ? extractFromTar + : extractFromZip; + // write all files to a temporary directory first, then rename to the final + // destination to avoid accidental partial extraction. We don't use + // `os.tmpdir` for this because `rename` will fail if the directories are on + // different file systems. + const tempDir = `${dir}.downloading`; + const rmOpts = { recursive: true, maxRetries: 3, force: true }; + try { + // clean up any previous in-progress downloads + await rm(tempDir, rmOpts); + // make the temporary directory to extract the binaries to + await mkdir(tempDir, { recursive: true }); + const downloads = await extract( + url, + binaries, + tempDir, + checksums?.algorithm, + ); + ok(downloads.length === binaries.length, 'Failed to extract all binaries'); + + const paths: string[] = []; + for (const { path, binary, checksum } of downloads) { + if (checksums) { + say(`verifying checksum for ${binary}`); + const expected = checksums.binaries[binary]; + if (checksum === expected) { + say(`checksum verified for ${binary}`); + } else { + throw new Error( + `checksum mismatch for ${binary}, expected ${expected}, got ${checksum}`, + ); + } + } + // add the *final* path to the list of binaries + paths.push(join(dir, relative(tempDir, path))); + } + + // this directory shouldn't exist, but if two simultaneous `yarn foundryup` + // processes are running, it might. Last process wins, so we remove other + // `dir`s just in case. + await rm(dir, rmOpts); + // everything has been extracted; move the files to their final destination + await rename(tempDir, dir); + // return the list of extracted binaries + return paths; + } catch (error) { + // if things fail for any reason try to clean up a bit. it is very important + // to not leave `dir` behind, as its existence is a signal that the binaries + // are installed. + const rmErrors = ( + await Promise.allSettled([rm(tempDir, rmOpts), rm(dir, rmOpts)]) + ) + .filter((r) => r.status === 'rejected') + .map((r) => (r as PromiseRejectedResult).reason); + + // if we failed to clean up, create an aggregate error message + if (rmErrors.length) { + throw new AggregateError( + [error, ...rmErrors], + 'This is a bug; you should report it.', + ); + } + throw error; + } +} +/** + * Extracts the binaries from a tar archive. + * + * @param url - The URL of the archive to extract the binaries from + * @param binaries - The list of binaries to extract + * @param dir - The destination directory + * @param checksumAlgorithm - The checksum algorithm to use + * @returns The list of binaries extracted + */ + +/** + * Extracts the binaries from a tar archive. + * + * @param url - The URL of the archive to extract the binaries from + * @param binaries - The list of binaries to extract + * @param dir - The destination directory + * @param checksumAlgorithm - The checksum algorithm to use + * @returns The list of binaries extracted + */ +async function extractFromTar( + url: URL, + binaries: Binary[], + dir: string, + checksumAlgorithm?: string, +) { + const downloads: { + path: string; + binary: Binary; + checksum?: string; + }[] = []; + await pipeline( + startDownload(url), + extractTar( + { + cwd: dir, + // @ts-expect-error: Types here broke in `tar@7.5.12`, but it appears to + // work fine as-is. + // See: https://github.com/isaacs/node-tar/issues/459 + transform: (entry) => { + const absolutePath = entry.absolute; + if (!absolutePath) { + throw new Error('Missing absolute path for entry'); + } + + if (checksumAlgorithm) { + const hash = createHash(checksumAlgorithm); + const passThrough = new Minipass({ async: true }); + passThrough.pipe(hash); + passThrough.on('end', () => { + downloads.push({ + path: absolutePath, + binary: entry.path as Binary, + checksum: hash.digest('hex'), + }); + }); + return passThrough; + } + + // When no checksum is needed, record the entry and return undefined + // to use the original stream without transformation + downloads.push({ + path: absolutePath, + binary: entry.path as Binary, + }); + return undefined; + }, + }, + binaries, + ), + ); + return downloads; +} +/** + * Extracts the binaries from a zip archive. + * + * @param url - The URL of the archive to extract the binaries from + * @param binaries - The list of binaries to extract + * @param dir - The destination directory + * @param checksumAlgorithm - The checksum algorithm to use + * @returns The list of binaries extracted + */ +async function extractFromZip( + url: URL, + binaries: Binary[], + dir: string, + checksumAlgorithm?: string, +) { + const agent = new (url.protocol === 'http:' ? HttpAgent : HttpsAgent)({ + keepAlive: true, + }); + const source: Source = { + async size() { + const download = startDownload(url, { agent, method: 'HEAD' }); + const response = await download.response(); + const contentLength = response.headers['content-length']; + return contentLength ? parseInt(contentLength, 10) : 0; + }, + stream(offset: number, bytes: number) { + const options = { + agent, + headers: { + range: `bytes=${offset}-${bytes ? offset + bytes : ''}`, + }, + }; + return startDownload(url, options); + }, + }; + + const { files } = await Open.custom(source, {}); + const filtered = files.filter(({ path }) => + binaries.includes(basename(path, extname(path)) as Binary), + ); + return await Promise.all( + filtered.map(async ({ path, stream }) => { + const dest = join(dir, path); + const entry = stream(); + const destStream = createWriteStream(dest); + const binary = basename(path, extname(path)) as Binary; + if (checksumAlgorithm) { + const hash = createHash(checksumAlgorithm); + const hashStream = async function* (entryStream: Entry) { + for await (const chunk of entryStream) { + hash.update(chunk); + yield chunk; + } + }; + await pipeline(entry, hashStream, destStream); + return { + path: dest, + binary, + checksum: hash.digest('hex'), + }; + } + await pipeline(entry, destStream); + return { + path: dest, + binary, + }; + }), + ); +} diff --git a/packages/foundryup/src/foundryup.test.ts b/packages/foundryup/src/foundryup.test.ts new file mode 100644 index 00000000000..113709505cf --- /dev/null +++ b/packages/foundryup/src/foundryup.test.ts @@ -0,0 +1,560 @@ +import type { Dir } from 'fs'; +import { readFileSync } from 'fs'; +import fs from 'fs/promises'; +import nock, { cleanAll } from 'nock'; +import { join, relative } from 'path'; +import { parse as parseYaml } from 'yaml'; + +import { + checkAndDownloadBinaries, + getBinaryArchiveUrl, + getCacheDirectory, +} from './index.js'; +import { parseArgs } from './options.js'; +import type { Binary, Checksums } from './types.js'; +import { Architecture, Platform } from './types.js'; +import { isCodedError } from './utils.js'; + +type OperationDetails = { + path?: string; + repo?: string; + tag?: string; + version?: string; + platform?: Platform; + arch?: Architecture; + binaries?: string[]; + binDir?: string; + cachePath?: string; + url?: URL; + checksums?: Checksums; +}; + +jest.mock('fs/promises', () => { + console.log('Mocking fs/promises'); + const actualFs = jest.requireActual('fs/promises'); + return { + ...actualFs, + opendir: jest.fn().mockImplementation((path) => { + console.log('Mock opendir called with path:', path); + // Simulate ENOENT error for the first call + const error = new Error( + `ENOENT: no such file or directory, opendir '${path}`, + ); + (error as NodeJS.ErrnoException).code = 'ENOENT'; + throw error; + }), + mkdir: jest.fn().mockResolvedValue(undefined), + access: jest.fn().mockResolvedValue(undefined), + symlink: jest.fn(), + unlink: jest.fn(), + copyFile: jest.fn(), + rm: jest.fn(), + }; +}); + +jest.mock('fs'); +jest.mock('yaml'); + +jest.mock('./options', () => ({ + ...jest.requireActual('./options'), + parseArgs: jest.fn(), + printBanner: jest.fn(), + say: jest.fn(), + getVersion: jest.fn().mockReturnValue('0.1.0'), + extractFrom: jest.fn().mockResolvedValue(['mock/path/to/binary']), +})); + +const mockInstallBinaries = async ( + downloadedBinaries: Dir, + BIN_DIR: string, + cachePath: string, +): Promise<{ operation: string; source?: string; target?: string }[]> => { + const mockOperations: { + operation: string; + source?: string; + target?: string; + }[] = []; + + for await (const file of downloadedBinaries) { + if (!file.isFile()) { + continue; + } + const target = join(file.parentPath, file.name); + const path = join(BIN_DIR, relative(cachePath, target)); + + mockOperations.push({ operation: 'unlink', target: path }); + + try { + await fs.symlink(target, path); + mockOperations.push({ + operation: 'symlink', + source: target, + target: path, + }); + } catch (e) { + if (!(isCodedError(e) && ['EPERM', 'EXDEV'].includes(e.code))) { + throw e; + } + mockOperations.push({ + operation: 'copyFile', + source: target, + target: path, + }); + } + + mockOperations.push({ operation: 'getVersion', target: path }); + } + + return mockOperations; +}; + +const mockDownloadAndInstallFoundryBinaries = async (): Promise< + { operation: string; details?: OperationDetails }[] +> => { + const operations: { operation: string; details?: OperationDetails }[] = []; + const parsedArgs = parseArgs(); + + operations.push({ operation: 'getCacheDirectory' }); + const CACHE_DIR = getCacheDirectory(); + + if (parsedArgs.command === 'cache clean') { + await fs.rm(CACHE_DIR, { recursive: true, force: true }); + operations.push({ operation: 'cleanCache', details: { path: CACHE_DIR } }); + return operations; + } + + const { + repo, + version: { version, tag }, + arch, + platform, + binaries, + } = parsedArgs.options; + + operations.push({ + operation: 'getBinaryArchiveUrl', + details: { repo, tag, version, platform, arch }, + }); + + const BIN_ARCHIVE_URL = getBinaryArchiveUrl( + repo, + tag, + version, + platform, + arch, + ); + const url = new URL(BIN_ARCHIVE_URL); + + operations.push({ + operation: 'checkAndDownloadBinaries', + details: { url, binaries, cachePath: CACHE_DIR, platform, arch }, + }); + + operations.push({ + operation: 'installBinaries', + details: { + binaries, + binDir: 'node_modules/.bin', + cachePath: CACHE_DIR, + }, + }); + + return operations; +}; + +describe('foundryup', () => { + describe('getCacheDirectory', () => { + it('uses global cache when enabled in .yarnrc.yml', () => { + (parseYaml as jest.Mock).mockReturnValue({ enableGlobalCache: true }); + (readFileSync as jest.Mock).mockReturnValue('dummy yaml content'); + + const result = getCacheDirectory(); + expect(result).toMatch(/\/(home|Users)\/.*\/\.cache\/metamask$/u); + }); + + it('uses local cache when global cache is disabled', () => { + (parseYaml as jest.Mock).mockReturnValue({ enableGlobalCache: false }); + (readFileSync as jest.Mock).mockReturnValue('dummy yaml content'); + + const result = getCacheDirectory(); + expect(result).toContain('.metamask/cache'); + }); + }); + + describe('getBinaryArchiveUrl', () => { + it('generates correct download URL for Linux', () => { + const result = getBinaryArchiveUrl( + 'foundry-rs/foundry', + 'v1.0.0', + '1.0.0', + Platform.Linux, + Architecture.Amd64, + ); + + expect(result).toMatch(/^https:\/\/github.com\/.*\.tar\.gz$/u); + }); + + it('generates correct download URL for Windows', () => { + const result = getBinaryArchiveUrl( + 'foundry-rs/foundry', + 'v1.0.0', + '1.0.0', + Platform.Windows, + Architecture.Amd64, + ); + + expect(result).toMatch(/^https:\/\/github.com\/.*\.zip$/u); + }); + }); + + describe('checkAndDownloadBinaries', () => { + const mockUrl = new URL('https://example.com/binaries.zip'); + const mockBinaries = ['forge'] as Binary[]; + const mockCachePath = './test-cache-path'; + + beforeEach(() => { + jest.clearAllMocks(); + cleanAll(); + }); + + it('handles download errors gracefully', async () => { + (fs.opendir as jest.Mock).mockRejectedValue({ code: 'ENOENT' }); + + cleanAll(); + nock('https://example.com') + .head('/binaries.zip') + .reply(500, 'Internal Server Error') + .get('/binaries.zip') + .reply(500, 'Internal Server Error'); + + const result = checkAndDownloadBinaries( + mockUrl, + mockBinaries, + mockCachePath, + Platform.Linux, + Architecture.Amd64, + ); + await expect(result).rejects.toThrow( + 'Request to https://example.com/binaries.zip failed. Status Code: 500 - null', + ); + }); + }); + + describe('installBinaries', () => { + const mockBinDir = '/mock/bin/dir'; + const mockCachePath = '/mock/cache/path'; + const mockDir = { + async *[Symbol.asyncIterator]() { + yield { + name: 'forge', + isFile: () => true, + parentPath: mockCachePath, + }; + }, + } as unknown as Dir; + + it('should correctly install binaries and create symlinks', async () => { + const operations = await mockInstallBinaries( + mockDir, + mockBinDir, + mockCachePath, + ); + + expect(operations).toStrictEqual([ + { operation: 'unlink', target: `${mockBinDir}/forge` }, + { + operation: 'symlink', + source: `${mockCachePath}/forge`, + target: `${mockBinDir}/forge`, + }, + { operation: 'getVersion', target: `${mockBinDir}/forge` }, + ]); + }); + + it('should fall back to copying files when symlink fails with EPERM', async () => { + const epermError = new Error('EPERM') as NodeJS.ErrnoException; + epermError.code = 'EPERM'; + + // Mock symlink to fail + (fs.symlink as jest.Mock).mockRejectedValueOnce(epermError); + + const operations = await mockInstallBinaries( + mockDir, + mockBinDir, + mockCachePath, + ); + + expect(operations).toStrictEqual([ + { operation: 'unlink', target: `${mockBinDir}/forge` }, + { + operation: 'copyFile', + source: `${mockCachePath}/forge`, + target: `${mockBinDir}/forge`, + }, + { operation: 'getVersion', target: `${mockBinDir}/forge` }, + ]); + }); + + it('should throw error for non-permission-related symlink failures', async () => { + const otherError = new Error('Other error'); + + // Mock symlink to fail with other error + jest.spyOn(fs, 'symlink').mockRejectedValue(otherError); + + await expect( + mockInstallBinaries(mockDir, mockBinDir, mockCachePath), + ).rejects.toThrow('Other error'); + }); + }); + + describe('downloadAndInstallFoundryBinaries', () => { + const mockArgs = { + command: '', + options: { + repo: 'foundry-rs/foundry', + version: { + version: '1.0.0', + tag: 'v1.0.0', + }, + arch: Architecture.Amd64, + platform: Platform.Linux, + binaries: ['forge', 'anvil'], + checksums: { + algorithm: 'sha256', + binaries: { + forge: { + 'linux-amd64': 'mock-checksum', + 'linux-arm64': 'mock-checksum', + 'darwin-amd64': 'mock-checksum', + 'darwin-arm64': 'mock-checksum', + 'win32-amd64': 'mock-checksum', + 'win32-arm64': 'mock-checksum', + }, + anvil: { + 'linux-amd64': 'mock-checksum', + 'linux-arm64': 'mock-checksum', + 'darwin-amd64': 'mock-checksum', + 'darwin-arm64': 'mock-checksum', + 'win32-amd64': 'mock-checksum', + 'win32-arm64': 'mock-checksum', + }, + }, + }, + }, + }; + + beforeEach(() => { + jest.clearAllMocks(); + const mockedOptions = jest.requireMock('./options'); + + mockedOptions.parseArgs.mockReturnValue(mockArgs); + mockedOptions.printBanner.mockImplementation(() => { + // Intentionally empty - used to suppress test output + }); + mockedOptions.say.mockImplementation(jest.fn()); + }); + + it('should execute all operations in correct order', async () => { + const operations = await mockDownloadAndInstallFoundryBinaries(); + + expect(operations).toStrictEqual([ + { operation: 'getCacheDirectory' }, + { + operation: 'getBinaryArchiveUrl', + details: { + repo: 'foundry-rs/foundry', + tag: 'v1.0.0', + version: '1.0.0', + platform: Platform.Linux, + arch: Architecture.Amd64, + }, + }, + { + operation: 'checkAndDownloadBinaries', + details: expect.objectContaining({ + binaries: ['forge', 'anvil'], + platform: Platform.Linux, + arch: Architecture.Amd64, + }), + }, + { + operation: 'installBinaries', + details: { + binaries: ['forge', 'anvil'], + binDir: 'node_modules/.bin', + cachePath: expect.stringContaining('metamask'), + }, + }, + ]); + }); + + it('should handle cache clean command', async () => { + const mockCleanArgs = { + ...mockArgs, + command: 'cache clean', + }; + + (parseArgs as jest.Mock).mockReturnValue(mockCleanArgs); + const rmSpy = jest.spyOn(fs, 'rm').mockResolvedValue(); + + const operations = await mockDownloadAndInstallFoundryBinaries(); + + expect(operations).toStrictEqual([ + { operation: 'getCacheDirectory' }, + { + operation: 'cleanCache', + details: { + path: expect.stringContaining('metamask'), + }, + }, + ]); + expect(rmSpy).toHaveBeenCalled(); + }); + + it('should handle errors gracefully', async () => { + jest.spyOn(fs, 'rm').mockRejectedValue(new Error('Mock error')); + const consoleSpy = jest.spyOn(console, 'error').mockImplementation(); + + const mockCleanArgs = { + ...mockArgs, + command: 'cache clean', + }; + + (parseArgs as jest.Mock).mockReturnValue(mockCleanArgs); + + await expect(mockDownloadAndInstallFoundryBinaries()).rejects.toThrow( + 'Mock error', + ); + consoleSpy.mockRestore(); + }); + }); + + describe('printBanner', () => { + it('should print the banner to the console', () => { + const { printBanner } = jest.requireActual('./options'); + const consoleSpy = jest.spyOn(console, 'log').mockImplementation(() => { + // Intentionally empty - used to suppress test output + }); + printBanner(); + expect(consoleSpy).toHaveBeenCalled(); + expect(consoleSpy.mock.calls[0][0]).toContain( + 'Portable and modular toolkit', + ); + consoleSpy.mockRestore(); + }); + }); + + describe('parseArgs', () => { + let actualParseArgs: (args?: string[]) => { + command: string; + options: { + binaries: string[]; + repo: string; + version: { version: string; tag: string }; + arch: string; + platform: string; + checksums?: Checksums; + }; + }; + + beforeEach(() => { + jest.unmock('./options'); + const optionsModule = jest.requireActual('./options'); + actualParseArgs = optionsModule.parseArgs; + }); + + afterEach(() => { + // Re-mock after each test + jest.doMock('./options', () => ({ + ...jest.requireActual('./options'), + parseArgs: jest.fn(), + printBanner: jest.fn(), + })); + }); + + describe('checksums option', () => { + it('should parse checksums from JSON string', () => { + const checksums = { + algorithm: 'sha256', + binaries: { + forge: { + 'linux-amd64': 'abc123', + }, + }, + }; + const result = actualParseArgs([ + '--checksums', + JSON.stringify(checksums), + ]); + + expect(result.command).toBe('install'); + expect(result.options.checksums).toStrictEqual(checksums); + }); + + it('should parse checksums with short flag -c', () => { + const checksums = { algorithm: 'sha256', binaries: {} }; + const result = actualParseArgs(['-c', JSON.stringify(checksums)]); + + expect(result.command).toBe('install'); + expect(result.options.checksums).toStrictEqual(checksums); + }); + }); + + describe('repo option', () => { + it('should parse custom repo with --repo flag', () => { + const result = actualParseArgs(['--repo', 'custom/repo']); + expect(result.command).toBe('install'); + expect(result.options.repo).toBe('custom/repo'); + }); + + it('should parse repo with short flag -r', () => { + const result = actualParseArgs(['-r', 'another/repo']); + + expect(result.command).toBe('install'); + expect(result.options.repo).toBe('another/repo'); + }); + }); + + describe('version option', () => { + it('should parse nightly version', () => { + const result = actualParseArgs(['--version', 'nightly']); + + expect(result.command).toBe('install'); + expect(result.options.version).toStrictEqual({ + version: 'nightly', + tag: 'nightly', + }); + }); + + it('should parse nightly with date suffix', () => { + const result = actualParseArgs(['--version', 'nightly-2024-01-01']); + + expect(result.command).toBe('install'); + expect(result.options.version).toStrictEqual({ + version: 'nightly', + tag: 'nightly-2024-01-01', + }); + }); + + it('should parse semantic version', () => { + const result = actualParseArgs(['--version', 'v1.2.3']); + + expect(result.command).toBe('install'); + expect(result.options.version).toStrictEqual({ + version: 'v1.2.3', + tag: 'v1.2.3', + }); + }); + + it('should parse version with short flag -v', () => { + const result = actualParseArgs(['-v', 'v2.0.0']); + + expect(result.command).toBe('install'); + expect(result.options.version).toStrictEqual({ + version: 'v2.0.0', + tag: 'v2.0.0', + }); + }); + }); + }); +}); diff --git a/packages/foundryup/src/index.ts b/packages/foundryup/src/index.ts new file mode 100755 index 00000000000..b483a61987e --- /dev/null +++ b/packages/foundryup/src/index.ts @@ -0,0 +1,224 @@ +#!/usr/bin/env -S node --require "./node_modules/tsx/dist/preflight.cjs" --import "./node_modules/tsx/dist/loader.mjs" + +import { createHash } from 'node:crypto'; +import { readFileSync } from 'node:fs'; +import type { Dir } from 'node:fs'; +import { + copyFile, + mkdir, + opendir, + rm, + symlink, + unlink, +} from 'node:fs/promises'; +import { homedir } from 'node:os'; +import { dirname, join, relative } from 'node:path'; +import { cwd, exit } from 'node:process'; +import { parse as parseYaml } from 'yaml'; + +import { extractFrom } from './extract.js'; +import { parseArgs, printBanner } from './options.js'; +import type { Checksums, Architecture, Binary } from './types.js'; +import { Extension, Platform } from './types.js'; +import { + getVersion, + isCodedError, + noop, + say, + transformChecksums, +} from './utils.js'; + +/** + * Determines the cache directory based on the .yarnrc.yml configuration. + * If global cache is enabled, returns a path in the user's home directory. + * Otherwise, returns a local cache path in the current working directory. + * + * @returns The path to the cache directory + */ +export function getCacheDirectory(): string { + let enableGlobalCache = false; + try { + const configFileContent = readFileSync('.yarnrc.yml', 'utf8'); + const parsedConfig = parseYaml(configFileContent); + enableGlobalCache = parsedConfig?.enableGlobalCache ?? false; + } catch (error) { + // If file doesn't exist or can't be read, default to local cache + if ((error as NodeJS.ErrnoException).code === 'ENOENT') { + return join(cwd(), '.metamask', 'cache'); + } + // For other errors, log but continue with default + console.warn( + 'Warning: Error reading .yarnrc.yml, using local cache:', + error, + ); + } + return enableGlobalCache + ? join(homedir(), '.cache', 'metamask') + : join(cwd(), '.metamask', 'cache'); +} + +/** + * Generates the URL for downloading the Foundry binary archive. + * + * @param repo - The GitHub repository (e.g., 'foundry-rs/foundry') + * @param tag - The release tag (e.g., 'v1.0.0') + * @param version - The version string + * @param platform - The target platform (e.g., Platform.Linux) + * @param arch - The target architecture (e.g., 'amd64') + * @returns The URL for the binary archive + */ +export function getBinaryArchiveUrl( + repo: string, + tag: string, + version: string, + platform: Platform, + arch: string, +): string { + const ext = platform === Platform.Windows ? Extension.Zip : Extension.Tar; + return `https://github.com/${repo}/releases/download/${tag}/foundry_${version}_${platform}_${arch}.${ext}`; +} + +/** + * Checks if binaries are already in the cache. If not, downloads and extracts them. + * + * @param url - The URL to download the binaries from + * @param binaries - The list of binaries to download + * @param cachePath - The path to the cache directory + * @param platform - The target platform + * @param arch - The target architecture + * @param checksums - Optional checksums for verification + * @returns A promise that resolves to the directory containing the downloaded binaries + */ +export async function checkAndDownloadBinaries( + url: URL, + binaries: Binary[], + cachePath: string, + platform: Platform, + arch: Architecture, + checksums?: Checksums, +): Promise { + let downloadedBinaries: Dir; + try { + say(`checking cache`); + downloadedBinaries = await opendir(cachePath); + say(`found binaries in cache`); + } catch (e: unknown) { + say(`binaries not in cache`); + if ((e as NodeJS.ErrnoException).code === 'ENOENT') { + say(`installing from ${url.toString()}`); + // directory doesn't exist, download and extract + const platformChecksums = transformChecksums(checksums, platform, arch); + await extractFrom(url, binaries, cachePath, platformChecksums); + downloadedBinaries = await opendir(cachePath); + } else { + throw e; + } + } + return downloadedBinaries; +} + +/** + * Installs the downloaded binaries by creating symlinks or copying files. + * + * @param downloadedBinaries - The directory containing the downloaded binaries + * @param BIN_DIR - The target directory for installation + * @param cachePath - The path to the cache directory + * @returns A promise that resolves when installation is complete + */ +export async function installBinaries( + downloadedBinaries: Dir, + BIN_DIR: string, + cachePath: string, +): Promise { + for await (const file of downloadedBinaries) { + if (!file.isFile()) { + continue; + } + const target = join(file.parentPath, file.name); + const path = join(BIN_DIR, relative(cachePath, target)); + + // compute the relative path from where the symlink will be created + // to the target file, so that it works even if the project is moved + // (like in some CI environments) + const relativeTarget = relative(dirname(path), target); + + // create the BIN_DIR paths if they don't exists already + await mkdir(BIN_DIR, { recursive: true }); + + // clean up any existing files or symlinks + await unlink(path).catch(noop); + try { + // create new symlink + await symlink(relativeTarget, path); + } catch (e) { + if (!(isCodedError(e) && ['EPERM', 'EXDEV'].includes(e.code))) { + throw e; + } + // symlinking can fail if it's a cross-device/filesystem link, or for + // permissions reasons, so we'll just copy the file instead + await copyFile(target, path); + } + // check that it works by logging the version + say(`installed - ${getVersion(path).toString()}`); + } +} + +/** + * Downloads and installs Foundry binaries based on command-line arguments. + * If the command is 'cache clean', it removes the cache directory. + * Otherwise, it downloads and installs the specified binaries. + * + * @returns A promise that resolves when the operation is complete + */ +export async function downloadAndInstallFoundryBinaries(): Promise { + const parsedArgs = parseArgs(); + + const CACHE_DIR = getCacheDirectory(); + + if (parsedArgs.command === 'cache clean') { + await rm(CACHE_DIR, { recursive: true, force: true }); + say('done!'); + exit(0); + } + + const { + repo, + version: { version, tag }, + arch, + platform, + binaries, + checksums, + } = parsedArgs.options; + + printBanner(); + const bins = binaries.join(', '); + say(`fetching ${bins} ${version} for ${platform} ${arch}`); + + const BIN_ARCHIVE_URL = getBinaryArchiveUrl( + repo, + tag, + version, + platform, + arch, + ); + const BIN_DIR = join(cwd(), 'node_modules', '.bin'); + + const url = new URL(BIN_ARCHIVE_URL); + const cacheKey = createHash('sha256') + .update(`${BIN_ARCHIVE_URL}-${bins}`) + .digest('hex'); + const cachePath = join(CACHE_DIR, cacheKey); + + const downloadedBinaries = await checkAndDownloadBinaries( + url, + binaries, + cachePath, + platform, + arch, + checksums, + ); + + await installBinaries(downloadedBinaries, BIN_DIR, cachePath); + + say('done!'); +} diff --git a/packages/foundryup/src/options.ts b/packages/foundryup/src/options.ts new file mode 100644 index 00000000000..97a95fd3816 --- /dev/null +++ b/packages/foundryup/src/options.ts @@ -0,0 +1,167 @@ +import { platform } from 'node:os'; +import { argv, stdout } from 'node:process'; +import yargs from 'yargs/yargs'; + +import { Architecture, Binary, Platform } from './types.js'; +import type { + Checksums, + ParsedOptions, + ArchitecturesTuple, + BinariesTuple, + PlatformsTuple, +} from './types.js'; +import { normalizeSystemArchitecture } from './utils.js'; + +/** + * Type guard to check if a string is a valid version string starting with 'v'. + * + * @param value - The string to check + * @returns True if the string is a valid version string + */ +function isVersionString(value: string): value is `v${string}` { + return /^v\d/u.test(value); +} + +/** + * Prints the Foundry banner to the console. + */ +export function printBanner() { + console.log(` +.xOx.xOx.xOx.xOx.xOx.xOx.xOx.xOx.xOx.xOx.xOx.xOx.xOx.xOx.xOx.xOx.xOx.xOx + + ╔═╗ ╔═╗ ╦ ╦ ╔╗╔ ╔╦╗ ╦═╗ ╦ ╦ Portable and modular toolkit + ╠╣ ║ ║ ║ ║ ║║║ ║║ ╠╦╝ ╚╦╝ for Ethereum Application Development + ╚ ╚═╝ ╚═╝ ╝╚╝ ═╩╝ ╩╚═ ╩ written in Rust. + +.xOx.xOx.xOx.xOx.xOx.xOx.xOx.xOx.xOx.xOx.xOx.xOx.xOx.xOx.xOx.xOx.xOx.xOx + +Repo : https://github.com/foundry-rs/ +Book : https://book.getfoundry.sh/ +Chat : https://t.me/foundry_rs/ +Support : https://t.me/foundry_support/ +Contribute : https://github.com/orgs/foundry-rs/projects/2/ + +.xOx.xOx.xOx.xOx.xOx.xOx.xOx.xOx.xOx.xOx.xOx.xOx.xOx.xOx.xOx.xOx.xOx.xOx +`); +} + +/** + * Parses command line arguments and returns the parsed options. + * + * @param args - Command line arguments to parse + * @returns Parsed command line arguments + */ +export function parseArgs(args: string[] = argv.slice(2)) { + const { $0, _, ...parsed } = yargs() + // Ensure unrecognized commands/options are reported as errors. + .strict() + // disable yargs's version, as it doesn't make sense here + .version(false) + // use the scriptName in `--help` output + .scriptName('yarn foundryup') + // wrap output at a maximum of 120 characters or `stdout.columns` + .wrap(Math.min(120, stdout.columns)) + .parserConfiguration({ + 'strip-aliased': true, + 'strip-dashed': true, + }) + // enable ENV parsing, which allows the user to specify foundryup options + // via environment variables prefixed with `FOUNDRYUP_` + .env('FOUNDRYUP') + .command(['$0', 'install'], 'Install foundry binaries', (builder) => { + builder.options(getOptions()).pkgConf('foundryup'); + }) + .command('cache', '', (builder) => { + builder.command('clean', 'Remove the shared cache files').demandCommand(); + }) + .parseSync(args); + + const command = _.join(' '); + if (command === 'cache clean') { + return { + command, + } as const; + } + + // if we get here `command` is always 'install' or '' (yargs checks it) + return { + command: 'install', + options: parsed as ParsedOptions>, + } as const; +} + +const Binaries = Object.values(Binary) as BinariesTuple; + +/** + * Returns the command line options configuration. + * + * @param defaultPlatform - Default platform to use + * @param defaultArch - Default architecture to use + * @returns Command line options configuration + */ +function getOptions( + defaultPlatform = platform(), + defaultArch = normalizeSystemArchitecture(), +) { + return { + binaries: { + alias: 'b', + type: 'array' as const, + multiple: true, + description: 'Specify the binaries to install', + default: Binaries, + choices: Binaries, + coerce: (values: Binary[]): Binary[] => [...new Set(values)], // Remove duplicates + }, + checksums: { + alias: 'c', + description: 'JSON object containing checksums for the binaries.', + coerce: (rawChecksums: string | Checksums): Checksums => { + try { + return typeof rawChecksums === 'string' + ? JSON.parse(rawChecksums) + : rawChecksums; + } catch { + throw new Error('Invalid checksums'); + } + }, + optional: true, + }, + repo: { + alias: 'r', + description: 'Specify the repository', + default: 'foundry-rs/foundry', + }, + version: { + alias: 'v', + description: + 'Specify the version (see: https://github.com/foundry-rs/foundry/tags)', + default: 'nightly', + coerce: ( + rawVersion: string, + ): { version: 'nightly' | `v${string}`; tag: string } => { + if (rawVersion.startsWith('nightly')) { + return { version: 'nightly', tag: rawVersion }; + // we don't validate the version much, we just trust the user + } else if (isVersionString(rawVersion)) { + return { version: rawVersion, tag: rawVersion }; + } + throw new Error('Invalid version'); + }, + }, + arch: { + alias: 'a', + description: 'Specify the architecture', + // if `defaultArch` is not a supported Architecture yargs will throw an error + default: defaultArch, + choices: Object.values(Architecture) as ArchitecturesTuple, + }, + platform: { + alias: 'p', + description: 'Specify the platform', + // if `defaultPlatform` is not a supported Platform yargs will throw an error + default: defaultPlatform as Platform, + choices: Object.values(Platform) as PlatformsTuple, + }, + }; +} diff --git a/packages/foundryup/src/types.ts b/packages/foundryup/src/types.ts new file mode 100644 index 00000000000..8428b214eaa --- /dev/null +++ b/packages/foundryup/src/types.ts @@ -0,0 +1,103 @@ +import type { Agent as HttpAgent } from 'node:http'; +import type { Agent as HttpsAgent } from 'node:https'; +import type { InferredOptionTypes, Options } from 'yargs'; + +// #region utils + +type UnionToIntersection = ((k: U) => void) extends (k: infer I) => void + ? I + : never; + +type LastInUnion = + UnionToIntersection< + U extends PropertyKey ? () => U : never + > extends () => infer Last + ? Last + : never; + +type UnionToTuple> = [U] extends [ + never, +] + ? [] + : [...UnionToTuple>, Last]; + +// #endregion utils + +// #region enums + +export enum Architecture { + Amd64 = 'amd64', + Arm64 = 'arm64', +} + +export enum Extension { + Zip = 'zip', + Tar = 'tar.gz', +} + +export enum Platform { + Windows = 'win32', + Linux = 'linux', + Mac = 'darwin', +} + +export enum Binary { + Anvil = 'anvil', + Forge = 'forge', + Cast = 'cast', + Chisel = 'chisel', +} + +// #endregion enums + +// #region helpers + +/** + * Tuple representing all members of the {@link Binary} enum. + */ +export type BinariesTuple = UnionToTuple; + +/** + * Tuple representing all members of the {@link Architecture} enum. + */ +export type ArchitecturesTuple = UnionToTuple; + +/** + * Tuple representing all members of the {@link Platform} enum. + */ +export type PlatformsTuple = UnionToTuple; + +/** + * Checksum types expected by the CLI. + */ +export type Checksums = { + algorithm: string; + binaries: Record>; +}; + +/** + * Checksum type expected by application code, specific to the selected + * {@link Platform} and {@link Architecture}. + * + * See also: {@link Checksums}. + */ +export type PlatformArchChecksums = { + algorithm: string; + binaries: Record; +}; + +/** + * Given a map of raw yargs options config, returns a map of inferred types. + */ +export type ParsedOptions = { + [key in keyof O]: InferredOptionTypes[key]; +}; + +export type DownloadOptions = { + method?: 'GET' | 'HEAD'; + headers?: Record; + agent?: HttpsAgent | HttpAgent; + maxRedirects?: number; +}; + +// #endregion helpers diff --git a/packages/foundryup/src/utils.ts b/packages/foundryup/src/utils.ts new file mode 100644 index 00000000000..51fc3a0f3fe --- /dev/null +++ b/packages/foundryup/src/utils.ts @@ -0,0 +1,125 @@ +import { execFileSync, execSync } from 'node:child_process'; +import { arch } from 'node:os'; + +import { Architecture } from './types.js'; +import type { + Checksums, + PlatformArchChecksums, + Binary, + Platform, +} from './types.js'; + +/** + * No Operation. A function that does nothing and returns nothing. + * + * @returns `undefined` + */ +export const noop = () => undefined; + +/** + * Returns the system architecture, normalized to one of the supported + * {@link Architecture} values. + * + * @param architecture - The architecture string to normalize (e.g., 'x64', 'arm64') + * @returns The normalized architecture value + */ +export function normalizeSystemArchitecture( + architecture: string = arch(), +): Architecture { + if (architecture.startsWith('arm')) { + // if `arm*`, use `arm64` + return Architecture.Arm64; + } else if (architecture === 'x64') { + // if `x64`, it _might_ be amd64 running via Rosetta on Apple Silicon + // (arm64). we can check this by running `sysctl.proc_translated` and + // checking the output; `1` === `arm64`. This can happen if the user is + // running an amd64 version of Node on Apple Silicon. We want to use the + // binaries native to the system for better performance. + try { + if (execSync('sysctl -n sysctl.proc_translated 2>/dev/null')[0] === 1) { + return Architecture.Arm64; + } + } catch { + // Ignore error: if sysctl check fails, we assume native amd64 + } + } + + return Architecture.Amd64; // Default for all other architectures +} + +/** + * Log a message to the console. + * + * @param message - The message to log + */ +export function say(message: string) { + console.log(`[foundryup] ${message}`); +} + +/** + * Get the version of the binary at the given path. + * + * @param binPath - Path to the binary executable + * @returns The `--version` reported by the binary + * @throws If the binary fails to report its version + */ +export function getVersion(binPath: string): Buffer { + try { + return execFileSync(binPath, ['--version']).subarray(0, -1); // ignore newline + } catch (error: unknown) { + const msg = `Failed to get version for ${binPath} + +Your selected platform or architecture may be incorrect, or the binary may not +support your system. If you believe this is an error, please report it.`; + if (error instanceof Error) { + error.message = `${msg}\n\n${error.message}`; + throw error; + } + throw new AggregateError([new Error(msg), error]); + } +} + +/** + * Type guard to check if an error has a code property. + * + * @param error - The error to check + * @returns True if the error has a code property + */ +export function isCodedError( + error: unknown, +): error is Error & { code: string } { + return ( + error instanceof Error && 'code' in error && typeof error.code === 'string' + ); +} + +/** + * Transforms the CLI checksum object into a platform+arch-specific checksum + * object. + * + * @param checksums - The CLI checksum object + * @param targetPlatform - The build platform + * @param targetArch - The build architecture + * @returns Platform and architecture specific checksums or null if no checksums provided + */ +export function transformChecksums( + checksums: Checksums | undefined, + targetPlatform: Platform, + targetArch: Architecture, +): PlatformArchChecksums | null { + if (!checksums) { + return null; + } + + const key = `${targetPlatform}-${targetArch}` as const; + return { + algorithm: checksums.algorithm, + binaries: Object.entries(checksums.binaries).reduce( + (acc, [name, record]) => { + acc[name as Binary] = record[key]; + return acc; + }, + {} as Record, + ), + }; +} diff --git a/packages/foundryup/tsconfig.build.json b/packages/foundryup/tsconfig.build.json new file mode 100644 index 00000000000..66e72c57694 --- /dev/null +++ b/packages/foundryup/tsconfig.build.json @@ -0,0 +1,11 @@ +{ + "extends": "../../tsconfig.packages.build.json", + "compilerOptions": { + "baseUrl": "./", + "lib": ["ES2021", "DOM"], + "outDir": "./dist", + "rootDir": "./src" + }, + "references": [], + "include": ["../../types", "./types", "./src"] +} diff --git a/packages/foundryup/tsconfig.json b/packages/foundryup/tsconfig.json new file mode 100644 index 00000000000..4ebb84c6ccb --- /dev/null +++ b/packages/foundryup/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "../../tsconfig.packages.json", + "compilerOptions": { + "baseUrl": "./", + "lib": ["ES2021", "DOM"] + }, + "references": [], + "include": ["../../types", "./types", "./src"] +} diff --git a/packages/foundryup/tsconfig.lint.json b/packages/foundryup/tsconfig.lint.json new file mode 100644 index 00000000000..fb65dcfce34 --- /dev/null +++ b/packages/foundryup/tsconfig.lint.json @@ -0,0 +1,8 @@ +{ + "extends": ["./tsconfig.json", "../../tsconfig.packages.lint.json"], + "compilerOptions": { + "outDir": "./.tsc-lint-cache", + "tsBuildInfoFile": "./.tsc-lint-cache/tsconfig.tsbuildinfo" + }, + "references": [] +} diff --git a/packages/foundryup/typedoc.json b/packages/foundryup/typedoc.json new file mode 100644 index 00000000000..c9da015dbf8 --- /dev/null +++ b/packages/foundryup/typedoc.json @@ -0,0 +1,7 @@ +{ + "entryPoints": ["./src/index.ts"], + "excludePrivate": true, + "hideGenerator": true, + "out": "docs", + "tsconfig": "./tsconfig.build.json" +} diff --git a/packages/foundryup/types/node_fs.d.ts b/packages/foundryup/types/node_fs.d.ts new file mode 100644 index 00000000000..326120121e6 --- /dev/null +++ b/packages/foundryup/types/node_fs.d.ts @@ -0,0 +1,6 @@ +declare module 'fs' { + // eslint-disable-next-line @typescript-eslint/consistent-type-definitions + interface Dirent { + parentPath: string; + } +} diff --git a/packages/foundryup/types/unzipper.d.ts b/packages/foundryup/types/unzipper.d.ts new file mode 100644 index 00000000000..fd665df66c7 --- /dev/null +++ b/packages/foundryup/types/unzipper.d.ts @@ -0,0 +1,17 @@ +import 'unzipper'; + +declare module 'unzipper' { + type Source = { + stream: (offset: number, length: number) => NodeJS.ReadableStream; + size: () => Promise; + }; + type Options = { + tailSize?: number; + }; + namespace Open { + function custom( + source: Source, + options?: Options, + ): Promise; + } +} diff --git a/packages/gas-fee-controller/CHANGELOG.md b/packages/gas-fee-controller/CHANGELOG.md index eecb467e44a..d1bfd791dc0 100644 --- a/packages/gas-fee-controller/CHANGELOG.md +++ b/packages/gas-fee-controller/CHANGELOG.md @@ -1,4 +1,5 @@ # 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/), @@ -6,24 +7,470 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [26.3.2] + +### Changed + +- Bump `@metamask/network-controller` from `^35.0.0` to `^36.0.0` ([#9758](https://github.com/MetaMask/core/pull/9758), [#9969](https://github.com/MetaMask/core/pull/9969)) + +## [26.3.1] + +### Changed + +- Bump `@metamask/network-controller` from `^34.0.0` to `^35.0.0` ([#9735](https://github.com/MetaMask/core/pull/9735)) +- Bump `@metamask/polling-controller` from `^16.0.8` to `^16.0.9` ([#9735](https://github.com/MetaMask/core/pull/9735)) + +## [26.3.0] + +### Changed + +- Defer the `GasFeeController` constructor's `NetworkController` and provider reads to the first gas fee fetch, making the controller initialization-order-agnostic; the constructor signature and fetching behavior are unchanged ([#9569](https://github.com/MetaMask/core/pull/9569)) +- Bump `@metamask/messenger` from `^1.2.0` to `^2.0.0` ([#9392](https://github.com/MetaMask/core/pull/9392)) + +## [26.2.4] + +### Changed + +- Bump `@metamask/network-controller` from `^33.0.0` to `^34.0.0` ([#9349](https://github.com/MetaMask/core/pull/9349)) +- Bump `@metamask/polling-controller` from `^16.0.7` to `^16.0.8` ([#9349](https://github.com/MetaMask/core/pull/9349)) + +## [26.2.3] + +### Changed + +- Bump `@metamask/utils` from `^11.9.0` to `^11.11.0` ([#9074](https://github.com/MetaMask/core/pull/9074)) +- Bump `@metamask/controller-utils` from `^12.1.0` to `^12.3.0` ([#9058](https://github.com/MetaMask/core/pull/9058), [#9083](https://github.com/MetaMask/core/pull/9083), [#9218](https://github.com/MetaMask/core/pull/9218)) +- Bump `@metamask/network-controller` from `^32.0.0` to `^33.0.0` ([#9218](https://github.com/MetaMask/core/pull/9218)) +- Bump `@metamask/polling-controller` from `^16.0.6` to `^16.0.7` ([#9218](https://github.com/MetaMask/core/pull/9218)) + +## [26.2.2] + +### Changed + +- Bump `@metamask/network-controller` from `^31.0.0` to `^32.0.0` ([#8765](https://github.com/MetaMask/core/pull/8765), [#8774](https://github.com/MetaMask/core/pull/8774)) +- Bump `@metamask/controller-utils` from `^12.0.0` to `^12.1.0` ([#8774](https://github.com/MetaMask/core/pull/8774)) +- Bump `@metamask/polling-controller` from `^16.0.5` to `^16.0.6` ([#8834](https://github.com/MetaMask/core/pull/8834)) + +## [26.2.1] + +### Changed + +- Bump `@metamask/controller-utils` from `^11.20.0` to `^12.0.0` ([#8755](https://github.com/MetaMask/core/pull/8755)) +- Bump `@metamask/network-controller` from `^30.1.0` to `^31.0.0` ([#8755](https://github.com/MetaMask/core/pull/8755)) +- Bump `@metamask/polling-controller` from `^16.0.4` to `^16.0.5` ([#8755](https://github.com/MetaMask/core/pull/8755)) + +## [26.2.0] + +### Added + +- Expose missing public `GasFeeController` methods through its messenger ([#8699](https://github.com/MetaMask/core/pull/8699)) + - The following actions are now available: + - `GasFeeController:enableNonRPCGasFeeApis` + - `GasFeeController:disableNonRPCGasFeeApis` + - Corresponding action types are available as well. + +### Changed + +- Bump `@metamask/messenger` from `^1.1.0` to `^1.2.0` ([#8373](https://github.com/MetaMask/core/pull/8373), [#8632](https://github.com/MetaMask/core/pull/8632)) +- Add missing `@metamask/messenger` dependency ([#8318](https://github.com/MetaMask/core/pull/8318), [#8364](https://github.com/MetaMask/core/pull/8364)) +- Bump `@metamask/controller-utils` from `^11.19.0` to `^11.20.0` ([#8344](https://github.com/MetaMask/core/pull/8344)) +- Bump `@metamask/base-controller` from `^9.0.1` to `^9.1.0` ([#8457](https://github.com/MetaMask/core/pull/8457)) +- Bump `@metamask/network-controller` from `^30.0.1` to `^30.1.0` ([#8636](https://github.com/MetaMask/core/pull/8636)) + +## [26.1.1] + +### Changed + +- Bump `@metamask/base-controller` from `^9.0.0` to `^9.0.1` ([#8317](https://github.com/MetaMask/core/pull/8317)) +- Bump `@metamask/network-controller` from `^30.0.0` to `^30.0.1` ([#8317](https://github.com/MetaMask/core/pull/8317)) +- Bump `@metamask/polling-controller` from `^16.0.3` to `^16.0.4` ([#8317](https://github.com/MetaMask/core/pull/8317)) + +## [26.1.0] + +### Added + +- Expose missing public `GasFeeController` methods through its messenger ([#8183](https://github.com/MetaMask/core/pull/8183)) + - The following actions are now available: + - `GasFeeController:resetPolling` + - `GasFeeController:fetchGasFeeEstimates` + - `GasFeeController:getGasFeeEstimatesAndStartPolling` + - `GasFeeController:disconnectPoller` + - `GasFeeController:stopPolling` + - `GasFeeController:getTimeEstimate` + - Corresponding action types (e.g. `GasFeeControllerResetPollingAction`) are available as well. + +## [26.0.3] + +### Changed + +- Bump `@metamask/network-controller` from `^29.0.0` to `^30.0.0` ([#7996](https://github.com/MetaMask/core/pull/7996)) +- Bump `@metamask/polling-controller` from `^16.0.2` to `^16.0.3` ([#7996](https://github.com/MetaMask/core/pull/7996)) +- Bump `@metamask/controller-utils` from `^11.18.0` to `^11.19.0` ([#7995](https://github.com/MetaMask/core/pull/7995)) + +## [26.0.2] + +### Changed + +- Bump `@metamask/network-controller` from `^28.0.0` to `^29.0.0` ([#7642](https://github.com/MetaMask/core/pull/7642)) +- Bump `@metamask/polling-controller` from `^16.0.1` to `^16.0.2` ([#7642](https://github.com/MetaMask/core/pull/7642)) + +## [26.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), [#7258](https://github.com/MetaMask/core/pull/7258), [#7534](https://github.com/MetaMask/core/pull/7534), [#7583](https://github.com/MetaMask/core/pull/7583), [#7604](https://github.com/MetaMask/core/pull/7604)) + - The dependencies moved are: + - `@metamask/network-controller` (^28.0.0) + - In clients, it is now possible for multiple versions of these packages to exist in the dependency tree. + - For example, this scenario would be valid: a client relies on `@metamask/controller-a` 1.0.0 and `@metamask/controller-b` 1.0.0, and `@metamask/controller-b` depends on `@metamask/controller-a` 1.1.0. + - Note, however, that the versions specified in the client's `package.json` always "win", and you are expected to keep them up to date so as not to break controller and service intercommunication. +- 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)) +- Bump `@metamask/polling-controller` from `^16.0.0` to `^16.0.1` ([#7604](https://github.com/MetaMask/core/pull/7604)) + +## [26.0.0] + +### Changed + +- Bump `@metamask/polling-controller` from `^15.0.0` to `^16.0.0` ([#7202](https://github.com/MetaMask/core/pull/7202)) +- Bump `@metamask/controller-utils` from `^11.15.0` to `^11.16.0` ([#7202](https://github.com/MetaMask/core/pull/7202)) +- **BREAKING:** Bump `@metamask/network-controller` from `^25.0.0` to `^26.0.0` ([#7202](https://github.com/MetaMask/core/pull/7202)) + +### Fixed + +- Ensure `networksMetadata` never references old network client IDs ([#7047](https://github.com/MetaMask/core/pull/7047)) + +## [25.0.0] + +### Added + +- Export `GasFeeMessenger` type ([#6386](https://github.com/MetaMask/core/pull/6386), [#6444](https://github.com/MetaMask/core/pull/6444)) + +### Changed + +- **BREAKING:** Use new `Messenger` from `@metamask/messenger` ([#6386](https://github.com/MetaMask/core/pull/6386)) + - Previously, `GasFeeController` accepted a `RestrictedMessenger` instance from `@metamask/base-controller`. +- **BREAKING:** Bump `@metamask/network-controller` from `^24.0.0` to `^25.0.0` ([#6962](https://github.com/MetaMask/core/pull/6962)) +- Bump `@metamask/base-controller` from `^8.4.2` to `^9.0.0` ([#6962](https://github.com/MetaMask/core/pull/6962)) +- Bump `@metamask/polling-controller` from `^14.0.2` to `^15.0.0` ([#6962](https://github.com/MetaMask/core/pull/6962)) + +## [24.1.1] + +### Changed + +- Bump `@metamask/base-controller` from `^8.4.1` to `^8.4.2` ([#6917](https://github.com/MetaMask/core/pull/6917)) +- Bump `@metamask/network-controller` from `^24.2.2` to `^24.3.0` ([#6883](https://github.com/MetaMask/core/pull/6883)) +- Bump `@metamask/polling-controller` from `^14.0.1` to `^14.0.2` ([#6940](https://github.com/MetaMask/core/pull/6940)) + +## [24.1.0] + +### Added + +- Add two new controller state metadata properties: `includeInStateLogs` and `usedInUi` ([#6473](https://github.com/MetaMask/core/pull/6473)) + +### Changed + +- Bump `@metamask/base-controller` from `^8.0.1` to `^8.4.1` ([#6284](https://github.com/MetaMask/core/pull/6284), [#6355](https://github.com/MetaMask/core/pull/6355), [#6465](https://github.com/MetaMask/core/pull/6465), [#6632](https://github.com/MetaMask/core/pull/6632), [#6807](https://github.com/MetaMask/core/pull/6807)) +- Bump `@metamask/controller-utils` from `^11.10.0` to `^11.14.1` ([#6069](https://github.com/MetaMask/core/pull/6069), [#6303](https://github.com/MetaMask/core/pull/6303), [#6620](https://github.com/MetaMask/core/pull/6620), [#6629](https://github.com/MetaMask/core/pull/6629), [#6807](https://github.com/MetaMask/core/pull/6807)) +- Bump `@metamask/utils` from `^11.2.0` to `^11.8.1` ([#6054](https://github.com/MetaMask/core/pull/6054), [#6588](https://github.com/MetaMask/core/pull/6588), [#6708](https://github.com/MetaMask/core/pull/6708)) +- Bump `@metamask/polling-controller` from `^14.0.0` to `^14.0.1` ([#6807](https://github.com/MetaMask/core/pull/6807)) + +## [24.0.0] + +### Changed + +- **BREAKING:** Bump peer dependency `@metamask/network-controller` to `^24.0.0` ([#5999](https://github.com/MetaMask/core/pull/5999)) +- Bump `@metamask/base-controller` to `^8.0.1` ([#5722](https://github.com/MetaMask/core/pull/5722)) +- Bump `@metamask/controller-utils` to `^11.10.0` ([#5935](https://github.com/MetaMask/core/pull/5935), [#5583](https://github.com/MetaMask/core/pull/5583), [#5765](https://github.com/MetaMask/core/pull/5765), [#5812](https://github.com/MetaMask/core/pull/5812)) +- Bump `@metamask/polling-controller` to `^14.0.0` ([#5999](https://github.com/MetaMask/core/pull/5999)) + +## [23.0.0] + +### Changed + +- **BREAKING:** Bump peer dependency `@metamask/network-controller` to `^23.0.0` ([#5507](https://github.com/MetaMask/core/pull/5507)) +- Bump `@metamask/controller-utils` to `^11.6.0` ([#5439](https://github.com/MetaMask/core/pull/5439)) +- Bump `@metamask/utils` to `^11.2.0` ([#5301](https://github.com/MetaMask/core/pull/5301)) +- Bump `@metamask/polling-controller` to `^13.0.0` ([#5507](https://github.com/MetaMask/core/pull/5507)) + +## [22.0.3] + +### Changed + +- Bump `@metamask/base-controller` from `^7.0.2` to `^8.0.0` ([#5079](https://github.com/MetaMask/core/pull/5079), [#5305](https://github.com/MetaMask/core/pull/5305)) +- Bump `@metamask/controller-utils` from `^11.4.4` to `^11.5.0` ([#5135](https://github.com/MetaMask/core/pull/5135), [#5272](https://github.com/MetaMask/core/pull/5272)) +- Bump `@metamask/polling-controller` from `^12.0.2` to `^12.0.3` ([#5305](https://github.com/MetaMask/core/pull/5305)) +- Bump `@metamask/utils` from `^10.0.0` to `^11.1.0` ([#5080](https://github.com/MetaMask/core/pull/5080), [#5223](https://github.com/MetaMask/core/pull/5223)) + +## [22.0.2] + +### Changed + +- Bump `@metamask/controller-utils` from `^11.4.3` to `^11.4.4` ([#5012](https://github.com/MetaMask/core/pull/5012)) +- Bump `@metamask/polling-controller` from `^12.0.1` to `^12.0.2` ([#5012](https://github.com/MetaMask/core/pull/5012)) + +### Fixed + +- Make implicit peer dependencies explicit ([#4974](https://github.com/MetaMask/core/pull/4974)) + - Add the following packages as peer dependencies of this package to satisfy peer dependency requirements from other dependencies: + - `@babel/runtime@^7.0.0` (required by `@metamask/ethjs-unit`) + - These dependencies really should be present in projects that consume this package (e.g. MetaMask clients), and this change ensures that they now are. + - Furthermore, we are assuming that clients already use these dependencies, since otherwise it would be impossible to consume this package in its entirety or even create a working build. Hence, the addition of these peer dependencies is really a formality and should not be breaking. +- Correct ESM-compatible build so that imports of the following packages that re-export other modules via `export *` are no longer corrupted: ([#5011](https://github.com/MetaMask/core/pull/5011)) + - `@metamask/eth-query` + - `bn.js` + +## [22.0.1] + +### Changed + +- Bump `@metamask/polling-controller` from `^12.0.0` to `^12.0.1` ([#4870](https://github.com/MetaMask/core/pull/4870)) +- Bump `@metamask/base-controller` from `^7.0.1` to `^7.0.2` ([#4862](https://github.com/MetaMask/core/pull/4862)) +- Bump `@metamask/controller-utils` from `^11.4.0` to `^11.4.3` ([#4862](https://github.com/MetaMask/core/pull/4862), [#4870](https://github.com/MetaMask/core/pull/4870), [#4195](https://github.com/MetaMask/core/pull/4195)) + +## [22.0.0] + +### Changed + +- **BREAKING:** Bump `@metamask/network-controller` peer dependency to `^22.0.0` ([#4841](https://github.com/MetaMask/core/pull/4841)) +- Bump `@metamask/controller-utils` to `^11.4.0` ([#4834](https://github.com/MetaMask/core/pull/4834)) +- Bump `@metamask/utils` to `^10.0.0` ([#4831](https://github.com/MetaMask/core/pull/4831)) + +## [21.0.0] + +### Changed + +- **BREAKING:** `GasFeeController` now uses a new polling interface that accepts the generic parameter `PollingInput` ([#4752](https://github.com/MetaMask/core/pull/4752)) +- **BREAKING:** The inherited `AbstractPollingController` method `startPollingByNetworkClientId` has been renamed to `startPolling` ([#4752](https://github.com/MetaMask/core/pull/4752)) +- **BREAKING:** The inherited `AbstractPollingController` method `onPollingComplete` now returns the entire input object of type `PollingInput`, instead of a network client id ([#4752](https://github.com/MetaMask/core/pull/4752)) + +## [20.0.1] + +### Fixed + +- Produce and export ESM-compatible TypeScript type declaration files in addition to CommonJS-compatible declaration files ([#4648](https://github.com/MetaMask/core/pull/4648)) + - Previously, this package shipped with only one variant of type declaration + files, and these files were only CommonJS-compatible, and the `exports` + field in `package.json` linked to these files. This is an anti-pattern and + was rightfully flagged by the + ["Are the Types Wrong?"](https://arethetypeswrong.github.io/) tool as + ["masquerading as CJS"](https://github.com/arethetypeswrong/arethetypeswrong.github.io/blob/main/docs/problems/FalseCJS.md). + All of the ATTW checks now pass. +- Remove chunk files ([#4648](https://github.com/MetaMask/core/pull/4648)) + - Previously, the build tool we used to generate JavaScript files extracted + common code to "chunk" files. While this was intended to make this package + more tree-shakeable, it also made debugging more difficult for our + development teams. These chunk files are no longer present. + +## [20.0.0] + +### Changed + +- **BREAKING:** Bump devDependency and peerDependency `@metamask/network-controller` from `^20.0.0` to `^21.0.0` ([#4618](https://github.com/MetaMask/core/pull/4618), [#4651](https://github.com/MetaMask/core/pull/4651)) +- Bump `@metamask/base-controller` from `^6.0.2` to `^7.0.0` ([#4625](https://github.com/MetaMask/core/pull/4625), [#4643](https://github.com/MetaMask/core/pull/4643)) +- Bump `@metamask/controller-utils` from `^11.0.2` to `^11.2.0` ([#4639](https://github.com/MetaMask/core/pull/4639), [#4651](https://github.com/MetaMask/core/pull/4651)) +- Bump `@metamask/polling-controller` from `^9.0.1` to `^10.0.0` ([#4651](https://github.com/MetaMask/core/pull/4651)) +- Bump `typescript` from `~5.0.4` to `~5.2.2` ([#4576](https://github.com/MetaMask/core/pull/4576), [#4584](https://github.com/MetaMask/core/pull/4584)) + +## [19.0.1] + +### Changed + +- Remove `@metamask/network-controller` dependency [#4556](https://github.com/MetaMask/core/pull/4556) + - This was listed under `peerDependencies` already, so it was redundant as a dependency. +- Upgrade TypeScript version to `~5.0.4` and set `moduleResolution` option to `Node16` ([#3645](https://github.com/MetaMask/core/pull/3645)) +- Bump `@metamask/base-controller` from `^6.0.0` to `^6.0.2` ([#4517](https://github.com/MetaMask/core/pull/4517), [#4544](https://github.com/MetaMask/core/pull/4544)) +- Bump `@metamask/controller-utils` from `^11.0.0` to `^11.0.2` ([#4517](https://github.com/MetaMask/core/pull/4517), [#4544](https://github.com/MetaMask/core/pull/4544)) +- Bump `@metamask/polling-controller` from `^9.0.0` to `^9.0.1` ([#4548](https://github.com/MetaMask/core/pull/4548)) +- Bump `@metamask/utils` from `^8.3.0` to `^9.1.0` ([#4516](https://github.com/MetaMask/core/pull/4516), [#4529](https://github.com/MetaMask/core/pull/4529)) + +## [19.0.0] + +### Changed + +- **BREAKING:** Bump peerDependency `@metamask/network-controller` to `^20.0.0` ([#4508](https://github.com/MetaMask/core/pull/4508)) +- Bump `@metamask/polling-controller` to `^9.0.0` ([#4508](https://github.com/MetaMask/core/pull/4508)) + +## [18.0.0] + +### Added + +- **BREAKING:** Add constructor options to `GasFeeController`: `EIP1559APIEndpoint` (required), and `legacyAPIEndpoint` (optional) which defaults to `LEGACY_GAS_PRICES_API_URL`. ([#4446](https://github.com/MetaMask/core/pull/4446)) + - These URLs are no longer hardcoded within the controller. + +### Removed + +- **BREAKING:** Remove `infuraAPIKey` as a constructor option for `GasFeeController`. This class field was previously used to construct and send the `Authorization` header for Infura gas API requests. ([#4446](https://github.com/MetaMask/core/pull/4446)) + +## [17.0.0] + +### Changed + +- **BREAKING:** Bump minimum Node version to 18.18 ([#3611](https://github.com/MetaMask/core/pull/3611)) +- **BREAKING:** Bump dependency and peer dependency `@metamask/network-controller` to `^19.0.0` ([#4352](https://github.com/MetaMask/core/pull/4352)) +- Bump `@metamask/base-controller` to `^6.0.0` ([#4352](https://github.com/MetaMask/core/pull/4352)) +- Bump `@metamask/controller-utils` to `^11.0.0` ([#4352](https://github.com/MetaMask/core/pull/4352)) +- Bump `@metamask/polling-controller` to `^8.0.0` ([#4352](https://github.com/MetaMask/core/pull/4352)) + +## [16.0.0] + +### Changed + +- **BREAKING:** Bump dependency and peer dependency `@metamask/network-controller` to `^18.1.3` ([#4342](https://github.com/MetaMask/core/pull/4342)) +- Bump `@metamask/controller-utils` to `^10.0.0` ([#4342](https://github.com/MetaMask/core/pull/4342)) +- Bump `@metamask/polling-controller` to `^7.0.0` ([#4342](https://github.com/MetaMask/core/pull/4342)) + +## [15.1.2] + +### Fixed + +- Add a metadata property for nonRPCGasFeeApisDisabled ([#4245](https://github.com/MetaMask/core/pull/4245)) + +## [15.1.1] + +### Changed + +- Bump `@metamask/polling-controller` to `^6.0.2` ([#4234](https://github.com/MetaMask/core/pull/4234)) + +### Removed + +- Remove fee history fallback in favour of `eth_gasPrice` call ([#4210](https://github.com/MetaMask/core/pull/4210)) + +## [15.1.0] + +### Added + +- Add nonRPCGasFeeApisDisabled property to the gas fee controller, allowing the user to specify that they want to prevent network request to gas estimate services, and only want gas estimates to be based on rpc requests (eth_feeHistory and eth_gasPrice) to the provider. ([#4094](https://github.com/MetaMask/core/pull/4094)) + +### Fixed + +- Fix GasFeeController incorrectly setting globally selected state, so that state is only updated if the gasFeeEstimate fetched is for the currently selected network ([#4214](https://github.com/MetaMask/core/pull/4214)) + +## [15.0.0] + +### Changed + +- **BREAKING**: The controller's constructor now requires `infuraAPIKey`. This is used to construct and send the `Authorization` header for Infura gas API requests. ([#4068](https://github.com/MetaMask/core/pull/4068)) +- Bump dependency `@metamask/network-controller` to `^18.1.0` ([#4121](https://github.com/MetaMask/core/pull/4121)) + +### Removed + +- **BREAKING**: Remove the constructor options `legacyAPIEndpoint` and `EIP1559APIEndpoint`. These URLs are now hardcoded within the controller. ([#4068](https://github.com/MetaMask/core/pull/4068)) + +## [14.0.1] + +### Fixed + +- Fix `types` field in `package.json` ([#4047](https://github.com/MetaMask/core/pull/4047)) + +## [14.0.0] + +### Added + +- **BREAKING**: Add ESM build ([#3998](https://github.com/MetaMask/core/pull/3998)) + - It's no longer possible to import files from `./dist` directly. + +### Changed + +- **BREAKING:** Bump `@metamask/base-controller` to `^5.0.0` ([#4039](https://github.com/MetaMask/core/pull/4039)) + - This version has a number of breaking changes. See the changelog for more. +- **BREAKING:** Bump dependency and peer dependency on `@metamask/network-controller` to `^18.0.0` ([#4039](https://github.com/MetaMask/core/pull/4039)) +- Bump `@metamask/controller-utils` to `^9.0.0` ([#4039](https://github.com/MetaMask/core/pull/4039)) +- Bump `@metamask/polling-controller` to `^6.0.0` ([#4039](https://github.com/MetaMask/core/pull/4039)) + +## [13.0.2] + +### Changed + +- Replace `ethereumjs-util` with `bn.js` ([#3943](https://github.com/MetaMask/core/pull/3943)) +- Bump `@metamask/controller-utils` to `^8.0.4` ([#4007](https://github.com/MetaMask/core/pull/4007)) +- Bump `@metamask/ethjs-unit` to `^0.3.0` ([#3897](https://github.com/MetaMask/core/pull/3897)) +- Bump `@metamask/network-controller` to `^17.2.1` ([#4007](https://github.com/MetaMask/core/pull/4007)) +- Bump `@metamask/polling-controller` to `^5.0.1` ([#4007](https://github.com/MetaMask/core/pull/4007)) + +## [13.0.1] + +### Changed + +- Bump `@metamask/controller-utils` to `^8.0.3` ([#3915](https://github.com/MetaMask/core/pull/3915)) + +## [13.0.0] + +### Changed + +- **BREAKING:** Bump `@metamask/network-controller` peer dependency to `^17.2.0` ([#3821](https://github.com/MetaMask/core/pull/3821)) +- Bump `@metamask/utils` to `^8.3.0` ([#3769](https://github.com/MetaMask/core/pull/3769)) +- Bump `@metamask/base-controller` to `^4.1.1` ([#3760](https://github.com/MetaMask/core/pull/3760), [#3821](https://github.com/MetaMask/core/pull/3821)) +- Bump `@metamask/controller-utils` to `^8.0.2` ([#3821](https://github.com/MetaMask/core/pull/3821)) +- Bump `@metamask/polling-controller` to `^5.0.0` ([#3821](https://github.com/MetaMask/core/pull/3821)) + +## [12.0.0] + +### Changed + +- **BREAKING:** Bump `@metamask/network-controller` dependency and peer dependency from `^17.0.0` to `^17.1.0` ([#3695](https://github.com/MetaMask/core/pull/3695)) +- **BREAKING:** The `GasFeeController` now detects network changes using the `NetworkController:networkDidChange` event instead of `NetworkController:stateChange` ([#3610](https://github.com/MetaMask/core/pull/3610)) + - Additionally, the optional constructor parameter `onNetworkStateChange` has been replaced by `onNetworkDidChange` +- Bump `@metamask/base-controller` to `^4.0.1` ([#3695](https://github.com/MetaMask/core/pull/3695)) +- Bump `@metamask/controller-utils` to `^8.0.1` ([#3695](https://github.com/MetaMask/core/pull/3695), [#3678](https://github.com/MetaMask/core/pull/3678), [#3667](https://github.com/MetaMask/core/pull/3667), [#3580](https://github.com/MetaMask/core/pull/3580)) +- Bump `@metamask/polling-controller` to `^4.0.0` ([#3695](https://github.com/MetaMask/core/pull/3695), [#3667](https://github.com/MetaMask/core/pull/3667), [#3636](https://github.com/MetaMask/core/pull/3636)) + - This update adds two new methods to each polling controller: `_startPollingByNetworkClientId` and `_stopPollingByPollingTokenSetId`. These methods are intended for internal use, and should not be called directly. + +## [11.0.0] + +### Changed + +- **BREAKING:** Bump `@metamask/base-controller` to ^4.0.0 ([#2063](https://github.com/MetaMask/core/pull/2063)) + - This is breaking because the type of the `messenger` has backward-incompatible changes. See the changelog for this package for more. +- Replace `ethjs-unit` ^0.1.6 with `@metamask/ethjs-unit` ^0.2.1 ([#2064](https://github.com/MetaMask/core/pull/2064)) +- Bump `@metamask/controller-utils` to ^6.0.0 ([#2063](https://github.com/MetaMask/core/pull/2063)) +- Bump `@metamask/network-controller` to ^17.0.0 ([#2063](https://github.com/MetaMask/core/pull/2063)) +- Bump `@metamask/polling-controller` to ^2.0.0 ([#2063](https://github.com/MetaMask/core/pull/2063)) + +## [10.0.1] + +### Changed + +- **BREAKING:** Bump dependency and peer dependency on `@metamask/network-controller` to ^16.0.0 +- Bump dependency `@metamask/eth-query` from ^3.0.1 to ^4.0.0 ([#2028](https://github.com/MetaMask/core/pull/2028)) +- Bump dependency on `@metamask/polling-controller` to ^1.0.2 +- Bump @metamask/utils from 8.1.0 to 8.2.0 ([#1957](https://github.com/MetaMask/core/pull/1957)) + +## [10.0.0] + +### Added + +- Add optional `networkClientId` argument to options object param of `fetchGasFeeEstimates` method which, if passed, fetches the required chainId and networkClient provider to fetch and store gasFee data appropriately. ([#1891](https://github.com/MetaMask/core/pull/1891)) + +### Changed + +- **BREAKING:** Bump dependency on `@metamask/polling-controller` to ^1.0.0 +- Bump dependency and peer dependency on `@metamask/network-controller` to ^15.1.0 + ## [9.0.0] + ### Added + - Add way to start and stop different polling sessions for the same network client ID by providing extra scoping data ([#1776](https://github.com/MetaMask/core/pull/1776)) - Add optional second argument to `stopPollingByPollingToken` (formerly `stopPollingByNetworkClientId`) - Add optional second argument to `onPollingCompleteByNetworkClientId` ### Changed + - **BREAKING:** Make `executePoll` private ([#1810](https://github.com/MetaMask/core/pull/1810)) - **BREAKING:** Rename `stopPollingByNetworkClientId` to `stopPollingByPollingToken` ([#1810](https://github.com/MetaMask/core/pull/1810)) - **BREAKING:** Bump dependency and peer dependency on `@metamask/network-controller` to ^15.0.0 - **BREAKING:** Bump dependency on `@metamask/polling-controller` to ^0.2.0 ## [8.0.0] + ### Added + - Add optional `gasFeeEstimatesByChainId` property to GasFeeController state ([#1673](https://github.com/MetaMask/core/pull/1673) - Add dependency on `@metamask/polling-controller` ([#1748])(https://github.com/MetaMask/core/pull/1748)) ### Changed + - **BREAKING:** Messenger must allow controller actions `NetworkController:getNetworkClientById` and `NetworkController:getEIP1559Compatibility` ([#1673](https://github.com/MetaMask/core/pull/1673) - Bump dependency on `@metamask/utils` to ^8.1.0 ([#1639](https://github.com/MetaMask/core/pull/1639)) - Bump dependency on `@metamask/base-controller` to ^3.2.3 @@ -31,36 +478,50 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Bump dependency and peer dependency on `@metamask/network-controller` to ^14.0.0 ## [7.0.1] + ### Changed + - Update TypeScript to v4.8.x ([#1718](https://github.com/MetaMask/core/pull/1718)) ## [7.0.0] + ### Changed + - **BREAKING**: Bump peer dependency on `@metamask/network-controller` to ^13.0.0 ([#1633](https://github.com/MetaMask/core/pull/1633)) - Bump dependency on `@metamask/controller-utils` to ^5.0.0 ([#1633](https://github.com/MetaMask/core/pull/1633)) ## [6.1.2] + ### Changed + - Bump dependency on `@metamask/base-controller` to ^3.2.1 - Bump dependency on `@metamask/controller-utils` to ^4.3.2 - Bump dependency and peer dependency on `@metamask/network-controller` to ^12.1.2 ## [6.1.1] + ### Changed + - Replace `eth-query` ^2.1.2 with `@metamask/eth-query` ^3.0.1 ([#1546](https://github.com/MetaMask/core/pull/1546)) ## [6.1.0] + ### Changed + - Update `@metamask/utils` to `^6.2.0` ([#1514](https://github.com/MetaMask/core/pull/1514)) - Remove unnecessary `babel-runtime` dependencies ([#1504](https://github.com/MetaMask/core/pull/1504)) ## [6.0.1] + ### Changed + - Bump dependency on `controller-utils` ([#1447](https://github.com/MetaMask/core/pull/1447)) - The new version of `controller-utils` adds `eth-query` to the list of dependencies. This dependency was added to improve internal types for `gas-fee-controller`. This has no impact on users of the package. ## [6.0.0] + ### Changed + - **BREAKING:** Bump to Node 16 ([#1262](https://github.com/MetaMask/core/pull/1262)) - **BREAKING:** The `getChainId` constructor parameter now expects a `Hex` return type rather than a decimal string ([#1367](https://github.com/MetaMask/core/pull/1367)) - Add `@metamask/utils` dependency @@ -71,46 +532,105 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **BREAKING:** Update `@metamask/network-controller` dependency and peer dependency ## [5.0.0] + ### Changed + - **BREAKING**: peerDeps: @metamask/network-controller@6.0.0->8.0.0 ([#1196](https://github.com/MetaMask/core/pull/1196)) ## [4.0.1] + ### Changed + - Adjust types to align with new version of `NetworkController` ([#1091](https://github.com/MetaMask/core/pull/1091)) ## [4.0.0] + ### Changed + - **BREAKING:** Make the EIP-1559 endpoint a required argument ([#1083](https://github.com/MetaMask/core/pull/1083)) ### Removed + - **BREAKING:** Remove `isomorphic-fetch` ([#1106](https://github.com/MetaMask/controllers/pull/1106)) - Consumers must now import `isomorphic-fetch` or another polyfill themselves if they are running in an environment without `fetch` ## [3.0.0] + ### Changed + - **BREAKING:** Update `@metamask/network-controller` peer dependency to v3 ([#1041](https://github.com/MetaMask/controllers/pull/1041)) - Rename this repository to `core` ([#1031](https://github.com/MetaMask/controllers/pull/1031)) - Update `@metamask/controller-utils` package ([#1041](https://github.com/MetaMask/controllers/pull/1041)) ## [2.0.1] + ### Fixed + - This package will now warn if a required package is not present ([#1003](https://github.com/MetaMask/core/pull/1003)) ## [2.0.0] + ### Changed + - **BREAKING:** Bump `@metamask/network-controller` to 2.0.0 ([#995](https://github.com/MetaMask/core/pull/995)) - GasFeeController now expects NetworkController to respond to the `NetworkController:providerChangeConfig` event (previously named `NetworkController:providerChange`). If you are depending directly on `@metamask/network-controller`, you should update your version to at least 2.0.0 as well. - Relax dependencies on `@metamask/base-controller`, `@metamask/controller-utils`, and `@metamask/network-controller` (use `^` instead of `~`) ([#998](https://github.com/MetaMask/core/pull/998)) ## [1.0.0] + ### Added + - Initial release - As a result of converting our shared controllers repo into a monorepo ([#831](https://github.com/MetaMask/core/pull/831)), we've created this package from select parts of [`@metamask/controllers` v33.0.0](https://github.com/MetaMask/core/tree/v33.0.0), namely: - Everything in `src/gas` All changes listed after this point were applied to this package following the monorepo conversion. -[Unreleased]: https://github.com/MetaMask/core/compare/@metamask/gas-fee-controller@9.0.0...HEAD +[Unreleased]: https://github.com/MetaMask/core/compare/@metamask/gas-fee-controller@26.3.2...HEAD +[26.3.2]: https://github.com/MetaMask/core/compare/@metamask/gas-fee-controller@26.3.1...@metamask/gas-fee-controller@26.3.2 +[26.3.1]: https://github.com/MetaMask/core/compare/@metamask/gas-fee-controller@26.3.0...@metamask/gas-fee-controller@26.3.1 +[26.3.0]: https://github.com/MetaMask/core/compare/@metamask/gas-fee-controller@26.2.4...@metamask/gas-fee-controller@26.3.0 +[26.2.4]: https://github.com/MetaMask/core/compare/@metamask/gas-fee-controller@26.2.3...@metamask/gas-fee-controller@26.2.4 +[26.2.3]: https://github.com/MetaMask/core/compare/@metamask/gas-fee-controller@26.2.2...@metamask/gas-fee-controller@26.2.3 +[26.2.2]: https://github.com/MetaMask/core/compare/@metamask/gas-fee-controller@26.2.1...@metamask/gas-fee-controller@26.2.2 +[26.2.1]: https://github.com/MetaMask/core/compare/@metamask/gas-fee-controller@26.2.0...@metamask/gas-fee-controller@26.2.1 +[26.2.0]: https://github.com/MetaMask/core/compare/@metamask/gas-fee-controller@26.1.1...@metamask/gas-fee-controller@26.2.0 +[26.1.1]: https://github.com/MetaMask/core/compare/@metamask/gas-fee-controller@26.1.0...@metamask/gas-fee-controller@26.1.1 +[26.1.0]: https://github.com/MetaMask/core/compare/@metamask/gas-fee-controller@26.0.3...@metamask/gas-fee-controller@26.1.0 +[26.0.3]: https://github.com/MetaMask/core/compare/@metamask/gas-fee-controller@26.0.2...@metamask/gas-fee-controller@26.0.3 +[26.0.2]: https://github.com/MetaMask/core/compare/@metamask/gas-fee-controller@26.0.1...@metamask/gas-fee-controller@26.0.2 +[26.0.1]: https://github.com/MetaMask/core/compare/@metamask/gas-fee-controller@26.0.0...@metamask/gas-fee-controller@26.0.1 +[26.0.0]: https://github.com/MetaMask/core/compare/@metamask/gas-fee-controller@25.0.0...@metamask/gas-fee-controller@26.0.0 +[25.0.0]: https://github.com/MetaMask/core/compare/@metamask/gas-fee-controller@24.1.1...@metamask/gas-fee-controller@25.0.0 +[24.1.1]: https://github.com/MetaMask/core/compare/@metamask/gas-fee-controller@24.1.0...@metamask/gas-fee-controller@24.1.1 +[24.1.0]: https://github.com/MetaMask/core/compare/@metamask/gas-fee-controller@24.0.0...@metamask/gas-fee-controller@24.1.0 +[24.0.0]: https://github.com/MetaMask/core/compare/@metamask/gas-fee-controller@23.0.0...@metamask/gas-fee-controller@24.0.0 +[23.0.0]: https://github.com/MetaMask/core/compare/@metamask/gas-fee-controller@22.0.3...@metamask/gas-fee-controller@23.0.0 +[22.0.3]: https://github.com/MetaMask/core/compare/@metamask/gas-fee-controller@22.0.2...@metamask/gas-fee-controller@22.0.3 +[22.0.2]: https://github.com/MetaMask/core/compare/@metamask/gas-fee-controller@22.0.1...@metamask/gas-fee-controller@22.0.2 +[22.0.1]: https://github.com/MetaMask/core/compare/@metamask/gas-fee-controller@22.0.0...@metamask/gas-fee-controller@22.0.1 +[22.0.0]: https://github.com/MetaMask/core/compare/@metamask/gas-fee-controller@21.0.0...@metamask/gas-fee-controller@22.0.0 +[21.0.0]: https://github.com/MetaMask/core/compare/@metamask/gas-fee-controller@20.0.1...@metamask/gas-fee-controller@21.0.0 +[20.0.1]: https://github.com/MetaMask/core/compare/@metamask/gas-fee-controller@20.0.0...@metamask/gas-fee-controller@20.0.1 +[20.0.0]: https://github.com/MetaMask/core/compare/@metamask/gas-fee-controller@19.0.1...@metamask/gas-fee-controller@20.0.0 +[19.0.1]: https://github.com/MetaMask/core/compare/@metamask/gas-fee-controller@19.0.0...@metamask/gas-fee-controller@19.0.1 +[19.0.0]: https://github.com/MetaMask/core/compare/@metamask/gas-fee-controller@18.0.0...@metamask/gas-fee-controller@19.0.0 +[18.0.0]: https://github.com/MetaMask/core/compare/@metamask/gas-fee-controller@17.0.0...@metamask/gas-fee-controller@18.0.0 +[17.0.0]: https://github.com/MetaMask/core/compare/@metamask/gas-fee-controller@16.0.0...@metamask/gas-fee-controller@17.0.0 +[16.0.0]: https://github.com/MetaMask/core/compare/@metamask/gas-fee-controller@15.1.2...@metamask/gas-fee-controller@16.0.0 +[15.1.2]: https://github.com/MetaMask/core/compare/@metamask/gas-fee-controller@15.1.1...@metamask/gas-fee-controller@15.1.2 +[15.1.1]: https://github.com/MetaMask/core/compare/@metamask/gas-fee-controller@15.1.0...@metamask/gas-fee-controller@15.1.1 +[15.1.0]: https://github.com/MetaMask/core/compare/@metamask/gas-fee-controller@15.0.0...@metamask/gas-fee-controller@15.1.0 +[15.0.0]: https://github.com/MetaMask/core/compare/@metamask/gas-fee-controller@14.0.1...@metamask/gas-fee-controller@15.0.0 +[14.0.1]: https://github.com/MetaMask/core/compare/@metamask/gas-fee-controller@14.0.0...@metamask/gas-fee-controller@14.0.1 +[14.0.0]: https://github.com/MetaMask/core/compare/@metamask/gas-fee-controller@13.0.2...@metamask/gas-fee-controller@14.0.0 +[13.0.2]: https://github.com/MetaMask/core/compare/@metamask/gas-fee-controller@13.0.1...@metamask/gas-fee-controller@13.0.2 +[13.0.1]: https://github.com/MetaMask/core/compare/@metamask/gas-fee-controller@13.0.0...@metamask/gas-fee-controller@13.0.1 +[13.0.0]: https://github.com/MetaMask/core/compare/@metamask/gas-fee-controller@12.0.0...@metamask/gas-fee-controller@13.0.0 +[12.0.0]: https://github.com/MetaMask/core/compare/@metamask/gas-fee-controller@11.0.0...@metamask/gas-fee-controller@12.0.0 +[11.0.0]: https://github.com/MetaMask/core/compare/@metamask/gas-fee-controller@10.0.1...@metamask/gas-fee-controller@11.0.0 +[10.0.1]: https://github.com/MetaMask/core/compare/@metamask/gas-fee-controller@10.0.0...@metamask/gas-fee-controller@10.0.1 +[10.0.0]: https://github.com/MetaMask/core/compare/@metamask/gas-fee-controller@9.0.0...@metamask/gas-fee-controller@10.0.0 [9.0.0]: https://github.com/MetaMask/core/compare/@metamask/gas-fee-controller@8.0.0...@metamask/gas-fee-controller@9.0.0 [8.0.0]: https://github.com/MetaMask/core/compare/@metamask/gas-fee-controller@7.0.1...@metamask/gas-fee-controller@8.0.0 [7.0.1]: https://github.com/MetaMask/core/compare/@metamask/gas-fee-controller@7.0.0...@metamask/gas-fee-controller@7.0.1 diff --git a/packages/gas-fee-controller/LICENSE b/packages/gas-fee-controller/LICENSE index ddfbecf9020..bbed2e24b91 100644 --- a/packages/gas-fee-controller/LICENSE +++ b/packages/gas-fee-controller/LICENSE @@ -18,3 +18,4 @@ 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/gas-fee-controller/jest.config.js b/packages/gas-fee-controller/jest.config.js index 17db4cd31b6..9085847b1a8 100644 --- a/packages/gas-fee-controller/jest.config.js +++ b/packages/gas-fee-controller/jest.config.js @@ -17,10 +17,10 @@ module.exports = merge(baseConfig, { // An object that configures minimum threshold enforcement for coverage results coverageThreshold: { global: { - branches: 65.31, - functions: 76.59, - lines: 75.83, - statements: 75.91, + branches: 85.36, + functions: 92.5, + lines: 92.26, + statements: 92.34, }, }, }); diff --git a/packages/gas-fee-controller/package.json b/packages/gas-fee-controller/package.json index 6de69487987..50d0d31eeb1 100644 --- a/packages/gas-fee-controller/package.json +++ b/packages/gas-fee-controller/package.json @@ -1,68 +1,93 @@ { "name": "@metamask/gas-fee-controller", - "version": "9.0.0", + "version": "26.3.2", "description": "Periodically calculates gas fee estimates based on various gas limits as well as other data displayed on transaction confirm screens", "keywords": [ - "MetaMask", - "Ethereum" + "Ethereum", + "MetaMask" ], "homepage": "https://github.com/MetaMask/core/tree/main/packages/gas-fee-controller#readme", "bugs": { "url": "https://github.com/MetaMask/core/issues" }, + "license": "MIT", "repository": { "type": "git", "url": "https://github.com/MetaMask/core.git" }, - "license": "MIT", - "main": "./dist/index.js", - "types": "./dist/index.d.ts", "files": [ "dist/" ], + "sideEffects": false, + "main": "./dist/index.cjs", + "types": "./dist/index.d.cts", + "exports": { + ".": { + "import": { + "types": "./dist/index.d.mts", + "default": "./dist/index.mjs" + }, + "require": { + "types": "./dist/index.d.cts", + "default": "./dist/index.cjs" + } + }, + "./package.json": "./package.json" + }, + "publishConfig": { + "access": "public", + "registry": "https://registry.npmjs.org/" + }, "scripts": { + "build": "ts-bridge --project tsconfig.build.json --verbose --clean --no-references", + "build:all": "ts-bridge --project tsconfig.build.json --verbose --clean", "build:docs": "typedoc", + "changelog:update": "../../scripts/update-changelog.sh @metamask/gas-fee-controller", "changelog:validate": "../../scripts/validate-changelog.sh @metamask/gas-fee-controller", - "publish:preview": "yarn npm publish --tag preview", - "test": "jest", - "test:clean": "jest --clearCache", - "test:watch": "jest --watch" + "lint:tsconfigs": "tsx ../../scripts/lint-tsconfigs/lint-tsconfigs.mts", + "lint:tsconfigs:fix": "tsx ../../scripts/lint-tsconfigs/lint-tsconfigs.mts --fix", + "messenger-action-types:check": "tsx ../../packages/messenger-cli/src/cli.ts --formatter oxfmt --esm --check", + "messenger-action-types:generate": "tsx ../../packages/messenger-cli/src/cli.ts --formatter oxfmt --esm --generate", + "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": "^3.2.3", - "@metamask/controller-utils": "^5.0.2", - "@metamask/eth-query": "^3.0.1", - "@metamask/network-controller": "^15.0.0", - "@metamask/polling-controller": "^0.2.0", - "@metamask/utils": "^8.1.0", + "@metamask/base-controller": "^9.1.0", + "@metamask/controller-utils": "^12.3.0", + "@metamask/eth-query": "^4.0.0", + "@metamask/ethjs-unit": "^0.3.0", + "@metamask/messenger": "^2.0.0", + "@metamask/network-controller": "^36.0.0", + "@metamask/polling-controller": "^16.0.9", + "@metamask/utils": "^11.11.0", + "@types/bn.js": "^5.1.5", "@types/uuid": "^8.3.0", - "ethereumjs-util": "^7.0.10", - "ethjs-unit": "^0.1.6", - "immer": "^9.0.6", + "bn.js": "^5.2.1", "uuid": "^8.3.2" }, "devDependencies": { - "@metamask/auto-changelog": "^3.1.0", - "@types/jest": "^27.4.1", + "@babel/runtime": "^7.23.9", + "@metamask/auto-changelog": "^6.1.0", + "@ts-bridge/cli": "^0.6.4", + "@types/jest": "^30.0.0", "@types/jest-when": "^2.7.3", "deepmerge": "^4.2.2", - "jest": "^27.5.1", - "jest-when": "^3.4.2", + "jest": "^30.4.2", + "jest-when": "^3.7.0", "nock": "^13.3.1", - "sinon": "^9.2.4", - "ts-jest": "^27.1.4", - "typedoc": "^0.24.8", + "ts-jest": "^29.4.11", + "tsx": "^4.20.5", + "typedoc": "^0.25.13", "typedoc-plugin-missing-exports": "^2.0.0", - "typescript": "~4.8.4" + "typescript": "~5.3.3" }, "peerDependencies": { - "@metamask/network-controller": "^15.0.0" + "@babel/runtime": "^7.0.0" }, "engines": { - "node": ">=16.0.0" - }, - "publishConfig": { - "access": "public", - "registry": "https://registry.npmjs.org/" + "node": "^18.18 || >=20" } } diff --git a/packages/gas-fee-controller/src/GasFeeController-method-action-types.ts b/packages/gas-fee-controller/src/GasFeeController-method-action-types.ts new file mode 100644 index 00000000000..4f50d3ce9a4 --- /dev/null +++ b/packages/gas-fee-controller/src/GasFeeController-method-action-types.ts @@ -0,0 +1,92 @@ +/** + * This file is auto generated. + * Do not edit manually. + */ + +import type { GasFeeController } from './GasFeeController.js'; + +/** + * Resets the polling interval by stopping and restarting polling + * with the existing poll tokens. + */ +export type GasFeeControllerResetPollingAction = { + type: `GasFeeController:resetPolling`; + handler: GasFeeController['resetPolling']; +}; + +/** + * Fetches gas fee estimates. + * + * @param options - The gas fee estimate options. + * @returns The gas fee estimates. + */ +export type GasFeeControllerFetchGasFeeEstimatesAction = { + type: `GasFeeController:fetchGasFeeEstimates`; + handler: GasFeeController['fetchGasFeeEstimates']; +}; + +/** + * Gets gas fee estimates and starts polling for updates. + * + * @param pollToken - An existing poll token to reuse, or undefined to + * generate a new one. + * @returns The poll token that can be used to stop polling. + */ +export type GasFeeControllerGetGasFeeEstimatesAndStartPollingAction = { + type: `GasFeeController:getGasFeeEstimatesAndStartPolling`; + handler: GasFeeController['getGasFeeEstimatesAndStartPolling']; +}; + +/** + * Remove the poll token, and stop polling if the set of poll tokens is empty. + * + * @param pollToken - The poll token to disconnect. + */ +export type GasFeeControllerDisconnectPollerAction = { + type: `GasFeeController:disconnectPoller`; + handler: GasFeeController['disconnectPoller']; +}; + +/** + * Stops polling for gas fee estimates and clears all poll tokens. + */ +export type GasFeeControllerStopPollingAction = { + type: `GasFeeController:stopPolling`; + handler: GasFeeController['stopPolling']; +}; + +/** + * Gets the estimated time for a transaction based on the given gas parameters. + * + * @param maxPriorityFeePerGas - The maximum priority fee per gas in GWEI. + * @param maxFeePerGas - The maximum fee per gas in GWEI. + * @returns The estimated time bounds, or an empty object if fee market + * estimates are not available. + */ +export type GasFeeControllerGetTimeEstimateAction = { + type: `GasFeeController:getTimeEstimate`; + handler: GasFeeController['getTimeEstimate']; +}; + +export type GasFeeControllerEnableNonRPCGasFeeApisAction = { + type: `GasFeeController:enableNonRPCGasFeeApis`; + handler: GasFeeController['enableNonRPCGasFeeApis']; +}; + +export type GasFeeControllerDisableNonRPCGasFeeApisAction = { + type: `GasFeeController:disableNonRPCGasFeeApis`; + handler: GasFeeController['disableNonRPCGasFeeApis']; +}; + +/** + * Union of all GasFeeController action types. + */ +export type GasFeeControllerMethodActions = + | GasFeeControllerResetPollingAction + | GasFeeControllerFetchGasFeeEstimatesAction + | GasFeeControllerGetGasFeeEstimatesAndStartPollingAction + | GasFeeControllerDisconnectPollerAction + | GasFeeControllerStopPollingAction + | GasFeeControllerGetTimeEstimateAction + | GasFeeControllerEnableNonRPCGasFeeApisAction + | GasFeeControllerDisableNonRPCGasFeeApisAction; diff --git a/packages/gas-fee-controller/src/GasFeeController.test.ts b/packages/gas-fee-controller/src/GasFeeController.test.ts index c876ac8ec19..00c67aa6265 100644 --- a/packages/gas-fee-controller/src/GasFeeController.test.ts +++ b/packages/gas-fee-controller/src/GasFeeController.test.ts @@ -1,40 +1,45 @@ -import { ControllerMessenger } from '@metamask/base-controller'; +import { deriveStateFromMetadata } from '@metamask/base-controller'; import { ChainId, convertHexToDecimal, - NetworkType, toHex, } from '@metamask/controller-utils'; import EthQuery from '@metamask/eth-query'; +import { Messenger, MOCK_ANY_NAMESPACE } from '@metamask/messenger'; +import type { + MessengerActions, + MessengerEvents, + MockAnyNamespace, +} from '@metamask/messenger'; import { NetworkController, NetworkStatus } from '@metamask/network-controller'; import type { - NetworkControllerGetEIP1559CompatibilityAction, - NetworkControllerGetNetworkClientByIdAction, - NetworkControllerGetStateAction, - NetworkControllerNetworkDidChangeEvent, - NetworkControllerStateChangeEvent, + NetworkControllerMessenger, NetworkState, + ProviderProxy, } from '@metamask/network-controller'; import type { Hex } from '@metamask/utils'; -import * as sinon from 'sinon'; +import nock from 'nock'; -import determineGasFeeCalculations from './determineGasFeeCalculations'; -import fetchGasEstimatesViaEthFeeHistory from './fetchGasEstimatesViaEthFeeHistory'; +import { flushPromises } from '../../../tests/helpers.js'; +import { + buildCustomNetworkConfiguration, + buildCustomRpcEndpoint, +} from '../../network-controller/tests/helpers.js'; +import determineGasFeeCalculations from './determineGasFeeCalculations.js'; import { fetchGasEstimates, fetchLegacyGasPriceEstimates, fetchEthGasPriceEstimate, calculateTimeEstimate, -} from './gas-util'; -import { GAS_ESTIMATE_TYPES, GasFeeController } from './GasFeeController'; +} from './gas-util.js'; +import { GAS_ESTIMATE_TYPES, GasFeeController } from './GasFeeController.js'; import type { + GasFeeMessenger, GasFeeState, - GasFeeStateChange, GasFeeStateEthGasPrice, GasFeeStateFeeMarket, GasFeeStateLegacy, - GetGasFeeState, -} from './GasFeeController'; +} from './GasFeeController.js'; jest.mock('./determineGasFeeCalculations'); @@ -46,72 +51,135 @@ const mockedDetermineGasFeeCalculations = const name = 'GasFeeController'; -type MainControllerMessenger = ControllerMessenger< - | GetGasFeeState - | NetworkControllerGetStateAction - | NetworkControllerGetNetworkClientByIdAction - | NetworkControllerGetEIP1559CompatibilityAction, - | GasFeeStateChange - | NetworkControllerStateChangeEvent - | NetworkControllerNetworkDidChangeEvent ->; - -const getControllerMessenger = (): MainControllerMessenger => { - return new ControllerMessenger(); +type AllGasFeeControllerActions = MessengerActions; +type AllGasFeeControllerEvents = MessengerEvents; + +type AllNetworkControllerActions = MessengerActions; +type AllNetworkControllerEvents = MessengerEvents; + +type AllActions = AllGasFeeControllerActions | AllNetworkControllerActions; +type AllEvents = AllGasFeeControllerEvents | AllNetworkControllerEvents; + +type RootMessenger = Messenger; + +const getRootMessenger = (): RootMessenger => { + const rootMessenger = new Messenger< + MockAnyNamespace, + MessengerActions, + MessengerEvents + >({ namespace: MOCK_ANY_NAMESPACE }); + + rootMessenger.registerActionHandler( + 'RemoteFeatureFlagController:getState', + () => ({ + remoteFeatureFlags: { + walletFrameworkRpcFailoverEnabled: false, + }, + cacheTimestamp: 0, + }), + ); + + rootMessenger.registerActionHandler( + 'ConfigRegistryController:getState', + () => ({ + configs: { networks: {} }, + lastFetched: 0, + etag: '', + version: '1', + }), + ); + + return rootMessenger; }; const setupNetworkController = async ({ - unrestrictedMessenger, + rootMessenger, state, - clock, + initializeProvider = true, }: { - unrestrictedMessenger: MainControllerMessenger; + rootMessenger: RootMessenger; state: Partial; - clock: sinon.SinonFakeTimers; + initializeProvider?: boolean; }) => { - const restrictedMessenger = unrestrictedMessenger.getRestricted({ - name: 'NetworkController', - allowedActions: [ - 'NetworkController:getState', - 'NetworkController:getNetworkClientById', - 'NetworkController:getEIP1559Compatibility', - ], - allowedEvents: [ - 'NetworkController:stateChange', - 'NetworkController:networkDidChange', + const networkControllerMessenger = new Messenger< + 'NetworkController', + MessengerActions, + MessengerEvents, + typeof rootMessenger + >({ + namespace: 'NetworkController', + parent: rootMessenger, + captureException: jest.fn(), + }); + + rootMessenger.delegate({ + messenger: networkControllerMessenger, + actions: [ + 'ConfigRegistryController:getState', + 'ConnectivityController:getState', + 'RemoteFeatureFlagController:getState', ], }); + const infuraProjectId = '123'; + const networkController = new NetworkController({ - messenger: restrictedMessenger, + messenger: networkControllerMessenger, state, - infuraProjectId: '123', - trackMetaMetricsEvent: jest.fn(), + infuraProjectId, + getRpcServiceOptions: () => ({ + fetch, + btoa, + isOffline: (): boolean => false, + }), }); - // Call this without awaiting to simulate what the extension or mobile app - // might do - networkController.initializeProvider(); - // Ensure that the request for eth_getBlockByNumber made by the PollingBlockTracker - // inside the NetworkController goes through - await clock.nextAsync(); + + nock('https://mainnet.infura.io') + .post(`/v3/${infuraProjectId}`, { + id: /^\d+$/u, + jsonrpc: '2.0', + method: 'eth_blockNumber', + params: [], + }) + .reply(200, { + id: 1, + jsonrpc: '2.0', + result: '0x1', + }) + .persist(); + + if (initializeProvider) { + // Call this without awaiting to simulate what the extension or mobile app + // might do + networkController.init(); + // Ensure that the request for eth_getBlockByNumber made by the PollingBlockTracker + // inside the NetworkController goes through + await jest.advanceTimersToNextTimerAsync(); + } return networkController; }; -const getRestrictedMessenger = ( - controllerMessenger: MainControllerMessenger, -) => { - const messenger = controllerMessenger.getRestricted({ - name, - allowedActions: [ +const getGasFeeControllerMessenger = (rootMessenger: RootMessenger) => { + const gasFeeControllerMessenger = new Messenger< + 'GasFeeController', + AllGasFeeControllerActions, + AllGasFeeControllerEvents, + typeof rootMessenger + >({ + namespace: 'GasFeeController', + parent: rootMessenger, + }); + rootMessenger.delegate({ + messenger: gasFeeControllerMessenger, + actions: [ 'NetworkController:getState', 'NetworkController:getNetworkClientById', 'NetworkController:getEIP1559Compatibility', ], - allowedEvents: ['NetworkController:stateChange'], + events: ['NetworkController:networkDidChange'], }); - - return messenger; + return gasFeeControllerMessenger; }; /** @@ -214,7 +282,6 @@ function buildMockGasFeeStateEthGasPrice({ } describe('GasFeeController', () => { - let clock: sinon.SinonFakeTimers; let gasFeeController: GasFeeController; let networkController: NetworkController; @@ -224,6 +291,8 @@ describe('GasFeeController', () => { * * @param options - The options. * @param options.getChainId - Sets getChainId on the GasFeeController. + * @param options.getProvider - Sets getProvider on the GasFeeController. + * @param options.onNetworkDidChange - A function for registering an event handler for the * @param options.getIsEIP1559Compatible - Sets getCurrentNetworkEIP1559Compatibility on the * GasFeeController. * @param options.getCurrentNetworkLegacyGasAPICompatibility - Sets @@ -235,6 +304,9 @@ describe('GasFeeController', () => { * NetworkController with. * @param options.interval - The polling interval. * @param options.state - The initial GasFeeController state + * @param options.initializeNetworkProvider - Whether to instruct the + * NetworkController to initialize its provider. + * @returns The root messenger, so tests can publish network events to it. */ async function setupGasFeeController({ getIsEIP1559Compatible = jest.fn().mockResolvedValue(true), @@ -245,11 +317,16 @@ describe('GasFeeController', () => { EIP1559APIEndpoint = 'http://eip-1559.endpoint/', clientId, getChainId, + getProvider = jest.fn(), + onNetworkDidChange, networkControllerState = {}, state, interval, + initializeNetworkProvider = true, }: { getChainId?: jest.Mock; + getProvider?: jest.Mock; + onNetworkDidChange?: jest.Mock; getIsEIP1559Compatible?: jest.Mock>; getCurrentNetworkLegacyGasAPICompatibility?: jest.Mock; legacyAPIEndpoint?: string; @@ -258,18 +335,20 @@ describe('GasFeeController', () => { networkControllerState?: Partial; state?: GasFeeState; interval?: number; + initializeNetworkProvider?: boolean; } = {}) { - const controllerMessenger = getControllerMessenger(); + const rootMessenger = getRootMessenger(); networkController = await setupNetworkController({ - unrestrictedMessenger: controllerMessenger, + rootMessenger, state: networkControllerState, - clock, + initializeProvider: initializeNetworkProvider, }); - const messenger = getRestrictedMessenger(controllerMessenger); + const restrictedMessenger = getGasFeeControllerMessenger(rootMessenger); gasFeeController = new GasFeeController({ - getProvider: jest.fn(), + getProvider, getChainId, - messenger, + onNetworkDidChange, + messenger: restrictedMessenger, getCurrentNetworkLegacyGasAPICompatibility, getCurrentNetworkEIP1559Compatibility: getIsEIP1559Compatible, // change this for networkDetails.state.networkDetails.isEIP1559Compatible ??? legacyAPIEndpoint, @@ -278,10 +357,11 @@ describe('GasFeeController', () => { clientId, interval, }); + return { rootMessenger }; } beforeEach(() => { - clock = sinon.useFakeTimers(); + jest.useFakeTimers({ doNotFake: ['nextTick', 'queueMicrotask'] }); mockedDetermineGasFeeCalculations.mockResolvedValue( buildMockGasFeeStateFeeMarket(), ); @@ -290,9 +370,10 @@ describe('GasFeeController', () => { afterEach(() => { gasFeeController.destroy(); const { blockTracker } = networkController.getProviderAndBlockTracker(); + // 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.clearAllMocks(); + jest.useRealTimers(); }); describe('constructor', () => { @@ -303,6 +384,110 @@ describe('GasFeeController', () => { it('should set the name of the controller to GasFeeController', () => { expect(gasFeeController.name).toBe(name); }); + + describe('initialization-order independence', () => { + /** + * Builds a messenger whose NetworkController action handlers throw if + * called, so that a test can assert the constructor never reads from the + * NetworkController. + * + * @returns The messenger along with spies for its handlers. + */ + const getMessengerWithThrowingNetworkHandlers = (): { + messenger: ReturnType; + getState: jest.Mock; + getNetworkClientById: jest.Mock; + } => { + const rootMessenger = getRootMessenger(); + const getState = jest.fn(() => { + throw new Error('NetworkController:getState should not be called'); + }); + const getNetworkClientById = jest.fn(() => { + throw new Error( + 'NetworkController:getNetworkClientById should not be called', + ); + }); + rootMessenger.registerActionHandler( + 'NetworkController:getState', + getState, + ); + rootMessenger.registerActionHandler( + 'NetworkController:getNetworkClientById', + getNetworkClientById, + ); + return { + messenger: getGasFeeControllerMessenger(rootMessenger), + getState, + getNetworkClientById, + }; + }; + + it('does not read the chain ID or provider from the network when constructed without getChainId/onNetworkDidChange', () => { + const { messenger, getState, getNetworkClientById } = + getMessengerWithThrowingNetworkHandlers(); + const getProvider = jest.fn(() => { + throw new Error('getProvider should not be called'); + }); + + let controller: GasFeeController | undefined; + expect(() => { + controller = new GasFeeController({ + messenger, + getProvider, + getCurrentNetworkLegacyGasAPICompatibility: jest + .fn() + .mockReturnValue(false), + getCurrentNetworkEIP1559Compatibility: jest + .fn() + .mockResolvedValue(true), + EIP1559APIEndpoint: 'http://eip-1559.endpoint/', + }); + }).not.toThrow(); + + expect(getState).not.toHaveBeenCalled(); + expect(getNetworkClientById).not.toHaveBeenCalled(); + expect(getProvider).not.toHaveBeenCalled(); + + controller?.destroy(); + }); + + it('does not read the chain ID or provider from the network when constructed with getChainId/onNetworkDidChange', () => { + const { messenger, getState, getNetworkClientById } = + getMessengerWithThrowingNetworkHandlers(); + const getProvider = jest.fn(() => { + throw new Error('getProvider should not be called'); + }); + const getChainId = jest.fn(() => { + throw new Error('getChainId should not be called'); + }); + const onNetworkDidChange = jest.fn(); + + let controller: GasFeeController | undefined; + expect(() => { + controller = new GasFeeController({ + messenger, + getProvider, + getChainId, + onNetworkDidChange, + getCurrentNetworkLegacyGasAPICompatibility: jest + .fn() + .mockReturnValue(false), + getCurrentNetworkEIP1559Compatibility: jest + .fn() + .mockResolvedValue(true), + EIP1559APIEndpoint: 'http://eip-1559.endpoint/', + }); + }).not.toThrow(); + + expect(getState).not.toHaveBeenCalled(); + expect(getNetworkClientById).not.toHaveBeenCalled(); + expect(getProvider).not.toHaveBeenCalled(); + expect(getChainId).not.toHaveBeenCalled(); + expect(onNetworkDidChange).toHaveBeenCalledTimes(1); + + controller?.destroy(); + }); + }); }); describe('getGasFeeEstimatesAndStartPolling', () => { @@ -332,14 +517,26 @@ describe('GasFeeController', () => { legacyAPIEndpoint: 'https://some-legacy-endpoint/', EIP1559APIEndpoint: 'https://some-eip-1559-endpoint/', networkControllerState: { - providerConfig: { - type: NetworkType.rpc, - chainId: toHex(1337), - rpcUrl: 'http://some/url', - ticker: 'TEST', + networkConfigurationsByChainId: { + [toHex(1337)]: buildCustomNetworkConfiguration({ + chainId: toHex(1337), + rpcEndpoints: [ + buildCustomRpcEndpoint({ + networkClientId: 'AAAA-BBBB-CCCC-DDDD', + }), + ], + }), }, + selectedNetworkClientId: 'AAAA-BBBB-CCCC-DDDD', }, clientId: '99999', + // Currently initializing the provider overwrites the + // `selectedNetworkClientId` we specify above based on whatever + // `providerConfig` is. So we prevent the provider from being + // initialized to make this test pass. Once `providerConfig` is + // removed, then we don't need this anymore and + // `selectedNetworkClientId` should no longer be overwritten. + initializeNetworkProvider: false, }); await gasFeeController.getGasFeeEstimatesAndStartPolling(undefined); @@ -349,7 +546,6 @@ describe('GasFeeController', () => { isLegacyGasAPICompatible: true, fetchGasEstimates, fetchGasEstimatesUrl: 'https://some-eip-1559-endpoint/1337', - fetchGasEstimatesViaEthFeeHistory, fetchLegacyGasPriceEstimates, fetchLegacyGasPriceEstimatesUrl: 'https://some-legacy-endpoint/1337', @@ -357,6 +553,7 @@ describe('GasFeeController', () => { calculateTimeEstimate, clientId: '99999', ethQuery: expect.any(EthQuery), + nonRPCGasFeeApisDisabled: false, }); }); @@ -369,8 +566,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], @@ -388,14 +587,26 @@ describe('GasFeeController', () => { legacyAPIEndpoint: 'https://some-legacy-endpoint/', EIP1559APIEndpoint: 'https://some-eip-1559-endpoint/', networkControllerState: { - providerConfig: { - type: NetworkType.rpc, - chainId: toHex(1337), - rpcUrl: 'http://some/url', - ticker: 'TEST', + networkConfigurationsByChainId: { + [toHex(1337)]: buildCustomNetworkConfiguration({ + chainId: toHex(1337), + rpcEndpoints: [ + buildCustomRpcEndpoint({ + networkClientId: 'AAAA-BBBB-CCCC-DDDD', + }), + ], + }), }, + selectedNetworkClientId: 'AAAA-BBBB-CCCC-DDDD', }, clientId: '99999', + // Currently initializing the provider overwrites the + // `selectedNetworkClientId` we specify above based on whatever + // `providerConfig` is. So we prevent the provider from being + // initialized to make this test pass. Once `providerConfig` is + // removed, then we don't need this anymore and + // `selectedNetworkClientId` should no longer be overwritten. + initializeNetworkProvider: false, }); await gasFeeController.getGasFeeEstimatesAndStartPolling( @@ -407,7 +618,6 @@ describe('GasFeeController', () => { isLegacyGasAPICompatible: true, fetchGasEstimates, fetchGasEstimatesUrl: 'https://some-eip-1559-endpoint/1337', - fetchGasEstimatesViaEthFeeHistory, fetchLegacyGasPriceEstimates, fetchLegacyGasPriceEstimatesUrl: 'https://some-legacy-endpoint/1337', @@ -415,6 +625,7 @@ describe('GasFeeController', () => { calculateTimeEstimate, clientId: '99999', ethQuery: expect.any(EthQuery), + nonRPCGasFeeApisDisabled: false, }); }); @@ -425,7 +636,7 @@ describe('GasFeeController', () => { await gasFeeController.getGasFeeEstimatesAndStartPolling( 'some-previously-unseen-token', ); - await clock.tickAsync(pollingInterval); + await jest.advanceTimersByTimeAsync(pollingInterval); expect(mockedDetermineGasFeeCalculations).toHaveBeenCalledTimes(2); }); @@ -448,8 +659,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); }); @@ -473,8 +684,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); }); @@ -506,14 +717,48 @@ 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); }); }); }); + describe('enableNonRPCGasFeeApis', () => { + it('should set state.nonRPCGasFeeApisDisabled to true', async () => { + await setupGasFeeController({ + state: { + ...buildMockGasFeeStateEthGasPrice(), + nonRPCGasFeeApisDisabled: false, + }, + }); + + // TODO: Either fix this lint violation or explain why it's necessary to ignore. + // eslint-disable-next-line @typescript-eslint/await-thenable + await gasFeeController.enableNonRPCGasFeeApis(); + + expect(gasFeeController.state.nonRPCGasFeeApisDisabled).toBe(false); + }); + }); + + describe('disableNonRPCGasFeeApis', () => { + it('should set state.nonRPCGasFeeApisDisabled to false', async () => { + await setupGasFeeController({ + state: { + ...buildMockGasFeeStateEthGasPrice(), + nonRPCGasFeeApisDisabled: true, + }, + }); + + // TODO: Either fix this lint violation or explain why it's necessary to ignore. + // eslint-disable-next-line @typescript-eslint/await-thenable + await gasFeeController.disableNonRPCGasFeeApis(); + + expect(gasFeeController.state.nonRPCGasFeeApisDisabled).toBe(true); + }); + }); + describe('disconnectPoller', () => { describe('assuming that getGasFeeEstimatesAndStartPolling was already called exactly once', () => { describe('given the same token as the result of the first call', () => { @@ -522,12 +767,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); }); @@ -536,13 +781,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); }); }); @@ -552,12 +797,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); }); }); @@ -571,12 +816,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); }); }); @@ -597,12 +842,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); }); @@ -611,13 +856,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); }); @@ -643,12 +888,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); }); @@ -658,13 +903,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); }); }); @@ -677,14 +922,14 @@ describe('GasFeeController', () => { }); }); - describe('_fetchGasFeeEstimateData', () => { + describe('fetchGasFeeEstimates', () => { describe('when on any network supporting legacy gas estimation api', () => { - const defaultConstructorOptions = { + const getDefaultOptions = () => ({ getIsEIP1559Compatible: jest.fn().mockResolvedValue(false), getCurrentNetworkLegacyGasAPICompatibility: jest .fn() .mockReturnValue(true), - }; + }); const mockDetermineGasFeeCalculations = buildMockGasFeeStateLegacy(); beforeEach(() => { @@ -695,41 +940,53 @@ describe('GasFeeController', () => { it('should call determineGasFeeCalculations correctly', async () => { await setupGasFeeController({ - ...defaultConstructorOptions, + ...getDefaultOptions(), legacyAPIEndpoint: 'https://some-legacy-endpoint/', EIP1559APIEndpoint: 'https://some-eip-1559-endpoint/', networkControllerState: { - providerConfig: { - type: NetworkType.rpc, - chainId: toHex(1337), - rpcUrl: 'http://some/url', - ticker: 'TEST', + networkConfigurationsByChainId: { + [toHex(1337)]: buildCustomNetworkConfiguration({ + chainId: toHex(1337), + rpcEndpoints: [ + buildCustomRpcEndpoint({ + networkClientId: 'AAAA-BBBB-CCCC-DDDD', + }), + ], + }), }, + selectedNetworkClientId: 'AAAA-BBBB-CCCC-DDDD', }, clientId: '99999', + // Currently initializing the provider overwrites the + // `selectedNetworkClientId` we specify above based on whatever + // `providerConfig` is. So we prevent the provider from being + // initialized to make this test pass. Once `providerConfig` is + // removed, then we don't need this anymore and + // `selectedNetworkClientId` should no longer be overwritten. + initializeNetworkProvider: false, }); - await gasFeeController._fetchGasFeeEstimateData(); + await gasFeeController.fetchGasFeeEstimates(); expect(mockedDetermineGasFeeCalculations).toHaveBeenCalledWith({ isEIP1559Compatible: false, isLegacyGasAPICompatible: true, fetchGasEstimates, fetchGasEstimatesUrl: 'https://some-eip-1559-endpoint/1337', - fetchGasEstimatesViaEthFeeHistory, fetchLegacyGasPriceEstimates, fetchLegacyGasPriceEstimatesUrl: 'https://some-legacy-endpoint/1337', fetchEthGasPriceEstimate, calculateTimeEstimate, clientId: '99999', ethQuery: expect.any(EthQuery), + nonRPCGasFeeApisDisabled: false, }); }); it('should update the state with a fetched set of estimates', async () => { - await setupGasFeeController(defaultConstructorOptions); + await setupGasFeeController(getDefaultOptions()); - await gasFeeController._fetchGasFeeEstimateData(); + await gasFeeController.fetchGasFeeEstimates(); expect(gasFeeController.state).toMatchObject( mockDetermineGasFeeCalculations, @@ -737,16 +994,16 @@ describe('GasFeeController', () => { }); it('should return the same data that it puts into state', async () => { - await setupGasFeeController(defaultConstructorOptions); + await setupGasFeeController(getDefaultOptions()); - const estimateData = await gasFeeController._fetchGasFeeEstimateData(); + const estimateData = await gasFeeController.fetchGasFeeEstimates(); expect(estimateData).toMatchObject(mockDetermineGasFeeCalculations); }); it('should call determineGasFeeCalculations correctly when getChainId returns a number input', async () => { await setupGasFeeController({ - ...defaultConstructorOptions, + ...getDefaultOptions(), legacyAPIEndpoint: 'http://legacy.endpoint/', getChainId: jest.fn().mockReturnValue(1), }); @@ -762,12 +1019,12 @@ describe('GasFeeController', () => { it('should call determineGasFeeCalculations correctly when getChainId returns a hexstring input', async () => { await setupGasFeeController({ - ...defaultConstructorOptions, + ...getDefaultOptions(), legacyAPIEndpoint: 'http://legacy.endpoint/', getChainId: jest.fn().mockReturnValue('0x1'), }); - await gasFeeController._fetchGasFeeEstimateData(); + await gasFeeController.fetchGasFeeEstimates(); expect(mockedDetermineGasFeeCalculations).toHaveBeenCalledWith( expect.objectContaining({ @@ -776,14 +1033,50 @@ describe('GasFeeController', () => { ); }); + it('should call determineGasFeeCalculations correctly when nonRPCGasFeeApisDisabled is true', async () => { + await setupGasFeeController({ + ...getDefaultOptions(), + state: { + ...buildMockGasFeeStateEthGasPrice(), + nonRPCGasFeeApisDisabled: true, + }, + }); + + await gasFeeController.fetchGasFeeEstimates(); + + expect(mockedDetermineGasFeeCalculations).toHaveBeenCalledWith( + expect.objectContaining({ + nonRPCGasFeeApisDisabled: true, + }), + ); + }); + + it('should call determineGasFeeCalculations correctly when nonRPCGasFeeApisDisabled is false', async () => { + await setupGasFeeController({ + ...getDefaultOptions(), + state: { + ...buildMockGasFeeStateEthGasPrice(), + nonRPCGasFeeApisDisabled: false, + }, + }); + + await gasFeeController.fetchGasFeeEstimates(); + + expect(mockedDetermineGasFeeCalculations).toHaveBeenCalledWith( + expect.objectContaining({ + nonRPCGasFeeApisDisabled: false, + }), + ); + }); + it('should call determineGasFeeCalculations correctly when getChainId returns a numeric string input', async () => { await setupGasFeeController({ - ...defaultConstructorOptions, + ...getDefaultOptions(), legacyAPIEndpoint: 'http://legacy.endpoint/', getChainId: jest.fn().mockReturnValue('1'), }); - await gasFeeController._fetchGasFeeEstimateData(); + await gasFeeController.fetchGasFeeEstimates(); expect(mockedDetermineGasFeeCalculations).toHaveBeenCalledWith( expect.objectContaining({ @@ -794,9 +1087,9 @@ describe('GasFeeController', () => { }); describe('when on any network supporting EIP-1559', () => { - const defaultConstructorOptions = { + const getDefaultOptions = () => ({ getIsEIP1559Compatible: jest.fn().mockResolvedValue(true), - }; + }); const mockDetermineGasFeeCalculations = buildMockGasFeeStateFeeMarket(); beforeEach(() => { @@ -807,41 +1100,53 @@ describe('GasFeeController', () => { it('should call determineGasFeeCalculations correctly', async () => { await setupGasFeeController({ - ...defaultConstructorOptions, + ...getDefaultOptions(), legacyAPIEndpoint: 'https://some-legacy-endpoint/', EIP1559APIEndpoint: 'https://some-eip-1559-endpoint/', networkControllerState: { - providerConfig: { - type: NetworkType.rpc, - chainId: toHex(1337), - rpcUrl: 'http://some/url', - ticker: 'TEST', + networkConfigurationsByChainId: { + [toHex(1337)]: buildCustomNetworkConfiguration({ + chainId: toHex(1337), + rpcEndpoints: [ + buildCustomRpcEndpoint({ + networkClientId: 'AAAA-BBBB-CCCC-DDDD', + }), + ], + }), }, + selectedNetworkClientId: 'AAAA-BBBB-CCCC-DDDD', }, clientId: '99999', + // Currently initializing the provider overwrites the + // `selectedNetworkClientId` we specify above based on whatever + // `providerConfig` is. So we prevent the provider from being + // initialized to make this test pass. Once `providerConfig` is + // removed, then we don't need this anymore and + // `selectedNetworkClientId` should no longer be overwritten. + initializeNetworkProvider: false, }); - await gasFeeController._fetchGasFeeEstimateData(); + await gasFeeController.fetchGasFeeEstimates(); expect(mockedDetermineGasFeeCalculations).toHaveBeenCalledWith({ isEIP1559Compatible: true, isLegacyGasAPICompatible: false, fetchGasEstimates, fetchGasEstimatesUrl: 'https://some-eip-1559-endpoint/1337', - fetchGasEstimatesViaEthFeeHistory, fetchLegacyGasPriceEstimates, fetchLegacyGasPriceEstimatesUrl: 'https://some-legacy-endpoint/1337', fetchEthGasPriceEstimate, calculateTimeEstimate, clientId: '99999', ethQuery: expect.any(EthQuery), + nonRPCGasFeeApisDisabled: false, }); }); it('should update the state with a fetched set of estimates', async () => { - await setupGasFeeController(defaultConstructorOptions); + await setupGasFeeController(getDefaultOptions()); - await gasFeeController._fetchGasFeeEstimateData(); + await gasFeeController.fetchGasFeeEstimates(); expect(gasFeeController.state).toMatchObject( mockDetermineGasFeeCalculations, @@ -849,21 +1154,21 @@ describe('GasFeeController', () => { }); it('should return the same data that it puts into state', async () => { - await setupGasFeeController(defaultConstructorOptions); + await setupGasFeeController(getDefaultOptions()); - const estimateData = await gasFeeController._fetchGasFeeEstimateData(); + const estimateData = await gasFeeController.fetchGasFeeEstimates(); expect(estimateData).toMatchObject(mockDetermineGasFeeCalculations); }); it('should call determineGasFeeCalculations with a URL that contains the chain ID', async () => { await setupGasFeeController({ - ...defaultConstructorOptions, + ...getDefaultOptions(), EIP1559APIEndpoint: 'http://eip-1559.endpoint/', getChainId: jest.fn().mockReturnValue('0x1'), }); - await gasFeeController._fetchGasFeeEstimateData(); + await gasFeeController.fetchGasFeeEstimates(); expect(mockedDetermineGasFeeCalculations).toHaveBeenCalledWith( expect.objectContaining({ @@ -872,10 +1177,174 @@ describe('GasFeeController', () => { ); }); }); + describe('when passed a networkClientId in options object', () => { + const getDefaultOptions = () => ({ + getIsEIP1559Compatible: jest.fn().mockResolvedValue(true), + networkControllerState: { + networksMetadata: { + 'linea-sepolia': { + EIPS: { + 1559: true, + }, + status: NetworkStatus.Available, + }, + sepolia: { + EIPS: { + 1559: true, + }, + status: NetworkStatus.Available, + }, + 'test-network-client-id': { + EIPS: { + 1559: true, + }, + status: NetworkStatus.Available, + }, + }, + }, + }); + const mockDetermineGasFeeCalculations = buildMockGasFeeStateFeeMarket(); + + beforeEach(() => { + mockedDetermineGasFeeCalculations.mockResolvedValue( + mockDetermineGasFeeCalculations, + ); + }); + + it('should call determineGasFeeCalculations correctly', async () => { + await setupGasFeeController({ + ...getDefaultOptions(), + legacyAPIEndpoint: 'https://some-legacy-endpoint/', + EIP1559APIEndpoint: 'https://some-eip-1559-endpoint/', + clientId: '99999', + }); + + await gasFeeController.fetchGasFeeEstimates({ + networkClientId: 'sepolia', + }); + + expect(mockedDetermineGasFeeCalculations).toHaveBeenCalledWith({ + isEIP1559Compatible: true, + isLegacyGasAPICompatible: false, + fetchGasEstimates, + fetchGasEstimatesUrl: `https://some-eip-1559-endpoint/${convertHexToDecimal( + ChainId.sepolia, + )}`, + fetchLegacyGasPriceEstimates, + fetchLegacyGasPriceEstimatesUrl: `https://some-legacy-endpoint/${convertHexToDecimal( + ChainId.sepolia, + )}`, + fetchEthGasPriceEstimate, + calculateTimeEstimate, + clientId: '99999', + ethQuery: expect.any(EthQuery), + nonRPCGasFeeApisDisabled: false, + }); + }); + + describe("the chainId of the networkClientId matches the globally selected network's chainId", () => { + it('should update the globally selected network state with a fetched set of estimates', async () => { + await setupGasFeeController({ + ...getDefaultOptions(), + getChainId: jest.fn().mockReturnValue(ChainId.sepolia), + onNetworkDidChange: jest.fn(), + }); + + await gasFeeController.fetchGasFeeEstimates({ + networkClientId: 'sepolia', + }); + + expect(gasFeeController.state).toMatchObject( + mockDetermineGasFeeCalculations, + ); + }); + + it('should update the gasFeeEstimatesByChainId state with a fetched set of estimates', async () => { + await setupGasFeeController({ + ...getDefaultOptions(), + getChainId: jest.fn().mockReturnValue(ChainId.sepolia), + onNetworkDidChange: jest.fn(), + }); + + await gasFeeController.fetchGasFeeEstimates({ + networkClientId: 'sepolia', + }); + + expect( + gasFeeController.state.gasFeeEstimatesByChainId?.[ChainId.sepolia], + ).toMatchObject(mockDetermineGasFeeCalculations); + }); + }); + + describe("the chainId of the networkClientId does not match the globally selected network's chainId", () => { + it('should not update the globally selected network state with a fetched set of estimates', async () => { + await setupGasFeeController({ + ...getDefaultOptions(), + getChainId: jest.fn().mockReturnValue(ChainId.mainnet), + onNetworkDidChange: jest.fn(), + }); + + await gasFeeController.fetchGasFeeEstimates({ + networkClientId: 'sepolia', + }); + + expect(gasFeeController.state).toMatchObject({ + gasFeeEstimates: {}, + estimatedGasFeeTimeBounds: {}, + gasEstimateType: GAS_ESTIMATE_TYPES.NONE, + }); + }); + + it('should update the gasFeeEstimatesByChainId state with a fetched set of estimates', async () => { + await setupGasFeeController({ + ...getDefaultOptions(), + getChainId: jest.fn().mockReturnValue(ChainId.mainnet), + onNetworkDidChange: jest.fn(), + }); + + await gasFeeController.fetchGasFeeEstimates({ + networkClientId: 'sepolia', + }); + + expect( + gasFeeController.state.gasFeeEstimatesByChainId?.[ChainId.sepolia], + ).toMatchObject(mockDetermineGasFeeCalculations); + }); + }); + + it('should return the same data that it puts into state', async () => { + await setupGasFeeController(getDefaultOptions()); + + const estimateData = await gasFeeController.fetchGasFeeEstimates({ + networkClientId: 'sepolia', + }); + + expect(estimateData).toMatchObject(mockDetermineGasFeeCalculations); + }); + + it('should call determineGasFeeCalculations with a URL that contains the chain ID', async () => { + await setupGasFeeController({ + ...getDefaultOptions(), + EIP1559APIEndpoint: 'http://eip-1559.endpoint/', + }); + + await gasFeeController.fetchGasFeeEstimates({ + networkClientId: 'sepolia', + }); + + expect(mockedDetermineGasFeeCalculations).toHaveBeenCalledWith( + expect.objectContaining({ + fetchGasEstimatesUrl: `http://eip-1559.endpoint/${convertHexToDecimal( + ChainId.sepolia, + )}`, + }), + ); + }); + }); }); describe('polling (by networkClientId)', () => { - it('should call determineGasFeeCalculations (via _executePoll) with a URL that contains the chainId corresponding to the networkClientId after the interval passed via the constructor', async () => { + it('should call determineGasFeeCalculations (via _executePoll) with a URL that contains the chainId corresponding to the networkClientId immedaitely and after each interval passed via the constructor', async () => { const pollingInterval = 10000; await setupGasFeeController({ getIsEIP1559Compatible: jest.fn().mockResolvedValue(false), @@ -886,7 +1355,7 @@ describe('GasFeeController', () => { EIP1559APIEndpoint: 'https://some-eip-1559-endpoint/', networkControllerState: { networksMetadata: { - goerli: { + 'linea-sepolia': { EIPS: { 1559: true, }, @@ -904,23 +1373,39 @@ describe('GasFeeController', () => { interval: pollingInterval, }); - gasFeeController.startPollingByNetworkClientId('goerli'); - await clock.tickAsync(pollingInterval / 2); - expect(mockedDetermineGasFeeCalculations).not.toHaveBeenCalled(); - await clock.tickAsync(pollingInterval / 2); - expect(mockedDetermineGasFeeCalculations).toHaveBeenCalledWith( + gasFeeController.startPolling({ + networkClientId: 'linea-sepolia', + }); + await jest.advanceTimersByTimeAsync(0); + expect(mockedDetermineGasFeeCalculations).toHaveBeenNthCalledWith( + 1, expect.objectContaining({ fetchGasEstimatesUrl: `https://some-eip-1559-endpoint/${convertHexToDecimal( - ChainId.goerli, + ChainId['linea-sepolia'], + )}`, + }), + ); + await jest.advanceTimersByTimeAsync(pollingInterval / 2); + expect(mockedDetermineGasFeeCalculations).toHaveBeenCalledTimes(1); + await jest.advanceTimersByTimeAsync(pollingInterval / 2); + expect(mockedDetermineGasFeeCalculations).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ + fetchGasEstimatesUrl: `https://some-eip-1559-endpoint/${convertHexToDecimal( + ChainId['linea-sepolia'], )}`, }), ); expect( - gasFeeController.state.gasFeeEstimatesByChainId?.['0x5'], + gasFeeController.state.gasFeeEstimatesByChainId?.[ + ChainId['linea-sepolia'] + ], ).toStrictEqual(buildMockGasFeeStateFeeMarket()); - gasFeeController.startPollingByNetworkClientId('sepolia'); - await clock.tickAsync(pollingInterval); + gasFeeController.startPolling({ + networkClientId: 'sepolia', + }); + await jest.advanceTimersByTimeAsync(pollingInterval); expect(mockedDetermineGasFeeCalculations).toHaveBeenCalledWith( expect.objectContaining({ fetchGasEstimatesUrl: `https://some-eip-1559-endpoint/${convertHexToDecimal( @@ -930,4 +1415,179 @@ describe('GasFeeController', () => { ); }); }); + + describe('when the selected network changes', () => { + it('updates the chain ID used for the next fetch when notified via the onNetworkDidChange callback', async () => { + let networkDidChangeListener: + | ((networkControllerState: NetworkState) => Promise) + | undefined; + const onNetworkDidChange = jest.fn((listener) => { + networkDidChangeListener = listener; + }); + await setupGasFeeController({ + getIsEIP1559Compatible: jest.fn().mockResolvedValue(true), + EIP1559APIEndpoint: 'https://some-eip-1559-endpoint/', + getChainId: jest.fn().mockReturnValue(ChainId.mainnet), + onNetworkDidChange, + }); + + await gasFeeController.fetchGasFeeEstimates(); + expect(mockedDetermineGasFeeCalculations).toHaveBeenLastCalledWith( + expect.objectContaining({ + fetchGasEstimatesUrl: `https://some-eip-1559-endpoint/${convertHexToDecimal( + ChainId.mainnet, + )}`, + }), + ); + + // Simulate the network switching to Sepolia. + await networkDidChangeListener?.({ + selectedNetworkClientId: 'sepolia', + } as NetworkState); + + await gasFeeController.fetchGasFeeEstimates(); + expect(mockedDetermineGasFeeCalculations).toHaveBeenLastCalledWith( + expect.objectContaining({ + fetchGasEstimatesUrl: `https://some-eip-1559-endpoint/${convertHexToDecimal( + ChainId.sepolia, + )}`, + }), + ); + }); + + it('updates the chain ID used for the next fetch when notified via NetworkController:networkDidChange', async () => { + const { rootMessenger } = await setupGasFeeController({ + EIP1559APIEndpoint: 'https://some-eip-1559-endpoint/', + initializeNetworkProvider: false, + }); + + await gasFeeController.fetchGasFeeEstimates(); + expect(mockedDetermineGasFeeCalculations).toHaveBeenLastCalledWith( + expect.objectContaining({ + fetchGasEstimatesUrl: `https://some-eip-1559-endpoint/${convertHexToDecimal( + ChainId.mainnet, + )}`, + }), + ); + + // Simulate the network switching to Sepolia. + rootMessenger.publish('NetworkController:networkDidChange', { + selectedNetworkClientId: 'sepolia', + } as NetworkState); + await flushPromises(); + + await gasFeeController.fetchGasFeeEstimates(); + expect(mockedDetermineGasFeeCalculations).toHaveBeenLastCalledWith( + expect.objectContaining({ + fetchGasEstimatesUrl: `https://some-eip-1559-endpoint/${convertHexToDecimal( + ChainId.sepolia, + )}`, + }), + ); + }); + + it('reads the provider once, caches the eth query, then rebuilds it from the provider after a network change', async () => { + const provider1 = { id: 1 } as unknown as ProviderProxy; + const provider2 = { id: 2 } as unknown as ProviderProxy; + const getProvider = jest + .fn() + .mockReturnValueOnce(provider1) + .mockReturnValueOnce(provider2); + const { rootMessenger } = await setupGasFeeController({ + getProvider, + EIP1559APIEndpoint: 'https://some-eip-1559-endpoint/', + initializeNetworkProvider: false, + }); + + // The provider is read lazily on the first fetch, and the resulting eth + // query is cached across subsequent fetches. + await gasFeeController.fetchGasFeeEstimates(); + await gasFeeController.fetchGasFeeEstimates(); + expect(getProvider).toHaveBeenCalledTimes(1); + const ethQueryBeforeChange = + mockedDetermineGasFeeCalculations.mock.lastCall?.[0].ethQuery; + + // Simulate the network switching to Sepolia. + rootMessenger.publish('NetworkController:networkDidChange', { + selectedNetworkClientId: 'sepolia', + } as NetworkState); + await flushPromises(); + + // The next fetch rebuilds the eth query from the provider. + await gasFeeController.fetchGasFeeEstimates(); + expect(getProvider).toHaveBeenCalledTimes(2); + const ethQueryAfterChange = + mockedDetermineGasFeeCalculations.mock.lastCall?.[0].ethQuery; + expect(ethQueryAfterChange).not.toBe(ethQueryBeforeChange); + }); + }); + + describe('metadata', () => { + beforeEach(async () => { + await setupGasFeeController(); + }); + + it('includes expected state in debug snapshots', () => { + expect( + deriveStateFromMetadata( + gasFeeController.state, + gasFeeController.metadata, + 'includeInDebugSnapshot', + ), + ).toMatchInlineSnapshot(`{}`); + }); + + it('includes expected state in state logs', () => { + expect( + deriveStateFromMetadata( + gasFeeController.state, + gasFeeController.metadata, + 'includeInStateLogs', + ), + ).toMatchInlineSnapshot(` + { + "estimatedGasFeeTimeBounds": {}, + "gasEstimateType": "none", + "gasFeeEstimates": {}, + "gasFeeEstimatesByChainId": {}, + "nonRPCGasFeeApisDisabled": false, + } + `); + }); + + it('persists expected state', () => { + expect( + deriveStateFromMetadata( + gasFeeController.state, + gasFeeController.metadata, + 'persist', + ), + ).toMatchInlineSnapshot(` + { + "estimatedGasFeeTimeBounds": {}, + "gasEstimateType": "none", + "gasFeeEstimates": {}, + "gasFeeEstimatesByChainId": {}, + "nonRPCGasFeeApisDisabled": false, + } + `); + }); + + it('exposes expected state to UI', () => { + expect( + deriveStateFromMetadata( + gasFeeController.state, + gasFeeController.metadata, + 'usedInUi', + ), + ).toMatchInlineSnapshot(` + { + "estimatedGasFeeTimeBounds": {}, + "gasEstimateType": "none", + "gasFeeEstimates": {}, + "gasFeeEstimatesByChainId": {}, + } + `); + }); + }); }); diff --git a/packages/gas-fee-controller/src/GasFeeController.ts b/packages/gas-fee-controller/src/GasFeeController.ts index 601428be8c4..987430af0ee 100644 --- a/packages/gas-fee-controller/src/GasFeeController.ts +++ b/packages/gas-fee-controller/src/GasFeeController.ts @@ -1,27 +1,36 @@ -import type { RestrictedControllerMessenger } from '@metamask/base-controller'; -import { convertHexToDecimal, safelyExecute } from '@metamask/controller-utils'; +import type { + ControllerGetStateAction, + ControllerStateChangeEvent, + StateMetadata, +} from '@metamask/base-controller'; +import { + convertHexToDecimal, + safelyExecute, + toHex, +} from '@metamask/controller-utils'; import EthQuery from '@metamask/eth-query'; +import type { Messenger } from '@metamask/messenger'; import type { + NetworkClientId, NetworkControllerGetEIP1559CompatibilityAction, NetworkControllerGetNetworkClientByIdAction, NetworkControllerGetStateAction, - NetworkControllerStateChangeEvent, + NetworkControllerNetworkDidChangeEvent, NetworkState, ProviderProxy, } from '@metamask/network-controller'; -import { PollingController } from '@metamask/polling-controller'; +import { StaticIntervalPollingController } from '@metamask/polling-controller'; import type { Hex } from '@metamask/utils'; -import type { Patch } from 'immer'; import { v1 as random } from 'uuid'; -import determineGasFeeCalculations from './determineGasFeeCalculations'; -import fetchGasEstimatesViaEthFeeHistory from './fetchGasEstimatesViaEthFeeHistory'; +import determineGasFeeCalculations from './determineGasFeeCalculations.js'; import { fetchGasEstimates, fetchLegacyGasPriceEstimates, fetchEthGasPriceEstimate, calculateTimeEstimate, -} from './gas-util'; +} from './gas-util.js'; +import type { GasFeeControllerMethodActions } from './GasFeeController-method-action-types.js'; export const LEGACY_GAS_PRICES_API_URL = `https://api.metaswap.codefi.network/gasPrices`; @@ -73,6 +82,7 @@ export type EstimatedGasFeeTimeBounds = { * A single gas price estimate for networks and accounts that don't support EIP-1559 * This estimate comes from eth_gasPrice but is converted to dec gwei to match other * return values + * * @property gasPrice - A GWEI dec string */ @@ -86,6 +96,7 @@ export type EthGasPriceEstimate = { * A set of gas price estimates for networks and accounts that don't support EIP-1559 * These estimates include low, medium and high all as strings representing gwei in * decimal format. + * * @property high - gasPrice, in decimal gwei string format, suggested for fast inclusion * @property medium - gasPrice, in decimal gwei string format, suggested for avg inclusion * @property low - gasPrice, in decimal gwei string format, suggested for slow inclusion @@ -100,6 +111,7 @@ export type LegacyGasPriceEstimate = { * @type Eip1559GasFee * * Data necessary to provide an estimate of a gas fee with a specific tip + * * @property minWaitTimeEstimate - The fastest the transaction will take, in milliseconds * @property maxWaitTimeEstimate - The slowest the transaction will take, in milliseconds * @property suggestedMaxPriorityFeePerGas - A suggested "tip", a GWEI hex number @@ -116,6 +128,7 @@ export type Eip1559GasFee = { * @type GasFeeEstimates * * Data necessary to provide multiple GasFee estimates, and supporting information, to the user + * * @property low - A GasFee for a minimum necessary combination of tip and maxFee * @property medium - A GasFee for a recommended combination of tip and maxFee * @property high - A GasFee for a high combination of tip and maxFee @@ -151,14 +164,37 @@ type FallbackGasFeeEstimates = { networkCongestion: null; }; -const metadata = { +const metadata: StateMetadata = { gasFeeEstimatesByChainId: { + includeInStateLogs: true, + persist: true, + includeInDebugSnapshot: false, + usedInUi: true, + }, + gasFeeEstimates: { + includeInStateLogs: true, + persist: true, + includeInDebugSnapshot: false, + usedInUi: true, + }, + estimatedGasFeeTimeBounds: { + includeInStateLogs: true, + persist: true, + includeInDebugSnapshot: false, + usedInUi: true, + }, + gasEstimateType: { + includeInStateLogs: true, + persist: true, + includeInDebugSnapshot: false, + usedInUi: true, + }, + nonRPCGasFeeApisDisabled: { + includeInStateLogs: true, persist: true, - anonymous: false, + includeInDebugSnapshot: false, + usedInUi: false, }, - gasFeeEstimates: { persist: true, anonymous: false }, - estimatedGasFeeTimeBounds: { persist: true, anonymous: false }, - gasEstimateType: { persist: true, anonymous: false }, }; export type GasFeeStateEthGasPrice = { @@ -187,12 +223,14 @@ export type GasFeeStateNoEstimates = { export type FetchGasFeeEstimateOptions = { shouldUpdateState?: boolean; + networkClientId?: NetworkClientId; }; /** * @type GasFeeState * * Gas Fee controller state + * * @property gasFeeEstimates - Gas fee estimate data based on new EIP-1559 properties * @property estimatedGasFeeTimeBounds - Estimates representing the minimum and maximum */ @@ -206,31 +244,46 @@ export type GasFeeEstimatesByChainId = { gasFeeEstimatesByChainId?: Record; }; -export type GasFeeState = GasFeeEstimatesByChainId & SingleChainGasFeeState; +export type GasFeeState = GasFeeEstimatesByChainId & + SingleChainGasFeeState & { + nonRPCGasFeeApisDisabled?: boolean; + }; const name = 'GasFeeController'; -export type GasFeeStateChange = { - type: `${typeof name}:stateChange`; - payload: [GasFeeState, Patch[]]; -}; +const MESSENGER_EXPOSED_METHODS = [ + 'disableNonRPCGasFeeApis', + 'disconnectPoller', + 'enableNonRPCGasFeeApis', + 'fetchGasFeeEstimates', + 'getGasFeeEstimatesAndStartPolling', + 'getTimeEstimate', + 'resetPolling', + 'stopPolling', +] as const; + +export type GasFeeStateChange = ControllerStateChangeEvent< + typeof name, + GasFeeState +>; -export type GetGasFeeState = { - type: `${typeof name}:getState`; - handler: () => GasFeeState; -}; +export type GetGasFeeState = ControllerGetStateAction; -type GasFeeMessenger = RestrictedControllerMessenger< - typeof name, +export type GasFeeControllerActions = | GetGasFeeState + | GasFeeControllerMethodActions; + +export type GasFeeControllerEvents = GasFeeStateChange; + +type AllowedActions = | NetworkControllerGetStateAction | NetworkControllerGetNetworkClientByIdAction - | NetworkControllerGetEIP1559CompatibilityAction, - GasFeeStateChange | NetworkControllerStateChangeEvent, - | NetworkControllerGetStateAction['type'] - | NetworkControllerGetNetworkClientByIdAction['type'] - | NetworkControllerGetEIP1559CompatibilityAction['type'], - NetworkControllerStateChangeEvent['type'] + | NetworkControllerGetEIP1559CompatibilityAction; + +export type GasFeeMessenger = Messenger< + typeof name, + GasFeeControllerActions | AllowedActions, + GasFeeControllerEvents | NetworkControllerNetworkDidChangeEvent >; const defaultState: GasFeeState = { @@ -238,12 +291,18 @@ const defaultState: GasFeeState = { gasFeeEstimates: {}, estimatedGasFeeTimeBounds: {}, gasEstimateType: GAS_ESTIMATE_TYPES.NONE, + nonRPCGasFeeApisDisabled: false, +}; + +/** The input to start polling for the {@link GasFeeController} */ +type GasFeePollingInput = { + networkClientId: NetworkClientId; }; /** * Controller that retrieves gas fee estimate data and polls for updated data on a set interval */ -export class GasFeeController extends PollingController< +export class GasFeeController extends StaticIntervalPollingController()< typeof name, GasFeeState, GasFeeMessenger @@ -264,13 +323,15 @@ export class GasFeeController extends PollingController< private readonly getCurrentAccountEIP1559Compatibility; - private currentChainId; + private currentChainId?: Hex; private ethQuery?: EthQuery; private readonly clientId?: string; - #getProvider: () => ProviderProxy; + readonly #getProvider: () => ProviderProxy; + + readonly #getChainId?: () => Hex; /** * Creates a GasFeeController instance. @@ -287,7 +348,7 @@ export class GasFeeController extends PollingController< * account is EIP-1559 compatible. * @param options.getChainId - Returns the current chain ID. * @param options.getProvider - Returns a network provider for the current network. - * @param options.onNetworkStateChange - A function for registering an event handler for the + * @param options.onNetworkDidChange - A function for registering an event handler for the * network state change event. * @param options.legacyAPIEndpoint - The legacy gas price API URL. This option is primarily for * testing purposes. @@ -304,7 +365,7 @@ export class GasFeeController extends PollingController< getChainId, getCurrentNetworkLegacyGasAPICompatibility, getProvider, - onNetworkStateChange, + onNetworkDidChange, legacyAPIEndpoint = LEGACY_GAS_PRICES_API_URL, EIP1559APIEndpoint, clientId, @@ -317,7 +378,7 @@ export class GasFeeController extends PollingController< getCurrentAccountEIP1559Compatibility?: () => boolean; getChainId?: () => Hex; getProvider: () => ProviderProxy; - onNetworkStateChange?: (listener: (state: NetworkState) => void) => void; + onNetworkDidChange?: (listener: (state: NetworkState) => void) => void; legacyAPIEndpoint?: string; EIP1559APIEndpoint: string; clientId?: string; @@ -342,27 +403,34 @@ export class GasFeeController extends PollingController< this.legacyAPIEndpoint = legacyAPIEndpoint; this.clientId = clientId; - // @ts-expect-error TODO: Provider type alignment - this.ethQuery = new EthQuery(this.#getProvider()); + this.messenger.registerMethodActionHandlers( + this, + MESSENGER_EXPOSED_METHODS, + ); - if (onNetworkStateChange && getChainId) { - this.currentChainId = getChainId(); - onNetworkStateChange(async (networkControllerState) => { - await this.#onNetworkControllerStateChange(networkControllerState); + if (onNetworkDidChange && getChainId) { + this.#getChainId = getChainId; + // TODO: Either fix this lint violation or explain why it's necessary to ignore. + // eslint-disable-next-line @typescript-eslint/no-misused-promises + onNetworkDidChange(async (networkControllerState) => { + await this.#onNetworkControllerDidChange(networkControllerState); }); } else { - this.currentChainId = this.messagingSystem.call( - 'NetworkController:getState', - ).providerConfig.chainId; - this.messagingSystem.subscribe( - 'NetworkController:stateChange', + this.messenger.subscribe( + 'NetworkController:networkDidChange', + // TODO: Either fix this lint violation or explain why it's necessary to ignore. + // eslint-disable-next-line @typescript-eslint/no-misused-promises async (networkControllerState) => { - await this.#onNetworkControllerStateChange(networkControllerState); + await this.#onNetworkControllerDidChange(networkControllerState); }, ); } } + /** + * Resets the polling interval by stopping and restarting polling + * with the existing poll tokens. + */ async resetPolling() { if (this.pollTokens.size !== 0) { const tokens = Array.from(this.pollTokens); @@ -374,10 +442,23 @@ export class GasFeeController extends PollingController< } } + /** + * Fetches gas fee estimates. + * + * @param options - The gas fee estimate options. + * @returns The gas fee estimates. + */ async fetchGasFeeEstimates(options?: FetchGasFeeEstimateOptions) { return await this._fetchGasFeeEstimateData(options); } + /** + * Gets gas fee estimates and starts polling for updates. + * + * @param pollToken - An existing poll token to reuse, or undefined to + * generate a new one. + * @returns The poll token that can be used to stop polling. + */ async getGasFeeEstimatesAndStartPolling( pollToken: string | undefined, ): Promise { @@ -393,63 +474,6 @@ export class GasFeeController extends PollingController< return _pollToken; } - async #fetchGasFeeEstimateForNetworkClientId(networkClientId: string) { - let isEIP1559Compatible = false; - - const networkClient = this.messagingSystem.call( - 'NetworkController:getNetworkClientById', - networkClientId, - ); - const isLegacyGasAPICompatible = - networkClient.configuration.chainId === '0x38'; - - const decimalChainId = convertHexToDecimal( - networkClient.configuration.chainId, - ); - - try { - const result = await this.messagingSystem.call( - 'NetworkController:getEIP1559Compatibility', - networkClientId, - ); - isEIP1559Compatible = result || false; - } catch { - isEIP1559Compatible = false; - } - - // @ts-expect-error TODO: Provider type alignment - const ethQuery = new EthQuery(networkClient.provider); - - const gasFeeCalculations = await determineGasFeeCalculations({ - isEIP1559Compatible, - isLegacyGasAPICompatible, - fetchGasEstimates, - fetchGasEstimatesUrl: this.EIP1559APIEndpoint.replace( - '', - `${decimalChainId}`, - ), - fetchGasEstimatesViaEthFeeHistory, - fetchLegacyGasPriceEstimates, - fetchLegacyGasPriceEstimatesUrl: this.legacyAPIEndpoint.replace( - '', - `${decimalChainId}`, - ), - fetchEthGasPriceEstimate, - calculateTimeEstimate, - clientId: this.clientId, - ethQuery, - }); - - this.update((state) => { - state.gasFeeEstimatesByChainId = state.gasFeeEstimatesByChainId || {}; - state.gasFeeEstimatesByChainId[networkClient.configuration.chainId] = { - gasFeeEstimates: gasFeeCalculations.gasFeeEstimates, - estimatedGasFeeTimeBounds: gasFeeCalculations.estimatedGasFeeTimeBounds, - gasEstimateType: gasFeeCalculations.gasEstimateType, - } as any; - }); - } - /** * Gets and sets gasFeeEstimates in state. * @@ -461,18 +485,46 @@ export class GasFeeController extends PollingController< async _fetchGasFeeEstimateData( options: FetchGasFeeEstimateOptions = {}, ): Promise { - const { shouldUpdateState = true } = options; - let isEIP1559Compatible; - const isLegacyGasAPICompatible = + const { shouldUpdateState = true, networkClientId } = options; + + let ethQuery, + isEIP1559Compatible, + isLegacyGasAPICompatible, + decimalChainId: number; + + if (networkClientId !== undefined) { + const networkClient = this.messenger.call( + 'NetworkController:getNetworkClientById', + networkClientId, + ); + isLegacyGasAPICompatible = networkClient.configuration.chainId === '0x38'; + + decimalChainId = convertHexToDecimal(networkClient.configuration.chainId); + + try { + const result = await this.messenger.call( + 'NetworkController:getEIP1559Compatibility', + networkClientId, + ); + isEIP1559Compatible = result || false; + } catch { + isEIP1559Compatible = false; + } + ethQuery = new EthQuery(networkClient.provider); + } + + ethQuery ??= this.#getEthQuery(); + + isLegacyGasAPICompatible ??= this.getCurrentNetworkLegacyGasAPICompatibility(); - const decimalChainId = convertHexToDecimal(this.currentChainId); + decimalChainId ??= convertHexToDecimal(this.#getCurrentChainId()); try { - isEIP1559Compatible = await this.getEIP1559Compatibility(); + isEIP1559Compatible ??= await this.getEIP1559Compatibility(); } catch (e) { console.error(e); - isEIP1559Compatible = false; + isEIP1559Compatible ??= false; } const gasFeeCalculations = await determineGasFeeCalculations({ @@ -483,7 +535,6 @@ export class GasFeeController extends PollingController< '', `${decimalChainId}`, ), - fetchGasEstimatesViaEthFeeHistory, fetchLegacyGasPriceEstimates, fetchLegacyGasPriceEstimatesUrl: this.legacyAPIEndpoint.replace( '', @@ -492,15 +543,27 @@ export class GasFeeController extends PollingController< fetchEthGasPriceEstimate, calculateTimeEstimate, clientId: this.clientId, - ethQuery: this.ethQuery, + ethQuery, + nonRPCGasFeeApisDisabled: this.state.nonRPCGasFeeApisDisabled, }); if (shouldUpdateState) { + const chainId = toHex(decimalChainId); + const currentChainId = this.#getCurrentChainId(); this.update((state) => { - state.gasFeeEstimates = gasFeeCalculations.gasFeeEstimates; - state.estimatedGasFeeTimeBounds = - gasFeeCalculations.estimatedGasFeeTimeBounds; - state.gasEstimateType = gasFeeCalculations.gasEstimateType; + if (currentChainId === chainId) { + state.gasFeeEstimates = gasFeeCalculations.gasFeeEstimates; + state.estimatedGasFeeTimeBounds = + gasFeeCalculations.estimatedGasFeeTimeBounds; + state.gasEstimateType = gasFeeCalculations.gasEstimateType; + } + state.gasFeeEstimatesByChainId ??= {}; + state.gasFeeEstimatesByChainId[chainId] = { + gasFeeEstimates: gasFeeCalculations.gasFeeEstimates, + estimatedGasFeeTimeBounds: + gasFeeCalculations.estimatedGasFeeTimeBounds, + gasEstimateType: gasFeeCalculations.gasEstimateType, + } as SingleChainGasFeeState; }); } @@ -519,6 +582,9 @@ export class GasFeeController extends PollingController< } } + /** + * Stops polling for gas fee estimates and clears all poll tokens. + */ stopPolling() { if (this.intervalId) { clearInterval(this.intervalId); @@ -542,6 +608,8 @@ export class GasFeeController extends PollingController< clearInterval(this.intervalId); } + // TODO: Either fix this lint violation or explain why it's necessary to ignore. + // eslint-disable-next-line @typescript-eslint/no-misused-promises this.intervalId = setInterval(async () => { await safelyExecute(() => this._fetchGasFeeEstimateData()); }, this.intervalDelay); @@ -550,12 +618,12 @@ export class GasFeeController extends PollingController< /** * Fetching token list from the Token Service API. * - * @private - * @param networkClientId - The ID of the network client triggering the fetch. + * @param input - The input for the poll. + * @param input.networkClientId - The ID of the network client triggering the fetch. * @returns A promise that resolves when this operation completes. */ - async _executePoll(networkClientId: string): Promise { - await this.#fetchGasFeeEstimateForNetworkClientId(networkClientId); + async _executePoll({ networkClientId }: GasFeePollingInput): Promise { + await this._fetchGasFeeEstimateData({ networkClientId }); } private resetState() { @@ -575,6 +643,14 @@ export class GasFeeController extends PollingController< ); } + /** + * Gets the estimated time for a transaction based on the given gas parameters. + * + * @param maxPriorityFeePerGas - The maximum priority fee per gas in GWEI. + * @param maxFeePerGas - The maximum fee per gas in GWEI. + * @returns The estimated time bounds, or an empty object if fee market + * estimates are not available. + */ getTimeEstimate( maxPriorityFeePerGas: string, maxFeePerGas: string, @@ -592,17 +668,58 @@ export class GasFeeController extends PollingController< ); } - async #onNetworkControllerStateChange(networkControllerState: NetworkState) { - const newChainId = networkControllerState.providerConfig.chainId; + async #onNetworkControllerDidChange({ + selectedNetworkClientId, + }: NetworkState) { + const newChainId = this.#getChainIdForNetworkClient( + selectedNetworkClientId, + ); if (newChainId !== this.currentChainId) { - // @ts-expect-error TODO: Provider type alignment - this.ethQuery = new EthQuery(this.#getProvider()); + // Reset so the next fetch rebuilds it from the new network's provider. + this.ethQuery = undefined; await this.resetPolling(); this.currentChainId = newChainId; } } + + #getEthQuery(): EthQuery { + this.ethQuery ??= new EthQuery(this.#getProvider()); + return this.ethQuery; + } + + #getCurrentChainId(): Hex { + this.currentChainId ??= + this.#getChainId?.() ?? this.#getChainIdFromNetworkController(); + return this.currentChainId; + } + + #getChainIdFromNetworkController(): Hex { + const { selectedNetworkClientId } = this.messenger.call( + 'NetworkController:getState', + ); + return this.#getChainIdForNetworkClient(selectedNetworkClientId); + } + + #getChainIdForNetworkClient(networkClientId: NetworkClientId): Hex { + return this.messenger.call( + 'NetworkController:getNetworkClientById', + networkClientId, + ).configuration.chainId; + } + + enableNonRPCGasFeeApis() { + this.update((state) => { + state.nonRPCGasFeeApisDisabled = false; + }); + } + + disableNonRPCGasFeeApis() { + this.update((state) => { + state.nonRPCGasFeeApisDisabled = true; + }); + } } export default GasFeeController; diff --git a/packages/gas-fee-controller/src/determineGasFeeCalculations.test.ts b/packages/gas-fee-controller/src/determineGasFeeCalculations.test.ts index 1bff2582a7f..deaf4f27aed 100644 --- a/packages/gas-fee-controller/src/determineGasFeeCalculations.test.ts +++ b/packages/gas-fee-controller/src/determineGasFeeCalculations.test.ts @@ -1,21 +1,19 @@ -import determineGasFeeCalculations from './determineGasFeeCalculations'; -import fetchGasEstimatesViaEthFeeHistory from './fetchGasEstimatesViaEthFeeHistory'; +import determineGasFeeCalculations from './determineGasFeeCalculations.js'; import { fetchGasEstimates, fetchLegacyGasPriceEstimates, fetchEthGasPriceEstimate, calculateTimeEstimate, -} from './gas-util'; +} from './gas-util.js'; import type { unknownString, GasFeeEstimates, LegacyGasPriceEstimate, EthGasPriceEstimate, EstimatedGasFeeTimeBounds, -} from './GasFeeController'; +} from './GasFeeController.js'; jest.mock('./gas-util'); -jest.mock('./fetchGasEstimatesViaEthFeeHistory'); const mockedFetchGasEstimates = fetchGasEstimates as jest.Mock< ReturnType, @@ -34,11 +32,6 @@ const mockedCalculateTimeEstimate = calculateTimeEstimate as jest.Mock< ReturnType, Parameters >; -const mockedFetchGasEstimatesViaEthFeeHistory = - fetchGasEstimatesViaEthFeeHistory as jest.Mock< - ReturnType, - Parameters - >; /** * Builds mock data for the `fetchGasEstimates` function. All of the data here is filled in to make @@ -124,7 +117,6 @@ describe('determineGasFeeCalculations', () => { isEIP1559Compatible: false, isLegacyGasAPICompatible: false, fetchGasEstimates: mockedFetchGasEstimates, - fetchGasEstimatesViaEthFeeHistory: mockedFetchGasEstimatesViaEthFeeHistory, fetchGasEstimatesUrl: 'http://doesnt-matter', fetchLegacyGasPriceEstimates: mockedFetchLegacyGasPriceEstimates, fetchLegacyGasPriceEstimatesUrl: 'http://doesnt-matter', @@ -141,6 +133,7 @@ describe('determineGasFeeCalculations', () => { isEIP1559Compatible: true, isLegacyGasAPICompatible: false, }); + mockedFetchGasEstimates.mockReset(); }); describe('assuming neither fetchGasEstimates nor calculateTimeEstimate throw errors', () => { @@ -161,110 +154,78 @@ describe('determineGasFeeCalculations', () => { }); }); - describe('when fetchGasEstimates throws an error', () => { - beforeEach(() => { - mockedFetchGasEstimates.mockImplementation(() => { - throw new Error('Some API failure'); - }); - }); + describe('when nonRPCGasFeeApisDisabled is true', () => { + describe('assuming fetchEthGasPriceEstimate does not throw an error', () => { + it('returns the fetched fee estimates and an empty set of time estimates', async () => { + const gasFeeEstimates = buildMockDataForFetchEthGasPriceEstimate(); + mockedFetchEthGasPriceEstimate.mockResolvedValue(gasFeeEstimates); - describe('assuming neither fetchGasEstimatesViaEthFeeHistory nor calculateTimeEstimate throws errors', () => { - it('returns a combination of the fetched fee and time estimates', async () => { - const gasFeeEstimates = buildMockDataForFetchGasEstimates(); - mockedFetchGasEstimatesViaEthFeeHistory.mockResolvedValue( - gasFeeEstimates, - ); - const estimatedGasFeeTimeBounds = - buildMockDataForCalculateTimeEstimate(); - mockedCalculateTimeEstimate.mockReturnValue( - estimatedGasFeeTimeBounds, - ); + const gasFeeCalculations = await determineGasFeeCalculations({ + ...options, + nonRPCGasFeeApisDisabled: true, + }); - const gasFeeCalculations = await determineGasFeeCalculations(options); + expect(mockedFetchGasEstimates).toHaveBeenCalledTimes(0); expect(gasFeeCalculations).toStrictEqual({ gasFeeEstimates, - estimatedGasFeeTimeBounds, - gasEstimateType: 'fee-market', + estimatedGasFeeTimeBounds: {}, + gasEstimateType: 'eth_gasPrice', }); }); }); - describe('when fetchGasEstimatesViaEthFeeHistory throws an error', () => { - beforeEach(() => { - mockedFetchGasEstimatesViaEthFeeHistory.mockImplementation(() => { - throw new Error('Some API failure'); + describe('when fetchEthGasPriceEstimate throws an error', () => { + it('throws an error that wraps that error', async () => { + mockedFetchEthGasPriceEstimate.mockImplementation(() => { + throw new Error('fetchEthGasPriceEstimate failed'); }); - }); - - describe('assuming fetchEthGasPriceEstimate does not throw an error', () => { - it('returns the fetched fee estimates and an empty set of time estimates', async () => { - const gasFeeEstimates = buildMockDataForFetchEthGasPriceEstimate(); - mockedFetchEthGasPriceEstimate.mockResolvedValue(gasFeeEstimates); - const gasFeeCalculations = await determineGasFeeCalculations( - options, - ); - - expect(gasFeeCalculations).toStrictEqual({ - gasFeeEstimates, - estimatedGasFeeTimeBounds: {}, - gasEstimateType: 'eth_gasPrice', - }); + const promise = determineGasFeeCalculations({ + ...options, + nonRPCGasFeeApisDisabled: true, }); - }); - - describe('when fetchEthGasPriceEstimate throws an error', () => { - it('throws an error that wraps that error', async () => { - mockedFetchEthGasPriceEstimate.mockImplementation(() => { - throw new Error('fetchEthGasPriceEstimate failed'); - }); - const promise = determineGasFeeCalculations(options); - - await expect(promise).rejects.toThrow( - 'Gas fee/price estimation failed. Message: fetchEthGasPriceEstimate failed', - ); - }); + await expect(promise).rejects.toThrow( + 'Gas fee/price estimation failed. Message: fetchEthGasPriceEstimate failed', + ); }); }); + }); - describe('when fetchGasEstimatesViaEthFeeHistory does not throw an error, but calculateTimeEstimate throws an error', () => { - beforeEach(() => { - mockedCalculateTimeEstimate.mockImplementation(() => { - throw new Error('Some API failure'); - }); + describe('when fetchGasEstimates throws an error', () => { + beforeEach(() => { + mockedFetchGasEstimates.mockImplementation(() => { + throw new Error('Some API failure'); }); + }); - describe('assuming fetchEthGasPriceEstimate does not throw an error', () => { - it('returns the fetched fee estimates and an empty set of time estimates', async () => { - const gasFeeEstimates = buildMockDataForFetchEthGasPriceEstimate(); - mockedFetchEthGasPriceEstimate.mockResolvedValue(gasFeeEstimates); + describe('assuming fetchEthGasPriceEstimate does not throw an error', () => { + it('returns the fetched fee estimates and an empty set of time estimates', async () => { + const gasFeeEstimates = buildMockDataForFetchEthGasPriceEstimate(); + mockedFetchEthGasPriceEstimate.mockResolvedValue(gasFeeEstimates); - const gasFeeCalculations = await determineGasFeeCalculations( - options, - ); + const gasFeeCalculations = await determineGasFeeCalculations(options); - expect(gasFeeCalculations).toStrictEqual({ - gasFeeEstimates, - estimatedGasFeeTimeBounds: {}, - gasEstimateType: 'eth_gasPrice', - }); + expect(gasFeeCalculations).toStrictEqual({ + gasFeeEstimates, + estimatedGasFeeTimeBounds: {}, + gasEstimateType: 'eth_gasPrice', }); }); + }); - describe('when fetchEthGasPriceEstimate throws an error', () => { - it('throws an error that wraps that error', async () => { - mockedFetchEthGasPriceEstimate.mockImplementation(() => { - throw new Error('fetchEthGasPriceEstimate failed'); - }); + describe('when fetchEthGasPriceEstimate throws an error', () => { + it('throws an error that wraps that error', async () => { + mockedFetchEthGasPriceEstimate.mockImplementation(() => { + throw new Error('fetchEthGasPriceEstimate failed'); + }); - const promise = determineGasFeeCalculations(options); + const promise = determineGasFeeCalculations(options); - await expect(promise).rejects.toThrow( - 'Gas fee/price estimation failed. Message: fetchEthGasPriceEstimate failed', - ); - }); + await expect(promise).rejects.toThrow( + 'Gas fee/price estimation failed. Message: fetchEthGasPriceEstimate failed', + ); }); }); }); @@ -315,6 +276,7 @@ describe('determineGasFeeCalculations', () => { fetchLegacyGasPriceEstimatesUrl: 'http://some-legacy-gas-price-estimates-url', }); + mockedFetchLegacyGasPriceEstimates.mockReset(); }); describe('assuming fetchLegacyGasPriceEstimates does not throw an error', () => { @@ -332,6 +294,28 @@ describe('determineGasFeeCalculations', () => { }); }); + describe('when nonRPCGasFeeApisDisabled is true', () => { + describe('assuming fetchEthGasPriceEstimate does not throw an error', () => { + it('returns the fetched fee estimates and an empty set of time estimates', async () => { + const gasFeeEstimates = buildMockDataForFetchEthGasPriceEstimate(); + mockedFetchEthGasPriceEstimate.mockResolvedValue(gasFeeEstimates); + + const gasFeeCalculations = await determineGasFeeCalculations({ + ...options, + nonRPCGasFeeApisDisabled: true, + }); + + expect(mockedFetchLegacyGasPriceEstimates).toHaveBeenCalledTimes(0); + + expect(gasFeeCalculations).toStrictEqual({ + gasFeeEstimates, + estimatedGasFeeTimeBounds: {}, + gasEstimateType: 'eth_gasPrice', + }); + }); + }); + }); + describe('when fetchLegacyGasPriceEstimates throws an error', () => { beforeEach(() => { mockedFetchLegacyGasPriceEstimates.mockImplementation(() => { diff --git a/packages/gas-fee-controller/src/determineGasFeeCalculations.ts b/packages/gas-fee-controller/src/determineGasFeeCalculations.ts index dfcafd4fc22..3d8382b451b 100644 --- a/packages/gas-fee-controller/src/determineGasFeeCalculations.ts +++ b/packages/gas-fee-controller/src/determineGasFeeCalculations.ts @@ -4,8 +4,36 @@ import type { GasFeeEstimates, GasFeeState as GasFeeCalculations, LegacyGasPriceEstimate, -} from './GasFeeController'; -import { GAS_ESTIMATE_TYPES } from './GasFeeController'; +} from './GasFeeController.js'; +import { GAS_ESTIMATE_TYPES } from './GasFeeController.js'; + +type DetermineGasFeeCalculationsRequest = { + isEIP1559Compatible: boolean; + isLegacyGasAPICompatible: boolean; + fetchGasEstimates: ( + url: string, + clientId?: string, + ) => Promise; + fetchGasEstimatesUrl: string; + fetchLegacyGasPriceEstimates: ( + url: string, + clientId?: string, + ) => Promise; + fetchLegacyGasPriceEstimatesUrl: string; + // TODO: Replace `any` with type + // eslint-disable-next-line @typescript-eslint/no-explicit-any + fetchEthGasPriceEstimate: (ethQuery: any) => Promise; + calculateTimeEstimate: ( + maxPriorityFeePerGas: string, + maxFeePerGas: string, + gasFeeEstimates: GasFeeEstimates, + ) => EstimatedGasFeeTimeBounds; + clientId: string | undefined; + // TODO: Replace `any` with type + // eslint-disable-next-line @typescript-eslint/no-explicit-any + ethQuery: any; + nonRPCGasFeeApisDisabled?: boolean; +}; /** * Obtains a set of max base and priority fee estimates along with time estimates so that we @@ -20,8 +48,6 @@ import { GAS_ESTIMATE_TYPES } from './GasFeeController'; * API. * @param args.fetchGasEstimatesUrl - The URL for the API we can use to obtain EIP-1559-specific * estimates. - * @param args.fetchGasEstimatesViaEthFeeHistory - A function that fetches gas estimates using - * `eth_feeHistory` (an EIP-1559 feature). * @param args.fetchLegacyGasPriceEstimates - A function that fetches gas estimates using an * non-EIP-1559-specific API. * @param args.fetchLegacyGasPriceEstimatesUrl - The URL for the API we can use to obtain @@ -31,92 +57,132 @@ import { GAS_ESTIMATE_TYPES } from './GasFeeController'; * @param args.calculateTimeEstimate - A function that determine time estimate bounds. * @param args.clientId - An identifier that an API can use to know who is asking for estimates. * @param args.ethQuery - An EthQuery instance we can use to talk to Ethereum directly. + * @param args.nonRPCGasFeeApisDisabled - Whether to disable requests to the legacyAPIEndpoint and the EIP1559APIEndpoint * @returns The gas fee calculations. */ -export default async function determineGasFeeCalculations({ - isEIP1559Compatible, - isLegacyGasAPICompatible, - fetchGasEstimates, - fetchGasEstimatesUrl, - fetchGasEstimatesViaEthFeeHistory, - fetchLegacyGasPriceEstimates, - fetchLegacyGasPriceEstimatesUrl, - fetchEthGasPriceEstimate, - calculateTimeEstimate, - clientId, - ethQuery, -}: { - isEIP1559Compatible: boolean; - isLegacyGasAPICompatible: boolean; - fetchGasEstimates: ( - url: string, - clientId?: string, - ) => Promise; - fetchGasEstimatesUrl: string; - fetchGasEstimatesViaEthFeeHistory: ( - ethQuery: any, - ) => Promise; - fetchLegacyGasPriceEstimates: ( - url: string, - clientId?: string, - ) => Promise; - fetchLegacyGasPriceEstimatesUrl: string; - fetchEthGasPriceEstimate: (ethQuery: any) => Promise; - calculateTimeEstimate: ( - maxPriorityFeePerGas: string, - maxFeePerGas: string, - gasFeeEstimates: GasFeeEstimates, - ) => EstimatedGasFeeTimeBounds; - clientId: string | undefined; - ethQuery: any; -}): Promise { +export default async function determineGasFeeCalculations( + args: DetermineGasFeeCalculationsRequest, +): Promise { try { - if (isEIP1559Compatible) { - let estimates: GasFeeEstimates; - try { - estimates = await fetchGasEstimates(fetchGasEstimatesUrl, clientId); - } catch { - estimates = await fetchGasEstimatesViaEthFeeHistory(ethQuery); - } - const { suggestedMaxPriorityFeePerGas, suggestedMaxFeePerGas } = - estimates.medium; - const estimatedGasFeeTimeBounds = calculateTimeEstimate( - suggestedMaxPriorityFeePerGas, - suggestedMaxFeePerGas, - estimates, - ); - return { - gasFeeEstimates: estimates, - estimatedGasFeeTimeBounds, - gasEstimateType: GAS_ESTIMATE_TYPES.FEE_MARKET, - }; - } else if (isLegacyGasAPICompatible) { - const estimates = await fetchLegacyGasPriceEstimates( - fetchLegacyGasPriceEstimatesUrl, - clientId, + return await getEstimatesUsingFallbacks(args); + } catch (error) { + if (error instanceof Error) { + throw new Error( + `Gas fee/price estimation failed. Message: ${error.message}`, ); - return { - gasFeeEstimates: estimates, - estimatedGasFeeTimeBounds: {}, - gasEstimateType: GAS_ESTIMATE_TYPES.LEGACY, - }; } + + throw error; + } +} + +/** + * Retrieve the gas fee estimates using a series of fallback mechanisms. + * + * @param request - The request object. + * @returns The gas fee estimates. + */ +async function getEstimatesUsingFallbacks( + request: DetermineGasFeeCalculationsRequest, +): Promise { + const { + isEIP1559Compatible, + isLegacyGasAPICompatible, + nonRPCGasFeeApisDisabled, + } = request; + + try { + if (isEIP1559Compatible && !nonRPCGasFeeApisDisabled) { + return await getEstimatesUsingFeeMarketEndpoint(request); + } + + if (isLegacyGasAPICompatible && !nonRPCGasFeeApisDisabled) { + return await getEstimatesUsingLegacyEndpoint(request); + } + throw new Error('Main gas fee/price estimation failed. Use fallback'); } catch { - try { - const estimates = await fetchEthGasPriceEstimate(ethQuery); - return { - gasFeeEstimates: estimates, - estimatedGasFeeTimeBounds: {}, - gasEstimateType: GAS_ESTIMATE_TYPES.ETH_GASPRICE, - }; - } catch (error) { - if (error instanceof Error) { - throw new Error( - `Gas fee/price estimation failed. Message: ${error.message}`, - ); - } - throw error; - } + return await getEstimatesUsingProvider(request); } } + +/** + * Retrieve gas fee estimates using the EIP-1559 endpoint of the gas API. + * + * @param request - The request object. + * @returns The gas fee estimates. + */ +async function getEstimatesUsingFeeMarketEndpoint( + request: DetermineGasFeeCalculationsRequest, +): Promise { + const { + fetchGasEstimates, + fetchGasEstimatesUrl, + clientId, + calculateTimeEstimate, + } = request; + + const estimates = await fetchGasEstimates(fetchGasEstimatesUrl, clientId); + + const { suggestedMaxPriorityFeePerGas, suggestedMaxFeePerGas } = + estimates.medium; + + const estimatedGasFeeTimeBounds = calculateTimeEstimate( + suggestedMaxPriorityFeePerGas, + suggestedMaxFeePerGas, + estimates, + ); + + return { + gasFeeEstimates: estimates, + estimatedGasFeeTimeBounds, + gasEstimateType: GAS_ESTIMATE_TYPES.FEE_MARKET, + }; +} + +/** + * Retrieve gas fee estimates using the legacy endpoint of the gas API. + * + * @param request - The request object. + * @returns The gas fee estimates. + */ +async function getEstimatesUsingLegacyEndpoint( + request: DetermineGasFeeCalculationsRequest, +): Promise { + const { + fetchLegacyGasPriceEstimates, + fetchLegacyGasPriceEstimatesUrl, + clientId, + } = request; + + const estimates = await fetchLegacyGasPriceEstimates( + fetchLegacyGasPriceEstimatesUrl, + clientId, + ); + + return { + gasFeeEstimates: estimates, + estimatedGasFeeTimeBounds: {}, + gasEstimateType: GAS_ESTIMATE_TYPES.LEGACY, + }; +} + +/** + * Retrieve gas fee estimates using an `eth_gasPrice` call to the RPC provider. + * + * @param request - The request object. + * @returns The gas fee estimates. + */ +async function getEstimatesUsingProvider( + request: DetermineGasFeeCalculationsRequest, +): Promise { + const { ethQuery, fetchEthGasPriceEstimate } = request; + + const estimates = await fetchEthGasPriceEstimate(ethQuery); + + return { + gasFeeEstimates: estimates, + estimatedGasFeeTimeBounds: {}, + gasEstimateType: GAS_ESTIMATE_TYPES.ETH_GASPRICE, + }; +} diff --git a/packages/gas-fee-controller/src/fetchBlockFeeHistory.test.ts b/packages/gas-fee-controller/src/fetchBlockFeeHistory.test.ts deleted file mode 100644 index 69554bec0ca..00000000000 --- a/packages/gas-fee-controller/src/fetchBlockFeeHistory.test.ts +++ /dev/null @@ -1,440 +0,0 @@ -import { query, fromHex, toHex } from '@metamask/controller-utils'; -import { BN } from 'ethereumjs-util'; -import { when } from 'jest-when'; - -import fetchBlockFeeHistory from './fetchBlockFeeHistory'; - -jest.mock('@metamask/controller-utils', () => { - return { - ...jest.requireActual('@metamask/controller-utils'), - __esModule: true, - query: jest.fn(), - }; -}); - -const mockedQuery = query as jest.Mock< - ReturnType, - Parameters ->; - -/** - * Calls the given function the given number of times, collecting the results from each call. - * - * @param n - The number of times you want to call the function. - * @param fn - The function to call. - * @returns An array of values gleaned from the results of each call to the function. - */ -function times(n: number, fn: (n: number) => T): T[] { - const values = []; - for (let i = 0; i < n; i++) { - values.push(fn(i)); - } - return values; -} - -describe('fetchBlockFeeHistory', () => { - const ethQuery = { eth: 'query' }; - - describe('with a minimal set of arguments', () => { - const latestBlockNumber = 3; - const numberOfRequestedBlocks = 3; - - beforeEach(() => { - when(mockedQuery) - // @ts-expect-error Mock eth query does not fulfill type requirements - .calledWith(ethQuery, 'blockNumber') - .mockResolvedValue(new BN(latestBlockNumber)); - }); - - it('should return a representation of fee history from the Ethereum network, organized by block rather than type of data', async () => { - when(mockedQuery) - // @ts-expect-error Mock eth query does not fulfill type requirements - .calledWith(ethQuery, 'eth_feeHistory', [ - toHex(numberOfRequestedBlocks), - toHex(latestBlockNumber), - [], - ]) - .mockResolvedValue({ - oldestBlock: toHex(1), - // Note that this array contains 4 items when the request was made for 3. Per - // , - // baseFeePerGas will always include an extra item which is the calculated base fee for the - // next (future) block. - baseFeePerGas: [ - toHex(10_000_000_000), - toHex(20_000_000_000), - toHex(30_000_000_000), - toHex(40_000_000_000), - ], - gasUsedRatio: [0.1, 0.2, 0.3], - }); - - const feeHistory = await fetchBlockFeeHistory({ - ethQuery, - numberOfBlocks: numberOfRequestedBlocks, - }); - - expect(feeHistory).toStrictEqual([ - { - number: fromHex(toHex(1)), - baseFeePerGas: fromHex(toHex(10_000_000_000)), - gasUsedRatio: 0.1, - priorityFeesByPercentile: {}, - }, - { - number: fromHex(toHex(2)), - baseFeePerGas: fromHex(toHex(20_000_000_000)), - gasUsedRatio: 0.2, - priorityFeesByPercentile: {}, - }, - { - number: fromHex(toHex(3)), - baseFeePerGas: fromHex(toHex(30_000_000_000)), - gasUsedRatio: 0.3, - priorityFeesByPercentile: {}, - }, - ]); - }); - - it('should be able to handle an "empty" response from eth_feeHistory', async () => { - when(mockedQuery) - // @ts-expect-error Mock eth query does not fulfill type requirements - .calledWith(ethQuery, 'eth_feeHistory', [ - toHex(numberOfRequestedBlocks), - toHex(latestBlockNumber), - [], - ]) - .mockResolvedValue({ - oldestBlock: toHex(0), - baseFeePerGas: [], - gasUsedRatio: [], - }); - - const feeHistory = await fetchBlockFeeHistory({ - ethQuery, - numberOfBlocks: numberOfRequestedBlocks, - }); - - expect(feeHistory).toStrictEqual([]); - }); - - it('should be able to handle an response with undefined baseFeePerGas from eth_feeHistory', async () => { - when(mockedQuery) - // @ts-expect-error Mock eth query does not fulfill type requirements - .calledWith(ethQuery, 'eth_feeHistory', [ - toHex(numberOfRequestedBlocks), - toHex(latestBlockNumber), - [], - ]) - .mockResolvedValue({ - oldestBlock: toHex(0), - gasUsedRatio: null, - }); - - const feeHistory = await fetchBlockFeeHistory({ - ethQuery, - numberOfBlocks: numberOfRequestedBlocks, - }); - - expect(feeHistory).toStrictEqual([]); - }); - }); - - describe('given a numberOfBlocks that exceeds the max limit that the EVM returns', () => { - it('divides the number into chunks and calls eth_feeHistory for each chunk', async () => { - const latestBlockNumber = 2348; - const numberOfRequestedBlocks = 2348; - const expectedChunks = [ - { startBlockNumber: 1, endBlockNumber: 1024 }, - { startBlockNumber: 1025, endBlockNumber: 2048 }, - { startBlockNumber: 2049, endBlockNumber: 2348 }, - ]; - const expectedBlocks = times(numberOfRequestedBlocks, (i) => { - return { - number: i + 1, - baseFeePerGas: toHex(1_000_000_000 * (i + 1)), - gasUsedRatio: (i + 1) / numberOfRequestedBlocks, - }; - }); - - when(mockedQuery) - // @ts-expect-error Mock eth query does not fulfill type requirements - .calledWith(ethQuery, 'blockNumber') - .mockResolvedValue(new BN(latestBlockNumber)); - - expectedChunks.forEach(({ startBlockNumber, endBlockNumber }) => { - const baseFeePerGas = expectedBlocks - .slice(startBlockNumber - 1, endBlockNumber + 1) - .map((block) => block.baseFeePerGas); - const gasUsedRatio = expectedBlocks - .slice(startBlockNumber - 1, endBlockNumber) - .map((block) => block.gasUsedRatio); - - when(mockedQuery) - // @ts-expect-error Mock eth query does not fulfill type requirements - .calledWith(ethQuery, 'eth_feeHistory', [ - toHex(endBlockNumber - startBlockNumber + 1), - toHex(endBlockNumber), - [], - ]) - .mockResolvedValue({ - oldestBlock: toHex(startBlockNumber), - baseFeePerGas, - gasUsedRatio, - }); - }); - - const feeHistory = await fetchBlockFeeHistory({ - ethQuery, - numberOfBlocks: numberOfRequestedBlocks, - }); - - expect(feeHistory).toStrictEqual( - expectedBlocks.map((block) => { - return { - number: fromHex(toHex(block.number)), - baseFeePerGas: fromHex(block.baseFeePerGas), - gasUsedRatio: block.gasUsedRatio, - priorityFeesByPercentile: {}, - }; - }), - ); - }); - }); - - describe('given an endBlock of a BN', () => { - it('should pass it to the eth_feeHistory call', async () => { - const latestBlockNumber = 3; - const numberOfRequestedBlocks = 3; - const endBlock = new BN(latestBlockNumber); - when(mockedQuery) - // @ts-expect-error Mock eth query does not fulfill type requirements - .calledWith(ethQuery, 'eth_feeHistory', [ - toHex(numberOfRequestedBlocks), - toHex(endBlock), - [], - ]) - .mockResolvedValue({ - oldestBlock: toHex(0), - baseFeePerGas: [], - gasUsedRatio: [], - }); - - const feeHistory = await fetchBlockFeeHistory({ - ethQuery, - numberOfBlocks: numberOfRequestedBlocks, - endBlock, - }); - - expect(feeHistory).toStrictEqual([]); - }); - }); - - describe('given percentiles', () => { - const latestBlockNumber = 3; - const numberOfRequestedBlocks = 3; - - beforeEach(() => { - when(mockedQuery) - // @ts-expect-error Mock eth query does not fulfill type requirements - .calledWith(ethQuery, 'blockNumber') - .mockResolvedValue(new BN(latestBlockNumber)); - }); - - it('should match each item in the "reward" key from the response to its percentile', async () => { - when(mockedQuery) - // @ts-expect-error Mock eth query does not fulfill type requirements - .calledWith(ethQuery, 'eth_feeHistory', [ - toHex(numberOfRequestedBlocks), - toHex(latestBlockNumber), - [10, 20, 30], - ]) - .mockResolvedValue({ - oldestBlock: toHex(1), - // Note that this array contains 4 items when the request was made for 3. Per - // , - // baseFeePerGas will always include an extra item which is the calculated base fee for the - // next (future) block. - baseFeePerGas: [ - toHex(100_000_000_000), - toHex(200_000_000_000), - toHex(300_000_000_000), - toHex(400_000_000_000), - ], - gasUsedRatio: [0.1, 0.2, 0.3], - reward: [ - [ - toHex(10_000_000_000), - toHex(15_000_000_000), - toHex(20_000_000_000), - ], - [toHex(0), toHex(10_000_000_000), toHex(15_000_000_000)], - [ - toHex(20_000_000_000), - toHex(20_000_000_000), - toHex(30_000_000_000), - ], - ], - }); - - const feeHistory = await fetchBlockFeeHistory({ - ethQuery, - numberOfBlocks: numberOfRequestedBlocks, - percentiles: [10, 20, 30], - }); - - expect(feeHistory).toStrictEqual([ - { - number: fromHex(toHex(1)), - baseFeePerGas: fromHex(toHex(100_000_000_000)), - gasUsedRatio: 0.1, - priorityFeesByPercentile: { - 10: fromHex(toHex(10_000_000_000)), - 20: fromHex(toHex(15_000_000_000)), - 30: fromHex(toHex(20_000_000_000)), - }, - }, - { - number: fromHex(toHex(2)), - baseFeePerGas: fromHex(toHex(200_000_000_000)), - gasUsedRatio: 0.2, - priorityFeesByPercentile: { - 10: fromHex(toHex(0)), - 20: fromHex(toHex(10_000_000_000)), - 30: fromHex(toHex(15_000_000_000)), - }, - }, - { - number: fromHex(toHex(3)), - baseFeePerGas: fromHex(toHex(300_000_000_000)), - gasUsedRatio: 0.3, - priorityFeesByPercentile: { - 10: fromHex(toHex(20_000_000_000)), - 20: fromHex(toHex(20_000_000_000)), - 30: fromHex(toHex(30_000_000_000)), - }, - }, - ]); - }); - - it('should be able to handle an "empty" response from eth_feeHistory including an empty "reward" array', async () => { - when(mockedQuery) - // @ts-expect-error Mock eth query does not fulfill type requirements - .calledWith(ethQuery, 'eth_feeHistory', [ - toHex(numberOfRequestedBlocks), - toHex(latestBlockNumber), - [10, 20, 30], - ]) - .mockResolvedValue({ - oldestBlock: toHex(0), - baseFeePerGas: [], - gasUsedRatio: [], - reward: [], - }); - - const feeHistory = await fetchBlockFeeHistory({ - ethQuery, - numberOfBlocks: numberOfRequestedBlocks, - percentiles: [10, 20, 30], - }); - - expect(feeHistory).toStrictEqual([]); - }); - }); - - describe('given includeNextBlock = true', () => { - const latestBlockNumber = 3; - const numberOfRequestedBlocks = 3; - - it('includes an extra block with an estimated baseFeePerGas', async () => { - when(mockedQuery) - // @ts-expect-error Mock eth query does not fulfill type requirements - .calledWith(ethQuery, 'eth_feeHistory', [ - toHex(numberOfRequestedBlocks), - toHex(latestBlockNumber), - [], - ]) - .mockResolvedValue({ - oldestBlock: toHex(1), - // Note that this array contains 6 items when we requested 5. Per - // , - // baseFeePerGas will always include an extra item which is the calculated base fee for the - // next (future) block. - baseFeePerGas: [ - toHex(10_000_000_000), - toHex(20_000_000_000), - toHex(30_000_000_000), - toHex(40_000_000_000), - ], - gasUsedRatio: [0.1, 0.2, 0.3], - }); - - const feeHistory = await fetchBlockFeeHistory({ - ethQuery, - numberOfBlocks: numberOfRequestedBlocks, - includeNextBlock: true, - }); - - expect(feeHistory).toStrictEqual([ - { - number: fromHex(toHex(1)), - baseFeePerGas: fromHex(toHex(10_000_000_000)), - gasUsedRatio: 0.1, - priorityFeesByPercentile: {}, - }, - { - number: fromHex(toHex(2)), - baseFeePerGas: fromHex(toHex(20_000_000_000)), - gasUsedRatio: 0.2, - priorityFeesByPercentile: {}, - }, - { - number: fromHex(toHex(3)), - baseFeePerGas: fromHex(toHex(30_000_000_000)), - gasUsedRatio: 0.3, - priorityFeesByPercentile: {}, - }, - { - number: fromHex(toHex(4)), - baseFeePerGas: fromHex(toHex(40_000_000_000)), - gasUsedRatio: null, - priorityFeesByPercentile: null, - }, - ]); - }); - }); - - describe('given a range which exceeds existing blocks', () => { - beforeEach(() => { - jest.resetAllMocks(); - }); - - it('should adjust fetched numberOfBlocks', async () => { - const latestBlockNumber = 1024; - const numberOfRequestedBlocks = 2048; - const endBlock = new BN(latestBlockNumber); - - when(mockedQuery) - // @ts-expect-error Mock eth query does not fulfill type requirements - .calledWith(ethQuery, 'eth_feeHistory', [ - toHex(latestBlockNumber), - toHex(latestBlockNumber), - [], - ]) - .mockResolvedValue({ - oldestBlock: toHex(0), - baseFeePerGas: [], - gasUsedRatio: [], - reward: [], - }); - - await fetchBlockFeeHistory({ - ethQuery, - numberOfBlocks: numberOfRequestedBlocks, - endBlock, - }); - - expect(mockedQuery).toHaveBeenCalledTimes(1); - }); - }); -}); diff --git a/packages/gas-fee-controller/src/fetchBlockFeeHistory.ts b/packages/gas-fee-controller/src/fetchBlockFeeHistory.ts deleted file mode 100644 index c7ca903e6d5..00000000000 --- a/packages/gas-fee-controller/src/fetchBlockFeeHistory.ts +++ /dev/null @@ -1,369 +0,0 @@ -import { query, fromHex, toHex } from '@metamask/controller-utils'; -import { BN } from 'ethereumjs-util'; - -type EthQuery = any; - -/** - * @type RequestChunkSpecifier - * - * Arguments to `eth_feeHistory` that can be used to fetch a set of historical data. - * @property blockCount - The number of blocks requested. - * @property endBlockNumber - The number of the block at the end of the requested range. - */ -type RequestChunkSpecifier = { - numberOfBlocks: number; - endBlockNumber: BN; -}; - -/** - * @type EthFeeHistoryResponse - * - * Response data for `eth_feeHistory`. - * @property oldestBlock - The id of the oldest block (in hex format) in the range of blocks - * requested. - * @property baseFeePerGas - Base fee per gas for each block in the range of blocks requested. - * For go-ethereum based chains baseFeePerGas will not returned in case of empty results - * - * @property gasUsedRatio - A number between 0 and 1 that represents the gas used vs. gas limit for - * each block in the range of blocks requested. - * @property reward - The priority fee at the percentiles requested for each block in the range of - * blocks requested. - */ - -export type EthFeeHistoryResponse = { - oldestBlock: string; - baseFeePerGas?: string[]; - gasUsedRatio: number[]; - reward?: string[][]; -}; - -/** - * @type ExistingFeeHistoryBlock - * - * Historical data for a particular block that exists on the blockchain. - * @property number - The number of the block, as a BN. - * @property baseFeePerGas - The base fee per gas for the block in WEI, as a BN. - * @property gasUsedRatio - A number between 0 and 1 that represents the ratio between the gas paid - * for the block and its set gas limit. - * @property priorityFeesByPercentile - The priority fees paid for the transactions in the block - * that occurred at particular levels at which those transactions contributed to the overall gas - * used for the block, indexed by those percentiles. (See docs for {@link fetchBlockFeeHistory} for more - * on how this works.) - */ -type ExistingFeeHistoryBlock = { - number: BN; - baseFeePerGas: BN; - gasUsedRatio: number; - priorityFeesByPercentile: Record; -}; - -/** - * @type NextFeeHistoryBlock - * - * Historical data for a theoretical block that could exist in the future. - * @property number - The number of the block, as a BN. - * @property baseFeePerGas - The estimated base fee per gas for the block in WEI, as a BN. - */ -type NextFeeHistoryBlock = { - number: BN; - baseFeePerGas: BN; -}; - -/** - * @type FeeHistoryBlock - * - * Historical data for a particular block. - * @property number - The number of the block, as a BN. - * @property baseFeePerGas - The base fee per gas for the block in WEI, as a BN. - * @property gasUsedRatio - A number between 0 and 1 that represents the ratio between the gas paid - * for the block and its set gas limit. - * @property priorityFeesByPercentile - The priority fees paid for the transactions in the block - * that occurred at particular levels at which those transactions contributed to the overall gas - * used for the block, indexed by those percentiles. (See docs for {@link fetchBlockFeeHistory} for more - * on how this works.) - */ -export type FeeHistoryBlock = - | ExistingFeeHistoryBlock - | NextFeeHistoryBlock; - -/** - * @type ExtractPercentileFrom - * - * Extracts the percentiles that the type assigned to an array of FeeHistoryBlock has been created - * with. This makes use of the `infer` keyword to read the type argument. - */ -export type ExtractPercentileFrom = T extends FeeHistoryBlock[] - ? P - : never; - -const MAX_NUMBER_OF_BLOCKS_PER_ETH_FEE_HISTORY_CALL = 1024; - -/** - * Uses `eth_feeHistory` (an EIP-1559 feature) to obtain information about gas fees from a range of - * blocks that have occurred recently on a network. - * - * To learn more, see these resources: - * - * - - * - - * - - * - - * - - * - * @param args - The arguments to this function. - * @param args.ethQuery - An EthQuery instance that wraps a provider for the network in question. - * @param args.endBlock - The desired end of the requested block range. Can be "latest" if you want - * to start from the latest successful block or the number of a known past block. - * @param args.numberOfBlocks - How many total blocks to fetch. Note that if this is more than 1024, - * multiple calls to `eth_feeHistory` will be made. - * @param args.percentiles - A set of numbers between 1 and 100 which will dictate how - * `priorityFeesByPercentile` in each returned block will be formed. When Ethereum runs the - * `eth_feeHistory` method, for each block it is considering, it will first sort all transactions by - * the priority fee. It will then go through each transaction and add the total amount of gas paid - * for that transaction to a bucket which maxes out at the total gas used for the whole block. As - * the bucket fills, it will cross percentages which correspond to the percentiles specified here, - * and the priority fees of the first transactions which cause it to reach those percentages will be - * recorded. Hence, `priorityFeesByPercentile` represents the priority fees of transactions at key - * gas used contribution levels, where earlier levels have smaller contributions and later levels - * have higher contributions. - * @param args.includeNextBlock - Whether to include an extra block that represents the next - * block after the latest one. Only the `baseFeePerGas` will be filled in for this block (which is - * estimated). - * @returns The list of blocks and their fee data, sorted from oldest to newest. - */ -export default async function fetchBlockFeeHistory({ - ethQuery, - numberOfBlocks: totalNumberOfBlocks, - endBlock: givenEndBlock = 'latest', - percentiles: givenPercentiles = [], - includeNextBlock = false, -}: { - ethQuery: EthQuery; - numberOfBlocks: number; - endBlock?: 'latest' | BN; - percentiles?: readonly Percentile[]; - includeNextBlock?: boolean; -}): Promise[]> { - const percentiles = - givenPercentiles.length > 0 - ? Array.from(new Set(givenPercentiles)).sort((a, b) => a - b) - : []; - - const finalEndBlockNumber = - givenEndBlock === 'latest' - ? fromHex(await query(ethQuery, 'blockNumber')) - : givenEndBlock; - - const requestChunkSpecifiers = determineRequestChunkSpecifiers( - finalEndBlockNumber, - totalNumberOfBlocks, - ); - - const blockChunks = await Promise.all( - requestChunkSpecifiers.map(({ numberOfBlocks, endBlockNumber }, i) => { - return i === requestChunkSpecifiers.length - 1 - ? makeRequestForChunk({ - ethQuery, - numberOfBlocks, - endBlockNumber, - percentiles, - includeNextBlock, - }) - : makeRequestForChunk({ - ethQuery, - numberOfBlocks, - endBlockNumber, - percentiles, - includeNextBlock: false, - }); - }), - ); - - return blockChunks.reduce( - (array, blocks) => [...array, ...blocks], - [] as FeeHistoryBlock[], - ); -} - -/** - * Builds an ExistingFeeHistoryBlock. - * - * @param args - The args to this function. - * @param args.number - The number of the block. - * @param args.baseFeePerGas - The base fee per gas of the block. - * @param args.blockIndex - The index of the block in the source chunk. - * @param args.gasUsedRatios - The gas used ratios for the block. - * @param args.priorityFeePercentileGroups - The priority fee percentile groups for the block. - * @param args.percentiles - The percentiles used to fetch the source chunk. - * @returns The ExistingFeeHistoryBlock. - */ -function buildExistingFeeHistoryBlock({ - baseFeePerGas, - number, - blockIndex, - gasUsedRatios, - priorityFeePercentileGroups, - percentiles, -}: { - baseFeePerGas: BN; - number: BN; - blockIndex: number; - gasUsedRatios: number[]; - priorityFeePercentileGroups: string[][]; - percentiles: readonly Percentile[]; -}): ExistingFeeHistoryBlock { - const gasUsedRatio = gasUsedRatios[blockIndex]; - const priorityFeesForEachPercentile = priorityFeePercentileGroups[blockIndex]; - const priorityFeesByPercentile = percentiles.reduce( - (obj, percentile, percentileIndex) => { - const priorityFee = priorityFeesForEachPercentile[percentileIndex]; - return { ...obj, [percentile]: fromHex(priorityFee) }; - }, - {} as Record, - ); - - return { - number, - baseFeePerGas, - gasUsedRatio, - priorityFeesByPercentile, - }; -} - -/** - * Builds a NextFeeHistoryBlock. - * - * @param args - The args to this function. - * @param args.baseFeePerGas - The base fee per gas of the block. - * @param args.number - The number of the block. - * @returns The NextFeeHistoryBlock. - */ -function buildNextFeeHistoryBlock({ - baseFeePerGas, - number, -}: { - baseFeePerGas: BN; - number: BN; -}) { - return { - number, - baseFeePerGas, - gasUsedRatio: null, - priorityFeesByPercentile: null, - }; -} - -/** - * Uses eth_feeHistory to request historical data about a group of blocks (max size 1024). - * - * @param args - The arguments - * @param args.ethQuery - An EthQuery instance. - * @param args.numberOfBlocks - The number of blocks in the chunk. Must be at most 1024, as this is - * the maximum that `eth_feeHistory` can return in one call. - * @param args.endBlockNumber - The end of the requested block range. - * @param args.percentiles - A set of numbers between 1 and 100 that will be used to pull priority - * fees for each block. - * @param args.includeNextBlock - Whether to include an extra block that represents the next - * block after the latest one. Only the `baseFeePerGas` will be filled in for this block (which is - * estimated). - * @returns A list of block data. - */ -async function makeRequestForChunk({ - ethQuery, - numberOfBlocks, - endBlockNumber, - percentiles, - includeNextBlock, -}: { - ethQuery: EthQuery; - numberOfBlocks: number; - endBlockNumber: BN; - percentiles: readonly Percentile[]; - includeNextBlock: boolean; -}): Promise[]> { - const response: EthFeeHistoryResponse = await query( - ethQuery, - 'eth_feeHistory', - [toHex(numberOfBlocks), toHex(endBlockNumber), percentiles], - ); - - const startBlockNumber = fromHex(response.oldestBlock); - - if ( - response.baseFeePerGas !== undefined && - response.baseFeePerGas.length > 0 && - response.gasUsedRatio.length > 0 && - (response.reward === undefined || response.reward.length > 0) - ) { - // Per - // , - // baseFeePerGas will always include an extra item which is the calculated base fee for the - // next (future) block. We may or may not care about this; if we don't, chop it off. - const baseFeesPerGasAsHex = includeNextBlock - ? response.baseFeePerGas - : response.baseFeePerGas.slice(0, numberOfBlocks); - const gasUsedRatios = response.gasUsedRatio; - const priorityFeePercentileGroups = response.reward ?? []; - // Chain is allowed to return fewer number of block results - const numberOfExistingResults = gasUsedRatios.length; - - return baseFeesPerGasAsHex.map((baseFeePerGasAsHex, blockIndex) => { - const baseFeePerGas = fromHex(baseFeePerGasAsHex); - const number = startBlockNumber.addn(blockIndex); - - return blockIndex >= numberOfExistingResults - ? buildNextFeeHistoryBlock({ baseFeePerGas, number }) - : buildExistingFeeHistoryBlock({ - baseFeePerGas, - number, - blockIndex, - gasUsedRatios, - priorityFeePercentileGroups, - percentiles, - }); - }); - } - - return []; -} - -/** - * Divides a block range (specified by a range size and the end of the range) into chunks based on - * the maximum number of blocks that `eth_feeHistory` can return in a single call. - * - * If the requested totalNumberOfBlocks exceed endBlockNumber, totalNumberOfBlocks is - * truncated to avoid requesting chunks with negative endBlockNumber. - * - * @param endBlockNumber - The final block in the complete desired block range after all - * `eth_feeHistory` requests have been made. - * @param totalNumberOfBlocks - The total number of desired blocks after all `eth_feeHistory` - * requests have been made. - * @returns A set of arguments that can be used to make requests to `eth_feeHistory` in order to - * retrieve all of the requested blocks, sorted from oldest block to newest block. - */ -function determineRequestChunkSpecifiers( - endBlockNumber: BN, - totalNumberOfBlocks: number, -): RequestChunkSpecifier[] { - if (endBlockNumber.lt(new BN(totalNumberOfBlocks))) { - totalNumberOfBlocks = endBlockNumber.toNumber(); - } - - const specifiers = []; - for ( - let chunkStartBlockNumber = endBlockNumber.subn(totalNumberOfBlocks); - chunkStartBlockNumber.lt(endBlockNumber); - chunkStartBlockNumber = chunkStartBlockNumber.addn( - MAX_NUMBER_OF_BLOCKS_PER_ETH_FEE_HISTORY_CALL, - ) - ) { - const distanceToEnd = endBlockNumber.sub(chunkStartBlockNumber).toNumber(); - const numberOfBlocks = - distanceToEnd < MAX_NUMBER_OF_BLOCKS_PER_ETH_FEE_HISTORY_CALL - ? distanceToEnd - : MAX_NUMBER_OF_BLOCKS_PER_ETH_FEE_HISTORY_CALL; - const chunkEndBlockNumber = chunkStartBlockNumber.addn(numberOfBlocks); - specifiers.push({ numberOfBlocks, endBlockNumber: chunkEndBlockNumber }); - } - return specifiers; -} diff --git a/packages/gas-fee-controller/src/fetchGasEstimatesViaEthFeeHistory.test.ts b/packages/gas-fee-controller/src/fetchGasEstimatesViaEthFeeHistory.test.ts deleted file mode 100644 index 11c03842141..00000000000 --- a/packages/gas-fee-controller/src/fetchGasEstimatesViaEthFeeHistory.test.ts +++ /dev/null @@ -1,101 +0,0 @@ -import { BN } from 'ethereumjs-util'; -import { when } from 'jest-when'; - -import fetchBlockFeeHistory from './fetchBlockFeeHistory'; -import fetchGasEstimatesViaEthFeeHistory from './fetchGasEstimatesViaEthFeeHistory'; -import calculateGasFeeEstimatesForPriorityLevels from './fetchGasEstimatesViaEthFeeHistory/calculateGasFeeEstimatesForPriorityLevels'; -import fetchLatestBlock from './fetchGasEstimatesViaEthFeeHistory/fetchLatestBlock'; - -jest.mock('./fetchBlockFeeHistory'); -jest.mock( - './fetchGasEstimatesViaEthFeeHistory/calculateGasFeeEstimatesForPriorityLevels', -); -jest.mock('./fetchGasEstimatesViaEthFeeHistory/fetchLatestBlock'); - -const mockedFetchBlockFeeHistory = fetchBlockFeeHistory as jest.Mock< - ReturnType, - Parameters ->; -const mockedCalculateGasFeeEstimatesForPriorityLevels = - calculateGasFeeEstimatesForPriorityLevels as jest.Mock< - ReturnType, - Parameters - >; -const mockedFetchLatestBlock = fetchLatestBlock as jest.Mock< - ReturnType, - Parameters ->; - -describe('fetchGasEstimatesViaEthFeeHistory', () => { - const latestBlock = { - number: new BN(1), - baseFeePerGas: new BN(100_000_000_000), - }; - const ethQuery = { - blockNumber: async () => latestBlock.number, - getBlockByNumber: async () => latestBlock, - }; - - it('calculates target fees for low, medium, and high transaction priority levels', async () => { - const blocks = [ - { - number: new BN(3), - baseFeePerGas: new BN(1), - gasUsedRatio: 1, - priorityFeesByPercentile: { - 10: new BN('0'), - 20: new BN('0'), - 30: new BN('0'), - }, - }, - ]; - const levelSpecificEstimates = { - low: { - minWaitTimeEstimate: 15_000, - maxWaitTimeEstimate: 30_000, - suggestedMaxPriorityFeePerGas: '1', - suggestedMaxFeePerGas: '221', - }, - medium: { - minWaitTimeEstimate: 15_000, - maxWaitTimeEstimate: 45_000, - suggestedMaxPriorityFeePerGas: '1.552', - suggestedMaxFeePerGas: '241.552', - }, - high: { - minWaitTimeEstimate: 15_000, - maxWaitTimeEstimate: 60_000, - suggestedMaxPriorityFeePerGas: '2.94', - suggestedMaxFeePerGas: '252.94', - }, - }; - - mockedFetchLatestBlock.mockResolvedValue(latestBlock); - when(mockedFetchBlockFeeHistory) - .calledWith({ - ethQuery, - endBlock: latestBlock.number, - numberOfBlocks: 5, - percentiles: [10, 20, 30], - }) - .mockResolvedValue(blocks); - - when(mockedCalculateGasFeeEstimatesForPriorityLevels) - .calledWith(blocks) - .mockReturnValue(levelSpecificEstimates); - - // @ts-expect-error Mock eth query does not fulfill type requirements - const gasFeeEstimates = await fetchGasEstimatesViaEthFeeHistory(ethQuery); - - expect(gasFeeEstimates).toStrictEqual({ - ...levelSpecificEstimates, - estimatedBaseFee: '100', - historicalBaseFeeRange: null, - baseFeeTrend: null, - latestPriorityFeeRange: null, - historicalPriorityFeeRange: null, - priorityFeeTrend: null, - networkCongestion: null, - }); - }); -}); diff --git a/packages/gas-fee-controller/src/fetchGasEstimatesViaEthFeeHistory.ts b/packages/gas-fee-controller/src/fetchGasEstimatesViaEthFeeHistory.ts deleted file mode 100644 index 8fe58bc0373..00000000000 --- a/packages/gas-fee-controller/src/fetchGasEstimatesViaEthFeeHistory.ts +++ /dev/null @@ -1,53 +0,0 @@ -import { GWEI } from '@metamask/controller-utils'; -import type EthQuery from '@metamask/eth-query'; -import { fromWei } from 'ethjs-unit'; - -import fetchBlockFeeHistory from './fetchBlockFeeHistory'; -import calculateGasFeeEstimatesForPriorityLevels from './fetchGasEstimatesViaEthFeeHistory/calculateGasFeeEstimatesForPriorityLevels'; -import fetchLatestBlock from './fetchGasEstimatesViaEthFeeHistory/fetchLatestBlock'; -import type { GasFeeEstimates } from './GasFeeController'; - -/** - * Generates gas fee estimates based on gas fees that have been used in the recent past so that - * those estimates can be displayed to users. - * - * To produce the estimates, the last 5 blocks are read from the network, and for each block, the - * priority fees for transactions at the 10th, 20th, and 30th percentiles are also read (here - * "percentile" signifies the level at which those transactions contribute to the overall gas used - * for the block, where higher percentiles correspond to higher fees). This information is used to - * calculate reasonable max priority and max fees for three different priority levels (higher - * priority = higher fee). - * - * Note that properties are returned for other data that are normally obtained via the API; however, - * to prevent extra requests to Infura, these properties are empty. - * - * @param ethQuery - An EthQuery instance. - * @returns Base and priority fee estimates, categorized by priority level, as well as an estimate - * for the next block's base fee. - */ -export default async function fetchGasEstimatesViaEthFeeHistory( - ethQuery: EthQuery, -): Promise { - const latestBlock = await fetchLatestBlock(ethQuery); - const blocks = await fetchBlockFeeHistory({ - ethQuery, - endBlock: latestBlock.number, - numberOfBlocks: 5, - percentiles: [10, 20, 30], - }); - const estimatedBaseFee = fromWei(latestBlock.baseFeePerGas, GWEI); - - const levelSpecificEstimates = - calculateGasFeeEstimatesForPriorityLevels(blocks); - - return { - ...levelSpecificEstimates, - estimatedBaseFee, - historicalBaseFeeRange: null, - baseFeeTrend: null, - latestPriorityFeeRange: null, - historicalPriorityFeeRange: null, - priorityFeeTrend: null, - networkCongestion: null, - }; -} diff --git a/packages/gas-fee-controller/src/fetchGasEstimatesViaEthFeeHistory/calculateGasFeeEstimatesForPriorityLevels.test.ts b/packages/gas-fee-controller/src/fetchGasEstimatesViaEthFeeHistory/calculateGasFeeEstimatesForPriorityLevels.test.ts deleted file mode 100644 index f39f3684963..00000000000 --- a/packages/gas-fee-controller/src/fetchGasEstimatesViaEthFeeHistory/calculateGasFeeEstimatesForPriorityLevels.test.ts +++ /dev/null @@ -1,61 +0,0 @@ -import { BN } from 'ethereumjs-util'; - -import calculateGasFeeEstimatesForPriorityLevels from './calculateGasFeeEstimatesForPriorityLevels'; - -describe('calculateGasFeeEstimatesForPriorityLevels', () => { - it('calculates a set of gas fee estimates targeting various priority levels based on the given blocks', () => { - const estimates = calculateGasFeeEstimatesForPriorityLevels([ - { - number: new BN(1), - baseFeePerGas: new BN(300_000_000_000), - gasUsedRatio: 1, - priorityFeesByPercentile: { - 10: new BN(0), - 20: new BN(1_000_000_000), - 30: new BN(0), - }, - }, - { - number: new BN(2), - baseFeePerGas: new BN(100_000_000_000), - gasUsedRatio: 1, - priorityFeesByPercentile: { - 10: new BN(500_000_000), - 20: new BN(1_600_000_000), - 30: new BN(3_000_000_000), - }, - }, - { - number: new BN(3), - baseFeePerGas: new BN(200_000_000_000), - gasUsedRatio: 1, - priorityFeesByPercentile: { - 10: new BN(500_000_000), - 20: new BN(2_000_000_000), - 30: new BN(3_000_000_000), - }, - }, - ]); - - expect(estimates).toStrictEqual({ - low: { - minWaitTimeEstimate: 15_000, - maxWaitTimeEstimate: 30_000, - suggestedMaxPriorityFeePerGas: '1', - suggestedMaxFeePerGas: '221', - }, - medium: { - minWaitTimeEstimate: 15_000, - maxWaitTimeEstimate: 45_000, - suggestedMaxPriorityFeePerGas: '1.552', - suggestedMaxFeePerGas: '241.552', - }, - high: { - minWaitTimeEstimate: 15_000, - maxWaitTimeEstimate: 60_000, - suggestedMaxPriorityFeePerGas: '2.94', - suggestedMaxFeePerGas: '252.94', - }, - }); - }); -}); diff --git a/packages/gas-fee-controller/src/fetchGasEstimatesViaEthFeeHistory/calculateGasFeeEstimatesForPriorityLevels.ts b/packages/gas-fee-controller/src/fetchGasEstimatesViaEthFeeHistory/calculateGasFeeEstimatesForPriorityLevels.ts deleted file mode 100644 index 80022c7f7b1..00000000000 --- a/packages/gas-fee-controller/src/fetchGasEstimatesViaEthFeeHistory/calculateGasFeeEstimatesForPriorityLevels.ts +++ /dev/null @@ -1,112 +0,0 @@ -import { GWEI } from '@metamask/controller-utils'; -import { BN } from 'ethereumjs-util'; -import { fromWei } from 'ethjs-unit'; - -import type { FeeHistoryBlock } from '../fetchBlockFeeHistory'; -import type { Eip1559GasFee, GasFeeEstimates } from '../GasFeeController'; -import medianOf from './medianOf'; - -export type PriorityLevel = (typeof PRIORITY_LEVELS)[number]; -export type Percentile = (typeof PRIORITY_LEVEL_PERCENTILES)[number]; - -const PRIORITY_LEVELS = ['low', 'medium', 'high'] as const; -const PRIORITY_LEVEL_PERCENTILES = [10, 20, 30] as const; -const SETTINGS_BY_PRIORITY_LEVEL = { - low: { - percentile: 10 as Percentile, - baseFeePercentageMultiplier: new BN(110), - priorityFeePercentageMultiplier: new BN(94), - minSuggestedMaxPriorityFeePerGas: new BN(1_000_000_000), - estimatedWaitTimes: { - minWaitTimeEstimate: 15_000, - maxWaitTimeEstimate: 30_000, - }, - }, - medium: { - percentile: 20 as Percentile, - baseFeePercentageMultiplier: new BN(120), - priorityFeePercentageMultiplier: new BN(97), - minSuggestedMaxPriorityFeePerGas: new BN(1_500_000_000), - estimatedWaitTimes: { - minWaitTimeEstimate: 15_000, - maxWaitTimeEstimate: 45_000, - }, - }, - high: { - percentile: 30 as Percentile, - baseFeePercentageMultiplier: new BN(125), - priorityFeePercentageMultiplier: new BN(98), - minSuggestedMaxPriorityFeePerGas: new BN(2_000_000_000), - estimatedWaitTimes: { - minWaitTimeEstimate: 15_000, - maxWaitTimeEstimate: 60_000, - }, - }, -}; - -/** - * Calculates a set of estimates assigned to a particular priority level based on the data returned - * by `eth_feeHistory`. - * - * @param priorityLevel - The level of fees that dictates how soon a transaction may go through - * ("low", "medium", or "high"). - * @param blocks - A set of blocks as obtained from {@link fetchBlockFeeHistory}. - * @returns The estimates. - */ -function calculateEstimatesForPriorityLevel( - priorityLevel: PriorityLevel, - blocks: FeeHistoryBlock[], -): Eip1559GasFee { - const settings = SETTINGS_BY_PRIORITY_LEVEL[priorityLevel]; - - const latestBaseFeePerGas = blocks[blocks.length - 1].baseFeePerGas; - - const adjustedBaseFee = latestBaseFeePerGas - .mul(settings.baseFeePercentageMultiplier) - .divn(100); - const priorityFees = blocks - .map((block) => { - return 'priorityFeesByPercentile' in block - ? block.priorityFeesByPercentile[settings.percentile] - : null; - }) - .filter(BN.isBN); - const medianPriorityFee = medianOf(priorityFees); - const adjustedPriorityFee = medianPriorityFee - .mul(settings.priorityFeePercentageMultiplier) - .divn(100); - - const suggestedMaxPriorityFeePerGas = BN.max( - adjustedPriorityFee, - settings.minSuggestedMaxPriorityFeePerGas, - ); - const suggestedMaxFeePerGas = adjustedBaseFee.add( - suggestedMaxPriorityFeePerGas, - ); - - return { - ...settings.estimatedWaitTimes, - suggestedMaxPriorityFeePerGas: fromWei(suggestedMaxPriorityFeePerGas, GWEI), - suggestedMaxFeePerGas: fromWei(suggestedMaxFeePerGas, GWEI), - }; -} - -/** - * Calculates a set of estimates suitable for different priority levels based on the data returned - * by `eth_feeHistory`. - * - * @param blocks - A set of blocks populated with data for priority fee percentiles 10, 20, and 30, - * obtained via {@link BlockFeeHistoryDatasetFetcher}. - * @returns The estimates. - */ -export default function calculateGasFeeEstimatesForPriorityLevels( - blocks: FeeHistoryBlock[], -): Pick { - return PRIORITY_LEVELS.reduce((obj, priorityLevel) => { - const gasEstimatesForPriorityLevel = calculateEstimatesForPriorityLevel( - priorityLevel, - blocks, - ); - return { ...obj, [priorityLevel]: gasEstimatesForPriorityLevel }; - }, {} as Pick); -} diff --git a/packages/gas-fee-controller/src/fetchGasEstimatesViaEthFeeHistory/fetchLatestBlock.ts b/packages/gas-fee-controller/src/fetchGasEstimatesViaEthFeeHistory/fetchLatestBlock.ts deleted file mode 100644 index ae3049ff2cd..00000000000 --- a/packages/gas-fee-controller/src/fetchGasEstimatesViaEthFeeHistory/fetchLatestBlock.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { query, fromHex } from '@metamask/controller-utils'; -import type EthQuery from '@metamask/eth-query'; - -import type { EthBlock } from './types'; - -/** - * Returns information about the latest completed block. - * - * @param ethQuery - An EthQuery instance - * @param includeFullTransactionData - Whether or not to include all data for transactions as - * opposed to merely hashes. False by default. - * @returns The block. - */ -export default async function fetchLatestBlock( - ethQuery: EthQuery, - includeFullTransactionData = false, -): Promise { - const blockNumber = await query(ethQuery, 'blockNumber'); - const block = await query(ethQuery, 'getBlockByNumber', [ - blockNumber, - includeFullTransactionData, - ]); - return { - ...block, - number: fromHex(block.number), - baseFeePerGas: fromHex(block.baseFeePerGas), - }; -} diff --git a/packages/gas-fee-controller/src/fetchGasEstimatesViaEthFeeHistory/medianOf.ts b/packages/gas-fee-controller/src/fetchGasEstimatesViaEthFeeHistory/medianOf.ts deleted file mode 100644 index c7dfdc2a6fb..00000000000 --- a/packages/gas-fee-controller/src/fetchGasEstimatesViaEthFeeHistory/medianOf.ts +++ /dev/null @@ -1,15 +0,0 @@ -import type { BN } from 'ethereumjs-util'; - -/** - * Finds the median among a list of numbers. Note that this is different from the implementation - * in the MetaSwap API, as we want to hold to using BN as much as possible. - * - * @param numbers - A list of numbers, as BNs. Will be sorted automatically if unsorted. - * @returns The median number. - */ -export default function medianOf(numbers: BN[]): BN { - const sortedNumbers = numbers.slice().sort((a, b) => a.cmp(b)); - const len = sortedNumbers.length; - const index = Math.floor((len - 1) / 2); - return sortedNumbers[index]; -} diff --git a/packages/gas-fee-controller/src/fetchGasEstimatesViaEthFeeHistory/types.ts b/packages/gas-fee-controller/src/fetchGasEstimatesViaEthFeeHistory/types.ts deleted file mode 100644 index 296700bd6d6..00000000000 --- a/packages/gas-fee-controller/src/fetchGasEstimatesViaEthFeeHistory/types.ts +++ /dev/null @@ -1,8 +0,0 @@ -import type { BN } from 'ethereumjs-util'; - -export type EthBlock = { - number: BN; - baseFeePerGas: BN; -}; - -export type FeeRange = [string, string]; diff --git a/packages/gas-fee-controller/src/gas-util.test.ts b/packages/gas-fee-controller/src/gas-util.test.ts index 34a594d2226..aaea5d2370b 100644 --- a/packages/gas-fee-controller/src/gas-util.test.ts +++ b/packages/gas-fee-controller/src/gas-util.test.ts @@ -5,8 +5,8 @@ import { normalizeGWEIDecimalNumbers, fetchGasEstimates, calculateTimeEstimate, -} from './gas-util'; -import type { GasFeeEstimates } from './GasFeeController'; +} from './gas-util.js'; +import type { GasFeeEstimates } from './GasFeeController.js'; const mockEIP1559ApiResponses: GasFeeEstimates[] = [ { @@ -206,7 +206,7 @@ describe('gas utils', () => { '123456.0000006', ); expect(normalizeGWEIDecimalNumbers(1.000000016025)).toBe('1.000000016'); - expect(normalizeGWEIDecimalNumbers(1.0000000160000028)).toBe( + expect(normalizeGWEIDecimalNumbers('1.0000000160000028')).toBe( '1.000000016', ); expect(normalizeGWEIDecimalNumbers(1.000000016522)).toBe('1.000000017'); diff --git a/packages/gas-fee-controller/src/gas-util.ts b/packages/gas-fee-controller/src/gas-util.ts index 6d91495bfe0..adabe9fbf23 100644 --- a/packages/gas-fee-controller/src/gas-util.ts +++ b/packages/gas-fee-controller/src/gas-util.ts @@ -5,7 +5,7 @@ import { weiHexToGweiDec, } from '@metamask/controller-utils'; import type EthQuery from '@metamask/eth-query'; -import { BN } from 'ethereumjs-util'; +import BN from 'bn.js'; import type { GasFeeEstimates, @@ -13,7 +13,7 @@ import type { EstimatedGasFeeTimeBounds, unknownString, LegacyGasPriceEstimate, -} from './GasFeeController'; +} from './GasFeeController.js'; const makeClientIdHeader = (clientId: string) => ({ 'X-Client-Id': clientId }); @@ -25,7 +25,7 @@ const makeClientIdHeader = (clientId: string) => ({ 'X-Client-Id': clientId }); */ export function normalizeGWEIDecimalNumbers(n: string | number) { const numberAsWEIHex = gweiDecToWEIBN(n).toString(16); - const numberAsGWEI = weiHexToGweiDec(numberAsWEIHex).toString(10); + const numberAsGWEI = weiHexToGweiDec(numberAsWEIHex); return numberAsGWEI; } diff --git a/packages/gas-fee-controller/src/index.ts b/packages/gas-fee-controller/src/index.ts index bb3be201ce6..20a4abf17e0 100644 --- a/packages/gas-fee-controller/src/index.ts +++ b/packages/gas-fee-controller/src/index.ts @@ -1 +1,11 @@ -export * from './GasFeeController'; +export * from './GasFeeController.js'; +export type { + GasFeeControllerResetPollingAction, + GasFeeControllerFetchGasFeeEstimatesAction, + GasFeeControllerGetGasFeeEstimatesAndStartPollingAction, + GasFeeControllerDisconnectPollerAction, + GasFeeControllerStopPollingAction, + GasFeeControllerGetTimeEstimateAction, + GasFeeControllerEnableNonRPCGasFeeApisAction, + GasFeeControllerDisableNonRPCGasFeeApisAction, +} from './GasFeeController-method-action-types.js'; diff --git a/packages/gas-fee-controller/tsconfig.build.json b/packages/gas-fee-controller/tsconfig.build.json index 0e520317bbc..c393837e3e3 100644 --- a/packages/gas-fee-controller/tsconfig.build.json +++ b/packages/gas-fee-controller/tsconfig.build.json @@ -8,6 +8,7 @@ "references": [ { "path": "../base-controller/tsconfig.build.json" }, { "path": "../controller-utils/tsconfig.build.json" }, + { "path": "../messenger/tsconfig.build.json" }, { "path": "../network-controller/tsconfig.build.json" }, { "path": "../polling-controller/tsconfig.build.json" } ], diff --git a/packages/gas-fee-controller/tsconfig.json b/packages/gas-fee-controller/tsconfig.json index ce385aec37b..87ff4a0e8c7 100644 --- a/packages/gas-fee-controller/tsconfig.json +++ b/packages/gas-fee-controller/tsconfig.json @@ -6,6 +6,7 @@ "references": [ { "path": "../base-controller" }, { "path": "../controller-utils" }, + { "path": "../messenger" }, { "path": "../network-controller" }, { "path": "../polling-controller" } ], diff --git a/packages/gator-permissions-controller/CHANGELOG.md b/packages/gator-permissions-controller/CHANGELOG.md new file mode 100644 index 00000000000..5b100822367 --- /dev/null +++ b/packages/gator-permissions-controller/CHANGELOG.md @@ -0,0 +1,338 @@ +# 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] + +## [5.0.2] + +### Changed + +- Bump `@metamask/transaction-controller` from `^69.4.0` to `^69.6.1` ([#9780](https://github.com/MetaMask/core/pull/9780), [#9798](https://github.com/MetaMask/core/pull/9798), [#9823](https://github.com/MetaMask/core/pull/9823), [#9960](https://github.com/MetaMask/core/pull/9960), [#9969](https://github.com/MetaMask/core/pull/9969)) +- Bump `@metamask/network-controller` from `^35.0.0` to `^36.0.0` ([#9758](https://github.com/MetaMask/core/pull/9758), [#9969](https://github.com/MetaMask/core/pull/9969)) + +## [5.0.1] + +### Changed + +- Bump `@metamask/network-controller` from `^34.0.0` to `^35.0.0` ([#9735](https://github.com/MetaMask/core/pull/9735)) +- Bump `@metamask/transaction-controller` from `^69.3.0` to `^69.4.0` ([#9735](https://github.com/MetaMask/core/pull/9735)) + +## [5.0.0] + +### Changed + +- Bump `@metamask/7715-permission-types` from `^0.7.1` to `^1.0.0` ([#9164](https://github.com/MetaMask/core/pull/9164)) + - Use permission decoders from `@metamask/7715-permission-types` +- Bump `@metamask/transaction-controller` from `^69.0.0` to `^69.3.0` ([#9568](https://github.com/MetaMask/core/pull/9568), [#9589](https://github.com/MetaMask/core/pull/9589), [#9593](https://github.com/MetaMask/core/pull/9593), [#9693](https://github.com/MetaMask/core/pull/9693)) + +### Removed + +- **BREAKING:** Drop support for `erc20-token-revocation` permission type ([#9164](https://github.com/MetaMask/core/pull/9164)) + +## [4.2.3] + +### Changed + +- Bump `@metamask/messenger` from `^1.2.0` to `^2.0.0` ([#9392](https://github.com/MetaMask/core/pull/9392)) +- Bump `@metamask/transaction-controller` from `^68.2.2` to `^69.0.0` ([#9421](https://github.com/MetaMask/core/pull/9421), [#9456](https://github.com/MetaMask/core/pull/9456), [#9470](https://github.com/MetaMask/core/pull/9470)) + +## [4.2.2] + +### Changed + +- Bump `@metamask/transaction-controller` from `^68.1.1` to `^68.2.2` ([#9253](https://github.com/MetaMask/core/pull/9253), [#9337](https://github.com/MetaMask/core/pull/9337), [#9349](https://github.com/MetaMask/core/pull/9349)) +- Bump `@metamask/network-controller` from `^33.0.0` to `^34.0.0` ([#9349](https://github.com/MetaMask/core/pull/9349)) + +## [4.2.1] + +### Changed + +- Bump `@metamask/utils` from `^11.9.0` to `^11.11.0` ([#9074](https://github.com/MetaMask/core/pull/9074)) +- Bump `@metamask/transaction-controller` from `^66.0.0` to `^68.1.1` ([#8999](https://github.com/MetaMask/core/pull/8999), [#9021](https://github.com/MetaMask/core/pull/9021), [#9027](https://github.com/MetaMask/core/pull/9027), [#9066](https://github.com/MetaMask/core/pull/9066), [#9089](https://github.com/MetaMask/core/pull/9089), [#9177](https://github.com/MetaMask/core/pull/9177), [#9203](https://github.com/MetaMask/core/pull/9203), [#9218](https://github.com/MetaMask/core/pull/9218)) +- Bump `@metamask/network-controller` from `^32.0.0` to `^33.0.0` ([#9218](https://github.com/MetaMask/core/pull/9218)) + +## [4.2.0] + +### Added + +- Add `token-approval-revocation` execution permission type decoding ([#8823](https://github.com/MetaMask/core/pull/8823)) + +### Changed + +- Bump `@metamask/transaction-controller` from `^65.3.0` to `^66.0.0` ([#8796](https://github.com/MetaMask/core/pull/8796), [#8848](https://github.com/MetaMask/core/pull/8848)) + +## [4.1.2] + +### Changed + +- Bump `@metamask/network-controller` from `^31.0.0` to `^32.0.0` ([#8765](https://github.com/MetaMask/core/pull/8765), [#8774](https://github.com/MetaMask/core/pull/8774)) + +## [4.1.1] + +### Changed + +- Bump `@metamask/transaction-controller` from `^65.1.0` to `^65.3.0` ([#8722](https://github.com/MetaMask/core/pull/8722), [#8755](https://github.com/MetaMask/core/pull/8755)) +- Bump `@metamask/network-controller` from `^30.1.0` to `^31.0.0` ([#8755](https://github.com/MetaMask/core/pull/8755)) + +## [4.1.0] + +### Added + +- Add `payee` rule decoding for execution permissions, extracting allowed recipient addresses from `AllowedTargetsEnforcer` (native token) and `AllowedCalldataEnforcer` (ERC-20 token) caveats ([#8668](https://github.com/MetaMask/core/pull/8668)) +- Add `redeemer` rule decoding for execution permissions, extracting addresses from `RedeemerEnforcer` caveats ([#8537](https://github.com/MetaMask/core/pull/8537)) +- Add `native-token-allowance` and `erc20-token-allowance` execution permission type decoding ([#8553](https://github.com/MetaMask/core/pull/8553)) + +### Changed + +- Use `decodeRedeemerTerms` from `@metamask/delegation-core` instead of a local implementation ([#8537](https://github.com/MetaMask/core/pull/8537)) +- Bump `@metamask/delegation-core` from `^0.2.0` to `^1.1.0` ([#8537](https://github.com/MetaMask/core/pull/8537)) +- Bump `@metamask/transaction-controller` from `^64.2.0` to `^65.1.0` ([#8482](https://github.com/MetaMask/core/pull/8482), [#8585](https://github.com/MetaMask/core/pull/8585), [#8613](https://github.com/MetaMask/core/pull/8613), [#8691](https://github.com/MetaMask/core/pull/8691)) +- Bump `@metamask/messenger` from `^1.1.1` to `^1.2.0` ([#8632](https://github.com/MetaMask/core/pull/8632)) +- Bump `@metamask/network-controller` from `^30.0.1` to `^30.1.0` ([#8636](https://github.com/MetaMask/core/pull/8636)) + +## [4.0.0] + +### Changed + +- **BREAKING:** Add `status` to `PermissionInfoWithMetadata` type, resolved from onchain data ([#8445](https://github.com/MetaMask/core/pull/8445)) +- Bump `@metamask/messenger` from `^1.0.0` to `^1.1.1` ([#8364](https://github.com/MetaMask/core/pull/8364), [#8373](https://github.com/MetaMask/core/pull/8373)) +- Bump `@metamask/transaction-controller` from `^64.0.0` to `^64.2.0` ([#8432](https://github.com/MetaMask/core/pull/8432), [#8447](https://github.com/MetaMask/core/pull/8447)) +- Bump `@metamask/base-controller` from `^9.0.1` to `^9.1.0` ([#8457](https://github.com/MetaMask/core/pull/8457)) + +## [3.0.1] + +### Changed + +- Bump `@metamask/transaction-controller` from `^63.3.1` to `^64.0.0` ([#8359](https://github.com/MetaMask/core/pull/8359)) + +## [3.0.0] + +### Changed + +- **BREAKING:** Bump `@metamask/snaps-controllers` from `^17.2.0` to `^19.0.0` ([#8319](https://github.com/MetaMask/core/pull/8319)) + - The controller now requires `SnapController:hasSnap` instead of `SnapController:has`. +- Bump `@metamask/snaps-sdk` from `^10.3.0` to `^11.0.0` ([#8319](https://github.com/MetaMask/core/pull/8319)) +- Bump `@metamask/snaps-utils` from `^11.7.0` to `^12.1.2` ([#8319](https://github.com/MetaMask/core/pull/8319)) + +## [2.2.0] + +### Added + +- Expose missing public `GatorPermissionsController` methods through its messenger ([#8205](https://github.com/MetaMask/core/pull/8205)) + - The following actions are now available: + - `GatorPermissionsController:initialize` + - Corresponding action types (e.g. `GatorPermissionsControllerInitializeAction`) are available as well. + +### Changed + +- Bump `@metamask/base-controller` from `^9.0.0` to `^9.0.1` ([#8317](https://github.com/MetaMask/core/pull/8317)) +- Bump `@metamask/messenger` from `^0.3.0` to `^1.0.0` ([#8317](https://github.com/MetaMask/core/pull/8317)) +- Bump `@metamask/transaction-controller` from `^63.0.0` to `^63.3.1` ([#8272](https://github.com/MetaMask/core/pull/8272), [#8301](https://github.com/MetaMask/core/pull/8301), [#8313](https://github.com/MetaMask/core/pull/8313), [#8317](https://github.com/MetaMask/core/pull/8317)) + +## [2.1.1] + +### Changed + +- Bump `@metamask/transaction-controller` from `^62.21.0` to `^63.0.0` ([#8217](https://github.com/MetaMask/core/pull/8217), [#8225](https://github.com/MetaMask/core/pull/8225)) + +## [2.1.0] + +### Changed + +- Improves permission validation during decoding ([#7844](https://github.com/MetaMask/core/pull/7844), [#8127](https://github.com/MetaMask/core/pull/8127)) + - Validates `ExactCalldataEnforcer` and `ValueLteEnforcer` caveat terms + - Validates that `periodAmount` is positive in `erc20-token-periodic` and `native-token-periodic` permission types + - Validates that `tokenAddress` is a valid hex string in `erc20-token-periodic` and `erc20-token-stream` permission types +- Bump `@metamask/transaction-controller` from `^62.17.0` to `^62.21.0` ([#7996](https://github.com/MetaMask/core/pull/7996), [#8005](https://github.com/MetaMask/core/pull/8005), [#8031](https://github.com/MetaMask/core/pull/8031), [#8104](https://github.com/MetaMask/core/pull/8104), [#8140](https://github.com/MetaMask/core/pull/8140)) + +## [2.0.0] + +### Changed + +- **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] + +### Fixed + +- Bump `@metamask/transaction-controller` from `^62.10.0` to `^62.11.0` to resolve mismatching `WebSocketState` enum export in `@metamask/core-backend` transient dependency ([#7760](https://github.com/MetaMask/core/pull/7760)) + +## [1.1.1] + +### Changed + +- Bump `@metamask/transaction-controller` from `^62.9.2` to `^62.10.0` ([#7737](https://github.com/MetaMask/core/pull/7737)) + +### Fixed + +- Correctly validate `erc20-token-revocation` terms when decoding permission. ([#7729](https://github.com/MetaMask/core/pull/7729)) + +## [1.1.0] + +### Changed + +- Calls to `permissionsProvider_submitRevocation` now include the hash of the transaction that revoked the permission if available. ([#7503](https://github.com/MetaMask/core/pull/7503)) +- Bump `@metamask/transaction-controller` from `^62.9.1` to `^62.9.2` ([#7642](https://github.com/MetaMask/core/pull/7642)) + +### Fixed + +- Ensure revocation transaction is successful before marking stored permission as revoked ([#7503](https://github.com/MetaMask/core/pull/7503)) + +## [1.0.0] + +### Changed + +- Bump `@metamask/snaps-controllers` from `^14.0.1` to `^17.2.0` ([#7550](https://github.com/MetaMask/core/pull/7550)) +- 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)) +- Bump `@metamask/transaction-controller` from `^62.5.0` to `^62.9.1` ([#7430](https://github.com/MetaMask/core/pull/7430), [#7494](https://github.com/MetaMask/core/pull/7494), [#7596](https://github.com/MetaMask/core/pull/7596), [#7602](https://github.com/MetaMask/core/pull/7602), [#7604](https://github.com/MetaMask/core/pull/7604)) +- **BREAKING:** Gator Permissions Controller and Gator Permission Decoder core types have been updated to comply with 7715 spec revisions ([#7613](https://github.com/MetaMask/core/pull/7613)) + - Bump `@metamask/7715-permission-type` from `^0.4.0` to `^0.5.0` + +## [0.8.0] + +### Added + +- Export `DELEGATION_FRAMEWORK_VERSION` constant to indicate the supported Delegation Framework version ([#7195](https://github.com/MetaMask/core/pull/7195)) + +### Changed + +- **BREAKING:** Permission decoding now rejects `TimestampEnforcer` caveats with zero `timestampBeforeThreshold` values ([#7195](https://github.com/MetaMask/core/pull/7195)) +- `PermissionResponseSanitized` now includes `rules` property for stronger typing support ([#7195](https://github.com/MetaMask/core/pull/7195)) +- Permission decoding now resolves `erc20-token-revocation` permission type ([#7299](https://github.com/MetaMask/core/pull/7299)) +- Differentiate `erc20-token-revocation` permissions from `other` in controller state ([#7318](https://github.com/MetaMask/core/pull/7318)) +- Bump `@metamask/transaction-controller` from `^62.3.1` to `^62.5.0` ([#7289](https://github.com/MetaMask/core/pull/7289), [#7325](https://github.com/MetaMask/core/pull/7325)) + +## [0.7.0] + +### Added + +- Refresh gator permissions map after revocation state change ([#7235](https://github.com/MetaMask/core/pull/7235)) +- New `submitDirectRevocation` method for already-disabled delegations that don't require an on-chain transaction ([#7244](https://github.com/MetaMask/core/pull/7244)) + +### Changed + +- Move peer dependencies for controller and service packages to direct dependencies ([#7209](https://github.com/MetaMask/core/pull/7209), [#7220](https://github.com/MetaMask/core/pull/7220), [#7236](https://github.com/MetaMask/core/pull/7236), [#7257](https://github.com/MetaMask/core/pull/7257)) + - The dependencies moved are: + - `@metamask/snaps-controllers` (^14.0.1) + - `@metamask/transaction-controller` (^62.3.1) + - In clients, it is now possible for multiple versions of these packages to exist in the dependency tree. + - For example, this scenario would be valid: a client relies on `@metamask/controller-a` 1.0.0 and `@metamask/controller-b` 1.0.0, and `@metamask/controller-b` depends on `@metamask/controller-a` 1.1.0. + - Note, however, that the versions specified in the client's `package.json` always "win", and you are expected to keep them up to date so as not to break controller and service intercommunication. + +## [0.6.0] + +### Changed + +- **BREAKING:** Bump `@metamask/transaction-controller` from `^61.1.0` to `^62.0.0` ([#7202](https://github.com/MetaMask/core/pull/7202)) + +## [0.5.0] + +### Fixed + +- Does not add a pending revocation if user cancels the transaction ([#7157](https://github.com/MetaMask/core/pull/7157)) +- **BREAKING** The GatorPermissionsController messenger must allow `TransactionController:transactionApproved` and `TransactionController:transactionRejected` events ([#7157](https://github.com/MetaMask/core/pull/7157)) + +## [0.4.0] + +### Added + +- **BREAKING:** Expose list of pending revocations in state ([#7055](https://github.com/MetaMask/core/pull/7055)) + - Add `pendingRevocations` property to state + - Add `pendingRevocations` getter to controller, which accesses the same property in state +- **BREAKING:** The GatorPermissionsController messenger must allow `TransactionController:transactionConfirmed`, `TransactionController:transactionFailed`, and `TransactionController:transactionDropped` events ([#6713](https://github.com/MetaMask/core/pull/6713)) +- Add `submitRevocation` and `addPendingRevocation` methods to GatorPermissionsController ([#6713](https://github.com/MetaMask/core/pull/6713)) + - These are also available as actions (`GatorPermissionsController:submitRevocation` and `GatorPermissionsController:addPendingRevocation`) + +### Changed + +- **BREAKING:** Add `@metamask/transaction-controller` as peer dependency ([#7058](https://github.com/MetaMask/core/pull/7058)) + +## [0.3.0] + +### Changed + +- **BREAKING:** Use new `Messenger` from `@metamask/messenger` ([#6461](https://github.com/MetaMask/core/pull/6461)) + - Previously, `GatorPermissionsController` accepted a `RestrictedMessenger` instance from `@metamask/base-controller`. +- **BREAKING:** Metadata property `anonymous` renamed to `includeInDebugSnapshot` ([#6461](https://github.com/MetaMask/core/pull/6461)) +- Bump `@metamask/base-controller` from `^8.4.2` to `^9.0.0` ([#6962](https://github.com/MetaMask/core/pull/6962)) + +## [0.2.2] + +### Changed + +- Bump `@metamask/base-controller` from `^8.4.1` to `^8.4.2` ([#6917](https://github.com/MetaMask/core/pull/6917)) + +## [0.2.1] + +### Changed + +- Bump `@metamask/utils` from `^11.8.0` to `^11.8.1` ([#6708](https://github.com/MetaMask/core/pull/6708)) +- Bump `@metamask/base-controller` from `^8.4.0` to `^8.4.1` ([#6807](https://github.com/MetaMask/core/pull/6807)) + +## [0.2.0] + +### Added + +- Add two new controller state metadata properties: `includeInStateLogs` and `usedInUi` ([#6552](https://github.com/MetaMask/core/pull/6552)) +- Add method to decode permission from `signTypedData` ([#6556](https://github.com/MetaMask/core/pull/6556)) + +### Changed + +- Bump `@metamask/utils` from `^11.4.2` to `^11.8.0` ([#6588](https://github.com/MetaMask/core/pull/6588)) +- Bump `@metamask/base-controller` from `^8.3.0` to `^8.4.0` ([#6632](https://github.com/MetaMask/core/pull/6632)) +- Function `decodePermissionFromPermissionContextForOrigin` is now synchronous ([#6656](https://github.com/MetaMask/core/pull/6656)) + +### Fixed + +- Fix incorrect default Gator Permissions SnapId ([#6546](https://github.com/MetaMask/core/pull/6546)) + +## [0.1.0] + +### Added + +- Initial release ([#6033](https://github.com/MetaMask/core/pull/6033)) + +[Unreleased]: https://github.com/MetaMask/core/compare/@metamask/gator-permissions-controller@5.0.2...HEAD +[5.0.2]: https://github.com/MetaMask/core/compare/@metamask/gator-permissions-controller@5.0.1...@metamask/gator-permissions-controller@5.0.2 +[5.0.1]: https://github.com/MetaMask/core/compare/@metamask/gator-permissions-controller@5.0.0...@metamask/gator-permissions-controller@5.0.1 +[5.0.0]: https://github.com/MetaMask/core/compare/@metamask/gator-permissions-controller@4.2.3...@metamask/gator-permissions-controller@5.0.0 +[4.2.3]: https://github.com/MetaMask/core/compare/@metamask/gator-permissions-controller@4.2.2...@metamask/gator-permissions-controller@4.2.3 +[4.2.2]: https://github.com/MetaMask/core/compare/@metamask/gator-permissions-controller@4.2.1...@metamask/gator-permissions-controller@4.2.2 +[4.2.1]: https://github.com/MetaMask/core/compare/@metamask/gator-permissions-controller@4.2.0...@metamask/gator-permissions-controller@4.2.1 +[4.2.0]: https://github.com/MetaMask/core/compare/@metamask/gator-permissions-controller@4.1.2...@metamask/gator-permissions-controller@4.2.0 +[4.1.2]: https://github.com/MetaMask/core/compare/@metamask/gator-permissions-controller@4.1.1...@metamask/gator-permissions-controller@4.1.2 +[4.1.1]: https://github.com/MetaMask/core/compare/@metamask/gator-permissions-controller@4.1.0...@metamask/gator-permissions-controller@4.1.1 +[4.1.0]: https://github.com/MetaMask/core/compare/@metamask/gator-permissions-controller@4.0.0...@metamask/gator-permissions-controller@4.1.0 +[4.0.0]: https://github.com/MetaMask/core/compare/@metamask/gator-permissions-controller@3.0.1...@metamask/gator-permissions-controller@4.0.0 +[3.0.1]: https://github.com/MetaMask/core/compare/@metamask/gator-permissions-controller@3.0.0...@metamask/gator-permissions-controller@3.0.1 +[3.0.0]: https://github.com/MetaMask/core/compare/@metamask/gator-permissions-controller@2.2.0...@metamask/gator-permissions-controller@3.0.0 +[2.2.0]: https://github.com/MetaMask/core/compare/@metamask/gator-permissions-controller@2.1.1...@metamask/gator-permissions-controller@2.2.0 +[2.1.1]: https://github.com/MetaMask/core/compare/@metamask/gator-permissions-controller@2.1.0...@metamask/gator-permissions-controller@2.1.1 +[2.1.0]: https://github.com/MetaMask/core/compare/@metamask/gator-permissions-controller@2.0.0...@metamask/gator-permissions-controller@2.1.0 +[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 +[1.0.0]: https://github.com/MetaMask/core/compare/@metamask/gator-permissions-controller@0.8.0...@metamask/gator-permissions-controller@1.0.0 +[0.8.0]: https://github.com/MetaMask/core/compare/@metamask/gator-permissions-controller@0.7.0...@metamask/gator-permissions-controller@0.8.0 +[0.7.0]: https://github.com/MetaMask/core/compare/@metamask/gator-permissions-controller@0.6.0...@metamask/gator-permissions-controller@0.7.0 +[0.6.0]: https://github.com/MetaMask/core/compare/@metamask/gator-permissions-controller@0.5.0...@metamask/gator-permissions-controller@0.6.0 +[0.5.0]: https://github.com/MetaMask/core/compare/@metamask/gator-permissions-controller@0.4.0...@metamask/gator-permissions-controller@0.5.0 +[0.4.0]: https://github.com/MetaMask/core/compare/@metamask/gator-permissions-controller@0.3.0...@metamask/gator-permissions-controller@0.4.0 +[0.3.0]: https://github.com/MetaMask/core/compare/@metamask/gator-permissions-controller@0.2.2...@metamask/gator-permissions-controller@0.3.0 +[0.2.2]: https://github.com/MetaMask/core/compare/@metamask/gator-permissions-controller@0.2.1...@metamask/gator-permissions-controller@0.2.2 +[0.2.1]: https://github.com/MetaMask/core/compare/@metamask/gator-permissions-controller@0.2.0...@metamask/gator-permissions-controller@0.2.1 +[0.2.0]: https://github.com/MetaMask/core/compare/@metamask/gator-permissions-controller@0.1.0...@metamask/gator-permissions-controller@0.2.0 +[0.1.0]: https://github.com/MetaMask/core/releases/tag/@metamask/gator-permissions-controller@0.1.0 diff --git a/packages/gator-permissions-controller/LICENSE b/packages/gator-permissions-controller/LICENSE new file mode 100644 index 00000000000..c259cd7ebcf --- /dev/null +++ b/packages/gator-permissions-controller/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2025 MetaMask + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packages/gator-permissions-controller/README.md b/packages/gator-permissions-controller/README.md new file mode 100644 index 00000000000..364b5481baf --- /dev/null +++ b/packages/gator-permissions-controller/README.md @@ -0,0 +1,50 @@ +# `@metamask/gator-permissions-controller` + +A dedicated controller for reading gator permissions from profile sync storage. This controller fetches data from the encrypted user storage database and caches it locally, providing fast access to permissions across devices while maintaining privacy through client-side encryption. + +## Installation + +`yarn add @metamask/gator-permissions-controller` + +or + +`npm install @metamask/gator-permissions-controller` + +## Usage + +### Basic Setup + +```typescript +import { GatorPermissionsController } from '@metamask/gator-permissions-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', + ], + // Optional: override the default gator permissions provider Snap id + // gatorPermissionsProviderSnapId: 'npm:@metamask/gator-permissions-snap', + }, +}); +``` + +### Fetch from Profile Sync + +```typescript +// Fetch all permissions +const permissions = + await gatorPermissionsController.fetchAndUpdateGatorPermissions(); + +// Fetch permissions and update internal state +const permissions = + await gatorPermissionsController.fetchAndUpdateGatorPermissions(); +``` + +## 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/gator-permissions-controller/jest.config.js b/packages/gator-permissions-controller/jest.config.js new file mode 100644 index 00000000000..37add1f8801 --- /dev/null +++ b/packages/gator-permissions-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: 96.38, + functions: 100, + lines: 100, + statements: 100, + }, + }, +}); diff --git a/packages/gator-permissions-controller/package.json b/packages/gator-permissions-controller/package.json new file mode 100644 index 00000000000..9a29f5542a4 --- /dev/null +++ b/packages/gator-permissions-controller/package.json @@ -0,0 +1,93 @@ +{ + "name": "@metamask/gator-permissions-controller", + "version": "5.0.2", + "description": "Controller for managing gator permissions with profile sync integration", + "keywords": [ + "Ethereum", + "MetaMask" + ], + "homepage": "https://github.com/MetaMask/core/tree/main/packages/gator-permissions-controller#readme", + "bugs": { + "url": "https://github.com/MetaMask/core/issues" + }, + "license": "MIT", + "repository": { + "type": "git", + "url": "https://github.com/MetaMask/core.git" + }, + "files": [ + "dist/" + ], + "sideEffects": false, + "main": "./dist/index.cjs", + "types": "./dist/index.d.cts", + "exports": { + ".": { + "import": { + "types": "./dist/index.d.mts", + "default": "./dist/index.mjs" + }, + "require": { + "types": "./dist/index.d.cts", + "default": "./dist/index.cjs" + } + }, + "./package.json": "./package.json" + }, + "publishConfig": { + "access": "public", + "registry": "https://registry.npmjs.org/" + }, + "scripts": { + "build": "ts-bridge --project tsconfig.build.json --verbose --clean --no-references", + "build:all": "ts-bridge --project tsconfig.build.json --verbose --clean", + "build:docs": "typedoc", + "changelog:update": "../../scripts/update-changelog.sh @metamask/gator-permissions-controller", + "changelog:validate": "../../scripts/validate-changelog.sh @metamask/gator-permissions-controller", + "lint:tsconfigs": "tsx ../../scripts/lint-tsconfigs/lint-tsconfigs.mts", + "lint:tsconfigs:fix": "tsx ../../scripts/lint-tsconfigs/lint-tsconfigs.mts --fix", + "messenger-action-types:check": "tsx ../../packages/messenger-cli/src/cli.ts --formatter oxfmt --esm --check", + "messenger-action-types:generate": "tsx ../../packages/messenger-cli/src/cli.ts --formatter oxfmt --esm --generate", + "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/7715-permission-types": "^1.0.0", + "@metamask/abi-utils": "^2.0.3", + "@metamask/base-controller": "^9.1.0", + "@metamask/delegation-core": "^2.2.1", + "@metamask/delegation-deployments": "^1.4.0", + "@metamask/messenger": "^2.0.0", + "@metamask/network-controller": "^36.0.0", + "@metamask/snaps-controllers": "^19.0.0", + "@metamask/snaps-sdk": "^11.0.0", + "@metamask/snaps-utils": "^12.1.2", + "@metamask/transaction-controller": "^69.6.1", + "@metamask/utils": "^11.11.0" + }, + "devDependencies": { + "@lavamoat/allow-scripts": "^3.0.4", + "@lavamoat/preinstall-always-fail": "^2.1.0", + "@metamask/auto-changelog": "^6.1.0", + "@ts-bridge/cli": "^0.6.4", + "@types/jest": "^30.0.0", + "deepmerge": "^4.2.2", + "jest": "^30.4.2", + "ts-jest": "^29.4.11", + "tsx": "^4.20.5", + "typedoc": "^0.25.13", + "typedoc-plugin-missing-exports": "^2.0.0", + "typescript": "~5.3.3" + }, + "engines": { + "node": "^18.18 || >=20" + }, + "lavamoat": { + "allowScripts": { + "@lavamoat/preinstall-always-fail": false + } + } +} diff --git a/packages/gator-permissions-controller/src/GatorPermissionsController-method-action-types.ts b/packages/gator-permissions-controller/src/GatorPermissionsController-method-action-types.ts new file mode 100644 index 00000000000..1acd3f0e7a2 --- /dev/null +++ b/packages/gator-permissions-controller/src/GatorPermissionsController-method-action-types.ts @@ -0,0 +1,134 @@ +/** + * This file is auto generated. + * Do not edit manually. + */ + +import type { GatorPermissionsController } from './GatorPermissionsController.js'; + +/** + * 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 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. + */ +export type GatorPermissionsControllerFetchAndUpdateGatorPermissionsAction = { + type: `GatorPermissionsController:fetchAndUpdateGatorPermissions`; + handler: GatorPermissionsController['fetchAndUpdateGatorPermissions']; +}; + +/** + * Initializes the controller. Call once after construction to ensure the + * controller is ready for use. + * + * @returns A promise that resolves when initialization is complete. + */ +export type GatorPermissionsControllerInitializeAction = { + type: `GatorPermissionsController:initialize`; + handler: GatorPermissionsController['initialize']; +}; + +/** + * Decodes a permission context into a structured permission for a specific origin. + * + * This method validates the caller origin, decodes the provided `permissionContext` + * into delegations, identifies the permission type from the caveat enforcers, + * extracts the permission-specific data and expiry, and reconstructs a + * {@link DecodedPermission} containing chainId, account addresses, to, type and data. + * + * @param args - The arguments to this function. + * @param args.origin - The caller's origin; must match the configured permissions provider Snap id. + * @param args.chainId - Numeric EIP-155 chain id used for resolving enforcer contracts and encoding. + * @param args.delegation - delegation representing the permission. + * @param args.metadata - metadata included in the request. + * @param args.metadata.justification - the justification as specified in the request metadata. + * @param args.metadata.origin - the origin as specified in the request metadata. + * + * @returns A decoded permission object suitable for UI consumption and follow-up actions. + * @throws If the origin is not allowed, the context cannot be decoded into exactly one delegation, + * the enforcers do not match any supported permission type, no candidate type validates + * the caveat terms, or more than one permission type successfully validates + * (ambiguous delegation). + */ +export type GatorPermissionsControllerDecodePermissionFromPermissionContextForOriginAction = + { + type: `GatorPermissionsController:decodePermissionFromPermissionContextForOrigin`; + handler: GatorPermissionsController['decodePermissionFromPermissionContextForOrigin']; + }; + +/** + * Submits a revocation to the gator permissions provider snap. + * + * @param revocationParams - The revocation parameters containing the permission context. + * @returns A promise that resolves when the revocation is submitted successfully. + * @throws {GatorPermissionsProviderError} If the snap request fails. + */ +export type GatorPermissionsControllerSubmitRevocationAction = { + type: `GatorPermissionsController:submitRevocation`; + handler: GatorPermissionsController['submitRevocation']; +}; + +/** + * Adds a pending revocation that will be submitted once the transaction is confirmed. + * + * This method sets up listeners for the user's approval/rejection decision and + * terminal transaction states (confirmed, failed, dropped). The flow is: + * 1. Wait for user to approve or reject the transaction + * 2. If approved, add to pending revocations state + * 3. If rejected, cleanup without adding to state + * 4. If confirmed, submit the revocation + * 5. If failed or dropped, cleanup + * + * Includes a timeout safety net to prevent memory leaks if the transaction never + * reaches a terminal state. + * + * @param params - The pending revocation parameters. + * @returns A promise that resolves when the listener is set up. + */ +export type GatorPermissionsControllerAddPendingRevocationAction = { + type: `GatorPermissionsController:addPendingRevocation`; + handler: GatorPermissionsController['addPendingRevocation']; +}; + +/** + * Submits a revocation directly without requiring an on-chain transaction. + * Used for already-disabled delegations that don't require an on-chain transaction. + * + * This method: + * 1. Adds the permission context to pending revocations state (disables UI button) + * 2. Immediately calls submitRevocation to remove from snap storage + * 3. On success, removes from pending revocations state (re-enables UI button) + * 4. On failure, keeps in pending revocations so UI can show error/retry state + * + * @param params - The revocation parameters containing the permission context. + * @returns A promise that resolves when the revocation is submitted successfully. + * @throws {GatorPermissionsProviderError} If the snap request fails. + */ +export type GatorPermissionsControllerSubmitDirectRevocationAction = { + type: `GatorPermissionsController:submitDirectRevocation`; + handler: GatorPermissionsController['submitDirectRevocation']; +}; + +/** + * Checks if a permission context is in the pending revocations list. + * + * @param permissionContext - The permission context to check. + * @returns `true` if the permission context is pending revocation, `false` otherwise. + */ +export type GatorPermissionsControllerIsPendingRevocationAction = { + type: `GatorPermissionsController:isPendingRevocation`; + handler: GatorPermissionsController['isPendingRevocation']; +}; + +/** + * Union of all GatorPermissionsController action types. + */ +export type GatorPermissionsControllerMethodActions = + | GatorPermissionsControllerFetchAndUpdateGatorPermissionsAction + | GatorPermissionsControllerInitializeAction + | GatorPermissionsControllerDecodePermissionFromPermissionContextForOriginAction + | GatorPermissionsControllerSubmitRevocationAction + | GatorPermissionsControllerAddPendingRevocationAction + | GatorPermissionsControllerSubmitDirectRevocationAction + | GatorPermissionsControllerIsPendingRevocationAction; diff --git a/packages/gator-permissions-controller/src/GatorPermissionsController.test.ts b/packages/gator-permissions-controller/src/GatorPermissionsController.test.ts new file mode 100644 index 00000000000..6d486737451 --- /dev/null +++ b/packages/gator-permissions-controller/src/GatorPermissionsController.test.ts @@ -0,0 +1,2705 @@ +import { deriveStateFromMetadata } from '@metamask/base-controller'; +import { + createTimestampTerms, + createNativeTokenStreamingTerms, + createNativeTokenPeriodTransferTerms, + createERC20StreamingTerms, + createERC20TokenPeriodTransferTerms, + createApprovalRevocationTerms, + createValueLteTerms, + encodeDelegations, + ROOT_AUTHORITY, +} from '@metamask/delegation-core'; +import { + CHAIN_ID, + DELEGATOR_CONTRACTS, +} from '@metamask/delegation-deployments'; +import { MOCK_ANY_NAMESPACE, Messenger } from '@metamask/messenger'; +import type { + MessengerActions, + MessengerEvents, + MockAnyNamespace, +} from '@metamask/messenger'; +import type { + SnapControllerHandleRequestAction, + SnapControllerHasSnapAction, +} from '@metamask/snaps-controllers'; +import type { SnapId } from '@metamask/snaps-sdk'; +import type { TransactionMeta } from '@metamask/transaction-controller'; +import { TransactionStatus } from '@metamask/transaction-controller'; +import { hexToBigInt, numberToHex } from '@metamask/utils'; +import type { Hex } from '@metamask/utils'; + +import { flushPromises } from '../../../tests/helpers.js'; +import { + mockGatorPermissionsStorageEntriesFactory, + mockNativeTokenStreamStorageEntry, +} from '../tests/mocks.js'; +import { DELEGATION_FRAMEWORK_VERSION } from './constants.js'; +import * as enforcerAddressesModule from './decodePermission/enforcerAddresses.js'; +import { + GatorPermissionsFetchError, + PermissionDecodingError, +} from './errors.js'; +import type { GatorPermissionsControllerMessenger } from './GatorPermissionsController.js'; +import { GatorPermissionsController } from './GatorPermissionsController.js'; +import type { + PermissionInfoWithMetadata, + GatorPermissionStatus, + StoredGatorPermission, + RevocationParams, + SupportedPermissionType, +} from './types.js'; + +jest.mock('./decodePermission/enforcerAddresses.js', () => ({ + ...jest.requireActual('./decodePermission/enforcerAddresses.js'), + __esModule: true, +})); + +const PERMISSION_STATUSES: GatorPermissionStatus[] = [ + 'Active', + 'Revoked', + 'Expired', +]; + +/** + * Default JSON-RPC behavior for permission status sync tests (disabled = false, + * latest block far in the future). + * + * @returns A Jest mock function suitable as a provider `request` implementation. + */ +function createDefaultPermissionStatusProviderRequest(): jest.MockedFunction< + (args: { method: string; params?: unknown[] }) => Promise +> { + return jest.fn(async (req) => { + if (req.method === 'eth_call') { + return '0x0000000000000000000000000000000000000000000000000000000000000000'; + } + if (req.method === 'eth_getBlockByNumber') { + return { timestamp: '0x77359400' }; + } + throw new Error(`Unexpected RPC method in tests: ${req.method}`); + }); +} + +/** + * Handlers last wired by {@link getRootMessenger} for NetworkController actions + * (assertable when sync must resolve on-chain permission status). + */ +let lastNetworkControllerTestMocks: { + findNetworkClientIdByChainId: jest.Mock; + getNetworkClientById: jest.Mock; + permissionStatusProviderRequest: jest.MockedFunction< + (args: { method: string; params?: unknown[] }) => Promise + >; +} | null = null; + +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 DEFAULT_TEST_CONFIG = { + gatorPermissionsProviderSnapId: MOCK_GATOR_PERMISSIONS_PROVIDER_SNAP_ID, + supportedPermissionTypes: [ + 'native-token-stream', + 'native-token-periodic', + 'erc20-token-stream', + 'erc20-token-periodic', + ] as SupportedPermissionType[], +}; + +const MOCK_GATOR_PERMISSIONS_STORAGE_ENTRIES: StoredGatorPermission[] = + mockGatorPermissionsStorageEntriesFactory({ + [MOCK_CHAIN_ID_1]: { + nativeTokenStream: 5, + nativeTokenPeriodic: 5, + erc20TokenStream: 5, + erc20TokenPeriodic: 5, + }, + [MOCK_CHAIN_ID_2]: { + nativeTokenStream: 5, + nativeTokenPeriodic: 5, + erc20TokenStream: 5, + erc20TokenPeriodic: 5, + }, + }); + +describe('GatorPermissionsController', () => { + describe('constructor', () => { + it('creates GatorPermissionsController with config and default state', () => { + const controller = new GatorPermissionsController({ + messenger: getGatorPermissionsControllerMessenger(), + config: DEFAULT_TEST_CONFIG, + }); + + 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 config and state override', () => { + const customState = { + 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.gatorPermissionsProviderSnapId).toBe( + MOCK_GATOR_PERMISSIONS_PROVIDER_SNAP_ID, + ); + expect(controller.state.grantedPermissions).toStrictEqual([]); + }); + + 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.gatorPermissionsProviderSnapId).toBe( + MOCK_GATOR_PERMISSIONS_PROVIDER_SNAP_ID, + ); + expect(controller.state.isFetchingGatorPermissions).toBe(false); + }); + + it('isFetchingGatorPermissions is false on initialization', () => { + const controller = new GatorPermissionsController({ + messenger: getGatorPermissionsControllerMessenger(), + config: DEFAULT_TEST_CONFIG, + }); + + expect(controller.state.isFetchingGatorPermissions).toBe(false); + }); + + it('isFetchingGatorPermissions is always false when the controller is created', () => { + const controller = new GatorPermissionsController({ + messenger: getGatorPermissionsControllerMessenger(), + config: DEFAULT_TEST_CONFIG, + state: { isFetchingGatorPermissions: true }, + }); + + expect(controller.state.isFetchingGatorPermissions).toBe(false); + }); + + it('instantiates successfully without gatorPermissionsProviderSnapId', () => { + const configWithoutSnapId = { + supportedPermissionTypes: DEFAULT_TEST_CONFIG.supportedPermissionTypes, + }; + + let controller: GatorPermissionsController | undefined; + + expect(() => { + controller = new GatorPermissionsController({ + messenger: getGatorPermissionsControllerMessenger(), + config: configWithoutSnapId, + }); + }).not.toThrow(); + + expect(controller).toBeDefined(); + expect(controller?.state.grantedPermissions).toStrictEqual([]); + }); + }); + + describe('fetchAndUpdateGatorPermissions', () => { + it('fetches and updates gator permissions successfully', async () => { + // Create mock data with rules to verify they are preserved + const mockStorageEntriesWithRules = [ + ...MOCK_GATOR_PERMISSIONS_STORAGE_ENTRIES, + { + ...mockNativeTokenStreamStorageEntry(MOCK_CHAIN_ID_1), + permissionResponse: { + ...mockNativeTokenStreamStorageEntry(MOCK_CHAIN_ID_1) + .permissionResponse, + rules: [ + { + type: 'test-rule', + isAdjustmentAllowed: false, + data: { + target: '0x1234567890123456789012345678901234567890', + signature: '0xabcd', + expiry: 1735689600, // Example expiry timestamp + }, + }, + ], + }, + }, + ]; + + const mockHandleRequestHandler = jest + .fn() + .mockResolvedValue(mockStorageEntriesWithRules); + const rootMessenger = getRootMessenger({ + snapControllerHandleRequestActionHandler: mockHandleRequestHandler, + }); + + const controller = new GatorPermissionsController({ + messenger: getGatorPermissionsControllerMessenger(rootMessenger), + config: DEFAULT_TEST_CONFIG, + }); + + await rootMessenger.call( + 'GatorPermissionsController:fetchAndUpdateGatorPermissions', + ); + + 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); + + grantedPermissions.forEach((entry) => { + expect(entry.permissionResponse).toBeDefined(); + expect(entry.siteOrigin).toBeDefined(); + expect(PERMISSION_STATUSES).toContain(entry.status); + // 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 = grantedPermissions.find( + (entry) => entry.permissionResponse.rules !== undefined, + ); + expect(entryWithRules).toBeDefined(); + expect(entryWithRules?.permissionResponse.rules).toBeDefined(); + expect(entryWithRules?.permissionResponse.rules).toStrictEqual([ + { + type: 'test-rule', + isAdjustmentAllowed: false, + data: { + target: '0x1234567890123456789012345678901234567890', + signature: '0xabcd', + expiry: 1735689600, + }, + }, + ]); + }); + + it('calls NetworkController to resolve on-chain status when snap returns a single-delegation context', async () => { + const frameworkContracts = + DELEGATOR_CONTRACTS[DELEGATION_FRAMEWORK_VERSION][CHAIN_ID.sepolia]; + const { NativeTokenStreamingEnforcer, DelegationManager } = + frameworkContracts; + + const delegation = { + delegate: '0x4f71DA06987BfeDE90aF0b33E1e3e4ffDCEE7a63', + delegator: '0xB68c70159E9892DdF5659ec42ff9BD2bbC23e778', + authority: ROOT_AUTHORITY, + caveats: [ + { + enforcer: NativeTokenStreamingEnforcer, + terms: createNativeTokenStreamingTerms({ + initialAmount: hexToBigInt('0x6f05b59d3b20000'), + maxAmount: hexToBigInt('0x22b1c8c1227a0000'), + amountPerSecond: hexToBigInt('0x6f05b59d3b20000'), + startTime: 1747699200, + }), + args: '0x', + }, + ], + salt: 0n, + signature: '0x' as Hex, + }; + const encodedContext = encodeDelegations([delegation]); + const base = mockNativeTokenStreamStorageEntry(MOCK_CHAIN_ID_1); + const storedEntry = { + ...base, + permissionResponse: { + ...base.permissionResponse, + context: encodedContext, + delegationManager: DelegationManager, + }, + }; + + const rootMessenger = getRootMessenger({ + snapControllerHandleRequestActionHandler: jest + .fn() + .mockResolvedValue([storedEntry]), + }); + + const controller = new GatorPermissionsController({ + messenger: getGatorPermissionsControllerMessenger(rootMessenger), + config: DEFAULT_TEST_CONFIG, + }); + + await rootMessenger.call( + 'GatorPermissionsController:fetchAndUpdateGatorPermissions', + ); + + expect(lastNetworkControllerTestMocks).not.toBeNull(); + expect( + lastNetworkControllerTestMocks?.findNetworkClientIdByChainId, + ).toHaveBeenCalledWith(MOCK_CHAIN_ID_1); + expect( + lastNetworkControllerTestMocks?.getNetworkClientById, + ).toHaveBeenCalledWith('test-network-client-id'); + expect( + lastNetworkControllerTestMocks?.permissionStatusProviderRequest, + ).toHaveBeenCalled(); + + expect(controller.state.grantedPermissions).toHaveLength(1); + expect(controller.state.grantedPermissions[0].status).toBe('Active'); + }); + + it('defaults merged status to Active when persisted row omits status for the same context', async () => { + const frameworkContracts = + DELEGATOR_CONTRACTS[DELEGATION_FRAMEWORK_VERSION][CHAIN_ID.sepolia]; + const { NativeTokenStreamingEnforcer, DelegationManager } = + frameworkContracts; + + const delegation = { + delegate: '0x4f71DA06987BfeDE90aF0b33E1e3e4ffDCEE7a63', + delegator: '0xB68c70159E9892DdF5659ec42ff9BD2bbC23e778', + authority: ROOT_AUTHORITY, + caveats: [ + { + enforcer: NativeTokenStreamingEnforcer, + terms: createNativeTokenStreamingTerms({ + initialAmount: hexToBigInt('0x6f05b59d3b20000'), + maxAmount: hexToBigInt('0x22b1c8c1227a0000'), + amountPerSecond: hexToBigInt('0x6f05b59d3b20000'), + startTime: 1747699200, + }), + args: '0x', + }, + ], + salt: 0n, + signature: '0x' as Hex, + }; + const encodedContext = encodeDelegations([delegation]); + const base = mockNativeTokenStreamStorageEntry(MOCK_CHAIN_ID_1); + const storedEntry = { + ...base, + permissionResponse: { + ...base.permissionResponse, + context: encodedContext, + delegationManager: DelegationManager, + }, + }; + + const persistedRowMissingStatus = { + permissionResponse: { + chainId: storedEntry.permissionResponse.chainId, + from: storedEntry.permissionResponse.from, + permission: storedEntry.permissionResponse.permission, + context: encodedContext, + delegationManager: DelegationManager, + }, + siteOrigin: storedEntry.siteOrigin, + } as unknown as PermissionInfoWithMetadata; + + const rootMessenger = getRootMessenger({ + snapControllerHandleRequestActionHandler: jest + .fn() + .mockResolvedValue([storedEntry]), + }); + + const controller = new GatorPermissionsController({ + messenger: getGatorPermissionsControllerMessenger(rootMessenger), + config: DEFAULT_TEST_CONFIG, + state: { + grantedPermissions: [persistedRowMissingStatus], + lastSyncedTimestamp: 1, + }, + }); + + await rootMessenger.call( + 'GatorPermissionsController:fetchAndUpdateGatorPermissions', + ); + + expect(controller.state.grantedPermissions).toHaveLength(1); + expect(controller.state.grantedPermissions[0].status).toBe('Active'); + }); + + it('handles null permissions data', async () => { + const rootMessenger = getRootMessenger({ + snapControllerHandleRequestActionHandler: async () => null, + }); + + const controller = new GatorPermissionsController({ + messenger: getGatorPermissionsControllerMessenger(rootMessenger), + config: DEFAULT_TEST_CONFIG, + }); + + await rootMessenger.call( + 'GatorPermissionsController:fetchAndUpdateGatorPermissions', + ); + + expect(controller.state.grantedPermissions).toStrictEqual([]); + }); + + it('handles empty permissions data', async () => { + const rootMessenger = getRootMessenger({ + snapControllerHandleRequestActionHandler: async () => [], + }); + + const controller = new GatorPermissionsController({ + messenger: getGatorPermissionsControllerMessenger(rootMessenger), + config: DEFAULT_TEST_CONFIG, + }); + + await rootMessenger.call( + 'GatorPermissionsController:fetchAndUpdateGatorPermissions', + ); + + expect(controller.state.grantedPermissions).toStrictEqual([]); + }); + + 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( + rootMessenger.call( + 'GatorPermissionsController:fetchAndUpdateGatorPermissions', + ), + ).rejects.toThrow('Failed to fetch gator permissions'); + + expect(controller.state.isFetchingGatorPermissions).toBe(false); + expect(controller.state.lastSyncedTimestamp).toBe(-1); + }); + + 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: mockHandleRequestHandler, + }); + + // eslint-disable-next-line no-new + new GatorPermissionsController({ + messenger: getGatorPermissionsControllerMessenger(rootMessenger), + config: DEFAULT_TEST_CONFIG, + }); + + const promise1 = rootMessenger.call( + 'GatorPermissionsController:fetchAndUpdateGatorPermissions', + ); + const promise2 = rootMessenger.call( + 'GatorPermissionsController:fetchAndUpdateGatorPermissions', + ); + + expect(promise1).toBe(promise2); + + resolveRequest?.(MOCK_GATOR_PERMISSIONS_STORAGE_ENTRIES); + await promise1; + }); + + it('performs a new sync when called after previous sync completes', async () => { + const mockHandleRequestHandler = jest + .fn() + .mockResolvedValue(MOCK_GATOR_PERMISSIONS_STORAGE_ENTRIES); + const rootMessenger = getRootMessenger({ + snapControllerHandleRequestActionHandler: mockHandleRequestHandler, + }); + + // eslint-disable-next-line no-new + new GatorPermissionsController({ + messenger: getGatorPermissionsControllerMessenger(rootMessenger), + config: DEFAULT_TEST_CONFIG, + }); + + await rootMessenger.call( + 'GatorPermissionsController:fetchAndUpdateGatorPermissions', + ); + expect(mockHandleRequestHandler).toHaveBeenCalledTimes(1); + + await rootMessenger.call( + 'GatorPermissionsController:fetchAndUpdateGatorPermissions', + ); + expect(mockHandleRequestHandler).toHaveBeenCalledTimes(2); + }); + }); + + describe('initialize', () => { + it('calls fetchAndUpdateGatorPermissions when lastSyncedTimestamp is -1', async () => { + const mockHandleRequestHandler = jest + .fn() + .mockResolvedValue(MOCK_GATOR_PERMISSIONS_STORAGE_ENTRIES); + const rootMessenger = getRootMessenger({ + snapControllerHandleRequestActionHandler: mockHandleRequestHandler, + }); + + const controller = new GatorPermissionsController({ + messenger: getGatorPermissionsControllerMessenger(rootMessenger), + config: DEFAULT_TEST_CONFIG, + }); + + expect(controller.state.lastSyncedTimestamp).toBe(-1); + + await rootMessenger.call('GatorPermissionsController:initialize'); + + expect(mockHandleRequestHandler).toHaveBeenCalledTimes(1); + expect(controller.state.lastSyncedTimestamp).not.toBe(-1); + expect(controller.state.grantedPermissions.length).toBeGreaterThan(0); + }); + + 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(rootMessenger), + config: DEFAULT_TEST_CONFIG, + state: { lastSyncedTimestamp: recentTimestamp }, + }); + + await rootMessenger.call('GatorPermissionsController:initialize'); + + expect(mockHandleRequestHandler).not.toHaveBeenCalled(); + expect(controller.state.lastSyncedTimestamp).toBe(recentTimestamp); + }); + + 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 rootMessenger.call('GatorPermissionsController:initialize'); + + expect(mockHandleRequestHandler).toHaveBeenCalledTimes(1); + expect(controller.state.lastSyncedTimestamp).not.toBe(staleTimestamp); + }); + + 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(rootMessenger), + config: { + ...DEFAULT_TEST_CONFIG, + maxSyncIntervalMs, + }, + state: { lastSyncedTimestamp: lastSyncedTwoSecondsAgo }, + }); + + await rootMessenger.call('GatorPermissionsController:initialize'); + + expect(mockHandleRequestHandler).toHaveBeenCalledTimes(1); + expect(controller.state.lastSyncedTimestamp).not.toBe( + lastSyncedTwoSecondsAgo, + ); + }); + }); + + describe('message handlers tests', () => { + it('registers all message handlers', () => { + const messenger = getGatorPermissionsControllerMessenger(); + const mockRegisterActionHandler = jest.spyOn( + messenger, + 'registerActionHandler', + ); + + const controller = new GatorPermissionsController({ + messenger, + config: DEFAULT_TEST_CONFIG, + }); + + expect(controller).toBeDefined(); + + expect(mockRegisterActionHandler).toHaveBeenCalledWith( + 'GatorPermissionsController:fetchAndUpdateGatorPermissions', + expect.any(Function), + ); + }); + }); + + describe('metadata', () => { + it('includes expected state in debug snapshots', () => { + const controller = new GatorPermissionsController({ + messenger: getGatorPermissionsControllerMessenger(), + config: DEFAULT_TEST_CONFIG, + }); + + expect( + deriveStateFromMetadata( + controller.state, + controller.metadata, + 'includeInDebugSnapshot', + ), + ).toMatchInlineSnapshot(`{}`); + }); + + it('includes expected state in state logs', () => { + const controller = new GatorPermissionsController({ + messenger: getGatorPermissionsControllerMessenger(), + config: DEFAULT_TEST_CONFIG, + }); + + expect( + deriveStateFromMetadata( + controller.state, + controller.metadata, + 'includeInStateLogs', + ), + ).toMatchInlineSnapshot(` + { + "grantedPermissions": [], + "isFetchingGatorPermissions": false, + "lastSyncedTimestamp": -1, + "pendingRevocations": [], + } + `); + }); + + it('persists expected state', () => { + const controller = new GatorPermissionsController({ + messenger: getGatorPermissionsControllerMessenger(), + config: DEFAULT_TEST_CONFIG, + }); + + expect( + deriveStateFromMetadata( + controller.state, + controller.metadata, + 'persist', + ), + ).toMatchInlineSnapshot(` + { + "grantedPermissions": [], + "lastSyncedTimestamp": -1, + } + `); + }); + + it('exposes expected state to UI', () => { + const controller = new GatorPermissionsController({ + messenger: getGatorPermissionsControllerMessenger(), + config: DEFAULT_TEST_CONFIG, + }); + + expect( + deriveStateFromMetadata( + controller.state, + controller.metadata, + 'usedInUi', + ), + ).toMatchInlineSnapshot(` + { + "grantedPermissions": [], + "isFetchingGatorPermissions": false, + "pendingRevocations": [], + } + `); + }); + }); + + describe('decodePermissionFromPermissionContextForOrigin', () => { + const chainId = CHAIN_ID.sepolia; + const contracts = + DELEGATOR_CONTRACTS[DELEGATION_FRAMEWORK_VERSION][chainId]; + + const delegatorAddressA = + '0x1111111111111111111111111111111111111111' as Hex; + const delegateAddressB = + '0x2222222222222222222222222222222222222222' as Hex; + const metamaskOrigin = 'https://metamask.io'; + const buildMetadata = ( + justification: string, + ): { justification: string; origin: string } => ({ + justification, + origin: metamaskOrigin, + }); + + let controller: GatorPermissionsController; + let rootMessenger: RootMessenger; + + beforeEach(() => { + rootMessenger = getRootMessenger(); + controller = new GatorPermissionsController({ + messenger: getGatorPermissionsControllerMessenger(rootMessenger), + config: DEFAULT_TEST_CONFIG, + }); + }); + + it('throws PermissionDecodingError if contracts are not found', () => { + let error: unknown; + try { + rootMessenger.call( + 'GatorPermissionsController:decodePermissionFromPermissionContextForOrigin', + { + origin: controller.gatorPermissionsProviderSnapId, + chainId: 999999, + delegation: { + caveats: [], + delegator: '0x1111111111111111111111111111111111111111', + delegate: '0x2222222222222222222222222222222222222222', + authority: ROOT_AUTHORITY as Hex, + }, + metadata: buildMetadata(''), + }, + ); + } catch (caught) { + error = caught; + } + + expect(error).toBeInstanceOf(PermissionDecodingError); + expect((error as PermissionDecodingError).message).toBe( + 'Failed to decode permission', + ); + expect((error as PermissionDecodingError).cause).toBeInstanceOf(Error); + expect((error as PermissionDecodingError).cause.message).toBe( + 'Contracts not found for chainId: 999999', + ); + }); + + it('throws PermissionDecodingError when toEnforcerAddressesByName throws', () => { + const toEnforcerAddressesByNameSpy = jest + .spyOn(enforcerAddressesModule, 'toEnforcerAddressesByName') + .mockImplementation(() => { + throw new Error('Failed to checksum enforcer addresses'); + }); + + let error: unknown; + try { + rootMessenger.call( + 'GatorPermissionsController:decodePermissionFromPermissionContextForOrigin', + { + origin: controller.gatorPermissionsProviderSnapId, + chainId, + delegation: { + caveats: [], + delegator: '0x1111111111111111111111111111111111111111', + delegate: '0x2222222222222222222222222222222222222222', + authority: ROOT_AUTHORITY, + }, + metadata: buildMetadata(''), + }, + ); + } catch (caught) { + error = caught; + } + + expect(error).toBeInstanceOf(PermissionDecodingError); + expect((error as PermissionDecodingError).message).toBe( + 'Failed to decode permission', + ); + expect((error as PermissionDecodingError).cause).toBeInstanceOf(Error); + expect((error as PermissionDecodingError).cause.message).toBe( + 'Failed to checksum enforcer addresses', + ); + expect(toEnforcerAddressesByNameSpy).toHaveBeenCalledTimes(1); + + toEnforcerAddressesByNameSpy.mockRestore(); + }); + + it('throws when origin does not match permissions provider', () => { + expect(() => + rootMessenger.call( + 'GatorPermissionsController:decodePermissionFromPermissionContextForOrigin', + { + origin: 'not-the-provider', + chainId: 1, + delegation: { + delegate: '0x1', + delegator: '0x2', + authority: ROOT_AUTHORITY as Hex, + caveats: [], + }, + metadata: buildMetadata(''), + }, + ), + ).toThrow('Origin not-the-provider not allowed'); + }); + + it('throws when enforcers do not identify a supported permission', () => { + const { TimestampEnforcer, ValueLteEnforcer } = contracts; + + const expiryTerms = createTimestampTerms( + { afterThreshold: 0, beforeThreshold: 100 }, + { out: 'hex' }, + ); + + const caveats = [ + { + enforcer: TimestampEnforcer, + terms: expiryTerms, + args: '0x', + } as const, + // Include a forbidden/irrelevant enforcer without required counterparts + { enforcer: ValueLteEnforcer, terms: '0x', args: '0x' } as const, + ]; + + expect(() => + rootMessenger.call( + 'GatorPermissionsController:decodePermissionFromPermissionContextForOrigin', + { + origin: controller.gatorPermissionsProviderSnapId, + chainId, + delegation: { + delegate: delegatorAddressA, + delegator: delegateAddressB, + authority: ROOT_AUTHORITY as Hex, + caveats, + }, + metadata: buildMetadata(''), + }, + ), + ).toThrow('Failed to decode permission'); + }); + + it('throws when caveat terms are invalid for the matched permission rule', () => { + const { + TimestampEnforcer, + NativeTokenStreamingEnforcer, + ExactCalldataEnforcer, + NonceEnforcer, + } = contracts; + + const expiryTerms = createTimestampTerms( + { afterThreshold: 0, beforeThreshold: 1720000 }, + { out: 'hex' }, + ); + + // Enforcers match native-token-stream but stream terms are truncated (invalid) + const truncatedStreamTerms: Hex = `0x${'00'.repeat(50)}`; + const caveats = [ + { + enforcer: TimestampEnforcer, + terms: expiryTerms, + args: '0x', + } as const, + { + enforcer: NativeTokenStreamingEnforcer, + terms: truncatedStreamTerms, + args: '0x', + } as const, + { enforcer: ExactCalldataEnforcer, terms: '0x', args: '0x' } as const, + { enforcer: NonceEnforcer, terms: '0x', args: '0x' } as const, + ]; + + expect(() => + rootMessenger.call( + 'GatorPermissionsController:decodePermissionFromPermissionContextForOrigin', + { + origin: MOCK_GATOR_PERMISSIONS_PROVIDER_SNAP_ID, + chainId, + delegation: { + delegate: delegatorAddressA, + delegator: delegateAddressB, + authority: ROOT_AUTHORITY as Hex, + caveats, + }, + metadata: buildMetadata(''), + }, + ), + ).toThrow('Failed to decode permission'); + }); + + it('throws when authority is not ROOT_AUTHORITY', () => { + const { + TimestampEnforcer, + NativeTokenStreamingEnforcer, + ExactCalldataEnforcer, + NonceEnforcer, + } = contracts; + + const delegator = delegatorAddressA; + const delegate = delegateAddressB; + + const beforeThreshold = 2000; + const expiryTerms = createTimestampTerms( + { afterThreshold: 0, beforeThreshold }, + { out: 'hex' }, + ); + + const initialAmount = 1n; + const maxAmount = 2n; + const amountPerSecond = 1n; + const startTime = 1715000; + const streamTerms = createNativeTokenStreamingTerms( + { initialAmount, maxAmount, amountPerSecond, startTime }, + { out: 'hex' }, + ); + + const caveats = [ + { + enforcer: TimestampEnforcer, + terms: expiryTerms, + args: '0x', + } as const, + { + enforcer: NativeTokenStreamingEnforcer, + terms: streamTerms, + args: '0x', + } as const, + { enforcer: ExactCalldataEnforcer, terms: '0x', args: '0x' } as const, + { enforcer: NonceEnforcer, terms: '0x', args: '0x' } as const, + ]; + + const invalidAuthority = + '0x0000000000000000000000000000000000000000' as Hex; + + expect(() => + rootMessenger.call( + 'GatorPermissionsController:decodePermissionFromPermissionContextForOrigin', + { + origin: controller.gatorPermissionsProviderSnapId, + chainId, + delegation: { + delegate, + delegator, + authority: invalidAuthority, + caveats, + }, + metadata: buildMetadata(''), + }, + ), + ).toThrow('Failed to decode permission'); + }); + + describe('specific permission types', () => { + const UINT256_MAX = 2n ** 256n - 1n; + + it('decodes a native-token-stream permission successfully', () => { + const { + TimestampEnforcer, + NativeTokenStreamingEnforcer, + ExactCalldataEnforcer, + NonceEnforcer, + } = contracts; + + const delegator = delegatorAddressA; + const delegate = delegateAddressB; + + const beforeThreshold = 1720000; + const expiryTerms = createTimestampTerms( + { afterThreshold: 0, beforeThreshold }, + { out: 'hex' }, + ); + + const initialAmount = 123456n; + const maxAmount = 999999n; + const amountPerSecond = 1n; + const startTime = 1715664; + const streamTerms = createNativeTokenStreamingTerms( + { initialAmount, maxAmount, amountPerSecond, startTime }, + { out: 'hex' }, + ); + + const caveats = [ + { + enforcer: TimestampEnforcer, + terms: expiryTerms, + args: '0x', + } as const, + { + enforcer: NativeTokenStreamingEnforcer, + terms: streamTerms, + args: '0x', + } as const, + { enforcer: ExactCalldataEnforcer, terms: '0x', args: '0x' } as const, + { enforcer: NonceEnforcer, terms: '0x', args: '0x' } as const, + ]; + + const delegation = { + delegate, + delegator, + authority: ROOT_AUTHORITY as Hex, + caveats, + }; + + const result = rootMessenger.call( + 'GatorPermissionsController:decodePermissionFromPermissionContextForOrigin', + { + origin: controller.gatorPermissionsProviderSnapId, + chainId, + delegation, + metadata: buildMetadata('Test justification'), + }, + ); + + expect(result.chainId).toBe(numberToHex(chainId)); + expect(result.from).toBe(delegator); + expect(result.to).toStrictEqual(delegate); + expect(result.permission.type).toBe('native-token-stream'); + expect(result.expiry).toBe(beforeThreshold); + // amounts are hex-encoded in decoded data; startTime is numeric + expect(result.permission.data.startTime).toBe(startTime); + + // BigInt fields are encoded as hex; compare after decoding + expect(hexToBigInt(result.permission.data.initialAmount)).toBe( + initialAmount, + ); + expect(hexToBigInt(result.permission.data.maxAmount)).toBe(maxAmount); + expect(hexToBigInt(result.permission.data.amountPerSecond)).toBe( + amountPerSecond, + ); + expect(result.permission.justification).toBe('Test justification'); + }); + + it('decodes a native-token-periodic permission successfully', () => { + const { + TimestampEnforcer, + NativeTokenPeriodTransferEnforcer, + ExactCalldataEnforcer, + NonceEnforcer, + } = contracts; + + const delegator = delegatorAddressA; + const delegate = delegateAddressB; + + const beforeThreshold = 1720000; + const expiryTerms = createTimestampTerms( + { afterThreshold: 0, beforeThreshold }, + { out: 'hex' }, + ); + + const periodAmount = 123456n; + const periodDuration = 86400; + const startDate = 1715664; + const periodicTerms = createNativeTokenPeriodTransferTerms( + { periodAmount, periodDuration, startDate }, + { out: 'hex' }, + ); + + const caveats = [ + { + enforcer: TimestampEnforcer, + terms: expiryTerms, + args: '0x', + } as const, + { + enforcer: NativeTokenPeriodTransferEnforcer, + terms: periodicTerms, + args: '0x', + } as const, + { enforcer: ExactCalldataEnforcer, terms: '0x', args: '0x' } as const, + { enforcer: NonceEnforcer, terms: '0x', args: '0x' } as const, + ]; + + const result = rootMessenger.call( + 'GatorPermissionsController:decodePermissionFromPermissionContextForOrigin', + { + origin: controller.gatorPermissionsProviderSnapId, + chainId, + delegation: { + delegate, + delegator, + authority: ROOT_AUTHORITY as Hex, + caveats, + }, + metadata: buildMetadata('Test justification'), + }, + ); + + expect(result.permission.type).toBe('native-token-periodic'); + expect(result.expiry).toBe(beforeThreshold); + expect(hexToBigInt(result.permission.data.periodAmount)).toBe( + periodAmount, + ); + expect(result.permission.data.periodDuration).toBe(periodDuration); + expect(result.permission.data.startTime).toBe(startDate); + expect(result.permission.justification).toBe('Test justification'); + }); + + it('decodes an erc20-token-stream permission successfully', () => { + const { + TimestampEnforcer, + ERC20StreamingEnforcer, + ValueLteEnforcer, + NonceEnforcer, + } = contracts; + + const delegator = delegatorAddressA; + const delegate = delegateAddressB; + + const beforeThreshold = 1720000; + const expiryTerms = createTimestampTerms( + { afterThreshold: 0, beforeThreshold }, + { out: 'hex' }, + ); + + const tokenAddress = + '0x3333333333333333333333333333333333333333' as Hex; + const initialAmount = 123456n; + const maxAmount = 999999n; + const amountPerSecond = 1n; + const startTime = 1715664; + const streamTerms = createERC20StreamingTerms( + { + tokenAddress, + initialAmount, + maxAmount, + amountPerSecond, + startTime, + }, + { out: 'hex' }, + ); + const valueLteTerms = createValueLteTerms( + { maxValue: 0n }, + { out: 'hex' }, + ); + + const caveats = [ + { + enforcer: TimestampEnforcer, + terms: expiryTerms, + args: '0x', + } as const, + { + enforcer: ERC20StreamingEnforcer, + terms: streamTerms, + args: '0x', + } as const, + { + enforcer: ValueLteEnforcer, + terms: valueLteTerms, + args: '0x', + } as const, + { enforcer: NonceEnforcer, terms: '0x', args: '0x' } as const, + ]; + + const result = rootMessenger.call( + 'GatorPermissionsController:decodePermissionFromPermissionContextForOrigin', + { + origin: controller.gatorPermissionsProviderSnapId, + chainId, + delegation: { + delegate, + delegator, + authority: ROOT_AUTHORITY as Hex, + caveats, + }, + metadata: buildMetadata('Test justification'), + }, + ); + + expect(result.permission.type).toBe('erc20-token-stream'); + expect(result.expiry).toBe(beforeThreshold); + expect(result.permission.data.tokenAddress.toLowerCase()).toBe( + tokenAddress.toLowerCase(), + ); + expect(hexToBigInt(result.permission.data.initialAmount)).toBe( + initialAmount, + ); + expect(hexToBigInt(result.permission.data.maxAmount)).toBe(maxAmount); + expect(hexToBigInt(result.permission.data.amountPerSecond)).toBe( + amountPerSecond, + ); + expect(result.permission.data.startTime).toBe(startTime); + expect(result.permission.justification).toBe('Test justification'); + }); + + it('decodes an erc20-token-periodic permission successfully', () => { + const { + TimestampEnforcer, + ERC20PeriodTransferEnforcer, + ValueLteEnforcer, + NonceEnforcer, + } = contracts; + + const delegator = delegatorAddressA; + const delegate = delegateAddressB; + + const beforeThreshold = 1720000; + const expiryTerms = createTimestampTerms( + { afterThreshold: 0, beforeThreshold }, + { out: 'hex' }, + ); + + const tokenAddress = + '0x3333333333333333333333333333333333333333' as Hex; + const periodAmount = 123456n; + const periodDuration = 86400; + const startDate = 1715664; + const periodicTerms = createERC20TokenPeriodTransferTerms( + { tokenAddress, periodAmount, periodDuration, startDate }, + { out: 'hex' }, + ); + const valueLteTerms = createValueLteTerms( + { maxValue: 0n }, + { out: 'hex' }, + ); + + const caveats = [ + { + enforcer: TimestampEnforcer, + terms: expiryTerms, + args: '0x', + } as const, + { + enforcer: ERC20PeriodTransferEnforcer, + terms: periodicTerms, + args: '0x', + } as const, + { + enforcer: ValueLteEnforcer, + terms: valueLteTerms, + args: '0x', + } as const, + { enforcer: NonceEnforcer, terms: '0x', args: '0x' } as const, + ]; + + const result = rootMessenger.call( + 'GatorPermissionsController:decodePermissionFromPermissionContextForOrigin', + { + origin: controller.gatorPermissionsProviderSnapId, + chainId, + delegation: { + delegate, + delegator, + authority: ROOT_AUTHORITY as Hex, + caveats, + }, + metadata: buildMetadata('Test justification'), + }, + ); + + expect(result.permission.type).toBe('erc20-token-periodic'); + expect(result.expiry).toBe(beforeThreshold); + expect(result.permission.data.tokenAddress.toLowerCase()).toBe( + tokenAddress.toLowerCase(), + ); + expect(hexToBigInt(result.permission.data.periodAmount)).toBe( + periodAmount, + ); + expect(result.permission.data.periodDuration).toBe(periodDuration); + expect(result.permission.data.startTime).toBe(startDate); + expect(result.permission.justification).toBe('Test justification'); + }); + + it('decodes a native-token-allowance permission successfully', () => { + const { + TimestampEnforcer, + NativeTokenPeriodTransferEnforcer, + ExactCalldataEnforcer, + NonceEnforcer, + } = contracts; + + const delegator = delegatorAddressA; + const delegate = delegateAddressB; + + const beforeThreshold = 1720000; + const expiryTerms = createTimestampTerms( + { afterThreshold: 0, beforeThreshold }, + { out: 'hex' }, + ); + + const allowanceAmount = 123456n; + const startDate = 1715664; + const allowanceTerms = createNativeTokenPeriodTransferTerms( + { + periodAmount: allowanceAmount, + periodDuration: UINT256_MAX, + startDate, + }, + { out: 'hex' }, + ); + + const caveats = [ + { + enforcer: TimestampEnforcer, + terms: expiryTerms, + args: '0x', + } as const, + { + enforcer: NativeTokenPeriodTransferEnforcer, + terms: allowanceTerms, + args: '0x', + } as const, + { enforcer: ExactCalldataEnforcer, terms: '0x', args: '0x' } as const, + { enforcer: NonceEnforcer, terms: '0x', args: '0x' } as const, + ]; + + const result = rootMessenger.call( + 'GatorPermissionsController:decodePermissionFromPermissionContextForOrigin', + { + origin: controller.gatorPermissionsProviderSnapId, + chainId, + delegation: { + delegate, + delegator, + authority: ROOT_AUTHORITY as Hex, + caveats, + }, + metadata: buildMetadata('Test justification'), + }, + ); + + expect(result.permission.type).toBe('native-token-allowance'); + expect(result.expiry).toBe(beforeThreshold); + expect(hexToBigInt(result.permission.data.allowanceAmount)).toBe( + allowanceAmount, + ); + expect(result.permission.data.startTime).toBe(startDate); + expect(result.permission.justification).toBe('Test justification'); + }); + + it('decodes an erc20-token-allowance permission successfully', () => { + const { + TimestampEnforcer, + ERC20PeriodTransferEnforcer, + ValueLteEnforcer, + NonceEnforcer, + } = contracts; + + const delegator = delegatorAddressA; + const delegate = delegateAddressB; + + const beforeThreshold = 1720000; + const expiryTerms = createTimestampTerms( + { afterThreshold: 0, beforeThreshold }, + { out: 'hex' }, + ); + + const tokenAddress = + '0x3333333333333333333333333333333333333333' as Hex; + const allowanceAmount = 123456n; + const startDate = 1715664; + const allowanceTerms = createERC20TokenPeriodTransferTerms( + { + tokenAddress, + periodAmount: allowanceAmount, + periodDuration: UINT256_MAX, + startDate, + }, + { out: 'hex' }, + ); + const valueLteTerms = createValueLteTerms( + { maxValue: 0n }, + { out: 'hex' }, + ); + + const caveats = [ + { + enforcer: TimestampEnforcer, + terms: expiryTerms, + args: '0x', + } as const, + { + enforcer: ERC20PeriodTransferEnforcer, + terms: allowanceTerms, + args: '0x', + } as const, + { + enforcer: ValueLteEnforcer, + terms: valueLteTerms, + args: '0x', + } as const, + { enforcer: NonceEnforcer, terms: '0x', args: '0x' } as const, + ]; + + const result = rootMessenger.call( + 'GatorPermissionsController:decodePermissionFromPermissionContextForOrigin', + { + origin: controller.gatorPermissionsProviderSnapId, + chainId, + delegation: { + delegate, + delegator, + authority: ROOT_AUTHORITY as Hex, + caveats, + }, + metadata: buildMetadata('Test justification'), + }, + ); + + expect(result.permission.type).toBe('erc20-token-allowance'); + expect(result.expiry).toBe(beforeThreshold); + expect(result.permission.data.tokenAddress.toLowerCase()).toBe( + tokenAddress.toLowerCase(), + ); + expect(hexToBigInt(result.permission.data.allowanceAmount)).toBe( + allowanceAmount, + ); + expect(result.permission.data.startTime).toBe(startDate); + expect(result.permission.justification).toBe('Test justification'); + }); + + it('decodes a token-approval-revocation permission successfully', () => { + const { TimestampEnforcer, ApprovalRevocationEnforcer, NonceEnforcer } = + contracts; + + const delegator = delegatorAddressA; + const delegate = delegateAddressB; + + const beforeThreshold = 1720000; + const expiryTerms = createTimestampTerms( + { afterThreshold: 0, beforeThreshold }, + { out: 'hex' }, + ); + + const approvalRevocationTerms = createApprovalRevocationTerms( + { + erc20Approve: true, + erc721Approve: false, + erc721SetApprovalForAll: true, + permit2Approve: false, + permit2Lockdown: true, + permit2InvalidateNonces: false, + }, + { out: 'hex' }, + ); + + const caveats = [ + { + enforcer: TimestampEnforcer, + terms: expiryTerms, + args: '0x', + } as const, + { + enforcer: ApprovalRevocationEnforcer, + terms: approvalRevocationTerms, + args: '0x', + } as const, + { enforcer: NonceEnforcer, terms: '0x', args: '0x' } as const, + ]; + + const result = rootMessenger.call( + 'GatorPermissionsController:decodePermissionFromPermissionContextForOrigin', + { + origin: controller.gatorPermissionsProviderSnapId, + chainId, + delegation: { + delegate, + delegator, + authority: ROOT_AUTHORITY as Hex, + caveats, + }, + metadata: buildMetadata('Test justification'), + }, + ); + + expect(result.permission.type).toBe('token-approval-revocation'); + expect(result.expiry).toBe(beforeThreshold); + expect(result.permission.data.erc20Approve).toBe(true); + expect(result.permission.data.erc721Approve).toBe(false); + expect(result.permission.data.erc721SetApprovalForAll).toBe(true); + expect(result.permission.data.permit2Approve).toBe(false); + expect(result.permission.data.permit2Lockdown).toBe(true); + expect(result.permission.data.permit2InvalidateNonces).toBe(false); + expect(result.permission.justification).toBe('Test justification'); + }); + }); + }); + + describe('submitRevocation', () => { + it('should successfully submit a revocation when gator permissions are enabled', async () => { + const mockHandleRequestHandler = jest.fn().mockResolvedValue(undefined); + const rootMessenger = getRootMessenger({ + snapControllerHandleRequestActionHandler: mockHandleRequestHandler, + }); + const messenger = getMessenger(rootMessenger); + + const controller = new GatorPermissionsController({ + messenger, + config: { + ...DEFAULT_TEST_CONFIG, + gatorPermissionsProviderSnapId: + MOCK_GATOR_PERMISSIONS_PROVIDER_SNAP_ID, + }, + state: { + pendingRevocations: [ + { + txId: 'test-tx-id', + permissionContext: '0x1234567890abcdef1234567890abcdef12345678', + }, + ], + }, + }); + + const revocationParams: RevocationParams = { + permissionContext: '0x1234567890abcdef1234567890abcdef12345678', + txHash: undefined, + }; + + await rootMessenger.call( + 'GatorPermissionsController:submitRevocation', + revocationParams, + ); + + expect(mockHandleRequestHandler).toHaveBeenCalledWith({ + snapId: MOCK_GATOR_PERMISSIONS_PROVIDER_SNAP_ID, + origin: 'metamask', + handler: 'onRpcRequest', + request: { + jsonrpc: '2.0', + method: 'permissionsProvider_submitRevocation', + params: revocationParams, + }, + }); + expect(controller.state.pendingRevocations).toStrictEqual([]); + }); + + it('should submit revocation when controller is configured', async () => { + const mockHandleRequestHandler = jest.fn().mockResolvedValue(undefined); + const rootMessenger = getRootMessenger({ + snapControllerHandleRequestActionHandler: mockHandleRequestHandler, + }); + const messenger = getMessenger(rootMessenger); + // eslint-disable-next-line no-new + new GatorPermissionsController({ + messenger, + config: DEFAULT_TEST_CONFIG, + }); + + const revocationParams: RevocationParams = { + permissionContext: '0x1234567890abcdef1234567890abcdef12345678', + txHash: undefined, + }; + + expect( + await rootMessenger.call( + 'GatorPermissionsController:submitRevocation', + revocationParams, + ), + ).toBeUndefined(); + }); + + it('should throw GatorPermissionsProviderError when snap request fails', async () => { + const mockHandleRequestHandler = jest + .fn() + .mockRejectedValue(new Error('Snap request failed')); + const rootMessenger = getRootMessenger({ + snapControllerHandleRequestActionHandler: mockHandleRequestHandler, + }); + const messenger = getMessenger(rootMessenger); + + // eslint-disable-next-line no-new + new GatorPermissionsController({ + messenger, + config: { + ...DEFAULT_TEST_CONFIG, + gatorPermissionsProviderSnapId: + MOCK_GATOR_PERMISSIONS_PROVIDER_SNAP_ID, + }, + }); + + const revocationParams: RevocationParams = { + permissionContext: '0x1234567890abcdef1234567890abcdef12345678', + txHash: undefined, + }; + + await expect( + rootMessenger.call( + 'GatorPermissionsController:submitRevocation', + revocationParams, + ), + ).rejects.toThrow( + 'Failed to handle snap request to gator permissions provider for method permissionsProvider_submitRevocation', + ); + }); + + it('should clear pending revocation in finally block even if refresh fails', async () => { + const mockHandleRequestHandler = jest.fn().mockResolvedValue(undefined); + const rootMessenger = getRootMessenger({ + snapControllerHandleRequestActionHandler: mockHandleRequestHandler, + }); + const messenger = getMessenger(rootMessenger); + + const controller = new GatorPermissionsController({ + messenger, + config: { + ...DEFAULT_TEST_CONFIG, + gatorPermissionsProviderSnapId: + MOCK_GATOR_PERMISSIONS_PROVIDER_SNAP_ID, + }, + state: { + pendingRevocations: [ + { + txId: 'test-tx-id', + permissionContext: '0x1234567890abcdef1234567890abcdef12345678', + }, + ], + }, + }); + + // Mock fetchAndUpdateGatorPermissions to fail with GatorPermissionsFetchError + // (which is what it actually throws in real scenarios) + const fetchError = new GatorPermissionsFetchError({ + message: 'Failed to fetch gator permissions', + cause: new Error('Refresh failed'), + }); + jest + .spyOn(controller, 'fetchAndUpdateGatorPermissions') + .mockRejectedValue(fetchError); + + const revocationParams: RevocationParams = { + permissionContext: '0x1234567890abcdef1234567890abcdef12345678', + txHash: undefined, + }; + + // Should throw GatorPermissionsFetchError (not GatorPermissionsProviderError) + // because revocation succeeded but refresh failed + await expect( + rootMessenger.call( + 'GatorPermissionsController:submitRevocation', + revocationParams, + ), + ).rejects.toThrow(GatorPermissionsFetchError); + + // Verify the error message indicates refresh failure, not revocation failure + await expect( + rootMessenger.call( + 'GatorPermissionsController:submitRevocation', + revocationParams, + ), + ).rejects.toThrow( + 'Failed to refresh permissions list after successful revocation', + ); + + // Pending revocation should still be cleared despite refresh failure + expect(controller.state.pendingRevocations).toStrictEqual([]); + }); + }); + + describe('submitDirectRevocation', () => { + it('should add to pending revocations and immediately submit revocation', async () => { + const mockHandleRequestHandler = jest.fn().mockResolvedValue(undefined); + const rootMessenger = getRootMessenger({ + snapControllerHandleRequestActionHandler: mockHandleRequestHandler, + }); + const messenger = getMessenger(rootMessenger); + + const controller = new GatorPermissionsController({ + messenger, + config: { + ...DEFAULT_TEST_CONFIG, + gatorPermissionsProviderSnapId: + MOCK_GATOR_PERMISSIONS_PROVIDER_SNAP_ID, + }, + }); + + const revocationParams: RevocationParams = { + permissionContext: '0x1234567890abcdef1234567890abcdef12345678', + txHash: undefined, + }; + + await rootMessenger.call( + 'GatorPermissionsController:submitDirectRevocation', + revocationParams, + ); + + // Should have called submitRevocation + expect(mockHandleRequestHandler).toHaveBeenCalledWith({ + snapId: MOCK_GATOR_PERMISSIONS_PROVIDER_SNAP_ID, + origin: 'metamask', + handler: 'onRpcRequest', + request: { + jsonrpc: '2.0', + method: 'permissionsProvider_submitRevocation', + params: revocationParams, + }, + }); + + // Pending revocation should be cleared after successful submission + expect(controller.state.pendingRevocations).toStrictEqual([]); + }); + + it('should add pending revocation with placeholder txId', async () => { + const mockHandleRequestHandler = jest.fn().mockResolvedValue(undefined); + const rootMessenger = getRootMessenger({ + snapControllerHandleRequestActionHandler: mockHandleRequestHandler, + }); + const messenger = getMessenger(rootMessenger); + + const controller = new GatorPermissionsController({ + messenger, + config: { + ...DEFAULT_TEST_CONFIG, + gatorPermissionsProviderSnapId: + MOCK_GATOR_PERMISSIONS_PROVIDER_SNAP_ID, + }, + }); + + const permissionContext = + '0x1234567890abcdef1234567890abcdef12345678' as Hex; + const revocationParams: RevocationParams = { + permissionContext, + txHash: undefined, + }; + + // Spy on submitRevocation to check pending state before it's called + const submitRevocationSpy = jest.spyOn(controller, 'submitRevocation'); + + await rootMessenger.call( + 'GatorPermissionsController:submitDirectRevocation', + revocationParams, + ); + + // Verify that pending revocation was added (before submitRevocation clears it) + // We check by verifying submitRevocation was called, which clears pending + expect(submitRevocationSpy).toHaveBeenCalledWith(revocationParams); + expect(controller.state.pendingRevocations).toStrictEqual([]); + }); + + it('should clear pending revocation even if submitRevocation fails (finally block)', async () => { + const mockHandleRequestHandler = jest + .fn() + .mockRejectedValue(new Error('Snap request failed')); + const rootMessenger = getRootMessenger({ + snapControllerHandleRequestActionHandler: mockHandleRequestHandler, + }); + const messenger = getMessenger(rootMessenger); + + const controller = new GatorPermissionsController({ + messenger, + config: { + ...DEFAULT_TEST_CONFIG, + gatorPermissionsProviderSnapId: + MOCK_GATOR_PERMISSIONS_PROVIDER_SNAP_ID, + }, + }); + + const permissionContext = + '0x1234567890abcdef1234567890abcdef12345678' as Hex; + const revocationParams: RevocationParams = { + permissionContext, + txHash: undefined, + }; + + await expect( + rootMessenger.call( + 'GatorPermissionsController:submitDirectRevocation', + revocationParams, + ), + ).rejects.toThrow( + 'Failed to handle snap request to gator permissions provider for method permissionsProvider_submitRevocation', + ); + + // Pending revocation is cleared in finally block even if submission failed + // This prevents stuck state, though the error is still thrown for caller handling + expect(controller.state.pendingRevocations).toStrictEqual([]); + }); + }); + + describe('isPendingRevocation', () => { + it('should return true when permission context is in pending revocations', () => { + const rootMessenger = getRootMessenger(); + const messenger = getMessenger(rootMessenger); + const permissionContext = + '0x1234567890abcdef1234567890abcdef12345678' as Hex; + // eslint-disable-next-line no-new + new GatorPermissionsController({ + messenger, + config: { + ...DEFAULT_TEST_CONFIG, + gatorPermissionsProviderSnapId: + MOCK_GATOR_PERMISSIONS_PROVIDER_SNAP_ID, + }, + state: { + pendingRevocations: [ + { + txId: 'test-tx-id', + permissionContext, + }, + ], + }, + }); + + expect( + rootMessenger.call( + 'GatorPermissionsController:isPendingRevocation', + permissionContext, + ), + ).toBe(true); + }); + + it('should return false when permission context is not in pending revocations', () => { + const rootMessenger = getRootMessenger(); + const messenger = getMessenger(rootMessenger); + // eslint-disable-next-line no-new + new GatorPermissionsController({ + messenger, + config: { + ...DEFAULT_TEST_CONFIG, + gatorPermissionsProviderSnapId: + MOCK_GATOR_PERMISSIONS_PROVIDER_SNAP_ID, + }, + state: { + pendingRevocations: [ + { + txId: 'test-tx-id', + permissionContext: '0x1234567890abcdef1234567890abcdef12345678', + }, + ], + }, + }); + + expect( + rootMessenger.call( + 'GatorPermissionsController:isPendingRevocation', + '0xabcdefabcdefabcdefabcdefabcdefabcdefabcdef' as Hex, + ), + ).toBe(false); + }); + + it('should be case-insensitive when checking permission context', () => { + const rootMessenger = getRootMessenger(); + const messenger = getMessenger(rootMessenger); + const permissionContext = + '0x1234567890abcdef1234567890abcdef12345678' as Hex; + // eslint-disable-next-line no-new + new GatorPermissionsController({ + messenger, + config: { + ...DEFAULT_TEST_CONFIG, + gatorPermissionsProviderSnapId: + MOCK_GATOR_PERMISSIONS_PROVIDER_SNAP_ID, + }, + state: { + pendingRevocations: [ + { + txId: 'test-tx-id', + permissionContext: permissionContext.toLowerCase() as Hex, + }, + ], + }, + }); + + expect( + rootMessenger.call( + 'GatorPermissionsController:isPendingRevocation', + permissionContext.toUpperCase() as Hex, + ), + ).toBe(true); + }); + }); + + describe('addPendingRevocation', () => { + beforeEach(() => { + jest.useFakeTimers(); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + it('should submit revocation when transaction is confirmed', async () => { + const mockHandleRequestHandler = jest.fn().mockResolvedValue(undefined); + const rootMessenger = getRootMessenger({ + snapControllerHandleRequestActionHandler: mockHandleRequestHandler, + }); + const messenger = getMessenger(rootMessenger); + + // eslint-disable-next-line no-new + new GatorPermissionsController({ + messenger, + config: { + ...DEFAULT_TEST_CONFIG, + gatorPermissionsProviderSnapId: + MOCK_GATOR_PERMISSIONS_PROVIDER_SNAP_ID, + }, + }); + + const txId = 'test-tx-id'; + const permissionContext = '0x1234567890abcdef1234567890abcdef12345678'; + + await rootMessenger.call( + 'GatorPermissionsController:addPendingRevocation', + { txId, permissionContext }, + ); + + // Emit transaction approved event (user confirms) + rootMessenger.publish('TransactionController:transactionApproved', { + transactionMeta: { id: txId } as TransactionMeta, + }); + + // Emit transaction confirmed event + rootMessenger.publish('TransactionController:transactionConfirmed', { + id: txId, + status: TransactionStatus.confirmed, + } as TransactionMeta); + + await flushPromises(); + + // Verify submitRevocation was called + expect(mockHandleRequestHandler).toHaveBeenCalledWith({ + snapId: MOCK_GATOR_PERMISSIONS_PROVIDER_SNAP_ID, + origin: 'metamask', + handler: 'onRpcRequest', + request: { + jsonrpc: '2.0', + method: 'permissionsProvider_submitRevocation', + params: { permissionContext, txHash: undefined }, + }, + }); + + // Verify that permissions are refreshed after revocation (getGrantedPermissions is called) + expect(mockHandleRequestHandler).toHaveBeenCalledWith({ + snapId: MOCK_GATOR_PERMISSIONS_PROVIDER_SNAP_ID, + origin: 'metamask', + handler: 'onRpcRequest', + request: { + jsonrpc: '2.0', + method: 'permissionsProvider_getGrantedPermissions', + params: { isRevoked: false }, + }, + }); + }); + + it('should throw and error if the transaction fails', async () => { + const mockHandleRequestHandler = jest.fn().mockResolvedValue(undefined); + const rootMessenger = getRootMessenger({ + snapControllerHandleRequestActionHandler: mockHandleRequestHandler, + }); + const messenger = getMessenger(rootMessenger); + + const controller = new GatorPermissionsController({ + messenger, + config: { + ...DEFAULT_TEST_CONFIG, + gatorPermissionsProviderSnapId: + MOCK_GATOR_PERMISSIONS_PROVIDER_SNAP_ID, + }, + }); + + const txId = 'test-tx-id'; + const permissionContext = '0x1234567890abcdef1234567890abcdef12345678'; + + await rootMessenger.call( + 'GatorPermissionsController:addPendingRevocation', + { txId, permissionContext }, + ); + + // Emit transaction approved event (user confirms) + rootMessenger.publish('TransactionController:transactionApproved', { + transactionMeta: { id: txId } as TransactionMeta, + }); + + // Emit transaction confirmed event + rootMessenger.publish('TransactionController:transactionConfirmed', { + id: txId, + status: TransactionStatus.failed, + } as TransactionMeta); + + await flushPromises(); + + // Should not call submitRevocation + expect(mockHandleRequestHandler).toHaveBeenCalledTimes(1); + + // Verify that permissions are refreshed after revocation (getGrantedPermissions is called) + expect(mockHandleRequestHandler).toHaveBeenCalledWith({ + snapId: MOCK_GATOR_PERMISSIONS_PROVIDER_SNAP_ID, + origin: 'metamask', + handler: 'onRpcRequest', + request: { + jsonrpc: '2.0', + method: 'permissionsProvider_getGrantedPermissions', + params: { isRevoked: false }, + }, + }); + + // Should not be in pending revocations + expect(controller.state.pendingRevocations).toStrictEqual([]); + }); + + it('should submit revocation metadata when transaction is confirmed', async () => { + const mockHandleRequestHandler = jest.fn().mockResolvedValue(undefined); + const rootMessenger = getRootMessenger({ + snapControllerHandleRequestActionHandler: mockHandleRequestHandler, + }); + const messenger = getMessenger(rootMessenger); + + // eslint-disable-next-line no-new + new GatorPermissionsController({ + messenger, + config: { + ...DEFAULT_TEST_CONFIG, + gatorPermissionsProviderSnapId: + MOCK_GATOR_PERMISSIONS_PROVIDER_SNAP_ID, + }, + }); + + const txId = 'test-tx-id'; + const permissionContext = '0x1234567890abcdef1234567890abcdef12345678'; + const hash = '0x-mock-hash'; + + await rootMessenger.call( + 'GatorPermissionsController:addPendingRevocation', + { txId, permissionContext }, + ); + + // Emit transaction approved event (user confirms) + rootMessenger.publish('TransactionController:transactionApproved', { + transactionMeta: { id: txId } as TransactionMeta, + }); + + // Emit transaction confirmed event + rootMessenger.publish('TransactionController:transactionConfirmed', { + id: txId, + status: TransactionStatus.confirmed, + hash, + } as TransactionMeta); + + await flushPromises(); + + // Verify submitRevocation was called + expect(mockHandleRequestHandler).toHaveBeenCalledWith({ + snapId: MOCK_GATOR_PERMISSIONS_PROVIDER_SNAP_ID, + origin: 'metamask', + handler: 'onRpcRequest', + request: { + jsonrpc: '2.0', + method: 'permissionsProvider_submitRevocation', + params: { + permissionContext, + txHash: hash, + }, + }, + }); + + // Verify that permissions are refreshed after revocation (getGrantedPermissions is called) + expect(mockHandleRequestHandler).toHaveBeenCalledWith({ + snapId: MOCK_GATOR_PERMISSIONS_PROVIDER_SNAP_ID, + origin: 'metamask', + handler: 'onRpcRequest', + request: { + jsonrpc: '2.0', + method: 'permissionsProvider_getGrantedPermissions', + params: { isRevoked: false }, + }, + }); + }); + + it('should cleanup without adding to state when transaction is rejected by user', async () => { + const mockHandleRequestHandler = jest.fn().mockResolvedValue(undefined); + const rootMessenger = getRootMessenger({ + snapControllerHandleRequestActionHandler: mockHandleRequestHandler, + }); + const messenger = getMessenger(rootMessenger); + + const controller = new GatorPermissionsController({ + messenger, + config: { + ...DEFAULT_TEST_CONFIG, + gatorPermissionsProviderSnapId: + MOCK_GATOR_PERMISSIONS_PROVIDER_SNAP_ID, + }, + }); + + const txId = 'test-tx-id'; + const permissionContext = '0x1234567890abcdef1234567890abcdef12345678'; + + await rootMessenger.call( + 'GatorPermissionsController:addPendingRevocation', + { txId, permissionContext }, + ); + + // Verify pending revocation is not in state yet + expect(controller.state.pendingRevocations).toStrictEqual([]); + + // Emit transaction rejected event (user cancels) + rootMessenger.publish('TransactionController:transactionRejected', { + transactionMeta: { id: txId } as TransactionMeta, + }); + + // Wait for async operations + await Promise.resolve(); + + // Should not call submitRevocation + expect(mockHandleRequestHandler).not.toHaveBeenCalled(); + // Should not be in pending revocations + expect(controller.state.pendingRevocations).toStrictEqual([]); + }); + + it('should cleanup and refresh permissions without submitting revocation when transaction fails', async () => { + const mockHandleRequestHandler = jest.fn().mockResolvedValue(undefined); + const rootMessenger = getRootMessenger({ + snapControllerHandleRequestActionHandler: mockHandleRequestHandler, + }); + const messenger = getMessenger(rootMessenger); + + const controller = new GatorPermissionsController({ + messenger, + config: { + ...DEFAULT_TEST_CONFIG, + gatorPermissionsProviderSnapId: + MOCK_GATOR_PERMISSIONS_PROVIDER_SNAP_ID, + }, + }); + + const txId = 'test-tx-id'; + const permissionContext = '0x1234567890abcdef1234567890abcdef12345678'; + + await rootMessenger.call( + 'GatorPermissionsController:addPendingRevocation', + { txId, permissionContext }, + ); + + // Emit transaction failed event + rootMessenger.publish('TransactionController:transactionFailed', { + transactionMeta: { id: txId } as TransactionMeta, + error: 'Transaction failed', + }); + + // Wait for async operations + await Promise.resolve(); + + // Should refresh permissions with isRevoked: false + expect(mockHandleRequestHandler).toHaveBeenCalledWith({ + handler: 'onRpcRequest', + origin: 'metamask', + request: { + jsonrpc: '2.0', + method: 'permissionsProvider_getGrantedPermissions', + params: { isRevoked: false }, + }, + snapId: MOCK_GATOR_PERMISSIONS_PROVIDER_SNAP_ID, + }); + + // Should not be in pending revocations + expect(controller.state.pendingRevocations).toStrictEqual([]); + }); + + it('should cleanup and refresh permissions without submitting revocation when transaction is dropped', async () => { + const mockHandleRequestHandler = jest.fn().mockResolvedValue(undefined); + const rootMessenger = getRootMessenger({ + snapControllerHandleRequestActionHandler: mockHandleRequestHandler, + }); + const messenger = getMessenger(rootMessenger); + + const controller = new GatorPermissionsController({ + messenger, + config: { + ...DEFAULT_TEST_CONFIG, + gatorPermissionsProviderSnapId: + MOCK_GATOR_PERMISSIONS_PROVIDER_SNAP_ID, + }, + }); + + const txId = 'test-tx-id'; + const permissionContext = '0x1234567890abcdef1234567890abcdef12345678'; + + await rootMessenger.call( + 'GatorPermissionsController:addPendingRevocation', + { txId, permissionContext }, + ); + + // Emit transaction dropped event + rootMessenger.publish('TransactionController:transactionDropped', { + transactionMeta: { id: txId } as TransactionMeta, + }); + + // Wait for async operations + await Promise.resolve(); + + // Should refresh permissions with isRevoked: false + expect(mockHandleRequestHandler).toHaveBeenCalledWith({ + handler: 'onRpcRequest', + origin: 'metamask', + request: { + jsonrpc: '2.0', + method: 'permissionsProvider_getGrantedPermissions', + params: { isRevoked: false }, + }, + snapId: MOCK_GATOR_PERMISSIONS_PROVIDER_SNAP_ID, + }); + + // Should not be in pending revocations + expect(controller.state.pendingRevocations).toStrictEqual([]); + }); + + it('should handle error when refreshing permissions after transaction fails', async () => { + const mockError = new Error('Failed to fetch permissions'); + const mockHandleRequestHandler = jest.fn().mockRejectedValue(mockError); + const rootMessenger = getRootMessenger({ + snapControllerHandleRequestActionHandler: mockHandleRequestHandler, + }); + const messenger = getMessenger(rootMessenger); + + const controller = new GatorPermissionsController({ + messenger, + config: { + ...DEFAULT_TEST_CONFIG, + gatorPermissionsProviderSnapId: + MOCK_GATOR_PERMISSIONS_PROVIDER_SNAP_ID, + }, + }); + + const txId = 'test-tx-id'; + const permissionContext = '0x1234567890abcdef1234567890abcdef12345678'; + + await rootMessenger.call( + 'GatorPermissionsController:addPendingRevocation', + { txId, permissionContext }, + ); + + // Emit transaction failed event + rootMessenger.publish('TransactionController:transactionFailed', { + transactionMeta: { id: txId } as TransactionMeta, + error: 'Transaction failed', + }); + + // Wait for async operations and catch blocks to execute + await Promise.resolve(); + await Promise.resolve(); + + // Should have attempted to refresh permissions + expect(mockHandleRequestHandler).toHaveBeenCalled(); + + // Should not be in pending revocations + expect(controller.state.pendingRevocations).toStrictEqual([]); + }); + + it('should handle error when refreshing permissions after transaction is dropped', async () => { + const mockError = new Error('Failed to fetch permissions'); + const mockHandleRequestHandler = jest.fn().mockRejectedValue(mockError); + const rootMessenger = getRootMessenger({ + snapControllerHandleRequestActionHandler: mockHandleRequestHandler, + }); + const messenger = getMessenger(rootMessenger); + + const controller = new GatorPermissionsController({ + messenger, + config: { + ...DEFAULT_TEST_CONFIG, + gatorPermissionsProviderSnapId: + MOCK_GATOR_PERMISSIONS_PROVIDER_SNAP_ID, + }, + }); + + const txId = 'test-tx-id'; + const permissionContext = '0x1234567890abcdef1234567890abcdef12345678'; + + await rootMessenger.call( + 'GatorPermissionsController:addPendingRevocation', + { txId, permissionContext }, + ); + + // Emit transaction dropped event + rootMessenger.publish('TransactionController:transactionDropped', { + transactionMeta: { id: txId } as TransactionMeta, + }); + + // Wait for async operations and catch blocks to execute + await Promise.resolve(); + await Promise.resolve(); + + // Should have attempted to refresh permissions + expect(mockHandleRequestHandler).toHaveBeenCalled(); + + // Should not be in pending revocations + expect(controller.state.pendingRevocations).toStrictEqual([]); + }); + + it('should cleanup without submitting revocation when timeout is reached', async () => { + const mockHandleRequestHandler = jest.fn().mockResolvedValue(undefined); + const rootMessenger = getRootMessenger({ + snapControllerHandleRequestActionHandler: mockHandleRequestHandler, + }); + const messenger = getMessenger(rootMessenger); + + // eslint-disable-next-line no-new + new GatorPermissionsController({ + messenger, + config: { + ...DEFAULT_TEST_CONFIG, + gatorPermissionsProviderSnapId: + MOCK_GATOR_PERMISSIONS_PROVIDER_SNAP_ID, + }, + }); + + const txId = 'test-tx-id'; + const permissionContext = '0x1234567890abcdef1234567890abcdef12345678'; + + await rootMessenger.call( + 'GatorPermissionsController:addPendingRevocation', + { txId, permissionContext }, + ); + + // Fast-forward time by 2 hours + jest.advanceTimersByTime(2 * 60 * 60 * 1000); + + // Wait for async operations + await Promise.resolve(); + + // Should not call submitRevocation + expect(mockHandleRequestHandler).not.toHaveBeenCalled(); + }); + + it('should add to pending revocations state only after user approval', async () => { + const mockHandleRequestHandler = jest.fn().mockResolvedValue(undefined); + const rootMessenger = getRootMessenger({ + snapControllerHandleRequestActionHandler: mockHandleRequestHandler, + }); + const messenger = getMessenger(rootMessenger); + + const controller = new GatorPermissionsController({ + messenger, + config: { + ...DEFAULT_TEST_CONFIG, + gatorPermissionsProviderSnapId: + MOCK_GATOR_PERMISSIONS_PROVIDER_SNAP_ID, + }, + }); + + const txId = 'test-tx-id'; + const permissionContext = '0x1234567890abcdef1234567890abcdef12345678'; + + await rootMessenger.call( + 'GatorPermissionsController:addPendingRevocation', + { txId, permissionContext }, + ); + + // Before approval, pending revocation should not be in state + expect(controller.state.pendingRevocations).toStrictEqual([]); + + // Emit transaction approved event (user confirms) + rootMessenger.publish('TransactionController:transactionApproved', { + transactionMeta: { id: txId } as TransactionMeta, + }); + + // After approval, pending revocation should be in state + expect(controller.state.pendingRevocations).toStrictEqual([ + { txId, permissionContext }, + ]); + }); + + it('should not submit revocation for different transaction IDs', async () => { + const mockHandleRequestHandler = jest.fn().mockResolvedValue(undefined); + const rootMessenger = getRootMessenger({ + snapControllerHandleRequestActionHandler: mockHandleRequestHandler, + }); + const messenger = getMessenger(rootMessenger); + + const controller = new GatorPermissionsController({ + messenger, + config: { + ...DEFAULT_TEST_CONFIG, + gatorPermissionsProviderSnapId: + MOCK_GATOR_PERMISSIONS_PROVIDER_SNAP_ID, + }, + }); + + const txId = 'test-tx-id'; + const permissionContext = '0x1234567890abcdef1234567890abcdef12345678'; + const differentTxId = 'different-tx-id'; + + await rootMessenger.call( + 'GatorPermissionsController:addPendingRevocation', + { txId, permissionContext }, + ); + + // Emit transaction approved event for different transaction + rootMessenger.publish('TransactionController:transactionApproved', { + transactionMeta: { id: differentTxId } as TransactionMeta, + }); + + // Emit transaction rejected event for different transaction + rootMessenger.publish('TransactionController:transactionRejected', { + transactionMeta: { id: differentTxId } as TransactionMeta, + }); + + // Emit transaction confirmed event for different transaction + rootMessenger.publish('TransactionController:transactionConfirmed', { + id: differentTxId, + status: TransactionStatus.confirmed, + } as TransactionMeta); + + // Emit transaction failed event for different transaction + rootMessenger.publish('TransactionController:transactionFailed', { + transactionMeta: { id: differentTxId } as TransactionMeta, + error: 'Transaction failed', + }); + + // Emit transaction dropped event for different transaction + rootMessenger.publish('TransactionController:transactionDropped', { + transactionMeta: { id: differentTxId } as TransactionMeta, + }); + + // Wait for async operations + await Promise.resolve(); + + // Should not call submitRevocation or add to pending revocations + expect(mockHandleRequestHandler).not.toHaveBeenCalled(); + expect(controller.state.pendingRevocations).toStrictEqual([]); + }); + + it('should cleanup listeners before timeout is set when transaction fails during subscription', async () => { + const mockHandleRequestHandler = jest.fn().mockResolvedValue(undefined); + const rootMessenger = getRootMessenger({ + snapControllerHandleRequestActionHandler: mockHandleRequestHandler, + }); + const messenger = getMessenger(rootMessenger); + + const controller = new GatorPermissionsController({ + messenger, + config: { + ...DEFAULT_TEST_CONFIG, + gatorPermissionsProviderSnapId: + MOCK_GATOR_PERMISSIONS_PROVIDER_SNAP_ID, + }, + }); + + const txId = 'test-tx-id'; + const permissionContext = '0x1234567890abcdef1234567890abcdef12345678'; + + const originalSubscribe = messenger.subscribe.bind(messenger); + jest + .spyOn(messenger, 'subscribe') + .mockImplementation((event, handler) => { + const subscription = originalSubscribe(event, handler); + if (event === 'TransactionController:transactionDropped') { + rootMessenger.publish('TransactionController:transactionFailed', { + transactionMeta: { id: txId } as TransactionMeta, + error: 'Transaction failed during subscription', + }); + } + return subscription; + }); + + await rootMessenger.call( + 'GatorPermissionsController:addPendingRevocation', + { txId, permissionContext }, + ); + + await flushPromises(); + + expect(mockHandleRequestHandler).toHaveBeenCalledWith({ + handler: 'onRpcRequest', + origin: 'metamask', + request: { + jsonrpc: '2.0', + method: 'permissionsProvider_getGrantedPermissions', + params: { isRevoked: false }, + }, + snapId: MOCK_GATOR_PERMISSIONS_PROVIDER_SNAP_ID, + }); + expect(controller.state.pendingRevocations).toStrictEqual([]); + }); + + it('should handle revocation submission errors gracefully', async () => { + const mockHandleRequestHandler = jest + .fn() + .mockRejectedValue(new Error('Revocation submission failed')); + const rootMessenger = getRootMessenger({ + snapControllerHandleRequestActionHandler: mockHandleRequestHandler, + }); + const messenger = getMessenger(rootMessenger); + + // eslint-disable-next-line no-new + new GatorPermissionsController({ + messenger, + config: { + ...DEFAULT_TEST_CONFIG, + gatorPermissionsProviderSnapId: + MOCK_GATOR_PERMISSIONS_PROVIDER_SNAP_ID, + }, + }); + + const txId = 'test-tx-id'; + const permissionContext = '0x1234567890abcdef1234567890abcdef12345678'; + + await rootMessenger.call( + 'GatorPermissionsController:addPendingRevocation', + { txId, permissionContext }, + ); + + // Emit transaction approved event (user confirms) + rootMessenger.publish('TransactionController:transactionApproved', { + transactionMeta: { id: txId } as TransactionMeta, + }); + + // Emit transaction confirmed event + rootMessenger.publish('TransactionController:transactionConfirmed', { + id: txId, + status: TransactionStatus.confirmed, + } as TransactionMeta); + + // Wait for async operations + await Promise.resolve(); + + // Should have attempted to call submitRevocation even though it failed + expect(mockHandleRequestHandler).toHaveBeenCalled(); + }); + }); +}); + +/** + * The union of actions that the root messenger allows. + */ +type AllGatorPermissionsControllerActions = + MessengerActions; + +/** + * The union of events that the root messenger allows. + */ +type AllGatorPermissionsControllerEvents = + MessengerEvents; + +type RootMessenger = Messenger< + MockAnyNamespace, + AllGatorPermissionsControllerActions, + AllGatorPermissionsControllerEvents +>; + +/** + * Constructs the root messenger. This can be used to call actions and + * publish events within the tests for this controller. + * + * @param args - The arguments to this function. + * `GatorPermissionsController:getState` action on the messenger. + * @param args.snapControllerHandleRequestActionHandler - Used to mock the + * `SnapController:handleRequest` action on the messenger. + * @param args.snapControllerHasActionHandler - Used to mock the + * `SnapController:hasSnap` action on the messenger. + * @returns The unrestricted messenger suited for GatorPermissionsController. + */ +function getRootMessenger({ + snapControllerHandleRequestActionHandler = jest + .fn< + ReturnType, + Parameters + >() + .mockResolvedValue(MOCK_GATOR_PERMISSIONS_STORAGE_ENTRIES), + snapControllerHasActionHandler = jest + .fn< + ReturnType, + Parameters + >() + .mockResolvedValue(true as never), +}: { + snapControllerHandleRequestActionHandler?: SnapControllerHandleRequestAction['handler']; + snapControllerHasActionHandler?: SnapControllerHasSnapAction['handler']; +} = {}): RootMessenger { + const rootMessenger: RootMessenger = new Messenger({ + namespace: MOCK_ANY_NAMESPACE, + }); + + const permissionStatusProviderRequest = + createDefaultPermissionStatusProviderRequest(); + const findNetworkClientIdByChainId = jest + .fn() + .mockReturnValue('test-network-client-id'); + const getNetworkClientById = jest.fn().mockReturnValue({ + provider: { request: permissionStatusProviderRequest }, + }); + lastNetworkControllerTestMocks = { + findNetworkClientIdByChainId, + getNetworkClientById, + permissionStatusProviderRequest, + }; + + rootMessenger.registerActionHandler( + 'SnapController:handleRequest', + snapControllerHandleRequestActionHandler, + ); + rootMessenger.registerActionHandler( + 'SnapController:hasSnap', + snapControllerHasActionHandler, + ); + rootMessenger.registerActionHandler( + 'NetworkController:findNetworkClientIdByChainId', + findNetworkClientIdByChainId, + ); + rootMessenger.registerActionHandler( + 'NetworkController:getNetworkClientById', + getNetworkClientById, + ); + return rootMessenger; +} + +/** + * Constructs the messenger supporting relevant SampleGasPricesController + * actions and events. + * + * @param rootMessenger - The root messenger to restrict. + * @returns The controller messenger. + */ +function getGatorPermissionsControllerMessenger( + rootMessenger = getRootMessenger(), +): GatorPermissionsControllerMessenger { + const gatorPermissionsControllerMessenger = new Messenger< + 'GatorPermissionsController', + AllGatorPermissionsControllerActions, + AllGatorPermissionsControllerEvents, + RootMessenger + >({ + namespace: 'GatorPermissionsController', + parent: rootMessenger, + }); + rootMessenger.delegate({ + messenger: gatorPermissionsControllerMessenger, + actions: [ + 'SnapController:handleRequest', + 'SnapController:hasSnap', + 'NetworkController:findNetworkClientIdByChainId', + 'NetworkController:getNetworkClientById', + ], + events: [ + 'TransactionController:transactionApproved', + 'TransactionController:transactionRejected', + 'TransactionController:transactionConfirmed', + 'TransactionController:transactionFailed', + 'TransactionController:transactionDropped', + ], + }); + return gatorPermissionsControllerMessenger; +} + +/** + * Shorthand alias for getGatorPermissionsControllerMessenger. + * + * @param rootMessenger - The root messenger to restrict. + * @returns The controller messenger. + */ +function getMessenger( + rootMessenger = getRootMessenger(), +): GatorPermissionsControllerMessenger { + return getGatorPermissionsControllerMessenger(rootMessenger); +} diff --git a/packages/gator-permissions-controller/src/GatorPermissionsController.ts b/packages/gator-permissions-controller/src/GatorPermissionsController.ts new file mode 100644 index 00000000000..61597240a0d --- /dev/null +++ b/packages/gator-permissions-controller/src/GatorPermissionsController.ts @@ -0,0 +1,1019 @@ +import type { + ControllerGetStateAction, + ControllerStateChangeEvent, + StateMetadata, +} from '@metamask/base-controller'; +import { BaseController } from '@metamask/base-controller'; +import type { Messenger } from '@metamask/messenger'; +import type { + NetworkControllerFindNetworkClientIdByChainIdAction, + NetworkControllerGetNetworkClientByIdAction, + NetworkClientId, +} from '@metamask/network-controller'; +import type { + SnapControllerHandleRequestAction, + SnapControllerHasSnapAction, +} from '@metamask/snaps-controllers'; +import type { SnapId } from '@metamask/snaps-sdk'; +import { HandlerType } from '@metamask/snaps-utils'; +import { TransactionStatus } from '@metamask/transaction-controller'; +import type { + TransactionControllerTransactionApprovedEvent, + TransactionControllerTransactionConfirmedEvent, + TransactionControllerTransactionDroppedEvent, + TransactionControllerTransactionFailedEvent, + TransactionControllerTransactionRejectedEvent, +} from '@metamask/transaction-controller'; +import type { Hex } from '@metamask/utils'; + +import { createPermissionDecodersForContracts } from './decodePermission/decoders/index.js'; +import { + delegationContractsByChainId, + toEnforcerAddressesByName, +} from './decodePermission/enforcerAddresses.js'; +import type { DecodedPermission } from './decodePermission/index.js'; +import { + findDecodersWithMatchingCaveatAddresses, + reconstructDecodedPermission, + selectUniqueDecoderAndDecodedPermission, +} from './decodePermission/index.js'; +import { + GatorPermissionsFetchError, + GatorPermissionsProviderError, + OriginNotAllowedError, + PermissionDecodingError, +} from './errors.js'; +import type { GatorPermissionsControllerMethodActions } from './GatorPermissionsController-method-action-types.js'; +import { controllerLog } from './logger.js'; +import { updateGrantedPermissionsStatus } from './permissionOnChainStatus.js'; +import type { PermissionStatusEip1193Provider } from './permissionOnChainStatus.js'; +import { GatorPermissionsSnapRpcMethod } from './types.js'; +import type { + StoredGatorPermission, + PermissionInfoWithMetadata, + GatorPermissionStatus, + SupportedPermissionType, + DelegationDetails, + RevocationParams, + PendingRevocationParams, +} from './types.js'; +import { executeSnapRpc } from './utils.js'; + +// === GENERAL === + +// Unique name for the controller +const controllerName = 'GatorPermissionsController'; + +const MESSENGER_EXPOSED_METHODS = [ + 'fetchAndUpdateGatorPermissions', + 'initialize', + 'decodePermissionFromPermissionContextForOrigin', + 'submitRevocation', + 'addPendingRevocation', + 'submitDirectRevocation', + 'isPendingRevocation', +] as const; + +// Default value for the gator permissions provider snap id +const defaultGatorPermissionsProviderSnapId = + 'npm:@metamask/gator-permissions-snap' as SnapId; + +const DEFAULT_MAX_SYNC_INTERVAL_MS = 30 * 24 * 60 * 60 * 1000; // 30 days in milliseconds + +/** + * Timeout duration for pending revocations (2 hours in milliseconds). + * After this time, event listeners will be cleaned up to prevent memory leaks. + */ +const PENDING_REVOCATION_TIMEOUT = 2 * 60 * 60 * 1000; + +// === CONFIG === + +/** + * Configuration for {@link GatorPermissionsController}. + */ +export type GatorPermissionsControllerConfig = { + /** + * Permission types the controller supports (e.g. 'native-token-stream', 'erc20-token-periodic'). + */ + supportedPermissionTypes: SupportedPermissionType[]; + /** + * Optional ID of the gator permissions provider Snap. Defaults to npm:@metamask/gator-permissions-snap. + */ + gatorPermissionsProviderSnapId?: SnapId; + /** + * Optional maximum age of cached permissions (ms) before {@link GatorPermissionsController.initialize} + * triggers a sync. Defaults to 30 days. + */ + maxSyncIntervalMs?: number; +}; + +// === STATE === + +/** + * State shape for {@link GatorPermissionsController}. + */ +export type GatorPermissionsControllerState = { + /** + * List of granted permissions with metadata (siteOrigin, status, revocationMetadata). + */ + grantedPermissions: PermissionInfoWithMetadata[]; + + /** + * Flag that indicates that fetching permissions is in progress + * This can be used to show a loading spinner in the UI + */ + isFetchingGatorPermissions: boolean; + + /** + * 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 = + { + grantedPermissions: { + includeInStateLogs: true, + persist: true, + includeInDebugSnapshot: false, + usedInUi: true, + }, + isFetchingGatorPermissions: { + includeInStateLogs: true, + persist: false, + includeInDebugSnapshot: false, + usedInUi: true, + }, + pendingRevocations: { + includeInStateLogs: true, + persist: false, + includeInDebugSnapshot: false, + usedInUi: true, + }, + lastSyncedTimestamp: { + includeInStateLogs: true, + persist: true, + includeInDebugSnapshot: false, + usedInUi: false, + }, + } satisfies StateMetadata; + +/** + * Creates initial controller state, merging defaults with optional partial state. + * Internal use only (e.g. constructor, tests). + * + * @param state - Optional partial state to merge with defaults. + * @returns Complete {@link GatorPermissionsController} state. + */ +function createGatorPermissionsControllerState( + state?: Partial, +): GatorPermissionsControllerState { + return { + grantedPermissions: [], + pendingRevocations: [], + lastSyncedTimestamp: -1, + ...state, + // isFetchingGatorPermissions is _always_ false when the controller is created + isFetchingGatorPermissions: false, + }; +} + +// === MESSENGER === + +/** + * The action which can be used to retrieve the state of the + * {@link GatorPermissionsController}. + */ +export type GatorPermissionsControllerGetStateAction = ControllerGetStateAction< + typeof controllerName, + GatorPermissionsControllerState +>; + +/** + * All actions that {@link GatorPermissionsController} registers, to be called + * externally. + */ +export type GatorPermissionsControllerActions = + | GatorPermissionsControllerGetStateAction + | GatorPermissionsControllerMethodActions; + +/** + * All actions that {@link GatorPermissionsController} calls internally. + * + * SnapController:handleRequest and SnapController:hasSnap are allowed to be + * called internally because they are used to fetch gator permissions from the + * Snap. + */ +type AllowedActions = + | SnapControllerHandleRequestAction + | SnapControllerHasSnapAction + | NetworkControllerFindNetworkClientIdByChainIdAction + | NetworkControllerGetNetworkClientByIdAction; + +/** + * The event that {@link GatorPermissionsController} publishes when updating state. + */ +export type GatorPermissionsControllerStateChangeEvent = + ControllerStateChangeEvent< + typeof controllerName, + GatorPermissionsControllerState + >; + +/** + * All events that {@link GatorPermissionsController} publishes, to be subscribed to + * externally. + */ +export type GatorPermissionsControllerEvents = + GatorPermissionsControllerStateChangeEvent; + +/** + * Events that {@link GatorPermissionsController} is allowed to subscribe to internally. + */ +type AllowedEvents = + | GatorPermissionsControllerStateChangeEvent + | TransactionControllerTransactionApprovedEvent + | TransactionControllerTransactionRejectedEvent + | TransactionControllerTransactionConfirmedEvent + | TransactionControllerTransactionFailedEvent + | TransactionControllerTransactionDroppedEvent; + +/** + * Messenger type for the GatorPermissionsController. + */ +export type GatorPermissionsControllerMessenger = Messenger< + typeof controllerName, + GatorPermissionsControllerActions | AllowedActions, + GatorPermissionsControllerEvents | AllowedEvents +>; + +/** + * Controller that manages gator permissions by reading from the gator permissions provider Snap. + */ +export 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 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: initialState, + }); + + this.#supportedPermissionTypes = config.supportedPermissionTypes; + this.#gatorPermissionsProviderSnapId = + config.gatorPermissionsProviderSnapId ?? + defaultGatorPermissionsProviderSnapId; + this.#maxSyncIntervalMs = + config.maxSyncIntervalMs ?? DEFAULT_MAX_SYNC_INTERVAL_MS; + + this.messenger.registerMethodActionHandlers( + this, + MESSENGER_EXPOSED_METHODS, + ); + } + + /** + * Supported permission types this controller was configured with. + * + * @returns The supported permission types. + */ + get supportedPermissionTypes(): readonly SupportedPermissionType[] { + return this.#supportedPermissionTypes; + } + + #setIsFetchingGatorPermissions(isFetchingGatorPermissions: boolean): void { + this.update((state) => { + state.isFetchingGatorPermissions = isFetchingGatorPermissions; + }); + } + + #addPendingRevocationToState(txId: string, permissionContext: Hex): void { + this.update((state) => { + state.pendingRevocations = [ + ...state.pendingRevocations, + { txId, permissionContext }, + ]; + }); + } + + #removePendingRevocationFromStateByTxId(txId: string): void { + this.update((state) => { + state.pendingRevocations = state.pendingRevocations.filter( + (pendingRevocations) => pendingRevocations.txId !== txId, + ); + }); + } + + #removePendingRevocationFromStateByPermissionContext( + permissionContext: Hex, + ): void { + this.update((state) => { + state.pendingRevocations = state.pendingRevocations.filter( + (pendingRevocations) => + pendingRevocations.permissionContext.toLowerCase() !== + permissionContext.toLowerCase(), + ); + }); + } + + /** + * Maps permission `context` (lowercase hex) to the last known {@link PermissionStatus} + * from the current controller state (used when merging after a snap sync). + * + * @returns Map from lowercase `permissionResponse.context` to the prior {@link PermissionStatus}. + */ + #buildPreviousStatusByContext(): Map { + const map = new Map(); + for (const prev of this.state.grantedPermissions) { + map.set( + prev.permissionResponse.context.toLowerCase(), + prev.status ?? 'Active', + ); + } + return map; + } + + async #getProviderForChainId( + chainId: Hex, + ): Promise { + const networkClientId: NetworkClientId = this.messenger.call( + 'NetworkController:findNetworkClientIdByChainId', + chainId, + ); + const { provider } = this.messenger.call( + 'NetworkController:getNetworkClientById', + networkClientId, + ); + return provider as PermissionStatusEip1193Provider; + } + + async #updateGrantedPermissionsStatus( + grantedPermissions: PermissionInfoWithMetadata[], + ): Promise { + return updateGrantedPermissionsStatus(grantedPermissions, { + getProviderForChainId: (chainId) => this.#getProviderForChainId(chainId), + }); + } + + /** + * Converts a stored gator permission to permission info with metadata. + * Strips internal fields (dependencies, to) from the permission response. + * + * @param storedGatorPermission - The stored gator permission from the Snap. + * @param status - The status for this permission. + * @returns Permission info with metadata for state/UI. + */ + #storedPermissionToPermissionInfo( + storedGatorPermission: StoredGatorPermission, + status: GatorPermissionStatus, + ): PermissionInfoWithMetadata { + const { permissionResponse: fullPermissionResponse } = + storedGatorPermission; + const { + dependencies: _dependencies, + to: _to, + ...permissionResponse + } = fullPermissionResponse; + + return { + ...storedGatorPermission, + permissionResponse, + status, + }; + } + + /** + * Converts stored gator permissions from the Snap into permission info with metadata. + * + * @param storedGatorPermissions - Stored gator permissions returned by the Snap, or null. + * @returns Array of permission info with metadata for state. + */ + #storedPermissionsToPermissionInfoWithMetadata( + storedGatorPermissions: StoredGatorPermission[] | null, + ): PermissionInfoWithMetadata[] { + if (!storedGatorPermissions) { + return []; + } + + const previousStatusByContext = this.#buildPreviousStatusByContext(); + + return storedGatorPermissions.map((storedPermission) => { + const previousStatus = previousStatusByContext.get( + storedPermission.permissionResponse.context.toLowerCase(), + ); + return this.#storedPermissionToPermissionInfo( + storedPermission, + previousStatus ?? 'Active', + ); + }); + } + + /** + * 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 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. + */ + public fetchAndUpdateGatorPermissions(): Promise { + if (this.#fetchAndUpdateGatorPermissionsPromise !== null) { + return this.#fetchAndUpdateGatorPermissionsPromise; + } + + 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, + }); + + const grantedPermissions = + this.#storedPermissionsToPermissionInfoWithMetadata(permissionsData); + + this.update((state) => { + state.grantedPermissions = grantedPermissions; + state.lastSyncedTimestamp = Date.now(); + }); + + const grantedPermissionsWithStatus = + await this.#updateGrantedPermissionsStatus(grantedPermissions); + + this.update((state) => { + state.grantedPermissions = grantedPermissionsWithStatus; + }); + } 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; + } + + /** + * Initializes the controller. Call once after construction to ensure the + * controller is ready for use. + * + * @returns A promise that resolves when initialization is complete. + */ + 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(); + } + } + + /** + * Decodes a permission context into a structured permission for a specific origin. + * + * This method validates the caller origin, decodes the provided `permissionContext` + * into delegations, identifies the permission type from the caveat enforcers, + * extracts the permission-specific data and expiry, and reconstructs a + * {@link DecodedPermission} containing chainId, account addresses, to, type and data. + * + * @param args - The arguments to this function. + * @param args.origin - The caller's origin; must match the configured permissions provider Snap id. + * @param args.chainId - Numeric EIP-155 chain id used for resolving enforcer contracts and encoding. + * @param args.delegation - delegation representing the permission. + * @param args.metadata - metadata included in the request. + * @param args.metadata.justification - the justification as specified in the request metadata. + * @param args.metadata.origin - the origin as specified in the request metadata. + * + * @returns A decoded permission object suitable for UI consumption and follow-up actions. + * @throws If the origin is not allowed, the context cannot be decoded into exactly one delegation, + * the enforcers do not match any supported permission type, no candidate type validates + * the caveat terms, or more than one permission type successfully validates + * (ambiguous delegation). + */ + public decodePermissionFromPermissionContextForOrigin({ + origin, + chainId, + delegation: { caveats, delegator, delegate, authority }, + metadata: { justification, origin: specifiedOrigin }, + }: { + origin: string; + chainId: number; + metadata: { + justification: string; + origin: string; + }; + delegation: DelegationDetails; + }): DecodedPermission { + if (origin !== this.#gatorPermissionsProviderSnapId) { + throw new OriginNotAllowedError({ origin }); + } + + const deploymentContracts = delegationContractsByChainId[chainId]; + + try { + if (!deploymentContracts) { + throw new Error(`Contracts not found for chainId: ${chainId}`); + } + + const contracts = toEnforcerAddressesByName(deploymentContracts); + + const enforcers = caveats.map((caveat) => caveat.enforcer); + const permissionDecoders = + createPermissionDecodersForContracts(contracts); + + // Every decoder where enforcer addresses match; multiple types may share the + // same caveat pattern and are disambiguated by validateAndDecodePermission. + const matchingDecoders = findDecodersWithMatchingCaveatAddresses({ + enforcers, + permissionDecoders, + }); + + const { + decoder: { permissionType }, + expiry, + data, + rules, + } = selectUniqueDecoderAndDecodedPermission({ + candidateDecoders: matchingDecoders, + caveats, + }); + + const permission = reconstructDecodedPermission({ + chainId, + permissionType, + delegator, + delegate, + authority, + expiry, + data, + justification, + rules, + specifiedOrigin, + }); + + return permission; + } catch (error) { + throw new PermissionDecodingError({ + cause: error as Error, + }); + } + } + + /** + * Submits a revocation to the gator permissions provider snap. + * + * @param revocationParams - The revocation parameters containing the permission context. + * @returns A promise that resolves when the revocation is submitted successfully. + * @throws {GatorPermissionsProviderError} If the snap request fails. + */ + public async submitRevocation( + revocationParams: RevocationParams, + ): Promise { + controllerLog('submitRevocation method called', { + permissionContext: revocationParams.permissionContext, + }); + + const snapRequest = { + snapId: this.#gatorPermissionsProviderSnapId, + origin: 'metamask', + handler: HandlerType.OnRpcRequest, + request: { + jsonrpc: '2.0', + method: + GatorPermissionsSnapRpcMethod.PermissionProviderSubmitRevocation, + params: revocationParams, + }, + }; + + try { + const result = await this.messenger.call( + 'SnapController:handleRequest', + snapRequest, + ); + + // Refresh list first (permission removed from list) + await this.fetchAndUpdateGatorPermissions(); + + controllerLog('Successfully submitted revocation', { + permissionContext: revocationParams.permissionContext, + result, + }); + } catch (error) { + // If it's a GatorPermissionsFetchError, revocation succeeded but refresh failed + if (error instanceof GatorPermissionsFetchError) { + controllerLog( + 'Revocation submitted successfully but failed to refresh permissions list', + { + error, + permissionContext: revocationParams.permissionContext, + }, + ); + // Wrap with a more specific message indicating revocation succeeded + throw new GatorPermissionsFetchError({ + message: + 'Failed to refresh permissions list after successful revocation', + cause: error as Error, + }); + } + + // Otherwise, revocation failed - wrap in provider error + controllerLog('Failed to submit revocation', { + error, + permissionContext: revocationParams.permissionContext, + }); + + throw new GatorPermissionsProviderError({ + method: + GatorPermissionsSnapRpcMethod.PermissionProviderSubmitRevocation, + cause: error as Error, + }); + } finally { + this.#removePendingRevocationFromStateByPermissionContext( + revocationParams.permissionContext, + ); + } + } + + /** + * Adds a pending revocation that will be submitted once the transaction is confirmed. + * + * This method sets up listeners for the user's approval/rejection decision and + * terminal transaction states (confirmed, failed, dropped). The flow is: + * 1. Wait for user to approve or reject the transaction + * 2. If approved, add to pending revocations state + * 3. If rejected, cleanup without adding to state + * 4. If confirmed, submit the revocation + * 5. If failed or dropped, cleanup + * + * Includes a timeout safety net to prevent memory leaks if the transaction never + * reaches a terminal state. + * + * @param params - The pending revocation parameters. + * @returns A promise that resolves when the listener is set up. + */ + public async addPendingRevocation( + params: PendingRevocationParams, + ): Promise { + const { txId, permissionContext } = params; + + controllerLog('addPendingRevocation method called', { + txId, + permissionContext, + }); + + type PendingRevocationHandlers = { + approved?: ( + ...args: TransactionControllerTransactionApprovedEvent['payload'] + ) => void; + rejected?: ( + ...args: TransactionControllerTransactionRejectedEvent['payload'] + ) => void; + confirmed?: ( + ...args: TransactionControllerTransactionConfirmedEvent['payload'] + ) => void; + failed?: ( + ...args: TransactionControllerTransactionFailedEvent['payload'] + ) => void; + dropped?: ( + ...args: TransactionControllerTransactionDroppedEvent['payload'] + ) => void; + timeoutId?: ReturnType; + }; + + // Track handlers and timeout for cleanup + const handlers: PendingRevocationHandlers = { + approved: undefined, + rejected: undefined, + confirmed: undefined, + failed: undefined, + dropped: undefined, + timeoutId: undefined, + }; + + // Helper to refresh permissions after transaction state change + const refreshPermissions = (context: string): void => { + 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 + const cleanupApprovalHandlers = (): void => { + if (handlers.approved) { + this.messenger.unsubscribe( + 'TransactionController:transactionApproved', + handlers.approved, + ); + handlers.approved = undefined; + } + if (handlers.rejected) { + this.messenger.unsubscribe( + 'TransactionController:transactionRejected', + handlers.rejected, + ); + handlers.rejected = undefined; + } + }; + + // Cleanup function to unsubscribe from all events and clear timeout + const cleanup = (txIdToRemove: string, removeFromState = true): void => { + cleanupApprovalHandlers(); + if (handlers.confirmed) { + this.messenger.unsubscribe( + 'TransactionController:transactionConfirmed', + handlers.confirmed, + ); + } + if (handlers.failed) { + this.messenger.unsubscribe( + 'TransactionController:transactionFailed', + handlers.failed, + ); + } + if (handlers.dropped) { + this.messenger.unsubscribe( + 'TransactionController:transactionDropped', + handlers.dropped, + ); + } + if (handlers.timeoutId !== undefined) { + clearTimeout(handlers.timeoutId); + } + + // Remove the pending revocation from the state (only if it was added) + if (removeFromState) { + this.#removePendingRevocationFromStateByTxId(txIdToRemove); + } + }; + + // Handle approved transaction - add to pending revocations state + handlers.approved = (payload): void => { + if (payload.transactionMeta.id === txId) { + controllerLog( + 'Transaction approved by user, adding to pending revocations', + { + txId, + permissionContext, + }, + ); + + this.#addPendingRevocationToState(txId, permissionContext); + + // Unsubscribe from approval/rejection events since decision is made + cleanupApprovalHandlers(); + } + }; + + // Handle rejected transaction - cleanup without adding to state + handlers.rejected = (payload): void => { + if (payload.transactionMeta.id === txId) { + controllerLog('Transaction rejected by user, cleaning up listeners', { + txId, + permissionContext, + }); + + // Don't remove from state since it was never added + cleanup(payload.transactionMeta.id, false); + } + }; + + // Handle confirmed transaction - submit revocation + handlers.confirmed = (transactionMeta): void => { + if (transactionMeta.id === txId) { + controllerLog('Transaction confirmed, submitting revocation', { + txId, + permissionContext, + txHash: transactionMeta.hash, + }); + + if (transactionMeta.status !== TransactionStatus.confirmed) { + controllerLog('Transaction not confirmed, skipping revocation', { + txId, + permissionContext, + status: transactionMeta.status, + }); + cleanup(transactionMeta.id); + refreshPermissions('transaction not confirmed'); + return; + } + + const txHash = transactionMeta.hash as Hex | undefined; + + if (txHash === undefined) { + controllerLog( + 'Failed to resolve transaction hash after revocation transaction confirmed', + { + txId, + permissionContext, + error: new Error( + 'Confirmed transaction is missing transaction hash', + ), + }, + ); + } + + this.submitRevocation({ permissionContext, txHash }) + .catch((error) => { + controllerLog( + 'Failed to submit revocation after transaction confirmed', + { + txId, + permissionContext, + error, + }, + ); + }) + .finally(() => refreshPermissions('transaction confirmed')); + + cleanup(transactionMeta.id); + } + }; + + // Handle failed transaction - cleanup without submitting revocation + handlers.failed = (payload): void => { + if (payload.transactionMeta.id === txId) { + controllerLog('Transaction failed, cleaning up revocation listener', { + txId, + permissionContext, + error: payload.error, + }); + + cleanup(payload.transactionMeta.id); + + refreshPermissions('transaction failed'); + } + }; + + // Handle dropped transaction - cleanup without submitting revocation + handlers.dropped = (payload): void => { + if (payload.transactionMeta.id === txId) { + controllerLog('Transaction dropped, cleaning up revocation listener', { + txId, + permissionContext, + }); + + cleanup(payload.transactionMeta.id); + + refreshPermissions('transaction dropped'); + } + }; + + // Subscribe to user approval/rejection events + this.messenger.subscribe( + 'TransactionController:transactionApproved', + handlers.approved, + ); + this.messenger.subscribe( + 'TransactionController:transactionRejected', + handlers.rejected, + ); + + // Subscribe to terminal transaction events + this.messenger.subscribe( + 'TransactionController:transactionConfirmed', + handlers.confirmed, + ); + this.messenger.subscribe( + 'TransactionController:transactionFailed', + handlers.failed, + ); + this.messenger.subscribe( + 'TransactionController:transactionDropped', + handlers.dropped, + ); + + // Set timeout as safety net to prevent memory leaks + handlers.timeoutId = setTimeout(() => { + controllerLog('Pending revocation timed out, cleaning up listeners', { + txId, + permissionContext, + }); + cleanup(txId); + }, PENDING_REVOCATION_TIMEOUT); + } + + /** + * Submits a revocation directly without requiring an on-chain transaction. + * Used for already-disabled delegations that don't require an on-chain transaction. + * + * This method: + * 1. Adds the permission context to pending revocations state (disables UI button) + * 2. Immediately calls submitRevocation to remove from snap storage + * 3. On success, removes from pending revocations state (re-enables UI button) + * 4. On failure, keeps in pending revocations so UI can show error/retry state + * + * @param params - The revocation parameters containing the permission context. + * @returns A promise that resolves when the revocation is submitted successfully. + * @throws {GatorPermissionsProviderError} If the snap request fails. + */ + public async submitDirectRevocation(params: RevocationParams): Promise { + // Use a placeholder txId that doesn't conflict with real transaction IDs + const placeholderTxId = `no-tx-${params.permissionContext}`; + + // Add to pending revocations state first (disables UI button immediately) + this.#addPendingRevocationToState( + placeholderTxId, + params.permissionContext, + ); + + // Immediately submit the revocation (will remove from pending on success) + await this.submitRevocation(params); + } + + /** + * Checks if a permission context is in the pending revocations list. + * + * @param permissionContext - The permission context to check. + * @returns `true` if the permission context is pending revocation, `false` otherwise. + */ + public isPendingRevocation(permissionContext: Hex): boolean { + const requestedPermissionContextLowercase = permissionContext.toLowerCase(); + + return this.state.pendingRevocations.some( + (pendingRevocation) => + pendingRevocation.permissionContext.toLowerCase() === + requestedPermissionContextLowercase, + ); + } +} + +export default GatorPermissionsController; diff --git a/packages/gator-permissions-controller/src/constants.ts b/packages/gator-permissions-controller/src/constants.ts new file mode 100644 index 00000000000..86efec89668 --- /dev/null +++ b/packages/gator-permissions-controller/src/constants.ts @@ -0,0 +1,28 @@ +/** + * Delegation framework version used to select the correct deployed enforcer + * contract addresses from `@metamask/delegation-deployments`. + */ +export const DELEGATION_FRAMEWORK_VERSION = '1.3.0'; + +/** + * `Rule.type` / `wallet_getSupportedExecutionPermissions` `ruleTypes` entry for + * redeemer allowlists (RedeemerEnforcer). Hosts should advertise this for every + * supported execution permission type. + */ +export const EXECUTION_PERMISSION_REDEEMER_RULE_TYPE = 'redeemer' as const; + +/** + * `Rule.type` / `wallet_getSupportedExecutionPermissions` `ruleTypes` entry for + * payee allowlists (AllowedCalldataEnforcer / AllowedTargetsEnforcer). Hosts + * should advertise this for every supported execution permission type that supports + * payee restrictions. + */ +export const EXECUTION_PERMISSION_PAYEE_RULE_TYPE = 'payee' as const; + +/** + * `Rule.type` / `wallet_getSupportedExecutionPermissions` `ruleTypes` entry for + * permission expiry derived from a TimestampEnforcer caveat. The decoded + * permission additionally hoists the expiry value onto its top-level `expiry` + * field for convenience. + */ +export const EXECUTION_PERMISSION_EXPIRY_RULE_TYPE = 'expiry' as const; diff --git a/packages/gator-permissions-controller/src/decodePermission/decodePermission.test.ts b/packages/gator-permissions-controller/src/decodePermission/decodePermission.test.ts new file mode 100644 index 00000000000..a7ef8106f71 --- /dev/null +++ b/packages/gator-permissions-controller/src/decodePermission/decodePermission.test.ts @@ -0,0 +1,476 @@ +import type { Caveat, Hex } from '@metamask/delegation-core'; + +import { + findDecodersWithMatchingCaveatAddresses, + reconstructDecodedPermission, + selectUniqueDecoderAndDecodedPermission, +} from './decodePermission.js'; +import type { PermissionDecoder, PermissionType } from './types.js'; + +describe('decodePermission', () => { + describe('findDecodersWithMatchingCaveatAddresses', () => { + it('returns all decoders that match the given enforcers', () => { + const matchingDecoder1 = { + permissionType: 'matching-permission-1', + caveatAddressesMatch: jest.fn().mockReturnValue(true), + }; + const matchingDecoder2 = { + permissionType: 'matching-permission-2', + caveatAddressesMatch: jest.fn().mockReturnValue(true), + }; + const nonMatchingDecoder = { + permissionType: 'non-matching-permission', + caveatAddressesMatch: jest.fn().mockReturnValue(false), + }; + const decoders = [ + matchingDecoder1, + matchingDecoder2, + nonMatchingDecoder, + ] as unknown as PermissionDecoder[]; + const rules = findDecodersWithMatchingCaveatAddresses({ + enforcers: [], + permissionDecoders: decoders, + }); + + expect(rules).toStrictEqual([matchingDecoder1, matchingDecoder2]); + }); + + it('returns an empty array if no decoders match the given enforcers', () => { + const nonMatchingDecoder1 = { + permissionType: 'non-matching-permission-1', + caveatAddressesMatch: jest.fn().mockReturnValue(false), + }; + const nonMatchingDecoder2 = { + permissionType: 'non-matching-permission-2', + caveatAddressesMatch: jest.fn().mockReturnValue(false), + }; + const nonMatchingDecoder3 = { + permissionType: 'non-matching-permission-3', + caveatAddressesMatch: jest.fn().mockReturnValue(false), + }; + const decoders = [ + nonMatchingDecoder1, + nonMatchingDecoder2, + nonMatchingDecoder3, + ] as unknown as PermissionDecoder[]; + const rules = findDecodersWithMatchingCaveatAddresses({ + enforcers: [], + permissionDecoders: decoders, + }); + + expect(rules).toStrictEqual([]); + }); + + it('returns an empty array if no decoders are provided', () => { + const rules = findDecodersWithMatchingCaveatAddresses({ + enforcers: [], + permissionDecoders: [], + }); + expect(rules).toStrictEqual([]); + }); + + it('calls caveatAddressesMatch with the given enforcers', () => { + const matchingDecoder1 = { + permissionType: 'matching-permission-1', + caveatAddressesMatch: jest.fn().mockReturnValue(true), + }; + const matchingDecoder2 = { + permissionType: 'matching-permission-2', + caveatAddressesMatch: jest.fn().mockReturnValue(true), + }; + const enforcers: Hex[] = ['0x0000000000000000000000000000000000000000']; + + findDecodersWithMatchingCaveatAddresses({ + enforcers, + permissionDecoders: [ + matchingDecoder1, + matchingDecoder2, + ] as unknown as PermissionDecoder[], + }); + + expect(matchingDecoder1.caveatAddressesMatch).toHaveBeenCalledWith( + enforcers, + ); + expect(matchingDecoder2.caveatAddressesMatch).toHaveBeenCalledWith( + enforcers, + ); + }); + }); + + describe('reconstructDecodedPermission', () => { + const chainId = 1; + const delegator = '0x1111111111111111111111111111111111111111' as const; + const delegate = '0x2222222222222222222222222222222222222222' as const; + const specifiedOrigin = 'https://dapp.example'; + const justification = 'Test justification'; + const permissionType = 'selected-permission-type' as PermissionType; + const data = { + value: 1, + }; + const authory = + '0xffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff' as const; + + it('throws if the authority is not ROOT_AUTHORITY', () => { + const invalidAuthority = + '0x0000000000000000000000000000000000000000' as const; + expect(() => + reconstructDecodedPermission({ + chainId, + permissionType, + delegator, + delegate, + authority: invalidAuthority, + expiry: null, + data, + justification, + specifiedOrigin, + }), + ).toThrow('Invalid authority'); + }); + + it('constructs a DecodedPermission with the specified values', () => { + const result = reconstructDecodedPermission({ + chainId, + permissionType, + delegator, + delegate, + authority: authory, + expiry: null, + data, + justification, + specifiedOrigin, + }); + + expect(result.chainId).toBe('0x1'); + expect(result.from).toBe(delegator); + expect(result.to).toStrictEqual(delegate); + expect(result.permission).toStrictEqual({ + type: permissionType, + data, + justification, + }); + expect(result.origin).toBe(specifiedOrigin); + + expect(result.rules).toBeUndefined(); + }); + + it('constructs a DecodedPermission with specified rules', () => { + const rules = [ + { + type: 'mock-rule', + data: { + value: 1, + }, + }, + ]; + + const result = reconstructDecodedPermission({ + chainId, + permissionType, + delegator, + delegate, + authority: authory, + expiry: null, + data, + justification, + specifiedOrigin, + rules, + }); + + expect(result.rules).toStrictEqual(rules); + }); + }); + + describe('selectUniqueDecoderAndDecodedPermission', () => { + const caveats = [ + { + enforcer: '0x0000000000000000000000000000000000000001', + terms: '0x0000000000000000000000000000000000000000', + args: '0x', + }, + ] as Caveat[]; + + const data = { + value: 1, + }; + + it('returns the successful decoder and decoded permission when exactly one decoder matches', () => { + const matchingDecoder = { + permissionType: 'matching-permission-type' as PermissionType, + requiredEnforcers: new Map([[caveats[0].enforcer, 1]]), + optionalEnforcers: new Set([ + '0x0000000000000000000000000000000000000000' as Hex, + ]), + caveatAddressesMatch: jest.fn(), + validateAndDecodePermission: jest.fn().mockReturnValue({ + isValid: true, + expiry: null, + data, + }), + }; + + const mismatchingDecoder = { + permissionType: 'mismatching-permission-type' as PermissionType, + requiredEnforcers: new Map([[caveats[0].enforcer, 1]]), + optionalEnforcers: new Set([ + '0x0000000000000000000000000000000000000000' as Hex, + ]), + caveatAddressesMatch: jest.fn(), + validateAndDecodePermission: jest.fn().mockReturnValue({ + isValid: false, + }), + }; + + const result = selectUniqueDecoderAndDecodedPermission({ + candidateDecoders: [matchingDecoder, mismatchingDecoder], + caveats, + }); + + expect(result.decoder).toBe(matchingDecoder); + expect(result.rules).toBeUndefined(); + expect(result.data).toBe(data); + expect(result.expiry).toBeNull(); + }); + + it('throws an error if no decoder matches', () => { + const mismatchingDecoder = { + permissionType: 'mismatching-permission-type' as PermissionType, + requiredEnforcers: new Map([[caveats[0].enforcer, 1]]), + optionalEnforcers: new Set([ + '0x0000000000000000000000000000000000000000' as Hex, + ]), + caveatAddressesMatch: jest.fn(), + validateAndDecodePermission: jest.fn().mockReturnValue({ + isValid: false, + error: new Error('Failed to validate and decode permission'), + }), + }; + + expect(() => { + selectUniqueDecoderAndDecodedPermission({ + candidateDecoders: [mismatchingDecoder], + caveats, + }); + }).toThrow('Failed to validate and decode permission'); + }); + + it('throws an error if the decoder throws an error', () => { + const throwingDecoder = { + permissionType: 'throwing-permission-type' as PermissionType, + requiredEnforcers: new Map([[caveats[0].enforcer, 1]]), + optionalEnforcers: new Set([ + '0x0000000000000000000000000000000000000000' as Hex, + ]), + caveatAddressesMatch: jest.fn(), + validateAndDecodePermission: jest.fn().mockImplementation(() => { + throw new Error('Failed to validate and decode permission'); + }), + }; + + expect(() => { + selectUniqueDecoderAndDecodedPermission({ + candidateDecoders: [throwingDecoder], + caveats, + }); + }).toThrow('Failed to validate and decode permission'); + }); + + it('throws an error if multiple decoders match', () => { + const matchingDecoder = { + permissionType: 'matching-permission-type' as PermissionType, + requiredEnforcers: new Map([[caveats[0].enforcer, 1]]), + optionalEnforcers: new Set([ + '0x0000000000000000000000000000000000000000' as Hex, + ]), + caveatAddressesMatch: jest.fn(), + validateAndDecodePermission: jest.fn().mockReturnValue({ + isValid: true, + expiry: null, + data, + }), + }; + + expect(() => { + selectUniqueDecoderAndDecodedPermission({ + candidateDecoders: [matchingDecoder, matchingDecoder], + caveats, + }); + }).toThrow( + 'Multiple permission types validate the same delegation caveats: matching-permission-type, matching-permission-type', + ); + }); + + it('throws an error when candidate decoders are empty', () => { + expect(() => { + selectUniqueDecoderAndDecodedPermission({ + candidateDecoders: [], + caveats, + }); + }).toThrow('Unable to identify permission type'); + }); + + it('throws an aggregated error when multiple decoders fail validation', () => { + const firstFailingDecoder = { + permissionType: 'first-failing-permission-type' as PermissionType, + requiredEnforcers: new Map([[caveats[0].enforcer, 1]]), + optionalEnforcers: new Set([ + '0x0000000000000000000000000000000000000000' as Hex, + ]), + caveatAddressesMatch: jest.fn(), + validateAndDecodePermission: jest.fn().mockReturnValue({ + isValid: false, + error: new Error('First decoder failed'), + }), + }; + const secondFailingDecoder = { + permissionType: 'second-failing-permission-type' as PermissionType, + requiredEnforcers: new Map([[caveats[0].enforcer, 1]]), + optionalEnforcers: new Set([ + '0x0000000000000000000000000000000000000000' as Hex, + ]), + caveatAddressesMatch: jest.fn(), + validateAndDecodePermission: jest.fn().mockReturnValue({ + isValid: false, + error: new Error('Second decoder failed'), + }), + }; + + expect(() => { + selectUniqueDecoderAndDecodedPermission({ + candidateDecoders: [firstFailingDecoder, secondFailingDecoder], + caveats, + }); + }).toThrow( + 'No permission type could validate the delegation caveats. Attempts: first-failing-permission-type: First decoder failed; second-failing-permission-type: Second decoder failed', + ); + }); + + it('passes caveats to validateAndDecodePermission for each candidate decoder', () => { + const matchingDecoder = { + permissionType: 'matching-permission-type' as PermissionType, + requiredEnforcers: new Map([[caveats[0].enforcer, 1]]), + optionalEnforcers: new Set([ + '0x0000000000000000000000000000000000000000' as Hex, + ]), + caveatAddressesMatch: jest.fn(), + validateAndDecodePermission: jest.fn().mockReturnValue({ + isValid: true, + expiry: null, + data, + }), + }; + const mismatchingDecoder = { + permissionType: 'mismatching-permission-type' as PermissionType, + requiredEnforcers: new Map([[caveats[0].enforcer, 1]]), + optionalEnforcers: new Set([ + '0x0000000000000000000000000000000000000000' as Hex, + ]), + caveatAddressesMatch: jest.fn(), + validateAndDecodePermission: jest.fn().mockReturnValue({ + isValid: false, + error: new Error('Failed to validate and decode permission'), + }), + }; + + selectUniqueDecoderAndDecodedPermission({ + candidateDecoders: [matchingDecoder, mismatchingDecoder], + caveats, + }); + + expect(matchingDecoder.validateAndDecodePermission).toHaveBeenCalledWith( + caveats, + ); + expect( + mismatchingDecoder.validateAndDecodePermission, + ).toHaveBeenCalledWith(caveats); + }); + + it('returns rules when the selected decoder includes decoded rules', () => { + const rules = [ + { + type: 'mock-rule', + data: { value: 1 }, + }, + ]; + const matchingDecoder = { + permissionType: 'matching-permission-type' as PermissionType, + requiredEnforcers: new Map([[caveats[0].enforcer, 1]]), + optionalEnforcers: new Set([ + '0x0000000000000000000000000000000000000000' as Hex, + ]), + caveatAddressesMatch: jest.fn(), + validateAndDecodePermission: jest.fn().mockReturnValue({ + isValid: true, + expiry: null, + data, + rules, + }), + }; + + const result = selectUniqueDecoderAndDecodedPermission({ + candidateDecoders: [matchingDecoder], + caveats, + }); + + expect(result.rules).toStrictEqual(rules); + }); + + it('returns a non-null expiry when provided by the selected decoder', () => { + const expiry = 1735689600; + const matchingDecoder = { + permissionType: 'matching-permission-type' as PermissionType, + requiredEnforcers: new Map([[caveats[0].enforcer, 1]]), + optionalEnforcers: new Set([ + '0x0000000000000000000000000000000000000000' as Hex, + ]), + caveatAddressesMatch: jest.fn(), + validateAndDecodePermission: jest.fn().mockReturnValue({ + isValid: true, + expiry, + data, + }), + }; + + const result = selectUniqueDecoderAndDecodedPermission({ + candidateDecoders: [matchingDecoder], + caveats, + }); + + expect(result.expiry).toBe(expiry); + }); + + it('throws if any candidate decoder throws, even when another candidate validates', () => { + const matchingDecoder = { + permissionType: 'matching-permission-type' as PermissionType, + requiredEnforcers: new Map([[caveats[0].enforcer, 1]]), + optionalEnforcers: new Set([ + '0x0000000000000000000000000000000000000000' as Hex, + ]), + caveatAddressesMatch: jest.fn(), + validateAndDecodePermission: jest.fn().mockReturnValue({ + isValid: true, + expiry: null, + data, + }), + }; + const throwingDecoder = { + permissionType: 'throwing-permission-type' as PermissionType, + requiredEnforcers: new Map([[caveats[0].enforcer, 1]]), + optionalEnforcers: new Set([ + '0x0000000000000000000000000000000000000000' as Hex, + ]), + caveatAddressesMatch: jest.fn(), + validateAndDecodePermission: jest.fn().mockImplementation(() => { + throw new Error('Failed to validate and decode permission'); + }), + }; + + expect(() => { + selectUniqueDecoderAndDecodedPermission({ + candidateDecoders: [matchingDecoder, throwingDecoder], + caveats, + }); + }).toThrow('Failed to validate and decode permission'); + }); + }); +}); diff --git a/packages/gator-permissions-controller/src/decodePermission/decodePermission.ts b/packages/gator-permissions-controller/src/decodePermission/decodePermission.ts new file mode 100644 index 00000000000..63c22021177 --- /dev/null +++ b/packages/gator-permissions-controller/src/decodePermission/decodePermission.ts @@ -0,0 +1,178 @@ +import type { Caveat, Hex } from '@metamask/delegation-core'; +import { ROOT_AUTHORITY } from '@metamask/delegation-core'; +import { numberToHex } from '@metamask/utils'; + +import type { + DecodedPermission, + PermissionType, + PermissionDecoder, + ValidateAndDecodeResult, +} from './types.js'; + +/** + * Returns every permission decoder whose caveat-address pattern matches the + * given enforcer list for the chain. Used when more than one permission type + * can share the same enforcer set; the caller must disambiguate by validating + * caveat terms (see {@link selectUniqueDecoderAndDecodedPermission}). + * + * @param args - The arguments to this function. + * @param args.enforcers - List of enforcer contract addresses (hex strings). + * @param args.permissionDecoders - The permission decoders for the chain. + * @returns All decoders that match, possibly empty. + */ +export const findDecodersWithMatchingCaveatAddresses = ({ + enforcers, + permissionDecoders, +}: { + enforcers: Hex[]; + permissionDecoders: PermissionDecoder[]; +}): PermissionDecoder[] => { + return permissionDecoders.filter((decoder) => + decoder.caveatAddressesMatch(enforcers), + ); +}; + +type SuccessfulValidateAndDecodeResult = Extract< + ValidateAndDecodeResult, + { isValid: true } +>; + +type DecoderAndDecodedPermission = { + decoder: PermissionDecoder; + rules: SuccessfulValidateAndDecodeResult['rules']; + data: SuccessfulValidateAndDecodeResult['data']; + expiry: SuccessfulValidateAndDecodeResult['expiry']; +}; + +/** + * Runs {@link PermissionDecoder.validateAndDecodePermission} on each candidate + * decoder. Use when several decoders share the same caveat addresses. + * + * @param args - The arguments to this function. + * @param args.candidateDecoders - Decoders whose addresses already match the caveats. + * @param args.caveats - Caveats from the delegation. + * @returns The unique decoder and decoded expiry/data when exactly one decoder validates. + * @throws If `candidateDecoders` is empty, if no decoder validates, or if more than one decoder validates. + */ +export const selectUniqueDecoderAndDecodedPermission = ({ + candidateDecoders, + caveats, +}: { + candidateDecoders: PermissionDecoder[]; + caveats: Caveat[]; +}): DecoderAndDecodedPermission => { + if (candidateDecoders.length === 0) { + throw new Error('Unable to identify permission type'); + } + + const successfulDecodingResult: DecoderAndDecodedPermission[] = []; + + const failedAttempts: { permissionType: PermissionType; error: Error }[] = []; + + for (const decoder of candidateDecoders) { + const decodeResult = decoder.validateAndDecodePermission(caveats); + if (decodeResult.isValid) { + successfulDecodingResult.push({ + decoder, + rules: decodeResult.rules, + data: decodeResult.data, + expiry: decodeResult.expiry, + }); + } else { + failedAttempts.push({ + permissionType: decoder.permissionType, + error: decodeResult.error, + }); + } + } + + if (successfulDecodingResult.length === 1) { + return successfulDecodingResult[0]; + } + + if (successfulDecodingResult.length > 1) { + const types = successfulDecodingResult + .map((result) => result.decoder.permissionType) + .join(', '); + throw new Error( + `Multiple permission types validate the same delegation caveats: ${types}`, + ); + } + + if (failedAttempts.length === 1) { + throw failedAttempts[0].error; + } + + const details = failedAttempts + .map( + (attempt) => + `${String(attempt.permissionType)}: ${attempt.error.message}`, + ) + .join('; '); + + throw new Error( + `No permission type could validate the delegation caveats. Attempts: ${details}`, + ); +}; + +/** + * Reconstructs a {@link DecodedPermission} object from primitive values + * obtained while decoding a permission context. + * + * @param args - The arguments to this function. + * @param args.chainId - Chain ID. + * @param args.permissionType - Identified permission type. + * @param args.delegator - Address of the account delegating permission. + * @param args.delegate - Address that will act under the granted permission. + * @param args.authority - Authority identifier; must be ROOT_AUTHORITY. + * @param args.expiry - Expiry timestamp (unix seconds) or null if unbounded. + * @param args.data - Permission-specific decoded data payload. + * @param args.justification - Human-readable justification for the permission. + * @param args.specifiedOrigin - The origin reported in the request metadata. + * @param args.rules - Rules recovered from caveats (e.g. redeemer allowlist). + * + * @returns The reconstructed {@link DecodedPermission}. + */ +export const reconstructDecodedPermission = ({ + chainId, + permissionType, + delegator, + delegate, + authority, + expiry, + data, + justification, + specifiedOrigin, + rules, +}: { + chainId: number; + permissionType: PermissionType; + delegator: Hex; + delegate: Hex; + authority: Hex; + expiry: number | null; + data: DecodedPermission['permission']['data']; + justification: string; + specifiedOrigin: string; + rules?: DecodedPermission['rules']; +}): DecodedPermission => { + if (authority !== ROOT_AUTHORITY) { + throw new Error('Invalid authority'); + } + + const permission: DecodedPermission = { + chainId: numberToHex(chainId), + from: delegator, + to: delegate, + permission: { + type: permissionType, + data, + justification, + }, + expiry, + origin: specifiedOrigin, + ...(rules === undefined ? {} : { rules }), + }; + + return permission; +}; diff --git a/packages/gator-permissions-controller/src/decodePermission/decoders/index.ts b/packages/gator-permissions-controller/src/decodePermission/decoders/index.ts new file mode 100644 index 00000000000..d0dfa89f185 --- /dev/null +++ b/packages/gator-permissions-controller/src/decodePermission/decoders/index.ts @@ -0,0 +1,27 @@ +import { makePermissionDecoderConfigs } from '@metamask/7715-permission-types'; +import type { EnforcerAddressesByName } from '@metamask/7715-permission-types'; + +import type { PermissionDecoder } from '../types.js'; +import { makePermissionDecoder } from './makePermissionDecoder.js'; + +/** + * Builds the canonical set of permission decoders for a chain. + * + * Each decoder specifies the `permissionType`, required/optional enforcers, + * and provides `caveatAddressesMatch` and `validateAndDecodePermission` so the + * entire decode flow can be driven by the decoders. + * + * `contracts` should already be checksummed (see {@link toEnforcerAddressesByName}). + * `makePermissionDecoderConfigs` checksums them again, and {@link makePermissionDecoder} + * normalizes enforcer sets once more — see the module comment in + * `makePermissionDecoder.ts` for why checksumming is layered this way. + * + * @param contracts - The deployed enforcer addresses for the chain. + * @returns A list of permission decoders used to identify and decode permission types. + * @throws Propagates any errors from resolving enforcer addresses. + */ +export const createPermissionDecodersForContracts = ( + contracts: EnforcerAddressesByName, +): PermissionDecoder[] => { + return makePermissionDecoderConfigs(contracts).map(makePermissionDecoder); +}; diff --git a/packages/gator-permissions-controller/src/decodePermission/decoders/makePermissionDecoder.test.ts b/packages/gator-permissions-controller/src/decodePermission/decoders/makePermissionDecoder.test.ts new file mode 100644 index 00000000000..f72d28bac3a --- /dev/null +++ b/packages/gator-permissions-controller/src/decodePermission/decoders/makePermissionDecoder.test.ts @@ -0,0 +1,633 @@ +import type { PermissionDecoderConfig } from '@metamask/7715-permission-types'; +import { Rule } from '@metamask/7715-permission-types'; +import type { Caveat } from '@metamask/delegation-core'; +import { + CHAIN_ID, + DELEGATOR_CONTRACTS, +} from '@metamask/delegation-deployments'; +import { getChecksumAddress } from '@metamask/utils'; +import type { Hex } from '@metamask/utils'; +import { randomBytes } from 'crypto'; + +import type { DelegationDeploymentsEnforcerAddressesByName } from '../../types.js'; +import { toEnforcerAddressesByName } from '../enforcerAddresses.js'; +import { PermissionType } from '../types.js'; +import { makePermissionDecoder } from './makePermissionDecoder.js'; + +const randomAddress = () => `0x${randomBytes(20).toString('hex')}` as const; + +type RuleDecoder = PermissionDecoderConfig['rules'][number]; + +describe('makePermissionDecoder', () => { + const permissionType = 'specified-permission-type' as PermissionType; + + const contracts = DELEGATOR_CONTRACTS['1.3.0'][ + CHAIN_ID.sepolia + ] as DelegationDeploymentsEnforcerAddressesByName; + const contractAddresses = toEnforcerAddressesByName(contracts); + + describe('factory function', () => { + it('returns the specified permission type', () => { + const decoder = makePermissionDecoder({ + permissionType, + contractAddresses, + optionalEnforcers: [], + requiredEnforcers: {}, + rules: [], + validateAndDecodeData: jest.fn(), + }); + + expect(decoder.permissionType).toStrictEqual(permissionType); + }); + + it('returns a set of checksummed optional enforcers', () => { + const optionalEnforcers: Hex[] = [ + randomAddress(), + randomAddress(), + randomAddress(), + ]; + + const decoder = makePermissionDecoder({ + permissionType, + contractAddresses, + optionalEnforcers, + requiredEnforcers: {}, + rules: [], + validateAndDecodeData: jest.fn(), + }); + + expect(decoder.optionalEnforcers).toStrictEqual( + new Set(optionalEnforcers.map(getChecksumAddress)), + ); + }); + + it('returns a Map of checksummed required enforcers to their required count', () => { + const requiredEnforcers = { + [randomAddress()]: 1, + [randomAddress()]: 2, + [randomAddress()]: 3, + }; + + const decoder = makePermissionDecoder({ + permissionType, + contractAddresses, + optionalEnforcers: [], + requiredEnforcers, + rules: [], + validateAndDecodeData: jest.fn(), + }); + + const requiredEnforcersMap = new Map( + Object.entries(requiredEnforcers).map(([enforcer, count]) => [ + getChecksumAddress(enforcer as Hex), + count, + ]), + ); + + expect(decoder.requiredEnforcers).toStrictEqual(requiredEnforcersMap); + }); + }); + + describe('caveatAddressesMatch', () => { + it('returns true when the specified addresses match the required enforcers', () => { + const enforcer1 = randomAddress(); + const enforcer2 = randomAddress(); + const enforcer3 = randomAddress(); + + const requiredEnforcers = { + [enforcer1]: 1, + [enforcer2]: 1, + [enforcer3]: 1, + }; + + const decoder = makePermissionDecoder({ + permissionType, + contractAddresses, + optionalEnforcers: [], + requiredEnforcers, + rules: [], + validateAndDecodeData: jest.fn(), + }); + + const specifiedCaveats = [enforcer1, enforcer2, enforcer3]; + + expect(decoder.caveatAddressesMatch(specifiedCaveats)).toBe(true); + }); + + it('returns true when the specified addresses include required addresses with the correct multiplicity', () => { + const enforcer1 = randomAddress(); + const enforcer2 = randomAddress(); + const enforcer3 = randomAddress(); + + const requiredEnforcers = { + [enforcer1]: 1, + [enforcer2]: 2, + [enforcer3]: 3, + }; + + const decoder = makePermissionDecoder({ + permissionType, + contractAddresses, + optionalEnforcers: [], + requiredEnforcers, + rules: [], + validateAndDecodeData: jest.fn(), + }); + + const specifiedCaveats = [ + enforcer1, + enforcer2, + enforcer2, + enforcer3, + enforcer3, + enforcer3, + ]; + + expect(decoder.caveatAddressesMatch(specifiedCaveats)).toBe(true); + }); + + it('returns true when the specified addresses include optional enforcers', () => { + const requiredEnforcer = randomAddress(); + const optionalEnforcer1 = randomAddress(); + const optionalEnforcer2 = randomAddress(); + + const requiredEnforcers = { + [getChecksumAddress(requiredEnforcer)]: 1, + }; + + const optionalEnforcers = [ + getChecksumAddress(optionalEnforcer1), + getChecksumAddress(optionalEnforcer2), + ]; + + const decoder = makePermissionDecoder({ + permissionType, + contractAddresses, + optionalEnforcers, + requiredEnforcers, + rules: [], + validateAndDecodeData: jest.fn(), + }); + + const specifiedCaveats = [ + requiredEnforcer, + optionalEnforcer1, + optionalEnforcer2, + ]; + + expect(decoder.caveatAddressesMatch(specifiedCaveats)).toBe(true); + }); + + it('returns true when the specified addresses include only a subset of declared optional enforcers', () => { + const requiredEnforcer = randomAddress(); + const optionalEnforcer1 = randomAddress(); + const optionalEnforcer2 = randomAddress(); + const optionalEnforcer3 = randomAddress(); + + const requiredEnforcers = { + [getChecksumAddress(requiredEnforcer)]: 1, + }; + + const optionalEnforcers = [ + getChecksumAddress(optionalEnforcer1), + getChecksumAddress(optionalEnforcer2), + getChecksumAddress(optionalEnforcer3), + ]; + + const decoder = makePermissionDecoder({ + permissionType, + contractAddresses, + optionalEnforcers, + requiredEnforcers, + rules: [], + validateAndDecodeData: jest.fn(), + }); + + const specifiedCaveats = [requiredEnforcer, optionalEnforcer2]; + + expect(decoder.caveatAddressesMatch(specifiedCaveats)).toBe(true); + }); + + it('returns false when the specified addresses include addresses that are neither required or optional enforcers', () => { + const requiredEnforcer = randomAddress(); + const optionalEnforcer = randomAddress(); + const unknownEnforcer = randomAddress(); + + const requiredEnforcers = { + [getChecksumAddress(requiredEnforcer)]: 1, + }; + + const optionalEnforcers = [getChecksumAddress(optionalEnforcer)]; + + const decoder = makePermissionDecoder({ + permissionType, + contractAddresses, + optionalEnforcers, + requiredEnforcers, + rules: [], + validateAndDecodeData: jest.fn(), + }); + + const specifiedCaveats = [ + requiredEnforcer, + optionalEnforcer, + unknownEnforcer, + ]; + + expect(decoder.caveatAddressesMatch(specifiedCaveats)).toBe(false); + }); + + it('returns false when the specified addresses do not include all required enforcers', () => { + const requiredEnforcer1 = randomAddress(); + const requiredEnforcer2 = randomAddress(); + const optionalEnforcer = randomAddress(); + + const requiredEnforcers = { + [getChecksumAddress(requiredEnforcer1)]: 1, + [getChecksumAddress(requiredEnforcer2)]: 1, + }; + + const optionalEnforcers = [getChecksumAddress(optionalEnforcer)]; + + const decoder = makePermissionDecoder({ + permissionType, + contractAddresses, + optionalEnforcers, + requiredEnforcers, + rules: [], + validateAndDecodeData: jest.fn(), + }); + + const specifiedCaveats = [requiredEnforcer1, optionalEnforcer]; + + expect(decoder.caveatAddressesMatch(specifiedCaveats)).toBe(false); + }); + + it('returns false when the specified addresses include required addresses with the incorrect multiplicity (less than required)', () => { + const enforcer1 = randomAddress(); + + const requiredEnforcers = { + [getChecksumAddress(enforcer1)]: 2, + }; + + const decoder = makePermissionDecoder({ + permissionType, + contractAddresses, + optionalEnforcers: [], + requiredEnforcers, + rules: [], + validateAndDecodeData: jest.fn(), + }); + + const specifiedCaveats = [enforcer1]; + + expect(decoder.caveatAddressesMatch(specifiedCaveats)).toBe(false); + }); + + it('returns false when the specified addresses include required addresses with the incorrect multiplicity (more than required)', () => { + const enforcer1 = randomAddress(); + + const requiredEnforcers = { + [getChecksumAddress(enforcer1)]: 1, + }; + + const decoder = makePermissionDecoder({ + permissionType, + contractAddresses, + optionalEnforcers: [], + requiredEnforcers, + rules: [], + validateAndDecodeData: jest.fn(), + }); + + const specifiedCaveats = [enforcer1, enforcer1]; + + expect(decoder.caveatAddressesMatch(specifiedCaveats)).toBe(false); + }); + + // todo: we could consider tightening this up to require optional enforcers to be singular + it('returns true when the specified addresses include duplicates of optional enforcers', () => { + const requiredEnforcer = randomAddress(); + const optionalEnforcer = randomAddress(); + + const requiredEnforcers = { + [getChecksumAddress(requiredEnforcer)]: 1, + }; + + const optionalEnforcers = [getChecksumAddress(optionalEnforcer)]; + + const decoder = makePermissionDecoder({ + permissionType, + contractAddresses, + optionalEnforcers, + requiredEnforcers, + rules: [], + validateAndDecodeData: jest.fn(), + }); + + const specifiedCaveats = [ + requiredEnforcer, + optionalEnforcer, + optionalEnforcer, + ]; + + expect(decoder.caveatAddressesMatch(specifiedCaveats)).toBe(true); + }); + + it('matches when decoder config address casing mismatches specified caveat addresses', () => { + const toUpperCaseHex = (address: Hex) => + `0x${address.slice(2).toUpperCase()}` as const; + const requiredEnforcer = randomAddress().toLowerCase() as Hex; + const optionalEnforcer = randomAddress().toLowerCase() as Hex; + + const decoder = makePermissionDecoder({ + permissionType, + contractAddresses, + optionalEnforcers: [optionalEnforcer], + requiredEnforcers: { + [requiredEnforcer]: 1, + }, + rules: [], + validateAndDecodeData: jest.fn(), + }); + + expect( + decoder.caveatAddressesMatch([ + toUpperCaseHex(requiredEnforcer), + toUpperCaseHex(optionalEnforcer), + ]), + ).toBe(true); + }); + }); + + describe('validateAndDecodePermission', () => { + it('returns a valid result when the specified validation and decoding succeeds', () => { + const data = { result: 'success' }; + const validateAndDecodeData = jest.fn().mockReturnValue(data); + const decoder = makePermissionDecoder({ + permissionType, + contractAddresses, + optionalEnforcers: [], + requiredEnforcers: {}, + rules: [], + validateAndDecodeData, + }); + + const result = decoder.validateAndDecodePermission([]); + + expect(validateAndDecodeData).toHaveBeenCalled(); + expect(result.isValid).toBe(true); + expect((result as { data: Record }).data).toStrictEqual( + data, + ); + }); + + it('calls the validation and decoding function with the correct arguments', () => { + const validateAndDecodeData = jest.fn(); + const decoder = makePermissionDecoder({ + permissionType, + contractAddresses, + optionalEnforcers: [], + requiredEnforcers: {}, + rules: [], + validateAndDecodeData, + }); + + const caveats: Caveat[] = [ + { + enforcer: randomAddress(), + terms: '0x123456', + args: '0x', + }, + { + enforcer: randomAddress(), + terms: '0x987654', + args: '0x', + }, + ]; + + const checksumCaveats = caveats.map((caveat) => ({ + ...caveat, + enforcer: getChecksumAddress(caveat.enforcer), + })); + + decoder.validateAndDecodePermission(caveats); + + expect(validateAndDecodeData).toHaveBeenCalledWith( + checksumCaveats, + contractAddresses, + ); + }); + + it('returns an invalid result, with thrown error when the specified validation and decoding throws', () => { + const validationError = new Error('test error'); + const validateAndDecodeData = jest.fn().mockImplementation(() => { + throw validationError; + }); + const decoder = makePermissionDecoder({ + permissionType, + contractAddresses, + optionalEnforcers: [], + requiredEnforcers: {}, + rules: [], + validateAndDecodeData, + }); + + const result = decoder.validateAndDecodePermission([]); + + expect(validateAndDecodeData).toHaveBeenCalled(); + expect(result.isValid).toBe(false); + expect((result as { error: Error }).error).toBe(validationError); + }); + + it('returns an invalid result, with appropriate error if any of the terms is not valid hex', () => { + const data = { result: 'success' }; + const validateAndDecodeData = jest.fn().mockReturnValue(data); + const decoder = makePermissionDecoder({ + permissionType, + contractAddresses, + optionalEnforcers: [], + requiredEnforcers: {}, + rules: [], + validateAndDecodeData, + }); + + const result = decoder.validateAndDecodePermission([ + { + enforcer: randomAddress(), + terms: '0xNOTHEX', + args: '0x', + }, + ]); + + expect(validateAndDecodeData).not.toHaveBeenCalled(); + expect(result.isValid).toBe(false); + expect((result as { error: Error }).error.message).toBe( + 'Invalid terms: must be a hex string', + ); + }); + + it('calls decode on each of the specified rules', () => { + const rules: RuleDecoder[] = [ + jest.fn().mockReturnValue(null), + jest.fn().mockReturnValue(null), + jest.fn().mockReturnValue(null), + ]; + + const decoder = makePermissionDecoder({ + permissionType, + contractAddresses, + optionalEnforcers: [], + requiredEnforcers: {}, + rules, + validateAndDecodeData: jest.fn(), + }); + + const caveats: Caveat[] = [ + { + enforcer: randomAddress(), + terms: '0x123456', + args: '0x', + }, + { + enforcer: randomAddress(), + terms: '0x987654', + args: '0x', + }, + ]; + + const checksumCaveats = caveats.map((caveat) => ({ + ...caveat, + enforcer: getChecksumAddress(caveat.enforcer), + })); + + decoder.validateAndDecodePermission(caveats); + + const ruleDecoderExpectedArgs = { + contractAddresses, + caveats: checksumCaveats, + requiredEnforcers: new Map(), + }; + + expect(rules[0]).toHaveBeenCalledWith(ruleDecoderExpectedArgs); + expect(rules[1]).toHaveBeenCalledWith(ruleDecoderExpectedArgs); + expect(rules[2]).toHaveBeenCalledWith(ruleDecoderExpectedArgs); + }); + + it('returns an invalid result, with thrown error when a rule decoder throws', () => { + const ruleDecoderError = new Error('test error'); + const ruleDecoder = jest.fn().mockImplementation(() => { + throw ruleDecoderError; + }); + const decoder = makePermissionDecoder({ + permissionType, + contractAddresses, + optionalEnforcers: [], + requiredEnforcers: {}, + rules: [ruleDecoder], + validateAndDecodeData: jest.fn(), + }); + + const result = decoder.validateAndDecodePermission([]); + + expect(ruleDecoder).toHaveBeenCalled(); + expect(result.isValid).toBe(false); + expect((result as { error: Error }).error).toBe(ruleDecoderError); + }); + + it('returns an undefined rules when no rules are decoded', () => { + const decoder = makePermissionDecoder({ + permissionType, + contractAddresses, + optionalEnforcers: [], + requiredEnforcers: {}, + rules: [], + validateAndDecodeData: jest.fn(), + }); + + const result = decoder.validateAndDecodePermission([]); + + expect(result.isValid).toBe(true); + expect((result as { rules: Rule[] }).rules).toBeUndefined(); + }); + + it('returns a null expiry when no expiry rule is decoded', () => { + const decoder = makePermissionDecoder({ + permissionType, + contractAddresses, + optionalEnforcers: [], + requiredEnforcers: {}, + rules: [], + validateAndDecodeData: jest.fn(), + }); + + const result = decoder.validateAndDecodePermission([]); + + expect(result.isValid).toBe(true); + expect((result as { expiry: number | null }).expiry).toBeNull(); + }); + + it('applies decoded rules to the result', () => { + const mockRule1 = { + type: 'mock-rule', + data: {}, + }; + const mockRule2 = { + type: 'mock-rule-2', + data: { + value: 1, + }, + }; + + const rules: RuleDecoder[] = [ + jest.fn().mockReturnValue(mockRule1), + jest.fn().mockReturnValue(null), + jest.fn().mockReturnValue(mockRule2), + ]; + + const decoder = makePermissionDecoder({ + permissionType, + contractAddresses, + optionalEnforcers: [], + requiredEnforcers: {}, + rules, + validateAndDecodeData: jest.fn(), + }); + + const result = decoder.validateAndDecodePermission([]); + + expect(result.isValid).toBe(true); + expect((result as { rules: Rule[] }).rules).toStrictEqual([ + mockRule1, + mockRule2, + ]); + }); + + it('hoists expiry rule to the top-level expiry field, as well as including it in the rules array', () => { + const timestamp = 1720000; + const expiryRule = { + type: 'expiry', + data: { + timestamp, + }, + }; + + const rules: RuleDecoder[] = [jest.fn().mockReturnValue(expiryRule)]; + + const decoder = makePermissionDecoder({ + permissionType, + contractAddresses, + optionalEnforcers: [], + requiredEnforcers: {}, + rules, + validateAndDecodeData: jest.fn(), + }); + + const result = decoder.validateAndDecodePermission([]); + + expect(result.isValid).toBe(true); + expect((result as { expiry: number }).expiry).toStrictEqual(timestamp); + expect((result as { rules: Rule[] }).rules).toStrictEqual([expiryRule]); + }); + }); +}); diff --git a/packages/gator-permissions-controller/src/decodePermission/decoders/makePermissionDecoder.ts b/packages/gator-permissions-controller/src/decodePermission/decoders/makePermissionDecoder.ts new file mode 100644 index 00000000000..75c1cc38aef --- /dev/null +++ b/packages/gator-permissions-controller/src/decodePermission/decoders/makePermissionDecoder.ts @@ -0,0 +1,143 @@ +import type { + PermissionDecoderConfig, + Rule, +} from '@metamask/7715-permission-types'; +import type { Caveat } from '@metamask/delegation-core'; +import { getChecksumAddress, isStrictHexString } from '@metamask/utils'; +import type { Hex } from '@metamask/utils'; + +import { EXECUTION_PERMISSION_EXPIRY_RULE_TYPE } from '../../constants.js'; +import type { PermissionDecoder, ValidateAndDecodeResult } from '../types.js'; +import { buildEnforcerCountsAndSet, enforcersMatchRule } from '../utils.js'; + +/** + * Address checksumming in the permission decode flow. + * + * Enforcer addresses come from three sources, each of which may use a + * different hex encoding (EIP-55 checksummed vs lowercase). Comparisons rely + * on strict equality, so we normalize at function boundaries instead of + * requiring callers to pass a specific encoding. `getChecksumAddress` is + * idempotent, so repeated normalization is intentional and cheap. + * + * 1. **Canonical contract addresses** (`EnforcerAddressesByName`): normalized + * in {@link toEnforcerAddressesByName}, again in + * `makePermissionDecoderConfigs` (`checksumEnforcerAddresses` in + * `@metamask/7715-permission-types`), and once more below when building + * each decoder's required/optional enforcer sets. + * + * 2. **Delegation caveat enforcers**: read from the encoded permission + * context with unpredictable casing. Normalized in + * {@link buildEnforcerCountsAndSet} during address matching and again in + * `validateAndDecodePermission` before term decoding (rule decoders and + * `getTermsByEnforcer` compare enforcer addresses with `===`). + * + * 3. **Decoder required/optional enforcers**: populated from contract + * addresses by `makePermissionDecoderConfigs`; normalized at decoder + * construction so `caveatAddressesMatch` compares like-for-like with + * caveat addresses from source (2). + */ + +/** + * Creates a single {@link PermissionDecoder} with the given type, enforcer + * sets, rule decoders, and decode/validate callback. + * + * @param config - The configuration describing the permission type's + * enforcers, rule decoders, and data decoder. See + * {@link PermissionDecoderConfig} for field documentation. + * @param config.permissionType - The type of permission to decode. + * @param config.contractAddresses - Checksummed enforcer addresses for the chain. + * @param config.optionalEnforcers - Optional enforcers for the permission. + * @param config.requiredEnforcers - Required enforcers for the permission. + * @param config.rules - Rule decoders for the permission. + * @param config.validateAndDecodeData - Data decoder for the permission. + * @returns A {@link PermissionDecoder} with `caveatAddressesMatch` and + * `validateAndDecodePermission`. + */ +export function makePermissionDecoder({ + permissionType, + contractAddresses, + optionalEnforcers, + requiredEnforcers, + rules, + validateAndDecodeData, +}: PermissionDecoderConfig): PermissionDecoder { + const optionalEnforcersSet = new Set( + optionalEnforcers.map(getChecksumAddress), + ); + const requiredEnforcersMap = new Map( + Object.entries(requiredEnforcers).map(([enforcer, count]) => [ + getChecksumAddress(enforcer as Hex), + count, + ]), + ); + + const caveatAddressesMatch = (caveatAddresses: Hex[]): boolean => { + const { counts, enforcersSet } = buildEnforcerCountsAndSet(caveatAddresses); + + return enforcersMatchRule( + counts, + enforcersSet, + requiredEnforcersMap, + optionalEnforcersSet, + ); + }; + + const validateAndDecodePermission = ( + caveats: Caveat[], + ): ValidateAndDecodeResult => { + const checksumCaveats: Caveat[] = caveats.map((caveat) => ({ + ...caveat, + enforcer: getChecksumAddress(caveat.enforcer), + })); + try { + const invalidTerms = checksumCaveats.filter( + // isStrictHexString rejects '0x' which is a valid terms value + ({ terms }) => terms !== '0x' && !isStrictHexString(terms), + ); + + if (invalidTerms.length > 0) { + throw new Error('Invalid terms: must be a hex string'); + } + + let expiry: number | null = null; + const decodedRules: Rule[] = []; + + for (const decode of rules) { + const rule = decode({ + contractAddresses, + caveats: checksumCaveats, + requiredEnforcers: requiredEnforcersMap, + }); + + if (rule === null) { + continue; + } + + decodedRules.push(rule); + + if (rule.type === EXECUTION_PERMISSION_EXPIRY_RULE_TYPE) { + expiry = rule.data.timestamp as number; + } + } + + const data = validateAndDecodeData(checksumCaveats, contractAddresses); + + return { + isValid: true, + expiry, + data, + rules: decodedRules.length > 0 ? decodedRules : undefined, + }; + } catch (caughtError) { + return { isValid: false, error: caughtError as Error }; + } + }; + + return { + permissionType, + caveatAddressesMatch, + validateAndDecodePermission, + optionalEnforcers: optionalEnforcersSet, + requiredEnforcers: requiredEnforcersMap, + }; +} diff --git a/packages/gator-permissions-controller/src/decodePermission/enforcerAddresses.test.ts b/packages/gator-permissions-controller/src/decodePermission/enforcerAddresses.test.ts new file mode 100644 index 00000000000..cab21c3a440 --- /dev/null +++ b/packages/gator-permissions-controller/src/decodePermission/enforcerAddresses.test.ts @@ -0,0 +1,155 @@ +import { makePermissionDecoderConfigs } from '@metamask/7715-permission-types'; +import type { EnforcerAddressesByName } from '@metamask/7715-permission-types'; +import { getChecksumAddress, isStrictHexString } from '@metamask/utils'; + +import { buildMockDelegationEnforcerContracts } from '../../tests/mocks.js'; +import type { DelegationDeploymentsEnforcerAddressesByName } from '../types.js'; +import { + toEnforcerAddressesByName, + delegationContractsByChainId, +} from './enforcerAddresses.js'; + +const ENFORCER_ADDRESS_KEYS = [ + 'allowedCalldataEnforcer', + 'allowedTargetsEnforcer', + 'redeemerEnforcer', + 'erc20StreamingEnforcer', + 'erc20PeriodTransferEnforcer', + 'nativeTokenStreamingEnforcer', + 'nativeTokenPeriodTransferEnforcer', + 'approvalRevocationEnforcer', + 'exactCalldataEnforcer', + 'valueLteEnforcer', + 'timestampEnforcer', + 'nonceEnforcer', +] as const satisfies readonly (keyof EnforcerAddressesByName)[]; + +const DEPLOYMENT_TO_ENFORCER_KEY = { + AllowedCalldataEnforcer: 'allowedCalldataEnforcer', + AllowedTargetsEnforcer: 'allowedTargetsEnforcer', + RedeemerEnforcer: 'redeemerEnforcer', + ERC20StreamingEnforcer: 'erc20StreamingEnforcer', + ERC20PeriodTransferEnforcer: 'erc20PeriodTransferEnforcer', + NativeTokenStreamingEnforcer: 'nativeTokenStreamingEnforcer', + NativeTokenPeriodTransferEnforcer: 'nativeTokenPeriodTransferEnforcer', + ApprovalRevocationEnforcer: 'approvalRevocationEnforcer', + ExactCalldataEnforcer: 'exactCalldataEnforcer', + ValueLteEnforcer: 'valueLteEnforcer', + TimestampEnforcer: 'timestampEnforcer', + NonceEnforcer: 'nonceEnforcer', +} as const satisfies Record< + keyof DelegationDeploymentsEnforcerAddressesByName, + keyof EnforcerAddressesByName +>; + +const DEPLOYMENT_CONTRACT_NAMES = Object.keys( + DEPLOYMENT_TO_ENFORCER_KEY, +) as (keyof DelegationDeploymentsEnforcerAddressesByName)[]; + +const expectValidEnforcerAddresses = ( + contracts: DelegationDeploymentsEnforcerAddressesByName, + result: EnforcerAddressesByName, +): void => { + expect(Object.keys(result).sort()).toStrictEqual( + [...ENFORCER_ADDRESS_KEYS].sort(), + ); + + for (const deploymentName of DEPLOYMENT_CONTRACT_NAMES) { + const enforcerKey = DEPLOYMENT_TO_ENFORCER_KEY[deploymentName]; + const sourceAddress = contracts[deploymentName]; + const resolvedAddress = result[enforcerKey]; + + expect(resolvedAddress).toBe(getChecksumAddress(sourceAddress)); + expect(isStrictHexString(resolvedAddress)).toBe(true); + expect(resolvedAddress).toBe(getChecksumAddress(resolvedAddress)); + } +}; + +describe('toEnforcerAddressesByName', () => { + describe('unit tests', () => { + it('maps deployment contract names to EnforcerAddressesByName keys with checksummed values', () => { + const contracts = buildMockDelegationEnforcerContracts(); + const result = toEnforcerAddressesByName(contracts); + + expect(result).toBeDefined(); + expectValidEnforcerAddresses(contracts, result); + }); + + it('checksums mixed-case deployment addresses', () => { + const contracts = buildMockDelegationEnforcerContracts(); + const mixedCaseContracts: DelegationDeploymentsEnforcerAddressesByName = { + ...contracts, + TimestampEnforcer: '0xAbCdEf0123456789AbCdEf0123456789AbCdEf01', + ERC20StreamingEnforcer: '0x0123456789abcdef0123456789abcdef01234567', + }; + + const result = toEnforcerAddressesByName(mixedCaseContracts); + + expect(result.timestampEnforcer).toBe( + getChecksumAddress(mixedCaseContracts.TimestampEnforcer), + ); + expect(result.erc20StreamingEnforcer).toBe( + getChecksumAddress(mixedCaseContracts.ERC20StreamingEnforcer), + ); + expect(result.timestampEnforcer).not.toBe( + mixedCaseContracts.TimestampEnforcer, + ); + }); + + it.each(DEPLOYMENT_CONTRACT_NAMES)( + 'throws when %s is missing', + (missingContractName) => { + const contracts = buildMockDelegationEnforcerContracts(); + const { [missingContractName]: _removed, ...incompleteContracts } = + contracts; + + expect(() => + toEnforcerAddressesByName( + incompleteContracts as DelegationDeploymentsEnforcerAddressesByName, + ), + ).toThrow(`Contract not found: ${missingContractName}`); + }, + ); + }); + + describe('integration with @metamask/delegation-deployments', () => { + it('resolves every deployed chain to a valid EnforcerAddressesByName', () => { + for (const [chainId, contracts] of Object.entries( + delegationContractsByChainId, + )) { + const result = toEnforcerAddressesByName(contracts); + + expectValidEnforcerAddresses(contracts, result); + expect(Number(chainId)).toBeGreaterThan(0); + } + }); + + it('produces addresses accepted by makePermissionDecoderConfigs for every deployed chain', () => { + for (const contracts of Object.values(delegationContractsByChainId)) { + const enforcerAddresses = toEnforcerAddressesByName(contracts); + const decoderConfigs = makePermissionDecoderConfigs(enforcerAddresses); + + expect(decoderConfigs.length).toBeGreaterThan(0); + for (const config of decoderConfigs) { + expect(config.contractAddresses).toStrictEqual(enforcerAddresses); + } + } + }); + + it('resolves sepolia deployment addresses to the expected canonical enforcers', () => { + const sepoliaChainId = 11155111; + const contracts = delegationContractsByChainId[sepoliaChainId]; + const result = toEnforcerAddressesByName(contracts); + + expect(result.timestampEnforcer).toBe( + getChecksumAddress(contracts.TimestampEnforcer), + ); + expect(result.nativeTokenStreamingEnforcer).toBe( + getChecksumAddress(contracts.NativeTokenStreamingEnforcer), + ); + expect(result.redeemerEnforcer).toBe( + getChecksumAddress(contracts.RedeemerEnforcer), + ); + }); + }); +}); diff --git a/packages/gator-permissions-controller/src/decodePermission/enforcerAddresses.ts b/packages/gator-permissions-controller/src/decodePermission/enforcerAddresses.ts new file mode 100644 index 00000000000..08e159c9b99 --- /dev/null +++ b/packages/gator-permissions-controller/src/decodePermission/enforcerAddresses.ts @@ -0,0 +1,74 @@ +import type { EnforcerAddressesByName } from '@metamask/7715-permission-types'; +import { DELEGATOR_CONTRACTS } from '@metamask/delegation-deployments'; +import { getChecksumAddress } from '@metamask/utils'; +import type { Hex } from '@metamask/utils'; + +import { DELEGATION_FRAMEWORK_VERSION } from '../constants.js'; +import type { DelegationDeploymentsEnforcerAddressesByName } from '../types.js'; + +// @metamask/delegation-deployments exports a very loosely typed object. We assert a more narrow typing here. +export const delegationContractsByChainId = DELEGATOR_CONTRACTS[ + DELEGATION_FRAMEWORK_VERSION +] as Record; + +const getChecksumContractAddress = ( + contracts: DelegationDeploymentsEnforcerAddressesByName, + contractName: keyof DelegationDeploymentsEnforcerAddressesByName, +): Hex => { + const address = contracts[contractName]; + + if (!address) { + throw new Error(`Contract not found: ${contractName}`); + } + + return getChecksumAddress(address); +}; + +/** + * Converts delegation-deployments enforcer addresses to the canonical shape + * expected by `@metamask/7715-permission-types`, checksumming each address. + * + * @param contracts - Enforcer addresses keyed by delegation-deployments names. + * @returns Checksummed enforcer addresses keyed by permission-types names. + * @throws If an expected enforcer contract is not found. + */ +export const toEnforcerAddressesByName = ( + contracts: DelegationDeploymentsEnforcerAddressesByName, +): EnforcerAddressesByName => ({ + allowedCalldataEnforcer: getChecksumContractAddress( + contracts, + 'AllowedCalldataEnforcer', + ), + allowedTargetsEnforcer: getChecksumContractAddress( + contracts, + 'AllowedTargetsEnforcer', + ), + redeemerEnforcer: getChecksumContractAddress(contracts, 'RedeemerEnforcer'), + erc20StreamingEnforcer: getChecksumContractAddress( + contracts, + 'ERC20StreamingEnforcer', + ), + erc20PeriodTransferEnforcer: getChecksumContractAddress( + contracts, + 'ERC20PeriodTransferEnforcer', + ), + nativeTokenStreamingEnforcer: getChecksumContractAddress( + contracts, + 'NativeTokenStreamingEnforcer', + ), + nativeTokenPeriodTransferEnforcer: getChecksumContractAddress( + contracts, + 'NativeTokenPeriodTransferEnforcer', + ), + approvalRevocationEnforcer: getChecksumContractAddress( + contracts, + 'ApprovalRevocationEnforcer', + ), + exactCalldataEnforcer: getChecksumContractAddress( + contracts, + 'ExactCalldataEnforcer', + ), + valueLteEnforcer: getChecksumContractAddress(contracts, 'ValueLteEnforcer'), + timestampEnforcer: getChecksumContractAddress(contracts, 'TimestampEnforcer'), + nonceEnforcer: getChecksumContractAddress(contracts, 'NonceEnforcer'), +}); diff --git a/packages/gator-permissions-controller/src/decodePermission/index.ts b/packages/gator-permissions-controller/src/decodePermission/index.ts new file mode 100644 index 00000000000..5f0bbab3a18 --- /dev/null +++ b/packages/gator-permissions-controller/src/decodePermission/index.ts @@ -0,0 +1,12 @@ +export { + findDecodersWithMatchingCaveatAddresses, + reconstructDecodedPermission, + selectUniqueDecoderAndDecodedPermission, +} from './decodePermission.js'; +export { createPermissionDecodersForContracts } from './decoders/index.js'; + +export type { + DecodedPermission, + PermissionDecoder, + ValidateAndDecodeResult, +} from './types.js'; diff --git a/packages/gator-permissions-controller/src/decodePermission/types.ts b/packages/gator-permissions-controller/src/decodePermission/types.ts new file mode 100644 index 00000000000..8b1a298a57a --- /dev/null +++ b/packages/gator-permissions-controller/src/decodePermission/types.ts @@ -0,0 +1,80 @@ +import type { + PermissionRequest, + PermissionTypes, + Rule, +} from '@metamask/7715-permission-types'; +import type { Caveat } from '@metamask/delegation-core'; +import type { Hex } from '@metamask/utils'; + +// This is a somewhat convoluted type - it includes all of the fields that are decoded from the permission context. +/** + * A partially reconstructed permission object decoded from a permission context. + * + * This mirrors the shape of {@link PermissionRequest} for fields that can be + * deterministically recovered from the encoded permission context, and it + * augments the result with an explicit `expiry` property derived from the + * `TimestampEnforcer` terms, as well as the `origin` property. + */ +export type DecodedPermission = Pick< + PermissionRequest, + 'chainId' | 'from' | 'to' +> & { + permission: Omit< + PermissionRequest['permission'], + 'isAdjustmentAllowed' | 'type' | 'data' + > & { + type: PermissionTypes['type']; + data: PermissionTypes['data']; + // PermissionRequest type does not work well without the specific permission type, so we amend it here + justification?: string; + }; + /** + * @deprecated Use `rules` instead. + */ + expiry: number | null; + origin: string; + /** Rules recovered from caveats (e.g. redeemer allowlist). */ + rules?: Rule[]; +}; + +/** + * Supported permission type identifiers that can be decoded from a permission context. + */ +export type PermissionType = DecodedPermission['permission']['type']; + +/** + * Result of validating and decoding permission terms from caveats. + * When valid, includes expiry and decoded data; when invalid, includes the error. + */ +export type ValidateAndDecodeResult = + | { + isValid: true; + expiry: number | null; + data: DecodedPermission['permission']['data']; + rules?: Rule[]; + } + | { isValid: false; error: Error }; + +/** + * A decoder that defines the required and optional enforcers for a permission + * type, and provides methods to test whether caveat addresses match the + * permission and to validate and decode permission terms from caveats. + */ +export type PermissionDecoder = { + permissionType: PermissionType; + requiredEnforcers: Map; + optionalEnforcers: Set; + /** + * Returns true if the given caveat addresses (enforcer addresses) match this + * decoder (required enforcers present with correct multiplicity, no + * forbidden enforcers). + */ + caveatAddressesMatch: (caveatAddresses: Hex[]) => boolean; + /** + * Validates and decodes permission terms from the caveats. Returns a result + * object with isValid; when valid, includes expiry and data. + */ + validateAndDecodePermission: ( + caveats: Caveat[], + ) => ValidateAndDecodeResult; +}; diff --git a/packages/gator-permissions-controller/src/decodePermission/utils.test.ts b/packages/gator-permissions-controller/src/decodePermission/utils.test.ts new file mode 100644 index 00000000000..6892e663324 --- /dev/null +++ b/packages/gator-permissions-controller/src/decodePermission/utils.test.ts @@ -0,0 +1,393 @@ +import type { Caveat } from '@metamask/delegation-core'; +import type { Hex } from '@metamask/utils'; + +import { buildMockDelegationEnforcerContracts } from '../../tests/mocks.js'; +import { createPermissionDecodersForContracts } from './decoders/index.js'; +import { toEnforcerAddressesByName } from './enforcerAddresses.js'; +import { + extractExpiryFromCaveatTerms, + getTermsByEnforcer, + splitHex, +} from './utils.js'; + +describe('createPermissionDecodersForContracts', () => { + it('builds canonical decoders with correct required and allowed enforcers', () => { + const contracts = buildMockDelegationEnforcerContracts(); + const enforcerAddresses = toEnforcerAddressesByName(contracts); + const { + erc20StreamingEnforcer, + erc20PeriodTransferEnforcer, + nativeTokenStreamingEnforcer, + nativeTokenPeriodTransferEnforcer, + approvalRevocationEnforcer, + exactCalldataEnforcer, + valueLteEnforcer, + timestampEnforcer, + nonceEnforcer, + allowedCalldataEnforcer, + allowedTargetsEnforcer, + redeemerEnforcer, + } = enforcerAddresses; + + // erc20-token-stream + // erc20-token-periodic + // erc20-token-allowance + // native-token-stream + // native-token-periodic + // native-token-allowance + // token-approval-revocation + const permissionTypeCount = 7; + const decoders = createPermissionDecodersForContracts(enforcerAddresses); + expect(decoders).toHaveLength(permissionTypeCount); + + const byType = Object.fromEntries( + decoders.map((decoder) => [decoder.permissionType, decoder]), + ); + + // native-token-stream + expect(byType['native-token-stream']).toBeDefined(); + expect(byType['native-token-stream'].permissionType).toBe( + 'native-token-stream', + ); + expect(byType['native-token-stream'].optionalEnforcers.size).toBe(3); + expect( + byType['native-token-stream'].optionalEnforcers.has(timestampEnforcer), + ).toBe(true); + expect( + byType['native-token-stream'].optionalEnforcers.has(redeemerEnforcer), + ).toBe(true); + expect( + byType['native-token-stream'].optionalEnforcers.has( + allowedTargetsEnforcer, + ), + ).toBe(true); + expect(byType['native-token-stream'].requiredEnforcers.size).toBe(3); + expect( + Array.from(byType['native-token-stream'].requiredEnforcers.entries()), + ).toStrictEqual( + expect.arrayContaining([ + [nativeTokenStreamingEnforcer, 1], + [exactCalldataEnforcer, 1], + [nonceEnforcer, 1], + ]), + ); + + // native-token-periodic + expect(byType['native-token-periodic']).toBeDefined(); + expect(byType['native-token-periodic'].permissionType).toBe( + 'native-token-periodic', + ); + expect(byType['native-token-periodic'].optionalEnforcers.size).toBe(3); + expect( + byType['native-token-periodic'].optionalEnforcers.has(timestampEnforcer), + ).toBe(true); + expect( + byType['native-token-periodic'].optionalEnforcers.has(redeemerEnforcer), + ).toBe(true); + expect( + byType['native-token-periodic'].optionalEnforcers.has( + allowedTargetsEnforcer, + ), + ).toBe(true); + expect(byType['native-token-periodic'].requiredEnforcers.size).toBe(3); + expect( + Array.from(byType['native-token-periodic'].requiredEnforcers.entries()), + ).toStrictEqual( + expect.arrayContaining([ + [nativeTokenPeriodTransferEnforcer, 1], + [exactCalldataEnforcer, 1], + [nonceEnforcer, 1], + ]), + ); + + // erc20-token-stream + expect(byType['erc20-token-stream']).toBeDefined(); + expect(byType['erc20-token-stream'].permissionType).toBe( + 'erc20-token-stream', + ); + expect(byType['erc20-token-stream'].optionalEnforcers.size).toBe(3); + expect( + byType['erc20-token-stream'].optionalEnforcers.has(timestampEnforcer), + ).toBe(true); + expect( + byType['erc20-token-stream'].optionalEnforcers.has(redeemerEnforcer), + ).toBe(true); + expect( + byType['erc20-token-stream'].optionalEnforcers.has( + allowedCalldataEnforcer, + ), + ).toBe(true); + expect(byType['erc20-token-stream'].requiredEnforcers.size).toBe(3); + expect( + Array.from(byType['erc20-token-stream'].requiredEnforcers.entries()), + ).toStrictEqual( + expect.arrayContaining([ + [erc20StreamingEnforcer, 1], + [valueLteEnforcer, 1], + [nonceEnforcer, 1], + ]), + ); + + // erc20-token-periodic + expect(byType['erc20-token-periodic']).toBeDefined(); + expect(byType['erc20-token-periodic'].permissionType).toBe( + 'erc20-token-periodic', + ); + expect(byType['erc20-token-periodic'].optionalEnforcers.size).toBe(3); + expect( + byType['erc20-token-periodic'].optionalEnforcers.has(timestampEnforcer), + ).toBe(true); + expect( + byType['erc20-token-periodic'].optionalEnforcers.has(redeemerEnforcer), + ).toBe(true); + expect( + byType['erc20-token-periodic'].optionalEnforcers.has( + allowedCalldataEnforcer, + ), + ).toBe(true); + expect(byType['erc20-token-periodic'].requiredEnforcers.size).toBe(3); + expect( + Array.from(byType['erc20-token-periodic'].requiredEnforcers.entries()), + ).toStrictEqual( + expect.arrayContaining([ + [erc20PeriodTransferEnforcer, 1], + [valueLteEnforcer, 1], + [nonceEnforcer, 1], + ]), + ); + + // native-token-allowance + expect(byType['native-token-allowance']).toBeDefined(); + expect(byType['native-token-allowance'].permissionType).toBe( + 'native-token-allowance', + ); + expect(byType['native-token-allowance'].optionalEnforcers.size).toBe(3); + expect( + byType['native-token-allowance'].optionalEnforcers.has(timestampEnforcer), + ).toBe(true); + expect( + byType['native-token-allowance'].optionalEnforcers.has(redeemerEnforcer), + ).toBe(true); + expect( + byType['native-token-allowance'].optionalEnforcers.has( + allowedTargetsEnforcer, + ), + ).toBe(true); + expect(byType['native-token-allowance'].requiredEnforcers.size).toBe(3); + expect( + Array.from(byType['native-token-allowance'].requiredEnforcers.entries()), + ).toStrictEqual( + expect.arrayContaining([ + [nativeTokenPeriodTransferEnforcer, 1], + [exactCalldataEnforcer, 1], + [nonceEnforcer, 1], + ]), + ); + + // erc20-token-allowance + expect(byType['erc20-token-allowance']).toBeDefined(); + expect(byType['erc20-token-allowance'].permissionType).toBe( + 'erc20-token-allowance', + ); + expect(byType['erc20-token-allowance'].optionalEnforcers.size).toBe(3); + expect( + byType['erc20-token-allowance'].optionalEnforcers.has(timestampEnforcer), + ).toBe(true); + expect( + byType['erc20-token-allowance'].optionalEnforcers.has(redeemerEnforcer), + ).toBe(true); + expect( + byType['erc20-token-allowance'].optionalEnforcers.has( + allowedCalldataEnforcer, + ), + ).toBe(true); + expect(byType['erc20-token-allowance'].requiredEnforcers.size).toBe(3); + expect( + Array.from(byType['erc20-token-allowance'].requiredEnforcers.entries()), + ).toStrictEqual( + expect.arrayContaining([ + [erc20PeriodTransferEnforcer, 1], + [valueLteEnforcer, 1], + [nonceEnforcer, 1], + ]), + ); + + // token-approval-revocation + expect(byType['token-approval-revocation']).toBeDefined(); + expect(byType['token-approval-revocation'].permissionType).toBe( + 'token-approval-revocation', + ); + expect(byType['token-approval-revocation'].optionalEnforcers.size).toBe(1); + expect( + byType['token-approval-revocation'].optionalEnforcers.has( + timestampEnforcer, + ), + ).toBe(true); + expect(byType['token-approval-revocation'].requiredEnforcers.size).toBe(2); + expect( + Array.from( + byType['token-approval-revocation'].requiredEnforcers.entries(), + ), + ).toStrictEqual( + expect.arrayContaining([ + [approvalRevocationEnforcer, 1], + [nonceEnforcer, 1], + ]), + ); + }); + + it('each decoder has caveatAddressesMatch and validateAndDecodePermission', () => { + const contracts = buildMockDelegationEnforcerContracts(); + const enforcerAddresses = toEnforcerAddressesByName(contracts); + const decoders = createPermissionDecodersForContracts(enforcerAddresses); + const { + nativeTokenStreamingEnforcer, + exactCalldataEnforcer, + nonceEnforcer, + timestampEnforcer, + } = enforcerAddresses; + + for (const decoder of decoders) { + expect(typeof decoder.caveatAddressesMatch).toBe('function'); + expect(typeof decoder.validateAndDecodePermission).toBe('function'); + } + + const nativeStreamDecoder = decoders.find( + (candidate) => candidate.permissionType === 'native-token-stream', + ); + expect(nativeStreamDecoder).toBeDefined(); + if (!nativeStreamDecoder) { + throw new Error('Decoder not found'); + } + + const matchingCaveatAddresses: Hex[] = [ + nativeTokenStreamingEnforcer, + exactCalldataEnforcer, + nonceEnforcer, + timestampEnforcer, + ]; + expect( + nativeStreamDecoder.caveatAddressesMatch(matchingCaveatAddresses), + ).toBe(true); + }); +}); + +describe('getTermsByEnforcer', () => { + const ENFORCER: Hex = '0x9999999999999999999999999999999999999999' as Hex; + const OTHER: Hex = '0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa' as Hex; + const TERMS: Hex = '0x1234' as Hex; + + it('returns the terms when exactly one matching caveat exists', () => { + const caveats: Caveat[] = [ + { enforcer: OTHER, terms: '0x00' as Hex, args: '0x' as Hex }, + { enforcer: ENFORCER, terms: TERMS, args: '0x' as Hex }, + ]; + + expect(getTermsByEnforcer({ caveats, enforcer: ENFORCER })).toBe(TERMS); + }); + + it('throws for zero matches', () => { + const caveats: Caveat[] = [ + { enforcer: OTHER, terms: '0x00' as Hex, args: '0x' as Hex }, + ]; + expect(() => getTermsByEnforcer({ caveats, enforcer: ENFORCER })).toThrow( + 'Invalid caveats', + ); + }); + + it('throws for zero matches if throwIfNotFound is true', () => { + const caveats: Caveat[] = [ + { enforcer: OTHER, terms: '0x00' as Hex, args: '0x' as Hex }, + ]; + expect(() => + getTermsByEnforcer({ + caveats, + enforcer: ENFORCER, + throwIfNotFound: true, + }), + ).toThrow('Invalid caveats'); + }); + + it('returns null for zero matches if throwIfNotFound is false', () => { + const caveats: Caveat[] = [ + { enforcer: OTHER, terms: '0x00' as Hex, args: '0x' as Hex }, + ]; + expect( + getTermsByEnforcer({ + caveats, + enforcer: ENFORCER, + throwIfNotFound: false, + }), + ).toBeNull(); + }); + + it('throws for multiple matches', () => { + const caveats: Caveat[] = [ + { enforcer: ENFORCER, terms: TERMS, args: '0x' as Hex }, + { enforcer: ENFORCER, terms: TERMS, args: '0x' as Hex }, + ]; + expect(() => getTermsByEnforcer({ caveats, enforcer: ENFORCER })).toThrow( + 'Invalid caveats', + ); + }); + + it('throws for multiple matches if throwIfNotFound is true', () => { + const caveats: Caveat[] = [ + { enforcer: ENFORCER, terms: TERMS, args: '0x' as Hex }, + { enforcer: ENFORCER, terms: TERMS, args: '0x' as Hex }, + ]; + expect(() => + getTermsByEnforcer({ + caveats, + enforcer: ENFORCER, + throwIfNotFound: true, + }), + ).toThrow('Invalid caveats'); + }); +}); + +describe('extractExpiryFromCaveatTerms', () => { + it('returns expiry from valid TimestampEnforcer terms', () => { + const expiry = 1735689600n; + const terms = + `0x${'0'.repeat(32)}${expiry.toString(16).padStart(32, '0')}` as Hex; + + expect(extractExpiryFromCaveatTerms(terms)).toBe(Number(expiry)); + }); + + it('throws if terms length is not 66 characters', () => { + const invalidTerms = '0x1234' as Hex; + expect(() => extractExpiryFromCaveatTerms(invalidTerms)).toThrow( + 'Invalid TimestampEnforcer terms length: expected 66 characters (0x + 64 hex), got 6', + ); + }); + + it('throws if timestampAfterThreshold is non-zero', () => { + const terms = + '0x0000000000000000000000000000000100000000000000000000000000000001' as Hex; + + expect(() => extractExpiryFromCaveatTerms(terms)).toThrow( + 'Invalid expiry: timestampAfterThreshold must be 0', + ); + }); + + it('throws if timestampBeforeThreshold is zero', () => { + const terms = `0x${'0'.repeat(64)}`; + + expect(() => extractExpiryFromCaveatTerms(terms)).toThrow( + 'Invalid expiry: timestampBeforeThreshold must be greater than 0', + ); + }); +}); + +describe('splitHex', () => { + it('splits per byte lengths and preserves leading zeros', () => { + const value = '0x00a0b0' as Hex; // 3 bytes + expect(splitHex(value, [1, 2])).toStrictEqual(['0x00', '0xa0b0']); + }); + + it('splits example input correctly', () => { + const value = '0x12345678' as Hex; + expect(splitHex(value, [1, 3])).toStrictEqual(['0x12', '0x345678']); + }); +}); diff --git a/packages/gator-permissions-controller/src/decodePermission/utils.ts b/packages/gator-permissions-controller/src/decodePermission/utils.ts new file mode 100644 index 00000000000..23d6d5ecc14 --- /dev/null +++ b/packages/gator-permissions-controller/src/decodePermission/utils.ts @@ -0,0 +1,148 @@ +import type { Caveat } from '@metamask/delegation-core'; +import { getChecksumAddress, hexToNumber } from '@metamask/utils'; +import type { Hex } from '@metamask/utils'; + +/** + * Extracts the expiry timestamp from TimestampEnforcer caveat terms. + * Terms are 32 bytes: first 16 bytes timestampAfterThreshold (must be 0), + * last 16 bytes timestampBeforeThreshold (expiry). + * + * @param terms - The hex-encoded terms from a TimestampEnforcer caveat. + * @returns The expiry timestamp in seconds. + * @throws If terms are invalid. + */ +export const extractExpiryFromCaveatTerms = (terms: Hex): number => { + if (terms.length !== 66) { + throw new Error( + `Invalid TimestampEnforcer terms length: expected 66 characters (0x + 64 hex), got ${terms.length}`, + ); + } + const [after, before] = splitHex(terms, [16, 16]); + if (hexToNumber(after) !== 0) { + throw new Error('Invalid expiry: timestampAfterThreshold must be 0'); + } + const expiry = hexToNumber(before); + if (expiry === 0) { + throw new Error( + 'Invalid expiry: timestampBeforeThreshold must be greater than 0', + ); + } + return expiry; +}; + +/** + * Builds enforcer counts and set from caveat addresses (checksummed). + * Used by caveatAddressesMatch. + * + * @param caveatAddresses - List of enforcer contract addresses (hex). + * @returns Counts per enforcer and set of unique enforcers. + */ +export function buildEnforcerCountsAndSet(caveatAddresses: Hex[]): { + counts: Map; + enforcersSet: Set; +} { + const counts = new Map(); + for (const addr of caveatAddresses.map(getChecksumAddress)) { + counts.set(addr, (counts.get(addr) ?? 0) + 1); + } + return { counts, enforcersSet: new Set(counts.keys()) }; +} + +/** + * Returns true if the given counts/set match the rule (required counts exact, + * no enforcer outside required + optional). + * + * @param counts - Map of enforcer address to occurrence count. + * @param enforcersSet - Set of unique enforcer addresses present. + * @param requiredEnforcers - Map of required enforcer to required count. + * @param optionalEnforcers - Set of optional enforcer addresses. + * @returns True if the counts match the rule. + */ +export function enforcersMatchRule( + counts: Map, + enforcersSet: Set, + requiredEnforcers: Map, + optionalEnforcers: Set, +): boolean { + const allowedEnforcers = new Set([ + ...optionalEnforcers, + ...requiredEnforcers.keys(), + ]); + for (const addr of enforcersSet) { + if (!allowedEnforcers.has(addr)) { + return false; + } + } + for (const [addr, requiredCount] of requiredEnforcers.entries()) { + if ((counts.get(addr) ?? 0) !== requiredCount) { + return false; + } + } + return true; +} + +/** + * Gets the terms for a given enforcer from a list of caveats. + * + * @param args - The arguments to this function. + * @param args.throwIfNotFound - Whether to throw an error if no matching enforcer is found. Default is true. + * @param args.caveats - The list of caveats to search. + * @param args.enforcer - The enforcer to search for. + * @returns The terms for the given enforcer. + */ +export function getTermsByEnforcer({ + caveats, + enforcer, + throwIfNotFound, +}: { + caveats: Caveat[]; + enforcer: Hex; + throwIfNotFound?: TThrowIfNotFound; +}): TThrowIfNotFound extends true ? Hex : Hex | null { + const matchingCaveats = caveats.filter( + (caveat) => caveat.enforcer === enforcer, + ); + + if (matchingCaveats.length === 0) { + if (throwIfNotFound ?? true) { + throw new Error('Invalid caveats'); + } + return null as TThrowIfNotFound extends true ? Hex : Hex | null; + } + + if (matchingCaveats.length > 1) { + throw new Error('Invalid caveats'); + } + + return matchingCaveats[0].terms; +} + +/** + * Splits a 0x-prefixed hex string into parts according to the provided byte lengths. + * + * Each entry in `lengths` represents a part length in bytes; internally this is + * multiplied by 2 to derive the number of hexadecimal characters to slice. The + * returned substrings do not include the `0x` prefix and preserve leading zeros. + * + * Note: This function does not perform input validation (e.g., verifying the + * payload length equals the sum of requested lengths). Callers are expected to + * provide well-formed inputs. + * + * Example: + * splitHex('0x12345678', [1, 3]) => ['0x12', '0x345678'] + * + * @param value - The 0x-prefixed hex string to split. + * @param lengths - The lengths of each part, in bytes. + * @returns An array of hex substrings (each with `0x` prefix), one for each part. + */ +export function splitHex(value: Hex, lengths: number[]): Hex[] { + let start = 2; + const parts: Hex[] = []; + for (const partLength of lengths) { + const partCharLength = partLength * 2; + const part = value.slice(start, start + partCharLength); + start += partCharLength; + parts.push(`0x${part}` as const); + } + return parts; +} diff --git a/packages/gator-permissions-controller/src/errors.test.ts b/packages/gator-permissions-controller/src/errors.test.ts new file mode 100644 index 00000000000..b1874474126 --- /dev/null +++ b/packages/gator-permissions-controller/src/errors.test.ts @@ -0,0 +1,104 @@ +import { + GatorPermissionsControllerError, + GatorPermissionsFetchError, + GatorPermissionsProviderError, + OriginNotAllowedError, + PermissionDecodingError, +} from './errors.js'; +import { + GatorPermissionsControllerErrorCode, + GatorPermissionsSnapRpcMethod, +} from './types.js'; + +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/errors.ts b/packages/gator-permissions-controller/src/errors.ts new file mode 100644 index 00000000000..45e1ccc3c6f --- /dev/null +++ b/packages/gator-permissions-controller/src/errors.ts @@ -0,0 +1,72 @@ +import type { GatorPermissionsSnapRpcMethod } from './types.js'; +import { GatorPermissionsControllerErrorCode } from './types.js'; + +/** + * Represents a base gator permissions error. + */ +type GatorPermissionsErrorParams = { + code: GatorPermissionsControllerErrorCode; + cause: Error; + message: string; +}; + +export class GatorPermissionsControllerError extends Error { + code: GatorPermissionsControllerErrorCode; + + cause: Error; + + constructor({ cause, message, code }: GatorPermissionsErrorParams) { + super(message); + + this.cause = cause; + this.code = code; + } +} + +export class GatorPermissionsFetchError extends GatorPermissionsControllerError { + constructor({ cause, message }: { cause: Error; message: string }) { + super({ + cause, + message, + code: GatorPermissionsControllerErrorCode.GatorPermissionsFetchError, + }); + } +} + +export class GatorPermissionsProviderError extends GatorPermissionsControllerError { + constructor({ + cause, + method, + }: { + cause: Error; + method: GatorPermissionsSnapRpcMethod; + }) { + super({ + cause, + message: `Failed to handle snap request to gator permissions provider for method ${method}`, + code: GatorPermissionsControllerErrorCode.GatorPermissionsProviderError, + }); + } +} + +export class OriginNotAllowedError extends GatorPermissionsControllerError { + constructor({ origin }: { origin: string }) { + const message = `Origin ${origin} not allowed`; + + super({ + cause: new Error(message), + message, + code: GatorPermissionsControllerErrorCode.OriginNotAllowedError, + }); + } +} + +export class PermissionDecodingError extends GatorPermissionsControllerError { + constructor({ cause }: { cause: Error }) { + super({ + cause, + message: `Failed to decode permission`, + code: GatorPermissionsControllerErrorCode.PermissionDecodingError, + }); + } +} diff --git a/packages/gator-permissions-controller/src/index.ts b/packages/gator-permissions-controller/src/index.ts new file mode 100644 index 00000000000..726fa792f7d --- /dev/null +++ b/packages/gator-permissions-controller/src/index.ts @@ -0,0 +1,50 @@ +export { default as GatorPermissionsController } from './GatorPermissionsController.js'; +export { + DELEGATION_FRAMEWORK_VERSION, + EXECUTION_PERMISSION_EXPIRY_RULE_TYPE, + EXECUTION_PERMISSION_PAYEE_RULE_TYPE, + EXECUTION_PERMISSION_REDEEMER_RULE_TYPE, +} from './constants.js'; +export type { + GatorPermissionsControllerFetchAndUpdateGatorPermissionsAction, + GatorPermissionsControllerAddPendingRevocationAction, + GatorPermissionsControllerDecodePermissionFromPermissionContextForOriginAction, + GatorPermissionsControllerInitializeAction, + GatorPermissionsControllerIsPendingRevocationAction, + GatorPermissionsControllerSubmitDirectRevocationAction, + GatorPermissionsControllerSubmitRevocationAction, +} from './GatorPermissionsController-method-action-types.js'; +export type { + GatorPermissionsControllerState, + GatorPermissionsControllerConfig, + GatorPermissionsControllerMessenger, + GatorPermissionsControllerGetStateAction, + GatorPermissionsControllerActions, + GatorPermissionsControllerEvents, + GatorPermissionsControllerStateChangeEvent, +} from './GatorPermissionsController.js'; +export type { DecodedPermission } from './decodePermission/index.js'; +export type { + GatorPermissionsControllerErrorCode, + GatorPermissionsSnapRpcMethod, + PermissionRequest, + PermissionResponse, + PermissionInfo, + StoredGatorPermission, + PermissionInfoWithMetadata, + GatorPermissionStatus, + DelegationDetails, + RevocationParams, + RevocationMetadata, + SupportedPermissionType, +} from './types.js'; + +export type { PayeeRule } from './payeeRule.js'; +export type { RedeemerRule } from './redeemerRule.js'; +export type { + NativeTokenStreamPermission, + NativeTokenPeriodicPermission, + Erc20TokenStreamPermission, + Erc20TokenPeriodicPermission, + MetaMaskBasePermissionData, +} from '@metamask/7715-permission-types'; diff --git a/packages/gator-permissions-controller/src/logger.ts b/packages/gator-permissions-controller/src/logger.ts new file mode 100644 index 00000000000..03445d678ed --- /dev/null +++ b/packages/gator-permissions-controller/src/logger.ts @@ -0,0 +1,16 @@ +/* istanbul ignore file */ + +import { createProjectLogger, createModuleLogger } from '@metamask/utils'; + +export const projectLogger = createProjectLogger( + 'gator-permissions-controller', +); + +export const controllerLog = createModuleLogger( + projectLogger, + 'GatorPermissionsController', +); + +export const utilsLog = createModuleLogger(projectLogger, 'utils'); + +export { createModuleLogger }; diff --git a/packages/gator-permissions-controller/src/payeeRule.ts b/packages/gator-permissions-controller/src/payeeRule.ts new file mode 100644 index 00000000000..4731c16c351 --- /dev/null +++ b/packages/gator-permissions-controller/src/payeeRule.ts @@ -0,0 +1,12 @@ +import type { Hex } from '@metamask/utils'; + +/** + * Execution permission rule restricting which addresses may receive payments + * (on-chain AllowedCalldataEnforcer / AllowedTargetsEnforcer caveat). + */ +export type PayeeRule = { + type: 'payee'; + data: { + addresses: Hex[]; + }; +}; diff --git a/packages/gator-permissions-controller/src/permissionOnChainStatus.test.ts b/packages/gator-permissions-controller/src/permissionOnChainStatus.test.ts new file mode 100644 index 00000000000..4d529e04138 --- /dev/null +++ b/packages/gator-permissions-controller/src/permissionOnChainStatus.test.ts @@ -0,0 +1,630 @@ +import { + createNativeTokenStreamingTerms, + createTimestampTerms, + Delegation, + encodeDelegations, + ROOT_AUTHORITY, +} from '@metamask/delegation-core'; +import { + CHAIN_ID, + DELEGATOR_CONTRACTS, +} from '@metamask/delegation-deployments'; +import { hexToBigInt, numberToHex } from '@metamask/utils'; +import type { Hex } from '@metamask/utils'; + +import { DELEGATION_FRAMEWORK_VERSION } from './constants.js'; +import { toEnforcerAddressesByName } from './decodePermission/enforcerAddresses.js'; +import { + encodeDisabledDelegationsCalldata, + getExpiryFromDelegation, + readDelegationDisabledOnChain, + readLatestBlockTimestampSeconds, + resolveGrantedPermissionOnChainStatus, + updateGrantedPermissionsStatus, +} from './permissionOnChainStatus.js'; +import type { PermissionInfoWithMetadata } from './types.js'; +import type { DelegationDeploymentsEnforcerAddressesByName } from './types.js'; + +const sepoliaContracts = + DELEGATOR_CONTRACTS[DELEGATION_FRAMEWORK_VERSION][CHAIN_ID.sepolia]; + +const delegationContracts = + sepoliaContracts as DelegationDeploymentsEnforcerAddressesByName; + +const enforcerAddresses = toEnforcerAddressesByName(delegationContracts); + +const { TimestampEnforcer, NativeTokenStreamingEnforcer } = delegationContracts; + +describe('permissionOnChainStatus', () => { + describe('encodeDisabledDelegationsCalldata', () => { + it('prefixes calldata with disabledDelegations(bytes32) selector', () => { + const hash = + '0x1111111111111111111111111111111111111111111111111111111111111111'; + const data = encodeDisabledDelegationsCalldata(hash); + expect(data.startsWith('0x2d40d052')).toBe(true); + expect(data).toHaveLength(2 + 8 + 64); + }); + }); + + describe('getExpiryFromDelegation', () => { + it('returns expiry from TimestampEnforcer caveat terms', () => { + const expirySeconds = 1893456000; + const terms = createTimestampTerms({ + afterThreshold: 0, + beforeThreshold: expirySeconds, + }); + const delegation: Delegation = { + delegate: '0x4f71DA06987BfeDE90aF0b33E1e3e4ffDCEE7a63', + delegator: '0xB68c70159E9892DdF5659ec42ff9BD2bbC23e778', + authority: ROOT_AUTHORITY, + caveats: [ + { + enforcer: NativeTokenStreamingEnforcer, + terms: createNativeTokenStreamingTerms({ + initialAmount: hexToBigInt('0x6f05b59d3b20000'), + maxAmount: hexToBigInt('0x22b1c8c1227a0000'), + amountPerSecond: hexToBigInt('0x6f05b59d3b20000'), + startTime: 1747699200, + }), + args: '0x', + }, + { + enforcer: TimestampEnforcer, + terms, + args: '0x', + }, + ], + salt: 0n, + signature: '0x' as const, + }; + const result = getExpiryFromDelegation(delegation, enforcerAddresses); + expect(result).toBe(expirySeconds); + }); + + it('returns null when no timestamp caveat matches', () => { + const delegation: Delegation = { + delegate: '0x4f71DA06987BfeDE90aF0b33E1e3e4ffDCEE7a63', + delegator: '0xB68c70159E9892DdF5659ec42ff9BD2bbC23e778', + authority: ROOT_AUTHORITY, + caveats: [ + { + enforcer: NativeTokenStreamingEnforcer, + terms: createNativeTokenStreamingTerms({ + initialAmount: hexToBigInt('0x6f05b59d3b20000'), + maxAmount: hexToBigInt('0x22b1c8c1227a0000'), + amountPerSecond: hexToBigInt('0x6f05b59d3b20000'), + startTime: 1747699200, + }), + args: '0x', + }, + ], + salt: 0n, + signature: '0x' as const, + }; + expect(getExpiryFromDelegation(delegation, enforcerAddresses)).toBeNull(); + }); + + it('returns null when TimestampEnforcer terms fail to decode', () => { + const invalidTerms: Hex = `0x${'01'.repeat(32)}`; + const delegation: Delegation = { + delegate: '0x4f71DA06987BfeDE90aF0b33E1e3e4ffDCEE7a63', + delegator: '0xB68c70159E9892DdF5659ec42ff9BD2bbC23e778', + authority: ROOT_AUTHORITY, + caveats: [ + { + enforcer: TimestampEnforcer, + terms: invalidTerms, + args: '0x', + }, + ], + salt: 0n, + signature: '0x' as const, + }; + expect(getExpiryFromDelegation(delegation, enforcerAddresses)).toBeNull(); + }); + }); + + describe('readDelegationDisabledOnChain', () => { + it('returns true when eth_call result is bool true', async () => { + const provider = { + request: jest + .fn() + .mockResolvedValue( + '0x0000000000000000000000000000000000000000000000000000000000000001', + ), + }; + expect( + await readDelegationDisabledOnChain({ + provider, + delegationManager: sepoliaContracts.DelegationManager, + delegationHash: + '0x1111111111111111111111111111111111111111111111111111111111111111', + }), + ).toBe(true); + }); + }); + + describe('readLatestBlockTimestampSeconds', () => { + it('returns the block timestamp in seconds', async () => { + const timestamp = 1_700_000_000; + const provider = { + request: jest.fn().mockResolvedValue({ + timestamp: numberToHex(timestamp), + }), + }; + expect(await readLatestBlockTimestampSeconds(provider)).toBe(timestamp); + }); + + it('throws when the block payload has no timestamp', async () => { + const provider = { + request: jest.fn().mockResolvedValue({}), + }; + await expect(readLatestBlockTimestampSeconds(provider)).rejects.toThrow( + 'Latest block missing timestamp', + ); + }); + }); + + describe('resolveGrantedPermissionOnChainStatus', () => { + it('returns Revoked when revocationMetadata is present without calling the network', async () => { + const getProviderForChainId = jest.fn(); + const entry: PermissionInfoWithMetadata = { + permissionResponse: { + chainId: numberToHex(CHAIN_ID.sepolia), + from: '0xB68c70159E9892DdF5659ec42ff9BD2bbC23e778', + permission: { + type: 'native-token-stream', + isAdjustmentAllowed: true, + data: { + maxAmount: '0x1', + initialAmount: '0x1', + amountPerSecond: '0x1', + startTime: 1, + justification: 'j', + }, + }, + context: '0x00000000', + delegationManager: sepoliaContracts.DelegationManager, + }, + siteOrigin: 'https://example.org', + status: 'Active', + revocationMetadata: { recordedAt: 1 }, + }; + + const result = await resolveGrantedPermissionOnChainStatus(entry, { + getProviderForChainId, + }); + + expect(result.status).toBe('Revoked'); + expect(getProviderForChainId).not.toHaveBeenCalled(); + }); + + it('preserves prior status when decoded context does not contain exactly one delegation', async () => { + const getProviderForChainId = jest.fn(); + const entry: PermissionInfoWithMetadata = { + permissionResponse: { + chainId: numberToHex(CHAIN_ID.sepolia), + from: '0xB68c70159E9892DdF5659ec42ff9BD2bbC23e778', + permission: { + type: 'native-token-stream', + isAdjustmentAllowed: true, + data: { + maxAmount: '0x1', + initialAmount: '0x1', + amountPerSecond: '0x1', + startTime: 1, + justification: 'j', + }, + }, + context: '0x00000000', + delegationManager: sepoliaContracts.DelegationManager, + }, + siteOrigin: 'https://example.org', + status: 'Expired', + }; + + const result = await resolveGrantedPermissionOnChainStatus(entry, { + getProviderForChainId, + }); + + expect(result.status).toBe('Expired'); + expect(getProviderForChainId).not.toHaveBeenCalled(); + }); + + it('defaults missing entry status to Active when preserving after a resolution error', async () => { + const getProviderForChainId = jest.fn(); + const entry = { + permissionResponse: { + chainId: numberToHex(CHAIN_ID.sepolia), + from: '0xB68c70159E9892DdF5659ec42ff9BD2bbC23e778', + permission: { + type: 'native-token-stream', + isAdjustmentAllowed: true, + data: { + maxAmount: '0x1', + initialAmount: '0x1', + amountPerSecond: '0x1', + startTime: 1, + justification: 'j', + }, + }, + context: '0x00000000', + delegationManager: sepoliaContracts.DelegationManager, + }, + siteOrigin: 'https://example.org', + // status is missing, so we must add type assertion + } as unknown as PermissionInfoWithMetadata; + + const result = await resolveGrantedPermissionOnChainStatus(entry, { + getProviderForChainId, + }); + + expect(result.status).toBe('Active'); + expect(getProviderForChainId).not.toHaveBeenCalled(); + }); + + it('sets Active when a single delegation is not disabled and has no timestamp expiry caveat', async () => { + const delegation: Delegation = { + delegate: '0x4f71DA06987BfeDE90aF0b33E1e3e4ffDCEE7a63', + delegator: '0xB68c70159E9892DdF5659ec42ff9BD2bbC23e778', + authority: ROOT_AUTHORITY, + caveats: [ + { + enforcer: NativeTokenStreamingEnforcer, + terms: createNativeTokenStreamingTerms({ + initialAmount: hexToBigInt('0x6f05b59d3b20000'), + maxAmount: hexToBigInt('0x22b1c8c1227a0000'), + amountPerSecond: hexToBigInt('0x6f05b59d3b20000'), + startTime: 1747699200, + }), + args: '0x', + }, + ], + salt: 0n, + signature: '0x', + }; + const context = encodeDelegations([delegation]); + const entry: PermissionInfoWithMetadata = { + permissionResponse: { + chainId: numberToHex(CHAIN_ID.sepolia), + from: delegation.delegator, + permission: { + type: 'native-token-stream', + isAdjustmentAllowed: true, + data: { + maxAmount: '0x22b1c8c1227a0000', + initialAmount: '0x6f05b59d3b20000', + amountPerSecond: '0x6f05b59d3b20000', + startTime: 1747699200, + justification: 'j', + }, + }, + context, + delegationManager: sepoliaContracts.DelegationManager, + }, + siteOrigin: 'https://example.org', + status: 'Expired', + }; + + const getProviderForChainId = jest.fn().mockResolvedValue({ + request: jest.fn(async (req) => { + if (req.method === 'eth_call') { + return '0x0000000000000000000000000000000000000000000000000000000000000000'; + } + if (req.method === 'eth_getBlockByNumber') { + return { timestamp: numberToHex(2_000_000_000) }; + } + throw new Error(`Unexpected RPC: ${req.method}`); + }), + }); + + const result = await resolveGrantedPermissionOnChainStatus(entry, { + getProviderForChainId, + }); + + expect(result.status).toBe('Active'); + expect(getProviderForChainId).toHaveBeenCalledTimes(1); + }); + + it('sets Expired when latest block time is at or past timestamp caveat expiry', async () => { + const expirySeconds = 1_000_000_000; + const terms = createTimestampTerms({ + afterThreshold: 0, + beforeThreshold: expirySeconds, + }); + const delegation: Delegation = { + delegate: '0x4f71DA06987BfeDE90aF0b33E1e3e4ffDCEE7a63', + delegator: '0xB68c70159E9892DdF5659ec42ff9BD2bbC23e778', + authority: ROOT_AUTHORITY, + caveats: [ + { + enforcer: NativeTokenStreamingEnforcer, + terms: createNativeTokenStreamingTerms({ + initialAmount: hexToBigInt('0x6f05b59d3b20000'), + maxAmount: hexToBigInt('0x22b1c8c1227a0000'), + amountPerSecond: hexToBigInt('0x6f05b59d3b20000'), + startTime: 1747699200, + }), + args: '0x', + }, + { + enforcer: TimestampEnforcer, + terms, + args: '0x', + }, + ], + salt: 0n, + signature: '0x', + }; + const context = encodeDelegations([delegation]); + const entry: PermissionInfoWithMetadata = { + permissionResponse: { + chainId: numberToHex(CHAIN_ID.sepolia), + from: delegation.delegator, + permission: { + type: 'native-token-stream', + isAdjustmentAllowed: true, + data: { + maxAmount: '0x22b1c8c1227a0000', + initialAmount: '0x6f05b59d3b20000', + amountPerSecond: '0x6f05b59d3b20000', + startTime: 1747699200, + justification: 'j', + }, + }, + context, + delegationManager: sepoliaContracts.DelegationManager, + }, + siteOrigin: 'https://example.org', + status: 'Active', + }; + + const getProviderForChainId = jest.fn().mockResolvedValue({ + request: jest.fn(async (req) => { + if (req.method === 'eth_call') { + return '0x0000000000000000000000000000000000000000000000000000000000000000'; + } + if (req.method === 'eth_getBlockByNumber') { + return { timestamp: numberToHex(expirySeconds) }; + } + throw new Error(`Unexpected RPC: ${req.method}`); + }), + }); + + const result = await resolveGrantedPermissionOnChainStatus(entry, { + getProviderForChainId, + }); + + expect(result.status).toBe('Expired'); + }); + + it('sets Revoked when disabledDelegations returns true', async () => { + const delegation: Delegation = { + delegate: '0x4f71DA06987BfeDE90aF0b33E1e3e4ffDCEE7a63', + delegator: '0xB68c70159E9892DdF5659ec42ff9BD2bbC23e778', + authority: ROOT_AUTHORITY, + caveats: [ + { + enforcer: NativeTokenStreamingEnforcer, + terms: createNativeTokenStreamingTerms({ + initialAmount: hexToBigInt('0x6f05b59d3b20000'), + maxAmount: hexToBigInt('0x22b1c8c1227a0000'), + amountPerSecond: hexToBigInt('0x6f05b59d3b20000'), + startTime: 1747699200, + }), + args: '0x', + }, + ], + salt: 0n, + signature: '0x', + }; + const context = encodeDelegations([delegation]); + const entry: PermissionInfoWithMetadata = { + permissionResponse: { + chainId: numberToHex(CHAIN_ID.sepolia), + from: delegation.delegator, + permission: { + type: 'native-token-stream', + isAdjustmentAllowed: true, + data: { + maxAmount: '0x22b1c8c1227a0000', + initialAmount: '0x6f05b59d3b20000', + amountPerSecond: '0x6f05b59d3b20000', + startTime: 1747699200, + justification: 'j', + }, + }, + context, + delegationManager: sepoliaContracts.DelegationManager, + }, + siteOrigin: 'https://example.org', + status: 'Active', + }; + + const getProviderForChainId = jest.fn().mockResolvedValue({ + request: jest.fn(async (req) => { + if (req.method === 'eth_call') { + return '0x0000000000000000000000000000000000000000000000000000000000000001'; + } + throw new Error(`Unexpected RPC: ${req.method}`); + }), + }); + + const result = await resolveGrantedPermissionOnChainStatus(entry, { + getProviderForChainId, + }); + + expect(result.status).toBe('Revoked'); + }); + + it('preserves prior status when deployment contracts are missing for the chain', async () => { + const delegation: Delegation = { + delegate: '0x4f71DA06987BfeDE90aF0b33E1e3e4ffDCEE7a63', + delegator: '0xB68c70159E9892DdF5659ec42ff9BD2bbC23e778', + authority: ROOT_AUTHORITY, + caveats: [ + { + enforcer: NativeTokenStreamingEnforcer, + terms: createNativeTokenStreamingTerms({ + initialAmount: hexToBigInt('0x6f05b59d3b20000'), + maxAmount: hexToBigInt('0x22b1c8c1227a0000'), + amountPerSecond: hexToBigInt('0x6f05b59d3b20000'), + startTime: 1747699200, + }), + args: '0x', + }, + ], + salt: 0n, + signature: '0x', + }; + const context = encodeDelegations([delegation]); + const entry: PermissionInfoWithMetadata = { + permissionResponse: { + chainId: numberToHex(999999), + from: delegation.delegator, + permission: { + type: 'native-token-stream', + isAdjustmentAllowed: true, + data: { + maxAmount: '0x22b1c8c1227a0000', + initialAmount: '0x6f05b59d3b20000', + amountPerSecond: '0x6f05b59d3b20000', + startTime: 1747699200, + justification: 'j', + }, + }, + context, + delegationManager: sepoliaContracts.DelegationManager, + }, + siteOrigin: 'https://example.org', + status: 'Expired', + }; + + const getProviderForChainId = jest.fn().mockResolvedValue({ + request: jest.fn(async (req) => { + if (req.method === 'eth_call') { + return '0x0000000000000000000000000000000000000000000000000000000000000000'; + } + throw new Error(`Unexpected RPC: ${req.method}`); + }), + }); + + const result = await resolveGrantedPermissionOnChainStatus(entry, { + getProviderForChainId, + }); + + expect(result.status).toBe('Expired'); + }); + + it('sets Active when latest block is strictly before timestamp caveat expiry', async () => { + const expirySeconds = 2_500_000_000; + const blockSeconds = expirySeconds - 10_000; + const terms = createTimestampTerms({ + afterThreshold: 0, + beforeThreshold: expirySeconds, + }); + const delegation: Delegation = { + delegate: '0x4f71DA06987BfeDE90aF0b33E1e3e4ffDCEE7a63', + delegator: '0xB68c70159E9892DdF5659ec42ff9BD2bbC23e778', + authority: ROOT_AUTHORITY, + caveats: [ + { + enforcer: NativeTokenStreamingEnforcer, + terms: createNativeTokenStreamingTerms({ + initialAmount: hexToBigInt('0x6f05b59d3b20000'), + maxAmount: hexToBigInt('0x22b1c8c1227a0000'), + amountPerSecond: hexToBigInt('0x6f05b59d3b20000'), + startTime: 1747699200, + }), + args: '0x', + }, + { + enforcer: TimestampEnforcer, + terms, + args: '0x', + }, + ], + salt: 0n, + signature: '0x', + }; + const context = encodeDelegations([delegation]); + const entry: PermissionInfoWithMetadata = { + permissionResponse: { + chainId: numberToHex(CHAIN_ID.sepolia), + from: delegation.delegator, + permission: { + type: 'native-token-stream', + isAdjustmentAllowed: true, + data: { + maxAmount: '0x22b1c8c1227a0000', + initialAmount: '0x6f05b59d3b20000', + amountPerSecond: '0x6f05b59d3b20000', + startTime: 1747699200, + justification: 'j', + }, + }, + context, + delegationManager: sepoliaContracts.DelegationManager, + }, + siteOrigin: 'https://example.org', + status: 'Expired', + }; + + const getProviderForChainId = jest.fn().mockResolvedValue({ + request: jest.fn(async (req) => { + if (req.method === 'eth_call') { + return '0x0000000000000000000000000000000000000000000000000000000000000000'; + } + if (req.method === 'eth_getBlockByNumber') { + return { timestamp: numberToHex(blockSeconds) }; + } + throw new Error(`Unexpected RPC: ${req.method}`); + }), + }); + + const result = await resolveGrantedPermissionOnChainStatus(entry, { + getProviderForChainId, + }); + + expect(result.status).toBe('Active'); + }); + }); + + describe('updateGrantedPermissionsStatus', () => { + it('resolves each permission entry', async () => { + const getProviderForChainId = jest.fn(); + const entry: PermissionInfoWithMetadata = { + permissionResponse: { + chainId: numberToHex(CHAIN_ID.sepolia), + from: '0xB68c70159E9892DdF5659ec42ff9BD2bbC23e778', + permission: { + type: 'native-token-stream', + isAdjustmentAllowed: true, + data: { + maxAmount: '0x1', + initialAmount: '0x1', + amountPerSecond: '0x1', + startTime: 1, + justification: 'j', + }, + }, + context: '0x00000000', + delegationManager: sepoliaContracts.DelegationManager, + }, + siteOrigin: 'https://a.example', + status: 'Active', + revocationMetadata: { recordedAt: 1 }, + }; + + const results = await updateGrantedPermissionsStatus( + [entry, { ...entry, siteOrigin: 'https://b.example' }], + { getProviderForChainId }, + ); + + expect(results).toHaveLength(2); + expect(results[0].status).toBe('Revoked'); + expect(results[1].status).toBe('Revoked'); + expect(getProviderForChainId).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/packages/gator-permissions-controller/src/permissionOnChainStatus.ts b/packages/gator-permissions-controller/src/permissionOnChainStatus.ts new file mode 100644 index 00000000000..6d487594cfe --- /dev/null +++ b/packages/gator-permissions-controller/src/permissionOnChainStatus.ts @@ -0,0 +1,200 @@ +import type { EnforcerAddressesByName } from '@metamask/7715-permission-types'; +import { encodeSingle, decodeSingle } from '@metamask/abi-utils'; +import { decodeDelegations, hashDelegation } from '@metamask/delegation-core'; +import type { Delegation } from '@metamask/delegation-core'; +import { bytesToHex, getChecksumAddress, hexToNumber } from '@metamask/utils'; +import type { Hex } from '@metamask/utils'; + +import { + delegationContractsByChainId, + toEnforcerAddressesByName, +} from './decodePermission/enforcerAddresses.js'; +import { extractExpiryFromCaveatTerms } from './decodePermission/utils.js'; +import { controllerLog } from './logger.js'; +import type { + PermissionInfoWithMetadata, + GatorPermissionStatus, +} from './types.js'; + +/** Function selector for `DelegationManager.disabledDelegations(bytes32)`. */ +const DISABLED_DELEGATIONS_SELECTOR = '0x2d40d052'; + +/** + * Minimal EIP-1193 provider used for permission status RPCs. + */ +export type PermissionStatusEip1193Provider = { + request(args: { method: string; params?: unknown[] }): Promise; +}; + +/** + * Resolves an RPC provider for the given EIP-155 `chainId` (hex). + */ +export type GetProviderForChainId = ( + chainId: Hex, +) => Promise; + +export type PermissionOnChainStatusOptions = { + getProviderForChainId: GetProviderForChainId; +}; + +/** + * ABI-encodes a call to `DelegationManager.disabledDelegations(bytes32)`. + * + * @param delegationHash - Delegation struct hash (bytes32). + * @returns Calldata hex string (selector + encoded argument). + */ +export function encodeDisabledDelegationsCalldata(delegationHash: Hex): Hex { + const encodedArgs = bytesToHex(encodeSingle('bytes32', delegationHash)); + return `${DISABLED_DELEGATIONS_SELECTOR}${encodedArgs.slice(2)}`; +} + +/** + * Reads `disabledDelegations(delegationHash)` from the delegation manager. + * + * @param args - Arguments. + * @param args.provider - JSON-RPC provider for the permission's chain. + * @param args.delegationManager - DelegationManager contract address. + * @param args.delegationHash - Hash of the leaf delegation. + * @returns Whether the delegation is disabled on-chain. + */ +export async function readDelegationDisabledOnChain({ + provider, + delegationManager, + delegationHash, +}: { + provider: PermissionStatusEip1193Provider; + delegationManager: Hex; + delegationHash: Hex; +}): Promise { + const data = encodeDisabledDelegationsCalldata(delegationHash); + const raw = (await provider.request({ + method: 'eth_call', + params: [{ to: delegationManager, data }, 'latest'], + })) as Hex; + return decodeSingle('bool', raw); +} + +/** + * Returns the latest block's timestamp in seconds. + * + * @param provider - JSON-RPC provider for the chain. + * @returns Unix timestamp in seconds. + */ +export async function readLatestBlockTimestampSeconds( + provider: PermissionStatusEip1193Provider, +): Promise { + const block = (await provider.request({ + method: 'eth_getBlockByNumber', + params: ['latest', false], + })) as { timestamp?: Hex }; + if (!block?.timestamp) { + throw new Error('Latest block missing timestamp'); + } + return hexToNumber(block.timestamp); +} + +/** + * Reads TimestampEnforcer expiry (unix seconds) from the leaf delegation's caveats. + * + * @param leaf - Leaf delegation (index 0 when decoded from permission context). + * @param contracts - enforcer addresses for the chain. + * @returns Expiry timestamp in seconds, or `null` if no valid timestamp caveat. + */ +export function getExpiryFromDelegation( + leaf: Delegation, + contracts: EnforcerAddressesByName, +): number | null { + const targetEnforcer = getChecksumAddress(contracts.timestampEnforcer); + const timestampCaveat = leaf.caveats.find( + (caveat) => getChecksumAddress(caveat.enforcer) === targetEnforcer, + ); + if (!timestampCaveat?.terms) { + return null; + } + try { + return extractExpiryFromCaveatTerms(timestampCaveat.terms); + } catch { + return null; + } +} + +/** + * Recomputes {@link PermissionStatus} for one granted permission using chain state. + * + * @param entry - Granted permission row (including merged `status` from the prior sync). + * @param options - `getProviderForChainId` and deployment map for the framework version. + * @returns The same entry with an updated `status`. + */ +export async function resolveGrantedPermissionOnChainStatus( + entry: PermissionInfoWithMetadata, + options: PermissionOnChainStatusOptions, +): Promise { + if (entry.revocationMetadata) { + return { ...entry, status: 'Revoked' }; + } + + const originalStatus: GatorPermissionStatus = entry.status ?? 'Active'; + + try { + const delegations = decodeDelegations(entry.permissionResponse.context); + + if (delegations.length !== 1) { + throw new Error( + 'Unexpected delegations length in decoded permission context', + ); + } + const delegation = delegations[0]; + const delegationHash = hashDelegation(delegation); + + const provider = await options.getProviderForChainId( + entry.permissionResponse.chainId, + ); + + const isDisabled = await readDelegationDisabledOnChain({ + provider, + delegationManager: entry.permissionResponse.delegationManager, + delegationHash, + }); + + if (isDisabled) { + return { ...entry, status: 'Revoked' }; + } + + const chainId = hexToNumber(entry.permissionResponse.chainId); + const deploymentContracts = delegationContractsByChainId[chainId]; + if (!deploymentContracts) { + return { ...entry, status: originalStatus }; + } + const contracts = toEnforcerAddressesByName(deploymentContracts); + const expiry = getExpiryFromDelegation(delegation, contracts); + if (expiry === null) { + return { ...entry, status: 'Active' }; + } + const blockTimestamp = await readLatestBlockTimestampSeconds(provider); + if (blockTimestamp >= expiry) { + return { ...entry, status: 'Expired' }; + } + return { ...entry, status: 'Active' }; + } catch (error) { + controllerLog('Failed to resolve permission status', error); + return { ...entry, status: originalStatus }; + } +} + +/** + * Recomputes status for all granted permissions in parallel. + * + * @param grantedPermissions - Rows returned from the permissions provider snap. + * @param options - Provider factory and deployment map. + * @returns Same rows with updated `status` fields. + */ +export async function updateGrantedPermissionsStatus( + grantedPermissions: PermissionInfoWithMetadata[], + options: PermissionOnChainStatusOptions, +): Promise { + return Promise.all( + grantedPermissions.map((row) => + resolveGrantedPermissionOnChainStatus(row, options), + ), + ); +} diff --git a/packages/gator-permissions-controller/src/redeemerRule.ts b/packages/gator-permissions-controller/src/redeemerRule.ts new file mode 100644 index 00000000000..43ef16b6f89 --- /dev/null +++ b/packages/gator-permissions-controller/src/redeemerRule.ts @@ -0,0 +1,12 @@ +import type { Hex } from '@metamask/utils'; + +/** + * Execution permission rule restricting which addresses may redeem the delegation + * (on-chain RedeemerEnforcer caveat). + */ +export type RedeemerRule = { + type: 'redeemer'; + data: { + addresses: Hex[]; + }; +}; diff --git a/packages/gator-permissions-controller/src/types.ts b/packages/gator-permissions-controller/src/types.ts new file mode 100644 index 00000000000..4327656aa5b --- /dev/null +++ b/packages/gator-permissions-controller/src/types.ts @@ -0,0 +1,206 @@ +import type { PermissionTypes, Rule } from '@metamask/7715-permission-types'; +import type { Delegation } from '@metamask/delegation-core'; +import type { Hex } from '@metamask/utils'; + +/** + * Enum for the error codes of the gator permissions controller. + */ +export enum GatorPermissionsControllerErrorCode { + GatorPermissionsFetchError = 'gator-permissions-fetch-error', + GatorPermissionsProviderError = 'gator-permissions-provider-error', + PermissionDecodingError = 'permission-decoding-error', + OriginNotAllowedError = 'origin-not-allowed-error', +} + +/** + * Enum for the RPC methods of the gator permissions provider snap. + */ +export enum GatorPermissionsSnapRpcMethod { + /** + * This method is used by the metamask to request a permissions provider to get granted permissions for all sites. + */ + PermissionProviderGetGrantedPermissions = 'permissionsProvider_getGrantedPermissions', + /** + * This method is used by the metamask to submit a revocation to the permissions provider. + */ + PermissionProviderSubmitRevocation = 'permissionsProvider_submitRevocation', +} + +/** + * Represents an ERC-7715 permission request. + * + * @template TPermission - The type of the permission provided. + */ +export type PermissionRequest = { + /** + * hex-encoding of uint256 defined the chain with EIP-155 + */ + chainId: Hex; + + /** + * + * The account being targeted for this permission request. + * It is optional to let the user choose which account to grant permission from. + */ + from?: Hex; + + /** + * A field that identifies the DApp session account associated with the permission + */ + to: Hex; + + /** + * Defines the allowed behavior the `to` account can do on behalf of the `from` account. + */ + permission: TPermission; + + rules?: Rule[] | null; +}; + +/** + * Represents an ERC-7715 permission response. + * + * @template TPermission - The type of the permission provided. + */ +export type PermissionResponse = + PermissionRequest & { + /** + * Is a catch-all to identify a permission for revoking permissions or submitting + * Defined in ERC-7710. + */ + context: Hex; + + /** + * The dependencyInfo field is required and contains information needed to deploy accounts. + * Each entry specifies a factory contract and its associated deployment data. + * If no account deployment is needed when redeeming the permission, this array must be empty. + * When non-empty, DApps MUST deploy the accounts by calling the factory contract with factoryData as the calldata. + * Defined in ERC-4337. + */ + dependencies: { + factory: Hex; + factoryData: Hex; + }[]; + + /** + * Is required as defined in ERC-7710. + */ + delegationManager: Hex; + }; + +/** + * Represents a gator ERC-7715 permission entry retrieved from gator permissions snap. + * + * @template TPermission - The type of the permission provided. + */ +export type StoredGatorPermission< + TPermission extends PermissionTypes = PermissionTypes, +> = { + permissionResponse: PermissionResponse; + siteOrigin: string; + revocationMetadata?: RevocationMetadata; +}; + +/** + * Permission response with internal fields (dependencies, to) removed. + * Used when exposing permission data to the client/UI. + * + * @template TPermission - The type of the permission provided. + */ +export type PermissionInfo = Omit< + PermissionResponse, + 'dependencies' | 'to' +>; + +/** + * Lifecycle status of a granted permission for UI and sync. + */ +export type GatorPermissionStatus = 'Active' | 'Revoked' | 'Expired'; + +/** + * Granted permission with metadata (siteOrigin, optional revocationMetadata). + * + * @template TPermission - The type of the permission provided. + */ +export type PermissionInfoWithMetadata< + TPermission extends PermissionTypes = PermissionTypes, +> = { + permissionResponse: PermissionInfo; + siteOrigin: string; + /** + * Whether the permission is active, revoked (off-chain and/or on-chain), or expired by time rule. + */ + status: GatorPermissionStatus; + revocationMetadata?: RevocationMetadata; +}; + +/** + * Delegation fields required to decode a permission (caveats, delegator, delegate, authority). + */ +export type DelegationDetails = Pick< + Delegation, + 'caveats' | 'delegator' | 'delegate' | 'authority' +>; + +/** + * Metadata for a confirmed revocation (e.g. when and how it was recorded). + */ +export type RevocationMetadata = { + /** Timestamp when the revocation was recorded in storage. */ + recordedAt: number; + /** Hash of the revocation transaction, if we submitted it. */ + txHash?: Hex | undefined; +}; + +/** + * Parameters for the permissions provider Snap's submitRevocation RPC. + */ +export type RevocationParams = { + /** + * The permission context as a hex string that identifies the permission to revoke. + */ + permissionContext: Hex; + + /** + * The hash of the transaction that was used to revoke the permission. Optional because we might not have submitted the transaction ourselves. + */ + txHash: Hex | undefined; +}; + +/** + * Parameters for adding a pending revocation (tracked until the revocation tx is confirmed). + */ +export type PendingRevocationParams = { + /** + * The transaction metadata ID to monitor. + */ + txId: string; + /** + * The permission context as a hex string that identifies the permission to revoke. + */ + permissionContext: Hex; +}; + +/** + * Permission type identifier: the `type` field of standard ERC-7715 permissions. + */ +export type SupportedPermissionType = PermissionTypes['type']; + +/** + * Narrower typing for the delegator contract address mapping exported from @metamask/delegation-deployments. + */ +export type DelegationDeploymentsEnforcerAddressesByName = Record< + | 'ERC20StreamingEnforcer' + | 'ERC20PeriodTransferEnforcer' + | 'NativeTokenStreamingEnforcer' + | 'NativeTokenPeriodTransferEnforcer' + | 'ApprovalRevocationEnforcer' + | 'ExactCalldataEnforcer' + | 'ValueLteEnforcer' + | 'TimestampEnforcer' + | 'NonceEnforcer' + | 'AllowedCalldataEnforcer' + | 'AllowedTargetsEnforcer' + | 'RedeemerEnforcer', + Hex +>; diff --git a/packages/gator-permissions-controller/src/utils.test.ts b/packages/gator-permissions-controller/src/utils.test.ts new file mode 100644 index 00000000000..de5d1c2d45b --- /dev/null +++ b/packages/gator-permissions-controller/src/utils.test.ts @@ -0,0 +1,122 @@ +import type { SnapId } from '@metamask/snaps-sdk'; +import { HandlerType } from '@metamask/snaps-utils'; + +import { GatorPermissionsProviderError } from './errors.js'; +import type { GatorPermissionsControllerMessenger } from './GatorPermissionsController.js'; +import { GatorPermissionsSnapRpcMethod } from './types.js'; +import { executeSnapRpc } from './utils.js'; + +describe('executeSnapRpc', () => { + const mockSnapId = 'npm:@metamask/test-snap' as SnapId; + + function createMockMessenger(): { call: jest.Mock } { + return { call: jest.fn() }; + } + + 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('includes params in request when provided', async () => { + const params = { isRevoked: false }; + const messenger = createMockMessenger(); + messenger.call.mockResolvedValue(null); + + await executeSnapRpc({ + messenger: getMessenger(messenger), + snapId: mockSnapId, + method: + GatorPermissionsSnapRpcMethod.PermissionProviderGetGrantedPermissions, + params, + }); + + expect(messenger.call).toHaveBeenCalledWith( + 'SnapController:handleRequest', + { + snapId: mockSnapId, + origin: 'metamask', + handler: HandlerType.OnRpcRequest, + request: { + jsonrpc: '2.0', + method: + GatorPermissionsSnapRpcMethod.PermissionProviderGetGrantedPermissions, + params, + }, + }, + ); + }); + + 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 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); + + 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 new file mode 100644 index 00000000000..27336d82e51 --- /dev/null +++ b/packages/gator-permissions-controller/src/utils.ts @@ -0,0 +1,51 @@ +import type { SnapId } from '@metamask/snaps-sdk'; +import { HandlerType } from '@metamask/snaps-utils'; +import type { Json } from '@metamask/utils'; + +import { GatorPermissionsProviderError } from './errors.js'; +import { GatorPermissionsControllerMessenger } from './GatorPermissionsController.js'; +import { utilsLog } from './logger.js'; +import type { GatorPermissionsSnapRpcMethod } from './types.js'; + +/** + * Executes an RPC request against a Snap and returns the typed response. + * + * @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 async function executeSnapRpc({ + messenger, + snapId, + method, + params, +}: { + messenger: GatorPermissionsControllerMessenger; + snapId: SnapId; + method: GatorPermissionsSnapRpcMethod | string; + params?: Json; +}): Promise { + try { + const response = await messenger.call('SnapController:handleRequest', { + snapId, + origin: 'metamask', + handler: HandlerType.OnRpcRequest, + request: { + jsonrpc: '2.0', + method, + ...(params !== undefined && { params }), + }, + }); + return response as TReturn; + } catch (error) { + utilsLog('Snap RPC request failed', { method, error }); + throw new GatorPermissionsProviderError({ + method: method as GatorPermissionsSnapRpcMethod, + cause: error as Error, + }); + } +} diff --git a/packages/gator-permissions-controller/tests/mock.test.ts b/packages/gator-permissions-controller/tests/mock.test.ts new file mode 100644 index 00000000000..fe5c446a683 --- /dev/null +++ b/packages/gator-permissions-controller/tests/mock.test.ts @@ -0,0 +1,234 @@ +import { mockGatorPermissionsStorageEntriesFactory } from './mocks.js'; +import type { MockGatorPermissionsStorageEntriesConfig } from './mocks.js'; + +describe('mockGatorPermissionsStorageEntriesFactory', () => { + it('should create mock storage entries for all permission types', () => { + const config: MockGatorPermissionsStorageEntriesConfig = { + '0x1': { + nativeTokenStream: 2, + nativeTokenPeriodic: 1, + erc20TokenStream: 3, + erc20TokenPeriodic: 1, + }, + '0x5': { + nativeTokenStream: 1, + nativeTokenPeriodic: 2, + erc20TokenStream: 1, + erc20TokenPeriodic: 2, + }, + }; + + const result = mockGatorPermissionsStorageEntriesFactory(config); + + expect(result).toHaveLength(13); + + // Check that all entries have the correct chainId + const chainIds = result.map((entry) => entry.permissionResponse.chainId); + expect(chainIds).toContain('0x1'); + expect(chainIds).toContain('0x5'); + }); + + it('should create entries with correct permission types', () => { + const config: MockGatorPermissionsStorageEntriesConfig = { + '0x1': { + nativeTokenStream: 1, + nativeTokenPeriodic: 1, + erc20TokenStream: 1, + erc20TokenPeriodic: 1, + }, + }; + + const result = mockGatorPermissionsStorageEntriesFactory(config); + + expect(result).toHaveLength(4); + + // Check native-token-stream permission + const nativeTokenStreamEntry = result.find( + (entry) => + entry.permissionResponse.permission.type === 'native-token-stream', + ); + expect(nativeTokenStreamEntry).toBeDefined(); + expect( + nativeTokenStreamEntry?.permissionResponse.permission.data, + ).toMatchObject({ + maxAmount: '0x22b1c8c1227a0000', + initialAmount: '0x6f05b59d3b20000', + amountPerSecond: '0x6f05b59d3b20000', + startTime: 1747699200, + justification: + 'This is a very important request for streaming allowance for some very important thing', + }); + + // Check native-token-periodic permission + const nativeTokenPeriodicEntry = result.find( + (entry) => + entry.permissionResponse.permission.type === 'native-token-periodic', + ); + expect(nativeTokenPeriodicEntry).toBeDefined(); + expect( + nativeTokenPeriodicEntry?.permissionResponse.permission.data, + ).toMatchObject({ + periodAmount: '0x22b1c8c1227a0000', + periodDuration: 1747699200, + startTime: 1747699200, + justification: + 'This is a very important request for streaming allowance for some very important thing', + }); + + // Check erc20-token-stream permission + const erc20TokenStreamEntry = result.find( + (entry) => + entry.permissionResponse.permission.type === 'erc20-token-stream', + ); + expect(erc20TokenStreamEntry).toBeDefined(); + expect( + erc20TokenStreamEntry?.permissionResponse.permission.data, + ).toMatchObject({ + initialAmount: '0x22b1c8c1227a0000', + maxAmount: '0x6f05b59d3b20000', + amountPerSecond: '0x6f05b59d3b20000', + startTime: 1747699200, + tokenAddress: '0xB68c70159E9892DdF5659ec42ff9BD2bbC23e778', + justification: + 'This is a very important request for streaming allowance for some very important thing', + }); + + // Check erc20-token-periodic permission + const erc20TokenPeriodicEntry = result.find( + (entry) => + entry.permissionResponse.permission.type === 'erc20-token-periodic', + ); + expect(erc20TokenPeriodicEntry).toBeDefined(); + expect( + erc20TokenPeriodicEntry?.permissionResponse.permission.data, + ).toMatchObject({ + periodAmount: '0x22b1c8c1227a0000', + periodDuration: 1747699200, + startTime: 1747699200, + tokenAddress: '0xB68c70159E9892DdF5659ec42ff9BD2bbC23e778', + justification: + 'This is a very important request for streaming allowance for some very important thing', + }); + }); + + it('should handle empty counts for all permission types', () => { + const config: MockGatorPermissionsStorageEntriesConfig = { + '0x1': { + nativeTokenStream: 0, + nativeTokenPeriodic: 0, + erc20TokenStream: 0, + erc20TokenPeriodic: 0, + }, + }; + + const result = mockGatorPermissionsStorageEntriesFactory(config); + + expect(result).toHaveLength(0); + }); + + it('should handle multiple chain IDs', () => { + const config: MockGatorPermissionsStorageEntriesConfig = { + '0x1': { + nativeTokenStream: 1, + nativeTokenPeriodic: 0, + erc20TokenStream: 0, + erc20TokenPeriodic: 0, + }, + '0x5': { + nativeTokenStream: 0, + nativeTokenPeriodic: 1, + erc20TokenStream: 0, + erc20TokenPeriodic: 0, + }, + '0xa': { + nativeTokenStream: 0, + nativeTokenPeriodic: 0, + erc20TokenStream: 1, + erc20TokenPeriodic: 0, + }, + }; + + const result = mockGatorPermissionsStorageEntriesFactory(config); + + expect(result).toHaveLength(3); + + // Check that each chain ID is represented + const chainIds = result.map((entry) => entry.permissionResponse.chainId); + expect(chainIds).toContain('0x1'); + expect(chainIds).toContain('0x5'); + expect(chainIds).toContain('0xa'); + + // Check that each entry has the correct permission type for its chain + const chain0x1Entry = result.find( + (entry) => entry.permissionResponse.chainId === '0x1', + ); + expect(chain0x1Entry?.permissionResponse.permission.type).toBe( + 'native-token-stream', + ); + + const chain0x5Entry = result.find( + (entry) => entry.permissionResponse.chainId === '0x5', + ); + expect(chain0x5Entry?.permissionResponse.permission.type).toBe( + 'native-token-periodic', + ); + + const chain0xaEntry = result.find( + (entry) => entry.permissionResponse.chainId === '0xa', + ); + expect(chain0xaEntry?.permissionResponse.permission.type).toBe( + 'erc20-token-stream', + ); + }); + + it('should handle complex configuration with multiple chain IDs and permission types', () => { + const config: MockGatorPermissionsStorageEntriesConfig = { + '0x1': { + nativeTokenStream: 2, + nativeTokenPeriodic: 1, + erc20TokenStream: 1, + erc20TokenPeriodic: 2, + }, + '0x5': { + nativeTokenStream: 1, + nativeTokenPeriodic: 3, + erc20TokenStream: 2, + erc20TokenPeriodic: 1, + }, + }; + + const result = mockGatorPermissionsStorageEntriesFactory(config); + + // 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(6); + expect(chain0x5Count).toBe(7); + + // Verify permission types are distributed correctly + const permissionTypes = result.map( + (entry) => entry.permissionResponse.permission.type, + ); + const nativeTokenStreamCount = permissionTypes.filter( + (type) => type === 'native-token-stream', + ).length; + const nativeTokenPeriodicCount = permissionTypes.filter( + (type) => type === 'native-token-periodic', + ).length; + const erc20TokenStreamCount = permissionTypes.filter( + (type) => type === 'erc20-token-stream', + ).length; + const erc20TokenPeriodicCount = permissionTypes.filter( + (type) => type === 'erc20-token-periodic', + ).length; + + expect(nativeTokenStreamCount).toBe(3); + expect(nativeTokenPeriodicCount).toBe(4); + expect(erc20TokenStreamCount).toBe(3); + expect(erc20TokenPeriodicCount).toBe(3); + }); +}); diff --git a/packages/gator-permissions-controller/tests/mocks.ts b/packages/gator-permissions-controller/tests/mocks.ts new file mode 100644 index 00000000000..77dbbbda269 --- /dev/null +++ b/packages/gator-permissions-controller/tests/mocks.ts @@ -0,0 +1,236 @@ +import type { + Erc20TokenPeriodicPermission, + Erc20TokenStreamPermission, + NativeTokenPeriodicPermission, + NativeTokenStreamPermission, +} from '@metamask/7715-permission-types'; +import type { Hex } from '@metamask/utils'; + +import type { + DelegationDeploymentsEnforcerAddressesByName, + StoredGatorPermission, +} from '../src/types.js'; + +/** + * Builds a mock delegation-deployments enforcer address map for unit tests. + * + * @returns Mock enforcer deployment addresses keyed by contract name. + */ +export const buildMockDelegationEnforcerContracts = + (): DelegationDeploymentsEnforcerAddressesByName => ({ + ERC20PeriodTransferEnforcer: '0x1111111111111111111111111111111111111111', + ERC20StreamingEnforcer: '0x2222222222222222222222222222222222222222', + ApprovalRevocationEnforcer: '0x1212121212121212121212121212121212121212', + ExactCalldataEnforcer: '0x3333333333333333333333333333333333333333', + NativeTokenPeriodTransferEnforcer: + '0x4444444444444444444444444444444444444444', + NativeTokenStreamingEnforcer: '0x5555555555555555555555555555555555555555', + TimestampEnforcer: '0x6666666666666666666666666666666666666666', + ValueLteEnforcer: '0x7777777777777777777777777777777777777777', + NonceEnforcer: '0x8888888888888888888888888888888888888888', + AllowedCalldataEnforcer: '0x9999999999999999999999999999999999999999', + AllowedTargetsEnforcer: '0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb', + RedeemerEnforcer: '0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', + }); + +/** + * 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 => ({ + permissionResponse: { + chainId, + from: '0xB68c70159E9892DdF5659ec42ff9BD2bbC23e778', + to: '0x4f71DA06987BfeDE90aF0b33E1e3e4ffDCEE7a63', + permission: { + type: 'native-token-stream', + isAdjustmentAllowed: true, + data: { + maxAmount: '0x22b1c8c1227a0000', + initialAmount: '0x6f05b59d3b20000', + amountPerSecond: '0x6f05b59d3b20000', + startTime: 1747699200, + justification: + 'This is a very important request for streaming allowance for some very important thing', + }, + }, + context: '0x00000000', + dependencies: [ + { + factory: '0x69Aa2f9fe1572F1B640E1bbc512f5c3a734fc77c', + factoryData: '0x0000000', + }, + ], + delegationManager: '0xdb9B1e94B5b69Df7e401DDbedE43491141047dB3', + }, + 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 => ({ + permissionResponse: { + chainId, + from: '0xB68c70159E9892DdF5659ec42ff9BD2bbC23e778', + to: '0x4f71DA06987BfeDE90aF0b33E1e3e4ffDCEE7a63', + permission: { + type: 'native-token-periodic', + isAdjustmentAllowed: true, + data: { + periodAmount: '0x22b1c8c1227a0000', + periodDuration: 1747699200, + startTime: 1747699200, + justification: + 'This is a very important request for streaming allowance for some very important thing', + }, + }, + context: '0x00000000', + dependencies: [ + { + factory: '0x69Aa2f9fe1572F1B640E1bbc512f5c3a734fc77c', + factoryData: '0x0000000', + }, + ], + delegationManager: '0xdb9B1e94B5b69Df7e401DDbedE43491141047dB3', + }, + 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 => ({ + permissionResponse: { + chainId, + from: '0xB68c70159E9892DdF5659ec42ff9BD2bbC23e778', + to: '0x4f71DA06987BfeDE90aF0b33E1e3e4ffDCEE7a63', + permission: { + type: 'erc20-token-stream', + isAdjustmentAllowed: true, + data: { + initialAmount: '0x22b1c8c1227a0000', + maxAmount: '0x6f05b59d3b20000', + amountPerSecond: '0x6f05b59d3b20000', + startTime: 1747699200, + tokenAddress: '0xB68c70159E9892DdF5659ec42ff9BD2bbC23e778', + justification: + 'This is a very important request for streaming allowance for some very important thing', + }, + }, + context: '0x00000000', + dependencies: [ + { + factory: '0x69Aa2f9fe1572F1B640E1bbc512f5c3a734fc77c', + factoryData: '0x0000000', + }, + ], + delegationManager: '0xdb9B1e94B5b69Df7e401DDbedE43491141047dB3', + }, + 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 => ({ + permissionResponse: { + chainId, + from: '0xB68c70159E9892DdF5659ec42ff9BD2bbC23e778', + to: '0x4f71DA06987BfeDE90aF0b33E1e3e4ffDCEE7a63', + permission: { + type: 'erc20-token-periodic', + isAdjustmentAllowed: true, + data: { + periodAmount: '0x22b1c8c1227a0000', + periodDuration: 1747699200, + startTime: 1747699200, + tokenAddress: '0xB68c70159E9892DdF5659ec42ff9BD2bbC23e778', + justification: + 'This is a very important request for streaming allowance for some very important thing', + }, + }, + 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; + }; +}; + +/** + * Creates mock stored gator permissions as returned by the gator permissions provider Snap. + * + * @param config - Per-chain counts for each permission type. + * @returns Array of {@link StoredGatorPermission} entries. + */ +export function mockGatorPermissionsStorageEntriesFactory( + config: MockGatorPermissionsStorageEntriesConfig, +): StoredGatorPermission[] { + const result: StoredGatorPermission[] = []; + + Object.entries(config).forEach(([chainId, counts]) => { + const createEntries = ( + count: number, + createEntry: () => StoredGatorPermission, + ): void => { + for (let i = 0; i < count; i++) { + const entry = createEntry(); + result.push(entry); + } + }; + + createEntries(counts.nativeTokenStream, () => + mockNativeTokenStreamStorageEntry(chainId as Hex), + ); + + createEntries(counts.nativeTokenPeriodic, () => + mockNativeTokenPeriodicStorageEntry(chainId as Hex), + ); + + createEntries(counts.erc20TokenStream, () => + mockErc20TokenStreamStorageEntry(chainId as Hex), + ); + + createEntries(counts.erc20TokenPeriodic, () => + mockErc20TokenPeriodicStorageEntry(chainId as Hex), + ); + }); + + return result; +} diff --git a/packages/gator-permissions-controller/tsconfig.build.json b/packages/gator-permissions-controller/tsconfig.build.json new file mode 100644 index 00000000000..13c25a0c6d0 --- /dev/null +++ b/packages/gator-permissions-controller/tsconfig.build.json @@ -0,0 +1,23 @@ +{ + "extends": "../../tsconfig.packages.build.json", + "compilerOptions": { + "baseUrl": "./", + "outDir": "./dist", + "rootDir": "./src" + }, + "references": [ + { + "path": "../base-controller/tsconfig.build.json" + }, + { + "path": "../messenger/tsconfig.build.json" + }, + { + "path": "../transaction-controller/tsconfig.build.json" + }, + { + "path": "../network-controller/tsconfig.build.json" + } + ], + "include": ["../../types", "./src"] +} diff --git a/packages/gator-permissions-controller/tsconfig.json b/packages/gator-permissions-controller/tsconfig.json new file mode 100644 index 00000000000..4d99c06dd95 --- /dev/null +++ b/packages/gator-permissions-controller/tsconfig.json @@ -0,0 +1,21 @@ +{ + "extends": "../../tsconfig.packages.json", + "compilerOptions": { + "baseUrl": "./" + }, + "references": [ + { + "path": "../base-controller" + }, + { + "path": "../messenger" + }, + { + "path": "../transaction-controller" + }, + { + "path": "../network-controller" + } + ], + "include": ["../../types", "./src", "./tests"] +} diff --git a/packages/gator-permissions-controller/typedoc.json b/packages/gator-permissions-controller/typedoc.json new file mode 100644 index 00000000000..c9da015dbf8 --- /dev/null +++ b/packages/gator-permissions-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/geolocation-controller/CHANGELOG.md b/packages/geolocation-controller/CHANGELOG.md new file mode 100644 index 00000000000..847aa091d48 --- /dev/null +++ b/packages/geolocation-controller/CHANGELOG.md @@ -0,0 +1,53 @@ +# 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] + +### Changed + +- **BREAKING:** Resolve the user's country, region, and timezone via the `v2` geolocation endpoint through new `GeolocationController:getGeolocationData` and `GeolocationApiService:fetchGeolocationData` actions that return a `GeolocationData` object, along with matching `GeolocationControllerState` fields ([#9691](https://github.com/MetaMask/core/pull/9691)) + - `GeolocationController` now delegates to `GeolocationApiService:fetchGeolocationData`; the location code returned by `getGeolocation`/`fetchGeolocation` preserves the legacy `v1` behavior of appending the region only for the US and Canada (e.g. `US-NY`, `CA-ON`) and returning the country alone elsewhere +- Point `GeolocationApiService` at API Platform's `geolocation-api` service instead of the legacy Ramps-owned `on-ramp` geolocation endpoint, which is slated for deprecation ([#9417](https://github.com/MetaMask/core/pull/9417)) + - UAT temporarily resolves to the production URL since API Platform has not yet provisioned a dedicated UAT deployment for this service +- Bump `@metamask/controller-utils` from `^12.0.0` to `^12.3.0` ([#8774](https://github.com/MetaMask/core/pull/8774), [#9058](https://github.com/MetaMask/core/pull/9058), [#9083](https://github.com/MetaMask/core/pull/9083), [#9218](https://github.com/MetaMask/core/pull/9218)) +- Bump `@metamask/messenger` from `^1.2.0` to `^2.0.0` ([#9392](https://github.com/MetaMask/core/pull/9392)) + +## [0.1.3] + +### Changed + +- Bump `@metamask/controller-utils` from `^11.19.0` to `^12.0.0` ([#8344](https://github.com/MetaMask/core/pull/8344), [#8755](https://github.com/MetaMask/core/pull/8755)) +- Bump `@metamask/messenger` from `^1.0.0` to `^1.2.0` ([#8364](https://github.com/MetaMask/core/pull/8364), [#8373](https://github.com/MetaMask/core/pull/8373), [#8632](https://github.com/MetaMask/core/pull/8632)) +- Bump `@metamask/base-controller` from `^9.0.1` to `^9.1.0` ([#8457](https://github.com/MetaMask/core/pull/8457)) + +## [0.1.2] + +### Changed + +- Bump `@metamask/base-controller` from `^9.0.0` to `^9.0.1` ([#8317](https://github.com/MetaMask/core/pull/8317)) +- Bump `@metamask/messenger` from `^0.3.0` to `^1.0.0` ([#8317](https://github.com/MetaMask/core/pull/8317)) + +## [0.1.1] + +### Fixed + +- Accept ISO 3166-2 subdivision codes (e.g. `US-NY`, `CA-ON`) from the geolocation API, not just 2-letter country codes ([#8137](https://github.com/MetaMask/core/pull/8137)) + +## [0.1.0] + +### Added + +- Initial release ([#8037](https://github.com/MetaMask/core/pull/8037)) + +[Unreleased]: https://github.com/MetaMask/core/compare/@metamask/geolocation-controller@1.0.0...HEAD +[1.0.0]: https://github.com/MetaMask/core/compare/@metamask/geolocation-controller@0.1.3...@metamask/geolocation-controller@1.0.0 +[0.1.3]: https://github.com/MetaMask/core/compare/@metamask/geolocation-controller@0.1.2...@metamask/geolocation-controller@0.1.3 +[0.1.2]: https://github.com/MetaMask/core/compare/@metamask/geolocation-controller@0.1.1...@metamask/geolocation-controller@0.1.2 +[0.1.1]: https://github.com/MetaMask/core/compare/@metamask/geolocation-controller@0.1.0...@metamask/geolocation-controller@0.1.1 +[0.1.0]: https://github.com/MetaMask/core/releases/tag/@metamask/geolocation-controller@0.1.0 diff --git a/packages/geolocation-controller/LICENSE b/packages/geolocation-controller/LICENSE new file mode 100644 index 00000000000..fe29e78e0fe --- /dev/null +++ b/packages/geolocation-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/geolocation-controller/README.md b/packages/geolocation-controller/README.md new file mode 100644 index 00000000000..9665504a7d6 --- /dev/null +++ b/packages/geolocation-controller/README.md @@ -0,0 +1,15 @@ +# `@metamask/geolocation-controller` + +Centralised geolocation controller with TTL caching and request deduplication. + +## Installation + +`yarn add @metamask/geolocation-controller` + +or + +`npm install @metamask/geolocation-controller` + +## Contributing + +This package is part of a monorepo. Instructions for contributing can be found in the [monorepo README](https://github.com/MetaMask/core#readme). diff --git a/packages/geolocation-controller/jest.config.js b/packages/geolocation-controller/jest.config.js new file mode 100644 index 00000000000..ca084133399 --- /dev/null +++ b/packages/geolocation-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/geolocation-controller/package.json b/packages/geolocation-controller/package.json new file mode 100644 index 00000000000..f20e31aa972 --- /dev/null +++ b/packages/geolocation-controller/package.json @@ -0,0 +1,77 @@ +{ + "name": "@metamask/geolocation-controller", + "version": "1.0.0", + "description": "Centralised geolocation controller with TTL caching and request deduplication", + "keywords": [ + "Ethereum", + "MetaMask" + ], + "homepage": "https://github.com/MetaMask/core/tree/main/packages/geolocation-controller#readme", + "bugs": { + "url": "https://github.com/MetaMask/core/issues" + }, + "license": "MIT", + "repository": { + "type": "git", + "url": "https://github.com/MetaMask/core.git" + }, + "files": [ + "dist/" + ], + "sideEffects": false, + "main": "./dist/index.cjs", + "types": "./dist/index.d.cts", + "exports": { + ".": { + "import": { + "types": "./dist/index.d.mts", + "default": "./dist/index.mjs" + }, + "require": { + "types": "./dist/index.d.cts", + "default": "./dist/index.cjs" + } + }, + "./package.json": "./package.json" + }, + "publishConfig": { + "access": "public", + "registry": "https://registry.npmjs.org/" + }, + "scripts": { + "build": "ts-bridge --project tsconfig.build.json --verbose --clean --no-references", + "build:all": "ts-bridge --project tsconfig.build.json --verbose --clean", + "build:docs": "typedoc", + "changelog:update": "../../scripts/update-changelog.sh @metamask/geolocation-controller", + "changelog:validate": "../../scripts/validate-changelog.sh @metamask/geolocation-controller", + "lint:tsconfigs": "tsx ../../scripts/lint-tsconfigs/lint-tsconfigs.mts", + "lint:tsconfigs:fix": "tsx ../../scripts/lint-tsconfigs/lint-tsconfigs.mts --fix", + "messenger-action-types:check": "tsx ../../packages/messenger-cli/src/cli.ts --formatter oxfmt --esm --check", + "messenger-action-types:generate": "tsx ../../packages/messenger-cli/src/cli.ts --formatter oxfmt --esm --generate", + "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.1.0", + "@metamask/controller-utils": "^12.3.0", + "@metamask/messenger": "^2.0.0" + }, + "devDependencies": { + "@metamask/auto-changelog": "^6.1.0", + "@ts-bridge/cli": "^0.6.4", + "@types/jest": "^30.0.0", + "deepmerge": "^4.2.2", + "jest": "^30.4.2", + "ts-jest": "^29.4.11", + "tsx": "^4.20.5", + "typedoc": "^0.25.13", + "typedoc-plugin-missing-exports": "^2.0.0", + "typescript": "~5.3.3" + }, + "engines": { + "node": "^18.18 || >=20" + } +} diff --git a/packages/geolocation-controller/src/GeolocationController-method-action-types.ts b/packages/geolocation-controller/src/GeolocationController-method-action-types.ts new file mode 100644 index 00000000000..a0d3446c611 --- /dev/null +++ b/packages/geolocation-controller/src/GeolocationController-method-action-types.ts @@ -0,0 +1,61 @@ +/** + * This file is auto generated. + * Do not edit manually. + */ + +import type { GeolocationController } from './GeolocationController.js'; + +/** + * Returns the geolocation code. Delegates to the + * {@link GeolocationApiService} for network fetching and caching, then + * updates controller state with the result. + * + * Best-effort: if the fetch fails, the last known location code (or + * {@link UNKNOWN_LOCATION}) is returned rather than throwing. + * + * @returns The ISO 3166-2 location code string. + */ +export type GeolocationControllerGetGeolocationAction = { + type: `GeolocationController:getGeolocation`; + handler: GeolocationController['getGeolocation']; +}; + +/** + * Returns the country, region, and timezone for the current client. + * Delegates to the {@link GeolocationApiService} for network fetching and + * caching, then updates controller state with the result. + * + * Unlike {@link getGeolocation}, this rejects when resolution fails instead + * of returning a stale value, so callers can distinguish a fresh result from + * a failed lookup (and, for example, omit location rather than enrich with a + * previous session's data). + * + * @returns The geolocation data, where each field is `null` when it could + * not be determined. + * @throws When the geolocation service fails to resolve. + */ +export type GeolocationControllerGetGeolocationDataAction = { + type: `GeolocationController:getGeolocationData`; + handler: GeolocationController['getGeolocationData']; +}; + +/** + * Forces a fresh geolocation fetch, bypassing the service's cache. + * + * Best-effort: if the fetch fails, the last known location code (or + * {@link UNKNOWN_LOCATION}) is returned rather than throwing. + * + * @returns The ISO 3166-2 location code string. + */ +export type GeolocationControllerRefreshGeolocationAction = { + type: `GeolocationController:refreshGeolocation`; + handler: GeolocationController['refreshGeolocation']; +}; + +/** + * Union of all GeolocationController action types. + */ +export type GeolocationControllerMethodActions = + | GeolocationControllerGetGeolocationAction + | GeolocationControllerGetGeolocationDataAction + | GeolocationControllerRefreshGeolocationAction; diff --git a/packages/geolocation-controller/src/GeolocationController.test.ts b/packages/geolocation-controller/src/GeolocationController.test.ts new file mode 100644 index 00000000000..385c70681c4 --- /dev/null +++ b/packages/geolocation-controller/src/GeolocationController.test.ts @@ -0,0 +1,610 @@ +import { deriveStateFromMetadata } from '@metamask/base-controller'; +import { Messenger, MOCK_ANY_NAMESPACE } from '@metamask/messenger'; +import type { + MockAnyNamespace, + MessengerActions, + MessengerEvents, +} from '@metamask/messenger'; + +import type { GeolocationData } from './geolocation-api-service/geolocation-api-service.js'; +import { + getUnknownGeolocationData, + UNKNOWN_LOCATION, +} from './geolocation-api-service/geolocation-api-service.js'; +import type { GeolocationControllerMessenger } from './GeolocationController.js'; +import { + GeolocationController, + getDefaultGeolocationControllerState, +} from './GeolocationController.js'; + +describe('GeolocationController', () => { + describe('constructor', () => { + it('initializes with default state', async () => { + await withController(({ controller }) => { + expect(controller.state).toStrictEqual( + getDefaultGeolocationControllerState(), + ); + }); + }); + + it('merges provided partial state with defaults', async () => { + await withController( + { options: { state: { location: 'GB' } } }, + ({ controller }) => { + expect(controller.state.location).toBe('GB'); + expect(controller.state.status).toBe('idle'); + }, + ); + }); + }); + + describe('metadata', () => { + it('includes expected state in debug snapshots', async () => { + await withController(({ controller }) => { + expect( + deriveStateFromMetadata( + controller.state, + controller.metadata, + 'includeInDebugSnapshot', + ), + ).toMatchInlineSnapshot(` + { + "country": null, + "error": null, + "lastFetchedAt": null, + "location": "UNKNOWN", + "region": null, + "status": "idle", + "timezone": null, + } + `); + }); + }); + + it('includes expected state in state logs', async () => { + await withController(({ controller }) => { + expect( + deriveStateFromMetadata( + controller.state, + controller.metadata, + 'includeInStateLogs', + ), + ).toMatchInlineSnapshot(` + { + "country": null, + "error": null, + "lastFetchedAt": null, + "location": "UNKNOWN", + "region": null, + "status": "idle", + "timezone": null, + } + `); + }); + }); + + it('persists no state', async () => { + await withController(({ controller }) => { + expect( + deriveStateFromMetadata( + controller.state, + controller.metadata, + 'persist', + ), + ).toMatchInlineSnapshot(`{}`); + }); + }); + + it('exposes expected state to UI', async () => { + await withController(({ controller }) => { + expect( + deriveStateFromMetadata( + controller.state, + controller.metadata, + 'usedInUi', + ), + ).toMatchInlineSnapshot(` + { + "country": null, + "location": "UNKNOWN", + "region": null, + "status": "idle", + "timezone": null, + } + `); + }); + }); + }); + + describe('getGeolocation', () => { + it('sets location, status to complete, and lastFetchedAt after fetch', async () => { + await withController( + { serviceResponse: { country: 'GB' } }, + async ({ controller }) => { + const now = Date.now(); + const result = await controller.getGeolocation(); + + expect(result).toBe('GB'); + expect(controller.state.location).toBe('GB'); + expect(controller.state.status).toBe('complete'); + expect(controller.state.lastFetchedAt).toBeGreaterThanOrEqual(now); + expect(controller.state.error).toBeNull(); + }, + ); + }); + + it('joins the country and region into the location code', async () => { + await withController( + { serviceResponse: { country: 'US', region: 'NY' } }, + async ({ controller }) => { + const result = await controller.getGeolocation(); + + expect(result).toBe('US-NY'); + expect(controller.state.location).toBe('US-NY'); + }, + ); + }); + + it('stores the country, region, and timezone in state', async () => { + await withController( + { + serviceResponse: { + country: 'US', + region: 'WA', + timezone: 'America/Los_Angeles', + }, + }, + async ({ controller }) => { + await controller.getGeolocation(); + + expect(controller.state.country).toBe('US'); + expect(controller.state.region).toBe('WA'); + expect(controller.state.timezone).toBe('America/Los_Angeles'); + }, + ); + }); + + it('transitions status from idle to loading to complete', async () => { + const states: string[] = []; + let resolveService!: (value: GeolocationData) => void; + + await withController( + { + serviceHandler: () => + new Promise((resolve) => { + resolveService = resolve; + }), + }, + async ({ controller, rootMessenger }) => { + rootMessenger.subscribe( + 'GeolocationController:stateChange', + (state) => { + states.push(state.status); + }, + ); + + const promise = controller.getGeolocation(); + expect(controller.state.status).toBe('loading'); + + resolveService(buildGeolocationData({ country: 'DE' })); + await promise; + + expect(states).toStrictEqual(['loading', 'complete']); + }, + ); + }); + + describe('when the service throws', () => { + it('sets status to error with the error message', async () => { + await withController( + { + serviceHandler: () => { + throw new Error('Network error'); + }, + }, + async ({ controller }) => { + await controller.getGeolocation(); + + expect(controller.state.status).toBe('error'); + expect(controller.state.error).toBe('Network error'); + }, + ); + }); + + it('preserves the last known location', async () => { + let callCount = 0; + + await withController( + { + serviceHandler: () => { + callCount += 1; + if (callCount === 1) { + return Promise.resolve(buildGeolocationData({ country: 'US' })); + } + throw new Error('Network error'); + }, + }, + async ({ controller }) => { + await controller.getGeolocation(); + expect(controller.state.location).toBe('US'); + + const result = await controller.getGeolocation(); + expect(result).toBe('US'); + expect(controller.state.location).toBe('US'); + expect(controller.state.status).toBe('error'); + }, + ); + }); + + it('returns UNKNOWN_LOCATION when no prior value exists', async () => { + await withController( + { + serviceHandler: () => { + throw new Error('Network error'); + }, + }, + async ({ controller }) => { + const result = await controller.getGeolocation(); + + expect(result).toBe(UNKNOWN_LOCATION); + expect(controller.state.location).toBe(UNKNOWN_LOCATION); + }, + ); + }); + + it('stores string representation of non-Error thrown values', async () => { + await withController( + { + serviceHandler: jest.fn().mockRejectedValue('string error'), + }, + async ({ controller }) => { + await controller.getGeolocation(); + + expect(controller.state.status).toBe('error'); + expect(controller.state.error).toBe('string error'); + }, + ); + }); + }); + }); + + describe('getGeolocationData', () => { + it('returns the country, region, and timezone', async () => { + await withController( + { + serviceResponse: { + country: 'FR', + region: '75', + timezone: 'Europe/Paris', + }, + }, + async ({ controller }) => { + const result = await controller.getGeolocationData(); + + expect(result).toStrictEqual({ + country: 'FR', + region: '75', + timezone: 'Europe/Paris', + }); + expect(controller.state.status).toBe('complete'); + }, + ); + }); + + it('rejects when the service throws instead of returning stale data', async () => { + let callCount = 0; + + await withController( + { + serviceHandler: () => { + callCount += 1; + if (callCount === 1) { + return Promise.resolve( + buildGeolocationData({ + country: 'US', + region: 'WA', + timezone: 'America/Los_Angeles', + }), + ); + } + throw new Error('Network error'); + }, + }, + async ({ controller }) => { + await controller.getGeolocationData(); + + await expect(controller.getGeolocationData()).rejects.toThrow( + 'Network error', + ); + expect(controller.state.status).toBe('error'); + expect(controller.state.error).toBe('Network error'); + }, + ); + }); + + it('rejects when the service throws and no prior value exists', async () => { + await withController( + { + serviceHandler: () => { + throw new Error('Network error'); + }, + }, + async ({ controller }) => { + await expect(controller.getGeolocationData()).rejects.toThrow( + 'Network error', + ); + }, + ); + }); + }); + + describe('refreshGeolocation', () => { + it('resets lastFetchedAt and calls service with bypassCache', async () => { + let callCount = 0; + const mockServiceHandler = jest.fn( + (_options?: { bypassCache?: boolean }) => { + callCount += 1; + return Promise.resolve( + buildGeolocationData({ country: callCount === 1 ? 'US' : 'GB' }), + ); + }, + ); + + await withController( + { serviceHandler: mockServiceHandler }, + async ({ controller }) => { + await controller.getGeolocation(); + expect(controller.state.location).toBe('US'); + expect(controller.state.lastFetchedAt).not.toBeNull(); + + const refreshPromise = controller.refreshGeolocation(); + expect(controller.state.lastFetchedAt).toBeNull(); + + const result = await refreshPromise; + expect(result).toBe('GB'); + expect(controller.state.location).toBe('GB'); + expect(mockServiceHandler).toHaveBeenLastCalledWith({ + bypassCache: true, + }); + }, + ); + }); + + it('sets status to error when the service throws', async () => { + let callCount = 0; + + await withController( + { + serviceHandler: () => { + callCount += 1; + if (callCount === 1) { + return Promise.resolve(buildGeolocationData({ country: 'US' })); + } + throw new Error('Refresh failed'); + }, + }, + async ({ controller }) => { + await controller.getGeolocation(); + expect(controller.state.location).toBe('US'); + + const result = await controller.refreshGeolocation(); + expect(result).toBe('US'); + expect(controller.state.status).toBe('error'); + expect(controller.state.error).toBe('Refresh failed'); + }, + ); + }); + + it('stores string representation of non-Error thrown values', async () => { + await withController( + { + serviceHandler: jest + .fn() + .mockResolvedValueOnce(buildGeolocationData({ country: 'US' })) + .mockRejectedValueOnce('string refresh error'), + }, + async ({ controller }) => { + await controller.getGeolocation(); + + await controller.refreshGeolocation(); + expect(controller.state.status).toBe('error'); + expect(controller.state.error).toBe('string refresh error'); + }, + ); + }); + }); + + describe('GeolocationController:getGeolocation', () => { + it('resolves with the fetched country code', async () => { + await withController( + { serviceResponse: { country: 'JP' } }, + async ({ rootMessenger }) => { + const result = await rootMessenger.call( + 'GeolocationController:getGeolocation', + ); + + expect(result).toBe('JP'); + }, + ); + }); + }); + + describe('GeolocationController:getGeolocationData', () => { + it('resolves with the fetched country, region, and timezone', async () => { + await withController( + { + serviceResponse: { + country: 'JP', + region: '13', + timezone: 'Asia/Tokyo', + }, + }, + async ({ rootMessenger }) => { + const result = await rootMessenger.call( + 'GeolocationController:getGeolocationData', + ); + + expect(result).toStrictEqual({ + country: 'JP', + region: '13', + timezone: 'Asia/Tokyo', + }); + }, + ); + }); + }); + + describe('GeolocationController:refreshGeolocation', () => { + it('resolves with the updated country code', async () => { + let callCount = 0; + + await withController( + { + serviceHandler: () => { + callCount += 1; + return Promise.resolve( + buildGeolocationData({ country: callCount === 1 ? 'US' : 'CA' }), + ); + }, + }, + async ({ rootMessenger }) => { + await rootMessenger.call('GeolocationController:getGeolocation'); + + const result = await rootMessenger.call( + 'GeolocationController:refreshGeolocation', + ); + + expect(result).toBe('CA'); + }, + ); + }); + }); +}); + +/** + * 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: GeolocationController; + rootMessenger: RootMessenger; + controllerMessenger: GeolocationControllerMessenger; +}) => Promise | ReturnValue; + +/** + * The options that `withController` takes. + */ +type WithControllerOptions = { + options?: Partial< + Omit[0], 'messenger'> + >; + serviceResponse?: Partial; + serviceHandler?: (options?: { + bypassCache?: boolean; + }) => Promise; +}; + +/** + * Constructs the messenger populated with all external actions and events + * required by the controller under test. + * + * @returns The root messenger. + */ +function getRootMessenger(): RootMessenger { + return new Messenger({ namespace: MOCK_ANY_NAMESPACE }); +} + +/** + * Constructs the messenger for the controller under test. + * + * @param rootMessenger - The root messenger, with all external actions and + * events required by the controller's messenger. + * @returns The controller-specific messenger. + */ +function getMessenger( + rootMessenger: RootMessenger, +): GeolocationControllerMessenger { + const messenger: GeolocationControllerMessenger = new Messenger({ + namespace: 'GeolocationController', + parent: rootMessenger, + }); + rootMessenger.delegate({ + actions: ['GeolocationApiService:fetchGeolocationData'], + events: [], + messenger, + }); + return messenger; +} + +/** + * Builds complete geolocation data from a partial fixture. + * + * @param data - The known geolocation fields. + * @returns The geolocation data with unknown fields set to null. + */ +function buildGeolocationData( + data: Partial = {}, +): GeolocationData { + return { ...getUnknownGeolocationData(), ...data }; +} + +/** + * 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 and optionally a + * `serviceResponse` fixture or a `serviceHandler` function to mock the + * `GeolocationApiService:fetchGeolocationData` action. The function is called + * with the instantiated 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 = {}, serviceResponse, serviceHandler } = {}, testFunction] = + args.length === 2 ? args : [{}, args[0]]; + + jest.useFakeTimers(); + + const rootMessenger = getRootMessenger(); + const controllerMessenger = getMessenger(rootMessenger); + + const handler: (options?: { + bypassCache?: boolean; + }) => Promise = + serviceHandler ?? + ((): Promise => + Promise.resolve(buildGeolocationData(serviceResponse))); + + rootMessenger.registerActionHandler( + 'GeolocationApiService:fetchGeolocationData', + handler, + ); + + const controller = new GeolocationController({ + messenger: controllerMessenger, + ...options, + }); + + try { + return await testFunction({ + controller, + rootMessenger, + controllerMessenger, + }); + } finally { + jest.useRealTimers(); + } +} diff --git a/packages/geolocation-controller/src/GeolocationController.ts b/packages/geolocation-controller/src/GeolocationController.ts new file mode 100644 index 00000000000..855090caeae --- /dev/null +++ b/packages/geolocation-controller/src/GeolocationController.ts @@ -0,0 +1,318 @@ +import type { + ControllerGetStateAction, + ControllerStateChangeEvent, + StateMetadata, +} from '@metamask/base-controller'; +import { BaseController } from '@metamask/base-controller'; +import type { Messenger } from '@metamask/messenger'; + +import type { GeolocationApiServiceFetchGeolocationDataAction } from './geolocation-api-service/geolocation-api-service-method-action-types.js'; +import type { GeolocationData } from './geolocation-api-service/geolocation-api-service.js'; +import { + getUnknownGeolocationData, + toLocationCode, + UNKNOWN_LOCATION, +} from './geolocation-api-service/geolocation-api-service.js'; +import type { GeolocationControllerMethodActions } from './GeolocationController-method-action-types.js'; +import type { GeolocationRequestStatus } from './types.js'; + +/** + * The name of the {@link GeolocationController}, 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 = 'GeolocationController'; + +/** + * State for the {@link GeolocationController}. + */ +export type GeolocationControllerState = { + /** ISO 3166-2 location code (e.g. "US", "US-NY", "CA-ON"), or "UNKNOWN" if not yet determined. */ + location: string; + /** ISO 3166-1 alpha-2 country code (e.g. "US"), or null if not yet determined. */ + country: string | null; + /** ISO 3166-2 subdivision code without the country prefix (e.g. "NY"), or null if not yet determined. */ + region: string | null; + /** IANA time zone name (e.g. "America/Los_Angeles"), or null if not yet determined. */ + timezone: string | null; + /** Current status of the geolocation fetch lifecycle. */ + status: GeolocationRequestStatus; + /** Epoch milliseconds of the last successful fetch, or null if never fetched. */ + lastFetchedAt: number | null; + /** Last error message, or null if no error has occurred. */ + error: string | null; +}; + +/** + * The metadata for each property in {@link GeolocationControllerState}. + */ +const geolocationControllerMetadata = { + location: { + persist: false, + includeInDebugSnapshot: true, + includeInStateLogs: true, + usedInUi: true, + }, + country: { + persist: false, + includeInDebugSnapshot: true, + includeInStateLogs: true, + usedInUi: true, + }, + region: { + persist: false, + includeInDebugSnapshot: true, + includeInStateLogs: true, + usedInUi: true, + }, + timezone: { + persist: false, + includeInDebugSnapshot: true, + includeInStateLogs: true, + usedInUi: true, + }, + status: { + persist: false, + includeInDebugSnapshot: true, + includeInStateLogs: true, + usedInUi: true, + }, + lastFetchedAt: { + persist: false, + includeInDebugSnapshot: true, + includeInStateLogs: true, + usedInUi: false, + }, + error: { + persist: false, + includeInDebugSnapshot: true, + includeInStateLogs: true, + usedInUi: false, + }, +} satisfies StateMetadata; + +/** + * Constructs the default {@link GeolocationController} 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 GeolocationController} state. + */ +export function getDefaultGeolocationControllerState(): GeolocationControllerState { + return { + location: UNKNOWN_LOCATION, + ...getUnknownGeolocationData(), + status: 'idle', + lastFetchedAt: null, + error: null, + }; +} + +const MESSENGER_EXPOSED_METHODS = [ + 'getGeolocation', + 'getGeolocationData', + 'refreshGeolocation', +] as const; + +/** + * Retrieves the state of the {@link GeolocationController}. + */ +export type GeolocationControllerGetStateAction = ControllerGetStateAction< + typeof controllerName, + GeolocationControllerState +>; + +/** + * Actions that {@link GeolocationControllerMessenger} exposes to other consumers. + */ +export type GeolocationControllerActions = + | GeolocationControllerGetStateAction + | GeolocationControllerMethodActions; + +/** + * Actions from other messengers that {@link GeolocationControllerMessenger} calls. + */ +type AllowedActions = GeolocationApiServiceFetchGeolocationDataAction; + +/** + * Published when the state of {@link GeolocationController} changes. + */ +export type GeolocationControllerStateChangeEvent = ControllerStateChangeEvent< + typeof controllerName, + GeolocationControllerState +>; + +/** + * Events that {@link GeolocationControllerMessenger} exposes to other consumers. + */ +export type GeolocationControllerEvents = GeolocationControllerStateChangeEvent; + +/** + * Events from other messengers that {@link GeolocationControllerMessenger} + * subscribes to. + */ +type AllowedEvents = never; + +/** + * The messenger restricted to actions and events accessed by + * {@link GeolocationController}. + */ +export type GeolocationControllerMessenger = Messenger< + typeof controllerName, + GeolocationControllerActions | AllowedActions, + GeolocationControllerEvents | AllowedEvents +>; + +/** + * Options for constructing the {@link GeolocationController}. + */ +export type GeolocationControllerOptions = { + /** The messenger for inter-controller communication. */ + messenger: GeolocationControllerMessenger; + /** Optional partial initial state. */ + state?: Partial; +}; + +/** + * GeolocationController manages UI-facing geolocation state by delegating + * the actual API interaction to {@link GeolocationApiService} via the + * messenger. + * + * The service (registered externally as + * `GeolocationApiService:fetchGeolocation`) handles HTTP requests, response + * validation, TTL caching, and promise deduplication. This controller focuses + * on state lifecycle (`idle` -> `loading` -> `complete` | `error`) and + * exposes `getGeolocation` / `refreshGeolocation` as messenger actions. + */ +export class GeolocationController extends BaseController< + typeof controllerName, + GeolocationControllerState, + GeolocationControllerMessenger +> { + /** + * Constructs a new {@link GeolocationController}. + * + * @param args - The arguments to this controller. + * @param args.messenger - The messenger suited for this controller. Must + * have a `GeolocationApiService:fetchGeolocation` action handler registered. + * @param args.state - Optional partial initial state. + */ + constructor({ messenger, state }: GeolocationControllerOptions) { + super({ + messenger, + metadata: geolocationControllerMetadata, + name: controllerName, + state: { ...getDefaultGeolocationControllerState(), ...state }, + }); + + this.messenger.registerMethodActionHandlers( + this, + MESSENGER_EXPOSED_METHODS, + ); + } + + /** + * Returns the geolocation code. Delegates to the + * {@link GeolocationApiService} for network fetching and caching, then + * updates controller state with the result. + * + * Best-effort: if the fetch fails, the last known location code (or + * {@link UNKNOWN_LOCATION}) is returned rather than throwing. + * + * @returns The ISO 3166-2 location code string. + */ + async getGeolocation(): Promise { + try { + await this.#fetchAndUpdate(); + } catch { + // Best-effort: fall back to the last known location code below. + } + return this.state.location; + } + + /** + * Returns the country, region, and timezone for the current client. + * Delegates to the {@link GeolocationApiService} for network fetching and + * caching, then updates controller state with the result. + * + * Unlike {@link getGeolocation}, this rejects when resolution fails instead + * of returning a stale value, so callers can distinguish a fresh result from + * a failed lookup (and, for example, omit location rather than enrich with a + * previous session's data). + * + * @returns The geolocation data, where each field is `null` when it could + * not be determined. + * @throws When the geolocation service fails to resolve. + */ + async getGeolocationData(): Promise { + return this.#fetchAndUpdate(); + } + + /** + * Forces a fresh geolocation fetch, bypassing the service's cache. + * + * Best-effort: if the fetch fails, the last known location code (or + * {@link UNKNOWN_LOCATION}) is returned rather than throwing. + * + * @returns The ISO 3166-2 location code string. + */ + async refreshGeolocation(): Promise { + this.update((draft) => { + draft.lastFetchedAt = null; + }); + try { + await this.#fetchAndUpdate({ bypassCache: true }); + } catch { + // Best-effort: fall back to the last known location code below. + } + return this.state.location; + } + + /** + * Calls the geolocation service and updates controller state with the + * result. + * + * @param options - Options forwarded to the service. + * @param options.bypassCache - When true, the service skips its TTL cache. + * @returns The resolved geolocation data. + * @throws Re-throws the service error after recording it in state, so + * callers can react to a failed lookup instead of receiving a stale value. + */ + async #fetchAndUpdate(options?: { + bypassCache?: boolean; + }): Promise { + this.update((draft) => { + draft.status = 'loading'; + draft.error = null; + }); + + try { + const geolocation = await this.messenger.call( + 'GeolocationApiService:fetchGeolocationData', + options, + ); + + this.update((draft) => { + draft.location = toLocationCode(geolocation); + draft.country = geolocation.country; + draft.region = geolocation.region; + draft.timezone = geolocation.timezone; + draft.status = 'complete'; + draft.lastFetchedAt = Date.now(); + draft.error = null; + }); + + return geolocation; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + + this.update((draft) => { + draft.status = 'error'; + draft.error = message; + }); + + throw error; + } + } +} diff --git a/packages/geolocation-controller/src/geolocation-api-service/geolocation-api-service-method-action-types.ts b/packages/geolocation-controller/src/geolocation-api-service/geolocation-api-service-method-action-types.ts new file mode 100644 index 00000000000..0bda2134a22 --- /dev/null +++ b/packages/geolocation-controller/src/geolocation-api-service/geolocation-api-service-method-action-types.ts @@ -0,0 +1,47 @@ +/** + * This file is auto generated. + * Do not edit manually. + */ + +import type { GeolocationApiService } from './geolocation-api-service.js'; + +/** + * Returns the geolocation code. Serves from cache when the TTL has not + * expired, otherwise performs a network fetch. Concurrent callers are + * deduplicated to a single in-flight request. + * + * @param options - Optional fetch options. + * @param options.bypassCache - When true, invalidates the TTL cache. If a + * request is already in-flight it will be reused (deduplication always + * applies). + * @returns An ISO 3166-2 location code (e.g. `US`, `US-NY`, `CA-ON`), or + * {@link UNKNOWN_LOCATION} when the API returns an empty or invalid body. + */ +export type GeolocationApiServiceFetchGeolocationAction = { + type: `GeolocationApiService:fetchGeolocation`; + handler: GeolocationApiService['fetchGeolocation']; +}; + +/** + * Returns the country, region, and timezone for the current client. Serves + * from cache when the TTL has not expired, otherwise performs a network + * fetch. Concurrent callers are deduplicated to a single in-flight request. + * + * @param options - Optional fetch options. + * @param options.bypassCache - When true, invalidates the TTL cache. If a + * request is already in-flight it will be reused (deduplication always + * applies). + * @returns The geolocation data, where each field is `null` when the API + * omits it or returns a value that fails validation. + */ +export type GeolocationApiServiceFetchGeolocationDataAction = { + type: `GeolocationApiService:fetchGeolocationData`; + handler: GeolocationApiService['fetchGeolocationData']; +}; + +/** + * Union of all GeolocationApiService action types. + */ +export type GeolocationApiServiceMethodActions = + | GeolocationApiServiceFetchGeolocationAction + | GeolocationApiServiceFetchGeolocationDataAction; diff --git a/packages/geolocation-controller/src/geolocation-api-service/geolocation-api-service.test.ts b/packages/geolocation-controller/src/geolocation-api-service/geolocation-api-service.test.ts new file mode 100644 index 00000000000..d45fc35f583 --- /dev/null +++ b/packages/geolocation-controller/src/geolocation-api-service/geolocation-api-service.test.ts @@ -0,0 +1,833 @@ +import { HttpError } from '@metamask/controller-utils'; +import { Messenger, MOCK_ANY_NAMESPACE } from '@metamask/messenger'; +import type { + MockAnyNamespace, + MessengerActions, + MessengerEvents, +} from '@metamask/messenger'; + +import { Env } from '../types.js'; +import type { GeolocationApiServiceMessenger } from './geolocation-api-service.js'; +import { + GeolocationApiService, + UNKNOWN_LOCATION, +} from './geolocation-api-service.js'; + +describe('GeolocationApiService', () => { + beforeEach(() => { + jest.useFakeTimers({ doNotFake: ['nextTick', 'queueMicrotask'] }); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + describe('GeolocationApiService:fetchGeolocation', () => { + it('returns the fetched location code', async () => { + const { rootMessenger } = getService({ + options: { fetch: createMockFetch({ country: 'GB' }) }, + }); + + const result = await rootMessenger.call( + 'GeolocationApiService:fetchGeolocation', + ); + + expect(result).toBe('GB'); + }); + + it('fetches from the production URL by default', async () => { + const mockFetch = createMockFetch({ country: 'FR' }); + const { rootMessenger } = getService({ + options: { fetch: mockFetch }, + }); + + await rootMessenger.call('GeolocationApiService:fetchGeolocation'); + + expect(mockFetch).toHaveBeenCalledWith( + 'https://geolocation.api.cx.metamask.io/v2/geolocation', + ); + }); + + it('fetches from the production URL when env is UAT, since API Platform has not provisioned a dedicated UAT deployment', async () => { + const mockFetch = createMockFetch({ country: 'FR' }); + const { rootMessenger } = getService({ + options: { fetch: mockFetch, env: Env.UAT }, + }); + + await rootMessenger.call('GeolocationApiService:fetchGeolocation'); + + expect(mockFetch).toHaveBeenCalledWith( + 'https://geolocation.api.cx.metamask.io/v2/geolocation', + ); + }); + + it('fetches from the DEV URL when env is DEV', async () => { + const mockFetch = createMockFetch({ country: 'FR' }); + const { rootMessenger } = getService({ + options: { fetch: mockFetch, env: Env.DEV }, + }); + + await rootMessenger.call('GeolocationApiService:fetchGeolocation'); + + expect(mockFetch).toHaveBeenCalledWith( + 'https://geolocation.dev-api.cx.metamask.io/v2/geolocation', + ); + }); + }); + + describe('fetchGeolocationData', () => { + it('returns the fetched country, region, and timezone', async () => { + const { rootMessenger } = getService({ + options: { + fetch: createMockFetch({ + country: 'US', + region: 'WA', + timezone: 'America/Los_Angeles', + }), + }, + }); + + const result = await rootMessenger.call( + 'GeolocationApiService:fetchGeolocationData', + ); + + expect(result).toStrictEqual({ + country: 'US', + region: 'WA', + timezone: 'America/Los_Angeles', + }); + }); + }); + + describe('fetchGeolocation', () => { + it('returns the same result as the messenger action', async () => { + const mockFetch = createMockFetch({ country: 'GB' }); + const { service } = getService({ options: { fetch: mockFetch } }); + + const result = await service.fetchGeolocation(); + + expect(result).toBe('GB'); + }); + + it('joins the country and region for the US', async () => { + const mockFetch = createMockFetch({ country: 'US', region: 'NY' }); + const { service } = getService({ options: { fetch: mockFetch } }); + + const result = await service.fetchGeolocation(); + + expect(result).toBe('US-NY'); + }); + + it('joins the country and region for Canada', async () => { + const mockFetch = createMockFetch({ country: 'CA', region: 'ON' }); + const { service } = getService({ options: { fetch: mockFetch } }); + + const result = await service.fetchGeolocation(); + + expect(result).toBe('CA-ON'); + }); + + it('returns the country only for other countries, even with a region', async () => { + const mockFetch = createMockFetch({ country: 'FR', region: 'IDF' }); + const { service } = getService({ options: { fetch: mockFetch } }); + + const result = await service.fetchGeolocation(); + + expect(result).toBe('FR'); + }); + + it('omits the region when the API does not return one', async () => { + const mockFetch = createMockFetch({ + country: 'FR', + timezone: 'Europe/Paris', + }); + const { service } = getService({ options: { fetch: mockFetch } }); + + const result = await service.fetchGeolocation(); + + expect(result).toBe('FR'); + }); + + it('returns UNKNOWN_LOCATION when the country is missing', async () => { + const mockFetch = createMockFetch({ region: 'WA' }); + const { service } = getService({ options: { fetch: mockFetch } }); + + const result = await service.fetchGeolocation(); + + expect(result).toBe(UNKNOWN_LOCATION); + }); + + describe('cache', () => { + it('returns cached value when TTL has not expired', async () => { + const mockFetch = createMockFetch({ country: 'US' }); + const { service } = getService({ options: { fetch: mockFetch } }); + + const first = await service.fetchGeolocation(); + expect(first).toBe('US'); + expect(mockFetch).toHaveBeenCalledTimes(1); + + jest.advanceTimersByTime(4 * 60 * 1000); + + const second = await service.fetchGeolocation(); + expect(second).toBe('US'); + expect(mockFetch).toHaveBeenCalledTimes(1); + }); + + it('re-fetches when TTL has expired', async () => { + const mockFetch = createMockFetch({ country: 'US' }); + const { service } = getService({ options: { fetch: mockFetch } }); + + await service.fetchGeolocation(); + expect(mockFetch).toHaveBeenCalledTimes(1); + + jest.advanceTimersByTime(5 * 60 * 1000 + 1); + + await service.fetchGeolocation(); + expect(mockFetch).toHaveBeenCalledTimes(2); + }); + + it('uses the provided TTL instead of the default', async () => { + const mockFetch = createMockFetch({ country: 'US' }); + const { service } = getService({ + options: { fetch: mockFetch, ttlMs: 100 }, + }); + + await service.fetchGeolocation(); + expect(mockFetch).toHaveBeenCalledTimes(1); + + await service.fetchGeolocation(); + expect(mockFetch).toHaveBeenCalledTimes(1); + + jest.advanceTimersByTime(101); + + await service.fetchGeolocation(); + expect(mockFetch).toHaveBeenCalledTimes(2); + }); + + it('shares the cache between fetchGeolocation and fetchGeolocationData', async () => { + const mockFetch = createMockFetch({ country: 'US', region: 'WA' }); + const { service } = getService({ options: { fetch: mockFetch } }); + + await service.fetchGeolocation(); + const data = await service.fetchGeolocationData(); + + expect(mockFetch).toHaveBeenCalledTimes(1); + expect(data.region).toBe('WA'); + }); + + it('does not cache UNKNOWN responses', async () => { + const mockFetch = jest + .fn() + .mockImplementationOnce(() => + Promise.resolve(createMockResponse('', 200)), + ) + .mockImplementationOnce(() => + Promise.resolve( + createMockResponse(JSON.stringify({ country: 'US' }), 200), + ), + ); + const { service } = getService({ options: { fetch: mockFetch } }); + + const first = await service.fetchGeolocation(); + expect(first).toBe(UNKNOWN_LOCATION); + expect(mockFetch).toHaveBeenCalledTimes(1); + + const second = await service.fetchGeolocation(); + expect(second).toBe('US'); + expect(mockFetch).toHaveBeenCalledTimes(2); + }); + + it('caches a partially-known response so it is not re-fetched within the TTL', async () => { + const mockFetch = jest.fn(() => + Promise.resolve( + createMockResponse( + JSON.stringify({ timezone: 'Europe/Paris' }), + 200, + ), + ), + ); + const { service } = getService({ options: { fetch: mockFetch } }); + + const first = await service.fetchGeolocationData(); + expect(first).toStrictEqual({ + country: null, + region: null, + timezone: 'Europe/Paris', + }); + expect(mockFetch).toHaveBeenCalledTimes(1); + + const second = await service.fetchGeolocationData(); + expect(second).toStrictEqual({ + country: null, + region: null, + timezone: 'Europe/Paris', + }); + expect(mockFetch).toHaveBeenCalledTimes(1); + }); + }); + + describe('promise deduplication', () => { + it('shares a single in-flight request across concurrent callers', async () => { + const mockFetch = createMockFetch({ country: 'IT' }); + const { service } = getService({ options: { fetch: mockFetch } }); + + const [result1, result2, result3] = await Promise.all([ + service.fetchGeolocation(), + service.fetchGeolocation(), + service.fetchGeolocationData(), + ]); + + expect(mockFetch).toHaveBeenCalledTimes(1); + expect(result1).toBe('IT'); + expect(result2).toBe('IT'); + expect(result3).toStrictEqual({ + country: 'IT', + region: null, + timezone: null, + }); + }); + }); + + describe('when the fetch fails', () => { + it('throws the network error', async () => { + const mockFetch = jest + .fn() + .mockRejectedValue(new Error('Network error')); + const { service } = getService({ + options: { + fetch: mockFetch, + policyOptions: { maxRetries: 0 }, + }, + }); + + await expect(service.fetchGeolocation()).rejects.toThrow( + 'Network error', + ); + }); + + it('throws an HttpError on non-OK response', async () => { + const mockFetch = jest + .fn() + .mockImplementation(() => + Promise.resolve(createMockResponse('', 500)), + ); + const { service } = getService({ + options: { + fetch: mockFetch, + policyOptions: { maxRetries: 0 }, + }, + }); + + await expect(service.fetchGeolocation()).rejects.toThrow( + 'Geolocation fetch failed: 500', + ); + }); + + it('rethrows non-Error values as-is', async () => { + const mockFetch = jest.fn().mockRejectedValue('string error'); + const { service } = getService({ + options: { + fetch: mockFetch, + policyOptions: { maxRetries: 0 }, + }, + }); + + await expect(service.fetchGeolocation()).rejects.toBe('string error'); + }); + }); + + describe('response validation', () => { + it('returns unknown data for an empty response body', async () => { + const { service } = getService({ + options: { fetch: createMockRawFetch('') }, + }); + + const result = await service.fetchGeolocationData(); + + expect(result).toStrictEqual({ + country: null, + region: null, + timezone: null, + }); + }); + + it('returns unknown data for a non-JSON response body', async () => { + const { service } = getService({ + options: { fetch: createMockRawFetch('error page') }, + }); + + const result = await service.fetchGeolocationData(); + + expect(result).toStrictEqual({ + country: null, + region: null, + timezone: null, + }); + }); + + it('returns unknown data when the response body is a JSON array', async () => { + const { service } = getService({ + options: { fetch: createMockRawFetch('[{"country":"US"}]') }, + }); + + const result = await service.fetchGeolocationData(); + + expect(result).toStrictEqual({ + country: null, + region: null, + timezone: null, + }); + }); + + it('returns unknown data when the response body is JSON null', async () => { + const { service } = getService({ + options: { fetch: createMockRawFetch('null') }, + }); + + const result = await service.fetchGeolocationData(); + + expect(result).toStrictEqual({ + country: null, + region: null, + timezone: null, + }); + }); + + it('trims whitespace from field values', async () => { + const { service } = getService({ + options: { + fetch: createMockFetch({ + country: ' US ', + region: ' WA ', + timezone: ' America/Los_Angeles ', + }), + }, + }); + + const result = await service.fetchGeolocationData(); + + expect(result).toStrictEqual({ + country: 'US', + region: 'WA', + timezone: 'America/Los_Angeles', + }); + }); + + it('rejects a lowercase country code', async () => { + const { service } = getService({ + options: { fetch: createMockFetch({ country: 'us' }) }, + }); + + const result = await service.fetchGeolocationData(); + + expect(result.country).toBeNull(); + }); + + it('rejects a three-letter country code', async () => { + const { service } = getService({ + options: { fetch: createMockFetch({ country: 'USA' }) }, + }); + + const result = await service.fetchGeolocationData(); + + expect(result.country).toBeNull(); + }); + + it('rejects a non-string country', async () => { + const { service } = getService({ + options: { fetch: createMockFetch({ country: 42 }) }, + }); + + const result = await service.fetchGeolocationData(); + + expect(result.country).toBeNull(); + }); + + it('accepts a numeric region code', async () => { + const { service } = getService({ + options: { fetch: createMockFetch({ country: 'FR', region: '75' }) }, + }); + + const result = await service.fetchGeolocationData(); + + expect(result.region).toBe('75'); + }); + + it('accepts a single-character region code', async () => { + const { service } = getService({ + options: { fetch: createMockFetch({ country: 'ES', region: 'M' }) }, + }); + + const result = await service.fetchGeolocationData(); + + expect(result.region).toBe('M'); + }); + + it('rejects a region code with too many characters', async () => { + const { service } = getService({ + options: { + fetch: createMockFetch({ country: 'US', region: 'ABCD' }), + }, + }); + + const result = await service.fetchGeolocationData(); + + expect(result).toStrictEqual({ + country: 'US', + region: null, + timezone: null, + }); + }); + + it('rejects a lowercase region code', async () => { + const { service } = getService({ + options: { fetch: createMockFetch({ country: 'US', region: 'ny' }) }, + }); + + const result = await service.fetchGeolocationData(); + + expect(result.region).toBeNull(); + }); + + it('accepts a single-segment timezone', async () => { + const { service } = getService({ + options: { + fetch: createMockFetch({ country: 'GB', timezone: 'UTC' }), + }, + }); + + const result = await service.fetchGeolocationData(); + + expect(result.timezone).toBe('UTC'); + }); + + it('accepts a three-segment timezone', async () => { + const { service } = getService({ + options: { + fetch: createMockFetch({ + country: 'US', + timezone: 'America/Indiana/Knox', + }), + }, + }); + + const result = await service.fetchGeolocationData(); + + expect(result.timezone).toBe('America/Indiana/Knox'); + }); + + it('rejects a timezone with unexpected characters', async () => { + const { service } = getService({ + options: { + fetch: createMockFetch({ + country: 'US', + timezone: 'America/Los Angeles', + }), + }, + }); + + const result = await service.fetchGeolocationData(); + + expect(result.timezone).toBeNull(); + }); + + it('ignores unexpected extra fields', async () => { + const { service } = getService({ + options: { + fetch: createMockFetch({ country: 'US', city: 'Seattle' }), + }, + }); + + const result = await service.fetchGeolocationData(); + + expect(result).toStrictEqual({ + country: 'US', + region: null, + timezone: null, + }); + }); + }); + + describe('bypassCache', () => { + it('invalidates the TTL cache and triggers a new fetch', async () => { + const mockFetch = jest + .fn() + .mockImplementationOnce(() => + Promise.resolve( + createMockResponse(JSON.stringify({ country: 'US' }), 200), + ), + ) + .mockImplementationOnce(() => + Promise.resolve( + createMockResponse(JSON.stringify({ country: 'GB' }), 200), + ), + ); + const { service } = getService({ options: { fetch: mockFetch } }); + + const first = await service.fetchGeolocation(); + expect(first).toBe('US'); + expect(mockFetch).toHaveBeenCalledTimes(1); + + const second = await service.fetchGeolocation({ bypassCache: true }); + expect(second).toBe('GB'); + expect(mockFetch).toHaveBeenCalledTimes(2); + }); + + it('reuses an in-flight request instead of starting a second one', async () => { + const mockFetch = createMockFetch({ country: 'US' }); + const { service } = getService({ options: { fetch: mockFetch } }); + + const [first, second] = await Promise.all([ + service.fetchGeolocation(), + service.fetchGeolocation({ bypassCache: true }), + ]); + + expect(mockFetch).toHaveBeenCalledTimes(1); + expect(first).toBe('US'); + expect(second).toBe('US'); + }); + + it('invalidates the TTL cache for fetchGeolocationData', async () => { + const mockFetch = jest + .fn() + .mockImplementationOnce(() => + Promise.resolve( + createMockResponse(JSON.stringify({ country: 'US' }), 200), + ), + ) + .mockImplementationOnce(() => + Promise.resolve( + createMockResponse(JSON.stringify({ country: 'GB' }), 200), + ), + ); + const { service } = getService({ options: { fetch: mockFetch } }); + + await service.fetchGeolocationData(); + const second = await service.fetchGeolocationData({ + bypassCache: true, + }); + + expect(second.country).toBe('GB'); + expect(mockFetch).toHaveBeenCalledTimes(2); + }); + }); + }); + + describe('service policy', () => { + it('retries on 500 and returns the result from the second attempt', async () => { + const mockFetch = jest + .fn() + .mockImplementationOnce(() => + Promise.resolve(createMockResponse('', 500)), + ) + .mockImplementationOnce(() => + Promise.resolve( + createMockResponse(JSON.stringify({ country: 'US' }), 200), + ), + ); + const { service } = getService({ options: { fetch: mockFetch } }); + service.onRetry(() => { + jest.advanceTimersToNextTimerAsync().catch(console.error); + }); + + const result = await service.fetchGeolocation(); + + expect(result).toBe('US'); + expect(mockFetch).toHaveBeenCalledTimes(2); + }); + + it('throws after exhausting all retry attempts', async () => { + const mockFetch = jest + .fn() + .mockImplementation(() => Promise.resolve(createMockResponse('', 500))); + const { service } = getService({ options: { fetch: mockFetch } }); + service.onRetry(() => { + jest.advanceTimersToNextTimerAsync().catch(console.error); + }); + + await expect(service.fetchGeolocation()).rejects.toThrow( + 'Geolocation fetch failed: 500', + ); + + expect(mockFetch.mock.calls.length).toBeGreaterThanOrEqual(2); + }); + + it('fires onDegraded when the request exceeds the degraded threshold', async () => { + const mockFetch = jest.fn().mockImplementation( + () => + new Promise((resolve) => { + setTimeout(() => { + resolve( + createMockResponse(JSON.stringify({ country: 'US' }), 200), + ); + }, 6000); + }), + ); + const { service } = getService({ options: { fetch: mockFetch } }); + const onDegradedListener = jest.fn(); + service.onDegraded(onDegradedListener); + + const fetchPromise = service.fetchGeolocation(); + await jest.advanceTimersByTimeAsync(6000); + await fetchPromise; + + expect(onDegradedListener).toHaveBeenCalled(); + }); + + it('fires onBreak after repeated failures trip the circuit breaker', async () => { + const mockFetch = jest + .fn() + .mockImplementation(() => Promise.resolve(createMockResponse('', 500))); + const { service } = getService({ + options: { + fetch: mockFetch, + policyOptions: { maxConsecutiveFailures: 4 }, + }, + }); + service.onRetry(() => { + jest.advanceTimersToNextTimerAsync().catch(console.error); + }); + const onBreakListener = jest.fn(); + service.onBreak(onBreakListener); + + await expect(service.fetchGeolocation()).rejects.toThrow( + 'Geolocation fetch failed: 500', + ); + + expect(onBreakListener).toHaveBeenCalledWith({ + error: expect.any(HttpError), + }); + }); + }); + + describe('constructor', () => { + it('falls back to globalThis.fetch when fetch option is omitted', async () => { + const spy = jest + .spyOn(globalThis, 'fetch') + .mockImplementation(() => + Promise.resolve( + createMockResponse(JSON.stringify({ country: 'SE' }), 200), + ), + ); + + try { + const rootMessenger = getRootMessenger(); + const messenger = getMessenger(rootMessenger); + const service = new GeolocationApiService({ messenger }); + + const result = await service.fetchGeolocation(); + expect(result).toBe('SE'); + expect(spy).toHaveBeenCalledTimes(1); + } finally { + spy.mockRestore(); + } + }); + }); +}); + +/** + * 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 root messenger for 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. + * @returns The service-specific messenger. + */ +function getMessenger( + rootMessenger: RootMessenger, +): GeolocationApiServiceMessenger { + return new Messenger({ + namespace: 'GeolocationApiService', + parent: rootMessenger, + }); +} + +/** + * Creates a mock Response-like object compatible with the service's fetch + * usage, without relying on the global `Response` constructor. + * + * @param body - The text body to return. + * @param status - The HTTP status code. + * @returns A mock Response object. + */ +function createMockResponse(body: string, status: number): Response { + return { + ok: status >= 200 && status < 300, + status, + text: () => Promise.resolve(body), + } as unknown as Response; +} + +/** + * Creates a mock fetch function that resolves with the given raw response body. + * Each call returns a fresh mock Response. + * + * @param body - The raw body to return. + * @returns A jest mock function. + */ +function createMockRawFetch( + body: string, +): jest.Mock, [string]> { + return jest + .fn() + .mockImplementation(() => Promise.resolve(createMockResponse(body, 200))); +} + +/** + * Creates a mock fetch function that resolves with the given geolocation + * payload serialized as JSON. Each call returns a fresh mock Response. + * + * @param payload - The geolocation payload to return. + * @returns A jest mock function. + */ +function createMockFetch( + payload: Record, +): jest.Mock, [string]> { + return createMockRawFetch(JSON.stringify(payload)); +} + +/** + * Constructs the service under test with sensible defaults. + * + * @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: GeolocationApiService; + rootMessenger: RootMessenger; + messenger: GeolocationApiServiceMessenger; +} { + const rootMessenger = getRootMessenger(); + const messenger = getMessenger(rootMessenger); + const service = new GeolocationApiService({ + fetch: createMockFetch({}), + messenger, + ...options, + }); + + return { service, rootMessenger, messenger }; +} diff --git a/packages/geolocation-controller/src/geolocation-api-service/geolocation-api-service.ts b/packages/geolocation-controller/src/geolocation-api-service/geolocation-api-service.ts new file mode 100644 index 00000000000..93963ab035f --- /dev/null +++ b/packages/geolocation-controller/src/geolocation-api-service/geolocation-api-service.ts @@ -0,0 +1,438 @@ +import type { + CreateServicePolicyOptions, + ServicePolicy, +} from '@metamask/controller-utils'; +import { createServicePolicy, HttpError } from '@metamask/controller-utils'; +import type { Messenger } from '@metamask/messenger'; +import type { IDisposable } from 'cockatiel'; + +import { Env } from '../types.js'; +import type { GeolocationApiServiceMethodActions } from './geolocation-api-service-method-action-types.js'; + +const DEFAULT_TTL_MS = 5 * 60 * 1000; + +const ENDPOINT_PATH = '/v2/geolocation'; + +const COUNTRY_PATTERN = /^[A-Z]{2}$/u; + +const REGION_PATTERN = /^[A-Z0-9]{1,3}$/u; + +const TIMEZONE_PATTERN = /^[A-Za-z][A-Za-z0-9_+-]*(?:\/[A-Za-z0-9_+-]+)*$/u; + +// === GENERAL === + +/** + * The name of the {@link GeolocationApiService}, used to namespace the + * service's actions and events. + */ +export const serviceName = 'GeolocationApiService'; + +/** + * Sentinel value used when the geolocation has not been determined yet or when + * the API returns an empty / invalid response. + */ +export const UNKNOWN_LOCATION = 'UNKNOWN'; + +/** + * Geolocation details returned by the geolocation API. + * + * Each field is `null` when the API omits it or returns a value that fails + * validation. + */ +export type GeolocationData = { + /** ISO 3166-1 alpha-2 country code (e.g. `US`, `FR`). */ + country: string | null; + /** ISO 3166-2 subdivision code without the country prefix (e.g. `WA`). */ + region: string | null; + /** IANA time zone name (e.g. `America/Los_Angeles`). */ + timezone: string | null; +}; + +/** + * Constructs a {@link GeolocationData} with no known fields. + * + * @returns Geolocation data where every field is `null`. + */ +export function getUnknownGeolocationData(): GeolocationData { + return { country: null, region: null, timezone: null }; +} + +/** + * Country codes for which the location code includes the region. This mirrors + * the legacy `v1` geolocation endpoint, which only appended the subdivision for + * the United States and Canada (e.g. `US-NY`, `CA-ON`) and returned the country + * alone for everywhere else. + */ +const REGION_APPENDED_COUNTRIES = new Set(['US', 'CA']); + +/** + * Converts geolocation data to a location code. + * + * To preserve backwards compatibility with the legacy `v1` endpoint, the region + * is appended only for {@link REGION_APPENDED_COUNTRIES} (e.g. `US-NY`, + * `CA-ON`); all other countries return the country code alone (e.g. `FR`), even + * when a region is known. + * + * @param data - The geolocation data to convert. + * @returns The location code (e.g. `US-NY`, `FR`), or + * {@link UNKNOWN_LOCATION} when the country is unknown. + */ +export function toLocationCode(data: GeolocationData): string { + if (data.country === null) { + return UNKNOWN_LOCATION; + } + + if (data.region !== null && REGION_APPENDED_COUNTRIES.has(data.country)) { + return `${data.country}-${data.region}`; + } + + return data.country; +} + +// === MESSENGER === + +const MESSENGER_EXPOSED_METHODS = [ + 'fetchGeolocation', + 'fetchGeolocationData', +] as const; + +/** + * Actions that {@link GeolocationApiService} exposes to other consumers. + */ +export type GeolocationApiServiceActions = GeolocationApiServiceMethodActions; + +/** + * Actions from other messengers that {@link GeolocationApiServiceMessenger} + * calls. + */ +type AllowedActions = never; + +/** + * Events that {@link GeolocationApiService} exposes to other consumers. + */ +export type GeolocationApiServiceEvents = never; + +/** + * Events from other messengers that {@link GeolocationApiService} subscribes + * to. + */ +type AllowedEvents = never; + +/** + * The messenger restricted to actions and events accessed by + * {@link GeolocationApiService}. + */ +export type GeolocationApiServiceMessenger = Messenger< + typeof serviceName, + GeolocationApiServiceActions | AllowedActions, + GeolocationApiServiceEvents | AllowedEvents +>; + +// === SERVICE DEFINITION === + +/** + * Returns the base URL for the geolocation API for the given environment. + * + * Served by API Platform's `geolocation-api` service, not the legacy + * Ramps-owned `on-ramp` endpoint this previously pointed to. API Platform has + * not yet provisioned a dedicated UAT deployment for this service, so UAT + * temporarily resolves to the production URL until one exists. + * + * @param env - The environment to get the URL for. + * @returns The full URL for the geolocation endpoint. + */ +function getGeolocationUrl(env: Env): string { + const envPrefix = env === Env.DEV ? 'dev-' : ''; + return `https://geolocation.${envPrefix}api.cx.metamask.io${ENDPOINT_PATH}`; +} + +/** + * Reads a string field from a parsed response body, keeping it only when it + * matches the expected format. + * + * @param body - The parsed response body. + * @param field - The name of the field to read. + * @param pattern - The pattern the field value must match. + * @returns The trimmed field value, or `null` when it is missing or invalid. + */ +function readValidatedField( + body: Record, + field: string, + pattern: RegExp, +): string | null { + const value = body[field]; + + if (typeof value !== 'string') { + return null; + } + + const trimmed = value.trim(); + + return pattern.test(trimmed) ? trimmed : null; +} + +/** + * Parses and validates the geolocation API response body. + * + * The endpoint is expected to return JSON such as + * `{"country":"US","region":"WA","timezone":"America/Los_Angeles"}`. Anything + * that cannot be parsed, or any individual field that fails validation, is + * reported as unknown rather than throwing, so that consumers can keep working + * with partial or missing data. + * + * @param raw - The raw response body. + * @returns The validated geolocation data. + */ +function parseGeolocationResponse(raw: string): GeolocationData { + let body: unknown; + + try { + body = JSON.parse(raw); + } catch { + return getUnknownGeolocationData(); + } + + if (typeof body !== 'object' || body === null || Array.isArray(body)) { + return getUnknownGeolocationData(); + } + + const record = body as Record; + + return { + country: readValidatedField(record, 'country', COUNTRY_PATTERN), + region: readValidatedField(record, 'region', REGION_PATTERN), + timezone: readValidatedField(record, 'timezone', TIMEZONE_PATTERN), + }; +} + +/** + * Options accepted by {@link GeolocationApiService.fetchGeolocation}. + */ +export type FetchGeolocationOptions = { + /** When true, the TTL cache is invalidated so the next request fetches fresh data. */ + bypassCache?: boolean; +}; + +/** + * Low-level data service that fetches geolocation details from the geolocation + * API. + * + * Responsibilities: + * - HTTP request to the geolocation endpoint (wrapped in a service policy) + * - Response validation of the country, region, and timezone fields + * - TTL-based in-memory cache + * - Promise deduplication (concurrent callers share a single in-flight request) + * + * This class is intentionally not a controller: it does not manage UI state. + * Its {@link fetchGeolocation} and {@link fetchGeolocationData} methods are + * automatically registered on the messenger so that controllers and other + * packages can call them directly. + */ +export class GeolocationApiService { + /** + * The name of the service. + */ + readonly name: typeof serviceName; + + readonly #messenger: GeolocationApiServiceMessenger; + + readonly #fetch: typeof globalThis.fetch; + + readonly #url: string; + + readonly #ttlMs: number; + + /** + * The policy that wraps each HTTP request. + * + * @see {@link createServicePolicy} + */ + readonly #policy: ServicePolicy; + + #cachedGeolocation: GeolocationData = getUnknownGeolocationData(); + + #lastFetchedAt: number | null = null; + + #fetchPromise: Promise | null = null; + + /** + * Constructs a new {@link GeolocationApiService}. + * + * @param args - The constructor arguments. + * @param args.messenger - The messenger suited for this service. + * @param args.env - The environment to determine the correct API endpoint. + * Defaults to PRD. + * @param args.fetch - A function that can be used to make an HTTP request. + * Defaults to the global fetch. + * @param args.ttlMs - Cache TTL in milliseconds. Defaults to 5 minutes. + * @param args.policyOptions - Options to pass to `createServicePolicy`, which + * is used to wrap each request. See {@link CreateServicePolicyOptions}. + */ + constructor({ + messenger, + env = Env.PRD, + fetch: fetchFunction = globalThis.fetch, + ttlMs, + policyOptions = {}, + }: { + messenger: GeolocationApiServiceMessenger; + env?: Env; + fetch?: typeof fetch; + ttlMs?: number; + policyOptions?: CreateServicePolicyOptions; + }) { + this.name = serviceName; + this.#messenger = messenger; + this.#url = getGeolocationUrl(env); + this.#fetch = fetchFunction; + this.#ttlMs = ttlMs ?? DEFAULT_TTL_MS; + this.#policy = createServicePolicy(policyOptions); + + this.#messenger.registerMethodActionHandlers( + this, + MESSENGER_EXPOSED_METHODS, + ); + } + + /** + * Registers a handler that will be called after a request returns a 5xx + * 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 requests are consistently + * failing or when a successful request takes longer than the degraded + * threshold. + * + * @param listener - The handler to be called. + * @returns An object that can be used to unregister the handler. + */ + onDegraded( + listener: Parameters[0], + ): IDisposable { + return this.#policy.onDegraded(listener); + } + + /** + * Returns the geolocation code. Serves from cache when the TTL has not + * expired, otherwise performs a network fetch. Concurrent callers are + * deduplicated to a single in-flight request. + * + * @param options - Optional fetch options. + * @param options.bypassCache - When true, invalidates the TTL cache. If a + * request is already in-flight it will be reused (deduplication always + * applies). + * @returns An ISO 3166-2 location code (e.g. `US`, `US-NY`, `CA-ON`), or + * {@link UNKNOWN_LOCATION} when the API returns an empty or invalid body. + */ + async fetchGeolocation(options?: FetchGeolocationOptions): Promise { + return toLocationCode(await this.fetchGeolocationData(options)); + } + + /** + * Returns the country, region, and timezone for the current client. Serves + * from cache when the TTL has not expired, otherwise performs a network + * fetch. Concurrent callers are deduplicated to a single in-flight request. + * + * @param options - Optional fetch options. + * @param options.bypassCache - When true, invalidates the TTL cache. If a + * request is already in-flight it will be reused (deduplication always + * applies). + * @returns The geolocation data, where each field is `null` when the API + * omits it or returns a value that fails validation. + */ + async fetchGeolocationData( + options?: FetchGeolocationOptions, + ): Promise { + if (options?.bypassCache) { + this.#lastFetchedAt = null; + } + + if (this.#isCacheValid()) { + return this.#cachedGeolocation; + } + + if (this.#fetchPromise) { + return this.#fetchPromise; + } + + const promise = this.#performFetch(); + this.#fetchPromise = promise; + + try { + return await promise; + } finally { + this.#fetchPromise = null; + } + } + + /** + * Checks whether the cached geolocation is still within the TTL window. + * + * @returns True if the cache is valid. + */ + #isCacheValid(): boolean { + return ( + this.#lastFetchedAt !== null && + Date.now() - this.#lastFetchedAt < this.#ttlMs + ); + } + + /** + * Performs the actual HTTP fetch, wrapped in the service policy for automatic + * retry and circuit-breaking, and validates the response. + * + * @returns The validated geolocation data. + */ + async #performFetch(): Promise { + const response = await this.#policy.execute(async () => { + const localResponse = await this.#fetch(this.#url); + if (!localResponse.ok) { + throw new HttpError( + localResponse.status, + `Geolocation fetch failed: ${localResponse.status}`, + ); + } + return localResponse; + }); + + const geolocation = parseGeolocationResponse( + (await response.text()).trim(), + ); + + // Cache whenever at least one field resolved. A partially-known result + // (e.g. timezone without a valid country) is still worth caching so we do + // not re-fetch it within the TTL window; only a fully-unknown response is + // left uncached so it can be retried. + const hasKnownField = + geolocation.country !== null || + geolocation.region !== null || + geolocation.timezone !== null; + + if (hasKnownField) { + this.#cachedGeolocation = geolocation; + this.#lastFetchedAt = Date.now(); + } + + return geolocation; + } +} diff --git a/packages/geolocation-controller/src/index.ts b/packages/geolocation-controller/src/index.ts new file mode 100644 index 00000000000..d8a20d51ee9 --- /dev/null +++ b/packages/geolocation-controller/src/index.ts @@ -0,0 +1,37 @@ +export type { + GeolocationControllerState, + GeolocationControllerGetStateAction, + GeolocationControllerActions, + GeolocationControllerStateChangeEvent, + GeolocationControllerEvents, + GeolocationControllerMessenger, + GeolocationControllerOptions, +} from './GeolocationController.js'; +export type { + GeolocationControllerGetGeolocationAction, + GeolocationControllerGetGeolocationDataAction, + GeolocationControllerRefreshGeolocationAction, +} from './GeolocationController-method-action-types.js'; +export type { GeolocationRequestStatus } from './types.js'; +export { Env } from './types.js'; +export { + GeolocationController, + getDefaultGeolocationControllerState, +} from './GeolocationController.js'; +export { + GeolocationApiService, + getUnknownGeolocationData, + toLocationCode, + UNKNOWN_LOCATION, +} from './geolocation-api-service/geolocation-api-service.js'; +export type { + GeolocationApiServiceMessenger, + GeolocationApiServiceActions, + GeolocationApiServiceEvents, + FetchGeolocationOptions, + GeolocationData, +} from './geolocation-api-service/geolocation-api-service.js'; +export type { + GeolocationApiServiceFetchGeolocationAction, + GeolocationApiServiceFetchGeolocationDataAction, +} from './geolocation-api-service/geolocation-api-service-method-action-types.js'; diff --git a/packages/geolocation-controller/src/types.ts b/packages/geolocation-controller/src/types.ts new file mode 100644 index 00000000000..0b9b6af9271 --- /dev/null +++ b/packages/geolocation-controller/src/types.ts @@ -0,0 +1,17 @@ +/** + * The status of a geolocation fetch operation. + */ +export type GeolocationRequestStatus = + | 'idle' + | 'loading' + | 'complete' + | 'error'; + +/** + * Deployment environment for API endpoint selection. + */ +export enum Env { + DEV = 'dev', + UAT = 'uat', + PRD = 'prd', +} diff --git a/packages/geolocation-controller/tsconfig.build.json b/packages/geolocation-controller/tsconfig.build.json new file mode 100644 index 00000000000..5a5c9e2326a --- /dev/null +++ b/packages/geolocation-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/geolocation-controller/tsconfig.json b/packages/geolocation-controller/tsconfig.json new file mode 100644 index 00000000000..dfd15011442 --- /dev/null +++ b/packages/geolocation-controller/tsconfig.json @@ -0,0 +1,12 @@ +{ + "extends": "../../tsconfig.packages.json", + "compilerOptions": { + "baseUrl": "./" + }, + "references": [ + { "path": "../base-controller" }, + { "path": "../controller-utils" }, + { "path": "../messenger" } + ], + "include": ["../../types", "./src"] +} diff --git a/packages/geolocation-controller/typedoc.json b/packages/geolocation-controller/typedoc.json new file mode 100644 index 00000000000..c9da015dbf8 --- /dev/null +++ b/packages/geolocation-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/java-tron-up/CHANGELOG.md b/packages/java-tron-up/CHANGELOG.md new file mode 100644 index 00000000000..b983a9bfc34 --- /dev/null +++ b/packages/java-tron-up/CHANGELOG.md @@ -0,0 +1,20 @@ +# 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 ([#9314](https://github.com/MetaMask/core/pull/9314)) + - Installs a pinned java-tron runtime for local development and CI + - Exposes `java-tron-up` and `java-tron` binaries via `node_modules/.bin` + - Uses `@metamask/local-node-utils` for cache resolution, downloads, and executable wrappers + +[Unreleased]: https://github.com/MetaMask/core/compare/@metamask/java-tron-up@1.0.0...HEAD +[1.0.0]: https://github.com/MetaMask/core/releases/tag/@metamask/java-tron-up@1.0.0 diff --git a/packages/java-tron-up/LICENSE b/packages/java-tron-up/LICENSE new file mode 100644 index 00000000000..9ec4f4514ea --- /dev/null +++ b/packages/java-tron-up/LICENSE @@ -0,0 +1,6 @@ +This project is licensed under either of + + * MIT license ([LICENSE.MIT](LICENSE.MIT)) + * Apache License, Version 2.0 ([LICENSE.APACHE2](LICENSE.APACHE2)) + +at your option. diff --git a/packages/java-tron-up/LICENSE.APACHE2 b/packages/java-tron-up/LICENSE.APACHE2 new file mode 100644 index 00000000000..0a5774327b6 --- /dev/null +++ b/packages/java-tron-up/LICENSE.APACHE2 @@ -0,0 +1,202 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright 2026 MetaMask + + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/packages/java-tron-up/LICENSE.MIT b/packages/java-tron-up/LICENSE.MIT new file mode 100644 index 00000000000..fe29e78e0fe --- /dev/null +++ b/packages/java-tron-up/LICENSE.MIT @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 MetaMask + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packages/java-tron-up/README.md b/packages/java-tron-up/README.md new file mode 100644 index 00000000000..8633d6c0830 --- /dev/null +++ b/packages/java-tron-up/README.md @@ -0,0 +1,124 @@ +# `@metamask/java-tron-up` + +`java-tron-up` installs a pinned native java-tron runtime for local development +and CI. It follows the same runtime-only shape as `@metamask/foundryup`: this +package installs external runtime artifacts into the MetaMask cache and exposes +binaries in `node_modules/.bin`; the consuming test harness owns process +startup, private-network config, readiness checks, and seeding. + +This package does not use Docker and does not start or seed a TRON node. + +## Usage + +Install the package in the consuming repo: + +```bash +yarn add @metamask/java-tron-up +npm install @metamask/java-tron-up +``` + +For Yarn v4 projects, it is usually simplest to add package scripts in the +consuming repo: + +```json +{ + "scripts": { + "java-tron-up": "node_modules/.bin/java-tron-up", + "java-tron": "node_modules/.bin/java-tron" + } +} +``` + +Install java-tron and its managed Java runtime: + +```bash +yarn java-tron-up install +``` + +Run the installed node wrapper: + +```bash +node_modules/.bin/java-tron -c /absolute/path/to/fullnode.conf --witness +``` + +For MetaMask Extension E2E tests, the Tron seeder should spawn +`node_modules/.bin/java-tron`, pass its generated private-network config, poll +java-tron's HTTP APIs directly, and perform all account/token/staking seeding +itself. + +## Installed Artifacts + +`java-tron-up` installs: + +- a platform-specific `FullNode.jar` +- a managed Java runtime matching java-tron's architecture requirements +- a `node_modules/.bin/java-tron` wrapper that runs: + +```bash +java -jar FullNode.jar "$@" +``` + +## CLI + +```bash +java-tron-up [install] [options] +java-tron-up cache clean [options] +``` + +Options: + +- `--bin-directory `: directory for generated wrappers. Defaults to + `node_modules/.bin`. +- `--cache-directory `: artifact cache directory. Defaults to + `.metamask/cache`. +- `--full-node-url ` and `--full-node-checksum `: override the + FullNode jar for the current platform. +- `--java-runtime-url ` and `--java-runtime-checksum `: override the + Java runtime archive for the current platform. +- `--platform `: override platform selection, for example + `linux-x64`. + +## Default Release + +The package currently pins java-tron `GreatVoyage-v4.8.1` for `darwin-arm64`, +`darwin-x64`, `linux-arm64`, and `linux-x64`. + +java-tron `4.8.1` requires JDK 8 for x86_64 and JDK 17 for arm64, so this +package installs Azul Zulu Java 8 on x64 platforms and Azul Zulu Java 17 on +arm64 platforms. + +## Cache + +The cache defaults to `.metamask/cache` in the current repo. The installer reads +`.yarnrc.yml` as YAML and, when `enableGlobalCache` is true, moves the cache to +`~/.cache/metamask`, matching the `@metamask/foundryup` behavior. + +Clean only this package's cache namespace: + +```bash +yarn java-tron-up cache clean +``` + +## Package Config + +The consuming repo can override the pinned artifact URLs and checksums in its +root `package.json`: + +```json +{ + "javaTronUp": { + "fullNode": { + "version": "GreatVoyage-v4.8.1", + "platforms": { + "linux-x64": { + "url": "https://github.com/tronprotocol/java-tron/releases/download/GreatVoyage-v4.8.1/FullNode.jar", + "checksum": "0e67b2fe75d7077750e73c4fa20725c6e9824657275d96be256ae5da681f9945" + } + } + } + } +} +``` + +Supported package config keys are `javaTronUp`, `javatronup`, and +`java-tron-up`. diff --git a/packages/java-tron-up/jest.config.js b/packages/java-tron-up/jest.config.js new file mode 100644 index 00000000000..ac981bb832e --- /dev/null +++ b/packages/java-tron-up/jest.config.js @@ -0,0 +1,32 @@ +/* + * 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, + + // The CLI entrypoint is exercised through package builds and installed-bin smoke tests. + coveragePathIgnorePatterns: [ + ...baseConfig.coveragePathIgnorePatterns, + './src/bin/java-tron-up.ts', + ], + + // An object that configures minimum threshold enforcement for coverage results + coverageThreshold: { + global: { + branches: 56.41, + functions: 100, + lines: 86.23, + statements: 86.33, + }, + }, +}); diff --git a/packages/java-tron-up/package.json b/packages/java-tron-up/package.json new file mode 100644 index 00000000000..6d66d1451ef --- /dev/null +++ b/packages/java-tron-up/package.json @@ -0,0 +1,76 @@ +{ + "name": "@metamask/java-tron-up", + "version": "1.0.0", + "description": "java-tron runtime installer for MetaMask E2E tests", + "keywords": [ + "Ethereum", + "MetaMask" + ], + "homepage": "https://github.com/MetaMask/core/tree/main/packages/java-tron-up#readme", + "bugs": { + "url": "https://github.com/MetaMask/core/issues" + }, + "license": "(MIT OR Apache-2.0)", + "repository": { + "type": "git", + "url": "https://github.com/MetaMask/core.git" + }, + "bin": "./dist/bin/java-tron-up.mjs", + "files": [ + "dist/" + ], + "sideEffects": false, + "main": "./dist/index.cjs", + "types": "./dist/index.d.cts", + "exports": { + ".": { + "import": { + "types": "./dist/index.d.mts", + "default": "./dist/index.mjs" + }, + "require": { + "types": "./dist/index.d.cts", + "default": "./dist/index.cjs" + } + }, + "./package.json": "./package.json" + }, + "publishConfig": { + "access": "public", + "registry": "https://registry.npmjs.org/" + }, + "scripts": { + "build": "ts-bridge --project tsconfig.build.json --verbose --clean --no-references", + "build:all": "ts-bridge --project tsconfig.build.json --verbose --clean", + "build:docs": "typedoc", + "changelog:update": "../../scripts/update-changelog.sh @metamask/java-tron-up", + "changelog:validate": "../../scripts/validate-changelog.sh @metamask/java-tron-up", + "lint:tsconfigs": "tsx ../../scripts/lint-tsconfigs/lint-tsconfigs.mts", + "lint:tsconfigs:fix": "tsx ../../scripts/lint-tsconfigs/lint-tsconfigs.mts --fix", + "messenger-action-types:check": "tsx ../../packages/messenger-cli/src/cli.ts --formatter oxfmt --check", + "messenger-action-types:generate": "tsx ../../packages/messenger-cli/src/cli.ts --formatter oxfmt --generate", + "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/local-node-utils": "^1.0.0" + }, + "devDependencies": { + "@metamask/auto-changelog": "^6.1.0", + "@ts-bridge/cli": "^0.6.4", + "@types/jest": "^30.0.0", + "deepmerge": "^4.2.2", + "jest": "^30.4.2", + "ts-jest": "^29.4.11", + "tsx": "^4.20.5", + "typedoc": "^0.25.13", + "typedoc-plugin-missing-exports": "^2.0.0", + "typescript": "~5.3.3" + }, + "engines": { + "node": "^18.18 || >=20" + } +} diff --git a/packages/java-tron-up/src/bin/java-tron-up.ts b/packages/java-tron-up/src/bin/java-tron-up.ts new file mode 100644 index 00000000000..5f73d89d154 --- /dev/null +++ b/packages/java-tron-up/src/bin/java-tron-up.ts @@ -0,0 +1,65 @@ +#!/usr/bin/env node +/* eslint-disable no-restricted-globals */ +import { + cleanJavaTronCache, + installJavaTron, + parseJavaTronInstallCliOptions, + readJavaTronInstallOptionsFromPackageJson, +} from '../install.js'; + +async function main(): Promise { + const [command, ...args] = process.argv.slice(2); + + if (command === '--help' || command === 'help') { + printHelp(); + return; + } + + if (command === 'cache' && args[0] === 'clean') { + await cleanJavaTronCache({ + ...readJavaTronInstallOptionsFromPackageJson(), + ...parseJavaTronInstallCliOptions(args.slice(1)), + }); + console.log('[java-tron-up] cache cleaned'); + return; + } + + const installArgs = command === 'install' ? args : process.argv.slice(2); + const result = await installJavaTron({ + ...readJavaTronInstallOptionsFromPackageJson(), + ...parseJavaTronInstallCliOptions(installArgs), + }); + + console.log( + `[java-tron-up] java-tron ${ + result.cacheHit ? 'found in cache' : 'installed' + } at ${result.fullNodeJar}`, + ); + console.log(`[java-tron-up] Java runtime installed at ${result.javaBinary}`); + console.log(`[java-tron-up] binary installed at ${result.binaryPath}`); +} + +main().catch((error) => { + console.error(error); + process.exit(1); +}); + +function printHelp(): void { + console.log(`Usage: java-tron-up [install] [options] + java-tron-up cache clean [options] + +Commands: + install Install java-tron and the managed Java runtime. Default command. + cache clean Remove cached java-tron-up artifacts. + +Options: + --bin-directory Directory for the java-tron executable. + Defaults to node_modules/.bin. + --cache-directory Cache directory. Defaults to .metamask/cache. + --full-node-url FullNode.jar URL for the current platform. + --full-node-checksum Expected FullNode.jar SHA-256 checksum. + --java-runtime-url Java runtime archive URL for the current platform. + --java-runtime-checksum Expected Java runtime SHA-256 checksum. + --platform Override platform key, e.g. linux-x64. + --help Show this help text.`); +} diff --git a/packages/java-tron-up/src/index.ts b/packages/java-tron-up/src/index.ts new file mode 100644 index 00000000000..cd1263803fc --- /dev/null +++ b/packages/java-tron-up/src/index.ts @@ -0,0 +1,18 @@ +export { + JAVA_TRON_DEFAULT_FULL_NODE, + JAVA_TRON_DEFAULT_JAVA_RUNTIME, + cleanJavaTronCache, + getJavaTronCacheDirectory, + installJavaRuntime, + installJavaTron, + parseJavaTronInstallCliOptions, + readJavaTronInstallOptionsFromPackageJson, +} from './install.js'; +export type { + JavaTronArtifactConfig, + JavaTronArtifactPlatformConfig, + JavaTronInstallDependencies, + JavaTronInstallOptions, + JavaTronInstallResult, + JavaTronJavaRuntimeConfig, +} from './install.js'; diff --git a/packages/java-tron-up/src/install.test.ts b/packages/java-tron-up/src/install.test.ts new file mode 100644 index 00000000000..3a1c17394de --- /dev/null +++ b/packages/java-tron-up/src/install.test.ts @@ -0,0 +1,606 @@ +/* eslint-disable jest/expect-expect, n/no-sync */ +import assert from 'node:assert/strict'; +import { execFileSync } from 'node:child_process'; +import { createHash } from 'node:crypto'; +import { + chmodSync, + existsSync, + lstatSync, + mkdtempSync, + readFileSync, + rmSync, + symlinkSync, + writeFileSync, +} from 'node:fs'; +import { mkdir, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { + JAVA_TRON_DEFAULT_FULL_NODE, + cleanJavaTronCache, + getJavaTronCacheDirectory, + installJavaTron, + parseJavaTronInstallCliOptions, + readJavaTronInstallOptionsFromPackageJson, +} from './install.js'; +import type { JavaTronInstallDependencies } from './install.js'; + +describe('java-tron-up installer', () => { + let tempDirs: string[] = []; + + afterEach(() => { + for (const tempDir of tempDirs) { + rmSync(tempDir, { force: true, recursive: true }); + } + tempDirs = []; + }); + + it('pins the current latest java-tron release', () => { + assert.equal(JAVA_TRON_DEFAULT_FULL_NODE.version, 'GreatVoyage-v4.8.1'); + assert.equal( + JAVA_TRON_DEFAULT_FULL_NODE.platforms['darwin-arm64']?.checksum, + '694431860ee76fc986ed495f9ec19f29ed3bd752a394386e7b3b9886b2292f59', + ); + assert.equal( + JAVA_TRON_DEFAULT_FULL_NODE.platforms['linux-x64']?.checksum, + '0e67b2fe75d7077750e73c4fa20725c6e9824657275d96be256ae5da681f9945', + ); + }); + + it('uses the global MetaMask cache when Yarn global cache is enabled', () => { + const cwd = createTempDir(); + const homeDirectory = createTempDir(); + writeFileSync(join(cwd, '.yarnrc.yml'), 'enableGlobalCache: true\n'); + + assert.equal( + getJavaTronCacheDirectory({ cwd, homeDirectory }), + join(homeDirectory, '.cache', 'metamask'), + ); + }); + + it('uses the local MetaMask cache when Yarn global cache is disabled', () => { + const cwd = createTempDir(); + writeFileSync(join(cwd, '.yarnrc.yml'), 'enableGlobalCache: false\n'); + + assert.equal( + getJavaTronCacheDirectory({ cwd }), + join(cwd, '.metamask', 'cache'), + ); + }); + + it('uses the local MetaMask cache when .yarnrc.yml is missing', () => { + const cwd = createTempDir(); + + assert.equal( + getJavaTronCacheDirectory({ cwd }), + join(cwd, '.metamask', 'cache'), + ); + }); + + it('uses the local MetaMask cache when .yarnrc.yml is unreadable', () => { + const cwd = createTempDir(); + const yarnRcPath = join(cwd, '.yarnrc.yml'); + writeFileSync(yarnRcPath, 'enableGlobalCache: true\n'); + chmodSync(yarnRcPath, 0o000); + + try { + assert.equal( + getJavaTronCacheDirectory({ cwd }), + join(cwd, '.metamask', 'cache'), + ); + } finally { + chmodSync(yarnRcPath, 0o644); + } + }); + + it('merges partial fullNode overrides with pinned defaults', async () => { + const cwd = createTempDir(); + const cacheDirectory = join(cwd, '.metamask', 'cache'); + const binDirectory = join(cwd, 'node_modules', '.bin'); + const downloads: string[] = []; + const customLinuxUrl = 'https://example.test/custom/FullNode.jar'; + const customLinuxContent = 'overridden linux jar'; + const armDefault = JAVA_TRON_DEFAULT_FULL_NODE.platforms['darwin-arm64']; + const javaArchiveContent = 'fake java archive'; + + const dependencies: JavaTronInstallDependencies = { + downloadFile: async (url, destination): Promise => { + downloads.push(url); + let content = javaArchiveContent; + if (url === customLinuxUrl) { + content = customLinuxContent; + } + await writeFile(destination, content); + }, + extractArchive: createDependencies({ + fullNodeContent: '', + javaArchiveContent, + }).extractArchive, + }; + + const partialFullNode = { + platforms: { + 'linux-x64': { + checksum: sha256(customLinuxContent), + url: customLinuxUrl, + }, + }, + }; + const linuxJavaRuntime = { + platforms: { + 'linux-x64': { + checksum: sha256(javaArchiveContent), + url: 'https://example.test/java-linux.tar.gz', + }, + }, + }; + const armJavaRuntime = { + platforms: { + 'darwin-arm64': { + checksum: sha256(javaArchiveContent), + url: 'https://example.test/java-arm.tar.gz', + }, + }, + }; + + const linuxResult = await installJavaTron( + { + binDirectory, + cacheDirectory, + cwd, + fullNode: partialFullNode, + javaRuntime: linuxJavaRuntime, + platform: 'linux-x64', + }, + dependencies, + ); + + assert.equal( + readFileSync(linuxResult.fullNodeJar, 'utf8'), + customLinuxContent, + ); + assert.equal(linuxResult.version, JAVA_TRON_DEFAULT_FULL_NODE.version); + + downloads.length = 0; + let usedDefaultArmUrl = false; + + await assert.rejects( + () => + installJavaTron( + { + binDirectory, + cacheDirectory, + cwd, + fullNode: partialFullNode, + javaRuntime: armJavaRuntime, + platform: 'darwin-arm64', + }, + { + ...dependencies, + downloadFile: async (url, destination): Promise => { + downloads.push(url); + if (url === armDefault?.url) { + usedDefaultArmUrl = true; + } + await writeFile(destination, javaArchiveContent); + }, + }, + ), + /checksum mismatch/u, + ); + + assert.ok(usedDefaultArmUrl); + }); + + it('re-downloads the Java runtime when the cached checksum marker is invalid', async () => { + const cwd = createTempDir(); + const cacheDirectory = join(cwd, '.metamask', 'cache'); + const binDirectory = join(cwd, 'node_modules', '.bin'); + const fullNodeContent = 'cached fullnode jar'; + const javaArchiveContent = 'cached java archive'; + const downloads: string[] = []; + const platformConfig = { + checksum: sha256(javaArchiveContent), + url: 'https://example.test/java.tar.gz', + }; + const dependencies: JavaTronInstallDependencies = { + downloadFile: async (url, destination): Promise => { + downloads.push(url); + await writeFile( + destination, + url.includes('FullNode') ? fullNodeContent : javaArchiveContent, + ); + }, + extractArchive: createDependencies({ + fullNodeContent, + javaArchiveContent, + }).extractArchive, + }; + + const installOptions = { + binDirectory, + cacheDirectory, + cwd, + fullNode: { + platforms: { + 'linux-x64': { + checksum: sha256(fullNodeContent), + url: 'https://example.test/FullNode.jar', + }, + }, + }, + javaRuntime: { + platforms: { + 'linux-x64': platformConfig, + }, + }, + platform: 'linux-x64', + }; + + await installJavaTron(installOptions, dependencies); + + const cacheKey = createHash('sha256') + .update(`${platformConfig.url}:${platformConfig.checksum}`) + .digest('hex'); + const sourceChecksumPath = join( + cacheDirectory, + 'java-tron-up', + 'java', + cacheKey, + '.source-checksum', + ); + writeFileSync(sourceChecksumPath, 'stale-checksum'); + + downloads.length = 0; + + await installJavaTron(installOptions, dependencies); + + assert.ok(downloads.includes(platformConfig.url)); + }); + + it('exits non-zero when the wrapped process terminates via a signal', async () => { + const cwd = createTempDir(); + const cacheDirectory = join(cwd, '.metamask', 'cache'); + const binDirectory = join(cwd, 'node_modules', '.bin'); + const fullNodeContent = 'fake fullnode jar'; + const signalJavaBinary = join(cwd, 'signal-java'); + + writeFileSync( + signalJavaBinary, + '#!/usr/bin/env node\nprocess.kill(process.pid, "SIGTERM");\n', + ); + chmodSync(signalJavaBinary, 0o755); + + const result = await installJavaTron( + { + binDirectory, + cacheDirectory, + cwd, + fullNode: { + platforms: { + 'linux-x64': { + checksum: sha256(fullNodeContent), + url: 'https://example.test/FullNode.jar', + }, + }, + }, + javaBinary: signalJavaBinary, + platform: 'linux-x64', + }, + { + downloadFile: async (_url, destination): Promise => { + await writeFile(destination, fullNodeContent); + }, + }, + ); + + let exitStatus: number | null = null; + try { + execFileSync(process.execPath, [result.binaryPath], { + stdio: 'pipe', + }); + } catch (error) { + exitStatus = (error as NodeJS.ErrnoException).status ?? null; + } + + assert.notEqual(exitStatus, 0); + }); + + it('reads pinned installer options from package.json', () => { + const cwd = createTempDir(); + writeFileSync( + join(cwd, 'package.json'), + JSON.stringify({ + javaTronUp: { + fullNode: { + platforms: { + 'linux-x64': { + checksum: sha256('jar-from-package-json'), + url: 'https://example.test/FullNode.jar', + }, + }, + version: 'test-version', + }, + }, + }), + ); + + assert.deepEqual(readJavaTronInstallOptionsFromPackageJson({ cwd }), { + fullNode: { + platforms: { + 'linux-x64': { + checksum: sha256('jar-from-package-json'), + url: 'https://example.test/FullNode.jar', + }, + }, + version: 'test-version', + }, + }); + }); + + it('returns empty options when package.json is absent', () => { + const cwd = createTempDir(); // no package.json written + assert.deepEqual(readJavaTronInstallOptionsFromPackageJson({ cwd }), {}); + }); + + it('parses installer CLI options', () => { + assert.deepEqual( + parseJavaTronInstallCliOptions([ + '--cache-directory', + '/tmp/cache', + '--bin-directory', + '/tmp/bin', + '--full-node-url', + 'https://example.test/FullNode.jar', + '--full-node-checksum', + 'abc123', + ]), + { + binDirectory: '/tmp/bin', + cacheDirectory: '/tmp/cache', + fullNode: { + platforms: { + current: { + checksum: 'abc123', + url: 'https://example.test/FullNode.jar', + }, + }, + }, + }, + ); + }); + + it('downloads, verifies, caches, and installs the java-tron wrapper', async () => { + const cwd = createTempDir(); + const cacheDirectory = join(cwd, '.metamask', 'cache'); + const binDirectory = join(cwd, 'node_modules', '.bin'); + const downloads: { destination: string; url: string }[] = []; + const fullNodeContent = 'fake fullnode jar'; + const javaArchiveContent = 'fake java archive'; + const dependencies = createDependencies({ + downloads, + fullNodeContent, + javaArchiveContent, + }); + + const result = await installJavaTron( + { + binDirectory, + cacheDirectory, + cwd, + fullNode: { + platforms: { + 'darwin-arm64': { + checksum: sha256(fullNodeContent), + url: 'https://example.test/FullNode-aarch64.jar', + }, + }, + version: 'test-java-tron', + }, + javaRuntime: { + platforms: { + 'darwin-arm64': { + checksum: sha256(javaArchiveContent), + url: 'https://example.test/java.tar.gz', + }, + }, + version: 'test-java', + }, + platform: 'darwin-arm64', + }, + dependencies, + ); + + assert.equal(result.cacheHit, false); + assert.equal(result.version, 'test-java-tron'); + assert.equal(result.binaryPath, join(binDirectory, 'java-tron')); + assert.equal(readFileSync(result.fullNodeJar, 'utf8'), fullNodeContent); + assert.ok(result.javaBinary.endsWith('/bin/java')); + assert.ok(existsSync(result.binaryPath)); + assert.deepEqual( + downloads.map(({ url }) => url), + [ + 'https://example.test/java.tar.gz', + 'https://example.test/FullNode-aarch64.jar', + ], + ); + + const wrapperOutput = execFileSync( + process.execPath, + [result.binaryPath, '-v'], + { + encoding: 'utf8', + }, + ); + assert.equal(wrapperOutput.trim(), 'java -jar FullNode.jar -v'); + }); + + it('replaces stale bin symlinks without modifying their targets', async () => { + const cwd = createTempDir(); + const cacheDirectory = join(cwd, '.metamask', 'cache'); + const binDirectory = join(cwd, 'node_modules', '.bin'); + const fullNodeContent = 'fake fullnode jar'; + const javaArchiveContent = 'fake java archive'; + const staleTarget = join(cwd, 'stale-java-tron-target'); + + await mkdir(binDirectory, { recursive: true }); + writeFileSync(staleTarget, 'do not overwrite'); + symlinkSync(staleTarget, join(binDirectory, 'java-tron')); + + const result = await installJavaTron( + { + binDirectory, + cacheDirectory, + cwd, + fullNode: { + platforms: { + 'darwin-arm64': { + checksum: sha256(fullNodeContent), + url: 'https://example.test/FullNode-aarch64.jar', + }, + }, + }, + javaRuntime: { + platforms: { + 'darwin-arm64': { + checksum: sha256(javaArchiveContent), + url: 'https://example.test/java.tar.gz', + }, + }, + }, + platform: 'darwin-arm64', + }, + createDependencies({ fullNodeContent, javaArchiveContent }), + ); + + assert.equal(readFileSync(staleTarget, 'utf8'), 'do not overwrite'); + assert.equal(lstatSync(result.binaryPath).isSymbolicLink(), false); + }); + + it('reuses cached artifacts without downloading again', async () => { + const cwd = createTempDir(); + const cacheDirectory = join(cwd, '.metamask', 'cache'); + const binDirectory = join(cwd, 'node_modules', '.bin'); + const fullNodeContent = 'cached fullnode jar'; + const javaArchiveContent = 'cached java archive'; + + await installJavaTron( + { + binDirectory, + cacheDirectory, + cwd, + fullNode: { + platforms: { + 'linux-x64': { + checksum: sha256(fullNodeContent), + url: 'https://example.test/FullNode.jar', + }, + }, + version: 'cached-version', + }, + javaRuntime: { + platforms: { + 'linux-x64': { + checksum: sha256(javaArchiveContent), + url: 'https://example.test/java.tar.gz', + }, + }, + version: 'cached-java', + }, + platform: 'linux-x64', + }, + createDependencies({ fullNodeContent, javaArchiveContent }), + ); + + const result = await installJavaTron( + { + binDirectory, + cacheDirectory, + cwd, + fullNode: { + platforms: { + 'linux-x64': { + checksum: sha256(fullNodeContent), + url: 'https://example.test/FullNode.jar', + }, + }, + version: 'cached-version', + }, + javaRuntime: { + platforms: { + 'linux-x64': { + checksum: sha256(javaArchiveContent), + url: 'https://example.test/java.tar.gz', + }, + }, + version: 'cached-java', + }, + platform: 'linux-x64', + }, + { + downloadFile: async () => { + throw new Error('cache miss'); + }, + }, + ); + + assert.equal(result.cacheHit, true); + assert.equal(readFileSync(result.fullNodeJar, 'utf8'), fullNodeContent); + }); + + it('cleans only the java-tron-up cache namespace', async () => { + const cwd = createTempDir(); + const cacheDirectory = join(cwd, '.metamask', 'cache'); + await mkdir(join(cacheDirectory, 'java-tron-up', 'old'), { + recursive: true, + }); + await mkdir(join(cacheDirectory, 'foundryup', 'kept'), { + recursive: true, + }); + + await cleanJavaTronCache({ cacheDirectory, cwd }); + + assert.equal(existsSync(join(cacheDirectory, 'java-tron-up')), false); + assert.equal(existsSync(join(cacheDirectory, 'foundryup', 'kept')), true); + }); + + function createTempDir(): string { + const tempDir = mkdtempSync(join(tmpdir(), 'java-tron-up-test-')); + tempDirs.push(tempDir); + return tempDir; + } +}); + +function createDependencies({ + downloads = [], + fullNodeContent, + javaArchiveContent, +}: { + downloads?: { destination: string; url: string }[]; + fullNodeContent: string; + javaArchiveContent: string; +}): JavaTronInstallDependencies { + return { + downloadFile: async (url, destination): Promise => { + downloads.push({ destination, url }); + await writeFile( + destination, + url.includes('FullNode') ? fullNodeContent : javaArchiveContent, + ); + }, + extractArchive: async (_archivePath, destination): Promise => { + const javaBinary = join(destination, 'jdk', 'bin', 'java'); + await mkdir(join(destination, 'jdk', 'bin'), { recursive: true }); + await writeFile( + javaBinary, + '#!/bin/sh\nflag="$1"\njar="$2"\nshift 2\necho "java $flag $(basename "$jar") $*"\n', + ); + chmodSync(javaBinary, 0o755); + }, + }; +} + +function sha256(value: string): string { + return createHash('sha256').update(value).digest('hex'); +} diff --git a/packages/java-tron-up/src/install.ts b/packages/java-tron-up/src/install.ts new file mode 100644 index 00000000000..9887ae76c4f --- /dev/null +++ b/packages/java-tron-up/src/install.ts @@ -0,0 +1,457 @@ +/* eslint-disable import-x/no-nodejs-modules, no-restricted-globals */ +import { + cleanInstallerCache, + downloadFileFromUrl, + extractTarGzArchive, + getCacheKey, + getMetamaskCacheDirectory, + getPlatformKey, + installExecutableWrapper, + isDirectory, + isFile, + mergeArtifactConfig, + readCliValue, + readPackageJsonToolConfig, + requireCompletePlatformConfig, + resolvePlatformConfig, + verifyFileChecksum, +} from '@metamask/local-node-utils'; +import type { + ArtifactConfig, + ArtifactPlatformConfig, + InstallDependencies, +} from '@metamask/local-node-utils'; +import { existsSync, readdirSync, readFileSync } from 'node:fs'; +import { mkdir, rename, rm, writeFile } from 'node:fs/promises'; +import { homedir } from 'node:os'; +import { dirname, join } from 'node:path'; + +const JAVA_TRON_CACHE_NAMESPACE = 'java-tron-up'; +const FULL_NODE_CACHE_NAMESPACE = 'fullnode'; +const JAVA_CACHE_NAMESPACE = 'java'; + +export type JavaTronArtifactConfig = ArtifactConfig; + +export type JavaTronArtifactPlatformConfig = ArtifactPlatformConfig; + +export type JavaTronJavaRuntimeConfig = JavaTronArtifactConfig; + +export type JavaTronInstallOptions = { + binDirectory?: string; + cacheDirectory?: string; + cwd?: string; + fullNode?: JavaTronArtifactConfig; + javaBinary?: string; + javaRuntime?: JavaTronJavaRuntimeConfig; + platform?: string; +}; + +export type JavaTronInstallResult = { + binaryPath: string; + cacheHit: boolean; + checksum: string; + fullNodeJar: string; + javaBinary: string; + version?: string; +}; + +export type JavaTronInstallDependencies = InstallDependencies; + +export const JAVA_TRON_DEFAULT_FULL_NODE: JavaTronArtifactConfig = { + version: 'GreatVoyage-v4.8.1', + platforms: { + 'darwin-arm64': { + checksum: + '694431860ee76fc986ed495f9ec19f29ed3bd752a394386e7b3b9886b2292f59', + size: 202_460_186, + url: 'https://github.com/tronprotocol/java-tron/releases/download/GreatVoyage-v4.8.1/FullNode-aarch64.jar', + }, + 'darwin-x64': { + checksum: + '0e67b2fe75d7077750e73c4fa20725c6e9824657275d96be256ae5da681f9945', + size: 145_863_030, + url: 'https://github.com/tronprotocol/java-tron/releases/download/GreatVoyage-v4.8.1/FullNode.jar', + }, + 'linux-arm64': { + checksum: + '694431860ee76fc986ed495f9ec19f29ed3bd752a394386e7b3b9886b2292f59', + size: 202_460_186, + url: 'https://github.com/tronprotocol/java-tron/releases/download/GreatVoyage-v4.8.1/FullNode-aarch64.jar', + }, + 'linux-x64': { + checksum: + '0e67b2fe75d7077750e73c4fa20725c6e9824657275d96be256ae5da681f9945', + size: 145_863_030, + url: 'https://github.com/tronprotocol/java-tron/releases/download/GreatVoyage-v4.8.1/FullNode.jar', + }, + }, +}; + +export const JAVA_TRON_DEFAULT_JAVA_RUNTIME: JavaTronJavaRuntimeConfig = { + version: 'zulu-java8-x64-java17-arm64', + platforms: { + 'darwin-arm64': { + checksum: + 'f2bd5afaaaa4c23eb4bf2c78913c7eb7d3d228e44209ffec652fb72388a2f25c', + size: 192_646_000, + url: 'https://cdn.azul.com/zulu/bin/zulu17.66.19-ca-jdk17.0.19-macosx_aarch64.tar.gz', + }, + 'darwin-x64': { + checksum: + '4ac2efcae5d49afe1f2419ceb09bd3fb4af9df8411ab80184795960fc18fb5f6', + size: 41_346_500, + url: 'https://cdn.azul.com/zulu/bin/zulu8.94.0.17-ca-jre8.0.492-macosx_x64.tar.gz', + }, + 'linux-arm64': { + checksum: + 'c17d5657a673c0cfc099e9d803ed30498495894d7359fd1064d463093ed9850b', + size: 199_156_000, + url: 'https://cdn.azul.com/zulu/bin/zulu17.66.19-ca-jdk17.0.19-linux_aarch64.tar.gz', + }, + 'linux-x64': { + checksum: + '39abf1dc6798b5f6b8e9dca4e78994da316a3f990e444c2c483ea04f7f882cf2', + size: 42_504_400, + url: 'https://cdn.azul.com/zulu/bin/zulu8.94.0.17-ca-jre8.0.492-linux_x64.tar.gz', + }, + }, +}; + +export function getJavaTronCacheDirectory({ + cwd = process.cwd(), + homeDirectory = homedir(), +}: { + cwd?: string; + homeDirectory?: string; +} = {}): string { + return getMetamaskCacheDirectory({ + cwd, + homeDirectory, + toolName: JAVA_TRON_CACHE_NAMESPACE, + }); +} + +export function readJavaTronInstallOptionsFromPackageJson({ + cwd = process.cwd(), + packageJsonPath, +}: { + cwd?: string; + packageJsonPath?: string; +} = {}): JavaTronInstallOptions { + const config = readPackageJsonToolConfig({ + cwd, + packageJsonPath, + configKeys: ['javaTronUp', 'javatronup', 'java-tron-up'], + }); + const options: JavaTronInstallOptions = {}; + + if (typeof config.binDirectory === 'string') { + options.binDirectory = config.binDirectory; + } + if (typeof config.cacheDirectory === 'string') { + options.cacheDirectory = config.cacheDirectory; + } + if (config.fullNode && typeof config.fullNode === 'object') { + options.fullNode = config.fullNode as JavaTronArtifactConfig; + } + if (config.javaRuntime && typeof config.javaRuntime === 'object') { + options.javaRuntime = config.javaRuntime as JavaTronJavaRuntimeConfig; + } + + return options; +} + +export function parseJavaTronInstallCliOptions( + args: string[], +): JavaTronInstallOptions { + const options: JavaTronInstallOptions = {}; + const fullNode: Partial = {}; + const javaRuntime: Partial = {}; + + for (let index = 0; index < args.length; index += 1) { + const arg = args[index]; + const value = args[index + 1]; + + switch (arg) { + case '--bin-directory': + options.binDirectory = readCliValue(arg, value); + index += 1; + break; + case '--cache-directory': + options.cacheDirectory = readCliValue(arg, value); + index += 1; + break; + case '--full-node-checksum': + fullNode.checksum = readCliValue(arg, value); + index += 1; + break; + case '--full-node-url': + fullNode.url = readCliValue(arg, value); + index += 1; + break; + case '--java-runtime-checksum': + javaRuntime.checksum = readCliValue(arg, value); + index += 1; + break; + case '--java-runtime-url': + javaRuntime.url = readCliValue(arg, value); + index += 1; + break; + case '--platform': + options.platform = readCliValue(arg, value); + index += 1; + break; + default: + throw new Error(`Unknown java-tron-up install option: ${arg}`); + } + } + + if (fullNode.url || fullNode.checksum) { + options.fullNode = { + platforms: { + current: requireCompletePlatformConfig( + fullNode, + 'FullNode CLI options', + ), + }, + }; + } + + if (javaRuntime.url || javaRuntime.checksum) { + options.javaRuntime = { + platforms: { + current: requireCompletePlatformConfig( + javaRuntime, + 'Java runtime CLI options', + ), + }, + }; + } + + return options; +} + +export async function installJavaTron( + options: JavaTronInstallOptions = {}, + dependencies: JavaTronInstallDependencies = {}, +): Promise { + const cwd = options.cwd ?? process.cwd(); + const cacheDirectory = + options.cacheDirectory ?? getJavaTronCacheDirectory({ cwd }); + const binDirectory = + options.binDirectory ?? join(cwd, 'node_modules', '.bin'); + const platformKey = options.platform ?? getPlatformKey(); + const fullNode = mergeArtifactConfig( + JAVA_TRON_DEFAULT_FULL_NODE, + options.fullNode, + ); + const javaRuntime = mergeArtifactConfig( + JAVA_TRON_DEFAULT_JAVA_RUNTIME, + options.javaRuntime, + ); + const fullNodeConfig = resolvePlatformConfig( + fullNode, + platformKey, + 'java-tron FullNode', + ); + const javaBinary = + options.javaBinary ?? + (await installJavaRuntime( + { + cacheDirectory, + javaRuntime, + platform: platformKey, + }, + dependencies, + )); + const fullNodeResult = await installFullNodeJar( + { + cacheDirectory, + config: fullNodeConfig, + }, + dependencies, + ); + const binaryPath = await installExecutableWrapper({ + binDirectory, + commandName: 'java-tron', + executableArgs: ['-jar', fullNodeResult.fullNodeJar], + executablePath: javaBinary, + pathResolution: 'absolute', + }); + + return { + binaryPath, + cacheHit: fullNodeResult.cacheHit, + checksum: fullNodeConfig.checksum, + fullNodeJar: fullNodeResult.fullNodeJar, + javaBinary, + version: fullNode.version, + }; +} + +export async function cleanJavaTronCache( + options: Pick = {}, +): Promise { + const cwd = options.cwd ?? process.cwd(); + const cacheDirectory = + options.cacheDirectory ?? getJavaTronCacheDirectory({ cwd }); + + await cleanInstallerCache({ + cacheDirectory, + namespace: JAVA_TRON_CACHE_NAMESPACE, + }); +} + +export async function installJavaRuntime( + { + cacheDirectory = getJavaTronCacheDirectory(), + javaRuntime = JAVA_TRON_DEFAULT_JAVA_RUNTIME, + platform = getPlatformKey(), + }: { + cacheDirectory?: string; + javaRuntime?: JavaTronJavaRuntimeConfig; + platform?: string; + } = {}, + dependencies: JavaTronInstallDependencies = {}, +): Promise { + const platformConfig = resolvePlatformConfig( + javaRuntime, + platform, + 'java-tron Java runtime', + ); + const cacheKey = getCacheKey(platformConfig); + const cacheRoot = join( + cacheDirectory, + JAVA_TRON_CACHE_NAMESPACE, + JAVA_CACHE_NAMESPACE, + cacheKey, + ); + const sourceChecksumPath = join(cacheRoot, '.source-checksum'); + const existingJavaBinary = findJavaBinary(cacheRoot); + + if ( + existingJavaBinary && + existsSync(sourceChecksumPath) && + readFileSync(sourceChecksumPath, 'utf8') === platformConfig.checksum + ) { + return existingJavaBinary; + } + + const tempRoot = `${cacheRoot}.downloading`; + const archivePath = join(tempRoot, 'java-runtime.tar.gz'); + const downloadFile = dependencies.downloadFile ?? downloadFileFromUrl; + const extractArchive = dependencies.extractArchive ?? extractTarGzArchive; + + await rm(tempRoot, { force: true, recursive: true }); + await rm(cacheRoot, { force: true, recursive: true }); + await mkdir(tempRoot, { recursive: true }); + + try { + await downloadFile(platformConfig.url, archivePath); + await verifyFileChecksum( + archivePath, + platformConfig.checksum, + 'Downloaded Java runtime', + ); + await extractArchive(archivePath, tempRoot); + + const javaBinary = findJavaBinary(tempRoot); + if (!javaBinary) { + throw new Error( + `Java runtime archive for ${platform} did not contain bin/java.`, + ); + } + + await writeFile( + join(tempRoot, '.source-checksum'), + platformConfig.checksum, + ); + await mkdir(dirname(cacheRoot), { recursive: true }); + await rename(tempRoot, cacheRoot); + + return javaBinary.replace(tempRoot, cacheRoot); + } catch (error) { + await rm(tempRoot, { force: true, recursive: true }); + await rm(cacheRoot, { force: true, recursive: true }); + throw error; + } +} + +async function installFullNodeJar( + { + cacheDirectory, + config, + }: { + cacheDirectory: string; + config: JavaTronArtifactPlatformConfig; + }, + dependencies: JavaTronInstallDependencies, +): Promise<{ cacheHit: boolean; fullNodeJar: string }> { + const cacheKey = getCacheKey(config); + const cacheRoot = join( + cacheDirectory, + JAVA_TRON_CACHE_NAMESPACE, + FULL_NODE_CACHE_NAMESPACE, + cacheKey, + ); + const fullNodeJar = join(cacheRoot, 'FullNode.jar'); + + if (existsSync(fullNodeJar)) { + await verifyFileChecksum( + fullNodeJar, + config.checksum, + 'Cached java-tron FullNode', + ); + return { cacheHit: true, fullNodeJar }; + } + + const tempRoot = `${cacheRoot}.downloading`; + const tempFullNodeJar = join(tempRoot, 'FullNode.jar'); + const downloadFile = dependencies.downloadFile ?? downloadFileFromUrl; + + await rm(tempRoot, { force: true, recursive: true }); + await rm(cacheRoot, { force: true, recursive: true }); + await mkdir(tempRoot, { recursive: true }); + + try { + await downloadFile(config.url, tempFullNodeJar); + await verifyFileChecksum( + tempFullNodeJar, + config.checksum, + 'Downloaded java-tron FullNode', + ); + await mkdir(dirname(cacheRoot), { recursive: true }); + await rename(tempRoot, cacheRoot); + + return { cacheHit: false, fullNodeJar }; + } catch (error) { + await rm(tempRoot, { force: true, recursive: true }); + await rm(cacheRoot, { force: true, recursive: true }); + throw error; + } +} + +function findJavaBinary(root: string): string | undefined { + if (!isDirectory(root)) { + return undefined; + } + + const candidate = join(root, 'bin', 'java'); + if (isFile(candidate)) { + return candidate; + } + + for (const entry of readdirSync(root)) { + const child = join(root, entry); + if (!isDirectory(child)) { + continue; + } + + const found = findJavaBinary(child); + if (found) { + return found; + } + } + + return undefined; +} diff --git a/packages/java-tron-up/tsconfig.build.json b/packages/java-tron-up/tsconfig.build.json new file mode 100644 index 00000000000..82530a36ddc --- /dev/null +++ b/packages/java-tron-up/tsconfig.build.json @@ -0,0 +1,10 @@ +{ + "extends": "../../tsconfig.packages.build.json", + "compilerOptions": { + "baseUrl": "./", + "outDir": "./dist", + "rootDir": "./src" + }, + "references": [{ "path": "../local-node-utils/tsconfig.build.json" }], + "include": ["../../types", "./src"] +} diff --git a/packages/java-tron-up/tsconfig.json b/packages/java-tron-up/tsconfig.json new file mode 100644 index 00000000000..437bfaf93ab --- /dev/null +++ b/packages/java-tron-up/tsconfig.json @@ -0,0 +1,12 @@ +{ + "extends": "../../tsconfig.packages.json", + "compilerOptions": { + "baseUrl": "./" + }, + "references": [ + { + "path": "../local-node-utils" + } + ], + "include": ["../../types", "./src"] +} diff --git a/packages/java-tron-up/typedoc.json b/packages/java-tron-up/typedoc.json new file mode 100644 index 00000000000..c9da015dbf8 --- /dev/null +++ b/packages/java-tron-up/typedoc.json @@ -0,0 +1,7 @@ +{ + "entryPoints": ["./src/index.ts"], + "excludePrivate": true, + "hideGenerator": true, + "out": "docs", + "tsconfig": "./tsconfig.build.json" +} diff --git a/packages/json-rpc-engine/CHANGELOG.md b/packages/json-rpc-engine/CHANGELOG.md new file mode 100644 index 00000000000..17d285184b9 --- /dev/null +++ b/packages/json-rpc-engine/CHANGELOG.md @@ -0,0 +1,352 @@ +# 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] + +### Changed + +- Bump `@metamask/utils` from `^11.9.0` to `^11.11.0` ([#9074](https://github.com/MetaMask/core/pull/9074)) +- Bump `@metamask/messenger` from `^1.2.0` to `^2.0.0` ([#9392](https://github.com/MetaMask/core/pull/9392)) + +## [10.5.0] + +### Added + +- Export `assertExpectedHooks` utility ([#8747](https://github.com/MetaMask/core/pull/8747)) + +## [10.4.0] + +### Added + +- Add legacy `createOriginMiddleware` utility ([#8734](https://github.com/MetaMask/core/pull/8734)) + +## [10.3.0] + +### Added + +- Add `createOriginMiddleware` utility to `v2` ([#8522](https://github.com/MetaMask/core/pull/8522)) +- Add `createMethodMiddleware` utility to `v2` ([#8506](https://github.com/MetaMask/core/pull/8506), [#8583](https://github.com/MetaMask/core/pull/8583)) + - This utility allows JSON-RPC method implementations to use both the hooks pattern and the messenger. +- Add legacy `createMethodMiddleware` ([#8583](https://github.com/MetaMask/core/pull/8583)) + - Consolidates bespoke `makeMethodMiddlewareMaker` implementations from the MetaMask extension and mobile clients. + - Handlers may now declare `actionNames` and receive a delegated messenger as the sixth argument to `implementation`, mirroring the v2 `createMethodMiddleware`. + - Deprecated in favor of the v2 `createMethodMiddleware`. + +### Changed + +- Bump `@metamask/messenger` from `^1.1.1` to `^1.2.0` ([#8632](https://github.com/MetaMask/core/pull/8632)) + +## [10.2.4] + +### Fixed + +- `JsonRpcServer.handle()` no longer throws or produces unhandled promise rejections when the `onError` callback throws or rejects ([#8071](https://github.com/MetaMask/core/pull/8071)) + +## [10.2.3] + +### Fixed + +- Clone `JsonRpcEngineV2` return values to prevent returning frozen objects ([#8077](https://github.com/MetaMask/core/pull/8077)) + +## [10.2.2] + +### Fixed + +- Preserve `data.cause` in RPC errors when using JsonRpcEngine compatibility tools ([#7838](https://github.com/MetaMask/core/pull/7838)) + +## [10.2.1] + +### Changed + +- Upgrade `@metamask/utils` from `^11.8.1` to `^11.9.0` ([#7511](https://github.com/MetaMask/core/pull/7511)) + +### Fixed + +- Ensure non-object data in RPC errors is deserialized correctly when using JsonRpcEngine compatibility tools ([#7638](https://github.com/MetaMask/core/pull/7638)) + +## [10.2.0] + +### Added + +- Add `JsonRpcEngineV2` ([#6176](https://github.com/MetaMask/core/pull/6176), [#6971](https://github.com/MetaMask/core/pull/6971), [#6975](https://github.com/MetaMask/core/pull/6975), [#6990](https://github.com/MetaMask/core/pull/6990), [#6991](https://github.com/MetaMask/core/pull/6991), [#7032](https://github.com/MetaMask/core/pull/7032), [#7001](https://github.com/MetaMask/core/pull/7001), [#7061](https://github.com/MetaMask/core/pull/7061), [#7065](https://github.com/MetaMask/core/pull/7065)) + - This is a complete rewrite of `JsonRpcEngine`, intended to replace the original implementation. See the readme for details. + +## [10.1.1] + +### Changed + +- Bump `@metamask/utils` from `^11.8.0` to `^11.8.1` ([#6708](https://github.com/MetaMask/core/pull/6708)) + +## [10.1.0] + +### Changed + +- Bump `@metamask/utils` from `^11.2.0` to `^11.4.2` ([#6054](https://github.com/MetaMask/core/pull/6054)) +- Bump `@metamask/utils` from `^11.4.2` to `^11.8.0` ([#6588](https://github.com/MetaMask/core/pull/6588)) + +### Deprecated + +- `JsonRpcEngine` and related types ([#6176](https://github.com/MetaMask/core/pull/6176)) + - To be replaced by `JsonRpcEngineV2`. + +## [10.0.3] + +### Changed + +- Bump `@metamask/utils` from `^11.0.1` to `^11.1.0` ([#5223](https://github.com/MetaMask/core/pull/5223)) + +## [10.0.2] + +### Changed + +- Bump `@metamask/utils` from `^10.0.0` to `^11.0.1` ([#5080](https://github.com/MetaMask/core/pull/5080)) +- Bump `@metamask/rpc-errors` from `^7.0.0` to `^7.0.2` ([#5080](https://github.com/MetaMask/core/pull/5080)) + +## [10.0.1] + +### Changed + +- Bump `@metamask/utils` from `^9.1.0` to `^10.0.0` ([#4831](https://github.com/MetaMask/core/pull/4831)) + +## [10.0.0] + +### Fixed + +- **BREAKING**: Bump `@metamask/rpc-errors` from `^6.3.1` to `^7.0.0` ([#4773](https://github.com/MetaMask/core/pull/4773)) + - This modifies the top-level error message for serialized internal JSON-RPC errors to include the actual error message, instead of the generic `Internal JSON-RPC Error.` string. + +## [9.0.3] + +### Changed + +- Bump TypeScript from `~5.0.4` to `~5.2.2` ([#4576](https://github.com/MetaMask/core/pull/4576), [#4584](https://github.com/MetaMask/core/pull/4584)) + +### Fixed + +- Produce and export ESM-compatible TypeScript type declaration files in addition to CommonJS-compatible declaration files ([#4648](https://github.com/MetaMask/core/pull/4648)) + - Previously, this package shipped with only one variant of type declaration + files, and these files were only CommonJS-compatible, and the `exports` + field in `package.json` linked to these files. This is an anti-pattern and + was rightfully flagged by the + ["Are the Types Wrong?"](https://arethetypeswrong.github.io/) tool as + ["masquerading as CJS"](https://github.com/arethetypeswrong/arethetypeswrong.github.io/blob/main/docs/problems/FalseCJS.md). + All of the ATTW checks now pass. +- Remove chunk files. ([#4648](https://github.com/MetaMask/core/pull/4648)) + - Previously, the build tool we used to generate JavaScript files extracted + common code to "chunk" files. While this was intended to make this package + more tree-shakeable, it also made debugging more difficult for our + development teams. These chunk files are no longer present. + +## [9.0.2] + +### Changed + +- Bump TypeScript version to `~5.0.4` and set `moduleResolution` option to `Node16` ([#3645](https://github.com/MetaMask/core/pull/3645)) +- Bump `@metamask/utils` from `^9.0.0` to `^9.1.0` ([#4529](https://github.com/MetaMask/core/pull/4529)) + +## [9.0.1] + +### Changed + +- Bump `@metamask/rpc-errors` from `6.2.1` to `^6.3.1` ([#4516](https://github.com/MetaMask/core/pull/4516)) +- Bump `@metamask/utils` from `^8.3.0` to `^9.0.0` ([#4516](https://github.com/MetaMask/core/pull/4516)) + +## [9.0.0] + +### Changed + +- **BREAKING:** Bump minimum Node version to 18.18 ([#3611](https://github.com/MetaMask/core/pull/3611)) + +## [8.0.2] + +### Changed + +- Widen the `error` parameter of `JsonRpcEngineReturnHandler`, `JsonRpcEngineEndCallback` function types from `JsonRpcEngineCallbackError` to `unknown` ([#3906](https://github.com/MetaMask/core/pull/3906)) +- Narrow the function parameters `req`, `callback` of the last overload of the `handle` method of the `JsonRpcEngine` class ([#3906](https://github.com/MetaMask/core/pull/3906)) + - This applies to the overload with two function parameters, one required and one optional, and no generic parameters. + - `req` is narrowed from `unknown` to `(JsonRpcRequest | JsonRpcNotification)[] | JsonRpcRequest | JsonRpcNotification`. + - `callback` is narrowed from `any` to `(error: unknown, response: never) => void`. +- Bump TypeScript version to `~4.9.5` ([#4084](https://github.com/MetaMask/core/pull/4084)) + +## [8.0.1] + +### Fixed + +- Fix `types` field in `package.json` ([#4047](https://github.com/MetaMask/core/pull/4047)) + +## [8.0.0] + +### Added + +- **BREAKING**: Add ESM build ([#3998](https://github.com/MetaMask/core/pull/3998)) + - It's no longer possible to import files from `./dist` directly. + +## [7.3.3] + +### Changed + +- Bump `@metamask/rpc-errors` to `^6.2.1` ([#3954](https://github.com/MetaMask/core/pull/3954)) + +## [7.3.2] + +### Changed + +- Bump `@metamask/utils` to `^8.3.0` ([#3769](https://github.com/MetaMask/core/pull/3769)) + +## [7.3.1] + +### Changed + +- There are no consumer-facing changes to this package. This version is a part of a synchronized release across all packages in our monorepo. + +## [7.3.0] + +### Added + +- Migrate `@metamask/json-rpc-engine` into the core monorepo ([#1895](https://github.com/MetaMask/core/pull/1895)) + +### Changed + +- Bump `@metamask/utils` from `^8.1.0` to `^8.2.0` ([#1895](https://github.com/MetaMask/core/pull/1895)) +- Bump `@metamask/rpc-errors` from `^6.0.0` to `^6.1.0` ([#1882](https://github.com/MetaMask/core/pull/1882)) +- Bump `@metamask/auto-changelog` from `3.4.2` to `3.4.3` ([#1997](https://github.com/MetaMask/core/pull/1997)) + +## [7.2.0] + +### Added + +- Applied eslint rules from core monorepo ([#172](https://github.com/MetaMask/json-rpc-engine/pull/172)) + +## [7.1.1] + +### Changed + +- Bumped `@metamask/utils` from `^5.0.2` to `^8.1.0` [#158](https://github.com/MetaMask/json-rpc-engine/pull/158) ([#162](https://github.com/MetaMask/json-rpc-engine/pull/162)) +- Bumped `@metamask/rpc-errors` from `^5.0.0` to `^6.0.0` ([#162](https://github.com/MetaMask/json-rpc-engine/pull/162)) + +## [7.1.0] + +### Changed + +- Bumped `@metamask/safe-event-emitter` from `^2.0.0` to `^3.0.0` ([#148](https://github.com/MetaMask/json-rpc-engine/pull/148)) +- Bumped `@metamask/utils` from `^5.0.1` to `^5.0.2` ([#151](https://github.com/MetaMask/json-rpc-engine/pull/151)) + +### Fixed + +- Fixed handling of empty batch array in requests ([#153](https://github.com/MetaMask/json-rpc-engine/pull/153)) + +## [7.0.0] + +### Added + +- Added JSON-RPC notification handling ([#104](https://github.com/MetaMask/json-rpc-engine/pull/104)) +- Added `destroy` method ([#106](https://github.com/MetaMask/json-rpc-engine/pull/106)) + +### Changed + +- **BREAKING:** Require a minimum Node version of 16 ([#139](https://github.com/MetaMask/json-rpc-engine/pull/139)) +- **BREAKING:** Use `@metamask/utils` types ([#105](https://github.com/MetaMask/json-rpc-engine/pull/105)) + - The JSON-RPC engine and all middleware now use `@metamask/utils` JSON-RPC types +- **(BREAKING)** Return a `null` instead of `undefined` response `id` for malformed request objects ([#91](https://github.com/MetaMask/json-rpc-engine/pull/91)) + - This is very unlikely to be breaking in practice, but the behavior could have been relied on. +- Change package name to `@metamask/json-rpc-engine` ([#139](https://github.com/MetaMask/json-rpc-engine/pull/139)) +- Use `@metamask/rpc-errors` ([#138](https://github.com/MetaMask/json-rpc-engine/pull/138)) + +## [6.1.0] - 2020-11-20 + +### Added + +- Add `PendingJsonRpcResponse` interface for use in middleware ([#75](https://github.com/MetaMask/json-rpc-engine/pull/75)) + +### Changed + +- Use `async`/`await` and `try`/`catch` instead of Promise methods everywhere ([#74](https://github.com/MetaMask/json-rpc-engine/pull/74)) + - Consumers may notice improved stack traces on certain platforms. + +## [6.0.0] - 2020-11-19 + +### Added + +- Add docstrings for public `JsonRpcEngine` methods ([#70](https://github.com/MetaMask/json-rpc-engine/pull/70)) + +### Changed + +- **(BREAKING)** Refactor exports ([#69](https://github.com/MetaMask/json-rpc-engine/pull/69)) + - All exports are now named, and available via the package entry point. + - All default exports have been removed. +- **(BREAKING)** Convert `asMiddleware` to instance method ([#69](https://github.com/MetaMask/json-rpc-engine/pull/69)) + - The `asMiddleware` export has been removed. +- **(BREAKING)** Add runtime typechecks to `JsonRpcEngine.handle()`, and error responses if they fail ([#70](https://github.com/MetaMask/json-rpc-engine/pull/70)) + - Requests will now error if: + - The request is not a plain object, or if the `method` property is not a `string`. Empty strings are allowed. + - A `next` middleware callback is called with a truthy, non-function parameter. +- Migrate to TypeScript ([#69](https://github.com/MetaMask/json-rpc-engine/pull/69)) +- Hopefully improve stack traces by removing uses of `Promise.then` and `.catch` internally ([#70](https://github.com/MetaMask/json-rpc-engine/pull/70)) +- Make some internal `JsonRpcEngine` methods `static` ([#71](https://github.com/MetaMask/json-rpc-engine/pull/71)) + +## [5.4.0] - 2020-11-07 + +### Changed + +- Make the TypeScript types not terrible ([#66](https://github.com/MetaMask/json-rpc-engine/pull/66), [#67](https://github.com/MetaMask/json-rpc-engine/pull/67)) + +## [5.3.0] - 2020-07-30 + +### Changed + +- Response object errors no longer include a `stack` property + +## [5.2.0] - 2020-07-24 + +### Added + +- Promise signatures for `engine.handle` ([#55](https://github.com/MetaMask/json-rpc-engine/pull/55)) + - So, in addition to `engine.handle(request, callback)`, you can do e.g. `await engine.handle(request)`. + +### Changed + +- Remove `async` and `promise-to-callback` dependencies + - These dependencies were used internally for middleware flow control. + They have been replaced with Promises and native `async`/`await`, which means that some operations are _no longer_ eagerly executed. + This change may affect consumers that depend on the eager execution of middleware _during_ request processing, _outside of_ middleware functions and request handlers. + - In general, it is a bad practice to work with state that depends on middleware execution, while the middleware are executing. + +[Unreleased]: https://github.com/MetaMask/core/compare/@metamask/json-rpc-engine@10.5.0...HEAD +[10.5.0]: https://github.com/MetaMask/core/compare/@metamask/json-rpc-engine@10.4.0...@metamask/json-rpc-engine@10.5.0 +[10.4.0]: https://github.com/MetaMask/core/compare/@metamask/json-rpc-engine@10.3.0...@metamask/json-rpc-engine@10.4.0 +[10.3.0]: https://github.com/MetaMask/core/compare/@metamask/json-rpc-engine@10.2.4...@metamask/json-rpc-engine@10.3.0 +[10.2.4]: https://github.com/MetaMask/core/compare/@metamask/json-rpc-engine@10.2.3...@metamask/json-rpc-engine@10.2.4 +[10.2.3]: https://github.com/MetaMask/core/compare/@metamask/json-rpc-engine@10.2.2...@metamask/json-rpc-engine@10.2.3 +[10.2.2]: https://github.com/MetaMask/core/compare/@metamask/json-rpc-engine@10.2.1...@metamask/json-rpc-engine@10.2.2 +[10.2.1]: https://github.com/MetaMask/core/compare/@metamask/json-rpc-engine@10.2.0...@metamask/json-rpc-engine@10.2.1 +[10.2.0]: https://github.com/MetaMask/core/compare/@metamask/json-rpc-engine@10.1.1...@metamask/json-rpc-engine@10.2.0 +[10.1.1]: https://github.com/MetaMask/core/compare/@metamask/json-rpc-engine@10.1.0...@metamask/json-rpc-engine@10.1.1 +[10.1.0]: https://github.com/MetaMask/core/compare/@metamask/json-rpc-engine@10.0.3...@metamask/json-rpc-engine@10.1.0 +[10.0.3]: https://github.com/MetaMask/core/compare/@metamask/json-rpc-engine@10.0.2...@metamask/json-rpc-engine@10.0.3 +[10.0.2]: https://github.com/MetaMask/core/compare/@metamask/json-rpc-engine@10.0.1...@metamask/json-rpc-engine@10.0.2 +[10.0.1]: https://github.com/MetaMask/core/compare/@metamask/json-rpc-engine@10.0.0...@metamask/json-rpc-engine@10.0.1 +[10.0.0]: https://github.com/MetaMask/core/compare/@metamask/json-rpc-engine@9.0.3...@metamask/json-rpc-engine@10.0.0 +[9.0.3]: https://github.com/MetaMask/core/compare/@metamask/json-rpc-engine@9.0.2...@metamask/json-rpc-engine@9.0.3 +[9.0.2]: https://github.com/MetaMask/core/compare/@metamask/json-rpc-engine@9.0.1...@metamask/json-rpc-engine@9.0.2 +[9.0.1]: https://github.com/MetaMask/core/compare/@metamask/json-rpc-engine@9.0.0...@metamask/json-rpc-engine@9.0.1 +[9.0.0]: https://github.com/MetaMask/core/compare/@metamask/json-rpc-engine@8.0.2...@metamask/json-rpc-engine@9.0.0 +[8.0.2]: https://github.com/MetaMask/core/compare/@metamask/json-rpc-engine@8.0.1...@metamask/json-rpc-engine@8.0.2 +[8.0.1]: https://github.com/MetaMask/core/compare/@metamask/json-rpc-engine@8.0.0...@metamask/json-rpc-engine@8.0.1 +[8.0.0]: https://github.com/MetaMask/core/compare/@metamask/json-rpc-engine@7.3.3...@metamask/json-rpc-engine@8.0.0 +[7.3.3]: https://github.com/MetaMask/core/compare/@metamask/json-rpc-engine@7.3.2...@metamask/json-rpc-engine@7.3.3 +[7.3.2]: https://github.com/MetaMask/core/compare/@metamask/json-rpc-engine@7.3.1...@metamask/json-rpc-engine@7.3.2 +[7.3.1]: https://github.com/MetaMask/core/compare/@metamask/json-rpc-engine@7.3.0...@metamask/json-rpc-engine@7.3.1 +[7.3.0]: https://github.com/MetaMask/core/compare/@metamask/json-rpc-engine@7.2.0...@metamask/json-rpc-engine@7.3.0 +[7.2.0]: https://github.com/MetaMask/core/compare/@metamask/json-rpc-engine@7.1.1...@metamask/json-rpc-engine@7.2.0 +[7.1.1]: https://github.com/MetaMask/core/compare/@metamask/json-rpc-engine@7.1.0...@metamask/json-rpc-engine@7.1.1 +[7.1.0]: https://github.com/MetaMask/core/compare/@metamask/json-rpc-engine@7.0.0...@metamask/json-rpc-engine@7.1.0 +[7.0.0]: https://github.com/MetaMask/core/compare/json-rpc-engine@6.1.0...@metamask/json-rpc-engine@7.0.0 +[6.1.0]: https://github.com/MetaMask/core/compare/json-rpc-engine@6.0.0...json-rpc-engine@6.1.0 +[6.0.0]: https://github.com/MetaMask/core/compare/json-rpc-engine@5.4.0...json-rpc-engine@6.0.0 +[5.4.0]: https://github.com/MetaMask/core/compare/json-rpc-engine@5.3.0...json-rpc-engine@5.4.0 +[5.3.0]: https://github.com/MetaMask/core/compare/json-rpc-engine@5.2.0...json-rpc-engine@5.3.0 +[5.2.0]: https://github.com/MetaMask/core/releases/tag/json-rpc-engine@5.2.0 diff --git a/packages/json-rpc-engine/LICENSE b/packages/json-rpc-engine/LICENSE new file mode 100644 index 00000000000..52357a65dae --- /dev/null +++ b/packages/json-rpc-engine/LICENSE @@ -0,0 +1,15 @@ +ISC License + +Copyright (c) 2022 MetaMask + +Permission to use, copy, modify, and/or distribute this software for any +purpose with or without fee is hereby granted, provided that the above +copyright notice and this permission notice appear in all copies. + +THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES +WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF +MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR +ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES +WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN +ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF +OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. diff --git a/packages/json-rpc-engine/README.md b/packages/json-rpc-engine/README.md new file mode 100644 index 00000000000..37edded5ed1 --- /dev/null +++ b/packages/json-rpc-engine/README.md @@ -0,0 +1,844 @@ +# `@metamask/json-rpc-engine` + +A tool for processing JSON-RPC requests and responses. + +## Installation + +`yarn add @metamask/json-rpc-engine` + +or + +`npm install @metamask/json-rpc-engine` + +## Usage + +> [!TIP] +> For the legacy `JsonRpcEngine`, see [its readme](./src/README.md). +> +> For how to migrate from the legacy `JsonRpcEngine` to `JsonRpcEngineV2`, see [Migrating from `JsonRpcEngine`](#migrating-from-jsonrpcengine). + +```ts +import { JsonRpcEngineV2 } from '@metamask/json-rpc-engine/v2'; +import type { + Json, + JsonRpcMiddleware, + MiddlewareContext, +} from '@metamask/json-rpc-engine/v2'; + +type Middleware = JsonRpcMiddleware< + JsonRpcRequest, + Json, + MiddlewareContext<{ hello: string }> +>; + +// Engines are instantiated using the `create()` factory method as opposed to +// the constructor, which is private. +const engine = JsonRpcEngineV2.create({ + middleware: [ + ({ request, next, context }) => { + if (request.method === 'hello') { + context.set('hello', 'world'); + return next(); + } + return null; + }, + ({ context }) => context.assertGet('hello'), + ], +}); +``` + +Requests are handled asynchronously, stepping down the middleware stack until complete. + +```ts +const request = { id: '1', jsonrpc: '2.0', method: 'hello' }; + +try { + const result = await engine.handle(request); + // Do something with the result +} catch (error) { + // Handle the error +} +``` + +Alternatively, pass the engine to a `JsonRpcServer`, which coerces raw request +objects into well-formed requests, and handles error serialization: + +```ts +const server = new JsonRpcServer({ engine, onError }); +const request = { id: '1', jsonrpc: '2.0', method: 'hello' }; + +// server.handle() never throws +const response = await server.handle(request); +if ('result' in response) { + // Handle result +} else { + // Handle error +} + +const notification = { jsonrpc: '2.0', method: 'hello' }; + +// Always returns undefined for notifications +await server.handle(notification); +``` + +### Legacy compatibility + +Use `asLegacyMiddleware()` to convert a `JsonRpcEngineV2` or one or more V2 middleware into a legacy middleware. + +#### Context propagation + +In keeping with the conventions of the legacy engine, non-JSON-RPC string properties of the `context` will be +copied over to the request once the V2 engine is done with the request. _Note that **only `string` keys** of +the `context` will be copied over._ + +#### Converting a V2 engine + +```ts +import { + asLegacyMiddleware, + JsonRpcEngineV2, +} from '@metamask/json-rpc-engine/v2'; +import { JsonRpcEngine } from '@metamask/json-rpc-engine'; + +const legacyEngine = new JsonRpcEngine(); + +const v2Engine = JsonRpcEngineV2.create({ + middleware: [ + // ... + ], +}); + +legacyEngine.push(asLegacyMiddleware(v2Engine)); +``` + +#### Converting V2 middleware + +```ts +import { + asLegacyMiddleware, + type JsonRpcMiddleware, +} from '@metamask/json-rpc-engine/v2'; +import { JsonRpcEngine } from '@metamask/json-rpc-engine'; + +// Convert a single V2 middleware +const middleware1: JsonRpcMiddleware = ({ request }) => { + /* ... */ +}; + +const legacyEngine = new JsonRpcEngine(); +legacyEngine.push(asLegacyMiddleware(middleware1)); + +// Convert multiple V2 middlewares at once +const middleware2: JsonRpcMiddleware = ({ context, next }) => { + /* ... */ +}; + +const legacyEngine2 = new JsonRpcEngine(); +legacyEngine2.push(asLegacyMiddleware(middleware1, middleware2)); +``` + +### Middleware + +Middleware functions can be sync or async. +They receive a `MiddlewareParams` object containing: + +- `request` + - The JSON-RPC request or notification (readonly) +- `context` + - An append-only `Map` for passing data between middleware +- `next` + - Function that calls the next middleware in the stack and returns its result (if any) + +Here's a basic example: + +```ts +const engine = JsonRpcEngineV2.create({ + middleware: [ + ({ next, context }) => { + context.set('foo', 'bar'); + // Proceed to the next middleware and return its result + return next(); + }, + async ({ request, context }) => { + await doSomething(request, context.get('foo')); + // Return a result wihout calling next() to end the request + return 42; + }, + ], +}); +``` + +In practice, middleware functions are often defined apart from the engine in which +they are used. Middleware defined in this manner must use the `JsonRpcMiddleware` type: + +```ts +export const permissionMiddleware: JsonRpcMiddleware< + JsonRpcRequest, + Json, // The result + MiddlewareContext<{ user: User; permissions: Permissions }> +> = async ({ request, context, next }) => { + const user = context.assertGet('user'); + const permissions = await getUserPermissions(user.id); + context.set('permissions', permissions); + return next(); +}; +``` + +Middleware can specify a return type, however `next()` always returns the widest possible +type based on the type of the `request`. See [Requests vs. notifications](#requests-vs-notifications) +for more details. + +Creating a useful `JsonRpcEngineV2` requires composing differently typed middleware together. +See [Engine composition](#engine-composition) for how to +accomplish this in the same or a set of composed engines. + +### Requests vs. notifications + +JSON-RPC requests come in two flavors: + +- [Requests](https://www.jsonrpc.org/specification#request_object), i.e. request objects _with_ an `id` +- [Notifications](https://www.jsonrpc.org/specification#notification), i.e. request objects _without_ an `id` + +`next()` returns `Json` for requests, `void` for notifications, and `Json | void` if the type of the request +object is not known. + +For requests, one of the engine's middleware must "end" the request by returning a non-`undefined` result, or `.handle()` +will throw an error: + +```ts +const engine = JsonRpcEngineV2.create({ + middleware: [ + () => { + if (Math.random() > 0.5) { + return 42; + } + return undefined; + }, + ], +}); + +const request = { jsonrpc: '2.0', id: '1', method: 'hello' }; + +try { + const result = await engine.handle(request); + console.log(result); // 42 +} catch (error) { + console.error(error); // Nothing ended request: { ... } +} +``` + +For notifications, on the other hand, one of the engine's middleware must return `undefined` to end the request, +and any non-`undefined` return values will cause an error to be thrown: + +```ts +const notification = { jsonrpc: '2.0', method: 'hello' }; + +try { + const result = await engine.handle(notification); + console.log(result); // undefined +} catch (error) { + console.error(error); // Result returned for notification: { ... } +} +``` + +If your middleware may be passed both requests and notifications, +use the `isRequest` or `isNotification` utilities to determine what to do: + +> [!NOTE] +> Middleware that handle both requests and notifications—i.e. the `JsonRpcCall` type— +> must ensure that their return values are valid for incoming requests at runtime. +> There is no compile time type error if such a middleware returns e.g. a string +> for a notification. + +```ts +import { + isRequest, + isNotification, + JsonRpcEngineV2, +} from '@metamask/json-rpc-engine/v2'; + +const engine = JsonRpcEngineV2.create({ + middleware: [ + async ({ request, next }) => { + if (isRequest(request) && request.method === 'everything') { + return 42; + } + return next(); + }, + ({ request }) => { + if (isNotification(request)) { + console.log(`Received notification: ${request.method}`); + return undefined; + } + return null; + }, + ], +}); +``` + +### Request modification + +The `request` object is immutable. +Attempting to directly modify it will throw an error. +Middleware can modify the `method` and `params` properties +by passing a new request object to `next()`: + +```ts +const engine = JsonRpcEngineV2.create({ + middleware: [ + ({ request, next }) => { + // Modify the request for subsequent middleware + // The new request object will be deeply frozen + return next({ + ...request, + method: 'modified_method', + params: [1, 2, 3], + }); + }, + ({ request }) => { + // This middleware receives the modified request + return request.params[0]; + }, + ], +}); +``` + +Modifying the `jsonrpc` or `id` properties is not allowed, and will cause +an error: + +```ts +const engine = JsonRpcEngineV2.create({ + middleware: [ + ({ request, next }) => { + return next({ + ...request, + // Modifying either property will cause an error + jsonrpc: '3.0', + id: 'foo', + }); + }, + () => 42, + ], +}); + +// Error: Middleware attempted to modify readonly property... +await engine.handle(anyRequest); +``` + +### Result handling + +Middleware can observe the result by awaiting `next()`: + +```ts +const engine = JsonRpcEngineV2.create({ + middleware: [ + async ({ request, next }) => { + const startTime = Date.now(); + const result = await next(); + const duration = Date.now() - startTime; + + // Log the request duration + console.log( + `Request ${request.method} producing ${result} took ${duration}ms`, + ); + + // By returning `undefined`, the result will be forwarded unmodified to earlier + // middleware. + }, + ({ request }) => { + return 'Hello, World!'; + }, + ], +}); +``` + +Like the `request`, the `result` is also immutable. +Middleware can update the result by returning a new one. + +```ts +const engine = JsonRpcEngineV2.create({ + middleware: [ + async ({ request, next }) => { + const result = await next(); + + // Add metadata to the result + if (result && typeof result === 'object') { + // The new result will also be deeply frozen + return { + ...result, + metadata: { + processedAt: new Date().toISOString(), + requestId: request.id, + }, + }; + } + + // Returning the unmodified result is equivalent to returning `undefined` + return result; + }, + ({ request }) => { + // Initial result + return { message: 'Hello, World!' }; + }, + ], +}); + +const result = await engine.handle({ + id: '1', + jsonrpc: '2.0', + method: 'hello', +}); +console.log(result); +// { +// message: 'Hello, World!', +// metadata: { +// processedAt: '2024-01-01T12:00:00.000Z', +// requestId: 1 +// } +// } +``` + +### The `MiddlewareContext` + +Use the `context` to share data between middleware: + +```ts +const engine = JsonRpcEngineV2.create({ + middleware: [ + async ({ context, next }) => { + context.set('user', { id: '123', name: 'Alice' }); + return next(); + }, + async ({ context, next }) => { + // context.assertGet() throws if the value does not exist + const user = context.assertGet('user') as { id: string; name: string }; + context.set('permissions', await getUserPermissions(user.id)); + return next(); + }, + ({ context }) => { + const user = context.get('user'); + const permissions = context.get('permissions'); + return { user, permissions }; + }, + ], +}); +``` + +The `context` supports `PropertyKey` keys, i.e. strings, numbers, and symbols. +To prevent accidental naming collisions, existing keys must be deleted before they can be +overwritten via `set()`. +Context values are not frozen, and objects can be mutated as normal: + +```ts +const engine = JsonRpcEngineV2.create({ + middleware: [ + async ({ context, next }) => { + context.set('user', { id: '123', name: 'Alice' }); + return next(); + }, + async ({ context, next }) => { + const user = context.assertGet<{ id: string; name: string }>('user'); + user.name = 'Bob'; + return next(); + }, + // ... + ], +}); +``` + +#### Passing the context to `handle()` + +You can pass a `MiddlewareContext` instance directly to `handle()`: + +```ts +const context = new MiddlewareContext(); +context.set('foo', 'bar'); +const result = await engine.handle( + { id: '1', jsonrpc: '2.0', method: 'hello' }, + { context }, +); +console.log(result); // 'bar' +``` + +You can also pass a plain object as a shorthand for a `MiddlewareContext` instance: + +```ts +const context = { foo: 'bar' }; +const result = await engine.handle( + { id: '1', jsonrpc: '2.0', method: 'hello' }, + { context }, +); +console.log(result); // 'bar' +``` + +This works the same way for `JsonRpcServer.handle()`. + +#### Constraining context keys and values + +The context exposes a generic parameter `KeyValues`, which determines the keys and values +a context instance supports: + +```ts +const context = new MiddlewareContext(); +context.set('foo', 'bar'); +context.get('foo'); // 'bar' +context.get('fizz'); // undefined +``` + +By default, `KeyValues` is `Record`. However, any object type can be +specified, effectively turning the context into a strongly typed `Map`: + +```ts +const context = new MiddlewareContext<{ foo: string }>([['foo', 'bar']]); +context.get('foo'); // 'bar' +context.get('fizz'); // Type error +``` + +The context is itself exposed as the third generic parameter of the `JsonRpcMiddleware` type. +See [Instrumenting middleware pipelines](#instrumenting-middleware-pipelines) for how to +compose different context types together. + +### Error handling + +Errors in middleware are propagated up the call stack: + +```ts +const engine = JsonRpcEngineV2.create({ + middleware: [ + ({ next }) => { + return next(); + }, + ({ request, next }) => { + if (request.method === 'restricted') { + throw new Error('Method not allowed'); + } + return 'Success'; + }, + ], +}); + +try { + await engine.handle({ id: '1', jsonrpc: '2.0', method: 'restricted' }); +} catch (error) { + console.error('Request failed:', error.message); +} +``` + +If your middleware awaits `next()`, it can handle errors using `try`/`catch`: + +```ts +const engine = JsonRpcEngineV2.create({ + middleware: [ + ({ request, next }) => { + try { + return await next(); + } catch (error) { + console.error(`Request ${request.method} errored:`, error); + return 42; + } + }, + ({ request }) => { + if (!isValid(request)) { + throw new Error('Invalid request'); + } + }, + ], +}); + +const result = await engine.handle({ + id: '1', + jsonrpc: '2.0', + method: 'hello', +}); +console.log('Result:', result); +// Request hello errored: Error: Invalid request +// Result: 42 +``` + +#### Internal errors + +The engine throws `JsonRpcEngineError` values when its invariants are violated, e.g. a middleware returns +a result value for a notification. +If you want to reliably detect these cases, use `JsonRpcEngineError.isInstance(error)`, which works across +versions of this package in the same realm. + +### Engine composition + +#### Instrumenting middleware pipelines + +As discussed in the [Middleware](#middleware) section, middleware are often defined apart from the +engine in which they are used. To be used within the same engine, a set of middleware must have +compatible types. Specifically, all middleware must: + +- Handle either `JsonRpcRequest`, `JsonRpcNotification`, or both (i.e. `JsonRpcCall`) + - It is okay to mix `JsonRpcCall` middleware with either `JsonRpcRequest` or `JsonRpcNotification` + middleware, as long as the latter two are not mixed together. +- Return valid results for the overall request type +- Specify mutually inclusive context types + - The context types may be the same, partially intersecting, or completely disjoint + so long as they are not mutually exclusive. + +For example, the following middleware are compatible: + +```ts +const middleware1: JsonRpcMiddleware< + JsonRpcRequest, + Json, + MiddlewareContext<{ foo: string }> +> = /* ... */; + +const middleware2: JsonRpcMiddleware< + JsonRpcRequest, + Json, + MiddlewareContext<{ bar: string }> +> = /* ... */; + +const middleware3: JsonRpcMiddleware< + JsonRpcRequest, + { foo: string; bar: string }, + MiddlewareContext<{ foo: string; bar: string; baz: number }> +> = /* ... */; + +// ✅ OK +const engine = JsonRpcEngineV2.create({ + middleware: [middleware1, middleware2, middleware3], +}); +``` + +The following middleware are incompatible due to mismatched request types: + +> [!WARNING] +> Providing `JsonRpcRequest`- and `JsonRpcNotification`-only middleware to the same engine is +> generally unsound and should be avoided. However, doing so will **not** cause a type error, +> and it is the programmer's responsibility to prevent it from happening. + +```ts +const middleware1: JsonRpcMiddleware = /* ... */; + +const middleware2: JsonRpcMiddleware = /* ... */; + +// ⚠️ Attempting to call engine.handle() will NOT cause a type error, but it +// may cause errors at runtime and should be avoided. +const engine = JsonRpcEngineV2.create({ + middleware: [middleware1, middleware2], +}); +``` + +Finally, these middleware are incompatible due to mismatched context types: + +```ts +const middleware1: JsonRpcMiddleware< + JsonRpcRequest, + Json, + MiddlewareContext<{ foo: string }> +> = /* ... */; + +const middleware2: JsonRpcMiddleware< + JsonRpcRequest, + Json, + MiddlewareContext<{ foo: number }> +> = /* ... */; + +// ❌ The type of the engine is `never`; accessing any property will cause a type error +const engine = JsonRpcEngineV2.create({ + middleware: [middleware1, middleware2], +}); +``` + +#### `asMiddleware()` + +Engines can be nested by converting them to middleware using `asMiddleware()`: + +```ts +const subEngine = JsonRpcEngineV2.create({ + middleware: [ + ({ request }) => { + return 'Sub-engine result'; + }, + ], +}); + +const mainEngine = JsonRpcEngineV2.create({ + middleware: [ + subEngine.asMiddleware(), + ({ request, next }) => { + const subResult = await next(); + return `Main engine processed: ${subResult}`; + }, + ], +}); +``` + +Engines used as middleware may return `undefined` for requests, but only when +used as middleware: + +```ts +const loggingEngine = JsonRpcEngineV2.create({ + middleware: [ + ({ request, next }) => { + console.log('Observed request:', request.method); + }, + ], +}); + +const mainEngine = JsonRpcEngineV2.create({ + middleware: [ + loggingEngine.asMiddleware(), + ({ request }) => { + return 'success'; + }, + ], +}); + +const request = { id: '1', jsonrpc: '2.0', method: 'hello' }; +const result = await mainEngine.handle(request); +console.log('Result:', result); +// Observed request: hello +// Result: success + +// ATTN: This will throw "Nothing ended request" +const result2 = await loggingEngine.handle(request); +``` + +#### Calling `handle()` in a middleware + +You can also compose different engines together by calling `handle(request, context)` +on a different engine in a middleware. Keep in mind that, unlike when using `asMiddleware()`, +these "sub"-engines must return results for requests. + +This method of composition can be useful to instrument request- and notification-only +middleware pipelines: + +```ts +const requestEngine = JsonRpcEngineV2.create({ + middleware: [ + /* Request-only middleware */ + ], +}); + +const notificationEngine = JsonRpcEngineV2.create({ + middleware: [ + /* Notification-only middleware */ + ], +}); + +const orchestratorEngine = JsonRpcEngineV2.create({ + middleware: [ + ({ request, context }) => + isRequest(request) + ? requestEngine.handle(request, { context }) + : notificationEngine.handle(request as JsonRpcNotification, { + context, + }), + ], +}); +``` + +### `JsonRpcServer` + +The `JsonRpcServer` wraps a `JsonRpcEngineV2` to provide JSON-RPC 2.0 compliance and error handling. It coerces raw request objects into well-formed requests and handles error serialization. + +```ts +import { JsonRpcEngineV2, JsonRpcServer } from '@metamask/json-rpc-engine/v2'; + +const engine = new JsonRpcEngine({ middleware }); + +const server = new JsonRpcServer({ + engine, + // onError receives the raw error, before it is coerced into a JSON-RPC error. + onError: (error) => console.error('Server error:', error), +}); + +// server.handle() never throws - all errors are handled by onError +const response = await server.handle({ + id: '1', + jsonrpc: '2.0', + method: 'hello', +}); +if ('result' in response) { + // Handle successful response +} else { + // Handle error response +} + +// Notifications always return undefined +const notification = { jsonrpc: '2.0', method: 'hello' }; +await server.handle(notification); // Returns undefined +``` + +The server accepts any object with a `method` property, coercing it into a request or notification +depending on the presence or absence of the `id` property, respectively. +Except for the `id`, all present JSON-RPC 2.0 fields are validated for spec conformance. +The `id` is replaced during request processing with an internal, trusted value, although the +original `id` is attached to the response before it is returned. + +Response objects are returned for requests, and contain +the `result` in case of success and `error` in case of failure. +`undefined` is always returned for notifications. + +Errors thrown by the underlying engine are always passed to `onError` unmodified. +If the request is not a notification, the error is subsequently serialized and attached +to the response object via the `error` property. + +> [!WARNING] +> It is possible to construct a `JsonRpcServer` the only accepts either requests or notifications, +> but not both. If you do so, it is your responsibility to ensure that the server is only used with the +> appropriate request objects. `JsonRpcServer.handle()` will not type error at compile time if you attempt to pass +> it an unsupported request object. + +## Migrating from `JsonRpcEngine` + +Migrating from the legacy `JsonRpcEngine` to `JsonRpcEngineV2` is generally straightforward. +For an example, see [MetaMask/core#7065](https://github.com/MetaMask/core/pull/7065). +There are a couple of pitfalls to watch out for: + +### `MiddlewareContext` vs. non-JSON-RPC string properties + +The legacy `JsonRpcEngine` allowed non-JSON-RPC string properties to be attached to the request object. +`JsonRpcEngineV2` does not allow this, and instead you must use the `context` object to pass data between middleware. +While it's easy to migrate a middleware function body to use the `context` object, injected dependencies +of the middleware function may need to be updated. + +For example if you have a legacy middleware implementation like this: + +```ts +const createFooMiddleware = + (processFoo: (req: JsonRpcRequest) => string) => (req, res, next, end) => { + if (req.method === 'foo') { + const fooResult = processFoo(req); // May expect non-JSON-RPC properties on the request object! + res.result = fooResult; + end(); + } else { + next(); + } + }; +``` + +`processFoo` may expect non-JSON-RPC properties on the request object. To fully migrate the middleware, you need to +investigate the implementation of `processFoo` and potentially update it to accept a `context` object. + +### Frozen requests + +In the legacy `JsonRpcEngine`, request and response objects are mutable and shared between all middleware. +In `JsonRpcEngineV2`, response objects are not visible to middleware, and request objects are deeply frozen. +If injected dependencies mutate the request object, it will cause an error. + +For example, if you have a legacy middleware implementation like this: + +```ts +const createBarMiddleware = + (processBar: (req: JsonRpcRequest) => string) => (req, _res, next, _end) => { + if (req.method === 'bar') { + processBar(req); // May mutate the request object! + } + next(); + }; +``` + +`processBar` may mutate the request object. To fully migrate the middleware, you need to +investigate the implementation of `processBar` and update it to not directly mutate the request object. +See [Request modification](#request-modification) for how to modify the request object in `JsonRpcEngineV2`. + +## 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/json-rpc-engine/jest.config.js b/packages/json-rpc-engine/jest.config.js new file mode 100644 index 00000000000..d1d26575e17 --- /dev/null +++ b/packages/json-rpc-engine/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: 99.6, + functions: 100, + lines: 100, + statements: 100, + }, + }, +}); diff --git a/packages/json-rpc-engine/package.json b/packages/json-rpc-engine/package.json new file mode 100644 index 00000000000..591502a5689 --- /dev/null +++ b/packages/json-rpc-engine/package.json @@ -0,0 +1,99 @@ +{ + "name": "@metamask/json-rpc-engine", + "version": "10.5.0", + "description": "A tool for processing JSON-RPC messages", + "keywords": [ + "Ethereum", + "MetaMask" + ], + "homepage": "https://github.com/MetaMask/core/tree/main/packages/json-rpc-engine#readme", + "bugs": { + "url": "https://github.com/MetaMask/core/issues" + }, + "license": "ISC", + "repository": { + "type": "git", + "url": "https://github.com/MetaMask/core.git" + }, + "directories": { + "test": "test" + }, + "files": [ + "dist/", + "v2.js" + ], + "sideEffects": false, + "main": "./dist/index.cjs", + "types": "./dist/index.d.cts", + "exports": { + ".": { + "import": { + "types": "./dist/index.d.mts", + "default": "./dist/index.mjs" + }, + "require": { + "types": "./dist/index.d.cts", + "default": "./dist/index.cjs" + } + }, + "./v2": { + "import": { + "types": "./dist/v2/index.d.mts", + "default": "./dist/v2/index.mjs" + }, + "require": { + "types": "./dist/v2/index.d.cts", + "default": "./dist/v2/index.cjs" + } + }, + "./package.json": "./package.json" + }, + "publishConfig": { + "access": "public", + "registry": "https://registry.npmjs.org/" + }, + "scripts": { + "build": "ts-bridge --project tsconfig.build.json --verbose --clean --no-references", + "build:all": "ts-bridge --project tsconfig.build.json --verbose --clean", + "build:docs": "typedoc", + "changelog:update": "../../scripts/update-changelog.sh @metamask/json-rpc-engine --tag-prefix-before-package-rename json-rpc-engine@ --version-before-package-rename 6.1.0", + "changelog:validate": "../../scripts/validate-changelog.sh @metamask/json-rpc-engine --tag-prefix-before-package-rename json-rpc-engine@ --version-before-package-rename 6.1.0", + "lint:tsconfigs": "tsx ../../scripts/lint-tsconfigs/lint-tsconfigs.mts", + "lint:tsconfigs:fix": "tsx ../../scripts/lint-tsconfigs/lint-tsconfigs.mts --fix", + "since-latest-release": "../../scripts/since-latest-release.sh", + "test": "NODE_OPTIONS=--experimental-vm-modules jest --reporters=jest-silent-reporter", + "test:clean": "NODE_OPTIONS=--experimental-vm-modules jest --clearCache", + "test:verbose": "NODE_OPTIONS=--experimental-vm-modules jest --verbose", + "test:watch": "NODE_OPTIONS=--experimental-vm-modules jest --watch" + }, + "dependencies": { + "@metamask/messenger": "^2.0.0", + "@metamask/rpc-errors": "^7.0.2", + "@metamask/safe-event-emitter": "^3.0.0", + "@metamask/utils": "^11.11.0", + "@types/deep-freeze-strict": "^1.1.0", + "deep-freeze-strict": "^1.1.1", + "klona": "^2.0.6" + }, + "devDependencies": { + "@lavamoat/allow-scripts": "^3.0.4", + "@lavamoat/preinstall-always-fail": "^2.1.0", + "@metamask/auto-changelog": "^6.1.0", + "@ts-bridge/cli": "^0.6.4", + "@types/jest": "^30.0.0", + "deepmerge": "^4.2.2", + "jest": "^30.4.2", + "ts-jest": "^29.4.11", + "tsx": "^4.20.5", + "typedoc": "^0.25.13", + "typescript": "~5.3.3" + }, + "engines": { + "node": "^18.18 || >=20" + }, + "lavamoat": { + "allowScripts": { + "@lavamoat/preinstall-always-fail": false + } + } +} diff --git a/packages/json-rpc-engine/src/JsonRpcEngine.test.ts b/packages/json-rpc-engine/src/JsonRpcEngine.test.ts new file mode 100644 index 00000000000..13f3eee66c4 --- /dev/null +++ b/packages/json-rpc-engine/src/JsonRpcEngine.test.ts @@ -0,0 +1,762 @@ +import { rpcErrors } from '@metamask/rpc-errors'; +import type { JsonRpcParams, Json } from '@metamask/utils'; +import { + assertIsJsonRpcSuccess, + assertIsJsonRpcFailure, + isJsonRpcFailure, + isJsonRpcSuccess, +} from '@metamask/utils'; + +import type { JsonRpcMiddleware } from './index.js'; +import { JsonRpcEngine } from './index.js'; + +const jsonrpc = '2.0' as const; + +describe('JsonRpcEngine', () => { + it('handle: throws on truthy, non-function callback', () => { + const engine = new JsonRpcEngine(); + // TODO: Replace `any` with type + // eslint-disable-next-line @typescript-eslint/no-explicit-any + expect(() => engine.handle({} as any, 'foo' as any)).toThrow( + '"callback" must be a function if provided.', + ); + }); + + it('handle: returns error for invalid request value', async () => { + const engine = new JsonRpcEngine(); + // TODO: Replace `any` with type + // eslint-disable-next-line @typescript-eslint/no-explicit-any + let response: any = await engine.handle(null as any); + expect(response.error.code).toBe(-32600); + expect(response.result).toBeUndefined(); + + // TODO: Replace `any` with type + // eslint-disable-next-line @typescript-eslint/no-explicit-any + response = await engine.handle(true as any); + expect(response.error.code).toBe(-32600); + expect(response.result).toBeUndefined(); + }); + + it('handle: returns error for invalid request method', async () => { + const engine = new JsonRpcEngine(); + // TODO: Replace `any` with type + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const response: any = await engine.handle({ id: 1, method: null } as any); + + expect(response.error.code).toBe(-32600); + expect(response.result).toBeUndefined(); + }); + + it('handle: returns error for invalid request method with nullish id', async () => { + const engine = new JsonRpcEngine(); + // TODO: Replace `any` with type + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const response: any = await engine.handle({ + id: undefined, + method: null, + // TODO: Replace `any` with type + // eslint-disable-next-line @typescript-eslint/no-explicit-any + } as any); + + expect(response.error.code).toBe(-32600); + expect(response.result).toBeUndefined(); + }); + + it('handle: returns undefined for malformed notifications', async () => { + const middleware = jest.fn(); + const notificationHandler = jest.fn(); + const engine = new JsonRpcEngine({ notificationHandler }); + engine.push(middleware); + + expect( + // TODO: Replace `any` with type + // eslint-disable-next-line @typescript-eslint/no-explicit-any + await engine.handle({ jsonrpc, method: true } as any), + ).toBeUndefined(); + expect(notificationHandler).not.toHaveBeenCalled(); + expect(middleware).not.toHaveBeenCalled(); + }); + + it('handle: treats notifications as requests when no notification handler is specified', async () => { + const middleware = jest + .fn() + .mockImplementation((_request, response, _next, end) => { + response.result = 'bar'; + end(); + }); + + const engine = new JsonRpcEngine(); + engine.push(middleware); + + expect(await engine.handle({ jsonrpc, method: 'foo' })).toStrictEqual({ + jsonrpc, + result: 'bar', + id: undefined, + }); + expect(middleware).toHaveBeenCalledTimes(1); + }); + + it('handle: forwards notifications to handlers', async () => { + const middleware = jest.fn(); + const notificationHandler = jest.fn(); + const engine = new JsonRpcEngine({ notificationHandler }); + engine.push(middleware); + + expect(await engine.handle({ jsonrpc, method: 'foo' })).toBeUndefined(); + expect(notificationHandler).toHaveBeenCalledTimes(1); + expect(notificationHandler).toHaveBeenCalledWith({ + jsonrpc, + method: 'foo', + }); + expect(middleware).not.toHaveBeenCalled(); + }); + + it('handle: re-throws errors from notification handlers (async)', async () => { + const notificationHandler = jest.fn().mockImplementation(() => { + throw new Error('baz'); + }); + const engine = new JsonRpcEngine({ notificationHandler }); + + await expect(engine.handle({ jsonrpc, method: 'foo' })).rejects.toThrow( + new Error('baz'), + ); + expect(notificationHandler).toHaveBeenCalledTimes(1); + expect(notificationHandler).toHaveBeenCalledWith({ + jsonrpc, + method: 'foo', + }); + }); + + it('handle: re-throws errors from notification handlers (callback)', async () => { + const notificationHandler = jest.fn().mockImplementation(() => { + throw new Error('baz'); + }); + const engine = new JsonRpcEngine({ notificationHandler }); + + await new Promise((resolve) => { + engine.handle({ jsonrpc, method: 'foo' }, (error, response) => { + expect(error).toStrictEqual(new Error('baz')); + expect(response).toBeUndefined(); + + expect(notificationHandler).toHaveBeenCalledTimes(1); + expect(notificationHandler).toHaveBeenCalledWith({ + jsonrpc, + method: 'foo', + }); + resolve(); + }); + }); + }); + + it('handle: basic middleware test 1', async () => { + const engine = new JsonRpcEngine(); + + engine.push(function (_request, response, _next, end) { + response.result = 42; + end(); + }); + + const payload = { id: 1, jsonrpc, method: 'hello' }; + + await new Promise((resolve) => { + engine.handle(payload, function (error, response) { + expect(error).toBeNull(); + assertIsJsonRpcSuccess(response); + expect(response.result).toBe(42); + resolve(); + }); + }); + }); + + it('handle: basic middleware test 2', async () => { + const engine = new JsonRpcEngine(); + + engine.push(function (request, response, _next, end) { + request.method = 'banana'; + response.result = 42; + end(); + }); + + const payload = { id: 1, jsonrpc, method: 'hello' }; + + await new Promise((resolve) => { + engine.handle(payload, function (error, response) { + expect(error).toBeNull(); + assertIsJsonRpcSuccess(response); + expect(response.result).toBe(42); + expect(payload.method).toBe('hello'); + resolve(); + }); + }); + }); + + it('handle (async): basic middleware test', async () => { + const engine = new JsonRpcEngine(); + + engine.push(function (_request, response, _next, end) { + response.result = 42; + end(); + }); + + const payload = { id: 1, jsonrpc, method: 'hello' }; + + const response = await engine.handle(payload); + assertIsJsonRpcSuccess(response); + expect(response.result).toBe(42); + }); + + it('allow null result', async () => { + const engine = new JsonRpcEngine(); + + engine.push(function (_request, response, _next, end) { + response.result = null; + end(); + }); + + const payload = { id: 1, jsonrpc, method: 'hello' }; + + await new Promise((resolve) => { + engine.handle(payload, function (error, response) { + expect(error).toBeNull(); + assertIsJsonRpcSuccess(response); + expect(response.result).toBeNull(); + resolve(); + }); + }); + }); + + it('interacting middleware test', async () => { + const engine = new JsonRpcEngine(); + + // TODO: Replace `any` with type + // eslint-disable-next-line @typescript-eslint/no-explicit-any + engine.push(function (request: any, _response, next, _end) { + request.resultShouldBe = 42; + next(); + }); + + // TODO: Replace `any` with type + // eslint-disable-next-line @typescript-eslint/no-explicit-any + engine.push(function (request: any, response, _next, end) { + response.result = request.resultShouldBe; + end(); + }); + + const payload = { id: 1, jsonrpc, method: 'hello' }; + + await new Promise((resolve) => { + engine.handle(payload, function (error, response) { + expect(error).toBeNull(); + assertIsJsonRpcSuccess(response); + expect(response.result).toBe(42); + resolve(); + }); + }); + }); + + it('middleware ending request before all middlewares applied', async () => { + const engine = new JsonRpcEngine(); + + engine.push(function (_request, response, _next, end) { + response.result = 42; + end(); + }); + + engine.push(function (_request, _response, _next, _end) { + throw new Error('Test should have ended already.'); + }); + + const payload = { id: 1, jsonrpc, method: 'hello' }; + + await new Promise((resolve) => { + engine.handle(payload, function (error, response) { + expect(error).toBeNull(); + assertIsJsonRpcSuccess(response); + expect(response.result).toBe(42); + resolve(); + }); + }); + }); + + it('erroring middleware test: end(error)', async () => { + const engine = new JsonRpcEngine(); + + engine.push(function (_request, _response, _next, end) { + end(new Error('no bueno')); + }); + + const payload = { id: 1, jsonrpc, method: 'hello' }; + + await new Promise((resolve) => { + engine.handle(payload, function (error, response) { + expect(error).toBeDefined(); + expect(response).toBeDefined(); + assertIsJsonRpcFailure(response); + expect(isJsonRpcSuccess(response)).toBe(false); + resolve(); + }); + }); + }); + + it('erroring middleware test: response.error -> next()', async () => { + const engine = new JsonRpcEngine(); + + engine.push(function (_request, response, next, _end) { + response.error = rpcErrors.internal({ message: 'foobar' }); + next(); + }); + + const payload = { id: 1, jsonrpc, method: 'hello' }; + + await new Promise((resolve) => { + engine.handle(payload, function (error, response) { + expect(error).toBeDefined(); + expect(response).toBeDefined(); + assertIsJsonRpcFailure(response); + expect(isJsonRpcSuccess(response)).toBe(false); + resolve(); + }); + }); + }); + + it('erroring middleware test: response.error -> end()', async () => { + const engine = new JsonRpcEngine(); + + engine.push(function (_request, response, _next, end) { + response.error = rpcErrors.internal({ message: 'foobar' }); + end(); + }); + + const payload = { id: 1, jsonrpc, method: 'hello' }; + + await new Promise((resolve) => { + engine.handle(payload, function (error, response) { + expect(error).toBeDefined(); + expect(response).toBeDefined(); + expect(isJsonRpcFailure(response)).toBe(true); + expect(isJsonRpcSuccess(response)).toBe(false); + resolve(); + }); + }); + }); + + it('erroring middleware test: non-function passsed to next()', async () => { + const engine = new JsonRpcEngine(); + + engine.push(function (_request, _response, next, _end) { + // TODO: Replace `any` with type + // eslint-disable-next-line @typescript-eslint/no-explicit-any + next(true as any); + }); + + const payload = { id: 1, jsonrpc, method: 'hello' }; + + await new Promise((resolve) => { + engine.handle(payload, function (error, response) { + expect(error).toBeDefined(); + expect(response).toBeDefined(); + assertIsJsonRpcFailure(response); + expect(response.error.code).toBe(-32603); + expect( + response.error.message.startsWith( + 'JsonRpcEngine: "next" return handlers must be functions.', + ), + ).toBe(true); + expect(isJsonRpcSuccess(response)).toBe(false); + resolve(); + }); + }); + }); + + it('empty middleware test', async () => { + const engine = new JsonRpcEngine(); + + const payload = { id: 1, jsonrpc, method: 'hello' }; + + await new Promise((resolve) => { + engine.handle(payload, function (error, _response) { + expect(error).toBeDefined(); + resolve(); + }); + }); + }); + + it('handle: empty batch', async () => { + const engine = new JsonRpcEngine(); + // TODO: Replace `any` with type + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const emptyBatch = [] as any; + + await new Promise((resolve) => { + // TODO: Replace `any` with type + // eslint-disable-next-line @typescript-eslint/no-explicit-any + engine.handle(emptyBatch, function (error, response: any) { + expect(error).toBeNull(); + expect(response).toBeInstanceOf(Array); + expect(response).toHaveLength(1); + expect( + response[0].error.message.startsWith( + 'Request batch must contain plain objects. Received an empty array', + ), + ).toBe(true); + expect(isJsonRpcSuccess(response[0])).toBe(false); + resolve(); + }); + }); + }); + + it('handle: empty batch (async signature)', async () => { + const engine = new JsonRpcEngine(); + // TODO: Replace `any` with type + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const emptyBatch = [] as any; + + // TODO: Replace `any` with type + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const response: any = await engine.handle(emptyBatch); + expect(response).toBeInstanceOf(Array); + expect(response).toHaveLength(1); + expect( + response[0].error.message.startsWith( + 'Request batch must contain plain objects. Received an empty array', + ), + ).toBe(true); + expect(isJsonRpcSuccess(response[0])).toBe(false); + }); + + it('handle: batch payloads', async () => { + const engine = new JsonRpcEngine(); + + engine.push(function (request, response, _next, end) { + // Separate handling for the 4th request. + if (request.id === 4) { + delete response.result; + response.error = rpcErrors.internal({ message: 'foobar' }); + return end(response.error); + } + response.result = request.id; + return end(); + }); + + const payloadA = { id: 1, jsonrpc, method: 'hello' }; + const payloadB = { id: 2, jsonrpc, method: 'hello' }; + const payloadC = { id: 3, jsonrpc, method: 'hello' }; + const payloadD = { id: 4, jsonrpc, method: 'hello' }; + const payloadE = { id: 5, jsonrpc, method: 'hello' }; + const payload = [payloadA, payloadB, payloadC, payloadD, payloadE]; + + await new Promise((resolve) => { + // TODO: Replace `any` with type + // eslint-disable-next-line @typescript-eslint/no-explicit-any + engine.handle(payload, function (error, response: any) { + expect(error).toBeNull(); + expect(response).toBeInstanceOf(Array); + expect(response[0].result).toBe(1); + expect(response[1].result).toBe(2); + expect(response[2].result).toBe(3); + expect(isJsonRpcSuccess(response[3])).toBe(false); + expect(response[3].error.code).toBe(-32603); + expect(response[4].result).toBe(5); + resolve(); + }); + }); + }); + + it('handle: batch payloads (async signature)', async () => { + const engine = new JsonRpcEngine(); + + engine.push(function (request, response, _next, end) { + // Separate handling for the 4th request. + if (request.id === 4) { + delete response.result; + response.error = rpcErrors.internal({ message: 'foobar' }); + return end(response.error); + } + response.result = request.id; + return end(); + }); + + const payloadA = { id: 1, jsonrpc, method: 'hello' }; + const payloadB = { id: 2, jsonrpc, method: 'hello' }; + const payloadC = { id: 3, jsonrpc, method: 'hello' }; + const payloadD = { id: 4, jsonrpc, method: 'hello' }; + const payloadE = { id: 5, jsonrpc, method: 'hello' }; + const payload = [payloadA, payloadB, payloadC, payloadD, payloadE]; + + // TODO: Replace `any` with type + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const response: any = await engine.handle(payload); + expect(response).toBeInstanceOf(Array); + expect(response[0].result).toBe(1); + expect(response[1].result).toBe(2); + expect(response[2].result).toBe(3); + expect(isJsonRpcSuccess(response[3])).toBe(false); + expect(response[3].error.code).toBe(-32603); + expect(response[4].result).toBe(5); + }); + + it('handle: batch payload with bad request object', async () => { + const engine = new JsonRpcEngine(); + + engine.push(function (request, response, _next, end) { + response.result = request.id; + return end(); + }); + + const payloadA = { id: 1, jsonrpc, method: 'hello' }; + const payloadB = true; + const payloadC = { id: 3, jsonrpc, method: 'hello' }; + const payload = [payloadA, payloadB, payloadC]; + + // TODO: Replace `any` with type + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const response: any = await engine.handle(payload as any); + expect(response).toBeInstanceOf(Array); + expect(response[0].result).toBe(1); + expect(isJsonRpcSuccess(response[1])).toBe(false); + expect(response[1].error.code).toBe(-32600); + expect(response[2].result).toBe(3); + }); + + it('basic notifications', async () => { + const engine = new JsonRpcEngine(); + + await new Promise((resolve) => { + engine.once('notification', (notification) => { + expect(notification.method).toBe('test_notif'); + resolve(); + }); + engine.emit('notification', { jsonrpc, method: 'test_notif' }); + }); + }); + + it('return handlers test', async () => { + const engine = new JsonRpcEngine(); + + // TODO: Replace `any` with type + // eslint-disable-next-line @typescript-eslint/no-explicit-any + engine.push(function (_request, response: any, next, _end) { + next(function (callback) { + response.sawReturnHandler = true; + callback(); + }); + }); + + engine.push(function (_request, response, _next, end) { + response.result = true; + end(); + }); + + const payload = { id: 1, jsonrpc, method: 'hello' }; + + await new Promise((resolve) => { + // TODO: Replace `any` with type + // eslint-disable-next-line @typescript-eslint/no-explicit-any + engine.handle(payload, function (error, response: any) { + expect(error).toBeNull(); + expect(response).toBeDefined(); + expect(response.sawReturnHandler).toBe(true); + resolve(); + }); + }); + }); + + it('return order of events', async () => { + const engine = new JsonRpcEngine(); + + const events: string[] = []; + + engine.push(function (_request, _response, next, _end) { + events.push('1-next'); + next(function (callback) { + events.push('1-return'); + callback(); + }); + }); + + engine.push(function (_request, _response, next, _end) { + events.push('2-next'); + next(function (callback) { + events.push('2-return'); + callback(); + }); + }); + + engine.push(function (_request, response, _next, end) { + events.push('3-end'); + response.result = true; + end(); + }); + + const payload = { id: 1, jsonrpc, method: 'hello' }; + + await new Promise((resolve) => { + engine.handle(payload, function (error, _response) { + expect(error).toBeNull(); + expect(events[0]).toBe('1-next'); + expect(events[1]).toBe('2-next'); + expect(events[2]).toBe('3-end'); + expect(events[3]).toBe('2-return'); + expect(events[4]).toBe('1-return'); + resolve(); + }); + }); + }); + + it('calls back next handler even if error', async () => { + const engine = new JsonRpcEngine(); + + let sawNextReturnHandlerCalled = false; + + engine.push(function (_request, _response, next, _end) { + next(function (callback) { + sawNextReturnHandlerCalled = true; + callback(); + }); + }); + + engine.push(function (_request, _response, _next, end) { + end(new Error('boom')); + }); + + const payload = { id: 1, jsonrpc, method: 'hello' }; + + await new Promise((resolve) => { + engine.handle(payload, (error, _response) => { + expect(error).toBeDefined(); + expect(sawNextReturnHandlerCalled).toBe(true); + resolve(); + }); + }); + }); + + it('handles error in next handler', async () => { + const engine = new JsonRpcEngine(); + + engine.push(function (_request, _response, next, _end) { + next(function (_cb) { + throw new Error('foo'); + }); + }); + + engine.push(function (_request, response, _next, end) { + response.result = 42; + end(); + }); + + const payload = { id: 1, jsonrpc, method: 'hello' }; + + await new Promise((resolve) => { + // TODO: Replace `any` with type + // eslint-disable-next-line @typescript-eslint/no-explicit-any + engine.handle(payload, (error: any, _response) => { + expect(error).toBeDefined(); + expect(error.message).toBe('foo'); + resolve(); + }); + }); + }); + + it('handles failure to end request', async () => { + const engine = new JsonRpcEngine(); + + engine.push(function (_request, response, next, _end) { + response.result = 42; + next(); + }); + + const payload = { id: 1, jsonrpc, method: 'hello' }; + + await new Promise((resolve) => { + // TODO: Replace `any` with type + // eslint-disable-next-line @typescript-eslint/no-explicit-any + engine.handle(payload, (error: any, response) => { + expect( + error.message.startsWith('JsonRpcEngine: Nothing ended request:'), + ).toBe(true); + expect(isJsonRpcSuccess(response)).toBe(false); + resolve(); + }); + }); + }); + + it('handles batch request processing error', async () => { + const engine = new JsonRpcEngine(); + jest + // TODO: Replace `any` with type + // eslint-disable-next-line @typescript-eslint/no-explicit-any + .spyOn(engine as any, '_promiseHandle') + .mockRejectedValue(new Error('foo')); + + await new Promise((resolve) => { + // TODO: Replace `any` with type + // eslint-disable-next-line @typescript-eslint/no-explicit-any + engine.handle([{}] as any, (error: any) => { + expect(error.message).toBe('foo'); + resolve(); + }); + }); + }); + + it('handles batch request processing error (async)', async () => { + const engine = new JsonRpcEngine(); + jest + // TODO: Replace `any` with type + // eslint-disable-next-line @typescript-eslint/no-explicit-any + .spyOn(engine as any, '_promiseHandle') + .mockRejectedValue(new Error('foo')); + + // TODO: Replace `any` with type + // eslint-disable-next-line @typescript-eslint/no-explicit-any + await expect(engine.handle([{}] as any)).rejects.toThrow('foo'); + }); + + describe('destroy', () => { + const destroyedError = new Error( + 'This engine is destroyed and can no longer be used.', + ); + + it('prevents the engine from being used', async () => { + const engine = new JsonRpcEngine(); + engine.destroy(); + + await expect(async () => engine.handle([])).rejects.toThrow( + destroyedError, + ); + + expect(() => engine.asMiddleware()).toThrow(destroyedError); + expect(() => engine.push(() => undefined)).toThrow(destroyedError); + }); + + it('destroying is idempotent', () => { + const engine = new JsonRpcEngine(); + engine.destroy(); + expect(async () => engine.destroy()).not.toThrow(); + expect(() => engine.asMiddleware()).toThrow(destroyedError); + }); + + it('calls the destroy method of middleware functions', async () => { + const engine = new JsonRpcEngine(); + + engine.push((_request, response, next, _end) => { + response.result = 42; + next(); + }); + + const destroyMock = jest.fn(); + const destroyableMiddleware: JsonRpcMiddleware = ( + _request, + _response, + _next, + end, + ) => { + end(); + }; + destroyableMiddleware.destroy = destroyMock; + engine.push(destroyableMiddleware); + + engine.destroy(); + expect(destroyMock).toHaveBeenCalledTimes(1); + }); + }); +}); diff --git a/packages/json-rpc-engine/src/JsonRpcEngine.ts b/packages/json-rpc-engine/src/JsonRpcEngine.ts new file mode 100644 index 00000000000..12bee48d0d1 --- /dev/null +++ b/packages/json-rpc-engine/src/JsonRpcEngine.ts @@ -0,0 +1,666 @@ +import { errorCodes, JsonRpcError, serializeError } from '@metamask/rpc-errors'; +import SafeEventEmitter from '@metamask/safe-event-emitter'; +import type { + JsonRpcError as SerializedJsonRpcError, + JsonRpcRequest, + JsonRpcResponse, + JsonRpcNotification, + Json, + JsonRpcParams, + PendingJsonRpcResponse, +} from '@metamask/utils'; +import { + hasProperty, + isJsonRpcNotification, + isJsonRpcRequest, +} from '@metamask/utils'; + +import { stringify } from './v2/utils.js'; + +export type JsonRpcEngineCallbackError = Error | SerializedJsonRpcError | null; + +/** + * @deprecated Use `JsonRpcEngineV2` and its corresponding types instead. + */ +export type JsonRpcEngineReturnHandler = ( + done: (error?: unknown) => void, +) => void; + +/** + * @deprecated Use `JsonRpcEngineV2` and its corresponding types instead. + */ +export type JsonRpcEngineNextCallback = ( + returnHandlerCallback?: JsonRpcEngineReturnHandler, +) => void; + +/** + * @deprecated Use `JsonRpcEngineV2` and its corresponding types instead. + */ +export type JsonRpcEngineEndCallback = (error?: unknown) => void; + +/** + * @deprecated Use `JsonRpcEngineV2` and its corresponding types instead. + */ +export type JsonRpcMiddleware< + Params extends JsonRpcParams, + Result extends Json, +> = { + ( + req: JsonRpcRequest, + res: PendingJsonRpcResponse, + next: JsonRpcEngineNextCallback, + end: JsonRpcEngineEndCallback, + ): void; + destroy?: () => void | Promise; +}; + +const DESTROYED_ERROR_MESSAGE = + 'This engine is destroyed and can no longer be used.'; + +/** + * @deprecated Use `JsonRpcEngineV2` and its corresponding types instead. + */ +export type JsonRpcNotificationHandler = ( + notification: JsonRpcNotification, +) => void | Promise; + +type JsonRpcEngineArgs = { + /** + * A function for handling JSON-RPC notifications. A JSON-RPC notification is + * defined as a JSON-RPC request without an `id` property. If this option is + * _not_ provided, notifications will be treated the same as requests. If this + * option _is_ provided, notifications will be passed to the handler + * function without touching the engine's middleware stack. + * + * This function should not throw or reject. + */ + notificationHandler?: JsonRpcNotificationHandler; +}; + +/** + * A JSON-RPC request and response processor. + * + * Give it a stack of middleware, pass it requests, and get back responses. + * + * @deprecated Use `JsonRpcEngineV2` instead. + */ +export class JsonRpcEngine extends SafeEventEmitter { + /** + * Indicating whether this engine is destroyed or not. + */ + #isDestroyed = false; + + #middleware: JsonRpcMiddleware[]; + + readonly #notificationHandler?: + | JsonRpcNotificationHandler + | undefined; + + /** + * Constructs a {@link JsonRpcEngine} instance. + * + * @param options - Options bag. + * @param options.notificationHandler - A function for handling JSON-RPC + * notifications. A JSON-RPC notification is defined as a JSON-RPC request + * without an `id` property. If this option is _not_ provided, notifications + * will be treated the same as requests. If this option _is_ provided, + * notifications will be passed to the handler function without touching + * the engine's middleware stack. This function should not throw or reject. + */ + constructor({ notificationHandler }: JsonRpcEngineArgs = {}) { + super(); + this.#middleware = []; + this.#notificationHandler = notificationHandler; + } + + /** + * Throws an error if this engine is destroyed. + */ + #assertIsNotDestroyed(): void { + if (this.#isDestroyed) { + throw new Error(DESTROYED_ERROR_MESSAGE); + } + } + + /** + * Calls the `destroy()` function of any middleware with that property, clears + * the middleware array, and marks this engine as destroyed. A destroyed + * engine cannot be used. + */ + destroy(): void { + this.#middleware.forEach( + (middleware: JsonRpcMiddleware) => { + if ( + // `in` walks the prototype chain, which is probably the desired + // behavior here. + 'destroy' in middleware && + typeof middleware.destroy === 'function' + ) { + // eslint-disable-next-line @typescript-eslint/no-floating-promises + middleware.destroy(); + } + }, + ); + this.#middleware = []; + this.#isDestroyed = true; + } + + /** + * Add a middleware function to the engine's middleware stack. + * + * @param middleware - The middleware function to add. + */ + push( + middleware: JsonRpcMiddleware, + ): void { + this.#assertIsNotDestroyed(); + this.#middleware.push(middleware as JsonRpcMiddleware); + } + + /** + * Handle a JSON-RPC request, and return a response. + * + * @param request - The request to handle. + * @param callback - An error-first callback that will receive the response. + */ + handle( + request: JsonRpcRequest, + callback: (error: unknown, response: JsonRpcResponse) => void, + ): void; + + /** + * Handle a JSON-RPC notification. + * + * @param notification - The notification to handle. + * @param callback - An error-first callback that will receive a `void` response. + */ + handle( + notification: JsonRpcNotification, + callback: (error: unknown, response: void) => void, + ): void; + + /** + * Handle an array of JSON-RPC requests and/or notifications, and return an + * array of responses to any included requests. + * + * @param request - The requests to handle. + * @param callback - An error-first callback that will receive the array of + * responses. + */ + handle( + requests: (JsonRpcRequest | JsonRpcNotification)[], + callback: (error: unknown, responses: JsonRpcResponse[]) => void, + ): void; + + /** + * Handle a JSON-RPC request, and return a response. + * + * @param request - The JSON-RPC request to handle. + * @returns The JSON-RPC response. + */ + handle( + request: JsonRpcRequest, + ): Promise>; + + /** + * Handle a JSON-RPC notification. + * + * @param notification - The notification to handle. + */ + handle( + notification: JsonRpcNotification, + ): Promise; + + /** + * Handle an array of JSON-RPC requests and/or notifications, and return an + * array of responses to any included requests. + * + * @param request - The JSON-RPC requests to handle. + * @returns An array of JSON-RPC responses. + */ + handle( + requests: (JsonRpcRequest | JsonRpcNotification)[], + ): Promise[]>; + + handle( + req: + | (JsonRpcRequest | JsonRpcNotification)[] + | JsonRpcRequest + | JsonRpcNotification, + callback?: (error: unknown, response: never) => void, + ): Promise | Promise { + this.#assertIsNotDestroyed(); + + if (callback && typeof callback !== 'function') { + throw new Error('"callback" must be a function if provided.'); + } + + if (Array.isArray(req)) { + if (callback) { + return this.#handleBatch( + req, + // This assertion is safe because of the runtime checks validating that `req` is an array and `callback` is defined. + // There is only one overload signature that satisfies both conditions, and its `callback` type is the one that's being asserted. + callback as (error: unknown, responses?: JsonRpcResponse[]) => void, + ); + } + return this.#handleBatch(req); + } + + if (callback) { + return this.#handle( + req, + callback as (error: unknown, response?: JsonRpcResponse) => void, + ); + } + return this._promiseHandle(req); + } + + /** + * Returns this engine as a middleware function that can be pushed to other + * engines. + * + * @returns This engine as a middleware function. + */ + asMiddleware(): JsonRpcMiddleware { + this.#assertIsNotDestroyed(); + + // eslint-disable-next-line @typescript-eslint/no-misused-promises + return async (req, res, next, end) => { + try { + const [middlewareError, isComplete, returnHandlers] = + await JsonRpcEngine.#runAllMiddleware(req, res, this.#middleware); + + if (isComplete) { + await JsonRpcEngine.#runReturnHandlers(returnHandlers); + return end(middlewareError); + } + + // eslint-disable-next-line @typescript-eslint/no-misused-promises + return next(async (handlerCallback) => { + try { + await JsonRpcEngine.#runReturnHandlers(returnHandlers); + } catch (error) { + return handlerCallback(error); + } + return handlerCallback(); + }); + } catch (error) { + return end(error); + } + }; + } + + /** + * Like _handle, but for batch requests. + */ + #handleBatch( + reqs: (JsonRpcRequest | JsonRpcNotification)[], + ): Promise; + + /** + * Like _handle, but for batch requests. + */ + #handleBatch( + reqs: (JsonRpcRequest | JsonRpcNotification)[], + callback: (error: unknown, responses?: JsonRpcResponse[]) => void, + ): Promise; + + /** + * Handles a batch of JSON-RPC requests, either in `async` or callback + * fashion. + * + * @param requests - The request objects to process. + * @param callback - The completion callback. + * @returns The array of responses, or nothing if a callback was specified. + */ + async #handleBatch( + requests: (JsonRpcRequest | JsonRpcNotification)[], + callback?: (error: unknown, responses?: JsonRpcResponse[]) => void, + ): Promise { + // The order here is important + try { + // If the batch is an empty array, the response array must contain a single object + if (requests.length === 0) { + const response: JsonRpcResponse[] = [ + { + id: null, + jsonrpc: '2.0', + error: new JsonRpcError( + errorCodes.rpc.invalidRequest, + 'Request batch must contain plain objects. Received an empty array', + ), + }, + ]; + if (callback) { + return callback(null, response); + } + return response; + } + + // 2. Wait for all requests to finish, or throw on some kind of fatal + // error + const responses = ( + await Promise.all( + // 1. Begin executing each request in the order received + requests.map(this._promiseHandle.bind(this)), + ) + ).filter( + // Filter out any notification responses. + (response): response is JsonRpcResponse => response !== undefined, + ); + + // 3. Return batch response + if (callback) { + return callback(null, responses); + } + return responses; + } catch (error) { + if (callback) { + return callback(error); + } + + throw error; + } + } + + /** + * A promise-wrapped _handle. + * + * @param request - The JSON-RPC request. + * @returns The JSON-RPC response. + */ + // This function is used in tests, so we cannot easily change it to use the + // hash syntax. + // eslint-disable-next-line no-restricted-syntax + private async _promiseHandle( + request: JsonRpcRequest | JsonRpcNotification, + ): Promise { + return new Promise((resolve, reject) => { + this.#handle(request, (error, res) => { + // For notifications, the response will be `undefined`, and any caught + // errors are unexpected and should be surfaced to the caller. + if (error && res === undefined) { + // We are not going to change this behavior. + // eslint-disable-next-line @typescript-eslint/prefer-promise-reject-errors + reject(error); + } else { + // Excepting notifications, there will always be a response, and it will + // always have any error that is caught and propagated. + resolve(res); + } + }).catch(reject); + }); + } + + /** + * Ensures that the request / notification object is valid, processes it, and + * passes any error and response object to the given callback. + * + * Does not reject. + * + * @param callerReq - The request object from the caller. + * @param callback - The callback function. + * @returns Nothing. + */ + async #handle( + callerReq: JsonRpcRequest | JsonRpcNotification, + callback: (error: unknown, response?: JsonRpcResponse) => void, + ): Promise { + if ( + !callerReq || + Array.isArray(callerReq) || + typeof callerReq !== 'object' + ) { + const error = new JsonRpcError( + errorCodes.rpc.invalidRequest, + `Requests must be plain objects. Received: ${typeof callerReq}`, + { request: callerReq }, + ); + return callback(error, { id: null, jsonrpc: '2.0', error }); + } + + if (typeof callerReq.method !== 'string') { + const error = new JsonRpcError( + errorCodes.rpc.invalidRequest, + `Must specify a string method. Received: ${typeof callerReq.method}`, + { request: callerReq }, + ); + + if (this.#notificationHandler && !isJsonRpcRequest(callerReq)) { + // Do not reply to notifications, even if they are malformed. + return callback(null); + } + + return callback(error, { + // Typecast: This could be a notification, but we want to access the + // `id` even if it doesn't exist. + id: (callerReq as JsonRpcRequest).id ?? null, + jsonrpc: '2.0', + error, + }); + } else if ( + this.#notificationHandler && + isJsonRpcNotification(callerReq) && + !isJsonRpcRequest(callerReq) + ) { + try { + await this.#notificationHandler(callerReq); + } catch (error) { + return callback(error); + } + return callback(null); + } + let error = null; + + // Handle requests. + // Typecast: Permit missing id's for backwards compatibility. + const req = { ...(callerReq as JsonRpcRequest) }; + const res: PendingJsonRpcResponse = { + id: req.id, + jsonrpc: req.jsonrpc, + }; + + try { + await JsonRpcEngine.#processRequest(req, res, this.#middleware); + } catch (_error) { + // A request handler error, a re-thrown middleware error, or something + // unexpected. + error = _error; + } + + if (error) { + // Ensure no result is present on an errored response + delete res.result; + res.error ??= serializeError(error); + } + + return callback(error, res as JsonRpcResponse); + } + + /** + * For the given request and response, runs all middleware and their return + * handlers, if any, and ensures that internal request processing semantics + * are satisfied. + * + * @param req - The request object. + * @param res - The response object. + * @param middlewares - The stack of middleware functions. + */ + static async #processRequest( + req: JsonRpcRequest, + res: PendingJsonRpcResponse, + middlewares: JsonRpcMiddleware[], + ): Promise { + const [error, isComplete, returnHandlers] = + await JsonRpcEngine.#runAllMiddleware(req, res, middlewares); + + // Throw if "end" was not called, or if the response has neither a result + // nor an error. + JsonRpcEngine.#checkForCompletion(req, res, isComplete); + + // The return handlers should run even if an error was encountered during + // middleware processing. + await JsonRpcEngine.#runReturnHandlers(returnHandlers); + + // Now we re-throw the middleware processing error, if any, to catch it + // further up the call chain. + if (error) { + // eslint-disable-next-line @typescript-eslint/only-throw-error + throw error; + } + } + + /** + * Serially executes the given stack of middleware. + * + * @param req - The request object. + * @param res - The response object. + * @param middlewares - The stack of middleware functions to execute. + * @returns An array of any error encountered during middleware execution, + * a boolean indicating whether the request was completed, and an array of + * middleware-defined return handlers. + */ + static async #runAllMiddleware( + req: JsonRpcRequest, + res: PendingJsonRpcResponse, + middlewares: JsonRpcMiddleware[], + ): Promise< + [ + unknown, // error + boolean, // isComplete + JsonRpcEngineReturnHandler[], + ] + > { + const returnHandlers: JsonRpcEngineReturnHandler[] = []; + let error = null; + let isComplete = false; + + // Go down stack of middleware, call and collect optional returnHandlers + for (const middleware of middlewares) { + [error, isComplete] = await JsonRpcEngine.#runMiddleware( + req, + res, + middleware, + returnHandlers, + ); + + if (isComplete) { + break; + } + } + return [error, isComplete, returnHandlers.reverse()]; + } + + /** + * Runs an individual middleware function. + * + * @param request - The request object. + * @param response - The response object. + * @param middleware - The middleware function to execute. + * @param returnHandlers - The return handlers array for the current request. + * @returns An array of any error encountered during middleware execution, + * and a boolean indicating whether the request should end. + */ + static async #runMiddleware( + request: JsonRpcRequest, + response: PendingJsonRpcResponse, + middleware: JsonRpcMiddleware, + returnHandlers: JsonRpcEngineReturnHandler[], + ): Promise<[unknown, boolean]> { + return new Promise((resolve) => { + const end: JsonRpcEngineEndCallback = (error) => { + const parsedError = error ?? response.error; + if (parsedError) { + response.error = serializeError(parsedError); + } + // True indicates that the request should end + resolve([parsedError, true]); + }; + + const next: JsonRpcEngineNextCallback = ( + returnHandler?: JsonRpcEngineReturnHandler, + ) => { + if (response.error) { + end(response.error); + } else { + if (returnHandler) { + if (typeof returnHandler !== 'function') { + end( + new JsonRpcError( + errorCodes.rpc.internal, + `JsonRpcEngine: "next" return handlers must be functions. ` + + `Received "${typeof returnHandler}" for request:\n${stringify( + request, + )}`, + { request }, + ), + ); + } + returnHandlers.push(returnHandler); + } + + // False indicates that the request should not end + resolve([null, false]); + } + }; + + try { + middleware(request, response, next, end); + } catch (error) { + end(error); + } + }); + } + + /** + * Serially executes array of return handlers. The request and response are + * assumed to be in their scope. + * + * @param handlers - The return handlers to execute. + */ + static async #runReturnHandlers( + handlers: JsonRpcEngineReturnHandler[], + ): Promise { + for (const handler of handlers) { + await new Promise((resolve, reject) => { + // We are not going to change this behavior. + // eslint-disable-next-line @typescript-eslint/prefer-promise-reject-errors + handler((error) => (error ? reject(error) : resolve())); + }); + } + } + + /** + * Throws an error if the response has neither a result nor an error, or if + * the "isComplete" flag is falsy. + * + * @param request - The request object. + * @param response - The response object. + * @param isComplete - Boolean from {@link JsonRpcEngine.#runAllMiddleware} + * indicating whether a middleware ended the request. + */ + static #checkForCompletion( + request: JsonRpcRequest, + response: PendingJsonRpcResponse, + isComplete: boolean, + ): void { + if (!hasProperty(response, 'result') && !hasProperty(response, 'error')) { + throw new JsonRpcError( + errorCodes.rpc.internal, + `JsonRpcEngine: Response has no error or result for request:\n${stringify( + request, + )}`, + { request }, + ); + } + + if (!isComplete) { + throw new JsonRpcError( + errorCodes.rpc.internal, + `JsonRpcEngine: Nothing ended request:\n${stringify(request)}`, + { request }, + ); + } + } +} diff --git a/packages/json-rpc-engine/src/README.md b/packages/json-rpc-engine/src/README.md new file mode 100644 index 00000000000..b3d41fd979b --- /dev/null +++ b/packages/json-rpc-engine/src/README.md @@ -0,0 +1,252 @@ +# `JsonRpcEngine` (deprecated) + +The deprecated, original `JsonRpcEngine` implementation. + +To be removed once the rest of MetaMask's codebase has been migrated to `JsonRpcEngineV2`. + +> [!TIP] +> For the new `JsonRpcEngineV2`, see [the package readme](../README.md). +> +> For how to migrate from the legacy `JsonRpcEngine` to `JsonRpcEngineV2`, see [this package readme section](../README.md#migrating-from-jsonrpcengine). + +## Usage + +```js +const { JsonRpcEngine } = require('@metamask/json-rpc-engine'); + +const engine = new JsonRpcEngine(); +``` + +Build a stack of JSON-RPC processors by pushing middleware to the engine. + +```js +engine.push(function (req, res, next, end) { + res.result = 42; + end(); +}); +``` + +### V2 compatibility + +Use `asV2Middleware()` to convert a `JsonRpcEngine` or one or more legacy middleware into a V2 middleware. + +#### Context propagation + +Non-JSON-RPC string properties on the request object will be copied over to the V2 engine's `context` object +once the legacy engine is done with the request, _unless_ they already exist on the `context`, in which case +they will be ignored. + +#### Converting a legacy engine + +```ts +import { JsonRpcEngineV2 } from '@metamask/json-rpc-engine/v2'; +import { asV2Middleware, JsonRpcEngine } from '@metamask/json-rpc-engine'; + +const legacyEngine = new JsonRpcEngine(); +legacyEngine.push(/* ... */); + +const v2Engine = JsonRpcEngineV2.create({ + middleware: [asV2Middleware(legacyEngine)], +}); +``` + +#### Converting legacy middleware + +You can also directly convert one or more legacy middlewares without creating an engine: + +```ts +import { JsonRpcEngineV2 } from '@metamask/json-rpc-engine/v2'; +import { asV2Middleware } from '@metamask/json-rpc-engine'; + +// Convert a single legacy middleware +const middleware1 = (req, res, next, end) => { + /* ... */ +}; + +const v2Engine = JsonRpcEngineV2.create({ + middleware: [asV2Middleware(middleware1)], +}); + +// Convert multiple legacy middlewares at once +const middleware2 = (req, res, next, end) => { + /* ... */ +}; + +const v2Engine2 = JsonRpcEngineV2.create({ + middleware: [asV2Middleware(middleware1, middleware2)], +}); +``` + +### Middleware + +Requests are handled asynchronously, stepping down the stack until complete. + +```js +const request = { id: 1, jsonrpc: '2.0', method: 'hello' }; + +engine.handle(request, function (err, response) { + // Do something with response.result, or handle response.error +}); + +// There is also a Promise signature +const response = await engine.handle(request); +``` + +Middleware have direct access to the request and response objects. +They can let processing continue down the stack with `next()`, or complete the request with `end()`. + +```js +engine.push(function (req, res, next, end) { + if (req.skipCache) return next(); + res.result = getResultFromCache(req); + end(); +}); +``` + +By passing a _return handler_ to the `next` function, you can get a peek at the result before it returns. + +```js +engine.push(function (req, res, next, end) { + next(function (cb) { + insertIntoCache(res, cb); + }); +}); +``` + +If you specify a `notificationHandler` when constructing the engine, JSON-RPC notifications passed to `handle()` will be handed off directly to this function without touching the middleware stack: + +```js +const engine = new JsonRpcEngine({ notificationHandler }); + +// A notification is defined as a JSON-RPC request without an `id` property. +const notification = { jsonrpc: '2.0', method: 'hello' }; + +const response = await engine.handle(notification); +console.log(typeof response); // 'undefined' +``` + +Engines can be nested by converting them to middleware using `JsonRpcEngine.asMiddleware()`: + +```js +const engine = new JsonRpcEngine(); +const subengine = new JsonRpcEngine(); +engine.push(subengine.asMiddleware()); +``` + +### `async` Middleware + +If you require your middleware function to be `async`, use `createAsyncMiddleware`: + +```js +const { createAsyncMiddleware } = require('@metamask/json-rpc-engine'); + +let engine = new RpcEngine(); +engine.push( + createAsyncMiddleware(async (req, res, next) => { + res.result = 42; + next(); + }), +); +``` + +`async` middleware do not take an `end` callback. +Instead, the request ends if the middleware returns without calling `next()`: + +```js +engine.push( + createAsyncMiddleware(async (req, res, next) => { + res.result = 42; + /* The request will end when this returns */ + }), +); +``` + +The `next` callback of `async` middleware also don't take return handlers. +Instead, you can `await next()`. +When the execution of the middleware resumes, you can work with the response again. + +```js +engine.push( + createAsyncMiddleware(async (req, res, next) => { + res.result = 42; + await next(); + /* Your return handler logic goes here */ + addToMetrics(res); + }), +); +``` + +You can freely mix callback-based and `async` middleware: + +```js +engine.push(function (req, res, next, end) { + if (!isCached(req)) { + return next((cb) => { + insertIntoCache(res, cb); + }); + } + res.result = getResultFromCache(req); + end(); +}); + +engine.push( + createAsyncMiddleware(async (req, res, next) => { + res.result = 42; + await next(); + addToMetrics(res); + }), +); +``` + +### Teardown + +If your middleware has teardown to perform, you can assign a method `destroy()` to your middleware function(s), +and calling `JsonRpcEngine.destroy()` will call this method on each middleware that has it. +A destroyed engine can no longer be used. + +```js +const middleware = (req, res, next, end) => { + /* do something */ +}; +middleware.destroy = () => { + /* perform teardown */ +}; + +const engine = new JsonRpcEngine(); +engine.push(middleware); + +/* perform work */ + +// This will call middleware.destroy() and destroy the engine itself. +engine.destroy(); + +// Calling any public method on the middleware other than `destroy()` itself +// will throw an error. +engine.handle(req); +``` + +### Gotchas + +Handle errors via `end(err)`, _NOT_ `next(err)`. + +```js +/* INCORRECT */ +engine.push(function (req, res, next, end) { + next(new Error()); +}); + +/* CORRECT */ +engine.push(function (req, res, next, end) { + end(new Error()); +}); +``` + +However, `next()` will detect errors on the response object, and cause +`end(res.error)` to be called. + +```js +engine.push(function (req, res, next, end) { + res.error = new Error(); + next(); /* This will cause end(res.error) to be called. */ +}); +``` diff --git a/packages/json-rpc-engine/src/asMiddleware.test.ts b/packages/json-rpc-engine/src/asMiddleware.test.ts new file mode 100644 index 00000000000..7f3938fc5db --- /dev/null +++ b/packages/json-rpc-engine/src/asMiddleware.test.ts @@ -0,0 +1,280 @@ +import type { JsonRpcRequest } from '@metamask/utils'; +import { assertIsJsonRpcSuccess, isJsonRpcSuccess } from '@metamask/utils'; + +import { JsonRpcEngine } from './index.js'; + +const jsonrpc = '2.0' as const; + +describe('asMiddleware', () => { + it('basic', async () => { + const engine = new JsonRpcEngine(); + const subengine = new JsonRpcEngine(); + let originalRequest: JsonRpcRequest; + + subengine.push(function (request, response, _next, end) { + originalRequest = request; + response.result = 'saw subengine'; + end(); + }); + + engine.push(subengine.asMiddleware()); + + const payload = { id: 1, jsonrpc, method: 'hello' }; + + await new Promise((resolve) => { + engine.handle(payload, function (error, response) { + expect(error).toBeNull(); + expect(response).toBeDefined(); + expect(originalRequest.id).toStrictEqual(response.id); + expect(originalRequest.jsonrpc).toStrictEqual(response.jsonrpc); + assertIsJsonRpcSuccess(response); + expect(response.result).toBe('saw subengine'); + resolve(); + }); + }); + }); + + it('decorate response', async () => { + const engine = new JsonRpcEngine(); + const subengine = new JsonRpcEngine(); + let originalRequest: JsonRpcRequest; + + subengine.push(function (request, response, _next, end) { + originalRequest = request; + // TODO: Replace `any` with type + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (response as any).xyz = true; + response.result = true; + end(); + }); + + engine.push(subengine.asMiddleware()); + + const payload = { id: 1, jsonrpc, method: 'hello' }; + + await new Promise((resolve) => { + engine.handle(payload, function (error, response) { + expect(error).toBeNull(); + expect(response).toBeDefined(); + expect(originalRequest.id).toStrictEqual(response.id); + expect(originalRequest.jsonrpc).toStrictEqual(response.jsonrpc); + // TODO: Replace `any` with type + // eslint-disable-next-line @typescript-eslint/no-explicit-any + expect((response as any).xyz).toBe(true); + resolve(); + }); + }); + }); + + it('decorate request', async () => { + const engine = new JsonRpcEngine(); + const subengine = new JsonRpcEngine(); + let originalRequest: JsonRpcRequest; + + subengine.push(function (request, response, _next, end) { + originalRequest = request; + // TODO: Replace `any` with type + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (request as any).xyz = true; + // TODO: Replace `any` with type + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (response as any).xyz = true; + response.result = true; + end(); + }); + + engine.push(subengine.asMiddleware()); + + const payload = { id: 1, jsonrpc, method: 'hello' }; + + await new Promise((resolve) => { + engine.handle(payload, function (error, response) { + expect(error).toBeNull(); + expect(response).toBeDefined(); + expect(originalRequest.id).toStrictEqual(response.id); + expect(originalRequest.jsonrpc).toStrictEqual(response.jsonrpc); + // TODO: Replace `any` with type + // eslint-disable-next-line @typescript-eslint/no-explicit-any + expect((originalRequest as any).xyz).toBe(true); + resolve(); + }); + }); + }); + + it('should not error even if end not called', async () => { + const engine = new JsonRpcEngine(); + const subengine = new JsonRpcEngine(); + + subengine.push((_request, _response, next, _end) => next()); + + engine.push(subengine.asMiddleware()); + engine.push((_request, response, _next, end) => { + response.result = true; + end(); + }); + + const payload = { id: 1, jsonrpc, method: 'hello' }; + + await new Promise((resolve) => { + engine.handle(payload, function (error, response) { + expect(error).toBeNull(); + expect(response).toBeDefined(); + resolve(); + }); + }); + }); + + it('handles next handler correctly when nested', async () => { + const engine = new JsonRpcEngine(); + const subengine = new JsonRpcEngine(); + + subengine.push((_request, response, next, _end) => { + next((callback) => { + // TODO: Replace `any` with type + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (response as any).copy = response.result; + callback(); + }); + }); + + engine.push(subengine.asMiddleware()); + engine.push((_request, response, _next, end) => { + response.result = true; + end(); + }); + const payload = { id: 1, jsonrpc, method: 'hello' }; + + await new Promise((resolve) => { + engine.handle(payload, function (error, response) { + expect(error).toBeNull(); + expect(response).toBeDefined(); + + // @ts-expect-error - `copy` is not a valid property of `JsonRpcSuccess`. + const { copy, ...rest } = response; + assertIsJsonRpcSuccess(rest); + + expect(rest.result).toStrictEqual(copy); + resolve(); + }); + }); + }); + + it('handles next handler correctly when flat', async () => { + const engine = new JsonRpcEngine(); + const subengine = new JsonRpcEngine(); + + subengine.push((_request, response, next, _end) => { + next((callback) => { + // TODO: Replace `any` with type + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (response as any).copy = response.result; + callback(); + }); + }); + + subengine.push((_request, response, _next, end) => { + response.result = true; + end(); + }); + + engine.push(subengine.asMiddleware()); + const payload = { id: 1, jsonrpc, method: 'hello' }; + + await new Promise((resolve) => { + engine.handle(payload, function (error, response) { + expect(error).toBeNull(); + expect(response).toBeDefined(); + + // @ts-expect-error - `copy` is not a valid property of `JsonRpcSuccess`. + const { copy, ...rest } = response; + assertIsJsonRpcSuccess(rest); + + expect(rest.result).toStrictEqual(copy); + resolve(); + }); + }); + }); + + it('handles error thrown in middleware', async () => { + const engine = new JsonRpcEngine(); + const subengine = new JsonRpcEngine(); + + subengine.push(function (_request, _response, _next, _end) { + throw new Error('foo'); + }); + + engine.push(subengine.asMiddleware()); + + const payload = { id: 1, jsonrpc, method: 'hello' }; + + await new Promise((resolve) => { + engine.handle(payload, function (error, response) { + expect(error).toBeDefined(); + // TODO: Replace `any` with type + // eslint-disable-next-line @typescript-eslint/no-explicit-any + expect((error as any).message).toBe('foo'); + expect(isJsonRpcSuccess(response)).toBe(false); + resolve(); + }); + }); + }); + + it('handles next handler error correctly when nested', async () => { + const engine = new JsonRpcEngine(); + const subengine = new JsonRpcEngine(); + + subengine.push((_request, _response, next, _end) => { + next((_callback) => { + throw new Error('foo'); + }); + }); + + engine.push(subengine.asMiddleware()); + engine.push((_request, response, _next, end) => { + response.result = true; + end(); + }); + const payload = { id: 1, jsonrpc, method: 'hello' }; + + await new Promise((resolve) => { + engine.handle(payload, function (error, response) { + expect(error).toBeDefined(); + // TODO: Replace `any` with type + // eslint-disable-next-line @typescript-eslint/no-explicit-any + expect((error as any).message).toBe('foo'); + expect(isJsonRpcSuccess(response)).toBe(false); + resolve(); + }); + }); + }); + + it('handles next handler error correctly when flat', async () => { + const engine = new JsonRpcEngine(); + const subengine = new JsonRpcEngine(); + + subengine.push((_request, _response, next, _end) => { + next((_callback) => { + throw new Error('foo'); + }); + }); + + subengine.push((_request, response, _next, end) => { + response.result = true; + end(); + }); + + engine.push(subengine.asMiddleware()); + const payload = { id: 1, jsonrpc, method: 'hello' }; + + await new Promise((resolve) => { + engine.handle(payload, function (error, response) { + expect(error).toBeDefined(); + // TODO: Replace `any` with type + // eslint-disable-next-line @typescript-eslint/no-explicit-any + expect((error as any).message).toBe('foo'); + expect(isJsonRpcSuccess(response)).toBe(false); + resolve(); + }); + }); + }); +}); diff --git a/packages/json-rpc-engine/src/asV2Middleware.test.ts b/packages/json-rpc-engine/src/asV2Middleware.test.ts new file mode 100644 index 00000000000..2fb1a8f961f --- /dev/null +++ b/packages/json-rpc-engine/src/asV2Middleware.test.ts @@ -0,0 +1,203 @@ +import { rpcErrors } from '@metamask/rpc-errors'; +import type { Json, JsonRpcRequest } from '@metamask/utils'; + +import { + getExtraneousKeys, + makeNullMiddleware, + makeRequest, +} from '../tests/utils.js'; +import { asV2Middleware } from './asV2Middleware.js'; +import { JsonRpcEngine } from './index.js'; +import { JsonRpcEngineV2 } from './v2/JsonRpcEngineV2.js'; +import type { JsonRpcMiddleware as V2Middleware } from './v2/JsonRpcEngineV2.js'; +import type { MiddlewareContext } from './v2/MiddlewareContext.js'; + +describe('asV2Middleware', () => { + it('converts a legacy engine to a v2 middleware', () => { + const engine = new JsonRpcEngine(); + const middleware = asV2Middleware(engine); + expect(typeof middleware).toBe('function'); + }); + + it('forwards a result to the v2 engine', async () => { + const legacyEngine = new JsonRpcEngine(); + legacyEngine.push((_req, res, _next, end) => { + res.result = null; + end(); + }); + + const v2Engine = JsonRpcEngineV2.create({ + middleware: [asV2Middleware(legacyEngine)], + }); + + const result = await v2Engine.handle(makeRequest()); + expect(result).toBeNull(); + }); + + it('forwards an error to the v2 engine', async () => { + const legacyEngine = new JsonRpcEngine(); + legacyEngine.push((_req, res, _next, end) => { + res.error = rpcErrors.internal('test'); + end(); + }); + + const v2Engine = JsonRpcEngineV2.create({ + middleware: [asV2Middleware(legacyEngine)], + }); + + await expect(v2Engine.handle(makeRequest())).rejects.toThrow( + rpcErrors.internal('test'), + ); + }); + + it('forwards a serialized error to the v2 engine', async () => { + const legacyEngine = new JsonRpcEngine(); + legacyEngine.push((_req, res, _next, end) => { + res.error = { message: 'test', code: 1000 }; + end(); + }); + + const v2Engine = JsonRpcEngineV2.create({ + middleware: [asV2Middleware(legacyEngine)], + }); + + await expect(v2Engine.handle(makeRequest())).rejects.toThrow( + new Error('test'), + ); + }); + + it('allows the v2 engine to continue when not ending the request', async () => { + const legacyEngine = new JsonRpcEngine(); + const legacyMiddleware = jest.fn((_req, _res, next) => { + next(); + }); + legacyEngine.push(legacyMiddleware); + + const v2Engine = JsonRpcEngineV2.create({ + middleware: [asV2Middleware(legacyEngine), makeNullMiddleware()], + }); + + const result = await v2Engine.handle(makeRequest()); + expect(result).toBeNull(); + expect(legacyMiddleware).toHaveBeenCalledTimes(1); + }); + + it('propagates the context to the legacy request and back', async () => { + const observedContextValues: number[] = []; + + const legacyEngine = new JsonRpcEngine(); + const legacyMiddleware = jest.fn((req, _res, next) => { + observedContextValues.push(req.value); + + expect(getExtraneousKeys(req)).toStrictEqual(['value']); + + req.newValue = 2; + next(); + }); + legacyEngine.push(legacyMiddleware); + + type Context = MiddlewareContext>; + const middleware1: V2Middleware = ({ + context, + next, + }) => { + context.set('value', 1); + return next(); + }; + const middleware2: V2Middleware = ({ + context, + }) => { + observedContextValues.push(context.assertGet('newValue')); + return null; + }; + const v2Engine = JsonRpcEngineV2.create({ + middleware: [middleware1, asV2Middleware(legacyEngine), middleware2], + }); + + await v2Engine.handle(makeRequest()); + expect(observedContextValues).toStrictEqual([1, 2]); + }); + + describe('with legacy middleware', () => { + it('accepts a single legacy middleware', async () => { + const legacyMiddleware = jest.fn((_req, res, _next, end) => { + res.result = 'test-result'; + end(); + }); + + const v2Engine = JsonRpcEngineV2.create({ + middleware: [asV2Middleware(legacyMiddleware)], + }); + + const result = await v2Engine.handle(makeRequest()); + expect(result).toBe('test-result'); + expect(legacyMiddleware).toHaveBeenCalledTimes(1); + }); + + it('accepts multiple legacy middlewares via rest params', async () => { + const middleware1 = jest.fn((req, _res, next) => { + req.visited1 = true; + next(); + }); + + const middleware2 = jest.fn((req, res, _next, end) => { + expect(req.visited1).toBe(true); + res.result = 'composed-result'; + end(); + }); + + const v2Engine = JsonRpcEngineV2.create({ + middleware: [asV2Middleware(middleware1, middleware2)], + }); + + const result = await v2Engine.handle(makeRequest()); + expect(result).toBe('composed-result'); + expect(middleware1).toHaveBeenCalledTimes(1); + expect(middleware2).toHaveBeenCalledTimes(1); + }); + + it('forwards errors from legacy middleware', async () => { + const legacyMiddleware = jest.fn((_req, res, _next, end) => { + res.error = rpcErrors.internal('legacy-error'); + end(); + }); + + const v2Engine = JsonRpcEngineV2.create({ + middleware: [asV2Middleware(legacyMiddleware)], + }); + + await expect(v2Engine.handle(makeRequest())).rejects.toThrow( + rpcErrors.internal('legacy-error'), + ); + }); + + it('does not forward undefined errors from legacy middleware', async () => { + const legacyMiddleware = jest.fn((_req, res, _next, end) => { + res.error = undefined; + res.result = 42; + end(); + }); + + const v2Engine = JsonRpcEngineV2.create({ + middleware: [asV2Middleware(legacyMiddleware)], + }); + + const result = await v2Engine.handle(makeRequest()); + expect(result).toBe(42); + }); + + it('allows v2 engine to continue when legacy middleware does not end', async () => { + const legacyMiddleware = jest.fn((_req, _res, next) => { + next(); + }); + + const v2Engine = JsonRpcEngineV2.create({ + middleware: [asV2Middleware(legacyMiddleware), makeNullMiddleware()], + }); + + const result = await v2Engine.handle(makeRequest()); + expect(result).toBeNull(); + expect(legacyMiddleware).toHaveBeenCalledTimes(1); + }); + }); +}); diff --git a/packages/json-rpc-engine/src/asV2Middleware.ts b/packages/json-rpc-engine/src/asV2Middleware.ts new file mode 100644 index 00000000000..f877f9212b5 --- /dev/null +++ b/packages/json-rpc-engine/src/asV2Middleware.ts @@ -0,0 +1,120 @@ +import { serializeError } from '@metamask/rpc-errors'; +import { hasProperty } from '@metamask/utils'; +import type { + Json, + JsonRpcFailure, + JsonRpcParams, + JsonRpcRequest, + JsonRpcResponse, +} from '@metamask/utils'; + +import type { + JsonRpcEngine, + JsonRpcEngineEndCallback, + JsonRpcEngineNextCallback, +} from './JsonRpcEngine.js'; +import type { JsonRpcMiddleware as LegacyMiddleware } from './JsonRpcEngine.js'; +import { mergeMiddleware } from './mergeMiddleware.js'; +import { + deepClone, + fromLegacyRequest, + propagateToContext, + propagateToRequest, + deserializeError, +} from './v2/compatibility-utils.js'; +import type { ContextConstraint, MiddlewareContext } from './v2/index.js'; +import type { + // Used in docs. + // eslint-disable-next-line @typescript-eslint/no-unused-vars + JsonRpcEngineV2, + JsonRpcMiddleware, + ResultConstraint, +} from './v2/JsonRpcEngineV2.js'; + +/** + * Convert a legacy {@link JsonRpcEngine} into a {@link JsonRpcEngineV2} middleware. + * + * @param engine - The legacy engine to convert. + * @returns The {@link JsonRpcEngineV2} middleware. + */ +export function asV2Middleware< + Params extends JsonRpcParams, + Request extends JsonRpcRequest, +>(engine: JsonRpcEngine): JsonRpcMiddleware; + +/** + * Convert one or more legacy middleware into a {@link JsonRpcEngineV2} middleware. + * + * @param middleware - The legacy middleware to convert. + * @returns The {@link JsonRpcEngineV2} middleware. + */ +export function asV2Middleware< + Params extends JsonRpcParams = JsonRpcParams, + Request extends JsonRpcRequest = JsonRpcRequest, + Result extends ResultConstraint = ResultConstraint, + Context extends ContextConstraint = MiddlewareContext, +>( + ...middleware: LegacyMiddleware[] +): JsonRpcMiddleware; + +/** + * The asV2Middleware implementation. + * + * @param engineOrMiddleware - A legacy engine or legacy middleware. + * @param rest - Any additional legacy middleware when the first argument is a middleware. + * @returns The {@link JsonRpcEngineV2} middleware. + */ +export function asV2Middleware< + Params extends JsonRpcParams, + Request extends JsonRpcRequest, +>( + engineOrMiddleware: JsonRpcEngine | LegacyMiddleware, + ...rest: LegacyMiddleware[] +): JsonRpcMiddleware { + const legacyMiddleware = + typeof engineOrMiddleware === 'function' + ? // mergeMiddleware uses .asMiddleware() internally, which is necessary for our purposes. + // See comment on this below. + mergeMiddleware([engineOrMiddleware, ...rest]) + : engineOrMiddleware.asMiddleware(); + + return async ({ request, context, next }) => { + const req = deepClone(request) as JsonRpcRequest; + propagateToRequest(req, context); + + const response = await new Promise((resolve) => { + // The result or error property will be set by the legacy engine + // middleware. + const res = { + jsonrpc: '2.0' as const, + id: req.id, + } as JsonRpcResponse; + + const end: JsonRpcEngineEndCallback = (error) => { + if (error !== undefined) { + (res as JsonRpcFailure).error = serializeError(error); + } + resolve(res); + }; + + // We know from the implementation of JsonRpcEngine.asMiddleware() that + // legacyNext will always be passed a callback, so cb can never be + // undefined. + const legacyNext = ((callback: JsonRpcEngineEndCallback) => + callback(end)) as JsonRpcEngineNextCallback; + + legacyMiddleware(req, res, legacyNext, end); + }); + propagateToContext(req, context); + + // Mimic the behavior of JsonRpcEngine.#handle(), which only treats truthy errors as errors. + // Legacy middleware may violate the invariant that response objects have either a result or an + // error property. In practice, we may see response objects with results and `{ error: undefined }`. + if (hasProperty(response, 'error') && response.error) { + throw deserializeError(response.error); + } else if (hasProperty(response, 'result')) { + return response.result as ResultConstraint; + } + return next(fromLegacyRequest(req as Request)); + }; +} diff --git a/packages/json-rpc-engine/src/createAsyncMiddleware.test.ts b/packages/json-rpc-engine/src/createAsyncMiddleware.test.ts new file mode 100644 index 00000000000..561fd05ef6c --- /dev/null +++ b/packages/json-rpc-engine/src/createAsyncMiddleware.test.ts @@ -0,0 +1,137 @@ +import { assertIsJsonRpcSuccess } from '@metamask/utils'; + +import { JsonRpcEngine, createAsyncMiddleware } from './index.js'; + +const jsonrpc = '2.0' as const; + +describe('createAsyncMiddleware', () => { + it('basic middleware test', async () => { + const engine = new JsonRpcEngine(); + + engine.push( + createAsyncMiddleware(async (_request, response, _next) => { + response.result = 42; + }), + ); + + const payload = { id: 1, jsonrpc, method: 'hello' }; + + await new Promise((resolve) => { + engine.handle(payload, function (error, response) { + expect(error).toBeNull(); + expect(response).toBeDefined(); + assertIsJsonRpcSuccess(response); + expect(response.result).toBe(42); + resolve(); + }); + }); + }); + + it('next middleware test', async () => { + const engine = new JsonRpcEngine(); + + engine.push( + createAsyncMiddleware(async (_request, response, next) => { + expect(response.result).toBeUndefined(); + // eslint-disable-next-line n/callback-return + await next(); + expect(response.result).toBe(1234); + // override value + response.result = 42; + }), + ); + + engine.push(function (_request, response, _next, end) { + response.result = 1234; + end(); + }); + + const payload = { id: 1, jsonrpc, method: 'hello' }; + + await new Promise((resolve) => { + engine.handle(payload, function (error, response) { + expect(error).toBeNull(); + expect(response).toBeDefined(); + assertIsJsonRpcSuccess(response); + expect(response.result).toBe(42); + resolve(); + }); + }); + }); + + it('basic throw test', async () => { + const engine = new JsonRpcEngine(); + + const thrownError = new Error('bad boy'); + + engine.push( + createAsyncMiddleware(async (_req, _res, _next) => { + throw thrownError; + }), + ); + + const payload = { id: 1, jsonrpc, method: 'hello' }; + + await new Promise((resolve) => { + engine.handle(payload, function (error, _response) { + expect(error).toBeDefined(); + expect(error).toStrictEqual(thrownError); + resolve(); + }); + }); + }); + + it('throw after next test', async () => { + const engine = new JsonRpcEngine(); + + const thrownError = new Error('bad boy'); + + engine.push( + createAsyncMiddleware(async (_request, _response, next) => { + // eslint-disable-next-line n/callback-return + await next(); + throw thrownError; + }), + ); + + engine.push(function (_request, response, _next, end) { + response.result = 1234; + end(); + }); + + const payload = { id: 1, jsonrpc, method: 'hello' }; + + await new Promise((resolve) => { + engine.handle(payload, function (error, _response) { + expect(error).toBeDefined(); + expect(error).toStrictEqual(thrownError); + resolve(); + }); + }); + }); + + it("doesn't await next", async () => { + const engine = new JsonRpcEngine(); + + engine.push( + createAsyncMiddleware(async (_request, _response, next) => { + // eslint-disable-next-line @typescript-eslint/no-floating-promises + next(); + }), + ); + + engine.push(function (_request, response, _next, end) { + response.result = 1234; + end(); + }); + + const payload = { id: 1, jsonrpc, method: 'hello' }; + + await new Promise((resolve) => { + engine.handle(payload, function (error, _response) { + expect(error).toBeDefined(); + resolve(); + }); + }); + }); +}); diff --git a/packages/json-rpc-engine/src/createAsyncMiddleware.ts b/packages/json-rpc-engine/src/createAsyncMiddleware.ts new file mode 100644 index 00000000000..f09a98ef8a7 --- /dev/null +++ b/packages/json-rpc-engine/src/createAsyncMiddleware.ts @@ -0,0 +1,98 @@ +import type { + Json, + JsonRpcParams, + JsonRpcRequest, + PendingJsonRpcResponse, +} from '@metamask/utils'; + +import type { + JsonRpcEngineReturnHandler, + JsonRpcMiddleware, +} from './JsonRpcEngine.js'; + +export type AsyncJsonRpcEngineNextCallback = () => Promise; + +export type AsyncJsonrpcMiddleware< + Params extends JsonRpcParams, + Result extends Json, +> = ( + request: JsonRpcRequest, + response: PendingJsonRpcResponse, + next: AsyncJsonRpcEngineNextCallback, +) => Promise; + +type ReturnHandlerCallback = Parameters[0]; + +/** + * JsonRpcEngine only accepts callback-based middleware directly. + * createAsyncMiddleware exists to enable consumers to pass in async middleware + * functions. + * + * Async middleware have no "end" function. Instead, they "end" if they return + * without calling "next". Rather than passing in explicit return handlers, + * async middleware can simply await "next", and perform operations on the + * response object when execution resumes. + * + * To accomplish this, createAsyncMiddleware passes the async middleware a + * wrapped "next" function. That function calls the internal JsonRpcEngine + * "next" function with a return handler that resolves a promise when called. + * + * The return handler will always be called. Its resolution of the promise + * enables the control flow described above. + * + * @deprecated Use `JsonRpcEngineV2` and its corresponding types instead. + * @param asyncMiddleware - The asynchronous middleware function to wrap. + * @returns The wrapped asynchronous middleware function, ready to be consumed + * by JsonRpcEngine. + */ +export function createAsyncMiddleware< + Params extends JsonRpcParams, + Result extends Json, +>( + asyncMiddleware: AsyncJsonrpcMiddleware, +): JsonRpcMiddleware { + // eslint-disable-next-line @typescript-eslint/no-misused-promises + return async (request, response, next, end) => { + // nextPromise is the key to the implementation + // it is resolved by the return handler passed to the + // "next" function + let resolveNextPromise: () => void; + const nextPromise = new Promise((resolve) => { + resolveNextPromise = resolve; + }); + + let returnHandlerCallback: unknown = null; + let nextWasCalled = false; + + // This will be called by the consumer's async middleware. + const asyncNext = async (): Promise => { + nextWasCalled = true; + + // We pass a return handler to next(). When it is called by the engine, + // the consumer's async middleware will resume executing. + next((runReturnHandlersCallback) => { + // This callback comes from JsonRpcEngine._runReturnHandlers + returnHandlerCallback = runReturnHandlersCallback; + resolveNextPromise(); + }); + return nextPromise; + }; + + try { + await asyncMiddleware(request, response, asyncNext); + + if (nextWasCalled) { + await nextPromise; // we must wait until the return handler is called + (returnHandlerCallback as ReturnHandlerCallback)(null); + } else { + end(null); + } + } catch (error) { + if (returnHandlerCallback) { + (returnHandlerCallback as ReturnHandlerCallback)(error); + } else { + end(error); + } + } + }; +} diff --git a/packages/json-rpc-engine/src/createMethodMiddleware.test.ts b/packages/json-rpc-engine/src/createMethodMiddleware.test.ts new file mode 100644 index 00000000000..bdaefd0272d --- /dev/null +++ b/packages/json-rpc-engine/src/createMethodMiddleware.test.ts @@ -0,0 +1,360 @@ +import { Messenger, MOCK_ANY_NAMESPACE } from '@metamask/messenger'; +import { + assertIsJsonRpcFailure, + assertIsJsonRpcSuccess, + Json, + JsonRpcParams, + JsonRpcRequest, +} from '@metamask/utils'; + +import { + MethodHandler, + MethodHandlerImplementation, + createMethodMiddleware, +} from './createMethodMiddleware.js'; +import { JsonRpcEngine, JsonRpcMiddleware } from './JsonRpcEngine.js'; + +type AllHooks = { + hook1: () => number; + hook2: () => number; +}; + +const getDefaultHooks = (): AllHooks => ({ + hook1: () => 42, + hook2: () => 99, +}); + +const makeHandler = >( + implementation: MethodHandlerImplementation, + hookNames: { [Name in keyof Hooks]: true }, + // eslint-disable-next-line @typescript-eslint/explicit-function-return-type +) => ({ implementation, hookNames }); + +const method1 = 'method1'; + +const baseRequest = { + jsonrpc: '2.0' as const, + id: 1, + method: method1, +}; + +describe('createMethodMiddleware', () => { + it('calls the handler for the matching method (uses hook1)', async () => { + const handler = makeHandler( + (_req, res, _next, end, hooks) => { + res.result = hooks.hook1(); + return end(); + }, + { hook1: true, hook2: true }, + ); + + const middleware = createMethodMiddleware({ + handlers: { method1: handler, method2: handler }, + hooks: getDefaultHooks(), + }); + const engine = new JsonRpcEngine(); + engine.push(middleware); + + const response = await engine.handle(baseRequest); + assertIsJsonRpcSuccess(response); + + expect(response.result).toBe(42); + }); + + it('calls the handler for the matching method (uses hook2)', async () => { + const handler = makeHandler( + (_req, res, _next, end, hooks) => { + res.result = hooks.hook2(); + return end(); + }, + { hook1: true, hook2: true }, + ); + + const middleware = createMethodMiddleware({ + handlers: { method1: handler, method2: handler }, + hooks: getDefaultHooks(), + }); + const engine = new JsonRpcEngine(); + engine.push(middleware); + + const response = await engine.handle(baseRequest); + assertIsJsonRpcSuccess(response); + + expect(response.result).toBe(99); + }); + + it('does not call the handler for a non-matching method', async () => { + const handler = makeHandler( + (_req, res, _next, end) => { + res.result = 'unreachable'; + return end(); + }, + { hook1: true, hook2: true }, + ); + + const middleware = createMethodMiddleware({ + handlers: { method1: handler, method2: handler }, + hooks: getDefaultHooks(), + }); + const engine = new JsonRpcEngine(); + engine.push(middleware); + + const response = await engine.handle({ + ...baseRequest, + method: 'nonMatchingMethod', + }); + assertIsJsonRpcFailure(response); + + expect(response.error).toMatchObject({ + message: expect.stringMatching( + /Response has no error or result for request/u, + ), + }); + }); + + it('handles errors returned by the implementation', async () => { + const handler = makeHandler( + (_req, _res, _next, end) => end(new Error('test error')), + { hook1: true, hook2: true }, + ); + + const middleware = createMethodMiddleware({ + handlers: { method1: handler, method2: handler }, + hooks: getDefaultHooks(), + }); + const engine = new JsonRpcEngine(); + engine.push(middleware); + + const response = await engine.handle(baseRequest); + assertIsJsonRpcFailure(response); + + expect(response.error.message).toBe('test error'); + expect( + (response.error.data as { cause: { message: string } }).cause.message, + ).toBe('test error'); + }); + + it('handles errors thrown by the implementation', async () => { + const handler = makeHandler( + () => { + throw new Error('test error'); + }, + { hook1: true, hook2: true }, + ); + + const middleware = createMethodMiddleware({ + handlers: { method1: handler, method2: handler }, + hooks: getDefaultHooks(), + }); + const engine = new JsonRpcEngine(); + engine.push(middleware); + + const response = await engine.handle(baseRequest); + assertIsJsonRpcFailure(response); + + expect(response.error.message).toBe('test error'); + expect( + (response.error.data as { cause: { message: string } }).cause.message, + ).toBe('test error'); + }); + + it('handles non-errors thrown by the implementation', async () => { + const handler = makeHandler( + () => { + // eslint-disable-next-line @typescript-eslint/only-throw-error + throw 'foo'; + }, + { hook1: true, hook2: true }, + ); + + const middleware = createMethodMiddleware({ + handlers: { method1: handler, method2: handler }, + hooks: getDefaultHooks(), + }); + const engine = new JsonRpcEngine(); + engine.push(middleware); + + const response = await engine.handle(baseRequest); + assertIsJsonRpcFailure(response); + + expect(response.error).toMatchObject({ + message: 'Internal JSON-RPC error.', + data: 'foo', + }); + }); + + it('invokes onError when a handler throws', async () => { + const onError = jest.fn(); + const handler = makeHandler( + () => { + throw new Error('test error'); + }, + { hook1: true, hook2: true }, + ); + + const middleware = createMethodMiddleware({ + handlers: { method1: handler, method2: handler }, + hooks: getDefaultHooks(), + onError, + }); + const engine = new JsonRpcEngine(); + engine.push(middleware); + + await engine.handle(baseRequest); + + expect(onError).toHaveBeenCalledTimes(1); + const [error, receivedRequest] = onError.mock.calls[0]; + expect(error).toBeInstanceOf(Error); + expect((error as Error).message).toBe('test error'); + expect(receivedRequest).toMatchObject(baseRequest); + }); + + it('works when no hooks are configured', async () => { + const noDepsHandler = { + implementation: ((_req, res, _next, end) => { + res.result = 'no-deps'; + return end(); + }) as JsonRpcMiddleware, + }; + + const middleware = createMethodMiddleware({ + handlers: { noDeps: noDepsHandler }, + hooks: {}, + }); + const engine = new JsonRpcEngine(); + engine.push(middleware); + + const response = await engine.handle({ ...baseRequest, method: 'noDeps' }); + assertIsJsonRpcSuccess(response); + + expect(response.result).toBe('no-deps'); + }); + + it('allows typing non-standard request fields via RequestExtras', async () => { + const originHandler = { + implementation: (req, res, _next, end): void => { + res.result = req.origin ?? 'missing'; + return end(); + }, + } satisfies MethodHandler< + never, + never, + JsonRpcParams, + Json, + { origin: string } + >; + + const middleware = createMethodMiddleware({ + handlers: { reportOrigin: originHandler }, + hooks: {}, + }); + const engine = new JsonRpcEngine(); + engine.push(middleware); + + const response = await engine.handle({ + ...baseRequest, + method: 'reportOrigin', + origin: 'https://example.com', + } as JsonRpcRequest & { origin: string }); + assertIsJsonRpcSuccess(response); + + expect(response.result).toBe('https://example.com'); + }); + + it('throws if handler actionNames are configured without a messenger', () => { + const actionHandler = { + implementation: (() => undefined) as JsonRpcMiddleware< + JsonRpcRequest, + Json + >, + actionNames: ['Example:TestAction'] as const, + }; + + expect(() => + createMethodMiddleware({ + handlers: { callAction: actionHandler }, + hooks: {}, + }), + ).toThrow('A messenger is required when a handler declares actionNames.'); + }); + + it('passes a delegated messenger to the handler', async () => { + type TestAction = { + type: 'Example:TestAction'; + handler: () => Promise; + }; + + const messengerHandler = { + implementation: async ( + _req, + res, + _next, + end, + _hooks, + messenger, + ): Promise => { + res.result = await messenger.call('Example:TestAction'); + return end(); + }, + actionNames: ['Example:TestAction'] as const, + } satisfies MethodHandler; + + const rootMessenger = new Messenger({ + namespace: MOCK_ANY_NAMESPACE, + }); + rootMessenger.registerActionHandler( + 'Example:TestAction', + async () => 'action-result', + ); + + const middleware = createMethodMiddleware({ + handlers: { callAction: messengerHandler }, + messenger: rootMessenger, + hooks: {}, + }); + const engine = new JsonRpcEngine(); + engine.push(middleware); + + const response = await engine.handle({ + ...baseRequest, + method: 'callAction', + }); + assertIsJsonRpcSuccess(response); + + expect(response.result).toBe('action-result'); + }); + + it('throws an error if a required hook is missing', () => { + const handler = makeHandler((_req, _res, _next, end) => end(), { + hook1: true, + hook2: true, + }); + const hooks = { hook1: (): number => 42 }; + + expect(() => + createMethodMiddleware({ + handlers: { method1: handler, method2: handler }, + // @ts-expect-error Intentionally missing a required hook. + hooks, + }), + ).toThrow('Missing expected hooks'); + }); + + it('throws an error if an extraneous hook is provided', () => { + const handler = makeHandler((_req, _res, _next, end) => end(), { + hook1: true, + hook2: true, + }); + const hooks = { + ...getDefaultHooks(), + extraneousHook: (): number => 100, + }; + + expect(() => + createMethodMiddleware({ + handlers: { method1: handler, method2: handler }, + hooks, + }), + ).toThrow('Received unexpected hooks'); + }); +}); diff --git a/packages/json-rpc-engine/src/createMethodMiddleware.ts b/packages/json-rpc-engine/src/createMethodMiddleware.ts new file mode 100644 index 00000000000..8dfac30fce4 --- /dev/null +++ b/packages/json-rpc-engine/src/createMethodMiddleware.ts @@ -0,0 +1,225 @@ +import type { ActionConstraint } from '@metamask/messenger'; +import type { Messenger } from '@metamask/messenger'; +import { rpcErrors } from '@metamask/rpc-errors'; +import type { + Json, + JsonRpcParams, + JsonRpcRequest, + PendingJsonRpcResponse, +} from '@metamask/utils'; + +import type { + JsonRpcEngineEndCallback, + JsonRpcEngineNextCallback, + JsonRpcMiddleware, +} from './JsonRpcEngine.js'; +import { + assertExpectedHooks, + createHandlerMessenger, + selectHooks, + UnionToIntersection, +} from './v2/utils.js'; + +type HandlerActions = Handler extends { + implementation: (...args: infer Args) => unknown; +} + ? Args extends [ + unknown, + unknown, + unknown, + unknown, + unknown, + infer HandlerMessenger, + ] + ? HandlerMessenger extends Messenger + ? Actions + : never + : never + : never; + +type HandlerHooks = Handler extends { + implementation: (...args: infer Args) => unknown; +} + ? Args extends [ + unknown, + unknown, + unknown, + unknown, + infer ArgHooks, + ...unknown[], + ] + ? ArgHooks extends Record + ? ArgHooks + : never + : never + : never; + +/** + * A {@link MethodHandler} implementation. + * + * @deprecated Use the v2 `createMethodMiddleware` instead. + */ +export type MethodHandlerImplementation< + Hooks extends Record = never, + MessengerActions extends ActionConstraint = never, + Params extends JsonRpcParams = JsonRpcParams, + Result extends Json = Json, + RequestExtras extends Record = Record, +> = ( + req: JsonRpcRequest & RequestExtras, + res: PendingJsonRpcResponse, + next: JsonRpcEngineNextCallback, + end: JsonRpcEngineEndCallback, + hooks: Hooks, + messenger: Messenger, +) => Promise | void; + +/** + * A handler for {@link createMethodMiddleware}. + * + * @deprecated Use the v2 `createMethodMiddleware` instead. + */ +export type MethodHandler< + Hooks extends Record = never, + MessengerActions extends ActionConstraint = never, + Params extends JsonRpcParams = JsonRpcParams, + Result extends Json = Json, + RequestExtras extends Record = Record, +> = { + implementation: MethodHandlerImplementation< + Hooks, + MessengerActions, + Params, + Result, + RequestExtras + >; +} & ([Hooks] extends [never] + ? { hookNames?: undefined } + : { hookNames: { [Key in keyof Hooks]: true } }) & + ([MessengerActions] extends [never] + ? { actionNames?: undefined } + : { actionNames: readonly MessengerActions['type'][] }); + +type AnyMethodHandler = { + implementation( + this: void, + req: JsonRpcRequest, + res: PendingJsonRpcResponse, + next: JsonRpcEngineNextCallback, + end: JsonRpcEngineEndCallback, + hooks: unknown, + messenger: unknown, + ): Promise | void; + hookNames?: Record; + actionNames?: readonly string[]; +}; + +type CreateMethodMiddlewareBaseOptions< + Handlers extends Record, +> = { + handlers: Handlers; + // Due to a quirk of TypeScript's inference over generics, the hooks property must + // be present even if no hooks are needed. Otherwise, TypeScript will fail to infer + // the correct type for the messenger property. `Record` is the + // (hopefully) least confusing way to satisfy this requirement. + hooks: [HandlerHooks] extends [never] + ? Record + : UnionToIntersection>; + /** + * Called when a handler throws, before the error is forwarded to `end`. + * Intended for logging; must not throw. + */ + onError?: (error: unknown, request: JsonRpcRequest) => void; +}; + +/** + * Options for {@link createMethodMiddleware}. + * + * @deprecated Use the v2 `createMethodMiddleware` instead. + */ +export type CreateMethodMiddlewareOptions< + Handlers extends Record, +> = CreateMethodMiddlewareBaseOptions & + ([HandlerActions] extends [never] + ? { + messenger?: undefined; + } + : { + messenger: Messenger>; + }); + +type ResolvedHandler = { + implementation: AnyMethodHandler['implementation']; + hooks: Record; + messenger?: Messenger | undefined; +}; + +/** + * Create a JSON-RPC middleware that handles the passed JSON-RPC method handlers using the messenger and hooks. + * + * @deprecated Use the v2 `createMethodMiddleware` instead. + * @param options The options. + * @param options.handlers - The JSON-RPC method handler implementations. + * @param options.messenger - The messenger to be used by the handlers. + * @param options.hooks - The hooks to be used by the handlers. + * @returns A JsonRpcEngineV2 middleware. + */ +export function createMethodMiddleware< + Handlers extends Record, +>( + options: CreateMethodMiddlewareOptions, +): JsonRpcMiddleware { + const { messenger: rootMessenger, onError } = options; + const allHooks = options.hooks as Record; + + const expectedHookNames = new Set( + Object.values(options.handlers).flatMap((handler) => + handler.hookNames ? Object.getOwnPropertyNames(handler.hookNames) : [], + ), + ); + assertExpectedHooks(allHooks, expectedHookNames); + + const handlers = Object.entries(options.handlers).reduce< + Record + >((accumulator, [handlerName, handler]) => { + const handlerHooks = selectHooks(allHooks, handler.hookNames) ?? {}; + const handlerMessenger = createHandlerMessenger< + HandlerActions + >({ + namespace: handlerName, + actionNames: handler.actionNames as + | readonly HandlerActions['type'][] + | undefined, + rootMessenger, + }); + + accumulator[handlerName] = { + implementation: handler.implementation, + hooks: handlerHooks, + messenger: handlerMessenger, + }; + return accumulator; + }, {}); + + // This should technically use createAsyncMiddleware, but we get around this by catching + // all handler errors. + // eslint-disable-next-line @typescript-eslint/no-misused-promises + return async (req, res, next, end) => { + const handler = handlers[req.method]; + if (!handler) { + return next(); + } + + const { implementation, hooks: handlerHooks, messenger } = handler; + try { + return await implementation(req, res, next, end, handlerHooks, messenger); + } catch (error) { + onError?.(error, req); + return end( + error instanceof Error + ? error + : rpcErrors.internal({ data: error as Json }), + ); + } + }; +} diff --git a/packages/json-rpc-engine/src/createOriginMiddleware.test.ts b/packages/json-rpc-engine/src/createOriginMiddleware.test.ts new file mode 100644 index 00000000000..568b2363861 --- /dev/null +++ b/packages/json-rpc-engine/src/createOriginMiddleware.test.ts @@ -0,0 +1,27 @@ +import { JsonRpcRequest } from '@metamask/utils'; + +import { makeRequest } from '../tests/utils.js'; +import { createOriginMiddleware } from './createOriginMiddleware.js'; +import { JsonRpcEngine } from './JsonRpcEngine.js'; + +describe('createOriginMiddleware', () => { + it('adds the origin property to the request', async () => { + const origin = 'https://metamask.io'; + const engine = new JsonRpcEngine(); + + engine.push(createOriginMiddleware(origin)); + + engine.push((request, response, _next, end) => { + response.result = ( + request as unknown as JsonRpcRequest & { origin: string } + ).origin; + end(); + }); + + expect(await engine.handle(makeRequest())).toStrictEqual({ + id: '1', + jsonrpc: '2.0', + result: origin, + }); + }); +}); diff --git a/packages/json-rpc-engine/src/createOriginMiddleware.ts b/packages/json-rpc-engine/src/createOriginMiddleware.ts new file mode 100644 index 00000000000..c615f7996e3 --- /dev/null +++ b/packages/json-rpc-engine/src/createOriginMiddleware.ts @@ -0,0 +1,19 @@ +import { Json, JsonRpcRequest } from '@metamask/utils'; + +import { JsonRpcMiddleware } from './JsonRpcEngine.js'; + +/** + * Create a middleware function that adds `origin` to the request object. + * + * @deprecated Use the v2 `createOriginMiddleware` instead. + * @param origin - The origin. + * @returns The middleware. + */ +export function createOriginMiddleware( + origin: string, +): JsonRpcMiddleware { + return (request, _result, next) => { + (request as unknown as JsonRpcRequest & { origin: string }).origin = origin; + next(); + }; +} diff --git a/packages/json-rpc-engine/src/createScaffoldMiddleware.test.ts b/packages/json-rpc-engine/src/createScaffoldMiddleware.test.ts new file mode 100644 index 00000000000..2f87ffe93c6 --- /dev/null +++ b/packages/json-rpc-engine/src/createScaffoldMiddleware.test.ts @@ -0,0 +1,55 @@ +import { rpcErrors } from '@metamask/rpc-errors'; +import type { JsonRpcParams, Json } from '@metamask/utils'; +import { + assertIsJsonRpcSuccess, + assertIsJsonRpcFailure, +} from '@metamask/utils'; + +import type { JsonRpcMiddleware } from './index.js'; +import { JsonRpcEngine, createScaffoldMiddleware } from './index.js'; + +describe('createScaffoldMiddleware', () => { + it('basic middleware test', async () => { + const engine = new JsonRpcEngine(); + + const scaffold: Record< + string, + string | JsonRpcMiddleware + > = { + method1: 'foo', + method2: (_request, response, _next, end) => { + response.result = 42; + end(); + }, + method3: (_request, response, _next, end) => { + response.error = rpcErrors.internal({ message: 'method3' }); + end(); + }, + }; + + engine.push(createScaffoldMiddleware(scaffold)); + engine.push((_request, response, _next, end) => { + response.result = 'passthrough'; + end(); + }); + + const payload = { id: 1, jsonrpc: '2.0' as const }; + + const response1 = await engine.handle({ ...payload, method: 'method1' }); + const response2 = await engine.handle({ ...payload, method: 'method2' }); + const response3 = await engine.handle({ ...payload, method: 'method3' }); + const response4 = await engine.handle({ ...payload, method: 'unknown' }); + + assertIsJsonRpcSuccess(response1); + expect(response1.result).toBe('foo'); + + assertIsJsonRpcSuccess(response2); + expect(response2.result).toBe(42); + + assertIsJsonRpcFailure(response3); + expect(response3.error.message).toBe('method3'); + + assertIsJsonRpcSuccess(response4); + expect(response4.result).toBe('passthrough'); + }); +}); diff --git a/packages/json-rpc-engine/src/createScaffoldMiddleware.ts b/packages/json-rpc-engine/src/createScaffoldMiddleware.ts new file mode 100644 index 00000000000..612b08ca082 --- /dev/null +++ b/packages/json-rpc-engine/src/createScaffoldMiddleware.ts @@ -0,0 +1,38 @@ +import type { Json, JsonRpcParams, JsonRpcSuccess } from '@metamask/utils'; + +import type { JsonRpcMiddleware } from './JsonRpcEngine.js'; + +type ScaffoldMiddlewareHandler< + Params extends JsonRpcParams, + Result extends Json, +> = JsonRpcMiddleware | Json; + +/** + * Creates a middleware function from an object of RPC method handler functions, + * keyed to particular method names. If a method corresponding to a key of this + * object is requested, this middleware will pass it to the corresponding + * handler and return the result. + * + * @deprecated Use `JsonRpcEngineV2` and its corresponding types instead. + * @param handlers - The RPC method handler functions. + * @returns The scaffold middleware function. + */ +export function createScaffoldMiddleware(handlers: { + [methodName: string]: ScaffoldMiddlewareHandler; +}): JsonRpcMiddleware { + return (req, res, next, end) => { + const handler = handlers[req.method]; + // if no handler, return + if (handler === undefined) { + return next(); + } + + // if handler is fn, call as middleware + if (typeof handler === 'function') { + return handler(req, res, next, end); + } + // if handler is some other value, use as result + (res as JsonRpcSuccess).result = handler; + return end(); + }; +} diff --git a/packages/json-rpc-engine/src/getUniqueId.ts b/packages/json-rpc-engine/src/getUniqueId.ts new file mode 100644 index 00000000000..01a587a73d3 --- /dev/null +++ b/packages/json-rpc-engine/src/getUniqueId.ts @@ -0,0 +1,16 @@ +// uint32 (two's complement) max +// more conservative than Number.MAX_SAFE_INTEGER +const MAX = 4_294_967_295; +let idCounter = Math.floor(Math.random() * MAX); + +/** + * Gets an ID that is guaranteed to be unique so long as no more than + * 4_294_967_295 (uint32 max) IDs are created, or the IDs are rapidly turned + * over. + * + * @returns The unique ID. + */ +export function getUniqueId(): number { + idCounter = (idCounter + 1) % MAX; + return idCounter; +} diff --git a/packages/json-rpc-engine/src/idRemapMiddleware.test.ts b/packages/json-rpc-engine/src/idRemapMiddleware.test.ts new file mode 100644 index 00000000000..adeec028573 --- /dev/null +++ b/packages/json-rpc-engine/src/idRemapMiddleware.test.ts @@ -0,0 +1,59 @@ +/* eslint-disable @typescript-eslint/no-non-null-assertion */ + +import { JsonRpcEngine, createIdRemapMiddleware } from './index.js'; + +describe('idRemapMiddleware', () => { + it('basic middleware test', async () => { + const engine = new JsonRpcEngine(); + + const observedIds: Record> = { + before: {}, + after: {}, + }; + + engine.push(function (request, response, next, _end) { + observedIds.before!.request = request.id; + observedIds.before!.response = response.id; + next(); + }); + engine.push(createIdRemapMiddleware()); + engine.push(function (request, response, _next, end) { + observedIds.after!.request = request.id; + observedIds.after!.response = response.id; + // set result so it doesnt error + response.result = true; + end(); + }); + + const payload = { id: 1, jsonrpc: '2.0' as const, method: 'hello' }; + const payloadCopy = { ...payload }; + + await new Promise((resolve) => { + engine.handle(payload, function (error, response) { + expect(error).toBeNull(); + expect(response).toBeDefined(); + // collected data + expect(observedIds.before!.request).toBeDefined(); + expect(observedIds.before!.response).toBeDefined(); + expect(observedIds.after!.request).toBeDefined(); + expect(observedIds.after!.response).toBeDefined(); + // data matches expectations + expect(observedIds.before!.request).toStrictEqual( + observedIds.before!.response, + ); + expect(observedIds.after!.request).toStrictEqual( + observedIds.after!.response, + ); + // correct behavior + expect(observedIds.before!.request).not.toStrictEqual( + observedIds.after!.request, + ); + + expect(observedIds.before!.request).toStrictEqual(response.id); + expect(payload.id).toStrictEqual(response.id); + expect(payloadCopy.id).toStrictEqual(response.id); + resolve(); + }); + }); + }); +}); diff --git a/packages/json-rpc-engine/src/idRemapMiddleware.ts b/packages/json-rpc-engine/src/idRemapMiddleware.ts new file mode 100644 index 00000000000..f966f623ced --- /dev/null +++ b/packages/json-rpc-engine/src/idRemapMiddleware.ts @@ -0,0 +1,31 @@ +import type { Json, JsonRpcParams } from '@metamask/utils'; + +import { getUniqueId } from './getUniqueId.js'; +import type { JsonRpcMiddleware } from './JsonRpcEngine.js'; + +/** + * Returns a middleware function that overwrites the `id` property of each + * request with an ID that is guaranteed to be unique, and restores the original + * ID in a return handler. + * + * If used, should be the first middleware in the stack. + * + * @deprecated Use `JsonRpcEngineV2` and its corresponding types instead. + * @returns The ID remap middleware function. + */ +export function createIdRemapMiddleware(): JsonRpcMiddleware< + JsonRpcParams, + Json +> { + return (request, response, next, _end) => { + const originalId = request.id; + const newId = getUniqueId(); + request.id = newId; + response.id = newId; + next((done) => { + request.id = originalId; + response.id = originalId; + done(); + }); + }; +} diff --git a/packages/json-rpc-engine/src/index.test.ts b/packages/json-rpc-engine/src/index.test.ts new file mode 100644 index 00000000000..b06b4fa4faf --- /dev/null +++ b/packages/json-rpc-engine/src/index.test.ts @@ -0,0 +1,19 @@ +import * as allExports from './index.js'; + +describe('@metamask/json-rpc-engine', () => { + it('has expected JavaScript exports', () => { + expect(Object.keys(allExports)).toMatchInlineSnapshot(` + [ + "asV2Middleware", + "createAsyncMiddleware", + "createMethodMiddleware", + "createOriginMiddleware", + "createScaffoldMiddleware", + "getUniqueId", + "createIdRemapMiddleware", + "JsonRpcEngine", + "mergeMiddleware", + ] + `); + }); +}); diff --git a/packages/json-rpc-engine/src/index.ts b/packages/json-rpc-engine/src/index.ts new file mode 100644 index 00000000000..e5d6faccbda --- /dev/null +++ b/packages/json-rpc-engine/src/index.ts @@ -0,0 +1,26 @@ +export { asV2Middleware } from './asV2Middleware.js'; +export type { + AsyncJsonRpcEngineNextCallback, + AsyncJsonrpcMiddleware, +} from './createAsyncMiddleware.js'; +export { createAsyncMiddleware } from './createAsyncMiddleware.js'; +export type { + CreateMethodMiddlewareOptions, + MethodHandler, + MethodHandlerImplementation, +} from './createMethodMiddleware.js'; +export { createMethodMiddleware } from './createMethodMiddleware.js'; +export { createOriginMiddleware } from './createOriginMiddleware.js'; +export { createScaffoldMiddleware } from './createScaffoldMiddleware.js'; +export { getUniqueId } from './getUniqueId.js'; +export { createIdRemapMiddleware } from './idRemapMiddleware.js'; +export type { + JsonRpcEngineCallbackError, + JsonRpcEngineReturnHandler, + JsonRpcEngineNextCallback, + JsonRpcEngineEndCallback, + JsonRpcMiddleware, + JsonRpcNotificationHandler, +} from './JsonRpcEngine.js'; +export { JsonRpcEngine } from './JsonRpcEngine.js'; +export { mergeMiddleware } from './mergeMiddleware.js'; diff --git a/packages/json-rpc-engine/src/mergeMiddleware.test.ts b/packages/json-rpc-engine/src/mergeMiddleware.test.ts new file mode 100644 index 00000000000..d24f589c281 --- /dev/null +++ b/packages/json-rpc-engine/src/mergeMiddleware.test.ts @@ -0,0 +1,196 @@ +import type { JsonRpcRequest } from '@metamask/utils'; +import { assertIsJsonRpcSuccess, hasProperty } from '@metamask/utils'; + +import { JsonRpcEngine, mergeMiddleware } from './index.js'; + +const jsonrpc = '2.0' as const; + +describe('mergeMiddleware', () => { + it('basic', async () => { + const engine = new JsonRpcEngine(); + let originalRequest: JsonRpcRequest; + + engine.push( + mergeMiddleware([ + function (req, res, _next, end): void { + originalRequest = req; + res.result = 'saw merged middleware'; + end(); + }, + ]), + ); + + const payload = { id: 1, jsonrpc, method: 'hello' }; + + await new Promise((resolve) => { + engine.handle(payload, function (error, response) { + expect(error).toBeNull(); + expect(response).toBeDefined(); + expect(originalRequest.id).toStrictEqual(response.id); + expect(originalRequest.jsonrpc).toStrictEqual(response.jsonrpc); + expect(hasProperty(response, 'result')).toBe(true); + resolve(); + }); + }); + }); + + it('handles next handler correctly for multiple merged', async () => { + const engine = new JsonRpcEngine(); + + engine.push( + mergeMiddleware([ + (_request, response, next, _end): void => { + next((callback) => { + // TODO: Replace `any` with type + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (response as any).copy = response.result; + callback(); + }); + }, + (_req, res, _next, end): void => { + res.result = true; + end(); + }, + ]), + ); + + const payload = { id: 1, jsonrpc, method: 'hello' }; + + await new Promise((resolve) => { + engine.handle(payload, function (error, res) { + expect(error).toBeNull(); + + // @ts-expect-error - `copy` is not a valid property of `JsonRpcSuccess`. + const { copy, ...rest } = res; + assertIsJsonRpcSuccess(rest); + + expect(rest.result).toStrictEqual(copy); + resolve(); + }); + }); + }); + + it('decorate res', async () => { + const engine = new JsonRpcEngine(); + let originalRequest: JsonRpcRequest; + + engine.push( + mergeMiddleware([ + function (request, response, _next, end): void { + originalRequest = request; + // TODO: Replace `any` with type + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (response as any).xyz = true; + response.result = true; + end(); + }, + ]), + ); + + const payload = { id: 1, jsonrpc, method: 'hello' }; + + await new Promise((resolve) => { + engine.handle(payload, function (error, res) { + expect(error).toBeNull(); + expect(res).toBeDefined(); + expect(originalRequest.id).toStrictEqual(res.id); + expect(originalRequest.jsonrpc).toStrictEqual(res.jsonrpc); + // TODO: Replace `any` with type + // eslint-disable-next-line @typescript-eslint/no-explicit-any + expect((res as any).xyz).toBe(true); + resolve(); + }); + }); + }); + + it('decorate req', async () => { + const engine = new JsonRpcEngine(); + let originalRequest: JsonRpcRequest; + + engine.push( + mergeMiddleware([ + function (request, response, _next, end): void { + originalRequest = request; + // TODO: Replace `any` with type + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (request as any).xyz = true; + response.result = true; + end(); + }, + ]), + ); + + const payload = { id: 1, jsonrpc, method: 'hello' }; + + await new Promise((resolve) => { + engine.handle(payload, function (error, response) { + expect(error).toBeNull(); + expect(response).toBeDefined(); + expect(originalRequest.id).toStrictEqual(response.id); + expect(originalRequest.jsonrpc).toStrictEqual(response.jsonrpc); + // TODO: Replace `any` with type + // eslint-disable-next-line @typescript-eslint/no-explicit-any + expect((originalRequest as any).xyz).toBe(true); + resolve(); + }); + }); + }); + + it('should not error even if end not called', async () => { + const engine = new JsonRpcEngine(); + + engine.push( + mergeMiddleware([(_request, _response, next, _end): void => next()]), + ); + engine.push((_request, response, _next, end) => { + response.result = true; + end(); + }); + + const payload = { id: 1, jsonrpc, method: 'hello' }; + + await new Promise((resolve) => { + engine.handle(payload, function (error, response) { + expect(error).toBeNull(); + expect(response).toBeDefined(); + resolve(); + }); + }); + }); + + it('handles next handler correctly across middleware', async () => { + const engine = new JsonRpcEngine(); + + engine.push( + mergeMiddleware([ + (_request, response, next, _end): void => { + next((callback) => { + // TODO: Replace `any` with type + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (response as any).copy = response.result; + callback(); + }); + }, + ]), + ); + + engine.push((_request, response, _next, end) => { + response.result = true; + end(); + }); + const payload = { id: 1, jsonrpc, method: 'hello' }; + + await new Promise((resolve) => { + engine.handle(payload, function (error, response) { + expect(error).toBeNull(); + + // @ts-expect-error - `copy` is not a valid property of `JsonRpcSuccess`. + const { copy, ...rest } = response; + assertIsJsonRpcSuccess(rest); + + expect(rest.result).toStrictEqual(copy); + resolve(); + }); + }); + }); +}); diff --git a/packages/json-rpc-engine/src/mergeMiddleware.ts b/packages/json-rpc-engine/src/mergeMiddleware.ts new file mode 100644 index 00000000000..7928dfb1b10 --- /dev/null +++ b/packages/json-rpc-engine/src/mergeMiddleware.ts @@ -0,0 +1,19 @@ +import type { Json, JsonRpcParams } from '@metamask/utils'; + +import type { JsonRpcMiddleware } from './JsonRpcEngine.js'; +import { JsonRpcEngine } from './JsonRpcEngine.js'; + +/** + * Takes a stack of middleware and joins them into a single middleware function. + * + * @deprecated Use `JsonRpcEngineV2` and its corresponding types instead. + * @param middlewareStack - The middleware stack to merge. + * @returns The merged middleware function. + */ +export function mergeMiddleware( + middlewareStack: JsonRpcMiddleware[], +): JsonRpcMiddleware { + const engine = new JsonRpcEngine(); + middlewareStack.forEach((middleware) => engine.push(middleware)); + return engine.asMiddleware(); +} diff --git a/packages/json-rpc-engine/src/v2/JsonRpcEngineV2.test.ts b/packages/json-rpc-engine/src/v2/JsonRpcEngineV2.test.ts new file mode 100644 index 00000000000..def258851af --- /dev/null +++ b/packages/json-rpc-engine/src/v2/JsonRpcEngineV2.test.ts @@ -0,0 +1,1404 @@ +/* eslint-disable n/callback-return */ // next() is not a Node.js callback. +import type { Json, JsonRpcId } from '@metamask/utils'; +import { createDeferredPromise } from '@metamask/utils'; + +import { + makeNotification, + makeNotificationMiddleware, + makeNullMiddleware, + makeRequest, + makeRequestMiddleware, +} from '../../tests/utils.js'; +import type { JsonRpcMiddleware, ResultConstraint } from './JsonRpcEngineV2.js'; +import { JsonRpcEngineV2 } from './JsonRpcEngineV2.js'; +import type { EmptyContext } from './MiddlewareContext.js'; +import { MiddlewareContext } from './MiddlewareContext.js'; +import { isRequest, JsonRpcEngineError, stringify } from './utils.js'; +import type { + JsonRpcCall, + JsonRpcNotification, + JsonRpcRequest, +} from './utils.js'; + +const jsonrpc = '2.0' as const; + +describe('JsonRpcEngineV2', () => { + describe('create', () => { + it('throws if the middleware array is empty', () => { + expect(() => JsonRpcEngineV2.create({ middleware: [] })).toThrow( + new JsonRpcEngineError('Middleware array cannot be empty'), + ); + }); + + it('type errors if passed middleware with incompatible context types', async () => { + const middleware1: JsonRpcMiddleware< + JsonRpcCall, + ResultConstraint, + MiddlewareContext<{ foo: string }> + > = ({ next, context }) => { + context.set('foo', 'bar'); + return next(); + }; + + const middleware2: JsonRpcMiddleware< + JsonRpcCall, + ResultConstraint, + MiddlewareContext<{ foo: number }> + > = ({ context }) => context.assertGet('foo'); + const engine = JsonRpcEngineV2.create({ + middleware: [middleware1, middleware2], + }); + + // @ts-expect-error - The engine is `InvalidEngine`. + expect(await engine.handle(makeRequest())).toBe('bar'); + }); + + // Keeping this here for documentation purposes. + // eslint-disable-next-line jest/no-disabled-tests + it.skip('type errors if passed middleware with incompatible request types', async () => { + const middleware1: JsonRpcMiddleware = ({ next }) => + next(); + const middleware2: JsonRpcMiddleware = () => { + return 'foo'; + }; + const engine = JsonRpcEngineV2.create({ + middleware: [middleware1, middleware2], + }); + + // TODO: We want this to cause a type error, but it's unclear if it can be + // made to work due to the difficulty (impossibility?) of distinguishing + // between these two cases: + // - JsonRpcMiddleware | JsonRpcMiddleware (invalid) + // - JsonRpcMiddleware | JsonRpcMiddleware (valid) + expect(await engine.handle(makeRequest())).toBe('foo'); + }); + }); + + describe('handle', () => { + describe('notifications', () => { + it('passes the notification through a middleware', async () => { + const middleware: JsonRpcMiddleware = jest.fn(); + const engine = JsonRpcEngineV2.create({ + middleware: [middleware], + }); + const notification = { jsonrpc, method: 'test_request' }; + + await engine.handle(notification); + + expect(middleware).toHaveBeenCalledTimes(1); + expect(middleware).toHaveBeenCalledWith({ + request: notification, + context: expect.any(Map), + next: expect.any(Function), + }); + }); + + it('returns no result', async () => { + const engine = JsonRpcEngineV2.create({ + middleware: [jest.fn()], + }); + const notification = { jsonrpc, method: 'test_request' }; + + const result = await engine.handle(notification); + + expect(result).toBeUndefined(); + }); + + it('returns no result, with multiple middleware', async () => { + const middleware1 = jest.fn(({ next }) => next()); + const middleware2 = jest.fn(); + const engine = JsonRpcEngineV2.create({ + middleware: [middleware1, middleware2], + }); + const notification = { jsonrpc, method: 'test_request' }; + + const result = await engine.handle(notification); + + expect(result).toBeUndefined(); + expect(middleware1).toHaveBeenCalledTimes(1); + expect(middleware2).toHaveBeenCalledTimes(1); + }); + + it('throws if a middleware throws', async () => { + const engine = JsonRpcEngineV2.create({ + middleware: [ + jest.fn(() => { + throw new Error('test'); + }), + ], + }); + const notification = { jsonrpc, method: 'test_request' }; + + await expect(engine.handle(notification)).rejects.toThrow( + new Error('test'), + ); + }); + + it('throws if a middleware throws, with multiple middleware', async () => { + const engine = JsonRpcEngineV2.create({ + middleware: [ + jest.fn(({ next }) => next()), + jest.fn(() => { + throw new Error('test'); + }), + ], + }); + const notification = { jsonrpc, method: 'test_request' }; + + await expect(engine.handle(notification)).rejects.toThrow( + new Error('test'), + ); + }); + + it('throws if a result is returned, from the first middleware', async () => { + const engine = JsonRpcEngineV2.create({ + middleware: [(): string => 'foo'], + }); + const notification = { jsonrpc, method: 'test_request' }; + + await expect(engine.handle(notification)).rejects.toThrow( + new JsonRpcEngineError( + `Result returned for notification: ${stringify(notification)}`, + ), + ); + }); + + it('throws if a result is returned, from a later middleware', async () => { + const engine = JsonRpcEngineV2.create({ + middleware: [ + async ({ next }): Promise => { + await next(); + return undefined; + }, + makeNullMiddleware(), + ], + }); + const notification = { jsonrpc, method: 'test_request' }; + + await expect(engine.handle(notification)).rejects.toThrow( + new JsonRpcEngineError( + `Result returned for notification: ${stringify(notification)}`, + ), + ); + }); + + it('throws if a middleware calls next() multiple times', async () => { + const engine = JsonRpcEngineV2.create({ + middleware: [ + jest.fn(async ({ next }) => { + await next(); + await next(); + }), + jest.fn(), + ], + }); + const notification = { jsonrpc, method: 'test_request' }; + + await expect(engine.handle(notification)).rejects.toThrow( + new JsonRpcEngineError( + `Middleware attempted to call next() multiple times for request: ${stringify(notification)}`, + ), + ); + }); + }); + + describe('requests', () => { + it('returns a result from the middleware', async () => { + const middleware = jest.fn(() => null); + const engine = JsonRpcEngineV2.create({ + middleware: [middleware], + }); + const request = makeRequest(); + + const result = await engine.handle(request); + + expect(result).toBeNull(); + expect(middleware).toHaveBeenCalledTimes(1); + expect(middleware).toHaveBeenCalledWith({ + request, + context: expect.any(Map), + next: expect.any(Function), + }); + }); + + it('returns a result from the middleware, with multiple middleware', async () => { + const middleware1: JsonRpcMiddleware = jest.fn(({ next }) => next()); + const middleware2: JsonRpcMiddleware = jest.fn(() => null); + const engine = JsonRpcEngineV2.create({ + middleware: [middleware1, middleware2], + }); + const request = makeRequest(); + + const result = await engine.handle(request); + + expect(result).toBeNull(); + expect(middleware1).toHaveBeenCalledTimes(1); + expect(middleware1).toHaveBeenCalledWith({ + request, + context: expect.any(Map), + next: expect.any(Function), + }); + expect(middleware2).toHaveBeenCalledTimes(1); + expect(middleware2).toHaveBeenCalledWith({ + request, + context: expect.any(Map), + next: expect.any(Function), + }); + }); + + it('throws if a middleware throws', async () => { + const engine = JsonRpcEngineV2.create({ + middleware: [ + jest.fn(() => { + throw new Error('test'); + }), + ], + }); + + await expect(engine.handle(makeRequest())).rejects.toThrow( + new Error('test'), + ); + }); + + it('throws if a middleware throws, with multiple middleware', async () => { + const engine = JsonRpcEngineV2.create({ + middleware: [ + jest.fn(({ next }) => next()), + jest.fn(() => { + throw new Error('test'); + }), + ], + }); + + await expect(engine.handle(makeRequest())).rejects.toThrow( + new Error('test'), + ); + }); + + it('throws if no middleware returns a result', async () => { + const engine = JsonRpcEngineV2.create({ + middleware: [jest.fn(({ next }) => next()), jest.fn()], + }); + const request = makeRequest(); + + await expect(engine.handle(makeRequest())).rejects.toThrow( + new JsonRpcEngineError( + `Nothing ended request: ${stringify(request)}`, + ), + ); + }); + + it('throws if a middleware calls next() multiple times', async () => { + const engine = JsonRpcEngineV2.create({ + middleware: [ + jest.fn(async ({ next }) => { + await next(); + await next(); + }), + makeNullMiddleware(), + ], + }); + const request = makeRequest(); + + await expect(engine.handle(request)).rejects.toThrow( + new JsonRpcEngineError( + `Middleware attempted to call next() multiple times for request: ${stringify(request)}`, + ), + ); + }); + }); + + describe('context', () => { + it('passes the context to the middleware', async () => { + const engine = JsonRpcEngineV2.create< + JsonRpcMiddleware + >({ + middleware: [ + ({ context }): null => { + expect(context).toBeInstanceOf(Map); + return null; + }, + ], + }); + + await engine.handle(makeRequest()); + }); + + it('propagates context changes to subsequent middleware', async () => { + type Context = MiddlewareContext<{ foo: string }>; + const middleware1: JsonRpcMiddleware< + JsonRpcCall, + Json | void, + Context + > = async ({ context, next }) => { + context.set('foo', 'bar'); + return next(); + }; + const middleware2: JsonRpcMiddleware< + JsonRpcCall, + string | undefined, + Context + > = ({ context }) => { + return context.get('foo'); + }; + const engine = JsonRpcEngineV2.create({ + middleware: [middleware1, middleware2], + }); + + const result = await engine.handle(makeRequest()); + + expect(result).toBe('bar'); + }); + + it('accepts an initial context', async () => { + const initialContext = new MiddlewareContext>(); + initialContext.set('foo', 'bar'); + const middleware: JsonRpcMiddleware< + JsonRpcRequest, + string, + MiddlewareContext> + > = ({ context }) => context.assertGet('foo'); + const engine = JsonRpcEngineV2.create({ + middleware: [middleware], + }); + + const result = await engine.handle(makeRequest(), { + context: initialContext, + }); + + expect(result).toBe('bar'); + }); + + it('accepts an initial context as a KeyValues object', async () => { + const initialContext = { foo: 'bar' } as const; + const middleware: JsonRpcMiddleware< + JsonRpcRequest, + string, + MiddlewareContext> + > = ({ context }) => context.assertGet('foo'); + const engine = JsonRpcEngineV2.create({ + middleware: [middleware], + }); + + const result = await engine.handle(makeRequest(), { + context: initialContext, + }); + + expect(result).toBe('bar'); + }); + + it('accepts middleware with different context types', async () => { + const middleware1: JsonRpcMiddleware< + JsonRpcCall, + ResultConstraint, + MiddlewareContext<{ foo: string }> + > = ({ context, next }) => { + context.set('foo', 'bar'); + return next(); + }; + + const middleware2: JsonRpcMiddleware< + JsonRpcCall, + ResultConstraint + > = ({ next }) => next(); + + const middleware3: JsonRpcMiddleware< + JsonRpcCall, + ResultConstraint, + EmptyContext + > = ({ next }) => next(); + + const middleware4: JsonRpcMiddleware< + JsonRpcCall, + string, + MiddlewareContext<{ foo: string; bar: number }> + > = ({ context }) => context.assertGet('foo'); + + const engine = JsonRpcEngineV2.create({ + middleware: [middleware1, middleware2, middleware3, middleware4], + }); + + const result = await engine.handle(makeRequest()); + + expect(result).toBe('bar'); + }); + + it('throws if a middleware attempts to modify properties of the context', async () => { + const engine = JsonRpcEngineV2.create({ + middleware: [ + ({ context }): void => { + // @ts-expect-error - Destructive testing. + context.set = (): MiddlewareContext => undefined; + }, + ], + }); + + await expect(engine.handle(makeRequest())).rejects.toThrow( + new TypeError(`Cannot add property set, object is not extensible`), + ); + }); + }); + + describe('asynchrony', () => { + it('handles asynchronous middleware', async () => { + const engine = JsonRpcEngineV2.create({ + middleware: [async (): Promise => null], + }); + + const result = await engine.handle(makeRequest()); + + expect(result).toBeNull(); + }); + + it('handles mixed synchronous and asynchronous middleware', async () => { + type Middleware = JsonRpcMiddleware< + JsonRpcRequest, + Json, + MiddlewareContext> + >; + + const engine = JsonRpcEngineV2.create({ + middleware: [ + async ({ context, next }): Promise | undefined> => { + context.set('foo', [1]); + return next(); + }, + ({ context, next }): Promise | undefined> => { + const nums = context.assertGet('foo'); + nums.push(2); + return next(); + }, + async ({ context }): Promise | undefined> => { + const nums = context.assertGet('foo'); + return [...nums, 3]; + }, + ], + }); + + const result = await engine.handle(makeRequest()); + + expect(result).toStrictEqual([1, 2, 3]); + }); + }); + + describe('request mutation', () => { + it('propagates new requests to subsequent middleware', async () => { + const observedParams: number[] = []; + let observedMethod: string | undefined; + const middleware1 = jest.fn(({ request, next }) => { + observedParams.push(request.params[0]); + return next({ + ...request, + params: [2], + }); + }); + const middleware2 = jest.fn(({ request, next }) => { + observedParams.push(request.params[0]); + return next({ + ...request, + method: 'test_request_2', + params: [3], + }); + }); + const middleware3 = jest.fn(({ request }) => { + observedParams.push(request.params[0]); + observedMethod = request.method; + return null; + }); + const engine = JsonRpcEngineV2.create({ + middleware: [middleware1, middleware2, middleware3], + }); + const request = makeRequest({ params: [1] }); + + await engine.handle(request); + + expect(middleware1).toHaveBeenCalledTimes(1); + expect(middleware2).toHaveBeenCalledTimes(1); + expect(middleware3).toHaveBeenCalledTimes(1); + expect(observedMethod).toBe('test_request_2'); + expect(observedParams).toStrictEqual([1, 2, 3]); + }); + + it('throws if directly modifying the request', async () => { + const engine = JsonRpcEngineV2.create({ + middleware: [ + jest.fn(({ request }) => { + // @ts-expect-error - Destructive testing. + request.params = [2]; + }) as JsonRpcMiddleware, + ], + }); + + await expect(engine.handle(makeRequest())).rejects.toThrow( + new TypeError( + `Cannot assign to read only property 'params' of object '#'`, + ), + ); + }); + + it('throws if a middleware attempts to modify the request "id" property', async () => { + const engine = JsonRpcEngineV2.create({ + middleware: [ + jest.fn(async ({ request, next }) => { + return await next({ + ...request, + id: '2', + }); + }), + makeNullMiddleware(), + ], + }); + const request = makeRequest(); + + await expect(engine.handle(request)).rejects.toThrow( + new JsonRpcEngineError( + `Middleware attempted to modify readonly property "id" for request: ${stringify(request)}`, + ), + ); + }); + + it('throws if a middleware attempts to modify the request "jsonrpc" property', async () => { + const engine = JsonRpcEngineV2.create({ + middleware: [ + async ({ + request, + next, + }): Promise | undefined> => { + return await next({ + ...request, + // @ts-expect-error - Destructive testing. + jsonrpc: '3.0', + }); + }, + makeNullMiddleware(), + ], + }); + const request = makeRequest(); + + await expect(engine.handle(request)).rejects.toThrow( + new JsonRpcEngineError( + `Middleware attempted to modify readonly property "jsonrpc" for request: ${stringify(request)}`, + ), + ); + }); + }); + + describe('result handling', () => { + it('updates the result after next() is called', async () => { + const engine = JsonRpcEngineV2.create< + JsonRpcMiddleware + >({ + middleware: [ + async ({ next }): Promise => { + const result = (await next()) as number; + return result + 1; + }, + (): number => 1, + ], + }); + + const result = await engine.handle(makeRequest()); + + expect(result).toBe(2); + }); + + it('updates an undefined result with a new value', async () => { + const engine = JsonRpcEngineV2.create({ + middleware: [ + async ({ next }): Promise => { + await next(); + return null; + }, + makeNotificationMiddleware(), + ], + }); + + const result = await engine.handle(makeRequest()); + + expect(result).toBeNull(); + }); + + it('returning undefined propagates previously defined result', async () => { + const engine = JsonRpcEngineV2.create({ + middleware: [ + jest.fn(async ({ next }) => { + await next(); + }), + makeNullMiddleware(), + ], + }); + + const result = await engine.handle(makeRequest()); + + expect(result).toBeNull(); + }); + + it('catches errors thrown by later middleware', async () => { + let observedError: Error | undefined; + const engine = JsonRpcEngineV2.create({ + middleware: [ + jest.fn(async ({ next }) => { + try { + return await next(); + } catch (error) { + observedError = error as Error; + return null; + } + }), + jest.fn(() => { + throw new Error('test'); + }), + ], + }); + + const result = await engine.handle(makeRequest()); + + expect(result).toBeNull(); + expect(observedError).toStrictEqual(new Error('test')); + }); + + it('handles returned results in reverse middleware order', async () => { + const returnHandlerResults: number[] = []; + const engine = JsonRpcEngineV2.create({ + middleware: [ + async ({ next }): Promise => { + await next(); + returnHandlerResults.push(1); + }, + async ({ next }): Promise => { + await next(); + returnHandlerResults.push(2); + }, + async ({ next }): Promise => { + await next(); + returnHandlerResults.push(3); + }, + makeNullMiddleware(), + ], + }); + + await engine.handle(makeRequest()); + + expect(returnHandlerResults).toStrictEqual([3, 2, 1]); + }); + + it('throws if directly modifying the result', async () => { + const engine = JsonRpcEngineV2.create({ + middleware: [ + async ({ next }): Promise<{ foo: string }> => { + const result = (await next()) as { foo: string }; + result.foo = 'baz'; + return result; + }, + (): { foo: string } => ({ foo: 'bar' }), + ], + }); + + await expect(engine.handle(makeRequest())).rejects.toThrow( + new TypeError( + `Cannot assign to read only property 'foo' of object '#'`, + ), + ); + }); + + it('returns non-frozen objects', async () => { + const engine = JsonRpcEngineV2.create({ + middleware: [ + (): { foo: string; baz: { qux: number } } => ({ + foo: 'bar', + baz: { qux: 1 }, + }), + ], + }); + + const result = await engine.handle(makeRequest()); + + expect(Object.isFrozen(result)).toBe(false); + expect(Object.isFrozen((result as { baz: { qux: number } }).baz)).toBe( + false, + ); + }); + }); + + describe('parallel requests', () => { + /** + * A "counter" latch that releases when a target count is reached. + * + * @param target - The target count to reach. + * @returns A counter latch. + */ + const makeCounterLatch = ( + target: number, + ): { + increment: () => void; + waitAll: () => Promise; + } => { + let count = 0; + const { promise: countdownPromise, resolve: release } = + createDeferredPromise(); + + return { + increment: (): void => { + count += 1; + if (count === target) { + release(); + } + }, + waitAll: () => countdownPromise, + }; + }; + + /** + * A queue for processing a target number of requests in arbitrary order. + * + * @param size - The size of the queue. + * @returns An "arbitrary" queue. + */ + const makeArbitraryQueue = ( + size: number, + ): { + enqueue: (id: number) => Promise; + dequeue: (id: number) => void; + filled: () => Promise; + } => { + let count = 0; + const queue: { resolve: () => void }[] = new Array(size); + const { promise: gate, resolve: openGate } = createDeferredPromise(); + + const enqueue = async (id: number): Promise => { + const { promise, resolve } = createDeferredPromise(); + queue[id] = { resolve }; + count += 1; + + if (count === size) { + openGate(); + } + return gate.then(() => promise); + }; + + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + const dequeue = (id: number): void => queue[id]!.resolve(); + return { enqueue, dequeue, filled: () => gate }; + }; + + it('processes requests in parallel with isolated contexts', async () => { + const target = 32; + const { promise: gate, resolve: openGate } = createDeferredPromise(); + const latch = makeCounterLatch(target); + + let inFlight = 0; + let maxInFlight = 0; + + type Context = MiddlewareContext<{ id: JsonRpcId }>; + const inflightMiddleware: JsonRpcMiddleware< + JsonRpcRequest, + Json, + Context + > = async ({ context, next, request }) => { + context.set('id', context.get('id') ?? request.id); + + inFlight += 1; + maxInFlight = Math.max(maxInFlight, inFlight); + latch.increment(); + + await gate; + + inFlight -= 1; + return next(); + }; + const resultMiddleware: JsonRpcMiddleware< + JsonRpcRequest, + string, + Context + > = ({ context, request }) => { + return `result:${request.id}:${context.assertGet('id')}`; + }; + const engine = JsonRpcEngineV2.create({ + middleware: [inflightMiddleware, resultMiddleware], + }); + + const requests: JsonRpcRequest[] = Array.from( + { length: target }, + (_, i) => + makeRequest({ + id: `${i}`, + }), + ); + + const resultPromises = requests.map((request) => + engine.handle(request), + ); + + await latch.waitAll(); + expect(inFlight).toBe(target); + openGate(); + + const results = await Promise.all(resultPromises); + expect(results).toStrictEqual( + requests.map((request) => `result:${request.id}:${request.id}`), + ); + expect(inFlight).toBe(0); + expect(maxInFlight).toBe(target); + }); + + it('eagerly processes requests in parallel, i.e. without queueing them', async () => { + const queue = makeArbitraryQueue(3); + type NumericIdRequest = JsonRpcRequest & { id: number }; + const middleware: JsonRpcMiddleware = async ({ + request, + }) => { + await queue.enqueue(request.id); + return null; + }; + const engine = JsonRpcEngineV2.create({ + middleware: [middleware], + }); + + const p0 = engine.handle(makeRequest({ id: 0 })); + const p1 = engine.handle(makeRequest({ id: 1 })); + const p2 = engine.handle(makeRequest({ id: 2 })); + + await queue.filled(); + + queue.dequeue(2); + expect(await p2).toBeNull(); + queue.dequeue(0); + expect(await p0).toBeNull(); + queue.dequeue(1); + expect(await p1).toBeNull(); + }); + }); + }); + + describe('composition', () => { + describe('asMiddleware', () => { + it('ends a request if it returns a value', async () => { + const engine1 = JsonRpcEngineV2.create({ + middleware: [makeNullMiddleware()], + }); + const engine2 = JsonRpcEngineV2.create({ + middleware: [engine1.asMiddleware(), jest.fn(() => 'foo')], + }); + + const result = await engine2.handle(makeRequest()); + + expect(result).toBeNull(); + }); + + it('permits returning undefined if a later middleware ends the request', async () => { + const engine1 = JsonRpcEngineV2.create({ + middleware: [makeNotificationMiddleware()], + }); + const engine2 = JsonRpcEngineV2.create({ + middleware: [engine1.asMiddleware(), makeNullMiddleware()], + }); + + const result = await engine2.handle(makeRequest()); + + expect(result).toBeNull(); + }); + + it('composes nested engines', async () => { + const middleware1 = jest.fn(async ({ next }) => next()); + const middleware2 = jest.fn(async ({ next }) => next()); + const engine1 = JsonRpcEngineV2.create({ + middleware: [middleware1], + }); + const engine2 = JsonRpcEngineV2.create({ + middleware: [engine1.asMiddleware(), middleware2], + }); + const engine3 = JsonRpcEngineV2.create({ + middleware: [engine2.asMiddleware(), (): null => null], + }); + + const result = await engine3.handle(makeRequest()); + + expect(result).toBeNull(); + expect(middleware1).toHaveBeenCalledTimes(1); + expect(middleware2).toHaveBeenCalledTimes(1); + }); + + it('propagates request mutation', async () => { + const engine1 = JsonRpcEngineV2.create({ + middleware: [ + ({ request, next }): Promise | undefined> => { + return next({ + ...request, + params: [2], + }); + }, + ({ request, next }): Promise | undefined> => { + return next({ + ...request, + method: 'test_request_2', + params: [(request.params as [number])[0] * 2], + }); + }, + ], + }); + + let observedMethod: string | undefined; + const engine2 = JsonRpcEngineV2.create({ + middleware: [ + engine1.asMiddleware(), + ({ request }): number => { + observedMethod = request.method; + return (request.params as [number])[0] * 2; + }, + ], + }); + + const result = await engine2.handle(makeRequest()); + + expect(result).toBe(8); + expect(observedMethod).toBe('test_request_2'); + }); + + it('propagates context changes', async () => { + const engine1 = JsonRpcEngineV2.create({ + middleware: [ + async ({ + context, + next, + }): Promise | undefined> => { + const nums = context.assertGet('foo') as [number]; + nums[0] *= 2; + return next(); + }, + ], + }); + + const engine2 = JsonRpcEngineV2.create({ + middleware: [ + async ({ + context, + next, + }): Promise | undefined> => { + context.set('foo', [2]); + return next(); + }, + engine1.asMiddleware(), + async ({ context }): Promise | undefined> => { + const nums = context.assertGet('foo') as [number]; + return nums[0] * 2; + }, + ], + }); + + const result = await engine2.handle(makeRequest()); + + expect(result).toBe(8); + }); + + it('observes results in expected order', async () => { + const returnHandlerResults: string[] = []; + const engine1 = JsonRpcEngineV2.create({ + middleware: [ + async ({ next }): Promise => { + await next(); + returnHandlerResults.push('1:a'); + }, + async ({ next }): Promise => { + await next(); + returnHandlerResults.push('1:b'); + }, + ], + }); + + const engine2 = JsonRpcEngineV2.create({ + middleware: [ + engine1.asMiddleware(), + async ({ next }): Promise => { + await next(); + returnHandlerResults.push('2:a'); + }, + async ({ next }): Promise => { + await next(); + returnHandlerResults.push('2:b'); + }, + (): null => null, + ], + }); + + await engine2.handle(makeRequest()); + + // Order of result handling is reversed _within_ engines, but not + // _between_ engines. + expect(returnHandlerResults).toStrictEqual([ + '1:b', + '1:a', + '2:b', + '2:a', + ]); + }); + }); + + describe('middleware with engine.handle()', () => { + it('composes nested engines', async () => { + const earlierMiddleware = jest.fn(async ({ next }) => next()); + + const engine1Middleware: JsonRpcMiddleware = () => null; + const engine1 = JsonRpcEngineV2.create({ + middleware: [engine1Middleware], + }); + + const engine1ProxyMiddleware: JsonRpcMiddleware< + JsonRpcRequest + > = async ({ request }) => { + return engine1.handle(request); + }; + const laterMiddleware: JsonRpcMiddleware = jest.fn( + () => 'foo', + ); + const engine2 = JsonRpcEngineV2.create({ + middleware: [ + earlierMiddleware, + engine1ProxyMiddleware, + laterMiddleware, + ], + }); + + const result = await engine2.handle(makeRequest()); + + expect(result).toBeNull(); + expect(earlierMiddleware).toHaveBeenCalledTimes(1); + expect(laterMiddleware).not.toHaveBeenCalled(); + }); + + it('does not propagate request mutation', async () => { + // Unlike asMiddleware(), although the inner engine mutates request, + // those mutations do not propagate when using engine.handle(). + const engine1 = JsonRpcEngineV2.create({ + middleware: [ + ({ request, next }): Promise | undefined> => { + return next({ + ...request, + params: [2], + }); + }, + ({ request, next }): Promise | undefined> => { + return next({ + ...request, + method: 'test_request_2', + params: [(request.params as [number])[0] * 2], + }); + }, + makeNullMiddleware(), + ], + }); + + let observedMethod: string | undefined; + const observedMethodMiddleware: JsonRpcMiddleware< + JsonRpcRequest, + number + > = ({ request }) => { + observedMethod = request.method; + return (request.params as [number])[0] * 2; + }; + const engine2 = JsonRpcEngineV2.create({ + middleware: [ + async ({ + request, + next, + context, + }): Promise | undefined> => { + await engine1.handle(request, { context }); + return next(); + }, + observedMethodMiddleware, + ], + }); + + const result = await engine2.handle(makeRequest({ params: [1] })); + + // Since inner-engine mutations do not affect the outer request, + // the outer middleware sees the original method and params. + expect(result).toBe(2); + expect(observedMethod).toBe('test_request'); + }); + + it('propagates context changes', async () => { + const engine1 = JsonRpcEngineV2.create({ + middleware: [ + async ({ context }): Promise => { + const nums = context.assertGet('foo') as [number]; + nums[0] *= 2; + return null; + }, + ], + }); + + const engine2 = JsonRpcEngineV2.create({ + middleware: [ + async ({ + context, + next, + }): Promise | undefined> => { + context.set('foo', [2]); + return next(); + }, + async ({ + request, + next, + context, + }): Promise | undefined> => { + await engine1.handle(request, { context }); + return next(); + }, + async ({ context }): Promise => { + const nums = context.assertGet('foo') as [number]; + return nums[0] * 2; + }, + ], + }); + + const result = await engine2.handle(makeRequest()); + + expect(result).toBe(8); + }); + + it('observes results in expected order', async () => { + const returnHandlerResults: string[] = []; + const engine1 = JsonRpcEngineV2.create({ + middleware: [ + async ({ next }): Promise => { + await next(); + returnHandlerResults.push('1:a'); + }, + async ({ next }): Promise => { + await next(); + returnHandlerResults.push('1:b'); + }, + makeNullMiddleware(), + ], + }); + + const engine2 = JsonRpcEngineV2.create({ + middleware: [ + async ({ + request, + next, + context, + }): Promise | undefined> => { + await engine1.handle(request as JsonRpcRequest, { context }); + return next(); + }, + async ({ next }): Promise => { + await next(); + returnHandlerResults.push('2:a'); + }, + async ({ next }): Promise => { + await next(); + returnHandlerResults.push('2:b'); + }, + makeNullMiddleware(), + ], + }); + + await engine2.handle(makeRequest()); + + // Inner engine return handlers run before outer engine return handlers + // since engine1.handle() completes before engine2 continues. + expect(returnHandlerResults).toStrictEqual([ + '1:b', + '1:a', + '2:b', + '2:a', + ]); + }); + + it('throws if the inner engine throws', async () => { + const engine1 = JsonRpcEngineV2.create({ + middleware: [ + (): never => { + throw new Error('test'); + }, + ], + }); + + const engine2 = JsonRpcEngineV2.create({ + middleware: [ + async ({ request }): Promise => { + await engine1.handle(request as JsonRpcRequest); + return null; + }, + ], + }); + + await expect(engine2.handle(makeRequest())).rejects.toThrow( + new Error('test'), + ); + }); + }); + + describe('request- and notification-only engines', () => { + it('constructs a request-only engine', async () => { + const engine = JsonRpcEngineV2.create({ + middleware: [makeRequestMiddleware()], + }); + + expect(await engine.handle(makeRequest())).toBeNull(); + // @ts-expect-error - Valid at runtime, but should cause a type error + expect(await engine.handle(makeRequest() as JsonRpcCall)).toBeNull(); + // @ts-expect-error - Invalid at runtime and should cause a type error + await expect(engine.handle(makeNotification())).rejects.toThrow( + new JsonRpcEngineError( + `Result returned for notification: ${stringify(makeNotification())}`, + ), + ); + }); + + it('constructs a notification-only engine', async () => { + const engine = JsonRpcEngineV2.create({ + middleware: [makeNotificationMiddleware()], + }); + + expect(await engine.handle(makeNotification())).toBeUndefined(); + await expect( + // @ts-expect-error - Invalid at runtime and should cause a type error + engine.handle({ id: '1', jsonrpc, method: 'test_request' }), + ).rejects.toThrow( + new JsonRpcEngineError( + `Nothing ended request: ${stringify({ id: '1', jsonrpc, method: 'test_request' })}`, + ), + ); + await expect( + // @ts-expect-error - Invalid at runtime and should cause a type error + engine.handle(makeRequest()), + ).rejects.toThrow( + new JsonRpcEngineError( + `Nothing ended request: ${stringify(makeRequest())}`, + ), + ); + }); + + it('constructs a mixed engine', async () => { + const mixedMiddleware: JsonRpcMiddleware = ({ request }) => { + return isRequest(request) ? null : undefined; + }; + const engine = JsonRpcEngineV2.create({ + middleware: [mixedMiddleware], + }); + + expect(await engine.handle(makeRequest())).toBeNull(); + expect(await engine.handle(makeNotification())).toBeUndefined(); + expect(await engine.handle(makeRequest() as JsonRpcCall)).toBeNull(); + }); + + it('composes a pipeline of request- and notification-only engines', async () => { + const requestEngine = JsonRpcEngineV2.create({ + middleware: [makeRequestMiddleware()], + }); + + const notificationEngine = JsonRpcEngineV2.create({ + middleware: [makeNotificationMiddleware()], + }); + + const orchestratorEngine = JsonRpcEngineV2.create({ + middleware: [ + ({ request, context }): Promise => + isRequest(request) + ? requestEngine.handle(request, { context }) + : notificationEngine.handle(request as JsonRpcNotification, { + context, + }), + ], + }); + + const result1 = await orchestratorEngine.handle(makeRequest()); + const result2 = await orchestratorEngine.handle(makeNotification()); + + expect(result1).toBeNull(); + expect(result2).toBeUndefined(); + }); + }); + }); + + describe('destroy', () => { + it('calls the destroy method of any middleware that has one', async () => { + const middleware = { + destroy: jest.fn(), + }; + const engine = JsonRpcEngineV2.create({ + middleware: [middleware as unknown as JsonRpcMiddleware], + }); + + await engine.destroy(); + + expect(middleware.destroy).toHaveBeenCalledTimes(1); + }); + + it('is idempotent', async () => { + const middleware = { + destroy: jest.fn(), + }; + + const engine = JsonRpcEngineV2.create({ + middleware: [middleware as unknown as JsonRpcMiddleware], + }); + + await engine.destroy(); + await engine.destroy(); + + expect(middleware.destroy).toHaveBeenCalledTimes(1); + }); + + it('causes handle() to throw after destroying the engine', async () => { + const engine = JsonRpcEngineV2.create({ + middleware: [makeNullMiddleware()], + }); + + await engine.destroy(); + + await expect(engine.handle(makeRequest())).rejects.toThrow( + new JsonRpcEngineError('Engine is destroyed'), + ); + }); + + it('causes asMiddleware() to throw after destroying the engine', async () => { + const engine = JsonRpcEngineV2.create({ + middleware: [makeNullMiddleware()], + }); + await engine.destroy(); + + expect(() => engine.asMiddleware()).toThrow( + new JsonRpcEngineError('Engine is destroyed'), + ); + }); + + it('rejects if a middleware throws when destroying', async () => { + const middleware = { + destroy: jest.fn(() => { + throw new Error('test'); + }), + }; + const engine = JsonRpcEngineV2.create({ + middleware: [middleware as unknown as JsonRpcMiddleware], + }); + + await expect(engine.destroy()).rejects.toThrow(new Error('test')); + }); + + it('calls the destroy() method of each middleware even if one throws', async () => { + const middleware1 = { + destroy: jest.fn(() => { + throw new Error('test'); + }), + }; + const middleware2 = { + destroy: jest.fn(), + }; + const engine = JsonRpcEngineV2.create({ + middleware: [ + middleware1, + middleware2, + ] as unknown as JsonRpcMiddleware[], + }); + + await expect(engine.destroy()).rejects.toThrow(new Error('test')); + + expect(middleware1.destroy).toHaveBeenCalledTimes(1); + expect(middleware2.destroy).toHaveBeenCalledTimes(1); + }); + }); +}); diff --git a/packages/json-rpc-engine/src/v2/JsonRpcEngineV2.ts b/packages/json-rpc-engine/src/v2/JsonRpcEngineV2.ts new file mode 100644 index 00000000000..92e9396f3d6 --- /dev/null +++ b/packages/json-rpc-engine/src/v2/JsonRpcEngineV2.ts @@ -0,0 +1,511 @@ +import { hasProperty } from '@metamask/utils'; +import type { + Json, + JsonRpcRequest, + JsonRpcNotification, + NonEmptyArray, +} from '@metamask/utils'; +import deepFreeze from 'deep-freeze-strict'; + +import { deepClone } from './compatibility-utils.js'; +import type { + ContextConstraint, + InferKeyValues, + MergeContexts, +} from './MiddlewareContext.js'; +import { MiddlewareContext } from './MiddlewareContext.js'; +import { + isNotification, + isRequest, + JsonRpcEngineError, + stringify, +} from './utils.js'; +import type { JsonRpcCall } from './utils.js'; + +// Helper to forbid `id` on notifications +type WithoutId = Request & { id?: never }; + +// Helper to enable JsonRpcCall overload of handle() +type MixedParam = [ + Extract, +] extends [never] + ? never + : [Extract] extends [never] + ? never + : + | Extract + | WithoutId>; + +export type ResultConstraint = + Request extends JsonRpcRequest ? Json : void; + +export type Next = ( + request?: Readonly, +) => Promise> | undefined>; + +export type MiddlewareParams< + Request extends JsonRpcCall = JsonRpcCall, + Context extends ContextConstraint = MiddlewareContext, +> = { + request: Readonly; + context: Context; + next: Next; +}; + +export type JsonRpcMiddleware< + Request extends JsonRpcCall = JsonRpcCall, + Result extends ResultConstraint = ResultConstraint, + Context extends ContextConstraint = MiddlewareContext, +> = ( + params: MiddlewareParams, +) => Readonly | undefined | Promise | undefined>; + +type RequestState = { + request: Request; + result: Readonly> | undefined; +}; + +/** + * The options for the JSON-RPC request/notification handling operation. + */ +export type HandleOptions = { + context?: Context | InferKeyValues; +}; + +type ConstructorOptions< + Request extends JsonRpcCall, + Context extends MiddlewareContext, +> = { + middleware: NonEmptyArray< + JsonRpcMiddleware, Context> + >; +}; + +/** + * The request type of a middleware. + */ +export type RequestOf = + Middleware extends JsonRpcMiddleware< + infer Request, + ResultConstraint, + // Non-polluting `any` constraint. + // eslint-disable-next-line @typescript-eslint/no-explicit-any + any + > + ? Request + : never; + +type ContextOf = + // Non-polluting `any` constraint. + // eslint-disable-next-line @typescript-eslint/no-explicit-any + Middleware extends JsonRpcMiddleware, infer C> + ? C + : never; + +/** + * A constraint for {@link JsonRpcMiddleware} generic parameters. + */ +// Non-polluting `any` constraint. +/* eslint-disable @typescript-eslint/no-explicit-any */ +export type MiddlewareConstraint = JsonRpcMiddleware< + any, + ResultConstraint, + MiddlewareContext +>; +/* eslint-enable @typescript-eslint/no-explicit-any */ + +/** + * The context supertype of a middleware type. + */ +export type MergedContextOf = + MergeContexts>; + +const INVALID_ENGINE = Symbol('Invalid engine'); + +/** + * An internal type for invalid engines that explains why the engine is invalid. + * + * @template Message - The message explaining why the engine is invalid. + */ +type InvalidEngine = { [INVALID_ENGINE]: Message }; + +/** + * A JSON-RPC request and response processor. + * + * Give it a stack of middleware, pass it requests, and get back responses. + * + * #### Requests vs. notifications + * + * JSON-RPC requests come in two flavors: + * + * - [Requests](https://www.jsonrpc.org/specification#request_object), i.e. request objects _with_ an `id` + * - [Notifications](https://www.jsonrpc.org/specification#notification), i.e. request objects _without_ an `id` + * + * For requests, one of the engine's middleware must "end" the request by returning a non-`undefined` result, + * or {@link handle} will throw an error: + * + * For notifications, on the other hand, one of the engine's middleware must return `undefined` to end the request, + * and any non-`undefined` return values will cause an error: + * + * @template Request - The type of request to handle. + * @template Result - The type of result to return. + * + * @example + * ```ts + * const engine = JsonRpcEngineV2.create({ + * middleware, + * }); + * + * try { + * const result = await engine.handle(request); + * // Handle result + * } catch (error) { + * // Handle error + * } + * ``` + */ +export class JsonRpcEngineV2< + Request extends JsonRpcCall = JsonRpcCall, + Context extends ContextConstraint = MiddlewareContext, +> { + #middleware: Readonly< + NonEmptyArray< + JsonRpcMiddleware, Context> + > + >; + + #isDestroyed = false; + + // See .create() for why this is private. + // eslint-disable-next-line no-restricted-syntax + private constructor({ middleware }: ConstructorOptions) { + this.#middleware = [...middleware]; + } + + // We use a static factory method in order to construct a supertype of all middleware contexts, + // which enables us to instantiate an engine despite different middleware expecting different + // context types. + /** + * Create a new JSON-RPC engine. + * + * @throws If the middleware array is empty. + * @param options - The options for the engine. + * @param options.middleware - The middleware to use. + * @returns The JSON-RPC engine. + */ + static create< + Middleware extends JsonRpcMiddleware< + // Non-polluting `any` constraint. + /* eslint-disable @typescript-eslint/no-explicit-any */ + any, + ResultConstraint, + any + /* eslint-enable @typescript-eslint/no-explicit-any */ + > = JsonRpcMiddleware, + >({ + middleware, + }: { + middleware: Middleware[]; + }): MergedContextOf extends never + ? InvalidEngine<'Some middleware have incompatible context types'> + : JsonRpcEngineV2, MergedContextOf> { + // We can't use NonEmptyArray for the params because it ruins type inference. + if (middleware.length === 0) { + throw new JsonRpcEngineError('Middleware array cannot be empty'); + } + + type MergedContext = MergedContextOf; + type InputRequest = RequestOf; + const mw = middleware as unknown as NonEmptyArray< + JsonRpcMiddleware< + InputRequest, + ResultConstraint, + MergedContext + > + >; + + return new JsonRpcEngineV2({ + middleware: mw, + }) as MergedContext extends never + ? InvalidEngine<'Some middleware have incompatible context types'> + : JsonRpcEngineV2; + } + + /** + * Handle a JSON-RPC request. + * + * @param request - The JSON-RPC request to handle. + * @param options - The options for the handle operation. + * @param options.context - The context to pass to the middleware. + * @returns The JSON-RPC response. + */ + async handle( + request: Extract extends never + ? never + : Extract, + options?: HandleOptions, + ): Promise< + Extract extends never + ? never + : ResultConstraint + >; + + /** + * Handle a JSON-RPC notification. Notifications do not return a result. + * + * @param notification - The JSON-RPC notification to handle. + * @param options - The options for the handle operation. + * @param options.context - The context to pass to the middleware. + */ + async handle( + notification: Extract extends never + ? never + : WithoutId>, + options?: HandleOptions, + ): Promise< + Extract extends never + ? never + : ResultConstraint + >; + + /** + * Handle a JSON-RPC call, i.e. request or notification. Requests return a + * result, notifications do not. + * + * @param call - The JSON-RPC call to handle. + * @param options - The options for the handle operation. + * @param options.context - The context to pass to the middleware. + * @returns The JSON-RPC response, or `undefined` if the call is a notification. + */ + async handle( + call: MixedParam, + options?: HandleOptions, + ): Promise | void>; + + async handle( + request: Request, + { context }: HandleOptions = {}, + ): Promise | void> { + const isReq = isRequest(request); + const { result } = await this.#handle(request, context); + + if (isReq && result === undefined) { + throw new JsonRpcEngineError( + `Nothing ended request: ${stringify(request)}`, + ); + } + return deepClone(result) as ResultConstraint; + } + + /** + * Handle a JSON-RPC request. Throws if a middleware performs an invalid + * operation. Permits returning an `undefined` result. + * + * @param originalRequest - The JSON-RPC request to handle. + * @param rawContext - The context to pass to the middleware. + * @returns The result from the middleware. + */ + async #handle( + originalRequest: Request, + rawContext: + | Context + | InferKeyValues = new MiddlewareContext() as Context, + ): Promise> { + this.#assertIsNotDestroyed(); + + deepFreeze(originalRequest); + + const state: RequestState = { + request: originalRequest, + result: undefined, + }; + const middlewareIterator = this.#makeMiddlewareIterator(); + const firstMiddleware = middlewareIterator.next().value; + const context = MiddlewareContext.isInstance(rawContext) + ? rawContext + : (new MiddlewareContext(rawContext) as Context); + + const makeNext = this.#makeNextFactory(middlewareIterator, state, context); + + const result = await firstMiddleware({ + request: originalRequest, + context, + next: makeNext(), + }); + this.#updateResult(result, state); + + return state; + } + + /** + * Create a factory of `next()` functions for use with a particular request. + * The factory is recursive, and a new `next()` is created for each middleware + * invocation. + * + * @param middlewareIterator - The iterator of middleware for the current + * request. + * @param state - The current values of the request and result. + * @param context - The context to pass to the middleware. + * @returns The `next()` function factory. + */ + #makeNextFactory( + middlewareIterator: Iterator< + JsonRpcMiddleware, Context> + >, + state: RequestState, + context: Context, + ): () => Next { + const makeNext = (): Next => { + let wasCalled = false; + + const next = async ( + request: Request = state.request, + ): Promise> | undefined> => { + if (wasCalled) { + throw new JsonRpcEngineError( + `Middleware attempted to call next() multiple times for request: ${stringify(request)}`, + ); + } + wasCalled = true; + + if (request !== state.request) { + this.#assertValidNextRequest(state.request, request); + state.request = deepFreeze(request); + } + + const { value: nextMiddleware, done } = middlewareIterator.next(); + if (done) { + // This will cause the last middleware to return `undefined`. See the class + // JSDoc or package README for more details. + return undefined; + } + + const result = await nextMiddleware({ + request, + context, + next: makeNext(), + }); + this.#updateResult(result, state); + + return state.result; + }; + return next; + }; + + return makeNext; + } + + #makeMiddlewareIterator(): Iterator< + JsonRpcMiddleware, Context> + > { + return this.#middleware[Symbol.iterator](); + } + + /** + * Validate the result from a middleware and, if it's a new value, update the + * current result. + * + * @param result - The result from the middleware. + * @param state - The current values of the request and result. + */ + #updateResult( + result: + | Readonly> + | ResultConstraint + | void, + state: RequestState, + ): void { + if (isNotification(state.request) && result !== undefined) { + throw new JsonRpcEngineError( + `Result returned for notification: ${stringify(state.request)}`, + ); + } + + if (result !== undefined && result !== state.result) { + if (typeof result === 'object' && result !== null) { + deepFreeze(result); + } + state.result = result; + } + } + + /** + * Assert that a request modified by a middleware is valid. + * + * @param currentRequest - The current request. + * @param nextRequest - The next request. + */ + #assertValidNextRequest(currentRequest: Request, nextRequest: Request): void { + if (nextRequest.jsonrpc !== currentRequest.jsonrpc) { + throw new JsonRpcEngineError( + `Middleware attempted to modify readonly property "jsonrpc" for request: ${stringify(currentRequest)}`, + ); + } + if ( + hasProperty(nextRequest, 'id') !== hasProperty(currentRequest, 'id') || + // @ts-expect-error - "id" does not exist on notifications, but we can still + // check the value of the property at runtime. + nextRequest.id !== currentRequest.id + ) { + throw new JsonRpcEngineError( + `Middleware attempted to modify readonly property "id" for request: ${stringify(currentRequest)}`, + ); + } + } + + /** + * Convert the engine into a JSON-RPC middleware. + * + * @returns The JSON-RPC middleware. + */ + asMiddleware(): JsonRpcMiddleware< + Request, + ResultConstraint, + Context + > { + this.#assertIsNotDestroyed(); + + return async ({ request, context, next }) => { + const { result, request: finalRequest } = await this.#handle( + request, + context, + ); + + // We can't use nullish coalescing here because `result` may be `null`. + // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing + return result === undefined ? await next(finalRequest) : result; + }; + } + + /** + * Destroy the engine. Calls the `destroy()` method of any middleware that has + * one. Attempting to use the engine after destroying it will throw an error. + */ + async destroy(): Promise { + if (this.#isDestroyed) { + return; + } + this.#isDestroyed = true; + + const destructionPromise = Promise.all( + this.#middleware.map(async (middleware) => { + if ( + // Intentionally using `in` to walk the prototype chain. + 'destroy' in middleware && + typeof middleware.destroy === 'function' + ) { + return middleware.destroy(); + } + return undefined; + }), + ); + this.#middleware = [] as never; + await destructionPromise; + } + + #assertIsNotDestroyed(): void { + if (this.#isDestroyed) { + throw new JsonRpcEngineError('Engine is destroyed'); + } + } +} diff --git a/packages/json-rpc-engine/src/v2/JsonRpcServer.test.ts b/packages/json-rpc-engine/src/v2/JsonRpcServer.test.ts new file mode 100644 index 00000000000..f3a56853777 --- /dev/null +++ b/packages/json-rpc-engine/src/v2/JsonRpcServer.test.ts @@ -0,0 +1,441 @@ +import { rpcErrors } from '@metamask/rpc-errors'; +import { Json } from '@metamask/utils'; + +import type { JsonRpcMiddleware } from './JsonRpcEngineV2.js'; +import { JsonRpcEngineV2 } from './JsonRpcEngineV2.js'; +import { JsonRpcServer } from './JsonRpcServer.js'; +import type { MiddlewareContext } from './MiddlewareContext.js'; +import type { JsonRpcNotification, JsonRpcRequest } from './utils.js'; +import { isRequest, JsonRpcEngineError, stringify } from './utils.js'; + +const jsonrpc = '2.0' as const; + +const makeEngine = (): JsonRpcEngineV2 => { + return JsonRpcEngineV2.create({ + middleware: [ + ({ request }): Json | undefined => { + if (request.method !== 'hello') { + throw new Error('Unknown method'); + } + return isRequest(request) ? (request.params ?? null) : undefined; + }, + ], + }); +}; + +describe('JsonRpcServer', () => { + it('can be constructed with an engine', () => { + const server = new JsonRpcServer({ + engine: makeEngine(), + onError: (): undefined => undefined, + }); + + expect(server).toBeDefined(); + }); + + it('can be constructed with middleware', () => { + const server = new JsonRpcServer({ + middleware: [(): null => null], + onError: (): undefined => undefined, + }); + + expect(server).toBeDefined(); + }); + + it('handles a request', async () => { + const server = new JsonRpcServer({ + engine: makeEngine(), + onError: (): undefined => undefined, + }); + + const response = await server.handle({ + jsonrpc, + id: 1, + method: 'hello', + }); + + expect(response).toStrictEqual({ + jsonrpc, + id: 1, + result: null, + }); + }); + + it('handles a request with params', async () => { + const server = new JsonRpcServer({ + engine: makeEngine(), + onError: (): undefined => undefined, + }); + + const response = await server.handle({ + jsonrpc, + id: 1, + method: 'hello', + params: ['world'], + }); + + expect(response).toStrictEqual({ + jsonrpc, + id: 1, + result: ['world'], + }); + }); + + it('handles a notification', async () => { + const server = new JsonRpcServer({ + engine: makeEngine(), + onError: (): undefined => undefined, + }); + + const response = await server.handle({ + jsonrpc, + method: 'hello', + }); + + expect(response).toBeUndefined(); + }); + + it('handles a notification with params', async () => { + const server = new JsonRpcServer({ + engine: makeEngine(), + onError: (): undefined => undefined, + }); + + const response = await server.handle({ + jsonrpc, + method: 'hello', + params: { hello: 'world' }, + }); + + expect(response).toBeUndefined(); + }); + + it('forwards the context to the engine', async () => { + const middleware: JsonRpcMiddleware< + JsonRpcRequest, + string, + MiddlewareContext<{ foo: string }> + > = ({ context }) => { + return context.assertGet('foo'); + }; + const server = new JsonRpcServer({ + middleware: [middleware], + onError: (): undefined => undefined, + }); + + const response = await server.handle( + { + jsonrpc, + id: 1, + method: 'hello', + }, + { + context: { + foo: 'bar', + }, + }, + ); + + expect(response).toStrictEqual({ + jsonrpc, + id: 1, + result: 'bar', + }); + }); + + it('returns an error response for a failed request', async () => { + const server = new JsonRpcServer({ + engine: makeEngine(), + onError: (): undefined => undefined, + }); + + const response = await server.handle({ + jsonrpc, + id: 1, + method: 'unknown', + }); + + expect(response).toStrictEqual({ + jsonrpc, + id: 1, + error: { + code: -32603, + message: 'Unknown method', + data: { cause: expect.any(Object) }, + }, + }); + }); + + it('returns undefined for a failed notification', async () => { + const server = new JsonRpcServer({ + engine: makeEngine(), + onError: (): undefined => undefined, + }); + + const response = await server.handle({ + jsonrpc, + method: 'unknown', + }); + + expect(response).toBeUndefined(); + }); + + it('calls onError for a failed request', async () => { + const onError = jest.fn(); + const server = new JsonRpcServer({ + engine: makeEngine(), + onError, + }); + + await server.handle({ + jsonrpc, + id: 1, + method: 'unknown', + }); + + expect(onError).toHaveBeenCalledTimes(1); + expect(onError).toHaveBeenCalledWith(new Error('Unknown method')); + }); + + it('returns a failed request when onError is not provided', async () => { + const server = new JsonRpcServer({ + engine: makeEngine(), + }); + + const response = await server.handle({ + jsonrpc, + id: 1, + method: 'unknown', + }); + + expect(response).toStrictEqual({ + jsonrpc, + id: 1, + error: { + code: -32603, + message: 'Unknown method', + data: { cause: expect.any(Object) }, + }, + }); + }); + + it('calls onError for a failed notification', async () => { + const onError = jest.fn(); + const server = new JsonRpcServer({ + engine: makeEngine(), + onError, + }); + + await server.handle({ + jsonrpc, + method: 'unknown', + }); + + expect(onError).toHaveBeenCalledTimes(1); + expect(onError).toHaveBeenCalledWith(new Error('Unknown method')); + }); + + it('accepts requests with malformed jsonrpc', async () => { + const server = new JsonRpcServer({ + engine: makeEngine(), + onError: (): undefined => undefined, + }); + + const response = await server.handle({ + jsonrpc: '1.0', + id: 1, + method: 'hello', + }); + + expect(response).toStrictEqual({ + jsonrpc, + id: 1, + result: null, + }); + }); + + it('errors if passed a notification when only requests are supported', async () => { + const onError = jest.fn(); + const server = new JsonRpcServer>({ + middleware: [(): null => null], + onError, + }); + + const notification = { jsonrpc, method: 'hello' }; + const response = await server.handle(notification); + + expect(response).toBeUndefined(); + expect(onError).toHaveBeenCalledTimes(1); + expect(onError).toHaveBeenCalledWith( + new JsonRpcEngineError( + `Result returned for notification: ${stringify(notification)}`, + ), + ); + }); + + it('errors if passed a request when only notifications are supported', async () => { + const onError = jest.fn(); + const server = new JsonRpcServer>({ + middleware: [(): undefined => undefined], + onError, + }); + + const request = { jsonrpc, id: 1, method: 'hello' }; + const response = await server.handle(request); + + expect(response).toStrictEqual({ + jsonrpc, + id: 1, + error: { + code: -32603, + message: expect.stringMatching(/^Nothing ended request: /u), + data: { cause: expect.any(Object) }, + }, + }); + expect(onError).toHaveBeenCalledTimes(1); + expect(onError).toHaveBeenCalledWith( + expect.objectContaining({ + // Using a regex match because id in the error message is not predictable. + message: expect.stringMatching(/^Nothing ended request: /u), + }), + ); + }); + + it.each([undefined, Symbol('test'), null, true, false, {}, []])( + 'accepts requests with malformed ids', + async (id) => { + const server = new JsonRpcServer({ + engine: makeEngine(), + onError: (): undefined => undefined, + }); + + const response = await server.handle({ + jsonrpc, + id, + method: 'hello', + }); + + expect(response).toStrictEqual({ + jsonrpc, + id, + result: null, + }); + }, + ); + + it('does not throw when onError throws synchronously for a request', async () => { + const server = new JsonRpcServer({ + engine: makeEngine(), + onError: (): never => { + throw new Error('onError failure'); + }, + }); + + const response = await server.handle({ + jsonrpc, + id: 1, + method: 'unknown', + }); + + expect(response).toStrictEqual({ + jsonrpc, + id: 1, + error: { + code: -32603, + message: 'Unknown method', + data: { cause: expect.any(Object) }, + }, + }); + }); + + it('does not throw when onError throws synchronously for a notification', async () => { + const server = new JsonRpcServer({ + engine: makeEngine(), + onError: (): never => { + throw new Error('onError failure'); + }, + }); + + const response = await server.handle({ + jsonrpc, + method: 'unknown', + }); + + expect(response).toBeUndefined(); + }); + + it('does not cause an unhandled rejection when onError rejects asynchronously for a request', async () => { + const server = new JsonRpcServer({ + engine: makeEngine(), + onError: (async (): Promise => { + throw new Error('async onError failure'); + }) as (error: unknown) => void, + }); + + const response = await server.handle({ + jsonrpc, + id: 1, + method: 'unknown', + }); + + expect(response).toStrictEqual({ + jsonrpc, + id: 1, + error: { + code: -32603, + message: 'Unknown method', + data: { cause: expect.any(Object) }, + }, + }); + }); + + it('does not cause an unhandled rejection when onError rejects asynchronously for a notification', async () => { + const server = new JsonRpcServer({ + engine: makeEngine(), + onError: (async (): Promise => { + throw new Error('async onError failure'); + }) as (error: unknown) => void, + }); + + const response = await server.handle({ + jsonrpc, + method: 'unknown', + }); + + expect(response).toBeUndefined(); + }); + + it.each([ + null, + {}, + [], + false, + true, + { method: 'hello', params: 'world' }, + { method: 'hello', params: null }, + { method: 'hello', params: undefined }, + { params: ['world'] }, + { jsonrpc }, + { id: 1 }, + ])( + 'errors if the request is not minimally conformant', + async (malformedRequest) => { + const onError = jest.fn(); + const server = new JsonRpcServer({ + engine: makeEngine(), + onError, + }); + + await server.handle(malformedRequest); + + expect(onError).toHaveBeenCalledTimes(1); + expect(onError).toHaveBeenCalledWith( + rpcErrors.invalidRequest({ + data: { + request: malformedRequest, + }, + }), + ); + }, + ); +}); diff --git a/packages/json-rpc-engine/src/v2/JsonRpcServer.ts b/packages/json-rpc-engine/src/v2/JsonRpcServer.ts new file mode 100644 index 00000000000..b27d7086c6b --- /dev/null +++ b/packages/json-rpc-engine/src/v2/JsonRpcServer.ts @@ -0,0 +1,285 @@ +import { rpcErrors, serializeError } from '@metamask/rpc-errors'; +import type { + JsonRpcNotification, + JsonRpcParams, + JsonRpcRequest, + JsonRpcResponse, + NonEmptyArray, +} from '@metamask/utils'; +import { hasProperty, isObject } from '@metamask/utils'; + +import { getUniqueId } from '../getUniqueId.js'; +import type { + HandleOptions, + JsonRpcMiddleware, + MergedContextOf, + MiddlewareConstraint, + RequestOf, +} from './JsonRpcEngineV2.js'; +import { JsonRpcEngineV2 } from './JsonRpcEngineV2.js'; +import type { JsonRpcCall } from './utils.js'; + +type OnError = (error: unknown) => void; + +type Options = { + onError?: OnError; +} & ( + | { + engine: ReturnType>; + } + | { + middleware: NonEmptyArray; + } +); + +const jsonrpc = '2.0' as const; + +/** + * A JSON-RPC server that handles requests and notifications. + * + * Essentially wraps a {@link JsonRpcEngineV2} in order to create a conformant + * yet permissive JSON-RPC 2.0 server. + * + * Note that the server will accept both requests and notifications via {@link handle}, + * even if the underlying engine is only able to handle one or the other. + * + * @example + * ```ts + * const server = new JsonRpcServer({ + * engine, + * onError, + * }); + * + * const response = await server.handle(request); + * if ('result' in response) { + * // Handle result + * } else { + * // Handle error + * } + * ``` + */ +export class JsonRpcServer< + Middleware extends MiddlewareConstraint = JsonRpcMiddleware, +> { + readonly #engine: JsonRpcEngineV2< + RequestOf, + MergedContextOf + >; + + readonly #onError?: OnError | undefined; + + /** + * Construct a new JSON-RPC server. + * + * @param options - The options for the server. + * @param options.onError - The callback to handle errors thrown by the + * engine. Errors always result in a failed response object, containing a + * JSON-RPC 2.0 serialized version of the original error. If you need to + * access the original error, use the `onError` callback. If the `onError` + * callback itself throws or rejects, the error is silently ignored. + * @param options.engine - The engine to use. Mutually exclusive with + * `middleware`. + * @param options.middleware - The middleware to use. Mutually exclusive with + * `engine`. + */ + constructor(options: Options) { + this.#onError = options.onError; + + if (hasProperty(options, 'engine')) { + // @ts-expect-error - hasProperty fails to narrow the type. + this.#engine = options.engine; + } else { + // @ts-expect-error - TypeScript complains that engine is of the wrong type, but clearly it's not. + this.#engine = JsonRpcEngineV2.create({ middleware: options.middleware }); + } + } + + /** + * Handle a JSON-RPC request. + * + * This method never throws. For requests, a response is always returned. + * All errors are passed to the engine's `onError` callback. + * + * **WARNING**: This method is unaware of the request type of the underlying + * engine. The request will fail if the engine can only handle notifications. + * + * @param request - The request to handle. + * @param options - The options for the handle operation. + * @param options.context - The context to pass to the middleware. + * @returns The JSON-RPC response. + */ + async handle( + request: JsonRpcRequest, + options?: HandleOptions>, + ): Promise; + + /** + * Handle a JSON-RPC notification. + * + * This method never throws. For notifications, `undefined` is always returned. + * All errors are passed to the engine's `onError` callback. + * + * **WARNING**: This method is unaware of the request type of the underlying + * engine. The request will fail if the engine cannot handle notifications. + * + * @param notification - The notification to handle. + * @param options - The options for the handle operation. + * @param options.context - The context to pass to the middleware. + */ + async handle( + notification: JsonRpcNotification, + options?: HandleOptions>, + ): Promise; + + /** + * Handle an alleged JSON-RPC request or notification. Permits any plain + * object with `{ method: string }`, so long as any present JSON-RPC 2.0 + * properties are valid. If the object has an `id` property, it will be + * treated as a request, otherwise it will be treated as a notification. + * + * This method never throws. All errors are passed to the engine's + * `onError` callback. A JSON-RPC response is always returned for requests, + * and `undefined` is returned for notifications. + * + * **WARNING**: The request will fail if its coerced type (i.e. request or + * response) is not of the type expected by the underlying engine. + * + * @param rawRequest - The raw request to handle. + * @param options - The options for the handle operation. + * @param options.context - The context to pass to the middleware. + * @returns The JSON-RPC response, or `undefined` if the request is a + * notification. + */ + async handle( + rawRequest: unknown, + options?: HandleOptions>, + ): Promise; + + async handle( + rawRequest: unknown, + options?: HandleOptions>, + ): Promise { + // If rawRequest is not a notification, the originalId will be attached + // to the response. We attach our own, trusted id in #coerceRequest() + // while the request is being handled. + const [originalId, isRequest] = getOriginalId(rawRequest); + + try { + const request = JsonRpcServer.#coerceRequest(rawRequest, isRequest); + // @ts-expect-error - The request may not be of the type expected by the engine, + // and we intentionally allow this to happen. + const result = await this.#engine.handle(request, options); + + if (result !== undefined) { + return { + jsonrpc, + // @ts-expect-error - Reassign the original id, regardless of its type. + id: originalId, + result, + }; + } + } catch (error) { + try { + const maybePromise: unknown = this.#onError?.(error); + if (maybePromise instanceof Promise) { + maybePromise.catch(() => { + // Prevent unhandled promise rejection. + }); + } + } catch { + // onError must not prevent handle() from honoring its "never throws" contract. + } + + if (isRequest) { + return { + jsonrpc, + // @ts-expect-error - Reassign the original id, regardless of its type. + id: originalId, + error: serializeError(error, { + shouldIncludeStack: false, + shouldPreserveMessage: true, + }), + }; + } + } + return undefined; + } + + static #coerceRequest(rawRequest: unknown, isRequest: boolean): JsonRpcCall { + if (!isMinimalRequest(rawRequest)) { + throw rpcErrors.invalidRequest({ + data: { + request: rawRequest, + }, + }); + } + + const request: JsonRpcCall = { + jsonrpc, + method: rawRequest.method, + }; + + if (hasProperty(rawRequest, 'params')) { + request.params = rawRequest.params as JsonRpcParams; + } + + if (isRequest) { + (request as JsonRpcRequest).id = getUniqueId(); + } + + return request; + } +} + +/** + * The most minimally conformant request object that we will accept. + */ +type MinimalRequest = { + method: string; + params?: JsonRpcParams; +} & Record; + +/** + * Check if an unvalidated request is a minimal request. + * + * @param rawRequest - The raw request to check. + * @returns `true` if the request is a {@link MinimalRequest}, `false` otherwise. + */ +function isMinimalRequest(rawRequest: unknown): rawRequest is MinimalRequest { + return ( + isObject(rawRequest) && + hasProperty(rawRequest, 'method') && + typeof rawRequest.method === 'string' && + hasValidParams(rawRequest) + ); +} + +/** + * Check if a request has valid params, i.e. an array or object. + * The contents of the params are not inspected. + * + * @param rawRequest - The request to check. + * @returns `true` if the request has valid params, `false` otherwise. + */ +function hasValidParams( + rawRequest: Record, +): rawRequest is { params?: JsonRpcParams } { + if (hasProperty(rawRequest, 'params')) { + return Array.isArray(rawRequest.params) || isObject(rawRequest.params); + } + return true; +} + +/** + * Get the original id from a request. + * + * @param rawRequest - The request to get the original id from. + * @returns The original id and a boolean indicating if the request is a request + * (as opposed to a notification). + */ +function getOriginalId(rawRequest: unknown): [unknown, boolean] { + if (isObject(rawRequest) && hasProperty(rawRequest, 'id')) { + return [rawRequest.id, true]; + } + return [undefined, false]; +} diff --git a/packages/json-rpc-engine/src/v2/MiddlewareContext.test.ts b/packages/json-rpc-engine/src/v2/MiddlewareContext.test.ts new file mode 100644 index 00000000000..c8dfa0ea631 --- /dev/null +++ b/packages/json-rpc-engine/src/v2/MiddlewareContext.test.ts @@ -0,0 +1,84 @@ +import { MiddlewareContext } from './MiddlewareContext.js'; + +describe('MiddlewareContext', () => { + it('can be constructed with entries', () => { + const symbol = Symbol('test'); + const context = new MiddlewareContext<{ test: string; [symbol]: string }>([ + ['test', 'value'], + [symbol, 'value'], + ]); + expect(context.get('test')).toBe('value'); + expect(context.get(symbol)).toBe('value'); + }); + + it('can be constructed with a KeyValues object', () => { + const symbol = Symbol('symbol'); + const context = new MiddlewareContext<{ test: string; [symbol]: string }>({ + test: 'string value', + [symbol]: 'symbol value', + }); + + expect(context.get('test')).toBe('string value'); + expect(context.get(symbol)).toBe('symbol value'); + }); + + it('is frozen', () => { + const context = new MiddlewareContext(); + expect(Object.isFrozen(context)).toBe(true); + }); + + it('type errors and returns undefined when getting unknown keys', () => { + const context = new MiddlewareContext<{ test: string }>(); + // @ts-expect-error - foo is not a valid key + expect(context.get('foo')).toBeUndefined(); + }); + + it('type errors and throws when assertGet:ing unknown keys', () => { + const context = new MiddlewareContext<{ test: string }>(); + // @ts-expect-error - foo is not a valid key + expect(() => context.assertGet('foo')).toThrow( + `Context key "foo" not found`, + ); + }); + + it('type errors when setting unknown keys', () => { + const context = new MiddlewareContext<{ test: string }>(); + // @ts-expect-error - foo is not a valid key + expect(context.set('foo', 'value')).toBe(context); + }); + + it('assertGet throws if the key is not found', () => { + const context = new MiddlewareContext<{ test: string }>(); + expect(() => context.assertGet('test')).toThrow( + `Context key "test" not found`, + ); + }); + + it('assertGet returns the value if the key is found (string)', () => { + const context = new MiddlewareContext<{ test: string }>(); + context.set('test', 'value'); + expect(context.assertGet('test')).toBe('value'); + }); + + it('assertGet returns the value if the key is found (symbol)', () => { + const symbol = Symbol('test'); + const context = new MiddlewareContext<{ [symbol]: string }>(); + context.set(symbol, 'value'); + expect(context.assertGet(symbol)).toBe('value'); + }); + + it('throws if setting an already set key', () => { + const context = new MiddlewareContext<{ test: string }>(); + context.set('test', 'value'); + expect(() => context.set('test', 'value')).toThrow( + `MiddlewareContext key "test" already exists`, + ); + }); + + it('identifies instances of MiddlewareContext via isInstance', () => { + const context = new MiddlewareContext(); + + expect(MiddlewareContext.isInstance(context)).toBe(true); + expect(MiddlewareContext.isInstance({ foo: 'bar' })).toBe(false); + }); +}); diff --git a/packages/json-rpc-engine/src/v2/MiddlewareContext.ts b/packages/json-rpc-engine/src/v2/MiddlewareContext.ts new file mode 100644 index 00000000000..4286d4eee85 --- /dev/null +++ b/packages/json-rpc-engine/src/v2/MiddlewareContext.ts @@ -0,0 +1,193 @@ +import { isInstance } from './utils.js'; +import type { UnionToIntersection } from './utils.js'; + +const MiddlewareContextSymbol = Symbol.for('json-rpc-engine#MiddlewareContext'); + +/** + * A context object for middleware that attempts to protect against accidental + * modifications. Its interface is frozen. + * + * Map keys may not be directly overridden with {@link set}. Instead, use + * {@link delete} to remove a key and then {@link set} to add a new value. + * + * The override protections are circumvented when using e.g. `Reflect.set`, so + * don't do that. + * + * @template KeyValues - The type of the keys and values in the context. + * @example + * // By default, the context permits any PropertyKey as a key. + * const context = new MiddlewareContext(); + * context.set('foo', 'bar'); + * context.get('foo'); // 'bar' + * context.get('fizz'); // undefined + * @example + * // By specifying an object type, the context permits only the keys of the object. + * type Context = MiddlewareContext<{ foo: string }>; + * const context = new Context([['foo', 'bar']]); + * context.get('foo'); // 'bar' + * context.get('fizz'); // Type error + */ +export class MiddlewareContext< + KeyValues extends Record = Record, +> extends Map { + // This is a computed property name, and it doesn't seem possible to make it + // hash private using `#`. + // eslint-disable-next-line no-restricted-syntax + private readonly [MiddlewareContextSymbol] = true; + + /** + * Check if a value is a {@link MiddlewareContext} instance. + * Works across different package versions in the same realm. + * + * @param value - The value to check. + * @returns Whether the value is a {@link MiddlewareContext} instance. + */ + static isInstance(value: unknown): value is MiddlewareContext { + return isInstance(value, MiddlewareContextSymbol); + } + + constructor( + entries?: + | Iterable + | KeyValues, + ) { + super( + entries && isIterable(entries) + ? entries + : entriesFromKeyValues(entries ?? {}), + ); + Object.freeze(this); + } + + get(key: Key): KeyValues[Key] | undefined { + return super.get(key) as KeyValues[Key] | undefined; + } + + /** + * Get a value from the context. Throws if the key is not found. + * + * @param key - The key to get the value for. + * @returns The value. + */ + assertGet(key: Key): KeyValues[Key] { + if (!super.has(key)) { + throw new Error(`Context key "${String(key)}" not found`); + } + return super.get(key) as KeyValues[Key]; + } + + /** + * Set a value in the context. Throws if the key already exists. + * {@link delete} an existing key before setting it to a new value. + * + * @throws If the key already exists. + * @param key - The key to set the value for. + * @param value - The value to set. + * @returns The context. + */ + set(key: Key, value: KeyValues[Key]): this { + if (super.has(key)) { + throw new Error(`MiddlewareContext key "${String(key)}" already exists`); + } + super.set(key, value); + return this; + } +} + +/** + * {@link Iterable} type guard. + * + * @param value - The value to check. + * @returns Whether the value is an {@link Iterable}. + */ +function isIterable( + value: Iterable | Record, +): value is Iterable { + return Symbol.iterator in value; +} + +/** + * Like Object.entries(), but includes symbol-keyed properties. + * + * @template KeyValues - The type of the keys and values in the object. + * @param keyValues - The object to convert. + * @returns The array of entries, including symbol-keyed properties. + */ +function entriesFromKeyValues>( + keyValues: KeyValues, +): [keyof KeyValues, KeyValues[keyof KeyValues]][] { + return Reflect.ownKeys(keyValues).map((key: keyof KeyValues) => [ + key, + keyValues[key], + ]); +} + +/** + * Infer the KeyValues type from a {@link MiddlewareContext}. + */ +export type InferKeyValues = + Type extends MiddlewareContext ? KeyValues : never; + +/** + * Simplifies an object type by "merging" its properties. + * + * - Expands intersections into a single object type. + * - Forces mapped/conditional results to resolve into a readable shape. + * - No runtime effect; purely a type-level normalization. + * + * @example + * type A = { a: string } & { b: number }; + * type B = Simplify; // { a: string; b: number } + */ +type Simplify = Type extends infer Object + ? { [Key in keyof Object]: Object[Key] } + : never; + +/** + * Rejects record types that contain any `never`-valued property. + * + * If any property of `T` resolves to `never`, the result is `never`; otherwise it returns `T` unchanged. + * Useful as a guard to ensure computed/merged record types didn't collapse any fields to `never`. + * + * @example + * type A = ExcludeNever<{ a: string; b: never }>; // never + * type B = ExcludeNever<{ a: string; b: number }>; // { a: string; b: number } + */ +type ExcludeNever> = { + [Key in keyof Type]-?: [Type[Key]] extends [never] ? Key : never; +}[keyof Type] extends never + ? Type + : never; + +/** + * Merge a union of {@link MiddlewareContext}s into a single {@link MiddlewareContext} + * supertype. + * + * @param Contexts - The union of {@link MiddlewareContext}s to merge. + * @returns The merged {@link MiddlewareContext} supertype. + * @example + * type A = MiddlewareContext<{ a: string }> | MiddlewareContext<{ b: number }>; + * type B = MergeContexts; // MiddlewareContext<{ a: string, b: number }> + */ +export type MergeContexts = + ExcludeNever< + Simplify>> + > extends never + ? never + : MiddlewareContext< + ExcludeNever>>> + >; + +/** + * A constraint for {@link MiddlewareContext} generic parameters. + */ +// Non-polluting `any` constraint. +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export type ContextConstraint = MiddlewareContext; + +/** + * The empty context type, i.e. `MiddlewareContext<{}>`. + */ +// The empty object type is literally an empty object in this context. +// eslint-disable-next-line @typescript-eslint/no-empty-object-type +export type EmptyContext = MiddlewareContext<{}>; diff --git a/packages/json-rpc-engine/src/v2/README.md b/packages/json-rpc-engine/src/v2/README.md new file mode 100644 index 00000000000..ac52988c308 --- /dev/null +++ b/packages/json-rpc-engine/src/v2/README.md @@ -0,0 +1,3 @@ +# `@metamask/json-rpc-engine/v2` + +See the [root readme](../../README.md). diff --git a/packages/json-rpc-engine/src/v2/asLegacyMiddleware.test.ts b/packages/json-rpc-engine/src/v2/asLegacyMiddleware.test.ts new file mode 100644 index 00000000000..20f188fb923 --- /dev/null +++ b/packages/json-rpc-engine/src/v2/asLegacyMiddleware.test.ts @@ -0,0 +1,287 @@ +import type { + Json, + JsonRpcFailure, + JsonRpcRequest, + JsonRpcSuccess, +} from '@metamask/utils'; + +import { + getExtraneousKeys, + makeRequest, + makeRequestMiddleware, +} from '../../tests/utils.js'; +import { JsonRpcEngine } from '../JsonRpcEngine.js'; +import { asLegacyMiddleware } from './asLegacyMiddleware.js'; +import type { JsonRpcMiddleware, ResultConstraint } from './JsonRpcEngineV2.js'; +import { JsonRpcEngineV2 } from './JsonRpcEngineV2.js'; + +describe('asLegacyMiddleware', () => { + it('converts a v2 engine to a legacy middleware', () => { + const engine = JsonRpcEngineV2.create({ + middleware: [makeRequestMiddleware()], + }); + const middleware = asLegacyMiddleware(engine); + expect(typeof middleware).toBe('function'); + }); + + it('forwards a result to the legacy engine', async () => { + const v2Engine = JsonRpcEngineV2.create({ + middleware: [makeRequestMiddleware()], + }); + + const legacyEngine = new JsonRpcEngine(); + legacyEngine.push(asLegacyMiddleware(v2Engine)); + + const response = (await legacyEngine.handle( + makeRequest(), + )) as JsonRpcSuccess; + + expect(response.result).toBeNull(); + }); + + it('forwarded results are not frozen', async () => { + const v2Middleware: JsonRpcMiddleware = () => []; + const v2Engine = JsonRpcEngineV2.create({ + middleware: [v2Middleware], + }); + + const legacyEngine = new JsonRpcEngine(); + legacyEngine.push(asLegacyMiddleware(v2Engine)); + + const response = (await legacyEngine.handle( + makeRequest(), + )) as JsonRpcSuccess; + + expect(response.result).toStrictEqual([]); + expect(Object.isFrozen(response.result)).toBe(false); + }); + + it('forwards an error to the legacy engine', async () => { + const v2Middleware: JsonRpcMiddleware = () => { + throw new Error('test'); + }; + const v2Engine = JsonRpcEngineV2.create({ + middleware: [v2Middleware], + }); + + const legacyEngine = new JsonRpcEngine(); + legacyEngine.push(asLegacyMiddleware(v2Engine)); + + const response = (await legacyEngine.handle( + makeRequest(), + )) as JsonRpcFailure; + + expect(response.error).toStrictEqual({ + message: 'test', + code: -32603, + data: { + cause: { + message: 'test', + stack: expect.any(String), + }, + }, + }); + }); + + it('allows the legacy engine to continue when not ending the request', async () => { + const v2Middleware: JsonRpcMiddleware = jest.fn( + ({ next }) => next(), + ); + const v2Engine = JsonRpcEngineV2.create({ + middleware: [v2Middleware], + }); + + const legacyEngine = new JsonRpcEngine(); + legacyEngine.push(asLegacyMiddleware(v2Engine)); + legacyEngine.push((_req, res, _next, end) => { + res.result = null; + end(); + }); + + const response = (await legacyEngine.handle( + makeRequest(), + )) as JsonRpcSuccess; + expect(response.result).toBeNull(); + expect(v2Middleware).toHaveBeenCalledTimes(1); + }); + + it('allows the legacy engine to continue when not ending the request (passing through the original request)', async () => { + const v2Middleware: JsonRpcMiddleware = jest.fn( + ({ request, next }) => next(request), + ); + const v2Engine = JsonRpcEngineV2.create({ + middleware: [v2Middleware], + }); + + const legacyEngine = new JsonRpcEngine(); + legacyEngine.push(asLegacyMiddleware(v2Engine)); + legacyEngine.push((_req, res, _next, end) => { + res.result = null; + end(); + }); + + const response = (await legacyEngine.handle( + makeRequest(), + )) as JsonRpcSuccess; + expect(response.result).toBeNull(); + expect(v2Middleware).toHaveBeenCalledTimes(1); + }); + + it('propagates request modifications to the legacy engine', async () => { + const v2Engine = JsonRpcEngineV2.create>({ + middleware: [ + ({ request, next }): Promise | undefined> => + next({ ...request, method: 'test_request_2' }), + ], + }); + + const legacyEngine = new JsonRpcEngine(); + legacyEngine.push((req, _res, next, _end) => { + expect(req.method).toBe('test_request'); + next(); + }); + legacyEngine.push(asLegacyMiddleware(v2Engine)); + legacyEngine.push((req, res, _next, end) => { + expect(req.method).toBe('test_request_2'); + res.result = null; + end(); + }); + + const response = (await legacyEngine.handle( + makeRequest(), + )) as JsonRpcSuccess; + expect(response.result).toBeNull(); + }); + + it('propagates additional request properties to the v2 context and back', async () => { + const observedContextValues: number[] = []; + + const v2Middleware = jest.fn((({ + context, + next, + }): Promise | undefined> => { + observedContextValues.push(context.assertGet('value') as number); + + expect(Array.from(context.keys())).toStrictEqual(['value']); + + context.set('newValue', 2); + return next(); + }) satisfies JsonRpcMiddleware< + JsonRpcRequest, + ResultConstraint + >); + + const v2Engine = JsonRpcEngineV2.create({ + middleware: [v2Middleware], + }); + + const legacyEngine = new JsonRpcEngine(); + legacyEngine.push((req, _res, next, _end) => { + (req as Record).value = 1; + return next(); + }); + legacyEngine.push(asLegacyMiddleware(v2Engine)); + legacyEngine.push((req, res, _next, end) => { + observedContextValues.push( + (req as Record).newValue as number, + ); + + expect(getExtraneousKeys(req)).toStrictEqual(['value', 'newValue']); + + res.result = null; + end(); + }); + + await legacyEngine.handle(makeRequest()); + expect(observedContextValues).toStrictEqual([1, 2]); + }); + + describe('with V2 middleware', () => { + it('accepts a single V2 middleware', async () => { + const v2Middleware: JsonRpcMiddleware = jest.fn( + () => 'test-result', + ); + + const legacyEngine = new JsonRpcEngine(); + legacyEngine.push(asLegacyMiddleware(v2Middleware)); + + const response = (await legacyEngine.handle( + makeRequest(), + )) as JsonRpcSuccess; + + expect(response.result).toBe('test-result'); + expect(v2Middleware).toHaveBeenCalledTimes(1); + }); + + it('accepts multiple V2 middlewares via rest params', async () => { + const middleware1: JsonRpcMiddleware = jest.fn( + ({ context, next }) => { + context.set('visited1', true); + return next(); + }, + ); + + const middleware2: JsonRpcMiddleware = jest.fn( + ({ context }) => { + expect(context.get('visited1')).toBe(true); + return 'composed-result'; + }, + ); + + const legacyEngine = new JsonRpcEngine(); + legacyEngine.push(asLegacyMiddleware(middleware1, middleware2)); + + const response = (await legacyEngine.handle( + makeRequest(), + )) as JsonRpcSuccess; + + expect(response.result).toBe('composed-result'); + expect(middleware1).toHaveBeenCalledTimes(1); + expect(middleware2).toHaveBeenCalledTimes(1); + }); + + it('forwards errors from V2 middleware', async () => { + const v2Middleware: JsonRpcMiddleware = jest.fn(() => { + throw new Error('v2-error'); + }); + + const legacyEngine = new JsonRpcEngine(); + legacyEngine.push(asLegacyMiddleware(v2Middleware)); + + const response = (await legacyEngine.handle( + makeRequest(), + )) as JsonRpcFailure; + + expect(response.error).toStrictEqual({ + message: 'v2-error', + code: -32603, + data: { + cause: { + message: 'v2-error', + stack: expect.any(String), + }, + }, + }); + }); + + it('allows legacy engine to continue when V2 middleware does not end', async () => { + const v2Middleware: JsonRpcMiddleware = jest.fn( + ({ next }) => next(), + ); + + const legacyEngine = new JsonRpcEngine(); + legacyEngine.push(asLegacyMiddleware(v2Middleware)); + legacyEngine.push((_req, res, _next, end) => { + res.result = 'continued'; + end(); + }); + + const response = (await legacyEngine.handle( + makeRequest(), + )) as JsonRpcSuccess; + + expect(response.result).toBe('continued'); + expect(v2Middleware).toHaveBeenCalledTimes(1); + }); + }); +}); diff --git a/packages/json-rpc-engine/src/v2/asLegacyMiddleware.ts b/packages/json-rpc-engine/src/v2/asLegacyMiddleware.ts new file mode 100644 index 00000000000..72c38d75688 --- /dev/null +++ b/packages/json-rpc-engine/src/v2/asLegacyMiddleware.ts @@ -0,0 +1,89 @@ +import type { JsonRpcParams, JsonRpcRequest } from '@metamask/utils'; + +import { createAsyncMiddleware } from '../index.js'; +import type { JsonRpcMiddleware as LegacyMiddleware } from '../index.js'; +import { + deepClone, + fromLegacyRequest, + makeContext, + propagateToRequest, +} from './compatibility-utils.js'; +import type { JsonRpcMiddleware, ResultConstraint } from './JsonRpcEngineV2.js'; +import { JsonRpcEngineV2 } from './JsonRpcEngineV2.js'; + +/** + * Convert a {@link JsonRpcEngineV2} into a legacy middleware. + * + * @param engine - The engine to convert. + * @returns The legacy middleware. + */ +export function asLegacyMiddleware< + Params extends JsonRpcParams, + Request extends JsonRpcRequest, +>( + engine: JsonRpcEngineV2, +): LegacyMiddleware>; + +/** + * Convert one or more V2 middlewares into a legacy middleware. + * + * @param middleware - The V2 middleware(s) to convert. + * @returns The legacy middleware. + */ +export function asLegacyMiddleware< + Params extends JsonRpcParams, + Request extends JsonRpcRequest, +>( + ...middleware: JsonRpcMiddleware>[] +): LegacyMiddleware>; + +/** + * The asLegacyMiddleware implementation. + * + * @param engineOrMiddleware - A V2 engine or V2 middleware. + * @param rest - Any additional V2 middleware when the first argument is a middleware. + * @returns The legacy middleware. + */ +export function asLegacyMiddleware< + Params extends JsonRpcParams, + Request extends JsonRpcRequest, +>( + engineOrMiddleware: + | JsonRpcEngineV2 + | JsonRpcMiddleware>, + ...rest: JsonRpcMiddleware>[] +): LegacyMiddleware> { + const v2Middleware = + typeof engineOrMiddleware === 'function' + ? JsonRpcEngineV2.create({ + middleware: [engineOrMiddleware, ...rest], + }).asMiddleware() + : engineOrMiddleware.asMiddleware(); + + return createAsyncMiddleware(async (req, res, next) => { + const request = fromLegacyRequest(req as Request); + const context = makeContext(req); + let modifiedRequest: Request | undefined; + + const result = await v2Middleware({ + request, + context, + next: (finalRequest) => { + modifiedRequest = finalRequest; + return Promise.resolve(undefined); + }, + }); + + if (modifiedRequest !== undefined && modifiedRequest !== request) { + Object.assign(req, deepClone(modifiedRequest)); + } + propagateToRequest(req, context); + + if (result !== undefined) { + // Unclear why the `as unknown` is needed here, but the cast is safe. + res.result = deepClone(result) as unknown as ResultConstraint; + return undefined; + } + return next(); + }); +} diff --git a/packages/json-rpc-engine/src/v2/compatibility-utils.test.ts b/packages/json-rpc-engine/src/v2/compatibility-utils.test.ts new file mode 100644 index 00000000000..bb57973323c --- /dev/null +++ b/packages/json-rpc-engine/src/v2/compatibility-utils.test.ts @@ -0,0 +1,558 @@ +import { JsonRpcError } from '@metamask/rpc-errors'; +import type { Json } from '@metamask/utils'; + +import { + deepClone, + fromLegacyRequest, + makeContext, + propagateToContext, + propagateToRequest, + deserializeError, +} from './compatibility-utils.js'; +import { MiddlewareContext } from './MiddlewareContext.js'; +import { stringify } from './utils.js'; + +const jsonrpc = '2.0' as const; + +describe('compatibility-utils', () => { + describe('deepClone', () => { + it('clones an object', () => { + const request = { + jsonrpc, + method: 'test_method', + params: [], + id: 1, + }; + const clonedRequest = deepClone(request); + + expect(clonedRequest).toStrictEqual(request); + expect(clonedRequest).not.toBe(request); + }); + + it('produces a mutable clone of a frozen object', () => { + const request = Object.freeze({ + jsonrpc, + method: 'test_method' as string, + params: Object.freeze([1, 2, 3]), + id: 1, + }); + + const clonedRequest = deepClone(request); + + expect(clonedRequest).toStrictEqual(request); + expect(clonedRequest).not.toBe(request); + expect(Object.isFrozen(clonedRequest)).toBe(false); + expect(Object.isFrozen(clonedRequest.params)).toBe(false); + + clonedRequest.method = 'modified_method'; + clonedRequest.params[1] = 42; + + expect(request.method).toBe('test_method'); + expect(clonedRequest.params[1]).toBe(42); + }); + + it('ignores symbol properties', () => { + const symbolProp = Symbol('test'); + const request = { + jsonrpc, + method: 'test_method' as string, + params: [1, 2, 3], + id: 1, + [symbolProp]: 'value', + }; + + const clonedRequest = deepClone(request); + // @ts-expect-error - Symbol properties are omitted + expect(clonedRequest[symbolProp]).toBeUndefined(); + }); + }); + + describe('fromLegacyRequest', () => { + it('converts a request, preserving its properties', () => { + const legacyRequest = { + jsonrpc, + method: 'test_method', + params: [1, 2, 3], + id: 42, + }; + const request = fromLegacyRequest(legacyRequest); + + expect(request).toStrictEqual({ + jsonrpc, + method: 'test_method', + params: [1, 2, 3], + id: 42, + }); + }); + + it('clones params to avoid freezing them as part of the new request object', () => { + const params = [1, { a: 2 }]; + const legacyRequest = { + jsonrpc, + method: 'test_method', + params, + id: 42, + }; + const request = fromLegacyRequest(legacyRequest); + + expect(request.params).toStrictEqual(params); + expect(request.params).not.toBe(params); + expect(request.params?.[1]).not.toBe(params[1]); + }); + + it('handles requests without params', () => { + const legacyRequest = { + jsonrpc, + method: 'test_method', + id: 42, + }; + const request = fromLegacyRequest(legacyRequest); + + expect(request).toStrictEqual({ + jsonrpc, + method: 'test_method', + id: 42, + }); + }); + + it('handles requests with undefined params', () => { + const legacyRequest = { + jsonrpc, + method: 'test_method', + id: 42, + params: undefined, + }; + + // @ts-expect-error - Destructive testing + const request = fromLegacyRequest(legacyRequest); + + expect(request).toStrictEqual({ + jsonrpc, + method: 'test_method', + id: 42, + }); + }); + + it('handles requests without a jsonrpc property', () => { + const legacyRequest = { + method: 'test_method', + params: [1], + id: 42, + }; + + // @ts-expect-error - Destructive testing + const request = fromLegacyRequest(legacyRequest); + + expect(request).toStrictEqual({ + jsonrpc, + method: 'test_method', + params: [1], + id: 42, + }); + }); + + it('handles requests with a faulty jsonrpc property', () => { + const legacyRequest = { + jsonrpc: '1.0', + method: 'test_method', + params: [1], + id: 42, + }; + + // @ts-expect-error - Destructive testing + const request = fromLegacyRequest(legacyRequest); + + expect(request).toStrictEqual({ + jsonrpc, + method: 'test_method', + params: [1], + id: 42, + }); + }); + + it('ignores additional properties on the legacy request', () => { + const legacyRequest = { + jsonrpc, + method: 'test_method', + params: [1], + id: 42, + extraProp: 'value', + anotherProp: { nested: true }, + }; + const request = fromLegacyRequest(legacyRequest); + + expect(request).toStrictEqual({ + jsonrpc, + method: 'test_method', + params: [1], + id: 42, + }); + }); + }); + + describe('makeContext', () => { + it('creates a middleware context from a valid JSON-RPC request', () => { + const request = { + jsonrpc, + method: 'test_method', + params: [1], + id: 42, + }; + const context = makeContext(request); + + expect(context).toBeInstanceOf(MiddlewareContext); + expect(Array.from(context.keys())).toStrictEqual([]); + }); + + it('includes non-JSON-RPC properties from request in context', () => { + const request = { + jsonrpc, + method: 'test_method', + params: [1], + id: 42, + extraProp: 'value', + anotherProp: { nested: true }, + }; + const context = makeContext(request); + + expect(Array.from(context.keys())).toStrictEqual([ + 'extraProp', + 'anotherProp', + ]); + }); + }); + + describe('propagateToContext', () => { + it('copies non-JSON-RPC properties from request to context', () => { + const request = { + jsonrpc, + method: 'test_method', + params: [1], + id: 42, + extraProp: 'value', + anotherProp: { nested: true }, + }; + const context = new MiddlewareContext(); + + propagateToContext(request, context); + + expect(Array.from(context.keys())).toStrictEqual([ + 'extraProp', + 'anotherProp', + ]); + }); + + it('handles requests with no extra properties', () => { + const request = { + jsonrpc, + method: 'test_method', + params: [1], + id: 42, + }; + const context = new MiddlewareContext(); + + propagateToContext(request, context); + + expect(Array.from(context.keys())).toStrictEqual([]); + }); + }); + + describe('propagateToRequest', () => { + it('copies non-JSON-RPC string properties from context to request', () => { + const request = { + jsonrpc, + method: 'test_method', + params: [1], + id: 42, + }; + const context = new MiddlewareContext>(); + context.set('extraProp', 'value'); + context.set('anotherProp', { nested: true }); + + propagateToRequest(request, context); + + expect(request).toStrictEqual({ + jsonrpc, + method: 'test_method', + params: [1], + id: 42, + extraProp: 'value', + anotherProp: { nested: true }, + }); + }); + + it('does not copy non-string properties from context to request', () => { + const symbol = Symbol('anotherProp'); + const context = new MiddlewareContext(); + context.set('extraProp', 'value'); + context.set(symbol, { nested: true }); + context.set(42, 'value'); + + const request = { + jsonrpc, + method: 'test_method', + params: [1], + id: 42, + }; + propagateToRequest(request, context); + + expect(request).toStrictEqual({ + jsonrpc, + method: 'test_method', + params: [1], + id: 42, + extraProp: 'value', + }); + expect(symbol in request).toBe(false); + }); + + it('excludes JSON-RPC properties from propagation', () => { + const request = { + jsonrpc, + method: 'test_method', + params: [1], + id: 42, + }; + const context = new MiddlewareContext>(); + context.set('jsonrpc', '3.0'); + context.set('method', 'other_method'); + context.set('params', [2]); + context.set('id', 99); + context.set('extraProp', 'value'); + + propagateToRequest(request, context); + + expect(request).toStrictEqual({ + jsonrpc, + method: 'test_method', + params: [1], + id: 42, + extraProp: 'value', + }); + }); + + it('overwrites existing request properties', () => { + const request = { + jsonrpc, + method: 'test_method', + params: [1], + id: 42, + existingKey: 'oldValue', + }; + const context = new MiddlewareContext>(); + context.set('existingKey', 'newValue'); + + propagateToRequest(request, context); + + expect(request.existingKey).toBe('newValue'); + }); + }); + + describe('deserializeError', () => { + // Requires some special handling due to the possible existence or + // non-existence of Error.isError + describe('Error.isError', () => { + const isErrorExists = 'isError' in Error; + let originalIsError: (value: unknown) => boolean; + let isError: jest.Mock; + + beforeAll(() => { + isError = jest.fn(); + // @ts-expect-error - Error type outdated + originalIsError = Error.isError; + // @ts-expect-error - Error type outdated + Error.isError = isError; + }); + + beforeEach(() => { + isError.mockClear(); + }); + + afterAll(() => { + if (isErrorExists) { + // @ts-expect-error - Error type outdated + Error.isError = originalIsError; + } else { + // @ts-expect-error - Error type outdated + delete Error.isError; + } + }); + + it('returns the thrown value when Error.isError is available and returns true', () => { + isError.mockReturnValueOnce(true); + const originalError = new Error('test error'); + + const result = deserializeError(originalError); + expect(result).toBe(originalError); + }); + + it('returns the thrown value when it is instanceof Error', () => { + isError.mockReturnValueOnce(false); + const originalError = new Error('test error'); + + const result = deserializeError(originalError); + expect(result).toBe(originalError); + }); + }); + + it('creates a new Error when thrown value is a string', () => { + const errorMessage = 'test error message'; + const result = deserializeError(errorMessage); + + expect(result).toBeInstanceOf(Error); + expect(result.message).toBe(errorMessage); + }); + + it.each([42, true, false, null, undefined, Symbol('test')])( + 'creates a new Error with stringified message for non-object values', + (value) => { + const result = deserializeError(value); + + expect(result).toBeInstanceOf(Error); + expect(result.message).toBe(`Unknown error: ${stringify(value)}`); + }, + ); + + it('creates a JsonRpcError when thrown value is an object with valid integer code', () => { + const thrownValue = { + code: 1234, + message: 'test error message', + cause: new Error('cause'), + data: { foo: 'bar' }, + }; + + const result = deserializeError(thrownValue); + + expect(result).toBeInstanceOf(JsonRpcError); + expect(result).toMatchObject({ + message: 'test error message', + code: 1234, + cause: thrownValue.cause, + data: { foo: 'bar' }, + }); + }); + + it('creates a plain Error when thrown value is an object without code property', () => { + const thrownValue = { + message: 'test error message', + cause: new Error('cause'), + data: { foo: 'bar' }, + }; + + const result = deserializeError(thrownValue); + + expect(result).toBeInstanceOf(Error); + expect(result).not.toBeInstanceOf(JsonRpcError); + expect(result).toStrictEqual( + // @ts-expect-error - Error type outdated + new Error('test error message', { cause: thrownValue.cause }), + ); + }); + + it('creates a plain Error when thrown value has non-integer code', () => { + const thrownValue = { + code: 123.45, + message: 'test error message', + }; + + const result = deserializeError(thrownValue); + + expect(result).toBeInstanceOf(Error); + expect(result).not.toBeInstanceOf(JsonRpcError); + expect(result).toStrictEqual(new Error('test error message')); + }); + + it('preserves stack trace when thrown value has stack property', () => { + const stackTrace = 'Error: test\n at test.js:1:1'; + const thrownValue = { + message: 'test error', + stack: stackTrace, + }; + + const result = deserializeError(thrownValue); + + expect(result).toBeInstanceOf(Error); + expect(result.stack).toBe(stackTrace); + }); + + it('preserves cause and data in JsonRpcError', () => { + const cause = new Error('original cause'); + const data = { custom: 'data' }; + const thrownValue = { + code: 1234, + message: 'test error', + cause, + data, + }; + + const result = deserializeError(thrownValue) as JsonRpcError; + + expect(result.cause).toBe(cause); + expect(result.data).toStrictEqual({ + ...data, + cause, + }); + }); + + it('uses default error message when message property is missing and code is unrecognized', () => { + const thrownValue = { + code: 1234, + }; + + const result = deserializeError(thrownValue); + + expect(result.message).toBe('Unknown error'); + }); + + it('uses default error message when message property is not a string and code is unrecognized', () => { + const thrownValue = { + code: 1234, + message: 42, + }; + + const result = deserializeError(thrownValue); + + expect(result.message).toBe('Unknown error'); + }); + + it('uses correct error message when message property is not a string and code is recognized', () => { + const thrownValue = { + code: -32603, + message: 42, + }; + + const result = deserializeError(thrownValue); + + expect(result.message).toBe('Internal JSON-RPC error.'); + }); + + it('uses correct data when data is a primitive', () => { + const thrownValue = { + code: 3, + message: 'execution reverted', + data: '0x556f1830000000000000000000000000de9049636f4a1dfe0a64d1bfe3155c0a14c54f3100000000000000000000000000000000000000000000000000000000000000a00000000000000000000000000000000000000000000000000000000000000160f4d4d2f800000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000028000000000000000000000000000000000000000000000000000000000000000010000000000000000000000000000000000000000000000000000000000000020000000000000000000000000000000000000000000000000000000000000004d68747470733a2f2f6170692e636f696e626173652e636f6d2f6170692f76312f646f6d61696e2f7265736f6c7665722f7265736f6c7665446f6d61696e2f7b73656e6465727d2f7b646174617d0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000e49061b92300000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000080000000000000000000000000000000000000000000000000000000000000000f046772656704626173650365746800000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000243b3b57de068bcd633299a45145c9f0ec708cd91d078cede4afe492db21c009229d02a405000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000e49061b92300000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000080000000000000000000000000000000000000000000000000000000000000000f046772656704626173650365746800000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000243b3b57de068bcd633299a45145c9f0ec708cd91d078cede4afe492db21c009229d02a4050000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000', + }; + + const result = deserializeError(thrownValue) as JsonRpcError; + + expect(result.data).toBe(thrownValue.data); + }); + + it('preserves existing data.cause if defined', () => { + const thrownValue = { + code: -31001, + message: 'Wrapped Snap Error', + data: { + cause: { code: -31002, message: 'Snap Error', data: {} }, + foo: 'bar', + }, + }; + + const result = deserializeError(thrownValue) as JsonRpcError; + + expect(result.code).toBe(thrownValue.code); + expect(result.message).toBe(thrownValue.message); + expect(result.data).toStrictEqual(thrownValue.data); + }); + }); +}); diff --git a/packages/json-rpc-engine/src/v2/compatibility-utils.ts b/packages/json-rpc-engine/src/v2/compatibility-utils.ts new file mode 100644 index 00000000000..421c195454e --- /dev/null +++ b/packages/json-rpc-engine/src/v2/compatibility-utils.ts @@ -0,0 +1,213 @@ +import type { OptionalDataWithOptionalCause } from '@metamask/rpc-errors'; +import { getMessageFromCode, JsonRpcError } from '@metamask/rpc-errors'; +import type { Json } from '@metamask/utils'; +import { hasProperty, isObject, isValidJson } from '@metamask/utils'; +// ATTN: We must NOT use 'klona/full' here because it freezes properties on the clone. +import { klona } from 'klona'; + +import { MiddlewareContext } from './MiddlewareContext.js'; +import { stringify } from './utils.js'; +import type { JsonRpcRequest } from './utils.js'; + +// Legacy engine compatibility utils + +/** + * Create a deep clone of a value as follows: + * - Assumes acyclical objects + * - Does not copy property descriptors (i.e. uses mutable defaults) + * - Ignores non-enumerable properties + * - Ignores getters and setters + * + * @throws If the value is an object with a circular reference. + * @param value - The value to clone. + * @returns The cloned value. + */ +export const deepClone = (value: Type): DeepCloned => + klona(value) as DeepCloned; + +// Matching the default implementation of klona, this type: +// - Removes readonly modifiers +// - Excludes non-enumerable / symbol properties +type DeepCloned = Type extends readonly (infer ArrayType)[] + ? DeepCloned[] + : Type extends object + ? { + -readonly [Key in keyof Type & (string | number)]: DeepCloned< + Type[Key] + >; + } + : Type; + +/** + * Standard JSON-RPC request properties. + */ +export const requestProps = ['jsonrpc', 'method', 'params', 'id']; + +/** + * Make a JSON-RPC request from a legacy request. Clones the params to avoid + * freezing them, which could cause errors in an involved legacy engine. + * + * @param req - The legacy request to make a request from. + * @returns The JSON-RPC request. + */ +export function fromLegacyRequest( + req: Request, +): Request { + const request = { + jsonrpc: '2.0' as const, + method: req.method, + } as Partial; + request.id = req.id; + if (hasProperty(req, 'params') && req.params !== undefined) { + request.params = deepClone(req.params); + } + return request as Request; +} + +/** + * Make a middleware context from a legacy request by copying over all non-JSON-RPC + * properties from the request to the context object. + * + * @param req - The legacy request to make a context from. + * @returns The middleware context. + */ +export function makeContext>( + req: Request, +): MiddlewareContext { + const context = new MiddlewareContext(); + propagateToContext(req, context); + return context; +} + +/** + * Copies non-JSON-RPC string properties from the request to the context. + * + * For compatibility with our problematic practice of appending non-standard + * fields to requests for inter-middleware communication in the legacy engine. + * + * **ATTN:** Only string properties that do not already exist in the context + * are copied. + * + * @param req - The request to propagate the context from. + * @param context - The context to propagate to. + */ +export function propagateToContext( + req: Record, + context: MiddlewareContext>, +): void { + Object.keys(req) + .filter( + (key) => + typeof key === 'string' && + !requestProps.includes(key) && + !context.has(key), + ) + .forEach((key) => { + context.set(key, req[key]); + }); +} + +/** + * Copies non-JSON-RPC string properties from the context to the request. + * + * For compatibility with our problematic practice of appending non-standard + * fields to requests for inter-middleware communication in the legacy engine. + * + * **ATTN:** Only string properties are copied. + * + * @param req - The request to propagate the context to. + * @param context - The context to propagate from. + */ +export function propagateToRequest( + req: Record, + context: MiddlewareContext, +): void { + Array.from(context.keys()) + .filter( + ((key) => typeof key === 'string' && !requestProps.includes(key)) as ( + value: unknown, + ) => value is string, + ) + .forEach((key) => { + req[key] = context.get(key); + }); +} + +/** + * Deserialize the error property for a thrown error, merging in the cause where possible. + * + * @param data - The data from the thrown error. + * @param cause - The cause from the thrown error. + * @returns The deserialized data. + */ +function deserializeData( + data: unknown, + cause: unknown, +): OptionalDataWithOptionalCause { + // If data is an object, merge with cause. + if (isObject(data)) { + return { ...data, cause: cause ?? data.cause }; + } + + // If data is a JSON value that's not mergeable. + if (isValidJson(data)) { + return data; + } + + // If data is undefined, only use cause. + return { cause }; +} + +/** + * Unserialize an error from a thrown value. Creates a {@link JsonRpcError} if + * the thrown value is an object with a `code` property. Otherwise, creates a + * plain {@link Error}. + * + * @param thrown - The thrown value to unserialize. + * @returns The unserialized error. + */ +export function deserializeError(thrown: unknown): Error | JsonRpcError { + // @ts-expect-error - New, but preferred if available. + // See: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Error/isError + if (typeof Error.isError === 'function' && Error.isError(thrown)) { + return thrown as Error; + } + // Unlike Error.isError, instanceof does not work for Errors from other realms. + if (thrown instanceof Error) { + return thrown; + } + if (typeof thrown === 'string') { + return new Error(thrown); + } + if (!isObject(thrown)) { + return new Error(`Unknown error: ${stringify(thrown)}`); + } + + const code = + typeof thrown.code === 'number' && Number.isInteger(thrown.code) + ? thrown.code + : undefined; + + let message = 'Unknown error'; + if (typeof thrown.message === 'string') { + message = thrown.message; + } else if (typeof code === 'number') { + message = getMessageFromCode(code, message); + } + + const { stack, cause, data } = thrown; + + const error = + code === undefined + ? // Jest complains if we use the `@ts-expect-error` directive here. + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore - Our error type is outdated. + new Error(message, { cause }) + : new JsonRpcError(code, message, deserializeData(data, cause)); + + if (typeof stack === 'string') { + error.stack = stack; + } + + return error; +} diff --git a/packages/json-rpc-engine/src/v2/createMethodMiddleware.test.ts b/packages/json-rpc-engine/src/v2/createMethodMiddleware.test.ts new file mode 100644 index 00000000000..c9535b1be64 --- /dev/null +++ b/packages/json-rpc-engine/src/v2/createMethodMiddleware.test.ts @@ -0,0 +1,164 @@ +import { Messenger, MOCK_ANY_NAMESPACE } from '@metamask/messenger'; + +import { makeRequest } from '../../tests/utils.js'; +import { + createMethodMiddleware, + MethodHandler, +} from './createMethodMiddleware.js'; +import { JsonRpcEngineV2 } from './JsonRpcEngineV2.js'; +import { JsonRpcRequest } from './utils.js'; + +type TestAction = { + type: 'Example:TestAction'; + handler: () => Promise; +}; + +function setup(): { engine: JsonRpcEngineV2 } { + const getValueA = { + hookNames: { testHook: true }, + implementation: ({ hooks }): Promise => hooks.testHook(), + } satisfies MethodHandler<{ testHook: () => Promise }>; + + const getValueB = { + actionNames: ['Example:TestAction'], + implementation: ({ messenger }): Promise => + messenger.call('Example:TestAction'), + } satisfies MethodHandler; + + const messenger = new Messenger({ + namespace: MOCK_ANY_NAMESPACE, + }); + + messenger.registerActionHandler('Example:TestAction', async () => 'B'); + + const middleware = createMethodMiddleware({ + handlers: { getValueA, getValueB }, + hooks: { testHook: async () => 'A' }, + messenger, + }); + + const engine = JsonRpcEngineV2.create({ middleware: [middleware] }); + + return { engine }; +} + +function setupWithoutMessenger(): { engine: JsonRpcEngineV2 } { + const getValueA = { + hookNames: { testHook: true }, + implementation: ({ hooks }): Promise => hooks.testHook(), + } satisfies MethodHandler<{ testHook: () => Promise }>; + + const middleware = createMethodMiddleware({ + handlers: { getValueA }, + hooks: { testHook: async () => 'A' }, + }); + + const engine = JsonRpcEngineV2.create({ middleware: [middleware] }); + + return { engine }; +} + +describe('createMethodMiddleware', () => { + it('passes in the requested hooks without a messenger', async () => { + const { engine } = setupWithoutMessenger(); + + const result = await engine.handle(makeRequest({ method: 'getValueA' })); + expect(result).toBe('A'); + }); + + it('passes in a delegated messenger', async () => { + const { engine } = setup(); + + const result = await engine.handle(makeRequest({ method: 'getValueB' })); + expect(result).toBe('B'); + }); + + it('skips unrecognized methods', async () => { + const { engine } = setup(); + + await expect( + engine.handle(makeRequest({ method: 'getValueC' })), + ).rejects.toThrow('Nothing ended request'); + }); + + it('handles a handler with no hooks or actions', async () => { + const noDeps = { + implementation: (): Promise => Promise.resolve('ok'), + } satisfies MethodHandler; + + const middleware = createMethodMiddleware({ + handlers: { noDeps }, + hooks: {}, + }); + const engine = JsonRpcEngineV2.create({ middleware: [middleware] }); + + const result = await engine.handle(makeRequest({ method: 'noDeps' })); + expect(result).toBe('ok'); + }); + + it('propagates errors thrown by the implementation', async () => { + const failing = { + implementation: (): Promise => { + throw new Error('test error'); + }, + } satisfies MethodHandler; + + const middleware = createMethodMiddleware({ + handlers: { failing }, + hooks: {}, + }); + const engine = JsonRpcEngineV2.create({ middleware: [middleware] }); + + await expect( + engine.handle(makeRequest({ method: 'failing' })), + ).rejects.toThrow('test error'); + }); + + it('throws if handler actionNames are configured without a messenger', () => { + const getValueB = { + actionNames: ['Example:TestAction'], + implementation: (): Promise => Promise.resolve('B'), + } satisfies MethodHandler; + + expect(() => + createMethodMiddleware({ + handlers: { getValueB }, + hooks: {}, + }), + ).toThrow('A messenger is required when a handler declares actionNames.'); + }); + + it('throws if a required hook is missing', () => { + const getValueA = { + hookNames: { testHook: true }, + implementation: ({ hooks }): Promise => hooks.testHook(), + } satisfies MethodHandler<{ testHook: () => Promise }>; + + expect(() => + createMethodMiddleware({ + handlers: { getValueA }, + // @ts-expect-error Intentionally missing a required hook. + hooks: {}, + }), + ).toThrow('Missing expected hooks'); + }); + + it('throws if an extraneous hook is provided', () => { + const getValueA = { + hookNames: { testHook: true }, + implementation: ({ hooks }): Promise => hooks.testHook(), + } satisfies MethodHandler<{ testHook: () => Promise }>; + + const hooks = { + testHook: async (): Promise => 'A', + extraneousHook: (): number => 100, + }; + + expect(() => + createMethodMiddleware({ + handlers: { getValueA }, + hooks, + }), + ).toThrow('Received unexpected hooks'); + }); +}); diff --git a/packages/json-rpc-engine/src/v2/createMethodMiddleware.ts b/packages/json-rpc-engine/src/v2/createMethodMiddleware.ts new file mode 100644 index 00000000000..3a6213a6e12 --- /dev/null +++ b/packages/json-rpc-engine/src/v2/createMethodMiddleware.ts @@ -0,0 +1,156 @@ +import { ActionConstraint, Messenger } from '@metamask/messenger'; + +import { JsonRpcMiddleware, Next } from './JsonRpcEngineV2.js'; +import { ContextConstraint } from './MiddlewareContext.js'; +import { + assertExpectedHooks, + createHandlerMessenger, + selectHooks, + Json, + JsonRpcParams, + JsonRpcRequest, + UnionToIntersection, +} from './utils.js'; + +type HandlerActions = Handler extends { + implementation: (options: infer Options) => unknown; +} + ? Options extends { messenger: Messenger } + ? Actions + : never + : never; + +type HandlerHooks = Handler extends { + implementation: (options: infer Options) => unknown; +} + ? Options extends { hooks: infer Hooks } + ? Hooks + : never + : never; + +/** + * A `JsonRpcEngineV2` method middleware handler. + */ +export type MethodHandler< + Hooks extends Record = never, + MessengerActions extends ActionConstraint = never, + Parameters extends JsonRpcParams = JsonRpcParams, + Result extends Json = Json, + Context extends ContextConstraint = ContextConstraint, +> = { + implementation: (options: { + request: Readonly>; + context: Context; + next: Next; + messenger: Messenger; + hooks: Hooks; + }) => Promise | Result; +} & ([Hooks] extends [never] + ? { hookNames?: undefined } + : { hookNames: { [Key in keyof Hooks]: true } }) & + ([MessengerActions] extends [never] + ? { actionNames?: undefined } + : { actionNames: MessengerActions['type'][] }); + +type AnyMethodHandler = { + implementation( + this: void, + options: { + request: Readonly; + context: ContextConstraint; + next: Next; + messenger: unknown; + hooks: unknown; + }, + ): Promise | Json; + hookNames?: Record; + actionNames?: readonly string[]; +}; + +type CreateMethodMiddlewareBaseOptions< + Handlers extends Record, +> = { + handlers: Handlers; + hooks: [HandlerHooks] extends [never] + ? Record + : UnionToIntersection>; +}; + +/** + * Options for {@link createMethodMiddleware}. + */ +export type CreateMethodMiddlewareOptions< + Handlers extends Record, +> = CreateMethodMiddlewareBaseOptions & + ([HandlerActions] extends [never] + ? { + messenger?: undefined; + } + : { + messenger: Messenger>; + }); + +type ResolvedHandler = { + implementation: AnyMethodHandler['implementation']; + hooks: Record; + messenger?: Messenger | undefined; +}; + +/** + * Create a JSON-RPC middleware that handles the passed JSON-RPC method handlers using the messenger and hooks. + * + * @param options The options. + * @param options.handlers - The JSON-RPC method handler implementations. + * @param options.messenger - The messenger to be used by the handlers. + * @param options.hooks - The hooks to be used by the handlers. + * @returns A JsonRpcEngineV2 middleware. + */ +export function createMethodMiddleware< + Handlers extends Record, + Context extends ContextConstraint, +>( + options: CreateMethodMiddlewareOptions, +): JsonRpcMiddleware { + const { messenger: rootMessenger } = options; + const allHooks = options.hooks as Record; + + const expectedHookNames = new Set( + Object.values(options.handlers).flatMap((handler) => + handler.hookNames ? Object.getOwnPropertyNames(handler.hookNames) : [], + ), + ); + assertExpectedHooks(allHooks, expectedHookNames); + + const handlers = Object.entries(options.handlers).reduce< + Record + >((accumulator, [handlerName, handler]) => { + const handlerHooks = selectHooks(allHooks, handler.hookNames) ?? {}; + const handlerMessenger = createHandlerMessenger< + HandlerActions + >({ + namespace: handlerName, + actionNames: handler.actionNames as + | readonly HandlerActions['type'][] + | undefined, + rootMessenger, + }); + + accumulator[handlerName] = { + implementation: handler.implementation, + hooks: handlerHooks, + messenger: handlerMessenger, + }; + return accumulator; + }, {}); + + return ({ request, context, next }) => { + const handler = handlers[request.method]; + if (handler === undefined) { + return next(); + } + + const { implementation, hooks, messenger } = handler; + + return implementation({ request, context, next, hooks, messenger }); + }; +} diff --git a/packages/json-rpc-engine/src/v2/createOriginMiddleware.test.ts b/packages/json-rpc-engine/src/v2/createOriginMiddleware.test.ts new file mode 100644 index 00000000000..18f9b97aee4 --- /dev/null +++ b/packages/json-rpc-engine/src/v2/createOriginMiddleware.test.ts @@ -0,0 +1,19 @@ +import { makeRequest } from '../../tests/utils.js'; +import { createOriginMiddleware } from './createOriginMiddleware.js'; +import { JsonRpcEngineV2 } from './JsonRpcEngineV2.js'; + +describe('createOriginMiddleware', () => { + it('sets the origin on the context object', async () => { + const origin = 'https://metamask.io'; + const middleware = createOriginMiddleware(origin); + + const engine = JsonRpcEngineV2.create({ + middleware: [ + middleware, + ({ context }): string => context.assertGet('origin'), + ], + }); + + expect(await engine.handle(makeRequest())).toBe(origin); + }); +}); diff --git a/packages/json-rpc-engine/src/v2/createOriginMiddleware.ts b/packages/json-rpc-engine/src/v2/createOriginMiddleware.ts new file mode 100644 index 00000000000..4a2ff9856ba --- /dev/null +++ b/packages/json-rpc-engine/src/v2/createOriginMiddleware.ts @@ -0,0 +1,18 @@ +import { JsonRpcMiddleware } from './JsonRpcEngineV2.js'; +import { MiddlewareContext } from './MiddlewareContext.js'; +import { Json, JsonRpcRequest } from './utils.js'; + +/** + * Create a middleware function that adds `origin` to the middleware context. + * + * @param origin - The origin. + * @returns The middleware. + */ +export function createOriginMiddleware< + Context extends MiddlewareContext<{ origin: string }>, +>(origin: string): JsonRpcMiddleware { + return ({ context, next }) => { + context.set('origin', origin); + return next(); + }; +} diff --git a/packages/json-rpc-engine/src/v2/createScaffoldMiddleware.test.ts b/packages/json-rpc-engine/src/v2/createScaffoldMiddleware.test.ts new file mode 100644 index 00000000000..bacc464f132 --- /dev/null +++ b/packages/json-rpc-engine/src/v2/createScaffoldMiddleware.test.ts @@ -0,0 +1,40 @@ +import { rpcErrors } from '@metamask/rpc-errors'; + +import { makeRequest } from '../../tests/utils.js'; +import type { MiddlewareScaffold } from './createScaffoldMiddleware.js'; +import { createScaffoldMiddleware } from './createScaffoldMiddleware.js'; +import { JsonRpcEngineV2 } from './JsonRpcEngineV2.js'; + +describe('createScaffoldMiddleware', () => { + it('basic middleware test', async () => { + const scaffold: MiddlewareScaffold = { + method1: 'foo', + method2: () => 42, + method3: () => { + throw rpcErrors.internal({ message: 'method3' }); + }, + }; + + const engine = JsonRpcEngineV2.create({ + middleware: [ + createScaffoldMiddleware(scaffold), + (): string => 'passthrough', + ], + }); + + const result1 = await engine.handle(makeRequest({ method: 'method1' })); + const result2 = await engine.handle(makeRequest({ method: 'method2' })); + const promise3 = engine.handle(makeRequest({ method: 'method3' })); + const result4 = await engine.handle(makeRequest({ method: 'unknown' })); + + expect(result1).toBe('foo'); + + expect(result2).toBe(42); + + await expect(promise3).rejects.toThrow( + rpcErrors.internal({ message: 'method3' }), + ); + + expect(result4).toBe('passthrough'); + }); +}); diff --git a/packages/json-rpc-engine/src/v2/createScaffoldMiddleware.ts b/packages/json-rpc-engine/src/v2/createScaffoldMiddleware.ts new file mode 100644 index 00000000000..7d145ffe4bf --- /dev/null +++ b/packages/json-rpc-engine/src/v2/createScaffoldMiddleware.ts @@ -0,0 +1,56 @@ +import type { Json, JsonRpcParams, JsonRpcRequest } from '@metamask/utils'; + +import type { JsonRpcMiddleware } from './JsonRpcEngineV2.js'; +import type { + ContextConstraint, + MiddlewareContext, +} from './MiddlewareContext.js'; + +// Only permit primitive values as hard-coded scaffold middleware results. +type JsonPrimitive = string | number | boolean | null; + +/** + * A handler for a scaffold middleware function. + * + * @template Params - The parameters of the request. + * @template Result - The result of the request. + * @template Context - The context of the request. + * @returns A JSON-RPC middleware function or a primitive JSON value. + */ +export type ScaffoldMiddlewareHandler< + Params extends JsonRpcParams, + Result extends Json, + Context extends ContextConstraint, +> = JsonRpcMiddleware, Result, Context> | JsonPrimitive; + +/** + * A record of RPC method handler functions or hard-coded results, keyed to particular method names. + * Only primitive JSON values are permitted as hard-coded results. + */ +export type MiddlewareScaffold< + Context extends ContextConstraint = MiddlewareContext, +> = Record>; + +/** + * Creates a middleware function from an object of RPC method handler functions, + * keyed to particular method names. If a method corresponding to a key of this + * object is requested, this middleware will pass it to the corresponding + * handler and return the result. + * + * @param handlers - The RPC method handler functions. + * @returns The scaffold middleware function. + */ +export function createScaffoldMiddleware( + handlers: MiddlewareScaffold, +): JsonRpcMiddleware { + return ({ request, context, next }) => { + const handlerOrResult = handlers[request.method]; + if (handlerOrResult === undefined) { + return next(); + } + + return typeof handlerOrResult === 'function' + ? handlerOrResult({ request, context, next }) + : handlerOrResult; + }; +} diff --git a/packages/json-rpc-engine/src/v2/index.test.ts b/packages/json-rpc-engine/src/v2/index.test.ts new file mode 100644 index 00000000000..595b71213e1 --- /dev/null +++ b/packages/json-rpc-engine/src/v2/index.test.ts @@ -0,0 +1,23 @@ +import * as allExports from './index.js'; + +describe('@metamask/json-rpc-engine/v2', () => { + it('has expected JavaScript exports', () => { + expect(Object.keys(allExports).sort()).toMatchInlineSnapshot(` + [ + "JsonRpcEngineError", + "JsonRpcEngineV2", + "JsonRpcServer", + "MiddlewareContext", + "asLegacyMiddleware", + "assertExpectedHooks", + "createMethodMiddleware", + "createOriginMiddleware", + "createScaffoldMiddleware", + "getUniqueId", + "isNotification", + "isRequest", + "selectHooks", + ] + `); + }); +}); diff --git a/packages/json-rpc-engine/src/v2/index.ts b/packages/json-rpc-engine/src/v2/index.ts new file mode 100644 index 00000000000..8ae58ba4415 --- /dev/null +++ b/packages/json-rpc-engine/src/v2/index.ts @@ -0,0 +1,35 @@ +export { asLegacyMiddleware } from './asLegacyMiddleware.js'; +export { getUniqueId } from '../getUniqueId.js'; +export { createMethodMiddleware } from './createMethodMiddleware.js'; +export type { MethodHandler } from './createMethodMiddleware.js'; +export { createOriginMiddleware } from './createOriginMiddleware.js'; +export { createScaffoldMiddleware } from './createScaffoldMiddleware.js'; +export { JsonRpcEngineV2 } from './JsonRpcEngineV2.js'; +export type { + JsonRpcMiddleware, + HandleOptions, + MergedContextOf, + MiddlewareParams, + MiddlewareConstraint, + Next, + RequestOf, + ResultConstraint, +} from './JsonRpcEngineV2.js'; +export { JsonRpcServer } from './JsonRpcServer.js'; +export { MiddlewareContext } from './MiddlewareContext.js'; +export type { EmptyContext, ContextConstraint } from './MiddlewareContext.js'; +export { + isNotification, + isRequest, + JsonRpcEngineError, + selectHooks, + assertExpectedHooks, +} from './utils.js'; +export type { + Json, + JsonRpcCall, + JsonRpcNotification, + JsonRpcParams, + JsonRpcRequest, + UnionToIntersection, +} from './utils.js'; diff --git a/packages/json-rpc-engine/src/v2/utils.test.ts b/packages/json-rpc-engine/src/v2/utils.test.ts new file mode 100644 index 00000000000..d86987d82a3 --- /dev/null +++ b/packages/json-rpc-engine/src/v2/utils.test.ts @@ -0,0 +1,100 @@ +import { + isRequest, + isNotification, + stringify, + JsonRpcEngineError, + isInstance, +} from './utils.js'; + +const jsonrpc = '2.0' as const; + +describe('utils', () => { + describe('isRequest', () => { + it.each([ + [ + { + jsonrpc, + id: 1, + method: 'eth_getBlockByNumber', + params: ['latest'], + }, + true, + ], + [ + { + jsonrpc, + method: 'eth_getBlockByNumber', + params: ['latest'], + }, + false, + ], + ])('returns $expected for $request', (request, expected) => { + expect(isRequest(request)).toBe(expected); + }); + }); + + describe('isNotification', () => { + it.each([ + [{ jsonrpc, method: 'eth_getBlockByNumber', params: ['latest'] }, true], + [ + { id: 1, jsonrpc, method: 'eth_getBlockByNumber', params: ['latest'] }, + false, + ], + ])('returns $expected for $request', (request, expected) => { + expect(isNotification(request)).toBe(expected); + }); + }); + + describe('stringify', () => { + it('stringifies a JSON object', () => { + expect(stringify({ foo: 'bar' })).toMatchInlineSnapshot(` + "{ + "foo": "bar" + }" + `); + }); + }); + + describe('isInstance', () => { + const TestClassSymbol = Symbol('TestClass'); + + class TestClass { + // This is a computed property name, and it doesn't seem possible to make + // it hash private using `#`. + // eslint-disable-next-line no-restricted-syntax + private readonly [TestClassSymbol] = true; + } + + it('identifies class instances via the symbol property', () => { + const value = new TestClass(); + expect(isInstance(value, TestClassSymbol)).toBe(true); + }); + + it('identifies plain objects via the symbol property', () => { + const value = { [TestClassSymbol]: true }; + expect(isInstance(value, TestClassSymbol)).toBe(true); + }); + + it('identifies sub-classes of the class via the symbol property', () => { + class SubClass extends TestClass {} + const value = new SubClass(); + expect(isInstance(value, TestClassSymbol)).toBe(true); + }); + }); + + describe('JsonRpcEngineError', () => { + it('creates an error with the correct name', () => { + const error = new JsonRpcEngineError('test'); + expect(error).toBeInstanceOf(Error); + expect(error.name).toBe('JsonRpcEngineError'); + expect(error.message).toBe('test'); + }); + + it('identifies JsonRpcEngineError instances via isInstance', () => { + const error = new JsonRpcEngineError('test'); + + expect(JsonRpcEngineError.isInstance(error)).toBe(true); + expect(JsonRpcEngineError.isInstance(new Error('test'))).toBe(false); + }); + }); +}); diff --git a/packages/json-rpc-engine/src/v2/utils.ts b/packages/json-rpc-engine/src/v2/utils.ts new file mode 100644 index 00000000000..5cc38239040 --- /dev/null +++ b/packages/json-rpc-engine/src/v2/utils.ts @@ -0,0 +1,198 @@ +import type { ActionConstraint } from '@metamask/messenger'; +import { Messenger } from '@metamask/messenger'; +import { hasProperty, isObject } from '@metamask/utils'; +import type { + JsonRpcNotification, + JsonRpcParams, + JsonRpcRequest, +} from '@metamask/utils'; + +export type { + Json, + JsonRpcParams, + JsonRpcRequest, + JsonRpcNotification, +} from '@metamask/utils'; + +export type JsonRpcCall = + | JsonRpcNotification + | JsonRpcRequest; + +export const isRequest = ( + message: JsonRpcCall | Readonly>, +): message is JsonRpcRequest => hasProperty(message, 'id'); + +export const isNotification = ( + message: JsonRpcCall, +): message is JsonRpcNotification => !isRequest(message); + +/** + * An unholy incantation that converts a union of object types into an + * intersection of object types. + * + * @example + * type A = { a: string } | { b: number }; + * type B = UnionToIntersection; // { a: string } & { b: number } + */ +export type UnionToIntersection = ( + Union extends never ? never : (k: Union) => void +) extends (k: infer Args) => void + ? Args + : never; + +/** + * JSON-stringifies a value. + * + * @param value - The value to stringify. + * @returns The stringified value. + */ +export function stringify(value: unknown): string { + return JSON.stringify(value, null, 2); +} + +/** + * The implementation of static `isInstance` methods for classes that have them. + * + * @param value - The value to check. + * @param symbol - The symbol property to check for. + * @returns Whether the value has `{ [symbol]: true }` in its prototype chain. + */ +export const isInstance = ( + value: unknown, + symbol: symbol, +): value is { [key: symbol]: true } => + isObject(value) && symbol in value && value[symbol] === true; + +const JsonRpcEngineErrorSymbol = Symbol.for( + 'json-rpc-engine#JsonRpcEngineError', +); + +export class JsonRpcEngineError extends Error { + // This is a computed property name, and it doesn't seem possible to make it + // hash private using `#`. + // eslint-disable-next-line no-restricted-syntax + private readonly [JsonRpcEngineErrorSymbol] = true; + + constructor(message: string) { + super(message); + this.name = 'JsonRpcEngineError'; + } + + /** + * Check if a value is a {@link JsonRpcEngineError} instance. + * Works across different package versions in the same realm. + * + * @param value - The value to check. + * @returns Whether the value is a {@link JsonRpcEngineError} instance. + */ + static isInstance(value: unknown): value is JsonRpcEngineError { + return isInstance(value, JsonRpcEngineErrorSymbol); + } +} + +// Method middleware utils + +/** + * Returns the subset of the specified `hooks` that are included in the + * `hookNames` object. This is a Principle of Least Authority (POLA) measure + * to ensure that each RPC method implementation only has access to the + * API "hooks" it needs to do its job. + * + * @param hooks - The hooks to select from. + * @param hookNames - The names of the hooks to select. + * @returns The selected hooks, or `undefined` if `hookNames` is not provided. + * @template Hooks - The hooks to select from. + * @template HookName - The names of the hooks to select. + */ +export function selectHooks( + hooks: Hooks, + hookNames?: Record, +): Pick | undefined { + if (hookNames) { + return Object.keys(hookNames).reduce>>( + (subset, name) => { + const hookName = name as HookName; + subset[hookName] = hooks[hookName]; + return subset; + }, + {}, + ) as Pick; + } + return undefined; +} + +/** + * Asserts that `hooks` contains exactly the hook names in `expectedHookNames`. + * Throws on any missing hooks, then on any extraneous hooks. + * + * @param hooks - The hooks object to validate. + * @param expectedHookNames - The expected hook names. + */ +export function assertExpectedHooks( + hooks: Record, + expectedHookNames: Set, +): void { + const missingHookNames = Array.from(expectedHookNames).filter( + (hookName) => !hasProperty(hooks, hookName), + ); + if (missingHookNames.length > 0) { + throw new Error( + `Missing expected hooks:\n\n${missingHookNames.join('\n')}\n`, + ); + } + + const extraneousHookNames = Object.getOwnPropertyNames(hooks).filter( + (hookName) => !expectedHookNames.has(hookName), + ); + if (extraneousHookNames.length > 0) { + throw new Error( + `Received unexpected hooks:\n\n${extraneousHookNames.join('\n')}\n`, + ); + } +} + +/** + * Creates a per-handler messenger namespaced to `namespace`, and delegates the + * specified `actionNames` from `rootMessenger` to it. This lets each handler + * call only the actions it declared, per POLA. + * + * @param options - The options. + * @param options.namespace - The namespace for the handler messenger. + * @param options.actionNames - Actions to delegate from the root messenger. + * @param options.rootMessenger - The root messenger to delegate from. Required + * when `actionNames` are provided. + * @returns The per-handler messenger. + */ +export function createHandlerMessenger({ + namespace, + actionNames, + rootMessenger, +}: { + namespace: string; + actionNames: readonly Actions['type'][] | undefined; + rootMessenger?: Messenger | undefined; +}): Messenger | undefined { + if (!actionNames) { + return undefined; + } + + if (!rootMessenger) { + throw new Error( + 'A messenger is required when a handler declares actionNames.', + ); + } + + const handlerMessenger = new Messenger< + string, + Actions, + never, + typeof rootMessenger + >({ namespace, parent: rootMessenger }); + + rootMessenger.delegate({ + actions: actionNames as Actions['type'][], + messenger: handlerMessenger, + }); + + return handlerMessenger; +} diff --git a/packages/json-rpc-engine/tests/utils.ts b/packages/json-rpc-engine/tests/utils.ts new file mode 100644 index 00000000000..40a6a5b0370 --- /dev/null +++ b/packages/json-rpc-engine/tests/utils.ts @@ -0,0 +1,70 @@ +import type { JsonRpcRequest } from '@metamask/utils'; +import type { JsonRpcMiddleware } from 'src/v2/JsonRpcEngineV2'; + +import { requestProps } from '../src/v2/compatibility-utils.js'; +import type { JsonRpcNotification } from '../src/v2/utils.js'; + +const jsonrpc = '2.0' as const; + +export const makeRequest = ( + request: Partial = {}, +): Request => + ({ + jsonrpc, + id: request.id ?? '1', + method: request.method ?? 'test_request', + + params: request.params ?? [], + ...request, + }) as Request; + +export const makeNotification = >( + params: Request = {} as Request, +): JsonRpcNotification => + ({ + jsonrpc, + method: 'test_request', + params: [], + ...params, + }) as JsonRpcNotification; + +/** + * Creates a {@link JsonRpcCall} middleware that returns `null`. + * + * @returns The middleware. + */ +export const makeNullMiddleware = (): JsonRpcMiddleware => { + return () => null; +}; + +/** + * Creates a {@link JsonRpcRequest} middleware that returns `null`. + * + * @returns The middleware. + */ +export const makeRequestMiddleware = (): JsonRpcMiddleware => { + return () => null; +}; + +/** + * Creates a {@link JsonRpcNotification} middleware that returns `undefined`. + * + * @returns The middleware. + */ +export const makeNotificationMiddleware = + (): JsonRpcMiddleware => { + return () => undefined; + }; + +/** + * Get the keys of a request that are not part of the standard JSON-RPC request + * properties. + * + * @param req - The request to get the extraneous keys from. + * @returns The extraneous keys. + */ +export function getExtraneousKeys(req: Record): string[] { + return Object.keys(req).filter( + (key) => !requestProps.find((requestProp) => requestProp === key), + ); +} diff --git a/packages/json-rpc-engine/tsconfig.build.json b/packages/json-rpc-engine/tsconfig.build.json new file mode 100644 index 00000000000..6b68c1d0498 --- /dev/null +++ b/packages/json-rpc-engine/tsconfig.build.json @@ -0,0 +1,14 @@ +{ + "extends": "../../tsconfig.packages.build.json", + "compilerOptions": { + "baseUrl": "./", + "outDir": "./dist", + "rootDir": "./src" + }, + "references": [ + { + "path": "../messenger/tsconfig.build.json" + } + ], + "include": ["../../types", "./src"] +} diff --git a/packages/json-rpc-engine/tsconfig.json b/packages/json-rpc-engine/tsconfig.json new file mode 100644 index 00000000000..7980c8c630a --- /dev/null +++ b/packages/json-rpc-engine/tsconfig.json @@ -0,0 +1,18 @@ +{ + "extends": "../../tsconfig.packages.json", + "compilerOptions": { + "baseUrl": "./", + "rootDir": "../..", + "exactOptionalPropertyTypes": true, + "forceConsistentCasingInFileNames": true, + "noErrorTruncation": true, + "noUncheckedIndexedAccess": true, + "target": "es2020" + }, + "references": [ + { + "path": "../messenger" + } + ], + "include": ["../../types", "../../tests", "./src", "./tests"] +} diff --git a/packages/json-rpc-engine/typedoc.json b/packages/json-rpc-engine/typedoc.json new file mode 100644 index 00000000000..c9da015dbf8 --- /dev/null +++ b/packages/json-rpc-engine/typedoc.json @@ -0,0 +1,7 @@ +{ + "entryPoints": ["./src/index.ts"], + "excludePrivate": true, + "hideGenerator": true, + "out": "docs", + "tsconfig": "./tsconfig.build.json" +} diff --git a/packages/json-rpc-engine/v2.js b/packages/json-rpc-engine/v2.js new file mode 100644 index 00000000000..faf7abd236a --- /dev/null +++ b/packages/json-rpc-engine/v2.js @@ -0,0 +1,3 @@ +// Re-exported for compatibility with Browserify. +// eslint-disable-next-line +module.exports = require('./dist/v2/index.cjs'); diff --git a/packages/json-rpc-middleware-stream/CHANGELOG.md b/packages/json-rpc-middleware-stream/CHANGELOG.md new file mode 100644 index 00000000000..de2ac1cda02 --- /dev/null +++ b/packages/json-rpc-middleware-stream/CHANGELOG.md @@ -0,0 +1,236 @@ +# 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] + +### Changed + +- Bump `@metamask/utils` from `^11.8.1` to `^11.11.0` ([#7511](https://github.com/MetaMask/core/pull/7511), [#9074](https://github.com/MetaMask/core/pull/9074)) +- Bump `@metamask/json-rpc-engine` from `^10.1.1` to `^10.5.0` ([#7202](https://github.com/MetaMask/core/pull/7202), [#7642](https://github.com/MetaMask/core/pull/7642), [#7856](https://github.com/MetaMask/core/pull/7856), [#8078](https://github.com/MetaMask/core/pull/8078), [#8317](https://github.com/MetaMask/core/pull/8317), [#8661](https://github.com/MetaMask/core/pull/8661), [#8746](https://github.com/MetaMask/core/pull/8746), [#8753](https://github.com/MetaMask/core/pull/8753)) + +## [8.0.8] + +### Changed + +- Bump `@metamask/utils` from `^11.2.0` to `^11.8.1` ([#6054](https://github.com/MetaMask/core/pull/6054), [#6588](https://github.com/MetaMask/core/pull/6588), [#6708](https://github.com/MetaMask/core/pull/6708)) +- Bump `@metamask/json-rpc-engine` from `^10.0.3` to `^10.1.1` ([#6678](https://github.com/MetaMask/core/pull/6678), [#6807](https://github.com/MetaMask/core/pull/6807)) + +## [8.0.7] + +### Changed + +- Bump `@metamask/json-rpc-engine` from `^10.0.2` to `^10.0.3` ([#5272](https://github.com/MetaMask/core/pull/5272)) +- Bump `@metamask/utils` from `^11.0.1` to `^11.1.0` ([#5223](https://github.com/MetaMask/core/pull/5223)) + +## [8.0.6] + +### Changed + +- Bump `@metamask/json-rpc-engine` from `^10.0.1` to `^10.0.2` ([#5082](https://github.com/MetaMask/core/pull/5082)) +- Bump `@metamask/utils` from `^10.0.0` to `^11.0.1` ([#5080](https://github.com/MetaMask/core/pull/5080)) + +## [8.0.5] + +### Changed + +- Bump `@metamask/utils` from `^9.1.0` to `^10.0.0` ([#4831](https://github.com/MetaMask/core/pull/4831)) + +## [8.0.4] + +### Fixed + +- Bump `@metamask/json-rpc-engine` to `^10.0.0` ([#4798](https://github.com/MetaMask/core/pull/4798)) + +## [8.0.3] + +### Changed + +- Bump TypeScript from `~5.0.4` to `~5.2.2` ([#4576](https://github.com/MetaMask/core/pull/4576), [#4584](https://github.com/MetaMask/core/pull/4584)) + +### Fixed + +- Produce and export ESM-compatible TypeScript type declaration files in addition to CommonJS-compatible declaration files ([#4648](https://github.com/MetaMask/core/pull/4648)) + - Previously, this package shipped with only one variant of type declaration + files, and these files were only CommonJS-compatible, and the `exports` + field in `package.json` linked to these files. This is an anti-pattern and + was rightfully flagged by the + ["Are the Types Wrong?"](https://arethetypeswrong.github.io/) tool as + ["masquerading as CJS"](https://github.com/arethetypeswrong/arethetypeswrong.github.io/blob/main/docs/problems/FalseCJS.md). + All of the ATTW checks now pass. +- Remove chunk files ([#4648](https://github.com/MetaMask/core/pull/4648)). + - Previously, the build tool we used to generate JavaScript files extracted + common code to "chunk" files. While this was intended to make this package + more tree-shakeable, it also made debugging more difficult for our + development teams. These chunk files are no longer present. + +## [8.0.2] + +### Changed + +- Bump TypeScript version to `~5.0.4` and set `moduleResolution` option to `Node16` ([#3645](https://github.com/MetaMask/core/pull/3645)) +- Bump `@metamask/utils` from `^9.0.0` to `^9.1.0` ([#4529](https://github.com/MetaMask/core/pull/4529)) + +## [8.0.1] + +### Changed + +- Bump `@metamask/json-rpc-engine` to `^9.0.1` ([#4517](https://github.com/MetaMask/core/pull/4517)) +- Bump `@metamask/rpc-errors` to `^6.3.1` ([#4516](https://github.com/MetaMask/core/pull/4516)) +- Bump `@metamask/utils` to `^9.0.0` ([#4516](https://github.com/MetaMask/core/pull/4516)) + +### Fixed + +- Fix incorrect notification detection logic ([#4427](https://github.com/MetaMask/core/pull/4427)) + - Previously, response objects with a falsy `id` property were detected as notifications. Now, we check for the absence of the `id` property. + +## [8.0.0] + +### Changed + +- **BREAKING:** Bump minimum Node version to 18.18 ([#3611](https://github.com/MetaMask/core/pull/3611)) +- Bump `@metamask/json-rpc-engine` to `^9.0.0` ([#4352](https://github.com/MetaMask/core/pull/4352)) + +## [7.0.2] + +### Changed + +- Bump `@metamask/json-rpc-engine` to `^8.0.2` ([#4234](https://github.com/MetaMask/core/pull/4234)) + +## [7.0.1] + +### Fixed + +- Fix `types` field in `package.json` ([#4047](https://github.com/MetaMask/core/pull/4047)) + +## [7.0.0] + +### Added + +- **BREAKING**: Add ESM build ([#3998](https://github.com/MetaMask/core/pull/3998)) + - It's no longer possible to import files from `./dist` directly. + +### Changed + +- Bump `@metamask/json-rpc-engine` to `^8.0.0` ([#4039](https://github.com/MetaMask/core/pull/4039)) + +## [6.0.2] + +### Changed + +- Bump `@metamask/utils` to `^8.3.0` ([#3769](https://github.com/MetaMask/core/pull/3769)) +- Bump `@metamask/json-rpc-engine` to `^7.3.2` ([#3821](https://github.com/MetaMask/core/pull/3821)) + +## [6.0.1] + +### Changed + +- Bump `@metamask/json-rpc-engine` to `^7.3.1` ([#3695](https://github.com/MetaMask/core/pull/3695)) + +## [6.0.0] + +### Added + +- Migrate `@metamask/json-rpc-engine` into the core monorepo ([#1762](https://github.com/MetaMask/core/pull/1762)) + +## Changed + +- **BREAKING**: Rename package from `json-rpc-middleware-stream` to `@metamask/json-rpc-middleware-stream` ([#1762](https://github.com/MetaMask/core/pull/1762)) +- Bump `@metamask/json-rpc-engine` from `^7.1.0` to `^7.2.0` ([#1762](https://github.com/MetaMask/core/pull/1762)) +- Bump `@metamask/utils` from `^8.1.0` to `^8.2.0` ([#1762](https://github.com/MetaMask/core/pull/1762)) + +## [5.0.1] + +### Changed + +- Upgrade typescript version to 4.8.4 ([#68](https://github.com/MetaMask/json-rpc-middleware-stream/pull/68)) + +## [5.0.0] + +### Changed + +- **BREAKING**: Increase minimum Node.js version to 16 ([#59](https://github.com/MetaMask/json-rpc-middleware-stream/pull/59)) +- **BREAKING**: Update `readable-stream` from `^2.3.3` to `^3.6.2` ([#55](https://github.com/MetaMask/json-rpc-middleware-stream/pull/55)) +- **BREAKING**: Switch from legacy `json-rpc-engine`@`^6.1.0` to `@metamask/json-rpc-engine`@`^7.1.1` ([#54](https://github.com/MetaMask/json-rpc-middleware-stream/pull/54)) +- Add dependency `@metamask/utils` ([#54](https://github.com/MetaMask/json-rpc-middleware-stream/pull/54)) + +## [4.2.3] + +### Fixed + +- Moved json-rpc-engine from devDependencies to dependencies ([#56](https://github.com/MetaMask/json-rpc-middleware-stream/pull/56)) + +## [4.2.2] + +### Changed + +- Bump @metamask/safe-event-emitter from 2.0.0 to 3.0.0 ([#44](https://github.com/MetaMask/json-rpc-middleware-stream/pull/44)) + +### Fixed + +- Fix race condition in `createStreamMiddleware` ([#47](https://github.com/MetaMask/json-rpc-middleware-stream/pull/47)) + - Previously this middleware would fail to process synchronous responses on initialized streams + +## [4.2.1] + +### Fixed + +- Add early return in createStreamMiddleware.processsResponse method if JSON RPC request is not found ([#35](https://github.com/MetaMask/json-rpc-middleware-stream/pull/35)) + +## [4.2.0] + +### Changed + +- Change error throw when response is seen for unknown request into warning displayed in console ([#32](https://github.com/MetaMask/json-rpc-middleware-stream/pull/32)) + +## [4.1.0] + +### Changed + +- Added retry limit of 3 to requests ([#30](https://github.com/MetaMask/json-rpc-middleware-stream/pull/30)) + +## [4.0.0] - 2022-10-03 + +### Changed + +- BREAKING: Add Node 12 as minimum required version [#15](https://github.com/MetaMask/json-rpc-middleware-stream/pull/15) +- Retry pending requests when notification to reconnect is received ([#27](https://github.com/MetaMask/json-rpc-middleware-stream/pull/27)) + +### Security + +- Add `@lavamoat/allow-scripts` to make dependency install scripts opt-in ([#25](https://github.com/MetaMask/json-rpc-middleware-stream/pull/25)) + +## [3.0.0] - 2020-12-08 + +### Added + +- TypeScript typings ([#11](https://github.com/MetaMask/json-rpc-middleware-stream/pull/11)) + +[Unreleased]: https://github.com/MetaMask/core/compare/@metamask/json-rpc-middleware-stream@8.0.8...HEAD +[8.0.8]: https://github.com/MetaMask/core/compare/@metamask/json-rpc-middleware-stream@8.0.7...@metamask/json-rpc-middleware-stream@8.0.8 +[8.0.7]: https://github.com/MetaMask/core/compare/@metamask/json-rpc-middleware-stream@8.0.6...@metamask/json-rpc-middleware-stream@8.0.7 +[8.0.6]: https://github.com/MetaMask/core/compare/@metamask/json-rpc-middleware-stream@8.0.5...@metamask/json-rpc-middleware-stream@8.0.6 +[8.0.5]: https://github.com/MetaMask/core/compare/@metamask/json-rpc-middleware-stream@8.0.4...@metamask/json-rpc-middleware-stream@8.0.5 +[8.0.4]: https://github.com/MetaMask/core/compare/@metamask/json-rpc-middleware-stream@8.0.3...@metamask/json-rpc-middleware-stream@8.0.4 +[8.0.3]: https://github.com/MetaMask/core/compare/@metamask/json-rpc-middleware-stream@8.0.2...@metamask/json-rpc-middleware-stream@8.0.3 +[8.0.2]: https://github.com/MetaMask/core/compare/@metamask/json-rpc-middleware-stream@8.0.1...@metamask/json-rpc-middleware-stream@8.0.2 +[8.0.1]: https://github.com/MetaMask/core/compare/@metamask/json-rpc-middleware-stream@8.0.0...@metamask/json-rpc-middleware-stream@8.0.1 +[8.0.0]: https://github.com/MetaMask/core/compare/@metamask/json-rpc-middleware-stream@7.0.2...@metamask/json-rpc-middleware-stream@8.0.0 +[7.0.2]: https://github.com/MetaMask/core/compare/@metamask/json-rpc-middleware-stream@7.0.1...@metamask/json-rpc-middleware-stream@7.0.2 +[7.0.1]: https://github.com/MetaMask/core/compare/@metamask/json-rpc-middleware-stream@7.0.0...@metamask/json-rpc-middleware-stream@7.0.1 +[7.0.0]: https://github.com/MetaMask/core/compare/@metamask/json-rpc-middleware-stream@6.0.2...@metamask/json-rpc-middleware-stream@7.0.0 +[6.0.2]: https://github.com/MetaMask/core/compare/@metamask/json-rpc-middleware-stream@6.0.1...@metamask/json-rpc-middleware-stream@6.0.2 +[6.0.1]: https://github.com/MetaMask/core/compare/@metamask/json-rpc-middleware-stream@6.0.0...@metamask/json-rpc-middleware-stream@6.0.1 +[6.0.0]: https://github.com/MetaMask/core/compare/json-rpc-middleware-stream@5.0.1...@metamask/json-rpc-middleware-stream@6.0.0 +[5.0.1]: https://github.com/MetaMask/core/compare/json-rpc-middleware-stream@5.0.0...json-rpc-middleware-stream@5.0.1 +[5.0.0]: https://github.com/MetaMask/core/compare/json-rpc-middleware-stream@4.2.3...json-rpc-middleware-stream@5.0.0 +[4.2.3]: https://github.com/MetaMask/core/compare/json-rpc-middleware-stream@4.2.2...json-rpc-middleware-stream@4.2.3 +[4.2.2]: https://github.com/MetaMask/core/compare/json-rpc-middleware-stream@4.2.1...json-rpc-middleware-stream@4.2.2 +[4.2.1]: https://github.com/MetaMask/core/compare/json-rpc-middleware-stream@4.2.0...json-rpc-middleware-stream@4.2.1 +[4.2.0]: https://github.com/MetaMask/core/compare/json-rpc-middleware-stream@4.1.0...json-rpc-middleware-stream@4.2.0 +[4.1.0]: https://github.com/MetaMask/core/compare/json-rpc-middleware-stream@4.0.0...json-rpc-middleware-stream@4.1.0 +[4.0.0]: https://github.com/MetaMask/core/compare/json-rpc-middleware-stream@3.0.0...json-rpc-middleware-stream@4.0.0 +[3.0.0]: https://github.com/MetaMask/core/releases/tag/json-rpc-middleware-stream@3.0.0 diff --git a/packages/json-rpc-middleware-stream/LICENSE b/packages/json-rpc-middleware-stream/LICENSE new file mode 100644 index 00000000000..b5ed1b9c52f --- /dev/null +++ b/packages/json-rpc-middleware-stream/LICENSE @@ -0,0 +1,15 @@ +ISC License + +Copyright (c) 2020 MetaMask + +Permission to use, copy, modify, and/or distribute this software for any +purpose with or without fee is hereby granted, provided that the above +copyright notice and this permission notice appear in all copies. + +THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES +WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF +MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR +ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES +WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN +ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF +OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. diff --git a/packages/json-rpc-middleware-stream/README.md b/packages/json-rpc-middleware-stream/README.md new file mode 100644 index 00000000000..88c3f2f4157 --- /dev/null +++ b/packages/json-rpc-middleware-stream/README.md @@ -0,0 +1,15 @@ +# @metamask/json-rpc-middleware-stream + +A small toolset for streaming JSON RPC data and matching requests and responses. Made to be used with [`@metamask/json-rpc-engine`](https://npmjs.com/package/@metamask/json-rpc-engine). + +## Installation + +`yarn add @metamask/json-rpc-middleware-stream` + +or + +`npm install @metamask/json-rpc-middleware-stream` + +## 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/json-rpc-middleware-stream/jest.config.js b/packages/json-rpc-middleware-stream/jest.config.js new file mode 100644 index 00000000000..f245081436d --- /dev/null +++ b/packages/json-rpc-middleware-stream/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: 80, + functions: 92.85, + lines: 96.49, + statements: 96.49, + }, + }, +}); diff --git a/packages/json-rpc-middleware-stream/package.json b/packages/json-rpc-middleware-stream/package.json new file mode 100644 index 00000000000..97eeee728fe --- /dev/null +++ b/packages/json-rpc-middleware-stream/package.json @@ -0,0 +1,79 @@ +{ + "name": "@metamask/json-rpc-middleware-stream", + "version": "8.0.8", + "description": "A small toolset for streaming JSON-RPC data and matching requests and responses", + "keywords": [ + "Ethereum", + "MetaMask" + ], + "homepage": "https://github.com/MetaMask/core/tree/main/packages/json-rpc-middleware-stream#readme", + "bugs": { + "url": "https://github.com/MetaMask/core/issues" + }, + "license": "ISC", + "repository": { + "type": "git", + "url": "https://github.com/MetaMask/core.git" + }, + "files": [ + "dist/" + ], + "sideEffects": false, + "main": "./dist/index.cjs", + "types": "./dist/index.d.cts", + "exports": { + ".": { + "import": { + "types": "./dist/index.d.mts", + "default": "./dist/index.mjs" + }, + "require": { + "types": "./dist/index.d.cts", + "default": "./dist/index.cjs" + } + }, + "./package.json": "./package.json" + }, + "publishConfig": { + "access": "public", + "registry": "https://registry.npmjs.org/" + }, + "scripts": { + "build": "ts-bridge --project tsconfig.build.json --verbose --clean --no-references", + "build:all": "ts-bridge --project tsconfig.build.json --verbose --clean", + "build:docs": "typedoc", + "changelog:update": "../../scripts/update-changelog.sh @metamask/json-rpc-middleware-stream --tag-prefix-before-package-rename json-rpc-middleware-stream@ --version-before-package-rename 5.0.1", + "changelog:validate": "../../scripts/validate-changelog.sh @metamask/json-rpc-middleware-stream --tag-prefix-before-package-rename json-rpc-middleware-stream@ --version-before-package-rename 5.0.1", + "lint:tsconfigs": "tsx ../../scripts/lint-tsconfigs/lint-tsconfigs.mts", + "lint:tsconfigs:fix": "tsx ../../scripts/lint-tsconfigs/lint-tsconfigs.mts --fix", + "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/json-rpc-engine": "^10.5.0", + "@metamask/safe-event-emitter": "^3.0.0", + "@metamask/utils": "^11.11.0", + "readable-stream": "^3.6.2" + }, + "devDependencies": { + "@metamask/auto-changelog": "^6.1.0", + "@ts-bridge/cli": "^0.6.4", + "@types/jest": "^30.0.0", + "@types/readable-stream": "^2.3.0", + "deepmerge": "^4.2.2", + "extension-port-stream": "^3.0.0", + "jest": "^30.4.2", + "ts-jest": "^29.4.11", + "tsx": "^4.20.5", + "typedoc": "^0.25.13", + "typedoc-plugin-missing-exports": "^2.0.0", + "typescript": "~5.3.3", + "webextension-polyfill-ts": "^0.26.0" + }, + "engines": { + "node": "^18.18 || >=20" + } +} diff --git a/merged-packages/json-rpc-middleware-stream/src/createEngineStream.ts b/packages/json-rpc-middleware-stream/src/createEngineStream.ts similarity index 91% rename from merged-packages/json-rpc-middleware-stream/src/createEngineStream.ts rename to packages/json-rpc-middleware-stream/src/createEngineStream.ts index f2dc51f9d57..886fb547498 100644 --- a/merged-packages/json-rpc-middleware-stream/src/createEngineStream.ts +++ b/packages/json-rpc-middleware-stream/src/createEngineStream.ts @@ -19,7 +19,11 @@ export default function createEngineStream(opts: EngineStreamOptions): Duplex { } const { engine } = opts; - const stream = new Duplex({ objectMode: true, read: () => undefined, write }); + const stream = new Duplex({ + objectMode: true, + read: (): undefined => undefined, + write, + }); // forward notifications if (engine.on) { engine.on('notification', (message) => { @@ -39,7 +43,7 @@ export default function createEngineStream(opts: EngineStreamOptions): Duplex { req: JsonRpcRequest, _encoding: unknown, streamWriteCallback: (error?: Error | null) => void, - ) { + ): void { engine.handle(req, (_err, res) => { stream.push(res); }); diff --git a/merged-packages/json-rpc-middleware-stream/src/createStreamMiddleware.ts b/packages/json-rpc-middleware-stream/src/createStreamMiddleware.ts similarity index 85% rename from merged-packages/json-rpc-middleware-stream/src/createStreamMiddleware.ts rename to packages/json-rpc-middleware-stream/src/createStreamMiddleware.ts index f60d11c51e8..c9d0c55dcac 100644 --- a/merged-packages/json-rpc-middleware-stream/src/createStreamMiddleware.ts +++ b/packages/json-rpc-middleware-stream/src/createStreamMiddleware.ts @@ -4,6 +4,7 @@ import type { JsonRpcMiddleware, } from '@metamask/json-rpc-engine'; import SafeEventEmitter from '@metamask/safe-event-emitter'; +import { hasProperty } from '@metamask/utils'; import type { JsonRpcNotification, JsonRpcParams, @@ -37,11 +38,15 @@ type Options = { * @param options - Configuration options for middleware. * @returns The event emitter, middleware, and stream. */ -export default function createStreamMiddleware(options: Options = {}) { +export default function createStreamMiddleware(options: Options = {}): { + events: SafeEventEmitter; + middleware: JsonRpcMiddleware; + stream: _Readable.Duplex; +} { const idMap: IdMap = {}; // TODO: replace with actual Map const stream = new Duplex({ objectMode: true, - read: () => undefined, + read: (): undefined => undefined, write: processMessage, }); @@ -66,7 +71,7 @@ export default function createStreamMiddleware(options: Options = {}) { * * @param req - The JSON-RPC request object. */ - function sendToStream(req: JsonRpcRequest) { + function sendToStream(req: JsonRpcRequest): void { // TODO: limiting retries could be implemented here stream.push(req); } @@ -82,10 +87,10 @@ export default function createStreamMiddleware(options: Options = {}) { res: PendingJsonRpcResponse, _encoding: unknown, streamWriteCallback: (error?: Error | null) => void, - ) { + ): void { let errorObj: Error | null = null; try { - const isNotification = !res.id; + const isNotification = !hasProperty(res, 'id'); if (isNotification) { processNotification(res as unknown as JsonRpcNotification); } else { @@ -103,8 +108,12 @@ export default function createStreamMiddleware(options: Options = {}) { * * @param res - The response to process. */ - function processResponse(res: PendingJsonRpcResponse) { - const responseId = res.id as unknown as string; + function processResponse(res: PendingJsonRpcResponse): void { + const { id: responseId } = res; + if (responseId === null) { + return; + } + const context = idMap[responseId]; if (!context) { console.warn(`StreamMiddleware - Unknown response id "${responseId}"`); @@ -124,7 +133,7 @@ export default function createStreamMiddleware(options: Options = {}) { * * @param notif - The notification to process. */ - function processNotification(notif: JsonRpcNotification) { + function processNotification(notif: JsonRpcNotification): void { if (options?.retryOnMessage && notif.method === options.retryOnMessage) { retryStuckRequests(); } @@ -134,7 +143,7 @@ export default function createStreamMiddleware(options: Options = {}) { /** * Retry pending requests. */ - function retryStuckRequests() { + function retryStuckRequests(): void { Object.values(idMap).forEach(({ req, retryCount = 0 }) => { // Avoid retrying requests without an id - they cannot have matching responses so retry logic doesn't apply // Check for retry count below ensure that a request is not retried more than 3 times @@ -147,8 +156,10 @@ export default function createStreamMiddleware(options: Options = {}) { `StreamMiddleware - Retry limit exceeded for request id "${req.id}"`, ); } - - idMap[req.id].retryCount = retryCount + 1; + const idMapObject = idMap[req.id]; + if (idMapObject) { + idMapObject.retryCount = retryCount + 1; + } sendToStream(req); }); } diff --git a/packages/json-rpc-middleware-stream/src/index.test.ts b/packages/json-rpc-middleware-stream/src/index.test.ts new file mode 100644 index 00000000000..78f4b6fbc69 --- /dev/null +++ b/packages/json-rpc-middleware-stream/src/index.test.ts @@ -0,0 +1,283 @@ +import { JsonRpcEngine } from '@metamask/json-rpc-engine'; +import PortStream from 'extension-port-stream'; +import type { Duplex } from 'stream'; +import type { Runtime } from 'webextension-polyfill-ts'; + +import { createStreamMiddleware, createEngineStream } from './index.js'; + +const artificialDelay = async (time = 0): Promise => + new Promise((resolve) => setTimeout(resolve, time)); +const noop = function (_a: unknown): void { + // noop +}; + +const jsonrpc = '2.0' as const; + +describe('createStreamMiddleware', () => { + it('processes a request', async () => { + const jsonRpcConnection = createStreamMiddleware(); + const req = { id: 1, jsonrpc, method: 'test' }; + const initRes = { id: 1, jsonrpc }; + const res = { id: 1, jsonrpc, result: 'test' }; + + // listen for incoming requests + jsonRpcConnection.stream.on('data', (_req) => { + expect(req).toStrictEqual(_req); + jsonRpcConnection.stream.write(res); + }); + + // wait for the stream to be ready + await artificialDelay(); + + await new Promise((resolve, reject) => { + // run middleware, expect end fn to be called + jsonRpcConnection.middleware( + req, + initRes, + () => { + reject(new Error('should not call next')); + }, + (errorObj) => { + try { + // eslint-disable-next-line jest/no-restricted-matchers + expect(errorObj).toBeFalsy(); + expect(initRes).toStrictEqual(res); + } catch (error) { + return reject(error); + } + return resolve(); + }, + ); + }); + }); +}); + +describe('createEngineStream', () => { + it('processes a request', async () => { + const engine = new JsonRpcEngine(); + engine.push((_req, res, _next, end) => { + res.result = 'test'; + end(); + }); + + const stream = createEngineStream({ engine }); + const req = { id: 1, jsonrpc, method: 'test' }; + const res = { id: 1, jsonrpc, result: 'test' }; + + await new Promise((resolve, reject) => { + // listen for incoming requests + stream.on('data', (_res) => { + try { + expect(res).toStrictEqual(_res); + } catch (error) { + return reject(error); + } + return resolve(); + }); + + stream.on('error', (errorObj) => { + // We don't control the source of this error, so we can't guarantee it's + // an Error instance. + // eslint-disable-next-line @typescript-eslint/prefer-promise-reject-errors + reject(errorObj); + }); + + stream.write(req); + }); + }); + + it('throw error when engine stream options not available', async () => { + expect(() => { + // TODO: Replace `any` with type + // eslint-disable-next-line @typescript-eslint/no-explicit-any + createEngineStream({} as any); + }).toThrow('Missing engine parameter!'); + }); +}); + +describe('middleware and engine to stream', () => { + it('forwards messages between streams', async () => { + // create guest + const engineA = new JsonRpcEngine(); + const jsonRpcConnection = createStreamMiddleware(); + engineA.push(jsonRpcConnection.middleware); + + // create host + const engineB = new JsonRpcEngine(); + engineB.push((_req, res, _next, end) => { + res.result = 'test'; + end(); + }); + + // connect both + const clientSideStream = jsonRpcConnection.stream; + const hostSideStream = createEngineStream({ engine: engineB }); + clientSideStream.pipe(hostSideStream).pipe(clientSideStream); + + // request and expected result + const req = { id: 1, jsonrpc, method: 'test' }; + const res = { id: 1, jsonrpc, result: 'test' }; + + const response = await engineA.handle(req); + expect(response).toStrictEqual(res); + }); +}); + +const RECONNECTED = 'CONNECTED'; +describe('retry logic in middleware connected to a port', () => { + let engineA: JsonRpcEngine | undefined; + // TODO: Replace `any` with type + // eslint-disable-next-line @typescript-eslint/no-explicit-any + let messages: any[] = []; + // TODO: Replace `any` with type + // eslint-disable-next-line @typescript-eslint/no-explicit-any + let messageConsumer: any; + beforeEach(() => { + // create guest + engineA = new JsonRpcEngine(); + const jsonRpcConnection = createStreamMiddleware({ + retryOnMessage: RECONNECTED, + }); + engineA.push(jsonRpcConnection.middleware); + + // create port + messageConsumer = noop; + messages = []; + const extensionPort = { + onMessage: { + // TODO: Replace `any` with type + // eslint-disable-next-line @typescript-eslint/no-explicit-any + addListener: (messageCallback: any): void => { + messageConsumer = messageCallback; + }, + }, + onDisconnect: { + addListener: noop, + }, + // TODO: Replace `any` with type + // eslint-disable-next-line @typescript-eslint/no-explicit-any + postMessage(message: any): void { + messages.push(message); + }, + }; + + const connectionStream = new PortStream( + extensionPort as unknown as Runtime.Port, + ); + + // connect both + const clientSideStream = jsonRpcConnection.stream; + clientSideStream + .pipe(connectionStream as unknown as Duplex) + .pipe(clientSideStream); + }); + + it('retries requests on reconnect message', async () => { + // request and expected result + const req1 = { id: 1, jsonrpc, method: 'test' }; + const req2 = { id: 2, jsonrpc, method: 'test' }; + const res = { id: 1, jsonrpc, result: 'test' }; + + // Initially sent once + const responsePromise1 = engineA?.handle(req1); + // intentionally not awaited + // eslint-disable-next-line @typescript-eslint/no-floating-promises + engineA?.handle(req2); + await artificialDelay(); + + expect(messages).toHaveLength(2); + + // Reconnected, gets sent again + messageConsumer({ + method: RECONNECTED, + }); + await artificialDelay(); + + expect(messages).toHaveLength(4); + expect(messages[0]).toBe(messages[2]); + expect(messages[1]).toBe(messages[3]); + + messageConsumer(res); + + expect(await responsePromise1).toStrictEqual(res); + + // Handled messages don't get retried but unhandled still do + + messageConsumer({ + method: RECONNECTED, + }); + await artificialDelay(); + + expect(messages).toHaveLength(5); + }); + + it('throw error when requests are retried more than 3 times', async () => { + // request and expected result + const req = { id: 1, jsonrpc, method: 'test' }; + + // Initially sent once, message count at 1 + // intentionally not awaited + // eslint-disable-next-line @typescript-eslint/no-floating-promises + engineA?.handle(req); + await artificialDelay(); + expect(messages).toHaveLength(1); + + // Reconnected, gets sent again message count increased to 2 + messageConsumer({ + method: RECONNECTED, + }); + await artificialDelay(); + expect(messages).toHaveLength(2); + + // Reconnected, gets sent again message count increased to 3 + messageConsumer({ + method: RECONNECTED, + }); + await artificialDelay(); + expect(messages).toHaveLength(3); + + // Reconnected, gets sent again message count increased to 4 + messageConsumer({ + method: RECONNECTED, + }); + await artificialDelay(); + expect(messages).toHaveLength(4); + + // Reconnected, error is thrrown when trying to resend request more that 3 times + expect(() => { + messageConsumer({ + method: RECONNECTED, + }); + }).toThrow('StreamMiddleware - Retry limit exceeded for request id'); + }); + + it('does not throw error when response is received for request not in map', async () => { + const res = { id: 1, jsonrpc, result: 'test' }; + + messageConsumer(res); + + expect(() => { + messageConsumer(res); + messageConsumer(res); + }).not.toThrow(); + }); + + it('does not retry if the request has no id', async () => { + // request and expected result + const req = { id: undefined, jsonrpc, method: 'test' }; + + // Initially sent once, message count at 1 + // intentionally not awaited + // eslint-disable-next-line @typescript-eslint/no-floating-promises + engineA?.handle(req); + await artificialDelay(); + expect(messages).toHaveLength(1); + + // Reconnected, but request is not re-submitted + messageConsumer({ + method: RECONNECTED, + }); + await artificialDelay(); + expect(messages).toHaveLength(1); + }); +}); diff --git a/packages/json-rpc-middleware-stream/src/index.ts b/packages/json-rpc-middleware-stream/src/index.ts new file mode 100644 index 00000000000..2a824d162d5 --- /dev/null +++ b/packages/json-rpc-middleware-stream/src/index.ts @@ -0,0 +1,4 @@ +import createEngineStream from './createEngineStream.js'; +import createStreamMiddleware from './createStreamMiddleware.js'; + +export { createEngineStream, createStreamMiddleware }; diff --git a/packages/json-rpc-middleware-stream/tsconfig.build.json b/packages/json-rpc-middleware-stream/tsconfig.build.json new file mode 100644 index 00000000000..1c5f260cfdc --- /dev/null +++ b/packages/json-rpc-middleware-stream/tsconfig.build.json @@ -0,0 +1,10 @@ +{ + "extends": "../../tsconfig.packages.build.json", + "compilerOptions": { + "baseUrl": "./", + "outDir": "./dist", + "rootDir": "./src" + }, + "references": [{ "path": "../json-rpc-engine/tsconfig.build.json" }], + "include": ["../../types", "./src"] +} diff --git a/packages/json-rpc-middleware-stream/tsconfig.json b/packages/json-rpc-middleware-stream/tsconfig.json new file mode 100644 index 00000000000..e4cc192738c --- /dev/null +++ b/packages/json-rpc-middleware-stream/tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": "../../tsconfig.packages.json", + "compilerOptions": { + "baseUrl": "./", + "forceConsistentCasingInFileNames": true, + "target": "es2017" + }, + "references": [{ "path": "../json-rpc-engine" }], + "include": ["../../types", "./src"] +} diff --git a/packages/json-rpc-middleware-stream/typedoc.json b/packages/json-rpc-middleware-stream/typedoc.json new file mode 100644 index 00000000000..c9da015dbf8 --- /dev/null +++ b/packages/json-rpc-middleware-stream/typedoc.json @@ -0,0 +1,7 @@ +{ + "entryPoints": ["./src/index.ts"], + "excludePrivate": true, + "hideGenerator": true, + "out": "docs", + "tsconfig": "./tsconfig.build.json" +} diff --git a/packages/keyring-controller/CHANGELOG.md b/packages/keyring-controller/CHANGELOG.md index 1fb5cfbaaa4..aa37a74cbc6 100644 --- a/packages/keyring-controller/CHANGELOG.md +++ b/packages/keyring-controller/CHANGELOG.md @@ -1,4 +1,5 @@ # 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/), @@ -6,55 +7,865 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Changed + +- Bump `@metamask/eth-sig-util` from `^8.2.0` to `^9.0.0` ([#9999](https://github.com/MetaMask/core/pull/9999)) + +## [27.1.1] + +### Changed + +- Bump `@metamask/keyring-api` from `^23.1.0` to `^24.0.0` ([#9249](https://github.com/MetaMask/core/pull/9249), [#9390](https://github.com/MetaMask/core/pull/9390), [#9676](https://github.com/MetaMask/core/pull/9676), [#9754](https://github.com/MetaMask/core/pull/9754)) +- Bump `@metamask/messenger` from `^1.2.0` to `^2.0.0` ([#9392](https://github.com/MetaMask/core/pull/9392)) +- Add missing dependency `@metamask/controller-utils` (`^12.3.0`) ([#8384](https://github.com/MetaMask/core/pull/8384)) +- Bump `@metamask/keyring-internal-api` from `^11.0.1` to `^12.0.0` ([#9676](https://github.com/MetaMask/core/pull/9676), [#9754](https://github.com/MetaMask/core/pull/9754)) +- Bump `@metamask/eth-hd-keyring` from `^14.1.1` to `^15.0.0` ([#9754](https://github.com/MetaMask/core/pull/9754)) +- Bump `@metamask/eth-simple-keyring` from `^12.0.2` to `^13.0.0` ([#9754](https://github.com/MetaMask/core/pull/9754)) + +## [27.1.0] + +### Added + +- Add `isKeyringControllerError` predicate ([#9095](https://github.com/MetaMask/core/pull/9095)) + +### Changed + +- Bump `@metamask/utils` from `^11.9.0` to `^11.11.0` ([#9074](https://github.com/MetaMask/core/pull/9074)) + +### Fixed + +- Remove use of `instanceof` for `isKeyringNotFoundError` ([#9095](https://github.com/MetaMask/core/pull/9095)) + - Using `instanceof` causes a lot of issue if we have 2 major `@metamask/keyring-controller` major versions in the dependency tree, `class KeyringControllerError` could be different classes and this, making the check to fail. + +## [27.0.0] + +### Changed + +- **BREAKING:** `exportSeedPhrase` and `exportAccount` now take `Credentials` (`{ password }` | `{ encryptionKey, encryptionSalt? }`) instead of a bare password string ([#8996](https://github.com/MetaMask/core/pull/8996)) + +### Fixed + +- Automatically remove and destroy non-primary keyrings whose last account is removed during a `withKeyring` or `withKeyringV2` callback ([#8951](https://github.com/MetaMask/core/pull/8951)) + - Previously, draining a keyring of all its accounts via these APIs left an empty keyring entry in `state.keyrings` and persisted it in the vault. + - Pre-existing empty keyrings (e.g. those created intentionally via `addNewKeyring` without subsequent account creation) are still preserved, matching the behavior of `removeAccount`. + - The primary keyring is never auto-removed, even if drained; this preserves the existing `removeAccount` invariant against losing the primary keyring. + +## [26.0.0] + +### Changed + +- **BREAKING:** Change `KeyringSelectorV2` type selectors for `withKeyringV2` and `withKeyringV2Unsafe` to use `KeyringType` (v2 variant) ([#8901](https://github.com/MetaMask/core/pull/8901)) + - Use values such as `KeyringType.Hd` instead of legacy `KeyringTypes.hd`. +- Deprecate `KeyringTypes` ([#8907](https://github.com/MetaMask/core/pull/8907)) + - Use `KeyringTypes` from `@metamask/keyring-api/v2` if your keyring has a v2 builder. + +## [25.5.0] + +### Added + +- Expose missing public `KeyringController` methods through its messenger ([#8674](https://github.com/MetaMask/core/pull/8674)) + - The following actions are now available: + - `KeyringController:changePassword`, + - `KeyringController:exportAccount`, + - `KeyringController:exportEncryptionKey`, + - `KeyringController:getAccountKeyringType`, + - `KeyringController:importAccountWithStrategy`, + - `KeyringController:setLocked`, + - `KeyringController:submitEncryptionKey`, + - `KeyringController:submitPassword`, + - `KeyringController:verifyPassword`, + - Corresponding action types are available as well. + +## [25.4.0] + +### Changed + +- Bump `@metamask/eth-hd-keyring` from `^14.1.0` to `^14.1.1` ([#8647](https://github.com/MetaMask/core/pull/8647)) +- Bump `@metamask/eth-simple-keyring` from `^12.0.1` to `^12.0.2` ([#8647](https://github.com/MetaMask/core/pull/8647)) +- Bump `@metamask/keyring-api` from `^23.0.1` to `^23.1.0` ([#8647](https://github.com/MetaMask/core/pull/8647)) +- Bump `@metamask/keyring-internal-api` from `^11.0.0` to `^11.0.1` ([#8647](https://github.com/MetaMask/core/pull/8647)) + +## [25.3.0] + +### Added + +- Expose `KeyringController:exportSeedPhrase` method through `KeyringController` messenger ([#8587](https://github.com/MetaMask/core/pull/8587)) +- Expose `KeyringController:isUnlocked` method through `KeyringController` messenger ([#8573](https://github.com/MetaMask/core/pull/8573)) + - Returns `true` when the vault is unlocked, `false` otherwise. Mirrors `state.isUnlocked` and the `isUnlocked()` instance method, allowing consumers to check lock status via the messenger without holding a controller reference. +- Add `withController` action to run atomic operations on multiple keyrings (within a single transaction) ([#8416](https://github.com/MetaMask/core/pull/8416)) + - This action uses a `RestrictedController` object that exposes `addNewKeyring` and `removeKeyring` methods to add and remove keyring during the transaction (atomic) call. +- Expose `KeyringController:signTransaction` method through `KeyringController` messenger ([#8408](https://github.com/MetaMask/core/pull/8408)) +- Persist vault when keyring state changes during unlock ([#8415](https://github.com/MetaMask/core/pull/8415)) + - If a keyring's serialized state differs after deserialization (e.g. a migration ran, or metadata was missing), the vault is now re-persisted so the change is not lost on the next unlock. +- Added `KeyringV2` support ([#8390](https://github.com/MetaMask/core/pull/8390)) + - The controller now maintains a list of `KeyringV2` instance in memory alongside previous `Keyring` instance. + - This new keyring interface is more generic and will become the new standard to interact with keyring (creating accounts, executing logic that involves accounts like signing, etc...). + - For now, most `KeyringV2` are wrappers (read adapters) around existing `Keyring` instance. +- Added `withKeyringV2Unsafe` method and `KeyringController:withKeyringV2Unsafe` messenger action for lock-free read-only access to `KeyringV2` adapters ([#8390](https://github.com/MetaMask/core/pull/8390)) + - Mirrors `withKeyringUnsafe` semantics: no mutex acquired, no persistence or rollback. + - Caller is responsible for ensuring the operation is read-only and accesses only immutable keyring data. +- Added `withKeyringV2` method and `KeyringController:withKeyringV2` messenger action for atomic operations using the `KeyringV2` API ([#8390](https://github.com/MetaMask/core/pull/8390)) + - Accepts a `KeyringSelectorV2` to select keyrings by `type`, `address`, `id`, or `filter`. + - Ships with default V2 builders for HD (`HdKeyringV2`) and Simple (`SimpleKeyringV2`) keyrings; additional builders can be registered via the `keyringV2Builders` constructor option. + +### Changed + +- Bump `@metamask/messenger` from `^1.0.0` to `^1.2.0` ([#8364](https://github.com/MetaMask/core/pull/8364), [#8373](https://github.com/MetaMask/core/pull/8373), [#8632](https://github.com/MetaMask/core/pull/8632)) +- Bump `@metamask/base-controller` from `^9.0.1` to `^9.1.0` ([#8457](https://github.com/MetaMask/core/pull/8457)) +- Bump `@metamask/eth-hd-keyring` from `^13.1.1` to `^14.0.1` ([#8464](https://github.com/MetaMask/core/pull/8464)) +- Bump `@metamask/eth-simple-keyring` from `^11.1.2` to `^12.0.1` ([#8464](https://github.com/MetaMask/core/pull/8464)) +- Bump `@metamask/keyring-api` from `^21.6.0` to `^23.0.1` ([#8464](https://github.com/MetaMask/core/pull/8464)) +- Bump `@metamask/keyring-internal-api` from `^10.0.0` to `^11.0.0` ([#8464](https://github.com/MetaMask/core/pull/8464), [#8584](https://github.com/MetaMask/core/pull/8584)) + +## [25.2.0] + +### Added + +- Added `filter` selector variant to `withKeyring` ([#8348](https://github.com/MetaMask/core/pull/8348)) + - `KeyringSelector` now accepts `{ filter: ({ keyring, metadata }) => boolean }`, which selects the first keyring for which the predicate returns `true`. +- Add `isKeyringNotFoundError` ([#8351](https://github.com/MetaMask/core/pull/8351)) + - This function can be used when trying to access a non-existing keyring using `withKeyring`. +- Added `withKeyringUnsafe` action ([#8358](https://github.com/MetaMask/core/pull/8358)) + - This new variant of `withKeyring` allows to fetch a keyring instance the same way. + - Mutations are not allowed and won't be replicated in the vault. + - Can be used to read immutable data safely. +- Add `KeyringTypes.money` enum value ([#8360](https://github.com/MetaMask/core/pull/8360)) + +## [25.1.1] + +### Changed + +- Bump `@metamask/base-controller` from `^9.0.0` to `^9.0.1` ([#8317](https://github.com/MetaMask/core/pull/8317)) +- Bump `@metamask/messenger` from `^0.3.0` to `^1.0.0` ([#8317](https://github.com/MetaMask/core/pull/8317)) +- Bump `@metamask/keyring-api` from `^21.0.0` to `^21.6.0` ([#7857](https://github.com/MetaMask/core/pull/7857), [#8259](https://github.com/MetaMask/core/pull/8259)) +- Bump `@metamask/keyring-internal-api` from `^9.0.0` to `^10.0.0` ([#7857](https://github.com/MetaMask/core/pull/7857)) + +## [25.1.0] + +### Added + +- Added new `KeyringBuilder` type ([#7334](https://github.com/MetaMask/core/pull/7334)) +- Added an action to call `removeAccount` ([#7241](https://github.com/MetaMask/core/pull/7241)) + - This action is meant to be consumed by the `MultichainAccountService` to encapsulate the act of removing a wallet when seed phrase backup fails in the clients. +- Added new `KeyringControllerError` ([#7498](https://github.com/MetaMask/core/pull/7498)) + - All controller's errors are now using this error type. + - Keyring instance operation errors are also now also wrapped with this error type. + +### Changed + +- Upgrade `@metamask/utils` from `^11.8.1` to `^11.9.0` ([#7511](https://github.com/MetaMask/core/pull/7511)) +- Prevent the accidental removal of unrelated empty keyrings when deleting an account ([#7670](https://github.com/MetaMask/core/pull/7670)) + - Empty keyrings were removed during account deletion, regardless of which account was being targeted. + +### Fixed + +- Fixed a bug where `removeAccount` would not prevent deletion of the primary keyring because of missing address normalization ([#7670](https://github.com/MetaMask/core/pull/7670)) + +## [25.0.0] + +### Added + +- Added optional `EncryptionKey`, `SupportedKeyDerivationOptions` and `EncryptionResult` type parameters to the `KeyringController`, `ExportableKeyEncryptor` and `KeyringControllerOptions` types ([#7127](https://github.com/MetaMask/core/pull/7127)) + - This type parameter allows specifying the encryption key, key derivation options and encryption result types supported by the injected encryptor, defaulting to `@metamask/browser-passworder` types. + +### Changed + +- **BREAKING:** The `KeyringController` constructor options now require an encryptor ([#7127](https://github.com/MetaMask/core/pull/7127)) + - The `encryptor` constructor option was previously optional and defaulted to an instance of `@metamask/browser-passworder`. +- **BREAKING:** The `GenericEncryptor` and `ExportableKeyEncryptor` types have been merged into a single `Encryptor` type ([#7127](https://github.com/MetaMask/core/pull/7127)) +- **BREAKING:** The `Encryptor` type requires `exportKey`, `keyFromPassword` and `generateSalt` methods ([#7128](https://github.com/MetaMask/core/pull/7128)) + +### Removed + +- **BREAKING:** The `cacheEncryptionKey` parameter has been removed from the `KeyringController` constructor options ([#7127](https://github.com/MetaMask/core/pull/7127)) + - This parameter was previously used to enable encryption key in-memory caching, but it is no longer needed as the controller now always uses the latest encryption key. + +### Fixed + +- Fixed incorrect type for `decryptWithKey` method of `ExportableKeyEncryptor` (now `Encryptor`) ([#7127](https://github.com/MetaMask/core/pull/7127)) + +## [24.0.0] + +### Changed + +- **BREAKING:** Use new `Messenger` from `@metamask/messenger` ([#6370](https://github.com/MetaMask/core/pull/6370)) + - Previously, `KeyringController` accepted a `RestrictedMessenger` instance from `@metamask/base-controller`. +- **BREAKING:** Metadata property `anonymous` renamed to `includeInDebugSnapshot` ([#6370](https://github.com/MetaMask/core/pull/6370)) +- Bump `@metamask/base-controller` from `^8.4.2` to `^9.0.0` ([#6962](https://github.com/MetaMask/core/pull/6962)) + +## [23.2.0] + +### Added + +- Add actions for `createNewVaultAndKeychain` and `createNewVaultAndRestore` ([#6928](https://github.com/MetaMask/core/pull/6928)) + - These actions are meant to to be consumed by the `MultichainAccountService` in its `createMultichainAccountWallet` method. + +### Changed + +- Bump `@metamask/base-controller` from `^8.4.1` to `^8.4.2` ([#6917](https://github.com/MetaMask/core/pull/6917)) + +## [23.1.1] + +### Changed + +- Bump `@metamask/utils` from `^11.4.2` to `^11.8.1` ([#6588](https://github.com/MetaMask/core/pull/6588), [#6708](https://github.com/MetaMask/core/pull/6708)) +- Bump `@metamask/base-controller` from `^8.3.0` to `^8.4.1` ([#6632](https://github.com/MetaMask/core/pull/6632), [#6807](https://github.com/MetaMask/core/pull/6807)) + +## [23.1.0] + +### Added + +- Add `KeyringController:addNewKeyring` action ([#6439](https://github.com/MetaMask/core/pull/6439)) +- Add two new controller state metadata properties: `includeInStateLogs` and `usedInUi` ([#6525](https://github.com/MetaMask/core/pull/6525)) + +### Changed + +- Bump `@metamask/base-controller` from `^8.1.0` to `^8.3.0` ([#6355](https://github.com/MetaMask/core/pull/6355), [#6465](https://github.com/MetaMask/core/pull/6465)) +- Bump `@metamask/keyring-api` from `^20.1.0` to `^21.0.0` ([#6560](https://github.com/MetaMask/core/pull/6560)) +- Bump `@metamask/keyring-internal-api` from `^8.1.0` to `^9.0.0` ([#6560](https://github.com/MetaMask/core/pull/6560)) +- Bump `@metamask/eth-hd-keyring` from `^12.0.0` to `13.0.0` ([#6566](https://github.com/MetaMask/core/pull/6566)) +- Bump `@metamask/eth-simple-keyring` from `^10.0.0` to `11.0.0` ([#6566](https://github.com/MetaMask/core/pull/6566)) + +## [23.0.0] + +### Changed + +- Bump `@metamask/base-controller` from `^8.0.1` to `^8.1.0` ([#6284](https://github.com/MetaMask/core/pull/6284)) +- Bump accounts related packages ([#6309](https://github.com/MetaMask/core/pull/6309)) + - Bump `@metamask/keyring-api` from `^20.0.0` to `^20.1.0` + - Bump `@metamask/keyring-internal-api` from `^8.0.0` to `^8.1.0` + +### Removed + +- **BREAKING:** Removed QR keyring methods ([#6031](https://github.com/MetaMask/core/pull/6031)) + - The following methods have been removed: + - `cancelQRSignRequest` + - `cancelQRSynchronization` + - `connectQRHardware` + - `forgetQRDevice` + - `getOrAddQRKeyring` + - `getQRKeyring` + - `getQRKeyringState` + - `resetQRKeyringState` + - `restoreQRKeyring` + - `submitQRCryptoHDKey` + - `submitQRCryptoAccount` + - `submitQRSignature` + - `unlockQRHardwareWalletAccount` + - Consumers can use the `withKeyring` method to select a QR keyring and execute a callback with it as argument. +- **BREAKING:** Removed `KeyringController:qrKeyringStateChange` event ([#6031](https://github.com/MetaMask/core/pull/6031)) + +## [22.1.1] + +### Changed + +- Bump `@metamask/keyring-api` from `^18.0.0` to `^20.0.0` ([#6146](https://github.com/MetaMask/core/pull/6146), [#6248](https://github.com/MetaMask/core/pull/6248)) +- Bump `@metamask/keyring-internal-api` from `^6.2.0` to `^8.0.0` ([#6146](https://github.com/MetaMask/core/pull/6146), [#6248](https://github.com/MetaMask/core/pull/6248)) +- Bump `@metamask/utils` from `^11.2.0` to `^11.4.2` ([#6054](https://github.com/MetaMask/core/pull/6054)) + +## [22.1.0] + +### Added + +- Add method `exportEncryptionKey` ([#5984](https://github.com/MetaMask/core/pull/5984)) + +### Changed + +- Make salt optional with method `submitEncryptionKey` ([#5984](https://github.com/MetaMask/core/pull/5984)) + +## [22.0.2] + +### Fixed + +- Fixed serialized keyring comparison when establishing whether a vault update is needed ([#5928](https://github.com/MetaMask/core/pull/5928)) + - The vault update was being skipped when a keyring class returns an object shallow copy through `.serialize()`. + +## [22.0.1] + +### Changed + +- Bump `@metamask/keyring-api` dependency from `^17.4.0` to `^18.0.0` ([#5871](https://github.com/MetaMask/core/pull/5871)) +- Bump `@metamask/keyring-internal-api` dependency from `^6.0.1` to `^6.2.0` ([#5871](https://github.com/MetaMask/core/pull/5871)) + +## [22.0.0] + +### Changed + +- **BREAKING** `keyringsMetadata` has been removed from the controller state ([#5725](https://github.com/MetaMask/core/pull/5725)) + - The metadata is now stored in each keyring object in the `state.keyrings` array. + - When updating to this version, we recommend removing the `keyringsMetadata` state and all state referencing a keyring ID with a migration. New metadata will be generated for each keyring automatically after the update. + +### Fixed + +- Keyrings with duplicate accounts are skipped as unsupported on unlock ([#5775](https://github.com/MetaMask/core/pull/5775)) + +## [21.0.6] + +### Changed + +- Prevent emitting `:stateChange` from `withKeyring` unnecessarily ([#5732](https://github.com/MetaMask/core/pull/5732)) + +## [21.0.5] + +### Changed + +- Bump `@metamask/base-controller` from ^8.0.0 to ^8.0.1 ([#5722](https://github.com/MetaMask/core/pull/5722)) + +### Fixed + +- The vault encryption upgrade fails gracefully during login ([#5740](https://github.com/MetaMask/core/pull/5740)) + +## [21.0.4] + +### Fixed + +- Ensure no duplicate accounts are persisted ([#5710](https://github.com/MetaMask/core/pull/5710)) + +## [21.0.3] + +### Changed + +- `ExportableKeyEncryptor` is now a generic type with a type parameter `EncryptionKey` ([#5395](https://github.com/MetaMask/core/pull/5395)) + - The type parameter defaults to `unknown` + +### Fixed + +- Fixed wrong error message thrown when using the wrong password ([#5627](https://github.com/MetaMask/core/pull/5627)) + +## [21.0.2] + +### Changed + +- Bump `@metamask/keyring-api` from `^17.2.0` to `^17.4.0` ([#5565](https://github.com/MetaMask/core/pull/5565)) +- Bump `@metamask/keyring-internal-api` from `^6.0.0` to `^6.0.1` ([#5565](https://github.com/MetaMask/core/pull/5565)) + +### Fixed + +- Ignore cached encryption key when the vault needs to upgrade its encryption parameters ([#5601](https://github.com/MetaMask/core/pull/5601)) + +## [21.0.1] + +### Fixed + +- Fixed duplication of unsupported keyrings ([#5535](https://github.com/MetaMask/core/pull/5535)) +- Enforce keyrings metadata alignment when unlocking existing vault ([#5535](https://github.com/MetaMask/core/pull/5535)) +- Fixed frozen object mutation attempt when updating metadata ([#5535](https://github.com/MetaMask/core/pull/5535)) + +## [21.0.0] [DEPRECATED] + +### Changed + +- **BREAKING:** Bump `@metamask/keyring-internal-api` from `^5.0.0` to `^6.0.0` ([#5347](https://github.com/MetaMask/core/pull/5347)) +- **BREAKING:** Bump `@metamask/eth-simple-keyring` from `^9.0.0` to `^10.0.0` ([#5347](https://github.com/MetaMask/core/pull/5347)) +- **BREAKING:** Bump `@metamask/eth-hd-keyring` from `^11.0.0` to `^12.0.0` ([#5347](https://github.com/MetaMask/core/pull/5347)) +- **BREAKING:** Bump `@ethereumjs/util` from `^8.1.0` to `^9.1.0` ([#5347](https://github.com/MetaMask/core/pull/5347)) + +## [20.0.0] [DEPRECATED] + +### Changed + +- **BREAKING:** `addNewKeyring` method now returns `Promise` instead of `Promise` ([#5372](https://github.com/MetaMask/core/pull/5372)) + - Consumers can use the returned `KeyringMetadata.id` to access the created keyring instance via `withKeyring`. +- **BREAKING:** `withKeyring` method now requires a callback argument of type `({ keyring: SelectedKeyring; metadata: KeyringMetadata }) => Promise` ([#5372](https://github.com/MetaMask/core/pull/5372)) +- Bump `@metamask/keyring-internal-api` from `^4.0.3` to `^5.0.0` ([#5405](https://github.com/MetaMask/core/pull/5405)) +- Bump `@metamask/eth-hd-keyring` from `^10.0.0` to `^11.0.0` ([#5405](https://github.com/MetaMask/core/pull/5405)) +- Bump `@metamask/eth-simple-keyring` from `^8.1.0` to `^9.0.0` ([#5405](https://github.com/MetaMask/core/pull/5405)) + +## [19.2.2] + +### Fixed + +- Fixed duplication of unsupported keyrings ([#5535](https://github.com/MetaMask/core/pull/5535)) +- Enforce keyrings metadata alignment when unlocking existing vault ([#5535](https://github.com/MetaMask/core/pull/5535)) +- Fixed frozen object mutation attempt when updating metadata ([#5535](https://github.com/MetaMask/core/pull/5535)) + +## [19.2.1] [DEPRECATED] + +### Changed + +- Bump `@metamask/keyring-api"` from `^17.0.0` to `^17.2.0` ([#5366](https://github.com/MetaMask/core/pull/5366)) +- Bump `@metamask/keyring-internal-api` from `^4.0.1` to `^4.0.3` ([#5356](https://github.com/MetaMask/core/pull/5356), [#5366](https://github.com/MetaMask/core/pull/5366)) + +### Fixed + +- Ensure authorization contract address is provided ([#5353](https://github.com/MetaMask/core/pull/5353)) + +## [19.2.0] [DEPRECATED] + +### Added + +- Add `signEip7702Authorization` to `KeyringController` ([#5301](https://github.com/MetaMask/core/pull/5301)) +- Add `KeyringController:withKeyring` action ([#5332](https://github.com/MetaMask/core/pull/5332)) + - The action can be used to consume the `withKeyring` method of the `KeyringController` class +- Support keyring metadata in KeyringController ([#5112](https://github.com/MetaMask/core/pull/5112)) + +## [19.1.0] + +### Added + +- Add new keyring type for OneKey ([#5216](https://github.com/MetaMask/core/pull/5216)) + +### Changed + +- A specific error message is thrown when any operation is attempted while the controller is locked ([#5172](https://github.com/MetaMask/core/pull/5172)) + +## [19.0.7] + +### Changed + +- Bump `@metamask/base-controller` from `^7.1.1` to `^8.0.0` ([#5305](https://github.com/MetaMask/core/pull/5305)) +- Bump `@metamask/message-manager` from `^12.0.0` to `^12.0.1` ([#5305](https://github.com/MetaMask/core/pull/5305)) + +## [19.0.6] + +### Changed + +- Bump `@metamask/keyring-api"` from `^16.1.0` to `^17.0.0` ([#5280](https://github.com/MetaMask/core/pull/5280)) +- Bump `@metamask/utils` from `^11.0.1` to `^11.1.0` ([#5223](https://github.com/MetaMask/core/pull/5223)) + +## [19.0.5] + +### Changed + +- Bump `@metamask/keyring-api` from `^14.0.0` to `^16.1.0` ([#5190](https://github.com/MetaMask/core/pull/5190), [#5208](https://github.com/MetaMask/core/pull/5208)) + +## [19.0.4] + +### Changed + +- Bump `@metamask/keyring-api` from `^13.0.0` to `^14.0.0` ([#5177](https://github.com/MetaMask/core/pull/5177)) +- Bump `@metamask/keyring-internal-api` from `^2.0.0` to `^2.0.1` ([#5177](https://github.com/MetaMask/core/pull/5177)) +- Bump `@metamask/message-manager` from `^12.0.0` to `^11.0.3` ([#5169](https://github.com/MetaMask/core/pull/5169)) + +## [19.0.3] + +### Changed + +- Bump `@metamask/base-controller` from `^7.0.0` to `^7.1.1`, ([#5079](https://github.com/MetaMask/core/pull/5079), [#5135](https://github.com/MetaMask/core/pull/5135)) +- Bump `@metamask/keyring-api` from `^12.0.0` to `^13.0.0` ([#5066](https://github.com/MetaMask/core/pull/5066)) +- Bump `@metamask/keyring-internal-api` from `^1.0.0` to `^2.0.0` ([#5066](https://github.com/MetaMask/core/pull/5066), [#5136](https://github.com/MetaMask/core/pull/5136)) +- Bump `@metamask/utils` to `^11.0.1` ([#5080](https://github.com/MetaMask/core/pull/5080)) +- Bump `@metamask/rpc-errors` to `^7.0.2` ([#5080](https://github.com/MetaMask/core/pull/5080)) + +### Fixed + +- Make `verifySeedPhrase` mutually exclusive ([#5077](https://github.com/MetaMask/core/pull/5077)) + +## [19.0.2] + +### Changed + +- Remove use of `@metamask/keyring-api` ([#4695](https://github.com/MetaMask/core/pull/4695)) + - `@metamask/providers` and `webextension-polyfill` peer depedencies are no longer required. +- Use new `@metamask/keyring-internal-api@^1.0.0` ([#4695](https://github.com/MetaMask/core/pull/4695)) + - This package has been split out from the Keyring API. Its types are compatible with the `@metamask/keyring-api` package used previously. +- Bump `@metamask/message-manager` from `^11.0.2` to `^11.0.3` ([#5048](https://github.com/MetaMask/core/pull/5048)) + +## [19.0.1] + +### Changed + +- Bump `@metamask/message-manager` from `^11.0.1` to `^11.0.2` ([#5012](https://github.com/MetaMask/core/pull/5012)) + +### Fixed + +- Make implicit peer dependencies explicit ([#4974](https://github.com/MetaMask/core/pull/4974)) + - Add the following packages as peer dependencies of this package to satisfy peer dependency requirements from other dependencies: + - `@metamask/providers` `^18.1.0` (required by `@metamask/keyring-api`) + - `webextension-polyfill` `^0.10.0 || ^0.11.0 || ^0.12.0` (required by `@metamask/providers`) + - These dependencies really should be present in projects that consume this package (e.g. MetaMask clients), and this change ensures that they now are. + - Furthermore, we are assuming that clients already use these dependencies, since otherwise it would be impossible to consume this package in its entirety or even create a working build. Hence, the addition of these peer dependencies is really a formality and should not be breaking. +- Correct ESM-compatible build so that imports of the following packages that re-export other modules via `export *` are no longer corrupted: ([#5011](https://github.com/MetaMask/core/pull/5011)) + - `@metamask/eth-hd-keyring` + - `@metamask/eth-simple-keyring` + - `@ethereumjs/util` + - `ethereumjs-wallet` + +## [19.0.0] + +### Changed + +- **BREAKING:** Bump `@metamask/keyring-api` from `^8.1.3` to `^10.1.0` ([#4948](https://github.com/MetaMask/core/pull/4948)) + - If you are depending on `@metamask/providers` directly, you will need to upgrade to 18.1.0. + +## [18.0.0] + +### Removed + +- **BREAKING** Remove `addNewAccountWithoutUpdate` method ([#4845](https://github.com/MetaMask/core/pull/4845)) + +## [17.3.1] + +### Changed + +- Bump `@metamask/base-controller` from `^7.0.1` to `^7.0.2` ([#4862](https://github.com/MetaMask/core/pull/4862)) +- Bump `@metamask/utils` from `^9.1.0` to `^10.0.0` ([#4831](https://github.com/MetaMask/core/pull/4831)) +- Bump `@metamask/eth-sig-util` from `^7.0.1` to `^8.0.0` ([#4830](https://github.com/MetaMask/core/pull/4830)) + +## [17.3.0] + +### Changed + +- Bump `@metamask/message-manager` from `^10.1.1` to `^11.0.0` ([#4805](https://github.com/MetaMask/core/pull/4805)) + +## [17.2.2] + +### Changed + +- Bump accounts related packages ([#4713](https://github.com/MetaMask/core/pull/4713), [#4728](https://github.com/MetaMask/core/pull/4728)) + - Those packages are now built slightly differently and are part of the [accounts monorepo](https://github.com/MetaMask/accounts). + - Bump `@metamask/keyring-api` from `^8.1.0` to `^8.1.4` + - Bump `@metamask/eth-hd-keyring` from `^7.0.1` to `^7.0.4` + - Bump `@metamask/eth-simple-keyring` from `^6.0.1` to `^6.0.5` + +## [17.2.1] + +### Fixed + +- Produce and export ESM-compatible TypeScript type declaration files in addition to CommonJS-compatible declaration files ([#4648](https://github.com/MetaMask/core/pull/4648)) + - Previously, this package shipped with only one variant of type declaration + files, and these files were only CommonJS-compatible, and the `exports` + field in `package.json` linked to these files. This is an anti-pattern and + was rightfully flagged by the + ["Are the Types Wrong?"](https://arethetypeswrong.github.io/) tool as + ["masquerading as CJS"](https://github.com/arethetypeswrong/arethetypeswrong.github.io/blob/main/docs/problems/FalseCJS.md). + All of the ATTW checks now pass. +- Remove chunk files ([#4648](https://github.com/MetaMask/core/pull/4648)) + - Previously, the build tool we used to generate JavaScript files extracted + common code to "chunk" files. While this was intended to make this package + more tree-shakeable, it also made debugging more difficult for our + development teams. These chunk files are no longer present. + +## [17.2.0] + +### Added + +- Add `KeyringController:addNewAccount` messenger action ([#4565](https://github.com/MetaMask/core/pull/4565)) + - Add and export `KeyringControllerAddNewAccountAction` type. + - Widen `KeyringControllerActions` to include `KeyringControllerAddNewAccountAction` type. + - `KeyringControllerMessenger` must allow `KeyringControllerAddNewAccountAction` type. + +### Changed + +- Bump `@metamask/base-controller` from `^6.0.2` to `^7.0.0` ([#4625](https://github.com/MetaMask/core/pull/4625), [#4643](https://github.com/MetaMask/core/pull/4643)) +- Bump `@metamask/keyring-api` from `^8.0.1` to `^8.1.0` ([#4594](https://github.com/MetaMask/core/pull/4594)) +- Bump `@metamask/message-manager` from `^10.0.2` to `^10.0.3` ([#4643](https://github.com/MetaMask/core/pull/4643)) +- Bump `typescript` from `~5.0.4` to `~5.2.2` ([#4576](https://github.com/MetaMask/core/pull/4576), [#4584](https://github.com/MetaMask/core/pull/4584)) + +## [17.1.2] + +### Changed + +- Upgrade TypeScript version to `~5.0.4` and set `moduleResolution` option to `Node16` ([#3645](https://github.com/MetaMask/core/pull/3645)) +- Bump `@metamask/base-controller` from `^6.0.0` to `^6.0.2` ([#4517](https://github.com/MetaMask/core/pull/4517), [#4544](https://github.com/MetaMask/core/pull/4544)) +- Bump `@metamask/keyring-api` from `^8.0.0` to `^8.0.1` ([#3645](https://github.com/MetaMask/core/pull/3645)) +- Bump `@metamask/utils` from `^9.0.0` to `^9.1.0` ([#4529](https://github.com/MetaMask/core/pull/4529)) +- Bump `@metamask/message-manager` from `^10.0.1` to `^10.0.2` ([#4548](https://github.com/MetaMask/core/pull/4548)) + +## [17.1.1] + +### Changed + +- Bump `@metamask/utils` to `^9.0.0`, `@metamask/rpc-errors` to `^6.3.1` ([#4516](https://github.com/MetaMask/core/pull/4516)) + +### Fixed + +- Clear encryption salt and key in `setLocked` and `#createNewVaultWithKeyring` to ensure that encryption key is always generated with the latest password ([#4514](https://github.com/MetaMask/core/pull/4514)) + +## [17.1.0] + +### Added + +- Add support for overwriting built-in keyring builders for the Simple and HD keyring ([#4362](https://github.com/MetaMask/core/pull/4362)) + +### Changed + +- Bump `@metamask/eth-snap-keyring` to `^4.3.1` ([#4405](https://github.com/MetaMask/core/pull/4405)) +- Bump `@metamask/keyring-api` to `^8.0.0` ([#4405](https://github.com/MetaMask/core/pull/4405)) + +### Deprecated + +- Deprecate QR keyring methods ([#4365](https://github.com/MetaMask/core/pull/4365)) + - `cancelQRSignRequest` + - `cancelQRSynchronization` + - `connectQRHardware` + - `forgetQRDevice` + - `getOrAddQRKeyring` + - `getQRKeyring` + - `getQRKeyringState` + - `resetQRKeyringState` + - `restoreQRKeyring` + - `submitQRCryptoHDKey` + - `submitQRCryptoAccount` + - `submitQRSignature` + - `unlockQRHardwareWalletAccount` + +## [17.0.0] + +### Changed + +- **BREAKING:** Bump minimum Node version to 18.18 ([#3611](https://github.com/MetaMask/core/pull/3611)) +- Bump `@metamask/base-controller` to `^6.0.0` ([#4352](https://github.com/MetaMask/core/pull/4352)) +- Bump `@metamask/message-manager` to `^10.0.0` ([#4352](https://github.com/MetaMask/core/pull/4352)) + +## [16.1.0] + +### Added + +- Add `changePassword` method ([#4279](https://github.com/MetaMask/core/pull/4279)) + - This method can be used to change the password used to encrypt the vault. +- Add support for non-EVM account addresses to most methods ([#4282](https://github.com/MetaMask/core/pull/4282)) + - Previously, all addresses were assumed to be Ethereum addresses and normalized, but now only Ethereum addresses are treated as such. + - Relax type of `account` argument on `removeAccount` from `Hex` to `string` + +### Changed + +- Bump `@metamask/keyring-api` to `^6.1.1` ([#4262](https://github.com/MetaMask/core/pull/4262)) +- Bump `@keystonehq/metamask-airgapped-keyring` to `^0.14.1` ([#4277](https://github.com/MetaMask/core/pull/4277)) +- Bump `async-mutex` to `^0.5.0` ([#4335](https://github.com/MetaMask/core/pull/4335)) +- Bump `@metamask/message-manager` to `^9.0.0` ([#4342](https://github.com/MetaMask/core/pull/4342)) + +### Fixed + +- Fix QR keyrings so that they are not initialized with invalid state ([#4256](https://github.com/MetaMask/core/pull/4256)) + +## [16.0.0] + +### Added + +- Added `withKeyring` method ([#4197](https://github.com/MetaMask/core/pull/4197)) + - Consumers can now use `withKeyring` to atomically select a keyring by address or type and execute a callback with the it as argument. + - This method can be used instead of `getKeyringForAccount`, `getKeyringsByType` and `persistAllKeyrings`, as the vault will be updated automatically after the callback execution, or rolled back in case of errors + +### Changed + +- **BREAKING**: Change various `KeyringController` methods so they no longer return the controller state ([#4199](https://github.com/MetaMask/core/pull/4199)) + - Changed `addNewAccount` return type to `Promise` + - Changed `addNewAccountWithoutUpdate` return type to `Promise` + - Changed `createNewVaultAndKeychain` return type to `Promise` + - Changed `createNewVaultAndRestore` return type to `Promise` + - Changed `importAccountWithStrategy` return type to `Promise` + - Changed `removeAccount` return type to `Promise` + - Changed `setLocked` return type to `Promise` + - Changed `submitEncryptionKey` return type to `Promise` + - Changed `submitPassword` return type to `Promise` +- Bump `@metamask/keyring-api` to `^6.0.0` ([#4193](https://github.com/MetaMask/core/pull/4193)) +- Bump `@metamask/base-controller` to `^5.0.2` ([#4232](https://github.com/MetaMask/core/pull/4232)) +- Bump `@metamask/message-manager` to `^8.0.2` ([#4234](https://github.com/MetaMask/core/pull/4234)) + +### Fixed + +- Method calls that change controller state are now atomic ([#4192](https://github.com/MetaMask/core/pull/4192)) + - Each method will roll back keyring instances in case of errors +- Method calls that change controller state are now mutually exclusive ([#4182](https://github.com/MetaMask/core/pull/4182)) +- Check presence of `HDKeyring` when updating the vault ([#4168](https://github.com/MetaMask/core/pull/4168)) +- Update state in single call when persisting or unlocking ([#4154](https://github.com/MetaMask/core/pull/4154)) + +## [15.0.0] + +### Changed + +- **BREAKING** use getAccounts on HD Keyring when calling addNewAccount ([#4158](https://github.com/MetaMask/core/pull/4158)) +- Pass CAIP-2 scope to execution context ([#4090](https://github.com/MetaMask/core/pull/4090)) +- Allow gas limits to be changed during #addPaymasterData ([#3942](https://github.com/MetaMask/core/pull/3942)) + +## [14.0.1] + +### Fixed + +- Fix `types` field in `package.json` ([#4047](https://github.com/MetaMask/core/pull/4047)) + +## [14.0.0] + +### Added + +- **BREAKING**: Add ESM build ([#3998](https://github.com/MetaMask/core/pull/3998)) + - It's no longer possible to import files from `./dist` directly. + +### Changed + +- **BREAKING:** Bump `@metamask/base-controller` to `^5.0.0` ([#4039](https://github.com/MetaMask/core/pull/4039)) + - This version has a number of breaking changes. See the changelog for more. +- Bump `@metamask/message-manager` to `^8.0.0` ([#4039](https://github.com/MetaMask/core/pull/4039)) + +### Fixed + +- **BREAKING:** Narrow `KeyringControllerMessenger` type parameters `AllowedAction` and `AllowedEvent` from `string` to `never` ([#4031](https://github.com/MetaMask/core/pull/4031)) + - Allowlisting or using any external actions or events will now produce a type error. + +## [13.0.0] + +### Added + +- Add `isCustodyKeyring` function ([#3899](https://github.com/MetaMask/core/pull/3899)) +- Add `keyringBuilderFactory` utility function ([#3830](https://github.com/MetaMask/core/pull/3830)) +- Add `GenericEncryptor`, `ExportableKeyEncryptor`, and `SerializedKeyring` types ([#3830](https://github.com/MetaMask/core/pull/3830)) + +### Changed + +- Replace `ethereumjs-util` with `@ethereumjs/util` ([#3943](https://github.com/MetaMask/core/pull/3943)) +- Bump `@metamask/message-manager` to `^7.3.9` ([#4007](https://github.com/MetaMask/core/pull/4007)) + +### Removed + +- **BREAKING:** Remove callbacks `updateIdentities`, `syncIdentities`, `setSelectedAddress`, `setAccountLabel` from constructor options of the `KeyringController` class. These were previously used to update `PreferencesController` state, but are now replaced with `PreferencesController`'s subscription to the `KeyringController:stateChange` event. ([#3853](https://github.com/MetaMask/core/pull/3853)) + - Methods `addNewAccount`, `addNewAccountForKeyring`, `createNewVaultAndRestore`, `createNewVaultAndKeychain`, `importAccountWithStrategy`, `restoreQRKeyring`, `unlockQRHardwareWalletAccount`, and `forgetQRDevice` no longer directly update `PreferencesController` state by calling the `updateIdentities` callback. + - Method `submitPassword` no longer directly updates `PreferencesController` state by calling the `syncIdentities` callback. + - Method `unlockQRHardwareWalletAccount` no longer directly updates `PreferencesController` state by calling the `setAccountLabel` or `setSelectedAddress` callbacks. +- Remove `@metamask/eth-keyring-controller` dependency, and transfer dependencies to this package instead ([#3830](https://github.com/MetaMask/core/pull/3830)) + - `@metamask/eth-hd-keyring` + - `@metamask/eth-simple-keyring` + - `@metamask/eth-sig-util` + - `@metamask/browser-passworder` + +## [12.2.0] + +### Added + +- Add `getDefaultKeyringState` function ([#3799](https://github.com/MetaMask/core/pull/3799)) + +### Changed + +- Bump `@metamask/base-controller` to `^4.1.1` ([#3821](https://github.com/MetaMask/core/pull/3821)) +- Bump `@metamask/message-manager` to `^7.3.8` ([#3821](https://github.com/MetaMask/core/pull/3821)) + +### Removed + +- Remove `peerDependency` and `devDependency` upon `@metamask/preferences-controller` ([#3799](https://github.com/MetaMask/core/pull/3799)) + - This dependency was just used to access the types of four methods. Those types are now inlined instead. + +## [12.1.0] + +### Added + +- Add methods to support ERC-4337 accounts ([#3602](https://github.com/MetaMask/core/pull/3602)) + +### Changed + +- Bump `@metamask/keyring-api` to ^3.0.0 ([#3747](https://github.com/MetaMask/core/pull/3747)) +- Bump @metamask/eth-keyring-controller from 17.0.0 to 17.0.1 ([#3805](https://github.com/MetaMask/core/pull/3805)) +- Bump `@metamask/utils` to `^8.3.0` ([#3769](https://github.com/MetaMask/core/pull/3769)) + +### Fixed + +- Fix custody keyring name ([#3803](https://github.com/MetaMask/core/pull/3803)) + +## [12.0.0] + +### Changed + +- **BREAKING:** Bump `@metamask/preferences-controller` to ^6.0.0 + +## [11.0.0] + +### Changed + +- **BREAKING:** Bump `@metamask/preferences-controller` peer dependency from `^5.0.0` to `^5.0.1` ([#3695](https://github.com/MetaMask/core/pull/3695)) +- Bump `@metamask/base-controller` to `^4.0.1` ([#3695](https://github.com/MetaMask/core/pull/3695)) +- Bump `@metamask/eth-keyring-controller` to `^15.1.0` ([#3617](https://github.com/MetaMask/core/pull/3617)) +- Bump `@metamask/eth-sig-util` to `^7.0.1` ([#3614](https://github.com/MetaMask/core/pull/3614)) +- Bump `@metamask/message-manager` to `^7.3.7` ([#3695](https://github.com/MetaMask/core/pull/3695)) +- Update `forgetQRDevice` to return an object containing `removedAccounts` and `remainingAccounts` ([#3641](https://github.com/MetaMask/core/pull/3641)) + +### Fixed + +- Remove `@metamask/preferences-controller` dependency ([#3607](https://github.com/MetaMask/core/pull/3607)) + +## [10.0.0] + +### Changed + +- **BREAKING:** Bump `@metamask/base-controller` to ^4.0.0 ([#2063](https://github.com/MetaMask/core/pull/2063)) + - This is breaking because the type of the `messenger` has backward-incompatible changes. See the changelog for this package for more. +- Bump `@metamask/message-manager` to ^7.3.6 ([#2063](https://github.com/MetaMask/core/pull/2063)) +- Bump `@metamask/preferences-controller` to ^4.5.0 ([#2063](https://github.com/MetaMask/core/pull/2063)) + +## [9.0.0] + +### Added + +- Add `KeyringController:persistAllKeyrings` messenger action ([#1965](https://github.com/MetaMask/core/pull/1965)) + +### Changed + +- **BREAKING** Change `encryptor` constructor option property type to `GenericEncryptor | ExportableKeyEncryptor | undefined` ([#2041](https://github.com/MetaMask/core/pull/2041)) + - When the controller is instantiated with `cacheEncryptionKey: true`, `encryptor` may no longer be of type `GenericEncryptor`. +- Bump dependency on `@metamask/scure-bip39` 2.1.1 ([#1868](https://github.com/MetaMask/core/pull/1868)) +- Bump dependency on `@metamask/utils` to 8.2.0 ([#1957](https://github.com/MetaMask/core/pull/1957)) +- Bump @metamask/eth-keyring-controller to 14.0.0 ([#1771](https://github.com/MetaMask/core/pull/1771)) + +## [8.1.0] + +### Changed + +- Adds additional options to KeyringTypes enum ([#1839](https://github.com/MetaMask/core/pull/1839)) + ## [8.0.3] + ### Changed + - `signTransaction` now accepts an optional `opts: Record` argument to support `signTransaction` from `Keyring` type ([#1789](https://github.com/MetaMask/core/pull/1789)) - Bump dependency and peer dependency on `@metamask/preferences-controller` to ^4.4.3 ## [8.0.2] + ### Changed + - Bump dependency on `@metamask/utils` to ^8.1.0 ([#1639](https://github.com/MetaMask/core/pull/1639)) - Bump dependency on `@metamask/base-controller` to ^3.2.3 - Bump dependency on `@metamask/message-manager` to ^7.3.5 ### Fixed + - Update `removeAccount` to remove call to `PreferencesController.removeIdentity` as `PreferencesController` already handles account removal side effects through messenger events ([#1759](https://github.com/MetaMask/core/pull/1759)) ## [8.0.1] + ### Changed + - Update TypeScript to v4.8.x ([#1718](https://github.com/MetaMask/core/pull/1718)) ### Fixed + - Removed `keyringTypes` from `memStore` ([#1710](https://github.com/MetaMask/core/pull/1710)) - This property was accidentally getting copied into the memstore from the internal keyring controller. It was causing errors because there is no metadata for this state property. ## [8.0.0] + ### Added + - Add `getQRKeyring(): QRKeyring | undefined` method - Add `KeyringController:qrKeyringStateChange` messenger event - The event emits updates from the internal `QRKeyring` instance, if there's one ### Changed + - **BREAKING:** addNewKeyring(type) return type changed from Promise> to Promise - When calling with QRKeyring type the keyring instance is retrieved or created (no multiple QRKeyring instances possible) - Bump dependency on `@metamask/message-manager` to ^7.3.3 - Bump dependency on `@metamask/preferences-controller` to ^4.4.1 ### Fixed + - Fix `addNewAccountForKeyring` for `CustodyKeyring` ([#1694](https://github.com/MetaMask/core/pull/1694)) ## [7.5.0] + ### Added + - Add `KeyringController` messenger actions ([#1691](https://github.com/MetaMask/core/pull/1691)) - `KeyringController:getAccounts` - `KeyringController:getKeyringsByType` - `KeyringController:getKeyringForAccount` ### Changed + - Bump `@metamask/eth-sig-util` from 6.0.0 to 7.0.0 ([#1669](https://github.com/MetaMask/core/pull/1669)) ## [7.4.0] + ### Added + - Add `KeyringController` messenger actions ([#1654](https://github.com/MetaMask/core/pull/1654)) - `KeyringController:signMessage` - `KeyringController:signPersonalMessage` @@ -63,26 +874,35 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - `KeyringController:getEncryptionPublicKey` ## [7.3.0] + ### Added + - Add `decryptMessage` method ([#1596](https://github.com/MetaMask/core/pull/1596)) ## [7.2.0] + ### Added + - Add `addNewAccountForKeyring` method ([#1591](https://github.com/MetaMask/core/pull/1591)) - Add `addNewKeyring` method ([#1594](https://github.com/MetaMask/core/pull/1594)) - Add `persistAllKeyrings` method ([#1574](https://github.com/MetaMask/core/pull/1574)) ### Changed + - Bump dependency on `@metamask/base-controller` to ^3.2.1 - Bump dependency on `@metamask/message-manager` to ^7.3.1 - Bump dependency and peer dependency on `@metamask/preferences-controller` to ^4.4.0 ## [7.1.0] + ### Added + - Add `getEncryptionPublicKey` method on KeyringController ([#1569](https://github.com/MetaMask/core/pull/1569)) ## [7.0.0] + ### Changed + - **BREAKING**: Remove `keyringTypes` property from the KeyringController state ([#1441](https://github.com/MetaMask/core/pull/1441)) - **BREAKING**: Constructor `KeyringControllerOptions` type changed ([#1441](https://github.com/MetaMask/core/pull/1441)) - The `KeyringControllerOptions.state` accepted type is now `{ vault?: string }` @@ -102,16 +922,21 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Update `@keystonehq/metamask-airgapped-keyring` to `^0.13.1` ([#1514](https://github.com/MetaMask/core/pull/1514)) ## [6.1.0] + ### Changed + - Bump @metamask/eth-sig-util to ^6.0.0 ([#1483](https://github.com/MetaMask/core/pull/1483)) ## [6.0.0] + ### Added + - Add messenger events `KeyringController:lock` and `KeyringController:unlock`, emitted when the inner EthKeyringController is locked/unlocked ([#1378](https://github.com/MetaMask/core/pull/1378)) - Also add corresponding types `KeyringControllerLockEvent` and `KeyringControllerUnlockEvent` - Add `KeyringController:accountRemoved` event, fired whenever an account is removed through `removeAccount` ([#1416](https://github.com/MetaMask/core/pull/1416)) ### Changed + - **BREAKING:** Update constructor to take a single argument, an options bag, instead of three arguments ([#1378](https://github.com/MetaMask/core/pull/1378)) - **BREAKING:** Update controller so state is now accessible via `controller.state` instead of `controller.store.getState()` ([#1378](https://github.com/MetaMask/core/pull/1378)) - **BREAKING:** Update KeyringController to take a required `messenger` option ([#1378](https://github.com/MetaMask/core/pull/1378)) @@ -138,6 +963,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Add `immer` as a dependency ([#1378](https://github.com/MetaMask/core/pull/1378)) ### Removed + - **BREAKING:** Remove `subscribe` and `unsubscribe` methods ([#1378](https://github.com/MetaMask/core/pull/1378)) - State changes can be directly subscribed to (or unsubscribed from) via the messenger if necessary - **BREAKING:** Remove `lock` and `unlock` methods ([#1378](https://github.com/MetaMask/core/pull/1378)) @@ -147,11 +973,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **BREAKING:** Remove `index` from the `Keyring` type ([#1378](https://github.com/MetaMask/core/pull/1378)) ## [5.1.0] + ### Added -- Add `cancelQRSynchronization` method ([#1387](https://github.com/MetaMask/core.git/pull/1387)) + +- Add `cancelQRSynchronization` method ([#1387](https://github.com/MetaMask/core/pull/1387)) ## [5.0.0] + ### Added + - Add support for encryption keys ([#1342](https://github.com/MetaMask/core/pull/1342)) - The configuration option `cacheEncryptionKey` has been added, along with two new state properties (`encryptionKey` and `encryptionSalt`) and a new method (`submitEncryptionKey`) - All new state and config entries are optional, so this will have no effect if you're not using this feature. @@ -160,9 +990,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Add deprecated `getKeyringForAccount` and `getKeyringsByType` methods ([#1376](https://github.com/MetaMask/core/pull/1376), [#1386](https://github.com/MetaMask/core/pull/1386)) ### Changed + - **BREAKING:** Bump to Node 16 ([#1262](https://github.com/MetaMask/core/pull/1262)) - **BREAKING:** Change return type of `createNewVaultAndRestore` from `string | number[]` to `Uint8Array` ([#1349](https://github.com/MetaMask/core/pull/1349)) -- **BREAKING:** Change return type of `verifySeedPhrase` from `string` to `Uint8Array` ([#1338](https://github.com/MetaMask/core/pull/1338)) +- **BREAKING:** Change return type of `verifySeedPhrase` from `string` to `Uint8Array` ([#1338](https://github.com/MetaMask/core/pull/1338)) - **BREAKING:** Replace `validatePassword` with `verifyPassword` ([#1348](https://github.com/MetaMask/core/pull/1348)) - `verifyPassword` is asynchronous, unlike `validatePassword` which was not. - `verifyPassword` does not return a boolean to indicate whether the password is valid. Instead an error is thrown when it's invalid. @@ -180,6 +1011,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Update `@metamask/preferences-controller` dependency ### Fixed + - Improve validation of `from` address in `signTypedMessage` ([#1293](https://github.com/MetaMask/core/pull/1293)) - Improve private key validation in `importAccountWithStrategy` ([#1297](https://github.com/MetaMask/core/pull/1297)) - A more helpful error is now thrown when the given private key has the wrong length @@ -188,37 +1020,111 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - The old behavior was especially confusing because the `subscribe` method is overridden to return state change events from the internal `EthKeyingController` state, resulting in state change events being out of sync with controller state. They should be the same now. ## [4.0.0] + ### Removed + - **BREAKING:** Remove `isomorphic-fetch` ([#1106](https://github.com/MetaMask/controllers/pull/1106)) - Consumers must now import `isomorphic-fetch` or another polyfill themselves if they are running in an environment without `fetch` ## [3.0.0] + ### Changed -- **BREAKING:**: Bump eth-keyring-controller version to @metamask/eth-keyring-controller v10 ([#1072](https://github.com/MetaMask/core.git/pull/1072)) - - `exportSeedPhrase` now returns a `Uint8Array` typed SRP (can be converted to a string using [this approach](https://github.com/MetaMask/eth-hd-keyring/blob/53b0570559595ba5b3fd8c80e900d847cd6dee3d/index.js#L40)). It was previously a Buffer. + +- **BREAKING:**: Bump eth-keyring-controller version to @metamask/eth-keyring-controller v10 ([#1072](https://github.com/MetaMask/core/pull/1072)) + - `exportSeedPhrase` now returns a `Uint8Array` typed SRP (can be converted to a string using [this approach](https://github.com/MetaMask/eth-hd-keyring/blob/53b0570559595ba5b3fd8c80e900d847cd6dee3d/index.js#L40)). It was previously a Buffer. - The HD keyring included with the keyring controller has been updated from v4 to v6. See [the `eth-hd-keyring` changelog entries for v5 and v6](https://github.com/MetaMask/eth-hd-keyring/blob/main/CHANGELOG.md#600) for further details on breaking changes. ## [2.0.0] + ### Changed + - **BREAKING:**: Require ES2020 support or greater ([#914](https://github.com/MetaMask/controllers/pull/914)) - - This change was introduced by an indirect dependency on `ethereumjs/util` v8 + - This change was introduced by an indirect dependency on `ethereumjs/util` v8 - Rename this repository to `core` ([#1031](https://github.com/MetaMask/controllers/pull/1031)) - Update `@metamask/eth-sig-util` to v5 ([#914](https://github.com/MetaMask/controllers/pull/914)) - Update `@metamask/controller-utils` package ([#1041](https://github.com/MetaMask/controllers/pull/1041)) ## [1.0.1] + ### Changed + - Relax dependencies on `@metamask/base-controller`, `@metamask/controller-utils`, `@metamask/message-manager`, and `@metamask/preferences-controller` (use `^` instead of `~`) ([#998](https://github.com/MetaMask/core/pull/998)) ## [1.0.0] + ### Added + - Initial release - As a result of converting our shared controllers repo into a monorepo ([#831](https://github.com/MetaMask/core/pull/831)), we've created this package from select parts of [`@metamask/controllers` v33.0.0](https://github.com/MetaMask/core/tree/v33.0.0), namely: - Everything in `src/keyring` All changes listed after this point were applied to this package following the monorepo conversion. -[Unreleased]: https://github.com/MetaMask/core/compare/@metamask/keyring-controller@8.0.3...HEAD +[Unreleased]: https://github.com/MetaMask/core/compare/@metamask/keyring-controller@27.1.1...HEAD +[27.1.1]: https://github.com/MetaMask/core/compare/@metamask/keyring-controller@27.1.0...@metamask/keyring-controller@27.1.1 +[27.1.0]: https://github.com/MetaMask/core/compare/@metamask/keyring-controller@27.0.0...@metamask/keyring-controller@27.1.0 +[27.0.0]: https://github.com/MetaMask/core/compare/@metamask/keyring-controller@26.0.0...@metamask/keyring-controller@27.0.0 +[26.0.0]: https://github.com/MetaMask/core/compare/@metamask/keyring-controller@25.5.0...@metamask/keyring-controller@26.0.0 +[25.5.0]: https://github.com/MetaMask/core/compare/@metamask/keyring-controller@25.4.0...@metamask/keyring-controller@25.5.0 +[25.4.0]: https://github.com/MetaMask/core/compare/@metamask/keyring-controller@25.3.0...@metamask/keyring-controller@25.4.0 +[25.3.0]: https://github.com/MetaMask/core/compare/@metamask/keyring-controller@25.2.0...@metamask/keyring-controller@25.3.0 +[25.2.0]: https://github.com/MetaMask/core/compare/@metamask/keyring-controller@25.1.1...@metamask/keyring-controller@25.2.0 +[25.1.1]: https://github.com/MetaMask/core/compare/@metamask/keyring-controller@25.1.0...@metamask/keyring-controller@25.1.1 +[25.1.0]: https://github.com/MetaMask/core/compare/@metamask/keyring-controller@25.0.0...@metamask/keyring-controller@25.1.0 +[25.0.0]: https://github.com/MetaMask/core/compare/@metamask/keyring-controller@24.0.0...@metamask/keyring-controller@25.0.0 +[24.0.0]: https://github.com/MetaMask/core/compare/@metamask/keyring-controller@23.2.0...@metamask/keyring-controller@24.0.0 +[23.2.0]: https://github.com/MetaMask/core/compare/@metamask/keyring-controller@23.1.1...@metamask/keyring-controller@23.2.0 +[23.1.1]: https://github.com/MetaMask/core/compare/@metamask/keyring-controller@23.1.0...@metamask/keyring-controller@23.1.1 +[23.1.0]: https://github.com/MetaMask/core/compare/@metamask/keyring-controller@23.0.0...@metamask/keyring-controller@23.1.0 +[23.0.0]: https://github.com/MetaMask/core/compare/@metamask/keyring-controller@22.1.1...@metamask/keyring-controller@23.0.0 +[22.1.1]: https://github.com/MetaMask/core/compare/@metamask/keyring-controller@22.1.0...@metamask/keyring-controller@22.1.1 +[22.1.0]: https://github.com/MetaMask/core/compare/@metamask/keyring-controller@22.0.2...@metamask/keyring-controller@22.1.0 +[22.0.2]: https://github.com/MetaMask/core/compare/@metamask/keyring-controller@22.0.1...@metamask/keyring-controller@22.0.2 +[22.0.1]: https://github.com/MetaMask/core/compare/@metamask/keyring-controller@22.0.0...@metamask/keyring-controller@22.0.1 +[22.0.0]: https://github.com/MetaMask/core/compare/@metamask/keyring-controller@21.0.6...@metamask/keyring-controller@22.0.0 +[21.0.6]: https://github.com/MetaMask/core/compare/@metamask/keyring-controller@21.0.5...@metamask/keyring-controller@21.0.6 +[21.0.5]: https://github.com/MetaMask/core/compare/@metamask/keyring-controller@21.0.4...@metamask/keyring-controller@21.0.5 +[21.0.4]: https://github.com/MetaMask/core/compare/@metamask/keyring-controller@21.0.3...@metamask/keyring-controller@21.0.4 +[21.0.3]: https://github.com/MetaMask/core/compare/@metamask/keyring-controller@21.0.2...@metamask/keyring-controller@21.0.3 +[21.0.2]: https://github.com/MetaMask/core/compare/@metamask/keyring-controller@21.0.1...@metamask/keyring-controller@21.0.2 +[21.0.1]: https://github.com/MetaMask/core/compare/@metamask/keyring-controller@21.0.0...@metamask/keyring-controller@21.0.1 +[21.0.0]: https://github.com/MetaMask/core/compare/@metamask/keyring-controller@20.0.0...@metamask/keyring-controller@21.0.0 +[20.0.0]: https://github.com/MetaMask/core/compare/@metamask/keyring-controller@19.2.2...@metamask/keyring-controller@20.0.0 +[19.2.2]: https://github.com/MetaMask/core/compare/@metamask/keyring-controller@19.2.1...@metamask/keyring-controller@19.2.2 +[19.2.1]: https://github.com/MetaMask/core/compare/@metamask/keyring-controller@19.2.0...@metamask/keyring-controller@19.2.1 +[19.2.0]: https://github.com/MetaMask/core/compare/@metamask/keyring-controller@19.1.0...@metamask/keyring-controller@19.2.0 +[19.1.0]: https://github.com/MetaMask/core/compare/@metamask/keyring-controller@19.0.7...@metamask/keyring-controller@19.1.0 +[19.0.7]: https://github.com/MetaMask/core/compare/@metamask/keyring-controller@19.0.6...@metamask/keyring-controller@19.0.7 +[19.0.6]: https://github.com/MetaMask/core/compare/@metamask/keyring-controller@19.0.5...@metamask/keyring-controller@19.0.6 +[19.0.5]: https://github.com/MetaMask/core/compare/@metamask/keyring-controller@19.0.4...@metamask/keyring-controller@19.0.5 +[19.0.4]: https://github.com/MetaMask/core/compare/@metamask/keyring-controller@19.0.3...@metamask/keyring-controller@19.0.4 +[19.0.3]: https://github.com/MetaMask/core/compare/@metamask/keyring-controller@19.0.2...@metamask/keyring-controller@19.0.3 +[19.0.2]: https://github.com/MetaMask/core/compare/@metamask/keyring-controller@19.0.1...@metamask/keyring-controller@19.0.2 +[19.0.1]: https://github.com/MetaMask/core/compare/@metamask/keyring-controller@19.0.0...@metamask/keyring-controller@19.0.1 +[19.0.0]: https://github.com/MetaMask/core/compare/@metamask/keyring-controller@18.0.0...@metamask/keyring-controller@19.0.0 +[18.0.0]: https://github.com/MetaMask/core/compare/@metamask/keyring-controller@17.3.1...@metamask/keyring-controller@18.0.0 +[17.3.1]: https://github.com/MetaMask/core/compare/@metamask/keyring-controller@17.3.0...@metamask/keyring-controller@17.3.1 +[17.3.0]: https://github.com/MetaMask/core/compare/@metamask/keyring-controller@17.2.2...@metamask/keyring-controller@17.3.0 +[17.2.2]: https://github.com/MetaMask/core/compare/@metamask/keyring-controller@17.2.1...@metamask/keyring-controller@17.2.2 +[17.2.1]: https://github.com/MetaMask/core/compare/@metamask/keyring-controller@17.2.0...@metamask/keyring-controller@17.2.1 +[17.2.0]: https://github.com/MetaMask/core/compare/@metamask/keyring-controller@17.1.2...@metamask/keyring-controller@17.2.0 +[17.1.2]: https://github.com/MetaMask/core/compare/@metamask/keyring-controller@17.1.1...@metamask/keyring-controller@17.1.2 +[17.1.1]: https://github.com/MetaMask/core/compare/@metamask/keyring-controller@17.1.0...@metamask/keyring-controller@17.1.1 +[17.1.0]: https://github.com/MetaMask/core/compare/@metamask/keyring-controller@17.0.0...@metamask/keyring-controller@17.1.0 +[17.0.0]: https://github.com/MetaMask/core/compare/@metamask/keyring-controller@16.1.0...@metamask/keyring-controller@17.0.0 +[16.1.0]: https://github.com/MetaMask/core/compare/@metamask/keyring-controller@16.0.0...@metamask/keyring-controller@16.1.0 +[16.0.0]: https://github.com/MetaMask/core/compare/@metamask/keyring-controller@15.0.0...@metamask/keyring-controller@16.0.0 +[15.0.0]: https://github.com/MetaMask/core/compare/@metamask/keyring-controller@14.0.1...@metamask/keyring-controller@15.0.0 +[14.0.1]: https://github.com/MetaMask/core/compare/@metamask/keyring-controller@14.0.0...@metamask/keyring-controller@14.0.1 +[14.0.0]: https://github.com/MetaMask/core/compare/@metamask/keyring-controller@13.0.0...@metamask/keyring-controller@14.0.0 +[13.0.0]: https://github.com/MetaMask/core/compare/@metamask/keyring-controller@12.2.0...@metamask/keyring-controller@13.0.0 +[12.2.0]: https://github.com/MetaMask/core/compare/@metamask/keyring-controller@12.1.0...@metamask/keyring-controller@12.2.0 +[12.1.0]: https://github.com/MetaMask/core/compare/@metamask/keyring-controller@12.0.0...@metamask/keyring-controller@12.1.0 +[12.0.0]: https://github.com/MetaMask/core/compare/@metamask/keyring-controller@11.0.0...@metamask/keyring-controller@12.0.0 +[11.0.0]: https://github.com/MetaMask/core/compare/@metamask/keyring-controller@10.0.0...@metamask/keyring-controller@11.0.0 +[10.0.0]: https://github.com/MetaMask/core/compare/@metamask/keyring-controller@9.0.0...@metamask/keyring-controller@10.0.0 +[9.0.0]: https://github.com/MetaMask/core/compare/@metamask/keyring-controller@8.1.0...@metamask/keyring-controller@9.0.0 +[8.1.0]: https://github.com/MetaMask/core/compare/@metamask/keyring-controller@8.0.3...@metamask/keyring-controller@8.1.0 [8.0.3]: https://github.com/MetaMask/core/compare/@metamask/keyring-controller@8.0.2...@metamask/keyring-controller@8.0.3 [8.0.2]: https://github.com/MetaMask/core/compare/@metamask/keyring-controller@8.0.1...@metamask/keyring-controller@8.0.2 [8.0.1]: https://github.com/MetaMask/core/compare/@metamask/keyring-controller@8.0.0...@metamask/keyring-controller@8.0.1 diff --git a/packages/keyring-controller/LICENSE b/packages/keyring-controller/LICENSE index ddfbecf9020..bbed2e24b91 100644 --- a/packages/keyring-controller/LICENSE +++ b/packages/keyring-controller/LICENSE @@ -18,3 +18,4 @@ 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/keyring-controller/jest.config.js b/packages/keyring-controller/jest.config.js index ca084133399..01ada25d53f 100644 --- a/packages/keyring-controller/jest.config.js +++ b/packages/keyring-controller/jest.config.js @@ -17,10 +17,13 @@ module.exports = merge(baseConfig, { // An object that configures minimum threshold enforcement for coverage results coverageThreshold: { global: { - branches: 100, + branches: 95.9, functions: 100, - lines: 100, - statements: 100, + lines: 99.17, + statements: 99.18, }, }, + + // These tests rely on the Crypto API + testEnvironment: '/jest.environment.js', }); diff --git a/packages/keyring-controller/jest.environment.js b/packages/keyring-controller/jest.environment.js new file mode 100644 index 00000000000..abe104a6a81 --- /dev/null +++ b/packages/keyring-controller/jest.environment.js @@ -0,0 +1,18 @@ +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 TestEnvironment { + async setup() { + await super.setup(); + if (typeof this.global.crypto === 'undefined') { + // Only used for testing. + // eslint-disable-next-line n/no-unsupported-features/node-builtins + this.global.crypto = require('crypto').webcrypto; + } + } +} + +module.exports = CustomTestEnvironment; diff --git a/packages/keyring-controller/package.json b/packages/keyring-controller/package.json index 398404c5cc9..8d4c0b2597b 100644 --- a/packages/keyring-controller/package.json +++ b/packages/keyring-controller/package.json @@ -1,70 +1,105 @@ { "name": "@metamask/keyring-controller", - "version": "8.0.3", + "version": "27.1.1", "description": "Stores identities seen in the wallet and manages interactions such as signing", "keywords": [ - "MetaMask", - "Ethereum" + "Ethereum", + "MetaMask" ], "homepage": "https://github.com/MetaMask/core/tree/main/packages/keyring-controller#readme", "bugs": { "url": "https://github.com/MetaMask/core/issues" }, + "license": "MIT", "repository": { "type": "git", "url": "https://github.com/MetaMask/core.git" }, - "license": "MIT", - "main": "./dist/index.js", - "types": "./dist/index.d.ts", "files": [ "dist/" ], + "sideEffects": false, + "main": "./dist/index.cjs", + "types": "./dist/index.d.cts", + "exports": { + ".": { + "import": { + "types": "./dist/index.d.mts", + "default": "./dist/index.mjs" + }, + "require": { + "types": "./dist/index.d.cts", + "default": "./dist/index.cjs" + } + }, + "./package.json": "./package.json" + }, + "publishConfig": { + "access": "public", + "registry": "https://registry.npmjs.org/" + }, "scripts": { + "build": "ts-bridge --project tsconfig.build.json --verbose --clean --no-references", + "build:all": "ts-bridge --project tsconfig.build.json --verbose --clean", "build:docs": "typedoc", + "changelog:update": "../../scripts/update-changelog.sh @metamask/keyring-controller", "changelog:validate": "../../scripts/validate-changelog.sh @metamask/keyring-controller", - "publish:preview": "yarn npm publish --tag preview", - "test": "jest", - "test:clean": "jest --clearCache", - "test:watch": "jest --watch" + "lint:tsconfigs": "tsx ../../scripts/lint-tsconfigs/lint-tsconfigs.mts", + "lint:tsconfigs:fix": "tsx ../../scripts/lint-tsconfigs/lint-tsconfigs.mts --fix", + "messenger-action-types:check": "tsx ../../packages/messenger-cli/src/cli.ts --formatter oxfmt --esm --check", + "messenger-action-types:generate": "tsx ../../packages/messenger-cli/src/cli.ts --formatter oxfmt --esm --generate", + "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": { - "@keystonehq/metamask-airgapped-keyring": "^0.13.1", - "@metamask/base-controller": "^3.2.3", - "@metamask/eth-keyring-controller": "^13.0.1", - "@metamask/message-manager": "^7.3.5", - "@metamask/preferences-controller": "^4.4.3", - "@metamask/utils": "^8.1.0", - "async-mutex": "^0.2.6", - "ethereumjs-util": "^7.0.10", + "@ethereumjs/util": "^9.1.0", + "@metamask/base-controller": "^9.1.0", + "@metamask/browser-passworder": "^6.0.0", + "@metamask/controller-utils": "^12.3.0", + "@metamask/eth-hd-keyring": "^15.0.0", + "@metamask/eth-sig-util": "^9.0.0", + "@metamask/eth-simple-keyring": "^13.0.0", + "@metamask/keyring-api": "^24.0.0", + "@metamask/keyring-internal-api": "^12.0.0", + "@metamask/messenger": "^2.0.0", + "@metamask/utils": "^11.11.0", + "async-mutex": "^0.5.0", "ethereumjs-wallet": "^1.0.1", - "immer": "^9.0.6" + "immer": "^9.0.6", + "lodash": "^4.17.21", + "ulid": "^2.3.0" }, "devDependencies": { - "@ethereumjs/common": "^3.2.0", - "@ethereumjs/tx": "^4.2.0", - "@keystonehq/bc-ur-registry-eth": "^0.9.0", - "@metamask/auto-changelog": "^3.1.0", - "@metamask/eth-sig-util": "^7.0.0", - "@metamask/scure-bip39": "^2.1.0", - "@types/jest": "^27.4.1", + "@ethereumjs/common": "^4.4.0", + "@ethereumjs/tx": "^5.4.0", + "@lavamoat/allow-scripts": "^3.0.4", + "@lavamoat/preinstall-always-fail": "^2.1.0", + "@metamask/auto-changelog": "^6.1.0", + "@metamask/keyring-utils": "^5.0.0", + "@metamask/scure-bip39": "^2.1.1", + "@ts-bridge/cli": "^0.6.4", + "@types/jest": "^30.0.0", "deepmerge": "^4.2.2", - "jest": "^27.5.1", - "sinon": "^9.2.4", - "ts-jest": "^27.1.4", - "typedoc": "^0.24.8", + "jest": "^30.4.2", + "jest-environment-node": "^30.4.1", + "ts-jest": "^29.4.11", + "tsx": "^4.20.5", + "typedoc": "^0.25.13", "typedoc-plugin-missing-exports": "^2.0.0", - "typescript": "~4.8.4", + "typescript": "~5.3.3", "uuid": "^8.3.2" }, - "peerDependencies": { - "@metamask/preferences-controller": "^4.4.3" - }, "engines": { - "node": ">=16.0.0" + "node": "^18.18 || >=20" }, - "publishConfig": { - "access": "public", - "registry": "https://registry.npmjs.org/" + "lavamoat": { + "allowScripts": { + "@lavamoat/preinstall-always-fail": false, + "ethereumjs-wallet>ethereum-cryptography>keccak": false, + "ethereumjs-wallet>ethereum-cryptography>secp256k1": false + } } } diff --git a/packages/keyring-controller/src/KeyringController-method-action-types.ts b/packages/keyring-controller/src/KeyringController-method-action-types.ts new file mode 100644 index 00000000000..8b8bf1d764f --- /dev/null +++ b/packages/keyring-controller/src/KeyringController-method-action-types.ts @@ -0,0 +1,562 @@ +/** + * This file is auto generated. + * Do not edit manually. + */ + +import type { KeyringController } from './KeyringController.js'; + +/** + * Adds a new account to the default (first) HD seed phrase keyring. + * + * @param accountCount - Number of accounts before adding a new one, used to + * make the method idempotent. + * @returns Promise resolving to the added account address. + */ +export type KeyringControllerAddNewAccountAction = { + type: `KeyringController:addNewAccount`; + handler: KeyringController['addNewAccount']; +}; + +/** + * Effectively the same as creating a new keychain then populating it + * using the given seed phrase. + * + * @param password - Password to unlock keychain. + * @param seed - A BIP39-compliant seed phrase as Uint8Array, + * either as a string or an array of UTF-8 bytes that represent the string. + * @returns Promise resolving when the operation ends successfully. + */ +export type KeyringControllerCreateNewVaultAndRestoreAction = { + type: `KeyringController:createNewVaultAndRestore`; + handler: KeyringController['createNewVaultAndRestore']; +}; + +/** + * Create a new vault and primary keyring. + * + * This only works if keyrings are empty. If there is a pre-existing unlocked vault, calling this will have no effect. + * If there is a pre-existing locked vault, it will be replaced. + * + * @param password - Password to unlock the new vault. + * @returns Promise resolving when the operation ends successfully. + */ +export type KeyringControllerCreateNewVaultAndKeychainAction = { + type: `KeyringController:createNewVaultAndKeychain`; + handler: KeyringController['createNewVaultAndKeychain']; +}; + +/** + * Adds a new keyring of the given `type`. + * + * @param type - Keyring type name. + * @param opts - Keyring options. + * @throws If a builder for the given `type` does not exist. + * @returns Promise resolving to the new keyring metadata. + */ +export type KeyringControllerAddNewKeyringAction = { + type: `KeyringController:addNewKeyring`; + handler: KeyringController['addNewKeyring']; +}; + +/** + * Method to verify a given password validity. Throws an + * error if the password is invalid. + * + * @param password - Password of the keyring. + */ +export type KeyringControllerVerifyPasswordAction = { + type: `KeyringController:verifyPassword`; + handler: KeyringController['verifyPassword']; +}; + +/** + * Returns the status of the vault. + * + * @returns Boolean returning true if the vault is unlocked. + */ +export type KeyringControllerIsUnlockedAction = { + type: `KeyringController:isUnlocked`; + handler: KeyringController['isUnlocked']; +}; + +/** + * Gets the seed phrase of the HD keyring. + * + * @param credentials - Object holding either the `password` or the vault + * `encryptionKey`. + * @param keyringId - The id of the keyring. + * @returns Promise resolving to the seed phrase. + */ +export type KeyringControllerExportSeedPhraseAction = { + type: `KeyringController:exportSeedPhrase`; + handler: KeyringController['exportSeedPhrase']; +}; + +/** + * Gets the private key from the keyring controlling an address. + * + * @param credentials - Object holding either the `password` or the vault + * `encryptionKey`. + * @param address - Address to export. + * @returns Promise resolving to the private key for an address. + */ +export type KeyringControllerExportAccountAction = { + type: `KeyringController:exportAccount`; + handler: KeyringController['exportAccount']; +}; + +/** + * Returns the public addresses of all accounts from every keyring. + * + * @returns A promise resolving to an array of addresses. + */ +export type KeyringControllerGetAccountsAction = { + type: `KeyringController:getAccounts`; + handler: KeyringController['getAccounts']; +}; + +/** + * Get encryption public key. + * + * @param account - An account address. + * @param opts - Additional encryption options. + * @throws If the `account` does not exist or does not support the `getEncryptionPublicKey` method + * @returns Promise resolving to encyption public key of the `account` if one exists. + */ +export type KeyringControllerGetEncryptionPublicKeyAction = { + type: `KeyringController:getEncryptionPublicKey`; + handler: KeyringController['getEncryptionPublicKey']; +}; + +/** + * Attempts to decrypt the provided message parameters. + * + * @param messageParams - The decryption message parameters. + * @param messageParams.from - The address of the account you want to use to decrypt the message. + * @param messageParams.data - The encrypted data that you want to decrypt. + * @returns The raw decryption result. + */ +export type KeyringControllerDecryptMessageAction = { + type: `KeyringController:decryptMessage`; + handler: KeyringController['decryptMessage']; +}; + +/** + * Returns the currently initialized keyring that manages + * the specified `address` if one exists. + * + * @deprecated Use of this method is discouraged as actions executed directly on + * keyrings are not being reflected in the KeyringController state and not + * persisted in the vault. Use `withKeyring` instead. + * @param account - An account address. + * @returns Promise resolving to keyring of the `account` if one exists. + */ +export type KeyringControllerGetKeyringForAccountAction = { + type: `KeyringController:getKeyringForAccount`; + handler: KeyringController['getKeyringForAccount']; +}; + +/** + * Returns all keyrings of the given type. + * + * @deprecated Use of this method is discouraged as actions executed directly on + * keyrings are not being reflected in the KeyringController state and not + * persisted in the vault. Use `withKeyring` instead. + * @param type - Keyring type name. + * @returns An array of keyrings of the given type. + */ +export type KeyringControllerGetKeyringsByTypeAction = { + type: `KeyringController:getKeyringsByType`; + handler: KeyringController['getKeyringsByType']; +}; + +/** + * Persist all serialized keyrings in the vault. + * + * @deprecated This method is being phased out in favor of `withKeyring`. + * @returns Promise resolving with `true` value when the + * operation completes. + */ +export type KeyringControllerPersistAllKeyringsAction = { + type: `KeyringController:persistAllKeyrings`; + handler: KeyringController['persistAllKeyrings']; +}; + +/** + * Imports an account with the specified import strategy. + * + * @param strategy - Import strategy name. + * @param args - Array of arguments to pass to the underlying stategy. + * @throws Will throw when passed an unrecognized strategy. + * @returns Promise resolving to the imported account address. + */ +export type KeyringControllerImportAccountWithStrategyAction = { + type: `KeyringController:importAccountWithStrategy`; + handler: KeyringController['importAccountWithStrategy']; +}; + +/** + * Removes an account from keyring state. + * + * @param address - Address of the account to remove. + * @fires KeyringController:accountRemoved + * @returns Promise resolving when the account is removed. + */ +export type KeyringControllerRemoveAccountAction = { + type: `KeyringController:removeAccount`; + handler: KeyringController['removeAccount']; +}; + +/** + * Deallocates all secrets and locks the wallet. + * + * @returns Promise resolving when the operation completes. + */ +export type KeyringControllerSetLockedAction = { + type: `KeyringController:setLocked`; + handler: KeyringController['setLocked']; +}; + +/** + * Signs message by calling down into a specific keyring. + * + * @param messageParams - PersonalMessageParams object to sign. + * @returns Promise resolving to a signed message string. + */ +export type KeyringControllerSignMessageAction = { + type: `KeyringController:signMessage`; + handler: KeyringController['signMessage']; +}; + +/** + * Signs EIP-7702 Authorization message by calling down into a specific keyring. + * + * @param params - EIP7702AuthorizationParams object to sign. + * @returns Promise resolving to an EIP-7702 Authorization signature. + * @throws Will throw UnsupportedSignEIP7702Authorization if the keyring does not support signing EIP-7702 Authorization messages. + */ +export type KeyringControllerSignEip7702AuthorizationAction = { + type: `KeyringController:signEip7702Authorization`; + handler: KeyringController['signEip7702Authorization']; +}; + +/** + * Signs personal message by calling down into a specific keyring. + * + * @param messageParams - PersonalMessageParams object to sign. + * @returns Promise resolving to a signed message string. + */ +export type KeyringControllerSignPersonalMessageAction = { + type: `KeyringController:signPersonalMessage`; + handler: KeyringController['signPersonalMessage']; +}; + +/** + * Signs typed message by calling down into a specific keyring. + * + * @param messageParams - TypedMessageParams object to sign. + * @param version - Compatibility version EIP712. + * @throws Will throw when passed an unrecognized version. + * @returns Promise resolving to a signed message string or an error if any. + */ +export type KeyringControllerSignTypedMessageAction = { + type: `KeyringController:signTypedMessage`; + handler: KeyringController['signTypedMessage']; +}; + +/** + * Signs a transaction by calling down into a specific keyring. + * + * @param transaction - Transaction object to sign. Must be a `ethereumjs-tx` transaction instance. + * @param from - Address to sign from, should be in keychain. + * @param opts - An optional options object. + * @returns Promise resolving to a signed transaction string. + */ +export type KeyringControllerSignTransactionAction = { + type: `KeyringController:signTransaction`; + handler: KeyringController['signTransaction']; +}; + +/** + * Convert a base transaction to a base UserOperation. + * + * @param from - Address of the sender. + * @param transactions - Base transactions to include in the UserOperation. + * @param executionContext - The execution context to use for the UserOperation. + * @returns A pseudo-UserOperation that can be used to construct a real. + */ +export type KeyringControllerPrepareUserOperationAction = { + type: `KeyringController:prepareUserOperation`; + handler: KeyringController['prepareUserOperation']; +}; + +/** + * Patches properties of a UserOperation. Currently, only the + * `paymasterAndData` can be patched. + * + * @param from - Address of the sender. + * @param userOp - UserOperation to patch. + * @param executionContext - The execution context to use for the UserOperation. + * @returns A patch to apply to the UserOperation. + */ +export type KeyringControllerPatchUserOperationAction = { + type: `KeyringController:patchUserOperation`; + handler: KeyringController['patchUserOperation']; +}; + +/** + * Signs an UserOperation. + * + * @param from - Address of the sender. + * @param userOp - UserOperation to sign. + * @param executionContext - The execution context to use for the UserOperation. + * @returns The signature of the UserOperation. + */ +export type KeyringControllerSignUserOperationAction = { + type: `KeyringController:signUserOperation`; + handler: KeyringController['signUserOperation']; +}; + +/** + * Changes the password used to encrypt the vault. + * + * @param password - The new password. + * @returns Promise resolving when the operation completes. + */ +export type KeyringControllerChangePasswordAction = { + type: `KeyringController:changePassword`; + handler: KeyringController['changePassword']; +}; + +/** + * Attempts to decrypt the current vault and load its keyrings, using the + * given encryption key and salt. The optional salt can be used to check for + * consistency with the vault salt. + * + * @param encryptionKey - Key to unlock the keychain. + * @param encryptionSalt - Optional salt to unlock the keychain. + * @returns Promise resolving when the operation completes. + */ +export type KeyringControllerSubmitEncryptionKeyAction = { + type: `KeyringController:submitEncryptionKey`; + handler: KeyringController['submitEncryptionKey']; +}; + +/** + * Exports the vault encryption key. + * + * @returns The vault encryption key. + */ +export type KeyringControllerExportEncryptionKeyAction = { + type: `KeyringController:exportEncryptionKey`; + handler: KeyringController['exportEncryptionKey']; +}; + +/** + * Attempts to decrypt the current vault and load its keyrings, + * using the given password. + * + * @param password - Password to unlock the keychain. + * @returns Promise resolving when the operation completes. + */ +export type KeyringControllerSubmitPasswordAction = { + type: `KeyringController:submitPassword`; + handler: KeyringController['submitPassword']; +}; + +/** + * Select a keyring and execute the given operation with + * the selected keyring, as a mutually exclusive atomic + * operation. + * + * The method automatically persists changes at the end of the + * function execution, or rolls back the changes if an error + * is thrown. + * + * @param selector - Keyring selector object. + * @param operation - Function to execute with the selected keyring. + * @param options - Additional options. + * @param options.createIfMissing - Whether to create a new keyring if the selected one is missing. + * @param options.createWithData - Optional data to use when creating a new keyring. + * @returns Promise resolving to the result of the function execution. + * @template SelectedKeyring - The type of the selected keyring. + * @template CallbackResult - The type of the value resolved by the callback function. + * @deprecated This method overload is deprecated. Use `withKeyring` without options instead. + */ +export type KeyringControllerWithKeyringAction = { + type: `KeyringController:withKeyring`; + handler: KeyringController['withKeyring']; +}; + +/** + * Select a keyring and execute the given operation with the selected + * keyring, **without** acquiring the controller's mutual exclusion lock. + * + * ## When to use this method + * + * This method is an escape hatch for read-only access to keyring data that + * is immutable once the keyring is initialized. A typical safe use case is + * reading the `mnemonic` from an `HdKeyring`: the mnemonic is set during + * `deserialize()` and never mutated afterwards, so it can safely be read + * without holding the lock. + * + * ## Why it is "unsafe" + * + * The "unsafe" designation mirrors the semantics of `unsafe { }` blocks in + * Rust: the method itself does not enforce thread-safety guarantees. By + * calling this method the **caller** explicitly takes responsibility for + * ensuring that: + * + * - The operation is **read-only** — no state is mutated. + * - The data being read is **immutable** after the keyring is initialized, + * so concurrent locked operations cannot alter it while this callback + * runs. + * + * Do **not** use this method to: + * - Mutate keyring state (add accounts, sign, etc.) — use `withKeyring`. + * - Read mutable fields that could change during concurrent operations. + * + * @param selector - Keyring selector object. + * @param operation - Read-only function to execute with the selected keyring. + * @returns Promise resolving to the result of the function execution. + * @template SelectedKeyring - The type of the selected keyring. + * @template CallbackResult - The type of the value resolved by the callback function. + */ +export type KeyringControllerWithKeyringUnsafeAction = { + type: `KeyringController:withKeyringUnsafe`; + handler: KeyringController['withKeyringUnsafe']; +}; + +/** + * Select a keyring using its `KeyringV2` adapter, and execute + * the given operation with the wrapped keyring as a mutually + * exclusive atomic operation. + * + * The cached `KeyringV2` adapter is retrieved from the keyring + * entry. + * + * A `KeyringV2Builder` for the selected keyring's type must exist + * (either as a default or registered via the `keyringV2Builders` + * constructor option); otherwise an error is thrown. + * + * The method automatically persists changes at the end of the + * function execution, or rolls back the changes if an error + * is thrown. + * + * @param selector - Keyring selector object. + * @param operation - Function to execute with the wrapped V2 keyring. + * @returns Promise resolving to the result of the function execution. + * @template CallbackResult - The type of the value resolved by the callback function. + */ +export type KeyringControllerWithKeyringV2Action = { + type: `KeyringController:withKeyringV2`; + handler: KeyringController['withKeyringV2']; +}; + +/** + * Select a keyring, wrap it in a `KeyringV2` adapter, and execute + * the given read-only operation **without** acquiring the controller's + * mutual exclusion lock. + * + * ## When to use this method + * + * This method is an escape hatch for read-only access to keyring data that + * is immutable once the keyring is initialized. A typical safe use case is + * reading immutable fields from a `KeyringV2` adapter: data that is set + * during initialization and never mutated afterwards. + * + * ## Why it is "unsafe" + * + * The "unsafe" designation mirrors the semantics of `unsafe { }` blocks in + * Rust: the method itself does not enforce thread-safety guarantees. By + * calling this method the **caller** explicitly takes responsibility for + * ensuring that: + * + * - The operation is **read-only** — no state is mutated. + * - The data being read is **immutable** after the keyring is initialized, + * so concurrent locked operations cannot alter it while this callback + * runs. + * + * Do **not** use this method to: + * - Mutate keyring state (add accounts, sign, etc.) — use `withKeyringV2`. + * - Read mutable fields that could change during concurrent operations. + * + * @param selector - Keyring selector object. + * @param operation - Read-only function to execute with the wrapped V2 keyring. + * @returns Promise resolving to the result of the function execution. + * @template SelectedKeyring - The type of the selected V2 keyring. + * @template CallbackResult - The type of the value resolved by the callback function. + */ +export type KeyringControllerWithKeyringV2UnsafeAction = { + type: `KeyringController:withKeyringV2Unsafe`; + handler: KeyringController['withKeyringV2Unsafe']; +}; + +/** + * Execute an operation against all keyrings as a mutually exclusive atomic + * operation. The operation receives a {@link RestrictedController} instance + * that exposes a read-only live view of all keyrings as well as + * `addNewKeyring` and `removeKeyring` methods to stage mutations. + * + * The method automatically persists changes at the end of the function + * execution, or rolls back the changes if an error is thrown. + * + * @param operation - Function to execute with the restricted controller. + * @returns Promise resolving to the result of the function execution. + * @template CallbackResult - The type of the value resolved by the callback function. + */ +export type KeyringControllerWithControllerAction = { + type: `KeyringController:withController`; + handler: KeyringController['withController']; +}; + +/** + * Gets the type of the keyring that manages the specified account. + * + * @param account - The account address to look up. + * @returns A promise that resolves to the type of the keyring managing the account. + */ +export type KeyringControllerGetAccountKeyringTypeAction = { + type: `KeyringController:getAccountKeyringType`; + handler: KeyringController['getAccountKeyringType']; +}; + +/** + * Union of all KeyringController action types. + */ +export type KeyringControllerMethodActions = + | KeyringControllerAddNewAccountAction + | KeyringControllerCreateNewVaultAndRestoreAction + | KeyringControllerCreateNewVaultAndKeychainAction + | KeyringControllerAddNewKeyringAction + | KeyringControllerVerifyPasswordAction + | KeyringControllerIsUnlockedAction + | KeyringControllerExportSeedPhraseAction + | KeyringControllerExportAccountAction + | KeyringControllerGetAccountsAction + | KeyringControllerGetEncryptionPublicKeyAction + | KeyringControllerDecryptMessageAction + | KeyringControllerGetKeyringForAccountAction + | KeyringControllerGetKeyringsByTypeAction + | KeyringControllerPersistAllKeyringsAction + | KeyringControllerImportAccountWithStrategyAction + | KeyringControllerRemoveAccountAction + | KeyringControllerSetLockedAction + | KeyringControllerSignMessageAction + | KeyringControllerSignEip7702AuthorizationAction + | KeyringControllerSignPersonalMessageAction + | KeyringControllerSignTypedMessageAction + | KeyringControllerSignTransactionAction + | KeyringControllerPrepareUserOperationAction + | KeyringControllerPatchUserOperationAction + | KeyringControllerSignUserOperationAction + | KeyringControllerChangePasswordAction + | KeyringControllerSubmitEncryptionKeyAction + | KeyringControllerExportEncryptionKeyAction + | KeyringControllerSubmitPasswordAction + | KeyringControllerWithKeyringAction + | KeyringControllerWithKeyringUnsafeAction + | KeyringControllerWithKeyringV2Action + | KeyringControllerWithKeyringV2UnsafeAction + | KeyringControllerWithControllerAction + | KeyringControllerGetAccountKeyringTypeAction; diff --git a/packages/keyring-controller/src/KeyringController.test.ts b/packages/keyring-controller/src/KeyringController.test.ts index 2cc605a8d02..0b59dd6f068 100644 --- a/packages/keyring-controller/src/KeyringController.test.ts +++ b/packages/keyring-controller/src/KeyringController.test.ts @@ -1,46 +1,79 @@ import { Chain, Common, Hardfork } from '@ethereumjs/common'; +import type { TypedTxData } from '@ethereumjs/tx'; import { TransactionFactory } from '@ethereumjs/tx'; -import { CryptoHDKey, ETHSignature } from '@keystonehq/bc-ur-registry-eth'; -import { MetaMaskKeyring as QRKeyring } from '@keystonehq/metamask-airgapped-keyring'; -import { ControllerMessenger } from '@metamask/base-controller'; -import { keyringBuilderFactory } from '@metamask/eth-keyring-controller'; +import { deriveStateFromMetadata } from '@metamask/base-controller'; +import { HdKeyring } from '@metamask/eth-hd-keyring'; import { normalize, recoverPersonalSignature, recoverTypedSignature, SignTypedDataVersion, encrypt, + recoverEIP7702Authorization, } from '@metamask/eth-sig-util'; +import SimpleKeyring from '@metamask/eth-simple-keyring'; +import { KeyringType } from '@metamask/keyring-api/v2'; +import type { EthKeyring } from '@metamask/keyring-internal-api'; +import type { KeyringClass } from '@metamask/keyring-utils'; +import { MOCK_ANY_NAMESPACE, Messenger } from '@metamask/messenger'; +import type { + MessengerActions, + MessengerEvents, + MockAnyNamespace, +} from '@metamask/messenger'; import { wordlist } from '@metamask/scure-bip39/dist/wordlists/english'; +import { bytesToHex, isValidHexAddress } from '@metamask/utils'; +import type { Hex } from '@metamask/utils'; +import { Mutex } from 'async-mutex'; + +import MockEncryptor, { + DECRYPTION_ERROR, + MOCK_ENCRYPTION_KEY, + SALT, +} from '../tests/mocks/mockEncryptor.js'; +import { MockErc4337Keyring } from '../tests/mocks/mockErc4337Keyring.js'; import { - isValidHexAddress, - type Hex, - type Keyring, - type Json, -} from '@metamask/utils'; -import { bufferToHex } from 'ethereumjs-util'; -import * as sinon from 'sinon'; -import * as uuid from 'uuid'; - -import MockEncryptor, { mockKey } from '../tests/mocks/mockEncryptor'; -import MockShallowGetAccountsKeyring from '../tests/mocks/mockShallowGetAccountsKeyring'; + HardwareWalletError, + MockHardwareKeyring, +} from '../tests/mocks/mockHardwareKeyring.js'; +import { MockKeyring } from '../tests/mocks/mockKeyring.js'; +import MockShallowKeyring from '../tests/mocks/mockShallowKeyring.js'; +import { buildMockTransaction } from '../tests/mocks/mockTransaction.js'; +import { KeyringControllerErrorMessage } from './constants.js'; +import { KeyringControllerError } from './errors.js'; import type { KeyringControllerEvents, KeyringControllerMessenger, KeyringControllerState, KeyringControllerOptions, KeyringControllerActions, -} from './KeyringController'; + KeyringMetadata, + SerializedKeyring, + KeyringSelector, + KeyringSelectorV2, +} from './KeyringController.js'; import { AccountImportStrategy, KeyringController, KeyringTypes, -} from './KeyringController'; + isCustodyKeyring, + keyringBuilderFactory, +} from './KeyringController.js'; + +type AllKeyringControllerActions = MessengerActions; + +type AllKeyringControllerEvents = MessengerEvents; + +type RootMessenger = Messenger< + MockAnyNamespace, + AllKeyringControllerActions, + AllKeyringControllerEvents +>; jest.mock('uuid', () => { return { ...jest.requireActual('uuid'), - v4: () => '9b1deb4d-3b7d-4bad-9bdd-2b0d7b3dcb6d', + v4: (): string => '9b1deb4d-3b7d-4bad-9bdd-2b0d7b3dcb6d', }; }); @@ -53,9 +86,8 @@ const input = const seedWords = 'puzzle seed penalty soldier say clay field arctic metal hen cage runway'; const uint8ArraySeed = new Uint8Array( - new Uint16Array( - seedWords.split(' ').map((word) => wordlist.indexOf(word)), - ).buffer, + new Uint16Array(seedWords.split(' ').map((word) => wordlist.indexOf(word))) + .buffer, ); const privateKey = '1e4e6a4c0c077f4ae8ddfbf372918e61dd0fb4a4cfa592cb16e7546d505e68fc'; @@ -63,65 +95,203 @@ const password = 'password123'; const commonConfig = { chain: Chain.Goerli, hardfork: Hardfork.Berlin }; +const defaultKeyrings: SerializedKeyring[] = [ + { + 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: '' }, + }, +]; + +const defaultCredentials = { password, salt: 'salt' }; + +/** + * Build a vault string with the given keyrings. + * This vault can be used with the MockEncryptor to test KeyringController + * with controlled keyrings. + * + * @param keyrings - The keyrings to include in the vault. + * @returns The vault string. + */ +function createVault(keyrings: SerializedKeyring[] = defaultKeyrings): string { + return JSON.stringify({ + data: JSON.stringify({ + tag: { key: defaultCredentials, iv: 'iv' }, + value: keyrings, + }), + iv: 'iv', + salt: 'salt', + }); +} + describe('KeyringController', () => { afterEach(() => { - sinon.restore(); + jest.resetAllMocks(); + }); + + describe('constructor', () => { + it('allows overwriting the built-in Simple keyring builder', async () => { + const mockSimpleKeyringBuilder = + // todo: keyring types are mismatched, this should be fixed in they keyrings themselves + // @ts-expect-error keyring types are mismatched + buildKeyringBuilderWithSpy(SimpleKeyring); + await withController( + { keyringBuilders: [mockSimpleKeyringBuilder] }, + async ({ controller }) => { + await controller.addNewKeyring(KeyringTypes.simple); + + expect(mockSimpleKeyringBuilder).toHaveBeenCalledTimes(1); + }, + ); + }); + + it('allows overwriting the built-in HD keyring builder', async () => { + const mockHdKeyringBuilder = buildKeyringBuilderWithSpy(HdKeyring); + await withController( + { keyringBuilders: [mockHdKeyringBuilder] }, + async () => { + // This is called as part of initializing the controller + // because the first keyring is assumed to always be an HD keyring + expect(mockHdKeyringBuilder).toHaveBeenCalledTimes(1); + }, + ); + }); + + it('allows removing a keyring builder without bricking the wallet when metadata was already generated', async () => { + await withController( + { + skipVaultCreation: true, + state: { + vault: createVault([ + { + type: KeyringTypes.hd, + data: '', + metadata: { id: 'hd', name: '' }, + }, + { + type: 'Unsupported', + data: '', + metadata: { id: 'unsupported', name: '' }, + }, + { + type: KeyringTypes.hd, + data: '', + metadata: { id: 'hd2', name: '' }, + }, + ]), + }, + }, + async ({ controller }) => { + await controller.submitPassword(password); + + expect(controller.state.keyrings).toHaveLength(2); + expect(controller.state.keyrings[0].type).toBe(KeyringTypes.hd); + expect(controller.state.keyrings[0].metadata).toStrictEqual({ + id: 'hd', + name: '', + }); + expect(controller.state.keyrings[1].type).toBe(KeyringTypes.hd); + expect(controller.state.keyrings[1].metadata).toStrictEqual({ + id: 'hd2', + name: '', + }); + }, + ); + }); + + it('allows removing a keyring builder without bricking the wallet when metadata was not yet generated', async () => { + await withController( + { + skipVaultCreation: true, + state: { + vault: createVault([ + { + type: 'HD Key Tree', + data: '', + metadata: { id: 'hd', name: '' }, + }, + { + type: 'HD Key Tree', + data: '', + metadata: { id: 'hd2', name: '' }, + }, + // This keyring was already unsupported + // (no metadata, and is at the end of the array) + { + type: MockKeyring.type, + data: 'unsupported', + }, + ]), + }, + }, + async ({ controller }) => { + await controller.submitPassword(password); + + expect(controller.state.keyrings).toHaveLength(2); + expect(controller.state.keyrings[0].type).toBe(KeyringTypes.hd); + expect(controller.state.keyrings[0].metadata).toStrictEqual({ + id: 'hd', + name: '', + }); + expect(controller.state.keyrings[1].type).toBe(KeyringTypes.hd); + expect(controller.state.keyrings[1].metadata).toStrictEqual({ + id: 'hd2', + name: '', + }); + }, + ); + }); }); describe('addNewAccount', () => { describe('when accountCount is not provided', () => { it('should add new account', async () => { - await withController( - async ({ controller, initialState, preferences }) => { - const { addedAccountAddress } = await controller.addNewAccount(); - expect(initialState.keyrings).toHaveLength(1); - expect(initialState.keyrings[0].accounts).not.toStrictEqual( - controller.state.keyrings[0].accounts, - ); - expect(controller.state.keyrings[0].accounts).toHaveLength(2); - expect(initialState.keyrings[0].accounts).not.toContain( - addedAccountAddress, - ); - expect(addedAccountAddress).toBe( - controller.state.keyrings[0].accounts[1], - ); - expect( - preferences.updateIdentities.calledWith( - controller.state.keyrings[0].accounts, - ), - ).toBe(true); - expect(preferences.setSelectedAddress.called).toBe(false); - }, - ); + await withController(async ({ controller, initialState }) => { + const addedAccountAddress = await controller.addNewAccount(); + expect(initialState.keyrings).toHaveLength(1); + expect(initialState.keyrings[0].accounts).not.toStrictEqual( + controller.state.keyrings[0].accounts, + ); + expect(controller.state.keyrings[0].accounts).toHaveLength(2); + expect(initialState.keyrings[0].accounts).not.toContain( + addedAccountAddress, + ); + expect(addedAccountAddress).toBe( + controller.state.keyrings[0].accounts[1], + ); + }); }); }); describe('when accountCount is provided', () => { it('should add new account if accountCount is in sequence', async () => { - await withController( - async ({ controller, initialState, preferences }) => { - const { addedAccountAddress } = await controller.addNewAccount( - initialState.keyrings[0].accounts.length, - ); - expect(initialState.keyrings).toHaveLength(1); - expect(initialState.keyrings[0].accounts).not.toStrictEqual( - controller.state.keyrings[0].accounts, - ); - expect(controller.state.keyrings[0].accounts).toHaveLength(2); - expect(initialState.keyrings[0].accounts).not.toContain( - addedAccountAddress, - ); - expect(addedAccountAddress).toBe( - controller.state.keyrings[0].accounts[1], - ); - expect( - preferences.updateIdentities.calledWith( - controller.state.keyrings[0].accounts, - ), - ).toBe(true); - expect(preferences.setSelectedAddress.called).toBe(false); - }, - ); + await withController(async ({ controller, initialState }) => { + const addedAccountAddress = await controller.addNewAccount( + initialState.keyrings[0].accounts.length, + ); + expect(initialState.keyrings).toHaveLength(1); + expect(initialState.keyrings[0].accounts).not.toStrictEqual( + controller.state.keyrings[0].accounts, + ); + expect(controller.state.keyrings[0].accounts).toHaveLength(2); + expect(initialState.keyrings[0].accounts).not.toContain( + addedAccountAddress, + ); + expect(addedAccountAddress).toBe( + controller.state.keyrings[0].accounts[1], + ); + }); }); it('should throw an error if passed accountCount param is out of sequence', async () => { @@ -136,9 +306,9 @@ describe('KeyringController', () => { it('should not add a new account if called twice with the same accountCount param', async () => { await withController(async ({ controller, initialState }) => { const accountCount = initialState.keyrings[0].accounts.length; - const { addedAccountAddress: firstAccountAdded } = + const firstAccountAdded = await controller.addNewAccount(accountCount); - const { addedAccountAddress: secondAccountAdded } = + const secondAccountAdded = await controller.addNewAccount(accountCount); expect(firstAccountAdded).toBe(secondAccountAdded); expect(controller.state.keyrings[0].accounts).toHaveLength( @@ -146,51 +316,147 @@ describe('KeyringController', () => { ); }); }); + + it('should throw an error if there is no primary keyring', async () => { + await withController( + { + skipVaultCreation: true, + state: { vault: createVault([{ type: 'Unsupported', data: '' }]) }, + }, + async ({ controller }) => { + await controller.submitPassword(password); + + await expect(controller.addNewAccount()).rejects.toThrow( + 'No HD keyring found', + ); + }, + ); + }); + }); + + it('should throw error when the controller is locked', async () => { + await withController(async ({ controller }) => { + await controller.setLocked(); + + await expect(controller.addNewAccount()).rejects.toThrow( + KeyringControllerErrorMessage.ControllerLocked, + ); + }); + }); + + // Testing fix for bug #4157 {@link https://github.com/MetaMask/core/issues/4157} + it('should return an existing HD account if the accountCount is lower than oldAccounts', async () => { + const mockAddress = '0x123'; + stubKeyringClassWithAccount(MockKeyring, mockAddress); + await withController( + { keyringBuilders: [keyringBuilderFactory(MockKeyring)] }, + async ({ controller, initialState }) => { + await controller.addNewKeyring(MockKeyring.type); + + // expect there to be two accounts, 1 from HD and 1 from MockKeyring + expect(await controller.getAccounts()).toHaveLength(2); + + const accountCount = initialState.keyrings[0].accounts.length; + // We add a new account for "index 1" (not existing yet) + const firstAccountAdded = + await controller.addNewAccount(accountCount); + // Adding an account for an existing index will return the existing account's address + const secondAccountAdded = + await controller.addNewAccount(accountCount); + expect(firstAccountAdded).toBe(secondAccountAdded); + expect(controller.state.keyrings[0].accounts).toHaveLength( + accountCount + 1, + ); + expect(await controller.getAccounts()).toHaveLength(3); + }, + ); + }); + + it('should throw instead of returning undefined', async () => { + await withController(async ({ controller }) => { + jest.spyOn(controller, 'getKeyringsByType').mockReturnValueOnce([ + { + getAccounts: async (): Promise<[undefined, undefined]> => [ + undefined, + undefined, + ], + }, + ]); + + await expect(controller.addNewAccount(1)).rejects.toThrow( + "Can't find account at index 1", + ); + }); + }); + + it('should throw error if the account is duplicated', async () => { + const mockAddress: Hex = '0x123'; + const addAccountsSpy = jest.spyOn(HdKeyring.prototype, 'addAccounts'); + const getAccountsSpy = jest.spyOn(HdKeyring.prototype, 'getAccounts'); + const serializeSpy = jest.spyOn(HdKeyring.prototype, 'serialize'); + + addAccountsSpy.mockResolvedValue([mockAddress]); + getAccountsSpy.mockResolvedValue([mockAddress]); + await withController(async ({ controller }) => { + getAccountsSpy.mockResolvedValue([mockAddress, mockAddress]); + serializeSpy + .mockResolvedValueOnce({ + mnemonic: [], + numberOfAccounts: 1, + hdPath: "m/44'/60'/0'/0", + }) + .mockResolvedValueOnce({ + mnemonic: [], + numberOfAccounts: 2, + hdPath: "m/44'/60'/0'/0", + }); + await expect(controller.addNewAccount()).rejects.toThrow( + KeyringControllerErrorMessage.DuplicatedAccount, + ); + }); }); }); describe('addNewAccountForKeyring', () => { describe('when accountCount is not provided', () => { it('should add new account', async () => { - await withController( - async ({ controller, initialState, preferences }) => { - const [primaryKeyring] = controller.getKeyringsByType( - KeyringTypes.hd, - ) as Keyring[]; - const addedAccountAddress = - await controller.addNewAccountForKeyring(primaryKeyring); - expect(initialState.keyrings).toHaveLength(1); - expect(initialState.keyrings[0].accounts).not.toStrictEqual( - controller.state.keyrings[0].accounts, - ); - expect(controller.state.keyrings[0].accounts).toHaveLength(2); - expect(initialState.keyrings[0].accounts).not.toContain( - addedAccountAddress, - ); - expect(addedAccountAddress).toBe( - controller.state.keyrings[0].accounts[1], - ); - expect( - preferences.updateIdentities.calledWith( - controller.state.keyrings[0].accounts, - ), - ).toBe(true); - expect(preferences.setSelectedAddress.called).toBe(false); - }, - ); + await withController(async ({ controller, initialState }) => { + const [primaryKeyring] = controller.getKeyringsByType( + KeyringTypes.hd, + ) as EthKeyring[]; + const addedAccountAddress = + await controller.addNewAccountForKeyring(primaryKeyring); + expect(initialState.keyrings).toHaveLength(1); + expect(initialState.keyrings[0].accounts).not.toStrictEqual( + controller.state.keyrings[0].accounts, + ); + expect(controller.state.keyrings[0].accounts).toHaveLength(2); + expect(initialState.keyrings[0].accounts).not.toContain( + addedAccountAddress, + ); + expect(addedAccountAddress).toBe( + controller.state.keyrings[0].accounts[1], + ); + }); }); it('should not throw when `keyring.getAccounts()` returns a shallow copy', async () => { await withController( { - keyringBuilders: [ - keyringBuilderFactory(MockShallowGetAccountsKeyring), - ], + keyringBuilders: [keyringBuilderFactory(MockShallowKeyring)], }, - async ({ controller, initialState, preferences }) => { - const mockKeyring = (await controller.addNewKeyring( - MockShallowGetAccountsKeyring.type, - )) as Keyring; + async ({ controller }) => { + await controller.addNewKeyring(MockShallowKeyring.type); + // TODO: This is a temporary workaround while `addNewAccountForKeyring` is not + // removed. + const mockKeyring = controller.getKeyringsByType( + MockShallowKeyring.type, + )[0] as EthKeyring; + + jest + .spyOn(mockKeyring, 'serialize') + .mockResolvedValueOnce({ numberOfAccounts: 1 }) + .mockResolvedValueOnce({ numberOfAccounts: 2 }); const addedAccountAddress = await controller.addNewAccountForKeyring(mockKeyring); @@ -200,13 +466,6 @@ describe('KeyringController', () => { expect(addedAccountAddress).toBe( controller.state.keyrings[1].accounts[0], ); - expect( - preferences.updateIdentities.calledWith([ - ...initialState.keyrings[0].accounts, - addedAccountAddress, - ]), - ).toBe(true); - expect(preferences.setSelectedAddress.called).toBe(false); }, ); }); @@ -214,39 +473,31 @@ describe('KeyringController', () => { describe('when accountCount is provided', () => { it('should add new account if accountCount is in sequence', async () => { - await withController( - async ({ controller, initialState, preferences }) => { - const [primaryKeyring] = controller.getKeyringsByType( - KeyringTypes.hd, - ) as Keyring[]; - const addedAccountAddress = - await controller.addNewAccountForKeyring(primaryKeyring); - expect(initialState.keyrings).toHaveLength(1); - expect(initialState.keyrings[0].accounts).not.toStrictEqual( - controller.state.keyrings[0].accounts, - ); - expect(controller.state.keyrings[0].accounts).toHaveLength(2); - expect(initialState.keyrings[0].accounts).not.toContain( - addedAccountAddress, - ); - expect(addedAccountAddress).toBe( - controller.state.keyrings[0].accounts[1], - ); - expect( - preferences.updateIdentities.calledWith( - controller.state.keyrings[0].accounts, - ), - ).toBe(true); - expect(preferences.setSelectedAddress.called).toBe(false); - }, - ); + await withController(async ({ controller, initialState }) => { + const [primaryKeyring] = controller.getKeyringsByType( + KeyringTypes.hd, + ) as EthKeyring[]; + const addedAccountAddress = + await controller.addNewAccountForKeyring(primaryKeyring); + expect(initialState.keyrings).toHaveLength(1); + expect(initialState.keyrings[0].accounts).not.toStrictEqual( + controller.state.keyrings[0].accounts, + ); + expect(controller.state.keyrings[0].accounts).toHaveLength(2); + expect(initialState.keyrings[0].accounts).not.toContain( + addedAccountAddress, + ); + expect(addedAccountAddress).toBe( + controller.state.keyrings[0].accounts[1], + ); + }); }); it('should throw an error if passed accountCount param is out of sequence', async () => { await withController(async ({ controller, initialState }) => { const [primaryKeyring] = controller.getKeyringsByType( KeyringTypes.hd, - ) as Keyring[]; + ) as EthKeyring[]; const accountCount = initialState.keyrings[0].accounts.length; await expect( controller.addNewAccountForKeyring( @@ -262,7 +513,7 @@ describe('KeyringController', () => { const accountCount = initialState.keyrings[0].accounts.length; const [primaryKeyring] = controller.getKeyringsByType( KeyringTypes.hd, - ) as Keyring[]; + ) as EthKeyring[]; const firstAccountAdded = await controller.addNewAccountForKeyring( primaryKeyring, accountCount, @@ -278,27 +529,16 @@ describe('KeyringController', () => { }); }); }); - }); - describe('addNewAccountWithoutUpdate', () => { - it('should add new account without updating', async () => { - await withController( - async ({ controller, initialState, preferences }) => { - const initialUpdateIdentitiesCallCount = - preferences.updateIdentities.callCount; - await controller.addNewAccountWithoutUpdate(); - expect(initialState.keyrings).toHaveLength(1); - expect(initialState.keyrings[0].accounts).not.toStrictEqual( - controller.state.keyrings[0].accounts, - ); - expect(controller.state.keyrings[0].accounts).toHaveLength(2); - // we make sure that updateIdentities is not called - // during this test - expect(preferences.updateIdentities.callCount).toBe( - initialUpdateIdentitiesCallCount, - ); - }, - ); + it('should throw error when the controller is locked', async () => { + await withController(async ({ controller }) => { + const keyring = controller.getKeyringsByType(KeyringTypes.hd)[0]; + await controller.setLocked(); + + await expect( + controller.addNewAccountForKeyring(keyring as EthKeyring), + ).rejects.toThrow(KeyringControllerErrorMessage.ControllerLocked); + }); }); }); @@ -312,6 +552,16 @@ describe('KeyringController', () => { expect(controller.state.keyrings).toHaveLength(2); }); }); + + it('should return a readonly object as metadata', async () => { + await withController(async ({ controller }) => { + const newMetadata = await controller.addNewKeyring(KeyringTypes.hd); + + expect(() => { + newMetadata.name = 'new name'; + }).toThrow(/Cannot assign to read only property 'name'/u); + }); + }); }); describe('when there is no builder for the given type', () => { @@ -323,166 +573,258 @@ describe('KeyringController', () => { }); }); }); + + it('should throw error when the controller is locked', async () => { + await withController(async ({ controller }) => { + await controller.setLocked(); + + await expect(controller.addNewKeyring(KeyringTypes.hd)).rejects.toThrow( + KeyringControllerErrorMessage.ControllerLocked, + ); + }); + }); }); describe('createNewVaultAndRestore', () => { - [false, true].map((cacheEncryptionKey) => - describe(`when cacheEncryptionKey is ${cacheEncryptionKey}`, () => { - it('should create new vault and restore', async () => { - await withController( - { cacheEncryptionKey }, - async ({ controller, initialState }) => { - const initialVault = controller.state.vault; - await controller.createNewVaultAndRestore( - password, - uint8ArraySeed, - ); - expect(controller.state).not.toBe(initialState); - expect(controller.state.vault).toBeDefined(); - expect(controller.state.vault).toStrictEqual(initialVault); - }, - ); - }); + it('should create new vault and restore', async () => { + await withController(async ({ controller, initialState }) => { + const initialKeyrings = controller.state.keyrings; + await controller.createNewVaultAndRestore(password, uint8ArraySeed); + expect(controller.state).not.toBe(initialState); + expect(controller.state.vault).toBeDefined(); + expect(controller.state.keyrings).toHaveLength(initialKeyrings.length); + // new keyring metadata should be generated + expect(controller.state.keyrings).not.toStrictEqual(initialKeyrings); + }); + }); - it('should restore same vault if old seedWord is used', async () => { - await withController( - { cacheEncryptionKey }, - async ({ controller, initialState }) => { - const currentSeedWord = await controller.exportSeedPhrase( - password, - ); + it('should call encryptor.encrypt with the same keyrings if old seedWord is used', async () => { + await withController(async ({ controller, encryptor }) => { + const encryptSpy = jest.spyOn(encryptor, 'encryptWithKey'); + const serializedKeyring = await controller.withKeyring( + { type: 'HD Key Tree' }, + async ({ keyring }) => keyring.serialize(), + ); + const currentSeedWord = await controller.exportSeedPhrase({ password }); - await controller.createNewVaultAndRestore( - password, - currentSeedWord, - ); - expect(initialState).toStrictEqual(controller.state); - }, - ); - }); + await controller.createNewVaultAndRestore(password, currentSeedWord); - it('should throw error if creating new vault and restore without password', async () => { - await withController( - { cacheEncryptionKey }, - async ({ controller }) => { - await expect( - controller.createNewVaultAndRestore('', uint8ArraySeed), - ).rejects.toThrow('Invalid password'); + const key = JSON.parse(MOCK_ENCRYPTION_KEY); + expect(encryptSpy).toHaveBeenCalledWith(key, [ + { + data: serializedKeyring, + type: 'HD Key Tree', + metadata: { + id: expect.any(String), + name: '', }, - ); - }); + }, + ]); + }); + }); - it('should throw error if creating new vault and restoring without seed phrase', async () => { - await withController( - { cacheEncryptionKey }, - async ({ controller }) => { - await expect( - controller.createNewVaultAndRestore( - password, - // @ts-expect-error invalid seed phrase - '', - ), - ).rejects.toThrow( - 'Eth-Hd-Keyring: Deserialize method cannot be called with an opts value for numberOfAccounts and no menmonic', - ); - }, - ); - }); + it('should create new vault with a different password', async () => { + await withController(async ({ controller, initialState }) => { + const initialKeyrings = controller.state.keyrings; - cacheEncryptionKey && - it('should set encryptionKey and encryptionSalt in state', async () => { - withController({ cacheEncryptionKey }, async ({ controller }) => { - await controller.createNewVaultAndRestore( - password, - uint8ArraySeed, - ); - expect(controller.state.encryptionKey).toBeDefined(); - expect(controller.state.encryptionSalt).toBeDefined(); - }); - }); - }), - ); - }); + await controller.createNewVaultAndRestore( + 'new-password', + uint8ArraySeed, + ); + + expect(controller.state).not.toBe(initialState); + expect(controller.state.vault).toBeDefined(); + expect(controller.state.keyrings).toHaveLength(initialKeyrings.length); + // new keyring metadata should be generated + expect(controller.state.keyrings).not.toStrictEqual(initialKeyrings); + }); + }); + + it('should throw error if creating new vault and restore without password', async () => { + await withController(async ({ controller }) => { + await expect( + controller.createNewVaultAndRestore('', uint8ArraySeed), + ).rejects.toThrow(KeyringControllerErrorMessage.InvalidEmptyPassword); + }); + }); + + it('should throw error if creating new vault and restoring without seed phrase', async () => { + await withController(async ({ controller }) => { + await expect( + controller.createNewVaultAndRestore( + password, + // @ts-expect-error invalid seed phrase + '', + ), + ).rejects.toThrow( + 'Eth-Hd-Keyring: Deserialize method cannot be called with an opts value for numberOfAccounts and no menmonic', + ); + }); + }); + + it('should set encryptionKey and encryptionSalt in state', async () => { + await withController(async ({ controller }) => { + await controller.createNewVaultAndRestore(password, uint8ArraySeed); + expect(controller.state.encryptionKey).toBeDefined(); + expect(controller.state.encryptionSalt).toBeDefined(); + }); + }); + }); describe('createNewVaultAndKeychain', () => { - [false, true].map((cacheEncryptionKey) => - describe(`when cacheEncryptionKey is ${cacheEncryptionKey}`, () => { - describe('when there is no existing vault', () => { - it('should create new vault, mnemonic and keychain', async () => { - await withController( - { cacheEncryptionKey }, - async ({ controller, initialState, preferences, encryptor }) => { - const cleanKeyringController = new KeyringController({ - ...preferences, - messenger: buildKeyringControllerMessenger(), - cacheEncryptionKey, - encryptor, - }); - const initialSeedWord = await controller.exportSeedPhrase( - password, - ); - await cleanKeyringController.createNewVaultAndKeychain( - password, - ); - const currentSeedWord = - await cleanKeyringController.exportSeedPhrase(password); - expect(initialSeedWord).toBeDefined(); - expect(initialState).not.toBe(cleanKeyringController.state); - expect(currentSeedWord).toBeDefined(); - expect(initialSeedWord).not.toBe(currentSeedWord); - expect( - isValidHexAddress( - cleanKeyringController.state.keyrings[0].accounts[0] as Hex, - ), - ).toBe(true); - expect(controller.state.vault).toBeDefined(); - }, - ); - }); + describe('when there is no existing vault', () => { + it('should create new vault, mnemonic and keychain', async () => { + await withController( + { skipVaultCreation: true }, + async ({ controller }) => { + await controller.createNewVaultAndKeychain(password); - it('should set default state', async () => { - await withController(async ({ controller }) => { - expect(controller.state.keyrings).not.toStrictEqual([]); - const keyring = controller.state.keyrings[0]; - expect(keyring.accounts).not.toStrictEqual([]); - expect(keyring.type).toBe('HD Key Tree'); - expect(controller.state.vault).toBeDefined(); + const currentSeedPhrase = await controller.exportSeedPhrase({ + password, }); + + expect(currentSeedPhrase.length).toBeGreaterThan(0); + expect( + isValidHexAddress( + controller.state.keyrings[0].accounts[0] as Hex, + ), + ).toBe(true); + expect(controller.state.vault).toBeDefined(); + }, + ); + }); + + it('should set encryptionKey and encryptionSalt in state', async () => { + await withController( + { skipVaultCreation: true }, + async ({ controller }) => { + await controller.createNewVaultAndKeychain(password); + + expect(controller.state.encryptionKey).toBeDefined(); + expect(controller.state.encryptionSalt).toBeDefined(); + }, + ); + }); + + it('should set default state', async () => { + await withController( + { skipVaultCreation: true }, + async ({ controller }) => { + await controller.createNewVaultAndKeychain(password); + + expect(controller.state.keyrings).not.toStrictEqual([]); + const keyring = controller.state.keyrings[0]; + expect(keyring.accounts).not.toStrictEqual([]); + expect(keyring.type).toBe('HD Key Tree'); + expect(controller.state.vault).toBeDefined(); + }, + ); + }); + + it('should throw error if password is of wrong type', async () => { + await withController( + { skipVaultCreation: true }, + async ({ controller }) => { + await expect( + controller.createNewVaultAndKeychain( + // @ts-expect-error invalid password + 123, + ), + ).rejects.toThrow(KeyringControllerErrorMessage.WrongPasswordType); + }, + ); + }); + + it('should throw error if the first account is not found on the keyring', async () => { + jest.spyOn(HdKeyring.prototype, 'getAccounts').mockResolvedValue([]); + await withController( + { skipVaultCreation: true }, + async ({ controller }) => { + await expect( + controller.createNewVaultAndKeychain(password), + ).rejects.toThrow(KeyringControllerErrorMessage.NoFirstAccount); + }, + ); + }); + + it('should throw error when HD keyring does not support generateRandomMnemonic', async () => { + // Create a custom HD keyring that doesn't support generateRandomMnemonic + class MockHdKeyringWithoutMnemonic { + static type = 'HD Key Tree'; + + type = 'HD Key Tree'; + + async getAccounts(): Promise { + return []; + } + + async addAccounts(): Promise { + return []; + } + + serialize = async (): Promise<{ type: string }> => ({ + type: this.type, }); - }); - describe('when there is an existing vault', () => { - it('should return existing vault', async () => { - await withController( - { cacheEncryptionKey }, - async ({ controller, initialState }) => { - const initialSeedWord = await controller.exportSeedPhrase( - password, - ); - const initialVault = controller.state.vault; - await controller.createNewVaultAndKeychain(password); - const currentSeedWord = await controller.exportSeedPhrase( - password, - ); - expect(initialSeedWord).toBeDefined(); - expect(initialState).toBe(controller.state); - expect(currentSeedWord).toBeDefined(); - expect(initialSeedWord).toBe(currentSeedWord); - expect(initialVault).toStrictEqual(controller.state.vault); - }, + deserialize = async (): Promise => { + // noop + }; + } + + const mockBuilder = keyringBuilderFactory( + MockHdKeyringWithoutMnemonic as unknown as KeyringClass, + ); + + await withController( + { + skipVaultCreation: true, + keyringBuilders: [mockBuilder], + }, + async ({ controller }) => { + // Try to create a new vault, which will attempt to generate a mnemonic + await expect( + controller.createNewVaultAndKeychain(password), + ).rejects.toThrow( + KeyringControllerErrorMessage.UnsupportedGenerateRandomMnemonic, ); + }, + ); + }); + }); + + describe('when there is an existing vault', () => { + it('should not create a new vault or keychain', async () => { + await withController(async ({ controller, initialState }) => { + const initialSeedWord = await controller.exportSeedPhrase({ + password, }); - }); + expect(initialSeedWord).toBeDefined(); + const initialVault = controller.state.vault; - cacheEncryptionKey && - it('should set encryptionKey and encryptionSalt in state', async () => { - withController({ cacheEncryptionKey }, async ({ initialState }) => { - expect(initialState.encryptionKey).toBeDefined(); - expect(initialState.encryptionSalt).toBeDefined(); - }); + await controller.createNewVaultAndKeychain(password); + + const currentSeedWord = await controller.exportSeedPhrase({ + password, }); - }), - ); + expect(initialState).toStrictEqual(controller.state); + expect(initialSeedWord).toBe(currentSeedWord); + expect(initialVault).toStrictEqual(controller.state.vault); + }); + }); + + it('should set encryptionKey and encryptionSalt in state', async () => { + await withController(async ({ controller }) => { + await controller.setLocked(); + expect(controller.state.encryptionKey).toBeUndefined(); + expect(controller.state.encryptionSalt).toBeUndefined(); + + await controller.createNewVaultAndKeychain(password); + + expect(controller.state.encryptionKey).toBeDefined(); + expect(controller.state.encryptionSalt).toBeDefined(); + }); + }); + }); }); describe('setLocked', () => { @@ -490,18 +832,32 @@ describe('KeyringController', () => { await withController(async ({ controller }) => { expect(controller.isUnlocked()).toBe(true); expect(controller.state.isUnlocked).toBe(true); - controller.setLocked(); + + await controller.setLocked(); + expect(controller.isUnlocked()).toBe(false); expect(controller.state.isUnlocked).toBe(false); + expect(controller.state).not.toHaveProperty('encryptionKey'); + expect(controller.state).not.toHaveProperty('encryptionSalt'); }); }); 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(); + }); + }); + + it('should throw error when the controller is locked', async () => { + await withController(async ({ controller }) => { + await controller.setLocked(); + + await expect(controller.setLocked()).rejects.toThrow( + KeyringControllerErrorMessage.ControllerLocked, + ); }); }); }); @@ -512,50 +868,216 @@ describe('KeyringController', () => { await withController(async ({ controller }) => { const primaryKeyring = controller.getKeyringsByType( KeyringTypes.hd, - )[0] as Keyring & { mnemonic: string }; + )[0] as EthKeyring & { mnemonic: string }; primaryKeyring.mnemonic = ''; - await expect(controller.exportSeedPhrase(password)).rejects.toThrow( - "Can't get mnemonic bytes from keyring", - ); + await expect( + controller.exportSeedPhrase({ password }), + ).rejects.toThrow("Can't get mnemonic bytes from keyring"); }); }); }); describe('when mnemonic is exportable', () => { describe('when correct password is provided', () => { - it('should export seed phrase', async () => { + it('should export seed phrase without keyringId', async () => { await withController(async ({ controller }) => { - const seed = await controller.exportSeedPhrase(password); + const seed = await controller.exportSeedPhrase({ password }); + expect(seed).not.toBe(''); + }); + }); + + it('should export seed phrase with valid keyringId', async () => { + await withController(async ({ controller, initialState }) => { + const keyringId = initialState.keyrings[0].metadata.id; + const seed = await controller.exportSeedPhrase( + { password }, + keyringId, + ); expect(seed).not.toBe(''); }); }); + + it('should throw error if keyringId is invalid', async () => { + await withController(async ({ controller }) => { + await expect( + controller.exportSeedPhrase({ password }, 'invalid-id'), + ).rejects.toThrow('Keyring not found'); + }); + }); }); describe('when wrong password is provided', () => { it('should export seed phrase', async () => { await withController(async ({ controller, encryptor }) => { - sinon - .stub(encryptor, 'decrypt') - .throws(new Error('Invalid password')); - await expect(controller.exportSeedPhrase('')).rejects.toThrow( - 'Invalid password', + jest + .spyOn(encryptor, 'decrypt') + .mockRejectedValueOnce(new Error('Invalid password')); + await expect( + controller.exportSeedPhrase({ password: '' }), + ).rejects.toThrow('Invalid password'); + }); + }); + + it('should throw invalid password error with valid keyringId', async () => { + await withController( + async ({ controller, encryptor, initialState }) => { + const keyringId = initialState.keyrings[0].metadata.id; + jest + .spyOn(encryptor, 'decrypt') + .mockRejectedValueOnce(new Error('Invalid password')); + await expect( + controller.exportSeedPhrase({ password: '' }, keyringId), + ).rejects.toThrow('Invalid password'); + }, + ); + }); + }); + + describe('when correct encryption key is provided', () => { + it('should export seed phrase with an encryption key credential', async () => { + await withController(async ({ controller }) => { + const encryptionKey = await controller.exportEncryptionKey(); + const seed = await controller.exportSeedPhrase({ encryptionKey }); + expect(seed).not.toBe(''); + }); + }); + + it('should export seed phrase with an encryption key and a valid keyringId', async () => { + await withController(async ({ controller, initialState }) => { + const keyringId = initialState.keyrings[0].metadata.id; + const encryptionKey = await controller.exportEncryptionKey(); + const seed = await controller.exportSeedPhrase( + { encryptionKey }, + keyringId, ); + expect(seed).not.toBe(''); + }); + }); + + it('should export seed phrase with an encryption key and matching encryptionSalt', async () => { + await withController(async ({ controller, initialState }) => { + const encryptionKey = await controller.exportEncryptionKey(); + const seed = await controller.exportSeedPhrase({ + encryptionKey, + encryptionSalt: initialState.encryptionSalt, + }); + expect(seed).not.toBe(''); + }); + }); + }); + + describe('when encryptionSalt does not match the vault', () => { + it('should throw error', async () => { + await withController(async ({ controller }) => { + const encryptionKey = await controller.exportEncryptionKey(); + await expect( + controller.exportSeedPhrase({ + encryptionKey, + encryptionSalt: '0x1234', + }), + ).rejects.toThrow(KeyringControllerErrorMessage.ExpiredCredentials); + }); + }); + }); + + describe('when wrong encryption key is provided', () => { + it('should throw the decryption error', async () => { + await withController(async ({ controller, encryptor }) => { + const encryptionKey = await controller.exportEncryptionKey(); + jest + .spyOn(encryptor, 'decryptWithKey') + .mockRejectedValueOnce(new Error('Invalid key')); + await expect( + controller.exportSeedPhrase({ encryptionKey }), + ).rejects.toThrow('Invalid key'); }); }); }); + + describe('when vault is missing', () => { + it('should throw error', async () => { + await withController( + { + skipVaultCreation: true, + state: { + isUnlocked: true, + } as KeyringControllerState, + }, + async ({ controller }) => { + await expect( + controller.exportSeedPhrase({ + encryptionKey: 'encryption-key', + }), + ).rejects.toThrow(KeyringControllerErrorMessage.VaultError); + }, + ); + }); + }); + }); + + it('should throw error when the controller is locked', async () => { + await withController(async ({ controller }) => { + await controller.setLocked(); + + await expect(controller.exportSeedPhrase({ password })).rejects.toThrow( + KeyringControllerErrorMessage.ControllerLocked, + ); + }); }); }); describe('exportAccount', () => { - describe('when correct password is provided', () => { - describe('when correct account is provided', () => { - it('should export account', async () => { + describe('when the keyring for the given address supports exportAccount', () => { + describe('when correct password is provided', () => { + describe('when correct account is provided', () => { + it('should export account', async () => { + await withController(async ({ controller, initialState }) => { + const account = initialState.keyrings[0].accounts[0]; + const newPrivateKey = await controller.exportAccount( + { password }, + account, + ); + expect(newPrivateKey).not.toBe(''); + }); + }); + }); + + describe('when wrong account is provided', () => { + it('should throw error', async () => { + await withController(async ({ controller }) => { + await expect( + controller.exportAccount({ password }, ''), + ).rejects.toThrow(KeyringControllerErrorMessage.KeyringNotFound); + }); + }); + }); + }); + + describe('when wrong password is provided', () => { + it('should throw error', async () => { + await withController( + async ({ controller, initialState, encryptor }) => { + const account = initialState.keyrings[0].accounts[0]; + jest + .spyOn(encryptor, 'decrypt') + .mockRejectedValueOnce(new Error('Invalid password')); + await expect( + controller.exportAccount({ password: '' }, account), + ).rejects.toThrow('Invalid password'); + }, + ); + }); + }); + + describe('when correct encryption key is provided', () => { + it('should export account with an encryption key credential', async () => { await withController(async ({ controller, initialState }) => { const account = initialState.keyrings[0].accounts[0]; + const encryptionKey = await controller.exportEncryptionKey(); const newPrivateKey = await controller.exportAccount( - password, + { encryptionKey }, account, ); expect(newPrivateKey).not.toBe(''); @@ -563,35 +1085,49 @@ describe('KeyringController', () => { }); }); - describe('when wrong account is provided', () => { - it('should throw error', async () => { - await withController(async ({ controller }) => { - await expect( - controller.exportAccount(password, ''), - ).rejects.toThrow( - 'KeyringController - No keyring found. Error info: The address passed in is invalid/empty', - ); - }); + describe('when wrong encryption key is provided', () => { + it('should throw the decryption error', async () => { + await withController( + async ({ controller, initialState, encryptor }) => { + const account = initialState.keyrings[0].accounts[0]; + const encryptionKey = await controller.exportEncryptionKey(); + jest + .spyOn(encryptor, 'decryptWithKey') + .mockRejectedValueOnce(new Error('Invalid key')); + await expect( + controller.exportAccount({ encryptionKey }, account), + ).rejects.toThrow('Invalid key'); + }, + ); }); }); }); - describe('when wrong password is provided', () => { + it('should throw error when the controller is locked', async () => { + await withController(async ({ controller, initialState }) => { + const account = initialState.keyrings[0].accounts[0]; + await controller.setLocked(); + + await expect( + controller.exportAccount({ password }, account), + ).rejects.toThrow(KeyringControllerErrorMessage.ControllerLocked); + }); + }); + + describe('when the keyring for the given address does not support exportAccount', () => { it('should throw error', async () => { + const address = '0x5AC6D462f054690a373FABF8CC28e161003aEB19'; + stubKeyringClassWithAccount(MockKeyring, address); await withController( - async ({ controller, initialState, encryptor }) => { - const account = initialState.keyrings[0].accounts[0]; - sinon - .stub(encryptor, 'decrypt') - .rejects(new Error('Invalid password')); - - await expect(controller.exportAccount('', account)).rejects.toThrow( - 'Invalid password', - ); + { keyringBuilders: [keyringBuilderFactory(MockKeyring)] }, + async ({ controller }) => { + await controller.addNewKeyring(MockKeyring.type); await expect( - controller.exportAccount('JUNK_VALUE', account), - ).rejects.toThrow('Invalid password'); + controller.exportAccount({ password }, address), + ).rejects.toThrow( + KeyringControllerErrorMessage.UnsupportedExportAccount, + ); }, ); }); @@ -606,87 +1142,195 @@ describe('KeyringController', () => { expect(accounts).toStrictEqual(initialAccount); }); }); - }); - describe('getEncryptionPublicKey', () => { - it('should return the correct encryption public key', async () => { + it('should throw error when the controller is locked', async () => { await withController(async ({ controller }) => { - const { importedAccountAddress } = - await controller.importAccountWithStrategy( - AccountImportStrategy.privateKey, - [privateKey], - ); - - const encryptionPublicKey = await controller.getEncryptionPublicKey( - importedAccountAddress, - ); + await controller.setLocked(); - expect(encryptionPublicKey).toBe( - 'ZfKqt4HSy4tt9/WvqP3QrnzbIS04cnV//BhksKbLgVA=', + await expect(controller.getAccounts()).rejects.toThrow( + KeyringControllerErrorMessage.ControllerLocked, ); }); }); }); - describe('decryptMessage', () => { - it('should successfully decrypt a message with valid parameters and return the raw decryption result', async () => { - await withController(async ({ controller }) => { - const { importedAccountAddress } = - await controller.importAccountWithStrategy( - AccountImportStrategy.privateKey, - [privateKey], - ); - const message = 'Hello, encrypted world!'; - const encryptedMessage = encrypt({ - publicKey: await controller.getEncryptionPublicKey( - importedAccountAddress, - ), - data: message, - version: 'x25519-xsalsa20-poly1305', - }); - - const messageParams = { - from: importedAccountAddress, - data: encryptedMessage, - }; - - const result = await controller.decryptMessage(messageParams); - - expect(result).toBe(message); + describe('getAccountKeyringType', () => { + it('should return the keyring type for the given account', async () => { + await withController(async ({ controller, initialState }) => { + const account = initialState.keyrings[0].accounts[0]; + const keyringType = await controller.getAccountKeyringType(account); + expect(keyringType).toBe(KeyringTypes.hd); }); }); - it("should throw an error if the 'from' parameter is not a valid account address", async () => { + it('should throw error if no keyring is found for the given account', async () => { await withController(async ({ controller }) => { - const messageParams = { - from: 'invalid address', - data: { - version: '1.0', - nonce: '123456', - ephemPublicKey: '0xabcdef1234567890', - ciphertext: '0xabcdef1234567890', - }, - }; - - await expect(controller.decryptMessage(messageParams)).rejects.toThrow( - 'KeyringController - No keyring found. Error info: The address passed in is invalid/empty', + await expect(controller.getAccountKeyringType('0x')).rejects.toThrow( + KeyringControllerErrorMessage.KeyringNotFound, ); }); }); }); - describe('getKeyringForAccount', () => { - describe('when existing account is provided', () => { - it('should get correct keyring', async () => { + describe('getEncryptionPublicKey', () => { + describe('when the keyring for the given address supports getEncryptionPublicKey', () => { + it('should return the correct encryption public key', async () => { + await withController(async ({ controller }) => { + const importedAccountAddress = + await controller.importAccountWithStrategy( + AccountImportStrategy.privateKey, + [privateKey], + ); + + const encryptionPublicKey = await controller.getEncryptionPublicKey( + importedAccountAddress, + ); + + expect(encryptionPublicKey).toBe( + 'ZfKqt4HSy4tt9/WvqP3QrnzbIS04cnV//BhksKbLgVA=', + ); + }); + }); + }); + + describe('when the keyring for the given address does not support getEncryptionPublicKey', () => { + it('should throw error', async () => { + const address = '0x5AC6D462f054690a373FABF8CC28e161003aEB19'; + stubKeyringClassWithAccount(MockKeyring, address); + await withController( + { keyringBuilders: [keyringBuilderFactory(MockKeyring)] }, + async ({ controller }) => { + await controller.addNewKeyring(MockKeyring.type); + + await expect( + controller.getEncryptionPublicKey(address), + ).rejects.toThrow( + KeyringControllerErrorMessage.UnsupportedGetEncryptionPublicKey, + ); + }, + ); + }); + }); + + it('should throw error when the controller is locked', async () => { + await withController(async ({ controller, initialState }) => { + await controller.setLocked(); + + await expect( + controller.getEncryptionPublicKey( + initialState.keyrings[0].accounts[0], + ), + ).rejects.toThrow(KeyringControllerErrorMessage.ControllerLocked); + }); + }); + }); + + describe('decryptMessage', () => { + describe('when the keyring for the given address supports decryptMessage', () => { + it('should successfully decrypt a message with valid parameters and return the raw decryption result', async () => { + await withController(async ({ controller }) => { + const importedAccountAddress = + await controller.importAccountWithStrategy( + AccountImportStrategy.privateKey, + [privateKey], + ); + const message = 'Hello, encrypted world!'; + const encryptedMessage = encrypt({ + publicKey: await controller.getEncryptionPublicKey( + importedAccountAddress, + ), + data: message, + version: 'x25519-xsalsa20-poly1305', + }); + + const messageParams = { + from: importedAccountAddress, + data: encryptedMessage, + }; + + const result = await controller.decryptMessage(messageParams); + + expect(result).toBe(message); + }); + }); + + it("should throw an error if the 'from' parameter is not a valid account address", async () => { + await withController(async ({ controller }) => { + const messageParams = { + from: 'invalid address', + data: { + version: '1.0', + nonce: '123456', + ephemPublicKey: '0xabcdef1234567890', + ciphertext: '0xabcdef1234567890', + }, + }; + + await expect( + controller.decryptMessage(messageParams), + ).rejects.toThrow(KeyringControllerErrorMessage.KeyringNotFound); + }); + }); + }); + + describe('when the keyring for the given address does not support decryptMessage', () => { + it('should throw error', async () => { + const address = '0x5AC6D462f054690a373FABF8CC28e161003aEB19'; + stubKeyringClassWithAccount(MockKeyring, address); + await withController( + { keyringBuilders: [keyringBuilderFactory(MockKeyring)] }, + async ({ controller }) => { + await controller.addNewKeyring(MockKeyring.type); + + await expect( + controller.decryptMessage({ + from: address, + data: { + version: '1.0', + nonce: '123456', + ephemPublicKey: '0xabcdef1234567890', + ciphertext: '0xabcdef1234567890', + }, + }), + ).rejects.toThrow( + KeyringControllerErrorMessage.UnsupportedDecryptMessage, + ); + }, + ); + }); + }); + + it('should throw error when the controller is locked', async () => { + await withController(async ({ controller, initialState }) => { + await controller.setLocked(); + + await expect( + controller.decryptMessage({ + from: initialState.keyrings[0].accounts[0], + data: { + version: '1.0', + nonce: '123456', + ephemPublicKey: '0xabcdef1234567890', + ciphertext: '0xabcdef1234567890', + }, + }), + ).rejects.toThrow(KeyringControllerErrorMessage.ControllerLocked); + }); + }); + }); + + describe('getKeyringForAccount', () => { + describe('when existing account is provided', () => { + it('should get correct keyring', async () => { await withController(async ({ controller }) => { const normalizedInitialAccounts = controller.state.keyrings[0].accounts.map(normalize); const keyring = (await controller.getKeyringForAccount( // eslint-disable-next-line @typescript-eslint/no-non-null-assertion normalizedInitialAccounts[0]!, - )) as Keyring; + )) as EthKeyring; expect(keyring.type).toBe('HD Key Tree'); - expect(keyring.getAccounts()).toStrictEqual( + expect(await keyring.getAccounts()).toStrictEqual( normalizedInitialAccounts, ); }); @@ -694,18 +1338,56 @@ describe('KeyringController', () => { }); describe('when non-existing account is provided', () => { - it('should throw error', async () => { + it('should throw error if no account matches the address', async () => { await withController(async ({ controller }) => { await expect( controller.getKeyringForAccount( '0x0000000000000000000000000000000000000000', ), - ).rejects.toThrow( - 'KeyringController - No keyring found. Error info: There are keyrings, but none match the address', - ); + ).rejects.toThrow(KeyringControllerErrorMessage.KeyringNotFound); + }); + }); + + it('should throw an error if there is no keyring', async () => { + await withController( + { + skipVaultCreation: true, + state: { vault: createVault([{ type: 'Unsupported', data: '' }]) }, + }, + async ({ controller }) => { + await controller.submitPassword(password); + + await expect( + controller.getKeyringForAccount( + '0x0000000000000000000000000000000000000000', + ), + ).rejects.toThrow(KeyringControllerErrorMessage.NoKeyring); + }, + ); + }); + + it('should throw an error if the controller is locked', async () => { + await withController(async ({ controller }) => { + await controller.setLocked(); + + await expect( + controller.getKeyringForAccount( + '0x51253087e6f8358b5f10c0a94315d69db3357859', + ), + ).rejects.toThrow(KeyringControllerErrorMessage.ControllerLocked); }); }); }); + + it('should throw error when the controller is locked', async () => { + await withController(async ({ controller, initialState }) => { + await controller.setLocked(); + + await expect( + controller.getKeyringForAccount(initialState.keyrings[0].accounts[0]), + ).rejects.toThrow(KeyringControllerErrorMessage.ControllerLocked); + }); + }); }); describe('getKeyringsByType', () => { @@ -714,10 +1396,10 @@ describe('KeyringController', () => { await withController(async ({ controller }) => { const keyrings = controller.getKeyringsByType( KeyringTypes.hd, - ) as Keyring[]; + ) as EthKeyring[]; expect(keyrings).toHaveLength(1); expect(keyrings[0].type).toBe(KeyringTypes.hd); - expect(keyrings[0].getAccounts()).toStrictEqual( + expect(await keyrings[0].getAccounts()).toStrictEqual( controller.state.keyrings[0].accounts.map(normalize), ); }); @@ -732,6 +1414,16 @@ describe('KeyringController', () => { }); }); }); + + it('should throw error when the controller is locked', async () => { + await withController(async ({ controller }) => { + await controller.setLocked(); + + expect(() => controller.getKeyringsByType(KeyringTypes.hd)).toThrow( + KeyringControllerErrorMessage.ControllerLocked, + ); + }); + }); }); describe('persistAllKeyrings', () => { @@ -739,7 +1431,7 @@ describe('KeyringController', () => { await withController(async ({ controller }) => { const primaryKeyring = controller.getKeyringsByType( KeyringTypes.hd, - )[0] as Keyring; + )[0] as EthKeyring; const [addedAccount] = await primaryKeyring.addAccounts(1); await controller.persistAllKeyrings(); @@ -747,6 +1439,16 @@ describe('KeyringController', () => { expect(controller.state.keyrings[0].accounts[1]).toBe(addedAccount); }); }); + + it('should throw error when locked', async () => { + await withController(async ({ controller }) => { + await controller.setLocked(); + + await expect(controller.persistAllKeyrings()).rejects.toThrow( + KeyringControllerErrorMessage.ControllerLocked, + ); + }); + }); }); describe('importAccountWithStrategy', () => { @@ -759,27 +1461,42 @@ describe('KeyringController', () => { accounts: [address], type: 'Simple Key Pair', }; - const { importedAccountAddress } = + const importedAccountAddress = await controller.importAccountWithStrategy( AccountImportStrategy.privateKey, [privateKey], ); const modifiedState = { ...initialState, - keyrings: [initialState.keyrings[0], newKeyring], + keyrings: [ + initialState.keyrings[0], + { + ...newKeyring, + metadata: controller.state.keyrings[1].metadata, + }, + ], + }; + const modifiedStateWithoutVault = { + ...modifiedState, + vault: undefined, }; - expect(controller.state).toStrictEqual(modifiedState); + const stateWithoutVault = { + ...controller.state, + vault: undefined, + }; + expect(stateWithoutVault).toStrictEqual(modifiedStateWithoutVault); expect(importedAccountAddress).toBe(address); }); }); + // TODO: Either fix this lint violation or explain why it's necessary to ignore. + // eslint-disable-next-line jest/expect-expect it('should not select imported account', async () => { - await withController(async ({ controller, preferences }) => { + await withController(async ({ controller }) => { await controller.importAccountWithStrategy( AccountImportStrategy.privateKey, [privateKey], ); - expect(preferences.setSelectedAddress.called).toBe(false); }); }); }); @@ -799,9 +1516,7 @@ describe('KeyringController', () => { AccountImportStrategy.privateKey, ['123'], ), - ).rejects.toThrow( - 'Expected private key to be an Uint8Array with length 32', - ); + ).rejects.toThrow('Cannot import invalid private key.'); await expect( controller.importAccountWithStrategy( @@ -828,7 +1543,7 @@ describe('KeyringController', () => { const somePassword = 'holachao123'; const address = '0xb97c80fab7a3793bbe746864db80d236f1345ea7'; - const { importedAccountAddress } = + const importedAccountAddress = await controller.importAccountWithStrategy( AccountImportStrategy.json, [input, somePassword], @@ -840,21 +1555,53 @@ describe('KeyringController', () => { }; const modifiedState = { ...initialState, - keyrings: [initialState.keyrings[0], newKeyring], + keyrings: [ + initialState.keyrings[0], + { + ...newKeyring, + metadata: controller.state.keyrings[1].metadata, + }, + ], + }; + const modifiedStateWithoutVault = { + ...modifiedState, + vault: undefined, + }; + const stateWithoutVault = { + ...controller.state, + vault: undefined, }; - expect(controller.state).toStrictEqual(modifiedState); + expect(stateWithoutVault).toStrictEqual(modifiedStateWithoutVault); expect(importedAccountAddress).toBe(address); }); }); + // TODO: Either fix this lint violation or explain why it's necessary to ignore. + // eslint-disable-next-line jest/expect-expect it('should not select imported account', async () => { - await withController(async ({ controller, preferences }) => { + await withController(async ({ controller }) => { + const somePassword = 'holachao123'; + await controller.importAccountWithStrategy( + AccountImportStrategy.json, + [input, somePassword], + ); + }); + }); + + it('should throw error when importing a duplicate account', async () => { + await withController(async ({ controller }) => { const somePassword = 'holachao123'; await controller.importAccountWithStrategy( AccountImportStrategy.json, [input, somePassword], ); - expect(preferences.setSelectedAddress.called).toBe(false); + + await expect( + controller.importAccountWithStrategy(AccountImportStrategy.json, [ + input, + somePassword, + ]), + ).rejects.toThrow(KeyringControllerErrorMessage.DuplicatedAccount); }); }); }); @@ -915,163 +1662,489 @@ describe('KeyringController', () => { }); }); }); - }); - - describe('removeAccount', () => { - /** - * If there is only HD Key Tree keyring with 1 account and removeAccount is called passing that account - * It deletes keyring object also from state - not sure if this is correct behavior. - * https://github.com/MetaMask/core/issues/801 - */ - it('should remove HD Key Tree keyring from state when single account associated with it is deleted', async () => { - await withController(async ({ controller, initialState }) => { - const account = initialState.keyrings[0].accounts[0] as Hex; - await controller.removeAccount(account); - expect(controller.state.keyrings).toHaveLength(0); - }); - }); - - it('should remove account', async () => { - await withController(async ({ controller, initialState }) => { - await controller.importAccountWithStrategy( - AccountImportStrategy.privateKey, - [privateKey], - ); - await controller.removeAccount( - '0x51253087e6f8358b5f10c0a94315d69db3357859', - ); - expect(controller.state).toStrictEqual(initialState); - }); - }); - - it('should emit `accountRemoved` event', async () => { - await withController(async ({ controller, messenger }) => { - await controller.importAccountWithStrategy( - AccountImportStrategy.privateKey, - [privateKey], - ); - const listener = sinon.spy(); - messenger.subscribe('KeyringController:accountRemoved', listener); - - const removedAccount = '0x51253087e6f8358b5f10c0a94315d69db3357859'; - await controller.removeAccount(removedAccount); - - expect(listener.calledWith(removedAccount)).toBe(true); - }); - }); - it('should not remove account if wrong address is provided', async () => { + it('should throw error when the controller is locked', async () => { await withController(async ({ controller }) => { - await controller.importAccountWithStrategy( - AccountImportStrategy.privateKey, - [privateKey], - ); + await controller.setLocked(); await expect( - controller.removeAccount( - '0x0000000000000000000000000000000000000000', + controller.importAccountWithStrategy( + AccountImportStrategy.privateKey, + [input, 'password'], ), - ).rejects.toThrow( - 'KeyringController - No keyring found. Error info: There are keyrings, but none match the address', - ); - - await expect(controller.removeAccount('0xDUMMY_INPUT')).rejects.toThrow( - 'KeyringController - No keyring found. Error info: The address passed in is invalid/empty', - ); + ).rejects.toThrow(KeyringControllerErrorMessage.ControllerLocked); }); }); }); - describe('signMessage', () => { - it('should sign message', async () => { - await withController(async ({ controller, initialState }) => { - const data = - '0x879a053d4800c6354e76c7985a865d2922c82fb5b3f4577b2fe08b998954f2e0'; - const account = initialState.keyrings[0].accounts[0]; - const signature = await controller.signMessage({ - data, - from: account, + describe('removeAccount', () => { + describe('when the keyring for the given address supports removeAccount', () => { + /** + * If there is only HD Key Tree keyring with 1 account and removeAccount is called passing that account + * It deletes keyring object also from state - not sure if this is correct behavior. + * https://github.com/MetaMask/core/issues/801 + * + * Update: The behaviour is now modified to never remove the HD keyring as a preventive and temporal solution to the current race + * condition case we have been seeing lately. https://github.com/MetaMask/mobile-planning/issues/1507 + * Enforcing this behaviour is not a 100% correct and it should be responsibility of the consumer to handle the accounts + * and keyrings in a way that it matches the expected behaviour. + */ + it('should not remove HD Key Tree keyring nor the single account from state', async () => { + await withController(async ({ controller, initialState }) => { + const account = initialState.keyrings[0].accounts[0] as Hex; + await expect(controller.removeAccount(account)).rejects.toThrow( + KeyringControllerErrorMessage.LastAccountInPrimaryKeyring, + ); + expect(controller.state.keyrings).toHaveLength(1); + expect(controller.state.keyrings[0].accounts).toHaveLength(1); }); - expect(signature).not.toBe(''); }); - }); - it('should not sign message if empty data is passed', async () => { - await withController(async ({ controller, initialState }) => { - await expect(() => - controller.signMessage({ - data: '', - from: initialState.keyrings[0].accounts[0], - }), - ).toThrow("Can't sign an empty message"); + it('should not remove primary keyring when address is not normalized', async () => { + await withController(async ({ controller, initialState }) => { + const account = initialState.keyrings[0].accounts[0] as Hex; + // Convert to checksummed/uppercase address (non-normalized), keeping 0x prefix lowercase + const nonNormalizedAccount = `0x${account.slice(2).toUpperCase()}`; + await expect( + controller.removeAccount(nonNormalizedAccount), + ).rejects.toThrow( + KeyringControllerErrorMessage.LastAccountInPrimaryKeyring, + ); + expect(controller.state.keyrings).toHaveLength(1); + expect(controller.state.keyrings[0].accounts).toHaveLength(1); + }); }); - }); - it('should not sign message if from account is not passed', async () => { - await withController(async ({ controller }) => { - await expect( - controller.signMessage({ - data: '0x879a053d4800c6354e76c7985a865d2922c82fb5b3f4577b2fe08b998954f2e0', - from: '', - }), - ).rejects.toThrow( - 'KeyringController - No keyring found. Error info: The address passed in is invalid/empty', - ); + it('should not remove primary keyring if it has no accounts even if it has more than one HD keyring', async () => { + await withController(async ({ controller }) => { + await controller.addNewKeyring(KeyringTypes.hd); + await expect( + controller.removeAccount(controller.state.keyrings[0].accounts[0]), + ).rejects.toThrow( + KeyringControllerErrorMessage.LastAccountInPrimaryKeyring, + ); + }); }); - }); - }); - describe('signPersonalMessage', () => { - it('should sign personal message', async () => { - await withController(async ({ controller, initialState }) => { - const data = bufferToHex(Buffer.from('Hello from test', 'utf8')); - const account = initialState.keyrings[0].accounts[0]; - const signature = await controller.signPersonalMessage({ - data, - from: account, + it('should remove account', async () => { + await withController(async ({ controller, initialState }) => { + await controller.importAccountWithStrategy( + AccountImportStrategy.privateKey, + [privateKey], + ); + await controller.removeAccount( + '0x51253087e6f8358b5f10c0a94315d69db3357859', + ); + expect(controller.state).toStrictEqual(initialState); }); - const recovered = recoverPersonalSignature({ data, signature }); - expect(account).toBe(recovered); }); - }); - /** - * signPersonalMessage does not fail for empty data value - * https://github.com/MetaMask/core/issues/799 - */ - it('should sign personal message even if empty data is passed', async () => { - await withController(async ({ controller, initialState }) => { - const account = initialState.keyrings[0].accounts[0]; - const signature = await controller.signPersonalMessage({ - data: '', - from: account, + it('should emit `accountRemoved` event', async () => { + await withController(async ({ controller, messenger }) => { + await controller.importAccountWithStrategy( + AccountImportStrategy.privateKey, + [privateKey], + ); + const listener = jest.fn(); + messenger.subscribe('KeyringController:accountRemoved', listener); + + const removedAccount = '0x51253087e6f8358b5f10c0a94315d69db3357859'; + await controller.removeAccount(removedAccount); + + expect(listener).toHaveBeenCalledWith(removedAccount); }); - const recovered = recoverPersonalSignature({ data: '', signature }); - expect(account).toBe(recovered); }); - }); - it('should not sign personal message if from account is not passed', async () => { - await withController(async ({ controller }) => { - await expect( - controller.signPersonalMessage({ - data: '0x879a053d4800c6354e76c7985a865d2922c82fb5b3f4577b2fe08b998954f2e0', - from: '', - }), - ).rejects.toThrow( - 'KeyringController - No keyring found. Error info: The address passed in is invalid/empty', - ); - }); - }); - }); + it('should not remove account if wrong address is provided', async () => { + await withController(async ({ controller }) => { + await controller.importAccountWithStrategy( + AccountImportStrategy.privateKey, + [privateKey], + ); - describe('signTypedMessage', () => { - it('should throw when given invalid version', async () => { - await withController( - // @ts-expect-error QRKeyring is not yet compatible with Keyring type. - { keyringBuilders: [keyringBuilderFactory(QRKeyring)] }, - async ({ controller, initialState }) => { - const typedMsgParams = [ + await expect( + controller.removeAccount( + '0x0000000000000000000000000000000000000000', + ), + ).rejects.toThrow('KeyringController - No keyring found'); + + await expect( + controller.removeAccount('0xDUMMY_INPUT'), + ).rejects.toThrow('KeyringController - No keyring found'); + }); + }); + + it('should remove the keyring if last account is removed and its not primary keyring', async () => { + await withController(async ({ controller }) => { + await controller.addNewKeyring(KeyringTypes.hd); + expect(controller.state.keyrings).toHaveLength(2); + await controller.removeAccount( + controller.state.keyrings[1].accounts[0], + ); + expect(controller.state.keyrings).toHaveLength(1); + }); + }); + + it('should not remove other empty keyrings when removing an account', async () => { + await withController(async ({ controller }) => { + // Import an account, creating a Simple keyring with 1 account + const importedAccount = await controller.importAccountWithStrategy( + AccountImportStrategy.privateKey, + [privateKey], + ); + + // Add an empty Simple keyring (no accounts) + await controller.addNewKeyring(KeyringTypes.simple); + + // We now have: 1 HD keyring + 1 Simple keyring (with account) + 1 empty Simple keyring = 3 keyrings + expect(controller.state.keyrings).toHaveLength(3); + expect(controller.state.keyrings[1].accounts).toStrictEqual([ + importedAccount, + ]); + expect(controller.state.keyrings[2].accounts).toStrictEqual([]); + + // Remove the imported account (empties the first Simple keyring) + await controller.removeAccount(importedAccount); + + // Only the targeted keyring should be removed, the other empty Simple keyring should remain + expect(controller.state.keyrings).toHaveLength(2); + expect(controller.state.keyrings[0].type).toBe(KeyringTypes.hd); + expect(controller.state.keyrings[1].type).toBe(KeyringTypes.simple); + expect(controller.state.keyrings[1].accounts).toStrictEqual([]); + }); + }); + + it('should await an async removeAccount method before removing the keyring', async () => { + const address = '0x5AC6D462f054690a373FABF8CC28e161003aEB19'; + + // Track async operation state + let removeAccountCompleted = false; + let keyringCountDuringRemove: number | undefined; + + // Create a mock keyring class with an async removeAccount + class AsyncRemoveAccountKeyring extends MockKeyring { + static override type = 'Async Remove Account Keyring'; + + override type = 'Async Remove Account Keyring'; + + removeAccount = jest.fn(async () => { + // Simulate async operation with a delay + await new Promise((resolve) => setTimeout(resolve, 10)); + removeAccountCompleted = true; + }); + } + + stubKeyringClassWithAccount(AsyncRemoveAccountKeyring, address); + + await withController( + { + keyringBuilders: [keyringBuilderFactory(AsyncRemoveAccountKeyring)], + }, + async ({ controller, messenger }) => { + await controller.addNewKeyring(AsyncRemoveAccountKeyring.type); + expect(controller.state.keyrings).toHaveLength(2); + + // Subscribe to state changes to capture timing + messenger.subscribe('KeyringController:stateChange', () => { + // Record keyring count when state changes and removeAccount hasn't completed yet + if ( + !removeAccountCompleted && + keyringCountDuringRemove === undefined + ) { + keyringCountDuringRemove = controller.state.keyrings.length; + } + }); + + await controller.removeAccount(address); + + // Verify removeAccount completed before the keyring was removed + expect(removeAccountCompleted).toBe(true); + // The keyring should only be removed after removeAccount completes, + // so the first state change should still have 2 keyrings (or be undefined if no change occurred before completion) + // After completion, keyring count should be 1 + expect(controller.state.keyrings).toHaveLength(1); + }, + ); + }); + }); + + describe('when the keyring for the given address does not support removeAccount', () => { + it('should throw error', async () => { + const address = '0x5AC6D462f054690a373FABF8CC28e161003aEB19'; + stubKeyringClassWithAccount(MockKeyring, address); + await withController( + { keyringBuilders: [keyringBuilderFactory(MockKeyring)] }, + async ({ controller }) => { + await controller.addNewKeyring(MockKeyring.type); + + await expect(controller.removeAccount(address)).rejects.toThrow( + KeyringControllerErrorMessage.UnsupportedRemoveAccount, + ); + }, + ); + }); + }); + + it('should throw error when the controller is locked', async () => { + await withController(async ({ controller, initialState }) => { + await controller.setLocked(); + + await expect( + controller.removeAccount(initialState.keyrings[0].accounts[0]), + ).rejects.toThrow(KeyringControllerErrorMessage.ControllerLocked); + }); + }); + }); + + describe('signMessage', () => { + describe('when the keyring for the given address supports signMessage', () => { + it('should sign message', async () => { + await withController(async ({ controller, initialState }) => { + const data = + '0x879a053d4800c6354e76c7985a865d2922c82fb5b3f4577b2fe08b998954f2e0'; + const account = initialState.keyrings[0].accounts[0]; + const signature = await controller.signMessage({ + data, + from: account, + }); + expect(signature).not.toBe(''); + }); + }); + + it('should not sign message if empty data is passed', async () => { + await withController(async ({ controller, initialState }) => { + await expect(() => + controller.signMessage({ + data: '', + from: initialState.keyrings[0].accounts[0], + }), + ).rejects.toThrow("Can't sign an empty message"); + }); + }); + + it('should not sign message if from account is not passed', async () => { + await withController(async ({ controller }) => { + await expect( + controller.signMessage({ + data: '0x879a053d4800c6354e76c7985a865d2922c82fb5b3f4577b2fe08b998954f2e0', + from: '', + }), + ).rejects.toThrow(KeyringControllerErrorMessage.KeyringNotFound); + }); + }); + }); + + describe('when the keyring for the given address does not support signMessage', () => { + it('should throw error', async () => { + const address = '0x5AC6D462f054690a373FABF8CC28e161003aEB19'; + stubKeyringClassWithAccount(MockKeyring, address); + await withController( + { keyringBuilders: [keyringBuilderFactory(MockKeyring)] }, + async ({ controller }) => { + await controller.addNewKeyring(MockKeyring.type); + const inputParams = { + from: address, + data: '0x879a053d4800c6354e76c7985a865d2922c82fb5b3f4577b2fe08b998954f2e0', + origin: 'https://metamask.github.io', + }; + + await expect(controller.signMessage(inputParams)).rejects.toThrow( + KeyringControllerErrorMessage.UnsupportedSignMessage, + ); + }, + ); + }); + }); + + it('should throw error when the controller is locked', async () => { + await withController(async ({ controller, initialState }) => { + await controller.setLocked(); + + await expect( + controller.signMessage({ + from: initialState.keyrings[0].accounts[0], + data: '0x879a053d4800c6354e76c7985a865d2922c82fb5b3f4577b2fe08b998954f2e0', + origin: 'https://metamask.github.io', + }), + ).rejects.toThrow(KeyringControllerErrorMessage.ControllerLocked); + }); + }); + }); + + describe('signPersonalMessage', () => { + describe('when the keyring for the given address supports signPersonalMessage', () => { + it('should sign personal message', async () => { + await withController(async ({ controller, initialState }) => { + const data = bytesToHex( + new Uint8Array(Buffer.from('Hello from test', 'utf8')), + ); + const account = initialState.keyrings[0].accounts[0]; + const signature = await controller.signPersonalMessage({ + data, + from: account, + }); + const recovered = recoverPersonalSignature({ data, signature }); + expect(account).toBe(recovered); + }); + }); + + /** + * signPersonalMessage does not fail for empty data value + * https://github.com/MetaMask/core/issues/799 + */ + it('should sign personal message even if empty data is passed', async () => { + await withController(async ({ controller, initialState }) => { + const account = initialState.keyrings[0].accounts[0]; + const signature = await controller.signPersonalMessage({ + data: '', + from: account, + }); + const recovered = recoverPersonalSignature({ data: '', signature }); + expect(account).toBe(recovered); + }); + }); + + it('should not sign personal message if from account is not passed', async () => { + await withController(async ({ controller }) => { + await expect( + controller.signPersonalMessage({ + data: '0x879a053d4800c6354e76c7985a865d2922c82fb5b3f4577b2fe08b998954f2e0', + from: '', + }), + ).rejects.toThrow(KeyringControllerErrorMessage.KeyringNotFound); + }); + }); + }); + + describe('when the keyring for the given address does not support signPersonalMessage', () => { + it('should throw error', async () => { + const address = '0x5AC6D462f054690a373FABF8CC28e161003aEB19'; + stubKeyringClassWithAccount(MockKeyring, address); + await withController( + { keyringBuilders: [keyringBuilderFactory(MockKeyring)] }, + async ({ controller }) => { + await controller.addNewKeyring(MockKeyring.type); + const inputParams = { + from: address, + data: '0x879a053d4800c6354e76c7985a865d2922c82fb5b3f4577b2fe08b998954f2e0', + origin: 'https://metamask.github.io', + }; + + await expect( + controller.signPersonalMessage(inputParams), + ).rejects.toThrow( + KeyringControllerErrorMessage.UnsupportedSignPersonalMessage, + ); + }, + ); + }); + }); + + it('should throw error when the controller is locked', async () => { + await withController(async ({ controller, initialState }) => { + await controller.setLocked(); + + await expect( + controller.signPersonalMessage({ + from: initialState.keyrings[0].accounts[0], + data: '0x879a053d4800c6354e76c7985a865d2922c82fb5b3f4577b2fe08b998954f2e0', + origin: 'https://metamask.github.io', + }), + ).rejects.toThrow(KeyringControllerErrorMessage.ControllerLocked); + }); + }); + }); + + describe('signEip7702Authorization', () => { + const from = '0x5AC6D462f054690a373FABF8CC28e161003aEB19'; + stubKeyringClassWithAccount(MockKeyring, from); + const chainId = 1; + const contractAddress = '0x6B175474E89094C44Da98b954EedeAC495271d0F'; + const nonce = 1; + + describe('when the keyring for the given address supports signEip7702Authorization', () => { + it('should sign EIP-7702 authorization message', async () => { + await withController(async ({ controller, initialState }) => { + const account = initialState.keyrings[0].accounts[0]; + const signature = await controller.signEip7702Authorization({ + from: account, + chainId, + contractAddress, + nonce, + }); + + const recovered = recoverEIP7702Authorization({ + authorization: [chainId, contractAddress, nonce], + signature, + }); + + expect(recovered).toBe(account); + }); + }); + + it('should not sign EIP-7702 authorization message if from account is not passed', async () => { + await withController(async ({ controller }) => { + await expect( + controller.signEip7702Authorization({ + chainId, + contractAddress, + nonce, + from: '', + }), + ).rejects.toThrow(KeyringControllerErrorMessage.KeyringNotFound); + }); + }); + + it.each([undefined, null])( + 'should throw error if contract address is %s', + async (invalidContractAddress) => { + await withController(async ({ controller, initialState }) => { + const account = initialState.keyrings[0].accounts[0]; + await expect( + controller.signEip7702Authorization({ + from: account, + chainId, + contractAddress: invalidContractAddress as unknown as string, + nonce, + }), + ).rejects.toThrow( + KeyringControllerErrorMessage.MissingEip7702AuthorizationContractAddress, + ); + }); + }, + ); + }); + + describe('when the keyring for the given address does not support signEip7702Authorization', () => { + it('should throw error', async () => { + stubKeyringClassWithAccount(MockKeyring, from); + + await withController( + { keyringBuilders: [keyringBuilderFactory(MockKeyring)] }, + async ({ controller }) => { + await controller.addNewKeyring(MockKeyring.type); + + await expect( + controller.signEip7702Authorization({ + from, + chainId, + contractAddress, + nonce, + }), + ).rejects.toThrow( + KeyringControllerErrorMessage.UnsupportedSignEip7702Authorization, + ); + }, + ); + }); + }); + }); + + describe('signTypedMessage', () => { + describe('when the keyring for the given address supports signTypedMessage', () => { + it('should throw when given invalid version', async () => { + await withController(async ({ controller, initialState }) => { + const typedMsgParams = [ { name: 'Message', type: 'string', @@ -1090,17 +2163,13 @@ describe('KeyringController', () => { 'junk' as SignTypedDataVersion, ), ).rejects.toThrow( - "Keyring Controller signTypedMessage: Error: Unexpected signTypedMessage version: 'junk'", + "Keyring Controller signTypedMessage: KeyringControllerError: Unexpected signTypedMessage version: 'junk'", ); - }, - ); - }); + }); + }); - it('should sign typed message V1', async () => { - await withController( - // @ts-expect-error QRKeyring is not yet compatible with Keyring type. - { keyringBuilders: [keyringBuilderFactory(QRKeyring)] }, - async ({ controller, initialState }) => { + it('should sign typed message V1', async () => { + await withController(async ({ controller, initialState }) => { const typedMsgParams = [ { name: 'Message', @@ -1124,15 +2193,11 @@ describe('KeyringController', () => { version: SignTypedDataVersion.V1, }); expect(account).toBe(recovered); - }, - ); - }); + }); + }); - it('should sign typed message V3', async () => { - await withController( - // @ts-expect-error QRKeyring is not yet compatible with Keyring type. - { keyringBuilders: [keyringBuilderFactory(QRKeyring)] }, - async ({ controller, initialState }) => { + it('should sign typed message V3', async () => { + await withController(async ({ controller, initialState }) => { const msgParams = { domain: { chainId: 1, @@ -1181,15 +2246,11 @@ describe('KeyringController', () => { version: SignTypedDataVersion.V3, }); expect(account).toBe(recovered); - }, - ); - }); + }); + }); - it('should sign typed message V4', async () => { - await withController( - // @ts-expect-error QRKeyring is not yet compatible with Keyring type. - { keyringBuilders: [keyringBuilderFactory(QRKeyring)] }, - async ({ controller, initialState }) => { + it('should sign typed message V4', async () => { + await withController(async ({ controller, initialState }) => { const msgParams = { domain: { chainId: 1, @@ -1252,90 +2313,126 @@ describe('KeyringController', () => { version: SignTypedDataVersion.V4, }); expect(account).toBe(recovered); - }, - ); - }); + }); + }); - it('should fail when sign typed message format is wrong', async () => { - await withController(async ({ controller, initialState }) => { - const msgParams = [{}]; - const account = initialState.keyrings[0].accounts[0]; + it('should fail when sign typed message format is wrong', async () => { + await withController(async ({ controller, initialState }) => { + const msgParams = [{}]; + const account = initialState.keyrings[0].accounts[0]; - await expect( - controller.signTypedMessage( - { data: msgParams, from: account }, - SignTypedDataVersion.V1, - ), - ).rejects.toThrow('Keyring Controller signTypedMessage:'); + await expect( + controller.signTypedMessage( + { data: msgParams, from: account }, + SignTypedDataVersion.V1, + ), + ).rejects.toThrow('Keyring Controller signTypedMessage:'); - await expect( - controller.signTypedMessage( - { data: msgParams, from: account }, - SignTypedDataVersion.V3, - ), - ).rejects.toThrow('Keyring Controller signTypedMessage:'); + await expect( + controller.signTypedMessage( + { data: msgParams, from: account }, + SignTypedDataVersion.V3, + ), + ).rejects.toThrow('Keyring Controller signTypedMessage:'); + }); }); - }); - it('should fail in signing message when from address is not provided', async () => { - await withController(async ({ controller }) => { - const typedMsgParams = [ - { - name: 'Message', - type: 'string', - value: 'Hi, Alice!', - }, - { - name: 'A number', - type: 'uint32', - value: '1337', + it('should fail in signing message when from address is not provided', async () => { + await withController(async ({ controller }) => { + const typedMsgParams = [ + { + name: 'Message', + type: 'string', + value: 'Hi, Alice!', + }, + { + name: 'A number', + type: 'uint32', + value: '1337', + }, + ]; + await expect( + controller.signTypedMessage( + { data: typedMsgParams, from: '' }, + SignTypedDataVersion.V1, + ), + ).rejects.toThrow(/^Keyring Controller signTypedMessage:/u); + }); + }); + }); + + describe('when the keyring for the given address does not support signTypedMessage', () => { + it('should throw error', async () => { + const address = '0x5AC6D462f054690a373FABF8CC28e161003aEB19'; + stubKeyringClassWithAccount(MockKeyring, address); + await withController( + { keyringBuilders: [keyringBuilderFactory(MockKeyring)] }, + async ({ controller }) => { + await controller.addNewKeyring(MockKeyring.type); + const inputParams = { + from: address, + data: [ + { + type: 'string', + name: 'Message', + value: 'Hi, Alice!', + }, + { + type: 'uint32', + name: 'A number', + value: '1337', + }, + ], + origin: 'https://metamask.github.io', + }; + + await expect( + controller.signTypedMessage(inputParams, SignTypedDataVersion.V1), + ).rejects.toThrow( + KeyringControllerErrorMessage.UnsupportedSignTypedMessage, + ); }, - ]; + ); + }); + }); + + it('should throw error when the controller is locked', async () => { + await withController(async ({ controller, initialState }) => { + await controller.setLocked(); + await expect( controller.signTypedMessage( - { data: typedMsgParams, from: '' }, + { + from: initialState.keyrings[0].accounts[0], + data: [ + { + type: 'string', + name: 'Message', + value: 'Hi, Alice!', + }, + { + type: 'uint32', + name: 'A number', + value: '1337', + }, + ], + origin: 'https://metamask.github.io', + }, SignTypedDataVersion.V1, ), - ).rejects.toThrow(/^Keyring Controller signTypedMessage:/u); + ).rejects.toThrow(KeyringControllerErrorMessage.ControllerLocked); }); }); }); describe('signTransaction', () => { - it('should sign transaction', async () => { - await withController(async ({ controller, initialState }) => { - const account = initialState.keyrings[0].accounts[0]; - const txParams = { - chainId: 5, - data: '0x1', - from: account, - gasLimit: '0x5108', - gasPrice: '0x5108', - to: '0x51253087e6f8358b5f10c0a94315d69db3357859', - value: '0x5208', - }; - const unsignedEthTx = TransactionFactory.fromTxData(txParams, { - common: new Common(commonConfig), - freeze: false, - }); - expect(unsignedEthTx.v).toBeUndefined(); - const signedTx = await controller.signTransaction( - unsignedEthTx, - account, - ); - expect(signedTx.v).toBeDefined(); - expect(signedTx).not.toBe(''); - }); - }); - - it('should not sign transaction if from account is not provided', async () => { - await withController(async ({ controller, initialState }) => { - await expect(async () => { + describe('when the keyring for the given address supports signTransaction', () => { + it('should sign transaction', async () => { + await withController(async ({ controller, initialState }) => { const account = initialState.keyrings[0].accounts[0]; - const txParams = { + const txParams: TypedTxData = { chainId: 5, data: '0x1', - from: account, gasLimit: '0x5108', gasPrice: '0x5108', to: '0x51253087e6f8358b5f10c0a94315d69db3357859', @@ -1346,726 +2443,2901 @@ describe('KeyringController', () => { freeze: false, }); expect(unsignedEthTx.v).toBeUndefined(); - await controller.signTransaction(unsignedEthTx, ''); - }).rejects.toThrow( - 'KeyringController - No keyring found. Error info: The address passed in is invalid/empty', + const signedTx = await controller.signTransaction( + unsignedEthTx, + account, + ); + expect(signedTx.v).toBeDefined(); + expect(signedTx).not.toBe(''); + }); + }); + + it('should not sign transaction if from account is not provided', async () => { + await withController(async ({ controller }) => { + await expect(async () => { + const txParams: TypedTxData = { + chainId: 5, + data: '0x1', + gasLimit: '0x5108', + gasPrice: '0x5108', + to: '0x51253087e6f8358b5f10c0a94315d69db3357859', + value: '0x5208', + }; + const unsignedEthTx = TransactionFactory.fromTxData(txParams, { + common: new Common(commonConfig), + freeze: false, + }); + expect(unsignedEthTx.v).toBeUndefined(); + await controller.signTransaction(unsignedEthTx, ''); + }).rejects.toThrow(KeyringControllerErrorMessage.KeyringNotFound); + }); + }); + + /** + * Task added to improve check for valid transaction in signTransaction method + * https://github.com/MetaMask/core/issues/800 + */ + it('should not sign transaction if transaction is not valid', async () => { + await withController(async ({ controller, initialState }) => { + await expect(async () => { + const account = initialState.keyrings[0].accounts[0]; + // @ts-expect-error invalid transaction + await controller.signTransaction({}, account); + }).rejects.toThrow('tx.sign is not a function'); + }); + }); + }); + + describe('when the keyring for the given address does not support signTransaction', () => { + it('should throw if the keyring for the given address does not support signTransaction', async () => { + const address = '0x5AC6D462f054690a373FABF8CC28e161003aEB19'; + stubKeyringClassWithAccount(MockKeyring, address); + await withController( + { keyringBuilders: [keyringBuilderFactory(MockKeyring)] }, + async ({ controller }) => { + await controller.addNewKeyring(MockKeyring.type); + + await expect( + controller.signTransaction(buildMockTransaction(), address), + ).rejects.toThrow( + KeyringControllerErrorMessage.UnsupportedSignTransaction, + ); + }, ); }); }); - /** - * Task added to improve check for valid transaction in signTransaction method - * https://github.com/MetaMask/core/issues/800 - */ - it('should not sign transaction if transaction is not valid', async () => { + it('should throw error when the controller is locked', async () => { await withController(async ({ controller, initialState }) => { - await expect(async () => { - const account = initialState.keyrings[0].accounts[0]; - // @ts-expect-error invalid transaction - await controller.signTransaction({}, account); - }).rejects.toThrow('tx.sign is not a function'); + await controller.setLocked(); + + await expect( + controller.signTransaction( + buildMockTransaction(), + initialState.keyrings[0].accounts[0], + ), + ).rejects.toThrow(KeyringControllerErrorMessage.ControllerLocked); }); }); }); - describe('submitPassword', () => { - [false, true].map((cacheEncryptionKey) => - describe(`when cacheEncryptionKey is ${cacheEncryptionKey}`, () => { - it('should submit password and decrypt', async () => { - await withController( - { cacheEncryptionKey }, - async ({ controller, initialState }) => { - await controller.submitPassword(password); - expect(controller.state).toStrictEqual(initialState); - }, - ); - }); + describe('prepareUserOperation', () => { + const chainId = '0x1'; + const executionContext = { + chainId, + }; + describe('when the keyring for the given address supports prepareUserOperation', () => { + it('should prepare base user operation', async () => { + const address = '0x660265edc169bab511a40c0e049cc1e33774443d'; + stubKeyringClassWithAccount(MockErc4337Keyring, address); + await withController( + { keyringBuilders: [keyringBuilderFactory(MockErc4337Keyring)] }, + async ({ controller }) => { + const { id } = await controller.addNewKeyring( + MockErc4337Keyring.type, + ); + const baseUserOp = { + callData: '0x7064', + initCode: '0x22ff', + nonce: '0x1', + gasLimits: { + callGasLimit: '0x58a83', + verificationGasLimit: '0xe8c4', + preVerificationGas: '0xc57c', + }, + dummySignature: '0x', + dummyPaymasterAndData: '0x', + bundlerUrl: 'https://bundler.example.com/rpc', + }; + const baseTxs = [ + { + to: '', + value: '0x0', + data: '0x7064', + }, + ]; + await controller.withKeyring({ id }, async ({ keyring }) => { + jest + .spyOn(keyring, 'prepareUserOperation') + .mockResolvedValueOnce(baseUserOp); + + const result = await controller.prepareUserOperation( + address, + baseTxs, + executionContext, + ); - it('should emit KeyringController:unlock event', async () => { - await withController( - { cacheEncryptionKey }, - async ({ controller, messenger }) => { - const listener = sinon.spy(); - messenger.subscribe('KeyringController:unlock', listener); - await controller.submitPassword(password); - expect(listener.called).toBe(true); + expect(result).toStrictEqual(baseUserOp); + expect(keyring.prepareUserOperation).toHaveBeenCalledTimes(1); + expect(keyring.prepareUserOperation).toHaveBeenCalledWith( + address, + baseTxs, + executionContext, + ); + }); + }, + ); + }); + }); + + describe('when the keyring for the given address does not support prepareUserOperation', () => { + it('should throw error', async () => { + const address = '0x660265edc169bab511a40c0e049cc1e33774443d'; + stubKeyringClassWithAccount(MockKeyring, address); + await withController( + { keyringBuilders: [keyringBuilderFactory(MockKeyring)] }, + async ({ controller }) => { + await controller.addNewKeyring(MockKeyring.type); + + await expect( + controller.prepareUserOperation(address, [], executionContext), + ).rejects.toThrow( + KeyringControllerErrorMessage.UnsupportedPrepareUserOperation, + ); + }, + ); + }); + }); + + it('should throw error when the controller is locked', async () => { + await withController(async ({ controller, initialState }) => { + await controller.setLocked(); + + await expect( + controller.prepareUserOperation( + initialState.keyrings[0].accounts[0], + [], + executionContext, + ), + ).rejects.toThrow(KeyringControllerErrorMessage.ControllerLocked); + }); + }); + }); + + describe('patchUserOperation', () => { + const chainId = '0x1'; + const executionContext = { + chainId, + }; + + describe('when the keyring for the given address supports patchUserOperation', () => { + it('should patch an user operation', async () => { + const address = '0x660265edc169bab511a40c0e049cc1e33774443d'; + stubKeyringClassWithAccount(MockErc4337Keyring, address); + await withController( + { keyringBuilders: [keyringBuilderFactory(MockErc4337Keyring)] }, + async ({ controller }) => { + const { id } = await controller.addNewKeyring( + MockErc4337Keyring.type, + ); + const userOp = { + sender: '0x4584d2B4905087A100420AFfCe1b2d73fC69B8E4', + nonce: '0x1', + initCode: '0x', + callData: '0x7064', + callGasLimit: '0x58a83', + verificationGasLimit: '0xe8c4', + preVerificationGas: '0xc57c', + maxFeePerGas: '0x87f0878c0', + maxPriorityFeePerGas: '0x1dcd6500', + paymasterAndData: '0x', + signature: '0x', + }; + const patch = { + paymasterAndData: '0x1234', + }; + await controller.withKeyring({ id }, async ({ keyring }) => { + jest + .spyOn(keyring, 'patchUserOperation') + .mockResolvedValueOnce(patch); + + const result = await controller.patchUserOperation( + address, + userOp, + executionContext, + ); + + expect(result).toStrictEqual(patch); + expect(keyring.patchUserOperation).toHaveBeenCalledTimes(1); + expect(keyring.patchUserOperation).toHaveBeenCalledWith( + address, + userOp, + executionContext, + ); + }); + }, + ); + }); + }); + + describe('when the keyring for the given address does not support patchUserOperation', () => { + it('should throw error', async () => { + const address = '0x660265edc169bab511a40c0e049cc1e33774443d'; + stubKeyringClassWithAccount(MockKeyring, address); + await withController( + { keyringBuilders: [keyringBuilderFactory(MockKeyring)] }, + async ({ controller }) => { + await controller.addNewKeyring(MockKeyring.type); + const userOp = { + sender: '0x4584d2B4905087A100420AFfCe1b2d73fC69B8E4', + nonce: '0x1', + initCode: '0x', + callData: '0x7064', + callGasLimit: '0x58a83', + verificationGasLimit: '0xe8c4', + preVerificationGas: '0xc57c', + maxFeePerGas: '0x87f0878c0', + maxPriorityFeePerGas: '0x1dcd6500', + paymasterAndData: '0x', + signature: '0x', + }; + + await expect( + controller.patchUserOperation(address, userOp, executionContext), + ).rejects.toThrow( + KeyringControllerErrorMessage.UnsupportedPatchUserOperation, + ); + }, + ); + }); + }); + + it('should throw error when the controller is locked', async () => { + await withController(async ({ controller, initialState }) => { + await controller.setLocked(); + + await expect( + controller.patchUserOperation( + initialState.keyrings[0].accounts[0], + { + sender: '0x4584d2B4905087A100420AFfCe1b2d73fC69B8E4', + nonce: '0x1', + initCode: '0x', + callData: '0x7064', + callGasLimit: '0x58a83', + verificationGasLimit: '0xe8c4', + preVerificationGas: '0xc57c', + maxFeePerGas: '0x87f0878c0', + maxPriorityFeePerGas: '0x1dcd6500', + paymasterAndData: '0x', + signature: '0x', }, - ); - }); + executionContext, + ), + ).rejects.toThrow(KeyringControllerErrorMessage.ControllerLocked); + }); + }); + }); + + describe('signUserOperation', () => { + const chainId = '0x1'; + const executionContext = { + chainId, + }; + describe('when the keyring for the given address supports signUserOperation', () => { + it('should sign an user operation', async () => { + const address = '0x660265edc169bab511a40c0e049cc1e33774443d'; + stubKeyringClassWithAccount(MockErc4337Keyring, address); + await withController( + { keyringBuilders: [keyringBuilderFactory(MockErc4337Keyring)] }, + async ({ controller }) => { + const { id } = await controller.addNewKeyring( + MockErc4337Keyring.type, + ); + const userOp = { + sender: '0x4584d2B4905087A100420AFfCe1b2d73fC69B8E4', + nonce: '0x1', + initCode: '0x', + callData: '0x7064', + callGasLimit: '0x58a83', + verificationGasLimit: '0xe8c4', + preVerificationGas: '0xc57c', + maxFeePerGas: '0x87f0878c0', + maxPriorityFeePerGas: '0x1dcd6500', + paymasterAndData: '0x', + signature: '0x', + }; + const signature = '0x1234'; + await controller.withKeyring({ id }, async ({ keyring }) => { + jest + .spyOn(keyring, 'signUserOperation') + .mockResolvedValueOnce(signature); + + const result = await controller.signUserOperation( + address, + userOp, + executionContext, + ); - cacheEncryptionKey && - it('should set encryptionKey and encryptionSalt in state', async () => { - withController({ cacheEncryptionKey }, async ({ controller }) => { - await controller.submitPassword(password); - expect(controller.state.encryptionKey).toBeDefined(); - expect(controller.state.encryptionSalt).toBeDefined(); + expect(result).toStrictEqual(signature); + expect(keyring.signUserOperation).toHaveBeenCalledTimes(1); + expect(keyring.signUserOperation).toHaveBeenCalledWith( + address, + userOp, + executionContext, + ); }); - }); - }), - ); + }, + ); + }); + }); + + describe('when the keyring for the given address does not support signUserOperation', () => { + it('should throw error', async () => { + const address = '0x660265edc169bab511a40c0e049cc1e33774443d'; + stubKeyringClassWithAccount(MockKeyring, address); + await withController( + { keyringBuilders: [keyringBuilderFactory(MockKeyring)] }, + async ({ controller }) => { + await controller.addNewKeyring(MockKeyring.type); + const userOp = { + sender: '0x4584d2B4905087A100420AFfCe1b2d73fC69B8E4', + nonce: '0x1', + initCode: '0x', + callData: '0x7064', + callGasLimit: '0x58a83', + verificationGasLimit: '0xe8c4', + preVerificationGas: '0xc57c', + maxFeePerGas: '0x87f0878c0', + maxPriorityFeePerGas: '0x1dcd6500', + paymasterAndData: '0x', + signature: '0x', + }; + + await expect( + controller.signUserOperation(address, userOp, executionContext), + ).rejects.toThrow( + KeyringControllerErrorMessage.UnsupportedSignUserOperation, + ); + }, + ); + }); + }); + + it('should throw error when the controller is locked', async () => { + await withController(async ({ controller, initialState }) => { + await controller.setLocked(); + + await expect( + controller.signUserOperation( + initialState.keyrings[0].accounts[0], + { + sender: '0x4584d2B4905087A100420AFfCe1b2d73fC69B8E4', + nonce: '0x1', + initCode: '0x', + callData: '0x7064', + callGasLimit: '0x58a83', + verificationGasLimit: '0xe8c4', + preVerificationGas: '0xc57c', + maxFeePerGas: '0x87f0878c0', + maxPriorityFeePerGas: '0x1dcd6500', + paymasterAndData: '0x', + signature: '0x', + }, + executionContext, + ), + ).rejects.toThrow(KeyringControllerErrorMessage.ControllerLocked); + }); + }); }); - describe('submitEncryptionKey', () => { - it('should submit encryption key and decrypt', async () => { + describe('changePassword', () => { + it('should encrypt the vault with the new password', async () => { + await withController(async ({ controller, encryptor }) => { + const newPassword = 'new-password'; + const keyFromPasswordSpy = jest.spyOn(encryptor, 'keyFromPassword'); + + await controller.changePassword(newPassword); + + expect(keyFromPasswordSpy).toHaveBeenCalledWith( + newPassword, + controller.state.encryptionSalt, + true, + ); + }); + }); + + it('should throw error if `isUnlocked` is false', async () => { + await withController(async ({ controller }) => { + await controller.setLocked(); + + await expect(async () => controller.changePassword('')).rejects.toThrow( + KeyringControllerErrorMessage.ControllerLocked, + ); + }); + }); + + it('should throw error if the new password is an empty string', async () => { + await withController(async ({ controller }) => { + await expect(controller.changePassword('')).rejects.toThrow( + KeyringControllerErrorMessage.InvalidEmptyPassword, + ); + }); + }); + + it('should throw error if the new password is undefined', async () => { + await withController(async ({ controller }) => { + await expect( + // @ts-expect-error we are testing wrong input + controller.changePassword(undefined), + ).rejects.toThrow(KeyringControllerErrorMessage.WrongPasswordType); + }); + }); + + it('should throw error when the controller is locked', async () => { + await withController(async ({ controller }) => { + await controller.setLocked(); + + await expect(async () => + controller.changePassword('whatever'), + ).rejects.toThrow(KeyringControllerErrorMessage.ControllerLocked); + }); + }); + }); + + describe('submitPassword', () => { + it('should submit password and decrypt', async () => { + await withController(async ({ controller, initialState }) => { + await controller.submitPassword(password); + expect(controller.state).toStrictEqual(initialState); + }); + }); + + it('should emit KeyringController:unlock event', async () => { + await withController(async ({ controller, messenger }) => { + const listener = jest.fn(); + messenger.subscribe('KeyringController:unlock', listener); + await controller.submitPassword(password); + expect(listener).toHaveBeenCalled(); + }); + }); + + it('should unlock also with unsupported keyrings', async () => { await withController( - { cacheEncryptionKey: true }, - async ({ controller, initialState }) => { - await controller.submitEncryptionKey( - mockKey.toString('hex'), - initialState.encryptionSalt as string, - ); - expect(controller.state).toStrictEqual(initialState); + { + skipVaultCreation: true, + state: { + vault: createVault([ + { + type: 'UnsupportedKeyring', + data: '0x1234', + }, + ]), + }, + }, + async ({ controller }) => { + await controller.submitPassword(password); + + expect(controller.state.isUnlocked).toBe(true); }, ); }); - }); - describe('verifySeedPhrase', () => { + it('should throw error if vault unlocked has an unexpected shape', async () => { + await withController( + { + skipVaultCreation: true, + state: { + vault: createVault([ + { + // @ts-expect-error testing invalid vault shape + foo: 'bar', + }, + ]), + }, + }, + async ({ controller }) => { + await expect(controller.submitPassword(password)).rejects.toThrow( + KeyringControllerErrorMessage.VaultDataError, + ); + }, + ); + }); + + it('should throw error if vault is missing', async () => { + await withController( + { skipVaultCreation: true }, + async ({ controller }) => { + await expect(controller.submitPassword(password)).rejects.toThrow( + KeyringControllerErrorMessage.VaultError, + ); + }, + ); + }); + + it('should throw an error if the encryptor returns an undefined encryption key', async () => { + await withController( + { + skipVaultCreation: true, + state: { + vault: createVault(), + encryptionKey: 'existing-key', + } as KeyringControllerState, + }, + async ({ controller, encryptor }) => { + jest.spyOn(encryptor, 'decryptWithDetail').mockResolvedValueOnce({ + vault: defaultKeyrings, + // @ts-expect-error we are testing a broken encryptor + exportedKeyString: undefined, + salt: '', + }); + + await expect(controller.submitPassword(password)).rejects.toThrow( + KeyringControllerErrorMessage.MissingCredentials, + ); + }, + ); + }); + + it('should unlock succesfully when the controller is instantiated with an existing `keyringsMetadata`', async () => { + stubKeyringClassWithAccount(HdKeyring, '0x123'); + await withController( + { + state: { + vault: createVault([ + { + type: KeyringTypes.hd, + data: { + accounts: ['0x123'], + }, + metadata: { + id: '123', + name: '', + }, + }, + ]), + }, + skipVaultCreation: true, + }, + async ({ controller }) => { + await controller.submitPassword(password); + + expect(controller.state.keyrings).toStrictEqual([ + { + type: KeyringTypes.hd, + accounts: ['0x123'], + metadata: { + id: '123', + name: '', + }, + }, + ]); + }, + ); + }); + + it('should generate new metadata when there is no metadata in the vault', async () => { + const vault = createVault([ + { + type: KeyringTypes.hd, + data: { + accounts: ['0x123'], + }, + }, + ]); + const hdKeyringSerializeSpy = jest.spyOn( + HdKeyring.prototype, + 'serialize', + ); + await withController( + { + state: { + vault, + }, + skipVaultCreation: true, + }, + async ({ controller, encryptor }) => { + const encryptWithKeySpy = jest.spyOn(encryptor, 'encryptWithKey'); + hdKeyringSerializeSpy.mockResolvedValue({ + // @ts-expect-error we are assigning a mock value + accounts: ['0x123'], + }); + + await controller.submitPassword(password); + + expect(controller.state.keyrings).toStrictEqual([ + { + type: KeyringTypes.hd, + accounts: expect.any(Array), + metadata: { + id: expect.any(String), + name: '', + }, + }, + ]); + expect(encryptWithKeySpy).toHaveBeenCalledWith(defaultCredentials, [ + { + type: KeyringTypes.hd, + data: { + accounts: ['0x123'], + }, + metadata: { + id: expect.any(String), + name: '', + }, + }, + ]); + }, + ); + }); + + it('should update the vault if the keyring state changes during deserialization', async () => { + const oldState = { version: 1, foo: 'bar' }; + const newState = { version: 2, foo: 'bar' }; + + // A keyring that migrates its own state in-place during deserialization: after + // deserialize(oldState), the original `data` object is mutated and serialize() + // returns the updated state. + class MigratingKeyring { + static type = 'Migrating Keyring'; + + type = 'Migrating Keyring'; + + #state: Record = {}; + + async serialize(): Promise> { + return this.#state; + } + + async deserialize(data: Record): Promise { + // Mutate in-place to simulate a keyring that upgrades its own format. + data.version = 2; + this.#state = data; + } + + async getAccounts(): Promise { + return []; + } + + async addAccounts(): Promise { + return []; + } + } + + await withController( + { + skipVaultCreation: true, + state: { + vault: createVault([ + // #updateVault requires at least one HD keyring to be present. + { + type: KeyringTypes.hd, + data: {}, + metadata: { id: '0', name: '' }, + }, + { + type: MigratingKeyring.type, + data: oldState, + metadata: { id: '1', name: '' }, + }, + ]), + }, + keyringBuilders: [ + keyringBuilderFactory(MigratingKeyring as unknown as KeyringClass), + ], + }, + async ({ controller, encryptor }) => { + const encryptWithKeySpy = jest.spyOn(encryptor, 'encryptWithKey'); + + await controller.submitPassword(password); + + // Migration should have triggered a new vault update that we need to + // re-encrypt: + expect(encryptWithKeySpy).toHaveBeenCalledWith( + defaultCredentials, + expect.arrayContaining([ + expect.objectContaining({ + type: MigratingKeyring.type, + data: newState, + }), + ]), + ); + }, + ); + }); + + it('should unlock the wallet if the state has a duplicate account and the encryption parameters are outdated', async () => { + stubKeyringClassWithAccount(MockKeyring, '0x123'); + stubKeyringClassWithAccount(HdKeyring, '0x123'); + await withController( + { + skipVaultCreation: true, + state: { + vault: createVault([ + { + type: KeyringTypes.hd, + data: {}, + }, + { + type: MockKeyring.type, + data: {}, + }, + ]), + }, + keyringBuilders: [keyringBuilderFactory(MockKeyring)], + }, + async ({ controller, encryptor, messenger }) => { + const unlockListener = jest.fn(); + messenger.subscribe('KeyringController:unlock', unlockListener); + jest.spyOn(encryptor, 'isVaultUpdated').mockReturnValue(false); + + await controller.submitPassword(password); + + expect(controller.state.isUnlocked).toBe(true); + expect(unlockListener).toHaveBeenCalledTimes(1); + }, + ); + }); + + it('should unlock the wallet also if encryption parameters are outdated and the vault upgrade fails', async () => { + await withController( + { + skipVaultCreation: true, + state: { + vault: createVault([ + { + type: KeyringTypes.hd, + data: { + accounts: ['0x123'], + }, + }, + ]), + }, + }, + async ({ controller, encryptor }) => { + jest.spyOn(encryptor, 'isVaultUpdated').mockReturnValue(false); + jest.spyOn(encryptor, 'encrypt').mockRejectedValue(new Error()); + + await controller.submitPassword(password); + + expect(controller.state.isUnlocked).toBe(true); + }, + ); + }); + + it('should unlock the wallet discarding existing duplicate accounts', async () => { + stubKeyringClassWithAccount(MockKeyring, '0x123'); + stubKeyringClassWithAccount(HdKeyring, '0x123'); + await withController( + { + skipVaultCreation: true, + state: { + vault: createVault([ + { + type: KeyringTypes.hd, + data: {}, + }, + { + type: MockKeyring.type, + data: {}, + }, + ]), + }, + keyringBuilders: [keyringBuilderFactory(MockKeyring)], + }, + async ({ controller, messenger }) => { + const unlockListener = jest.fn(); + messenger.subscribe('KeyringController:unlock', unlockListener); + + await controller.submitPassword(password); + + expect(controller.state.keyrings).toHaveLength(1); // Second keyring will be skipped as "unsupported". + expect(unlockListener).toHaveBeenCalledTimes(1); + }, + ); + }); + + it('should upgrade the vault encryption if the key encryptor has different parameters', async () => { + await withController( + { + skipVaultCreation: true, + state: { + vault: createVault([ + { + type: KeyringTypes.hd, + data: { + accounts: ['0x123'], + }, + }, + ]), + }, + }, + async ({ controller, encryptor }) => { + jest.spyOn(encryptor, 'isVaultUpdated').mockReturnValue(false); + const encryptSpy = jest.spyOn(encryptor, 'encryptWithKey'); + + await controller.submitPassword(password); + + expect(encryptSpy).toHaveBeenCalledTimes(1); + }, + ); + }); + + it('should not upgrade the vault encryption if the key encryptor has the same parameters', async () => { + await withController( + { + skipVaultCreation: true, + state: { + vault: createVault([ + { + type: KeyringTypes.hd, + data: { + accounts: ['0x123'], + }, + }, + ]), + }, + }, + async ({ controller, encryptor }) => { + jest.spyOn(encryptor, 'isVaultUpdated').mockReturnValue(true); + const encryptSpy = jest.spyOn(encryptor, 'encrypt'); + + // TODO actually this does trigger re-encryption. The catch is + // that this test is run with cacheEncryptionKey enabled, so + // `encryptWithKey` is being used instead of `encrypt`. Hence, + // the spy on `encrypt` doesn't trigger. + await controller.submitPassword(password); + + expect(encryptSpy).not.toHaveBeenCalled(); + }, + ); + }); + + it('should not upgrade the vault encryption if the encryptor has the same parameters and the keyring has metadata', async () => { + await withController( + { + skipVaultCreation: true, + state: { + vault: createVault([ + { + type: KeyringTypes.hd, + data: { + accounts: ['0x123'], + }, + metadata: { + id: '123', + name: '', + }, + }, + ]), + }, + }, + async ({ controller, encryptor }) => { + jest.spyOn(encryptor, 'isVaultUpdated').mockReturnValue(true); + const encryptSpy = jest.spyOn(encryptor, 'encrypt'); + + await controller.submitPassword(password); + + expect(encryptSpy).not.toHaveBeenCalled(); + }, + ); + }); + + it('should set encryptionKey and encryptionSalt in state', async () => { + // TODO: Either fix this lint violation or explain why it's necessary to ignore. + // eslint-disable-next-line @typescript-eslint/no-floating-promises + withController(async ({ controller }) => { + await controller.submitPassword(password); + expect(controller.state.encryptionKey).toBeDefined(); + expect(controller.state.encryptionSalt).toBeDefined(); + }); + }); + + it('should throw error when using the wrong password', async () => { + await withController( + { + skipVaultCreation: true, + state: { + vault: createVault(), + // @ts-expect-error we want to force the controller to have an + // encryption salt equal to the one in the vault + encryptionSalt: SALT, + }, + }, + async ({ controller }) => { + await expect( + controller.submitPassword('wrong password'), + ).rejects.toThrow(DECRYPTION_ERROR); + }, + ); + }); + + it('should throw an error if the password is not a string', async () => { + await withController(async ({ controller }) => { + await expect( + // @ts-expect-error we are testing wrong input + controller.submitPassword(123456), + ).rejects.toThrow(KeyringControllerErrorMessage.WrongPasswordType); + }); + }); + + it('should siletly fail the key derivation params upgrade if it fails', async () => { + await withController( + { + skipVaultCreation: true, + state: { + vault: createVault([ + { + type: KeyringTypes.hd, + data: { + accounts: ['0x123'], + }, + }, + ]), + }, + }, + async ({ controller, encryptor }) => { + jest.spyOn(encryptor, 'isVaultUpdated').mockReturnValue(false); + jest + .spyOn(encryptor, 'exportKey') + .mockRejectedValue(new Error('Error')); + + await controller.submitPassword(password); + + expect(controller.state.isUnlocked).toBe(true); + }, + ); + }); + }); + + describe('submitEncryptionKey', () => { + it('should submit encryption key and decrypt', async () => { + await withController(async ({ controller, initialState }) => { + await controller.submitEncryptionKey( + MOCK_ENCRYPTION_KEY, + initialState.encryptionSalt as string, + ); + expect(controller.state).toStrictEqual(initialState); + }); + }); + + it('should unlock also with unsupported keyrings', async () => { + await withController( + { + skipVaultCreation: true, + state: { + vault: createVault([ + { + type: 'UnsupportedKeyring', + data: '0x1234', + }, + ]), + // @ts-expect-error we want to force the controller to have an + // encryption salt equal to the one in the vault + encryptionSalt: SALT, + }, + }, + async ({ controller, initialState }) => { + await controller.submitEncryptionKey( + MOCK_ENCRYPTION_KEY, + initialState.encryptionSalt as string, + ); + + expect(controller.state.isUnlocked).toBe(true); + }, + ); + }); + + it('should update the vault if new metadata is created while unlocking', async () => { + jest.spyOn(HdKeyring.prototype, 'serialize').mockResolvedValue({ + // @ts-expect-error we are assigning a mock value + accounts: ['0x123'], + }); + await withController( + { + skipVaultCreation: true, + state: { + vault: createVault([ + { + type: KeyringTypes.hd, + data: '0x123', + }, + ]), + // @ts-expect-error we want to force the controller to have an + // encryption salt equal to the one in the vault + encryptionSalt: SALT, + }, + }, + async ({ controller, initialState, encryptor }) => { + const encryptWithKeySpy = jest.spyOn(encryptor, 'encryptWithKey'); + + await controller.submitEncryptionKey( + MOCK_ENCRYPTION_KEY, + initialState.encryptionSalt as string, + ); + + expect(controller.state.isUnlocked).toBe(true); + expect(encryptWithKeySpy).toHaveBeenCalledWith( + JSON.parse(MOCK_ENCRYPTION_KEY), + [ + { + type: KeyringTypes.hd, + data: { + accounts: ['0x123'], + }, + metadata: { + id: expect.any(String), + name: '', + }, + }, + ], + ); + }, + ); + }); + + it('should suppress errors if new metadata is created while unlocking and the vault update fails', async () => { + jest.spyOn(HdKeyring.prototype, 'serialize').mockResolvedValue({ + // @ts-expect-error we are assigning a mock value + accounts: ['0x123'], + }); + await withController( + { + skipVaultCreation: true, + state: { + vault: createVault([ + { + type: KeyringTypes.hd, + data: '0x123', + }, + ]), + // @ts-expect-error we want to force the controller to have an + // encryption salt equal to the one in the vault + encryptionSalt: SALT, + }, + }, + async ({ controller, initialState, encryptor }) => { + const encryptWithKeySpy = jest.spyOn(encryptor, 'encryptWithKey'); + jest + .spyOn(encryptor, 'encryptWithKey') + .mockRejectedValueOnce(new Error('Error')); + + await controller.submitEncryptionKey( + MOCK_ENCRYPTION_KEY, + initialState.encryptionSalt as string, + ); + + expect(controller.state.isUnlocked).toBe(true); + expect(encryptWithKeySpy).toHaveBeenCalledWith( + JSON.parse(MOCK_ENCRYPTION_KEY), + [ + { + type: KeyringTypes.hd, + data: { + accounts: ['0x123'], + }, + metadata: { + id: expect.any(String), + name: '', + }, + }, + ], + ); + }, + ); + }); + + it('should throw error if vault unlocked has an unexpected shape', async () => { + await withController(async ({ controller, initialState, encryptor }) => { + jest.spyOn(encryptor, 'decryptWithKey').mockResolvedValueOnce([ + { + foo: 'bar', + }, + ]); + + await expect( + controller.submitEncryptionKey( + MOCK_ENCRYPTION_KEY, + initialState.encryptionSalt as string, + ), + ).rejects.toThrow(KeyringControllerErrorMessage.VaultDataError); + }); + }); + + it('should throw error if encryptionSalt is different from the one in the vault', async () => { + await withController(async ({ controller }) => { + await expect( + controller.submitEncryptionKey(MOCK_ENCRYPTION_KEY, '0x1234'), + ).rejects.toThrow(KeyringControllerErrorMessage.ExpiredCredentials); + }); + }); + + it('should throw error if encryptionKey is of an unexpected type', async () => { + await withController(async ({ controller }) => { + await expect( + controller.submitEncryptionKey( + // @ts-expect-error we are testing the case of a user using + // the wrong encryptionKey type + 12341234, + SALT, + ), + ).rejects.toThrow(KeyringControllerErrorMessage.WrongEncryptionKeyType); + }); + }); + }); + + describe('exportEncryptionKey', () => { + it('should export encryption key and unlock', async () => { + await withController(async ({ controller }) => { + const encryptionKey = await controller.exportEncryptionKey(); + expect(encryptionKey).toBeDefined(); + + await controller.setLocked(); + + await controller.submitEncryptionKey(encryptionKey); + + expect(controller.isUnlocked()).toBe(true); + }); + }); + + it('should throw error if controller is locked', async () => { + await withController(async ({ controller }) => { + await controller.setLocked(); + await expect(controller.exportEncryptionKey()).rejects.toThrow( + KeyringControllerErrorMessage.ControllerLocked, + ); + }); + }); + + it('should export key after password change', async () => { + await withController(async ({ controller }) => { + await controller.changePassword('new password'); + const encryptionKey = await controller.exportEncryptionKey(); + expect(encryptionKey).toBeDefined(); + }); + }); + + it('should export key after password change to the same password', async () => { + await withController(async ({ controller }) => { + await controller.changePassword(password); + const encryptionKey = await controller.exportEncryptionKey(); + expect(encryptionKey).toBeDefined(); + }); + }); + }); + + describe('verifySeedPhrase', () => { it('should return current seedphrase', async () => { await withController(async ({ controller }) => { - const seedPhrase = await controller.verifySeedPhrase(); - expect(seedPhrase).toBeDefined(); + const seedPhrase = await controller.verifySeedPhrase(); + expect(seedPhrase).toBeDefined(); + }); + }); + + it('should return current seedphrase as Uint8Array', async () => { + await withController(async ({ controller }) => { + const seedPhrase = await controller.verifySeedPhrase(); + expect(seedPhrase).toBeInstanceOf(Uint8Array); + }); + }); + + it('should return seedphrase for a specific keyring', async () => { + await withController(async ({ controller }) => { + const seedPhrase = await controller.verifySeedPhrase( + controller.state.keyrings[0].metadata.id, + ); + expect(seedPhrase).toBeDefined(); + }); + }); + + it('should throw if mnemonic is not defined', async () => { + await withController(async ({ controller }) => { + const primaryKeyring = controller.getKeyringsByType( + KeyringTypes.hd, + )[0] as EthKeyring & { mnemonic: string }; + + primaryKeyring.mnemonic = ''; + + await expect(controller.verifySeedPhrase()).rejects.toThrow( + "Can't get mnemonic bytes from keyring", + ); + }); + }); + + it('should throw error if the controller is locked', async () => { + await withController( + { skipVaultCreation: true }, + async ({ controller }) => { + await expect(controller.verifySeedPhrase()).rejects.toThrow( + KeyringControllerErrorMessage.ControllerLocked, + ); + }, + ); + }); + + it('should throw unsupported seed phrase error when keyring is not HD', async () => { + await withController(async ({ controller }) => { + await controller.addNewKeyring(KeyringTypes.simple, [privateKey]); + + const keyringId = controller.state.keyrings[1].metadata.id; + await expect(controller.verifySeedPhrase(keyringId)).rejects.toThrow( + KeyringControllerErrorMessage.UnsupportedVerifySeedPhrase, + ); + }); + }); + + it('should throw an error if there is no primary keyring', async () => { + await withController( + { + skipVaultCreation: true, + state: { vault: createVault([{ type: 'Unsupported', data: '' }]) }, + }, + async ({ controller }) => { + await controller.submitPassword(password); + + await expect(controller.verifySeedPhrase()).rejects.toThrow( + KeyringControllerErrorMessage.KeyringNotFound, + ); + }, + ); + }); + + it('should throw error when the controller is locked', async () => { + await withController(async ({ controller }) => { + await controller.setLocked(); + + await expect(controller.verifySeedPhrase()).rejects.toThrow( + KeyringControllerErrorMessage.ControllerLocked, + ); + }); + }); + }); + + describe('verifyPassword', () => { + describe('when correct password is provided', () => { + it('should not throw any error', async () => { + await withController(async ({ controller }) => { + expect(async () => { + await controller.verifyPassword(password); + }).not.toThrow(); + }); + }); + + it('should throw error if vault is missing', async () => { + await withController( + { skipVaultCreation: true }, + async ({ controller }) => { + await expect(controller.verifyPassword(password)).rejects.toThrow( + KeyringControllerErrorMessage.VaultError, + ); + }, + ); + }); + }); + + describe('when wrong password is provided', () => { + it('should throw an error', async () => { + await withController(async ({ controller, encryptor }) => { + jest + .spyOn(encryptor, 'decrypt') + .mockRejectedValue(new Error('Incorrect password')); + + await expect(controller.verifyPassword('12341234')).rejects.toThrow( + 'Incorrect password', + ); + }); + }); + + it('should throw error if vault is missing', async () => { + await withController( + { skipVaultCreation: true }, + async ({ controller }) => { + await expect(controller.verifyPassword('123')).rejects.toThrow( + KeyringControllerErrorMessage.VaultError, + ); + }, + ); + }); + }); + }); + + describe('withKeyring', () => { + it('should rollback if an error is thrown', async () => { + await withController(async ({ controller, initialState }) => { + const selector = { type: KeyringTypes.hd }; + const fn = async ({ + keyring, + }: { + keyring: EthKeyring; + }): Promise => { + await keyring.addAccounts(1); + throw new Error('Oops'); + }; + + await expect(controller.withKeyring(selector, fn)).rejects.toThrow( + 'Oops', + ); + + expect(controller.state.keyrings[0].accounts).toHaveLength(1); + expect(await controller.getAccounts()).toStrictEqual( + initialState.keyrings[0].accounts, + ); + }); + }); + + describe('when the keyring is selected by type', () => { + it('should call the given function with the selected keyring', async () => { + await withController(async ({ controller }) => { + const fn = jest.fn(); + const selector = { type: KeyringTypes.hd }; + const keyring = controller.getKeyringsByType(KeyringTypes.hd)[0]; + const { metadata } = controller.state.keyrings[0]; + + await controller.withKeyring(selector, fn); + + expect(fn).toHaveBeenCalledWith({ keyring, metadata }); + }); + }); + + it('should return the result of the function', async () => { + await withController(async ({ controller }) => { + const fn = async (): Promise => Promise.resolve('hello'); + const selector = { type: KeyringTypes.hd }; + + expect(await controller.withKeyring(selector, fn)).toBe('hello'); + }); + }); + + it('should throw an error if the callback returns the selected keyring', async () => { + await withController(async ({ controller }) => { + await expect( + controller.withKeyring( + { type: KeyringTypes.hd }, + async ({ keyring }) => { + return keyring; + }, + ), + ).rejects.toThrow( + KeyringControllerErrorMessage.UnsafeDirectKeyringAccess, + ); + }); + }); + + describe('when the keyring is not found', () => { + it('should throw an error if the keyring is not found and `createIfMissing` is false', async () => { + await withController(async ({ controller }) => { + const selector = { type: 'foo' }; + const fn = jest.fn(); + + await expect(controller.withKeyring(selector, fn)).rejects.toThrow( + KeyringControllerErrorMessage.KeyringNotFound, + ); + expect(fn).not.toHaveBeenCalled(); + }); + }); + + it('should add the keyring if `createIfMissing` is true', async () => { + await withController( + { keyringBuilders: [keyringBuilderFactory(MockKeyring)] }, + async ({ controller }) => { + const selector = { type: MockKeyring.type }; + const fn = jest.fn(); + + await controller.withKeyring(selector, fn, { + createIfMissing: true, + }); + + expect(fn).toHaveBeenCalled(); + expect(controller.state.keyrings).toHaveLength(2); + }, + ); + }); + + it('should update the vault if the keyring is being updated', async () => { + const mockAddress = '0x4584d2B4905087A100420AFfCe1b2d73fC69B8E4'; + stubKeyringClassWithAccount(MockKeyring, mockAddress); + await withController( + { keyringBuilders: [keyringBuilderFactory(MockKeyring)] }, + async ({ controller, messenger }) => { + const selector = { type: MockKeyring.type }; + + await controller.addNewKeyring(MockKeyring.type); + const serializeSpy = jest.spyOn( + MockKeyring.prototype, + 'serialize', + ); + serializeSpy.mockResolvedValueOnce({ + foo: 'bar', // Initial keyring state. + }); + + const mockStateChange = jest.fn(); + messenger.subscribe( + 'KeyringController:stateChange', + mockStateChange, + ); + + await controller.withKeyring(selector, async () => { + serializeSpy.mockResolvedValueOnce({ + foo: 'zzz', // Mock keyring state change. + }); + }); + + expect(mockStateChange).toHaveBeenCalled(); + }, + ); + }); + + it('should update the vault if the keyring is being updated but `keyring.serialize()` includes a shallow copy', async () => { + await withController( + { keyringBuilders: [keyringBuilderFactory(MockShallowKeyring)] }, + async ({ controller, messenger }) => { + await controller.addNewKeyring(MockShallowKeyring.type); + const mockStateChange = jest.fn(); + messenger.subscribe( + 'KeyringController:stateChange', + mockStateChange, + ); + + await controller.withKeyring( + { type: MockShallowKeyring.type }, + async ({ keyring }) => keyring.addAccounts(1), + ); + + expect(mockStateChange).toHaveBeenCalled(); + expect(controller.state.keyrings[1].accounts).toHaveLength(1); + }, + ); + }); + + it('should not update the vault if the keyring has not been updated', async () => { + const mockAddress = '0x4584d2B4905087A100420AFfCe1b2d73fC69B8E4'; + stubKeyringClassWithAccount(MockKeyring, mockAddress); + await withController( + { + keyringBuilders: [keyringBuilderFactory(MockKeyring)], + }, + async ({ controller, messenger }) => { + const selector = { type: MockKeyring.type }; + + await controller.addNewKeyring(MockKeyring.type); + const serializeSpy = jest.spyOn( + MockKeyring.prototype, + 'serialize', + ); + serializeSpy.mockResolvedValue({ + foo: 'bar', // Initial keyring state. + }); + + const mockStateChange = jest.fn(); + messenger.subscribe( + 'KeyringController:stateChange', + mockStateChange, + ); + + await controller.withKeyring(selector, async () => { + // No-op, keyring state won't be updated. + }); + + expect(mockStateChange).not.toHaveBeenCalled(); + }, + ); + }); + }); + }); + + describe('when the keyring is selected by address', () => { + it('should call the given function with the selected keyring', async () => { + await withController(async ({ controller, initialState }) => { + const fn = jest.fn(); + const selector = { + address: initialState.keyrings[0].accounts[0] as Hex, + }; + const keyring = controller.getKeyringsByType(KeyringTypes.hd)[0]; + const { metadata } = controller.state.keyrings[0]; + + await controller.withKeyring(selector, fn); + + expect(fn).toHaveBeenCalledWith({ keyring, metadata }); + }); + }); + + it('should return the result of the function', async () => { + await withController(async ({ controller, initialState }) => { + const fn = async (): Promise => Promise.resolve('hello'); + const selector = { + address: initialState.keyrings[0].accounts[0] as Hex, + }; + + expect(await controller.withKeyring(selector, fn)).toBe('hello'); + }); + }); + + describe('when the keyring is not found', () => { + [true, false].forEach((value) => + it(`should throw an error if the createIfMissing is ${value}`, async () => { + await withController(async ({ controller }) => { + const selector = { + address: '0x4584d2B4905087A100420AFfCe1b2d73fC69B8E4' as Hex, + }; + const fn = jest.fn(); + + await expect( + controller.withKeyring(selector, fn), + ).rejects.toThrow(KeyringControllerErrorMessage.KeyringNotFound); + expect(fn).not.toHaveBeenCalled(); + }); + }), + ); + }); + }); + + describe('when the keyring is selected by id', () => { + it('should call the given function with the selected keyring', async () => { + await withController(async ({ controller }) => { + const fn = jest.fn(); + const keyring = controller.getKeyringsByType(KeyringTypes.hd)[0]; + const { metadata } = controller.state.keyrings[0]; + const selector = { id: metadata.id }; + + await controller.withKeyring(selector, fn); + + expect(fn).toHaveBeenCalledWith({ keyring, metadata }); + }); + }); + + it('should return the result of the function', async () => { + await withController(async ({ controller, initialState }) => { + const fn = async (): Promise => Promise.resolve('hello'); + const selector = { id: initialState.keyrings[0].metadata.id }; + + expect(await controller.withKeyring(selector, fn)).toBe('hello'); + }); + }); + + it('should throw an error if the callback returns the selected keyring', async () => { + await withController(async ({ controller, initialState }) => { + const selector = { id: initialState.keyrings[0].metadata.id }; + + await expect( + controller.withKeyring(selector, async ({ keyring }) => { + return keyring; + }), + ).rejects.toThrow( + KeyringControllerErrorMessage.UnsafeDirectKeyringAccess, + ); + }); + }); + + describe('when the keyring is not found', () => { + it('should throw an error if the keyring is not found and `createIfMissing` is false', async () => { + await withController( + async ({ controller, initialState: _initialState }) => { + const selector = { id: 'non-existent-id' }; + const fn = jest.fn(); + + await expect( + controller.withKeyring(selector, fn), + ).rejects.toThrow(KeyringControllerErrorMessage.KeyringNotFound); + expect(fn).not.toHaveBeenCalled(); + }, + ); + }); + + it('should throw an error even if `createIfMissing` is true', async () => { + await withController( + async ({ controller, initialState: _initialState }) => { + const selector = { id: 'non-existent-id' }; + const fn = jest.fn(); + + await expect( + controller.withKeyring(selector, fn, { createIfMissing: true }), + ).rejects.toThrow(KeyringControllerErrorMessage.KeyringNotFound); + expect(fn).not.toHaveBeenCalled(); + }, + ); + }); + }); + }); + + describe('when the keyring is selected by filter', () => { + it('calls the given function with the matching keyring', async () => { + await withController(async ({ controller }) => { + const fn = jest.fn(); + const keyring = controller.getKeyringsByType(KeyringTypes.hd)[0]; + const { metadata } = controller.state.keyrings[0]; + const selector: KeyringSelector = { + filter: (k): boolean => k.type === KeyringTypes.hd, + }; + + await controller.withKeyring(selector, fn); + + expect(fn).toHaveBeenCalledWith({ keyring, metadata }); + }); + }); + + it('returns the result of the function', async () => { + await withController(async ({ controller }) => { + const fn = async (): Promise => Promise.resolve('hello'); + const selector: KeyringSelector = { + filter: (): boolean => true, + }; + + expect(await controller.withKeyring(selector, fn)).toBe('hello'); + }); + }); + + it('passes both keyring and metadata to the filter', async () => { + await withController(async ({ controller }) => { + const filterFn = jest.fn( + (k: EthKeyring): boolean => k.type === KeyringTypes.hd, + ); + const selector: KeyringSelector = { filter: filterFn }; + + await controller.withKeyring(selector, jest.fn()); + + expect(filterFn).toHaveBeenCalledWith( + expect.any(Object), + expect.objectContaining({ id: expect.any(String) }), + ); + }); + }); + + it('selects the first keyring matching the filter when multiple keyrings exist', async () => { + const mockAddress = '0x4584d2B4905087A100420AFfCe1b2d73fC69B8E4'; + stubKeyringClassWithAccount(MockKeyring, mockAddress); + await withController( + { keyringBuilders: [keyringBuilderFactory(MockKeyring)] }, + async ({ controller }) => { + await controller.addNewKeyring(MockKeyring.type); + const fn = jest.fn(); + const selector: KeyringSelector = { + filter: (k): boolean => k.type === MockKeyring.type, + }; + + await controller.withKeyring(selector, fn); + + expect(fn).toHaveBeenCalledWith( + expect.objectContaining({ + keyring: expect.objectContaining({ type: MockKeyring.type }), + }), + ); + }, + ); + }); + + it('throws KeyringNotFound if no keyring matches the filter', async () => { + await withController(async ({ controller }) => { + const selector: KeyringSelector = { + filter: (): boolean => false, + }; + const fn = jest.fn(); + + await expect(controller.withKeyring(selector, fn)).rejects.toThrow( + KeyringControllerErrorMessage.KeyringNotFound, + ); + expect(fn).not.toHaveBeenCalled(); + }); + }); + + it('throws UnsafeDirectKeyringAccess if the callback returns the selected keyring', async () => { + await withController(async ({ controller }) => { + await expect( + controller.withKeyring( + { filter: (): boolean => true }, + async ({ + keyring, + }: { + keyring: EthKeyring; + metadata: KeyringMetadata; + }) => keyring, + ), + ).rejects.toThrow( + KeyringControllerErrorMessage.UnsafeDirectKeyringAccess, + ); + }); + }); + + it('narrows down to a proper keyring type in the callback', async () => { + await withController( + { keyringBuilders: [keyringBuilderFactory(MockErc4337Keyring)] }, + async ({ controller }) => { + await controller.addNewKeyring(MockErc4337Keyring.type); + + await controller.withKeyring( + { + filter: (k): k is MockErc4337Keyring => + k.type === MockErc4337Keyring.type, + }, + async ({ keyring }) => { + // eslint-disable-next-line prefer-destructuring + const type: MockErc4337Keyring['type'] = keyring.type; // Should be able to access type without type assertion; + + expect(type).toBe(MockErc4337Keyring.type); + expect(keyring).toBeInstanceOf(MockErc4337Keyring); + }, + ); + }, + ); + }); + }); + + it('throws KeyringNotFound if keyring metadata is not found (internal consistency check)', async () => { + // This test verifies the defensive #getKeyringMetadata guard that ensures + // internal state consistency. In normal operation, this should never occur, + // but the guard exists to catch potential data corruption scenarios where + // a keyring exists but its metadata is not in the internal keyrings array. + await withController(async ({ controller }) => { + // Mock getKeyringForAccount to return a keyring that isn't in the internal array + // This simulates an inconsistent internal state + const mockOrphanKeyring: Partial = { + type: 'OrphanKeyring', + getAccounts: jest.fn().mockResolvedValue([]), + }; + + jest + .spyOn(controller, 'getKeyringForAccount') + .mockResolvedValue(mockOrphanKeyring as EthKeyring); + + const selector = { + address: '0x1234567890123456789012345678901234567890' as Hex, + }; + const fn = jest.fn(); + + // This should trigger the #getKeyringMetadata error because mockOrphanKeyring + // is not in the internal #keyrings array + await expect(controller.withKeyring(selector, fn)).rejects.toThrow( + KeyringControllerErrorMessage.KeyringNotFound, + ); + expect(fn).not.toHaveBeenCalled(); + }); + }); + + describe('when the operation drains a keyring of all its accounts', () => { + it('removes the now-empty non-primary keyring from state', async () => { + await withController(async ({ controller }) => { + const importedAccount = await controller.importAccountWithStrategy( + AccountImportStrategy.privateKey, + [privateKey], + ); + expect(controller.state.keyrings).toHaveLength(2); + expect(controller.state.keyrings[1].type).toBe(KeyringTypes.simple); + + await controller.withKeyring( + { type: KeyringTypes.simple }, + async ({ keyring }) => { + keyring.removeAccount?.(importedAccount as Hex); + }, + ); + + expect(controller.state.keyrings).toHaveLength(1); + expect(controller.state.keyrings[0].type).toBe(KeyringTypes.hd); + }); + }); + + it('destroys the drained keyring', async () => { + const address = '0x5AC6D462f054690a373FABF8CC28e161003aEB19'; + stubKeyringClassWithAccount(MockKeyring, address); + + const destroySpy = jest.spyOn(MockKeyring.prototype, 'destroy'); + + await withController( + { keyringBuilders: [keyringBuilderFactory(MockKeyring)] }, + async ({ controller }) => { + await controller.addNewKeyring(MockKeyring.type); + expect(controller.state.keyrings).toHaveLength(2); + + // Drain the mock keyring's accounts via withKeyring. + await controller.withKeyring( + { type: MockKeyring.type }, + async ({ keyring }) => { + jest + .spyOn(keyring, 'getAccounts') + .mockResolvedValue([] as Hex[]); + }, + ); + + expect(controller.state.keyrings).toHaveLength(1); + expect(destroySpy).toHaveBeenCalled(); + }, + ); + }); + + it('persists the cleanup so the empty keyring does not return on unlock', async () => { + await withController(async ({ controller }) => { + const importedAccount = await controller.importAccountWithStrategy( + AccountImportStrategy.privateKey, + [privateKey], + ); + expect(controller.state.keyrings).toHaveLength(2); + + await controller.withKeyring( + { type: KeyringTypes.simple }, + async ({ keyring }) => { + keyring.removeAccount?.(importedAccount as Hex); + }, + ); + + await controller.setLocked(); + await controller.submitPassword(password); + + expect(controller.state.keyrings).toHaveLength(1); + expect(controller.state.keyrings[0].type).toBe(KeyringTypes.hd); + }); + }); + + it('preserves pre-existing empty keyrings that were not drained by the operation', async () => { + await withController(async ({ controller }) => { + const importedAccount = await controller.importAccountWithStrategy( + AccountImportStrategy.privateKey, + [privateKey], + ); + await controller.addNewKeyring(KeyringTypes.simple); + + // HD keyring + Simple-with-account + intentionally empty Simple + expect(controller.state.keyrings).toHaveLength(3); + expect(controller.state.keyrings[2].accounts).toStrictEqual([]); + + await controller.withKeyring( + { address: importedAccount as Hex }, + async ({ keyring }) => { + keyring.removeAccount?.(importedAccount as Hex); + }, + ); + + // The drained keyring is removed; the intentionally empty + // keyring (created via addNewKeyring) is preserved. + expect(controller.state.keyrings).toHaveLength(2); + expect(controller.state.keyrings[0].type).toBe(KeyringTypes.hd); + expect(controller.state.keyrings[1].type).toBe(KeyringTypes.simple); + expect(controller.state.keyrings[1].accounts).toStrictEqual([]); + }); + }); + + it('does not remove the primary keyring even if its last account is removed', async () => { + await withController(async ({ controller }) => { + const [primaryKeyring] = controller.getKeyringsByType( + KeyringTypes.hd, + ) as EthKeyring[]; + const [primaryAccount] = await primaryKeyring.getAccounts(); + + await controller.withKeyring( + { type: KeyringTypes.hd }, + async ({ keyring }) => { + keyring.removeAccount?.(primaryAccount); + }, + ); + + expect(controller.state.keyrings).toHaveLength(1); + expect(controller.state.keyrings[0].type).toBe(KeyringTypes.hd); + expect(controller.state.keyrings[0].accounts).toStrictEqual([]); + }); + }); + + it('does not remove keyrings if the operation rolls back', async () => { + await withController(async ({ controller }) => { + const importedAccount = await controller.importAccountWithStrategy( + AccountImportStrategy.privateKey, + [privateKey], + ); + expect(controller.state.keyrings).toHaveLength(2); + + await expect( + controller.withKeyring( + { type: KeyringTypes.simple }, + async ({ keyring }) => { + keyring.removeAccount?.(importedAccount as Hex); + throw new Error('Oops'); + }, + ), + ).rejects.toThrow('Oops'); + + // Rollback restores the original keyrings (still 2). + expect(controller.state.keyrings).toHaveLength(2); + expect(controller.state.keyrings[1].accounts).toStrictEqual([ + importedAccount, + ]); + }); + }); + }); + }); + + describe('withController', () => { + it('throws if the controller is locked', async () => { + await withController( + { skipVaultCreation: true }, + async ({ controller }) => { + await expect(controller.withController(jest.fn())).rejects.toThrow( + KeyringControllerErrorMessage.ControllerLocked, + ); + }, + ); + }); + + it('provides the current keyrings to the callback', async () => { + await withController(async ({ controller, initialState }) => { + await controller.withController(async (restrictedController) => { + expect(restrictedController.keyrings).toHaveLength(1); + expect(restrictedController.keyrings[0].metadata).toStrictEqual( + initialState.keyrings[0].metadata, + ); + }); + }); + }); + + it('returns the result of the callback', async () => { + await withController(async ({ controller }) => { + const result = await controller.withController(async () => 'hello'); + expect(result).toBe('hello'); + }); + }); + + it('throws if the callback returns a raw keyring instance', async () => { + await withController(async ({ controller }) => { + await expect( + controller.withController(async (restrictedController) => { + return restrictedController.keyrings[0].keyring; + }), + ).rejects.toThrow( + KeyringControllerErrorMessage.UnsafeDirectKeyringAccess, + ); + }); + }); + + it('throws if the callback returns a raw keyring (v2) instance', async () => { + await withController(async ({ controller }) => { + await expect( + controller.withController(async (restrictedController) => { + return restrictedController.keyrings[0].keyringV2; + }), + ).rejects.toThrow( + KeyringControllerErrorMessage.UnsafeDirectKeyringAccess, + ); + }); + }); + + describe('addNewKeyring', () => { + it('creates an initialized keyring and stages it for commit', async () => { + const mockAddress = '0x4584d2B4905087A100420AFfCe1b2d73fC69B8E4'; + stubKeyringClassWithAccount(MockKeyring, mockAddress); + + await withController( + { keyringBuilders: [keyringBuilderFactory(MockKeyring)] }, + async ({ controller }) => { + await controller.withController(async (restrictedController) => { + const entry = await restrictedController.addNewKeyring( + MockKeyring.type, + ); + + expect(entry.keyring).toBeInstanceOf(MockKeyring); + expect(entry.metadata.id).toBeDefined(); + }); + + expect(controller.state.keyrings).toHaveLength(2); + }, + ); + }); + + it('populates keyringV2 when a V2 builder is registered for the type', async () => { + await withController(async ({ controller }) => { + await controller.withController(async (restrictedController) => { + const entry = await restrictedController.addNewKeyring( + KeyringTypes.simple, + ); + + expect(entry.keyringV2).toBeDefined(); + }); + }); + }); + + it('appears immediately in restrictedController.keyrings', async () => { + const mockAddress = '0x4584d2B4905087A100420AFfCe1b2d73fC69B8E4'; + stubKeyringClassWithAccount(MockKeyring, mockAddress); + + await withController( + { keyringBuilders: [keyringBuilderFactory(MockKeyring)] }, + async ({ controller }) => { + await controller.withController(async (restrictedController) => { + expect(restrictedController.keyrings).toHaveLength(1); + await restrictedController.addNewKeyring(MockKeyring.type); + expect(restrictedController.keyrings).toHaveLength(2); + }); + }, + ); + }); + + it('destroys created keyrings and does not commit them if the callback throws', async () => { + const mockAddress = '0x4584d2B4905087A100420AFfCe1b2d73fC69B8E4'; + stubKeyringClassWithAccount(MockKeyring, mockAddress); + const destroySpy = jest + .spyOn(MockKeyring.prototype, 'destroy') + .mockResolvedValue(undefined); + + await withController( + { keyringBuilders: [keyringBuilderFactory(MockKeyring)] }, + async ({ controller }) => { + await expect( + controller.withController(async (restrictedController) => { + await restrictedController.addNewKeyring(MockKeyring.type); + throw new Error('Oops'); + }), + ).rejects.toThrow('Oops'); + + expect(destroySpy).toHaveBeenCalledTimes(1); + expect(controller.state.keyrings).toHaveLength(1); + }, + ); + }); + }); + + describe('removeKeyring', () => { + it('removes a keyring by id and commits the removal', async () => { + const mockAddress = '0x4584d2B4905087A100420AFfCe1b2d73fC69B8E4'; + stubKeyringClassWithAccount(MockKeyring, mockAddress); + + await withController( + { keyringBuilders: [keyringBuilderFactory(MockKeyring)] }, + async ({ controller }) => { + await controller.addNewKeyring(MockKeyring.type); + const idToRemove = controller.state.keyrings[1].metadata.id; + + await controller.withController(async (restrictedController) => { + await restrictedController.removeKeyring(idToRemove); + }); + + expect(controller.state.keyrings).toHaveLength(1); + expect( + controller.state.keyrings.find( + (k) => k.metadata.id === idToRemove, + ), + ).toBeUndefined(); + }, + ); + }); + + it('disappears from restrictedController.keyrings immediately', async () => { + const mockAddress = '0x4584d2B4905087A100420AFfCe1b2d73fC69B8E4'; + stubKeyringClassWithAccount(MockKeyring, mockAddress); + + await withController( + { keyringBuilders: [keyringBuilderFactory(MockKeyring)] }, + async ({ controller }) => { + await controller.addNewKeyring(MockKeyring.type); + const idToRemove = controller.state.keyrings[1].metadata.id; + + await controller.withController(async (restrictedController) => { + expect(restrictedController.keyrings).toHaveLength(2); + await restrictedController.removeKeyring(idToRemove); + expect(restrictedController.keyrings).toHaveLength(1); + }); + }, + ); + }); + + it('destroys the removed keyring', async () => { + const mockAddress = '0x4584d2B4905087A100420AFfCe1b2d73fC69B8E4'; + stubKeyringClassWithAccount(MockKeyring, mockAddress); + const destroySpy = jest + .spyOn(MockKeyring.prototype, 'destroy') + .mockResolvedValue(undefined); + + await withController( + { keyringBuilders: [keyringBuilderFactory(MockKeyring)] }, + async ({ controller }) => { + await controller.addNewKeyring(MockKeyring.type); + const idToRemove = controller.state.keyrings[1].metadata.id; + + await controller.withController(async (restrictedController) => { + await restrictedController.removeKeyring(idToRemove); + }); + + expect(destroySpy).toHaveBeenCalledTimes(1); + }, + ); + }); + + it('throws KeyringNotFound for an unknown id', async () => { + await withController(async ({ controller }) => { + await expect( + controller.withController(async (restrictedController) => { + await restrictedController.removeKeyring('non-existent-id'); + }), + ).rejects.toThrow(KeyringControllerErrorMessage.KeyringNotFound); + }); + }); + + it('throws CannotRemovePrimaryHdKeyring when attempting to remove the primary HD keyring', async () => { + await withController(async ({ controller }) => { + const primaryId = controller.state.keyrings[0].metadata.id; + + await expect( + controller.withController(async (restrictedController) => { + await restrictedController.removeKeyring(primaryId); + }), + ).rejects.toThrow( + KeyringControllerErrorMessage.CannotRemovePrimaryKeyring, + ); + + expect(controller.state.keyrings).toHaveLength(1); + }); + }); + + it('destroys a keyring that was created then removed within the same callback', async () => { + const mockAddress = '0x4584d2B4905087A100420AFfCe1b2d73fC69B8E4'; + stubKeyringClassWithAccount(MockKeyring, mockAddress); + const destroySpy = jest + .spyOn(MockKeyring.prototype, 'destroy') + .mockResolvedValue(undefined); + + await withController( + { keyringBuilders: [keyringBuilderFactory(MockKeyring)] }, + async ({ controller }) => { + await controller.withController(async (restrictedController) => { + const { metadata } = await restrictedController.addNewKeyring( + MockKeyring.type, + ); + await restrictedController.removeKeyring(metadata.id); + }); + + expect(destroySpy).toHaveBeenCalledTimes(1); + expect(controller.state.keyrings).toHaveLength(1); + }, + ); + }); + }); + + it('rolls back on error', async () => { + await withController(async ({ controller, initialState }) => { + await expect( + controller.withController(async (restrictedController) => { + await restrictedController.addNewKeyring(KeyringTypes.simple); + throw new Error('Oops'); + }), + ).rejects.toThrow('Oops'); + + expect(controller.state.keyrings).toHaveLength( + initialState.keyrings.length, + ); + expect(await controller.getAccounts()).toStrictEqual( + initialState.keyrings[0].accounts, + ); + }); + }); + + it('does not update the vault if no keyrings change', async () => { + await withController(async ({ controller, encryptor }) => { + const encryptSpy = jest.spyOn(encryptor, 'encrypt'); + + await controller.withController(async () => { + // no-op + }); + + expect(encryptSpy).not.toHaveBeenCalled(); + }); + }); + }); + + describe('withKeyringUnsafe', () => { + it('calls the given function without acquiring the lock', async () => { + await withController(async ({ controller }) => { + const acquireSpy = jest.spyOn(Mutex.prototype, 'acquire'); + const fn = jest.fn().mockResolvedValue('result'); + const selector = { type: KeyringTypes.hd }; + const keyring = controller.getKeyringsByType(KeyringTypes.hd)[0]; + const { metadata } = controller.state.keyrings[0]; + + const result = await controller.withKeyringUnsafe(selector, fn); + + expect(acquireSpy).not.toHaveBeenCalled(); + expect(fn).toHaveBeenCalledWith({ keyring, metadata }); + expect(result).toBe('result'); + }); + }); + + it('throws if the controller is locked', async () => { + await withController( + { skipVaultCreation: true }, + async ({ controller }) => { + await expect( + controller.withKeyringUnsafe({ type: KeyringTypes.hd }, jest.fn()), + ).rejects.toThrow(KeyringControllerErrorMessage.ControllerLocked); + }, + ); + }); + + it('throws KeyringNotFound if no keyring matches the selector', async () => { + await withController(async ({ controller }) => { + const fn = jest.fn(); + + await expect( + controller.withKeyringUnsafe({ type: 'NonExistentType' }, fn), + ).rejects.toThrow(KeyringControllerErrorMessage.KeyringNotFound); + + expect(fn).not.toHaveBeenCalled(); + }); + }); + + it('throws UnsafeDirectKeyringAccess if the callback returns the selected keyring', async () => { + await withController(async ({ controller }) => { + await expect( + controller.withKeyringUnsafe( + { type: KeyringTypes.hd }, + async ({ keyring }) => keyring, + ), + ).rejects.toThrow( + KeyringControllerErrorMessage.UnsafeDirectKeyringAccess, + ); + }); + }); + + describe('when the keyring is selected by address', () => { + it('calls the given function with the selected keyring', async () => { + await withController(async ({ controller, initialState }) => { + const fn = jest.fn(); + const selector = { + address: initialState.keyrings[0].accounts[0] as Hex, + }; + const keyring = controller.getKeyringsByType(KeyringTypes.hd)[0]; + const { metadata } = controller.state.keyrings[0]; + + await controller.withKeyringUnsafe(selector, fn); + + expect(fn).toHaveBeenCalledWith({ keyring, metadata }); + }); + }); + }); + + describe('when the keyring is selected by id', () => { + it('calls the given function with the selected keyring', async () => { + await withController(async ({ controller }) => { + const fn = jest.fn(); + const keyring = controller.getKeyringsByType(KeyringTypes.hd)[0]; + const { metadata } = controller.state.keyrings[0]; + const selector = { id: metadata.id }; + + await controller.withKeyringUnsafe(selector, fn); + + expect(fn).toHaveBeenCalledWith({ keyring, metadata }); + }); + }); + + it('throws KeyringNotFound if no keyring has the given id', async () => { + await withController(async ({ controller }) => { + const fn = jest.fn(); + + await expect( + controller.withKeyringUnsafe({ id: 'non-existent-id' }, fn), + ).rejects.toThrow(KeyringControllerErrorMessage.KeyringNotFound); + expect(fn).not.toHaveBeenCalled(); + }); + }); + }); + + describe('when the keyring is selected by filter', () => { + it('calls the given function with the matching keyring', async () => { + await withController(async ({ controller }) => { + const fn = jest.fn(); + const keyring = controller.getKeyringsByType(KeyringTypes.hd)[0]; + const { metadata } = controller.state.keyrings[0]; + const selector: KeyringSelector = { + filter: (k): boolean => k.type === KeyringTypes.hd, + }; + + await controller.withKeyringUnsafe(selector, fn); + + expect(fn).toHaveBeenCalledWith({ keyring, metadata }); + }); + }); + + it('throws KeyringNotFound if no keyring matches the filter', async () => { + await withController(async ({ controller }) => { + const fn = jest.fn(); + + await expect( + controller.withKeyringUnsafe({ filter: (): boolean => false }, fn), + ).rejects.toThrow(KeyringControllerErrorMessage.KeyringNotFound); + expect(fn).not.toHaveBeenCalled(); + }); + }); + }); + + it('does not roll back state if an error is thrown', async () => { + await withController(async ({ controller, initialState }) => { + // Mutate state directly via withKeyring first to have a known state + await controller.withKeyring( + { type: KeyringTypes.hd }, + async ({ keyring }) => keyring.addAccounts(1), + ); + + const accountsBefore = controller.state.keyrings[0].accounts; + + // withKeyringUnsafe does not roll back — errors just propagate + await expect( + controller.withKeyringUnsafe({ type: KeyringTypes.hd }, async () => { + throw new Error('Oops'); + }), + ).rejects.toThrow('Oops'); + + // State is unchanged (no rollback to pre-withKeyringUnsafe state) + expect(controller.state.keyrings[0].accounts).toStrictEqual( + accountsBefore, + ); + expect(controller.state.keyrings[0].accounts).not.toStrictEqual( + initialState.keyrings[0].accounts, + ); + }); + }); + }); + + describe('withKeyringV2', () => { + it('should wrap the V1 keyring using the default builder and call the operation', async () => { + await withController(async ({ controller }) => { + const fn = jest.fn(); + await controller.withKeyringV2({ type: KeyringType.Hd }, fn); + + expect(fn).toHaveBeenCalledWith( + expect.objectContaining({ + keyring: expect.any(Object), + metadata: expect.objectContaining({ id: expect.any(String) }), + }), + ); + }); + }); + + it('should return the result of the operation', async () => { + await withController(async ({ controller }) => { + const result = await controller.withKeyringV2( + { type: KeyringType.Hd }, + async () => 'result-value', + ); + + expect(result).toBe('result-value'); + }); + }); + + it('should throw KeyringNotFound when no keyring matches', async () => { + await withController(async ({ controller }) => { + const fn = jest.fn(); + + await expect( + controller.withKeyringV2({ type: KeyringType.Snap }, fn), + ).rejects.toThrow(KeyringControllerErrorMessage.KeyringNotFound); + + expect(fn).not.toHaveBeenCalled(); + }); + }); + + it('should throw KeyringV2NotSupported when the selected keyring has no V2 adapter', async () => { + await withController( + { + keyringBuilders: [keyringBuilderFactory(MockShallowKeyring)], + }, + async ({ controller }) => { + const metadata = await controller.addNewKeyring( + MockShallowKeyring.type, + ); + + const fn = jest.fn(); + await expect( + controller.withKeyringV2({ id: metadata.id }, fn), + ).rejects.toThrow( + KeyringControllerErrorMessage.KeyringV2NotSupported, + ); + + expect(fn).not.toHaveBeenCalled(); + }, + ); + }); + + it('should throw an error if the callback returns the wrapped keyring', async () => { + await withController(async ({ controller }) => { + await expect( + controller.withKeyringV2( + { type: KeyringType.Hd }, + async ({ keyring }) => keyring, + ), + ).rejects.toThrow( + KeyringControllerErrorMessage.UnsafeDirectKeyringAccess, + ); }); }); - it('should return current seedphrase as Uint8Array', async () => { + it('should throw an error if the controller is locked', async () => { await withController(async ({ controller }) => { - const seedPhrase = await controller.verifySeedPhrase(); - expect(seedPhrase).toBeInstanceOf(Uint8Array); + await controller.setLocked(); + + await expect( + controller.withKeyringV2({ type: KeyringType.Hd }, jest.fn()), + ).rejects.toThrow(KeyringControllerErrorMessage.ControllerLocked); }); }); - it('should throw if mnemonic is not defined', async () => { + it('should not match legacy KeyringTypes values in V2 type selectors', async () => { await withController(async ({ controller }) => { - const primaryKeyring = controller.getKeyringsByType( - KeyringTypes.hd, - )[0] as Keyring & { mnemonic: string }; + const fn = jest.fn(); - primaryKeyring.mnemonic = ''; + await expect( + controller.withKeyringV2( + { type: KeyringTypes.hd } as unknown as KeyringSelectorV2, + fn, + ), + ).rejects.toThrow(KeyringControllerErrorMessage.KeyringNotFound); - await expect(controller.verifySeedPhrase()).rejects.toThrow( - "Can't get mnemonic bytes from keyring", - ); + expect(fn).not.toHaveBeenCalled(); }); }); - }); - describe('verifyPassword', () => { - describe('when correct password is provided', () => { - it('should not throw any error', async () => { - await withController(async ({ controller }) => { - expect(async () => { - await controller.verifyPassword(password); - }).not.toThrow(); - }); - }); - }); + describe('when the keyring is selected by address', () => { + it('should wrap the V1 keyring that holds the given address', async () => { + await withController(async ({ controller, initialState }) => { + const fn = jest.fn(); + const address = initialState.keyrings[0].accounts[0] as Hex; - 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')); + await controller.withKeyringV2({ address }, fn); - await expect(controller.verifyPassword('12341234')).rejects.toThrow( - 'Incorrect password', + expect(fn).toHaveBeenCalledWith( + expect.objectContaining({ + keyring: expect.any(Object), + metadata: expect.objectContaining({ id: expect.any(String) }), + }), ); }); }); }); - }); - describe('QR keyring', () => { - const composeMockSignature = ( - requestId: string, - signature: string, - ): ETHSignature => { - const rlpSignatureData = Buffer.from(signature, 'hex'); - const idBuffer = uuid.parse(requestId); - return new ETHSignature( - rlpSignatureData, - Buffer.from(Uint8Array.from(idBuffer)), - ); - }; - - let signProcessKeyringController: KeyringController; - let signProcessKeyringControllerMessenger: KeyringControllerMessenger; + describe('when the keyring is selected by id', () => { + it('should wrap the V1 keyring with the matching metadata id', async () => { + await withController(async ({ controller, initialState }) => { + const fn = jest.fn(); + const keyringId = initialState.keyrings[0].metadata.id; - let requestSignatureStub: sinon.SinonStub; - let readAccountSub: sinon.SinonStub; + await controller.withKeyringV2({ id: keyringId }, fn); - const setupQRKeyring = async () => { - readAccountSub.resolves( - CryptoHDKey.fromCBOR( - Buffer.from( - 'a902f40358210219218eb65839d08bde4338640b03fdbbdec439ef880d397c2f881282c5b5d135045820e65ed63f52e3e93d48ffb55cd68c6721e58ead9b29b784b8aba58354f4a3d92905d90131a201183c020006d90130a30186182cf5183cf500f5021a5271c071030307d90130a2018400f480f40300081a625f3e6209684b657973746f6e650a706163636f756e742e7374616e64617264', - 'hex', - ), - ), - ); - await signProcessKeyringController.connectQRHardware(0); - await signProcessKeyringController.unlockQRHardwareWalletAccount(0); - await signProcessKeyringController.unlockQRHardwareWalletAccount(1); - await signProcessKeyringController.unlockQRHardwareWalletAccount(2); - }; + expect(fn).toHaveBeenCalledWith( + expect.objectContaining({ + metadata: expect.objectContaining({ id: keyringId }), + }), + ); + }); + }); - beforeEach(async () => { - const { controller, messenger } = await withController( - { - // @ts-expect-error QRKeyring is not yet compatible with Keyring type. - keyringBuilders: [keyringBuilderFactory(QRKeyring)], - cacheEncryptionKey: true, - }, - (args) => args, - ); + it('should throw KeyringNotFound if no keyring has the id', async () => { + await withController(async ({ controller }) => { + await expect( + controller.withKeyringV2({ id: 'non-existent-id' }, jest.fn()), + ).rejects.toThrow(KeyringControllerErrorMessage.KeyringNotFound); + }); + }); + }); - signProcessKeyringController = controller; - signProcessKeyringControllerMessenger = messenger; + describe('when the keyring is selected by filter', () => { + it('should use the V2 keyring instance that matches the filter', async () => { + await withController(async ({ controller }) => { + const fn = jest.fn(); + await controller.withKeyringV2( + { + filter: (k): boolean => + k.type === KeyringType.Hd /* New V2 enum */, + }, + fn, + ); - const qrkeyring = await signProcessKeyringController.getOrAddQRKeyring(); - qrkeyring.forgetDevice(); + expect(fn).toHaveBeenCalledWith( + expect.objectContaining({ + keyring: expect.any(Object), + metadata: expect.objectContaining({ id: expect.any(String) }), + }), + ); + }); + }); - requestSignatureStub = sinon.stub( - qrkeyring.getInteraction(), - 'requestSignature', - ); + it('should skip instances that do not support v2', async () => { + await withController( + { + keyringBuilders: [ + keyringBuilderFactory(MockKeyring), + ] /* No V2 support for this type */, + }, + async ({ controller }) => { + await controller.addNewKeyring(MockKeyring.type); - readAccountSub = sinon.stub( - qrkeyring.getInteraction(), - 'readCryptoHDKeyOrCryptoAccount', - ); - }); + // 1. The HD keyring that supports V2, so we explicitly skip it. + // 2. The mock keyring that we want to filter, but that will get implicitly skipped because it doesn't support V2. + const filter = jest + .fn() + .mockReturnValueOnce(false) + .mockReturnValueOnce(true); - describe('getQRKeyring', () => { - it('should return QR keyring', async () => { - const qrKeyring = signProcessKeyringController.getQRKeyring(); - expect(qrKeyring).toBeDefined(); - expect(qrKeyring).toBeInstanceOf(QRKeyring); + const fn = jest.fn(); + await expect( + controller.withKeyringV2({ filter }, fn), + ).rejects.toThrow(KeyringControllerErrorMessage.KeyringNotFound); + }, + ); }); - it('should return undefined if QR keyring is not present', async () => { + it('should throw KeyringNotFound if no keyring matches the filter', async () => { await withController(async ({ controller }) => { - const qrKeyring = controller.getQRKeyring(); - expect(qrKeyring).toBeUndefined(); + await expect( + controller.withKeyringV2( + { filter: (): boolean => false }, + jest.fn(), + ), + ).rejects.toThrow(KeyringControllerErrorMessage.KeyringNotFound); }); }); }); - describe('connectQRHardware', () => { - it('should setup QR keyring with crypto-hdkey', async () => { - readAccountSub.resolves( - CryptoHDKey.fromCBOR( - Buffer.from( - 'a902f40358210219218eb65839d08bde4338640b03fdbbdec439ef880d397c2f881282c5b5d135045820e65ed63f52e3e93d48ffb55cd68c6721e58ead9b29b784b8aba58354f4a3d92905d90131a201183c020006d90130a30186182cf5183cf500f5021a5271c071030307d90130a2018400f480f40300081a625f3e6209684b657973746f6e650a706163636f756e742e7374616e64617264', - 'hex', - ), - ), - ); - - const firstPage = await signProcessKeyringController.connectQRHardware( - 0, - ); - expect(firstPage).toHaveLength(5); - expect(firstPage[0].index).toBe(0); + describe('rollback', () => { + it('should rollback the underlying V1 keyring if the operation throws', async () => { + await withController(async ({ controller, initialState }) => { + await expect( + controller.withKeyringV2({ type: KeyringType.Hd }, async () => { + throw new Error('Rollback test'); + }), + ).rejects.toThrow('Rollback test'); - const secondPage = await signProcessKeyringController.connectQRHardware( - 1, - ); - expect(secondPage).toHaveLength(5); - expect(secondPage[0].index).toBe(5); + expect(controller.state.keyrings[0].accounts).toStrictEqual( + initialState.keyrings[0].accounts, + ); + }); + }); + }); - const goBackPage = await signProcessKeyringController.connectQRHardware( - -1, - ); - expect(goBackPage).toStrictEqual(firstPage); + describe('messenger action', () => { + it('should be callable through the messenger', async () => { + await withController(async ({ messenger }) => { + const fn = jest.fn(); - await signProcessKeyringController.unlockQRHardwareWalletAccount(0); - await signProcessKeyringController.unlockQRHardwareWalletAccount(1); - await signProcessKeyringController.unlockQRHardwareWalletAccount(2); + await messenger.call( + 'KeyringController:withKeyringV2', + { type: KeyringType.Hd }, + fn, + ); - const qrKeyring = signProcessKeyringController.state.keyrings.find( - (keyring) => keyring.type === KeyringTypes.qr, - ); - expect(qrKeyring?.accounts).toHaveLength(3); + expect(fn).toHaveBeenCalled(); + }); }); }); - describe('signMessage', () => { - it('should sign message with QR keyring', async () => { - await setupQRKeyring(); - requestSignatureStub.resolves( - composeMockSignature( - '9b1deb4d-3b7d-4bad-9bdd-2b0d7b3dcb6d', - '4cb25933c5225f9f92fc9b487451b93bc3646c6aa01b72b01065b8509ac4fd6c37798695d0d5c0949ed10c5e102800ea2b62c2b670729c5631c81b0c52002a641b', - ), - ); + describe('when the operation drains a keyring of all its accounts', () => { + it('removes the now-empty non-primary keyring from state', async () => { + await withController(async ({ controller }) => { + await controller.importAccountWithStrategy( + AccountImportStrategy.privateKey, + [privateKey], + ); + expect(controller.state.keyrings).toHaveLength(2); - const data = - '0x879a053d4800c6354e76c7985a865d2922c82fb5b3f4577b2fe08b998954f2e0'; - const qrKeyring = signProcessKeyringController.state.keyrings.find( - (keyring) => keyring.type === KeyringTypes.qr, - ); - const account = qrKeyring?.accounts[0] || ''; - const signature = await signProcessKeyringController.signMessage({ - data, - from: account, + await controller.withKeyringV2( + { type: KeyringType.PrivateKey }, + async ({ keyring }) => { + const [account] = await keyring.getAccounts(); + await keyring.deleteAccount(account.id); + }, + ); + + expect(controller.state.keyrings).toHaveLength(1); + expect(controller.state.keyrings[0].type).toBe(KeyringTypes.hd); }); - expect(signature).not.toBe(''); }); - }); - describe('signPersonalMessage', () => { - it('should sign personal message with QR keyring', async () => { - await setupQRKeyring(); - requestSignatureStub.resolves( - composeMockSignature( - '9b1deb4d-3b7d-4bad-9bdd-2b0d7b3dcb6d', - '73f31609b618050c4058e8f959961c203470657e7218a21d8b94ac1bdef80f255ac5e7a07493302443296ccb20a04ebfa0c8f6ea4dd9134c19ecd65673c336261b', - ), - ); + it('destroys the drained keyring, including its V2 instance', async () => { + await withController(async ({ controller }) => { + await controller.importAccountWithStrategy( + AccountImportStrategy.privateKey, + [privateKey], + ); - const data = bufferToHex( - Buffer.from('Example `personal_sign` message', 'utf8'), - ); - const qrKeyring = signProcessKeyringController.state.keyrings.find( - (keyring) => keyring.type === KeyringTypes.qr, - ); - const account = qrKeyring?.accounts[0] || ''; - const signature = - await signProcessKeyringController.signPersonalMessage({ - data, - from: account, + // The V2 adapter for simple keyrings has no native `destroy`, so we + // attach one to assert the cleanup tears down the V2 instance too. + const destroyV2 = jest.fn(); + await controller.withController(async (restrictedController) => { + const { keyringV2 } = restrictedController.keyrings[1]; + (keyringV2 as { destroy?: () => void }).destroy = destroyV2; }); - const recovered = recoverPersonalSignature({ data, signature }); - expect(account.toLowerCase()).toBe(recovered.toLowerCase()); - }); - }); - describe('signTypedMessage', () => { - it('should sign typed message V1 with QR keyring', async () => { - await setupQRKeyring(); - requestSignatureStub.resolves( - composeMockSignature( - '9b1deb4d-3b7d-4bad-9bdd-2b0d7b3dcb6d', - '4b9b4cde5c883e3281a5a603179379817a94796f3a06079374db94f0b2c1882c5e708de2fa0ec84d74b3819f7baae0d310b4494d101359afe470910bec5d36071b', - ), - ); + await controller.withKeyringV2( + { type: KeyringType.PrivateKey }, + async ({ keyring }) => { + const [account] = await keyring.getAccounts(); + await keyring.deleteAccount(account.id); + }, + ); - const typedMsgParams = [ - { - name: 'Message', - type: 'string', - value: 'Hi, Alice!', - }, - { - name: 'A number', - type: 'uint32', - value: '1337', - }, - ]; - const qrKeyring = signProcessKeyringController.state.keyrings.find( - (keyring) => keyring.type === KeyringTypes.qr, - ); - const account = qrKeyring?.accounts[0] || ''; - const signature = await signProcessKeyringController.signTypedMessage( - { data: typedMsgParams, from: account }, - SignTypedDataVersion.V1, - ); - const recovered = recoverTypedSignature({ - data: typedMsgParams, - signature, - version: SignTypedDataVersion.V1, + expect(controller.state.keyrings).toHaveLength(1); + expect(destroyV2).toHaveBeenCalled(); }); - expect(account.toLowerCase()).toBe(recovered.toLowerCase()); }); - it('should sign typed message V3 with QR keyring', async () => { - await setupQRKeyring(); - requestSignatureStub.resolves( - composeMockSignature( - '9b1deb4d-3b7d-4bad-9bdd-2b0d7b3dcb6d', - '112e4591abc834251f2671127acabebf33be3a8d8fa15312e94ba0f008e53d697930b4ae99cb36955e1c96fee888cf1ed6e314769db0bd4d6246d492b8685fd21c', - ), - ); + it('does not remove keyrings if the operation rolls back', async () => { + await withController(async ({ controller }) => { + const importedAccount = await controller.importAccountWithStrategy( + AccountImportStrategy.privateKey, + [privateKey], + ); + expect(controller.state.keyrings).toHaveLength(2); - const msg = - '{"types":{"EIP712Domain":[{"name":"name","type":"string"},{"name":"version","type":"string"},{"name":"chainId","type":"uint256"},{"name":"verifyingContract","type":"address"}],"Person":[{"name":"name","type":"string"},{"name":"wallet","type":"address"}],"Mail":[{"name":"from","type":"Person"},{"name":"to","type":"Person"},{"name":"contents","type":"string"}]},"primaryType":"Mail","domain":{"name":"Ether Mail","version":"1","chainId":4,"verifyingContract":"0xCcCCccccCCCCcCCCCCCcCcCccCcCCCcCcccccccC"},"message":{"from":{"name":"Cow","wallet":"0xCD2a3d9F938E13CD947Ec05AbC7FE734Df8DD826"},"to":{"name":"Bob","wallet":"0xbBbBBBBbbBBBbbbBbbBbbbbBBbBbbbbBbBbbBBbB"},"contents":"Hello, Bob!"}}'; + await expect( + controller.withKeyringV2( + { type: KeyringType.PrivateKey }, + async ({ keyring }) => { + const [account] = await keyring.getAccounts(); + await keyring.deleteAccount(account.id); + throw new Error('Oops'); + }, + ), + ).rejects.toThrow('Oops'); - const qrKeyring = signProcessKeyringController.state.keyrings.find( - (keyring) => keyring.type === KeyringTypes.qr, - ); - const account = qrKeyring?.accounts[0] || ''; - const signature = await signProcessKeyringController.signTypedMessage( - { - data: msg, - from: account, - }, - SignTypedDataVersion.V3, - ); - const recovered = recoverTypedSignature({ - data: JSON.parse(msg), - signature, - version: SignTypedDataVersion.V3, + // Rollback restores the original keyrings (still 2). + expect(controller.state.keyrings).toHaveLength(2); + expect(controller.state.keyrings[1].accounts).toStrictEqual([ + importedAccount, + ]); }); - expect(account.toLowerCase()).toBe(recovered); }); + }); + }); - it('should sign typed message V4 with QR keyring', async () => { - await setupQRKeyring(); - requestSignatureStub.resolves( - composeMockSignature( - '9b1deb4d-3b7d-4bad-9bdd-2b0d7b3dcb6d', - '1271c3de4683ed99b11ceecc0a81f48701057174eb0edd729342ecdd9e061ed26eea3c4b84d232e01de00f1f3884fdfe15f664fe2c58c2e565d672b3cb281ccb1c', - ), - ); + describe('withKeyringV2Unsafe', () => { + it('calls the given function without acquiring the lock', async () => { + await withController(async ({ controller, initialState }) => { + const acquireSpy = jest.spyOn(Mutex.prototype, 'acquire'); + const fn = jest.fn().mockResolvedValue('result'); + const selector = { type: KeyringType.Hd }; + const { metadata } = initialState.keyrings[0]; - const msg = - '{"domain":{"chainId":"4","name":"Ether Mail","verifyingContract":"0xCcCCccccCCCCcCCCCCCcCcCccCcCCCcCcccccccC","version":"1"},"message":{"contents":"Hello, Bob!","from":{"name":"Cow","wallets":["0xCD2a3d9F938E13CD947Ec05AbC7FE734Df8DD826","0xDeaDbeefdEAdbeefdEadbEEFdeadbeEFdEaDbeeF"]},"to":[{"name":"Bob","wallets":["0xbBbBBBBbbBBBbbbBbbBbbbbBBbBbbbbBbBbbBBbB","0xB0BdaBea57B0BDABeA57b0bdABEA57b0BDabEa57","0xB0B0b0b0b0b0B000000000000000000000000000"]}]},"primaryType":"Mail","types":{"EIP712Domain":[{"name":"name","type":"string"},{"name":"version","type":"string"},{"name":"chainId","type":"uint256"},{"name":"verifyingContract","type":"address"}],"Group":[{"name":"name","type":"string"},{"name":"members","type":"Person[]"}],"Mail":[{"name":"from","type":"Person"},{"name":"to","type":"Person[]"},{"name":"contents","type":"string"}],"Person":[{"name":"name","type":"string"},{"name":"wallets","type":"address[]"}]}}'; + const result = await controller.withKeyringV2Unsafe(selector, fn); - const qrKeyring = signProcessKeyringController.state.keyrings.find( - (keyring) => keyring.type === KeyringTypes.qr, - ); - const account = qrKeyring?.accounts[0] || ''; - const signature = await signProcessKeyringController.signTypedMessage( - { data: msg, from: account }, - SignTypedDataVersion.V4, - ); - const recovered = recoverTypedSignature({ - data: JSON.parse(msg), - signature, - version: SignTypedDataVersion.V4, + expect(acquireSpy).not.toHaveBeenCalled(); + expect(fn).toHaveBeenCalledWith({ + keyring: expect.any(Object), + metadata, }); - expect(account.toLowerCase()).toBe(recovered); + expect(result).toBe('result'); }); }); - describe('signTransaction', () => { - it('should sign transaction with QR keyring', async () => { - await setupQRKeyring(); - requestSignatureStub.resolves( - composeMockSignature( - '9b1deb4d-3b7d-4bad-9bdd-2b0d7b3dcb6d', - '33ea4c1dc4b201ad1b1feaf172aadf60dcf2f8bd76d941396bfaebfc3b2868b0340d5689341925c99cdea39e3c5daf7fe2776f220e5b018e85d3b1df19c7bc4701', - ), - ); - - const qrKeyring = signProcessKeyringController.state.keyrings.find( - (keyring) => keyring.type === KeyringTypes.qr, - ); - const account = qrKeyring?.accounts[0] || ''; - const tx = TransactionFactory.fromTxData( - { - accessList: [], - chainId: '0x5', - data: '0x', - gasLimit: '0x5208', - maxFeePerGas: '0x2540be400', - maxPriorityFeePerGas: '0x3b9aca00', - nonce: '0x68', - r: undefined, - s: undefined, - to: '0x0c54fccd2e384b4bb6f2e405bf5cbc15a017aafb', - v: undefined, - value: '0x0', - type: 2, - }, - { - common: Common.custom({ - name: 'goerli', - chainId: parseInt('5'), - networkId: parseInt('5'), - defaultHardfork: 'london', - }), - }, - ); - const signedTx = await signProcessKeyringController.signTransaction( - tx, - account, - ); - expect(signedTx.v).toBeDefined(); - expect(signedTx).not.toBe(''); - }); + it('throws if the controller is locked', async () => { + await withController( + { skipVaultCreation: true }, + async ({ controller }) => { + await expect( + controller.withKeyringV2Unsafe({ type: KeyringType.Hd }, jest.fn()), + ).rejects.toThrow(KeyringControllerErrorMessage.ControllerLocked); + }, + ); }); - describe('resetQRKeyringState', () => { - it('should reset qr keyring state', async () => { - await setupQRKeyring(); - (await signProcessKeyringController.getQRKeyringState()).updateState({ - sign: { - request: { - requestId: 'test', - payload: { - cbor: 'test', - type: 'test', - }, - }, - }, - }); + it('throws KeyringNotFound if no keyring matches the selector', async () => { + await withController(async ({ controller }) => { + const fn = jest.fn(); - expect( - (await signProcessKeyringController.getQRKeyringState()).getState() - .sign.request, - ).toBeDefined(); - - await signProcessKeyringController.resetQRKeyringState(); - - expect( - (await signProcessKeyringController.getQRKeyringState()).getState() - .sign.request, - ).toBeUndefined(); - }); - }); - - describe('forgetQRDevice', () => { - it('should forget qr keyring', async () => { - await setupQRKeyring(); - expect( - signProcessKeyringController.state.keyrings[1].accounts, - ).toHaveLength(3); - await signProcessKeyringController.forgetQRDevice(); - expect( - signProcessKeyringController.state.keyrings[1].accounts, - ).toHaveLength(0); - }); - }); - - describe('restoreQRKeyring', () => { - it('should restore qr keyring', async () => { - const serializedQRKeyring = { - initialized: true, - accounts: ['0xE410157345be56688F43FF0D9e4B2B38Ea8F7828'], - currentAccount: 0, - page: 0, - perPage: 5, - keyringAccount: 'account.standard', - keyringMode: 'hd', - name: 'Keystone', - version: 1, - xfp: '5271c071', - xpub: 'xpub6CNhtuXAHDs84AhZj5ALZB6ii4sP5LnDXaKDSjiy6kcBbiysq89cDrLG29poKvZtX9z4FchZKTjTyiPuDeiFMUd1H4g5zViQxt4tpkronJr', - hdPath: "m/44'/60'/0'", - childrenPath: '0/*', - indexes: { - '0xE410157345be56688F43FF0D9e4B2B38Ea8F7828': 0, - '0xEEACb7a5e53600c144C0b9839A834bb4b39E540c': 1, - '0xA116800A72e56f91cF1677D40C9984f9C9f4B2c7': 2, - '0x4826BadaBC9894B3513e23Be408605611b236C0f': 3, - '0x8a1503beb17Ef02cC4Ff288b0A73583c4ce547c7': 4, - }, - paths: {}, - }; - await signProcessKeyringController.restoreQRKeyring( - serializedQRKeyring, - ); - expect( - signProcessKeyringController.state.keyrings[1].accounts, - ).toHaveLength(1); + await expect( + controller.withKeyringV2Unsafe({ type: KeyringType.Snap }, fn), + ).rejects.toThrow(KeyringControllerErrorMessage.KeyringNotFound); + + expect(fn).not.toHaveBeenCalled(); }); }); - describe('getAccountKeyringType', () => { - it('should get account keyring type', async () => { - await setupQRKeyring(); - const qrAccount = '0xE410157345be56688F43FF0D9e4B2B38Ea8F7828'; - const hdAccount = - signProcessKeyringController.state.keyrings[0].accounts[0]; - expect( - await signProcessKeyringController.getAccountKeyringType(hdAccount), - ).toBe(KeyringTypes.hd); + it('throws KeyringV2NotSupported when the selected keyring has no V2 adapter', async () => { + await withController( + { keyringBuilders: [keyringBuilderFactory(MockShallowKeyring)] }, + async ({ controller }) => { + const metadata = await controller.addNewKeyring( + MockShallowKeyring.type, + ); - expect( - await signProcessKeyringController.getAccountKeyringType(qrAccount), - ).toBe(KeyringTypes.qr); - }); - }); + const fn = jest.fn(); + await expect( + controller.withKeyringV2Unsafe({ id: metadata.id }, fn), + ).rejects.toThrow( + KeyringControllerErrorMessage.KeyringV2NotSupported, + ); - describe('submitQRCryptoHDKey', () => { - it("should call qr keyring's method", async () => { - await setupQRKeyring(); - const qrKeyring = - await signProcessKeyringController.getOrAddQRKeyring(); + expect(fn).not.toHaveBeenCalled(); + }, + ); + }); - const submitCryptoHDKeyStub = sinon.stub( - qrKeyring, - 'submitCryptoHDKey', + it('throws UnsafeDirectKeyringAccess if the callback returns the selected keyring', async () => { + await withController(async ({ controller }) => { + await expect( + controller.withKeyringV2Unsafe( + { type: KeyringType.Hd }, + async ({ keyring }) => keyring, + ), + ).rejects.toThrow( + KeyringControllerErrorMessage.UnsafeDirectKeyringAccess, ); - submitCryptoHDKeyStub.resolves(); - await signProcessKeyringController.submitQRCryptoHDKey('anything'); - expect(submitCryptoHDKeyStub.calledWith('anything')).toBe(true); }); }); - describe('submitQRCryptoAccount', () => { - it("should call qr keyring's method", async () => { - await setupQRKeyring(); - const qrKeyring = - await signProcessKeyringController.getOrAddQRKeyring(); + it('does not match legacy KeyringTypes values in V2 type selectors', async () => { + await withController(async ({ controller }) => { + const fn = jest.fn(); - const submitCryptoAccountStub = sinon.stub( - qrKeyring, - 'submitCryptoAccount', - ); - submitCryptoAccountStub.resolves(); - await signProcessKeyringController.submitQRCryptoAccount('anything'); - expect(submitCryptoAccountStub.calledWith('anything')).toBe(true); + await expect( + controller.withKeyringV2Unsafe( + { type: KeyringTypes.hd } as unknown as KeyringSelectorV2, + fn, + ), + ).rejects.toThrow(KeyringControllerErrorMessage.KeyringNotFound); + + expect(fn).not.toHaveBeenCalled(); }); }); - describe('submitQRSignature', () => { - it("should call qr keyring's method", async () => { - await setupQRKeyring(); - const qrKeyring = - await signProcessKeyringController.getOrAddQRKeyring(); + describe('when the keyring is selected by address', () => { + it('calls the given function with the wrapped V2 keyring', async () => { + await withController(async ({ controller, initialState }) => { + const fn = jest.fn(); + const selector = { + address: initialState.keyrings[0].accounts[0] as Hex, + }; + const { metadata } = initialState.keyrings[0]; + + await controller.withKeyringV2Unsafe(selector, fn); - const submitSignatureStub = sinon.stub(qrKeyring, 'submitSignature'); - submitSignatureStub.resolves(); - await signProcessKeyringController.submitQRSignature( - 'anything', - 'anything', - ); - expect(submitSignatureStub.calledWith('anything', 'anything')).toBe( - true, - ); + expect(fn).toHaveBeenCalledWith({ + keyring: expect.any(Object), + metadata, + }); + }); }); }); - describe('cancelQRSignRequest', () => { - it("should call qr keyring's method", async () => { - await setupQRKeyring(); - const qrKeyring = - await signProcessKeyringController.getOrAddQRKeyring(); + describe('when the keyring is selected by id', () => { + it('calls the given function with the wrapped V2 keyring', async () => { + await withController(async ({ controller, initialState }) => { + const fn = jest.fn(); + const { metadata } = initialState.keyrings[0]; + const selector = { id: metadata.id }; + + await controller.withKeyringV2Unsafe(selector, fn); - const cancelSignRequestStub = sinon.stub( - qrKeyring, - 'cancelSignRequest', - ); - cancelSignRequestStub.resolves(); - await signProcessKeyringController.cancelQRSignRequest(); - expect(cancelSignRequestStub.called).toBe(true); + expect(fn).toHaveBeenCalledWith({ + keyring: expect.any(Object), + metadata, + }); + }); }); - }); - describe('cancelQRSynchronization', () => { - it('should call `cancelSync` on the QR keyring', async () => { - await setupQRKeyring(); - const qrKeyring = - await signProcessKeyringController.getOrAddQRKeyring(); + it('throws KeyringNotFound if no keyring has the given id', async () => { + await withController(async ({ controller }) => { + const fn = jest.fn(); - const cancelSyncRequestStub = sinon.stub(qrKeyring, 'cancelSync'); - cancelSyncRequestStub.resolves(); - await signProcessKeyringController.cancelQRSynchronization(); - expect(cancelSyncRequestStub.called).toBe(true); + await expect( + controller.withKeyringV2Unsafe({ id: 'non-existent-id' }, fn), + ).rejects.toThrow(KeyringControllerErrorMessage.KeyringNotFound); + expect(fn).not.toHaveBeenCalled(); + }); }); }); - describe('QRKeyring store events', () => { - describe('KeyringController:qrKeyringStateChange', () => { - it('should emit KeyringController:qrKeyringStateChange event after `getOrAddQRKeyring()`', async () => { - const listener = jest.fn(); - signProcessKeyringControllerMessenger.subscribe( - 'KeyringController:qrKeyringStateChange', - listener, - ); - const qrKeyring = - await signProcessKeyringController.getOrAddQRKeyring(); + describe('when the keyring is selected by filter', () => { + it('calls the given function with the matching V2 keyring', async () => { + await withController(async ({ controller, initialState }) => { + const fn = jest.fn(); + const { metadata } = initialState.keyrings[0]; + const selector: KeyringSelectorV2 = { + filter: (k): boolean => k.type === KeyringType.Hd, + }; - qrKeyring.getMemStore().updateState({ - sync: { - reading: true, - }, - }); + await controller.withKeyringV2Unsafe(selector, fn); - expect(listener).toHaveBeenCalledTimes(1); + expect(fn).toHaveBeenCalledWith({ + keyring: expect.any(Object), + metadata, + }); }); + }); - it('should emit KeyringController:qrKeyringStateChange after `submitPassword()`', async () => { - const listener = jest.fn(); - signProcessKeyringControllerMessenger.subscribe( - 'KeyringController:qrKeyringStateChange', - listener, - ); - // We ensure there is a QRKeyring before locking - await signProcessKeyringController.getOrAddQRKeyring(); - // Locking the keyring will dereference the QRKeyring - await signProcessKeyringController.setLocked(); - // ..and unlocking it should add a new instance of QRKeyring - await signProcessKeyringController.submitPassword(password); - // We call `getQRKeyring` instead of `getOrAddQRKeyring` so that - // we are able to test if the subscription to the internal QR keyring - // was made while unlocking the keyring. - const qrKeyring = signProcessKeyringController.getQRKeyring(); - - // As we added a QR keyring before lock/unlock, this must be defined - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - qrKeyring!.getMemStore().updateState({ - sync: { - reading: true, - }, - }); + it('throws KeyringNotFound if no keyring matches the filter', async () => { + await withController(async ({ controller }) => { + const fn = jest.fn(); - // Only one call ensures that the first subscription made by - // QR keyring before locking was removed - expect(listener).toHaveBeenCalledTimes(1); + await expect( + controller.withKeyringV2Unsafe( + { filter: (): boolean => false }, + fn, + ), + ).rejects.toThrow(KeyringControllerErrorMessage.KeyringNotFound); + expect(fn).not.toHaveBeenCalled(); }); + }); - it('should emit KeyringController:qrKeyringStateChange after `submitEncryptionKey()`', async () => { - const listener = jest.fn(); - signProcessKeyringControllerMessenger.subscribe( - 'KeyringController:qrKeyringStateChange', - listener, - ); - const salt = signProcessKeyringController.state - .encryptionSalt as string; - // We ensure there is a QRKeyring before locking - await signProcessKeyringController.getOrAddQRKeyring(); - // Locking the keyring will dereference the QRKeyring - await signProcessKeyringController.setLocked(); - // ..and unlocking it should add a new instance of QRKeyring - await signProcessKeyringController.submitEncryptionKey( - mockKey.toString('hex'), - salt, - ); - // We call `getQRKeyring` instead of `getOrAddQRKeyring` so that - // we are able to test if the subscription to the internal QR keyring - // was made while unlocking the keyring. - const qrKeyring = signProcessKeyringController.getQRKeyring(); - - // As we added a QR keyring before lock/unlock, this must be defined - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - qrKeyring!.getMemStore().updateState({ - sync: { - reading: true, - }, - }); + it('skips keyrings that do not have a v2 wrapper', async () => { + await withController( + { keyringBuilders: [keyringBuilderFactory(MockKeyring)] }, + async ({ controller }) => { + await controller.addNewKeyring(MockKeyring.type); + + // The HD keyring supports v2, the MockKeyring does not. + // Filter skips HD (first call returns false) then hits MockKeyring + // which has no v2 wrapper, so it is implicitly skipped. + const filter = jest + .fn() + .mockReturnValueOnce(false) + .mockReturnValueOnce(true); + + const fn = jest.fn(); + await expect( + controller.withKeyringV2Unsafe({ filter }, fn), + ).rejects.toThrow(KeyringControllerErrorMessage.KeyringNotFound); + }, + ); + }); + }); - // Only one call ensures that the first subscription made by - // QR keyring before locking was removed - expect(listener).toHaveBeenCalledTimes(1); - }); + it('does not roll back state if an error is thrown', async () => { + await withController(async ({ controller, initialState }) => { + // Mutate state via withKeyring first to establish a known state. + await controller.withKeyring( + { type: KeyringTypes.hd }, + async ({ keyring }) => keyring.addAccounts(1), + ); - it('should emit KeyringController:qrKeyringStateChange after `addNewKeyring()`', async () => { - const listener = jest.fn(); - signProcessKeyringControllerMessenger.subscribe( - 'KeyringController:qrKeyringStateChange', - listener, - ); - const qrKeyring = (await signProcessKeyringController.addNewKeyring( - KeyringTypes.qr, - )) as QRKeyring; + const accountsBefore = controller.state.keyrings[0].accounts; - qrKeyring.getMemStore().updateState({ - sync: { - reading: true, - }, - }); + // withKeyringV2Unsafe does not roll back — errors just propagate. + await expect( + controller.withKeyringV2Unsafe({ type: KeyringType.Hd }, async () => { + throw new Error('Oops'); + }), + ).rejects.toThrow('Oops'); + + // State is unchanged (no rollback to pre-withKeyringV2Unsafe state). + expect(controller.state.keyrings[0].accounts).toStrictEqual( + accountsBefore, + ); + expect(controller.state.keyrings[0].accounts).not.toStrictEqual( + initialState.keyrings[0].accounts, + ); + }); + }); + + describe('messenger action', () => { + it('should be callable through the messenger', async () => { + await withController(async ({ messenger }) => { + const fn = jest.fn(); + + await messenger.call( + 'KeyringController:withKeyringV2Unsafe', + { type: KeyringType.Hd }, + fn, + ); - expect(listener).toHaveBeenCalledTimes(1); + expect(fn).toHaveBeenCalled(); }); }); }); }); + describe('isCustodyKeyring', () => { + it('should return true if keyring is custody keyring', () => { + expect(isCustodyKeyring('Custody JSON-RPC')).toBe(true); + }); + + it('should not return true if keyring is not custody keyring', () => { + expect(isCustodyKeyring(KeyringTypes.hd)).toBe(false); + }); + + it("should not return true if the keyring doesn't start with custody", () => { + expect(isCustodyKeyring('NotCustody')).toBe(false); + }); + }); + describe('actions', () => { beforeEach(() => { jest @@ -2083,6 +5355,29 @@ describe('KeyringController', () => { jest .spyOn(KeyringController.prototype, 'getEncryptionPublicKey') .mockResolvedValue('ZfKqt4HSy4tt9/WvqP3QrnzbIS04cnV//BhksKbLgVA='); + jest + .spyOn(KeyringController.prototype, 'prepareUserOperation') + .mockResolvedValue({ + callData: '0x706', + initCode: '0x22ff', + nonce: '0x1', + gasLimits: { + callGasLimit: '0x58a83', + verificationGasLimit: '0xe8c4', + preVerificationGas: '0xc57c', + }, + dummySignature: '0x', + dummyPaymasterAndData: '0x', + bundlerUrl: 'https://bundler.example.com/rpc', + }); + jest + .spyOn(KeyringController.prototype, 'patchUserOperation') + .mockResolvedValue({ + paymasterAndData: '0x1234', + }); + jest + .spyOn(KeyringController.prototype, 'signUserOperation') + .mockResolvedValue('0x1234'); }); describe('signMessage', () => { @@ -2195,6 +5490,118 @@ describe('KeyringController', () => { }); }); + describe('prepareUserOperation', () => { + const chainId = '0x1'; + const executionContext = { + chainId, + }; + + it('should return a base UserOp', async () => { + await withController( + async ({ controller, messenger, initialState }) => { + const baseTxs = [ + { + to: '0x0c54fccd2e384b4bb6f2e405bf5cbc15a017aafb', + value: '0x0', + data: '0x0', + }, + ]; + + await messenger.call( + 'KeyringController:prepareUserOperation', + initialState.keyrings[0].accounts[0], + baseTxs, + executionContext, + ); + + expect(controller.prepareUserOperation).toHaveBeenCalledWith( + initialState.keyrings[0].accounts[0], + baseTxs, + executionContext, + ); + }, + ); + }); + }); + + describe('patchUserOperation', () => { + const chainId = '0x1'; + const executionContext = { + chainId, + }; + it('should return an UserOp patch', async () => { + await withController( + async ({ controller, messenger, initialState }) => { + const userOp = { + sender: '0x4584d2B4905087A100420AFfCe1b2d73fC69B8E4', + nonce: '0x1', + initCode: '0x', + callData: '0x7064', + callGasLimit: '0x58a83', + verificationGasLimit: '0xe8c4', + preVerificationGas: '0xc57c', + maxFeePerGas: '0x87f0878c0', + maxPriorityFeePerGas: '0x1dcd6500', + paymasterAndData: '0x', + signature: '0x', + }; + + await messenger.call( + 'KeyringController:patchUserOperation', + initialState.keyrings[0].accounts[0], + userOp, + executionContext, + ); + + expect(controller.patchUserOperation).toHaveBeenCalledWith( + initialState.keyrings[0].accounts[0], + userOp, + executionContext, + ); + }, + ); + }); + }); + + describe('signUserOperation', () => { + const chainId = '0x1'; + const executionContext = { + chainId, + }; + it('should return an UserOp signature', async () => { + await withController( + async ({ controller, messenger, initialState }) => { + const userOp = { + sender: '0x4584d2B4905087A100420AFfCe1b2d73fC69B8E4', + nonce: '0x1', + initCode: '0x', + callData: '0x7064', + callGasLimit: '0x58a83', + verificationGasLimit: '0xe8c4', + preVerificationGas: '0xc57c', + maxFeePerGas: '0x87f0878c0', + maxPriorityFeePerGas: '0x1dcd6500', + paymasterAndData: '0x', + signature: '0x', + }; + + await messenger.call( + 'KeyringController:signUserOperation', + initialState.keyrings[0].accounts[0], + userOp, + executionContext, + ); + + expect(controller.signUserOperation).toHaveBeenCalledWith( + initialState.keyrings[0].accounts[0], + userOp, + executionContext, + ); + }, + ); + }); + }); + describe('getKeyringsByType', () => { it('should return correct keyring by type', async () => { jest @@ -2243,75 +5650,535 @@ describe('KeyringController', () => { }); }); }); + + describe('persistAllKeyrings', () => { + it('should call persistAllKeyrings', async () => { + jest + .spyOn(KeyringController.prototype, 'persistAllKeyrings') + .mockResolvedValue(true); + await withController(async ({ controller, messenger }) => { + await messenger.call('KeyringController:persistAllKeyrings'); + + expect(controller.persistAllKeyrings).toHaveBeenCalledWith(); + }); + }); + }); + + describe('withKeyring', () => { + it('should call withKeyring', async () => { + await withController( + { keyringBuilders: [keyringBuilderFactory(MockKeyring)] }, + async ({ controller, messenger }) => { + await controller.addNewKeyring(MockKeyring.type); + + const actionReturnValue = await messenger.call( + 'KeyringController:withKeyring', + { type: MockKeyring.type }, + async ({ keyring }) => { + expect(keyring.type).toBe(MockKeyring.type); + return keyring.type; + }, + ); + + expect(actionReturnValue).toBe(MockKeyring.type); + }, + ); + }); + }); + + describe('withController', () => { + it('should call withController', async () => { + await withController(async ({ messenger }) => { + const operation = jest.fn().mockResolvedValue('result'); + + const actionReturnValue = await messenger.call( + 'KeyringController:withController', + operation, + ); + + expect(operation).toHaveBeenCalledWith( + expect.objectContaining({ + keyrings: expect.any(Array), + addNewKeyring: expect.any(Function), + removeKeyring: expect.any(Function), + }), + ); + expect(actionReturnValue).toBe('result'); + }); + }); + }); + + describe('addNewKeyring', () => { + it('should call addNewKeyring', async () => { + const mockKeyringMetadata: KeyringMetadata = { + id: 'mock-id', + name: 'mock-keyring', + }; + jest + .spyOn(KeyringController.prototype, 'addNewKeyring') + .mockImplementationOnce(async () => mockKeyringMetadata); + + await withController( + { keyringBuilders: [keyringBuilderFactory(MockKeyring)] }, + async ({ controller, messenger }) => { + const mockKeyringOptions = {}; + + expect( + await messenger.call( + 'KeyringController:addNewKeyring', + MockKeyring.type, + mockKeyringOptions, + ), + ).toStrictEqual(mockKeyringMetadata); + + expect(controller.addNewKeyring).toHaveBeenCalledWith( + MockKeyring.type, + mockKeyringOptions, + ); + }, + ); + }); + }); + }); + + describe('run conditions', () => { + it('should not cause run conditions when called multiple times', async () => { + await withController(async ({ controller, initialState }) => { + await Promise.all([ + controller.submitPassword(password), + controller.submitPassword(password), + controller.submitPassword(password), + controller.submitPassword(password), + ]); + + expect(controller.state).toStrictEqual(initialState); + }); + }); + + it('should not cause run conditions when called multiple times in combination with persistAllKeyrings', async () => { + await withController(async ({ controller, initialState }) => { + await Promise.all([ + controller.submitPassword(password), + controller.persistAllKeyrings(), + controller.submitPassword(password), + controller.persistAllKeyrings(), + ]); + + expect(controller.state).toStrictEqual(initialState); + }); + }); + + it('should not cause a deadlock when subscribing to state changes', async () => { + await withController(async ({ controller, initialState, messenger }) => { + let callCount = 0; + const noOp = async (): Promise => { + // No operation for subsequent calls + }; + const persistAction = async (): Promise => { + await controller.persistAllKeyrings(); + }; + const actions: (() => Promise)[] = [persistAction, noOp, noOp]; + const listener = jest.fn(async () => { + callCount += 1; + // Only execute persistAllKeyrings on the first call to prevent infinite loops + const actionIndex = Math.min(callCount - 1, actions.length - 1); + await actions[actionIndex](); + }); + + messenger.subscribe( + 'KeyringController:stateChange', + // Cast to avoid misued-promise warning. + listener as jest.Mocked<() => void>, + ); + + await controller.submitPassword(password); + + expect(controller.state).toStrictEqual(initialState); + expect(listener).toHaveBeenCalled(); + }); + }); + }); + + describe('atomic operations', () => { + describe('addNewKeyring', () => { + it('should rollback the controller keyrings if the keyring creation fails', async () => { + const mockAddress = '0x4584d2B4905087A100420AFfCe1b2d73fC69B8E4'; + stubKeyringClassWithAccount(MockKeyring, mockAddress); + // Mocking the serialize method to throw an error will + // halt the controller everytime it tries to persist the keyring, + // making it impossible to update the vault + jest + .spyOn(MockKeyring.prototype, 'serialize') + .mockImplementation(async () => { + throw new Error('You will never be able to persist me!'); + }); + await withController( + { keyringBuilders: [keyringBuilderFactory(MockKeyring)] }, + async ({ controller, initialState }) => { + await expect( + controller.addNewKeyring(MockKeyring.type), + ).rejects.toThrow('You will never be able to persist me!'); + + expect(controller.state).toStrictEqual(initialState); + await expect( + controller.exportAccount({ password }, mockAddress), + ).rejects.toThrow(KeyringControllerErrorMessage.KeyringNotFound); + }, + ); + }); + }); + }); + + describe('metadata', () => { + it('includes expected state in debug snapshots', async () => { + await withController( + // Skip vault creation and use static vault to get deterministic state snapshot + { skipVaultCreation: true, state: { vault: createVault() } }, + ({ controller }) => { + expect( + deriveStateFromMetadata( + controller.state, + controller.metadata, + 'includeInDebugSnapshot', + ), + ).toMatchInlineSnapshot(` + { + "isUnlocked": false, + } + `); + }, + ); + }); + + it('includes expected state in state logs', async () => { + await withController( + // Skip vault creation and use static vault to get deterministic state snapshot + { skipVaultCreation: true, state: { vault: createVault() } }, + ({ controller }) => { + expect( + deriveStateFromMetadata( + controller.state, + controller.metadata, + 'includeInStateLogs', + ), + ).toMatchInlineSnapshot(` + { + "isUnlocked": false, + "keyrings": [], + } + `); + }, + ); + }); + + it('persists expected state', async () => { + await withController( + // Skip vault creation and use static vault to get deterministic state snapshot + { skipVaultCreation: true, state: { vault: createVault() } }, + ({ controller }) => { + expect( + deriveStateFromMetadata( + controller.state, + controller.metadata, + 'persist', + ), + ).toMatchInlineSnapshot(` + { + "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"}", + } + `); + }, + ); + }); + + it('exposes expected state to UI', async () => { + await withController( + // Skip vault creation and use static vault to get deterministic state snapshot + { skipVaultCreation: true, state: { vault: createVault() } }, + ({ controller }) => { + expect( + deriveStateFromMetadata( + controller.state, + controller.metadata, + 'usedInUi', + ), + ).toMatchInlineSnapshot(` + { + "isUnlocked": false, + "keyrings": [], + } + `); + }, + ); + }); + }); + + describe('KeyringControllerError', () => { + describe('error features', () => { + it('should support error codes', () => { + const error = new KeyringControllerError('Test error', { + code: 'TEST_CODE', + }); + + expect(error.code).toBe('TEST_CODE'); + expect(error.message).toBe('Test error'); + expect(error.name).toBe('KeyringControllerError'); + }); + + it('should support additional data', () => { + const error = new KeyringControllerError('Test error', { + context: { key: 'value', number: 42 }, + }); + + expect(error.context).toStrictEqual({ key: 'value', number: 42 }); + }); + + it('should support error chaining with cause', () => { + const originalError = new Error('Original error'); + const error = new KeyringControllerError('Wrapped error', { + cause: originalError, + }); + + expect(error.cause).toBe(originalError); + expect(error.originalError).toBe(originalError); + }); + + it('should support backward compatibility with Error as second param', () => { + const originalError = new Error('Original error'); + const error = new KeyringControllerError( + 'Wrapped error', + originalError, + ); + + expect(error.cause).toBe(originalError); + expect(error.originalError).toBe(originalError); + }); + + it('should serialize to JSON correctly', () => { + const originalError = new Error('Original error'); + const error = new KeyringControllerError('Test error', { + code: 'TEST_CODE', + context: { key: 'value' }, + cause: originalError, + }); + + const json = error.toJSON(); + + expect(json.name).toBe('KeyringControllerError'); + expect(json.message).toBe('Test error'); + expect(json.code).toBe('TEST_CODE'); + expect(json.context).toStrictEqual({ key: 'value' }); + expect(json.cause).toStrictEqual({ + name: 'Error', + message: 'Original error', + stack: originalError.stack, + }); + }); + + it('should serialize to JSON without cause if not present', () => { + const error = new KeyringControllerError('Test error', { + code: 'TEST_CODE', + }); + + const json = error.toJSON(); + + expect(json.cause).toBeUndefined(); + }); + + it('should convert to string with code', () => { + const error = new KeyringControllerError('Test error', { + code: 'TEST_CODE', + }); + + const str = error.toString(); + + expect(str).toContain('KeyringControllerError'); + expect(str).toContain('Test error'); + expect(str).toContain('[TEST_CODE]'); + }); + + it('should convert to string with cause', () => { + const originalError = new Error('Original error'); + const error = new KeyringControllerError('Test error', { + cause: originalError, + }); + + const str = error.toString(); + + expect(str).toContain('KeyringControllerError: Test error'); + expect(str).toContain('Caused by: Error: Original error'); + }); + + it('should convert to string with both code and cause', () => { + const originalError = new Error('Original error'); + const error = new KeyringControllerError('Test error', { + code: 'TEST_CODE', + cause: originalError, + }); + + const str = error.toString(); + + expect(str).toContain('KeyringControllerError: Test error'); + expect(str).toContain('[TEST_CODE]'); + expect(str).toContain('Caused by: Error: Original error'); + }); + }); + }); + + describe('error handling', () => { + describe('when hardware wallet throws custom error', () => { + it('should preserve hardware wallet error in originalError property', async () => { + const mockHardwareKeyringBuilder = keyringBuilderFactory( + MockHardwareKeyring as unknown as KeyringClass, + ); + + await withController( + { + keyringBuilders: [mockHardwareKeyringBuilder], + }, + async ({ controller }) => { + // Add the hardware keyring + await controller.addNewKeyring('Mock Hardware'); + // Get all accounts - the hardware wallet should be the second keyring + const allAccounts = await controller.getAccounts(); + // Use the hardware wallet address (last one added) + const hardwareAddress = allAccounts[allAccounts.length - 1]; + + const typedData = { + types: { + EIP712Domain: [ + { name: 'name', type: 'string' }, + { name: 'version', type: 'string' }, + ], + Message: [{ name: 'content', type: 'string' }], + }, + primaryType: 'Message', + domain: { + name: 'Test', + version: '1', + }, + message: { + content: 'Hello!', + }, + }; + + await expect( + controller.signTypedMessage( + { data: JSON.stringify(typedData), from: hardwareAddress }, + SignTypedDataVersion.V4, + ), + ).rejects.toThrow(KeyringControllerError); + + // Verify the error details by catching it explicitly + let caughtError: unknown; + try { + await controller.signTypedMessage( + { data: JSON.stringify(typedData), from: hardwareAddress }, + SignTypedDataVersion.V4, + ); + } catch (error) { + caughtError = error; + } + + // Verify the error is a KeyringControllerError (wrapped by signTypedMessage) + expect(caughtError).toBeInstanceOf(KeyringControllerError); + + const keyringError = caughtError as KeyringControllerError; + + // Verify the error message contains information about the hardware wallet error + expect(keyringError.message).toContain( + 'Keyring Controller signTypedMessage', + ); + expect(keyringError.message).toContain('HardwareWalletError'); + expect(keyringError.message).toContain( + 'User rejected the request on hardware device', + ); + + // Verify the original hardware wallet error is preserved in originalError + expect(keyringError.cause).toBeInstanceOf(HardwareWalletError); + expect(keyringError.cause?.message).toBe( + 'User rejected the request on hardware device', + ); + expect(keyringError.cause?.name).toBe('HardwareWalletError'); + expect((keyringError.cause as HardwareWalletError).code).toBe( + 'USER_REJECTED', + ); + }, + ); + }); + }); }); }); type WithControllerCallback = ({ controller, - preferences, initialState, encryptor, messenger, }: { controller: KeyringController; - preferences: { - setAccountLabel: sinon.SinonStub; - syncIdentities: sinon.SinonStub; - updateIdentities: sinon.SinonStub; - setSelectedAddress: sinon.SinonStub; - }; encryptor: MockEncryptor; initialState: KeyringControllerState; - messenger: KeyringControllerMessenger; + messenger: RootMessenger; }) => Promise | ReturnValue; -type WithControllerOptions = Partial; +type WithControllerOptions = Partial & { + skipVaultCreation?: boolean; +}; type WithControllerArgs = | [WithControllerCallback] | [WithControllerOptions, WithControllerCallback]; /** - * Build a controller messenger that includes all events used by the keyring - * controller. + * Stub the `getAccounts` and `addAccounts` methods of the given keyring class to return the given + * account. * - * @returns The controller messenger. + * @param keyringClass - The keyring class to stub. + * @param account - The account to return. */ -function buildMessenger() { - return new ControllerMessenger< - KeyringControllerActions, - KeyringControllerEvents - >(); +function stubKeyringClassWithAccount( + keyringClass: KeyringClass, + account: string, +): void { + jest + .spyOn(keyringClass.prototype, 'getAccounts') + .mockResolvedValue([account]); + jest + .spyOn(keyringClass.prototype, 'addAccounts') + .mockResolvedValue([account]); +} + +/** + * Build a root messenger. + * + * @returns The root messenger. + */ +function buildRootMessenger(): RootMessenger { + return new Messenger({ namespace: MOCK_ANY_NAMESPACE }); } /** - * Build a restricted controller messenger for the keyring controller. + * Build a messenger for the keyring controller. * - * @param messenger - A controller messenger. + * @param messenger - An optional root messenger to use as the base for the + * controller messenger * @returns The keyring controller restricted messenger. */ -function buildKeyringControllerMessenger(messenger = buildMessenger()) { - return messenger.getRestricted({ - name: 'KeyringController', - allowedActions: [ - 'KeyringController:getState', - 'KeyringController:signMessage', - 'KeyringController:signPersonalMessage', - 'KeyringController:signTypedMessage', - 'KeyringController:decryptMessage', - 'KeyringController:getEncryptionPublicKey', - 'KeyringController:getKeyringsByType', - 'KeyringController:getKeyringForAccount', - 'KeyringController:getAccounts', - ], - allowedEvents: [ - 'KeyringController:stateChange', - 'KeyringController:lock', - 'KeyringController:unlock', - 'KeyringController:accountRemoved', - 'KeyringController:qrKeyringStateChange', - ], - }); +function buildKeyringControllerMessenger( + messenger = buildRootMessenger(), +): Messenger< + 'KeyringController', + KeyringControllerActions, + KeyringControllerEvents, + typeof messenger +> { + return new Messenger< + 'KeyringController', + KeyringControllerActions, + KeyringControllerEvents, + typeof messenger + >({ namespace: 'KeyringController', parent: messenger }); } /** @@ -2329,25 +6196,38 @@ async function withController( ): Promise { const [{ ...rest }, fn] = args.length === 2 ? args : [{}, args[0]]; const encryptor = new MockEncryptor(); - const preferences = { - setAccountLabel: sinon.stub(), - syncIdentities: sinon.stub(), - updateIdentities: sinon.stub(), - setSelectedAddress: sinon.stub(), - }; - const messenger = buildKeyringControllerMessenger(); + const messenger = buildRootMessenger(); + const keyringControllerMessenger = buildKeyringControllerMessenger(messenger); const controller = new KeyringController({ - encryptor, - messenger, - ...preferences, + encryptor: encryptor.asEncryptor(), + messenger: keyringControllerMessenger, ...rest, }); - await controller.createNewVaultAndKeychain(password); + if (!rest.skipVaultCreation) { + await controller.createNewVaultAndKeychain(password); + } return await fn({ controller, - preferences, encryptor, initialState: controller.state, messenger, }); } + +/** + * Construct a keyring builder with a spy. + * + * @param KeyringConstructor - The constructor to use for building the keyring. + * @returns A keyring builder that uses `jest.fn()` to spy on invocations. + */ +function buildKeyringBuilderWithSpy(KeyringConstructor: KeyringClass): { + (): EthKeyring; + type: string; +} { + const keyringBuilderWithSpy: { (): EthKeyring; type?: string } = jest + .fn() + .mockImplementation((...args) => new KeyringConstructor(...args)); + keyringBuilderWithSpy.type = KeyringConstructor.type; + // Not sure why TypeScript isn't smart enough to infer that `type` is set here. + return keyringBuilderWithSpy as { (): EthKeyring; type: string }; +} diff --git a/packages/keyring-controller/src/KeyringController.ts b/packages/keyring-controller/src/KeyringController.ts index f7c2b1bdeac..1fbf8426ff4 100644 --- a/packages/keyring-controller/src/KeyringController.ts +++ b/packages/keyring-controller/src/KeyringController.ts @@ -1,62 +1,158 @@ -import type { TxData, TypedTransaction } from '@ethereumjs/tx'; +import type { TypedTransaction, TypedTxData } from '@ethereumjs/tx'; +import { isValidPrivate, getBinarySize } from '@ethereumjs/util'; +import { BaseController } from '@metamask/base-controller'; +import type * as encryptorUtils from '@metamask/browser-passworder'; +import { HdKeyring } from '@metamask/eth-hd-keyring'; +import { HdKeyring as HdKeyringV2 } from '@metamask/eth-hd-keyring/v2'; +import { normalize as ethNormalize } from '@metamask/eth-sig-util'; +import SimpleKeyring from '@metamask/eth-simple-keyring'; +import { SimpleKeyring as SimpleKeyringV2 } from '@metamask/eth-simple-keyring/v2'; import type { - MetaMaskKeyring as QRKeyring, - IKeyringState as IQRKeyringState, -} from '@keystonehq/metamask-airgapped-keyring'; -import type { RestrictedControllerMessenger } from '@metamask/base-controller'; -import { BaseControllerV2 } from '@metamask/base-controller'; -import { KeyringController as EthKeyringController } from '@metamask/eth-keyring-controller'; + KeyringExecutionContext, + EthBaseTransaction, + EthBaseUserOperation, + EthUserOperation, + EthUserOperationPatch, + KeyringAccount, +} from '@metamask/keyring-api'; import type { - PersonalMessageParams, - TypedMessageParams, -} from '@metamask/message-manager'; -import type { PreferencesController } from '@metamask/preferences-controller'; -import type { Eip1024EncryptedData, Hex, Keyring, Json } from '@metamask/utils'; -import { assertIsStrictHexString, hasProperty } from '@metamask/utils'; -import { Mutex } from 'async-mutex'; + Keyring as KeyringV2, + KeyringType, +} from '@metamask/keyring-api/v2'; +import type { EthKeyring } from '@metamask/keyring-internal-api'; +import type { Keyring, KeyringClass } from '@metamask/keyring-utils'; +import type { Messenger } from '@metamask/messenger'; +import type { Eip1024EncryptedData, Hex, Json } from '@metamask/utils'; import { - addHexPrefix, - bufferToHex, - isValidPrivate, - toBuffer, - stripHexPrefix, - getBinarySize, -} from 'ethereumjs-util'; + add0x, + assertIsStrictHexString, + bytesToHex, + hasProperty, + hexToBytes, + isObject, + isStrictHexString, + isValidHexAddress, + isValidJson, + remove0x, +} from '@metamask/utils'; +import { Mutex } from 'async-mutex'; +import type { MutexInterface } from 'async-mutex'; import Wallet, { thirdparty as importers } from 'ethereumjs-wallet'; import type { Patch } from 'immer'; +import { cloneDeep } from 'lodash'; +// When generating a ULID within the same millisecond, monotonicFactory provides some guarantees regarding sort order. +import { ulid } from 'ulid'; + +import { KeyringControllerErrorMessage } from './constants.js'; +import { KeyringControllerError } from './errors.js'; +import type { KeyringControllerMethodActions } from './KeyringController-method-action-types.js'; +import type { + Eip7702AuthorizationParams, + Credentials, + PersonalMessageParams, + TypedMessageParams, +} from './types.js'; const name = 'KeyringController'; +const MESSENGER_EXPOSED_METHODS = [ + 'signMessage', + 'signEip7702Authorization', + 'signPersonalMessage', + 'signTransaction', + 'signTypedMessage', + 'decryptMessage', + 'getEncryptionPublicKey', + 'getAccounts', + 'getKeyringsByType', + 'getKeyringForAccount', + 'persistAllKeyrings', + 'prepareUserOperation', + 'patchUserOperation', + 'signUserOperation', + 'addNewAccount', + 'withController', + 'withKeyring', + 'withKeyringUnsafe', + 'withKeyringV2', + 'withKeyringV2Unsafe', + 'addNewKeyring', + 'createNewVaultAndKeychain', + 'createNewVaultAndRestore', + 'removeAccount', + 'isUnlocked', + 'exportSeedPhrase', + 'changePassword', + 'exportAccount', + 'exportEncryptionKey', + 'getAccountKeyringType', + 'importAccountWithStrategy', + 'setLocked', + 'submitEncryptionKey', + 'submitPassword', + 'verifyPassword', +] as const; + /** * Available keyring types + * + * @deprecated Use `KeyringType` from `@metamask/keyring-api/v2` instead. This enum will be removed + * in a future release once V2 is fully adopted. Only use it if the keyring you are trying to access + * has no V2 builder available yet. */ export enum KeyringTypes { + // Changing this would be a breaking change, and not worth the effort at this + // time, so we disable the linting rule for this block. + /* eslint-disable @typescript-eslint/naming-convention */ simple = 'Simple Key Pair', hd = 'HD Key Tree', qr = 'QR Hardware Wallet Device', trezor = 'Trezor Hardware', + oneKey = 'OneKey Hardware', ledger = 'Ledger Hardware', lattice = 'Lattice Hardware', snap = 'Snap Keyring', - custody = 'Custody', + money = 'Money Keyring', + /* eslint-enable @typescript-eslint/naming-convention */ } /** - * @type KeyringControllerState + * Custody keyring types are a special case, as they are not a single type + * but they all start with the prefix "Custody". * - * Keyring controller state - * @property vault - Encrypted string representing keyring data - * @property isUnlocked - Whether vault is unlocked - * @property keyringTypes - Account types - * @property keyrings - Group of accounts - * @property encryptionKey - Keyring encryption key - * @property encryptionSalt - Keyring encryption salt + * @param keyringType - The type of the keyring. + * @returns Whether the keyring type is a custody keyring. + */ +export const isCustodyKeyring = (keyringType: string): boolean => { + return keyringType.startsWith('Custody'); +}; + +/** + * The KeyringController state */ export type KeyringControllerState = { + /** + * Encrypted array of serialized keyrings data. + */ vault?: string; + /** + * Whether the vault has been decrypted successfully and + * keyrings contained within are deserialized and available. + */ isUnlocked: boolean; + /** + * Representations of managed keyrings. + */ keyrings: KeyringObject[]; + /** + * The encryption key derived from the password and used to encrypt + * the vault. This is only stored if the `cacheEncryptionKey` option + * is enabled. + */ encryptionKey?: string; + /** + * The salt used to derive the encryption key from the password. + */ encryptionSalt?: string; }; @@ -70,46 +166,6 @@ export type KeyringControllerGetStateAction = { handler: () => KeyringControllerState; }; -export type KeyringControllerSignMessageAction = { - type: `${typeof name}:signMessage`; - handler: KeyringController['signMessage']; -}; - -export type KeyringControllerSignPersonalMessageAction = { - type: `${typeof name}:signPersonalMessage`; - handler: KeyringController['signPersonalMessage']; -}; - -export type KeyringControllerSignTypedMessageAction = { - type: `${typeof name}:signTypedMessage`; - handler: KeyringController['signTypedMessage']; -}; - -export type KeyringControllerDecryptMessageAction = { - type: `${typeof name}:decryptMessage`; - handler: KeyringController['decryptMessage']; -}; - -export type KeyringControllerGetEncryptionPublicKeyAction = { - type: `${typeof name}:getEncryptionPublicKey`; - handler: KeyringController['getEncryptionPublicKey']; -}; - -export type KeyringControllerGetKeyringsByTypeAction = { - type: `${typeof name}:getKeyringsByType`; - handler: KeyringController['getKeyringsByType']; -}; - -export type KeyringControllerGetKeyringForAccountAction = { - type: `${typeof name}:getKeyringForAccount`; - handler: KeyringController['getKeyringForAccount']; -}; - -export type KeyringControllerGetAccountsAction = { - type: `${typeof name}:getAccounts`; - handler: KeyringController['getAccounts']; -}; - export type KeyringControllerStateChangeEvent = { type: `${typeof name}:stateChange`; payload: [KeyringControllerState, Patch[]]; @@ -130,67 +186,133 @@ export type KeyringControllerUnlockEvent = { payload: []; }; -export type KeyringControllerQRKeyringStateChangeEvent = { - type: `${typeof name}:qrKeyringStateChange`; - payload: [ReturnType]; -}; - export type KeyringControllerActions = | KeyringControllerGetStateAction - | KeyringControllerSignMessageAction - | KeyringControllerSignPersonalMessageAction - | KeyringControllerSignTypedMessageAction - | KeyringControllerDecryptMessageAction - | KeyringControllerGetEncryptionPublicKeyAction - | KeyringControllerGetAccountsAction - | KeyringControllerGetKeyringsByTypeAction - | KeyringControllerGetKeyringForAccountAction; + | KeyringControllerMethodActions; export type KeyringControllerEvents = | KeyringControllerStateChangeEvent | KeyringControllerLockEvent | KeyringControllerUnlockEvent - | KeyringControllerAccountRemovedEvent - | KeyringControllerQRKeyringStateChangeEvent; + | KeyringControllerAccountRemovedEvent; -export type KeyringControllerMessenger = RestrictedControllerMessenger< +export type KeyringControllerMessenger = Messenger< typeof name, KeyringControllerActions, - KeyringControllerEvents, - string, - string + KeyringControllerEvents >; -export type KeyringControllerOptions = { - syncIdentities: PreferencesController['syncIdentities']; - updateIdentities: PreferencesController['updateIdentities']; - setSelectedAddress: PreferencesController['setSelectedAddress']; - setAccountLabel?: PreferencesController['setAccountLabel']; - encryptor?: any; - keyringBuilders?: { (): Keyring; type: string }[]; - cacheEncryptionKey?: boolean; +export type KeyringControllerOptions< + EncryptionKey = encryptorUtils.EncryptionKey | CryptoKey, + SupportedKeyDerivationOptions = encryptorUtils.KeyDerivationOptions, + EncryptionResult extends + EncryptionResultConstraint = + DefaultEncryptionResult, +> = { + keyringBuilders?: { (): EthKeyring; type: string }[]; + keyringV2Builders?: KeyringV2Builder[]; messenger: KeyringControllerMessenger; - state?: { vault?: string }; + state?: { vault?: string; keyringsMetadata?: KeyringMetadata[] }; + encryptor: Encryptor< + EncryptionKey, + SupportedKeyDerivationOptions, + EncryptionResult + >; }; /** - * @type KeyringObject - * - * Keyring object to return in fullUpdate - * @property type - Keyring type - * @property accounts - Associated accounts + * A keyring object representation. */ export type KeyringObject = { + /** + * Accounts associated with the keyring. + */ accounts: string[]; + /** + * Keyring type. + */ type: string; + /** + * Additional data associated with the keyring. + */ + metadata: KeyringMetadata; +}; + +/** + * Additional information related to a keyring. + */ +export type KeyringMetadata = { + /** + * Keyring ID + */ + id: string; + /** + * Keyring name + */ + name: string; +}; + +/** + * A keyring entry, including the keyring instance (+ v2 instance) and its metadata. + */ +export type KeyringEntry = { + /** + * The keyring instance. + */ + keyring: EthKeyring; + + /** + * The keyring V2 instance, if available. + */ + keyringV2?: KeyringV2; + + /** + * The keyring metadata. + */ + metadata: KeyringMetadata; +}; + +/** + * A restricted view of the {@link KeyringController} exposed to the callback + * passed to {@link KeyringController.withController}. + * + * It provides a read-only live view of all keyrings and the ability to stage + * keyring additions and removals atomically within a single transaction. + */ +export type RestrictedController = { + /** + * Read-only live view of all keyrings in the current transaction (original + * keyrings plus any added, minus any removed so far in this callback). + */ + readonly keyrings: readonly KeyringEntry[]; + /** + * Create a new keyring of the given type and stage it for commit. The new + * entry is immediately visible in {@link RestrictedController.keyrings}. + * + * @param type - The type of keyring to create. + * @param opts - Optional data to pass to the keyring builder. + * @returns The newly created `{ keyring, metadata }` entry. + */ + addNewKeyring(type: string, opts?: unknown): Promise; + /** + * Stage the keyring with the given id for removal. The keyring is + * immediately removed from {@link RestrictedController.keyrings}. + * + * @param id - The id of the keyring to remove. + */ + removeKeyring(id: string): Promise; }; /** * A strategy for importing an account */ export enum AccountImportStrategy { + // Changing this would be a breaking change, and not worth the effort at this + // time, so we disable the linting rule for this block. + /* eslint-disable @typescript-eslint/naming-convention */ privateKey = 'privateKey', json = 'json', + /* eslint-enable @typescript-eslint/naming-convention */ } /** @@ -204,9 +326,316 @@ export enum SignTypedDataVersion { V4 = 'V4', } -const defaultState: KeyringControllerState = { - isUnlocked: false, - keyrings: [], +/** + * A serialized keyring object. + */ +export type SerializedKeyring = { + type: string; + data: Json; + metadata?: KeyringMetadata; +}; + +/** + * Cached encryption key used to encrypt/decrypt the vault. + */ +type CachedEncryptionKey = { + /** + * The serialized encryption key. + */ + serialized: string; + /** + * The salt used to derive the encryption key. + */ + salt: string; +}; + +/** + * State/data that can be updated during a `withKeyring` operation. + */ +type SessionState = { + keyrings: SerializedKeyring[]; + encryptionKey?: CachedEncryptionKey; +}; + +export type EncryptionResultConstraint = { + salt?: string; + keyMetadata?: SupportedKeyMetadata; +}; + +export type DefaultEncryptionResult = { + data: string; + iv: string; + salt?: string; + keyMetadata?: SupportedKeyMetadata; +}; + +/** + * An encryptor interface that supports encrypting and decrypting + * serializable data with a password, and exporting and importing keys. + */ +export type Encryptor< + EncryptionKey = encryptorUtils.EncryptionKey | CryptoKey, + SupportedKeyDerivationParams = encryptorUtils.KeyDerivationOptions, + EncryptionResult extends + EncryptionResultConstraint = + DefaultEncryptionResult, +> = { + /** + * Encrypts the given object with the given password. + * + * @param password - The password to encrypt with. + * @param object - The object to encrypt. + * @returns The encrypted string. + */ + encrypt: (password: string, object: Json) => Promise; + /** + * Decrypts the given encrypted string with the given password. + * + * @param password - The password to decrypt with. + * @param encryptedString - The encrypted string to decrypt. + * @returns The decrypted object. + */ + decrypt: (password: string, encryptedString: string) => Promise; + /** + * Optional vault migration helper. Checks if the provided vault is up to date + * with the desired encryption algorithm. + * + * @param vault - The encrypted string to check. + * @param targetDerivationParams - The desired target derivation params. + * @returns The updated encrypted string. + */ + isVaultUpdated?: ( + vault: string, + targetDerivationParams?: encryptorUtils.KeyDerivationOptions, + ) => boolean; + /** + * Encrypts the given object with the given encryption key. + * + * @param key - The encryption key to encrypt with. + * @param object - The object to encrypt. + * @returns The encryption result. + */ + encryptWithKey: ( + key: EncryptionKey, + object: Json, + ) => Promise; + /** + * Encrypts the given object with the given password, and returns the + * encryption result and the serialized key string. + * + * @param password - The password to encrypt with. + * @param object - The object to encrypt. + * @param salt - The optional salt to use for encryption. + * @returns The encrypted string and the serialized key string. + */ + encryptWithDetail: ( + password: string, + object: Json, + salt?: string, + ) => Promise; + /** + * Decrypts the given encrypted string with the given encryption key. + * + * @param key - The encryption key to decrypt with. + * @param encryptedObject - The encrypted string to decrypt. + * @returns The decrypted object. + */ + decryptWithKey: ( + key: EncryptionKey, + encryptedObject: EncryptionResult, + ) => Promise; + /** + * Decrypts the given encrypted string with the given password, and returns + * the decrypted object and the salt and serialized key string used for + * encryption. + * + * @param password - The password to decrypt with. + * @param encryptedString - The encrypted string to decrypt. + * @returns The decrypted object and the salt and serialized key string used for + * encryption. + */ + decryptWithDetail: ( + password: string, + encryptedString: string, + ) => Promise; + /** + * Generates an encryption key from a serialized key. + * + * @param key - The serialized key string. + * @returns The encryption key. + */ + importKey: (key: string) => Promise; + /** + * Exports the encryption key as a string. + * + * @param key - The encryption key to export. + * @returns The serialized key string. + */ + exportKey: (key: EncryptionKey) => Promise; + /** + * Derives an encryption key from a password. + * + * @param password - The password to derive the key from. + * @param salt - The salt to use for key derivation. + * @param exportable - Whether the key should be exportable or not. + * @param options - Optional key derivation options. + * @returns The derived encryption key. + */ + keyFromPassword: ( + password: string, + salt: string, + exportable?: boolean, + keyDerivationOptions?: SupportedKeyDerivationParams, + ) => Promise; + /** + * Generates a random salt for key derivation. + */ + generateSalt: typeof encryptorUtils.generateSalt; +}; + +/** + * Keyring selector used for `withKeyring`. + */ +export type KeyringSelector = + | { + type: string; + index?: number; + } + | { + address: Hex; + } + | { + id: string; + } + | { + /** + * A predicate function used to select a keyring. The first keyring for + * which this function returns `true` will be selected. + * + * NOTE: The caller must not mutate the keyring instance passed to this + * function. Mutations bypass the controller's state management + * safeguards and will lead to inconsistent state. The instance is not + * frozen for performance reasons, but treating it as read-only is a + * firm requirement — any mutation is a bug in the caller. + */ + filter: + | ((keyring: EthKeyring, metadata: KeyringMetadata) => boolean) + // Variant of the `filter` function that also acts as a type + // guard, allowing callers to narrow the keyring type within the + // callback. + | (( + keyring: EthKeyring, + metadata: KeyringMetadata, + ) => keyring is SelectedKeyring); + }; + +/** + * Keyring selector used for `withKeyringV2` (see {@link KeyringController#withKeyringV2} and {@link KeyringSelector}). + */ +export type KeyringSelectorV2 = + | { + type: `${KeyringType}`; + index?: number; + } + | { + address: KeyringAccount['address']; + } + | { + id: KeyringMetadata['id']; + } + | { + /** Similar to {@link KeyringSelector.filter} but for `KeyringV2` instances. */ + filter: + | ((keyring: KeyringV2, metadata: KeyringMetadata) => boolean) + | (( + keyring: KeyringV2, + metadata: KeyringMetadata, + ) => keyring is SelectedKeyring); + }; + +/** + * Keyring builder. + */ +export type KeyringBuilder = { + (): Keyring; + type: string; +}; + +/** + * A builder that wraps a legacy `Keyring` into a `KeyringV2` adapter. + * + * The controller calls the builder once when the V1 keyring is created + * or restored; the resulting wrapper is cached for the keyring's lifetime. + */ +export type KeyringV2Builder = { + (keyring: Keyring, metadata: KeyringMetadata): KeyringV2; + type: string; +}; + +/** + * A function executed within a mutually exclusive lock, with + * a mutex releaser in its option bag. + * + * @param releaseLock - A function to release the lock. + */ +type MutuallyExclusiveCallback = ({ + releaseLock, +}: { + releaseLock: MutexInterface.Releaser; +}) => Promise; + +/** + * Get builder function for `Keyring` + * + * Returns a builder function for `Keyring` with a `type` property. + * + * @param KeyringConstructor - The Keyring class for the builder. + * @returns A builder function for the given Keyring. + */ +export function keyringBuilderFactory( + KeyringConstructor: KeyringClass, +): KeyringBuilder { + const builder: KeyringBuilder = (): Keyring => new KeyringConstructor(); + + builder.type = KeyringConstructor.type; + + return builder; +} + +const defaultKeyringBuilders = [ + // todo: keyring types are mismatched, this should be fixed in they keyrings themselves + // @ts-expect-error keyring types are mismatched + keyringBuilderFactory(SimpleKeyring), + keyringBuilderFactory(HdKeyring), +]; + +const hdKeyringV2Builder: KeyringV2Builder = Object.assign( + (keyring: Keyring, metadata: KeyringMetadata): KeyringV2 => + new HdKeyringV2({ + legacyKeyring: keyring as HdKeyring, + entropySource: metadata.id, + }), + { type: KeyringTypes.hd as string }, +); + +const simpleKeyringV2Builder: KeyringV2Builder = Object.assign( + (keyring: Keyring): KeyringV2 => + new SimpleKeyringV2({ + legacyKeyring: keyring as SimpleKeyring, + }), + { type: KeyringTypes.simple as string }, +); + +const defaultKeyringV2Builders: KeyringV2Builder[] = [ + simpleKeyringV2Builder, + hdKeyringV2Builder, +]; + +export const getDefaultKeyringState = (): KeyringControllerState => { + return { + isUnlocked: false, + keyrings: [], + }; }; /** @@ -217,17 +646,126 @@ const defaultState: KeyringControllerState = { * @throws When the keyring does not have a mnemonic */ function assertHasUint8ArrayMnemonic( - keyring: Keyring, -): asserts keyring is Keyring & { mnemonic: Uint8Array } { + keyring: EthKeyring, +): asserts keyring is EthKeyring & { mnemonic: Uint8Array } { if ( !( hasProperty(keyring, 'mnemonic') && keyring.mnemonic instanceof Uint8Array ) ) { - throw new Error("Can't get mnemonic bytes from keyring"); + throw new KeyringControllerError("Can't get mnemonic bytes from keyring"); + } +} + +/** + * 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 KeyringControllerError( + KeyringControllerErrorMessage.WrongPasswordType, + ); + } + + if (!password?.length) { + throw new KeyringControllerError( + KeyringControllerErrorMessage.InvalidEmptyPassword, + ); + } +} + +/** + * Assert that the provided encryption key is a valid non-empty string. + * + * @param encryptionKey - The encryption key to check. + * @throws If the encryption key is not a valid string. + */ +function assertIsEncryptionKeySet( + encryptionKey: string | undefined, +): asserts encryptionKey is string { + if (!encryptionKey) { + throw new KeyringControllerError( + KeyringControllerErrorMessage.EncryptionKeyNotSet, + ); } } +/** + * Checks if the provided value is a serialized keyrings array. + * + * @param array - The value to check. + * @returns True if the value is a serialized keyrings array. + */ +function isSerializedKeyringsArray( + array: unknown, +): array is SerializedKeyring[] { + return ( + typeof array === 'object' && + Array.isArray(array) && + array.every((value) => value.type && isValidJson(value.data)) + ); +} + +/** + * Display For Keyring + * + * Is used for adding the current keyrings to the state object. + * + * @param keyringWithMetadata - The keyring and its metadata. + * @param keyringWithMetadata.keyring - The keyring to display. + * @param keyringWithMetadata.metadata - The metadata of the keyring. + * @returns A keyring display object, with type and accounts properties. + */ +async function displayForKeyring({ + keyring, + metadata, +}: KeyringEntry): Promise { + const accounts = await keyring.getAccounts(); + + return { + type: keyring.type, + // Cast to `string[]` here is safe here because `accounts` has no nullish + // values, and `normalize` returns `string` unless given a nullish value + accounts: accounts.map(normalize) as string[], + metadata, + }; +} + +/** + * Check if address is an ethereum address + * + * @param address - An address. + * @returns Returns true if the address is an ethereum one, false otherwise. + */ +function isEthAddress(address: string): boolean { + // We first check if it's a matching `Hex` string, so that is narrows down + // `address` as an `Hex` type, allowing us to use `isValidHexAddress` + return ( + // NOTE: This function only checks for lowercased strings + isStrictHexString(address.toLowerCase()) && + // This checks for lowercased addresses and checksum addresses too + isValidHexAddress(address as Hex) + ); +} + +/** + * Normalize ethereum or non-EVM address. + * + * @param address - Ethereum or non-EVM address. + * @returns The normalized address. + */ +function normalize(address: string): string | undefined { + // Since the `KeyringController` is only dealing with address, we have + // no other way to get the associated account type with this address. So we + // are down to check the actual address format for now + // TODO: Find a better way to not have those runtime checks based on the + // address value! + return isEthAddress(address) ? ethNormalize(address) : address; +} + /** * Controller responsible for establishing and managing user identity. * @@ -237,83 +775,109 @@ function assertHasUint8ArrayMnemonic( * with the internal keyring controller and handling certain complex operations that involve the * keyrings. */ -export class KeyringController extends BaseControllerV2< +export class KeyringController< + EncryptionKey = encryptorUtils.EncryptionKey | CryptoKey, + SupportedKeyDerivationOptions = encryptorUtils.KeyDerivationOptions, + EncryptionResult extends + EncryptionResultConstraint = + DefaultEncryptionResult, +> extends BaseController< typeof name, KeyringControllerState, KeyringControllerMessenger > { - private readonly mutex = new Mutex(); + readonly #controllerOperationMutex = new Mutex(); + + readonly #vaultOperationMutex = new Mutex(); - private readonly syncIdentities: PreferencesController['syncIdentities']; + readonly #keyringBuilders: { (): EthKeyring; type: string }[]; - private readonly updateIdentities: PreferencesController['updateIdentities']; + readonly #keyringV2Builders: KeyringV2Builder[]; - private readonly setSelectedAddress: PreferencesController['setSelectedAddress']; + readonly #encryptor: Encryptor< + EncryptionKey, + SupportedKeyDerivationOptions, + EncryptionResult + >; - private readonly setAccountLabel?: PreferencesController['setAccountLabel']; + #keyrings: KeyringEntry[]; - #keyring: EthKeyringController; + #unsupportedKeyrings: SerializedKeyring[]; - #qrKeyringStateListener?: ( - state: ReturnType, - ) => void; + #encryptionKey?: CachedEncryptionKey; /** * Creates a KeyringController instance. * - * @param opts - Initial options used to configure this controller - * @param opts.syncIdentities - Sync identities with the given list of addresses. - * @param opts.updateIdentities - Generate an identity for each address given that doesn't already have an identity. - * @param opts.setSelectedAddress - Set the selected address. - * @param opts.setAccountLabel - Set a new name for account. - * @param opts.encryptor - An optional object for defining encryption schemes. - * @param opts.keyringBuilders - Set a new name for account. - * @param opts.cacheEncryptionKey - Whether to cache or not encryption key. - * @param opts.messenger - A restricted controller messenger. - * @param opts.state - Initial state to set on this controller. - */ - constructor({ - syncIdentities, - updateIdentities, - setSelectedAddress, - setAccountLabel, - encryptor, - keyringBuilders, - cacheEncryptionKey = false, - messenger, - state, - }: KeyringControllerOptions) { + * @param options - Initial options used to configure this controller + * @param options.encryptor - An optional object for defining encryption schemes. + * @param options.keyringBuilders - Set a new name for account. + * @param options.cacheEncryptionKey - Whether to cache or not encryption key. + * @param options.messenger - A restricted messenger. + * @param options.state - Initial state to set on this controller. + */ + constructor( + options: KeyringControllerOptions< + EncryptionKey, + SupportedKeyDerivationOptions, + EncryptionResult + >, + ) { + const { encryptor, keyringBuilders, keyringV2Builders, messenger, state } = + options; + super({ name, metadata: { - vault: { persist: true, anonymous: false }, - isUnlocked: { persist: false, anonymous: true }, - keyrings: { persist: false, anonymous: false }, - encryptionKey: { persist: false, anonymous: false }, - encryptionSalt: { persist: false, anonymous: false }, + vault: { + includeInStateLogs: false, + persist: true, + includeInDebugSnapshot: false, + usedInUi: false, + }, + isUnlocked: { + includeInStateLogs: true, + persist: false, + includeInDebugSnapshot: true, + usedInUi: true, + }, + keyrings: { + includeInStateLogs: true, + persist: false, + includeInDebugSnapshot: false, + usedInUi: true, + }, + encryptionKey: { + includeInStateLogs: false, + persist: false, + includeInDebugSnapshot: false, + usedInUi: false, + }, + encryptionSalt: { + includeInStateLogs: false, + persist: false, + includeInDebugSnapshot: false, + usedInUi: false, + }, }, messenger, state: { - ...defaultState, + ...getDefaultKeyringState(), ...state, }, }); - this.#keyring = new EthKeyringController({ - initState: state, - encryptor, - keyringBuilders, - cacheEncryptionKey, - }); - this.#keyring.memStore.subscribe(this.#fullUpdate.bind(this)); - this.#keyring.store.subscribe(this.#fullUpdate.bind(this)); - this.#keyring.on('lock', this.#handleLock.bind(this)); - this.#keyring.on('unlock', this.#handleUnlock.bind(this)); + this.#keyringBuilders = keyringBuilders + ? keyringBuilders.concat(defaultKeyringBuilders) + : defaultKeyringBuilders; + + this.#keyringV2Builders = keyringV2Builders + ? keyringV2Builders.concat(defaultKeyringV2Builders) + : defaultKeyringV2Builders; - this.syncIdentities = syncIdentities; - this.updateIdentities = updateIdentities; - this.setSelectedAddress = setSelectedAddress; - this.setAccountLabel = setAccountLabel; + this.#encryptor = encryptor; + this.#keyrings = []; + this.#unsupportedKeyrings = []; this.#registerMessageHandlers(); } @@ -323,47 +887,41 @@ export class KeyringController extends BaseControllerV2< * * @param accountCount - Number of accounts before adding a new one, used to * make the method idempotent. - * @returns Promise resolving to keyring current state and added account - * address. + * @returns Promise resolving to the added account address. */ - async addNewAccount(accountCount?: number): Promise<{ - keyringState: KeyringControllerMemState; - addedAccountAddress: string; - }> { - const primaryKeyring = this.#keyring.getKeyringsByType('HD Key Tree')[0]; - /* istanbul ignore if */ - if (!primaryKeyring) { - throw new Error('No HD keyring found'); - } - const oldAccounts = await this.#keyring.getAccounts(); - - if (accountCount && oldAccounts.length !== accountCount) { - if (accountCount > oldAccounts.length) { - throw new Error('Account out of sequence'); + async addNewAccount(accountCount?: number): Promise { + this.#assertIsUnlocked(); + + return this.#persistOrRollback(async () => { + const primaryKeyring = this.getKeyringsByType('HD Key Tree')[0] as + | EthKeyring + | undefined; + if (!primaryKeyring) { + throw new KeyringControllerError('No HD keyring found'); } - // we return the account already existing at index `accountCount` - const primaryKeyringAccounts = await primaryKeyring.getAccounts(); - return { - keyringState: this.#getMemState(), - addedAccountAddress: primaryKeyringAccounts[accountCount], - }; - } + const oldAccounts = await primaryKeyring.getAccounts(); - await this.#keyring.addNewAccount(primaryKeyring); - const newAccounts = await this.#keyring.getAccounts(); + if (accountCount && oldAccounts.length !== accountCount) { + if (accountCount > oldAccounts.length) { + throw new KeyringControllerError('Account out of sequence'); + } + // we return the account already existing at index `accountCount` + const existingAccount = oldAccounts[accountCount]; - await this.verifySeedPhrase(); + if (!existingAccount) { + throw new KeyringControllerError( + `Can't find account at index ${accountCount}`, + ); + } - this.updateIdentities(newAccounts); - const addedAccountAddress = newAccounts.find( - (selectedAddress: string) => !oldAccounts.includes(selectedAddress), - ); + return existingAccount; + } - assertIsStrictHexString(addedAccountAddress); - return { - keyringState: this.#getMemState(), - addedAccountAddress, - }; + const [addedAccountAddress] = await primaryKeyring.addAccounts(1); + await this.#verifySeedPhrase(); + + return addedAccountAddress; + }); } /** @@ -371,98 +929,87 @@ export class KeyringController extends BaseControllerV2< * * @param keyring - Keyring to add the account to. * @param accountCount - Number of accounts before adding a new one, used to make the method idempotent. - * @returns Promise resolving to keyring current state and added account + * @returns Promise resolving to the added account address */ async addNewAccountForKeyring( - keyring: Keyring, + keyring: EthKeyring, accountCount?: number, ): Promise { - const oldAccounts = await this.getAccounts(); - - if (accountCount && oldAccounts.length !== accountCount) { - if (accountCount > oldAccounts.length) { - throw new Error('Account out of sequence'); - } + // READ THIS CAREFULLY: + // We still uses `Hex` here, since we are not using this method when creating + // and account using a "Snap Keyring". This function assume the `keyring` is + // ethereum compatible, but "Snap Keyring" might not be. + this.#assertIsUnlocked(); + + return this.#persistOrRollback(async () => { + const oldAccounts = await this.#getAccountsFromKeyrings(); + + if (accountCount && oldAccounts.length !== accountCount) { + if (accountCount > oldAccounts.length) { + throw new KeyringControllerError('Account out of sequence'); + } - const existingAccount = oldAccounts[accountCount]; - assertIsStrictHexString(existingAccount); + const existingAccount = oldAccounts[accountCount]; + assertIsStrictHexString(existingAccount); - return existingAccount; - } + return existingAccount; + } - await this.#keyring.addNewAccount(keyring); - const addedAccountAddress = (await this.getAccounts()).find( - (selectedAddress) => !oldAccounts.includes(selectedAddress), - ); - assertIsStrictHexString(addedAccountAddress); + await keyring.addAccounts(1); - this.updateIdentities(await this.#keyring.getAccounts()); + const addedAccountAddress = (await this.#getAccountsFromKeyrings()).find( + (selectedAddress) => !oldAccounts.includes(selectedAddress), + ); + assertIsStrictHexString(addedAccountAddress); - return addedAccountAddress; + return addedAccountAddress; + }); } /** - * Adds a new account to the default (first) HD seed phrase keyring without updating identities in preferences. + * Effectively the same as creating a new keychain then populating it + * using the given seed phrase. * - * @returns Promise resolving to current state when the account is added. - */ - async addNewAccountWithoutUpdate(): Promise { - const primaryKeyring = this.#keyring.getKeyringsByType('HD Key Tree')[0]; - /* istanbul ignore if */ - if (!primaryKeyring) { - throw new Error('No HD keyring found'); - } - await this.#keyring.addNewAccount(primaryKeyring); - await this.verifySeedPhrase(); - return this.#getMemState(); - } - - /** - * Effectively the same as creating a new keychain then populating it - * using the given seed phrase. - * - * @param password - Password to unlock keychain. - * @param seed - A BIP39-compliant seed phrase as Uint8Array, - * either as a string or an array of UTF-8 bytes that represent the string. - * @returns Promise resolving to the restored keychain object. + * @param password - Password to unlock keychain. + * @param seed - A BIP39-compliant seed phrase as Uint8Array, + * either as a string or an array of UTF-8 bytes that represent the string. + * @returns Promise resolving when the operation ends successfully. */ async createNewVaultAndRestore( password: string, seed: Uint8Array, - ): Promise { - const releaseLock = await this.mutex.acquire(); - if (!password || !password.length) { - throw new Error('Invalid password'); - } - - try { - this.updateIdentities([]); - await this.#keyring.createNewVaultAndRestore(password, seed); - this.updateIdentities(await this.#keyring.getAccounts()); - return this.#getMemState(); - } finally { - releaseLock(); - } + ): Promise { + return this.#persistOrRollback(async () => { + assertIsValidPassword(password); + + await this.#createNewVaultWithKeyring(password, { + type: KeyringTypes.hd, + opts: { + mnemonic: seed, + numberOfAccounts: 1, + }, + }); + }); } /** - * Create a new primary keychain and wipe any previous keychains. + * Create a new vault and primary keyring. + * + * This only works if keyrings are empty. If there is a pre-existing unlocked vault, calling this will have no effect. + * If there is a pre-existing locked vault, it will be replaced. * * @param password - Password to unlock the new vault. - * @returns Newly-created keychain object. + * @returns Promise resolving when the operation ends successfully. */ - async createNewVaultAndKeychain(password: string) { - const releaseLock = await this.mutex.acquire(); - try { - const accounts = await this.getAccounts(); + async createNewVaultAndKeychain(password: string): Promise { + return this.#persistOrRollback(async () => { + const accounts = await this.#getAccountsFromKeyrings(); if (!accounts.length) { - await this.#keyring.createNewVaultAndKeychain(password); - this.updateIdentities(await this.getAccounts()); + await this.#createNewVaultWithKeyring(password, { + type: KeyringTypes.hd, + }); } - return this.#getMemState(); - } finally { - releaseLock(); - } + }); } /** @@ -471,17 +1018,17 @@ export class KeyringController extends BaseControllerV2< * @param type - Keyring type name. * @param opts - Keyring options. * @throws If a builder for the given `type` does not exist. - * @returns Promise resolving to the added keyring. + * @returns Promise resolving to the new keyring metadata. */ async addNewKeyring( type: KeyringTypes | string, opts?: unknown, - ): Promise { - if (type === KeyringTypes.qr) { - return this.getOrAddQRKeyring(); - } + ): Promise { + this.#assertIsUnlocked(); - return this.#keyring.addNewKeyring(type, opts); + return this.#getKeyringMetadata( + await this.#persistOrRollback(async () => this.#newKeyring(type, opts)), + ); } /** @@ -490,8 +1037,63 @@ export class KeyringController extends BaseControllerV2< * * @param password - Password of the keyring. */ - async verifyPassword(password: string) { - await this.#keyring.verifyPassword(password); + async verifyPassword(password: string): Promise { + if (!this.state.vault) { + throw new KeyringControllerError( + KeyringControllerErrorMessage.VaultError, + ); + } + await this.#encryptor.decrypt(password, this.state.vault); + } + + /** + * Method to verify a given encryption key validity. Throws an error if the + * encryption key is invalid, i.e. it cannot decrypt the vault. + * + * @param encryptionKey - Serialized vault encryption key. + * @param encryptionSalt - Optional salt to verify against the vault. When + * omitted, the salt serialized alongside the vault is used. + */ + async #verifyEncryptionKey( + encryptionKey: string, + encryptionSalt?: string, + ): Promise { + if (!this.state.vault) { + throw new KeyringControllerError( + KeyringControllerErrorMessage.VaultError, + ); + } + + const parsedEncryptedVault = JSON.parse(this.state.vault); + const salt = encryptionSalt ?? parsedEncryptedVault.salt; + + if (parsedEncryptedVault.salt !== salt) { + throw new KeyringControllerError( + KeyringControllerErrorMessage.ExpiredCredentials, + ); + } + + const key = await this.#encryptor.importKey(encryptionKey); + await this.#encryptor.decryptWithKey(key, parsedEncryptedVault); + } + + /** + * Verifies export credentials by checking either the wallet password or the + * vault encryption key. + * + * @param credentials - Object holding either the `password` or the vault + * `encryptionKey`. + */ + async #verifyCredentials(credentials: Credentials): Promise { + // eslint-disable-next-line no-restricted-syntax + if ('password' in credentials) { + await this.verifyPassword(credentials.password); + } else { + await this.#verifyEncryptionKey( + credentials.encryptionKey, + credentials.encryptionSalt, + ); + } } /** @@ -506,34 +1108,65 @@ export class KeyringController extends BaseControllerV2< /** * Gets the seed phrase of the HD keyring. * - * @param password - Password of the keyring. + * @param credentials - Object holding either the `password` or the vault + * `encryptionKey`. + * @param keyringId - The id of the keyring. * @returns Promise resolving to the seed phrase. */ - async exportSeedPhrase(password: string): Promise { - await this.verifyPassword(password); - assertHasUint8ArrayMnemonic(this.#keyring.keyrings[0]); - return this.#keyring.keyrings[0].mnemonic; + async exportSeedPhrase( + credentials: Credentials, + keyringId?: string, + ): Promise { + this.#assertIsUnlocked(); + + await this.#verifyCredentials(credentials); + + const selectedKeyring = this.#getKeyringByIdOrDefault(keyringId); + if (!selectedKeyring) { + throw new KeyringControllerError('Keyring not found'); + } + assertHasUint8ArrayMnemonic(selectedKeyring); + + return selectedKeyring.mnemonic; } /** * Gets the private key from the keyring controlling an address. * - * @param password - Password of the keyring. + * @param credentials - Object holding either the `password` or the vault + * `encryptionKey`. * @param address - Address to export. * @returns Promise resolving to the private key for an address. */ - async exportAccount(password: string, address: string): Promise { - await this.verifyPassword(password); - return this.#keyring.exportAccount(address); + async exportAccount( + credentials: Credentials, + address: string, + ): Promise { + this.#assertIsUnlocked(); + + await this.#verifyCredentials(credentials); + + const keyring = (await this.getKeyringForAccount(address)) as EthKeyring; + if (!keyring.exportAccount) { + throw new KeyringControllerError( + KeyringControllerErrorMessage.UnsupportedExportAccount, + ); + } + + return await keyring.exportAccount(normalize(address) as Hex); } /** - * Returns the public addresses of all accounts for the current keyring. + * Returns the public addresses of all accounts from every keyring. * * @returns A promise resolving to an array of addresses. */ - getAccounts(): Promise { - return this.#keyring.getAccounts(); + async getAccounts(): Promise { + this.#assertIsUnlocked(); + return this.state.keyrings.reduce( + (accounts, keyring) => accounts.concat(keyring.accounts), + [], + ); } /** @@ -548,7 +1181,16 @@ export class KeyringController extends BaseControllerV2< account: string, opts?: Record, ): Promise { - return this.#keyring.getEncryptionPublicKey(account, opts); + this.#assertIsUnlocked(); + const address = ethNormalize(account) as Hex; + const keyring = (await this.getKeyringForAccount(account)) as EthKeyring; + if (!keyring.getEncryptionPublicKey) { + throw new KeyringControllerError( + KeyringControllerErrorMessage.UnsupportedGetEncryptionPublicKey, + ); + } + + return await keyring.getEncryptionPublicKey(address, opts); } /** @@ -563,7 +1205,16 @@ export class KeyringController extends BaseControllerV2< from: string; data: Eip1024EncryptedData; }): Promise { - return this.#keyring.decryptMessage(messageParams); + this.#assertIsUnlocked(); + const address = ethNormalize(messageParams.from) as Hex; + const keyring = (await this.getKeyringForAccount(address)) as EthKeyring; + if (!keyring.decryptMessage) { + throw new KeyringControllerError( + KeyringControllerErrorMessage.UnsupportedDecryptMessage, + ); + } + + return keyring.decryptMessage(address, messageParams.data); } /** @@ -572,12 +1223,54 @@ export class KeyringController extends BaseControllerV2< * * @deprecated Use of this method is discouraged as actions executed directly on * keyrings are not being reflected in the KeyringController state and not - * persisted in the vault. + * persisted in the vault. Use `withKeyring` instead. * @param account - An account address. * @returns Promise resolving to keyring of the `account` if one exists. */ async getKeyringForAccount(account: string): Promise { - return this.#keyring.getKeyringForAccount(account); + this.#assertIsUnlocked(); + const keyring = await this.#getKeyringForAccount(account); + if (keyring) { + return keyring; + } + + if (this.#keyrings.length === 0) { + throw new KeyringControllerError(KeyringControllerErrorMessage.NoKeyring); + } + + throw new KeyringControllerError( + KeyringControllerErrorMessage.KeyringNotFound, + ); + } + + async #getKeyringForAccount( + account: string, + ): Promise { + this.#assertIsUnlocked(); + const entry = await this.#getKeyringEntryForAccount(account); + return entry?.keyring; + } + + async #getKeyringEntryForAccount( + account: string, + ): Promise { + this.#assertIsUnlocked(); + const keyringIndex = await this.#findKeyringIndexForAccount(account); + if (keyringIndex > -1) { + return this.#keyrings[keyringIndex]; + } + return undefined; + } + + async #findKeyringIndexForAccount(account: string): Promise { + this.#assertIsUnlocked(); + const address = account.toLowerCase(); + const accountsPerKeyring = await Promise.all( + this.#keyrings.map(({ keyring }) => keyring.getAccounts()), + ); + return accountsPerKeyring.findIndex((accounts) => + accounts.map((a) => a.toLowerCase()).includes(address), + ); } /** @@ -585,22 +1278,49 @@ export class KeyringController extends BaseControllerV2< * * @deprecated Use of this method is discouraged as actions executed directly on * keyrings are not being reflected in the KeyringController state and not - * persisted in the vault. + * persisted in the vault. Use `withKeyring` instead. * @param type - Keyring type name. * @returns An array of keyrings of the given type. */ getKeyringsByType(type: KeyringTypes | string): unknown[] { - return this.#keyring.getKeyringsByType(type); + this.#assertIsUnlocked(); + return this.#getKeyringEntriesByType({ v2: false, type }).map( + ({ keyring }) => keyring, + ); + } + + #getKeyringEntriesByType({ + v2, + type, + }: + | { + v2: false; + type: KeyringTypes | string; + } + | { + v2: true; + type: `${KeyringType}`; + }): KeyringEntry[] { + this.#assertIsUnlocked(); + return this.#keyrings.filter(({ keyring, keyringV2 }) => + v2 ? keyringV2?.type === type : keyring.type === type, + ); } /** * Persist all serialized keyrings in the vault. * + * @deprecated This method is being phased out in favor of `withKeyring`. * @returns Promise resolving with `true` value when the * operation completes. */ async persistAllKeyrings(): Promise { - return this.#keyring.persistAllKeyrings(); + return this.#withRollback(async () => { + this.#assertIsUnlocked(); + + await this.#updateVault(); + return true; + }); } /** @@ -609,66 +1329,69 @@ export class KeyringController extends BaseControllerV2< * @param strategy - Import strategy name. * @param args - Array of arguments to pass to the underlying stategy. * @throws Will throw when passed an unrecognized strategy. - * @returns Promise resolving to keyring current state and imported account - * address. + * @returns Promise resolving to the imported account address. */ async importAccountWithStrategy( strategy: AccountImportStrategy, + // TODO: Replace `any` with type + // eslint-disable-next-line @typescript-eslint/no-explicit-any args: any[], - ): Promise<{ - keyringState: KeyringControllerMemState; - importedAccountAddress: string; - }> { - let privateKey; - switch (strategy) { - case 'privateKey': - const [importedKey] = args; - if (!importedKey) { - throw new Error('Cannot import an empty key.'); - } - const prefixed = addHexPrefix(importedKey); - - let bufferedPrivateKey; - try { - bufferedPrivateKey = toBuffer(prefixed); - } catch { - throw new Error('Cannot import invalid private key.'); - } - - /* istanbul ignore if */ - if ( - !isValidPrivate(bufferedPrivateKey) || - // ensures that the key is 64 bytes long - getBinarySize(prefixed) !== 64 + '0x'.length - ) { - throw new Error('Cannot import invalid private key.'); + ): Promise { + this.#assertIsUnlocked(); + return this.#persistOrRollback(async () => { + let privateKey; + switch (strategy) { + case AccountImportStrategy.privateKey: { + const [importedKey] = args; + if (!importedKey) { + throw new KeyringControllerError('Cannot import an empty key.'); + } + const prefixed = add0x(importedKey); + + let bufferedPrivateKey; + try { + bufferedPrivateKey = hexToBytes(prefixed); + } catch { + throw new KeyringControllerError( + 'Cannot import invalid private key.', + ); + } + + if ( + !isValidPrivate(bufferedPrivateKey) || + // ensures that the key is 64 bytes long + getBinarySize(prefixed) !== 64 + '0x'.length + ) { + throw new KeyringControllerError( + 'Cannot import invalid private key.', + ); + } + + privateKey = remove0x(prefixed); + break; } - - privateKey = stripHexPrefix(prefixed); - break; - case 'json': - let wallet; - const [input, password] = args; - try { - wallet = importers.fromEtherWallet(input, password); - } catch (e) { - wallet = wallet || (await Wallet.fromV3(input, password, true)); + case AccountImportStrategy.json: { + let wallet; + const [input, password] = args; + try { + wallet = importers.fromEtherWallet(input, password); + } catch { + wallet = wallet ?? (await Wallet.fromV3(input, password, true)); + } + privateKey = bytesToHex(new Uint8Array(wallet.getPrivateKey())); + break; } - privateKey = bufferToHex(wallet.getPrivateKey()); - break; - default: - throw new Error(`Unexpected import strategy: '${strategy}'`); - } - const newKeyring = await this.#keyring.addNewKeyring(KeyringTypes.simple, [ - privateKey, - ]); - const accounts = await newKeyring.getAccounts(); - const allAccounts = await this.#keyring.getAccounts(); - this.updateIdentities(allAccounts); - return { - keyringState: this.#getMemState(), - importedAccountAddress: accounts[0], - }; + default: + throw new KeyringControllerError( + `Unexpected import strategy: '${String(strategy)}'`, + ); + } + const newKeyring = await this.#newKeyring(KeyringTypes.simple, [ + privateKey, + ]); + const accounts = await newKeyring.getAccounts(); + return accounts[0]; + }); } /** @@ -676,23 +1399,78 @@ export class KeyringController extends BaseControllerV2< * * @param address - Address of the account to remove. * @fires KeyringController:accountRemoved - * @returns Promise resolving current state when this account removal completes. + * @returns Promise resolving when the account is removed. */ - async removeAccount(address: Hex): Promise { - await this.#keyring.removeAccount(address); - this.messagingSystem.publish(`${name}:accountRemoved`, address); - return this.#getMemState(); + async removeAccount(address: string): Promise { + this.#assertIsUnlocked(); + + await this.#persistOrRollback(async () => { + const keyringIndex = await this.#findKeyringIndexForAccount(address); + + if (keyringIndex === -1) { + throw new KeyringControllerError( + KeyringControllerErrorMessage.NoKeyring, + ); + } + + const { keyring, keyringV2 } = this.#keyrings[keyringIndex]; + + const isPrimaryKeyring = keyringIndex === 0; + const shouldRemoveKeyring = (await keyring.getAccounts()).length === 1; + + // Primary keyring should never be removed, so we need to keep at least one account in it + if (isPrimaryKeyring && shouldRemoveKeyring) { + throw new KeyringControllerError( + KeyringControllerErrorMessage.LastAccountInPrimaryKeyring, + ); + } + + // Not all the keyrings support this, so we have to check + if (!keyring.removeAccount) { + throw new KeyringControllerError( + KeyringControllerErrorMessage.UnsupportedRemoveAccount, + ); + } + + // FIXME #1: We do cast to `Hex` to make the type checker happy here, and + // because `Keyring.removeAccount` requires address to be `Hex`. + // Those types would need to be updated for a full non-EVM support. + // + // FIXME #2: The `removeAccount` method of snaps keyring is async. We have + // to update the interface of the other keyrings to be async as well. + // eslint-disable-next-line @typescript-eslint/await-thenable + await keyring.removeAccount(address as Hex); + + if (shouldRemoveKeyring) { + this.#keyrings.splice(keyringIndex, 1); + await this.#destroyKeyring(keyring, keyringV2); + } + }); + + this.messenger.publish(`${name}:accountRemoved`, address); } /** * Deallocates all secrets and locks the wallet. * - * @returns Promise resolving to current state. + * @returns Promise resolving when the operation completes. */ - async setLocked(): Promise { - this.#unsubscribeFromQRKeyringsEvents(); - await this.#keyring.setLocked(); - return this.#getMemState(); + async setLocked(): Promise { + this.#assertIsUnlocked(); + + return this.#withRollback(async () => { + this.#encryptionKey = undefined; + await this.#clearKeyrings(); + + this.update((state) => { + state.isUnlocked = false; + state.keyrings = []; + delete state.encryptionKey; + delete state.encryptionSalt; + }); + + this.messenger.publish(`${name}:lock`); + }); } /** @@ -701,11 +1479,60 @@ export class KeyringController extends BaseControllerV2< * @param messageParams - PersonalMessageParams object to sign. * @returns Promise resolving to a signed message string. */ - signMessage(messageParams: PersonalMessageParams) { + async signMessage(messageParams: PersonalMessageParams): Promise { + this.#assertIsUnlocked(); + if (!messageParams.data) { - throw new Error("Can't sign an empty message"); + throw new KeyringControllerError("Can't sign an empty message"); + } + + const address = ethNormalize(messageParams.from) as Hex; + const keyring = (await this.getKeyringForAccount(address)) as EthKeyring; + if (!keyring.signMessage) { + throw new KeyringControllerError( + KeyringControllerErrorMessage.UnsupportedSignMessage, + ); + } + + return await keyring.signMessage(address, messageParams.data); + } + + /** + * Signs EIP-7702 Authorization message by calling down into a specific keyring. + * + * @param params - EIP7702AuthorizationParams object to sign. + * @returns Promise resolving to an EIP-7702 Authorization signature. + * @throws Will throw UnsupportedSignEIP7702Authorization if the keyring does not support signing EIP-7702 Authorization messages. + */ + async signEip7702Authorization( + params: Eip7702AuthorizationParams, + ): Promise { + const from = ethNormalize(params.from) as Hex; + + const keyring = (await this.getKeyringForAccount(from)) as EthKeyring; + + if (!keyring.signEip7702Authorization) { + throw new KeyringControllerError( + KeyringControllerErrorMessage.UnsupportedSignEip7702Authorization, + ); + } + + const { chainId, nonce } = params; + const contractAddress = ethNormalize(params.contractAddress) as + | Hex + | undefined; + + if (contractAddress === undefined) { + throw new KeyringControllerError( + KeyringControllerErrorMessage.MissingEip7702AuthorizationContractAddress, + ); } - return this.#keyring.signMessage(messageParams); + + return await keyring.signEip7702Authorization(from, [ + chainId, + contractAddress, + nonce, + ]); } /** @@ -714,8 +1541,21 @@ export class KeyringController extends BaseControllerV2< * @param messageParams - PersonalMessageParams object to sign. * @returns Promise resolving to a signed message string. */ - signPersonalMessage(messageParams: PersonalMessageParams) { - return this.#keyring.signPersonalMessage(messageParams); + async signPersonalMessage( + messageParams: PersonalMessageParams, + ): Promise { + this.#assertIsUnlocked(); + const address = ethNormalize(messageParams.from) as Hex; + const keyring = (await this.getKeyringForAccount(address)) as EthKeyring; + if (!keyring.signPersonalMessage) { + throw new KeyringControllerError( + KeyringControllerErrorMessage.UnsupportedSignPersonalMessage, + ); + } + + const normalizedData = normalize(messageParams.data) as Hex; + + return await keyring.signPersonalMessage(address, normalizedData); } /** @@ -730,6 +1570,8 @@ export class KeyringController extends BaseControllerV2< messageParams: TypedMessageParams, version: SignTypedDataVersion, ): Promise { + this.#assertIsUnlocked(); + try { if ( ![ @@ -738,22 +1580,38 @@ export class KeyringController extends BaseControllerV2< SignTypedDataVersion.V4, ].includes(version) ) { - throw new Error(`Unexpected signTypedMessage version: '${version}'`); + throw new KeyringControllerError( + `Unexpected signTypedMessage version: '${version}'`, + ); } - return await this.#keyring.signTypedMessage( - { - from: messageParams.from, - data: - version !== SignTypedDataVersion.V1 && - typeof messageParams.data === 'string' - ? JSON.parse(messageParams.data) - : messageParams.data, - }, + // Cast to `Hex` here is safe here because `messageParams.from` is not nullish. + // `normalize` returns `Hex` unless given a nullish value. + const address = ethNormalize(messageParams.from) as Hex; + const keyring = (await this.getKeyringForAccount(address)) as EthKeyring; + if (!keyring.signTypedData) { + throw new KeyringControllerError( + KeyringControllerErrorMessage.UnsupportedSignTypedMessage, + ); + } + + return await keyring.signTypedData( + address, + version !== SignTypedDataVersion.V1 && + typeof messageParams.data === 'string' + ? JSON.parse(messageParams.data) + : messageParams.data, { version }, ); } catch (error) { - throw new Error(`Keyring Controller signTypedMessage: ${error}`); + const errorMessage = + error instanceof Error + ? `${error.name}: ${error.message}` + : String(error); + throw new KeyringControllerError( + `Keyring Controller signTypedMessage: ${errorMessage}`, + error instanceof Error ? error : undefined, + ); } } @@ -765,374 +1623,1757 @@ export class KeyringController extends BaseControllerV2< * @param opts - An optional options object. * @returns Promise resolving to a signed transaction string. */ - signTransaction( + async signTransaction( transaction: TypedTransaction, from: string, opts?: Record, - ): Promise { - return this.#keyring.signTransaction(transaction, from, opts); + ): Promise { + this.#assertIsUnlocked(); + const address = ethNormalize(from) as Hex; + const keyring = (await this.getKeyringForAccount(address)) as EthKeyring; + if (!keyring.signTransaction) { + throw new KeyringControllerError( + KeyringControllerErrorMessage.UnsupportedSignTransaction, + ); + } + + return await keyring.signTransaction(address, transaction, opts); } /** - * Attempts to decrypt the current vault and load its keyrings, - * using the given encryption key and salt. + * Convert a base transaction to a base UserOperation. * - * @param encryptionKey - Key to unlock the keychain. - * @param encryptionSalt - Salt to unlock the keychain. - * @returns Promise resolving to the current state. + * @param from - Address of the sender. + * @param transactions - Base transactions to include in the UserOperation. + * @param executionContext - The execution context to use for the UserOperation. + * @returns A pseudo-UserOperation that can be used to construct a real. */ - async submitEncryptionKey( - encryptionKey: string, - encryptionSalt: string, - ): Promise { - await this.#keyring.submitEncryptionKey(encryptionKey, encryptionSalt); - - const qrKeyring = this.getQRKeyring(); - if (qrKeyring) { - // if there is a QR keyring, we need to subscribe - // to its events after unlocking the vault - this.#subscribeToQRKeyringEvents(qrKeyring); + async prepareUserOperation( + from: string, + transactions: EthBaseTransaction[], + executionContext: KeyringExecutionContext, + ): Promise { + this.#assertIsUnlocked(); + const address = ethNormalize(from) as Hex; + const keyring = (await this.getKeyringForAccount(address)) as EthKeyring; + + if (!keyring.prepareUserOperation) { + throw new KeyringControllerError( + KeyringControllerErrorMessage.UnsupportedPrepareUserOperation, + ); } - return this.#getMemState(); + return await keyring.prepareUserOperation( + address, + transactions, + executionContext, + ); } /** - * Attempts to decrypt the current vault and load its keyrings, - * using the given password. + * Patches properties of a UserOperation. Currently, only the + * `paymasterAndData` can be patched. * - * @param password - Password to unlock the keychain. - * @returns Promise resolving to the current state. + * @param from - Address of the sender. + * @param userOp - UserOperation to patch. + * @param executionContext - The execution context to use for the UserOperation. + * @returns A patch to apply to the UserOperation. */ - async submitPassword(password: string): Promise { - await this.#keyring.submitPassword(password); - const accounts = await this.#keyring.getAccounts(); - - const qrKeyring = this.getQRKeyring(); - if (qrKeyring) { - // if there is a QR keyring, we need to subscribe - // to its events after unlocking the vault - this.#subscribeToQRKeyringEvents(qrKeyring); + async patchUserOperation( + from: string, + userOp: EthUserOperation, + executionContext: KeyringExecutionContext, + ): Promise { + this.#assertIsUnlocked(); + const address = ethNormalize(from) as Hex; + const keyring = (await this.getKeyringForAccount(address)) as EthKeyring; + + if (!keyring.patchUserOperation) { + throw new KeyringControllerError( + KeyringControllerErrorMessage.UnsupportedPatchUserOperation, + ); } - await this.syncIdentities(accounts); - return this.#getMemState(); + return await keyring.patchUserOperation(address, userOp, executionContext); } /** - * Verifies the that the seed phrase restores the current keychain's accounts. + * Signs an UserOperation. * - * @returns Promise resolving to the seed phrase as Uint8Array. + * @param from - Address of the sender. + * @param userOp - UserOperation to sign. + * @param executionContext - The execution context to use for the UserOperation. + * @returns The signature of the UserOperation. */ - async verifySeedPhrase(): Promise { - const primaryKeyring = this.#keyring.getKeyringsByType(KeyringTypes.hd)[0]; - /* istanbul ignore if */ - if (!primaryKeyring) { - throw new Error('No HD keyring found.'); - } - - assertHasUint8ArrayMnemonic(primaryKeyring); + async signUserOperation( + from: string, + userOp: EthUserOperation, + executionContext: KeyringExecutionContext, + ): Promise { + this.#assertIsUnlocked(); + const address = ethNormalize(from) as Hex; + const keyring = (await this.getKeyringForAccount(address)) as EthKeyring; - const seedWords = primaryKeyring.mnemonic; - const accounts = await primaryKeyring.getAccounts(); - /* istanbul ignore if */ - if (accounts.length === 0) { - throw new Error('Cannot verify an empty keyring.'); + if (!keyring.signUserOperation) { + throw new KeyringControllerError( + KeyringControllerErrorMessage.UnsupportedSignUserOperation, + ); } - // The HD Keyring Builder is a default keyring builder - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - const hdKeyringBuilder = this.#keyring.getKeyringBuilderForType( - KeyringTypes.hd, - )!; + return await keyring.signUserOperation(address, userOp, executionContext); + } - const hdKeyring = hdKeyringBuilder(); - // @ts-expect-error @metamask/eth-hd-keyring correctly handles - // Uint8Array seed phrases in the `deserialize` method. - hdKeyring.deserialize({ - mnemonic: seedWords, - numberOfAccounts: accounts.length, - }); - const testAccounts = await hdKeyring.getAccounts(); - /* istanbul ignore if */ - if (testAccounts.length !== accounts.length) { - throw new Error('Seed phrase imported incorrect number of accounts.'); - } + /** + * Changes the password used to encrypt the vault. + * + * @param password - The new password. + * @returns Promise resolving when the operation completes. + */ + changePassword(password: string): Promise { + this.#assertIsUnlocked(); - testAccounts.forEach((account: string, i: number) => { - /* istanbul ignore if */ - if (account.toLowerCase() !== accounts[i].toLowerCase()) { - throw new Error('Seed phrase imported different accounts.'); - } + return this.#persistOrRollback(async () => { + assertIsValidPassword(password); + await this.#deriveAndSetEncryptionKey(password, { + ignoreExistingVault: true, + }); }); - - return seedWords; } - // QR Hardware related methods - /** - * Get QR Hardware keyring. + * Attempts to decrypt the current vault and load its keyrings, using the + * given encryption key and salt. The optional salt can be used to check for + * consistency with the vault salt. * - * @returns The QR Keyring if defined, otherwise undefined + * @param encryptionKey - Key to unlock the keychain. + * @param encryptionSalt - Optional salt to unlock the keychain. + * @returns Promise resolving when the operation completes. */ - getQRKeyring(): QRKeyring | undefined { - // QRKeyring is not yet compatible with Keyring type from @metamask/utils - return this.#keyring.getKeyringsByType( - KeyringTypes.qr, - )[0] as unknown as QRKeyring; + async submitEncryptionKey( + encryptionKey: string, + encryptionSalt?: string, + ): Promise { + const { hasChanged } = await this.#withRollback(async () => { + const result = await this.#unlockKeyrings({ + encryptionKey, + encryptionSalt, + }); + this.#setUnlocked(); + return result; + }); + + try { + // if new metadata has been generated during login, we + // can attempt to upgrade the vault. + await this.#withRollback(async () => { + if (hasChanged) { + await this.#updateVault(); + } + }); + } catch (error) { + // We don't want to throw an error if the upgrade fails + // since the controller is already unlocked. + console.error('Failed to update vault during login:', error); + } } /** - * Get QR hardware keyring. If it doesn't exist, add it. + * Exports the vault encryption key. * - * @returns The added keyring + * @returns The vault encryption key. */ - async getOrAddQRKeyring(): Promise { - return this.getQRKeyring() || (await this.#addQRKeyring()); - } + async exportEncryptionKey(): Promise { + this.#assertIsUnlocked(); - async restoreQRKeyring(serialized: any): Promise { - (await this.getOrAddQRKeyring()).deserialize(serialized); - await this.#keyring.persistAllKeyrings(); - this.updateIdentities(await this.#keyring.getAccounts()); + return await this.#withControllerLock(async () => { + assertIsEncryptionKeySet(this.#encryptionKey?.serialized); + return this.#encryptionKey.serialized; + }); } - async resetQRKeyringState(): Promise { - (await this.getOrAddQRKeyring()).resetStore(); - } + /** + * Attempts to decrypt the current vault and load its keyrings, + * using the given password. + * + * @param password - Password to unlock the keychain. + * @returns Promise resolving when the operation completes. + */ + async submitPassword(password: string): Promise { + const { hasChanged } = await this.#withRollback(async () => { + const result = await this.#unlockKeyrings({ password }); + this.#setUnlocked(); + return result; + }); - async getQRKeyringState(): Promise { - return (await this.getOrAddQRKeyring()).getMemStore(); + try { + // If there are stronger encryption params available, or + // if the keyring state has changed during deserialization, we + // can attempt to upgrade the vault. + await this.#withRollback(async () => { + if (hasChanged || this.#isNewEncryptionAvailable()) { + await this.#deriveAndSetEncryptionKey(password, { + // If the vault is being upgraded, we want to ignore the metadata + // that is already in the vault, so we can effectively + // re-encrypt the vault with the new encryption config. + ignoreExistingVault: true, + }); + await this.#updateVault(); + } + }); + } catch (error) { + // We don't want to throw an error if the upgrade fails + // since the controller is already unlocked. + console.error('Failed to update vault during login:', error); + } } - async submitQRCryptoHDKey(cryptoHDKey: string): Promise { - (await this.getOrAddQRKeyring()).submitCryptoHDKey(cryptoHDKey); - } + /** + * Verifies the that the seed phrase restores the current keychain's accounts. + * + * @param keyringId - The id of the keyring to verify. + * @returns Promise resolving to the seed phrase as Uint8Array. + */ + async verifySeedPhrase(keyringId?: string): Promise { + this.#assertIsUnlocked(); - async submitQRCryptoAccount(cryptoAccount: string): Promise { - (await this.getOrAddQRKeyring()).submitCryptoAccount(cryptoAccount); + return this.#withControllerLock(async () => + this.#verifySeedPhrase(keyringId), + ); } - async submitQRSignature( - requestId: string, - ethSignature: string, - ): Promise { - (await this.getOrAddQRKeyring()).submitSignature(requestId, ethSignature); - } + /** + * Asserts a value is not a specific keyring instance, and throws an error if it is. + * + * @param value The value to check. + * @param keyring The keyring instance to check against. + * @throws If the value is the same instance as the keyring. + * @returns The original value if the check passes. + */ + #assertNoUnsafeDirectKeyringAccess( + value: Value, + keyring: SelectedKeyring, + ): Value { + if (Object.is(value, keyring)) { + // Access to a keyring instance outside of controller safeguards + // should be discouraged, as it can lead to unexpected behavior. + // This error is thrown to prevent consumers using `withKeyring` + // as a way to get a reference to a keyring instance. + throw new KeyringControllerError( + KeyringControllerErrorMessage.UnsafeDirectKeyringAccess, + ); + } - async cancelQRSignRequest(): Promise { - (await this.getOrAddQRKeyring()).cancelSignRequest(); + return value; } /** - * Cancels qr keyring sync. + * Select a keyring and execute the given operation with + * the selected keyring, as a mutually exclusive atomic + * operation. + * + * The method automatically persists changes at the end of the + * function execution, or rolls back the changes if an error + * is thrown. + * + * @param selector - Keyring selector object. + * @param operation - Function to execute with the selected keyring. + * @param options - Additional options. + * @param options.createIfMissing - Whether to create a new keyring if the selected one is missing. + * @param options.createWithData - Optional data to use when creating a new keyring. + * @returns Promise resolving to the result of the function execution. + * @template SelectedKeyring - The type of the selected keyring. + * @template CallbackResult - The type of the value resolved by the callback function. + * @deprecated This method overload is deprecated. Use `withKeyring` without options instead. */ - async cancelQRSynchronization(): Promise { - // eslint-disable-next-line n/no-sync - (await this.getOrAddQRKeyring()).cancelSync(); - } + async withKeyring< + SelectedKeyring extends EthKeyring = EthKeyring, + CallbackResult = void, + >( + selector: KeyringSelector, + operation: ({ keyring, metadata }: KeyringEntry) => Promise, + // eslint-disable-next-line @typescript-eslint/unified-signatures + options: + | { createIfMissing?: false } + | { createIfMissing: true; createWithData?: unknown }, + ): Promise; - async connectQRHardware( - page: number, - ): Promise<{ balance: string; address: string; index: number }[]> { - try { - const keyring = await this.getOrAddQRKeyring(); - let accounts; - switch (page) { - case -1: - accounts = await keyring.getPreviousPage(); - break; - case 1: - accounts = await keyring.getNextPage(); - break; - default: - accounts = await keyring.getFirstPage(); - } - return accounts.map((account: any) => { - return { - ...account, - balance: '0x0', - }; - }); - } catch (e) { - // TODO: Add test case for when keyring throws - /* istanbul ignore next */ - throw new Error(`Unspecified error when connect QR Hardware, ${e}`); + /** + * Select a keyring and execute the given operation with + * the selected keyring, as a mutually exclusive atomic + * operation. + * + * The method automatically persists changes at the end of the + * function execution, or rolls back the changes if an error + * is thrown. + * + * @param selector - Keyring selector object. + * @param operation - Function to execute with the selected keyring. + * @returns Promise resolving to the result of the function execution. + * @template SelectedKeyring - The type of the selected keyring. + * @template CallbackResult - The type of the value resolved by the callback function. + */ + async withKeyring< + SelectedKeyring extends EthKeyring = EthKeyring, + CallbackResult = void, + >( + selector: KeyringSelector, + operation: ({ keyring, metadata }: KeyringEntry) => Promise, + ): Promise; + + async withKeyring< + SelectedKeyring extends EthKeyring = EthKeyring, + CallbackResult = void, + >( + selector: KeyringSelector, + operation: ({ + keyring, + metadata, + }: { + keyring: SelectedKeyring; + metadata: KeyringMetadata; + }) => Promise, + options: + | { createIfMissing?: false } + | { createIfMissing: true; createWithData?: unknown } = { + createIfMissing: false, + }, + ): Promise { + this.#assertIsUnlocked(); + + return this.#persistOrRollback(async () => { + let entry: KeyringEntry | undefined = await this.#selectKeyringEntry({ + v2: false, + selector, + }); + + if (!entry && 'type' in selector && options.createIfMissing) { + const newKeyring = (await this.#newKeyring( + selector.type, + options.createWithData, + )) as SelectedKeyring; + entry = this.#keyrings.find(({ keyring }) => keyring === newKeyring); + } + + if (!entry) { + throw new KeyringControllerError( + KeyringControllerErrorMessage.KeyringNotFound, + ); + } + + const { metadata } = entry; + const keyring = entry.keyring as SelectedKeyring; + + return this.#assertNoUnsafeDirectKeyringAccess( + await this.#cleanUpEmptiedKeyringsAfter(async () => + operation({ keyring, metadata }), + ), + keyring, + ); + }); + } + + /** + * Select a keyring and execute the given operation with the selected + * keyring, **without** acquiring the controller's mutual exclusion lock. + * + * ## When to use this method + * + * This method is an escape hatch for read-only access to keyring data that + * is immutable once the keyring is initialized. A typical safe use case is + * reading the `mnemonic` from an `HdKeyring`: the mnemonic is set during + * `deserialize()` and never mutated afterwards, so it can safely be read + * without holding the lock. + * + * ## Why it is "unsafe" + * + * The "unsafe" designation mirrors the semantics of `unsafe { }` blocks in + * Rust: the method itself does not enforce thread-safety guarantees. By + * calling this method the **caller** explicitly takes responsibility for + * ensuring that: + * + * - The operation is **read-only** — no state is mutated. + * - The data being read is **immutable** after the keyring is initialized, + * so concurrent locked operations cannot alter it while this callback + * runs. + * + * Do **not** use this method to: + * - Mutate keyring state (add accounts, sign, etc.) — use `withKeyring`. + * - Read mutable fields that could change during concurrent operations. + * + * @param selector - Keyring selector object. + * @param operation - Read-only function to execute with the selected keyring. + * @returns Promise resolving to the result of the function execution. + * @template SelectedKeyring - The type of the selected keyring. + * @template CallbackResult - The type of the value resolved by the callback function. + */ + async withKeyringUnsafe< + SelectedKeyring extends EthKeyring = EthKeyring, + CallbackResult = void, + >( + selector: KeyringSelector, + operation: ({ + keyring, + metadata, + }: { + keyring: SelectedKeyring; + metadata: KeyringMetadata; + }) => Promise, + ): Promise { + this.#assertIsUnlocked(); + + const entry = await this.#selectKeyringEntry({ v2: false, selector }); + + if (!entry) { + throw new KeyringControllerError( + KeyringControllerErrorMessage.KeyringNotFound, + ); } + + const { metadata } = entry; + const keyring = entry.keyring as SelectedKeyring; + + // Even if this method is "unsafe", we still want to prevent returning + // the keyring directly. + return this.#assertNoUnsafeDirectKeyringAccess( + await operation({ keyring, metadata }), + keyring, + ); } - async unlockQRHardwareWalletAccount(index: number): Promise { - const keyring = await this.getOrAddQRKeyring(); - - keyring.setAccountToUnlock(index); - const oldAccounts = await this.#keyring.getAccounts(); - // QRKeyring is not yet compatible with Keyring from - // @metamask/utils, but we can use the `addNewAccount` method - // as it internally calls `addAccounts` from on the keyring instance, - // which is supported by QRKeyring API. - await this.#keyring.addNewAccount(keyring as unknown as Keyring); - const newAccounts = await this.#keyring.getAccounts(); - this.updateIdentities(newAccounts); - newAccounts.forEach((address: string) => { - if (!oldAccounts.includes(address)) { - if (this.setAccountLabel) { - this.setAccountLabel(address, `${keyring.getName()} ${index}`); - } - this.setSelectedAddress(address); + /** + * Select a keyring using its `KeyringV2` adapter, and execute + * the given operation with the wrapped keyring as a mutually + * exclusive atomic operation. + * + * The cached `KeyringV2` adapter is retrieved from the keyring + * entry. + * + * A `KeyringV2Builder` for the selected keyring's type must exist + * (either as a default or registered via the `keyringV2Builders` + * constructor option); otherwise an error is thrown. + * + * The method automatically persists changes at the end of the + * function execution, or rolls back the changes if an error + * is thrown. + * + * @param selector - Keyring selector object. + * @param operation - Function to execute with the wrapped V2 keyring. + * @returns Promise resolving to the result of the function execution. + * @template CallbackResult - The type of the value resolved by the callback function. + */ + async withKeyringV2< + SelectedKeyring extends KeyringV2 = KeyringV2, + CallbackResult = void, + >( + selector: KeyringSelectorV2, + operation: ({ + keyring, + metadata, + }: { + keyring: SelectedKeyring; + metadata: KeyringMetadata; + }) => Promise, + ): Promise { + this.#assertIsUnlocked(); + + return this.#persistOrRollback(async () => { + const entry = await this.#selectKeyringEntry({ + v2: true, + selector, + }); + + if (!entry) { + throw new KeyringControllerError( + KeyringControllerErrorMessage.KeyringNotFound, + ); + } + + if (!entry.keyringV2) { + throw new KeyringControllerError( + KeyringControllerErrorMessage.KeyringV2NotSupported, + ); } + + const { metadata } = entry; + const keyring = entry.keyringV2 as SelectedKeyring; + + return this.#assertNoUnsafeDirectKeyringAccess( + await this.#cleanUpEmptiedKeyringsAfter(async () => + operation({ + keyring, + metadata, + }), + ), + keyring, + ); }); - await this.#keyring.persistAllKeyrings(); } - async getAccountKeyringType(account: string): Promise { - return (await this.#keyring.getKeyringForAccount(account)).type; + /** + * Select a keyring, wrap it in a `KeyringV2` adapter, and execute + * the given read-only operation **without** acquiring the controller's + * mutual exclusion lock. + * + * ## When to use this method + * + * This method is an escape hatch for read-only access to keyring data that + * is immutable once the keyring is initialized. A typical safe use case is + * reading immutable fields from a `KeyringV2` adapter: data that is set + * during initialization and never mutated afterwards. + * + * ## Why it is "unsafe" + * + * The "unsafe" designation mirrors the semantics of `unsafe { }` blocks in + * Rust: the method itself does not enforce thread-safety guarantees. By + * calling this method the **caller** explicitly takes responsibility for + * ensuring that: + * + * - The operation is **read-only** — no state is mutated. + * - The data being read is **immutable** after the keyring is initialized, + * so concurrent locked operations cannot alter it while this callback + * runs. + * + * Do **not** use this method to: + * - Mutate keyring state (add accounts, sign, etc.) — use `withKeyringV2`. + * - Read mutable fields that could change during concurrent operations. + * + * @param selector - Keyring selector object. + * @param operation - Read-only function to execute with the wrapped V2 keyring. + * @returns Promise resolving to the result of the function execution. + * @template SelectedKeyring - The type of the selected V2 keyring. + * @template CallbackResult - The type of the value resolved by the callback function. + */ + async withKeyringV2Unsafe< + SelectedKeyring extends KeyringV2 = KeyringV2, + CallbackResult = void, + >( + selector: KeyringSelectorV2, + operation: ({ + keyring, + metadata, + }: { + keyring: SelectedKeyring; + metadata: KeyringMetadata; + }) => Promise, + ): Promise { + this.#assertIsUnlocked(); + + const entry = await this.#selectKeyringEntry({ + v2: true, + selector, + }); + + if (!entry) { + throw new KeyringControllerError( + KeyringControllerErrorMessage.KeyringNotFound, + ); + } + + if (!entry.keyringV2) { + throw new KeyringControllerError( + KeyringControllerErrorMessage.KeyringV2NotSupported, + ); + } + + const { metadata } = entry; + const keyring = entry.keyringV2 as SelectedKeyring; + + // Even if this method is "unsafe", we still want to prevent returning + // the keyring directly. + return this.#assertNoUnsafeDirectKeyringAccess( + await operation({ keyring, metadata }), + keyring, + ); } - async forgetQRDevice(): Promise { - const keyring = await this.getOrAddQRKeyring(); - keyring.forgetDevice(); - const accounts = (await this.#keyring.getAccounts()) as string[]; - accounts.forEach((account) => { - this.setSelectedAddress(account); + /** + * Execute an operation against all keyrings as a mutually exclusive atomic + * operation. The operation receives a {@link RestrictedController} instance + * that exposes a read-only live view of all keyrings as well as + * `addNewKeyring` and `removeKeyring` methods to stage mutations. + * + * The method automatically persists changes at the end of the function + * execution, or rolls back the changes if an error is thrown. + * + * @param operation - Function to execute with the restricted controller. + * @returns Promise resolving to the result of the function execution. + * @template CallbackResult - The type of the value resolved by the callback function. + */ + async withController( + operation: ( + restrictedController: RestrictedController, + ) => Promise, + ): Promise { + this.#assertIsUnlocked(); + + return this.#persistOrRollback(async () => { + // Track created and removed keyrings during the operation execution. + const createdEntries = new Set(); + const removedEntries = new Set(); + + // Copy of the current keyrings that is mutated during the operation execution. + const restrictedEntries = [...this.#keyrings]; + + // The restricted controller proxies the current keyrings and allows staging + // mutations that are only applied to the real keyrings if the operation + // completes successfully. This allows us to have a single source of truth + // for the keyrings during the operation execution, and to automatically + // roll back any changes if an error is thrown. + const restrictedController: RestrictedController = { + // We freeze the array to prevent direct mutations, but the keyring instances + // themselves are not frozen, allowing safe read-only access. + get keyrings() { + return Object.freeze([...restrictedEntries]); + }, + + // Method to create a new keyring and adds it to the restricted entries. + addNewKeyring: async (type: string, opts?: unknown) => { + const entry = await this.#createKeyring(type, opts); + + restrictedEntries.push(entry); + createdEntries.add(entry); + + return entry; + }, + + // Method to remove a keyring from the restricted entries. + removeKeyring: async (id: string) => { + const index = restrictedEntries.findIndex( + (entry) => entry.metadata.id === id, + ); + if (index === -1) { + throw new KeyringControllerError( + KeyringControllerErrorMessage.KeyringNotFound, + ); + } + + this.#assertNotRemovingPrimaryKeyring( + restrictedEntries[index], + restrictedEntries, + ); + + const [removed] = restrictedEntries.splice(index, 1) as [ + KeyringEntry, + ]; + removedEntries.add(removed); + }, + }; + + const destroyKeyrings = async ( + entries: Iterable, + ): Promise => { + await Promise.all( + [...entries].map(({ keyring, keyringV2 }) => + this.#destroyKeyring(keyring, keyringV2), + ), + ); + }; + + let result: CallbackResult; + try { + result = await operation(restrictedController); + } catch (error) { + await destroyKeyrings(createdEntries); + + throw error; + } + + await destroyKeyrings(removedEntries); + + // We update the real keyrings only after the operation completes successfully, so that + // they will be persisted in the vault. + this.#keyrings = restrictedEntries; + + // As usual, we want to prevent returning direct references to keyring instances, so we check + // the result for any unsafe direct access before returning. + for (const { keyring, keyringV2 } of [ + ...this.#keyrings, + // We also check for keyrings that got removed during the operation, since the result could + // still have references to them. + ...removedEntries, + ]) { + this.#assertNoUnsafeDirectKeyringAccess(result, keyring); + if (keyringV2) { + this.#assertNoUnsafeDirectKeyringAccess(result, keyringV2); + } + } + + return result; }); - await this.#keyring.persistAllKeyrings(); } /** - * Constructor helper for registering this controller's messaging system + * Gets the type of the keyring that manages the specified account. + * + * @param account - The account address to look up. + * @returns A promise that resolves to the type of the keyring managing the account. + */ + async getAccountKeyringType(account: string): Promise { + this.#assertIsUnlocked(); + + const keyring = (await this.getKeyringForAccount(account)) as EthKeyring; + return keyring.type; + } + + /** + * Constructor helper for registering this controller's messeger * actions. */ - #registerMessageHandlers() { - this.messagingSystem.registerActionHandler( - `${name}:signMessage`, - this.signMessage.bind(this), + #registerMessageHandlers(): void { + this.messenger.registerMethodActionHandlers( + this, + MESSENGER_EXPOSED_METHODS, ); + } + + /** + * Select a keyring entry using a selector without acquiring the controller lock. + * + * @param options - Selection options. + * @param options.v2 - Tag to indicate whether the selector is for a V2 keyring. + * @param options.selector - Keyring selector object. + * @returns The selected keyring entry, or `undefined` if no match is found. + * @template SelectedKeyring - The expected type of the selected keyring. + * @template SelectedKeyringV2 - The expected type of the selected keyring (v2). + */ + async #selectKeyringEntry< + SelectedKeyring extends EthKeyring, + SelectedKeyringV2 extends KeyringV2, + >({ + v2, + selector, + }: // Use distinct union tags to ensure proper type narrowing of the selector object. + | { + v2: false; + selector: KeyringSelector; + } + | { + v2: true; + selector: KeyringSelectorV2; + }): Promise { + let entry: KeyringEntry | undefined; + + if ('address' in selector) { + entry = await this.#getKeyringEntryForAccount(selector.address); + } else if ('type' in selector) { + const entries = v2 + ? this.#getKeyringEntriesByType({ v2: true, type: selector.type }) + : this.#getKeyringEntriesByType({ v2: false, type: selector.type }); + entry = entries[selector.index ?? 0]; + } else if ('id' in selector) { + entry = this.#getKeyringEntryById(selector.id); + } else if ('filter' in selector) { + entry = this.#keyrings.find(({ keyring, keyringV2, metadata }) => { + // If v2, then we'll use the v2 selector which expects a `KeyringV2` instance. + if (v2) { + // However, some keyrings do not have a v2 wrapper, so we just skip them. + if (!keyringV2) { + return false; + } + + return selector.filter(keyringV2, metadata); + } - this.messagingSystem.registerActionHandler( - `${name}:signPersonalMessage`, - this.signPersonalMessage.bind(this), + return selector.filter(keyring, metadata); + }); + } + + return entry; + } + + /** + * Get the keyring by id. + * + * @param keyringId - The id of the keyring. + * @returns The keyring. + */ + #getKeyringById(keyringId: string): EthKeyring | undefined { + return this.#getKeyringEntryById(keyringId)?.keyring; + } + + #getKeyringEntryById(keyringId: string): KeyringEntry | undefined { + return this.#keyrings.find(({ metadata }) => metadata.id === keyringId); + } + + /** + * Get the keyring by id or return the first keyring if the id is not found. + * + * @param keyringId - The id of the keyring. + * @returns The keyring. + */ + #getKeyringByIdOrDefault(keyringId?: string): EthKeyring | undefined { + if (!keyringId) { + return this.#keyrings[0]?.keyring; + } + + return this.#getKeyringById(keyringId); + } + + /** + * Get the metadata for the specified keyring. + * + * @param keyring - The keyring instance to get the metadata for. + * @returns The keyring metadata. + */ + #getKeyringMetadata(keyring: unknown): KeyringMetadata { + const keyringWithMetadata = this.#keyrings.find( + (candidate) => candidate.keyring === keyring, ); + if (!keyringWithMetadata) { + throw new KeyringControllerError( + KeyringControllerErrorMessage.KeyringNotFound, + ); + } + return keyringWithMetadata.metadata; + } + + /** + * Get the keyring builder for the given `type`. + * + * @param type - The type of keyring to get the builder for. + * @returns The keyring builder, or undefined if none exists. + */ + #getKeyringBuilderForType( + type: string, + ): { (): EthKeyring; type: string } | undefined { + return this.#keyringBuilders.find( + (keyringBuilder) => keyringBuilder.type === type, + ); + } + + /** + * Get the V2 keyring builder for the given `type`. + * + * @param type - The type of keyring to get the builder for. + * @returns The V2 keyring builder, or undefined if none exists. + */ + #getKeyringV2BuilderForType(type: string): KeyringV2Builder | undefined { + return this.#keyringV2Builders.find((builder) => builder.type === type); + } + + /** + * Create new vault with an initial keyring + * + * Destroys any old encrypted storage, + * creates a new encrypted store with the given password, + * creates a new wallet with 1 account. + * + * @fires KeyringController:unlock + * @param password - The password to encrypt the vault with. + * @param keyring - A object containing the params to instantiate a new keyring. + * @param keyring.type - The keyring type. + * @param keyring.opts - Optional parameters required to instantiate the keyring. + * @returns A promise that resolves to the state. + */ + async #createNewVaultWithKeyring( + password: string, + keyring: { + type: string; + opts?: unknown; + }, + ): Promise { + this.#assertControllerMutexIsLocked(); + + if (typeof password !== 'string') { + throw new TypeError(KeyringControllerErrorMessage.WrongPasswordType); + } + + this.update((state) => { + delete state.encryptionKey; + delete state.encryptionSalt; + }); + + await this.#deriveAndSetEncryptionKey(password, { + ignoreExistingVault: true, + }); + + await this.#clearKeyrings(); + await this.#createKeyringWithFirstAccount(keyring.type, keyring.opts); + this.#setUnlocked(); + } + + /** + * Derive the vault encryption key from the provided password, and + * assign it to the instance variable for later use with cryptographic + * functions. + * + * When the controller has a vault in its state, the key is derived + * using the salt from the vault. If the vault is empty, a new salt + * is generated and used to derive the key. + * + * If `options.ignoreExistingVault` is set to `true`, the existing + * vault is completely ignored: the new key won't be able to decrypt + * the existing vault, and should be used to re-encrypt it. + * + * @param password - The password to use for decryption or derivation. + * @param options - Options for the key derivation. + * @param options.ignoreExistingVault - Whether to ignore the existing vault salt and key metadata + */ + async #deriveAndSetEncryptionKey( + password: string, + options: { ignoreExistingVault: boolean } = { + ignoreExistingVault: false, + }, + ): Promise { + this.#assertControllerMutexIsLocked(); + const { vault } = this.state; + + if (typeof password !== 'string') { + throw new TypeError(KeyringControllerErrorMessage.WrongPasswordType); + } + + let serializedEncryptionKey: string, salt: string; + if (vault && !options.ignoreExistingVault) { + // The `decryptWithDetail` method is being used here instead of + // `keyFromPassword` + `exportKey` to let the encryptor handle + // any legacy encryption formats and metadata that might be + // present (or absent) in the vault. + const { exportedKeyString, salt: existingSalt } = + await this.#encryptor.decryptWithDetail(password, vault); + serializedEncryptionKey = exportedKeyString; + salt = existingSalt; + } else { + salt = this.#encryptor.generateSalt(); + serializedEncryptionKey = await this.#encryptor.exportKey( + await this.#encryptor.keyFromPassword(password, salt, true), + ); + } + + this.#encryptionKey = { + salt, + serialized: serializedEncryptionKey, + }; + } + + /** + * Set the the `#encryptionKey` instance variable. + * This method is used when the user provides an encryption key and salt + * to unlock the keychain, instead of using a password. + * + * @param encryptionKey - The encryption key to use. + * @param keyDerivationSalt - The salt to use for the encryption key. + */ + #setEncryptionKey(encryptionKey: string, keyDerivationSalt: string): void { + this.#assertControllerMutexIsLocked(); + + if ( + typeof encryptionKey !== 'string' || + typeof keyDerivationSalt !== 'string' + ) { + throw new TypeError(KeyringControllerErrorMessage.WrongEncryptionKeyType); + } + + const { vault } = this.state; + if (vault && JSON.parse(vault).salt !== keyDerivationSalt) { + throw new KeyringControllerError( + KeyringControllerErrorMessage.ExpiredCredentials, + ); + } + + this.#encryptionKey = { + salt: keyDerivationSalt, + serialized: encryptionKey, + }; + } + + /** + * Internal non-exclusive method to verify the seed phrase. + * + * @param keyringId - The id of the keyring to verify the seed phrase for. + * @returns A promise resolving to the seed phrase as Uint8Array. + */ + async #verifySeedPhrase(keyringId?: string): Promise { + this.#assertControllerMutexIsLocked(); + + const keyring = this.#getKeyringByIdOrDefault(keyringId); + + if (!keyring) { + throw new KeyringControllerError( + KeyringControllerErrorMessage.KeyringNotFound, + ); + } + + if (keyring.type !== (KeyringTypes.hd as string)) { + throw new KeyringControllerError( + KeyringControllerErrorMessage.UnsupportedVerifySeedPhrase, + ); + } + + assertHasUint8ArrayMnemonic(keyring); + + const seedWords = keyring.mnemonic; + const accounts = await keyring.getAccounts(); + /* istanbul ignore if */ + if (accounts.length === 0) { + throw new KeyringControllerError('Cannot verify an empty keyring.'); + } + + // The HD Keyring Builder is a default keyring builder + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + const hdKeyringBuilder = this.#getKeyringBuilderForType(KeyringTypes.hd)!; + + const hdKeyring = hdKeyringBuilder(); + // @ts-expect-error @metamask/eth-hd-keyring correctly handles + // Uint8Array seed phrases in the `deserialize` method. + await hdKeyring.deserialize({ + mnemonic: seedWords, + numberOfAccounts: accounts.length, + }); + const testAccounts = await hdKeyring.getAccounts(); + /* istanbul ignore if */ + if (testAccounts.length !== accounts.length) { + throw new KeyringControllerError( + 'Seed phrase imported incorrect number of accounts.', + ); + } + + testAccounts.forEach((account: string, i: number) => { + /* istanbul ignore if */ + if (account.toLowerCase() !== accounts[i].toLowerCase()) { + throw new KeyringControllerError( + 'Seed phrase imported different accounts.', + ); + } + }); + + return seedWords; + } + + /** + * Get the updated array of each keyring's type and + * accounts list. + * + * @returns A promise resolving to the updated keyrings array. + */ + async #getUpdatedKeyrings(): Promise { + return Promise.all(this.#keyrings.map(displayForKeyring)); + } - this.messagingSystem.registerActionHandler( - `${name}:signTypedMessage`, - this.signTypedMessage.bind(this), + /** + * Serialize the current array of keyring instances, + * including unsupported keyrings by default. + * + * @param options - Method options. + * @param options.includeUnsupported - Whether to include unsupported keyrings. + * @returns The serialized keyrings. + */ + async #getSerializedKeyrings( + { includeUnsupported }: { includeUnsupported: boolean } = { + includeUnsupported: true, + }, + ): Promise { + const serializedKeyrings: SerializedKeyring[] = await Promise.all( + this.#keyrings.map(async ({ keyring, metadata }) => { + return { + type: keyring.type, + data: await keyring.serialize(), + metadata, + }; + }), ); - this.messagingSystem.registerActionHandler( - `${name}:decryptMessage`, - this.decryptMessage.bind(this), + if (includeUnsupported) { + serializedKeyrings.push(...this.#unsupportedKeyrings); + } + + return serializedKeyrings; + } + + /** + * Get a snapshot of session data held by instance variables. + * + * @returns An object with serialized keyrings, keyrings metadata, + * and the user password. + */ + async #getSessionState(): Promise { + return { + keyrings: await this.#getSerializedKeyrings(), + encryptionKey: this.#encryptionKey, + }; + } + + /** + * Restore a serialized keyrings array. + * + * @param serializedKeyrings - The serialized keyrings array. + * @returns The restored keyrings. + */ + async #restoreSerializedKeyrings( + serializedKeyrings: SerializedKeyring[], + ): Promise<{ + keyrings: { keyring: EthKeyring; metadata: KeyringMetadata }[]; + hasChanged: boolean; + }> { + await this.#clearKeyrings(); + const keyrings: { keyring: EthKeyring; metadata: KeyringMetadata }[] = []; + let hasChanged = false; + + for (const serializedKeyring of serializedKeyrings) { + const result = await this.#restoreKeyring(serializedKeyring); + if (result) { + const { keyring, metadata } = result; + keyrings.push({ keyring, metadata }); + if (result.hasChanged) { + hasChanged = true; + } + } + } + + return { keyrings, hasChanged }; + } + + /** + * Unlock Keyrings, decrypting the vault and deserializing all + * keyrings contained in it, using a password or an encryption key with salt. + * + * @param credentials - The credentials to unlock the keyrings. + * @returns A promise resolving to the deserialized keyrings array. + */ + async #unlockKeyrings(credentials: Credentials): Promise<{ + keyrings: { keyring: EthKeyring; metadata: KeyringMetadata }[]; + hasChanged: boolean; + }> { + return this.#withVaultLock(async () => { + if (!this.state.vault) { + throw new KeyringControllerError( + KeyringControllerErrorMessage.VaultError, + ); + } + const parsedEncryptedVault = JSON.parse(this.state.vault); + + if ('password' in credentials) { + await this.#deriveAndSetEncryptionKey(credentials.password); + } else { + this.#setEncryptionKey( + credentials.encryptionKey, + credentials.encryptionSalt ?? parsedEncryptedVault.salt, + ); + } + + const encryptionKey = this.#encryptionKey?.serialized; + if (!encryptionKey) { + throw new KeyringControllerError( + KeyringControllerErrorMessage.MissingCredentials, + ); + } + + const key = await this.#encryptor.importKey(encryptionKey); + const vault = await this.#encryptor.decryptWithKey( + key, + parsedEncryptedVault, + ); + + if (!isSerializedKeyringsArray(vault)) { + throw new KeyringControllerError( + KeyringControllerErrorMessage.VaultDataError, + ); + } + + const { keyrings, hasChanged } = + await this.#restoreSerializedKeyrings(vault); + + const updatedKeyrings = await this.#getUpdatedKeyrings(); + + this.update((state) => { + state.keyrings = updatedKeyrings; + state.encryptionKey = encryptionKey; + state.encryptionSalt = this.#encryptionKey?.salt; + }); + + return { keyrings, hasChanged }; + }); + } + + /** + * Update the vault with the current keyrings. + * + * @returns A promise resolving to `true` if the operation is successful. + */ + #updateVault(): Promise { + return this.#withVaultLock(async () => { + // Ensure no duplicate accounts are persisted. + await this.#assertNoDuplicateAccounts(); + + if (!this.#encryptionKey) { + throw new KeyringControllerError( + KeyringControllerErrorMessage.MissingCredentials, + ); + } + + const serializedKeyrings = await this.#getSerializedKeyrings(); + + if ( + !serializedKeyrings.some( + (keyring) => keyring.type === (KeyringTypes.hd as string), + ) + ) { + throw new KeyringControllerError( + KeyringControllerErrorMessage.NoHdKeyring, + ); + } + + const key = await this.#encryptor.importKey( + this.#encryptionKey.serialized, + ); + const encryptedVault = await this.#encryptor.encryptWithKey( + key, + serializedKeyrings, + ); + // We need to include the salt used to derive + // the encryption key, to be able to derive it + // from password again. + encryptedVault.salt = this.#encryptionKey.salt; + const updatedState: Partial = { + vault: JSON.stringify(encryptedVault), + encryptionKey: this.#encryptionKey.serialized, + encryptionSalt: this.#encryptionKey.salt, + }; + + const updatedKeyrings = await this.#getUpdatedKeyrings(); + + this.update((state) => { + state.vault = updatedState.vault; + state.keyrings = updatedKeyrings; + state.encryptionKey = updatedState.encryptionKey; + state.encryptionSalt = updatedState.encryptionSalt; + }); + + return true; + }); + } + + /** + * Check if there are new encryption parameters available. + * + * @returns A promise resolving to `void`. + */ + #isNewEncryptionAvailable(): boolean { + const { vault } = this.state; + + if (!vault || !this.#encryptor.isVaultUpdated) { + return false; + } + + return !this.#encryptor.isVaultUpdated(vault); + } + + /** + * Retrieves all the accounts from keyrings instances + * that are currently in memory. + * + * @param additionalKeyrings - Additional keyrings to include in the search. + * @returns A promise resolving to an array of accounts. + */ + async #getAccountsFromKeyrings( + additionalKeyrings: EthKeyring[] = [], + ): Promise { + const keyrings = this.#keyrings.map(({ keyring }) => keyring); + + const keyringArrays = await Promise.all( + [...keyrings, ...additionalKeyrings].map(async (keyring) => + keyring.getAccounts(), + ), ); + const addresses = keyringArrays.reduce((res, arr) => { + return res.concat(arr); + }, []); + + // Cast to `string[]` here is safe here because `addresses` has no nullish + // values, and `normalize` returns `string` unless given a nullish value + return addresses.map(normalize) as string[]; + } + + /** + * Create a new keyring, ensuring that the first account is + * also created. + * + * @param type - Keyring type to instantiate. + * @param opts - Optional parameters required to instantiate the keyring. + * @returns A promise that resolves if the operation is successful. + */ + async #createKeyringWithFirstAccount( + type: string, + opts?: unknown, + ): Promise { + this.#assertControllerMutexIsLocked(); - this.messagingSystem.registerActionHandler( - `${name}:getEncryptionPublicKey`, - this.getEncryptionPublicKey.bind(this), + const keyring = await this.#newKeyring(type, opts); + + const [firstAccount] = await keyring.getAccounts(); + if (!firstAccount) { + throw new KeyringControllerError( + KeyringControllerErrorMessage.NoFirstAccount, + ); + } + return firstAccount; + } + + /** + * Instantiate, initialize and return a new keyring of the given `type`, + * using the given `opts`. The keyring is built using the keyring builder + * registered for the given `type`. + * + * The internal keyring and keyring metadata arrays are updated with the new + * keyring as well. + * + * @param type - The type of keyring to add. + * @param data - Keyring initialization options. + * @returns The new keyring. + * @throws If the keyring includes duplicated accounts. + */ + async #newKeyring(type: string, data?: unknown): Promise { + const { keyring, keyringV2, metadata } = await this.#createKeyring( + type, + data, ); - this.messagingSystem.registerActionHandler( - `${name}:getAccounts`, - this.getAccounts.bind(this), + this.#keyrings.push({ keyring, keyringV2, metadata }); + + return keyring; + } + + /** + * Instantiate, initialize and return a keyring of the given `type` using the + * given `opts`. The keyring is built using the keyring builder registered + * for the given `type`. + * + * The keyring might be new, or it might be restored from the vault. This + * function should only be called from `#newKeyring` or `#restoreKeyring`, + * for the "new" and "restore" cases respectively. + * + * The internal keyring and keyring metadata arrays are *not* updated, the + * caller is expected to update them. + * + * @param type - The type of keyring to add. + * @param data - Keyring initialization options. + * @param metadata - Keyring metadata if available. + * @returns The new keyring. + * @throws If the keyring includes duplicated accounts. + */ + async #createKeyring( + type: string, + data?: unknown, + metadata?: KeyringMetadata, + ): Promise { + this.#assertControllerMutexIsLocked(); + + const keyringMetadata = metadata ?? getDefaultKeyringMetadata(); + + const keyringBuilder = this.#getKeyringBuilderForType(type); + if (!keyringBuilder) { + throw new KeyringControllerError( + `${KeyringControllerErrorMessage.NoKeyringBuilder}. Keyring type: ${type}`, + ); + } + + const keyring = keyringBuilder(); + if (data) { + // @ts-expect-error Enforce data type after updating clients + await keyring.deserialize(data); + } + + if (keyring.init) { + await keyring.init(); + } + + if ( + type === (KeyringTypes.hd as string) && + (!isObject(data) || !data.mnemonic) + ) { + if (!keyring.generateRandomMnemonic) { + throw new KeyringControllerError( + KeyringControllerErrorMessage.UnsupportedGenerateRandomMnemonic, + ); + } + + // NOTE: Not all keyrings implement this method in a asynchronous-way. Using `await` for + // non-thenable will still be valid (despite not being really useful). It allows us to cover both + // cases and allow retro-compatibility too. + await keyring.generateRandomMnemonic(); + await keyring.addAccounts(1); + } + + // We now create the keyring V2 wrappers and store them in memory. + const keyringBuilderV2 = this.#getKeyringV2BuilderForType(type); + let keyringV2: KeyringV2 | undefined; + if (keyringBuilderV2) { + keyringV2 = keyringBuilderV2(keyring, keyringMetadata); + } + + return { keyring, keyringV2, metadata: keyringMetadata }; + } + + /** + * Run the given operation and afterwards clean up any keyring whose + * account list transitioned from non-empty to empty during the operation. + * + * This mirrors the cleanup behavior of {@link KeyringController.removeAccount} + * for code paths where the consumer mutates a keyring directly via + * {@link KeyringController.withKeyring} or + * {@link KeyringController.withKeyringV2}: if the consumer drains the last + * account from a keyring, the now-empty keyring is removed from + * {@link KeyringController.#keyrings} and destroyed before persistence runs. + * + * Pre-existing empty keyrings (e.g. those created intentionally via + * {@link KeyringController.addNewKeyring} without subsequent account + * creation) are left alone, as are keyrings created within the operation + * itself (they are not part of the pre-operation snapshot). The primary + * keyring (see {@link KeyringController.#isPrimaryKeyring}) is also preserved + * unconditionally to keep `removeAccount`'s primary-keyring invariant intact. + * + * @param operation - The operation to execute. + * @returns The result of the operation. + * @template Result - The type of the value resolved by the operation. + */ + async #cleanUpEmptiedKeyringsAfter( + operation: () => Promise, + ): Promise { + // Only the primary keyring exists, which is never auto-removed, so there + // is nothing to clean up regardless of what the operation does. + if (this.#keyrings.length <= 1) { + return operation(); + } + + const wasNonEmpty = new WeakSet(); + await Promise.all( + this.#keyrings.map(async ({ keyring }) => { + if ((await keyring.getAccounts()).length > 0) { + wasNonEmpty.add(keyring); + } + }), ); - this.messagingSystem.registerActionHandler( - `${name}:getKeyringsByType`, - this.getKeyringsByType.bind(this), + const result = await operation(); + + const isNowEmpty = await Promise.all( + this.#keyrings.map( + async ({ keyring }) => (await keyring.getAccounts()).length === 0, + ), ); - this.messagingSystem.registerActionHandler( - `${name}:getKeyringForAccount`, - this.getKeyringForAccount.bind(this), + const emptied = this.#keyrings.filter( + (entry, index) => + !this.#isPrimaryKeyring(entry, this.#keyrings) && + wasNonEmpty.has(entry.keyring) && + isNowEmpty[index], ); + + if (emptied.length > 0) { + const removed = new Set(emptied); + this.#keyrings = this.#keyrings.filter((entry) => !removed.has(entry)); + await Promise.all( + emptied.map(({ keyring, keyringV2 }) => + this.#destroyKeyring(keyring, keyringV2), + ), + ); + } + + return result; + } + + /** + * Remove all managed keyrings, destroying all their + * instances in memory. + */ + async #clearKeyrings(): Promise { + this.#assertControllerMutexIsLocked(); + for (const { keyring, keyringV2 } of this.#keyrings) { + await this.#destroyKeyring(keyring, keyringV2); + } + this.#keyrings = []; + this.#unsupportedKeyrings = []; } /** - * Add qr hardware keyring. + * Restore a Keyring from a provided serialized payload. + * On success, returns the resulting keyring instance. * - * @returns The added keyring - * @throws If a QRKeyring builder is not provided - * when initializing the controller + * @param serialized - The serialized keyring. + * @returns The deserialized keyring or undefined if the keyring type is unsupported. */ - async #addQRKeyring(): Promise { - // QRKeyring is not yet compatible with Keyring type from @metamask/utils - const qrKeyring = (await this.#keyring.addNewKeyring( - KeyringTypes.qr, - )) as unknown as QRKeyring; + async #restoreKeyring(serialized: SerializedKeyring): Promise< + | (KeyringEntry & { + hasChanged: boolean; + }) + | undefined + > { + this.#assertControllerMutexIsLocked(); + + try { + const { type, data, metadata: serializedMetadata } = serialized; + + // Track if we need to trigger a vault update. + let hasChanged = false; + + // If metadata is missing, assume the data is from an installation before we had + // keyring metadata. + let metadata = serializedMetadata; + if (!metadata) { + hasChanged = true; + metadata = getDefaultKeyringMetadata(); + } + + const oldState = JSON.stringify(data); + const { keyring, keyringV2 } = await this.#createKeyring( + type, + data, + metadata, + ); + const newState = JSON.stringify(await keyring.serialize()); + hasChanged ||= oldState !== newState; - this.#subscribeToQRKeyringEvents(qrKeyring); + await this.#assertNoDuplicateAccounts([keyring]); - return qrKeyring; + // The keyring is added to the keyrings array only if it's successfully restored + // and the metadata is successfully added to the controller + this.#keyrings.push({ + keyring, + keyringV2, + metadata, + }); + + return { keyring, keyringV2, metadata, hasChanged }; + } catch (error) { + console.error(error); + this.#unsupportedKeyrings.push(serialized); + return undefined; + } } /** - * Subscribe to a QRKeyring state change events and - * forward them through the messaging system. + * Destroy Keyring * - * @param qrKeyring - The QRKeyring instance to subscribe to + * Some keyrings support a method called `destroy`, that destroys the + * keyring along with removing all its event listeners and, in some cases, + * clears the keyring bridge iframe from the DOM. + * + * @param keyring - The keyring to destroy. + * @param keyringV2 - The keyring v2 to destroy (if any). */ - #subscribeToQRKeyringEvents(qrKeyring: QRKeyring) { - this.#qrKeyringStateListener = (state) => { - this.messagingSystem.publish(`${name}:qrKeyringStateChange`, state); - }; + async #destroyKeyring( + keyring: EthKeyring, + keyringV2?: KeyringV2, + ): Promise { + await keyring.destroy?.(); + if (keyringV2) { + await keyringV2.destroy?.(); + } + } + + /** + * Assert that there are no duplicate accounts in the keyrings. + * + * @param additionalKeyrings - Additional keyrings to include in the check. + * @throws If there are duplicate accounts. + */ + async #assertNoDuplicateAccounts( + additionalKeyrings: EthKeyring[] = [], + ): Promise { + const accounts = await this.#getAccountsFromKeyrings(additionalKeyrings); + + if (new Set(accounts).size !== accounts.length) { + throw new KeyringControllerError( + KeyringControllerErrorMessage.DuplicatedAccount, + ); + } + } - qrKeyring.getMemStore().subscribe(this.#qrKeyringStateListener); + /** + * Set the `isUnlocked` to true and notify listeners + * through the messenger. + * + * @fires KeyringController:unlock + */ + #setUnlocked(): void { + this.#assertControllerMutexIsLocked(); + + this.update((state) => { + state.isUnlocked = true; + }); + this.messenger.publish(`${name}:unlock`); } - #unsubscribeFromQRKeyringsEvents() { - const qrKeyrings = this.#keyring.getKeyringsByType( - KeyringTypes.qr, - ) as unknown as QRKeyring[]; + /** + * Assert that the controller is unlocked. + * + * @throws If the controller is locked. + */ + #assertIsUnlocked(): void { + if (!this.state.isUnlocked) { + throw new KeyringControllerError( + KeyringControllerErrorMessage.ControllerLocked, + ); + } + } - qrKeyrings.forEach((qrKeyring) => { - if (this.#qrKeyringStateListener) { - qrKeyring.getMemStore().unsubscribe(this.#qrKeyringStateListener); + /** + * Execute the given function after acquiring the controller lock + * and save the vault to state after it (only if needed), or rollback to their + * previous state in case of error. + * + * @param callback - The function to execute. + * @returns The result of the function. + */ + async #persistOrRollback( + callback: MutuallyExclusiveCallback, + ): Promise { + return this.#withRollback(async ({ releaseLock }) => { + const oldState = JSON.stringify(await this.#getSessionState()); + const callbackResult = await callback({ releaseLock }); + const newState = JSON.stringify(await this.#getSessionState()); + + // State is committed only if the operation is successful and need to trigger a vault update. + if (oldState !== newState) { + await this.#updateVault(); } + + return callbackResult; }); } /** - * Sync controller state with current keyring store - * and memStore states. + * Execute the given function after acquiring the controller lock + * and rollback keyrings and password states in case of error. + * + * @param callback - The function to execute atomically. + * @returns The result of the function. + */ + async #withRollback( + callback: MutuallyExclusiveCallback, + ): Promise { + return this.#withControllerLock(async ({ releaseLock }) => { + const currentSerializedKeyrings = await this.#getSerializedKeyrings(); + const currentEncryptionKey = cloneDeep(this.#encryptionKey); + + try { + return await callback({ releaseLock }); + } catch (error) { + // Keyrings and encryption credentials are restored to their previous state + this.#encryptionKey = currentEncryptionKey; + await this.#restoreSerializedKeyrings(currentSerializedKeyrings); + + throw error; + } + }); + } + + /** + * Assert that the controller mutex is locked. + * + * @throws If the controller mutex is not locked. + */ + #assertControllerMutexIsLocked(): void { + if (!this.#controllerOperationMutex.isLocked()) { + throw new KeyringControllerError( + KeyringControllerErrorMessage.ControllerLockRequired, + ); + } + } + + /** + * Check whether the given keyring entry is the primary keyring. * - * @fires KeyringController:stateChange + * The primary keyring is the first HD keyring in the given list. Both the + * position (index 0) and the keyring type are checked so that the definition + * of "primary" lives in one place and does not rely on positional index + * alone, which could misidentify the primary keyring in the event of a bug. + * + * @param entry - The keyring entry to check. + * @param keyrings - The list of keyring entries `entry` belongs to. + * @returns Whether the entry is the primary keyring. */ - #fullUpdate() { - const { vault } = this.#keyring.store.getState(); - const { keyrings, isUnlocked, encryptionKey, encryptionSalt } = - this.#keyring.memStore.getState(); + #isPrimaryKeyring(entry: KeyringEntry, keyrings: KeyringEntry[]): boolean { + return ( + keyrings[0] === entry && + entry.keyring.type === (KeyringTypes.hd as string) + ); + } - this.update(() => ({ - vault, - keyrings, - isUnlocked, - encryptionKey, - encryptionSalt, - })); + /** + * Assert that the given keyring entry is not the primary HD keyring. + * + * @param entry - The keyring entry to check. + * @param keyrings - The current list of keyring entries. + * @throws If the entry is the primary keyring. + */ + #assertNotRemovingPrimaryKeyring( + entry: KeyringEntry, + keyrings: KeyringEntry[], + ): void { + if (this.#isPrimaryKeyring(entry, keyrings)) { + throw new KeyringControllerError( + KeyringControllerErrorMessage.CannotRemovePrimaryKeyring, + ); + } } /** - * Handle keyring lock event. + * Lock the controller mutex before executing the given function, + * and release it after the function is resolved or after an + * error is thrown. + * + * This wrapper ensures that each mutable operation that interacts with the + * controller and that changes its state is executed in a mutually exclusive way, + * preventing unsafe concurrent access that could lead to unpredictable behavior. * - * @fires KeyringController:lock + * @param callback - The function to execute while the controller mutex is locked. + * @returns The result of the function. */ - #handleLock() { - this.messagingSystem.publish(`${name}:lock`); + async #withControllerLock( + callback: MutuallyExclusiveCallback, + ): Promise { + return withLock(this.#controllerOperationMutex, callback); } /** - * Handle keyring unlock event. + * Lock the vault mutex before executing the given function, + * and release it after the function is resolved or after an + * error is thrown. * - * @fires KeyringController:unlock + * This ensures that each operation that interacts with the vault + * is executed in a mutually exclusive way. + * + * @param callback - The function to execute while the vault mutex is locked. + * @returns The result of the function. */ - #handleUnlock() { - this.messagingSystem.publish(`${name}:unlock`); + async #withVaultLock( + callback: MutuallyExclusiveCallback, + ): Promise { + this.#assertControllerMutexIsLocked(); + + return withLock(this.#vaultOperationMutex, callback); } +} - #getMemState(): KeyringControllerMemState { - return { - isUnlocked: this.state.isUnlocked, - keyrings: this.state.keyrings, - }; +/** + * Lock the given mutex before executing the given function, + * and release it after the function is resolved or after an + * error is thrown. + * + * @param mutex - The mutex to lock. + * @param callback - The function to execute while the mutex is locked. + * @returns The result of the function. + */ +async function withLock( + mutex: Mutex, + callback: MutuallyExclusiveCallback, +): Promise { + const releaseLock = await mutex.acquire(); + + try { + return await callback({ releaseLock }); + } finally { + releaseLock(); } } +/** + * Generate a new keyring metadata object. + * + * @returns Keyring metadata. + */ +function getDefaultKeyringMetadata(): KeyringMetadata { + return { id: ulid(), name: '' }; +} + export default KeyringController; diff --git a/packages/keyring-controller/src/constants.ts b/packages/keyring-controller/src/constants.ts new file mode 100644 index 00000000000..f97cdd646cb --- /dev/null +++ b/packages/keyring-controller/src/constants.ts @@ -0,0 +1,43 @@ +export enum KeyringControllerErrorMessage { + NoKeyring = 'KeyringController - No keyring found', + KeyringNotFound = 'KeyringController - Keyring not found.', + UnsafeDirectKeyringAccess = 'KeyringController - Returning keyring instances is unsafe', + WrongPasswordType = 'KeyringController - Password must be of type string.', + WrongEncryptionKeyType = 'KeyringController - Encryption key must be of type string.', + InvalidEmptyPassword = 'KeyringController - Password cannot be empty.', + NoFirstAccount = 'KeyringController - First Account not found.', + DuplicatedAccount = 'KeyringController - The account you are trying to import is a duplicate', + VaultError = 'KeyringController - Cannot unlock without a previous vault.', + VaultDataError = 'KeyringController - The decrypted vault has an unexpected shape.', + UnsupportedEncryptionKeyExport = 'KeyringController - The encryptor does not support encryption key export.', + UnsupportedGenerateRandomMnemonic = 'KeyringController - The current keyring does not support the method generateRandomMnemonic.', + UnsupportedExportAccount = '`KeyringController - The keyring for the current address does not support the method exportAccount', + UnsupportedRemoveAccount = '`KeyringController - The keyring for the current address does not support the method removeAccount', + UnsupportedSignTransaction = 'KeyringController - The keyring for the current address does not support the method signTransaction.', + UnsupportedSignMessage = 'KeyringController - The keyring for the current address does not support the method signMessage.', + UnsupportedSignPersonalMessage = 'KeyringController - The keyring for the current address does not support the method signPersonalMessage.', + UnsupportedSignEip7702Authorization = 'KeyringController - The keyring for the current address does not support the method signEip7702Authorization.', + UnsupportedGetEncryptionPublicKey = 'KeyringController - The keyring for the current address does not support the method getEncryptionPublicKey.', + UnsupportedDecryptMessage = 'KeyringController - The keyring for the current address does not support the method decryptMessage.', + UnsupportedSignTypedMessage = 'KeyringController - The keyring for the current address does not support the method signTypedMessage.', + UnsupportedGetAppKeyAddress = 'KeyringController - The keyring for the current address does not support the method getAppKeyAddress.', + UnsupportedExportAppKeyForAddress = 'KeyringController - The keyring for the current address does not support the method exportAppKeyForAddress.', + UnsupportedPrepareUserOperation = 'KeyringController - The keyring for the current address does not support the method prepareUserOperation.', + UnsupportedPatchUserOperation = 'KeyringController - The keyring for the current address does not support the method patchUserOperation.', + UnsupportedSignUserOperation = 'KeyringController - The keyring for the current address does not support the method signUserOperation.', + UnsupportedVerifySeedPhrase = 'KeyringController - The keyring does not support the method verifySeedPhrase.', + MissingEip7702AuthorizationContractAddress = 'KeyringController - The EIP-7702 Authorization is invalid. No contract address provided.', + NoAccountOnKeychain = "KeyringController - The keychain doesn't have accounts.", + ControllerLocked = 'KeyringController - The operation cannot be completed while the controller is locked.', + MissingCredentials = 'KeyringController - Cannot persist vault without password and encryption key', + MissingVaultData = 'KeyringController - Cannot persist vault without vault information', + ExpiredCredentials = 'KeyringController - Encryption key and salt provided are expired', + NoKeyringBuilder = 'KeyringController - No keyringBuilder found for keyring', + DataType = 'KeyringController - Incorrect data type provided', + NoHdKeyring = 'KeyringController - No HD Keyring found', + ControllerLockRequired = 'KeyringController - attempt to update vault during a non mutually exclusive operation', + LastAccountInPrimaryKeyring = 'KeyringController - Last account in primary keyring cannot be removed', + EncryptionKeyNotSet = 'KeyringController - Encryption key not set', + KeyringV2NotSupported = 'KeyringController - The selected keyring does not support the KeyringV2 API.', + CannotRemovePrimaryKeyring = 'KeyringController - Cannot remove the primary keyring', +} diff --git a/packages/keyring-controller/src/errors.test.ts b/packages/keyring-controller/src/errors.test.ts new file mode 100644 index 00000000000..21426f3d5dc --- /dev/null +++ b/packages/keyring-controller/src/errors.test.ts @@ -0,0 +1,69 @@ +import { KeyringControllerErrorMessage } from './constants.js'; +import { + isKeyringControllerError, + isKeyringNotFoundError, + KeyringControllerError, +} from './errors.js'; + +describe('isKeyringControllerError', () => { + it('returns true for a KeyringControllerError', () => { + const error = new KeyringControllerError( + KeyringControllerErrorMessage.KeyringNotFound, + ); + expect(isKeyringControllerError(error)).toBe(true); + }); + + it('returns true for an error from another version of the package (duck-typing)', () => { + const error = Object.assign(new Error('some message'), { + name: 'KeyringControllerError', + }); + expect(isKeyringControllerError(error)).toBe(true); + }); + + it('returns false for a plain Error', () => { + expect(isKeyringControllerError(new Error('oops'))).toBe(false); + }); + + it('returns false for a non-error value', () => { + expect(isKeyringControllerError('not an error')).toBe(false); + expect(isKeyringControllerError(null)).toBe(false); + expect(isKeyringControllerError(undefined)).toBe(false); + }); +}); + +describe('isKeyringNotFoundError', () => { + it('returns true for a KeyringControllerError with the KeyringNotFound message', () => { + const error = new KeyringControllerError( + KeyringControllerErrorMessage.KeyringNotFound, + ); + expect(isKeyringNotFoundError(error)).toBe(true); + }); + + it('returns true for an error from another version of the package (duck-typing)', () => { + const error = Object.assign( + new Error(KeyringControllerErrorMessage.KeyringNotFound), + { + name: 'KeyringControllerError', + }, + ); + expect(isKeyringNotFoundError(error)).toBe(true); + }); + + it('returns false for a KeyringControllerError with a different message', () => { + const error = new KeyringControllerError( + KeyringControllerErrorMessage.NoKeyring, + ); + expect(isKeyringNotFoundError(error)).toBe(false); + }); + + it('returns false for a plain Error', () => { + const error = new Error(KeyringControllerErrorMessage.KeyringNotFound); + expect(isKeyringNotFoundError(error)).toBe(false); + }); + + it('returns false for a non-error value', () => { + expect(isKeyringNotFoundError('not an error')).toBe(false); + expect(isKeyringNotFoundError(null)).toBe(false); + expect(isKeyringNotFoundError(undefined)).toBe(false); + }); +}); diff --git a/packages/keyring-controller/src/errors.ts b/packages/keyring-controller/src/errors.ts new file mode 100644 index 00000000000..e080719aa15 --- /dev/null +++ b/packages/keyring-controller/src/errors.ts @@ -0,0 +1,172 @@ +import { KeyringControllerErrorMessage } from './constants.js'; + +/** + * Options for creating a KeyringControllerError. + */ +export type KeyringControllerErrorOptions = { + /** + * The underlying error that caused this error (for error chaining). + * Uses the standard Error.cause property (ES2022). + */ + cause?: Error; + /** + * Optional error code for programmatic error handling. + * This can be used to identify specific error types without string matching. + */ + code?: string; + /** + * Additional context data associated with the error. + * Useful for debugging and error reporting. + */ + context?: Record; +}; + +/** + * Error class for KeyringController-related errors. + * + * This error class extends the standard Error class and supports: + * - Error chaining via the `cause` property (ES2022 standard) + * - Optional error codes for programmatic error handling + * - Additional context data for debugging + * - Backward compatibility with the legacy `originalError` property + */ +export class KeyringControllerError extends Error { + /** + * Optional error code for programmatic error handling. + */ + code?: string; + + /** + * Additional context data associated with the error. + */ + context?: Record; + + /** + * The underlying error that caused this error (ES2022 standard). + * This is set manually for compatibility with older TypeScript versions. + */ + cause?: Error; + + /** + * @deprecated Use `cause` instead. This property is maintained for backward compatibility. + */ + originalError?: Error; + + /** + * Creates a new KeyringControllerError. + * + * @param message - The error message. + * @param options - Error options or an Error object for backward compatibility. + */ + constructor( + message: string, + options?: KeyringControllerErrorOptions | Error, + ) { + super(message); + this.name = 'KeyringControllerError'; + + // Support both new signature (options object) and legacy signature (Error as second param) + const cause = options instanceof Error ? options : options?.cause; + const code = options instanceof Error ? undefined : options?.code; + const context = options instanceof Error ? undefined : options?.context; + + // Set cause property for error chaining (ES2022 standard) + if (cause) { + this.cause = cause; + // Maintain backward compatibility with originalError + this.originalError = cause; + } + + // Set code and data if provided + if (code) { + this.code = code; + } + if (context) { + this.context = context; + } + + // Ensure proper prototype chain for instanceof checks + Object.setPrototypeOf(this, KeyringControllerError.prototype); + } + + /** + * Returns a JSON representation of the error. + * Useful for logging and error reporting. + * + * @returns JSON representation of the error. + */ + toJSON(): Record { + return { + name: this.name, + message: this.message, + code: this.code, + context: this.context, + stack: this.stack, + cause: this.cause + ? { + name: this.cause.name, + message: this.cause.message, + stack: this.cause.stack, + } + : undefined, + }; + } + + /** + * Returns a string representation of the error chain. + * Includes all chained errors for better debugging. + * + * @returns String representation of the error chain. + */ + toString(): string { + let result = `${this.name}: ${this.message}`; + + if (this.code) { + result += ` [${this.code}]`; + } + + if (this.cause) { + result += `\n Caused by: ${this.cause}`; + } + + return result; + } +} + +/** + * Returns `true` if the error is a `KeyringControllerError`. + * + * Uses duck-typing on `error.name` rather than `instanceof` so that the check + * remains correct when multiple major versions of `@metamask/keyring-controller` + * coexist in the dependency tree (different versions produce different classes, + * so `instanceof` would return `false` for errors from another version). + * + * @param error - The value to check. + * @returns Whether the error is a `KeyringControllerError`. + */ +export function isKeyringControllerError( + error: unknown, +): error is KeyringControllerError { + return ( + typeof error === 'object' && + error !== null && + (error as { name?: unknown }).name === 'KeyringControllerError' + ); +} + +/** + * Returns `true` if the error is a `KeyringNotFound` error thrown by + * `KeyringController:withKeyring`. Use this to distinguish a missing keyring + * from other failures and apply fallback logic. + * + * @param error - The value to check. + * @returns Whether the error is a `KeyringNotFound` error. + */ +export function isKeyringNotFoundError( + error: unknown, +): error is KeyringControllerError { + return ( + isKeyringControllerError(error) && + error.message === KeyringControllerErrorMessage.KeyringNotFound + ); +} diff --git a/packages/keyring-controller/src/index.ts b/packages/keyring-controller/src/index.ts index 9b98ad6fd7a..f1394113e80 100644 --- a/packages/keyring-controller/src/index.ts +++ b/packages/keyring-controller/src/index.ts @@ -1 +1,41 @@ -export * from './KeyringController'; +export * from './KeyringController.js'; +export type { + KeyringControllerAddNewAccountAction, + KeyringControllerCreateNewVaultAndRestoreAction, + KeyringControllerCreateNewVaultAndKeychainAction, + KeyringControllerAddNewKeyringAction, + KeyringControllerIsUnlockedAction, + KeyringControllerGetAccountsAction, + KeyringControllerGetEncryptionPublicKeyAction, + KeyringControllerDecryptMessageAction, + KeyringControllerGetKeyringForAccountAction, + KeyringControllerGetKeyringsByTypeAction, + KeyringControllerPersistAllKeyringsAction, + KeyringControllerRemoveAccountAction, + KeyringControllerSignMessageAction, + KeyringControllerSignEip7702AuthorizationAction, + KeyringControllerSignPersonalMessageAction, + KeyringControllerSignTransactionAction, + KeyringControllerSignTypedMessageAction, + KeyringControllerPrepareUserOperationAction, + KeyringControllerPatchUserOperationAction, + KeyringControllerSignUserOperationAction, + KeyringControllerWithControllerAction, + KeyringControllerWithKeyringAction, + KeyringControllerWithKeyringUnsafeAction, + KeyringControllerWithKeyringV2Action, + KeyringControllerWithKeyringV2UnsafeAction, + KeyringControllerExportSeedPhraseAction, + KeyringControllerVerifyPasswordAction, + KeyringControllerExportAccountAction, + KeyringControllerImportAccountWithStrategyAction, + KeyringControllerSetLockedAction, + KeyringControllerChangePasswordAction, + KeyringControllerSubmitEncryptionKeyAction, + KeyringControllerExportEncryptionKeyAction, + KeyringControllerSubmitPasswordAction, + KeyringControllerGetAccountKeyringTypeAction, +} from './KeyringController-method-action-types.js'; +export type * from './types.js'; +export * from './errors.js'; +export { KeyringControllerErrorMessage } from './constants.js'; diff --git a/packages/keyring-controller/src/types.ts b/packages/keyring-controller/src/types.ts new file mode 100644 index 00000000000..c58def8ce37 --- /dev/null +++ b/packages/keyring-controller/src/types.ts @@ -0,0 +1,82 @@ +import type { SIWEMessage } from '@metamask/controller-utils'; + +/** + * AbstractMessageParams + * + * Represents the parameters to pass to the signing method once the signature request is approved. + * + * from - Address from which the message is processed + * origin? - Added for request origin identification + * requestId? - Original request id + * deferSetAsSigned? - Whether to defer setting the message as signed immediately after the keyring is told to sign it + */ +export type AbstractMessageParams = { + from: string; + origin?: string; + requestId?: number; + deferSetAsSigned?: boolean; +}; + +/** + * Eip7702AuthorizationParams + * + * Represents the parameters for EIP-7702 authorization signing requests. + * + * chainId - The chain ID + * contractAddress - The contract address + * nonce - The nonce + */ +export type Eip7702AuthorizationParams = { + chainId: number; + contractAddress: string; + nonce: number; +} & AbstractMessageParams; + +/** + * PersonalMessageParams + * + * Represents the parameters for personal signing messages. + * + * data - The data to sign + * siwe? - The SIWE message + */ +export type PersonalMessageParams = { + data: string; + siwe?: SIWEMessage; +} & AbstractMessageParams; + +/** + * SignTypedDataMessageV3V4 + * + * Represents the structure of a typed data message for EIP-712 signing requests. + * + * types - The types of the message + * domain - The domain of the message + * primaryType - The primary type of the message + * message - The message + */ +export type SignTypedDataMessageV3V4 = { + types: Record; + domain: Record; + primaryType: string; + message: unknown; +}; + +/** + * TypedMessageParams + * + * Represents the parameters for typed signing messages. + * + * data - The data to sign + */ +export type TypedMessageParams = { + data: Record[] | string | SignTypedDataMessageV3V4; +} & AbstractMessageParams; + +/** + * Credentials for re-authenticating the keyring during sensitive operations + * such as `exportSeedPhrase` and `exportAccount`. + */ +export type Credentials = + | { password: string } + | { encryptionKey: string; encryptionSalt?: string }; diff --git a/packages/keyring-controller/tests/mocks/mockEncryptor.ts b/packages/keyring-controller/tests/mocks/mockEncryptor.ts index 6a8ee18d9b0..af97c8d3e5c 100644 --- a/packages/keyring-controller/tests/mocks/mockEncryptor.ts +++ b/packages/keyring-controller/tests/mocks/mockEncryptor.ts @@ -1,55 +1,141 @@ -const mockHex = '0xabcdef0123456789'; -export const mockKey = Buffer.alloc(32); -let cacheVal: any; +// Omitting jsdoc because mock is only internal and simple enough. -export default class MockEncryptor { - async encrypt(password: string, dataObj: any) { +import type { + DetailedDecryptResult, + DetailedEncryptionResult, + EncryptionResult, +} from '@metamask/browser-passworder'; +import type { Json } from '@metamask/utils'; +import { isEqual } from 'lodash'; + +import type { Encryptor } from '../../src/KeyringController.js'; + +export const PASSWORD = 'password123'; +export const SALT = 'salt'; +export const MOCK_ENCRYPTION_KEY = JSON.stringify({ + password: PASSWORD, + salt: SALT, +}); +export const MOCK_KEY = Buffer.alloc(32); + +export const DECRYPTION_ERROR = 'Decryption failed.'; + +function deriveKey( + password: string, + salt: string, +): { password: string; salt: string } { + return { + password, + salt, + }; +} + +export default class MockEncryptor implements Encryptor { + async encrypt(password: string, dataObj: Json): Promise { + const salt = this.generateSalt(); + const key = deriveKey(password, salt); + const result = await this.encryptWithKey(key, dataObj); return JSON.stringify({ - ...this.encryptWithKey(password, dataObj), - salt: this.generateSalt(), + ...result, + salt, }); } - async decrypt(_password: string, _text: string) { - return cacheVal || {}; + async decrypt(password: string, text: string): Promise { + const payload = JSON.parse(text); + const key = deriveKey(password, payload.salt); + return await this.decryptWithKey(key, payload); } - async encryptWithKey(_key: string, dataObj: any) { - cacheVal = dataObj; + async encryptWithDetail( + password: string, + dataObj: Json, + salt?: string, + ): Promise { + const _salt = salt ?? this.generateSalt(); + const key = deriveKey(password, _salt); + const result = await this.encryptWithKey(key, dataObj); return { - data: mockHex, - iv: 'anIv', + vault: JSON.stringify({ + ...result, + salt: _salt, + }), + exportedKeyString: JSON.stringify(key), }; } - async encryptWithDetail(key: string, dataObj: any) { + async decryptWithDetail( + password: string, + text: string, + ): Promise { + const payload = JSON.parse(text); + const key = deriveKey(password, payload.salt); return { - vault: await this.encrypt(key, dataObj), - exportedKeyString: mockKey.toString('hex'), + vault: await this.decryptWithKey(key, payload), + salt: payload.salt, + exportedKeyString: JSON.stringify(key), }; } - async decryptWithDetail(key: string, text: string) { + async encryptWithKey(key: unknown, dataObj: Json): Promise { + const iv = generateIV(); return { - vault: await this.decrypt(key, text), - salt: this.generateSalt(), - exportedKeyString: mockKey.toString('hex'), + data: JSON.stringify({ + tag: { key, iv }, + value: dataObj, + }), + iv, }; } - async decryptWithKey(key: string, text: string) { - return this.decrypt(key, text); + async decryptWithKey( + key: unknown, + ciphertext: EncryptionResult, + ): Promise { + // This conditional assignment is required because sometimes the keyring + // controller passes in the parsed object instead of the string. + const ciphertextObj = + typeof ciphertext === 'string' ? JSON.parse(ciphertext) : ciphertext; + const data = JSON.parse(ciphertextObj.data); + if (!isEqual(data.tag, { key, iv: ciphertextObj.iv })) { + throw new Error(DECRYPTION_ERROR); + } + return data.value; + } + + async keyFromPassword(_password: string, _salt: string): Promise { + return JSON.parse(MOCK_ENCRYPTION_KEY); } - async keyFromPassword(_password: string) { - return mockKey; + async importKey(key: string): Promise { + return JSON.parse(key); } - async importKey(_key: string) { - return {}; + async exportKey(key: unknown): Promise { + return JSON.stringify(key); } - generateSalt() { - return 'WHADDASALT!'; + async updateVault(_vault: string, _password: string): Promise { + return _vault; } + + generateSalt(): string { + return SALT; + } + + isVaultUpdated(_vault: string): boolean { + return true; + } + + asEncryptor(): Encryptor { + // This mock is not using the right crypto types, but that's ok for the tests. + return this as unknown as Encryptor; + } +} + +function generateIV(): string { + // Generate random salt. + + // return crypto.randomUUID(); + return 'iv'; // TODO some tests rely on fixed iv, but wouldn't it be better to generate random value here? } diff --git a/packages/keyring-controller/tests/mocks/mockErc4337Keyring.ts b/packages/keyring-controller/tests/mocks/mockErc4337Keyring.ts new file mode 100644 index 00000000000..c56d7f7cae2 --- /dev/null +++ b/packages/keyring-controller/tests/mocks/mockErc4337Keyring.ts @@ -0,0 +1,30 @@ +import type { EthKeyring } from '@metamask/keyring-internal-api'; +import type { Hex, Json } from '@metamask/utils'; + +export class MockErc4337Keyring implements EthKeyring { + static type = 'ERC-4337 Keyring'; + + public type = MockErc4337Keyring.type; + + async serialize(): Promise { + return {}; + } + + async deserialize(): Promise { + // Empty + } + + async getAccounts(): Promise { + return []; + } + + async addAccounts(_: number): Promise { + return []; + } + + prepareUserOperation = jest.fn(); + + patchUserOperation = jest.fn(); + + signUserOperation = jest.fn(); +} diff --git a/packages/keyring-controller/tests/mocks/mockHardwareKeyring.ts b/packages/keyring-controller/tests/mocks/mockHardwareKeyring.ts new file mode 100644 index 00000000000..bfaf3ae14f8 --- /dev/null +++ b/packages/keyring-controller/tests/mocks/mockHardwareKeyring.ts @@ -0,0 +1,43 @@ +import type { Hex } from '@metamask/utils'; + +export class HardwareWalletError extends Error { + code: string; + + constructor(message: string, code: string) { + super(message); + this.name = 'HardwareWalletError'; + this.code = code; + } +} + +/** + * Mock hardware keyring that supports signTypedData but throws an error. + */ +export class MockHardwareKeyring { + static type = 'Mock Hardware'; + + type = 'Mock Hardware'; + + async getAccounts(): Promise { + return ['0x9876543210987654321098765432109876543210']; + } + + async signTypedData( + _address: Hex, + _data: unknown, + _opts: unknown, + ): Promise { + throw new HardwareWalletError( + 'User rejected the request on hardware device', + 'USER_REJECTED', + ); + } + + serialize = async (): Promise<{ type: string }> => ({ + type: this.type, + }); + + deserialize = async (_opts: unknown): Promise => { + // noop + }; +} diff --git a/packages/keyring-controller/tests/mocks/mockKeyring.ts b/packages/keyring-controller/tests/mocks/mockKeyring.ts new file mode 100644 index 00000000000..89bff0f1abd --- /dev/null +++ b/packages/keyring-controller/tests/mocks/mockKeyring.ts @@ -0,0 +1,39 @@ +import type { EthKeyring } from '@metamask/keyring-internal-api'; +import type { Hex, Json } from '@metamask/utils'; + +export class MockKeyring implements EthKeyring { + static type = 'Mock Keyring'; + + public type = 'Mock Keyring'; + + readonly #accounts: Hex[] = []; + + constructor(options: Record | undefined = {}) { + // eslint-disable-next-line @typescript-eslint/no-floating-promises + this.deserialize(options); + } + + async init(): Promise { + return Promise.resolve(); + } + + async addAccounts(_: number): Promise { + return Promise.resolve(this.#accounts); + } + + async getAccounts(): Promise { + return Promise.resolve(this.#accounts); + } + + async serialize(): Promise { + return Promise.resolve({}); + } + + async deserialize(_: unknown): Promise { + return Promise.resolve(); + } + + async destroy(): Promise { + return Promise.resolve(); + } +} diff --git a/packages/keyring-controller/tests/mocks/mockShallowGetAccountsKeyring.ts b/packages/keyring-controller/tests/mocks/mockShallowGetAccountsKeyring.ts deleted file mode 100644 index 7c2dc230ed9..00000000000 --- a/packages/keyring-controller/tests/mocks/mockShallowGetAccountsKeyring.ts +++ /dev/null @@ -1,51 +0,0 @@ -import type { Keyring, Json, Hex } from '@metamask/utils'; - -/** - * A test keyring that returns a shallow copy of the accounts array - * when calling getAccounts(). - * - * This is used to test the `KeyringController`'s behavior when using this - * keyring, to make sure that, for example, the keyring's - * accounts array is not not used to determinate the added account after - * an operation. - */ -export default class MockShallowGetAccountsKeyring implements Keyring { - static type = 'Mock Shallow getAccounts Keyring'; - - public type = MockShallowGetAccountsKeyring.type; - - public accounts: Hex[]; - - constructor(_: any) { - this.accounts = []; - } - - async serialize(): Promise { - return {}; - } - - async deserialize(state: { accounts: Hex[] }) { - if (state) { - this.accounts = state.accounts || []; - } - } - - /** - * This method returns a shallow copy of the accounts array. - * This means that when mutating the internal account array, the - * array returned by this method could be also mutated, and vice-versa. - * - * @returns a shallow copy of the accounts array - */ - async getAccounts(): Promise { - // Shallow copy - return this.accounts; - } - - // this fake method works only with n = 1 - async addAccounts(_: number): Promise { - const newAddress = `0x68612830F5E3e285E8EAcc06f19a31aEB446C5Ee`; - this.accounts.push(newAddress); - return [newAddress]; - } -} diff --git a/packages/keyring-controller/tests/mocks/mockShallowKeyring.ts b/packages/keyring-controller/tests/mocks/mockShallowKeyring.ts new file mode 100644 index 00000000000..ae8f7365902 --- /dev/null +++ b/packages/keyring-controller/tests/mocks/mockShallowKeyring.ts @@ -0,0 +1,57 @@ +import type { EthKeyring } from '@metamask/keyring-internal-api'; +import type { Json, Hex } from '@metamask/utils'; + +/** + * A test keyring that returns a shallow copy of the accounts array + * when calling `getAccounts()` and `serialize()`. + * + * This is used to test the `KeyringController`'s behavior when using this + * keyring, to make sure that, for example, the keyring's + * accounts array is not not used to determinate the added account after + * an operation. + */ +export default class MockShallowKeyring implements EthKeyring { + static type = 'Mock Shallow Keyring'; + + public type = MockShallowKeyring.type; + + public accounts: Hex[]; + + // TODO: Replace `any` with type + // eslint-disable-next-line @typescript-eslint/no-explicit-any + constructor(_: any) { + this.accounts = []; + } + + async serialize(): Promise { + return { + // Shallow copy + accounts: this.accounts, + }; + } + + async deserialize(state: { accounts: Hex[] }): Promise { + if (state) { + this.accounts = state.accounts || []; + } + } + + /** + * This method returns a shallow copy of the accounts array. + * This means that when mutating the internal account array, the + * array returned by this method could be also mutated, and vice-versa. + * + * @returns a shallow copy of the accounts array + */ + async getAccounts(): Promise { + // Shallow copy + return this.accounts; + } + + // this fake method works only with n = 1 + async addAccounts(_: number): Promise { + const newAddress = `0x68612830F5E3e285E8EAcc06f19a31aEB446C5Ee`; + this.accounts.push(newAddress); + return [newAddress]; + } +} diff --git a/packages/keyring-controller/tests/mocks/mockTransaction.ts b/packages/keyring-controller/tests/mocks/mockTransaction.ts new file mode 100644 index 00000000000..290ab426965 --- /dev/null +++ b/packages/keyring-controller/tests/mocks/mockTransaction.ts @@ -0,0 +1,21 @@ +import { TransactionFactory } from '@ethereumjs/tx'; +import type { TypedTransaction, TypedTxData } from '@ethereumjs/tx'; + +/** + * Build a mock transaction, optionally overriding + * any of the default values. + * + * @param options - The transaction options to override. + * @returns The mock transaction. + */ +export const buildMockTransaction = ( + options: TypedTxData = {}, +): TypedTransaction => + TransactionFactory.fromTxData({ + to: '0xB1A13aBECeB71b2E758c7e0Da404DF0C72Ca3a12', + value: '0x0', + data: '0x', + gasPrice: '0x0', + nonce: '0x0', + ...options, + }); diff --git a/packages/keyring-controller/tsconfig.build.json b/packages/keyring-controller/tsconfig.build.json index 093088e762a..df01f3c175b 100644 --- a/packages/keyring-controller/tsconfig.build.json +++ b/packages/keyring-controller/tsconfig.build.json @@ -13,10 +13,7 @@ "path": "../controller-utils/tsconfig.build.json" }, { - "path": "../message-manager/tsconfig.build.json" - }, - { - "path": "../preferences-controller/tsconfig.build.json" + "path": "../messenger/tsconfig.build.json" } ], "include": ["../../types", "./src"] diff --git a/packages/keyring-controller/tsconfig.json b/packages/keyring-controller/tsconfig.json index 8d413c26528..7d7c67c579c 100644 --- a/packages/keyring-controller/tsconfig.json +++ b/packages/keyring-controller/tsconfig.json @@ -11,10 +11,7 @@ "path": "../controller-utils" }, { - "path": "../message-manager" - }, - { - "path": "../preferences-controller" + "path": "../messenger" } ], "include": ["../../types", "./src", "./tests"] diff --git a/packages/kyc-controller/ARCHITECTURE.md b/packages/kyc-controller/ARCHITECTURE.md new file mode 100644 index 00000000000..c81b079e0dd --- /dev/null +++ b/packages/kyc-controller/ARCHITECTURE.md @@ -0,0 +1,714 @@ +## Architecture + +`@metamask/kyc-controller` is a shared, **platform-agnostic** package that owns +the end-to-end KYC / identity-verification flow used across MetaMask clients +(mobile, extension, web). It hides the vendor implementation (currently +**MoonPay** for identity + **SumSub** for document verification) behind a +vendor-neutral, per-product surface consumed by features such as **ramps** and +**card**. + +This document explains: + +- The package's internal building blocks and responsibilities. +- How the pieces communicate (messenger actions, injected adapters). +- The identity flow as a state machine and an end-to-end sequence. +- The encrypted frame message protocol and crypto. +- How the **metamask-mobile** client wires everything together on the client + side. + +--- + +### 1. Design principles + +The package is built around a few deliberate constraints: + +| Principle | How it shows up in the code | +| ----------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| **Vendor-neutral surface** | Consumers deal with `KycProduct` (`'ramps' \| 'card' \| 'money'`) and a phase machine. Identity vendor is a parameterized `KycVendor` (`initialize({ vendor })`), not vendor-branded public methods. | +| **Platform-agnostic core** | No React, no `Buffer`/`atob`, no native SDK imports. Crypto uses `@noble/*` + `@scure/base`. WebView/iframe presentation and the SumSub SDK are **injected** by each client. | +| **Controller owns orchestration; clients own presentation** | `KycController` owns all state, HTTP orchestration, crypto and the frame protocol. Clients only render frames, forward raw messages, and present the SumSub SDK. | +| **Stateless service** | `KycService` performs HTTP only; it holds no state and derives auth/geolocation from other controllers via the messenger. | +| **Everything through the messenger** | Both classes register their public methods as messenger actions, and reach external capabilities (auth token, geolocation) via delegated actions. | + +--- + +### 2. Component overview + +The package splits cleanly into a **stateful orchestrator** (`KycController`), a +**stateless HTTP client** (`KycService`), and supporting modules (crypto, +selectors, types). + +```mermaid +graph TB + subgraph pkg["@metamask/kyc-controller"] + direction TB + Controller["KycController
(BaseController)
state + orchestration + frame protocol"] + Service["KycService
(stateless)
HTTP + response validation"] + Crypto["crypto.ts
X25519 ECDH + AES-256-GCM"] + Selectors["selectors.ts
memoized reselect selectors"] + Types["types.ts
KycPhase, KycProduct,
KycSumSubLauncher, ..."] + Country["countryCodes.ts
alpha-2 → alpha-3"] + end + + subgraph deps["External MetaMask dependencies"] + Base["@metamask/base-controller"] + Msgr["@metamask/messenger"] + CU["@metamask/controller-utils
createServicePolicy, HttpError"] + Geo["GeolocationController"] + Auth["AuthenticationController
(profile-sync)"] + end + + subgraph vendor["Vendor backends (HTTP / frames)"] + UKYC["Universal KYC API
kyc-api.cx.metamask.io"] + Frames["MoonPay frames
blocks.moonpay.com"] + SumSubSDK["SumSub SDK
(native / web)"] + end + + Controller -->|"decryptCredentials()"| Crypto + Controller -->|"messenger.call(KycService:*)"| Service + Controller -.->|"injected launcher"| SumSubSDK + Controller -->|"builds frame URLs
handles frame messages"| Frames + + Service -->|"createServicePolicy / HttpError"| CU + Service -->|"messenger.call(GeolocationController:getGeolocation)"| Geo + Service -->|"messenger.call(AuthenticationController:getBearerToken)"| Auth + Service -->|"fetch()"| UKYC + + Controller --- Base + Controller --- Msgr + Service --- Msgr + Selectors -.->|"read"| Controller +``` + +#### 2.1 `KycController` + +- Extends `BaseController<'KycController', KycControllerState, KycControllerMessenger>`. +- Holds **all flow state** (see [§3](#3-state-shape)). +- Owns an ephemeral **X25519 keypair** (`#keypair`) generated at construction — + never persisted, used only for the frame key exchange. +- Registers its public methods as messenger actions via + `registerMethodActionHandlers`. +- Calls `KycService` exclusively **through the messenger** (`KycService:*` + actions), never a direct reference. +- Delegates SumSub SDK presentation to an injected `sumsubLauncher` + (`KycSumSubLauncher`). +- When the flow is scoped to a product (passed to `initialize` / + `acceptTermsAndStartSession` and stored as `activeProduct`), automatically + runs the KYC-required check once authenticated and chains into document + verification when KYC is required — no extra consumer calls needed. + +Exposed messenger actions (`MESSENGER_EXPOSED_METHODS`): + +`initialize`, `loadDisclaimers`, `acceptTermsAndStartSession`, +`createVendorCustomer`, `clearSavedTerms`, `handleFrameMessage`, +`buildCheckFrameUrl`, `buildAuthFrameUrl`, `buildResetFrameUrl`, +`checkKycRequired`, `getKycStatus`, `getCustomerIdentity`, `refreshKycStatus`, +`startSumSub`, `reset`. + +#### 2.2 `KycService` + +- **Stateless**, platform-agnostic HTTP client for the Universal KYC (UKYC) + backend. +- Base URL derived from `env` (`production` / `development`) or an explicit + `baseUrl` override. +- Every request is wrapped in a **service policy** (`createServicePolicy`) for + retries/circuit-breaking, and carries a **bearer token** obtained from + `AuthenticationController:getBearerToken`. +- Every response is validated with **superstruct** before being returned; + malformed responses throw a descriptive error. +- Resolves the customer's country from `GeolocationController:getGeolocation` + and maps alpha-2 → alpha-3. + +Exposed messenger actions (`MESSENGER_EXPOSED_METHODS`): + +`getGeoCountry`, `fetchDisclaimers`, `createSession`, `checkKycRequired`, +`createVendorCustomer`, `submitVendorDisclaimers`, `fetchSessionDisclaimers`, `submitSessionDisclaimers`, +`fetchKycStatus`, `fetchIdosEnclaveJwks`, `fetchIdosRelayJwks`, `createUkycSession`, `setAuthorizations`, +`createJourney`, `getSessionStatus`. + +Endpoints: + +| Method | HTTP | Endpoint | Purpose | +| -------------------------- | ------ | -------------------------------------------- | -------------------------------------------------------------------------------------- | +| `getGeoCountry` | — | (geolocation action) | Resolve alpha-3 country | +| `fetchDisclaimers` | `GET` | `/vendors/{vendor}/disclaimers?country=` | Vendor T&Cs to accept (`vendor` defaults to `moonpay`) | +| `createSession` | `POST` | `/vendors/moonpay/sessions` | Create MoonPay vendor session | +| `checkKycRequired` | `POST` | `/vendors/{vendor}/kyc-required` | Is KYC required? (normalizes `required` → `kycRequired`) | +| `createVendorCustomer` | `POST` | `/vendors/{vendor}/customers` | Create or resume an empty-shell vendor customer | +| `submitVendorDisclaimers` | `POST` | `/vendors/{vendor}/disclaimers` | Record vendor T&C signings (`disclaimerIds`) | +| `fetchSessionDisclaimers` | `GET` | `/sessions/{id}/disclaimers` | Session-scoped idOS + KYC-provider catalog | +| `submitSessionDisclaimers` | `POST` | `/sessions/{id}/disclaimers` | Record `{ idOS, kycProvider, credentialReusabilityConsentGiven }` consents | +| `fetchKycStatus` | `GET` | `/kyc/status` | User-keyed simplified KYC status | +| `fetchIdosEnclaveJwks` | `GET` | `{idosEnclaveBaseUrl}/.well-known/jwks.json` | idOS enclave JWKS for `encryptionDataKey` attestation | +| `fetchIdosRelayJwks` | `GET` | `{idosRelayBaseUrl}/.well-known/jwks.json` | idOS relay JWKS for `ukycCapabilityToken` attestation | +| `createUkycSession` | `POST` | `/sessions` | Start SumSub sub-flow; registers session client public key; returns encryption schemas | +| `setAuthorizations` | `POST` | `/sessions/{id}/authorizations` | Submit wrapped `data_encryption_key` and wrapped `ukyc_capability_token` | +| `createJourney` | `POST` | `/sessions/{id}/journey` | Create verification journey → applicant token | + +### 2.3 `crypto.ts` + +Implements the Check/Auth frame credential decryption: + +1. Client generates an X25519 keypair; the public key (hex) is added to the + frame URL. +2. The frame returns `{ ephemeralPublicKey, iv|nonce, ciphertext }`. +3. Client derives `shared = X25519(ourPriv, theirEphemeralPub)`, then + `key = HKDF-SHA256(shared, 32 bytes)`, then AES-256-GCM decrypts the + ciphertext (which includes the 16-byte tag). IV must be 12 bytes. + +It tolerates envelopes delivered as an object, a JSON string, or base64(JSON), +and hex-or-base64 binary fields. + +#### 2.4 `selectors.ts` + +Memoized `reselect` selectors over `KycControllerState`: +`selectKycPhase`, `selectKycSumSub`, and the parametric +`selectIsKycRequiredForProduct(product)`. + +--- + +### 3. State shape + +```mermaid +classDiagram + class KycControllerState { + +KycPhase phase + +string statusMessage + +string error + +string email + +string termsAcceptedAt [persisted] + +string[] acceptedDisclaimerIds [persisted] + +KycVendor termsAcceptedVendor [persisted] + +KycDisclaimer[] disclaimers + +string disclaimersError + +string geoCountry + +string sessionToken [secret] + +string accessToken [secret] + +string moonpayCustomerId + +KycProduct activeProduct + +Record kycRequiredByProduct [persisted] + +string lastCheckedAt [persisted] + +SumSubState sumsub + } + class SumSubState { + +KycSumSubStatus status + +Json result + +string sessionId + +string applicantAccessToken + } + KycControllerState --> SumSubState : sumsub +``` + +> Note: nullable fields (`error`, `email`, `sessionToken`, …) are typed as +> `T | null` in the source; `Record` is `Partial>`. +> Types are simplified above for diagram readability. + +State metadata highlights (`kycControllerMetadata`): + +- **Persisted** (`persist: true`): `termsAcceptedAt`, `acceptedDisclaimerIds`, + `termsAcceptedVendor`, `sumsubTncAccepted`, `idosTncAccepted`, + `kycRequiredByProduct`, `lastCheckedAt`. These survive restarts so the flow + can skip already-accepted terms and reuse cached results. Session-scoped + `sessionDisclaimers` and `credentialReusabilityConsentGiven` are in-memory + only (`persist: false`) and are cleared on `reset()`. + Acceptance is vendor-scoped: `initialize` (and `createVendorCustomer`) drops + the stored acceptance when it belongs to a different vendor, so one vendor's + disclaimer ids are never submitted to another. The drop waits until the + vendor switch commits (`createVendorCustomer` succeeds, or the MoonPay + path proceeds); a failed or reset switch leaves the previous vendor's + acceptance in place. +- **Secrets, never persisted / never logged**: `sessionToken`, `accessToken`, + `moonpayCustomerId`, `email`, `disclaimers`, and the whole `sumsub` sub-tree. + Switching away from MoonPay (`initialize` / `createVendorCustomer`) drops + these MoonPay Check/Auth artifacts immediately so `buildCheckFrameUrl` cannot + return a MoonPay URL while `activeVendor` is a consents-path vendor. +- Additional non-state secrets kept **off** the state object entirely: the + X25519 private key (`#keypair`) and the Auth-frame client token + (`#authClientToken`). The auth client token is cleared on the same vendor + switch. + +--- + +### 4. The identity flow (phase state machine) + +`KycPhase` models the linear identity flow. Each transition is driven by a +controller method or an incoming frame message. + +```mermaid +stateDiagram-v2 + [*] --> idle + idle --> terms : initialize() (no saved terms) + idle --> session : initialize() (saved terms + email) + + terms --> session : acceptTermsAndStartSession({ sumsubTncSigned, idosTncSigned }) + session --> check : createSession() ok + session --> terms : createSession() fails
(clears saved terms, activeProduct + stale tokens) + + check --> form : Check frame → active (already authenticated) + check --> auth : Check frame → connectionRequired (needs OTP) + check --> terms : Check frame → termsAcceptanceRequired + + auth --> form : Auth frame → active (OTP verified) + auth --> terms : Auth frame → termsAcceptanceRequired + + form --> submit : checkKycRequired()
(auto when a product is set) + submit --> done : kyc-required response ok + submit --> error : request failed + + check --> error : unexpected status / decrypt failure + auth --> error : unexpected status + done --> [*] + error --> idle : reset() + done --> idle : reset() +``` + +> When the flow is scoped to a product (a `product` is passed to `initialize` +> or `acceptTermsAndStartSession`), reaching `form` **automatically** runs the +> KYC-required check (`form → submit → done`) with no user interaction, and — if +> KYC is required — automatically launches the SumSub document-verification +> sub-flow (see [§7](#7-sumsub-sub-flow)). When no product is set the flow stops +> at `form` and the consumer drives `checkKycRequired` / `startSumSub` manually. + +> **Non-MoonPay vendors use a consents path.** `initialize({ vendor: 'iron' })` +> creates an empty-shell customer, loads vendor disclaimers, and — after terms +> are accepted — records vendor T&Cs (`POST /vendors/{vendor}/disclaimers`), +> creates a UKYC session, records session-scoped idOS / KYC-provider +> disclaimers, and launches SumSub. MoonPay Check/Auth frames are skipped; +> `phase` moves `terms → session → submit → done`. A SumSub failure (thrown step +> or SDK close without completion) rewinds to `terms` instead of forcing `done`. +> A terminal UKYC rejection after the SDK reported `Completed` still finishes as +> `done` so `refreshKycStatus` can surface the decision. +> `acceptTermsAndStartSession` requires `sumsubTncSigned` and `idosTncSigned` +> (T&C2) for every vendor; omitted flags fail the flow instead of defaulting to +> `true`. Those flags are mapped onto the session catalog's `idOS` / +> `kycProvider` document records; `credentialReusabilityConsentGiven` is +> forwarded as well (defaults to `false`). + +> **`initialize` and `createVendorCustomer` never tear down an active flow.** If +> `phase` is already one of the in-progress phases (`session`, `check`, `auth`, +> `form`, `submit`), a repeat `initialize` or `createVendorCustomer` is a +> **no-op** — it will not create a new session, switch `activeVendor`, clear +> tokens, or reset `activeProduct`. Call `reset()` first to start over. +> When a switch away from MoonPay is allowed, leftover `sessionToken`, +> `accessToken`, `moonpayCustomerId`, and `#authClientToken` are cleared so +> Check/Auth URLs cannot outlive the MoonPay session. Check/Auth `complete` +> messages are also ignored unless `activeVendor` is `moonpay`, so a +> still-mounted MoonPay frame cannot recapture `moonpayCustomerId` under +> another vendor. + +> **`reset()` is callable from any phase and supersedes in-flight work.** In +> addition to returning `phase` to `idle` (and clearing tokens, `activeProduct`, +> and the `sumsub` sub-tree), `reset()` bumps an internal flow generation so any +> still-pending async step (geolocation, disclaimers, session creation, the +> KYC-required check, or the SumSub sub-flow) discards its result instead of +> writing it onto the now-idle controller. + +Phase meanings (from `types.ts`): + +| Phase | Meaning | +| --------- | ----------------------------------------------------------------------------------------------------------- | +| `idle` | Nothing started. | +| `terms` | Waiting for the customer to accept vendor terms. | +| `session` | Creating the vendor session. | +| `check` | Running the **invisible** connection-check frame. | +| `auth` | Running the **visible** authentication (email OTP) frame. | +| `form` | Authenticated. Auto-runs the KYC-required check when a product is set; otherwise waits for the consumer. | +| `submit` | Submitting the KYC-required check. | +| `done` | Complete — see `kycRequiredByProduct` / `sumsub`. Document verification auto-launches when KYC is required. | +| `error` | Halted — see `error`. | + +--- + +### 5. End-to-end sequence + +This sequence shows the full happy path including the two frames and the SumSub +hand-off. The **client transport** (WebView on mobile, iframe on web) is +generic — it only forwards raw frame messages to `handleFrameMessage` and posts +back any returned `reply`. + +```mermaid +sequenceDiagram + autonumber + actor User + participant UI as Client UI + transport
(WebView/iframe) + participant Ctrl as KycController + participant Svc as KycService + participant Geo as GeolocationController + participant API as UKYC API + participant Frame as MoonPay Check/Auth frame + participant Launcher as SumSub launcher (injected) + + User->>Ctrl: initialize({ email, product }) + Ctrl->>Svc: getGeoCountry() + Svc->>Geo: getGeolocation() + Note over Svc: map alpha-2 → alpha-3 locally + Ctrl->>Svc: fetchDisclaimers({ country }) + Svc->>API: GET /disclaimers + Ctrl-->>UI: phase = terms (+ disclaimers) + + User->>Ctrl: acceptTermsAndStartSession({ email, sumsubTncSigned, idosTncSigned }) + Ctrl->>Svc: createSession({ email, termsAcceptedAt, disclaimerIds }) + Svc->>API: POST /sessions + Ctrl-->>UI: phase = check (+ sessionToken) + + UI->>Ctrl: buildCheckFrameUrl() + Ctrl-->>UI: URL (sessionToken + publicKey) + UI->>Frame: load Check frame (invisible) + Frame-->>UI: handshake + UI->>Ctrl: handleFrameMessage(handshake) + Ctrl-->>UI: reply = ack + UI->>Frame: post ack + Frame-->>UI: complete (status + encrypted credentials) + UI->>Ctrl: handleFrameMessage(complete) + Note over Ctrl: decryptCredentials() → accessToken / clientToken + + alt Check → connectionRequired + Ctrl-->>UI: phase = auth + UI->>Frame: load Auth frame (visible, OTP) + Frame-->>UI: complete (active + credentials) + UI->>Ctrl: handleFrameMessage(complete) + end + + Ctrl-->>UI: phase = form (accessToken set) + + Note over Ctrl: activeProduct set at initialize →
continue automatically (no user action) + Ctrl->>Svc: checkKycRequired({ accessToken, country, capabilities }) + Svc->>API: POST /kyc-required + Ctrl-->>UI: phase = done (kycRequiredByProduct[product]) + + opt kycRequired === true → auto-launch document verification + Ctrl->>Svc: createUkycSession({ jwtToken, sessionClientPublicKey, residenceCountry, vendorMetadata }) + Svc->>API: POST /sessions + Note over Ctrl: verify encryptionDataKey vs idOS enclave JWKS,
ukycCapabilityToken vs idOS relay JWKS;
wrap data_encryption_key and ukyc_capability_token + Ctrl->>Svc: setAuthorizations({ sessionId, wrappedEncryptionDataKey, wrappedUkycCapabilityToken }) + Svc->>API: POST /sessions/{id}/authorizations + Ctrl->>Svc: createJourney(sessionId) + Svc->>API: POST /sessions/{id}/journey + Ctrl->>Launcher: launch({ applicantAccessToken, onTokenExpiration, onStatusChange }) + Launcher-->>Ctrl: SDK result + Ctrl-->>UI: sumsub.status = complete (+ result) + end +``` + +> The KYC-required check and the document-verification launch after `form` are +> driven by the controller itself, not the user — the flow captures the +> `product` at `initialize` and continues automatically. If `initialize` is +> called without a `product`, the flow stops at `form` and the consumer triggers +> `checkKycRequired` (and later `startSumSub`) explicitly. + +--- + +### 6. Frame message protocol & crypto + +The Check, Auth and Reset frames all speak a small `postMessage` protocol. +`KycController.handleFrameMessage` implements the identity portion; the client +transport is responsible only for delivering messages and injecting replies. + +```mermaid +sequenceDiagram + autonumber + participant Frame as MoonPay frame + participant UI as Client transport + participant Ctrl as KycController + + Frame->>UI: { kind: "handshake", meta:{channelId} } + UI->>Ctrl: handleFrameMessage({ message }) + Ctrl-->>UI: { reply: { version:2, meta:{channelId}, kind:"ack" } } + UI->>Frame: postMessage(ack) + + Frame->>UI: { kind:"complete", meta:{channelId},
payload:{ status, credentials, customer } } + UI->>Ctrl: handleFrameMessage({ message }) + Note over Ctrl: 1. phase guard: only honor ch_1 in `check`,
ch_2 in `auth` — else drop the message
2. store customer.id (moonpayCustomerId)
3. decryptCredentials(envelope, privKey)
4. route by channelId (ch_1 Check / ch_2 Auth) + Ctrl->>Ctrl: apply outcome → next phase +``` + +Channels: `ch_1` = Check, `ch_2` = Auth, `ch_reset` = Reset. + +> **Phase-guarded intake.** A `complete` is only processed when the flow is +> actually waiting on that frame — `ch_1` while `phase === 'check'`, `ch_2` +> while `phase === 'auth'`. Because both outcome handlers advance `phase` to +> `form` synchronously, a stale, duplicate, or post-`reset()` `complete` +> (delivered once the flow has moved on) is dropped before any state is touched, +> so it cannot resurrect tokens, re-store `customer.id`, or rewind `phase`. +> Frame messages are external input and are not covered by the `#generation` +> guard used for the controller's own async steps, so this boundary check is how +> late frame posts are neutralized. + +Credential decryption (`crypto.ts`): + +```mermaid +graph LR + A["envelope
{ ephemeralPublicKey, iv|nonce, ciphertext }"] --> B["X25519 ECDH
shared = f(ourPriv, theirPub)"] + B --> C["HKDF-SHA256
key (32 bytes)"] + C --> D["AES-256-GCM decrypt
(iv = 12 bytes)"] + D --> E["JSON credentials
{ accessToken?, clientToken? }"] +``` + +Check-frame outcomes (`#handleCheckOutcome`): + +- `active` + `accessToken` → phase `form` (already authenticated). +- `connectionRequired` + `clientToken` → store `#authClientToken`, phase `auth`. +- `termsAcceptanceRequired` → clear saved terms, phase `terms`. +- anything else → `error`. + +Auth-frame outcomes (`#handleAuthOutcome`): + +- `active` + `accessToken` → phase `form`. +- `termsAcceptanceRequired` → clear saved terms, phase `terms`. +- anything else → `error`. + +--- + +### 7. SumSub sub-flow + +The document-verification sub-flow tracks its own status independently of the +identity `phase`, and delegates the actual SDK presentation to the injected +launcher. + +```mermaid +stateDiagram-v2 + [*] --> idle + idle --> creatingSession : startSumSub() + creatingSession --> fetchingToken : setAuthorizations() ok + creatingSession --> vendorProcessing : setAuthorizations() kycStatus=approved, finalStatus=pending + fetchingToken --> launching : createJourney() ok + launching --> inProgress : onStatusChange (non-Completed) + launching --> complete : onStatusChange = Completed + inProgress --> complete : onStatusChange = Completed + launching --> failed : resolves without a Completed status + inProgress --> failed : resolves without a Completed status + creatingSession --> failed : error + fetchingToken --> failed : error + launching --> failed : launcher unavailable / error +``` + +> **Already processing on the vendor.** A user who already finished the journey +> can return to a session the relay has approved (`kycStatus: approved`) while +> the vendor is still finalizing its decision (`finalStatus: pending`). When +> authorizations report this, the sub-flow stops at `vendorProcessing` +> (setting `statusMessage`) instead of launching the SDK, so an already-approved +> applicant is not asked to verify again. + +> **Completion is status-driven, not resolution-driven.** A resolved `launch` +> is only recorded as `complete` when the SDK reported the `Completed` status +> via `onStatusChange` at least once. If `launch` resolves without ever having +> reported `Completed` (e.g. the applicant abandoned the flow, or a non-success +> outcome), the controller records `failed` — so consumers never mistake an +> unfinished flow for a verified one. + +The `KycSumSubLauncher` interface (injected per client): + +```ts +type KycSumSubLauncher = { + isAvailable(): boolean; + launch(params: KycSumSubLaunchParams): Promise>; +}; +``` + +`launch` receives `applicantAccessToken`, an `onTokenExpiration` callback (the +controller re-runs `createJourney` to refresh — but **refuses to refresh +after a `reset()`**, throwing instead so a still-open SDK cannot keep an +orphaned UKYC session alive), and an `onStatusChange` callback that the +controller maps into `sumsub.status`. + +--- + +### 8. Messenger wiring + +Both classes are messenger-driven. The controller depends on the service's +actions; the service depends on auth + geolocation actions from other +controllers. + +```mermaid +graph LR + subgraph CtrlMsgr["KycControllerMessenger"] + C_own["Own actions:
KycController:getState + 12 methods"] + C_ext["Allowed (delegated):
KycService:*"] + end + subgraph SvcMsgr["KycServiceMessenger"] + S_own["Own actions:
KycService: messenger methods"] + S_ext["Allowed (delegated):
AuthenticationController:getBearerToken
GeolocationController:getGeolocation"] + end + + C_ext -.delegates.-> S_own + S_ext -.delegates.-> Auth["AuthenticationController"] + S_ext -.delegates.-> Geo["GeolocationController"] +``` + +- `KycController` emits `KycController:stateChange` and exposes + `KycController:getState` plus its method actions. +- `KycController`'s `AllowedActions` = `KycServiceMethodActions` — it can call + the service. +- `KycService`'s `AllowedActions` = the auth bearer-token and geolocation + actions. + +--- + +### 9. Client-side usage (metamask-mobile) + +The mobile app is a reference consumer. It wires the controller/service into the +Engine, injects a React Native SumSub launcher, bridges WebView frame messages, +and reads state through Redux selectors. The **package stays free of any of +this** — all React/native/WebView code lives in the app. + +```mermaid +graph TB + subgraph app["metamask-mobile"] + direction TB + subgraph engine["Engine wiring"] + CInit["kyc-controller-init.ts
new KycController({ messenger, state, sumsubLauncher })"] + SInit["kyc-service-init.ts
new KycService({ messenger, baseUrl, idosEnclaveBaseUrl, idosRelayBaseUrl })"] + CMsgr["kyc-controller-messenger.ts
delegates KycService:*"] + SMsgr["kyc-service-messenger.ts
delegates Auth + Geolocation"] + Launcher["reactNativeSumSubLauncher.ts
lazy-loads @sumsub/react-native-mobilesdk-module"] + end + subgraph ui["UI layer"] + Hook["useKycFlow.ts
binds controller ↔ React"] + Frame["MoonpayFrame + useMoonpayFrame
WebView postMessage bridge"] + Reset["useMoonpayReset.ts
Reset frame"] + Demo["MoonpayDemo / SumSubDemo / KYCDemo
screens"] + end + subgraph redux["Redux"] + Sel["selectors/kycController.ts
wraps core selectors"] + end + end + + subgraph core["@metamask/kyc-controller"] + KC["KycController"] + KS["KycService"] + end + + CInit --> KC + SInit --> KS + CInit --> Launcher + Launcher -. injected .-> KC + CMsgr --> KC + SMsgr --> KS + + Hook -->|"Engine.context.KycController.*"| KC + Hook -->|"useSelector"| Sel + Sel -->|"state.engine.backgroundState.KycController"| KC + Frame -->|"raw frame message"| Hook + Hook -->|"handleFrameMessage()"| KC + Demo --> Hook + Demo --> Frame + Demo --> Reset +``` + +#### 9.1 Engine wiring + +- **`kyc-controller-init.ts`** constructs `KycController` with the persisted + state slice and injects `reactNativeSumSubLauncher`. +- **`kyc-service-init.ts`** constructs `KycService` with an `env` derived from + `isProduction()` and (currently) a dev `baseUrl` override. It does not inject + a `fetch`; `KycService` defaults to the runtime's native `fetch`. +- **`kyc-controller-messenger.ts`** delegates `KycService:*` actions to + the controller's messenger. +- **`kyc-service-messenger.ts`** delegates + `AuthenticationController:getBearerToken` and + `GeolocationController:getGeolocation` to the service's messenger. + +#### 9.2 SumSub launcher adapter + +`reactNativeSumSubLauncher` implements `KycSumSubLauncher`: + +- `isAvailable()` checks for the native module (`NativeModules.SNSMobileSDKModule`). +- `launch()` **lazily imports** `@sumsub/react-native-mobilesdk-module` (so + merely wiring the controller never loads the native module — important for + Jest / Expo Go), initializes the SDK with the applicant token, and forwards + `onStatusChanged` / token-expiration callbacks back to the controller. + +#### 9.3 React binding — `useKycFlow` + +A thin hook that: + +- Reads controller state from Redux via the `selectors/kycController.ts` + selectors. +- Forwards user intents to controller actions through + `Engine.context.KycController.*` (`initialize`, `acceptTermsAndStartSession`, + `checkKycRequired`, `startSumSub`, `clearSavedTerms`, `reset`). +- Builds frame URLs on demand (`buildCheckFrameUrl` / `buildAuthFrameUrl`) as + the phase changes. +- Bridges WebView frame messages into `handleFrameMessage` and posts back the + returned `reply`. +- Keeps view-only concerns (email input, debug log, frame visibility) in local + React state. + +#### 9.4 WebView transport — `useMoonpayFrame` / `MoonpayFrame` + +- Injects a `postMessage` bridge into the frame that forwards the frame's + outbound messages to React Native via `window.ReactNativeWebView.postMessage`. +- **Validates the origin** (`https://blocks.moonpay.com`) before handing a + message to the controller. +- Implements `reply()` by dispatching a `MessageEvent` back into the WebView on + both `document` and `window` (platform quirk between iOS WKWebView and Android + System WebView). +- The Check frame is rendered **invisible** (1×1, opacity 0) unless the user + toggles it in the debug panel; the Auth frame is rendered visibly for OTP. + +#### 9.5 Redux selectors + +`selectors/kycController.ts` wraps the package's core selectors and reads the +slice at `state.engine.backgroundState.KycController`, exposing app-friendly +selectors (`selectKycPhase`, `selectKycSumSub`, +`selectIsKycRequiredForProduct(product)`, plus per-field selectors). + +--- + +### 10. Boundaries & responsibilities summary + +```mermaid +graph LR + subgraph shared["Shared package (platform-agnostic)"] + A1["Flow orchestration + state"] + A2["HTTP + response validation"] + A3["Crypto (X25519 / AES-GCM)"] + A4["Frame message protocol"] + A5["Selectors + vendor-neutral types"] + end + subgraph client["Client (per platform)"] + B1["Engine/DI wiring"] + B2["WebView / iframe transport"] + B3["SumSub SDK launcher"] + B4["Auth token + geolocation providers"] + B5["UI + Redux binding"] + end + shared -. injected adapters .- client +``` + +| Concern | Owner | +| ------------------------------------ | ------------------------------------------- | +| Flow phase machine & state | `KycController` (shared) | +| UKYC HTTP + validation + retries | `KycService` (shared) | +| Credential decryption / key exchange | `crypto.ts` (shared) | +| Frame message semantics | `KycController.handleFrameMessage` (shared) | +| Frame **transport** (WebView/iframe) | Client | +| SumSub SDK presentation | Client (via `KycSumSubLauncher`) | +| Auth bearer token / geolocation | Other controllers (via messenger) | +| Persistence of state | Client (base-controller persistence) | + +--- + +### Appendix — key source files + +| File | Responsibility | +| ---------------------- | ----------------------------------------------------- | +| `src/KycController.ts` | Stateful orchestrator, phase machine, frame protocol. | +| `src/KycService.ts` | Stateless UKYC HTTP client + superstruct validation. | +| `src/crypto.ts` | X25519 ECDH + AES-256-GCM credential decryption. | +| `src/selectors.ts` | Memoized selectors over controller state. | +| `src/types.ts` | `KycPhase`, `KycProduct`, `KycSumSubLauncher`, etc. | +| `src/countryCodes.ts` | ISO alpha-2 → alpha-3 mapping. | +| `src/index.ts` | Public exports (no barrel wildcards). | + +Reference client (metamask-mobile): + +| File | Responsibility | +| -------------------------------------------------------------- | --------------------------------------- | +| `app/core/Engine/controllers/kyc/kyc-controller-init.ts` | Construct controller + inject launcher. | +| `app/core/Engine/controllers/kyc/kyc-service-init.ts` | Construct service. | +| `app/core/Engine/controllers/kyc/reactNativeSumSubLauncher.ts` | Native SumSub adapter. | +| `app/core/Engine/messengers/kyc/*.ts` | Messenger delegation. | +| `app/components/Views/MoonpayDemo/useKycFlow.ts` | React ↔ controller binding. | +| `app/components/Views/MoonpayDemo/useMoonpayFrame.ts` | WebView postMessage bridge. | +| `app/selectors/kycController.ts` | Redux selectors. | diff --git a/packages/kyc-controller/CHANGELOG.md b/packages/kyc-controller/CHANGELOG.md new file mode 100644 index 00000000000..6ab5cc1eba3 --- /dev/null +++ b/packages/kyc-controller/CHANGELOG.md @@ -0,0 +1,60 @@ +# 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 + +- Add `idosRelayBaseUrl` on `KycService` and `KycService.fetchIdosRelayJwks()` to fetch the idOS relay well-known JWKS used to verify the `ukycCapabilityToken` encryption schema. `encryptionDataKey` continues to verify against idOS enclave JWKS via `fetchIdosEnclaveJwks` / `idosEnclaveBaseUrl`. ([#10008](https://github.com/MetaMask/core/pull/10008)) +- Add session-scoped disclaimer APIs on `KycService` for the idOS / KYC-provider catalog ([#9979](https://github.com/MetaMask/core/pull/9979)): + - `fetchSessionDisclaimers({ sessionId })` calls `GET /sessions/{sessionId}/disclaimers` + - `submitSessionDisclaimers({ sessionId, idOS, kycProvider, credentialReusabilityConsentGiven })` calls `POST /sessions/{sessionId}/disclaimers` +- Add `KycService.submitVendorDisclaimers({ vendor, disclaimerIds })` (`POST /vendors/{vendor}/disclaimers`) to record Iron T&C signings, plus the `KycVendorSigning` response type. The consents path calls this alongside session-scoped disclaimers; vendor T&C ids are no longer sent on the session disclaimer POST. ([#9979](https://github.com/MetaMask/core/pull/9979)) +- Add `KycConsentDocument`, `KycConsentRecord`, and `KycSessionDisclaimers` types for that catalog, plus in-memory `credentialReusabilityConsentGiven` and `sessionDisclaimers` controller state. `acceptTermsAndStartSession` forwards optional `credentialReusabilityConsentGiven` (default `false`). ([#9979](https://github.com/MetaMask/core/pull/9979)) +- Parameterize Universal KYC vendor HTTP on `KycService` so identity vendors share one client surface instead of vendor-branded methods ([#9908](https://github.com/MetaMask/core/pull/9908)): + - `fetchDisclaimers({ vendor, country })` and `checkKycRequired({ vendor, ... })` call `/vendors/{vendor}/disclaimers` and `/vendors/{vendor}/kyc-required` (`vendor` defaults to `moonpay`) + - `createVendorCustomer({ vendor, email })` calls `POST /vendors/{vendor}/customers` + - `fetchKycStatus()` reads `GET /kyc/status` +- Add a consents-path KYC flow on `KycController` for non-MoonPay vendors (currently `iron`): empty-shell customer → disclaimers → consents → SumSub, skipping MoonPay Check/Auth frames. `initialize({ vendor })` and `createVendorCustomer({ vendor, email })` drive the path; `acceptTermsAndStartSession` requires `sumsubTncSigned` / `idosTncSigned`. ([#9908](https://github.com/MetaMask/core/pull/9908)) +- Add `KycController.refreshKycStatus()` and the `KycController:statusChanged` event so consumers can poll user-keyed KYC status for toast / banner surfaces. ([#9908](https://github.com/MetaMask/core/pull/9908)) +- Add `KycController.getCustomerIdentity()` (and `KycCustomerIdentity`) returning the vendor-scoped `{ vendor, id }` for the current session, or `null` before authentication and after `reset()`. ([#9908](https://github.com/MetaMask/core/pull/9908)) +- Extend `KycProduct` with `'money'` and `KycVendor` with `'iron'`. ([#9908](https://github.com/MetaMask/core/pull/9908)) +- Add `KycUserStatus` / `KycUserStatusResponse` types for the simplified `GET /kyc/status` payload. ([#9908](https://github.com/MetaMask/core/pull/9908)) +- Add persisted `termsAcceptedVendor` state recording which vendor's disclaimers `acceptedDisclaimerIds` belong to, so stored acceptance is only reused for that vendor. ([#9908](https://github.com/MetaMask/core/pull/9908)) +- Add persisted `sumsubTncAccepted` and `idosTncAccepted` state so T&C2 flags can be validated when resuming a consents-path session. ([#9908](https://github.com/MetaMask/core/pull/9908)) +- Add `KycController.clearState()` (and the `KycController:clearState` action) restoring the default state, including the fields `reset()` preserves: the session email, terms acceptance, the per-product KYC-required cache and the user-keyed status. Intended for a full wallet reset. ([#9958](https://github.com/MetaMask/core/pull/9958)) + +### Changed + +- **BREAKING:** Rename `fractalEncryptionBaseUrl` to `idosEnclaveBaseUrl`, `KycService.fetchJwks` / `KycService:fetchJwks` / `KycServiceFetchJwksAction` to `fetchIdosEnclaveJwks` / `KycService:fetchIdosEnclaveJwks` / `KycServiceFetchIdosEnclaveJwksAction`, and related Fractal encryption naming to idOS enclave. ([#10008](https://github.com/MetaMask/core/pull/10008)) +- **BREAKING:** Verify `encryptionDataKey` against idOS enclave JWKS (`KycService:fetchIdosEnclaveJwks` / `idosEnclaveBaseUrl`) and `ukycCapabilityToken` against idOS relay JWKS (`KycService:fetchIdosRelayJwks` / `idosRelayBaseUrl`) when wrapping UKYC authorizations, instead of validating both schemas against Fractal. Hosts must supply `idosRelayBaseUrl` on `KycService` construction (same class of requirement as `idosEnclaveBaseUrl`). ([#10008](https://github.com/MetaMask/core/pull/10008)) +- **BREAKING:** Require `sessionClientPublicKey` (unpadded base64url X25519 public key) and `residenceCountry` (ISO 3166-1 alpha-3) on `KycService.createUkycSession` (`POST /sessions`). The controller generates the per-session keypair before creating the session and uses the private half to wrap authorizations; residence country is taken from the resolved geo country. ([#9993](https://github.com/MetaMask/core/pull/9993)) +- **BREAKING:** Replace wrapping-key exchange (`KycService.getWrappingKey`) and sending wrapped keys at session creation with encryption schemas from `createUkycSession` plus `setAuthorizations` (`POST /sessions/{id}/authorizations`). `createUkycSession` no longer accepts `wrappedEncryptionKey` or `ukycCapabilityToken`; both secrets are wrapped on the client against per-secret schemas and posted separately. ([#9944](https://github.com/MetaMask/core/pull/9944)) +- **BREAKING:** Replace `KycService.submitConsents` (`POST /consents`) with session-scoped `fetchSessionDisclaimers` / `submitSessionDisclaimers` plus vendor T&C recording via `submitVendorDisclaimers`. Consents now use `{ key, version }` document records plus `credentialReusabilityConsentGiven` instead of Iron disclaimer ids and boolean T&C flags, and they require a UKYC session id. Iron content ids are posted separately to `POST /vendors/{vendor}/disclaimers`. The consents path records vendor T&Cs, then creates the UKYC session, then records session disclaimers. A 409 conflict is re-checked with a GET and only treated as success when every accepted document is consented. ([#9979](https://github.com/MetaMask/core/pull/9979)) +- Make the `fetch` option on the `KycService` constructor optional; it now defaults to the runtime's native `fetch` (browser, React Native, Node 18+), so consumers no longer need to inject one. ([#9908](https://github.com/MetaMask/core/pull/9908)) +- **BREAKING:** Invalidate terms acceptance when `termsAcceptedVendor` is `null` (pre-migration state), forcing reacceptance after the multi-vendor upgrade to ensure users review current vendor terms. ([#9908](https://github.com/MetaMask/core/pull/9908)) +- **BREAKING:** Require `sumsubTncSigned` and `idosTncSigned` on `acceptTermsAndStartSession` for every vendor, so callers explicitly declare T&C2 acceptance. Zero-argument calls and omitted flags fail instead of defaulting to `true`. ([#9908](https://github.com/MetaMask/core/pull/9908)) +- Rename `CreateUkycSessionParams.vendorId` to `vendor` for consistency with other service methods. ([#9908](https://github.com/MetaMask/core/pull/9908)) +- Bump `@metamask/base-data-service` from `^0.1.3` to `^1.0.0` ([#9972](https://github.com/MetaMask/core/pull/9972)) + +### Fixed + +- Clear leftover MoonPay `sessionToken`, `accessToken`, and Check/Auth frame credentials when `initialize` or `createVendorCustomer` switches to another vendor, so `buildCheckFrameUrl` cannot return a MoonPay URL for a consents-path session. ([#9908](https://github.com/MetaMask/core/pull/9908)) +- Rewind the consents path when SumSub fails before completion (thrown step or SDK close without `Completed`), instead of refreshing user status and forcing `phase` to `done`. A terminal UKYC rejection after the SDK completed still finishes as `done` so the decision can be reflected in user status. ([#9908](https://github.com/MetaMask/core/pull/9908)) +- Make `createVendorCustomer` a no-op during in-progress phases (matching `initialize`), so a vendor switch cannot leave Check/Auth frames attached to the wrong vendor. ([#9908](https://github.com/MetaMask/core/pull/9908)) +- Ignore Check/Auth frame completion unless the active vendor is MoonPay, and do not return a MoonPay customer id from `getCustomerIdentity()` under another vendor. ([#9908](https://github.com/MetaMask/core/pull/9908)) +- Drop persisted terms acceptance on a vendor switch only after `createVendorCustomer` succeeds, so a failed or reset Money start cannot erase another vendor's stored acceptance. ([#9908](https://github.com/MetaMask/core/pull/9908)) +- Clear `moonpayCustomerId` when the active vendor is not MoonPay, so `getCustomerIdentity()` cannot report a MoonPay customer id under another vendor. ([#9908](https://github.com/MetaMask/core/pull/9908)) +- Call `unref()` on the user-status poll timer only when it exists. React Native and browser timers are numbers, so an unconditional `unref()` threw when status polling started outside Node. ([#9908](https://github.com/MetaMask/core/pull/9908)) +- Skip the `session_not_in_valid_state` completion write when a `reset()` superseded the SumSub flow, so a late vendor response can no longer force `userStatus` to `completed` (and publish `statusChanged`) on an idle controller. ([#9908](https://github.com/MetaMask/core/pull/9908)) +- Stop `initialize()` once a `reset()` or `clearState()` has superseded it, so a geolocation response arriving afterwards can no longer move the controller into the `terms` phase or reload disclaimers onto a controller that was just reset. ([#9958](https://github.com/MetaMask/core/pull/9958)) +- Validate that `accessToken` and `country` are provided when calling `checkKycRequired` with vendor `moonpay`, failing fast with a clear error instead of posting `undefined` values to the API. ([#9908](https://github.com/MetaMask/core/pull/9908)) +- Check for global `fetch` availability before binding in `KycService` constructor, throwing a descriptive error if `fetch` is neither provided nor globally available (e.g. older Node environments). ([#9908](https://github.com/MetaMask/core/pull/9908)) +- Check for null bearer token before calling `assert()` in `#requestJson`, ensuring the custom "wallet signed in" error message is shown instead of a generic superstruct error. ([#9908](https://github.com/MetaMask/core/pull/9908)) +- Require reacceptance of consents-path terms when `sumsubTncAccepted` or `idosTncAccepted` are `null` (pre-migration state), preventing invalid T&C2 flag submission on session resume. ([#9908](https://github.com/MetaMask/core/pull/9908)) + +[Unreleased]: https://github.com/MetaMask/core/ diff --git a/packages/kyc-controller/LICENSE b/packages/kyc-controller/LICENSE new file mode 100644 index 00000000000..9ec4f4514ea --- /dev/null +++ b/packages/kyc-controller/LICENSE @@ -0,0 +1,6 @@ +This project is licensed under either of + + * MIT license ([LICENSE.MIT](LICENSE.MIT)) + * Apache License, Version 2.0 ([LICENSE.APACHE2](LICENSE.APACHE2)) + +at your option. diff --git a/packages/kyc-controller/LICENSE.APACHE2 b/packages/kyc-controller/LICENSE.APACHE2 new file mode 100644 index 00000000000..e6e77b08909 --- /dev/null +++ b/packages/kyc-controller/LICENSE.APACHE2 @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/packages/kyc-controller/LICENSE.MIT b/packages/kyc-controller/LICENSE.MIT new file mode 100644 index 00000000000..fe29e78e0fe --- /dev/null +++ b/packages/kyc-controller/LICENSE.MIT @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 MetaMask + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packages/kyc-controller/README.md b/packages/kyc-controller/README.md new file mode 100644 index 00000000000..7aaf642375e --- /dev/null +++ b/packages/kyc-controller/README.md @@ -0,0 +1,23 @@ +# KYC Controller `@metamask/kyc-controller` + +Shared KYC / identity verification controller used across MetaMask clients + +## Installation + +`yarn add @metamask/kyc-controller` + +or + +`npm install @metamask/kyc-controller` + +## Development + +To rebuild the package automatically whenever you change a source file, run the `build:watch` script from core repository root folder: + +`yarn workspace @metamask/kyc-controller run build:watch` + +This watches `src/**/*.ts` and re-runs the build on each change (it also performs an initial build on start), which is useful when developing against a client that consumes this package locally. + +## 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/kyc-controller/jest.config.js b/packages/kyc-controller/jest.config.js new file mode 100644 index 00000000000..ca084133399 --- /dev/null +++ b/packages/kyc-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/kyc-controller/package.json b/packages/kyc-controller/package.json new file mode 100644 index 00000000000..2a7fd97a842 --- /dev/null +++ b/packages/kyc-controller/package.json @@ -0,0 +1,93 @@ +{ + "name": "@metamask/kyc-controller", + "version": "0.0.0", + "description": "Shared KYC / identity verification controller used across MetaMask clients", + "keywords": [ + "Ethereum", + "MetaMask" + ], + "homepage": "https://github.com/MetaMask/core/tree/main/packages/kyc-controller#readme", + "bugs": { + "url": "https://github.com/MetaMask/core/issues" + }, + "license": "(MIT OR Apache-2.0)", + "repository": { + "type": "git", + "url": "https://github.com/MetaMask/core.git" + }, + "files": [ + "dist/" + ], + "sideEffects": false, + "main": "./dist/index.cjs", + "types": "./dist/index.d.cts", + "exports": { + ".": { + "import": { + "types": "./dist/index.d.mts", + "default": "./dist/index.mjs" + }, + "require": { + "types": "./dist/index.d.cts", + "default": "./dist/index.cjs" + } + }, + "./package.json": "./package.json" + }, + "publishConfig": { + "access": "public", + "registry": "https://registry.npmjs.org/" + }, + "scripts": { + "build": "ts-bridge --project tsconfig.build.json --verbose --clean --no-references", + "build:all": "ts-bridge --project tsconfig.build.json --verbose --clean", + "build:docs": "typedoc", + "build:watch": "yarn build && chokidar 'src/**/*.ts' -c 'ts-bridge --project tsconfig.build.json --verbose --no-references' --initial", + "changelog:update": "../../scripts/update-changelog.sh @metamask/kyc-controller", + "changelog:validate": "../../scripts/validate-changelog.sh @metamask/kyc-controller", + "lint:tsconfigs": "tsx ../../scripts/lint-tsconfigs/lint-tsconfigs.mts", + "lint:tsconfigs:fix": "tsx ../../scripts/lint-tsconfigs/lint-tsconfigs.mts --fix", + "messenger-action-types:check": "tsx ../../packages/messenger-cli/src/cli.ts --formatter oxfmt --esm --check", + "messenger-action-types:generate": "tsx ../../packages/messenger-cli/src/cli.ts --formatter oxfmt --esm --generate", + "mint:ukyc-token": "tsx scripts/mint-ukyc-test-token.ts", + "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.1.0", + "@metamask/base-data-service": "^1.0.0", + "@metamask/controller-utils": "^12.3.0", + "@metamask/geolocation-controller": "^1.0.0", + "@metamask/messenger": "^2.0.0", + "@metamask/profile-sync-controller": "^29.0.0", + "@metamask/superstruct": "^3.4.1", + "@metamask/utils": "^11.11.0", + "@noble/ciphers": "^1.3.0", + "@noble/curves": "^1.9.2", + "@noble/hashes": "^1.8.0", + "@scure/base": "^1.2.6", + "@tanstack/query-core": "^5.62.16", + "reselect": "^5.1.1", + "tweetnacl": "^1.0.3" + }, + "devDependencies": { + "@metamask/auto-changelog": "^6.1.0", + "@ts-bridge/cli": "^0.6.4", + "@types/jest": "^30.0.0", + "chokidar-cli": "^3.0.0", + "deepmerge": "^4.2.2", + "jest": "^30.4.2", + "nock": "^13.3.1", + "ts-jest": "^29.4.11", + "tsx": "^4.20.5", + "typedoc": "^0.25.13", + "typedoc-plugin-missing-exports": "^2.0.0", + "typescript": "~5.3.3" + }, + "engines": { + "node": "^18.18 || >=20" + } +} diff --git a/packages/kyc-controller/scripts/mint-ukyc-test-token.ts b/packages/kyc-controller/scripts/mint-ukyc-test-token.ts new file mode 100644 index 00000000000..10b47b1ea4c --- /dev/null +++ b/packages/kyc-controller/scripts/mint-ukyc-test-token.ts @@ -0,0 +1,108 @@ +/** + * CLI to mint a UKYC `storage_access_token` for testing UKYC Storage. + * + * All real logic lives in the tested `mintUkycTestToken`; this is a thin + * argument-parsing wrapper that prints the result as JSON. + * + * Usage (from the package root, via the `mint:ukyc-token` script): + * yarn workspace @metamask/kyc-controller run mint:ukyc-token -- \ + * --operations read,write --expires-in 4h [--secret ] \ + * [--presenter client|idos-relay] [--session-id ] + * + * Reuse the printed `localUserSecret` (pass it back via --secret) to keep the + * same `storageId` and controlling key across runs. + */ +import process from 'node:process'; + +import type { + UkycStorageOperation, + UkycTokenPresenter, +} from '../src/ukyc/storageAccessToken.js'; +import { mintUkycTestToken } from '../src/ukyc/testToken.js'; +import type { MintUkycTestTokenParams } from '../src/ukyc/testToken.js'; + +/** + * Parses `--flag value` and `--flag=value` pairs into a map. Flags without a + * following value are treated as booleans (`"true"`). + * + * @param argv - Raw CLI arguments (typically `process.argv.slice(2)`). + * @returns The parsed flags keyed by name (without the leading `--`). + */ +function parseFlags(argv: string[]): Record { + const flags: Record = {}; + let i = 0; + while (i < argv.length) { + const arg = argv[i]; + if (!arg.startsWith('--')) { + i += 1; + continue; + } + const body = arg.slice(2); + const eq = body.indexOf('='); + if (eq !== -1) { + flags[body.slice(0, eq)] = body.slice(eq + 1); + i += 1; + continue; + } + const next = argv[i + 1]; + if (next !== undefined && !next.startsWith('--')) { + flags[body] = next; + i += 2; + } else { + flags[body] = 'true'; + i += 1; + } + } + return flags; +} + +/** + * Parses a duration like `4h`, `30m`, `90s`, or a bare number of seconds. + * + * @param value - The duration string. + * @returns The duration in milliseconds. + */ +function parseDurationMs(value: string): number { + const match = /^(\d+)(s|m|h|d)?$/u.exec(value); + if (!match) { + throw new Error(`invalid --expires-in duration: ${value}`); + } + const amount = Number(match[1]); + const unitMs = { s: 1000, m: 60_000, h: 3_600_000, d: 86_400_000 }; + return amount * unitMs[(match[2] ?? 's') as keyof typeof unitMs]; +} + +const flags = parseFlags(process.argv.slice(2)); + +const params: MintUkycTestTokenParams = {}; + +if (flags.secret) { + params.localUserSecret = flags.secret; +} +if (flags.operations) { + params.operations = flags.operations + .split(',') + .map((op) => op.trim()) as UkycStorageOperation[]; +} +if (flags.presenter) { + params.presenter = flags.presenter as UkycTokenPresenter; +} +if (flags['session-id']) { + params.sessionId = flags['session-id']; +} +if (flags['issued-at']) { + params.issuedAt = new Date(flags['issued-at']); +} +if (flags['expires-at']) { + params.expiresAt = new Date(flags['expires-at']); +} else if (flags['expires-in']) { + const issuedAt = params.issuedAt ?? new Date(); + params.issuedAt = issuedAt; + params.expiresAt = new Date( + issuedAt.getTime() + parseDurationMs(flags['expires-in']), + ); +} + +const result = mintUkycTestToken(params); + +console.log(JSON.stringify(result, null, 2)); diff --git a/packages/kyc-controller/src/KycController-method-action-types.ts b/packages/kyc-controller/src/KycController-method-action-types.ts new file mode 100644 index 00000000000..dd4614d1a36 --- /dev/null +++ b/packages/kyc-controller/src/KycController-method-action-types.ts @@ -0,0 +1,274 @@ +/** + * This file is auto generated. + * Do not edit manually. + */ + +import type { KycController } from './KycController.js'; + +/** + * Resolves persisted terms + geolocation, and auto-creates a session when + * terms are already accepted and an email is available. + * + * @param params - Optional parameters. + * @param params.email - The account email to associate with the session. + * @param params.product - The consuming feature the flow runs for. When + * provided, the controller automatically runs the KYC-required check once + * authentication completes (and chains into document verification when KYC + * is required). When omitted, the flow stops at `form` and the consumer must + * call `checkKycRequired` manually. + * @param params.vendor - Identity vendor for this flow. Non-MoonPay vendors + * skip Check/Auth frames and use the consents path. Defaults to `moonpay`. + */ +export type KycControllerInitializeAction = { + type: `KycController:initialize`; + handler: KycController['initialize']; +}; + +/** + * Creates (or resumes) an empty-shell customer for the given identity + * vendor. Exposed so a consumer can ensure the customer exists before + * showing T&C screens independently of {@link initialize}. + * + * A call while a session flow is already in progress is a no-op — matching + * {@link initialize} — so a vendor switch cannot leave Check/Auth frames + * attached to the wrong vendor. Call {@link reset} first to start over. + * + * @param params - The parameters. + * @param params.vendor - Identity vendor for the customer. + * @param params.email - Email for the vendor customer. + */ +export type KycControllerCreateVendorCustomerAction = { + type: `KycController:createVendorCustomer`; + handler: KycController['createVendorCustomer']; +}; + +/** + * Loads the disclaimers for the resolved (or provided) country. + * + * @param params - Optional parameters. + * @param params.country - ISO 3166-1 alpha-3 country code override. + */ +export type KycControllerLoadDisclaimersAction = { + type: `KycController:loadDisclaimers`; + handler: KycController['loadDisclaimers']; +}; + +/** + * Captures terms acceptance for the currently loaded disclaimers and creates + * a session. + * + * @param params - The parameters. + * @param params.email - The account email to associate with the session. + * @param params.product - The consuming feature the flow runs for. See + * {@link initialize} for how the product drives the automatic post + * authentication continuation. + * @param params.sumsubTncSigned - Whether Sumsub T&C were accepted (T&C2). + * Required for every vendor so callers explicitly declare acceptance. + * @param params.idosTncSigned - Whether idOS T&C were accepted (T&C2). + * Required for every vendor so callers explicitly declare acceptance. + * @param params.credentialReusabilityConsentGiven - Whether the customer + * consented to reuse existing idOS credentials. Used when recording + * session-scoped disclaimers on the consents path. Defaults to `false`. + */ +export type KycControllerAcceptTermsAndStartSessionAction = { + type: `KycController:acceptTermsAndStartSession`; + handler: KycController['acceptTermsAndStartSession']; +}; + +/** + * Clears the persisted terms acceptance. + */ +export type KycControllerClearSavedTermsAction = { + type: `KycController:clearSavedTerms`; + handler: KycController['clearSavedTerms']; +}; + +/** + * Handles a message posted by a Check/Auth frame and advances the flow. + * + * The transport-agnostic caller (WebView on mobile, iframe on web) forwards + * the raw message and injects the returned `reply` back into the frame. + * + * @param params - The parameters. + * @param params.message - The raw message posted by the frame. + * @returns An object whose optional `reply` should be posted back. + */ +export type KycControllerHandleFrameMessageAction = { + type: `KycController:handleFrameMessage`; + handler: KycController['handleFrameMessage']; +}; + +/** + * Builds the Check-frame URL, or `null` when no session exists yet. + * + * @returns The Check-frame URL or `null`. + */ +export type KycControllerBuildCheckFrameUrlAction = { + type: `KycController:buildCheckFrameUrl`; + handler: KycController['buildCheckFrameUrl']; +}; + +/** + * Builds the Auth-frame URL, or `null` when no client token is available. + * + * @returns The Auth-frame URL or `null`. + */ +export type KycControllerBuildAuthFrameUrlAction = { + type: `KycController:buildAuthFrameUrl`; + handler: KycController['buildAuthFrameUrl']; +}; + +/** + * Builds the Reset-frame URL. + * + * @returns The Reset-frame URL. + */ +export type KycControllerBuildResetFrameUrlAction = { + type: `KycController:buildResetFrameUrl`; + handler: KycController['buildResetFrameUrl']; +}; + +/** + * Checks whether KYC is required for a product and caches the result. + * + * @param params - The parameters. + * @param params.product - The consuming feature. + * @param params.country - Optional alpha-3 country override. + * @returns Whether KYC is required. + */ +export type KycControllerCheckKycRequiredAction = { + type: `KycController:checkKycRequired`; + handler: KycController['checkKycRequired']; +}; + +/** + * Reads the cached "is KYC required" result for a product. + * + * @param params - The parameters. + * @param params.product - The consuming feature. + * @returns The cached value, or `undefined` if not yet checked. + */ +export type KycControllerGetKycStatusAction = { + type: `KycController:getKycStatus`; + handler: KycController['getKycStatus']; +}; + +/** + * Returns the vendor-scoped identity for the currently authenticated + * customer, or `null` when the flow has not yet captured a vendor customer + * id (before authentication or after {@link reset}), or when a MoonPay id + * is present under a different `activeVendor`. + * + * Exposed so consumers (e.g. ramps autoramp creation) can attach the vendor + * customer id to downstream calls without reading the full KYC state, which + * also holds session/access tokens. The id is session-scoped and never + * persisted. + * + * @returns The current {@link KycCustomerIdentity}, or `null`. + */ +export type KycControllerGetCustomerIdentityAction = { + type: `KycController:getCustomerIdentity`; + handler: KycController['getCustomerIdentity']; +}; + +/** + * Runs the SumSub document-verification sub-flow end to end: + * + * 1. creates a UKYC session, receiving per-secret encryption schemas; + * 2. verifies the `encryptionDataKey` schema's `jwtChain` against the + * idOS enclave JWKS and the `ukycCapabilityToken` schema's `jwtChain` against + * the idOS relay JWKS, then confirms each attested session server public + * key; + * 3. derives the `data_encryption_key` from the wallet's UKYC + * `local_user_secret` and wraps it for the session server; + * 4. mints a client-signed, read-only `ukyc_capability_token`, wraps it the + * same way as the encryption key, and submits both via authorizations; + * 5. fetches the SumSub applicant access token; and + * 6. presents the SDK via the injected launcher. + * + * If a UKYC session already exists (the consents path creates it before + * recording session disclaimers), steps 1–4 are skipped. + * + * If authorizations report the applicant is already approved on the relay + * while the vendor is still finalizing (`kycStatus: approved`, + * `finalStatus: pending`), the sub-flow stops at step 4 with a + * `vendorProcessing` status and a message rather than launching the SDK. + * + * @param params - Optional parameters. + * @param params.locale - BCP-47 locale for the SDK UI. + * @param params.debug - Enables SDK debug logging. + * @returns The SDK result. + */ +export type KycControllerStartSumSubAction = { + type: `KycController:startSumSub`; + handler: KycController['startSumSub']; +}; + +/** + * Refreshes the user-keyed simplified KYC status from `GET /kyc/status`, + * stores it on state, publishes {@link KycControllerStatusChangedEvent}, and + * schedules short-interval polling while the status is `pending`. + * + * @returns The latest status payload. + */ +export type KycControllerRefreshKycStatusAction = { + type: `KycController:refreshKycStatus`; + handler: KycController['refreshKycStatus']; +}; + +/** + * Fetches the current UKYC session status for the active sub-flow and records + * it on state. Useful for a one-off refresh outside the automatic polling + * loop that {@link startSumSub} runs. + * + * @returns The fetched session status. + * @throws If there is no active SumSub session to query. + */ +export type KycControllerGetSessionStatusAction = { + type: `KycController:getSessionStatus`; + handler: KycController['getSessionStatus']; +}; + +/** + * Resets the flow to idle, clearing session tokens and sub-flow state while + * preserving persisted terms acceptance and the per-product cache. + */ +export type KycControllerResetAction = { + type: `KycController:reset`; + handler: KycController['reset']; +}; + +/** + * Restores the controller to its default state, discarding everything + * {@link reset} deliberately keeps: the session email, the persisted terms + * acceptance, the per-product KYC-required cache and the user-keyed status. + * + * Intended for a full wallet reset, where no trace of the previous + * customer may survive into the next wallet. + */ +export type KycControllerClearStateAction = { + type: `KycController:clearState`; + handler: KycController['clearState']; +}; + +/** + * Union of all KycController action types. + */ +export type KycControllerMethodActions = + | KycControllerInitializeAction + | KycControllerCreateVendorCustomerAction + | KycControllerLoadDisclaimersAction + | KycControllerAcceptTermsAndStartSessionAction + | KycControllerClearSavedTermsAction + | KycControllerHandleFrameMessageAction + | KycControllerBuildCheckFrameUrlAction + | KycControllerBuildAuthFrameUrlAction + | KycControllerBuildResetFrameUrlAction + | KycControllerCheckKycRequiredAction + | KycControllerGetKycStatusAction + | KycControllerGetCustomerIdentityAction + | KycControllerStartSumSubAction + | KycControllerRefreshKycStatusAction + | KycControllerGetSessionStatusAction + | KycControllerResetAction + | KycControllerClearStateAction; diff --git a/packages/kyc-controller/src/KycController.test.ts b/packages/kyc-controller/src/KycController.test.ts new file mode 100644 index 00000000000..d39fa78ae43 --- /dev/null +++ b/packages/kyc-controller/src/KycController.test.ts @@ -0,0 +1,4910 @@ +import { HttpError } from '@metamask/controller-utils'; +import { Messenger, MOCK_ANY_NAMESPACE } from '@metamask/messenger'; +import type { + MockAnyNamespace, + MessengerActions, + MessengerEvents, +} from '@metamask/messenger'; +import { areUint8ArraysEqual, bytesToString } from '@metamask/utils'; +import { gcm } from '@noble/ciphers/aes'; +import { x25519 } from '@noble/curves/ed25519'; +import { hkdf } from '@noble/hashes/hkdf'; +import { sha256 } from '@noble/hashes/sha2'; +import { bytesToHex, hexToBytes, utf8ToBytes } from '@noble/hashes/utils'; + +import { base64UrlToBytes, toBase64Url } from './encoding.js'; +import { + getDefaultKycControllerState, + KycController, +} from './KycController.js'; +import type { KycControllerMessenger } from './KycController.js'; +import type { KycSessionDisclaimers, KycSumSubLauncher } from './types.js'; +import { verifyJwtChain } from './ukyc/jwtChain.js'; +import { wrapEncryptionKey } from './ukyc/wrapEncryptionKey.js'; + +// `verifyJwtChain` (JWKS attestation) and `wrapEncryptionKey` (X25519 sealing) +// need a real signed chain / valid keys, so they are stubbed here; the rest of +// the UKYC layer (local-user-secret storage adapter, client-material +// derivation) runs for real so the controller's messenger wiring is exercised. +// Return values are (re)configured per test in `withController` because the +// shared jest config enables `resetMocks`. +jest.mock('./ukyc/jwtChain', () => { + const actual = jest.requireActual('./ukyc/jwtChain'); + return { + ...actual, + verifyJwtChain: jest.fn(), + }; +}); +jest.mock('./ukyc/wrapEncryptionKey', () => { + const actual = jest.requireActual('./ukyc/wrapEncryptionKey'); + return { + ...actual, + wrapEncryptionKey: jest.fn(), + }; +}); + +const mockVerifyJwtChain = verifyJwtChain as jest.MockedFunction< + typeof verifyJwtChain +>; +const mockWrapEncryptionKey = wrapEncryptionKey as jest.MockedFunction< + typeof wrapEncryptionKey +>; + +const MOCK_SESSION_DISCLAIMERS: KycSessionDisclaimers = { + idOS: [ + { + key: 'idos-tos', + version: '1', + title: 'idOS ToS', + url: 'https://idos.example/tos', + consented: false, + }, + ], + kycProvider: [ + { + key: 'sumsub-tos', + version: '1', + title: 'SumSub ToS', + url: 'https://sumsub.example/tos', + consented: false, + }, + ], + credentialReusabilityConsentGiven: false, +}; + +/** + * Builds an encrypted envelope for a recipient's X25519 public key. + * + * @param publicKey - The recipient's public key bytes. + * @param credentials - The plaintext credentials to encrypt. + * @returns The encrypted envelope. + */ +function makeEnvelope( + publicKey: Uint8Array, + credentials: Record, +): { ephemeralPublicKey: string; iv: string; ciphertext: string } { + const ephemeralPrivate = x25519.utils.randomSecretKey(); + const ephemeralPublic = x25519.getPublicKey(ephemeralPrivate); + const shared = x25519.getSharedSecret(ephemeralPrivate, publicKey); + const key = hkdf(sha256, shared, undefined, undefined, 32); + const iv = new Uint8Array(12).fill(7); + const ciphertext = gcm(key, iv).encrypt( + utf8ToBytes(JSON.stringify(credentials)), + ); + return { + ephemeralPublicKey: bytesToHex(ephemeralPublic), + iv: bytesToHex(iv), + ciphertext: bytesToHex(ciphertext), + }; +} + +/** + * Extracts the controller's ephemeral public key from the Check-frame URL and + * builds a decryptable credentials envelope for it. + * + * @param controller - The controller under test (must have a session token). + * @param credentials - The plaintext credentials to encrypt. + * @returns The encrypted envelope. + */ +function envelopeFor( + controller: KycController, + credentials: Record, +): { ephemeralPublicKey: string; iv: string; ciphertext: string } { + const url = controller.buildCheckFrameUrl(); + const publicKeyHex = new URL(url as string).searchParams.get( + 'publicKey', + ) as string; + return makeEnvelope(hexToBytes(publicKeyHex), credentials); +} + +describe('KycController', () => { + describe('constructor', () => { + it('accepts initial state merged over defaults', async () => { + await withController( + { options: { state: { phase: 'form' } } }, + ({ controller }) => { + expect(controller.state.phase).toBe('form'); + expect(controller.state.sumsub.status).toBe('idle'); + }, + ); + }); + }); + + describe('initialize', () => { + it('auto-creates a session when terms and email are present', async () => { + await withController( + { + options: { + state: { + termsAcceptedAt: 't', + acceptedDisclaimerIds: ['1'], + termsAcceptedVendor: 'moonpay', + }, + }, + }, + async ({ controller, handlers }) => { + handlers.getGeoCountry.mockResolvedValue('USA'); + handlers.createSession.mockResolvedValue({ sessionToken: 'sess' }); + + await controller.initialize({ email: 'a@b.co' }); + + expect(controller.state.geoCountry).toBe('USA'); + expect(controller.state.sessionToken).toBe('sess'); + expect(controller.state.phase).toBe('check'); + }, + ); + }); + + it('falls back to the terms phase and loads disclaimers when geo fails and no terms exist', async () => { + await withController(async ({ controller, handlers }) => { + handlers.getGeoCountry.mockRejectedValue(new Error('geo down')); + + await controller.initialize(); + + expect(controller.state.phase).toBe('terms'); + expect(controller.state.disclaimersError).toMatch(/Failed to load/u); + }); + }); + + it('captures the active product for the automatic post-auth continuation', async () => { + await withController(async ({ controller, handlers }) => { + handlers.getGeoCountry.mockResolvedValue('USA'); + handlers.fetchDisclaimers.mockResolvedValue([]); + + await controller.initialize({ product: 'card' }); + + expect(controller.state.activeProduct).toBe('card'); + }); + }); + + it('clears a stale active product when re-initialized without one', async () => { + await withController( + { options: { state: { activeProduct: 'card' } } }, + async ({ controller, handlers }) => { + handlers.getGeoCountry.mockResolvedValue('USA'); + handlers.fetchDisclaimers.mockResolvedValue([]); + + await controller.initialize({ email: 'a@b.co' }); + + expect(controller.state.activeProduct).toBeNull(); + }, + ); + }); + + it('does not restart an in-progress session flow', async () => { + await withController( + { + options: { + state: { + phase: 'check', + email: 'a@b.co', + sessionToken: 'live-session', + termsAcceptedAt: 't', + acceptedDisclaimerIds: ['1'], + activeProduct: 'ramps', + activeVendor: 'moonpay', + moonpayCustomerId: 'cust-1', + }, + }, + }, + async ({ controller, handlers }) => { + await controller.initialize({ + email: 'other@b.co', + product: 'card', + vendor: 'iron', + }); + + // A repeat initialize mid-flow must be a no-op: no new session, no + // token/phase teardown, no vendor switch, and no clobbering of the + // active product. + expect(handlers.createSession).not.toHaveBeenCalled(); + expect(handlers.getGeoCountry).not.toHaveBeenCalled(); + expect(handlers.createVendorCustomer).not.toHaveBeenCalled(); + expect(controller.state.phase).toBe('check'); + expect(controller.state.sessionToken).toBe('live-session'); + expect(controller.state.activeProduct).toBe('ramps'); + expect(controller.state.email).toBe('a@b.co'); + expect(controller.state.activeVendor).toBe('moonpay'); + expect(controller.state.moonpayCustomerId).toBe('cust-1'); + }, + ); + }); + + it('stays on terms when terms exist but no email is available', async () => { + await withController( + { + options: { + state: { termsAcceptedAt: 't', acceptedDisclaimerIds: ['1'] }, + }, + }, + async ({ controller, handlers }) => { + handlers.getGeoCountry.mockResolvedValue('USA'); + handlers.fetchDisclaimers.mockResolvedValue([]); + + await controller.initialize(); + + expect(controller.state.phase).toBe('terms'); + }, + ); + }); + }); + + describe('loadDisclaimers', () => { + it('loads disclaimers for a provided country', async () => { + await withController(async ({ controller, handlers }) => { + const disclaimers = [{ id: '1', display_name: 'T', url: 'u' }]; + handlers.fetchDisclaimers.mockResolvedValue(disclaimers); + + await controller.loadDisclaimers({ country: 'USA' }); + + expect(controller.state.disclaimers).toStrictEqual(disclaimers); + expect(handlers.getGeoCountry).not.toHaveBeenCalled(); + }); + }); + + it('caches the provided country override in geoCountry', async () => { + await withController(async ({ controller, handlers }) => { + handlers.fetchDisclaimers.mockResolvedValue([]); + + await controller.loadDisclaimers({ country: 'USA' }); + + expect(controller.state.geoCountry).toBe('USA'); + }); + }); + + it('lets a later checkKycRequired reuse the overridden country without an override', async () => { + await withController( + { options: { state: { accessToken: 'a' } } }, + async ({ controller, handlers }) => { + handlers.fetchDisclaimers.mockResolvedValue([]); + handlers.checkKycRequired.mockResolvedValue({ kycRequired: true }); + + await controller.loadDisclaimers({ country: 'USA' }); + await controller.checkKycRequired({ product: 'ramps' }); + + expect(handlers.getGeoCountry).not.toHaveBeenCalled(); + expect(handlers.checkKycRequired).toHaveBeenCalledWith({ + accessToken: 'a', + country: 'USA', + capabilities: [{ product: 'ramps' }], + }); + expect(controller.state.error).toBeNull(); + }, + ); + }); + + it('uses the cached geoCountry when no country is provided', async () => { + await withController( + { options: { state: { geoCountry: 'USA' } } }, + async ({ controller, handlers }) => { + handlers.fetchDisclaimers.mockResolvedValue([]); + + await controller.loadDisclaimers(); + + expect(handlers.getGeoCountry).not.toHaveBeenCalled(); + expect(handlers.fetchDisclaimers).toHaveBeenCalledWith({ + vendor: 'moonpay', + country: 'USA', + }); + }, + ); + }); + + it('resolves the country when neither param nor cache is available', async () => { + await withController(async ({ controller, handlers }) => { + handlers.getGeoCountry.mockResolvedValue('FRA'); + handlers.fetchDisclaimers.mockResolvedValue([]); + + await controller.loadDisclaimers(); + + expect(controller.state.geoCountry).toBe('FRA'); + expect(handlers.fetchDisclaimers).toHaveBeenCalledWith({ + vendor: 'moonpay', + country: 'FRA', + }); + }); + }); + + it('records an error when loading fails', async () => { + await withController(async ({ controller, handlers }) => { + handlers.fetchDisclaimers.mockRejectedValue(new Error('boom')); + + await controller.loadDisclaimers({ country: 'USA' }); + + expect(controller.state.disclaimersError).toMatch(/boom/u); + }); + }); + }); + + describe('acceptTermsAndStartSession', () => { + it('captures terms and creates a session', async () => { + await withController( + { + options: { + state: { disclaimers: [{ id: '1', display_name: 'T', url: 'u' }] }, + }, + }, + async ({ controller, handlers }) => { + handlers.createSession.mockResolvedValue({ sessionToken: 'sess' }); + + await controller.acceptTermsAndStartSession({ + email: 'a@b.co', + product: 'ramps', + sumsubTncSigned: true, + idosTncSigned: true, + }); + + expect(controller.state.acceptedDisclaimerIds).toStrictEqual(['1']); + expect(controller.state.termsAcceptedAt).not.toBeNull(); + expect(controller.state.activeProduct).toBe('ramps'); + expect(controller.state.phase).toBe('check'); + }, + ); + }); + + it('fails when T&C2 flags are omitted', async () => { + await withController( + { + options: { + state: { + email: 'a@b.co', + disclaimers: [{ id: '1', display_name: 'T', url: 'u' }], + }, + }, + }, + async ({ controller, handlers }) => { + // @ts-expect-error T&C2 flags are required + await controller.acceptTermsAndStartSession(); + + expect(controller.state.phase).toBe('error'); + expect(controller.state.error).toMatch(/Missing T&C2 acceptance/u); + expect(controller.state.termsAcceptedAt).toBeNull(); + expect(handlers.createSession).not.toHaveBeenCalled(); + }, + ); + }); + + it('persists required T&C2 flags on a MoonPay session', async () => { + await withController( + { + options: { + state: { + email: 'a@b.co', + disclaimers: [{ id: '1', display_name: 'T', url: 'u' }], + }, + }, + }, + async ({ controller, handlers }) => { + handlers.createSession.mockResolvedValue({ sessionToken: 'sess' }); + + await controller.acceptTermsAndStartSession({ + sumsubTncSigned: true, + idosTncSigned: true, + }); + + expect(controller.state.acceptedDisclaimerIds).toStrictEqual(['1']); + expect(controller.state.termsAcceptedVendor).toBe('moonpay'); + expect(controller.state.sumsubTncAccepted).toBe(true); + expect(controller.state.idosTncAccepted).toBe(true); + expect(controller.state.phase).toBe('check'); + expect(handlers.submitVendorDisclaimers).not.toHaveBeenCalled(); + }, + ); + }); + + it('clears stale auth tokens when a new session is created', async () => { + await withController( + { + options: { + state: { + phase: 'check', + email: 'a@b.co', + sessionToken: 'old-session', + accessToken: 'stale-access', + disclaimers: [{ id: '1', display_name: 'T', url: 'u' }], + }, + }, + }, + async ({ controller, handlers }) => { + handlers.createSession.mockResolvedValue({ + sessionToken: 'new-session', + }); + + // Establish an auth-frame client token from a prior authentication. + const envelope = envelopeFor(controller, { + clientToken: 'old-client', + }); + await controller.handleFrameMessage({ + message: { + kind: 'complete', + meta: { channelId: 'ch_1' }, + payload: { status: 'connectionRequired', credentials: envelope }, + }, + }); + expect(controller.buildAuthFrameUrl()).toContain( + 'clientToken=old-client', + ); + + // Creating a new session must invalidate the carried-over auth. + await controller.acceptTermsAndStartSession({ + sumsubTncSigned: true, + idosTncSigned: true, + }); + + expect(controller.state.accessToken).toBeNull(); + expect(controller.buildAuthFrameUrl()).toBeNull(); + expect(controller.state.sessionToken).toBe('new-session'); + }, + ); + }); + + it('clears the old session token while a new session is being created', async () => { + await withController( + { + options: { + state: { + email: 'a@b.co', + sessionToken: 'old-session', + disclaimers: [{ id: '1', display_name: 'T', url: 'u' }], + }, + }, + }, + async ({ controller, handlers }) => { + let releaseSession: (value: { + sessionToken: string; + }) => void = () => { + // no-op placeholder until the deferred promise is wired up + }; + handlers.createSession.mockReturnValue( + new Promise<{ sessionToken: string }>((resolve) => { + releaseSession = resolve; + }), + ); + + const pending = controller.acceptTermsAndStartSession({ + sumsubTncSigned: true, + idosTncSigned: true, + }); + + // While the request is in flight (phase `session`) the stale token + // must already be gone so no Check frame URL can be built for it. + expect(controller.state.phase).toBe('session'); + expect(controller.state.sessionToken).toBeNull(); + expect(controller.buildCheckFrameUrl()).toBeNull(); + + releaseSession({ sessionToken: 'new-session' }); + await pending; + + expect(controller.state.sessionToken).toBe('new-session'); + expect(controller.buildCheckFrameUrl()).toContain( + 'sessionToken=new-session', + ); + }, + ); + }); + + it('reverts to terms when session creation fails', async () => { + await withController( + { + options: { + state: { + email: 'a@b.co', + sessionToken: 'old-session', + disclaimers: [{ id: '1', display_name: 'T', url: 'u' }], + }, + }, + }, + async ({ controller, handlers }) => { + handlers.createSession.mockRejectedValue(new Error('nope')); + handlers.fetchDisclaimers.mockResolvedValue([]); + + await controller.acceptTermsAndStartSession({ + sumsubTncSigned: true, + idosTncSigned: true, + }); + + expect(controller.state.phase).toBe('terms'); + expect(controller.state.termsAcceptedAt).toBeNull(); + expect(controller.state.error).toMatch(/Session creation failed/u); + // A failed creation must not leave the old session token behind, so + // the Check frame cannot be built against an invalid session. + expect(controller.state.sessionToken).toBeNull(); + expect(controller.buildCheckFrameUrl()).toBeNull(); + }, + ); + }); + + it('leaves the controller idle when reset() runs before session creation fails', async () => { + await withController( + { + options: { + state: { + email: 'a@b.co', + sessionToken: 'old-session', + disclaimers: [{ id: '1', display_name: 'T', url: 'u' }], + }, + }, + }, + async ({ controller, handlers }) => { + let rejectSession: (reason: Error) => void = () => { + // no-op placeholder until the deferred promise is wired up + }; + handlers.createSession.mockReturnValue( + new Promise<{ sessionToken: string }>((_resolve, reject) => { + rejectSession = reject; + }), + ); + + const pending = controller.acceptTermsAndStartSession({ + sumsubTncSigned: true, + idosTncSigned: true, + }); + + // Reset while the create request is in flight, then let it fail. The + // superseded flow must not force the now-idle controller back to + // `terms` or re-run disclaimer loading. + controller.reset(); + rejectSession(new Error('nope')); + await pending; + + expect(controller.state.phase).toBe('idle'); + expect(controller.state.error).toBeNull(); + expect(handlers.fetchDisclaimers).not.toHaveBeenCalled(); + }, + ); + }); + + it('clears the active product when session creation fails', async () => { + await withController( + { + options: { + state: { + email: 'a@b.co', + activeProduct: 'card', + disclaimers: [{ id: '1', display_name: 'T', url: 'u' }], + }, + }, + }, + async ({ controller, handlers }) => { + handlers.createSession.mockRejectedValue(new Error('nope')); + handlers.fetchDisclaimers.mockResolvedValue([]); + + await controller.acceptTermsAndStartSession({ + product: 'ramps', + sumsubTncSigned: true, + idosTncSigned: true, + }); + + // The failed flow must not leave a lingering product behind that a + // later product-less `acceptTermsAndStartSession` would auto-run. + expect(controller.state.phase).toBe('terms'); + expect(controller.state.activeProduct).toBeNull(); + }, + ); + }); + + it('fails when no email is available', async () => { + await withController( + { + options: { + state: { disclaimers: [{ id: '1', display_name: 'T', url: 'u' }] }, + }, + }, + async ({ controller }) => { + await controller.acceptTermsAndStartSession({ + sumsubTncSigned: true, + idosTncSigned: true, + }); + + expect(controller.state.phase).toBe('error'); + expect(controller.state.error).toMatch(/Missing email/u); + }, + ); + }); + + it('fails when no disclaimers were accepted', async () => { + await withController(async ({ controller }) => { + await controller.acceptTermsAndStartSession({ + email: 'a@b.co', + sumsubTncSigned: true, + idosTncSigned: true, + }); + + expect(controller.state.phase).toBe('error'); + expect(controller.state.error).toMatch(/Missing terms acceptance/u); + }); + }); + }); + + describe('clearSavedTerms', () => { + it('clears persisted terms', async () => { + await withController( + { + options: { + state: { termsAcceptedAt: 't', acceptedDisclaimerIds: ['1'] }, + }, + }, + ({ controller }) => { + controller.clearSavedTerms(); + expect(controller.state.termsAcceptedAt).toBeNull(); + expect(controller.state.acceptedDisclaimerIds).toStrictEqual([]); + }, + ); + }); + }); + + describe('handleFrameMessage', () => { + it('acks a handshake', async () => { + await withController(async ({ controller }) => { + const result = await controller.handleFrameMessage({ + message: { kind: 'handshake', meta: { channelId: 'ch_1' } }, + }); + expect(result).toStrictEqual({ + reply: { version: 2, meta: { channelId: 'ch_1' }, kind: 'ack' }, + }); + }); + }); + + it('ignores undefined and non-complete messages', async () => { + await withController(async ({ controller }) => { + expect( + await controller.handleFrameMessage({ message: undefined }), + ).toStrictEqual({}); + expect( + await controller.handleFrameMessage({ message: { kind: 'other' } }), + ).toStrictEqual({}); + }); + }); + + it('captures the customer id and ignores a status-less complete message', async () => { + await withController( + { options: { state: { phase: 'check' } } }, + async ({ controller }) => { + const result = await controller.handleFrameMessage({ + message: { + kind: 'complete', + meta: { channelId: 'ch_1' }, + payload: { customer: { id: 'cust-1' } }, + }, + }); + expect(result).toStrictEqual({}); + expect(controller.state.moonpayCustomerId).toBe('cust-1'); + }, + ); + }); + + it('ignores messages on an unknown channel', async () => { + await withController(async ({ controller }) => { + const result = await controller.handleFrameMessage({ + message: { + kind: 'complete', + meta: { channelId: 'ch_unknown' }, + payload: { status: 'active' }, + }, + }); + expect(result).toStrictEqual({}); + }); + }); + + it('ignores a stale completion for a frame the flow is no longer waiting on', async () => { + // Phase `done` (e.g. after a completed flow or a `reset()` that returns + // to an idle phase) means the Check frame is no longer active; a late or + // duplicate `ch_1` completion must not resurrect tokens or rewind phase. + await withController( + { options: { state: { phase: 'done', sessionToken: 'tok' } } }, + async ({ controller }) => { + const envelope = envelopeFor(controller, { accessToken: 'access-1' }); + const result = await controller.handleFrameMessage({ + message: { + kind: 'complete', + meta: { channelId: 'ch_1' }, + payload: { + status: 'active', + credentials: envelope, + customer: { id: 'cust-late' }, + }, + }, + }); + expect(result).toStrictEqual({}); + expect(controller.state.phase).toBe('done'); + expect(controller.state.accessToken).toBeNull(); + expect(controller.state.moonpayCustomerId).toBeNull(); + }, + ); + }); + + it('ignores a Check complete when the active vendor is not MoonPay', async () => { + await withController( + { + options: { + state: { + phase: 'check', + activeVendor: 'iron', + sessionToken: 'tok', + }, + }, + }, + async ({ controller }) => { + const result = await controller.handleFrameMessage({ + message: { + kind: 'complete', + meta: { channelId: 'ch_1' }, + payload: { + status: 'active', + credentials: 'not-decryptable', + customer: { id: 'cust-late' }, + }, + }, + }); + + expect(result).toStrictEqual({}); + expect(controller.state.phase).toBe('check'); + expect(controller.state.accessToken).toBeNull(); + expect(controller.state.moonpayCustomerId).toBeNull(); + expect(controller.getCustomerIdentity()).toBeNull(); + }, + ); + }); + + it('fails when credential decryption throws', async () => { + await withController( + { options: { state: { phase: 'check', sessionToken: 'tok' } } }, + async ({ controller }) => { + await controller.handleFrameMessage({ + message: { + kind: 'complete', + meta: { channelId: 'ch_1' }, + payload: { status: 'active', credentials: 'not-decryptable' }, + }, + }); + expect(controller.state.phase).toBe('error'); + expect(controller.state.error).toMatch(/Failed to decrypt/u); + }, + ); + }); + + describe('check frame', () => { + it('moves to form on an active status with an access token', async () => { + await withController( + { options: { state: { phase: 'check', sessionToken: 'tok' } } }, + async ({ controller }) => { + const envelope = envelopeFor(controller, { + accessToken: 'access-1', + }); + await controller.handleFrameMessage({ + message: { + kind: 'complete', + meta: { channelId: 'ch_1' }, + payload: { status: 'active', credentials: envelope }, + }, + }); + expect(controller.state.phase).toBe('form'); + expect(controller.state.accessToken).toBe('access-1'); + }, + ); + }); + + it('moves to auth on connectionRequired and enables the auth frame URL', async () => { + await withController( + { options: { state: { phase: 'check', sessionToken: 'tok' } } }, + async ({ controller }) => { + const envelope = envelopeFor(controller, { + clientToken: 'client-1', + }); + await controller.handleFrameMessage({ + message: { + kind: 'complete', + meta: { channelId: 'ch_1' }, + payload: { + status: 'connectionRequired', + credentials: envelope, + }, + }, + }); + expect(controller.state.phase).toBe('auth'); + expect(controller.buildAuthFrameUrl()).toContain( + 'clientToken=client-1', + ); + }, + ); + }); + + it('requires re-acceptance on termsAcceptanceRequired', async () => { + await withController( + { + options: { + state: { + phase: 'check', + sessionToken: 'tok', + termsAcceptedAt: 't', + acceptedDisclaimerIds: ['1'], + }, + }, + }, + async ({ controller }) => { + await controller.handleFrameMessage({ + message: { + kind: 'complete', + meta: { channelId: 'ch_1' }, + payload: { status: 'termsAcceptanceRequired' }, + }, + }); + expect(controller.state.phase).toBe('terms'); + expect(controller.state.termsAcceptedAt).toBeNull(); + }, + ); + }); + + it('fails on an unexpected status', async () => { + await withController( + { options: { state: { phase: 'check', sessionToken: 'tok' } } }, + async ({ controller }) => { + await controller.handleFrameMessage({ + message: { + kind: 'complete', + meta: { channelId: 'ch_1' }, + payload: { status: 'failed' }, + }, + }); + expect(controller.state.phase).toBe('error'); + }, + ); + }); + }); + + describe('auth frame', () => { + it('moves to form on an active status with an access token', async () => { + await withController( + { options: { state: { phase: 'auth', sessionToken: 'tok' } } }, + async ({ controller }) => { + const envelope = envelopeFor(controller, { + accessToken: 'access-2', + }); + await controller.handleFrameMessage({ + message: { + kind: 'complete', + meta: { channelId: 'ch_2' }, + payload: { status: 'active', credentials: envelope }, + }, + }); + expect(controller.state.phase).toBe('form'); + expect(controller.state.accessToken).toBe('access-2'); + }, + ); + }); + + it('requires re-acceptance on termsAcceptanceRequired', async () => { + await withController( + { options: { state: { phase: 'auth' } } }, + async ({ controller }) => { + await controller.handleFrameMessage({ + message: { + kind: 'complete', + meta: { channelId: 'ch_2' }, + payload: { status: 'termsAcceptanceRequired' }, + }, + }); + expect(controller.state.phase).toBe('terms'); + }, + ); + }); + + it('fails on an unexpected status', async () => { + await withController( + { options: { state: { phase: 'auth' } } }, + async ({ controller }) => { + await controller.handleFrameMessage({ + message: { + kind: 'complete', + meta: { channelId: 'ch_2' }, + payload: { status: 'unavailable' }, + }, + }); + expect(controller.state.phase).toBe('error'); + }, + ); + }); + }); + }); + + describe('automatic post-authentication continuation', () => { + it('stays at form and does not run the check when no product is set', async () => { + await withController( + { + options: { + state: { phase: 'check', sessionToken: 'tok', geoCountry: 'USA' }, + }, + }, + async ({ controller, handlers }) => { + const envelope = envelopeFor(controller, { accessToken: 'access-1' }); + + await controller.handleFrameMessage({ + message: { + kind: 'complete', + meta: { channelId: 'ch_1' }, + payload: { status: 'active', credentials: envelope }, + }, + }); + + expect(controller.state.phase).toBe('form'); + expect(handlers.checkKycRequired).not.toHaveBeenCalled(); + }, + ); + }); + + it('auto-runs the KYC check on reaching form and stops at done when KYC is not required', async () => { + await withController( + { + options: { + state: { + phase: 'check', + sessionToken: 'tok', + activeProduct: 'ramps', + geoCountry: 'USA', + }, + }, + }, + async ({ controller, handlers, launcher }) => { + handlers.checkKycRequired.mockResolvedValue({ kycRequired: false }); + const envelope = envelopeFor(controller, { accessToken: 'access-1' }); + + await controller.handleFrameMessage({ + message: { + kind: 'complete', + meta: { channelId: 'ch_1' }, + payload: { status: 'active', credentials: envelope }, + }, + }); + + expect(handlers.checkKycRequired).toHaveBeenCalledWith({ + accessToken: 'access-1', + country: 'USA', + capabilities: [{ product: 'ramps' }], + }); + expect(controller.state.kycRequiredByProduct.ramps).toBe(false); + expect(controller.state.phase).toBe('done'); + expect(launcher.launch).not.toHaveBeenCalled(); + }, + ); + }); + + it('auto-chains into document verification when KYC is required (via the auth frame)', async () => { + await withController( + { + options: { + state: { + phase: 'auth', + sessionToken: 'tok', + activeProduct: 'card', + geoCountry: 'FRA', + }, + }, + }, + async ({ controller, handlers, launcher }) => { + handlers.checkKycRequired.mockResolvedValue({ kycRequired: true }); + launcher.launch.mockImplementation(async ({ onStatusChange }) => { + onStatusChange?.('InProgress', 'Completed'); + return { ok: true }; + }); + const envelope = envelopeFor(controller, { accessToken: 'access-2' }); + + await controller.handleFrameMessage({ + message: { + kind: 'complete', + meta: { channelId: 'ch_2' }, + payload: { status: 'active', credentials: envelope }, + }, + }); + + expect(controller.state.kycRequiredByProduct.card).toBe(true); + expect(launcher.launch).toHaveBeenCalledTimes(1); + expect(controller.state.sumsub.status).toBe('complete'); + }, + ); + }); + + it('records a failed sub-flow without throwing when verification is required but the SDK is unavailable', async () => { + await withController( + { + options: { + state: { + phase: 'check', + sessionToken: 'tok', + activeProduct: 'ramps', + geoCountry: 'USA', + }, + }, + }, + async ({ controller, handlers, launcher }) => { + handlers.checkKycRequired.mockResolvedValue({ kycRequired: true }); + launcher.isAvailable.mockReturnValue(false); + const envelope = envelopeFor(controller, { accessToken: 'access-1' }); + + const result = await controller.handleFrameMessage({ + message: { + kind: 'complete', + meta: { channelId: 'ch_1' }, + payload: { status: 'active', credentials: envelope }, + }, + }); + + expect(result).toStrictEqual({}); + expect(controller.state.sumsub.status).toBe('failed'); + }, + ); + }); + + it('ignores a duplicate completion while a prior continuation is in flight', async () => { + await withController( + { + options: { + state: { + phase: 'auth', + sessionToken: 'tok', + activeProduct: 'card', + geoCountry: 'FRA', + }, + }, + }, + async ({ controller, handlers, launcher }) => { + // Hold the KYC-required check open so the first continuation is still + // in flight when the second (duplicate) completion arrives. The first + // completion moves `phase` to `form` synchronously, so the duplicate + // is dropped by the frame-phase guard before it can re-run the check. + let releaseCheck: (value: { kycRequired: boolean }) => void = () => { + // no-op placeholder until the deferred promise is wired up + }; + handlers.checkKycRequired.mockReturnValue( + new Promise<{ kycRequired: boolean }>((resolve) => { + releaseCheck = resolve; + }), + ); + launcher.launch.mockImplementation(async ({ onStatusChange }) => { + onStatusChange?.('InProgress', 'Completed'); + return { ok: true }; + }); + const envelope = envelopeFor(controller, { accessToken: 'access-1' }); + const message = { + kind: 'complete', + meta: { channelId: 'ch_2' }, + payload: { status: 'active', credentials: envelope }, + }; + + const first = controller.handleFrameMessage({ message }); + const second = controller.handleFrameMessage({ message }); + + releaseCheck({ kycRequired: true }); + await Promise.all([first, second]); + + expect(handlers.checkKycRequired).toHaveBeenCalledTimes(1); + expect(launcher.launch).toHaveBeenCalledTimes(1); + expect(controller.state.sumsub.status).toBe('complete'); + }, + ); + }); + + it('allows a fresh flow to continue after a reset interrupts an in-flight continuation', async () => { + await withController( + { + options: { + state: { + phase: 'check', + email: 'a@b.co', + sessionToken: 'tok', + activeProduct: 'ramps', + geoCountry: 'USA', + // Persisted terms so a post-reset `initialize` auto-recreates the + // session (reaching phase `check`) for the second completion. + termsAcceptedAt: 't', + acceptedDisclaimerIds: ['1'], + termsAcceptedVendor: 'moonpay', + }, + }, + }, + async ({ controller, handlers }) => { + // The keypair is stable across reset, so both envelopes can be built + // up front while the session token (used only to derive the public + // key here) is still present. + const envelope1 = envelopeFor(controller, { + accessToken: 'access-1', + }); + const envelope2 = envelopeFor(controller, { + accessToken: 'access-2', + }); + const messageFor = ( + credentials: unknown, + ): { + kind: string; + meta: { channelId: string }; + payload: { status: string; credentials: unknown }; + } => ({ + kind: 'complete', + meta: { channelId: 'ch_1' }, + payload: { status: 'active', credentials }, + }); + + // Hold the first continuation open so a reset can land while it is + // still in flight. + let releaseCheck: (value: { kycRequired: boolean }) => void = () => { + // no-op placeholder until the deferred promise is wired up + }; + handlers.checkKycRequired.mockReturnValueOnce( + new Promise<{ kycRequired: boolean }>((resolve) => { + releaseCheck = resolve; + }), + ); + + const first = controller.handleFrameMessage({ + message: messageFor(envelope1), + }); + + // Reset while the continuation is awaiting the check. Its result is + // discarded by the generation guard (the check belongs to the + // superseded generation) rather than written onto the idle flow. + controller.reset(); + releaseCheck({ kycRequired: false }); + await first; + + // Re-establish a product-scoped flow (auto-creates a session and + // returns to phase `check`) and confirm the next completion continues + // again rather than being blocked forever by a stuck guard. + await controller.initialize({ product: 'ramps' }); + handlers.checkKycRequired.mockResolvedValue({ kycRequired: false }); + await controller.handleFrameMessage({ + message: messageFor(envelope2), + }); + + expect(handlers.checkKycRequired).toHaveBeenCalledTimes(2); + }, + ); + }); + + it('does not launch verification when the auto-run check fails', async () => { + await withController( + { + options: { + state: { + phase: 'check', + sessionToken: 'tok', + activeProduct: 'ramps', + geoCountry: 'USA', + }, + }, + }, + async ({ controller, handlers, launcher }) => { + handlers.checkKycRequired.mockRejectedValue(new Error('down')); + const envelope = envelopeFor(controller, { accessToken: 'access-1' }); + + await controller.handleFrameMessage({ + message: { + kind: 'complete', + meta: { channelId: 'ch_1' }, + payload: { status: 'active', credentials: envelope }, + }, + }); + + expect(controller.state.phase).toBe('error'); + expect(launcher.launch).not.toHaveBeenCalled(); + }, + ); + }); + }); + + describe('frame URL builders', () => { + it('returns null for the check frame without a session', async () => { + await withController(({ controller }) => { + expect(controller.buildCheckFrameUrl()).toBeNull(); + }); + }); + + it('builds the check frame URL with a session', async () => { + await withController( + { options: { state: { sessionToken: 'tok' } } }, + ({ controller }) => { + const url = controller.buildCheckFrameUrl() as string; + expect(url).toContain('sessionToken=tok'); + expect(url).toContain('channelId=ch_1'); + expect(url).toContain('skipKyc=true'); + }, + ); + }); + + it('returns null for the check frame when the active vendor is not MoonPay', async () => { + await withController( + { + options: { + state: { sessionToken: 'tok', activeVendor: 'iron' }, + }, + }, + ({ controller }) => { + expect(controller.buildCheckFrameUrl()).toBeNull(); + }, + ); + }); + + it('returns null for the auth frame without a client token', async () => { + await withController(({ controller }) => { + expect(controller.buildAuthFrameUrl()).toBeNull(); + }); + }); + + it('builds the reset frame URL', async () => { + await withController(({ controller }) => { + expect(controller.buildResetFrameUrl()).toContain('channelId=ch_reset'); + }); + }); + }); + + describe('checkKycRequired', () => { + it('fails without an access token', async () => { + await withController(async ({ controller }) => { + expect(await controller.checkKycRequired({ product: 'ramps' })).toBe( + false, + ); + expect(controller.state.error).toMatch(/Missing accessToken/u); + }); + }); + + it('fails without a country', async () => { + await withController( + { options: { state: { accessToken: 'a' } } }, + async ({ controller }) => { + expect(await controller.checkKycRequired({ product: 'ramps' })).toBe( + false, + ); + expect(controller.state.error).toMatch(/Missing country/u); + }, + ); + }); + + it('caches the result on success (cached country)', async () => { + await withController( + { options: { state: { accessToken: 'a', geoCountry: 'USA' } } }, + async ({ controller, handlers }) => { + handlers.checkKycRequired.mockResolvedValue({ kycRequired: true }); + + expect(await controller.checkKycRequired({ product: 'ramps' })).toBe( + true, + ); + expect(controller.state.kycRequiredByProduct.ramps).toBe(true); + expect(controller.state.phase).toBe('done'); + }, + ); + }); + + it('accepts a country override', async () => { + await withController( + { options: { state: { accessToken: 'a' } } }, + async ({ controller, handlers }) => { + handlers.checkKycRequired.mockResolvedValue({ kycRequired: false }); + + await controller.checkKycRequired({ + product: 'card', + country: 'FRA', + }); + + expect(handlers.checkKycRequired).toHaveBeenCalledWith({ + accessToken: 'a', + country: 'FRA', + capabilities: [{ product: 'card' }], + }); + }, + ); + }); + + it('fails when the service throws', async () => { + await withController( + { options: { state: { accessToken: 'a', geoCountry: 'USA' } } }, + async ({ controller, handlers }) => { + handlers.checkKycRequired.mockRejectedValue(new Error('down')); + + expect(await controller.checkKycRequired({ product: 'ramps' })).toBe( + false, + ); + expect(controller.state.error).toMatch(/KYC check failed/u); + }, + ); + }); + + it('discards a successful result when reset() runs while the check is in flight', async () => { + await withController( + { options: { state: { accessToken: 'a', geoCountry: 'USA' } } }, + async ({ controller, handlers }) => { + handlers.checkKycRequired.mockImplementation(async () => { + // Simulate a reset() landing while the HTTP call is in flight. + controller.reset(); + return { kycRequired: true }; + }); + + const result = await controller.checkKycRequired({ + product: 'ramps', + }); + + expect(result).toBe(false); + expect(controller.state.phase).toBe('idle'); + expect(controller.state.kycRequiredByProduct.ramps).toBeUndefined(); + expect(controller.state.lastCheckedAt).toBeNull(); + }, + ); + }); + + it('discards an error when reset() runs while the check is in flight', async () => { + await withController( + { options: { state: { accessToken: 'a', geoCountry: 'USA' } } }, + async ({ controller, handlers }) => { + handlers.checkKycRequired.mockImplementation(async () => { + controller.reset(); + throw new Error('down'); + }); + + const result = await controller.checkKycRequired({ + product: 'ramps', + }); + + expect(result).toBe(false); + expect(controller.state.phase).toBe('idle'); + expect(controller.state.error).toBeNull(); + }, + ); + }); + }); + + describe('getKycStatus', () => { + it('returns the cached value or undefined', async () => { + await withController( + { options: { state: { kycRequiredByProduct: { ramps: true } } } }, + ({ controller }) => { + expect(controller.getKycStatus({ product: 'ramps' })).toBe(true); + expect(controller.getKycStatus({ product: 'card' })).toBeUndefined(); + }, + ); + }); + }); + + describe('getCustomerIdentity', () => { + it('returns null before a vendor customer id is captured', async () => { + await withController(({ controller }) => { + expect(controller.getCustomerIdentity()).toBeNull(); + }); + }); + + it('returns the vendor-scoped identity once captured', async () => { + await withController( + { + options: { + state: { moonpayCustomerId: 'cust-1', activeVendor: 'moonpay' }, + }, + }, + ({ controller }) => { + expect(controller.getCustomerIdentity()).toStrictEqual({ + vendor: 'moonpay', + id: 'cust-1', + }); + }, + ); + }); + + it('returns null after reset clears the captured id', async () => { + await withController( + { + options: { + state: { moonpayCustomerId: 'cust-1', activeVendor: 'moonpay' }, + }, + }, + ({ controller }) => { + controller.reset(); + expect(controller.getCustomerIdentity()).toBeNull(); + }, + ); + }); + + it('drops a MoonPay id when initialize switches to another vendor', async () => { + await withController( + { + options: { + state: { + moonpayCustomerId: 'cust-1', + activeVendor: 'moonpay', + sessionToken: 'tok', + accessToken: 'access-1', + }, + }, + }, + async ({ controller }) => { + await controller.initialize({ vendor: 'iron' }); + + expect(controller.state.moonpayCustomerId).toBeNull(); + expect(controller.state.sessionToken).toBeNull(); + expect(controller.state.accessToken).toBeNull(); + expect(controller.buildCheckFrameUrl()).toBeNull(); + expect(controller.getCustomerIdentity()).toBeNull(); + }, + ); + }); + + it('keeps a MoonPay id when initialize stays on MoonPay', async () => { + await withController( + { + options: { + state: { + moonpayCustomerId: 'cust-1', + activeVendor: 'moonpay', + sessionToken: 'tok', + accessToken: 'access-1', + }, + }, + }, + async ({ controller }) => { + await controller.initialize({ vendor: 'moonpay' }); + + expect(controller.state.moonpayCustomerId).toBe('cust-1'); + expect(controller.state.sessionToken).toBe('tok'); + expect(controller.state.accessToken).toBe('access-1'); + expect(controller.buildCheckFrameUrl()).toContain('sessionToken=tok'); + }, + ); + }); + + it('drops a MoonPay id when a non-MoonPay customer is created', async () => { + await withController( + { + options: { + state: { + moonpayCustomerId: 'cust-1', + activeVendor: 'moonpay', + sessionToken: 'tok', + accessToken: 'access-1', + }, + }, + }, + async ({ controller }) => { + await controller.createVendorCustomer({ + vendor: 'iron', + email: 'a@b.co', + }); + + expect(controller.state.moonpayCustomerId).toBeNull(); + expect(controller.state.sessionToken).toBeNull(); + expect(controller.state.accessToken).toBeNull(); + expect(controller.buildCheckFrameUrl()).toBeNull(); + expect(controller.getCustomerIdentity()).toBeNull(); + }, + ); + }); + + it('returns null when a MoonPay id is present under another vendor', async () => { + await withController( + { + options: { + state: { moonpayCustomerId: 'cust-1', activeVendor: 'iron' }, + }, + }, + ({ controller }) => { + expect(controller.getCustomerIdentity()).toBeNull(); + }, + ); + }); + + it('keeps a MoonPay id when createVendorCustomer stays on MoonPay', async () => { + await withController( + { + options: { + state: { + moonpayCustomerId: 'cust-1', + activeVendor: 'moonpay', + sessionToken: 'tok', + accessToken: 'access-1', + }, + }, + }, + async ({ controller, handlers }) => { + handlers.createVendorCustomer.mockResolvedValue({ + id: 'mp-1', + email: 'a@b.co', + status: 'active', + }); + + await controller.createVendorCustomer({ + vendor: 'moonpay', + email: 'a@b.co', + }); + + expect(controller.state.moonpayCustomerId).toBe('cust-1'); + expect(controller.state.sessionToken).toBe('tok'); + expect(controller.state.accessToken).toBe('access-1'); + }, + ); + }); + }); + + describe('startSumSub', () => { + it('throws and marks failed when the SDK is unavailable', async () => { + await withController(async ({ controller, launcher }) => { + launcher.isAvailable.mockReturnValue(false); + + await expect(controller.startSumSub()).rejects.toThrow( + /not available/u, + ); + expect(controller.state.sumsub.status).toBe('failed'); + }); + }); + + it('runs the full sub-flow and completes', async () => { + await withController( + { options: { state: { geoCountry: 'USA' } } }, + async ({ controller, handlers, launcher }) => { + launcher.launch.mockImplementation( + async ({ onStatusChange, onTokenExpiration }) => { + onStatusChange?.('idle', 'InProgress'); + onStatusChange?.('InProgress', 'Completed'); + await onTokenExpiration(); + return { ok: true }; + }, + ); + + const result = await controller.startSumSub({ + locale: 'fr', + debug: true, + }); + + expect(result).toStrictEqual({ ok: true }); + expect(controller.state.sumsub.status).toBe('complete'); + expect(controller.state.sumsub.applicantAccessToken).toBe('aat'); + // Session creation returns encryption schemas; wrapping happens on + // the client and both secrets are posted via authorizations. + expect(handlers.createUkycSession).toHaveBeenCalledWith( + expect.objectContaining({ + jwtToken: 'mock-jwt-token', + sessionClientPublicKey: + expect.stringMatching(/^[A-Za-z0-9_-]+$/u), + residenceCountry: 'USA', + vendorMetadata: expect.objectContaining({ + moonPayAccessToken: null, + moonPayUserId: null, + }), + }), + ); + expect(handlers.fetchIdosEnclaveJwks).toHaveBeenCalledTimes(1); + expect(handlers.fetchIdosRelayJwks).toHaveBeenCalledTimes(1); + expect(mockVerifyJwtChain).toHaveBeenCalledTimes(2); + expect(mockVerifyJwtChain).toHaveBeenNthCalledWith( + 1, + [], + 'jwt.chain.sig', + ); + expect(mockVerifyJwtChain).toHaveBeenNthCalledWith( + 2, + [], + 'jwt.chain.sig', + ); + const { sessionClientPublicKey } = handlers.createUkycSession.mock + .calls[0][0] as { + sessionClientPublicKey: string; + }; + const sessionClientPublicKeyBytes = base64UrlToBytes( + sessionClientPublicKey, + ); + expect(sessionClientPublicKeyBytes).toHaveLength(32); + expect( + areUint8ArraysEqual( + x25519.getPublicKey(mockWrapEncryptionKey.mock.calls[0][0]), + sessionClientPublicKeyBytes, + ), + ).toBe(true); + expect( + toBase64Url( + x25519.getPublicKey(mockWrapEncryptionKey.mock.calls[1][0]), + ), + ).toBe(sessionClientPublicKey); + expect( + handlers.createUkycSession.mock.calls[0][0], + ).not.toHaveProperty('wrappedEncryptionKey'); + expect( + handlers.createUkycSession.mock.calls[0][0], + ).not.toHaveProperty('ukycCapabilityToken'); + expect(mockWrapEncryptionKey).toHaveBeenCalledTimes(2); + // First wrap is the 32-byte data_encryption_key; second is the + // encoded capability token (longer than a raw key). + expect(mockWrapEncryptionKey.mock.calls[0][1]).toBe('spk-x'); + expect(mockWrapEncryptionKey.mock.calls[0][2]).toHaveLength(32); + expect(mockWrapEncryptionKey.mock.calls[1][1]).toBe('spk-x'); + expect(mockWrapEncryptionKey.mock.calls[1][2].length).toBeGreaterThan( + 32, + ); + // The capability token is wrapped as the UTF-8 bytes of the same + // compact header encoding previously sent as a plaintext field. + expect(bytesToString(mockWrapEncryptionKey.mock.calls[1][2])).toMatch( + /^[A-Za-z0-9\-_]+$/u, + ); + expect(handlers.setAuthorizations).toHaveBeenCalledWith({ + sessionId: 'sid', + wrappedEncryptionDataKey: { data: 'enc', nonce: 'nonce' }, + wrappedUkycCapabilityToken: { data: 'enc', nonce: 'nonce' }, + }); + // onTokenExpiration re-fetches the applicant access token. + expect(handlers.createJourney).toHaveBeenCalledTimes(2); + }, + ); + }); + + it('forwards the resolved geo country as residenceCountry', async () => { + await withController( + { options: { state: { geoCountry: 'FRA' } } }, + async ({ controller, handlers }) => { + await controller.startSumSub(); + + expect(handlers.createUkycSession).toHaveBeenCalledWith( + expect.objectContaining({ residenceCountry: 'FRA' }), + ); + expect(handlers.getGeoCountry).not.toHaveBeenCalled(); + }, + ); + }); + + it('does not create a UKYC session when reset() runs while resolving residence country', async () => { + await withController(async ({ controller, handlers, launcher }) => { + let release: (country: string) => void = () => { + // Replaced synchronously by the promise executor below. + }; + handlers.getGeoCountry.mockReturnValue( + new Promise((resolve) => { + release = resolve; + }), + ); + + const pending = controller.startSumSub(); + while (handlers.getGeoCountry.mock.calls.length === 0) { + await Promise.resolve(); + } + controller.reset(); + release('USA'); + const result = await pending; + + expect(result).toStrictEqual({}); + expect(handlers.createUkycSession).not.toHaveBeenCalled(); + expect(launcher.launch).not.toHaveBeenCalled(); + expect(controller.state.sumsub.status).toBe('idle'); + }); + }); + + it('stops with a vendorProcessing status when the relay approved but the vendor is still pending', async () => { + await withController(async ({ controller, handlers, launcher }) => { + // The applicant already finished the journey: the relay reports + // `approved` while the vendor is still finalizing (`pending`). + handlers.setAuthorizations.mockResolvedValue({ + ...sessionStatus('pending'), + kycStatus: 'approved', + finalStatus: 'pending', + }); + + const result = await controller.startSumSub(); + + expect(result).toStrictEqual({ + kycStatus: 'approved', + finalStatus: 'pending', + }); + expect(controller.state.sumsub.status).toBe('vendorProcessing'); + expect(controller.state.sumsub.sessionId).toBe('sid'); + expect(controller.state.statusMessage).toMatch( + /being processed by the vendor/u, + ); + // The SDK is never launched and no journey is created for an + // already-approved applicant. + expect(handlers.createJourney).not.toHaveBeenCalled(); + expect(launcher.launch).not.toHaveBeenCalled(); + }); + }); + + it('continues the flow when approved and the vendor is not pending', async () => { + await withController(async ({ controller, handlers, launcher }) => { + // A terminal vendor status (not `pending`) must not short-circuit. + handlers.setAuthorizations.mockResolvedValue({ + ...sessionStatus('approved'), + kycStatus: 'approved', + finalStatus: 'approved', + }); + launcher.launch.mockImplementation(async ({ onStatusChange }) => { + onStatusChange?.('InProgress', 'Completed'); + return { ok: true }; + }); + handlers.getSessionStatus.mockResolvedValue(sessionStatus('approved')); + + await controller.startSumSub(); + + expect(handlers.createJourney).toHaveBeenCalled(); + expect(launcher.launch).toHaveBeenCalled(); + }); + }); + + it('does not write vendorProcessing state when reset() runs while creating the session', async () => { + await withController(async ({ controller, handlers, launcher }) => { + handlers.createUkycSession.mockImplementation(async () => { + controller.reset(); + return ukycSessionResponse(); + }); + + const result = await controller.startSumSub(); + + expect(result).toStrictEqual({}); + expect(controller.state.sumsub.status).toBe('idle'); + expect(controller.state.sumsub.sessionId).toBeNull(); + expect(launcher.launch).not.toHaveBeenCalled(); + expect(handlers.setAuthorizations).not.toHaveBeenCalled(); + }); + }); + + it('does not submit authorizations when reset() runs while preparing wrapped secrets', async () => { + await withController(async ({ controller, handlers, launcher }) => { + handlers.fetchIdosEnclaveJwks.mockImplementation(async () => { + controller.reset(); + return { keys: [] }; + }); + + const result = await controller.startSumSub(); + + expect(result).toStrictEqual({}); + expect(handlers.setAuthorizations).not.toHaveBeenCalled(); + expect(launcher.launch).not.toHaveBeenCalled(); + expect(controller.state.sumsub.status).toBe('idle'); + }); + }); + + it('does not submit authorizations when reset() runs while fetching idOS relay JWKS', async () => { + await withController(async ({ controller, handlers, launcher }) => { + handlers.fetchIdosRelayJwks.mockImplementation(async () => { + controller.reset(); + return { keys: [] }; + }); + + const result = await controller.startSumSub(); + + expect(result).toStrictEqual({}); + expect(handlers.setAuthorizations).not.toHaveBeenCalled(); + expect(launcher.launch).not.toHaveBeenCalled(); + expect(controller.state.sumsub.status).toBe('idle'); + }); + }); + + it('does not write vendorProcessing state when reset() runs while setting authorizations', async () => { + await withController(async ({ controller, handlers, launcher }) => { + handlers.setAuthorizations.mockImplementation(async () => { + controller.reset(); + return { + ...sessionStatus('pending'), + kycStatus: 'approved', + finalStatus: 'pending', + }; + }); + + const result = await controller.startSumSub(); + + expect(result).toStrictEqual({}); + expect(controller.state.sumsub.status).toBe('idle'); + expect(controller.state.sumsub.sessionId).toBeNull(); + expect(launcher.launch).not.toHaveBeenCalled(); + }); + }); + + it('does not create a journey when reset() runs during a non-pending authorizations response', async () => { + await withController(async ({ controller, handlers, launcher }) => { + handlers.setAuthorizations.mockImplementation(async () => { + controller.reset(); + return sessionStatus('approved'); + }); + + const result = await controller.startSumSub(); + + expect(result).toStrictEqual({}); + expect(handlers.createJourney).not.toHaveBeenCalled(); + expect(launcher.launch).not.toHaveBeenCalled(); + expect(controller.state.sumsub.status).toBe('idle'); + }); + }); + + it('verifies encryptionDataKey against idOS enclave JWKS and capability token against idOS relay JWKS', async () => { + await withController(async ({ controller, handlers }) => { + const idosEnclaveKeys = [ + { kty: 'OKP', crv: 'Ed25519', x: 'enclave', kid: 'f1' }, + ]; + const idosRelayKeys = [ + { kty: 'OKP', crv: 'Ed25519', x: 'relay', kid: 'r1' }, + ]; + handlers.fetchIdosEnclaveJwks.mockResolvedValue({ + keys: idosEnclaveKeys, + }); + handlers.fetchIdosRelayJwks.mockResolvedValue({ keys: idosRelayKeys }); + handlers.createUkycSession.mockResolvedValue( + ukycSessionResponse({ + encryptionDataKey: { + serverPublicKey: { + kty: 'OKP', + crv: 'X25519', + x: 'spk-x', + }, + jwtChain: 'encryption.jwt.chain', + }, + ukycCapabilityToken: { + serverPublicKey: { + kty: 'OKP', + crv: 'X25519', + x: 'spk-x', + }, + jwtChain: 'capability.jwt.chain', + }, + }), + ); + + await controller.startSumSub(); + + expect(mockVerifyJwtChain).toHaveBeenNthCalledWith( + 1, + idosEnclaveKeys, + 'encryption.jwt.chain', + ); + expect(mockVerifyJwtChain).toHaveBeenNthCalledWith( + 2, + idosRelayKeys, + 'capability.jwt.chain', + ); + }); + }); + + it('aborts when the attested session server public key does not match', async () => { + await withController(async ({ controller, handlers, launcher }) => { + handlers.createUkycSession.mockResolvedValue( + ukycSessionResponse({ + encryptionDataKey: { + serverPublicKey: { + kty: 'OKP', + crv: 'X25519', + x: 'tampered', + }, + jwtChain: 'jwt.chain.sig', + }, + }), + ); + + const result = await controller.startSumSub(); + + expect(result).toMatchObject({ + error: expect.stringContaining( + 'sessionServerPublicKey does not match', + ), + }); + expect(controller.state.sumsub.status).toBe('failed'); + expect(launcher.launch).not.toHaveBeenCalled(); + expect(handlers.setAuthorizations).not.toHaveBeenCalled(); + }); + }); + + it('aborts when the capability-token schema public key does not match', async () => { + await withController(async ({ controller, handlers, launcher }) => { + handlers.createUkycSession.mockResolvedValue( + ukycSessionResponse({ + ukycCapabilityToken: { + serverPublicKey: { + kty: 'OKP', + crv: 'X25519', + x: 'tampered-token-key', + }, + jwtChain: 'jwt.chain.sig', + }, + }), + ); + + const result = await controller.startSumSub(); + + expect(result).toMatchObject({ + error: expect.stringContaining( + 'sessionServerPublicKey does not match', + ), + }); + expect(controller.state.sumsub.status).toBe('failed'); + expect(launcher.launch).not.toHaveBeenCalled(); + expect(handlers.setAuthorizations).not.toHaveBeenCalled(); + }); + }); + + it('defaults locale and debug when no params are given', async () => { + await withController(async ({ controller, launcher }) => { + launcher.launch.mockImplementation(async ({ onStatusChange }) => { + onStatusChange?.('InProgress', 'Completed'); + return { ok: true }; + }); + + await controller.startSumSub(); + + expect(launcher.launch).toHaveBeenCalledWith( + expect.objectContaining({ locale: 'en', debug: false }), + ); + expect(controller.state.sumsub.status).toBe('complete'); + }); + }); + + it('marks failed when launch resolves without a Completed status', async () => { + await withController(async ({ controller, launcher }) => { + launcher.launch.mockImplementation(async ({ onStatusChange }) => { + // The applicant abandons the flow: the SDK reports progress but never + // a Completed status, yet `launch` still resolves. + onStatusChange?.('idle', 'InProgress'); + return { ok: false }; + }); + + const result = await controller.startSumSub(); + + expect(result).toStrictEqual({ ok: false }); + expect(controller.state.sumsub.status).toBe('failed'); + expect(controller.state.sumsub.result).toStrictEqual({ ok: false }); + }); + }); + + it('marks failed and returns the error when a step throws', async () => { + await withController(async ({ controller, handlers }) => { + handlers.createUkycSession.mockRejectedValue(new Error('ukyc down')); + + const result = await controller.startSumSub(); + + expect(result).toMatchObject({ + error: expect.stringContaining('ukyc down'), + }); + expect(controller.state.sumsub.status).toBe('failed'); + }); + }); + + it('aborts without launching the SDK when reset() runs while in flight', async () => { + await withController(async ({ controller, handlers, launcher }) => { + // Simulate a reset() landing while the UKYC session is being created. + handlers.createUkycSession.mockImplementation(async () => { + controller.reset(); + return ukycSessionResponse(); + }); + + const result = await controller.startSumSub(); + + expect(result).toStrictEqual({}); + expect(launcher.launch).not.toHaveBeenCalled(); + // The interrupted step must not write stale sub-flow state. + expect(controller.state.sumsub.status).toBe('idle'); + expect(controller.state.sumsub.sessionId).toBeNull(); + expect(controller.state.phase).toBe('idle'); + }); + }); + + it('aborts without launching the SDK when reset() runs just before launch', async () => { + await withController(async ({ controller, handlers, launcher }) => { + // A reset() lands during the final token exchange, i.e. after the + // session is prepared but before the SDK is presented. + handlers.createJourney.mockImplementation(async () => { + controller.reset(); + return { status: 'ok', applicantAccessToken: 'aat' }; + }); + + const result = await controller.startSumSub(); + + expect(result).toStrictEqual({}); + // The SDK must not be opened on a flow that was reset to idle, and the + // `launching` status must not be written. + expect(launcher.launch).not.toHaveBeenCalled(); + expect(controller.state.sumsub.status).toBe('idle'); + expect(controller.state.sumsub.applicantAccessToken).toBeNull(); + expect(controller.state.phase).toBe('idle'); + }); + }); + + it('refuses to refresh the token via onTokenExpiration after a reset', async () => { + await withController(async ({ controller, handlers, launcher }) => { + let refreshError: unknown; + launcher.launch.mockImplementation(async ({ onTokenExpiration }) => { + // The SDK stays open across a reset, then asks for a fresh token. + controller.reset(); + // Only the initial createJourney (session setup) should + // have run. + const callsBeforeRefresh = handlers.createJourney.mock.calls.length; + try { + await onTokenExpiration(); + } catch (error) { + refreshError = error; + } + // The refresh must not hit the stale UKYC session. + expect(handlers.createJourney.mock.calls).toHaveLength( + callsBeforeRefresh, + ); + return { ok: true }; + }); + + await controller.startSumSub(); + + expect(refreshError).toBeInstanceOf(Error); + expect((refreshError as Error).message).toMatch(/flow was reset/u); + }); + }); + + it('suppresses status and terminal writes when reset() runs during the SDK launch', async () => { + await withController(async ({ controller, launcher }) => { + launcher.launch.mockImplementation(async ({ onStatusChange }) => { + // First status arrives on the active flow, then a reset() lands and + // a later status + the resolved result must not resurrect state. + onStatusChange?.('idle', 'InProgress'); + controller.reset(); + onStatusChange?.('InProgress', 'Completed'); + return { ok: true }; + }); + + const result = await controller.startSumSub(); + + expect(result).toStrictEqual({ ok: true }); + expect(controller.state.sumsub.status).toBe('idle'); + expect(controller.state.sumsub.result).toBeNull(); + expect(controller.state.phase).toBe('idle'); + }); + }); + }); + + describe('session status polling', () => { + afterEach(() => { + jest.useRealTimers(); + }); + + /** + * Makes the launcher report a successful SDK completion so the sub-flow + * proceeds into session-status polling. + * + * @param launcher - The mocked launcher. + */ + function completeSdk(launcher: Launcher): void { + launcher.launch.mockImplementation(async ({ onStatusChange }) => { + onStatusChange?.('InProgress', 'Completed'); + return { ok: true }; + }); + } + + it('polls the session status after completion and completes on an approved status', async () => { + await withController(async ({ controller, handlers, launcher }) => { + completeSdk(launcher); + handlers.getSessionStatus.mockResolvedValue(sessionStatus('approved')); + + await controller.startSumSub(); + + expect(handlers.getSessionStatus).toHaveBeenCalledWith({ + sessionId: 'sid', + }); + expect(controller.state.sumsub.status).toBe('complete'); + expect(controller.state.sumsub.sessionStatus).toStrictEqual( + sessionStatus('approved'), + ); + }); + }); + + it('maps a rejected terminal status to a failed sub-flow', async () => { + await withController(async ({ controller, handlers, launcher }) => { + completeSdk(launcher); + handlers.getSessionStatus.mockResolvedValue(sessionStatus('rejected')); + + await controller.startSumSub(); + + expect(controller.state.sumsub.status).toBe('failed'); + expect(controller.state.sumsub.sessionStatus).toStrictEqual( + sessionStatus('rejected'), + ); + }); + }); + + it('treats SDK completion as final when the UKYC session has no id to poll', async () => { + await withController(async ({ controller, handlers, launcher }) => { + // A session created without an id leaves nothing to poll against. + handlers.createUkycSession.mockResolvedValue( + ukycSessionResponse({ sessionId: '' }), + ); + completeSdk(launcher); + + await controller.startSumSub(); + + expect(handlers.getSessionStatus).not.toHaveBeenCalled(); + expect(controller.state.sumsub.status).toBe('complete'); + }); + }); + + it('does not poll when the SDK did not report completion', async () => { + await withController(async ({ controller, handlers, launcher }) => { + // The applicant abandons the flow: `launch` resolves without ever + // reporting a Completed status. + launcher.launch.mockResolvedValue({ ok: false }); + + await controller.startSumSub(); + + expect(controller.state.sumsub.status).toBe('failed'); + expect(handlers.getSessionStatus).not.toHaveBeenCalled(); + }); + }); + + it('keeps polling on a transient error, preserving the last good status', async () => { + jest.useFakeTimers(); + await withController( + { options: { sessionStatusPollIntervalMs: 1000 } }, + async ({ controller, handlers, launcher }) => { + completeSdk(launcher); + handlers.getSessionStatus + .mockResolvedValueOnce(sessionStatus('pending')) + .mockRejectedValueOnce(new Error('network blip')) + .mockResolvedValueOnce(sessionStatus('approved')); + + await controller.startSumSub(); + + // First poll: non-terminal, keeps polling. + expect(controller.state.sumsub.status).toBe('polling'); + expect(controller.state.sumsub.sessionStatus).toStrictEqual( + sessionStatus('pending'), + ); + + // Second poll fails transiently: the last good status is preserved + // and the loop keeps going. + await jest.advanceTimersByTimeAsync(1000); + expect(controller.state.sumsub.status).toBe('polling'); + expect(controller.state.sumsub.sessionStatus).toStrictEqual( + sessionStatus('pending'), + ); + + // Third poll reaches a terminal status. + await jest.advanceTimersByTimeAsync(1000); + expect(controller.state.sumsub.status).toBe('complete'); + expect(controller.state.sumsub.sessionStatus).toStrictEqual( + sessionStatus('approved'), + ); + expect(handlers.getSessionStatus).toHaveBeenCalledTimes(3); + }, + ); + }); + + it('stops polling once a terminal status is reached', async () => { + jest.useFakeTimers(); + await withController( + { options: { sessionStatusPollIntervalMs: 1000 } }, + async ({ controller, handlers, launcher }) => { + completeSdk(launcher); + handlers.getSessionStatus.mockResolvedValue( + sessionStatus('approved'), + ); + + await controller.startSumSub(); + expect(handlers.getSessionStatus).toHaveBeenCalledTimes(1); + + // No further polls after a terminal status. + await jest.advanceTimersByTimeAsync(5000); + expect(handlers.getSessionStatus).toHaveBeenCalledTimes(1); + }, + ); + }); + + it('stops polling when reset() is called', async () => { + jest.useFakeTimers(); + await withController( + { options: { sessionStatusPollIntervalMs: 1000 } }, + async ({ controller, handlers, launcher }) => { + completeSdk(launcher); + handlers.getSessionStatus.mockResolvedValue(sessionStatus('pending')); + + await controller.startSumSub(); + expect(handlers.getSessionStatus).toHaveBeenCalledTimes(1); + + controller.reset(); + await jest.advanceTimersByTimeAsync(5000); + + // The scheduled poll was cancelled by reset(). + expect(handlers.getSessionStatus).toHaveBeenCalledTimes(1); + expect(controller.state.sumsub.status).toBe('idle'); + }, + ); + }); + + it('discards a poll result when reset() runs while the request is in flight', async () => { + await withController(async ({ controller, handlers, launcher }) => { + completeSdk(launcher); + // Simulate a reset() landing while the status request is in flight. + handlers.getSessionStatus.mockImplementation(async () => { + controller.reset(); + return sessionStatus('approved'); + }); + + await controller.startSumSub(); + + expect(controller.state.sumsub.status).toBe('idle'); + expect(controller.state.sumsub.sessionStatus).toBeNull(); + }); + }); + + it('supersedes a prior polling loop when a new sub-flow starts', async () => { + jest.useFakeTimers(); + await withController( + { options: { sessionStatusPollIntervalMs: 1000 } }, + async ({ controller, handlers, launcher }) => { + completeSdk(launcher); + // First sub-flow polls a never-terminal status. + handlers.getSessionStatus.mockResolvedValue(sessionStatus('pending')); + + await controller.startSumSub(); + expect(handlers.getSessionStatus).toHaveBeenCalledTimes(1); + + // A second sub-flow reaches a terminal status on its first poll and + // must cancel the first loop's scheduled poll. + handlers.getSessionStatus.mockResolvedValue( + sessionStatus('approved'), + ); + await controller.startSumSub(); + expect(controller.state.sumsub.status).toBe('complete'); + + const callsAfterSecondFlow = + handlers.getSessionStatus.mock.calls.length; + await jest.advanceTimersByTimeAsync(5000); + + // No stray polls from the superseded first loop. + expect(handlers.getSessionStatus).toHaveBeenCalledTimes( + callsAfterSecondFlow, + ); + }, + ); + }); + }); + + describe('getSessionStatus', () => { + it('fetches and records the session status on demand', async () => { + await withController( + { + options: { + state: { + sumsub: { + status: 'complete', + result: null, + sessionId: 'sid', + applicantAccessToken: null, + sessionStatus: null, + }, + }, + }, + }, + async ({ controller, handlers }) => { + handlers.getSessionStatus.mockResolvedValue( + sessionStatus('approved'), + ); + + const result = await controller.getSessionStatus(); + + expect(handlers.getSessionStatus).toHaveBeenCalledWith({ + sessionId: 'sid', + }); + expect(result).toStrictEqual(sessionStatus('approved')); + expect(controller.state.sumsub.sessionStatus).toStrictEqual( + sessionStatus('approved'), + ); + }, + ); + }); + + it('throws when there is no active SumSub session', async () => { + await withController(async ({ controller }) => { + await expect(controller.getSessionStatus()).rejects.toThrow( + /no active SumSub session/u, + ); + }); + }); + }); + + describe('reset', () => { + it('clears session state but preserves persisted terms', async () => { + await withController( + { + options: { + state: { + phase: 'form', + sessionToken: 'tok', + accessToken: 'a', + activeProduct: 'ramps', + termsAcceptedAt: 't', + acceptedDisclaimerIds: ['1'], + kycRequiredByProduct: { ramps: true }, + }, + }, + }, + ({ controller }) => { + controller.reset(); + expect(controller.state.phase).toBe('idle'); + expect(controller.state.sessionToken).toBeNull(); + expect(controller.state.accessToken).toBeNull(); + expect(controller.state.activeProduct).toBeNull(); + expect(controller.state.termsAcceptedAt).toBe('t'); + expect(controller.state.kycRequiredByProduct.ramps).toBe(true); + }, + ); + }); + + it('does not let a superseded flow drive the controller back out of idle', async () => { + await withController(async ({ controller, handlers }) => { + let release: (country: string) => void = () => { + // Replaced synchronously by the promise executor below. + }; + handlers.getGeoCountry.mockReturnValue( + new Promise((resolve) => { + release = resolve; + }), + ); + + const pending = controller.initialize({ email: 'a@b.co' }); + controller.reset(); + release('USA'); + await pending; + + expect(controller.state.phase).toBe('idle'); + expect(handlers.fetchDisclaimers).not.toHaveBeenCalled(); + }); + }); + }); + + describe('clearState', () => { + it('restores the default state from a fully populated state', async () => { + await withController( + { + options: { + state: { + phase: 'form', + statusMessage: 'Review to submit.', + error: 'stale error', + email: 'a@b.co', + termsAcceptedAt: 't', + acceptedDisclaimerIds: ['1'], + termsAcceptedVendor: 'iron', + sumsubTncAccepted: true, + idosTncAccepted: true, + disclaimers: [{ id: '1', display_name: 'T', url: 'u' }], + disclaimersError: 'stale disclaimers error', + geoCountry: 'USA', + sessionToken: 'tok', + accessToken: 'a', + moonpayCustomerId: 'cus-1', + activeVendor: 'iron', + activeProduct: 'ramps', + kycRequiredByProduct: { ramps: true }, + lastCheckedAt: 't', + userStatus: 'completed', + userStatusSumsubSessionId: 's1', + userStatusErrorCode: 'code', + sumsub: { + status: 'complete', + result: { ok: true }, + sessionId: 'sess-1', + applicantAccessToken: 'aat', + sessionStatus: sessionStatus('approved'), + }, + }, + }, + }, + ({ controller }) => { + controller.clearState(); + + expect(controller.state).toStrictEqual( + getDefaultKycControllerState(), + ); + }, + ); + }); + + it('leaves the state at its defaults when a flow was in flight', async () => { + await withController(async ({ controller, handlers }) => { + let release: (country: string) => void = () => { + // Replaced synchronously by the promise executor below. + }; + handlers.getGeoCountry.mockReturnValue( + new Promise((resolve) => { + release = resolve; + }), + ); + + const pending = controller.initialize({ email: 'a@b.co' }); + controller.clearState(); + release('USA'); + await pending; + + expect(controller.state).toStrictEqual(getDefaultKycControllerState()); + // The superseded flow must not resume the terms step either. + expect(handlers.fetchDisclaimers).not.toHaveBeenCalled(); + }); + }); + + it('drops the auth-frame client token', async () => { + await withController( + { options: { state: { phase: 'check', sessionToken: 'tok' } } }, + async ({ controller }) => { + const envelope = envelopeFor(controller, { + clientToken: 'client-1', + }); + await controller.handleFrameMessage({ + message: { + kind: 'complete', + meta: { channelId: 'ch_1' }, + payload: { status: 'connectionRequired', credentials: envelope }, + }, + }); + expect(controller.buildAuthFrameUrl()).toContain( + 'clientToken=client-1', + ); + + controller.clearState(); + + expect(controller.buildAuthFrameUrl()).toBeNull(); + }, + ); + }); + + it('stops session-status polling', async () => { + jest.useFakeTimers(); + try { + await withController( + { options: { sessionStatusPollIntervalMs: 1000 } }, + async ({ controller, handlers, launcher }) => { + launcher.launch.mockImplementation(async ({ onStatusChange }) => { + onStatusChange?.('InProgress', 'Completed'); + return { ok: true }; + }); + handlers.getSessionStatus.mockResolvedValue( + sessionStatus('pending'), + ); + + await controller.startSumSub(); + expect(handlers.getSessionStatus).toHaveBeenCalledTimes(1); + + controller.clearState(); + await jest.advanceTimersByTimeAsync(5000); + + expect(handlers.getSessionStatus).toHaveBeenCalledTimes(1); + expect(controller.state).toStrictEqual( + getDefaultKycControllerState(), + ); + }, + ); + } finally { + jest.useRealTimers(); + } + }); + + it('stops user-status polling', async () => { + jest.useFakeTimers(); + try { + await withController( + { options: { userStatusPollIntervalMs: 1000 } }, + async ({ controller, handlers }) => { + handlers.fetchKycStatus.mockResolvedValue({ status: 'pending' }); + + await controller.refreshKycStatus(); + expect(handlers.fetchKycStatus).toHaveBeenCalledTimes(1); + + controller.clearState(); + await jest.advanceTimersByTimeAsync(5000); + + expect(handlers.fetchKycStatus).toHaveBeenCalledTimes(1); + expect(controller.state).toStrictEqual( + getDefaultKycControllerState(), + ); + }, + ); + } finally { + jest.useRealTimers(); + } + }); + + it('is callable via the messenger', async () => { + await withController( + { options: { state: { email: 'a@b.co' } } }, + ({ controller, rootMessenger }) => { + rootMessenger.call('KycController:clearState'); + + expect(controller.state.email).toBeNull(); + }, + ); + }); + }); + + describe('iron vendor flow', () => { + afterEach(() => { + jest.clearAllTimers(); + jest.useRealTimers(); + }); + + it('creates an Iron customer and loads Iron disclaimers on initialize', async () => { + await withController(async ({ controller, handlers }) => { + handlers.getGeoCountry.mockResolvedValue('USA'); + handlers.fetchDisclaimers.mockResolvedValue([ + { id: 'd1', display_name: 'Iron T&C', url: 'https://t' }, + ]); + + await controller.initialize({ + email: 'a@b.co', + vendor: 'iron', + product: 'money', + }); + + expect(handlers.createVendorCustomer).toHaveBeenCalledWith({ + vendor: 'iron', + email: 'a@b.co', + }); + expect(handlers.fetchDisclaimers).toHaveBeenCalledWith({ + vendor: 'iron', + country: 'USA', + }); + expect(handlers.createSession).not.toHaveBeenCalled(); + expect(controller.state.activeVendor).toBe('iron'); + expect(controller.state.activeProduct).toBe('money'); + expect(controller.state.phase).toBe('terms'); + expect(controller.state.disclaimers).toHaveLength(1); + }); + }); + + it('fails initialize when Iron customer creation fails', async () => { + await withController(async ({ controller, handlers }) => { + handlers.createVendorCustomer.mockRejectedValue(new Error('iron down')); + + await controller.initialize({ email: 'a@b.co', vendor: 'iron' }); + + expect(controller.state.phase).toBe('error'); + expect(controller.state.error).toMatch( + /Vendor customer creation failed/u, + ); + }); + }); + + it('preserves MoonPay terms when Iron customer creation fails on initialize', async () => { + await withController( + { + options: { + state: { + termsAcceptedAt: 't', + acceptedDisclaimerIds: ['moonpay-d1'], + termsAcceptedVendor: 'moonpay', + }, + }, + }, + async ({ controller, handlers }) => { + handlers.createVendorCustomer.mockRejectedValue( + new Error('iron down'), + ); + + await controller.initialize({ email: 'a@b.co', vendor: 'iron' }); + + expect(controller.state.phase).toBe('error'); + expect(controller.state.termsAcceptedAt).toBe('t'); + expect(controller.state.acceptedDisclaimerIds).toStrictEqual([ + 'moonpay-d1', + ]); + expect(controller.state.termsAcceptedVendor).toBe('moonpay'); + }, + ); + }); + + it('preserves MoonPay terms when reset lands during Iron customer creation', async () => { + await withController( + { + options: { + state: { + termsAcceptedAt: 't', + acceptedDisclaimerIds: ['moonpay-d1'], + termsAcceptedVendor: 'moonpay', + }, + }, + }, + async ({ controller, handlers }) => { + handlers.createVendorCustomer.mockImplementation(async () => { + controller.reset(); + throw new Error('late'); + }); + + await controller.initialize({ + email: 'a@b.co', + vendor: 'iron', + }); + + expect(controller.state.phase).toBe('idle'); + expect(controller.state.termsAcceptedAt).toBe('t'); + expect(controller.state.acceptedDisclaimerIds).toStrictEqual([ + 'moonpay-d1', + ]); + expect(controller.state.termsAcceptedVendor).toBe('moonpay'); + }, + ); + }); + + it('does not fail initialize when reset lands during Iron customer creation', async () => { + await withController(async ({ controller, handlers }) => { + // Simulate a reset() landing while customer creation is in flight. + handlers.createVendorCustomer.mockImplementation(async () => { + controller.reset(); + return { id: '1', email: 'a@b.co', status: 'SigningsRequired' }; + }); + + await controller.initialize({ email: 'a@b.co', vendor: 'iron' }); + + expect(controller.state.phase).toBe('idle'); + expect(controller.state.error).toBeNull(); + }); + }); + + it('does not fail initialize when Iron customer creation rejects after reset', async () => { + await withController(async ({ controller, handlers }) => { + handlers.createVendorCustomer.mockImplementation(async () => { + controller.reset(); + throw new Error('late'); + }); + + await controller.initialize({ email: 'a@b.co', vendor: 'iron' }); + + expect(controller.state.phase).toBe('idle'); + expect(controller.state.error).toBeNull(); + }); + }); + + it('resumes an Iron session when terms and email are already present', async () => { + await withController( + { + options: { + state: { + termsAcceptedAt: 't', + acceptedDisclaimerIds: ['d1'], + termsAcceptedVendor: 'iron', + sumsubTncAccepted: true, + idosTncAccepted: true, + }, + userStatusPollIntervalMs: 60_000, + }, + }, + async ({ controller, handlers, launcher }) => { + launcher.launch.mockImplementation(async ({ onStatusChange }) => { + onStatusChange?.('InProgress', 'Completed'); + return { ok: true }; + }); + handlers.fetchKycStatus.mockResolvedValue({ status: 'completed' }); + + await controller.initialize({ + email: 'a@b.co', + vendor: 'iron', + product: 'money', + }); + + expect(handlers.fetchSessionDisclaimers).toHaveBeenCalled(); + expect(handlers.submitVendorDisclaimers).toHaveBeenCalledWith({ + vendor: 'iron', + disclaimerIds: ['d1'], + }); + expect(handlers.createSession).not.toHaveBeenCalled(); + expect(controller.state.phase).toBe('done'); + controller.reset(); + }, + ); + }); + + it('does not reuse MoonPay terms acceptance for a consents-path vendor', async () => { + await withController( + { + options: { + state: { + termsAcceptedAt: 't', + acceptedDisclaimerIds: ['moonpay-d1'], + termsAcceptedVendor: 'moonpay', + }, + }, + }, + async ({ controller, handlers }) => { + handlers.fetchDisclaimers.mockResolvedValue([ + { id: 'iron-d1', display_name: 'T', url: 'u' }, + ]); + + await controller.initialize({ email: 'a@b.co', vendor: 'iron' }); + + expect(handlers.submitSessionDisclaimers).not.toHaveBeenCalled(); + expect(controller.state.termsAcceptedAt).toBeNull(); + expect(controller.state.acceptedDisclaimerIds).toStrictEqual([]); + expect(controller.state.termsAcceptedVendor).toBeNull(); + expect(handlers.fetchDisclaimers).toHaveBeenCalledWith({ + vendor: 'iron', + country: 'USA', + }); + expect(controller.state.phase).toBe('terms'); + }, + ); + }); + + it('requires reacceptance when T&C2 flags are null (pre-migration state)', async () => { + await withController( + { + options: { + state: { + termsAcceptedAt: 't', + acceptedDisclaimerIds: ['d1'], + termsAcceptedVendor: 'iron', + // T&C2 flags are null, simulating pre-migration state + sumsubTncAccepted: null, + idosTncAccepted: null, + }, + }, + }, + async ({ controller, handlers }) => { + handlers.fetchDisclaimers.mockResolvedValue([ + { id: 'd1', display_name: 'T', url: 'u' }, + ]); + + await controller.initialize({ email: 'a@b.co', vendor: 'iron' }); + + // T&C2 flags were null; reacceptance required. + expect(controller.state.phase).toBe('terms'); + expect(controller.state.termsAcceptedAt).toBeNull(); + expect(controller.state.sumsubTncAccepted).toBeNull(); + expect(controller.state.idosTncAccepted).toBeNull(); + }, + ); + }); + + it('does not reuse consents-path terms acceptance for MoonPay', async () => { + await withController( + { + options: { + state: { + termsAcceptedAt: 't', + acceptedDisclaimerIds: ['iron-d1'], + termsAcceptedVendor: 'iron', + }, + }, + }, + async ({ controller, handlers }) => { + await controller.initialize({ email: 'a@b.co', vendor: 'moonpay' }); + + expect(handlers.createSession).not.toHaveBeenCalled(); + expect(controller.state.acceptedDisclaimerIds).toStrictEqual([]); + expect(controller.state.phase).toBe('terms'); + }, + ); + }); + + it('drops another vendor terms acceptance when createVendorCustomer switches vendor', async () => { + await withController( + { + options: { + state: { + termsAcceptedAt: 't', + acceptedDisclaimerIds: ['moonpay-d1'], + termsAcceptedVendor: 'moonpay', + }, + }, + }, + async ({ controller }) => { + await controller.createVendorCustomer({ + vendor: 'iron', + email: 'a@b.co', + }); + + expect(controller.state.termsAcceptedAt).toBeNull(); + expect(controller.state.acceptedDisclaimerIds).toStrictEqual([]); + expect(controller.state.termsAcceptedVendor).toBeNull(); + }, + ); + }); + + it('keeps terms acceptance when createVendorCustomer stays on the same vendor', async () => { + await withController( + { + options: { + state: { + activeVendor: 'iron', + termsAcceptedAt: 't', + acceptedDisclaimerIds: ['iron-d1'], + termsAcceptedVendor: 'iron', + }, + }, + }, + async ({ controller }) => { + await controller.createVendorCustomer({ + vendor: 'iron', + email: 'a@b.co', + }); + + expect(controller.state.acceptedDisclaimerIds).toStrictEqual([ + 'iron-d1', + ]); + expect(controller.state.termsAcceptedVendor).toBe('iron'); + }, + ); + }); + + it.each(['session', 'check', 'auth', 'form', 'submit'] as const)( + 'does not switch vendor or drop the MoonPay id while phase is %s', + async (phase) => { + await withController( + { + options: { + state: { + phase, + activeVendor: 'moonpay', + moonpayCustomerId: 'cust-1', + sessionToken: 'tok', + }, + }, + }, + async ({ controller, handlers }) => { + await controller.createVendorCustomer({ + vendor: 'iron', + email: 'a@b.co', + }); + + expect(handlers.createVendorCustomer).not.toHaveBeenCalled(); + expect(controller.state.activeVendor).toBe('moonpay'); + expect(controller.state.moonpayCustomerId).toBe('cust-1'); + expect(controller.state.phase).toBe(phase); + expect(controller.getCustomerIdentity()).toStrictEqual({ + vendor: 'moonpay', + id: 'cust-1', + }); + }, + ); + }, + ); + + it('stamps the active vendor onto the terms acceptance', async () => { + await withController( + { + options: { + state: { + activeVendor: 'iron', + disclaimers: [{ id: 'd1', display_name: 'T', url: 'u' }], + }, + userStatusPollIntervalMs: 60_000, + }, + }, + async ({ controller, launcher }) => { + launcher.launch.mockImplementation(async ({ onStatusChange }) => { + onStatusChange?.('InProgress', 'Completed'); + return { ok: true }; + }); + + await controller.acceptTermsAndStartSession({ + email: 'a@b.co', + sumsubTncSigned: true, + idosTncSigned: true, + }); + + expect(controller.state.termsAcceptedVendor).toBe('iron'); + expect(controller.state.sumsubTncAccepted).toBe(true); + expect(controller.state.idosTncAccepted).toBe(true); + controller.reset(); + }, + ); + }); + + it('createVendorCustomer sets the vendor and fails on API errors', async () => { + await withController(async ({ controller, handlers }) => { + handlers.createVendorCustomer.mockRejectedValue(new Error('nope')); + + await controller.createVendorCustomer({ + vendor: 'iron', + email: 'a@b.co', + }); + + expect(controller.state.activeVendor).toBe('iron'); + expect(controller.state.email).toBe('a@b.co'); + expect(controller.state.phase).toBe('error'); + }); + }); + + it('preserves MoonPay terms when createVendorCustomer fails after a vendor switch', async () => { + await withController( + { + options: { + state: { + termsAcceptedAt: 't', + acceptedDisclaimerIds: ['moonpay-d1'], + termsAcceptedVendor: 'moonpay', + }, + }, + }, + async ({ controller, handlers }) => { + handlers.createVendorCustomer.mockRejectedValue(new Error('nope')); + + await controller.createVendorCustomer({ + vendor: 'iron', + email: 'a@b.co', + }); + + expect(controller.state.phase).toBe('error'); + expect(controller.state.termsAcceptedAt).toBe('t'); + expect(controller.state.acceptedDisclaimerIds).toStrictEqual([ + 'moonpay-d1', + ]); + expect(controller.state.termsAcceptedVendor).toBe('moonpay'); + }, + ); + }); + + it('preserves MoonPay terms when reset lands during createVendorCustomer', async () => { + await withController( + { + options: { + state: { + termsAcceptedAt: 't', + acceptedDisclaimerIds: ['moonpay-d1'], + termsAcceptedVendor: 'moonpay', + }, + }, + }, + async ({ controller, handlers }) => { + let release: (value: { + id: string; + email: string; + status: string; + }) => void = () => { + // placeholder + }; + handlers.createVendorCustomer.mockReturnValue( + new Promise((resolve) => { + release = resolve; + }), + ); + + const pending = controller.createVendorCustomer({ + vendor: 'iron', + email: 'a@b.co', + }); + controller.reset(); + release({ id: '1', email: 'a@b.co', status: 'SigningsRequired' }); + await pending; + + expect(controller.state.phase).toBe('idle'); + expect(controller.state.termsAcceptedAt).toBe('t'); + expect(controller.state.acceptedDisclaimerIds).toStrictEqual([ + 'moonpay-d1', + ]); + expect(controller.state.termsAcceptedVendor).toBe('moonpay'); + }, + ); + }); + + it('createVendorCustomer ignores API errors after reset', async () => { + await withController(async ({ controller, handlers }) => { + let release: (error: Error) => void = () => { + // placeholder + }; + handlers.createVendorCustomer.mockReturnValue( + new Promise((_resolve, reject) => { + release = reject; + }), + ); + + const pending = controller.createVendorCustomer({ + vendor: 'iron', + email: 'a@b.co', + }); + controller.reset(); + release(new Error('late')); + await pending; + + expect(controller.state.phase).toBe('idle'); + expect(controller.state.error).toBeNull(); + }); + }); + + it('posts consents and starts SumSub without MoonPay frames', async () => { + await withController( + { + options: { + state: { + activeVendor: 'iron', + disclaimers: [{ id: 'd1', display_name: 'T', url: 'u' }], + }, + userStatusPollIntervalMs: 60_000, + }, + }, + async ({ controller, handlers, launcher }) => { + handlers.fetchSessionDisclaimers.mockResolvedValue( + MOCK_SESSION_DISCLAIMERS, + ); + handlers.submitSessionDisclaimers.mockResolvedValue({ + ...MOCK_SESSION_DISCLAIMERS, + credentialReusabilityConsentGiven: true, + idOS: MOCK_SESSION_DISCLAIMERS.idOS.map((doc) => ({ + ...doc, + consented: true, + })), + kycProvider: MOCK_SESSION_DISCLAIMERS.kycProvider.map((doc) => ({ + ...doc, + consented: true, + })), + }); + handlers.fetchKycStatus.mockResolvedValue({ status: 'pending' }); + launcher.launch.mockImplementation(async ({ onStatusChange }) => { + onStatusChange?.('InProgress', 'Completed'); + return { ok: true }; + }); + + await controller.acceptTermsAndStartSession({ + email: 'a@b.co', + product: 'money', + sumsubTncSigned: true, + idosTncSigned: true, + }); + + expect(handlers.createSession).not.toHaveBeenCalled(); + expect(handlers.submitVendorDisclaimers).toHaveBeenCalledWith({ + vendor: 'iron', + disclaimerIds: ['d1'], + }); + expect(handlers.fetchSessionDisclaimers).toHaveBeenCalledWith({ + sessionId: 'sid', + }); + expect(handlers.submitSessionDisclaimers).toHaveBeenCalledWith({ + sessionId: 'sid', + idOS: [{ key: 'idos-tos', version: '1' }], + kycProvider: [{ key: 'sumsub-tos', version: '1' }], + credentialReusabilityConsentGiven: false, + }); + expect(handlers.createUkycSession).toHaveBeenCalledTimes(1); + expect( + handlers.submitVendorDisclaimers.mock.invocationCallOrder[0], + ).toBeLessThan( + handlers.createUkycSession.mock.invocationCallOrder[0], + ); + expect( + handlers.createUkycSession.mock.invocationCallOrder[0], + ).toBeLessThan( + handlers.fetchSessionDisclaimers.mock.invocationCallOrder[0], + ); + expect(handlers.createUkycSession).toHaveBeenCalledWith( + expect.objectContaining({ + vendor: 'iron', + residenceCountry: 'USA', + }), + ); + expect(launcher.launch).toHaveBeenCalled(); + expect(controller.buildCheckFrameUrl()).toBeNull(); + expect(controller.buildAuthFrameUrl()).toBeNull(); + expect(controller.state.userStatus).toBe('pending'); + expect(controller.state.phase).toBe('done'); + expect(controller.state.sumsub.status).toBe('complete'); + expect(controller.state.sessionDisclaimers?.idOS[0]?.consented).toBe( + true, + ); + controller.reset(); + }, + ); + }); + + it('forwards credential reusability consent onto session disclaimers', async () => { + await withController( + { + options: { + state: { + activeVendor: 'iron', + disclaimers: [{ id: 'd1', display_name: 'T', url: 'u' }], + }, + userStatusPollIntervalMs: 60_000, + }, + }, + async ({ controller, handlers, launcher }) => { + launcher.launch.mockImplementation(async ({ onStatusChange }) => { + onStatusChange?.('InProgress', 'Completed'); + return { ok: true }; + }); + + await controller.acceptTermsAndStartSession({ + email: 'a@b.co', + product: 'money', + sumsubTncSigned: true, + idosTncSigned: true, + credentialReusabilityConsentGiven: true, + }); + + expect(handlers.submitSessionDisclaimers).toHaveBeenCalledWith({ + sessionId: 'sid', + idOS: [{ key: 'idos-tos', version: '1' }], + kycProvider: [{ key: 'sumsub-tos', version: '1' }], + credentialReusabilityConsentGiven: true, + }); + expect(controller.state.credentialReusabilityConsentGiven).toBe(true); + controller.reset(); + }, + ); + }); + + it('treats a 409 conflict as already-recorded consents', async () => { + await withController( + { + options: { + state: { + activeVendor: 'iron', + disclaimers: [{ id: 'd1', display_name: 'T', url: 'u' }], + }, + userStatusPollIntervalMs: 60_000, + }, + }, + async ({ controller, handlers, launcher }) => { + const consentedCatalog = { + ...MOCK_SESSION_DISCLAIMERS, + credentialReusabilityConsentGiven: false, + idOS: MOCK_SESSION_DISCLAIMERS.idOS.map((doc) => ({ + ...doc, + consented: true, + })), + kycProvider: MOCK_SESSION_DISCLAIMERS.kycProvider.map((doc) => ({ + ...doc, + consented: true, + })), + }; + handlers.fetchSessionDisclaimers + .mockResolvedValueOnce(MOCK_SESSION_DISCLAIMERS) + .mockResolvedValueOnce(consentedCatalog); + handlers.submitSessionDisclaimers.mockRejectedValue( + new HttpError( + 409, + "Fetching 'disclaimers' failed with status '409'", + ), + ); + launcher.launch.mockImplementation(async ({ onStatusChange }) => { + onStatusChange?.('InProgress', 'Completed'); + return { ok: true }; + }); + + await controller.acceptTermsAndStartSession({ + email: 'a@b.co', + product: 'money', + sumsubTncSigned: true, + idosTncSigned: true, + }); + + expect(handlers.fetchSessionDisclaimers).toHaveBeenCalledTimes(2); + expect(controller.state.phase).toBe('done'); + expect(launcher.launch).toHaveBeenCalled(); + controller.reset(); + }, + ); + }); + + it('treats a 409 as recorded when declined idOS documents stay unconsented', async () => { + await withController( + { + options: { + state: { + activeVendor: 'iron', + disclaimers: [{ id: 'd1', display_name: 'T', url: 'u' }], + }, + userStatusPollIntervalMs: 60_000, + }, + }, + async ({ controller, handlers, launcher }) => { + const afterConflict = { + ...MOCK_SESSION_DISCLAIMERS, + kycProvider: MOCK_SESSION_DISCLAIMERS.kycProvider.map((doc) => ({ + ...doc, + consented: true, + })), + }; + handlers.fetchSessionDisclaimers + .mockResolvedValueOnce(MOCK_SESSION_DISCLAIMERS) + .mockResolvedValueOnce(afterConflict); + handlers.submitSessionDisclaimers.mockRejectedValue( + new HttpError( + 409, + "Fetching 'disclaimers' failed with status '409'", + ), + ); + launcher.launch.mockImplementation(async ({ onStatusChange }) => { + onStatusChange?.('InProgress', 'Completed'); + return { ok: true }; + }); + + await controller.acceptTermsAndStartSession({ + email: 'a@b.co', + product: 'money', + sumsubTncSigned: true, + idosTncSigned: false, + }); + + expect(controller.state.phase).toBe('done'); + expect(launcher.launch).toHaveBeenCalled(); + controller.reset(); + }, + ); + }); + + it('fails closed when a 409 leaves credential reuse unconsented', async () => { + await withController( + { + options: { + state: { + activeVendor: 'iron', + disclaimers: [{ id: 'd1', display_name: 'T', url: 'u' }], + }, + }, + }, + async ({ controller, handlers, launcher }) => { + const consentedDocs = { + idOS: MOCK_SESSION_DISCLAIMERS.idOS.map((doc) => ({ + ...doc, + consented: true, + })), + kycProvider: MOCK_SESSION_DISCLAIMERS.kycProvider.map((doc) => ({ + ...doc, + consented: true, + })), + credentialReusabilityConsentGiven: false, + }; + handlers.fetchSessionDisclaimers.mockResolvedValue(consentedDocs); + handlers.submitSessionDisclaimers.mockRejectedValue( + new HttpError( + 409, + "Fetching 'disclaimers' failed with status '409'", + ), + ); + handlers.fetchDisclaimers.mockResolvedValue([]); + + await controller.acceptTermsAndStartSession({ + email: 'a@b.co', + product: 'money', + sumsubTncSigned: true, + idosTncSigned: true, + credentialReusabilityConsentGiven: true, + }); + + expect(controller.state.phase).toBe('terms'); + expect(controller.state.error).toMatch(/Consents session failed/u); + expect(launcher.launch).not.toHaveBeenCalled(); + }, + ); + }); + + it('fails closed when a 409 leaves accepted documents unconsented', async () => { + await withController( + { + options: { + state: { + activeVendor: 'iron', + disclaimers: [{ id: 'd1', display_name: 'T', url: 'u' }], + }, + }, + }, + async ({ controller, handlers, launcher }) => { + handlers.fetchSessionDisclaimers.mockResolvedValue( + MOCK_SESSION_DISCLAIMERS, + ); + handlers.submitSessionDisclaimers.mockRejectedValue( + new HttpError( + 409, + "Fetching 'disclaimers' failed with status '409'", + ), + ); + handlers.fetchDisclaimers.mockResolvedValue([]); + + await controller.acceptTermsAndStartSession({ + email: 'a@b.co', + product: 'money', + sumsubTncSigned: true, + idosTncSigned: true, + }); + + expect(controller.state.phase).toBe('terms'); + expect(controller.state.error).toMatch(/Consents session failed/u); + expect(launcher.launch).not.toHaveBeenCalled(); + }, + ); + }); + + it('omits already-consented catalog documents from the POST body', async () => { + await withController( + { + options: { + state: { + activeVendor: 'iron', + disclaimers: [{ id: 'd1', display_name: 'T', url: 'u' }], + }, + userStatusPollIntervalMs: 60_000, + }, + }, + async ({ controller, handlers, launcher }) => { + handlers.fetchSessionDisclaimers.mockResolvedValue({ + idOS: [ + { + key: 'idos-tos', + version: '1', + title: 'idOS ToS', + url: 'https://idos.example/tos', + consented: true, + }, + { + key: 'idos-privacy', + version: '2', + title: 'idOS Privacy', + url: 'https://idos.example/privacy', + consented: false, + }, + ], + kycProvider: MOCK_SESSION_DISCLAIMERS.kycProvider, + credentialReusabilityConsentGiven: false, + }); + launcher.launch.mockImplementation(async ({ onStatusChange }) => { + onStatusChange?.('InProgress', 'Completed'); + return { ok: true }; + }); + + await controller.acceptTermsAndStartSession({ + email: 'a@b.co', + product: 'money', + sumsubTncSigned: true, + idosTncSigned: true, + }); + + expect(handlers.submitSessionDisclaimers).toHaveBeenCalledWith({ + sessionId: 'sid', + idOS: [{ key: 'idos-privacy', version: '2' }], + kycProvider: [{ key: 'sumsub-tos', version: '1' }], + credentialReusabilityConsentGiven: false, + }); + controller.reset(); + }, + ); + }); + + it('skips posting session disclaimers when the catalog is already consented', async () => { + await withController( + { + options: { + state: { + activeVendor: 'iron', + disclaimers: [{ id: 'd1', display_name: 'T', url: 'u' }], + }, + userStatusPollIntervalMs: 60_000, + }, + }, + async ({ controller, handlers, launcher }) => { + handlers.fetchSessionDisclaimers.mockResolvedValue({ + ...MOCK_SESSION_DISCLAIMERS, + idOS: MOCK_SESSION_DISCLAIMERS.idOS.map((doc) => ({ + ...doc, + consented: true, + })), + kycProvider: MOCK_SESSION_DISCLAIMERS.kycProvider.map((doc) => ({ + ...doc, + consented: true, + })), + }); + launcher.launch.mockImplementation(async ({ onStatusChange }) => { + onStatusChange?.('InProgress', 'Completed'); + return { ok: true }; + }); + + await controller.acceptTermsAndStartSession({ + email: 'a@b.co', + product: 'money', + sumsubTncSigned: true, + idosTncSigned: true, + }); + + expect(handlers.submitSessionDisclaimers).not.toHaveBeenCalled(); + expect(launcher.launch).toHaveBeenCalled(); + controller.reset(); + }, + ); + }); + + it('fails the consents path when T&C2 flags are omitted', async () => { + await withController( + { + options: { + state: { + activeVendor: 'iron', + disclaimers: [{ id: 'd1', display_name: 'T', url: 'u' }], + }, + }, + }, + async ({ controller, handlers }) => { + // @ts-expect-error T&C2 flags are required + await controller.acceptTermsAndStartSession({ + email: 'a@b.co', + product: 'money', + }); + + expect(controller.state.phase).toBe('error'); + expect(controller.state.error).toMatch(/Missing T&C2 acceptance/u); + expect(controller.state.termsAcceptedAt).toBeNull(); + expect(handlers.submitSessionDisclaimers).not.toHaveBeenCalled(); + }, + ); + }); + + it('fails the consents path when only one T&C2 flag is provided', async () => { + await withController( + { + options: { + state: { + activeVendor: 'iron', + disclaimers: [{ id: 'd1', display_name: 'T', url: 'u' }], + }, + }, + }, + async ({ controller, handlers }) => { + // @ts-expect-error both T&C2 flags are required + await controller.acceptTermsAndStartSession({ + email: 'a@b.co', + sumsubTncSigned: true, + }); + + expect(controller.state.phase).toBe('error'); + expect(controller.state.error).toMatch(/Missing T&C2 acceptance/u); + expect(handlers.submitSessionDisclaimers).not.toHaveBeenCalled(); + }, + ); + }); + + it('does not post session disclaimers when T&C2 is declined', async () => { + await withController( + { + options: { + state: { + activeVendor: 'iron', + disclaimers: [{ id: 'd1', display_name: 'T', url: 'u' }], + }, + userStatusPollIntervalMs: 60_000, + }, + }, + async ({ controller, handlers, launcher }) => { + launcher.launch.mockImplementation(async ({ onStatusChange }) => { + onStatusChange?.('InProgress', 'Completed'); + return { ok: true }; + }); + + await controller.acceptTermsAndStartSession({ + email: 'a@b.co', + product: 'money', + sumsubTncSigned: false, + idosTncSigned: false, + }); + + expect(handlers.submitSessionDisclaimers).not.toHaveBeenCalled(); + expect(controller.state.sumsubTncAccepted).toBe(false); + expect(controller.state.idosTncAccepted).toBe(false); + expect(handlers.submitVendorDisclaimers).toHaveBeenCalledWith({ + vendor: 'iron', + disclaimerIds: ['d1'], + }); + expect(launcher.launch).toHaveBeenCalled(); + controller.reset(); + }, + ); + }); + + it('fails the Iron session when email is missing', async () => { + await withController( + { + options: { + state: { + activeVendor: 'iron', + disclaimers: [{ id: 'd1', display_name: 'T', url: 'u' }], + }, + }, + }, + async ({ controller }) => { + await controller.acceptTermsAndStartSession({ + sumsubTncSigned: true, + idosTncSigned: true, + }); + + expect(controller.state.phase).toBe('error'); + expect(controller.state.error).toMatch(/Missing email/u); + }, + ); + }); + + it('fails the Iron session when disclaimer acceptance is missing', async () => { + await withController( + { + options: { + state: { + activeVendor: 'iron', + email: 'a@b.co', + disclaimers: [], + }, + }, + }, + async ({ controller }) => { + await controller.acceptTermsAndStartSession({ + email: 'a@b.co', + sumsubTncSigned: true, + idosTncSigned: true, + }); + + expect(controller.state.phase).toBe('error'); + expect(controller.state.error).toMatch( + /Missing disclaimer acceptance/u, + ); + }, + ); + }); + + it('returns to terms when SumSub fails during the Iron session', async () => { + await withController( + { + options: { + state: { + activeVendor: 'iron', + disclaimers: [{ id: 'd1', display_name: 'T', url: 'u' }], + }, + }, + }, + async ({ controller, handlers }) => { + handlers.createUkycSession.mockRejectedValue( + new Error('sumsub down'), + ); + handlers.fetchDisclaimers.mockResolvedValue([]); + + await controller.acceptTermsAndStartSession({ + email: 'a@b.co', + sumsubTncSigned: true, + idosTncSigned: true, + }); + + expect(controller.state.phase).toBe('terms'); + expect(controller.state.termsAcceptedAt).toBeNull(); + expect(controller.state.error).toMatch(/Consents session failed/u); + }, + ); + }); + + it('returns to terms when the SumSub journey fails after consents', async () => { + await withController( + { + options: { + state: { + activeVendor: 'iron', + disclaimers: [{ id: 'd1', display_name: 'T', url: 'u' }], + }, + }, + }, + async ({ controller, handlers }) => { + handlers.createJourney.mockRejectedValue(new Error('journey down')); + handlers.fetchDisclaimers.mockResolvedValue([]); + + await controller.acceptTermsAndStartSession({ + email: 'a@b.co', + sumsubTncSigned: true, + idosTncSigned: true, + }); + + expect(controller.state.phase).toBe('terms'); + expect(controller.state.error).toMatch(/journey down/u); + }, + ); + }); + + it('returns to terms when SumSub closes without completion during the Iron session', async () => { + await withController( + { + options: { + state: { + activeVendor: 'iron', + disclaimers: [{ id: 'd1', display_name: 'T', url: 'u' }], + }, + }, + }, + async ({ controller, handlers, launcher }) => { + launcher.launch.mockImplementation(async ({ onStatusChange }) => { + // Applicant abandons: launch resolves without a Completed status. + onStatusChange?.('idle', 'InProgress'); + return { ok: false }; + }); + handlers.fetchDisclaimers.mockResolvedValue([]); + + await controller.acceptTermsAndStartSession({ + email: 'a@b.co', + sumsubTncSigned: true, + idosTncSigned: true, + }); + + expect(controller.state.phase).toBe('terms'); + expect(controller.state.sumsub.status).toBe('idle'); + expect(controller.state.sumsub.sessionId).toBeNull(); + expect(controller.state.termsAcceptedAt).toBeNull(); + expect(controller.state.error).toMatch(/Consents session failed/u); + expect(handlers.fetchKycStatus).not.toHaveBeenCalled(); + }, + ); + }); + + it('finishes as done when UKYC rejects after SumSub completed', async () => { + await withController( + { + options: { + state: { + activeVendor: 'iron', + disclaimers: [{ id: 'd1', display_name: 'T', url: 'u' }], + }, + userStatusPollIntervalMs: 60_000, + }, + }, + async ({ controller, handlers, launcher }) => { + launcher.launch.mockImplementation(async ({ onStatusChange }) => { + onStatusChange?.('InProgress', 'Completed'); + return { ok: true }; + }); + handlers.getSessionStatus.mockResolvedValue( + sessionStatus('rejected'), + ); + handlers.fetchKycStatus.mockResolvedValue({ + status: 'terminal-failure', + }); + + await controller.acceptTermsAndStartSession({ + email: 'a@b.co', + sumsubTncSigned: true, + idosTncSigned: true, + }); + + expect(controller.state.phase).toBe('done'); + expect(controller.state.sumsub.status).toBe('failed'); + expect(controller.state.sumsub.sessionStatus).toStrictEqual( + sessionStatus('rejected'), + ); + expect(controller.state.termsAcceptedAt).not.toBeNull(); + expect(controller.state.error).toBeNull(); + expect(handlers.fetchKycStatus).toHaveBeenCalled(); + expect(controller.state.userStatus).toBe('terminal-failure'); + controller.reset(); + }, + ); + }); + + it('keeps done when status refresh fails after a successful SumSub', async () => { + await withController( + { + options: { + state: { + activeVendor: 'iron', + disclaimers: [{ id: 'd1', display_name: 'T', url: 'u' }], + }, + userStatusPollIntervalMs: 60_000, + }, + }, + async ({ controller, handlers, launcher }) => { + launcher.launch.mockImplementation(async ({ onStatusChange }) => { + onStatusChange?.('InProgress', 'Completed'); + return { ok: true }; + }); + handlers.fetchKycStatus.mockRejectedValue(new Error('status down')); + + await controller.acceptTermsAndStartSession({ + email: 'a@b.co', + sumsubTncSigned: true, + idosTncSigned: true, + }); + + expect(controller.state.phase).toBe('done'); + expect(controller.state.sumsub.status).toBe('complete'); + controller.reset(); + }, + ); + }); + + it('ignores in-flight Iron consents after reset', async () => { + await withController( + { + options: { + state: { + activeVendor: 'iron', + disclaimers: [{ id: 'd1', display_name: 'T', url: 'u' }], + }, + }, + }, + async ({ controller, handlers }) => { + let release: () => void = () => { + // placeholder + }; + handlers.fetchSessionDisclaimers.mockReturnValue( + new Promise((resolve) => { + release = (): void => { + resolve(MOCK_SESSION_DISCLAIMERS); + }; + }), + ); + + const pending = controller.acceptTermsAndStartSession({ + email: 'a@b.co', + sumsubTncSigned: true, + idosTncSigned: true, + }); + controller.reset(); + release(); + await pending; + + expect(controller.state.phase).toBe('idle'); + expect(handlers.submitSessionDisclaimers).not.toHaveBeenCalled(); + }, + ); + }); + + it('ignores SumSub completion after reset during the Iron session', async () => { + await withController( + { + options: { + state: { + activeVendor: 'iron', + disclaimers: [{ id: 'd1', display_name: 'T', url: 'u' }], + }, + userStatusPollIntervalMs: 60_000, + }, + }, + async ({ controller, handlers, launcher }) => { + let releaseLaunch: (value: { ok: boolean }) => void = () => { + // placeholder + }; + launcher.launch.mockReturnValue( + new Promise((resolve) => { + releaseLaunch = resolve; + }), + ); + + const pending = controller.acceptTermsAndStartSession({ + email: 'a@b.co', + sumsubTncSigned: true, + idosTncSigned: true, + }); + // Session create + session disclaimers run first; wait until launch + // is pending so reset races with an in-flight SDK presentation. + while (launcher.launch.mock.calls.length === 0) { + await Promise.resolve(); + } + controller.reset(); + releaseLaunch({ ok: true }); + await pending; + + expect(controller.state.phase).toBe('idle'); + expect(handlers.fetchKycStatus).not.toHaveBeenCalled(); + }, + ); + }); + + it('ignores Iron session failures after reset', async () => { + await withController( + { + options: { + state: { + activeVendor: 'iron', + disclaimers: [{ id: 'd1', display_name: 'T', url: 'u' }], + }, + }, + }, + async ({ controller, handlers }) => { + let release: (error: Error) => void = () => { + // placeholder + }; + handlers.createUkycSession.mockReturnValue( + new Promise((_resolve, reject) => { + release = reject; + }), + ); + + const pending = controller.acceptTermsAndStartSession({ + email: 'a@b.co', + sumsubTncSigned: true, + idosTncSigned: true, + }); + while (handlers.createUkycSession.mock.calls.length === 0) { + await Promise.resolve(); + } + controller.reset(); + release(new Error('late consent failure')); + await pending; + + expect(controller.state.phase).toBe('idle'); + expect(controller.state.error).toBeNull(); + }, + ); + }); + + it('skips SumSub when the consents-path session is already vendor-processing', async () => { + await withController( + { + options: { + state: { + activeVendor: 'iron', + disclaimers: [{ id: 'd1', display_name: 'T', url: 'u' }], + }, + userStatusPollIntervalMs: 60_000, + }, + }, + async ({ controller, handlers, launcher }) => { + handlers.setAuthorizations.mockResolvedValue({ + ...sessionStatus('pending'), + kycStatus: 'approved', + finalStatus: 'pending', + }); + handlers.fetchKycStatus.mockRejectedValue(new Error('status down')); + + await controller.acceptTermsAndStartSession({ + email: 'a@b.co', + product: 'money', + sumsubTncSigned: true, + idosTncSigned: true, + }); + + expect(handlers.fetchSessionDisclaimers).toHaveBeenCalledWith({ + sessionId: 'sid', + }); + expect(launcher.launch).not.toHaveBeenCalled(); + expect(controller.state.sumsub.status).toBe('vendorProcessing'); + expect(controller.state.phase).toBe('done'); + controller.reset(); + }, + ); + }); + + it('ignores a 409 re-fetch that settles after reset', async () => { + await withController( + { + options: { + state: { + activeVendor: 'iron', + disclaimers: [{ id: 'd1', display_name: 'T', url: 'u' }], + }, + }, + }, + async ({ controller, handlers }) => { + handlers.fetchSessionDisclaimers + .mockResolvedValueOnce(MOCK_SESSION_DISCLAIMERS) + .mockImplementationOnce(async () => { + controller.reset(); + return MOCK_SESSION_DISCLAIMERS; + }); + handlers.submitSessionDisclaimers.mockRejectedValue( + new HttpError( + 409, + "Fetching 'disclaimers' failed with status '409'", + ), + ); + + await controller.acceptTermsAndStartSession({ + email: 'a@b.co', + sumsubTncSigned: true, + idosTncSigned: true, + }); + + expect(controller.state.phase).toBe('idle'); + expect(controller.state.sessionDisclaimers).toBeNull(); + }, + ); + }); + + it('ignores a session-disclaimer fetch that settles after reset', async () => { + await withController( + { + options: { + state: { + activeVendor: 'iron', + disclaimers: [{ id: 'd1', display_name: 'T', url: 'u' }], + }, + }, + }, + async ({ controller, handlers }) => { + handlers.fetchSessionDisclaimers.mockImplementation(async () => { + controller.reset(); + return MOCK_SESSION_DISCLAIMERS; + }); + + await controller.acceptTermsAndStartSession({ + email: 'a@b.co', + sumsubTncSigned: true, + idosTncSigned: true, + }); + + expect(controller.state.phase).toBe('idle'); + expect(handlers.submitSessionDisclaimers).not.toHaveBeenCalled(); + }, + ); + }); + + it('returns to terms when recording vendor disclaimers fails', async () => { + await withController( + { + options: { + state: { + activeVendor: 'iron', + disclaimers: [{ id: 'd1', display_name: 'T', url: 'u' }], + }, + }, + }, + async ({ controller, handlers }) => { + handlers.submitVendorDisclaimers.mockRejectedValue( + new Error('iron signings down'), + ); + handlers.fetchDisclaimers.mockResolvedValue([]); + + await controller.acceptTermsAndStartSession({ + email: 'a@b.co', + sumsubTncSigned: true, + idosTncSigned: true, + }); + + expect(controller.state.phase).toBe('terms'); + expect(controller.state.error).toMatch(/iron signings down/u); + expect(handlers.createUkycSession).not.toHaveBeenCalled(); + expect(handlers.submitSessionDisclaimers).not.toHaveBeenCalled(); + expect(controller.state.sumsub.sessionId).toBeNull(); + }, + ); + }); + + it('ignores vendor disclaimer recording that settles after reset', async () => { + await withController( + { + options: { + state: { + activeVendor: 'iron', + disclaimers: [{ id: 'd1', display_name: 'T', url: 'u' }], + }, + }, + }, + async ({ controller, handlers }) => { + handlers.submitVendorDisclaimers.mockImplementation(async () => { + controller.reset(); + return [{ id: 'sign-1', customer_id: 'cust-1', content_id: 'd1' }]; + }); + + await controller.acceptTermsAndStartSession({ + email: 'a@b.co', + sumsubTncSigned: true, + idosTncSigned: true, + }); + + expect(controller.state.phase).toBe('idle'); + expect(handlers.createUkycSession).not.toHaveBeenCalled(); + }, + ); + }); + + it('returns to terms when recording session disclaimers fails', async () => { + await withController( + { + options: { + state: { + activeVendor: 'iron', + disclaimers: [{ id: 'd1', display_name: 'T', url: 'u' }], + }, + }, + }, + async ({ controller, handlers }) => { + handlers.submitSessionDisclaimers.mockRejectedValue( + new HttpError( + 500, + "Fetching 'disclaimers' failed with status '500'", + ), + ); + handlers.fetchDisclaimers.mockResolvedValue([]); + + await controller.acceptTermsAndStartSession({ + email: 'a@b.co', + sumsubTncSigned: true, + idosTncSigned: true, + }); + + expect(controller.state.phase).toBe('terms'); + expect(controller.state.error).toMatch(/Consents session failed/u); + expect(controller.state.sessionDisclaimers).toBeNull(); + expect(controller.state.sumsub.sessionId).toBeNull(); + expect(controller.state.sumsub.status).toBe('idle'); + }, + ); + }); + + it('fails closed when an accepted catalog category is empty', async () => { + await withController( + { + options: { + state: { + activeVendor: 'iron', + disclaimers: [{ id: 'd1', display_name: 'T', url: 'u' }], + }, + }, + }, + async ({ controller, handlers, launcher }) => { + handlers.fetchSessionDisclaimers.mockResolvedValue({ + idOS: [], + kycProvider: MOCK_SESSION_DISCLAIMERS.kycProvider, + credentialReusabilityConsentGiven: false, + }); + handlers.fetchDisclaimers.mockResolvedValue([]); + + await controller.acceptTermsAndStartSession({ + email: 'a@b.co', + sumsubTncSigned: true, + idosTncSigned: true, + }); + + expect(controller.state.phase).toBe('terms'); + expect(controller.state.error).toMatch( + /missing documents for an accepted category/u, + ); + expect(handlers.submitSessionDisclaimers).not.toHaveBeenCalled(); + expect(controller.state.sumsub.sessionId).toBeNull(); + expect(launcher.launch).not.toHaveBeenCalled(); + }, + ); + }); + + it('fails closed when an accepted KYC-provider catalog is empty', async () => { + await withController( + { + options: { + state: { + activeVendor: 'iron', + disclaimers: [{ id: 'd1', display_name: 'T', url: 'u' }], + }, + }, + }, + async ({ controller, handlers, launcher }) => { + handlers.fetchSessionDisclaimers.mockResolvedValue({ + idOS: MOCK_SESSION_DISCLAIMERS.idOS, + kycProvider: [], + credentialReusabilityConsentGiven: false, + }); + handlers.fetchDisclaimers.mockResolvedValue([]); + + await controller.acceptTermsAndStartSession({ + email: 'a@b.co', + sumsubTncSigned: true, + idosTncSigned: true, + }); + + expect(controller.state.phase).toBe('terms'); + expect(controller.state.error).toMatch( + /missing documents for an accepted category/u, + ); + expect(handlers.submitSessionDisclaimers).not.toHaveBeenCalled(); + expect(launcher.launch).not.toHaveBeenCalled(); + }, + ); + }); + + it('fails closed when a 409 re-GET returns an empty accepted category', async () => { + await withController( + { + options: { + state: { + activeVendor: 'iron', + disclaimers: [{ id: 'd1', display_name: 'T', url: 'u' }], + }, + }, + }, + async ({ controller, handlers, launcher }) => { + handlers.fetchSessionDisclaimers + .mockResolvedValueOnce(MOCK_SESSION_DISCLAIMERS) + .mockResolvedValueOnce({ + idOS: [], + kycProvider: MOCK_SESSION_DISCLAIMERS.kycProvider.map((doc) => ({ + ...doc, + consented: true, + })), + credentialReusabilityConsentGiven: false, + }); + handlers.submitSessionDisclaimers.mockRejectedValue( + new HttpError( + 409, + "Fetching 'disclaimers' failed with status '409'", + ), + ); + handlers.fetchDisclaimers.mockResolvedValue([]); + + await controller.acceptTermsAndStartSession({ + email: 'a@b.co', + sumsubTncSigned: true, + idosTncSigned: true, + }); + + expect(controller.state.phase).toBe('terms'); + expect(controller.state.error).toMatch(/Consents session failed/u); + expect(controller.state.sumsub.sessionId).toBeNull(); + expect(launcher.launch).not.toHaveBeenCalled(); + }, + ); + }); + + it('ignores recorded session disclaimers that settle after reset', async () => { + await withController( + { + options: { + state: { + activeVendor: 'iron', + disclaimers: [{ id: 'd1', display_name: 'T', url: 'u' }], + }, + }, + }, + async ({ controller, handlers }) => { + handlers.submitSessionDisclaimers.mockImplementation(async () => { + controller.reset(); + return MOCK_SESSION_DISCLAIMERS; + }); + + await controller.acceptTermsAndStartSession({ + email: 'a@b.co', + sumsubTncSigned: true, + idosTncSigned: true, + }); + + expect(controller.state.phase).toBe('idle'); + expect(handlers.createJourney).not.toHaveBeenCalled(); + }, + ); + }); + + it('ignores UKYC session creation that settles after reset', async () => { + await withController( + { + options: { + state: { + activeVendor: 'iron', + disclaimers: [{ id: 'd1', display_name: 'T', url: 'u' }], + }, + }, + }, + async ({ controller, handlers }) => { + handlers.createUkycSession.mockImplementation(async () => { + controller.reset(); + return { sessionId: 'sid' }; + }); + + await controller.acceptTermsAndStartSession({ + email: 'a@b.co', + sumsubTncSigned: true, + idosTncSigned: true, + }); + + expect(controller.state.phase).toBe('idle'); + expect(handlers.submitSessionDisclaimers).not.toHaveBeenCalled(); + }, + ); + }); + + it('ignores already-completed session creation after reset', async () => { + await withController( + { + options: { + state: { + activeVendor: 'iron', + disclaimers: [{ id: 'd1', display_name: 'T', url: 'u' }], + }, + }, + }, + async ({ controller, handlers }) => { + handlers.createUkycSession.mockImplementation(async () => { + controller.reset(); + throw new Error('session_not_in_valid_state'); + }); + + await controller.acceptTermsAndStartSession({ + email: 'a@b.co', + sumsubTncSigned: true, + idosTncSigned: true, + }); + + expect(controller.state.phase).toBe('idle'); + expect(controller.state.userStatus).toBeNull(); + }, + ); + }); + + it('refreshKycStatus stores status and emits statusChanged', async () => { + await withController( + { options: { userStatusPollIntervalMs: 60_000 } }, + async ({ controller, handlers, rootMessenger }) => { + const listener = jest.fn(); + rootMessenger.subscribe('KycController:statusChanged', listener); + handlers.fetchKycStatus.mockResolvedValue({ + status: 'completed', + sumsubSessionId: 'ss-1', + }); + + const result = await controller.refreshKycStatus(); + + expect(result).toStrictEqual({ + status: 'completed', + sumsubSessionId: 'ss-1', + errorCode: null, + }); + expect(controller.state.userStatus).toBe('completed'); + expect(listener).toHaveBeenCalledWith({ + status: 'completed', + sumsubSessionId: 'ss-1', + errorCode: null, + }); + }, + ); + }); + + it('polls user status while pending and stops on a terminal status', async () => { + jest.useFakeTimers(); + try { + await withController( + { options: { userStatusPollIntervalMs: 1000 } }, + async ({ controller, handlers }) => { + handlers.fetchKycStatus + .mockResolvedValueOnce({ status: 'pending' }) + .mockResolvedValueOnce({ status: 'pending' }) + .mockResolvedValueOnce({ status: 'completed' }); + + await controller.refreshKycStatus(); + expect(controller.state.userStatus).toBe('pending'); + + // First tick stays pending and reschedules; second tick completes. + await jest.advanceTimersByTimeAsync(1000); + expect(controller.state.userStatus).toBe('pending'); + await jest.advanceTimersByTimeAsync(1000); + expect(controller.state.userStatus).toBe('completed'); + + // A second refresh while pending would no-op the timer start; then + // reset clears any leftover handles. + handlers.fetchKycStatus.mockResolvedValue({ status: 'pending' }); + await controller.refreshKycStatus(); + await controller.refreshKycStatus(); + controller.reset(); + }, + ); + } finally { + jest.useRealTimers(); + } + }); + + it('does not start a second poll loop when refreshed during an in-flight tick', async () => { + jest.useFakeTimers(); + try { + await withController( + { options: { userStatusPollIntervalMs: 1000 } }, + async ({ controller, handlers }) => { + let releaseTick: (value: { status: string }) => void = () => { + // placeholder + }; + handlers.fetchKycStatus + // Initial refresh starts the loop. + .mockResolvedValueOnce({ status: 'pending' }) + // The first scheduled tick hangs, so the timer handle is null + // while the request is in flight. + .mockImplementationOnce( + async () => + new Promise((resolve) => { + releaseTick = resolve; + }), + ) + // Any later poll stays pending so the loop keeps scheduling. + .mockResolvedValue({ status: 'pending' }); + + await controller.refreshKycStatus(); + expect(handlers.fetchKycStatus).toHaveBeenCalledTimes(1); + + // Fire the scheduled tick; it clears the timer handle then awaits. + jest.advanceTimersByTime(1000); + await Promise.resolve(); + expect(handlers.fetchKycStatus).toHaveBeenCalledTimes(2); + + // A concurrent refresh while the tick is in flight (timer handle + // null) must not spin up a second loop on the same token. + await controller.refreshKycStatus(); + expect(handlers.fetchKycStatus).toHaveBeenCalledTimes(3); + + // Let the in-flight tick resolve and reschedule. + releaseTick({ status: 'pending' }); + await Promise.resolve(); + await Promise.resolve(); + + // A single loop means exactly one fetch per interval; a duplicated + // loop would fire twice here. + await jest.advanceTimersByTimeAsync(1000); + expect(handlers.fetchKycStatus).toHaveBeenCalledTimes(4); + + controller.reset(); + }, + ); + } finally { + jest.useRealTimers(); + } + }); + + it('drops superseded user-status poll ticks after reset', async () => { + jest.useFakeTimers(); + try { + await withController( + { options: { userStatusPollIntervalMs: 1000 } }, + async ({ controller, handlers }) => { + let release: (value: { status: string }) => void = () => { + // placeholder + }; + handlers.fetchKycStatus + .mockResolvedValueOnce({ status: 'pending' }) + .mockImplementationOnce( + async () => + new Promise((resolve) => { + release = resolve; + }), + ); + + await controller.refreshKycStatus(); + jest.advanceTimersByTime(1000); + await Promise.resolve(); + await Promise.resolve(); + controller.reset(); + release({ status: 'completed' }); + await Promise.resolve(); + await Promise.resolve(); + + expect(controller.state.userStatus).toBe('pending'); + }, + ); + } finally { + jest.useRealTimers(); + } + }); + + it('keeps polling when a user-status tick fails transiently', async () => { + jest.useFakeTimers(); + try { + await withController( + { options: { userStatusPollIntervalMs: 1000 } }, + async ({ controller, handlers }) => { + handlers.fetchKycStatus + .mockResolvedValueOnce({ status: 'pending' }) + .mockRejectedValueOnce(new Error('transient')) + .mockResolvedValueOnce({ status: 'completed' }); + + await controller.refreshKycStatus(); + await jest.advanceTimersByTimeAsync(1000); + await jest.advanceTimersByTimeAsync(1000); + + expect(controller.state.userStatus).toBe('completed'); + controller.reset(); + }, + ); + } finally { + jest.useRealTimers(); + } + }); + + it('drops superseded user-status ticks that fail after reset', async () => { + jest.useFakeTimers(); + try { + await withController( + { options: { userStatusPollIntervalMs: 1000 } }, + async ({ controller, handlers }) => { + let release: (error: Error) => void = () => { + // placeholder + }; + handlers.fetchKycStatus + .mockResolvedValueOnce({ status: 'pending' }) + .mockImplementationOnce( + async () => + new Promise((_resolve, reject) => { + release = reject; + }), + ); + + await controller.refreshKycStatus(); + jest.advanceTimersByTime(1000); + await Promise.resolve(); + await Promise.resolve(); + controller.reset(); + release(new Error('late')); + await Promise.resolve(); + await Promise.resolve(); + + expect(controller.state.userStatus).toBe('pending'); + }, + ); + } finally { + jest.useRealTimers(); + } + }); + + it('returns cached user status when reset lands during refresh', async () => { + await withController( + { + options: { + state: { userStatus: 'pending' }, + userStatusPollIntervalMs: 60_000, + }, + }, + async ({ controller, handlers }) => { + let release: (value: { status: string }) => void = () => { + // placeholder + }; + handlers.fetchKycStatus.mockReturnValue( + new Promise((resolve) => { + release = resolve; + }), + ); + + const pending = controller.refreshKycStatus(); + controller.reset(); + release({ status: 'completed' }); + const result = await pending; + + expect(result.status).toBe('pending'); + }, + ); + }); + + it('does not restart polling when reset lands during refresh', async () => { + jest.useFakeTimers(); + try { + await withController( + { + options: { + state: { userStatus: 'pending' }, + userStatusPollIntervalMs: 1000, + }, + }, + async ({ controller, handlers, rootMessenger }) => { + const listener = jest.fn(); + rootMessenger.subscribe('KycController:statusChanged', listener); + let release: (value: { status: string }) => void = () => { + // placeholder + }; + handlers.fetchKycStatus.mockReturnValue( + new Promise((resolve) => { + release = resolve; + }), + ); + + const pending = controller.refreshKycStatus(); + controller.reset(); + release({ status: 'pending' }); + await pending; + handlers.fetchKycStatus.mockClear(); + await jest.advanceTimersByTimeAsync(3000); + + expect(handlers.fetchKycStatus).not.toHaveBeenCalled(); + expect(listener).not.toHaveBeenCalled(); + }, + ); + } finally { + jest.useRealTimers(); + } + }); + + it('defaults superseded refresh status to not-started when unset', async () => { + await withController( + { options: { userStatusPollIntervalMs: 60_000 } }, + async ({ controller, handlers }) => { + let release: (value: { status: string }) => void = () => { + // placeholder + }; + handlers.fetchKycStatus.mockReturnValue( + new Promise((resolve) => { + release = resolve; + }), + ); + + const pending = controller.refreshKycStatus(); + controller.reset(); + release({ status: 'completed' }); + const result = await pending; + + expect(result.status).toBe('not-started'); + }, + ); + }); + + it('maps session_not_in_valid_state to completed during SumSub', async () => { + await withController( + { + options: { + state: { activeVendor: 'iron', phase: 'submit', geoCountry: 'USA' }, + }, + }, + async ({ controller, handlers }) => { + handlers.createUkycSession.mockRejectedValue( + new Error( + "Fetching 'https://x' failed with status '409': session_not_in_valid_state", + ), + ); + + const result = await controller.startSumSub(); + + expect(result).toStrictEqual({ alreadyCompleted: true }); + expect(controller.state.userStatus).toBe('completed'); + expect(controller.state.phase).toBe('done'); + expect(controller.state.sumsub.status).toBe('complete'); + }, + ); + }); + + it('leaves an already-reset controller idle when SumSub reports a stale session', async () => { + await withController( + { + options: { + state: { activeVendor: 'iron', phase: 'submit', geoCountry: 'USA' }, + }, + }, + async ({ controller, handlers }) => { + let rejectSession: (error: Error) => void = () => undefined; + handlers.createUkycSession.mockReturnValue( + new Promise((_resolve, reject) => { + rejectSession = reject; + }), + ); + + const pending = controller.startSumSub(); + controller.reset(); + rejectSession(new Error('session_not_in_valid_state')); + + expect(await pending).toStrictEqual({ alreadyCompleted: true }); + expect(controller.state.userStatus).toBeNull(); + expect(controller.state.phase).toBe('idle'); + expect(controller.state.sumsub.status).toBe('idle'); + }, + ); + }); + + it('keeps phase done when Iron SumSub reports already completed', async () => { + await withController( + { + options: { + state: { + activeVendor: 'iron', + disclaimers: [{ id: 'd1', display_name: 'T', url: 'u' }], + }, + userStatusPollIntervalMs: 60_000, + }, + }, + async ({ controller, handlers }) => { + handlers.createUkycSession.mockRejectedValue( + new Error('session_not_in_valid_state'), + ); + handlers.fetchKycStatus.mockResolvedValue({ status: 'completed' }); + + await controller.acceptTermsAndStartSession({ + email: 'a@b.co', + sumsubTncSigned: true, + idosTncSigned: true, + }); + + expect(controller.state.phase).toBe('done'); + expect(controller.state.userStatus).toBe('completed'); + controller.reset(); + }, + ); + }); + + it('keeps phase done when the journey reports already completed after consents', async () => { + await withController( + { + options: { + state: { + activeVendor: 'iron', + disclaimers: [{ id: 'd1', display_name: 'T', url: 'u' }], + }, + userStatusPollIntervalMs: 60_000, + }, + }, + async ({ controller, handlers }) => { + handlers.createJourney.mockRejectedValue( + new Error('session_not_in_valid_state'), + ); + + await controller.acceptTermsAndStartSession({ + email: 'a@b.co', + sumsubTncSigned: true, + idosTncSigned: true, + }); + + expect(controller.state.phase).toBe('done'); + expect(controller.state.sumsub.result).toStrictEqual({ + alreadyCompleted: true, + }); + controller.reset(); + }, + ); + }); + }); + + describe('messenger actions', () => { + it('exposes methods as messenger actions', async () => { + await withController(({ rootMessenger }) => { + expect( + rootMessenger.call('KycController:buildResetFrameUrl'), + ).toContain('ch_reset'); + }); + }); + }); +}); + +type RootMessenger = Messenger< + MockAnyNamespace, + MessengerActions, + MessengerEvents +>; + +type ServiceHandlers = { + getGeoCountry: jest.Mock; + fetchDisclaimers: jest.Mock; + createSession: jest.Mock; + checkKycRequired: jest.Mock; + createVendorCustomer: jest.Mock; + submitVendorDisclaimers: jest.Mock; + fetchSessionDisclaimers: jest.Mock; + submitSessionDisclaimers: jest.Mock; + fetchKycStatus: jest.Mock; + fetchIdosEnclaveJwks: jest.Mock; + fetchIdosRelayJwks: jest.Mock; + createUkycSession: jest.Mock; + setAuthorizations: jest.Mock; + createJourney: jest.Mock; + getSessionStatus: jest.Mock; + performGetStorage: jest.Mock; + performSetStorage: jest.Mock; +}; + +type Launcher = { + isAvailable: jest.Mock; + launch: jest.Mock; +}; + +type WithControllerCallback = (payload: { + controller: KycController; + rootMessenger: RootMessenger; + handlers: ServiceHandlers; + launcher: Launcher; +}) => Promise | ReturnValue; + +type WithControllerOptions = { + options: Partial[0]>; +}; + +const SERVICE_ACTIONS = [ + 'KycService:getGeoCountry', + 'KycService:fetchDisclaimers', + 'KycService:createSession', + 'KycService:checkKycRequired', + 'KycService:createVendorCustomer', + 'KycService:submitVendorDisclaimers', + 'KycService:fetchSessionDisclaimers', + 'KycService:submitSessionDisclaimers', + 'KycService:fetchKycStatus', + 'KycService:fetchIdosEnclaveJwks', + 'KycService:fetchIdosRelayJwks', + 'KycService:createUkycSession', + 'KycService:setAuthorizations', + 'KycService:createJourney', + 'KycService:getSessionStatus', + 'UserStorageController:performGetStorage', + 'UserStorageController:performSetStorage', +] as const; + +const ENCRYPTION_SCHEMA = { + serverPublicKey: { kty: 'OKP', crv: 'X25519', x: 'spk-x' }, + jwtChain: 'jwt.chain.sig', +}; + +/** + * Builds a UKYC session-creation payload with encryption schemas. + * + * @param overrides - Fields to overlay on the default session response. + * @returns A complete session-creation response. + */ +function ukycSessionResponse( + overrides: Partial<{ + sessionId: string; + encryptionDataKey: typeof ENCRYPTION_SCHEMA; + ukycCapabilityToken: typeof ENCRYPTION_SCHEMA; + }> = {}, +): { + sessionId: string; + encryptionDataKey: typeof ENCRYPTION_SCHEMA; + ukycCapabilityToken: typeof ENCRYPTION_SCHEMA; +} { + return { + sessionId: 'sid', + encryptionDataKey: ENCRYPTION_SCHEMA, + ukycCapabilityToken: ENCRYPTION_SCHEMA, + ...overrides, + }; +} + +/** + * Builds a UKYC session status payload with a given `finalStatus`. + * + * @param finalStatus - The overall session status. + * @returns A complete session status object. + */ +function sessionStatus(finalStatus: string): { + finalStatus: string; + externalUserId: string; + kycStatus: string; + vendor: string; + vendorStatus: string; +} { + return { + finalStatus, + externalUserId: 'ext-1', + kycStatus: finalStatus, + vendor: 'sumsub', + vendorStatus: finalStatus, + }; +} + +/** + * Wraps a test with a fully-wired controller, mocked service handlers, and a + * mocked SumSub launcher. + * + * @param args - Either a callback, or an options bag and a callback. + * @returns The callback's return value. + */ +function withController( + ...args: + | [WithControllerCallback] + | [WithControllerOptions, WithControllerCallback] +): ReturnValue | Promise { + const [{ options = {} }, testFunction] = + args.length === 2 ? args : [{}, args[0]]; + + const rootMessenger: RootMessenger = new Messenger({ + namespace: MOCK_ANY_NAMESPACE, + captureException: jest.fn(), + }); + const messenger: KycControllerMessenger = new Messenger({ + namespace: 'KycController', + parent: rootMessenger, + }); + rootMessenger.delegate({ + actions: SERVICE_ACTIONS, + events: [], + messenger, + }); + + const handlers: ServiceHandlers = { + getGeoCountry: jest.fn().mockResolvedValue('USA'), + fetchDisclaimers: jest.fn().mockResolvedValue([]), + createSession: jest.fn().mockResolvedValue({ sessionToken: 'sess' }), + checkKycRequired: jest.fn().mockResolvedValue({ kycRequired: false }), + createVendorCustomer: jest.fn().mockResolvedValue({ + id: 'iron-1', + email: 'a@b.co', + status: 'SigningsRequired', + }), + submitVendorDisclaimers: jest + .fn() + .mockResolvedValue([ + { id: 'sign-1', customer_id: 'cust-1', content_id: 'd1' }, + ]), + fetchSessionDisclaimers: jest + .fn() + .mockResolvedValue(MOCK_SESSION_DISCLAIMERS), + submitSessionDisclaimers: jest.fn().mockResolvedValue({ + ...MOCK_SESSION_DISCLAIMERS, + credentialReusabilityConsentGiven: true, + idOS: MOCK_SESSION_DISCLAIMERS.idOS.map((doc) => ({ + ...doc, + consented: true, + })), + kycProvider: MOCK_SESSION_DISCLAIMERS.kycProvider.map((doc) => ({ + ...doc, + consented: true, + })), + }), + fetchKycStatus: jest.fn().mockResolvedValue({ status: 'pending' }), + fetchIdosEnclaveJwks: jest.fn().mockResolvedValue({ keys: [] }), + fetchIdosRelayJwks: jest.fn().mockResolvedValue({ keys: [] }), + createUkycSession: jest.fn().mockResolvedValue(ukycSessionResponse()), + setAuthorizations: jest.fn().mockResolvedValue(sessionStatus('approved')), + createJourney: jest + .fn() + .mockResolvedValue({ status: 'ok', applicantAccessToken: 'aat' }), + getSessionStatus: jest.fn().mockResolvedValue(sessionStatus('approved')), + performGetStorage: jest.fn().mockResolvedValue(null), + performSetStorage: jest.fn().mockResolvedValue(undefined), + }; + rootMessenger.registerActionHandler( + 'KycService:getGeoCountry', + handlers.getGeoCountry, + ); + rootMessenger.registerActionHandler( + 'KycService:fetchDisclaimers', + handlers.fetchDisclaimers, + ); + rootMessenger.registerActionHandler( + 'KycService:createSession', + handlers.createSession, + ); + rootMessenger.registerActionHandler( + 'KycService:checkKycRequired', + handlers.checkKycRequired, + ); + rootMessenger.registerActionHandler( + 'KycService:createVendorCustomer', + handlers.createVendorCustomer, + ); + rootMessenger.registerActionHandler( + 'KycService:submitVendorDisclaimers', + handlers.submitVendorDisclaimers, + ); + rootMessenger.registerActionHandler( + 'KycService:fetchSessionDisclaimers', + handlers.fetchSessionDisclaimers, + ); + rootMessenger.registerActionHandler( + 'KycService:submitSessionDisclaimers', + handlers.submitSessionDisclaimers, + ); + rootMessenger.registerActionHandler( + 'KycService:fetchKycStatus', + handlers.fetchKycStatus, + ); + rootMessenger.registerActionHandler( + 'KycService:fetchIdosEnclaveJwks', + handlers.fetchIdosEnclaveJwks, + ); + rootMessenger.registerActionHandler( + 'KycService:fetchIdosRelayJwks', + handlers.fetchIdosRelayJwks, + ); + rootMessenger.registerActionHandler( + 'KycService:createUkycSession', + handlers.createUkycSession, + ); + rootMessenger.registerActionHandler( + 'KycService:setAuthorizations', + handlers.setAuthorizations, + ); + rootMessenger.registerActionHandler( + 'KycService:createJourney', + handlers.createJourney, + ); + rootMessenger.registerActionHandler( + 'KycService:getSessionStatus', + handlers.getSessionStatus, + ); + rootMessenger.registerActionHandler( + 'UserStorageController:performGetStorage', + handlers.performGetStorage, + ); + rootMessenger.registerActionHandler( + 'UserStorageController:performSetStorage', + handlers.performSetStorage, + ); + + // Configure the mocked UKYC crypto for this test (reset before each test by + // the shared jest config). + mockVerifyJwtChain.mockReturnValue({ + sessionServerPublicKeyX: 'spk-x', + nonce: 'n', + }); + mockWrapEncryptionKey.mockReturnValue({ + data: 'enc', + nonce: 'nonce', + }); + + const launcher: Launcher = { + isAvailable: jest.fn().mockReturnValue(true), + launch: jest.fn().mockResolvedValue({ ok: true }), + }; + + const controller = new KycController({ + messenger, + sumsubLauncher: launcher as unknown as KycSumSubLauncher, + ...options, + }); + + return testFunction({ controller, rootMessenger, handlers, launcher }); +} diff --git a/packages/kyc-controller/src/KycController.ts b/packages/kyc-controller/src/KycController.ts new file mode 100644 index 00000000000..d1c914178bb --- /dev/null +++ b/packages/kyc-controller/src/KycController.ts @@ -0,0 +1,2562 @@ +import type { + ControllerGetStateAction, + ControllerStateChangeEvent, + StateMetadata, +} from '@metamask/base-controller'; +import { BaseController } from '@metamask/base-controller'; +import type { Messenger } from '@metamask/messenger'; +import type { + UserStorageControllerPerformGetStorageAction, + UserStorageControllerPerformSetStorageAction, +} from '@metamask/profile-sync-controller/user-storage'; +import type { Json } from '@metamask/utils'; +import { stringToBytes } from '@metamask/utils'; +import { x25519 } from '@noble/curves/ed25519'; + +import { decryptCredentials, generateKeyPair } from './crypto.js'; +import type { EncryptedCredentialsEnvelope, X25519KeyPair } from './crypto.js'; +import { toBase64Url } from './encoding.js'; +import type { KycControllerMethodActions } from './KycController-method-action-types.js'; +import type { KycServiceMethodActions } from './KycService-method-action-types.js'; +import type { + CreateUkycSessionParams, + EncryptionSchema, +} from './KycService.js'; +import type { + KycConsentDocument, + KycCustomerIdentity, + KycDisclaimer, + KycPhase, + KycProduct, + KycSessionDisclaimers, + KycSessionStatus, + KycSumSubLauncher, + KycSumSubStatus, + KycUserStatus, + KycVendor, +} from './types.js'; +import { deriveClientMaterial } from './ukyc/deriveClientMaterial.js'; +import { verifyJwtChain } from './ukyc/jwtChain.js'; +import type { Jwk } from './ukyc/jwtChain.js'; +import { getOrCreateLocalUserSecret } from './ukyc/localUserSecret.js'; +import type { UkycLocalUserSecretStore } from './ukyc/localUserSecret.js'; +import { + encodeStorageAccessTokenForHeader, + signStorageAccessToken, +} from './ukyc/storageAccessToken.js'; +import { wrapEncryptionKey } from './ukyc/wrapEncryptionKey.js'; + +// === GENERAL === + +export const controllerName = 'KycController'; + +const FRAMES_BASE_URL = 'https://blocks.moonpay.com/platform/v1'; +const CHANNEL_CHECK = 'ch_1'; +const CHANNEL_AUTH = 'ch_2'; +const CHANNEL_RESET = 'ch_reset'; + +// Placeholder credentials for the SumSub sub-flow. These are demo values that +// must be replaced with real UKYC-issued material before production use. +const MOCK_JWT_TOKEN = 'mock-jwt-token'; + +// Lifetime of the read-only `ukyc_capability_token` minted when creating a +// UKYC session. The storage-and-auth spec requires the token's `expires_at` to +// cover the KYC session's expected lifetime — including the provider journey — +// rather than a fixed short window, so this is a session-scoped window. +const UKYC_CAPABILITY_TOKEN_TTL_MS = 4 * 60 * 60 * 1000; + +// The SumSub SDK status that signals the applicant finished the flow +// successfully. Any other resolution (abandonment, failure, or a non-success +// outcome) must not be recorded as `complete`. +const SUMSUB_COMPLETED_STATUS = 'Completed'; + +// Phases that represent an active vendor-session flow (tokens issued and/or +// Check/Auth frames in progress). A repeat `initialize` while in one of these +// must not restart the session and disrupt the in-flight flow. +const IN_PROGRESS_PHASES: KycPhase[] = [ + 'session', + 'check', + 'auth', + 'form', + 'submit', +]; + +// How often to poll the UKYC session status after the SumSub SDK completes, +// until a terminal status is reached. Overridable via the constructor. +const DEFAULT_SESSION_STATUS_POLL_INTERVAL_MS = 15_000; + +// UKYC status values. `kycStatus` (the relay-side decision) and `finalStatus` +// (the vendor-side outcome) draw from the same vocabulary, so they are defined +// once here and composed into the sets/checks below rather than repeated as +// literals. +const KYC_STATUSES = { + approved: 'approved', + completed: 'completed', + rejected: 'rejected', + failed: 'failed', + blocked: 'blocked', + pending: 'pending', +} as const; + +// `finalStatus` values that end the polling loop. Anything else (e.g. +// `KYC_STATUSES.pending`) keeps polling. +const TERMINAL_SESSION_STATUSES: ReadonlySet = new Set([ + KYC_STATUSES.approved, + KYC_STATUSES.completed, + KYC_STATUSES.rejected, + KYC_STATUSES.failed, + KYC_STATUSES.blocked, +]); + +// Terminal `finalStatus` values that represent a successful verification. Any +// other terminal status resolves the sub-flow to `failed`. +const SUCCESSFUL_SESSION_STATUSES: ReadonlySet = new Set([ + KYC_STATUSES.approved, + KYC_STATUSES.completed, +]); + +// Session creation can report that the applicant is already approved on the +// relay (`kycStatus === KYC_STATUSES.approved`) while the vendor is still +// finalizing its decision (`finalStatus === KYC_STATUSES.pending`, a +// non-terminal status). In that case there is nothing left for the applicant +// to do, so the sub-flow stops before launching the SDK and surfaces this +// message. +const VENDOR_PROCESSING_MESSAGE = + 'Your KYC has been submitted and is being processed by the vendor.'; + +// UKYC / relay error indicating the applicant already finished KYC. Mapped to +// the simplified `completed` user status for the Money toast surface. +const SESSION_NOT_IN_VALID_STATE = 'session_not_in_valid_state'; + +// How often to refresh the user-keyed `GET /kyc/status` while the simplified +// status is still `pending`. Overridable via the constructor. +const DEFAULT_USER_STATUS_POLL_INTERVAL_MS = 15_000; + +// === STATE === + +/** + * Describes the shape of the state object for {@link KycController}. + */ +export type KycControllerState = { + /** Current phase of the identity flow. */ + phase: KycPhase; + /** Human-readable status message for the current phase. */ + statusMessage: string; + /** The current error message, or `null`. */ + error: string | null; + + /** Email associated with the session (sourced from the account). */ + email: string | null; + + /** ISO-8601 timestamp of the customer's terms acceptance (persisted). */ + termsAcceptedAt: string | null; + /** IDs of the disclaimers the customer accepted (persisted). */ + acceptedDisclaimerIds: string[]; + /** + * The vendor whose disclaimers `acceptedDisclaimerIds` belong to (persisted). + * Each vendor serves its own disclaimer set, so acceptance recorded for one + * vendor must not be reused for another. `null` when nothing is accepted. + */ + termsAcceptedVendor: KycVendor | null; + /** + * Whether the customer accepted the SumSub T&C (T&C2) during the last + * terms acceptance (persisted). Consents-path vendors require this flag + * when resuming a session. `null` for acceptance recorded before this + * field existed (treated as requiring reacceptance). + */ + sumsubTncAccepted: boolean | null; + /** + * Whether the customer accepted the idOS T&C (T&C2) during the last + * terms acceptance (persisted). Consents-path vendors require this flag + * when resuming a session. `null` for acceptance recorded before this + * field existed (treated as requiring reacceptance). + */ + idosTncAccepted: boolean | null; + /** + * Whether the customer consented to reuse existing idOS credentials + * during this session. Applied when recording session-scoped disclaimers. + * Not persisted: a new UKYC session must collect reuse consent again. + * `null` when never set (treated as `false`). + */ + credentialReusabilityConsentGiven: boolean | null; + + /** Disclaimers fetched for the current country. */ + disclaimers: KycDisclaimer[]; + /** Error encountered while loading disclaimers, or `null`. */ + disclaimersError: string | null; + /** + * Session-scoped idOS / KYC-provider disclaimer catalog from + * `GET /sessions/{sessionId}/disclaimers`. `null` until a UKYC session + * exists and the catalog has been fetched. + */ + sessionDisclaimers: KycSessionDisclaimers | null; + + /** Resolved ISO 3166-1 alpha-3 country code. */ + geoCountry: string | null; + + /** Vendor session token (not persisted, not logged). */ + sessionToken: string | null; + /** Vendor access token (not persisted, not logged). */ + accessToken: string | null; + /** Vendor customer id, used for the SumSub hand-off. */ + moonpayCustomerId: string | null; + + /** + * The identity vendor driving the current flow. Captured at `initialize`. + * Defaults to `moonpay` when omitted so existing ramps/card callers keep + * the Check/Auth frame path. Non-MoonPay vendors skip those frames. + */ + activeVendor: KycVendor; + + /** + * The product the current flow is running for. Captured at `initialize` + * (or `acceptTermsAndStartSession`) and used to automatically run the + * KYC-required check once authentication completes. `null` outside a + * product-scoped flow (in which case the flow stops at `form` and the + * consumer drives the check manually). + */ + activeProduct: KycProduct | null; + + /** Cached "is KYC required" result per product (persisted). */ + kycRequiredByProduct: Partial>; + /** ISO-8601 timestamp of the last KYC-required check (persisted). */ + lastCheckedAt: string | null; + + /** + * User-keyed simplified KYC status from `GET /kyc/status` (persisted so the + * Money toast can render across cold starts). `null` until the first + * successful `refreshKycStatus`. + */ + userStatus: KycUserStatus | null; + /** Optional SumSub session id for the retryable error path. */ + userStatusSumsubSessionId: string | null; + /** Optional machine-readable error code for terminal / EDD UX. */ + userStatusErrorCode: string | null; + + /** SumSub document-verification sub-flow state. */ + sumsub: { + status: KycSumSubStatus; + result: Json | null; + sessionId: string | null; + applicantAccessToken: string | null; + /** + * The latest UKYC session status, populated while polling after the SDK + * completes. `null` until the first successful poll. + */ + sessionStatus: KycSessionStatus | null; + }; +}; + +const kycControllerMetadata = { + phase: { + includeInDebugSnapshot: true, + includeInStateLogs: true, + persist: false, + usedInUi: true, + }, + statusMessage: { + includeInDebugSnapshot: true, + includeInStateLogs: true, + persist: false, + usedInUi: true, + }, + error: { + includeInDebugSnapshot: true, + includeInStateLogs: true, + persist: false, + usedInUi: true, + }, + email: { + includeInDebugSnapshot: false, + includeInStateLogs: false, + persist: false, + usedInUi: false, + }, + termsAcceptedAt: { + includeInDebugSnapshot: true, + includeInStateLogs: true, + persist: true, + usedInUi: false, + }, + acceptedDisclaimerIds: { + includeInDebugSnapshot: true, + includeInStateLogs: true, + persist: true, + usedInUi: false, + }, + termsAcceptedVendor: { + includeInDebugSnapshot: true, + includeInStateLogs: true, + persist: true, + usedInUi: false, + }, + sumsubTncAccepted: { + includeInDebugSnapshot: true, + includeInStateLogs: true, + persist: true, + usedInUi: false, + }, + idosTncAccepted: { + includeInDebugSnapshot: true, + includeInStateLogs: true, + persist: true, + usedInUi: false, + }, + credentialReusabilityConsentGiven: { + includeInDebugSnapshot: true, + includeInStateLogs: true, + persist: false, + usedInUi: false, + }, + disclaimers: { + includeInDebugSnapshot: false, + includeInStateLogs: false, + persist: false, + usedInUi: true, + }, + disclaimersError: { + includeInDebugSnapshot: true, + includeInStateLogs: true, + persist: false, + usedInUi: true, + }, + sessionDisclaimers: { + includeInDebugSnapshot: false, + includeInStateLogs: false, + persist: false, + usedInUi: true, + }, + geoCountry: { + includeInDebugSnapshot: true, + includeInStateLogs: true, + persist: false, + usedInUi: true, + }, + sessionToken: { + includeInDebugSnapshot: false, + includeInStateLogs: false, + persist: false, + usedInUi: false, + }, + accessToken: { + includeInDebugSnapshot: false, + includeInStateLogs: false, + persist: false, + usedInUi: false, + }, + moonpayCustomerId: { + includeInDebugSnapshot: false, + includeInStateLogs: false, + persist: false, + usedInUi: false, + }, + activeVendor: { + includeInDebugSnapshot: true, + includeInStateLogs: true, + persist: false, + usedInUi: true, + }, + activeProduct: { + includeInDebugSnapshot: true, + includeInStateLogs: true, + persist: false, + usedInUi: true, + }, + kycRequiredByProduct: { + includeInDebugSnapshot: true, + includeInStateLogs: true, + persist: true, + usedInUi: true, + }, + lastCheckedAt: { + includeInDebugSnapshot: true, + includeInStateLogs: true, + persist: true, + usedInUi: false, + }, + userStatus: { + includeInDebugSnapshot: true, + includeInStateLogs: true, + persist: true, + usedInUi: true, + }, + userStatusSumsubSessionId: { + includeInDebugSnapshot: false, + includeInStateLogs: false, + persist: true, + usedInUi: true, + }, + userStatusErrorCode: { + includeInDebugSnapshot: true, + includeInStateLogs: true, + persist: true, + usedInUi: true, + }, + sumsub: { + includeInDebugSnapshot: false, + includeInStateLogs: false, + persist: false, + usedInUi: true, + }, +} satisfies StateMetadata; + +/** + * Constructs the default {@link KycController} state. + * + * @returns The default state. + */ +export function getDefaultKycControllerState(): KycControllerState { + return { + phase: 'idle', + statusMessage: '', + error: null, + email: null, + termsAcceptedAt: null, + acceptedDisclaimerIds: [], + termsAcceptedVendor: null, + sumsubTncAccepted: null, + idosTncAccepted: null, + credentialReusabilityConsentGiven: null, + disclaimers: [], + disclaimersError: null, + sessionDisclaimers: null, + geoCountry: null, + sessionToken: null, + accessToken: null, + moonpayCustomerId: null, + activeVendor: 'moonpay', + activeProduct: null, + kycRequiredByProduct: {}, + lastCheckedAt: null, + userStatus: null, + userStatusSumsubSessionId: null, + userStatusErrorCode: null, + sumsub: { + status: 'idle', + result: null, + sessionId: null, + applicantAccessToken: null, + sessionStatus: null, + }, + }; +} + +/** + * Whether an error indicates the applicant already finished KYC — the UKYC / + * relay `session_not_in_valid_state` signal — which the controller maps to the + * simplified `completed` user status. + * + * @param error - The caught error. + * @returns `true` when the error carries the `session_not_in_valid_state` + * marker. + */ +function isSessionAlreadyCompletedError(error: unknown): boolean { + return String(error).includes(SESSION_NOT_IN_VALID_STATE); +} + +/** + * Whether recording session disclaimers failed because those document + * versions were already consented for the session (`409 Conflict`). + * + * @param error - The caught error. + * @returns `true` when the error is an HTTP 409. + */ +function isConsentConflictError(error: unknown): boolean { + return ( + typeof error === 'object' && + error !== null && + typeof (error as { httpStatus?: unknown }).httpStatus === 'number' && + (error as { httpStatus: number }).httpStatus === 409 + ); +} + +/** + * Maps a session-disclaimer catalog into the `{ key, version }` records the + * record-consents API expects, or an empty list when the user declined that + * category. + * + * @param documents - Catalog documents for one consent category. + * @param accepted - Whether the user accepted that category. + * @returns Consent records, or `[]` when not accepted. + */ +function consentRecordsFromCatalog( + documents: KycConsentDocument[], + accepted: boolean, +): { key: string; version: string }[] { + if (!accepted) { + return []; + } + return documents + .filter((document) => !document.consented) + .map(({ key, version }) => ({ key, version })); +} + +/** + * Whether an accepted T&C2 category has no catalog documents. An empty list + * would otherwise skip the POST and count as success. + * + * @param documents - Catalog documents for one consent category. + * @param accepted - Whether the user accepted that category. + * @returns `true` when the user accepted and the catalog is empty. + */ +function isAcceptedCategoryEmpty( + documents: KycConsentDocument[], + accepted: boolean, +): boolean { + return accepted && documents.length === 0; +} + +/** + * Whether an accepted category is still missing consent after a 409 re-GET: + * empty catalog or any document still unconsented. + * + * @param documents - Latest catalog documents for one consent category. + * @param accepted - Whether the user accepted that category. + * @returns `true` when accepted documents are not fully consented. + */ +function acceptedCategoryStillMissing( + documents: KycConsentDocument[], + accepted: boolean, +): boolean { + return ( + accepted && + (documents.length === 0 || + documents.some((document) => !document.consented)) + ); +} + +/** + * Vendors other than MoonPay skip Check/Auth frames and use the empty-shell + * customer + consents path instead. + * + * @param vendor - The identity vendor for the current flow. + * @returns `true` when the vendor uses the consents session path. + */ +function usesConsentsFlow(vendor: KycVendor): boolean { + return vendor !== 'moonpay'; +} + +// === MESSENGER === + +const MESSENGER_EXPOSED_METHODS = [ + 'initialize', + 'loadDisclaimers', + 'acceptTermsAndStartSession', + 'createVendorCustomer', + 'clearSavedTerms', + 'handleFrameMessage', + 'buildCheckFrameUrl', + 'buildAuthFrameUrl', + 'buildResetFrameUrl', + 'checkKycRequired', + 'getKycStatus', + 'getCustomerIdentity', + 'refreshKycStatus', + 'startSumSub', + 'getSessionStatus', + 'reset', + 'clearState', +] as const; + +export type KycControllerGetStateAction = ControllerGetStateAction< + typeof controllerName, + KycControllerState +>; + +export type KycControllerActions = + | KycControllerGetStateAction + | KycControllerMethodActions; + +type AllowedActions = + | KycServiceMethodActions + | UserStorageControllerPerformGetStorageAction + | UserStorageControllerPerformSetStorageAction; + +export type KycControllerStateChangeEvent = ControllerStateChangeEvent< + typeof controllerName, + KycControllerState +>; + +/** + * Published when the user-keyed simplified KYC status changes (Money toast). + */ +export type KycControllerStatusChangedEvent = { + type: `${typeof controllerName}:statusChanged`; + payload: [ + { + status: KycUserStatus; + sumsubSessionId: string | null; + errorCode: string | null; + }, + ]; +}; + +export type KycControllerEvents = + | KycControllerStateChangeEvent + | KycControllerStatusChangedEvent; + +type AllowedEvents = never; + +export type KycControllerMessenger = Messenger< + typeof controllerName, + KycControllerActions | AllowedActions, + KycControllerEvents | AllowedEvents +>; + +/** + * Options for constructing a {@link KycController}. + */ +export type KycControllerOptions = { + messenger: KycControllerMessenger; + state?: Partial; + /** + * Platform adapter that presents the SumSub SDK. Injected by each client so + * the controller stays platform-agnostic. + */ + sumsubLauncher: KycSumSubLauncher; + /** + * How often, in milliseconds, to poll the UKYC session status after the + * SumSub SDK completes. Defaults to + * {@link DEFAULT_SESSION_STATUS_POLL_INTERVAL_MS}. + */ + sessionStatusPollIntervalMs?: number; + /** + * How often, in milliseconds, to refresh `GET /kyc/status` while the + * simplified user status is `pending`. Defaults to + * {@link DEFAULT_USER_STATUS_POLL_INTERVAL_MS}. + */ + userStatusPollIntervalMs?: number; +}; + +/** + * The shape of a message posted by a Check/Auth frame. + */ +type FrameMessage = { + meta?: { channelId?: string }; + kind?: string; + payload?: { + status?: + | 'active' + | 'connectionRequired' + | 'termsAcceptanceRequired' + | 'pending' + | 'unavailable' + | 'failed'; + credentials?: EncryptedCredentialsEnvelope | string; + customer?: { id?: string }; + }; +}; + +// === CONTROLLER DEFINITION === + +/** + * `KycController` orchestrates the vendor-backed KYC / identity-verification + * flow (MoonPay identity + SumSub documents) behind a vendor-neutral, per + * product surface used by ramps and card. It owns all state, HTTP + * orchestration (via `KycService`), crypto, and the frame message protocol; + * platform-specific presentation (WebView/iframe, SumSub SDK) is injected. + */ +export class KycController extends BaseController< + typeof controllerName, + KycControllerState, + KycControllerMessenger +> { + readonly #sumsubLauncher: KycSumSubLauncher; + + /** Ephemeral X25519 keypair for the frame key exchange (never persisted). */ + readonly #keypair: X25519KeyPair; + + /** Auth-frame client token, kept out of state. */ + #authClientToken: string | null = null; + + /** + * Monotonic flow generation. Incremented by {@link reset} and + * {@link clearState} so in-flight async work (e.g. the KYC-required check) + * can detect that it was superseded and avoid writing stale results onto a + * reset controller. + */ + #generation = 0; + + /** Interval, in milliseconds, between session-status polls. */ + readonly #sessionStatusPollIntervalMs: number; + + /** Handle for the scheduled next session-status poll, or `null`. */ + #pollTimer: ReturnType | null = null; + + /** + * Monotonic polling token. Bumped by {@link #stopPolling} (called on reset, a + * new sub-flow, and once a terminal status is reached) so an in-flight poll + * `tick` can detect it was superseded and neither write state nor schedule a + * follow-up. This closes the gap where clearing the timer alone would still + * let an already-awaiting request finish and reschedule. + */ + #pollToken = 0; + + /** Interval, in milliseconds, between user-keyed status polls. */ + readonly #userStatusPollIntervalMs: number; + + /** Handle for the scheduled next user-status poll, or `null`. */ + #userStatusPollTimer: ReturnType | null = null; + + /** + * Whether a user-status poll loop is currently active. Tracked separately + * from {@link #userStatusPollTimer} because a scheduled tick clears the timer + * handle before awaiting `fetchKycStatus`; relying on the handle alone would + * let a concurrent {@link refreshKycStatus} start a second loop on the same + * token during that in-flight window. + */ + #userStatusPolling = false; + + /** Monotonic token for the user-status poll loop (see `#pollToken`). */ + #userStatusPollToken = 0; + + /** + * Constructs a new {@link KycController}. + * + * @param options - The constructor options. + * @param options.messenger - The messenger suited for this controller. + * @param options.state - Partial initial state; merged over defaults. + * @param options.sumsubLauncher - The platform SumSub launcher adapter. + * @param options.sessionStatusPollIntervalMs - How often to poll the UKYC + * session status after the SumSub SDK completes. + * @param options.userStatusPollIntervalMs - How often to refresh the + * user-keyed KYC status while it is still `pending`. + */ + constructor({ + messenger, + state, + sumsubLauncher, + sessionStatusPollIntervalMs = DEFAULT_SESSION_STATUS_POLL_INTERVAL_MS, + userStatusPollIntervalMs = DEFAULT_USER_STATUS_POLL_INTERVAL_MS, + }: KycControllerOptions) { + super({ + messenger, + metadata: kycControllerMetadata, + name: controllerName, + state: { ...getDefaultKycControllerState(), ...state }, + }); + + this.#sumsubLauncher = sumsubLauncher; + this.#sessionStatusPollIntervalMs = sessionStatusPollIntervalMs; + this.#userStatusPollIntervalMs = userStatusPollIntervalMs; + this.#keypair = generateKeyPair(); + + this.messenger.registerMethodActionHandlers( + this, + MESSENGER_EXPOSED_METHODS, + ); + } + + /** + * Builds an adapter over `UserStorageController` that the platform-agnostic + * `getOrCreateLocalUserSecret` helper uses to persist/load the UKYC + * `local_user_secret`. + * + * @returns The Encrypted User Storage adapter. + */ + #localUserSecretStore(): UkycLocalUserSecretStore { + return { + get: async ( + path: string, + entropySourceId?: string, + ): Promise => + this.messenger.call( + 'UserStorageController:performGetStorage', + path as `${string}.${string}`, + entropySourceId, + ), + set: async ( + path: string, + value: string, + entropySourceId?: string, + ): Promise => + this.messenger.call( + 'UserStorageController:performSetStorage', + path as `${string}.${string}`, + value, + entropySourceId, + ), + }; + } + + /** + * Resolves persisted terms + geolocation, and auto-creates a session when + * terms are already accepted and an email is available. + * + * @param params - Optional parameters. + * @param params.email - The account email to associate with the session. + * @param params.product - The consuming feature the flow runs for. When + * provided, the controller automatically runs the KYC-required check once + * authentication completes (and chains into document verification when KYC + * is required). When omitted, the flow stops at `form` and the consumer must + * call `checkKycRequired` manually. + * @param params.vendor - Identity vendor for this flow. Non-MoonPay vendors + * skip Check/Auth frames and use the consents path. Defaults to `moonpay`. + */ + async initialize(params?: { + email?: string; + product?: KycProduct; + vendor?: KycVendor; + }): Promise { + // A repeat `initialize` while a session flow is already in progress must + // not tear it down: creating a new vendor session clears the tokens and + // forces `phase` back through `session`/`check`, breaking an in-flight + // Check/Auth frame flow. Leave the active flow untouched and let the + // consumer drive it (or call `reset` first to start over). + if (IN_PROGRESS_PHASES.includes(this.state.phase)) { + return; + } + + const vendor = params?.vendor ?? 'moonpay'; + + // `initialize` starts a fresh flow, so `activeProduct` is always reset to + // this call's product (or `null`). Otherwise a prior run's product could + // linger and cause `#continueAfterAuthentication` to auto-run the check / + // sub-flow when the caller intended the manual (product-less) flow. + this.#applyUpdate((state) => { + if (params?.email) { + state.email = params.email; + } + state.activeVendor = vendor; + // MoonPay Check/Auth artifacts must not survive a switch to another + // vendor: leftover `sessionToken` would keep `buildCheckFrameUrl` alive, + // leftover `accessToken` / `#authClientToken` would keep Auth / KYC + // calls bound to MoonPay, and leftover `moonpayCustomerId` would make + // `getCustomerIdentity` report a MoonPay id under the wrong vendor. + if (vendor !== 'moonpay') { + this.#authClientToken = null; + this.#clearMoonPaySession(state); + } + state.activeProduct = params?.product ?? null; + }); + + // Capture the flow generation so a `reset()` landing while the async + // geolocation / session steps below are in flight cannot write results + // onto an idle controller. + const generation = this.#generation; + + // Resolve country for display; non-blocking. + try { + const country = await this.messenger.call('KycService:getGeoCountry'); + this.#updateIfCurrent(generation, (state) => { + state.geoCountry = country; + }); + } catch { + // Ignore; disclaimers loading will surface a country error if needed. + } + + // A `reset()` / `clearState()` that landed while the geolocation request + // was in flight supersedes this flow. Stop here rather than driving the + // controller on into `terms` (or a new session): the steps below write + // unconditionally, and `loadDisclaimers` captures the post-reset + // generation, so its own guard would not catch this. + if (this.#generation !== generation) { + return; + } + + if (usesConsentsFlow(vendor) && this.state.email) { + try { + await this.messenger.call('KycService:createVendorCustomer', { + vendor, + email: this.state.email, + }); + } catch (error) { + if (this.#generation !== generation) { + return; + } + this.#fail(`Vendor customer creation failed: ${String(error)}`); + return; + } + } + + // Drop another vendor's persisted acceptance only after this flow has + // committed (customer creation succeeded, or there was none to wait for). + // Clearing earlier would permanently lose ramps/card terms if Iron + // customer creation failed or a reset landed while it was in flight. + if (this.#generation !== generation) { + return; + } + this.#dropTermsUnlessForVendor(vendor); + + const hasTerms = + Boolean(this.state.termsAcceptedAt) && + this.state.acceptedDisclaimerIds.length > 0; + + if (hasTerms && this.state.email) { + if (usesConsentsFlow(vendor)) { + // Consents-path vendors require T&C2 flags; if they weren't persisted + // (i.e. null from pre-migration state), require reacceptance. + const sumsubTncSigned = this.state.sumsubTncAccepted; + const idosTncSigned = this.state.idosTncAccepted; + if (sumsubTncSigned === null || idosTncSigned === null) { + this.#applyUpdate((state) => { + this.#clearAcceptedTerms(state); + state.phase = 'terms'; + }); + await this.loadDisclaimers(); + return; + } + await this.#startConsentsSession({ + sumsubTncSigned, + idosTncSigned, + credentialReusabilityConsentGiven: + this.state.credentialReusabilityConsentGiven ?? false, + }); + } else { + await this.#createSession(); + } + return; + } + + this.#applyUpdate((state) => { + state.phase = 'terms'; + }); + await this.loadDisclaimers(); + } + + /** + * Creates (or resumes) an empty-shell customer for the given identity + * vendor. Exposed so a consumer can ensure the customer exists before + * showing T&C screens independently of {@link initialize}. + * + * A call while a session flow is already in progress is a no-op — matching + * {@link initialize} — so a vendor switch cannot leave Check/Auth frames + * attached to the wrong vendor. Call {@link reset} first to start over. + * + * @param params - The parameters. + * @param params.vendor - Identity vendor for the customer. + * @param params.email - Email for the vendor customer. + */ + async createVendorCustomer(params: { + vendor: KycVendor; + email: string; + }): Promise { + if (IN_PROGRESS_PHASES.includes(this.state.phase)) { + return; + } + + this.#applyUpdate((state) => { + state.email = params.email; + // MoonPay Check/Auth artifacts must not survive a switch to another + // vendor — see `initialize`. Terms for another vendor are dropped only + // after this request succeeds. + state.activeVendor = params.vendor; + if (params.vendor !== 'moonpay') { + this.#authClientToken = null; + this.#clearMoonPaySession(state); + } + }); + const generation = this.#generation; + try { + await this.messenger.call('KycService:createVendorCustomer', { + vendor: params.vendor, + email: params.email, + }); + if (this.#generation !== generation) { + return; + } + this.#dropTermsUnlessForVendor(params.vendor); + } catch (error) { + if (this.#generation !== generation) { + return; + } + this.#fail(`Vendor customer creation failed: ${String(error)}`); + } + } + + /** + * Loads the disclaimers for the resolved (or provided) country. + * + * @param params - Optional parameters. + * @param params.country - ISO 3166-1 alpha-3 country code override. + */ + async loadDisclaimers(params?: { country?: string }): Promise { + // Capture the flow generation so a `reset()` landing while the geo / + // disclaimers requests are in flight cannot write results onto an idle + // controller. + const generation = this.#generation; + try { + const country = + params?.country ?? + this.state.geoCountry ?? + (await this.messenger.call('KycService:getGeoCountry')); + if (country !== this.state.geoCountry) { + this.#updateIfCurrent(generation, (state) => { + state.geoCountry = country; + }); + } + const disclaimers = await this.messenger.call( + 'KycService:fetchDisclaimers', + { + vendor: this.state.activeVendor, + country, + }, + ); + this.#updateIfCurrent(generation, (state) => { + state.disclaimers = disclaimers; + state.disclaimersError = null; + }); + } catch (error) { + this.#updateIfCurrent(generation, (state) => { + state.disclaimersError = `Failed to load disclaimers: ${String(error)}`; + }); + } + } + + /** + * Captures terms acceptance for the currently loaded disclaimers and creates + * a session. + * + * @param params - The parameters. + * @param params.email - The account email to associate with the session. + * @param params.product - The consuming feature the flow runs for. See + * {@link initialize} for how the product drives the automatic post + * authentication continuation. + * @param params.sumsubTncSigned - Whether Sumsub T&C were accepted (T&C2). + * Required for every vendor so callers explicitly declare acceptance. + * @param params.idosTncSigned - Whether idOS T&C were accepted (T&C2). + * Required for every vendor so callers explicitly declare acceptance. + * @param params.credentialReusabilityConsentGiven - Whether the customer + * consented to reuse existing idOS credentials. Used when recording + * session-scoped disclaimers on the consents path. Defaults to `false`. + */ + async acceptTermsAndStartSession(params: { + email?: string; + product?: KycProduct; + sumsubTncSigned: boolean; + idosTncSigned: boolean; + credentialReusabilityConsentGiven?: boolean; + }): Promise { + const sumsubTncSigned = params?.sumsubTncSigned; + const idosTncSigned = params?.idosTncSigned; + if ( + typeof sumsubTncSigned !== 'boolean' || + typeof idosTncSigned !== 'boolean' + ) { + this.#fail('Missing T&C2 acceptance flags.'); + return; + } + const credentialReusabilityConsentGiven = + params.credentialReusabilityConsentGiven ?? false; + + const termsAcceptedAt = new Date().toISOString(); + const disclaimerIds = this.state.disclaimers.map( + (disclaimer) => disclaimer.id, + ); + this.#applyUpdate((state) => { + if (params.email) { + state.email = params.email; + } + if (params.product) { + state.activeProduct = params.product; + } + state.termsAcceptedAt = termsAcceptedAt; + state.acceptedDisclaimerIds = disclaimerIds; + state.termsAcceptedVendor = state.activeVendor; + state.sumsubTncAccepted = sumsubTncSigned; + state.idosTncAccepted = idosTncSigned; + state.credentialReusabilityConsentGiven = + credentialReusabilityConsentGiven; + }); + if (usesConsentsFlow(this.state.activeVendor)) { + await this.#startConsentsSession({ + sumsubTncSigned, + idosTncSigned, + credentialReusabilityConsentGiven, + }); + return; + } + await this.#createSession(); + } + + /** + * Consents-path vendors: record vendor T&Cs, create a UKYC session, + * record session-scoped idOS / KYC-provider disclaimers, then launch + * SumSub — skipping MoonPay Check/Auth frames. + * + * @param consents - T&C2 flags mapped onto the session disclaimer catalog. + * @param consents.sumsubTncSigned - Whether Sumsub T&C were accepted. + * @param consents.idosTncSigned - Whether idOS T&C were accepted. + * @param consents.credentialReusabilityConsentGiven - Whether credential + * reuse was accepted. + */ + async #startConsentsSession(consents: { + sumsubTncSigned: boolean; + idosTncSigned: boolean; + credentialReusabilityConsentGiven: boolean; + }): Promise { + const { email, acceptedDisclaimerIds } = this.state; + if (!email) { + this.#fail('Missing email for consents session.'); + return; + } + if (acceptedDisclaimerIds.length === 0) { + this.#fail('Missing disclaimer acceptance.'); + return; + } + + const generation = this.#generation; + this.#applyUpdate((state) => { + state.error = null; + state.phase = 'session'; + state.statusMessage = 'Submitting consents...'; + state.sumsub.status = 'creatingSession'; + state.sumsub.result = null; + state.sumsub.sessionStatus = null; + // Consents-path vendors have no MoonPay session/access tokens. + this.#clearMoonPaySession(state); + }); + + try { + await this.messenger.call('KycService:submitVendorDisclaimers', { + vendor: this.state.activeVendor, + disclaimerIds: acceptedDisclaimerIds, + }); + if (this.#generation !== generation) { + return; + } + + this.#updateIfCurrent(generation, (state) => { + state.statusMessage = 'Creating session...'; + }); + + const created = await this.#createUkycSession(generation); + if (!created || this.#generation !== generation) { + return; + } + + await this.#recordSessionDisclaimers( + created.sessionId, + consents, + generation, + ); + if (this.#generation !== generation) { + return; + } + + if (created.vendorProcessing) { + try { + await this.refreshKycStatus(); + } catch (statusError) { + console.error('KYC status refresh failed:', statusError); + } + this.#updateIfCurrent(generation, (state) => { + state.phase = 'done'; + state.statusMessage = VENDOR_PROCESSING_MESSAGE; + }); + return; + } + this.#applyUpdate((state) => { + state.phase = 'submit'; + state.statusMessage = 'Starting document verification...'; + }); + const sumsubResult = await this.startSumSub(); + if (this.#generation !== generation) { + return; + } + // `startSumSub` records `sumsub.status = 'failed'` for thrown steps, + // an SDK close without Completed, *and* a terminal UKYC rejection + // after the SDK reported Completed. Only rewind when there is no + // session-status decision yet (abandonment / thrown step). A + // Completed-then-rejected poll writes `sessionStatus` and is a + // finished flow: refresh user status and land on `done`. + if ( + this.state.sumsub.status === 'failed' && + this.state.sumsub.sessionStatus === null + ) { + const sumsubError = sumsubResult?.error; + throw new Error( + typeof sumsubError === 'string' + ? sumsubError + : 'SumSub verification did not complete.', + ); + } + // After SumSub, refresh user-keyed status for the Money toast and start + // polling while still pending. Soft-fail: toast refresh must not rewind + // the consent / SumSub outcome. + try { + await this.refreshKycStatus(); + } catch (statusError) { + console.error('KYC status refresh failed:', statusError); + } + this.#updateIfCurrent(generation, (state) => { + if (state.phase !== 'error' && state.phase !== 'done') { + state.phase = 'done'; + state.statusMessage = 'KYC submitted.'; + } + }); + } catch (error) { + if (isSessionAlreadyCompletedError(error)) { + if (this.#generation !== generation) { + return; + } + this.#applyUserStatus({ + status: 'completed', + sumsubSessionId: null, + errorCode: null, + }); + this.#updateIfCurrent(generation, (state) => { + state.sumsub.status = 'complete'; + state.sumsub.result = { alreadyCompleted: true }; + state.statusMessage = 'KYC already completed.'; + state.phase = 'done'; + state.error = null; + }); + return; + } + console.error('Consents session failed:', error); + if (this.#generation !== generation) { + return; + } + this.#applyUpdate((state) => { + this.#clearAcceptedTerms(state); + state.activeProduct = null; + state.sessionDisclaimers = null; + // Session create ran before recording disclaimers. Drop the leftover + // UKYC session so a later `startSumSub` cannot skip consent recording. + state.sumsub = { ...getDefaultKycControllerState().sumsub }; + state.error = `Consents session failed: ${String(error)}`; + state.statusMessage = + 'Consent / verification failed — accept the terms to try again.'; + state.phase = 'terms'; + }); + await this.loadDisclaimers(); + } + } + + /** + * Fetches the session-scoped disclaimer catalog and records consents + * derived from the T&C2 flags. Already-consented catalog rows are omitted + * from the POST. A 409 is re-checked with a GET: continue only when every + * accepted document is now consented, otherwise fail closed. + * + * @param sessionId - The UKYC session id. + * @param consents - T&C2 flags mapped onto catalog documents. + * @param consents.sumsubTncSigned - Whether Sumsub T&C were accepted. + * @param consents.idosTncSigned - Whether idOS T&C were accepted. + * @param consents.credentialReusabilityConsentGiven - Whether credential + * reuse was accepted. + * @param generation - Flow generation captured by the caller. + */ + async #recordSessionDisclaimers( + sessionId: string, + consents: { + sumsubTncSigned: boolean; + idosTncSigned: boolean; + credentialReusabilityConsentGiven: boolean; + }, + generation: number, + ): Promise { + const catalog = await this.messenger.call( + 'KycService:fetchSessionDisclaimers', + { sessionId }, + ); + if (this.#generation !== generation) { + return; + } + this.#applyUpdate((state) => { + state.sessionDisclaimers = catalog; + state.statusMessage = 'Submitting consents...'; + }); + + if ( + isAcceptedCategoryEmpty(catalog.idOS, consents.idosTncSigned) || + isAcceptedCategoryEmpty(catalog.kycProvider, consents.sumsubTncSigned) + ) { + throw new Error( + 'Session disclaimer catalog is missing documents for an accepted category.', + ); + } + + const idOS = consentRecordsFromCatalog( + catalog.idOS, + consents.idosTncSigned, + ); + const kycProvider = consentRecordsFromCatalog( + catalog.kycProvider, + consents.sumsubTncSigned, + ); + const reuseUnchanged = + catalog.credentialReusabilityConsentGiven === + consents.credentialReusabilityConsentGiven; + if (idOS.length === 0 && kycProvider.length === 0 && reuseUnchanged) { + return; + } + + try { + const recorded = await this.messenger.call( + 'KycService:submitSessionDisclaimers', + { + sessionId, + idOS, + kycProvider, + credentialReusabilityConsentGiven: + consents.credentialReusabilityConsentGiven, + }, + ); + this.#updateIfCurrent(generation, (state) => { + state.sessionDisclaimers = recorded; + }); + } catch (error) { + if (!isConsentConflictError(error)) { + throw error; + } + // 409 means some document version was already recorded. Re-fetch and + // continue only when every document the user accepted is now consented; + // otherwise fail closed so a version bump cannot skip new docs. + const latest = await this.messenger.call( + 'KycService:fetchSessionDisclaimers', + { sessionId }, + ); + if (this.#generation !== generation) { + return; + } + this.#applyUpdate((state) => { + state.sessionDisclaimers = latest; + }); + const stillMissingIdos = acceptedCategoryStillMissing( + latest.idOS, + consents.idosTncSigned, + ); + const stillMissingProvider = acceptedCategoryStillMissing( + latest.kycProvider, + consents.sumsubTncSigned, + ); + const stillMissingReuse = + consents.credentialReusabilityConsentGiven && + !latest.credentialReusabilityConsentGiven; + if (stillMissingIdos || stillMissingProvider || stillMissingReuse) { + throw error; + } + } + } + + /** + * Creates a vendor session from the currently stored terms + email. + */ + async #createSession(): Promise { + const { email, termsAcceptedAt, acceptedDisclaimerIds } = this.state; + if (!email) { + this.#fail('Missing email for session creation.'); + return; + } + if (!termsAcceptedAt || acceptedDisclaimerIds.length === 0) { + this.#fail('Missing terms acceptance for session creation.'); + return; + } + + // A new session invalidates any authentication carried over from a prior + // session. Clear the stale session token, access token, and auth-frame + // client token so `buildCheckFrameUrl` cannot return a URL bound to an old + // (or, on failure, invalid) session token, `buildAuthFrameUrl` cannot + // return a URL tied to an old client token, and `checkKycRequired` cannot + // run with an access token from an earlier authentication. The Check/Auth + // frames re-populate these for the new session. Because `sessionToken` is + // cleared here and only re-set on success, a failed creation leaves it + // `null` rather than resurrecting the previous session. + // Capture the flow generation so a `reset()` landing while the create + // request is in flight cannot resurrect a session (success) or overwrite + // the now-idle controller (failure). The synchronous update below runs + // before any `await`, so it needs no guard. + const generation = this.#generation; + this.#authClientToken = null; + this.#applyUpdate((state) => { + state.error = null; + state.phase = 'session'; + state.statusMessage = 'Creating session...'; + state.sessionToken = null; + state.accessToken = null; + }); + + try { + const { sessionToken } = await this.messenger.call( + 'KycService:createSession', + { email, termsAcceptedAt, disclaimerIds: acceptedDisclaimerIds }, + ); + this.#updateIfCurrent(generation, (state) => { + state.sessionToken = sessionToken; + state.phase = 'check'; + state.statusMessage = 'Authenticating via Check frame...'; + }); + } catch (error) { + console.error('Session creation failed:', error); + // A reset() superseded this flow while the request was in flight; leave + // the idle controller alone rather than forcing it back to `terms`. + if (this.#generation !== generation) { + return; + } + // Invalidate the stored acceptance so the customer can retry. Also clear + // `activeProduct` so a later `acceptTermsAndStartSession` that omits a + // product cannot auto-run the KYC check / SumSub chain for this failed + // flow's product — matching how `initialize` starts from a clean product. + this.#applyUpdate((state) => { + this.#clearAcceptedTerms(state); + state.activeProduct = null; + state.error = `Session creation failed: ${String(error)}`; + state.statusMessage = + 'Session creation failed — accept the terms to try again.'; + state.phase = 'terms'; + }); + await this.loadDisclaimers(); + } + } + + /** + * Clears the persisted terms acceptance. + */ + clearSavedTerms(): void { + this.#applyUpdate((state) => { + this.#clearAcceptedTerms(state); + }); + } + + /** + * Clears the stored terms acceptance on the given draft state. Shared by the + * paths that must invalidate acceptance — explicit clear, vendor terms + * update, and session-creation failure — so they stay in sync. This is a + * targeted invalidation and, unlike {@link reset}, deliberately leaves the + * rest of the flow (geolocation, disclaimers, phase) untouched. + * + * @param state - The state to mutate. + */ + #clearAcceptedTerms(state: KycControllerState): void { + state.termsAcceptedAt = null; + state.acceptedDisclaimerIds = []; + state.termsAcceptedVendor = null; + state.sumsubTncAccepted = null; + state.idosTncAccepted = null; + state.credentialReusabilityConsentGiven = null; + } + + /** + * Drops MoonPay Check/Auth artifacts from the draft. Used when switching + * away from MoonPay (and again when the consents path starts) so leftover + * tokens cannot keep `buildCheckFrameUrl` / `buildAuthFrameUrl` alive for + * a consents-path vendor. + * + * @param state - The state to mutate. + */ + #clearMoonPaySession(state: KycControllerState): void { + state.moonpayCustomerId = null; + state.sessionToken = null; + state.accessToken = null; + } + + /** + * Drops persisted terms acceptance when it does not belong to `vendor`. + * Callers must invoke this only after the vendor switch has committed + * (e.g. `createVendorCustomer` succeeded) so a failed or reset switch + * cannot erase another vendor's stored acceptance. + * + * @param vendor - The vendor that now owns the flow. + */ + #dropTermsUnlessForVendor(vendor: KycVendor): void { + if (this.#hasTermsForVendor(vendor)) { + return; + } + this.#applyUpdate((state) => { + this.#clearAcceptedTerms(state); + }); + } + + /** + * Determines whether the stored terms acceptance belongs to the given + * vendor. Acceptance persisted before `termsAcceptedVendor` existed + * (indicated by `null`) is invalidated to force reacceptance, ensuring users + * re-review vendor terms after the multi-vendor upgrade. + * + * @param vendor - The vendor about to drive the flow. + * @returns `true` when the stored acceptance can be reused for `vendor`. + */ + #hasTermsForVendor(vendor: KycVendor): boolean { + if (this.state.termsAcceptedVendor === null) { + return false; + } + return this.state.termsAcceptedVendor === vendor; + } + + /** + * Handles a message posted by a Check/Auth frame and advances the flow. + * + * The transport-agnostic caller (WebView on mobile, iframe on web) forwards + * the raw message and injects the returned `reply` back into the frame. + * + * @param params - The parameters. + * @param params.message - The raw message posted by the frame. + * @returns An object whose optional `reply` should be posted back. + */ + async handleFrameMessage(params: { + message: unknown; + }): Promise<{ reply?: unknown }> { + const payload = params.message as FrameMessage | undefined; + + if (!payload) { + return {}; + } + + if (payload.kind === 'handshake') { + const channelId = payload.meta?.channelId; + return { reply: { version: 2, meta: { channelId }, kind: 'ack' } }; + } + + if (payload.kind !== 'complete') { + return {}; + } + + const channelId = payload.meta?.channelId; + + // Only honor a Check/Auth `complete` for the MoonPay frame the flow is + // currently waiting on. This drops stale or duplicate messages — e.g. a + // late post after `reset()` (phase `idle`), after the flow already + // advanced past this frame, or after a vendor switch — so they cannot + // resurrect tokens, rewind `phase`, or recapture `moonpayCustomerId` on a + // controller that has moved on. Frame messages are external input and, + // unlike the async steps, are not covered by the `#generation` guard. + let expectedPhase: KycPhase | null = null; + if (channelId === CHANNEL_CHECK) { + expectedPhase = 'check'; + } else if (channelId === CHANNEL_AUTH) { + expectedPhase = 'auth'; + } + if ( + !expectedPhase || + this.state.phase !== expectedPhase || + this.state.activeVendor !== 'moonpay' + ) { + return {}; + } + + const status = payload.payload?.status; + const credsEnvelope = payload.payload?.credentials; + + const customerId = payload.payload?.customer?.id ?? null; + if (customerId) { + this.#applyUpdate((state) => { + state.moonpayCustomerId = customerId; + }); + } + + if (!status) { + return {}; + } + + let accessToken: string | undefined; + let clientToken: string | undefined; + if (credsEnvelope) { + try { + const { credentials } = decryptCredentials( + credsEnvelope, + this.#keypair.privateKey, + ); + accessToken = credentials.accessToken; + clientToken = credentials.clientToken; + } catch (error) { + this.#fail(`Failed to decrypt frame credentials: ${String(error)}`); + return {}; + } + } + + if (channelId === CHANNEL_CHECK) { + await this.#handleCheckOutcome(status, accessToken, clientToken); + return {}; + } + + // channelId === CHANNEL_AUTH, guaranteed by the expectedPhase guard above. + await this.#handleAuthOutcome(status, accessToken); + return {}; + } + + /** + * Applies a Check-frame outcome. + * + * @param status - The frame status. + * @param accessToken - The decrypted access token, if any. + * @param clientToken - The decrypted client token, if any. + */ + async #handleCheckOutcome( + status: NonNullable['status'], + accessToken?: string, + clientToken?: string, + ): Promise { + if (status === 'active' && accessToken) { + this.#applyUpdate((state) => { + state.accessToken = accessToken; + state.phase = 'form'; + state.statusMessage = 'Already authenticated. Review to submit.'; + }); + await this.#continueAfterAuthentication(); + return; + } + if (status === 'connectionRequired' && clientToken) { + this.#authClientToken = clientToken; + this.#applyUpdate((state) => { + state.phase = 'auth'; + state.statusMessage = 'Verify your email via OTP in the Auth frame.'; + }); + return; + } + if (status === 'termsAcceptanceRequired') { + this.#requireTermsReacceptance(); + return; + } + this.#fail(`Check frame returned status: ${status}`); + } + + /** + * Applies an Auth-frame outcome. + * + * @param status - The frame status. + * @param accessToken - The decrypted access token, if any. + */ + async #handleAuthOutcome( + status: NonNullable['status'], + accessToken?: string, + ): Promise { + if (status === 'active' && accessToken) { + this.#applyUpdate((state) => { + state.accessToken = accessToken; + state.phase = 'form'; + state.statusMessage = 'Authenticated. Review to submit.'; + }); + await this.#continueAfterAuthentication(); + return; + } + if (status === 'termsAcceptanceRequired') { + this.#requireTermsReacceptance(); + return; + } + this.#fail(`Auth frame returned status: ${status}`); + } + + /** + * Continues the flow once authentication has completed (phase `form`). + * + * When the flow is scoped to a product (see {@link initialize}), the + * KYC-required check runs automatically, and — when KYC is required — the + * document-verification sub-flow is launched. When no product is set, this is + * a no-op and the flow stays at `form` for the consumer to drive manually. + * + * Errors are already recorded on state by `checkKycRequired` (`error` + * phase) and `startSumSub` (`sumsub.status = 'failed'`); this method swallows + * them so it can be awaited safely from the frame-message handler. + */ + async #continueAfterAuthentication(): Promise { + const product = this.state.activeProduct; + if (!product) { + return; + } + + // Re-entry protection lives at the frame boundary: `handleFrameMessage` + // only honors a Check/Auth `complete` while `phase` matches and + // `activeVendor` is MoonPay, and both outcome handlers move `phase` to + // `form` before awaiting this method. A duplicate, late, or cross-vendor + // `complete` therefore lands after the phase moved on (or on the wrong + // vendor) and is dropped before it can start a second continuation. Any + // writes here are additionally guarded by `#generation` (see + // `checkKycRequired` / `startSumSub`) so a `reset()` mid-continuation + // cannot corrupt state. + const kycRequired = await this.checkKycRequired({ product }); + if (!kycRequired) { + return; + } + + try { + await this.startSumSub(); + } catch { + // `startSumSub` already records `sumsub.status = 'failed'`; swallow the + // rethrown error (e.g. SDK unavailable) so the awaited continuation + // resolves cleanly rather than surfacing as an unhandled rejection. + } + } + + /** + * Invalidates stored terms and returns to the terms phase. + */ + #requireTermsReacceptance(): void { + this.#applyUpdate((state) => { + this.#clearAcceptedTerms(state); + state.phase = 'terms'; + state.statusMessage = + 'The vendor updated its Terms of Use — please re-accept.'; + }); + } + + /** + * Builds the Check-frame URL, or `null` when no session exists yet. + * + * @returns The Check-frame URL or `null`. + */ + buildCheckFrameUrl(): string | null { + if (this.state.activeVendor !== 'moonpay' || !this.state.sessionToken) { + return null; + } + const url = new URL(`${FRAMES_BASE_URL}/check-connection`); + url.searchParams.set('sessionToken', this.state.sessionToken); + url.searchParams.set('publicKey', this.#keypair.publicKeyHex); + url.searchParams.set('channelId', CHANNEL_CHECK); + url.searchParams.set('skipKyc', 'true'); + return url.toString(); + } + + /** + * Builds the Auth-frame URL, or `null` when no client token is available. + * + * @returns The Auth-frame URL or `null`. + */ + buildAuthFrameUrl(): string | null { + if (this.state.activeVendor !== 'moonpay' || !this.#authClientToken) { + return null; + } + const url = new URL(`${FRAMES_BASE_URL}/auth`); + url.searchParams.set('clientToken', this.#authClientToken); + url.searchParams.set('publicKey', this.#keypair.publicKeyHex); + url.searchParams.set('channelId', CHANNEL_AUTH); + return url.toString(); + } + + /** + * Builds the Reset-frame URL. + * + * @returns The Reset-frame URL. + */ + buildResetFrameUrl(): string { + const url = new URL(`${FRAMES_BASE_URL}/reset`); + url.searchParams.set('channelId', CHANNEL_RESET); + return url.toString(); + } + + /** + * Checks whether KYC is required for a product and caches the result. + * + * @param params - The parameters. + * @param params.product - The consuming feature. + * @param params.country - Optional alpha-3 country override. + * @returns Whether KYC is required. + */ + async checkKycRequired(params: { + product: KycProduct; + country?: string; + }): Promise { + const { accessToken } = this.state; + if (!accessToken) { + this.#fail('Missing accessToken — repeat the authentication step.'); + return false; + } + const country = params.country ?? this.state.geoCountry; + if (!country) { + this.#fail('Missing country for KYC-required check.'); + return false; + } + + // Capture the flow generation so we can detect a `reset()` that happens + // while the HTTP call is in flight and avoid writing stale results. + const generation = this.#generation; + + this.#applyUpdate((state) => { + state.phase = 'submit'; + state.statusMessage = 'Checking KYC status...'; + }); + + try { + const { kycRequired } = await this.messenger.call( + 'KycService:checkKycRequired', + { accessToken, country, capabilities: [{ product: params.product }] }, + ); + // The flow was reset while the check was in flight; discard the result + // rather than resurrecting a done/cached state on an idle controller. + const applied = this.#updateIfCurrent(generation, (state) => { + state.kycRequiredByProduct[params.product] = kycRequired; + state.lastCheckedAt = new Date().toISOString(); + state.phase = 'done'; + state.statusMessage = 'KYC check complete.'; + }); + if (!applied) { + return false; + } + return kycRequired; + } catch (error) { + if (this.#generation !== generation) { + return false; + } + this.#fail(`KYC check failed: ${String(error)}`); + return false; + } + } + + /** + * Reads the cached "is KYC required" result for a product. + * + * @param params - The parameters. + * @param params.product - The consuming feature. + * @returns The cached value, or `undefined` if not yet checked. + */ + getKycStatus(params: { product: KycProduct }): boolean | undefined { + return this.state.kycRequiredByProduct[params.product]; + } + + /** + * Returns the vendor-scoped identity for the currently authenticated + * customer, or `null` when the flow has not yet captured a vendor customer + * id (before authentication or after {@link reset}), or when a MoonPay id + * is present under a different `activeVendor`. + * + * Exposed so consumers (e.g. ramps autoramp creation) can attach the vendor + * customer id to downstream calls without reading the full KYC state, which + * also holds session/access tokens. The id is session-scoped and never + * persisted. + * + * @returns The current {@link KycCustomerIdentity}, or `null`. + */ + getCustomerIdentity(): KycCustomerIdentity | null { + const { moonpayCustomerId, activeVendor } = this.state; + // `moonpayCustomerId` is issued only by MoonPay Check/Auth frames. Never + // pair it with another vendor, even if a switch left the fields out of + // sync, so consumers cannot attach a MoonPay id to an Iron (or other) + // downstream call. + if (!moonpayCustomerId || activeVendor !== 'moonpay') { + return null; + } + return { vendor: 'moonpay', id: moonpayCustomerId }; + } + + /** + * Builds the vendor-specific fields spread into a + * `KycService:createUkycSession` call, derived from the active vendor and the + * currently captured auth state. + * + * MoonPay sessions must carry the access token and customer id in + * `vendorMetadata`; other vendors carry no vendor metadata. + * + * @returns The vendor-specific subset of the `createUkycSession` params. + */ + #buildUkycSessionVendorFields(): Pick< + CreateUkycSessionParams, + 'vendor' | 'vendorMetadata' + > { + if (this.state.activeVendor === 'moonpay') { + return { + vendor: 'moonpay', + vendorMetadata: { + moonPayAccessToken: this.state.accessToken, + moonPayUserId: this.state.moonpayCustomerId, + }, + }; + } + + return { vendor: this.state.activeVendor }; + } + + /** + * Creates a UKYC session, wraps the `data_encryption_key` and + * `ukyc_capability_token` against the returned encryption schemas, and + * submits both via authorizations. Stores `sumsub.sessionId`. Returns `null` + * when a `reset()` superseded the flow. + * + * @param generation - Flow generation captured by the caller. + * @returns The created session, or `null` if superseded. + */ + async #createUkycSession(generation: number): Promise<{ + sessionId: string; + kycStatus?: string; + finalStatus?: string; + vendorProcessing: boolean; + } | null> { + const jwtToken = MOCK_JWT_TOKEN; + + // Establish a per-session X25519 keypair used to seal both secrets. The + // private half stays on the device; the public half is registered on the + // session so the server can open later authorizations. Each encryption + // schema from session creation supplies the matching server public key. + const sessionClientPrivateKey = x25519.utils.randomSecretKey(); + const sessionClientPublicKey = toBase64Url( + x25519.getPublicKey(sessionClientPrivateKey), + ); + // Residence is the ISO 3166-1 alpha-3 country already resolved for + // disclaimers / KYC-required; fetch it if this sub-flow started without + // that earlier step. + const residenceCountry = + this.state.geoCountry ?? + (await this.messenger.call('KycService:getGeoCountry')); + if (this.#generation !== generation) { + return null; + } + if (residenceCountry !== this.state.geoCountry) { + this.#updateIfCurrent(generation, (state) => { + state.geoCountry = residenceCountry; + }); + } + + const { + sessionId, + encryptionDataKey, + ukycCapabilityToken: capabilityTokenSchema, + } = await this.messenger.call('KycService:createUkycSession', { + jwtToken, + sessionClientPublicKey, + residenceCountry, + ...this.#buildUkycSessionVendorFields(), + }); + if (this.#generation !== generation) { + return null; + } + + // Verify each schema's jwtChain against the matching issuer JWKS, then + // confirm the returned server public key matches the value attested inside + // the verified JWT payload before trusting it for wrapping. + // `encryptionDataKey` is attested by the idOS enclave; `ukycCapabilityToken` by the + // idOS relay. + const [{ keys: idosEnclaveKeys }, { keys: idosRelayKeys }] = + await Promise.all([ + this.messenger.call('KycService:fetchIdosEnclaveJwks'), + this.messenger.call('KycService:fetchIdosRelayJwks'), + ]); + this.#assertAttestedServerPublicKey(idosEnclaveKeys, encryptionDataKey); + this.#assertAttestedServerPublicKey(idosRelayKeys, capabilityTokenSchema); + + // Derive the data_encryption_key from the local_user_secret, mint a + // read-only capability token, and wrap both for the session server. Only + // the wrapped (encrypted) material ever leaves the device. + const localUserSecret = await getOrCreateLocalUserSecret( + this.#localUserSecretStore(), + ); + const clientMaterial = deriveClientMaterial(localUserSecret); + const wrappedEncryptionDataKey = wrapEncryptionKey( + sessionClientPrivateKey, + encryptionDataKey.serverPublicKey.x, + clientMaterial.dataEncryptionKey, + ); + + // Only the client holds the signing key derived from `local_user_secret`, + // so only the client can mint the token; scoping it to `read` means it + // authorizes later storage reads without granting write or delete access. + const ukycCapabilityToken = signStorageAccessToken({ + material: clientMaterial, + operations: ['read'], + expiresAt: new Date(Date.now() + UKYC_CAPABILITY_TOKEN_TTL_MS), + }); + const wrappedUkycCapabilityToken = wrapEncryptionKey( + sessionClientPrivateKey, + capabilityTokenSchema.serverPublicKey.x, + stringToBytes(encodeStorageAccessTokenForHeader(ukycCapabilityToken)), + ); + if (this.#generation !== generation) { + return null; + } + + const { kycStatus, finalStatus } = await this.messenger.call( + 'KycService:setAuthorizations', + { + sessionId, + wrappedEncryptionDataKey, + wrappedUkycCapabilityToken, + }, + ); + + const vendorProcessing = + kycStatus === KYC_STATUSES.approved && + finalStatus === KYC_STATUSES.pending; + + const stillCurrent = this.#updateIfCurrent(generation, (state) => { + state.sumsub.sessionId = sessionId; + if (vendorProcessing) { + state.sumsub.status = 'vendorProcessing'; + state.statusMessage = VENDOR_PROCESSING_MESSAGE; + } + }); + if (!stillCurrent) { + return null; + } + return { sessionId, kycStatus, finalStatus, vendorProcessing }; + } + + /** + * Runs the SumSub document-verification sub-flow end to end: + * + * 1. creates a UKYC session, receiving per-secret encryption schemas; + * 2. verifies the `encryptionDataKey` schema's `jwtChain` against the + * idOS enclave JWKS and the `ukycCapabilityToken` schema's `jwtChain` against + * the idOS relay JWKS, then confirms each attested session server public + * key; + * 3. derives the `data_encryption_key` from the wallet's UKYC + * `local_user_secret` and wraps it for the session server; + * 4. mints a client-signed, read-only `ukyc_capability_token`, wraps it the + * same way as the encryption key, and submits both via authorizations; + * 5. fetches the SumSub applicant access token; and + * 6. presents the SDK via the injected launcher. + * + * If a UKYC session already exists (the consents path creates it before + * recording session disclaimers), steps 1–4 are skipped. + * + * If authorizations report the applicant is already approved on the relay + * while the vendor is still finalizing (`kycStatus: approved`, + * `finalStatus: pending`), the sub-flow stops at step 4 with a + * `vendorProcessing` status and a message rather than launching the SDK. + * + * @param params - Optional parameters. + * @param params.locale - BCP-47 locale for the SDK UI. + * @param params.debug - Enables SDK debug logging. + * @returns The SDK result. + */ + async startSumSub(params?: { + locale?: string; + debug?: boolean; + }): Promise> { + // A new sub-flow supersedes any polling still running from a prior run. + this.#stopPolling(); + + if (!this.#sumsubLauncher.isAvailable()) { + const error = 'SumSub SDK is not available in this runtime.'; + this.#applyUpdate((state) => { + state.sumsub.status = 'failed'; + state.sumsub.result = { error }; + }); + throw new Error(error); + } + + // Capture the flow generation so each async step can detect a `reset()` + // that lands mid-flight and avoid writing stale sub-flow state (or, worse, + // presenting the SDK) on a controller that is now idle. + const generation = this.#generation; + + try { + if (!this.state.sumsub.sessionId) { + this.#applyUpdate((state) => { + state.sumsub.status = 'creatingSession'; + state.sumsub.result = null; + state.sumsub.sessionStatus = null; + }); + + const created = await this.#createUkycSession(generation); + if (!created) { + return {}; + } + + // A user who already finished the journey can return to a session the + // relay has already approved (`kycStatus`) while the vendor is still + // finalizing its own decision (`finalStatus`). There is nothing left to + // verify, so stop here and surface a message rather than launching the + // SDK again. + if (created.vendorProcessing) { + return { + kycStatus: created.kycStatus, + finalStatus: created.finalStatus, + }; + } + } + + // Empty string is a valid "no id to poll" session id used by tests and + // must not be coalesced away as missing. + // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing + const sessionId = this.state.sumsub.sessionId || ''; + + this.#updateIfCurrent(generation, (state) => { + state.sumsub.status = 'fetchingToken'; + state.sumsub.sessionId = sessionId; + }); + + const { applicantAccessToken } = await this.messenger.call( + 'KycService:createJourney', + sessionId, + ); + + // A reset() may have landed while the session/token was being prepared. + // Gate the `launching` write and the decision to open the SDK behind a + // single generation check: `#updateIfCurrent` only writes when still + // current and reports whether it did. Since there is no `await` between + // this check and `launch` below, a successful result guarantees the SDK + // is never presented on a flow that a concurrent reset() returned to idle. + const stillCurrent = this.#updateIfCurrent(generation, (state) => { + state.sumsub.status = 'launching'; + state.sumsub.applicantAccessToken = applicantAccessToken; + }); + if (!stillCurrent) { + return {}; + } + + // Track whether the SDK ever reported a successful completion. A resolved + // `launch` alone does not imply success — the applicant may have + // abandoned the flow or the SDK may have reported a non-success outcome. + let reachedCompletion = false; + + const result = await this.#sumsubLauncher.launch({ + applicantAccessToken, + onTokenExpiration: async () => { + // A reset() may have superseded this flow while the SDK stayed open. + // Refuse to refresh against the now-stale UKYC session rather than + // silently keeping an orphaned SDK alive. + if (this.#generation !== generation) { + throw new Error( + 'KYC flow was reset; SumSub session is no longer active.', + ); + } + const refreshed = await this.messenger.call( + 'KycService:createJourney', + sessionId, + ); + return refreshed.applicantAccessToken; + }, + onStatusChange: (_prev, next) => { + if (next === SUMSUB_COMPLETED_STATUS) { + reachedCompletion = true; + } + this.#updateIfCurrent(generation, (state) => { + state.sumsub.status = + next === SUMSUB_COMPLETED_STATUS ? 'complete' : 'inProgress'; + }); + }, + locale: params?.locale ?? 'en', + debug: params?.debug ?? false, + }); + + // A resolved `launch` alone is not the final outcome: only a SDK-reported + // completion is worth polling for a verification decision. Anything else + // (abandonment, non-success) is `failed` and must not be polled. + const applied = this.#updateIfCurrent(generation, (state) => { + state.sumsub.status = reachedCompletion ? 'polling' : 'failed'; + state.sumsub.result = result as Json; + }); + + // Once the SDK completes, the authoritative verification decision comes + // from the UKYC backend, not the SDK result. Poll the session status + // until it reaches a terminal decision. Guard on `applied` so a `reset()` + // that landed during `launch` cannot start polling on an idle flow. + if (applied && reachedCompletion) { + if (sessionId) { + await this.#startSessionStatusPolling(sessionId); + } else { + // No session id to poll against; fall back to treating the SDK + // completion as the final outcome. + this.#updateIfCurrent(generation, (state) => { + state.sumsub.status = 'complete'; + }); + } + } + return result; + } catch (error) { + // Applicant already finished KYC — treat as completed for Money toast. + if (isSessionAlreadyCompletedError(error)) { + // A reset() may have landed while `launch` was in flight; forcing + // `completed` (and publishing `statusChanged`) on an idle controller + // would resurrect a flow the consumer already tore down. + if (this.#generation !== generation) { + return { alreadyCompleted: true }; + } + this.#applyUserStatus({ + status: 'completed', + sumsubSessionId: null, + errorCode: null, + }); + this.#updateIfCurrent(generation, (state) => { + state.sumsub.status = 'complete'; + state.sumsub.result = { alreadyCompleted: true }; + state.statusMessage = 'KYC already completed.'; + state.phase = 'done'; + state.error = null; + }); + return { alreadyCompleted: true }; + } + const result = { error: String(error) }; + this.#updateIfCurrent(generation, (state) => { + state.sumsub.status = 'failed'; + state.sumsub.result = result; + }); + return result; + } + } + + /** + * Refreshes the user-keyed simplified KYC status from `GET /kyc/status`, + * stores it on state, publishes {@link KycControllerStatusChangedEvent}, and + * schedules short-interval polling while the status is `pending`. + * + * @returns The latest status payload. + */ + async refreshKycStatus(): Promise<{ + status: KycUserStatus; + sumsubSessionId: string | null; + errorCode: string | null; + }> { + const generation = this.#generation; + const payload = await this.#fetchAndApplyUserStatus(); + // A `reset()` landing while the request was in flight already stopped + // polling and left the flow idle, and the payload above is the pre-reset + // cached status. Starting a loop from it would poll — and publish + // `statusChanged` — on a torn-down flow. + if (this.#generation !== generation) { + return payload; + } + if (payload.status === 'pending') { + this.#ensureUserStatusPolling(); + } else { + this.#stopUserStatusPolling(); + } + return payload; + } + + /** + * Fetches `GET /kyc/status` and applies it to state without managing the + * poll loop (used by both {@link refreshKycStatus} and the poll tick). + * + * @returns The latest status payload. + */ + async #fetchAndApplyUserStatus(): Promise<{ + status: KycUserStatus; + sumsubSessionId: string | null; + errorCode: string | null; + }> { + const generation = this.#generation; + const response = await this.messenger.call('KycService:fetchKycStatus'); + if (this.#generation !== generation) { + return { + status: this.state.userStatus ?? 'not-started', + sumsubSessionId: this.state.userStatusSumsubSessionId, + errorCode: this.state.userStatusErrorCode, + }; + } + const payload = { + status: response.status, + sumsubSessionId: response.sumsubSessionId ?? null, + errorCode: response.errorCode ?? null, + }; + this.#applyUserStatus(payload); + return payload; + } + + /** + * Writes user-keyed status onto state and publishes `statusChanged` when the + * value actually changes. + * + * @param payload - The status payload to apply. + * @param payload.status - User-keyed KYC status from `GET /kyc/status`. + * @param payload.sumsubSessionId - Optional SumSub session id from status. + * @param payload.errorCode - Optional error code from status. + */ + #applyUserStatus(payload: { + status: KycUserStatus; + sumsubSessionId: string | null; + errorCode: string | null; + }): void { + const previous = this.state.userStatus; + this.#applyUpdate((state) => { + state.userStatus = payload.status; + state.userStatusSumsubSessionId = payload.sumsubSessionId; + state.userStatusErrorCode = payload.errorCode; + }); + if (previous !== payload.status) { + this.messenger.publish(`${controllerName}:statusChanged`, payload); + } + } + + /** + * Starts the user-status poll loop when not already running and status is + * still `pending`. + */ + #ensureUserStatusPolling(): void { + if (this.#userStatusPolling) { + return; + } + this.#userStatusPolling = true; + const token = this.#userStatusPollToken; + const tick = async (): Promise => { + try { + const payload = await this.#fetchAndApplyUserStatus(); + // Race with `reset()` / `#stopUserStatusPolling` while the request was + // in flight — do not reschedule onto an idle controller. + /* istanbul ignore next */ + if (this.#userStatusPollToken !== token) { + return; + } + if (payload.status !== 'pending') { + this.#stopUserStatusPolling(); + return; + } + } catch { + // Keep polling on transient errors, unless the loop was superseded. + /* istanbul ignore next */ + if (this.#userStatusPollToken !== token) { + return; + } + } + this.#userStatusPollTimer = setTimeout(() => { + this.#userStatusPollTimer = null; + // eslint-disable-next-line @typescript-eslint/no-floating-promises + tick(); + }, this.#userStatusPollIntervalMs); + // Allow the process to exit while a pending-status poll is scheduled. + // React Native / browser timers are numbers with no `unref`, hence the + // optional call. + this.#userStatusPollTimer.unref?.(); + }; + this.#userStatusPollTimer = setTimeout(() => { + this.#userStatusPollTimer = null; + // eslint-disable-next-line @typescript-eslint/no-floating-promises + tick(); + }, this.#userStatusPollIntervalMs); + this.#userStatusPollTimer.unref?.(); + } + + /** + * Stops the user-keyed status poll loop. + */ + #stopUserStatusPolling(): void { + this.#userStatusPollToken += 1; + this.#userStatusPolling = false; + if (this.#userStatusPollTimer !== null) { + clearTimeout(this.#userStatusPollTimer); + this.#userStatusPollTimer = null; + } + } + + /** + * Fetches the current UKYC session status for the active sub-flow and records + * it on state. Useful for a one-off refresh outside the automatic polling + * loop that {@link startSumSub} runs. + * + * @returns The fetched session status. + * @throws If there is no active SumSub session to query. + */ + async getSessionStatus(): Promise { + const { sessionId } = this.state.sumsub; + if (!sessionId) { + throw new Error('Cannot fetch session status: no active SumSub session.'); + } + + // Capture the flow generation so a `reset()` landing while the request is + // in flight cannot write the result onto an idle controller. + const generation = this.#generation; + const sessionStatus = await this.messenger.call( + 'KycService:getSessionStatus', + { sessionId }, + ); + this.#updateIfCurrent(generation, (state) => { + state.sumsub.sessionStatus = sessionStatus; + }); + return sessionStatus; + } + + /** + * Begins polling the UKYC session status until a terminal decision is + * reached. The first poll runs immediately (and is awaited by + * {@link startSumSub}); subsequent polls are scheduled every + * `#sessionStatusPollIntervalMs`. + * + * @param sessionId - The UKYC session id to poll. + * @returns A promise that resolves once the first poll settles. + */ + async #startSessionStatusPolling(sessionId: string): Promise { + // Supersede any prior loop and claim a fresh token for this one. Because + // `#stopPolling` bumps the token, any in-flight poll from a previous loop + // sees a mismatch and neither writes state nor reschedules. + this.#stopPolling(); + const token = this.#pollToken; + + const tick = async (): Promise => { + const shouldStop = await this.#pollSessionStatusOnce(sessionId, token); + if (shouldStop) { + return; + } + this.#pollTimer = setTimeout(() => { + this.#pollTimer = null; + // `tick` swallows its own errors (see `#pollSessionStatusOnce`) and + // therefore never rejects, so this fire-and-forget scheduled poll + // cannot surface as an unhandled rejection. + // eslint-disable-next-line @typescript-eslint/no-floating-promises + tick(); + }, this.#sessionStatusPollIntervalMs); + }; + + await tick(); + } + + /** + * Performs a single session-status poll: fetches the status, records it, and + * resolves the sub-flow when the status is terminal. + * + * Transient errors are swallowed so the loop keeps polling; the last good + * `sessionStatus` is deliberately preserved rather than being overwritten + * with the error. + * + * @param sessionId - The UKYC session id to poll. + * @param token - The polling token captured when the loop started. + * @returns `true` when the loop should stop (terminal status or superseded + * by a reset / new sub-flow), `false` when it should keep polling. + */ + async #pollSessionStatusOnce( + sessionId: string, + token: number, + ): Promise { + try { + const sessionStatus = await this.messenger.call( + 'KycService:getSessionStatus', + { sessionId }, + ); + // Superseded while the request was in flight — drop the result. + if (this.#pollToken !== token) { + return true; + } + const isTerminal = TERMINAL_SESSION_STATUSES.has( + sessionStatus.finalStatus, + ); + this.#applyUpdate((state) => { + state.sumsub.sessionStatus = sessionStatus; + if (isTerminal) { + state.sumsub.status = SUCCESSFUL_SESSION_STATUSES.has( + sessionStatus.finalStatus, + ) + ? 'complete' + : 'failed'; + } + }); + if (isTerminal) { + this.#stopPolling(); + } + return isTerminal; + } catch { + // Keep polling on transient errors, preserving the last good status. + // Stop only when a reset / new sub-flow superseded this loop. + return this.#pollToken !== token; + } + } + + /** + * Stops the session-status polling loop: bumps the polling token (so any + * in-flight `tick` bows out) and clears any scheduled poll. + */ + #stopPolling(): void { + this.#pollToken += 1; + if (this.#pollTimer !== null) { + clearTimeout(this.#pollTimer); + this.#pollTimer = null; + } + } + + /** + * Resets the flow to idle, clearing session tokens and sub-flow state while + * preserving persisted terms acceptance and the per-product cache. + */ + reset(): void { + this.#cancelPendingSession(); + this.#applyUpdate((state) => { + state.phase = 'idle'; + state.statusMessage = ''; + state.error = null; + state.disclaimers = []; + state.disclaimersError = null; + state.sessionDisclaimers = null; + state.credentialReusabilityConsentGiven = null; + state.sessionToken = null; + state.accessToken = null; + state.moonpayCustomerId = null; + state.activeVendor = 'moonpay'; + state.activeProduct = null; + state.sumsub = { + status: 'idle', + result: null, + sessionId: null, + applicantAccessToken: null, + sessionStatus: null, + }; + }); + } + + /** + * Restores the controller to its default state, discarding everything + * {@link reset} deliberately keeps: the session email, the persisted terms + * acceptance, the per-product KYC-required cache and the user-keyed status. + * + * Intended for a full wallet reset, where no trace of the previous + * customer may survive into the next wallet. + */ + clearState(): void { + this.#cancelPendingSession(); + this.#applyUpdate((state) => { + Object.assign(state, getDefaultKycControllerState()); + }); + } + + /** + * Tears down everything that lives outside state: drops the auth-frame + * client token, stops both polling loops, and bumps the flow generation so + * async steps started earlier discard their results instead of writing them + * onto the controller. Shared by {@link reset} and {@link clearState}. + */ + #cancelPendingSession(): void { + this.#authClientToken = null; + this.#stopPolling(); + this.#stopUserStatusPolling(); + this.#generation += 1; + } + + /** + * Applies a state update only when the flow has not been reset since + * `generation` was captured. Prevents an in-flight async step from writing + * stale results onto a controller that a concurrent {@link reset} has + * returned to idle. + * + * @param generation - The flow generation captured before the async work. + * @param updater - The state mutation to apply when still current. + * @returns `true` if the update was applied, `false` if it was superseded. + */ + #updateIfCurrent( + generation: number, + updater: (state: KycControllerState) => void, + ): boolean { + if (this.#generation !== generation) { + return false; + } + this.#applyUpdate(updater); + return true; + } + + /** + * The single state-update path for this controller. All mutations go through + * here (rather than calling `this.update` directly) so the mechanism stays + * consistent and one subtlety is handled in a single place: + * + * `sumsub.result` is typed as the recursive `Json`, and expanding + * `Draft` (which happens whenever an updater touches `sumsub.result`) + * can trip TypeScript's "type instantiation is excessively deep" guard. By + * typing the callback parameter as the plain {@link KycControllerState} + * instead of Immer's `Draft`, we avoid expanding the draft type while keeping + * the same mutate-in-place semantics (the underlying value is still the Immer + * draft at runtime). + * + * @param updater - The state mutation to apply. + */ + #applyUpdate(updater: (state: KycControllerState) => void): void { + this.update((state) => { + // @ts-expect-error Avoid "type instantiation is excessively deep". + updater(state); + }); + } + + /** + * Confirms that an encryption schema's `serverPublicKey.x` matches the + * `sessionServerPublicKeyX` attested inside its verified `jwtChain`. Rejects + * a key that was swapped out-of-band after the chain was signed. + * + * @param keys - The issuer JWKS used to verify the chain (idOS enclave for + * `encryptionDataKey`, idOS relay for `ukycCapabilityToken`). + * @param schema - The encryption schema returned by session creation. + */ + #assertAttestedServerPublicKey(keys: Jwk[], schema: EncryptionSchema): void { + const jwtChainPayload = verifyJwtChain(keys, schema.jwtChain); + if (jwtChainPayload.sessionServerPublicKeyX !== schema.serverPublicKey.x) { + throw new Error( + 'sessionServerPublicKey does not match the verified jwtChain payload (sessionServerPublicKeyX).', + ); + } + } + + /** + * Transitions to the error phase with a message. + * + * @param message - The error message. + */ + #fail(message: string): void { + this.#applyUpdate((state) => { + state.error = message; + state.phase = 'error'; + }); + } +} diff --git a/packages/kyc-controller/src/KycService-method-action-types.ts b/packages/kyc-controller/src/KycService-method-action-types.ts new file mode 100644 index 00000000000..4da7d075de4 --- /dev/null +++ b/packages/kyc-controller/src/KycService-method-action-types.ts @@ -0,0 +1,234 @@ +/** + * This file is auto generated. + * Do not edit manually. + */ + +import type { KycService } from './KycService.js'; + +/** + * Resolves the customer's country from the geolocation source and converts it + * to an ISO 3166-1 alpha-3 code. + * + * @returns The alpha-3 country code. + * @throws If the country cannot be determined or mapped. + */ +export type KycServiceGetGeoCountryAction = { + type: `KycService:getGeoCountry`; + handler: KycService['getGeoCountry']; +}; + +/** + * Fetches the disclaimers the customer must accept before a session is + * created. + * + * @param params - The parameters. + * @param params.vendor - Identity vendor. Defaults to `moonpay`. + * @param params.country - ISO 3166-1 alpha-3 country code. + * @returns The disclaimers. + */ +export type KycServiceFetchDisclaimersAction = { + type: `KycService:fetchDisclaimers`; + handler: KycService['fetchDisclaimers']; +}; + +/** + * Creates a vendor session via the UKYC backend. + * + * @param params - The session parameters. + * @returns The created session token. + */ +export type KycServiceCreateSessionAction = { + type: `KycService:createSession`; + handler: KycService['createSession']; +}; + +/** + * Checks whether KYC is required for the given vendor, country, and + * capabilities. + * + * @param params - The check parameters. + * @returns Whether KYC is required. + */ +export type KycServiceCheckKycRequiredAction = { + type: `KycService:checkKycRequired`; + handler: KycService['checkKycRequired']; +}; + +/** + * Creates (or resumes) an empty-shell customer for the authenticated + * canonical user on the given identity vendor. Must run before showing + * vendor T&C so the customer exists and resume logic can key off vendor + * status. + * + * @param params - The parameters. + * @param params.vendor - Identity vendor (e.g. `iron` for Money/VBA). + * @param params.email - Email associated with the customer. + * @returns The vendor customer record (subset validated for controller use). + */ +export type KycServiceCreateVendorCustomerAction = { + type: `KycService:createVendorCustomer`; + handler: KycService['createVendorCustomer']; +}; + +/** + * Records vendor T&C acceptance (`POST /vendors/{vendor}/disclaimers`). + * For Iron this creates content signings from the disclaimer ids the + * customer accepted. Session-scoped idOS / KYC-provider consents are + * recorded separately via {@link submitSessionDisclaimers}. Retries re-POST + * the same ids, matching the legacy `POST /consents` signing step. + * + * @param params - The parameters. + * @param params.vendor - Identity vendor (e.g. `iron`). + * @param params.disclaimerIds - Accepted vendor T&C ids. + * @returns The vendor signing records. + */ +export type KycServiceSubmitVendorDisclaimersAction = { + type: `KycService:submitVendorDisclaimers`; + handler: KycService['submitVendorDisclaimers']; +}; + +/** + * Fetches the session-scoped idOS + KYC-provider disclaimer catalog + * (`GET /sessions/{sessionId}/disclaimers`). Requires an existing UKYC + * session; vendor T&Cs continue to come from {@link fetchDisclaimers}. + * + * @param params - The parameters. + * @param params.sessionId - The UKYC session id. + * @returns The catalog, including which documents are already consented. + */ +export type KycServiceFetchSessionDisclaimersAction = { + type: `KycService:fetchSessionDisclaimers`; + handler: KycService['fetchSessionDisclaimers']; +}; + +/** + * Records idOS + KYC-provider consents for a UKYC session + * (`POST /sessions/{sessionId}/disclaimers`). `key`/`version` pairs must + * match the current catalog from {@link fetchSessionDisclaimers}. A 409 + * means those document versions were already recorded for the session. + * + * @param params - The consent parameters. + * @returns The updated catalog after recording. + */ +export type KycServiceSubmitSessionDisclaimersAction = { + type: `KycService:submitSessionDisclaimers`; + handler: KycService['submitSessionDisclaimers']; +}; + +/** + * Fetches the user-keyed simplified KYC status used by Money toast / banner + * surfaces (`GET /kyc/status`). + * + * @returns The simplified status payload. + */ +export type KycServiceFetchKycStatusAction = { + type: `KycService:fetchKycStatus`; + handler: KycService['fetchKycStatus']; +}; + +/** + * Fetches the idOS enclave JWKS used to verify the + * `encryptionDataKey` schema's `jwtChain` from + * {@link KycService.createUkycSession}. + * + * This is an unauthenticated request to a well-known path on the idOS enclave + * host, distinct from the UKYC base URL. + * + * @returns The JWKS keys. + */ +export type KycServiceFetchIdosEnclaveJwksAction = { + type: `KycService:fetchIdosEnclaveJwks`; + handler: KycService['fetchIdosEnclaveJwks']; +}; + +/** + * Fetches the idOS relay JWKS used to verify the `ukycCapabilityToken` + * schema's `jwtChain` from {@link KycService.createUkycSession}. + * + * This is an unauthenticated request to a well-known path on the idOS relay + * host, distinct from both the UKYC base URL and the idOS enclave. + * + * @returns The JWKS keys. + */ +export type KycServiceFetchIdosRelayJwksAction = { + type: `KycService:fetchIdosRelayJwks`; + handler: KycService['fetchIdosRelayJwks']; +}; + +/** + * Creates a UKYC session for the SumSub document-verification sub-flow. + * + * The client registers its per-session X25519 public key so the server can + * later open boxes sealed with the matching private key, and supplies the + * customer's ISO 3166-1 alpha-3 country of residence. The response + * carries per-secret encryption schemas (`encryptionDataKey` and + * `ukycCapabilityToken`) so the client can wrap the `data_encryption_key` and + * the read-only `ukyc_capability_token` and submit them via + * {@link KycService.setAuthorizations}. + * + * @param params - The session parameters. + * @returns The UKYC session id and encryption schemas. + */ +export type KycServiceCreateUkycSessionAction = { + type: `KycService:createUkycSession`; + handler: KycService['createUkycSession']; +}; + +/** + * Submits the wrapped `data_encryption_key` and wrapped + * `ukyc_capability_token` for a UKYC session. Both secrets are sealed with + * `wrapEncryptionKey` against the encryption schemas returned by + * {@link KycService.createUkycSession}. + * + * @param params - The wrapped authorizations. + * @returns The session status after the authorizations are applied. + */ +export type KycServiceSetAuthorizationsAction = { + type: `KycService:setAuthorizations`; + handler: KycService['setAuthorizations']; +}; + +/** + * Creates (or refreshes) the SumSub verification journey for a UKYC session, + * returning the applicant access token used to launch the SDK. + * + * @param sessionId - The UKYC session id from `createUkycSession`. + * @returns The applicant access token and status. + */ +export type KycServiceCreateJourneyAction = { + type: `KycService:createJourney`; + handler: KycService['createJourney']; +}; + +/** + * Fetches the current status of a UKYC session. Polled after the SumSub SDK + * completes to determine the final verification decision. + * + * @param params - The parameters. + * @param params.sessionId - The UKYC session id. + * @returns The session status. + */ +export type KycServiceGetSessionStatusAction = { + type: `KycService:getSessionStatus`; + handler: KycService['getSessionStatus']; +}; + +/** + * Union of all KycService action types. + */ +export type KycServiceMethodActions = + | KycServiceGetGeoCountryAction + | KycServiceFetchDisclaimersAction + | KycServiceCreateSessionAction + | KycServiceCheckKycRequiredAction + | KycServiceCreateVendorCustomerAction + | KycServiceSubmitVendorDisclaimersAction + | KycServiceFetchSessionDisclaimersAction + | KycServiceSubmitSessionDisclaimersAction + | KycServiceFetchKycStatusAction + | KycServiceFetchIdosEnclaveJwksAction + | KycServiceFetchIdosRelayJwksAction + | KycServiceCreateUkycSessionAction + | KycServiceSetAuthorizationsAction + | KycServiceCreateJourneyAction + | KycServiceGetSessionStatusAction; diff --git a/packages/kyc-controller/src/KycService.test.ts b/packages/kyc-controller/src/KycService.test.ts new file mode 100644 index 00000000000..dec4ed331b1 --- /dev/null +++ b/packages/kyc-controller/src/KycService.test.ts @@ -0,0 +1,1097 @@ +import { Messenger, MOCK_ANY_NAMESPACE } from '@metamask/messenger'; +import type { + MockAnyNamespace, + MessengerActions, + MessengerEvents, +} from '@metamask/messenger'; +import nock, { cleanAll } from 'nock'; + +import type { EncryptionSchema, KycServiceMessenger } from './KycService.js'; +import { KycService } from './KycService.js'; + +const MOCK_API_URL = 'https://kyc-api.dev-api.cx.metamask.io'; +const MOCK_IDOS_ENCLAVE_URL = 'https://idos-enclave.dev-api.cx.metamask.io'; +const MOCK_IDOS_RELAY_URL = 'https://idos-relay.dev-api.cx.metamask.io'; +const SESSION_CLIENT_PUBLIC_KEY = 'session-client-public-key'; +const RESIDENCE_COUNTRY = 'USA'; + +describe('KycService', () => { + afterEach(() => { + cleanAll(); + }); + + describe('constructor', () => { + it('falls back to the native fetch when no fetch is injected', async () => { + const disclaimers = [ + { id: '1', display_name: 'Terms', url: 'https://t' }, + ]; + nock(MOCK_API_URL) + .get('/vendors/moonpay/disclaimers') + .query({ country: 'USA' }) + .reply(200, disclaimers); + const { service } = getService({ omitFetch: true }); + + expect(await service.fetchDisclaimers({ country: 'USA' })).toStrictEqual( + disclaimers, + ); + }); + + it('throws when fetch is not globally available and not provided', () => { + const savedFetch = globalThis.fetch; + try { + // @ts-expect-error - deliberately removing fetch for test + delete globalThis.fetch; + + const rootMessenger: RootMessenger = new Messenger({ + namespace: MOCK_ANY_NAMESPACE, + }); + const messenger: KycServiceMessenger = new Messenger({ + namespace: 'KycService', + parent: rootMessenger, + }); + + expect( + () => + new KycService({ + messenger: + messenger as unknown as MockAnyNamespace, + baseUrl: MOCK_API_URL, + }), + ).toThrow( + 'fetch is not available globally and was not provided in options', + ); + } finally { + globalThis.fetch = savedFetch; + } + }); + }); + + describe('getGeoCountry', () => { + it('maps the geolocation to an ISO alpha-3 country code', async () => { + const { service } = getService({ geolocation: 'US-NY' }); + expect(await service.getGeoCountry()).toBe('USA'); + }); + + it('throws when the location is unknown', async () => { + const { service } = getService({ geolocation: 'UNKNOWN' }); + await expect(service.getGeoCountry()).rejects.toThrow( + /Unable to determine country/u, + ); + }); + + it('throws when the country cannot be mapped to alpha-3', async () => { + const { service } = getService({ geolocation: 'ZZ' }); + await expect(service.getGeoCountry()).rejects.toThrow( + /Unable to map country code "ZZ"/u, + ); + }); + + it('throws when the location resolves to a nullish value', async () => { + const { service } = getService({ geolocation: null }); + await expect(service.getGeoCountry()).rejects.toThrow( + /Unable to determine country/u, + ); + }); + + it('constructs with the default service policy options', async () => { + const { service } = getService({ + defaultPolicy: true, + geolocation: 'US', + }); + expect(await service.getGeoCountry()).toBe('USA'); + }); + }); + + describe('fetchDisclaimers', () => { + it('returns the disclaimers for a country', async () => { + const disclaimers = [ + { id: '1', display_name: 'Terms', url: 'https://t' }, + ]; + nock(MOCK_API_URL) + .get('/vendors/moonpay/disclaimers') + .query({ country: 'USA' }) + .reply(200, disclaimers); + const { service } = getService(); + + expect(await service.fetchDisclaimers({ country: 'USA' })).toStrictEqual( + disclaimers, + ); + }); + + it('throws on a malformed response', async () => { + nock(MOCK_API_URL) + .get('/vendors/moonpay/disclaimers') + .query({ country: 'USA' }) + .reply(200, [{ id: 1 }]); + const { service } = getService(); + + await expect( + service.fetchDisclaimers({ country: 'USA' }), + ).rejects.toThrow(/Malformed response received from disclaimers API/u); + }); + + it('throws when no bearer token is available', async () => { + const { service } = getService({ bearerToken: '' }); + await expect( + service.fetchDisclaimers({ country: 'USA' }), + ).rejects.toThrow(/Unable to obtain an authentication bearer token/u); + }); + + it('throws an HttpError on a non-ok response', async () => { + nock(MOCK_API_URL) + .get('/vendors/moonpay/disclaimers') + .query({ country: 'USA' }) + .reply(500); + const { service } = getService(); + + await expect( + service.fetchDisclaimers({ country: 'USA' }), + ).rejects.toThrow(/failed with status '500'/u); + }); + }); + + describe('createSession', () => { + it('creates a session and returns the token', async () => { + nock(MOCK_API_URL) + .post('/vendors/moonpay/sessions') + .reply(200, { sessionToken: 'session-1' }); + const { service } = getService(); + + expect( + await service.createSession({ + email: 'a@b.co', + termsAcceptedAt: '2026-01-01T00:00:00.000Z', + disclaimerIds: ['1'], + }), + ).toStrictEqual({ sessionToken: 'session-1' }); + }); + + it('throws on a malformed response', async () => { + nock(MOCK_API_URL).post('/vendors/moonpay/sessions').reply(200, {}); + const { service } = getService(); + + await expect( + service.createSession({ + email: 'a@b.co', + termsAcceptedAt: '2026-01-01T00:00:00.000Z', + disclaimerIds: ['1'], + }), + ).rejects.toThrow(/Malformed response received from sessions API/u); + }); + }); + + describe('checkKycRequired', () => { + it('returns whether KYC is required (default capabilities)', async () => { + nock(MOCK_API_URL) + .post('/vendors/moonpay/kyc-required', { + accessToken: 'access-1', + country: 'USA', + capabilities: [{ product: 'ramps' }], + }) + .reply(200, { required: true }); + const { service } = getService(); + + expect( + await service.checkKycRequired({ + accessToken: 'access-1', + country: 'USA', + }), + ).toStrictEqual({ kycRequired: true }); + }); + + it('passes provided capabilities', async () => { + nock(MOCK_API_URL) + .post('/vendors/moonpay/kyc-required', { + accessToken: 'access-1', + country: 'USA', + capabilities: [{ product: 'card' }], + }) + .reply(200, { required: false }); + const { service } = getService(); + + expect( + await service.checkKycRequired({ + accessToken: 'access-1', + country: 'USA', + capabilities: [{ product: 'card' }], + }), + ).toStrictEqual({ kycRequired: false }); + }); + + it('throws on a malformed response', async () => { + nock(MOCK_API_URL).post('/vendors/moonpay/kyc-required').reply(200, {}); + const { service } = getService(); + + await expect( + service.checkKycRequired({ accessToken: 'access-1', country: 'USA' }), + ).rejects.toThrow(/Malformed response received from kyc-required API/u); + }); + + it('surfaces the specific field mismatch and payload in the error', async () => { + nock(MOCK_API_URL) + .post('/vendors/moonpay/kyc-required') + .reply(200, { required: 'yes' }); + const { service } = getService(); + + await expect( + service.checkKycRequired({ accessToken: 'access-1', country: 'USA' }), + ).rejects.toThrow( + /Malformed response received from kyc-required API:.*required.*received: \{"required":"yes"\}/su, + ); + }); + }); + + describe('fetchIdosEnclaveJwks', () => { + it('fetches the JWKS from the idOS enclave well-known path', async () => { + const response = { + keys: [{ kty: 'OKP', crv: 'Ed25519', x: 'pub', kid: 'k1' }], + }; + nock(MOCK_IDOS_ENCLAVE_URL) + .get('/.well-known/jwks.json') + .reply(200, response); + const { service } = getService(); + + expect(await service.fetchIdosEnclaveJwks()).toStrictEqual(response); + }); + + it('throws when no idOS enclave base URL is configured', async () => { + // Omit the option entirely so the constructor falls back to ''. + const { service } = getService({ idosEnclaveBaseUrl: null }); + + await expect(service.fetchIdosEnclaveJwks()).rejects.toThrow( + /idosEnclaveBaseUrl is not configured; cannot fetch JWKS to verify the encryptionDataKey schema/u, + ); + }); + + it('throws on a malformed response', async () => { + nock(MOCK_IDOS_ENCLAVE_URL) + .get('/.well-known/jwks.json') + .reply(200, { keys: [{ kty: 'OKP' }] }); + const { service } = getService(); + + await expect(service.fetchIdosEnclaveJwks()).rejects.toThrow( + /Malformed response received from idOS enclave JWKS API/u, + ); + }); + }); + + describe('fetchIdosRelayJwks', () => { + it('fetches the JWKS from the idOS relay well-known path', async () => { + const response = { + keys: [{ kty: 'OKP', crv: 'Ed25519', x: 'relay-pub', kid: 'r1' }], + }; + nock(MOCK_IDOS_RELAY_URL) + .get('/.well-known/jwks.json') + .reply(200, response); + const { service } = getService(); + + expect(await service.fetchIdosRelayJwks()).toStrictEqual(response); + }); + + it('throws when no idOS relay base URL is configured', async () => { + const { service } = getService({ idosRelayBaseUrl: null }); + + await expect(service.fetchIdosRelayJwks()).rejects.toThrow( + /idosRelayBaseUrl is not configured; cannot fetch JWKS to verify the ukycCapabilityToken schema/u, + ); + }); + + it('throws on a malformed response', async () => { + nock(MOCK_IDOS_RELAY_URL) + .get('/.well-known/jwks.json') + .reply(200, { keys: [{ kty: 'OKP' }] }); + const { service } = getService(); + + await expect(service.fetchIdosRelayJwks()).rejects.toThrow( + /Malformed response received from idOS relay JWKS API/u, + ); + }); + }); + + describe('createUkycSession', () => { + const encryptionSchema: EncryptionSchema = { + serverPublicKey: { kty: 'OKP', crv: 'X25519', x: 'spk-x' }, + jwtChain: 'jwt.chain.sig', + }; + const response = { + sessionId: 'sid', + encryptionDataKey: encryptionSchema, + ukycCapabilityToken: { + ...encryptionSchema, + jwtChain: 'capability.jwt.chain', + }, + }; + + it('creates a UKYC session and returns encryption schemas for wrapping', async () => { + nock(MOCK_API_URL) + .post( + '/sessions', + (body: Record) => + body.jwtToken === 'jwt' && + body.vendorId === 'moonpay' && + body.sessionClientPublicKey === SESSION_CLIENT_PUBLIC_KEY && + body.residenceCountry === RESIDENCE_COUNTRY && + body.wrappedEncryptionKey === undefined && + body.ukycCapabilityToken === undefined, + ) + .reply(200, response); + const { service } = getService(); + + expect( + await service.createUkycSession({ + jwtToken: 'jwt', + sessionClientPublicKey: SESSION_CLIENT_PUBLIC_KEY, + residenceCountry: RESIDENCE_COUNTRY, + vendorMetadata: { foo: 'bar' }, + }), + ).toStrictEqual(response); + }); + + it('throws on a malformed response', async () => { + nock(MOCK_API_URL).post('/sessions').reply(200, { unexpected: true }); + const { service } = getService(); + + await expect( + service.createUkycSession({ + jwtToken: 'jwt', + sessionClientPublicKey: SESSION_CLIENT_PUBLIC_KEY, + residenceCountry: RESIDENCE_COUNTRY, + vendorMetadata: {}, + }), + ).rejects.toThrow(/Malformed response received from UKYC sessions API/u); + }); + }); + + describe('setAuthorizations', () => { + const wrappedEncryptionDataKey = { nonce: 'nonce-1', data: 'data-1' }; + const wrappedUkycCapabilityToken = { nonce: 'nonce-2', data: 'data-2' }; + const statusResponse = { + finalStatus: 'approved', + statusMessage: 'All good', + externalUserId: 'ext-1', + kycStatus: 'approved', + vendor: 'sumsub', + vendorStatus: 'GREEN', + }; + + it('posts the wrapped secrets and returns the session status', async () => { + nock(MOCK_API_URL) + .post( + '/sessions/sid/authorizations', + (body: Record) => + JSON.stringify(body.wrappedEncryptionDataKey) === + JSON.stringify(wrappedEncryptionDataKey) && + JSON.stringify(body.wrappedUkycCapabilityToken) === + JSON.stringify(wrappedUkycCapabilityToken), + ) + .reply(200, statusResponse); + const { service } = getService(); + + expect( + await service.setAuthorizations({ + sessionId: 'sid', + wrappedEncryptionDataKey, + wrappedUkycCapabilityToken, + }), + ).toStrictEqual(statusResponse); + }); + + it('url-encodes the session id', async () => { + nock(MOCK_API_URL) + .post('/sessions/a%2Fb/authorizations') + .reply(200, statusResponse); + const { service } = getService(); + + expect( + await service.setAuthorizations({ + sessionId: 'a/b', + wrappedEncryptionDataKey, + wrappedUkycCapabilityToken, + }), + ).toStrictEqual(statusResponse); + }); + + it('throws on a malformed response', async () => { + nock(MOCK_API_URL) + .post('/sessions/sid/authorizations') + .reply(200, { unexpected: true }); + const { service } = getService(); + + await expect( + service.setAuthorizations({ + sessionId: 'sid', + wrappedEncryptionDataKey, + wrappedUkycCapabilityToken, + }), + ).rejects.toThrow(/Malformed response received from authorizations API/u); + }); + }); + + describe('createJourney', () => { + it('fetches the applicant access token for a session', async () => { + const response = { status: 'ok', applicantAccessToken: 'aat' }; + nock(MOCK_API_URL).post('/sessions/sid/journey').reply(200, response); + const { service } = getService(); + + expect(await service.createJourney('sid')).toStrictEqual(response); + }); + + it('does not send a Content-Type header since it has no body', async () => { + const response = { status: 'ok', applicantAccessToken: 'aat' }; + nock(MOCK_API_URL) + .post('/sessions/sid/journey') + .matchHeader('content-type', (value) => value === undefined) + .reply(200, response); + const { service } = getService(); + + expect(await service.createJourney('sid')).toStrictEqual(response); + }); + + it('throws on a malformed response', async () => { + nock(MOCK_API_URL) + .post('/sessions/sid/journey') + .reply(200, { status: 'ok' }); + const { service } = getService(); + + await expect(service.createJourney('sid')).rejects.toThrow( + /Malformed response received from journey API/u, + ); + }); + }); + + describe('getSessionStatus', () => { + it('returns the session status', async () => { + const response = { + finalStatus: 'approved', + statusMessage: 'All good', + externalUserId: 'ext-1', + kycStatus: 'approved', + vendor: 'sumsub', + vendorStatus: 'GREEN', + }; + nock(MOCK_API_URL).get('/sessions/sid/status').reply(200, response); + const { service } = getService(); + + expect( + await service.getSessionStatus({ sessionId: 'sid' }), + ).toStrictEqual(response); + }); + + it('url-encodes the session id', async () => { + const response = { + finalStatus: 'pending', + externalUserId: 'ext-1', + kycStatus: 'pending', + vendor: 'sumsub', + vendorStatus: 'YELLOW', + }; + nock(MOCK_API_URL).get('/sessions/a%2Fb/status').reply(200, response); + const { service } = getService(); + + expect( + await service.getSessionStatus({ sessionId: 'a/b' }), + ).toStrictEqual(response); + }); + + it('throws on a malformed response', async () => { + nock(MOCK_API_URL) + .get('/sessions/sid/status') + .reply(200, { finalStatus: 'approved' }); + const { service } = getService(); + + await expect( + service.getSessionStatus({ sessionId: 'sid' }), + ).rejects.toThrow(/Malformed response received from session status API/u); + }); + + it('throws an HttpError on a non-ok response', async () => { + nock(MOCK_API_URL).get('/sessions/sid/status').reply(404); + const { service } = getService(); + + await expect( + service.getSessionStatus({ sessionId: 'sid' }), + ).rejects.toThrow(/failed with status '404'/u); + }); + + it('includes the API error message in HttpError when present', async () => { + nock(MOCK_API_URL) + .get('/sessions/sid/status') + .reply(409, { message: 'session_not_in_valid_state' }); + const { service } = getService(); + + await expect( + service.getSessionStatus({ sessionId: 'sid' }), + ).rejects.toThrow(/session_not_in_valid_state/u); + }); + + it('includes the API error field in HttpError when message is absent', async () => { + nock(MOCK_API_URL) + .get('/sessions/sid/status') + .reply(409, { error: 'session_not_in_valid_state' }); + const { service } = getService(); + + await expect( + service.getSessionStatus({ sessionId: 'sid' }), + ).rejects.toThrow(/session_not_in_valid_state/u); + }); + + it('prefers a string error field when message is not a string', async () => { + nock(MOCK_API_URL) + .get('/sessions/sid/status') + .reply(409, { message: 123, error: 'session_not_in_valid_state' }); + const { service } = getService(); + + await expect( + service.getSessionStatus({ sessionId: 'sid' }), + ).rejects.toThrow(/session_not_in_valid_state/u); + }); + + it('falls back to status-only HttpError when the body has no useful fields', async () => { + nock(MOCK_API_URL) + .get('/sessions/sid/status') + .reply(409, { message: 1, error: 2 }); + const { service } = getService(); + + await expect( + service.getSessionStatus({ sessionId: 'sid' }), + ).rejects.toThrow(/failed with status '409'$/u); + }); + + it('falls back to status-only HttpError when the body is not an object', async () => { + nock(MOCK_API_URL).get('/sessions/sid/status').reply(409, null); + const { service } = getService(); + + await expect( + service.getSessionStatus({ sessionId: 'sid' }), + ).rejects.toThrow(/failed with status '409'$/u); + }); + }); + + describe('createVendorCustomer', () => { + it('creates an Iron customer and returns the validated subset', async () => { + nock(MOCK_API_URL) + .post('/vendors/iron/customers', { email: 'a@b.co' }) + .reply(200, { + id: 'iron-1', + email: 'a@b.co', + status: 'SigningsRequired', + customer_type: 'Person', + name: '', + partner_id: 'p', + identification_ids: [], + signing_ids: [], + created_at: '2026-01-01T00:00:00.000Z', + updated_at: '2026-01-01T00:00:00.000Z', + }); + const { service } = getService(); + + expect( + await service.createVendorCustomer({ vendor: 'iron', email: 'a@b.co' }), + ).toMatchObject({ + id: 'iron-1', + email: 'a@b.co', + status: 'SigningsRequired', + }); + }); + + it('throws on a malformed response', async () => { + nock(MOCK_API_URL).post('/vendors/iron/customers').reply(200, {}); + const { service } = getService(); + + await expect( + service.createVendorCustomer({ + vendor: 'iron', + email: 'a@b.co', + }), + ).rejects.toThrow( + /Malformed response received from vendor customers API/u, + ); + }); + }); + + describe('submitVendorDisclaimers', () => { + const signings = [ + { id: 'sign-1', customer_id: 'cust-1', content_id: 'disc-1' }, + { id: 'sign-2', customer_id: 'cust-1', content_id: 'disc-2' }, + ]; + + it('posts accepted disclaimer ids and returns vendor signings', async () => { + nock(MOCK_API_URL) + .post('/vendors/iron/disclaimers', { + disclaimerIds: ['disc-1', 'disc-2'], + }) + .reply(200, signings); + const { service } = getService(); + + expect( + await service.submitVendorDisclaimers({ + vendor: 'iron', + disclaimerIds: ['disc-1', 'disc-2'], + }), + ).toStrictEqual(signings); + }); + + it('accepts extra signing fields and an omitted content_id', async () => { + nock(MOCK_API_URL) + .post('/vendors/iron/disclaimers', { disclaimerIds: ['disc-1'] }) + .reply(200, [{ id: 'sign-1', customer_id: 'cust-1', signed: true }]); + const { service } = getService(); + + expect( + await service.submitVendorDisclaimers({ + vendor: 'iron', + disclaimerIds: ['disc-1'], + }), + ).toMatchObject([{ id: 'sign-1', customer_id: 'cust-1' }]); + }); + + it('throws on a malformed response', async () => { + nock(MOCK_API_URL).post('/vendors/iron/disclaimers').reply(200, {}); + const { service } = getService(); + + await expect( + service.submitVendorDisclaimers({ + vendor: 'iron', + disclaimerIds: ['disc-1'], + }), + ).rejects.toThrow( + /Malformed response received from vendor disclaimers API/u, + ); + }); + + it('throws an HttpError on a non-ok response', async () => { + nock(MOCK_API_URL).post('/vendors/iron/disclaimers').reply(500); + const { service } = getService(); + + await expect( + service.submitVendorDisclaimers({ + vendor: 'iron', + disclaimerIds: ['disc-1'], + }), + ).rejects.toThrow(/failed with status '500'/u); + }); + }); + + describe('fetchDisclaimers for a non-MoonPay vendor', () => { + it('returns Iron disclaimers for a country', async () => { + const disclaimers = [ + { id: '1', display_name: 'Iron Terms', url: 'https://t' }, + ]; + nock(MOCK_API_URL) + .get('/vendors/iron/disclaimers') + .query({ country: 'USA' }) + .reply(200, disclaimers); + const { service } = getService(); + + expect( + await service.fetchDisclaimers({ vendor: 'iron', country: 'USA' }), + ).toStrictEqual(disclaimers); + }); + + it('throws on a malformed response', async () => { + nock(MOCK_API_URL) + .get('/vendors/iron/disclaimers') + .query({ country: 'USA' }) + .reply(200, [{ id: 1 }]); + const { service } = getService(); + + await expect( + service.fetchDisclaimers({ vendor: 'iron', country: 'USA' }), + ).rejects.toThrow(/Malformed response received from disclaimers API/u); + }); + }); + + describe('checkKycRequired for a non-MoonPay vendor', () => { + it('returns whether Iron KYC is required', async () => { + nock(MOCK_API_URL) + .post('/vendors/iron/kyc-required') + .reply(200, { required: true }); + const { service } = getService(); + + expect(await service.checkKycRequired({ vendor: 'iron' })).toStrictEqual({ + kycRequired: true, + }); + }); + + it('throws when accessToken is missing for MoonPay vendor', async () => { + const { service } = getService(); + + await expect( + service.checkKycRequired({ vendor: 'moonpay', country: 'USA' }), + ).rejects.toThrow('accessToken is required for vendor "moonpay"'); + }); + + it('throws when country is missing for MoonPay vendor', async () => { + const { service } = getService(); + + await expect( + service.checkKycRequired({ vendor: 'moonpay', accessToken: 'tok' }), + ).rejects.toThrow('country is required for vendor "moonpay"'); + }); + + it('throws on a malformed response', async () => { + nock(MOCK_API_URL).post('/vendors/iron/kyc-required').reply(200, {}); + const { service } = getService(); + + await expect( + service.checkKycRequired({ vendor: 'iron' }), + ).rejects.toThrow(/Malformed response received from kyc-required API/u); + }); + }); + + describe('fetchSessionDisclaimers', () => { + const catalog = { + idOS: [ + { + key: 'idos-tos', + version: '1', + title: 'idOS ToS', + url: 'https://idos.example/tos', + consented: false, + }, + ], + kycProvider: [ + { + key: 'sumsub-tos', + version: '1', + title: 'SumSub ToS', + url: 'https://sumsub.example/tos', + consented: false, + }, + ], + credentialReusabilityConsentGiven: false, + }; + + it('returns the session-scoped disclaimer catalog', async () => { + nock(MOCK_API_URL).get('/sessions/sid-1/disclaimers').reply(200, catalog); + const { service } = getService(); + + expect( + await service.fetchSessionDisclaimers({ sessionId: 'sid-1' }), + ).toStrictEqual(catalog); + }); + + it('throws on a malformed response', async () => { + nock(MOCK_API_URL).get('/sessions/sid-1/disclaimers').reply(200, {}); + const { service } = getService(); + + await expect( + service.fetchSessionDisclaimers({ sessionId: 'sid-1' }), + ).rejects.toThrow( + /Malformed response received from session disclaimers API/u, + ); + }); + + it('throws an HttpError on a non-ok response', async () => { + nock(MOCK_API_URL).get('/sessions/sid-1/disclaimers').reply(500); + const { service } = getService(); + + await expect( + service.fetchSessionDisclaimers({ sessionId: 'sid-1' }), + ).rejects.toThrow(/failed with status '500'/u); + }); + }); + + describe('submitSessionDisclaimers', () => { + const recorded = { + idOS: [ + { + key: 'idos-tos', + version: '1', + title: 'idOS ToS', + url: 'https://idos.example/tos', + consented: true, + }, + ], + kycProvider: [ + { + key: 'sumsub-tos', + version: '1', + title: 'SumSub ToS', + url: 'https://sumsub.example/tos', + consented: true, + }, + ], + credentialReusabilityConsentGiven: true, + }; + + it('posts consent records and returns the updated catalog', async () => { + nock(MOCK_API_URL) + .post('/sessions/sid-1/disclaimers', { + idOS: [{ key: 'idos-tos', version: '1' }], + kycProvider: [{ key: 'sumsub-tos', version: '1' }], + credentialReusabilityConsentGiven: true, + }) + .reply(200, recorded); + const { service } = getService(); + + expect( + await service.submitSessionDisclaimers({ + sessionId: 'sid-1', + idOS: [{ key: 'idos-tos', version: '1' }], + kycProvider: [{ key: 'sumsub-tos', version: '1' }], + credentialReusabilityConsentGiven: true, + }), + ).toStrictEqual(recorded); + }); + + it('throws on a malformed response', async () => { + nock(MOCK_API_URL).post('/sessions/sid-1/disclaimers').reply(200, {}); + const { service } = getService(); + + await expect( + service.submitSessionDisclaimers({ + sessionId: 'sid-1', + idOS: [], + kycProvider: [], + credentialReusabilityConsentGiven: false, + }), + ).rejects.toThrow( + /Malformed response received from session disclaimers API/u, + ); + }); + + it('throws an HttpError on a non-ok response', async () => { + nock(MOCK_API_URL).post('/sessions/sid-1/disclaimers').reply(409); + const { service } = getService(); + + await expect( + service.submitSessionDisclaimers({ + sessionId: 'sid-1', + idOS: [{ key: 'idos-tos', version: '1' }], + kycProvider: [], + credentialReusabilityConsentGiven: false, + }), + ).rejects.toThrow(/failed with status '409'/u); + }); + + it('treats a 204 response as empty and fails catalog validation', async () => { + nock(MOCK_API_URL).post('/sessions/sid-1/disclaimers').reply(204); + const { service } = getService(); + + await expect( + service.submitSessionDisclaimers({ + sessionId: 'sid-1', + idOS: [], + kycProvider: [], + credentialReusabilityConsentGiven: false, + }), + ).rejects.toThrow( + /Malformed response received from session disclaimers API/u, + ); + }); + }); + + describe('fetchKycStatus', () => { + it('returns the simplified user-keyed status', async () => { + nock(MOCK_API_URL).get('/kyc/status').reply(200, { + status: 'pending', + sumsubSessionId: 'ss-1', + }); + const { service } = getService(); + + expect(await service.fetchKycStatus()).toStrictEqual({ + status: 'pending', + sumsubSessionId: 'ss-1', + }); + }); + + it('throws on an unknown status value', async () => { + nock(MOCK_API_URL).get('/kyc/status').reply(200, { status: 'weird' }); + const { service } = getService(); + + await expect(service.fetchKycStatus()).rejects.toThrow( + /Malformed response received from kyc status API/u, + ); + }); + }); + + describe('createUkycSession vendorId', () => { + const encryptionSchema: EncryptionSchema = { + serverPublicKey: { kty: 'OKP', crv: 'X25519', x: 'spk-x' }, + jwtChain: 'jwt.chain.sig', + }; + + it('defaults vendorId to moonpay and forwards vendorMetadata', async () => { + const response = { + sessionId: 'sid', + encryptionDataKey: encryptionSchema, + ukycCapabilityToken: encryptionSchema, + }; + nock(MOCK_API_URL) + .post('/sessions', (body) => { + return ( + body.vendorId === 'moonpay' && + body.sessionClientPublicKey === SESSION_CLIENT_PUBLIC_KEY && + body.residenceCountry === RESIDENCE_COUNTRY && + body.vendorMetadata?.moonPayAccessToken === 'tok' && + body.wrappedEncryptionKey === undefined && + body.ukycCapabilityToken === undefined + ); + }) + .reply(200, response); + const { service } = getService(); + + expect( + await service.createUkycSession({ + jwtToken: 'jwt', + sessionClientPublicKey: SESSION_CLIENT_PUBLIC_KEY, + residenceCountry: RESIDENCE_COUNTRY, + vendorMetadata: { moonPayAccessToken: 'tok' }, + }), + ).toStrictEqual(response); + }); + + it('sends vendor iron with empty vendorMetadata when omitted', async () => { + const response = { + sessionId: 'sid-iron', + encryptionDataKey: encryptionSchema, + ukycCapabilityToken: encryptionSchema, + }; + nock(MOCK_API_URL) + .post('/sessions', (body) => { + return ( + body.vendorId === 'iron' && + body.sessionClientPublicKey === SESSION_CLIENT_PUBLIC_KEY && + body.residenceCountry === RESIDENCE_COUNTRY && + JSON.stringify(body.vendorMetadata) === '{}' + ); + }) + .reply(200, response); + const { service } = getService(); + + expect( + await service.createUkycSession({ + jwtToken: 'jwt', + sessionClientPublicKey: SESSION_CLIENT_PUBLIC_KEY, + residenceCountry: RESIDENCE_COUNTRY, + vendor: 'iron', + }), + ).toStrictEqual(response); + }); + }); + + describe('baseUrl', () => { + it('uses the provided baseUrl for requests', async () => { + const customUrl = 'https://kyc-api.local.test'; + const disclaimers = [ + { id: '1', display_name: 'Terms', url: 'https://t' }, + ]; + nock(customUrl) + .get('/vendors/moonpay/disclaimers') + .query({ country: 'USA' }) + .reply(200, disclaimers); + const { service } = getService({ baseUrl: customUrl }); + + expect(await service.fetchDisclaimers({ country: 'USA' })).toStrictEqual( + disclaimers, + ); + }); + + it('throws when baseUrl is empty', () => { + expect(() => getService({ baseUrl: '' })).toThrow( + 'KycService: baseUrl is required', + ); + }); + }); + + describe('messenger actions', () => { + it('exposes methods as messenger actions', async () => { + nock(MOCK_API_URL) + .get('/vendors/moonpay/disclaimers') + .query({ country: 'USA' }) + .reply(200, []); + const { rootMessenger } = getService(); + + expect( + await rootMessenger.call('KycService:fetchDisclaimers', { + country: 'USA', + }), + ).toStrictEqual([]); + }); + }); +}); + +type RootMessenger = Messenger< + MockAnyNamespace, + MessengerActions, + MessengerEvents +>; + +/** + * Constructs the service under test with mocked auth + geo handlers. + * + * @param args - Options. + * @param args.bearerToken - The bearer token the auth handler returns. + * @param args.geolocation - The location the geolocation handler returns. + * @param args.defaultPolicy - When true, omit `policyOptions` to use defaults. + * @param args.baseUrl - Base URL of the KYC API. + * @param args.idosEnclaveBaseUrl - idOS enclave base URL; `null` omits the + * option so the service falls back to an empty string. + * @param args.idosRelayBaseUrl - idOS relay base URL; `null` omits the option + * so the service falls back to an empty string. + * @param args.omitFetch - When true, omit the `fetch` option so the service + * falls back to the runtime's native `fetch`. + * @returns The service, root messenger, and service messenger. + */ +function getService({ + bearerToken = 'test-bearer', + geolocation = 'US-NY', + defaultPolicy = false, + baseUrl = MOCK_API_URL, + // `null` means "omit the option entirely" (exercises the constructor's + // `?? ''` fallback); omitting the field defaults to the mock idOS enclave URL. + idosEnclaveBaseUrl = MOCK_IDOS_ENCLAVE_URL, + // Same `null` omission convention as `idosEnclaveBaseUrl`. + idosRelayBaseUrl = MOCK_IDOS_RELAY_URL, + // When true, omit the `fetch` option so the service falls back to the + // runtime's native `fetch` (which nock intercepts). + omitFetch = false, +}: { + bearerToken?: string; + geolocation?: string | null; + defaultPolicy?: boolean; + baseUrl?: string; + idosEnclaveBaseUrl?: string | null; + idosRelayBaseUrl?: string | null; + omitFetch?: boolean; +} = {}): { + service: KycService; + rootMessenger: RootMessenger; + messenger: KycServiceMessenger; +} { + const rootMessenger: RootMessenger = new Messenger({ + namespace: MOCK_ANY_NAMESPACE, + }); + const messenger: KycServiceMessenger = new Messenger({ + namespace: 'KycService', + parent: rootMessenger, + }); + rootMessenger.delegate({ + actions: [ + 'AuthenticationController:getBearerToken', + 'GeolocationController:getGeolocation', + ], + events: [], + messenger, + }); + rootMessenger.registerActionHandler( + 'AuthenticationController:getBearerToken', + async () => bearerToken, + ); + rootMessenger.registerActionHandler( + 'GeolocationController:getGeolocation', + async () => geolocation as string, + ); + + const service = new KycService({ + ...(omitFetch ? {} : { fetch }), + messenger, + baseUrl, + ...(idosEnclaveBaseUrl === null ? {} : { idosEnclaveBaseUrl }), + ...(idosRelayBaseUrl === null ? {} : { idosRelayBaseUrl }), + ...(defaultPolicy ? {} : { policyOptions: { maxRetries: 0 } }), + }); + + return { service, rootMessenger, messenger }; +} diff --git a/packages/kyc-controller/src/KycService.ts b/packages/kyc-controller/src/KycService.ts new file mode 100644 index 00000000000..5d028c34e6a --- /dev/null +++ b/packages/kyc-controller/src/KycService.ts @@ -0,0 +1,1103 @@ +import { BaseDataService } from '@metamask/base-data-service'; +import type { + DataServiceCacheUpdatedEvent, + DataServiceGranularCacheUpdatedEvent, + DataServiceInvalidateQueriesAction, +} from '@metamask/base-data-service'; +import type { CreateServicePolicyOptions } from '@metamask/controller-utils'; +import { HttpError } from '@metamask/controller-utils'; +import type { GeolocationControllerGetGeolocationAction } from '@metamask/geolocation-controller'; +import type { Messenger } from '@metamask/messenger'; +import type { AuthenticationControllerGetBearerTokenAction } from '@metamask/profile-sync-controller/auth'; +import type { Infer, Struct } from '@metamask/superstruct'; +import { + array, + assert, + boolean, + enums, + optional, + string, + StructError, + type, +} from '@metamask/superstruct'; +import type { Json } from '@metamask/utils'; +import { Duration, inMilliseconds } from '@metamask/utils'; +import type { QueryClientConfig } from '@tanstack/query-core'; + +import { alpha2ToAlpha3 } from './countryCodes.js'; +import type { KycServiceMethodActions } from './KycService-method-action-types.js'; +import type { + KycConsentRecord, + KycDisclaimer, + KycSessionDisclaimers, + KycSessionStatus, + KycUserStatusResponse, + KycVendor, + KycVendorSigning, +} from './types.js'; +import { UKYC_JWKS_PATH } from './ukyc/constants.js'; + +// === GENERAL === + +/** + * The name of the {@link KycService}, used to namespace the service's actions. + */ +export const serviceName = 'KycService'; + +// === MESSENGER === + +const MESSENGER_EXPOSED_METHODS = [ + 'getGeoCountry', + 'fetchDisclaimers', + 'createSession', + 'checkKycRequired', + 'createVendorCustomer', + 'submitVendorDisclaimers', + 'fetchSessionDisclaimers', + 'submitSessionDisclaimers', + 'fetchKycStatus', + 'fetchIdosEnclaveJwks', + 'fetchIdosRelayJwks', + 'createUkycSession', + 'setAuthorizations', + 'createJourney', + 'getSessionStatus', +] as const; + +/** + * Invalidates cached queries serviced by {@link KycService}. + */ +export type KycServiceInvalidateQueriesAction = + DataServiceInvalidateQueriesAction; + +/** + * Actions that {@link KycService} exposes to other consumers. + */ +export type KycServiceActions = + | KycServiceMethodActions + | KycServiceInvalidateQueriesAction; + +/** + * Actions from other messengers that {@link KycService} calls. + */ +type AllowedActions = + | AuthenticationControllerGetBearerTokenAction + | GeolocationControllerGetGeolocationAction; + +/** + * Published when {@link KycService}'s cache is updated. + */ +export type KycServiceCacheUpdatedEvent = DataServiceCacheUpdatedEvent< + typeof serviceName +>; + +/** + * Published when a single key within {@link KycService}'s cache is updated. + */ +export type KycServiceGranularCacheUpdatedEvent = + DataServiceGranularCacheUpdatedEvent; + +/** + * Events that {@link KycService} exposes to other consumers. + */ +export type KycServiceEvents = + | KycServiceCacheUpdatedEvent + | KycServiceGranularCacheUpdatedEvent; + +/** + * Events from other messengers that {@link KycService} subscribes to. + */ +type AllowedEvents = never; + +/** + * The messenger restricted to actions and events accessed by + * {@link KycService}. + */ +export type KycServiceMessenger = Messenger< + typeof serviceName, + KycServiceActions | AllowedActions, + KycServiceEvents | AllowedEvents +>; + +/** + * Options for constructing a {@link KycService}. + */ +export type KycServiceOptions = { + messenger: KycServiceMessenger; + /** + * A function used to make HTTP requests. Defaults to the runtime's native + * `fetch`, so consumers do not need to inject one on platforms where `fetch` + * is available globally (browser, React Native, Node 18+). + */ + fetch?: typeof fetch; + /** + * Mandatory value that sets the base url to KYC api + */ + baseUrl: string; + /** + * Base URL of the idOS enclave, from which the JWKS used to + * verify the `encryptionDataKey` schema's `jwtChain` is fetched. + */ + idosEnclaveBaseUrl?: string; + /** + * Base URL of the idOS relay, from which the JWKS used to verify the + * `ukycCapabilityToken` schema's `jwtChain` is fetched. + */ + idosRelayBaseUrl?: string; + /** + * Shared configuration applied to all queries exposed by the service (e.g. a + * default `staleTime`/`gcTime`). Each data service gets its own + * `QueryClient`. + */ + queryClientConfig?: QueryClientConfig; + policyOptions?: CreateServicePolicyOptions; +}; + +// === API RESPONSE SCHEMAS === + +const DisclaimerStruct = type({ + id: string(), + display_name: string(), + url: string(), +}); +const DisclaimersResponseStruct = array(DisclaimerStruct); + +const VendorSigningStruct = type({ + id: string(), + customer_id: string(), + content_id: optional(string()), +}); +const VendorSigningsResponseStruct = array(VendorSigningStruct); + +const CreateSessionResponseStruct = type({ sessionToken: string() }); + +// The live KYC API returns the flag under `required`; the service normalizes +// this to `kycRequired` for consumers (see `checkKycRequired`). +const KycRequiredResponseStruct = type({ required: boolean() }); + +// The session server's public key, in JWK-like form, returned inside an +// encryption schema from `POST /sessions`. `x` is the base64url public key +// used to wrap a secret for that schema. +const ServerPublicKeyStruct = type({ + kty: string(), + crv: string(), + x: string(), + kid: optional(string()), + alg: optional(string()), + use: optional(string()), +}); + +const EncryptionSchemaStruct = type({ + serverPublicKey: ServerPublicKeyStruct, + jwtChain: string(), +}); +export type EncryptionSchema = Infer; + +// A single Ed25519 (OKP) JWK. `type` (not `object`) keeps optional/extra JWK +// fields (`use`, `alg`) from failing validation. +const JwkStruct = type({ + kty: string(), + crv: string(), + x: string(), + kid: string(), +}); +const JwksResponseStruct = type({ keys: array(JwkStruct) }); +export type JwksResponse = Infer; + +const UkycSessionResponseStruct = type({ + sessionId: string(), + // Per-secret wrapping material so the client can seal the + // `data_encryption_key` and the `ukyc_capability_token` independently. + encryptionDataKey: EncryptionSchemaStruct, + ukycCapabilityToken: EncryptionSchemaStruct, +}); +export type UkycSessionResponse = Infer; + +const ApplicantAccessTokenResponseStruct = type({ + status: string(), + applicantAccessToken: string(), +}); +export type ApplicantAccessTokenResponse = Infer< + typeof ApplicantAccessTokenResponseStruct +>; + +const SessionStatusResponseStruct = type({ + finalStatus: string(), + statusMessage: optional(string()), + externalUserId: string(), + kycStatus: string(), + vendor: string(), + vendorStatus: string(), +}); + +// Vendor customer subset — `type` (not `object`) keeps extra vendor fields from +// failing validation while still requiring the fields the controller needs. +const VendorCustomerResponseStruct = type({ + id: string(), + email: string(), + status: string(), +}); +export type VendorCustomerResponse = Infer; + +const KYC_USER_STATUSES = [ + 'not-started', + 'pending', + 'need-more-information', + 'terminal-failure', + 'completed', +] as const; + +const KycUserStatusResponseStruct = type({ + status: enums([...KYC_USER_STATUSES]), + sumsubSessionId: optional(string()), + errorCode: optional(string()), +}); + +const ConsentDocumentStruct = type({ + key: string(), + version: string(), + title: string(), + url: string(), + consented: boolean(), +}); + +const SessionDisclaimersResponseStruct = type({ + idOS: array(ConsentDocumentStruct), + kycProvider: array(ConsentDocumentStruct), + credentialReusabilityConsentGiven: boolean(), +}); + +// === PARAM TYPES === + +export type CreateSessionParams = { + email: string; + termsAcceptedAt: string; + disclaimerIds: string[]; +}; + +export type CheckKycRequiredParams = { + /** + * Identity vendor to check. Defaults to `moonpay` for the existing + * Check/Auth path. + */ + vendor?: KycVendor; + /** + * MoonPay access token. Required when `vendor` is `moonpay` (or omitted). + */ + accessToken?: string; + /** + * ISO 3166-1 alpha-3 country code. Required when `vendor` is `moonpay`. + */ + country?: string; + capabilities?: { product: string }[]; +}; + +export type CreateVendorCustomerParams = { + vendor: KycVendor; + email: string; +}; + +export type SubmitVendorDisclaimersParams = { + /** Identity vendor whose T&Cs were accepted (currently `iron`). */ + vendor: KycVendor; + /** Disclaimer ids from {@link KycService.fetchDisclaimers}. */ + disclaimerIds: string[]; +}; + +export type FetchSessionDisclaimersParams = { + /** UKYC session id from {@link KycService.createUkycSession}. */ + sessionId: string; +}; + +export type SubmitSessionDisclaimersParams = { + /** UKYC session id from {@link KycService.createUkycSession}. */ + sessionId: string; + /** Consents to the idOS legal documents (`key`/`version` from the catalog). */ + idOS: KycConsentRecord[]; + /** + * Consents to the KYC provider (SumSub) legal documents (`key`/`version` + * from the catalog). + */ + kycProvider: KycConsentRecord[]; + /** Consent to reuse the user's existing idOS credentials. */ + credentialReusabilityConsentGiven: boolean; +}; + +export type CreateUkycSessionParams = { + jwtToken: string; + /** + * The client's per-session X25519 public key (unpadded base64url). Generated + * with the matching private key used later to wrap authorizations, so the + * session server can open those boxes. + */ + sessionClientPublicKey: string; + /** + * Country of residence in ISO 3166-1 alpha-3 format (e.g. `USA`, `GBR`). + */ + residenceCountry: string; + /** + * Identity vendor for the UKYC session. Defaults to `moonpay` for the + * existing Check/Auth flow. Pass a non-MoonPay vendor (e.g. `iron`) for + * the consents path (no MoonPay metadata required). + */ + vendor?: KycVendor; + /** + * Vendor-specific metadata. Required for MoonPay (`moonPayAccessToken` / + * `moonPayUserId`); optional / omitted for other vendors. + */ + vendorMetadata?: Record; +}; + +/** + * Encrypted capability authorization payload (base64url nonce + ciphertext) + * accepted by `POST /sessions/:sessionId/authorizations`. Produced by + * `wrapEncryptionKey` for both the `data_encryption_key` and the + * `ukyc_capability_token`. + */ +export type CapabilityAuthorization = { + nonce: string; + data: string; +}; + +export type SetAuthorizationsParams = { + sessionId: string; + wrappedEncryptionDataKey: CapabilityAuthorization; + wrappedUkycCapabilityToken: CapabilityAuthorization; +}; + +export type GetSessionStatusParams = { + sessionId: string; +}; + +// === SERVICE DEFINITION === + +/** + * `KycService` communicates with the Universal KYC (UKYC) backend to drive the + * identity + document-verification flow. It is stateless and platform-agnostic: + * HTTP is performed through the runtime's native `fetch` (or an injected + * `fetch` when provided), and the auth bearer token and geolocation come from + * other controllers via the messenger. + * + * It extends {@link BaseDataService}, so every request is routed through + * `fetchQuery`: it is wrapped in the shared service policy (retries, circuit + * breaker) and its result is exposed via the service's `QueryClient`. Read-only + * endpoints (`fetchDisclaimers`, `fetchIdosEnclaveJwks`, `fetchIdosRelayJwks`) are cached + * with a `staleTime`; vendor-disclaimer, session-scoped disclaimer, + * session-creating, and status-polling endpoints opt out of caching + * (`staleTime`/`gcTime` of `0`) so they never serve a stale result. + */ +export class KycService extends BaseDataService< + typeof serviceName, + KycServiceMessenger +> { + readonly #fetch: typeof fetch; + + readonly #baseUrl: string; + + readonly #idosEnclaveBaseUrl: string; + + readonly #idosRelayBaseUrl: string; + + /** + * Constructs a new KycService. + * + * @param options - The constructor options. + * @param options.messenger - The messenger suited for this service. + * @param options.fetch - A function used to make HTTP requests. Defaults to + * the runtime's native `fetch`. + * @param options.baseUrl - Base URL of the KYC API + * @param options.idosEnclaveBaseUrl - Base URL of the idOS enclave, from + * which the JWKS used to verify the `encryptionDataKey` schema's `jwtChain` + * is fetched. + * @param options.idosRelayBaseUrl - Base URL of the idOS relay, from which + * the JWKS used to verify the `ukycCapabilityToken` schema's `jwtChain` is + * fetched. + * @param options.queryClientConfig - Shared configuration for all queries + * exposed by the service. + * @param options.policyOptions - Options for the request service policy. + */ + constructor({ + messenger, + fetch: fetchFunction, + baseUrl, + idosEnclaveBaseUrl, + idosRelayBaseUrl, + queryClientConfig = {}, + policyOptions = {}, + }: KycServiceOptions) { + super({ + name: serviceName, + messenger, + queryClientConfig, + policyOptions, + }); + // Fall back to the runtime's native `fetch`, bound to `globalThis` so it + // can be invoked as a method of this instance without an illegal-invocation + // error on platforms that check the receiver. + if (fetchFunction) { + this.#fetch = fetchFunction; + } else if (typeof globalThis.fetch === 'function') { + this.#fetch = globalThis.fetch.bind(globalThis); + } else { + throw new Error( + 'KycService: fetch is not available globally and was not provided in options. Please inject a fetch implementation.', + ); + } + if (!baseUrl) { + throw new Error('KycService: baseUrl is required'); + } + this.#baseUrl = baseUrl; + this.#idosEnclaveBaseUrl = idosEnclaveBaseUrl ?? ''; + this.#idosRelayBaseUrl = idosRelayBaseUrl ?? ''; + this.messenger.registerMethodActionHandlers( + this, + MESSENGER_EXPOSED_METHODS, + ); + } + + /** + * Resolves the customer's country from the geolocation source and converts it + * to an ISO 3166-1 alpha-3 code. + * + * @returns The alpha-3 country code. + * @throws If the country cannot be determined or mapped. + */ + async getGeoCountry(): Promise { + const location = await this.messenger.call( + 'GeolocationController:getGeolocation', + ); + // Guard nullish/empty geolocation with the documented domain error rather + // than letting `assert(location, string())` surface a superstruct + // assertion error (which would change how the failure reads in + // `disclaimersError`). + const alpha2 = + typeof location === 'string' ? location.split('-')[0].toUpperCase() : ''; + if (!alpha2 || alpha2 === 'UNKNOWN') { + throw new Error( + `Unable to determine country from geolocation (got "${String( + location, + )}").`, + ); + } + const alpha3 = alpha2ToAlpha3(alpha2); + if (!alpha3) { + throw new Error( + `Unable to map country code "${alpha2}" to an ISO 3166-1 alpha-3 code.`, + ); + } + return alpha3; + } + + /** + * Fetches the disclaimers the customer must accept before a session is + * created. + * + * @param params - The parameters. + * @param params.vendor - Identity vendor. Defaults to `moonpay`. + * @param params.country - ISO 3166-1 alpha-3 country code. + * @returns The disclaimers. + */ + async fetchDisclaimers({ + vendor = 'moonpay', + country, + }: { + vendor?: KycVendor; + country: string; + }): Promise { + const url = new URL(`/vendors/${vendor}/disclaimers`, this.#baseUrl); + url.searchParams.set('country', country); + const data = await this.fetchQuery({ + queryKey: [`${this.name}:fetchDisclaimers`, vendor, country], + queryFn: async () => this.#requestJson(url, { method: 'GET' }), + staleTime: inMilliseconds(5, Duration.Minute), + }); + return this.#validateResponse( + data, + DisclaimersResponseStruct, + 'disclaimers', + ) as KycDisclaimer[]; + } + + /** + * Creates a vendor session via the UKYC backend. + * + * @param params - The session parameters. + * @returns The created session token. + */ + async createSession( + params: CreateSessionParams, + ): Promise> { + const url = new URL('/vendors/moonpay/sessions', this.#baseUrl); + const data = await this.fetchQuery({ + queryKey: [ + `${this.name}:createSession`, + params.email, + params.termsAcceptedAt, + params.disclaimerIds, + ], + queryFn: async () => + this.#requestJson(url, { + method: 'POST', + body: JSON.stringify(params), + }), + // A session-creating mutation must never serve a stale/cached result. + staleTime: 0, + gcTime: 0, + }); + return this.#validateResponse( + data, + CreateSessionResponseStruct, + 'sessions', + ); + } + + /** + * Checks whether KYC is required for the given vendor, country, and + * capabilities. + * + * @param params - The check parameters. + * @returns Whether KYC is required. + */ + async checkKycRequired( + params: CheckKycRequiredParams, + ): Promise<{ kycRequired: boolean }> { + const vendor = params.vendor ?? 'moonpay'; + const url = new URL(`/vendors/${vendor}/kyc-required`, this.#baseUrl); + const capabilities = params.capabilities ?? [{ product: 'ramps' }]; + const body = + vendor === 'moonpay' + ? { + accessToken: params.accessToken, + country: params.country, + capabilities, + } + : {}; + + // MoonPay requires accessToken and country; validate before making the request. + if (vendor === 'moonpay') { + if (!params.accessToken) { + throw new Error( + 'checkKycRequired: accessToken is required for vendor "moonpay".', + ); + } + if (!params.country) { + throw new Error( + 'checkKycRequired: country is required for vendor "moonpay".', + ); + } + } + + const data = await this.fetchQuery({ + queryKey: [ + `${this.name}:checkKycRequired`, + vendor, + params.accessToken ?? null, + params.country ?? null, + capabilities, + ], + queryFn: async () => + this.#requestJson(url, { + method: 'POST', + body: JSON.stringify(body), + }), + // The requirement can change server-side, so always re-check. + staleTime: 0, + gcTime: 0, + }); + const { required } = this.#validateResponse( + data, + KycRequiredResponseStruct, + 'kyc-required', + ); + return { kycRequired: required }; + } + + /** + * Creates (or resumes) an empty-shell customer for the authenticated + * canonical user on the given identity vendor. Must run before showing + * vendor T&C so the customer exists and resume logic can key off vendor + * status. + * + * @param params - The parameters. + * @param params.vendor - Identity vendor (e.g. `iron` for Money/VBA). + * @param params.email - Email associated with the customer. + * @returns The vendor customer record (subset validated for controller use). + */ + async createVendorCustomer( + params: CreateVendorCustomerParams, + ): Promise { + const url = new URL(`/vendors/${params.vendor}/customers`, this.#baseUrl); + const data = await this.fetchQuery({ + queryKey: [ + `${this.name}:createVendorCustomer`, + params.vendor, + params.email, + ], + queryFn: async () => + this.#requestJson(url, { + method: 'POST', + body: JSON.stringify({ email: params.email }), + }), + // Customer creation/resume must never serve a stale/cached result. + staleTime: 0, + gcTime: 0, + }); + return this.#validateResponse( + data, + VendorCustomerResponseStruct, + 'vendor customers', + ); + } + + /** + * Records vendor T&C acceptance (`POST /vendors/{vendor}/disclaimers`). + * For Iron this creates content signings from the disclaimer ids the + * customer accepted. Session-scoped idOS / KYC-provider consents are + * recorded separately via {@link submitSessionDisclaimers}. Retries re-POST + * the same ids, matching the legacy `POST /consents` signing step. + * + * @param params - The parameters. + * @param params.vendor - Identity vendor (e.g. `iron`). + * @param params.disclaimerIds - Accepted vendor T&C ids. + * @returns The vendor signing records. + */ + async submitVendorDisclaimers( + params: SubmitVendorDisclaimersParams, + ): Promise { + const url = new URL( + `/vendors/${encodeURIComponent(params.vendor)}/disclaimers`, + this.#baseUrl, + ); + const data = await this.fetchQuery({ + queryKey: [ + `${this.name}:submitVendorDisclaimers`, + params.vendor, + params.disclaimerIds, + ], + queryFn: async () => + this.#requestJson(url, { + method: 'POST', + body: JSON.stringify({ disclaimerIds: params.disclaimerIds }), + }), + staleTime: 0, + gcTime: 0, + }); + return this.#validateResponse( + data, + VendorSigningsResponseStruct, + 'vendor disclaimers', + ); + } + + /** + * Fetches the session-scoped idOS + KYC-provider disclaimer catalog + * (`GET /sessions/{sessionId}/disclaimers`). Requires an existing UKYC + * session; vendor T&Cs continue to come from {@link fetchDisclaimers}. + * + * @param params - The parameters. + * @param params.sessionId - The UKYC session id. + * @returns The catalog, including which documents are already consented. + */ + async fetchSessionDisclaimers( + params: FetchSessionDisclaimersParams, + ): Promise { + const url = new URL( + `/sessions/${encodeURIComponent(params.sessionId)}/disclaimers`, + this.#baseUrl, + ); + const data = await this.fetchQuery({ + queryKey: [`${this.name}:fetchSessionDisclaimers`, params.sessionId], + queryFn: async () => this.#requestJson(url, { method: 'GET' }), + // Consent state can change after a POST, so always re-fetch. + staleTime: 0, + gcTime: 0, + }); + return this.#validateResponse( + data, + SessionDisclaimersResponseStruct, + 'session disclaimers', + ); + } + + /** + * Records idOS + KYC-provider consents for a UKYC session + * (`POST /sessions/{sessionId}/disclaimers`). `key`/`version` pairs must + * match the current catalog from {@link fetchSessionDisclaimers}. A 409 + * means those document versions were already recorded for the session. + * + * @param params - The consent parameters. + * @returns The updated catalog after recording. + */ + async submitSessionDisclaimers( + params: SubmitSessionDisclaimersParams, + ): Promise { + const url = new URL( + `/sessions/${encodeURIComponent(params.sessionId)}/disclaimers`, + this.#baseUrl, + ); + const data = await this.fetchQuery({ + queryKey: [ + `${this.name}:submitSessionDisclaimers`, + params.sessionId, + params.idOS, + params.kycProvider, + params.credentialReusabilityConsentGiven, + ], + queryFn: async () => + this.#requestJson(url, { + method: 'POST', + body: JSON.stringify({ + idOS: params.idOS, + kycProvider: params.kycProvider, + credentialReusabilityConsentGiven: + params.credentialReusabilityConsentGiven, + }), + }), + staleTime: 0, + gcTime: 0, + }); + return this.#validateResponse( + data, + SessionDisclaimersResponseStruct, + 'session disclaimers', + ); + } + + /** + * Fetches the user-keyed simplified KYC status used by Money toast / banner + * surfaces (`GET /kyc/status`). + * + * @returns The simplified status payload. + */ + async fetchKycStatus(): Promise { + const url = new URL('/kyc/status', this.#baseUrl); + const data = await this.fetchQuery({ + queryKey: [`${this.name}:fetchKycStatus`], + queryFn: async () => this.#requestJson(url, { method: 'GET' }), + // Status is polled for toast flips, so it must always be fresh. + staleTime: 0, + gcTime: 0, + }); + return this.#validateResponse( + data, + KycUserStatusResponseStruct, + 'kyc status', + ); + } + + /** + * Fetches a well-known JWKS from `baseUrl`, caching the result for an hour. + * + * @param baseUrl - Host base URL that serves `/.well-known/jwks.json`. + * @param queryName - Cache query-key segment. + * @param responseLabel - Label used in malformed-response errors. + * @param missingConfigMessage - Error thrown when `baseUrl` is empty. + * @returns The JWKS keys. + */ + async #fetchWellKnownJwks( + baseUrl: string, + queryName: string, + responseLabel: string, + missingConfigMessage: string, + ): Promise { + if (!baseUrl) { + throw new Error(missingConfigMessage); + } + const url = new URL(UKYC_JWKS_PATH, baseUrl); + const data = await this.fetchQuery({ + queryKey: [`${this.name}:${queryName}`, baseUrl], + queryFn: async () => + this.#requestJson(url, { method: 'GET' }, { authenticated: false }), + staleTime: inMilliseconds(1, Duration.Hour), + }); + return this.#validateResponse(data, JwksResponseStruct, responseLabel); + } + + /** + * Fetches the idOS enclave JWKS used to verify the + * `encryptionDataKey` schema's `jwtChain` from + * {@link KycService.createUkycSession}. + * + * This is an unauthenticated request to a well-known path on the idOS enclave + * host, distinct from the UKYC base URL. + * + * @returns The JWKS keys. + */ + async fetchIdosEnclaveJwks(): Promise { + return this.#fetchWellKnownJwks( + this.#idosEnclaveBaseUrl, + 'fetchIdosEnclaveJwks', + 'idOS enclave JWKS', + 'KycService: idosEnclaveBaseUrl is not configured; cannot fetch JWKS to verify the encryptionDataKey schema.', + ); + } + + /** + * Fetches the idOS relay JWKS used to verify the `ukycCapabilityToken` + * schema's `jwtChain` from {@link KycService.createUkycSession}. + * + * This is an unauthenticated request to a well-known path on the idOS relay + * host, distinct from both the UKYC base URL and the idOS enclave. + * + * @returns The JWKS keys. + */ + async fetchIdosRelayJwks(): Promise { + return this.#fetchWellKnownJwks( + this.#idosRelayBaseUrl, + 'fetchIdosRelayJwks', + 'idOS relay JWKS', + 'KycService: idosRelayBaseUrl is not configured; cannot fetch JWKS to verify the ukycCapabilityToken schema.', + ); + } + + /** + * Creates a UKYC session for the SumSub document-verification sub-flow. + * + * The client registers its per-session X25519 public key so the server can + * later open boxes sealed with the matching private key, and supplies the + * customer's ISO 3166-1 alpha-3 country of residence. The response + * carries per-secret encryption schemas (`encryptionDataKey` and + * `ukycCapabilityToken`) so the client can wrap the `data_encryption_key` and + * the read-only `ukyc_capability_token` and submit them via + * {@link KycService.setAuthorizations}. + * + * @param params - The session parameters. + * @returns The UKYC session id and encryption schemas. + */ + async createUkycSession( + params: CreateUkycSessionParams, + ): Promise { + const url = new URL('/sessions', this.#baseUrl); + const data = await this.fetchQuery({ + queryKey: [`${this.name}:createUkycSession`, params.jwtToken], + queryFn: async () => + this.#requestJson(url, { + method: 'POST', + body: JSON.stringify({ + vendorId: params.vendor ?? 'moonpay', + vendorUserId: 'mockedId', + jwtToken: params.jwtToken, + sessionClientPublicKey: params.sessionClientPublicKey, + residenceCountry: params.residenceCountry, + vendorMetadata: params.vendorMetadata ?? {}, + }), + }), + // A session-creating mutation must never serve a stale/cached result. + staleTime: 0, + gcTime: 0, + }); + return this.#validateResponse( + data, + UkycSessionResponseStruct, + 'UKYC sessions', + ); + } + + /** + * Submits the wrapped `data_encryption_key` and wrapped + * `ukyc_capability_token` for a UKYC session. Both secrets are sealed with + * `wrapEncryptionKey` against the encryption schemas returned by + * {@link KycService.createUkycSession}. + * + * @param params - The wrapped authorizations. + * @returns The session status after the authorizations are applied. + */ + async setAuthorizations( + params: SetAuthorizationsParams, + ): Promise { + const url = new URL( + `/sessions/${encodeURIComponent(params.sessionId)}/authorizations`, + this.#baseUrl, + ); + const data = await this.fetchQuery({ + queryKey: [`${this.name}:setAuthorizations`, params.sessionId], + queryFn: async () => + this.#requestJson(url, { + method: 'POST', + body: JSON.stringify({ + wrappedEncryptionDataKey: params.wrappedEncryptionDataKey, + wrappedUkycCapabilityToken: params.wrappedUkycCapabilityToken, + }), + }), + staleTime: 0, + gcTime: 0, + }); + return this.#validateResponse( + data, + SessionStatusResponseStruct, + 'authorizations', + ); + } + + /** + * Creates (or refreshes) the SumSub verification journey for a UKYC session, + * returning the applicant access token used to launch the SDK. + * + * @param sessionId - The UKYC session id from `createUkycSession`. + * @returns The applicant access token and status. + */ + async createJourney( + sessionId: string, + ): Promise { + const url = new URL( + `/sessions/${encodeURIComponent(sessionId)}/journey`, + this.#baseUrl, + ); + const data = await this.fetchQuery({ + queryKey: [`${this.name}:createJourney`, sessionId], + queryFn: async () => this.#requestJson(url, { method: 'POST' }), + // Journeys are (re)created on demand; do not reuse a cached token. + staleTime: 0, + gcTime: 0, + }); + return this.#validateResponse( + data, + ApplicantAccessTokenResponseStruct, + 'journey', + ); + } + + /** + * Fetches the current status of a UKYC session. Polled after the SumSub SDK + * completes to determine the final verification decision. + * + * @param params - The parameters. + * @param params.sessionId - The UKYC session id. + * @returns The session status. + */ + async getSessionStatus( + params: GetSessionStatusParams, + ): Promise { + const url = new URL( + `/sessions/${encodeURIComponent(params.sessionId)}/status`, + this.#baseUrl, + ); + const data = await this.fetchQuery({ + queryKey: [`${this.name}:getSessionStatus`, params.sessionId], + queryFn: async () => this.#requestJson(url, { method: 'GET' }), + // Status is polled for a terminal decision, so it must always be fresh. + staleTime: 0, + gcTime: 0, + }); + return this.#validateResponse( + data, + SessionStatusResponseStruct, + 'session status', + ); + } + + /** + * Validates a parsed API response against a superstruct schema, throwing a + * descriptive error when the response does not match. + * + * Unlike a bare `Struct.is` check, this surfaces exactly which field was + * missing or had the wrong type, which is essential for diagnosing shape + * mismatches between the client and the live API. + * + * @param data - The parsed response body. + * @param struct - The superstruct schema the body is expected to satisfy. + * @param apiName - A human-readable name of the API, used in the error message. + * @returns The validated, typed response. + * @throws If `data` does not match `struct`. + */ + #validateResponse( + data: unknown, + struct: Struct, + apiName: string, + ): Type { + try { + assert(data, struct); + return data; + } catch (error) { + const detail = + error instanceof StructError + ? `${error.message} (received: ${JSON.stringify(data)})` + : // `assert` only ever throws `StructError` for the plain structs used + // here, so this is a defensive fallback that is not exercised. + /* istanbul ignore next */ + String(error); + throw new Error( + `Malformed response received from ${apiName} API: ${detail}`, + ); + } + } + + /** + * Performs a single JSON request. + * + * This is meant to be used as the `queryFn` for {@link fetchQuery}, which + * wraps it in the shared service policy (retries, circuit breaker). Requests + * are authenticated with the wallet bearer token by default; pass + * `{ authenticated: false }` for calls to services that do not expect it + * (e.g. the idOS enclave or idOS relay JWKS endpoints). + * + * @param url - The request URL. + * @param init - The request init (method, body). + * @param options - Request options. + * @param options.authenticated - Whether to attach the bearer token. Defaults + * to `true`. + * @returns The parsed JSON response. + */ + async #requestJson( + url: URL, + init: RequestInit, + options: { authenticated?: boolean } = {}, + ): Promise { + const { authenticated = true } = options; + + const headers: Record = {}; + + // Only advertise a JSON body when one is actually sent; bodyless requests + // (e.g. `createJourney`) must not carry a `Content-Type`. + if (init.body !== undefined && init.body !== null) { + headers['Content-Type'] = 'application/json'; + } + + if (authenticated) { + const bearerToken = await this.messenger.call( + 'AuthenticationController:getBearerToken', + ); + if (!bearerToken) { + throw new Error( + 'Unable to obtain an authentication bearer token — is the wallet signed in?', + ); + } + assert(bearerToken, string()); + headers.Authorization = `Bearer ${bearerToken}`; + } + + const response = await this.#fetch(url.toString(), { + ...init, + headers, + }); + if (!response.ok) { + let detail = ''; + try { + const errorBody: unknown = await response.json(); + if (errorBody && typeof errorBody === 'object') { + const record = errorBody as Record; + if (typeof record.message === 'string') { + detail = record.message; + } else if (typeof record.error === 'string') { + detail = record.error; + } + } + } catch { + // Ignore body parse failures; status alone is still useful. + } + throw new HttpError( + response.status, + `Fetching '${url.toString()}' failed with status '${response.status}'${ + detail ? `: ${detail}` : '' + }`, + ); + } + + // DELETE (and similar) endpoints return 204 No Content. + if (response.status === 204) { + return null; + } + + return (await response.json()) as Json; + } +} diff --git a/packages/kyc-controller/src/countryCodes.test.ts b/packages/kyc-controller/src/countryCodes.test.ts new file mode 100644 index 00000000000..9188868966d --- /dev/null +++ b/packages/kyc-controller/src/countryCodes.test.ts @@ -0,0 +1,19 @@ +import { ALPHA2_TO_ALPHA3, alpha2ToAlpha3 } from './countryCodes.js'; + +describe('countryCodes', () => { + it('exposes the alpha-2 to alpha-3 map', () => { + expect(ALPHA2_TO_ALPHA3.US).toBe('USA'); + }); + + it('maps a known uppercase alpha-2 code', () => { + expect(alpha2ToAlpha3('GB')).toBe('GBR'); + }); + + it('is case-insensitive', () => { + expect(alpha2ToAlpha3('fr')).toBe('FRA'); + }); + + it('returns undefined for an unknown code', () => { + expect(alpha2ToAlpha3('ZZ')).toBeUndefined(); + }); +}); diff --git a/packages/kyc-controller/src/countryCodes.ts b/packages/kyc-controller/src/countryCodes.ts new file mode 100644 index 00000000000..a5712d24109 --- /dev/null +++ b/packages/kyc-controller/src/countryCodes.ts @@ -0,0 +1,270 @@ +/** + * ISO 3166-1 alpha-2 to alpha-3 country code mapping. + * + * The geolocation source returns ISO 3166-2 codes whose leading segment is an + * alpha-2 country code (e.g. "US", "US-NY"). The identity vendor APIs + * (disclaimers, kyc-required) expect alpha-3 codes (e.g. "USA"). This map + * bridges the two. + */ +export const ALPHA2_TO_ALPHA3: Record = { + AD: 'AND', + AE: 'ARE', + AF: 'AFG', + AG: 'ATG', + AI: 'AIA', + AL: 'ALB', + AM: 'ARM', + AO: 'AGO', + AQ: 'ATA', + AR: 'ARG', + AS: 'ASM', + AT: 'AUT', + AU: 'AUS', + AW: 'ABW', + AX: 'ALA', + AZ: 'AZE', + BA: 'BIH', + BB: 'BRB', + BD: 'BGD', + BE: 'BEL', + BF: 'BFA', + BG: 'BGR', + BH: 'BHR', + BI: 'BDI', + BJ: 'BEN', + BL: 'BLM', + BM: 'BMU', + BN: 'BRN', + BO: 'BOL', + BQ: 'BES', + BR: 'BRA', + BS: 'BHS', + BT: 'BTN', + BV: 'BVT', + BW: 'BWA', + BY: 'BLR', + BZ: 'BLZ', + CA: 'CAN', + CC: 'CCK', + CD: 'COD', + CF: 'CAF', + CG: 'COG', + CH: 'CHE', + CI: 'CIV', + CK: 'COK', + CL: 'CHL', + CM: 'CMR', + CN: 'CHN', + CO: 'COL', + CR: 'CRI', + CU: 'CUB', + CV: 'CPV', + CW: 'CUW', + CX: 'CXR', + CY: 'CYP', + CZ: 'CZE', + DE: 'DEU', + DJ: 'DJI', + DK: 'DNK', + DM: 'DMA', + DO: 'DOM', + DZ: 'DZA', + EC: 'ECU', + EE: 'EST', + EG: 'EGY', + EH: 'ESH', + ER: 'ERI', + ES: 'ESP', + ET: 'ETH', + FI: 'FIN', + FJ: 'FJI', + FK: 'FLK', + FM: 'FSM', + FO: 'FRO', + FR: 'FRA', + GA: 'GAB', + GB: 'GBR', + GD: 'GRD', + GE: 'GEO', + GF: 'GUF', + GG: 'GGY', + GH: 'GHA', + GI: 'GIB', + GL: 'GRL', + GM: 'GMB', + GN: 'GIN', + GP: 'GLP', + GQ: 'GNQ', + GR: 'GRC', + GS: 'SGS', + GT: 'GTM', + GU: 'GUM', + GW: 'GNB', + GY: 'GUY', + HK: 'HKG', + HM: 'HMD', + HN: 'HND', + HR: 'HRV', + HT: 'HTI', + HU: 'HUN', + ID: 'IDN', + IE: 'IRL', + IL: 'ISR', + IM: 'IMN', + IN: 'IND', + IO: 'IOT', + IQ: 'IRQ', + IR: 'IRN', + IS: 'ISL', + IT: 'ITA', + JE: 'JEY', + JM: 'JAM', + JO: 'JOR', + JP: 'JPN', + KE: 'KEN', + KG: 'KGZ', + KH: 'KHM', + KI: 'KIR', + KM: 'COM', + KN: 'KNA', + KP: 'PRK', + KR: 'KOR', + KW: 'KWT', + KY: 'CYM', + KZ: 'KAZ', + LA: 'LAO', + LB: 'LBN', + LC: 'LCA', + LI: 'LIE', + LK: 'LKA', + LR: 'LBR', + LS: 'LSO', + LT: 'LTU', + LU: 'LUX', + LV: 'LVA', + LY: 'LBY', + MA: 'MAR', + MC: 'MCO', + MD: 'MDA', + ME: 'MNE', + MF: 'MAF', + MG: 'MDG', + MH: 'MHL', + MK: 'MKD', + ML: 'MLI', + MM: 'MMR', + MN: 'MNG', + MO: 'MAC', + MP: 'MNP', + MQ: 'MTQ', + MR: 'MRT', + MS: 'MSR', + MT: 'MLT', + MU: 'MUS', + MV: 'MDV', + MW: 'MWI', + MX: 'MEX', + MY: 'MYS', + MZ: 'MOZ', + NA: 'NAM', + NC: 'NCL', + NE: 'NER', + NF: 'NFK', + NG: 'NGA', + NI: 'NIC', + NL: 'NLD', + NO: 'NOR', + NP: 'NPL', + NR: 'NRU', + NU: 'NIU', + NZ: 'NZL', + OM: 'OMN', + PA: 'PAN', + PE: 'PER', + PF: 'PYF', + PG: 'PNG', + PH: 'PHL', + PK: 'PAK', + PL: 'POL', + PM: 'SPM', + PN: 'PCN', + PR: 'PRI', + PS: 'PSE', + PT: 'PRT', + PW: 'PLW', + PY: 'PRY', + QA: 'QAT', + RE: 'REU', + RO: 'ROU', + RS: 'SRB', + RU: 'RUS', + RW: 'RWA', + SA: 'SAU', + SB: 'SLB', + SC: 'SYC', + SD: 'SDN', + SE: 'SWE', + SG: 'SGP', + SH: 'SHN', + SI: 'SVN', + SJ: 'SJM', + SK: 'SVK', + SL: 'SLE', + SM: 'SMR', + SN: 'SEN', + SO: 'SOM', + SR: 'SUR', + SS: 'SSD', + ST: 'STP', + SV: 'SLV', + SX: 'SXM', + SY: 'SYR', + SZ: 'SWZ', + TC: 'TCA', + TD: 'TCD', + TF: 'ATF', + TG: 'TGO', + TH: 'THA', + TJ: 'TJK', + TK: 'TKL', + TL: 'TLS', + TM: 'TKM', + TN: 'TUN', + TO: 'TON', + TR: 'TUR', + TT: 'TTO', + TV: 'TUV', + TW: 'TWN', + TZ: 'TZA', + UA: 'UKR', + UG: 'UGA', + UM: 'UMI', + US: 'USA', + UY: 'URY', + UZ: 'UZB', + VA: 'VAT', + VC: 'VCT', + VE: 'VEN', + VG: 'VGB', + VI: 'VIR', + VN: 'VNM', + VU: 'VUT', + WF: 'WLF', + WS: 'WSM', + YE: 'YEM', + YT: 'MYT', + ZA: 'ZAF', + ZM: 'ZMB', + ZW: 'ZWE', +}; + +/** + * Converts an ISO 3166-1 alpha-2 country code (e.g. "US") to its alpha-3 + * equivalent (e.g. "USA"). Returns `undefined` for unknown codes. + * + * @param alpha2 - The ISO 3166-1 alpha-2 country code. + * @returns The alpha-3 code, or `undefined` if the input is not recognized. + */ +export function alpha2ToAlpha3(alpha2: string): string | undefined { + return ALPHA2_TO_ALPHA3[alpha2.toUpperCase()]; +} diff --git a/packages/kyc-controller/src/crypto.test.ts b/packages/kyc-controller/src/crypto.test.ts new file mode 100644 index 00000000000..45bfc7b190b --- /dev/null +++ b/packages/kyc-controller/src/crypto.test.ts @@ -0,0 +1,204 @@ +import { gcm } from '@noble/ciphers/aes'; +import { x25519 } from '@noble/curves/ed25519'; +import { hkdf } from '@noble/hashes/hkdf'; +import { sha256 } from '@noble/hashes/sha2'; +import { bytesToHex, utf8ToBytes } from '@noble/hashes/utils'; +import { base64 } from '@scure/base'; + +import type { EncryptedCredentialsEnvelope } from './crypto.js'; +import { decryptCredentials, generateKeyPair } from './crypto.js'; + +/** + * Builds an encrypted-credentials envelope that `decryptCredentials` can + * reverse with `ourPublicKey`'s matching private key. + * + * @param ourPublicKey - The recipient's X25519 public key. + * @param credentials - The plaintext credentials to encrypt. + * @param options - Encoding options. + * @param options.encoding - `'hex'` (default) or `'base64'`. + * @param options.ivLength - IV length in bytes (default 12). + * @param options.useNonceField - Emit `nonce` instead of `iv`. + * @returns The encrypted envelope. + */ +function makeEnvelope( + ourPublicKey: Uint8Array, + credentials: Record, + { + encoding = 'hex' as 'hex' | 'base64', + ivLength = 12, + useNonceField = false, + } = {}, +): EncryptedCredentialsEnvelope { + const ephemeralPrivate = x25519.utils.randomSecretKey(); + const ephemeralPublic = x25519.getPublicKey(ephemeralPrivate); + const shared = x25519.getSharedSecret(ephemeralPrivate, ourPublicKey); + const key = hkdf(sha256, shared, undefined, undefined, 32); + const iv = new Uint8Array(ivLength).fill(7); + const ciphertext = gcm(key, iv).encrypt( + utf8ToBytes(JSON.stringify(credentials)), + ); + const encode = (bytes: Uint8Array): string => + encoding === 'hex' ? bytesToHex(bytes) : base64.encode(bytes); + const envelope: EncryptedCredentialsEnvelope = { + ephemeralPublicKey: encode(ephemeralPublic), + ciphertext: encode(ciphertext), + }; + if (useNonceField) { + envelope.nonce = encode(iv); + } else { + envelope.iv = encode(iv); + } + return envelope; +} + +describe('crypto', () => { + describe('generateKeyPair', () => { + it('produces a 32-byte keypair with a hex public key', () => { + const keypair = generateKeyPair(); + expect(keypair.privateKey).toHaveLength(32); + expect(keypair.publicKey).toHaveLength(32); + expect(keypair.publicKeyHex).toMatch(/^[0-9a-f]{64}$/u); + }); + }); + + describe('decryptCredentials', () => { + it('decrypts a hex-encoded envelope object', () => { + const keypair = generateKeyPair(); + const envelope = makeEnvelope(keypair.publicKey, { + accessToken: 'access-1', + }); + + const { credentials, method } = decryptCredentials( + envelope, + keypair.privateKey, + ); + + expect(credentials.accessToken).toBe('access-1'); + expect(method).toBe('aes-256-gcm/hkdf-sha256'); + }); + + it('decrypts a base64-encoded envelope', () => { + const keypair = generateKeyPair(); + const envelope = makeEnvelope( + keypair.publicKey, + { clientToken: 'client-1' }, + { encoding: 'base64' }, + ); + + const { credentials } = decryptCredentials(envelope, keypair.privateKey); + + expect(credentials.clientToken).toBe('client-1'); + }); + + it('honors an explicit base64 encoding hint', () => { + const keypair = generateKeyPair(); + const envelope = makeEnvelope( + keypair.publicKey, + { accessToken: 'access-2' }, + { encoding: 'base64' }, + ); + envelope.encoding = 'base64'; + + const { credentials } = decryptCredentials(envelope, keypair.privateKey); + + expect(credentials.accessToken).toBe('access-2'); + }); + + it('accepts a `nonce` field as an alias for `iv`', () => { + const keypair = generateKeyPair(); + const envelope = makeEnvelope( + keypair.publicKey, + { accessToken: 'access-3' }, + { useNonceField: true }, + ); + + const { credentials } = decryptCredentials(envelope, keypair.privateKey); + + expect(credentials.accessToken).toBe('access-3'); + }); + + it('decrypts an envelope delivered as a JSON string', () => { + const keypair = generateKeyPair(); + const envelope = makeEnvelope(keypair.publicKey, { + accessToken: 'access-4', + }); + + const { credentials } = decryptCredentials( + JSON.stringify(envelope), + keypair.privateKey, + ); + + expect(credentials.accessToken).toBe('access-4'); + }); + + it('decrypts an envelope delivered as base64(JSON)', () => { + const keypair = generateKeyPair(); + const envelope = makeEnvelope(keypair.publicKey, { + accessToken: 'access-5', + }); + const base64Json = base64.encode(utf8ToBytes(JSON.stringify(envelope))); + + const { credentials } = decryptCredentials( + base64Json, + keypair.privateKey, + ); + + expect(credentials.accessToken).toBe('access-5'); + }); + + it('throws for a JSON string that fails to parse', () => { + const keypair = generateKeyPair(); + expect(() => + decryptCredentials('{ not valid json', keypair.privateKey), + ).toThrow(/looked like JSON but failed to parse/u); + }); + + it('throws for base64 that decodes to non-JSON starting with a brace', () => { + const keypair = generateKeyPair(); + const bad = base64.encode(utf8ToBytes('{ still not json')); + expect(() => decryptCredentials(bad, keypair.privateKey)).toThrow( + /base64-decoded to non-JSON/u, + ); + }); + + it('throws for an opaque string that is neither JSON nor base64(JSON)', () => { + const keypair = generateKeyPair(); + const bad = base64.encode(utf8ToBytes('hello world')); + expect(() => decryptCredentials(bad, keypair.privateKey)).toThrow( + /opaque string/u, + ); + }); + + it('throws for an object missing required fields', () => { + const keypair = generateKeyPair(); + expect(() => + decryptCredentials( + { ephemeralPublicKey: 'aa' } as EncryptedCredentialsEnvelope, + keypair.privateKey, + ), + ).toThrow(/missing required fields/u); + }); + + it('reports the value type for a non-object input', () => { + const keypair = generateKeyPair(); + expect(() => + decryptCredentials( + 123 as unknown as EncryptedCredentialsEnvelope, + keypair.privateKey, + ), + ).toThrow(/Got: number/u); + }); + + it('throws when the IV length is not 12 bytes', () => { + const keypair = generateKeyPair(); + const envelope = makeEnvelope( + keypair.publicKey, + { accessToken: 'x' }, + { ivLength: 16 }, + ); + expect(() => decryptCredentials(envelope, keypair.privateKey)).toThrow( + /Unexpected IV length 16/u, + ); + }); + }); +}); diff --git a/packages/kyc-controller/src/crypto.ts b/packages/kyc-controller/src/crypto.ts new file mode 100644 index 00000000000..50012f016d3 --- /dev/null +++ b/packages/kyc-controller/src/crypto.ts @@ -0,0 +1,238 @@ +/** + * Check / Auth frame key exchange and credential decryption. + * + * The identity vendor's Check and Auth frames return encrypted credentials. + * The confirmed protocol is X25519 ECDH + AES-256-GCM (an "ECDH-ES" pattern + * signalled by a 12-byte IV): + * + * 1. Client generates an X25519 keypair, sends `publicKey` (hex) into the + * frame as a URL param. + * 2. Frame generates its own ephemeral X25519 keypair and encrypts the + * credentials, returning `{ ephemeralPublicKey, iv, ciphertext }`. + * 3. Client reverses: + * shared = X25519(ourPrivate, theirEphemeralPublic) + * key = HKDF-SHA256(shared, salt=none, info=none, 32 bytes) + * plain = AES-256-GCM.decrypt(key, iv, ciphertext || 16-byte tag) + * + * This module is platform-agnostic: it uses `@noble/*` and `@metamask/utils` + * (via the shared encoding helpers) and avoids `Buffer` / `atob` so it runs + * unchanged on mobile, extension, and web. + */ + +import { gcm } from '@noble/ciphers/aes'; +import { x25519 } from '@noble/curves/ed25519'; +import { hkdf } from '@noble/hashes/hkdf'; +import { sha256 } from '@noble/hashes/sha2'; +import { bytesToHex, hexToBytes } from '@noble/hashes/utils'; + +import { base64UrlToBytes } from './encoding.js'; + +/** + * An X25519 keypair used for the Check/Auth frame key exchange. + */ +export type X25519KeyPair = { + /** Raw 32-byte X25519 private (scalar) key. Never leaves the device. */ + privateKey: Uint8Array; + /** Raw 32-byte X25519 public key. */ + publicKey: Uint8Array; + /** Hex-encoded public key, ready to drop into a Check/Auth frame URL. */ + publicKeyHex: string; +}; + +/** + * The encrypted-credentials envelope returned by the Check/Auth frames. Binary + * fields may be hex or base64; the IV field may be named `iv` or `nonce`. + */ +export type EncryptedCredentialsEnvelope = { + /** Ephemeral public key produced by the frame for this exchange (32 bytes). */ + ephemeralPublicKey: string; + /** Per-message IV. May be provided as `iv` or `nonce`. */ + iv?: string; + nonce?: string; + /** Ciphertext (plaintext + 16-byte GCM auth tag). */ + ciphertext: string; + /** Optional explicit encoding hint. Defaults to auto-detect. */ + encoding?: 'hex' | 'base64'; +}; + +/** + * Decrypted Check/Auth frame credentials. + * + * - `accessToken` is the Bearer token for the identity API. + * - `clientToken` is the short-lived token consumed by the Auth frame when the + * Check frame returns `connectionRequired`. + */ +export type DecryptedCredentials = { + accessToken?: string; + clientToken?: string; + [key: string]: unknown; +}; + +/** + * Result of a successful decryption — the credentials plus the `method` that + * authenticated. + */ +export type DecryptResult = { + credentials: DecryptedCredentials; + method: string; +}; + +/** + * Generate a fresh X25519 keypair. The private key never leaves the device; + * only `publicKeyHex` is sent to the vendor via the frame URL. + * + * @returns The generated keypair. + */ +export function generateKeyPair(): X25519KeyPair { + const privateKey = x25519.utils.randomSecretKey(); + const publicKey = x25519.getPublicKey(privateKey); + return { + privateKey, + publicKey, + publicKeyHex: bytesToHex(publicKey), + }; +} + +/** + * Decode a binary envelope field that may be hex or base64. + * + * @param value - The encoded field. + * @param encoding - Optional explicit encoding; auto-detected when omitted. + * @returns The decoded bytes. + */ +function decodeBinary(value: string, encoding?: 'hex' | 'base64'): Uint8Array { + const isHex = + encoding === 'hex' || + (encoding === undefined && /^[0-9a-fA-F]+$/u.test(value)); + if (isHex) { + return hexToBytes(value); + } + return base64UrlToBytes(value); +} + +/** + * Coerce the `credentials` field into a structured envelope. The frame may + * deliver it as an object, a JSON string, or base64(JSON). + * + * @param input - The raw credentials value. + * @returns The normalized envelope. + * @throws If the value is not a structured or base64(JSON) envelope, or is + * missing required fields. + */ +function normalizeEnvelope( + input: EncryptedCredentialsEnvelope | string, +): EncryptedCredentialsEnvelope { + let value: unknown = input; + + if (typeof value === 'string') { + const trimmed = value.trim(); + if (trimmed.startsWith('{')) { + try { + value = JSON.parse(trimmed); + } catch { + throw new Error( + `credentials looked like JSON but failed to parse (preview: "${trimmed.slice( + 0, + 64, + )}").`, + ); + } + } else { + let decodedText: string | null = null; + try { + decodedText = new TextDecoder().decode(base64UrlToBytes(trimmed)); + } catch { + decodedText = null; + } + const decodedTrimmed = decodedText?.trim(); + if (decodedTrimmed?.startsWith('{')) { + try { + value = JSON.parse(decodedTrimmed); + } catch { + throw new Error( + `credentials base64-decoded to non-JSON (preview: "${decodedTrimmed.slice( + 0, + 64, + )}").`, + ); + } + } else { + throw new Error( + `credentials is an opaque string, not a structured or base64(JSON) envelope (preview: "${trimmed.slice( + 0, + 64, + )}").`, + ); + } + } + } + + const env = value as Partial; + if (!env.ephemeralPublicKey || !(env.iv ?? env.nonce) || !env.ciphertext) { + const keys = + value && typeof value === 'object' + ? Object.keys(value).join(', ') + : typeof value; + throw new Error( + `credentials envelope missing required fields (ephemeralPublicKey/iv/ciphertext). Got: ${keys}`, + ); + } + return env as EncryptedCredentialsEnvelope; +} + +/** + * X25519 ECDH to AES-256-GCM decryption. + * + * @param theirPublicKey - The frame's ephemeral public key. + * @param iv - The 12-byte GCM IV. + * @param ciphertext - The ciphertext including the 16-byte auth tag. + * @param ourPrivateKey - Our X25519 private key. + * @returns The decrypted credentials and method. + */ +function aesGcmDecrypt( + theirPublicKey: Uint8Array, + iv: Uint8Array, + ciphertext: Uint8Array, + ourPrivateKey: Uint8Array, +): DecryptResult { + const shared = x25519.getSharedSecret(ourPrivateKey, theirPublicKey); + const key = hkdf(sha256, shared, undefined, undefined, 32); + const plaintext = gcm(key, iv).decrypt(ciphertext); + const text = new TextDecoder().decode(plaintext); + return { + credentials: JSON.parse(text) as DecryptedCredentials, + method: 'aes-256-gcm/hkdf-sha256', + }; +} + +/** + * Decrypt a Check/Auth frame credentials envelope using our X25519 private + * key. + * + * @param rawEnvelope - The raw envelope (object, JSON string, or base64(JSON)). + * @param ourPrivateKey - Our X25519 private key. + * @returns The parsed credentials and the method that authenticated. + * @throws If the envelope is malformed or the IV length is not 12 bytes. + */ +export function decryptCredentials( + rawEnvelope: EncryptedCredentialsEnvelope | string, + ourPrivateKey: Uint8Array, +): DecryptResult { + const envelope = normalizeEnvelope(rawEnvelope); + const theirPublicKey = decodeBinary( + envelope.ephemeralPublicKey, + envelope.encoding, + ); + // `normalizeEnvelope` guarantees one of `iv` / `nonce` is present. + const ivField = (envelope.iv ?? envelope.nonce) as string; + const iv = decodeBinary(ivField, envelope.encoding); + const ciphertext = decodeBinary(envelope.ciphertext, envelope.encoding); + + if (iv.length !== 12) { + throw new Error( + `Unexpected IV length ${iv.length} (expected 12 for AES-256-GCM).`, + ); + } + + return aesGcmDecrypt(theirPublicKey, iv, ciphertext, ourPrivateKey); +} diff --git a/packages/kyc-controller/src/encoding.test.ts b/packages/kyc-controller/src/encoding.test.ts new file mode 100644 index 00000000000..bd21da29df3 --- /dev/null +++ b/packages/kyc-controller/src/encoding.test.ts @@ -0,0 +1,32 @@ +import { areUint8ArraysEqual } from '@metamask/utils'; + +import { base64UrlToBytes, toBase64Url } from './encoding.js'; + +describe('encoding', () => { + describe('toBase64Url', () => { + it('produces unpadded, url-safe base64', () => { + // 0xFB 0xFF encodes to "+/8=" in standard base64, exercising both the + // `+`->`-`, `/`->`_`, and padding-stripping substitutions. + const encoded = toBase64Url(new Uint8Array([0xfb, 0xff])); + + expect(encoded).toBe('-_8'); + expect(encoded).not.toContain('='); + }); + }); + + describe('base64UrlToBytes', () => { + it('round-trips arbitrary bytes through toBase64Url', () => { + const bytes = new Uint8Array([0x00, 0x01, 0xfb, 0xff, 0x10, 0x2a, 0x7f]); + + const roundTripped = base64UrlToBytes(toBase64Url(bytes)); + + expect(areUint8ArraysEqual(roundTripped, bytes)).toBe(true); + }); + + it('decodes an already-padded standard base64url string', () => { + const bytes = new Uint8Array([0xfb, 0xff]); + + expect(areUint8ArraysEqual(base64UrlToBytes('-_8='), bytes)).toBe(true); + }); + }); +}); diff --git a/packages/kyc-controller/src/encoding.ts b/packages/kyc-controller/src/encoding.ts new file mode 100644 index 00000000000..f9fb9f265a2 --- /dev/null +++ b/packages/kyc-controller/src/encoding.ts @@ -0,0 +1,39 @@ +import { base64ToBytes, bytesToBase64 } from '@metamask/utils'; + +/** + * Shared base64url encoding helpers used by frame crypto and UKYC modules. + * + * These are platform-agnostic: they rely on `@metamask/utils` rather than + * `Buffer` / `atob`, so they run unchanged on mobile, extension, and web. + */ + +/** + * Encodes bytes as unpadded base64url (RFC 4648 §5). This is the wire shape + * used for `storage_id`, `signing_public_key`, and Ed25519 signatures in the + * UKYC storage API. + * + * @param bytes - The bytes to encode. + * @returns The base64url string without `=` padding. + */ +export function toBase64Url(bytes: Uint8Array): string { + return bytesToBase64(bytes) + .replace(/\+/gu, '-') + .replace(/\//gu, '_') + .replace(/[=]+$/u, ''); +} + +/** + * Decodes an unpadded (or padded) base64url string back to bytes. Inverse of + * {@link toBase64Url}. + * + * @param value - The base64url string. + * @returns The decoded bytes. + */ +export function base64UrlToBytes(value: string): Uint8Array { + return base64ToBytes( + value + .replace(/-/gu, '+') + .replace(/_/gu, '/') + .padEnd(value.length + ((4 - (value.length % 4)) % 4), '='), + ); +} diff --git a/packages/kyc-controller/src/index.test.ts b/packages/kyc-controller/src/index.test.ts new file mode 100644 index 00000000000..f986f8847a4 --- /dev/null +++ b/packages/kyc-controller/src/index.test.ts @@ -0,0 +1,19 @@ +import * as packageExports from './index.js'; + +describe('@metamask/kyc-controller', () => { + it('exports the controller, service, selectors, and helpers', () => { + expect(packageExports).toMatchObject({ + KycController: expect.any(Function), + KycService: expect.any(Function), + getDefaultKycControllerState: expect.any(Function), + selectKycPhase: expect.any(Function), + selectKycSumSub: expect.any(Function), + selectIsKycRequiredForProduct: expect.any(Function), + alpha2ToAlpha3: expect.any(Function), + generateKeyPair: expect.any(Function), + decryptCredentials: expect.any(Function), + controllerName: 'KycController', + serviceName: 'KycService', + }); + }); +}); diff --git a/packages/kyc-controller/src/index.ts b/packages/kyc-controller/src/index.ts new file mode 100644 index 00000000000..985991a7d3d --- /dev/null +++ b/packages/kyc-controller/src/index.ts @@ -0,0 +1,144 @@ +export { + KycController, + getDefaultKycControllerState, + controllerName, +} from './KycController.js'; +export type { + KycControllerActions, + KycControllerEvents, + KycControllerGetStateAction, + KycControllerMessenger, + KycControllerOptions, + KycControllerState, + KycControllerStateChangeEvent, + KycControllerStatusChangedEvent, +} from './KycController.js'; +export type { + KycControllerAcceptTermsAndStartSessionAction, + KycControllerBuildAuthFrameUrlAction, + KycControllerBuildCheckFrameUrlAction, + KycControllerBuildResetFrameUrlAction, + KycControllerCheckKycRequiredAction, + KycControllerClearSavedTermsAction, + KycControllerClearStateAction, + KycControllerCreateVendorCustomerAction, + KycControllerGetCustomerIdentityAction, + KycControllerGetKycStatusAction, + KycControllerGetSessionStatusAction, + KycControllerHandleFrameMessageAction, + KycControllerInitializeAction, + KycControllerLoadDisclaimersAction, + KycControllerRefreshKycStatusAction, + KycControllerResetAction, + KycControllerStartSumSubAction, +} from './KycController-method-action-types.js'; + +export { KycService, serviceName } from './KycService.js'; +export type { + ApplicantAccessTokenResponse, + CapabilityAuthorization, + CheckKycRequiredParams, + CreateVendorCustomerParams, + CreateSessionParams, + CreateUkycSessionParams, + EncryptionSchema, + FetchSessionDisclaimersParams, + GetSessionStatusParams, + VendorCustomerResponse, + JwksResponse, + KycServiceActions, + KycServiceCacheUpdatedEvent, + KycServiceEvents, + KycServiceGranularCacheUpdatedEvent, + KycServiceInvalidateQueriesAction, + KycServiceMessenger, + KycServiceOptions, + SetAuthorizationsParams, + SubmitSessionDisclaimersParams, + SubmitVendorDisclaimersParams, + UkycSessionResponse, +} from './KycService.js'; +export type { + KycServiceCheckKycRequiredAction, + KycServiceCreateVendorCustomerAction, + KycServiceCreateJourneyAction, + KycServiceCreateSessionAction, + KycServiceCreateUkycSessionAction, + KycServiceFetchDisclaimersAction, + KycServiceFetchIdosEnclaveJwksAction, + KycServiceFetchIdosRelayJwksAction, + KycServiceFetchKycStatusAction, + KycServiceFetchSessionDisclaimersAction, + KycServiceGetGeoCountryAction, + KycServiceGetSessionStatusAction, + KycServiceSetAuthorizationsAction, + KycServiceSubmitSessionDisclaimersAction, + KycServiceSubmitVendorDisclaimersAction, +} from './KycService-method-action-types.js'; + +export { + selectIsKycRequiredForProduct, + selectKycPhase, + selectKycSumSub, +} from './selectors.js'; + +export { alpha2ToAlpha3, ALPHA2_TO_ALPHA3 } from './countryCodes.js'; +export { decryptCredentials, generateKeyPair } from './crypto.js'; +export type { + DecryptedCredentials, + DecryptResult, + EncryptedCredentialsEnvelope, + X25519KeyPair, +} from './crypto.js'; + +export type { + KycConsentDocument, + KycConsentRecord, + KycCustomerIdentity, + KycDisclaimer, + KycPhase, + KycProduct, + KycSessionDisclaimers, + KycSessionStatus, + KycSumSubLaunchParams, + KycSumSubLauncher, + KycSumSubStatus, + KycUserStatus, + KycUserStatusResponse, + KycVendor, + KycVendorSigning, +} from './types.js'; + +// UKYC storage-access-token utilities. Exported so a signed capability token can +// be minted for testing UKYC Storage (see `mintUkycTestToken`). +export { + UKYC_CAPABILITY_AUTH_SCHEME, + UKYC_KWIL_AUDIENCE, + UKYC_STORAGE_ACCESS_TOKEN_AUDIENCE, + UKYC_STORAGE_ACCESS_TOKEN_AUDIENCES, + UKYC_STORAGE_ACCESS_TOKEN_VERSION, +} from './ukyc/constants.js'; +export { + deriveClientMaterial, + encodeClientMaterial, +} from './ukyc/deriveClientMaterial.js'; +export type { + EncodedUkycClientMaterial, + UkycClientMaterial, +} from './ukyc/deriveClientMaterial.js'; +export { + encodeStorageAccessTokenForHeader, + signStorageAccessToken, +} from './ukyc/storageAccessToken.js'; +export type { + SignStorageAccessTokenParams, + UkycStorageAccessToken, + UkycStorageAccessTokenPayload, + UkycStorageOperation, + UkycTokenPresenter, +} from './ukyc/storageAccessToken.js'; +export { mintUkycTestToken } from './ukyc/testToken.js'; +export type { + MintedUkycTestToken, + MintUkycTestTokenParams, +} from './ukyc/testToken.js'; diff --git a/packages/kyc-controller/src/selectors.test.ts b/packages/kyc-controller/src/selectors.test.ts new file mode 100644 index 00000000000..5eee934acd0 --- /dev/null +++ b/packages/kyc-controller/src/selectors.test.ts @@ -0,0 +1,33 @@ +import { getDefaultKycControllerState } from './KycController.js'; +import { + selectIsKycRequiredForProduct, + selectKycPhase, + selectKycSumSub, +} from './selectors.js'; + +describe('selectors', () => { + it('selectKycPhase returns the current phase', () => { + const state = { ...getDefaultKycControllerState(), phase: 'form' as const }; + expect(selectKycPhase(state)).toBe('form'); + }); + + it('selectKycSumSub returns the sub-flow state', () => { + const state = getDefaultKycControllerState(); + expect(selectKycSumSub(state)).toStrictEqual(state.sumsub); + }); + + describe('selectIsKycRequiredForProduct', () => { + it('returns the cached requirement for a product', () => { + const state = { + ...getDefaultKycControllerState(), + kycRequiredByProduct: { ramps: true }, + }; + expect(selectIsKycRequiredForProduct('ramps')(state)).toBe(true); + }); + + it('returns undefined when the product has not been checked', () => { + const state = getDefaultKycControllerState(); + expect(selectIsKycRequiredForProduct('card')(state)).toBeUndefined(); + }); + }); +}); diff --git a/packages/kyc-controller/src/selectors.ts b/packages/kyc-controller/src/selectors.ts new file mode 100644 index 00000000000..6247e01796f --- /dev/null +++ b/packages/kyc-controller/src/selectors.ts @@ -0,0 +1,42 @@ +import { createSelector } from 'reselect'; + +import type { KycControllerState } from './KycController.js'; +import type { KycProduct } from './types.js'; + +const selectKycRequiredByProduct = ( + state: KycControllerState, +): KycControllerState['kycRequiredByProduct'] => state.kycRequiredByProduct; + +/** + * Selects the current flow phase. + * + * @param state - The KycController state. + * @returns The current phase. + */ +export const selectKycPhase = ( + state: KycControllerState, +): KycControllerState['phase'] => state.phase; + +/** + * Selects the SumSub sub-flow state. + * + * @param state - The KycController state. + * @returns The SumSub state. + */ +export const selectKycSumSub = ( + state: KycControllerState, +): KycControllerState['sumsub'] => state.sumsub; + +/** + * Creates a selector that returns whether KYC is required for a product. + * + * @param product - The consuming feature. + * @returns A selector returning the cached requirement, or `undefined`. + */ +export const selectIsKycRequiredForProduct = ( + product: KycProduct, +): ((state: KycControllerState) => boolean | undefined) => + createSelector( + [selectKycRequiredByProduct], + (map): boolean | undefined => map[product], + ); diff --git a/packages/kyc-controller/src/types.ts b/packages/kyc-controller/src/types.ts new file mode 100644 index 00000000000..ef4198ad0d3 --- /dev/null +++ b/packages/kyc-controller/src/types.ts @@ -0,0 +1,256 @@ +/** + * Shared types for the KYC controller and service. + * + * The KYC flow is vendor-backed (currently MoonPay for identity + SumSub for + * document verification) but the surface exposed to consumers (ramps, card) is + * intentionally vendor-neutral so a future vendor swap does not ripple out. + */ + +/** + * A MetaMask feature that consumes KYC. Used to key the per-product + * "is KYC required" cache so ramps, card, and money can share one controller. + */ +export type KycProduct = 'ramps' | 'card' | 'money'; + +/** + * Identity vendors supported behind the KYC surface. + * + * - `moonpay` — MoonPay Check/Auth frames + SumSub documents. + * - `iron` — Iron-only Money/VBA path: empty-shell customer → consents → + * SumSub, with no MoonPay Check/Auth frames. + */ +export type KycVendor = 'moonpay' | 'iron'; + +/** + * Vendor-scoped identity for the currently authenticated KYC customer. + * + * Exposed to consumers (e.g. ramps) that must attach the vendor customer id to + * downstream provider calls without reading the full KYC state, which also + * holds session/access tokens. The identifier is session-scoped: it is only + * available once the customer has authenticated through the current flow and + * is cleared on `reset()`. + */ +export type KycCustomerIdentity = { + /** The identity vendor that issued {@link KycCustomerIdentity.id}. */ + vendor: KycVendor; + /** The vendor customer id (e.g. MoonPay customer UUID). */ + id: string; +}; + +/** + * User-keyed KYC status returned by `GET /kyc/status` and stored for toast / + * banner rendering. Collapses vendor + SumSub / relay state into the offsite + * contract. + */ +export type KycUserStatus = + | 'not-started' + | 'pending' + | 'need-more-information' + | 'terminal-failure' + | 'completed'; + +/** + * Payload from `GET /kyc/status`, including optional fields that power the + * 3-state error contract (retryable SumSub vs terminal vs EDD). + */ +export type KycUserStatusResponse = { + status: KycUserStatus; + /** Present when the user can reopen a SumSub session (retryable path). */ + sumsubSessionId?: string; + /** Machine-readable error code for terminal / EDD UX. */ + errorCode?: string; +}; + +/** + * Phases of the end-to-end identity flow. + * + * - `idle` — nothing started. + * - `terms` — waiting for the customer to accept the vendor terms. + * - `session` — creating the vendor session (MoonPay) or creating the UKYC + * session and recording session-scoped disclaimers (non-MoonPay vendors). + * - `check` — running the invisible connection-check frame (MoonPay only). + * - `auth` — running the visible authentication (OTP) frame (MoonPay only). + * - `form` — authenticated. When the flow is scoped to a product, the + * KYC-required check runs automatically from here; otherwise the consumer + * drives it manually via `checkKycRequired`. Consents-path vendors skip + * this phase. + * - `submit` — submitting the KYC-required check / launching SumSub. + * - `done` — flow complete; see `kycRequiredByProduct` / `sumsub` / + * `userStatus`. When KYC is required, the document-verification sub-flow is + * launched automatically. + * - `error` — flow halted; see `error`. + */ +export type KycPhase = + | 'idle' + | 'terms' + | 'session' + | 'check' + | 'auth' + | 'form' + | 'submit' + | 'done' + | 'error'; + +/** + * Progress of the SumSub document-verification sub-flow. + * + * - `polling` — the SDK finished and the controller is polling the UKYC + * backend for the session's final decision (see `KycSessionStatus`). The + * sub-flow resolves to `complete` or `failed` once a terminal status arrives. + * - `vendorProcessing` — session creation reported that the applicant is + * already approved on the relay (`kycStatus`) while the vendor is still + * finalizing its own decision (`finalStatus`). There is nothing left for the + * applicant to do, so the SDK is not launched; see `statusMessage`. + */ +export type KycSumSubStatus = + | 'idle' + | 'creatingSession' + | 'fetchingToken' + | 'launching' + | 'inProgress' + | 'polling' + | 'complete' + | 'failed' + | 'vendorProcessing'; + +/** + * The status of a UKYC session, returned by the `GET /sessions/{id}/status` + * endpoint and polled after the SumSub SDK completes to determine the final + * verification decision. + */ +export type KycSessionStatus = { + /** + * The overall status of the session. Terminal values (e.g. `approved`, + * `completed`, `rejected`, `failed`, `blocked`) end polling; any other value + * keeps polling. + */ + finalStatus: string; + /** Optional human-readable message describing the status. */ + statusMessage?: string; + /** The vendor-agnostic external user id associated with the session. */ + externalUserId: string; + /** The KYC decision status. */ + kycStatus: string; + /** The identity vendor that handled the session. */ + vendor: string; + /** The vendor-specific status. */ + vendorStatus: string; +}; + +/** + * A single disclaimer/term the customer must accept before a vendor session is + * created (`GET /vendors/{vendor}/disclaimers`). + */ +export type KycDisclaimer = { + id: string; + // Mirrors the vendor API response field, which is snake_case. + // eslint-disable-next-line @typescript-eslint/naming-convention + display_name: string; + url: string; +}; + +/** + * A vendor T&C signing returned by `POST /vendors/{vendor}/disclaimers`. + */ +export type KycVendorSigning = { + /** Iron signing id. */ + id: string; + // Mirrors the vendor API response field, which is snake_case. + // eslint-disable-next-line @typescript-eslint/naming-convention + customer_id: string; + // Mirrors the vendor API response field, which is snake_case. + // eslint-disable-next-line @typescript-eslint/naming-convention + content_id?: string; +}; + +/** + * A legal document in the session-scoped idOS / KYC-provider catalog + * (`GET`/`POST /sessions/{sessionId}/disclaimers`). + */ +export type KycConsentDocument = { + /** Stable identifier of the legal document. */ + key: string; + /** Version of the document currently in force. */ + version: string; + /** Human-readable document title. */ + title: string; + /** URL the document body is hosted at. */ + url: string; + /** Whether this session already consented to this document version. */ + consented: boolean; +}; + +/** + * A consent record posted for a catalog document. `key` and `version` must + * match the current session catalog. + */ +export type KycConsentRecord = { + key: string; + version: string; +}; + +/** + * Session-scoped disclaimer catalog returned by + * `GET`/`POST /sessions/{sessionId}/disclaimers`. + */ +export type KycSessionDisclaimers = { + /** idOS legal documents. */ + idOS: KycConsentDocument[]; + /** KYC provider (SumSub) legal documents. */ + kycProvider: KycConsentDocument[]; + /** Whether the user consented to reuse existing idOS credentials. */ + credentialReusabilityConsentGiven: boolean; +}; + +/** + * Parameters passed to a platform SumSub launcher. + */ +export type KycSumSubLaunchParams = { + /** + * The applicant access token used to initialize the SumSub SDK. + */ + applicantAccessToken: string; + + /** + * Called by the SDK when the access token expires; must resolve with a fresh + * applicant access token. + */ + onTokenExpiration: () => Promise; + + /** + * Called when the SDK reports a status transition. + */ + onStatusChange?: (prevStatus: string, newStatus: string) => void; + + /** + * BCP-47 locale for the SDK UI. + */ + locale?: string; + + /** + * Enables SDK debug logging. + */ + debug?: boolean; +}; + +/** + * Platform adapter that launches the native/web SumSub SDK. + * + * The KYC controller is platform-agnostic and does not import any SDK; each + * client (mobile / extension / web) injects an implementation of this + * interface. The controller owns all orchestration (session creation, token + * exchange, token refresh, state) and only delegates the actual SDK + * presentation to `launch`. + */ +export type KycSumSubLauncher = { + /** + * Whether the underlying SDK is available in the current runtime (e.g. the + * native module is linked). When `false`, `startSumSub` fails fast. + */ + isAvailable(): boolean; + + /** + * Presents the SumSub verification flow and resolves with the SDK result. + */ + launch(params: KycSumSubLaunchParams): Promise>; +}; diff --git a/packages/kyc-controller/src/ukyc/constants.ts b/packages/kyc-controller/src/ukyc/constants.ts new file mode 100644 index 00000000000..e873685c0af --- /dev/null +++ b/packages/kyc-controller/src/ukyc/constants.ts @@ -0,0 +1,82 @@ +/** + * Constants for the UKYC client-derived key material and storage-authorization + * layer. See the architecture doc, section "Client-Derived Material". + */ + +/** + * Fully-qualified key path for the `local_user_secret` in Encrypted User + * Storage. + */ +export const UKYC_LOCAL_USER_SECRET_PATH = `ukyc.local_user_secret` as const; + +/** + * Size of the `local_user_secret` in bytes. 32 bytes (256 bits) provides high + * entropy and matches the input length expected by the HKDF-SHA256 derivations + * below. + */ +export const UKYC_LOCAL_USER_SECRET_SIZE_BYTES = 32; + +/** + * Byte length of each value derived from `local_user_secret`. + * + * `signingKey` is 32 bytes because it is used directly as the Ed25519 + * private key (for Ed25519 the 32-byte seed *is* the private key). + */ +export const UKYC_DERIVED_KEY_SIZES = { + storageId: 32, + dataEncryptionKey: 32, + signingKey: 32, + relayTunnelKey: 32, +} as const; + +/** + * HKDF `info` labels providing domain separation between the values derived + * from `local_user_secret`. + */ +export const UKYC_KDF_INFO = { + storageId: 'metamask.ukyc.storage.v1.storage_id', + dataEncryptionKey: 'metamask.ukyc.storage.v1.data_encryption_key', + signingKey: 'metamask.ukyc.storage.v1.signing_key', + relayTunnelKey: 'metamask.ukyc.storage.v1.relay_tunnel_key', +} as const; + +/** + * Version bound into every `storage_access_token` payload. + */ +export const UKYC_STORAGE_ACCESS_TOKEN_VERSION = 1; + +/** + * Audience identifying the UKYC user-storage service. Required by UKYC Storage + * when it verifies a `storage_access_token`. + */ +export const UKYC_STORAGE_ACCESS_TOKEN_AUDIENCE = + 'metamask:user-storage:ukyc' as const; + +/** + * Audience identifying the idOS Kwil credential-registry nodes. Required by + * idOS Kwil when it verifies a `storage_access_token`. + */ +export const UKYC_KWIL_AUDIENCE = 'idos:kwil' as const; + +/** + * Full audience list bound into every `storage_access_token` payload. `aud` + * lists every verifier that may accept the token, so both UKYC Storage and + * idOS Kwil can each find their own entry. + */ +export const UKYC_STORAGE_ACCESS_TOKEN_AUDIENCES = [ + UKYC_STORAGE_ACCESS_TOKEN_AUDIENCE, + UKYC_KWIL_AUDIENCE, +] as const; + +/** + * Authorization scheme under which a signed `storage_access_token` envelope is + * carried to UKYC Storage: `Authorization: AccessToken `. + * The credentials portion is what `encodeStorageAccessTokenForHeader` returns. + */ +export const UKYC_CAPABILITY_AUTH_SCHEME = 'AccessToken' as const; + +/** + * Standard well-known path where the idOS enclave and the idOS relay publish their JWKS + * (the Ed25519 public keys used to sign encryption-schema `jwtChain`s). + */ +export const UKYC_JWKS_PATH = '/.well-known/jwks.json'; diff --git a/packages/kyc-controller/src/ukyc/deriveClientMaterial.test.ts b/packages/kyc-controller/src/ukyc/deriveClientMaterial.test.ts new file mode 100644 index 00000000000..17ae0da5bdb --- /dev/null +++ b/packages/kyc-controller/src/ukyc/deriveClientMaterial.test.ts @@ -0,0 +1,109 @@ +import { areUint8ArraysEqual } from '@metamask/utils'; +import { ed25519 } from '@noble/curves/ed25519'; + +import { + UKYC_DERIVED_KEY_SIZES, + UKYC_LOCAL_USER_SECRET_SIZE_BYTES, +} from './constants.js'; +import { + deriveClientMaterial, + encodeClientMaterial, +} from './deriveClientMaterial.js'; + +const LOCAL_USER_SECRET = new Uint8Array( + UKYC_LOCAL_USER_SECRET_SIZE_BYTES, +).fill(42); +const OTHER_LOCAL_USER_SECRET = new Uint8Array( + UKYC_LOCAL_USER_SECRET_SIZE_BYTES, +).fill(43); + +describe('UKYC deriveClientMaterial', () => { + it('derives each value at the documented length', () => { + const material = deriveClientMaterial(LOCAL_USER_SECRET); + + expect(material.storageId).toHaveLength(UKYC_DERIVED_KEY_SIZES.storageId); + expect(material.dataEncryptionKey).toHaveLength( + UKYC_DERIVED_KEY_SIZES.dataEncryptionKey, + ); + expect(material.signingKey).toHaveLength(UKYC_DERIVED_KEY_SIZES.signingKey); + expect(material.relayTunnelKey).toHaveLength( + UKYC_DERIVED_KEY_SIZES.relayTunnelKey, + ); + // Ed25519 public keys are 32 bytes. + expect(material.signingPublicKey).toHaveLength(32); + }); + + it('is deterministic for the same local_user_secret', () => { + const a = deriveClientMaterial(LOCAL_USER_SECRET); + const b = deriveClientMaterial(LOCAL_USER_SECRET); + + expect(a).toStrictEqual(b); + }); + + it('produces different material for a different local_user_secret', () => { + const a = deriveClientMaterial(LOCAL_USER_SECRET); + const b = deriveClientMaterial(OTHER_LOCAL_USER_SECRET); + + expect(areUint8ArraysEqual(a.storageId, b.storageId)).toBe(false); + expect(areUint8ArraysEqual(a.dataEncryptionKey, b.dataEncryptionKey)).toBe( + false, + ); + expect(areUint8ArraysEqual(a.signingKey, b.signingKey)).toBe(false); + expect(areUint8ArraysEqual(a.relayTunnelKey, b.relayTunnelKey)).toBe(false); + }); + + it('domain-separates the derived values from one another', () => { + const { storageId, dataEncryptionKey, signingKey, relayTunnelKey } = + deriveClientMaterial(LOCAL_USER_SECRET); + const values = [storageId, dataEncryptionKey, signingKey, relayTunnelKey]; + + for (let i = 0; i < values.length; i++) { + for (let j = i + 1; j < values.length; j++) { + expect(areUint8ArraysEqual(values[i], values[j])).toBe(false); + } + } + }); + + it('derives a signing public key that matches the signing key', () => { + const material = deriveClientMaterial(LOCAL_USER_SECRET); + + expect(material.signingPublicKey).toStrictEqual( + ed25519.getPublicKey(material.signingKey), + ); + }); + + it('produces a working Ed25519 keypair for storage authorization', () => { + const material = deriveClientMaterial(LOCAL_USER_SECRET); + const message = new TextEncoder().encode('storage-authorization-payload'); + + const signature = ed25519.sign(message, material.signingKey); + + expect(ed25519.verify(signature, message, material.signingPublicKey)).toBe( + true, + ); + }); +}); + +describe('UKYC encodeClientMaterial', () => { + it('encodes storage_id and signing public key as unpadded base64url', () => { + const material = deriveClientMaterial(LOCAL_USER_SECRET); + + const encoded = encodeClientMaterial(material); + + expect(encoded.storageId).toMatch(/^[A-Za-z0-9_-]+$/u); + expect(encoded.signingPublicKey).toMatch(/^[A-Za-z0-9_-]+$/u); + expect(encoded.storageId).not.toContain('='); + expect(encoded.signingPublicKey).not.toContain('='); + }); + + it('omits secret material from the encoded output', () => { + const material = deriveClientMaterial(LOCAL_USER_SECRET); + + const encoded = encodeClientMaterial(material); + + expect(Object.keys(encoded).sort()).toStrictEqual([ + 'signingPublicKey', + 'storageId', + ]); + }); +}); diff --git a/packages/kyc-controller/src/ukyc/deriveClientMaterial.ts b/packages/kyc-controller/src/ukyc/deriveClientMaterial.ts new file mode 100644 index 00000000000..b9c8b8e5aa3 --- /dev/null +++ b/packages/kyc-controller/src/ukyc/deriveClientMaterial.ts @@ -0,0 +1,128 @@ +import { stringToBytes } from '@metamask/utils'; +import { ed25519 } from '@noble/curves/ed25519'; +import { hkdf } from '@noble/hashes/hkdf'; +import { sha256 } from '@noble/hashes/sha2'; + +import { toBase64Url } from '../encoding.js'; +import { UKYC_DERIVED_KEY_SIZES, UKYC_KDF_INFO } from './constants.js'; + +/** + * Derives UKYC client material from the root `local_user_secret` using + * HKDF-SHA256 with domain-separated `info` labels — see the architecture doc, + * section "Client-Derived Material". + */ + +/** + * The set of values derived from `local_user_secret`. + */ +export type UkycClientMaterial = { + /** Opaque lookup key for the encrypted KYC object. */ + storageId: Uint8Array; + /** Symmetric key that encrypts `encrypted_kyc_data` (or wraps per-blob keys). */ + dataEncryptionKey: Uint8Array; + /** + * Ed25519 private key used to sign `storage_access_token` capabilities. For + * Ed25519 the 32-byte HKDF output *is* the private key. The private half + * never leaves the device. + */ + signingKey: Uint8Array; + /** Public half of `signingKey`, registered with the object on first write. */ + signingPublicKey: Uint8Array; + /** Key for establishing/authenticating the encrypted tunnel to idOS, if needed. */ + relayTunnelKey: Uint8Array; +}; + +/** + * The same material with byte fields base64url-encoded, matching the wire + * shapes in the architecture doc (`storage_id` as an opaque id, public key as a + * "base64url public key"). Secret material is intentionally omitted. + */ +export type EncodedUkycClientMaterial = { + storageId: string; + signingPublicKey: string; +}; + +/** + * Derives a single labeled value from `local_user_secret`. + * + * No salt is used: `local_user_secret` is already a high-entropy + * uniformly-random secret, so per-output domain separation comes entirely from + * the `info` label. + * + * @param localUserSecret - The root `local_user_secret` bytes. + * @param info - Domain-separation label for this output. + * @param length - Desired output length in bytes. + * @returns The derived bytes. + */ +function deriveLabeled( + localUserSecret: Uint8Array, + info: string, + length: number, +): Uint8Array { + return hkdf(sha256, localUserSecret, undefined, stringToBytes(info), length); +} + +/** + * Derives all UKYC client material from the root `local_user_secret`. + * + * This is a pure function of `local_user_secret`: the same input always yields + * the same outputs, which is what makes `storage_id` and `signing_key` stable + * across sessions and devices. + * + * @param localUserSecret - The `local_user_secret` produced by + * `getOrCreateLocalUserSecret`. + * @returns The derived {@link UkycClientMaterial}. + */ +export function deriveClientMaterial( + localUserSecret: Uint8Array, +): UkycClientMaterial { + const storageId = deriveLabeled( + localUserSecret, + UKYC_KDF_INFO.storageId, + UKYC_DERIVED_KEY_SIZES.storageId, + ); + + const dataEncryptionKey = deriveLabeled( + localUserSecret, + UKYC_KDF_INFO.dataEncryptionKey, + UKYC_DERIVED_KEY_SIZES.dataEncryptionKey, + ); + + const signingKey = deriveLabeled( + localUserSecret, + UKYC_KDF_INFO.signingKey, + UKYC_DERIVED_KEY_SIZES.signingKey, + ); + + const relayTunnelKey = deriveLabeled( + localUserSecret, + UKYC_KDF_INFO.relayTunnelKey, + UKYC_DERIVED_KEY_SIZES.relayTunnelKey, + ); + + const signingPublicKey = ed25519.getPublicKey(signingKey); + + return { + storageId, + dataEncryptionKey, + signingKey, + signingPublicKey, + relayTunnelKey, + }; +} + +/** + * Encodes the non-secret client material into the base64url wire shapes used by + * the UKYC storage API (`storage_id` and `signing_public_key`). + * + * @param material - The derived client material. + * @returns The base64url-encoded, non-secret fields. + */ +export function encodeClientMaterial( + material: UkycClientMaterial, +): EncodedUkycClientMaterial { + return { + storageId: toBase64Url(material.storageId), + signingPublicKey: toBase64Url(material.signingPublicKey), + }; +} diff --git a/packages/kyc-controller/src/ukyc/jwtChain.test.ts b/packages/kyc-controller/src/ukyc/jwtChain.test.ts new file mode 100644 index 00000000000..735da283d21 --- /dev/null +++ b/packages/kyc-controller/src/ukyc/jwtChain.test.ts @@ -0,0 +1,100 @@ +import { stringToBytes } from '@metamask/utils'; +import { ed25519 } from '@noble/curves/ed25519'; + +import { toBase64Url } from '../encoding.js'; +import type { Jwk } from './jwtChain.js'; +import { verifyJwtChain } from './jwtChain.js'; + +const KID = 'key-1'; +const PAYLOAD = { sessionServerPublicKeyX: 'spk-x', nonce: 'nonce-1' }; + +const SIGNING_PRIVATE_KEY = ed25519.utils.randomSecretKey(); +const SIGNING_PUBLIC_KEY = ed25519.getPublicKey(SIGNING_PRIVATE_KEY); + +const JWK: Jwk = { + kty: 'OKP', + crv: 'Ed25519', + x: toBase64Url(SIGNING_PUBLIC_KEY), + kid: KID, +}; + +/** + * Builds a compact EdDSA JWT signed with the module's signing key. + * + * @param options - Overrides. + * @param options.header - The protected header (defaults to a valid EdDSA one). + * @param options.payload - The payload (defaults to {@link PAYLOAD}). + * @param options.privateKey - The signing key (defaults to the module key). + * @param options.tamper - When true, corrupts the signature. + * @returns The compact-serialized JWT. + */ +function buildJwt({ + header = { alg: 'EdDSA', kid: KID }, + payload = PAYLOAD, + privateKey = SIGNING_PRIVATE_KEY, + tamper = false, +}: { + header?: Record; + payload?: Record; + privateKey?: Uint8Array; + tamper?: boolean; +} = {}): string { + const headerSegment = toBase64Url(stringToBytes(JSON.stringify(header))); + const payloadSegment = toBase64Url(stringToBytes(JSON.stringify(payload))); + const signature = ed25519.sign( + new TextEncoder().encode(`${headerSegment}.${payloadSegment}`), + privateKey, + ); + if (tamper) { + signature[0] = signature[0] === 0 ? 1 : 0; + } + return `${headerSegment}.${payloadSegment}.${toBase64Url(signature)}`; +} + +describe('UKYC verifyJwtChain', () => { + it('returns the payload for a validly-signed jwtChain', () => { + expect(verifyJwtChain([JWK], buildJwt())).toStrictEqual(PAYLOAD); + }); + + it('rejects a jwtChain that is not three segments', () => { + expect(() => verifyJwtChain([JWK], 'only.two')).toThrow( + 'not a well-formed JWT', + ); + }); + + it('rejects a non-EdDSA algorithm', () => { + const jwt = buildJwt({ header: { alg: 'RS256', kid: KID } }); + + expect(() => verifyJwtChain([JWK], jwt)).toThrow('expected EdDSA'); + }); + + it('rejects when no JWKS key matches the kid', () => { + const jwt = buildJwt({ header: { alg: 'EdDSA', kid: 'other' } }); + + expect(() => verifyJwtChain([JWK], jwt)).toThrow('no JWKS key matches'); + }); + + it('rejects a JWKS key that is not an Ed25519 OKP key', () => { + const badJwk: Jwk = { ...JWK, crv: 'X25519' }; + + expect(() => verifyJwtChain([badJwk], buildJwt())).toThrow( + 'is not an Ed25519 OKP key', + ); + }); + + it('rejects a tampered signature', () => { + expect(() => verifyJwtChain([JWK], buildJwt({ tamper: true }))).toThrow( + 'signature verification failed', + ); + }); + + it('rejects a malformed (non-JSON) header segment', () => { + const jwt = `not-json.${toBase64Url( + stringToBytes(JSON.stringify(PAYLOAD)), + )}.sig`; + + expect(() => verifyJwtChain([JWK], jwt)).toThrow( + 'failed to decode jwtChain header', + ); + }); +}); diff --git a/packages/kyc-controller/src/ukyc/jwtChain.ts b/packages/kyc-controller/src/ukyc/jwtChain.ts new file mode 100644 index 00000000000..ce4affbce10 --- /dev/null +++ b/packages/kyc-controller/src/ukyc/jwtChain.ts @@ -0,0 +1,113 @@ +import { bytesToString } from '@metamask/utils'; +import { ed25519 } from '@noble/curves/ed25519'; + +import { base64UrlToBytes } from '../encoding.js'; + +/** + * Verifies an encryption-schema `jwtChain` against the issuer's published JWKS + * (idOS enclave for `encryptionDataKey`, idOS relay for `ukycCapabilityToken`). + * + * The signature check is done with `@noble/curves` (rather than WebCrypto + * `subtle`) because not every MetaMask runtime exposes a `subtle` + * implementation for Ed25519; JWT parsing is a plain base64url/JSON decode, so + * no `jose` dependency is required. + */ + +/** + * A single Ed25519 (OKP) JSON Web Key from an issuer JWKS. + */ +export type Jwk = { + kty: string; + crv: string; + x: string; + kid: string; + use?: string; + alg?: string; +}; + +/** + * The verified `jwtChain` payload. `sessionServerPublicKeyX` attests the + * server's X25519 public key so the client can confirm the value returned + * out-of-band in an encryption schema was not tampered with. + */ +export type JwtChainPayload = { + sessionServerPublicKeyX: string; + nonce: string; +}; + +/** + * The protected header of a compact JWT. + */ +type JwtHeader = { + alg?: string; + kid?: string; +}; + +/** + * Decodes a base64url JWT segment into a parsed JSON object. + * + * @param segment - The base64url-encoded segment. + * @param label - Human-readable segment name for error messages. + * @returns The parsed JSON object. + */ +function decodeJsonSegment(segment: string, label: string): Type { + try { + return JSON.parse(bytesToString(base64UrlToBytes(segment))) as Type; + } catch (error) { + throw new Error( + `UKYC: failed to decode jwtChain ${label}: ${String(error)}`, + ); + } +} + +/** + * Verifies `jwtChain` against `keys`: matches the JWT header `kid` to a + * published Ed25519 signing key and checks the EdDSA signature over the + * `header.payload` input. Returns the decoded, verified payload. + * + * @param keys - The issuer JWKS keys used to verify the chain. + * @param jwtChain - The compact-serialized EdDSA JWT from an encryption schema. + * @returns The verified JWT payload. + */ +export function verifyJwtChain(keys: Jwk[], jwtChain: string): JwtChainPayload { + const [headerSegment, payloadSegment, signatureSegment] = jwtChain.split('.'); + if (!headerSegment || !payloadSegment || !signatureSegment) { + throw new Error( + 'UKYC: jwtChain is not a well-formed JWT (expected 3 segments).', + ); + } + + const header = decodeJsonSegment(headerSegment, 'header'); + if (header.alg !== 'EdDSA') { + throw new Error( + `UKYC: unsupported jwtChain alg "${String( + header.alg, + )}" (expected EdDSA).`, + ); + } + + const jwk = keys.find((key) => key.kid === header.kid); + if (!jwk) { + throw new Error( + `UKYC: no JWKS key matches jwtChain kid "${String(header.kid)}".`, + ); + } + if (jwk.kty !== 'OKP' || jwk.crv !== 'Ed25519') { + throw new Error( + `UKYC: JWKS key ${jwk.kid} is not an Ed25519 OKP key (kty=${jwk.kty}, crv=${jwk.crv}).`, + ); + } + + const isValid = ed25519.verify( + base64UrlToBytes(signatureSegment), + new TextEncoder().encode(`${headerSegment}.${payloadSegment}`), + base64UrlToBytes(jwk.x), + ); + if (!isValid) { + throw new Error( + 'UKYC: jwtChain signature verification failed against JWKS.', + ); + } + + return decodeJsonSegment(payloadSegment, 'payload'); +} diff --git a/packages/kyc-controller/src/ukyc/localUserSecret.test.ts b/packages/kyc-controller/src/ukyc/localUserSecret.test.ts new file mode 100644 index 00000000000..b2e7869813d --- /dev/null +++ b/packages/kyc-controller/src/ukyc/localUserSecret.test.ts @@ -0,0 +1,148 @@ +import { base64ToBytes, bytesToBase64 } from '@metamask/utils'; + +import { + UKYC_LOCAL_USER_SECRET_PATH, + UKYC_LOCAL_USER_SECRET_SIZE_BYTES, +} from './constants.js'; +import type { UkycLocalUserSecretStore } from './localUserSecret.js'; +import { + getOrCreateLocalUserSecret, + hasLocalUserSecret, + loadLocalUserSecret, +} from './localUserSecret.js'; + +const SECRET_BYTES = new Uint8Array(UKYC_LOCAL_USER_SECRET_SIZE_BYTES).fill(7); +const SECRET_BASE64 = bytesToBase64(SECRET_BYTES); + +/** + * Builds a stateful in-memory store adapter backed by a single value. + * + * @param initial - The initial stored base64 value. + * @returns The store plus jest spies for `get` / `set`. + */ +function makeStore(initial: string | null = null): { + store: UkycLocalUserSecretStore; + get: jest.Mock; + set: jest.Mock; +} { + let value = initial; + const get = jest.fn(async () => value); + const set = jest.fn(async (_path: string, next: string) => { + value = next; + }); + return { store: { get, set }, get, set }; +} + +describe('UKYC localUserSecret', () => { + describe('loadLocalUserSecret', () => { + it('returns null when no local_user_secret is stored', async () => { + const { store, get } = makeStore(null); + + expect(await loadLocalUserSecret(store)).toBeNull(); + expect(get).toHaveBeenCalledWith(UKYC_LOCAL_USER_SECRET_PATH, undefined); + }); + + it('decodes and returns the stored local_user_secret', async () => { + const { store } = makeStore(SECRET_BASE64); + + expect(await loadLocalUserSecret(store)).toStrictEqual(SECRET_BYTES); + }); + + it('forwards the entropy source id', async () => { + const { store, get } = makeStore(SECRET_BASE64); + + await loadLocalUserSecret(store, 'entropy-1'); + + expect(get).toHaveBeenCalledWith( + UKYC_LOCAL_USER_SECRET_PATH, + 'entropy-1', + ); + }); + + it('throws when the stored local_user_secret has an unexpected length', async () => { + const { store } = makeStore(bytesToBase64(new Uint8Array(16))); + + await expect(loadLocalUserSecret(store)).rejects.toThrow( + 'unexpected length', + ); + }); + }); + + describe('getOrCreateLocalUserSecret', () => { + it('returns the existing local_user_secret without generating a new one', async () => { + const { store, set } = makeStore(SECRET_BASE64); + + expect(await getOrCreateLocalUserSecret(store)).toStrictEqual( + SECRET_BYTES, + ); + expect(set).not.toHaveBeenCalled(); + }); + + it('generates and persists a new local_user_secret on first enrollment', async () => { + const { store, set } = makeStore(null); + + const result = await getOrCreateLocalUserSecret(store); + + expect(set).toHaveBeenCalledTimes(1); + const [path, persisted] = set.mock.calls[0]; + expect(path).toBe(UKYC_LOCAL_USER_SECRET_PATH); + // The persisted value round-trips to the returned bytes. + expect(result).toStrictEqual(base64ToBytes(persisted)); + expect(result).toHaveLength(UKYC_LOCAL_USER_SECRET_SIZE_BYTES); + }); + + it('converges on a competing value that won the write race', async () => { + const competing = new Uint8Array(UKYC_LOCAL_USER_SECRET_SIZE_BYTES).fill( + 9, + ); + // First read (existence check) misses; the re-read after our write sees a + // value another writer landed first. + const get = jest + .fn() + .mockResolvedValueOnce(null) + .mockResolvedValueOnce(bytesToBase64(competing)); + const set = jest.fn().mockResolvedValue(undefined); + + const result = await getOrCreateLocalUserSecret({ get, set }); + + expect(result).toStrictEqual(competing); + }); + + it('deduplicates concurrent create calls into a single generation', async () => { + const { store, set } = makeStore(null); + + const [a, b] = await Promise.all([ + getOrCreateLocalUserSecret(store), + getOrCreateLocalUserSecret(store), + ]); + + expect(a).toStrictEqual(b); + expect(set).toHaveBeenCalledTimes(1); + }); + + it('falls back to the generated secret if the re-read returns nothing', async () => { + // `get` always misses, even after the write, so the helper falls back to + // the value it just generated. + const get = jest.fn().mockResolvedValue(null); + const set = jest.fn().mockResolvedValue(undefined); + + const result = await getOrCreateLocalUserSecret({ get, set }); + + expect(result).toHaveLength(UKYC_LOCAL_USER_SECRET_SIZE_BYTES); + }); + }); + + describe('hasLocalUserSecret', () => { + it('returns true when a local_user_secret exists', async () => { + const { store } = makeStore(SECRET_BASE64); + + expect(await hasLocalUserSecret(store)).toBe(true); + }); + + it('returns false when no local_user_secret exists', async () => { + const { store } = makeStore(null); + + expect(await hasLocalUserSecret(store)).toBe(false); + }); + }); +}); diff --git a/packages/kyc-controller/src/ukyc/localUserSecret.ts b/packages/kyc-controller/src/ukyc/localUserSecret.ts new file mode 100644 index 00000000000..46583684e96 --- /dev/null +++ b/packages/kyc-controller/src/ukyc/localUserSecret.ts @@ -0,0 +1,161 @@ +import { base64ToBytes, bytesToBase64 } from '@metamask/utils'; +import { randomBytes } from '@noble/hashes/utils'; + +import { + UKYC_LOCAL_USER_SECRET_PATH, + UKYC_LOCAL_USER_SECRET_SIZE_BYTES, +} from './constants.js'; + +/** + * Orchestrates creation and loading of the UKYC `local_user_secret`. + * + * The `local_user_secret` is the root secret for all UKYC client-derived + * material. It is generated once, on first enrollment, and persisted to + * MetaMask Encrypted User Storage. It is never transmitted off the device, not + * even to the idOS Relay. Every subsequent value (`storage_id`, + * `data_encryption_key`, `signing_key`, `relay_tunnel_key`) is derived from it + * via HKDF — see `deriveClientMaterial`. + * + * This module is platform-agnostic: the Encrypted User Storage backing is + * injected as a {@link UkycLocalUserSecretStore} so the controller (which owns + * the messenger) supplies the concrete `UserStorageController` calls. + */ + +/** + * The Encrypted User Storage operations this module needs. On MetaMask clients + * these are backed by `UserStorageController:performGetStorage` / + * `performSetStorage`. + */ +export type UkycLocalUserSecretStore = { + /** + * Reads the base64 string stored at `path`, or `null` if none exists. + */ + get: (path: string, entropySourceId?: string) => Promise; + /** + * Writes the base64 string `value` at `path`. + */ + set: (path: string, value: string, entropySourceId?: string) => Promise; +}; + +/** + * In-flight `getOrCreateLocalUserSecret` calls, keyed by entropy source. + * Deduplicates concurrent enrollments in a single client session so we never + * generate and persist two competing `local_user_secret`s for the same source. + */ +const inFlightCreations = new Map>(); + +/** + * Loads the persisted `local_user_secret` from Encrypted User Storage, if one + * exists. + * + * @param store - The Encrypted User Storage adapter. + * @param entropySourceId - Optional HD keyring entropy source id, used to scope + * the secret to a specific SRP in multi-SRP wallets. Defaults to the primary SRP. + * @returns The decoded `local_user_secret` bytes, or `null` if none has been + * enrolled. + */ +export async function loadLocalUserSecret( + store: UkycLocalUserSecretStore, + entropySourceId?: string, +): Promise { + const stored = await store.get(UKYC_LOCAL_USER_SECRET_PATH, entropySourceId); + + if (!stored) { + return null; + } + + const localUserSecret = base64ToBytes(stored); + + if (localUserSecret.length !== UKYC_LOCAL_USER_SECRET_SIZE_BYTES) { + throw new Error( + `UKYC: stored local_user_secret has unexpected length ${localUserSecret.length}, expected ${UKYC_LOCAL_USER_SECRET_SIZE_BYTES}.`, + ); + } + + return localUserSecret; +} + +/** + * Persists a freshly generated `local_user_secret` to Encrypted User Storage. + * + * @param store - The Encrypted User Storage adapter. + * @param localUserSecret - The `local_user_secret` bytes to persist. + * @param entropySourceId - Optional HD keyring entropy source id. + */ +async function persistLocalUserSecret( + store: UkycLocalUserSecretStore, + localUserSecret: Uint8Array, + entropySourceId?: string, +): Promise { + await store.set( + UKYC_LOCAL_USER_SECRET_PATH, + bytesToBase64(localUserSecret), + entropySourceId, + ); +} + +/** + * Creates the UKYC `local_user_secret` if it does not already exist, otherwise + * loads the existing one. This is the single entry point used on UKYC + * enrollment. + * + * The operation is idempotent and safe against concurrent callers in the same + * session: repeated or parallel calls resolve to the same `local_user_secret` + * and never generate more than one secret for a given entropy source. + * + * @param store - The Encrypted User Storage adapter. + * @param entropySourceId - Optional HD keyring entropy source id, used to scope + * the secret to a specific SRP in multi-SRP wallets. Defaults to the primary SRP. + * @returns The `local_user_secret` bytes (existing or newly created). + */ +export async function getOrCreateLocalUserSecret( + store: UkycLocalUserSecretStore, + entropySourceId?: string, +): Promise { + const cacheKey = entropySourceId ?? ''; + + const pending = inFlightCreations.get(cacheKey); + if (pending) { + return pending; + } + + const creation = (async (): Promise => { + const existing = await loadLocalUserSecret(store, entropySourceId); + if (existing) { + return existing; + } + + const localUserSecret = randomBytes(UKYC_LOCAL_USER_SECRET_SIZE_BYTES); + await persistLocalUserSecret(store, localUserSecret, entropySourceId); + + // Re-read after persisting so that all callers converge on whatever value + // actually landed in storage (defends against a competing write that may + // have won the race, e.g. from another device syncing the same feature). + return ( + (await loadLocalUserSecret(store, entropySourceId)) ?? localUserSecret + ); + })(); + + inFlightCreations.set(cacheKey, creation); + + try { + return await creation; + } finally { + inFlightCreations.delete(cacheKey); + } +} + +/** + * Whether a `local_user_secret` has already been enrolled for the given entropy + * source. + * + * @param store - The Encrypted User Storage adapter. + * @param entropySourceId - Optional HD keyring entropy source id. + * @returns `true` if a `local_user_secret` exists in Encrypted User Storage. + */ +export async function hasLocalUserSecret( + store: UkycLocalUserSecretStore, + entropySourceId?: string, +): Promise { + return (await loadLocalUserSecret(store, entropySourceId)) !== null; +} diff --git a/packages/kyc-controller/src/ukyc/storageAccessToken.test.ts b/packages/kyc-controller/src/ukyc/storageAccessToken.test.ts new file mode 100644 index 00000000000..d0299e7c9ef --- /dev/null +++ b/packages/kyc-controller/src/ukyc/storageAccessToken.test.ts @@ -0,0 +1,241 @@ +import { base64ToBytes } from '@metamask/utils'; +import { ed25519 } from '@noble/curves/ed25519'; + +import { + UKYC_KWIL_AUDIENCE, + UKYC_LOCAL_USER_SECRET_SIZE_BYTES, + UKYC_STORAGE_ACCESS_TOKEN_AUDIENCE, + UKYC_STORAGE_ACCESS_TOKEN_VERSION, +} from './constants.js'; +import { deriveClientMaterial } from './deriveClientMaterial.js'; +import { + canonicalizeJson, + encodeStorageAccessTokenForHeader, + signStorageAccessToken, +} from './storageAccessToken.js'; + +const LOCAL_USER_SECRET = new Uint8Array( + UKYC_LOCAL_USER_SECRET_SIZE_BYTES, +).fill(42); +const MATERIAL = deriveClientMaterial(LOCAL_USER_SECRET); + +const ISSUED_AT = new Date('2026-07-07T00:00:00.000Z'); +const EXPIRES_AT = new Date('2026-07-07T04:00:00.000Z'); + +/** + * Decodes an unpadded base64url string back to bytes. + * + * @param value - The base64url string. + * @returns The decoded bytes. + */ +function fromBase64Url(value: string): Uint8Array { + const padded = value.padEnd(Math.ceil(value.length / 4) * 4, '='); + return base64ToBytes(padded.replace(/-/gu, '+').replace(/_/gu, '/')); +} + +describe('UKYC canonicalizeJson', () => { + it('sorts object keys by code unit', () => { + expect(canonicalizeJson({ b: 1, a: 2, c: 3 })).toBe('{"a":2,"b":1,"c":3}'); + }); + + it('preserves array order and emits no whitespace', () => { + expect(canonicalizeJson({ z: [3, 2, 1], a: 'x' })).toBe( + '{"a":"x","z":[3,2,1]}', + ); + }); + + it('drops undefined members', () => { + expect(canonicalizeJson({ a: 1, b: undefined, c: 2 })).toBe( + '{"a":1,"c":2}', + ); + }); + + it('serializes primitives', () => { + expect(canonicalizeJson(null)).toBe('null'); + expect(canonicalizeJson(true)).toBe('true'); + expect(canonicalizeJson(false)).toBe('false'); + expect(canonicalizeJson('hi')).toBe('"hi"'); + expect(canonicalizeJson(7)).toBe('7'); + }); + + it('rejects non-integer numbers', () => { + expect(() => canonicalizeJson(1.5)).toThrow('non-integer'); + }); +}); + +describe('UKYC signStorageAccessToken', () => { + it('mints a client-presented token with the expected payload', () => { + const token = signStorageAccessToken({ + material: MATERIAL, + operations: ['delete'], + issuedAt: ISSUED_AT, + expiresAt: EXPIRES_AT, + }); + + expect(token.payload).toStrictEqual({ + version: UKYC_STORAGE_ACCESS_TOKEN_VERSION, + aud: [UKYC_STORAGE_ACCESS_TOKEN_AUDIENCE, UKYC_KWIL_AUDIENCE], + storage_id: expect.stringMatching(/^[A-Za-z0-9_-]+$/u), + signing_public_key: expect.stringMatching(/^[A-Za-z0-9_-]+$/u), + operations: ['delete'], + presenter: 'client', + issued_at: '2026-07-07T00:00:00Z', + expires_at: '2026-07-07T04:00:00Z', + }); + expect(token.payload).not.toHaveProperty('session_id'); + }); + + it('formats timestamps as RFC 3339 with whole seconds, truncating sub-second precision', () => { + const token = signStorageAccessToken({ + material: MATERIAL, + operations: ['read'], + issuedAt: new Date('2026-07-07T00:00:00.715Z'), + expiresAt: new Date('2026-07-07T04:00:00.999Z'), + }); + + expect(token.payload.issued_at).toBe('2026-07-07T00:00:00Z'); + expect(token.payload.expires_at).toBe('2026-07-07T04:00:00Z'); + }); + + it('produces a signature that verifies against the signing public key', () => { + const token = signStorageAccessToken({ + material: MATERIAL, + operations: ['read', 'write'], + presenter: 'idos-relay', + sessionId: 'session-1', + issuedAt: ISSUED_AT, + expiresAt: EXPIRES_AT, + }); + + const message = new TextEncoder().encode(canonicalizeJson(token.payload)); + const signature = fromBase64Url(token.signature); + + expect(ed25519.verify(signature, message, MATERIAL.signingPublicKey)).toBe( + true, + ); + }); + + it('binds session_id for Relay-presented tokens', () => { + const token = signStorageAccessToken({ + material: MATERIAL, + operations: ['read'], + presenter: 'idos-relay', + sessionId: 'session-42', + issuedAt: ISSUED_AT, + expiresAt: EXPIRES_AT, + }); + + expect(token.payload.session_id).toBe('session-42'); + expect(token.payload.presenter).toBe('idos-relay'); + }); + + it('is deterministic for the same inputs', () => { + const params = { + material: MATERIAL, + operations: ['read' as const], + issuedAt: ISSUED_AT, + expiresAt: EXPIRES_AT, + }; + + expect(signStorageAccessToken(params)).toStrictEqual( + signStorageAccessToken(params), + ); + }); + + it('rejects a Relay presenter without a session_id', () => { + expect(() => + signStorageAccessToken({ + material: MATERIAL, + operations: ['read'], + presenter: 'idos-relay', + issuedAt: ISSUED_AT, + expiresAt: EXPIRES_AT, + }), + ).toThrow('requires a session_id'); + }); + + it('rejects delegating a delete token to the Relay', () => { + expect(() => + signStorageAccessToken({ + material: MATERIAL, + operations: ['delete'], + presenter: 'idos-relay', + sessionId: 'session-1', + issuedAt: ISSUED_AT, + expiresAt: EXPIRES_AT, + }), + ).toThrow('cannot be delegated to the Relay'); + }); + + it('rejects delete combined with other operations', () => { + expect(() => + signStorageAccessToken({ + material: MATERIAL, + operations: ['delete', 'read'], + issuedAt: ISSUED_AT, + expiresAt: EXPIRES_AT, + }), + ).toThrow('must contain only "delete"'); + }); + + it('rejects an empty operations list', () => { + expect(() => + signStorageAccessToken({ + material: MATERIAL, + operations: [], + issuedAt: ISSUED_AT, + expiresAt: EXPIRES_AT, + }), + ).toThrow('at least one operation'); + }); + + it('rejects duplicate operations', () => { + expect(() => + signStorageAccessToken({ + material: MATERIAL, + operations: ['read', 'read'], + issuedAt: ISSUED_AT, + expiresAt: EXPIRES_AT, + }), + ).toThrow('must be unique'); + }); + + it('rejects an expiry at or before issued_at', () => { + expect(() => + signStorageAccessToken({ + material: MATERIAL, + operations: ['read'], + issuedAt: EXPIRES_AT, + expiresAt: EXPIRES_AT, + }), + ).toThrow('expires_at must be after issued_at'); + }); + + it('defaults issuedAt to now when omitted', () => { + const token = signStorageAccessToken({ + material: MATERIAL, + operations: ['read'], + expiresAt: new Date(Date.now() + 60_000), + }); + + expect(token.payload.issued_at).toStrictEqual(expect.any(String)); + }); +}); + +describe('UKYC encodeStorageAccessTokenForHeader', () => { + it('encodes the envelope as unpadded base64url that round-trips', () => { + const token = signStorageAccessToken({ + material: MATERIAL, + operations: ['read'], + issuedAt: ISSUED_AT, + expiresAt: EXPIRES_AT, + }); + + const header = encodeStorageAccessTokenForHeader(token); + + expect(header).toMatch(/^[A-Za-z0-9_-]+$/u); + expect( + JSON.parse(new TextDecoder().decode(fromBase64Url(header))), + ).toStrictEqual(token); + }); +}); diff --git a/packages/kyc-controller/src/ukyc/storageAccessToken.ts b/packages/kyc-controller/src/ukyc/storageAccessToken.ts new file mode 100644 index 00000000000..f020337696f --- /dev/null +++ b/packages/kyc-controller/src/ukyc/storageAccessToken.ts @@ -0,0 +1,262 @@ +import { stringToBytes } from '@metamask/utils'; +import { ed25519 } from '@noble/curves/ed25519'; + +import { toBase64Url } from '../encoding.js'; +import { + UKYC_STORAGE_ACCESS_TOKEN_AUDIENCES, + UKYC_STORAGE_ACCESS_TOKEN_VERSION, +} from './constants.js'; +import type { UkycClientMaterial } from './deriveClientMaterial.js'; + +/** + * Mints `storage_access_token` capabilities — the client-signed, scoped, + * session-bound proofs that authorize UKYC storage operations. See the + * architecture doc, section "Storage Authentication". + * + * The token is Ed25519 over RFC 8785 (JCS) canonical JSON of the payload. Only + * the client holds the private `signing_key`, so only the client can mint a + * token; a `read`/`write`-scoped token may then be handed to the Relay to + * present, but `delete` is never delegated. + */ + +/** + * Storage operations a `storage_access_token` can authorize. + */ +export type UkycStorageOperation = 'read' | 'write' | 'delete'; + +/** + * Who presents the token to UKYC storage. The Relay may only present + * `read`/`write` tokens; `delete` is always client-presented. + */ +export type UkycTokenPresenter = 'client' | 'idos-relay'; + +/** + * The signed `storage_access_token` payload. Field names are snake_case because + * they are canonicalized and hashed exactly as they appear on the wire. + */ +export type UkycStorageAccessTokenPayload = { + version: number; + /** Every verifier that may accept the token, e.g. UKYC Storage and idOS Kwil. */ + aud: string[]; + // Wire-shape fields are snake_case; they are canonicalized and signed exactly + // as they appear on the wire. + /* eslint-disable @typescript-eslint/naming-convention */ + storage_id: string; + signing_public_key: string; + operations: UkycStorageOperation[]; + presenter: UkycTokenPresenter; + /** UKYC session id. Required (and only present) when presenter is `idos-relay`. */ + session_id?: string; + issued_at: string; + expires_at: string; + /* eslint-enable @typescript-eslint/naming-convention */ +}; + +/** + * The on-the-wire envelope: the payload plus its detached Ed25519 signature + * (base64url) over the JCS canonicalization of the payload. + */ +export type UkycStorageAccessToken = { + payload: UkycStorageAccessTokenPayload; + signature: string; +}; + +/** + * Inputs for minting a `storage_access_token`. + */ +export type SignStorageAccessTokenParams = { + /** Client material derived from `local_user_secret`. */ + material: UkycClientMaterial; + /** Operations the token authorizes. `delete` must be the sole operation. */ + operations: UkycStorageOperation[]; + /** Who will present the token. Defaults to `client`. */ + presenter?: UkycTokenPresenter; + /** UKYC session id. Required when presenter is `idos-relay`. */ + sessionId?: string; + /** Token issue time. Defaults to now. */ + issuedAt?: Date; + /** Token expiry. Must be strictly after `issuedAt`. */ + expiresAt: Date; +}; + +type JsonValue = + | null + | boolean + | number + | string + | JsonValue[] + | { [key: string]: JsonValue | undefined }; + +/** + * Serializes a JSON value to RFC 8785 (JCS) canonical form. + * + * Scope note: this implementation covers the JSON shapes used by UKYC storage + * payloads — objects, arrays, strings, integers, booleans, and null. Object + * members are sorted by their UTF-16 code units (matching JS default string + * ordering, which is what JCS requires) and `undefined` members are dropped. + * Non-finite and non-integer numbers are rejected, since the payloads never + * contain them and correct JCS number formatting for the general case is + * intentionally out of scope here. + * + * @param value - The value to canonicalize. + * @returns The canonical JSON string. + */ +export function canonicalizeJson(value: JsonValue): string { + if (value === null) { + return 'null'; + } + + if (typeof value === 'boolean') { + return value ? 'true' : 'false'; + } + + if (typeof value === 'number') { + if (!Number.isInteger(value)) { + throw new Error( + 'UKYC: cannot canonicalize a non-integer number for JCS.', + ); + } + return JSON.stringify(value); + } + + if (typeof value === 'string') { + return JSON.stringify(value); + } + + if (Array.isArray(value)) { + return `[${value.map((item) => canonicalizeJson(item)).join(',')}]`; + } + + const entries = Object.keys(value) + .sort() + .reduce((acc, key) => { + const child = value[key]; + if (child !== undefined) { + acc.push(`${JSON.stringify(key)}:${canonicalizeJson(child)}`); + } + return acc; + }, []); + + return `{${entries.join(',')}}`; +} + +/** + * Formats a date as RFC 3339 UTC with whole-second precision (e.g. + * `2026-07-07T00:00:00Z`). `Date.prototype.toISOString` always emits + * milliseconds (`...:00.000Z`); the `storage_access_token` wire format omits + * fractional seconds, so the sub-second component is truncated (not rounded). + * + * @param date - The date to format. + * @returns The RFC 3339 timestamp without fractional seconds. + */ +function toRfc3339Seconds(date: Date): string { + return `${date.toISOString().slice(0, 19)}Z`; +} + +/** + * Builds and signs a `storage_access_token`. + * + * @param params - See {@link SignStorageAccessTokenParams}. + * @returns The signed token envelope. + */ +export function signStorageAccessToken( + params: SignStorageAccessTokenParams, +): UkycStorageAccessToken { + const { + material, + operations, + presenter = 'client', + sessionId, + issuedAt = new Date(), + expiresAt, + } = params; + + assertValidOperations(operations); + + if (expiresAt.getTime() <= issuedAt.getTime()) { + throw new Error( + 'UKYC: storage_access_token expires_at must be after issued_at.', + ); + } + + const isDelete = operations.includes('delete'); + + if (presenter === 'idos-relay' && isDelete) { + throw new Error( + 'UKYC: a delete-scoped storage_access_token cannot be delegated to the Relay.', + ); + } + + if (presenter === 'idos-relay' && !sessionId) { + throw new Error( + 'UKYC: a Relay-presented storage_access_token requires a session_id.', + ); + } + + const payload: UkycStorageAccessTokenPayload = { + version: UKYC_STORAGE_ACCESS_TOKEN_VERSION, + aud: [...UKYC_STORAGE_ACCESS_TOKEN_AUDIENCES], + storage_id: toBase64Url(material.storageId), + signing_public_key: toBase64Url(material.signingPublicKey), + operations, + presenter, + issued_at: toRfc3339Seconds(issuedAt), + expires_at: toRfc3339Seconds(expiresAt), + }; + + // Only bind session_id for Relay-presented tokens; omit the key entirely for + // client-presented tokens so it does not appear in the canonicalized payload. + if (presenter === 'idos-relay') { + payload.session_id = sessionId; + } + + const message = stringToBytes(canonicalizeJson(payload)); + const signature = ed25519.sign(message, material.signingKey); + + return { + payload, + signature: toBase64Url(signature), + }; +} + +/** + * Serializes a signed token to a compact string suitable for header transport + * (`Authorization: AccessToken `, see `UKYC_CAPABILITY_AUTH_SCHEME`). The + * complete envelope is base64url-encoded; the private `signing_key` is never + * included. + * + * @param token - The signed token envelope. + * @returns The base64url-encoded envelope string. + */ +export function encodeStorageAccessTokenForHeader( + token: UkycStorageAccessToken, +): string { + return toBase64Url(stringToBytes(JSON.stringify(token))); +} + +/** + * Validates that an operations list is one storage understands: a non-empty set + * of `read`/`write`, or exactly `['delete']`. `delete` is never combined with + * other operations. + * + * @param operations - The requested operations. + */ +function assertValidOperations(operations: UkycStorageOperation[]): void { + if (operations.length === 0) { + throw new Error( + 'UKYC: storage_access_token requires at least one operation.', + ); + } + + const unique = new Set(operations); + + if (unique.size !== operations.length) { + throw new Error('UKYC: storage_access_token operations must be unique.'); + } + + if (unique.has('delete') && operations.length > 1) { + throw new Error( + 'UKYC: a delete-scoped storage_access_token must contain only "delete".', + ); + } +} diff --git a/packages/kyc-controller/src/ukyc/testToken.test.ts b/packages/kyc-controller/src/ukyc/testToken.test.ts new file mode 100644 index 00000000000..0098a6bbbc6 --- /dev/null +++ b/packages/kyc-controller/src/ukyc/testToken.test.ts @@ -0,0 +1,133 @@ +import { hexToBytes, stringToBytes } from '@metamask/utils'; +import { ed25519 } from '@noble/curves/ed25519'; + +import { base64UrlToBytes } from '../encoding.js'; +import { + UKYC_CAPABILITY_AUTH_SCHEME, + UKYC_STORAGE_ACCESS_TOKEN_AUDIENCE, + UKYC_KWIL_AUDIENCE, +} from './constants.js'; +import { canonicalizeJson } from './storageAccessToken.js'; +import { mintUkycTestToken } from './testToken.js'; + +// A fixed 32-byte secret (all 0x42), as hex, so storage_id and keys are stable. +const SECRET_HEX = '42'.repeat(32); +const ISSUED_AT = new Date('2026-07-07T00:00:00Z'); +const EXPIRES_AT = new Date('2026-07-07T04:00:00Z'); + +/** + * Splits an `AccessToken ` header and decodes the credentials into the + * envelope, the way UKYC Storage does on the wire. + * + * @param header - The full Authorization header value. + * @returns The decoded token envelope. + */ +function decodeHeader(header: string): { + payload: Record; + signature: string; +} { + const [scheme, creds] = header.split(' '); + expect(scheme).toBe(UKYC_CAPABILITY_AUTH_SCHEME); + return JSON.parse(new TextDecoder().decode(base64UrlToBytes(creds))); +} + +describe('UKYC mintUkycTestToken', () => { + it('mints a client token from a hex secret with the derived identifiers', () => { + const result = mintUkycTestToken({ + localUserSecret: SECRET_HEX, + operations: ['read', 'write'], + issuedAt: ISSUED_AT, + expiresAt: EXPIRES_AT, + }); + + expect(result.localUserSecret).toBe(SECRET_HEX); + expect(result.token.payload).toMatchObject({ + version: 1, + aud: [UKYC_STORAGE_ACCESS_TOKEN_AUDIENCE, UKYC_KWIL_AUDIENCE], + operations: ['read', 'write'], + presenter: 'client', + issued_at: '2026-07-07T00:00:00Z', + expires_at: '2026-07-07T04:00:00Z', + }); + // storage_id / signing_public_key are the client-derived values, not + // anything a server fills in. + expect(result.storageId).toBe(result.token.payload.storage_id); + expect(result.signingPublicKey).toBe( + result.token.payload.signing_public_key, + ); + expect(result.token.payload).not.toHaveProperty('session_id'); + }); + + it('produces an Authorization header whose signature verifies (as UKYC Storage checks it)', () => { + const result = mintUkycTestToken({ + localUserSecret: SECRET_HEX, + issuedAt: ISSUED_AT, + expiresAt: EXPIRES_AT, + }); + + const envelope = decodeHeader(result.authorizationHeader); + const message = stringToBytes(canonicalizeJson(envelope.payload)); + const signature = base64UrlToBytes(envelope.signature); + const publicKey = base64UrlToBytes(result.signingPublicKey); + + expect(ed25519.verify(signature, message, publicKey)).toBe(true); + }); + + it('defaults operations to ["read"] and expiry to issued_at + 4h', () => { + const result = mintUkycTestToken({ + localUserSecret: SECRET_HEX, + issuedAt: ISSUED_AT, + }); + + expect(result.token.payload.operations).toStrictEqual(['read']); + expect(result.token.payload.issued_at).toBe('2026-07-07T00:00:00Z'); + expect(result.token.payload.expires_at).toBe('2026-07-07T04:00:00Z'); + }); + + it('accepts a raw byte secret and is deterministic for the same inputs', () => { + const secret = hexToBytes(SECRET_HEX); + const params = { + localUserSecret: secret, + issuedAt: ISSUED_AT, + expiresAt: EXPIRES_AT, + }; + + expect(mintUkycTestToken(params)).toStrictEqual(mintUkycTestToken(params)); + }); + + it('generates a fresh random secret when none is supplied', () => { + const result = mintUkycTestToken(); + + // 32 bytes hex-encoded. + expect(result.localUserSecret).toMatch(/^[0-9a-f]{64}$/u); + expect(result.token.payload.operations).toStrictEqual(['read']); + expect( + result.authorizationHeader.startsWith(`${UKYC_CAPABILITY_AUTH_SCHEME} `), + ).toBe(true); + }); + + it('binds session_id for a Relay-presented token', () => { + const result = mintUkycTestToken({ + localUserSecret: SECRET_HEX, + operations: ['read', 'write'], + presenter: 'idos-relay', + sessionId: 'session-1', + issuedAt: ISSUED_AT, + expiresAt: EXPIRES_AT, + }); + + expect(result.token.payload.presenter).toBe('idos-relay'); + expect(result.token.payload.session_id).toBe('session-1'); + }); + + it('rejects a Relay presenter without a session_id', () => { + expect(() => + mintUkycTestToken({ + localUserSecret: SECRET_HEX, + presenter: 'idos-relay', + issuedAt: ISSUED_AT, + expiresAt: EXPIRES_AT, + }), + ).toThrow('requires a session_id'); + }); +}); diff --git a/packages/kyc-controller/src/ukyc/testToken.ts b/packages/kyc-controller/src/ukyc/testToken.ts new file mode 100644 index 00000000000..055d1b08448 --- /dev/null +++ b/packages/kyc-controller/src/ukyc/testToken.ts @@ -0,0 +1,132 @@ +import { bytesToHex, hexToBytes, randomBytes } from '@noble/hashes/utils'; + +import { + UKYC_CAPABILITY_AUTH_SCHEME, + UKYC_LOCAL_USER_SECRET_SIZE_BYTES, +} from './constants.js'; +import { + deriveClientMaterial, + encodeClientMaterial, +} from './deriveClientMaterial.js'; +import { + encodeStorageAccessTokenForHeader, + signStorageAccessToken, +} from './storageAccessToken.js'; +import type { + UkycStorageAccessToken, + UkycStorageOperation, + UkycTokenPresenter, +} from './storageAccessToken.js'; + +/** + * Mints ready-to-use UKYC `storage_access_token`s for testing UKYC Storage. + * + * This composes the same pure functions the MetaMask client uses in production + * (`deriveClientMaterial` + `signStorageAccessToken`), so a third party such as + * idOS can produce a valid, signed token without deriving one on-device. No + * server "fills in" the `signing_public_key`: it is derived from a + * `local_user_secret` the caller controls and registered on first write, so + * reusing the same secret yields a stable `storage_id` and controlling key. + */ + +/** Default token lifetime when `expiresAt` is not supplied (4 hours). */ +const DEFAULT_TOKEN_LIFETIME_MS = 4 * 60 * 60 * 1000; + +/** + * Inputs for {@link mintUkycTestToken}. All fields are optional; the only value + * a caller usually pins is `localUserSecret`, so `storage_id` and the signing + * key stay stable across runs. + */ +export type MintUkycTestTokenParams = { + /** + * The root `local_user_secret`, as raw 32 bytes or a hex string. When omitted + * a fresh random secret is generated (returned in the result so it can be + * reused). + */ + localUserSecret?: Uint8Array | string; + /** Operations the token authorizes. Defaults to `['read']`. */ + operations?: UkycStorageOperation[]; + /** Who will present the token. Defaults to `client`. */ + presenter?: UkycTokenPresenter; + /** UKYC session id. Required when `presenter` is `idos-relay`. */ + sessionId?: string; + /** Token issue time. Defaults to now. */ + issuedAt?: Date; + /** Token expiry. Defaults to `issuedAt` + 4 hours. */ + expiresAt?: Date; +}; + +/** + * The minted token plus everything needed to exercise UKYC Storage with it. + */ +export type MintedUkycTestToken = { + /** The `local_user_secret` used, hex-encoded, so the caller can reuse it. */ + localUserSecret: string; + /** base64url `storage_id` — use it as the `{storage_id}` path segment. */ + storageId: string; + /** base64url `signing_public_key` registered on first write. */ + signingPublicKey: string; + /** The signed token envelope (payload + signature). */ + token: UkycStorageAccessToken; + /** + * The full `Authorization` header value, e.g. + * `AccessToken `, ready to send to UKYC Storage. + */ + authorizationHeader: string; +}; + +/** + * Resolves the caller-supplied secret into raw bytes, generating a random one + * when none is provided. + * + * @param secret - Raw 32 bytes, a hex string, or undefined for a random secret. + * @returns The `local_user_secret` bytes. + */ +function resolveLocalUserSecret(secret?: Uint8Array | string): Uint8Array { + if (secret === undefined) { + return randomBytes(UKYC_LOCAL_USER_SECRET_SIZE_BYTES); + } + return typeof secret === 'string' ? hexToBytes(secret) : secret; +} + +/** + * Mints a signed UKYC `storage_access_token` for testing. + * + * @param params - See {@link MintUkycTestTokenParams}. + * @returns The token, its `Authorization` header, and the derived identifiers. + */ +export function mintUkycTestToken( + params: MintUkycTestTokenParams = {}, +): MintedUkycTestToken { + const { + operations = ['read'], + presenter, + sessionId, + issuedAt = new Date(), + expiresAt = new Date(issuedAt.getTime() + DEFAULT_TOKEN_LIFETIME_MS), + } = params; + + const localUserSecret = resolveLocalUserSecret(params.localUserSecret); + const material = deriveClientMaterial(localUserSecret); + + const token = signStorageAccessToken({ + material, + operations, + presenter, + sessionId, + issuedAt, + expiresAt, + }); + + const { storageId, signingPublicKey } = encodeClientMaterial(material); + + return { + localUserSecret: bytesToHex(localUserSecret), + storageId, + signingPublicKey, + token, + authorizationHeader: `${UKYC_CAPABILITY_AUTH_SCHEME} ${encodeStorageAccessTokenForHeader( + token, + )}`, + }; +} diff --git a/packages/kyc-controller/src/ukyc/wrapEncryptionKey.test.ts b/packages/kyc-controller/src/ukyc/wrapEncryptionKey.test.ts new file mode 100644 index 00000000000..2dc616c2f1f --- /dev/null +++ b/packages/kyc-controller/src/ukyc/wrapEncryptionKey.test.ts @@ -0,0 +1,138 @@ +import { areUint8ArraysEqual } from '@metamask/utils'; +import { box } from 'tweetnacl'; + +import { base64UrlToBytes, toBase64Url } from '../encoding.js'; +import { wrapEncryptionKey } from './wrapEncryptionKey.js'; + +const DATA_ENCRYPTION_KEY = new Uint8Array(32).fill(7); + +/** + * Reverses {@link wrapEncryptionKey} from the server's perspective: reads the + * client public key from the first 32 bytes of `data` and opens the NaCl box + * with the server private key. The client also registers this public key on + * `createUkycSession`. + * + * @param serverPrivateKey - The server's X25519 private key. + * @param data - The base64url `clientPublicKey || ciphertext+tag`. + * @param nonce - The base64url nonce. + * @returns The recovered plaintext. + */ +function unwrap( + serverPrivateKey: Uint8Array, + data: string, + nonce: string, +): Uint8Array { + const packed = base64UrlToBytes(data); + const clientPublicKey = packed.slice(0, box.publicKeyLength); + const ciphertext = packed.slice(box.publicKeyLength); + const recovered = box.open( + ciphertext, + base64UrlToBytes(nonce), + clientPublicKey, + serverPrivateKey, + ); + if (recovered === null) { + throw new Error('Failed to open NaCl box'); + } + return recovered; +} + +describe('UKYC wrapEncryptionKey', () => { + it('wraps a key the session server can recover using only the packed data', () => { + const serverKeyPair = box.keyPair(); + const clientKeyPair = box.keyPair(); + + const { data, nonce } = wrapEncryptionKey( + clientKeyPair.secretKey, + toBase64Url(serverKeyPair.publicKey), + DATA_ENCRYPTION_KEY, + ); + + const recovered = unwrap(serverKeyPair.secretKey, data, nonce); + expect(areUint8ArraysEqual(recovered, DATA_ENCRYPTION_KEY)).toBe(true); + }); + + it('prefixes the client public key onto data so the server can open the box', () => { + const serverKeyPair = box.keyPair(); + const clientKeyPair = box.keyPair(); + + const { data } = wrapEncryptionKey( + clientKeyPair.secretKey, + toBase64Url(serverKeyPair.publicKey), + DATA_ENCRYPTION_KEY, + ); + + const packed = base64UrlToBytes(data); + expect(packed.slice(0, box.publicKeyLength)).toStrictEqual( + clientKeyPair.publicKey, + ); + }); + + it('cannot be opened if data is treated as ciphertext with no embedded public key', () => { + const serverKeyPair = box.keyPair(); + const clientKeyPair = box.keyPair(); + + const { data, nonce } = wrapEncryptionKey( + clientKeyPair.secretKey, + toBase64Url(serverKeyPair.publicKey), + DATA_ENCRYPTION_KEY, + ); + + const opened = box.open( + base64UrlToBytes(data), + base64UrlToBytes(nonce), + clientKeyPair.publicKey, + serverKeyPair.secretKey, + ); + expect(opened).toBeNull(); + }); + + it('emits base64url fields', () => { + const serverPublicKey = box.keyPair().publicKey; + const clientPrivateKey = box.keyPair().secretKey; + + const { data, nonce } = wrapEncryptionKey( + clientPrivateKey, + toBase64Url(serverPublicKey), + DATA_ENCRYPTION_KEY, + ); + + expect(data).toMatch(/^[A-Za-z0-9\-_]+$/u); + expect(nonce).toMatch(/^[A-Za-z0-9\-_]+$/u); + }); + + it('wraps an arbitrary-length payload the session server can recover', () => { + const serverKeyPair = box.keyPair(); + const clientKeyPair = box.keyPair(); + const tokenBytes = new Uint8Array(64).map((_, i) => i + 1); + + const { data, nonce } = wrapEncryptionKey( + clientKeyPair.secretKey, + toBase64Url(serverKeyPair.publicKey), + tokenBytes, + ); + + const recovered = unwrap(serverKeyPair.secretKey, data, nonce); + expect(areUint8ArraysEqual(recovered, tokenBytes)).toBe(true); + }); + + it('uses a fresh nonce per call', () => { + const serverPublicKey = box.keyPair().publicKey; + const clientPrivateKey = box.keyPair().secretKey; + const serverPublicKeyB64 = toBase64Url(serverPublicKey); + + const first = wrapEncryptionKey( + clientPrivateKey, + serverPublicKeyB64, + DATA_ENCRYPTION_KEY, + ); + const second = wrapEncryptionKey( + clientPrivateKey, + serverPublicKeyB64, + DATA_ENCRYPTION_KEY, + ); + + expect(first.nonce).not.toBe(second.nonce); + expect(first.data).not.toBe(second.data); + }); +}); diff --git a/packages/kyc-controller/src/ukyc/wrapEncryptionKey.ts b/packages/kyc-controller/src/ukyc/wrapEncryptionKey.ts new file mode 100644 index 00000000000..67e01399b8c --- /dev/null +++ b/packages/kyc-controller/src/ukyc/wrapEncryptionKey.ts @@ -0,0 +1,66 @@ +import { randomBytes, box } from 'tweetnacl'; + +import { base64UrlToBytes, toBase64Url } from '../encoding.js'; + +/** + * Wraps a secret for the UKYC session server using NaCl's `crypto_box` + * (X25519 + XSalsa20-Poly1305) established with a per-secret wrapping key + * returned inside an encryption schema from `createUkycSession`. + * + * Reuses a session client keypair whose public half is registered on + * `createUkycSession`. `data` is still `clientPublicKey (32) || ciphertext+tag` + * so the box can be opened from `{ data, nonce }` alone. Used for both the + * `data_encryption_key` and the `ukyc_capability_token`. + */ + +/** + * The transmitted portion of a wrapped secret: the sender public key prefixed + * to the `crypto_box` ciphertext (which includes the 16-byte Poly1305 auth + * tag), and the nonce, both unpadded base64url-encoded. Matches the KYC API + * `CapabilityAuthorization` wire shape. + */ +export type WrappedEncryptionKeyParts = { + data: string; + nonce: string; +}; + +/** + * Wraps `plaintext` for the UKYC session server. + * + * The box is sealed with NaCl's `crypto_box`, keyed by the X25519 shared secret + * between our session client private key and the session server public key + * from an encryption schema (`encryptionDataKey` or `ukycCapabilityToken`) + * returned by `createUkycSession`. The 32-byte client public key is still + * prefixed onto `data` so the box is self-describing on the wire. + * + * @param sessionClientPrivateKey - Our session's X25519 private key. + * @param sessionServerPublicKey - The server's X25519 public key (base64url). + * @param plaintext - The raw bytes to encrypt (e.g. the `data_encryption_key` + * or the encoded `ukyc_capability_token`). + * @returns The base64url `data` (`clientPublicKey || ciphertext+tag`) and + * `nonce`. + */ +export function wrapEncryptionKey( + sessionClientPrivateKey: Uint8Array, + sessionServerPublicKey: string, + plaintext: Uint8Array, +): WrappedEncryptionKeyParts { + const serverPublicKey = base64UrlToBytes(sessionServerPublicKey); + const { publicKey: clientPublicKey } = box.keyPair.fromSecretKey( + sessionClientPrivateKey, + ); + const nonce = randomBytes(box.nonceLength); + const ciphertext = box( + plaintext, + nonce, + serverPublicKey, + sessionClientPrivateKey, + ); + const data = new Uint8Array(clientPublicKey.length + ciphertext.length); + data.set(clientPublicKey, 0); + data.set(ciphertext, clientPublicKey.length); + return { + data: toBase64Url(data), + nonce: toBase64Url(nonce), + }; +} diff --git a/packages/kyc-controller/src/ukyc/wrappedRelayPayload.test.ts b/packages/kyc-controller/src/ukyc/wrappedRelayPayload.test.ts new file mode 100644 index 00000000000..bfc422dd7fa --- /dev/null +++ b/packages/kyc-controller/src/ukyc/wrappedRelayPayload.test.ts @@ -0,0 +1,90 @@ +import { UKYC_LOCAL_USER_SECRET_SIZE_BYTES } from './constants.js'; +import { deriveClientMaterial } from './deriveClientMaterial.js'; +import type { UkycStorageAccessToken } from './storageAccessToken.js'; +import { signStorageAccessToken } from './storageAccessToken.js'; +import { buildWrappedRelayPayload } from './wrappedRelayPayload.js'; + +const LOCAL_USER_SECRET = new Uint8Array( + UKYC_LOCAL_USER_SECRET_SIZE_BYTES, +).fill(42); +const MATERIAL = deriveClientMaterial(LOCAL_USER_SECRET); +const ISSUED_AT = new Date('2026-07-07T00:00:00.000Z'); +const EXPIRES_AT = new Date('2026-07-07T04:00:00.000Z'); + +/** + * Mints a token with a given presenter/operations for the tests below. + * + * @param presenter - The token presenter. + * @param operations - The token operations. + * @returns The signed token. + */ +function tokenFor( + presenter: 'client' | 'idos-relay', + operations: ('read' | 'write' | 'delete')[], +): UkycStorageAccessToken { + return signStorageAccessToken({ + material: MATERIAL, + operations, + presenter, + sessionId: presenter === 'idos-relay' ? 'session-1' : undefined, + issuedAt: ISSUED_AT, + expiresAt: EXPIRES_AT, + }); +} + +describe('UKYC buildWrappedRelayPayload', () => { + it('bundles the Relay-facing material with the token', () => { + const token = tokenFor('idos-relay', ['read', 'write']); + + const payload = buildWrappedRelayPayload(MATERIAL, token); + + expect(payload.storage_id).toMatch(/^[A-Za-z0-9_-]+$/u); + expect(payload.data_encryption_key).toMatch(/^[A-Za-z0-9_-]+$/u); + expect(payload.signing_public_key).toMatch(/^[A-Za-z0-9_-]+$/u); + expect(payload.storage_access_token).toBe(token); + }); + + it('shares the data_encryption_key with the Relay', () => { + const token = tokenFor('idos-relay', ['read']); + + const payload = buildWrappedRelayPayload(MATERIAL, token); + + // The DEK is intentionally included so the Relay can encrypt/decrypt. + expect(payload.data_encryption_key.length).toBeGreaterThan(0); + }); + + it('never leaks the local secret or private signing key', () => { + const token = tokenFor('idos-relay', ['read']); + + const payload = buildWrappedRelayPayload(MATERIAL, token); + + expect(Object.keys(payload).sort()).toStrictEqual([ + 'data_encryption_key', + 'signing_public_key', + 'storage_access_token', + 'storage_id', + ]); + }); + + it('rejects a client-presented token', () => { + const token = tokenFor('client', ['read']); + + expect(() => buildWrappedRelayPayload(MATERIAL, token)).toThrow( + 'requires a Relay-presented storage_access_token', + ); + }); + + it('rejects a delete-scoped token', () => { + // A delete token cannot be Relay-presented, so craft one that slips past + // signing by mutating the presenter after the fact. + const token = tokenFor('client', ['delete']); + const relayDeleteToken = { + ...token, + payload: { ...token.payload, presenter: 'idos-relay' as const }, + }; + + expect(() => buildWrappedRelayPayload(MATERIAL, relayDeleteToken)).toThrow( + 'must not carry a delete-scoped storage_access_token', + ); + }); +}); diff --git a/packages/kyc-controller/src/ukyc/wrappedRelayPayload.ts b/packages/kyc-controller/src/ukyc/wrappedRelayPayload.ts new file mode 100644 index 00000000000..2fe21e50845 --- /dev/null +++ b/packages/kyc-controller/src/ukyc/wrappedRelayPayload.ts @@ -0,0 +1,65 @@ +import { toBase64Url } from '../encoding.js'; +import type { UkycClientMaterial } from './deriveClientMaterial.js'; +import type { UkycStorageAccessToken } from './storageAccessToken.js'; + +/** + * Builds the `wrapped_relay_payload` — the only client-derived material that + * leaves the device. It is sent to the idOS Relay (through the UKYC API) so the + * Relay can encrypt/decrypt and store KYC payloads on the user's behalf. See the + * architecture doc, section "Client-Derived Material". + * + * Neither `local_user_secret` nor the private `signing_key` are ever included. + * The `data_encryption_key` *is* included: it is intentionally shared with the + * Relay so it can encrypt/decrypt payloads transiently. + */ + +/** + * The Relay-facing bundle. `data_encryption_key` is base64url-encoded secret + * material shared with the Relay; the other fields are non-secret. + */ +export type UkycWrappedRelayPayload = { + // Wire-shape fields are snake_case. + /* eslint-disable @typescript-eslint/naming-convention */ + storage_id: string; + data_encryption_key: string; + signing_public_key: string; + storage_access_token: UkycStorageAccessToken; + /* eslint-enable @typescript-eslint/naming-convention */ +}; + +/** + * Assembles the `wrapped_relay_payload` from derived client material and a + * Relay-presented `storage_access_token`. + * + * The token must be scoped for the Relay to present (`presenter: 'idos-relay'`) + * and must not authorize `delete`, which is never delegated to the Relay. + * + * @param material - Client material derived from `local_user_secret`. + * @param storageAccessToken - A `read`/`write`-scoped, Relay-presented token. + * @returns The `wrapped_relay_payload` to send to the Relay via the UKYC API. + */ +export function buildWrappedRelayPayload( + material: UkycClientMaterial, + storageAccessToken: UkycStorageAccessToken, +): UkycWrappedRelayPayload { + const { presenter, operations } = storageAccessToken.payload; + + if (presenter !== 'idos-relay') { + throw new Error( + 'UKYC: wrapped_relay_payload requires a Relay-presented storage_access_token.', + ); + } + + if (operations.includes('delete')) { + throw new Error( + 'UKYC: wrapped_relay_payload must not carry a delete-scoped storage_access_token.', + ); + } + + return { + storage_id: toBase64Url(material.storageId), + data_encryption_key: toBase64Url(material.dataEncryptionKey), + signing_public_key: toBase64Url(material.signingPublicKey), + storage_access_token: storageAccessToken, + }; +} diff --git a/packages/kyc-controller/tsconfig.build.json b/packages/kyc-controller/tsconfig.build.json new file mode 100644 index 00000000000..d355169e16c --- /dev/null +++ b/packages/kyc-controller/tsconfig.build.json @@ -0,0 +1,17 @@ +{ + "extends": "../../tsconfig.packages.build.json", + "compilerOptions": { + "baseUrl": "./", + "outDir": "./dist", + "rootDir": "./src" + }, + "references": [ + { "path": "../base-controller/tsconfig.build.json" }, + { "path": "../base-data-service/tsconfig.build.json" }, + { "path": "../controller-utils/tsconfig.build.json" }, + { "path": "../geolocation-controller/tsconfig.build.json" }, + { "path": "../messenger/tsconfig.build.json" }, + { "path": "../profile-sync-controller/tsconfig.build.json" } + ], + "include": ["../../types", "./src"] +} diff --git a/packages/kyc-controller/tsconfig.json b/packages/kyc-controller/tsconfig.json new file mode 100644 index 00000000000..1079229158f --- /dev/null +++ b/packages/kyc-controller/tsconfig.json @@ -0,0 +1,15 @@ +{ + "extends": "../../tsconfig.packages.json", + "compilerOptions": { + "baseUrl": "./" + }, + "references": [ + { "path": "../base-controller" }, + { "path": "../base-data-service" }, + { "path": "../controller-utils" }, + { "path": "../geolocation-controller" }, + { "path": "../messenger" }, + { "path": "../profile-sync-controller" } + ], + "include": ["../../types", "./src", "./scripts"] +} diff --git a/packages/kyc-controller/typedoc.json b/packages/kyc-controller/typedoc.json new file mode 100644 index 00000000000..c9da015dbf8 --- /dev/null +++ b/packages/kyc-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/local-node-utils/CHANGELOG.md b/packages/local-node-utils/CHANGELOG.md new file mode 100644 index 00000000000..a6197a256d6 --- /dev/null +++ b/packages/local-node-utils/CHANGELOG.md @@ -0,0 +1,20 @@ +# 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 ([#9314](https://github.com/MetaMask/core/pull/9314)) + - Cache directory resolution from Yarn config + - Artifact config helpers, checksum verification, and downloads + - Archive extraction, executable wrappers, and filesystem helpers + +[Unreleased]: https://github.com/MetaMask/core/compare/@metamask/local-node-utils@1.0.0...HEAD +[1.0.0]: https://github.com/MetaMask/core/releases/tag/@metamask/local-node-utils@1.0.0 diff --git a/packages/local-node-utils/LICENSE b/packages/local-node-utils/LICENSE new file mode 100644 index 00000000000..9ec4f4514ea --- /dev/null +++ b/packages/local-node-utils/LICENSE @@ -0,0 +1,6 @@ +This project is licensed under either of + + * MIT license ([LICENSE.MIT](LICENSE.MIT)) + * Apache License, Version 2.0 ([LICENSE.APACHE2](LICENSE.APACHE2)) + +at your option. diff --git a/packages/local-node-utils/LICENSE.APACHE2 b/packages/local-node-utils/LICENSE.APACHE2 new file mode 100644 index 00000000000..56752e8ff49 --- /dev/null +++ b/packages/local-node-utils/LICENSE.APACHE2 @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright 2026 MetaMask + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/packages/local-node-utils/LICENSE.MIT b/packages/local-node-utils/LICENSE.MIT new file mode 100644 index 00000000000..fe29e78e0fe --- /dev/null +++ b/packages/local-node-utils/LICENSE.MIT @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 MetaMask + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packages/local-node-utils/README.md b/packages/local-node-utils/README.md new file mode 100644 index 00000000000..c3f1a6658c8 --- /dev/null +++ b/packages/local-node-utils/README.md @@ -0,0 +1,25 @@ +# `@metamask/local-node-utils` + +Shared utilities for MetaMask local node runtime installers such as +`java-tron-up`, `bitcoin-regtest-up`, and `solana-test-validator-up`. + +## Installation + +`yarn add @metamask/local-node-utils` + +or + +`npm install @metamask/local-node-utils` + +## API + +The package exports shared helpers for: + +- Resolving MetaMask cache directories from Yarn configuration +- Parsing artifact platform configuration and cache keys +- Downloading release archives with checksum verification +- Extracting archives and installing executable wrappers in `node_modules/.bin` + +## 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/local-node-utils/jest.config.js b/packages/local-node-utils/jest.config.js new file mode 100644 index 00000000000..e733dd05831 --- /dev/null +++ b/packages/local-node-utils/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: 80.45, + functions: 96.42, + lines: 95.09, + statements: 95.09, + }, + }, +}); diff --git a/packages/local-node-utils/package.json b/packages/local-node-utils/package.json new file mode 100644 index 00000000000..575f2286b30 --- /dev/null +++ b/packages/local-node-utils/package.json @@ -0,0 +1,76 @@ +{ + "name": "@metamask/local-node-utils", + "version": "1.0.0", + "description": "Shared utilities for MetaMask local node runtime installers", + "keywords": [ + "Ethereum", + "MetaMask" + ], + "homepage": "https://github.com/MetaMask/core/tree/main/packages/local-node-utils#readme", + "bugs": { + "url": "https://github.com/MetaMask/core/issues" + }, + "license": "(MIT OR Apache-2.0)", + "repository": { + "type": "git", + "url": "https://github.com/MetaMask/core.git" + }, + "files": [ + "dist/" + ], + "sideEffects": false, + "main": "./dist/index.cjs", + "types": "./dist/index.d.cts", + "exports": { + ".": { + "import": { + "types": "./dist/index.d.mts", + "default": "./dist/index.mjs" + }, + "require": { + "types": "./dist/index.d.cts", + "default": "./dist/index.cjs" + } + }, + "./package.json": "./package.json" + }, + "publishConfig": { + "access": "public", + "registry": "https://registry.npmjs.org/" + }, + "scripts": { + "build": "ts-bridge --project tsconfig.build.json --verbose --clean --no-references", + "build:all": "ts-bridge --project tsconfig.build.json --verbose --clean", + "build:docs": "typedoc", + "changelog:update": "../../scripts/update-changelog.sh @metamask/local-node-utils", + "changelog:validate": "../../scripts/validate-changelog.sh @metamask/local-node-utils", + "lint:tsconfigs": "tsx ../../scripts/lint-tsconfigs/lint-tsconfigs.mts", + "lint:tsconfigs:fix": "tsx ../../scripts/lint-tsconfigs/lint-tsconfigs.mts --fix", + "messenger-action-types:check": "tsx ../../packages/messenger-cli/src/cli.ts --formatter oxfmt --check", + "messenger-action-types:generate": "tsx ../../packages/messenger-cli/src/cli.ts --formatter oxfmt --generate", + "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": { + "yaml": "^2.3.4" + }, + "devDependencies": { + "@metamask/auto-changelog": "^6.1.0", + "@ts-bridge/cli": "^0.6.4", + "@types/jest": "^30.0.0", + "deepmerge": "^4.2.2", + "jest": "^30.4.2", + "nock": "^13.3.1", + "ts-jest": "^29.4.11", + "tsx": "^4.20.5", + "typedoc": "^0.25.13", + "typedoc-plugin-missing-exports": "^2.0.0", + "typescript": "~5.3.3" + }, + "engines": { + "node": "^18.18 || >=20" + } +} diff --git a/packages/local-node-utils/src/archive.ts b/packages/local-node-utils/src/archive.ts new file mode 100644 index 00000000000..81fc6bf86b6 --- /dev/null +++ b/packages/local-node-utils/src/archive.ts @@ -0,0 +1,15 @@ +import { runCommand } from './command.js'; + +export async function extractTarGzArchive( + archivePath: string, + destination: string, +): Promise { + await runCommand('tar', ['-xzf', archivePath, '-C', destination]); +} + +export async function extractTarBz2Archive( + archivePath: string, + destination: string, +): Promise { + await runCommand('tar', ['-xjf', archivePath, '-C', destination]); +} diff --git a/packages/local-node-utils/src/artifact.ts b/packages/local-node-utils/src/artifact.ts new file mode 100644 index 00000000000..4a7093ff081 --- /dev/null +++ b/packages/local-node-utils/src/artifact.ts @@ -0,0 +1,52 @@ +/* eslint-disable import-x/no-nodejs-modules */ +import { createHash } from 'node:crypto'; + +import type { ArtifactConfig, ArtifactPlatformConfig } from './types.js'; + +export function mergeArtifactConfig( + defaults: ArtifactConfig, + override: ArtifactConfig | undefined, +): ArtifactConfig { + if (!override) { + return defaults; + } + + return { + version: override.version ?? defaults.version, + platforms: { ...defaults.platforms, ...override.platforms }, + }; +} + +export function resolvePlatformConfig( + config: ArtifactConfig, + platform: string, + label: string, +): ArtifactPlatformConfig { + const platformConfig = config.platforms.current ?? config.platforms[platform]; + + if (!platformConfig) { + throw new Error(`No ${label} is configured for ${platform}.`); + } + + return platformConfig; +} + +export function requireCompletePlatformConfig( + config: Partial, + label: string, +): ArtifactPlatformConfig { + if (!config.url || !config.checksum) { + throw new Error(`${label} require both a URL and a checksum.`); + } + + return { + checksum: config.checksum, + url: config.url, + }; +} + +export function getCacheKey(config: ArtifactPlatformConfig): string { + return createHash('sha256') + .update(`${config.url}:${config.checksum}`) + .digest('hex'); +} diff --git a/packages/local-node-utils/src/cache-directory.ts b/packages/local-node-utils/src/cache-directory.ts new file mode 100644 index 00000000000..be720afddd1 --- /dev/null +++ b/packages/local-node-utils/src/cache-directory.ts @@ -0,0 +1,37 @@ +/* eslint-disable import-x/no-nodejs-modules, no-restricted-globals */ +import { readFileSync } from 'node:fs'; +import { homedir } from 'node:os'; +import { join } from 'node:path'; +import { parse as parseYaml } from 'yaml'; + +import { isFileMissingError } from './errors.js'; + +export function getMetamaskCacheDirectory({ + cwd = process.cwd(), + homeDirectory = homedir(), + toolName = 'local-node-utils', +}: { + cwd?: string; + homeDirectory?: string; + toolName?: string; +} = {}): string { + const yarnRcPath = join(cwd, '.yarnrc.yml'); + let enableGlobalCache = false; + + try { + const parsedConfig = parseYaml(readFileSync(yarnRcPath, 'utf8')); + enableGlobalCache = parsedConfig?.enableGlobalCache ?? false; + } catch (error) { + if (isFileMissingError(error)) { + return join(cwd, '.metamask', 'cache'); + } + console.warn( + `Warning: Error reading ${yarnRcPath}, using local ${toolName} cache:`, + error, + ); + } + + return enableGlobalCache + ? join(homeDirectory, '.cache', 'metamask') + : join(cwd, '.metamask', 'cache'); +} diff --git a/packages/local-node-utils/src/cache.ts b/packages/local-node-utils/src/cache.ts new file mode 100644 index 00000000000..e1bd3630175 --- /dev/null +++ b/packages/local-node-utils/src/cache.ts @@ -0,0 +1,16 @@ +/* eslint-disable import-x/no-nodejs-modules */ +import { rm } from 'node:fs/promises'; +import { join } from 'node:path'; + +export async function cleanInstallerCache({ + cacheDirectory, + namespace, +}: { + cacheDirectory: string; + namespace: string; +}): Promise { + await rm(join(cacheDirectory, namespace), { + force: true, + recursive: true, + }); +} diff --git a/packages/local-node-utils/src/checksum.ts b/packages/local-node-utils/src/checksum.ts new file mode 100644 index 00000000000..82471893bab --- /dev/null +++ b/packages/local-node-utils/src/checksum.ts @@ -0,0 +1,20 @@ +/* eslint-disable import-x/no-nodejs-modules */ +import { createHash } from 'node:crypto'; +import { createReadStream } from 'node:fs'; +import { pipeline } from 'node:stream/promises'; + +export async function verifyFileChecksum( + filePath: string, + expectedChecksum: string, + label: string, +): Promise { + const hash = createHash('sha256'); + await pipeline(createReadStream(filePath), hash); + const checksum = hash.digest('hex'); + + if (checksum !== expectedChecksum) { + throw new Error( + `${label} checksum mismatch. Expected ${expectedChecksum}, got ${checksum}.`, + ); + } +} diff --git a/packages/local-node-utils/src/cli.ts b/packages/local-node-utils/src/cli.ts new file mode 100644 index 00000000000..79209896b26 --- /dev/null +++ b/packages/local-node-utils/src/cli.ts @@ -0,0 +1,10 @@ +export function readCliValue( + option: string, + value: string | undefined, +): string { + if (!value || value.startsWith('--')) { + throw new Error(`${option} requires a value.`); + } + + return value; +} diff --git a/packages/local-node-utils/src/command.test.ts b/packages/local-node-utils/src/command.test.ts new file mode 100644 index 00000000000..5319ab59479 --- /dev/null +++ b/packages/local-node-utils/src/command.test.ts @@ -0,0 +1,17 @@ +/* eslint-disable jest/expect-expect */ +import assert from 'node:assert/strict'; + +import { runCommand } from './command.js'; + +describe('runCommand', () => { + it('runs a successful command', async () => { + await runCommand(process.execPath, ['-e', 'process.exit(0)']); + }); + + it('rejects when a command fails', async () => { + await assert.rejects( + runCommand(process.execPath, ['-e', 'process.exit(2)']), + /failed with code 2/u, + ); + }); +}); diff --git a/packages/local-node-utils/src/command.ts b/packages/local-node-utils/src/command.ts new file mode 100644 index 00000000000..251c2dc50f5 --- /dev/null +++ b/packages/local-node-utils/src/command.ts @@ -0,0 +1,33 @@ +/* eslint-disable import-x/no-nodejs-modules */ +import { spawn } from 'node:child_process'; + +export async function runCommand( + command: string, + args: string[], +): Promise { + await new Promise((resolvePromise, rejectPromise) => { + const child = spawn(command, args, { + shell: false, + stdio: ['ignore', 'ignore', 'pipe'], + }); + let stderr = ''; + + child.stderr.on('data', (chunk) => { + stderr += chunk.toString(); + }); + child.on('error', rejectPromise); + child.on('close', (code, signal) => { + if (code === 0) { + resolvePromise(); + return; + } + + const exitStatus = signal ? `signal ${signal}` : `code ${code ?? 'null'}`; + rejectPromise( + new Error( + `${command} ${args.join(' ')} failed with ${exitStatus}: ${stderr}`, + ), + ); + }); + }); +} diff --git a/packages/local-node-utils/src/download.ts b/packages/local-node-utils/src/download.ts new file mode 100644 index 00000000000..b86cb427a0e --- /dev/null +++ b/packages/local-node-utils/src/download.ts @@ -0,0 +1,69 @@ +/* eslint-disable import-x/no-nodejs-modules */ +import { createWriteStream } from 'node:fs'; +import { mkdir } from 'node:fs/promises'; +import { request as requestHttp } from 'node:http'; +import { request as requestHttps } from 'node:https'; +import { dirname } from 'node:path'; +import { pipeline } from 'node:stream/promises'; + +export async function downloadFileFromUrl( + url: string, + destination: string, +): Promise { + await mkdir(dirname(destination), { recursive: true }); + await pipeline( + await openDownloadStream(new URL(url)), + createWriteStream(destination), + ); +} + +export async function openDownloadStream( + url: URL, + redirectsRemaining = 5, +): Promise { + const request = url.protocol === 'http:' ? requestHttp : requestHttps; + + return await new Promise((resolvePromise, rejectPromise) => { + const req = request(url, (response) => { + const { headers, statusCode, statusMessage } = response; + + if ( + statusCode && + statusCode >= 300 && + statusCode < 400 && + headers.location + ) { + response.resume(); + if (redirectsRemaining <= 0) { + rejectPromise(new Error(`Too many redirects downloading ${url}`)); + return; + } + + openDownloadStream( + new URL(headers.location, url), + redirectsRemaining - 1, + ) + .then(resolvePromise) + .catch(rejectPromise); + return; + } + + if (!statusCode || statusCode < 200 || statusCode >= 300) { + response.resume(); + rejectPromise( + new Error( + `Request to ${url} failed with ${statusCode ?? 'unknown'} ${ + statusMessage ?? '' + }`.trim(), + ), + ); + return; + } + + resolvePromise(response); + }); + + req.on('error', rejectPromise); + req.end(); + }); +} diff --git a/packages/local-node-utils/src/errors.ts b/packages/local-node-utils/src/errors.ts new file mode 100644 index 00000000000..20ecf357ef4 --- /dev/null +++ b/packages/local-node-utils/src/errors.ts @@ -0,0 +1,8 @@ +export function isFileMissingError(error: unknown): boolean { + return ( + typeof error === 'object' && + error !== null && + Object.prototype.hasOwnProperty.call(error, 'code') && + (error as NodeJS.ErrnoException).code === 'ENOENT' + ); +} diff --git a/packages/local-node-utils/src/executable-wrapper.ts b/packages/local-node-utils/src/executable-wrapper.ts new file mode 100644 index 00000000000..4b6a76722ae --- /dev/null +++ b/packages/local-node-utils/src/executable-wrapper.ts @@ -0,0 +1,103 @@ +/* eslint-disable import-x/no-nodejs-modules */ +import { chmod, mkdir, unlink, writeFile } from 'node:fs/promises'; +import { join, relative, resolve } from 'node:path'; + +import { isFileMissingError } from './errors.js'; + +export type ExecutableWrapperPathResolution = 'absolute' | 'relative'; + +export async function installExecutableWrapper({ + binDirectory, + commandName, + executableArgs = [], + executablePath, + pathResolution = 'absolute', +}: { + binDirectory: string; + commandName: string; + executableArgs?: string[]; + executablePath: string; + pathResolution?: ExecutableWrapperPathResolution; +}): Promise { + const binaryPath = join(binDirectory, commandName); + const wrapperSource = buildExecutableWrapperSource({ + binDirectory, + executableArgs, + executablePath, + pathResolution, + }); + + await mkdir(binDirectory, { recursive: true }); + await unlink(binaryPath).catch((error) => { + if (!isFileMissingError(error)) { + throw error; + } + }); + await writeFile(binaryPath, wrapperSource); + await chmod(binaryPath, 0o755); + + return binaryPath; +} + +function buildExecutableWrapperSource({ + binDirectory, + executableArgs, + executablePath, + pathResolution, +}: { + binDirectory: string; + executableArgs: string[]; + executablePath: string; + pathResolution: ExecutableWrapperPathResolution; +}): string { + if (pathResolution === 'relative') { + const relativeExecutablePath = relative(binDirectory, executablePath); + + return `#!/usr/bin/env node +const { spawnSync } = require('node:child_process'); +const path = require('node:path'); + +const executablePath = path.resolve(__dirname, ${JSON.stringify(relativeExecutablePath)}); +const executableArgs = ${JSON.stringify(executableArgs)}; +const result = spawnSync(executablePath, executableArgs.concat(process.argv.slice(2)), { + stdio: 'inherit', +}); + +if (result.error) { + console.error(result.error.message); + process.exit(1); +} + +if (result.signal) { + process.kill(process.pid, result.signal); + process.exit(1); +} + +process.exit(result.status ?? 0); +`; + } + + const resolvedExecutablePath = resolve(executablePath); + + return `#!/usr/bin/env node +const { spawnSync } = require('node:child_process'); + +const executablePath = ${JSON.stringify(resolvedExecutablePath)}; +const executableArgs = ${JSON.stringify(executableArgs)}; +const result = spawnSync(executablePath, executableArgs.concat(process.argv.slice(2)), { + stdio: 'inherit', +}); + +if (result.error) { + console.error(result.error.message); + process.exit(1); +} + +if (result.signal) { + process.kill(process.pid, result.signal); + process.exit(1); +} + +process.exit(result.status ?? 0); +`; +} diff --git a/packages/local-node-utils/src/filesystem.ts b/packages/local-node-utils/src/filesystem.ts new file mode 100644 index 00000000000..870f9d1b903 --- /dev/null +++ b/packages/local-node-utils/src/filesystem.ts @@ -0,0 +1,40 @@ +/* eslint-disable import-x/no-nodejs-modules */ +import { existsSync, readdirSync, statSync } from 'node:fs'; +import { join } from 'node:path'; + +export function findExecutable(root: string, name: string): string | undefined { + if (!existsSync(root)) { + return undefined; + } + + for (const entry of readdirSync(root)) { + const entryPath = join(root, entry); + const stat = statSync(entryPath); + if (stat.isDirectory()) { + const found = findExecutable(entryPath, name); + if (found) { + return found; + } + } else if (entry === name) { + return entryPath; + } + } + + return undefined; +} + +export function isDirectory(path: string): boolean { + try { + return statSync(path).isDirectory(); + } catch { + return false; + } +} + +export function isFile(path: string): boolean { + try { + return statSync(path).isFile(); + } catch { + return false; + } +} diff --git a/packages/local-node-utils/src/index.test.ts b/packages/local-node-utils/src/index.test.ts new file mode 100644 index 00000000000..beb4e0864ed --- /dev/null +++ b/packages/local-node-utils/src/index.test.ts @@ -0,0 +1,241 @@ +/* eslint-disable jest/expect-expect, n/no-sync */ +import assert from 'node:assert/strict'; +import { createHash } from 'node:crypto'; +import { mkdtempSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { + getCacheKey, + mergeArtifactConfig, + requireCompletePlatformConfig, + resolvePlatformConfig, +} from './artifact.js'; +import { getMetamaskCacheDirectory } from './cache-directory.js'; +import { readCliValue } from './cli.js'; +import { readPackageJsonToolConfig } from './package-json.js'; +import type { ArtifactConfig } from './types.js'; + +describe('artifact helpers', () => { + const defaults: ArtifactConfig = { + version: '1.0.0', + platforms: { + 'linux-x64': { + checksum: 'abc', + url: 'https://example.com/linux', + }, + }, + }; + + it('merges artifact config overrides', () => { + assert.deepEqual( + mergeArtifactConfig(defaults, { + version: '2.0.0', + platforms: { + 'darwin-arm64': { + checksum: 'def', + url: 'https://example.com/darwin', + }, + }, + }), + { + version: '2.0.0', + platforms: { + 'linux-x64': defaults.platforms['linux-x64'], + 'darwin-arm64': { + checksum: 'def', + url: 'https://example.com/darwin', + }, + }, + }, + ); + }); + + it('resolves platform config from current override', () => { + assert.deepEqual( + resolvePlatformConfig( + { + platforms: { + current: { + checksum: 'current', + url: 'https://example.com/current', + }, + }, + }, + 'linux-x64', + 'test artifact', + ), + { + checksum: 'current', + url: 'https://example.com/current', + }, + ); + }); + + it('throws when platform config is missing', () => { + assert.throws( + () => resolvePlatformConfig(defaults, 'darwin-arm64', 'test artifact'), + /No test artifact is configured for darwin-arm64/u, + ); + }); + + it('merges artifact config defaults when override is missing', () => { + assert.deepEqual(mergeArtifactConfig(defaults, undefined), defaults); + }); + + it('requires complete platform config values', () => { + assert.deepEqual( + requireCompletePlatformConfig( + { + checksum: 'abc', + url: 'https://example.com', + }, + 'CLI', + ), + { + checksum: 'abc', + url: 'https://example.com', + }, + ); + assert.throws( + () => + requireCompletePlatformConfig({ url: 'https://example.com' }, 'CLI'), + /CLI require both a URL and a checksum/u, + ); + }); + + it('builds a stable cache key', () => { + const config = { + checksum: 'abc', + url: 'https://example.com/linux', + }; + + assert.equal( + getCacheKey(config), + createHash('sha256') + .update(`${config.url}:${config.checksum}`) + .digest('hex'), + ); + }); +}); + +describe('cache directory', () => { + it('uses the global MetaMask cache when Yarn global cache is enabled', () => { + const cwd = mkdtempSync(join(tmpdir(), 'local-node-utils-')); + const homeDirectory = mkdtempSync(join(tmpdir(), 'local-node-utils-home-')); + writeFileSync(join(cwd, '.yarnrc.yml'), 'enableGlobalCache: true\n'); + + assert.equal( + getMetamaskCacheDirectory({ cwd, homeDirectory }), + join(homeDirectory, '.cache', 'metamask'), + ); + }); + + it('uses the local MetaMask cache when Yarn global cache is disabled', () => { + const cwd = mkdtempSync(join(tmpdir(), 'local-node-utils-')); + writeFileSync(join(cwd, '.yarnrc.yml'), 'enableGlobalCache: false\n'); + + assert.equal( + getMetamaskCacheDirectory({ cwd }), + join(cwd, '.metamask', 'cache'), + ); + }); + + it('uses the local MetaMask cache when .yarnrc.yml is missing', () => { + const cwd = mkdtempSync(join(tmpdir(), 'local-node-utils-')); + + assert.equal( + getMetamaskCacheDirectory({ cwd }), + join(cwd, '.metamask', 'cache'), + ); + }); +}); + +describe('cli helpers', () => { + it('reads the next CLI value', () => { + assert.equal(readCliValue('--platform', 'linux-x64'), 'linux-x64'); + }); + + it('throws when a CLI value is missing', () => { + assert.throws( + () => readCliValue('--platform', undefined), + /--platform requires a value/u, + ); + }); +}); + +describe('package.json helpers', () => { + it('reads the first matching tool config key', () => { + const cwd = mkdtempSync(join(tmpdir(), 'local-node-utils-')); + writeFileSync( + join(cwd, 'package.json'), + JSON.stringify({ + javaTronUp: { + binDirectory: './bin', + }, + 'java-tron-up': { + cacheDirectory: './cache', + }, + }), + ); + + assert.deepEqual( + readPackageJsonToolConfig({ + cwd, + configKeys: ['javaTronUp', 'javatronup', 'java-tron-up'], + }), + { + binDirectory: './bin', + }, + ); + }); + + it('returns an empty object when package.json is missing', () => { + const cwd = mkdtempSync(join(tmpdir(), 'local-node-utils-')); + + assert.deepEqual( + readPackageJsonToolConfig({ + cwd, + configKeys: ['java-tron-up'], + }), + {}, + ); + }); + + it('throws when package.json is invalid JSON', () => { + const cwd = mkdtempSync(join(tmpdir(), 'local-node-utils-')); + writeFileSync(join(cwd, 'package.json'), '{'); + + assert.throws( + () => + readPackageJsonToolConfig({ + cwd, + configKeys: ['java-tron-up'], + }), + /SyntaxError/u, + ); + }); + + it('skips non-object config entries', () => { + const cwd = mkdtempSync(join(tmpdir(), 'local-node-utils-')); + writeFileSync( + join(cwd, 'package.json'), + JSON.stringify({ + javaTronUp: 'not-an-object', + 'java-tron-up': { + cacheDirectory: './cache', + }, + }), + ); + + assert.deepEqual( + readPackageJsonToolConfig({ + cwd, + configKeys: ['javaTronUp', 'java-tron-up'], + }), + { + cacheDirectory: './cache', + }, + ); + }); +}); diff --git a/packages/local-node-utils/src/index.ts b/packages/local-node-utils/src/index.ts new file mode 100644 index 00000000000..c68ea6b9033 --- /dev/null +++ b/packages/local-node-utils/src/index.ts @@ -0,0 +1,24 @@ +export type { + ArtifactConfig, + ArtifactPlatformConfig, + InstallDependencies, +} from './types.js'; +export { + getCacheKey, + mergeArtifactConfig, + requireCompletePlatformConfig, + resolvePlatformConfig, +} from './artifact.js'; +export { cleanInstallerCache } from './cache.js'; +export { getMetamaskCacheDirectory } from './cache-directory.js'; +export { verifyFileChecksum } from './checksum.js'; +export { readCliValue } from './cli.js'; +export { runCommand } from './command.js'; +export { isFileMissingError } from './errors.js'; +export { extractTarBz2Archive, extractTarGzArchive } from './archive.js'; +export { downloadFileFromUrl } from './download.js'; +export { installExecutableWrapper } from './executable-wrapper.js'; +export type { ExecutableWrapperPathResolution } from './executable-wrapper.js'; +export { findExecutable, isDirectory, isFile } from './filesystem.js'; +export { getPlatformKey, normalizeSystemArchitecture } from './platform.js'; +export { readPackageJsonToolConfig } from './package-json.js'; diff --git a/packages/local-node-utils/src/integration.test.ts b/packages/local-node-utils/src/integration.test.ts new file mode 100644 index 00000000000..03ecb5a14f2 --- /dev/null +++ b/packages/local-node-utils/src/integration.test.ts @@ -0,0 +1,242 @@ +import nock, { cleanAll } from 'nock'; +/* eslint-disable jest/expect-expect, n/no-sync */ +import assert from 'node:assert/strict'; +import { + chmodSync, + existsSync, + mkdirSync, + mkdtempSync, + readFileSync, + writeFileSync, +} from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { extractTarBz2Archive, extractTarGzArchive } from './archive.js'; +import { getMetamaskCacheDirectory } from './cache-directory.js'; +import { cleanInstallerCache } from './cache.js'; +import { verifyFileChecksum } from './checksum.js'; +import { runCommand } from './command.js'; +import { downloadFileFromUrl, openDownloadStream } from './download.js'; +import { isFileMissingError } from './errors.js'; +import { installExecutableWrapper } from './executable-wrapper.js'; +import { findExecutable, isDirectory, isFile } from './filesystem.js'; +import { getPlatformKey, normalizeSystemArchitecture } from './platform.js'; + +jest.mock('./command', () => ({ + runCommand: jest.fn(), +})); + +const runCommandMock = jest.mocked(runCommand); + +describe('archive', () => { + beforeEach(() => { + runCommandMock.mockReset(); + runCommandMock.mockResolvedValue(undefined); + }); + + it('extracts tar.gz archives', async () => { + await extractTarGzArchive('/tmp/archive.tar.gz', '/tmp/output'); + + expect(runCommandMock).toHaveBeenCalledWith('tar', [ + '-xzf', + '/tmp/archive.tar.gz', + '-C', + '/tmp/output', + ]); + }); + + it('extracts tar.bz2 archives', async () => { + await extractTarBz2Archive('/tmp/archive.tar.bz2', '/tmp/output'); + + expect(runCommandMock).toHaveBeenCalledWith('tar', [ + '-xjf', + '/tmp/archive.tar.bz2', + '-C', + '/tmp/output', + ]); + }); +}); + +describe('cache', () => { + it('removes a namespaced cache directory', async () => { + const tempDir = mkdtempSync(join(tmpdir(), 'local-node-utils-')); + const namespaceDir = join(tempDir, 'java-tron-up', 'fullnode'); + mkdirSync(namespaceDir, { recursive: true }); + writeFileSync(join(namespaceDir, 'artifact.jar'), 'data'); + + await cleanInstallerCache({ + cacheDirectory: tempDir, + namespace: 'java-tron-up', + }); + + assert.equal(existsSync(namespaceDir), false); + }); +}); + +describe('download', () => { + afterEach(() => { + cleanAll(); + }); + + it('downloads a file from a URL', async () => { + const tempDir = mkdtempSync(join(tmpdir(), 'local-node-utils-')); + const destination = join(tempDir, 'nested', 'artifact.bin'); + + nock('https://example.com').get('/artifact.bin').reply(200, 'artifact'); + + await downloadFileFromUrl('https://example.com/artifact.bin', destination); + + assert.equal(readFileSync(destination, 'utf8'), 'artifact'); + }); + + it('follows redirects', async () => { + nock('https://example.com') + .get('/redirect') + .reply(302, '', { Location: 'https://example.com/final' }); + nock('https://example.com').get('/final').reply(200, 'redirected'); + + const stream = await openDownloadStream( + new URL('https://example.com/redirect'), + ); + const chunks: Buffer[] = []; + for await (const chunk of stream) { + chunks.push(Buffer.from(chunk)); + } + + assert.equal(Buffer.concat(chunks).toString('utf8'), 'redirected'); + }); + + it('rejects failed downloads', async () => { + nock('https://example.com').get('/missing.bin').reply(500, 'nope'); + + await assert.rejects( + downloadFileFromUrl( + 'https://example.com/missing.bin', + join(tmpdir(), 'missing.bin'), + ), + /failed with 500/u, + ); + }); + + it('rejects redirect loops', async () => { + nock('https://example.com') + .persist() + .get('/loop') + .reply(302, '', { Location: 'https://example.com/loop' }); + + await assert.rejects( + openDownloadStream(new URL('https://example.com/loop')), + /Too many redirects/u, + ); + }); +}); + +describe('cache directory warnings', () => { + it('falls back to the local cache when .yarnrc.yml is invalid', () => { + const cwd = mkdtempSync(join(tmpdir(), 'local-node-utils-')); + const warnSpy = jest + .spyOn(console, 'warn') + .mockImplementation(() => undefined); + writeFileSync(join(cwd, '.yarnrc.yml'), 'not: [valid'); + + assert.equal( + getMetamaskCacheDirectory({ cwd, toolName: 'java-tron-up' }), + join(cwd, '.metamask', 'cache'), + ); + assert.match( + String(warnSpy.mock.calls[0]?.[0]), + /using local java-tron-up cache/u, + ); + + warnSpy.mockRestore(); + }); +}); + +describe('errors', () => { + it('detects missing file errors', () => { + assert.equal(isFileMissingError({ code: 'ENOENT' }), true); + assert.equal(isFileMissingError(new Error('nope')), false); + }); +}); + +describe('platform', () => { + it('returns a platform key', () => { + assert.match(getPlatformKey(), /^(darwin|linux|win32)-/u); + }); + + it('normalizes the current architecture', () => { + assert.equal(typeof normalizeSystemArchitecture(), 'string'); + }); +}); + +describe('checksum', () => { + it('verifies a file checksum', async () => { + const tempDir = mkdtempSync(join(tmpdir(), 'local-node-utils-')); + const filePath = join(tempDir, 'artifact.bin'); + writeFileSync(filePath, 'hello'); + + await verifyFileChecksum( + filePath, + '2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824', + 'test artifact', + ); + }); + + it('throws when checksums do not match', async () => { + const tempDir = mkdtempSync(join(tmpdir(), 'local-node-utils-')); + const filePath = join(tempDir, 'artifact.bin'); + writeFileSync(filePath, 'hello'); + + await assert.rejects( + verifyFileChecksum(filePath, 'deadbeef', 'test artifact'), + /test artifact checksum mismatch/u, + ); + }); +}); + +describe('filesystem', () => { + it('finds nested executables by name', () => { + const tempDir = mkdtempSync(join(tmpdir(), 'local-node-utils-')); + const nestedDir = join(tempDir, 'release', 'bin'); + mkdirSync(nestedDir, { recursive: true }); + const executablePath = join(nestedDir, 'solana'); + writeFileSync(executablePath, ''); + chmodSync(executablePath, 0o755); + + assert.equal(findExecutable(tempDir, 'solana'), executablePath); + assert.equal(findExecutable(tempDir, 'missing'), undefined); + assert.equal(isDirectory(tempDir), true); + assert.equal(isFile(executablePath), true); + assert.equal(isDirectory(join(tempDir, 'missing')), false); + assert.equal(isFile(join(tempDir, 'missing')), false); + }); +}); + +describe('executable wrapper', () => { + it('installs wrappers with absolute and relative paths', async () => { + const tempDir = mkdtempSync(join(tmpdir(), 'local-node-utils-')); + const binDirectory = join(tempDir, 'bin'); + const executablePath = join(tempDir, 'release', 'bin', 'solana'); + mkdirSync(join(tempDir, 'release', 'bin'), { recursive: true }); + writeFileSync(executablePath, '#!/bin/sh\necho solana\n'); + chmodSync(executablePath, 0o755); + + const relativeWrapper = await installExecutableWrapper({ + binDirectory, + commandName: 'solana', + executablePath, + pathResolution: 'relative', + }); + const absoluteWrapper = await installExecutableWrapper({ + binDirectory, + commandName: 'tool', + executableArgs: ['--flag'], + executablePath, + pathResolution: 'absolute', + }); + + assert.match(readFileSync(relativeWrapper, 'utf8'), /path\.resolve/u); + assert.match(readFileSync(absoluteWrapper, 'utf8'), /--flag/u); + }); +}); diff --git a/packages/local-node-utils/src/package-json.ts b/packages/local-node-utils/src/package-json.ts new file mode 100644 index 00000000000..cd5abf3fa9a --- /dev/null +++ b/packages/local-node-utils/src/package-json.ts @@ -0,0 +1,35 @@ +/* eslint-disable import-x/no-nodejs-modules, no-restricted-globals */ +import { readFileSync } from 'node:fs'; +import { join } from 'node:path'; + +import { isFileMissingError } from './errors.js'; + +export function readPackageJsonToolConfig({ + cwd = process.cwd(), + packageJsonPath = join(cwd, 'package.json'), + configKeys, +}: { + cwd?: string; + packageJsonPath?: string; + configKeys: string[]; +}): Record { + let raw: string; + try { + raw = readFileSync(packageJsonPath, 'utf8'); + } catch (error) { + if (isFileMissingError(error)) { + return {}; + } + throw error; + } + + const packageJson = JSON.parse(raw) as Record; + for (const key of configKeys) { + const config = packageJson[key]; + if (config && typeof config === 'object') { + return config as Record; + } + } + + return {}; +} diff --git a/packages/local-node-utils/src/platform.ts b/packages/local-node-utils/src/platform.ts new file mode 100644 index 00000000000..0df844c81d9 --- /dev/null +++ b/packages/local-node-utils/src/platform.ts @@ -0,0 +1,22 @@ +/* eslint-disable import-x/no-nodejs-modules */ +import { spawnSync } from 'node:child_process'; +import { arch as osArch, platform as osPlatform } from 'node:os'; + +export function getPlatformKey(): string { + return `${osPlatform()}-${normalizeSystemArchitecture()}`; +} + +export function normalizeSystemArchitecture(architecture = osArch()): string { + if (architecture === 'x64' && osPlatform() === 'darwin') { + const result = spawnSync('sysctl', ['-n', 'sysctl.proc_translated'], { + encoding: 'utf8', + shell: false, + stdio: ['ignore', 'pipe', 'ignore'], + }); + if (result.stdout.trim() === '1') { + return 'arm64'; + } + } + + return architecture; +} diff --git a/packages/local-node-utils/src/types.ts b/packages/local-node-utils/src/types.ts new file mode 100644 index 00000000000..a6efc6cfbc9 --- /dev/null +++ b/packages/local-node-utils/src/types.ts @@ -0,0 +1,15 @@ +export type ArtifactPlatformConfig = { + checksum: string; + size?: number; + url: string; +}; + +export type ArtifactConfig = { + platforms: Record; + version?: string; +}; + +export type InstallDependencies = { + downloadFile?: (url: string, destination: string) => Promise; + extractArchive?: (archivePath: string, destination: string) => Promise; +}; diff --git a/packages/local-node-utils/tsconfig.build.json b/packages/local-node-utils/tsconfig.build.json new file mode 100644 index 00000000000..02a0eea03fe --- /dev/null +++ b/packages/local-node-utils/tsconfig.build.json @@ -0,0 +1,10 @@ +{ + "extends": "../../tsconfig.packages.build.json", + "compilerOptions": { + "baseUrl": "./", + "outDir": "./dist", + "rootDir": "./src" + }, + "references": [], + "include": ["../../types", "./src"] +} diff --git a/packages/local-node-utils/tsconfig.json b/packages/local-node-utils/tsconfig.json new file mode 100644 index 00000000000..025ba2ef7f4 --- /dev/null +++ b/packages/local-node-utils/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../../tsconfig.packages.json", + "compilerOptions": { + "baseUrl": "./" + }, + "references": [], + "include": ["../../types", "./src"] +} diff --git a/packages/local-node-utils/tsconfig.lint.json b/packages/local-node-utils/tsconfig.lint.json new file mode 100644 index 00000000000..fb65dcfce34 --- /dev/null +++ b/packages/local-node-utils/tsconfig.lint.json @@ -0,0 +1,8 @@ +{ + "extends": ["./tsconfig.json", "../../tsconfig.packages.lint.json"], + "compilerOptions": { + "outDir": "./.tsc-lint-cache", + "tsBuildInfoFile": "./.tsc-lint-cache/tsconfig.tsbuildinfo" + }, + "references": [] +} diff --git a/packages/local-node-utils/typedoc.json b/packages/local-node-utils/typedoc.json new file mode 100644 index 00000000000..c9da015dbf8 --- /dev/null +++ b/packages/local-node-utils/typedoc.json @@ -0,0 +1,7 @@ +{ + "entryPoints": ["./src/index.ts"], + "excludePrivate": true, + "hideGenerator": true, + "out": "docs", + "tsconfig": "./tsconfig.build.json" +} diff --git a/packages/logging-controller/CHANGELOG.md b/packages/logging-controller/CHANGELOG.md index 5dd49515403..931a2d01026 100644 --- a/packages/logging-controller/CHANGELOG.md +++ b/packages/logging-controller/CHANGELOG.md @@ -1,4 +1,5 @@ # 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/), @@ -6,30 +7,253 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [9.0.0] + +### Added + +- **BREAKING:** Add optional `expiryTime` constructor argument, by default logs are pruned after 7 days ([#9839](https://github.com/MetaMask/core/pull/9839)) + +### Changed + +- Bump `@metamask/controller-utils` from `^12.0.0` to `^12.3.0` ([#8774](https://github.com/MetaMask/core/pull/8774), [#9058](https://github.com/MetaMask/core/pull/9058), [#9083](https://github.com/MetaMask/core/pull/9083), [#9218](https://github.com/MetaMask/core/pull/9218)) +- Bump `@metamask/messenger` from `^1.2.0` to `^2.0.0` ([#9392](https://github.com/MetaMask/core/pull/9392)) + +## [8.0.2] + +### Changed + +- Bump `@metamask/controller-utils` from `^11.19.0` to `^12.0.0` ([#8344](https://github.com/MetaMask/core/pull/8344), [#8755](https://github.com/MetaMask/core/pull/8755)) +- Bump `@metamask/messenger` from `^1.0.0` to `^1.2.0` ([#8364](https://github.com/MetaMask/core/pull/8364), [#8373](https://github.com/MetaMask/core/pull/8373), [#8632](https://github.com/MetaMask/core/pull/8632)) +- Bump `@metamask/base-controller` from `^9.0.1` to `^9.1.0` ([#8457](https://github.com/MetaMask/core/pull/8457)) + +## [8.0.1] + +### Changed + +- Bump `@metamask/base-controller` from `^9.0.0` to `^9.0.1` ([#8317](https://github.com/MetaMask/core/pull/8317)) +- Bump `@metamask/messenger` from `^0.3.0` to `^1.0.0` ([#8317](https://github.com/MetaMask/core/pull/8317)) + +## [8.0.0] + +### Added + +- Expose missing public `LoggingController` methods through its messenger ([#8183](https://github.com/MetaMask/core/pull/8183)) + - The following action is now available: + - `LoggingController:clear` + - Corresponding action type (`LoggingControllerClearAction`) is available as well. + +### Changed + +- **BREAKING:** Standardize names of `LoggingController` messenger action types ([#8183](https://github.com/MetaMask/core/pull/8183)) + - All existing types for messenger actions have been renamed so they end in `Action` and include the controller name (e.g. `AddLog` -> `LoggingControllerAddAction`). You will need to update imports appropriately. + - This change only affects the types. The action type strings themselves have not changed, so you do not need to update the list of actions you pass when initializing `LoggingController` messengers. +- Bump `@metamask/controller-utils` from `^11.16.0` to `^11.19.0` ([#7534](https://github.com/MetaMask/core/pull/7534), [#7583](https://github.com/MetaMask/core/pull/7583), [#7995](https://github.com/MetaMask/core/pull/7995)) + +## [7.0.1] + +### Changed + +- Bump `@metamask/controller-utils` from `^11.15.0` to `^11.16.0` ([#7202](https://github.com/MetaMask/core/pull/7202)) + +## [7.0.0] + +### Changed + +- **BREAKING:** Use new `Messenger` from `@metamask/messenger` ([#6463](https://github.com/MetaMask/core/pull/6463)) + - Previously, `LoggingController` accepted a `RestrictedMessenger` instance from `@metamask/base-controller`. +- **BREAKING:** Metadata property `anonymous` renamed to `includeInDebugSnapshot` ([#6463](https://github.com/MetaMask/core/pull/6463)) +- Bump `@metamask/base-controller` from `^8.4.2` to `^9.0.0` ([#6962](https://github.com/MetaMask/core/pull/6962)) + +## [6.1.1] + +### Changed + +- Bump `@metamask/base-controller` from `^8.4.1` to `^8.4.2` ([#6917](https://github.com/MetaMask/core/pull/6917)) + +## [6.1.0] + +### Added + +- Add two new controller state metadata properties: `includeInStateLogs` and `usedInUi` ([#6473](https://github.com/MetaMask/core/pull/6473)) + +### Changed + +- Bump `@metamask/base-controller` from `^8.0.0` to `^8.4.1` ([#5722](https://github.com/MetaMask/core/pull/5722), [#6284](https://github.com/MetaMask/core/pull/6284), [#6355](https://github.com/MetaMask/core/pull/6355), [#6465](https://github.com/MetaMask/core/pull/6465), [#6632](https://github.com/MetaMask/core/pull/6632), [#6807](https://github.com/MetaMask/core/pull/6807)) +- Bump `@metamask/controller-utils` from `^11.5.0` to `^11.14.1` ([#5439](https://github.com/MetaMask/core/pull/5439), [#5583](https://github.com/MetaMask/core/pull/5583), [#5765](https://github.com/MetaMask/core/pull/5765), [#5812](https://github.com/MetaMask/core/pull/5812), [#5935](https://github.com/MetaMask/core/pull/5935), [#6069](https://github.com/MetaMask/core/pull/6069), [#6303](https://github.com/MetaMask/core/pull/6303), [#6620](https://github.com/MetaMask/core/pull/6620), [#6629](https://github.com/MetaMask/core/pull/6629), [#6807](https://github.com/MetaMask/core/pull/6807)) + +## [6.0.4] + +### Changed + +- Bump `@metamask/base-controller` from `^7.0.2` to `^8.0.0` ([#5079](https://github.com/MetaMask/core/pull/5079), [#5305](https://github.com/MetaMask/core/pull/5305)) +- Bump `@metamask/controller-utils` from `^11.4.4` to `^11.5.0` ([#5135](https://github.com/MetaMask/core/pull/5135), [#5272](https://github.com/MetaMask/core/pull/5272)) + +## [6.0.3] + +### Changed + +- Bump `@metamask/controller-utils` from `^11.4.3` to `^11.4.4` ([#5012](https://github.com/MetaMask/core/pull/5012)) + +## [6.0.2] + +### Changed + +- Bump `@metamask/controller-utils` from `^11.3.0` to `^11.4.3` ([#4870](https://github.com/MetaMask/core/pull/4870), [#4862](https://github.com/MetaMask/core/pull/4862), [#4834](https://github.com/MetaMask/core/pull/4834), [#4915](https://github.com/MetaMask/core/pull/4915)) +- Bump `@metamask/base-controller` from `^7.0.1` to `^^7.0.2` ([#4862](https://github.com/MetaMask/core/pull/4862)) + +## [6.0.1] + +### Fixed + +- Produce and export ESM-compatible TypeScript type declaration files in addition to CommonJS-compatible declaration files ([#4648](https://github.com/MetaMask/core/pull/4648)) + - Previously, this package shipped with only one variant of type declaration + files, and these files were only CommonJS-compatible, and the `exports` + field in `package.json` linked to these files. This is an anti-pattern and + was rightfully flagged by the + ["Are the Types Wrong?"](https://arethetypeswrong.github.io/) tool as + ["masquerading as CJS"](https://github.com/arethetypeswrong/arethetypeswrong.github.io/blob/main/docs/problems/FalseCJS.md). + All of the ATTW checks now pass. +- Remove chunk files ([#4648](https://github.com/MetaMask/core/pull/4648)) + - Previously, the build tool we used to generate JavaScript files extracted + common code to "chunk" files. While this was intended to make this package + more tree-shakeable, it also made debugging more difficult for our + development teams. These chunk files are no longer present. + +## [6.0.0] + +### Added + +- Define and export new types: `LoggingControllerGetStateAction`, `LoggingControllerStateChangeEvent`, `LoggingControllerEvents` ([#4633](https://github.com/MetaMask/core/pull/4633)) + +### Changed + +- **BREAKING:** `LoggingControllerMessenger` must allow internal events defined in the `LoggingControllerEvents` type ([#4633](https://github.com/MetaMask/core/pull/4633)) +- `LoggingControllerActions` is widened to include the `LoggingController:getState` action ([#4633](https://github.com/MetaMask/core/pull/4633)) +- Bump `@metamask/base-controller` from `^6.0.0` to `^7.0.0` ([#4517](https://github.com/MetaMask/core/pull/4517), [#4544](https://github.com/MetaMask/core/pull/4544), [#4625](https://github.com/MetaMask/core/pull/4625), [#4643](https://github.com/MetaMask/core/pull/4643)) +- Bump `@metamask/controller-utils` from `^11.0.0` to `^11.0.2` ([#4517](https://github.com/MetaMask/core/pull/4517), [#4544](https://github.com/MetaMask/core/pull/4544)) +- Bump `typescript` from `~4.9.5` to `~5.2.2` and set `module{,Resolution}` option to `Node16` ([#3645](https://github.com/MetaMask/core/pull/3645), [#4576](https://github.com/MetaMask/core/pull/4576), [#4584](https://github.com/MetaMask/core/pull/4584)) + +## [5.0.0] + +### Changed + +- **BREAKING:** Bump minimum Node version to 18.18 ([#3611](https://github.com/MetaMask/core/pull/3611)) +- Bump `@metamask/base-controller` to `^6.0.0` ([#4352](https://github.com/MetaMask/core/pull/4352)) +- Bump `@metamask/controller-utils` to `^11.0.0` ([#4352](https://github.com/MetaMask/core/pull/4352)) + +## [4.0.0] + +### Changed + +- Bump `@metamask/base-controller` to `^5.0.2` ([#4232](https://github.com/MetaMask/core/pull/4232)) +- Bump `@metamask/controller-utils` to `^10.0.0` ([#4342](https://github.com/MetaMask/core/pull/4342)) + +### Removed + +- **BREAKING:** Remove `EthSign` from `SigningMethod` ([#4319](https://github.com/MetaMask/core/pull/4319)) + +## [3.0.1] + +### Fixed + +- Fix `types` field in `package.json` ([#4047](https://github.com/MetaMask/core/pull/4047)) + +## [3.0.0] + +### Added + +- **BREAKING**: Add ESM build ([#3998](https://github.com/MetaMask/core/pull/3998)) + - It's no longer possible to import files from `./dist` directly. + +### Changed + +- **BREAKING:** Bump `@metamask/base-controller` to `^5.0.0` ([#4039](https://github.com/MetaMask/core/pull/4039)) + - This version has a number of breaking changes. See the changelog for more. +- Bump `@metamask/controller-utils` to `^9.0.0` ([#4039](https://github.com/MetaMask/core/pull/4039)) + +## [2.0.3] + +### Changed + +- Bump `@metamask/controller-utils` to `^8.0.4` ([#4007](https://github.com/MetaMask/core/pull/4007)) + +## [2.0.2] + +### Changed + +- Bump `@metamask/base-controller` to `^4.1.1` ([#3760](https://github.com/MetaMask/core/pull/3760), [#3821](https://github.com/MetaMask/core/pull/3821)) +- Bump `@metamask/controller-utils` to `^8.0.2` ([#3821](https://github.com/MetaMask/core/pull/3821)) + +## [2.0.1] + +### Changed + +- Bump `@metamask/base-controller` to `^4.0.1` ([#3695](https://github.com/MetaMask/core/pull/3695)) +- Bump `@metamask/controller-utils` to `^8.0.1` ([#3695](https://github.com/MetaMask/core/pull/3695), [#3678](https://github.com/MetaMask/core/pull/3678), [#3667](https://github.com/MetaMask/core/pull/3667), [#3580](https://github.com/MetaMask/core/pull/3580)) + +## [2.0.0] + +### Changed + +- **BREAKING:** Bump `@metamask/base-controller` to ^4.0.0 ([#2063](https://github.com/MetaMask/core/pull/2063)) + - This is breaking because the type of the `messenger` has backward-incompatible changes. See the changelog for this package for more. +- Bump `@metamask/controller-utils` to ^6.0.0 ([#2063](https://github.com/MetaMask/core/pull/2063)) + ## [1.0.4] + ### Changed + - Bump dependency on `@metamask/base-controller` to ^3.2.3 ([#1747](https://github.com/MetaMask/core/pull/1747)) - Bump dependency on `@metamask/controller-utils` to ^5.0.2 ([#1747](https://github.com/MetaMask/core/pull/1747)) ## [1.0.3] + ### Changed + - Update TypeScript to v4.8.x ([#1718](https://github.com/MetaMask/core/pull/1718)) ## [1.0.2] + ### Changed + - Bump dependency on `@metamask/controller-utils` to ^5.0.0 ## [1.0.1] + ### Changed + - Bump dependency on `@metamask/base-controller` to ^3.2.1 - Bump dependency on `@metamask/controller-utils` to ^4.3.2 ## [1.0.0] + ### Added + - Initial Release - Add logging controller ([#1089](https://github.com/MetaMask/core.git/pull/1089)) -[Unreleased]: https://github.com/MetaMask/core/compare/@metamask/logging-controller@1.0.4...HEAD +[Unreleased]: https://github.com/MetaMask/core/compare/@metamask/logging-controller@9.0.0...HEAD +[9.0.0]: https://github.com/MetaMask/core/compare/@metamask/logging-controller@8.0.2...@metamask/logging-controller@9.0.0 +[8.0.2]: https://github.com/MetaMask/core/compare/@metamask/logging-controller@8.0.1...@metamask/logging-controller@8.0.2 +[8.0.1]: https://github.com/MetaMask/core/compare/@metamask/logging-controller@8.0.0...@metamask/logging-controller@8.0.1 +[8.0.0]: https://github.com/MetaMask/core/compare/@metamask/logging-controller@7.0.1...@metamask/logging-controller@8.0.0 +[7.0.1]: https://github.com/MetaMask/core/compare/@metamask/logging-controller@7.0.0...@metamask/logging-controller@7.0.1 +[7.0.0]: https://github.com/MetaMask/core/compare/@metamask/logging-controller@6.1.1...@metamask/logging-controller@7.0.0 +[6.1.1]: https://github.com/MetaMask/core/compare/@metamask/logging-controller@6.1.0...@metamask/logging-controller@6.1.1 +[6.1.0]: https://github.com/MetaMask/core/compare/@metamask/logging-controller@6.0.4...@metamask/logging-controller@6.1.0 +[6.0.4]: https://github.com/MetaMask/core/compare/@metamask/logging-controller@6.0.3...@metamask/logging-controller@6.0.4 +[6.0.3]: https://github.com/MetaMask/core/compare/@metamask/logging-controller@6.0.2...@metamask/logging-controller@6.0.3 +[6.0.2]: https://github.com/MetaMask/core/compare/@metamask/logging-controller@6.0.1...@metamask/logging-controller@6.0.2 +[6.0.1]: https://github.com/MetaMask/core/compare/@metamask/logging-controller@6.0.0...@metamask/logging-controller@6.0.1 +[6.0.0]: https://github.com/MetaMask/core/compare/@metamask/logging-controller@5.0.0...@metamask/logging-controller@6.0.0 +[5.0.0]: https://github.com/MetaMask/core/compare/@metamask/logging-controller@4.0.0...@metamask/logging-controller@5.0.0 +[4.0.0]: https://github.com/MetaMask/core/compare/@metamask/logging-controller@3.0.1...@metamask/logging-controller@4.0.0 +[3.0.1]: https://github.com/MetaMask/core/compare/@metamask/logging-controller@3.0.0...@metamask/logging-controller@3.0.1 +[3.0.0]: https://github.com/MetaMask/core/compare/@metamask/logging-controller@2.0.3...@metamask/logging-controller@3.0.0 +[2.0.3]: https://github.com/MetaMask/core/compare/@metamask/logging-controller@2.0.2...@metamask/logging-controller@2.0.3 +[2.0.2]: https://github.com/MetaMask/core/compare/@metamask/logging-controller@2.0.1...@metamask/logging-controller@2.0.2 +[2.0.1]: https://github.com/MetaMask/core/compare/@metamask/logging-controller@2.0.0...@metamask/logging-controller@2.0.1 +[2.0.0]: https://github.com/MetaMask/core/compare/@metamask/logging-controller@1.0.4...@metamask/logging-controller@2.0.0 [1.0.4]: https://github.com/MetaMask/core/compare/@metamask/logging-controller@1.0.3...@metamask/logging-controller@1.0.4 [1.0.3]: https://github.com/MetaMask/core/compare/@metamask/logging-controller@1.0.2...@metamask/logging-controller@1.0.3 [1.0.2]: https://github.com/MetaMask/core/compare/@metamask/logging-controller@1.0.1...@metamask/logging-controller@1.0.2 diff --git a/packages/logging-controller/LICENCE b/packages/logging-controller/LICENCE index b703d6a4a23..e3e71d8cf71 100644 --- a/packages/logging-controller/LICENCE +++ b/packages/logging-controller/LICENCE @@ -18,3 +18,4 @@ 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/logging-controller/package.json b/packages/logging-controller/package.json index 451e666d4a4..08cd7e5f5ef 100644 --- a/packages/logging-controller/package.json +++ b/packages/logging-controller/package.json @@ -1,53 +1,78 @@ { "name": "@metamask/logging-controller", - "version": "1.0.4", + "version": "9.0.0", "description": "Manages logging data to assist users and support staff", "keywords": [ - "MetaMask", - "Ethereum" + "Ethereum", + "MetaMask" ], "homepage": "https://github.com/MetaMask/core/tree/main/packages/logging-controller#readme", "bugs": { "url": "https://github.com/MetaMask/core/issues" }, + "license": "MIT", "repository": { "type": "git", "url": "https://github.com/MetaMask/core.git" }, - "license": "MIT", - "main": "./dist/index.js", - "types": "./dist/index.d.ts", "files": [ "dist/" ], + "sideEffects": false, + "main": "./dist/index.cjs", + "types": "./dist/index.d.cts", + "exports": { + ".": { + "import": { + "types": "./dist/index.d.mts", + "default": "./dist/index.mjs" + }, + "require": { + "types": "./dist/index.d.cts", + "default": "./dist/index.cjs" + } + }, + "./package.json": "./package.json" + }, + "publishConfig": { + "access": "public", + "registry": "https://registry.npmjs.org/" + }, "scripts": { + "build": "ts-bridge --project tsconfig.build.json --verbose --clean --no-references", + "build:all": "ts-bridge --project tsconfig.build.json --verbose --clean", "build:docs": "typedoc", + "changelog:update": "../../scripts/update-changelog.sh @metamask/logging-controller", "changelog:validate": "../../scripts/validate-changelog.sh @metamask/logging-controller", - "publish:preview": "yarn npm publish --tag preview", - "test": "jest", - "test:clean": "jest --clearCache", - "test:watch": "jest --watch" + "lint:tsconfigs": "tsx ../../scripts/lint-tsconfigs/lint-tsconfigs.mts", + "lint:tsconfigs:fix": "tsx ../../scripts/lint-tsconfigs/lint-tsconfigs.mts --fix", + "messenger-action-types:check": "tsx ../../packages/messenger-cli/src/cli.ts --formatter oxfmt --esm --check", + "messenger-action-types:generate": "tsx ../../packages/messenger-cli/src/cli.ts --formatter oxfmt --esm --generate", + "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": "^3.2.3", - "@metamask/controller-utils": "^5.0.2", + "@metamask/base-controller": "^9.1.0", + "@metamask/messenger": "^2.0.0", + "@metamask/utils": "^11.11.0", "uuid": "^8.3.2" }, "devDependencies": { - "@metamask/auto-changelog": "^3.1.0", - "@types/jest": "^27.4.1", + "@metamask/auto-changelog": "^6.1.0", + "@ts-bridge/cli": "^0.6.4", + "@types/jest": "^30.0.0", "deepmerge": "^4.2.2", - "jest": "^27.5.1", - "ts-jest": "^27.1.4", - "typedoc": "^0.24.8", + "jest": "^30.4.2", + "ts-jest": "^29.4.11", + "tsx": "^4.20.5", + "typedoc": "^0.25.13", "typedoc-plugin-missing-exports": "^2.0.0", - "typescript": "~4.8.4" + "typescript": "~5.3.3" }, "engines": { - "node": ">=16.0.0" - }, - "publishConfig": { - "access": "public", - "registry": "https://registry.npmjs.org/" + "node": "^18.18 || >=20" } } diff --git a/packages/logging-controller/src/LoggingController-method-action-types.ts b/packages/logging-controller/src/LoggingController-method-action-types.ts new file mode 100644 index 00000000000..3e2fd71b6aa --- /dev/null +++ b/packages/logging-controller/src/LoggingController-method-action-types.ts @@ -0,0 +1,31 @@ +/** + * This file is auto generated. + * Do not edit manually. + */ + +import type { LoggingController } from './LoggingController.js'; + +/** + * Add log to the state. + * + * @param log - Log to add to the controller + */ +export type LoggingControllerAddAction = { + type: `LoggingController:add`; + handler: LoggingController['add']; +}; + +/** + * Removes all log entries. + */ +export type LoggingControllerClearAction = { + type: `LoggingController:clear`; + handler: LoggingController['clear']; +}; + +/** + * Union of all LoggingController action types. + */ +export type LoggingControllerMethodActions = + | LoggingControllerAddAction + | LoggingControllerClearAction; diff --git a/packages/logging-controller/src/LoggingController.test.ts b/packages/logging-controller/src/LoggingController.test.ts index dd55ae004cb..ef8840a3b23 100644 --- a/packages/logging-controller/src/LoggingController.test.ts +++ b/packages/logging-controller/src/LoggingController.test.ts @@ -1,58 +1,74 @@ -import { ControllerMessenger } from '@metamask/base-controller'; -import * as uuid from 'uuid'; +import { deriveStateFromMetadata } from '@metamask/base-controller'; +import { Messenger, MOCK_ANY_NAMESPACE } from '@metamask/messenger'; +import type { + MessengerActions, + MessengerEvents, + MockAnyNamespace, +} from '@metamask/messenger'; +import { Duration, inMilliseconds } from '@metamask/utils'; -import type { LoggingControllerActions } from './LoggingController'; -import { LoggingController } from './LoggingController'; -import { LogType } from './logTypes'; -import { SigningMethod, SigningStage } from './logTypes/EthSignLog'; +import type { LoggingControllerMessenger } from './LoggingController.js'; +import { LoggingController } from './LoggingController.js'; +import { SigningMethod, SigningStage } from './logTypes/EthSignLog.js'; +import { LogType } from './logTypes/index.js'; jest.mock('uuid', () => { - const actual = jest.requireActual('uuid'); return { - ...actual, - v1: jest.fn(() => actual.v1()), + __esModule: true, + ...jest.requireActual('uuid'), }; }); -const name = 'LoggingController'; +type AllLoggingControllerActions = MessengerActions; + +type AllLoggingControllerEvents = MessengerEvents; + +type RootMessenger = Messenger< + MockAnyNamespace, + AllLoggingControllerActions, + AllLoggingControllerEvents +>; + +const namespace = 'LoggingController'; /** - * Constructs a unrestricted controller messenger. + * Constructs a root messenger instance. * - * @returns A unrestricted controller messenger. + * @returns A root messenger. */ -function getUnrestrictedMessenger() { - return new ControllerMessenger(); +function getRootMessenger(): RootMessenger { + return new Messenger({ namespace: MOCK_ANY_NAMESPACE }); } /** - * Constructs a restricted controller messenger. + * Constructs a messenger instance for LoggingController. * - * @param controllerMessenger - An optional unrestricted messenger - * @returns A restricted controller messenger. + * @param messenger - An optional root messenger + * @returns A controller messenger. */ -function getRestrictedMessenger( - controllerMessenger = getUnrestrictedMessenger(), -) { - return controllerMessenger.getRestricted({ - name, +function getLoggingControllerMessenger(messenger = getRootMessenger()) { + return new Messenger< + typeof namespace, + AllLoggingControllerActions, + AllLoggingControllerEvents, + RootMessenger + >({ + namespace, + parent: messenger, }); } describe('LoggingController', () => { - afterEach(() => { - jest.clearAllMocks(); - }); it('action: LoggingController:add with generic log', async () => { - const unrestricted = getUnrestrictedMessenger(); - const messenger = getRestrictedMessenger(unrestricted); + const rootMessenger = getRootMessenger(); + const messenger = getLoggingControllerMessenger(rootMessenger); const controller = new LoggingController({ messenger, }); expect( - await unrestricted.call('LoggingController:add', { + rootMessenger.call('LoggingController:add', { type: LogType.GenericLog, data: `Generic log`, }), @@ -70,20 +86,20 @@ describe('LoggingController', () => { }); it('action: LoggingController:add for a signing request', async () => { - const unrestricted = getUnrestrictedMessenger(); - const messenger = getRestrictedMessenger(unrestricted); + const rootMessenger = getRootMessenger(); + const messenger = getLoggingControllerMessenger(rootMessenger); const controller = new LoggingController({ messenger, }); expect( - await unrestricted.call('LoggingController:add', { + rootMessenger.call('LoggingController:add', { type: LogType.EthSignLog, data: { - signingMethod: SigningMethod.EthSign, + signingMethod: SigningMethod.PersonalSign, stage: SigningStage.Proposed, - signingData: '0x0000000000000', + signingData: 'hello', }, }), ).toBeUndefined(); @@ -95,79 +111,75 @@ describe('LoggingController', () => { log: expect.objectContaining({ type: LogType.EthSignLog, data: { - signingMethod: SigningMethod.EthSign, + signingMethod: SigningMethod.PersonalSign, stage: SigningStage.Proposed, - signingData: '0x0000000000000', + signingData: 'hello', }, }), }); }); - it('action: LoggingController:add prevents possible collision of ids', async () => { - const unrestricted = getUnrestrictedMessenger(); - const messenger = getRestrictedMessenger(unrestricted); + it('removes expired logs', () => { + const rootMessenger = getRootMessenger(); + const messenger = getLoggingControllerMessenger(rootMessenger); const controller = new LoggingController({ messenger, + state: { + logs: { + foo: { + id: 'foo', + timestamp: Date.now() - inMilliseconds(14, Duration.Day), + log: { + type: LogType.GenericLog, + data: 'bar', + }, + }, + baz: { + id: 'baz', + timestamp: Date.now() - inMilliseconds(1, Duration.Day), + log: { + type: LogType.GenericLog, + data: 'qux', + }, + }, + }, + }, }); expect( - await unrestricted.call('LoggingController:add', { + rootMessenger.call('LoggingController:add', { type: LogType.GenericLog, data: `Generic log`, }), ).toBeUndefined(); - - const { id } = Object.values(controller.state.logs)[0]; - - if (jest.isMockFunction(uuid.v1)) { - uuid.v1.mockImplementationOnce(() => id); - } - - expect( - await unrestricted.call('LoggingController:add', { - type: LogType.GenericLog, - data: `Generic log 2`, - }), - ).toBeUndefined(); const logs = Object.values(controller.state.logs); expect(logs).toHaveLength(2); - expect(logs).toContainEqual({ - timestamp: expect.any(Number), - id, - log: expect.objectContaining({ - type: LogType.GenericLog, - data: 'Generic log', - }), - }); - expect(logs).toContainEqual({ timestamp: expect.any(Number), id: expect.any(String), log: expect.objectContaining({ type: LogType.GenericLog, - data: 'Generic log 2', + data: 'Generic log', }), }); - - expect(uuid.v1).toHaveBeenCalledTimes(3); }); it('internal method: clear', async () => { - const unrestricted = getUnrestrictedMessenger(); - const messenger = getRestrictedMessenger(unrestricted); + const rootMessenger = getRootMessenger(); + const messenger = getLoggingControllerMessenger(rootMessenger); const controller = new LoggingController({ messenger, }); expect( - await unrestricted.call('LoggingController:add', { + rootMessenger.call('LoggingController:add', { type: LogType.EthSignLog, data: { - signingMethod: SigningMethod.EthSign, + signingMethod: SigningMethod.PersonalSign, stage: SigningStage.Proposed, - signingData: '0x0000000000000', + signingData: 'Heya', }, }), ).toBeUndefined(); @@ -176,4 +188,78 @@ describe('LoggingController', () => { const logs = Object.values(controller.state.logs); expect(logs).toHaveLength(0); }); + + describe('metadata', () => { + it('includes expected state in debug snapshots', () => { + const rootMessenger = getRootMessenger(); + const messenger = getLoggingControllerMessenger(rootMessenger); + const controller = new LoggingController({ + messenger, + }); + + expect( + deriveStateFromMetadata( + controller.state, + controller.metadata, + 'includeInDebugSnapshot', + ), + ).toMatchInlineSnapshot(`{}`); + }); + + it('includes expected state in state logs', () => { + const rootMessenger = getRootMessenger(); + const messenger = getLoggingControllerMessenger(rootMessenger); + const controller = new LoggingController({ + messenger, + }); + + expect( + deriveStateFromMetadata( + controller.state, + controller.metadata, + 'includeInStateLogs', + ), + ).toMatchInlineSnapshot(` + { + "logs": {}, + } + `); + }); + + it('persists expected state', () => { + const rootMessenger = getRootMessenger(); + const messenger = getLoggingControllerMessenger(rootMessenger); + const controller = new LoggingController({ + messenger, + }); + + expect( + deriveStateFromMetadata( + controller.state, + controller.metadata, + 'persist', + ), + ).toMatchInlineSnapshot(` + { + "logs": {}, + } + `); + }); + + it('exposes expected state to UI', () => { + const rootMessenger = getRootMessenger(); + const messenger = getLoggingControllerMessenger(rootMessenger); + const controller = new LoggingController({ + messenger, + }); + + expect( + deriveStateFromMetadata( + controller.state, + controller.metadata, + 'usedInUi', + ), + ).toMatchInlineSnapshot(`{}`); + }); + }); }); diff --git a/packages/logging-controller/src/LoggingController.ts b/packages/logging-controller/src/LoggingController.ts index 6d5ef199007..07ede4f25fa 100644 --- a/packages/logging-controller/src/LoggingController.ts +++ b/packages/logging-controller/src/LoggingController.ts @@ -1,8 +1,15 @@ -import type { RestrictedControllerMessenger } from '@metamask/base-controller'; -import { BaseControllerV2 } from '@metamask/base-controller'; -import { v1 as random } from 'uuid'; +import type { + ControllerGetStateAction, + ControllerStateChangeEvent, + StateMetadata, +} from '@metamask/base-controller'; +import { BaseController } from '@metamask/base-controller'; +import type { Messenger } from '@metamask/messenger'; +import { Duration, inMilliseconds } from '@metamask/utils'; +import { v4 as uuid } from 'uuid'; -import type { Log } from './logTypes'; +import type { LoggingControllerMethodActions } from './LoggingController-method-action-types.js'; +import type { Log } from './logTypes/index.js'; /** * LogEntry is the entry that will be added to the logging controller state. @@ -28,30 +35,37 @@ export type LoggingControllerState = { const name = 'LoggingController'; -/** - * An action to add log messages to the controller state. - */ -export type AddLog = { - type: `${typeof name}:add`; - handler: LoggingController['add']; -}; +const MESSENGER_EXPOSED_METHODS = ['add', 'clear'] as const; -/** - * Currently only an alias, but the idea here is if future actions are needed - * this can transition easily into a union type. - */ -export type LoggingControllerActions = AddLog; +export type LoggingControllerGetStateAction = ControllerGetStateAction< + typeof name, + LoggingControllerState +>; + +export type LoggingControllerActions = + | LoggingControllerGetStateAction + | LoggingControllerMethodActions; + +export type LoggingControllerStateChangeEvent = ControllerStateChangeEvent< + typeof name, + LoggingControllerState +>; -export type LoggingControllerMessenger = RestrictedControllerMessenger< +export type LoggingControllerEvents = LoggingControllerStateChangeEvent; + +export type LoggingControllerMessenger = Messenger< typeof name, LoggingControllerActions, - never, - never, - never + LoggingControllerEvents >; -const metadata = { - logs: { persist: true, anonymous: false }, +const metadata: StateMetadata = { + logs: { + includeInStateLogs: true, + persist: true, + includeInDebugSnapshot: false, + usedInUi: false, + }, }; const defaultState = { @@ -61,24 +75,29 @@ const defaultState = { /** * Controller that manages a list of logs for signature requests. */ -export class LoggingController extends BaseControllerV2< +export class LoggingController extends BaseController< typeof name, LoggingControllerState, LoggingControllerMessenger > { + readonly #expiryTime: number; + /** * Creates a LoggingController instance. * * @param options - Constructor options - * @param options.messenger - An instance of the ControllerMessenger + * @param options.messenger - An instance of the Messenger * @param options.state - Initial state to set on this controller. + * @param options.expiryTime - The number of milliseconds before we consider a log entry expired. */ constructor({ messenger, state, + expiryTime = inMilliseconds(7, Duration.Day), }: { messenger: LoggingControllerMessenger; state?: Partial; + expiryTime?: number; }) { super({ name, @@ -90,27 +109,12 @@ export class LoggingController extends BaseControllerV2< }, }); - this.messagingSystem.registerActionHandler( - `${name}:add` as const, - (log: Log) => this.add(log), - ); - } + this.#expiryTime = expiryTime; - /** - * Method to generate a randomId and ensures no collision with existing ids. - * - * We may want to end up using a hashing mechanism to make ids deterministic - * by the *data* passed in, and then make each key an array of logs that - * match that id. - * - * @returns unique id - */ - #generateId(): string { - let id = random(); - while (id in this.state.logs) { - id = random(); - } - return id; + this.messenger.registerMethodActionHandlers( + this, + MESSENGER_EXPOSED_METHODS, + ); } /** @@ -120,12 +124,19 @@ export class LoggingController extends BaseControllerV2< */ add(log: Log) { const newLog: LogEntry = { - id: this.#generateId(), + id: uuid(), timestamp: Date.now(), log, }; + const expiry = Date.now() - this.#expiryTime; + this.update((state) => { + for (const [id, entry] of Object.entries(state.logs)) { + if (entry.timestamp < expiry) { + delete state.logs[id]; + } + } state.logs[newLog.id] = newLog; }); } diff --git a/packages/logging-controller/src/index.ts b/packages/logging-controller/src/index.ts index e52d08b05da..662d5365fd7 100644 --- a/packages/logging-controller/src/index.ts +++ b/packages/logging-controller/src/index.ts @@ -1,2 +1,6 @@ -export * from './LoggingController'; -export * from './logTypes'; +export * from './LoggingController.js'; +export type { + LoggingControllerAddAction, + LoggingControllerClearAction, +} from './LoggingController-method-action-types.js'; +export * from './logTypes/index.js'; diff --git a/packages/logging-controller/src/logTypes/EthSignLog.ts b/packages/logging-controller/src/logTypes/EthSignLog.ts index 008f47ba5fe..7c58744c12c 100644 --- a/packages/logging-controller/src/logTypes/EthSignLog.ts +++ b/packages/logging-controller/src/logTypes/EthSignLog.ts @@ -1,10 +1,9 @@ -import type { LogType } from './LogType'; +import type { LogType } from './LogType.js'; /** * An enum of the signing method types that we are interested in logging. */ export enum SigningMethod { - EthSign = 'eth_sign', PersonalSign = 'personal_sign', EthSignTypedData = 'eth_signTypedData', EthSignTypedDataV3 = 'eth_signTypedData_v3', @@ -31,6 +30,8 @@ export type EthSignLog = { data: { signingMethod: SigningMethod; stage: SigningStage; + // TODO: Replace `any` with type + // eslint-disable-next-line @typescript-eslint/no-explicit-any signingData?: any; }; }; diff --git a/packages/logging-controller/src/logTypes/GenericLog.ts b/packages/logging-controller/src/logTypes/GenericLog.ts index be9fd378670..2f398143658 100644 --- a/packages/logging-controller/src/logTypes/GenericLog.ts +++ b/packages/logging-controller/src/logTypes/GenericLog.ts @@ -1,4 +1,4 @@ -import type { LogType } from './LogType'; +import type { LogType } from './LogType.js'; /* * The logging controller can handle any kind of log statement that may benefit @@ -7,5 +7,7 @@ import type { LogType } from './LogType'; */ export type GenericLog = { type: LogType.GenericLog; + // TODO: Replace `any` with type + // eslint-disable-next-line @typescript-eslint/no-explicit-any data: any; }; diff --git a/packages/logging-controller/src/logTypes/index.ts b/packages/logging-controller/src/logTypes/index.ts index 48239f262c5..c8dc8436593 100644 --- a/packages/logging-controller/src/logTypes/index.ts +++ b/packages/logging-controller/src/logTypes/index.ts @@ -1,5 +1,5 @@ -import type { EthSignLog } from './EthSignLog'; -import type { GenericLog } from './GenericLog'; +import type { EthSignLog } from './EthSignLog.js'; +import type { GenericLog } from './GenericLog.js'; /** * Union of all possible log data structures. @@ -9,6 +9,6 @@ export type Log = EthSignLog | GenericLog; /** * Export all other types from these files for usage by clients */ -export * from './EthSignLog'; -export * from './GenericLog'; -export * from './LogType'; +export * from './EthSignLog.js'; +export type * from './GenericLog.js'; +export * from './LogType.js'; diff --git a/packages/logging-controller/tsconfig.build.json b/packages/logging-controller/tsconfig.build.json index ac0df4920c6..931c4d6594b 100644 --- a/packages/logging-controller/tsconfig.build.json +++ b/packages/logging-controller/tsconfig.build.json @@ -7,8 +7,7 @@ }, "references": [ { "path": "../base-controller/tsconfig.build.json" }, - { "path": "../controller-utils/tsconfig.build.json" }, - { "path": "../network-controller/tsconfig.build.json" } + { "path": "../messenger/tsconfig.build.json" } ], "include": ["../../types", "./src"] } diff --git a/packages/logging-controller/tsconfig.json b/packages/logging-controller/tsconfig.json index 4bbb0be81b1..68c3ddfc2cd 100644 --- a/packages/logging-controller/tsconfig.json +++ b/packages/logging-controller/tsconfig.json @@ -3,10 +3,6 @@ "compilerOptions": { "baseUrl": "./" }, - "references": [ - { "path": "../base-controller" }, - { "path": "../controller-utils" }, - { "path": "../network-controller" } - ], + "references": [{ "path": "../base-controller" }, { "path": "../messenger" }], "include": ["../../types", "./src"] } diff --git a/packages/message-manager/CHANGELOG.md b/packages/message-manager/CHANGELOG.md index a67e05a7f25..21dbb9ea671 100644 --- a/packages/message-manager/CHANGELOG.md +++ b/packages/message-manager/CHANGELOG.md @@ -1,4 +1,5 @@ # 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/), @@ -6,61 +7,360 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Changed + +- Bump `@metamask/utils` from `^11.9.0` to `^11.11.0` ([#9074](https://github.com/MetaMask/core/pull/9074)) +- Bump `@metamask/controller-utils` from `^12.0.0` to `^12.3.0` ([#8774](https://github.com/MetaMask/core/pull/8774), [#9058](https://github.com/MetaMask/core/pull/9058), [#9083](https://github.com/MetaMask/core/pull/9083), [#9218](https://github.com/MetaMask/core/pull/9218)) +- Bump `@metamask/messenger` from `^1.2.0` to `^2.0.0` ([#9392](https://github.com/MetaMask/core/pull/9392)) +- Bump `@metamask/eth-sig-util` from `^8.2.0` to `^9.0.0` ([#9999](https://github.com/MetaMask/core/pull/9999)) + +## [14.1.2] + +### Changed + +- Bump `@metamask/controller-utils` from `^11.19.0` to `^12.0.0` ([#8344](https://github.com/MetaMask/core/pull/8344), [#8755](https://github.com/MetaMask/core/pull/8755)) +- Bump `@metamask/messenger` from `^1.0.0` to `^1.2.0` ([#8364](https://github.com/MetaMask/core/pull/8364), [#8373](https://github.com/MetaMask/core/pull/8373), [#8632](https://github.com/MetaMask/core/pull/8632)) +- Bump `@metamask/base-controller` from `^9.0.1` to `^9.1.0` ([#8457](https://github.com/MetaMask/core/pull/8457)) + +## [14.1.1] + +### Changed + +- Bump `@metamask/base-controller` from `^9.0.0` to `^9.0.1` ([#8317](https://github.com/MetaMask/core/pull/8317)) +- Bump `@metamask/messenger` from `^0.3.0` to `^1.0.0` ([#8317](https://github.com/MetaMask/core/pull/8317)) +- Upgrade `@metamask/utils` from `^11.8.1` to `^11.9.0` ([#7511](https://github.com/MetaMask/core/pull/7511)) +- Bump `@metamask/controller-utils` from `^11.16.0` to `^11.19.0` ([#7534](https://github.com/MetaMask/core/pull/7534), [#7583](https://github.com/MetaMask/core/pull/7583), [#7995](https://github.com/MetaMask/core/pull/7995)) + +## [14.1.0] + +### Changed + +- Bump `@metamask/controller-utils` from `^11.15.0` to `^11.16.0` ([#7202](https://github.com/MetaMask/core/pull/7202)) +- Rename `OriginalRequest` type to `MessageRequest` and permit string `id` values ([#7065](https://github.com/MetaMask/core/pull/7065)) + - Previously, only number values were permitted for the `id` property. + - `OriginalRequest` is kept for backward compatibility. + +### Deprecated + +- Deprecate `OriginalRequest`; use `MessageRequest` instead ([#7138](https://github.com/MetaMask/core/pull/7138)) + +## [14.0.0] + +### Changed + +- **BREAKING:** Use new `Messenger` from `@metamask/messenger` ([#6545](https://github.com/MetaMask/core/pull/6545)) + - Previously, `AbstractMessageManager`, `DecryptMessageManager` and `EncryptionPublicKeyManager` accepted a `RestrictedMessenger` instance from `@metamask/base-controller`. +- Bump `@metamask/base-controller` from `^8.4.2` to `^9.0.0` ([#6962](https://github.com/MetaMask/core/pull/6962)) + +## [13.0.2] + +### Changed + +- Bump `@metamask/base-controller` from `^8.4.1` to `^8.4.2` ([#6917](https://github.com/MetaMask/core/pull/6917)) + +## [13.0.1] + +### Changed + +- Bump `@metamask/utils` from `^11.8.0` to `^11.8.1` ([#6708](https://github.com/MetaMask/core/pull/6708)) +- Bump `@metamask/base-controller` from `^8.4.0` to `^8.4.1` ([#6807](https://github.com/MetaMask/core/pull/6807)) +- Bump `@metamask/controller-utils` from `^11.14.0` to `^11.14.1` ([#6807](https://github.com/MetaMask/core/pull/6807)) + +## [13.0.0] + +### Added + +- Add two new controller state metadata properties: `includeInStateLogs` and `usedInUi` ([#6473](https://github.com/MetaMask/core/pull/6473)) + +### Changed + +- **BREAKING:** `AbstractMessageManager` now expects a `Name extends string` generic parameter to define the name of the message manager ([#6469](https://github.com/MetaMask/core/pull/6469)) + - The type is used as namespace for `BaseController` and `Messenger` events and actions. +- Bump `@metamask/base-controller` from `^8.0.1` to `^8.4.0` ([#6284](https://github.com/MetaMask/core/pull/6284), [#6355](https://github.com/MetaMask/core/pull/6355), [#6465](https://github.com/MetaMask/core/pull/6465), [#6632](https://github.com/MetaMask/core/pull/6632)) +- Bump `@metamask/controller-utils` from `^11.11.0` to `^11.14.0` ([#6303](https://github.com/MetaMask/core/pull/6303), [#6620](https://github.com/MetaMask/core/pull/6620), [#6629](https://github.com/MetaMask/core/pull/6629)) +- Bump `@metamask/utils` from `^11.4.2` to `^11.8.0` ([#6588](https://github.com/MetaMask/core/pull/6588)) + +## [12.0.2] + +### Changed + +- Bump `@metamask/eth-sig-util` from `^8.0.0` to `^8.2.0` ([#5301](https://github.com/MetaMask/core/pull/5301)) +- Bump `@metamask/utils` from `^11.1.0` to `^11.4.2` ([#5301](https://github.com/MetaMask/core/pull/5301), [#6054](https://github.com/MetaMask/core/pull/6054)) +- Bump `@metamask/base-controller` from ^8.0.0 to ^8.0.1 ([#5722](https://github.com/MetaMask/core/pull/5722)) +- Bump `@metamask/controller-utils` to `^11.11.0` ([#5439](https://github.com/MetaMask/core/pull/5439), [#5935](https://github.com/MetaMask/core/pull/5935), [#5583](https://github.com/MetaMask/core/pull/5583), [#5765](https://github.com/MetaMask/core/pull/5765), [#5812](https://github.com/MetaMask/core/pull/5812), [#6069](https://github.com/MetaMask/core/pull/6069)) + - This upgrade includes performance improvements to checksum hex address normalization + +## [12.0.1] + +### Changed + +- Bump `@metamask/base-controller` from `^7.1.0` to `^8.0.0` ([#5135](https://github.com/MetaMask/core/pull/5135)), ([#5305](https://github.com/MetaMask/core/pull/5305)) +- Bump `@metamask/controller-utils` from `^11.4.4` to `^11.5.0` ([#5135](https://github.com/MetaMask/core/pull/5135)), ([#5272](https://github.com/MetaMask/core/pull/5272)) +- Bump `@metamask/utils` from `^11.0.1` to `^11.1.0` ([#5223](https://github.com/MetaMask/core/pull/5223)) + +## [12.0.0] + +### Changed + +- **BREAKING:** Base class of `DecryptMessageManager` and `EncryptionPublicKeyManager`(`AbstractMessageManager`) now expects new options to initialise ([#5103](https://github.com/MetaMask/core/pull/5103)) +- Bump `@metamask/base-controller` from `^7.0.0` to `^7.1.0` ([#5079](https://github.com/MetaMask/core/pull/5079)) + +### Removed + +- **BREAKING:** Removed internal event emitter (`hub` property) from `AbstractMessageManager` ([#5103](https://github.com/MetaMask/core/pull/5103)) +- **BREAKING:** `unapprovedMessage` and `updateBadge` removed from internal events. These events are now emitted from messaging system ([#5103](https://github.com/MetaMask/core/pull/5103)) + - Controllers should now listen to `DerivedManagerName:X` event instead of using internal event emitter. + +## [11.0.3] + +### Changed + +- Bump `jsonschema` from `^1.2.4` to `^1.4.1` ([#4998](https://github.com/MetaMask/core/pull/4998), [#5027](https://github.com/MetaMask/core/pull/5027)) + +## [11.0.2] + +### Changed + +- Bump `@metamask/controller-utils` from `^11.4.2` to `^11.4.4` ([#4915](https://github.com/MetaMask/core/pull/4915), [#5012](https://github.com/MetaMask/core/pull/5012)) + +## [11.0.1] + +### Changed + +- Bump `@metamask/base-controller` from `^7.0.1` to `^7.0.2` ([#4862](https://github.com/MetaMask/core/pull/4862)) +- Bump `@metamask/controller-utils` from `^11.3.0` to `^11.4.2` ([#4834](https://github.com/MetaMask/core/pull/4834), [#4862](https://github.com/MetaMask/core/pull/4862), [#4870](https://github.com/MetaMask/core/pull/4870)) +- Bump `@metamask/utils` from `^9.1.0` to `^10.0.0` ([#4831](https://github.com/MetaMask/core/pull/4831)) +- Bump `@metamask/eth-sig-util` from `^7.0.1` to `^8.0.0` ([#4830](https://github.com/MetaMask/core/pull/4830)) + +## [11.0.0] + +### Removed + +- Remove all code related to `@metamask/signature-controller` ([#4785](https://github.com/MetaMask/core/pull/4785)) + - Remove `TypedMessageManager`. + - Remove `PersonalMessageManager`. + - Remove utils: + - `validateSignMessageData` + - `validateTypedSignMessageDataV1` + - `validateTypedSignMessageDataV3V4` + +## [10.1.1] + +### Fixed + +- Produce and export ESM-compatible TypeScript type declaration files in addition to CommonJS-compatible declaration files ([#4648](https://github.com/MetaMask/core/pull/4648)) + - Previously, this package shipped with only one variant of type declaration + files, and these files were only CommonJS-compatible, and the `exports` + field in `package.json` linked to these files. This is an anti-pattern and + was rightfully flagged by the + ["Are the Types Wrong?"](https://arethetypeswrong.github.io/) tool as + ["masquerading as CJS"](https://github.com/arethetypeswrong/arethetypeswrong.github.io/blob/main/docs/problems/FalseCJS.md). + All of the ATTW checks now pass. +- Remove chunk files ([#4648](https://github.com/MetaMask/core/pull/4648)). + - Previously, the build tool we used to generate JavaScript files extracted + common code to "chunk" files. While this was intended to make this package + more tree-shakeable, it also made debugging more difficult for our + development teams. These chunk files are no longer present. + +## [10.1.0] + +### Added + +- Add protected methods `addRequestToMessageParams`, `createUnapprovedMessage` to `AbstractMessageManager` + +### Changed + +- Add `requestId` property to the `messageParams` object to reference metric event fragments created from the `createRPCMethodTrackingMiddleware` in the client ([#4636](https://github.com/MetaMask/core/pull/4636)) + - Add optional property `requestId` to `AbstractMessageParams` type + - Add optional property `id` to `OriginalRequest` type +- Bump `@metamask/controller-utils` from `^11.1.0` to `^11.2.0` ([#4651](https://github.com/MetaMask/core/pull/4651)) + +## [10.0.3] + +### Changed + +- Bump `@metamask/base-controller` from `^6.0.2` to `^7.0.0` ([#4625](https://github.com/MetaMask/core/pull/4625), [#4643](https://github.com/MetaMask/core/pull/4643)) +- Bump `typescript` from `~5.0.4` to `~5.2.2` ([#4576](https://github.com/MetaMask/core/pull/4576), [#4584](https://github.com/MetaMask/core/pull/4584)) + +## [10.0.2] + +### Changed + +- Upgrade TypeScript version to `~5.0.4` and set `moduleResolution` option to `Node16` ([#3645](https://github.com/MetaMask/core/pull/3645)) +- Bump `@metamask/base-controller` from `^6.0.1` to `^6.0.2` ([#4544](https://github.com/MetaMask/core/pull/4544)) +- Bump `@metamask/controller-utils` from `^11.0.1` to `^11.0.2` ([#4544](https://github.com/MetaMask/core/pull/4544)) +- Bump `@metamask/utils` from `^9.0.0` to `^9.1.0` ([#4529](https://github.com/MetaMask/core/pull/4529)) + +## [10.0.1] + +### Changed + +- Bump `@metamask/utils` to `^9.0.0`, `@metamask/rpc-errors` to `^6.3.1` ([#4516](https://github.com/MetaMask/core/pull/4516)) + +### Fixed + +- Add `EventEmitter` type annotation to the `hub` class field of `AbstractMessageManager` ([#4510](https://github.com/MetaMask/core/pull/4510)) + - This ensures that `hub` is not inferred to be a generic type, which would break types for downstream consumers. + +## [10.0.0] + +### Changed + +- **BREAKING:** Bump minimum Node version to 18.18 ([#3611](https://github.com/MetaMask/core/pull/3611)) +- Bump `@metamask/base-controller` to `^6.0.0` ([#4352](https://github.com/MetaMask/core/pull/4352)) +- Bump `@metamask/controller-utils` to `^11.0.0` ([#4352](https://github.com/MetaMask/core/pull/4352)) + +## [9.0.0] + +### Changed + +- Bump `@metamask/controller-utils` to `^10.0.0` ([#4342](https://github.com/MetaMask/core/pull/4342)) + +### Removed + +- **BREAKING:** Remove `Message`, `MessageParams`, `MessageParamsMetamask`, and `MessageManager` ([#4319](https://github.com/MetaMask/core/pull/4319)) + - Support for `eth_sign` is being removed, so these are no longer needed. + +## [8.0.2] + +### Changed + +- Bump TypeScript version to `~4.9.5` ([#4084](https://github.com/MetaMask/core/pull/4084)) +- Bump `@metamask/base-controller` to `^5.0.2` ([#4232](https://github.com/MetaMask/core/pull/4232)) +- Bump `@metamask/controller-utils` to `^9.1.0` ([#4153](https://github.com/MetaMask/core/pull/4153), [#4065](https://github.com/MetaMask/core/pull/4065)) + +## [8.0.1] + +### Fixed + +- Fix `types` field in `package.json` ([#4047](https://github.com/MetaMask/core/pull/4047)) + +## [8.0.0] + +### Added + +- **BREAKING**: Add ESM build ([#3998](https://github.com/MetaMask/core/pull/3998)) + - It's no longer possible to import files from `./dist` directly. + +### Changed + +- **BREAKING:** Bump `@metamask/base-controller` to `^5.0.0` ([#4039](https://github.com/MetaMask/core/pull/4039)) + - This version has a number of breaking changes. See the changelog for more. +- Bump `@metamask/controller-utils` to `^9.0.0` ([#4039](https://github.com/MetaMask/core/pull/4039)) + +## [7.3.9] + +### Changed + +- Remove dependency `ethereumjs-util` ([#3943](https://github.com/MetaMask/core/pull/3943)) +- Bump `@metamask/controller-utils` to `^8.0.4` ([#4007](https://github.com/MetaMask/core/pull/4007)) + +## [7.3.8] + +### Changed + +- Bump `@metamask/utils` to `^8.3.0` ([#3769](https://github.com/MetaMask/core/pull/3769)) +- Bump `@metamask/base-controller` to `^4.1.1` ([#3760](https://github.com/MetaMask/core/pull/3760), [#3821](https://github.com/MetaMask/core/pull/3821)) +- Bump `@metamask/controller-utils` to `^8.0.2` ([#3821](https://github.com/MetaMask/core/pull/3821)) + +## [7.3.7] + +### Changed + +- Bump `@metamask/base-controller` to `^4.0.1` ([#3695](https://github.com/MetaMask/core/pull/3695)) +- Bump `@metamask/controller-utils` to `^8.0.1` ([#3695](https://github.com/MetaMask/core/pull/3695), [#3678](https://github.com/MetaMask/core/pull/3678), [#3667](https://github.com/MetaMask/core/pull/3667), [#3580](https://github.com/MetaMask/core/pull/3580)) +- Bump `@metamask/eth-sig-util` to `^7.0.1` ([#3614](https://github.com/MetaMask/core/pull/3614)) + +## [7.3.6] + +### Changed + +- Bump `@metamask/utils` to ^8.2.0 ([#1957](https://github.com/MetaMask/core/pull/1957)) +- Bump `@metamask/base-controller` to ^4.0.0 ([#2063](https://github.com/MetaMask/core/pull/2063)) + - This is not breaking because the message managers still inherit from BaseController v1. +- Bump `@metamask/controller-utils` to ^6.0.0 ([#2063](https://github.com/MetaMask/core/pull/2063)) + ## [7.3.5] + ### Changed + - Bump dependency on `@metamask/utils` to ^8.1.0 ([#1639](https://github.com/MetaMask/core/pull/1639)) - Bump dependency on `@metamask/base-controller` to ^3.2.3 - Bump dependency on `metamask/controller-utils` to ^5.0.2 ### Fixed + - Fix `prepMessageForSigning` in all message managers to handle frozen `messageParams` ([#1733](https://github.com/MetaMask/core/pull/1733)) ## [7.3.4] + ### Changed + - Update TypeScript to v4.8.x ([#1718](https://github.com/MetaMask/core/pull/1718)) ## [7.3.3] + ### Changed + - Bump dependency on `@metamask/controller-utils` to ^5.0.0 ## [7.3.2] + ### Changed + - Bump @metamask/eth-sig-util from 6.0.0 to 7.0.0 ([#1669](https://github.com/MetaMask/core/pull/1669)) ## [7.3.1] + ### Changed + - Bump dependency on `@metamask/base-controller` to ^3.2.1 - Bump dependency on `@metamask/controller-utils` to ^4.3.2 ## [7.3.0] + ### Changed + - Add Blockaid validation response to messages ([#1541](https://github.com/MetaMask/core/pull/1541)) ## [7.2.0] + ### Changed + - Update `@metamask/utils` to `^6.2.0` ([#1514](https://github.com/MetaMask/core/pull/1514)) ## [7.1.0] + ### Changed + - Replace eth-sig-util with @metamask/eth-sig-util ([#1483](https://github.com/MetaMask/core/pull/1483)) ## [7.0.2] + ### Fixed + - Avoid race condition when creating typed messages ([#1467](https://github.com/MetaMask/core/pull/1467)) ## [7.0.1] + ### Fixed + - eth_signTypedData_v4 and v3 should take an object as well as string for data parameter. ([#1438](https://github.com/MetaMask/core/pull/1438)) ## [7.0.0] + ### Added + - Added `waitForFinishStatus` to `AbstractMessageManager` which is waiting for the message to be proccesed and resolve. ([#1377](https://github.com/MetaMask/core/pull/1377)) ### Changed + - **BREAKING:** Removed `addUnapprovedMessageAsync` methods from `PersonalMessageManager`, `TypedMessageManager` and `MessageManager` because it's not consumed by `SignatureController` anymore. ([#1377](https://github.com/MetaMask/core/pull/1377)) ## [6.0.0] + ### Added + - Add `getAllMessages` and `setMetadata` methods to message managers ([#1364](https://github.com/MetaMask/core/pull/1364)) - A new optional `metadata` property has been added to the message type as well - Add support for deferred signing ([#1364](https://github.com/MetaMask/core/pull/1364)) @@ -68,57 +368,79 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Add the `setMessageStatusInProgress` method to set a message status to `inProgress` ([#1339](https://github.com/MetaMask/core/pull/1339)) ### Changed + - **BREAKING:** The `getCurrentChainId` constructor parameter for each message manager now expects a `Hex` return type rather than a decimal string ([#1367](https://github.com/MetaMask/core/pull/1367)) - Note that while every message manager class accepts this as a constructor parameter, it's only used by the `TypedMessageManager` at the moment - Add `@metamask/utils` dependency ([#1370](https://github.com/MetaMask/core/pull/1370)) ## [5.0.0] + ### Fixed + - **BREAKING:** Add chain validation to `eth_signTypedData_v4` signature requests ([#1331](https://github.com/MetaMask/core/pull/1331)) ## [4.0.0] + ### Changed + - **BREAKING:** Change type of `securityProviderResponse` to `Record` ([#1214](https://github.com/MetaMask/core/pull/1214)) - **BREAKING:** Update to Node 16 ([#1262](https://github.com/MetaMask/core/pull/1262)) ## [3.1.1] + ### Fixed -- Ensure message updates get saved in state even when they aren't emitted right away ([#1245](https://github.com/MetaMask/core/pull/1245)) + +- Ensure message updates get saved in state even when they aren't emitted right away ([#1245](https://github.com/MetaMask/core/pull/1245)) - The `updateMessage` method included in each message manager accepted an `emitUpdate` boolean argument that would enable to caller to prevent that update from updating the badge (which displays the count of pending confirmations). Unfortunately this option would also prevent the update from being saved in state. - This method has been updated to ensure message updates are saved in state, even when the badge update event is suppressed ## [3.1.0] + ### Added + - Add DecryptMessageManager ([#1149](https://github.com/MetaMask/core/pull/1149)) ## [3.0.0] + ### Added + - Add EncryptionPublicKeyManager ([#1144](https://github.com/MetaMask/core/pull/1144)) - Add security provider request to AbstractMessageManager ([#1145](https://github.com/MetaMask/core/pull/1145)) ### Changed + - **BREAKING:** The methods `addMessage` and `addUnapprovedMessage` on each "message manager" controller are now asynchronous ([#1145](https://github.com/MetaMask/core/pull/1145)) ## [2.1.0] + ### Added + - Add SIWE detection support for PersonalMessageManager ([#1139](https://github.com/MetaMask/core/pull/1139)) ## [2.0.0] + ### Removed + - **BREAKING:** Remove `isomorphic-fetch` ([#1106](https://github.com/MetaMask/controllers/pull/1106)) - Consumers must now import `isomorphic-fetch` or another polyfill themselves if they are running in an environment without `fetch` ## [1.0.2] + ### Changed + - Rename this repository to `core` ([#1031](https://github.com/MetaMask/controllers/pull/1031)) - Update `@metamask/controller-utils` package ([#1041](https://github.com/MetaMask/controllers/pull/1041)) ## [1.0.1] + ### Changed + - Relax dependencies on `@metamask/base-controller` and `@metamask/controller-utils` (use `^` instead of `~`) ([#998](https://github.com/MetaMask/core/pull/998)) ## [1.0.0] + ### Added + - Initial release - As a result of converting our shared controllers repo into a monorepo ([#831](https://github.com/MetaMask/core/pull/831)), we've created this package from select parts of [`@metamask/controllers` v33.0.0](https://github.com/MetaMask/core/tree/v33.0.0), namely: - Everything in `src/message-manager` @@ -126,7 +448,35 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 All changes listed after this point were applied to this package following the monorepo conversion. -[Unreleased]: https://github.com/MetaMask/core/compare/@metamask/message-manager@7.3.5...HEAD +[Unreleased]: https://github.com/MetaMask/core/compare/@metamask/message-manager@14.1.2...HEAD +[14.1.2]: https://github.com/MetaMask/core/compare/@metamask/message-manager@14.1.1...@metamask/message-manager@14.1.2 +[14.1.1]: https://github.com/MetaMask/core/compare/@metamask/message-manager@14.1.0...@metamask/message-manager@14.1.1 +[14.1.0]: https://github.com/MetaMask/core/compare/@metamask/message-manager@14.0.0...@metamask/message-manager@14.1.0 +[14.0.0]: https://github.com/MetaMask/core/compare/@metamask/message-manager@13.0.2...@metamask/message-manager@14.0.0 +[13.0.2]: https://github.com/MetaMask/core/compare/@metamask/message-manager@13.0.1...@metamask/message-manager@13.0.2 +[13.0.1]: https://github.com/MetaMask/core/compare/@metamask/message-manager@13.0.0...@metamask/message-manager@13.0.1 +[13.0.0]: https://github.com/MetaMask/core/compare/@metamask/message-manager@12.0.2...@metamask/message-manager@13.0.0 +[12.0.2]: https://github.com/MetaMask/core/compare/@metamask/message-manager@12.0.1...@metamask/message-manager@12.0.2 +[12.0.1]: https://github.com/MetaMask/core/compare/@metamask/message-manager@12.0.0...@metamask/message-manager@12.0.1 +[12.0.0]: https://github.com/MetaMask/core/compare/@metamask/message-manager@11.0.3...@metamask/message-manager@12.0.0 +[11.0.3]: https://github.com/MetaMask/core/compare/@metamask/message-manager@11.0.2...@metamask/message-manager@11.0.3 +[11.0.2]: https://github.com/MetaMask/core/compare/@metamask/message-manager@11.0.1...@metamask/message-manager@11.0.2 +[11.0.1]: https://github.com/MetaMask/core/compare/@metamask/message-manager@11.0.0...@metamask/message-manager@11.0.1 +[11.0.0]: https://github.com/MetaMask/core/compare/@metamask/message-manager@10.1.1...@metamask/message-manager@11.0.0 +[10.1.1]: https://github.com/MetaMask/core/compare/@metamask/message-manager@10.1.0...@metamask/message-manager@10.1.1 +[10.1.0]: https://github.com/MetaMask/core/compare/@metamask/message-manager@10.0.3...@metamask/message-manager@10.1.0 +[10.0.3]: https://github.com/MetaMask/core/compare/@metamask/message-manager@10.0.2...@metamask/message-manager@10.0.3 +[10.0.2]: https://github.com/MetaMask/core/compare/@metamask/message-manager@10.0.1...@metamask/message-manager@10.0.2 +[10.0.1]: https://github.com/MetaMask/core/compare/@metamask/message-manager@10.0.0...@metamask/message-manager@10.0.1 +[10.0.0]: https://github.com/MetaMask/core/compare/@metamask/message-manager@9.0.0...@metamask/message-manager@10.0.0 +[9.0.0]: https://github.com/MetaMask/core/compare/@metamask/message-manager@8.0.2...@metamask/message-manager@9.0.0 +[8.0.2]: https://github.com/MetaMask/core/compare/@metamask/message-manager@8.0.1...@metamask/message-manager@8.0.2 +[8.0.1]: https://github.com/MetaMask/core/compare/@metamask/message-manager@8.0.0...@metamask/message-manager@8.0.1 +[8.0.0]: https://github.com/MetaMask/core/compare/@metamask/message-manager@7.3.9...@metamask/message-manager@8.0.0 +[7.3.9]: https://github.com/MetaMask/core/compare/@metamask/message-manager@7.3.8...@metamask/message-manager@7.3.9 +[7.3.8]: https://github.com/MetaMask/core/compare/@metamask/message-manager@7.3.7...@metamask/message-manager@7.3.8 +[7.3.7]: https://github.com/MetaMask/core/compare/@metamask/message-manager@7.3.6...@metamask/message-manager@7.3.7 +[7.3.6]: https://github.com/MetaMask/core/compare/@metamask/message-manager@7.3.5...@metamask/message-manager@7.3.6 [7.3.5]: https://github.com/MetaMask/core/compare/@metamask/message-manager@7.3.4...@metamask/message-manager@7.3.5 [7.3.4]: https://github.com/MetaMask/core/compare/@metamask/message-manager@7.3.3...@metamask/message-manager@7.3.4 [7.3.3]: https://github.com/MetaMask/core/compare/@metamask/message-manager@7.3.2...@metamask/message-manager@7.3.3 diff --git a/packages/message-manager/LICENSE b/packages/message-manager/LICENSE index ddfbecf9020..bbed2e24b91 100644 --- a/packages/message-manager/LICENSE +++ b/packages/message-manager/LICENSE @@ -18,3 +18,4 @@ 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/message-manager/README.md b/packages/message-manager/README.md index 54c03863d65..f49f66919a7 100644 --- a/packages/message-manager/README.md +++ b/packages/message-manager/README.md @@ -10,6 +10,10 @@ or `npm install @metamask/message-manager` +## Compatibility + +This package relies implicitly upon the `EventEmitter` module. This module is available natively in Node.js, but when using this package for the browser, make sure to use a polyfill such as `events`. + ## 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/message-manager/package.json b/packages/message-manager/package.json index 48b9b2d8bf8..140dbf990dc 100644 --- a/packages/message-manager/package.json +++ b/packages/message-manager/package.json @@ -1,58 +1,80 @@ { "name": "@metamask/message-manager", - "version": "7.3.5", + "version": "14.1.2", "description": "Stores and manages interactions with signing requests", "keywords": [ - "MetaMask", - "Ethereum" + "Ethereum", + "MetaMask" ], "homepage": "https://github.com/MetaMask/core/tree/main/packages/message-manager#readme", "bugs": { "url": "https://github.com/MetaMask/core/issues" }, + "license": "MIT", "repository": { "type": "git", "url": "https://github.com/MetaMask/core.git" }, - "license": "MIT", - "main": "./dist/index.js", - "types": "./dist/index.d.ts", "files": [ "dist/" ], + "sideEffects": false, + "main": "./dist/index.cjs", + "types": "./dist/index.d.cts", + "exports": { + ".": { + "import": { + "types": "./dist/index.d.mts", + "default": "./dist/index.mjs" + }, + "require": { + "types": "./dist/index.d.cts", + "default": "./dist/index.cjs" + } + }, + "./package.json": "./package.json" + }, + "publishConfig": { + "access": "public", + "registry": "https://registry.npmjs.org/" + }, "scripts": { + "build": "ts-bridge --project tsconfig.build.json --verbose --clean --no-references", + "build:all": "ts-bridge --project tsconfig.build.json --verbose --clean", "build:docs": "typedoc", + "changelog:update": "../../scripts/update-changelog.sh @metamask/message-manager", "changelog:validate": "../../scripts/validate-changelog.sh @metamask/message-manager", - "publish:preview": "yarn npm publish --tag preview", - "test": "jest", - "test:clean": "jest --clearCache", - "test:watch": "jest --watch" + "lint:tsconfigs": "tsx ../../scripts/lint-tsconfigs/lint-tsconfigs.mts", + "lint:tsconfigs:fix": "tsx ../../scripts/lint-tsconfigs/lint-tsconfigs.mts --fix", + "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": "^3.2.3", - "@metamask/controller-utils": "^5.0.2", - "@metamask/eth-sig-util": "^7.0.0", - "@metamask/utils": "^8.1.0", + "@metamask/base-controller": "^9.1.0", + "@metamask/controller-utils": "^12.3.0", + "@metamask/eth-sig-util": "^9.0.0", + "@metamask/messenger": "^2.0.0", + "@metamask/utils": "^11.11.0", "@types/uuid": "^8.3.0", - "ethereumjs-util": "^7.0.10", - "jsonschema": "^1.2.4", + "jsonschema": "^1.4.1", "uuid": "^8.3.2" }, "devDependencies": { - "@metamask/auto-changelog": "^3.1.0", - "@types/jest": "^27.4.1", + "@metamask/auto-changelog": "^6.1.0", + "@ts-bridge/cli": "^0.6.4", + "@types/jest": "^30.0.0", "deepmerge": "^4.2.2", - "jest": "^27.5.1", - "ts-jest": "^27.1.4", - "typedoc": "^0.24.8", + "jest": "^30.4.2", + "ts-jest": "^29.4.11", + "tsx": "^4.20.5", + "typedoc": "^0.25.13", "typedoc-plugin-missing-exports": "^2.0.0", - "typescript": "~4.8.4" + "typescript": "~5.3.3" }, "engines": { - "node": ">=16.0.0" - }, - "publishConfig": { - "access": "public", - "registry": "https://registry.npmjs.org/" + "node": "^18.18 || >=20" } } diff --git a/packages/message-manager/src/AbstractMessageManager.test.ts b/packages/message-manager/src/AbstractMessageManager.test.ts index da11beb542b..5ecae7c2b86 100644 --- a/packages/message-manager/src/AbstractMessageManager.test.ts +++ b/packages/message-manager/src/AbstractMessageManager.test.ts @@ -1,21 +1,72 @@ -import type { SecurityProviderRequest } from './AbstractMessageManager'; -import { AbstractMessageManager } from './AbstractMessageManager'; +import { deriveStateFromMetadata } from '@metamask/base-controller'; import type { - TypedMessage, - TypedMessageParams, - TypedMessageParamsMetamask, -} from './TypedMessageManager'; + ControllerGetStateAction, + ControllerStateChangeEvent, +} from '@metamask/base-controller'; +import { ApprovalType } from '@metamask/controller-utils'; +import type { Messenger } from '@metamask/messenger'; + +import { AbstractMessageManager } from './AbstractMessageManager.js'; +import type { + AbstractMessage, + AbstractMessageParams, + MessageManagerState, + MessageRequest, + SecurityProviderRequest, +} from './AbstractMessageManager.js'; + +type ConcreteMessage = AbstractMessage & { + messageParams: ConcreteMessageParams; +}; + +type ConcreteMessageParams = AbstractMessageParams & { + test: number; +}; + +type ConcreteMessageParamsMetamask = ConcreteMessageParams & { + metamaskId?: string; +}; + +type ConcreteMessageManagerActions = ControllerGetStateAction< + 'TestManager', + MessageManagerState +>; +type ConcreteMessageManagerEvents = ControllerStateChangeEvent< + 'TestManager', + MessageManagerState +>; +type ConcreteMessageManagerMessenger = Messenger< + 'TestManager', + ConcreteMessageManagerActions, + ConcreteMessageManagerEvents +>; class AbstractTestManager extends AbstractMessageManager< - TypedMessage, - TypedMessageParams, - TypedMessageParamsMetamask + 'TestManager', + ConcreteMessage, + ConcreteMessageParams, + ConcreteMessageParamsMetamask, + ConcreteMessageManagerMessenger > { + addRequestToMessageParams( + messageParams: MessageParams, + req?: MessageRequest, + ) { + return super.addRequestToMessageParams(messageParams, req); + } + + createUnapprovedMessage( + messageParams: MessageParams, + type: ApprovalType, + req?: MessageRequest, + ) { + return super.createUnapprovedMessage(messageParams, type, req); + } + prepMessageForSigning( - messageParams: TypedMessageParamsMetamask, - ): Promise { + messageParams: ConcreteMessageParamsMetamask, + ): Promise { delete messageParams.metamaskId; - delete messageParams.version; return Promise.resolve(messageParams); } @@ -23,53 +74,60 @@ class AbstractTestManager extends AbstractMessageManager< return super.setMessageStatus(messageId, status); } - async addUnapprovedMessage(_messageParams: TypedMessageParamsMetamask) { + async addUnapprovedMessage(_messageParams: ConcreteMessageParamsMetamask) { return Promise.resolve('mocked'); } } -const typedMessage = [ - { - name: 'Message', - type: 'string', - value: 'Hi, Alice!', - }, - { - name: 'A number', - type: 'uint32', - value: '1337', - }, -]; + +const MOCK_MESSENGER = { + clearEventSubscriptions: jest.fn(), + publish: jest.fn(), + registerActionHandler: jest.fn(), + registerInitialEventPayload: jest.fn(), +} as unknown as Messenger<'TestManager'>; + +const MOCK_INITIAL_OPTIONS = { + additionalFinishStatuses: undefined, + messenger: MOCK_MESSENGER, + name: 'TestManager' as const, + securityProviderRequest: undefined, +}; + const messageId = '1'; const messageId2 = '2'; const from = '0x0123'; const messageTime = Date.now(); const messageStatus = 'unapproved'; const messageType = 'eth_signTypedData'; -const messageData = typedMessage; +const testData = 123; +const testData2 = 456; const rawSigMock = '0xsignaturemocked'; const messageIdMock = 'message-id-mocked'; const fromMock = '0xc38bf1ad06ef69f0c04e29dbeb4152b4175f0a8d'; +const mockSecurityProviderResponse = { flagAsDangerous: 2 }; +const mockRequest = { + origin: 'example.com', + id: 123, + securityAlertResponse: mockSecurityProviderResponse, +}; +const mockMessageParams = { from, test: testData }; + describe('AbstractTestManager', () => { it('should set default state', () => { - const controller = new AbstractTestManager(); + const controller = new AbstractTestManager(MOCK_INITIAL_OPTIONS); expect(controller.state).toStrictEqual({ unapprovedMessages: {}, unapprovedMessagesCount: 0, }); }); - it('should set default config', () => { - const controller = new AbstractTestManager(); - expect(controller.config).toStrictEqual({}); - }); - it('should add a valid message', async () => { - const controller = new AbstractTestManager(); + const controller = new AbstractTestManager(MOCK_INITIAL_OPTIONS); await controller.addMessage({ id: messageId, messageParams: { - data: typedMessage, + test: testData, from, }, status: messageStatus, @@ -82,18 +140,18 @@ describe('AbstractTestManager', () => { } expect(message.id).toBe(messageId); expect(message.messageParams.from).toBe(from); - expect(message.messageParams.data).toBe(messageData); + expect(message.messageParams.test).toBe(testData); expect(message.time).toBe(messageTime); expect(message.status).toBe(messageStatus); expect(message.type).toBe(messageType); }); it('should get all messages', async () => { - const controller = new AbstractTestManager(); + const controller = new AbstractTestManager(MOCK_INITIAL_OPTIONS); const message = { id: messageId, messageParams: { - data: typedMessage, + test: testData, from, }, status: messageStatus, @@ -103,7 +161,7 @@ describe('AbstractTestManager', () => { const message2 = { id: messageId2, messageParams: { - data: typedMessage, + test: testData, from, }, status: messageStatus, @@ -122,15 +180,14 @@ describe('AbstractTestManager', () => { const securityProviderRequestMock: SecurityProviderRequest = jest .fn() .mockResolvedValue(securityProviderResponseMock); - const controller = new AbstractTestManager( - undefined, - undefined, - securityProviderRequestMock, - ); + const controller = new AbstractTestManager({ + ...MOCK_INITIAL_OPTIONS, + securityProviderRequest: securityProviderRequestMock, + }); await controller.addMessage({ id: messageId, messageParams: { - data: typedMessage, + test: testData, from, }, status: messageStatus, @@ -144,7 +201,7 @@ describe('AbstractTestManager', () => { } expect(message.id).toBe(messageId); expect(message.messageParams.from).toBe(from); - expect(message.messageParams.data).toBe(messageData); + expect(message.messageParams.test).toBe(testData); expect(message.time).toBe(messageTime); expect(message.status).toBe(messageStatus); expect(message.type).toBe(messageType); @@ -154,11 +211,11 @@ describe('AbstractTestManager', () => { }); it('should reject a message', async () => { - const controller = new AbstractTestManager(); + const controller = new AbstractTestManager(MOCK_INITIAL_OPTIONS); await controller.addMessage({ id: messageId, messageParams: { - data: typedMessage, + test: testData, from, }, status: messageStatus, @@ -174,11 +231,11 @@ describe('AbstractTestManager', () => { }); it('should sign a message', async () => { - const controller = new AbstractTestManager(); + const controller = new AbstractTestManager(MOCK_INITIAL_OPTIONS); await controller.addMessage({ id: messageId, messageParams: { - data: typedMessage, + test: testData, from, }, status: messageStatus, @@ -195,16 +252,14 @@ describe('AbstractTestManager', () => { }); it('sets message to one of the allowed statuses', async () => { - const controller = new AbstractTestManager( - undefined, - undefined, - undefined, - ['test-status'], - ); + const controller = new AbstractTestManager({ + ...MOCK_INITIAL_OPTIONS, + additionalFinishStatuses: ['test-status'], + }); await controller.addMessage({ id: messageId, messageParams: { - data: typedMessage, + test: testData, from, }, status: messageStatus, @@ -220,16 +275,14 @@ describe('AbstractTestManager', () => { }); it('should set a status to inProgress', async () => { - const controller = new AbstractTestManager( - undefined, - undefined, - undefined, - ['test-status'], - ); + const controller = new AbstractTestManager({ + ...MOCK_INITIAL_OPTIONS, + additionalFinishStatuses: ['test-status'], + }); await controller.addMessage({ id: messageId, messageParams: { - data: typedMessage, + test: testData, from, }, status: messageStatus, @@ -245,45 +298,21 @@ describe('AbstractTestManager', () => { }); it('should get correct unapproved messages', async () => { - const firstMessageData = [ - { - name: 'Message', - type: 'string', - value: 'Hi, Alice!', - }, - { - name: 'A number', - type: 'uint32', - value: '1337', - }, - ]; - const secondMessageData = [ - { - name: 'Message', - type: 'string', - value: 'Hi, Alice!', - }, - { - name: 'A number', - type: 'uint32', - value: '1337', - }, - ]; const firstMessage = { id: '1', - messageParams: { from: '0x1', data: firstMessageData }, + messageParams: { from: '0x1', test: testData }, status: 'unapproved', time: 123, type: 'eth_signTypedData', }; const secondMessage = { id: '2', - messageParams: { from: '0x1', data: secondMessageData }, + messageParams: { from: '0x1', test: testData2 }, status: 'unapproved', time: 123, type: 'eth_signTypedData', }; - const controller = new AbstractTestManager(); + const controller = new AbstractTestManager(MOCK_INITIAL_OPTIONS); await controller.addMessage(firstMessage); await controller.addMessage(secondMessage); expect(controller.getUnapprovedMessagesCount()).toBe(2); @@ -293,10 +322,9 @@ describe('AbstractTestManager', () => { }); }); - it('should approve typed message', async () => { - const controller = new AbstractTestManager(); - const firstMessage = { from: '0xfoO', data: typedMessage }; - const version = 'V1'; + it('should approve message', async () => { + const controller = new AbstractTestManager(MOCK_INITIAL_OPTIONS); + const firstMessage = { from: '0xfoO', test: testData }; await controller.addMessage({ id: messageId, messageParams: firstMessage, @@ -307,7 +335,6 @@ describe('AbstractTestManager', () => { const messageParams = await controller.approveMessage({ ...firstMessage, metamaskId: messageId, - version, }); const message = controller.getMessage(messageId); expect(messageParams).toStrictEqual(firstMessage); @@ -317,12 +344,54 @@ describe('AbstractTestManager', () => { expect(message.status).toBe('approved'); }); + describe('addRequestToMessageParams', () => { + it('adds original request id and origin to messageParams', () => { + const controller = new AbstractTestManager(MOCK_INITIAL_OPTIONS); + + const result = controller.addRequestToMessageParams( + mockMessageParams, + mockRequest, + ); + + expect(result).toStrictEqual({ + ...mockMessageParams, + origin: mockRequest.origin, + requestId: mockRequest.id, + }); + }); + }); + + describe('createUnapprovedMessage', () => { + it('creates a Message object with an unapproved status', () => { + const controller = new AbstractTestManager(MOCK_INITIAL_OPTIONS); + + const result = controller.createUnapprovedMessage( + mockMessageParams, + ApprovalType.PersonalSign, + mockRequest, + ); + + expect(result.messageParams).toBe(mockMessageParams); + expect(result.securityAlertResponse).toBe( + mockRequest.securityAlertResponse, + ); + expect(result.status).toBe('unapproved'); + expect(result.type).toBe(ApprovalType.PersonalSign); + expect(typeof result.time).toBe('number'); + expect(typeof result.id).toBe('string'); + }); + }); + describe('setMessageStatus', () => { - it('should set the given message status', async () => { - const controller = new AbstractTestManager(); + it('updates the status of a message', async () => { + jest.mock('events', () => ({ + emit: jest.fn(), + })); + + const controller = new AbstractTestManager(MOCK_INITIAL_OPTIONS); await controller.addMessage({ id: messageId, - messageParams: { from: '0x1234', data: 'test' }, + messageParams: { ...mockMessageParams }, status: 'status', time: 10, type: 'type', @@ -331,15 +400,16 @@ describe('AbstractTestManager', () => { expect(messageBefore?.status).toBe('status'); controller.setMessageStatus(messageId, 'newstatus'); + const messageAfter = controller.getMessage(messageId); expect(messageAfter?.status).toBe('newstatus'); }); - it('should throw an error if message is not found', () => { - const controller = new AbstractTestManager(); + it('throws an error if the message is not found', async () => { + const controller = new AbstractTestManager(MOCK_INITIAL_OPTIONS); expect(() => controller.setMessageStatus(messageId, 'newstatus')).toThrow( - 'AbstractMessageManager: Message not found for id: 1.', + 'TestManager: Message not found for id: 1.', ); }); }); @@ -350,10 +420,10 @@ describe('AbstractTestManager', () => { emit: jest.fn(), })); - const controller = new AbstractTestManager(); + const controller = new AbstractTestManager(MOCK_INITIAL_OPTIONS); await controller.addMessage({ id: messageId, - messageParams: { from: '0x1234', data: 'test' }, + messageParams: { ...mockMessageParams }, status: 'status', time: 10, type: 'type', @@ -364,17 +434,16 @@ describe('AbstractTestManager', () => { controller.setMessageStatusAndResult(messageId, 'newRawSig', 'newstatus'); const messageAfter = controller.getMessage(messageId); - // expect(controller.hub.emit).toHaveBeenNthCalledWith(1, 'updateBadge'); expect(messageAfter?.status).toBe('newstatus'); }); }); describe('setMetadata', () => { it('should set the given message metadata', async () => { - const controller = new AbstractTestManager(); + const controller = new AbstractTestManager(MOCK_INITIAL_OPTIONS); await controller.addMessage({ id: messageId, - messageParams: { from: '0x1234', data: 'test' }, + messageParams: { ...mockMessageParams }, status: 'status', time: 10, type: 'type', @@ -389,17 +458,17 @@ describe('AbstractTestManager', () => { }); it('should throw an error if message is not found', () => { - const controller = new AbstractTestManager(); + const controller = new AbstractTestManager(MOCK_INITIAL_OPTIONS); expect(() => controller.setMetadata(messageId, { foo: 'bar' })).toThrow( - 'AbstractMessageManager: Message not found for id: 1.', + 'TestManager: Message not found for id: 1.', ); }); }); describe('waitForFinishStatus', () => { it('signs the message when status is "signed"', async () => { - const controller = new AbstractTestManager(); + const controller = new AbstractTestManager(MOCK_INITIAL_OPTIONS); const promise = controller.waitForFinishStatus( { from: fromMock, @@ -409,7 +478,7 @@ describe('AbstractTestManager', () => { ); setTimeout(() => { - controller.hub.emit(`${messageIdMock}:finished`, { + controller.internalEvents.emit(`${messageIdMock}:finished`, { status: 'signed', rawSig: rawSigMock, }); @@ -419,7 +488,7 @@ describe('AbstractTestManager', () => { }); it('rejects with an error when status is "rejected"', async () => { - const controller = new AbstractTestManager(); + const controller = new AbstractTestManager(MOCK_INITIAL_OPTIONS); const promise = controller.waitForFinishStatus( { from: fromMock, @@ -429,7 +498,7 @@ describe('AbstractTestManager', () => { ); setTimeout(() => { - controller.hub.emit(`${messageIdMock}:finished`, { + controller.internalEvents.emit(`${messageIdMock}:finished`, { status: 'rejected', }); }, 100); @@ -440,7 +509,7 @@ describe('AbstractTestManager', () => { }); it('rejects with an error when finishes with unknown status', async () => { - const controller = new AbstractTestManager(); + const controller = new AbstractTestManager(MOCK_INITIAL_OPTIONS); const promise = controller.waitForFinishStatus( { from: fromMock, @@ -450,7 +519,7 @@ describe('AbstractTestManager', () => { ); setTimeout(() => { - controller.hub.emit(`${messageIdMock}:finished`, { + controller.internalEvents.emit(`${messageIdMock}:finished`, { status: 'unknown', }); }, 100); @@ -465,7 +534,7 @@ describe('AbstractTestManager', () => { }); it('rejects with an error when finishes with errored status', async () => { - const controller = new AbstractTestManager(); + const controller = new AbstractTestManager(MOCK_INITIAL_OPTIONS); const promise = controller.waitForFinishStatus( { from: fromMock, @@ -475,7 +544,7 @@ describe('AbstractTestManager', () => { ); setTimeout(() => { - controller.hub.emit(`${messageIdMock}:finished`, { + controller.internalEvents.emit(`${messageIdMock}:finished`, { status: 'errored', error: 'error message', }); @@ -486,4 +555,86 @@ describe('AbstractTestManager', () => { ); }); }); + + describe('clearUnapprovedMessages', () => { + it('clears the unapproved messages', () => { + const controller = new AbstractTestManager({ + ...MOCK_INITIAL_OPTIONS, + state: { + unapprovedMessages: { + '1': { + id: '1', + messageParams: { from: '0x1', test: 1 }, + status: 'unapproved', + time: 10, + type: 'type', + }, + }, + unapprovedMessagesCount: 1, + }, + }); + controller.clearUnapprovedMessages(); + expect(controller.getUnapprovedMessagesCount()).toBe(0); + }); + }); + + describe('metadata', () => { + it('includes expected state in debug snapshots', () => { + const controller = new AbstractTestManager(MOCK_INITIAL_OPTIONS); + + expect( + deriveStateFromMetadata( + controller.state, + controller.metadata, + 'includeInDebugSnapshot', + ), + ).toMatchInlineSnapshot(`{}`); + }); + + it('includes expected state in state logs', () => { + const controller = new AbstractTestManager(MOCK_INITIAL_OPTIONS); + + expect( + deriveStateFromMetadata( + controller.state, + controller.metadata, + 'includeInStateLogs', + ), + ).toMatchInlineSnapshot(` + { + "unapprovedMessages": {}, + "unapprovedMessagesCount": 0, + } + `); + }); + + it('persists expected state', () => { + const controller = new AbstractTestManager(MOCK_INITIAL_OPTIONS); + + expect( + deriveStateFromMetadata( + controller.state, + controller.metadata, + 'persist', + ), + ).toMatchInlineSnapshot(`{}`); + }); + + it('exposes expected state to UI', () => { + const controller = new AbstractTestManager(MOCK_INITIAL_OPTIONS); + + expect( + deriveStateFromMetadata( + controller.state, + controller.metadata, + 'usedInUi', + ), + ).toMatchInlineSnapshot(` + { + "unapprovedMessages": {}, + "unapprovedMessagesCount": 0, + } + `); + }); + }); }); diff --git a/packages/message-manager/src/AbstractMessageManager.ts b/packages/message-manager/src/AbstractMessageManager.ts index f0e45d92e4c..eda638e379e 100644 --- a/packages/message-manager/src/AbstractMessageManager.ts +++ b/packages/message-manager/src/AbstractMessageManager.ts @@ -1,23 +1,62 @@ -import type { BaseConfig, BaseState } from '@metamask/base-controller'; import { BaseController } from '@metamask/base-controller'; -import type { Hex, Json } from '@metamask/utils'; +import type { + ControllerStateChangeEvent, + ControllerGetStateAction, +} from '@metamask/base-controller'; +import type { ApprovalType } from '@metamask/controller-utils'; +import type { + Messenger, + EventConstraint, + ActionConstraint, +} from '@metamask/messenger'; +import type { Json } from '@metamask/utils'; +// This package purposefully relies on Node's EventEmitter module. +// eslint-disable-next-line import-x/no-nodejs-modules import { EventEmitter } from 'events'; +import type { Draft } from 'immer'; +import { v1 as random } from 'uuid'; + +const stateMetadata = { + unapprovedMessages: { + includeInStateLogs: true, + persist: false, + includeInDebugSnapshot: false, + usedInUi: true, + }, + unapprovedMessagesCount: { + includeInStateLogs: true, + persist: false, + includeInDebugSnapshot: false, + usedInUi: true, + }, +}; + +const getDefaultState = () => ({ + unapprovedMessages: {}, + unapprovedMessagesCount: 0, +}); /** - * @type OriginalRequest - * - * Represents the original request object for adding a message. - * @property origin? - Is it is specified, represents the origin + * Represents the request adding a message. */ -export interface OriginalRequest { +export type MessageRequest = { + id?: string | number; origin?: string; securityAlertResponse?: Record; -} +}; + +/** + * Represents the request adding a message. + * + * @deprecated Please use `MessageRequest` instead. + */ +export type OriginalRequest = MessageRequest; /** - * @type Message + * @type AbstractMessage * * Represents and contains data about a signing type signature request. + * * @property id - An id to track and identify the message object * @property type - The json-prc signing method for which a signature request has been made. * A 'Message' which always has a signing type @@ -25,7 +64,7 @@ export interface OriginalRequest { * @property securityProviderResponse - Response from a security provider, whether it is malicious or not * @property metadata - Additional data for the message, for example external identifiers */ -export interface AbstractMessage { +export type AbstractMessage = { id: string; time: number; status: string; @@ -35,47 +74,55 @@ export interface AbstractMessage { securityAlertResponse?: Record; metadata?: Json; error?: string; -} +}; /** - * @type MessageParams + * @type AbstractMessageParams * * Represents the parameters to pass to the signing method once the signature request is approved. * @property from - Address from which the message is processed * @property origin? - Added for request origin identification + * @property requestId? - Original request id * @property deferSetAsSigned? - Whether to defer setting the message as signed immediately after the keyring is told to sign it */ -export interface AbstractMessageParams { +export type AbstractMessageParams = { from: string; origin?: string; + requestId?: string | number; deferSetAsSigned?: boolean; -} +}; /** * @type MessageParamsMetamask * * Represents the parameters to pass to the signing method once the signature request is approved * plus data added by MetaMask. + * * @property metamaskId - Added for tracking and identification within MetaMask * @property from - Address from which the message is processed * @property origin? - Added for request origin identification */ -export interface AbstractMessageParamsMetamask extends AbstractMessageParams { +export type AbstractMessageParamsMetamask = AbstractMessageParams & { metamaskId?: string; -} +}; /** * @type MessageManagerState * * Message Manager state + * * @property unapprovedMessages - A collection of all Messages in the 'unapproved' state * @property unapprovedMessagesCount - The count of all Messages in this.unapprovedMessages */ -export interface MessageManagerState - extends BaseState { - unapprovedMessages: { [key: string]: M }; +export type MessageManagerState = { + unapprovedMessages: Record; unapprovedMessagesCount: number; -} +}; + +export type UpdateBadgeEvent = { + type: `${Namespace}:updateBadge`; + payload: []; +}; /** * A function for verifying a message, whether it is malicious or not @@ -85,35 +132,145 @@ export type SecurityProviderRequest = ( messageType: string, ) => Promise; -type getCurrentChainId = () => Hex; +/** + * AbstractMessageManager constructor options. + * + * @property additionalFinishStatuses - Optional list of statuses that are accepted to emit a finished event. + * @property messenger - Controller messaging system. + * @property name - The name of the manager. + * @property securityProviderRequest - A function for verifying a message, whether it is malicious or not. + * @property state - Initial state to set on this controller. + */ +export type AbstractMessageManagerOptions< + Name extends string, + Message extends AbstractMessage, + MessageManagerMessenger extends Messenger< + Name, + | ControllerGetStateAction> + | ActionConstraint, + | ControllerStateChangeEvent> + | UpdateBadgeEvent + | EventConstraint + >, +> = { + additionalFinishStatuses?: string[]; + messenger: MessageManagerMessenger; + name: Name; + securityProviderRequest?: SecurityProviderRequest; + state?: MessageManagerState; +}; /** * Controller in charge of managing - storing, adding, removing, updating - Messages. */ export abstract class AbstractMessageManager< - M extends AbstractMessage, - P extends AbstractMessageParams, - PM extends AbstractMessageParamsMetamask, -> extends BaseController> { - protected messages: M[]; - - protected getCurrentChainId: getCurrentChainId | undefined; + Name extends string, + Message extends AbstractMessage, + Params extends AbstractMessageParams, + ParamsMetamask extends AbstractMessageParamsMetamask, + MessageManagerMessenger extends Messenger< + Name, + | ControllerGetStateAction> + | ActionConstraint, + | ControllerStateChangeEvent> + | UpdateBadgeEvent + | EventConstraint + >, +> extends BaseController< + Name, + MessageManagerState, + MessageManagerMessenger +> { + protected messages: Message[]; private readonly securityProviderRequest: SecurityProviderRequest | undefined; private readonly additionalFinishStatuses: string[]; + internalEvents = new EventEmitter(); + + constructor({ + additionalFinishStatuses, + messenger, + name, + securityProviderRequest, + state = {} as MessageManagerState, + }: AbstractMessageManagerOptions) { + super({ + messenger, + metadata: stateMetadata, + name, + state: { + ...getDefaultState(), + ...state, + }, + }); + this.messages = []; + this.securityProviderRequest = securityProviderRequest; + this.additionalFinishStatuses = additionalFinishStatuses ?? []; + } + + /** + * Adds request props to the message params and returns a new messageParams object. + * + * @param messageParams - The messageParams to add the request props to. + * @param req - The original request object. + * @returns The messageParams with the request props added. + */ + protected addRequestToMessageParams< + MessageParams extends AbstractMessageParams, + >(messageParams: MessageParams, req?: MessageRequest) { + const updatedMessageParams = { + ...messageParams, + }; + + if (req) { + updatedMessageParams.requestId = req.id; + updatedMessageParams.origin = req.origin; + } + + return updatedMessageParams; + } + + /** + * Creates a new Message with a random id and an 'unapproved' status. + * + * @param messageParams - The messageParams to add the request props to. + * @param type - The approval type of the message. + * @param req - The original request object. + * @returns The new unapproved message for a specified type. + */ + protected createUnapprovedMessage< + MessageParams extends AbstractMessageParams, + >(messageParams: MessageParams, type: ApprovalType, req?: MessageRequest) { + const messageId = random(); + + return { + id: messageId, + messageParams, + securityAlertResponse: req?.securityAlertResponse, + status: 'unapproved', + time: Date.now(), + type, + }; + } + /** * Saves the unapproved messages, and their count to state. * * @param emitUpdateBadge - Whether to emit the updateBadge event. */ protected saveMessageList(emitUpdateBadge = true) { - const unapprovedMessages = this.getUnapprovedMessages(); - const unapprovedMessagesCount = this.getUnapprovedMessagesCount(); - this.update({ unapprovedMessages, unapprovedMessagesCount }); + this.update((state) => { + state.unapprovedMessages = + this.getUnapprovedMessages() as unknown as Record< + string, + Draft + >; + state.unapprovedMessagesCount = this.getUnapprovedMessagesCount(); + }); if (emitUpdateBadge) { - this.hub.emit('updateBadge'); + this.messenger.publish(`${this.name}:updateBadge` as const); } } @@ -126,18 +283,23 @@ export abstract class AbstractMessageManager< protected setMessageStatus(messageId: string, status: string) { const message = this.getMessage(messageId); if (!message) { - throw new Error(`${this.name}: Message not found for id: ${messageId}.`); + throw new Error( + `${this.name as string}: Message not found for id: ${messageId}.`, + ); } - message.status = status; - this.updateMessage(message); - this.hub.emit(`${messageId}:${status}`, message); + const updatedMessage = { + ...message, + status, + }; + this.updateMessage(updatedMessage); + this.internalEvents.emit(`${messageId}:${status}`, updatedMessage); if ( status === 'rejected' || status === 'signed' || status === 'errored' || this.additionalFinishStatuses.includes(status) ) { - this.hub.emit(`${messageId}:finished`, message); + this.internalEvents.emit(`${messageId}:finished`, updatedMessage); } } @@ -148,7 +310,7 @@ export abstract class AbstractMessageManager< * @param message - A Message that will replace an existing Message (with the id) in this.messages. * @param emitUpdateBadge - Whether to emit the updateBadge event. */ - protected updateMessage(message: M, emitUpdateBadge = true) { + protected updateMessage(message: Message, emitUpdateBadge = true) { const index = this.messages.findIndex((msg) => message.id === msg.id); /* istanbul ignore next */ if (index !== -1) { @@ -163,7 +325,7 @@ export abstract class AbstractMessageManager< * @param message - The message to verify. * @returns A promise that resolves to a secured message with additional security provider response data. */ - private async securityCheck(message: M): Promise { + private async securityCheck(message: Message): Promise { if (this.securityProviderRequest) { const securityProviderResponse = await this.securityProviderRequest( message, @@ -177,42 +339,11 @@ export abstract class AbstractMessageManager< return message; } - /** - * EventEmitter instance used to listen to specific message events - */ - hub = new EventEmitter(); - - /** - * Name of this controller used during composition - */ - override name = 'AbstractMessageManager'; - - /** - * Creates an AbstractMessageManager instance. - * - * @param config - Initial options used to configure this controller. - * @param state - Initial state to set on this controller. - * @param securityProviderRequest - A function for verifying a message, whether it is malicious or not. - * @param additionalFinishStatuses - Optional list of statuses that are accepted to emit a finished event. - * @param getCurrentChainId - Optional function to get the current chainId. - */ - constructor( - config?: Partial, - state?: Partial>, - securityProviderRequest?: SecurityProviderRequest, - additionalFinishStatuses?: string[], - getCurrentChainId?: getCurrentChainId, - ) { - super(config, state); - this.defaultState = { - unapprovedMessages: {}, - unapprovedMessagesCount: 0, - }; - this.messages = []; - this.securityProviderRequest = securityProviderRequest; - this.additionalFinishStatuses = additionalFinishStatuses ?? []; - this.getCurrentChainId = getCurrentChainId; - this.initialize(); + clearUnapprovedMessages() { + this.update((state) => { + state.unapprovedMessages = {}; + state.unapprovedMessagesCount = 0; + }); } /** @@ -232,10 +363,10 @@ export abstract class AbstractMessageManager< getUnapprovedMessages() { return this.messages .filter((message) => message.status === 'unapproved') - .reduce((result: { [key: string]: M }, message: M) => { + .reduce((result: Record, message) => { result[message.id] = message; return result; - }, {}) as { [key: string]: M }; + }, {}); } /** @@ -244,7 +375,7 @@ export abstract class AbstractMessageManager< * * @param message - The Message to add to this.messages. */ - async addMessage(message: M) { + async addMessage(message: Message) { const securedMessage = await this.securityCheck(message); this.messages.push(securedMessage); this.saveMessageList(); @@ -278,7 +409,7 @@ export abstract class AbstractMessageManager< * plus data added by MetaMask. * @returns Promise resolving to the messageParams with the metamaskId property removed. */ - approveMessage(messageParams: PM): Promise

{ + approveMessage(messageParams: ParamsMetamask): Promise { // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore this.setMessageStatusApproved(messageParams.metamaskId); @@ -340,8 +471,13 @@ export abstract class AbstractMessageManager< if (!message) { return; } - message.rawSig = result; - this.updateMessage(message, false); + this.updateMessage( + { + ...message, + rawSig: result, + }, + false, + ); } /** @@ -350,14 +486,20 @@ export abstract class AbstractMessageManager< * @param messageId - The id of the Message to update * @param metadata - The data with which to replace the metadata property in the message */ - setMetadata(messageId: string, metadata: Json) { const message = this.getMessage(messageId); if (!message) { - throw new Error(`${this.name}: Message not found for id: ${messageId}.`); + throw new Error( + `${this.name as string}: Message not found for id: ${messageId}.`, + ); } - message.metadata = metadata; - this.updateMessage(message, false); + this.updateMessage( + { + ...message, + metadata, + }, + false, + ); } /** @@ -367,7 +509,9 @@ export abstract class AbstractMessageManager< * @param messageParams - The messageParams to modify * @returns Promise resolving to the messageParams with the metamaskId property removed */ - abstract prepMessageForSigning(messageParams: PM): Promise

; + abstract prepMessageForSigning( + messageParams: ParamsMetamask, + ): Promise; /** * Creates a new Message with an 'unapproved' status using the passed messageParams. @@ -380,8 +524,8 @@ export abstract class AbstractMessageManager< * @returns The id of the newly created message. */ abstract addUnapprovedMessage( - messageParams: PM, - request: OriginalRequest, + messageParams: ParamsMetamask, + request: MessageRequest, version?: string, ): Promise; @@ -407,30 +551,35 @@ export abstract class AbstractMessageManager< ): Promise { const { metamaskId: messageId, ...messageParams } = messageParamsWithId; return new Promise((resolve, reject) => { - this.hub.once(`${messageId}:finished`, (data: AbstractMessage) => { - switch (data.status) { - case 'signed': - return resolve(data.rawSig as string); - case 'rejected': - return reject( - new Error( - `MetaMask ${messageName} Signature: User denied message signature.`, - ), - ); - case 'errored': - return reject( - new Error(`MetaMask ${messageName} Signature: ${data.error}`), - ); - default: - return reject( - new Error( - `MetaMask ${messageName} Signature: Unknown problem: ${JSON.stringify( - messageParams, - )}`, - ), - ); - } - }); + this.internalEvents.once( + `${messageId as string}:finished`, + (data: AbstractMessage) => { + switch (data.status) { + case 'signed': + return resolve(data.rawSig as string); + case 'rejected': + return reject( + new Error( + `MetaMask ${messageName} Signature: User denied message signature.`, + ), + ); + case 'errored': + return reject( + new Error( + `MetaMask ${messageName} Signature: ${data.error as string}`, + ), + ); + default: + return reject( + new Error( + `MetaMask ${messageName} Signature: Unknown problem: ${JSON.stringify( + messageParams, + )}`, + ), + ); + } + }, + ); }); } } diff --git a/packages/message-manager/src/DecryptMessageManager.test.ts b/packages/message-manager/src/DecryptMessageManager.test.ts index 6d68212912c..df9e5e47368 100644 --- a/packages/message-manager/src/DecryptMessageManager.test.ts +++ b/packages/message-manager/src/DecryptMessageManager.test.ts @@ -1,4 +1,18 @@ -import { DecryptMessageManager } from './DecryptMessageManager'; +import { DecryptMessageManager } from './DecryptMessageManager.js'; +import type { DecryptMessageManagerMessenger } from './DecryptMessageManager.js'; + +const mockMessenger = { + registerActionHandler: jest.fn(), + registerInitialEventPayload: jest.fn(), + publish: jest.fn(), + clearEventSubscriptions: jest.fn(), +} as unknown as DecryptMessageManagerMessenger; + +const mockInitialOptions = { + additionalFinishStatuses: undefined, + messenger: mockMessenger, + securityProviderRequest: undefined, +}; describe('DecryptMessageManager', () => { let controller: DecryptMessageManager; @@ -9,7 +23,7 @@ describe('DecryptMessageManager', () => { const dataMock = '0x12345'; beforeEach(() => { - controller = new DecryptMessageManager(); + controller = new DecryptMessageManager(mockInitialOptions); }); it('sets default state', () => { @@ -19,10 +33,6 @@ describe('DecryptMessageManager', () => { }); }); - it('sets default config', () => { - expect(controller.config).toStrictEqual({}); - }); - it('adds a valid message', async () => { const messageData = '0x123'; const messageTime = Date.now(); @@ -52,9 +62,7 @@ describe('DecryptMessageManager', () => { describe('addUnapprovedMessageAsync', () => { beforeEach(() => { - controller = new DecryptMessageManager(undefined, undefined, undefined, [ - 'decrypted', - ]); + controller = new DecryptMessageManager(mockInitialOptions); jest .spyOn(controller, 'addUnapprovedMessage') @@ -72,7 +80,7 @@ describe('DecryptMessageManager', () => { data: dataMock, }); setTimeout(() => { - controller.hub.emit(`${messageIdMock}:finished`, { + controller.internalEvents.emit(`${messageIdMock}:finished`, { status: 'decrypted', rawSig: rawSigMock, }); @@ -88,7 +96,7 @@ describe('DecryptMessageManager', () => { }); setTimeout(() => { - controller.hub.emit(`${messageIdMock}:finished`, { + controller.internalEvents.emit(`${messageIdMock}:finished`, { status: 'rejected', }); }, 100); @@ -105,7 +113,7 @@ describe('DecryptMessageManager', () => { }); setTimeout(() => { - controller.hub.emit(`${messageIdMock}:finished`, { + controller.internalEvents.emit(`${messageIdMock}:finished`, { status: 'errored', }); }, 100); @@ -122,7 +130,7 @@ describe('DecryptMessageManager', () => { }); setTimeout(() => { - controller.hub.emit(`${messageIdMock}:finished`, { + controller.internalEvents.emit(`${messageIdMock}:finished`, { status: 'unknown', }); }, 100); @@ -140,7 +148,7 @@ describe('DecryptMessageManager', () => { const messageStatus = 'unapproved'; const messageType = 'eth_decrypt'; const messageParams = { from: fromMock, data: dataMock }; - const originalRequest = { origin: 'origin' }; + const originalRequest = { id: 111, origin: 'origin' }; const messageId = await controller.addUnapprovedMessage( messageParams, originalRequest, @@ -151,6 +159,7 @@ describe('DecryptMessageManager', () => { throw new Error('"message" is falsy'); } expect(message.messageParams.from).toBe(messageParams.from); + expect(message.messageParams.requestId).toBe(originalRequest.id); expect(message.time).toBeDefined(); expect(message.status).toBe(messageStatus); expect(message.type).toBe(messageType); diff --git a/packages/message-manager/src/DecryptMessageManager.ts b/packages/message-manager/src/DecryptMessageManager.ts index 12006b164c3..0292ba1726c 100644 --- a/packages/message-manager/src/DecryptMessageManager.ts +++ b/packages/message-manager/src/DecryptMessageManager.ts @@ -1,27 +1,73 @@ -import { v1 as random } from 'uuid'; +import type { + ControllerGetStateAction, + ControllerStateChangeEvent, +} from '@metamask/base-controller'; +import { ApprovalType } from '@metamask/controller-utils'; +import type { Messenger } from '@metamask/messenger'; +import {} from '@metamask/messenger'; import type { AbstractMessage, AbstractMessageParams, AbstractMessageParamsMetamask, - OriginalRequest, -} from './AbstractMessageManager'; -import { AbstractMessageManager } from './AbstractMessageManager'; -import { normalizeMessageData, validateDecryptedMessageData } from './utils'; + MessageManagerState, + MessageRequest, + SecurityProviderRequest, +} from './AbstractMessageManager.js'; +import { AbstractMessageManager } from './AbstractMessageManager.js'; +import { normalizeMessageData, validateDecryptedMessageData } from './utils.js'; + +const managerName = 'DecryptMessageManager'; + +export type DecryptMessageManagerState = MessageManagerState; + +export type DecryptMessageManagerUnapprovedMessageAddedEvent = { + type: `${typeof managerName}:unapprovedMessage`; + payload: [AbstractMessageParamsMetamask]; +}; + +export type DecryptMessageManagerUpdateBadgeEvent = { + type: `${typeof managerName}:updateBadge`; + payload: []; +}; + +type DecryptMessageManagerActions = ControllerGetStateAction< + typeof managerName, + DecryptMessageManagerState +>; + +type DecryptMessageManagerEvents = + | ControllerStateChangeEvent + | DecryptMessageManagerUnapprovedMessageAddedEvent + | DecryptMessageManagerUpdateBadgeEvent; + +export type DecryptMessageManagerMessenger = Messenger< + typeof managerName, + DecryptMessageManagerActions, + DecryptMessageManagerEvents +>; + +type DecryptMessageManagerOptions = { + messenger: DecryptMessageManagerMessenger; + securityProviderRequest?: SecurityProviderRequest; + state?: MessageManagerState; + additionalFinishStatuses?: string[]; +}; /** * @type DecryptMessage * * Represents and contains data about a 'eth_decrypt' type signature request. * These are created when a signature for an eth_decrypt call is requested. + * * @property id - An id to track and identify the message object * @property messageParams - The parameters to pass to the eth_decrypt method once the request is approved * @property type - The json-prc signing method for which a signature request has been made. * A 'DecryptMessage' which always has a 'eth_decrypt' type */ -export interface DecryptMessage extends AbstractMessage { +export type DecryptMessage = AbstractMessage & { messageParams: DecryptMessageParams; -} +}; /** * @type DecryptMessageParams @@ -29,22 +75,25 @@ export interface DecryptMessage extends AbstractMessage { * Represents the parameters to pass to the eth_decrypt method once the request is approved. * @property data - A hex string conversion of the raw buffer data of the signature request */ -export interface DecryptMessageParams extends AbstractMessageParams { +export type DecryptMessageParams = AbstractMessageParams & { data: string; -} +}; /** * @type DecryptMessageParamsMetamask * * Represents the parameters to pass to the eth_decrypt method once the request is approved * plus data added by MetaMask. + * * @property metamaskId - Added for tracking and identification within MetaMask * @property data - A hex string conversion of the raw buffer data of the signature request * @property from - Address to sign this message from * @property origin? - Added for request origin identification */ -export interface DecryptMessageParamsMetamask - extends AbstractMessageParamsMetamask { +// This interface was created before this ESLint rule was added. +// Convert to a `type` in a future major version. +// eslint-disable-next-line @typescript-eslint/consistent-type-definitions +export interface DecryptMessageParamsMetamask extends AbstractMessageParamsMetamask { data: string; } @@ -52,14 +101,26 @@ export interface DecryptMessageParamsMetamask * Controller in charge of managing - storing, adding, removing, updating - DecryptMessages. */ export class DecryptMessageManager extends AbstractMessageManager< + typeof managerName, DecryptMessage, DecryptMessageParams, - DecryptMessageParamsMetamask + DecryptMessageParamsMetamask, + DecryptMessageManagerMessenger > { - /** - * Name of this controller used during composition - */ - override name = 'DecryptMessageManager'; + constructor({ + additionalFinishStatuses, + messenger, + securityProviderRequest, + state, + }: DecryptMessageManagerOptions) { + super({ + additionalFinishStatuses, + messenger, + name: managerName, + securityProviderRequest, + state, + }); + } /** * Creates a new Message with an 'unapproved' status using the passed messageParams. @@ -71,38 +132,41 @@ export class DecryptMessageManager extends AbstractMessageManager< */ async addUnapprovedMessageAsync( messageParams: DecryptMessageParams, - req?: OriginalRequest, + req?: MessageRequest, ): Promise { validateDecryptedMessageData(messageParams); const messageId = await this.addUnapprovedMessage(messageParams, req); return new Promise((resolve, reject) => { - this.hub.once(`${messageId}:finished`, (data: DecryptMessage) => { - switch (data.status) { - case 'decrypted': - return resolve(data.rawSig as string); - case 'rejected': - return reject( - new Error( - 'MetaMask DecryptMessage: User denied message decryption.', - ), - ); - case 'errored': - return reject( - new Error( - 'MetaMask DecryptMessage: This message cannot be decrypted.', - ), - ); - default: - return reject( - new Error( - `MetaMask DecryptMessage: Unknown problem: ${JSON.stringify( - messageParams, - )}`, - ), - ); - } - }); + this.internalEvents.once( + `${messageId}:finished`, + (data: DecryptMessage) => { + switch (data.status) { + case 'decrypted': + return resolve(data.rawSig as string); + case 'rejected': + return reject( + new Error( + 'MetaMask DecryptMessage: User denied message decryption.', + ), + ); + case 'errored': + return reject( + new Error( + 'MetaMask DecryptMessage: This message cannot be decrypted.', + ), + ); + default: + return reject( + new Error( + `MetaMask DecryptMessage: Unknown problem: ${JSON.stringify( + messageParams, + )}`, + ), + ); + } + }, + ); }); } @@ -118,24 +182,26 @@ export class DecryptMessageManager extends AbstractMessageManager< */ async addUnapprovedMessage( messageParams: DecryptMessageParams, - req?: OriginalRequest, + req?: MessageRequest, ) { - if (req) { - messageParams.origin = req.origin; - } - messageParams.data = normalizeMessageData(messageParams.data); - const messageId = random(); - const messageData: DecryptMessage = { - id: messageId, + const updatedMessageParams = this.addRequestToMessageParams( messageParams, - status: 'unapproved', - time: Date.now(), - type: 'eth_decrypt', - }; + req, + ) satisfies DecryptMessageParams; + messageParams.data = normalizeMessageData(messageParams.data); + + const messageData = this.createUnapprovedMessage( + updatedMessageParams, + ApprovalType.EthDecrypt, + req, + ) satisfies DecryptMessage; + + const messageId = messageData.id; + await this.addMessage(messageData); - this.hub.emit(`unapprovedMessage`, { - ...messageParams, - ...{ metamaskId: messageId }, + this.messenger.publish(`${managerName}:unapprovedMessage`, { + ...updatedMessageParams, + metamaskId: messageId, }); return messageId; } diff --git a/packages/message-manager/src/EncryptionPublicKeyManager.test.ts b/packages/message-manager/src/EncryptionPublicKeyManager.test.ts index 219dba663d7..740a9093564 100644 --- a/packages/message-manager/src/EncryptionPublicKeyManager.test.ts +++ b/packages/message-manager/src/EncryptionPublicKeyManager.test.ts @@ -1,4 +1,18 @@ -import { EncryptionPublicKeyManager } from './EncryptionPublicKeyManager'; +import { EncryptionPublicKeyManager } from './EncryptionPublicKeyManager.js'; +import type { EncryptionPublicKeyManagerMessenger } from './EncryptionPublicKeyManager.js'; + +const mockMessenger = { + registerActionHandler: jest.fn(), + registerInitialEventPayload: jest.fn(), + publish: jest.fn(), + clearEventSubscriptions: jest.fn(), +} as unknown as EncryptionPublicKeyManagerMessenger; + +const mockInitialOptions = { + additionalFinishStatuses: undefined, + messenger: mockMessenger, + securityProviderRequest: undefined, +}; describe('EncryptionPublicKeyManager', () => { let controller: EncryptionPublicKeyManager; @@ -8,7 +22,7 @@ describe('EncryptionPublicKeyManager', () => { const rawSigMock = '231124fe67213512='; beforeEach(() => { - controller = new EncryptionPublicKeyManager(); + controller = new EncryptionPublicKeyManager(mockInitialOptions); }); it('sets default state', () => { @@ -18,10 +32,6 @@ describe('EncryptionPublicKeyManager', () => { }); }); - it('sets default config', () => { - expect(controller.config).toStrictEqual({}); - }); - it('adds a valid message', async () => { const messageTime = Date.now(); const messageStatus = 'unapproved'; @@ -48,12 +58,7 @@ describe('EncryptionPublicKeyManager', () => { describe('addUnapprovedMessageAsync', () => { beforeEach(() => { - controller = new EncryptionPublicKeyManager( - undefined, - undefined, - undefined, - ['received'], - ); + controller = new EncryptionPublicKeyManager(mockInitialOptions); jest .spyOn(controller, 'addUnapprovedMessage') @@ -70,7 +75,7 @@ describe('EncryptionPublicKeyManager', () => { }); setTimeout(() => { - controller.hub.emit(`${messageIdMock}:finished`, { + controller.internalEvents.emit(`${messageIdMock}:finished`, { status: 'received', rawSig: rawSigMock, }); @@ -85,7 +90,7 @@ describe('EncryptionPublicKeyManager', () => { }); setTimeout(() => { - controller.hub.emit(`${messageIdMock}:finished`, { + controller.internalEvents.emit(`${messageIdMock}:finished`, { status: 'rejected', }); }, 100); @@ -101,7 +106,7 @@ describe('EncryptionPublicKeyManager', () => { }); setTimeout(() => { - controller.hub.emit(`${messageIdMock}:finished`, { + controller.internalEvents.emit(`${messageIdMock}:finished`, { status: 'unknown', }); }, 100); @@ -120,7 +125,7 @@ describe('EncryptionPublicKeyManager', () => { const messageParams = { from: fromMock, }; - const originalRequest = { origin: 'origin' }; + const originalRequest = { id: 111, origin: 'origin' }; const messageId = await controller.addUnapprovedMessage( messageParams, originalRequest, @@ -131,6 +136,7 @@ describe('EncryptionPublicKeyManager', () => { throw new Error('"message" is falsy'); } expect(message.messageParams.from).toBe(messageParams.from); + expect(message.messageParams.requestId).toBe(originalRequest.id); expect(message.time).toBeDefined(); expect(message.status).toBe(messageStatus); expect(message.type).toBe(messageType); diff --git a/packages/message-manager/src/EncryptionPublicKeyManager.ts b/packages/message-manager/src/EncryptionPublicKeyManager.ts index 3966c28777c..c3464096dfb 100644 --- a/packages/message-manager/src/EncryptionPublicKeyManager.ts +++ b/packages/message-manager/src/EncryptionPublicKeyManager.ts @@ -1,28 +1,77 @@ -import { v1 as random } from 'uuid'; +import type { + ControllerGetStateAction, + ControllerStateChangeEvent, +} from '@metamask/base-controller'; +import { ApprovalType } from '@metamask/controller-utils'; +import type { Messenger } from '@metamask/messenger'; import type { AbstractMessage, AbstractMessageParams, AbstractMessageParamsMetamask, - OriginalRequest, -} from './AbstractMessageManager'; -import { AbstractMessageManager } from './AbstractMessageManager'; -import { validateEncryptionPublicKeyMessageData } from './utils'; + MessageManagerState, + MessageRequest, + SecurityProviderRequest, +} from './AbstractMessageManager.js'; +import { AbstractMessageManager } from './AbstractMessageManager.js'; +import { validateEncryptionPublicKeyMessageData } from './utils.js'; + +const managerName = 'EncryptionPublicKeyManager'; + +export type EncryptionPublicKeyManagerState = + MessageManagerState; + +export type EncryptionPublicKeyManagerUnapprovedMessageAddedEvent = { + type: `${typeof managerName}:unapprovedMessage`; + payload: [AbstractMessageParamsMetamask]; +}; + +export type EncryptionPublicKeyManagerUpdateBadgeEvent = { + type: `${typeof managerName}:updateBadge`; + payload: []; +}; + +type EncryptionPublicKeyManagerActions = ControllerGetStateAction< + typeof managerName, + EncryptionPublicKeyManagerState +>; + +type EncryptionPublicKeyManagerEvents = + | ControllerStateChangeEvent< + typeof managerName, + EncryptionPublicKeyManagerState + > + | EncryptionPublicKeyManagerUnapprovedMessageAddedEvent + | EncryptionPublicKeyManagerUpdateBadgeEvent; + +export type EncryptionPublicKeyManagerMessenger = Messenger< + typeof managerName, + EncryptionPublicKeyManagerActions, + EncryptionPublicKeyManagerEvents +>; + +type EncryptionPublicKeyManagerOptions = { + messenger: EncryptionPublicKeyManagerMessenger; + securityProviderRequest?: SecurityProviderRequest; + state?: MessageManagerState; + additionalFinishStatuses?: string[]; +}; /** * @type EncryptionPublicKey * * Represents and contains data about a 'eth_getEncryptionPublicKey' type request. * These are created when an encryption public key is requested. + * * @property id - An id to track and identify the message object * @property messageParams - The parameters to pass to the eth_getEncryptionPublicKey method once the request is approved * @property type - The json-prc method for which an encryption public key request has been made. * A 'Message' which always has a 'eth_getEncryptionPublicKey' type * @property rawSig - Encryption public key */ -export interface EncryptionPublicKey extends AbstractMessage { +export type EncryptionPublicKey = AbstractMessage & { messageParams: EncryptionPublicKeyParams; -} +}; /** * @type EncryptionPublicKeyParams @@ -38,28 +87,41 @@ export type EncryptionPublicKeyParams = AbstractMessageParams; * * Represents the parameters to pass to the eth_getEncryptionPublicKey method once the request is approved * plus data added by MetaMask. + * * @property metamaskId - Added for tracking and identification within MetaMask * @property data - Encryption public key * @property from - Address from which to extract the encryption public key * @property origin? - Added for request origin identification */ -export interface EncryptionPublicKeyParamsMetamask - extends AbstractMessageParamsMetamask { - data: string; -} +export type EncryptionPublicKeyParamsMetamask = + AbstractMessageParamsMetamask & { + data: string; + }; /** * Controller in charge of managing - storing, adding, removing, updating - Messages. */ export class EncryptionPublicKeyManager extends AbstractMessageManager< + typeof managerName, EncryptionPublicKey, EncryptionPublicKeyParams, - EncryptionPublicKeyParamsMetamask + EncryptionPublicKeyParamsMetamask, + EncryptionPublicKeyManagerMessenger > { - /** - * Name of this controller used during composition - */ - override name = 'EncryptionPublicKeyManager'; + constructor({ + additionalFinishStatuses, + messenger, + securityProviderRequest, + state, + }: EncryptionPublicKeyManagerOptions) { + super({ + additionalFinishStatuses, + messenger, + name: managerName, + securityProviderRequest, + state, + }); + } /** * Creates a new Message with an 'unapproved' status using the passed messageParams. @@ -71,32 +133,35 @@ export class EncryptionPublicKeyManager extends AbstractMessageManager< */ async addUnapprovedMessageAsync( messageParams: EncryptionPublicKeyParams, - req?: OriginalRequest, + req?: MessageRequest, ): Promise { validateEncryptionPublicKeyMessageData(messageParams); const messageId = await this.addUnapprovedMessage(messageParams, req); return new Promise((resolve, reject) => { - this.hub.once(`${messageId}:finished`, (data: EncryptionPublicKey) => { - switch (data.status) { - case 'received': - return resolve(data.rawSig as string); - case 'rejected': - return reject( - new Error( - 'MetaMask EncryptionPublicKey: User denied message EncryptionPublicKey.', - ), - ); - default: - return reject( - new Error( - `MetaMask EncryptionPublicKey: Unknown problem: ${JSON.stringify( - messageParams, - )}`, - ), - ); - } - }); + this.internalEvents.once( + `${messageId}:finished`, + (data: EncryptionPublicKey) => { + switch (data.status) { + case 'received': + return resolve(data.rawSig as string); + case 'rejected': + return reject( + new Error( + 'MetaMask EncryptionPublicKey: User denied message EncryptionPublicKey.', + ), + ); + default: + return reject( + new Error( + `MetaMask EncryptionPublicKey: Unknown problem: ${JSON.stringify( + messageParams, + )}`, + ), + ); + } + }, + ); }); } @@ -112,23 +177,25 @@ export class EncryptionPublicKeyManager extends AbstractMessageManager< */ async addUnapprovedMessage( messageParams: EncryptionPublicKeyParams, - req?: OriginalRequest, + req?: MessageRequest, ): Promise { - if (req) { - messageParams.origin = req.origin; - } - const messageId = random(); - const messageData: EncryptionPublicKey = { - id: messageId, + const updatedMessageParams = this.addRequestToMessageParams( messageParams, - status: 'unapproved', - time: Date.now(), - type: 'eth_getEncryptionPublicKey', - }; + req, + ) satisfies EncryptionPublicKeyParams; + + const messageData = this.createUnapprovedMessage( + updatedMessageParams, + ApprovalType.EthGetEncryptionPublicKey, + req, + ) satisfies EncryptionPublicKey; + + const messageId = messageData.id; + await this.addMessage(messageData); - this.hub.emit(`unapprovedMessage`, { - ...messageParams, - ...{ metamaskId: messageId }, + this.messenger.publish(`${this.name}:unapprovedMessage` as const, { + ...updatedMessageParams, + metamaskId: messageId, }); return messageId; } diff --git a/packages/message-manager/src/MessageManager.test.ts b/packages/message-manager/src/MessageManager.test.ts deleted file mode 100644 index abdc9075f89..00000000000 --- a/packages/message-manager/src/MessageManager.test.ts +++ /dev/null @@ -1,156 +0,0 @@ -import { MessageManager } from './MessageManager'; - -describe('MessageManager', () => { - let controller: MessageManager; - - const fromMock = '0xc38bf1ad06ef69f0c04e29dbeb4152b4175f0a8d'; - beforeEach(() => { - controller = new MessageManager(); - }); - - it('should set default state', () => { - expect(controller.state).toStrictEqual({ - unapprovedMessages: {}, - unapprovedMessagesCount: 0, - }); - }); - - it('should set default config', () => { - expect(controller.config).toStrictEqual({}); - }); - - it('should add a valid message', async () => { - const messageId = '1'; - const from = '0x0123'; - const messageData = '0x123'; - const messageTime = Date.now(); - const messageStatus = 'unapproved'; - const messageType = 'eth_sign'; - await controller.addMessage({ - id: messageId, - messageParams: { - data: messageData, - from, - }, - status: messageStatus, - time: messageTime, - type: messageType, - }); - const message = controller.getMessage(messageId); - if (!message) { - throw new Error('"message" is falsy'); - } - expect(message.id).toBe(messageId); - expect(message.messageParams.from).toBe(from); - expect(message.messageParams.data).toBe(messageData); - expect(message.time).toBe(messageTime); - expect(message.status).toBe(messageStatus); - expect(message.type).toBe(messageType); - }); - - it('should add a valid unapproved message', async () => { - const messageStatus = 'unapproved'; - const messageType = 'eth_sign'; - const messageParams = { - data: '0x123', - from: fromMock, - }; - const originalRequest = { - origin: 'origin', - securityAlertResponse: { result_type: 'result_type', reason: 'reason' }, - }; - const messageId = await controller.addUnapprovedMessage( - messageParams, - originalRequest, - ); - expect(messageId).toBeDefined(); - const message = controller.getMessage(messageId); - if (!message) { - throw new Error('"message" is falsy'); - } - expect(message.messageParams.from).toBe(messageParams.from); - expect(message.messageParams.data).toBe(messageParams.data); - expect(message.time).toBeDefined(); - expect(message.status).toBe(messageStatus); - expect(message.type).toBe(messageType); - expect(message.securityAlertResponse?.result_type).toBe('result_type'); - expect(message.securityAlertResponse?.reason).toBe('reason'); - }); - - it('should throw when adding invalid message', async () => { - const from = 'foo'; - const messageData = '0x123'; - await expect( - controller.addUnapprovedMessage({ - data: messageData, - from, - }), - ).rejects.toThrow( - `Invalid "from" address: ${from} must be a valid string.`, - ); - }); - - it('should get correct unapproved messages', async () => { - const firstMessage = { - id: '1', - messageParams: { from: '0x1', data: '0x123' }, - status: 'unapproved', - time: 123, - type: 'eth_sign', - }; - const secondMessage = { - id: '2', - messageParams: { from: '0x1', data: '0x321' }, - status: 'unapproved', - time: 123, - type: 'eth_sign', - }; - await controller.addMessage(firstMessage); - await controller.addMessage(secondMessage); - expect(controller.getUnapprovedMessagesCount()).toBe(2); - expect(controller.getUnapprovedMessages()).toStrictEqual({ - [firstMessage.id]: firstMessage, - [secondMessage.id]: secondMessage, - }); - }); - - it('should approve message', async () => { - const firstMessage = { from: fromMock, data: '0x123' }; - const messageId = await controller.addUnapprovedMessage(firstMessage); - const messageParams = await controller.approveMessage({ - ...firstMessage, - metamaskId: messageId, - }); - const message = controller.getMessage(messageId); - expect(messageParams).toStrictEqual(firstMessage); - if (!message) { - throw new Error('"message" is falsy'); - } - expect(message.status).toBe('approved'); - }); - - it('should set message status signed', async () => { - const firstMessage = { from: fromMock, data: '0x123' }; - const rawSig = '0x5f7a0'; - const messageId = await controller.addUnapprovedMessage(firstMessage); - - controller.setMessageStatusSigned(messageId, rawSig); - const message = controller.getMessage(messageId); - if (!message) { - throw new Error('"message" is falsy'); - } - expect(message.rawSig).toStrictEqual(rawSig); - expect(message.status).toBe('signed'); - }); - - it('should reject message', async () => { - const firstMessage = { from: fromMock, data: '0x123' }; - const messageId = await controller.addUnapprovedMessage(firstMessage); - controller.rejectMessage(messageId); - const message = controller.getMessage(messageId); - if (!message) { - throw new Error('"message" is falsy'); - } - expect(message.status).toBe('rejected'); - }); -}); diff --git a/packages/message-manager/src/MessageManager.ts b/packages/message-manager/src/MessageManager.ts deleted file mode 100644 index 9df4448ece4..00000000000 --- a/packages/message-manager/src/MessageManager.ts +++ /dev/null @@ -1,119 +0,0 @@ -import { v1 as random } from 'uuid'; - -import type { - AbstractMessage, - AbstractMessageParams, - AbstractMessageParamsMetamask, - OriginalRequest, -} from './AbstractMessageManager'; -import { AbstractMessageManager } from './AbstractMessageManager'; -import { normalizeMessageData, validateSignMessageData } from './utils'; - -/** - * @type Message - * - * Represents and contains data about a 'eth_sign' type signature request. - * These are created when a signature for an eth_sign call is requested. - * @property id - An id to track and identify the message object - * @property messageParams - The parameters to pass to the eth_sign method once the signature request is approved - * @property type - The json-prc signing method for which a signature request has been made. - * A 'Message' which always has a 'eth_sign' type - * @property rawSig - Raw data of the signature request - */ -export interface Message extends AbstractMessage { - messageParams: MessageParams; -} - -/** - * @type PersonalMessageParams - * - * Represents the parameters to pass to the eth_sign method once the signature request is approved. - * @property data - A hex string conversion of the raw buffer data of the signature request - * @property from - Address to sign this message from - * @property origin? - Added for request origin identification - */ -export interface MessageParams extends AbstractMessageParams { - data: string; -} - -/** - * @type MessageParamsMetamask - * - * Represents the parameters to pass to the eth_sign method once the signature request is approved - * plus data added by MetaMask. - * @property metamaskId - Added for tracking and identification within MetaMask - * @property data - A hex string conversion of the raw buffer data of the signature request - * @property from - Address to sign this message from - * @property origin? - Added for request origin identification - */ -export interface MessageParamsMetamask extends AbstractMessageParamsMetamask { - data: string; -} - -/** - * Controller in charge of managing - storing, adding, removing, updating - Messages. - */ -export class MessageManager extends AbstractMessageManager< - Message, - MessageParams, - MessageParamsMetamask -> { - /** - * Name of this controller used during composition - */ - override name = 'MessageManager'; - - /** - * Creates a new Message with an 'unapproved' status using the passed messageParams. - * this.addMessage is called to add the new Message to this.messages, and to save the - * unapproved Messages. - * - * @param messageParams - The params for the eth_sign call to be made after the message - * is approved. - * @param req - The original request object possibly containing the origin. - * @returns The id of the newly created message. - */ - async addUnapprovedMessage( - messageParams: MessageParams, - req?: OriginalRequest, - ): Promise { - validateSignMessageData(messageParams); - if (req) { - messageParams.origin = req.origin; - } - messageParams.data = normalizeMessageData(messageParams.data); - const messageId = random(); - const messageData: Message = { - id: messageId, - messageParams, - securityAlertResponse: req?.securityAlertResponse, - status: 'unapproved', - time: Date.now(), - type: 'eth_sign', - }; - await this.addMessage(messageData); - this.hub.emit(`unapprovedMessage`, { - ...messageParams, - ...{ metamaskId: messageId }, - }); - return messageId; - } - - /** - * Removes the metamaskId property from passed messageParams and returns a promise which - * resolves the updated messageParams. - * - * @param messageParams - The messageParams to modify. - * @returns Promise resolving to the messageParams with the metamaskId property removed. - */ - prepMessageForSigning( - messageParams: MessageParamsMetamask, - ): Promise { - // Using delete operation will throw an error on frozen messageParams - const { metamaskId: _metamaskId, ...messageParamsWithoutId } = - messageParams; - return Promise.resolve(messageParamsWithoutId); - } -} - -export default MessageManager; diff --git a/packages/message-manager/src/PersonalMessageManager.test.ts b/packages/message-manager/src/PersonalMessageManager.test.ts deleted file mode 100644 index 99ad5dd77d0..00000000000 --- a/packages/message-manager/src/PersonalMessageManager.test.ts +++ /dev/null @@ -1,190 +0,0 @@ -import type { SIWEMessage } from '@metamask/controller-utils'; -import { detectSIWE } from '@metamask/controller-utils'; - -import { PersonalMessageManager } from './PersonalMessageManager'; - -jest.mock('@metamask/controller-utils', () => ({ - ...jest.requireActual('@metamask/controller-utils'), - detectSIWE: jest.fn(), -})); - -const siweMockNotFound = { - isSIWEMessage: false, - parsedMessage: null, -} as SIWEMessage; - -const siweMockFound = { - isSIWEMessage: true, - parsedMessage: { - address: '0x0000000', - domain: 'example.eth', - }, -} as SIWEMessage; - -describe('PersonalMessageManager', () => { - let controller: PersonalMessageManager; - - const detectSIWEMock = detectSIWE as jest.MockedFunction; - const fromMock = '0xc38bf1ad06ef69f0c04e29dbeb4152b4175f0a8d'; - beforeEach(() => { - controller = new PersonalMessageManager(); - detectSIWEMock.mockReturnValue(siweMockNotFound); - }); - - it('should set default state', () => { - expect(controller.state).toStrictEqual({ - unapprovedMessages: {}, - unapprovedMessagesCount: 0, - }); - }); - - it('should set default config', () => { - expect(controller.config).toStrictEqual({}); - }); - - it('should add a valid message', async () => { - const messageId = '1'; - const from = '0x0123'; - const messageData = '0x123'; - const messageTime = Date.now(); - const messageStatus = 'unapproved'; - const messageType = 'personal_sign'; - await controller.addMessage({ - id: messageId, - messageParams: { - data: messageData, - from, - }, - status: messageStatus, - time: messageTime, - type: messageType, - }); - const message = controller.getMessage(messageId); - if (!message) { - throw new Error('"message" is falsy'); - } - expect(message.id).toBe(messageId); - expect(message.messageParams.from).toBe(from); - expect(message.messageParams.data).toBe(messageData); - expect(message.time).toBe(messageTime); - expect(message.status).toBe(messageStatus); - expect(message.type).toBe(messageType); - }); - - it('should add a valid unapproved message', async () => { - const messageStatus = 'unapproved'; - const messageType = 'personal_sign'; - const messageParams = { - data: '0x123', - from: fromMock, - }; - const originalRequest = { - origin: 'origin', - securityAlertResponse: { result_type: 'result_type', reason: 'reason' }, - }; - const messageId = await controller.addUnapprovedMessage( - messageParams, - originalRequest, - ); - expect(messageId).toBeDefined(); - const message = controller.getMessage(messageId); - if (!message) { - throw new Error('"message" is falsy'); - } - expect(message.messageParams.from).toBe(messageParams.from); - expect(message.messageParams.data).toBe(messageParams.data); - expect(message.time).toBeDefined(); - expect(message.status).toBe(messageStatus); - expect(message.type).toBe(messageType); - expect(message.securityAlertResponse?.result_type).toBe('result_type'); - expect(message.securityAlertResponse?.reason).toBe('reason'); - }); - - it('should throw when adding invalid message', async () => { - const from = 'foo'; - const messageData = '0x123'; - await expect( - controller.addUnapprovedMessage({ - data: messageData, - from, - }), - ).rejects.toThrow( - `Invalid "from" address: ${from} must be a valid string.`, - ); - }); - - it('should get correct unapproved messages', async () => { - const firstMessage = { - id: '1', - messageParams: { from: '0x1', data: '0x123' }, - status: 'unapproved', - time: 123, - type: 'personal_sign', - }; - const secondMessage = { - id: '2', - messageParams: { from: '0x1', data: '0x321' }, - status: 'unapproved', - time: 123, - type: 'personal_sign', - }; - await controller.addMessage(firstMessage); - await controller.addMessage(secondMessage); - expect(controller.getUnapprovedMessagesCount()).toBe(2); - expect(controller.getUnapprovedMessages()).toStrictEqual({ - [firstMessage.id]: firstMessage, - [secondMessage.id]: secondMessage, - }); - }); - - it('should approve message', async () => { - const firstMessage = { from: fromMock, data: '0x123' }; - const messageId = await controller.addUnapprovedMessage(firstMessage); - const messageParams = await controller.approveMessage({ - ...firstMessage, - metamaskId: messageId, - }); - const message = controller.getMessage(messageId); - expect(messageParams).toStrictEqual(firstMessage); - if (!message) { - throw new Error('"message" is falsy'); - } - expect(message.status).toBe('approved'); - }); - - it('should set message status signed', async () => { - const firstMessage = { from: fromMock, data: '0x123' }; - const rawSig = '0x5f7a0'; - const messageId = await controller.addUnapprovedMessage(firstMessage); - - controller.setMessageStatusSigned(messageId, rawSig); - const message = controller.getMessage(messageId); - if (!message) { - throw new Error('"message" is falsy'); - } - expect(message.rawSig).toStrictEqual(rawSig); - expect(message.status).toBe('signed'); - }); - - it('should reject message', async () => { - const firstMessage = { from: fromMock, data: '0x123' }; - const messageId = await controller.addUnapprovedMessage(firstMessage); - controller.rejectMessage(messageId); - const message = controller.getMessage(messageId); - if (!message) { - throw new Error('"message" is falsy'); - } - expect(message.status).toBe('rejected'); - }); - - it('should add message including Ethereum sign in data', async () => { - detectSIWEMock.mockReturnValue(siweMockFound); - const firstMessage = { from: fromMock, data: '0x123' }; - const messageId = await controller.addUnapprovedMessage(firstMessage); - const message = controller.getMessage(messageId); - if (!message) { - throw new Error('"message" is falsy'); - } - expect(message.messageParams.siwe).toBe(siweMockFound); - }); -}); diff --git a/packages/message-manager/src/PersonalMessageManager.ts b/packages/message-manager/src/PersonalMessageManager.ts deleted file mode 100644 index 91514153b18..00000000000 --- a/packages/message-manager/src/PersonalMessageManager.ts +++ /dev/null @@ -1,127 +0,0 @@ -import type { SIWEMessage } from '@metamask/controller-utils'; -import { detectSIWE } from '@metamask/controller-utils'; -import { v1 as random } from 'uuid'; - -import type { - AbstractMessage, - AbstractMessageParams, - AbstractMessageParamsMetamask, - OriginalRequest, -} from './AbstractMessageManager'; -import { AbstractMessageManager } from './AbstractMessageManager'; -import { normalizeMessageData, validateSignMessageData } from './utils'; - -/** - * @type Message - * - * Represents and contains data about a 'personal_sign' type signature request. - * These are created when a signature for a personal_sign call is requested. - * @property id - An id to track and identify the message object - * @property messageParams - The parameters to pass to the personal_sign method once the signature request is approved - * @property type - The json-prc signing method for which a signature request has been made. - * A 'Message' which always has a 'personal_sign' type - * @property rawSig - Raw data of the signature request - */ -export interface PersonalMessage extends AbstractMessage { - messageParams: PersonalMessageParams; -} - -/** - * @type PersonalMessageParams - * - * Represents the parameters to pass to the personal_sign method once the signature request is approved. - * @property data - A hex string conversion of the raw buffer data of the signature request - * @property from - Address to sign this message from - * @property origin? - Added for request origin identification - */ -export interface PersonalMessageParams extends AbstractMessageParams { - data: string; - siwe?: SIWEMessage; -} - -/** - * @type MessageParamsMetamask - * - * Represents the parameters to pass to the personal_sign method once the signature request is approved - * plus data added by MetaMask. - * @property metamaskId - Added for tracking and identification within MetaMask - * @property data - A hex string conversion of the raw buffer data of the signature request - * @property from - Address to sign this message from - * @property origin? - Added for request origin identification - */ -export interface PersonalMessageParamsMetamask - extends AbstractMessageParamsMetamask { - data: string; -} - -/** - * Controller in charge of managing - storing, adding, removing, updating - Messages. - */ -export class PersonalMessageManager extends AbstractMessageManager< - PersonalMessage, - PersonalMessageParams, - PersonalMessageParamsMetamask -> { - /** - * Name of this controller used during composition - */ - override name = 'PersonalMessageManager'; - - /** - * Creates a new Message with an 'unapproved' status using the passed messageParams. - * this.addMessage is called to add the new Message to this.messages, and to save the - * unapproved Messages. - * - * @param messageParams - The params for the personal_sign call to be made after the message - * is approved. - * @param req - The original request object possibly containing the origin. - * @returns The id of the newly created message. - */ - async addUnapprovedMessage( - messageParams: PersonalMessageParams, - req?: OriginalRequest, - ): Promise { - validateSignMessageData(messageParams); - if (req) { - messageParams.origin = req.origin; - } - messageParams.data = normalizeMessageData(messageParams.data); - - const ethereumSignInData = detectSIWE(messageParams); - const finalMsgParams = { ...messageParams, siwe: ethereumSignInData }; - - const messageId = random(); - const messageData: PersonalMessage = { - id: messageId, - messageParams: finalMsgParams, - securityAlertResponse: req?.securityAlertResponse, - status: 'unapproved', - time: Date.now(), - type: 'personal_sign', - }; - await this.addMessage(messageData); - this.hub.emit(`unapprovedMessage`, { - ...finalMsgParams, - ...{ metamaskId: messageId }, - }); - return messageId; - } - - /** - * Removes the metamaskId property from passed messageParams and returns a promise which - * resolves the updated messageParams. - * - * @param messageParams - The messageParams to modify. - * @returns Promise resolving to the messageParams with the metamaskId property removed. - */ - prepMessageForSigning( - messageParams: PersonalMessageParamsMetamask, - ): Promise { - // Using delete operation will throw an error on frozen messageParams - const { metamaskId: _metamaskId, ...messageParamsWithoutId } = - messageParams; - return Promise.resolve(messageParamsWithoutId); - } -} - -export default PersonalMessageManager; diff --git a/packages/message-manager/src/TypedMessageManager.test.ts b/packages/message-manager/src/TypedMessageManager.test.ts deleted file mode 100644 index 1ee445bd91d..00000000000 --- a/packages/message-manager/src/TypedMessageManager.test.ts +++ /dev/null @@ -1,381 +0,0 @@ -import { TypedMessageManager } from './TypedMessageManager'; - -let controller: TypedMessageManager; -const getCurrentChainIdStub = jest.fn(); - -const fromMock = '0xc38bf1ad06ef69f0c04e29dbeb4152b4175f0a8d'; - -const typedMessage = [ - { - name: 'Message', - type: 'string', - value: 'Hi, Alice!', - }, - { - name: 'A number', - type: 'uint32', - value: '1337', - }, -]; - -const typedMessageV3V4 = { - types: { - EIP712Domain: [ - { name: 'name', type: 'string' }, - { name: 'version', type: 'string' }, - { name: 'chainId', type: 'uint256' }, - { name: 'verifyingContract', type: 'address' }, - ], - Person: [ - { name: 'name', type: 'string' }, - { name: 'wallet', type: 'address' }, - ], - Mail: [ - { name: 'from', type: 'Person' }, - { name: 'to', type: 'Person' }, - { name: 'contents', type: 'string' }, - ], - }, - primaryType: 'Mail', - domain: { - name: 'Ether Mail', - version: '1', - chainId: 1, - verifyingContract: '0xCcCCccccCCCCcCCCCCCcCcCccCcCCCcCcccccccC', - }, - message: { - from: { name: 'Cow', wallet: '0xCD2a3d9F938E13CD947Ec05AbC7FE734Df8DD826' }, - to: { name: 'Bob', wallet: '0xbBbBBBBbbBBBbbbBbbBbbbbBBbBbbbbBbBbbBBbB' }, - contents: 'Hello, Bob!', - }, -}; - -describe('TypedMessageManager', () => { - beforeEach(() => { - controller = new TypedMessageManager( - undefined, - undefined, - undefined, - undefined, - getCurrentChainIdStub, - ); - }); - - it('should set default state', () => { - expect(controller.state).toStrictEqual({ - unapprovedMessages: {}, - unapprovedMessagesCount: 0, - }); - }); - - it('should set default config', () => { - expect(controller.config).toStrictEqual({}); - }); - - it('should add a valid message', async () => { - const messageId = '1'; - const from = '0x0123'; - const messageTime = Date.now(); - const messageStatus = 'unapproved'; - const messageType = 'eth_signTypedData'; - const messageData = typedMessage; - await controller.addMessage({ - id: messageId, - messageParams: { - data: messageData, - from, - }, - status: messageStatus, - time: messageTime, - type: messageType, - }); - const message = controller.getMessage(messageId); - if (!message) { - throw new Error('"message" is falsy'); - } - expect(message.id).toBe(messageId); - expect(message.messageParams.from).toBe(from); - expect(message.messageParams.data).toBe(messageData); - expect(message.time).toBe(messageTime); - expect(message.status).toBe(messageStatus); - expect(message.type).toBe(messageType); - }); - - it('should throw when adding a valid unapproved message when getCurrentChainId is undefined', async () => { - controller = new TypedMessageManager(); - const version = 'V3'; - const messageData = JSON.stringify(typedMessageV3V4); - const messageParams = { - data: messageData, - from: fromMock, - }; - const originalRequest = { origin: 'origin' }; - - await expect( - controller.addUnapprovedMessage(messageParams, originalRequest, version), - ).rejects.toThrow('Current chainId cannot be null or undefined.'); - }); - - it('should add a valid unapproved message', async () => { - const messageStatus = 'unapproved'; - const messageType = 'eth_signTypedData'; - const version = 'version'; - const messageData = typedMessage; - const messageParams = { - data: messageData, - from: fromMock, - }; - const originalRequest = { - origin: 'origin', - securityAlertResponse: { result_type: 'result_type', reason: 'reason' }, - }; - const messageId = await controller.addUnapprovedMessage( - messageParams, - originalRequest, - version, - ); - expect(messageId).toBeDefined(); - const message = controller.getMessage(messageId); - if (!message) { - throw new Error('"message" is falsy'); - } - expect(message.messageParams.from).toBe(messageParams.from); - expect(message.messageParams.data).toBe(messageParams.data); - expect(message.time).toBeDefined(); - expect(message.status).toBe(messageStatus); - expect(message.type).toBe(messageType); - expect(message.securityAlertResponse?.result_type).toBe('result_type'); - expect(message.securityAlertResponse?.reason).toBe('reason'); - }); - - it('should add a valid V3 unapproved message as a JSON-parseable string', async () => { - getCurrentChainIdStub.mockImplementation(() => 1); - const messageStatus = 'unapproved'; - const messageType = 'eth_signTypedData'; - const version = 'V3'; - const messageData = JSON.stringify(typedMessageV3V4); - const messageParams = { - data: messageData, - from: fromMock, - }; - const originalRequest = { origin: 'origin' }; - const messageId = await controller.addUnapprovedMessage( - messageParams, - originalRequest, - version, - ); - expect(messageId).toBeDefined(); - const message = controller.getMessage(messageId); - if (!message) { - throw new Error('"message" is falsy'); - } - expect(message.messageParams.from).toBe(messageParams.from); - expect(message.messageParams.data).toBe(messageParams.data); - expect(message.time).toBeDefined(); - expect(message.status).toBe(messageStatus); - expect(message.type).toBe(messageType); - }); - - it('should add a valid V3 unapproved message as an object', async () => { - getCurrentChainIdStub.mockImplementation(() => 1); - const messageStatus = 'unapproved'; - const messageType = 'eth_signTypedData'; - const version = 'V3'; - const messageData = typedMessageV3V4; - const messageParams = { - data: messageData, - from: fromMock, - }; - const originalRequest = { origin: 'origin' }; - const messageId = await controller.addUnapprovedMessage( - messageParams, - originalRequest, - version, - ); - expect(messageId).toBeDefined(); - const message = controller.getMessage(messageId); - if (!message) { - throw new Error('"message" is falsy'); - } - expect(message.messageParams.from).toBe(messageParams.from); - expect(message.messageParams.data).toBe(messageParams.data); - expect(message.time).toBeDefined(); - expect(message.status).toBe(messageStatus); - expect(message.type).toBe(messageType); - }); - - it('should throw when adding invalid legacy typed message', async () => { - const from = '0xc38bf1ad06ef69f0c04e29dbeb4152b4175f0a8d'; - const messageData = '0x879'; - const version = 'V1'; - await expect( - controller.addUnapprovedMessage( - { - data: messageData, - from, - }, - undefined, - version, - ), - ).rejects.toThrow('Invalid message "data":'); - }); - - it('should throw when adding invalid typed message', async () => { - const mockGetChainId = jest.fn(); - const from = '0xc38bf1ad06ef69f0c04e29dbeb4152b4175f0a8d'; - const messageData = typedMessage; - const version = 'V3'; - await expect( - controller.addUnapprovedMessage( - { - data: messageData, - from, - }, - undefined, - version, - ), - ).rejects.toThrow('Invalid message "data":'); - - const controllerWithGetCurrentChainIdCallback = new TypedMessageManager( - undefined, - undefined, - undefined, - undefined, - mockGetChainId, - ); - await expect( - controllerWithGetCurrentChainIdCallback.addUnapprovedMessage( - { - data: messageData, - from, - }, - undefined, - 'V4', - ), - ).rejects.toThrow('Invalid message "data":'); - expect(mockGetChainId).toHaveBeenCalled(); - }); - - it('should get correct unapproved messages', async () => { - const firstMessageData = [ - { - name: 'Message', - type: 'string', - value: 'Hi, Alice!', - }, - { - name: 'A number', - type: 'uint32', - value: '1337', - }, - ]; - const secondMessageData = [ - { - name: 'Message', - type: 'string', - value: 'Hi, Alice!', - }, - { - name: 'A number', - type: 'uint32', - value: '1337', - }, - ]; - const firstMessage = { - id: '1', - messageParams: { from: '0x1', data: firstMessageData }, - status: 'unapproved', - time: 123, - type: 'eth_signTypedData', - }; - const secondMessage = { - id: '2', - messageParams: { from: '0x1', data: secondMessageData }, - status: 'unapproved', - time: 123, - type: 'eth_signTypedData', - }; - await controller.addMessage(firstMessage); - await controller.addMessage(secondMessage); - expect(controller.getUnapprovedMessagesCount()).toBe(2); - expect(controller.getUnapprovedMessages()).toStrictEqual({ - [firstMessage.id]: firstMessage, - [secondMessage.id]: secondMessage, - }); - }); - - it('should approve typed message', async () => { - const messageData = typedMessage; - const firstMessage = { from: fromMock, data: messageData }; - const version = 'V1'; - const messageId = await await controller.addUnapprovedMessage( - firstMessage, - undefined, - version, - ); - const messageParams = await controller.approveMessage({ - ...firstMessage, - metamaskId: messageId, - version, - }); - const message = controller.getMessage(messageId); - expect(messageParams).toStrictEqual(firstMessage); - if (!message) { - throw new Error('"message" is falsy'); - } - expect(message.status).toBe('approved'); - }); - - it('should set message status signed', async () => { - const messageData = typedMessage; - const firstMessage = { from: fromMock, data: messageData }; - const version = 'V1'; - const rawSig = '0x5f7a0'; - const messageId = await controller.addUnapprovedMessage( - firstMessage, - undefined, - version, - ); - controller.setMessageStatusSigned(messageId, rawSig); - const message = controller.getMessage(messageId); - if (!message) { - throw new Error('"message" is falsy'); - } - expect(message.rawSig).toStrictEqual(rawSig); - expect(message.status).toBe('signed'); - }); - - it('should reject message', async () => { - const messageData = typedMessage; - const firstMessage = { from: fromMock, data: messageData }; - const version = 'V1'; - const messageId = await controller.addUnapprovedMessage( - firstMessage, - undefined, - version, - ); - controller.rejectMessage(messageId); - const message = controller.getMessage(messageId); - if (!message) { - throw new Error('"message" is falsy'); - } - expect(message.status).toBe('rejected'); - }); - - it('should set message status errored', async () => { - const messageData = typedMessage; - const firstMessage = { from: fromMock, data: messageData }; - const version = 'V1'; - const messageId = await controller.addUnapprovedMessage( - firstMessage, - undefined, - version, - ); - controller.setMessageStatusErrored(messageId, 'errored'); - const message = controller.getMessage(messageId); - if (!message) { - throw new Error('"message" is falsy'); - } - expect(message.status).toBe('errored'); - }); -}); diff --git a/packages/message-manager/src/TypedMessageManager.ts b/packages/message-manager/src/TypedMessageManager.ts deleted file mode 100644 index b2ff7efb608..00000000000 --- a/packages/message-manager/src/TypedMessageManager.ts +++ /dev/null @@ -1,182 +0,0 @@ -import { v1 as random } from 'uuid'; - -import type { - AbstractMessage, - AbstractMessageParams, - AbstractMessageParamsMetamask, - OriginalRequest, -} from './AbstractMessageManager'; -import { AbstractMessageManager } from './AbstractMessageManager'; -import { - validateTypedSignMessageDataV1, - validateTypedSignMessageDataV3V4, -} from './utils'; - -/** - * @type TypedMessage - * - * Represents and contains data about an 'eth_signTypedData' type signature request. - * These are created when a signature for an eth_signTypedData call is requested. - * @property id - An id to track and identify the message object - * @property error - Error corresponding to eth_signTypedData error in failure case - * @property messageParams - The parameters to pass to the eth_signTypedData method once - * the signature request is approved - * @property type - The json-prc signing method for which a signature request has been made. - * A 'TypedMessage' which always has a 'eth_signTypedData' type - * @property rawSig - Raw data of the signature request - */ -export interface TypedMessage extends AbstractMessage { - error?: string; - messageParams: TypedMessageParams; - time: number; - status: string; - type: string; - rawSig?: string; -} - -export type SignTypedDataMessageV3V4 = { - types: Record; - domain: Record; - primaryType: string; - message: unknown; -}; - -/** - * @type TypedMessageParams - * - * Represents the parameters to pass to the eth_signTypedData method once the signature request is approved. - * @property data - A hex string conversion of the raw buffer or an object containing data of the signature - * request depending on version - * @property from - Address to sign this message from - * @property origin? - Added for request origin identification - */ -export interface TypedMessageParams extends AbstractMessageParams { - data: Record[] | string | SignTypedDataMessageV3V4; -} - -/** - * @type TypedMessageParamsMetamask - * - * Represents the parameters to pass to the eth_signTypedData method once the signature request is approved - * plus data added by MetaMask. - * @property metamaskId - Added for tracking and identification within MetaMask - * @property data - A hex string conversion of the raw buffer or an object containing data of the signature - * request depending on version - * @property error? - Added for message errored - * @property from - Address to sign this message from - * @property origin? - Added for request origin identification - * @property version - Compatibility version EIP712 - */ -export interface TypedMessageParamsMetamask - extends AbstractMessageParamsMetamask { - data: TypedMessageParams['data']; - metamaskId?: string; - error?: string; - version?: string; -} - -/** - * Controller in charge of managing - storing, adding, removing, updating - TypedMessages. - */ -export class TypedMessageManager extends AbstractMessageManager< - TypedMessage, - TypedMessageParams, - TypedMessageParamsMetamask -> { - /** - * Name of this controller used during composition - */ - override name = 'TypedMessageManager'; - - /** - * Creates a new TypedMessage with an 'unapproved' status using the passed messageParams. - * this.addMessage is called to add the new TypedMessage to this.messages, and to save the - * unapproved TypedMessages. - * - * @param messageParams - The params for the 'eth_signTypedData' call to be made after the message - * is approved. - * @param req - The original request object possibly containing the origin. - * @param version - Compatibility version EIP712. - * @returns The id of the newly created TypedMessage. - */ - async addUnapprovedMessage( - messageParams: TypedMessageParams, - req?: OriginalRequest, - version?: string, - ): Promise { - if (version === 'V1') { - validateTypedSignMessageDataV1(messageParams); - } - - if (version === 'V3' || version === 'V4') { - const currentChainId = this.getCurrentChainId?.(); - validateTypedSignMessageDataV3V4(messageParams, currentChainId); - } - - if ( - typeof messageParams.data !== 'string' && - (version === 'V3' || version === 'V4') - ) { - messageParams.data = JSON.stringify(messageParams.data); - } - - const messageId = random(); - const messageParamsMetamask = { - ...messageParams, - metamaskId: messageId, - version, - }; - if (req) { - messageParams.origin = req.origin; - } - const messageData: TypedMessage = { - id: messageId, - messageParams, - securityAlertResponse: req?.securityAlertResponse, - status: 'unapproved', - time: Date.now(), - type: 'eth_signTypedData', - }; - await this.addMessage(messageData); - this.hub.emit(`unapprovedMessage`, messageParamsMetamask); - return messageId; - } - - /** - * Sets a TypedMessage status to 'errored' via a call to this.setMessageStatus. - * - * @param messageId - The id of the TypedMessage to error. - * @param error - The error to be included in TypedMessage. - */ - setMessageStatusErrored(messageId: string, error: string) { - const message = this.getMessage(messageId); - /* istanbul ignore if */ - if (!message) { - return; - } - message.error = error; - this.updateMessage(message); - this.setMessageStatus(messageId, 'errored'); - } - - /** - * Removes the metamaskId and version properties from passed messageParams and returns a promise which - * resolves the updated messageParams. - * - * @param messageParams - The messageParams to modify. - * @returns Promise resolving to the messageParams with the metamaskId and version properties removed. - */ - prepMessageForSigning( - messageParams: TypedMessageParamsMetamask, - ): Promise { - // Using delete operation will throw an error on frozen messageParams - const { - metamaskId: _metamaskId, - version: _version, - ...messageParamsWithoutId - } = messageParams; - return Promise.resolve(messageParamsWithoutId); - } -} - -export default TypedMessageManager; diff --git a/packages/message-manager/src/index.ts b/packages/message-manager/src/index.ts index 71e07950c7e..0ab01d5a3a0 100644 --- a/packages/message-manager/src/index.ts +++ b/packages/message-manager/src/index.ts @@ -1,6 +1,4 @@ -export * from './AbstractMessageManager'; -export * from './MessageManager'; -export * from './PersonalMessageManager'; -export * from './TypedMessageManager'; -export * from './EncryptionPublicKeyManager'; -export * from './DecryptMessageManager'; +export * from './AbstractMessageManager.js'; +export * from './EncryptionPublicKeyManager.js'; +export * from './DecryptMessageManager.js'; +export type * from './types.js'; diff --git a/packages/message-manager/src/types.ts b/packages/message-manager/src/types.ts new file mode 100644 index 00000000000..81726bbcf7f --- /dev/null +++ b/packages/message-manager/src/types.ts @@ -0,0 +1,21 @@ +import type { SIWEMessage } from '@metamask/controller-utils'; + +import type { AbstractMessageParams } from './AbstractMessageManager.js'; + +// Below types are have been moved into KeyringController, but are still exported here for backwards compatibility. + +export type SignTypedDataMessageV3V4 = { + types: Record; + domain: Record; + primaryType: string; + message: unknown; +}; + +export type PersonalMessageParams = { + data: string; + siwe?: SIWEMessage; +} & AbstractMessageParams; + +export type TypedMessageParams = { + data: Record[] | string | SignTypedDataMessageV3V4; +} & AbstractMessageParams; diff --git a/packages/message-manager/src/utils.test.ts b/packages/message-manager/src/utils.test.ts index 8ed17af92ea..936dab2c751 100644 --- a/packages/message-manager/src/utils.test.ts +++ b/packages/message-manager/src/utils.test.ts @@ -1,6 +1,4 @@ -import { convertHexToDecimal, toHex } from '@metamask/controller-utils'; - -import * as util from './utils'; +import * as util from './utils.js'; describe('utils', () => { it('normalizeMessageData', () => { @@ -14,273 +12,11 @@ describe('utils', () => { expect(secondNormalized).toBe('0x736f6d6564617461'); }); - describe('validateSignMessageData', () => { - it('should throw if no from address', () => { - expect(() => - util.validateSignMessageData({ - data: '0x879a05', - } as any), - ).toThrow(`Invalid "from" address: undefined must be a valid string.`); - }); - - it('should throw if invalid from address', () => { - const from = '01'; - expect(() => - util.validateSignMessageData({ - data: '0x879a05', - from, - } as any), - ).toThrow(`Invalid "from" address: ${from} must be a valid string.`); - }); - - it('should throw if invalid type from address', () => { - const from = 123; - expect(() => - util.validateSignMessageData({ - data: '0x879a05', - from, - } as any), - ).toThrow(`Invalid "from" address: ${from} must be a valid string.`); - }); - - it('should throw if no data', () => { - expect(() => - util.validateSignMessageData({ - data: '0x879a05', - } as any), - ).toThrow(`Invalid "from" address: undefined must be a valid string.`); - }); - - it('should throw if invalid tyoe data', () => { - expect(() => - util.validateSignMessageData({ - data: 123, - from: '0x3244e191f1b4903970224322180f1fbbc415696b', - } as any), - ).toThrow('Invalid message "data": 123 must be a valid string.'); - }); - }); - - describe('validateTypedMessageDataV1', () => { - it('should throw if no from address legacy', () => { - expect(() => - util.validateTypedSignMessageDataV1({ - data: [], - } as any), - ).toThrow(`Invalid "from" address: undefined must be a valid string.`); - }); - - it('should throw if invalid from address', () => { - const from = '3244e191f1b4903970224322180f1'; - expect(() => - util.validateTypedSignMessageDataV1({ - data: [], - from, - } as any), - ).toThrow(`Invalid "from" address: ${from} must be a valid string.`); - }); - - it('should throw if invalid type from address', () => { - const from = 123; - expect(() => - util.validateTypedSignMessageDataV1({ - data: [], - from, - } as any), - ).toThrow(`Invalid "from" address: ${from} must be a valid string.`); - }); - - it('should throw if incorrect data', () => { - expect(() => - util.validateTypedSignMessageDataV1({ - data: '0x879a05', - from: '0x3244e191f1b4903970224322180f1fbbc415696b', - } as any), - ).toThrow('Invalid message "data":'); - }); - - it('should throw if no data', () => { - expect(() => - util.validateTypedSignMessageDataV1({ - data: '0x879a05', - from: '0x3244e191f1b4903970224322180f1fbbc415696b', - } as any), - ).toThrow('Invalid message "data":'); - }); - - it('should throw if invalid type data', () => { - expect(() => - util.validateTypedSignMessageDataV1({ - data: [], - from: '0x3244e191f1b4903970224322180f1fbbc415696b', - } as any), - ).toThrow('Expected EIP712 typed data.'); - }); - }); - - describe('validateTypedSignMessageDataV3V4', () => { - const dataTyped = - '{"types":{"EIP712Domain":[{"name":"name","type":"string"},{"name":"version","type":"string"},{"name":"chainId","type":"uint256"},{"name":"verifyingContract","type":"address"}],"Person":[{"name":"name","type":"string"},{"name":"wallet","type":"address"}],"Mail":[{"name":"from","type":"Person"},{"name":"to","type":"Person"},{"name":"contents","type":"string"}]},"primaryType":"Mail","domain":{"name":"Ether Mail","version":"1","chainId":1,"verifyingContract":"0xCcCCccccCCCCcCCCCCCcCcCccCcCCCcCcccccccC"},"message":{"from":{"name":"Cow","wallet":"0xCD2a3d9F938E13CD947Ec05AbC7FE734Df8DD826"},"to":{"name":"Bob","wallet":"0xbBbBBBBbbBBBbbbBbbBbbbbBBbBbbbbBbBbbBBbB"},"contents":"Hello, Bob!"}}'; - const mockedCurrentChainId = toHex(1); - it('should throw if no from address', () => { - expect(() => - util.validateTypedSignMessageDataV3V4( - { - data: '0x879a05', - } as any, - mockedCurrentChainId, - ), - ).toThrow(`Invalid "from" address: undefined must be a valid string.`); - }); - - it('should throw if invalid from address', () => { - const from = '3244e191f1b4903970224322180f1fb'; - expect(() => - util.validateTypedSignMessageDataV3V4( - { - data: '0x879a05', - from, - } as any, - mockedCurrentChainId, - ), - ).toThrow(`Invalid "from" address: ${from} must be a valid string.`); - }); - - it('should throw if invalid type from address', () => { - const from = 123; - expect(() => - util.validateTypedSignMessageDataV3V4( - { - data: '0x879a05', - from: 123, - } as any, - mockedCurrentChainId, - ), - ).toThrow(`Invalid "from" address: ${from} must be a valid string.`); - }); - - it('should throw if array data', () => { - expect(() => - util.validateTypedSignMessageDataV3V4( - { - data: [], - from: '0x3244e191f1b4903970224322180f1fbbc415696b', - } as any, - mockedCurrentChainId, - ), - ).toThrow('Invalid message "data":'); - }); - - it('should throw if no array data', () => { - expect(() => - util.validateTypedSignMessageDataV3V4( - { - from: '0x3244e191f1b4903970224322180f1fbbc415696b', - } as any, - mockedCurrentChainId, - ), - ).toThrow('Invalid message "data":'); - }); - - it('should throw if no json valid data', () => { - expect(() => - util.validateTypedSignMessageDataV3V4( - { - data: 'uh oh', - from: '0x3244e191f1b4903970224322180f1fbbc415696b', - } as any, - mockedCurrentChainId, - ), - ).toThrow('Data must be passed as a valid JSON string.'); - }); - - it('should throw if current chain id is not present', () => { - expect(() => - util.validateTypedSignMessageDataV3V4( - { - data: dataTyped, - from: '0x3244e191f1b4903970224322180f1fbbc415696b', - } as any, - undefined, - ), - ).toThrow('Current chainId cannot be null or undefined.'); - }); - - it('should throw if current chain id is not convertable to integer', () => { - const unexpectedChainId = 'unexpected chain id'; - expect(() => - util.validateTypedSignMessageDataV3V4( - { - data: dataTyped.replace(`"chainId":1`, `"chainId":"0x1"`), - from: '0x3244e191f1b4903970224322180f1fbbc415696b', - } as any, - // @ts-expect-error Intentionally invalid - unexpectedChainId, - ), - ).toThrow( - `Cannot sign messages for chainId "${convertHexToDecimal( - mockedCurrentChainId, - )}", because MetaMask is switching networks.`, - ); - }); - - it('should throw if current chain id is not matched with provided in message data', () => { - const chainId = toHex(2); - expect(() => - util.validateTypedSignMessageDataV3V4( - { - data: dataTyped, - from: '0x3244e191f1b4903970224322180f1fbbc415696b', - } as any, - chainId, - ), - ).toThrow( - `Provided chainId "${convertHexToDecimal( - mockedCurrentChainId, - )}" must match the active chainId "${convertHexToDecimal(chainId)}"`, - ); - }); - - it('should throw if data not in typed message schema', () => { - expect(() => - util.validateTypedSignMessageDataV3V4( - { - data: '{"greetings":"I am Alice"}', - from: '0x3244e191f1b4903970224322180f1fbbc415696b', - } as any, - mockedCurrentChainId, - ), - ).toThrow('Data must conform to EIP-712 schema.'); - }); - - it('should not throw if data is correct', () => { - expect(() => - util.validateTypedSignMessageDataV3V4( - { - data: dataTyped.replace(`"chainId":1`, `"chainId":"1"`), - from: '0x3244e191f1b4903970224322180f1fbbc415696b', - } as any, - mockedCurrentChainId, - ), - ).not.toThrow(); - }); - - it('should not throw if data is correct (object format)', () => { - expect(() => - util.validateTypedSignMessageDataV3V4( - { - data: JSON.parse(dataTyped), - from: '0x3244e191f1b4903970224322180f1fbbc415696b', - } as any, - mockedCurrentChainId, - ), - ).not.toThrow(); - }); - }); - describe('validateEncryptionPublicKeyMessageData', () => { it('should throw if no from address', () => { expect(() => + // TODO: Replace `any` with type + // eslint-disable-next-line @typescript-eslint/no-explicit-any util.validateEncryptionPublicKeyMessageData({} as any), ).toThrow(`Invalid "from" address: undefined must be a valid string.`); }); @@ -290,6 +26,8 @@ describe('utils', () => { expect(() => util.validateEncryptionPublicKeyMessageData({ from, + // TODO: Replace `any` with type + // eslint-disable-next-line @typescript-eslint/no-explicit-any } as any), ).toThrow(`Invalid "from" address: ${from} must be a valid string.`); }); @@ -299,6 +37,8 @@ describe('utils', () => { expect(() => util.validateEncryptionPublicKeyMessageData({ from: 123, + // TODO: Replace `any` with type + // eslint-disable-next-line @typescript-eslint/no-explicit-any } as any), ).toThrow(`Invalid "from" address: ${from} must be a valid string.`); }); @@ -307,6 +47,8 @@ describe('utils', () => { expect(() => util.validateEncryptionPublicKeyMessageData({ from: '0x3244e191f1b4903970224322180f1fbbc415696b', + // TODO: Replace `any` with type + // eslint-disable-next-line @typescript-eslint/no-explicit-any } as any), ).not.toThrow(); }); @@ -314,6 +56,8 @@ describe('utils', () => { describe('validateDecryptedMessageData', () => { it('should throw if no from address', () => { + // TODO: Replace `any` with type + // eslint-disable-next-line @typescript-eslint/no-explicit-any expect(() => util.validateDecryptedMessageData({} as any)).toThrow( 'Invalid "from" address: undefined must be a valid string.', ); @@ -324,6 +68,8 @@ describe('utils', () => { expect(() => util.validateDecryptedMessageData({ from, + // TODO: Replace `any` with type + // eslint-disable-next-line @typescript-eslint/no-explicit-any } as any), ).toThrow(`Invalid "from" address: ${from} must be a valid string.`); }); @@ -333,6 +79,8 @@ describe('utils', () => { expect(() => util.validateDecryptedMessageData({ from, + // TODO: Replace `any` with type + // eslint-disable-next-line @typescript-eslint/no-explicit-any } as any), ).toThrow(`Invalid "from" address: ${from} must be a valid string.`); }); @@ -341,6 +89,8 @@ describe('utils', () => { expect(() => util.validateDecryptedMessageData({ from: '0x3244e191f1b4903970224322180f1fbbc415696b', + // TODO: Replace `any` with type + // eslint-disable-next-line @typescript-eslint/no-explicit-any } as any), ).not.toThrow(); }); diff --git a/packages/message-manager/src/utils.ts b/packages/message-manager/src/utils.ts index 6d80714c485..3fafade1e81 100644 --- a/packages/message-manager/src/utils.ts +++ b/packages/message-manager/src/utils.ts @@ -1,17 +1,8 @@ import { isValidHexAddress } from '@metamask/controller-utils'; -import { - TYPED_MESSAGE_SCHEMA, - typedSignatureHash, -} from '@metamask/eth-sig-util'; -import type { Hex } from '@metamask/utils'; -import { addHexPrefix, bufferToHex, stripHexPrefix } from 'ethereumjs-util'; -import { validate } from 'jsonschema'; +import { add0x, bytesToHex, remove0x } from '@metamask/utils'; -import type { DecryptMessageParams } from './DecryptMessageManager'; -import type { EncryptionPublicKeyParams } from './EncryptionPublicKeyManager'; -import type { MessageParams } from './MessageManager'; -import type { PersonalMessageParams } from './PersonalMessageManager'; -import type { TypedMessageParams } from './TypedMessageManager'; +import type { DecryptMessageParams } from './DecryptMessageManager.js'; +import type { EncryptionPublicKeyParams } from './EncryptionPublicKeyManager.js'; const hexRe = /^[0-9A-Fa-f]+$/gu; /** @@ -37,123 +28,14 @@ function validateAddress(address: string, propertyName: string) { */ export function normalizeMessageData(data: string) { try { - const stripped = stripHexPrefix(data); + const stripped = remove0x(data); if (stripped.match(hexRe)) { - return addHexPrefix(stripped); + return add0x(stripped); } } catch (e) { /* istanbul ignore next */ } - return bufferToHex(Buffer.from(data, 'utf8')); -} - -/** - * Validates a PersonalMessageParams and MessageParams objects for required properties and throws in - * the event of any validation error. - * - * @param messageData - PersonalMessageParams object to validate. - */ -export function validateSignMessageData( - messageData: PersonalMessageParams | MessageParams, -) { - const { from, data } = messageData; - validateAddress(from, 'from'); - - if (!data || typeof data !== 'string') { - throw new Error(`Invalid message "data": ${data} must be a valid string.`); - } -} - -/** - * Validates a TypedMessageParams object for required properties and throws in - * the event of any validation error for eth_signTypedMessage_V1. - * - * @param messageData - TypedMessageParams object to validate. - */ -export function validateTypedSignMessageDataV1( - messageData: TypedMessageParams, -) { - validateAddress(messageData.from, 'from'); - - if (!messageData.data || !Array.isArray(messageData.data)) { - throw new Error( - `Invalid message "data": ${messageData.data} must be a valid array.`, - ); - } - - try { - // typedSignatureHash will throw if the data is invalid. - typedSignatureHash(messageData.data as any); - } catch (e) { - throw new Error(`Expected EIP712 typed data.`); - } -} - -/** - * Validates a TypedMessageParams object for required properties and throws in - * the event of any validation error for eth_signTypedMessage_V3. - * - * @param messageData - TypedMessageParams object to validate. - * @param currentChainId - The current chainId. - */ -export function validateTypedSignMessageDataV3V4( - messageData: TypedMessageParams, - currentChainId: Hex | undefined, -) { - validateAddress(messageData.from, 'from'); - - if ( - !messageData.data || - Array.isArray(messageData.data) || - (typeof messageData.data !== 'object' && - typeof messageData.data !== 'string') - ) { - throw new Error( - `Invalid message "data": Must be a valid string or object.`, - ); - } - - let data; - if (typeof messageData.data === 'object') { - data = messageData.data; - } else { - try { - data = JSON.parse(messageData.data); - } catch (e) { - throw new Error('Data must be passed as a valid JSON string.'); - } - } - - const validation = validate(data, TYPED_MESSAGE_SCHEMA); - if (validation.errors.length > 0) { - throw new Error( - 'Data must conform to EIP-712 schema. See https://git.io/fNtcx.', - ); - } - - if (!currentChainId) { - throw new Error('Current chainId cannot be null or undefined.'); - } - - let { chainId } = data.domain; - if (chainId) { - if (typeof chainId === 'string') { - chainId = parseInt(chainId, chainId.startsWith('0x') ? 16 : 10); - } - - const activeChainId = parseInt(currentChainId, 16); - if (Number.isNaN(activeChainId)) { - throw new Error( - `Cannot sign messages for chainId "${chainId}", because MetaMask is switching networks.`, - ); - } - - if (chainId !== activeChainId) { - throw new Error( - `Provided chainId "${chainId}" must match the active chainId "${activeChainId}"`, - ); - } - } + return bytesToHex(Buffer.from(data, 'utf8')); } /** diff --git a/packages/message-manager/tsconfig.build.json b/packages/message-manager/tsconfig.build.json index bbfe057a207..5a5c9e2326a 100644 --- a/packages/message-manager/tsconfig.build.json +++ b/packages/message-manager/tsconfig.build.json @@ -7,7 +7,8 @@ }, "references": [ { "path": "../base-controller/tsconfig.build.json" }, - { "path": "../controller-utils/tsconfig.build.json" } + { "path": "../controller-utils/tsconfig.build.json" }, + { "path": "../messenger/tsconfig.build.json" } ], "include": ["../../types", "./src"] } diff --git a/packages/message-manager/tsconfig.json b/packages/message-manager/tsconfig.json index 7ee9852347a..dfd15011442 100644 --- a/packages/message-manager/tsconfig.json +++ b/packages/message-manager/tsconfig.json @@ -5,7 +5,8 @@ }, "references": [ { "path": "../base-controller" }, - { "path": "../controller-utils" } + { "path": "../controller-utils" }, + { "path": "../messenger" } ], "include": ["../../types", "./src"] } diff --git a/packages/messenger-cli/CHANGELOG.md b/packages/messenger-cli/CHANGELOG.md new file mode 100644 index 00000000000..052cbdaa022 --- /dev/null +++ b/packages/messenger-cli/CHANGELOG.md @@ -0,0 +1,39 @@ +# Changelog + +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [Unreleased] + +### Added + +- Add `--esm` flag for ESM-compatible import extensions ([#9572](https://github.com/MetaMask/core/pull/9572)) + - When `--esm` is set, the generated files will have `.js` import extensions. + +### Changed + +- Bump `@metamask/utils` from `^11.9.0` to `^11.11.0` ([#9074](https://github.com/MetaMask/core/pull/9074)) + +## [0.2.0] + +### Added + +- **BREAKING:** Add support for formatting the generated method action type files with Prettier or Oxfmt ([#8486](https://github.com/MetaMask/core/pull/8486)) + - This adds a `--formatter` option to the CLI, which accepts either `oxfmt` or + `prettier` (default). + - ESLint is no longer used to format the generated files, and is no longer a + (peer) dependency of this package. + +## [0.1.0] + +### Added + +- Initial release, extracted from `@metamask/messenger` ([#8378](https://github.com/MetaMask/core/pull/8378)) + - CLI tool for generating TypeScript action type files for controllers and services that define `MESSENGER_EXPOSED_METHODS`. + - Available as a CLI binary (`messenger-action-types`). + +[Unreleased]: https://github.com/MetaMask/core/compare/@metamask/messenger-cli@0.2.0...HEAD +[0.2.0]: https://github.com/MetaMask/core/compare/@metamask/messenger-cli@0.1.0...@metamask/messenger-cli@0.2.0 +[0.1.0]: https://github.com/MetaMask/core/releases/tag/@metamask/messenger-cli@0.1.0 diff --git a/packages/messenger-cli/LICENSE b/packages/messenger-cli/LICENSE new file mode 100644 index 00000000000..fe29e78e0fe --- /dev/null +++ b/packages/messenger-cli/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/messenger-cli/README.md b/packages/messenger-cli/README.md new file mode 100644 index 00000000000..85a103a945a --- /dev/null +++ b/packages/messenger-cli/README.md @@ -0,0 +1,21 @@ +# `@metamask/messenger-cli` + +CLI tools for the MetaMask messenger system + +## Installation + +`yarn add @metamask/messenger-cli` + +or + +`npm install @metamask/messenger-cli` + +Either Prettier (default) or Oxfmt (when using `--formatter oxfmt`) must also be +installed: + +- Prettier: `yarn add --dev prettier` or `npm install --save-dev prettier`. +- Oxfmt: `yarn add --dev oxfmt` or `npm install --save-dev oxfmt`. + +## 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/messenger-cli/jest.config.js b/packages/messenger-cli/jest.config.js new file mode 100644 index 00000000000..ab426afd5c0 --- /dev/null +++ b/packages/messenger-cli/jest.config.js @@ -0,0 +1,29 @@ +/* + * 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, + + // cli.ts is tested via execa subprocess in cli.test.ts; Jest can't instrument it + coveragePathIgnorePatterns: ['./src/cli.ts'], + + // An object that configures minimum threshold enforcement for coverage results + coverageThreshold: { + global: { + branches: 90.65, + functions: 100, + lines: 97.59, + statements: 97.6, + }, + }, +}); diff --git a/packages/messenger-cli/package.json b/packages/messenger-cli/package.json new file mode 100644 index 00000000000..ac25ea88a86 --- /dev/null +++ b/packages/messenger-cli/package.json @@ -0,0 +1,76 @@ +{ + "name": "@metamask/messenger-cli", + "version": "0.2.0", + "description": "CLI tools for the MetaMask messenger system", + "keywords": [ + "Ethereum", + "MetaMask" + ], + "homepage": "https://github.com/MetaMask/core/tree/main/packages/messenger-cli#readme", + "bugs": { + "url": "https://github.com/MetaMask/core/issues" + }, + "license": "MIT", + "repository": { + "type": "git", + "url": "https://github.com/MetaMask/core.git" + }, + "bin": { + "messenger-action-types": "./dist/cli.mjs" + }, + "files": [ + "dist/" + ], + "sideEffects": false, + "publishConfig": { + "access": "public", + "registry": "https://registry.npmjs.org/" + }, + "scripts": { + "build": "ts-bridge --project tsconfig.build.json --verbose --clean --no-references", + "build:all": "ts-bridge --project tsconfig.build.json --verbose --clean", + "changelog:update": "../../scripts/update-changelog.sh @metamask/messenger-cli", + "changelog:validate": "../../scripts/validate-changelog.sh @metamask/messenger-cli", + "lint:tsconfigs": "tsx ../../scripts/lint-tsconfigs/lint-tsconfigs.mts", + "lint:tsconfigs:fix": "tsx ../../scripts/lint-tsconfigs/lint-tsconfigs.mts --fix", + "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/utils": "^11.11.0", + "yargs": "^17.7.2" + }, + "devDependencies": { + "@metamask/auto-changelog": "^6.1.0", + "@ts-bridge/cli": "^0.6.4", + "@types/jest": "^30.0.0", + "@types/yargs": "^17.0.32", + "deepmerge": "^4.2.2", + "execa": "^5.0.0", + "jest": "^30.4.2", + "oxfmt": "^0.44.0", + "prettier": "^3.3.3", + "ts-jest": "^29.4.11", + "tsx": "^4.20.5", + "typescript": "~5.3.3" + }, + "peerDependencies": { + "oxfmt": "^0.44.0", + "prettier": "^3.0.0", + "typescript": ">=5.0.0" + }, + "peerDependenciesMeta": { + "oxfmt": { + "optional": true + }, + "prettier": { + "optional": true + } + }, + "engines": { + "node": "^18.18 || >=20" + } +} diff --git a/packages/messenger-cli/src/check.test.ts b/packages/messenger-cli/src/check.test.ts new file mode 100644 index 00000000000..ea212269438 --- /dev/null +++ b/packages/messenger-cli/src/check.test.ts @@ -0,0 +1,119 @@ +import { createSandbox } from '@metamask/utils/node'; +import * as fs from 'node:fs'; +import * as path from 'node:path'; + +import { checkActionTypesFiles } from './check.js'; +import { generateActionTypesContent } from './generate-content.js'; +import type { SourceInfo } from './parse-source.js'; + +const { withinSandbox } = createSandbox('messenger/check-action-types'); + +describe('checkActionTypesFiles', () => { + it('reports up to date when files match', async () => { + expect.assertions(1); + + await withinSandbox(async ({ directoryPath }) => { + const controller: SourceInfo = { + name: 'TestController', + filePath: path.join(directoryPath, 'TestController.ts'), + + methods: [{ name: 'doStuff', jsDoc: '' }], + }; + + const content = await generateActionTypesContent(controller, 'prettier'); + await fs.promises.writeFile( + path.join(directoryPath, 'TestController-method-action-types.ts'), + content, + 'utf8', + ); + + const consoleSpy = jest.spyOn(console, 'log').mockImplementation(); + const result = await checkActionTypesFiles([controller], 'prettier'); + consoleSpy.mockRestore(); + + expect(result).toBe(true); + }); + }); + + it('reports out of date when files differ', async () => { + expect.assertions(1); + + await withinSandbox(async ({ directoryPath }) => { + const controller: SourceInfo = { + name: 'TestController', + filePath: path.join(directoryPath, 'TestController.ts'), + + methods: [{ name: 'doStuff', jsDoc: '' }], + }; + + await fs.promises.writeFile( + path.join(directoryPath, 'TestController-method-action-types.ts'), + '// outdated content\n', + 'utf8', + ); + + const consoleSpy = jest.spyOn(console, 'log').mockImplementation(); + const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(); + const result = await checkActionTypesFiles([controller], 'prettier'); + consoleSpy.mockRestore(); + consoleErrorSpy.mockRestore(); + + expect(result).toBe(false); + }); + }); + + it('reports missing files', async () => { + expect.assertions(1); + + await withinSandbox(async ({ directoryPath }) => { + const controller: SourceInfo = { + name: 'TestController', + filePath: path.join(directoryPath, 'TestController.ts'), + + methods: [{ name: 'doStuff', jsDoc: '' }], + }; + + const consoleSpy = jest.spyOn(console, 'log').mockImplementation(); + const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(); + const result = await checkActionTypesFiles([controller], 'prettier'); + consoleSpy.mockRestore(); + consoleErrorSpy.mockRestore(); + + expect(result).toBe(false); + }); + }); + + it('reports non-ENOENT errors when accessing files', async () => { + expect.assertions(2); + + await withinSandbox(async ({ directoryPath }) => { + const controller: SourceInfo = { + name: 'TestController', + filePath: path.join(directoryPath, 'TestController.ts'), + + methods: [{ name: 'doStuff', jsDoc: '' }], + }; + + // Mock fs.promises.access to throw a non-ENOENT error + const accessSpy = jest + .spyOn(fs.promises, 'access') + .mockRejectedValue( + Object.assign(new Error('EPERM'), { code: 'EPERM' }), + ); + + const consoleSpy = jest.spyOn(console, 'log').mockImplementation(); + const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(); + const result = await checkActionTypesFiles([controller], 'prettier'); + + expect(consoleErrorSpy).toHaveBeenCalledWith( + expect.stringContaining('Error reading'), + expect.anything(), + ); + expect(result).toBe(false); + + accessSpy.mockRestore(); + consoleSpy.mockRestore(); + consoleErrorSpy.mockRestore(); + }); + }); +}); diff --git a/packages/messenger-cli/src/check.ts b/packages/messenger-cli/src/check.ts new file mode 100644 index 00000000000..fad106865d9 --- /dev/null +++ b/packages/messenger-cli/src/check.ts @@ -0,0 +1,92 @@ +import * as fs from 'node:fs'; +import * as path from 'node:path'; + +import { generateActionTypesContent } from './generate-content.js'; +import type { SourceInfo } from './parse-source.js'; +import { Formatter } from './types.js'; + +/** + * Checks if generated action types files are up to date. + * + * @param sources - Array of source information objects. + * @param formatter - The formatter to use for formatting the generated content. + * @param esm - Whether to add `.js` extensions to import paths for ESM + * compatibility. + * @returns Whether all files are up to date. + */ +export async function checkActionTypesFiles( + sources: SourceInfo[], + formatter: Formatter, + esm = false, +): Promise { + let hasErrors = false; + + const fileComparisonJobs: { + expectedContent: string; + actualFile: string; + baseFileName: string; + }[] = []; + + for (const source of sources) { + console.log(`\n🔧 Checking ${source.name}...`); + const outputDir = path.dirname(source.filePath); + const baseFileName = path.basename(source.filePath, '.ts'); + const actualFile = path.join( + outputDir, + `${baseFileName}-method-action-types.ts`, + ); + + const expectedContent = await generateActionTypesContent( + source, + formatter, + esm, + ); + + try { + await fs.promises.access(actualFile); + fileComparisonJobs.push({ + expectedContent, + actualFile, + baseFileName, + }); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') { + console.error( + `❌ ${baseFileName}-method-action-types.ts does not exist`, + ); + } else { + console.error( + `❌ Error reading ${baseFileName}-method-action-types.ts:`, + error, + ); + } + hasErrors = true; + } + } + + if (fileComparisonJobs.length > 0) { + for (const job of fileComparisonJobs) { + const actualContent = await fs.promises.readFile(job.actualFile, 'utf8'); + + if (job.expectedContent === actualContent) { + console.log( + `✅ ${job.baseFileName}-method-action-types.ts is up to date`, + ); + } else { + console.error( + `❌ ${job.baseFileName}-method-action-types.ts is out of date`, + ); + hasErrors = true; + } + } + } + + if (hasErrors) { + console.error('\n💥 Some action type files are out of date or missing.'); + console.error('Run `messenger-action-types --generate` to update them.'); + return false; + } + + console.log('\n🎉 All action type files are up to date!'); + return true; +} diff --git a/packages/messenger-cli/src/cli.test.ts b/packages/messenger-cli/src/cli.test.ts new file mode 100644 index 00000000000..ce87d9138d8 --- /dev/null +++ b/packages/messenger-cli/src/cli.test.ts @@ -0,0 +1,597 @@ +import { createSandbox } from '@metamask/utils/node'; +import execa from 'execa'; +import * as fs from 'node:fs'; +import * as path from 'node:path'; + +const ROOT_DIR = path.resolve(__dirname, '..', '..', '..'); +const TSX_PATH = path.join(ROOT_DIR, 'node_modules', '.bin', 'tsx'); +const CLI_PATH = path.join( + ROOT_DIR, + 'packages', + 'messenger-cli', + 'src', + 'cli.ts', +); + +/** + * Runs the CLI with the given arguments. + * + * @param args - The CLI arguments. + * @returns The execa result. + */ +async function runCLI(args: string[]): Promise { + return await execa(TSX_PATH, [CLI_PATH, ...args], { + cwd: ROOT_DIR, + reject: false, + all: true, + }); +} + +/** + * Recursively lists generated `-method-action-types.ts` files in a directory. + * + * @param dir - The directory to search. + * @returns Sorted list of relative paths to generated files. + */ +async function listGeneratedFiles(dir: string): Promise { + const results: string[] = []; + + async function walk(current: string): Promise { + const entries = await fs.promises.readdir(current, { withFileTypes: true }); + for (const entry of entries) { + const fullPath = path.join(current, entry.name); + if (entry.isDirectory()) { + await walk(fullPath); + } else if (entry.name.endsWith('-method-action-types.ts')) { + results.push(path.relative(dir, fullPath)); + } + } + } + + await walk(dir); + return results.sort(); +} + +const { withinSandbox } = createSandbox('messenger/cli-functional'); + +jest.setTimeout(30_000); + +describe('generate-action-types CLI (functional)', () => { + describe('--generate', () => { + it('generates FooController-method-action-types.ts for a controller with multiple documented methods', async () => { + expect.assertions(3); + + await withinSandbox(async ({ directoryPath }) => { + await fs.promises.writeFile( + path.join(directoryPath, 'FooController.ts'), + ` +const MESSENGER_EXPOSED_METHODS = ['getState', 'reset'] as const; + +class FooController { + /** + * Gets the current state. + */ + getState() { + return {}; + } + + /** + * Resets the controller. + */ + reset() { + return; + } +} +`, + 'utf8', + ); + + const result = await runCLI(['--generate', directoryPath]); + expect(result.exitCode).toBe(0); + + const generatedFiles = await listGeneratedFiles(directoryPath); + expect(generatedFiles).toStrictEqual([ + 'FooController-method-action-types.ts', + ]); + + const content = await fs.promises.readFile( + path.join(directoryPath, 'FooController-method-action-types.ts'), + 'utf8', + ); + expect(content).toMatchInlineSnapshot(` + "/** + * This file is auto generated. + * Do not edit manually. + */ + + import type { FooController } from './FooController'; + + /** + * Gets the current state. + */ + export type FooControllerGetStateAction = { + type: \`FooController:getState\`; + handler: FooController['getState']; + }; + + /** + * Resets the controller. + */ + export type FooControllerResetAction = { + type: \`FooController:reset\`; + handler: FooController['reset']; + }; + + /** + * Union of all FooController action types. + */ + export type FooControllerMethodActions = + | FooControllerGetStateAction + | FooControllerResetAction; + " + `); + }); + }); + + it('generates DataService-method-action-types.ts for a service with JSDoc containing @param and @returns', async () => { + expect.assertions(3); + + await withinSandbox(async ({ directoryPath }) => { + await fs.promises.writeFile( + path.join(directoryPath, 'DataService.ts'), + ` +const MESSENGER_EXPOSED_METHODS = ['fetchItems'] as const; + +class DataService { + /** + * Fetches items from the API. + * + * @returns The items. + */ + fetchItems() { + return []; + } +} +`, + 'utf8', + ); + + const result = await runCLI(['--generate', directoryPath]); + expect(result.exitCode).toBe(0); + + const generatedFiles = await listGeneratedFiles(directoryPath); + expect(generatedFiles).toStrictEqual([ + 'DataService-method-action-types.ts', + ]); + + const content = await fs.promises.readFile( + path.join(directoryPath, 'DataService-method-action-types.ts'), + 'utf8', + ); + expect(content).toMatchInlineSnapshot(` + "/** + * This file is auto generated. + * Do not edit manually. + */ + + import type { DataService } from './DataService'; + + /** + * Fetches items from the API. + * + * @returns The items. + */ + export type DataServiceFetchItemsAction = { + type: \`DataService:fetchItems\`; + handler: DataService['fetchItems']; + }; + + /** + * Union of all DataService action types. + */ + export type DataServiceMethodActions = DataServiceFetchItemsAction; + " + `); + }); + }); + + it('generates correct types for a controller with many methods without JSDoc', async () => { + expect.assertions(3); + + await withinSandbox(async ({ directoryPath }) => { + await fs.promises.writeFile( + path.join(directoryPath, 'BarController.ts'), + ` +const MESSENGER_EXPOSED_METHODS = ['enable', 'disable', 'isEnabled'] as const; + +class BarController { + enable() { return; } + disable() { return; } + isEnabled() { return true; } +} +`, + 'utf8', + ); + + const result = await runCLI(['--generate', directoryPath]); + expect(result.exitCode).toBe(0); + + const generatedFiles = await listGeneratedFiles(directoryPath); + expect(generatedFiles).toStrictEqual([ + 'BarController-method-action-types.ts', + ]); + + const content = await fs.promises.readFile( + path.join(directoryPath, 'BarController-method-action-types.ts'), + 'utf8', + ); + expect(content).toMatchInlineSnapshot(` + "/** + * This file is auto generated. + * Do not edit manually. + */ + + import type { BarController } from './BarController'; + + export type BarControllerEnableAction = { + type: \`BarController:enable\`; + handler: BarController['enable']; + }; + + export type BarControllerDisableAction = { + type: \`BarController:disable\`; + handler: BarController['disable']; + }; + + export type BarControllerIsEnabledAction = { + type: \`BarController:isEnabled\`; + handler: BarController['isEnabled']; + }; + + /** + * Union of all BarController action types. + */ + export type BarControllerMethodActions = + | BarControllerEnableAction + | BarControllerDisableAction + | BarControllerIsEnabledAction; + " + `); + }); + }); + + it('generates AuthService-method-action-types.ts for a service with @param and @returns JSDoc', async () => { + expect.assertions(3); + + await withinSandbox(async ({ directoryPath }) => { + await fs.promises.writeFile( + path.join(directoryPath, 'AuthService.ts'), + ` +const MESSENGER_EXPOSED_METHODS = ['authenticate'] as const; + +class AuthService { + /** + * Authenticates the user. + * + * @param token - The auth token. + * @returns Whether authentication succeeded. + */ + authenticate(token: string) { + return token.length > 0; + } +} +`, + 'utf8', + ); + + const result = await runCLI(['--generate', directoryPath]); + expect(result.exitCode).toBe(0); + + const generatedFiles = await listGeneratedFiles(directoryPath); + expect(generatedFiles).toStrictEqual([ + 'AuthService-method-action-types.ts', + ]); + + const content = await fs.promises.readFile( + path.join(directoryPath, 'AuthService-method-action-types.ts'), + 'utf8', + ); + expect(content).toMatchInlineSnapshot(` + "/** + * This file is auto generated. + * Do not edit manually. + */ + + import type { AuthService } from './AuthService'; + + /** + * Authenticates the user. + * + * @param token - The auth token. + * @returns Whether authentication succeeded. + */ + export type AuthServiceAuthenticateAction = { + type: \`AuthService:authenticate\`; + handler: AuthService['authenticate']; + }; + + /** + * Union of all AuthService action types. + */ + export type AuthServiceMethodActions = AuthServiceAuthenticateAction; + " + `); + }); + }); + + it('generates separate files for both a controller and service in the same directory', async () => { + expect.assertions(8); + + await withinSandbox(async ({ directoryPath }) => { + await fs.promises.writeFile( + path.join(directoryPath, 'MyController.ts'), + ` +const MESSENGER_EXPOSED_METHODS = ['doWork'] as const; +class MyController { + doWork() { return true; } +} +`, + 'utf8', + ); + await fs.promises.writeFile( + path.join(directoryPath, 'MyService.ts'), + ` +const MESSENGER_EXPOSED_METHODS = ['query'] as const; +class MyService { + query() { return []; } +} +`, + 'utf8', + ); + + const result = await runCLI(['--generate', directoryPath]); + expect(result.exitCode).toBe(0); + + const generatedFiles = await listGeneratedFiles(directoryPath); + expect(generatedFiles).toStrictEqual([ + 'MyController-method-action-types.ts', + 'MyService-method-action-types.ts', + ]); + + const controllerContent = await fs.promises.readFile( + path.join(directoryPath, 'MyController-method-action-types.ts'), + 'utf8', + ); + expect(controllerContent).toContain('MyControllerDoWorkAction'); + expect(controllerContent).toContain("handler: MyController['doWork']"); + expect(controllerContent).toContain('MyControllerMethodActions'); + + const serviceContent = await fs.promises.readFile( + path.join(directoryPath, 'MyService-method-action-types.ts'), + 'utf8', + ); + expect(serviceContent).toContain('MyServiceQueryAction'); + expect(serviceContent).toContain("handler: MyService['query']"); + expect(serviceContent).toContain('MyServiceMethodActions'); + }); + }); + + it('discovers and generates files for sources in nested subdirectories', async () => { + expect.assertions(4); + + await withinSandbox(async ({ directoryPath }) => { + const subDir = path.join(directoryPath, 'nested'); + await fs.promises.mkdir(subDir); + await fs.promises.writeFile( + path.join(subDir, 'NestedController.ts'), + ` +const MESSENGER_EXPOSED_METHODS = ['doNested'] as const; +class NestedController { + doNested() { return 'nested'; } +} +`, + 'utf8', + ); + + const result = await runCLI(['--generate', directoryPath]); + expect(result.exitCode).toBe(0); + + const generatedFiles = await listGeneratedFiles(directoryPath); + expect(generatedFiles).toStrictEqual([ + path.join('nested', 'NestedController-method-action-types.ts'), + ]); + + const content = await fs.promises.readFile( + path.join(subDir, 'NestedController-method-action-types.ts'), + 'utf8', + ); + expect(content).toContain('NestedControllerDoNestedAction'); + expect(content).toContain("handler: NestedController['doNested']"); + }); + }); + + it('warns and generates no files when no sources are found', async () => { + expect.assertions(3); + + await withinSandbox(async ({ directoryPath }) => { + await fs.promises.writeFile( + path.join(directoryPath, 'empty.ts'), + 'export const foo = 1;', + 'utf8', + ); + + const result = await runCLI(['--generate', directoryPath]); + expect(result.exitCode).toBe(0); + expect(result.all).toContain('No controllers/services found'); + + const generatedFiles = await listGeneratedFiles(directoryPath); + expect(generatedFiles).toStrictEqual([]); + }); + }); + }); + + describe('--esm', () => { + it('adds .js extension to import paths in generated files', async () => { + expect.assertions(3); + + await withinSandbox(async ({ directoryPath }) => { + await fs.promises.writeFile( + path.join(directoryPath, 'FooController.ts'), + ` +const MESSENGER_EXPOSED_METHODS = ['doSomething'] as const; + +class FooController { + doSomething() { + return true; + } +} +`, + 'utf8', + ); + + const result = await runCLI(['--generate', '--esm', directoryPath]); + expect(result.exitCode).toBe(0); + + const generatedFiles = await listGeneratedFiles(directoryPath); + expect(generatedFiles).toStrictEqual([ + 'FooController-method-action-types.ts', + ]); + + const content = await fs.promises.readFile( + path.join(directoryPath, 'FooController-method-action-types.ts'), + 'utf8', + ); + expect(content).toMatchInlineSnapshot(` + "/** + * This file is auto generated. + * Do not edit manually. + */ + + import type { FooController } from './FooController.js'; + + export type FooControllerDoSomethingAction = { + type: \`FooController:doSomething\`; + handler: FooController['doSomething']; + }; + + /** + * Union of all FooController action types. + */ + export type FooControllerMethodActions = FooControllerDoSomethingAction; + " + `); + }); + }); + + it('--check passes for files generated with --esm', async () => { + expect.assertions(2); + + await withinSandbox(async ({ directoryPath }) => { + await fs.promises.writeFile( + path.join(directoryPath, 'FooController.ts'), + ` +const MESSENGER_EXPOSED_METHODS = ['doSomething'] as const; + +class FooController { + doSomething() { + return true; + } +} +`, + 'utf8', + ); + + await runCLI(['--generate', '--esm', directoryPath]); + const result = await runCLI(['--check', '--esm', directoryPath]); + + expect(result.exitCode).toBe(0); + expect(result.all).toContain('up to date'); + }); + }); + }); + + describe('--check', () => { + it('exits 0 when generated files are up to date', async () => { + expect.assertions(2); + + await withinSandbox(async ({ directoryPath }) => { + await fs.promises.writeFile( + path.join(directoryPath, 'TestController.ts'), + ` +const MESSENGER_EXPOSED_METHODS = ['doStuff'] as const; +class TestController { + doStuff() { return true; } +} +`, + 'utf8', + ); + + await runCLI(['--generate', directoryPath]); + const result = await runCLI(['--check', directoryPath]); + + expect(result.exitCode).toBe(0); + expect(result.all).toContain('up to date'); + }); + }); + + it('exits 1 when generated files are out of date', async () => { + expect.assertions(2); + + await withinSandbox(async ({ directoryPath }) => { + await fs.promises.writeFile( + path.join(directoryPath, 'TestController.ts'), + ` +const MESSENGER_EXPOSED_METHODS = ['doStuff'] as const; +class TestController { + doStuff() { return true; } +} +`, + 'utf8', + ); + await fs.promises.writeFile( + path.join(directoryPath, 'TestController-method-action-types.ts'), + '// outdated\n', + 'utf8', + ); + + const result = await runCLI(['--check', directoryPath]); + + expect(result.exitCode).toBe(1); + expect(result.all).toContain('out of date'); + }); + }); + + it('exits 1 when generated files are missing', async () => { + expect.assertions(2); + + await withinSandbox(async ({ directoryPath }) => { + await fs.promises.writeFile( + path.join(directoryPath, 'TestController.ts'), + ` +const MESSENGER_EXPOSED_METHODS = ['doStuff'] as const; +class TestController { + doStuff() { return true; } +} +`, + 'utf8', + ); + + const result = await runCLI(['--check', directoryPath]); + + expect(result.exitCode).toBe(1); + expect(result.all).toContain('does not exist'); + }); + }); + }); + + describe('argument validation', () => { + it('exits 1 when neither --check nor --fix is provided', async () => { + expect.assertions(1); + + await withinSandbox(async ({ directoryPath }) => { + const result = await runCLI([directoryPath]); + expect(result.exitCode).toBe(1); + }); + }); + }); +}); diff --git a/packages/messenger-cli/src/cli.ts b/packages/messenger-cli/src/cli.ts new file mode 100644 index 00000000000..88aabc73c8a --- /dev/null +++ b/packages/messenger-cli/src/cli.ts @@ -0,0 +1,124 @@ +#!/usr/bin/env node + +import yargs from 'yargs'; + +import { checkActionTypesFiles } from './check.js'; +import { generateAllActionTypesFiles } from './fix.js'; +import { findSourcesWithExposedMethods } from './parse-source.js'; +import { Formatter } from './types.js'; + +type CommandLineArguments = { + check: boolean; + generate: boolean; + formatter: Formatter; + sourcePath: string; + esm: boolean; +}; + +/** + * Parses the given CLI arguments. + * + * @param args - The arguments to parse. + * @returns The parsed command line arguments. + */ +async function parseCommandLineArguments( + args: string[], +): Promise { + const { + check, + generate, + formatter, + esm, + path: sourcePath, + } = await yargs(args) + .command( + '$0 [path]', + 'Generate method action types for controller and service messengers', + (yargsInstance) => { + yargsInstance.positional('path', { + type: 'string', + description: + 'Path to the folder where controllers/services are located', + default: 'src', + }); + }, + ) + .option('check', { + type: 'boolean', + description: 'Check if generated action type files are up to date', + default: false, + }) + .option('generate', { + type: 'boolean', + description: 'Generate/update action type files', + default: false, + }) + .option('formatter', { + type: 'string', + description: 'The formatter to use for formatting generated files', + choices: ['oxfmt', 'prettier'], + default: 'prettier', + }) + .option('esm', { + type: 'boolean', + description: 'Add .js extensions to import paths for ESM compatibility', + default: false, + }) + .help() + .check((argv) => { + if (!argv.check && !argv.generate) { + throw new Error('Either --check or --generate must be provided.\n'); + } + return true; + }).argv; + + return { + check, + generate, + formatter: formatter as Formatter, + sourcePath: sourcePath as string, + esm, + }; +} + +/** + * Main entry point for the CLI. + */ +async function main(): Promise { + const { generate, sourcePath, formatter, esm } = + await parseCommandLineArguments(globalThis.process.argv.slice(2)); + + console.log( + '🔍 Searching for controllers/services with MESSENGER_EXPOSED_METHODS...', + ); + + const sources = await findSourcesWithExposedMethods(sourcePath); + + if (sources.length === 0) { + console.log( + '⚠️ No controllers/services found with MESSENGER_EXPOSED_METHODS', + ); + return; + } + + console.log( + `📦 Found ${sources.length} controller(s)/service(s) with exposed methods`, + ); + + if (generate) { + await generateAllActionTypesFiles(sources, formatter, esm); + console.log('\n🎉 All action types generated successfully!'); + } else { + const success = await checkActionTypesFiles(sources, formatter, esm); + if (!success) { + // eslint-disable-next-line no-restricted-globals + process.exitCode = 1; + } + } +} + +main().catch((error) => { + console.error('❌ Script failed:', error); + // eslint-disable-next-line no-restricted-globals + process.exitCode = 1; +}); diff --git a/packages/messenger-cli/src/fix.test.ts b/packages/messenger-cli/src/fix.test.ts new file mode 100644 index 00000000000..dd436ca363b --- /dev/null +++ b/packages/messenger-cli/src/fix.test.ts @@ -0,0 +1,75 @@ +import { createSandbox } from '@metamask/utils/node'; +import * as fs from 'node:fs'; +import * as path from 'node:path'; + +import { generateAllActionTypesFiles } from './fix.js'; +import { generateActionTypesContent } from './generate-content.js'; +import type { SourceInfo } from './parse-source.js'; + +const { withinSandbox } = createSandbox('messenger/fix-action-types'); + +describe('generateAllActionTypesFiles', () => { + it('generates files for controllers (no ESLint)', async () => { + expect.assertions(1); + + await withinSandbox(async ({ directoryPath }) => { + const controller: SourceInfo = { + name: 'TestController', + filePath: path.join(directoryPath, 'TestController.ts'), + + methods: [{ name: 'doStuff', jsDoc: '' }], + }; + + const consoleSpy = jest.spyOn(console, 'log').mockImplementation(); + await generateAllActionTypesFiles([controller], 'prettier'); + consoleSpy.mockRestore(); + + const outputFile = path.join( + directoryPath, + 'TestController-method-action-types.ts', + ); + const content = await fs.promises.readFile(outputFile, 'utf8'); + const expected = await generateActionTypesContent(controller, 'prettier'); + + expect(content).toBe(expected); + }); + }); + + it('generates files for multiple controllers', async () => { + expect.assertions(2); + + await withinSandbox(async ({ directoryPath }) => { + const controllers: SourceInfo[] = [ + { + name: 'FooController', + filePath: path.join(directoryPath, 'FooController.ts'), + methods: [{ name: 'doFoo', jsDoc: '' }], + }, + { + name: 'BarService', + filePath: path.join(directoryPath, 'BarService.ts'), + methods: [{ name: 'doBar', jsDoc: '' }], + }, + ]; + + const consoleSpy = jest.spyOn(console, 'log').mockImplementation(); + await generateAllActionTypesFiles(controllers, 'prettier'); + consoleSpy.mockRestore(); + + const fooFile = path.join( + directoryPath, + 'FooController-method-action-types.ts', + ); + const barFile = path.join( + directoryPath, + 'BarService-method-action-types.ts', + ); + + const fooContent = await fs.promises.readFile(fooFile, 'utf8'); + const barContent = await fs.promises.readFile(barFile, 'utf8'); + + expect(fooContent).toContain('FooController'); + expect(barContent).toContain('BarService'); + }); + }); +}); diff --git a/packages/messenger-cli/src/fix.ts b/packages/messenger-cli/src/fix.ts new file mode 100644 index 00000000000..80de4222212 --- /dev/null +++ b/packages/messenger-cli/src/fix.ts @@ -0,0 +1,40 @@ +import * as fs from 'node:fs'; +import * as path from 'node:path'; + +import { generateActionTypesContent } from './generate-content.js'; +import type { SourceInfo } from './parse-source.js'; +import type { Formatter } from './types.js'; + +/** + * Generates action types files for all controllers/services. + * + * @param sources - Array of source information objects. + * @param formatter - The formatter to use for formatting the generated content. + * @param esm - Whether to add `.js` extensions to import paths for ESM + * compatibility. + * @returns Whether all files were generated successfully. + */ +export async function generateAllActionTypesFiles( + sources: SourceInfo[], + formatter: Formatter, + esm = false, +): Promise { + for (const source of sources) { + console.log(`\n🔧 Processing ${source.name}...`); + const outputDir = path.dirname(source.filePath); + const baseFileName = path.basename(source.filePath, '.ts'); + const outputFile = path.join( + outputDir, + `${baseFileName}-method-action-types.ts`, + ); + + const generatedContent = await generateActionTypesContent( + source, + formatter, + esm, + ); + + await fs.promises.writeFile(outputFile, generatedContent, 'utf8'); + console.log(`✅ Generated action types for ${source.name}`); + } +} diff --git a/packages/messenger-cli/src/generate-content.test.ts b/packages/messenger-cli/src/generate-content.test.ts new file mode 100644 index 00000000000..023389f6068 --- /dev/null +++ b/packages/messenger-cli/src/generate-content.test.ts @@ -0,0 +1,176 @@ +import { generateActionTypesContent } from './generate-content.js'; +import type { SourceInfo } from './parse-source.js'; + +describe('generateActionTypesContent', () => { + it('generates action types for a controller with one method', async () => { + const controller: SourceInfo = { + name: 'FooController', + filePath: '/some/path/FooController.ts', + + methods: [ + { + name: 'doSomething', + jsDoc: '', + }, + ], + }; + + const result = await generateActionTypesContent(controller, 'prettier'); + expect(result).toMatchInlineSnapshot(` + "/** + * This file is auto generated. + * Do not edit manually. + */ + + import type { FooController } from './FooController'; + + export type FooControllerDoSomethingAction = { + type: \`FooController:doSomething\`; + handler: FooController['doSomething']; + }; + + /** + * Union of all FooController action types. + */ + export type FooControllerMethodActions = FooControllerDoSomethingAction; + " + `); + }); + + it('generates action types for a controller with multiple methods', async () => { + const controller: SourceInfo = { + name: 'BarController', + filePath: '/some/path/BarController.ts', + + methods: [ + { name: 'methodA', jsDoc: '' }, + { name: 'methodB', jsDoc: '' }, + ], + }; + + const result = await generateActionTypesContent(controller, 'prettier'); + expect(result).toMatchInlineSnapshot(` + "/** + * This file is auto generated. + * Do not edit manually. + */ + + import type { BarController } from './BarController'; + + export type BarControllerMethodAAction = { + type: \`BarController:methodA\`; + handler: BarController['methodA']; + }; + + export type BarControllerMethodBAction = { + type: \`BarController:methodB\`; + handler: BarController['methodB']; + }; + + /** + * Union of all BarController action types. + */ + export type BarControllerMethodActions = + | BarControllerMethodAAction + | BarControllerMethodBAction; + " + `); + }); + + it('formats the generated content with Oxfmt if specified', async () => { + const controller: SourceInfo = { + name: 'BazController', + filePath: '/some/path/BazController.ts', + + methods: [{ name: 'doSomething', jsDoc: '' }], + }; + + const result = await generateActionTypesContent(controller, 'oxfmt'); + expect(result).toMatchInlineSnapshot(` + "/** + * This file is auto generated. + * Do not edit manually. + */ + + import type { BazController } from './BazController'; + + export type BazControllerDoSomethingAction = { + type: \`BazController:doSomething\`; + handler: BazController['doSomething']; + }; + + /** + * Union of all BazController action types. + */ + export type BazControllerMethodActions = BazControllerDoSomethingAction; + " + `); + }); + + it('includes JSDoc comments when present', async () => { + const controller: SourceInfo = { + name: 'FooController', + filePath: '/some/path/FooController.ts', + + methods: [ + { + name: 'doSomething', + jsDoc: '/**\n * Does something.\n */', + }, + ], + }; + + const result = await generateActionTypesContent(controller, 'prettier'); + + expect(result).toContain('/**\n * Does something.\n */'); + }); + + it('generates no union type for controllers with no methods', async () => { + const controller: SourceInfo = { + name: 'EmptyController', + filePath: '/some/path/EmptyController.ts', + + methods: [], + }; + + const result = await generateActionTypesContent(controller, 'prettier'); + + expect(result).not.toContain('EmptyControllerMethodActions'); + }); + + describe('with esm: true', () => { + it('adds .js extension to the import path', async () => { + const controller: SourceInfo = { + name: 'FooController', + filePath: '/some/path/FooController.ts', + + methods: [{ name: 'doSomething', jsDoc: '' }], + }; + + const result = await generateActionTypesContent( + controller, + 'prettier', + true, + ); + expect(result).toMatchInlineSnapshot(` + "/** + * This file is auto generated. + * Do not edit manually. + */ + + import type { FooController } from './FooController.js'; + + export type FooControllerDoSomethingAction = { + type: \`FooController:doSomething\`; + handler: FooController['doSomething']; + }; + + /** + * Union of all FooController action types. + */ + export type FooControllerMethodActions = FooControllerDoSomethingAction; + " + `); + }); + }); +}); diff --git a/packages/messenger-cli/src/generate-content.ts b/packages/messenger-cli/src/generate-content.ts new file mode 100644 index 00000000000..89cd2b41f22 --- /dev/null +++ b/packages/messenger-cli/src/generate-content.ts @@ -0,0 +1,151 @@ +import { assertExhaustive, getErrorMessage } from '@metamask/utils'; +import * as path from 'node:path'; + +import type { SourceInfo } from './parse-source.js'; +import { Formatter } from './types.js'; + +/** + * The default options used by Oxfmt and Prettier when formatting the generated + * content. In the case of Prettier, these options will be used if the user does + * not have a Prettier configuration file in their project. Oxfmt doesn't have a + * `resolveConfig` function like Prettier, so it will always use these options + * when formatting. + */ +const DEFAULT_FORMATTING_OPTIONS = { + printWidth: 80, + singleQuote: true, +}; + +/** + * Safely format a TypeScript file with Prettier. If Prettier is not installed, + * it will throw an error with a clear message. This allows us to use Prettier + * for formatting when available, without making it a hard dependency of the + * project. + * + * @param contents - The source code to format. + * @param filePath - The file path to use for resolving Prettier configuration. + * @returns The formatted source code. + */ +async function prettier(contents: string, filePath: string): Promise { + try { + const { format, resolveConfig } = await import('prettier'); + + const config = + (await resolveConfig(filePath)) ?? DEFAULT_FORMATTING_OPTIONS; + + return await format(contents, { + ...config, + parser: 'typescript', + }); + } catch (error) { + const message = getErrorMessage(error); + throw new Error( + `Failed to format source code with Prettier. Is Prettier installed?\n\n${message}`, + ); + } +} + +/** + * Safely format a TypeScript file with Oxfmt. If Oxfmt is not installed, it + * will throw an error with a clear message. This allows us to use Oxfmt for + * formatting when available, without making it a hard dependency of the + * project. + * + * @param contents - The source code to format. + * @param filePath - The file path to use for resolving Oxfmt configuration. Not + * currently used, but included for future extensibility. + * @returns The formatted source code. + */ +async function oxfmt(contents: string, filePath: string): Promise { + try { + const { format } = await import('oxfmt'); + const result = await format(filePath, contents, DEFAULT_FORMATTING_OPTIONS); + + return result.code; + } catch (error) { + const message = getErrorMessage(error); + throw new Error( + `Failed to format source code with Oxfmt. Is Oxfmt installed?\n\n${message}`, + ); + } +} + +/** + * Get the appropriate formatter function based on the specified formatter. + * + * @param formatter - The formatter to use. + * @returns A function that takes source code as input and returns the formatted + * source code. + */ +function getFormatter( + formatter: Formatter, +): (contents: string, filePath: string) => Promise { + switch (formatter) { + case 'prettier': + return prettier; + + case 'oxfmt': + return oxfmt; + + default: + return assertExhaustive(formatter); + } +} + +/** + * Generates the content for the action types file. + * + * @param source - The source information object (controller or service). + * @param formatter - The formatter to use for formatting the generated content. + * @param esm - Whether to add `.js` extensions to import paths for ESM + * compatibility. + * @returns The content for the action types file. + */ +export async function generateActionTypesContent( + source: SourceInfo, + formatter: Formatter, + esm = false, +): Promise { + const baseFileName = path.basename(source.filePath, '.ts'); + const sourceImportPath = `./${baseFileName}${esm ? '.js' : ''}`; + + let content = `/** + * This file is auto generated. + * Do not edit manually. + */ + +import type { ${source.name} } from '${sourceImportPath}'; + +`; + + const actionTypeNames: string[] = []; + + for (const method of source.methods) { + const capitalizedName = + method.name.charAt(0).toUpperCase() + method.name.slice(1); + const actionTypeName = `${source.name}${capitalizedName}Action`; + const actionString = `${source.name}:${method.name}`; + + actionTypeNames.push(actionTypeName); + + if (method.jsDoc) { + content += `${method.jsDoc}\n`; + } + + content += `export type ${actionTypeName} = { + type: \`${actionString}\`; + handler: ${source.name}['${method.name}']; +};\n\n`; + } + + if (actionTypeNames.length > 0) { + const unionTypeName = `${source.name}MethodActions`; + content += `/** + * Union of all ${source.name} action types. + */ +export type ${unionTypeName} = ${actionTypeNames.join(' | ')};\n`; + } + + const formatterFunction = getFormatter(formatter); + return await formatterFunction(content, source.filePath); +} diff --git a/packages/messenger-cli/src/parse-source.test.ts b/packages/messenger-cli/src/parse-source.test.ts new file mode 100644 index 00000000000..f80a44d4cfb --- /dev/null +++ b/packages/messenger-cli/src/parse-source.test.ts @@ -0,0 +1,627 @@ +import { createSandbox } from '@metamask/utils/node'; +import * as fs from 'node:fs'; +import * as path from 'node:path'; + +import { + findSourcesWithExposedMethods, + parseSourceFile, +} from './parse-source.js'; + +const { withinSandbox: withinParseSourceSandbox } = createSandbox( + 'messenger/parse-source', +); +const { withinSandbox: withinFindControllersSandbox } = createSandbox( + 'messenger/find-controllers', +); + +describe('parseSourceFile', () => { + it('extracts controller info from a file with MESSENGER_EXPOSED_METHODS', async () => { + expect.assertions(1); + + await withinParseSourceSandbox(async ({ directoryPath }) => { + const controllerFile = path.join(directoryPath, 'TestController.ts'); + await fs.promises.writeFile( + controllerFile, + ` +const MESSENGER_EXPOSED_METHODS = ['doStuff'] as const; + +class TestController { + /** + * Does stuff. + */ + doStuff() { + return true; + } +} +`, + 'utf8', + ); + + const result = await parseSourceFile(controllerFile); + + expect(result).toStrictEqual({ + name: 'TestController', + filePath: controllerFile, + methods: [ + { + name: 'doStuff', + jsDoc: '/**\n * Does stuff.\n */', + }, + ], + }); + }); + }); + + it('returns null for a file without MESSENGER_EXPOSED_METHODS', async () => { + expect.assertions(1); + + await withinParseSourceSandbox(async ({ directoryPath }) => { + const controllerFile = path.join(directoryPath, 'NoExposed.ts'); + await fs.promises.writeFile( + controllerFile, + ` +class NoExposedController { + doStuff() { + return true; + } +} +`, + 'utf8', + ); + + const result = await parseSourceFile(controllerFile); + + expect(result).toBeNull(); + }); + }); + + it('returns null for a file with empty MESSENGER_EXPOSED_METHODS', async () => { + expect.assertions(1); + + await withinParseSourceSandbox(async ({ directoryPath }) => { + const controllerFile = path.join(directoryPath, 'EmptyController.ts'); + await fs.promises.writeFile( + controllerFile, + ` +const MESSENGER_EXPOSED_METHODS = [] as const; + +class EmptyController { + doStuff() { + return true; + } +} +`, + 'utf8', + ); + + const result = await parseSourceFile(controllerFile); + + expect(result).toBeNull(); + }); + }); + + it('handles array literals without as const', async () => { + expect.assertions(2); + + await withinParseSourceSandbox(async ({ directoryPath }) => { + const controllerFile = path.join( + directoryPath, + 'PlainArrayController.ts', + ); + await fs.promises.writeFile( + controllerFile, + ` +const MESSENGER_EXPOSED_METHODS = ['doStuff']; + +class PlainArrayController { + doStuff() { + return true; + } +} +`, + 'utf8', + ); + + const result = await parseSourceFile(controllerFile); + + expect(result).not.toBeNull(); + expect(result?.methods.map((method) => method.name)).toStrictEqual([ + 'doStuff', + ]); + }); + }); + + it('works with Service class names', async () => { + expect.assertions(2); + + await withinParseSourceSandbox(async ({ directoryPath }) => { + const serviceFile = path.join(directoryPath, 'TestService.ts'); + await fs.promises.writeFile( + serviceFile, + ` +const MESSENGER_EXPOSED_METHODS = ['fetchData'] as const; + +class TestService { + fetchData() { + return []; + } +} +`, + 'utf8', + ); + + const result = await parseSourceFile(serviceFile); + + expect(result).not.toBeNull(); + expect(result?.name).toBe('TestService'); + }); + }); + + it('extracts methods without JSDoc', async () => { + expect.assertions(2); + + await withinParseSourceSandbox(async ({ directoryPath }) => { + const controllerFile = path.join(directoryPath, 'NoDocController.ts'); + await fs.promises.writeFile( + controllerFile, + ` +const MESSENGER_EXPOSED_METHODS = ['doStuff'] as const; + +class NoDocController { + doStuff() { + return true; + } +} +`, + 'utf8', + ); + + const result = await parseSourceFile(controllerFile); + + expect(result).not.toBeNull(); + expect(result?.methods[0].jsDoc).toBe(''); + }); + }); + + it('handles inherited methods via type checker', async () => { + expect.assertions(5); + + await withinParseSourceSandbox(async ({ directoryPath }) => { + // Create a tsconfig.json so the type checker can work + await fs.promises.writeFile( + path.join(directoryPath, 'tsconfig.json'), + JSON.stringify({ + compilerOptions: { + target: 'ES2020', + module: 'commonjs', + strict: true, + }, + include: ['./*.ts'], + }), + 'utf8', + ); + + await fs.promises.writeFile( + path.join(directoryPath, 'BaseController.ts'), + ` +export class BaseController { + /** + * Base method. + */ + baseMethod() { + return 'base'; + } +} +`, + 'utf8', + ); + + const controllerFile = path.join(directoryPath, 'ChildController.ts'); + await fs.promises.writeFile( + controllerFile, + ` +import { BaseController } from './BaseController.js'; + +const MESSENGER_EXPOSED_METHODS = ['doStuff', 'baseMethod'] as const; + +class ChildController extends BaseController { + doStuff() { + return true; + } +} +`, + 'utf8', + ); + + const result = await parseSourceFile(controllerFile); + + expect(result).not.toBeNull(); + expect(result?.methods).toHaveLength(2); + expect(result?.methods[0].name).toBe('doStuff'); + expect(result?.methods[1].name).toBe('baseMethod'); + expect(result?.methods[1].jsDoc).toContain('Base method.'); + }); + }); + + it('handles inherited methods without JSDoc', async () => { + expect.assertions(4); + + await withinParseSourceSandbox(async ({ directoryPath }) => { + await fs.promises.writeFile( + path.join(directoryPath, 'tsconfig.json'), + JSON.stringify({ + compilerOptions: { + target: 'ES2020', + module: 'commonjs', + strict: true, + }, + include: ['./*.ts'], + }), + 'utf8', + ); + + await fs.promises.writeFile( + path.join(directoryPath, 'BaseNoDoc.ts'), + ` +export class BaseNoDoc { + baseMethod() { + return 'base'; + } +} +`, + 'utf8', + ); + + const controllerFile = path.join( + directoryPath, + 'ChildNoDocController.ts', + ); + await fs.promises.writeFile( + controllerFile, + ` +import { BaseNoDoc } from './BaseNoDoc.js'; + +const MESSENGER_EXPOSED_METHODS = ['doStuff', 'baseMethod'] as const; + +class ChildNoDocController extends BaseNoDoc { + doStuff() { + return true; + } +} +`, + 'utf8', + ); + + const result = await parseSourceFile(controllerFile); + + expect(result).not.toBeNull(); + expect(result?.methods).toHaveLength(2); + expect(result?.methods[1].name).toBe('baseMethod'); + // Method without JSDoc should have empty string + expect(result?.methods[1].jsDoc).toBe(''); + }); + }); + + it('handles exposed method not found in hierarchy', async () => { + expect.assertions(4); + + await withinParseSourceSandbox(async ({ directoryPath }) => { + await fs.promises.writeFile( + path.join(directoryPath, 'tsconfig.json'), + JSON.stringify({ + compilerOptions: { + target: 'ES2020', + module: 'commonjs', + strict: true, + }, + include: ['./*.ts'], + }), + 'utf8', + ); + + const controllerFile = path.join( + directoryPath, + 'MissingMethodController.ts', + ); + await fs.promises.writeFile( + controllerFile, + ` +const MESSENGER_EXPOSED_METHODS = ['doStuff', 'nonExistentMethod'] as const; + +class MissingMethodController { + doStuff() { + return true; + } +} +`, + 'utf8', + ); + + const result = await parseSourceFile(controllerFile); + + expect(result).not.toBeNull(); + expect(result?.methods).toHaveLength(2); + expect(result?.methods[1].name).toBe('nonExistentMethod'); + expect(result?.methods[1].jsDoc).toBe(''); + }); + }); + + it('formats JSDoc with empty middle lines', async () => { + expect.assertions(4); + + await withinParseSourceSandbox(async ({ directoryPath }) => { + const controllerFile = path.join( + directoryPath, + 'EmptyLineDocController.ts', + ); + await fs.promises.writeFile( + controllerFile, + ` +const MESSENGER_EXPOSED_METHODS = ['doStuff'] as const; + +class EmptyLineDocController { + /** + * First line. + * + * After empty line. + */ + doStuff() { + return true; + } +} +`, + 'utf8', + ); + + const result = await parseSourceFile(controllerFile); + + expect(result).not.toBeNull(); + expect(result?.methods[0].jsDoc).toContain(' *\n'); + expect(result?.methods[0].jsDoc).toContain(' * First line.'); + expect(result?.methods[0].jsDoc).toContain(' * After empty line.'); + }); + }); + + it('extracts JSDoc with non-standard middle lines', async () => { + expect.assertions(3); + + await withinParseSourceSandbox(async ({ directoryPath }) => { + const controllerFile = path.join(directoryPath, 'WeirdDocController.ts'); + // Write file with a JSDoc containing a line without * prefix and an empty line without * prefix + const source = [ + '', + "const MESSENGER_EXPOSED_METHODS = ['doStuff'] as const;", + '', + 'class WeirdDocController {', + ' /**', + ' This line has no asterisk prefix.', + ' ', + ' */', + ' doStuff() {', + ' return true;', + ' }', + '}', + '', + ].join('\n'); + await fs.promises.writeFile(controllerFile, source, 'utf8'); + + const result = await parseSourceFile(controllerFile); + + expect(result).not.toBeNull(); + expect(result?.methods[0].jsDoc).toContain( + ' * This line has no asterisk prefix.', + ); + // The empty line (only whitespace, no *) should become ' *' + expect(result?.methods[0].jsDoc).toContain(' *\n'); + }); + }); + + it('handles inherited methods with malformed tsconfig', async () => { + expect.assertions(2); + + await withinParseSourceSandbox(async ({ directoryPath }) => { + // Write an invalid tsconfig to trigger readConfigFile error + await fs.promises.writeFile( + path.join(directoryPath, 'tsconfig.json'), + 'this is not valid json', + 'utf8', + ); + + const controllerFile = path.join( + directoryPath, + 'BadTsconfigController.ts', + ); + await fs.promises.writeFile( + controllerFile, + ` +const MESSENGER_EXPOSED_METHODS = ['doStuff', 'inherited'] as const; + +class BadTsconfigController { + doStuff() { + return true; + } +} +`, + 'utf8', + ); + + const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(); + const result = await parseSourceFile(controllerFile); + + expect(result).toBeNull(); + expect(consoleErrorSpy).toHaveBeenCalled(); + + consoleErrorSpy.mockRestore(); + }); + }); + + it('handles inherited methods when tsconfig is missing', async () => { + expect.assertions(2); + + await withinParseSourceSandbox(async ({ directoryPath }) => { + // No tsconfig.json in directoryPath — createProgramForFile should fail with assert + const controllerFile = path.join( + directoryPath, + 'NoTsconfigController.ts', + ); + await fs.promises.writeFile( + controllerFile, + ` +const MESSENGER_EXPOSED_METHODS = ['doStuff', 'inheritedMethod'] as const; + +class NoTsconfigController { + doStuff() { + return true; + } +} +`, + 'utf8', + ); + + const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(); + const result = await parseSourceFile(controllerFile); + + // Should return null because assert fails when type checker can't be created + expect(result).toBeNull(); + expect(consoleErrorSpy).toHaveBeenCalled(); + + consoleErrorSpy.mockRestore(); + }); + }); + + it('returns null and logs error for invalid file', async () => { + expect.assertions(2); + + const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(); + + const result = await parseSourceFile('/nonexistent/file.ts'); + + expect(result).toBeNull(); + expect(consoleErrorSpy).toHaveBeenCalled(); + consoleErrorSpy.mockRestore(); + }); +}); + +describe('findSourcesWithExposedMethods', () => { + it('finds controllers with MESSENGER_EXPOSED_METHODS in a directory', async () => { + expect.assertions(2); + + await withinFindControllersSandbox(async ({ directoryPath }) => { + await fs.promises.writeFile( + path.join(directoryPath, 'FooController.ts'), + ` +const MESSENGER_EXPOSED_METHODS = ['doFoo'] as const; +class FooController { + doFoo() { return 'foo'; } +} +`, + 'utf8', + ); + + await fs.promises.writeFile( + path.join(directoryPath, 'BarController.ts'), + ` +class BarController { + doBar() { return 'bar'; } +} +`, + 'utf8', + ); + + const result = await findSourcesWithExposedMethods(directoryPath); + + expect(result).toHaveLength(1); + expect(result[0].name).toBe('FooController'); + }); + }); + + it('skips test files', async () => { + expect.assertions(1); + + await withinFindControllersSandbox(async ({ directoryPath }) => { + await fs.promises.writeFile( + path.join(directoryPath, 'FooController.test.ts'), + ` +const MESSENGER_EXPOSED_METHODS = ['doFoo'] as const; +class FooController { + doFoo() { return 'foo'; } +} +`, + 'utf8', + ); + + const result = await findSourcesWithExposedMethods(directoryPath); + + expect(result).toHaveLength(0); + }); + }); + + it('finds sources in nested subdirectories', async () => { + expect.assertions(2); + + await withinFindControllersSandbox(async ({ directoryPath }) => { + const subDir = path.join(directoryPath, 'nested'); + await fs.promises.mkdir(subDir); + + await fs.promises.writeFile( + path.join(subDir, 'NestedController.ts'), + ` +const MESSENGER_EXPOSED_METHODS = ['doNested'] as const; +class NestedController { + doNested() { return 'nested'; } +} +`, + 'utf8', + ); + + const result = await findSourcesWithExposedMethods(directoryPath); + + expect(result).toHaveLength(1); + expect(result[0].name).toBe('NestedController'); + }); + }); + + it('skips excluded directories like node_modules', async () => { + expect.assertions(1); + + await withinFindControllersSandbox(async ({ directoryPath }) => { + const nodeModulesDir = path.join(directoryPath, 'node_modules'); + await fs.promises.mkdir(nodeModulesDir); + + await fs.promises.writeFile( + path.join(nodeModulesDir, 'HiddenController.ts'), + ` +const MESSENGER_EXPOSED_METHODS = ['doHidden'] as const; +class HiddenController { + doHidden() { return 'hidden'; } +} +`, + 'utf8', + ); + + const result = await findSourcesWithExposedMethods(directoryPath); + + expect(result).toHaveLength(0); + }); + }); + + it('throws an error when the path is not a directory', async () => { + await expect( + findSourcesWithExposedMethods('/nonexistent/path'), + ).rejects.toThrow('The specified path is not a directory'); + }); + + it('re-throws non-ENOENT errors from isDirectory', async () => { + const statSpy = jest + .spyOn(fs.promises, 'stat') + .mockRejectedValue( + Object.assign(new Error('EACCES'), { code: 'EACCES' }), + ); + + await expect(findSourcesWithExposedMethods('/some/path')).rejects.toThrow( + 'EACCES', + ); + + statSpy.mockRestore(); + }); +}); diff --git a/packages/messenger-cli/src/parse-source.ts b/packages/messenger-cli/src/parse-source.ts new file mode 100644 index 00000000000..133ad6ce361 --- /dev/null +++ b/packages/messenger-cli/src/parse-source.ts @@ -0,0 +1,439 @@ +import { assert, hasProperty, isObject } from '@metamask/utils'; +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import type { + ArrayLiteralExpression, + ClassDeclaration, + MethodDeclaration, + Node as TSNode, + Program, + SourceFile, + Type, +} from 'typescript'; +import { + ScriptTarget, + createProgram, + createSourceFile, + findConfigFile, + forEachChild, + getJSDocCommentsAndTags, + isArrayLiteralExpression, + isAsExpression, + isClassDeclaration, + isIdentifier, + isJSDoc, + isMethodDeclaration, + isStringLiteral, + isVariableStatement, + parseJsonConfigFileContent, + readConfigFile, + sys, +} from 'typescript'; + +export type MethodInfo = { + name: string; + jsDoc: string; +}; + +export type SourceInfo = { + name: string; + filePath: string; + methods: MethodInfo[]; +}; + +type VisitorContext = { + exposedMethods: string[]; + className: string; + methods: MethodInfo[]; + sourceFile: SourceFile; +}; + +/** + * Extracts JSDoc comment from a method declaration. + * + * @param node - The method declaration node. + * @param source - The source file. + * @returns The JSDoc comment. + */ +function extractJSDoc(node: MethodDeclaration, source: SourceFile): string { + const jsDocTags = getJSDocCommentsAndTags(node); + if (jsDocTags.length === 0) { + return ''; + } + + const jsDoc = jsDocTags[0]; + if (isJSDoc(jsDoc)) { + const fullText = source.getFullText(); + const start = jsDoc.getFullStart(); + const end = jsDoc.getEnd(); + const rawJsDoc = fullText.substring(start, end).trim(); + return formatJSDoc(rawJsDoc); + } + + // istanbul ignore next: defensive check — getJSDocCommentsAndTags always returns JSDoc nodes + return ''; +} + +/** + * Formats JSDoc comments to have consistent indentation for the generated file. + * + * @param rawJsDoc - The raw JSDoc comment from the source. + * @returns The formatted JSDoc comment. + */ +function formatJSDoc(rawJsDoc: string): string { + const lines = rawJsDoc.split('\n'); + const formattedLines: string[] = []; + + for (let i = 0; i < lines.length; i++) { + const line = lines[i]; + if (i === 0) { + formattedLines.push('/**'); + } else if (i === lines.length - 1) { + formattedLines.push(' */'); + } else { + const trimmed = line.trim(); + if (trimmed.startsWith('*')) { + const content = trimmed.substring(1).trim(); + formattedLines.push(content ? ` * ${content}` : ' *'); + } else { + formattedLines.push(trimmed ? ` * ${trimmed}` : ' *'); + } + } + } + + return formattedLines.join('\n'); +} + +/** + * Visits AST nodes to find exposed methods and controller/service class. + * + * @param context - The visitor context. + * @returns A function to visit nodes. + */ +function createASTVisitor(context: VisitorContext): (node: TSNode) => void { + function visitNode(node: TSNode): void { + if (isVariableStatement(node)) { + const declaration = node.declarationList.declarations[0]; + if ( + isIdentifier(declaration.name) && + declaration.name.text === 'MESSENGER_EXPOSED_METHODS' + ) { + if (declaration.initializer) { + let arrayExpression: ArrayLiteralExpression | undefined; + + if (isArrayLiteralExpression(declaration.initializer)) { + arrayExpression = declaration.initializer; + } else if ( + isAsExpression(declaration.initializer) && + isArrayLiteralExpression(declaration.initializer.expression) + ) { + arrayExpression = declaration.initializer.expression; + } + + if (arrayExpression) { + context.exposedMethods = arrayExpression.elements + .filter(isStringLiteral) + .map((element) => element.text); + } + } + } + } + + if (isClassDeclaration(node) && node.name) { + const classText = node.name.text; + if (classText.includes('Controller') || classText.includes('Service')) { + context.className = classText; + + const seenMethods = new Set(); + for (const member of node.members) { + if ( + isMethodDeclaration(member) && + member.name && + isIdentifier(member.name) + ) { + const methodName = member.name.text; + if ( + context.exposedMethods.includes(methodName) && + !seenMethods.has(methodName) + ) { + seenMethods.add(methodName); + const jsDoc = extractJSDoc(member, context.sourceFile); + context.methods.push({ + name: methodName, + jsDoc, + }); + } + } + } + } + } + + forEachChild(node, visitNode); + } + + return visitNode; +} + +/** + * Create a TypeScript program for the given file by locating the nearest + * tsconfig.json. + * + * @param filePath - Absolute path to the source file. + * @returns A TypeScript program, or null if no tsconfig was found. + */ +function createProgramForFile(filePath: string): Program | null { + const configPath = findConfigFile( + path.dirname(filePath), + sys.fileExists.bind(sys), + 'tsconfig.json', + ); + if (!configPath) { + return null; + } + + const { config, error } = readConfigFile(configPath, sys.readFile.bind(sys)); + + if (error) { + return null; + } + + const parsedConfig = parseJsonConfigFileContent( + config, + sys, + path.dirname(configPath), + ); + + return createProgram({ + rootNames: parsedConfig.fileNames, + options: parsedConfig.options, + }); +} + +/** + * Find a class declaration with the given name in a source file. + * + * @param source - The source file to search. + * @param className - The class name to look for. + * @returns The class declaration node, or null if not found. + */ +function findClassInSourceFile( + source: SourceFile, + className: string, +): ClassDeclaration | null { + return ( + source.statements.find( + (node): node is ClassDeclaration => + isClassDeclaration(node) && node.name?.text === className, + ) ?? // istanbul ignore next: class is always found when called from parseSourceFile + null + ); +} + +/** + * Search through the class hierarchy of a TypeScript type to find the + * declaration of a method with the given name. + * + * @param classType - The class type to search. + * @param methodName - The method name to look for. + * @returns The method declaration node, or null if not found. + */ +function findMethodInHierarchy( + classType: Type, + methodName: string, +): MethodDeclaration | null { + const symbol = classType.getProperty(methodName); + if (!symbol) { + return null; + } + + const declarations = symbol.getDeclarations(); + // istanbul ignore next: defensive check — symbols from getProperty always have declarations + if (!declarations) { + return null; + } + + for (const declaration of declarations) { + if (isMethodDeclaration(declaration)) { + return declaration; + } + } + + // istanbul ignore next: defensive fallback — property found but not a method declaration + return null; +} + +/** + * Check if a path is a directory. + * + * @param pathValue - The path to check. + * @returns True if the path is a directory, false otherwise. + */ +async function isDirectory(pathValue: string): Promise { + try { + const stats = await fs.promises.stat(pathValue); + return stats.isDirectory(); + } catch (error) { + if ( + isObject(error) && + hasProperty(error, 'code') && + error.code === 'ENOENT' + ) { + return false; + } + + throw error; + } +} + +/** + * Parses a source file to extract exposed methods and their metadata. + * + * @param filePath - Path to the controller/service file to parse. + * @returns Source information or null if parsing fails. + */ +export async function parseSourceFile( + filePath: string, +): Promise { + try { + const content = await fs.promises.readFile(filePath, 'utf8'); + const source = createSourceFile( + filePath, + content, + ScriptTarget.Latest, + true, + ); + + const context: VisitorContext = { + exposedMethods: [], + className: '', + methods: [], + sourceFile: source, + }; + + createASTVisitor(context)(source); + + if (context.exposedMethods.length === 0 || !context.className) { + return null; + } + + const foundMethodNames = new Set( + context.methods.map((method) => method.name), + ); + + const inheritedMethodNames = context.exposedMethods.filter( + (name) => !foundMethodNames.has(name), + ); + + if (inheritedMethodNames.length > 0) { + const program = createProgramForFile(filePath); + const checker = program?.getTypeChecker(); + const programSourceFile = program?.getSourceFile(filePath); + + assert( + checker, + `Type checker could not be created for "${filePath}". Ensure a valid tsconfig.json is present.`, + ); + + assert( + programSourceFile, + `Source file "${filePath}" not found in program.`, + ); + + const classNode = findClassInSourceFile( + programSourceFile, + context.className, + ); + + assert( + classNode, + `Class "${context.className}" not found in "${filePath}".`, + ); + + const classType = checker.getTypeAtLocation(classNode); + for (const methodName of inheritedMethodNames) { + const methodDeclaration = findMethodInHierarchy(classType, methodName); + + const jsDoc = methodDeclaration + ? extractJSDoc(methodDeclaration, methodDeclaration.getSourceFile()) + : ''; + context.methods.push({ name: methodName, jsDoc }); + } + } + + return { + name: context.className, + filePath, + methods: context.methods, + }; + } catch (error) { + console.error(`Error parsing ${filePath}:`, error); + return null; + } +} + +/** + * Recursively get all files in a directory and its subdirectories. + * + * @param directory - The directory to search. + * @returns An array of file paths. + */ +const EXCLUDED_DIRECTORIES = new Set([ + 'node_modules', + 'dist', + '.git', + 'coverage', +]); + +async function getFiles(directory: string): Promise { + const entries = await fs.promises.readdir(directory, { withFileTypes: true }); + const files = await Promise.all( + entries.map(async (entry) => { + const fullPath = path.join(directory, entry.name); + if (entry.isDirectory()) { + return EXCLUDED_DIRECTORIES.has(entry.name) + ? [] + : await getFiles(fullPath); + } + return fullPath; + }), + ); + + return files.flat(); +} + +/** + * Finds all source files that have MESSENGER_EXPOSED_METHODS constants. + * Searches recursively through subdirectories. + * + * @param sourcePath - Path to the folder where controllers/services are located. + * @returns A list of source information objects. + */ +export async function findSourcesWithExposedMethods( + sourcePath: string, +): Promise { + const srcPath = path.resolve(globalThis.process.cwd(), sourcePath); + const sources: SourceInfo[] = []; + + if (!(await isDirectory(srcPath))) { + throw new Error(`The specified path is not a directory: ${srcPath}`); + } + + const srcFiles = await getFiles(srcPath); + + for (const file of srcFiles) { + if (!file.endsWith('.ts') || file.endsWith('.test.ts')) { + continue; + } + + const content = await fs.promises.readFile(file, 'utf8'); + + if (content.includes('MESSENGER_EXPOSED_METHODS')) { + const sourceInfo = await parseSourceFile(file); + if (sourceInfo) { + sources.push(sourceInfo); + } + } + } + + return sources; +} diff --git a/packages/messenger-cli/src/types.ts b/packages/messenger-cli/src/types.ts new file mode 100644 index 00000000000..aac2c6e9e5d --- /dev/null +++ b/packages/messenger-cli/src/types.ts @@ -0,0 +1,4 @@ +/** + * The formatting tool to use for formatting the source code. + */ +export type Formatter = 'oxfmt' | 'prettier'; diff --git a/packages/messenger-cli/tsconfig.build.json b/packages/messenger-cli/tsconfig.build.json new file mode 100644 index 00000000000..02a0eea03fe --- /dev/null +++ b/packages/messenger-cli/tsconfig.build.json @@ -0,0 +1,10 @@ +{ + "extends": "../../tsconfig.packages.build.json", + "compilerOptions": { + "baseUrl": "./", + "outDir": "./dist", + "rootDir": "./src" + }, + "references": [], + "include": ["../../types", "./src"] +} diff --git a/packages/messenger-cli/tsconfig.json b/packages/messenger-cli/tsconfig.json new file mode 100644 index 00000000000..025ba2ef7f4 --- /dev/null +++ b/packages/messenger-cli/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../../tsconfig.packages.json", + "compilerOptions": { + "baseUrl": "./" + }, + "references": [], + "include": ["../../types", "./src"] +} diff --git a/packages/messenger-cli/tsconfig.lint.json b/packages/messenger-cli/tsconfig.lint.json new file mode 100644 index 00000000000..fb65dcfce34 --- /dev/null +++ b/packages/messenger-cli/tsconfig.lint.json @@ -0,0 +1,8 @@ +{ + "extends": ["./tsconfig.json", "../../tsconfig.packages.lint.json"], + "compilerOptions": { + "outDir": "./.tsc-lint-cache", + "tsBuildInfoFile": "./.tsc-lint-cache/tsconfig.tsbuildinfo" + }, + "references": [] +} diff --git a/packages/messenger/CHANGELOG.md b/packages/messenger/CHANGELOG.md new file mode 100644 index 00000000000..e41e6cbeefd --- /dev/null +++ b/packages/messenger/CHANGELOG.md @@ -0,0 +1,138 @@ +# 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 + +- Add `delegateAll` method for exhaustive delegation with compile-time checking ([#8338](https://github.com/MetaMask/core/pull/8338)) + - Unlike `delegate`, this method requires all external actions and events to be listed, producing a TypeScript error showing exactly which items are missing. +- Add `MessengerNamespace` utility type to extract the namespace from a Messenger type ([#8338](https://github.com/MetaMask/core/pull/8338)) + +### Fixed + +- Defer re-entrant publishes of the same event so subscribers no longer receive a stale payload ([#9840](https://github.com/MetaMask/core/pull/9840)) + - When a subscriber publishes the same event it is currently handling (directly, or indirectly through a delegated messenger), that nested publish is now queued and delivered after the in-progress publish finishes, rather than inline. Previously the in-progress publish would resume afterwards and re-deliver its now-stale payload to the subscribers it had not yet reached. + +## [2.0.0] + +### Added + +- Add `Messenger.getRegisteredActionTypes` method, which returns the action types the messenger can call directly ([#9271](https://github.com/MetaMask/core/pull/9271)) +- Add a `buildChild` utility method to `Messenger` ([#9338](https://github.com/MetaMask/core/pull/9338)) + +### Changed + +- Bump `@metamask/utils` from `^11.9.0` to `^11.11.0` ([#9074](https://github.com/MetaMask/core/pull/9074)) + +### Removed + +- **BREAKING:** Remove deprecated `generate-action-types` CLI tool ([#9367](https://github.com/MetaMask/core/pull/9367)) + - The CLI has been extracted to `@metamask/messenger-cli`. Use `messenger-action-types` from that package instead. + +### Fixed + +- Fix `Messenger.delegate` and `Messenger.revoke` to reduce the chance of TS2590 errors when delegatee has large number of actions/events or a large number of actions/events are being delegated ([#8748](https://github.com/MetaMask/core/pull/8748)) + +## [1.2.0] + +### Added + +- Allow overriding action handler in subclass ([#8617](https://github.com/MetaMask/core/pull/8617)) + - The `Messenger` class now has a protected `getAction` method which returns the action handler for a given action name. +- Add `subscribeOnce` and `waitUntil` utility methods to `Messenger` ([#8575](https://github.com/MetaMask/core/pull/8575)) + +### Deprecated + +- Deprecate `generate-action-types` CLI tool and `messenger-generate-action-types` binary ([#8378](https://github.com/MetaMask/core/pull/8378)) + - The CLI has been extracted to `@metamask/messenger-cli`. Use `messenger-action-types` from this package instead. + +### Fixed + +- Throw different error for missing delegated actions ([#8557](https://github.com/MetaMask/core/pull/8557)) + +## [1.1.1] + +### Fixed + +- Drop peer dependency on `eslint` to prevent audit failures on consumers using ESLint 8.x ([#8371](https://github.com/MetaMask/core/pull/8371)) + +## [1.1.0] + +### Added + +- Add `generate-action-types` CLI tool ([#8264](https://github.com/MetaMask/core/pull/8264)) + - Generates TypeScript action type files for controllers and services that define `MESSENGER_EXPOSED_METHODS`. + - Available as a CLI binary (`messenger-generate-action-types`). + - `typescript` and `eslint` are peer dependencies. + +## [1.0.0] + +### Changed + +- This package is now considered stable ([#8317](https://github.com/MetaMask/core/pull/8317)) + +## [0.3.0] + +### Added + +- Add `captureException` constructor parameter ([#6605](https://github.com/MetaMask/core/pull/6605)) + - This function will be used to capture any errors thrown from subscribers. + - If this is unset but a parent is provided, `captureException` is inherited from the parent. + +### Changed + +- Stop re-throwing subscriber errors in a `setTimeout` ([#6605](https://github.com/MetaMask/core/pull/6605)) + - Instead errors are captured with `captureException`, or logged to the console. + +## [0.2.0] + +### Added + +- Allow disabling namespace checks in unit tests using the new `MOCK_ANY_NAMESPACE` constant and `MockAnyNamespace` type ([#6420](https://github.com/MetaMask/core/pull/6420)) + - To disable namespace checks, use `MockAnyNamespace` as the `Namespace` type parameter, and use `MOCK_ANY_NAMESPACE` as the `namespace` constructor parameter. + +### Changed + +- Keep delegated handlers when unregistering actions ([#6395](https://github.com/MetaMask/core/pull/6395)) + +## [0.1.0] + +### Added + +- Migrate `Messenger` class from `@metamask/base-controller` package ([#6127](https://github.com/MetaMask/core/pull/6127)) +- Add `delegate` and `revoke` methods ([#6132](https://github.com/MetaMask/core/pull/6132)) + - These allow delegating or revoking capabilities (actions or events) from one `Messenger` instance to another. + - This allows passing capabilities through chains of messengers of arbitrary length + - See this ADR for details: https://github.com/MetaMask/decisions/blob/main/decisions/core/0012-messenger-delegation.md +- Add `parent` constructor parameter and type parameter to `Messenger` ([#6142](https://github.com/MetaMask/core/pull/6142)) + - All capabilities registered under this messenger's namespace are delegated to the parent automatically. This is similar to how the `RestrictedMessenger` would automatically delegate all capabilities to the messenger it was created from. +- Add `MessengerActions` and `MessengerEvents` utility types for extracting actions/events from a `Messenger` type ([#6317](https://github.com/MetaMask/core/pull/6317)) + +### Changed + +- **BREAKING:** Add `Namespace` type parameter and required `namespace` constructor parameter ([#6132](https://github.com/MetaMask/core/pull/6132)) + - All published events and registered actions should fall under the given namespace. Typically the namespace is the controller or service name. This is the equivalent to the `Namespace` parameter from the old `RestrictedMessenger` class. +- **BREAKING:** The `type` property of `ActionConstraint` and `EventConstraint` is now a `NamespacedName` rather than a string ([#6132](https://github.com/MetaMask/core/pull/6132)) +- Add default for `ReturnHandler` type parameter of `SelectorEventHandler` and `SelectorFunction` ([#6262](https://github.com/MetaMask/core/pull/6262), [#6264](https://github.com/MetaMask/core/pull/6264)) +- Add default of `never` to action and event type parameters of `Messenger` ([#6311](https://github.com/MetaMask/core/pull/6311)) + +### Removed + +- **BREAKING:** Remove `RestrictedMessenger` class ([#6132](https://github.com/MetaMask/core/pull/6132)) + - Existing `RestrictedMessenger` instances should be replaced with a `Messenger` with the `parent` constructor parameter set to the global messenger. We can now use the same class everywhere, passing capabilities using `delegate`. + - See this ADR for details: https://github.com/MetaMask/decisions/blob/main/decisions/core/0012-messenger-delegation.md + +[Unreleased]: https://github.com/MetaMask/core/compare/@metamask/messenger@2.0.0...HEAD +[2.0.0]: https://github.com/MetaMask/core/compare/@metamask/messenger@1.2.0...@metamask/messenger@2.0.0 +[1.2.0]: https://github.com/MetaMask/core/compare/@metamask/messenger@1.1.1...@metamask/messenger@1.2.0 +[1.1.1]: https://github.com/MetaMask/core/compare/@metamask/messenger@1.1.0...@metamask/messenger@1.1.1 +[1.1.0]: https://github.com/MetaMask/core/compare/@metamask/messenger@1.0.0...@metamask/messenger@1.1.0 +[1.0.0]: https://github.com/MetaMask/core/compare/@metamask/messenger@0.3.0...@metamask/messenger@1.0.0 +[0.3.0]: https://github.com/MetaMask/core/compare/@metamask/messenger@0.2.0...@metamask/messenger@0.3.0 +[0.2.0]: https://github.com/MetaMask/core/compare/@metamask/messenger@0.1.0...@metamask/messenger@0.2.0 +[0.1.0]: https://github.com/MetaMask/core/releases/tag/@metamask/messenger@0.1.0 diff --git a/packages/messenger/LICENSE b/packages/messenger/LICENSE new file mode 100644 index 00000000000..c259cd7ebcf --- /dev/null +++ b/packages/messenger/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2025 MetaMask + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packages/messenger/README.md b/packages/messenger/README.md new file mode 100644 index 00000000000..609fdd198d5 --- /dev/null +++ b/packages/messenger/README.md @@ -0,0 +1,17 @@ +# `@metamask/messenger` + +A type-safe message bus library. + +The `Messenger` class allows registering functions as 'actions' that can be called elsewhere, and it allows publishing and subscribing to events. Both actions and events are identified by namespaced strings. + +## Installation + +`yarn add @metamask/messenger` + +or + +`npm install @metamask/messenger` + +## 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/messenger/jest.config.js b/packages/messenger/jest.config.js new file mode 100644 index 00000000000..9ad52b2640b --- /dev/null +++ b/packages/messenger/jest.config.js @@ -0,0 +1,29 @@ +/* + * 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, + + // Exclude TSTyche type test files from coverage collection + collectCoverageFrom: ['./src/**/*.ts', '!**/*.tst.ts'], + + // An object that configures minimum threshold enforcement for coverage results + coverageThreshold: { + global: { + branches: 98.97, + functions: 100, + lines: 100, + statements: 100, + }, + }, +}); diff --git a/packages/messenger/package.json b/packages/messenger/package.json new file mode 100644 index 00000000000..034e0b46bd6 --- /dev/null +++ b/packages/messenger/package.json @@ -0,0 +1,82 @@ +{ + "name": "@metamask/messenger", + "version": "2.0.0", + "description": "A type-safe message bus library", + "keywords": [ + "Ethereum", + "MetaMask" + ], + "homepage": "https://github.com/MetaMask/core/tree/main/packages/messenger#readme", + "bugs": { + "url": "https://github.com/MetaMask/core/issues" + }, + "license": "MIT", + "repository": { + "type": "git", + "url": "https://github.com/MetaMask/core.git" + }, + "files": [ + "dist/" + ], + "sideEffects": false, + "main": "./dist/index.cjs", + "types": "./dist/index.d.cts", + "exports": { + ".": { + "import": { + "types": "./dist/index.d.mts", + "default": "./dist/index.mjs" + }, + "require": { + "types": "./dist/index.d.cts", + "default": "./dist/index.cjs" + } + }, + "./package.json": "./package.json" + }, + "publishConfig": { + "access": "public", + "registry": "https://registry.npmjs.org/" + }, + "scripts": { + "build": "ts-bridge --project tsconfig.build.json --verbose --clean --no-references", + "build:all": "ts-bridge --project tsconfig.build.json --verbose --clean", + "build:docs": "typedoc", + "changelog:update": "../../scripts/update-changelog.sh @metamask/messenger", + "changelog:validate": "../../scripts/validate-changelog.sh @metamask/messenger", + "lint:tsconfigs": "tsx ../../scripts/lint-tsconfigs/lint-tsconfigs.mts", + "lint:tsconfigs:fix": "tsx ../../scripts/lint-tsconfigs/lint-tsconfigs.mts --fix", + "since-latest-release": "../../scripts/since-latest-release.sh", + "test": "yarn test:unit && yarn test:types", + "test:clean": "yarn test:unit:clean && yarn test:types", + "test:types": "tstyche", + "test:unit": "NODE_OPTIONS=--experimental-vm-modules jest --reporters=jest-silent-reporter", + "test:unit:clean": "NODE_OPTIONS=--experimental-vm-modules jest --clearCache", + "test:unit:verbose": "NODE_OPTIONS=--experimental-vm-modules jest --verbose", + "test:verbose": "yarn test:unit:verbose && yarn test:types", + "test:watch": "NODE_OPTIONS=--experimental-vm-modules jest --watch" + }, + "dependencies": { + "@metamask/utils": "^11.11.0" + }, + "devDependencies": { + "@metamask/auto-changelog": "^6.1.0", + "@ts-bridge/cli": "^0.6.4", + "@types/jest": "^30.0.0", + "deepmerge": "^4.2.2", + "immer": "^9.0.6", + "jest": "^30.4.2", + "ts-jest": "^29.4.11", + "tstyche": "^5.0.2", + "tsx": "^4.20.5", + "typedoc": "^0.25.13", + "typedoc-plugin-missing-exports": "^2.0.0", + "typescript": "~5.3.3" + }, + "peerDependencies": { + "typescript": ">=5.0.0" + }, + "engines": { + "node": "^18.18 || >=20" + } +} diff --git a/packages/messenger/src/Messenger.test.ts b/packages/messenger/src/Messenger.test.ts new file mode 100644 index 00000000000..dea73b23a71 --- /dev/null +++ b/packages/messenger/src/Messenger.test.ts @@ -0,0 +1,2531 @@ +import type { Patch } from 'immer'; + +import { Messenger, MOCK_ANY_NAMESPACE } from './Messenger.js'; +import type { ActionConstraint } from './Messenger.js'; +import type { MockAnyNamespace } from './Messenger.js'; + +describe('Messenger', () => { + describe('registerActionHandler and call', () => { + it('allows registering and calling an action handler', () => { + type CountAction = { + type: 'Fixture:count'; + handler: (increment: number) => void; + }; + const messenger = new Messenger<'Fixture', CountAction, never>({ + namespace: 'Fixture', + }); + + let count = 0; + messenger.registerActionHandler('Fixture:count', (increment: number) => { + count += increment; + }); + messenger.call('Fixture:count', 1); + + expect(count).toBe(1); + }); + + it('allows registering and calling an action handler for a different namespace using MOCK_ANY_NAMESPACE', () => { + type CountAction = { + type: 'Fixture:count'; + handler: (increment: number) => void; + }; + const messenger = new Messenger({ + namespace: MOCK_ANY_NAMESPACE, + }); + + let count = 0; + messenger.registerActionHandler('Fixture:count', (increment: number) => { + count += increment; + }); + messenger.call('Fixture:count', 1); + + expect(count).toBe(1); + }); + + it('automatically delegates actions to parent upon registration', () => { + type CountAction = { + type: 'Fixture:count'; + handler: (increment: number) => void; + }; + const parentMessenger = new Messenger<'Parent', CountAction, never>({ + namespace: 'Parent', + }); + const messenger = new Messenger< + 'Fixture', + CountAction, + never, + typeof parentMessenger + >({ + namespace: 'Fixture', + parent: parentMessenger, + }); + + let count = 0; + messenger.registerActionHandler('Fixture:count', (increment: number) => { + count += increment; + }); + parentMessenger.call('Fixture:count', 1); + + expect(count).toBe(1); + }); + + it('allows registering and calling multiple different action handlers', () => { + // These 'Other' types are included to demonstrate that messenger generics can indeed be unions + // of actions and events from different modules. + type GetOtherState = { + type: `OtherController:getState`; + handler: () => { stuff: string }; + }; + + type OtherStateChange = { + type: `OtherController:stateChange`; + payload: [{ stuff: string }, Patch[]]; + }; + + type MessageAction = + | { type: 'Fixture:concat'; handler: (message: string) => void } + | { type: 'Fixture:reset'; handler: (initialMessage: string) => void }; + const messenger = new Messenger< + 'Fixture', + MessageAction | GetOtherState, + OtherStateChange + >({ namespace: 'Fixture' }); + + let message = ''; + messenger.registerActionHandler( + 'Fixture:reset', + (initialMessage: string) => { + message = initialMessage; + }, + ); + + messenger.registerActionHandler('Fixture:concat', (input: string) => { + message += input; + }); + + messenger.call('Fixture:reset', 'hello'); + messenger.call('Fixture:concat', ', world'); + + expect(message).toBe('hello, world'); + }); + + it('allows registering and calling an action handler with no parameters', () => { + type IncrementAction = { type: 'Fixture:increment'; handler: () => void }; + const messenger = new Messenger<'Fixture', IncrementAction, never>({ + namespace: 'Fixture', + }); + + let count = 0; + messenger.registerActionHandler('Fixture:increment', () => { + count += 1; + }); + messenger.call('Fixture:increment'); + + expect(count).toBe(1); + }); + + it('allows registering and calling an action handler with multiple parameters', () => { + type MessageAction = { + type: 'Fixture:message'; + handler: (to: string, message: string) => void; + }; + const messenger = new Messenger<'Fixture', MessageAction, never>({ + namespace: 'Fixture', + }); + + const messages: Record = {}; + messenger.registerActionHandler('Fixture:message', (to, message) => { + messages[to] = message; + }); + messenger.call('Fixture:message', '0x123', 'hello'); + + expect(messages['0x123']).toBe('hello'); + }); + + it('allows registering and calling an action handler with a return value', () => { + type AddAction = { + type: 'Fixture:add'; + handler: (a: number, b: number) => number; + }; + const messenger = new Messenger<'Fixture', AddAction, never>({ + namespace: 'Fixture', + }); + + messenger.registerActionHandler('Fixture:add', (a, b) => { + return a + b; + }); + const result = messenger.call('Fixture:add', 5, 10); + + expect(result).toBe(15); + }); + + it('does not allow registering multiple action handlers under the same name', () => { + type PingAction = { type: 'Fixture:ping'; handler: () => void }; + const messenger = new Messenger<'Fixture', PingAction, never>({ + namespace: 'Fixture', + }); + + messenger.registerActionHandler('Fixture:ping', () => undefined); + + expect(() => { + messenger.registerActionHandler('Fixture:ping', () => undefined); + }).toThrow('A handler for Fixture:ping has already been registered'); + }); + + it('allows overriding the action handler in child classes', () => { + type Action = { type: 'Fixture:ping'; handler: () => string }; + + const handler = jest.fn().mockReturnValue('foo'); + + class CustomMessenger extends Messenger<'Fixture', Action> { + protected getAction( + _actionType: Action['type'], + ): ActionConstraint['handler'] | undefined { + return handler; + } + } + + const messenger = new CustomMessenger({ + namespace: 'Fixture', + }); + + const realHandler = jest.fn().mockReturnValue('bar'); + messenger.registerActionHandler('Fixture:ping', realHandler); + + expect(messenger.call('Fixture:ping')).toBe('foo'); + expect(handler).toHaveBeenCalled(); + expect(realHandler).not.toHaveBeenCalled(); + }); + + it('throws when calling unregistered action', () => { + type PingAction = { type: 'Fixture:ping'; handler: () => void }; + const messenger = new Messenger<'Fixture', PingAction, never>({ + namespace: 'Fixture', + }); + + expect(() => { + messenger.call('Fixture:ping'); + }).toThrow('A handler for Fixture:ping has not been registered'); + }); + + it('throws when registering an action handler for a different namespace', () => { + type CountAction = { + type: 'Fixture:count'; + handler: (increment: number) => void; + }; + const messenger = new Messenger<'Different', CountAction, never>({ + namespace: 'Different', + }); + + expect(() => + // @ts-expect-error Intentionally invalid parameter + messenger.registerActionHandler('Fixture:count', jest.fn()), + ).toThrow( + `Only allowed registering action handlers prefixed by 'Different:'`, + ); + }); + + it('throws when unregistering an action handler for a different namespace', () => { + type CountAction = { + type: 'Source:count'; + handler: (increment: number) => void; + }; + const sourceMessenger = new Messenger<'Source', CountAction, never>({ + namespace: 'Source', + }); + const messenger = new Messenger<'Destination', CountAction, never>({ + namespace: 'Destination', + }); + sourceMessenger.delegate({ actions: ['Source:count'], messenger }); + + expect(() => + // @ts-expect-error Intentionally invalid parameter + messenger.unregisterActionHandler('Source:count'), + ).toThrow( + `Only allowed unregistering action handlers prefixed by 'Destination:'`, + ); + }); + + it('throws when calling an action from a different namespace that has been unregistered using MOCK_ANY_NAMESPACE', () => { + type PingAction = { type: 'Fixture:ping'; handler: () => void }; + const messenger = new Messenger({ + namespace: MOCK_ANY_NAMESPACE, + }); + + expect(() => { + messenger.call('Fixture:ping'); + }).toThrow('A handler for Fixture:ping has not been registered'); + + let pingCount = 0; + messenger.registerActionHandler('Fixture:ping', () => { + pingCount += 1; + }); + + messenger.unregisterActionHandler('Fixture:ping'); + + expect(() => { + messenger.call('Fixture:ping'); + }).toThrow('A handler for Fixture:ping has not been registered'); + expect(pingCount).toBe(0); + }); + + it('throws when calling an action that has been unregistered', () => { + type PingAction = { type: 'Fixture:ping'; handler: () => void }; + const messenger = new Messenger<'Fixture', PingAction, never>({ + namespace: 'Fixture', + }); + + expect(() => { + messenger.call('Fixture:ping'); + }).toThrow('A handler for Fixture:ping has not been registered'); + + let pingCount = 0; + messenger.registerActionHandler('Fixture:ping', () => { + pingCount += 1; + }); + + messenger.unregisterActionHandler('Fixture:ping'); + + expect(() => { + messenger.call('Fixture:ping'); + }).toThrow('A handler for Fixture:ping has not been registered'); + expect(pingCount).toBe(0); + }); + + it('throws when calling an action after actions have been reset', () => { + type PingAction = { type: 'Fixture:ping'; handler: () => void }; + const messenger = new Messenger<'Fixture', PingAction, never>({ + namespace: 'Fixture', + }); + + expect(() => { + messenger.call('Fixture:ping'); + }).toThrow('A handler for Fixture:ping has not been registered'); + + let pingCount = 0; + messenger.registerActionHandler('Fixture:ping', () => { + pingCount += 1; + }); + + messenger.clearActions(); + + expect(() => { + messenger.call('Fixture:ping'); + }).toThrow('A handler for Fixture:ping has not been registered'); + expect(pingCount).toBe(0); + }); + + it('throws when calling a delegated action after actions have been reset', () => { + type PingAction = { type: 'Fixture:ping'; handler: () => void }; + const messenger = new Messenger<'Fixture', PingAction, never>({ + namespace: 'Fixture', + }); + let pingCount = 0; + messenger.registerActionHandler('Fixture:ping', () => { + pingCount += 1; + }); + const delegatedMessenger = new Messenger< + 'Destination', + PingAction, + never + >({ + namespace: 'Destination', + }); + messenger.delegate({ + messenger: delegatedMessenger, + actions: ['Fixture:ping'], + }); + + messenger.clearActions(); + + expect(() => { + delegatedMessenger.call('Fixture:ping'); + }).toThrow('A handler for Fixture:ping has not been registered'); + expect(pingCount).toBe(0); + }); + }); + + describe('getRegisteredActionTypes', () => { + it('returns an empty array when no actions are registered', () => { + type PingAction = { type: 'Fixture:ping'; handler: () => void }; + const messenger = new Messenger<'Fixture', PingAction, never>({ + namespace: 'Fixture', + }); + + expect(messenger.getRegisteredActionTypes()).toStrictEqual([]); + }); + + it('returns the types of registered actions', () => { + type MessageAction = + | { type: 'Fixture:concat'; handler: (message: string) => void } + | { type: 'Fixture:reset'; handler: (initialMessage: string) => void }; + const messenger = new Messenger<'Fixture', MessageAction, never>({ + namespace: 'Fixture', + }); + + messenger.registerActionHandler('Fixture:concat', () => undefined); + messenger.registerActionHandler('Fixture:reset', () => undefined); + + expect(messenger.getRegisteredActionTypes()).toStrictEqual([ + 'Fixture:concat', + 'Fixture:reset', + ]); + }); + + it('no longer includes an action type after it is unregistered', () => { + type MessageAction = + | { type: 'Fixture:concat'; handler: (message: string) => void } + | { type: 'Fixture:reset'; handler: (initialMessage: string) => void }; + const messenger = new Messenger<'Fixture', MessageAction, never>({ + namespace: 'Fixture', + }); + + messenger.registerActionHandler('Fixture:concat', () => undefined); + messenger.registerActionHandler('Fixture:reset', () => undefined); + messenger.unregisterActionHandler('Fixture:concat'); + + expect(messenger.getRegisteredActionTypes()).toStrictEqual([ + 'Fixture:reset', + ]); + }); + + it('includes actions delegated in from another messenger', () => { + type CountAction = { + type: 'Source:count'; + handler: (increment: number) => void; + }; + const sourceMessenger = new Messenger<'Source', CountAction, never>({ + namespace: 'Source', + }); + const messenger = new Messenger<'Destination', CountAction, never>({ + namespace: 'Destination', + }); + sourceMessenger.registerActionHandler('Source:count', () => undefined); + sourceMessenger.delegate({ actions: ['Source:count'], messenger }); + + expect(messenger.getRegisteredActionTypes()).toStrictEqual([ + 'Source:count', + ]); + }); + }); + + describe('buildChild', () => { + it('delegates actions to children', () => { + type PingAction = { type: 'Fixture:ping'; handler: () => string }; + const messenger = new Messenger({ + namespace: MOCK_ANY_NAMESPACE, + }); + + messenger.registerActionHandler('Fixture:ping', () => 'pong'); + + const childMessenger = messenger.buildChild({ + namespace: 'ChildMessenger', + actions: ['Fixture:ping'], + }); + + expect(childMessenger.call('Fixture:ping')).toBe('pong'); + }); + + it('delegates events to children', () => { + type MessageEvent = { type: 'Fixture:message'; payload: [string] }; + const messenger = new Messenger({ + namespace: MOCK_ANY_NAMESPACE, + }); + + const childMessenger = messenger.buildChild({ + namespace: 'ChildMessenger', + events: ['Fixture:message'], + }); + + const handler = jest.fn(); + childMessenger.subscribe('Fixture:message', handler); + messenger.publish('Fixture:message', 'hello'); + + expect(handler).toHaveBeenCalledWith('hello'); + }); + }); + + describe('publish and subscribe', () => { + it('publishes event to subscriber', () => { + type MessageEvent = { type: 'Fixture:message'; payload: [string] }; + const messenger = new Messenger<'Fixture', never, MessageEvent>({ + namespace: 'Fixture', + }); + + const handler = jest.fn(); + messenger.subscribe('Fixture:message', handler); + messenger.publish('Fixture:message', 'hello'); + + expect(handler).toHaveBeenCalledWith('hello'); + expect(handler.mock.calls).toHaveLength(1); + }); + + it('publishes event from different namespace using MOCK_ANY_NAMESPACE', () => { + type MessageEvent = { type: 'Fixture:message'; payload: [string] }; + const messenger = new Messenger({ + namespace: MOCK_ANY_NAMESPACE, + }); + + const handler = jest.fn(); + messenger.subscribe('Fixture:message', handler); + messenger.publish('Fixture:message', 'hello'); + + expect(handler).toHaveBeenCalledWith('hello'); + expect(handler.mock.calls).toHaveLength(1); + }); + + it('automatically delegates events to parent upon first publish', () => { + type MessageEvent = { type: 'Fixture:message'; payload: [string] }; + const parentMessenger = new Messenger<'Parent', never, MessageEvent>({ + namespace: 'Parent', + }); + const messenger = new Messenger< + 'Fixture', + never, + MessageEvent, + typeof parentMessenger + >({ + namespace: 'Fixture', + parent: parentMessenger, + }); + + const handler = jest.fn(); + parentMessenger.subscribe('Fixture:message', handler); + messenger.publish('Fixture:message', 'hello'); + + expect(handler).toHaveBeenCalledWith('hello'); + expect(handler.mock.calls).toHaveLength(1); + }); + + it('allows publishing multiple different events to subscriber', () => { + type MessageEvent = + | { type: 'Fixture:message'; payload: [string] } + | { type: 'Fixture:ping'; payload: [] }; + const messenger = new Messenger<'Fixture', never, MessageEvent>({ + namespace: 'Fixture', + }); + + 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).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', () => { + type PingEvent = { type: 'Fixture:ping'; payload: [] }; + const messenger = new Messenger<'Fixture', never, PingEvent>({ + namespace: 'Fixture', + }); + + const handler = jest.fn(); + messenger.subscribe('Fixture:ping', handler); + messenger.publish('Fixture:ping'); + + expect(handler).toHaveBeenCalledWith(); + expect(handler.mock.calls).toHaveLength(1); + }); + + it('publishes event with multiple payload parameters to subscriber', () => { + type MessageEvent = { + type: 'Fixture:message'; + payload: [string, string]; + }; + const messenger = new Messenger<'Fixture', never, MessageEvent>({ + namespace: 'Fixture', + }); + + const handler = jest.fn(); + messenger.subscribe('Fixture:message', handler); + messenger.publish('Fixture:message', 'hello', 'there'); + + expect(handler).toHaveBeenCalledWith('hello', 'there'); + expect(handler.mock.calls).toHaveLength(1); + }); + + it('publishes event once to subscriber even if subscribed multiple times', () => { + type MessageEvent = { type: 'Fixture:message'; payload: [string] }; + const messenger = new Messenger<'Fixture', never, MessageEvent>({ + namespace: 'Fixture', + }); + + const handler = jest.fn(); + messenger.subscribe('Fixture:message', handler); + messenger.subscribe('Fixture:message', handler); + messenger.publish('Fixture:message', 'hello'); + + expect(handler).toHaveBeenCalledWith('hello'); + expect(handler.mock.calls).toHaveLength(1); + }); + + it('publishes event to many subscribers', () => { + type MessageEvent = { type: 'Fixture:message'; payload: [string] }; + const messenger = new Messenger<'Fixture', never, MessageEvent>({ + namespace: 'Fixture', + }); + + 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).toHaveBeenCalledWith('hello'); + expect(handler1.mock.calls).toHaveLength(1); + expect(handler2).toHaveBeenCalledWith('hello'); + expect(handler2.mock.calls).toHaveLength(1); + }); + + it('defers a re-entrant publish of the same event until the current publish finishes', () => { + type MessageEvent = { type: 'Fixture:message'; payload: [string] }; + const messenger = new Messenger<'Fixture', never, MessageEvent>({ + namespace: 'Fixture', + }); + + const calls: string[] = []; + let republished = false; + messenger.subscribe('Fixture:message', (message) => { + calls.push(`first:${message}`); + if (!republished) { + republished = true; + messenger.publish('Fixture:message', 'second'); + } + }); + messenger.subscribe('Fixture:message', (message) => { + calls.push(`second:${message}`); + }); + + messenger.publish('Fixture:message', 'first'); + + expect(calls).toStrictEqual([ + 'first:first', + 'second:first', + 'first:second', + 'second:second', + ]); + }); + + it('drains multiple re-entrant publishes of the same event in order', () => { + type MessageEvent = { type: 'Fixture:message'; payload: [string] }; + const messenger = new Messenger<'Fixture', never, MessageEvent>({ + namespace: 'Fixture', + }); + + const received: string[] = []; + let done = false; + messenger.subscribe('Fixture:message', (message) => { + received.push(message); + if (!done) { + done = true; + messenger.publish('Fixture:message', 'b'); + messenger.publish('Fixture:message', 'c'); + } + }); + + messenger.publish('Fixture:message', 'a'); + + expect(received).toStrictEqual(['a', 'b', 'c']); + }); + + it('runs a re-entrant publish of a different event inline', () => { + type MessageEvent = + | { type: 'Fixture:a'; payload: [] } + | { type: 'Fixture:b'; payload: [] }; + const messenger = new Messenger<'Fixture', never, MessageEvent>({ + namespace: 'Fixture', + }); + + const calls: string[] = []; + messenger.subscribe('Fixture:a', () => { + calls.push('a:start'); + messenger.publish('Fixture:b'); + calls.push('a:end'); + }); + messenger.subscribe('Fixture:b', () => { + calls.push('b'); + }); + + messenger.publish('Fixture:a'); + + expect(calls).toStrictEqual(['a:start', 'b', 'a:end']); + }); + + it('defers a re-entrant publish that crosses a delegated messenger', () => { + type ExampleEvent = { type: 'Source:event'; payload: [string] }; + const source = new Messenger<'Source', never, ExampleEvent>({ + namespace: 'Source', + }); + const delegatee = new Messenger<'Destination', never, ExampleEvent>({ + namespace: 'Destination', + }); + source.delegate({ messenger: delegatee, events: ['Source:event'] }); + + const calls: string[] = []; + let republished = false; + delegatee.subscribe('Source:event', (message) => { + calls.push(`delegatee:${message}`); + if (!republished) { + republished = true; + source.publish('Source:event', 'second'); + } + }); + source.subscribe('Source:event', (message) => { + calls.push(`source:${message}`); + }); + + source.publish('Source:event', 'first'); + + expect(calls).toStrictEqual([ + 'delegatee:first', + 'source:first', + 'delegatee:second', + 'source:second', + ]); + }); + + describe('on first state change with an initial payload function registered', () => { + it('publishes event if selected payload differs', () => { + const state = { + propA: 1, + propB: 1, + }; + type MessageEvent = { + type: 'Fixture:complexMessage'; + payload: [typeof state]; + }; + const messenger = new Messenger<'Fixture', never, MessageEvent>({ + namespace: 'Fixture', + }); + messenger.registerInitialEventPayload({ + eventType: 'Fixture:complexMessage', + getPayload: () => [state], + }); + const handler = jest.fn(); + messenger.subscribe( + 'Fixture:complexMessage', + handler, + (obj) => obj.propA, + ); + + state.propA += 1; + messenger.publish('Fixture:complexMessage', state); + + 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', () => { + const state = { + propA: 1, + propB: 1, + }; + type MessageEvent = { + type: 'Fixture:complexMessage'; + payload: [typeof state]; + }; + const messenger = new Messenger<'Fixture', never, MessageEvent>({ + namespace: 'Fixture', + }); + messenger.registerInitialEventPayload({ + eventType: 'Fixture:complexMessage', + getPayload: () => [state], + }); + const handler = jest.fn(); + messenger.subscribe( + 'Fixture:complexMessage', + handler, + (obj) => obj.propA, + ); + + messenger.publish('Fixture:complexMessage', state); + + expect(handler.mock.calls).toHaveLength(0); + }); + }); + + describe('on first state change with an initial payload function from another namespace registered (using MOCK_ANY_NAMESPACE)', () => { + it('publishes event if selected payload differs', () => { + const state = { + propA: 1, + propB: 1, + }; + type MessageEvent = { + type: 'Fixture:complexMessage'; + payload: [typeof state]; + }; + const messenger = new Messenger({ + namespace: MOCK_ANY_NAMESPACE, + }); + messenger.registerInitialEventPayload({ + eventType: 'Fixture:complexMessage', + getPayload: () => [state], + }); + const handler = jest.fn(); + messenger.subscribe( + 'Fixture:complexMessage', + handler, + (obj) => obj.propA, + ); + + state.propA += 1; + messenger.publish('Fixture:complexMessage', state); + + 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', () => { + const state = { + propA: 1, + propB: 1, + }; + type MessageEvent = { + type: 'Fixture:complexMessage'; + payload: [typeof state]; + }; + const messenger = new Messenger({ + namespace: MOCK_ANY_NAMESPACE, + }); + messenger.registerInitialEventPayload({ + eventType: 'Fixture:complexMessage', + getPayload: () => [state], + }); + const handler = jest.fn(); + messenger.subscribe( + 'Fixture:complexMessage', + handler, + (obj) => obj.propA, + ); + + messenger.publish('Fixture:complexMessage', state); + + expect(handler.mock.calls).toHaveLength(0); + }); + }); + + describe('on first state change without an initial payload function registered', () => { + it('publishes event if selected payload differs', () => { + const state = { + propA: 1, + propB: 1, + }; + type MessageEvent = { + type: 'Fixture:complexMessage'; + payload: [typeof state]; + }; + const messenger = new Messenger<'Fixture', never, MessageEvent>({ + namespace: 'Fixture', + }); + const handler = jest.fn(); + messenger.subscribe( + 'Fixture:complexMessage', + handler, + (obj) => obj.propA, + ); + + state.propA += 1; + messenger.publish('Fixture:complexMessage', state); + + expect(handler.mock.calls[0]).toStrictEqual([2, undefined]); + expect(handler.mock.calls).toHaveLength(1); + }); + + it('publishes event even when selected payload does not change', () => { + const state = { + propA: 1, + propB: 1, + }; + type MessageEvent = { + type: 'Fixture:complexMessage'; + payload: [typeof state]; + }; + const messenger = new Messenger<'Fixture', never, MessageEvent>({ + namespace: 'Fixture', + }); + const handler = jest.fn(); + messenger.subscribe( + 'Fixture:complexMessage', + handler, + (obj) => obj.propA, + ); + + messenger.publish('Fixture:complexMessage', state); + + expect(handler.mock.calls[0]).toStrictEqual([1, undefined]); + expect(handler.mock.calls).toHaveLength(1); + }); + + it('does not publish if selector returns undefined', () => { + const state = { + propA: undefined, + propB: 1, + }; + type MessageEvent = { + type: 'Fixture:complexMessage'; + payload: [typeof state]; + }; + const messenger = new Messenger<'Fixture', never, MessageEvent>({ + namespace: 'Fixture', + }); + const handler = jest.fn(); + messenger.subscribe( + 'Fixture:complexMessage', + handler, + (obj) => obj.propA, + ); + + messenger.publish('Fixture:complexMessage', state); + + expect(handler.mock.calls).toHaveLength(0); + }); + }); + + describe('on later state change', () => { + it('calls selector event handler with previous selector return value', () => { + type MessageEvent = { + type: 'Fixture:complexMessage'; + payload: [Record]; + }; + const messenger = new Messenger<'Fixture', never, MessageEvent>({ + namespace: 'Fixture', + }); + + const handler = jest.fn(); + messenger.subscribe( + 'Fixture:complexMessage', + handler, + (obj) => obj.prop1, + ); + messenger.publish('Fixture:complexMessage', { prop1: 'a', prop2: 'b' }); + messenger.publish('Fixture:complexMessage', { prop1: 'z', prop2: 'b' }); + + 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', () => { + type MessageEvent = { + type: 'Fixture:complexMessage'; + payload: [Record]; + }; + const messenger = new Messenger<'Fixture', never, MessageEvent>({ + namespace: 'Fixture', + }); + + const handler = jest.fn(); + messenger.subscribe( + 'Fixture:complexMessage', + handler, + (obj) => obj.prop1, + ); + messenger.publish('Fixture:complexMessage', { prop1: 'a', prop2: 'b' }); + + expect(handler).toHaveBeenCalledWith('a', undefined); + expect(handler.mock.calls).toHaveLength(1); + }); + + it('does not publish event with selector if selector return value is unchanged', () => { + type MessageEvent = { + type: 'Fixture:complexMessage'; + payload: [Record]; + }; + const messenger = new Messenger<'Fixture', never, MessageEvent>({ + namespace: 'Fixture', + }); + + const handler = jest.fn(); + messenger.subscribe( + 'Fixture:complexMessage', + handler, + (obj) => obj.prop1, + ); + messenger.publish('Fixture:complexMessage', { prop1: 'a', prop2: 'b' }); + messenger.publish('Fixture:complexMessage', { prop1: 'a', prop3: 'c' }); + + expect(handler).toHaveBeenCalledWith('a', undefined); + expect(handler.mock.calls).toHaveLength(1); + }); + }); + + it('automatically delegates to parent when an initial payload is registered', () => { + const state = { + propA: 1, + propB: 1, + }; + type MessageEvent = { + type: 'Fixture:complexMessage'; + payload: [typeof state]; + }; + const parentMessenger = new Messenger<'Parent', never, MessageEvent>({ + namespace: 'Parent', + }); + const messenger = new Messenger< + 'Fixture', + never, + MessageEvent, + typeof parentMessenger + >({ + namespace: 'Fixture', + parent: parentMessenger, + }); + const handler = jest.fn(); + + messenger.registerInitialEventPayload({ + eventType: 'Fixture:complexMessage', + getPayload: () => [state], + }); + + parentMessenger.subscribe( + 'Fixture:complexMessage', + handler, + (obj) => obj.propA, + ); + messenger.publish('Fixture:complexMessage', state); + expect(handler.mock.calls).toHaveLength(0); + state.propA += 1; + messenger.publish('Fixture:complexMessage', state); + expect(handler.mock.calls[0]).toStrictEqual([2, 1]); + expect(handler.mock.calls).toHaveLength(1); + }); + + it('publishes event to many subscribers with the same selector', () => { + type MessageEvent = { + type: 'Fixture:complexMessage'; + payload: [Record]; + }; + const messenger = new Messenger<'Fixture', never, MessageEvent>({ + namespace: 'Fixture', + }); + + 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).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', () => { + const captureException = jest.fn(); + type MessageEvent = { type: 'Fixture:message'; payload: [string] }; + const messenger = new Messenger<'Fixture', never, MessageEvent>({ + captureException, + namespace: 'Fixture', + }); + const exampleError = new Error('Example error'); + + const handler = jest.fn(() => { + throw exampleError; + }); + messenger.subscribe('Fixture:message', handler); + + expect(() => messenger.publish('Fixture:message', 'hello')).not.toThrow(); + expect(captureException).toHaveBeenCalledTimes(1); + expect(captureException).toHaveBeenCalledWith(exampleError); + }); + + it('captures subscriber thrown non-errors using captureException', () => { + const captureException = jest.fn(); + type MessageEvent = { type: 'Fixture:message'; payload: [string] }; + const messenger = new Messenger<'Fixture', never, MessageEvent>({ + captureException, + namespace: 'Fixture', + }); + const exampleException = 'Non-error thrown value'; + + 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(); + expect(captureException).toHaveBeenCalledTimes(1); + expect(captureException).toHaveBeenCalledWith( + new Error(exampleException), + ); + }); + + it('captures subscriber errors using inherited captureException', () => { + const captureException = jest.fn(); + type MessageEvent = { type: 'Fixture:message'; payload: [string] }; + const parentMessenger = new Messenger<'Parent', never, MessageEvent>({ + captureException, + namespace: 'Parent', + }); + const messenger = new Messenger< + 'Fixture', + never, + MessageEvent, + typeof parentMessenger + >({ + namespace: 'Fixture', + parent: parentMessenger, + }); + const exampleError = new Error('Example error'); + + const handler = jest.fn(() => { + throw exampleError; + }); + messenger.subscribe('Fixture:message', handler); + + expect(() => messenger.publish('Fixture:message', 'hello')).not.toThrow(); + expect(captureException).toHaveBeenCalledTimes(1); + expect(captureException).toHaveBeenCalledWith(exampleError); + }); + + it('logs subscriber errors to console if no captureException provided', () => { + const consoleError = jest.fn(); + jest.spyOn(console, 'error').mockImplementation(consoleError); + type MessageEvent = { type: 'Fixture:message'; payload: [string] }; + const messenger = new Messenger<'Fixture', never, MessageEvent>({ + namespace: 'Fixture', + }); + const exampleError = new Error('Example error'); + + const handler = jest.fn(() => { + throw exampleError; + }); + messenger.subscribe('Fixture:message', handler); + + expect(() => messenger.publish('Fixture:message', 'hello')).not.toThrow(); + expect(consoleError).toHaveBeenCalledTimes(1); + expect(consoleError).toHaveBeenCalledWith(exampleError); + }); + + it('continues calling subscribers when one throws', () => { + type MessageEvent = { type: 'Fixture:message'; payload: [string] }; + const messenger = new Messenger<'Fixture', never, MessageEvent>({ + captureException: jest.fn(), + namespace: 'Fixture', + }); + + 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).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', () => { + type MessageEvent = { type: 'Fixture:message'; payload: [string] }; + const messenger = new Messenger<'Fixture', never, MessageEvent>({ + namespace: 'Fixture', + }); + + const handler = jest.fn(); + messenger.subscribe('Fixture:message', handler); + messenger.unsubscribe('Fixture:message', handler); + messenger.publish('Fixture:message', 'hello'); + + expect(handler.mock.calls).toHaveLength(0); + }); + + it('does not call subscriber with selector after unsubscribing', () => { + type MessageEvent = { + type: 'Fixture:complexMessage'; + payload: [{ prop1: string; prop2: string }]; + }; + const messenger = new Messenger<'Fixture', never, MessageEvent>({ + namespace: 'Fixture', + }); + const stub = jest.fn(); + const handler = (current: string, previous: string | undefined): void => { + stub(current, previous); + }; + const selector = (state: { prop1: string; prop2: string }): string => + state.prop1; + messenger.subscribe('Fixture:complexMessage', handler, selector); + messenger.unsubscribe('Fixture:complexMessage', handler); + + messenger.publish('Fixture:complexMessage', { prop1: 'a', prop2: 'b' }); + + expect(stub.mock.calls).toHaveLength(0); + }); + + it('throws when publishing an event from another namespace', () => { + type MessageEvent = { type: 'Fixture:message'; payload: [string] }; + const messenger = new Messenger<'Other', never, MessageEvent>({ + namespace: 'Other', + }); + const handler = jest.fn(); + messenger.subscribe('Fixture:message', handler); + + // @ts-expect-error Intentionally invalid parameter + expect(() => messenger.publish('Fixture:message', 'hello')).toThrow( + `Only allowed publishing events prefixed by 'Other:'`, + ); + expect(handler).not.toHaveBeenCalled(); + }); + + it('throws when registering an initial event payload from another namespace', () => { + type MessageEvent = { + type: 'Fixture:complexMessage'; + payload: [null]; + }; + const messenger = new Messenger<'Other', never, MessageEvent>({ + namespace: 'Other', + }); + + expect(() => + messenger.registerInitialEventPayload({ + // @ts-expect-error Intentionally invalid parameter + eventType: 'Fixture:complexMessage', + // @ts-expect-error Intentionally invalid parameter + getPayload: () => [null], + }), + ).toThrow( + `Only allowed registering initial payloads for events prefixed by 'Other:'`, + ); + }); + + it('throws when unsubscribing when there are no subscriptions', () => { + type MessageEvent = { type: 'Fixture:message'; payload: [string] }; + const messenger = new Messenger<'Fixture', never, MessageEvent>({ + namespace: 'Fixture', + }); + + const handler = jest.fn(); + expect(() => messenger.unsubscribe('Fixture:message', handler)).toThrow( + 'Subscription not found for event: Fixture:message', + ); + }); + + it('throws when unsubscribing a handler that is not subscribed', () => { + type MessageEvent = { type: 'Fixture:message'; payload: [string] }; + const messenger = new Messenger<'Fixture', never, MessageEvent>({ + namespace: 'Fixture', + }); + + const handler1 = jest.fn(); + const handler2 = jest.fn(); + messenger.subscribe('Fixture:message', handler1); + + expect(() => messenger.unsubscribe('Fixture:message', handler2)).toThrow( + 'Subscription not found for event: Fixture:message', + ); + }); + }); + + describe('subscribeOnce', () => { + it('unsubscribes automatically after receiving the first event', () => { + type MessageEvent = { + type: 'Fixture:message'; + payload: [string]; + }; + + const messenger = new Messenger<'Fixture', never, MessageEvent>({ + namespace: 'Fixture', + }); + + const handler = jest.fn(); + messenger.subscribeOnce('Fixture:message', handler); + messenger.publish('Fixture:message', 'foo'); + messenger.publish('Fixture:message', 'bar'); + + expect(handler).toHaveBeenCalledWith('foo'); + expect(handler).not.toHaveBeenCalledWith('bar'); + expect(handler.mock.calls).toHaveLength(1); + }); + + it('supports selectors', () => { + type MessageEvent = { + type: 'Fixture:message'; + payload: [{ value: string }]; + }; + + const messenger = new Messenger<'Fixture', never, MessageEvent>({ + namespace: 'Fixture', + }); + + const handler = jest.fn(); + messenger.subscribeOnce('Fixture:message', handler, { + selector: ({ value }) => value, + }); + messenger.publish('Fixture:message', { value: 'foo' }); + messenger.publish('Fixture:message', { value: 'bar' }); + + expect(handler).toHaveBeenCalledWith('foo', undefined); + expect(handler).not.toHaveBeenCalledWith('bar', 'foo'); + expect(handler.mock.calls).toHaveLength(1); + }); + + it('supports conditions without a selector', () => { + type MessageEvent = { + type: 'Fixture:message'; + payload: [string]; + }; + + const messenger = new Messenger<'Fixture', never, MessageEvent>({ + namespace: 'Fixture', + }); + + const handler = jest.fn(); + messenger.subscribeOnce('Fixture:message', handler, { + condition: (value) => value === 'bar', + }); + messenger.publish('Fixture:message', 'foo'); + messenger.publish('Fixture:message', 'bar'); + + expect(handler).not.toHaveBeenCalledWith('foo'); + expect(handler).toHaveBeenCalledWith('bar'); + expect(handler.mock.calls).toHaveLength(1); + }); + + it('supports conditions with a selector', () => { + type MessageEvent = { + type: 'Fixture:message'; + payload: [{ value: string }]; + }; + + const messenger = new Messenger<'Fixture', never, MessageEvent>({ + namespace: 'Fixture', + }); + + const handler = jest.fn(); + messenger.subscribeOnce('Fixture:message', handler, { + selector: ({ value }) => value, + condition: (value) => value === 'bar', + }); + messenger.publish('Fixture:message', { value: 'foo' }); + messenger.publish('Fixture:message', { value: 'bar' }); + + expect(handler).not.toHaveBeenCalledWith('foo'); + expect(handler).toHaveBeenCalledWith('bar', 'foo'); + expect(handler.mock.calls).toHaveLength(1); + }); + }); + + describe('waitUntil', () => { + it('resolves the promise when the event fires', async () => { + type MessageEvent = { + type: 'Fixture:message'; + payload: [string]; + }; + + const messenger = new Messenger<'Fixture', never, MessageEvent>({ + namespace: 'Fixture', + }); + + const promise = messenger.waitUntil('Fixture:message'); + messenger.publish('Fixture:message', 'foo'); + + expect(await promise).toStrictEqual(['foo']); + }); + + it('resolves the promise with multiple parameters', async () => { + type MessageEvent = { + type: 'Fixture:message'; + payload: [string, string, string]; + }; + + const messenger = new Messenger<'Fixture', never, MessageEvent>({ + namespace: 'Fixture', + }); + + const promise = messenger.waitUntil('Fixture:message'); + messenger.publish('Fixture:message', 'foo', 'bar', 'baz'); + + expect(await promise).toStrictEqual(['foo', 'bar', 'baz']); + }); + + it('supports selectors', async () => { + type MessageEvent = { + type: 'Fixture:message'; + payload: [{ value: string }]; + }; + + const messenger = new Messenger<'Fixture', never, MessageEvent>({ + namespace: 'Fixture', + }); + + const promise = messenger.waitUntil('Fixture:message', { + selector: ({ value }) => value, + }); + messenger.publish('Fixture:message', { value: 'foo' }); + + expect(await promise).toStrictEqual(['foo', undefined]); + }); + + it('supports conditions without a selector', async () => { + type MessageEvent = { + type: 'Fixture:message'; + payload: [string]; + }; + + const messenger = new Messenger<'Fixture', never, MessageEvent>({ + namespace: 'Fixture', + }); + + const promise = messenger.waitUntil('Fixture:message', { + condition: (value) => value === 'bar', + }); + messenger.publish('Fixture:message', 'foo'); + messenger.publish('Fixture:message', 'bar'); + + expect(await promise).toStrictEqual(['bar']); + }); + + it('supports conditions with a selector', async () => { + type MessageEvent = { + type: 'Fixture:message'; + payload: [{ value: string }]; + }; + + const messenger = new Messenger<'Fixture', never, MessageEvent>({ + namespace: 'Fixture', + }); + + const promise = messenger.waitUntil('Fixture:message', { + selector: ({ value }) => value, + condition: (value) => value === 'bar', + }); + messenger.publish('Fixture:message', { value: 'foo' }); + messenger.publish('Fixture:message', { value: 'bar' }); + + expect(await promise).toStrictEqual(['bar', 'foo']); + }); + }); + + describe('clearEventSubscriptions', () => { + it('does not call subscriber after clearing event subscriptions', () => { + type MessageEvent = { type: 'Fixture:message'; payload: [string] }; + const messenger = new Messenger<'Fixture', never, MessageEvent>({ + namespace: 'Fixture', + }); + + const handler = jest.fn(); + messenger.subscribe('Fixture:message', handler); + messenger.clearEventSubscriptions('Fixture:message'); + messenger.publish('Fixture:message', 'hello'); + + expect(handler.mock.calls).toHaveLength(0); + }); + + it('does not throw when clearing event that has no subscriptions', () => { + type MessageEvent = { type: 'Fixture:message'; payload: [string] }; + const messenger = new Messenger<'Fixture', never, MessageEvent>({ + namespace: 'Fixture', + }); + + expect(() => + messenger.clearEventSubscriptions('Fixture:message'), + ).not.toThrow(); + }); + + it('leaves delegated events intact after clearing event subscriptions', () => { + type ExampleEvent = { + type: 'Source:event'; + payload: ['test']; + }; + const sourceMessenger = new Messenger<'Source', never, ExampleEvent>({ + namespace: 'Source', + }); + const delegatedMessenger = new Messenger< + 'Destination', + never, + ExampleEvent + >({ namespace: 'Destination' }); + const subscriber = jest.fn(); + sourceMessenger.delegate({ + messenger: delegatedMessenger, + events: ['Source:event'], + }); + + sourceMessenger.clearEventSubscriptions('Source:event'); + + delegatedMessenger.subscribe('Source:event', subscriber); + sourceMessenger.publish('Source:event', 'test'); + expect(subscriber).toHaveBeenCalledWith('test'); + }); + }); + + describe('clearSubscriptions', () => { + it('does not call subscriber after resetting subscriptions', () => { + type MessageEvent = { type: 'Fixture:message'; payload: [string] }; + const messenger = new Messenger<'Fixture', never, MessageEvent>({ + namespace: 'Fixture', + }); + + const handler = jest.fn(); + messenger.subscribe('Fixture:message', handler); + messenger.clearSubscriptions(); + messenger.publish('Fixture:message', 'hello'); + + expect(handler.mock.calls).toHaveLength(0); + }); + + it('does not throw when clearing subscriptions on messenger that has no subscriptions', () => { + type MessageEvent = { type: 'Fixture:message'; payload: [string] }; + const messenger = new Messenger<'Fixture', never, MessageEvent>({ + namespace: 'Fixture', + }); + + expect(() => messenger.clearSubscriptions()).not.toThrow(); + }); + + it('leaves delegated events intact after clearing subscriptions', () => { + type ExampleEvent = { + type: 'Source:event'; + payload: ['test']; + }; + const sourceMessenger = new Messenger<'Source', never, ExampleEvent>({ + namespace: 'Source', + }); + const delegatedMessenger = new Messenger< + 'Destination', + never, + ExampleEvent + >({ namespace: 'Destination' }); + const subscriber = jest.fn(); + sourceMessenger.delegate({ + messenger: delegatedMessenger, + events: ['Source:event'], + }); + + sourceMessenger.clearSubscriptions(); + + delegatedMessenger.subscribe('Source:event', subscriber); + sourceMessenger.publish('Source:event', 'test'); + expect(subscriber).toHaveBeenCalledWith('test'); + }); + }); + + describe('registerMethodActionHandlers', () => { + it('registers action handlers for specified methods on the given messenger client', () => { + type TestActions = + | { type: 'TestService:getType'; handler: () => string } + | { + type: 'TestService:getCount'; + handler: () => number; + }; + + const messenger = new Messenger<'TestService', TestActions, never>({ + namespace: 'TestService', + }); + + class TestService { + name = 'TestService' as const; + + getType(): 'api' { + return 'api'; + } + + getCount(): number { + return 42; + } + } + + const service = new TestService(); + const methodNames = ['getType', 'getCount'] as const; + + messenger.registerMethodActionHandlers(service, methodNames); + + const state = messenger.call('TestService:getType'); + expect(state).toBe('api'); + + const count = messenger.call('TestService:getCount'); + expect(count).toBe(42); + }); + + it('binds action handlers to the given messenger client', () => { + type TestAction = { + type: 'TestService:getPrivateValue'; + handler: () => string; + }; + const messenger = new Messenger<'TestService', TestAction, never>({ + namespace: 'TestService', + }); + + class TestService { + name = 'TestService' as const; + + privateValue = 'secret'; + + getPrivateValue(): string { + return this.privateValue; + } + } + + const service = new TestService(); + messenger.registerMethodActionHandlers(service, ['getPrivateValue']); + + const result = messenger.call('TestService:getPrivateValue'); + expect(result).toBe('secret'); + }); + + it('handles async methods', async () => { + type TestAction = { + type: 'TestService:fetchData'; + handler: (id: string) => Promise; + }; + const messenger = new Messenger<'TestService', TestAction, never>({ + namespace: 'TestService', + }); + + class TestService { + name = 'TestService' as const; + + async fetchData(id: string): Promise { + return `data-${id}`; + } + } + + const service = new TestService(); + messenger.registerMethodActionHandlers(service, ['fetchData']); + + const result = await messenger.call('TestService:fetchData', '123'); + expect(result).toBe('data-123'); + }); + + it('does not throw when given an empty methodNames array', () => { + type TestAction = { type: 'TestController:test'; handler: () => void }; + const messenger = new Messenger<'TestController', TestAction, never>({ + namespace: 'TestController', + }); + + class TestController { + name = 'TestController' as const; + } + + const controller = new TestController(); + const methodNames: readonly string[] = []; + + expect(() => { + messenger.registerMethodActionHandlers( + controller, + methodNames as never[], + ); + }).not.toThrow(); + }); + + it('skips non-function properties', () => { + type TestAction = { + type: 'TestController:getValue'; + handler: () => string; + }; + const messenger = new Messenger<'TestController', TestAction, never>({ + namespace: 'TestController', + }); + + class TestController { + name = 'TestController' as const; + + readonly nonFunction = 'not a function'; + + getValue(): string { + return 'test'; + } + } + + const controller = new TestController(); + messenger.registerMethodActionHandlers(controller, ['getValue']); + + // getValue should be registered + expect(messenger.call('TestController:getValue')).toBe('test'); + + // nonFunction should not be registered + expect(() => { + // @ts-expect-error - This is a test + messenger.call('TestController:nonFunction'); + }).toThrow( + 'A handler for TestController:nonFunction has not been registered', + ); + }); + + it('works with class inheritance', () => { + type TestActions = + | { type: 'ChildController:baseMethod'; handler: () => string } + | { type: 'ChildController:childMethod'; handler: () => string }; + + const messenger = new Messenger<'ChildController', TestActions, never>({ + namespace: 'ChildController', + }); + + class BaseController { + name: Namespace; + + constructor({ namespace }: { namespace: Namespace }) { + this.name = namespace; + } + + baseMethod(): string { + return 'base method'; + } + } + + class ChildController extends BaseController<'ChildController'> { + name = 'ChildController' as const; + + constructor() { + super({ namespace: 'ChildController' }); + } + + childMethod(): string { + return 'child method'; + } + } + + const controller = new ChildController(); + messenger.registerMethodActionHandlers(controller, [ + 'baseMethod', + 'childMethod', + ]); + + expect(messenger.call('ChildController:baseMethod')).toBe('base method'); + expect(messenger.call('ChildController:childMethod')).toBe( + 'child method', + ); + }); + }); + + describe('delegate', () => { + it('allows subscribing to delegated event', () => { + type ExampleEvent = { + type: 'Source:event'; + payload: ['test']; + }; + const sourceMessenger = new Messenger<'Source', never, ExampleEvent>({ + namespace: 'Source', + }); + const delegatedMessenger = new Messenger< + 'Destination', + never, + ExampleEvent + >({ namespace: 'Destination' }); + const subscriber = jest.fn(); + + sourceMessenger.delegate({ + messenger: delegatedMessenger, + events: ['Source:event'], + }); + + delegatedMessenger.subscribe('Source:event', subscriber); + sourceMessenger.publish('Source:event', 'test'); + expect(subscriber).toHaveBeenCalledWith('test'); + }); + + it('throws an error when delegating the same event a second time', () => { + type ExampleEvent = { + type: 'Source:event'; + payload: ['test']; + }; + const sourceMessenger = new Messenger<'Source', never, ExampleEvent>({ + namespace: 'Source', + }); + const delegatedMessenger = new Messenger< + 'Destination', + never, + ExampleEvent + >({ namespace: 'Destination' }); + sourceMessenger.delegate({ + messenger: delegatedMessenger, + events: ['Source:event'], + }); + + expect(() => + sourceMessenger.delegate({ + messenger: delegatedMessenger, + events: ['Source:event'], + }), + ).toThrow( + `The event 'Source:event' has already been delegated to this messenger`, + ); + }); + + it('correctly registers initial event payload when delegated after payload is set', () => { + type ExampleEvent = { + type: 'Source:event'; + payload: [string]; + }; + const sourceMessenger = new Messenger<'Source', never, ExampleEvent>({ + namespace: 'Source', + }); + const delegatedMessenger = new Messenger< + 'Destination', + never, + ExampleEvent + >({ namespace: 'Destination' }); + const subscriber = jest.fn(); + + sourceMessenger.registerInitialEventPayload({ + eventType: 'Source:event', + getPayload: () => ['test'], + }); + sourceMessenger.delegate({ + messenger: delegatedMessenger, + events: ['Source:event'], + }); + + delegatedMessenger.subscribe( + 'Source:event', + subscriber, + (payloadEntry) => payloadEntry.length, + ); + sourceMessenger.publish('Source:event', 'four'); // same length as initial payload + expect(subscriber).not.toHaveBeenCalled(); + sourceMessenger.publish('Source:event', '12345'); // different length + expect(subscriber).toHaveBeenCalledTimes(1); + expect(subscriber).toHaveBeenCalledWith(5, 4); + }); + + it('correctly registers initial event payload when delegated before payload is set', () => { + type ExampleEvent = { + type: 'Source:event'; + payload: [string]; + }; + const sourceMessenger = new Messenger<'Source', never, ExampleEvent>({ + namespace: 'Source', + }); + const delegatedMessenger = new Messenger< + 'Destination', + never, + ExampleEvent + >({ namespace: 'Destination' }); + const subscriber = jest.fn(); + + sourceMessenger.delegate({ + messenger: delegatedMessenger, + events: ['Source:event'], + }); + sourceMessenger.registerInitialEventPayload({ + eventType: 'Source:event', + getPayload: () => ['test'], + }); + + delegatedMessenger.subscribe( + 'Source:event', + subscriber, + (payloadEntry) => payloadEntry.length, + ); + sourceMessenger.publish('Source:event', 'four'); // same length as initial payload + expect(subscriber).not.toHaveBeenCalled(); + sourceMessenger.publish('Source:event', '12345'); // different length + expect(subscriber).toHaveBeenCalledTimes(1); + expect(subscriber).toHaveBeenCalledWith(5, 4); + }); + + it('allows calling delegated action', () => { + type ExampleAction = { + type: 'Source:getLength'; + handler: (input: string) => number; + }; + const sourceMessenger = new Messenger<'Source', ExampleAction, never>({ + namespace: 'Source', + }); + const delegatedMessenger = new Messenger< + 'Destination', + ExampleAction, + never + >({ namespace: 'Destination' }); + const handler = jest.fn((input) => input.length); + sourceMessenger.registerActionHandler('Source:getLength', handler); + + sourceMessenger.delegate({ + messenger: delegatedMessenger, + actions: ['Source:getLength'], + }); + + const result = delegatedMessenger.call('Source:getLength', 'test'); + expect(result).toBe(4); + expect(handler).toHaveBeenCalledWith('test'); + }); + + it('allows calling delegated action that is not registered yet at time of delegation', () => { + type ExampleAction = { + type: 'Source:getLength'; + handler: (input: string) => number; + }; + const sourceMessenger = new Messenger<'Source', ExampleAction, never>({ + namespace: 'Source', + }); + const delegatedMessenger = new Messenger< + 'Destination', + ExampleAction, + never + >({ namespace: 'Destination' }); + const handler = jest.fn((input) => input.length); + + sourceMessenger.delegate({ + messenger: delegatedMessenger, + actions: ['Source:getLength'], + }); + // registration happens after delegation + sourceMessenger.registerActionHandler('Source:getLength', handler); + + const result = delegatedMessenger.call('Source:getLength', 'test'); + expect(result).toBe(4); + expect(handler).toHaveBeenCalledWith('test'); + }); + + it('allows calling delegated action that was registered before delegation, unregistered, then registered again', () => { + type ExampleAction = { + type: 'Source:getLength'; + handler: (input: string) => number; + }; + const sourceMessenger = new Messenger<'Source', ExampleAction, never>({ + namespace: 'Source', + }); + const delegatedMessenger = new Messenger< + 'Destination', + ExampleAction, + never + >({ namespace: 'Destination' }); + const handler1 = jest.fn((input) => input.length); + const handler2 = jest.fn((input) => input.length); + // registration happens before delegation + sourceMessenger.registerActionHandler('Source:getLength', handler1); + + sourceMessenger.delegate({ + messenger: delegatedMessenger, + actions: ['Source:getLength'], + }); + sourceMessenger.unregisterActionHandler('Source:getLength'); + sourceMessenger.registerActionHandler('Source:getLength', handler2); + + const result = delegatedMessenger.call('Source:getLength', 'test'); + expect(result).toBe(4); + expect(handler1).not.toHaveBeenCalled(); + expect(handler2).toHaveBeenCalledWith('test'); + }); + + it('allows calling delegated action that was registered after delegation, unregistered, then registered again', () => { + type ExampleAction = { + type: 'Source:getLength'; + handler: (input: string) => number; + }; + const sourceMessenger = new Messenger<'Source', ExampleAction, never>({ + namespace: 'Source', + }); + const delegatedMessenger = new Messenger< + 'Destination', + ExampleAction, + never + >({ namespace: 'Destination' }); + const handler1 = jest.fn((input) => input.length); + const handler2 = jest.fn((input) => input.length); + + sourceMessenger.delegate({ + messenger: delegatedMessenger, + actions: ['Source:getLength'], + }); + // registration happens after delegation + sourceMessenger.registerActionHandler('Source:getLength', handler1); + sourceMessenger.unregisterActionHandler('Source:getLength'); + sourceMessenger.registerActionHandler('Source:getLength', handler2); + + const result = delegatedMessenger.call('Source:getLength', 'test'); + expect(result).toBe(4); + expect(handler1).not.toHaveBeenCalled(); + expect(handler2).toHaveBeenCalledWith('test'); + }); + + it('throws an error when an action is delegated a second time', () => { + type ExampleAction = { + type: 'Source:getLength'; + handler: (input: string) => number; + }; + const sourceMessenger = new Messenger<'Source', ExampleAction, never>({ + namespace: 'Source', + }); + const delegatedMessenger = new Messenger< + 'Destination', + ExampleAction, + never + >({ namespace: 'Destination' }); + const handler = jest.fn((input) => input.length); + sourceMessenger.registerActionHandler('Source:getLength', handler); + sourceMessenger.delegate({ + messenger: delegatedMessenger, + actions: ['Source:getLength'], + }); + + expect(() => + sourceMessenger.delegate({ + messenger: delegatedMessenger, + actions: ['Source:getLength'], + }), + ).toThrow( + `The action 'Source:getLength' has already been delegated to this messenger`, + ); + }); + + it('throws an error when delegated action is called before it is registered', () => { + type ExampleAction = { + type: 'Source:getLength'; + handler: (input: string) => number; + }; + const sourceMessenger = new Messenger<'Source', ExampleAction, never>({ + namespace: 'Source', + }); + const delegatedMessenger = new Messenger< + 'Destination', + ExampleAction, + never + >({ namespace: 'Destination' }); + + sourceMessenger.delegate({ + messenger: delegatedMessenger, + actions: ['Source:getLength'], + }); + + expect(() => delegatedMessenger.call('Source:getLength', 'test')).toThrow( + `A handler for Source:getLength has not been registered`, + ); + }); + + it('throws an error when delegated action is called after an action is unregistered', () => { + type ExampleAction = { + type: 'Source:getLength'; + handler: (input: string) => number; + }; + const sourceMessenger = new Messenger<'Source', ExampleAction, never>({ + namespace: 'Source', + }); + const delegatedMessenger = new Messenger< + 'Destination', + ExampleAction, + never + >({ namespace: 'Destination' }); + const handler = jest.fn((input) => input.length); + sourceMessenger.registerActionHandler('Source:getLength', handler); + + sourceMessenger.delegate({ + messenger: delegatedMessenger, + actions: ['Source:getLength'], + }); + sourceMessenger.unregisterActionHandler('Source:getLength'); + + expect(() => delegatedMessenger.call('Source:getLength', 'test')).toThrow( + `A handler for Source:getLength has not been registered`, + ); + }); + }); + + describe('delegateAll', () => { + it('delegates all listed actions and events', () => { + type SourceAction = { + type: 'Source:getValue'; + handler: () => number; + }; + type ChildOwnAction = { + type: 'Child:doStuff'; + handler: () => void; + }; + type SourceEvent = { + type: 'Source:stateChange'; + payload: [{ value: number }]; + }; + + const sourceMessenger = new Messenger< + 'Source', + SourceAction | ChildOwnAction, + SourceEvent + >({ namespace: 'Source' }); + + const childMessenger = new Messenger< + 'Child', + SourceAction | ChildOwnAction, + SourceEvent + >({ namespace: 'Child' }); + + sourceMessenger.registerActionHandler('Source:getValue', () => 42); + + sourceMessenger.delegateAll({ + messenger: childMessenger, + actions: ['Source:getValue'], + events: ['Source:stateChange'], + }); + + // Child can now call the delegated action + expect(childMessenger.call('Source:getValue')).toBe(42); + + // Child can now subscribe to the delegated event + const subscriber = jest.fn(); + // eslint-disable-next-line no-restricted-syntax + childMessenger.subscribe('Source:stateChange', subscriber); + sourceMessenger.publish('Source:stateChange', { value: 1 }); + expect(subscriber).toHaveBeenCalledWith({ value: 1 }); + }); + + it('delegates actions with an empty events array', () => { + type SourceAction = { + type: 'Source:getValue'; + handler: () => number; + }; + + const sourceMessenger = new Messenger<'Source', SourceAction, never>({ + namespace: 'Source', + }); + const childMessenger = new Messenger<'Child', SourceAction, never>({ + namespace: 'Child', + }); + + sourceMessenger.registerActionHandler('Source:getValue', () => 99); + + sourceMessenger.delegateAll({ + messenger: childMessenger, + actions: ['Source:getValue'], + events: [], + }); + + expect(childMessenger.call('Source:getValue')).toBe(99); + }); + }); + + describe('revoke', () => { + it('throws when attempting to revoke from parent', () => { + type ExampleEvent = { + type: 'Source:event'; + payload: ['test']; + }; + const parentMessenger = new Messenger<'Parent', never, ExampleEvent>({ + namespace: 'Parent', + }); + const sourceMessenger = new Messenger< + 'Source', + never, + ExampleEvent, + typeof parentMessenger + >({ + namespace: 'Source', + parent: parentMessenger, + }); + + expect(() => + sourceMessenger.revoke({ + messenger: parentMessenger, + events: ['Source:event'], + }), + ).toThrow('Cannot revoke from parent'); + }); + + it('allows revoking a delegated event', () => { + type ExampleEvent = { + type: 'Source:event'; + payload: ['test']; + }; + const sourceMessenger = new Messenger<'Source', never, ExampleEvent>({ + namespace: 'Source', + }); + const delegatedMessenger = new Messenger< + 'Destination', + never, + ExampleEvent + >({ namespace: 'Destination' }); + const subscriber = jest.fn(); + sourceMessenger.delegate({ + messenger: delegatedMessenger, + events: ['Source:event'], + }); + delegatedMessenger.subscribe('Source:event', subscriber); + sourceMessenger.publish('Source:event', 'test'); + expect(subscriber).toHaveBeenCalledWith('test'); + expect(subscriber).toHaveBeenCalledTimes(1); + + sourceMessenger.revoke({ + messenger: delegatedMessenger, + events: ['Source:event'], + }); + sourceMessenger.publish('Source:event', 'test'); + + expect(subscriber).toHaveBeenCalledTimes(1); + }); + + it('allows revoking both a delegated and undelegated event', () => { + type ExampleFirstEvent = { + type: 'Source:firstEvent'; + payload: ['first']; + }; + type ExampleSecondEvent = { + type: 'Source:secondEvent'; + payload: ['second']; + }; + const sourceMessenger = new Messenger< + 'Source', + never, + ExampleFirstEvent | ExampleSecondEvent + >({ + namespace: 'Source', + }); + const delegatedMessenger = new Messenger< + 'Destination', + never, + ExampleFirstEvent | ExampleSecondEvent + >({ namespace: 'Destination' }); + const subscriber = jest.fn(); + sourceMessenger.delegate({ + messenger: delegatedMessenger, + events: ['Source:firstEvent'], + }); + delegatedMessenger.subscribe('Source:firstEvent', subscriber); + sourceMessenger.publish('Source:firstEvent', 'first'); + expect(subscriber).toHaveBeenCalledWith('first'); + expect(subscriber).toHaveBeenCalledTimes(1); + + expect(() => + sourceMessenger.revoke({ + messenger: delegatedMessenger, + // Second event here is not delegated, but first is + events: ['Source:firstEvent', 'Source:secondEvent'], + }), + ).not.toThrow(); + sourceMessenger.publish('Source:firstEvent', 'first'); + expect(subscriber).toHaveBeenCalledTimes(1); + }); + + it('allows revoking an event that is delegated elsewhere', () => { + type ExampleEvent = { + type: 'Source:event'; + payload: ['first test' | 'second test']; + }; + const sourceMessenger = new Messenger<'Source', never, ExampleEvent>({ + namespace: 'Source', + }); + const firstDelegatedMessenger = new Messenger< + 'FirstDestination', + never, + ExampleEvent + >({ namespace: 'FirstDestination' }); + const secondDelegatedMessenger = new Messenger< + 'SecondDestination', + never, + ExampleEvent + >({ namespace: 'SecondDestination' }); + const firstSubscriber = jest.fn(); + const secondSubscriber = jest.fn(); + sourceMessenger.delegate({ + messenger: firstDelegatedMessenger, + events: ['Source:event'], + }); + sourceMessenger.delegate({ + messenger: secondDelegatedMessenger, + events: ['Source:event'], + }); + firstDelegatedMessenger.subscribe('Source:event', firstSubscriber); + secondDelegatedMessenger.subscribe('Source:event', secondSubscriber); + sourceMessenger.publish('Source:event', 'first test'); + expect(firstSubscriber).toHaveBeenCalledWith('first test'); + expect(firstSubscriber).toHaveBeenCalledTimes(1); + expect(secondSubscriber).toHaveBeenCalledWith('first test'); + expect(secondSubscriber).toHaveBeenCalledTimes(1); + + sourceMessenger.revoke({ + messenger: firstDelegatedMessenger, + events: ['Source:event'], + }); + sourceMessenger.publish('Source:event', 'second test'); + + expect(firstSubscriber).toHaveBeenCalledTimes(1); + expect(secondSubscriber).toHaveBeenCalledWith('second test'); + expect(secondSubscriber).toHaveBeenCalledTimes(2); + }); + + it('ignores revokation of event that is not delegated to the given messenger, but is delegated elsewhere', () => { + type ExampleEvent = { + type: 'Source:event'; + payload: ['first test' | 'second test']; + }; + const sourceMessenger = new Messenger<'Source', never, ExampleEvent>({ + namespace: 'Source', + }); + const firstDelegatedMessenger = new Messenger< + 'FirstDestination', + never, + ExampleEvent + >({ namespace: 'FirstDestination' }); + const secondDelegatedMessenger = new Messenger< + 'SecondDestination', + never, + ExampleEvent + >({ namespace: 'SecondDestination' }); + const firstSubscriber = jest.fn(); + sourceMessenger.delegate({ + messenger: firstDelegatedMessenger, + events: ['Source:event'], + }); + firstDelegatedMessenger.subscribe('Source:event', firstSubscriber); + sourceMessenger.publish('Source:event', 'first test'); + expect(firstSubscriber).toHaveBeenCalledWith('first test'); + expect(firstSubscriber).toHaveBeenCalledTimes(1); + + expect(() => + sourceMessenger.revoke({ + messenger: secondDelegatedMessenger, + events: ['Source:event'], + }), + ).not.toThrow(); + sourceMessenger.publish('Source:event', 'second test'); + expect(firstSubscriber).toHaveBeenCalledWith('second test'); + expect(firstSubscriber).toHaveBeenCalledTimes(2); + }); + + it('ignores revokation of event that is not delegated', () => { + type ExampleEvent = { + type: 'Source:event'; + payload: ['test']; + }; + const sourceMessenger = new Messenger<'Source', never, ExampleEvent>({ + namespace: 'Source', + }); + const delegatedMessenger = new Messenger< + 'Destination', + never, + ExampleEvent + >({ namespace: 'Destination' }); + + expect(() => + sourceMessenger.revoke({ + messenger: delegatedMessenger, + events: ['Source:event'], + }), + ).not.toThrow(); + }); + + it('allows revoking a delegated action', () => { + type ExampleAction = { + type: 'Source:getLength'; + handler: (input: string) => number; + }; + const sourceMessenger = new Messenger<'Source', ExampleAction, never>({ + namespace: 'Source', + }); + const delegatedMessenger = new Messenger< + 'Destination', + ExampleAction, + never + >({ namespace: 'Destination' }); + const handler = jest.fn((input) => input.length); + + sourceMessenger.delegate({ + messenger: delegatedMessenger, + actions: ['Source:getLength'], + }); + sourceMessenger.registerActionHandler('Source:getLength', handler); + const result = delegatedMessenger.call('Source:getLength', 'test'); + expect(result).toBe(4); + expect(handler).toHaveBeenCalledWith('test'); + expect(handler).toHaveBeenCalledTimes(1); + + sourceMessenger.revoke({ + messenger: delegatedMessenger, + actions: ['Source:getLength'], + }); + + expect(() => delegatedMessenger.call('Source:getLength', 'test')).toThrow( + 'A handler for Source:getLength has not been delegated to Destination', + ); + }); + + it('allows revoking both a delegated and undelegated action', () => { + type ExampleFirstAction = { + type: 'Source:getLength'; + handler: (input: string) => number; + }; + type ExampleSecondAction = { + type: 'Source:getRandomString'; + handler: (seed: string) => string; + }; + const sourceMessenger = new Messenger< + 'Source', + ExampleFirstAction | ExampleSecondAction, + never + >({ + namespace: 'Source', + }); + const delegatedMessenger = new Messenger< + 'Destination', + ExampleFirstAction | ExampleSecondAction, + never + >({ namespace: 'Destination' }); + const handler = jest.fn((input) => input.length); + + sourceMessenger.delegate({ + messenger: delegatedMessenger, + actions: ['Source:getLength'], + }); + sourceMessenger.registerActionHandler('Source:getLength', handler); + const result = delegatedMessenger.call('Source:getLength', 'test'); + expect(result).toBe(4); + expect(handler).toHaveBeenCalledWith('test'); + expect(handler).toHaveBeenCalledTimes(1); + + expect(() => + sourceMessenger.revoke({ + messenger: delegatedMessenger, + // Second action is not delegated, but first is + actions: ['Source:getLength', 'Source:getRandomString'], + }), + ).not.toThrow(); + expect(() => delegatedMessenger.call('Source:getLength', 'test')).toThrow( + 'A handler for Source:getLength has not been delegated to Destination', + ); + expect(() => + delegatedMessenger.call('Source:getRandomString', 'test'), + ).toThrow( + 'A handler for Source:getRandomString has not been delegated to Destination', + ); + }); + + it('allows revoking a delegated action that is delegated elsewhere', () => { + type ExampleAction = { + type: 'Source:getLength'; + handler: (input: string) => number; + }; + const sourceMessenger = new Messenger<'Source', ExampleAction, never>({ + namespace: 'Source', + }); + const firstDelegatedMessenger = new Messenger< + 'FirstDestination', + ExampleAction, + never + >({ namespace: 'FirstDestination' }); + const secondDelegatedMessenger = new Messenger< + 'SecondDestination', + ExampleAction, + never + >({ namespace: 'SecondDestination' }); + const handler = jest.fn((input) => input.length); + + sourceMessenger.delegate({ + messenger: firstDelegatedMessenger, + actions: ['Source:getLength'], + }); + sourceMessenger.delegate({ + messenger: secondDelegatedMessenger, + actions: ['Source:getLength'], + }); + sourceMessenger.registerActionHandler('Source:getLength', handler); + const firstResult = firstDelegatedMessenger.call( + 'Source:getLength', + 'first test', // length 10 + ); + const secondResult = secondDelegatedMessenger.call( + 'Source:getLength', + 'second test', // length 11 + ); + expect(firstResult).toBe(10); + expect(secondResult).toBe(11); + expect(handler).toHaveBeenCalledWith('first test'); + expect(handler).toHaveBeenCalledWith('second test'); + expect(handler).toHaveBeenCalledTimes(2); + + sourceMessenger.revoke({ + messenger: firstDelegatedMessenger, + actions: ['Source:getLength'], + }); + + expect(() => + firstDelegatedMessenger.call('Source:getLength', 'test'), + ).toThrow( + 'A handler for Source:getLength has not been delegated to FirstDestination', + ); + const thirdResult = secondDelegatedMessenger.call( + 'Source:getLength', + 'third test', // length 10 + ); + expect(thirdResult).toBe(10); + expect(handler).toHaveBeenCalledWith('third test'); + expect(handler).toHaveBeenCalledTimes(3); + }); + + it('ignores revokation of action that is not delegated to the given messenger, but is delegated elsewhere', () => { + type ExampleAction = { + type: 'Source:getLength'; + handler: (input: string) => number; + }; + const sourceMessenger = new Messenger<'Source', ExampleAction, never>({ + namespace: 'Source', + }); + const firstDelegatedMessenger = new Messenger< + 'FirstDestination', + ExampleAction, + never + >({ namespace: 'FirstDestination' }); + const secondDelegatedMessenger = new Messenger< + 'SecondDestination', + ExampleAction, + never + >({ namespace: 'SecondDestination' }); + const handler = jest.fn((input) => input.length); + sourceMessenger.delegate({ + messenger: firstDelegatedMessenger, + actions: ['Source:getLength'], + }); + sourceMessenger.registerActionHandler('Source:getLength', handler); + + expect(() => + sourceMessenger.revoke({ + // This messenger was never delegated this action + messenger: secondDelegatedMessenger, + actions: ['Source:getLength'], + }), + ).not.toThrow(); + const result = firstDelegatedMessenger.call( + 'Source:getLength', + 'test', // length 4 + ); + expect(result).toBe(4); + expect(handler).toHaveBeenCalledWith('test'); + expect(handler).toHaveBeenCalledTimes(1); + }); + + it('ignores revokation of action that is not delegated', () => { + type ExampleAction = { + type: 'Source:getLength'; + handler: (input: string) => number; + }; + const sourceMessenger = new Messenger<'Source', ExampleAction, never>({ + namespace: 'Source', + }); + const delegatedMessenger = new Messenger< + 'Destination', + ExampleAction, + never + >({ namespace: 'Destination' }); + + expect(() => + sourceMessenger.revoke({ + messenger: delegatedMessenger, + actions: ['Source:getLength'], + }), + ).not.toThrow(); + }); + }); +}); diff --git a/packages/messenger/src/Messenger.ts b/packages/messenger/src/Messenger.ts new file mode 100644 index 00000000000..e7cfc7bc9be --- /dev/null +++ b/packages/messenger/src/Messenger.ts @@ -0,0 +1,1447 @@ +export type ActionHandler< + Action extends ActionConstraint, + ActionType = Action['type'], +> = ( + ...args: ExtractActionParameters +) => ExtractActionResponse; + +export type ExtractActionParameters< + Action extends ActionConstraint, + ActionType = Action['type'], +> = Action extends { + type: ActionType; + handler: (...args: infer HandlerArgs) => unknown; +} + ? HandlerArgs + : never; + +export type ExtractActionResponse< + Action extends ActionConstraint, + ActionType = Action['type'], +> = Action extends { + type: ActionType; + handler: (...args: infer _) => infer HandlerReturnValue; +} + ? HandlerReturnValue + : never; + +export type ExtractEventHandler< + Event extends EventConstraint, + EventType = Event['type'], +> = Event extends { + type: EventType; + payload: infer Payload; +} + ? Payload extends unknown[] + ? (...payload: Payload) => void + : never + : never; + +export type ExtractEventPayload< + Event extends EventConstraint, + EventType = Event['type'], +> = Event extends { + type: EventType; + payload: infer Payload; +} + ? Payload extends unknown[] + ? Payload + : never + : never; + +export type GenericEventHandler = (...args: unknown[]) => void; + +export type SelectorFunction< + Event extends EventConstraint, + EventType extends Event['type'], + ReturnValue = unknown, +> = (...args: ExtractEventPayload) => ReturnValue; +export type SelectorEventHandler = ( + newValue: SelectorReturnValue, + previousValue: SelectorReturnValue | undefined, +) => void; + +export type ActionConstraint = { + type: NamespacedName; + handler: ((...args: never) => unknown) | ((...args: never[]) => unknown); +}; +export type EventConstraint = { + type: NamespacedName; + payload: unknown[]; +}; + +/** + * Extract action types from a Messenger type. + * + * @template Subject - The messenger type to extract from. + */ +export type MessengerActions< + Subject extends Messenger, +> = + Subject extends Messenger + ? Action + : never; + +/** + * Extract event types from a Messenger type. + * + * @template Subject - The messenger type to extract from. + */ +export type MessengerEvents< + Subject extends Messenger, +> = + Subject extends Messenger + ? Event + : never; + +/** + * Extract the namespace from a Messenger type. + * + * @template Subject - The messenger type to extract from. + */ +export type MessengerNamespace< + Subject extends Messenger, +> = + Subject extends Messenger + ? N + : never; + +/** + * Validate that all members of a union are present in a tuple. + * + * When all required members are present, evaluates to the input tuple unchanged. + * When members are missing, evaluates to a branded intersection type that + * produces a clear compile error showing exactly which items are missing via + * the `__MISSING_DELEGATIONS__` property. + * + * @template Required - The union of all required string types. + * @template Provided - The readonly tuple of provided string types. + * @example + * ```typescript + * // OK — all required items present + * type T1 = RequireExhaustive<'A' | 'B', readonly ['A', 'B']>; + * // => readonly ['A', 'B'] + * + * // Error — 'C' is missing + * type T2 = RequireExhaustive<'A' | 'B' | 'C', readonly ['A', 'B']>; + * // => readonly ['A', 'B'] & { __MISSING_DELEGATIONS__: 'C' } + * ``` + */ +type RequireExhaustive< + Required extends string, + Provided extends readonly string[], +> = [Exclude] extends [never] + ? Provided + : // eslint-disable-next-line @typescript-eslint/naming-convention + Provided & { __MISSING_DELEGATIONS__: Exclude }; + +/** + * Messenger namespace checks can be disabled by using this as the `namespace` constructor + * parameter, and using `MockAnyNamespace` as the Namespace type parameter. + * + * This is useful for mocking a variety of different actions/events in unit tests. Please do not + * use this in production code. + */ +export const MOCK_ANY_NAMESPACE = 'MOCK_ANY_NAMESPACE'; + +/** + * A type representing any namespace. + * + * This is useful for mocking a variety of different actions/events in unit tests. Please do not + * use this in production code. + */ +export type MockAnyNamespace = string; + +/** + * Metadata for a single event subscription. + * + * @template Event - The event this subscription is for. + */ +type SubscriptionMetadata = { + /** + * Whether this subscription is for a delegated messenger. Delegation subscriptions are ignored + * when clearing subscriptions. + */ + delegation: boolean; + /** + * The optional selector function for this subscription. + */ + selector?: SelectorFunction; +}; + +/** + * A map of event handlers for a specific event. + * + * The key is the handler function, and the value contains additional subscription metadata. + * + * @template Event - The event these handlers are for. + */ +type EventSubscriptionMap = Map< + GenericEventHandler | SelectorEventHandler, + SubscriptionMetadata +>; + +/** + * A namespaced string + * + * This type verifies that the string Name is prefixed by the string Name followed by a colon. + * + * @template Namespace - The namespace we're checking for. + * @template Name - The full string, including the namespace. + */ +export type NamespacedBy< + Namespace extends string, + Name extends string, +> = Name extends `${Namespace}:${string}` ? Name : never; + +export type NotNamespacedBy< + Namespace extends string, + Name extends string, +> = Name extends `${Namespace}:${string}` ? never : Name; + +export type NamespacedName = + `${Namespace}:${string}`; + +/** + * A messenger that actions and/or events can be delegated to. + * + * This is a minimal type interface to avoid complex incompatibilities resulting from generics over + * invariant types. + */ +type DelegatedMessenger = Pick< + // The type is broadened to all actions/events because some messenger methods are contravariant + // over this type (`registerDelegatedActionHandler` and `publishDelegated` for example). If this + // type is narrowed to just the delegated actions/events, the types for event payload and action + // parameters would not be wide enough. + Messenger, + | '_internalPublishDelegated' + | '_internalRegisterDelegatedActionHandler' + | '_internalRegisterDelegatedInitialEventPayload' + | '_internalUnregisterDelegatedActionHandler' + | 'captureException' +>; + +type StripNamespace = + Namespaced extends `${string}:${infer Name}` ? Name : never; + +/** + * A message broker for "actions" and "events". + * + * The messenger allows registering functions as 'actions' that can be called elsewhere, + * and it allows publishing and subscribing to events. Both actions and events are identified by + * unique strings prefixed by a namespace (which is delimited by a colon, e.g. + * `Namespace:actionName`). + * + * @template Action - A type union of all Action types. + * @template Event - A type union of all Event types. + * @template Namespace - The namespace for the messenger. + */ +export class Messenger< + Namespace extends string, + Action extends ActionConstraint = never, + Event extends EventConstraint = never, + Parent extends Messenger< + string, + ActionConstraint, + EventConstraint, + // Use `any` to avoid preventing a parent from having a parent. `any` is harmless in a type + // constraint anyway, it's the one totally safe place to use it. + // eslint-disable-next-line @typescript-eslint/no-explicit-any + any + > = never, +> { + readonly #namespace: Namespace; + + /** + * The parent messenger. All actions/events under this namespace are automatically delegated to + * the parent messenger. + */ + readonly #parent?: DelegatedMessenger; + + readonly #actions = new Map(); + + readonly #events = new Map>(); + + /** + * In-progress publishes, keyed by event type. A key is present for the + * duration of a publish (so presence means "publishing"); its array collects + * re-entrant publishes of that event, drained when the publish finishes. + */ + readonly #deferredPublishes = new Map void)[]>(); + + /** + * The set of messengers we've delegated events to and their event handlers, by event type. + */ + readonly #subscriptionDelegationTargets = new Map< + Event['type'], + Map> + >(); + + /** + * The set of messengers we've delegated actions to, by action type. + */ + readonly #actionDelegationTargets = new Map< + Action['type'], + Set + >(); + + /** + * A map of functions for getting the initial event payload. + * + * Used only for events that represent state changes. + */ + readonly #initialEventPayloadGetters = new Map< + Event['type'], + () => ExtractEventPayload + >(); + + /** + * A cache of selector return values for their respective handlers. + */ + readonly #eventPayloadCache = new Map< + GenericEventHandler, + unknown | undefined + >(); + + /** + * Reports an error to an error monitoring service. + * + * @param error - The error to report. + */ + readonly captureException?: (error: Error) => void; + + /** + * Construct a messenger. + * + * If a parent messenger is given, all actions and events under this messenger's namespace will + * be delegated to the parent automatically. + * + * @param args - Constructor arguments + * @param args.captureException - Reports an error to an error monitoring service. + * @param args.namespace - The messenger namespace. + * @param args.parent - The parent messenger. + */ + constructor({ + captureException, + namespace, + parent, + }: { + captureException?: (error: Error) => void; + namespace: Namespace; + parent?: Action['type'] extends MessengerActions['type'] + ? Event['type'] extends MessengerEvents['type'] + ? Parent + : never + : never; + }) { + this.#namespace = namespace; + this.#parent = parent; + this.captureException = captureException ?? this.#parent?.captureException; + } + + /** + * Register an action handler. + * + * This will make the registered function available to call via the `call` method. + * + * The action being registered must be under the same namespace as the messenger. + * + * @param actionType - The action type. This is a unique identifier for this action. + * @param handler - The action handler. This function gets called when the `call` method is + * invoked with the given action type. + * @throws Will throw when a handler has been registered for this action type already. + * @template ActionType - A type union of Action type strings under this messenger's namespace. + */ + registerActionHandler< + ActionType extends Action['type'] & NamespacedName, + >(actionType: ActionType, handler: ActionHandler): void { + if (!this.#isInCurrentNamespace(actionType)) { + throw new Error( + `Only allowed registering action handlers prefixed by '${ + this.#namespace + }:'`, + ); + } + this.#registerActionHandler(actionType, handler); + if (this.#parent) { + // @ts-expect-error The parent type isn't constructed in a way that proves it supports this + // action, but this is OK because it's validated in the constructor. + this.delegate({ actions: [actionType], messenger: this.#parent }); + } + } + + #registerActionHandler( + actionType: ActionType, + handler: ActionHandler, + ): void { + if (this.#actions.has(actionType)) { + throw new Error( + `A handler for ${actionType} has already been registered`, + ); + } + this.#actions.set(actionType, handler); + } + + /** + * Registers action handlers for a list of methods on a messenger client + * + * @param messengerClient - The object that is expected to make use of the messenger. + * @param methodNames - The names of the methods on the messenger client to register as action + * handlers. + * @template MessengerClient - The type expected to make use of the messenger. + * @template MethodNames - The type union of method names to register as action handlers. + */ + registerMethodActionHandlers< + MessengerClient extends { name: Namespace }, + MethodNames extends keyof MessengerClient & StripNamespace, + >( + messengerClient: MessengerClient, + methodNames: readonly MethodNames[], + ): void { + for (const methodName of methodNames) { + const method = messengerClient[methodName]; + if (typeof method === 'function') { + const actionType = `${messengerClient.name}:${methodName}` as const; + this.registerActionHandler(actionType, method.bind(messengerClient)); + } + } + } + + /** + * Unregister an action handler. + * + * This will prevent this action from being called. + * + * The action being unregistered must be under the same namespace as the messenger. + * + * @param actionType - The action type. This is a unique identifier for this action. + * @template ActionType - A type union of Action type strings under this messenger's namespace. + */ + unregisterActionHandler< + ActionType extends Action['type'] & NamespacedName, + >(actionType: ActionType): void { + if (!this.#isInCurrentNamespace(actionType)) { + throw new Error( + `Only allowed unregistering action handlers prefixed by '${ + this.#namespace + }:'`, + ); + } + this.#unregisterActionHandler(actionType); + } + + #unregisterActionHandler( + actionType: ActionType, + ): void { + this.#actions.delete(actionType); + } + + /** + * Unregister all action handlers. + * + * This prevents all actions from being called. + */ + clearActions(): void { + for (const actionType of this.#actions.keys()) { + this.#unregisterActionHandler(actionType); + } + } + + /** + * Get the types of all actions that this messenger can call directly. + * + * This includes actions registered on this messenger as well as actions that + * have been delegated to it from another messenger. + * + * Note that this reflects the registrations on this specific messenger + * instance. + * + * @returns An array of every action type this messenger can call directly. + */ + getRegisteredActionTypes(): string[] { + return [...this.#actions.keys()]; + } + + /** + * Get the action handler for a given action type. + * + * This is a protected method to allow subclasses to override the way action + * handlers are retrieved, for example to implement custom delegation logic. + * + * @param actionType - The action type. This is a unique identifier for this + * action. + * @returns The action handler for the given action type, or undefined if no + * handler has been registered. + */ + protected getAction( + actionType: Action['type'], + ): ActionConstraint['handler'] | undefined { + return this.#actions.get(actionType); + } + + /** + * Create a new messenger as a child of this messenger (the "parent"). + * All actions/events are delegated from the child to the parent, and the specified actions/events are delegated from the parent to the child. + * + * @param args - Arguments. + * @param args.namespace - The child messenger namespace. + * @param args.actions - A list of action types to delegate to the child messenger. + * @param args.events - A list of event types to delegate to the child messenger. + * @returns The child messenger. + */ + buildChild< + ChildNamespace extends string, + ChildAction extends Action, + ChildEvent extends Event, + >({ + namespace, + actions, + events, + }: { + namespace: ChildNamespace; + actions?: ChildAction['type'][]; + events?: ChildEvent['type'][]; + }): Messenger { + const childMessenger = new Messenger< + ChildNamespace, + ChildAction, + ChildEvent, + typeof this + >({ + namespace, + // @ts-expect-error TypeScript cannot correctly infer this, but should be safe + // given `ChildAction extends Action` and `ChildEvent extends Event`. + parent: this, + }); + + this.delegate({ + messenger: childMessenger, + actions, + events, + }); + + return childMessenger; + } + + /** + * Call an action. + * + * This function will call the action handler corresponding to the given action type, passing + * along any parameters given. + * + * @param actionType - The action type. This is a unique identifier for this action. + * @param params - The action parameters. These must match the type of the parameters of the + * registered action handler. + * @throws Will throw when no handler has been registered for the given type. + * @template ActionType - A type union of Action type strings. + * @returns The action return value. + */ + call( + actionType: ActionType, + ...params: ExtractActionParameters + ): ExtractActionResponse { + const handler = this.getAction(actionType) as + | ActionHandler + | undefined; + + if (!handler) { + throw new Error( + this.#isInCurrentNamespace(actionType) + ? `A handler for ${actionType} has not been registered` + : `A handler for ${actionType} has not been delegated to ${this.#namespace}`, + ); + } + + return handler(...params); + } + + /** + * Register a function for getting the initial payload for an event. + * + * This is used for events that represent a state change, where the payload is the state. + * Registering a function for getting the payload allows event selectors to have a point of + * comparison the first time state changes. + * + * The event type must be under the same namespace as the messenger. + * + * @param args - The arguments to this function + * @param args.eventType - The event type to register a payload for. + * @param args.getPayload - A function for retrieving the event payload. + * @template EventType - A type union of Event type strings under this messenger's namespace. + */ + registerInitialEventPayload< + EventType extends Event['type'] & NamespacedName, + >({ + eventType, + getPayload, + }: { + eventType: EventType; + getPayload: () => ExtractEventPayload; + }): void { + if (!this.#isInCurrentNamespace(eventType)) { + throw new Error( + `Only allowed registering initial payloads for events prefixed by '${ + this.#namespace + }:'`, + ); + } + if ( + this.#parent && + !this.#subscriptionDelegationTargets.get(eventType)?.has(this.#parent) + ) { + // @ts-expect-error The parent type isn't constructed in a way that proves it supports this + // event, but this is OK because it's validated in the constructor. + this.delegate({ events: [eventType], messenger: this.#parent }); + } + this.#registerInitialEventPayload({ eventType, getPayload }); + } + + #registerInitialEventPayload({ + eventType, + getPayload, + }: { + eventType: EventType; + getPayload: () => ExtractEventPayload; + }): void { + this.#initialEventPayloadGetters.set(eventType, getPayload); + const delegationTargets = + this.#subscriptionDelegationTargets.get(eventType); + if (!delegationTargets) { + return; + } + for (const messenger of delegationTargets.keys()) { + messenger._internalRegisterDelegatedInitialEventPayload({ + eventType, + getPayload, + }); + } + } + + /** + * Publish an event. + * + * Publishes the given payload to all subscribers of the given event type. + * + * Note that this method should never throw directly. Any errors from + * subscribers are captured and re-thrown in a timeout handler. + * + * The event being published must be under the same namespace as the messenger. + * + * @param eventType - The event type. This is a unique identifier for this event. + * @param payload - The event payload. The type of the parameters for each event handler must + * match the type of this payload. + * @template EventType - A type union of Event type strings under this messenger's namespace. + */ + publish>( + eventType: EventType & NamespacedName, + ...payload: ExtractEventPayload + ): void { + if (!this.#isInCurrentNamespace(eventType)) { + throw new Error( + `Only allowed publishing events prefixed by '${this.#namespace}:'`, + ); + } + if ( + this.#parent && + !this.#subscriptionDelegationTargets.get(eventType)?.has(this.#parent) + ) { + // @ts-expect-error The parent type isn't constructed in a way that proves it supports this + // event, but this is OK because it's validated in the constructor. + this.delegate({ events: [eventType], messenger: this.#parent }); + } + this.#publish(eventType, ...payload); + } + + #publish( + eventType: EventType, + ...payload: ExtractEventPayload + ): void { + // Defer a re-entrant publish of the same event (e.g. a subscriber that + // publishes the event it is handling). Delivering it inline would let the + // in-progress publish resume and re-deliver its now-stale payload to the + // subscribers it had not reached yet. + const inProgress = this.#deferredPublishes.get(eventType); + if (inProgress) { + inProgress.push((): void => + this.#deliverToSubscribers(eventType, ...payload), + ); + return; + } + + const deferred: (() => void)[] = []; + this.#deferredPublishes.set(eventType, deferred); + try { + this.#deliverToSubscribers(eventType, ...payload); + + // Drain deferred publishes in order. The array grows as further + // re-entrant publishes push onto it; the iterator reads those too. + for (const run of deferred) { + run(); + } + } finally { + this.#deferredPublishes.delete(eventType); + } + } + + #deliverToSubscribers( + eventType: EventType, + ...payload: ExtractEventPayload + ): void { + const subscribers = this.#events.get(eventType); + + if (subscribers) { + for (const [handler, { selector }] of subscribers.entries()) { + try { + if (selector) { + const previousValue = this.#eventPayloadCache.get(handler); + const newValue = selector(...payload); + + if (newValue !== previousValue) { + this.#eventPayloadCache.set(handler, newValue); + handler(newValue, previousValue); + } + } else { + (handler as GenericEventHandler)(...payload); + } + } catch (error) { + // Capture error without interrupting the event publishing. + if (this.captureException) { + this.captureException( + error instanceof Error ? error : new Error(String(error)), + ); + } else { + console.error(error); + } + } + } + } + } + + /** + * Subscribe to an event. + * + * Registers the given function as an event handler for the given event type. + * + * @param eventType - The event type. This is a unique identifier for this event. + * @param handler - The event handler. The type of the parameters for this event handler must + * match the type of the payload for this event type. + * @template EventType - A type union of Event type strings. + */ + subscribe( + eventType: EventType, + handler: ExtractEventHandler, + ): void; + + /** + * Subscribe to an event, with a selector. + * + * Registers the given handler function as an event handler for the given + * event type. When an event is published, its payload is first passed to the + * selector. The event handler is only called if the selector's return value + * differs from its last known return value. + * + * @param eventType - The event type. This is a unique identifier for this event. + * @param handler - The event handler. The type of the parameters for this event + * handler must match the return type of the selector. + * @param selector - The selector function used to select relevant data from + * the event payload. The type of the parameters for this selector must match + * the type of the payload for this event type. + * @template EventType - A type union of Event type strings. + * @template SelectorReturnValue - The selector return value. + */ + subscribe( + eventType: EventType, + handler: SelectorEventHandler, + selector: SelectorFunction, + ): void; + + subscribe( + eventType: EventType, + handler: + | ExtractEventHandler + | SelectorEventHandler, + selector?: SelectorFunction, + ): void { + // Widen type of event handler by dropping ReturnType parameter. + // + // We need to drop it here because it's used as the parameter to the event handler, and + // functions in general are contravariant over the parameter type. This means the type is no + // longer valid once it's added to a broader type union with other handlers (because as far + // as TypeScript knows, we might call the handler with output from a different selector). + // + // This cast means the type system is not guaranteeing the handler is called with the matching + // input selector return value. The parameter types do ensure they match when `subscribe` is + // called, but past that point we need to make sure of that with manual review and tests + // instead. + const widenedHandler = handler as + | ExtractEventHandler + | SelectorEventHandler; + this.#subscribe(eventType, widenedHandler, { delegation: false, selector }); + + if (selector) { + const getPayload = this.#initialEventPayloadGetters.get(eventType); + if (getPayload) { + const initialValue = selector(...getPayload()); + this.#eventPayloadCache.set(widenedHandler, initialValue); + } + } + } + + /** + * Subscribe to an event. + * + * @param eventType - The event type. This is a unique identifier for this event. + * @param handler - The event handler. The type of the parameters for this event handler must + * match the type of the payload for this event type. + * @param metadata - Event metadata. + * @template SubscribedEvent - The event being subscribed to. + * @template SelectorReturnValue - The selector return value. + */ + #subscribe( + eventType: SubscribedEvent['type'], + handler: + | ExtractEventHandler + | SelectorEventHandler, + metadata: SubscriptionMetadata, + ): void { + let subscribers = this.#events.get(eventType); + if (!subscribers) { + subscribers = new Map(); + this.#events.set(eventType, subscribers); + } + subscribers.set(handler, metadata); + } + + /** + * Subscribe to an event, with a selector, invoking the handler exactly once. + * + * Registers the given handler function as an event handler for the given + * event type. When an event is published, its payload is first passed to the + * selector. The event handler is only called if the selector's return value + * differs from its last known return value. Additionally if the optional condition + * function is provided, it is checked whether it returns `true`. + * The handler is invoked at most once, after which the subscription is removed. + * + * @param eventType - The event type. This is a unique identifier for this event. + * @param handler - The event handler. The type of the parameters for this event + * handler must match the return type of the selector. + * @param options - Options bag. + * @param options.selector - The selector function used to select relevant data + * from the event payload. The type of the parameters for this selector must + * match the type of the payload for this event type. + * @param options.condition - An optional predicate evaluated against the + * selector's return value. The handler is only invoked when this returns `true`. + * @template EventType - A type union of Event type strings. + * @template SelectorReturnValue - The selector return value. + * @example + * ```typescript + * messenger.subscribeOnce( + * 'TransactionController:transactionConfirmed', + * (hash) => { ... }, + * { selector: (tx) => tx.hash, condition: (hash) => hash === 'foo' }, + * ); + * ``` + */ + subscribeOnce( + eventType: EventType, + handler: SelectorEventHandler, + options: { + selector: SelectorFunction; + condition?: (value: SelectorReturnValue) => boolean; + }, + ): void; + + /** + * Subscribe to an event, invoking the handler exactly once. + * + * Registers the given function as an event handler for the given event type + * and automatically unsubscribes after the first invocation. + * + * If `options.condition` is provided, the handler is only invoked (and the + * subscription only removed) when the condition returns `true`. + * + * @param eventType - The event type. This is a unique identifier for this event. + * @param handler - The event handler. The type of the parameters for this event + * handler must match the type of the payload for this event type. + * @param options - Options bag. + * @param options.condition - A predicate evaluated against the event payload. + * The handler is only invoked when this returns `true`. + * @template EventType - A type union of Event type strings. + * @example + * ```typescript + * messenger.subscribeOnce( + * 'TransactionController:transactionConfirmed', + * (tx) => { ... }, + * { condition: (tx) => tx.hash === 'foo' }, + * ); + * ``` + */ + subscribeOnce( + eventType: EventType, + handler: ExtractEventHandler, + options?: { + condition?: ( + ...payload: ExtractEventPayload + ) => boolean; + }, + ): void; + + subscribeOnce( + eventType: EventType, + handler: + | ExtractEventHandler + | SelectorEventHandler, + options?: { + selector?: SelectorFunction; + condition?: + | ((...payload: ExtractEventPayload) => boolean) + | ((value: SelectorReturnValue) => boolean); + }, + ): void { + const { selector, condition } = options ?? {}; + // Casting to unknown to handle both the code path where a selector is defined and where it is omitted. + const internalHandler = (...args: unknown[]): void => { + if ( + condition && + !(condition as (...args: unknown[]) => boolean)(...args) + ) { + return; + } + this.unsubscribe(eventType, internalHandler); + (handler as (...args: unknown[]) => void)(...args); + }; + + this.subscribe( + eventType, + internalHandler, + selector as SelectorFunction, + ); + } + + /** + * Return a promise that resolves the next time the selector's return value + * changes and, if provided, the `options.condition` predicate returns `true`. + * + * @param eventType - The event type. This is a unique identifier for this event. + * @param options - Options bag. + * @param options.selector - The selector function used to select relevant data + * from the event payload. + * @param options.condition - An optional predicate evaluated against the + * selector's return value. The promise only resolves when this returns `true`. + * @template EventType - A type union of Event type strings. + * @template SelectorReturnValue - The selector return value. + * @returns A promise that resolves with the selector's return value. + * @example + * ```typescript + * const [hash] = await messenger.waitUntil( + * 'TransactionController:transactionConfirmed', + * { selector: (tx) => tx.hash, condition: (hash) => hash === 'foo' }, + * ); + * ``` + */ + waitUntil( + eventType: EventType, + options: { + selector: SelectorFunction; + condition?: (value: SelectorReturnValue) => boolean; + }, + ): Promise<[SelectorReturnValue, SelectorReturnValue | undefined]>; + + /** + * Return a promise that resolves the next time the given event is published. + * + * If `options.condition` is provided, the promise only resolves when the + * condition returns `true`. + * + * @param eventType - The event type. This is a unique identifier for this event. + * @param options - Options bag. + * @param options.condition - A predicate evaluated against the event payload. + * The promise only resolves when this returns `true`. + * @template EventType - A type union of Event type strings. + * @returns A promise that resolves with the event payload. + * @example + * ```typescript + * const [transactionMeta] = await messenger.waitUntil( + * 'TransactionController:transactionConfirmed', + * { condition: (tx) => tx.hash === 'foo' }, + * ); + * ``` + * @example + * ```typescript + * await messenger.waitUntil('KeyringController:unlock'); + * ``` + */ + waitUntil( + eventType: EventType, + options?: { + condition?: ( + ...payload: ExtractEventPayload + ) => boolean; + }, + ): Promise>; + + waitUntil( + eventType: EventType, + options?: { + selector?: SelectorFunction; + condition?: + | ((...payload: ExtractEventPayload) => boolean) + | ((value: SelectorReturnValue) => boolean); + }, + ): Promise[0]> { + return new Promise((resolve) => { + this.subscribeOnce( + eventType, + (...args) => resolve(args), + options as { + selector: SelectorFunction; + condition?: (value: SelectorReturnValue) => boolean; + }, + ); + }); + } + + /** + * Unsubscribe from an event. + * + * Unregisters the given function as an event handler for the given event. + * + * @param eventType - The event type. This is a unique identifier for this event. + * @param handler - The event handler to unregister. + * @throws Will throw when the given event handler is not registered for this event. + * @template EventType - A type union of Event type strings. + * @template SelectorReturnValue - The selector return value. + */ + unsubscribe( + eventType: EventType, + handler: + | ExtractEventHandler + | SelectorEventHandler, + ): void { + const subscribers = this.#events.get(eventType); + + // Widen type of event handler by dropping ReturnType parameter. + // + // We need to drop it here because it's used as the parameter to the event handler, and + // functions in general are contravariant over the parameter type. This means the type is no + // longer valid once it's added to a broader type union with other handlers (because as far + // as TypeScript knows, we might call the handler with output from a different selector). + // + // This poses no risk in this case, since we never call the handler past this point. + const widenedHandler = handler as + | ExtractEventHandler + | SelectorEventHandler; + if (!subscribers) { + throw new Error(`Subscription not found for event: ${eventType}`); + } + const metadata = subscribers.get(widenedHandler); + if (!metadata) { + throw new Error(`Subscription not found for event: ${eventType}`); + } + if (metadata.selector) { + this.#eventPayloadCache.delete(widenedHandler); + } + + subscribers.delete(widenedHandler); + } + + /** + * Clear subscriptions for a specific event. + * + * This will remove all subscribed handlers for this event registered from this messenger. The + * event may still have subscribers if it has been delegated to another messenger. + * + * @param eventType - The event type. This is a unique identifier for this event. + * @template EventType - A type union of Event type strings. + */ + clearEventSubscriptions( + eventType: EventType, + ): void { + const subscriptions = this.#events.get(eventType); + if (!subscriptions) { + return; + } + + for (const [handler, metadata] of subscriptions.entries()) { + if (metadata.delegation) { + continue; + } + subscriptions.delete(handler); + } + + if (subscriptions.size === 0) { + this.#events.delete(eventType); + } + } + + /** + * Clear all subscriptions. + * + * This will remove all subscribed handlers for all events registered from this messenger. Events + * may still have subscribers if they are delegated to another messenger. + */ + clearSubscriptions(): void { + for (const eventType of this.#events.keys()) { + this.clearEventSubscriptions(eventType); + } + } + + /** + * Delegate actions and/or events to another messenger. + * + * The messenger these actions/events are delegated to will be able to call these actions and + * subscribe to these events. + * + * Note that the messenger these actions/events are delegated to must still have these + * actions/events included in its type definition (as part of the Action and Event type + * parameters). Actions and events are statically type checked, they cannot be delegated + * dynamically at runtime. + * + * @param args - Arguments. + * @param args.actions - The action types to delegate. + * @param args.events - The event types to delegate. + * @param args.messenger - The messenger to delegate to. + * @template Delegatee - The messenger the actions/events are delegated to. + * @template DelegatedActions - An array of delegated action types. + * @template DelegatedEvents - An array of delegated event types. + */ + delegate< + Delegatee extends Messenger, + DelegatedActions extends (MessengerActions['type'] & + Action['type'])[], + DelegatedEvents extends (MessengerEvents['type'] & + Event['type'])[], + >({ + actions, + events, + messenger, + }: { + actions?: DelegatedActions; + events?: DelegatedEvents; + messenger: Delegatee; + }): void { + for (const actionType of actions ?? []) { + const delegatedActionHandler = ( + ...args: ExtractActionParameters< + MessengerActions & Action, + typeof actionType + > + ): ExtractActionResponse< + MessengerActions & Action, + typeof actionType + > => { + // Cast to get more specific type, for this specific action + // The types get collapsed by `this.#actions` + const actionHandler = this.getAction(actionType) as + | ActionHandler< + MessengerActions & Action, + typeof actionType + > + | undefined; + + if (!actionHandler) { + throw new Error( + `A handler for ${actionType} has not been registered`, + ); + } + + return actionHandler(...args); + }; + let delegationTargets = this.#actionDelegationTargets.get(actionType); + if (!delegationTargets) { + delegationTargets = new Set(); + this.#actionDelegationTargets.set(actionType, delegationTargets); + } + if (delegationTargets.has(messenger)) { + throw new Error( + `The action '${actionType}' has already been delegated to this messenger`, + ); + } + delegationTargets.add(messenger); + + messenger._internalRegisterDelegatedActionHandler( + actionType, + delegatedActionHandler, + ); + } + for (const eventType of events ?? []) { + const untypedSubscriber = ( + ...payload: ExtractEventPayload< + MessengerEvents & Event, + typeof eventType + > + ): void => { + messenger._internalPublishDelegated(eventType, ...payload); + }; + // Cast to get more specific subscriber type for this specific event. + // The types get collapsed here to the type union of all delegated + // events, rather than the single subscriber type corresponding to this + // event. + const subscriber = untypedSubscriber as ExtractEventHandler< + MessengerEvents & Event, + typeof eventType + >; + let delegatedEventSubscriptions = + this.#subscriptionDelegationTargets.get(eventType); + if (!delegatedEventSubscriptions) { + delegatedEventSubscriptions = new Map(); + this.#subscriptionDelegationTargets.set( + eventType, + delegatedEventSubscriptions, + ); + } + if (delegatedEventSubscriptions.has(messenger)) { + throw new Error( + `The event '${eventType}' has already been delegated to this messenger`, + ); + } + delegatedEventSubscriptions.set(messenger, subscriber); + const getPayload = this.#initialEventPayloadGetters.get(eventType); + if (getPayload) { + messenger._internalRegisterDelegatedInitialEventPayload({ + eventType, + getPayload, + }); + } + + this.#subscribe(eventType, subscriber, { delegation: true }); + } + } + + /** + * Delegate all external actions and events to another messenger, with + * compile-time exhaustiveness checking. + * + * Unlike {@link delegate}, which accepts a partial list of actions/events, + * this method requires that **every** action and event the delegatee needs + * from outside its own namespace is included. If any are missing, TypeScript + * produces a type error showing the missing items. + * + * The source messenger's action/event types must include every required + * external item. Items the source cannot provide still appear in the + * missing set, so incomplete source typing fails loudly instead of being + * silently skipped. + * + * Use this when a single source messenger provides all external + * actions/events for a child messenger (the common pattern in controller + * initialisation). + * + * @param args - Arguments. + * @param args.actions - The action types to delegate. Must include every + * action type defined on the delegatee that is **not** under its own + * namespace. + * @param args.events - The event types to delegate. Must include every event + * type defined on the delegatee that is **not** under its own namespace. + * @param args.messenger - The messenger to delegate to. + * @template Delegatee - The messenger the actions/events are delegated to. + * @template DelegatedActions - An array of delegated action type strings. + * @template DelegatedEvents - An array of delegated event type strings. + */ + delegateAll< + Delegatee extends Messenger, + DelegatedActions extends (MessengerActions['type'] & + Action['type'])[], + DelegatedEvents extends (MessengerEvents['type'] & + Event['type'])[], + >({ + actions, + events, + messenger, + }: { + messenger: Delegatee; + actions: RequireExhaustive< + NotNamespacedBy< + MessengerNamespace, + MessengerActions['type'] + >, + DelegatedActions + >; + events: RequireExhaustive< + NotNamespacedBy< + MessengerNamespace, + MessengerEvents['type'] + >, + DelegatedEvents + >; + }): void { + this.delegate({ actions, events, messenger }); + } + + /** + * Revoke delegated actions and/or events from another messenger. + * + * The messenger these actions/events are delegated to will no longer be able to call these + * actions or subscribe to these events. + * + * @param args - Arguments. + * @param args.actions - The action types to revoke. + * @param args.events - The event types to revoke. + * @param args.messenger - The messenger these actions/events were delegated to. + * @template Delegatee - The messenger the actions/events are being revoked from. + * @template DelegatedActions - An array of delegated action types. + * @template DelegatedEvents - An array of delegated event types. + */ + revoke< + Delegatee extends Messenger, + DelegatedActions extends (MessengerActions['type'] & + Action['type'])[], + DelegatedEvents extends (MessengerEvents['type'] & + Event['type'])[], + >({ + actions, + events, + messenger, + }: { + actions?: DelegatedActions; + events?: DelegatedEvents; + messenger: Delegatee; + }): void { + if (messenger === this.#parent) { + throw new Error('Cannot revoke from parent'); + } + for (const actionType of actions ?? []) { + const delegationTargets = this.#actionDelegationTargets.get(actionType); + if (!delegationTargets?.has(messenger)) { + // Nothing to revoke + continue; + } + messenger._internalUnregisterDelegatedActionHandler(actionType); + delegationTargets.delete(messenger); + if (delegationTargets.size === 0) { + this.#actionDelegationTargets.delete(actionType); + } + } + for (const eventType of events ?? []) { + const delegationTargets = + this.#subscriptionDelegationTargets.get(eventType); + if (!delegationTargets) { + // Nothing to revoke + continue; + } + const delegatedSubscriber = delegationTargets.get(messenger); + if (!delegatedSubscriber) { + // Nothing to revoke + continue; + } + this.unsubscribe(eventType, delegatedSubscriber); + delegationTargets.delete(messenger); + if (delegationTargets.size === 0) { + this.#subscriptionDelegationTargets.delete(eventType); + } + } + } + + /** + * Register an action handler for an action delegated from another messenger. + * + * This will make the registered function available to call via the `call` method. + * + * Note: This is an internal method. Never access this property from another module. This must be + * exposed as a public property so that these methods can be called internally on other messenger + * instances. + * + * @deprecated Internal use only. Use the `delegate` method for delegation. + * @param actionType - The action type. This is a unique identifier for this action. + * @param handler - The action handler. This function gets called when the `call` method is + * invoked with the given action type. + * @throws Will throw when a handler has been registered for this action type already. + * @template ActionType - A type union of Action type strings. + */ + _internalRegisterDelegatedActionHandler( + actionType: ActionType, + // Using wider `ActionConstraint` type here rather than `Action` because the `Action` type is + // contravariant over the handler parameter type. Using `Action` would lead to a type error + // here because the messenger we've delegated to supports _additional_ actions. + handler: ActionHandler, + ): void { + this.#registerActionHandler(actionType, handler); + } + + /** + * Unregister an action handler for an action delegated from another messenger. + * + * This will prevent this action from being called. + * + * Note: This is an internal method. Never access this property from another module. This must be + * exposed as a public property so that these methods can be called internally on other messenger + * instances. + * + * @deprecated Internal use only. Use the `delegate` method for delegation. + * @param actionType - The action type. This is a unqiue identifier for this action. + * @template ActionType - A type union of Action type strings. + */ + _internalUnregisterDelegatedActionHandler( + actionType: ActionType, + ): void { + this.#unregisterActionHandler(actionType); + } + + /** + * Register a function for getting the initial payload for an event that has been delegated from + * another messenger. + * + * This is used for events that represent a state change, where the payload is the state. + * Registering a function for getting the payload allows event selectors to have a point of + * comparison the first time state changes. + * + * Note: This is an internal method. Never access this property from another module. This must be + * exposed as a public property so that these methods can be called internally on other messenger + * instances. + * + * @deprecated Internal use only. Use the `delegate` method for delegation. + * @param args - The arguments to this function + * @param args.eventType - The event type to register a payload for. + * @param args.getPayload - A function for retrieving the event payload. + */ + _internalRegisterDelegatedInitialEventPayload< + EventType extends Event['type'], + >({ + eventType, + getPayload, + }: { + eventType: EventType; + getPayload: () => ExtractEventPayload; + }): void { + this.#registerInitialEventPayload({ eventType, getPayload }); + } + + /** + * Publish an event that was delegated from another messenger. + * + * Publishes the given payload to all subscribers of the given event type. + * + * Note that this method should never throw directly. Any errors from + * subscribers are captured and re-thrown in a timeout handler. + * + * Note: This is an internal method. Never access this property from another module. This must be + * exposed as a public property so that these methods can be called internally on other messenger + * instances. + * + * @deprecated Internal use only. Use the `delegate` method for delegation. + * @param eventType - The event type. This is a unique identifier for this event. + * @param payload - The event payload. The type of the parameters for each event handler must + * match the type of this payload. + * @template EventType - A type union of Event type strings. + */ + _internalPublishDelegated( + eventType: EventType, + ...payload: ExtractEventPayload + ): void { + this.#publish(eventType, ...payload); + } + + /** + * Determine whether the given name is within the current namespace. + * + * If the current namespace is MOCK_ANY_NAMESPACE, this check always returns true. + * + * @param name - The name to check + * @returns Whether the name is within the current namespace + */ + #isInCurrentNamespace(name: string): name is NamespacedName { + return ( + this.#namespace === MOCK_ANY_NAMESPACE || + name.startsWith(`${this.#namespace}:`) + ); + } +} diff --git a/packages/messenger/src/Messenger.tst.ts b/packages/messenger/src/Messenger.tst.ts new file mode 100644 index 00000000000..757abe21991 --- /dev/null +++ b/packages/messenger/src/Messenger.tst.ts @@ -0,0 +1,108 @@ +import { describe, expect, test } from 'tstyche'; + +import { Messenger } from './Messenger.js'; + +describe('Messenger', () => { + describe('delegateAll', () => { + type ActionA = { type: 'A:getValue'; handler: () => number }; + type ActionB = { type: 'B:getName'; handler: () => string }; + type ChildOwnAction = { type: 'Child:doStuff'; handler: () => void }; + type EventA = { + type: 'A:stateChange'; + payload: [{ value: number }]; + }; + type EventB = { + type: 'B:nameChange'; + payload: [{ name: string }]; + }; + + test('accepts a complete list of external actions and events', () => { + const source = new Messenger< + 'Source', + ActionA | ActionB | ChildOwnAction, + EventA | EventB + >({ namespace: 'Source' }); + const child = new Messenger< + 'Child', + ActionA | ActionB | ChildOwnAction, + EventA | EventB + >({ namespace: 'Child' }); + + expect( + source.delegateAll({ + messenger: child, + actions: ['A:getValue', 'B:getName'], + events: ['A:stateChange', 'B:nameChange'], + }), + ).type.not.toRaiseError(); + }); + + test('excludes the delegatee own-namespace actions from the exhaustiveness check', () => { + const source = new Messenger<'Source', ActionA | ChildOwnAction, never>({ + namespace: 'Source', + }); + const child = new Messenger<'Child', ActionA | ChildOwnAction, never>({ + namespace: 'Child', + }); + + expect( + source.delegateAll({ + messenger: child, + actions: ['A:getValue'], + events: [], + }), + ).type.not.toRaiseError(); + }); + + test('raises a type error when an external action is missing', () => { + const source = new Messenger<'Source', ActionA | ActionB, never>({ + namespace: 'Source', + }); + const child = new Messenger<'Child', ActionA | ActionB, never>({ + namespace: 'Child', + }); + + expect( + source.delegateAll({ + messenger: child, + actions: ['A:getValue'], + events: [], + }), + ).type.toRaiseError(); + }); + + test('raises a type error when an external event is missing', () => { + const source = new Messenger<'Source', never, EventA | EventB>({ + namespace: 'Source', + }); + const child = new Messenger<'Child', never, EventA | EventB>({ + namespace: 'Child', + }); + + expect( + source.delegateAll({ + messenger: child, + actions: [], + events: ['A:stateChange'], + }), + ).type.toRaiseError(); + }); + + test('raises a type error when the source cannot provide a required external action', () => { + const source = new Messenger<'Source', ActionA, never>({ + namespace: 'Source', + }); + const child = new Messenger<'Child', ActionA | ActionB, never>({ + namespace: 'Child', + }); + + expect( + source.delegateAll({ + messenger: child, + actions: ['A:getValue'], + events: [], + }), + ).type.toRaiseError(); + }); + }); +}); diff --git a/packages/messenger/src/index.test.ts b/packages/messenger/src/index.test.ts new file mode 100644 index 00000000000..266a5bec561 --- /dev/null +++ b/packages/messenger/src/index.test.ts @@ -0,0 +1,12 @@ +import * as allExports from './index.js'; + +describe('@metamask/messenger', () => { + it('has expected JavaScript exports', () => { + expect(Object.keys(allExports)).toMatchInlineSnapshot(` + [ + "MOCK_ANY_NAMESPACE", + "Messenger", + ] + `); + }); +}); diff --git a/packages/messenger/src/index.ts b/packages/messenger/src/index.ts new file mode 100644 index 00000000000..f7d78fca57a --- /dev/null +++ b/packages/messenger/src/index.ts @@ -0,0 +1,19 @@ +export type { + ActionHandler, + ExtractActionParameters, + ExtractActionResponse, + ExtractEventHandler, + ExtractEventPayload, + GenericEventHandler, + SelectorFunction, + ActionConstraint, + EventConstraint, + MessengerActions, + MessengerEvents, + MessengerNamespace, + MockAnyNamespace, + NamespacedBy, + NotNamespacedBy, + NamespacedName, +} from './Messenger.js'; +export { MOCK_ANY_NAMESPACE, Messenger } from './Messenger.js'; diff --git a/packages/messenger/tsconfig.build.json b/packages/messenger/tsconfig.build.json new file mode 100644 index 00000000000..12ff215ad3b --- /dev/null +++ b/packages/messenger/tsconfig.build.json @@ -0,0 +1,11 @@ +{ + "extends": "../../tsconfig.packages.build.json", + "compilerOptions": { + "baseUrl": "./", + "outDir": "./dist", + "rootDir": "./src" + }, + "references": [], + "include": ["../../types", "./src"], + "exclude": ["**/*.test.ts", "**/*.tst.ts"] +} diff --git a/packages/messenger/tsconfig.json b/packages/messenger/tsconfig.json new file mode 100644 index 00000000000..025ba2ef7f4 --- /dev/null +++ b/packages/messenger/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../../tsconfig.packages.json", + "compilerOptions": { + "baseUrl": "./" + }, + "references": [], + "include": ["../../types", "./src"] +} diff --git a/packages/messenger/tsconfig.lint.json b/packages/messenger/tsconfig.lint.json new file mode 100644 index 00000000000..672c3683956 --- /dev/null +++ b/packages/messenger/tsconfig.lint.json @@ -0,0 +1,8 @@ +{ + "extends": ["./tsconfig.json", "../../tsconfig.packages.lint.json"], + "compilerOptions": { + "outDir": "./.tsc-lint-cache", + "tsBuildInfoFile": "./.tsc-lint-cache/tsconfig.tsbuildinfo" + }, + "exclude": ["**/*.tst.ts"] +} diff --git a/packages/messenger/typedoc.json b/packages/messenger/typedoc.json new file mode 100644 index 00000000000..c9da015dbf8 --- /dev/null +++ b/packages/messenger/typedoc.json @@ -0,0 +1,7 @@ +{ + "entryPoints": ["./src/index.ts"], + "excludePrivate": true, + "hideGenerator": true, + "out": "docs", + "tsconfig": "./tsconfig.build.json" +} diff --git a/packages/money-account-api-data-service/CHANGELOG.md b/packages/money-account-api-data-service/CHANGELOG.md new file mode 100644 index 00000000000..2dff9f1efc0 --- /dev/null +++ b/packages/money-account-api-data-service/CHANGELOG.md @@ -0,0 +1,53 @@ +# Changelog + +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [Unreleased] + +## [0.4.1] + +### Changed + +- Bump `@metamask/superstruct` from `^3.1.0` to `^3.4.1` ([#9754](https://github.com/MetaMask/core/pull/9754)) +- Bump `@tanstack/query-core` from `^4.43.0` to `^5.62.16` ([#9712](https://github.com/MetaMask/core/pull/9712)) +- Bump `@metamask/base-data-service` from `^0.1.3` to `^1.0.0` ([#9972](https://github.com/MetaMask/core/pull/9972)) + +## [0.4.0] + +### Changed + +- Add best-effort profile JWT authentication to Money Account API requests, falling back to unauthenticated requests when a token is unavailable ([#9661](https://github.com/MetaMask/core/pull/9661)) + +## [0.3.0] + +### Added + +- Add optional `trace` callback to `MoneyAccountApiDataService` constructor for network request tracing ([#9451](https://github.com/MetaMask/core/pull/9451)) + - All HTTP calls (`fetchPositions`, `fetchInterest`, `fetchHistory`, `fetchRateHistory`) emit best-effort backdated traces with `startTime`, `success`, and `errorName` attributes + - Tracing is isolated from fetch/retry logic; trace failures do not impact queries + +## [0.2.0] + +### Added + +- Add optional nullable `balance` field to the positions response (`musd_balance`, `vmusd_value_in_musd`, `total_balance`), matching the Money Account API contract. Export `PositionBalance` type. ([#9554](https://github.com/MetaMask/core/pull/9554)) + +## [0.1.0] + +### Added + +- Add `MoneyAccountApiDataService` data service ([#9402](https://github.com/MetaMask/core/pull/9402)) + - Fetch user vault positions from the Money Account API (`fetchPositions`) + - Fetch interest earned over a time window (`fetchInterest`) + - Fetch cursor-paginated cash-flow history (`fetchHistory`) + - Fetch vault exchange-rate time series (`fetchRateHistory`) + +[Unreleased]: https://github.com/MetaMask/core/compare/@metamask/money-account-api-data-service@0.4.1...HEAD +[0.4.1]: https://github.com/MetaMask/core/compare/@metamask/money-account-api-data-service@0.4.0...@metamask/money-account-api-data-service@0.4.1 +[0.4.0]: https://github.com/MetaMask/core/compare/@metamask/money-account-api-data-service@0.3.0...@metamask/money-account-api-data-service@0.4.0 +[0.3.0]: https://github.com/MetaMask/core/compare/@metamask/money-account-api-data-service@0.2.0...@metamask/money-account-api-data-service@0.3.0 +[0.2.0]: https://github.com/MetaMask/core/compare/@metamask/money-account-api-data-service@0.1.0...@metamask/money-account-api-data-service@0.2.0 +[0.1.0]: https://github.com/MetaMask/core/releases/tag/@metamask/money-account-api-data-service@0.1.0 diff --git a/packages/money-account-api-data-service/LICENSE b/packages/money-account-api-data-service/LICENSE new file mode 100644 index 00000000000..fe29e78e0fe --- /dev/null +++ b/packages/money-account-api-data-service/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/money-account-api-data-service/LICENSE.APACHE2 b/packages/money-account-api-data-service/LICENSE.APACHE2 new file mode 100644 index 00000000000..e6e77b08909 --- /dev/null +++ b/packages/money-account-api-data-service/LICENSE.APACHE2 @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/packages/money-account-api-data-service/LICENSE.MIT b/packages/money-account-api-data-service/LICENSE.MIT new file mode 100644 index 00000000000..fe29e78e0fe --- /dev/null +++ b/packages/money-account-api-data-service/LICENSE.MIT @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 MetaMask + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packages/money-account-api-data-service/README.md b/packages/money-account-api-data-service/README.md new file mode 100644 index 00000000000..cb913820cc5 --- /dev/null +++ b/packages/money-account-api-data-service/README.md @@ -0,0 +1,26 @@ +# `@metamask/money-account-api-data-service` + +Data service for fetching Money account positions, interest, cash-flow history, and vault rate history from the Money Account API. + +## Installation + +`yarn add @metamask/money-account-api-data-service` + +or + +`npm install @metamask/money-account-api-data-service` + +## Usage + +This package exports a `MoneyAccountApiDataService` class that exposes the following methods through the messenger pattern: + +- **`fetchPositions`** — Fetch user vault positions from the Money Account API. +- **`fetchInterest`** — Fetch interest earned over a time window. +- **`fetchHistory`** — Fetch cursor-paginated cash-flow history. +- **`fetchRateHistory`** — Fetch vault exchange-rate time series. + +See the [main `MoneyAccountApiDataService` source](./src/money-account-api-data-service.ts) for full API details. + +## 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/money-account-api-data-service/jest.config.js b/packages/money-account-api-data-service/jest.config.js new file mode 100644 index 00000000000..c17efa251af --- /dev/null +++ b/packages/money-account-api-data-service/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, + + coverageThreshold: { + global: { + branches: 100, + functions: 100, + lines: 100, + statements: 100, + }, + }, +}); diff --git a/packages/money-account-api-data-service/package.json b/packages/money-account-api-data-service/package.json new file mode 100644 index 00000000000..d08248443cf --- /dev/null +++ b/packages/money-account-api-data-service/package.json @@ -0,0 +1,81 @@ +{ + "name": "@metamask/money-account-api-data-service", + "version": "0.4.1", + "description": "Data service for fetching Money account positions, interest, cash-flow history, and vault rate history from the Money Account API", + "keywords": [ + "Ethereum", + "MetaMask" + ], + "homepage": "https://github.com/MetaMask/core/tree/main/packages/money-account-api-data-service#readme", + "bugs": { + "url": "https://github.com/MetaMask/core/issues" + }, + "license": "MIT", + "repository": { + "type": "git", + "url": "https://github.com/MetaMask/core.git" + }, + "files": [ + "dist/" + ], + "sideEffects": false, + "main": "./dist/index.cjs", + "types": "./dist/index.d.cts", + "exports": { + ".": { + "import": { + "types": "./dist/index.d.mts", + "default": "./dist/index.mjs" + }, + "require": { + "types": "./dist/index.d.cts", + "default": "./dist/index.cjs" + } + }, + "./package.json": "./package.json" + }, + "publishConfig": { + "access": "public", + "registry": "https://registry.npmjs.org/" + }, + "scripts": { + "build": "ts-bridge --project tsconfig.build.json --verbose --clean --no-references", + "build:all": "ts-bridge --project tsconfig.build.json --verbose --clean", + "build:docs": "typedoc", + "changelog:update": "../../scripts/update-changelog.sh @metamask/money-account-api-data-service", + "changelog:validate": "../../scripts/validate-changelog.sh @metamask/money-account-api-data-service", + "lint:tsconfigs": "tsx ../../scripts/lint-tsconfigs/lint-tsconfigs.mts", + "lint:tsconfigs:fix": "tsx ../../scripts/lint-tsconfigs/lint-tsconfigs.mts --fix", + "messenger-action-types:check": "tsx ../../packages/messenger-cli/src/cli.ts --formatter oxfmt --esm --check", + "messenger-action-types:generate": "tsx ../../packages/messenger-cli/src/cli.ts --formatter oxfmt --esm --generate", + "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-data-service": "^1.0.0", + "@metamask/controller-utils": "^12.3.0", + "@metamask/messenger": "^2.0.0", + "@metamask/superstruct": "^3.4.1", + "@metamask/utils": "^11.11.0", + "@tanstack/query-core": "^5.62.16" + }, + "devDependencies": { + "@metamask/auto-changelog": "^6.1.0", + "@ts-bridge/cli": "^0.6.4", + "@types/jest": "^30.0.0", + "deepmerge": "^4.2.2", + "jest": "^30.4.2", + "nock": "^13.3.1", + "ts-jest": "^29.4.11", + "tsx": "^4.20.5", + "typedoc": "^0.25.13", + "typedoc-plugin-missing-exports": "^2.0.0", + "typescript": "~5.3.3" + }, + "engines": { + "node": "^18.18 || >=20" + } +} diff --git a/packages/money-account-api-data-service/src/constants.ts b/packages/money-account-api-data-service/src/constants.ts new file mode 100644 index 00000000000..120cfe8ab8f --- /dev/null +++ b/packages/money-account-api-data-service/src/constants.ts @@ -0,0 +1,29 @@ +/** + * Supported environments for the Money Account APY Tracking API. + */ +export enum Env { + DEV = 'dev', + UAT = 'uat', + PRD = 'prd', +} + +/** + * Base URL map for the Money Account APY Tracking API, keyed by environment. + */ +export const MONEY_ACCOUNT_API_URL_MAP: Record = { + [Env.DEV]: 'https://money.dev-api.cx.metamask.io', + [Env.UAT]: 'https://money.uat-api.cx.metamask.io', + [Env.PRD]: 'https://money.api.cx.metamask.io', +}; + +/** + * Default stale time (ms) for position/interest/history queries. + * Matches the server-side cache TTL of 30 seconds. + */ +export const DEFAULT_STALE_TIME_MS = 30_000; + +/** + * Default stale time (ms) for rate-history queries. + * Matches the server-side cache TTL of 5 minutes. + */ +export const RATE_HISTORY_STALE_TIME_MS = 300_000; diff --git a/packages/money-account-api-data-service/src/errors.ts b/packages/money-account-api-data-service/src/errors.ts new file mode 100644 index 00000000000..de31a872247 --- /dev/null +++ b/packages/money-account-api-data-service/src/errors.ts @@ -0,0 +1,13 @@ +/** + * Thrown when a response from the Money Account API fails superstruct + * validation. Indicates a contract mismatch between client and server. + */ +export class MoneyAccountApiResponseValidationError extends Error { + constructor(message?: string) { + super( + message ?? + 'MoneyAccountApiDataService: malformed response received from Money Account API', + ); + this.name = 'MoneyAccountApiResponseValidationError'; + } +} diff --git a/packages/money-account-api-data-service/src/index.ts b/packages/money-account-api-data-service/src/index.ts new file mode 100644 index 00000000000..bf60cee5d1c --- /dev/null +++ b/packages/money-account-api-data-service/src/index.ts @@ -0,0 +1,36 @@ +export { MoneyAccountApiDataService } from './money-account-api-data-service.js'; +export type { + MoneyAccountApiDataServiceActions, + MoneyAccountApiDataServiceEvents, + MoneyAccountApiDataServiceMessenger, + MoneyAccountApiDataServiceOptions, + MoneyAccountApiDataServiceTraceCallback, + MoneyAccountApiDataServiceTraceRequest, +} from './money-account-api-data-service.js'; +export type { + MoneyAccountApiDataServiceFetchPositionsAction, + MoneyAccountApiDataServiceFetchInterestAction, + MoneyAccountApiDataServiceFetchHistoryAction, + MoneyAccountApiDataServiceFetchRateHistoryAction, +} from './money-account-api-data-service-method-action-types.js'; +export type { + PositionResponse, + PositionBalance, + InterestResponse, + HistoryResponse, + RateHistoryResponse, + VaultPosition, + CashFlowEntry, + RateHistoryEntry, + DataFreshness, + CashFlowType, + CashFlowSource, +} from './response.types'; +export type { + InterestWindow, + InterestOptions, + HistoryOptions, + RateHistoryOptions, +} from './types.js'; +export { Env } from './constants.js'; +export { MoneyAccountApiResponseValidationError } from './errors.js'; diff --git a/packages/money-account-api-data-service/src/logger.ts b/packages/money-account-api-data-service/src/logger.ts new file mode 100644 index 00000000000..3b9c4a811d1 --- /dev/null +++ b/packages/money-account-api-data-service/src/logger.ts @@ -0,0 +1,7 @@ +import { createProjectLogger, createModuleLogger } from '@metamask/utils'; + +export const projectLogger = createProjectLogger( + 'money-account-api-data-service', +); + +export { createModuleLogger }; diff --git a/packages/money-account-api-data-service/src/money-account-api-data-service-method-action-types.ts b/packages/money-account-api-data-service/src/money-account-api-data-service-method-action-types.ts new file mode 100644 index 00000000000..20470a20c29 --- /dev/null +++ b/packages/money-account-api-data-service/src/money-account-api-data-service-method-action-types.ts @@ -0,0 +1,70 @@ +/** + * This file is auto generated. + * Do not edit manually. + */ + +import type { MoneyAccountApiDataService } from './money-account-api-data-service.js'; + +/** + * Fetches the current vault positions for a given user address. + * + * @param address - The user's Ethereum address. + * @returns The position response containing vault positions and an optional + * `balance` summary (`null` when the API balance path is unavailable). + */ +export type MoneyAccountApiDataServiceFetchPositionsAction = { + type: `MoneyAccountApiDataService:fetchPositions`; + handler: MoneyAccountApiDataService['fetchPositions']; +}; + +/** + * Fetches the interest earned for a given address and vault over a + * specified time window. + * + * @param address - The user's Ethereum address. + * @param options - Options specifying vault, window, and optional chain ID. + * @returns The interest response. + */ +export type MoneyAccountApiDataServiceFetchInterestAction = { + type: `MoneyAccountApiDataService:fetchInterest`; + handler: MoneyAccountApiDataService['fetchInterest']; +}; + +/** + * Fetches cursor-paginated cash-flow history for a given address. + * Uses `fetchInfiniteQuery` for proper TanStack Query pagination semantics. + * + * When paginating, consumers must re-pass the same filter options + * (`vaultAddress`, `chainId`, `limit`) alongside `cursor` on every page + * request. This ensures the query key matches the original infinite query + * and that the HTTP request includes the correct filters. + * + * @param address - The user's Ethereum address. + * @param options - Optional filtering and pagination options. + * @returns The history response containing cash-flow entries for the requested page. + */ +export type MoneyAccountApiDataServiceFetchHistoryAction = { + type: `MoneyAccountApiDataService:fetchHistory`; + handler: MoneyAccountApiDataService['fetchHistory']; +}; + +/** + * Fetches the exchange-rate time series for a given vault. + * + * @param vaultAddress - The vault's Ethereum address. + * @param options - Optional range and chain ID filters. + * @returns The rate history response. + */ +export type MoneyAccountApiDataServiceFetchRateHistoryAction = { + type: `MoneyAccountApiDataService:fetchRateHistory`; + handler: MoneyAccountApiDataService['fetchRateHistory']; +}; + +/** + * Union of all MoneyAccountApiDataService action types. + */ +export type MoneyAccountApiDataServiceMethodActions = + | MoneyAccountApiDataServiceFetchPositionsAction + | MoneyAccountApiDataServiceFetchInterestAction + | MoneyAccountApiDataServiceFetchHistoryAction + | MoneyAccountApiDataServiceFetchRateHistoryAction; diff --git a/packages/money-account-api-data-service/src/money-account-api-data-service.test.ts b/packages/money-account-api-data-service/src/money-account-api-data-service.test.ts new file mode 100644 index 00000000000..ed89b3de49d --- /dev/null +++ b/packages/money-account-api-data-service/src/money-account-api-data-service.test.ts @@ -0,0 +1,1027 @@ +import { DEFAULT_MAX_RETRIES, HttpError } from '@metamask/controller-utils'; +import { Messenger, MOCK_ANY_NAMESPACE } from '@metamask/messenger'; +import type { + MockAnyNamespace, + MessengerActions, + MessengerEvents, +} from '@metamask/messenger'; +import nock, { cleanAll as nockCleanAll } from 'nock'; + +import { Env, MONEY_ACCOUNT_API_URL_MAP } from './constants.js'; +import { MoneyAccountApiResponseValidationError } from './errors.js'; +import type { + MoneyAccountApiDataServiceMessenger, + MoneyAccountApiDataServiceTraceCallback, + MoneyAccountApiDataServiceTraceRequest, +} from './money-account-api-data-service.js'; +import { + MoneyAccountApiDataService, + serviceName, + TRACES, +} from './money-account-api-data-service.js'; + +// ============================================================ +// Fixtures +// ============================================================ + +const MOCK_ADDRESS = '0x1111111111111111111111111111111111111111'; +const MOCK_VAULT_ADDRESS = '0x2222222222222222222222222222222222222222'; + +const MOCK_POSITION_BALANCE = { + musd_balance: '2', + vmusd_value_in_musd: '1513527', + total_balance: '1513529', +}; + +const MOCK_POSITION_RESPONSE = { + address: MOCK_ADDRESS, + as_of_block: 12345, + as_of_timestamp: '2026-06-01T12:00:00Z', + data_freshness: 'live' as const, + indexer_lag_seconds: 5, + balance: MOCK_POSITION_BALANCE, + positions: [ + { + vault_address: MOCK_VAULT_ADDRESS, + shares_held: '1000000000000000000', + current_rate: '1052340000000000000', + current_value_assets: '1052340000000000000', + current_value_usd: '1052.34', + cost_basis_assets: '1000000000000000000', + cost_basis_usd: '1000.00', + realized_interest_usd: '10.00', + unrealised_interest_usd: '42.34', + lifetime_interest_usd: '52.34', + current_apy: '0.0412', + effective_apy: '0.0395', + }, + ], +}; + +const MOCK_INTEREST_RESPONSE = { + address: MOCK_ADDRESS, + vault_address: MOCK_VAULT_ADDRESS, + window: '7d', + window_start: '2026-05-25T00:00:00Z', + window_end: '2026-06-01T00:00:00Z', + interest_earned_assets: '5000000000000000', + interest_earned_usd: '5.00', + method: 'nav_difference', + as_of_block: 12345, + as_of_timestamp: '2026-06-01T12:00:00Z', + data_freshness: 'live' as const, + indexer_lag_seconds: 5, +}; + +const MOCK_HISTORY_RESPONSE = { + address: MOCK_ADDRESS, + cash_flows: [ + { + type: 'deposit' as const, + chain_id: 143, + vault_address: MOCK_VAULT_ADDRESS, + timestamp: '2026-05-01T10:00:00Z', + block_number: 10000, + log_index: 0, + tx_hash: '0xabc123', + assets_usd: '1000.00', + assets_wei: '1000000000000000000', + shares_wei: '950000000000000000', + rate: '1.052340', + source: 'teller' as const, + }, + ], + next_cursor: 'eyJiIjoxMDAwMH0=', + has_more: true, + as_of_block: 12345, + as_of_timestamp: '2026-06-01T12:00:00Z', + data_freshness: 'live' as const, + indexer_lag_seconds: 5, +}; + +const MOCK_RATE_HISTORY_RESPONSE = { + vault_address: MOCK_VAULT_ADDRESS, + chain_id: 143, + range_start: '2026-05-01T00:00:00Z', + range_end: '2026-06-01T00:00:00Z', + rates: [ + { + timestamp: '2026-05-01T00:00:00Z', + block_number: 10000, + rate: '1.000000000000000000', + tx_hash: '0xdef456', + }, + { + timestamp: '2026-06-01T00:00:00Z', + block_number: 12345, + rate: '1.052340000000000000', + tx_hash: '0xghi789', + }, + ], + as_of_block: 12345, + as_of_timestamp: '2026-06-01T12:00:00Z', + data_freshness: 'live' as const, + indexer_lag_seconds: 5, +}; + +// ============================================================ +// Messenger helpers +// ============================================================ + +type RootMessenger = Messenger< + MockAnyNamespace, + MessengerActions, + MessengerEvents +>; + +function createRootMessenger(): RootMessenger { + return new Messenger({ namespace: MOCK_ANY_NAMESPACE }); +} + +function createServiceMessenger( + rootMessenger: RootMessenger, +): MoneyAccountApiDataServiceMessenger { + return new Messenger({ + namespace: serviceName, + parent: rootMessenger, + }); +} + +// ============================================================ +// Factory +// ============================================================ + +function createService( + env: Env = Env.DEV, + { + trace, + getBearerToken, + }: { + trace?: MoneyAccountApiDataServiceTraceCallback; + getBearerToken?: () => Promise; + } = {}, +): { + service: MoneyAccountApiDataService; + rootMessenger: RootMessenger; + messenger: MoneyAccountApiDataServiceMessenger; +} { + const rootMessenger = createRootMessenger(); + const messenger = createServiceMessenger(rootMessenger); + if (getBearerToken) { + rootMessenger.registerActionHandler( + 'AuthenticationController:getBearerToken', + getBearerToken, + ); + } + rootMessenger.delegate({ + messenger, + actions: ['AuthenticationController:getBearerToken'], + events: [], + }); + const service = new MoneyAccountApiDataService({ + messenger, + env, + trace, + }); + return { service, rootMessenger, messenger }; +} + +// ============================================================ +// Tests +// ============================================================ + +describe('MoneyAccountApiDataService', () => { + afterEach(() => { + nockCleanAll(); + }); + + describe('constructor', () => { + it('initializes with default PRD environment', () => { + const rootMessenger = createRootMessenger(); + const messenger = createServiceMessenger(rootMessenger); + const service = new MoneyAccountApiDataService({ messenger }); + expect(service.name).toBe(serviceName); + service.destroy(); + }); + + it('initializes with specified environment', () => { + const { service } = createService(Env.UAT); + expect(service.name).toBe(serviceName); + service.destroy(); + }); + }); + + describe('authentication headers', () => { + it('attaches the profile bearer token to every API request', async () => { + const getBearerToken = jest.fn().mockResolvedValue('jwt-token'); + const { service } = createService(Env.DEV, { getBearerToken }); + const requestHeaders = { + reqheaders: { authorization: 'Bearer jwt-token' }, + }; + + const positionsScope = nock( + MONEY_ACCOUNT_API_URL_MAP[Env.DEV], + requestHeaders, + ) + .get(`/v1/positions/${MOCK_ADDRESS}`) + .reply(200, MOCK_POSITION_RESPONSE); + const interestScope = nock( + MONEY_ACCOUNT_API_URL_MAP[Env.DEV], + requestHeaders, + ) + .get(`/v1/positions/${MOCK_ADDRESS}/interest`) + .query({ + vault_address: MOCK_VAULT_ADDRESS, + window: '7d', + }) + .reply(200, MOCK_INTEREST_RESPONSE); + const historyScope = nock( + MONEY_ACCOUNT_API_URL_MAP[Env.DEV], + requestHeaders, + ) + .get(`/v1/positions/${MOCK_ADDRESS}/history`) + .reply(200, MOCK_HISTORY_RESPONSE); + const rateHistoryScope = nock( + MONEY_ACCOUNT_API_URL_MAP[Env.DEV], + requestHeaders, + ) + .get(`/v1/vaults/${MOCK_VAULT_ADDRESS}/rate-history`) + .reply(200, MOCK_RATE_HISTORY_RESPONSE); + + await service.fetchPositions(MOCK_ADDRESS); + await service.fetchInterest(MOCK_ADDRESS, { + vaultAddress: MOCK_VAULT_ADDRESS, + window: '7d', + }); + await service.fetchHistory(MOCK_ADDRESS); + await service.fetchRateHistory(MOCK_VAULT_ADDRESS); + + expect(getBearerToken).toHaveBeenCalledTimes(4); + positionsScope.done(); + interestScope.done(); + historyScope.done(); + rateHistoryScope.done(); + service.destroy(); + }); + + it('proceeds unauthenticated when token retrieval fails', async () => { + const { service } = createService(Env.DEV, { + getBearerToken: async () => { + throw new Error('wallet is locked'); + }, + }); + const scope = nock(MONEY_ACCOUNT_API_URL_MAP[Env.DEV], { + badheaders: ['authorization'], + }) + .get(`/v1/positions/${MOCK_ADDRESS}`) + .reply(200, MOCK_POSITION_RESPONSE); + + const result = await service.fetchPositions(MOCK_ADDRESS); + + expect(result).toStrictEqual(MOCK_POSITION_RESPONSE); + scope.done(); + service.destroy(); + }); + + it('omits the authorization header when the token is empty', async () => { + const { service } = createService(Env.DEV, { + getBearerToken: async () => '', + }); + const scope = nock(MONEY_ACCOUNT_API_URL_MAP[Env.DEV], { + badheaders: ['authorization'], + }) + .get(`/v1/positions/${MOCK_ADDRESS}`) + .reply(200, MOCK_POSITION_RESPONSE); + + const result = await service.fetchPositions(MOCK_ADDRESS); + + expect(result).toStrictEqual(MOCK_POSITION_RESPONSE); + scope.done(); + service.destroy(); + }); + }); + + describe('fetchPositions', () => { + it('returns position data for a valid address', async () => { + const { service } = createService(Env.DEV); + + nock(MONEY_ACCOUNT_API_URL_MAP[Env.DEV]) + .get(`/v1/positions/${MOCK_ADDRESS}`) + .reply(200, MOCK_POSITION_RESPONSE); + + const result = await service.fetchPositions(MOCK_ADDRESS); + expect(result).toStrictEqual(MOCK_POSITION_RESPONSE); + service.destroy(); + }); + + it('lowercases the address in the request', async () => { + const { service } = createService(Env.DEV); + const upperAddress = MOCK_ADDRESS.toUpperCase().replace('0X', '0x'); + + nock(MONEY_ACCOUNT_API_URL_MAP[Env.DEV]) + .get(`/v1/positions/${MOCK_ADDRESS}`) + .reply(200, MOCK_POSITION_RESPONSE); + + const result = await service.fetchPositions(upperAddress); + expect(result).toStrictEqual(MOCK_POSITION_RESPONSE); + service.destroy(); + }); + + it('throws HttpError on non-2xx response', async () => { + const { service } = createService(Env.DEV); + + nock(MONEY_ACCOUNT_API_URL_MAP[Env.DEV]) + .get(`/v1/positions/${MOCK_ADDRESS}`) + .times(DEFAULT_MAX_RETRIES + 1) + .reply(500); + + await expect(service.fetchPositions(MOCK_ADDRESS)).rejects.toThrow( + HttpError, + ); + service.destroy(); + }); + + it('does not retry an authorization failure', async () => { + const { service } = createService(Env.DEV); + const scope = nock(MONEY_ACCOUNT_API_URL_MAP[Env.DEV]) + .get(`/v1/positions/${MOCK_ADDRESS}`) + .once() + .reply(403); + + await expect(service.fetchPositions(MOCK_ADDRESS)).rejects.toThrow( + HttpError, + ); + + scope.done(); + service.destroy(); + }); + + it('retries a rate-limit response', async () => { + const { service } = createService(Env.DEV); + const scope = nock(MONEY_ACCOUNT_API_URL_MAP[Env.DEV]) + .get(`/v1/positions/${MOCK_ADDRESS}`) + .times(DEFAULT_MAX_RETRIES + 1) + .reply(429); + + await expect(service.fetchPositions(MOCK_ADDRESS)).rejects.toThrow( + HttpError, + ); + + scope.done(); + service.destroy(); + }); + + it('throws MoneyAccountApiResponseValidationError on malformed response', async () => { + const { service } = createService(Env.DEV); + + nock(MONEY_ACCOUNT_API_URL_MAP[Env.DEV]) + .get(`/v1/positions/${MOCK_ADDRESS}`) + .once() + .reply(200, { invalid: true }); + + await expect(service.fetchPositions(MOCK_ADDRESS)).rejects.toThrow( + MoneyAccountApiResponseValidationError, + ); + service.destroy(); + }); + + it('accepts a null balance when the API balance path is unavailable', async () => { + const { service } = createService(Env.DEV); + const responseWithNullBalance = { + ...MOCK_POSITION_RESPONSE, + balance: null, + }; + + nock(MONEY_ACCOUNT_API_URL_MAP[Env.DEV]) + .get(`/v1/positions/${MOCK_ADDRESS}`) + .reply(200, responseWithNullBalance); + + const result = await service.fetchPositions(MOCK_ADDRESS); + expect(result).toStrictEqual(responseWithNullBalance); + service.destroy(); + }); + + it('accepts a response that omits the balance field', async () => { + const { service } = createService(Env.DEV); + const { balance: _balance, ...responseWithoutBalance } = + MOCK_POSITION_RESPONSE; + + nock(MONEY_ACCOUNT_API_URL_MAP[Env.DEV]) + .get(`/v1/positions/${MOCK_ADDRESS}`) + .reply(200, responseWithoutBalance); + + const result = await service.fetchPositions(MOCK_ADDRESS); + expect(result).toStrictEqual(responseWithoutBalance); + service.destroy(); + }); + + it('throws MoneyAccountApiResponseValidationError on malformed balance', async () => { + const { service } = createService(Env.DEV); + + nock(MONEY_ACCOUNT_API_URL_MAP[Env.DEV]) + .get(`/v1/positions/${MOCK_ADDRESS}`) + .reply(200, { + ...MOCK_POSITION_RESPONSE, + balance: { musd_balance: '1' }, + }); + + await expect(service.fetchPositions(MOCK_ADDRESS)).rejects.toThrow( + MoneyAccountApiResponseValidationError, + ); + service.destroy(); + }); + + it('caches responses via TanStack Query', async () => { + const { service } = createService(Env.DEV); + + nock(MONEY_ACCOUNT_API_URL_MAP[Env.DEV]) + .get(`/v1/positions/${MOCK_ADDRESS}`) + .once() + .reply(200, MOCK_POSITION_RESPONSE); + + const result1 = await service.fetchPositions(MOCK_ADDRESS); + const result2 = await service.fetchPositions(MOCK_ADDRESS); + expect(result1).toStrictEqual(result2); + service.destroy(); + }); + + it('is callable via messenger action', async () => { + const { rootMessenger, service } = createService(Env.DEV); + + nock(MONEY_ACCOUNT_API_URL_MAP[Env.DEV]) + .get(`/v1/positions/${MOCK_ADDRESS}`) + .reply(200, MOCK_POSITION_RESPONSE); + + const result = await rootMessenger.call( + 'MoneyAccountApiDataService:fetchPositions', + MOCK_ADDRESS, + ); + expect(result).toStrictEqual(MOCK_POSITION_RESPONSE); + service.destroy(); + }); + }); + + describe('fetchInterest', () => { + it('returns interest data for valid params', async () => { + const { service } = createService(Env.DEV); + + nock(MONEY_ACCOUNT_API_URL_MAP[Env.DEV]) + .get(`/v1/positions/${MOCK_ADDRESS}/interest`) + .query({ + vault_address: MOCK_VAULT_ADDRESS, + window: '7d', + }) + .reply(200, MOCK_INTEREST_RESPONSE); + + const result = await service.fetchInterest(MOCK_ADDRESS, { + vaultAddress: MOCK_VAULT_ADDRESS, + window: '7d', + }); + expect(result).toStrictEqual(MOCK_INTEREST_RESPONSE); + service.destroy(); + }); + + it('includes chain_id query param when specified', async () => { + const { service } = createService(Env.DEV); + + nock(MONEY_ACCOUNT_API_URL_MAP[Env.DEV]) + .get(`/v1/positions/${MOCK_ADDRESS}/interest`) + .query({ + vault_address: MOCK_VAULT_ADDRESS, + window: '30d', + chain_id: '143', + }) + .reply(200, MOCK_INTEREST_RESPONSE); + + const result = await service.fetchInterest(MOCK_ADDRESS, { + vaultAddress: MOCK_VAULT_ADDRESS, + window: '30d', + chainId: 143, + }); + expect(result).toStrictEqual(MOCK_INTEREST_RESPONSE); + service.destroy(); + }); + + it('throws HttpError on non-2xx response', async () => { + const { service } = createService(Env.DEV); + + nock(MONEY_ACCOUNT_API_URL_MAP[Env.DEV]) + .get(`/v1/positions/${MOCK_ADDRESS}/interest`) + .query({ + vault_address: MOCK_VAULT_ADDRESS, + window: '7d', + }) + .times(DEFAULT_MAX_RETRIES + 1) + .reply(400); + + await expect( + service.fetchInterest(MOCK_ADDRESS, { + vaultAddress: MOCK_VAULT_ADDRESS, + window: '7d', + }), + ).rejects.toThrow(HttpError); + service.destroy(); + }); + + it('throws MoneyAccountApiResponseValidationError on malformed response', async () => { + const { service } = createService(Env.DEV); + + nock(MONEY_ACCOUNT_API_URL_MAP[Env.DEV]) + .get(`/v1/positions/${MOCK_ADDRESS}/interest`) + .query({ + vault_address: MOCK_VAULT_ADDRESS, + window: '7d', + }) + .once() + .reply(200, { bad: 'data' }); + + await expect( + service.fetchInterest(MOCK_ADDRESS, { + vaultAddress: MOCK_VAULT_ADDRESS, + window: '7d', + }), + ).rejects.toThrow(MoneyAccountApiResponseValidationError); + service.destroy(); + }); + + it('is callable via messenger action', async () => { + const { rootMessenger, service } = createService(Env.DEV); + + nock(MONEY_ACCOUNT_API_URL_MAP[Env.DEV]) + .get(`/v1/positions/${MOCK_ADDRESS}/interest`) + .query({ + vault_address: MOCK_VAULT_ADDRESS, + window: '7d', + }) + .reply(200, MOCK_INTEREST_RESPONSE); + + const result = await rootMessenger.call( + 'MoneyAccountApiDataService:fetchInterest', + MOCK_ADDRESS, + { vaultAddress: MOCK_VAULT_ADDRESS, window: '7d' }, + ); + expect(result).toStrictEqual(MOCK_INTEREST_RESPONSE); + service.destroy(); + }); + }); + + describe('fetchHistory', () => { + it('returns history data with no options', async () => { + const { service } = createService(Env.DEV); + + nock(MONEY_ACCOUNT_API_URL_MAP[Env.DEV]) + .get(`/v1/positions/${MOCK_ADDRESS}/history`) + .reply(200, MOCK_HISTORY_RESPONSE); + + const result = await service.fetchHistory(MOCK_ADDRESS); + expect(result).toStrictEqual(MOCK_HISTORY_RESPONSE); + service.destroy(); + }); + + it('includes vault_address query param when specified', async () => { + const { service } = createService(Env.DEV); + + nock(MONEY_ACCOUNT_API_URL_MAP[Env.DEV]) + .get(`/v1/positions/${MOCK_ADDRESS}/history`) + .query({ vault_address: MOCK_VAULT_ADDRESS }) + .reply(200, MOCK_HISTORY_RESPONSE); + + const result = await service.fetchHistory(MOCK_ADDRESS, { + vaultAddress: MOCK_VAULT_ADDRESS, + }); + expect(result).toStrictEqual(MOCK_HISTORY_RESPONSE); + service.destroy(); + }); + + it('includes all optional query params', async () => { + const { service } = createService(Env.DEV); + + nock(MONEY_ACCOUNT_API_URL_MAP[Env.DEV]) + .get(`/v1/positions/${MOCK_ADDRESS}/history`) + .query({ + vault_address: MOCK_VAULT_ADDRESS, + chain_id: '143', + cursor: 'abc123', + limit: '10', + }) + .reply(200, MOCK_HISTORY_RESPONSE); + + const result = await service.fetchHistory(MOCK_ADDRESS, { + vaultAddress: MOCK_VAULT_ADDRESS, + chainId: 143, + cursor: 'abc123', + limit: 10, + }); + expect(result).toStrictEqual(MOCK_HISTORY_RESPONSE); + service.destroy(); + }); + + it('supports paginated fetching via cursor', async () => { + const { service } = createService(Env.DEV); + + const page2Response = { + ...MOCK_HISTORY_RESPONSE, + next_cursor: null, + has_more: false, + }; + + nock(MONEY_ACCOUNT_API_URL_MAP[Env.DEV]) + .get(`/v1/positions/${MOCK_ADDRESS}/history`) + .reply(200, MOCK_HISTORY_RESPONSE); + + const firstPage = await service.fetchHistory(MOCK_ADDRESS); + expect(firstPage.next_cursor).toBe('eyJiIjoxMDAwMH0='); + + nock(MONEY_ACCOUNT_API_URL_MAP[Env.DEV]) + .get(`/v1/positions/${MOCK_ADDRESS}/history`) + .query({ cursor: 'eyJiIjoxMDAwMH0=' }) + .reply(200, page2Response); + + const secondPage = await service.fetchHistory(MOCK_ADDRESS, { + cursor: 'eyJiIjoxMDAwMH0=', + }); + expect(secondPage.next_cursor).toBeNull(); + expect(secondPage.has_more).toBe(false); + service.destroy(); + }); + + it('throws HttpError on non-2xx response', async () => { + const { service } = createService(Env.DEV); + + nock(MONEY_ACCOUNT_API_URL_MAP[Env.DEV]) + .get(`/v1/positions/${MOCK_ADDRESS}/history`) + .times(DEFAULT_MAX_RETRIES + 1) + .reply(500); + + await expect(service.fetchHistory(MOCK_ADDRESS)).rejects.toThrow( + HttpError, + ); + service.destroy(); + }); + + it('throws MoneyAccountApiResponseValidationError on malformed response', async () => { + const { service } = createService(Env.DEV); + + nock(MONEY_ACCOUNT_API_URL_MAP[Env.DEV]) + .get(`/v1/positions/${MOCK_ADDRESS}/history`) + .once() + .reply(200, { wrong: 'shape' }); + + await expect(service.fetchHistory(MOCK_ADDRESS)).rejects.toThrow( + MoneyAccountApiResponseValidationError, + ); + service.destroy(); + }); + + it('is callable via messenger action', async () => { + const { rootMessenger, service } = createService(Env.DEV); + + nock(MONEY_ACCOUNT_API_URL_MAP[Env.DEV]) + .get(`/v1/positions/${MOCK_ADDRESS}/history`) + .reply(200, MOCK_HISTORY_RESPONSE); + + const result = await rootMessenger.call( + 'MoneyAccountApiDataService:fetchHistory', + MOCK_ADDRESS, + ); + expect(result).toStrictEqual(MOCK_HISTORY_RESPONSE); + service.destroy(); + }); + }); + + describe('fetchRateHistory', () => { + it('returns rate history data with no options', async () => { + const { service } = createService(Env.DEV); + + nock(MONEY_ACCOUNT_API_URL_MAP[Env.DEV]) + .get(`/v1/vaults/${MOCK_VAULT_ADDRESS}/rate-history`) + .reply(200, MOCK_RATE_HISTORY_RESPONSE); + + const result = await service.fetchRateHistory(MOCK_VAULT_ADDRESS); + expect(result).toStrictEqual(MOCK_RATE_HISTORY_RESPONSE); + service.destroy(); + }); + + it('includes optional query params', async () => { + const { service } = createService(Env.DEV); + + nock(MONEY_ACCOUNT_API_URL_MAP[Env.DEV]) + .get(`/v1/vaults/${MOCK_VAULT_ADDRESS}/rate-history`) + .query({ + chain_id: '143', + from: '2026-05-01T00:00:00Z', + to: '2026-06-01T00:00:00Z', + }) + .reply(200, MOCK_RATE_HISTORY_RESPONSE); + + const result = await service.fetchRateHistory(MOCK_VAULT_ADDRESS, { + chainId: 143, + from: '2026-05-01T00:00:00Z', + to: '2026-06-01T00:00:00Z', + }); + expect(result).toStrictEqual(MOCK_RATE_HISTORY_RESPONSE); + service.destroy(); + }); + + it('throws HttpError on non-2xx response', async () => { + const { service } = createService(Env.DEV); + + nock(MONEY_ACCOUNT_API_URL_MAP[Env.DEV]) + .get(`/v1/vaults/${MOCK_VAULT_ADDRESS}/rate-history`) + .times(DEFAULT_MAX_RETRIES + 1) + .reply(404); + + await expect( + service.fetchRateHistory(MOCK_VAULT_ADDRESS), + ).rejects.toThrow(HttpError); + service.destroy(); + }); + + it('throws MoneyAccountApiResponseValidationError on malformed response', async () => { + const { service } = createService(Env.DEV); + + nock(MONEY_ACCOUNT_API_URL_MAP[Env.DEV]) + .get(`/v1/vaults/${MOCK_VAULT_ADDRESS}/rate-history`) + .once() + .reply(200, { not: 'valid' }); + + await expect( + service.fetchRateHistory(MOCK_VAULT_ADDRESS), + ).rejects.toThrow(MoneyAccountApiResponseValidationError); + service.destroy(); + }); + + it('is callable via messenger action', async () => { + const { rootMessenger, service } = createService(Env.DEV); + + nock(MONEY_ACCOUNT_API_URL_MAP[Env.DEV]) + .get(`/v1/vaults/${MOCK_VAULT_ADDRESS}/rate-history`) + .reply(200, MOCK_RATE_HISTORY_RESPONSE); + + const result = await rootMessenger.call( + 'MoneyAccountApiDataService:fetchRateHistory', + MOCK_VAULT_ADDRESS, + ); + expect(result).toStrictEqual(MOCK_RATE_HISTORY_RESPONSE); + service.destroy(); + }); + }); + + describe('tracing', () => { + let mockTrace: jest.Mock; + + beforeEach(() => { + mockTrace = jest.fn().mockResolvedValue(undefined); + }); + + it('emits a trace for fetchPositions on cache miss', async () => { + const { service } = createService(Env.DEV, { trace: mockTrace }); + + nock(MONEY_ACCOUNT_API_URL_MAP[Env.DEV]) + .get(`/v1/positions/${MOCK_ADDRESS}`) + .reply(200, MOCK_POSITION_RESPONSE); + + await service.fetchPositions(MOCK_ADDRESS); + + expect(mockTrace).toHaveBeenCalledTimes(1); + const [request] = mockTrace.mock.calls[0] as [ + MoneyAccountApiDataServiceTraceRequest, + unknown, + ]; + expect(request.name).toBe(TRACES.POSITIONS_API); + expect(request.data).toStrictEqual( + expect.objectContaining({ + operation: 'fetchPositions', + success: true, + }), + ); + expect(request.startTime).toStrictEqual(expect.any(Number)); + service.destroy(); + }); + + it('emits a trace for fetchInterest on cache miss', async () => { + const { service } = createService(Env.DEV, { trace: mockTrace }); + + nock(MONEY_ACCOUNT_API_URL_MAP[Env.DEV]) + .get(`/v1/positions/${MOCK_ADDRESS}/interest`) + .query({ vault_address: MOCK_VAULT_ADDRESS, window: '7d' }) + .reply(200, MOCK_INTEREST_RESPONSE); + + await service.fetchInterest(MOCK_ADDRESS, { + vaultAddress: MOCK_VAULT_ADDRESS, + window: '7d', + }); + + expect(mockTrace).toHaveBeenCalledTimes(1); + const [request] = mockTrace.mock.calls[0] as [ + MoneyAccountApiDataServiceTraceRequest, + unknown, + ]; + expect(request.name).toBe(TRACES.INTEREST_API); + expect(request.data).toStrictEqual( + expect.objectContaining({ + operation: 'fetchInterest', + success: true, + }), + ); + service.destroy(); + }); + + it('emits a trace for fetchHistory on cache miss', async () => { + const { service } = createService(Env.DEV, { trace: mockTrace }); + + nock(MONEY_ACCOUNT_API_URL_MAP[Env.DEV]) + .get(`/v1/positions/${MOCK_ADDRESS}/history`) + .reply(200, MOCK_HISTORY_RESPONSE); + + await service.fetchHistory(MOCK_ADDRESS); + + expect(mockTrace).toHaveBeenCalledTimes(1); + const [request] = mockTrace.mock.calls[0] as [ + MoneyAccountApiDataServiceTraceRequest, + unknown, + ]; + expect(request.name).toBe(TRACES.HISTORY_API); + expect(request.data).toStrictEqual( + expect.objectContaining({ + operation: 'fetchHistory', + success: true, + }), + ); + service.destroy(); + }); + + it('emits a trace for fetchRateHistory on cache miss', async () => { + const { service } = createService(Env.DEV, { trace: mockTrace }); + + nock(MONEY_ACCOUNT_API_URL_MAP[Env.DEV]) + .get(`/v1/vaults/${MOCK_VAULT_ADDRESS}/rate-history`) + .reply(200, MOCK_RATE_HISTORY_RESPONSE); + + await service.fetchRateHistory(MOCK_VAULT_ADDRESS); + + expect(mockTrace).toHaveBeenCalledTimes(1); + const [request] = mockTrace.mock.calls[0] as [ + MoneyAccountApiDataServiceTraceRequest, + unknown, + ]; + expect(request.name).toBe(TRACES.RATE_HISTORY_API); + expect(request.data).toStrictEqual( + expect.objectContaining({ + operation: 'fetchRateHistory', + success: true, + }), + ); + service.destroy(); + }); + + it('does not emit a trace on cache hit', async () => { + const { service } = createService(Env.DEV, { trace: mockTrace }); + + nock(MONEY_ACCOUNT_API_URL_MAP[Env.DEV]) + .get(`/v1/positions/${MOCK_ADDRESS}`) + .once() + .reply(200, MOCK_POSITION_RESPONSE); + + await service.fetchPositions(MOCK_ADDRESS); + mockTrace.mockClear(); + + await service.fetchPositions(MOCK_ADDRESS); + expect(mockTrace).not.toHaveBeenCalled(); + service.destroy(); + }); + + it('records success: false and errorName on failed request', async () => { + const { service } = createService(Env.DEV, { trace: mockTrace }); + + nock(MONEY_ACCOUNT_API_URL_MAP[Env.DEV]) + .get(`/v1/positions/${MOCK_ADDRESS}`) + .times(DEFAULT_MAX_RETRIES + 1) + .reply(500); + + await expect(service.fetchPositions(MOCK_ADDRESS)).rejects.toThrow( + HttpError, + ); + + expect(mockTrace).toHaveBeenCalled(); + const lastCall = mockTrace.mock.calls[ + mockTrace.mock.calls.length - 1 + ] as [MoneyAccountApiDataServiceTraceRequest, unknown]; + expect(lastCall[0].data).toStrictEqual( + expect.objectContaining({ + success: false, + errorName: expect.any(String), + }), + ); + service.destroy(); + }); + + it('traces non-Error rejections with the thrown value type', async () => { + const { service } = createService(Env.DEV, { trace: mockTrace }); + jest + .spyOn(globalThis, 'fetch') + .mockRejectedValue('network down' as never); + + await expect(service.fetchPositions(MOCK_ADDRESS)).rejects.toBe( + 'network down', + ); + + expect(mockTrace).toHaveBeenCalled(); + const lastCall = mockTrace.mock.calls[ + mockTrace.mock.calls.length - 1 + ] as [MoneyAccountApiDataServiceTraceRequest, unknown]; + expect(lastCall[0].data).toStrictEqual( + expect.objectContaining({ + success: false, + errorName: 'string', + }), + ); + jest.restoreAllMocks(); + service.destroy(); + }); + + it('does not break the request when trace callback throws synchronously', async () => { + const throwingTrace = jest.fn().mockImplementation(() => { + throw new Error('trace sync failure'); + }) as unknown as MoneyAccountApiDataServiceTraceCallback; + + const { service } = createService(Env.DEV, { trace: throwingTrace }); + + nock(MONEY_ACCOUNT_API_URL_MAP[Env.DEV]) + .get(`/v1/positions/${MOCK_ADDRESS}`) + .reply(200, MOCK_POSITION_RESPONSE); + + const result = await service.fetchPositions(MOCK_ADDRESS); + expect(result).toStrictEqual(MOCK_POSITION_RESPONSE); + service.destroy(); + }); + + it('does not break the request when trace callback rejects', async () => { + const rejectingTrace = jest + .fn() + .mockRejectedValue( + new Error('trace async failure'), + ) as unknown as MoneyAccountApiDataServiceTraceCallback; + + const { service } = createService(Env.DEV, { trace: rejectingTrace }); + + nock(MONEY_ACCOUNT_API_URL_MAP[Env.DEV]) + .get(`/v1/positions/${MOCK_ADDRESS}`) + .reply(200, MOCK_POSITION_RESPONSE); + + const result = await service.fetchPositions(MOCK_ADDRESS); + expect(result).toStrictEqual(MOCK_POSITION_RESPONSE); + service.destroy(); + }); + }); + + describe('invalidateQueries', () => { + it('invalidates cached queries', async () => { + const { service } = createService(Env.DEV); + + nock(MONEY_ACCOUNT_API_URL_MAP[Env.DEV]) + .get(`/v1/positions/${MOCK_ADDRESS}`) + .twice() + .reply(200, MOCK_POSITION_RESPONSE); + + await service.fetchPositions(MOCK_ADDRESS); + await service.invalidateQueries(); + const result = await service.fetchPositions(MOCK_ADDRESS); + expect(result).toStrictEqual(MOCK_POSITION_RESPONSE); + service.destroy(); + }); + + it('is callable via messenger action', async () => { + const { rootMessenger, service } = createService(Env.DEV); + + const result = await rootMessenger.call( + 'MoneyAccountApiDataService:invalidateQueries', + ); + expect(result).toBeUndefined(); + service.destroy(); + }); + }); + + describe('destroy', () => { + it('cleans up messenger subscriptions', () => { + const { service } = createService(Env.DEV); + expect(() => service.destroy()).not.toThrow(); + }); + }); +}); + +describe('MoneyAccountApiResponseValidationError', () => { + it('uses default message when none provided', () => { + const error = new MoneyAccountApiResponseValidationError(); + expect(error.message).toBe( + 'MoneyAccountApiDataService: malformed response received from Money Account API', + ); + expect(error.name).toBe('MoneyAccountApiResponseValidationError'); + }); + + it('uses custom message when provided', () => { + const error = new MoneyAccountApiResponseValidationError('custom message'); + expect(error.message).toBe('custom message'); + expect(error.name).toBe('MoneyAccountApiResponseValidationError'); + }); +}); diff --git a/packages/money-account-api-data-service/src/money-account-api-data-service.ts b/packages/money-account-api-data-service/src/money-account-api-data-service.ts new file mode 100644 index 00000000000..f559b416c75 --- /dev/null +++ b/packages/money-account-api-data-service/src/money-account-api-data-service.ts @@ -0,0 +1,565 @@ +import type { + DataServiceCacheUpdatedEvent, + DataServiceGranularCacheUpdatedEvent, + DataServiceInvalidateQueriesAction, +} from '@metamask/base-data-service'; +import { BaseDataService } from '@metamask/base-data-service'; +import type { + CreateServicePolicyOptions, + TraceContext, + TraceRequest, +} from '@metamask/controller-utils'; +import { handleWhen, HttpError } from '@metamask/controller-utils'; +import type { Messenger } from '@metamask/messenger'; +import { validate } from '@metamask/superstruct'; +import type { Json } from '@metamask/utils'; +import type { QueryClientConfig } from '@tanstack/query-core'; + +import { + DEFAULT_STALE_TIME_MS, + Env, + MONEY_ACCOUNT_API_URL_MAP, + RATE_HISTORY_STALE_TIME_MS, +} from './constants.js'; +import { MoneyAccountApiResponseValidationError } from './errors.js'; +import { projectLogger, createModuleLogger } from './logger.js'; +import type { MoneyAccountApiDataServiceMethodActions } from './money-account-api-data-service-method-action-types.js'; +import type { + HistoryResponse, + InterestResponse, + PositionResponse, + RateHistoryResponse, +} from './response.types'; +import { + HistoryResponseStruct, + InterestResponseStruct, + PositionResponseStruct, + RateHistoryResponseStruct, +} from './structs.js'; +import type { + HistoryOptions, + InterestOptions, + RateHistoryOptions, +} from './types.js'; + +// === GENERAL === + +/** + * The name of the {@link MoneyAccountApiDataService}, used to namespace the + * service's actions and events. + */ +export const serviceName = 'MoneyAccountApiDataService'; + +const log = createModuleLogger(projectLogger, serviceName); +const traceLogger = createModuleLogger(projectLogger, 'trace'); + +export const TRACES = { + POSITIONS_API: 'Money Account API Fetch Positions', + INTEREST_API: 'Money Account API Fetch Interest', + HISTORY_API: 'Money Account API Fetch History', + RATE_HISTORY_API: 'Money Account API Fetch Rate History', +} as const; + +export type MoneyAccountApiDataServiceTraceName = + (typeof TRACES)[keyof typeof TRACES]; + +export type MoneyAccountApiDataServiceTraceRequest = Omit< + TraceRequest, + 'name' +> & { + name: MoneyAccountApiDataServiceTraceName; + startTime?: number; +}; + +export type MoneyAccountApiDataServiceTraceCallback = ( + request: MoneyAccountApiDataServiceTraceRequest, + fn?: (context?: TraceContext) => ReturnType, +) => Promise; + +// === MESSENGER === + +const MESSENGER_EXPOSED_METHODS = [ + 'fetchPositions', + 'fetchInterest', + 'fetchHistory', + 'fetchRateHistory', +] as const; + +/** + * Invalidates cached queries for {@link MoneyAccountApiDataService}. + */ +export type MoneyAccountApiDataServiceInvalidateQueriesAction = + DataServiceInvalidateQueriesAction; + +/** + * Actions that {@link MoneyAccountApiDataService} exposes to other consumers. + */ +export type MoneyAccountApiDataServiceActions = + | MoneyAccountApiDataServiceMethodActions + | MoneyAccountApiDataServiceInvalidateQueriesAction; + +/** + * Actions from other messengers that {@link MoneyAccountApiDataService} calls. + */ +type AuthenticationControllerGetBearerTokenAction = { + type: 'AuthenticationController:getBearerToken'; + handler: (entropySourceId?: string) => Promise; +}; + +type AllowedActions = AuthenticationControllerGetBearerTokenAction; + +/** + * Published when {@link MoneyAccountApiDataService}'s cache is updated. + */ +export type MoneyAccountApiDataServiceCacheUpdatedEvent = + DataServiceCacheUpdatedEvent; + +/** + * Published when a key within {@link MoneyAccountApiDataService}'s cache is + * updated. + */ +export type MoneyAccountApiDataServiceGranularCacheUpdatedEvent = + DataServiceGranularCacheUpdatedEvent; + +/** + * Events that {@link MoneyAccountApiDataService} exposes to other consumers. + */ +export type MoneyAccountApiDataServiceEvents = + | MoneyAccountApiDataServiceCacheUpdatedEvent + | MoneyAccountApiDataServiceGranularCacheUpdatedEvent; + +/** + * Events from other messengers that {@link MoneyAccountApiDataService} + * subscribes to. + */ +type AllowedEvents = never; + +/** + * The messenger which is restricted to actions and events accessed by + * {@link MoneyAccountApiDataService}. + */ +export type MoneyAccountApiDataServiceMessenger = Messenger< + typeof serviceName, + MoneyAccountApiDataServiceActions | AllowedActions, + MoneyAccountApiDataServiceEvents | AllowedEvents +>; + +// === SERVICE DEFINITION === + +export type MoneyAccountApiDataServiceOptions = { + messenger: MoneyAccountApiDataServiceMessenger; + env?: Env; + queryClientConfig?: QueryClientConfig; + policyOptions?: CreateServicePolicyOptions; + trace?: MoneyAccountApiDataServiceTraceCallback; +}; + +/** + * Data service responsible for fetching positions, interest, cash-flow + * history, and vault rate history from the Money Account APY Tracking API. + */ +export class MoneyAccountApiDataService extends BaseDataService< + typeof serviceName, + MoneyAccountApiDataServiceMessenger +> { + readonly #baseUrl: string; + + readonly #trace: MoneyAccountApiDataServiceTraceCallback; + + /** + * Constructs a new MoneyAccountApiDataService. + * + * @param options - The constructor arguments. + * @param options.messenger - The messenger suited for this service. + * @param options.env - The target environment. Defaults to production. + * @param options.queryClientConfig - Configuration for the underlying + * TanStack Query client. + * @param options.policyOptions - Options to pass to `createServicePolicy`. + * @param options.trace - Optional callback to trace network requests. + */ + constructor({ + messenger, + env = Env.PRD, + queryClientConfig = {}, + policyOptions = {}, + trace, + }: MoneyAccountApiDataServiceOptions) { + super({ + name: serviceName, + messenger, + queryClientConfig, + policyOptions: { + retryFilterPolicy: handleWhen( + (error) => + !(error instanceof MoneyAccountApiResponseValidationError) && + !(error instanceof HttpError && error.httpStatus === 403), + ), + ...policyOptions, + }, + }); + + this.#baseUrl = MONEY_ACCOUNT_API_URL_MAP[env]; + + this.#trace = + trace ?? + (async ( + _request: MoneyAccountApiDataServiceTraceRequest, + fn?: (context?: TraceContext) => Result, + ): Promise => { + return await Promise.resolve(fn?.() as Result); + }); + + this.messenger.registerMethodActionHandlers( + this, + MESSENGER_EXPOSED_METHODS, + ); + + log('Initialized', { env, baseUrl: this.#baseUrl }); + } + + /** + * Builds best-effort authentication headers for a Money Account API request. + * + * Requests proceed without authentication when the wallet is locked, the + * user is signed out, or the token is otherwise unavailable. The API edge + * rate limiter falls back to IP-based limiting when the header is omitted. + * + * @returns The Authorization header when a profile token is available. + */ + async #getRequestHeaders(): Promise> { + try { + const token = await this.messenger.call( + 'AuthenticationController:getBearerToken', + ); + return token ? { Authorization: `Bearer ${token}` } : {}; + } catch (error: unknown) { + log('Auth token unavailable, proceeding unauthenticated', error); + return {}; + } + } + + /** + * Fetches the current vault positions for a given user address. + * + * @param address - The user's Ethereum address. + * @returns The position response containing vault positions and an optional + * `balance` summary (`null` when the API balance path is unavailable). + */ + async fetchPositions(address: string): Promise { + const url = new URL( + `/v1/positions/${address.toLowerCase()}`, + this.#baseUrl, + ); + + return this.fetchQuery({ + queryKey: [`${this.name}:fetchPositions`, address.toLowerCase()], + staleTime: DEFAULT_STALE_TIME_MS, + queryFn: async () => { + return this.#traceNetworkRequest( + { + name: TRACES.POSITIONS_API, + data: { operation: 'fetchPositions' }, + }, + async () => { + const response = await fetch(url, { + headers: await this.#getRequestHeaders(), + }); + + if (!response.ok) { + throw new HttpError( + response.status, + `Money Account API positions request failed with status '${response.status}'`, + ); + } + + const json: Json = await response.json(); + + const [error, validated] = validate(json, PositionResponseStruct); + if (error) { + throw new MoneyAccountApiResponseValidationError( + `Malformed response from positions endpoint: ${error.message}`, + ); + } + + return validated as unknown as PositionResponse; + }, + ); + }, + }); + } + + /** + * Fetches the interest earned for a given address and vault over a + * specified time window. + * + * @param address - The user's Ethereum address. + * @param options - Options specifying vault, window, and optional chain ID. + * @returns The interest response. + */ + async fetchInterest( + address: string, + options: InterestOptions, + ): Promise { + const url = new URL( + `/v1/positions/${address.toLowerCase()}/interest`, + this.#baseUrl, + ); + url.searchParams.append( + 'vault_address', + options.vaultAddress.toLowerCase(), + ); + url.searchParams.append('window', options.window); + if (options.chainId !== undefined) { + url.searchParams.append('chain_id', String(options.chainId)); + } + + return this.fetchQuery({ + queryKey: [ + `${this.name}:fetchInterest`, + address.toLowerCase(), + options.vaultAddress.toLowerCase(), + options.window, + ...(options.chainId === undefined ? [] : [options.chainId]), + ], + staleTime: DEFAULT_STALE_TIME_MS, + queryFn: async () => { + return this.#traceNetworkRequest( + { + name: TRACES.INTEREST_API, + data: { operation: 'fetchInterest' }, + }, + async () => { + const response = await fetch(url, { + headers: await this.#getRequestHeaders(), + }); + + if (!response.ok) { + throw new HttpError( + response.status, + `Money Account API interest request failed with status '${response.status}'`, + ); + } + + const json: Json = await response.json(); + + const [error, validated] = validate(json, InterestResponseStruct); + if (error) { + throw new MoneyAccountApiResponseValidationError( + `Malformed response from interest endpoint: ${error.message}`, + ); + } + + return validated as unknown as InterestResponse; + }, + ); + }, + }); + } + + /** + * Fetches cursor-paginated cash-flow history for a given address. + * Uses `fetchInfiniteQuery` for proper TanStack Query pagination semantics. + * + * When paginating, consumers must re-pass the same filter options + * (`vaultAddress`, `chainId`, `limit`) alongside `cursor` on every page + * request. This ensures the query key matches the original infinite query + * and that the HTTP request includes the correct filters. + * + * @param address - The user's Ethereum address. + * @param options - Optional filtering and pagination options. + * @returns The history response containing cash-flow entries for the requested page. + */ + async fetchHistory( + address: string, + options?: HistoryOptions, + ): Promise { + const normalizedAddress = address.toLowerCase(); + const normalizedVault = options?.vaultAddress?.toLowerCase() ?? null; + + return this.fetchInfiniteQuery( + { + queryKey: [ + `${this.name}:fetchHistory`, + normalizedAddress, + normalizedVault, + options?.chainId ?? null, + options?.limit ?? null, + ], + initialPageParam: null, + getNextPageParam: (result) => result.next_cursor, + staleTime: DEFAULT_STALE_TIME_MS, + queryFn: async (context) => { + const cursor = context.pageParam as string | null | undefined; + + const url = new URL( + `/v1/positions/${normalizedAddress}/history`, + this.#baseUrl, + ); + if (normalizedVault) { + url.searchParams.append('vault_address', normalizedVault); + } + if (options?.chainId !== undefined) { + url.searchParams.append('chain_id', String(options.chainId)); + } + if (cursor) { + url.searchParams.append('cursor', cursor); + } + if (options?.limit !== undefined) { + url.searchParams.append('limit', String(options.limit)); + } + + return this.#traceNetworkRequest( + { + name: TRACES.HISTORY_API, + data: { operation: 'fetchHistory' }, + }, + async () => { + const response = await fetch(url, { + headers: await this.#getRequestHeaders(), + }); + + if (!response.ok) { + throw new HttpError( + response.status, + `Money Account API history request failed with status '${response.status}'`, + ); + } + + const json: Json = await response.json(); + + const [error, validated] = validate(json, HistoryResponseStruct); + if (error) { + throw new MoneyAccountApiResponseValidationError( + `Malformed response from history endpoint: ${error.message}`, + ); + } + + return validated as unknown as HistoryResponse; + }, + ); + }, + }, + options?.cursor ?? undefined, + ); + } + + /** + * Fetches the exchange-rate time series for a given vault. + * + * @param vaultAddress - The vault's Ethereum address. + * @param options - Optional range and chain ID filters. + * @returns The rate history response. + */ + async fetchRateHistory( + vaultAddress: string, + options?: RateHistoryOptions, + ): Promise { + const url = new URL( + `/v1/vaults/${vaultAddress.toLowerCase()}/rate-history`, + this.#baseUrl, + ); + if (options?.chainId !== undefined) { + url.searchParams.append('chain_id', String(options.chainId)); + } + if (options?.from) { + url.searchParams.append('from', options.from); + } + if (options?.to) { + url.searchParams.append('to', options.to); + } + + return this.fetchQuery({ + queryKey: [ + `${this.name}:fetchRateHistory`, + vaultAddress.toLowerCase(), + ...(options?.chainId === undefined ? [null] : [options.chainId]), + ...(options?.from ? [options.from] : [null]), + ...(options?.to ? [options.to] : [null]), + ], + staleTime: RATE_HISTORY_STALE_TIME_MS, + queryFn: async () => { + return this.#traceNetworkRequest( + { + name: TRACES.RATE_HISTORY_API, + data: { operation: 'fetchRateHistory' }, + }, + async () => { + const response = await fetch(url, { + headers: await this.#getRequestHeaders(), + }); + + if (!response.ok) { + throw new HttpError( + response.status, + `Money Account API rate-history request failed with status '${response.status}'`, + ); + } + + const json: Json = await response.json(); + + const [error, validated] = validate( + json, + RateHistoryResponseStruct, + ); + if (error) { + throw new MoneyAccountApiResponseValidationError( + `Malformed response from rate-history endpoint: ${error.message}`, + ); + } + + return validated as unknown as RateHistoryResponse; + }, + ); + }, + }); + } + + /** + * Runs a network request and emits a best-effort backdated trace. + * + * @param request - Trace metadata for the network request. + * @param fn - Network request to execute. + * @returns The network request result. + */ + async #traceNetworkRequest( + request: MoneyAccountApiDataServiceTraceRequest, + fn: () => Promise, + ): Promise { + const startTime = Date.now(); + let success = false; + let errorName: string | undefined; + + try { + const result = await fn(); + success = true; + return result; + } catch (error) { + errorName = error instanceof Error ? error.name : typeof error; + throw error; + } finally { + const traceRequest = { + ...request, + startTime, + data: { + ...request.data, + success, + ...(errorName ? { errorName } : {}), + }, + }; + const onTraceError = (traceError: unknown): void => { + traceLogger('Failed to emit trace', { + traceName: request.name, + traceError, + }); + }; + + try { + Promise.resolve(this.#trace(traceRequest, () => undefined)).catch( + onTraceError, + ); + } catch (traceError) { + onTraceError(traceError); + } + } + } +} diff --git a/packages/money-account-api-data-service/src/response.types.ts b/packages/money-account-api-data-service/src/response.types.ts new file mode 100644 index 00000000000..a5373038556 --- /dev/null +++ b/packages/money-account-api-data-service/src/response.types.ts @@ -0,0 +1,116 @@ +import type { Infer } from '@metamask/superstruct'; + +import type { + HistoryResponseStruct, + InterestResponseStruct, + PositionResponseStruct, + RateHistoryResponseStruct, +} from './structs.js'; + +// All types in this file mirror the external Money Account API's snake_case +// JSON contract verbatim to maintain 1:1 parity with API responses. +/* eslint-disable @typescript-eslint/naming-convention */ + +/** + * Data freshness indicator returned by all business endpoints. + */ +export type DataFreshness = 'live' | 'degraded'; + +/** + * A single vault position within the positions response. + */ +export type VaultPosition = { + vault_address: string; + shares_held: string; + current_rate: string; + current_value_assets: string; + current_value_usd: string; + cost_basis_assets: string; + cost_basis_usd: string; + realized_interest_usd: string; + unrealised_interest_usd: string; + lifetime_interest_usd: string; + current_apy: string; + effective_apy: string; +}; + +/** + * Balance summary on the positions response. + * `null` when the API's wallet-balance path is disabled or unavailable. + */ +export type PositionBalance = { + musd_balance: string; + vmusd_value_in_musd: string; + total_balance: string; +}; + +/** + * Response from `GET /v1/positions/:address`. + * Derived from {@link PositionResponseStruct} to ensure type/struct parity. + */ +export type PositionResponse = Infer; + +/** + * Response from `GET /v1/positions/:address/interest`. + * Derived from {@link InterestResponseStruct} to ensure type/struct parity. + */ +export type InterestResponse = Infer; + +/** + * Cash-flow type for the history endpoint. + */ +export type CashFlowType = + | 'deposit' + | 'withdraw' + | 'transfer_in' + | 'transfer_out'; + +/** + * Cash-flow source label. + */ +export type CashFlowSource = + | 'teller' + | 'withdraw_queue' + | 'atomic_queue' + | 'erc20_transfer' + | 'cross_chain'; + +/** + * A single entry in the cash-flow history. + */ +export type CashFlowEntry = { + type: CashFlowType; + chain_id: number; + vault_address: string; + timestamp: string; + block_number: number; + log_index: number; + tx_hash: string; + assets_usd: string; + assets_wei: string; + shares_wei: string; + rate: string; + source: CashFlowSource; +}; + +/** + * Response from `GET /v1/positions/:address/history`. + * Derived from {@link HistoryResponseStruct} to ensure type/struct parity. + */ +export type HistoryResponse = Infer; + +/** + * A single entry in the rate-history time series. + */ +export type RateHistoryEntry = { + timestamp: string; + block_number: number; + rate: string; + tx_hash: string; +}; + +/** + * Response from `GET /v1/vaults/:address/rate-history`. + * Derived from {@link RateHistoryResponseStruct} to ensure type/struct parity. + */ +export type RateHistoryResponse = Infer; diff --git a/packages/money-account-api-data-service/src/structs.ts b/packages/money-account-api-data-service/src/structs.ts new file mode 100644 index 00000000000..1041327c745 --- /dev/null +++ b/packages/money-account-api-data-service/src/structs.ts @@ -0,0 +1,115 @@ +import { + array, + boolean, + enums, + nullable, + number, + object, + optional, + string, +} from '@metamask/superstruct'; + +const DataFreshnessStruct = enums(['live', 'degraded']); + +const VaultPositionStruct = object({ + vault_address: string(), + shares_held: string(), + current_rate: string(), + current_value_assets: string(), + current_value_usd: string(), + cost_basis_assets: string(), + cost_basis_usd: string(), + realized_interest_usd: string(), + unrealised_interest_usd: string(), + lifetime_interest_usd: string(), + current_apy: string(), + effective_apy: string(), +}); + +/** + * Wallet + vault balance summary on the positions response. + * `null` when the API's wallet-balance path is disabled or unavailable. + */ +const PositionBalanceStruct = object({ + musd_balance: string(), + vmusd_value_in_musd: string(), + total_balance: string(), +}); + +export const PositionResponseStruct = object({ + address: string(), + as_of_block: number(), + as_of_timestamp: string(), + data_freshness: DataFreshnessStruct, + indexer_lag_seconds: number(), + // Optional for backwards compatibility with responses that omit the field; + // when present, may be `null` if the API balance flag is off. + balance: optional(nullable(PositionBalanceStruct)), + positions: array(VaultPositionStruct), +}); + +export const InterestResponseStruct = object({ + address: string(), + vault_address: string(), + window: string(), + window_start: string(), + window_end: string(), + interest_earned_assets: string(), + interest_earned_usd: string(), + method: string(), + as_of_block: number(), + as_of_timestamp: string(), + data_freshness: DataFreshnessStruct, + indexer_lag_seconds: number(), +}); + +const CashFlowEntryStruct = object({ + type: enums(['deposit', 'withdraw', 'transfer_in', 'transfer_out']), + chain_id: number(), + vault_address: string(), + timestamp: string(), + block_number: number(), + log_index: number(), + tx_hash: string(), + assets_usd: string(), + assets_wei: string(), + shares_wei: string(), + rate: string(), + source: enums([ + 'teller', + 'withdraw_queue', + 'atomic_queue', + 'erc20_transfer', + 'cross_chain', + ]), +}); + +export const HistoryResponseStruct = object({ + address: string(), + cash_flows: array(CashFlowEntryStruct), + next_cursor: nullable(string()), + has_more: boolean(), + as_of_block: number(), + as_of_timestamp: string(), + data_freshness: DataFreshnessStruct, + indexer_lag_seconds: number(), +}); + +const RateHistoryEntryStruct = object({ + timestamp: string(), + block_number: number(), + rate: string(), + tx_hash: string(), +}); + +export const RateHistoryResponseStruct = object({ + vault_address: string(), + chain_id: number(), + range_start: string(), + range_end: string(), + rates: array(RateHistoryEntryStruct), + as_of_block: number(), + as_of_timestamp: string(), + data_freshness: DataFreshnessStruct, + indexer_lag_seconds: number(), +}); diff --git a/packages/money-account-api-data-service/src/types.ts b/packages/money-account-api-data-service/src/types.ts new file mode 100644 index 00000000000..82500cb2f59 --- /dev/null +++ b/packages/money-account-api-data-service/src/types.ts @@ -0,0 +1,32 @@ +/** + * Valid time-window values for the interest endpoint. + */ +export type InterestWindow = '24h' | '7d' | '30d' | 'ytd' | 'since_inception'; + +/** + * Options for the `fetchInterest` method. + */ +export type InterestOptions = { + vaultAddress: string; + window: InterestWindow; + chainId?: number; +}; + +/** + * Options for the `fetchHistory` method. + */ +export type HistoryOptions = { + vaultAddress?: string; + chainId?: number; + cursor?: string; + limit?: number; +}; + +/** + * Options for the `fetchRateHistory` method. + */ +export type RateHistoryOptions = { + chainId?: number; + from?: string; + to?: string; +}; diff --git a/packages/money-account-api-data-service/tsconfig.build.json b/packages/money-account-api-data-service/tsconfig.build.json new file mode 100644 index 00000000000..02d3bf93d6f --- /dev/null +++ b/packages/money-account-api-data-service/tsconfig.build.json @@ -0,0 +1,14 @@ +{ + "extends": "../../tsconfig.packages.build.json", + "compilerOptions": { + "baseUrl": "./", + "outDir": "./dist", + "rootDir": "./src" + }, + "references": [ + { "path": "../base-data-service/tsconfig.build.json" }, + { "path": "../controller-utils/tsconfig.build.json" }, + { "path": "../messenger/tsconfig.build.json" } + ], + "include": ["../../types", "./src"] +} diff --git a/packages/money-account-api-data-service/tsconfig.json b/packages/money-account-api-data-service/tsconfig.json new file mode 100644 index 00000000000..e514ad1c607 --- /dev/null +++ b/packages/money-account-api-data-service/tsconfig.json @@ -0,0 +1,12 @@ +{ + "extends": "../../tsconfig.packages.json", + "compilerOptions": { + "baseUrl": "./" + }, + "references": [ + { "path": "../base-data-service" }, + { "path": "../controller-utils" }, + { "path": "../messenger" } + ], + "include": ["../../types", "./src"] +} diff --git a/packages/money-account-api-data-service/typedoc.json b/packages/money-account-api-data-service/typedoc.json new file mode 100644 index 00000000000..c9da015dbf8 --- /dev/null +++ b/packages/money-account-api-data-service/typedoc.json @@ -0,0 +1,7 @@ +{ + "entryPoints": ["./src/index.ts"], + "excludePrivate": true, + "hideGenerator": true, + "out": "docs", + "tsconfig": "./tsconfig.build.json" +} diff --git a/packages/money-account-balance-service/CHANGELOG.md b/packages/money-account-balance-service/CHANGELOG.md new file mode 100644 index 00000000000..5e5eb634e9e --- /dev/null +++ b/packages/money-account-balance-service/CHANGELOG.md @@ -0,0 +1,171 @@ +# 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] + +### Changed + +- Bump `@metamask/remote-feature-flag-controller` from `^6.0.0` to `^6.1.0` ([#9980](https://github.com/MetaMask/core/pull/9980)) + +## [2.4.3] + +### Changed + +- Bump `@metamask/base-data-service` from `^0.1.3` to `^1.0.0` ([#9972](https://github.com/MetaMask/core/pull/9972)) +- Bump `@metamask/money-account-api-data-service` from `^0.4.0` to `^0.4.1` ([#9972](https://github.com/MetaMask/core/pull/9972)) + +## [2.4.2] + +### Changed + +- Bump `@metamask/network-controller` from `^35.0.0` to `^36.0.0` ([#9758](https://github.com/MetaMask/core/pull/9758), [#9969](https://github.com/MetaMask/core/pull/9969)) +- Bump `@metamask/superstruct` from `^3.1.0` to `^3.4.1` ([#9754](https://github.com/MetaMask/core/pull/9754)) +- Bump `@metamask/remote-feature-flag-controller` from `^5.0.0` to `^6.0.0` ([#9945](https://github.com/MetaMask/core/pull/9945)) + +## [2.4.1] + +### Changed + +- Bump `@metamask/network-controller` from `^34.0.0` to `^35.0.0` ([#9735](https://github.com/MetaMask/core/pull/9735)) +- Bump `@metamask/remote-feature-flag-controller` from `^4.2.2` to `^5.0.0` ([#9735](https://github.com/MetaMask/core/pull/9735)) + +## [2.4.0] + +### Changed + +- Convert all Veda vault APR values returned by `getVaultApy` to compounded APY using daily compounding before exposing them to consumers. ([#9684](https://github.com/MetaMask/core/pull/9684)) +- Bump `@metamask/money-account-api-data-service` from `^0.3.0` to `^0.4.0` ([#9677](https://github.com/MetaMask/core/pull/9677)) +- Bump `@metamask/money-account-api-data-service` from `^0.2.0` to `^0.3.0` ([#9592](https://github.com/MetaMask/core/pull/9592)) + +## [2.3.0] + +### Added + +- Add `fetchBalanceWithFallback` facade that selects Money API or RPC balance sources from the `moneyAccountBalanceSource` remote feature flag (`api` | `rpc` | `api-only` | `rpc-only`; default `rpc` = RPC primary with Money API fallback). Returns canonical amounts plus `source` and `usedFallback` provenance; reports validation/unavailable source defects via messenger `captureException`; throws `MoneyAccountBalanceFetchError` when all eligible sources fail. ([#9554](https://github.com/MetaMask/core/pull/9554)) +- Permit `MoneyAccountApiDataService:fetchPositions` on the balance service messenger so the facade can read Money API balances. ([#9554](https://github.com/MetaMask/core/pull/9554)) +- Export `CanonicalMoneyAccountBalanceResponse`, balance-source constants/types, and `MoneyAccountBalanceFetchError` / `MoneyAccountBalanceUnavailableError` / `MoneyAccountBalanceValidationError`. ([#9554](https://github.com/MetaMask/core/pull/9554)) + +### Changed + +- Bump `@metamask/money-account-api-data-service` from `^0.1.0` to `^0.2.0` ([#9573](https://github.com/MetaMask/core/pull/9573)) + +## [2.2.0] + +### Added + +- Add optional `trace` constructor option to `MoneyAccountBalanceService` for tracing network requests (RPC calls and the Veda APY API fetch). Tracing is best-effort and does not affect query results if it fails. ([#9434](https://github.com/MetaMask/core/pull/9434)) + +### Changed + +- Bump `@metamask/messenger` from `^1.2.0` to `^2.0.0` ([#9392](https://github.com/MetaMask/core/pull/9392)) + +## [2.1.2] + +### Changed + +- Bump `@metamask/network-controller` from `^33.0.0` to `^34.0.0` ([#9349](https://github.com/MetaMask/core/pull/9349)) + +## [2.1.1] + +### Changed + +- Bump `@metamask/controller-utils` from `^12.2.0` to `^12.3.0` ([#9218](https://github.com/MetaMask/core/pull/9218)) +- Bump `@metamask/network-controller` from `^32.0.0` to `^33.0.0` ([#9218](https://github.com/MetaMask/core/pull/9218)) + +## [2.1.0] + +### Changed + +- Fetch on-chain Money account balances at the `pending` block tag instead of `latest`, so a balance refetch triggered by `TransactionController:transactionConfirmed` returns the post-transaction balance immediately rather than stale data for up to ~20 seconds. ([#9163](https://github.com/MetaMask/core/pull/9163)) + - Applies to `getMoneyAccountBalance`, `getMusdBalance`, `getVmusdBalance`, and `getMusdEquivalentValue`. As a result these reads now reflect pending (mempool-inclusive) state. `getExchangeRate` and the on-chain `Accountant.base()` token-address lookup intentionally remain on `latest`. + +## [2.0.0] + +### Added + +- Add `getMoneyAccountBalance` method that fetches the account's mUSD wallet balance and vault shares valued in mUSD in a single Multicall3 `aggregate3` request. ([#9100](https://github.com/MetaMask/core/pull/9100)) +- Add optional `underlyingToken` field to `VaultConfig` (validated by `VaultConfigStruct`). When present, `getMusdBalance` reads the underlying mUSD token address from config and skips the on-chain `Accountant.base()` call; when absent it falls back to reading `base()` on-chain. ([#9100](https://github.com/MetaMask/core/pull/9100)) +- Add support for configuring the balance `staleTime` at runtime via the `moneyAccountBalanceStaletime` remote feature flag. The flag is read during `init()` and updated on `RemoteFeatureFlagController:stateChange`; absent or malformed values fall back to the default of 60 seconds. ([#9100](https://github.com/MetaMask/core/pull/9100)) + +### Changed + +- **BREAKING:** Rename `musdSHFvd` to `vmusd` across the public API to align with the vmUSD token name: ([#9100](https://github.com/MetaMask/core/pull/9100)) + - `getMusdSHFvdBalance` method → `getVmusdBalance` + - `MoneyAccountBalanceServiceGetMusdSHFvdBalanceAction` type → `MoneyAccountBalanceServiceGetVmusdBalanceAction` + - `MoneyAccountBalanceService:getMusdSHFvdBalance` messenger action string → `MoneyAccountBalanceService:getVmusdBalance` + - `MoneyAccountBalanceResponse.musdSHFvdValueInMusd` property → `vmusdValueInMusd` +- Increase the default `staleTime` for on-chain balance reads (`getMusdBalance`, `getVmusdBalance`, `getMusdEquivalentValue`, and the default for `getExchangeRate`) from 30 seconds to 60 seconds. This default is now overridable via the `moneyAccountBalanceStaletime` remote feature flag. ([#9100](https://github.com/MetaMask/core/pull/9100)) +- Bump `@metamask/utils` from `^11.9.0` to `^11.11.0` ([#9074](https://github.com/MetaMask/core/pull/9074)) +- Bump `@metamask/controller-utils` from `^12.1.0` to `^12.2.0` ([#9058](https://github.com/MetaMask/core/pull/9058), [#9083](https://github.com/MetaMask/core/pull/9083)) +- Bump `@metamask/base-data-service` from `^0.1.2` to `^0.1.3` ([#8799](https://github.com/MetaMask/core/pull/8799)) +- Bump `@metamask/remote-feature-flag-controller` from `^4.2.1` to `^4.2.2` ([#8986](https://github.com/MetaMask/core/pull/8986)) + +## [1.0.2] + +### Changed + +- Bump `@metamask/network-controller` from `^31.0.0` to `^32.0.0` ([#8765](https://github.com/MetaMask/core/pull/8765), [#8774](https://github.com/MetaMask/core/pull/8774)) +- Bump `@metamask/controller-utils` from `^12.0.0` to `^12.1.0` ([#8774](https://github.com/MetaMask/core/pull/8774)) + +## [1.0.1] + +### Changed + +- Bump `@metamask/base-data-service` from `^0.1.1` to `^0.1.2` ([#8755](https://github.com/MetaMask/core/pull/8755)) +- Bump `@metamask/controller-utils` from `^11.20.0` to `^12.0.0` ([#8755](https://github.com/MetaMask/core/pull/8755)) +- Bump `@metamask/network-controller` from `^30.1.0` to `^31.0.0` ([#8755](https://github.com/MetaMask/core/pull/8755)) +- Bump `@metamask/remote-feature-flag-controller` from `^4.2.0` to `^4.2.1` ([#8755](https://github.com/MetaMask/core/pull/8755)) + +## [1.0.0] + +### Added + +- Add `VaultConfigNotAvailableError` and `VaultConfigValidationError` error classes for typed consumer error handling ([#8742](https://github.com/MetaMask/core/pull/8742)) +- Add `LENS_ABI` constant for the Arctic Architecture Lens contract ([#8742](https://github.com/MetaMask/core/pull/8742)) + +### Changed + +- **BREAKING:** `MoneyAccountBalanceService` no longer accepts vault config via constructor. Vault config is now read from `RemoteFeatureFlagController` state. Add `@metamask/remote-feature-flag-controller` as a dependency and permit `RemoteFeatureFlagController:getState`action and `RemoteFeatureFlagController:stateChange` event on the service's messenger. Service methods throw `VaultConfigNotAvailableError` until a valid config is available. ([#8742](https://github.com/MetaMask/core/pull/8742)) +- **BREAKING:** `VaultConfig` fields have changed — `vaultAddress` → `boringVault`, `vaultChainId` → `chainId`; `underlyingTokenAddress` and `underlyingTokenDecimals` removed; `lensAddress` and `tellerAddress` added ([#8742](https://github.com/MetaMask/core/pull/8742)) +- **BREAKING:** `MusdEquivalentValueResponse` shape has changed — `musdSHFvdBalance`, `exchangeRate`, and `musdEquivalentValue` replaced by a single `balanceOfInAssets` field ([#8742](https://github.com/MetaMask/core/pull/8742)) +- Monad (`0x8f`) added to `VEDA_API_NETWORK_NAMES` ([#8742](https://github.com/MetaMask/core/pull/8742)) +- Bump `@metamask/messenger` from `^1.1.1` to `^1.2.0` ([#8632](https://github.com/MetaMask/core/pull/8632)) +- Bump `@metamask/network-controller` from `^30.0.1` to `^30.1.0` ([#8636](https://github.com/MetaMask/core/pull/8636)) + +## [0.2.0] + +### Added + +- Add `money-account-balance-service` to the root `tsconfig.json` and `tsconfig.build.json` files so that it is usable ([#8477](https://github.com/MetaMask/core/pull/8477)) + +## [0.1.0] [DEPRECATED] + +### Added + +- Add `MoneyAccountBalanceService` data service ([#8428](https://github.com/MetaMask/core/pull/8428)) + - Fetch mUSD ERC-20 balance via RPC (`getMusdBalance`) + - Fetch musdSHFvd vault share balance via RPC (`getMusdSHFvdBalance`) + - Fetch Veda Accountant exchange rate via RPC (`getExchangeRate`) + - Compute mUSD-equivalent value of vault share holdings (`getMusdEquivalentValue`) + - Fetch vault APY from the Veda performance REST API (`getVaultApy`) + +[Unreleased]: https://github.com/MetaMask/core/compare/@metamask/money-account-balance-service@2.4.3...HEAD +[2.4.3]: https://github.com/MetaMask/core/compare/@metamask/money-account-balance-service@2.4.2...@metamask/money-account-balance-service@2.4.3 +[2.4.2]: https://github.com/MetaMask/core/compare/@metamask/money-account-balance-service@2.4.1...@metamask/money-account-balance-service@2.4.2 +[2.4.1]: https://github.com/MetaMask/core/compare/@metamask/money-account-balance-service@2.4.0...@metamask/money-account-balance-service@2.4.1 +[2.4.0]: https://github.com/MetaMask/core/compare/@metamask/money-account-balance-service@2.3.0...@metamask/money-account-balance-service@2.4.0 +[2.3.0]: https://github.com/MetaMask/core/compare/@metamask/money-account-balance-service@2.2.0...@metamask/money-account-balance-service@2.3.0 +[2.2.0]: https://github.com/MetaMask/core/compare/@metamask/money-account-balance-service@2.1.2...@metamask/money-account-balance-service@2.2.0 +[2.1.2]: https://github.com/MetaMask/core/compare/@metamask/money-account-balance-service@2.1.1...@metamask/money-account-balance-service@2.1.2 +[2.1.1]: https://github.com/MetaMask/core/compare/@metamask/money-account-balance-service@2.1.0...@metamask/money-account-balance-service@2.1.1 +[2.1.0]: https://github.com/MetaMask/core/compare/@metamask/money-account-balance-service@2.0.0...@metamask/money-account-balance-service@2.1.0 +[2.0.0]: https://github.com/MetaMask/core/compare/@metamask/money-account-balance-service@1.0.2...@metamask/money-account-balance-service@2.0.0 +[1.0.2]: https://github.com/MetaMask/core/compare/@metamask/money-account-balance-service@1.0.1...@metamask/money-account-balance-service@1.0.2 +[1.0.1]: https://github.com/MetaMask/core/compare/@metamask/money-account-balance-service@1.0.0...@metamask/money-account-balance-service@1.0.1 +[1.0.0]: https://github.com/MetaMask/core/compare/@metamask/money-account-balance-service@0.2.0...@metamask/money-account-balance-service@1.0.0 +[0.2.0]: https://github.com/MetaMask/core/compare/@metamask/money-account-balance-service@0.1.0...@metamask/money-account-balance-service@0.2.0 +[0.1.0]: https://github.com/MetaMask/core/releases/tag/@metamask/money-account-balance-service@0.1.0 diff --git a/packages/money-account-balance-service/LICENSE b/packages/money-account-balance-service/LICENSE new file mode 100644 index 00000000000..fe29e78e0fe --- /dev/null +++ b/packages/money-account-balance-service/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/money-account-balance-service/README.md b/packages/money-account-balance-service/README.md new file mode 100644 index 00000000000..609c790c871 --- /dev/null +++ b/packages/money-account-balance-service/README.md @@ -0,0 +1,65 @@ +# `@metamask/money-account-balance-service` + +Data service for Money account balances. For presentation, prefer +`fetchBalanceWithFallback`, which selects between the Money Account API and +on-chain Multicall3 RPC according to the `moneyAccountBalanceSource` remote +feature flag. + +Also provides lower-level RPC helpers (mUSD / vmUSD / exchange rate), plus +vault APY from Veda's REST API. + +## Canonical balance facade + +```ts +const result = await service.fetchBalanceWithFallback(accountAddress); +// { +// musdBalance, vmusdValueInMusd, totalBalance, +// source: 'api' | 'rpc', +// usedFallback: boolean, +// } +``` + +### Feature flag: `moneyAccountBalanceSource` + +| Value | Behavior | +| ------------------------------------- | ------------------------------- | +| `rpc` (default when absent/malformed) | RPC primary, Money API fallback | +| `api` | Money API primary, RPC fallback | +| `rpc-only` | RPC only (no fallback) | +| `api-only` | Money API only (no fallback) | + +Callers must not select a source. Provenance (`source`, `usedFallback`) is +always returned so fallback is never silent. When both eligible sources fail, +the service throws `MoneyAccountBalanceFetchError` (with `causes`) and never +invents a zero balance. + +Malformed or unavailable source balances +(`MoneyAccountBalanceValidationError` / `MoneyAccountBalanceUnavailableError`) +are reported via the messenger's `captureException` before fallback. + +### Messenger wiring + +The facade calls `MoneyAccountApiDataService:fetchPositions`. Client +composition must permit that action on the balance-service messenger (same +pattern as NetworkController / RemoteFeatureFlagController actions). + +### POC resilience note + +Balance RPC and third-party vault APY currently share one `BaseDataService` +retry / circuit-breaker policy. A Veda APY outage can affect RPC balance +availability (including facade fallback). Splitting those failure domains is +planned before production reliance on the facade. Source equivalence between +Money API and RPC totals is also not yet proven — keep the default `rpc` +policy until shadow comparison and rollout gates from the ADR are met. + +## Installation + +`yarn add @metamask/money-account-balance-service` + +or + +`npm install @metamask/money-account-balance-service` + +## Contributing + +This package is part of a monorepo. Instructions for contributing can be found in the [monorepo README](https://github.com/MetaMask/core/blob/main/README.md). diff --git a/packages/money-account-balance-service/jest.config.js b/packages/money-account-balance-service/jest.config.js new file mode 100644 index 00000000000..c17efa251af --- /dev/null +++ b/packages/money-account-balance-service/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, + + coverageThreshold: { + global: { + branches: 100, + functions: 100, + lines: 100, + statements: 100, + }, + }, +}); diff --git a/packages/money-account-balance-service/package.json b/packages/money-account-balance-service/package.json new file mode 100644 index 00000000000..c9693c77830 --- /dev/null +++ b/packages/money-account-balance-service/package.json @@ -0,0 +1,86 @@ +{ + "name": "@metamask/money-account-balance-service", + "version": "2.4.3", + "description": "Data service for fetching Money account balances, exchange rates, and vault APY", + "keywords": [ + "Ethereum", + "MetaMask" + ], + "homepage": "https://github.com/MetaMask/core/tree/main/packages/money-account-balance-service#readme", + "bugs": { + "url": "https://github.com/MetaMask/core/issues" + }, + "license": "MIT", + "repository": { + "type": "git", + "url": "https://github.com/MetaMask/core.git" + }, + "files": [ + "dist/" + ], + "sideEffects": false, + "main": "./dist/index.cjs", + "types": "./dist/index.d.cts", + "exports": { + ".": { + "import": { + "types": "./dist/index.d.mts", + "default": "./dist/index.mjs" + }, + "require": { + "types": "./dist/index.d.cts", + "default": "./dist/index.cjs" + } + }, + "./package.json": "./package.json" + }, + "publishConfig": { + "access": "public", + "registry": "https://registry.npmjs.org/" + }, + "scripts": { + "build": "ts-bridge --project tsconfig.build.json --verbose --clean --no-references", + "build:all": "ts-bridge --project tsconfig.build.json --verbose --clean", + "build:docs": "typedoc", + "changelog:update": "../../scripts/update-changelog.sh @metamask/money-account-balance-service", + "changelog:validate": "../../scripts/validate-changelog.sh @metamask/money-account-balance-service", + "lint:tsconfigs": "tsx ../../scripts/lint-tsconfigs/lint-tsconfigs.mts", + "lint:tsconfigs:fix": "tsx ../../scripts/lint-tsconfigs/lint-tsconfigs.mts --fix", + "messenger-action-types:check": "tsx ../../packages/messenger-cli/src/cli.ts --formatter oxfmt --esm --check", + "messenger-action-types:generate": "tsx ../../packages/messenger-cli/src/cli.ts --formatter oxfmt --esm --generate", + "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": { + "@ethersproject/contracts": "^5.7.0", + "@ethersproject/providers": "^5.7.0", + "@metamask/base-data-service": "^1.0.0", + "@metamask/controller-utils": "^12.3.0", + "@metamask/messenger": "^2.0.0", + "@metamask/metamask-eth-abis": "^3.1.1", + "@metamask/money-account-api-data-service": "^0.4.1", + "@metamask/network-controller": "^36.0.0", + "@metamask/remote-feature-flag-controller": "^6.1.0", + "@metamask/superstruct": "^3.4.1", + "@metamask/utils": "^11.11.0" + }, + "devDependencies": { + "@metamask/auto-changelog": "^6.1.0", + "@ts-bridge/cli": "^0.6.4", + "@types/jest": "^30.0.0", + "deepmerge": "^4.2.2", + "jest": "^30.4.2", + "nock": "^13.3.1", + "ts-jest": "^29.4.11", + "tsx": "^4.20.5", + "typedoc": "^0.25.13", + "typedoc-plugin-missing-exports": "^2.0.0", + "typescript": "~5.3.3" + }, + "engines": { + "node": "^18.18 || >=20" + } +} diff --git a/packages/money-account-balance-service/src/constants.ts b/packages/money-account-balance-service/src/constants.ts new file mode 100644 index 00000000000..2b0874d3b0e --- /dev/null +++ b/packages/money-account-balance-service/src/constants.ts @@ -0,0 +1,163 @@ +import { Duration, Hex, inMilliseconds } from '@metamask/utils'; + +export const VEDA_PERFORMANCE_API_BASE_URL = 'https://api.sevenseas.capital'; + +/** + * The key under which vault config is stored in + * `RemoteFeatureFlagController` state's `remoteFeatureFlags` map. + */ +export const VAULT_CONFIG_FEATURE_FLAG_KEY = 'moneyAccountVaultConfig'; + +/** + * The key under which the Money account balance `staleTime` (in milliseconds) + * is stored in `RemoteFeatureFlagController` state's `remoteFeatureFlags` map. + * Falls back to {@link DEFAULT_BALANCE_STALE_TIME} when absent or malformed. + */ +export const MONEY_ACCOUNT_BALANCE_STALETIME_FEATURE_FLAG_KEY = + 'moneyAccountBalanceStaletime'; + +/** + * Default `staleTime` (in milliseconds) for on-chain Money account balance + * reads, used when {@link MONEY_ACCOUNT_BALANCE_STALETIME_FEATURE_FLAG_KEY} is + * absent or malformed. + */ +export const DEFAULT_BALANCE_STALE_TIME = inMilliseconds(1, Duration.Minute); + +/** + * The key under which the Money account balance source routing policy is + * stored in `RemoteFeatureFlagController` state's `remoteFeatureFlags` map. + * Falls back to {@link DEFAULT_BALANCE_SOURCE_POLICY} when absent or malformed. + */ +export const MONEY_ACCOUNT_BALANCE_SOURCE_FEATURE_FLAG_KEY = + 'moneyAccountBalanceSource'; + +/** + * Supported values for {@link MONEY_ACCOUNT_BALANCE_SOURCE_FEATURE_FLAG_KEY}. + * + * - `api` — Money API primary, RPC fallback + * - `rpc` — RPC primary, Money API fallback + * - `api-only` — Money API only (incident kill switch) + * - `rpc-only` — RPC only (incident kill switch) + */ +export const BALANCE_SOURCE_POLICIES = [ + 'api', + 'rpc', + 'api-only', + 'rpc-only', +] as const; + +export type BalanceSourcePolicy = (typeof BALANCE_SOURCE_POLICIES)[number]; + +/** + * Hard default when the routing flag is absent or malformed: RPC primary + * with Money API fallback. API-primary remains opt-in via the feature flag + * until source equivalence is proven. + */ +export const DEFAULT_BALANCE_SOURCE_POLICY: BalanceSourcePolicy = 'rpc'; + +/** + * Balance source used in the canonical facade result. + */ +export type BalanceSource = 'api' | 'rpc'; + +export const VEDA_API_NETWORK_NAMES: Record = { + '0xa4b1': 'arbitrum', + '0x8f': 'monad', +}; + +/** + * Multicall3 contract address by chain ID, used to batch the Money account + * balance reads into a single RPC request. Multicall3 is deployed at the same + * canonical address on every supported chain. + * + * Source: https://github.com/mds1/multicall/blob/main/deployments.json + */ +export const MULTICALL3_ADDRESS_BY_CHAIN_ID: Record = { + '0xa4b1': '0xcA11bde05977b3631167028862bE2a173976CA11', // Arbitrum One + '0x8f': '0xcA11bde05977b3631167028862bE2a173976CA11', // Monad mainnet +}; + +/** + * Minimal ABI for the Multicall3 `aggregate3` function. + */ +export const MULTICALL3_ABI = [ + { + name: 'aggregate3', + type: 'function', + stateMutability: 'payable', + inputs: [ + { + name: 'calls', + type: 'tuple[]', + components: [ + { name: 'target', type: 'address' }, + { name: 'allowFailure', type: 'bool' }, + { name: 'callData', type: 'bytes' }, + ], + }, + ], + outputs: [ + { + name: 'returnData', + type: 'tuple[]', + components: [ + { name: 'success', type: 'bool' }, + { name: 'returnData', type: 'bytes' }, + ], + }, + ], + }, +] as const; + +/** + * Minimal ABI for the Veda Accountant contract. Covers: + * - base (0x5001f3b5) — the underlying ERC20 base asset address + * - getRate (0x679aefce) — exchange rate between vault shares and the + * underlying asset (mUSD) as a uint256 + */ +export const ACCOUNTANT_ABI = [ + { + inputs: [], + name: 'base', + outputs: [{ internalType: 'contract ERC20', name: '', type: 'address' }], + stateMutability: 'view', + type: 'function', + }, + { + inputs: [], + name: 'getRate', + outputs: [{ internalType: 'uint256', name: 'rate', type: 'uint256' }], + stateMutability: 'view', + type: 'function', + }, +] as const; + +/** + * Minimal ABI for the Arctic Architecture Lens contract. + * Covers: + * - balanceOf (0xf7888aec) — shares held by an account in a BoringVault + * - balanceOfInAssets (0x789fd871) — share balance denominated in underlying assets + */ +export const LENS_ABI = [ + { + inputs: [ + { name: 'account', type: 'address' }, + { name: 'boringVault', type: 'address' }, + ], + name: 'balanceOf', + outputs: [{ name: 'shares', type: 'uint256' }], + stateMutability: 'view', + type: 'function', + }, + { + inputs: [ + { name: 'account', type: 'address' }, + { name: 'boringVault', type: 'address' }, + { name: 'accountant', type: 'address' }, + ], + name: 'balanceOfInAssets', + outputs: [{ name: 'assets', type: 'uint256' }], + stateMutability: 'view', + type: 'function', + }, +] as const; diff --git a/packages/money-account-balance-service/src/errors.ts b/packages/money-account-balance-service/src/errors.ts new file mode 100644 index 00000000000..22118c9c787 --- /dev/null +++ b/packages/money-account-balance-service/src/errors.ts @@ -0,0 +1,79 @@ +export class VedaResponseValidationError extends Error { + constructor(message?: string) { + super(message ?? 'Malformed response received from Veda API'); + this.name = 'VedaResponseValidationError'; + } +} + +/** + * Thrown when a balance source returns data that fails semantic validation + * (e.g. non-integer amounts, or `totalBalance !== musdBalance + vmusdValueInMusd`). + * Reported via the messenger's `captureException` when encountered by + * {@link MoneyAccountBalanceService.fetchBalanceWithFallback}. + */ +export class MoneyAccountBalanceValidationError extends Error { + constructor(message: string) { + super(message); + this.name = 'MoneyAccountBalanceValidationError'; + } +} + +/** + * Thrown when a balance source is transport-successful but has no usable + * balance (e.g. Money API `balance: null`). Reported via the messenger's + * `captureException` when encountered by + * {@link MoneyAccountBalanceService.fetchBalanceWithFallback}. + */ +export class MoneyAccountBalanceUnavailableError extends Error { + constructor(message: string) { + super(message); + this.name = 'MoneyAccountBalanceUnavailableError'; + } +} + +/** + * Thrown when every eligible balance source fails. Preserves each cause for + * diagnostics; never substitutes a zero balance. + */ +export class MoneyAccountBalanceFetchError extends Error { + readonly causes: unknown[]; + + constructor(causes: unknown[]) { + super( + 'MoneyAccountBalanceService: failed to fetch balance from all eligible sources', + ); + this.name = 'MoneyAccountBalanceFetchError'; + this.causes = causes; + } +} + +/** + * Thrown when a public method is called but vault config has not yet been + * loaded from RemoteFeatureFlagController, or the flag key is absent. + * This is a transient condition — the service will recover once flags are + * fetched and a valid config arrives. + */ +export class VaultConfigNotAvailableError extends Error { + constructor() { + super( + 'MoneyAccountBalanceService: vault config is not available. ' + + 'RemoteFeatureFlagController may not have fetched flags yet.', + ); + this.name = 'VaultConfigNotAvailableError'; + } +} + +/** + * Thrown when the vault config flag value is present but fails superstruct + * validation. This surfaces to error monitoring service (e.g. Sentry) via the messenger's captureException + * handler. + */ +export class VaultConfigValidationError extends Error { + constructor(message?: string) { + super( + message ?? + 'MoneyAccountBalanceService: vault config from remote feature flags is malformed.', + ); + this.name = 'VaultConfigValidationError'; + } +} diff --git a/packages/money-account-balance-service/src/index.ts b/packages/money-account-balance-service/src/index.ts new file mode 100644 index 00000000000..2690bdd4782 --- /dev/null +++ b/packages/money-account-balance-service/src/index.ts @@ -0,0 +1,39 @@ +export { MoneyAccountBalanceService } from './money-account-balance-service.js'; +export type { + MoneyAccountBalanceServiceActions, + MoneyAccountBalanceServiceEvents, + MoneyAccountBalanceServiceMessenger, + MoneyAccountBalanceServiceOptions, + MoneyAccountBalanceServiceTraceCallback, + MoneyAccountBalanceServiceTraceRequest, +} from './money-account-balance-service.js'; +export type { + MoneyAccountBalanceServiceFetchBalanceWithFallbackAction, + MoneyAccountBalanceServiceGetMoneyAccountBalanceAction, + MoneyAccountBalanceServiceGetMusdBalanceAction, + MoneyAccountBalanceServiceGetVmusdBalanceAction, + MoneyAccountBalanceServiceGetExchangeRateAction, + MoneyAccountBalanceServiceGetMusdEquivalentValueAction, + MoneyAccountBalanceServiceGetVaultApyAction, +} from './money-account-balance-service-method-action-types.js'; +export type { + CanonicalMoneyAccountBalanceResponse, + ExchangeRateResponse, + MoneyAccountBalanceResponse, + MusdEquivalentValueResponse, + NormalizedVaultApyResponse, +} from './response.types'; +export type { BalanceSource, BalanceSourcePolicy } from './constants.js'; +export { + BALANCE_SOURCE_POLICIES, + DEFAULT_BALANCE_SOURCE_POLICY, + MONEY_ACCOUNT_BALANCE_SOURCE_FEATURE_FLAG_KEY, +} from './constants.js'; +export { + MoneyAccountBalanceFetchError, + MoneyAccountBalanceUnavailableError, + MoneyAccountBalanceValidationError, + VaultConfigNotAvailableError, + VaultConfigValidationError, +} from './errors.js'; +export type { VaultConfig } from './types.js'; diff --git a/packages/money-account-balance-service/src/logger.ts b/packages/money-account-balance-service/src/logger.ts new file mode 100644 index 00000000000..32c17d104f1 --- /dev/null +++ b/packages/money-account-balance-service/src/logger.ts @@ -0,0 +1,7 @@ +import { createProjectLogger, createModuleLogger } from '@metamask/utils'; + +export const projectLogger = createProjectLogger( + 'money-account-balance-service', +); + +export { createModuleLogger }; diff --git a/packages/money-account-balance-service/src/money-account-balance-service-method-action-types.ts b/packages/money-account-balance-service/src/money-account-balance-service-method-action-types.ts new file mode 100644 index 00000000000..90dbc84a7d8 --- /dev/null +++ b/packages/money-account-balance-service/src/money-account-balance-service-method-action-types.ts @@ -0,0 +1,117 @@ +/** + * This file is auto generated. + * Do not edit manually. + */ + +import type { MoneyAccountBalanceService } from './money-account-balance-service.js'; + +/** + * Fetches the canonical Money account balance, selecting the Money API or + * RPC source according to the `moneyAccountBalanceSource` remote feature + * flag (default: RPC primary with Money API fallback). + * + * Callers must not select a source. Provenance is returned on the result so + * fallback is never silent. Malformed or unavailable source balances are + * reported via the messenger's `captureException` before fallback. + * + * @param accountAddress - The Money account's Ethereum address. + * @returns Canonical balance amounts with source provenance. + * @throws {@link MoneyAccountBalanceFetchError} when every eligible source + * fails. Never returns a synthetic zero balance. + */ +export type MoneyAccountBalanceServiceFetchBalanceWithFallbackAction = { + type: `MoneyAccountBalanceService:fetchBalanceWithFallback`; + handler: MoneyAccountBalanceService['fetchBalanceWithFallback']; +}; + +/** + * Fetches the mUSD ERC-20 balance for the given account address via RPC. + * + * @param accountAddress - The Money account's Ethereum address. + * @returns The mUSD balance as a raw uint256 string. + * @throws {@link VaultConfigNotAvailableError} if vault config has not been loaded. + */ +export type MoneyAccountBalanceServiceGetMusdBalanceAction = { + type: `MoneyAccountBalanceService:getMusdBalance`; + handler: MoneyAccountBalanceService['getMusdBalance']; +}; + +/** + * Fetches the account's total Money balance inputs in a single batched RPC + * request via Multicall3's `aggregate3` + * + * @param accountAddress - The Money account's Ethereum address. + * @returns The mUSD balance and the mUSD-equivalent value of vault shares as + * raw uint256 strings. The total balance is the sum of the mUSD balance and the mUSD-equivalent value of vault shares. + * @throws {@link VaultConfigNotAvailableError} if vault config has not been loaded. + */ +export type MoneyAccountBalanceServiceGetMoneyAccountBalanceAction = { + type: `MoneyAccountBalanceService:getMoneyAccountBalance`; + handler: MoneyAccountBalanceService['getMoneyAccountBalance']; +}; + +/** + * Fetches the vmUSD (Veda vault share) ERC-20 balance for the given + * account address via RPC. + * + * @param accountAddress - The Money account's Ethereum address. + * @returns The vmUSD balance as a raw uint256 string. + * @throws {@link VaultConfigNotAvailableError} if vault config has not been loaded. + */ +export type MoneyAccountBalanceServiceGetVmusdBalanceAction = { + type: `MoneyAccountBalanceService:getVmusdBalance`; + handler: MoneyAccountBalanceService['getVmusdBalance']; +}; + +/** + * Fetches the current exchange rate from the Veda Accountant contract via + * RPC. The rate represents the conversion factor from vmUSD shares to + * the underlying mUSD asset. + * + * @param options - The options for the query. + * @param options.staleTime - Cache stale time override for this query. + * @returns The exchange rate as a raw uint256 string. + * @throws {@link VaultConfigNotAvailableError} if vault config has not been loaded. + */ +export type MoneyAccountBalanceServiceGetExchangeRateAction = { + type: `MoneyAccountBalanceService:getExchangeRate`; + handler: MoneyAccountBalanceService['getExchangeRate']; +}; + +/** + * Fetches the mUSD-equivalent value of the account's vmUSD vault shares + * via `Lens.balanceOfInAssets` RPC. + * + * @param accountAddress - The Money account's Ethereum address. + * @returns The mUSD-equivalent value of vault shares as a raw uint256 string. + * @throws {@link VaultConfigNotAvailableError} if vault config has not been loaded. + */ +export type MoneyAccountBalanceServiceGetMusdEquivalentValueAction = { + type: `MoneyAccountBalanceService:getMusdEquivalentValue`; + handler: MoneyAccountBalanceService['getMusdEquivalentValue']; +}; + +/** + * Fetches the vault's APY and fee breakdown from the Veda performance REST API. + * APR values in the response are converted to APY using daily + * compounding before the normalized response is returned. + * + * @returns The normalized vault APY response with compounded APY values. + * @throws {@link VaultConfigNotAvailableError} if vault config has not been loaded. + */ +export type MoneyAccountBalanceServiceGetVaultApyAction = { + type: `MoneyAccountBalanceService:getVaultApy`; + handler: MoneyAccountBalanceService['getVaultApy']; +}; + +/** + * Union of all MoneyAccountBalanceService action types. + */ +export type MoneyAccountBalanceServiceMethodActions = + | MoneyAccountBalanceServiceFetchBalanceWithFallbackAction + | MoneyAccountBalanceServiceGetMusdBalanceAction + | MoneyAccountBalanceServiceGetMoneyAccountBalanceAction + | MoneyAccountBalanceServiceGetVmusdBalanceAction + | MoneyAccountBalanceServiceGetExchangeRateAction + | MoneyAccountBalanceServiceGetMusdEquivalentValueAction + | MoneyAccountBalanceServiceGetVaultApyAction; diff --git a/packages/money-account-balance-service/src/money-account-balance-service.test.ts b/packages/money-account-balance-service/src/money-account-balance-service.test.ts new file mode 100644 index 00000000000..a2ae8e923ed --- /dev/null +++ b/packages/money-account-balance-service/src/money-account-balance-service.test.ts @@ -0,0 +1,2452 @@ +import { Contract } from '@ethersproject/contracts'; +import { Web3Provider } from '@ethersproject/providers'; +import { DEFAULT_MAX_RETRIES, HttpError } from '@metamask/controller-utils'; +import type { + TraceCallback, + TraceContext, + TraceRequest, +} from '@metamask/controller-utils'; +import { Messenger, MOCK_ANY_NAMESPACE } from '@metamask/messenger'; +import type { + MockAnyNamespace, + MessengerActions, + MessengerEvents, +} from '@metamask/messenger'; +import { abiERC20 } from '@metamask/metamask-eth-abis'; +import type { Json } from '@metamask/utils'; +import nock, { cleanAll as nockCleanAll } from 'nock'; + +import { + LENS_ABI, + MONEY_ACCOUNT_BALANCE_SOURCE_FEATURE_FLAG_KEY, + MONEY_ACCOUNT_BALANCE_STALETIME_FEATURE_FLAG_KEY, + MULTICALL3_ADDRESS_BY_CHAIN_ID, + VAULT_CONFIG_FEATURE_FLAG_KEY, +} from './constants.js'; +import { + MoneyAccountBalanceFetchError, + MoneyAccountBalanceUnavailableError, + MoneyAccountBalanceValidationError, + VaultConfigNotAvailableError, + VaultConfigValidationError, + VedaResponseValidationError, +} from './errors.js'; +import type { MoneyAccountBalanceServiceMessenger } from './money-account-balance-service.js'; +import { + MoneyAccountBalanceService, + serviceName, +} from './money-account-balance-service.js'; + +jest.mock('@ethersproject/contracts'); +jest.mock('@ethersproject/providers'); + +const MockContract = Contract as jest.MockedClass; +const MockWeb3Provider = Web3Provider as jest.MockedClass; + +// ============================================================ +// Fixtures +// ============================================================ + +const MOCK_VAULT_ADDRESS = + '0x1111111111111111111111111111111111111111' as const; +const MOCK_ACCOUNTANT_ADDRESS = + '0x2222222222222222222222222222222222222222' as const; +const MOCK_UNDERLYING_TOKEN_ADDRESS = + '0x3333333333333333333333333333333333333333' as const; +const MOCK_ACCOUNT_ADDRESS = + '0x4444444444444444444444444444444444444444' as const; +const MOCK_TELLER_ADDRESS = + '0x5555555555555555555555555555555555555555' as const; +const MOCK_LENS_ADDRESS = '0x6666666666666666666666666666666666666666' as const; +const MOCK_NETWORK_CLIENT_ID = 'arbitrum-mainnet'; + +const MOCK_VAULT_CONFIG = { + boringVault: MOCK_VAULT_ADDRESS, + accountantAddress: MOCK_ACCOUNTANT_ADDRESS, + tellerAddress: MOCK_TELLER_ADDRESS, + lensAddress: MOCK_LENS_ADDRESS, + chainId: '0xa4b1' as const, +}; + +const MOCK_VAULT_CONFIG_WITH_UNDERLYING_TOKEN = { + ...MOCK_VAULT_CONFIG, + underlyingToken: MOCK_UNDERLYING_TOKEN_ADDRESS, +}; + +const MOCK_NETWORK_CONFIG = { + chainId: '0xa4b1' as const, + rpcEndpoints: [ + { + networkClientId: MOCK_NETWORK_CLIENT_ID, + url: 'https://arb1.arbitrum.io/rpc', + }, + ], + defaultRpcEndpointIndex: 0, + name: 'Arbitrum One', + nativeCurrency: 'ETH', + blockExplorerUrls: [], +}; + +// Web3Provider is mocked at the module level so type correctness is irrelevant. +const MOCK_PROVIDER = {} as unknown as ConstructorParameters< + typeof Web3Provider +>[0]; + +const MOCK_VAULT_APY_RAW_RESPONSE = { + Response: { + aggregation_period: '7 days', + apy: 0.055, + chain_allocation: { arbitrum: 1.0 }, + fees: 0.005, + global_apy_breakdown: { + fee: 0.005, + maturity_apy: 0.03, + real_apy: 0.05, + }, + performance_fees: 0.001, + real_apy_breakdown: [ + { + allocation: 1.0, + apy: 0.055, + apy_net: 0.05, + chain: 'arbitrum', + protocol: 'aave', + }, + ], + timestamp: '2024-01-01T00:00:00Z', + }, +}; + +const MOCK_VAULT_APY_NORMALIZED = { + aggregationPeriod: '7 days', + apy: 0.05653623699373145, + chainAllocation: { arbitrum: 1.0 }, + fees: 0.005, + globalApyBreakdown: { + fee: 0.005, + maturityApy: 0.030453263600551006, + realApy: 0.05126749646744733, + }, + performanceFees: 0.001, + realApyBreakdown: [ + { + allocation: 1.0, + apy: 0.05653623699373145, + apyNet: 0.05126749646744733, + chain: 'arbitrum', + protocol: 'aave', + }, + ], + timestamp: '2024-01-01T00:00:00Z', +}; + +// ============================================================ +// Messenger helpers +// ============================================================ + +type RootMessenger = Messenger< + MockAnyNamespace, + MessengerActions, + MessengerEvents +>; + +function createRootMessenger( + captureException?: (error: Error) => void, +): RootMessenger { + return new Messenger({ namespace: MOCK_ANY_NAMESPACE, captureException }); +} + +function createServiceMessenger( + rootMessenger: RootMessenger, +): MoneyAccountBalanceServiceMessenger { + return new Messenger({ + namespace: serviceName, + parent: rootMessenger, + }); +} + +// ============================================================ +// Factory +// ============================================================ + +/** + * Publishes a `RemoteFeatureFlagController:stateChange` event via the root + * messenger, simulating a flag update from RemoteFeatureFlagController. + * + * @param rootMessenger - The root messenger to publish on. + * @param remoteFeatureFlags - The new flags object. + */ +function publishRFFCStateChange( + rootMessenger: RootMessenger, + remoteFeatureFlags: Record, +): void { + rootMessenger.publish( + 'RemoteFeatureFlagController:stateChange', + { remoteFeatureFlags, cacheTimestamp: Date.now() }, + [], + ); +} + +/** + * Builds the service under test with messenger action stubs for all + * dependencies, including RemoteFeatureFlagController. + * + * By default, `init()` is called and the RFFC state contains a valid + * `moneyVaultConfig`. Pass `rffcFlags` to override, or `callInit: false` to + * skip the eager init. + * + * A `captureException` mock is wired onto the root messenger by default so + * that subscriber errors (e.g. `VaultConfigValidationError`) are routed there + * instead of `console.error`. Pass your own mock to assert on it. + * + * @param args - Optional overrides. + * @param args.rffcFlags - Flags to return from `RemoteFeatureFlagController:getState`. + * @param args.callInit - Whether to call `service.init()` after construction. Defaults to true. + * @param args.captureException - Error reporter wired on the root messenger. + * @param args.options - Partial constructor options for the service. + * @param args.mockFetchPositions - Stub for `MoneyAccountApiDataService:fetchPositions`. + * @returns The constructed service together with messenger instances and mock stubs. + */ +function createService({ + rffcFlags = { [VAULT_CONFIG_FEATURE_FLAG_KEY]: MOCK_VAULT_CONFIG }, + callInit = true, + captureException = jest.fn(), + options = {}, + mockFetchPositions = jest.fn(), +}: { + rffcFlags?: Record; + callInit?: boolean; + captureException?: jest.Mock; + options?: Partial< + ConstructorParameters[0] + >; + mockFetchPositions?: jest.Mock; +} = {}): { + service: MoneyAccountBalanceService; + rootMessenger: RootMessenger; + messenger: MoneyAccountBalanceServiceMessenger; + mockGetNetworkConfig: jest.Mock; + mockGetNetworkClient: jest.Mock; + mockGetRFFCState: jest.Mock; + mockFetchPositions: jest.Mock; + captureException: jest.Mock; +} { + const rootMessenger = createRootMessenger(captureException); + const messenger = createServiceMessenger(rootMessenger); + + const mockGetNetworkConfig = jest.fn().mockReturnValue(MOCK_NETWORK_CONFIG); + const mockGetNetworkClient = jest.fn().mockReturnValue({ + provider: MOCK_PROVIDER, + }); + const mockGetRFFCState = jest + .fn() + .mockReturnValue({ remoteFeatureFlags: rffcFlags, cacheTimestamp: 0 }); + + rootMessenger.registerActionHandler( + 'NetworkController:getNetworkConfigurationByChainId', + mockGetNetworkConfig, + ); + rootMessenger.registerActionHandler( + 'NetworkController:getNetworkClientById', + mockGetNetworkClient, + ); + rootMessenger.registerActionHandler( + 'RemoteFeatureFlagController:getState', + mockGetRFFCState, + ); + rootMessenger.registerActionHandler( + 'MoneyAccountApiDataService:fetchPositions', + mockFetchPositions, + ); + + rootMessenger.delegate({ + actions: [ + 'NetworkController:getNetworkConfigurationByChainId', + 'NetworkController:getNetworkClientById', + 'RemoteFeatureFlagController:getState', + 'MoneyAccountApiDataService:fetchPositions', + ], + // eslint-disable-next-line no-restricted-syntax + events: ['RemoteFeatureFlagController:stateChange'], + messenger, + }); + + const service = new MoneyAccountBalanceService({ messenger, ...options }); + + if (callInit) { + service.init(); + } + + return { + service, + rootMessenger, + messenger, + mockGetNetworkConfig, + mockGetNetworkClient, + mockGetRFFCState, + mockFetchPositions, + captureException, + }; +} + +/** + * Configures the Contract mock so that `balanceOf` resolves to an object + * whose `.toString()` returns `balance`. Used for single-contract flows + * such as `getVmusdBalance`. + * + * @param balance - The raw uint256 balance string to return. + */ +function mockErc20BalanceOf(balance: string): void { + MockContract.mockImplementation( + () => + ({ + balanceOf: jest.fn().mockResolvedValue({ toString: () => balance }), + }) as unknown as Contract, + ); +} + +/** + * Configures the first Contract instantiation to respond to `.base()` with + * `MOCK_UNDERLYING_TOKEN_ADDRESS`. Used alongside `mockErc20BalanceOf` to + * stub the two-step flow inside `getMusdBalance`: the Accountant is + * instantiated first to resolve the underlying token address, then the ERC-20 + * is instantiated to read the balance. + */ +function mockAccountantBase(): void { + MockContract.mockImplementationOnce( + () => + ({ + base: jest.fn().mockResolvedValue(MOCK_UNDERLYING_TOKEN_ADDRESS), + }) as unknown as Contract, + ); +} + +/** + * Configures the Contract mock so that `getRate` resolves to an object + * whose `.toString()` returns `rate`. + * + * @param rate - The raw uint256 rate string to return. + */ +function mockAccountantGetRate(rate: string): void { + MockContract.mockImplementation( + () => + ({ + getRate: jest.fn().mockResolvedValue({ toString: () => rate }), + }) as unknown as Contract, + ); +} + +/** + * Configures the Contract mock so that `balanceOfInAssets` resolves to an + * object whose `.toString()` returns `balanceOfInAssets`. Used for + * `getMusdEquivalentValue`, which delegates the share-to-asset conversion to + * the Veda Lens contract. + * + * @param balanceOfInAssets - The raw uint256 asset balance string to return. + */ +function mockLensBalanceOfInAssets(balanceOfInAssets: string): void { + MockContract.mockImplementation( + () => + ({ + balanceOfInAssets: jest + .fn() + .mockResolvedValue({ toString: () => balanceOfInAssets }), + }) as unknown as Contract, + ); +} + +function makeMockBN(value: string): { + toString: () => string; + add: (other: { toString: () => string }) => { + toString: () => string; + add: (o: { toString: () => string }) => unknown; + }; +} { + return { + toString: () => value, + add: (other) => + makeMockBN((BigInt(value) + BigInt(other.toString())).toString()), + }; +} + +function mockMoneyAccountBalanceMulticall({ + musdBalance = '0', + vmusdValueInMusd = '0', + aggregate3, +}: { + musdBalance?: string; + vmusdValueInMusd?: string; + aggregate3?: jest.Mock; +} = {}): jest.Mock { + const MUSD_RETURN_DATA = '0xMUSD'; + const SHFVD_RETURN_DATA = '0xSHFVD'; + + const aggregate3Mock = + aggregate3 ?? + jest.fn().mockResolvedValue([ + { success: true, returnData: MUSD_RETURN_DATA }, + { success: true, returnData: SHFVD_RETURN_DATA }, + ]); + + const multicall3Address = + MULTICALL3_ADDRESS_BY_CHAIN_ID[MOCK_VAULT_CONFIG.chainId]; + + MockContract.mockImplementation( + (address: string) => + (address === multicall3Address + ? { callStatic: { aggregate3: aggregate3Mock } } + : { + base: jest.fn().mockResolvedValue(MOCK_UNDERLYING_TOKEN_ADDRESS), + interface: { + encodeFunctionData: jest.fn().mockReturnValue('0xcalldata'), + decodeFunctionResult: jest + .fn() + .mockImplementation((_functionFragment: string, data: string) => + data === MUSD_RETURN_DATA + ? [makeMockBN(musdBalance)] + : [makeMockBN(vmusdValueInMusd)], + ), + }, + }) as unknown as Contract, + ); + + return aggregate3Mock; +} + +function createTraceCallback(): jest.MockedFunction { + return jest + .fn() + .mockImplementation( + async ( + _request: TraceRequest, + fn?: (context?: TraceContext) => ReturnType, + ): Promise => { + if (!fn) { + return undefined as ReturnType; + } + return await Promise.resolve(fn()); + }, + ) as jest.MockedFunction; +} + +function expectTraceRequest( + traceCallback: jest.MockedFunction, + { + errorName, + name, + operation, + success = true, + tokenAddress, + }: { + errorName?: string; + name: string; + operation: string; + success?: boolean; + tokenAddress?: string; + }, +): void { + const traceRequest = traceCallback.mock.calls.find( + ([request]) => request.name === name, + )?.[0]; + + expect(traceRequest).toStrictEqual({ + name, + startTime: expect.any(Number), + data: { + chainId: MOCK_VAULT_CONFIG.chainId, + ...(errorName ? { errorName } : {}), + operation, + success, + ...(tokenAddress ? { tokenAddress } : {}), + }, + }); +} + +// ============================================================ +// Tests +// ============================================================ + +describe('MoneyAccountBalanceService', () => { + beforeEach(() => { + MockContract.mockReset(); + MockWeb3Provider.mockImplementation(() => ({}) as unknown as Web3Provider); + nockCleanAll(); + }); + + afterEach(() => { + jest.resetAllMocks(); + }); + + // ---------------------------------------------------------- + // init + // ---------------------------------------------------------- + + describe('init', () => { + it('loads vault config when RemoteFeatureFlagController already has valid flags', async () => { + mockAccountantBase(); + mockErc20BalanceOf('5000000'); + const { service } = createService(); + + // If vault config was loaded, getMusdBalance succeeds without throwing. + expect(await service.getMusdBalance(MOCK_ACCOUNT_ADDRESS)).toStrictEqual({ + balance: '5000000', + }); + }); + + it('leaves config undefined and degrades gracefully when flag key is absent', async () => { + const { service } = createService({ rffcFlags: {} }); + + await expect( + service.getMusdBalance(MOCK_ACCOUNT_ADDRESS), + ).rejects.toThrow(VaultConfigNotAvailableError); + }); + + it('leaves config undefined and degrades gracefully when the flag value is malformed', async () => { + const { service } = createService({ + rffcFlags: { + [VAULT_CONFIG_FEATURE_FLAG_KEY]: { notAValidConfig: true }, + }, + }); + + await expect( + service.getMusdBalance(MOCK_ACCOUNT_ADDRESS), + ).rejects.toThrow(VaultConfigNotAvailableError); + }); + + it('does not throw when RemoteFeatureFlagController is not yet registered', () => { + const rootMessenger = createRootMessenger(); + const messenger = createServiceMessenger(rootMessenger); + + // Do NOT register RemoteFeatureFlagController:getState or delegate it. + rootMessenger.registerActionHandler( + 'NetworkController:getNetworkConfigurationByChainId', + jest.fn(), + ); + rootMessenger.registerActionHandler( + 'NetworkController:getNetworkClientById', + jest.fn(), + ); + rootMessenger.delegate({ + actions: [ + 'NetworkController:getNetworkConfigurationByChainId', + 'NetworkController:getNetworkClientById', + ], + events: [], + messenger, + }); + + const service = new MoneyAccountBalanceService({ messenger }); + + expect(() => service.init()).not.toThrow(); + }); + }); + + // ---------------------------------------------------------- + // RemoteFeatureFlagController:stateChange subscription + // ---------------------------------------------------------- + + describe('RemoteFeatureFlagController:stateChange subscription', () => { + describe('config lifecycle', () => { + it('sets vault config when a valid config arrives via subscription', async () => { + mockAccountantBase(); + mockErc20BalanceOf('9000000'); + // Start with no flags so config is absent after init. + const { service, rootMessenger } = createService({ rffcFlags: {} }); + + publishRFFCStateChange(rootMessenger, { + [VAULT_CONFIG_FEATURE_FLAG_KEY]: MOCK_VAULT_CONFIG, + }); + + expect( + await service.getMusdBalance(MOCK_ACCOUNT_ADDRESS), + ).toStrictEqual({ balance: '9000000' }); + }); + + it('uses the updated vault address after config changes', async () => { + const NEW_VAULT_ADDRESS = + '0x9999999999999999999999999999999999999999' as const; + mockErc20BalanceOf('1000000'); + const { service, rootMessenger } = createService(); + + publishRFFCStateChange(rootMessenger, { + [VAULT_CONFIG_FEATURE_FLAG_KEY]: { + ...MOCK_VAULT_CONFIG, + boringVault: NEW_VAULT_ADDRESS, + }, + }); + + await service.getVmusdBalance(MOCK_ACCOUNT_ADDRESS); + + // getVmusdBalance uses vaultAddress — verify the new address was used. + expect(MockContract).toHaveBeenCalledWith( + NEW_VAULT_ADDRESS, + expect.anything(), + expect.anything(), + ); + expect(MockContract).not.toHaveBeenCalledWith( + MOCK_VAULT_ADDRESS, + expect.anything(), + expect.anything(), + ); + }); + + it('clears vault config when the flag key is removed from remoteFeatureFlags', async () => { + const { service, rootMessenger } = createService(); + + publishRFFCStateChange(rootMessenger, {}); + + await expect( + service.getMusdBalance(MOCK_ACCOUNT_ADDRESS), + ).rejects.toThrow(VaultConfigNotAvailableError); + }); + + it('clears vault config and routes VaultConfigValidationError when a malformed config arrives after valid config', async () => { + const captureException = jest.fn(); + const { service, rootMessenger } = createService({ captureException }); + + publishRFFCStateChange(rootMessenger, { + [VAULT_CONFIG_FEATURE_FLAG_KEY]: { malformed: true }, + }); + + // Messenger routes the thrown VaultConfigValidationError to captureException. + expect(captureException).toHaveBeenCalledWith( + expect.any(VaultConfigValidationError), + ); + + // Config has been cleared so subsequent calls throw VaultConfigNotAvailableError. + await expect( + service.getMusdBalance(MOCK_ACCOUNT_ADDRESS), + ).rejects.toThrow(VaultConfigNotAvailableError); + }); + + it('routes VaultConfigValidationError to captureException when malformed config arrives with no prior config', () => { + const captureException = jest.fn(); + const { rootMessenger } = createService({ + rffcFlags: {}, + captureException, + }); + + publishRFFCStateChange(rootMessenger, { + [VAULT_CONFIG_FEATURE_FLAG_KEY]: { malformed: true }, + }); + + expect(captureException).toHaveBeenCalledWith( + expect.any(VaultConfigValidationError), + ); + }); + }); + + describe('cache invalidation', () => { + it('does NOT invalidate queries when config is set for the first time via subscription', () => { + const { service, rootMessenger } = createService({ rffcFlags: {} }); + const invalidateQueriesSpy = jest.spyOn(service, 'invalidateQueries'); + + publishRFFCStateChange(rootMessenger, { + [VAULT_CONFIG_FEATURE_FLAG_KEY]: MOCK_VAULT_CONFIG, + }); + + expect(invalidateQueriesSpy).not.toHaveBeenCalled(); + }); + + it('invalidates queries when config changes to a different valid config', () => { + const { service, rootMessenger } = createService(); + const invalidateQueriesSpy = jest.spyOn(service, 'invalidateQueries'); + + const updatedConfig = { + ...MOCK_VAULT_CONFIG, + lensAddress: '0x7777777777777777777777777777777777777777' as const, + }; + publishRFFCStateChange(rootMessenger, { + [VAULT_CONFIG_FEATURE_FLAG_KEY]: updatedConfig, + }); + + expect(invalidateQueriesSpy).toHaveBeenCalledTimes(1); + }); + + it('does NOT invalidate queries when the same config arrives again', () => { + const { service, rootMessenger } = createService(); + const invalidateQueriesSpy = jest.spyOn(service, 'invalidateQueries'); + + publishRFFCStateChange(rootMessenger, { + [VAULT_CONFIG_FEATURE_FLAG_KEY]: { ...MOCK_VAULT_CONFIG }, + }); + + expect(invalidateQueriesSpy).not.toHaveBeenCalled(); + }); + + it('invalidates queries when the flag key is removed after valid config was set', () => { + const { service, rootMessenger } = createService(); + const invalidateQueriesSpy = jest.spyOn(service, 'invalidateQueries'); + + publishRFFCStateChange(rootMessenger, {}); + + expect(invalidateQueriesSpy).toHaveBeenCalledTimes(1); + }); + + it('does NOT invalidate queries when absent flag key arrives with no prior config', () => { + const { service, rootMessenger } = createService({ rffcFlags: {} }); + const invalidateQueriesSpy = jest.spyOn(service, 'invalidateQueries'); + + publishRFFCStateChange(rootMessenger, {}); + + expect(invalidateQueriesSpy).not.toHaveBeenCalled(); + }); + + it('invalidates queries when a malformed config arrives after valid config was set', () => { + const { service, rootMessenger } = createService(); + const invalidateQueriesSpy = jest.spyOn(service, 'invalidateQueries'); + + publishRFFCStateChange(rootMessenger, { + [VAULT_CONFIG_FEATURE_FLAG_KEY]: { malformed: true }, + }); + + expect(invalidateQueriesSpy).toHaveBeenCalledTimes(1); + }); + + it('does NOT invalidate queries when a malformed config arrives with no prior config', () => { + const { service, rootMessenger } = createService({ rffcFlags: {} }); + const invalidateQueriesSpy = jest.spyOn(service, 'invalidateQueries'); + + publishRFFCStateChange(rootMessenger, { + [VAULT_CONFIG_FEATURE_FLAG_KEY]: { malformed: true }, + }); + + expect(invalidateQueriesSpy).not.toHaveBeenCalled(); + }); + }); + }); + + // ---------------------------------------------------------- + // VaultConfigNotAvailableError — all public methods + // ---------------------------------------------------------- + + describe('when vault config is not available', () => { + it('getMoneyAccountBalance throws VaultConfigNotAvailableError', async () => { + const { service } = createService({ rffcFlags: {} }); + + await expect( + service.getMoneyAccountBalance(MOCK_ACCOUNT_ADDRESS), + ).rejects.toThrow(VaultConfigNotAvailableError); + }); + + it('getMusdBalance throws VaultConfigNotAvailableError', async () => { + const { service } = createService({ rffcFlags: {} }); + + await expect( + service.getMusdBalance(MOCK_ACCOUNT_ADDRESS), + ).rejects.toThrow(VaultConfigNotAvailableError); + }); + + it('getVmusdBalance throws VaultConfigNotAvailableError', async () => { + const { service } = createService({ rffcFlags: {} }); + + await expect( + service.getVmusdBalance(MOCK_ACCOUNT_ADDRESS), + ).rejects.toThrow(VaultConfigNotAvailableError); + }); + + it('getExchangeRate throws VaultConfigNotAvailableError', async () => { + const { service } = createService({ rffcFlags: {} }); + + await expect(service.getExchangeRate()).rejects.toThrow( + VaultConfigNotAvailableError, + ); + }); + + it('getMusdEquivalentValue throws VaultConfigNotAvailableError', async () => { + const { service } = createService({ rffcFlags: {} }); + + await expect( + service.getMusdEquivalentValue(MOCK_ACCOUNT_ADDRESS), + ).rejects.toThrow(VaultConfigNotAvailableError); + }); + + it('getVaultApy throws VaultConfigNotAvailableError', async () => { + const { service } = createService({ rffcFlags: {} }); + + await expect(service.getVaultApy()).rejects.toThrow( + VaultConfigNotAvailableError, + ); + }); + }); + + // ---------------------------------------------------------- + // tracing + // ---------------------------------------------------------- + + describe('tracing', () => { + it('traces getMusdBalance ERC-20 balance RPC on cache miss', async () => { + mockErc20BalanceOf('5000000'); + const trace = createTraceCallback(); + const { service } = createService({ + rffcFlags: { + [VAULT_CONFIG_FEATURE_FLAG_KEY]: + MOCK_VAULT_CONFIG_WITH_UNDERLYING_TOKEN, + }, + options: { trace }, + }); + + await service.getMusdBalance(MOCK_ACCOUNT_ADDRESS); + + expect(trace).toHaveBeenCalledTimes(1); + expectTraceRequest(trace, { + name: 'Get Money Account ERC20 Balance RPC', + operation: 'balanceOf', + tokenAddress: MOCK_UNDERLYING_TOKEN_ADDRESS, + }); + }); + + it('traces getMoneyAccountBalance Multicall3 RPC on cache miss', async () => { + mockMoneyAccountBalanceMulticall({ + musdBalance: '5000000', + vmusdValueInMusd: '2200000', + }); + const trace = createTraceCallback(); + const { service } = createService({ + rffcFlags: { + [VAULT_CONFIG_FEATURE_FLAG_KEY]: + MOCK_VAULT_CONFIG_WITH_UNDERLYING_TOKEN, + }, + options: { trace }, + }); + + await service.getMoneyAccountBalance(MOCK_ACCOUNT_ADDRESS); + + expect(trace).toHaveBeenCalledTimes(1); + expectTraceRequest(trace, { + name: 'Get Money Account Balance RPC', + operation: 'aggregate3', + }); + }); + + it('traces getVmusdBalance ERC-20 balance RPC on cache miss', async () => { + mockErc20BalanceOf('3000000'); + const trace = createTraceCallback(); + const { service } = createService({ options: { trace } }); + + await service.getVmusdBalance(MOCK_ACCOUNT_ADDRESS); + + expect(trace).toHaveBeenCalledTimes(1); + expectTraceRequest(trace, { + name: 'Get Money Account ERC20 Balance RPC', + operation: 'balanceOf', + tokenAddress: MOCK_VAULT_ADDRESS, + }); + }); + + it('traces getExchangeRate Accountant RPC on cache miss', async () => { + mockAccountantGetRate('1050000'); + const trace = createTraceCallback(); + const { service } = createService({ options: { trace } }); + + await service.getExchangeRate(); + + expect(trace).toHaveBeenCalledTimes(1); + expectTraceRequest(trace, { + name: 'Get Money Account Exchange Rate RPC', + operation: 'getRate', + }); + }); + + it('traces getMusdEquivalentValue Lens RPC on cache miss', async () => { + mockLensBalanceOfInAssets('1000000'); + const trace = createTraceCallback(); + const { service } = createService({ options: { trace } }); + + await service.getMusdEquivalentValue(MOCK_ACCOUNT_ADDRESS); + + expect(trace).toHaveBeenCalledTimes(1); + expectTraceRequest(trace, { + name: 'Get Money Account mUSD Equivalent Value RPC', + operation: 'balanceOfInAssets', + }); + }); + + it('traces getVaultApy Veda API fetch on cache miss', async () => { + nock('https://api.sevenseas.capital') + .get(`/performance/arbitrum/${MOCK_VAULT_ADDRESS}`) + .reply(200, MOCK_VAULT_APY_RAW_RESPONSE); + const trace = createTraceCallback(); + const { service } = createService({ options: { trace } }); + + await service.getVaultApy(); + + expect(trace).toHaveBeenCalledTimes(1); + expectTraceRequest(trace, { + name: 'Get Money Account Vault APY API', + operation: 'fetchVaultApy', + }); + }); + + it('traces fallback underlying token RPC when configured token is absent', async () => { + mockAccountantBase(); + mockErc20BalanceOf('5000000'); + const trace = createTraceCallback(); + const { service } = createService({ options: { trace } }); + + await service.getMusdBalance(MOCK_ACCOUNT_ADDRESS); + + expect(trace).toHaveBeenCalledTimes(2); + expectTraceRequest(trace, { + name: 'Get Money Account Underlying Token RPC', + operation: 'base', + }); + expectTraceRequest(trace, { + name: 'Get Money Account ERC20 Balance RPC', + operation: 'balanceOf', + tokenAddress: MOCK_UNDERLYING_TOKEN_ADDRESS, + }); + }); + + it('does not trace a cached query result', async () => { + mockAccountantGetRate('1050000'); + const trace = createTraceCallback(); + const { service } = createService({ options: { trace } }); + + await service.getExchangeRate(); + await service.getExchangeRate(); + + expect(trace).toHaveBeenCalledTimes(1); + expectTraceRequest(trace, { + name: 'Get Money Account Exchange Rate RPC', + operation: 'getRate', + }); + }); + + it('does not fail or refetch when the trace callback rejects', async () => { + const mockGetRate = jest + .fn() + .mockResolvedValue({ toString: () => '1050000' }); + MockContract.mockImplementation( + () => ({ getRate: mockGetRate }) as unknown as Contract, + ); + const trace = jest + .fn() + .mockRejectedValue( + new Error('trace boom'), + ) as jest.MockedFunction; + const { service } = createService({ options: { trace } }); + + expect(await service.getExchangeRate()).toStrictEqual({ + rate: '1050000', + }); + expect(await service.getExchangeRate()).toStrictEqual({ + rate: '1050000', + }); + + expect(mockGetRate).toHaveBeenCalledTimes(1); + expect(trace).toHaveBeenCalledTimes(1); + }); + + it('does not fail when the trace callback returns undefined', async () => { + mockAccountantGetRate('1050000'); + const trace = jest + .fn() + .mockReturnValue(undefined) as jest.MockedFunction; + const { service } = createService({ options: { trace } }); + + expect(await service.getExchangeRate()).toStrictEqual({ + rate: '1050000', + }); + + expect(trace).toHaveBeenCalledTimes(1); + expectTraceRequest(trace, { + name: 'Get Money Account Exchange Rate RPC', + operation: 'getRate', + }); + }); + + it('does not fail when the trace callback throws synchronously', async () => { + const mockGetRate = jest + .fn() + .mockResolvedValue({ toString: () => '1050000' }); + MockContract.mockImplementation( + () => ({ getRate: mockGetRate }) as unknown as Contract, + ); + const trace = jest.fn().mockImplementation(() => { + throw new Error('trace boom'); + }) as jest.MockedFunction; + const { service } = createService({ options: { trace } }); + + expect(await service.getExchangeRate()).toStrictEqual({ + rate: '1050000', + }); + expect(await service.getExchangeRate()).toStrictEqual({ + rate: '1050000', + }); + + expect(mockGetRate).toHaveBeenCalledTimes(1); + expect(trace).toHaveBeenCalledTimes(1); + }); + + it('propagates aggregate3 rejections after emitting a failed trace', async () => { + const aggregate3 = jest + .fn() + .mockRejectedValue(new Error('execution reverted')); + mockMoneyAccountBalanceMulticall({ aggregate3 }); + const trace = createTraceCallback(); + const { service } = createService({ + rffcFlags: { + [VAULT_CONFIG_FEATURE_FLAG_KEY]: + MOCK_VAULT_CONFIG_WITH_UNDERLYING_TOKEN, + }, + options: { trace }, + }); + + await expect( + service.getMoneyAccountBalance(MOCK_ACCOUNT_ADDRESS), + ).rejects.toThrow('execution reverted'); + expectTraceRequest(trace, { + errorName: 'Error', + name: 'Get Money Account Balance RPC', + operation: 'aggregate3', + success: false, + }); + }); + + it('traces non-Error aggregate3 rejections with the thrown value type', async () => { + const aggregate3 = jest.fn().mockRejectedValue('execution reverted'); + mockMoneyAccountBalanceMulticall({ aggregate3 }); + const trace = createTraceCallback(); + const { service } = createService({ + rffcFlags: { + [VAULT_CONFIG_FEATURE_FLAG_KEY]: + MOCK_VAULT_CONFIG_WITH_UNDERLYING_TOKEN, + }, + options: { trace }, + }); + + await expect( + service.getMoneyAccountBalance(MOCK_ACCOUNT_ADDRESS), + ).rejects.toBe('execution reverted'); + expectTraceRequest(trace, { + errorName: 'string', + name: 'Get Money Account Balance RPC', + operation: 'aggregate3', + success: false, + }); + }); + + it('propagates Veda API errors after emitting a failed trace', async () => { + nock('https://api.sevenseas.capital') + .get(`/performance/arbitrum/${MOCK_VAULT_ADDRESS}`) + .times(DEFAULT_MAX_RETRIES + 1) + .reply(500); + const trace = createTraceCallback(); + const { service } = createService({ options: { trace } }); + + await expect(service.getVaultApy()).rejects.toThrow( + new HttpError(500, "Veda performance API failed with status '500'"), + ); + expectTraceRequest(trace, { + errorName: 'Error', + name: 'Get Money Account Vault APY API', + operation: 'fetchVaultApy', + success: false, + }); + }); + }); + + // ---------------------------------------------------------- + // getMusdBalance + // ---------------------------------------------------------- + + describe('getMusdBalance', () => { + it('returns the mUSD balance for the given address', async () => { + mockAccountantBase(); + mockErc20BalanceOf('5000000'); + const { service } = createService(); + + const result = await service.getMusdBalance(MOCK_ACCOUNT_ADDRESS); + + expect(result).toStrictEqual({ balance: '5000000' }); + }); + + it('first calls base() on the Accountant to resolve the underlying token, then calls balanceOf on it', async () => { + mockAccountantBase(); + mockErc20BalanceOf('5000000'); + const { service } = createService(); + + await service.getMusdBalance(MOCK_ACCOUNT_ADDRESS); + + // Step 1: Accountant contract instantiated to fetch underlying token address. + expect(MockContract).toHaveBeenCalledWith( + MOCK_ACCOUNTANT_ADDRESS, + expect.anything(), + expect.anything(), + ); + // Step 2: ERC-20 contract instantiated with the resolved underlying token address. + expect(MockContract).toHaveBeenCalledWith( + MOCK_UNDERLYING_TOKEN_ADDRESS, + expect.anything(), + expect.anything(), + ); + }); + + it('uses the configured underlyingToken and skips the on-chain base() read when present', async () => { + // Only the ERC-20 contract is instantiated; no Accountant.base() call. + mockErc20BalanceOf('5000000'); + const { service } = createService({ + rffcFlags: { + [VAULT_CONFIG_FEATURE_FLAG_KEY]: { + ...MOCK_VAULT_CONFIG, + underlyingToken: MOCK_UNDERLYING_TOKEN_ADDRESS, + }, + }, + }); + + const result = await service.getMusdBalance(MOCK_ACCOUNT_ADDRESS); + + expect(result).toStrictEqual({ balance: '5000000' }); + // balanceOf is read directly on the configured underlying token... + expect(MockContract).toHaveBeenCalledWith( + MOCK_UNDERLYING_TOKEN_ADDRESS, + expect.anything(), + expect.anything(), + ); + // ...and the Accountant is never instantiated to resolve base(). + expect(MockContract).not.toHaveBeenCalledWith( + MOCK_ACCOUNTANT_ADDRESS, + expect.anything(), + expect.anything(), + ); + }); + + it('is also callable via the messenger action', async () => { + mockAccountantBase(); + mockErc20BalanceOf('5000000'); + const { rootMessenger } = createService(); + + const result = await rootMessenger.call( + 'MoneyAccountBalanceService:getMusdBalance', + MOCK_ACCOUNT_ADDRESS, + ); + + expect(result).toStrictEqual({ balance: '5000000' }); + }); + + it('throws if no network configuration is found for the vault chain', async () => { + const { service, mockGetNetworkConfig } = createService(); + mockGetNetworkConfig.mockReturnValue(undefined); + + await expect( + service.getMusdBalance(MOCK_ACCOUNT_ADDRESS), + ).rejects.toThrow('No network configuration found for chain 0xa4b1'); + }); + + it('throws if the network client has no provider', async () => { + const { service, mockGetNetworkClient } = createService(); + mockGetNetworkClient.mockReturnValue({ provider: null }); + + await expect( + service.getMusdBalance(MOCK_ACCOUNT_ADDRESS), + ).rejects.toThrow('No provider found for chain 0xa4b1'); + }); + + it('uses the network client at defaultRpcEndpointIndex, not always index 0', async () => { + mockAccountantBase(); + mockErc20BalanceOf('1000000'); + const { service, mockGetNetworkConfig, mockGetNetworkClient } = + createService(); + mockGetNetworkConfig.mockReturnValue({ + ...MOCK_NETWORK_CONFIG, + rpcEndpoints: [ + { + networkClientId: 'client-at-index-0', + url: 'https://rpc0.example.com', + }, + { + networkClientId: 'client-at-index-1', + url: 'https://rpc1.example.com', + }, + ], + defaultRpcEndpointIndex: 1, + }); + + await service.getMusdBalance(MOCK_ACCOUNT_ADDRESS); + + expect(mockGetNetworkClient).toHaveBeenCalledWith('client-at-index-1'); + expect(mockGetNetworkClient).not.toHaveBeenCalledWith( + 'client-at-index-0', + ); + }); + + it('reads the ERC-20 balance at the pending block tag', async () => { + const mockBalanceOf = jest + .fn() + .mockResolvedValue({ toString: () => '5000000' }); + MockContract.mockImplementation( + () => ({ balanceOf: mockBalanceOf }) as unknown as Contract, + ); + const { service } = createService({ + rffcFlags: { + [VAULT_CONFIG_FEATURE_FLAG_KEY]: { + ...MOCK_VAULT_CONFIG, + underlyingToken: MOCK_UNDERLYING_TOKEN_ADDRESS, + }, + }, + }); + + await service.getMusdBalance(MOCK_ACCOUNT_ADDRESS); + + expect(mockBalanceOf).toHaveBeenCalledWith(MOCK_ACCOUNT_ADDRESS, { + blockTag: 'pending', + }); + }); + }); + + // ---------------------------------------------------------- + // getVmusdBalance + // ---------------------------------------------------------- + + describe('getVmusdBalance', () => { + it('returns the vault share balance for the given address', async () => { + mockErc20BalanceOf('3000000'); + const { service } = createService(); + + const result = await service.getVmusdBalance(MOCK_ACCOUNT_ADDRESS); + + expect(result).toStrictEqual({ balance: '3000000' }); + }); + + it('calls balanceOf on the vault contract, not the underlying token', async () => { + mockErc20BalanceOf('3000000'); + const { service } = createService(); + + await service.getVmusdBalance(MOCK_ACCOUNT_ADDRESS); + + expect(MockContract).toHaveBeenCalledWith( + MOCK_VAULT_ADDRESS, + expect.anything(), + expect.anything(), + ); + expect(MockContract).not.toHaveBeenCalledWith( + MOCK_UNDERLYING_TOKEN_ADDRESS, + expect.anything(), + expect.anything(), + ); + }); + + it('throws if no network configuration is found for the vault chain', async () => { + const { service, mockGetNetworkConfig } = createService(); + mockGetNetworkConfig.mockReturnValue(undefined); + + await expect( + service.getVmusdBalance(MOCK_ACCOUNT_ADDRESS), + ).rejects.toThrow('No network configuration found for chain 0xa4b1'); + }); + + it('throws if the network client has no provider', async () => { + const { service, mockGetNetworkClient } = createService(); + mockGetNetworkClient.mockReturnValue({ provider: null }); + + await expect( + service.getVmusdBalance(MOCK_ACCOUNT_ADDRESS), + ).rejects.toThrow('No provider found for chain 0xa4b1'); + }); + }); + + // ---------------------------------------------------------- + // getExchangeRate + // ---------------------------------------------------------- + + describe('getExchangeRate', () => { + it('returns the exchange rate from the Accountant contract', async () => { + mockAccountantGetRate('1050000'); + const { service } = createService(); + + const result = await service.getExchangeRate(); + + expect(result).toStrictEqual({ rate: '1050000' }); + }); + + it('calls getRate on the accountant contract address', async () => { + mockAccountantGetRate('1050000'); + const { service } = createService(); + + await service.getExchangeRate(); + + expect(MockContract).toHaveBeenCalledWith( + MOCK_ACCOUNTANT_ADDRESS, + expect.anything(), + expect.anything(), + ); + }); + + it('throws if no network configuration is found for the vault chain', async () => { + const { service, mockGetNetworkConfig } = createService(); + mockGetNetworkConfig.mockReturnValue(undefined); + + await expect(service.getExchangeRate()).rejects.toThrow( + 'No network configuration found for chain 0xa4b1', + ); + }); + + it('throws if the network client has no provider', async () => { + const { service, mockGetNetworkClient } = createService(); + mockGetNetworkClient.mockReturnValue({ provider: null }); + + await expect(service.getExchangeRate()).rejects.toThrow( + 'No provider found for chain 0xa4b1', + ); + }); + + it('returns the cached rate when called without options within the default stale window', async () => { + const mockGetRate = jest + .fn() + .mockResolvedValue({ toString: () => '1050000' }); + MockContract.mockImplementation( + () => ({ getRate: mockGetRate }) as unknown as Contract, + ); + const { service } = createService(); + + // Seed the cache. + await service.getExchangeRate(); + + mockGetRate.mockResolvedValue({ toString: () => '1100000' }); + + // Second call should return the cached value. + const result = await service.getExchangeRate(); + + expect(result).toStrictEqual({ rate: '1050000' }); + expect(mockGetRate).toHaveBeenCalledTimes(1); + }); + + it('refetches when called with staleTime: 0 even if a cached value exists', async () => { + const mockGetRate = jest + .fn() + .mockResolvedValue({ toString: () => '1050000' }); + MockContract.mockImplementation( + () => ({ getRate: mockGetRate }) as unknown as Contract, + ); + const { service } = createService(); + + // Seed the cache. + const firstResult = await service.getExchangeRate(); + expect(firstResult).toStrictEqual({ rate: '1050000' }); + + mockGetRate.mockResolvedValue({ toString: () => '1100000' }); + + // Refetch using staleTime: 0. + const freshResult = await service.getExchangeRate({ staleTime: 0 }); + + expect(freshResult).toStrictEqual({ rate: '1100000' }); + expect(mockGetRate).toHaveBeenCalledTimes(2); + }); + }); + + // ---------------------------------------------------------- + // getMusdEquivalentValue + // ---------------------------------------------------------- + + describe('getMusdEquivalentValue', () => { + it('returns balanceOfInAssets from the Veda Lens contract', async () => { + mockLensBalanceOfInAssets('2200000'); + + const { service } = createService(); + + const result = await service.getMusdEquivalentValue(MOCK_ACCOUNT_ADDRESS); + + expect(result).toStrictEqual({ balanceOfInAssets: '2200000' }); + }); + + it('returns zero balanceOfInAssets when the account holds no vault shares', async () => { + mockLensBalanceOfInAssets('0'); + + const { service } = createService(); + + const result = await service.getMusdEquivalentValue(MOCK_ACCOUNT_ADDRESS); + + expect(result).toStrictEqual({ balanceOfInAssets: '0' }); + }); + + it('instantiates the Lens contract with lensAddress and calls balanceOfInAssets with (accountAddress, boringVault, accountantAddress)', async () => { + const mockBalanceOfInAssets = jest + .fn() + .mockResolvedValue({ toString: () => '1000000' }); + MockContract.mockImplementation( + () => + ({ + balanceOfInAssets: mockBalanceOfInAssets, + }) as unknown as Contract, + ); + + const { service } = createService(); + + await service.getMusdEquivalentValue(MOCK_ACCOUNT_ADDRESS); + + expect(MockContract).toHaveBeenCalledWith( + MOCK_LENS_ADDRESS, + expect.anything(), + expect.anything(), + ); + expect(mockBalanceOfInAssets).toHaveBeenCalledWith( + MOCK_ACCOUNT_ADDRESS, + MOCK_VAULT_ADDRESS, + MOCK_ACCOUNTANT_ADDRESS, + { blockTag: 'pending' }, + ); + }); + + it('throws if no network configuration is found for the vault chain', async () => { + const { service, mockGetNetworkConfig } = createService(); + mockGetNetworkConfig.mockReturnValue(undefined); + + await expect( + service.getMusdEquivalentValue(MOCK_ACCOUNT_ADDRESS), + ).rejects.toThrow('No network configuration found for chain 0xa4b1'); + }); + + it('throws if the network client has no provider', async () => { + const { service, mockGetNetworkClient } = createService(); + mockGetNetworkClient.mockReturnValue({ provider: null }); + + await expect( + service.getMusdEquivalentValue(MOCK_ACCOUNT_ADDRESS), + ).rejects.toThrow('No provider found for chain 0xa4b1'); + }); + }); + + // ---------------------------------------------------------- + // getMoneyAccountBalance + // ---------------------------------------------------------- + + describe('getMoneyAccountBalance', () => { + it('returns musdBalance, vmusdValueInMusd, and totalBalance from a single aggregate3 call', async () => { + const aggregate3 = mockMoneyAccountBalanceMulticall({ + musdBalance: '5000000', + vmusdValueInMusd: '2200000', + }); + const { service } = createService({ + rffcFlags: { + [VAULT_CONFIG_FEATURE_FLAG_KEY]: + MOCK_VAULT_CONFIG_WITH_UNDERLYING_TOKEN, + }, + }); + + const result = await service.getMoneyAccountBalance(MOCK_ACCOUNT_ADDRESS); + + expect(result).toStrictEqual({ + musdBalance: '5000000', + vmusdValueInMusd: '2200000', + totalBalance: '7200000', + }); + expect(aggregate3).toHaveBeenCalledTimes(1); + }); + + it('exercises real ABI encode/decode through the multicall path', async () => { + const { Contract: RealContract } = jest.requireActual< + typeof import('@ethersproject/contracts') + >('@ethersproject/contracts'); + const erc20Iface = new RealContract( + MOCK_UNDERLYING_TOKEN_ADDRESS, + abiERC20, + ).interface; + const lensIface = new RealContract(MOCK_LENS_ADDRESS, LENS_ABI).interface; + + const musdReturnData = erc20Iface.encodeFunctionResult('balanceOf', [ + '5000000', + ]); + const vmusdReturnData = lensIface.encodeFunctionResult( + 'balanceOfInAssets', + ['2200000'], + ); + + const aggregate3Mock = jest.fn().mockResolvedValue([ + { success: true, returnData: musdReturnData }, + { success: true, returnData: vmusdReturnData }, + ]); + + const multicall3Address = + MULTICALL3_ADDRESS_BY_CHAIN_ID[MOCK_VAULT_CONFIG.chainId]; + + MockContract.mockImplementation( + (address, abi) => + (address === multicall3Address + ? { callStatic: { aggregate3: aggregate3Mock } } + : new RealContract(address, abi)) as unknown as Contract, + ); + + const { service } = createService({ + rffcFlags: { + [VAULT_CONFIG_FEATURE_FLAG_KEY]: + MOCK_VAULT_CONFIG_WITH_UNDERLYING_TOKEN, + }, + }); + + const result = await service.getMoneyAccountBalance(MOCK_ACCOUNT_ADDRESS); + + expect(result).toStrictEqual({ + musdBalance: '5000000', + vmusdValueInMusd: '2200000', + totalBalance: '7200000', + }); + + const [[calls]] = aggregate3Mock.mock.calls; + expect(calls).toHaveLength(2); + expect(calls[0].callData).toBe( + erc20Iface.encodeFunctionData('balanceOf', [MOCK_ACCOUNT_ADDRESS]), + ); + expect(calls[1].callData).toBe( + lensIface.encodeFunctionData('balanceOfInAssets', [ + MOCK_ACCOUNT_ADDRESS, + MOCK_VAULT_ADDRESS, + MOCK_ACCOUNTANT_ADDRESS, + ]), + ); + }); + + it('batches the mUSD and Lens reads into one aggregate3 request with allowFailure disabled', async () => { + const aggregate3 = mockMoneyAccountBalanceMulticall(); + const { service } = createService({ + rffcFlags: { + [VAULT_CONFIG_FEATURE_FLAG_KEY]: + MOCK_VAULT_CONFIG_WITH_UNDERLYING_TOKEN, + }, + }); + + await service.getMoneyAccountBalance(MOCK_ACCOUNT_ADDRESS); + + // A single batched request containing exactly the two balance reads, + // read at the pending block tag. + expect(aggregate3).toHaveBeenCalledWith( + [ + expect.objectContaining({ + target: MOCK_UNDERLYING_TOKEN_ADDRESS, + allowFailure: false, + }), + expect.objectContaining({ + target: MOCK_LENS_ADDRESS, + allowFailure: false, + }), + ], + { blockTag: 'pending' }, + ); + // The Multicall3 contract is instantiated at the canonical address. + expect(MockContract).toHaveBeenCalledWith( + MULTICALL3_ADDRESS_BY_CHAIN_ID[MOCK_VAULT_CONFIG.chainId], + expect.anything(), + expect.anything(), + ); + }); + + it('falls back to an on-chain base() read when underlyingToken is absent from config', async () => { + const aggregate3 = mockMoneyAccountBalanceMulticall({ + musdBalance: '7', + vmusdValueInMusd: '3', + }); + // MOCK_VAULT_CONFIG has no underlyingToken. + const { service } = createService(); + + const result = await service.getMoneyAccountBalance(MOCK_ACCOUNT_ADDRESS); + + expect(result).toStrictEqual({ + musdBalance: '7', + vmusdValueInMusd: '3', + totalBalance: '10', + }); + // Accountant is instantiated for the base() fallback... + expect(MockContract).toHaveBeenCalledWith( + MOCK_ACCOUNTANT_ADDRESS, + expect.anything(), + expect.anything(), + ); + // ...and the resolved underlying token is used as the mUSD read target. + expect(aggregate3).toHaveBeenCalledWith( + [ + expect.objectContaining({ target: MOCK_UNDERLYING_TOKEN_ADDRESS }), + expect.objectContaining({ target: MOCK_LENS_ADDRESS }), + ], + { blockTag: 'pending' }, + ); + }); + + it('is also callable via the messenger action', async () => { + mockMoneyAccountBalanceMulticall({ + musdBalance: '5000000', + vmusdValueInMusd: '2200000', + }); + const { rootMessenger } = createService({ + rffcFlags: { + [VAULT_CONFIG_FEATURE_FLAG_KEY]: + MOCK_VAULT_CONFIG_WITH_UNDERLYING_TOKEN, + }, + }); + + const result = await rootMessenger.call( + 'MoneyAccountBalanceService:getMoneyAccountBalance', + MOCK_ACCOUNT_ADDRESS, + ); + + expect(result).toStrictEqual({ + musdBalance: '5000000', + vmusdValueInMusd: '2200000', + totalBalance: '7200000', + }); + }); + + it('rejects without reporting a partial balance when the aggregate3 multicall reverts', async () => { + const aggregate3 = jest + .fn() + .mockRejectedValue(new Error('execution reverted')); + mockMoneyAccountBalanceMulticall({ aggregate3 }); + const { service } = createService({ + rffcFlags: { + [VAULT_CONFIG_FEATURE_FLAG_KEY]: + MOCK_VAULT_CONFIG_WITH_UNDERLYING_TOKEN, + }, + }); + + await expect( + service.getMoneyAccountBalance(MOCK_ACCOUNT_ADDRESS), + ).rejects.toThrow('execution reverted'); + }); + + it('throws when no Multicall3 address is configured for the vault chain', async () => { + mockMoneyAccountBalanceMulticall(); + const { service } = createService({ + rffcFlags: { + [VAULT_CONFIG_FEATURE_FLAG_KEY]: { + ...MOCK_VAULT_CONFIG_WITH_UNDERLYING_TOKEN, + chainId: '0x1', + }, + }, + }); + + await expect( + service.getMoneyAccountBalance(MOCK_ACCOUNT_ADDRESS), + ).rejects.toThrow('No Multicall3 address configured for chain 0x1'); + }); + }); + + // ---------------------------------------------------------- + // fetchBalanceWithFallback + // ---------------------------------------------------------- + + describe('fetchBalanceWithFallback', () => { + const MOCK_API_BALANCE = { + musd_balance: '2', + vmusd_value_in_musd: '1513527', + total_balance: '1513529', + }; + + const MOCK_API_POSITIONS = { + address: MOCK_ACCOUNT_ADDRESS, + as_of_block: 88976660, + as_of_timestamp: '2026-07-20T10:49:51Z', + data_freshness: 'live' as const, + indexer_lag_seconds: 3, + balance: MOCK_API_BALANCE, + positions: [], + }; + + const apiPrimaryFlags = { + [VAULT_CONFIG_FEATURE_FLAG_KEY]: MOCK_VAULT_CONFIG_WITH_UNDERLYING_TOKEN, + [MONEY_ACCOUNT_BALANCE_SOURCE_FEATURE_FLAG_KEY]: 'api', + }; + + it('returns RPC balance by default (RPC primary) without using fallback', async () => { + mockMoneyAccountBalanceMulticall({ + musdBalance: '5000000', + vmusdValueInMusd: '2200000', + }); + const mockFetchPositions = jest.fn(); + const { service } = createService({ + rffcFlags: { + [VAULT_CONFIG_FEATURE_FLAG_KEY]: + MOCK_VAULT_CONFIG_WITH_UNDERLYING_TOKEN, + }, + mockFetchPositions, + }); + + const result = + await service.fetchBalanceWithFallback(MOCK_ACCOUNT_ADDRESS); + + expect(result).toStrictEqual({ + musdBalance: '5000000', + vmusdValueInMusd: '2200000', + totalBalance: '7200000', + source: 'rpc', + usedFallback: false, + }); + expect(mockFetchPositions).not.toHaveBeenCalled(); + }); + + it('returns API balance when the flag is set to api', async () => { + const mockFetchPositions = jest + .fn() + .mockResolvedValue(MOCK_API_POSITIONS); + const { service } = createService({ + rffcFlags: apiPrimaryFlags, + mockFetchPositions, + }); + + const result = + await service.fetchBalanceWithFallback(MOCK_ACCOUNT_ADDRESS); + + expect(result).toStrictEqual({ + musdBalance: '2', + vmusdValueInMusd: '1513527', + totalBalance: '1513529', + source: 'api', + usedFallback: false, + }); + expect(mockFetchPositions).toHaveBeenCalledWith(MOCK_ACCOUNT_ADDRESS); + }); + + it('falls back to RPC when API balance is null', async () => { + mockMoneyAccountBalanceMulticall({ + musdBalance: '5000000', + vmusdValueInMusd: '2200000', + }); + const mockFetchPositions = jest.fn().mockResolvedValue({ + ...MOCK_API_POSITIONS, + balance: null, + }); + const captureException = jest.fn(); + const { service } = createService({ + rffcFlags: apiPrimaryFlags, + mockFetchPositions, + captureException, + }); + + const result = + await service.fetchBalanceWithFallback(MOCK_ACCOUNT_ADDRESS); + + expect(result).toStrictEqual({ + musdBalance: '5000000', + vmusdValueInMusd: '2200000', + totalBalance: '7200000', + source: 'rpc', + usedFallback: true, + }); + expect(captureException).toHaveBeenCalledWith( + expect.any(MoneyAccountBalanceUnavailableError), + ); + }); + + it('falls back to RPC when API balance is omitted', async () => { + mockMoneyAccountBalanceMulticall({ + musdBalance: '9', + vmusdValueInMusd: '1', + }); + const { balance: _omittedBalance, ...positionsWithoutBalance } = + MOCK_API_POSITIONS; + const mockFetchPositions = jest + .fn() + .mockResolvedValue(positionsWithoutBalance); + const captureException = jest.fn(); + const { service } = createService({ + rffcFlags: apiPrimaryFlags, + mockFetchPositions, + captureException, + }); + + const result = + await service.fetchBalanceWithFallback(MOCK_ACCOUNT_ADDRESS); + + expect(result).toStrictEqual({ + musdBalance: '9', + vmusdValueInMusd: '1', + totalBalance: '10', + source: 'rpc', + usedFallback: true, + }); + expect(captureException).toHaveBeenCalledWith( + expect.any(MoneyAccountBalanceUnavailableError), + ); + }); + + it('falls back to RPC when the API call fails', async () => { + mockMoneyAccountBalanceMulticall({ + musdBalance: '7', + vmusdValueInMusd: '3', + }); + const mockFetchPositions = jest + .fn() + .mockRejectedValue(new Error('network down')); + const captureException = jest.fn(); + const { service } = createService({ + rffcFlags: apiPrimaryFlags, + mockFetchPositions, + captureException, + }); + + const result = + await service.fetchBalanceWithFallback(MOCK_ACCOUNT_ADDRESS); + + expect(result).toStrictEqual({ + musdBalance: '7', + vmusdValueInMusd: '3', + totalBalance: '10', + source: 'rpc', + usedFallback: true, + }); + expect(captureException).not.toHaveBeenCalled(); + }); + + it('falls back to RPC when API balance fails semantic validation', async () => { + mockMoneyAccountBalanceMulticall({ + musdBalance: '1', + vmusdValueInMusd: '2', + }); + const mockFetchPositions = jest.fn().mockResolvedValue({ + ...MOCK_API_POSITIONS, + balance: { + musd_balance: '1', + vmusd_value_in_musd: '2', + total_balance: '999', + }, + }); + const captureException = jest.fn(); + const { service } = createService({ + rffcFlags: apiPrimaryFlags, + mockFetchPositions, + captureException, + }); + + const result = + await service.fetchBalanceWithFallback(MOCK_ACCOUNT_ADDRESS); + + expect(result).toStrictEqual({ + musdBalance: '1', + vmusdValueInMusd: '2', + totalBalance: '3', + source: 'rpc', + usedFallback: true, + }); + expect(captureException).toHaveBeenCalledWith( + expect.any(MoneyAccountBalanceValidationError), + ); + }); + + it('falls back to RPC when API balance contains a non-integer amount', async () => { + mockMoneyAccountBalanceMulticall({ + musdBalance: '5', + vmusdValueInMusd: '5', + }); + const mockFetchPositions = jest.fn().mockResolvedValue({ + ...MOCK_API_POSITIONS, + balance: { + musd_balance: '1.5', + vmusd_value_in_musd: '2', + total_balance: '3.5', + }, + }); + const captureException = jest.fn(); + const { service } = createService({ + rffcFlags: apiPrimaryFlags, + mockFetchPositions, + captureException, + }); + + const result = + await service.fetchBalanceWithFallback(MOCK_ACCOUNT_ADDRESS); + + expect(result).toStrictEqual({ + musdBalance: '5', + vmusdValueInMusd: '5', + totalBalance: '10', + source: 'rpc', + usedFallback: true, + }); + expect(captureException).toHaveBeenCalledWith( + expect.any(MoneyAccountBalanceValidationError), + ); + }); + + it('falls back to API when RPC primary fails', async () => { + const mockFetchPositions = jest + .fn() + .mockResolvedValue(MOCK_API_POSITIONS); + const { service } = createService({ + rffcFlags: { + [VAULT_CONFIG_FEATURE_FLAG_KEY]: + MOCK_VAULT_CONFIG_WITH_UNDERLYING_TOKEN, + }, + mockFetchPositions, + }); + + MockContract.mockImplementation( + () => + ({ + callStatic: { + aggregate3: jest + .fn() + .mockRejectedValue(new Error('execution reverted')), + }, + interface: { + encodeFunctionData: jest.fn().mockReturnValue('0x'), + decodeFunctionResult: jest.fn(), + }, + }) as unknown as Contract, + ); + + const result = + await service.fetchBalanceWithFallback(MOCK_ACCOUNT_ADDRESS); + + expect(result).toStrictEqual({ + musdBalance: '2', + vmusdValueInMusd: '1513527', + totalBalance: '1513529', + source: 'api', + usedFallback: true, + }); + }); + + it('does not fall back when the flag is api-only', async () => { + const mockFetchPositions = jest.fn().mockResolvedValue({ + ...MOCK_API_POSITIONS, + balance: null, + }); + const captureException = jest.fn(); + const { service } = createService({ + rffcFlags: { + [VAULT_CONFIG_FEATURE_FLAG_KEY]: MOCK_VAULT_CONFIG, + [MONEY_ACCOUNT_BALANCE_SOURCE_FEATURE_FLAG_KEY]: 'api-only', + }, + mockFetchPositions, + captureException, + }); + + let thrown: unknown; + try { + await service.fetchBalanceWithFallback(MOCK_ACCOUNT_ADDRESS); + } catch (error) { + thrown = error; + } + + expect(thrown).toBeInstanceOf(MoneyAccountBalanceFetchError); + expect((thrown as MoneyAccountBalanceFetchError).causes).toHaveLength(1); + expect( + (thrown as MoneyAccountBalanceFetchError).causes[0], + ).toBeInstanceOf(MoneyAccountBalanceUnavailableError); + expect(captureException).toHaveBeenCalledWith( + expect.any(MoneyAccountBalanceUnavailableError), + ); + }); + + it('does not fall back when the flag is rpc-only', async () => { + MockContract.mockImplementation( + () => + ({ + callStatic: { + aggregate3: jest + .fn() + .mockRejectedValue(new Error('execution reverted')), + }, + interface: { + encodeFunctionData: jest.fn().mockReturnValue('0x'), + decodeFunctionResult: jest.fn(), + }, + }) as unknown as Contract, + ); + const mockFetchPositions = jest + .fn() + .mockResolvedValue(MOCK_API_POSITIONS); + const { service } = createService({ + rffcFlags: { + [VAULT_CONFIG_FEATURE_FLAG_KEY]: + MOCK_VAULT_CONFIG_WITH_UNDERLYING_TOKEN, + [MONEY_ACCOUNT_BALANCE_SOURCE_FEATURE_FLAG_KEY]: 'rpc-only', + }, + mockFetchPositions, + }); + + await expect( + service.fetchBalanceWithFallback(MOCK_ACCOUNT_ADDRESS), + ).rejects.toThrow(MoneyAccountBalanceFetchError); + expect(mockFetchPositions).not.toHaveBeenCalled(); + }); + + it('throws MoneyAccountBalanceFetchError with both causes when primary and fallback fail', async () => { + MockContract.mockImplementation( + () => + ({ + callStatic: { + aggregate3: jest + .fn() + .mockRejectedValue(new Error('execution reverted')), + }, + interface: { + encodeFunctionData: jest.fn().mockReturnValue('0x'), + decodeFunctionResult: jest.fn(), + }, + }) as unknown as Contract, + ); + const mockFetchPositions = jest.fn().mockResolvedValue({ + ...MOCK_API_POSITIONS, + balance: null, + }); + const captureException = jest.fn(); + const { service } = createService({ + rffcFlags: { + [VAULT_CONFIG_FEATURE_FLAG_KEY]: + MOCK_VAULT_CONFIG_WITH_UNDERLYING_TOKEN, + }, + mockFetchPositions, + captureException, + }); + + let thrown: unknown; + try { + await service.fetchBalanceWithFallback(MOCK_ACCOUNT_ADDRESS); + } catch (error) { + thrown = error; + } + + expect(thrown).toBeInstanceOf(MoneyAccountBalanceFetchError); + const { causes } = thrown as MoneyAccountBalanceFetchError; + expect(causes).toHaveLength(2); + expect(causes[0]).toBeInstanceOf(Error); + expect((causes[0] as Error).message).toBe('execution reverted'); + expect(causes[1]).toBeInstanceOf(MoneyAccountBalanceUnavailableError); + expect(captureException).toHaveBeenCalledWith( + expect.any(MoneyAccountBalanceUnavailableError), + ); + }); + + it('defaults to rpc policy when the source flag is malformed', async () => { + mockMoneyAccountBalanceMulticall({ + musdBalance: '5000000', + vmusdValueInMusd: '2200000', + }); + const mockFetchPositions = jest.fn(); + const { service } = createService({ + rffcFlags: { + [VAULT_CONFIG_FEATURE_FLAG_KEY]: + MOCK_VAULT_CONFIG_WITH_UNDERLYING_TOKEN, + [MONEY_ACCOUNT_BALANCE_SOURCE_FEATURE_FLAG_KEY]: 'not-a-policy', + }, + mockFetchPositions, + }); + + const result = + await service.fetchBalanceWithFallback(MOCK_ACCOUNT_ADDRESS); + + expect(result.source).toBe('rpc'); + expect(result.usedFallback).toBe(false); + expect(mockFetchPositions).not.toHaveBeenCalled(); + }); + + it('updates the source policy on RemoteFeatureFlagController:stateChange', async () => { + mockMoneyAccountBalanceMulticall({ + musdBalance: '5000000', + vmusdValueInMusd: '2200000', + }); + const mockFetchPositions = jest + .fn() + .mockResolvedValue(MOCK_API_POSITIONS); + const { service, rootMessenger } = createService({ + rffcFlags: { + [VAULT_CONFIG_FEATURE_FLAG_KEY]: + MOCK_VAULT_CONFIG_WITH_UNDERLYING_TOKEN, + [MONEY_ACCOUNT_BALANCE_SOURCE_FEATURE_FLAG_KEY]: 'api', + }, + mockFetchPositions, + }); + + publishRFFCStateChange(rootMessenger, { + [VAULT_CONFIG_FEATURE_FLAG_KEY]: + MOCK_VAULT_CONFIG_WITH_UNDERLYING_TOKEN, + [MONEY_ACCOUNT_BALANCE_SOURCE_FEATURE_FLAG_KEY]: 'rpc-only', + }); + + const result = + await service.fetchBalanceWithFallback(MOCK_ACCOUNT_ADDRESS); + + expect(result.source).toBe('rpc'); + expect(mockFetchPositions).not.toHaveBeenCalled(); + }); + + it('is callable via messenger action', async () => { + mockMoneyAccountBalanceMulticall({ + musdBalance: '5000000', + vmusdValueInMusd: '2200000', + }); + const mockFetchPositions = jest.fn(); + const { rootMessenger, service } = createService({ + rffcFlags: { + [VAULT_CONFIG_FEATURE_FLAG_KEY]: + MOCK_VAULT_CONFIG_WITH_UNDERLYING_TOKEN, + }, + mockFetchPositions, + }); + + const result = await rootMessenger.call( + 'MoneyAccountBalanceService:fetchBalanceWithFallback', + MOCK_ACCOUNT_ADDRESS, + ); + + expect(result.source).toBe('rpc'); + service.destroy(); + }); + }); + + // ---------------------------------------------------------- + // getVaultApy + // ---------------------------------------------------------- + + describe('getVaultApy', () => { + it('returns the normalized vault APY from the Veda performance API', async () => { + nock('https://api.sevenseas.capital') + .get(`/performance/arbitrum/${MOCK_VAULT_ADDRESS}`) + .reply(200, MOCK_VAULT_APY_RAW_RESPONSE); + + const { service } = createService(); + + const result = await service.getVaultApy(); + + expect(result).toStrictEqual(MOCK_VAULT_APY_NORMALIZED); + }); + + it('throws HttpError on a non-200 response', async () => { + nock('https://api.sevenseas.capital') + .get(`/performance/arbitrum/${MOCK_VAULT_ADDRESS}`) + .times(DEFAULT_MAX_RETRIES + 1) + .reply(500); + + const { service } = createService(); + + await expect(service.getVaultApy()).rejects.toThrow( + new HttpError(500, "Veda performance API failed with status '500'"), + ); + }); + + it('throws VedaResponseValidationError on a malformed response body', async () => { + nock('https://api.sevenseas.capital') + .get(`/performance/arbitrum/${MOCK_VAULT_ADDRESS}`) + .reply(200, { unexpected: 'shape' }); + + const { service } = createService(); + + await expect(service.getVaultApy()).rejects.toThrow( + new VedaResponseValidationError( + 'Malformed response received from Veda performance API', + ), + ); + }); + + it.each([ + { description: 'missing Response key', body: {} }, + { + description: 'missing apy field', + body: { + Response: { ...MOCK_VAULT_APY_RAW_RESPONSE.Response, apy: undefined }, + }, + }, + { + description: 'apy is not a number', + body: { + Response: { ...MOCK_VAULT_APY_RAW_RESPONSE.Response, apy: 'high' }, + }, + }, + { + description: 'missing timestamp field', + body: { + Response: { + ...MOCK_VAULT_APY_RAW_RESPONSE.Response, + timestamp: undefined, + }, + }, + }, + ])( + 'throws VedaResponseValidationError when response is malformed: $description', + async ({ body }) => { + nock('https://api.sevenseas.capital') + .get(`/performance/arbitrum/${MOCK_VAULT_ADDRESS}`) + .reply(200, body); + + const { service } = createService(); + + await expect(service.getVaultApy()).rejects.toThrow( + VedaResponseValidationError, + ); + }, + ); + + it('accepts and normalizes a response with zero values and empty array breakdowns', async () => { + // All optional fields are present but carry zero / empty values — verifies + // that falsy values are not accidentally dropped during normalization. + const zeroValuesResponse = { + Response: { + aggregation_period: '7 days', + apy: 0, + chain_allocation: { arbitrum: 0 }, + fees: 0, + global_apy_breakdown: { fee: 0, maturity_apy: 0, real_apy: 0 }, + performance_fees: 0, + real_apy_breakdown: [], + timestamp: 'Fri, 10 Apr 2026 22:05:54 GMT', + }, + }; + + nock('https://api.sevenseas.capital') + .get(`/performance/arbitrum/${MOCK_VAULT_ADDRESS}`) + .reply(200, zeroValuesResponse); + + const { service } = createService(); + + const result = await service.getVaultApy(); + + expect(result.apy).toBe(0); + expect(result.fees).toBe(0); + expect(result.globalApyBreakdown).toStrictEqual({ + fee: 0, + maturityApy: 0, + realApy: 0, + }); + expect(result.timestamp).toBe('Fri, 10 Apr 2026 22:05:54 GMT'); + expect(result.realApyBreakdown).toStrictEqual([]); + }); + + it('accepts a response that omits all optional fields', async () => { + const minimalResponse = { + Response: { + apy: 0.03, + timestamp: '2026-01-01T00:00:00Z', + }, + }; + + nock('https://api.sevenseas.capital') + .get(`/performance/arbitrum/${MOCK_VAULT_ADDRESS}`) + .reply(200, minimalResponse); + + const { service } = createService(); + + const result = await service.getVaultApy(); + + expect(result).toStrictEqual({ + aggregationPeriod: undefined, + apy: 0.030453263600551006, + chainAllocation: undefined, + fees: undefined, + globalApyBreakdown: undefined, + performanceFees: undefined, + realApyBreakdown: undefined, + timestamp: '2026-01-01T00:00:00Z', + }); + }); + + it('does not retry on VedaResponseValidationError', async () => { + // Only one nock scope — if retry happened, the second call would throw a + // different error (nock "no match" instead of VedaResponseValidationError). + nock('https://api.sevenseas.capital') + .get(`/performance/arbitrum/${MOCK_VAULT_ADDRESS}`) + .once() + .reply(200, { unexpected: 'shape' }); + + const { service } = createService(); + + await expect(service.getVaultApy()).rejects.toThrow( + VedaResponseValidationError, + ); + }); + + it('throws when the vault chain ID has no Veda API network name mapping', async () => { + const { service } = createService({ + rffcFlags: { + [VAULT_CONFIG_FEATURE_FLAG_KEY]: { + ...MOCK_VAULT_CONFIG, + chainId: '0x1', + }, + }, + }); + + await expect(service.getVaultApy()).rejects.toThrow( + 'No Veda API network name found for chain 0x1', + ); + }); + }); + + // ---------------------------------------------------------- + // Balance staleTime feature flag + // ---------------------------------------------------------- + + describe('balance staleTime feature flag', () => { + /** + * Stubs the Accountant so `getRate` invocations are observable across + * calls. `getExchangeRate` is used as the probe because its staleTime + * defaults to the configurable balance staleTime. + * + * @returns The `getRate` mock. + */ + function mockAccountantGetRateSpy(): jest.Mock { + const mockGetRate = jest + .fn() + .mockResolvedValue({ toString: () => '1050000' }); + MockContract.mockImplementation( + () => ({ getRate: mockGetRate }) as unknown as Contract, + ); + return mockGetRate; + } + + it('applies a valid staleTime override read from the flag during init', async () => { + const mockGetRate = mockAccountantGetRateSpy(); + // staleTime 0 disables caching, so each call performs a fresh read. + const { service } = createService({ + rffcFlags: { + [VAULT_CONFIG_FEATURE_FLAG_KEY]: MOCK_VAULT_CONFIG, + [MONEY_ACCOUNT_BALANCE_STALETIME_FEATURE_FLAG_KEY]: 0, + }, + }); + + await service.getExchangeRate(); + await service.getExchangeRate(); + + expect(mockGetRate).toHaveBeenCalledTimes(2); + }); + + it('applies a staleTime override that arrives via stateChange', async () => { + const mockGetRate = mockAccountantGetRateSpy(); + const { service, rootMessenger } = createService(); + + // Default 60s window → the second call is served from cache. + await service.getExchangeRate(); + await service.getExchangeRate(); + expect(mockGetRate).toHaveBeenCalledTimes(1); + + // Lower staleTime to 0 remotely → caching is disabled for later calls. + publishRFFCStateChange(rootMessenger, { + [VAULT_CONFIG_FEATURE_FLAG_KEY]: MOCK_VAULT_CONFIG, + [MONEY_ACCOUNT_BALANCE_STALETIME_FEATURE_FLAG_KEY]: 0, + }); + + await service.getExchangeRate(); + expect(mockGetRate).toHaveBeenCalledTimes(2); + }); + + it.each([ + { description: 'a non-number', value: 'soon' }, + { description: 'NaN', value: NaN }, + { description: 'a negative number', value: -1 }, + ])( + 'falls back to the default staleTime when the flag is $description', + async ({ value }) => { + const mockGetRate = mockAccountantGetRateSpy(); + const { service } = createService({ + rffcFlags: { + [VAULT_CONFIG_FEATURE_FLAG_KEY]: MOCK_VAULT_CONFIG, + [MONEY_ACCOUNT_BALANCE_STALETIME_FEATURE_FLAG_KEY]: value, + }, + }); + + // Default (non-zero) window applies → the second call is cached. + await service.getExchangeRate(); + await service.getExchangeRate(); + + expect(mockGetRate).toHaveBeenCalledTimes(1); + }, + ); + + it('applies staleTime even when the vault config flag is malformed (orchestrator isolation)', async () => { + // This test verifies that when #onRemoteFeatureFlagChange (the orchestrator) + // receives a stateChange with both flags, it processes BOTH — even when + // #applyVaultConfig throws. #applyBalanceStaleTimeFlag runs first and is + // never blocked by a vault config error. + const captureException = jest.fn(); + const mockGetRate = mockAccountantGetRateSpy(); + const { service, rootMessenger } = createService({ + rffcFlags: {}, + captureException, + }); + + // stateChange carries staleTime=0 AND a malformed vault config. The + // orchestrator calls #applyBalanceStaleTimeFlag first (→ staleTime=0), + // then #applyVaultConfig which throws. The messenger routes the throw to + // captureException so the subscriber does not crash. + publishRFFCStateChange(rootMessenger, { + [VAULT_CONFIG_FEATURE_FLAG_KEY]: { malformed: true }, + [MONEY_ACCOUNT_BALANCE_STALETIME_FEATURE_FLAG_KEY]: 0, + }); + + expect(captureException).toHaveBeenCalledWith( + expect.any(VaultConfigValidationError), + ); + + // Restore a valid vault config. The orchestrator now also re-applies + // staleTime for this event (no flag present → resets to default), but the + // key assertion above already confirms the orchestrator processed both + // flags on the previous event (captureException was called, which means + // #applyVaultConfig was reached, meaning #applyBalanceStaleTimeFlag ran + // first as intended). + publishRFFCStateChange(rootMessenger, { + [VAULT_CONFIG_FEATURE_FLAG_KEY]: MOCK_VAULT_CONFIG, + [MONEY_ACCOUNT_BALANCE_STALETIME_FEATURE_FLAG_KEY]: 0, + }); + + // Cache is bypassed on every call since staleTime=0 is active. + await service.getExchangeRate(); + await service.getExchangeRate(); + expect(mockGetRate).toHaveBeenCalledTimes(2); + }); + }); +}); + +// ============================================================ +// Error class unit tests +// ============================================================ + +describe('VaultConfigNotAvailableError', () => { + it('has the expected message and name', () => { + const error = new VaultConfigNotAvailableError(); + + expect(error.message).toBe( + 'MoneyAccountBalanceService: vault config is not available. ' + + 'RemoteFeatureFlagController may not have fetched flags yet.', + ); + expect(error.name).toBe('VaultConfigNotAvailableError'); + }); +}); + +describe('MoneyAccountBalanceUnavailableError', () => { + it('has the expected name', () => { + const error = new MoneyAccountBalanceUnavailableError('missing'); + expect(error.message).toBe('missing'); + expect(error.name).toBe('MoneyAccountBalanceUnavailableError'); + }); +}); + +describe('MoneyAccountBalanceValidationError', () => { + it('has the expected name', () => { + const error = new MoneyAccountBalanceValidationError('bad total'); + expect(error.message).toBe('bad total'); + expect(error.name).toBe('MoneyAccountBalanceValidationError'); + }); +}); + +describe('MoneyAccountBalanceFetchError', () => { + it('preserves causes', () => { + const causes = [new Error('a'), new Error('b')]; + const error = new MoneyAccountBalanceFetchError(causes); + expect(error.name).toBe('MoneyAccountBalanceFetchError'); + expect(error.causes).toBe(causes); + }); +}); + +describe('VaultConfigValidationError', () => { + it('uses the default message when constructed with no argument', () => { + const error = new VaultConfigValidationError(); + + expect(error.message).toBe( + 'MoneyAccountBalanceService: vault config from remote feature flags is malformed.', + ); + expect(error.name).toBe('VaultConfigValidationError'); + }); + + it('uses a custom message when one is provided', () => { + const error = new VaultConfigValidationError('custom message'); + + expect(error.message).toBe('custom message'); + expect(error.name).toBe('VaultConfigValidationError'); + }); +}); + +describe('VedaResponseValidationError', () => { + it('uses the default message when constructed with no argument', () => { + const error = new VedaResponseValidationError(); + + expect(error.message).toBe('Malformed response received from Veda API'); + expect(error.name).toBe('VedaResponseValidationError'); + }); +}); diff --git a/packages/money-account-balance-service/src/money-account-balance-service.ts b/packages/money-account-balance-service/src/money-account-balance-service.ts new file mode 100644 index 00000000000..33f1cb70e75 --- /dev/null +++ b/packages/money-account-balance-service/src/money-account-balance-service.ts @@ -0,0 +1,1159 @@ +import { Contract } from '@ethersproject/contracts'; +import { Web3Provider } from '@ethersproject/providers'; +import type { + DataServiceCacheUpdatedEvent, + DataServiceGranularCacheUpdatedEvent, + DataServiceInvalidateQueriesAction, +} from '@metamask/base-data-service'; +import { BaseDataService } from '@metamask/base-data-service'; +import type { + CreateServicePolicyOptions, + TraceContext, + TraceRequest, +} from '@metamask/controller-utils'; +import { handleWhen, HttpError } from '@metamask/controller-utils'; +import type { Messenger } from '@metamask/messenger'; +import { abiERC20 } from '@metamask/metamask-eth-abis'; +import type { + MoneyAccountApiDataServiceFetchPositionsAction, + PositionResponse, +} from '@metamask/money-account-api-data-service'; +import type { + NetworkControllerGetNetworkClientByIdAction, + NetworkControllerGetNetworkConfigurationByChainIdAction, +} from '@metamask/network-controller'; +import type { + RemoteFeatureFlagControllerGetStateAction, + RemoteFeatureFlagControllerStateChangeEvent, +} from '@metamask/remote-feature-flag-controller'; +import { assert, is } from '@metamask/superstruct'; +import type { Hex, Json } from '@metamask/utils'; +import { Duration, inMilliseconds } from '@metamask/utils'; + +import { + ACCOUNTANT_ABI, + BALANCE_SOURCE_POLICIES, + DEFAULT_BALANCE_SOURCE_POLICY, + DEFAULT_BALANCE_STALE_TIME, + LENS_ABI, + MONEY_ACCOUNT_BALANCE_SOURCE_FEATURE_FLAG_KEY, + MONEY_ACCOUNT_BALANCE_STALETIME_FEATURE_FLAG_KEY, + MULTICALL3_ABI, + MULTICALL3_ADDRESS_BY_CHAIN_ID, + VAULT_CONFIG_FEATURE_FLAG_KEY, + VEDA_API_NETWORK_NAMES, + VEDA_PERFORMANCE_API_BASE_URL, +} from './constants.js'; +import type { BalanceSource, BalanceSourcePolicy } from './constants.js'; +import { + MoneyAccountBalanceFetchError, + MoneyAccountBalanceUnavailableError, + MoneyAccountBalanceValidationError, + VaultConfigNotAvailableError, + VaultConfigValidationError, + VedaResponseValidationError, +} from './errors.js'; +import { projectLogger, createModuleLogger } from './logger.js'; +import type { MoneyAccountBalanceServiceMethodActions } from './money-account-balance-service-method-action-types.js'; +import { normalizeVaultApyResponse } from './requestNormalization.js'; +import type { + CanonicalMoneyAccountBalanceResponse, + ExchangeRateResponse, + MoneyAccountBalanceResponse, + MusdEquivalentValueResponse, + NormalizedVaultApyResponse, +} from './response.types'; +import { VaultApyRawResponseStruct, VaultConfigStruct } from './structs.js'; +import type { VaultConfig } from './types.js'; + +// === GENERAL === + +/** + * The shape of a single result entry returned by Multicall3's `aggregate3`. + * Mirrors the on-chain `struct Multicall3.Result`. + */ +type Multicall3Result = { + success: boolean; + returnData: string; +}; + +/** + * ethers `CallOverrides` used for BALANCE reads (mUSD, vmUSD, Lens). + * + * We deliberately read at `pending` rather than `latest` to bypass the provider's block cache middleware. + */ +const PENDING_READ_OVERRIDES = { blockTag: 'pending' } as const; + +/** + * The name of the {@link MoneyAccountBalanceService}, used to namespace the + * service's actions and events. + */ +export const serviceName = 'MoneyAccountBalanceService'; + +export const TRACES = { + ERC20_BALANCE_RPC: 'Get Money Account ERC20 Balance RPC', + UNDERLYING_TOKEN_RPC: 'Get Money Account Underlying Token RPC', + MONEY_ACCOUNT_BALANCE_RPC: 'Get Money Account Balance RPC', + EXCHANGE_RATE_RPC: 'Get Money Account Exchange Rate RPC', + MUSD_EQUIVALENT_VALUE_RPC: 'Get Money Account mUSD Equivalent Value RPC', + VAULT_APY_API: 'Get Money Account Vault APY API', +} as const; + +export type MoneyAccountBalanceServiceTraceName = + (typeof TRACES)[keyof typeof TRACES]; + +export type MoneyAccountBalanceServiceTraceRequest = Omit< + TraceRequest, + 'name' +> & { + name: MoneyAccountBalanceServiceTraceName; + startTime?: number; +}; + +export type MoneyAccountBalanceServiceTraceCallback = ( + request: MoneyAccountBalanceServiceTraceRequest, + fn?: (context?: TraceContext) => ReturnType, +) => Promise; + +const configLogger = createModuleLogger(projectLogger, 'config'); +const traceLogger = createModuleLogger(projectLogger, 'trace'); +const balanceLogger = createModuleLogger(projectLogger, 'balance'); + +const NON_NEGATIVE_INTEGER_STRING_PATTERN = /^\d+$/u; + +/** + * Validates that balance amounts are non-negative integer strings and that + * `totalBalance === musdBalance + vmusdValueInMusd`. + * + * @param balance - Balance amounts to validate. + * @throws {@link MoneyAccountBalanceValidationError} when validation fails. + */ +function assertValidBalanceAmounts(balance: MoneyAccountBalanceResponse): void { + const entries: [keyof MoneyAccountBalanceResponse, string][] = [ + ['musdBalance', balance.musdBalance], + ['vmusdValueInMusd', balance.vmusdValueInMusd], + ['totalBalance', balance.totalBalance], + ]; + + for (const [field, value] of entries) { + if (!NON_NEGATIVE_INTEGER_STRING_PATTERN.test(value)) { + throw new MoneyAccountBalanceValidationError( + `Invalid ${field}: expected a non-negative integer string, got '${value}'`, + ); + } + } + + if ( + BigInt(balance.musdBalance) + BigInt(balance.vmusdValueInMusd) !== + BigInt(balance.totalBalance) + ) { + throw new MoneyAccountBalanceValidationError( + `Invalid balance invariant: totalBalance (${balance.totalBalance}) must equal musdBalance (${balance.musdBalance}) + vmusdValueInMusd (${balance.vmusdValueInMusd})`, + ); + } +} + +/** + * Routing table from policy to primary/fallback sources. + */ +const BALANCE_ROUTING_BY_POLICY: Record< + BalanceSourcePolicy, + { primary: BalanceSource; fallback: BalanceSource | null } +> = { + api: { primary: 'api', fallback: 'rpc' }, + rpc: { primary: 'rpc', fallback: 'api' }, + 'api-only': { primary: 'api', fallback: null }, + 'rpc-only': { primary: 'rpc', fallback: null }, +}; + +/** + * Resolves primary and optional fallback sources from a routing policy. + * + * @param policy - The active balance source policy. + * @returns Primary source and optional fallback. + */ +function resolveBalanceRouting(policy: BalanceSourcePolicy): { + primary: BalanceSource; + fallback: BalanceSource | null; +} { + return BALANCE_ROUTING_BY_POLICY[policy]; +} + +// === MESSENGER === + +const MESSENGER_EXPOSED_METHODS = [ + 'fetchBalanceWithFallback', + 'getMoneyAccountBalance', + 'getMusdBalance', + 'getVmusdBalance', + 'getExchangeRate', + 'getMusdEquivalentValue', + 'getVaultApy', +] as const; + +/** + * Invalidates cached queries for {@link MoneyAccountBalanceService}. + */ +export type MoneyAccountBalanceServiceInvalidateQueriesAction = + DataServiceInvalidateQueriesAction; + +/** + * Actions that {@link MoneyAccountBalanceService} exposes to other consumers. + */ +export type MoneyAccountBalanceServiceActions = + | MoneyAccountBalanceServiceMethodActions + | MoneyAccountBalanceServiceInvalidateQueriesAction; + +/** + * Actions from other messengers that {@link MoneyAccountBalanceService} calls. + */ +type AllowedActions = + | NetworkControllerGetNetworkConfigurationByChainIdAction + | NetworkControllerGetNetworkClientByIdAction + | RemoteFeatureFlagControllerGetStateAction + | MoneyAccountApiDataServiceFetchPositionsAction; + +/** + * Published when {@link MoneyAccountBalanceService}'s cache is updated. + */ +export type MoneyAccountBalanceServiceCacheUpdatedEvent = + DataServiceCacheUpdatedEvent; + +/** + * Published when a key within {@link MoneyAccountBalanceService}'s cache is + * updated. + */ +export type MoneyAccountBalanceServiceGranularCacheUpdatedEvent = + DataServiceGranularCacheUpdatedEvent; + +/** + * Events that {@link MoneyAccountBalanceService} exposes to other consumers. + */ +export type MoneyAccountBalanceServiceEvents = + | MoneyAccountBalanceServiceCacheUpdatedEvent + | MoneyAccountBalanceServiceGranularCacheUpdatedEvent; + +/** + * Events from other messengers that {@link MoneyAccountBalanceService} + * subscribes to. + */ +type AllowedEvents = RemoteFeatureFlagControllerStateChangeEvent; + +/** + * The messenger which is restricted to actions and events accessed by + * {@link MoneyAccountBalanceService}. + */ +export type MoneyAccountBalanceServiceMessenger = Messenger< + typeof serviceName, + MoneyAccountBalanceServiceActions | AllowedActions, + MoneyAccountBalanceServiceEvents | AllowedEvents +>; + +// === SERVICE DEFINITION === + +/** + * Data service responsible for fetching Money account balances (mUSD and + * vmUSD). Prefer {@link MoneyAccountBalanceService.fetchBalanceWithFallback} + * for presentation — it selects between the Money API and Multicall3 RPC + * sources via the `moneyAccountBalanceSource` remote feature flag (default: + * RPC primary with Money API fallback). + * + * Lower-level methods remain available for diagnostics and source-specific + * use cases: on-chain RPC reads (`getMoneyAccountBalance`, etc.), the Veda + * Accountant exchange rate, and the Veda vault APY from the Seven Seas REST + * API. + * + * All queries are cached via TanStack Query (inherited from + * {@link BaseDataService}) and protected by a service policy that provides + * automatic retries and circuit-breaking. + * + * **POC resilience note:** balance RPC and third-party vault APY currently + * share the same `BaseDataService` retry / circuit-breaker policy. A Veda APY + * outage can therefore affect RPC balance availability (including facade + * fallback). Splitting those failure domains is planned follow-up work before + * production reliance on the facade. + * + * Vault configuration (addresses, chain ID, decimals) is read from the + * remote feature flag via {@link RemoteFeatureFlagControllerGetStateAction}. + * Methods throw {@link VaultConfigNotAvailableError} until flags have been fetched and a + * valid config is present. + * + * @example + * + * ```ts + * const service = new MoneyAccountBalanceService({ + * messenger: moneyAccountBalanceServiceMessenger, + * }); + * + * const result = await service.fetchBalanceWithFallback('0xYourMoneyAccount...'); + * // { + * // musdBalance, vmusdValueInMusd, totalBalance, + * // source: 'api' | 'rpc', + * // usedFallback: boolean, + * // } + * ``` + */ + +export type MoneyAccountBalanceServiceOptions = { + messenger: MoneyAccountBalanceServiceMessenger; + policyOptions?: CreateServicePolicyOptions; + trace?: MoneyAccountBalanceServiceTraceCallback; +}; + +export class MoneyAccountBalanceService extends BaseDataService< + typeof serviceName, + MoneyAccountBalanceServiceMessenger +> { + #vaultConfig: VaultConfig | undefined; + + /** Cache stale time (ms) for on-chain balance reads. Overridable via remote feature flag. */ + #balanceStaleTime: number = DEFAULT_BALANCE_STALE_TIME; + + /** + * Preferred balance source routing policy. Overridable via remote feature + * flag; defaults to RPC primary with Money API fallback. + */ + #balanceSourcePolicy: BalanceSourcePolicy = DEFAULT_BALANCE_SOURCE_POLICY; + + readonly #trace: MoneyAccountBalanceServiceTraceCallback; + + /** + * @param options - Constructor options. + * @param options.messenger - The messenger for this service. + * @param options.policyOptions - Options passed to `createServicePolicy`. + * @param options.trace - Optional callback to trace network requests. + */ + constructor({ + messenger, + policyOptions = {}, + trace, + }: MoneyAccountBalanceServiceOptions) { + super({ + name: serviceName, + messenger, + policyOptions: { + retryFilterPolicy: handleWhen( + (error) => + !(error instanceof VedaResponseValidationError) && + !(error instanceof VaultConfigNotAvailableError), + ), + ...policyOptions, + }, + }); + + this.#trace = + trace ?? + (async ( + _request: MoneyAccountBalanceServiceTraceRequest, + fn?: (context?: TraceContext) => ReturnType, + ): Promise => { + return await Promise.resolve(fn?.() as ReturnType); + }); + + this.messenger.subscribe( + // eslint-disable-next-line no-restricted-syntax + 'RemoteFeatureFlagController:stateChange', + (state) => this.#onRemoteFeatureFlagChange(state.remoteFeatureFlags), + ); + + this.messenger.registerMethodActionHandlers( + this, + MESSENGER_EXPOSED_METHODS, + ); + } + + /** + * Runs a network request and emits a best-effort backdated trace. + * + * @param request - Trace metadata for the network request. + * @param fn - Network request to execute. + * @returns The network request result. + */ + async #traceNetworkRequest( + request: MoneyAccountBalanceServiceTraceRequest, + fn: () => Promise, + ): Promise { + const startTime = Date.now(); + let success = false; + let errorName: string | undefined; + + try { + const result = await fn(); + success = true; + return result; + } catch (error) { + errorName = error instanceof Error ? error.name : typeof error; + throw error; + } finally { + const traceRequest = { + ...request, + startTime, + data: { + ...request.data, + success, + ...(errorName ? { errorName } : {}), + }, + }; + const onTraceError = (traceError: unknown): void => { + traceLogger('Failed to emit trace', { + traceName: request.name, + traceError, + }); + }; + + try { + Promise.resolve(this.#trace(traceRequest, () => undefined)).catch( + onTraceError, + ); + } catch (traceError) { + onTraceError(traceError); + } + } + } + + /** + * Eagerly reads already-loaded feature flags and initialises service state. + * + * Must be called after all controllers and services have been instantiated so + * that the `RemoteFeatureFlagController:getState` action is guaranteed to be + * registered. Validation errors are swallowed — the service degrades + * gracefully and throws {@link VaultConfigNotAvailableError} on the first + * method call instead. + */ + init(): void { + try { + const { remoteFeatureFlags } = this.messenger.call( + 'RemoteFeatureFlagController:getState', + ); + this.#onRemoteFeatureFlagChange(remoteFeatureFlags); + } catch (error) { + if (error instanceof VaultConfigValidationError) { + configLogger( + 'Init failed — vault config validation error, service will start without config', + { error }, + ); + } else { + configLogger( + 'Init failed — RemoteFeatureFlagController not available, service will start without config', + { error }, + ); + } + } + } + + /** + * Returns the current vault config, or throws {@link VaultConfigNotAvailableError} + * if it has not been loaded yet. + * + * @returns The validated vault configuration. + */ + #requireConfig(): VaultConfig { + if (!this.#vaultConfig) { + throw new VaultConfigNotAvailableError(); + } + return this.#vaultConfig; + } + + /** + * Applies the balance `staleTime` feature flag, falling back to + * {@link DEFAULT_BALANCE_STALE_TIME} when the flag is absent or malformed. + * + * @param flagValue - Raw flag value from `remoteFeatureFlags`; expected to be + * a non-negative number of milliseconds. + */ + #applyBalanceStaleTimeFlag(flagValue: Json | undefined): void { + let nextStaleTime = DEFAULT_BALANCE_STALE_TIME; + + if (flagValue !== undefined) { + if ( + typeof flagValue === 'number' && + Number.isFinite(flagValue) && + flagValue >= 0 + ) { + nextStaleTime = flagValue; + } else { + configLogger('Invalid balance staleTime flag value; using default', { + flagValue, + default: DEFAULT_BALANCE_STALE_TIME, + }); + } + } + + if (nextStaleTime !== this.#balanceStaleTime) { + configLogger('Balance staleTime updated', { + previous: this.#balanceStaleTime, + next: nextStaleTime, + }); + this.#balanceStaleTime = nextStaleTime; + } + } + + /** + * Handles `RemoteFeatureFlagController:stateChange` events and the initial + * {@link init} call. + * + * @param remoteFeatureFlags - The `remoteFeatureFlags` map from + * `RemoteFeatureFlagController` state. + */ + #onRemoteFeatureFlagChange(remoteFeatureFlags: Record): void { + this.#applyBalanceStaleTimeFlag( + remoteFeatureFlags[MONEY_ACCOUNT_BALANCE_STALETIME_FEATURE_FLAG_KEY], + ); + this.#applyBalanceSourcePolicyFlag( + remoteFeatureFlags[MONEY_ACCOUNT_BALANCE_SOURCE_FEATURE_FLAG_KEY], + ); + this.#applyVaultConfig(remoteFeatureFlags[VAULT_CONFIG_FEATURE_FLAG_KEY]); + } + + /** + * Applies the balance source routing feature flag, falling back to + * {@link DEFAULT_BALANCE_SOURCE_POLICY} when the flag is absent or malformed. + * + * @param flagValue - Raw flag value from `remoteFeatureFlags`. + */ + #applyBalanceSourcePolicyFlag(flagValue: Json | undefined): void { + let nextPolicy = DEFAULT_BALANCE_SOURCE_POLICY; + + if (flagValue !== undefined) { + if ( + typeof flagValue === 'string' && + (BALANCE_SOURCE_POLICIES as readonly string[]).includes(flagValue) + ) { + nextPolicy = flagValue as BalanceSourcePolicy; + } else { + configLogger( + 'Invalid balance source policy flag value; using default', + { + flagValue, + default: DEFAULT_BALANCE_SOURCE_POLICY, + }, + ); + } + } + + if (nextPolicy !== this.#balanceSourcePolicy) { + configLogger('Balance source policy updated', { + previous: this.#balanceSourcePolicy, + next: nextPolicy, + }); + this.#balanceSourcePolicy = nextPolicy; + } + } + + /** + * Validates the vault config feature flag value, updates `#vaultConfig`, and + * invalidates all cached queries when the config changes. + * Throws {@link VaultConfigValidationError} when the flag value is malformed. + * + * @param flagValue - The raw flag value from `remoteFeatureFlags`. + */ + #applyVaultConfig(flagValue: Json | undefined): void { + const previousConfig = this.#vaultConfig; + const hadConfig = previousConfig !== undefined; + + if (flagValue === undefined) { + if (hadConfig) { + // Invalidate the cache if the flag key was removed. We don't want to keep using old config values. + this.#vaultConfig = undefined; + configLogger( + 'Vault config cleared — flag key absent; cache invalidated', + previousConfig, + ); + // eslint-disable-next-line @typescript-eslint/no-floating-promises + this.invalidateQueries(); + } else { + configLogger( + 'Flag key still absent after remote flag change — config remains unavailable', + ); + } + return; + } + + let newConfig: VaultConfig; + try { + newConfig = this.#parseAndValidateVaultConfig(flagValue); + } catch (error) { + if (hadConfig) { + // Invalidate the cache if the config is malformed. We don't want to keep using old config values. + this.#vaultConfig = undefined; + configLogger( + 'Vault config validation failed — previous config cleared; cache invalidated', + { previousConfig, error }, + ); + // eslint-disable-next-line @typescript-eslint/no-floating-promises + this.invalidateQueries(); + } else { + configLogger( + 'Vault config validation failed — config was already absent', + { error }, + ); + } + throw error; + } + + if (JSON.stringify(newConfig) === JSON.stringify(this.#vaultConfig)) { + return; + } + + this.#vaultConfig = newConfig; + if (hadConfig) { + configLogger('Vault config updated; cache invalidated', { + previous: previousConfig, + next: newConfig, + }); + // eslint-disable-next-line @typescript-eslint/no-floating-promises + this.invalidateQueries(); + } else { + configLogger('Vault config loaded', newConfig); + } + } + + /** + * Validates `flagValue` against {@link VaultConfigStruct} and returns it + * cast as {@link VaultConfig}. + * + * @param flagValue - The raw JSON value from the feature flag. + * @returns The validated vault config. + * @throws {@link VaultConfigValidationError} if the value does not match the + * expected shape. + */ + #parseAndValidateVaultConfig(flagValue: Json): VaultConfig { + try { + assert(flagValue, VaultConfigStruct); + } catch { + throw new VaultConfigValidationError(); + } + return flagValue as unknown as VaultConfig; + } + + /** + * Resolves a Web3Provider for the given chain ID by looking up the network + * configuration and client via the messenger. + * + * @param chainId - The chain ID to resolve a provider for. + * @returns A Web3Provider connected to the given chain. + * @throws If no network configuration exists for the chain, or if the + * resolved network client has no provider. + */ + #getProvider(chainId: Hex): Web3Provider { + const config = this.messenger.call( + 'NetworkController:getNetworkConfigurationByChainId', + chainId, + ); + + if (!config) { + throw new Error(`No network configuration found for chain ${chainId}`); + } + + const { rpcEndpoints, defaultRpcEndpointIndex } = config; + const { networkClientId } = rpcEndpoints[defaultRpcEndpointIndex]; + + const networkClient = this.messenger.call( + 'NetworkController:getNetworkClientById', + networkClientId, + ); + + if (!networkClient?.provider) { + throw new Error(`No provider found for chain ${chainId}`); + } + + return new Web3Provider(networkClient.provider); + } + + /** + * Fetches the ERC-20 balance for the given contract address and account address via RPC. + * + * @param contractAddress - The address of the ERC-20 contract. + * @param accountAddress - The address of the account. + * @param chainId - The chain ID to use for the provider. + * @returns The balance as a raw uint256 string. + */ + async #fetchErc20Balance( + contractAddress: Hex, + accountAddress: Hex, + chainId: Hex, + ): Promise { + const provider = this.#getProvider(chainId); + const contract = new Contract(contractAddress, abiERC20, provider); + const balance = await this.#traceNetworkRequest( + { + name: TRACES.ERC20_BALANCE_RPC, + data: { + chainId, + tokenAddress: contractAddress, + operation: 'balanceOf', + }, + }, + async () => + await contract.balanceOf(accountAddress, PENDING_READ_OVERRIDES), + ); + return balance.toString(); + } + + /** + * Fetches the underlying token address from the Accountant contract via RPC. + * + * @param chainId - The chain ID to use for the provider. + * @returns The underlying token address as a hex string. + */ + async #fetchUnderlyingTokenAddress(chainId: Hex): Promise { + const { accountantAddress } = this.#requireConfig(); + const provider = this.#getProvider(chainId); + const contract = new Contract(accountantAddress, ACCOUNTANT_ABI, provider); + const underlyingTokenAddress = await this.#traceNetworkRequest( + { + name: TRACES.UNDERLYING_TOKEN_RPC, + data: { chainId, operation: 'base' }, + }, + async () => await contract.base(), + ); + return underlyingTokenAddress; + } + + /** + * Resolves the underlying mUSD token address. + * + * Prefers the remotely-configured underlyingToken. + * Falls back to on-chain read when the flag isn't available. + * + * @param chainId - The chain ID to use for the provider on the fallback path. + * @returns The underlying mUSD token address. + */ + async #resolveUnderlyingTokenAddress(chainId: Hex): Promise { + const { underlyingToken } = this.#requireConfig(); + if (underlyingToken) { + return underlyingToken; + } + configLogger( + 'underlyingToken absent from vault config; falling back to on-chain read', + ); + return this.#fetchUnderlyingTokenAddress(chainId); + } + + /** + * Returns the Multicall3 contract address for the given chain, or throws if + * the chain is not supported. + * + * @param chainId - The chain ID to resolve a Multicall3 address for. + * @returns The Multicall3 contract address. + * @throws If no Multicall3 address is configured for the chain. + */ + #getMulticall3Address(chainId: Hex): Hex { + const multicall3Address = MULTICALL3_ADDRESS_BY_CHAIN_ID[chainId]; + if (!multicall3Address) { + throw new Error(`No Multicall3 address configured for chain ${chainId}`); + } + return multicall3Address; + } + + /** + * Fetches the canonical Money account balance, selecting the Money API or + * RPC source according to the `moneyAccountBalanceSource` remote feature + * flag (default: RPC primary with Money API fallback). + * + * Callers must not select a source. Provenance is returned on the result so + * fallback is never silent. Malformed or unavailable source balances are + * reported via the messenger's `captureException` before fallback. + * + * @param accountAddress - The Money account's Ethereum address. + * @returns Canonical balance amounts with source provenance. + * @throws {@link MoneyAccountBalanceFetchError} when every eligible source + * fails. Never returns a synthetic zero balance. + */ + async fetchBalanceWithFallback( + accountAddress: Hex, + ): Promise { + const { primary, fallback } = resolveBalanceRouting( + this.#balanceSourcePolicy, + ); + const errors: unknown[] = []; + + try { + return await this.#fetchBalanceFromSource(accountAddress, primary, false); + } catch (primaryError) { + errors.push(primaryError); + this.#reportBalanceSourceDefect(primaryError); + balanceLogger('Primary balance source failed', { + primary, + fallback, + primaryError, + }); + if (fallback === null) { + throw new MoneyAccountBalanceFetchError(errors); + } + } + + try { + return await this.#fetchBalanceFromSource(accountAddress, fallback, true); + } catch (fallbackError) { + errors.push(fallbackError); + this.#reportBalanceSourceDefect(fallbackError); + balanceLogger('Fallback balance source failed', { + primary, + fallback, + fallbackError, + }); + throw new MoneyAccountBalanceFetchError(errors); + } + } + + /** + * Reports high-severity balance source defects (malformed or unavailable + * balances) to error monitoring without interrupting fallback. + * + * @param error - Error thrown by a balance source attempt. + */ + #reportBalanceSourceDefect(error: unknown): void { + if ( + error instanceof MoneyAccountBalanceValidationError || + error instanceof MoneyAccountBalanceUnavailableError + ) { + this.messenger.captureException?.(error); + } + } + + /** + * Fetches and validates balance from a single source. + * + * @param accountAddress - The Money account's Ethereum address. + * @param source - Balance source to query. + * @param usedFallback - Whether this attempt is a fallback after primary failure. + * @returns Canonical balance result for the source. + */ + async #fetchBalanceFromSource( + accountAddress: Hex, + source: BalanceSource, + usedFallback: boolean, + ): Promise { + if (source === 'api') { + return await this.#fetchBalanceFromApi(accountAddress, usedFallback); + } + return await this.#fetchBalanceFromRpc(accountAddress, usedFallback); + } + + /** + * Reads balance from MoneyAccountApiDataService positions and maps it to + * the canonical result. + * + * @param accountAddress - The Money account's Ethereum address. + * @param usedFallback - Whether this attempt is a fallback. + * @returns Canonical balance from the Money API. + * @throws {@link MoneyAccountBalanceUnavailableError} when `balance` is null + * or absent. + * @throws {@link MoneyAccountBalanceValidationError} when amounts fail + * semantic validation. + */ + async #fetchBalanceFromApi( + accountAddress: Hex, + usedFallback: boolean, + ): Promise { + const positions: PositionResponse = await this.messenger.call( + 'MoneyAccountApiDataService:fetchPositions', + accountAddress, + ); + + if (positions.balance === undefined || positions.balance === null) { + throw new MoneyAccountBalanceUnavailableError( + 'Money API returned a null or missing balance', + ); + } + + const amounts: MoneyAccountBalanceResponse = { + musdBalance: positions.balance.musd_balance, + vmusdValueInMusd: positions.balance.vmusd_value_in_musd, + totalBalance: positions.balance.total_balance, + }; + assertValidBalanceAmounts(amounts); + + return { + ...amounts, + source: 'api', + usedFallback, + }; + } + + /** + * Reads balance via the existing Multicall3 RPC path and maps it to the + * canonical result. + * + * @param accountAddress - The Money account's Ethereum address. + * @param usedFallback - Whether this attempt is a fallback. + * @returns Canonical balance from RPC. + */ + async #fetchBalanceFromRpc( + accountAddress: Hex, + usedFallback: boolean, + ): Promise { + const amounts = await this.getMoneyAccountBalance(accountAddress); + assertValidBalanceAmounts(amounts); + + return { + ...amounts, + source: 'rpc', + usedFallback, + }; + } + + /** + * Fetches the mUSD ERC-20 balance for the given account address via RPC. + * + * @param accountAddress - The Money account's Ethereum address. + * @returns The mUSD balance as a raw uint256 string. + * @throws {@link VaultConfigNotAvailableError} if vault config has not been loaded. + */ + async getMusdBalance(accountAddress: Hex): Promise<{ balance: string }> { + return this.fetchQuery({ + queryKey: [`${this.name}:getMusdBalance`, accountAddress], + queryFn: async () => { + const { chainId } = this.#requireConfig(); + + const underlyingTokenAddress = + await this.#resolveUnderlyingTokenAddress(chainId); + + const balance = await this.#fetchErc20Balance( + underlyingTokenAddress, + accountAddress, + chainId, + ); + return { balance }; + }, + staleTime: this.#balanceStaleTime, + }); + } + + /** + * Fetches the account's total Money balance inputs in a single batched RPC + * request via Multicall3's `aggregate3` + * + * @param accountAddress - The Money account's Ethereum address. + * @returns The mUSD balance and the mUSD-equivalent value of vault shares as + * raw uint256 strings. The total balance is the sum of the mUSD balance and the mUSD-equivalent value of vault shares. + * @throws {@link VaultConfigNotAvailableError} if vault config has not been loaded. + */ + async getMoneyAccountBalance( + accountAddress: Hex, + ): Promise { + return this.fetchQuery({ + queryKey: [`${this.name}:getMoneyAccountBalance`, accountAddress], + queryFn: async () => { + const { chainId, boringVault, accountantAddress, lensAddress } = + this.#requireConfig(); + const provider = this.#getProvider(chainId); + + const underlyingTokenAddress = + await this.#resolveUnderlyingTokenAddress(chainId); + + const erc20 = new Contract(underlyingTokenAddress, abiERC20, provider); + const lens = new Contract(lensAddress, LENS_ABI, provider); + + const calls = [ + { + target: underlyingTokenAddress, + allowFailure: false, + callData: erc20.interface.encodeFunctionData('balanceOf', [ + accountAddress, + ]), + }, + { + target: lensAddress, + allowFailure: false, + callData: lens.interface.encodeFunctionData('balanceOfInAssets', [ + accountAddress, + boringVault, + accountantAddress, + ]), + }, + ]; + + const multicall3 = new Contract( + this.#getMulticall3Address(chainId), + MULTICALL3_ABI, + provider, + ); + const [musdResult, vmusdResult] = (await this.#traceNetworkRequest( + { + name: TRACES.MONEY_ACCOUNT_BALANCE_RPC, + data: { chainId, operation: 'aggregate3' }, + }, + async () => + await multicall3.callStatic.aggregate3( + calls, + PENDING_READ_OVERRIDES, + ), + )) as [Multicall3Result, Multicall3Result]; + + const musdBalanceBN = erc20.interface.decodeFunctionResult( + 'balanceOf', + musdResult.returnData, + )[0]; + const vmusdBN = lens.interface.decodeFunctionResult( + 'balanceOfInAssets', + vmusdResult.returnData, + )[0]; + + return { + musdBalance: musdBalanceBN.toString(), + vmusdValueInMusd: vmusdBN.toString(), + totalBalance: musdBalanceBN.add(vmusdBN).toString(), + }; + }, + staleTime: this.#balanceStaleTime, + }); + } + + /** + * Fetches the vmUSD (Veda vault share) ERC-20 balance for the given + * account address via RPC. + * + * @param accountAddress - The Money account's Ethereum address. + * @returns The vmUSD balance as a raw uint256 string. + * @throws {@link VaultConfigNotAvailableError} if vault config has not been loaded. + */ + async getVmusdBalance(accountAddress: Hex): Promise<{ balance: string }> { + return this.fetchQuery({ + queryKey: [`${this.name}:getVmusdBalance`, accountAddress], + queryFn: async () => { + const { boringVault, chainId } = this.#requireConfig(); + const balance = await this.#fetchErc20Balance( + boringVault, + accountAddress, + chainId, + ); + return { balance }; + }, + staleTime: this.#balanceStaleTime, + }); + } + + /** + * Fetches the current exchange rate from the Veda Accountant contract via + * RPC. The rate represents the conversion factor from vmUSD shares to + * the underlying mUSD asset. + * + * @param options - The options for the query. + * @param options.staleTime - Cache stale time override for this query. + * @returns The exchange rate as a raw uint256 string. + * @throws {@link VaultConfigNotAvailableError} if vault config has not been loaded. + */ + async getExchangeRate({ + staleTime, + }: { staleTime?: number } = {}): Promise { + return this.fetchQuery({ + queryKey: [`${this.name}:getExchangeRate`], + queryFn: async () => { + const { accountantAddress, chainId } = this.#requireConfig(); + const provider = this.#getProvider(chainId); + const contract = new Contract( + accountantAddress, + ACCOUNTANT_ABI, + provider, + ); + const rate = await this.#traceNetworkRequest( + { + name: TRACES.EXCHANGE_RATE_RPC, + data: { chainId, operation: 'getRate' }, + }, + async () => await contract.getRate(), + ); + return { rate: rate.toString() }; + }, + staleTime: staleTime ?? this.#balanceStaleTime, + }); + } + + /** + * Fetches the mUSD-equivalent value of the account's vmUSD vault shares + * via `Lens.balanceOfInAssets` RPC. + * + * @param accountAddress - The Money account's Ethereum address. + * @returns The mUSD-equivalent value of vault shares as a raw uint256 string. + * @throws {@link VaultConfigNotAvailableError} if vault config has not been loaded. + */ + async getMusdEquivalentValue( + accountAddress: Hex, + ): Promise { + return this.fetchQuery({ + queryKey: [`${this.name}:getMusdEquivalentValue`, accountAddress], + queryFn: async () => { + const { lensAddress, boringVault, accountantAddress, chainId } = + this.#requireConfig(); + const provider = this.#getProvider(chainId); + const contract = new Contract(lensAddress, LENS_ABI, provider); + const balanceOfInAssets = await this.#traceNetworkRequest( + { + name: TRACES.MUSD_EQUIVALENT_VALUE_RPC, + data: { chainId, operation: 'balanceOfInAssets' }, + }, + async () => + await contract.balanceOfInAssets( + accountAddress, + boringVault, + accountantAddress, + PENDING_READ_OVERRIDES, + ), + ); + + return { balanceOfInAssets: balanceOfInAssets.toString() }; + }, + staleTime: this.#balanceStaleTime, + }); + } + + /** + * Fetches the vault's APY and fee breakdown from the Veda performance REST API. + * APR values in the response are converted to APY using daily + * compounding before the normalized response is returned. + * + * @returns The normalized vault APY response with compounded APY values. + * @throws {@link VaultConfigNotAvailableError} if vault config has not been loaded. + */ + async getVaultApy(): Promise { + return this.fetchQuery({ + queryKey: [`${this.name}:getVaultApy`], + queryFn: async () => { + const { chainId, boringVault } = this.#requireConfig(); + const networkName = VEDA_API_NETWORK_NAMES[chainId]; + + if (!networkName) { + throw new Error( + `No Veda API network name found for chain ${chainId}`, + ); + } + + const url = new URL( + `/performance/${networkName}/${boringVault}`, + VEDA_PERFORMANCE_API_BASE_URL, + ); + + const rawResponse = await this.#traceNetworkRequest( + { + name: TRACES.VAULT_APY_API, + data: { chainId, operation: 'fetchVaultApy' }, + }, + async () => { + const response = await fetch(url); + + if (!response.ok) { + throw new HttpError( + response.status, + `Veda performance API failed with status '${response.status}'`, + ); + } + + return await response.json(); + }, + ); + + // Validate raw response inside queryFn to avoid poisoned cache. + if (!is(rawResponse, VaultApyRawResponseStruct)) { + throw new VedaResponseValidationError( + 'Malformed response received from Veda performance API', + ); + } + + return normalizeVaultApyResponse(rawResponse); + }, + staleTime: inMilliseconds(5, Duration.Minute), + }); + } +} diff --git a/packages/money-account-balance-service/src/requestNormalization.ts b/packages/money-account-balance-service/src/requestNormalization.ts new file mode 100644 index 00000000000..88ca9f1f4a4 --- /dev/null +++ b/packages/money-account-balance-service/src/requestNormalization.ts @@ -0,0 +1,43 @@ +import { Infer } from '@metamask/superstruct'; + +import type { NormalizedVaultApyResponse } from './response.types'; +import { VaultApyRawResponseStruct } from './structs.js'; +import { convertAprToApy } from './utils.js'; + +/** + * Normalizes the raw response from the Veda performance API into the expected + * format and converts all APR values to compounded APY values. + * + * @param rawResponse - The raw response from the Veda performance API. + * @returns The normalized response. + */ +export function normalizeVaultApyResponse( + rawResponse: Infer, +): NormalizedVaultApyResponse { + const { Response: response } = rawResponse; + + return { + aggregationPeriod: response.aggregation_period, + apy: convertAprToApy(response.apy), + chainAllocation: response.chain_allocation, + fees: response.fees, + globalApyBreakdown: response.global_apy_breakdown + ? { + fee: response.global_apy_breakdown.fee, + maturityApy: convertAprToApy( + response.global_apy_breakdown.maturity_apy, + ), + realApy: convertAprToApy(response.global_apy_breakdown.real_apy), + } + : undefined, + performanceFees: response.performance_fees, + realApyBreakdown: response.real_apy_breakdown?.map((item) => ({ + allocation: item.allocation, + apy: convertAprToApy(item.apy), + apyNet: convertAprToApy(item.apy_net), + chain: item.chain, + protocol: item.protocol, + })), + timestamp: response.timestamp, + }; +} diff --git a/packages/money-account-balance-service/src/response.types.ts b/packages/money-account-balance-service/src/response.types.ts new file mode 100644 index 00000000000..6d1db61748f --- /dev/null +++ b/packages/money-account-balance-service/src/response.types.ts @@ -0,0 +1,66 @@ +/** + * Response from {@link MoneyAccountBalanceService.getExchangeRate}. + * Rate is the raw uint256 string returned by the Accountant's `getRate()`. + */ +export type ExchangeRateResponse = { + rate: string; +}; + +/** + * Response from {@link MoneyAccountBalanceService.getMusdEquivalentValue}. + * Balance of in assets is the raw uint256 string returned by the Lens's `balanceOfInAssets()`. + */ +export type MusdEquivalentValueResponse = { + balanceOfInAssets: string; +}; + +/** + * Response from {@link MoneyAccountBalanceService.getMoneyAccountBalance}. + */ +export type MoneyAccountBalanceResponse = { + musdBalance: string; + vmusdValueInMusd: string; + totalBalance: string; +}; + +/** + * Canonical balance result from + * {@link MoneyAccountBalanceService.fetchBalanceWithFallback}. + */ +export type CanonicalMoneyAccountBalanceResponse = + MoneyAccountBalanceResponse & { + source: 'api' | 'rpc'; + usedFallback: boolean; + }; + +/** + * Response from {@link MoneyAccountBalanceService.getVaultApy}. + * APY and fee values are decimals (multiply by 100 for percentage). + * Veda's APY values are actually APR (labeled incorrectly). They are converted APY using daily compounding + * (see {@link convertAprToApy}) before this response is returned. + * + * Only `apy` and `timestamp` are guaranteed to be present — all other fields + * are optional because the Veda API omits them when the vault has no activity. + */ +export type NormalizedVaultApyResponse = { + aggregationPeriod?: string; // E.g. "7 days" + apy: number; + chainAllocation?: { + [network: string]: number; + }; + fees?: number; + globalApyBreakdown?: { + fee?: number; + maturityApy?: number; + realApy?: number; + }; + performanceFees?: number; + realApyBreakdown?: { + allocation?: number; + apy?: number; + apyNet?: number; + chain?: string; + protocol?: string; + }[]; + timestamp: string; +}; diff --git a/packages/money-account-balance-service/src/structs.ts b/packages/money-account-balance-service/src/structs.ts new file mode 100644 index 00000000000..d8e65e990cb --- /dev/null +++ b/packages/money-account-balance-service/src/structs.ts @@ -0,0 +1,66 @@ +import { + array, + number, + optional, + record, + string, + type, +} from '@metamask/superstruct'; +import { StrictHexStruct } from '@metamask/utils'; + +/** + * Superstruct schema for {@link VaultConfig}. + * + * Uses `type()` (loose validation) so that extra keys added to the feature + * flag in future do not break existing clients. + */ +export const VaultConfigStruct = type({ + accountantAddress: StrictHexStruct, + boringVault: StrictHexStruct, + lensAddress: StrictHexStruct, + tellerAddress: StrictHexStruct, + chainId: StrictHexStruct, + // Optional so flags deployed before this field existed still validate. When + // present it lets the service skip the on-chain `Accountant.base()` read. + underlyingToken: optional(StrictHexStruct), +}); + +/** + * Superstruct schema for the raw Veda vault performance response. + * + * Uses `type()` (loose validation) so that unknown fields returned by the + * Veda API do not cause validation failures. + * + * APY fields are APR values despite their API names. They are converted to + * compounded APY values by {@link normalizeVaultApyResponse}. Only `apy` and + * `timestamp` are required — all other fields are optional because the Veda + * API omits some fields when the vault has no activity. + */ +export const VaultApyRawResponseStruct = type({ + Response: type({ + aggregation_period: optional(string()), + apy: number(), + chain_allocation: optional(record(string(), number())), + fees: optional(number()), + global_apy_breakdown: optional( + type({ + fee: optional(number()), + maturity_apy: optional(number()), + real_apy: optional(number()), + }), + ), + performance_fees: optional(number()), + real_apy_breakdown: optional( + array( + type({ + allocation: optional(number()), + apy: optional(number()), + apy_net: optional(number()), + chain: optional(string()), + protocol: optional(string()), + }), + ), + ), + timestamp: string(), + }), +}); diff --git a/packages/money-account-balance-service/src/types.ts b/packages/money-account-balance-service/src/types.ts new file mode 100644 index 00000000000..a47087bf541 --- /dev/null +++ b/packages/money-account-balance-service/src/types.ts @@ -0,0 +1,23 @@ +import type { Hex } from '@metamask/utils'; + +/** + * The vault configuration read from the remote feature flag. + * Runtime validation is performed by {@link VaultConfigStruct}. + */ +export type VaultConfig = { + boringVault: Hex; + tellerAddress: Hex; + accountantAddress: Hex; + lensAddress: Hex; + chainId: Hex; + /** + * Address of the vault's underlying ERC-20 asset (mUSD). + * + * Optional for backwards compatibility with flags deployed before this + * field existed. When present, it is used directly as the source of truth + * (the flag already moves in lockstep with the vault addresses), avoiding an + * on-chain `Accountant.base()` read on every mUSD balance fetch. When absent, + * the service falls back to reading `base()` on-chain. + */ + underlyingToken?: Hex; +}; diff --git a/packages/money-account-balance-service/src/utils.test.ts b/packages/money-account-balance-service/src/utils.test.ts new file mode 100644 index 00000000000..2aad92faee7 --- /dev/null +++ b/packages/money-account-balance-service/src/utils.test.ts @@ -0,0 +1,15 @@ +import { convertAprToApy } from './utils.js'; + +describe('convertAprToApy', () => { + it('returns undefined when APR is undefined', () => { + expect(convertAprToApy(undefined)).toBeUndefined(); + }); + + it('returns zero when APR is zero', () => { + expect(convertAprToApy(0)).toBe(0); + }); + + it('converts APR to APY using daily compounding', () => { + expect(convertAprToApy(0.05)).toBeCloseTo(0.05126749646744733, 15); + }); +}); diff --git a/packages/money-account-balance-service/src/utils.ts b/packages/money-account-balance-service/src/utils.ts new file mode 100644 index 00000000000..40e507e9b5f --- /dev/null +++ b/packages/money-account-balance-service/src/utils.ts @@ -0,0 +1,19 @@ +const DAYS_PER_YEAR = 365; + +export function convertAprToApy(apr: undefined): undefined; +export function convertAprToApy(apr: number): number; +export function convertAprToApy(apr: number | undefined): number | undefined; + +/** + * Converts a decimal APR to a decimal APY using daily compounding. + * + * @param apr - The APR as a decimal. + * @returns The compounded APY, or undefined when APR is undefined. + */ +export function convertAprToApy(apr: number | undefined): number | undefined { + if (apr === undefined || apr === 0) { + return apr; + } + + return (1 + apr / DAYS_PER_YEAR) ** DAYS_PER_YEAR - 1; +} diff --git a/packages/money-account-balance-service/tsconfig.build.json b/packages/money-account-balance-service/tsconfig.build.json new file mode 100644 index 00000000000..fd6acd41017 --- /dev/null +++ b/packages/money-account-balance-service/tsconfig.build.json @@ -0,0 +1,17 @@ +{ + "extends": "../../tsconfig.packages.build.json", + "compilerOptions": { + "baseUrl": "./", + "outDir": "./dist", + "rootDir": "./src" + }, + "references": [ + { "path": "../base-data-service/tsconfig.build.json" }, + { "path": "../controller-utils/tsconfig.build.json" }, + { "path": "../messenger/tsconfig.build.json" }, + { "path": "../money-account-api-data-service/tsconfig.build.json" }, + { "path": "../network-controller/tsconfig.build.json" }, + { "path": "../remote-feature-flag-controller/tsconfig.build.json" } + ], + "include": ["../../types", "./src"] +} diff --git a/packages/money-account-balance-service/tsconfig.json b/packages/money-account-balance-service/tsconfig.json new file mode 100644 index 00000000000..af55fc4762b --- /dev/null +++ b/packages/money-account-balance-service/tsconfig.json @@ -0,0 +1,15 @@ +{ + "extends": "../../tsconfig.packages.json", + "compilerOptions": { + "baseUrl": "./" + }, + "references": [ + { "path": "../base-data-service" }, + { "path": "../controller-utils" }, + { "path": "../messenger" }, + { "path": "../money-account-api-data-service" }, + { "path": "../network-controller" }, + { "path": "../remote-feature-flag-controller" } + ], + "include": ["../../types", "./src"] +} diff --git a/packages/money-account-balance-service/typedoc.json b/packages/money-account-balance-service/typedoc.json new file mode 100644 index 00000000000..c9da015dbf8 --- /dev/null +++ b/packages/money-account-balance-service/typedoc.json @@ -0,0 +1,7 @@ +{ + "entryPoints": ["./src/index.ts"], + "excludePrivate": true, + "hideGenerator": true, + "out": "docs", + "tsconfig": "./tsconfig.build.json" +} diff --git a/packages/money-account-controller/CHANGELOG.md b/packages/money-account-controller/CHANGELOG.md new file mode 100644 index 00000000000..8249b7783e9 --- /dev/null +++ b/packages/money-account-controller/CHANGELOG.md @@ -0,0 +1,83 @@ +# 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] + +### Changed + +- Bump `@metamask/accounts-controller` from `^39.0.7` to `^39.1.1` ([#9807](https://github.com/MetaMask/core/pull/9807), [#9969](https://github.com/MetaMask/core/pull/9969)) + +## [1.0.0] + +### Changed + +- **BREAKING:** Remove `eth_signTransaction` account method ([#9763](https://github.com/MetaMask/core/pull/9763)) + - Money accounts were not supposed to sign transaction, the support has been removed from the keyring. +- Bump `@metamask/eth-money-keyring` from `^2.0.4` to `^4.0.0` ([#9763](https://github.com/MetaMask/core/pull/9763), [#9754](https://github.com/MetaMask/core/pull/9754)) +- Bump `@metamask/utils` from `^11.9.0` to `^11.11.0` ([#9074](https://github.com/MetaMask/core/pull/9074)) +- Bump `@metamask/keyring-controller` from `^27.0.0` to `^27.1.1` ([#9129](https://github.com/MetaMask/core/pull/9129), [#9791](https://github.com/MetaMask/core/pull/9791)) +- Bump `@metamask/accounts-controller` from `^39.0.1` to `^39.0.7` ([#9218](https://github.com/MetaMask/core/pull/9218), [#9231](https://github.com/MetaMask/core/pull/9231), [#9349](https://github.com/MetaMask/core/pull/9349), [#9470](https://github.com/MetaMask/core/pull/9470), [#9735](https://github.com/MetaMask/core/pull/9735), [#9791](https://github.com/MetaMask/core/pull/9791)) +- Bump `@metamask/keyring-api` from `^23.1.0` to `^24.0.0` ([#9249](https://github.com/MetaMask/core/pull/9249), [#9390](https://github.com/MetaMask/core/pull/9390), [#9676](https://github.com/MetaMask/core/pull/9676), [#9754](https://github.com/MetaMask/core/pull/9754)) +- Bump `@metamask/messenger` from `^1.2.0` to `^2.0.0` ([#9392](https://github.com/MetaMask/core/pull/9392)) +- Bump `@metamask/eth-money-keyring` from `^2.0.4` to `^4.0.0` ([#9754](https://github.com/MetaMask/core/pull/9754)) + +## [0.3.3] + +### Changed + +- Bump `@metamask/accounts-controller` from `^39.0.0` to `^39.0.1` ([#9058](https://github.com/MetaMask/core/pull/9058)) +- Bump `@metamask/keyring-controller` from `^26.0.0` to `^27.0.0` ([#9058](https://github.com/MetaMask/core/pull/9058)) + +## [0.3.2] + +### Changed + +- Bump `@metamask/accounts-controller` from `^38.1.2` to `^39.0.0` ([#8999](https://github.com/MetaMask/core/pull/8999)) + +## [0.3.1] + +### Changed + +- Bump `@metamask/accounts-controller` from `^38.0.0` to `^38.1.2` ([#8755](https://github.com/MetaMask/core/pull/8755), [#8774](https://github.com/MetaMask/core/pull/8774), [#8912](https://github.com/MetaMask/core/pull/8912)) +- Bump `@metamask/keyring-controller` from `^25.5.0` to `^26.0.0` ([#8912](https://github.com/MetaMask/core/pull/8912)) + +## [0.3.0] + +### Added + +- Expose missing `MoneyAccountController:init` action through its messenger ([#8718](https://github.com/MetaMask/core/pull/8718)) + - Corresponding action type is available as well. + +### Changed + +- Bump `@metamask/keyring-controller` from `^25.4.0` to `^25.5.0` ([#8722](https://github.com/MetaMask/core/pull/8722)) + +## [0.2.0] + +### Changed + +- Bump `@metamask/accounts-controller` from `^37.2.0` to `^38.0.0` ([#8665](https://github.com/MetaMask/core/pull/8665)) +- Bump `@metamask/messenger` from `^1.1.0` to `^1.2.0` ([#8373](https://github.com/MetaMask/core/pull/8373), [#8632](https://github.com/MetaMask/core/pull/8632)) +- Bump `@metamask/base-controller` from `^9.0.1` to `^9.1.0` ([#8457](https://github.com/MetaMask/core/pull/8457)) +- Bump `@metamask/eth-money-keyring` from `^2.0.0` to `^2.0.4` ([#8464](https://github.com/MetaMask/core/pull/8464), [#8584](https://github.com/MetaMask/core/pull/8584), [#8647](https://github.com/MetaMask/core/pull/8647)) +- Bump `@metamask/keyring-api` from `^21.6.0` to `^23.1.0` ([#8464](https://github.com/MetaMask/core/pull/8464), [#8647](https://github.com/MetaMask/core/pull/8647)) +- Bump `@metamask/keyring-controller` from `^25.2.0` to `^25.4.0` ([#8634](https://github.com/MetaMask/core/pull/8634), [#8665](https://github.com/MetaMask/core/pull/8665)) + +## [0.1.0] + +### Added + +- Add `MoneyAccountController` ([#8361](https://github.com/MetaMask/core/pull/8361)) + +[Unreleased]: https://github.com/MetaMask/core/compare/@metamask/money-account-controller@1.0.0...HEAD +[1.0.0]: https://github.com/MetaMask/core/compare/@metamask/money-account-controller@0.3.3...@metamask/money-account-controller@1.0.0 +[0.3.3]: https://github.com/MetaMask/core/compare/@metamask/money-account-controller@0.3.2...@metamask/money-account-controller@0.3.3 +[0.3.2]: https://github.com/MetaMask/core/compare/@metamask/money-account-controller@0.3.1...@metamask/money-account-controller@0.3.2 +[0.3.1]: https://github.com/MetaMask/core/compare/@metamask/money-account-controller@0.3.0...@metamask/money-account-controller@0.3.1 +[0.3.0]: https://github.com/MetaMask/core/compare/@metamask/money-account-controller@0.2.0...@metamask/money-account-controller@0.3.0 +[0.2.0]: https://github.com/MetaMask/core/compare/@metamask/money-account-controller@0.1.0...@metamask/money-account-controller@0.2.0 +[0.1.0]: https://github.com/MetaMask/core/releases/tag/@metamask/money-account-controller@0.1.0 diff --git a/packages/money-account-controller/LICENSE b/packages/money-account-controller/LICENSE new file mode 100644 index 00000000000..fe29e78e0fe --- /dev/null +++ b/packages/money-account-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/money-account-controller/README.md b/packages/money-account-controller/README.md new file mode 100644 index 00000000000..bb50d58eae3 --- /dev/null +++ b/packages/money-account-controller/README.md @@ -0,0 +1,15 @@ +# `@metamask/money-account-controller` + +MetaMask Money account controller. + +## Installation + +`yarn add @metamask/money-account-controller` + +or + +`npm install @metamask/money-account-controller` + +## Contributing + +This package is part of a monorepo. Instructions for contributing can be found in the [monorepo README](https://github.com/MetaMask/core#readme). diff --git a/packages/money-account-controller/jest.config.js b/packages/money-account-controller/jest.config.js new file mode 100644 index 00000000000..ca084133399 --- /dev/null +++ b/packages/money-account-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/money-account-controller/package.json b/packages/money-account-controller/package.json new file mode 100644 index 00000000000..c8973d6ebf2 --- /dev/null +++ b/packages/money-account-controller/package.json @@ -0,0 +1,83 @@ +{ + "name": "@metamask/money-account-controller", + "version": "1.0.0", + "description": "MetaMask Money account controller", + "keywords": [ + "Ethereum", + "MetaMask" + ], + "homepage": "https://github.com/MetaMask/core/tree/main/packages/money-account-controller#readme", + "bugs": { + "url": "https://github.com/MetaMask/core/issues" + }, + "license": "MIT", + "repository": { + "type": "git", + "url": "https://github.com/MetaMask/core.git" + }, + "files": [ + "dist/" + ], + "sideEffects": false, + "main": "./dist/index.cjs", + "types": "./dist/index.d.cts", + "exports": { + ".": { + "import": { + "types": "./dist/index.d.mts", + "default": "./dist/index.mjs" + }, + "require": { + "types": "./dist/index.d.cts", + "default": "./dist/index.cjs" + } + }, + "./package.json": "./package.json" + }, + "publishConfig": { + "access": "public", + "registry": "https://registry.npmjs.org/" + }, + "scripts": { + "build": "ts-bridge --project tsconfig.build.json --verbose --clean --no-references", + "build:all": "ts-bridge --project tsconfig.build.json --verbose --clean", + "build:docs": "typedoc", + "changelog:update": "../../scripts/update-changelog.sh @metamask/money-account-controller", + "changelog:validate": "../../scripts/validate-changelog.sh @metamask/money-account-controller", + "lint:tsconfigs": "tsx ../../scripts/lint-tsconfigs/lint-tsconfigs.mts", + "lint:tsconfigs:fix": "tsx ../../scripts/lint-tsconfigs/lint-tsconfigs.mts --fix", + "messenger-action-types:check": "tsx ../../packages/messenger-cli/src/cli.ts --formatter oxfmt --esm --check", + "messenger-action-types:generate": "tsx ../../packages/messenger-cli/src/cli.ts --formatter oxfmt --esm --generate", + "since-latest-release": "../../scripts/since-latest-release.sh", + "test": "NODE_OPTIONS=--experimental-vm-modules jest --reporters=jest-silent-reporter", + "test:clean": "NODE_OPTIONS=--experimental-vm-modules jest --clearCache", + "test:verbose": "NODE_OPTIONS=--experimental-vm-modules jest --verbose", + "test:watch": "NODE_OPTIONS=--experimental-vm-modules jest --watch" + }, + "dependencies": { + "@metamask/accounts-controller": "^39.1.1", + "@metamask/base-controller": "^9.1.0", + "@metamask/eth-money-keyring": "^4.0.0", + "@metamask/keyring-api": "^24.0.0", + "@metamask/keyring-controller": "^27.1.1", + "@metamask/messenger": "^2.0.0", + "async-mutex": "^0.5.0" + }, + "devDependencies": { + "@metamask/auto-changelog": "^6.1.0", + "@metamask/keyring-utils": "^5.0.0", + "@metamask/utils": "^11.11.0", + "@ts-bridge/cli": "^0.6.4", + "@types/jest": "^30.0.0", + "deepmerge": "^4.2.2", + "jest": "^30.4.2", + "ts-jest": "^29.4.11", + "tsx": "^4.20.5", + "typedoc": "^0.25.13", + "typedoc-plugin-missing-exports": "^2.0.0", + "typescript": "~5.3.3" + }, + "engines": { + "node": "^18.18 || >=20" + } +} diff --git a/packages/money-account-controller/src/MoneyAccountController-method-action-types.ts b/packages/money-account-controller/src/MoneyAccountController-method-action-types.ts new file mode 100644 index 00000000000..a0e8d1fb519 --- /dev/null +++ b/packages/money-account-controller/src/MoneyAccountController-method-action-types.ts @@ -0,0 +1,61 @@ +/** + * This file is auto generated. + * Do not edit manually. + */ + +import type { MoneyAccountController } from './MoneyAccountController.js'; + +/** + * Initializes the controller by creating a money account for the primary + * entropy source if one does not already exist. + */ +export type MoneyAccountControllerInitAction = { + type: `MoneyAccountController:init`; + handler: MoneyAccountController['init']; +}; + +/** + * Creates a money account for the given entropy source. If an account + * already exists for that entropy source, it is returned as-is (idempotent). + * + * @param entropySource - The entropy source ID to create the money account for. + * @returns The money account. + */ +export type MoneyAccountControllerCreateMoneyAccountAction = { + type: `MoneyAccountController:createMoneyAccount`; + handler: MoneyAccountController['createMoneyAccount']; +}; + +/** + * Gets a money account by its associated entropy source ID. If no ID is + * provided, the primary entropy source will be used. + * + * @param selector - Selector options for getting the money account. + * @param selector.entropySource - The entropy source ID to get the money account for. If not provided, the primary entropy source will be used. + * @returns The money account, or `undefined` if no account exists for the given entropy source. + */ +export type MoneyAccountControllerGetMoneyAccountAction = { + type: `MoneyAccountController:getMoneyAccount`; + handler: MoneyAccountController['getMoneyAccount']; +}; + +/** + * Resets the controller state to its default, removing all money accounts. + * + * Intended for use during a full app reset (e.g. when the user wipes all + * wallet data). Does not interact with the keyring — the caller is + * responsible for ensuring the associated keyring state is also cleared. + */ +export type MoneyAccountControllerClearStateAction = { + type: `MoneyAccountController:clearState`; + handler: MoneyAccountController['clearState']; +}; + +/** + * Union of all MoneyAccountController action types. + */ +export type MoneyAccountControllerMethodActions = + | MoneyAccountControllerInitAction + | MoneyAccountControllerCreateMoneyAccountAction + | MoneyAccountControllerGetMoneyAccountAction + | MoneyAccountControllerClearStateAction; diff --git a/packages/money-account-controller/src/MoneyAccountController.test.ts b/packages/money-account-controller/src/MoneyAccountController.test.ts new file mode 100644 index 00000000000..60b56cb69a2 --- /dev/null +++ b/packages/money-account-controller/src/MoneyAccountController.test.ts @@ -0,0 +1,574 @@ +import { + KeyringControllerError, + KeyringControllerErrorMessage, +} from '@metamask/keyring-controller'; +import { EthKeyring } from '@metamask/keyring-utils'; +import { Messenger, MOCK_ANY_NAMESPACE } from '@metamask/messenger'; +import type { + MockAnyNamespace, + MessengerActions, + MessengerEvents, +} from '@metamask/messenger'; + +import type { MoneyAccount, MoneyAccountControllerMessenger } from './index.js'; +import { + MoneyAccountController, + getDefaultMoneyAccountControllerState, +} from './index.js'; + +const MOCK_ENTROPY_SOURCE_ID = 'entropy-source-1'; +const MOCK_OTHER_ENTROPY_SOURCE_ID = 'entropy-source-2'; +const MOCK_ADDRESS = '0xabcdef1234567890abcdef1234567890abcdef12'; + +const MOCK_HD_KEYRING = { + type: 'HD Key Tree', + accounts: [MOCK_ADDRESS], + metadata: { id: MOCK_ENTROPY_SOURCE_ID, name: 'HD Key Tree' }, +}; + +const MOCK_MONEY_ACCOUNT: MoneyAccount = { + id: 'e9b8f87e-f08d-4e98-a3e4-3c2d3a4e5b6f', + type: 'eip155:eoa', + address: MOCK_ADDRESS, + scopes: ['eip155:0'], + options: { + entropy: { + type: 'mnemonic', + id: MOCK_ENTROPY_SOURCE_ID, + groupIndex: 0, + derivationPath: "m/44'/4392018'/0'/0", + }, + exportable: false, + }, + methods: [ + 'personal_sign', + 'eth_signTypedData_v1', + 'eth_signTypedData_v3', + 'eth_signTypedData_v4', + ], +}; + +const MOCK_MONEY_ACCOUNT_2: MoneyAccount = { + id: 'a1b2c3d4-e5f6-7890-abcd-ef1234567890', + type: 'eip155:eoa', + address: '0x1111111111111111111111111111111111111111', + scopes: ['eip155:0'], + options: { + entropy: { + type: 'mnemonic', + id: MOCK_OTHER_ENTROPY_SOURCE_ID, + groupIndex: 0, + derivationPath: "m/44'/4392018'/0'/0", + }, + exportable: false, + }, + methods: [ + 'personal_sign', + 'eth_signTypedData_v1', + 'eth_signTypedData_v3', + 'eth_signTypedData_v4', + ], +}; + +type RootMessenger = Messenger< + MockAnyNamespace, + MessengerActions, + MessengerEvents +>; + +// `withKeyring`'s callback requires an `EthKeyring`, but our mock keyrings +// only implement the subset of methods the controller actually calls. +function asKeyring(keyring: object): EthKeyring { + return keyring as unknown as EthKeyring; +} + +class MockMoneyKeyring { + readonly type = 'Money Keyring'; + + readonly entropySource: string; + + readonly #accounts: string[]; + + constructor({ + accounts = [MOCK_ADDRESS], + entropySource = MOCK_ENTROPY_SOURCE_ID, + }: { accounts?: string[]; entropySource?: string } = {}) { + this.entropySource = entropySource; + this.#accounts = [...accounts]; + } + + async getAccounts(): Promise { + return [...this.#accounts]; + } + + async addAccounts(_n: number): Promise { + this.#accounts.push(MOCK_ADDRESS); + return [MOCK_ADDRESS]; + } +} + +type SetupOptions = { + accounts?: MoneyAccount[]; + isUnlocked?: boolean; + keyrings?: { + type: string; + accounts: string[]; + metadata: { id: string; name: string }; + }[]; +}; + +type AllMoneyAccountControllerActions = + MessengerActions; + +type AllMoneyAccountControllerEvents = + MessengerEvents; + +function setup({ + accounts = [], + isUnlocked = true, + keyrings = [MOCK_HD_KEYRING], +}: SetupOptions = {}): { + controller: MoneyAccountController; + rootMessenger: RootMessenger; + messenger: MoneyAccountControllerMessenger; + mocks: { + // eslint-disable-next-line @typescript-eslint/naming-convention + KeyringController: { + withKeyring: jest.Mock; + addNewKeyring: jest.Mock; + }; + }; +} { + const mocks = { + KeyringController: { + withKeyring: jest.fn(), + addNewKeyring: jest.fn(), + }, + }; + + const rootMessenger = new Messenger< + MockAnyNamespace, + AllMoneyAccountControllerActions, + AllMoneyAccountControllerEvents + >({ namespace: MOCK_ANY_NAMESPACE }); + + rootMessenger.registerActionHandler( + 'KeyringController:getState', + () => + ({ + keyrings, + isUnlocked, + vault: '', + }) as never, + ); + + mocks.KeyringController.addNewKeyring.mockResolvedValue({ + id: 'mock-keyring-id', + name: 'Money Keyring', + }); + + mocks.KeyringController.withKeyring + // First call: no MoneyKeyring exists yet — controller will call addNewKeyring. + .mockRejectedValueOnce( + new KeyringControllerError(KeyringControllerErrorMessage.KeyringNotFound), + ) + // Subsequent calls: keyring exists (e.g. just created). + .mockImplementation(async (_selector, callback) => { + return callback({ + keyring: asKeyring(new MockMoneyKeyring()), + metadata: MOCK_HD_KEYRING.metadata, + }); + }); + + rootMessenger.registerActionHandler( + 'KeyringController:withKeyring', + mocks.KeyringController.withKeyring, + ); + + rootMessenger.registerActionHandler( + 'KeyringController:addNewKeyring', + mocks.KeyringController.addNewKeyring, + ); + + const messenger: MoneyAccountControllerMessenger = new Messenger({ + namespace: 'MoneyAccountController', + parent: rootMessenger, + }); + + rootMessenger.delegate({ + actions: [ + 'KeyringController:getState', + 'KeyringController:withKeyring', + 'KeyringController:addNewKeyring', + ], + events: [], + messenger, + }); + + const moneyAccounts = Object.fromEntries( + accounts.map((account) => [account.id, account]), + ); + + const controller = new MoneyAccountController({ + messenger, + state: { moneyAccounts }, + }); + + return { + controller, + rootMessenger, + messenger, + mocks, + }; +} + +describe('MoneyAccountController', () => { + describe('constructor', () => { + it('initializes with default state when no state is provided', () => { + const { controller } = setup(); + + expect(controller.state).toStrictEqual( + getDefaultMoneyAccountControllerState(), + ); + }); + + it('accepts initial state', () => { + const { controller } = setup({ accounts: [MOCK_MONEY_ACCOUNT] }); + + expect(controller.state.moneyAccounts).toStrictEqual({ + [MOCK_MONEY_ACCOUNT.id]: MOCK_MONEY_ACCOUNT, + }); + }); + }); + + describe('init', () => { + it('creates a money account for the primary entropy source', async () => { + const { controller } = setup(); + await controller.init(); + + const account = controller.getMoneyAccount(); + expect(account).toMatchObject({ + address: MOCK_ADDRESS, + options: { entropy: { id: MOCK_ENTROPY_SOURCE_ID } }, + }); + }); + + it('does nothing when no HD keyring exists', async () => { + const { controller } = setup({ keyrings: [] }); + await controller.init(); + + expect(controller.state.moneyAccounts).toStrictEqual({}); + }); + + it('is idempotent — calling init twice does not create duplicate accounts', async () => { + const { controller } = setup(); + await controller.init(); + await controller.init(); + expect(Object.keys(controller.state.moneyAccounts)).toHaveLength(1); + }); + + it('throws when the keyring is locked', async () => { + const { controller } = setup({ isUnlocked: false }); + await expect(controller.init()).rejects.toThrow( + 'Cannot create a money account while the keyring is locked', + ); + }); + }); + + describe('createMoneyAccount', () => { + it('creates a new money account with the correct shape', async () => { + const { controller } = setup(); + const account = await controller.createMoneyAccount( + MOCK_ENTROPY_SOURCE_ID, + ); + expect(account).toMatchObject({ + address: MOCK_ADDRESS, + type: 'eip155:eoa', + scopes: ['eip155:0'], + options: { + entropy: { + type: 'mnemonic', + id: MOCK_ENTROPY_SOURCE_ID, + groupIndex: 0, + derivationPath: "m/44'/4392018'/0'/0", + }, + }, + methods: expect.arrayContaining(['personal_sign']), + }); + expect(typeof account.id).toBe('string'); + }); + + it('persists the created account to state', async () => { + const { controller } = setup(); + const account = await controller.createMoneyAccount( + MOCK_ENTROPY_SOURCE_ID, + ); + expect(controller.state.moneyAccounts[account.id]).toStrictEqual(account); + }); + + it('returns the existing account without calling withKeyring (idempotent)', async () => { + const { controller, mocks } = setup({ + accounts: [MOCK_MONEY_ACCOUNT], + }); + const account = await controller.createMoneyAccount( + MOCK_ENTROPY_SOURCE_ID, + ); + expect(account).toStrictEqual(MOCK_MONEY_ACCOUNT); + expect(mocks.KeyringController.withKeyring).not.toHaveBeenCalled(); + }); + + it('reuses the keyring address when keyring has an account but state does not', async () => { + const EXISTING_ADDRESS = '0xdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef'; + const { controller, mocks } = setup(); + mocks.KeyringController.withKeyring.mockImplementation( + async (_selector, callback) => { + return callback({ + keyring: asKeyring( + new MockMoneyKeyring({ accounts: [EXISTING_ADDRESS] }), + ), + metadata: MOCK_HD_KEYRING.metadata, + }); + }, + ); + const account = await controller.createMoneyAccount( + MOCK_ENTROPY_SOURCE_ID, + ); + expect(account.address).toBe(EXISTING_ADDRESS); + }); + + it('adds an account when the money keyring exists but has no accounts', async () => { + const { controller, mocks } = setup(); + const mockKeyring = new MockMoneyKeyring({ accounts: [] }); + mocks.KeyringController.withKeyring.mockImplementation( + async (_selector, callback) => { + return callback({ + keyring: asKeyring(mockKeyring), + metadata: MOCK_HD_KEYRING.metadata, + }); + }, + ); + const account = await controller.createMoneyAccount( + MOCK_ENTROPY_SOURCE_ID, + ); + expect(account.address).toBe(MOCK_ADDRESS); + }); + + it('does not create duplicate keyrings when called concurrently for the same entropy source', async () => { + const { controller, mocks } = setup(); + + let keyringCreated = false; + + mocks.KeyringController.withKeyring.mockReset(); + mocks.KeyringController.withKeyring.mockImplementation( + async (_selector, callback) => { + // Yield to the event loop so concurrent calls can interleave at this + // point — simulating real async I/O latency. + await Promise.resolve(); + if (!keyringCreated) { + throw new KeyringControllerError( + KeyringControllerErrorMessage.KeyringNotFound, + ); + } + return callback({ + keyring: asKeyring(new MockMoneyKeyring()), + metadata: MOCK_HD_KEYRING.metadata, + }); + }, + ); + + mocks.KeyringController.addNewKeyring.mockReset(); + mocks.KeyringController.addNewKeyring.mockImplementation(async () => { + keyringCreated = true; + return { id: 'mock-keyring-id', name: 'Money Keyring' }; + }); + + await Promise.all([ + controller.createMoneyAccount(MOCK_ENTROPY_SOURCE_ID), + controller.createMoneyAccount(MOCK_ENTROPY_SOURCE_ID), + ]); + + // The mutex in #withKeyring serializes the two calls, so only the first + // one creates the keyring; the second finds it already created. + expect(mocks.KeyringController.addNewKeyring).toHaveBeenCalledTimes(1); + }); + + it('rethrows unexpected errors from withKeyring', async () => { + const { controller, mocks } = setup(); + + const unexpectedError = new Error('Unexpected keyring error'); + mocks.KeyringController.withKeyring.mockReset(); + mocks.KeyringController.withKeyring.mockRejectedValueOnce( + unexpectedError, + ); + + await expect( + controller.createMoneyAccount(MOCK_ENTROPY_SOURCE_ID), + ).rejects.toThrow('Unexpected keyring error'); + }); + + it('throws when the keyring is locked', async () => { + const { controller } = setup({ isUnlocked: false }); + + await expect( + controller.createMoneyAccount(MOCK_ENTROPY_SOURCE_ID), + ).rejects.toThrow( + 'Cannot create a money account while the keyring is locked', + ); + }); + + it('passes only the matching MoneyKeyring to the withKeyring callback', async () => { + const { controller, mocks } = setup(); + // Reset clears the "once" reject queue from setup() so the first (and only) + // call goes through this implementation directly (no create-keyring retry). + mocks.KeyringController.withKeyring.mockReset(); + mocks.KeyringController.withKeyring.mockImplementation( + async ( + selector: { filter: (k: EthKeyring) => boolean }, + callback: Parameters[1], + ) => { + const { filter } = selector; + // Non-MoneyKeyring keyrings should not match. + expect(filter(asKeyring({ type: 'HD Key Tree' }))).toBe(false); + // A MoneyKeyring for a different entropy source should not match. + expect( + filter( + asKeyring( + new MockMoneyKeyring({ + entropySource: MOCK_OTHER_ENTROPY_SOURCE_ID, + }), + ), + ), + ).toBe(false); + // A MoneyKeyring for the correct entropy source should match. + const mockKeyring = new MockMoneyKeyring(); + expect(filter(asKeyring(mockKeyring))).toBe(true); + return callback({ + keyring: asKeyring(mockKeyring), + metadata: MOCK_HD_KEYRING.metadata, + }); + }, + ); + await controller.createMoneyAccount(MOCK_ENTROPY_SOURCE_ID); + }); + + it('uses the explicitly provided entropy source', async () => { + const { controller, mocks } = setup({ + keyrings: [ + MOCK_HD_KEYRING, + { + type: 'HD Key Tree', + accounts: ['0x2222222222222222222222222222222222222222'], + metadata: { id: MOCK_OTHER_ENTROPY_SOURCE_ID, name: 'HD Key Tree' }, + }, + ], + }); + mocks.KeyringController.withKeyring.mockImplementation( + async (_selector, callback) => { + return callback({ + keyring: asKeyring( + new MockMoneyKeyring({ + entropySource: MOCK_OTHER_ENTROPY_SOURCE_ID, + }), + ), + metadata: MOCK_HD_KEYRING.metadata, + }); + }, + ); + + const account = await controller.createMoneyAccount( + MOCK_OTHER_ENTROPY_SOURCE_ID, + ); + expect(account.options.entropy.id).toBe(MOCK_OTHER_ENTROPY_SOURCE_ID); + }); + + it('is callable via the messenger', async () => { + const { rootMessenger } = setup(); + + const account = await rootMessenger.call( + 'MoneyAccountController:createMoneyAccount', + MOCK_ENTROPY_SOURCE_ID, + ); + expect(account).toMatchObject({ + address: MOCK_ADDRESS, + options: { entropy: { id: MOCK_ENTROPY_SOURCE_ID } }, + }); + }); + }); + + describe('getMoneyAccount', () => { + it('returns the account for the given entropy source', () => { + const { controller } = setup({ + accounts: [MOCK_MONEY_ACCOUNT, MOCK_MONEY_ACCOUNT_2], + }); + + expect( + controller.getMoneyAccount({ entropySource: MOCK_ENTROPY_SOURCE_ID }), + ).toStrictEqual(MOCK_MONEY_ACCOUNT); + }); + + it('returns undefined for an unknown entropy source', () => { + const { controller } = setup(); + + expect( + controller.getMoneyAccount({ entropySource: 'unknown-entropy-source' }), + ).toBeUndefined(); + }); + + it('falls back to the primary entropy source when none is provided', () => { + const { controller } = setup({ accounts: [MOCK_MONEY_ACCOUNT] }); + + expect(controller.getMoneyAccount()).toStrictEqual(MOCK_MONEY_ACCOUNT); + }); + + it('returns undefined when no entropy source is provided and no HD keyring exists', () => { + const { controller } = setup({ + accounts: [MOCK_MONEY_ACCOUNT], + keyrings: [], + }); + + expect(controller.getMoneyAccount()).toBeUndefined(); + }); + + it('is callable via the messenger', () => { + const { rootMessenger } = setup({ accounts: [MOCK_MONEY_ACCOUNT] }); + + expect( + rootMessenger.call('MoneyAccountController:getMoneyAccount', { + entropySource: MOCK_ENTROPY_SOURCE_ID, + }), + ).toStrictEqual(MOCK_MONEY_ACCOUNT); + }); + }); + + describe('clearState', () => { + it('resets moneyAccounts to an empty object', () => { + const { controller } = setup({ + accounts: [MOCK_MONEY_ACCOUNT, MOCK_MONEY_ACCOUNT_2], + }); + + expect(Object.keys(controller.state.moneyAccounts)).toHaveLength(2); + + controller.clearState(); + + expect(controller.state.moneyAccounts).toStrictEqual({}); + }); + + it('is a no-op when state is already empty', () => { + const { controller } = setup(); + + controller.clearState(); + + expect(controller.state.moneyAccounts).toStrictEqual({}); + }); + + it('is callable via the messenger', () => { + const { controller, rootMessenger } = setup({ + accounts: [MOCK_MONEY_ACCOUNT], + }); + + rootMessenger.call('MoneyAccountController:clearState'); + + expect(controller.state.moneyAccounts).toStrictEqual({}); + }); + }); +}); diff --git a/packages/money-account-controller/src/MoneyAccountController.ts b/packages/money-account-controller/src/MoneyAccountController.ts new file mode 100644 index 00000000000..5e6d259adfa --- /dev/null +++ b/packages/money-account-controller/src/MoneyAccountController.ts @@ -0,0 +1,380 @@ +import { getUUIDFromAddressOfNormalAccount } from '@metamask/accounts-controller'; +import type { + ControllerGetStateAction, + ControllerStateChangeEvent, + StateMetadata, +} from '@metamask/base-controller'; +import { BaseController } from '@metamask/base-controller'; +import type { + MoneyKeyring, + MoneyKeyringSerializedState, +} from '@metamask/eth-money-keyring'; +import { MONEY_DERIVATION_PATH } from '@metamask/eth-money-keyring'; +import { EthAccountType, EthMethod, EthScope } from '@metamask/keyring-api'; +import type { EntropySourceId } from '@metamask/keyring-api'; +import type { + KeyringControllerAddNewKeyringAction, + KeyringControllerGetStateAction, + KeyringControllerWithKeyringAction, + KeyringMetadata, + KeyringSelector, +} from '@metamask/keyring-controller'; +import { + isKeyringNotFoundError, + KeyringTypes, +} from '@metamask/keyring-controller'; +import { EthKeyring } from '@metamask/keyring-utils'; +import type { Messenger } from '@metamask/messenger'; +import { Mutex } from 'async-mutex'; + +import { projectLogger as log } from './logger.js'; +import type { MoneyAccountControllerMethodActions } from './MoneyAccountController-method-action-types.js'; +import type { MoneyAccount } from './types.js'; +import { isMoneyKeyring } from './utils.js'; + +export const controllerName = 'MoneyAccountController'; + +export type MoneyAccountControllerState = { + moneyAccounts: { + [id: MoneyAccount['id']]: MoneyAccount; + }; +}; + +const moneyAccountControllerMetadata = { + moneyAccounts: { + includeInDebugSnapshot: false, + includeInStateLogs: true, + persist: true, + usedInUi: true, + }, +} satisfies StateMetadata; + +export function getDefaultMoneyAccountControllerState(): MoneyAccountControllerState { + return { + moneyAccounts: {}, + }; +} + +const MESSENGER_EXPOSED_METHODS = [ + 'createMoneyAccount', + 'getMoneyAccount', + 'clearState', + 'init', +] as const; + +export type MoneyAccountControllerGetStateAction = ControllerGetStateAction< + typeof controllerName, + MoneyAccountControllerState +>; + +export type MoneyAccountControllerActions = + | MoneyAccountControllerGetStateAction + | MoneyAccountControllerMethodActions; + +type AllowedActions = + | KeyringControllerGetStateAction + | KeyringControllerAddNewKeyringAction + | KeyringControllerWithKeyringAction; + +export type MoneyAccountControllerStateChangeEvent = ControllerStateChangeEvent< + typeof controllerName, + MoneyAccountControllerState +>; + +export type MoneyAccountControllerEvents = + MoneyAccountControllerStateChangeEvent; + +type AllowedEvents = never; + +export type MoneyAccountControllerMessenger = Messenger< + typeof controllerName, + MoneyAccountControllerActions | AllowedActions, + MoneyAccountControllerEvents | AllowedEvents +>; + +/** + * Controller for managing money accounts. + */ +export class MoneyAccountController extends BaseController< + typeof controllerName, + MoneyAccountControllerState, + MoneyAccountControllerMessenger +> { + readonly #lock: Mutex; + + /** + * Constructor for the MoneyAccountController. + * + * @param options - The options for constructing the controller. + * @param options.messenger - The messenger to use for inter-controller communication. + * @param options.state - The initial state of the controller. If not provided, the default state will be used. + */ + constructor({ + messenger, + state, + }: { + messenger: MoneyAccountControllerMessenger; + state?: Partial; + }) { + super({ + messenger, + metadata: moneyAccountControllerMetadata, + name: controllerName, + state: { + ...getDefaultMoneyAccountControllerState(), + ...state, + }, + }); + + this.messenger.registerMethodActionHandlers( + this, + MESSENGER_EXPOSED_METHODS, + ); + + this.#lock = new Mutex(); + } + + /** + * Initializes the controller by creating a money account for the primary + * entropy source if one does not already exist. + */ + async init(): Promise { + this.#assertIsUnlocked(); + + const primaryEntropySource = this.#getPrimaryEntropySource(); + if (primaryEntropySource) { + const { id, address } = + await this.createMoneyAccount(primaryEntropySource); + log( + `Money keyring (entropy:${primaryEntropySource} - primary) account is: ${address} (${id})`, + ); + } else { + const message = + 'No primary HD keyring found, skipping default Money account creation!'; + + console.warn(message); + log(`WARNING -- ${message}`); + } + } + + /** + * Creates a money account for the given entropy source. If an account + * already exists for that entropy source, it is returned as-is (idempotent). + * + * @param entropySource - The entropy source ID to create the money account for. + * @returns The money account. + */ + async createMoneyAccount( + entropySource: EntropySourceId, + ): Promise { + this.#assertIsUnlocked(); + + // Idempotent: return existing account if already in state. + const existingAccount = this.getMoneyAccount({ entropySource }); + if (existingAccount) { + return existingAccount; + } + + const address = await this.#withKeyring(entropySource, async (keyring) => { + // We're adding this logic to be defensive against the possibility of a money keyring + // existing without any accounts, which shouldn't normally happen but we want to be + // sure we can handle it if it does. + // If there are no accounts, we'll add one and then get the address. + const accounts = await keyring.getAccounts(); + if (accounts.length > 0) { + const [moneyAddress] = accounts; + return moneyAddress; + } + + log( + `Money keyring (entropy:${entropySource}) has no accounts, creating one...`, + ); + const [moneyAddress] = await keyring.addAccounts(1); + return moneyAddress; + }); + + const account: MoneyAccount = { + // This is an EVM account, so let's re-use the deterministic ID generation logic of + // EVM accounts. + id: getUUIDFromAddressOfNormalAccount(address), + type: EthAccountType.Eoa, + address, + scopes: [EthScope.Eoa], + options: { + entropy: { + type: 'mnemonic', + id: entropySource, + groupIndex: 0, + derivationPath: MONEY_DERIVATION_PATH, + }, + exportable: false, + }, + methods: [ + EthMethod.PersonalSign, + EthMethod.SignTypedDataV1, + EthMethod.SignTypedDataV3, + EthMethod.SignTypedDataV4, + // TODO: Update this once the `keyring-api` package supports `SignEip7702Authorization` method. + ], + }; + + // Store the account in state. + this.update((state) => { + state.moneyAccounts[account.id] = account; + }); + + log( + `Money keyring (entropy:${account.options.entropy.id}) account created: ${account.address} (${account.id})`, + ); + return account; + } + + /** + * Gets a money account by its associated entropy source ID. If no ID is + * provided, the primary entropy source will be used. + * + * @param selector - Selector options for getting the money account. + * @param selector.entropySource - The entropy source ID to get the money account for. If not provided, the primary entropy source will be used. + * @returns The money account, or `undefined` if no account exists for the given entropy source. + */ + getMoneyAccount( + selector: { entropySource?: EntropySourceId } = {}, + ): MoneyAccount | undefined { + const entropySource = + selector.entropySource ?? this.#getPrimaryEntropySource(); + if (entropySource === undefined) { + return undefined; + } + + // We should never have more than one money account per entropy source, but if we + // do, just return the first one we find. + return Object.values(this.state.moneyAccounts).find( + (account) => account.options.entropy.id === entropySource, + ); + } + + /** + * Resets the controller state to its default, removing all money accounts. + * + * Intended for use during a full app reset (e.g. when the user wipes all + * wallet data). Does not interact with the keyring — the caller is + * responsible for ensuring the associated keyring state is also cleared. + */ + clearState(): void { + this.update((state) => { + state.moneyAccounts = {}; + }); + } + + /** + * Calls `KeyringController:withKeyring` for the `MoneyKeyring` associated with the + * given entropy source, creating one first if it does not yet exist. + * + * @param entropySource - The entropy source ID identifying the target keyring. + * @param operation - Callback invoked with the resolved `MoneyKeyring`. + * @returns The value returned by `operation`. + */ + async #withKeyring( + entropySource: EntropySourceId, + operation: (keyring: MoneyKeyring) => Promise, + ): Promise { + // Filter to find a specific `MoneyKeyring` for the given entropy source. + const isMoneyKeyringForEntropySource = ( + keyring: EthKeyring, + ): keyring is MoneyKeyring => + isMoneyKeyring(keyring) && keyring.entropySource === entropySource; + + // We cannot use proper generic-type inference using the messenger + // here, so we have to use a type casts for `keyring` and the return type. + const withKeyring = async ( + selector: KeyringSelector, + callback: (keyring: MoneyKeyring) => Promise, + ): Promise => + this.messenger.call( + 'KeyringController:withKeyring', + selector, + async ({ keyring }) => callback(keyring as MoneyKeyring), + ) as Promise; + + // We have an extra lock here to avoid a race-condition where 2 calls to + // `#withKeyring` for the same entropy source happen at the same time, and + // both don't find an existing keyring, so they both try to create a new + // one, which creates multiple keyrings for the same entropy source. + // NOTE: We cannot use `createIfMissing` here either, since it's only supported + // for selectors by type (and we want to deprecate this option). + // TODO: Move this new pattern in the `KeyringController`. + return await this.#lock.runExclusive(async () => { + try { + return await withKeyring( + { + filter: isMoneyKeyringForEntropySource, + }, + operation, + ); + } catch (error) { + // Forward any unexpected errors, but if the error is that + // the keyring wasn't found, we'll create it below. + if (!isKeyringNotFoundError(error)) { + throw error; + } + + // Create the keyring so we can use `withKeyring` to operate on it in the + // retry below. + log( + `Money keyring (entropy:${entropySource}) not found, creating one...`, + ); + const { id } = await this.#createMoneyKeyring(entropySource); + + // Use the ID directly on the retry (we just created this keyring so we + // know exactly which one to target). + return await withKeyring({ id }, operation); + } + }); + } + + /** + * Adds a new money keyring for the given entropy source and returns its metadata. + * + * NOTE: This function won't check if a money keyring for the given entropy source already + * exists! + * + * @param entropySource - The entropy source ID to create the money keyring for. + * @returns The metadata of the newly created money keyring. + */ + #createMoneyKeyring( + entropySource: EntropySourceId, + ): Promise { + return this.messenger.call( + 'KeyringController:addNewKeyring', + KeyringTypes.money, + { + entropySource, + } as MoneyKeyringSerializedState, + ); + } + + /** + * Gets the primary entropy source ID. + * + * @returns The primary entropy source ID, or `undefined` if no HD keyring exists. + */ + #getPrimaryEntropySource(): EntropySourceId | undefined { + const { keyrings } = this.messenger.call('KeyringController:getState'); + const primaryHdKeyring = keyrings.find( + (keyring) => keyring.type === KeyringTypes.hd, + ); + return primaryHdKeyring?.metadata.id; + } + + /** + * Throws if the keyring is currently locked. + */ + #assertIsUnlocked(): void { + const { isUnlocked } = this.messenger.call('KeyringController:getState'); + if (!isUnlocked) { + throw new Error( + 'Cannot create a money account while the keyring is locked', + ); + } + } +} diff --git a/packages/money-account-controller/src/index.ts b/packages/money-account-controller/src/index.ts new file mode 100644 index 00000000000..d8ab43a19a9 --- /dev/null +++ b/packages/money-account-controller/src/index.ts @@ -0,0 +1,21 @@ +export type { MoneyAccount } from './types.js'; +export { isMoneyKeyring } from './utils.js'; +export { + MoneyAccountController, + controllerName, + getDefaultMoneyAccountControllerState, +} from './MoneyAccountController.js'; +export type { + MoneyAccountControllerState, + MoneyAccountControllerGetStateAction, + MoneyAccountControllerActions, + MoneyAccountControllerStateChangeEvent, + MoneyAccountControllerEvents, + MoneyAccountControllerMessenger, +} from './MoneyAccountController.js'; +export type { + MoneyAccountControllerClearStateAction, + MoneyAccountControllerCreateMoneyAccountAction, + MoneyAccountControllerGetMoneyAccountAction, + MoneyAccountControllerInitAction, +} from './MoneyAccountController-method-action-types.js'; diff --git a/packages/money-account-controller/src/logger.ts b/packages/money-account-controller/src/logger.ts new file mode 100644 index 00000000000..981e19a62d2 --- /dev/null +++ b/packages/money-account-controller/src/logger.ts @@ -0,0 +1,7 @@ +/* istanbul ignore file */ + +import { createProjectLogger, createModuleLogger } from '@metamask/utils'; + +export const projectLogger = createProjectLogger('money-account-controller'); + +export { createModuleLogger }; diff --git a/packages/money-account-controller/src/types.ts b/packages/money-account-controller/src/types.ts new file mode 100644 index 00000000000..ca5fdc4a596 --- /dev/null +++ b/packages/money-account-controller/src/types.ts @@ -0,0 +1,14 @@ +import type { + KeyringAccount, + KeyringAccountEntropyMnemonicOptions, +} from '@metamask/keyring-api'; + +/** A money account represents an account managed by the MoneyAccountController. */ +export type MoneyAccount = Omit & { + // We use stricter options for money accounts. They can be seen as BIP-44 accounts + // and we make them non-exportable too. + options: { + entropy: KeyringAccountEntropyMnemonicOptions; + exportable: false; + }; +}; diff --git a/packages/money-account-controller/src/utils.test.ts b/packages/money-account-controller/src/utils.test.ts new file mode 100644 index 00000000000..b3fa2459282 --- /dev/null +++ b/packages/money-account-controller/src/utils.test.ts @@ -0,0 +1,20 @@ +import { KeyringTypes } from '@metamask/keyring-controller'; +import { EthKeyring } from '@metamask/keyring-utils'; + +import { isMoneyKeyring } from './utils.js'; + +describe('isMoneyKeyring', () => { + it('returns true for a Money Keyring', () => { + expect( + // Partial implementation, we only need the type for this test. + isMoneyKeyring({ type: KeyringTypes.money } as unknown as EthKeyring), + ).toBe(true); + }); + + it('returns false for a non-Money Keyring', () => { + expect( + // Partial implementation, we only need the type for this test. + isMoneyKeyring({ type: KeyringTypes.hd } as unknown as EthKeyring), + ).toBe(false); + }); +}); diff --git a/packages/money-account-controller/src/utils.ts b/packages/money-account-controller/src/utils.ts new file mode 100644 index 00000000000..af40858f12d --- /dev/null +++ b/packages/money-account-controller/src/utils.ts @@ -0,0 +1,13 @@ +import type { MoneyKeyring } from '@metamask/eth-money-keyring'; +import { KeyringTypes } from '@metamask/keyring-controller'; +import { EthKeyring } from '@metamask/keyring-utils'; + +/** + * Returns `true` if the given keyring is a {@link MoneyKeyring}. + * + * @param keyring - The keyring to check. + * @returns Whether the keyring is a `MoneyKeyring`. + */ +export function isMoneyKeyring(keyring: EthKeyring): keyring is MoneyKeyring { + return keyring.type === KeyringTypes.money; +} diff --git a/packages/money-account-controller/tsconfig.build.json b/packages/money-account-controller/tsconfig.build.json new file mode 100644 index 00000000000..8fa5f6bf61b --- /dev/null +++ b/packages/money-account-controller/tsconfig.build.json @@ -0,0 +1,21 @@ +{ + "extends": "../../tsconfig.packages.build.json", + "compilerOptions": { + "baseUrl": "./", + "outDir": "./dist", + "rootDir": "./src" + }, + "references": [ + { + "path": "../base-controller/tsconfig.build.json" + }, + { + "path": "../accounts-controller/tsconfig.build.json" + }, + { + "path": "../keyring-controller/tsconfig.build.json" + }, + { "path": "../messenger/tsconfig.build.json" } + ], + "include": ["../../types", "./src"] +} diff --git a/packages/money-account-controller/tsconfig.json b/packages/money-account-controller/tsconfig.json new file mode 100644 index 00000000000..e1b9b25e4a4 --- /dev/null +++ b/packages/money-account-controller/tsconfig.json @@ -0,0 +1,13 @@ +{ + "extends": "../../tsconfig.packages.json", + "compilerOptions": { + "baseUrl": "./" + }, + "references": [ + { "path": "../base-controller" }, + { "path": "../accounts-controller" }, + { "path": "../keyring-controller" }, + { "path": "../messenger" } + ], + "include": ["../../types", "./src"] +} diff --git a/packages/money-account-controller/typedoc.json b/packages/money-account-controller/typedoc.json new file mode 100644 index 00000000000..c9da015dbf8 --- /dev/null +++ b/packages/money-account-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/money-account-upgrade-controller/CHANGELOG.md b/packages/money-account-upgrade-controller/CHANGELOG.md new file mode 100644 index 00000000000..41f29bc6c5a --- /dev/null +++ b/packages/money-account-upgrade-controller/CHANGELOG.md @@ -0,0 +1,200 @@ +# 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] + +### Changed + +- Bump `@metamask/authenticated-user-storage` from `^3.0.1` to `^3.0.2` ([#9972](https://github.com/MetaMask/core/pull/9972)) +- Bump `@metamask/chomp-api-service` from `^4.0.0` to `^4.0.1` ([#9972](https://github.com/MetaMask/core/pull/9972)) + +## [3.0.2] + +### Changed + +- Bump `@metamask/network-controller` from `^35.0.0` to `^36.0.0` ([#9758](https://github.com/MetaMask/core/pull/9758), [#9969](https://github.com/MetaMask/core/pull/9969)) +- Bump `@metamask/keyring-controller` from `^27.1.0` to `^27.1.1` ([#9791](https://github.com/MetaMask/core/pull/9791)) + +## [3.0.1] + +### Changed + +- Bump `@metamask/network-controller` from `^34.0.0` to `^35.0.0` ([#9735](https://github.com/MetaMask/core/pull/9735)) + +## [3.0.0] + +### Added + +- **BREAKING:** Add persisted state tracking fully upgraded accounts ([#9500](https://github.com/MetaMask/core/pull/9500)) + - `MoneyAccountUpgradeControllerState` changes from `Record` to `{ upgradedAccounts }`, keyed by lowercased account address. Each entry records when the upgrade sequence completed and a fingerprint of the config it completed under (see new `MoneyAccountUpgradeStatus` type). Code constructing the state type (e.g. `{}` in tests or default-state maps) must include `upgradedAccounts`. + - The constructor now accepts an optional `state` option, merged with the defaults; add `getDefaultMoneyAccountUpgradeControllerState` to construct those defaults. +- Add `TerminalUpgradeError` and `isTerminalMoneyAccountUpgradeError`, and a `terminal` property on `MoneyAccountUpgradeStepError`, marking failures that cannot resolve by retrying — currently an account delegated to a third-party EIP-7702 implementation, an account with unexpected on-chain code, or an address confirmed to be associated with a different CHOMP profile ([#9500](https://github.com/MetaMask/core/pull/9500)) + - The controller does not retry on its own; clients implementing their own retry logic around `upgradeAccount` can use `isTerminalMoneyAccountUpgradeError` to stop retrying failures that cannot succeed. + +### Changed + +- **BREAKING:** The `associate-address` upgrade step now checks the profile's existing address associations via `ChompApiService:getAssociatedAddresses` before signing, and reports `already-done` without signing or submitting anything when the address is already associated ([#9387](https://github.com/MetaMask/core/pull/9387)) + - `MoneyAccountUpgradeControllerMessenger` consumers must grant the `ChompApiService:getAssociatedAddresses` action alongside the previously required actions, and must provide a `@metamask/chomp-api-service` version that registers it (`>=4.0.0`). + - The lookup is an optimization: if it fails, the step falls through to the previous sign-and-submit behavior. + - A 409 conflict from the association request is disambiguated by re-fetching the associations, so a same-profile create race reports `already-done` instead of failing the upgrade; a genuine conflict (address associated with a different profile) still fails the step. +- `upgradeAccount` now skips the step sequence entirely when the account is recorded in state as upgraded under the active config fingerprint, and records the account after a successful run. If the chain, CHOMP contract addresses, or Delegation Framework version change, the fingerprint no longer matches and the sequence re-runs ([#9500](https://github.com/MetaMask/core/pull/9500)) +- Bump `@metamask/messenger` from `^1.2.0` to `^2.0.0` ([#9392](https://github.com/MetaMask/core/pull/9392)) +- Bump `@metamask/authenticated-user-storage` from `^3.0.0` to `^3.0.1` ([#9458](https://github.com/MetaMask/core/pull/9458)) +- Bump `@metamask/chomp-api-service` from `^3.1.0` to `^4.0.0` ([#9592](https://github.com/MetaMask/core/pull/9592)) + +## [2.2.1] + +### Changed + +- Bump `@metamask/network-controller` from `^33.0.0` to `^34.0.0` ([#9349](https://github.com/MetaMask/core/pull/9349)) + +## [2.2.0] + +### Changed + +- Bump `@metamask/authenticated-user-storage` from `^2.0.0` to `^3.0.0` ([#9220](https://github.com/MetaMask/core/pull/9220), [#9348](https://github.com/MetaMask/core/pull/9348)) + +## [2.1.0] + +### Added + +- Add `MoneyAccountUpgradeStepError` (and the `isMoneyAccountUpgradeStepError` type guard). `upgradeAccount` now wraps any error thrown by a step in this error, exposing the failing step's `name` as `step` and preserving the original error as `cause`, so consumers can attribute failures to a specific step when reporting to Sentry). ([#9204](https://github.com/MetaMask/core/pull/9204)) + +### Changed + +- Bump `@metamask/keyring-controller` from `^27.0.0` to `^27.1.0` ([#9129](https://github.com/MetaMask/core/pull/9129)) +- Bump `@metamask/network-controller` from `^32.0.0` to `^33.0.0` ([#9218](https://github.com/MetaMask/core/pull/9218)) + +## [2.0.5] + +### Changed + +- Bump `@metamask/utils` from `^11.9.0` to `^11.11.0` ([#9074](https://github.com/MetaMask/core/pull/9074)) + +### Fixed + +- Scope the `register-intents` step to the currently-configured token addresses (deposit mUSD / withdrawal vmUSD) so that stale delegations for previously-configured tokens are no longer registered as intents ([#9075](https://github.com/MetaMask/core/pull/9075)) + +## [2.0.4] + +### Changed + +- Bump `@metamask/delegation-controller` from `^3.0.1` to `^3.0.2` ([#9058](https://github.com/MetaMask/core/pull/9058)) +- Bump `@metamask/keyring-controller` from `^26.0.0` to `^27.0.0` ([#9058](https://github.com/MetaMask/core/pull/9058)) + +## [2.0.3] + +### Changed + +- Bump `@metamask/delegation-core` from `^2.0.0` to `^2.2.1` ([#8823](https://github.com/MetaMask/core/pull/8823)) +- Bump `@metamask/delegation-deployments` from `^1.3.0` to `^1.4.0` ([#8823](https://github.com/MetaMask/core/pull/8823)) +- Bump `@metamask/keyring-controller` from `^25.5.0` to `^26.0.0` ([#8912](https://github.com/MetaMask/core/pull/8912)) +- Bump `@metamask/delegation-controller` from `^3.0.0` to `^3.0.1` ([#8912](https://github.com/MetaMask/core/pull/8912)) + +## [2.0.2] + +### Changed + +- Bump `@metamask/authenticated-user-storage` from `^1.0.1` to `^2.0.0` ([#8802](https://github.com/MetaMask/core/pull/8802)) + +## [2.0.1] + +### Changed + +- Bump `@metamask/network-controller` from `^31.1.0` to `^32.0.0` ([#8774](https://github.com/MetaMask/core/pull/8774)) + +## [2.0.0] + +### Added + +- Add remaining steps in money account upgrade process ([#8621](https://github.com/MetaMask/core/pull/8621)) + +### Changed + +- **BREAKING:** The controller messenger now requires access to six additional allowed actions: `AuthenticatedUserStorageService:listDelegations`, `AuthenticatedUserStorageService:createDelegation`, `ChompApiService:verifyDelegation`, `ChompApiService:getIntentsByAddress`, `ChompApiService:createIntents`, and `DelegationController:signDelegation`. Delegation signing is now delegated to `@metamask/delegation-controller` rather than calling `KeyringController:signTypedMessage` directly; consumers must instantiate `DelegationController` and update their messenger configuration accordingly. ([#8621](https://github.com/MetaMask/core/pull/8621)) +- **BREAKING:** `init()` now takes a `{ chainId, boringVaultAddress }` object instead of an `InitConfig`. The EIP-7702 delegator implementation and caveat enforcer addresses are resolved from `@metamask/delegation-deployments` for the target chain; `init()` throws if the chain is not supported by Delegation Framework 1.3.0. The `InitConfig` type is no longer exported. ([#8621](https://github.com/MetaMask/core/pull/8621)) +- Add `@metamask/authenticated-user-storage`, `@metamask/delegation-controller`, `@metamask/delegation-core`, and `@metamask/delegation-deployments` as dependencies. ([#8621](https://github.com/MetaMask/core/pull/8621)) +- Bump `@metamask/network-controller` from `^31.0.0` to `^31.1.0` ([#8765](https://github.com/MetaMask/core/pull/8765)) +- Bump `@metamask/chomp-api-service` from `^3.0.1` to `^3.1.0` ([#8769](https://github.com/MetaMask/core/pull/8769)) + +### Fixed + +- Build-delegation step no longer emits a redundant duplicate `ValueLteEnforcer` caveat; the Delegation Framework treats both as equivalent, but the duplicate was inadvertently inherited from `@metamask/smart-accounts-kit`'s `erc20TransferAmount` scope helper. ([#8621](https://github.com/MetaMask/core/pull/8621)) +- EIP-7702 authorization step now treats a 409 response from `POST /v1/account-upgrade` as `already-done` instead of a fatal error, making the step retry-safe when a prior submission was accepted by CHOMP but has not yet been observed on-chain. ([#8621](https://github.com/MetaMask/core/pull/8621)) + +## [1.3.2] + +### Changed + +- Bump `@metamask/keyring-controller` from `^25.4.0` to `^25.5.0` ([#8722](https://github.com/MetaMask/core/pull/8722)) +- Bump `@metamask/chomp-api-service` from `^3.0.0` to `^3.0.1` ([#8755](https://github.com/MetaMask/core/pull/8755)) +- Bump `@metamask/network-controller` from `^30.1.0` to `^31.0.0` ([#8755](https://github.com/MetaMask/core/pull/8755)) + +## [1.3.1] + +### Changed + +- Bump `@metamask/keyring-controller` from `^25.3.0` to `^25.4.0` ([#8665](https://github.com/MetaMask/core/pull/8665)) + +### Fixed + +- Fix the ChompApiService:createUpgrade call in the EIP-7702 auth step, pasing correct arguments ([#8657](https://github.com/MetaMask/core/pull/8657)) + +## [1.3.0] + +### Changed + +- Bump `@metamask/chomp-api-service` from `^2.0.0` to `^3.0.0` ([#8651](https://github.com/MetaMask/core/pull/8651)) +- Bump `@metamask/messenger` from `^1.1.1` to `^1.2.0` ([#8632](https://github.com/MetaMask/core/pull/8632)) +- Bump `@metamask/keyring-controller` from `^25.2.0` to `^25.3.0` ([#8634](https://github.com/MetaMask/core/pull/8634)) +- Bump `@metamask/network-controller` from `^30.0.1` to `^30.1.0` ([#8636](https://github.com/MetaMask/core/pull/8636)) + +### Fixed + +- Fix the associate-address step to detect the already-associated case via `status: 'active'`. ([#8635](https://github.com/MetaMask/core/pull/8635)) + +## [1.2.0] + +### Changed + +- Bump `@metamask/chomp-api-service` from `^1.0.0` to `^2.0.0` ([#8618](https://github.com/MetaMask/core/pull/8618)) + +### Fixed + +- Send the CHOMP authentication timestamp as a number instead of a string in the associate-address step. ([#8610](https://github.com/MetaMask/core/pull/8610)) + +## [1.1.0] + +### Added + +- Add EIP-7702 authorization step to the upgrade sequence. ([#8565](https://github.com/MetaMask/core/pull/8565)) + +## [1.0.0] + +### Added + +- Add `MoneyAccountUpgradeController` with `upgradeAccount` method ([#8426](https://github.com/MetaMask/core/pull/8426)) + +[Unreleased]: https://github.com/MetaMask/core/compare/@metamask/money-account-upgrade-controller@3.0.2...HEAD +[3.0.2]: https://github.com/MetaMask/core/compare/@metamask/money-account-upgrade-controller@3.0.1...@metamask/money-account-upgrade-controller@3.0.2 +[3.0.1]: https://github.com/MetaMask/core/compare/@metamask/money-account-upgrade-controller@3.0.0...@metamask/money-account-upgrade-controller@3.0.1 +[3.0.0]: https://github.com/MetaMask/core/compare/@metamask/money-account-upgrade-controller@2.2.1...@metamask/money-account-upgrade-controller@3.0.0 +[2.2.1]: https://github.com/MetaMask/core/compare/@metamask/money-account-upgrade-controller@2.2.0...@metamask/money-account-upgrade-controller@2.2.1 +[2.2.0]: https://github.com/MetaMask/core/compare/@metamask/money-account-upgrade-controller@2.1.0...@metamask/money-account-upgrade-controller@2.2.0 +[2.1.0]: https://github.com/MetaMask/core/compare/@metamask/money-account-upgrade-controller@2.0.5...@metamask/money-account-upgrade-controller@2.1.0 +[2.0.5]: https://github.com/MetaMask/core/compare/@metamask/money-account-upgrade-controller@2.0.4...@metamask/money-account-upgrade-controller@2.0.5 +[2.0.4]: https://github.com/MetaMask/core/compare/@metamask/money-account-upgrade-controller@2.0.3...@metamask/money-account-upgrade-controller@2.0.4 +[2.0.3]: https://github.com/MetaMask/core/compare/@metamask/money-account-upgrade-controller@2.0.2...@metamask/money-account-upgrade-controller@2.0.3 +[2.0.2]: https://github.com/MetaMask/core/compare/@metamask/money-account-upgrade-controller@2.0.1...@metamask/money-account-upgrade-controller@2.0.2 +[2.0.1]: https://github.com/MetaMask/core/compare/@metamask/money-account-upgrade-controller@2.0.0...@metamask/money-account-upgrade-controller@2.0.1 +[2.0.0]: https://github.com/MetaMask/core/compare/@metamask/money-account-upgrade-controller@1.3.2...@metamask/money-account-upgrade-controller@2.0.0 +[1.3.2]: https://github.com/MetaMask/core/compare/@metamask/money-account-upgrade-controller@1.3.1...@metamask/money-account-upgrade-controller@1.3.2 +[1.3.1]: https://github.com/MetaMask/core/compare/@metamask/money-account-upgrade-controller@1.3.0...@metamask/money-account-upgrade-controller@1.3.1 +[1.3.0]: https://github.com/MetaMask/core/compare/@metamask/money-account-upgrade-controller@1.2.0...@metamask/money-account-upgrade-controller@1.3.0 +[1.2.0]: https://github.com/MetaMask/core/compare/@metamask/money-account-upgrade-controller@1.1.0...@metamask/money-account-upgrade-controller@1.2.0 +[1.1.0]: https://github.com/MetaMask/core/compare/@metamask/money-account-upgrade-controller@1.0.0...@metamask/money-account-upgrade-controller@1.1.0 +[1.0.0]: https://github.com/MetaMask/core/releases/tag/@metamask/money-account-upgrade-controller@1.0.0 diff --git a/packages/money-account-upgrade-controller/LICENSE b/packages/money-account-upgrade-controller/LICENSE new file mode 100644 index 00000000000..fe29e78e0fe --- /dev/null +++ b/packages/money-account-upgrade-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/money-account-upgrade-controller/README.md b/packages/money-account-upgrade-controller/README.md new file mode 100644 index 00000000000..dde58824dd1 --- /dev/null +++ b/packages/money-account-upgrade-controller/README.md @@ -0,0 +1,15 @@ +# `@metamask/money-account-upgrade-controller` + +MetaMask Money account upgrade controller. + +## Installation + +`yarn add @metamask/money-account-upgrade-controller` + +or + +`npm install @metamask/money-account-upgrade-controller` + +## Contributing + +This package is part of a monorepo. Instructions for contributing can be found in the [monorepo README](https://github.com/MetaMask/core#readme). diff --git a/packages/money-account-upgrade-controller/jest.config.js b/packages/money-account-upgrade-controller/jest.config.js new file mode 100644 index 00000000000..f5ba61687a0 --- /dev/null +++ b/packages/money-account-upgrade-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, + coverageThreshold: { + global: { + branches: 100, + functions: 100, + lines: 100, + statements: 100, + }, + }, + testEnvironment: '/jest.environment.js', +}); diff --git a/packages/money-account-upgrade-controller/jest.environment.js b/packages/money-account-upgrade-controller/jest.environment.js new file mode 100644 index 00000000000..a2ab37baeba --- /dev/null +++ b/packages/money-account-upgrade-controller/jest.environment.js @@ -0,0 +1,18 @@ +const { TestEnvironment } = require('jest-environment-node'); + +/** + * Some transitive dependencies rely on the Web Crypto API, which is not + * exposed as a global by jest-environment-node. + */ +class CustomTestEnvironment extends TestEnvironment { + async setup() { + await super.setup(); + if (typeof this.global.crypto === 'undefined') { + // Only used for testing. + // eslint-disable-next-line n/no-unsupported-features/node-builtins + this.global.crypto = require('crypto').webcrypto; + } + } +} + +module.exports = CustomTestEnvironment; diff --git a/packages/money-account-upgrade-controller/package.json b/packages/money-account-upgrade-controller/package.json new file mode 100644 index 00000000000..cbd02224142 --- /dev/null +++ b/packages/money-account-upgrade-controller/package.json @@ -0,0 +1,85 @@ +{ + "name": "@metamask/money-account-upgrade-controller", + "version": "3.0.2", + "description": "MetaMask Money account upgrade controller", + "keywords": [ + "Ethereum", + "MetaMask" + ], + "homepage": "https://github.com/MetaMask/core/tree/main/packages/money-account-upgrade-controller#readme", + "bugs": { + "url": "https://github.com/MetaMask/core/issues" + }, + "license": "MIT", + "repository": { + "type": "git", + "url": "https://github.com/MetaMask/core.git" + }, + "files": [ + "dist/" + ], + "sideEffects": false, + "main": "./dist/index.cjs", + "types": "./dist/index.d.cts", + "exports": { + ".": { + "import": { + "types": "./dist/index.d.mts", + "default": "./dist/index.mjs" + }, + "require": { + "types": "./dist/index.d.cts", + "default": "./dist/index.cjs" + } + }, + "./package.json": "./package.json" + }, + "publishConfig": { + "access": "public", + "registry": "https://registry.npmjs.org/" + }, + "scripts": { + "build": "ts-bridge --project tsconfig.build.json --verbose --clean --no-references", + "build:all": "ts-bridge --project tsconfig.build.json --verbose --clean", + "build:docs": "typedoc", + "changelog:update": "../../scripts/update-changelog.sh @metamask/money-account-upgrade-controller", + "changelog:validate": "../../scripts/validate-changelog.sh @metamask/money-account-upgrade-controller", + "lint:tsconfigs": "tsx ../../scripts/lint-tsconfigs/lint-tsconfigs.mts", + "lint:tsconfigs:fix": "tsx ../../scripts/lint-tsconfigs/lint-tsconfigs.mts --fix", + "messenger-action-types:check": "tsx ../../packages/messenger-cli/src/cli.ts --formatter oxfmt --esm --check", + "messenger-action-types:generate": "tsx ../../packages/messenger-cli/src/cli.ts --formatter oxfmt --esm --generate", + "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/authenticated-user-storage": "^3.0.2", + "@metamask/base-controller": "^9.1.0", + "@metamask/chomp-api-service": "^4.0.1", + "@metamask/delegation-controller": "^3.0.2", + "@metamask/delegation-core": "^2.2.1", + "@metamask/delegation-deployments": "^1.4.0", + "@metamask/keyring-controller": "^27.1.1", + "@metamask/messenger": "^2.0.0", + "@metamask/network-controller": "^36.0.0", + "@metamask/utils": "^11.11.0" + }, + "devDependencies": { + "@metamask/auto-changelog": "^6.1.0", + "@ts-bridge/cli": "^0.6.4", + "@types/jest": "^30.0.0", + "deepmerge": "^4.2.2", + "jest": "^30.4.2", + "jest-environment-node": "^30.4.1", + "ts-jest": "^29.4.11", + "tsx": "^4.20.5", + "typedoc": "^0.25.13", + "typedoc-plugin-missing-exports": "^2.0.0", + "typescript": "~5.3.3" + }, + "engines": { + "node": "^18.18 || >=20" + } +} diff --git a/packages/money-account-upgrade-controller/src/MoneyAccountUpgradeController-method-action-types.ts b/packages/money-account-upgrade-controller/src/MoneyAccountUpgradeController-method-action-types.ts new file mode 100644 index 00000000000..7aa0061afdc --- /dev/null +++ b/packages/money-account-upgrade-controller/src/MoneyAccountUpgradeController-method-action-types.ts @@ -0,0 +1,33 @@ +/** + * This file is auto generated. + * Do not edit manually. + */ + +import type { MoneyAccountUpgradeController } from './MoneyAccountUpgradeController.js'; + +/** + * Runs each step in the upgrade sequence in order. A step that reports + * `'already-done'` is skipped without performing any action; a step that + * reports `'completed'` has performed its action. An error thrown by any + * step halts the sequence and is re-thrown wrapped in a + * {@link MoneyAccountUpgradeStepError} that records which step failed (the + * original error is preserved as `cause`). + * + * A run that completes is recorded in state (keyed by lowercased address, + * fingerprinted against the active config); subsequent calls for a + * recorded account return immediately without running any steps. If the + * active config no longer matches the recorded fingerprint, the sequence + * re-runs. + * + * @param address - The Money Account address to upgrade. + */ +export type MoneyAccountUpgradeControllerUpgradeAccountAction = { + type: `MoneyAccountUpgradeController:upgradeAccount`; + handler: MoneyAccountUpgradeController['upgradeAccount']; +}; + +/** + * Union of all MoneyAccountUpgradeController action types. + */ +export type MoneyAccountUpgradeControllerMethodActions = + MoneyAccountUpgradeControllerUpgradeAccountAction; diff --git a/packages/money-account-upgrade-controller/src/MoneyAccountUpgradeController.test.ts b/packages/money-account-upgrade-controller/src/MoneyAccountUpgradeController.test.ts new file mode 100644 index 00000000000..50f9932e884 --- /dev/null +++ b/packages/money-account-upgrade-controller/src/MoneyAccountUpgradeController.test.ts @@ -0,0 +1,737 @@ +import { DELEGATOR_CONTRACTS } from '@metamask/delegation-deployments'; +import { Messenger, MOCK_ANY_NAMESPACE } from '@metamask/messenger'; +import type { + MockAnyNamespace, + MessengerActions, + MessengerEvents, +} from '@metamask/messenger'; +import { hexToNumber } from '@metamask/utils'; +import type { Hex } from '@metamask/utils'; + +import type { + MoneyAccountUpgradeControllerMessenger, + MoneyAccountUpgradeControllerState, + MoneyAccountUpgradeStepError, +} from './index.js'; +import { + MoneyAccountUpgradeController, + getDefaultMoneyAccountUpgradeControllerState, + isMoneyAccountUpgradeStepError, + isTerminalMoneyAccountUpgradeError, +} from './index.js'; + +const MOCK_CHAIN_ID = '0x1' as Hex; // mainnet, supported in delegation-deployments@1.3.0 +const UNSUPPORTED_CHAIN_ID = '0x539' as Hex; // 1337 — local dev, not in registry +const MOCK_ACCOUNT_ADDRESS = + '0xabcdef1234567890abcdef1234567890abcdef12' as Hex; +const MOCK_BORING_VAULT_ADDRESS = + '0xA20f97813014129E7609171d2D3AA3da5206259e' as Hex; + +// CHOMP-API-derived values. +const MOCK_DELEGATE_ADDRESS = + '0x1111111111111111111111111111111111111111' as Hex; +const MOCK_MUSD_TOKEN_ADDRESS = + '0x3333333333333333333333333333333333333333' as Hex; +const MOCK_VEDA_VAULT_ADAPTER_ADDRESS = + '0x4444444444444444444444444444444444444444' as Hex; + +// Delegation Framework deployment for mainnet @ 1.3.0 — the controller resolves +// these from `@metamask/delegation-deployments` rather than accepting them via +// `init()`. We re-read from the same source here so the test does not drift if +// the deployment registry is bumped. +const MAINNET_CONTRACTS = + DELEGATOR_CONTRACTS['1.3.0'][hexToNumber(MOCK_CHAIN_ID)]; + +const MOCK_SERVICE_DETAILS_RESPONSE = { + auth: { message: 'CHOMP Authentication' }, + chains: { + [MOCK_CHAIN_ID]: { + autoDepositDelegate: MOCK_DELEGATE_ADDRESS, + protocol: { + vedaProtocol: { + supportedTokens: [ + { + tokenAddress: MOCK_MUSD_TOKEN_ADDRESS, + tokenDecimals: 18, + }, + ], + adapterAddress: MOCK_VEDA_VAULT_ADAPTER_ADDRESS, + intentTypes: ['cash-deposit', 'cash-withdrawal'] as const, + }, + }, + }, + }, +}; + +type AllActions = MessengerActions; + +type AllEvents = MessengerEvents; + +type RootMessenger = Messenger; + +type Mocks = { + getServiceDetails: jest.Mock; + signPersonalMessage: jest.Mock; + associateAddress: jest.Mock; + getAssociatedAddresses: jest.Mock; + createUpgrade: jest.Mock; + signEip7702Authorization: jest.Mock; + findNetworkClientIdByChainId: jest.Mock; + getNetworkClientById: jest.Mock; + providerRequest: jest.Mock; + listDelegations: jest.Mock; + createDelegation: jest.Mock; + signDelegation: jest.Mock; + verifyDelegation: jest.Mock; + getIntentsByAddress: jest.Mock; + createIntents: jest.Mock; +}; + +function setup({ + state, +}: { + state?: Partial; +} = {}): { + controller: MoneyAccountUpgradeController; + rootMessenger: RootMessenger; + messenger: MoneyAccountUpgradeControllerMessenger; + mocks: Mocks; +} { + // 65-byte signature — r (32 bytes) + s (32 bytes) + v = 0x1c (28). + const signature = `0x${'1'.repeat(64)}${'2'.repeat(64)}1c`; + + // Default provider responses: account is a plain EOA with nonce 0. + const providerRequest = jest + .fn() + .mockImplementation(async ({ method }: { method: string }) => { + if (method === 'eth_getCode') { + return '0x'; + } + if (method === 'eth_getTransactionCount') { + return '0x0'; + } + throw new Error(`Unexpected RPC method: ${method}`); + }); + + const mocks: Mocks = { + getServiceDetails: jest + .fn() + .mockResolvedValue(MOCK_SERVICE_DETAILS_RESPONSE), + signPersonalMessage: jest.fn().mockResolvedValue('0xdeadbeef'), + associateAddress: jest.fn().mockResolvedValue({ + profileId: 'profile-1', + address: MOCK_ACCOUNT_ADDRESS, + status: 'created', + }), + getAssociatedAddresses: jest.fn().mockResolvedValue([]), + createUpgrade: jest.fn().mockResolvedValue({ + signerAddress: MOCK_ACCOUNT_ADDRESS, + address: MAINNET_CONTRACTS.EIP7702StatelessDeleGatorImpl, + chainId: MOCK_CHAIN_ID, + nonce: '0x0', + status: 'pending', + createdAt: '2026-04-21T12:00:00.000Z', + }), + signEip7702Authorization: jest.fn().mockResolvedValue(signature), + findNetworkClientIdByChainId: jest + .fn() + .mockReturnValue('network-client-id'), + getNetworkClientById: jest.fn().mockReturnValue({ + provider: { request: providerRequest }, + }), + providerRequest, + listDelegations: jest.fn().mockResolvedValue([]), + createDelegation: jest.fn().mockResolvedValue(undefined), + signDelegation: jest.fn().mockResolvedValue(`0x${'cd'.repeat(65)}`), + verifyDelegation: jest.fn().mockResolvedValue({ valid: true }), + getIntentsByAddress: jest.fn().mockResolvedValue([]), + createIntents: jest.fn().mockResolvedValue([]), + }; + + const rootMessenger = new Messenger({ + namespace: MOCK_ANY_NAMESPACE, + }); + + rootMessenger.registerActionHandler( + 'ChompApiService:getServiceDetails', + mocks.getServiceDetails, + ); + rootMessenger.registerActionHandler( + 'KeyringController:signPersonalMessage', + mocks.signPersonalMessage, + ); + rootMessenger.registerActionHandler( + 'ChompApiService:associateAddress', + mocks.associateAddress, + ); + rootMessenger.registerActionHandler( + 'ChompApiService:getAssociatedAddresses', + mocks.getAssociatedAddresses, + ); + rootMessenger.registerActionHandler( + 'ChompApiService:createUpgrade', + mocks.createUpgrade, + ); + rootMessenger.registerActionHandler( + 'KeyringController:signEip7702Authorization', + mocks.signEip7702Authorization, + ); + rootMessenger.registerActionHandler( + 'NetworkController:findNetworkClientIdByChainId', + mocks.findNetworkClientIdByChainId, + ); + rootMessenger.registerActionHandler( + 'NetworkController:getNetworkClientById', + mocks.getNetworkClientById, + ); + rootMessenger.registerActionHandler( + 'AuthenticatedUserStorageService:listDelegations', + mocks.listDelegations, + ); + rootMessenger.registerActionHandler( + 'AuthenticatedUserStorageService:createDelegation', + mocks.createDelegation, + ); + rootMessenger.registerActionHandler( + 'DelegationController:signDelegation', + mocks.signDelegation, + ); + rootMessenger.registerActionHandler( + 'ChompApiService:verifyDelegation', + mocks.verifyDelegation, + ); + rootMessenger.registerActionHandler( + 'ChompApiService:getIntentsByAddress', + mocks.getIntentsByAddress, + ); + rootMessenger.registerActionHandler( + 'ChompApiService:createIntents', + mocks.createIntents, + ); + + const messenger: MoneyAccountUpgradeControllerMessenger = new Messenger({ + namespace: 'MoneyAccountUpgradeController', + parent: rootMessenger, + }); + + rootMessenger.delegate({ + actions: [ + 'ChompApiService:getServiceDetails', + 'KeyringController:signPersonalMessage', + 'ChompApiService:associateAddress', + 'ChompApiService:getAssociatedAddresses', + 'ChompApiService:createUpgrade', + 'KeyringController:signEip7702Authorization', + 'NetworkController:findNetworkClientIdByChainId', + 'NetworkController:getNetworkClientById', + 'AuthenticatedUserStorageService:listDelegations', + 'AuthenticatedUserStorageService:createDelegation', + 'DelegationController:signDelegation', + 'ChompApiService:verifyDelegation', + 'ChompApiService:getIntentsByAddress', + 'ChompApiService:createIntents', + ], + events: [], + messenger, + }); + + const controller = new MoneyAccountUpgradeController({ + messenger, + state, + }); + + return { controller, rootMessenger, messenger, mocks }; +} + +/** + * Resets the call history of every mock in the bag, preserving their + * configured implementations. Useful for asserting that a later + * `upgradeAccount` call performs no work. + * + * @param mocks - The mocks bag from `setup`. + */ +function clearMockCalls(mocks: Mocks): void { + for (const mock of Object.values(mocks)) { + mock.mockClear(); + } +} + +describe('MoneyAccountUpgradeController', () => { + describe('constructor', () => { + it('does not make async init calls when constructed', () => { + const { mocks } = setup(); + + expect(mocks.getServiceDetails).not.toHaveBeenCalled(); + }); + + it('starts with the default empty state', () => { + const { controller } = setup(); + + expect(controller.state).toStrictEqual( + getDefaultMoneyAccountUpgradeControllerState(), + ); + expect(controller.state.upgradedAccounts).toStrictEqual({}); + }); + + it('merges provided partial state with the defaults', () => { + const status = { configFingerprint: 'fingerprint', completedAt: 123 }; + + const { controller } = setup({ + state: { upgradedAccounts: { [MOCK_ACCOUNT_ADDRESS]: status } }, + }); + + expect( + controller.state.upgradedAccounts[MOCK_ACCOUNT_ADDRESS], + ).toStrictEqual(status); + }); + }); + + describe('init', () => { + it('fetches service details and builds config', async () => { + const { controller, mocks } = setup(); + + await controller.init({ + chainId: MOCK_CHAIN_ID, + boringVaultAddress: MOCK_BORING_VAULT_ADDRESS, + }); + + expect(mocks.getServiceDetails).toHaveBeenCalledWith([MOCK_CHAIN_ID]); + }); + + it('throws when the chain has no Delegation Framework deployment', async () => { + const { controller, mocks } = setup(); + + await expect( + controller.init({ + chainId: UNSUPPORTED_CHAIN_ID, + boringVaultAddress: MOCK_BORING_VAULT_ADDRESS, + }), + ).rejects.toThrow( + `Delegation Framework 1.3.0 is not deployed on chain ${UNSUPPORTED_CHAIN_ID}`, + ); + expect(mocks.getServiceDetails).not.toHaveBeenCalled(); + }); + + it('uses the supplied boring vault address as the withdrawal-side delegation token', async () => { + const { controller, mocks } = setup(); + + await controller.init({ + chainId: MOCK_CHAIN_ID, + boringVaultAddress: MOCK_BORING_VAULT_ADDRESS, + }); + await controller.upgradeAccount(MOCK_ACCOUNT_ADDRESS); + + // Both delegations were signed; the boring-vault address shows up in the + // ABI-encoded ERC20TransferAmount caveat terms of one of them. + expect(mocks.signDelegation).toHaveBeenCalledTimes(2); + const allCaveatTerms = mocks.verifyDelegation.mock.calls + .flatMap(([{ signedDelegation }]) => signedDelegation.caveats) + .map((caveat) => caveat.terms.toLowerCase()); + expect( + allCaveatTerms.some((terms) => + terms.includes(MOCK_BORING_VAULT_ADDRESS.toLowerCase().slice(2)), + ), + ).toBe(true); + }); + + it('throws when the chain is not found in service details', async () => { + const { controller, mocks } = setup(); + + mocks.getServiceDetails.mockResolvedValue({ + auth: { message: 'CHOMP Authentication' }, + chains: {}, + }); + + await expect( + controller.init({ + chainId: MOCK_CHAIN_ID, + boringVaultAddress: MOCK_BORING_VAULT_ADDRESS, + }), + ).rejects.toThrow( + `Chain ${MOCK_CHAIN_ID} not found in service details response`, + ); + }); + + it('throws when vedaProtocol is not found', async () => { + const { controller, mocks } = setup(); + + mocks.getServiceDetails.mockResolvedValue({ + auth: { message: 'CHOMP Authentication' }, + chains: { + [MOCK_CHAIN_ID]: { + autoDepositDelegate: MOCK_DELEGATE_ADDRESS, + protocol: {}, + }, + }, + }); + + await expect( + controller.init({ + chainId: MOCK_CHAIN_ID, + boringVaultAddress: MOCK_BORING_VAULT_ADDRESS, + }), + ).rejects.toThrow( + `vedaProtocol not found for chain ${MOCK_CHAIN_ID} in service details response`, + ); + }); + + it('throws when supportedTokens is empty', async () => { + const { controller, mocks } = setup(); + + mocks.getServiceDetails.mockResolvedValue({ + auth: { message: 'CHOMP Authentication' }, + chains: { + [MOCK_CHAIN_ID]: { + autoDepositDelegate: MOCK_DELEGATE_ADDRESS, + protocol: { + vedaProtocol: { + supportedTokens: [], + adapterAddress: MOCK_VEDA_VAULT_ADAPTER_ADDRESS, + intentTypes: ['cash-deposit', 'cash-withdrawal'], + }, + }, + }, + }, + }); + + await expect( + controller.init({ + chainId: MOCK_CHAIN_ID, + boringVaultAddress: MOCK_BORING_VAULT_ADDRESS, + }), + ).rejects.toThrow( + `No supported tokens found for vedaProtocol on chain ${MOCK_CHAIN_ID}`, + ); + }); + }); + + describe('upgradeAccount', () => { + it('throws when called before init', async () => { + const { controller } = setup(); + + await expect( + controller.upgradeAccount(MOCK_ACCOUNT_ADDRESS), + ).rejects.toThrow( + 'MoneyAccountUpgradeController must be initialized via init() before upgradeAccount() can be called', + ); + }); + + it('throws when a previous init attempt failed', async () => { + const { controller, mocks } = setup(); + mocks.getServiceDetails.mockResolvedValueOnce({ + auth: { message: 'CHOMP Authentication' }, + chains: {}, + }); + await expect( + controller.init({ + chainId: MOCK_CHAIN_ID, + boringVaultAddress: MOCK_BORING_VAULT_ADDRESS, + }), + ).rejects.toThrow('Chain 0x1 not found in service details response'); + + await expect( + controller.upgradeAccount(MOCK_ACCOUNT_ADDRESS), + ).rejects.toThrow( + 'MoneyAccountUpgradeController must be initialized via init() before upgradeAccount() can be called', + ); + }); + + it('runs each step against the deployment-derived contract addresses', async () => { + const { controller, mocks } = setup(); + await controller.init({ + chainId: MOCK_CHAIN_ID, + boringVaultAddress: MOCK_BORING_VAULT_ADDRESS, + }); + + await controller.upgradeAccount(MOCK_ACCOUNT_ADDRESS); + + expect(mocks.signPersonalMessage).toHaveBeenCalledWith( + expect.objectContaining({ from: MOCK_ACCOUNT_ADDRESS }), + ); + expect(mocks.associateAddress).toHaveBeenCalledWith( + expect.objectContaining({ address: MOCK_ACCOUNT_ADDRESS }), + ); + expect(mocks.signEip7702Authorization).toHaveBeenCalledWith( + expect.objectContaining({ + from: MOCK_ACCOUNT_ADDRESS, + contractAddress: MAINNET_CONTRACTS.EIP7702StatelessDeleGatorImpl, + }), + ); + expect(mocks.createUpgrade).toHaveBeenCalledWith( + expect.objectContaining({ + address: MAINNET_CONTRACTS.EIP7702StatelessDeleGatorImpl, + chainId: MOCK_CHAIN_ID, + nonce: '0x0', + }), + ); + }); + + it('is callable via the messenger', async () => { + const { controller, rootMessenger } = setup(); + await controller.init({ + chainId: MOCK_CHAIN_ID, + boringVaultAddress: MOCK_BORING_VAULT_ADDRESS, + }); + + expect( + await rootMessenger.call( + 'MoneyAccountUpgradeController:upgradeAccount', + MOCK_ACCOUNT_ADDRESS, + ), + ).toBeUndefined(); + }); + + it('propagates errors thrown by a step', async () => { + const { controller, mocks } = setup(); + await controller.init({ + chainId: MOCK_CHAIN_ID, + boringVaultAddress: MOCK_BORING_VAULT_ADDRESS, + }); + mocks.signPersonalMessage.mockRejectedValue(new Error('signing failed')); + + await expect( + controller.upgradeAccount(MOCK_ACCOUNT_ADDRESS), + ).rejects.toThrow('signing failed'); + }); + + it('wraps a step failure in a MoneyAccountUpgradeStepError that records the step and cause', async () => { + const { controller, mocks } = setup(); + await controller.init({ + chainId: MOCK_CHAIN_ID, + boringVaultAddress: MOCK_BORING_VAULT_ADDRESS, + }); + const cause = new Error('signing failed'); + // The associate-address step (first in the sequence) signs a personal + // message before calling CHOMP, so failing this surfaces that step. + mocks.signPersonalMessage.mockRejectedValue(cause); + + const error = await controller + .upgradeAccount(MOCK_ACCOUNT_ADDRESS) + .catch((thrown: unknown) => thrown); + + expect(isMoneyAccountUpgradeStepError(error)).toBe(true); + expect(error).toMatchObject({ + step: 'associate-address', + cause, + }); + expect((error as MoneyAccountUpgradeStepError).message).toBe( + 'Money Account upgrade failed at step "associate-address": signing failed', + ); + }); + + it('records the name of the specific step that failed', async () => { + const { controller, mocks } = setup(); + await controller.init({ + chainId: MOCK_CHAIN_ID, + boringVaultAddress: MOCK_BORING_VAULT_ADDRESS, + }); + // The first step (associate-address) passes; fail at the second step + // (eip-7702-authorization), which signs the authorization. + mocks.signEip7702Authorization.mockRejectedValue( + new Error('authorization rejected'), + ); + + const error = await controller + .upgradeAccount(MOCK_ACCOUNT_ADDRESS) + .catch((thrown: unknown) => thrown); + + expect(error).toMatchObject({ step: 'eip-7702-authorization' }); + }); + + it('wraps a non-Error thrown by a step, stringifying it as the cause message', async () => { + const { controller, mocks } = setup(); + await controller.init({ + chainId: MOCK_CHAIN_ID, + boringVaultAddress: MOCK_BORING_VAULT_ADDRESS, + }); + mocks.signPersonalMessage.mockRejectedValue('plain string failure'); + + const error = await controller + .upgradeAccount(MOCK_ACCOUNT_ADDRESS) + .catch((thrown: unknown) => thrown); + + expect(error).toMatchObject({ + step: 'associate-address', + cause: 'plain string failure', + }); + expect((error as MoneyAccountUpgradeStepError).message).toBe( + 'Money Account upgrade failed at step "associate-address": plain string failure', + ); + }); + + it('marks the failure terminal when the account is delegated to another implementation', async () => { + const { controller, mocks } = setup(); + await controller.init({ + chainId: MOCK_CHAIN_ID, + boringVaultAddress: MOCK_BORING_VAULT_ADDRESS, + }); + // EIP-7702 delegation code pointing at a third-party impl. + mocks.providerRequest.mockImplementation( + async ({ method }: { method: string }) => { + if (method === 'eth_getCode') { + return `0xef0100${'9'.repeat(40)}`; + } + return '0x0'; + }, + ); + + const error = await controller + .upgradeAccount(MOCK_ACCOUNT_ADDRESS) + .catch((thrown: unknown) => thrown); + + expect(isTerminalMoneyAccountUpgradeError(error)).toBe(true); + }); + + it('marks ordinary step failures as non-terminal', async () => { + const { controller, mocks } = setup(); + await controller.init({ + chainId: MOCK_CHAIN_ID, + boringVaultAddress: MOCK_BORING_VAULT_ADDRESS, + }); + mocks.signPersonalMessage.mockRejectedValue(new Error('network down')); + + const error = await controller + .upgradeAccount(MOCK_ACCOUNT_ADDRESS) + .catch((thrown: unknown) => thrown); + + expect(isMoneyAccountUpgradeStepError(error)).toBe(true); + expect(isTerminalMoneyAccountUpgradeError(error)).toBe(false); + }); + }); + + describe('upgrade status tracking', () => { + it('records a successful upgrade against the lowercased address', async () => { + const { controller, mocks } = setup(); + await controller.init({ + chainId: MOCK_CHAIN_ID, + boringVaultAddress: MOCK_BORING_VAULT_ADDRESS, + }); + const mixedCaseAddress = MOCK_ACCOUNT_ADDRESS.replace( + '0xabc', + '0xABC', + ) as Hex; + + await controller.upgradeAccount(mixedCaseAddress); + + expect(mocks.signPersonalMessage).toHaveBeenCalled(); + expect( + controller.state.upgradedAccounts[MOCK_ACCOUNT_ADDRESS], + ).toStrictEqual({ + configFingerprint: expect.any(String), + completedAt: expect.any(Number), + }); + }); + + it('skips the steps on a subsequent call for an already-upgraded account', async () => { + const { controller, mocks } = setup(); + await controller.init({ + chainId: MOCK_CHAIN_ID, + boringVaultAddress: MOCK_BORING_VAULT_ADDRESS, + }); + await controller.upgradeAccount(MOCK_ACCOUNT_ADDRESS); + clearMockCalls(mocks); + + await controller.upgradeAccount(MOCK_ACCOUNT_ADDRESS); + + expect(mocks.signPersonalMessage).not.toHaveBeenCalled(); + expect(mocks.providerRequest).not.toHaveBeenCalled(); + expect(mocks.listDelegations).not.toHaveBeenCalled(); + expect(mocks.getIntentsByAddress).not.toHaveBeenCalled(); + }); + + it('treats recorded upgrades case-insensitively', async () => { + const { controller, mocks } = setup(); + await controller.init({ + chainId: MOCK_CHAIN_ID, + boringVaultAddress: MOCK_BORING_VAULT_ADDRESS, + }); + await controller.upgradeAccount(MOCK_ACCOUNT_ADDRESS); + clearMockCalls(mocks); + + await controller.upgradeAccount( + MOCK_ACCOUNT_ADDRESS.replace('0xabc', '0xABC') as Hex, + ); + + expect(mocks.signPersonalMessage).not.toHaveBeenCalled(); + }); + + it('skips the steps when constructed with state from a previous successful upgrade', async () => { + const first = setup(); + await first.controller.init({ + chainId: MOCK_CHAIN_ID, + boringVaultAddress: MOCK_BORING_VAULT_ADDRESS, + }); + await first.controller.upgradeAccount(MOCK_ACCOUNT_ADDRESS); + + const second = setup({ state: first.controller.state }); + await second.controller.init({ + chainId: MOCK_CHAIN_ID, + boringVaultAddress: MOCK_BORING_VAULT_ADDRESS, + }); + await second.controller.upgradeAccount(MOCK_ACCOUNT_ADDRESS); + + expect(second.mocks.signPersonalMessage).not.toHaveBeenCalled(); + expect(second.mocks.providerRequest).not.toHaveBeenCalled(); + }); + + it('does not record the account when a step fails, and re-runs on the next call', async () => { + const { controller, mocks } = setup(); + await controller.init({ + chainId: MOCK_CHAIN_ID, + boringVaultAddress: MOCK_BORING_VAULT_ADDRESS, + }); + mocks.signPersonalMessage.mockRejectedValueOnce( + new Error('signing failed'), + ); + + await expect( + controller.upgradeAccount(MOCK_ACCOUNT_ADDRESS), + ).rejects.toThrow('signing failed'); + + expect(controller.state.upgradedAccounts).toStrictEqual({}); + + await controller.upgradeAccount(MOCK_ACCOUNT_ADDRESS); + + expect( + controller.state.upgradedAccounts[MOCK_ACCOUNT_ADDRESS], + ).toBeDefined(); + }); + + it('re-runs the sequence when the active config no longer matches the recorded fingerprint', async () => { + const { controller, mocks } = setup(); + await controller.init({ + chainId: MOCK_CHAIN_ID, + boringVaultAddress: MOCK_BORING_VAULT_ADDRESS, + }); + await controller.upgradeAccount(MOCK_ACCOUNT_ADDRESS); + const { configFingerprint: originalFingerprint } = + controller.state.upgradedAccounts[MOCK_ACCOUNT_ADDRESS]; + + // CHOMP rotates its delegate address — the recorded upgrade no longer + // reflects the active config. + mocks.getServiceDetails.mockResolvedValue({ + ...MOCK_SERVICE_DETAILS_RESPONSE, + chains: { + [MOCK_CHAIN_ID]: { + ...MOCK_SERVICE_DETAILS_RESPONSE.chains[MOCK_CHAIN_ID], + autoDepositDelegate: + '0x2222222222222222222222222222222222222222' as Hex, + }, + }, + }); + await controller.init({ + chainId: MOCK_CHAIN_ID, + boringVaultAddress: MOCK_BORING_VAULT_ADDRESS, + }); + clearMockCalls(mocks); + + await controller.upgradeAccount(MOCK_ACCOUNT_ADDRESS); + + expect(mocks.signPersonalMessage).toHaveBeenCalled(); + expect( + controller.state.upgradedAccounts[MOCK_ACCOUNT_ADDRESS] + .configFingerprint, + ).not.toBe(originalFingerprint); + }); + }); +}); diff --git a/packages/money-account-upgrade-controller/src/MoneyAccountUpgradeController.ts b/packages/money-account-upgrade-controller/src/MoneyAccountUpgradeController.ts new file mode 100644 index 00000000000..e912f408424 --- /dev/null +++ b/packages/money-account-upgrade-controller/src/MoneyAccountUpgradeController.ts @@ -0,0 +1,331 @@ +import type { + AuthenticatedUserStorageServiceCreateDelegationAction, + AuthenticatedUserStorageServiceListDelegationsAction, +} from '@metamask/authenticated-user-storage'; +import type { + ControllerGetStateAction, + ControllerStateChangedEvent, + StateMetadata, +} from '@metamask/base-controller'; +import { BaseController } from '@metamask/base-controller'; +import type { + ChompApiServiceAssociateAddressAction, + ChompApiServiceCreateIntentsAction, + ChompApiServiceCreateUpgradeAction, + ChompApiServiceGetAssociatedAddressesAction, + ChompApiServiceGetIntentsByAddressAction, + ChompApiServiceGetServiceDetailsAction, + ChompApiServiceVerifyDelegationAction, +} from '@metamask/chomp-api-service'; +import type { DelegationControllerSignDelegationAction } from '@metamask/delegation-controller'; +import { DELEGATOR_CONTRACTS } from '@metamask/delegation-deployments'; +import type { + KeyringControllerSignEip7702AuthorizationAction, + KeyringControllerSignPersonalMessageAction, +} from '@metamask/keyring-controller'; +import type { Messenger } from '@metamask/messenger'; +import type { + NetworkControllerFindNetworkClientIdByChainIdAction, + NetworkControllerGetNetworkClientByIdAction, +} from '@metamask/network-controller'; +import { hexToNumber } from '@metamask/utils'; +import type { Hex } from '@metamask/utils'; + +import { MoneyAccountUpgradeStepError } from './errors.js'; +import type { MoneyAccountUpgradeControllerMethodActions } from './MoneyAccountUpgradeController-method-action-types.js'; +import { associateAddressStep } from './steps/associate-address.js'; +import { buildDelegationStep } from './steps/build-delegations.js'; +import { eip7702AuthorizationStep } from './steps/eip-7702-authorization.js'; +import { registerIntentsStep } from './steps/register-intents.js'; +import type { Step } from './steps/step.js'; +import type { UpgradeConfig } from './types.js'; + +/** + * The Delegation Framework deployment version we resolve contract addresses + * against in `@metamask/delegation-deployments`. + */ +const DELEGATION_FRAMEWORK_VERSION = '1.3.0'; + +export const controllerName = 'MoneyAccountUpgradeController'; + +/** + * Record of a Money Account upgrade sequence that ran to completion. + */ +export type MoneyAccountUpgradeStatus = { + /** + * Fingerprint of the upgrade config the sequence completed under. The + * record is only trusted while the active config produces the same + * fingerprint — if the chain, CHOMP contracts, or Delegation Framework + * version change, the sequence re-runs. + */ + configFingerprint: string; + /** Unix timestamp (in milliseconds) when the sequence completed. */ + completedAt: number; +}; + +export type MoneyAccountUpgradeControllerState = { + /** + * Accounts whose upgrade sequence has fully completed, keyed by lowercased + * account address. + */ + upgradedAccounts: { [address: Hex]: MoneyAccountUpgradeStatus }; +}; + +const moneyAccountUpgradeControllerMetadata = { + upgradedAccounts: { + includeInDebugSnapshot: false, + includeInStateLogs: false, + persist: true, + usedInUi: false, + }, +} satisfies StateMetadata; + +/** + * Constructs the default {@link MoneyAccountUpgradeController} 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 MoneyAccountUpgradeController} state. + */ +export function getDefaultMoneyAccountUpgradeControllerState(): MoneyAccountUpgradeControllerState { + return { + upgradedAccounts: {}, + }; +} + +const MESSENGER_EXPOSED_METHODS = ['upgradeAccount'] as const; + +export type MoneyAccountUpgradeControllerGetStateAction = + ControllerGetStateAction< + typeof controllerName, + MoneyAccountUpgradeControllerState + >; + +export type MoneyAccountUpgradeControllerActions = + | MoneyAccountUpgradeControllerGetStateAction + | MoneyAccountUpgradeControllerMethodActions; + +type AllowedActions = + | AuthenticatedUserStorageServiceCreateDelegationAction + | AuthenticatedUserStorageServiceListDelegationsAction + | ChompApiServiceAssociateAddressAction + | ChompApiServiceCreateIntentsAction + | ChompApiServiceCreateUpgradeAction + | ChompApiServiceGetAssociatedAddressesAction + | ChompApiServiceGetIntentsByAddressAction + | ChompApiServiceGetServiceDetailsAction + | ChompApiServiceVerifyDelegationAction + | DelegationControllerSignDelegationAction + | KeyringControllerSignEip7702AuthorizationAction + | KeyringControllerSignPersonalMessageAction + | NetworkControllerFindNetworkClientIdByChainIdAction + | NetworkControllerGetNetworkClientByIdAction; + +export type MoneyAccountUpgradeControllerStateChangedEvent = + ControllerStateChangedEvent< + typeof controllerName, + MoneyAccountUpgradeControllerState + >; + +export type MoneyAccountUpgradeControllerEvents = + MoneyAccountUpgradeControllerStateChangedEvent; + +type AllowedEvents = never; + +export type MoneyAccountUpgradeControllerMessenger = Messenger< + typeof controllerName, + MoneyAccountUpgradeControllerActions | AllowedActions, + MoneyAccountUpgradeControllerEvents | AllowedEvents +>; + +/** + * Controller that orchestrates the Money Account upgrade sequence. + */ +export class MoneyAccountUpgradeController extends BaseController< + typeof controllerName, + MoneyAccountUpgradeControllerState, + MoneyAccountUpgradeControllerMessenger +> { + #config?: UpgradeConfig & { chainId: Hex }; + + readonly #steps: Step[] = [ + associateAddressStep, + eip7702AuthorizationStep, + buildDelegationStep, + registerIntentsStep, + ]; + + /** + * Constructor for the MoneyAccountUpgradeController. + * + * @param options - The options for constructing the controller. + * @param options.messenger - The messenger to use for inter-controller communication. + * @param options.state - The initial state, merged with the defaults. + */ + constructor({ + messenger, + state, + }: { + messenger: MoneyAccountUpgradeControllerMessenger; + state?: Partial; + }) { + super({ + messenger, + metadata: moneyAccountUpgradeControllerMetadata, + name: controllerName, + state: { + ...getDefaultMoneyAccountUpgradeControllerState(), + ...state, + }, + }); + + this.messenger.registerMethodActionHandlers( + this, + MESSENGER_EXPOSED_METHODS, + ); + } + + /** + * Fetches service details and validates the controller can operate on the + * given chain. Resolves the Delegation Framework contract addresses for the + * chain from `@metamask/delegation-deployments`. + * + * @param params - The parameters for initialization. + * @param params.chainId - The chain to initialize for. + * @param params.boringVaultAddress - The Veda boring vault contract + * (vmUSD) for the given chain. Used as the withdrawal-side delegation + * token. Supplied by the consumer until the CHOMP service-details API + * exposes it. + */ + async init({ + chainId, + boringVaultAddress, + }: { + chainId: Hex; + boringVaultAddress: Hex; + }): Promise { + const contracts = + DELEGATOR_CONTRACTS[DELEGATION_FRAMEWORK_VERSION][hexToNumber(chainId)]; + if (!contracts) { + throw new Error( + `Delegation Framework ${DELEGATION_FRAMEWORK_VERSION} is not deployed on chain ${chainId}`, + ); + } + + const response = await this.messenger.call( + 'ChompApiService:getServiceDetails', + [chainId], + ); + + const chain = response.chains[chainId]; + if (!chain) { + throw new Error(`Chain ${chainId} not found in service details response`); + } + + const { vedaProtocol } = chain.protocol; + if (!vedaProtocol) { + throw new Error( + `vedaProtocol not found for chain ${chainId} in service details response`, + ); + } + + if (vedaProtocol.supportedTokens.length === 0) { + throw new Error( + `No supported tokens found for vedaProtocol on chain ${chainId}`, + ); + } + + this.#config = { + chainId, + delegateAddress: chain.autoDepositDelegate, + musdTokenAddress: vedaProtocol.supportedTokens[0].tokenAddress, + boringVaultAddress, + vedaVaultAdapterAddress: vedaProtocol.adapterAddress, + delegatorImplAddress: contracts.EIP7702StatelessDeleGatorImpl, + erc20TransferAmountEnforcer: contracts.ERC20TransferAmountEnforcer, + redeemerEnforcer: contracts.RedeemerEnforcer, + valueLteEnforcer: contracts.ValueLteEnforcer, + }; + } + + /** + * Runs each step in the upgrade sequence in order. A step that reports + * `'already-done'` is skipped without performing any action; a step that + * reports `'completed'` has performed its action. An error thrown by any + * step halts the sequence and is re-thrown wrapped in a + * {@link MoneyAccountUpgradeStepError} that records which step failed (the + * original error is preserved as `cause`). + * + * A run that completes is recorded in state (keyed by lowercased address, + * fingerprinted against the active config); subsequent calls for a + * recorded account return immediately without running any steps. If the + * active config no longer matches the recorded fingerprint, the sequence + * re-runs. + * + * @param address - The Money Account address to upgrade. + */ + async upgradeAccount(address: Hex): Promise { + if (!this.#config) { + throw new Error( + 'MoneyAccountUpgradeController must be initialized via init() before upgradeAccount() can be called', + ); + } + const config = this.#config; + + const accountKey = address.toLowerCase() as Hex; + const configFingerprint = computeConfigFingerprint(config); + if ( + this.state.upgradedAccounts[accountKey]?.configFingerprint === + configFingerprint + ) { + return; + } + + for (const step of this.#steps) { + try { + await step.run({ + messenger: this.messenger, + address, + ...config, + }); + } catch (error) { + throw new MoneyAccountUpgradeStepError(step.name, error); + } + } + + this.update((state) => { + state.upgradedAccounts[accountKey] = { + configFingerprint, + completedAt: Date.now(), + }; + }); + } +} + +/** + * Derives a stable fingerprint of the config fields that define what + * "upgraded" means for an account. A recorded upgrade is only trusted while + * the active config produces the same fingerprint. + * + * @param config - The active upgrade config. + * @returns A canonical string over the config's identifying fields. + */ +function computeConfigFingerprint( + config: UpgradeConfig & { chainId: Hex }, +): string { + return [ + DELEGATION_FRAMEWORK_VERSION, + config.chainId, + config.delegateAddress, + config.musdTokenAddress, + config.boringVaultAddress, + config.vedaVaultAdapterAddress, + config.delegatorImplAddress, + config.erc20TransferAmountEnforcer, + config.redeemerEnforcer, + config.valueLteEnforcer, + ] + .map((value) => value.toLowerCase()) + .join('|'); +} diff --git a/packages/money-account-upgrade-controller/src/errors.test.ts b/packages/money-account-upgrade-controller/src/errors.test.ts new file mode 100644 index 00000000000..e92ddea89fa --- /dev/null +++ b/packages/money-account-upgrade-controller/src/errors.test.ts @@ -0,0 +1,148 @@ +import { + MoneyAccountUpgradeStepError, + TerminalUpgradeError, + isMoneyAccountUpgradeStepError, + isTerminalMoneyAccountUpgradeError, +} from './errors.js'; + +describe('MoneyAccountUpgradeStepError', () => { + it('records the step name and preserves an Error cause', () => { + const cause = new Error('boom'); + + const error = new MoneyAccountUpgradeStepError('build-delegation', cause); + + expect(error).toBeInstanceOf(Error); + expect(error.name).toBe('MoneyAccountUpgradeStepError'); + expect(error.step).toBe('build-delegation'); + expect(error.cause).toBe(cause); + expect(error.message).toBe( + 'Money Account upgrade failed at step "build-delegation": boom', + ); + }); + + it('stringifies a non-Error cause in the message', () => { + const error = new MoneyAccountUpgradeStepError('register-intents', 42); + + expect(error.cause).toBe(42); + expect(error.message).toBe( + 'Money Account upgrade failed at step "register-intents": 42', + ); + }); + + it('is non-terminal when the cause is a plain Error', () => { + const error = new MoneyAccountUpgradeStepError( + 'associate-address', + new Error('network down'), + ); + + expect(error.terminal).toBe(false); + }); + + it('is terminal when the cause is a TerminalUpgradeError', () => { + const error = new MoneyAccountUpgradeStepError( + 'eip-7702-authorization', + new TerminalUpgradeError('delegated elsewhere'), + ); + + expect(error.terminal).toBe(true); + }); + + it('is terminal when the cause is a structurally-terminal error from another realm', () => { + const cause = new Error('delegated elsewhere'); + (cause as unknown as { terminal: boolean }).terminal = true; + + const error = new MoneyAccountUpgradeStepError( + 'eip-7702-authorization', + cause, + ); + + expect(error.terminal).toBe(true); + }); + + it('is non-terminal when the cause is a non-Error carrying a terminal property', () => { + const error = new MoneyAccountUpgradeStepError('associate-address', { + terminal: true, + }); + + expect(error.terminal).toBe(false); + }); +}); + +describe('TerminalUpgradeError', () => { + it('is an Error marked as terminal', () => { + const error = new TerminalUpgradeError('cannot recover'); + + expect(error).toBeInstanceOf(Error); + expect(error.name).toBe('TerminalUpgradeError'); + expect(error.message).toBe('cannot recover'); + expect(error.terminal).toBe(true); + }); +}); + +describe('isTerminalMoneyAccountUpgradeError', () => { + it('returns true for a step error with a terminal cause', () => { + expect( + isTerminalMoneyAccountUpgradeError( + new MoneyAccountUpgradeStepError( + 'eip-7702-authorization', + new TerminalUpgradeError('delegated elsewhere'), + ), + ), + ).toBe(true); + }); + + it('returns false for a step error with a non-terminal cause', () => { + expect( + isTerminalMoneyAccountUpgradeError( + new MoneyAccountUpgradeStepError('associate-address', new Error('x')), + ), + ).toBe(false); + }); + + it('returns false for an unwrapped TerminalUpgradeError', () => { + expect( + isTerminalMoneyAccountUpgradeError(new TerminalUpgradeError('x')), + ).toBe(false); + }); + + it('returns false for non-step-error values', () => { + expect(isTerminalMoneyAccountUpgradeError(undefined)).toBe(false); + expect(isTerminalMoneyAccountUpgradeError(new Error('x'))).toBe(false); + }); +}); + +describe('isMoneyAccountUpgradeStepError', () => { + it('returns true for a MoneyAccountUpgradeStepError', () => { + expect( + isMoneyAccountUpgradeStepError( + new MoneyAccountUpgradeStepError('associate-address', new Error('x')), + ), + ).toBe(true); + }); + + it('returns true for a structurally-equivalent error from another realm', () => { + const lookalike = new Error('whatever'); + lookalike.name = 'MoneyAccountUpgradeStepError'; + (lookalike as unknown as { step: string }).step = 'associate-address'; + + expect(isMoneyAccountUpgradeStepError(lookalike)).toBe(true); + }); + + it('returns false for a plain Error', () => { + expect(isMoneyAccountUpgradeStepError(new Error('nope'))).toBe(false); + }); + + it('returns false for an error with the right name but no step', () => { + const error = new Error('nope'); + error.name = 'MoneyAccountUpgradeStepError'; + + expect(isMoneyAccountUpgradeStepError(error)).toBe(false); + }); + + it('returns false for non-error values', () => { + expect(isMoneyAccountUpgradeStepError(undefined)).toBe(false); + expect(isMoneyAccountUpgradeStepError(null)).toBe(false); + expect(isMoneyAccountUpgradeStepError('error')).toBe(false); + expect(isMoneyAccountUpgradeStepError({ step: 'x' })).toBe(false); + }); +}); diff --git a/packages/money-account-upgrade-controller/src/errors.ts b/packages/money-account-upgrade-controller/src/errors.ts new file mode 100644 index 00000000000..1aef34ff3c8 --- /dev/null +++ b/packages/money-account-upgrade-controller/src/errors.ts @@ -0,0 +1,93 @@ +/** + * Error thrown by `MoneyAccountUpgradeController.upgradeAccount` when one of + * the upgrade steps fails. + * + * Wraps the underlying error (preserved as `cause`) and records the name of + * the step that was running, so consumers can attribute the failure to a + * specific step — e.g. when tagging an error report — without parsing the + * message. + */ +export class MoneyAccountUpgradeStepError extends Error { + /** The name of the step that threw. */ + readonly step: string; + + /** The underlying error thrown by the step. */ + readonly cause: unknown; + + /** + * Whether the failure is terminal — the condition will not resolve on its + * own, so retrying the upgrade sequence cannot succeed. Derived from the + * cause (see {@link TerminalUpgradeError}). + */ + readonly terminal: boolean; + + constructor(step: string, cause: unknown) { + const causeMessage = cause instanceof Error ? cause.message : String(cause); + super(`Money Account upgrade failed at step "${step}": ${causeMessage}`); + + this.name = 'MoneyAccountUpgradeStepError'; + this.step = step; + this.cause = cause; + this.terminal = + cause instanceof Error && + (cause as { terminal?: unknown }).terminal === true; + } +} + +/** + * Error a step throws to mark a failure as terminal: the condition will not + * resolve on its own, so retrying the upgrade sequence is pointless — e.g. + * the account is already delegated to a third-party implementation. + * + * Detected structurally via the `terminal` property (rather than + * `instanceof`) so the marking survives module-realm duplication. + */ +export class TerminalUpgradeError extends Error { + /** Marks the failure as not retryable. */ + readonly terminal = true; + + constructor(message: string) { + super(message); + this.name = 'TerminalUpgradeError'; + } +} + +/** + * Type guard for {@link MoneyAccountUpgradeStepError}. + * + * Uses a structural check rather than `instanceof` so it holds across module + * realm boundaries — e.g. when the controller is consumed from a bundled host + * app where a duplicate copy of this class may exist. + * + * @param error - The value to test. + * @returns Whether `error` is a `MoneyAccountUpgradeStepError`. + */ +export function isMoneyAccountUpgradeStepError( + error: unknown, +): error is MoneyAccountUpgradeStepError { + return ( + error instanceof Error && + error.name === 'MoneyAccountUpgradeStepError' && + typeof (error as { step?: unknown }).step === 'string' + ); +} + +/** + * Whether `error` is a {@link MoneyAccountUpgradeStepError} marked as + * terminal — a failure that will not resolve on its own, so retrying the + * upgrade sequence cannot succeed. + * + * Uses the same structural checks as {@link isMoneyAccountUpgradeStepError} + * so it holds across module realm boundaries. + * + * @param error - The value to test. + * @returns Whether `error` is a terminal `MoneyAccountUpgradeStepError`. + */ +export function isTerminalMoneyAccountUpgradeError( + error: unknown, +): error is MoneyAccountUpgradeStepError { + return ( + isMoneyAccountUpgradeStepError(error) && + (error as { terminal?: unknown }).terminal === true + ); +} diff --git a/packages/money-account-upgrade-controller/src/index.ts b/packages/money-account-upgrade-controller/src/index.ts new file mode 100644 index 00000000000..ddb674d4114 --- /dev/null +++ b/packages/money-account-upgrade-controller/src/index.ts @@ -0,0 +1,21 @@ +export type { UpgradeConfig } from './types.js'; +export { + MoneyAccountUpgradeStepError, + TerminalUpgradeError, + isMoneyAccountUpgradeStepError, + isTerminalMoneyAccountUpgradeError, +} from './errors.js'; +export { + MoneyAccountUpgradeController, + getDefaultMoneyAccountUpgradeControllerState, +} from './MoneyAccountUpgradeController.js'; +export type { + MoneyAccountUpgradeControllerState, + MoneyAccountUpgradeControllerGetStateAction, + MoneyAccountUpgradeControllerActions, + MoneyAccountUpgradeControllerStateChangedEvent, + MoneyAccountUpgradeControllerEvents, + MoneyAccountUpgradeControllerMessenger, + MoneyAccountUpgradeStatus, +} from './MoneyAccountUpgradeController.js'; +export type { MoneyAccountUpgradeControllerUpgradeAccountAction } from './MoneyAccountUpgradeController-method-action-types.js'; diff --git a/packages/money-account-upgrade-controller/src/steps/associate-address.test.ts b/packages/money-account-upgrade-controller/src/steps/associate-address.test.ts new file mode 100644 index 00000000000..94fa76e8266 --- /dev/null +++ b/packages/money-account-upgrade-controller/src/steps/associate-address.test.ts @@ -0,0 +1,290 @@ +import { Messenger, MOCK_ANY_NAMESPACE } from '@metamask/messenger'; +import type { + MockAnyNamespace, + MessengerActions, + MessengerEvents, +} from '@metamask/messenger'; +import type { Hex } from '@metamask/utils'; + +import type { MoneyAccountUpgradeControllerMessenger } from '../MoneyAccountUpgradeController.js'; +import { associateAddressStep } from './associate-address.js'; + +const MOCK_ADDRESS = '0xAbCdEf1234567890AbCdEf1234567890AbCdEf12' as Hex; +const MOCK_ADDRESS_LOWERCASE = MOCK_ADDRESS.toLowerCase() as Hex; +const MOCK_CHAIN_ID = '0x1' as Hex; +const MOCK_DELEGATE = '0x1111111111111111111111111111111111111111' as Hex; +const MOCK_DELEGATOR_IMPL = '0x2222222222222222222222222222222222222222' as Hex; +const MOCK_TOKEN = '0x3333333333333333333333333333333333333333' as Hex; +const MOCK_VAULT_ADAPTER = '0x4444444444444444444444444444444444444444' as Hex; +const MOCK_ERC20_ENFORCER = '0x5555555555555555555555555555555555555555' as Hex; +const MOCK_REDEEMER_ENFORCER = + '0x6666666666666666666666666666666666666666' as Hex; +const MOCK_VALUE_LTE_ENFORCER = + '0x7777777777777777777777777777777777777777' as Hex; +const MOCK_SIGNATURE = '0xdeadbeefcafebabe'; +const MOCK_NOW = new Date('2026-04-17T12:00:00.000Z').getTime(); + +/** + * Builds the error `ChompApiService.associateAddress` throws on a 409. + * + * @returns An `Error` carrying `httpStatus: 409`, matching `HttpError` from + * `@metamask/controller-utils`. + */ +function conflictError(): Error { + return Object.assign( + new Error("POST /v1/auth/address failed with status '409'"), + { httpStatus: 409 }, + ); +} + +type AllActions = MessengerActions; +type AllEvents = MessengerEvents; + +type Mocks = { + signPersonalMessage: jest.Mock; + associateAddress: jest.Mock; + getAssociatedAddresses: jest.Mock; +}; + +function setup(): { + messenger: MoneyAccountUpgradeControllerMessenger; + mocks: Mocks; +} { + const mocks: Mocks = { + signPersonalMessage: jest.fn().mockResolvedValue(MOCK_SIGNATURE), + associateAddress: jest.fn().mockResolvedValue({ + profileId: 'profile-1', + address: MOCK_ADDRESS_LOWERCASE, + status: 'created', + }), + getAssociatedAddresses: jest.fn().mockResolvedValue([]), + }; + + const rootMessenger = new Messenger({ + namespace: MOCK_ANY_NAMESPACE, + }); + rootMessenger.registerActionHandler( + 'KeyringController:signPersonalMessage', + mocks.signPersonalMessage, + ); + rootMessenger.registerActionHandler( + 'ChompApiService:associateAddress', + mocks.associateAddress, + ); + rootMessenger.registerActionHandler( + 'ChompApiService:getAssociatedAddresses', + mocks.getAssociatedAddresses, + ); + + const messenger: MoneyAccountUpgradeControllerMessenger = new Messenger({ + namespace: 'MoneyAccountUpgradeController', + parent: rootMessenger, + }); + rootMessenger.delegate({ + actions: [ + 'KeyringController:signPersonalMessage', + 'ChompApiService:associateAddress', + 'ChompApiService:getAssociatedAddresses', + ], + events: [], + messenger, + }); + + return { messenger, mocks }; +} + +async function run( + messenger: MoneyAccountUpgradeControllerMessenger, +): ReturnType { + return associateAddressStep.run({ + messenger, + address: MOCK_ADDRESS, + chainId: MOCK_CHAIN_ID, + boringVaultAddress: '0xeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee' as Hex, + delegateAddress: MOCK_DELEGATE, + delegatorImplAddress: MOCK_DELEGATOR_IMPL, + erc20TransferAmountEnforcer: MOCK_ERC20_ENFORCER, + musdTokenAddress: MOCK_TOKEN, + redeemerEnforcer: MOCK_REDEEMER_ENFORCER, + valueLteEnforcer: MOCK_VALUE_LTE_ENFORCER, + vedaVaultAdapterAddress: MOCK_VAULT_ADAPTER, + }); +} + +describe('associateAddressStep', () => { + beforeEach(() => { + jest.useFakeTimers(); + jest.setSystemTime(MOCK_NOW); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + it('is named "associate-address"', () => { + expect(associateAddressStep.name).toBe('associate-address'); + }); + + it('checks the associated addresses before signing anything', async () => { + const { messenger, mocks } = setup(); + + await run(messenger); + + expect(mocks.getAssociatedAddresses).toHaveBeenCalledTimes(1); + expect( + mocks.getAssociatedAddresses.mock.invocationCallOrder[0], + ).toBeLessThan(mocks.signPersonalMessage.mock.invocationCallOrder[0]); + }); + + it('returns "already-done" without signing or submitting when the address is already associated', async () => { + const { messenger, mocks } = setup(); + // CHOMP lowercases stored addresses; the step receives a checksummed one, + // so this also covers the case-insensitive match. + mocks.getAssociatedAddresses.mockResolvedValue([ + { + profileId: 'profile-1', + address: MOCK_ADDRESS_LOWERCASE, + status: 'active', + }, + ]); + + const result = await run(messenger); + + expect(result).toBe('already-done'); + expect(mocks.signPersonalMessage).not.toHaveBeenCalled(); + expect(mocks.associateAddress).not.toHaveBeenCalled(); + }); + + it('proceeds with association when only other addresses are associated', async () => { + const { messenger, mocks } = setup(); + mocks.getAssociatedAddresses.mockResolvedValue([ + { + profileId: 'profile-1', + address: '0x9999999999999999999999999999999999999999', + status: 'active', + }, + ]); + + const result = await run(messenger); + + expect(result).toBe('completed'); + expect(mocks.associateAddress).toHaveBeenCalled(); + }); + + it('signs the CHOMP Authentication message with the given address', async () => { + const { messenger, mocks } = setup(); + + await run(messenger); + + expect(mocks.signPersonalMessage).toHaveBeenCalledWith({ + data: `CHOMP Authentication ${MOCK_NOW}`, + from: MOCK_ADDRESS, + }); + }); + + it('submits the signature, timestamp, and address to the CHOMP API', async () => { + const { messenger, mocks } = setup(); + + await run(messenger); + + expect(mocks.associateAddress).toHaveBeenCalledWith({ + signature: MOCK_SIGNATURE, + timestamp: MOCK_NOW, + address: MOCK_ADDRESS, + }); + }); + + it('returns "completed" when CHOMP creates the association', async () => { + const { messenger } = setup(); + + const result = await run(messenger); + + expect(result).toBe('completed'); + }); + + it('returns "already-done" when the association was created concurrently', async () => { + const { messenger, mocks } = setup(); + mocks.associateAddress.mockResolvedValue({ + address: MOCK_ADDRESS_LOWERCASE, + status: 'active', + }); + + const result = await run(messenger); + + expect(result).toBe('already-done'); + }); + + it('falls through to sign-and-submit when the lookup fails', async () => { + const { messenger, mocks } = setup(); + mocks.getAssociatedAddresses.mockRejectedValue(new Error('lookup failed')); + + const result = await run(messenger); + + expect(result).toBe('completed'); + expect(mocks.signPersonalMessage).toHaveBeenCalled(); + expect(mocks.associateAddress).toHaveBeenCalled(); + }); + + it('returns "already-done" when a conflict turns out to be a same-profile race', async () => { + const { messenger, mocks } = setup(); + mocks.associateAddress.mockRejectedValue(conflictError()); + // First lookup (pre-check) misses; second (disambiguation) finds it. + mocks.getAssociatedAddresses + .mockResolvedValueOnce([]) + .mockResolvedValueOnce([ + { + profileId: 'profile-1', + address: MOCK_ADDRESS_LOWERCASE, + status: 'active', + }, + ]); + + const result = await run(messenger); + + expect(result).toBe('already-done'); + }); + + it('throws a terminal error when the address belongs to another profile', async () => { + const { messenger, mocks } = setup(); + mocks.associateAddress.mockRejectedValue(conflictError()); + mocks.getAssociatedAddresses.mockResolvedValue([]); + + const error = await run(messenger).catch((thrown: unknown) => thrown); + + expect(error).toBeInstanceOf(Error); + expect((error as Error).message).toBe( + `Address ${MOCK_ADDRESS} is associated with a different CHOMP profile.`, + ); + expect(error).toMatchObject({ terminal: true }); + }); + + it('rethrows the original, non-terminal conflict when the disambiguating lookup also fails', async () => { + const { messenger, mocks } = setup(); + mocks.associateAddress.mockRejectedValue(conflictError()); + mocks.getAssociatedAddresses + .mockResolvedValueOnce([]) + .mockRejectedValueOnce(new Error('lookup failed')); + + const error = await run(messenger).catch((thrown: unknown) => thrown); + + expect((error as Error).message).toBe( + "POST /v1/auth/address failed with status '409'", + ); + expect(error).not.toMatchObject({ terminal: true }); + }); + + it('propagates errors from signing and does not submit to the API', async () => { + const { messenger, mocks } = setup(); + mocks.signPersonalMessage.mockRejectedValue(new Error('signing failed')); + + await expect(run(messenger)).rejects.toThrow('signing failed'); + expect(mocks.associateAddress).not.toHaveBeenCalled(); + }); + + it('propagates errors from the CHOMP API', async () => { + const { messenger, mocks } = setup(); + mocks.associateAddress.mockRejectedValue(new Error('api failed')); + + await expect(run(messenger)).rejects.toThrow('api failed'); + }); +}); diff --git a/packages/money-account-upgrade-controller/src/steps/associate-address.ts b/packages/money-account-upgrade-controller/src/steps/associate-address.ts new file mode 100644 index 00000000000..6e7293d0610 --- /dev/null +++ b/packages/money-account-upgrade-controller/src/steps/associate-address.ts @@ -0,0 +1,98 @@ +import type { Hex } from '@metamask/utils'; +import { hasProperty } from '@metamask/utils'; + +import { TerminalUpgradeError } from '../errors.js'; +import { equalsIgnoreCase } from './delegation-matchers.js'; +import type { Step } from './step.js'; + +/** + * Determines whether an error is a CHOMP conflict (HTTP 409) response. + * + * @param error - The error to inspect. + * @returns `true` when the error carries a 409 HTTP status. + */ +function isConflictError(error: unknown): boolean { + return ( + error instanceof Error && + hasProperty(error, 'httpStatus') && + error.httpStatus === 409 + ); +} + +/** + * Associates the Money Account address with the user's CHOMP profile. + * + * First checks the profile's existing associations via + * `GET /v1/auth/address`; if the address is already associated the step + * reports `'already-done'` without signing anything. The lookup is an + * optimization: if it fails, the step falls through to the POST path below, + * which is authoritative and matches the behavior before the lookup existed. + * + * Otherwise, signs `CHOMP Authentication {timestamp}` (EIP-191) with the + * account's key and submits the signature to CHOMP, which verifies the + * timestamp is fresh, recovers the signer, and records the profile–address + * mapping. CHOMP responds with `status: 'created'` for a new association and + * `status: 'active'` when the address was already associated with this + * profile, so the latter also reports `'already-done'`. + * + * A 409 usually means the address belongs to a different profile, but CHOMP + * also returns it when two same-profile requests race on the initial create + * (the loser's conditional write fails). The step disambiguates by re-fetching + * the associations: if the address is now present the race was benign and the + * step reports `'already-done'`. A confirmed cross-profile conflict is thrown + * as a {@link TerminalUpgradeError}, since no amount of retrying dissociates + * the address from the other profile; if the disambiguating lookup itself + * fails, the original (retryable) conflict propagates instead. + */ +export const associateAddressStep: Step = { + name: 'associate-address', + async run({ messenger, address }) { + const isAssociated = async (): Promise => { + const entries = await messenger.call( + 'ChompApiService:getAssociatedAddresses', + ); + return entries.some((entry) => equalsIgnoreCase(entry.address, address)); + }; + + try { + if (await isAssociated()) { + return 'already-done'; + } + } catch { + // The lookup is an optimization — the POST path below is authoritative. + } + + const timestamp = Date.now(); + const message = `CHOMP Authentication ${timestamp}`; + + const signature = (await messenger.call( + 'KeyringController:signPersonalMessage', + { data: message, from: address }, + )) as Hex; + + try { + const response = await messenger.call( + 'ChompApiService:associateAddress', + { signature, timestamp, address }, + ); + return response.status === 'active' ? 'already-done' : 'completed'; + } catch (error) { + if (isConflictError(error)) { + let associated; + try { + associated = await isAssociated(); + } catch { + // Could not disambiguate — surface the original conflict. + throw error; + } + if (associated) { + return 'already-done'; + } + throw new TerminalUpgradeError( + `Address ${address} is associated with a different CHOMP profile.`, + ); + } + throw error; + } + }, +}; diff --git a/packages/money-account-upgrade-controller/src/steps/build-delegations.test.ts b/packages/money-account-upgrade-controller/src/steps/build-delegations.test.ts new file mode 100644 index 00000000000..7b06ae1729a --- /dev/null +++ b/packages/money-account-upgrade-controller/src/steps/build-delegations.test.ts @@ -0,0 +1,579 @@ +import type { DelegationResponse } from '@metamask/authenticated-user-storage'; +import { + ROOT_AUTHORITY, + createERC20TransferAmountTerms, + createRedeemerTerms, + createValueLteTerms, + hashDelegation, +} from '@metamask/delegation-core'; +import { Messenger, MOCK_ANY_NAMESPACE } from '@metamask/messenger'; +import type { + MockAnyNamespace, + MessengerActions, + MessengerEvents, +} from '@metamask/messenger'; +import type { Hex } from '@metamask/utils'; + +import type { MoneyAccountUpgradeControllerMessenger } from '../MoneyAccountUpgradeController.js'; +import { buildDelegationStep } from './build-delegations.js'; + +jest.mock('@metamask/delegation-core', () => ({ + ROOT_AUTHORITY: + '0xffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff', + createERC20TransferAmountTerms: jest.fn(), + createRedeemerTerms: jest.fn(), + createValueLteTerms: jest.fn(), + hashDelegation: jest.fn(), +})); + +const mockCreateErc20Terms = jest.mocked(createERC20TransferAmountTerms); +const mockCreateRedeemerTerms = jest.mocked(createRedeemerTerms); +const mockCreateValueLteTerms = jest.mocked(createValueLteTerms); +const mockHashDelegation = jest.mocked(hashDelegation); + +const MOCK_ADDRESS = '0xabcdef1234567890abcdef1234567890abcdef12' as Hex; +const MOCK_CHAIN_ID = '0xaa36a7' as Hex; // 11155111 (Sepolia) +const MOCK_DELEGATE = '0x1111111111111111111111111111111111111111' as Hex; +const MOCK_MUSD = '0x3333333333333333333333333333333333333333' as Hex; +const MOCK_BORING_VAULT = '0x7777777777777777777777777777777777777777' as Hex; +const MOCK_VAULT_ADAPTER = '0x4444444444444444444444444444444444444444' as Hex; +const MOCK_ERC20_ENFORCER = '0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa' as Hex; +const MOCK_REDEEMER_ENFORCER = + '0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb' as Hex; +const MOCK_VALUE_LTE_ENFORCER = + '0xcccccccccccccccccccccccccccccccccccccccc' as Hex; +const OTHER_ADDRESS = '0x9999999999999999999999999999999999999999' as Hex; +const OTHER_CHAIN_ID = '0x1' as Hex; +const OTHER_TOKEN = '0x8888888888888888888888888888888888888888' as Hex; +const MOCK_SIGNATURE: Hex = `0x${'cd'.repeat(65)}`; + +const MOCK_VALUE_LTE_TERMS: Hex = '0xa1'; +const MOCK_MUSD_ERC20_TERMS: Hex = '0xa2'; +const MOCK_VMUSD_ERC20_TERMS: Hex = '0xa4'; +const MOCK_REDEEMER_TERMS: Hex = '0xa3'; +const MOCK_MUSD_DELEGATION_HASH: Hex = `0x${'ee'.repeat(32)}`; +const MOCK_VMUSD_DELEGATION_HASH: Hex = `0x${'ff'.repeat(32)}`; +const MAX_UINT256_HEX: Hex = `0x${'f'.repeat(64)}`; + +type ExpectedCaveat = { enforcer: Hex; terms: Hex; args: '0x' }; +const expectedCaveats = (erc20Terms: Hex): ExpectedCaveat[] => [ + { + enforcer: MOCK_VALUE_LTE_ENFORCER, + terms: MOCK_VALUE_LTE_TERMS, + args: '0x', + }, + { enforcer: MOCK_ERC20_ENFORCER, terms: erc20Terms, args: '0x' }, + { enforcer: MOCK_REDEEMER_ENFORCER, terms: MOCK_REDEEMER_TERMS, args: '0x' }, +]; + +/** + * Builds a `DelegationResponse` for use as a mocked `listDelegations` entry, + * defaulting every identifying field to the deposit-side delegation, and + * including a redeemer caveat that points at the Veda vault adapter. Tests + * override one field at a time to probe the matcher. + * + * @param overrides - Identifying fields to override. + * @param overrides.delegator - The delegator address. + * @param overrides.delegate - The delegate address. + * @param overrides.chainIdHex - The chain ID in hex. + * @param overrides.tokenAddress - The token address. + * @param overrides.caveats - The caveats attached to the delegation. Defaults + * to a single redeemer caveat targeting the Veda vault adapter. + * @returns A complete `DelegationResponse`. + */ +function makeDelegationResponse( + overrides: { + delegator?: Hex; + delegate?: Hex; + chainIdHex?: Hex; + tokenAddress?: Hex; + caveats?: { enforcer: Hex; terms: Hex; args: Hex }[]; + } = {}, +): DelegationResponse { + return { + signedDelegation: { + delegate: overrides.delegate ?? MOCK_DELEGATE, + delegator: overrides.delegator ?? MOCK_ADDRESS, + authority: ROOT_AUTHORITY as Hex, + caveats: overrides.caveats ?? [ + { + enforcer: MOCK_REDEEMER_ENFORCER, + terms: MOCK_REDEEMER_TERMS, + args: '0x', + }, + ], + salt: `0x${'42'.repeat(32)}`, + signature: '0x' as Hex, + }, + metadata: { + delegationHash: `0x${'ab'.repeat(32)}`, + chainIdHex: overrides.chainIdHex ?? MOCK_CHAIN_ID, + allowance: '0x00', + tokenSymbol: 'mUSD', + tokenAddress: overrides.tokenAddress ?? MOCK_MUSD, + type: 'lend', + }, + }; +} + +type AllActions = MessengerActions; +type AllEvents = MessengerEvents; + +type Mocks = { + listDelegations: jest.Mock; + signDelegation: jest.Mock; + verifyDelegation: jest.Mock; + createDelegation: jest.Mock; +}; + +function setup(): { + messenger: MoneyAccountUpgradeControllerMessenger; + mocks: Mocks; +} { + const mocks: Mocks = { + listDelegations: jest.fn().mockResolvedValue([]), + signDelegation: jest.fn().mockResolvedValue(MOCK_SIGNATURE), + verifyDelegation: jest.fn().mockResolvedValue({ valid: true }), + createDelegation: jest.fn().mockResolvedValue(undefined), + }; + + const rootMessenger = new Messenger({ + namespace: MOCK_ANY_NAMESPACE, + }); + rootMessenger.registerActionHandler( + 'AuthenticatedUserStorageService:listDelegations', + mocks.listDelegations, + ); + rootMessenger.registerActionHandler( + 'AuthenticatedUserStorageService:createDelegation', + mocks.createDelegation, + ); + rootMessenger.registerActionHandler( + 'DelegationController:signDelegation', + mocks.signDelegation, + ); + rootMessenger.registerActionHandler( + 'ChompApiService:verifyDelegation', + mocks.verifyDelegation, + ); + + const messenger: MoneyAccountUpgradeControllerMessenger = new Messenger({ + namespace: 'MoneyAccountUpgradeController', + parent: rootMessenger, + }); + rootMessenger.delegate({ + actions: [ + 'AuthenticatedUserStorageService:listDelegations', + 'AuthenticatedUserStorageService:createDelegation', + 'DelegationController:signDelegation', + 'ChompApiService:verifyDelegation', + ], + events: [], + messenger, + }); + + return { messenger, mocks }; +} + +async function run( + messenger: MoneyAccountUpgradeControllerMessenger, +): ReturnType { + return buildDelegationStep.run({ + messenger, + address: MOCK_ADDRESS, + chainId: MOCK_CHAIN_ID, + boringVaultAddress: MOCK_BORING_VAULT, + delegateAddress: MOCK_DELEGATE, + delegatorImplAddress: '0x2222222222222222222222222222222222222222' as Hex, + erc20TransferAmountEnforcer: MOCK_ERC20_ENFORCER, + musdTokenAddress: MOCK_MUSD, + redeemerEnforcer: MOCK_REDEEMER_ENFORCER, + valueLteEnforcer: MOCK_VALUE_LTE_ENFORCER, + vedaVaultAdapterAddress: MOCK_VAULT_ADAPTER, + }); +} + +describe('buildDelegationStep', () => { + beforeEach(() => { + // The term creators are overloaded over output encoding; the runtime path + // picks the hex overload, but `jest.mocked()` picks the bytes overload, so + // cast through `never` to satisfy both. + mockCreateValueLteTerms.mockReturnValue(MOCK_VALUE_LTE_TERMS as never); + mockCreateRedeemerTerms.mockReturnValue(MOCK_REDEEMER_TERMS as never); + // Return a different ERC20 terms blob per token so tests can tell which + // delegation was signed when. + mockCreateErc20Terms.mockImplementation((({ + tokenAddress, + }: { + tokenAddress: Hex; + }) => + tokenAddress === MOCK_MUSD + ? MOCK_MUSD_ERC20_TERMS + : MOCK_VMUSD_ERC20_TERMS) as never); + // Distinguish the two delegations by call order — the run loop signs + // mUSD first, then vmUSD, so the first hashDelegation call corresponds to + // mUSD. + mockHashDelegation + .mockReturnValueOnce(MOCK_MUSD_DELEGATION_HASH as never) + .mockReturnValueOnce(MOCK_VMUSD_DELEGATION_HASH as never); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + it('is named "build-delegation"', () => { + expect(buildDelegationStep.name).toBe('build-delegation'); + }); + + describe('when neither delegation exists in storage', () => { + it('signs and submits both delegations, deposit (mUSD) before withdrawal (vmUSD)', async () => { + const { messenger, mocks } = setup(); + + const result = await run(messenger); + + expect(result).toBe('completed'); + expect(mocks.signDelegation).toHaveBeenCalledTimes(2); + expect(mocks.verifyDelegation).toHaveBeenCalledTimes(2); + + const signedTokens = mocks.signDelegation.mock.calls.map( + ([{ delegation }]) => delegation.caveats[1].terms, + ); + expect(signedTokens).toStrictEqual([ + MOCK_MUSD_ERC20_TERMS, + MOCK_VMUSD_ERC20_TERMS, + ]); + }); + + it('encodes each caveat against the right enforcer addresses for each token', async () => { + const { messenger } = setup(); + + await run(messenger); + + // valueLte and redeemer share configuration across both delegations. + expect(mockCreateValueLteTerms).toHaveBeenCalledWith({ maxValue: 0n }); + expect(mockCreateRedeemerTerms).toHaveBeenCalledWith({ + redeemers: [MOCK_VAULT_ADAPTER], + }); + // erc20TransferAmount is per-token. + expect(mockCreateErc20Terms).toHaveBeenCalledWith({ + tokenAddress: MOCK_MUSD, + maxAmount: 2n ** 256n - 1n, + }); + expect(mockCreateErc20Terms).toHaveBeenCalledWith({ + tokenAddress: MOCK_BORING_VAULT, + maxAmount: 2n ** 256n - 1n, + }); + }); + + it('hands each unsigned delegation to DelegationController:signDelegation, scoped to the chain, with a fresh 32-byte salt', async () => { + const { messenger, mocks } = setup(); + + await run(messenger); + + const [first, second] = mocks.signDelegation.mock.calls.map( + ([params]) => params, + ); + + for (const { chainId, delegation } of [first, second]) { + expect(chainId).toBe(MOCK_CHAIN_ID); + expect(delegation.delegate).toBe(MOCK_DELEGATE); + expect(delegation.delegator).toBe(MOCK_ADDRESS); + expect(delegation.authority).toBe(ROOT_AUTHORITY); + expect(delegation.salt).toMatch(/^0x[0-9a-f]{64}$/u); + expect(delegation).not.toHaveProperty('signature'); + } + + expect(first.delegation.caveats).toStrictEqual( + expectedCaveats(MOCK_MUSD_ERC20_TERMS), + ); + expect(second.delegation.caveats).toStrictEqual( + expectedCaveats(MOCK_VMUSD_ERC20_TERMS), + ); + // Salts are independent per delegation. + expect(first.delegation.salt).not.toBe(second.delegation.salt); + }); + + it('submits each signed delegation to ChompApiService:verifyDelegation', async () => { + const { messenger, mocks } = setup(); + + await run(messenger); + + const [first, second] = mocks.verifyDelegation.mock.calls.map( + ([params]) => params, + ); + + for (const { chainId, signedDelegation } of [first, second]) { + expect(chainId).toBe(MOCK_CHAIN_ID); + expect(signedDelegation.delegate).toBe(MOCK_DELEGATE); + expect(signedDelegation.delegator).toBe(MOCK_ADDRESS); + expect(signedDelegation.authority).toBe(ROOT_AUTHORITY); + expect(signedDelegation.signature).toBe(MOCK_SIGNATURE); + expect(signedDelegation.salt).toMatch(/^0x[0-9a-f]{64}$/u); + } + + expect(first.signedDelegation.caveats).toStrictEqual( + expectedCaveats(MOCK_MUSD_ERC20_TERMS), + ); + expect(second.signedDelegation.caveats).toStrictEqual( + expectedCaveats(MOCK_VMUSD_ERC20_TERMS), + ); + }); + + it('persists each delegation via AuthenticatedUserStorageService:createDelegation, with deposit/withdrawal metadata', async () => { + const { messenger, mocks } = setup(); + + await run(messenger); + + expect(mocks.createDelegation).toHaveBeenCalledTimes(2); + const [first, second] = mocks.createDelegation.mock.calls.map( + ([submission]) => submission, + ); + + // Each submission carries the same signed-delegation as the + // corresponding verifyDelegation call. + expect(first.signedDelegation.caveats).toStrictEqual( + expectedCaveats(MOCK_MUSD_ERC20_TERMS), + ); + expect(first.signedDelegation.signature).toBe(MOCK_SIGNATURE); + expect(second.signedDelegation.caveats).toStrictEqual( + expectedCaveats(MOCK_VMUSD_ERC20_TERMS), + ); + expect(second.signedDelegation.signature).toBe(MOCK_SIGNATURE); + + expect(first.metadata).toStrictEqual({ + delegationHash: MOCK_MUSD_DELEGATION_HASH, + chainIdHex: MOCK_CHAIN_ID, + allowance: MAX_UINT256_HEX, + tokenSymbol: 'mUSD', + tokenAddress: MOCK_MUSD, + type: 'cash-deposit', + }); + expect(second.metadata).toStrictEqual({ + delegationHash: MOCK_VMUSD_DELEGATION_HASH, + chainIdHex: MOCK_CHAIN_ID, + allowance: MAX_UINT256_HEX, + tokenSymbol: 'vmUSD', + tokenAddress: MOCK_BORING_VAULT, + type: 'cash-withdrawal', + }); + }); + + it('hashes each signed delegation (with bigint salt) before persisting it', async () => { + const { messenger } = setup(); + + await run(messenger); + + expect(mockHashDelegation).toHaveBeenCalledTimes(2); + // Each hashDelegation call should receive a delegation whose salt is a + // bigint (delegation-core's expectation), not a hex string. + for (const [delegationStruct] of mockHashDelegation.mock.calls) { + expect(typeof delegationStruct.salt).toBe('bigint'); + expect(delegationStruct.signature).toBe(MOCK_SIGNATURE); + } + }); + }); + + describe('when only one delegation already exists', () => { + it('signs, submits, and persists only the missing withdrawal delegation when the deposit one already exists', async () => { + const { messenger, mocks } = setup(); + mocks.listDelegations.mockResolvedValue([ + makeDelegationResponse({ tokenAddress: MOCK_MUSD }), + ]); + + const result = await run(messenger); + + expect(result).toBe('completed'); + expect(mocks.signDelegation).toHaveBeenCalledTimes(1); + const { delegation } = mocks.signDelegation.mock.calls[0][0]; + expect(delegation.caveats[1].terms).toBe(MOCK_VMUSD_ERC20_TERMS); + + expect(mocks.createDelegation).toHaveBeenCalledTimes(1); + const [submission] = mocks.createDelegation.mock.calls[0]; + expect(submission.metadata.tokenAddress).toBe(MOCK_BORING_VAULT); + expect(submission.metadata.type).toBe('cash-withdrawal'); + }); + + it('signs, submits, and persists only the missing deposit delegation when the withdrawal one already exists', async () => { + const { messenger, mocks } = setup(); + mocks.listDelegations.mockResolvedValue([ + makeDelegationResponse({ tokenAddress: MOCK_BORING_VAULT }), + ]); + + const result = await run(messenger); + + expect(result).toBe('completed'); + expect(mocks.signDelegation).toHaveBeenCalledTimes(1); + const { delegation } = mocks.signDelegation.mock.calls[0][0]; + expect(delegation.caveats[1].terms).toBe(MOCK_MUSD_ERC20_TERMS); + + expect(mocks.createDelegation).toHaveBeenCalledTimes(1); + const [submission] = mocks.createDelegation.mock.calls[0]; + expect(submission.metadata.tokenAddress).toBe(MOCK_MUSD); + expect(submission.metadata.type).toBe('cash-deposit'); + }); + }); + + describe('when both delegations already exist', () => { + it('returns "already-done" without signing, submitting, or persisting', async () => { + const { messenger, mocks } = setup(); + mocks.listDelegations.mockResolvedValue([ + makeDelegationResponse({ tokenAddress: MOCK_MUSD }), + makeDelegationResponse({ tokenAddress: MOCK_BORING_VAULT }), + ]); + + const result = await run(messenger); + + expect(result).toBe('already-done'); + expect(mocks.signDelegation).not.toHaveBeenCalled(); + expect(mocks.verifyDelegation).not.toHaveBeenCalled(); + expect(mocks.createDelegation).not.toHaveBeenCalled(); + }); + + it('matches addresses, chainId, and tokenAddress case-insensitively', async () => { + const { messenger, mocks } = setup(); + mocks.listDelegations.mockResolvedValue([ + makeDelegationResponse({ + delegator: MOCK_ADDRESS.toUpperCase() as Hex, + delegate: MOCK_DELEGATE.toUpperCase() as Hex, + chainIdHex: MOCK_CHAIN_ID.toUpperCase() as Hex, + tokenAddress: MOCK_MUSD.toUpperCase() as Hex, + }), + makeDelegationResponse({ + tokenAddress: MOCK_BORING_VAULT.toUpperCase() as Hex, + }), + ]); + + const result = await run(messenger); + + expect(result).toBe('already-done'); + }); + + it('ignores entries that differ on any identifying field', async () => { + const { messenger, mocks } = setup(); + mocks.listDelegations.mockResolvedValue([ + // Same token but wrong delegator/delegate/chain. + makeDelegationResponse({ + tokenAddress: MOCK_MUSD, + delegator: OTHER_ADDRESS, + }), + makeDelegationResponse({ + tokenAddress: MOCK_MUSD, + delegate: OTHER_ADDRESS, + }), + makeDelegationResponse({ + tokenAddress: MOCK_MUSD, + chainIdHex: OTHER_CHAIN_ID, + }), + // Unrelated token. + makeDelegationResponse({ tokenAddress: OTHER_TOKEN }), + ]); + + const result = await run(messenger); + + expect(result).toBe('completed'); + expect(mocks.signDelegation).toHaveBeenCalledTimes(2); + }); + + it('ignores entries that do not carry a redeemer caveat targeting the Veda vault adapter', async () => { + const { messenger, mocks } = setup(); + mocks.listDelegations.mockResolvedValue([ + // No caveats at all. + makeDelegationResponse({ tokenAddress: MOCK_MUSD, caveats: [] }), + // Right enforcer, wrong terms (different redeemer encoded). + makeDelegationResponse({ + tokenAddress: MOCK_BORING_VAULT, + caveats: [ + { + enforcer: MOCK_REDEEMER_ENFORCER, + terms: '0xdeadbeef', + args: '0x', + }, + ], + }), + ]); + + const result = await run(messenger); + + expect(result).toBe('completed'); + expect(mocks.signDelegation).toHaveBeenCalledTimes(2); + }); + }); + + describe('when CHOMP rejects a delegation', () => { + it('throws with the joined error list', async () => { + const { messenger, mocks } = setup(); + mocks.verifyDelegation.mockResolvedValue({ + valid: false, + errors: ['caveat mismatch', 'unknown enforcer'], + }); + + await expect(run(messenger)).rejects.toThrow( + 'CHOMP rejected delegation: caveat mismatch, unknown enforcer', + ); + }); + + it('throws with a default message when CHOMP returns no errors', async () => { + const { messenger, mocks } = setup(); + mocks.verifyDelegation.mockResolvedValue({ valid: false }); + + await expect(run(messenger)).rejects.toThrow( + 'CHOMP rejected delegation: unknown error', + ); + }); + + it('does not attempt the second delegation, and does not persist, if the first one is rejected', async () => { + const { messenger, mocks } = setup(); + mocks.verifyDelegation.mockResolvedValueOnce({ + valid: false, + errors: ['nope'], + }); + + await expect(run(messenger)).rejects.toThrow( + 'CHOMP rejected delegation: nope', + ); + expect(mocks.signDelegation).toHaveBeenCalledTimes(1); + expect(mocks.createDelegation).not.toHaveBeenCalled(); + }); + }); + + describe('error propagation', () => { + it('propagates errors from listDelegations and does not sign or submit anything', async () => { + const { messenger, mocks } = setup(); + mocks.listDelegations.mockRejectedValue(new Error('storage failed')); + + await expect(run(messenger)).rejects.toThrow('storage failed'); + expect(mocks.signDelegation).not.toHaveBeenCalled(); + expect(mocks.verifyDelegation).not.toHaveBeenCalled(); + }); + + it('propagates errors from signing and stops the sequence', async () => { + const { messenger, mocks } = setup(); + mocks.signDelegation.mockRejectedValue(new Error('signing failed')); + + await expect(run(messenger)).rejects.toThrow('signing failed'); + expect(mocks.signDelegation).toHaveBeenCalledTimes(1); + expect(mocks.verifyDelegation).not.toHaveBeenCalled(); + }); + + it('propagates errors from verifyDelegation and stops the sequence', async () => { + const { messenger, mocks } = setup(); + mocks.verifyDelegation.mockRejectedValue(new Error('chomp failed')); + + await expect(run(messenger)).rejects.toThrow('chomp failed'); + expect(mocks.signDelegation).toHaveBeenCalledTimes(1); + expect(mocks.verifyDelegation).toHaveBeenCalledTimes(1); + expect(mocks.createDelegation).not.toHaveBeenCalled(); + }); + + it('propagates errors from createDelegation and stops the sequence', async () => { + const { messenger, mocks } = setup(); + mocks.createDelegation.mockRejectedValue(new Error('storage failed')); + + await expect(run(messenger)).rejects.toThrow('storage failed'); + expect(mocks.signDelegation).toHaveBeenCalledTimes(1); + expect(mocks.verifyDelegation).toHaveBeenCalledTimes(1); + expect(mocks.createDelegation).toHaveBeenCalledTimes(1); + }); + }); +}); diff --git a/packages/money-account-upgrade-controller/src/steps/build-delegations.ts b/packages/money-account-upgrade-controller/src/steps/build-delegations.ts new file mode 100644 index 00000000000..6663c1de4d9 --- /dev/null +++ b/packages/money-account-upgrade-controller/src/steps/build-delegations.ts @@ -0,0 +1,204 @@ +import type { DelegationResponse } from '@metamask/authenticated-user-storage'; +import { + ROOT_AUTHORITY, + createERC20TransferAmountTerms, + createRedeemerTerms, + createValueLteTerms, + hashDelegation, +} from '@metamask/delegation-core'; +import { add0x, bytesToHex } from '@metamask/utils'; +import type { Hex } from '@metamask/utils'; + +import type { MoneyAccountUpgradeControllerMessenger } from '../MoneyAccountUpgradeController.js'; +import { + equalsIgnoreCase, + makeHasVedaRedeemerCaveat, +} from './delegation-matchers.js'; +import type { Step } from './step.js'; + +const MAX_UINT256 = 2n ** 256n - 1n; +const MAX_UINT256_HEX: Hex = add0x(MAX_UINT256.toString(16)); + +/** + * Builds, signs, verifies (with CHOMP), and persists a single auto-deposit + * delegation for the given token. Both the deposit (mUSD) and withdrawal + * (vmUSD / boring vault) delegations share this shape; only the token + * address, symbol, and metadata `type` differ. + * + * @param params - The parameters for building the delegation. + * @param params.messenger - The messenger to call signing/verifying actions on. + * @param params.address - The delegator (the Money Account being upgraded). + * @param params.chainId - The chain to scope the delegation to. + * @param params.delegateAddress - CHOMP's delegate. + * @param params.tokenAddress - The token the delegation authorises transfers of. + * @param params.tokenSymbol - Symbol stored in the delegation metadata (e.g. "mUSD"). + * @param params.delegationType - Storage metadata `type` field; matches CHOMP's intent type. + * @param params.vedaVaultAdapterAddress - The redeemer (Veda vault adapter). + * @param params.erc20TransferAmountEnforcer - The ERC20TransferAmountEnforcer contract. + * @param params.redeemerEnforcer - The RedeemerEnforcer contract. + * @param params.valueLteEnforcer - The ValueLteEnforcer contract. + */ +async function signAndStoreDelegation(params: { + messenger: MoneyAccountUpgradeControllerMessenger; + address: Hex; + chainId: Hex; + delegateAddress: Hex; + tokenAddress: Hex; + tokenSymbol: string; + delegationType: 'cash-deposit' | 'cash-withdrawal'; + vedaVaultAdapterAddress: Hex; + erc20TransferAmountEnforcer: Hex; + redeemerEnforcer: Hex; + valueLteEnforcer: Hex; +}): Promise { + const { + messenger, + address, + chainId, + delegateAddress, + tokenAddress, + tokenSymbol, + delegationType, + vedaVaultAdapterAddress, + erc20TransferAmountEnforcer, + redeemerEnforcer, + valueLteEnforcer, + } = params; + + const saltBytes = globalThis.crypto.getRandomValues(new Uint8Array(32)); + const salt = bytesToHex(saltBytes); + + const delegation = { + delegate: delegateAddress, + delegator: address, + authority: ROOT_AUTHORITY, + caveats: [ + { + enforcer: valueLteEnforcer, + terms: createValueLteTerms({ maxValue: 0n }), + args: '0x' as Hex, + }, + { + enforcer: erc20TransferAmountEnforcer, + terms: createERC20TransferAmountTerms({ + tokenAddress, + maxAmount: MAX_UINT256, + }), + args: '0x' as Hex, + }, + { + enforcer: redeemerEnforcer, + terms: createRedeemerTerms({ redeemers: [vedaVaultAdapterAddress] }), + args: '0x' as Hex, + }, + ], + salt, + }; + + const signature = (await messenger.call( + 'DelegationController:signDelegation', + { delegation, chainId }, + )) as Hex; + + const signedDelegation = { ...delegation, signature }; + + const result = await messenger.call('ChompApiService:verifyDelegation', { + signedDelegation, + chainId, + }); + + if (!result.valid) { + throw new Error( + `CHOMP rejected delegation: ${result.errors?.join(', ') ?? 'unknown error'}`, + ); + } + + const delegationHash = hashDelegation({ + ...delegation, + salt: BigInt(salt), + signature, + }); + + await messenger.call('AuthenticatedUserStorageService:createDelegation', { + signedDelegation, + metadata: { + delegationHash, + chainIdHex: chainId, + allowance: MAX_UINT256_HEX, + tokenSymbol, + tokenAddress, + type: delegationType, + }, + }); +} + +export const buildDelegationStep: Step = { + name: 'build-delegation', + async run({ + messenger, + address, + chainId, + boringVaultAddress, + delegateAddress, + erc20TransferAmountEnforcer, + musdTokenAddress, + redeemerEnforcer, + valueLteEnforcer, + vedaVaultAdapterAddress, + }) { + const existingDelegations = await messenger.call( + 'AuthenticatedUserStorageService:listDelegations', + ); + + const hasVedaRedeemerCaveat = makeHasVedaRedeemerCaveat( + redeemerEnforcer, + vedaVaultAdapterAddress, + ); + + const matches = + (tokenAddress: Hex) => + (entry: DelegationResponse): boolean => + equalsIgnoreCase(entry.signedDelegation.delegator, address) && + equalsIgnoreCase(entry.signedDelegation.delegate, delegateAddress) && + equalsIgnoreCase(entry.metadata.chainIdHex, chainId) && + equalsIgnoreCase(entry.metadata.tokenAddress, tokenAddress) && + hasVedaRedeemerCaveat(entry); + + // The deposit delegation authorises transfers of mUSD (delegator → vault); + // the withdrawal delegation authorises transfers of vmUSD (vault share + // token → adapter, which redeems back to mUSD). + const delegations = [ + { + tokenAddress: musdTokenAddress, + tokenSymbol: 'mUSD', + delegationType: 'cash-deposit' as const, + }, + { + tokenAddress: boringVaultAddress, + tokenSymbol: 'vmUSD', + delegationType: 'cash-withdrawal' as const, + }, + ]; + + let didWork = false; + for (const config of delegations) { + if (existingDelegations.some(matches(config.tokenAddress))) { + continue; + } + await signAndStoreDelegation({ + messenger, + address, + chainId, + delegateAddress, + ...config, + vedaVaultAdapterAddress, + erc20TransferAmountEnforcer, + redeemerEnforcer, + valueLteEnforcer, + }); + didWork = true; + } + + return didWork ? 'completed' : 'already-done'; + }, +}; diff --git a/packages/money-account-upgrade-controller/src/steps/delegation-matchers.ts b/packages/money-account-upgrade-controller/src/steps/delegation-matchers.ts new file mode 100644 index 00000000000..0c78973d527 --- /dev/null +++ b/packages/money-account-upgrade-controller/src/steps/delegation-matchers.ts @@ -0,0 +1,32 @@ +import type { DelegationResponse } from '@metamask/authenticated-user-storage'; +import { createRedeemerTerms } from '@metamask/delegation-core'; +import type { Hex } from '@metamask/utils'; + +export const equalsIgnoreCase = (a: Hex, b: Hex): boolean => + a.toLowerCase() === b.toLowerCase(); + +/** + * Builds a predicate that matches stored delegations carrying a redeemer + * caveat targeting the Veda vault adapter — i.e. delegations we wrote for + * auto-deposit / auto-withdrawal. The expected terms blob is computed once + * and reused across calls. + * + * @param redeemerEnforcer - The RedeemerEnforcer contract address. + * @param vedaVaultAdapterAddress - The Veda vault adapter address that must + * be encoded as the sole redeemer. + * @returns A predicate over `DelegationResponse`. + */ +export const makeHasVedaRedeemerCaveat = ( + redeemerEnforcer: Hex, + vedaVaultAdapterAddress: Hex, +): ((entry: DelegationResponse) => boolean) => { + const expectedRedeemerTerms = createRedeemerTerms({ + redeemers: [vedaVaultAdapterAddress], + }); + return (entry) => + entry.signedDelegation.caveats.some( + (caveat) => + equalsIgnoreCase(caveat.enforcer, redeemerEnforcer) && + equalsIgnoreCase(caveat.terms, expectedRedeemerTerms), + ); +}; diff --git a/packages/money-account-upgrade-controller/src/steps/eip-7702-authorization.test.ts b/packages/money-account-upgrade-controller/src/steps/eip-7702-authorization.test.ts new file mode 100644 index 00000000000..9635a5c166c --- /dev/null +++ b/packages/money-account-upgrade-controller/src/steps/eip-7702-authorization.test.ts @@ -0,0 +1,448 @@ +import { Messenger, MOCK_ANY_NAMESPACE } from '@metamask/messenger'; +import type { + MockAnyNamespace, + MessengerActions, + MessengerEvents, +} from '@metamask/messenger'; +import type { Hex } from '@metamask/utils'; + +import type { MoneyAccountUpgradeControllerMessenger } from '../MoneyAccountUpgradeController.js'; +import { eip7702AuthorizationStep } from './eip-7702-authorization.js'; + +const MOCK_ADDRESS = '0xabcdef1234567890abcdef1234567890abcdef12' as Hex; +const MOCK_CHAIN_ID = '0xaa36a7' as Hex; // 11155111 (Sepolia) — non-trivial decimal +const MOCK_CHAIN_ID_DECIMAL = parseInt(MOCK_CHAIN_ID, 16); +const MOCK_DELEGATE = '0x1111111111111111111111111111111111111111' as Hex; +const MOCK_DELEGATOR_IMPL = '0x2222222222222222222222222222222222222222' as Hex; +const MOCK_TOKEN = '0x3333333333333333333333333333333333333333' as Hex; +const MOCK_VAULT_ADAPTER = '0x4444444444444444444444444444444444444444' as Hex; +const MOCK_ERC20_ENFORCER = '0x5555555555555555555555555555555555555555' as Hex; +const MOCK_REDEEMER_ENFORCER = + '0x6666666666666666666666666666666666666666' as Hex; +const MOCK_VALUE_LTE_ENFORCER = + '0x7777777777777777777777777777777777777777' as Hex; +const MOCK_THIRD_PARTY_IMPL = + '0x9999999999999999999999999999999999999999' as Hex; +const MOCK_NETWORK_CLIENT_ID = 'network-client-id'; +const MOCK_NONCE_HEX = '0x7'; +const MOCK_NONCE = 7; + +const PLAIN_EOA_CODE = '0x'; +const delegationCode = (impl: Hex): Hex => + `0xef0100${impl.slice(2).toLowerCase()}` as Hex; + +// 65-byte signature: r (32) + s (32) + v (1). v = 28 → yParity = 1. +const MOCK_R = + '0x1111111111111111111111111111111111111111111111111111111111111111' as Hex; +const MOCK_S_NO_PREFIX = + '2222222222222222222222222222222222222222222222222222222222222222'; +const MOCK_S = `0x${MOCK_S_NO_PREFIX}` as Hex; +const MOCK_V_HEX = '1c'; // 28 +const MOCK_SIGNATURE = `${MOCK_R}${MOCK_S_NO_PREFIX}${MOCK_V_HEX}` as Hex; + +type AllActions = MessengerActions; +type AllEvents = MessengerEvents; + +type ProviderRequest = (args: { + method: string; + params: unknown[]; +}) => Promise; + +type Mocks = { + createUpgrade: jest.Mock; + signEip7702Authorization: jest.Mock; + findNetworkClientIdByChainId: jest.Mock; + getNetworkClientById: jest.Mock; + providerRequest: jest.Mock< + ReturnType, + [Parameters[0]] + >; +}; + +/** + * Configures the provider mock so that `eth_getCode` returns the given code + * and `eth_getTransactionCount` returns `MOCK_NONCE_HEX`. Other methods throw. + * + * @param mocks - The mocks bag from `setup`. + * @param code - The code to return for `eth_getCode`. + */ +function configureProvider(mocks: Mocks, code: Hex = PLAIN_EOA_CODE): void { + mocks.providerRequest.mockImplementation(async ({ method }) => { + if (method === 'eth_getCode') { + return code; + } + if (method === 'eth_getTransactionCount') { + return MOCK_NONCE_HEX; + } + throw new Error(`Unexpected RPC method: ${method}`); + }); +} + +function setup(): { + messenger: MoneyAccountUpgradeControllerMessenger; + mocks: Mocks; +} { + const providerRequest = jest.fn() as Mocks['providerRequest']; + + const mocks: Mocks = { + createUpgrade: jest.fn().mockResolvedValue({ + signerAddress: MOCK_ADDRESS, + address: MOCK_DELEGATOR_IMPL, + chainId: MOCK_CHAIN_ID, + nonce: MOCK_NONCE_HEX, + status: 'pending', + createdAt: '2026-04-21T12:00:00.000Z', + }), + signEip7702Authorization: jest.fn().mockResolvedValue(MOCK_SIGNATURE), + findNetworkClientIdByChainId: jest + .fn() + .mockReturnValue(MOCK_NETWORK_CLIENT_ID), + getNetworkClientById: jest.fn().mockReturnValue({ + provider: { request: providerRequest }, + }), + providerRequest, + }; + + configureProvider(mocks); + + const rootMessenger = new Messenger({ + namespace: MOCK_ANY_NAMESPACE, + }); + rootMessenger.registerActionHandler( + 'ChompApiService:createUpgrade', + mocks.createUpgrade, + ); + rootMessenger.registerActionHandler( + 'KeyringController:signEip7702Authorization', + mocks.signEip7702Authorization, + ); + rootMessenger.registerActionHandler( + 'NetworkController:findNetworkClientIdByChainId', + mocks.findNetworkClientIdByChainId, + ); + rootMessenger.registerActionHandler( + 'NetworkController:getNetworkClientById', + mocks.getNetworkClientById, + ); + + const messenger: MoneyAccountUpgradeControllerMessenger = new Messenger({ + namespace: 'MoneyAccountUpgradeController', + parent: rootMessenger, + }); + rootMessenger.delegate({ + actions: [ + 'ChompApiService:createUpgrade', + 'KeyringController:signEip7702Authorization', + 'NetworkController:findNetworkClientIdByChainId', + 'NetworkController:getNetworkClientById', + ], + events: [], + messenger, + }); + + return { messenger, mocks }; +} + +async function run( + messenger: MoneyAccountUpgradeControllerMessenger, +): ReturnType { + return eip7702AuthorizationStep.run({ + messenger, + address: MOCK_ADDRESS, + chainId: MOCK_CHAIN_ID, + boringVaultAddress: '0xeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee' as Hex, + delegateAddress: MOCK_DELEGATE, + delegatorImplAddress: MOCK_DELEGATOR_IMPL, + erc20TransferAmountEnforcer: MOCK_ERC20_ENFORCER, + musdTokenAddress: MOCK_TOKEN, + redeemerEnforcer: MOCK_REDEEMER_ENFORCER, + valueLteEnforcer: MOCK_VALUE_LTE_ENFORCER, + vedaVaultAdapterAddress: MOCK_VAULT_ADAPTER, + }); +} + +describe('eip7702AuthorizationStep', () => { + it('is named "eip-7702-authorization"', () => { + expect(eip7702AuthorizationStep.name).toBe('eip-7702-authorization'); + }); + + describe('when the account is already delegated to the configured impl', () => { + it('returns "already-done" and does not sign or submit', async () => { + const { messenger, mocks } = setup(); + configureProvider(mocks, delegationCode(MOCK_DELEGATOR_IMPL)); + + const result = await run(messenger); + + expect(result).toBe('already-done'); + expect(mocks.signEip7702Authorization).not.toHaveBeenCalled(); + expect(mocks.createUpgrade).not.toHaveBeenCalled(); + }); + + it('matches the configured impl case-insensitively', async () => { + const { messenger, mocks } = setup(); + configureProvider( + mocks, + `0xef0100${MOCK_DELEGATOR_IMPL.slice(2).toUpperCase()}` as Hex, + ); + + const result = await run(messenger); + + expect(result).toBe('already-done'); + }); + }); + + describe('when the account is delegated to a different impl', () => { + it('throws and does not sign or submit', async () => { + const { messenger, mocks } = setup(); + configureProvider(mocks, delegationCode(MOCK_THIRD_PARTY_IMPL)); + + await expect(run(messenger)).rejects.toThrow( + `Account ${MOCK_ADDRESS} is already upgraded to another smart account: ${MOCK_THIRD_PARTY_IMPL}.`, + ); + expect(mocks.signEip7702Authorization).not.toHaveBeenCalled(); + expect(mocks.createUpgrade).not.toHaveBeenCalled(); + }); + + it('marks the failure as terminal', async () => { + const { messenger, mocks } = setup(); + configureProvider(mocks, delegationCode(MOCK_THIRD_PARTY_IMPL)); + + await expect(run(messenger)).rejects.toMatchObject({ terminal: true }); + }); + }); + + describe('when the account has unexpected non-delegation code', () => { + it('throws without signing or submitting', async () => { + const { messenger, mocks } = setup(); + // A regular contract — not a 7702 delegation. + configureProvider(mocks, '0x6080604052' as Hex); + + await expect(run(messenger)).rejects.toThrow( + `Account ${MOCK_ADDRESS} has unexpected on-chain code; expected either no code or an EIP-7702 delegation.`, + ); + expect(mocks.signEip7702Authorization).not.toHaveBeenCalled(); + expect(mocks.createUpgrade).not.toHaveBeenCalled(); + }); + + it('marks the failure as terminal', async () => { + const { messenger, mocks } = setup(); + configureProvider(mocks, '0x6080604052' as Hex); + + await expect(run(messenger)).rejects.toMatchObject({ terminal: true }); + }); + + it('throws when eth_getCode returns a non-hex value', async () => { + const { messenger, mocks } = setup(); + mocks.providerRequest.mockImplementation(async ({ method }) => { + if (method === 'eth_getCode') { + return null; + } + return MOCK_NONCE_HEX; + }); + + await expect(run(messenger)).rejects.toThrow( + 'Expected 0x-prefixed hex string from eth_getCode, got null', + ); + }); + }); + + describe('when the account is a plain EOA', () => { + it('resolves the network client for the target chain', async () => { + const { messenger, mocks } = setup(); + + await run(messenger); + + expect(mocks.findNetworkClientIdByChainId).toHaveBeenCalledWith( + MOCK_CHAIN_ID, + ); + expect(mocks.getNetworkClientById).toHaveBeenCalledWith( + MOCK_NETWORK_CLIENT_ID, + ); + }); + + it('reads the on-chain code and nonce for the address', async () => { + const { messenger, mocks } = setup(); + + await run(messenger); + + expect(mocks.providerRequest).toHaveBeenCalledWith({ + method: 'eth_getCode', + params: [MOCK_ADDRESS, 'latest'], + }); + expect(mocks.providerRequest).toHaveBeenCalledWith({ + method: 'eth_getTransactionCount', + params: [MOCK_ADDRESS, 'latest'], + }); + }); + + it('signs the authorization against the configured delegatorImplAddress', async () => { + const { messenger, mocks } = setup(); + + await run(messenger); + + expect(mocks.signEip7702Authorization).toHaveBeenCalledWith({ + chainId: MOCK_CHAIN_ID_DECIMAL, + contractAddress: MOCK_DELEGATOR_IMPL, + nonce: MOCK_NONCE, + from: MOCK_ADDRESS, + }); + }); + + it('submits the split signature, delegator impl address, and hex-formatted chainId/nonce to CHOMP', async () => { + const { messenger, mocks } = setup(); + + await run(messenger); + + expect(mocks.createUpgrade).toHaveBeenCalledWith({ + r: MOCK_R, + s: MOCK_S, + v: 28, + yParity: 1, + address: MOCK_DELEGATOR_IMPL, + chainId: MOCK_CHAIN_ID, + nonce: MOCK_NONCE_HEX, + }); + }); + + it('returns "completed" on success', async () => { + const { messenger } = setup(); + + const result = await run(messenger); + + expect(result).toBe('completed'); + }); + + it('encodes yParity as 0 when v is 27', async () => { + const { messenger, mocks } = setup(); + const sigWithV27 = `${MOCK_R}${MOCK_S_NO_PREFIX}1b` as Hex; + mocks.signEip7702Authorization.mockResolvedValue(sigWithV27); + + await run(messenger); + + expect(mocks.createUpgrade).toHaveBeenCalledWith( + expect.objectContaining({ v: 27, yParity: 0 }), + ); + }); + + it('propagates errors from signing and does not submit to CHOMP', async () => { + const { messenger, mocks } = setup(); + mocks.signEip7702Authorization.mockRejectedValue( + new Error('signing failed'), + ); + + await expect(run(messenger)).rejects.toThrow('signing failed'); + expect(mocks.createUpgrade).not.toHaveBeenCalled(); + }); + + it('propagates errors from createUpgrade', async () => { + const { messenger, mocks } = setup(); + mocks.createUpgrade.mockRejectedValue(new Error('api failed')); + + await expect(run(messenger)).rejects.toThrow('api failed'); + }); + + it('returns "already-done" when CHOMP responds 409 (authorization already submitted)', async () => { + const { messenger, mocks } = setup(); + mocks.createUpgrade.mockRejectedValue( + Object.assign(new Error('conflict'), { httpStatus: 409 }), + ); + + expect(await run(messenger)).toBe('already-done'); + }); + + it('propagates non-409 HttpError responses from createUpgrade', async () => { + const { messenger, mocks } = setup(); + mocks.createUpgrade.mockRejectedValue( + Object.assign(new Error('server error'), { httpStatus: 500 }), + ); + + await expect(run(messenger)).rejects.toThrow('server error'); + }); + + it.each([ + ['a string', 'boom'], + ['null', null], + ])( + 'propagates non-object rejections from createUpgrade (%s)', + async (_label, rejection) => { + const { messenger, mocks } = setup(); + mocks.createUpgrade.mockRejectedValue(rejection); + + await expect(run(messenger)).rejects.toBe(rejection); + }, + ); + + it('throws when eth_getTransactionCount returns a non-hex response', async () => { + const { messenger, mocks } = setup(); + mocks.providerRequest.mockImplementation(async ({ method }) => { + if (method === 'eth_getCode') { + return PLAIN_EOA_CODE; + } + return null; + }); + + await expect(run(messenger)).rejects.toThrow( + 'Expected hex string from eth_getTransactionCount, got null', + ); + expect(mocks.signEip7702Authorization).not.toHaveBeenCalled(); + }); + + it.each([ + ['a non-hex string', 'not-a-hex-string'], + ['a truncated hex string', `${MOCK_R}${MOCK_S_NO_PREFIX}`], + [ + 'an over-long hex string', + `${MOCK_R}${MOCK_S_NO_PREFIX}${MOCK_V_HEX}00`, + ], + ['null', null], + ])( + 'throws when signEip7702Authorization returns %s', + async (_label, value) => { + const { messenger, mocks } = setup(); + mocks.signEip7702Authorization.mockResolvedValue(value); + + await expect(run(messenger)).rejects.toThrow( + /Expected a 0x-prefixed 65-byte signature from signEip7702Authorization/u, + ); + expect(mocks.createUpgrade).not.toHaveBeenCalled(); + }, + ); + + it.each([ + ['0', '00'], + ['1', '01'], + ['26', '1a'], + ['29', '1d'], + ])('throws when v is %s rather than 27 or 28', async (vDecimal, vHex) => { + const { messenger, mocks } = setup(); + mocks.signEip7702Authorization.mockResolvedValue( + `${MOCK_R}${MOCK_S_NO_PREFIX}${vHex}`, + ); + + await expect(run(messenger)).rejects.toThrow( + `Expected v to be 27 or 28 in signEip7702Authorization signature, got ${vDecimal}`, + ); + expect(mocks.createUpgrade).not.toHaveBeenCalled(); + }); + + it('accepts an uppercase signature and normalizes it to lowercase', async () => { + const { messenger, mocks } = setup(); + const upperR = MOCK_R.toUpperCase().replace('0X', '0x'); + const upperS = MOCK_S_NO_PREFIX.toUpperCase(); + mocks.signEip7702Authorization.mockResolvedValue( + `${upperR}${upperS}${MOCK_V_HEX.toUpperCase()}`, + ); + + await run(messenger); + + expect(mocks.createUpgrade).toHaveBeenCalledWith( + expect.objectContaining({ + r: MOCK_R, + s: MOCK_S, + v: 28, + yParity: 1, + }), + ); + }); + }); +}); diff --git a/packages/money-account-upgrade-controller/src/steps/eip-7702-authorization.ts b/packages/money-account-upgrade-controller/src/steps/eip-7702-authorization.ts new file mode 100644 index 00000000000..45c5f5cb7a1 --- /dev/null +++ b/packages/money-account-upgrade-controller/src/steps/eip-7702-authorization.ts @@ -0,0 +1,235 @@ +import type { Provider } from '@metamask/network-controller'; +import { add0x, isStrictHexString } from '@metamask/utils'; +import type { Hex } from '@metamask/utils'; + +import { TerminalUpgradeError } from '../errors.js'; +import type { Step, StepContext } from './step.js'; + +const EIP_7702_DELEGATION_PREFIX = '0xef0100'; +// '0x' (2) + 'ef0100' (6) + 20-byte address (40) = 48 characters. +const EIP_7702_DELEGATED_CODE_LENGTH = 48; + +// 65-byte signature: 32-byte r + 32-byte s + 1-byte v. +// '0x' (2) + 32 bytes (64) + 32 bytes (64) + 1 byte (2) = 132 characters. +const SIGNATURE_HEX_LENGTH = 132; +// '0x' + 32-byte r = 66 characters. +const R_END_INDEX = 66; +// r (66 chars) + 32-byte s (64 chars) = 130 characters. +const S_END_INDEX = 130; +const V_END_INDEX = SIGNATURE_HEX_LENGTH; +// v = 27 means yParity = 0; v = 28 means yParity = 1. +const V_BASE = 27; + +/** + * Submits the EIP-7702 delegation-slot authorization to CHOMP so the Money + * Account can be upgraded to a smart account pointed at the configured + * delegator impl. + * + * The step: + * + * 1. Reads the account's on-chain code. If it is already delegated to the + * configured `delegatorImplAddress`, reports `'already-done'`. If it is + * delegated to a different address, throws rather than silently + * overwriting an existing delegation. + * 2. Fetches the account's current on-chain transaction count — CHOMP + * validates the nonce matches when it applies the authorization. + * 3. Signs the EIP-7702 authorization `{ chainId, delegatorImpl, nonce }` + * with the Money Account's key via the keyring. + * 4. Splits the 65-byte signature into `r`, `s`, `v`, `yParity` and submits + * it to `POST /v1/account-upgrade`. + */ +export const eip7702AuthorizationStep: Step = { + name: 'eip-7702-authorization', + async run({ messenger, address, chainId, delegatorImplAddress }) { + const provider = getProvider(messenger, chainId); + + const existingDelegation = await fetchDelegationAddress(provider, address); + if (existingDelegation !== undefined) { + if (existingDelegation === delegatorImplAddress.toLowerCase()) { + return 'already-done'; + } + throw new TerminalUpgradeError( + `Account ${address} is already upgraded to another smart account: ${existingDelegation}.`, + ); + } + + const chainIdDecimal = parseInt(chainId, 16); + const nonce = await fetchNonce(provider, address); + + const signature = await messenger.call( + 'KeyringController:signEip7702Authorization', + { + chainId: chainIdDecimal, + contractAddress: delegatorImplAddress, + nonce, + from: address, + }, + ); + + const { r, s, v, yParity } = splitEip7702Signature(signature); + + try { + await messenger.call('ChompApiService:createUpgrade', { + r, + s, + v, + yParity, + address: delegatorImplAddress, + chainId, + nonce: add0x(nonce.toString(16)), + }); + } catch (error) { + // CHOMP returns 409 when an authorization for this address already + // exists with the same or higher nonce — typically on retry when a + // previous submission was accepted but hasn't yet been observed + // on-chain (so `fetchDelegationAddress` returned undefined above). + // Treat as already-done so the upgrade sequence is retry-safe. + if (isHttp409(error)) { + return 'already-done'; + } + throw error; + } + + return 'completed'; + }, +}; + +function isHttp409(error: unknown): boolean { + if (typeof error !== 'object' || error === null) { + return false; + } + const { httpStatus } = error as { httpStatus?: unknown }; + return httpStatus === 409; +} + +/** + * Splits a 65-byte ECDSA signature produced by + * `KeyringController:signEip7702Authorization` into its `r`, `s`, `v` + * components and derives `yParity` (`0` for `v = 27`, `1` for `v = 28`). + * + * @param signature - A 0x-prefixed 132-character hex string. Accepted in any + * case; normalized to lowercase before validation. + * @returns The signature components. + */ +function splitEip7702Signature(signature: unknown): { + r: Hex; + s: Hex; + v: number; + yParity: 0 | 1; +} { + const normalized = + typeof signature === 'string' ? signature.toLowerCase() : signature; + + if ( + !isStrictHexString(normalized) || + normalized.length !== SIGNATURE_HEX_LENGTH + ) { + throw new Error( + `Expected a 0x-prefixed 65-byte signature from signEip7702Authorization, got ${JSON.stringify(signature)}`, + ); + } + + // eslint-disable-next-line id-length + const v = parseInt(normalized.slice(S_END_INDEX, V_END_INDEX), 16); + if (v !== 27 && v !== 28) { + throw new Error( + `Expected v to be 27 or 28 in signEip7702Authorization signature, got ${v}`, + ); + } + + return { + r: normalized.slice(0, R_END_INDEX) as Hex, + s: add0x(normalized.slice(R_END_INDEX, S_END_INDEX)), + v, + yParity: v === V_BASE ? 0 : 1, + }; +} + +/** + * Reads the account's on-chain code and, if the account is currently + * delegated via EIP-7702, returns the implementation address the delegation + * points at. Returns `undefined` if the account has no code (a plain EOA). + * Throws if the code is present but not a valid EIP-7702 delegation, since + * that means the address is a regular contract and is not eligible for + * upgrade. + * + * @param provider - JSON-RPC provider for the target chain. + * @param address - The Money Account address. + * @returns The current delegation address, or `undefined` if none. + */ +async function fetchDelegationAddress( + provider: Provider, + address: Hex, +): Promise { + const code = await provider.request({ + method: 'eth_getCode', + params: [address, 'latest'], + }); + + if (typeof code !== 'string' || !code.startsWith('0x')) { + throw new Error( + `Expected 0x-prefixed hex string from eth_getCode, got ${JSON.stringify(code)}`, + ); + } + + const normalized = code.toLowerCase(); + + if (normalized === '0x') { + return undefined; + } + + if ( + normalized.length === EIP_7702_DELEGATED_CODE_LENGTH && + normalized.startsWith(EIP_7702_DELEGATION_PREFIX) + ) { + return add0x(normalized.slice(EIP_7702_DELEGATION_PREFIX.length)); + } + + throw new TerminalUpgradeError( + `Account ${address} has unexpected on-chain code; expected either no code or an EIP-7702 delegation.`, + ); +} + +/** + * Fetches the current on-chain transaction count for the given address by + * issuing an `eth_getTransactionCount` RPC request. + * + * @param provider - JSON-RPC provider for the target chain. + * @param address - The Money Account address. + * @returns The current nonce as a decimal number. + */ +async function fetchNonce(provider: Provider, address: Hex): Promise { + const nonceHex = await provider.request({ + method: 'eth_getTransactionCount', + params: [address, 'latest'], + }); + + if (!isStrictHexString(nonceHex)) { + throw new Error( + `Expected hex string from eth_getTransactionCount, got ${JSON.stringify(nonceHex)}`, + ); + } + + return parseInt(nonceHex, 16); +} + +/** + * Resolves the JSON-RPC provider for the given chain via NetworkController. + * + * @param messenger - The upgrade controller messenger. + * @param chainId - The chain to query. + * @returns The provider for that chain. + */ +function getProvider( + messenger: StepContext['messenger'], + chainId: Hex, +): Provider { + const networkClientId = messenger.call( + 'NetworkController:findNetworkClientIdByChainId', + chainId, + ); + return messenger.call( + 'NetworkController:getNetworkClientById', + networkClientId, + ).provider; +} diff --git a/packages/money-account-upgrade-controller/src/steps/register-intents.test.ts b/packages/money-account-upgrade-controller/src/steps/register-intents.test.ts new file mode 100644 index 00000000000..87cf94993dd --- /dev/null +++ b/packages/money-account-upgrade-controller/src/steps/register-intents.test.ts @@ -0,0 +1,537 @@ +import type { + DelegationResponse, + DelegationMetadata, +} from '@metamask/authenticated-user-storage'; +import type { IntentEntry } from '@metamask/chomp-api-service'; +import { createRedeemerTerms } from '@metamask/delegation-core'; +import { Messenger, MOCK_ANY_NAMESPACE } from '@metamask/messenger'; +import type { + MockAnyNamespace, + MessengerActions, + MessengerEvents, +} from '@metamask/messenger'; +import type { Hex } from '@metamask/utils'; + +import type { MoneyAccountUpgradeControllerMessenger } from '../MoneyAccountUpgradeController.js'; +import { registerIntentsStep } from './register-intents.js'; + +jest.mock('@metamask/delegation-core', () => ({ + createRedeemerTerms: jest.fn(), +})); + +const mockCreateRedeemerTerms = jest.mocked(createRedeemerTerms); + +const MOCK_ADDRESS = '0xabcdef1234567890abcdef1234567890abcdef12' as Hex; +const MOCK_CHAIN_ID = '0xaa36a7' as Hex; // 11155111 (Sepolia) +const MOCK_DELEGATE = '0x1111111111111111111111111111111111111111' as Hex; +const MOCK_MUSD = '0x3333333333333333333333333333333333333333' as Hex; +const MOCK_BORING_VAULT = '0x7777777777777777777777777777777777777777' as Hex; +const MOCK_VAULT_ADAPTER = '0x4444444444444444444444444444444444444444' as Hex; +const MOCK_ERC20_ENFORCER = '0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa' as Hex; +const MOCK_REDEEMER_ENFORCER = + '0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb' as Hex; +const MOCK_VALUE_LTE_ENFORCER = + '0xcccccccccccccccccccccccccccccccccccccccc' as Hex; +const OTHER_ADDRESS = '0x9999999999999999999999999999999999999999' as Hex; +const OTHER_CHAIN_ID = '0x1' as Hex; +const OTHER_TOKEN = '0x8888888888888888888888888888888888888888' as Hex; + +const MOCK_MUSD_DELEGATION_HASH: Hex = `0x${'ee'.repeat(32)}`; +const MOCK_VMUSD_DELEGATION_HASH: Hex = `0x${'ff'.repeat(32)}`; +const MAX_UINT256_HEX: Hex = `0x${'f'.repeat(64)}`; +const MOCK_REDEEMER_TERMS: Hex = '0xa3'; + +/** + * Builds a `DelegationResponse` for use as a mocked `listDelegations` entry. + * Defaults match the deposit-side delegation written by the build-delegation + * step, including a redeemer caveat that points at the Veda vault adapter. + * Tests override identifying fields and metadata to probe the matcher. + * + * @param overrides - Identifying fields and metadata to override. + * @param overrides.delegator - The delegator address. + * @param overrides.delegate - The delegate address. + * @param overrides.chainIdHex - The chain ID in hex. + * @param overrides.tokenAddress - The token address. + * @param overrides.tokenSymbol - The token symbol. + * @param overrides.delegationHash - The delegation hash recorded in metadata. + * @param overrides.type - The metadata `type` field. + * @param overrides.caveats - The caveats attached to the delegation. Defaults + * to a single redeemer caveat targeting the Veda vault adapter. + * @returns A complete `DelegationResponse`. + */ +function makeDelegationResponse( + overrides: { + delegator?: Hex; + delegate?: Hex; + chainIdHex?: Hex; + tokenAddress?: Hex; + tokenSymbol?: string; + delegationHash?: Hex; + type?: DelegationMetadata['type']; + caveats?: { enforcer: Hex; terms: Hex; args: Hex }[]; + } = {}, +): DelegationResponse { + return { + signedDelegation: { + delegate: overrides.delegate ?? MOCK_DELEGATE, + delegator: overrides.delegator ?? MOCK_ADDRESS, + authority: `0x${'ff'.repeat(32)}`, + caveats: overrides.caveats ?? [ + { + enforcer: MOCK_REDEEMER_ENFORCER, + terms: MOCK_REDEEMER_TERMS, + args: '0x', + }, + ], + salt: `0x${'42'.repeat(32)}`, + signature: `0x${'cd'.repeat(65)}`, + }, + metadata: { + delegationHash: overrides.delegationHash ?? MOCK_MUSD_DELEGATION_HASH, + chainIdHex: overrides.chainIdHex ?? MOCK_CHAIN_ID, + allowance: MAX_UINT256_HEX, + tokenSymbol: overrides.tokenSymbol ?? 'mUSD', + tokenAddress: overrides.tokenAddress ?? MOCK_MUSD, + type: overrides.type ?? 'cash-deposit', + }, + }; +} + +const depositDelegation = (): DelegationResponse => + makeDelegationResponse({ + tokenAddress: MOCK_MUSD, + tokenSymbol: 'mUSD', + delegationHash: MOCK_MUSD_DELEGATION_HASH, + type: 'cash-deposit', + }); + +const withdrawalDelegation = (): DelegationResponse => + makeDelegationResponse({ + tokenAddress: MOCK_BORING_VAULT, + tokenSymbol: 'vmUSD', + delegationHash: MOCK_VMUSD_DELEGATION_HASH, + type: 'cash-withdrawal', + }); + +/** + * Builds an `IntentEntry` for use as a mocked `getIntentsByAddress` entry. + * Defaults to an active deposit-side intent matching the deposit delegation. + * + * @param overrides - Fields to override. + * @param overrides.delegationHash - The delegationHash this intent points at. + * @param overrides.status - The intent status (active or revoked). + * @returns A complete `IntentEntry`. + */ +function makeIntentEntry( + overrides: { delegationHash?: Hex; status?: IntentEntry['status'] } = {}, +): IntentEntry { + return { + account: MOCK_ADDRESS, + delegationHash: overrides.delegationHash ?? MOCK_MUSD_DELEGATION_HASH, + chainId: MOCK_CHAIN_ID, + status: overrides.status ?? 'active', + metadata: { + allowance: MAX_UINT256_HEX, + tokenAddress: MOCK_MUSD, + tokenSymbol: 'mUSD', + type: 'cash-deposit', + }, + }; +} + +type AllActions = MessengerActions; +type AllEvents = MessengerEvents; + +type Mocks = { + listDelegations: jest.Mock; + getIntentsByAddress: jest.Mock; + createIntents: jest.Mock; +}; + +function setup(): { + messenger: MoneyAccountUpgradeControllerMessenger; + mocks: Mocks; +} { + const mocks: Mocks = { + listDelegations: jest + .fn() + .mockResolvedValue([depositDelegation(), withdrawalDelegation()]), + getIntentsByAddress: jest.fn().mockResolvedValue([]), + createIntents: jest.fn().mockResolvedValue([]), + }; + + const rootMessenger = new Messenger({ + namespace: MOCK_ANY_NAMESPACE, + }); + rootMessenger.registerActionHandler( + 'AuthenticatedUserStorageService:listDelegations', + mocks.listDelegations, + ); + rootMessenger.registerActionHandler( + 'ChompApiService:getIntentsByAddress', + mocks.getIntentsByAddress, + ); + rootMessenger.registerActionHandler( + 'ChompApiService:createIntents', + mocks.createIntents, + ); + + const messenger: MoneyAccountUpgradeControllerMessenger = new Messenger({ + namespace: 'MoneyAccountUpgradeController', + parent: rootMessenger, + }); + rootMessenger.delegate({ + actions: [ + 'AuthenticatedUserStorageService:listDelegations', + 'ChompApiService:getIntentsByAddress', + 'ChompApiService:createIntents', + ], + events: [], + messenger, + }); + + return { messenger, mocks }; +} + +async function run( + messenger: MoneyAccountUpgradeControllerMessenger, +): ReturnType { + return registerIntentsStep.run({ + messenger, + address: MOCK_ADDRESS, + chainId: MOCK_CHAIN_ID, + boringVaultAddress: MOCK_BORING_VAULT, + delegateAddress: MOCK_DELEGATE, + delegatorImplAddress: '0x2222222222222222222222222222222222222222' as Hex, + erc20TransferAmountEnforcer: MOCK_ERC20_ENFORCER, + musdTokenAddress: MOCK_MUSD, + redeemerEnforcer: MOCK_REDEEMER_ENFORCER, + valueLteEnforcer: MOCK_VALUE_LTE_ENFORCER, + vedaVaultAdapterAddress: MOCK_VAULT_ADAPTER, + }); +} + +describe('registerIntentsStep', () => { + beforeEach(() => { + // The terms factory is overloaded over output encoding; the runtime path + // picks the hex overload, but `jest.mocked()` picks the bytes overload, so + // cast through `never` to satisfy both. + mockCreateRedeemerTerms.mockReturnValue(MOCK_REDEEMER_TERMS as never); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + it('is named "register-intents"', () => { + expect(registerIntentsStep.name).toBe('register-intents'); + }); + + describe('when no intents exist for the account', () => { + it('submits an intent for each stored delegation and returns "completed"', async () => { + const { messenger, mocks } = setup(); + + const result = await run(messenger); + + expect(result).toBe('completed'); + expect(mocks.createIntents).toHaveBeenCalledTimes(1); + + const [submitted] = mocks.createIntents.mock.calls[0]; + expect(submitted).toStrictEqual([ + { + account: MOCK_ADDRESS, + delegationHash: MOCK_MUSD_DELEGATION_HASH, + chainId: MOCK_CHAIN_ID, + metadata: { + allowance: MAX_UINT256_HEX, + tokenSymbol: 'mUSD', + tokenAddress: MOCK_MUSD, + type: 'cash-deposit', + }, + }, + { + account: MOCK_ADDRESS, + delegationHash: MOCK_VMUSD_DELEGATION_HASH, + chainId: MOCK_CHAIN_ID, + metadata: { + allowance: MAX_UINT256_HEX, + tokenSymbol: 'vmUSD', + tokenAddress: MOCK_BORING_VAULT, + type: 'cash-withdrawal', + }, + }, + ]); + }); + }); + + describe('when an active intent already exists for one delegation', () => { + it('submits only the missing intent', async () => { + const { messenger, mocks } = setup(); + mocks.getIntentsByAddress.mockResolvedValue([ + makeIntentEntry({ delegationHash: MOCK_MUSD_DELEGATION_HASH }), + ]); + + const result = await run(messenger); + + expect(result).toBe('completed'); + expect(mocks.createIntents).toHaveBeenCalledTimes(1); + const [submitted] = mocks.createIntents.mock.calls[0]; + expect(submitted).toHaveLength(1); + expect(submitted[0].delegationHash).toBe(MOCK_VMUSD_DELEGATION_HASH); + expect(submitted[0].metadata.type).toBe('cash-withdrawal'); + }); + + it('matches delegationHash case-insensitively', async () => { + const { messenger, mocks } = setup(); + mocks.getIntentsByAddress.mockResolvedValue([ + makeIntentEntry({ + delegationHash: MOCK_MUSD_DELEGATION_HASH.toUpperCase() as Hex, + }), + makeIntentEntry({ + delegationHash: MOCK_VMUSD_DELEGATION_HASH.toUpperCase() as Hex, + }), + ]); + + const result = await run(messenger); + + expect(result).toBe('already-done'); + expect(mocks.createIntents).not.toHaveBeenCalled(); + }); + }); + + describe('when active intents already exist for both delegations', () => { + it('returns "already-done" without calling createIntents', async () => { + const { messenger, mocks } = setup(); + mocks.getIntentsByAddress.mockResolvedValue([ + makeIntentEntry({ delegationHash: MOCK_MUSD_DELEGATION_HASH }), + makeIntentEntry({ delegationHash: MOCK_VMUSD_DELEGATION_HASH }), + ]); + + const result = await run(messenger); + + expect(result).toBe('already-done'); + expect(mocks.createIntents).not.toHaveBeenCalled(); + }); + }); + + describe('when an intent exists but is revoked', () => { + it('re-registers the revoked intent', async () => { + const { messenger, mocks } = setup(); + mocks.getIntentsByAddress.mockResolvedValue([ + makeIntentEntry({ + delegationHash: MOCK_MUSD_DELEGATION_HASH, + status: 'revoked', + }), + makeIntentEntry({ + delegationHash: MOCK_VMUSD_DELEGATION_HASH, + status: 'active', + }), + ]); + + const result = await run(messenger); + + expect(result).toBe('completed'); + expect(mocks.createIntents).toHaveBeenCalledTimes(1); + const [submitted] = mocks.createIntents.mock.calls[0]; + expect(submitted).toHaveLength(1); + expect(submitted[0].delegationHash).toBe(MOCK_MUSD_DELEGATION_HASH); + }); + }); + + describe('filtering stored delegations', () => { + it('ignores delegations from a different delegator', async () => { + const { messenger, mocks } = setup(); + mocks.listDelegations.mockResolvedValue([ + makeDelegationResponse({ + delegator: OTHER_ADDRESS, + delegationHash: `0x${'01'.repeat(32)}`, + }), + depositDelegation(), + withdrawalDelegation(), + ]); + + await run(messenger); + + const [submitted] = mocks.createIntents.mock.calls[0]; + expect(submitted).toHaveLength(2); + expect( + submitted.map( + (intent: { delegationHash: Hex }) => intent.delegationHash, + ), + ).toStrictEqual([MOCK_MUSD_DELEGATION_HASH, MOCK_VMUSD_DELEGATION_HASH]); + }); + + it('ignores delegations to a different delegate', async () => { + const { messenger, mocks } = setup(); + mocks.listDelegations.mockResolvedValue([ + makeDelegationResponse({ + delegate: OTHER_ADDRESS, + delegationHash: `0x${'02'.repeat(32)}`, + }), + depositDelegation(), + withdrawalDelegation(), + ]); + + await run(messenger); + + const [submitted] = mocks.createIntents.mock.calls[0]; + expect(submitted).toHaveLength(2); + }); + + it('ignores delegations on a different chain', async () => { + const { messenger, mocks } = setup(); + mocks.listDelegations.mockResolvedValue([ + makeDelegationResponse({ + chainIdHex: OTHER_CHAIN_ID, + delegationHash: `0x${'03'.repeat(32)}`, + }), + depositDelegation(), + withdrawalDelegation(), + ]); + + await run(messenger); + + const [submitted] = mocks.createIntents.mock.calls[0]; + expect(submitted).toHaveLength(2); + }); + + it('matches identifying fields case-insensitively', async () => { + const { messenger, mocks } = setup(); + mocks.listDelegations.mockResolvedValue([ + makeDelegationResponse({ + delegator: MOCK_ADDRESS.toUpperCase() as Hex, + delegate: MOCK_DELEGATE.toUpperCase() as Hex, + chainIdHex: MOCK_CHAIN_ID.toUpperCase() as Hex, + tokenAddress: MOCK_MUSD, + tokenSymbol: 'mUSD', + delegationHash: MOCK_MUSD_DELEGATION_HASH, + type: 'cash-deposit', + }), + withdrawalDelegation(), + ]); + + const result = await run(messenger); + + expect(result).toBe('completed'); + const [submitted] = mocks.createIntents.mock.calls[0]; + expect(submitted).toHaveLength(2); + }); + + it('returns "already-done" when no delegations match the filter', async () => { + const { messenger, mocks } = setup(); + mocks.listDelegations.mockResolvedValue([ + makeDelegationResponse({ + delegator: OTHER_ADDRESS, + tokenAddress: OTHER_TOKEN, + }), + ]); + + const result = await run(messenger); + + expect(result).toBe('already-done'); + expect(mocks.createIntents).not.toHaveBeenCalled(); + }); + + it('ignores delegations for a token address that is no longer configured', async () => { + const { messenger, mocks } = setup(); + mocks.listDelegations.mockResolvedValue([ + // Stale withdrawal delegation left over from a previous config (e.g. a + // dev boring vault address) that still carries the current redeemer + // caveat. It must not be registered against the current configuration. + makeDelegationResponse({ + tokenAddress: OTHER_TOKEN, + tokenSymbol: 'vmUSD', + delegationHash: `0x${'04'.repeat(32)}`, + type: 'cash-withdrawal', + }), + depositDelegation(), + withdrawalDelegation(), + ]); + + const result = await run(messenger); + + expect(result).toBe('completed'); + const [submitted] = mocks.createIntents.mock.calls[0]; + expect(submitted).toHaveLength(2); + expect( + submitted.map( + (intent: { delegationHash: Hex }) => intent.delegationHash, + ), + ).toStrictEqual([MOCK_MUSD_DELEGATION_HASH, MOCK_VMUSD_DELEGATION_HASH]); + }); + + it('ignores delegations whose caveats do not include a redeemer caveat targeting the Veda vault adapter', async () => { + const { messenger, mocks } = setup(); + mocks.listDelegations.mockResolvedValue([ + // No caveats at all. + makeDelegationResponse({ + tokenAddress: MOCK_MUSD, + delegationHash: MOCK_MUSD_DELEGATION_HASH, + caveats: [], + }), + // Right enforcer, wrong terms (different redeemer encoded). + makeDelegationResponse({ + tokenAddress: MOCK_BORING_VAULT, + tokenSymbol: 'vmUSD', + delegationHash: MOCK_VMUSD_DELEGATION_HASH, + type: 'cash-withdrawal', + caveats: [ + { + enforcer: MOCK_REDEEMER_ENFORCER, + terms: '0xdeadbeef', + args: '0x', + }, + ], + }), + ]); + + const result = await run(messenger); + + expect(result).toBe('already-done'); + expect(mocks.createIntents).not.toHaveBeenCalled(); + }); + }); + + describe('when a stored delegation has an unrecognized metadata type', () => { + it('throws rather than coercing into a CHOMP intent', async () => { + const { messenger, mocks } = setup(); + mocks.listDelegations.mockResolvedValue([ + makeDelegationResponse({ + tokenAddress: MOCK_MUSD, + delegationHash: MOCK_MUSD_DELEGATION_HASH, + type: 'lend', + }), + ]); + + await expect(run(messenger)).rejects.toThrow( + 'Expected delegation type to be "cash-deposit" or "cash-withdrawal", got "lend"', + ); + expect(mocks.createIntents).not.toHaveBeenCalled(); + }); + }); + + describe('error propagation', () => { + it('propagates errors from listDelegations and does not call createIntents', async () => { + const { messenger, mocks } = setup(); + mocks.listDelegations.mockRejectedValue(new Error('storage failed')); + + await expect(run(messenger)).rejects.toThrow('storage failed'); + expect(mocks.createIntents).not.toHaveBeenCalled(); + }); + + it('propagates errors from getIntentsByAddress and does not call createIntents', async () => { + const { messenger, mocks } = setup(); + mocks.getIntentsByAddress.mockRejectedValue(new Error('chomp failed')); + + await expect(run(messenger)).rejects.toThrow('chomp failed'); + expect(mocks.createIntents).not.toHaveBeenCalled(); + }); + + it('propagates errors from createIntents', async () => { + const { messenger, mocks } = setup(); + mocks.createIntents.mockRejectedValue(new Error('submit failed')); + + await expect(run(messenger)).rejects.toThrow('submit failed'); + }); + }); +}); diff --git a/packages/money-account-upgrade-controller/src/steps/register-intents.ts b/packages/money-account-upgrade-controller/src/steps/register-intents.ts new file mode 100644 index 00000000000..eda4b0d0b9d --- /dev/null +++ b/packages/money-account-upgrade-controller/src/steps/register-intents.ts @@ -0,0 +1,110 @@ +import type { DelegationResponse } from '@metamask/authenticated-user-storage'; +import type { + IntentEntry, + SendIntentParams, +} from '@metamask/chomp-api-service'; + +import { + equalsIgnoreCase, + makeHasVedaRedeemerCaveat, +} from './delegation-matchers.js'; +import type { Step } from './step.js'; + +type IntentMetadataType = SendIntentParams['metadata']['type']; + +/** + * Parses a delegation's metadata `type` field — typed as `string` in storage — + * into the narrow set of CHOMP intent types. Throws if the field carries any + * other value, since registering it as an intent would be a category error. + * + * @param type - The `type` field from `DelegationMetadata`. + * @returns The same value, narrowed to `IntentMetadataType`. + */ +function parseIntentMetadataType(type: string): IntentMetadataType { + if (type !== 'cash-deposit' && type !== 'cash-withdrawal') { + throw new Error( + `Expected delegation type to be "cash-deposit" or "cash-withdrawal", got "${type}"`, + ); + } + return type; +} + +/** + * Registers CHOMP intents for the auto-deposit / auto-withdrawal delegations + * persisted by the build-delegation step. + * + * For each stored delegation between this account and CHOMP's delegate on + * this chain, the step builds an intent referencing the stored + * `delegationHash` and submits the batch to `POST /v1/intent`. Delegations + * whose `delegationHash` already has an active intent on CHOMP are skipped + * (revoked intents are re-registered). Reports `'already-done'` when every + * eligible delegation already has an active intent. + * + * Once registered, CHOMP re-fetches the delegation from Authenticated User + * Storage, re-validates it, and adds the account to its monitoring list so + * subsequent eligible operations can be picked up automatically. + */ +export const registerIntentsStep: Step = { + name: 'register-intents', + async run({ + messenger, + address, + chainId, + boringVaultAddress, + delegateAddress, + musdTokenAddress, + redeemerEnforcer, + vedaVaultAdapterAddress, + }) { + const [delegations, existingIntents] = await Promise.all([ + messenger.call('AuthenticatedUserStorageService:listDelegations'), + messenger.call('ChompApiService:getIntentsByAddress', address), + ]); + + const activeIntentHashes = new Set( + existingIntents + .filter((intent: IntentEntry) => intent.status === 'active') + .map((intent: IntentEntry) => intent.delegationHash.toLowerCase()), + ); + + const hasVedaRedeemerCaveat = makeHasVedaRedeemerCaveat( + redeemerEnforcer, + vedaVaultAdapterAddress, + ); + + const configuredTokenAddresses = [musdTokenAddress, boringVaultAddress]; + const matchesConfiguredToken = (entry: DelegationResponse): boolean => + configuredTokenAddresses.some((tokenAddress) => + equalsIgnoreCase(entry.metadata.tokenAddress, tokenAddress), + ); + + const needsIntent = (entry: DelegationResponse): boolean => + equalsIgnoreCase(entry.signedDelegation.delegator, address) && + equalsIgnoreCase(entry.signedDelegation.delegate, delegateAddress) && + equalsIgnoreCase(entry.metadata.chainIdHex, chainId) && + matchesConfiguredToken(entry) && + hasVedaRedeemerCaveat(entry) && + !activeIntentHashes.has(entry.metadata.delegationHash.toLowerCase()); + + const toIntent = (entry: DelegationResponse): SendIntentParams => ({ + account: address, + delegationHash: entry.metadata.delegationHash, + chainId, + metadata: { + allowance: entry.metadata.allowance, + tokenSymbol: entry.metadata.tokenSymbol, + tokenAddress: entry.metadata.tokenAddress, + type: parseIntentMetadataType(entry.metadata.type), + }, + }); + + const intents = delegations.filter(needsIntent).map(toIntent); + + if (intents.length === 0) { + return 'already-done'; + } + + await messenger.call('ChompApiService:createIntents', intents); + return 'completed'; + }, +}; diff --git a/packages/money-account-upgrade-controller/src/steps/step.ts b/packages/money-account-upgrade-controller/src/steps/step.ts new file mode 100644 index 00000000000..70e2855380e --- /dev/null +++ b/packages/money-account-upgrade-controller/src/steps/step.ts @@ -0,0 +1,41 @@ +import type { Hex } from '@metamask/utils'; + +import type { MoneyAccountUpgradeControllerMessenger } from '../MoneyAccountUpgradeController.js'; + +/** + * Context supplied to each step when it is run. + */ +export type StepContext = { + messenger: MoneyAccountUpgradeControllerMessenger; + address: Hex; + chainId: Hex; + boringVaultAddress: Hex; + delegateAddress: Hex; + delegatorImplAddress: Hex; + erc20TransferAmountEnforcer: Hex; + musdTokenAddress: Hex; + redeemerEnforcer: Hex; + valueLteEnforcer: Hex; + vedaVaultAdapterAddress: Hex; +}; + +/** + * The outcome of running a single step in the Money Account upgrade sequence. + * + * - `'already-done'` — the step's remote check determined that no work was + * required; no action was taken. + * - `'completed'` — the step performed its action and is now done. + */ +export type StepResult = 'already-done' | 'completed'; + +/** + * A single step in the Money Account upgrade sequence. + * + * Each step is responsible for checking whether its action has already been + * applied (returning `'already-done'` if so) and otherwise performing the + * action and returning `'completed'`. + */ +export type Step = { + name: string; + run: (context: StepContext) => Promise; +}; diff --git a/packages/money-account-upgrade-controller/src/types.ts b/packages/money-account-upgrade-controller/src/types.ts new file mode 100644 index 00000000000..db8db0ab263 --- /dev/null +++ b/packages/money-account-upgrade-controller/src/types.ts @@ -0,0 +1,29 @@ +import type { Hex } from '@metamask/utils'; + +/** + * Configuration required to perform the Money Account upgrade sequence. + * + * `delegateAddress`, `musdTokenAddress`, and `vedaVaultAdapterAddress` come + * from the CHOMP service details API. `delegatorImplAddress` and the caveat + * enforcer addresses are resolved from `@metamask/delegation-deployments` for + * the target chain. (DelegationManager resolution is delegated to + * `@metamask/delegation-controller`, which handles delegation signing.) + */ +export type UpgradeConfig = { + /** CHOMP's delegate address — receives the delegation. */ + delegateAddress: Hex; + /** The mUSD token contract address (deposit-side delegation token). */ + musdTokenAddress: Hex; + /** The Veda boring vault contract address (withdrawal-side delegation token, vmUSD). */ + boringVaultAddress: Hex; + /** The Veda vault adapter contract address. */ + vedaVaultAdapterAddress: Hex; + /** The EIP-7702 delegation target (EIP7702StatelessDeleGatorImpl). */ + delegatorImplAddress: Hex; + /** Address of the ERC20TransferAmountEnforcer caveat enforcer. */ + erc20TransferAmountEnforcer: Hex; + /** Address of the RedeemerEnforcer caveat enforcer. */ + redeemerEnforcer: Hex; + /** Address of the ValueLteEnforcer caveat enforcer. */ + valueLteEnforcer: Hex; +}; diff --git a/packages/money-account-upgrade-controller/tsconfig.build.json b/packages/money-account-upgrade-controller/tsconfig.build.json new file mode 100644 index 00000000000..66f113f221d --- /dev/null +++ b/packages/money-account-upgrade-controller/tsconfig.build.json @@ -0,0 +1,32 @@ +{ + "extends": "../../tsconfig.packages.build.json", + "compilerOptions": { + "baseUrl": "./", + "outDir": "./dist", + "rootDir": "./src" + }, + "references": [ + { + "path": "../authenticated-user-storage/tsconfig.build.json" + }, + { + "path": "../base-controller/tsconfig.build.json" + }, + { + "path": "../chomp-api-service/tsconfig.build.json" + }, + { + "path": "../delegation-controller/tsconfig.build.json" + }, + { + "path": "../keyring-controller/tsconfig.build.json" + }, + { + "path": "../messenger/tsconfig.build.json" + }, + { + "path": "../network-controller/tsconfig.build.json" + } + ], + "include": ["../../types", "./src"] +} diff --git a/packages/money-account-upgrade-controller/tsconfig.json b/packages/money-account-upgrade-controller/tsconfig.json new file mode 100644 index 00000000000..1273483ee1d --- /dev/null +++ b/packages/money-account-upgrade-controller/tsconfig.json @@ -0,0 +1,30 @@ +{ + "extends": "../../tsconfig.packages.json", + "compilerOptions": { + "baseUrl": "./" + }, + "references": [ + { + "path": "../authenticated-user-storage" + }, + { + "path": "../base-controller" + }, + { + "path": "../chomp-api-service" + }, + { + "path": "../delegation-controller" + }, + { + "path": "../keyring-controller" + }, + { + "path": "../messenger" + }, + { + "path": "../network-controller" + } + ], + "include": ["../../types", "./src"] +} diff --git a/packages/money-account-upgrade-controller/typedoc.json b/packages/money-account-upgrade-controller/typedoc.json new file mode 100644 index 00000000000..c9da015dbf8 --- /dev/null +++ b/packages/money-account-upgrade-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/money-account-utils/CHANGELOG.md b/packages/money-account-utils/CHANGELOG.md new file mode 100644 index 00000000000..23da8d9400a --- /dev/null +++ b/packages/money-account-utils/CHANGELOG.md @@ -0,0 +1,42 @@ +# 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] + +### Changed + +- Bump `@metamask/transaction-controller` from `^69.4.0` to `^69.6.1` ([#9780](https://github.com/MetaMask/core/pull/9780), [#9798](https://github.com/MetaMask/core/pull/9798), [#9823](https://github.com/MetaMask/core/pull/9823), [#9960](https://github.com/MetaMask/core/pull/9960), [#9969](https://github.com/MetaMask/core/pull/9969)) + +## [1.1.0] + +### Added + +- Add Money Account transaction batch builders, ported from MetaMask Mobile ([#9680](https://github.com/MetaMask/core/pull/9680)) + - `buildMoneyAccountDepositBatch` builds the approve + deposit call pair, deriving `minimumMint` from the vault lens' `previewDeposit` less a 0.2% slippage tolerance + - `buildMoneyAccountWithdrawBatch` builds the withdraw + transfer call pair, converting the asset amount to vault shares at the accountant's current rate + - Both take an `@ethersproject` `Provider` for their read calls, and both throw on a zero amount rather than encoding a call that cannot succeed — a zero-share redemption is rejected by the teller, and a zero-amount deposit mints nothing + - `buildMoneyAccountDepositPlaceholderBatch` and `buildMoneyAccountWithdrawPlaceholderBatch` resolve their call targets and types without calldata, for placeholder batches that MetaMask Pay re-encodes once the user picks an amount. They perform no vault reads, so they are synchronous and take only `chainId` and `tellerAddress` + - `getMoneyAccountDepositAssetId` returns the CAIP-19 asset id of the deposit asset for a chain, or `undefined` if mUSD is not deployed there. Clients that want Money Account's Monad-only default apply it at the call site + - Supporting exports: `applySlippage`, `getSharesForWithdrawal`, `getMoneyAccountDepositAssetAddress`, `TELLER_ABI`, and the `MoneyAccountTxParams`, `MoneyAccountPlaceholderTxParams`, `MoneyAccountDepositBatchResult`, `MoneyAccountDepositPlaceholderBatchResult`, `MoneyAccountWithdrawBatchResult`, `MoneyAccountWithdrawPlaceholderBatchResult`, `BuildMoneyAccountDepositBatchOptions`, `BuildMoneyAccountDepositPlaceholderBatchOptions`, `BuildMoneyAccountWithdrawBatchOptions`, `BuildMoneyAccountWithdrawPlaceholderBatchOptions` types + +### Changed + +- Type the values of `MUSD_TOKEN_ASSET_ID_BY_CHAIN` as `CaipAssetType` rather than `string` ([#9680](https://github.com/MetaMask/core/pull/9680)) +- Bump `@metamask/transaction-controller` from `^69.3.0` to `^69.4.0` ([#9735](https://github.com/MetaMask/core/pull/9735)) + +## [1.0.0] + +### Added + +- Add mUSD token constants and guards, ported from MetaMask Mobile ([#9397](https://github.com/MetaMask/core/pull/9397)) + - Constants: `MUSD_TOKEN` (without client icon assets), `MUSD_DECIMALS`, `MUSD_TOKEN_ADDRESS`, `MUSD_TOKEN_ADDRESS_BY_CHAIN`, `MUSD_TOKEN_ASSET_ID_BY_CHAIN`, `MUSD_CURRENCY`, `MUSD_MONEY_ACCOUNT_CHAIN_IDS` + - Guards: `isMusdToken`, `isMusdTokenOnChain`, `isMusdOnMoneyAccountChain` +- Add `getTokenDisplaySymbol`, ported from MetaMask Mobile, which canonicalises the registry symbol of the mUSD token to its branded casing (`MUSD` → `mUSD`) and passes all other symbols through unchanged ([#9397](https://github.com/MetaMask/core/pull/9397)) + +[Unreleased]: https://github.com/MetaMask/core/compare/@metamask/money-account-utils@1.1.0...HEAD +[1.1.0]: https://github.com/MetaMask/core/compare/@metamask/money-account-utils@1.0.0...@metamask/money-account-utils@1.1.0 +[1.0.0]: https://github.com/MetaMask/core/releases/tag/@metamask/money-account-utils@1.0.0 diff --git a/packages/money-account-utils/LICENSE b/packages/money-account-utils/LICENSE new file mode 100644 index 00000000000..9ec4f4514ea --- /dev/null +++ b/packages/money-account-utils/LICENSE @@ -0,0 +1,6 @@ +This project is licensed under either of + + * MIT license ([LICENSE.MIT](LICENSE.MIT)) + * Apache License, Version 2.0 ([LICENSE.APACHE2](LICENSE.APACHE2)) + +at your option. diff --git a/packages/money-account-utils/LICENSE.APACHE2 b/packages/money-account-utils/LICENSE.APACHE2 new file mode 100644 index 00000000000..e6e77b08909 --- /dev/null +++ b/packages/money-account-utils/LICENSE.APACHE2 @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/packages/money-account-utils/LICENSE.MIT b/packages/money-account-utils/LICENSE.MIT new file mode 100644 index 00000000000..fe29e78e0fe --- /dev/null +++ b/packages/money-account-utils/LICENSE.MIT @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 MetaMask + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packages/money-account-utils/README.md b/packages/money-account-utils/README.md new file mode 100644 index 00000000000..8b92aeb484a --- /dev/null +++ b/packages/money-account-utils/README.md @@ -0,0 +1,15 @@ +# `@metamask/money-account-utils` + +Shared money account utilities: mUSD constants and vault transaction builders. + +## Installation + +`yarn add @metamask/money-account-utils` + +or + +`npm install @metamask/money-account-utils` + +## 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/money-account-utils/jest.config.js b/packages/money-account-utils/jest.config.js new file mode 100644 index 00000000000..ca084133399 --- /dev/null +++ b/packages/money-account-utils/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/money-account-utils/package.json b/packages/money-account-utils/package.json new file mode 100644 index 00000000000..f56a8c04273 --- /dev/null +++ b/packages/money-account-utils/package.json @@ -0,0 +1,79 @@ +{ + "name": "@metamask/money-account-utils", + "version": "1.1.0", + "description": "Shared money account utilities: mUSD constants and vault transaction builders", + "keywords": [ + "Ethereum", + "MetaMask" + ], + "homepage": "https://github.com/MetaMask/core/tree/main/packages/money-account-utils#readme", + "bugs": { + "url": "https://github.com/MetaMask/core/issues" + }, + "license": "(MIT OR Apache-2.0)", + "repository": { + "type": "git", + "url": "https://github.com/MetaMask/core.git" + }, + "files": [ + "dist/" + ], + "sideEffects": false, + "main": "./dist/index.cjs", + "types": "./dist/index.d.cts", + "exports": { + ".": { + "import": { + "types": "./dist/index.d.mts", + "default": "./dist/index.mjs" + }, + "require": { + "types": "./dist/index.d.cts", + "default": "./dist/index.cjs" + } + }, + "./package.json": "./package.json" + }, + "publishConfig": { + "access": "public", + "registry": "https://registry.npmjs.org/" + }, + "scripts": { + "build": "ts-bridge --project tsconfig.build.json --verbose --clean --no-references", + "build:all": "ts-bridge --project tsconfig.build.json --verbose --clean", + "build:docs": "typedoc", + "changelog:update": "../../scripts/update-changelog.sh @metamask/money-account-utils", + "changelog:validate": "../../scripts/validate-changelog.sh @metamask/money-account-utils", + "lint:tsconfigs": "tsx ../../scripts/lint-tsconfigs/lint-tsconfigs.mts", + "lint:tsconfigs:fix": "tsx ../../scripts/lint-tsconfigs/lint-tsconfigs.mts --fix", + "messenger-action-types:check": "tsx ../../packages/messenger-cli/src/cli.ts --formatter oxfmt --check", + "messenger-action-types:generate": "tsx ../../packages/messenger-cli/src/cli.ts --formatter oxfmt --generate", + "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": { + "@ethersproject/abi": "^5.7.0", + "@ethersproject/abstract-provider": "^5.7.0", + "@ethersproject/contracts": "^5.7.0", + "@metamask/transaction-controller": "^69.6.1", + "@metamask/utils": "^11.11.0" + }, + "devDependencies": { + "@metamask/auto-changelog": "^6.1.0", + "@ts-bridge/cli": "^0.6.4", + "@types/jest": "^30.0.0", + "deepmerge": "^4.2.2", + "jest": "^30.4.2", + "ts-jest": "^29.4.11", + "tsx": "^4.20.5", + "typedoc": "^0.25.13", + "typedoc-plugin-missing-exports": "^2.0.0", + "typescript": "~5.3.3" + }, + "engines": { + "node": "^18.18 || >=20" + } +} diff --git a/packages/money-account-utils/src/index.ts b/packages/money-account-utils/src/index.ts new file mode 100644 index 00000000000..cb74ffbacee --- /dev/null +++ b/packages/money-account-utils/src/index.ts @@ -0,0 +1,36 @@ +export { + MUSD_TOKEN, + MUSD_DECIMALS, + MUSD_TOKEN_ADDRESS, + MUSD_TOKEN_ADDRESS_BY_CHAIN, + MUSD_TOKEN_ASSET_ID_BY_CHAIN, + MUSD_CURRENCY, + MUSD_MONEY_ACCOUNT_CHAIN_IDS, + getTokenDisplaySymbol, + isMusdToken, + isMusdTokenOnChain, + isMusdOnMoneyAccountChain, +} from './musd.js'; +export { + TELLER_ABI, + applySlippage, + buildMoneyAccountDepositBatch, + buildMoneyAccountDepositPlaceholderBatch, + buildMoneyAccountWithdrawBatch, + buildMoneyAccountWithdrawPlaceholderBatch, + getMoneyAccountDepositAssetAddress, + getMoneyAccountDepositAssetId, + getSharesForWithdrawal, +} from './transactions.js'; +export type { + BuildMoneyAccountDepositBatchOptions, + BuildMoneyAccountDepositPlaceholderBatchOptions, + BuildMoneyAccountWithdrawBatchOptions, + BuildMoneyAccountWithdrawPlaceholderBatchOptions, + MoneyAccountDepositBatchResult, + MoneyAccountDepositPlaceholderBatchResult, + MoneyAccountPlaceholderTxParams, + MoneyAccountTxParams, + MoneyAccountWithdrawBatchResult, + MoneyAccountWithdrawPlaceholderBatchResult, +} from './transactions.js'; diff --git a/packages/money-account-utils/src/musd.test.ts b/packages/money-account-utils/src/musd.test.ts new file mode 100644 index 00000000000..a9c61e215fb --- /dev/null +++ b/packages/money-account-utils/src/musd.test.ts @@ -0,0 +1,170 @@ +import { CHAIN_IDS } from '@metamask/transaction-controller'; + +import { + getTokenDisplaySymbol, + isMusdOnMoneyAccountChain, + isMusdToken, + isMusdTokenOnChain, + MUSD_DECIMALS, + MUSD_MONEY_ACCOUNT_CHAIN_IDS, + MUSD_TOKEN, + MUSD_TOKEN_ADDRESS, + MUSD_TOKEN_ADDRESS_BY_CHAIN, + MUSD_TOKEN_ASSET_ID_BY_CHAIN, +} from './musd.js'; + +const MUSD_ADDRESS = MUSD_TOKEN_ADDRESS_BY_CHAIN[CHAIN_IDS.MAINNET]; + +describe('mUSD constants', () => { + it('derives MUSD_DECIMALS from MUSD_TOKEN', () => { + expect(MUSD_DECIMALS).toBe(MUSD_TOKEN.decimals); + }); + + it('uses the same address on every supported chain', () => { + for (const address of Object.values(MUSD_TOKEN_ADDRESS_BY_CHAIN)) { + expect(address).toBe(MUSD_TOKEN_ADDRESS); + } + }); + + it('defines a CAIP asset id for every chain mUSD is deployed on', () => { + expect(Object.keys(MUSD_TOKEN_ASSET_ID_BY_CHAIN).sort()).toStrictEqual( + Object.keys(MUSD_TOKEN_ADDRESS_BY_CHAIN).sort(), + ); + for (const assetId of Object.values(MUSD_TOKEN_ASSET_ID_BY_CHAIN)) { + expect(assetId.toLowerCase()).toContain( + `erc20:${MUSD_TOKEN_ADDRESS.toLowerCase()}`, + ); + } + }); + + it('only tracks Money Account activity on a subset of deployed chains', () => { + for (const chainId of MUSD_MONEY_ACCOUNT_CHAIN_IDS) { + expect(MUSD_TOKEN_ADDRESS_BY_CHAIN[chainId]).toBeDefined(); + } + }); +}); + +describe('isMusdToken', () => { + it('returns true for the mUSD token address in lowercase', () => { + expect(isMusdToken(MUSD_ADDRESS)).toBe(true); + }); + + it('returns true for the mUSD token address in uppercase', () => { + expect(isMusdToken(MUSD_ADDRESS.toUpperCase())).toBe(true); + }); + + it('returns true for the mUSD token address with mixed case', () => { + expect(isMusdToken('0xAcA92E438df0B2401fF60dA7E4337B687a2435DA')).toBe( + true, + ); + }); + + it('returns false for a non-mUSD token address', () => { + expect(isMusdToken('0x1234567890123456789012345678901234567890')).toBe( + false, + ); + }); + + it('returns false for an undefined address', () => { + expect(isMusdToken(undefined)).toBe(false); + }); + + it('returns false for an empty string address', () => { + expect(isMusdToken('')).toBe(false); + }); +}); + +describe('getTokenDisplaySymbol', () => { + it('canonicalises the registry symbol to the branded casing for the mUSD address', () => { + expect(getTokenDisplaySymbol(MUSD_ADDRESS, 'MUSD')).toBe(MUSD_TOKEN.symbol); + }); + + it('canonicalises regardless of address casing', () => { + expect( + getTokenDisplaySymbol( + '0xAcA92E438df0B2401fF60dA7E4337B687a2435DA', + 'MUSD', + ), + ).toBe(MUSD_TOKEN.symbol); + }); + + it('passes non-mUSD symbols through untouched', () => { + expect( + getTokenDisplaySymbol( + '0x1234567890123456789012345678901234567890', + 'USDC', + ), + ).toBe('USDC'); + }); + + it('passes the symbol through when the address is undefined', () => { + expect(getTokenDisplaySymbol(undefined, 'USDC')).toBe('USDC'); + }); + + it('returns undefined when a non-mUSD address has no symbol', () => { + expect( + getTokenDisplaySymbol( + '0x1234567890123456789012345678901234567890', + undefined, + ), + ).toBeUndefined(); + }); +}); + +describe('isMusdTokenOnChain', () => { + it('returns true for the mUSD address on a supported chain', () => { + expect(isMusdTokenOnChain(MUSD_ADDRESS, CHAIN_IDS.MAINNET)).toBe(true); + expect(isMusdTokenOnChain(MUSD_ADDRESS, CHAIN_IDS.LINEA_MAINNET)).toBe( + true, + ); + expect(isMusdTokenOnChain(MUSD_ADDRESS, CHAIN_IDS.BSC)).toBe(true); + expect(isMusdTokenOnChain(MUSD_ADDRESS, CHAIN_IDS.MONAD)).toBe(true); + }); + + it('returns false for the mUSD address on an unsupported chain', () => { + expect(isMusdTokenOnChain(MUSD_ADDRESS, CHAIN_IDS.POLYGON)).toBe(false); + expect(isMusdTokenOnChain(MUSD_ADDRESS, CHAIN_IDS.ARBITRUM)).toBe(false); + expect(isMusdTokenOnChain(MUSD_ADDRESS, CHAIN_IDS.OPTIMISM)).toBe(false); + }); + + it('is case-insensitive', () => { + expect( + isMusdTokenOnChain(MUSD_ADDRESS.toUpperCase(), CHAIN_IDS.MAINNET), + ).toBe(true); + }); + + it('returns false for a missing address or chainId', () => { + expect(isMusdTokenOnChain(undefined, CHAIN_IDS.MAINNET)).toBe(false); + expect(isMusdTokenOnChain(MUSD_ADDRESS, undefined)).toBe(false); + }); +}); + +describe('isMusdOnMoneyAccountChain', () => { + it('returns true only for mUSD on Monad', () => { + expect(isMusdOnMoneyAccountChain(MUSD_ADDRESS, CHAIN_IDS.MONAD)).toBe(true); + }); + + it('returns false for mUSD on chains where mUSD is deployed but the Money Account is not active', () => { + expect(isMusdOnMoneyAccountChain(MUSD_ADDRESS, CHAIN_IDS.MAINNET)).toBe( + false, + ); + expect( + isMusdOnMoneyAccountChain(MUSD_ADDRESS, CHAIN_IDS.LINEA_MAINNET), + ).toBe(false); + expect(isMusdOnMoneyAccountChain(MUSD_ADDRESS, CHAIN_IDS.BSC)).toBe(false); + }); + + it('returns false for missing arguments', () => { + expect(isMusdOnMoneyAccountChain(undefined, CHAIN_IDS.MONAD)).toBe(false); + expect(isMusdOnMoneyAccountChain(MUSD_ADDRESS, undefined)).toBe(false); + }); + + it('returns false for a non-mUSD address on Monad', () => { + expect( + isMusdOnMoneyAccountChain( + '0x1234567890123456789012345678901234567890', + CHAIN_IDS.MONAD, + ), + ).toBe(false); + }); +}); diff --git a/packages/money-account-utils/src/musd.ts b/packages/money-account-utils/src/musd.ts new file mode 100644 index 00000000000..4ce87a32695 --- /dev/null +++ b/packages/money-account-utils/src/musd.ts @@ -0,0 +1,138 @@ +import { CHAIN_IDS } from '@metamask/transaction-controller'; +import type { CaipAssetType, Hex } from '@metamask/utils'; + +/** + * The mUSD (MetaMask USD) token, minus any client-specific presentation + * (icon assets stay in each client). + */ +export const MUSD_TOKEN = { + symbol: 'mUSD', + name: 'MetaMask USD', + decimals: 6, + /** + * Remote image URL used when the token is not yet in the user's wallet + * token list and a URI-based image source is needed (e.g. for token avatars + * in confirmation screens). The address casing in the path matches the + * token address on all supported chains. + */ + image: + 'https://static.cx.metamask.io/api/v2/tokenIcons/assets/eip155/1/erc20/0xaca92e438df0b2401ff60da7e4337b687a2435da.png', +} as const; + +/** + * mUSD token decimals (derived from {@link MUSD_TOKEN} for a single source of + * truth). + */ +export const MUSD_DECIMALS = MUSD_TOKEN.decimals; + +/** + * mUSD token address (same on all supported chains). + */ +export const MUSD_TOKEN_ADDRESS: Hex = + '0xaca92e438df0b2401ff60da7e4337b687a2435da'; + +/** + * The mUSD token address on each chain where it is deployed. + */ +export const MUSD_TOKEN_ADDRESS_BY_CHAIN: Record = { + [CHAIN_IDS.MAINNET]: MUSD_TOKEN_ADDRESS, + [CHAIN_IDS.LINEA_MAINNET]: MUSD_TOKEN_ADDRESS, + [CHAIN_IDS.BSC]: MUSD_TOKEN_ADDRESS, + [CHAIN_IDS.MONAD]: MUSD_TOKEN_ADDRESS, +}; + +/** + * The CAIP-19 asset id of the mUSD token on each chain where it is deployed. + */ +export const MUSD_TOKEN_ASSET_ID_BY_CHAIN: Record = { + [CHAIN_IDS.MAINNET]: + 'eip155:1/erc20:0xacA92E438df0B2401fF60dA7E4337B687a2435DA', + [CHAIN_IDS.LINEA_MAINNET]: + 'eip155:59144/erc20:0xacA92E438df0B2401fF60dA7E4337B687a2435DA', + [CHAIN_IDS.BSC]: 'eip155:56/erc20:0xacA92E438df0B2401fF60dA7E4337B687a2435DA', + [CHAIN_IDS.MONAD]: + 'eip155:143/erc20:0xacA92E438df0B2401fF60dA7E4337B687a2435DA', +}; + +/** + * The ticker used for mUSD when treated as a currency. + */ +export const MUSD_CURRENCY = 'MUSD'; + +/** + * Chains where the Money Account surfaces mUSD activity. mUSD exists on + * several chains for buy/convert flows, but the Money Account currently only + * tracks Monad — inbound mUSD on Mainnet/Linea/BSC is unrelated to it and + * must not appear in Money activity. + */ +export const MUSD_MONEY_ACCOUNT_CHAIN_IDS: Hex[] = [CHAIN_IDS.MONAD]; + +/** + * Check whether the given token address is mUSD. mUSD has the same address on + * all supported chains. + * + * @param address - The token address to check. + * @returns Whether the address is the mUSD token address. + */ +export function isMusdToken(address?: string): boolean { + if (!address) { + return false; + } + return address.toLowerCase() === MUSD_TOKEN_ADDRESS.toLowerCase(); +} + +/** + * Resolves the display symbol for a token, special-casing tokens whose + * registry symbol differs from the branded casing. mUSD may be registered + * with the uppercase symbol "MUSD" (e.g. via token detection); canonicalise + * it to the branded "mUSD" so UI never leaks the registry casing. + * + * @param address - The token address the symbol belongs to. + * @param symbol - The registry symbol for the token. + * @returns The branded mUSD symbol for the mUSD address, otherwise the given + * symbol unchanged. + */ +export function getTokenDisplaySymbol( + address?: string, + symbol?: string, +): string | undefined { + return isMusdToken(address) ? MUSD_TOKEN.symbol : symbol; +} + +/** + * Like {@link isMusdToken} but also requires `chainId` to be a chain where + * mUSD is actually deployed. Prevents a same-address token on an unsupported + * chain from being misclassified as mUSD. + * + * @param address - The token address to check. + * @param chainId - The chain the token lives on. + * @returns Whether the address is mUSD on a chain where mUSD is deployed. + */ +export function isMusdTokenOnChain(address?: string, chainId?: Hex): boolean { + if (!address || !chainId) { + return false; + } + const expected = MUSD_TOKEN_ADDRESS_BY_CHAIN[chainId]; + if (!expected) { + return false; + } + return address.toLowerCase() === expected.toLowerCase(); +} + +/** + * Like {@link isMusdTokenOnChain} but restricted to chains where the Money + * Account is active (currently Monad only). + * + * @param address - The token address to check. + * @param chainId - The chain the token lives on. + * @returns Whether the address is mUSD on a Money Account chain. + */ +export function isMusdOnMoneyAccountChain( + address?: string, + chainId?: Hex, +): boolean { + if (!chainId || !MUSD_MONEY_ACCOUNT_CHAIN_IDS.includes(chainId)) { + return false; + } + return isMusdTokenOnChain(address, chainId); +} diff --git a/packages/money-account-utils/src/transactions.test.ts b/packages/money-account-utils/src/transactions.test.ts new file mode 100644 index 00000000000..52f96b96a2c --- /dev/null +++ b/packages/money-account-utils/src/transactions.test.ts @@ -0,0 +1,554 @@ +import type { Result } from '@ethersproject/abi'; +import { Interface } from '@ethersproject/abi'; +import type { Provider } from '@ethersproject/abstract-provider'; +import { Contract } from '@ethersproject/contracts'; +import { CHAIN_IDS, TransactionType } from '@metamask/transaction-controller'; +import type { Hex } from '@metamask/utils'; + +import { MUSD_TOKEN_ADDRESS, MUSD_TOKEN_ASSET_ID_BY_CHAIN } from './musd.js'; +import { + applySlippage, + buildMoneyAccountDepositBatch, + buildMoneyAccountDepositPlaceholderBatch, + buildMoneyAccountWithdrawBatch, + buildMoneyAccountWithdrawPlaceholderBatch, + getMoneyAccountDepositAssetAddress, + getMoneyAccountDepositAssetId, + getSharesForWithdrawal, + TELLER_ABI, +} from './transactions.js'; + +jest.mock('@ethersproject/contracts'); + +const MockContract = Contract as jest.MockedClass; + +const CHAIN_ID = CHAIN_IDS.MONAD; +const UNSUPPORTED_CHAIN_ID = '0xdead' as Hex; +const BORING_VAULT = '0xB5F07d769dD60fE54c97dd53101181073DDf21b2' as Hex; +const TELLER = '0x86821F179eaD9F0b3C79b2f8deF0227eEBFDc9f9' as Hex; +const ACCOUNTANT = '0x800ebc3B74F67EaC27C9CCE4E4FF28b17CdCA173' as Hex; +const LENS = '0x846a7832022350434B5cC006d07cc9c782469660' as Hex; +const MONEY_ACCOUNT_ADDRESS = + '0xdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef' as Hex; +const RECIPIENT_ADDRESS = '0xabcdefabcdefabcdefabcdefabcdefabcdefabcd' as Hex; +const ZERO_ADDRESS = '0x0000000000000000000000000000000000000000'; +const PROVIDER = {} as Provider; + +const ERC20_INTERFACE = new Interface([ + 'function approve(address spender, uint256 amount)', + 'function transfer(address to, uint256 amount)', +]); +const TELLER_INTERFACE = new Interface(TELLER_ABI); + +const previewDeposit = jest.fn(); +const getRate = jest.fn(); + +/** + * Builds the arguments for a deposit batch, with defaults for every vault + * address so each test only states what it cares about. + * + * @param overrides - Argument overrides. + * @returns The deposit batch arguments. + */ +function depositArgs( + overrides: Partial[0]> = {}, +): Parameters[0] { + return { + amount: BigInt(1_000_000), + chainId: CHAIN_ID, + boringVault: BORING_VAULT, + tellerAddress: TELLER, + accountantAddress: ACCOUNTANT, + lensAddress: LENS, + provider: PROVIDER, + ...overrides, + }; +} + +/** + * Builds the arguments for a withdraw batch, with defaults for every vault + * address so each test only states what it cares about. + * + * @param overrides - Argument overrides. + * @returns The withdraw batch arguments. + */ +function withdrawArgs( + overrides: Partial[0]> = {}, +): Parameters[0] { + return { + amount: BigInt(1_000_000), + chainId: CHAIN_ID, + tellerAddress: TELLER, + accountantAddress: ACCOUNTANT, + moneyAccountAddress: MONEY_ACCOUNT_ADDRESS, + recipient: RECIPIENT_ADDRESS, + provider: PROVIDER, + ...overrides, + }; +} + +/** + * Asserts two addresses are equal, ignoring case. Decoded calldata comes back + * EIP-55 checksummed regardless of the casing that was encoded. + * + * @param actual - The address to check. + * @param expected - The address it should equal. + */ +function expectSameAddress(actual: string, expected: string): void { + expect(actual.toLowerCase()).toBe(expected.toLowerCase()); +} + +/** + * Decodes the arguments of an encoded teller call. + * + * @param name - The teller function that was encoded. + * @param data - The encoded calldata. + * @returns The decoded arguments. + */ +function decodeTellerCall( + name: 'deposit' | 'withdraw', + data: Hex | undefined, +): Result { + if (!data) { + throw new Error(`Expected ${name} calldata`); + } + return TELLER_INTERFACE.decodeFunctionData(name, data); +} + +/** + * Decodes the arguments of an encoded ERC-20 call. + * + * @param name - The ERC-20 function that was encoded. + * @param data - The encoded calldata. + * @returns The decoded arguments. + */ +function decodeErc20Call( + name: 'approve' | 'transfer', + data: Hex | undefined, +): Result { + if (!data) { + throw new Error(`Expected ${name} calldata`); + } + return ERC20_INTERFACE.decodeFunctionData(name, data); +} + +/** + * Points the vault contract reads at the local mocks. The builders construct + * contracts by ABI, so the mock dispatches on which function the given ABI + * declares. + */ +function mockVaultContracts(): void { + jest.clearAllMocks(); + MockContract.mockImplementation( + (_address: string, abi: unknown) => + (JSON.stringify(abi).includes('previewDeposit') + ? { previewDeposit } + : { getRate }) as unknown as Contract, + ); +} + +describe('applySlippage', () => { + it('applies 0.2% slippage to a round value', () => { + expect(applySlippage(BigInt(1000))).toBe(BigInt(998)); + }); + + it('applies 0.2% slippage with integer truncation', () => { + expect(applySlippage(BigInt(1))).toBe(BigInt(0)); + }); + + it('applies 0.2% slippage to a large value', () => { + const amount = BigInt('1000000000000000000'); + expect(applySlippage(amount)).toBe((amount * BigInt(998)) / BigInt(1000)); + }); + + it('returns 0 for 0 input', () => { + expect(applySlippage(BigInt(0))).toBe(BigInt(0)); + }); +}); + +describe('getSharesForWithdrawal', () => { + const SHARE_SCALAR = BigInt(1_000_000); + + it('converts amount to shares at 1:1 rate (exact division)', () => { + expect(getSharesForWithdrawal(BigInt(1_000_000), BigInt(1_000_000))).toBe( + BigInt(1_000_000), + ); + }); + + it('scales down when rate is higher than 1:1 (exact division)', () => { + expect(getSharesForWithdrawal(BigInt(1_000_000), BigInt(2_000_000))).toBe( + BigInt(500_000), + ); + }); + + it('scales up when rate is lower than 1:1 (exact division)', () => { + expect(getSharesForWithdrawal(BigInt(2_000_000), BigInt(1_000_000))).toBe( + BigInt(2_000_000), + ); + }); + + it('uses ceiling division — rounds up when remainder exists', () => { + // floor(1_000_000 * 1_000_000 / 3_000_000) = 333_333, so ceiling is 333_334. + const amount = BigInt(1_000_000); + const rate = BigInt(3_000_000); + expect((amount * SHARE_SCALAR) / rate).toBe(BigInt(333_333)); + expect(getSharesForWithdrawal(amount, rate)).toBe(BigInt(333_334)); + }); + + it('reproduces the exact reported scenario — $1.96 at rate ~1,000,094', () => { + // This was the failing case: floor division gave 1,959,815 shares, and the + // contract's mulDivDown produced 1,959,999 assetsOut < 1,960,000 + // minimumAssets. + const amount = BigInt(1_960_000); // $1.96 in 6 decimals + const rate = BigInt(1_000_094); + + expect((amount * SHARE_SCALAR) / rate).toBe(BigInt(1_959_815)); // old buggy value + + const ceilShares = getSharesForWithdrawal(amount, rate); + expect(ceilShares).toBe(BigInt(1_959_816)); // fixed: one more share + + // Verify: contract mulDivDown(ceilShares * rate / SCALAR) >= amount + expect((ceilShares * rate) / SHARE_SCALAR).toBeGreaterThanOrEqual(amount); + }); + + it('reproduces the reported $1.00 scenario — was passing by luck', () => { + const amount = BigInt(1_000_000); + const rate = BigInt(1_000_094); + + const floorShares = (amount * SHARE_SCALAR) / rate; + const ceilShares = getSharesForWithdrawal(amount, rate); + + expect(ceilShares).toBeGreaterThanOrEqual(floorShares); + expect((ceilShares * rate) / SHARE_SCALAR).toBeGreaterThanOrEqual(amount); + }); + + it('handles large amounts with ceiling division', () => { + const amount = BigInt('1000000000000'); // $1M in 6 decimals + const rate = BigInt('1500000'); + const result = getSharesForWithdrawal(amount, rate); + const floorResult = (amount * SHARE_SCALAR) / rate; + + expect(result).toBeGreaterThanOrEqual(floorResult); + // And at most one more than floor. + expect(result - floorResult).toBeLessThanOrEqual(BigInt(1)); + }); + + it('ceiling division equals floor when division is exact', () => { + const amount = BigInt(2_000_000); + const rate = BigInt(500_000); + expect(getSharesForWithdrawal(amount, rate)).toBe( + (amount * SHARE_SCALAR) / rate, + ); + }); + + it('returns 0 for zero amount', () => { + expect(getSharesForWithdrawal(BigInt(0), BigInt(1_000_000))).toBe( + BigInt(0), + ); + }); + + it('guarantees assetsOut >= amount across rates near 1:1', () => { + const amount = BigInt(1_960_000); + for (let rawRate = 999_900; rawRate <= 1_000_200; rawRate++) { + const rate = BigInt(rawRate); + const shares = getSharesForWithdrawal(amount, rate); + // Simulate the contract's mulDivDown. + expect((shares * rate) / SHARE_SCALAR).toBeGreaterThanOrEqual(amount); + } + }); +}); + +describe('getMoneyAccountDepositAssetAddress', () => { + it('returns the mUSD address for a chain mUSD is deployed on', () => { + expect(getMoneyAccountDepositAssetAddress(CHAIN_ID)).toBe( + MUSD_TOKEN_ADDRESS, + ); + }); + + it('throws for a chain mUSD is not deployed on', () => { + expect(() => + getMoneyAccountDepositAssetAddress(UNSUPPORTED_CHAIN_ID), + ).toThrow(`mUSD not deployed on chain ${UNSUPPORTED_CHAIN_ID}`); + }); +}); + +describe('getMoneyAccountDepositAssetId', () => { + it('returns the mapped asset id for a known chain', () => { + expect(getMoneyAccountDepositAssetId(CHAIN_IDS.MONAD)).toBe( + MUSD_TOKEN_ASSET_ID_BY_CHAIN[CHAIN_IDS.MONAD], + ); + expect(getMoneyAccountDepositAssetId(CHAIN_IDS.MAINNET)).toBe( + MUSD_TOKEN_ASSET_ID_BY_CHAIN[CHAIN_IDS.MAINNET], + ); + }); + + it('returns undefined for a chain mUSD is not deployed on', () => { + expect(getMoneyAccountDepositAssetId(UNSUPPORTED_CHAIN_ID)).toBeUndefined(); + }); + + it('returns undefined when chainId is undefined', () => { + expect(getMoneyAccountDepositAssetId(undefined)).toBeUndefined(); + }); +}); + +describe('buildMoneyAccountDepositBatch', () => { + beforeEach(mockVaultContracts); + + it('returns approve and deposit transactions with the expected targets and types', async () => { + previewDeposit.mockResolvedValue(BigInt(1_000_000)); + + const result = await buildMoneyAccountDepositBatch(depositArgs()); + + expect(result.approveTx.type).toBe(TransactionType.tokenMethodApprove); + expect(result.approveTx.params.to).toBe(MUSD_TOKEN_ADDRESS); + expect(result.approveTx.params.value).toBe('0x0'); + + expect(result.depositTx.type).toBe(TransactionType.moneyAccountDeposit); + expect(result.depositTx.params.to).toBe(TELLER); + expect(result.depositTx.params.value).toBe('0x0'); + }); + + it('encodes an approval of the deposit amount for the boring vault', async () => { + previewDeposit.mockResolvedValue(BigInt(1_000_000)); + + const result = await buildMoneyAccountDepositBatch( + depositArgs({ amount: BigInt(500_000) }), + ); + + const decoded = decodeErc20Call('approve', result.approveTx.params.data); + expectSameAddress(decoded.spender, BORING_VAULT); + expect(BigInt(decoded.amount.toString())).toBe(BigInt(500_000)); + }); + + it('calls previewDeposit with the deposit asset, amount and vault addresses', async () => { + previewDeposit.mockResolvedValue(BigInt(500_000)); + + await buildMoneyAccountDepositBatch(depositArgs()); + + expect(previewDeposit).toHaveBeenCalledWith( + MUSD_TOKEN_ADDRESS, + '1000000', + BORING_VAULT, + ACCOUNTANT, + ); + }); + + it('derives minimumMint from the previewed shares less slippage', async () => { + const shares = BigInt(1_000_000); + previewDeposit.mockResolvedValue(shares); + + const result = await buildMoneyAccountDepositBatch(depositArgs()); + + const decoded = decodeTellerCall('deposit', result.depositTx.params.data); + expectSameAddress(decoded.depositAsset, MUSD_TOKEN_ADDRESS); + expect(BigInt(decoded.depositAmount.toString())).toBe(BigInt(1_000_000)); + expect(BigInt(decoded.minimumMint.toString())).toBe(applySlippage(shares)); + expectSameAddress(decoded.referralAddress, ZERO_ADDRESS); + }); + + it('rejects a zero amount instead of encoding a deposit that mints nothing', async () => { + await expect( + buildMoneyAccountDepositBatch(depositArgs({ amount: BigInt(0) })), + ).rejects.toThrow( + 'Cannot encode a zero-amount Money Account vault call — use buildMoneyAccountDepositPlaceholderBatch for placeholder batches', + ); + + // Rejected before any I/O, so a placeholder caller pays for no vault read. + expect(previewDeposit).not.toHaveBeenCalled(); + }); + + it('throws for a chain mUSD is not deployed on', async () => { + await expect( + buildMoneyAccountDepositBatch( + depositArgs({ chainId: UNSUPPORTED_CHAIN_ID }), + ), + ).rejects.toThrow(`mUSD not deployed on chain ${UNSUPPORTED_CHAIN_ID}`); + }); + + it('propagates previewDeposit failures', async () => { + previewDeposit.mockRejectedValue(new Error('RPC down')); + + await expect(buildMoneyAccountDepositBatch(depositArgs())).rejects.toThrow( + 'RPC down', + ); + }); +}); + +describe('buildMoneyAccountDepositPlaceholderBatch', () => { + beforeEach(mockVaultContracts); + + it('returns the approve and deposit targets and types without calldata', () => { + const result = buildMoneyAccountDepositPlaceholderBatch({ + chainId: CHAIN_ID, + tellerAddress: TELLER, + }); + + expect(result.approveTx.type).toBe(TransactionType.tokenMethodApprove); + expect(result.approveTx.params.to).toBe(MUSD_TOKEN_ADDRESS); + expect(result.approveTx.params.value).toBe('0x0'); + expect(result.approveTx.params).not.toHaveProperty('data'); + + expect(result.depositTx.type).toBe(TransactionType.moneyAccountDeposit); + expect(result.depositTx.params.to).toBe(TELLER); + expect(result.depositTx.params.value).toBe('0x0'); + expect(result.depositTx.params).not.toHaveProperty('data'); + }); + + it('performs no vault reads', () => { + buildMoneyAccountDepositPlaceholderBatch({ + chainId: CHAIN_ID, + tellerAddress: TELLER, + }); + + expect(previewDeposit).not.toHaveBeenCalled(); + expect(MockContract).not.toHaveBeenCalled(); + }); + + it('throws for a chain mUSD is not deployed on', () => { + expect(() => + buildMoneyAccountDepositPlaceholderBatch({ + chainId: UNSUPPORTED_CHAIN_ID, + tellerAddress: TELLER, + }), + ).toThrow(`mUSD not deployed on chain ${UNSUPPORTED_CHAIN_ID}`); + }); +}); + +describe('buildMoneyAccountWithdrawBatch', () => { + beforeEach(mockVaultContracts); + + it('returns withdraw and transfer transactions with the expected targets and types', async () => { + getRate.mockResolvedValue(BigInt(1_000_000)); + + const result = await buildMoneyAccountWithdrawBatch(withdrawArgs()); + + expect(result.withdrawTx.type).toBe(TransactionType.moneyAccountWithdraw); + expect(result.withdrawTx.params.to).toBe(TELLER); + expect(result.withdrawTx.params.value).toBe('0x0'); + + // The transfer targets the mUSD token contract, not the recipient. + expect(result.transferTx.type).toBe(TransactionType.tokenMethodTransfer); + expect(result.transferTx.params.to).toBe(MUSD_TOKEN_ADDRESS); + expect(result.transferTx.params.value).toBe('0x0'); + }); + + it('redeems shares to the money account and transfers the amount to the recipient', async () => { + getRate.mockResolvedValue(BigInt(1_000_000)); + + const result = await buildMoneyAccountWithdrawBatch(withdrawArgs()); + + const withdraw = decodeTellerCall( + 'withdraw', + result.withdrawTx.params.data, + ); + expectSameAddress(withdraw.withdrawAsset, MUSD_TOKEN_ADDRESS); + expectSameAddress(withdraw.to, MONEY_ACCOUNT_ADDRESS); + + const transfer = decodeErc20Call('transfer', result.transferTx.params.data); + expectSameAddress(transfer.to, RECIPIENT_ADDRESS); + expect(BigInt(transfer.amount.toString())).toBe(BigInt(1_000_000)); + }); + + it('reads the vault rate once', async () => { + getRate.mockResolvedValue(BigInt(2_000_000)); + + await buildMoneyAccountWithdrawBatch(withdrawArgs()); + + expect(getRate).toHaveBeenCalledTimes(1); + }); + + it('rejects a zero amount instead of encoding a zero-share redemption', async () => { + // `withdraw(mUSD, 0, 0, moneyAccount)` is valid, submittable calldata that + // the teller rejects for redeeming no shares. + await expect( + buildMoneyAccountWithdrawBatch(withdrawArgs({ amount: BigInt(0) })), + ).rejects.toThrow( + 'Cannot encode a zero-amount Money Account vault call — use buildMoneyAccountWithdrawPlaceholderBatch for placeholder batches', + ); + + // Rejected before any I/O, so a placeholder caller pays for no vault read. + expect(getRate).not.toHaveBeenCalled(); + }); + + it('encodes minimumAssets as amount - 1 for defense-in-depth', async () => { + getRate.mockResolvedValue(BigInt(1_000_000)); + const amount = BigInt(1_960_000); + + const result = await buildMoneyAccountWithdrawBatch( + withdrawArgs({ amount }), + ); + + const decoded = decodeTellerCall('withdraw', result.withdrawTx.params.data); + expect(BigInt(decoded.minimumAssets.toString())).toBe(amount - BigInt(1)); + }); + + it('uses ceiling division for shareAmount in withdraw calldata', async () => { + // A rate that produces a remainder, to verify ceiling division. + getRate.mockResolvedValue(BigInt(1_000_094)); + + const result = await buildMoneyAccountWithdrawBatch( + withdrawArgs({ amount: BigInt(1_960_000) }), + ); + + const decoded = decodeTellerCall('withdraw', result.withdrawTx.params.data); + // Ceiling: (1_960_000 * 1_000_000 + 1_000_094 - 1) / 1_000_094 = 1_959_816. + // Floor division would give 1_959_815. + expect(BigInt(decoded.shareAmount.toString())).toBe(BigInt(1_959_816)); + }); + + it('throws for a chain mUSD is not deployed on', async () => { + await expect( + buildMoneyAccountWithdrawBatch( + withdrawArgs({ chainId: UNSUPPORTED_CHAIN_ID }), + ), + ).rejects.toThrow(`mUSD not deployed on chain ${UNSUPPORTED_CHAIN_ID}`); + }); + + it('propagates getRate failures', async () => { + getRate.mockRejectedValue(new Error('RPC down')); + + await expect( + buildMoneyAccountWithdrawBatch(withdrawArgs()), + ).rejects.toThrow('RPC down'); + }); +}); + +describe('buildMoneyAccountWithdrawPlaceholderBatch', () => { + beforeEach(mockVaultContracts); + + it('returns the withdraw and transfer targets and types without calldata', () => { + const result = buildMoneyAccountWithdrawPlaceholderBatch({ + chainId: CHAIN_ID, + tellerAddress: TELLER, + }); + + expect(result.withdrawTx.type).toBe(TransactionType.moneyAccountWithdraw); + expect(result.withdrawTx.params.to).toBe(TELLER); + expect(result.withdrawTx.params.value).toBe('0x0'); + expect(result.withdrawTx.params).not.toHaveProperty('data'); + + expect(result.transferTx.type).toBe(TransactionType.tokenMethodTransfer); + expect(result.transferTx.params.to).toBe(MUSD_TOKEN_ADDRESS); + expect(result.transferTx.params.value).toBe('0x0'); + expect(result.transferTx.params).not.toHaveProperty('data'); + }); + + it('performs no vault reads', () => { + buildMoneyAccountWithdrawPlaceholderBatch({ + chainId: CHAIN_ID, + tellerAddress: TELLER, + }); + + expect(getRate).not.toHaveBeenCalled(); + expect(MockContract).not.toHaveBeenCalled(); + }); + + it('throws for a chain mUSD is not deployed on', () => { + expect(() => + buildMoneyAccountWithdrawPlaceholderBatch({ + chainId: UNSUPPORTED_CHAIN_ID, + tellerAddress: TELLER, + }), + ).toThrow(`mUSD not deployed on chain ${UNSUPPORTED_CHAIN_ID}`); + }); +}); diff --git a/packages/money-account-utils/src/transactions.ts b/packages/money-account-utils/src/transactions.ts new file mode 100644 index 00000000000..45e11542177 --- /dev/null +++ b/packages/money-account-utils/src/transactions.ts @@ -0,0 +1,569 @@ +import { Interface } from '@ethersproject/abi'; +import type { Provider } from '@ethersproject/abstract-provider'; +import { Contract } from '@ethersproject/contracts'; +import { TransactionType } from '@metamask/transaction-controller'; +import type { CaipAssetType, Hex } from '@metamask/utils'; + +import { + MUSD_TOKEN_ADDRESS_BY_CHAIN, + MUSD_TOKEN_ASSET_ID_BY_CHAIN, +} from './musd.js'; + +const LENS_ABI = [ + 'function previewDeposit(address depositAsset, uint256 depositAmount, address boringVault, address accountant) view returns (uint256 shares)', +]; + +export const TELLER_ABI = [ + 'function deposit(address depositAsset, uint256 depositAmount, uint256 minimumMint, address referralAddress) payable returns (uint256 shares)', + 'function withdraw(address withdrawAsset, uint256 shareAmount, uint256 minimumAssets, address to) returns (uint256 assetsOut)', +]; + +const ACCOUNTANT_ABI = ['function getRate() view returns (uint256 rate)']; + +const ERC20_ABI = [ + 'function approve(address spender, uint256 amount)', + 'function transfer(address to, uint256 amount)', +]; + +/** + * Referral address passed to the teller's `deposit` call. The Money Account + * deposit flow has no referrer, so the zero address is sent explicitly. + */ +const ZERO_ADDRESS: Hex = '0x0000000000000000000000000000000000000000'; + +// -- Shared constants ------------------------------------------------------ + +const SLIPPAGE_NUMERATOR = BigInt(998); +const SLIPPAGE_DENOMINATOR = BigInt(1000); + +/** + * Applies a 0.2% slippage tolerance to a bigint value. + * If this sanity-check causes a revert, no funds are lost — retry with a fresh quote. + * + * @param value - The value to apply the slippage tolerance to. + * @returns The value reduced by the slippage tolerance, truncated to an integer. + */ +export function applySlippage(value: bigint): bigint { + return (value * SLIPPAGE_NUMERATOR) / SLIPPAGE_DENOMINATOR; +} + +// -- Shared types ---------------------------------------------------------- + +export type MoneyAccountTxParams = { + params: { + to: Hex; + data: Hex; + value: Hex; + }; + type: TransactionType; +}; + +/** + * A Money Account call with its target and type resolved but no calldata, for + * placeholder batches that Pay re-encodes once the user picks an amount. + * Distinct from {@link MoneyAccountTxParams} so callers of the encoding + * builders never have to narrow an optional `data`. + */ +export type MoneyAccountPlaceholderTxParams = { + params: { + to: Hex; + value: Hex; + }; + type: TransactionType; +}; + +/** + * Result shape for Money Account transaction batch builders. The string keys + * (e.g. `approveTx`, `withdrawTx`) name each call so callers don't depend on + * positional ordering in `addTransactionBatch.transactions[]`. + */ +type MoneyAccountBatchResult = Record< + TxKey, + MoneyAccountTxParams +>; + +/** + * Result shape for the placeholder variants of the batch builders. Mirrors + * {@link MoneyAccountBatchResult} but without calldata. + */ +type MoneyAccountPlaceholderBatchResult = Record< + TxKey, + MoneyAccountPlaceholderTxParams +>; + +// -- Deposit helpers ------------------------------------------------------- + +/** + * Reads the vault shares a deposit of `amount` would mint, via the lens + * contract's `previewDeposit`. + * + * @param options - Options bag. + * @param options.lensAddress - Address of the vault lens contract. + * @param options.boringVault - Address of the boring vault. + * @param options.accountantAddress - Address of the vault accountant contract. + * @param options.musdAddress - Address of the mUSD deposit asset. + * @param options.amount - Deposit amount in mUSD base units. + * @param options.provider - Provider used for the read call. + * @returns The expected vault shares. + */ +async function getExpectedDepositShares({ + lensAddress, + boringVault, + accountantAddress, + musdAddress, + amount, + provider, +}: { + lensAddress: string; + boringVault: string; + accountantAddress: string; + musdAddress: string; + amount: bigint; + provider: Provider; +}): Promise { + const lensContract = new Contract(lensAddress, LENS_ABI, provider); + const shares = await lensContract.previewDeposit( + musdAddress, + amount.toString(), + boringVault, + accountantAddress, + ); + return BigInt(shares.toString()); +} + +/** + * Encodes the ERC-20 `approve` call granting the boring vault an allowance. + * + * @param boringVault - Address to approve as spender. + * @param amount - Allowance in mUSD base units. + * @returns The encoded calldata. + */ +function buildApproveData(boringVault: string, amount: bigint): Hex { + const iface = new Interface(ERC20_ABI); + return iface.encodeFunctionData('approve', [ + boringVault, + amount.toString(), + ]) as Hex; +} + +/** + * Encodes an ERC-20 `transfer` call. + * + * @param to - Recipient of the transfer. + * @param amount - Transfer amount in token base units. + * @returns The encoded calldata. + */ +function buildErc20TransferData(to: string, amount: bigint): Hex { + const iface = new Interface(ERC20_ABI); + return iface.encodeFunctionData('transfer', [to, amount.toString()]) as Hex; +} + +/** + * Encodes the teller's `deposit` call. + * + * @param musdAddress - Address of the mUSD deposit asset. + * @param amount - Deposit amount in mUSD base units. + * @param minimumMint - Minimum vault shares the deposit must mint. + * @returns The encoded calldata. + */ +function buildDepositData( + musdAddress: string, + amount: bigint, + minimumMint: bigint, +): Hex { + const iface = new Interface(TELLER_ABI); + return iface.encodeFunctionData('deposit', [ + musdAddress, + amount.toString(), + minimumMint.toString(), + ZERO_ADDRESS, + ]) as Hex; +} + +/** + * Single source of truth for the deposit asset so both calldata encoding + * (`buildMoneyAccountDepositBatch`) and Pay's `requiredAssets` agree. + * + * @param chainId - The chain ID to get the deposit asset address for. + * @returns The deposit asset address for the given chain ID. + */ +export function getMoneyAccountDepositAssetAddress(chainId: Hex): Hex { + const musdAddress = MUSD_TOKEN_ADDRESS_BY_CHAIN[chainId]; + if (!musdAddress) { + throw new Error(`mUSD not deployed on chain ${chainId}`); + } + return musdAddress; +} + +/** + * Resolves the CAIP-19 asset id of the Money Account deposit asset (mUSD) for a + * given chain. Pure mapping over `MUSD_TOKEN_ASSET_ID_BY_CHAIN`. + * + * Returns `undefined` for a chain mUSD is not deployed on, so an unsupported + * chain stays distinguishable from a supported one. Clients that want a default + * (e.g. Money Account being Monad-only today) apply it at the call site: + * `getMoneyAccountDepositAssetId(chainId) ?? + * MUSD_TOKEN_ASSET_ID_BY_CHAIN[CHAIN_IDS.MONAD]`. + * + * @param chainId - The chain ID to get the deposit asset id for. + * @returns The CAIP-19 asset id of the deposit asset, or `undefined` if mUSD is + * not deployed on the given chain. + */ +export function getMoneyAccountDepositAssetId( + chainId?: Hex, +): CaipAssetType | undefined { + if (!chainId) { + return undefined; + } + return MUSD_TOKEN_ASSET_ID_BY_CHAIN[chainId]; +} + +export type MoneyAccountDepositBatchResult = MoneyAccountBatchResult< + 'approveTx' | 'depositTx' +>; + +export type MoneyAccountDepositPlaceholderBatchResult = + MoneyAccountPlaceholderBatchResult<'approveTx' | 'depositTx'>; + +export type BuildMoneyAccountDepositBatchOptions = { + amount: bigint; + chainId: Hex; + boringVault: Hex; + tellerAddress: Hex; + accountantAddress: Hex; + lensAddress: Hex; + provider: Provider; +}; + +export type BuildMoneyAccountDepositPlaceholderBatchOptions = { + chainId: Hex; + tellerAddress: Hex; +}; + +/** + * Guards the encoding builders against a zero amount. + * + * A zero-amount vault call encodes calldata that is structurally valid and + * submittable but cannot succeed — the teller rejects a zero-share redemption, + * and a zero-amount deposit mints nothing. Callers that need a batch before the + * user has picked an amount want a placeholder builder instead, which resolves + * the call targets without calldata. + * + * @param amount - The amount the caller asked to encode. + * @param builderName - Name of the placeholder builder to point the caller at. + */ +function assertNonZeroAmount(amount: bigint, builderName: string): void { + if (amount === 0n) { + throw new Error( + `Cannot encode a zero-amount Money Account vault call — use ${builderName} for placeholder batches`, + ); + } +} + +/** + * Builds the approve + deposit transaction pair for a Money Account deposit. + * + * 1. Calls `previewDeposit` on the lens contract to get expected vault shares. + * 2. Applies a 0.2% slippage tolerance to derive `minimumMint`. + * 3. Encodes ERC-20 `approve(boringVault, amount)` on the mUSD token. + * 4. Encodes `deposit(mUSD, amount, minimumMint, 0x0)` on the teller contract. + * + * For placeholder batches with no amount yet, use + * {@link buildMoneyAccountDepositPlaceholderBatch} instead — it needs neither a + * provider nor the vault read. Throws on a zero amount rather than encoding a + * deposit that mints nothing. + * + * @param options - Options bag. + * @param options.amount - Deposit amount in mUSD base units. Must be non-zero. + * @param options.chainId - Chain the deposit happens on. + * @param options.boringVault - Address of the boring vault. + * @param options.tellerAddress - Address of the teller contract. + * @param options.accountantAddress - Address of the vault accountant contract. + * @param options.lensAddress - Address of the vault lens contract. + * @param options.provider - Provider used for the `previewDeposit` read. + * @returns The approve and deposit transactions, keyed by name. + */ +export async function buildMoneyAccountDepositBatch({ + amount, + chainId, + boringVault, + tellerAddress, + accountantAddress, + lensAddress, + provider, +}: BuildMoneyAccountDepositBatchOptions): Promise { + assertNonZeroAmount(amount, 'buildMoneyAccountDepositPlaceholderBatch'); + + const musdAddress = getMoneyAccountDepositAssetAddress(chainId); + + const minimumMint = applySlippage( + await getExpectedDepositShares({ + lensAddress, + boringVault, + accountantAddress, + musdAddress, + amount, + provider, + }), + ); + + return { + approveTx: { + params: { + to: musdAddress, + data: buildApproveData(boringVault, amount), + value: '0x0', + }, + type: TransactionType.tokenMethodApprove, + }, + depositTx: { + params: { + to: tellerAddress, + data: buildDepositData(musdAddress, amount, minimumMint), + value: '0x0', + }, + type: TransactionType.moneyAccountDeposit, + }, + }; +} + +/** + * Builds the approve + deposit pair for a Money Account deposit *without* + * calldata, for placeholder batches that Pay re-encodes once the user picks an + * amount. Resolves the call targets and types only, so it performs no vault + * reads and needs no provider. + * + * @param options - Options bag. + * @param options.chainId - Chain the deposit happens on. + * @param options.tellerAddress - Address of the teller contract. + * @returns The approve and deposit transaction targets, keyed by name. + */ +export function buildMoneyAccountDepositPlaceholderBatch({ + chainId, + tellerAddress, +}: BuildMoneyAccountDepositPlaceholderBatchOptions): MoneyAccountDepositPlaceholderBatchResult { + return { + approveTx: { + params: { + to: getMoneyAccountDepositAssetAddress(chainId), + value: '0x0', + }, + type: TransactionType.tokenMethodApprove, + }, + depositTx: { + params: { + to: tellerAddress, + value: '0x0', + }, + type: TransactionType.moneyAccountDeposit, + }, + }; +} + +// -- Withdrawal helpers ---------------------------------------------------- + +/** + * Reads the current vault exchange rate from the accountant contract. + * + * @param options - Options bag. + * @param options.accountantAddress - Address of the vault accountant contract. + * @param options.provider - Provider used for the read call. + * @returns The current vault rate. + */ +async function getVaultRate({ + accountantAddress, + provider, +}: { + accountantAddress: string; + provider: Provider; +}): Promise { + const accountant = new Contract(accountantAddress, ACCOUNTANT_ABI, provider); + const rate = await accountant.getRate(); + return BigInt(rate.toString()); +} + +const SHARE_DECIMALS_SCALAR = BigInt(1_000_000); + +/** + * Converts a USD asset amount (6 decimals) to vault shares given a pre-fetched rate. + * Pure arithmetic — no I/O, safe to call directly inside workflows. + * + * Uses ceiling division so the contract's `mulDivDown(shares × rate / ONE_SHARE)` + * always produces `assetsOut >= minimumAssets`. Floor division caused a double- + * truncation bug where `assetsOut` could land 1 unit below `minimumAssets`, + * reverting with `MinimumAssetsNotMet`. + * + * @param amount - The asset amount in mUSD base units. + * @param rate - The current vault rate. + * @returns The vault shares needed to withdraw `amount`. + */ +export function getSharesForWithdrawal(amount: bigint, rate: bigint): bigint { + return (amount * SHARE_DECIMALS_SCALAR + rate - 1n) / rate; +} + +/** + * Encodes the teller's `withdraw` call. + * + * @param musdAddress - Address of the mUSD withdraw asset. + * @param shareAmount - Vault shares to redeem. + * @param minimumAssets - Minimum assets the redemption must return. + * @param toAddress - Address that receives the redeemed assets. + * @returns The encoded calldata. + */ +function buildWithdrawData( + musdAddress: string, + shareAmount: bigint, + minimumAssets: bigint, + toAddress: string, +): Hex { + const iface = new Interface(TELLER_ABI); + return iface.encodeFunctionData('withdraw', [ + musdAddress, + shareAmount.toString(), + minimumAssets.toString(), + toAddress, + ]) as Hex; +} + +export type MoneyAccountWithdrawBatchResult = MoneyAccountBatchResult< + 'withdrawTx' | 'transferTx' +>; + +export type MoneyAccountWithdrawPlaceholderBatchResult = + MoneyAccountPlaceholderBatchResult<'withdrawTx' | 'transferTx'>; + +export type BuildMoneyAccountWithdrawBatchOptions = { + amount: bigint; + chainId: Hex; + tellerAddress: Hex; + accountantAddress: Hex; + /** Address of the money account — vault sends the redeemed mUSD here first. */ + moneyAccountAddress: Hex; + /** Address of the user's selected EVM account — receives the mUSD transfer. */ + recipient: Hex; + provider: Provider; +}; + +/** + * Builds the two-transaction withdrawal batch for a Money Account withdrawal. + * + * 1. Calls `getRate` on the accountant contract to get the current vault rate. + * 2. Converts the asset amount to vault shares. + * 3. Encodes `withdraw(mUSD, shareAmount, minimumAssets, moneyAccountAddress)` on the teller contract — the redeemed mUSD lands on the money account. + * 4. Encodes `transfer(recipient, amount)` on the mUSD token contract — moves the exact requested amount from the money account to the user's selected EVM account. + * + * For placeholder batches with no amount yet, use + * {@link buildMoneyAccountWithdrawPlaceholderBatch} instead — it needs neither a + * provider nor the rate read. Throws on a zero amount rather than encoding a + * zero-share redemption, which the teller rejects. + * + * @param options - Options bag. + * @param options.amount - Withdrawal amount in mUSD base units. Must be + * non-zero. + * @param options.chainId - Chain the withdrawal happens on. + * @param options.tellerAddress - Address of the teller contract. + * @param options.accountantAddress - Address of the vault accountant contract. + * @param options.moneyAccountAddress - Money account address; the vault sends + * the redeemed assets here first. + * @param options.recipient - Address that receives the subsequent transfer. + * @param options.provider - Provider used for the `getRate` read. + * @returns The withdraw and transfer transactions, keyed by name. + */ +export async function buildMoneyAccountWithdrawBatch({ + amount, + chainId, + tellerAddress, + accountantAddress, + moneyAccountAddress, + recipient, + provider, +}: BuildMoneyAccountWithdrawBatchOptions): Promise { + assertNonZeroAmount(amount, 'buildMoneyAccountWithdrawPlaceholderBatch'); + + const musdAddress = getMoneyAccountDepositAssetAddress(chainId); + + const shareAmount = getSharesForWithdrawal( + amount, + await getVaultRate({ accountantAddress, provider }), + ); + // Allow 1-unit slippage on minimumAssets as defense-in-depth against + // rounding: the contract's mulDivDown can truncate assetsOut by up to + // 1 unit relative to the requested amount. This tolerance is safe + // because ceiling division in getSharesForWithdrawal already guarantees + // assetsOut >= amount; the 1-unit slack here is a second line of + // defense, not a standalone fix. The subsequent ERC-20 transfer uses + // the original `amount`, so the tolerance does not affect how much the + // user receives — it only prevents a spurious revert from the teller's + // MinimumAssetsNotMet check. + const minimumAssets = amount - 1n; + const withdrawData = buildWithdrawData( + musdAddress, + shareAmount, + minimumAssets, + moneyAccountAddress, + ); + const transferData = buildErc20TransferData(recipient, amount); + + return { + withdrawTx: { + params: { + to: tellerAddress, + data: withdrawData, + value: '0x0', + }, + type: TransactionType.moneyAccountWithdraw, + }, + transferTx: { + params: { + to: musdAddress, + data: transferData, + value: '0x0', + }, + type: TransactionType.tokenMethodTransfer, + }, + }; +} + +export type BuildMoneyAccountWithdrawPlaceholderBatchOptions = { + chainId: Hex; + tellerAddress: Hex; +}; + +/** + * Builds the withdraw + transfer pair for a Money Account withdrawal *without* + * calldata, for placeholder batches that Pay re-encodes once the user picks an + * amount. Resolves the call targets and types only, so it performs no vault + * reads and needs neither a provider, an accountant address, a recipient, nor + * the money account address. + * + * Mirrors {@link buildMoneyAccountDepositPlaceholderBatch}. Encoding a + * zero-amount withdrawal instead would produce a valid, submittable + * `withdraw(mUSD, 0, 0, moneyAccount)` that the teller rejects for redeeming no + * shares — this builder makes that state unrepresentable. + * + * @param options - Options bag. + * @param options.chainId - Chain the withdrawal happens on. + * @param options.tellerAddress - Address of the teller contract. + * @returns The withdraw and transfer transaction targets, keyed by name. + */ +export function buildMoneyAccountWithdrawPlaceholderBatch({ + chainId, + tellerAddress, +}: BuildMoneyAccountWithdrawPlaceholderBatchOptions): MoneyAccountWithdrawPlaceholderBatchResult { + return { + withdrawTx: { + params: { + to: tellerAddress, + value: '0x0', + }, + type: TransactionType.moneyAccountWithdraw, + }, + transferTx: { + params: { + to: getMoneyAccountDepositAssetAddress(chainId), + value: '0x0', + }, + type: TransactionType.tokenMethodTransfer, + }, + }; +} diff --git a/packages/money-account-utils/tsconfig.build.json b/packages/money-account-utils/tsconfig.build.json new file mode 100644 index 00000000000..4b8f0550830 --- /dev/null +++ b/packages/money-account-utils/tsconfig.build.json @@ -0,0 +1,14 @@ +{ + "extends": "../../tsconfig.packages.build.json", + "compilerOptions": { + "baseUrl": "./", + "outDir": "./dist", + "rootDir": "./src" + }, + "references": [ + { + "path": "../transaction-controller/tsconfig.build.json" + } + ], + "include": ["../../types", "./src"] +} diff --git a/packages/money-account-utils/tsconfig.json b/packages/money-account-utils/tsconfig.json new file mode 100644 index 00000000000..e76f4735241 --- /dev/null +++ b/packages/money-account-utils/tsconfig.json @@ -0,0 +1,12 @@ +{ + "extends": "../../tsconfig.packages.json", + "compilerOptions": { + "baseUrl": "./" + }, + "references": [ + { + "path": "../transaction-controller" + } + ], + "include": ["../../types", "./src"] +} diff --git a/packages/money-account-utils/typedoc.json b/packages/money-account-utils/typedoc.json new file mode 100644 index 00000000000..c9da015dbf8 --- /dev/null +++ b/packages/money-account-utils/typedoc.json @@ -0,0 +1,7 @@ +{ + "entryPoints": ["./src/index.ts"], + "excludePrivate": true, + "hideGenerator": true, + "out": "docs", + "tsconfig": "./tsconfig.build.json" +} diff --git a/packages/multichain-account-service/CHANGELOG.md b/packages/multichain-account-service/CHANGELOG.md new file mode 100644 index 00000000000..fe40d5794da --- /dev/null +++ b/packages/multichain-account-service/CHANGELOG.md @@ -0,0 +1,663 @@ +# 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] + +### Changed + +- Bump `@metamask/accounts-controller` from `^39.1.0` to `^39.1.1` ([#9969](https://github.com/MetaMask/core/pull/9969)) + +## [13.0.2] + +### Changed + +- Bump `@metamask/accounts-controller` from `^39.0.7` to `^39.1.0` ([#9807](https://github.com/MetaMask/core/pull/9807)) + +### Fixed + +- Ensure providers are ready before running post-alignment ([#9812](https://github.com/MetaMask/core/pull/9812)) + - This prevents to lock a multichain account wallet if one of its provider is not ready yet to proceed. + - By checking this before locking the multichain account wallet, we prevent potential deadlocks. + +## [13.0.1] + +### Changed + +- Bump `@metamask/account-api` from `^1.0.4` to `^2.0.0` ([#9676](https://github.com/MetaMask/core/pull/9676), [#9754](https://github.com/MetaMask/core/pull/9754)) +- Bump `@metamask/keyring-api` from `^23.5.0` to `^24.0.0` ([#9676](https://github.com/MetaMask/core/pull/9676), [#9754](https://github.com/MetaMask/core/pull/9754)) +- Bump `@metamask/keyring-internal-api` from `^11.0.1` to `^12.0.0` ([#9676](https://github.com/MetaMask/core/pull/9676), [#9754](https://github.com/MetaMask/core/pull/9754)) +- Bump `@metamask/keyring-snap-client` from `^9.2.0` to `^10.0.0` ([#9676](https://github.com/MetaMask/core/pull/9676), [#9754](https://github.com/MetaMask/core/pull/9754)) +- Bump `@metamask/snap-account-service` from `^2.0.0` to `^2.1.2` ([#9716](https://github.com/MetaMask/core/pull/9716), [#9736](https://github.com/MetaMask/core/pull/9736), [#9791](https://github.com/MetaMask/core/pull/9791)) +- Bump `@metamask/accounts-controller` from `^39.0.5` to `^39.0.7` ([#9735](https://github.com/MetaMask/core/pull/9735), [#9791](https://github.com/MetaMask/core/pull/9791)) +- Bump `@metamask/eth-snap-keyring` from `^23.0.0` to `^24.0.0` ([#9754](https://github.com/MetaMask/core/pull/9754)) +- Bump `@metamask/keyring-utils` from `^3.3.1` to `^5.0.0` ([#9754](https://github.com/MetaMask/core/pull/9754)) +- Bump `@metamask/superstruct` from `^3.1.0` to `^3.4.1` ([#9754](https://github.com/MetaMask/core/pull/9754)) +- Bump `@metamask/keyring-controller` from `^27.1.0` to `^27.1.1` ([#9791](https://github.com/MetaMask/core/pull/9791)) + +## [13.0.0] + +### Changed + +- Bump `@metamask/accounts-controller` from `^39.0.4` to `^39.0.5` ([#9470](https://github.com/MetaMask/core/pull/9470)) + +### Removed + +- **BREAKING:** Removed use of v1 `createAccount` entirely ([#9460](https://github.com/MetaMask/core/pull/9460)) + - All BIP-44 Snaps are expected to implement `createAccounts` (v1 or v2). + - The old v1 `createAccount` flow had some undesired side-effetcs sometimes (e.g auto-selecting a non-EVM account after being created, which is not compatible with our new group model). + +## [12.0.0] + +### Added + +- Capability-gated v1/v2 keyring client selection for Snap account providers ([#9377](https://github.com/MetaMask/core/pull/9377)) + - Providers resolve a Snap's capabilities via `SnapAccountService:getCapabilities` and route account creation and discovery through the v1 or v2 keyring client accordingly (a Snap that declares BIP-44 capabilities is treated as v2). + - Account discovery for v2 Snaps that declare `bip44.discover` flows through `createAccounts({ bip44:discover })`; v1 Snaps keep using the `discoverAccounts` client method. +- Adds `MultichainAccountGroup.isProviderAligned(provider)` to check alignment per provider ([#9269](https://github.com/MetaMask/core/pull/9269)) + +### Changed + +- **BREAKING:** Remove `batched` from `SnapAccountProviderConfig['createAccounts']` ([#9377](https://github.com/MetaMask/core/pull/9377)) + - Batching is now derived from the Snap's capabilities (v2 Snaps that declare `bip44` use the `createAccounts` flow, since they expose no singular `createAccount`) instead of static config. +- **BREAKING:** Now requires `SnapAccountService:getCapabilities` action ([#9377](https://github.com/MetaMask/core/pull/9377)) +- **BREAKING:** `RestrictedSnapKeyring.createAccount` has been replaced by `RestrictedSnapKeyring.v1`, which is `undefined` for v2-only Snaps ([#9390](https://github.com/MetaMask/core/pull/9390)) + - Any subclass implementing `SnapAccountProvider.createAccountV1` must now check `keyring.v1` guard followed by `keyring.v1.createAccount(options)`. +- Wallet alignment now only creates the missing `(provider, group index)` pairs instead of re-creating the whole range for every provider, avoiding redundant `createAccounts` calls (and their traces) ([#9269](https://github.com/MetaMask/core/pull/9269)) +- Bump `@metamask/accounts-controller` from `^39.0.3` to `^39.0.4` ([#9349](https://github.com/MetaMask/core/pull/9349)) +- Bump `@metamask/messenger` from `^1.2.0` to `^2.0.0` ([#9392](https://github.com/MetaMask/core/pull/9392)) +- Bump `@metamask/eth-snap-keyring` from `^22.3.0` to `^23.0.0` ([#9390](https://github.com/MetaMask/core/pull/9390)) +- Bump `@metamask/keyring-api` from `^23.3.0` to `^23.5.0` ([#9390](https://github.com/MetaMask/core/pull/9390)) +- Bump `@metamask/keyring-snap-client` from `^9.0.2` to `^9.2.0` ([#9390](https://github.com/MetaMask/core/pull/9390)) +- Bump `@metamask/snap-account-service` from `^1.0.0` to `^2.0.0` ([#9429](https://github.com/MetaMask/core/pull/9429)) + +## [11.1.0] + +### Added + +- Add `XlmAccountProvider` for Stellar account support ([#8830](https://github.com/MetaMask/core/pull/8830)) + - Export `XlmAccountProvider`, `XLM_ACCOUNT_PROVIDER_NAME`, and `XlmAccountProviderConfig`. + +### Changed + +- Bump `@metamask/keyring-api` from `^23.1.0` to `^23.3.0` ([#9249](https://github.com/MetaMask/core/pull/9249)) +- Bump `@metamask/keyring-utils` from `^3.2.1` to `^3.3.1` ([#9249](https://github.com/MetaMask/core/pull/9249)) + +## [11.0.0] + +### Added + +- Added `Bip44AccountProvider.deleteAccount(id)` method ([#8960](https://github.com/MetaMask/core/pull/8960)) + - The `KeyringController` will automatically prunes the non-primary empty keyrings when the last EVM account is getting removed. + - `AccountProviderWrapper.deleteAccount(id)` always removes the account, even if disabled. +- Added `AccountProviderWrapper.unwrap` method ([#8960](https://github.com/MetaMask/core/pull/8960)) + - Use this if you need to access the inner (wrapped) keyring. +- Add `isAligned` ([#9039](https://github.com/MetaMask/core/pull/9039)) + - This allows callers to cheaply check whether alignment has already occurred before triggering an explicit alignment operation. + +### Changed + +- **BREAKING:** Replace `KeyringController:withKeyring` with `KeyringController:withKeyringV2` for the Snap account providers ([#8732](https://github.com/MetaMask/core/pull/8732)) +- Bump `@metamask/eth-snap-keyring` from `^22.0.1` to `^22.3.0` ([#8732](https://github.com/MetaMask/core/pull/8732)) +- **BREAKING:** `MultichainAccountService.removeMultichainAccountWallet` (and messenger action) now takes a single `entropySource` argument ([#8960](https://github.com/MetaMask/core/pull/8960)) + - The previous `accountAddress` parameter has been removed. + - All accounts are now unconditionally removed from the wallet and providers (even for disabled `AccountProviderWrapper`). + - Per-account deletions are best-effort: a single account's failure does not abort cleanup of the remaining accounts. + - Errors are aggregated and reported in case of failure. +- Bump `@metamask/utils` from `^11.9.0` to `^11.11.0` ([#9074](https://github.com/MetaMask/core/pull/9074)) +- Bump `@metamask/controller-utils` from `^12.1.1` to `^12.2.0` ([#9083](https://github.com/MetaMask/core/pull/9083)) +- Bump `@metamask/keyring-controller` from `^27.0.0` to `^27.1.0` ([#9129](https://github.com/MetaMask/core/pull/9129)) +- Bump `@metamask/accounts-controller` from `^39.0.1` to `^39.0.3` ([#9218](https://github.com/MetaMask/core/pull/9218), [#9231](https://github.com/MetaMask/core/pull/9231)) +- Bump `@metamask/snap-account-service` from `^0.3.1` to `^1.0.0` ([#9231](https://github.com/MetaMask/core/pull/9231)) + +## [10.0.3] + +### Changed + +- Bump `@metamask/accounts-controller` from `^39.0.0` to `^39.0.1` ([#9058](https://github.com/MetaMask/core/pull/9058)) +- Bump `@metamask/keyring-controller` from `^26.0.0` to `^27.0.0` ([#9058](https://github.com/MetaMask/core/pull/9058)) +- Bump `@metamask/snap-account-service` from `^0.3.0` to `^0.3.1` ([#9058](https://github.com/MetaMask/core/pull/9058)) + +## [10.0.2] + +### Changed + +- Bump `@metamask/accounts-controller` from `^38.1.2` to `^39.0.0` ([#8999](https://github.com/MetaMask/core/pull/8999)) +- Bump `@metamask/snap-account-service` from `^0.2.1` to `^0.3.0` ([#8999](https://github.com/MetaMask/core/pull/8999)) + +## [10.0.1] + +### Changed + +- Bump `@metamask/snap-account-service` from `^0.1.0` to `^0.2.1` ([#8844](https://github.com/MetaMask/core/pull/8844), [#8912](https://github.com/MetaMask/core/pull/8912)) +- Bump `@metamask/keyring-controller` from `^25.5.0` to `^26.0.0` ([#8912](https://github.com/MetaMask/core/pull/8912)) +- Bump `@metamask/accounts-controller` from `^38.1.1` to `^38.1.2` ([#8912](https://github.com/MetaMask/core/pull/8912)) + +## [10.0.0] + +### Changed + +- **BREAKING:** The service messenger now requires the `SnapAccountService:ensureReady` action to be declared ([#8715](https://github.com/MetaMask/core/pull/8715)) +- **BREAKING:** Delegate Snap platform readiness to `@metamask/snap-account-service` ([#8715](https://github.com/MetaMask/core/pull/8715), [#8752](https://github.com/MetaMask/core/pull/8752)) + - Removed `MultichainAccountService.ensureCanUseSnapPlatform()` method and the corresponding `MultichainAccountService:ensureCanUseSnapPlatform` messenger action. + - Removed the `MultichainAccountServiceEnsureCanUseSnapPlatformAction` type export. + - Removed `MultichainAccountServiceOptions.ensureOnboardingComplete`. Configure it via `SnapAccountService`'s `config.snapPlatformWatcher.ensureOnboardingComplete` instead. + - Removed `MultichainAccountServiceConfig.snapPlatformWatcher` and the `SnapPlatformWatcherConfig` type export. Configure the keyring-wait timeout via `SnapAccountService`'s `config.snapPlatformWatcher.snapKeyringWaitTimeoutMs` instead. + - The service messenger no longer needs `SnapController:getState`, `SnapController:stateChange` or `KeyringController:stateChange`. +- **BREAKING:** Rename `SnapAccountProvider.ensureCanUseSnapPlatform()` to `ensureReady()` ([#8715](https://github.com/MetaMask/core/pull/8715)) +- Bump `@metamask/accounts-controller` from `^38.0.0` to `^38.1.1` ([#8755](https://github.com/MetaMask/core/pull/8755), [#8774](https://github.com/MetaMask/core/pull/8774)) +- Bump `@metamask/snap-account-service` from `^0.0.0` to `^0.1.0` ([#8783](https://github.com/MetaMask/core/pull/8783)) + +## [9.0.0] + +### Added + +- Expose missing `MultichainAccountService:init` action through its messenger ([#8717](https://github.com/MetaMask/core/pull/8717)) + - Corresponding action type is available as well. +- Filter out `KeyringController` locked errors from sentry reporting ([#8619](https://github.com/MetaMask/core/pull/8619)) + +### Changed + +- **BREAKING:** Replace `KeyringController:withKeyring` with `KeyringController:withKeyringV2` for the EVM account provider ([#8491](https://github.com/MetaMask/core/pull/8491)) +- Bump `@metamask/accounts-controller` from `^37.1.1` to `^38.0.0` ([#8363](https://github.com/MetaMask/core/pull/8363), [#8665](https://github.com/MetaMask/core/pull/8665)) +- Bump `@metamask/keyring-controller` from `^25.1.1` to `^25.5.0` ([#8363](https://github.com/MetaMask/core/pull/8363), [#8634](https://github.com/MetaMask/core/pull/8634), [#8665](https://github.com/MetaMask/core/pull/8665), [#8722](https://github.com/MetaMask/core/pull/8722)) +- Bump `@metamask/messenger` from `^1.0.0` to `^1.2.0` ([#8364](https://github.com/MetaMask/core/pull/8364), [#8373](https://github.com/MetaMask/core/pull/8373), [#8632](https://github.com/MetaMask/core/pull/8632)) +- Bump `@metamask/base-controller` from `^9.0.1` to `^9.1.0` ([#8457](https://github.com/MetaMask/core/pull/8457)) +- Bump `@metamask/account-api` from `^1.0.0` to `^1.0.4` ([#8464](https://github.com/MetaMask/core/pull/8464), [#8647](https://github.com/MetaMask/core/pull/8647)) +- Bump `@metamask/eth-snap-keyring` from `^19.0.0` to `^22.0.1` ([#8464](https://github.com/MetaMask/core/pull/8464), [#8584](https://github.com/MetaMask/core/pull/8584), [#8647](https://github.com/MetaMask/core/pull/8647)) +- Bump `@metamask/keyring-api` from `^21.6.0` to `^23.1.0` ([#8464](https://github.com/MetaMask/core/pull/8464), [#8647](https://github.com/MetaMask/core/pull/8647)) +- Bump `@metamask/keyring-internal-api` from `^10.0.0` to `^11.0.1` ([#8464](https://github.com/MetaMask/core/pull/8464), [#8584](https://github.com/MetaMask/core/pull/8584), [#8647](https://github.com/MetaMask/core/pull/8647)) +- Bump `@metamask/keyring-snap-client` from `^8.2.0` to `^9.0.2` ([#8464](https://github.com/MetaMask/core/pull/8464), [#8647](https://github.com/MetaMask/core/pull/8647)) +- Bump `@metamask/keyring-utils` from `^3.1.0` to `^3.2.1` ([#8703](https://github.com/MetaMask/core/pull/8703)) + +## [8.0.1] + +### Changed + +- Bump `@metamask/snaps-controllers` from `^17.2.0` to `^19.0.0` ([#8319](https://github.com/MetaMask/core/pull/8319)) +- Bump `@metamask/snaps-sdk` from `^10.3.0` to `^11.0.0` ([#8319](https://github.com/MetaMask/core/pull/8319)) +- Bump `@metamask/snaps-utils` from `^11.7.0` to `^12.1.2` ([#8319](https://github.com/MetaMask/core/pull/8319)) +- Bump `@metamask/accounts-controller` from `^37.1.0` to `^37.1.1` ([#8325](https://github.com/MetaMask/core/pull/8325)) + +## [8.0.0] + +### Added + +- Use `{Btc,Sol}AccountProvider` as default providers ([#8262](https://github.com/MetaMask/core/pull/8262)) + - Those providers were initially provided by the clients. +- Add new `createMultichainAccountGroups` support to create multiple groups in batch ([#7801](https://github.com/MetaMask/core/pull/7801), [#8190](https://github.com/MetaMask/core/pull/8190)) +- Add new `resyncAccounts.autoRemoveExtraSnapAccounts` configuration on Snap-based providers ([#8200](https://github.com/MetaMask/core/pull/8200)) + - When enabled, this will make the `resyncAccounts` method automatically remove any extra accounts that exist on the Snap side but not on MetaMask side. + - This behavior was enabled by default and can now be turned off by the clients. +- Add new `snapPlatformWatcher.timeoutMs` configuration ([#8196](https://github.com/MetaMask/core/pull/8196)) + - Allows configuring how long to wait for the Snap keyring to appear in `KeyringController` before timing out (Default is 5000 ms). +- Add more tracing (alignment, create account v1/v2) ([#8244](https://github.com/MetaMask/core/pull/8244)) +- Add local perf tracing ([#8244](https://github.com/MetaMask/core/pull/8244)) + - Each trace is now automatically wrapped and will log performance timings using the internal logger. + - Only enabled if `metamask:multichain-account-service` is part of `DEBUG` (env var) filters. + +### Changed + +- Bump `@metamask/accounts-controller` from `^37.0.0` to `^37.1.0` ([#8317](https://github.com/MetaMask/core/pull/8317)) +- Bump `@metamask/base-controller` from `^9.0.0` to `^9.0.1` ([#8317](https://github.com/MetaMask/core/pull/8317)) +- Bump `@metamask/keyring-controller` from `^25.1.0` to `^25.1.1` ([#8317](https://github.com/MetaMask/core/pull/8317)) +- Bump `@metamask/messenger` from `^0.3.0` to `^1.0.0` ([#8317](https://github.com/MetaMask/core/pull/8317)) +- Bump `@metamask/keyring-api` from `^21.5.0` to `^21.6.0` ([#8259](https://github.com/MetaMask/core/pull/8259)) +- Optimize `{Sol,Btc,Trx}AccountProvider.createAccounts` for range operations ([#8131](https://github.com/MetaMask/core/pull/8131)) + - Each Snaps have to implement the new `keyring_createAccounts` method accordingly and enable the batch option using the provider's configuration object. + - Batch account creation with the new `SnapKeyring.createAccounts` method. + - Significantly reduces lock acquisitions and API calls for batch operations. +- Optimize `EvmAccountProvider.createAccounts` for range operations ([#7801](https://github.com/MetaMask/core/pull/7801)) + - Batch account creation with single a `withKeyring` call for entire range instead of one call per account. + - Batch account creation with single `keyring.addAccounts` call. + - Fetch all accounts in single `AccountsController:getAccounts` call instead of multiple `getAccount` calls. + - Significantly reduces lock acquisitions and API calls for batch operations. +- Do not report `TimeoutError` errors ([#8249](https://github.com/MetaMask/core/pull/8249)) + - All other kind of errors are still reported as usual. + +### Removed + +- **BREAKING:** Remove `MultichainAccountGroup.alignAccounts` method ([#7801](https://github.com/MetaMask/core/pull/7801)) + - Use `MultichainAccountWallet.alignAccountsOf` instead, since this method properly lock the wallet (parent of this group) state. + +### Fixed + +- Prevent wallet's lock by-pass when creating non-EVM account asynchronously ([#7801](https://github.com/MetaMask/core/pull/7801)) + - The `waitForAllProvidersToFinishCreatingAccounts` option (when set to `false`) was causing account creation to be asynchronous for non-EVM providers, which was potentially creating accounts after the wallet's internal lock was released. + - We now run an internal account alignment operation which locks the wallet properly and runs in the background. +- Wait for Snap keyring in KeyringController before non-EVM account creation ([#8196](https://github.com/MetaMask/core/pull/8196)) + - After wallet reset or restore, the Snap keyring is created lazily (e.g. when `getSnapKeyring()` runs). We now wait for it to appear (via `KeyringController:getState` and `KeyringController:stateChange`) with a timeout, avoiding "Keyring not found" error. + +## [7.1.0] + +### Added + +- Add new optional `ensureOnboardingComplete` callback ([#8124](https://github.com/MetaMask/core/pull/8124)) + - This allows the service to wait for the user to re-onboard after a wallet reset. + +### Changed + +- Bump `@metamask/accounts-controller` from `^36.0.0` to `^37.0.0` ([#7996](https://github.com/MetaMask/core/pull/7996), [#8140](https://github.com/MetaMask/core/pull/8140)) + +## [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)) +- **BREAKING:** Bump `@metamask/eth-snap-keyring` from `^18.0.0` to `^19.0.0` ([#7857](https://github.com/MetaMask/core/pull/7857)) + - Required to invoke `createAccounts` on any account management Snaps. +- **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)) + +## [6.0.0] + +### Changed + +- **BREAKING** A performance refactor was made around all the classes in this package ([#6654](https://github.com/MetaMask/core/pull/6654)) + - The `MultichainAccountService` is refactored to construct a top level service state for its `init` function, this state is passed down to the `MultichainAccountWallet` and `MultichainAccountGroup` classes in slices for them to construct their internal states. + - Additional state is generated at the entry points where it needs to be updated i.e. `createMultichainAccountGroup`, `discoverAccounts` and `alignAccounts`. + - We no longer prevent group creation if some providers' `createAccounts` calls fail during group creation, only if they all fail. + - The `getAccounts` method in the `BaseBip44AccountProvider` class no longer relies on fetching the entire list of internal accounts from the `AccountsController`, instead it gets the specific accounts that it stores in its internal accounts list. + - The `EvmAccountProvider` no longer fetches from the `AccountController` to get an account for its ID, we deterministically get the associated account ID through `getUUIDFromAddressOfNormalAccount`. + - The `EvmAccountProvider` now uses the `getAccount` method from the `AccountsController` when fetching an account after account creation as it is more efficient. + - Add logic in the `createMultichainAccountWallet` method in `MultichainAccountService` so that it can handle all entry points: importing an SRP, recovering a vault and creating a new vault. + - Add a `getAccountIds` method which returns all the account ids pertaining to a group. + - Add an `addAccounts` method on the `BaseBip44AccountProvider` class which keeps track of all the account IDs that pertain to it. +- Bump `@metamask/keyring-controller` from `^25.0.0` to `^25.1.0` ([#7713](https://github.com/MetaMask/core/pull/7713)) + +### Removed + +- **BREAKING** A performance refactor was made around all the classes in this package ([#6654](https://github.com/MetaMask/core/pull/6654)) + - Remove `#handleOnAccountAdded` and `#handleOnAccountRemoved` methods in `MultichainAccountService` due to internal state being updated within the service. + - Remove `getAccountContext` (and associated map) in the `MultichainAccountService` as the service no longer uses that method. + - Remove the `sync` method in favor of the sole `init` method for both `MultichainAccountWallet` and `MultichainAccountGroup`. + +## [5.1.0] + +### Added + +- Recover from Snap account de-sync when Snap has more accounts than MetaMask ([#7671](https://github.com/MetaMask/core/pull/7671)) + +### Changed + +- Bump `@metamask/accounts-controller` from `^35.0.0` to `^35.0.2` ([#7604](https://github.com/MetaMask/core/pull/7604), [#7642](https://github.com/MetaMask/core/pull/7642)) +- Remove Sentry log before attempting Snap account re-sync ([#7675](https://github.com/MetaMask/core/pull/7675)) + +## [5.0.0] + +### Added + +- Wait for Snap platform to be ready before any wallet/group operations ([#7266](https://github.com/MetaMask/core/pull/7266)) +- Add `SnapAccountProvider.withSnap` protected helper ([#7266](https://github.com/MetaMask/core/pull/7266)) + - This is used to protect any Snap operation behind a guard that checks if the Snap platform is ready. +- Add `MultichainAccountService:ensureCanUseSnapPlatform` method and action. + - This will resolve once the Snap platform is ready for the first time and will throw afterward if Snap platform has been disabled dynamically. + - This action is mostly used internally by any Snap-based account providers. + +### Changed + +- **BREAKING:** The `SnapAccountProvider.client` property is now private ([#7266](https://github.com/MetaMask/core/pull/7266)) + - You now need to use `SnapAccountProvider.withSnap` to access to it. +- Bump `@metamask/snaps-controllers` from `^14.0.1` to `^17.2.0` ([#7550](https://github.com/MetaMask/core/pull/7550)) +- 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)) +- Remove dependency on `@metamask/error-reporting-service` ([#7542](https://github.com/MetaMask/core/pull/7542)) + - The service no longer needs `ErrorReportingService:captureException`. + +## [4.1.0] + +### Added + +- Add `config.discovery.enabled` option for all account provider config objects ([#7447](https://github.com/MetaMask/core/pull/7447)) +- Add `{EVM,SOL,BTC,TRX}_ACCOUNT_PROVIDER_DEFAULT_CONFIG` ([#7447](https://github.com/MetaMask/core/pull/7447)) + +## [4.0.1] + +### Changed + +- Move peer dependencies for controller and service packages to direct dependencies ([#7209](https://github.com/MetaMask/core/pull/7209)) + - The dependencies moved are: + - `@metamask/accounts-controller` (^35.0.0) + - `@metamask/error-reporting-service` (^3.0.0) + - `@metamask/keyring-controller` (^25.0.0) + - `@metamask/snaps-controllers` (^14.0.1) + - In clients, it is now possible for multiple versions of these packages to exist in the dependency tree. + - For example, this scenario would be valid: a client relies on `@metamask/controller-a` 1.0.0 and `@metamask/controller-b` 1.0.0, and `@metamask/controller-b` depends on `@metamask/controller-a` 1.1.0. + - Note, however, that the versions specified in the client's `package.json` always "win", and you are expected to keep them up to date so as not to break controller and service intercommunication. + +### Fixed + +- Harden EVM discovery in case of bad RPC response ([#7434](https://github.com/MetaMask/core/pull/7434)) + - If the response was not hex-formatted, then the EVM discovery was continuously running. + +## [4.0.0] + +### Changed + +- **BREAKING:** Bump `@metamask/keyring-controller` from `^24.0.0` to `^25.0.0` ([#7202](https://github.com/MetaMask/core/pull/7202)) +- **BREAKING:** Bump `@metamask/accounts-controller` from `^34.0.0` to `^35.0.0` ([#7202](https://github.com/MetaMask/core/pull/7202)) + +## [3.0.0] + +### Added + +- **BREAKING:** Added error reporting around account creation with the `ErrorReportingService` ([#7044](https://github.com/MetaMask/core/pull/7044)) + - The `@metamask/error-reporting-service` is now a peer dependency. +- Add `MultichainAccountService.resyncAccounts` method and action ([#7087](https://github.com/MetaMask/core/pull/7087), [#7093](https://github.com/MetaMask/core/pull/7093)) +- Add `*AccountProvider.resyncAccounts` method ([#7087](https://github.com/MetaMask/core/pull/7087)) + +### Changed + +- **BREAKING:** Make `init` method `async` ([#7087](https://github.com/MetaMask/core/pull/7087)) + - While this is not yet really used, we might want to make some `async` calls (like `resyncAccounts`) in `init` directly at some point. +- Add optional tracing configuration ([#7006](https://github.com/MetaMask/core/pull/7006)) + - For now, only the account discovery is being traced. +- Limit Bitcoin and Tron providers to 3 concurrent account creations by default when creating multichain account groups ([#7052](https://github.com/MetaMask/core/pull/7052)) + +## [2.1.0] + +### Added + +- Add per-provider throttling for non-EVM account creation to improve performance on low-end devices ([#7000](https://github.com/MetaMask/core/pull/7000)) + - Solana provider is now limited to 3 concurrent account creations by default when creating multichain account groups. + - Other providers remain unthrottled by default. + +## [2.0.1] + +### Fixed + +- Use `groupIndex` for account creations on `TrxAccountProvider` instead of the outdated `derivationPath` ([#7010](https://github.com/MetaMask/core/pull/7010), [#7018](https://github.com/MetaMask/core/pull/7018)) + +## [2.0.0] + +### Changed + +- **BREAKING:** Use new `Messenger` from `@metamask/messenger` ([#6544](https://github.com/MetaMask/core/pull/6544)) + - Previously, `MultichainAccountService` accepted a `RestrictedMessenger` instance from `@metamask/base-controller`. +- **BREAKING:** Bump `@metamask/accounts-controller` from `^33.0.0` to `^34.0.0` ([#6962](https://github.com/MetaMask/core/pull/6962)) +- **BREAKING:** Bump `@metamask/keyring-controller` from `^23.0.0` to `^24.0.0` ([#6962](https://github.com/MetaMask/core/pull/6962)) +- Bump `@metamask/base-controller` from `^8.4.2` to `^9.0.0` ([#6962](https://github.com/MetaMask/core/pull/6962)) +- Bump `@metamask/eth-snap-keyring` from `^17.0.0` to `^18.0.0` ([#6951](https://github.com/MetaMask/core/pull/6951)) + +## [1.6.2] + +### Changed + +- Bump `@metamask/base-controller` from `^8.4.1` to `^8.4.2` ([#6917](https://github.com/MetaMask/core/pull/6917)) + +## [1.6.1] + +### Changed + +- Bump `@metamask/base-controller` from `^8.4.0` to `^8.4.1` ([#6807](https://github.com/MetaMask/core/pull/6807)) + +## [1.6.0] + +### Changed + +- Update Bitcoin account provider to only create/discover Native SegWit (P2wpkh) accounts ([#6783](https://github.com/MetaMask/core/pull/6783)) + +## [1.5.0] + +### Added + +- Add an optional `options` parameter to `MultichainAccountWallet.createMultichainAccountGroup()` ([#6759](https://github.com/MetaMask/core/pull/6759)) + - Introduces `options.waitForAllProvidersToFinishCreatingAccounts`, that will make `createMultichainAccountGroup` await either only the EVM provider or all the providers to have created their accounts depending on the value. Defaults to `false` (only awaits for EVM accounts creation by default). + +## [1.4.0] + +### Changed + +- Only await for EVM account creation in `MultichainAccountWallet.createMultichainAccountGroup()` instead of all types of providers ([#6755](https://github.com/MetaMask/core/pull/6755)) + - Other type of providers will create accounts in the background and won't throw errors in case they fail to do so. + - Multichain account groups will now be "misaligned" for a short period of time, until each of the other providers finish creating their accounts. + +## [1.3.0] + +### Added + +- Add `{Btc/Trx}AccountProvider` account providers ([#6662](https://github.com/MetaMask/core/pull/6662)) + +### Changed + +- Bump `@metamask/utils` from `^11.8.0` to `^11.8.1` ([#6708](https://github.com/MetaMask/core/pull/6708)) + +## [1.2.0] + +### Changed + +- Add more internal logs ([#6729](https://github.com/MetaMask/core/pull/6729)) + +## [1.1.0] + +### Added + +- Add a timeout around Solana account creation ([#6704](https://github.com/MetaMask/core/pull/6704)) + - This timeout can be configured at the client level through the config passed to the `MultichainAccountService`. + +## [1.0.0] + +### Changed + +- Bump package version to v1.0 to mark stabilization ([#6676](https://github.com/MetaMask/core/pull/6676)) + +## [0.11.0] + +### Added + +- Add missing exports for providers (`{EVM,SOL}_ACCOUNT_PROVIDER_NAME` + `${Evm,Sol}AccountProvider}`) ([#6660](https://github.com/MetaMask/core/pull/6660)) + - These are required when setting the new account providers when constructing the service. + +## [0.10.0] + +### Added + +- Add timeout and retry mechanism to Solana discovery ([#6624](https://github.com/MetaMask/core/pull/6624)) +- Add custom account provider configs ([#6624](https://github.com/MetaMask/core/pull/6624)) + - This new config can be set by the clients to update discovery timeout/retry values. + +### Fixed + +- No longer create temporary EVM account during discovery ([#6650](https://github.com/MetaMask/core/pull/6650)) + - We used to create the EVM account and remove it if there was no activity for that account. Now we're just deriving the next address directly, which avoids state mutation. + - This prevents `:accountAdded` event from being published, which also prevents account-tree and multichain-account service updates. + - Backup & sync will no longer synchronize this temporary account group, which was causing a bug that persisted it on the user profile and left it permanently. + +## [0.9.0] + +### Added + +- **BREAKING** Add additional allowed actions to the `MultichainAccountService` messenger + - `KeyringController:getKeyringsByType` and `KeyringController:addNewKeyring` actions were added. +- Add `createMultichainAccountWallet` method to create a new multichain account wallet from a mnemonic ([#6478](https://github.com/MetaMask/core/pull/6478)) + - An action handler was also registered for this method so that it can be called from the clients. + +### Changed + +- **BREAKING:** Rename `MultichainAccountWallet.alignGroup` to `alignAccountsOf` ([#6595](https://github.com/MetaMask/core/pull/6595)) +- **BREAKING:** Rename `MultichainAccountGroup.align` to `alignAccounts` ([#6595](https://github.com/MetaMask/core/pull/6595)) +- Add timeout and retry mechanism to EVM discovery ([#6609](https://github.com/MetaMask/core/pull/6609), [#6621](https://github.com/MetaMask/core/pull/6621)) +- Bump `@metamask/utils` from `^11.4.2` to `^11.8.0` ([#6588](https://github.com/MetaMask/core/pull/6588)) +- Bump `@metamask/base-controller` from `^8.3.0` to `^8.4.0` ([#6632](https://github.com/MetaMask/core/pull/6632)) + +## [0.8.0] + +### Added + +- Add mutable operation lock (per wallets) ([#6527](https://github.com/MetaMask/core/pull/6527)) + - Operations such as discovery, alignment, group creation will now lock an internal mutex (per wallets). +- Add wallet status tracking with `:walletStatusChange` event ([#6527](https://github.com/MetaMask/core/pull/6527)) + - This can be used to track what's the current status of a wallet (e.g. which operation is currently running OR if the wallet is ready to run any new operations). +- Add `MultichainAccountWalletStatus` enum ([#6527](https://github.com/MetaMask/core/pull/6527)) + - Enumeration of all possible wallet statuses. +- Add `MultichainAccountWallet.status` ([#6527](https://github.com/MetaMask/core/pull/6527)) + - To get the current status of a multichain account wallet instance. +- Add multichain account group lifecycle events ([#6441](https://github.com/MetaMask/core/pull/6441)) + - Add `multichainAccountGroupCreated` event emitted from wallet level when new groups are created. + - Add `multichainAccountGroupUpdated` event emitted from wallet level when groups are synchronized. + +### Changed + +- **BREAKING:** Bump peer dependency `@metamask/account-api` from `^0.9.0` to `^0.12.0` ([#6560](https://github.com/MetaMask/core/pull/6560)) +- **BREAKING:** Rename `alignGroups` to `alignAccounts` for `MultichainAccountWallet` ([#6560](https://github.com/MetaMask/core/pull/6560)) +- **BREAKING:** Rename `MultichainAccountWallet.discoverAndCreateAccounts` to `discoverAccounts` for `MultichainAccountWallet` and `*Provider*` types ([#6560](https://github.com/MetaMask/core/pull/6560)) +- **BREAKING:** Remove `MultichainAccountService:getIsAlignementInProgress` action ([#6527](https://github.com/MetaMask/core/pull/6527)) + - This is now being replaced with the wallet's status logic. +- Bump `@metamask/keyring-api` from `^20.1.0` to `^21.0.0` ([#6560](https://github.com/MetaMask/core/pull/6560)) +- Bump `@metamask/keyring-internal-api` from `^8.1.0` to `^9.0.0` ([#6560](https://github.com/MetaMask/core/pull/6560)) +- Bump `@metamask/keyring-snap-client` from `^7.0.0` to `^8.0.0` ([#6560](https://github.com/MetaMask/core/pull/6560)) +- Bump `@metamask/eth-snap-keyring` from `^16.1.0` to `^17.0.0` ([#6560](https://github.com/MetaMask/core/pull/6560)) + +## [0.7.0] + +### Added + +- Add `discoverAndCreateAccounts` methods for EVM and Solana providers ([#6397](https://github.com/MetaMask/core/pull/6397)) +- Add `discoverAndCreateAccounts` method to `MultichainAccountWallet` to orchestrate provider discovery ([#6397](https://github.com/MetaMask/core/pull/6397)) +- **BREAKING** Add additional allowed actions to the `MultichainAccountService` messenger + - `NetworkController:getNetworkClientById` and `NetworkController:findNetworkClientIdByChainId` were added. + +### Changed + +- Bump `@metamask/base-controller` from `^8.2.0` to `^8.3.0` ([#6465](https://github.com/MetaMask/core/pull/6465)) + +## [0.6.0] + +### Added + +- Add `setBasicFunctionality` method to control providers state and trigger wallets alignment ([#6332](https://github.com/MetaMask/core/pull/6332)) + - Add `AccountProviderWrapper` to handle Snap account providers behavior according to the basic functionality flag. + +### Changed + +- Bump `@metamask/base-controller` from `^8.1.0` to `^8.2.0` ([#6355](https://github.com/MetaMask/core/pull/6355)) +- **BREAKING**: Rename `BaseAccountProvider` to `BaseBip44AccountProvider` for clarity ([#6332](https://github.com/MetaMask/core/pull/6332)) + +### Fixed + +- Move account event subscriptions to the constructor ([#6394](https://github.com/MetaMask/core/pull/6394)) +- Clear state before re-initilizing the service ([#6394](https://github.com/MetaMask/core/pull/6394)) + +## [0.5.0] + +### Added + +- Allow for multichain account group alignment through the `align` method ([#6326](https://github.com/MetaMask/core/pull/6326)) + - You can now call alignment from the group, wallet and service levels. + +### Changed + +- **BREAKING:** Bump peer dependency `@metamask/accounts-controller` from `^32.0.0` to `^33.0.0` ([#6345](https://github.com/MetaMask/core/pull/6345)) +- **BREAKING:** Bump peer dependency `@metamask/keyring-controller` from `^22.0.0` to `^23.0.0` ([#6345](https://github.com/MetaMask/core/pull/6345)) +- Bump `@metamask/base-controller` from `^8.0.1` to `^8.1.0` ([#6284](https://github.com/MetaMask/core/pull/6284)) +- Bump accounts related packages ([#6309](https://github.com/MetaMask/core/pull/6309)) + - Bump `@metamask/keyring-api` from `^20.0.0` to `^20.1.0` + - Bump `@metamask/keyring-internal-api` from `^8.0.0` to `^8.1.0` + - Bump `@metamask/eth-snap-keyring` from `^16.0.0` to `^16.1.0` + +## [0.4.0] + +### Added + +- Allow custom account providers ([#6231](https://github.com/MetaMask/core/pull/6231)) + - You can now pass an extra option `providers` in the service's constructor. +- Add multichain account group creation support ([#6222](https://github.com/MetaMask/core/pull/6222), [#6238](https://github.com/MetaMask/core/pull/6238), [#6240](https://github.com/MetaMask/core/pull/6240)) + - This includes the new actions `MultichainAccountService:createNextMultichainAccountGroup` and `MultichainAccountService:createMultichainAccountGroup`. +- Export `MultichainAccountWallet` and `MultichainAccountGroup` types ([#6220](https://github.com/MetaMask/core/pull/6220)) + +### Changed + +- **BREAKING:** Use `KeyringAccount` instead of `InternalAccount` ([#6227](https://github.com/MetaMask/core/pull/6227)) +- **BREAKING:** Bump peer dependency `@metamask/account-api` from `^0.3.0` to `^0.9.0` ([#6214](https://github.com/MetaMask/core/pull/6214), [#6216](https://github.com/MetaMask/core/pull/6216), [#6222](https://github.com/MetaMask/core/pull/6222), [#6248](https://github.com/MetaMask/core/pull/6248)) +- **BREAKING:** Rename `MultichainAccount` to `MultichainAccountGroup` ([#6216](https://github.com/MetaMask/core/pull/6216), [#6219](https://github.com/MetaMask/core/pull/6219)) + - The naming was confusing and since a `MultichainAccount` is also an `AccountGroup` it makes sense to have the suffix there too. +- **BREAKING:** Rename `getMultichainAccount*` to `getMultichainAccountGroup*` ([#6216](https://github.com/MetaMask/core/pull/6216), [#6219](https://github.com/MetaMask/core/pull/6219)) + - The naming was confusing and since a `MultichainAccount` is also an `AccountGroup` it makes sense to have the suffix there too. + +## [0.3.0] + +### Added + +- Add multichain account/wallet syncs ([#6165](https://github.com/MetaMask/core/pull/6165)) + - Those are getting sync'd during `AccountsController:account{Added,Removed}` events. +- Add actions `MultichainAccountService:getMultichain{Account,Accounts,AccountWallet,AccountWallets}` ([#6193](https://github.com/MetaMask/core/pull/6193)) + +### Changed + +- **BREAKING:** Add `@metamask/account-api` peer dependency ([#6115](https://github.com/MetaMask/core/pull/6115), [#6146](https://github.com/MetaMask/core/pull/6146)) + +## [0.2.1] + +### Fixed + +- Add missing `name` class field ([#6173](https://github.com/MetaMask/core/pull/6173)) + +## [0.2.0] + +### Changed + +- **BREAKING:** Bump peer dependency `@metamask/accounts-controller` from `^31.0.0` to `^32.0.0` ([#6171](https://github.com/MetaMask/core/pull/6171)) + +## [0.1.0] + +### Added + +- 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@13.0.2...HEAD +[13.0.2]: https://github.com/MetaMask/core/compare/@metamask/multichain-account-service@13.0.1...@metamask/multichain-account-service@13.0.2 +[13.0.1]: https://github.com/MetaMask/core/compare/@metamask/multichain-account-service@13.0.0...@metamask/multichain-account-service@13.0.1 +[13.0.0]: https://github.com/MetaMask/core/compare/@metamask/multichain-account-service@12.0.0...@metamask/multichain-account-service@13.0.0 +[12.0.0]: https://github.com/MetaMask/core/compare/@metamask/multichain-account-service@11.1.0...@metamask/multichain-account-service@12.0.0 +[11.1.0]: https://github.com/MetaMask/core/compare/@metamask/multichain-account-service@11.0.0...@metamask/multichain-account-service@11.1.0 +[11.0.0]: https://github.com/MetaMask/core/compare/@metamask/multichain-account-service@10.0.3...@metamask/multichain-account-service@11.0.0 +[10.0.3]: https://github.com/MetaMask/core/compare/@metamask/multichain-account-service@10.0.2...@metamask/multichain-account-service@10.0.3 +[10.0.2]: https://github.com/MetaMask/core/compare/@metamask/multichain-account-service@10.0.1...@metamask/multichain-account-service@10.0.2 +[10.0.1]: https://github.com/MetaMask/core/compare/@metamask/multichain-account-service@10.0.0...@metamask/multichain-account-service@10.0.1 +[10.0.0]: https://github.com/MetaMask/core/compare/@metamask/multichain-account-service@9.0.0...@metamask/multichain-account-service@10.0.0 +[9.0.0]: https://github.com/MetaMask/core/compare/@metamask/multichain-account-service@8.0.1...@metamask/multichain-account-service@9.0.0 +[8.0.1]: https://github.com/MetaMask/core/compare/@metamask/multichain-account-service@8.0.0...@metamask/multichain-account-service@8.0.1 +[8.0.0]: https://github.com/MetaMask/core/compare/@metamask/multichain-account-service@7.1.0...@metamask/multichain-account-service@8.0.0 +[7.1.0]: https://github.com/MetaMask/core/compare/@metamask/multichain-account-service@7.0.0...@metamask/multichain-account-service@7.1.0 +[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 +[4.1.0]: https://github.com/MetaMask/core/compare/@metamask/multichain-account-service@4.0.1...@metamask/multichain-account-service@4.1.0 +[4.0.1]: https://github.com/MetaMask/core/compare/@metamask/multichain-account-service@4.0.0...@metamask/multichain-account-service@4.0.1 +[4.0.0]: https://github.com/MetaMask/core/compare/@metamask/multichain-account-service@3.0.0...@metamask/multichain-account-service@4.0.0 +[3.0.0]: https://github.com/MetaMask/core/compare/@metamask/multichain-account-service@2.1.0...@metamask/multichain-account-service@3.0.0 +[2.1.0]: https://github.com/MetaMask/core/compare/@metamask/multichain-account-service@2.0.1...@metamask/multichain-account-service@2.1.0 +[2.0.1]: https://github.com/MetaMask/core/compare/@metamask/multichain-account-service@2.0.0...@metamask/multichain-account-service@2.0.1 +[2.0.0]: https://github.com/MetaMask/core/compare/@metamask/multichain-account-service@1.6.2...@metamask/multichain-account-service@2.0.0 +[1.6.2]: https://github.com/MetaMask/core/compare/@metamask/multichain-account-service@1.6.1...@metamask/multichain-account-service@1.6.2 +[1.6.1]: https://github.com/MetaMask/core/compare/@metamask/multichain-account-service@1.6.0...@metamask/multichain-account-service@1.6.1 +[1.6.0]: https://github.com/MetaMask/core/compare/@metamask/multichain-account-service@1.5.0...@metamask/multichain-account-service@1.6.0 +[1.5.0]: https://github.com/MetaMask/core/compare/@metamask/multichain-account-service@1.4.0...@metamask/multichain-account-service@1.5.0 +[1.4.0]: https://github.com/MetaMask/core/compare/@metamask/multichain-account-service@1.3.0...@metamask/multichain-account-service@1.4.0 +[1.3.0]: https://github.com/MetaMask/core/compare/@metamask/multichain-account-service@1.2.0...@metamask/multichain-account-service@1.3.0 +[1.2.0]: https://github.com/MetaMask/core/compare/@metamask/multichain-account-service@1.1.0...@metamask/multichain-account-service@1.2.0 +[1.1.0]: https://github.com/MetaMask/core/compare/@metamask/multichain-account-service@1.0.0...@metamask/multichain-account-service@1.1.0 +[1.0.0]: https://github.com/MetaMask/core/compare/@metamask/multichain-account-service@0.11.0...@metamask/multichain-account-service@1.0.0 +[0.11.0]: https://github.com/MetaMask/core/compare/@metamask/multichain-account-service@0.10.0...@metamask/multichain-account-service@0.11.0 +[0.10.0]: https://github.com/MetaMask/core/compare/@metamask/multichain-account-service@0.9.0...@metamask/multichain-account-service@0.10.0 +[0.9.0]: https://github.com/MetaMask/core/compare/@metamask/multichain-account-service@0.8.0...@metamask/multichain-account-service@0.9.0 +[0.8.0]: https://github.com/MetaMask/core/compare/@metamask/multichain-account-service@0.7.0...@metamask/multichain-account-service@0.8.0 +[0.7.0]: https://github.com/MetaMask/core/compare/@metamask/multichain-account-service@0.6.0...@metamask/multichain-account-service@0.7.0 +[0.6.0]: https://github.com/MetaMask/core/compare/@metamask/multichain-account-service@0.5.0...@metamask/multichain-account-service@0.6.0 +[0.5.0]: https://github.com/MetaMask/core/compare/@metamask/multichain-account-service@0.4.0...@metamask/multichain-account-service@0.5.0 +[0.4.0]: https://github.com/MetaMask/core/compare/@metamask/multichain-account-service@0.3.0...@metamask/multichain-account-service@0.4.0 +[0.3.0]: https://github.com/MetaMask/core/compare/@metamask/multichain-account-service@0.2.1...@metamask/multichain-account-service@0.3.0 +[0.2.1]: https://github.com/MetaMask/core/compare/@metamask/multichain-account-service@0.2.0...@metamask/multichain-account-service@0.2.1 +[0.2.0]: https://github.com/MetaMask/core/compare/@metamask/multichain-account-service@0.1.0...@metamask/multichain-account-service@0.2.0 +[0.1.0]: https://github.com/MetaMask/core/releases/tag/@metamask/multichain-account-service@0.1.0 diff --git a/packages/multichain-account-service/LICENSE b/packages/multichain-account-service/LICENSE new file mode 100644 index 00000000000..c259cd7ebcf --- /dev/null +++ b/packages/multichain-account-service/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2025 MetaMask + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packages/multichain-account-service/README.md b/packages/multichain-account-service/README.md new file mode 100644 index 00000000000..ee795b4005d --- /dev/null +++ b/packages/multichain-account-service/README.md @@ -0,0 +1,17 @@ +# `@metamask/multichain-account-service` + +Multichain account service. + +This service provides operations and functionalities around multichain accounts and wallets. + +## Installation + +`yarn add @metamask/multichain-account-service` + +or + +`npm install @metamask/multichain-account-service` + +## Contributing + +This package is part of a monorepo. Instructions for contributing can be found in the [monorepo README](https://github.com/MetaMask/core#readme). diff --git a/packages/multichain-account-service/jest.config.js b/packages/multichain-account-service/jest.config.js new file mode 100644 index 00000000000..ffd06163763 --- /dev/null +++ b/packages/multichain-account-service/jest.config.js @@ -0,0 +1,29 @@ +/* + * 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, + + // Exclude test helpers from coverage collection + coveragePathIgnorePatterns: ['.*/src/tests/.*'], + + // An object that configures minimum threshold enforcement for coverage results + coverageThreshold: { + global: { + branches: 96.76, + functions: 100, + lines: 100, + statements: 100, + }, + }, +}); diff --git a/packages/multichain-account-service/package.json b/packages/multichain-account-service/package.json new file mode 100644 index 00000000000..9ff41906e67 --- /dev/null +++ b/packages/multichain-account-service/package.json @@ -0,0 +1,104 @@ +{ + "name": "@metamask/multichain-account-service", + "version": "13.0.2", + "description": "Service to manage multichain accounts", + "keywords": [ + "Ethereum", + "MetaMask" + ], + "homepage": "https://github.com/MetaMask/core/tree/main/packages/multichain-account-service#readme", + "bugs": { + "url": "https://github.com/MetaMask/core/issues" + }, + "license": "MIT", + "repository": { + "type": "git", + "url": "https://github.com/MetaMask/core.git" + }, + "files": [ + "dist/" + ], + "sideEffects": false, + "main": "./dist/index.cjs", + "types": "./dist/index.d.cts", + "exports": { + ".": { + "import": { + "types": "./dist/index.d.mts", + "default": "./dist/index.mjs" + }, + "require": { + "types": "./dist/index.d.cts", + "default": "./dist/index.cjs" + } + }, + "./package.json": "./package.json" + }, + "publishConfig": { + "access": "public", + "registry": "https://registry.npmjs.org/" + }, + "scripts": { + "build": "ts-bridge --project tsconfig.build.json --verbose --clean --no-references", + "build:all": "ts-bridge --project tsconfig.build.json --verbose --clean", + "build:docs": "typedoc", + "changelog:update": "../../scripts/update-changelog.sh @metamask/multichain-account-service", + "changelog:validate": "../../scripts/validate-changelog.sh @metamask/multichain-account-service", + "lint:tsconfigs": "tsx ../../scripts/lint-tsconfigs/lint-tsconfigs.mts", + "lint:tsconfigs:fix": "tsx ../../scripts/lint-tsconfigs/lint-tsconfigs.mts --fix", + "messenger-action-types:check": "tsx ../../packages/messenger-cli/src/cli.ts --formatter oxfmt --esm --check", + "messenger-action-types:generate": "tsx ../../packages/messenger-cli/src/cli.ts --formatter oxfmt --esm --generate", + "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": { + "@ethereumjs/util": "^9.1.0", + "@metamask/account-api": "^2.0.0", + "@metamask/accounts-controller": "^39.1.1", + "@metamask/base-controller": "^9.1.0", + "@metamask/eth-snap-keyring": "^24.0.0", + "@metamask/key-tree": "^10.1.1", + "@metamask/keyring-api": "^24.0.0", + "@metamask/keyring-controller": "^27.1.1", + "@metamask/keyring-internal-api": "^12.0.0", + "@metamask/keyring-snap-client": "^10.0.0", + "@metamask/keyring-utils": "^5.0.0", + "@metamask/messenger": "^2.0.0", + "@metamask/snap-account-service": "^2.1.2", + "@metamask/snaps-controllers": "^19.0.0", + "@metamask/snaps-sdk": "^11.0.0", + "@metamask/snaps-utils": "^12.1.2", + "@metamask/superstruct": "^3.4.1", + "@metamask/utils": "^11.11.0", + "async-mutex": "^0.5.0", + "lodash": "^4.17.21" + }, + "devDependencies": { + "@metamask/auto-changelog": "^6.1.0", + "@metamask/controller-utils": "^12.3.0", + "@metamask/eth-hd-keyring": "^15.0.0", + "@metamask/providers": "^22.1.0", + "@ts-bridge/cli": "^0.6.4", + "@types/jest": "^30.0.0", + "@types/uuid": "^8.3.0", + "deepmerge": "^4.2.2", + "jest": "^30.4.2", + "ts-jest": "^29.4.11", + "tsx": "^4.20.5", + "typedoc": "^0.25.13", + "typedoc-plugin-missing-exports": "^2.0.0", + "typescript": "~5.3.3", + "uuid": "^8.3.2", + "webextension-polyfill": "^0.12.0" + }, + "peerDependencies": { + "@metamask/providers": "^22.0.0", + "webextension-polyfill": "^0.10.0 || ^0.11.0 || ^0.12.0" + }, + "engines": { + "node": "^18.18 || >=20" + } +} diff --git a/packages/multichain-account-service/src/MultichainAccountGroup.test.ts b/packages/multichain-account-service/src/MultichainAccountGroup.test.ts new file mode 100644 index 00000000000..2baca3d5e7e --- /dev/null +++ b/packages/multichain-account-service/src/MultichainAccountGroup.test.ts @@ -0,0 +1,222 @@ +import type { Bip44Account } from '@metamask/account-api'; +import { + AccountGroupType, + isBip44Account, + toMultichainAccountGroupId, + toMultichainAccountWalletId, +} from '@metamask/account-api'; +import { EthScope, SolScope } from '@metamask/keyring-api'; +import type { InternalAccount } from '@metamask/keyring-internal-api'; + +import type { GroupState } from './MultichainAccountGroup.js'; +import { MultichainAccountGroup } from './MultichainAccountGroup.js'; +import { MultichainAccountWallet } from './MultichainAccountWallet.js'; +import type { RootMessenger, MockAccountProvider } from './tests/index.js'; +import { + MOCK_SNAP_ACCOUNT_2, + MOCK_WALLET_1_BTC_P2TR_ACCOUNT, + MOCK_WALLET_1_BTC_P2WPKH_ACCOUNT, + MOCK_WALLET_1_ENTROPY_SOURCE, + MOCK_WALLET_1_EVM_ACCOUNT, + MOCK_WALLET_1_SOL_ACCOUNT, + setupBip44AccountProvider, + getMultichainAccountServiceMessenger, + getRootMessenger, +} from './tests/index.js'; +import type { MultichainAccountServiceMessenger } from './types.js'; + +function setup({ + groupIndex = 0, + messenger = getRootMessenger(), + accounts = [ + [MOCK_WALLET_1_EVM_ACCOUNT], + [ + MOCK_WALLET_1_SOL_ACCOUNT, + MOCK_WALLET_1_BTC_P2WPKH_ACCOUNT, + MOCK_WALLET_1_BTC_P2TR_ACCOUNT, + MOCK_SNAP_ACCOUNT_2, // Non-BIP-44 account. + ], + ], +}: { + groupIndex?: number; + messenger?: RootMessenger; + accounts?: InternalAccount[][]; +} = {}): { + wallet: MultichainAccountWallet>; + group: MultichainAccountGroup>; + providers: MockAccountProvider[]; + messenger: MultichainAccountServiceMessenger; +} { + const providers = accounts.map((providerAccounts, idx) => { + return setupBip44AccountProvider({ + name: `Provider ${idx + 1}`, + accounts: providerAccounts, + }); + }); + + const serviceMessenger = getMultichainAccountServiceMessenger(messenger); + + const wallet = new MultichainAccountWallet>({ + entropySource: MOCK_WALLET_1_ENTROPY_SOURCE, + messenger: serviceMessenger, + providers, + }); + + const group = new MultichainAccountGroup({ + wallet, + groupIndex, + providers, + messenger: serviceMessenger, + }); + + // Initialize group state from provided accounts so that constructor tests + // observe accounts immediately + const groupState = providers.reduce((state, provider, idx) => { + const ids = accounts[idx].filter(isBip44Account).map((a) => a.id); + if (ids.length > 0) { + state[provider.getName()] = ids; + } + return state; + }, {}); + + group.init(groupState); + + return { wallet, group, providers, messenger: serviceMessenger }; +} + +describe('MultichainAccountGroup', () => { + describe('constructor', () => { + it('constructs a multichain account group', async () => { + const accounts = [ + [MOCK_WALLET_1_EVM_ACCOUNT], + [MOCK_WALLET_1_SOL_ACCOUNT], + ]; + const groupIndex = 0; + const { wallet, group } = setup({ groupIndex, accounts }); + + const expectedWalletId = toMultichainAccountWalletId( + wallet.entropySource, + ); + const expectedAccounts = accounts.flat(); + + expect(group.id).toStrictEqual( + toMultichainAccountGroupId(expectedWalletId, groupIndex), + ); + expect(group.type).toBe(AccountGroupType.MultichainAccount); + expect(group.groupIndex).toBe(groupIndex); + expect(group.wallet).toStrictEqual(wallet); + expect(group.hasAccounts()).toBe(true); + expect(group.getAccountIds()).toStrictEqual( + expectedAccounts.map((a) => a.id), + ); + expect(group.getAccounts()).toHaveLength(expectedAccounts.length); + expect(group.getAccounts()).toStrictEqual(expectedAccounts); + }); + + it('constructs a multichain account group for a specific index', async () => { + const groupIndex = 2; + const { group } = setup({ groupIndex }); + + expect(group.groupIndex).toBe(groupIndex); + }); + }); + + describe('getAccount', () => { + it('gets internal account from its id', async () => { + const evmAccount = MOCK_WALLET_1_EVM_ACCOUNT; + const solAccount = MOCK_WALLET_1_SOL_ACCOUNT; + const { group } = setup({ accounts: [[evmAccount], [solAccount]] }); + + expect(group.getAccount(evmAccount.id)).toBe(evmAccount); + expect(group.getAccount(solAccount.id)).toBe(solAccount); + }); + + it('returns undefined if the account ID does not belong to the multichain account group', async () => { + const { group } = setup(); + + expect(group.getAccount('unknown-id')).toBeUndefined(); + }); + }); + + describe('get', () => { + it('gets one account using a selector', () => { + const { group } = setup({ accounts: [[MOCK_WALLET_1_EVM_ACCOUNT]] }); + + expect(group.get({ scopes: [EthScope.Mainnet] })).toBe( + MOCK_WALLET_1_EVM_ACCOUNT, + ); + }); + + it('gets no account if selector did not match', () => { + const { group } = setup({ accounts: [[MOCK_WALLET_1_EVM_ACCOUNT]] }); + + expect(group.get({ scopes: [SolScope.Mainnet] })).toBeUndefined(); + }); + + it('throws if too many accounts are matching selector', () => { + const { group } = setup({ + accounts: [[MOCK_WALLET_1_EVM_ACCOUNT, MOCK_WALLET_1_EVM_ACCOUNT]], + }); + + expect(() => group.get({ scopes: [EthScope.Mainnet] })).toThrow( + 'Too many account candidates, expected 1, got: 2', + ); + }); + }); + + describe('select', () => { + it('selects accounts using a selector', () => { + const { group } = setup(); + + expect(group.select({ scopes: [EthScope.Mainnet] })).toStrictEqual([ + MOCK_WALLET_1_EVM_ACCOUNT, + ]); + }); + + it('selects no account if selector did not match', () => { + const { group } = setup({ accounts: [[MOCK_WALLET_1_EVM_ACCOUNT]] }); + + expect(group.select({ scopes: [SolScope.Mainnet] })).toStrictEqual([]); + }); + }); + + describe('isAligned', () => { + it('returns true when every provider has at least one account in the group', () => { + const { group } = setup({ + accounts: [[MOCK_WALLET_1_EVM_ACCOUNT], [MOCK_WALLET_1_SOL_ACCOUNT]], + }); + + expect(group.isAligned()).toBe(true); + }); + + it('returns false when at least one provider has no accounts in the group', () => { + const { group } = setup({ + accounts: [ + [MOCK_WALLET_1_EVM_ACCOUNT], + [], // second provider has no accounts for this group + ], + }); + + expect(group.isAligned()).toBe(false); + }); + + it('returns true for a group with no providers', () => { + const { group } = setup({ accounts: [] }); + + expect(group.isAligned()).toBe(true); + }); + + it('returns true when a provider mock is configured to return true despite having no accounts (simulates disabled wrapper)', () => { + const { group, providers } = setup({ + accounts: [ + [MOCK_WALLET_1_EVM_ACCOUNT], + [], // second provider has no accounts + ], + }); + // Simulate a disabled AccountProviderWrapper, which always returns true. + providers[1].isAligned.mockReturnValue(true); + + expect(group.isAligned()).toBe(true); + }); + }); +}); diff --git a/packages/multichain-account-service/src/MultichainAccountGroup.ts b/packages/multichain-account-service/src/MultichainAccountGroup.ts new file mode 100644 index 00000000000..6d11bce3bb5 --- /dev/null +++ b/packages/multichain-account-service/src/MultichainAccountGroup.ts @@ -0,0 +1,289 @@ +import { AccountGroupType, select, selectOne } from '@metamask/account-api'; +import { toMultichainAccountGroupId } from '@metamask/account-api'; +import type { + MultichainAccountGroupId, + MultichainAccountGroup as MultichainAccountGroupDefinition, +} from '@metamask/account-api'; +import type { Bip44Account } from '@metamask/account-api'; +import type { AccountSelector } from '@metamask/account-api'; +import type { KeyringAccount } from '@metamask/keyring-api'; + +import type { Logger } from './logger.js'; +import { projectLogger as log, createModuleLogger } from './logger.js'; +import type { ServiceState, StateKeys } from './MultichainAccountService.js'; +import type { MultichainAccountWallet } from './MultichainAccountWallet.js'; +import type { Bip44AccountProvider } from './providers/index.js'; +import type { MultichainAccountServiceMessenger } from './types.js'; + +export type GroupState = + ServiceState[StateKeys['entropySource']][StateKeys['groupIndex']]; + +/** + * A multichain account group that holds multiple accounts. + */ +export class MultichainAccountGroup< + Account extends Bip44Account, +> implements MultichainAccountGroupDefinition { + readonly #id: MultichainAccountGroupId; + + readonly #wallet: MultichainAccountWallet; + + readonly #groupIndex: number; + + readonly #providers: Bip44AccountProvider[]; + + readonly #providerToAccounts: Map< + Bip44AccountProvider, + Account['id'][] + >; + + readonly #accountToProvider: Map< + Account['id'], + Bip44AccountProvider + >; + + readonly #messenger: MultichainAccountServiceMessenger; + + readonly #log: Logger; + + #initialized = false; + + constructor({ + groupIndex, + wallet, + providers, + messenger, + }: { + groupIndex: number; + wallet: MultichainAccountWallet; + providers: Bip44AccountProvider[]; + messenger: MultichainAccountServiceMessenger; + }) { + this.#id = toMultichainAccountGroupId(wallet.id, groupIndex); + this.#groupIndex = groupIndex; + this.#wallet = wallet; + this.#providers = providers; + this.#messenger = messenger; + this.#providerToAccounts = new Map(); + this.#accountToProvider = new Map(); + + this.#log = createModuleLogger(log, `[${this.#id}]`); + } + + /** + * Clear the account to provider state for a given provider. + * + * @param provider - The provider to clear the account to provider state for. + */ + #clearAccountToProviderState(provider: Bip44AccountProvider): void { + this.#accountToProvider.forEach((accountProvider, id) => { + if (accountProvider === provider) { + this.#accountToProvider.delete(id); + } + }); + } + + /** + * Update the internal representation of accounts with the given group state. + * + * @param groupState - The group state. + */ + #setState(groupState: GroupState): void { + for (const provider of this.#providers) { + const accountIds = groupState[provider.getName()]; + + if (accountIds) { + this.#clearAccountToProviderState(provider); + this.#providerToAccounts.set(provider, accountIds); + + for (const accountId of accountIds) { + this.#accountToProvider.set(accountId, provider); + } + } + } + } + + /** + * Initialize the multichain account group and construct the internal representation of accounts. + * + * @param groupState - The group state. + */ + init(groupState: GroupState): void { + this.#log('Initializing group state...'); + this.#setState(groupState); + this.#log('Finished initializing group state...'); + + this.#initialized = true; + } + + /** + * Update the group state. + * + * @param groupState - The group state. + */ + update(groupState: GroupState): void { + this.#log('Updating group state...'); + this.#setState(groupState); + this.#log('Finished updating group state...'); + + if (this.#initialized) { + this.#messenger.publish( + 'MultichainAccountService:multichainAccountGroupUpdated', + this, + ); + } + } + + /** + * Gets the multichain account group ID. + * + * @returns The multichain account group ID. + */ + get id(): MultichainAccountGroupId { + return this.#id; + } + + /** + * Gets the multichain account group type. + * + * @returns The multichain account type. + */ + get type(): AccountGroupType.MultichainAccount { + return AccountGroupType.MultichainAccount; + } + + /** + * Gets the multichain account's wallet reference (parent). + * + * @returns The multichain account's wallet. + */ + get wallet(): MultichainAccountWallet { + return this.#wallet; + } + + /** + * Gets the multichain account group index. + * + * @returns The multichain account group index. + */ + get groupIndex(): number { + return this.#groupIndex; + } + + /** + * Checks if there's any underlying accounts for this multichain accounts. + * + * @returns True if there's any underlying accounts, false otherwise. + */ + hasAccounts(): boolean { + // If there's anything in the reverse-map, it means we have some accounts. + return this.#accountToProvider.size > 0; + } + + /** + * Gets the accounts for this multichain account. + * + * @returns The accounts. + */ + getAccounts(): Account[] { + const allAccounts: Account[] = []; + + for (const [provider, accounts] of this.#providerToAccounts.entries()) { + for (const id of accounts) { + const account = provider.getAccount(id); + + if (account) { + // If for some reason we cannot get this account from the provider, it + // might means it has been deleted or something, so we just filter it + // out. + allAccounts.push(account); + } + } + } + + return allAccounts; + } + + /** + * Gets the account IDs for this multichain account. + * + * @returns The account IDs. + */ + getAccountIds(): Account['id'][] { + return [...this.#accountToProvider.keys()]; + } + + /** + * Gets the account for a given account ID. + * + * @param id - Account ID. + * @returns The account or undefined if not found. + */ + getAccount(id: Account['id']): Account | undefined { + const provider = this.#accountToProvider.get(id); + + // If there's nothing in the map, it means we tried to get an account + // that does not belong to this multichain account. + if (!provider) { + return undefined; + } + + return provider.getAccount(id); + } + + /** + * Query an account matching the selector. + * + * @param selector - Query selector. + * @returns The account matching the selector or undefined if not matching. + * @throws If multiple accounts match the selector. + */ + get(selector: AccountSelector): Account | undefined { + return selectOne(this.getAccounts(), selector); + } + + /** + * Query accounts matching the selector. + * + * @param selector - Query selector. + * @returns The accounts matching the selector. + */ + select(selector: AccountSelector): Account[] { + return select(this.getAccounts(), selector); + } + + /** + * Check whether every provider has an aligned account in this group. + * + * A group is aligned when every registered provider reports that the + * account IDs it contributed to this group are non-empty and owned by it. + * Disabled {@link AccountProviderWrapper} instances always report `true`. + * + * @returns `true` when all providers are aligned for this group. + */ + isAligned(): boolean { + return this.#providers.every((provider) => + this.isProviderAligned(provider), + ); + } + + /** + * Check whether a single provider has an aligned account in this group. + * + * A provider is aligned when the account IDs it contributed to this group are + * non-empty and owned by it. Disabled {@link AccountProviderWrapper} instances + * always report `true`. + * + * @param provider - The provider to check. + * @returns `true` when the provider is aligned for this group. + */ + isProviderAligned(provider: Bip44AccountProvider): boolean { + return provider.isAligned( + { + entropySource: this.#wallet.entropySource, + groupIndex: this.#groupIndex, + }, + this.#providerToAccounts.get(provider) ?? [], + ); + } +} diff --git a/packages/multichain-account-service/src/MultichainAccountService-method-action-types.ts b/packages/multichain-account-service/src/MultichainAccountService-method-action-types.ts new file mode 100644 index 00000000000..24a2ee83aaa --- /dev/null +++ b/packages/multichain-account-service/src/MultichainAccountService-method-action-types.ts @@ -0,0 +1,214 @@ +/** + * This file is auto generated. + * Do not edit manually. + */ + +import type { MultichainAccountService } from './MultichainAccountService.js'; + +/** + * Initialize the service and constructs the internal reprensentation of + * multichain accounts and wallets. + */ +export type MultichainAccountServiceInitAction = { + type: `MultichainAccountService:init`; + handler: MultichainAccountService['init']; +}; + +/** + * Re-synchronize MetaMask accounts and the providers accounts if needed. + * + * NOTE: This is mostly required if one of the providers (keyrings or Snaps) + * have different sets of accounts. This method would ensure that both are + * in-sync and use the same accounts (and same IDs). + * + * READ THIS CAREFULLY (State inconsistency bugs/de-sync) + * We've seen some problems were keyring accounts on some Snaps were not synchronized + * with the accounts on MM side. This causes problems where we cannot interact with + * those accounts because the Snap does know about them. + * To "workaround" this de-sync problem for now, we make sure that both parties are + * in-sync when the service boots up. + * ---------------------------------------------------------------------------------- + */ +export type MultichainAccountServiceResyncAccountsAction = { + type: `MultichainAccountService:resyncAccounts`; + handler: MultichainAccountService['resyncAccounts']; +}; + +/** + * Gets a reference to the multichain account wallet matching this entropy source. + * + * @param options - Options. + * @param options.entropySource - The entropy source of the multichain account. + * @throws If none multichain account match this entropy. + * @returns A reference to the multichain account wallet. + */ +export type MultichainAccountServiceGetMultichainAccountWalletAction = { + type: `MultichainAccountService:getMultichainAccountWallet`; + handler: MultichainAccountService['getMultichainAccountWallet']; +}; + +/** + * Gets an array of all multichain account wallets. + * + * @returns An array of all multichain account wallets. + */ +export type MultichainAccountServiceGetMultichainAccountWalletsAction = { + type: `MultichainAccountService:getMultichainAccountWallets`; + handler: MultichainAccountService['getMultichainAccountWallets']; +}; + +/** + * Creates a new multichain account wallet by either importing an existing mnemonic, + * creating a new vault and keychain, or restoring a vault and keyring. + * + * NOTE: This method should only be called in client code where a mutex lock is acquired. + * `discoverAccounts` should be called after this method to discover and create accounts. + * + * @param params - The parameters to use to create the new wallet. + * @param params.mnemonic - The mnemonic to use to create the new wallet. + * @param params.password - The password to encrypt the vault with. + * @param params.type - The flow type to use to create the new wallet. + * @throws If the mnemonic has already been imported. + * @returns The new multichain account wallet. + */ +export type MultichainAccountServiceCreateMultichainAccountWalletAction = { + type: `MultichainAccountService:createMultichainAccountWallet`; + handler: MultichainAccountService['createMultichainAccountWallet']; +}; + +/** + * Removes a multichain account wallet, deleting all of its accounts across + * every registered provider (EVM and snap-based). + * + * The deletion iterates providers (the source of truth for their own + * account lists) and filters each provider's accounts to those matching + * the wallet's entropy source. Cleanup is best-effort end-to-end: neither + * a single account deletion failure nor a failure to enumerate a given + * provider's accounts aborts cleanup of the remaining providers. If one or + * more operations fail, a single aggregated error is reported via + * `reportError` with all per-failure details in its context. The wallet is + * always removed from the service's internal map at the end. + * + * @param entropySource - The entropy source of the multichain account wallet. + */ +export type MultichainAccountServiceRemoveMultichainAccountWalletAction = { + type: `MultichainAccountService:removeMultichainAccountWallet`; + handler: MultichainAccountService['removeMultichainAccountWallet']; +}; + +/** + * Gets a reference to the multichain account group matching this entropy source + * and a group index. + * + * @param options - Options. + * @param options.entropySource - The entropy source of the multichain account. + * @param options.groupIndex - The group index of the multichain account. + * @throws If none multichain account match this entropy source and group index. + * @returns A reference to the multichain account. + */ +export type MultichainAccountServiceGetMultichainAccountGroupAction = { + type: `MultichainAccountService:getMultichainAccountGroup`; + handler: MultichainAccountService['getMultichainAccountGroup']; +}; + +/** + * Gets all multichain account groups for a given entropy source. + * + * @param options - Options. + * @param options.entropySource - The entropy source to query. + * @throws If no multichain accounts match this entropy source. + * @returns A list of all multichain accounts. + */ +export type MultichainAccountServiceGetMultichainAccountGroupsAction = { + type: `MultichainAccountService:getMultichainAccountGroups`; + handler: MultichainAccountService['getMultichainAccountGroups']; +}; + +/** + * Creates the next multichain account group. + * + * @param options - Options. + * @param options.entropySource - The wallet's entropy source. + * @returns The next multichain account group. + */ +export type MultichainAccountServiceCreateNextMultichainAccountGroupAction = { + type: `MultichainAccountService:createNextMultichainAccountGroup`; + handler: MultichainAccountService['createNextMultichainAccountGroup']; +}; + +/** + * Creates a multichain account group. + * + * @param options - Options. + * @param options.groupIndex - The group index to use. + * @param options.entropySource - The wallet's entropy source. + * @returns The multichain account group for this group index. + */ +export type MultichainAccountServiceCreateMultichainAccountGroupAction = { + type: `MultichainAccountService:createMultichainAccountGroup`; + handler: MultichainAccountService['createMultichainAccountGroup']; +}; + +/** + * Creates multiple multichain account groups up to maxGroupIndex. + * + * @param params - Parameters for creating account groups. + * @param params.fromGroupIndex - Starting group index to create (inclusive) (defaults to 0). + * @param params.toGroupIndex - Maximum group index to create (inclusive). + * @param params.entropySource - The entropy source ID. + * @returns Array of created multichain account groups. + */ +export type MultichainAccountServiceCreateMultichainAccountGroupsAction = { + type: `MultichainAccountService:createMultichainAccountGroups`; + handler: MultichainAccountService['createMultichainAccountGroups']; +}; + +/** + * Set basic functionality state and trigger alignment if enabled. + * When basic functionality is disabled, snap-based providers are disabled. + * When enabled, all snap providers are enabled and wallet alignment is triggered. + * EVM providers are never disabled as they're required for basic wallet functionality. + * + * @param enabled - Whether basic functionality is enabled. + */ +export type MultichainAccountServiceSetBasicFunctionalityAction = { + type: `MultichainAccountService:setBasicFunctionality`; + handler: MultichainAccountService['setBasicFunctionality']; +}; + +/** + * Align all multichain account wallets. + */ +export type MultichainAccountServiceAlignWalletsAction = { + type: `MultichainAccountService:alignWallets`; + handler: MultichainAccountService['alignWallets']; +}; + +/** + * Align a specific multichain account wallet. + * + * @param entropySource - The entropy source of the multichain account wallet. + */ +export type MultichainAccountServiceAlignWalletAction = { + type: `MultichainAccountService:alignWallet`; + handler: MultichainAccountService['alignWallet']; +}; + +/** + * Union of all MultichainAccountService action types. + */ +export type MultichainAccountServiceMethodActions = + | MultichainAccountServiceInitAction + | MultichainAccountServiceResyncAccountsAction + | MultichainAccountServiceGetMultichainAccountWalletAction + | MultichainAccountServiceGetMultichainAccountWalletsAction + | MultichainAccountServiceCreateMultichainAccountWalletAction + | MultichainAccountServiceRemoveMultichainAccountWalletAction + | MultichainAccountServiceGetMultichainAccountGroupAction + | MultichainAccountServiceGetMultichainAccountGroupsAction + | MultichainAccountServiceCreateNextMultichainAccountGroupAction + | MultichainAccountServiceCreateMultichainAccountGroupAction + | MultichainAccountServiceCreateMultichainAccountGroupsAction + | MultichainAccountServiceSetBasicFunctionalityAction + | MultichainAccountServiceAlignWalletsAction + | MultichainAccountServiceAlignWalletAction; diff --git a/packages/multichain-account-service/src/MultichainAccountService.test.ts b/packages/multichain-account-service/src/MultichainAccountService.test.ts new file mode 100644 index 00000000000..527f7114d37 --- /dev/null +++ b/packages/multichain-account-service/src/MultichainAccountService.test.ts @@ -0,0 +1,1848 @@ +import { Bip44Account, isBip44Account } from '@metamask/account-api'; +import { mnemonicPhraseToBytes } from '@metamask/key-tree'; +import type { + CreateAccountOptions, + KeyringAccount, +} from '@metamask/keyring-api'; +import { + AccountCreationType, + BtcAccountType, + EthAccountType, + SolAccountType, + TrxAccountType, +} from '@metamask/keyring-api'; +import type { Keyring } from '@metamask/keyring-api/v2'; +import { KeyringType } from '@metamask/keyring-api/v2'; +import type { KeyringObject } from '@metamask/keyring-controller'; + +import { traceFallback } from './analytics/index.js'; +import { isPerfEnabled, withLocalPerfTrace } from './analytics/perf.js'; +import type { + MultichainAccountServiceOptions, + RemoveMultichainAccountWalletFailureContext, +} from './MultichainAccountService.js'; +import { MultichainAccountService } from './MultichainAccountService.js'; +import { AccountProviderWrapper } from './providers/AccountProviderWrapper.js'; +import { + BTC_ACCOUNT_PROVIDER_NAME, + BtcAccountProvider, +} from './providers/BtcAccountProvider.js'; +import { + EVM_ACCOUNT_PROVIDER_NAME, + EvmAccountProvider, +} from './providers/EvmAccountProvider.js'; +import type { Bip44AccountProvider } from './providers/index.js'; +import { TimeoutError } from './providers/index.js'; +import { + SOL_ACCOUNT_PROVIDER_NAME, + SolAccountProvider, +} from './providers/SolAccountProvider.js'; +import { + TRX_ACCOUNT_PROVIDER_NAME, + TrxAccountProvider, +} from './providers/TrxAccountProvider.js'; +import type { RootMessenger, MockAccountProvider } from './tests/index.js'; +import { + MOCK_HARDWARE_ACCOUNT_1, + MOCK_HD_ACCOUNT_1, + MOCK_HD_ACCOUNT_2, + MOCK_MNEMONIC, + MOCK_SNAP_ACCOUNT_1, + MOCK_SNAP_ACCOUNT_2, + MOCK_SOL_ACCOUNT_1, + MockAccountBuilder, +} from './tests/index.js'; +import { + MOCK_HD_KEYRING_1, + MOCK_HD_KEYRING_2, + getMultichainAccountServiceMessenger, + getRootMessenger, + makeMockAccountProvider, + setupBip44AccountProvider, +} from './tests/index.js'; +import type { MultichainAccountServiceMessenger } from './types.js'; +import type { SentryError } from './utils.js'; + +// Mock perf helpers so tests can control isPerfEnabled() without setting DEBUG env var. +jest.mock('./analytics/perf', () => ({ + isPerfEnabled: jest.fn().mockReturnValue(false), + withLocalPerfTrace: jest.fn((trace) => trace), +})); + +// Mock providers. +jest.mock('./providers/EvmAccountProvider', () => { + return { + ...jest.requireActual('./providers/EvmAccountProvider'), + EvmAccountProvider: jest.fn(), + }; +}); +jest.mock('./providers/SolAccountProvider', () => { + return { + ...jest.requireActual('./providers/SolAccountProvider'), + SolAccountProvider: jest.fn(), + }; +}); +jest.mock('./providers/BtcAccountProvider', () => { + return { + ...jest.requireActual('./providers/BtcAccountProvider'), + BtcAccountProvider: jest.fn(), + }; +}); +jest.mock('./providers/TrxAccountProvider', () => { + return { + ...jest.requireActual('./providers/TrxAccountProvider'), + TrxAccountProvider: jest.fn(), + }; +}); + +type Mocks = { + // eslint-disable-next-line @typescript-eslint/naming-convention + KeyringController: { + keyrings: KeyringObject[]; + getState: jest.Mock; + getKeyringsByType: jest.Mock; + addNewKeyring: jest.Mock; + createNewVaultAndKeychain: jest.Mock; + createNewVaultAndRestore: jest.Mock; + withKeyring: jest.Mock; + removeAccount: jest.Mock; + }; + // eslint-disable-next-line @typescript-eslint/naming-convention + AccountsController: { + listMultichainAccounts: jest.Mock; + }; + // eslint-disable-next-line @typescript-eslint/naming-convention + ErrorReportingService: { + captureException: jest.Mock; + }; + // eslint-disable-next-line @typescript-eslint/naming-convention + EvmAccountProvider: MockAccountProvider; + // eslint-disable-next-line @typescript-eslint/naming-convention + SolAccountProvider: MockAccountProvider; + // eslint-disable-next-line @typescript-eslint/naming-convention + BtcAccountProvider: MockAccountProvider; + // eslint-disable-next-line @typescript-eslint/naming-convention + TrxAccountProvider: MockAccountProvider; +}; + +function mockAccountProvider( + providerClass: new (messenger: MultichainAccountServiceMessenger) => Provider, + mocks: MockAccountProvider, + accounts: KeyringAccount[], + idx: number, + _type: KeyringAccount['type'], +): void { + jest.mocked(providerClass).mockImplementation((...args) => { + mocks.constructor(...args); + return mocks as unknown as Provider; + }); + + setupBip44AccountProvider({ + mocks, + accounts, + index: idx, + }); + + // Provide stable provider name and compatibility logic for grouping + if (providerClass === (EvmAccountProvider as unknown)) { + mocks.getName.mockReturnValue(EVM_ACCOUNT_PROVIDER_NAME); + mocks.isAccountCompatible?.mockImplementation( + (account: KeyringAccount) => account.type === EthAccountType.Eoa, + ); + } else if (providerClass === (SolAccountProvider as unknown)) { + mocks.getName.mockReturnValue(SOL_ACCOUNT_PROVIDER_NAME); + mocks.isAccountCompatible?.mockImplementation( + (account: KeyringAccount) => account.type === SolAccountType.DataAccount, + ); + } else if (providerClass === (BtcAccountProvider as unknown)) { + mocks.getName.mockReturnValue(BTC_ACCOUNT_PROVIDER_NAME); + mocks.isAccountCompatible?.mockImplementation( + (account: KeyringAccount) => account.type === BtcAccountType.P2wpkh, + ); + } else if (providerClass === (TrxAccountProvider as unknown)) { + mocks.getName.mockReturnValue(TRX_ACCOUNT_PROVIDER_NAME); + mocks.isAccountCompatible?.mockImplementation( + (account: KeyringAccount) => account.type === TrxAccountType.Eoa, + ); + } + + // Mirror production behavior: a provider's tracked account IDs are only + // those it can manage (per `isAccountCompatible`). `setupBip44AccountProvider` + // initially populates every provider with every account, so we re-filter + // here once the compatibility predicate has been wired up. + const compatiblePredicate = mocks.isAccountCompatible.getMockImplementation(); + if (compatiblePredicate) { + mocks.accounts = new Set( + accounts + .filter((account) => + compatiblePredicate(account as Bip44Account), + ) + .map((account) => account.id), + ); + } +} + +async function setup({ + rootMessenger = getRootMessenger(), + keyrings = [MOCK_HD_KEYRING_1, MOCK_HD_KEYRING_2], + accounts, + providerConfigs, + config, +}: { + rootMessenger?: RootMessenger; + keyrings?: KeyringObject[]; + accounts?: KeyringAccount[]; + providerConfigs?: MultichainAccountServiceOptions['providerConfigs']; + config?: MultichainAccountServiceOptions['config']; +} = {}): Promise<{ + service: MultichainAccountService; + rootMessenger: RootMessenger; + messenger: MultichainAccountServiceMessenger; + mocks: Mocks; +}> { + const mocks: Mocks = { + KeyringController: { + keyrings, + getState: jest.fn(), + getKeyringsByType: jest.fn(), + addNewKeyring: jest.fn(), + createNewVaultAndKeychain: jest.fn(), + createNewVaultAndRestore: jest.fn(), + withKeyring: jest.fn(), + removeAccount: jest.fn(), + }, + AccountsController: { + listMultichainAccounts: jest.fn(), + }, + ErrorReportingService: { + captureException: jest.fn(), + }, + EvmAccountProvider: makeMockAccountProvider(), + SolAccountProvider: makeMockAccountProvider(), + BtcAccountProvider: makeMockAccountProvider(), + TrxAccountProvider: makeMockAccountProvider(), + }; + + // Required for the `assert` on `MultichainAccountWallet.createMultichainAccountGroup`. + Object.setPrototypeOf(mocks.EvmAccountProvider, EvmAccountProvider.prototype); + + mocks.KeyringController.getState.mockImplementation(() => ({ + isUnlocked: true, + keyrings: mocks.KeyringController.keyrings, + })); + + rootMessenger.registerActionHandler( + 'KeyringController:getState', + mocks.KeyringController.getState, + ); + + rootMessenger.registerActionHandler( + 'KeyringController:getKeyringsByType', + mocks.KeyringController.getKeyringsByType, + ); + + rootMessenger.registerActionHandler( + 'KeyringController:addNewKeyring', + mocks.KeyringController.addNewKeyring, + ); + + rootMessenger.registerActionHandler( + 'KeyringController:createNewVaultAndKeychain', + mocks.KeyringController.createNewVaultAndKeychain, + ); + + rootMessenger.registerActionHandler( + 'KeyringController:createNewVaultAndRestore', + mocks.KeyringController.createNewVaultAndRestore, + ); + + rootMessenger.registerActionHandler( + 'KeyringController:removeAccount', + mocks.KeyringController.removeAccount, + ); + + if (accounts) { + mocks.AccountsController.listMultichainAccounts.mockImplementation( + () => accounts, + ); + + rootMessenger.registerActionHandler( + 'AccountsController:listMultichainAccounts', + mocks.AccountsController.listMultichainAccounts, + ); + + // Because we mock the entire class, this static field gets set to undefined, so we + // force it here. + EvmAccountProvider.NAME = EVM_ACCOUNT_PROVIDER_NAME; + SolAccountProvider.NAME = SOL_ACCOUNT_PROVIDER_NAME; + BtcAccountProvider.NAME = BTC_ACCOUNT_PROVIDER_NAME; + TrxAccountProvider.NAME = TRX_ACCOUNT_PROVIDER_NAME; + + mockAccountProvider( + EvmAccountProvider, + mocks.EvmAccountProvider, + accounts, + 0, + EthAccountType.Eoa, + ); + mockAccountProvider( + SolAccountProvider, + mocks.SolAccountProvider, + accounts, + 1, + SolAccountType.DataAccount, + ); + mockAccountProvider( + BtcAccountProvider, + mocks.BtcAccountProvider, + accounts, + 2, + BtcAccountType.P2wpkh, + ); + mockAccountProvider( + TrxAccountProvider, + mocks.TrxAccountProvider, + accounts, + 3, + TrxAccountType.Eoa, + ); + } + + const messenger = getMultichainAccountServiceMessenger(rootMessenger); + + const service = new MultichainAccountService({ + messenger, + providerConfigs, + config, + }); + + await service.init(); + + return { + service, + rootMessenger, + messenger, + mocks, + }; +} + +describe('MultichainAccountService', () => { + describe('constructor', () => { + it('forwards configs to each provider', async () => { + const providerConfigs: MultichainAccountServiceOptions['providerConfigs'] = + { + // NOTE: We use constants here, since `*AccountProvider` are mocked, thus, their `.NAME` will + // be `undefined`. + [EVM_ACCOUNT_PROVIDER_NAME]: { + discovery: { + timeoutMs: 1000, + maxAttempts: 2, + backOffMs: 1000, + }, + }, + [SOL_ACCOUNT_PROVIDER_NAME]: { + maxConcurrency: 3, + discovery: { + timeoutMs: 5000, + maxAttempts: 4, + backOffMs: 2000, + }, + createAccounts: { + timeoutMs: 3000, + }, + }, + }; + + const { mocks, messenger } = await setup({ + accounts: [MOCK_HD_ACCOUNT_1, MOCK_SOL_ACCOUNT_1], + providerConfigs, + }); + + expect(mocks.EvmAccountProvider.constructor).toHaveBeenCalledWith( + messenger, + providerConfigs?.[EVM_ACCOUNT_PROVIDER_NAME], + expect.any(Function), // TraceCallback + ); + expect(mocks.SolAccountProvider.constructor).toHaveBeenCalledWith( + messenger, + providerConfigs?.[SOL_ACCOUNT_PROVIDER_NAME], + expect.any(Function), // TraceCallback + ); + }); + + it('passes traceFallback to providers when no config.trace is provided and perf is disabled', async () => { + jest.mocked(isPerfEnabled).mockReturnValue(false); + + const { mocks, messenger } = await setup({ + accounts: [MOCK_HD_ACCOUNT_1, MOCK_SOL_ACCOUNT_1], + }); + + expect(withLocalPerfTrace).not.toHaveBeenCalled(); + expect(mocks.EvmAccountProvider.constructor).toHaveBeenCalledWith( + messenger, + undefined, + traceFallback, + ); + expect(mocks.SolAccountProvider.constructor).toHaveBeenCalledWith( + messenger, + undefined, + traceFallback, + ); + }); + + it('passes config.trace to providers when provided and perf is disabled', async () => { + jest.mocked(isPerfEnabled).mockReturnValue(false); + const customTrace = jest.fn(); + + const { mocks, messenger } = await setup({ + accounts: [MOCK_HD_ACCOUNT_1, MOCK_SOL_ACCOUNT_1], + config: { trace: customTrace }, + }); + + expect(withLocalPerfTrace).not.toHaveBeenCalled(); + expect(mocks.EvmAccountProvider.constructor).toHaveBeenCalledWith( + messenger, + undefined, + customTrace, + ); + expect(mocks.SolAccountProvider.constructor).toHaveBeenCalledWith( + messenger, + undefined, + customTrace, + ); + }); + + it('wraps trace with local perf trace and passes it to providers when perf is enabled', async () => { + jest.mocked(isPerfEnabled).mockReturnValue(true); + const wrappedTrace = jest.fn(); + jest.mocked(withLocalPerfTrace).mockReturnValue(wrappedTrace); + + const { mocks, messenger } = await setup({ + accounts: [MOCK_HD_ACCOUNT_1, MOCK_SOL_ACCOUNT_1], + }); + + expect(withLocalPerfTrace).toHaveBeenCalledTimes(1); + expect(withLocalPerfTrace).toHaveBeenCalledWith(traceFallback); + expect(mocks.EvmAccountProvider.constructor).toHaveBeenCalledWith( + messenger, + undefined, + wrappedTrace, + ); + expect(mocks.SolAccountProvider.constructor).toHaveBeenCalledWith( + messenger, + undefined, + wrappedTrace, + ); + }); + + it('wraps config.trace with local perf trace when perf is enabled', async () => { + jest.mocked(isPerfEnabled).mockReturnValue(true); + const customTrace = jest.fn(); + const wrappedTrace = jest.fn(); + jest.mocked(withLocalPerfTrace).mockReturnValue(wrappedTrace); + + const { mocks } = await setup({ + accounts: [MOCK_HD_ACCOUNT_1, MOCK_SOL_ACCOUNT_1], + config: { trace: customTrace }, + }); + + expect(withLocalPerfTrace).toHaveBeenCalledWith(customTrace); + expect(mocks.EvmAccountProvider.constructor).toHaveBeenCalledWith( + expect.anything(), + undefined, + wrappedTrace, + ); + }); + + it('allows optional configs for some providers', async () => { + const providerConfigs: MultichainAccountServiceOptions['providerConfigs'] = + { + // NOTE: We use constants here, since `*AccountProvider` are mocked, thus, their `.NAME` will + // be `undefined`. + [SOL_ACCOUNT_PROVIDER_NAME]: { + maxConcurrency: 3, + discovery: { + timeoutMs: 5000, + maxAttempts: 4, + backOffMs: 2000, + }, + createAccounts: { + timeoutMs: 3000, + }, + }, + // No `EVM_ACCOUNT_PROVIDER_NAME`, cause it's optional in this test. + }; + + const { mocks, messenger } = await setup({ + accounts: [MOCK_HD_ACCOUNT_1, MOCK_SOL_ACCOUNT_1], + providerConfigs, + }); + + expect(mocks.EvmAccountProvider.constructor).toHaveBeenCalledWith( + messenger, + undefined, + expect.any(Function), // TraceCallback + ); + expect(mocks.SolAccountProvider.constructor).toHaveBeenCalledWith( + messenger, + providerConfigs?.[SOL_ACCOUNT_PROVIDER_NAME], + expect.any(Function), // TraceCallback + ); + }); + }); + + describe('getMultichainAccountGroups', () => { + it('gets multichain accounts', async () => { + const { service } = await setup({ + accounts: [ + // Wallet 1: + MockAccountBuilder.from(MOCK_HD_ACCOUNT_1) + .withEntropySource(MOCK_HD_KEYRING_1.metadata.id) + .withGroupIndex(0) + .get(), + MockAccountBuilder.from(MOCK_SNAP_ACCOUNT_1) + .withEntropySource(MOCK_HD_KEYRING_1.metadata.id) + .withGroupIndex(0) + .get(), + // Wallet 2: + MockAccountBuilder.from(MOCK_HD_ACCOUNT_2) + .withEntropySource(MOCK_HD_KEYRING_2.metadata.id) + .withGroupIndex(0) + .get(), + // Not HD accounts + MOCK_SNAP_ACCOUNT_2, + MOCK_HARDWARE_ACCOUNT_1, + ], + }); + + expect( + service.getMultichainAccountGroups({ + entropySource: MOCK_HD_KEYRING_1.metadata.id, + }), + ).toHaveLength(1); + expect( + service.getMultichainAccountGroups({ + entropySource: MOCK_HD_KEYRING_2.metadata.id, + }), + ).toHaveLength(1); + }); + + it('gets multichain accounts with multiple wallets', async () => { + const { service } = await setup({ + accounts: [ + // Wallet 1: + MockAccountBuilder.from(MOCK_HD_ACCOUNT_1) + .withEntropySource(MOCK_HD_KEYRING_1.metadata.id) + .withGroupIndex(0) + .get(), + MockAccountBuilder.from(MOCK_SNAP_ACCOUNT_1) + .withEntropySource(MOCK_HD_KEYRING_1.metadata.id) + .withGroupIndex(1) + .get(), + ], + }); + + const groups = service.getMultichainAccountGroups({ + entropySource: MOCK_HD_KEYRING_1.metadata.id, + }); + expect(groups).toHaveLength(2); // Group index 0 + 1. + + const internalAccounts0 = groups[0].getAccounts(); + expect(internalAccounts0).toHaveLength(1); // Just EVM. + expect(internalAccounts0[0].type).toBe(EthAccountType.Eoa); + + const internalAccounts1 = groups[1].getAccounts(); + expect(internalAccounts1).toHaveLength(1); // Just SOL. + expect(internalAccounts1[0].type).toBe(SolAccountType.DataAccount); + }); + + it('throws if trying to access an unknown wallet', async () => { + const { service } = await setup({ + keyrings: [MOCK_HD_KEYRING_1], + accounts: [ + // Wallet 1: + MockAccountBuilder.from(MOCK_HD_ACCOUNT_1) + .withEntropySource(MOCK_HD_KEYRING_1.metadata.id) + .withGroupIndex(0) + .get(), + ], + }); + + // Wallet 2 should not exist, thus, this should throw. + expect(() => + // NOTE: We use `getMultichainAccountGroups` which uses `#getWallet` under the hood. + service.getMultichainAccountGroups({ + entropySource: MOCK_HD_KEYRING_2.metadata.id, + }), + ).toThrow('Unknown wallet, no wallet matching this entropy source'); + }); + }); + + describe('getMultichainAccountGroup', () => { + it('gets a specific multichain account', async () => { + const accounts = [ + // Wallet 1: + MockAccountBuilder.from(MOCK_HD_ACCOUNT_1) + .withEntropySource(MOCK_HD_KEYRING_1.metadata.id) + .withGroupIndex(0) + .get(), + MockAccountBuilder.from(MOCK_HD_ACCOUNT_2) + .withEntropySource(MOCK_HD_KEYRING_1.metadata.id) + .withGroupIndex(1) + .get(), + ]; + const { service } = await setup({ + accounts, + }); + + const groupIndex = 1; + const group = service.getMultichainAccountGroup({ + entropySource: MOCK_HD_KEYRING_1.metadata.id, + groupIndex, + }); + expect(group.groupIndex).toBe(groupIndex); + + const internalAccounts = group.getAccounts(); + expect(internalAccounts).toHaveLength(1); + expect(internalAccounts[0]).toStrictEqual(accounts[1]); + }); + + it('throws if trying to access an out-of-bound group index', async () => { + const { service } = await setup({ + accounts: [ + // Wallet 1: + MockAccountBuilder.from(MOCK_HD_ACCOUNT_1) + .withEntropySource(MOCK_HD_KEYRING_1.metadata.id) + .withGroupIndex(0) + .get(), + ], + }); + + const groupIndex = 1; + expect(() => + service.getMultichainAccountGroup({ + entropySource: MOCK_HD_KEYRING_1.metadata.id, + groupIndex, + }), + ).toThrow(`No multichain account for index: ${groupIndex}`); + }); + }); + + describe('createNextMultichainAccountGroup', () => { + it('creates the next multichain account group', async () => { + const mockEvmAccount = MockAccountBuilder.from(MOCK_HD_ACCOUNT_1) + .withEntropySource(MOCK_HD_KEYRING_1.metadata.id) + .withGroupIndex(0) + .get(); + + const { service, mocks } = await setup({ accounts: [mockEvmAccount] }); + + // Groups cannot be empty, we need mock the next account creation too. + const mockNextEvmAccount = MockAccountBuilder.from(MOCK_HD_ACCOUNT_1) + .withEntropySource(MOCK_HD_KEYRING_1.metadata.id) + .withUuid() + .withGroupIndex(1) + .get(); + mocks.EvmAccountProvider.createAccounts.mockResolvedValueOnce([ + mockNextEvmAccount, + ]); + + const nextGroup = await service.createNextMultichainAccountGroup({ + entropySource: MOCK_HD_KEYRING_1.metadata.id, + }); + expect(nextGroup.groupIndex).toBe(1); + + // We always fetch account objects from the provider, so we also have + // to mock this. + mocks.EvmAccountProvider.getAccount.mockReturnValueOnce( + mockNextEvmAccount, + ); + const accounts = nextGroup.getAccounts(); + expect(mocks.EvmAccountProvider.getAccount).toHaveBeenCalledWith( + mockNextEvmAccount.id, + ); + expect(accounts).toHaveLength(1); + expect(accounts[0]).toStrictEqual(mockNextEvmAccount); + }); + + it('emits multichainAccountGroupCreated event when creating next group', async () => { + const mockEvmAccount = MockAccountBuilder.from(MOCK_HD_ACCOUNT_1) + .withEntropySource(MOCK_HD_KEYRING_1.metadata.id) + .withGroupIndex(0) + .get(); + + const { service, messenger, mocks } = await setup({ + accounts: [mockEvmAccount], + }); + const publishSpy = jest.spyOn(messenger, 'publish'); + + // Groups cannot be empty, we need mock the next account creation too. + const mockNextEvmAccount = MockAccountBuilder.from(MOCK_HD_ACCOUNT_1) + .withEntropySource(MOCK_HD_KEYRING_1.metadata.id) + .withUuid() + .withGroupIndex(1) + .get(); + mocks.EvmAccountProvider.createAccounts.mockResolvedValueOnce([ + mockNextEvmAccount, + ]); + + const nextGroup = await service.createNextMultichainAccountGroup({ + entropySource: MOCK_HD_KEYRING_1.metadata.id, + }); + + expect(publishSpy).toHaveBeenCalledWith( + 'MultichainAccountService:multichainAccountGroupCreated', + nextGroup, + ); + }); + }); + + describe('createMultichainAccountGroup', () => { + it('creates a multichain account group with the given group index', async () => { + const mockEvmAccount = MockAccountBuilder.from(MOCK_HD_ACCOUNT_1) + .withEntropySource(MOCK_HD_KEYRING_1.metadata.id) + .withGroupIndex(0) + .get(); + const mockSolAccount = MockAccountBuilder.from(MOCK_HD_ACCOUNT_2) + .withEntropySource(MOCK_HD_KEYRING_1.metadata.id) + .withGroupIndex(1) + .get(); + + const { service } = await setup({ + accounts: [mockEvmAccount, mockSolAccount], + }); + + const firstGroup = await service.createMultichainAccountGroup({ + entropySource: MOCK_HD_KEYRING_1.metadata.id, + groupIndex: 0, + }); + + const secondGroup = await service.createMultichainAccountGroup({ + entropySource: MOCK_HD_KEYRING_1.metadata.id, + groupIndex: 1, + }); + + expect(firstGroup.groupIndex).toBe(0); + expect(firstGroup.getAccounts()).toHaveLength(1); + expect(firstGroup.getAccounts()[0]).toStrictEqual(mockEvmAccount); + + expect(secondGroup.groupIndex).toBe(1); + expect(secondGroup.getAccounts()).toHaveLength(1); + expect(secondGroup.getAccounts()[0]).toStrictEqual(mockSolAccount); + }); + + it('emits multichainAccountGroupCreated event when creating specific group', async () => { + const mockEvmAccount = MockAccountBuilder.from(MOCK_HD_ACCOUNT_1) + .withEntropySource(MOCK_HD_KEYRING_1.metadata.id) + .withGroupIndex(0) + .get(); + + const { service, messenger, mocks } = await setup({ + accounts: [mockEvmAccount], + }); + const publishSpy = jest.spyOn(messenger, 'publish'); + + // Groups cannot be empty, we need mock the next account creation too. + const mockEvmAccount1 = MockAccountBuilder.from(MOCK_HD_ACCOUNT_1) + .withEntropySource(MOCK_HD_KEYRING_1.metadata.id) + .withUuid() + .withGroupIndex(1) + .get(); + mocks.EvmAccountProvider.createAccounts.mockResolvedValueOnce([ + mockEvmAccount1, + ]); + + const group = await service.createMultichainAccountGroup({ + entropySource: MOCK_HD_KEYRING_1.metadata.id, + groupIndex: 1, + }); + + expect(publishSpy).toHaveBeenCalledWith( + 'MultichainAccountService:multichainAccountGroupCreated', + group, + ); + }); + }); + + describe('createMultichainAccountGroups', () => { + it('creates multiple multichain account groups up to toGroupIndex', async () => { + // Start with group 0 existing to initialize the wallet. + const mockEvmAccount0 = MockAccountBuilder.from(MOCK_HD_ACCOUNT_1) + .withEntropySource(MOCK_HD_KEYRING_1.metadata.id) + .withGroupIndex(0) + .get(); + const mockSolAccount0 = MockAccountBuilder.from(MOCK_SOL_ACCOUNT_1) + .withEntropySource(MOCK_HD_KEYRING_1.metadata.id) + .withGroupIndex(0) + .get(); + + const { service, mocks } = await setup({ + accounts: [mockEvmAccount0, mockSolAccount0], + }); + + // Mock accounts that will be returned when creating groups 1, 2. + const mockEvmAccount1 = MockAccountBuilder.from(MOCK_HD_ACCOUNT_1) + .withEntropySource(MOCK_HD_KEYRING_1.metadata.id) + .withGroupIndex(1) + .get(); + const mockEvmAccount2 = MockAccountBuilder.from(MOCK_HD_ACCOUNT_1) + .withEntropySource(MOCK_HD_KEYRING_1.metadata.id) + .withGroupIndex(2) + .get(); + + const mockSolAccount1 = MockAccountBuilder.from(MOCK_SOL_ACCOUNT_1) + .withEntropySource(MOCK_HD_KEYRING_1.metadata.id) + .withGroupIndex(1) + .get(); + const mockSolAccount2 = MockAccountBuilder.from(MOCK_SOL_ACCOUNT_1) + .withEntropySource(MOCK_HD_KEYRING_1.metadata.id) + .withGroupIndex(2) + .get(); + + // Mock EVM provider to return new accounts for range 1-2. + mocks.EvmAccountProvider.createAccounts.mockResolvedValueOnce([ + mockEvmAccount1, + mockEvmAccount2, + ]); + + // Mock SOL provider for new groups. + mocks.SolAccountProvider.createAccounts.mockResolvedValueOnce([ + mockSolAccount1, + ]); + mocks.SolAccountProvider.createAccounts.mockResolvedValueOnce([ + mockSolAccount2, + ]); + + const groups = await service.createMultichainAccountGroups({ + entropySource: MOCK_HD_KEYRING_1.metadata.id, + toGroupIndex: 2, + }); + + expect(groups).toHaveLength(3); + expect(groups[0].groupIndex).toBe(0); // Existing group. + expect(groups[1].groupIndex).toBe(1); // New group. + expect(groups[2].groupIndex).toBe(2); // New group. + + // Verify EVM provider was called with range for new groups. + expect(mocks.EvmAccountProvider.createAccounts).toHaveBeenCalledWith({ + type: AccountCreationType.Bip44DeriveIndexRange, + entropySource: MOCK_HD_KEYRING_1.metadata.id, + range: { + from: 1, + to: 2, + }, + }); + }); + + it('creates multiple groups via messenger action handler', async () => { + const mockEvmAccount0 = MockAccountBuilder.from(MOCK_HD_ACCOUNT_1) + .withEntropySource(MOCK_HD_KEYRING_1.metadata.id) + .withGroupIndex(0) + .get(); + const mockEvmAccount1 = MockAccountBuilder.from(MOCK_HD_ACCOUNT_1) + .withEntropySource(MOCK_HD_KEYRING_1.metadata.id) + .withGroupIndex(1) + .get(); + + const { messenger } = await setup({ + accounts: [mockEvmAccount0, mockEvmAccount1], + }); + + const groups = await messenger.call( + 'MultichainAccountService:createMultichainAccountGroups', + { + entropySource: MOCK_HD_KEYRING_1.metadata.id, + toGroupIndex: 1, + }, + ); + + expect(groups).toHaveLength(2); + expect(groups[0].groupIndex).toBe(0); + expect(groups[1].groupIndex).toBe(1); + }); + + it('publishes multichainAccountGroupCreated events for each new group', async () => { + // Start with group 0 existing to initialize the wallet. + const mockEvmAccount0 = MockAccountBuilder.from(MOCK_HD_ACCOUNT_1) + .withEntropySource(MOCK_HD_KEYRING_1.metadata.id) + .withGroupIndex(0) + .get(); + const mockSolAccount0 = MockAccountBuilder.from(MOCK_SOL_ACCOUNT_1) + .withEntropySource(MOCK_HD_KEYRING_1.metadata.id) + .withGroupIndex(0) + .get(); + + const { service, messenger, mocks } = await setup({ + accounts: [mockEvmAccount0, mockSolAccount0], + }); + + const mockEvmAccount1 = MockAccountBuilder.from(MOCK_HD_ACCOUNT_1) + .withEntropySource(MOCK_HD_KEYRING_1.metadata.id) + .withGroupIndex(1) + .get(); + const mockSolAccount1 = MockAccountBuilder.from(MOCK_SOL_ACCOUNT_1) + .withEntropySource(MOCK_HD_KEYRING_1.metadata.id) + .withGroupIndex(1) + .get(); + + // Mock EVM provider to return account for group 1. + mocks.EvmAccountProvider.createAccounts.mockResolvedValueOnce([ + mockEvmAccount1, + ]); + + // Mock SOL provider for group 1. + mocks.SolAccountProvider.createAccounts.mockResolvedValueOnce([ + mockSolAccount1, + ]); + + const publishSpy = jest.spyOn(messenger, 'publish'); + + await service.createMultichainAccountGroups({ + entropySource: MOCK_HD_KEYRING_1.metadata.id, + toGroupIndex: 1, + }); + + // Should publish event for the new group (group 1). + // Group 0 already existed, so it shouldn't publish an event for it. + expect(publishSpy).toHaveBeenCalledWith( + 'MultichainAccountService:multichainAccountGroupCreated', + expect.objectContaining({ groupIndex: 1 }), + ); + }); + }); + + describe('alignWallets', () => { + it('aligns all multichain account wallets', async () => { + const mockEvmAccount1 = MockAccountBuilder.from(MOCK_HD_ACCOUNT_1) + .withEntropySource(MOCK_HD_KEYRING_1.metadata.id) + .withGroupIndex(0) + .get(); + const mockSolAccount1 = MockAccountBuilder.from(MOCK_SOL_ACCOUNT_1) + .withEntropySource(MOCK_HD_KEYRING_2.metadata.id) + .withGroupIndex(0) + .get(); + const { service, mocks } = await setup({ + accounts: [mockEvmAccount1, mockSolAccount1], + }); + + await service.alignWallets(); + + expect(mocks.EvmAccountProvider.createAccounts).toHaveBeenCalledWith({ + type: AccountCreationType.Bip44DeriveIndexRange, + entropySource: MOCK_HD_KEYRING_2.metadata.id, + range: { from: 0, to: 0 }, + }); + expect(mocks.SolAccountProvider.createAccounts).toHaveBeenCalledWith({ + type: AccountCreationType.Bip44DeriveIndexRange, + entropySource: MOCK_HD_KEYRING_1.metadata.id, + range: { from: 0, to: 0 }, + }); + }); + }); + + describe('alignWallet', () => { + it('aligns a specific multichain account wallet', async () => { + const mockEvmAccount1 = MockAccountBuilder.from(MOCK_HD_ACCOUNT_1) + .withEntropySource(MOCK_HD_KEYRING_1.metadata.id) + .withGroupIndex(0) + .get(); + const mockSolAccount1 = MockAccountBuilder.from(MOCK_SOL_ACCOUNT_1) + .withEntropySource(MOCK_HD_KEYRING_2.metadata.id) + .withGroupIndex(0) + .get(); + const { service, mocks } = await setup({ + accounts: [mockEvmAccount1, mockSolAccount1], + }); + + await service.alignWallet(MOCK_HD_KEYRING_1.metadata.id); + + expect(mocks.SolAccountProvider.createAccounts).toHaveBeenCalledWith({ + type: AccountCreationType.Bip44DeriveIndexRange, + entropySource: MOCK_HD_KEYRING_1.metadata.id, + range: { from: 0, to: 0 }, + }); + }); + }); + + describe('removeMultichainAccountWallet', () => { + const makeWalletAccount = ( + template: Account, + ): Account => + MockAccountBuilder.from(template) + .withEntropySource(MOCK_HD_KEYRING_1.metadata.id) + .withGroupIndex(0) + .get(); + + it('calls deleteAccount on the EVM provider for an EVM-only wallet and removes the wallet', async () => { + const mockEvmAccount = makeWalletAccount(MOCK_HD_ACCOUNT_1); + + const { service, mocks } = await setup({ + accounts: [mockEvmAccount], + }); + + // Wallet should exist before removal. + expect( + service.getMultichainAccountWallet({ + entropySource: MOCK_HD_KEYRING_1.metadata.id, + }), + ).toBeDefined(); + + await service.removeMultichainAccountWallet( + MOCK_HD_KEYRING_1.metadata.id, + ); + + expect(mocks.EvmAccountProvider.deleteAccount).toHaveBeenCalledTimes(1); + expect(mocks.EvmAccountProvider.deleteAccount).toHaveBeenCalledWith( + mockEvmAccount.id, + ); + expect(mocks.SolAccountProvider.deleteAccount).not.toHaveBeenCalled(); + expect(() => + service.getMultichainAccountWallet({ + entropySource: MOCK_HD_KEYRING_1.metadata.id, + }), + ).toThrow('Unknown wallet, no wallet matching this entropy source'); + }); + + it('dispatches deleteAccount per provider for a wallet with EVM and Sol accounts', async () => { + const mockEvmAccount = makeWalletAccount(MOCK_HD_ACCOUNT_1); + const mockSolAccount = makeWalletAccount(MOCK_SOL_ACCOUNT_1); + + const { service, mocks } = await setup({ + accounts: [mockEvmAccount, mockSolAccount], + }); + + await service.removeMultichainAccountWallet( + MOCK_HD_KEYRING_1.metadata.id, + ); + + expect(mocks.EvmAccountProvider.deleteAccount).toHaveBeenCalledTimes(1); + expect(mocks.EvmAccountProvider.deleteAccount).toHaveBeenCalledWith( + mockEvmAccount.id, + ); + expect(mocks.SolAccountProvider.deleteAccount).toHaveBeenCalledTimes(1); + expect(mocks.SolAccountProvider.deleteAccount).toHaveBeenCalledWith( + mockSolAccount.id, + ); + // Providers that own no accounts for this wallet should be left alone. + expect(mocks.BtcAccountProvider.deleteAccount).not.toHaveBeenCalled(); + expect(mocks.TrxAccountProvider.deleteAccount).not.toHaveBeenCalled(); + expect(() => + service.getMultichainAccountWallet({ + entropySource: MOCK_HD_KEYRING_1.metadata.id, + }), + ).toThrow('Unknown wallet, no wallet matching this entropy source'); + }); + + it('continues with remaining accounts and removes the wallet when one provider deleteAccount call throws', async () => { + const mockEvmAccount = makeWalletAccount(MOCK_HD_ACCOUNT_1); + const mockSolAccount = makeWalletAccount(MOCK_SOL_ACCOUNT_1); + + const { service, messenger, mocks } = await setup({ + accounts: [mockEvmAccount, mockSolAccount], + }); + const captureExceptionSpy = jest.spyOn(messenger, 'captureException'); + mocks.SolAccountProvider.deleteAccount.mockRejectedValueOnce( + new Error('snap is unavailable'), + ); + + await service.removeMultichainAccountWallet( + MOCK_HD_KEYRING_1.metadata.id, + ); + + expect(mocks.EvmAccountProvider.deleteAccount).toHaveBeenCalledWith( + mockEvmAccount.id, + ); + expect(mocks.SolAccountProvider.deleteAccount).toHaveBeenCalledWith( + mockSolAccount.id, + ); + // A single aggregated Sentry report is fired even though only one + // account failed: the wallet-removal action is treated as one + // coherent incident. + expect(captureExceptionSpy).toHaveBeenCalledTimes(1); + const sentryError = captureExceptionSpy.mock + .calls[0]?.[0] as SentryError; + expect(sentryError.message).toBe( + 'Failed to delete one or more accounts during wallet removal', + ); + expect(sentryError.context?.failures).toStrictEqual([ + expect.objectContaining({ + provider: SOL_ACCOUNT_PROVIDER_NAME, + accountId: mockSolAccount.id, + }), + ]); + expect(() => + service.getMultichainAccountWallet({ + entropySource: MOCK_HD_KEYRING_1.metadata.id, + }), + ).toThrow('Unknown wallet, no wallet matching this entropy source'); + }); + + it('aggregates multiple per-account failures into one report', async () => { + const mockEvmAccount = makeWalletAccount(MOCK_HD_ACCOUNT_1); + const mockSolAccount = makeWalletAccount(MOCK_SOL_ACCOUNT_1); + + const { service, messenger, mocks } = await setup({ + accounts: [mockEvmAccount, mockSolAccount], + }); + const captureExceptionSpy = jest.spyOn(messenger, 'captureException'); + mocks.EvmAccountProvider.deleteAccount.mockRejectedValueOnce( + new Error('evm keyring locked'), + ); + mocks.SolAccountProvider.deleteAccount.mockRejectedValueOnce( + new Error('snap is unavailable'), + ); + + await service.removeMultichainAccountWallet( + MOCK_HD_KEYRING_1.metadata.id, + ); + + expect(captureExceptionSpy).toHaveBeenCalledTimes(1); + const sentryError = captureExceptionSpy.mock + .calls[0]?.[0] as SentryError; + expect(sentryError.context?.failures).toStrictEqual( + expect.arrayContaining([ + expect.objectContaining({ + provider: EVM_ACCOUNT_PROVIDER_NAME, + accountId: mockEvmAccount.id, + error: 'evm keyring locked', + }), + expect.objectContaining({ + provider: SOL_ACCOUNT_PROVIDER_NAME, + accountId: mockSolAccount.id, + error: 'snap is unavailable', + }), + ]), + ); + expect(() => + service.getMultichainAccountWallet({ + entropySource: MOCK_HD_KEYRING_1.metadata.id, + }), + ).toThrow('Unknown wallet, no wallet matching this entropy source'); + }); + + it('continues with remaining providers and removes the wallet when enumerating one provider throws', async () => { + const mockEvmAccount = makeWalletAccount(MOCK_HD_ACCOUNT_1); + const mockSolAccount = makeWalletAccount(MOCK_SOL_ACCOUNT_1); + + const { service, messenger, mocks } = await setup({ + accounts: [mockEvmAccount, mockSolAccount], + }); + const captureExceptionSpy = jest.spyOn(messenger, 'captureException'); + // Enumerating the Sol provider's accounts throws before any specific + // account can be targeted. This must not abort cleanup of the other + // providers nor skip the always-remove step. + mocks.SolAccountProvider.getAccounts.mockImplementationOnce(() => { + throw new Error('snap keyring unavailable'); + }); + + await service.removeMultichainAccountWallet( + MOCK_HD_KEYRING_1.metadata.id, + ); + + // EVM account must still be deleted even though the Sol provider failed + // to enumerate. + expect(mocks.EvmAccountProvider.deleteAccount).toHaveBeenCalledWith( + mockEvmAccount.id, + ); + // The Sol provider failure is reported as a provider-level failure with + // no specific `accountId`. + expect(captureExceptionSpy).toHaveBeenCalledTimes(1); + const sentryError = captureExceptionSpy.mock + .calls[0]?.[0] as SentryError; + expect(sentryError.context?.failures).toStrictEqual([ + { + provider: SOL_ACCOUNT_PROVIDER_NAME, + accountId: undefined, + error: 'snap keyring unavailable', + }, + ]); + // The wallet is always removed at the end. + expect(() => + service.getMultichainAccountWallet({ + entropySource: MOCK_HD_KEYRING_1.metadata.id, + }), + ).toThrow('Unknown wallet, no wallet matching this entropy source'); + }); + + it('handles non-Error rejections in the aggregated report', async () => { + const mockEvmAccount = makeWalletAccount(MOCK_HD_ACCOUNT_1); + + const { service, messenger, mocks } = await setup({ + accounts: [mockEvmAccount], + }); + const captureExceptionSpy = jest.spyOn(messenger, 'captureException'); + // Real keyring code only rejects with `Error`s, but downstream + // messenger handlers can in principle reject with any value. The + // aggregator should handle that without throwing. + mocks.EvmAccountProvider.deleteAccount.mockRejectedValueOnce( + 'plain string failure', + ); + + await service.removeMultichainAccountWallet( + MOCK_HD_KEYRING_1.metadata.id, + ); + + expect(captureExceptionSpy).toHaveBeenCalledTimes(1); + const sentryError = captureExceptionSpy.mock + .calls[0]?.[0] as SentryError; + expect(sentryError.context?.failures[0]).toStrictEqual( + expect.objectContaining({ + error: 'plain string failure', + }), + ); + }); + + it('does not call captureException when all deletes succeed', async () => { + const mockEvmAccount = makeWalletAccount(MOCK_HD_ACCOUNT_1); + const mockSolAccount = makeWalletAccount(MOCK_SOL_ACCOUNT_1); + + const { service, messenger } = await setup({ + accounts: [mockEvmAccount, mockSolAccount], + }); + const captureExceptionSpy = jest.spyOn(messenger, 'captureException'); + + await service.removeMultichainAccountWallet( + MOCK_HD_KEYRING_1.metadata.id, + ); + + expect(captureExceptionSpy).not.toHaveBeenCalled(); + }); + + it('still deletes snap-backed accounts when basic functionality is disabled', async () => { + const mockEvmAccount = makeWalletAccount(MOCK_HD_ACCOUNT_1); + const mockSolAccount = makeWalletAccount(MOCK_SOL_ACCOUNT_1); + + const { service, mocks } = await setup({ + accounts: [mockEvmAccount, mockSolAccount], + }); + + // Turning basic functionality off disables the snap-backed provider + // wrappers (Sol/Btc/Trx). EVM is intentionally left enabled. + await service.setBasicFunctionality(false); + + await service.removeMultichainAccountWallet( + MOCK_HD_KEYRING_1.metadata.id, + ); + + // EVM account must still be deleted. + expect(mocks.EvmAccountProvider.deleteAccount).toHaveBeenCalledWith( + mockEvmAccount.id, + ); + // Regression check: snap-backed account must NOT be orphaned just + // because the wrapper is disabled. The wrapper's `deleteAccount` is + // already designed to forward unconditionally; the consumer must also + // discover the account in the first place. + expect(mocks.SolAccountProvider.deleteAccount).toHaveBeenCalledWith( + mockSolAccount.id, + ); + expect(() => + service.getMultichainAccountWallet({ + entropySource: MOCK_HD_KEYRING_1.metadata.id, + }), + ).toThrow('Unknown wallet, no wallet matching this entropy source'); + }); + }); + + describe('actions', () => { + it('gets a multichain account with MultichainAccountService:getMultichainAccount', async () => { + const accounts = [MOCK_HD_ACCOUNT_1]; + const { messenger } = await setup({ accounts }); + + const group = messenger.call( + 'MultichainAccountService:getMultichainAccountGroup', + { entropySource: MOCK_HD_KEYRING_1.metadata.id, groupIndex: 0 }, + ); + expect(group).toBeDefined(); + }); + + it('gets multichain accounts with MultichainAccountService:getMultichainAccounts', async () => { + const accounts = [MOCK_HD_ACCOUNT_1]; + const { messenger } = await setup({ accounts }); + + const groups = messenger.call( + 'MultichainAccountService:getMultichainAccountGroups', + { entropySource: MOCK_HD_KEYRING_1.metadata.id }, + ); + expect(groups.length).toBeGreaterThan(0); + }); + + it('gets multichain account wallet with MultichainAccountService:getMultichainAccountWallet', async () => { + const accounts = [MOCK_HD_ACCOUNT_1]; + const { messenger } = await setup({ accounts }); + + const wallet = messenger.call( + 'MultichainAccountService:getMultichainAccountWallet', + { entropySource: MOCK_HD_KEYRING_1.metadata.id }, + ); + expect(wallet).toBeDefined(); + }); + + it('gets multichain account wallet with MultichainAccountService:getMultichainAccountWallets', async () => { + const accounts = [MOCK_HD_ACCOUNT_1]; + const { messenger } = await setup({ accounts }); + + const wallets = messenger.call( + 'MultichainAccountService:getMultichainAccountWallets', + ); + expect(wallets.length).toBeGreaterThan(0); + }); + + it('create the next multichain account group with MultichainAccountService:createNextMultichainAccountGroup', async () => { + const accounts = [MOCK_HD_ACCOUNT_1]; + const { messenger, mocks } = await setup({ accounts }); + + // Groups cannot be empty, we need mock the next account creation too. + const mockNextEvmAccount = MockAccountBuilder.from(MOCK_HD_ACCOUNT_1) + .withEntropySource(MOCK_HD_KEYRING_1.metadata.id) + .withUuid() + .withGroupIndex(1) + .get(); + mocks.EvmAccountProvider.createAccounts.mockResolvedValueOnce([ + mockNextEvmAccount, + ]); + + const nextGroup = await messenger.call( + 'MultichainAccountService:createNextMultichainAccountGroup', + { entropySource: MOCK_HD_KEYRING_1.metadata.id }, + ); + expect(nextGroup.groupIndex).toBe(1); + }); + + it('creates a multichain account group with MultichainAccountService:createMultichainAccountGroup', async () => { + const accounts = [MOCK_HD_ACCOUNT_1]; + const { messenger } = await setup({ accounts }); + + const firstGroup = await messenger.call( + 'MultichainAccountService:createMultichainAccountGroup', + { + entropySource: MOCK_HD_KEYRING_1.metadata.id, + groupIndex: 0, + }, + ); + + expect(firstGroup.groupIndex).toBe(0); + expect(firstGroup.getAccounts()).toHaveLength(1); + expect(firstGroup.getAccounts()[0]).toStrictEqual(MOCK_HD_ACCOUNT_1); + }); + + it('aligns a multichain account wallet with MultichainAccountService:alignWallet', async () => { + const mockEvmAccount1 = MockAccountBuilder.from(MOCK_HD_ACCOUNT_1) + .withEntropySource(MOCK_HD_KEYRING_1.metadata.id) + .withGroupIndex(0) + .get(); + const mockSolAccount1 = MockAccountBuilder.from(MOCK_SOL_ACCOUNT_1) + .withEntropySource(MOCK_HD_KEYRING_2.metadata.id) + .withGroupIndex(0) + .get(); + const { messenger, mocks } = await setup({ + accounts: [mockEvmAccount1, mockSolAccount1], + }); + + await messenger.call( + 'MultichainAccountService:alignWallet', + MOCK_HD_KEYRING_1.metadata.id, + ); + + expect(mocks.SolAccountProvider.createAccounts).toHaveBeenCalledWith({ + type: AccountCreationType.Bip44DeriveIndexRange, + entropySource: MOCK_HD_KEYRING_1.metadata.id, + range: { from: 0, to: 0 }, + }); + }); + + it('aligns all multichain account wallets with MultichainAccountService:alignWallets', async () => { + const mockEvmAccount1 = MockAccountBuilder.from(MOCK_HD_ACCOUNT_1) + .withEntropySource(MOCK_HD_KEYRING_1.metadata.id) + .withGroupIndex(0) + .get(); + const mockSolAccount1 = MockAccountBuilder.from(MOCK_SOL_ACCOUNT_1) + .withEntropySource(MOCK_HD_KEYRING_2.metadata.id) + .withGroupIndex(0) + .get(); + const { messenger, mocks } = await setup({ + accounts: [mockEvmAccount1, mockSolAccount1], + }); + + await messenger.call('MultichainAccountService:alignWallets'); + + expect(mocks.EvmAccountProvider.createAccounts).toHaveBeenCalledWith({ + type: AccountCreationType.Bip44DeriveIndexRange, + entropySource: MOCK_HD_KEYRING_2.metadata.id, + range: { from: 0, to: 0 }, + }); + expect(mocks.SolAccountProvider.createAccounts).toHaveBeenCalledWith({ + type: AccountCreationType.Bip44DeriveIndexRange, + entropySource: MOCK_HD_KEYRING_1.metadata.id, + range: { from: 0, to: 0 }, + }); + }); + + it('sets basic functionality with MultichainAccountService:setBasicFunctionality', async () => { + const { messenger } = await setup({ + accounts: [MOCK_HD_ACCOUNT_1], + }); + + // This tests the action handler registration + expect( + await messenger.call( + 'MultichainAccountService:setBasicFunctionality', + true, + ), + ).toBeUndefined(); + expect( + await messenger.call( + 'MultichainAccountService:setBasicFunctionality', + false, + ), + ).toBeUndefined(); + }); + + it('creates a multichain account wallet with MultichainAccountService:createMultichainAccountWallet', async () => { + const { messenger, mocks } = await setup({ accounts: [], keyrings: [] }); + + const mnemonic = mnemonicPhraseToBytes(MOCK_MNEMONIC); + + mocks.KeyringController.getKeyringsByType.mockImplementationOnce( + () => [], + ); + + mocks.KeyringController.addNewKeyring.mockImplementationOnce(() => ({ + id: 'abc', + name: '', + })); + + mocks.EvmAccountProvider.createAccounts.mockResolvedValueOnce([ + MOCK_HD_ACCOUNT_1, + ]); + + const wallet = await messenger.call( + 'MultichainAccountService:createMultichainAccountWallet', + { mnemonic, type: 'import' }, + ); + + expect(wallet).toBeDefined(); + expect(wallet.entropySource).toBe('abc'); + }); + + it('resync accounts with MultichainAccountService:resyncAccounts', async () => { + const { messenger, mocks } = await setup({ + accounts: [MOCK_HD_ACCOUNT_1], + }); + + await messenger.call('MultichainAccountService:resyncAccounts'); + + expect(mocks.EvmAccountProvider.resyncAccounts).toHaveBeenCalled(); + expect(mocks.SolAccountProvider.resyncAccounts).toHaveBeenCalled(); + }); + + it('removes a multichain account wallet with MultichainAccountService:removeMultichainAccountWallet', async () => { + const { messenger, mocks } = await setup({ + accounts: [MOCK_HD_ACCOUNT_1], + }); + + await messenger.call( + 'MultichainAccountService:removeMultichainAccountWallet', + MOCK_HD_KEYRING_1.metadata.id, + ); + + expect(mocks.EvmAccountProvider.deleteAccount).toHaveBeenCalledWith( + MOCK_HD_ACCOUNT_1.id, + ); + }); + }); + + describe('resyncAccounts', () => { + it('calls resyncAccounts on each providers', async () => { + const { service, mocks } = await setup({ accounts: [MOCK_HD_ACCOUNT_1] }); + + await service.resyncAccounts(); + + expect(mocks.EvmAccountProvider.resyncAccounts).toHaveBeenCalled(); + expect(mocks.SolAccountProvider.resyncAccounts).toHaveBeenCalled(); + }); + + it('filters BIP-44 accounts before calling providers', async () => { + const accounts = [ + MOCK_HD_ACCOUNT_1, + MOCK_SNAP_ACCOUNT_1, // Not a BIP-44 accounts. + MOCK_HD_ACCOUNT_2, + ]; + + const { service, mocks } = await setup({ + accounts, + }); + + await service.resyncAccounts(); + + const bip44Accounts = accounts.filter(isBip44Account); + + expect(mocks.EvmAccountProvider.resyncAccounts).toHaveBeenCalledWith( + bip44Accounts, + ); + expect(mocks.SolAccountProvider.resyncAccounts).toHaveBeenCalledWith( + bip44Accounts, + ); + }); + + it('does not throw if any providers is throwing', async () => { + const rootMessenger = getRootMessenger(); + const captureExceptionSpy = jest.spyOn(rootMessenger, 'captureException'); + + const { service, mocks } = await setup({ + rootMessenger, + accounts: [MOCK_HD_ACCOUNT_1], + }); + + const providerError = new Error('Unable to resync accounts'); + mocks.SolAccountProvider.resyncAccounts.mockRejectedValue(providerError); + + await service.resyncAccounts(); // Should not throw. + + expect(mocks.EvmAccountProvider.resyncAccounts).toHaveBeenCalled(); + expect(mocks.SolAccountProvider.resyncAccounts).toHaveBeenCalled(); + + expect(captureExceptionSpy).toHaveBeenCalled(); + expect(captureExceptionSpy.mock.lastCall[0]).toHaveProperty( + 'cause', + providerError, + ); + }); + + it('does not capture exception when provider throws a TimeoutError', async () => { + const rootMessenger = getRootMessenger(); + const captureExceptionSpy = jest.spyOn(rootMessenger, 'captureException'); + const consoleWarnSpy = jest.spyOn(console, 'warn').mockImplementation(); + const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(); + + const { service, mocks } = await setup({ + rootMessenger, + accounts: [MOCK_HD_ACCOUNT_1], + }); + + mocks.SolAccountProvider.resyncAccounts.mockRejectedValue( + new TimeoutError('Timed out after: 500ms'), + ); + + await service.resyncAccounts(); // Should not throw. + + expect(captureExceptionSpy).not.toHaveBeenCalled(); + expect(consoleWarnSpy).toHaveBeenCalled(); + expect(consoleErrorSpy).not.toHaveBeenCalled(); + + consoleWarnSpy.mockRestore(); + consoleErrorSpy.mockRestore(); + }); + }); + + describe('setBasicFunctionality', () => { + it('can be called with boolean true', async () => { + const { service } = await setup({ accounts: [MOCK_HD_ACCOUNT_1] }); + + // This tests the simplified parameter signature + expect(await service.setBasicFunctionality(true)).toBeUndefined(); + }); + + it('can be called with boolean false', async () => { + const { service } = await setup({ accounts: [MOCK_HD_ACCOUNT_1] }); + + // This tests the simplified parameter signature + expect(await service.setBasicFunctionality(false)).toBeUndefined(); + }); + }); + + describe('AccountProviderWrapper', () => { + let wrapper: AccountProviderWrapper; + let solProvider: SolAccountProvider; + + beforeEach(async () => { + const { rootMessenger } = await setup({ + accounts: [MOCK_HD_ACCOUNT_1], + }); + + // Create actual SolAccountProvider instance for wrapping + solProvider = new SolAccountProvider( + getMultichainAccountServiceMessenger(rootMessenger), + ); + + // Spy on the provider methods + jest.spyOn(solProvider, 'resyncAccounts'); + jest.spyOn(solProvider, 'getAccounts'); + jest.spyOn(solProvider, 'getAccount'); + jest.spyOn(solProvider, 'createAccounts'); + jest.spyOn(solProvider, 'discoverAccounts'); + jest.spyOn(solProvider, 'isAccountCompatible'); + + wrapper = new AccountProviderWrapper( + getMultichainAccountServiceMessenger(rootMessenger), + solProvider, + ); + }); + + it('forwards capabilities from adapted provider', async () => { + expect(wrapper.capabilities).toStrictEqual(solProvider.capabilities); + }); + + it('forwards resyncAccounts() if provider is enabled', async () => { + const spy = jest.spyOn(solProvider, 'resyncAccounts'); + + // Enable first - should work normally + spy.mockResolvedValue(undefined); + await wrapper.resyncAccounts([]); + expect(spy).toHaveBeenCalledTimes(1); + + // Disable - should return empty array + wrapper.setEnabled(false); + await wrapper.resyncAccounts([]); + expect(spy).toHaveBeenCalledTimes(1); // No new call, still 1 call + }); + + it('returns empty array when getAccounts() is disabled', () => { + // Enable first - should work normally + (solProvider.getAccounts as jest.Mock).mockReturnValue([ + MOCK_HD_ACCOUNT_1, + ]); + expect(wrapper.getAccounts()).toStrictEqual([MOCK_HD_ACCOUNT_1]); + + // Disable - should return empty array + wrapper.setEnabled(false); + expect(wrapper.getAccounts()).toStrictEqual([]); + }); + + it('throws error when getAccount() is disabled', () => { + // Enable first - should work normally + (solProvider.getAccount as jest.Mock).mockReturnValue(MOCK_HD_ACCOUNT_1); + expect(wrapper.getAccount('test-id')).toStrictEqual(MOCK_HD_ACCOUNT_1); + + // Disable - should throw error + wrapper.setEnabled(false); + expect(() => wrapper.getAccount('test-id')).toThrow( + 'Provider is disabled', + ); + }); + + it('returns empty array when createAccounts() is disabled', async () => { + const options: CreateAccountOptions = { + type: AccountCreationType.Bip44DeriveIndex, + entropySource: MOCK_HD_ACCOUNT_1.options.entropy.id, + groupIndex: 0, + }; + + // Enable first - should work normally + (solProvider.createAccounts as jest.Mock).mockResolvedValue([ + MOCK_HD_ACCOUNT_1, + ]); + expect(await wrapper.createAccounts(options)).toStrictEqual([ + MOCK_HD_ACCOUNT_1, + ]); + + // Disable - should return empty array and not call underlying provider + wrapper.setEnabled(false); + + const result = await wrapper.createAccounts(options); + expect(result).toStrictEqual([]); + }); + + it('returns empty array when discoverAccounts() is disabled', async () => { + const options = { + entropySource: MOCK_HD_ACCOUNT_1.options.entropy.id, + groupIndex: 0, + }; + + // Enable first - should work normally + (solProvider.discoverAccounts as jest.Mock).mockResolvedValue([ + MOCK_HD_ACCOUNT_1, + ]); + expect(await wrapper.discoverAccounts(options)).toStrictEqual([ + MOCK_HD_ACCOUNT_1, + ]); + + // Disable - should return empty array + wrapper.setEnabled(false); + + const result = await wrapper.discoverAccounts(options); + expect(result).toStrictEqual([]); + }); + + it('delegates isAccountCompatible() to wrapped provider', () => { + // Mock the provider's compatibility check + (solProvider.isAccountCompatible as jest.Mock).mockReturnValue(true); + expect(wrapper.isAccountCompatible(MOCK_HD_ACCOUNT_1)).toBe(true); + expect(solProvider.isAccountCompatible).toHaveBeenCalledWith( + MOCK_HD_ACCOUNT_1, + ); + + // Test with false return + (solProvider.isAccountCompatible as jest.Mock).mockReturnValue(false); + expect(wrapper.isAccountCompatible(MOCK_HD_ACCOUNT_1)).toBe(false); + }); + + it('exposes the wrapped provider via unwrap() regardless of enabled state', () => { + expect(wrapper.unwrap()).toBe(solProvider); + + // The escape hatch must keep working when the wrapper is disabled, + // since cleanup flows rely on it to discover snap-backed accounts. + wrapper.setEnabled(false); + expect(wrapper.unwrap()).toBe(solProvider); + }); + + it('forwards deleteAccount() to the wrapped provider regardless of enabled state', async () => { + const deleteAccountSpy = jest + .spyOn(solProvider, 'deleteAccount') + .mockResolvedValue(undefined); + + await wrapper.deleteAccount(MOCK_HD_ACCOUNT_1.id); + expect(deleteAccountSpy).toHaveBeenCalledTimes(1); + expect(deleteAccountSpy).toHaveBeenCalledWith(MOCK_HD_ACCOUNT_1.id); + + // Even when disabled, deletion must still go through so that wallet + // removal can clean up snap-backed accounts. + wrapper.setEnabled(false); + await wrapper.deleteAccount(MOCK_HD_ACCOUNT_1.id); + expect(deleteAccountSpy).toHaveBeenCalledTimes(2); + }); + }); + + describe('createMultichainAccountWallet', () => { + describe('createWalletByImport', () => { + it('creates a new multichain account wallet by the import flow', async () => { + const { mocks, service } = await setup({ + accounts: [], + keyrings: [], + }); + + const mnemonic = mnemonicPhraseToBytes(MOCK_MNEMONIC); + + mocks.KeyringController.getKeyringsByType.mockImplementationOnce(() => [ + {}, + ]); + + mocks.KeyringController.addNewKeyring.mockImplementationOnce(() => ({ + id: 'abc', + name: '', + })); + + mocks.EvmAccountProvider.createAccounts.mockResolvedValueOnce([ + MOCK_HD_ACCOUNT_1, + ]); + + const wallet = await service.createMultichainAccountWallet({ + mnemonic, + type: 'import', + }); + + expect(wallet).toBeDefined(); + expect(wallet.entropySource).toBe('abc'); + }); + + it("throws an error if there's already an existing keyring from the same mnemonic", async () => { + const { service, mocks } = await setup({ accounts: [], keyrings: [] }); + + const mnemonic = mnemonicPhraseToBytes(MOCK_MNEMONIC); + + mocks.KeyringController.getKeyringsByType.mockImplementationOnce(() => [ + { + mnemonic, + }, + ]); + + await expect( + service.createMultichainAccountWallet({ + mnemonic, + type: 'import', + }), + ).rejects.toThrow( + 'This Secret Recovery Phrase has already been imported.', + ); + + // Ensure we did not attempt to create a new keyring when duplicate is detected + expect(mocks.KeyringController.addNewKeyring).not.toHaveBeenCalled(); + }); + }); + + describe('createWalletByNewVault', () => { + it('creates a new multichain account wallet by the new vault flow', async () => { + const { service, mocks, rootMessenger } = await setup({ + accounts: [], + keyrings: [], + }); + + const password = 'password'; + + mocks.KeyringController.createNewVaultAndKeychain.mockImplementationOnce( + () => { + mocks.KeyringController.keyrings.push(MOCK_HD_KEYRING_1); + }, + ); + + rootMessenger.registerActionHandler( + 'KeyringController:withKeyringV2', + async (_, operation) => { + const newKeyring = mocks.KeyringController.keyrings.find( + (keyring) => keyring.type === KeyringType.Hd, + ) as KeyringObject; + return operation({ + keyring: {} as unknown as Keyring, + metadata: newKeyring.metadata, + }); + }, + ); + + mocks.EvmAccountProvider.createAccounts.mockResolvedValueOnce([ + MOCK_HD_ACCOUNT_1, + ]); + + const newWallet = await service.createMultichainAccountWallet({ + password, + type: 'create', + }); + + expect(newWallet).toBeDefined(); + expect(newWallet.entropySource).toBe(MOCK_HD_KEYRING_1.metadata.id); + }); + }); + + describe('createWalletByRestore', () => { + it('creates a new multichain account wallet by the restore flow', async () => { + const { service, mocks, rootMessenger } = await setup({ + accounts: [], + keyrings: [], + }); + + const mnemonic = mnemonicPhraseToBytes(MOCK_MNEMONIC); + const password = 'password'; + + mocks.KeyringController.createNewVaultAndRestore.mockImplementationOnce( + () => { + mocks.KeyringController.keyrings.push(MOCK_HD_KEYRING_1); + }, + ); + + rootMessenger.registerActionHandler( + 'KeyringController:withKeyringV2', + async (_, operation) => { + const newKeyring = mocks.KeyringController.keyrings.find( + (keyring) => keyring.type === 'HD Key Tree', + ) as KeyringObject; + return operation({ + keyring: {} as unknown as Keyring, + metadata: newKeyring.metadata, + }); + }, + ); + + mocks.EvmAccountProvider.createAccounts.mockResolvedValueOnce([ + MOCK_HD_ACCOUNT_1, + ]); + + const newWallet = await service.createMultichainAccountWallet({ + password, + mnemonic, + type: 'restore', + }); + + expect(newWallet).toBeDefined(); + expect(newWallet.entropySource).toBe(MOCK_HD_KEYRING_1.metadata.id); + }); + }); + }); +}); diff --git a/packages/multichain-account-service/src/MultichainAccountService.ts b/packages/multichain-account-service/src/MultichainAccountService.ts new file mode 100644 index 00000000000..ccf02c63e79 --- /dev/null +++ b/packages/multichain-account-service/src/MultichainAccountService.ts @@ -0,0 +1,801 @@ +import { + isBip44Account, + toMultichainAccountWalletId, +} from '@metamask/account-api'; +import type { + MultichainAccountWalletId, + Bip44Account, +} from '@metamask/account-api'; +import type { TraceCallback } from '@metamask/controller-utils'; +import type { HdKeyring } from '@metamask/eth-hd-keyring'; +import type { EntropySourceId, KeyringAccount } from '@metamask/keyring-api'; +import { KeyringTypes } from '@metamask/keyring-controller'; +import type { InternalAccount } from '@metamask/keyring-internal-api'; +import { areUint8ArraysEqual, assert } from '@metamask/utils'; + +import { traceFallback } from './analytics/index.js'; +import { isPerfEnabled, withLocalPerfTrace } from './analytics/perf.js'; +import { reportError } from './errors.js'; +import { projectLogger as log } from './logger.js'; +import type { MultichainAccountGroup } from './MultichainAccountGroup.js'; +import { MultichainAccountWallet } from './MultichainAccountWallet.js'; +import { + AccountProviderWrapper, + isAccountProviderWrapper, +} from './providers/AccountProviderWrapper.js'; +import { EvmAccountProvider } from './providers/EvmAccountProvider.js'; +import { + EvmAccountProviderConfig, + Bip44AccountProvider, + EVM_ACCOUNT_PROVIDER_NAME, + BtcAccountProviderConfig, + TrxAccountProviderConfig, + BTC_ACCOUNT_PROVIDER_NAME, + TRX_ACCOUNT_PROVIDER_NAME, + BtcAccountProvider, + TrxAccountProvider, +} from './providers/index.js'; +import { SolAccountProvider } from './providers/SolAccountProvider.js'; +import { + SOL_ACCOUNT_PROVIDER_NAME, + SolAccountProviderConfig, +} from './providers/SolAccountProvider.js'; +import type { + MultichainAccountServiceConfig, + MultichainAccountServiceMessenger, +} from './types.js'; +import { toErrorMessage } from './utils.js'; + +/** + * Per-account failure detail attached to the aggregated Sentry report + * produced by {@link MultichainAccountService.removeMultichainAccountWallet}. + * + * Names the on-the-wire shape so consumers reading the Sentry context (and + * tests asserting on it) have one place to look. + */ +export type RemoveMultichainAccountWalletFailure = { + provider: string; + // Omitted for provider-level failures (e.g. enumerating a provider's + // accounts threw before any specific account could be targeted). + accountId?: Bip44Account['id']; + error: unknown; +}; + +/** + * Aggregated context payload attached to the Sentry report produced by + * {@link MultichainAccountService.removeMultichainAccountWallet} when one + * or more per-account deletions fail. + */ +export type RemoveMultichainAccountWalletFailureContext = { + failures: RemoveMultichainAccountWalletFailure[]; +}; + +export const serviceName = 'MultichainAccountService'; + +/** + * The options that {@link MultichainAccountService} takes. + */ +export type MultichainAccountServiceOptions = { + messenger: MultichainAccountServiceMessenger; + providers?: Bip44AccountProvider[]; + providerConfigs?: { + [EVM_ACCOUNT_PROVIDER_NAME]?: EvmAccountProviderConfig; + [SOL_ACCOUNT_PROVIDER_NAME]?: SolAccountProviderConfig; + [BTC_ACCOUNT_PROVIDER_NAME]?: BtcAccountProviderConfig; + [TRX_ACCOUNT_PROVIDER_NAME]?: TrxAccountProviderConfig; + }; + config?: MultichainAccountServiceConfig; +}; + +/** + * The keys used to identify an account in the service state. + */ +export type StateKeys = { + entropySource: EntropySourceId; + groupIndex: number; + providerName: string; +}; + +/** + * The service state. + */ +export type ServiceState = { + [entropySource: StateKeys['entropySource']]: { + [groupIndex: string]: { + [providerName: StateKeys['providerName']]: Bip44Account['id'][]; + }; + }; +}; + +export type CreateWalletParams = + | { + type: 'restore'; + password: string; + mnemonic: Uint8Array; + } + | { + type: 'import'; + mnemonic: Uint8Array; + } + | { + type: 'create'; + password: string; + }; + +const MESSENGER_EXPOSED_METHODS = [ + 'getMultichainAccountGroup', + 'getMultichainAccountGroups', + 'getMultichainAccountWallet', + 'getMultichainAccountWallets', + 'createNextMultichainAccountGroup', + 'createMultichainAccountGroup', + 'createMultichainAccountGroups', + 'setBasicFunctionality', + 'alignWallets', + 'alignWallet', + 'createMultichainAccountWallet', + 'resyncAccounts', + 'removeMultichainAccountWallet', + 'init', +] as const; + +/** + * Service to expose multichain accounts capabilities. + */ +export class MultichainAccountService { + readonly #messenger: MultichainAccountServiceMessenger; + + readonly #providers: Bip44AccountProvider[]; + + readonly #trace: TraceCallback; + + readonly #wallets: Map< + MultichainAccountWalletId, + MultichainAccountWallet> + >; + + /** + * The name of the service. + */ + name: typeof serviceName = serviceName; + + /** + * Constructs a new MultichainAccountService. + * + * @param options - The options. + * @param options.messenger - The messenger suited to this + * MultichainAccountService. + * @param options.providers - Optional list of account + * @param options.providerConfigs - Optional provider configs + * @param options.config - Optional config. + */ + constructor({ + messenger, + providers = [], + providerConfigs, + config, + }: MultichainAccountServiceOptions) { + this.#messenger = messenger; + this.#wallets = new Map(); + + // Pass trace callback directly to preserve original 'this' context. + // This avoids binding the callback to the MultichainAccountService instance. + let trace: TraceCallback = config?.trace ?? traceFallback; + + // Wrap the trace callback with local performance tracing if performance logging is enabled. + if (isPerfEnabled()) { + trace = withLocalPerfTrace(trace); + } + + // This trace is passed down to wallets and providers to be used for tracing operations within them. + this.#trace = trace; + + // TODO: Rely on keyring capabilities once the keyring API is used by all keyrings. + this.#providers = [ + new EvmAccountProvider( + this.#messenger, + providerConfigs?.[EVM_ACCOUNT_PROVIDER_NAME], + trace, + ), + new AccountProviderWrapper( + this.#messenger, + new SolAccountProvider( + this.#messenger, + providerConfigs?.[SOL_ACCOUNT_PROVIDER_NAME], + trace, + ), + ), + new AccountProviderWrapper( + this.#messenger, + new BtcAccountProvider( + this.#messenger, + providerConfigs?.[BTC_ACCOUNT_PROVIDER_NAME], + trace, + ), + ), + new AccountProviderWrapper( + this.#messenger, + new TrxAccountProvider( + this.#messenger, + providerConfigs?.[TRX_ACCOUNT_PROVIDER_NAME], + trace, + ), + ), + // Custom account providers that can be provided by the MetaMask client. + ...providers, + ]; + + this.#messenger.registerMethodActionHandlers( + this, + MESSENGER_EXPOSED_METHODS, + ); + } + + /** + * Get the keys used to identify an account in the service state. + * + * @param account - The account to get the keys for. + * @returns The keys used to identify an account in the service state. + * Returns null if the account is not compatible with any provider. + */ + #getStateKeys(account: InternalAccount): StateKeys | null { + for (const provider of this.#providers) { + if (isBip44Account(account) && provider.isAccountCompatible(account)) { + return { + entropySource: account.options.entropy.id, + groupIndex: account.options.entropy.groupIndex, + providerName: provider.getName(), + }; + } + } + return null; + } + + /** + * Construct the service and provider state. + * + * @returns The service and provider state. + */ + #constructServiceState(): { + serviceState: ServiceState; + providerState: Record['id'][]>; + } { + const accounts = this.#messenger.call( + 'AccountsController:listMultichainAccounts', + ); + + const serviceState: ServiceState = {}; + + const providerState: Record['id'][]> = + {}; + + for (const account of accounts) { + const keys = this.#getStateKeys(account); + if (keys) { + const { entropySource, groupIndex, providerName } = keys; + serviceState[entropySource] ??= {}; + serviceState[entropySource][groupIndex] ??= {}; + serviceState[entropySource][groupIndex][providerName] ??= []; + serviceState[entropySource][groupIndex][providerName].push(account.id); + providerState[providerName] ??= []; + providerState[providerName].push(account.id); + } + } + return { serviceState, providerState }; + } + + /** + * Initialize the service and constructs the internal reprensentation of + * multichain accounts and wallets. + */ + async init(): Promise { + log('Initializing...'); + + this.#wallets.clear(); + + const { serviceState, providerState } = this.#constructServiceState(); + + for (const provider of this.#providers) { + const providerName = provider.getName(); + // Initialize providers even if there are no accounts yet. + // Passing an empty array ensures providers start in a valid state. + const state = providerState[providerName] ?? []; + provider.init(state); + } + + for (const entropySource of Object.keys(serviceState)) { + const wallet = new MultichainAccountWallet({ + entropySource, + providers: this.#providers, + messenger: this.#messenger, + trace: this.#trace, + }); + wallet.init(serviceState[entropySource]); + this.#wallets.set(wallet.id, wallet); + } + + log('Initialized'); + } + + /** + * Re-synchronize MetaMask accounts and the providers accounts if needed. + * + * NOTE: This is mostly required if one of the providers (keyrings or Snaps) + * have different sets of accounts. This method would ensure that both are + * in-sync and use the same accounts (and same IDs). + * + * READ THIS CAREFULLY (State inconsistency bugs/de-sync) + * We've seen some problems were keyring accounts on some Snaps were not synchronized + * with the accounts on MM side. This causes problems where we cannot interact with + * those accounts because the Snap does know about them. + * To "workaround" this de-sync problem for now, we make sure that both parties are + * in-sync when the service boots up. + * ---------------------------------------------------------------------------------- + */ + async resyncAccounts(): Promise { + log('Re-sync provider accounts if needed...'); + const accounts = this.#messenger + .call('AccountsController:listMultichainAccounts') + .filter(isBip44Account); + // We use `Promise.all` + `try-catch` combo, since we don't wanna block the wallet + // from being used even if some accounts are not sync (best-effort). + await Promise.all( + this.#providers.map(async (provider) => { + try { + await provider.resyncAccounts(accounts); + } catch (error) { + reportError( + this.#messenger, + `Unable to re-sync provider "${provider.getName()}"`, + error, + { + provider: provider.getName(), + }, + ); + } + }), + ); + log('Providers got re-synced!'); + } + + /** + * Get the wallet matching the given entropy source. + * + * @param entropySource - The entropy source of the wallet. + * @returns The wallet matching the given entropy source. + * @throws If no wallet matches the given entropy source. + */ + #getWallet( + entropySource: EntropySourceId, + ): MultichainAccountWallet> { + const wallet = this.#wallets.get( + toMultichainAccountWalletId(entropySource), + ); + + if (!wallet) { + throw new Error('Unknown wallet, no wallet matching this entropy source'); + } + + return wallet; + } + + /** + * Gets a reference to the multichain account wallet matching this entropy source. + * + * @param options - Options. + * @param options.entropySource - The entropy source of the multichain account. + * @throws If none multichain account match this entropy. + * @returns A reference to the multichain account wallet. + */ + getMultichainAccountWallet({ + entropySource, + }: { + entropySource: EntropySourceId; + }): MultichainAccountWallet> { + return this.#getWallet(entropySource); + } + + /** + * Gets an array of all multichain account wallets. + * + * @returns An array of all multichain account wallets. + */ + getMultichainAccountWallets(): MultichainAccountWallet< + Bip44Account + >[] { + return Array.from(this.#wallets.values()); + } + + #getPrimaryEntropySourceId(): EntropySourceId { + const { keyrings } = this.#messenger.call('KeyringController:getState'); + const primaryKeyring = keyrings.find( + (keyring) => keyring.type === KeyringTypes.hd, + ); + assert(primaryKeyring, 'Primary keyring not found'); + return primaryKeyring.metadata.id; + } + + /** + * Creates a new multichain account wallet by importing an existing mnemonic. + * + * @param mnemonic - The mnemonic to use to create the new wallet. + * @returns The new multichain account wallet. + */ + async #createWalletByImport( + mnemonic: Uint8Array, + ): Promise>> { + log(`Creating new wallet by importing an existing mnemonic...`); + const existingKeyrings = this.#messenger.call( + 'KeyringController:getKeyringsByType', + KeyringTypes.hd, + ) as HdKeyring[]; + + const alreadyHasImportedSrp = existingKeyrings.some((keyring) => { + if (!keyring.mnemonic) { + return false; + } + return areUint8ArraysEqual(keyring.mnemonic, mnemonic); + }); + + if (alreadyHasImportedSrp) { + throw new Error('This Secret Recovery Phrase has already been imported.'); + } + + const result = await this.#messenger.call( + 'KeyringController:addNewKeyring', + KeyringTypes.hd, + { mnemonic, numberOfAccounts: 1 }, + ); + + return new MultichainAccountWallet({ + providers: this.#providers, + entropySource: result.id, + messenger: this.#messenger, + trace: this.#trace, + }); + } + + /** + * Creates a new multichain account wallet by creating a new vault and keychain. + * + * @param password - The password to encrypt the vault with. + * @returns The new multichain account wallet. + */ + async #createWalletByNewVault( + password: string, + ): Promise>> { + log(`Creating new wallet by creating a new vault and keychain...`); + await this.#messenger.call( + 'KeyringController:createNewVaultAndKeychain', + password, + ); + + const entropySourceId = this.#getPrimaryEntropySourceId(); + + return new MultichainAccountWallet({ + providers: this.#providers, + entropySource: entropySourceId, + messenger: this.#messenger, + trace: this.#trace, + }); + } + + /** + * Creates a new multichain account wallet by restoring a vault and keyring. + * + * @param password - The password to encrypt the vault with. + * @param mnemonic - The mnemonic to use to restore the new wallet. + * @returns The new multichain account wallet. + */ + async #createWalletByRestore( + password: string, + mnemonic: Uint8Array, + ): Promise>> { + log(`Creating new wallet by restoring vault and keyring...`); + await this.#messenger.call( + 'KeyringController:createNewVaultAndRestore', + password, + mnemonic, + ); + + const entropySourceId = this.#getPrimaryEntropySourceId(); + + return new MultichainAccountWallet({ + providers: this.#providers, + entropySource: entropySourceId, + messenger: this.#messenger, + trace: this.#trace, + }); + } + + /** + * Creates a new multichain account wallet by either importing an existing mnemonic, + * creating a new vault and keychain, or restoring a vault and keyring. + * + * NOTE: This method should only be called in client code where a mutex lock is acquired. + * `discoverAccounts` should be called after this method to discover and create accounts. + * + * @param params - The parameters to use to create the new wallet. + * @param params.mnemonic - The mnemonic to use to create the new wallet. + * @param params.password - The password to encrypt the vault with. + * @param params.type - The flow type to use to create the new wallet. + * @throws If the mnemonic has already been imported. + * @returns The new multichain account wallet. + */ + async createMultichainAccountWallet( + params: CreateWalletParams, + ): Promise>> { + let wallet: + | MultichainAccountWallet> + | undefined; + + if (params.type === 'import') { + wallet = await this.#createWalletByImport(params.mnemonic); + } else if (params.type === 'create') { + wallet = await this.#createWalletByNewVault(params.password); + } else if (params.type === 'restore') { + wallet = await this.#createWalletByRestore( + params.password, + params.mnemonic, + ); + } + + assert(wallet, 'Failed to create wallet.'); + + wallet.init({}); + // READ THIS CAREFULLY: + // We do not await for non-EVM account creations as they + // are depending on the Snap platform to be ready (which is, waiting for onboarding to be completed). + // Awaiting for this might cause a deadlock otherwise (during onboarding at least). + await wallet.createMultichainAccountGroup(0, { + waitForAllProvidersToFinishCreatingAccounts: false, + }); + + this.#wallets.set(wallet.id, wallet); + + log(`Wallet created: [${wallet.id}]`); + + return wallet; + } + + /** + * Removes a multichain account wallet, deleting all of its accounts across + * every registered provider (EVM and snap-based). + * + * The deletion iterates providers (the source of truth for their own + * account lists) and filters each provider's accounts to those matching + * the wallet's entropy source. Cleanup is best-effort end-to-end: neither + * a single account deletion failure nor a failure to enumerate a given + * provider's accounts aborts cleanup of the remaining providers. If one or + * more operations fail, a single aggregated error is reported via + * `reportError` with all per-failure details in its context. The wallet is + * always removed from the service's internal map at the end. + * + * @param entropySource - The entropy source of the multichain account wallet. + */ + async removeMultichainAccountWallet( + entropySource: EntropySourceId, + ): Promise { + const wallet = this.#getWallet(entropySource); + const failures: RemoveMultichainAccountWalletFailure[] = []; + + for (const provider of this.#providers) { + // Enumerating a provider's owned accounts can itself throw (e.g. + // `unwrap()`, `getAccounts()`, or reading account options). Catch it as + // a provider-level failure and move on so one bad provider does not + // abort cleanup of the others or skip the always-remove step below. + let owned: Bip44Account[]; + try { + // For wrapped providers, enumerate via the underlying provider so we + // also see accounts when the wrapper has been disabled (i.e. basic + // functionality is off). The wrapper's `deleteAccount` itself forwards + // unconditionally, but its `getAccounts()` returns `[]` when disabled, + // which would otherwise leave snap-backed accounts orphaned in their + // underlying keyrings. + const source = isAccountProviderWrapper(provider) + ? provider.unwrap() + : provider; + owned = source + .getAccounts() + .filter((account) => account.options.entropy.id === entropySource); + } catch (error) { + failures.push({ + provider: provider.getName(), + error, + }); + continue; + } + + for (const account of owned) { + try { + await provider.deleteAccount(account.id); + } catch (error) { + failures.push({ + provider: provider.getName(), + accountId: account.id, + error, + }); + } + } + } + + if (failures.length > 0) { + // One aggregated report per wallet-removal action: keeps the Sentry + // message stable for grouping while still surfacing every per-account + // failure in `context`. The shape is pinned by + // `RemoveMultichainAccountWalletFailureContext`. + const context: RemoveMultichainAccountWalletFailureContext = { + failures: failures.map(({ provider, accountId, error }) => ({ + provider, + accountId, + error: toErrorMessage(error), + })), + }; + reportError( + this.#messenger, + `Failed to delete one or more accounts during wallet removal`, + new Error('Wallet removal partially failed'), + context, + ); + } + + this.#wallets.delete(wallet.id); + } + + /** + * Gets a reference to the multichain account group matching this entropy source + * and a group index. + * + * @param options - Options. + * @param options.entropySource - The entropy source of the multichain account. + * @param options.groupIndex - The group index of the multichain account. + * @throws If none multichain account match this entropy source and group index. + * @returns A reference to the multichain account. + */ + getMultichainAccountGroup({ + entropySource, + groupIndex, + }: { + entropySource: EntropySourceId; + groupIndex: number; + }): MultichainAccountGroup> { + const multichainAccount = + this.#getWallet(entropySource).getMultichainAccountGroup(groupIndex); + + if (!multichainAccount) { + throw new Error(`No multichain account for index: ${groupIndex}`); + } + + return multichainAccount; + } + + /** + * Gets all multichain account groups for a given entropy source. + * + * @param options - Options. + * @param options.entropySource - The entropy source to query. + * @throws If no multichain accounts match this entropy source. + * @returns A list of all multichain accounts. + */ + getMultichainAccountGroups({ + entropySource, + }: { + entropySource: EntropySourceId; + }): MultichainAccountGroup>[] { + return this.#getWallet(entropySource).getMultichainAccountGroups(); + } + + /** + * Creates the next multichain account group. + * + * @param options - Options. + * @param options.entropySource - The wallet's entropy source. + * @returns The next multichain account group. + */ + async createNextMultichainAccountGroup({ + entropySource, + }: { + entropySource: EntropySourceId; + }): Promise>> { + return await this.#getWallet( + entropySource, + ).createNextMultichainAccountGroup(); + } + + /** + * Creates a multichain account group. + * + * @param options - Options. + * @param options.groupIndex - The group index to use. + * @param options.entropySource - The wallet's entropy source. + * @returns The multichain account group for this group index. + */ + async createMultichainAccountGroup({ + groupIndex, + entropySource, + }: { + groupIndex: number; + entropySource: EntropySourceId; + }): Promise>> { + return await this.#getWallet(entropySource).createMultichainAccountGroup( + groupIndex, + ); + } + + /** + * Creates multiple multichain account groups up to maxGroupIndex. + * + * @param params - Parameters for creating account groups. + * @param params.fromGroupIndex - Starting group index to create (inclusive) (defaults to 0). + * @param params.toGroupIndex - Maximum group index to create (inclusive). + * @param params.entropySource - The entropy source ID. + * @returns Array of created multichain account groups. + */ + async createMultichainAccountGroups({ + fromGroupIndex = 0, + toGroupIndex, + entropySource, + }: { + fromGroupIndex?: number; + toGroupIndex: number; + entropySource: EntropySourceId; + }): Promise>[]> { + return await this.#getWallet(entropySource).createMultichainAccountGroups( + { from: fromGroupIndex, to: toGroupIndex }, + { waitForAllProvidersToFinishCreatingAccounts: false }, + ); + } + + /** + * Set basic functionality state and trigger alignment if enabled. + * When basic functionality is disabled, snap-based providers are disabled. + * When enabled, all snap providers are enabled and wallet alignment is triggered. + * EVM providers are never disabled as they're required for basic wallet functionality. + * + * @param enabled - Whether basic functionality is enabled. + */ + async setBasicFunctionality(enabled: boolean): Promise { + log(`Turning basic functionality: ${enabled ? 'ON' : 'OFF'}`); + + // Loop through providers and enable/disable only wrapped ones when basic functionality changes + for (const provider of this.#providers) { + if (isAccountProviderWrapper(provider)) { + log( + `${enabled ? 'Enabling' : 'Disabling'} account provider: "${provider.getName()}"`, + ); + provider.setEnabled(enabled); + } + // Regular providers (like EVM) are never disabled for basic functionality + } + + // Trigger alignment only when basic functionality is enabled + if (enabled) { + await this.alignWallets(); + } + } + + /** + * Align all multichain account wallets. + */ + async alignWallets(): Promise { + log(`Triggering alignment on all wallets...`); + + const wallets = this.getMultichainAccountWallets(); + await Promise.all(wallets.map((w) => w.alignAccounts())); + + log(`Wallets aligned`); + } + + /** + * Align a specific multichain account wallet. + * + * @param entropySource - The entropy source of the multichain account wallet. + */ + async alignWallet(entropySource: EntropySourceId): Promise { + const wallet = this.getMultichainAccountWallet({ entropySource }); + + log(`Triggering alignment for wallet: [${wallet.id}]`); + await wallet.alignAccounts(); + log(`Wallet [${wallet.id}] aligned`); + } +} diff --git a/packages/multichain-account-service/src/MultichainAccountWallet.test.ts b/packages/multichain-account-service/src/MultichainAccountWallet.test.ts new file mode 100644 index 00000000000..40aa3285b97 --- /dev/null +++ b/packages/multichain-account-service/src/MultichainAccountWallet.test.ts @@ -0,0 +1,1366 @@ +import type { Bip44Account } from '@metamask/account-api'; +import { + AccountWalletType, + toAccountGroupId, + toDefaultAccountGroupId, + toMultichainAccountGroupId, + toMultichainAccountWalletId, +} from '@metamask/account-api'; +import type { EntropySourceId } from '@metamask/keyring-api'; +import { + EthAccountType, + SolAccountType, + KeyringAccountEntropyTypeOption, + AccountCreationType, +} from '@metamask/keyring-api'; +import type { InternalAccount } from '@metamask/keyring-internal-api'; +import { createDeferredPromise } from '@metamask/utils'; + +import type { WalletState } from './MultichainAccountWallet.js'; +import { MultichainAccountWallet } from './MultichainAccountWallet.js'; +import { TimeoutError } from './providers/index.js'; +import type { MockAccountProvider, RootMessenger } from './tests/index.js'; +import { + MOCK_HD_ACCOUNT_1, + MOCK_HD_KEYRING_1, + MOCK_SNAP_ACCOUNT_2, + MOCK_SOL_ACCOUNT_1, + MOCK_WALLET_1_BTC_P2TR_ACCOUNT, + MOCK_WALLET_1_BTC_P2WPKH_ACCOUNT, + MOCK_WALLET_1_ENTROPY_SOURCE, + MOCK_WALLET_1_EVM_ACCOUNT, + MOCK_WALLET_1_SOL_ACCOUNT, + MockAccountBuilder, + mockCreateAccountsOnce, + setupBip44AccountProvider, + getMultichainAccountServiceMessenger, + getRootMessenger, +} from './tests/index.js'; +import type { MultichainAccountServiceMessenger } from './types.js'; + +function setup({ + entropySource = MOCK_WALLET_1_ENTROPY_SOURCE, + messenger = getRootMessenger(), + providers, + accounts = [ + [MOCK_WALLET_1_EVM_ACCOUNT], + [ + MOCK_WALLET_1_SOL_ACCOUNT, + MOCK_WALLET_1_BTC_P2WPKH_ACCOUNT, + MOCK_WALLET_1_BTC_P2TR_ACCOUNT, + MOCK_SNAP_ACCOUNT_2, // Non-BIP-44 account. + ], + ], +}: { + entropySource?: EntropySourceId; + messenger?: RootMessenger; + providers?: MockAccountProvider[]; + accounts?: InternalAccount[][]; +} = {}): { + wallet: MultichainAccountWallet>; + providers: MockAccountProvider[]; + messenger: MultichainAccountServiceMessenger; +} { + const providersList = + providers ?? + accounts.map((providerAccounts, i) => { + return setupBip44AccountProvider({ + name: `Mocked Provider ${i}`, + accounts: providerAccounts, + index: i, + }); + }); + + const serviceMessenger = getMultichainAccountServiceMessenger(messenger); + + const wallet = new MultichainAccountWallet>({ + entropySource, + providers: providersList, + messenger: serviceMessenger, + }); + + const walletState = accounts.reduce( + (state, providerAccounts, idx) => { + const providerName = providersList[idx].getName(); + for (const account of providerAccounts) { + if ( + 'options' in account && + account.options?.entropy?.type === + KeyringAccountEntropyTypeOption.Mnemonic + ) { + const groupIndexKey = account.options.entropy.groupIndex; + state[groupIndexKey] ??= {}; + const groupState = state[groupIndexKey]; + groupState[providerName] ??= []; + groupState[providerName].push(account.id); + } + } + return state; + }, + {}, + ); + + wallet.init(walletState); + + return { wallet, providers: providersList, messenger: serviceMessenger }; +} + +async function waitForOtherProvidersToHaveBeenCalled( + providers: MockAccountProvider[] = [], +): Promise { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + for (const provider of providers) { + await new Promise((resolve) => { + setTimeout(() => resolve(), 0); + }); + } +} + +describe('MultichainAccountWallet', () => { + afterEach(() => { + jest.clearAllTimers(); + jest.useRealTimers(); + jest.restoreAllMocks(); + }); + + describe('constructor', () => { + it('constructs a multichain account wallet', () => { + const entropySource = MOCK_WALLET_1_ENTROPY_SOURCE; + const { wallet } = setup({ + entropySource, + }); + + const expectedWalletId = toMultichainAccountWalletId(entropySource); + expect(wallet.id).toStrictEqual(expectedWalletId); + expect(wallet.status).toBe('ready'); + expect(wallet.type).toBe(AccountWalletType.Entropy); + expect(wallet.entropySource).toStrictEqual(entropySource); + expect(wallet.getMultichainAccountGroups()).toHaveLength(1); // All internal accounts are using index 0, so it means only 1 multichain account. + }); + }); + + describe('getMultichainAccountGroup', () => { + it('gets a multichain account group from its index', () => { + const { wallet } = setup(); + + const groupIndex = 0; + const multichainAccountGroup = + wallet.getMultichainAccountGroup(groupIndex); + expect(multichainAccountGroup).toBeDefined(); + expect(multichainAccountGroup?.groupIndex).toBe(groupIndex); + + // We can still get a multichain account group as a "basic" account group too. + const group = wallet.getAccountGroup( + toMultichainAccountGroupId(wallet.id, groupIndex), + ); + expect(group).toBeDefined(); + expect(group?.id).toBe(multichainAccountGroup?.id); + }); + }); + + describe('getAccountGroup', () => { + it('gets the default multichain account group', () => { + const { wallet } = setup(); + + const group = wallet.getAccountGroup(toDefaultAccountGroupId(wallet.id)); + expect(group).toBeDefined(); + expect(group?.id).toBe(toMultichainAccountGroupId(wallet.id, 0)); + }); + + it('gets a multichain account group when using a multichain account group id', () => { + const { wallet } = setup(); + + const group = wallet.getAccountGroup(toDefaultAccountGroupId(wallet.id)); + expect(group).toBeDefined(); + expect(group?.id).toBe(toMultichainAccountGroupId(wallet.id, 0)); + }); + + it('returns undefined when using a bad multichain account group id', () => { + const { wallet } = setup(); + + const group = wallet.getAccountGroup(toAccountGroupId(wallet.id, 'bad')); + expect(group).toBeUndefined(); + }); + }); + + describe('createMultichainAccountGroup', () => { + it('creates a multichain account group for a given index (waitForAllProvidersToFinishCreatingAccounts = false)', async () => { + const groupIndex = 0; + + const { wallet, providers } = setup({ + accounts: [[], []], // 2 providers: EVM + SOL + }); + + const [evmProvider, solProvider] = providers; + const mockNextEvmAccount = MockAccountBuilder.from(MOCK_HD_ACCOUNT_1) + .withEntropySource(MOCK_HD_KEYRING_1.metadata.id) + .withGroupIndex(groupIndex) + .get(); + evmProvider.createAccounts.mockResolvedValueOnce([mockNextEvmAccount]); + evmProvider.getAccounts.mockReturnValueOnce([mockNextEvmAccount]); + evmProvider.getAccount.mockReturnValueOnce(mockNextEvmAccount); + + // By default (wait=false), only the EVM provider creates the group immediately. + // Non-EVM account creation is deferred via fire-and-forget alignAccounts, which + // uses the batch Bip44DeriveIndexRange API. + const specificGroup = + await wallet.createMultichainAccountGroup(groupIndex); + expect(specificGroup.groupIndex).toBe(groupIndex); + + // EVM provider is called during group creation. + expect(evmProvider.createAccounts).toHaveBeenCalled(); + + // Alignment fires as fire-and-forget, so wait for this. + await waitForOtherProvidersToHaveBeenCalled([solProvider]); + + expect(solProvider.createAccounts).toHaveBeenCalledWith({ + type: AccountCreationType.Bip44DeriveIndexRange, + entropySource: wallet.entropySource, + range: { from: groupIndex, to: groupIndex }, + }); + }); + + it('returns the same reference when re-creating using the same index (waitForAllProvidersToFinishCreatingAccounts = false)', async () => { + const { wallet } = setup({ + accounts: [[MOCK_HD_ACCOUNT_1]], + }); + + const group = wallet.getMultichainAccountGroup(0); + const newGroup = await wallet.createMultichainAccountGroup(0); + + expect(newGroup).toBe(group); + }); + + it('fails to create an account beyond the next index (waitForAllProvidersToFinishCreatingAccounts = false)', async () => { + const { wallet } = setup({ + accounts: [[MOCK_HD_ACCOUNT_1]], + }); + + const groupIndex = 10; + await expect( + wallet.createMultichainAccountGroup(groupIndex), + ).rejects.toThrow( + `Bad group index, groupIndex (${groupIndex}) cannot be higher than the next available one (<= 1)`, + ); + }); + + it('does not create an account group if only some of the providers fail to create its account (waitForAllProvidersToFinishCreatingAccounts = true)', async () => { + const groupIndex = 1; + + // Baseline accounts at index 0 for two providers + const mockEvmAccount = MockAccountBuilder.from(MOCK_HD_ACCOUNT_1) + .withEntropySource(MOCK_HD_KEYRING_1.metadata.id) + .withGroupIndex(0) + .get(); + const mockSolAccount = MockAccountBuilder.from(MOCK_SOL_ACCOUNT_1) + .withEntropySource(MOCK_HD_KEYRING_1.metadata.id) + .withGroupIndex(0) + .get(); + + const { wallet, providers } = setup({ + accounts: [[mockEvmAccount], [mockSolAccount]], // 2 providers + }); + + const [succeedingProvider, failingProvider] = providers; + + // Arrange: first provider fails, second succeeds creating one account at index 1 + failingProvider.createAccounts.mockRejectedValueOnce( + new Error('Unable to create accounts'), + ); + + const mockNextEvmAccount = MockAccountBuilder.from(mockEvmAccount) + .withEntropySource(MOCK_HD_KEYRING_1.metadata.id) + .withGroupIndex(groupIndex) + .get(); + + succeedingProvider.createAccounts.mockResolvedValueOnce([ + mockNextEvmAccount, + ]); + + succeedingProvider.getAccounts.mockReturnValueOnce([mockNextEvmAccount]); + succeedingProvider.getAccount.mockReturnValueOnce(mockNextEvmAccount); + + await expect( + wallet.createMultichainAccountGroup(groupIndex, { + waitForAllProvidersToFinishCreatingAccounts: true, + }), + ).rejects.toThrow('Unable to create accounts'); + }); + + it('captures an error when a provider fails to create its account', async () => { + const groupIndex = 1; + const { wallet, providers, messenger } = setup({ + accounts: [[MOCK_HD_ACCOUNT_1]], + }); + const [provider] = providers; + const providerError = new Error('Unable to create accounts'); + provider.createAccounts.mockRejectedValueOnce(providerError); + const captureExceptionSpy = jest.spyOn(messenger, 'captureException'); + await expect( + wallet.createMultichainAccountGroup(groupIndex), + ).rejects.toThrow('Unable to create accounts'); + expect(captureExceptionSpy).toHaveBeenCalledWith( + new Error( + 'Unable to create some accounts with provider "Mocked Provider 0"', + ), + ); + expect(captureExceptionSpy.mock.lastCall[0]).toHaveProperty( + 'cause', + providerError, + ); + }); + + it('does not capture exception when a provider times out creating accounts', async () => { + const groupIndex = 1; + const { wallet, providers, messenger } = setup({ + accounts: [[MOCK_HD_ACCOUNT_1]], + }); + const [provider] = providers; + provider.createAccounts.mockRejectedValueOnce( + new TimeoutError('Timed out after: 500ms'), + ); + const captureExceptionSpy = jest.spyOn(messenger, 'captureException'); + const consoleWarnSpy = jest.spyOn(console, 'warn').mockImplementation(); + const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(); + await expect( + wallet.createMultichainAccountGroup(groupIndex), + ).rejects.toThrow('Timed out after: 500ms'); + expect(captureExceptionSpy).not.toHaveBeenCalled(); + expect(consoleWarnSpy).toHaveBeenCalled(); + expect(consoleErrorSpy).not.toHaveBeenCalled(); + consoleWarnSpy.mockRestore(); + consoleErrorSpy.mockRestore(); + }); + + it('defers non-EVM account creation to alignment after group creation (waitForAllProvidersToFinishCreatingAccounts = false)', async () => { + const groupIndex = 1; + + const mockEvmAccount0 = MockAccountBuilder.from(MOCK_HD_ACCOUNT_1) + .withEntropySource(MOCK_HD_KEYRING_1.metadata.id) + .withGroupIndex(0) + .get(); + const mockEvmAccount1 = MockAccountBuilder.from(MOCK_HD_ACCOUNT_1) + .withEntropySource(MOCK_HD_KEYRING_1.metadata.id) + .withGroupIndex(groupIndex) + .get(); + + const { wallet, providers } = setup({ + accounts: [[mockEvmAccount0], []], // EVM has group 0, SOL has none + }); + + const [evmProvider, solProvider] = providers; + evmProvider.createAccounts.mockResolvedValueOnce([mockEvmAccount1]); + evmProvider.getAccounts.mockReturnValueOnce([mockEvmAccount1]); + evmProvider.getAccount.mockReturnValueOnce(mockEvmAccount1); + + await wallet.createMultichainAccountGroup(groupIndex); + + // Alignment fires as fire-and-forget, so wait for this. + await waitForOtherProvidersToHaveBeenCalled([solProvider]); + + expect(solProvider.createAccounts).toHaveBeenCalledWith({ + type: AccountCreationType.Bip44DeriveIndexRange, + entropySource: wallet.entropySource, + range: { from: groupIndex, to: groupIndex }, + }); + }); + }); + + describe('createNextMultichainAccountGroup', () => { + it('does not schedule alignment (uses all providers synchronously)', async () => { + const mockEvmAccount = MockAccountBuilder.from(MOCK_HD_ACCOUNT_1) + .withEntropySource(MOCK_HD_KEYRING_1.metadata.id) + .withGroupIndex(0) + .get(); + const mockSolAccount = MockAccountBuilder.from(MOCK_SOL_ACCOUNT_1) + .withEntropySource(MOCK_HD_KEYRING_1.metadata.id) + .withGroupIndex(0) + .get(); + + const { wallet, providers } = setup({ + accounts: [ + [mockEvmAccount], // EVM provider. + [mockSolAccount], // Solana provider. + ], + }); + + const mockNextEvmAccount = MockAccountBuilder.from(mockEvmAccount) + .withEntropySource(MOCK_HD_KEYRING_1.metadata.id) + .withGroupIndex(1) + .get(); + const mockNextSolAccount = MockAccountBuilder.from(mockSolAccount) + .withEntropySource(MOCK_HD_KEYRING_1.metadata.id) + .withGroupIndex(1) + .withUuid() + .get(); + + const [evmAccountProvider, solAccountProvider] = providers; + for (const [mockAccountProvider, mockNextAccount] of [ + [evmAccountProvider, mockNextEvmAccount], + [solAccountProvider, mockNextSolAccount], + ] as const) { + mockAccountProvider.createAccounts.mockResolvedValueOnce([ + mockNextAccount, + ]); + mockAccountProvider.getAccounts.mockReturnValueOnce([mockNextAccount]); + mockAccountProvider.getAccount.mockReturnValueOnce(mockNextAccount); + } + + const alignAccountsOfSpy = jest.spyOn(wallet, 'alignAccountsOf'); + const alignAccountsSpy = jest.spyOn(wallet, 'alignAccounts'); + + await wallet.createNextMultichainAccountGroup(); + + // createNextMultichainAccountGroup uses wait=true, so no alignment is scheduled. + expect(alignAccountsOfSpy).not.toHaveBeenCalled(); + expect(alignAccountsSpy).not.toHaveBeenCalled(); + }); + + it('creates the next multichain account group (with multiple providers)', async () => { + const mockEvmAccount = MockAccountBuilder.from(MOCK_HD_ACCOUNT_1) + .withEntropySource(MOCK_HD_KEYRING_1.metadata.id) + .withGroupIndex(0) + .get(); + const mockSolAccount = MockAccountBuilder.from(MOCK_SOL_ACCOUNT_1) + .withEntropySource(MOCK_HD_KEYRING_1.metadata.id) + .withGroupIndex(0) + .get(); + + const { wallet, providers } = setup({ + accounts: [ + [mockEvmAccount], // EVM provider. + [mockSolAccount], // Solana provider. + ], + }); + + const mockNextEvmAccount = MockAccountBuilder.from(mockEvmAccount) + .withEntropySource(MOCK_HD_KEYRING_1.metadata.id) + .withGroupIndex(1) + .get(); + const mockNextSolAccount = MockAccountBuilder.from(mockSolAccount) + .withEntropySource(MOCK_HD_KEYRING_1.metadata.id) + .withGroupIndex(1) + .withUuid() // Required by KeyringClient. + .get(); + + // We need to mock every call made to the providers when creating an accounts: + const [evmAccountProvider, solAccountProvider] = providers; + for (const [mockAccountProvider, mockNextAccount] of [ + [evmAccountProvider, mockNextEvmAccount], + [solAccountProvider, mockNextSolAccount], + ] as const) { + mockAccountProvider.createAccounts.mockResolvedValueOnce([ + mockNextAccount, + ]); + mockAccountProvider.getAccounts.mockReturnValueOnce([mockNextAccount]); + mockAccountProvider.getAccount.mockReturnValueOnce(mockNextAccount); + } + + const nextGroup = await wallet.createNextMultichainAccountGroup(); + expect(nextGroup.groupIndex).toBe(1); + + const internalAccounts = nextGroup.getAccounts(); + expect(internalAccounts).toHaveLength(2); // EVM + SOL. + expect(internalAccounts[0].type).toBe(EthAccountType.Eoa); + expect(internalAccounts[1].type).toBe(SolAccountType.DataAccount); + expect(wallet.getAccountGroups()).toHaveLength(2); + }); + }); + + describe('createMultichainAccountGroups', () => { + it('creates multiple groups from 0 to maxGroupIndex when no groups exist (waitForAllProvidersToFinishCreatingAccounts = false)', async () => { + const { wallet, providers } = setup({ + accounts: [[], []], + }); + + const [evmProvider, solProvider] = providers; + + // Mock EVM provider to return accounts for groups 0, 1, 2. + const evmAccounts = [ + MockAccountBuilder.from(MOCK_HD_ACCOUNT_1) + .withEntropySource(MOCK_HD_KEYRING_1.metadata.id) + .withGroupIndex(0) + .get(), + MockAccountBuilder.from(MOCK_HD_ACCOUNT_1) + .withEntropySource(MOCK_HD_KEYRING_1.metadata.id) + .withGroupIndex(1) + .get(), + MockAccountBuilder.from(MOCK_HD_ACCOUNT_1) + .withEntropySource(MOCK_HD_KEYRING_1.metadata.id) + .withGroupIndex(2) + .get(), + ]; + evmProvider.createAccounts.mockResolvedValueOnce(evmAccounts); + + // With wait=false (default), only the EVM provider creates accounts immediately. + const groups = await wallet.createMultichainAccountGroups({ to: 2 }); + + expect(groups).toHaveLength(3); + expect(groups[0].groupIndex).toBe(0); + expect(groups[1].groupIndex).toBe(1); + expect(groups[2].groupIndex).toBe(2); + expect(wallet.getAccountGroups()).toHaveLength(3); + + // EVM is called for creation; SOL is called by fire-and-forget alignment + // covering the full batch range via the Bip44DeriveIndexRange API. + expect(evmProvider.createAccounts).toHaveBeenCalled(); + + // Alignment fires as fire-and-forget, so wait for this. + await waitForOtherProvidersToHaveBeenCalled([solProvider]); + + expect(solProvider.createAccounts).toHaveBeenCalledWith({ + type: AccountCreationType.Bip44DeriveIndexRange, + entropySource: wallet.entropySource, + range: { from: 0, to: 2 }, + }); + }); + + it('returns existing groups and creates new ones when some groups already exist (waitForAllProvidersToFinishCreatingAccounts = false)', async () => { + const mockEvmAccount0 = MockAccountBuilder.from(MOCK_HD_ACCOUNT_1) + .withEntropySource(MOCK_HD_KEYRING_1.metadata.id) + .withGroupIndex(0) + .get(); + const mockSolAccount0 = MockAccountBuilder.from(MOCK_SOL_ACCOUNT_1) + .withEntropySource(MOCK_HD_KEYRING_1.metadata.id) + .withGroupIndex(0) + .get(); + + const { wallet, providers } = setup({ + accounts: [[mockEvmAccount0], [mockSolAccount0]], + }); + + const [evmProvider, solProvider] = providers; + + // Mock EVM provider to return accounts for groups 1, 2 (group 0 already exists). + const evmAccounts = [ + MockAccountBuilder.from(MOCK_HD_ACCOUNT_1) + .withEntropySource(MOCK_HD_KEYRING_1.metadata.id) + .withGroupIndex(1) + .get(), + MockAccountBuilder.from(MOCK_HD_ACCOUNT_1) + .withEntropySource(MOCK_HD_KEYRING_1.metadata.id) + .withGroupIndex(2) + .get(), + ]; + evmProvider.createAccounts.mockResolvedValueOnce(evmAccounts); + + // We use a non-resolving mock for SOL provider because we want to verify + // that it's not called during group creation, but rather during the deferred alignment. + const { + promise: mockSolCreateAccountsPromise, + resolve: mockSolCreateAccountsResolve, + } = createDeferredPromise(); + jest + .spyOn(solProvider, 'createAccounts') + .mockImplementationOnce(() => mockSolCreateAccountsPromise); + + // With wait=false (default), only EVM accounts are created immediately. + const groups = await wallet.createMultichainAccountGroups({ to: 2 }); + + // At this point, only EVM provider should have been called to create accounts for groups 1 and 2, but + // the SOL provider is has been scheduled, so it shouldn't block. + expect(groups).toHaveLength(3); + expect(groups[0].groupIndex).toBe(0); // Existing group. + expect(groups[1].groupIndex).toBe(1); // New group. + expect(groups[2].groupIndex).toBe(2); // New group. + expect(wallet.getAccountGroups()).toHaveLength(3); + + // SOL provider is not called during group creation; it's deferred to alignment. + mockSolCreateAccountsResolve(); + await mockSolCreateAccountsPromise; + expect(solProvider.createAccounts).toHaveBeenCalled(); + }); + + it('returns all existing groups when maxGroupIndex is less than nextGroupIndex', async () => { + const mockEvmAccount0 = MockAccountBuilder.from(MOCK_HD_ACCOUNT_1) + .withEntropySource(MOCK_HD_KEYRING_1.metadata.id) + .withGroupIndex(0) + .get(); + const mockEvmAccount1 = MockAccountBuilder.from(MOCK_HD_ACCOUNT_1) + .withEntropySource(MOCK_HD_KEYRING_1.metadata.id) + .withGroupIndex(1) + .get(); + const mockEvmAccount2 = MockAccountBuilder.from(MOCK_HD_ACCOUNT_1) + .withEntropySource(MOCK_HD_KEYRING_1.metadata.id) + .withGroupIndex(2) + .get(); + + const { wallet } = setup({ + accounts: [[mockEvmAccount0, mockEvmAccount1, mockEvmAccount2]], + }); + + // Request groups 0-1 when groups 0-2 exist. + const groups = await wallet.createMultichainAccountGroups({ to: 1 }); + + expect(groups).toHaveLength(2); + expect(groups[0].groupIndex).toBe(0); + expect(groups[1].groupIndex).toBe(1); + // Verify we didn't create any new groups. + expect(wallet.getAccountGroups()).toHaveLength(3); + }); + + it('throws when maxGroupIndex is negative', async () => { + const { wallet } = setup({ + accounts: [[]], + }); + + const badIndex = -1; + await expect( + wallet.createMultichainAccountGroups({ to: badIndex }), + ).rejects.toThrow(`Bad range, to (${badIndex}) must be >= 0`); + }); + + it('captures an error with batch mode message when EVM provider fails', async () => { + const { wallet, providers, messenger } = setup({ + accounts: [[]], + }); + + const [evmProvider] = providers; + const providerError = new Error('EVM provider failed'); + evmProvider.createAccounts.mockRejectedValueOnce(providerError); + + const captureExceptionSpy = jest.spyOn(messenger, 'captureException'); + + await expect( + wallet.createMultichainAccountGroups({ to: 2 }), + ).rejects.toThrow('EVM provider failed'); + + expect(captureExceptionSpy).toHaveBeenCalledWith( + new Error( + 'Unable to create some accounts (batch) with provider "Mocked Provider 0"', + ), + ); + expect(captureExceptionSpy.mock.lastCall[0]).toHaveProperty( + 'cause', + providerError, + ); + }); + + it('does not capture exception when a provider times out creating accounts in batch', async () => { + const { wallet, providers, messenger } = setup({ + accounts: [[]], + }); + + const [evmProvider] = providers; + evmProvider.createAccounts.mockRejectedValueOnce( + new TimeoutError('Timed out after: 500ms'), + ); + + const captureExceptionSpy = jest.spyOn(messenger, 'captureException'); + const consoleWarnSpy = jest.spyOn(console, 'warn').mockImplementation(); + const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(); + + await expect( + wallet.createMultichainAccountGroups({ to: 2 }), + ).rejects.toThrow('Timed out after: 500ms'); + + expect(captureExceptionSpy).not.toHaveBeenCalled(); + expect(consoleWarnSpy).toHaveBeenCalled(); + expect(consoleErrorSpy).not.toHaveBeenCalled(); + consoleWarnSpy.mockRestore(); + consoleErrorSpy.mockRestore(); + }); + + it('creates accounts for all providers synchronously when waitForAllProvidersToFinishCreatingAccounts is true', async () => { + const { wallet, providers } = setup({ + accounts: [[], []], + }); + + const [evmProvider, solProvider] = providers; + + // Mock EVM provider. + const evmAccount = MockAccountBuilder.from(MOCK_HD_ACCOUNT_1) + .withEntropySource(MOCK_HD_KEYRING_1.metadata.id) + .withGroupIndex(0) + .get(); + evmProvider.createAccounts.mockResolvedValueOnce([evmAccount]); + + // Mock SOL provider. + const solAccount = MockAccountBuilder.from(MOCK_SOL_ACCOUNT_1) + .withEntropySource(MOCK_HD_KEYRING_1.metadata.id) + .withGroupIndex(0) + .get(); + solProvider.createAccounts.mockResolvedValueOnce([solAccount]); + + const alignAccountsSpy = jest.spyOn(wallet, 'alignAccounts'); + + const groups = await wallet.createMultichainAccountGroups( + { to: 0 }, + { + waitForAllProvidersToFinishCreatingAccounts: true, + }, + ); + + expect(groups).toHaveLength(1); + expect(groups[0].groupIndex).toBe(0); + + // Both providers are called synchronously; no alignment is scheduled. + expect(evmProvider.createAccounts).toHaveBeenCalled(); + expect(solProvider.createAccounts).toHaveBeenCalled(); + expect(alignAccountsSpy).not.toHaveBeenCalled(); + }); + + it('defers non-EVM account creation to alignment after group creation (waitForAllProvidersToFinishCreatingAccounts = false)', async () => { + const { wallet, providers } = setup({ + accounts: [[], []], + }); + + const [evmProvider, solProvider] = providers; + + const evmAccount = MockAccountBuilder.from(MOCK_HD_ACCOUNT_1) + .withEntropySource(MOCK_HD_KEYRING_1.metadata.id) + .withGroupIndex(0) + .get(); + evmProvider.createAccounts.mockResolvedValueOnce([evmAccount]); + + await wallet.createMultichainAccountGroups({ to: 0 }); + + // Alignment fires as fire-and-forget, so wait for this. + await waitForOtherProvidersToHaveBeenCalled([solProvider]); + + expect(solProvider.createAccounts).toHaveBeenCalledWith({ + type: AccountCreationType.Bip44DeriveIndexRange, + entropySource: wallet.entropySource, + range: { from: 0, to: 0 }, + }); + }); + + it('updates an existing group when created accounts overlap with it (gap scenario)', async () => { + const account0 = MockAccountBuilder.from(MOCK_HD_ACCOUNT_1) + .withEntropySource(MOCK_HD_KEYRING_1.metadata.id) + .withGroupIndex(0) + .get(); + const account2 = MockAccountBuilder.from(MOCK_HD_ACCOUNT_1) + .withEntropySource(MOCK_HD_KEYRING_1.metadata.id) + .withGroupIndex(2) + .withUuid() + .get(); + + // Wallet with groups 0 and 2, gap at 1. + const { wallet, providers } = setup({ + accounts: [[account0, account2]], + }); + + const [evmProvider] = providers; + + const account1 = MockAccountBuilder.from(MOCK_HD_ACCOUNT_1) + .withEntropySource(MOCK_HD_KEYRING_1.metadata.id) + .withGroupIndex(1) + .withUuid() + .get(); + const account2Updated = MockAccountBuilder.from(MOCK_HD_ACCOUNT_1) + .withEntropySource(MOCK_HD_KEYRING_1.metadata.id) + .withGroupIndex(2) + .withUuid() + .get(); + // Provider returns accounts for both gap (1) and existing (2) indices. + evmProvider.createAccounts.mockResolvedValueOnce([ + account1, + account2Updated, + ]); + + // Request range [0..2]: pre-loop pushes group 0, then creates [1..2]. + // Group 1 is new (created), group 2 already exists (updated — hits update branch). + const groups = await wallet.createMultichainAccountGroups( + { from: 0, to: 2 }, + { waitForAllProvidersToFinishCreatingAccounts: true }, + ); + + expect(groups).toHaveLength(3); // group 0 (pre-loop) + group 1 (created) + group 2 (updated). + expect(groups[0].groupIndex).toBe(0); + expect(groups[1].groupIndex).toBe(1); + expect(groups[2].groupIndex).toBe(2); + // Group 2 was updated (not re-created), still exists. + expect(wallet.getMultichainAccountGroup(2)).toBeDefined(); + }); + + it('does not throw if a group cannot be created if it has no accounts', async () => { + const { wallet, providers } = setup({ + accounts: [[]], + }); + + const [evmProvider] = providers; + + // Provider only returns an account for group 0, not group 1 in the range [0..1]. + const account0 = MockAccountBuilder.from(MOCK_HD_ACCOUNT_1) + .withEntropySource(MOCK_HD_KEYRING_1.metadata.id) + .withGroupIndex(0) + .get(); + evmProvider.createAccounts.mockResolvedValueOnce([account0]); + + // Request range [0..1] BUT group 1 has no accounts. + const groups = await wallet.createMultichainAccountGroups( + { from: 0, to: 1 }, + { waitForAllProvidersToFinishCreatingAccounts: true }, + ); + + expect(groups).toHaveLength(1); + expect(groups[0].groupIndex).toBe(0); + expect(wallet.getMultichainAccountGroup(1)).toBeUndefined(); + }); + + it('calls ensureReady on non-EVM providers before acquiring the wallet lock in the fire-and-forget alignment path', async () => { + const { wallet, providers } = setup({ + accounts: [[MOCK_WALLET_1_EVM_ACCOUNT], []], + }); + + const [, solProvider] = providers; + const statusAtEnsureReady: string[] = []; + + solProvider.ensureReady.mockImplementation(async () => { + // The wallet lock must NOT be held when ensureReady is called. + statusAtEnsureReady.push(wallet.status); + }); + + await wallet.createMultichainAccountGroups({ from: 0, to: 0 }); + + // Wait for the fire-and-forget alignment to complete. + await waitForOtherProvidersToHaveBeenCalled([solProvider]); + + expect(solProvider.ensureReady).toHaveBeenCalledTimes(1); + expect(statusAtEnsureReady[0]).toBe('ready'); + }); + + it('skips a provider that fails ensureReady but still aligns the others', async () => { + // EVM + two non-EVM providers; SOL fails ensureReady, BTC succeeds. + const { wallet, providers } = setup({ + accounts: [ + [MOCK_WALLET_1_EVM_ACCOUNT], + [], // SOL — will fail ensureReady + [], // BTC — will succeed ensureReady + ], + }); + + const [, solProvider, btcProvider] = providers; + + solProvider.ensureReady.mockRejectedValueOnce( + new Error('Snap platform not ready'), + ); + + // Use a deferred promise as a reliable signal that the BTC alignment ran. + const { promise: btcAligned, resolve: resolveBtcAligned } = + createDeferredPromise(); + btcProvider.createAccounts.mockImplementationOnce(async () => { + resolveBtcAligned(); + return []; + }); + + await wallet.createMultichainAccountGroups({ from: 0, to: 0 }); + + // Wait until BTC alignment has actually run. + await btcAligned; + + // SOL was excluded (ensureReady failed); BTC proceeded normally. + expect(solProvider.createAccounts).not.toHaveBeenCalled(); + expect(btcProvider.createAccounts).toHaveBeenCalled(); + }); + + it('logs an error to console when post-alignment fails unexpectedly', async () => { + // Group 0 exists for EVM; SOL has no accounts yet (will be aligned). + const { wallet, providers, messenger } = setup({ + accounts: [[MOCK_WALLET_1_EVM_ACCOUNT], []], + }); + + const [, solProvider] = providers; + + // The Solana provider creates an account during alignment, which causes + // group.update() to run and publish `:multichainAccountGroupUpdated`. + solProvider.createAccounts.mockResolvedValueOnce([ + MOCK_WALLET_1_SOL_ACCOUNT, + ]); + + const alignmentError = new Error('Unexpected alignment failure'); + + // `:multichainAccountGroupUpdated` is published inside `group.update()`, + // which is called from `#createOrUpdateMultichainAccountGroup` in + // `#alignAccountsForRange` — outside `Promise.allSettled`. A throw here + // escapes `#alignAccountsForRange` and `#withLock`, triggering the .catch() + // on the fire-and-forget `alignOtherAccounts()` call. + jest.spyOn(messenger, 'publish').mockImplementation((event, ..._args) => { + if ( + event === 'MultichainAccountService:multichainAccountGroupUpdated' + ) { + throw alignmentError; + } + }); + + const consoleErrorSpy = jest + .spyOn(console, 'error') + .mockImplementation(() => undefined); + + const from = 0; + const to = 2; + await wallet.createMultichainAccountGroups({ from, to }); + + // Wait for the fire-and-forget alignment to have run and failed. + await waitForOtherProvidersToHaveBeenCalled([solProvider]); + + expect(consoleErrorSpy).toHaveBeenCalledWith( + `Unable to align non-EVM accounts from group index ${from} to ${to}`, + alignmentError, + ); + }); + }); + + describe('alignAccounts', () => { + it('creates missing accounts only for providers with no accounts associated with a particular group index', async () => { + const mockEvmAccount1 = MockAccountBuilder.from(MOCK_HD_ACCOUNT_1) + .withEntropySource(MOCK_HD_KEYRING_1.metadata.id) + .withGroupIndex(0) + .get(); + const mockEvmAccount2 = MockAccountBuilder.from(MOCK_HD_ACCOUNT_1) + .withEntropySource(MOCK_HD_KEYRING_1.metadata.id) + .withGroupIndex(1) + .get(); + const mockSolAccount = MockAccountBuilder.from(MOCK_SOL_ACCOUNT_1) + .withEntropySource(MOCK_HD_KEYRING_1.metadata.id) + .withGroupIndex(0) + .get(); + const { wallet, providers, messenger } = setup({ + accounts: [[mockEvmAccount1, mockEvmAccount2], [mockSolAccount]], + }); + + const mockWalletStatusChange = jest + .fn() + // 1. Triggered when group alignment begins. + .mockImplementationOnce((walletId, status) => { + expect(walletId).toBe(wallet.id); + expect(status).toBe('in-progress:alignment'); + }) + // 2. Triggered when group alignment ends. + .mockImplementationOnce((walletId, status) => { + expect(walletId).toBe(wallet.id); + expect(status).toBe('ready'); + }); + + messenger.subscribe( + 'MultichainAccountService:walletStatusChange', + mockWalletStatusChange, + ); + + await wallet.alignAccounts(); + + // Sol provider is missing group 1 only; it should be called for that + // missing sub-range only, NOT the already-aligned group 0. + expect(providers[1].createAccounts).toHaveBeenCalledWith({ + type: AccountCreationType.Bip44DeriveIndexRange, + entropySource: wallet.entropySource, + range: { from: 1, to: 1 }, + }); + expect(providers[1].createAccounts).not.toHaveBeenCalledWith( + expect.objectContaining({ range: { from: 0, to: 1 } }), + ); + + // EVM provider already has both groups aligned, so it must not be asked + // to create (or re-trace) any account during alignment. + expect(providers[0].createAccounts).not.toHaveBeenCalled(); + }); + + it('does not re-create accounts for providers that are already aligned across the whole range', async () => { + // Both groups have EVM + SOL accounts already; nothing is missing for SOL, + // but EVM is missing group 1 to force the wallet out of the aligned state. + const mockEvmAccount0 = MockAccountBuilder.from(MOCK_HD_ACCOUNT_1) + .withEntropySource(MOCK_HD_KEYRING_1.metadata.id) + .withGroupIndex(0) + .get(); + const mockSolAccount0 = MockAccountBuilder.from(MOCK_SOL_ACCOUNT_1) + .withEntropySource(MOCK_HD_KEYRING_1.metadata.id) + .withGroupIndex(0) + .get(); + const mockSolAccount1 = MockAccountBuilder.from(MOCK_SOL_ACCOUNT_1) + .withEntropySource(MOCK_HD_KEYRING_1.metadata.id) + .withGroupIndex(1) + .withUuid() + .get(); + + const { wallet, providers } = setup({ + // EVM only has group 0 (missing group 1), SOL has both groups. + accounts: [[mockEvmAccount0], [mockSolAccount0, mockSolAccount1]], + }); + + await wallet.alignAccounts(); + + // SOL is aligned for every group in the range, so it must be skipped + // entirely (no spans, no work). + expect(providers[1].createAccounts).not.toHaveBeenCalled(); + }); + + it('creates accounts only for the non-contiguous missing sub-ranges', async () => { + // EVM present for groups 0, 1, 2, 3. SOL present for groups 0 and 2, so it + // is missing the non-contiguous indices 1 and 3. + const evmAccounts = [0, 1, 2, 3].map((groupIndex) => + MockAccountBuilder.from(MOCK_HD_ACCOUNT_1) + .withEntropySource(MOCK_HD_KEYRING_1.metadata.id) + .withGroupIndex(groupIndex) + .withUuid() + .get(), + ); + const solAccounts = [0, 2].map((groupIndex) => + MockAccountBuilder.from(MOCK_SOL_ACCOUNT_1) + .withEntropySource(MOCK_HD_KEYRING_1.metadata.id) + .withGroupIndex(groupIndex) + .withUuid() + .get(), + ); + + const { wallet, providers } = setup({ + accounts: [evmAccounts, solAccounts], + }); + + await wallet.alignAccounts(); + + // Two separate single-index sub-ranges, one per gap. + expect(providers[1].createAccounts).toHaveBeenCalledWith({ + type: AccountCreationType.Bip44DeriveIndexRange, + entropySource: wallet.entropySource, + range: { from: 1, to: 1 }, + }); + expect(providers[1].createAccounts).toHaveBeenCalledWith({ + type: AccountCreationType.Bip44DeriveIndexRange, + entropySource: wallet.entropySource, + range: { from: 3, to: 3 }, + }); + expect(providers[1].createAccounts).toHaveBeenCalledTimes(2); + }); + + it('updates a group when a provider returns accounts during alignment', async () => { + const mockEvmAccount = MockAccountBuilder.from(MOCK_HD_ACCOUNT_1) + .withEntropySource(MOCK_HD_KEYRING_1.metadata.id) + .withGroupIndex(0) + .get(); + const mockSolAccount = MockAccountBuilder.from(MOCK_SOL_ACCOUNT_1) + .withEntropySource(MOCK_HD_KEYRING_1.metadata.id) + .withGroupIndex(0) + .withUuid() + .get(); + + const { wallet, providers } = setup({ + accounts: [[mockEvmAccount], []], // SOL provider has no accounts yet + }); + + // SOL provider returns an account for group 0 during alignment (also updates internal mock state) + mockCreateAccountsOnce(providers[1], [mockSolAccount]); + + await wallet.alignAccounts(); + + // The group should now include the newly aligned SOL account + const group = wallet.getMultichainAccountGroup(0); + expect(group).toBeDefined(); + expect(group?.getAccounts()).toContainEqual( + expect.objectContaining({ id: mockSolAccount.id }), + ); + }); + + it('logs a warning and does not throw when a provider fails during alignment', async () => { + const mockEvmAccount = MockAccountBuilder.from(MOCK_HD_ACCOUNT_1) + .withEntropySource(MOCK_HD_KEYRING_1.metadata.id) + .withGroupIndex(0) + .get(); + + const { wallet, providers } = setup({ + accounts: [[mockEvmAccount], []], // EVM + SOL + }); + + const consoleWarnSpy = jest + .spyOn(console, 'warn') + .mockImplementation(() => undefined); + + providers[1].createAccounts.mockRejectedValueOnce( + new Error('alignment provider failed'), + ); + + // Should not throw; failures during alignment are best-effort + expect(await wallet.alignAccounts()).toBeUndefined(); + expect(consoleWarnSpy).toHaveBeenCalledWith( + expect.stringContaining('Unable to align some accounts'), + ); + }); + + it('is a no-op when the wallet is already aligned', async () => { + const { wallet, providers } = setup({ + accounts: [[MOCK_WALLET_1_EVM_ACCOUNT], [MOCK_WALLET_1_SOL_ACCOUNT]], + }); + + await wallet.alignAccounts(); + + expect(providers[0].createAccounts).not.toHaveBeenCalled(); + expect(providers[1].createAccounts).not.toHaveBeenCalled(); + }); + }); + + describe('alignGroup', () => { + it('aligns a specific multichain account group', async () => { + const mockEvmAccount = MockAccountBuilder.from(MOCK_HD_ACCOUNT_1) + .withEntropySource(MOCK_HD_KEYRING_1.metadata.id) + .withGroupIndex(0) + .get(); + const mockSolAccount = MockAccountBuilder.from(MOCK_SOL_ACCOUNT_1) + .withEntropySource(MOCK_HD_KEYRING_1.metadata.id) + .withGroupIndex(1) + .get(); + const { wallet, providers, messenger } = setup({ + accounts: [[mockEvmAccount], [mockSolAccount]], + }); + + const mockWalletStatusChange = jest + .fn() + // 1. Triggered when group alignment begins. + .mockImplementationOnce((walletId, status) => { + expect(walletId).toBe(wallet.id); + expect(status).toBe('in-progress:alignment'); + }) + // 2. Triggered when group alignment ends. + .mockImplementationOnce((walletId, status) => { + expect(walletId).toBe(wallet.id); + expect(status).toBe('ready'); + }); + + messenger.subscribe( + 'MultichainAccountService:walletStatusChange', + mockWalletStatusChange, + ); + + await wallet.alignAccountsOf(0); + + // Sol provider is missing group 0; should be called via the batch range API for that group only. + expect(providers[1].createAccounts).toHaveBeenCalledWith({ + type: AccountCreationType.Bip44DeriveIndexRange, + entropySource: wallet.entropySource, + range: { from: 0, to: 0 }, + }); + + expect(providers[1].createAccounts).not.toHaveBeenCalledWith({ + type: AccountCreationType.Bip44DeriveIndexRange, + entropySource: wallet.entropySource, + range: { from: 1, to: 1 }, + }); + }); + + it('is a no-op when the group is already aligned', async () => { + const { wallet, providers } = setup({ + accounts: [[MOCK_WALLET_1_EVM_ACCOUNT], [MOCK_WALLET_1_SOL_ACCOUNT]], + }); + + await wallet.alignAccountsOf(0); + + expect(providers[0].createAccounts).not.toHaveBeenCalled(); + expect(providers[1].createAccounts).not.toHaveBeenCalled(); + }); + }); + + describe('isAligned', () => { + it('returns true when all groups are aligned', () => { + const { wallet } = setup({ + accounts: [[MOCK_WALLET_1_EVM_ACCOUNT], [MOCK_WALLET_1_SOL_ACCOUNT]], + }); + + expect(wallet.isAligned()).toBe(true); + }); + + it('returns false when at least one group is not aligned', () => { + const { wallet } = setup({ + accounts: [ + [MOCK_WALLET_1_EVM_ACCOUNT], + [], // second provider has no accounts, so the group is not aligned + ], + }); + + expect(wallet.isAligned()).toBe(false); + }); + + it('returns true for a wallet with no groups', () => { + const serviceMessenger = + getMultichainAccountServiceMessenger(getRootMessenger()); + const wallet = new MultichainAccountWallet>( + { + entropySource: MOCK_WALLET_1_ENTROPY_SOURCE, + providers: [], + messenger: serviceMessenger, + }, + ); + wallet.init({}); + + expect(wallet.isAligned()).toBe(true); + }); + }); + + describe('discoverAccounts', () => { + it('runs discovery', async () => { + const { wallet, providers, messenger } = setup({ + accounts: [[], []], + }); + + providers[0].discoverAccounts + .mockImplementationOnce(async () => [MOCK_HD_ACCOUNT_1]) + .mockImplementationOnce(async () => []); + providers[1].discoverAccounts + .mockImplementationOnce(async () => [MOCK_SOL_ACCOUNT_1]) + .mockImplementationOnce(async () => []); + + const mockWalletStatusChange = jest + .fn() + // 1. Triggered when group alignment begins. + .mockImplementationOnce((walletId, status) => { + expect(walletId).toBe(wallet.id); + expect(status).toBe('in-progress:discovery'); + }) + // 2. Triggered when group alignment ends. + .mockImplementationOnce((walletId, status) => { + expect(walletId).toBe(wallet.id); + expect(status).toBe('ready'); + }); + + messenger.subscribe( + 'MultichainAccountService:walletStatusChange', + mockWalletStatusChange, + ); + + await wallet.discoverAccounts(); + + expect(providers[0].discoverAccounts).toHaveBeenCalledTimes(2); + expect(providers[1].discoverAccounts).toHaveBeenCalledTimes(2); + }); + + it('fast-forwards lagging providers to the highest group index', async () => { + const { wallet, providers } = setup({ + accounts: [[], []], + }); + + providers[0].getName.mockImplementation(() => 'EVM'); + providers[1].getName.mockImplementation(() => 'Solana'); + + // Fast provider: succeeds at indices 0,1 then stops at 2 + providers[0].discoverAccounts + .mockImplementationOnce(() => Promise.resolve([{}])) + .mockImplementationOnce(() => Promise.resolve([{}])) + .mockImplementationOnce(() => Promise.resolve([])); + + // Slow provider: first call (index 0) resolves on a later tick, then it should be + // rescheduled directly at index 2 (the max group index) and stop there + providers[1].discoverAccounts + .mockImplementationOnce( + () => new Promise((resolve) => setTimeout(() => resolve([{}]), 100)), + ) + .mockImplementationOnce(() => Promise.resolve([])); + + jest.useFakeTimers(); + const discovery = wallet.discoverAccounts(); + // Allow fast provider microtasks to run and advance maxGroupIndex first + await Promise.resolve(); // Mutex lock. + await Promise.resolve(); + await Promise.resolve(); + jest.advanceTimersByTime(100); + await discovery; + + // Assert call order per provider shows skipping ahead + const fastIndices = Array.from( + providers[0].discoverAccounts.mock.calls, + ).map((c) => Number(c[0].groupIndex)); + expect(fastIndices).toStrictEqual([0, 1, 2]); + + const slowIndices = Array.from( + providers[1].discoverAccounts.mock.calls, + ).map((c) => Number(c[0].groupIndex)); + expect(slowIndices).toStrictEqual([0, 2]); + }); + + it('stops scheduling a provider when it returns no accounts', async () => { + const { wallet, providers } = setup({ + accounts: [[MOCK_HD_ACCOUNT_1], []], + }); + + providers[0].getName.mockImplementation(() => 'EVM'); + providers[1].getName.mockImplementation(() => 'Solana'); + + // First provider finds one at 0 then stops at 1 + providers[0].discoverAccounts + .mockImplementationOnce(() => Promise.resolve([{}])) + .mockImplementationOnce(() => Promise.resolve([])); + + // Second provider stops immediately at 0 + providers[1].discoverAccounts.mockImplementationOnce(() => + Promise.resolve([]), + ); + + await wallet.discoverAccounts(); + + expect(providers[0].discoverAccounts).toHaveBeenCalledTimes(2); + expect(providers[1].discoverAccounts).toHaveBeenCalledTimes(1); + }); + + it('marks a provider stopped on error and does not reschedule it', async () => { + const { wallet, providers } = setup({ + accounts: [[], []], + }); + + providers[0].getName.mockImplementation(() => 'EVM'); + providers[1].getName.mockImplementation(() => 'Solana'); + + const consoleSpy = jest.spyOn(console, 'error').mockImplementation(); + + // First provider throws on its first step + providers[0].discoverAccounts.mockImplementationOnce(() => + Promise.reject(new Error('Failed to discover accounts')), + ); + // Second provider stops immediately + providers[1].discoverAccounts.mockImplementationOnce(() => + Promise.resolve([]), + ); + + await wallet.discoverAccounts(); + + // Thrown provider should have been called once and not rescheduled + expect(providers[0].discoverAccounts).toHaveBeenCalledTimes(1); + expect(consoleSpy).toHaveBeenCalledWith( + expect.any(String), + expect.any(Error), + ); + expect((consoleSpy.mock.calls[0][1] as Error).message).toBe( + 'Failed to discover accounts', + ); + + // Other provider proceeds normally + expect(providers[1].discoverAccounts).toHaveBeenCalledTimes(1); + }); + + it('captures an error when a provider fails to discover its accounts', async () => { + const { wallet, providers, messenger } = setup({ + accounts: [[], []], + }); + const providerError = new Error('Unable to discover accounts'); + providers[0].discoverAccounts.mockRejectedValueOnce(providerError); + const captureExceptionSpy = jest.spyOn(messenger, 'captureException'); + // Ensure the other provider stops immediately to finish the Promise.all + providers[1].discoverAccounts.mockResolvedValueOnce([]); + await wallet.discoverAccounts(); + expect(captureExceptionSpy).toHaveBeenCalledWith( + new Error( + 'Unable to discover accounts with provider "Mocked Provider 0"', + ), + ); + expect(captureExceptionSpy.mock.lastCall[0]).toHaveProperty( + 'cause', + providerError, + ); + }); + + it('does not capture exception when a provider times out during account discovery', async () => { + const { wallet, providers, messenger } = setup({ + accounts: [[], []], + }); + providers[0].discoverAccounts.mockRejectedValueOnce( + new TimeoutError('Timed out after: 500ms'), + ); + const captureExceptionSpy = jest.spyOn(messenger, 'captureException'); + const consoleWarnSpy = jest.spyOn(console, 'warn').mockImplementation(); + const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(); + providers[1].discoverAccounts.mockResolvedValueOnce([]); + await wallet.discoverAccounts(); + expect(captureExceptionSpy).not.toHaveBeenCalled(); + expect(consoleWarnSpy).toHaveBeenCalled(); + expect(consoleErrorSpy).not.toHaveBeenCalled(); + consoleWarnSpy.mockRestore(); + consoleErrorSpy.mockRestore(); + }); + }); +}); diff --git a/packages/multichain-account-service/src/MultichainAccountWallet.ts b/packages/multichain-account-service/src/MultichainAccountWallet.ts new file mode 100644 index 00000000000..30a39a034d4 --- /dev/null +++ b/packages/multichain-account-service/src/MultichainAccountWallet.ts @@ -0,0 +1,1062 @@ +import type { + AccountGroupId, + Bip44Account, + MultichainAccountWalletId, + MultichainAccountWallet as MultichainAccountWalletDefinition, + MultichainAccountWalletStatus, +} from '@metamask/account-api'; +import { + AccountWalletType, + getGroupIndexFromMultichainAccountGroupId, + isMultichainAccountGroupId, + toDefaultAccountGroupId, + toMultichainAccountWalletId, +} from '@metamask/account-api'; +import type { TraceCallback, TraceRequest } from '@metamask/controller-utils'; +import { AccountCreationType } from '@metamask/keyring-api'; +import type { EntropySourceId, KeyringAccount } from '@metamask/keyring-api'; +import { assert } from '@metamask/utils'; +import { Mutex } from 'async-mutex'; + +import { + toProviderDataTraces, + traceFallback, + TraceName, +} from './analytics/index.js'; +import { reportError } from './errors.js'; +import type { Logger } from './logger.js'; +import { + createModuleLogger, + ERROR_PREFIX, + projectLogger as log, + WARNING_PREFIX, +} from './logger.js'; +import type { GroupState } from './MultichainAccountGroup.js'; +import { MultichainAccountGroup } from './MultichainAccountGroup.js'; +import type { ServiceState, StateKeys } from './MultichainAccountService.js'; +import { EvmAccountProvider } from './providers/EvmAccountProvider.js'; +import type { Bip44AccountProvider } from './providers/index.js'; +import type { MultichainAccountServiceMessenger } from './types.js'; +import { + assertGroupIndexIsValid, + assertGroupIndexRangeIsValid, + GroupIndexRange, + toErrorMessage, +} from './utils.js'; + +/** + * The context for a provider discovery. + */ +type AccountProviderDiscoveryContext< + Account extends Bip44Account, +> = { + provider: Bip44AccountProvider; + stopped: boolean; + groupIndex: number; + accounts: Account[]; +}; + +export type WalletState = ServiceState[StateKeys['entropySource']]; + +// type alias to make clear this state is generated by discovery +type DiscoveredGroupsState = WalletState; + +/** + * A multichain account wallet that holds multiple multichain accounts (one multichain account per + * group index). + */ +export class MultichainAccountWallet< + Account extends Bip44Account, +> implements MultichainAccountWalletDefinition { + readonly #lock = new Mutex(); + + readonly #id: MultichainAccountWalletId; + + readonly #providers: Bip44AccountProvider[]; + + readonly #entropySource: EntropySourceId; + + readonly #accountGroups: Map>; + + readonly #messenger: MultichainAccountServiceMessenger; + + readonly #trace: TraceCallback; + + readonly #log: Logger; + + #initialized = false; + + #status: MultichainAccountWalletStatus; + + constructor({ + providers, + entropySource, + messenger, + trace, + }: { + providers: Bip44AccountProvider[]; + entropySource: EntropySourceId; + messenger: MultichainAccountServiceMessenger; + trace?: TraceCallback; + }) { + this.#id = toMultichainAccountWalletId(entropySource); + this.#providers = providers; + this.#entropySource = entropySource; + this.#messenger = messenger; + this.#accountGroups = new Map(); + this.#trace = trace ?? traceFallback; + + this.#log = createModuleLogger(log, `[${this.#id}]`); + + // Initial synchronization (don't emit events during initialization). + this.#status = 'uninitialized'; + } + + /** + * Initialize the wallet and construct the internal representation of multichain account groups. + * + * @param walletState - The wallet state. + */ + init(walletState: WalletState): void { + this.#log('Initializing wallet state...'); + for (const [groupIndexString, groupState] of Object.entries(walletState)) { + // Have to convert to number because the state keys become strings when we construct the state object in the service + const groupIndex = Number(groupIndexString); + const group = new MultichainAccountGroup({ + groupIndex, + wallet: this, + providers: this.#providers, + messenger: this.#messenger, + }); + + this.#log(`Creating new group for index ${groupIndex}...`); + + group.init(groupState); + + this.#accountGroups.set(groupIndex, group); + } + if (!this.#initialized) { + this.#initialized = true; + this.#status = 'ready'; + } + + this.#log('Finished initializing wallet state...'); + } + + /** + * Gets the multichain account wallet ID. + * + * @returns The multichain account wallet ID. + */ + get id(): MultichainAccountWalletId { + return this.#id; + } + + /** + * Gets the multichain account wallet type, which is always {@link AccountWalletType.Entropy}. + * + * @returns The multichain account wallet type. + */ + get type(): AccountWalletType.Entropy { + return AccountWalletType.Entropy; + } + + /** + * Gets the multichain account wallet entropy source. + * + * @returns The multichain account wallet entropy source. + */ + get entropySource(): EntropySourceId { + return this.#entropySource; + } + + /** + * Gets the multichain account wallet current status. + * + * @returns The multichain account wallet current status. + */ + get status(): MultichainAccountWalletStatus { + return this.#status; + } + + /** + * Set the wallet status and run the associated operation callback. + * + * @param status - Wallet status associated with this operation. + * @param operation - Operation to run. + * @returns The operation's result. + * @throws {Error} If the wallet is already running a mutable operation. + */ + async #withLock( + status: MultichainAccountWalletStatus, + operation: () => Promise, + ): Promise { + const release = await this.#lock.acquire(); + try { + this.#log(`Locking wallet with status "${status}"...`); + this.#status = status; + this.#messenger.publish( + 'MultichainAccountService:walletStatusChange', + this.id, + this.#status, + ); + return await operation(); + } finally { + this.#status = 'ready'; + this.#messenger.publish( + 'MultichainAccountService:walletStatusChange', + this.id, + this.#status, + ); + release(); + this.#log(`Releasing wallet lock (was "${status}")`); + } + } + + /** + * Gets the providers and ensure the EVM provider is located at index 0. + * + * @returns The account providers. + */ + #getProviders(): Bip44AccountProvider[] { + const [evmProvider, ...otherProviders] = this.#providers; + assert( + evmProvider instanceof EvmAccountProvider, + 'EVM account provider must be first', + ); + + return [evmProvider, ...otherProviders]; + } + + /** + * Create accounts for a given provider and group index range. + * + * @param provider - The provider to create accounts for. + * @param from - The starting group index (inclusive). + * @param to - The ending group index (inclusive). + * @returns The created accounts. + */ + async #createAccountsRangeForProvider( + provider: Bip44AccountProvider, + from: number, + to: number, + ): Promise[]> { + const isBatching = to > from; + + try { + return await provider.createAccounts({ + type: AccountCreationType.Bip44DeriveIndexRange, + entropySource: this.#entropySource, + range: { + from, + to, + }, + }); + } catch (error) { + reportError( + this.#messenger, + `Unable to create ${isBatching ? 'some accounts (batch)' : 'some accounts'} with provider "${provider.getName()}"`, + error, + { + range: { from, to }, + provider: provider.getName(), + isBatching, + }, + ); + throw error; + } + } + + /** + * Create or update a multichain account group state for a given group index and group state. + * + * @param groupIndex The group's index. + * @param groupState The group's state to create or update the group with. + * @returns The created or updated multichain account group. + */ + #createOrUpdateMultichainAccountGroup( + groupIndex: number, + groupState: GroupState, + ): MultichainAccountGroup { + let group = this.#accountGroups.get(groupIndex); + if (group) { + // NOTE: This will publish an update event automatically. + group.update(groupState); + + this.#log(`Group updated: [${group.id}]`); + } else { + group = new MultichainAccountGroup({ + wallet: this, + providers: this.#providers, + groupIndex, + messenger: this.#messenger, + }); + group.init(groupState); + + this.#accountGroups.set(groupIndex, group); + + this.#log(`Group created: [${group.id}]`); + + if (this.#initialized) { + this.#messenger.publish( + 'MultichainAccountService:multichainAccountGroupCreated', + group, + ); + } + } + + return group; + } + + /** + * Build group state by calling all providers in parallel. + * + * This is a non-locking shared core used by both creation and alignment paths. + * Each provider is asked which contiguous sub-ranges of group indices it needs + * accounts created for via `getSubRanges`, so callers can skip indices that are + * already satisfied (e.g. during alignment). + * + * @param providers - The providers to create accounts for. + * @param getSubRanges - Resolver returning the sub-ranges to create for a + * given provider. Returning an empty array means the provider is skipped. + * @returns The collected group state and any provider failure messages. + */ + async #buildGroupState( + providers: Bip44AccountProvider[], + getSubRanges: ( + provider: Bip44AccountProvider, + ) => Required[], + ): Promise<{ + groupStateByGroupIndex: Map; + failures: string[]; + }> { + const groupStateByGroupIndex = new Map(); + + const results = await Promise.allSettled( + providers.map(async (provider) => { + const providerName = provider.getName(); + const subRanges = getSubRanges(provider); + + for (const { from, to } of subRanges) { + const accounts = await this.#createAccountsRangeForProvider( + provider, + from, + to, + ); + accounts.forEach((account) => { + const { groupIndex } = account.options.entropy; + let groupState = groupStateByGroupIndex.get(groupIndex); + if (!groupState) { + groupState = {}; + groupStateByGroupIndex.set(groupIndex, groupState); + } + if (!groupState[providerName]) { + groupState[providerName] = []; + } + groupState[providerName].push(account.id); + }); + } + }), + ); + + const failures = providers.reduce((messages: string[], provider, index) => { + const result = results[index]; + if (result?.status === 'rejected') { + messages.push( + `[${provider.getName()}] ${toErrorMessage(result.reason)}`, + ); + } + return messages; + }, []); + + return { groupStateByGroupIndex, failures }; + } + + /** + * Compute the contiguous sub-ranges of group indices in `[from, to]` for which + * the given provider is NOT aligned (i.e. is missing an account). + * + * Already-aligned indices are skipped so alignment never re-creates (or + * re-traces) accounts that already exist. A group that does not exist yet is + * treated as unaligned. + * + * @param provider - The provider to compute missing sub-ranges for. + * @param from - Starting group index (inclusive). + * @param to - Ending group index (inclusive). + * @returns The contiguous sub-ranges where the provider needs accounts. + */ + #getUnalignedSubRangesForProvider( + provider: Bip44AccountProvider, + from: number, + to: number, + ): Required[] { + const subRanges: Required[] = []; + + let runStart: number | undefined; + for (let groupIndex = from; groupIndex <= to; groupIndex++) { + const group = this.getMultichainAccountGroup(groupIndex); + const aligned = group ? group.isProviderAligned(provider) : false; + + if (!aligned) { + runStart ??= groupIndex; + } else if (runStart !== undefined) { + subRanges.push({ from: runStart, to: groupIndex - 1 }); + runStart = undefined; + } + } + + if (runStart !== undefined) { + subRanges.push({ from: runStart, to }); + } + + return subRanges; + } + + /** + * Internal method to create a range of multichain account groups. + * + * This method acquires the wallet lock internally and creates accounts for all + * given providers synchronously. Callers decide which providers to pass. + * + * @param range - The range of group indices to create. + * @param range.from - Starting group index to create (inclusive). + * @param range.to - Maximum group index to create (inclusive). + * @param providers - The providers to create accounts for. + * @returns Array of created multichain account groups. + */ + async #createMultichainAccountGroupsRange( + { from: rangeFrom, to }: Required, + providers: Bip44AccountProvider[], + ): Promise[]> { + return await this.#withLock('in-progress:create-accounts', async () => { + const groups: MultichainAccountGroup[] = []; + + // Get existing groups (fromGroupIndex to nextGroupIndex - 1). + let from = rangeFrom; + for (; from <= to; from++) { + const group = this.getMultichainAccountGroup(from); + if (group) { + groups.push(group); + } else { + break; // Assuming we have no gap, if the group does not exist, we can stop and create the remaining ones. + } + } + + // Create new groups now. + if (from <= to) { + this.#log(`Creating groups from index ${from} to ${to}...`); + + const { groupStateByGroupIndex, failures } = + await this.#buildGroupState(providers, () => [{ from, to }]); + + // Check for provider failures — always treated as hard errors. + if (failures.length) { + throw new Error( + failures.reduce( + (message, failure) => `${message}\n- ${failure}`, + 'Unable to create some accounts. Providers threw the following errors:', + ), + ); + } + + // Create or update groups from the collected state. + for (let groupIndex = from; groupIndex <= to; groupIndex++) { + const groupState = groupStateByGroupIndex.get(groupIndex); + + if (groupState) { + const group = this.#createOrUpdateMultichainAccountGroup( + groupIndex, + groupState, + ); + + groups.push(group); + } else { + this.#log( + `${WARNING_PREFIX} Failed to create new group for group index: ${groupIndex} because no accounts were created for it`, + ); + } + } + } + + return groups; + }); + } + + /** + * Calls `ensureReady` on every provider concurrently (best-effort). + * + * Returns the subset of providers that became ready and a list of failure + * messages for the ones that did not, following the same `{ ..., failures }` + * pattern used by {@link MultichainAccountWallet.#buildGroupState}. + * + * @param providers - Providers to check. + * @returns Ready providers and failure messages for those that were not. + */ + async #ensureReadyProviders( + providers: Bip44AccountProvider[], + ): Promise<{ + readyProviders: Bip44AccountProvider[]; + failures: string[]; + }> { + const results = await Promise.allSettled( + providers.map((provider) => provider.ensureReady()), + ); + + const readyProviders: Bip44AccountProvider[] = []; + const failures: string[] = []; + + for (const [i, result] of results.entries()) { + const provider = providers[i]; + if (result.status === 'fulfilled') { + readyProviders.push(provider); + } else { + failures.push( + `[${provider?.getName()}] ${toErrorMessage(result.reason)}`, + ); + } + } + + return { readyProviders, failures }; + } + + /** + * Align accounts for a range of group indices (non-locking). + * + * Calls all providers in parallel via the batch API. Provider failures are + * logged as warnings (best-effort); no error is thrown. + * + * @param range - The range of group indices to align. + * @param range.from - Starting group index (inclusive). + * @param range.to - Ending group index (inclusive). + * @param providers - The providers to align accounts for. + * @param options - Options. + * @param options.trace - Trace options. + * @param options.trace.data - Optional trace data. + */ + async #alignAccountsForRange( + { from, to }: Required, + providers: Bip44AccountProvider[], + options: { trace?: { data?: TraceRequest['data'] } } = {}, + ): Promise { + await this.#trace( + { + name: TraceName.WalletAlignment, + data: { + from, + to, + ...toProviderDataTraces(providers), + ...options.trace?.data, + }, + }, + async () => { + const { groupStateByGroupIndex, failures } = + await this.#buildGroupState(providers, (provider) => + this.#getUnalignedSubRangesForProvider(provider, from, to), + ); + + if (failures.length) { + const error = failures.reduce( + (message, failure) => `${message}\n- ${failure}`, + 'Unable to align some accounts. Providers threw the following errors:', + ); + console.warn(error); + this.#log(`${WARNING_PREFIX} ${error}`); + } + + for (let groupIndex = from; groupIndex <= to; groupIndex++) { + const groupState = groupStateByGroupIndex.get(groupIndex); + if (groupState) { + this.#createOrUpdateMultichainAccountGroup(groupIndex, groupState); + } + } + }, + ); + } + + /** + * Gets multichain account for a given ID. + * The default group ID will default to the multichain account with index 0. + * + * @param id - Account group ID. + * @returns Account group. + */ + getAccountGroup( + id: AccountGroupId, + ): MultichainAccountGroup | undefined { + // We consider the "default case" to be mapped to index 0. + if (id === toDefaultAccountGroupId(this.id)) { + return this.#accountGroups.get(0); + } + + // If it is not a valid ID, we cannot extract the group index + // from it, so we fail fast. + if (!isMultichainAccountGroupId(id)) { + return undefined; + } + + const groupIndex = getGroupIndexFromMultichainAccountGroupId(id); + return this.#accountGroups.get(groupIndex); + } + + /** + * Gets all multichain accounts. Similar to {@link MultichainAccountWallet.getMultichainAccountGroups}. + * + * @returns The multichain accounts. + */ + getAccountGroups(): MultichainAccountGroup[] { + return this.getMultichainAccountGroups(); + } + + /** + * Gets multichain account group for a given index. + * + * @param groupIndex - Multichain account index. + * @returns The multichain account associated with the given index. + */ + getMultichainAccountGroup( + groupIndex: number, + ): MultichainAccountGroup | undefined { + return this.#accountGroups.get(groupIndex); + } + + /** + * Gets all multichain account groups. + * + * @returns The multichain accounts. + */ + getMultichainAccountGroups(): MultichainAccountGroup[] { + return Array.from(this.#accountGroups.values()); // TODO: Prevent copy here. + } + + /** + * Gets next group index for this wallet. + * + * @returns The next group index of this wallet. + */ + getNextGroupIndex(): number { + // We do not check for gaps. + return ( + Math.max( + -1, // So it will default to 0 if no groups. + ...this.#accountGroups.keys(), + ) + 1 + ); + } + + /** + * Creates a multichain account group for a given group index. + * + * NOTE: This operation WILL lock the wallet's mutex. + * + * @param groupIndex - The group index to use. + * @param options - Options to configure the account creation. + * @param options.waitForAllProvidersToFinishCreatingAccounts - Whether to wait for all + * account providers to finish creating their accounts before returning. If `false`, only + * the EVM provider is used and non-EVM account creation is deferred via + * {@link MultichainAccountWallet.alignAccountsOf}. Defaults to `false`. + * @throws If groupIndex is greater than the next available group index. + * @throws If any account provider fails to create accounts. + * @returns The multichain account group for this group index. + */ + async createMultichainAccountGroup( + groupIndex: number, + options: { + waitForAllProvidersToFinishCreatingAccounts?: boolean; + } = {}, + ): Promise> { + // Use this to avoid having it as `boolean | undefined`. + const waitForAllProvidersToFinishCreatingAccounts = + options.waitForAllProvidersToFinishCreatingAccounts ?? false; + + return await this.#trace( + { + name: TraceName.WalletCreateMultichainAccountGroup, + data: { + groupIndex, + waitForAllProvidersToFinishCreatingAccounts, + }, + }, + async () => { + assertGroupIndexIsValid(groupIndex, this.getNextGroupIndex()); + + // If the group already exists, return it. + const existingGroup = this.getMultichainAccountGroup(groupIndex); + if (existingGroup) { + this.#log( + `Trying to re-create existing group: [${existingGroup.id}] (idempotent)`, + ); + return existingGroup; + } + + // Create a single group with a range of 1 (so we can reuse the batch creation logic) for the + // given group index. + const groups = await this.#createMultichainAccountGroups( + { from: groupIndex, to: groupIndex }, + options, + ); + + const group = groups[0]; + assert(group, `Expected group at index ${groupIndex} to exist`); + return group; + }, + ); + } + + /** + * Creates multiple multichain account groups up to maxGroupIndex. + * + * NOTE: This operation WILL lock the wallet's mutex. + * + * @param range - The range of group indices to create. + * @param range.from - Starting group index to create (inclusive) (defaults to 0). + * @param range.to - Maximum group index to create (inclusive). + * @param options - Options to configure the account creation. + * @param options.waitForAllProvidersToFinishCreatingAccounts - Whether to wait for all + * account providers to finish creating their accounts before returning. If `false`, only + * the EVM provider is used and non-EVM account creation is deferred via + * {@link MultichainAccountWallet.alignAccounts}. Defaults to false. + * @throws If range is invalid (e.g. from is greater than to, from or to is negative, etc.). + * @returns Array of created multichain account groups. + */ + async createMultichainAccountGroups( + { from = 0, to }: GroupIndexRange, + options: { + waitForAllProvidersToFinishCreatingAccounts?: boolean; + } = {}, + ): Promise[]> { + // Use this to avoid having it as `boolean | undefined`. + const waitForAllProvidersToFinishCreatingAccounts = + options.waitForAllProvidersToFinishCreatingAccounts ?? false; + + return await this.#trace( + { + name: TraceName.WalletCreateMultichainAccountGroups, + data: { + from, + to, + waitForAllProvidersToFinishCreatingAccounts, + }, + }, + async () => + await this.#createMultichainAccountGroups({ from, to }, options), + ); + } + + /** + * Creates multiple multichain account groups up to maxGroupIndex. + * + * NOTE: This operation WILL lock the wallet's mutex. + * + * @param range - The range of group indices to create. + * @param range.from - Starting group index to create (inclusive). + * @param range.to - Maximum group index to create (inclusive). + * @param options - Options to configure the account creation. + * @param options.waitForAllProvidersToFinishCreatingAccounts - Whether to wait for all + * account providers to finish creating their accounts before returning. If `false`, only + * the EVM provider is used and non-EVM account creation is deferred via + * {@link MultichainAccountWallet.alignAccounts}. Defaults to false. + * @throws If range is invalid (e.g. from is greater than to, from or to is negative, etc.). + * @returns Array of created multichain account groups. + */ + async #createMultichainAccountGroups( + { from, to }: Required, + options: { + waitForAllProvidersToFinishCreatingAccounts?: boolean; + }, + ): Promise[]> { + assertGroupIndexRangeIsValid({ from, to }); + assertGroupIndexIsValid(from, this.getNextGroupIndex()); + + const waitForAllProvidersToFinishCreatingAccounts = + options.waitForAllProvidersToFinishCreatingAccounts ?? false; + + const [evmProvider, ...otherProviders] = this.#getProviders(); + const providers = waitForAllProvidersToFinishCreatingAccounts + ? this.#providers + : [evmProvider]; + + const groups = await this.#createMultichainAccountGroupsRange( + { from, to }, + providers, + ); + + // We need to run a post-alignment since non-EVM accounts have not + // been created yet. + if (!waitForAllProvidersToFinishCreatingAccounts) { + const alignOtherAccounts = async (): Promise => { + // Ensure the Snap platform is ready for each non-EVM provider BEFORE + // acquiring the wallet lock. Without this guard the lock would be held + // while waiting for onboarding to complete, blocking all subsequent + // wallet operations that also need the lock. + // + // This is best-effort: providers that fail to become ready are excluded + // from this round. Explicit alignments triggered later will recover them. + const { readyProviders, failures } = + await this.#ensureReadyProviders(otherProviders); + + if (failures.length) { + const error = failures.reduce( + (message, failure) => `${message}\n- ${failure}`, + 'Some providers are not ready and will be skipped for post-alignment:', + ); + this.#log(`${WARNING_PREFIX} ${error}`); + } + + if (readyProviders.length === 0) { + return; + } + + this.#log(`Aligning accounts... (post)`); + + await this.#withLock('in-progress:alignment', async () => { + await this.#alignAccountsForRange({ from, to }, readyProviders, { + trace: { + data: { + post: true, // Tag to identify post-alignment traces in analytics. + }, + }, + }); + }); + + this.#log('Aligned accounts! (post)'); + }; + + // eslint-disable-next-line no-void + void alignOtherAccounts().catch((error) => { + const errorMessage = `Unable to align non-EVM accounts from group index ${from} to ${to}`; + this.#log( + `${ERROR_PREFIX} ${errorMessage}: ${toErrorMessage(error)} (post)`, + ); + console.error(errorMessage, error); + }); + } + + return groups; + } + + /** + * Creates the next multichain account group. + * + * @throws If any of the account providers fails to create their accounts. + * @returns The multichain account group for the next group index available. + */ + async createNextMultichainAccountGroup(): Promise< + MultichainAccountGroup + > { + return this.createMultichainAccountGroup(this.getNextGroupIndex(), { + waitForAllProvidersToFinishCreatingAccounts: true, + }); + } + + /** + * Align all accounts from each existing multichain account groups. + * + * NOTE: This operation WILL lock the wallet's mutex. + */ + async alignAccounts(): Promise { + if (this.isAligned()) { + this.#log('Already aligned, skipping...'); + return; + } + + const nextGroupIndex = this.getNextGroupIndex(); + + if (nextGroupIndex > 0) { + this.#log('Aligning accounts...'); + + const from = 0; + const to = nextGroupIndex - 1; + + await this.#withLock( + 'in-progress:alignment', + async () => + await this.#alignAccountsForRange({ from, to }, this.#providers), + ); + + this.#log('Aligned!'); + } + } + + /** + * Check whether every group in this wallet is aligned. + * + * A wallet is aligned when every multichain account group reports that all + * of its registered providers have contributed at least one account. + * Returns `true` if the wallet has no groups. + * + * @returns `true` when all groups are aligned. + */ + isAligned(): boolean { + return this.getMultichainAccountGroups().every((group) => + group.isAligned(), + ); + } + + /** + * Align a specific multichain account group. + * + * NOTE: This operation WILL lock the wallet's mutex. + * + * @param groupIndex - The group index to align. + */ + async alignAccountsOf(groupIndex: number): Promise { + const group = this.getMultichainAccountGroup(groupIndex); + + if (group) { + if (group.isAligned()) { + this.#log(`Group "${group.id}" is already aligned, skipping...`); + return; + } + + this.#log(`Aligning accounts for group "${group.id}"...`); + + await this.#withLock( + 'in-progress:alignment', + async () => + await this.#alignAccountsForRange( + { from: groupIndex, to: groupIndex }, + this.#providers, + { trace: { data: { groupIndex } } }, + ), + ); + + this.#log(`Aligned accounts for group "${group.id}"!`); + } + } + + /** + * Discover and create accounts for all providers. + * + * NOTE: This operation WILL lock the wallet's mutex. + * + * @returns The discovered accounts for each provider. + */ + async discoverAccounts(): Promise { + return this.#withLock('in-progress:discovery', async () => { + // Start with the next available group index (so we can resume the discovery + // from there). + let maxGroupIndex = this.getNextGroupIndex(); + const discoveredGroupsState: DiscoveredGroupsState = {}; + + const addDiscoveryResultToState = ( + result: Account[], + providerName: string, + groupIndex: number, + ) => { + const accountIds = result.map((account) => account.id); + discoveredGroupsState[groupIndex] ??= {}; + discoveredGroupsState[groupIndex][providerName] = accountIds; + }; + + // One serialized loop per provider; all run concurrently + const runProviderDiscovery = async ( + context: AccountProviderDiscoveryContext, + ): Promise => { + const providerName = context.provider.getName(); + const message = (stepName: string, groupIndex: number) => + `[${providerName}] Discovery ${stepName} for group index: ${groupIndex}`; + + while (!context.stopped) { + // Fast‑forward to current high‑water mark + const targetGroupIndex = Math.max(context.groupIndex, maxGroupIndex); + + log(message('started', targetGroupIndex)); + + let accounts: Account[] = []; + try { + accounts = await context.provider.discoverAccounts({ + entropySource: this.#entropySource, + groupIndex: targetGroupIndex, + }); + } catch (error) { + context.stopped = true; + + log( + message( + `failed (with: "${toErrorMessage(error)}")`, + targetGroupIndex, + ), + ); + + reportError( + this.#messenger, + `Unable to discover accounts with provider "${providerName}"`, + error, + { + provider: providerName, + groupIndex: targetGroupIndex, + }, + ); + break; + } + + if (!accounts.length) { + log( + message('stopped (no accounts got discovered)', targetGroupIndex), + ); + context.stopped = true; + break; + } + + log(message('**succeeded**', targetGroupIndex)); + + context.accounts = context.accounts.concat(accounts); + + addDiscoveryResultToState(accounts, providerName, targetGroupIndex); + + const nextGroupIndex = targetGroupIndex + 1; + context.groupIndex = nextGroupIndex; + + if (nextGroupIndex > maxGroupIndex) { + maxGroupIndex = nextGroupIndex; + } + } + }; + + const providerContexts: AccountProviderDiscoveryContext[] = + this.#providers.map((provider) => ({ + provider, + stopped: false, + groupIndex: maxGroupIndex, + accounts: [], + })); + + // Start discovery for each providers. + await Promise.all(providerContexts.map(runProviderDiscovery)); + + // Create discovered groups + for (const [groupIndexString, groupState] of Object.entries( + discoveredGroupsState, + )) { + const groupIndex = Number(groupIndexString); + const group = new MultichainAccountGroup({ + wallet: this, + providers: this.#providers, + groupIndex, + messenger: this.#messenger, + }); + group.init(groupState); + this.#accountGroups.set(groupIndex, group); + } + + // Align missing accounts from group. This is required to create missing account from non-discovered + // indexes for some providers. + const nextGroupIndex = this.getNextGroupIndex(); + if (nextGroupIndex > 0) { + await this.#alignAccountsForRange( + { from: 0, to: nextGroupIndex - 1 }, + this.#providers, + { + trace: { + data: { + discovery: true, // Tag to identify discovery-alignment traces in analytics. + }, + }, + }, + ); + } + + return providerContexts.flatMap((context) => context.accounts); + }); + } +} diff --git a/packages/multichain-account-service/src/analytics/index.ts b/packages/multichain-account-service/src/analytics/index.ts new file mode 100644 index 00000000000..ad422446927 --- /dev/null +++ b/packages/multichain-account-service/src/analytics/index.ts @@ -0,0 +1 @@ +export * from './traces.js'; diff --git a/packages/multichain-account-service/src/analytics/perf.test.ts b/packages/multichain-account-service/src/analytics/perf.test.ts new file mode 100644 index 00000000000..730be4b9734 --- /dev/null +++ b/packages/multichain-account-service/src/analytics/perf.test.ts @@ -0,0 +1,173 @@ +import type { TraceCallback, TraceRequest } from '@metamask/controller-utils'; + +import { projectLogger } from '../logger.js'; +import { + isPerfEnabled, + log as perfLog, + tick, + withLocalPerfTrace, +} from './perf.js'; +import { now } from './timer.js'; + +jest.mock('./timer', () => ({ + now: jest.fn(), +})); + +jest.mock('../logger', () => ({ + projectLogger: { enabled: false }, + createModuleLogger: jest + .fn() + .mockReturnValue(Object.assign(jest.fn(), { enabled: false })), +})); + +const mockProjectLogger = projectLogger as { enabled: boolean }; +const mockPerfLog = perfLog as unknown as { enabled: boolean }; + +describe('perf', () => { + describe('isPerfEnabled', () => { + it('returns false when projectLogger is disabled', () => { + mockProjectLogger.enabled = false; + mockPerfLog.enabled = false; + expect(isPerfEnabled()).toBe(false); + }); + + it('returns true when projectLogger is enabled', () => { + mockProjectLogger.enabled = true; + expect(isPerfEnabled()).toBe(true); + mockProjectLogger.enabled = false; + }); + + it('returns true when (perf) log is enabled', () => { + mockPerfLog.enabled = true; + expect(isPerfEnabled()).toBe(true); + mockPerfLog.enabled = false; + }); + }); + + describe('tick', () => { + const request: TraceRequest = { name: 'test-operation' }; + + beforeEach(() => { + jest.mocked(now).mockReset(); + }); + + afterEach(() => { + mockProjectLogger.enabled = false; + mockPerfLog.enabled = false; + }); + + it('returns a no-op when perf is disabled', () => { + mockProjectLogger.enabled = false; + const tock = tick(request); + + expect(now).not.toHaveBeenCalled(); + expect(tock()).toBeUndefined(); + }); + + it('captures start time when perf is enabled', () => { + mockProjectLogger.enabled = true; + jest.mocked(now).mockReturnValueOnce(100); + + tick(request); + + expect(now).toHaveBeenCalledTimes(1); + }); + + it('logs elapsed time when tock is called', () => { + mockProjectLogger.enabled = true; + jest.mocked(now).mockReturnValueOnce(100).mockReturnValueOnce(250); + + const tock = tick(request); + tock(); + + expect(now).toHaveBeenCalledTimes(2); + }); + + it('includes JSON-encoded data in the log when request has data', () => { + mockProjectLogger.enabled = true; + const requestWithData: TraceRequest = { + name: 'test-operation', + data: { foo: 'bar' }, + }; + jest.mocked(now).mockReturnValueOnce(0).mockReturnValueOnce(42); + + // Should not throw regardless of data shape + const tock = tick(requestWithData); + expect(() => tock()).not.toThrow(); + }); + + it('omits context when request has no data', () => { + mockProjectLogger.enabled = true; + jest.mocked(now).mockReturnValueOnce(0).mockReturnValueOnce(10); + + const tock = tick({ name: 'no-data' }); + expect(() => tock()).not.toThrow(); + }); + }); + + describe('withLocalPerfTrace', () => { + const request: TraceRequest = { name: 'wrapped-op' }; + let mockTrace: jest.MockedFunction; + + beforeEach(() => { + mockTrace = jest.fn(); + jest.mocked(now).mockReset(); + }); + + afterEach(() => { + mockProjectLogger.enabled = false; + }); + + it('calls trace directly when perf is disabled', async () => { + mockProjectLogger.enabled = false; + mockTrace.mockResolvedValue('result'); + + const wrapped = withLocalPerfTrace(mockTrace); + const fn = jest.fn().mockReturnValue('result'); + const result = await wrapped(request, fn); + + expect(mockTrace).toHaveBeenCalledTimes(1); + expect(mockTrace).toHaveBeenCalledWith(request, fn); + expect(result).toBe('result'); + expect(now).not.toHaveBeenCalled(); + }); + + it('calls trace and measures timing when perf is enabled', async () => { + mockProjectLogger.enabled = true; + jest.mocked(now).mockReturnValueOnce(0).mockReturnValueOnce(100); + mockTrace.mockResolvedValue('result'); + + const wrapped = withLocalPerfTrace(mockTrace); + const fn = jest.fn().mockReturnValue('result'); + const result = await wrapped(request, fn); + + expect(mockTrace).toHaveBeenCalledTimes(1); + expect(mockTrace).toHaveBeenCalledWith(request, fn); + expect(result).toBe('result'); + expect(now).toHaveBeenCalledTimes(2); + }); + + it('still calls tock when trace throws', async () => { + mockProjectLogger.enabled = true; + jest.mocked(now).mockReturnValueOnce(0).mockReturnValueOnce(50); + const error = new Error('trace failed'); + mockTrace.mockRejectedValue(error); + + const wrapped = withLocalPerfTrace(mockTrace); + + await expect(wrapped(request, jest.fn())).rejects.toThrow(error); + // now called once for tick (start) and once for tock (end) + expect(now).toHaveBeenCalledTimes(2); + }); + + it('works without a fn argument', async () => { + mockProjectLogger.enabled = false; + mockTrace.mockResolvedValue(undefined); + + const wrapped = withLocalPerfTrace(mockTrace); + await wrapped(request); + + expect(mockTrace).toHaveBeenCalledWith(request, undefined); + }); + }); +}); diff --git a/packages/multichain-account-service/src/analytics/perf.ts b/packages/multichain-account-service/src/analytics/perf.ts new file mode 100644 index 00000000000..62e7fd7938d --- /dev/null +++ b/packages/multichain-account-service/src/analytics/perf.ts @@ -0,0 +1,74 @@ +import type { + TraceCallback, + TraceContext, + TraceRequest, +} from '@metamask/controller-utils'; + +import { createModuleLogger, projectLogger } from '../logger.js'; +import { now } from './timer.js'; + +export const log = createModuleLogger(projectLogger, 'perf'); + +/** + * Returns true when DEBUG=metamask:multichain-account-service, DEBUG=metamask:multichain-account-service:perf + * or a matching glob is set. + * Re-uses the same enable/disable logic as the rest of the package loggers. + * + * @returns True if performance logging is enabled, false otherwise. + */ +export function isPerfEnabled(): boolean { + return projectLogger.enabled || log.enabled; +} + +/** + * Starts a local performance timer. Returns a `tock` function that, when called, + * logs the elapsed time for `label`. + * + * @example + * ```ts + * const tock = tick(request); + * await createAccounts(...); + * tock(); // logs: "${request.name}: 123.45ms" + * ``` + * + * @param request - A trace request object containing the name and optional data. + * @returns A function that, when called, logs the elapsed time since `tick` was called. + */ +export function tick(request: TraceRequest): () => void { + if (!isPerfEnabled()) { + return () => undefined; + } + + const start = now(); + return function tock(): void { + const duration = now() - start; + + const context = request.data ? ` (${JSON.stringify(request.data)})` : ''; + + log(`${request.name}${context}: ${duration.toFixed(2)}ms`); + }; +} + +/** + * Wraps a trace callback with local performance logging. + * + * @param trace - The original trace callback to wrap. + * @returns A new trace callback that logs the duration of the traced operation. + */ +export function withLocalPerfTrace(trace: TraceCallback): TraceCallback { + return async ( + request: TraceRequest, + fn?: (context?: TraceContext) => ReturnType, + ): Promise => { + if (!isPerfEnabled()) { + return await trace(request, fn); + } + + const tock = tick(request); + try { + return await trace(request, fn); + } finally { + tock(); + } + }; +} diff --git a/packages/multichain-account-service/src/analytics/timer.ts b/packages/multichain-account-service/src/analytics/timer.ts new file mode 100644 index 00000000000..c4b2093cf8b --- /dev/null +++ b/packages/multichain-account-service/src/analytics/timer.ts @@ -0,0 +1,10 @@ +/* istanbul ignore file */ // We use this file mainly to ease testing of performance logging, so we don't need to cover it with tests. + +/** + * Returns the current high-resolution timestamp in milliseconds. This is a thin wrapper around `performance.now()`. + * + * @returns The current high-resolution timestamp in milliseconds. + */ +export function now(): number { + return performance.now(); +} diff --git a/packages/multichain-account-service/src/analytics/traces.test.ts b/packages/multichain-account-service/src/analytics/traces.test.ts new file mode 100644 index 00000000000..d3308097f8c --- /dev/null +++ b/packages/multichain-account-service/src/analytics/traces.test.ts @@ -0,0 +1,130 @@ +import type { TraceRequest } from '@metamask/controller-utils'; +import { AccountCreationType } from '@metamask/keyring-api'; +import type { CreateAccountOptions } from '@metamask/keyring-api'; + +import type { Bip44AccountProvider } from '../providers/index.js'; +import { + toCreateAccountsV2DataTraces, + toProviderDataTraces, + traceFallback, + TraceName, +} from './traces.js'; + +describe('MultichainAccountService - Traces', () => { + describe('traceFallback', () => { + let mockTraceRequest: TraceRequest; + + beforeEach(() => { + mockTraceRequest = { + name: TraceName.SnapDiscoverAccounts, + id: 'trace-id-123', + tags: {}, + }; + }); + + it('returns undefined when no function is provided', async () => { + const result = await traceFallback(mockTraceRequest); + + expect(result).toBeUndefined(); + }); + + it('executes the provided function and return its result', async () => { + const mockResult = 'test-result'; + const mockFn = jest.fn().mockReturnValue(mockResult); + + const result = await traceFallback(mockTraceRequest, mockFn); + + expect(mockFn).toHaveBeenCalledTimes(1); + expect(mockFn).toHaveBeenCalledWith(); + expect(result).toBe(mockResult); + }); + + it('executes async function and return its result', async () => { + const mockResult = { data: 'async-result' }; + const mockAsyncFn = jest.fn().mockResolvedValue(mockResult); + + const result = await traceFallback(mockTraceRequest, mockAsyncFn); + + expect(mockAsyncFn).toHaveBeenCalledTimes(1); + expect(result).toBe(mockResult); + }); + + it('handles function that throws an error', async () => { + const mockError = new Error('Test error'); + const mockFn = jest.fn().mockImplementation(() => { + throw mockError; + }); + + await expect(traceFallback(mockTraceRequest, mockFn)).rejects.toThrow( + mockError, + ); + expect(mockFn).toHaveBeenCalledTimes(1); + }); + + it('handles function that returns a rejected promise', async () => { + const mockError = new Error('Async error'); + const mockFn = jest.fn().mockRejectedValue(mockError); + + await expect(traceFallback(mockTraceRequest, mockFn)).rejects.toThrow( + mockError, + ); + expect(mockFn).toHaveBeenCalledTimes(1); + }); + }); + + describe('toProviderDataTraces', () => { + const mockProvider = (name: string): Bip44AccountProvider => + ({ getName: () => name }) as unknown as Bip44AccountProvider; + + it('returns an empty object for an empty providers list', () => { + expect(toProviderDataTraces([])).toStrictEqual({}); + }); + + it('returns a single entry for a single provider', () => { + expect(toProviderDataTraces([mockProvider('evm')])).toStrictEqual({ + evm: true, + }); + }); + + it('returns one entry per provider', () => { + expect( + toProviderDataTraces([mockProvider('evm'), mockProvider('btc')]), + ).toStrictEqual({ evm: true, btc: true }); + }); + }); + + describe('toCreateAccountsV2DataTraces', () => { + it('returns groupIndex for bip44:derive-index options', () => { + const options: CreateAccountOptions = { + type: AccountCreationType.Bip44DeriveIndex, + entropySource: 'entropy-source-id', + groupIndex: 3, + }; + + expect(toCreateAccountsV2DataTraces(options)).toStrictEqual({ + groupIndex: 3, + }); + }); + + it('returns range bounds for bip44:derive-index-range options', () => { + const options: CreateAccountOptions = { + type: AccountCreationType.Bip44DeriveIndexRange, + entropySource: 'entropy-source-id', + range: { from: 0, to: 5 }, + }; + + expect(toCreateAccountsV2DataTraces(options)).toStrictEqual({ + from: 0, + to: 5, + }); + }); + + it('returns empty options otherwise', () => { + const options: CreateAccountOptions = { + type: AccountCreationType.Custom, + }; + + expect(toCreateAccountsV2DataTraces(options)).toStrictEqual({}); + }); + }); +}); diff --git a/packages/multichain-account-service/src/analytics/traces.ts b/packages/multichain-account-service/src/analytics/traces.ts new file mode 100644 index 00000000000..d6e1be0b810 --- /dev/null +++ b/packages/multichain-account-service/src/analytics/traces.ts @@ -0,0 +1,82 @@ +import type { + TraceCallback, + TraceContext, + TraceRequest, +} from '@metamask/controller-utils'; +import { CreateAccountOptions } from '@metamask/keyring-api'; + +// Explicit import to avoid circular dependency between `analytics` and `providers`. +import type { Bip44AccountProvider } from '../providers/BaseBip44AccountProvider.js'; + +/** + * Fallback function for tracing. + * This function is used when no specific trace function is provided. + * It executes the provided function in a trace context if available. + * + * @param _request - The trace request containing additional data and context. + * @param fn - The function to execute within the trace context. + * @returns A promise that resolves to the result of the executed function. + * If no function is provided, it resolves to undefined. + */ +export const traceFallback: TraceCallback = async ( + _request: TraceRequest, + fn?: (context?: TraceContext) => ReturnType, +): Promise => { + if (!fn) { + return undefined as ReturnType; + } + return await Promise.resolve(fn()); +}; + +/** + * Compute trace data for a list of providers. + * + * @param providers Providers to be included in the trace data. + * @returns An object mapping provider names to true, indicating their presence in the trace. + */ +export function toProviderDataTraces( + providers: Bip44AccountProvider[], +): Record { + // We cannot use complex objects within traces, so we just map provider names with true. + return providers.reduce( + (data, provider) => ({ + ...data, + [provider.getName()]: true, + }), + {}, + ); +} + +/** + * Compute trace data for `createAccounts` options. + * + * @param options The `createAccounts` options. + * @returns An object containing options data depending on its type. + */ +export function toCreateAccountsV2DataTraces( + options: CreateAccountOptions, +): Record { + if (options.type === 'bip44:derive-index') { + return { + groupIndex: options.groupIndex, + }; + } else if (options.type === 'bip44:derive-index-range') { + return { + from: options.range.from, + to: options.range.to, + }; + } + return {}; +} + +/** + * Trace names. + */ +export enum TraceName { + SnapDiscoverAccounts = 'Snap Discover Accounts', + EvmDiscoverAccounts = 'EVM Discover Accounts', + ProviderCreateAccounts = 'Provider Create Accounts (v2 - batched)', + WalletAlignment = 'Wallet Alignment', + WalletCreateMultichainAccountGroup = 'Wallet Create Multichain Account Group', + WalletCreateMultichainAccountGroups = 'Wallet Create Multichain Account Groups', +} diff --git a/packages/multichain-account-service/src/errors.test.ts b/packages/multichain-account-service/src/errors.test.ts new file mode 100644 index 00000000000..986a35e123b --- /dev/null +++ b/packages/multichain-account-service/src/errors.test.ts @@ -0,0 +1,71 @@ +import { + KeyringControllerError, + KeyringControllerErrorMessage, +} from '@metamask/keyring-controller'; + +import { reportError } from './errors.js'; +import { logErrorAs } from './logger.js'; +import { TimeoutError } from './providers/utils.js'; + +jest.mock('./logger', () => ({ + logErrorAs: jest.fn(), +})); + +describe('reportError', () => { + const message = 'Unable to create account'; + + beforeEach(() => { + jest.spyOn(console, 'warn').mockImplementation(); + jest.spyOn(console, 'error').mockImplementation(); + }); + + it.each([ + { + name: 'timeout errors', + error: new TimeoutError('Timed out after: 500ms'), + }, + { + name: 'keyring controller locked errors', + error: new KeyringControllerError( + KeyringControllerErrorMessage.ControllerLocked, + ), + }, + ])('logs $name as warnings without capturing them', ({ error }) => { + const messenger = { captureException: jest.fn() }; + + reportError(messenger, message, error); + + expect(logErrorAs).toHaveBeenCalledWith('warn', message, error); + expect(console.warn).toHaveBeenCalledWith(message, error); + expect(console.error).not.toHaveBeenCalled(); + expect(messenger.captureException).not.toHaveBeenCalled(); + }); + + it('logs unexpected errors and captures them with context', () => { + const error = new Error('Something went wrong'); + const context = { accountId: 'account-id' }; + const messenger = { captureException: jest.fn() }; + + reportError(messenger, message, error, context); + + expect(logErrorAs).toHaveBeenCalledWith('error', message, error); + expect(console.error).toHaveBeenCalledWith(message, error); + expect(console.warn).not.toHaveBeenCalled(); + expect(messenger.captureException).toHaveBeenCalledWith( + expect.objectContaining({ + message, + cause: error, + context, + }), + ); + }); + + it('does not throw if captureException is not provided', () => { + const error = new Error('Something went wrong'); + + expect(() => reportError({}, message, error)).not.toThrow(); + + expect(logErrorAs).toHaveBeenCalledWith('error', message, error); + expect(console.error).toHaveBeenCalledWith(message, error); + }); +}); diff --git a/packages/multichain-account-service/src/errors.ts b/packages/multichain-account-service/src/errors.ts new file mode 100644 index 00000000000..425a882591c --- /dev/null +++ b/packages/multichain-account-service/src/errors.ts @@ -0,0 +1,36 @@ +import { logErrorAs } from './logger.js'; +import { + isKeyringControllerLockedError, + isTimeoutError, +} from './providers/utils.js'; +import { createSentryError } from './utils.js'; + +/** + * Reports an error by logging it and optionally capturing it in Sentry. + * + * Timeout errors are treated as warnings (not reported to Sentry). All other + * errors are logged as errors and captured via `captureException`. + * + * @param messenger - Object with an optional `captureException` method. + * @param messenger.captureException - Optional method to capture exceptions in Sentry. + * @param message - The static message describing what failed. + * @param error - The caught error. + * @param context - Optional context to attach to the Sentry error. + */ +export function reportError( + messenger: { captureException?: (error: Error) => void }, + message: string, + error: unknown, + context?: Record, +): void { + if (isTimeoutError(error) || isKeyringControllerLockedError(error)) { + logErrorAs('warn', message, error); + console.warn(message, error); + } else { + logErrorAs('error', message, error); + console.error(message, error); + + const sentryError = createSentryError(message, error as Error, context); + messenger.captureException?.(sentryError); + } +} diff --git a/packages/multichain-account-service/src/index.ts b/packages/multichain-account-service/src/index.ts new file mode 100644 index 00000000000..d63916b7882 --- /dev/null +++ b/packages/multichain-account-service/src/index.ts @@ -0,0 +1,43 @@ +export type { + MultichainAccountServiceActions, + MultichainAccountServiceEvents, + MultichainAccountServiceMessenger, + MultichainAccountServiceMultichainAccountGroupCreatedEvent, + MultichainAccountServiceMultichainAccountGroupUpdatedEvent, + MultichainAccountServiceWalletStatusChangeEvent, +} from './types.js'; +export type { + MultichainAccountServiceResyncAccountsAction, + MultichainAccountServiceGetMultichainAccountWalletAction, + MultichainAccountServiceGetMultichainAccountWalletsAction, + MultichainAccountServiceCreateMultichainAccountWalletAction, + MultichainAccountServiceRemoveMultichainAccountWalletAction, + MultichainAccountServiceGetMultichainAccountGroupAction, + MultichainAccountServiceGetMultichainAccountGroupsAction, + MultichainAccountServiceCreateNextMultichainAccountGroupAction, + MultichainAccountServiceCreateMultichainAccountGroupAction, + MultichainAccountServiceCreateMultichainAccountGroupsAction, + MultichainAccountServiceSetBasicFunctionalityAction, + MultichainAccountServiceAlignWalletsAction, + MultichainAccountServiceAlignWalletAction, + MultichainAccountServiceInitAction, +} from './MultichainAccountService-method-action-types.js'; +export { + AccountProviderWrapper, + BaseBip44AccountProvider, + SnapAccountProvider, + TimeoutError, + EVM_ACCOUNT_PROVIDER_NAME, + EvmAccountProvider, + SOL_ACCOUNT_PROVIDER_NAME, + SolAccountProvider, + BTC_ACCOUNT_PROVIDER_NAME, + BtcAccountProvider, + TRX_ACCOUNT_PROVIDER_NAME, + TrxAccountProvider, + XLM_ACCOUNT_PROVIDER_NAME, + XlmAccountProvider, +} from './providers/index.js'; +export { MultichainAccountWallet } from './MultichainAccountWallet.js'; +export { MultichainAccountGroup } from './MultichainAccountGroup.js'; +export { MultichainAccountService } from './MultichainAccountService.js'; diff --git a/packages/multichain-account-service/src/logger.ts b/packages/multichain-account-service/src/logger.ts new file mode 100644 index 00000000000..98049e7d3d9 --- /dev/null +++ b/packages/multichain-account-service/src/logger.ts @@ -0,0 +1,28 @@ +import { createProjectLogger, createModuleLogger } from '@metamask/utils'; + +import { toErrorMessage } from './utils.js'; + +export const projectLogger = createProjectLogger('multichain-account-service'); + +export { createModuleLogger }; + +export const WARNING_PREFIX = 'WARNING --'; +export const ERROR_PREFIX = 'ERROR --'; + +export type Logger = typeof projectLogger; + +/** + * Logs an error with either WARNING or ERROR prefix, appending the error message. + * + * @param level - 'warn' for WARNING prefix, 'error' for ERROR prefix. + * @param message - The static message describing what failed. + * @param error - The caught error. + */ +export function logErrorAs( + level: 'warn' | 'error', + message: string, + error: unknown, +): void { + const prefix = level === 'warn' ? WARNING_PREFIX : ERROR_PREFIX; + projectLogger(`${prefix} ${message}: ${toErrorMessage(error)}`); +} diff --git a/packages/multichain-account-service/src/providers/AccountProviderWrapper.test.ts b/packages/multichain-account-service/src/providers/AccountProviderWrapper.test.ts new file mode 100644 index 00000000000..f449f1978a7 --- /dev/null +++ b/packages/multichain-account-service/src/providers/AccountProviderWrapper.test.ts @@ -0,0 +1,86 @@ +import { + getMultichainAccountServiceMessenger, + getRootMessenger, + MOCK_WALLET_1_ENTROPY_SOURCE, +} from '../tests/index.js'; +import { AccountProviderWrapper } from './AccountProviderWrapper.js'; +import { EvmAccountProvider } from './EvmAccountProvider.js'; + +function setup(): { + wrapper: AccountProviderWrapper; + innerProvider: EvmAccountProvider; +} { + const messenger = getRootMessenger(); + const serviceMessenger = getMultichainAccountServiceMessenger(messenger); + const innerProvider = new EvmAccountProvider(serviceMessenger); + const wrapper = new AccountProviderWrapper(serviceMessenger, innerProvider); + return { wrapper, innerProvider }; +} + +describe('AccountProviderWrapper', () => { + describe('ensureReady', () => { + it('delegates to the inner provider when enabled', async () => { + const { wrapper, innerProvider } = setup(); + const ensureReadySpy = jest.spyOn(innerProvider, 'ensureReady'); + + await wrapper.ensureReady(); + + expect(ensureReadySpy).toHaveBeenCalledTimes(1); + }); + + it('returns immediately without calling the inner provider when disabled', async () => { + const { wrapper, innerProvider } = setup(); + wrapper.setEnabled(false); + const ensureReadySpy = jest.spyOn(innerProvider, 'ensureReady'); + + await wrapper.ensureReady(); + + expect(ensureReadySpy).not.toHaveBeenCalled(); + }); + }); + + describe('isAligned', () => { + it('returns true unconditionally when the wrapper is disabled', () => { + const { wrapper } = setup(); + wrapper.setEnabled(false); + + expect( + wrapper.isAligned( + { entropySource: MOCK_WALLET_1_ENTROPY_SOURCE, groupIndex: 0 }, + [], + ), + ).toBe(true); + + expect( + wrapper.isAligned( + { entropySource: MOCK_WALLET_1_ENTROPY_SOURCE, groupIndex: 0 }, + ['some-id'], + ), + ).toBe(true); + }); + + it('delegates to the inner provider when enabled and accounts are owned', () => { + const { wrapper, innerProvider } = setup(); + const accountId = 'owned-id'; + innerProvider.init([accountId]); + + expect( + wrapper.isAligned( + { entropySource: MOCK_WALLET_1_ENTROPY_SOURCE, groupIndex: 0 }, + [accountId], + ), + ).toBe(true); + }); + + it('delegates to the inner provider when enabled and accounts are not owned', () => { + const { wrapper } = setup(); + + expect( + wrapper.isAligned( + { entropySource: MOCK_WALLET_1_ENTROPY_SOURCE, groupIndex: 0 }, + [], + ), + ).toBe(false); + }); + }); +}); diff --git a/packages/multichain-account-service/src/providers/AccountProviderWrapper.ts b/packages/multichain-account-service/src/providers/AccountProviderWrapper.ts new file mode 100644 index 00000000000..39afdd92d15 --- /dev/null +++ b/packages/multichain-account-service/src/providers/AccountProviderWrapper.ts @@ -0,0 +1,225 @@ +import type { Bip44Account } from '@metamask/account-api'; +import type { + CreateAccountOptions, + EntropySourceId, + KeyringAccount, +} from '@metamask/keyring-api'; +import type { KeyringCapabilities } from '@metamask/keyring-api/v2'; +import type { InternalAccount } from '@metamask/keyring-internal-api'; + +import type { MultichainAccountServiceMessenger } from '../types.js'; +import { BaseBip44AccountProvider } from './BaseBip44AccountProvider.js'; + +/** + * A simple wrapper that adds disable functionality to any BaseBip44AccountProvider. + * When disabled, the provider will not create new accounts and return empty results. + */ +export class AccountProviderWrapper extends BaseBip44AccountProvider { + private isEnabled: boolean = true; + + private readonly provider: BaseBip44AccountProvider; + + constructor( + messenger: MultichainAccountServiceMessenger, + provider: BaseBip44AccountProvider, + ) { + super(messenger); + this.provider = provider; + } + + override getName(): string { + return this.provider.getName(); + } + + get capabilities(): KeyringCapabilities { + return this.provider.capabilities; + } + + /** + * Forward initialization to the wrapped provider to ensure both + * instances share the same visible account IDs. + * + * @param accounts - Account IDs to initialize with. + */ + override init(accounts: Bip44Account['id'][]): void { + this.provider.init(accounts); + } + + /** + * Returns the underlying (unwrapped) provider. + * + * Most callers should go through the wrapper's public surface so that the + * `disabled` gating is respected. This escape hatch is reserved for + * cleanup flows (e.g. wallet removal) that must see the wrapped + * provider's accounts regardless of enabled state, so that snap-backed + * accounts created while the provider was enabled can still be deleted + * after it has been disabled. + * + * @returns The wrapped provider instance. + */ + unwrap(): BaseBip44AccountProvider { + return this.provider; + } + + /** + * Set the enabled state for this provider. + * + * @param enabled - Whether the provider should be enabled. + */ + setEnabled(enabled: boolean): void { + this.isEnabled = enabled; + } + + /** + * Check if the provider is disabled. + * + * @returns True if the provider is disabled, false otherwise. + */ + isDisabled(): boolean { + return !this.isEnabled; + } + + /** + * Override resyncAccounts to not execute it when disabled. + * + * @param accounts - List of local accounts. + */ + override async resyncAccounts( + accounts: Bip44Account[], + ): Promise { + if (!this.isEnabled) { + return; + } + await this.provider.resyncAccounts(accounts); + } + + /** + * Override getAccounts to return empty array when disabled. + * + * @returns Array of accounts, or empty array if disabled. + */ + override getAccounts(): Bip44Account[] { + if (!this.isEnabled) { + return []; + } + return this.provider.getAccounts(); + } + + /** + * Override getAccount to throw when disabled. + * + * @param id - The account ID to retrieve. + * @returns The account with the specified ID. + * @throws When disabled or account not found. + */ + override getAccount( + id: Bip44Account['id'], + ): Bip44Account { + if (!this.isEnabled) { + throw new Error('Provider is disabled'); + } + return this.provider.getAccount(id); + } + + /** + * Returns true immediately when disabled (a disabled provider is considered + * aligned by definition). Delegates to the wrapped provider otherwise. + * + * @param context - The entropy source and group index to check. + * @param context.entropySource - The entropy source to check against. + * @param context.groupIndex - The group index to check against. + * @param accountIds - Account IDs pre-filtered by the caller. + * @returns Whether the provider is aligned for the given context. + */ + override isAligned( + context: { entropySource: EntropySourceId; groupIndex: number }, + accountIds: Bip44Account['id'][], + ): boolean { + if (!this.isEnabled) { + return true; + } + return this.provider.isAligned(context, accountIds); + } + + /** + * Implement abstract method: Check if account is compatible. + * Delegates directly to wrapped provider - no runtime checks needed! + * + * @param account - The account to check. + * @returns True if the account is compatible. + */ + isAccountCompatible(account: Bip44Account): boolean { + return this.provider.isAccountCompatible(account); + } + + /** + * Implement abstract method: Create accounts, returns empty array when disabled. + * + * @param options - Account creation options. + * @returns Promise resolving to created accounts, or empty array if disabled. + */ + async createAccounts( + options: CreateAccountOptions, + ): Promise[]> { + if (!this.isEnabled) { + return []; + } + return this.provider.createAccounts(options); + } + + /** + * Returns immediately when disabled. Delegates to the wrapped provider otherwise, + * waiting for the underlying platform (e.g. snap runtime) to be ready. + * + * @returns A promise that resolves when the provider is ready to use. + */ + override async ensureReady(): Promise { + if (!this.isEnabled) { + return; + } + await this.provider.ensureReady(); + } + + /** + * Forwards to the wrapped provider unconditionally, because deletion must run even + * when the wrapper is disabled, so that wallet-removal flows can clean up + * snap-backed accounts that were created while the provider was previously + * enabled. + * + * @param id - The id of the account to delete. + * @returns A promise that resolves when the account is deleted. + */ + async deleteAccount(id: Bip44Account['id']): Promise { + return this.provider.deleteAccount(id); + } + + /** + * Implement abstract method: Discover and create accounts, returns empty array when disabled. + * + * @param options - Account discovery options. + * @param options.entropySource - The entropy source to use. + * @param options.groupIndex - The group index to use. + * @returns Promise resolving to discovered accounts, or empty array if disabled. + */ + async discoverAccounts(options: { + entropySource: EntropySourceId; + groupIndex: number; + }): Promise[]> { + if (!this.isEnabled) { + return []; + } + return this.provider.discoverAccounts(options); + } +} + +/** + * Simple type guard to check if a provider is wrapped. + * + * @param provider - The provider to check. + * @returns True if the provider is an AccountProviderWrapper. + */ +export function isAccountProviderWrapper( + provider: unknown, +): provider is AccountProviderWrapper { + return provider instanceof AccountProviderWrapper; +} diff --git a/packages/multichain-account-service/src/providers/BaseBip44AccountProvider.ts b/packages/multichain-account-service/src/providers/BaseBip44AccountProvider.ts new file mode 100644 index 00000000000..6554a1229fe --- /dev/null +++ b/packages/multichain-account-service/src/providers/BaseBip44AccountProvider.ts @@ -0,0 +1,270 @@ +import { isBip44Account } from '@metamask/account-api'; +import type { AccountProvider, Bip44Account } from '@metamask/account-api'; +import type { + CreateAccountOptions, + EntropySourceId, + KeyringAccount, +} from '@metamask/keyring-api'; +import type { + Keyring as KeyringV2, + KeyringCapabilities, +} from '@metamask/keyring-api/v2'; +import type { + KeyringMetadata, + KeyringSelectorV2, +} from '@metamask/keyring-controller'; +import type { InternalAccount } from '@metamask/keyring-internal-api'; + +import type { MultichainAccountServiceMessenger } from '../types.js'; + +/** + * Asserts a keyring account is BIP-44 compatible. + * + * @param account - Keyring account to check. + * @throws If the keyring account is not compatible. + */ +export function assertIsBip44Account( + account: KeyringAccount, +): asserts account is Bip44Account { + if (!isBip44Account(account)) { + throw new Error('Created account is not BIP-44 compatible'); + } +} + +/** + * Asserts that a list of keyring accounts are all BIP-44 compatible. + * + * @param accounts - Keyring accounts to check. + * @throws If any of the keyring account is not compatible. + */ +export function assertAreBip44Accounts( + accounts: KeyringAccount[], +): asserts accounts is Bip44Account[] { + accounts.forEach(assertIsBip44Account); +} + +export type Bip44AccountProvider< + Account extends Bip44Account = Bip44Account, +> = AccountProvider & { + /** + * Provider capabilities, including supported scopes and BIP-44 options. + * + * @returns The provider capabilities. + */ + get capabilities(): KeyringCapabilities; + /** + * Get the name of the provider. + * + * @returns The name of the provider. + */ + getName(): string; + /** + * Initialize the provider with the given accounts. + * + * @param accounts - The accounts to initialize the provider with. + */ + init(accounts: Bip44Account['id'][]): void; + /** + * Check if the account is compatible with the provider. + */ + isAccountCompatible(account: Bip44Account): boolean; + /** + * Create accounts for the provider. + * + * @param options - The options for creating the accounts. + * @param options.entropySource - The entropy source. + * @param options.groupIndex - The group index. + * @param options.type - The type of account creation. + * @returns The created accounts. + */ + createAccounts(options: CreateAccountOptions): Promise; + /** + * Delete an account managed by this provider. + * + * Mirrors the v2 keyring `deleteAccount(accountId)` contract. Each provider + * implementation is responsible for resolving any extra information it needs + * (e.g. address for snap-based providers) and for performing the underlying + * keyring removal. + * + * @param id - The id of the account to delete. + */ + deleteAccount(id: Account['id']): Promise; + /** + * Re-synchronize MetaMask accounts and the providers accounts if needed. + * + * NOTE: This is mostly required if one of the providers (keyrings or Snaps) + * have different sets of accounts. This method would ensure that both are + * in-sync and use the same accounts (and same IDs). + */ + resyncAccounts(accounts: Bip44Account[]): Promise; + /** + * Check if the provider has an aligned (i.e. present and owned) account for + * the given entropy source and group index. + * + * Callers pre-filter the relevant account IDs from the group and pass them + * in so the provider needs no messenger call. + * + * @param context - The entropy source and group index to check. + * @param context.entropySource - The entropy source to check against. + * @param context.groupIndex - The group index to check against. + * @param accountIds - Account IDs already associated with this provider for + * the given group (may be empty if no alignment has happened yet). + * @returns `true` when `accountIds` is non-empty and every ID is in the + * provider's internal accounts Set. + */ + isAligned( + context: { entropySource: EntropySourceId; groupIndex: number }, + accountIds: Account['id'][], + ): boolean; + /** + * Ensures the provider is ready before any account operation is attempted: + * - EVM providers return immediately. + * - Snap providers will wait for the Snap platform and keyring to be available. + * + * @returns A promise that resolves when the provider is ready to use. + */ + ensureReady(): Promise; +}; + +export abstract class BaseBip44AccountProvider< + Account extends Bip44Account = Bip44Account, +> implements Bip44AccountProvider { + protected readonly messenger: MultichainAccountServiceMessenger; + + protected accounts: Set['id']> = new Set(); + + constructor(messenger: MultichainAccountServiceMessenger) { + this.messenger = messenger; + } + + /** + * Add accounts to the provider. + * + * Note: There's an implicit assumption that the accounts are BIP-44 compatible. + * + * @param accounts - The accounts to add. + */ + init(accounts: Account['id'][]): void { + for (const account of accounts) { + this.accounts.add(account); + } + } + + /** + * Get the accounts list for the provider. + * + * @returns The accounts list. + */ + #getAccountIds(): Account['id'][] { + return [...this.accounts]; + } + + /** + * Get the accounts list for the provider from the AccountsController. + * + * @returns The accounts list. + */ + getAccounts(): Account[] { + const accountsIds = this.#getAccountIds(); + const internalAccounts = this.messenger.call( + 'AccountsController:getAccounts', + accountsIds, + ); + // we cast here because we know that the accounts are BIP-44 compatible + return internalAccounts as unknown as Account[]; + } + + /** + * Get the account for the provider. + * + * @param id - The account ID. + * @returns The account. + * @throws If the account is not found. + */ + getAccount(id: Account['id']): Account { + const hasAccount = this.accounts.has(id); + + if (!hasAccount) { + throw new Error(`Unable to find account: ${id}`); + } + + // We need to upcast here since InternalAccounts are not always BIP-44 compatible + // but we know that the account is BIP-44 compatible here so it is safe to do so + return this.messenger.call( + 'AccountsController:getAccount', + id, + ) as unknown as Account; + } + + /** + * Run an operation against a V2 keyring selected by `selector`. + * + * Forwards to `KeyringController:withKeyringV2`. Use this for keyrings + * that implement the unified V2 `Keyring` interface from + * `@metamask/keyring-api/v2`. + * + * @param selector - The selector identifying the keyring. + * @param operation - The operation to run with the selected keyring. + * @returns The result of the operation. + */ + protected async withKeyringV2< + SelectedKeyring extends KeyringV2 = KeyringV2, + CallbackResult = void, + >( + selector: KeyringSelectorV2, + operation: ({ + keyring, + metadata, + }: { + keyring: SelectedKeyring; + metadata: KeyringMetadata; + }) => Promise, + ): Promise { + const result = await this.messenger.call( + 'KeyringController:withKeyringV2', + selector, + ({ keyring, metadata }) => + operation({ + keyring: keyring as SelectedKeyring, + metadata, + }), + ); + + return result as CallbackResult; + } + + isAligned( + _context: { entropySource: EntropySourceId; groupIndex: number }, + accountIds: Account['id'][], + ): boolean { + return ( + accountIds.length >= 1 && accountIds.every((id) => this.accounts.has(id)) + ); + } + + async ensureReady(): Promise { + // No-op for non-snap providers. + } + + abstract get capabilities(): KeyringCapabilities; + + abstract getName(): string; + + abstract resyncAccounts( + accounts: Bip44Account[], + ): Promise; + + abstract isAccountCompatible(account: Bip44Account): boolean; + + abstract createAccounts(options: CreateAccountOptions): Promise; + + abstract deleteAccount(id: Account['id']): Promise; + + abstract discoverAccounts({ + entropySource, + groupIndex, + }: { + entropySource: EntropySourceId; + groupIndex: number; + }): Promise; +} diff --git a/packages/multichain-account-service/src/providers/BtcAccountProvider.test.ts b/packages/multichain-account-service/src/providers/BtcAccountProvider.test.ts new file mode 100644 index 00000000000..a2bcbeff7f8 --- /dev/null +++ b/packages/multichain-account-service/src/providers/BtcAccountProvider.test.ts @@ -0,0 +1,586 @@ +import { isBip44Account } from '@metamask/account-api'; +import { AccountCreationType, BtcScope } from '@metamask/keyring-api'; +import type { KeyringCapabilities } from '@metamask/keyring-api/v2'; +import type { KeyringMetadata } from '@metamask/keyring-controller'; +import type { InternalAccount } from '@metamask/keyring-internal-api'; +import { SnapControllerState } from '@metamask/snaps-controllers'; +import deepmerge from 'deepmerge'; + +import { TraceName } from '../analytics/traces.js'; +import { + getMultichainAccountServiceMessenger, + getRootMessenger, + MOCK_BTC_P2TR_ACCOUNT_1, + MOCK_BTC_P2WPKH_ACCOUNT_1, + MOCK_BTC_P2TR_DISCOVERED_ACCOUNT_1, + MOCK_HD_ACCOUNT_1, + MOCK_HD_KEYRING_1, + MockAccountBuilder, + toGroupIndexRangeArray, +} from '../tests/index.js'; +import type { RootMessenger, DeepPartial } from '../tests/index.js'; +import { AccountProviderWrapper } from './AccountProviderWrapper.js'; +import { + BTC_ACCOUNT_PROVIDER_DEFAULT_CONFIG, + BTC_ACCOUNT_PROVIDER_NAME, + BtcAccountProvider, +} from './BtcAccountProvider.js'; +import type { SnapAccountProviderConfig } from './SnapAccountProvider.js'; + +function asConfig( + partial: DeepPartial, +): SnapAccountProviderConfig { + return deepmerge( + BTC_ACCOUNT_PROVIDER_DEFAULT_CONFIG, + partial, + ) as SnapAccountProviderConfig; +} + +/** + * v2 capabilities as declared by a fully v2-compliant Bitcoin Snap manifest. + * Drives the batched `createAccounts` flow and the v2 discovery path. + */ +const BTC_V2_CAPABILITIES: KeyringCapabilities = { + scopes: [BtcScope.Mainnet], + bip44: { + deriveIndex: true, + deriveIndexRange: true, + discover: true, + }, +}; + +class MockBtcKeyring { + readonly type = 'MockBtcKeyring'; + + readonly metadata: KeyringMetadata = { + id: 'mock-btc-keyring-id', + name: '', + }; + + readonly accounts: InternalAccount[]; + + constructor(accounts: InternalAccount[]) { + this.accounts = accounts; + } + + createAccounts = jest.fn().mockImplementation((options) => { + const groupIndices = + options.type === 'bip44:derive-index' + ? [options.groupIndex] + : toGroupIndexRangeArray(options.range); + + return groupIndices.map((groupIndex) => { + const found = this.accounts.find( + (account) => + isBip44Account(account) && + account.options.entropy.groupIndex === groupIndex, + ); + + if (found) { + return found; // Idempotent. + } + + const account = MockAccountBuilder.from(MOCK_BTC_P2WPKH_ACCOUNT_1) + .withUuid() + .withAddressSuffix(`${groupIndex}`) + .withGroupIndex(groupIndex) + .get(); + this.accounts.push(account); + return account; + }); + }); + + deleteAccount = jest.fn().mockResolvedValue(undefined); +} + +class MockBtcAccountProvider extends BtcAccountProvider { + override async ensureReady(): Promise { + // Override to avoid waiting during tests. + } +} + +/** + * Sets up a BtcAccountProvider for testing. + * + * @param options - Configuration options for setup. + * @param options.messenger - An optional messenger instance to use. Defaults to a new Messenger. + * @param options.accounts - List of accounts to use. + * @param options.config - Provider config. + * @param options.capabilities - The Snap keyring capabilities to expose via `SnapAccountService:getCapabilities`. + * @returns An object containing the controller instance and the messenger. + */ +function setup({ + messenger = getRootMessenger(), + accounts = [], + config, + capabilities = { scopes: [] }, +}: { + messenger?: RootMessenger; + accounts?: InternalAccount[]; + config?: SnapAccountProviderConfig; + capabilities?: KeyringCapabilities; +} = {}): { + provider: AccountProviderWrapper; + messenger: RootMessenger; + keyring: MockBtcKeyring; + mocks: { + handleRequest: jest.Mock; + keyring: { + createAccounts: jest.Mock; + }; + trace: jest.Mock; + }; +} { + const keyring = new MockBtcKeyring(accounts); + + messenger.registerActionHandler( + 'AccountsController:getAccounts', + () => accounts, + ); + + messenger.registerActionHandler( + 'SnapController:getState', + () => ({ isReady: true }) as SnapControllerState, + ); + + messenger.registerActionHandler( + 'SnapAccountService:getCapabilities', + async () => capabilities, + ); + + messenger.registerActionHandler( + 'AccountsController:listMultichainAccounts', + () => accounts, + ); + + const mockGetAccount = jest.fn().mockImplementation((id) => { + return keyring.accounts.find((account) => account.id === id); + }); + messenger.registerActionHandler( + 'AccountsController:getAccount', + mockGetAccount, + ); + + const mockHandleRequest = jest + .fn() + .mockImplementation((address: string) => + keyring.accounts.find((account) => account.address === address), + ); + messenger.registerActionHandler( + 'SnapController:handleRequest', + mockHandleRequest, + ); + + messenger.registerActionHandler( + 'KeyringController:withKeyringV2', + async (_, operation) => + operation({ + keyring, + metadata: keyring.metadata, + }), + ); + + const mockTrace = jest.fn().mockImplementation(async (_request, fn) => { + return await fn(); + }); + + const multichainMessenger = getMultichainAccountServiceMessenger(messenger); + const btcProvider = new MockBtcAccountProvider( + multichainMessenger, + config, + mockTrace, + ); + const accountIds = accounts.map((account) => account.id); + btcProvider.init(accountIds); + const provider = new AccountProviderWrapper(multichainMessenger, btcProvider); + + return { + provider, + messenger, + keyring, + mocks: { + handleRequest: mockHandleRequest, + keyring: { + createAccounts: keyring.createAccounts, + }, + trace: mockTrace, + }, + }; +} + +describe('BtcAccountProvider', () => { + it('getName returns Bitcoin', () => { + const { provider } = setup({ accounts: [] }); + expect(provider.getName()).toBe('Bitcoin'); + }); + + it('gets accounts', () => { + const accounts = [MOCK_BTC_P2WPKH_ACCOUNT_1]; + const { provider } = setup({ + accounts, + }); + + expect(provider.getAccounts()).toStrictEqual(accounts); + }); + + it('gets a specific account', () => { + const account = MOCK_BTC_P2WPKH_ACCOUNT_1; + const { provider } = setup({ + accounts: [account], + }); + + expect(provider.getAccount(account.id)).toStrictEqual(account); + }); + + it('throws if account does not exist', () => { + const account = MOCK_BTC_P2TR_ACCOUNT_1; + const { provider } = setup({ + accounts: [account], + }); + + const unknownAccount = MOCK_HD_ACCOUNT_1; + expect(() => provider.getAccount(unknownAccount.id)).toThrow( + `Unable to find account: ${unknownAccount.id}`, + ); + }); + + it('returns true if an account is compatible', () => { + const account = MOCK_BTC_P2WPKH_ACCOUNT_1; + const { provider } = setup({ + accounts: [account], + }); + expect(provider.isAccountCompatible(account)).toBe(true); + }); + + it('returns false if an account is not compatible', () => { + const account = MOCK_HD_ACCOUNT_1; + const { provider } = setup({ + accounts: [account], + }); + expect(provider.isAccountCompatible(account)).toBe(false); + }); + + it('discover accounts at a new group index creates an account (v1 discovery flow)', async () => { + const { provider, mocks } = setup({ accounts: [] }); + + // Simulate one discovered account at the requested index via v1 client.discoverAccounts. + mocks.handleRequest.mockReturnValue([MOCK_BTC_P2TR_DISCOVERED_ACCOUNT_1]); + + const discovered = await provider.discoverAccounts({ + entropySource: MOCK_HD_KEYRING_1.metadata.id, + groupIndex: 0, + }); + + expect(discovered).toHaveLength(1); + // After v1 discovery, account creation goes through the v2 batched path. + expect(mocks.keyring.createAccounts).toHaveBeenCalled(); + // Provider should now expose one account (newly created) + expect(provider.getAccounts()).toHaveLength(1); + }); + + describe('v2 - batched', () => { + it('creates accounts', async () => { + const accounts = [MOCK_BTC_P2WPKH_ACCOUNT_1]; + const { provider, mocks } = setup({ + accounts, + capabilities: BTC_V2_CAPABILITIES, + }); + + const newGroupIndex = accounts.length; // Group-index are 0-based. + const newAccounts = await provider.createAccounts({ + type: AccountCreationType.Bip44DeriveIndex, + entropySource: MOCK_HD_KEYRING_1.metadata.id, + groupIndex: newGroupIndex, + }); + expect(newAccounts).toHaveLength(1); + // Batch endpoint must be called, NOT the singular one. + expect(mocks.keyring.createAccounts).toHaveBeenCalledWith({ + type: AccountCreationType.Bip44DeriveIndex, + entropySource: MOCK_HD_KEYRING_1.metadata.id, + groupIndex: newGroupIndex, + }); + }); + + it('does not re-create accounts (idempotent)', async () => { + const accounts = [MOCK_BTC_P2WPKH_ACCOUNT_1]; + const { provider } = setup({ + accounts, + capabilities: BTC_V2_CAPABILITIES, + }); + + const newAccounts = await provider.createAccounts({ + entropySource: MOCK_HD_KEYRING_1.metadata.id, + groupIndex: 0, + type: AccountCreationType.Bip44DeriveIndex, + }); + expect(newAccounts).toHaveLength(1); + expect(newAccounts[0]).toStrictEqual(MOCK_BTC_P2WPKH_ACCOUNT_1); + }); + + it('creates multiple accounts using Bip44DeriveIndexRange', async () => { + const accounts = [MOCK_BTC_P2WPKH_ACCOUNT_1]; + const { provider, mocks } = setup({ + accounts, + capabilities: BTC_V2_CAPABILITIES, + }); + + const from = 1; + const newAccounts = await provider.createAccounts({ + type: AccountCreationType.Bip44DeriveIndexRange, + entropySource: MOCK_HD_KEYRING_1.metadata.id, + range: { from, to: 3 }, + }); + + expect(newAccounts).toHaveLength(3); + // Single batch call, NOT three individual calls. + expect(mocks.keyring.createAccounts).toHaveBeenCalledTimes(1); + + // Verify each account has the correct group index. + for (const [index, account] of newAccounts.entries()) { + expect(isBip44Account(account)).toBe(true); + expect(account.options.entropy.groupIndex).toBe(from + index); + } + }); + + it('creates accounts with range starting from 0', async () => { + const { provider, mocks } = setup({ + accounts: [], + capabilities: BTC_V2_CAPABILITIES, + }); + + const newAccounts = await provider.createAccounts({ + type: AccountCreationType.Bip44DeriveIndexRange, + entropySource: MOCK_HD_KEYRING_1.metadata.id, + range: { from: 0, to: 2 }, + }); + + expect(newAccounts).toHaveLength(3); + expect(mocks.keyring.createAccounts).toHaveBeenCalledTimes(1); + }); + + it('creates a single account when range from equals to', async () => { + const { provider, mocks } = setup({ + accounts: [], + capabilities: BTC_V2_CAPABILITIES, + }); + + const newAccounts = await provider.createAccounts({ + type: AccountCreationType.Bip44DeriveIndexRange, + entropySource: MOCK_HD_KEYRING_1.metadata.id, + range: { from: 5, to: 5 }, + }); + + expect(newAccounts).toHaveLength(1); + expect(mocks.keyring.createAccounts).toHaveBeenCalledTimes(1); + expect( + isBip44Account(newAccounts[0]) && + newAccounts[0].options.entropy.groupIndex, + ).toBe(5); + }); + + it('throws if the account creation process takes too long', async () => { + const { provider, mocks } = setup({ + accounts: [], + capabilities: BTC_V2_CAPABILITIES, + }); + + mocks.keyring.createAccounts.mockImplementation( + () => + new Promise((resolve) => { + setTimeout(() => resolve([MOCK_BTC_P2WPKH_ACCOUNT_1]), 4000); + }), + ); + + await expect( + provider.createAccounts({ + type: AccountCreationType.Bip44DeriveIndex, + entropySource: MOCK_HD_KEYRING_1.metadata.id, + groupIndex: 0, + }), + ).rejects.toThrow('Timed out'); + }); + }); + + it('throws an error when type is not "bip44:derive-index"', async () => { + const { provider } = setup(); + + await expect( + provider.createAccounts({ + // @ts-expect-error Testing invalid type handling. + type: 'unsupported-type', + entropySource: MOCK_HD_KEYRING_1.metadata.id, + groupIndex: 0, + }), + ).rejects.toThrow( + 'Unsupported create account option type: unsupported-type', + ); + }); + + it('returns existing account if it already exists at index', async () => { + const { provider, mocks } = setup({ + accounts: [MOCK_BTC_P2WPKH_ACCOUNT_1], + }); + + // Simulate one discovered account — should resolve to the existing one + mocks.handleRequest.mockReturnValue([MOCK_BTC_P2TR_DISCOVERED_ACCOUNT_1]); + + const discovered = await provider.discoverAccounts({ + entropySource: MOCK_HD_KEYRING_1.metadata.id, + groupIndex: 0, + }); + + expect(discovered).toStrictEqual([MOCK_BTC_P2WPKH_ACCOUNT_1]); + }); + + it('does not return any accounts if no account is discovered', async () => { + const { provider, mocks } = setup({ + accounts: [], + }); + + mocks.handleRequest.mockReturnValue([]); + + const discovered = await provider.discoverAccounts({ + entropySource: MOCK_HD_KEYRING_1.metadata.id, + groupIndex: 0, + }); + + expect(discovered).toStrictEqual([]); + }); + + it('returns no accounts when a v2 Snap does not support bip44:discover', async () => { + const { provider, mocks } = setup({ + accounts: [], + capabilities: { + scopes: [BtcScope.Mainnet], + bip44: { deriveIndex: true, deriveIndexRange: true }, + }, + }); + + const discovered = await provider.discoverAccounts({ + entropySource: MOCK_HD_KEYRING_1.metadata.id, + groupIndex: 0, + }); + + expect(discovered).toStrictEqual([]); + expect(mocks.keyring.createAccounts).not.toHaveBeenCalled(); + }); + + it('does not run discovery if disabled', async () => { + const { provider } = setup({ + accounts: [MOCK_BTC_P2WPKH_ACCOUNT_1], + config: asConfig({ + discovery: { + enabled: false, + }, + }), + }); + + expect( + await provider.discoverAccounts({ + entropySource: MOCK_HD_KEYRING_1.metadata.id, + groupIndex: 0, + }), + ).toStrictEqual([]); + }); + + describe('trace functionality', () => { + it('calls trace callback during account discovery', async () => { + const { provider, mocks } = setup({ + accounts: [], + }); + + // Simulate one discovered account at the requested index. + mocks.handleRequest.mockReturnValue([MOCK_BTC_P2TR_DISCOVERED_ACCOUNT_1]); + + const discovered = await provider.discoverAccounts({ + entropySource: MOCK_HD_KEYRING_1.metadata.id, + groupIndex: 0, + }); + + expect(discovered).toHaveLength(1); + expect(mocks.trace).toHaveBeenCalledWith( + expect.objectContaining({ + name: TraceName.SnapDiscoverAccounts, + data: { provider: BTC_ACCOUNT_PROVIDER_NAME }, + }), + expect.any(Function), + ); + }); + + it('uses fallback trace when no trace callback is provided', async () => { + const { messenger, mocks } = setup({ accounts: [] }); + + mocks.handleRequest.mockReturnValue([MOCK_BTC_P2TR_DISCOVERED_ACCOUNT_1]); + + const multichainMessenger = + getMultichainAccountServiceMessenger(messenger); + // No trace callback (defaults to `traceFallback`). + const btcProvider = new MockBtcAccountProvider(multichainMessenger); + const provider = new AccountProviderWrapper( + multichainMessenger, + btcProvider, + ); + + const discovered = await provider.discoverAccounts({ + entropySource: MOCK_HD_KEYRING_1.metadata.id, + groupIndex: 0, + }); + + expect(discovered).toHaveLength(1); + }); + + it('trace callback is called even when discovery returns empty results', async () => { + const { provider, mocks } = setup({ + accounts: [], + }); + + mocks.handleRequest.mockReturnValue([]); + + const discovered = await provider.discoverAccounts({ + entropySource: MOCK_HD_KEYRING_1.metadata.id, + groupIndex: 0, + }); + + expect(discovered).toStrictEqual([]); + expect(mocks.trace).toHaveBeenCalledTimes(1); + }); + + it('trace callback receives error when discovery fails', async () => { + const mockError = new Error('Discovery failed'); + const { provider, mocks } = setup({ + accounts: [], + }); + + mocks.handleRequest.mockRejectedValue(mockError); + + await expect( + provider.discoverAccounts({ + entropySource: MOCK_HD_KEYRING_1.metadata.id, + groupIndex: 0, + }), + ).rejects.toThrow(mockError); + + expect(mocks.trace).toHaveBeenCalledTimes(1); + }); + }); + + describe('isDisabled', () => { + it('returns false when the provider is enabled (default)', () => { + const { provider } = setup(); + expect(provider.isDisabled()).toBe(false); + }); + + it('returns true after setEnabled(false)', () => { + const { provider } = setup(); + provider.setEnabled(false); + expect(provider.isDisabled()).toBe(true); + }); + + it('returns false after re-enabling', () => { + const { provider } = setup(); + provider.setEnabled(false); + provider.setEnabled(true); + expect(provider.isDisabled()).toBe(false); + }); + }); +}); diff --git a/packages/multichain-account-service/src/providers/BtcAccountProvider.ts b/packages/multichain-account-service/src/providers/BtcAccountProvider.ts new file mode 100644 index 00000000000..d6d73599a04 --- /dev/null +++ b/packages/multichain-account-service/src/providers/BtcAccountProvider.ts @@ -0,0 +1,60 @@ +import type { Bip44Account } from '@metamask/account-api'; +import type { TraceCallback } from '@metamask/controller-utils'; +import { BtcAccountType, BtcScope } from '@metamask/keyring-api'; +import type { InternalAccount } from '@metamask/keyring-internal-api'; +import type { SnapId } from '@metamask/snaps-sdk'; +import type { CaipChainId } from '@metamask/utils'; + +import { traceFallback } from '../analytics/index.js'; +import type { MultichainAccountServiceMessenger } from '../types.js'; +import { SnapAccountProvider } from './SnapAccountProvider.js'; +import type { SnapAccountProviderConfig } from './SnapAccountProvider.js'; + +export type BtcAccountProviderConfig = SnapAccountProviderConfig; + +export const BTC_ACCOUNT_PROVIDER_NAME = 'Bitcoin'; + +export const BTC_ACCOUNT_PROVIDER_DEFAULT_CONFIG: BtcAccountProviderConfig = { + maxConcurrency: 3, + createAccounts: { + timeoutMs: 3000, + }, + discovery: { + enabled: true, + timeoutMs: 2000, + maxAttempts: 3, + backOffMs: 1000, + }, + resyncAccounts: { + autoRemoveExtraSnapAccounts: true, + }, +}; + +export class BtcAccountProvider extends SnapAccountProvider { + static NAME = BTC_ACCOUNT_PROVIDER_NAME; + + static BTC_SNAP_ID = 'npm:@metamask/bitcoin-wallet-snap' as SnapId; + + // TODO: Remove once the Snap is fully v2 — discovery is then driven by the + // Snap's own supported scopes via `createAccounts({ bip44:discover })`. + protected readonly v1DiscoveryScopes: CaipChainId[] = [BtcScope.Mainnet]; + + constructor( + messenger: MultichainAccountServiceMessenger, + config: BtcAccountProviderConfig = BTC_ACCOUNT_PROVIDER_DEFAULT_CONFIG, + trace: TraceCallback = traceFallback, + ) { + super(BtcAccountProvider.BTC_SNAP_ID, messenger, config, trace); + } + + getName(): string { + return BtcAccountProvider.NAME; + } + + isAccountCompatible(account: Bip44Account): boolean { + return ( + account.type === BtcAccountType.P2wpkh && + Object.values(BtcAccountType).includes(account.type) + ); + } +} diff --git a/packages/multichain-account-service/src/providers/EvmAccountProvider.test.ts b/packages/multichain-account-service/src/providers/EvmAccountProvider.test.ts new file mode 100644 index 00000000000..aed64a8dae5 --- /dev/null +++ b/packages/multichain-account-service/src/providers/EvmAccountProvider.test.ts @@ -0,0 +1,915 @@ +import { publicToAddress } from '@ethereumjs/util'; +import { isBip44Account } from '@metamask/account-api'; +import { HdKeyring as LegacyHdKeyring } from '@metamask/eth-hd-keyring'; +import { AccountCreationType, EthScope } from '@metamask/keyring-api'; +import type { + CreateAccountOptions, + KeyringAccount, +} from '@metamask/keyring-api'; +import type { Keyring } from '@metamask/keyring-api/v2'; +import { KeyringType } from '@metamask/keyring-api/v2'; +import type { KeyringMetadata } from '@metamask/keyring-controller'; +import type { InternalAccount } from '@metamask/keyring-internal-api'; +import type { + AutoManagedNetworkClient, + CustomNetworkClientConfiguration, +} from '@metamask/network-controller'; +import { add0x, bytesToHex } from '@metamask/utils'; + +import { TraceName } from '../analytics/traces.js'; +import { + asKeyringAccount, + getMultichainAccountServiceMessenger, + getRootMessenger, + MOCK_HD_ACCOUNT_1, + MOCK_HD_ACCOUNT_2, + MOCK_HD_KEYRING_1, + MOCK_SOL_ACCOUNT_1, + MockAccountBuilder, + mockAsInternalAccount, + RootMessenger, +} from '../tests/index.js'; +import { + EVM_ACCOUNT_PROVIDER_DEFAULT_CONFIG, + EVM_ACCOUNT_PROVIDER_NAME, + EvmAccountProvider, + EvmAccountProviderConfig, +} from './EvmAccountProvider.js'; +import { TimeoutError } from './utils.js'; + +// Real HD root rooted at a valid BIP-39 test mnemonic so the address peeked via +// `keyring.root.deriveChild(groupIndex)` matches the address that the mock's +// `createAccounts` later returns at the same index. +const TEST_MNEMONIC = + 'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about'; +let mockHdRoot: NonNullable; + +/** + * Derives the EVM address for a given group index using the test mnemonic. + * + * @param groupIndex - The BIP-44 group index. + * @returns The lowercase hex address. + */ +function deriveAddressForIndex(groupIndex: number): string { + const child = mockHdRoot.deriveChild(groupIndex); + if (!child.publicKey) { + throw new Error('Expected derived public key to be set'); + } + return add0x( + bytesToHex(publicToAddress(child.publicKey, true)).toLowerCase(), + ); +} + +/** + * Builds an HD account fixture whose address matches what + * `mockHdRoot.deriveChild(groupIndex)` would derive. + * + * @param groupIndex - The BIP-44 group index. + * @returns A Bip44 InternalAccount fixture for the index. + */ +function makeDerivedHdAccount(groupIndex: number): InternalAccount { + return MockAccountBuilder.from(MOCK_HD_ACCOUNT_1) + .withUuid() + .withAddress(deriveAddressForIndex(groupIndex)) + .withGroupIndex(groupIndex) + .get(); +} + +// Mock V2 HD Keyring implementing the Keyring interface from @metamask/keyring-api/v2. +class MockHdKeyringV2 implements Keyring { + readonly type = KeyringType.Hd; + + readonly capabilities = { + scopes: [EthScope.Eoa], + bip44: { deriveIndex: true }, + }; + + // Internal test-only state — not part of the Keyring interface. + readonly accounts: KeyringAccount[]; + + readonly metadata: KeyringMetadata = { + id: 'mock-eth-keyring-id', + name: '', + }; + + constructor(accounts: InternalAccount[]) { + this.accounts = accounts.map( + ({ metadata, ...keyringAccount }) => keyringAccount, + ); + } + + /** + * The HD root that the EVM provider uses to peek the next address + * (via `root.deriveChild(groupIndex)`) without persisting an account. + * + * @returns The HD root derived from the test mnemonic. + */ + get root(): NonNullable { + return mockHdRoot; + } + + getAccounts = jest.fn().mockImplementation(() => this.accounts); + + getAccount = jest.fn().mockImplementation((accountId: string) => { + const account = this.accounts.find((a) => a.id === accountId); + if (!account) { + throw new Error(`Account not found: ${accountId}`); + } + return account; + }); + + createAccounts = jest + .fn() + .mockImplementation((options: CreateAccountOptions) => { + const newAccounts: KeyringAccount[] = []; + + if (options.type === AccountCreationType.Bip44DeriveIndex) { + // Derive at the caller-supplied `groupIndex` (rather than + // `this.accounts.length`) so that a production bug forwarding the + // wrong index would surface as an address/identity mismatch in + // tests, instead of being masked by the mock re-deriving + // sequentially. + const { groupIndex } = options; + const { metadata, ...keyringAccount } = + makeDerivedHdAccount(groupIndex); + this.accounts.push(keyringAccount); + newAccounts.push(keyringAccount); + } + + return newAccounts; + }); + + deleteAccount = jest.fn().mockImplementation((accountId: string) => { + const index = this.accounts.findIndex((a) => a.id === accountId); + if (index >= 0) { + this.accounts.splice(index, 1); + } + }); + + serialize = jest.fn().mockResolvedValue({}); + + deserialize = jest.fn().mockResolvedValue(undefined); + + submitRequest = jest.fn(); +} + +/** + * Sets up a EvmAccountProvider for testing. + * + * @param options - Configuration options for setup. + * @param options.messenger - An optional messenger instance to use. Defaults to a new Messenger. + * @param options.accounts - List of accounts to use. + * @param options.discovery - Discovery options. + * @param options.discovery.transactionCount - Transaction count (use '0x0' to stop the discovery). + * @param options.config - Provider config. + * @returns An object containing the controller instance and the messenger. + */ +function setup({ + messenger = getRootMessenger(), + accounts = [], + discovery, + config, +}: { + messenger?: RootMessenger; + accounts?: InternalAccount[]; + discovery?: { + transactionCount: string; + }; + config?: EvmAccountProviderConfig; +} = {}): { + provider: EvmAccountProvider; + messenger: RootMessenger; + keyring: MockHdKeyringV2; + mocks: { + mockProviderRequest: jest.Mock; + mockGetAccount: jest.Mock; + }; +} { + const keyring = new MockHdKeyringV2(accounts); + + messenger.registerActionHandler( + 'AccountsController:getAccounts', + (accountIds: string[]) => + keyring.accounts.filter((account) => accountIds.includes(account.id)), + ); + + const mockGetAccount = jest.fn().mockImplementation((id) => { + return keyring.accounts.find((account) => account.id === id); + }); + + messenger.registerActionHandler( + 'AccountsController:getAccount', + mockGetAccount, + ); + + const mockProviderRequest = jest.fn().mockImplementation(({ method }) => { + if (method === 'eth_getTransactionCount') { + return discovery?.transactionCount ?? '0x2'; + } + throw new Error(`Unknown method: ${method}`); + }); + + messenger.registerActionHandler( + 'KeyringController:withKeyringV2', + async (_, operation) => operation({ keyring, metadata: keyring.metadata }), + ); + + messenger.registerActionHandler( + 'NetworkController:findNetworkClientIdByChainId', + () => 'mock-network-client-id', + ); + + messenger.registerActionHandler( + 'NetworkController:getNetworkClientById', + () => { + const provider = { + request: mockProviderRequest, + }; + + return { + provider, + } as unknown as AutoManagedNetworkClient; + }, + ); + + const provider = new EvmAccountProvider( + getMultichainAccountServiceMessenger(messenger), + config, + ); + + const accountIds = accounts.map((account) => account.id); + provider.init(accountIds); + + return { + provider, + messenger, + keyring, + mocks: { + mockProviderRequest, + mockGetAccount, + }, + }; +} + +describe('EvmAccountProvider', () => { + beforeAll(async () => { + const legacy = new LegacyHdKeyring(); + await legacy.deserialize({ mnemonic: TEST_MNEMONIC }); + if (!legacy.root) { + throw new Error('Failed to initialize test HD root'); + } + mockHdRoot = legacy.root; + }); + + it('getName returns EVM', () => { + const { provider } = setup({ accounts: [] }); + expect(provider.getName()).toBe(EVM_ACCOUNT_PROVIDER_NAME); + }); + + it('gets accounts', () => { + const accounts = [MOCK_HD_ACCOUNT_1, MOCK_HD_ACCOUNT_2]; + const { provider } = setup({ + accounts, + }); + + expect(provider.getAccounts()).toStrictEqual( + accounts.map(asKeyringAccount), + ); + }); + + it('gets a specific account', () => { + const customId = 'custom-id-123'; + const account = MockAccountBuilder.from(MOCK_HD_ACCOUNT_1) + .withId(customId) + .get(); + const { provider } = setup({ + accounts: [account], + }); + + expect(provider.getAccount(customId)).toStrictEqual( + asKeyringAccount(account), + ); + }); + + it('throws if account does not exist', () => { + const account = MOCK_HD_ACCOUNT_1; + const { provider } = setup({ + accounts: [account], + }); + + const unknownAccount = MOCK_HD_ACCOUNT_2; + expect(() => provider.getAccount(unknownAccount.id)).toThrow( + `Unable to find account: ${unknownAccount.id}`, + ); + }); + + it('returns true if an account is compatible', () => { + const account = MOCK_HD_ACCOUNT_1; + const { provider } = setup({ + accounts: [account], + }); + expect(provider.isAccountCompatible(account)).toBe(true); + }); + + it('returns false if an account is not compatible', () => { + const account = MOCK_SOL_ACCOUNT_1; + const { provider } = setup({ + accounts: [account], + }); + expect(provider.isAccountCompatible(account)).toBe(false); + }); + + it('does not re-create accounts (idempotent)', async () => { + const accounts = [MOCK_HD_ACCOUNT_1, MOCK_HD_ACCOUNT_2]; + const { provider } = setup({ + accounts, + }); + + const newAccounts = await provider.createAccounts({ + type: AccountCreationType.Bip44DeriveIndex, + entropySource: MOCK_HD_KEYRING_1.metadata.id, + groupIndex: 0, + }); + expect(newAccounts).toHaveLength(1); + expect(newAccounts[0]).toStrictEqual(asKeyringAccount(MOCK_HD_ACCOUNT_1)); + }); + + it('creates multiple accounts using Bip44DeriveIndexRange', async () => { + const accounts = [MOCK_HD_ACCOUNT_1]; + const { provider, keyring } = setup({ + accounts, + }); + + const from = 1; + const newAccounts = await provider.createAccounts({ + type: AccountCreationType.Bip44DeriveIndexRange, + entropySource: MOCK_HD_KEYRING_1.metadata.id, + range: { + from, + to: 3, + }, + }); + + expect(newAccounts).toHaveLength(3); + // HdKeyringV2 only supports bip44:derive-index, so range creation + // calls createAccounts once per new index. + expect(keyring.createAccounts).toHaveBeenCalledTimes(3); + + // Verify each account has the correct group index. + for (const [index, account] of newAccounts.entries()) { + expect(isBip44Account(account)).toBe(true); + expect(account.options.entropy.groupIndex).toBe(from + index); + } + }); + + it('creates accounts with range starting from 0', async () => { + const { provider, keyring } = setup({ + accounts: [], + }); + + const newAccounts = await provider.createAccounts({ + type: AccountCreationType.Bip44DeriveIndexRange, + entropySource: MOCK_HD_KEYRING_1.metadata.id, + range: { + from: 0, + to: 2, + }, + }); + + expect(newAccounts).toHaveLength(3); + expect(keyring.createAccounts).toHaveBeenCalledTimes(3); + expect(keyring.createAccounts).toHaveBeenCalledWith({ + type: AccountCreationType.Bip44DeriveIndex, + entropySource: MOCK_HD_KEYRING_1.metadata.id, + groupIndex: 0, + }); + }); + + it('creates a single account when range from equals to', async () => { + const { provider, keyring } = setup({ + accounts: [], + }); + + // First create accounts 0-4 to avoid gaps. + await provider.createAccounts({ + type: AccountCreationType.Bip44DeriveIndexRange, + entropySource: MOCK_HD_KEYRING_1.metadata.id, + range: { + from: 0, + to: 4, + }, + }); + + // Now create a single account at index 5 where from equals to. + const newAccounts = await provider.createAccounts({ + type: AccountCreationType.Bip44DeriveIndexRange, + entropySource: MOCK_HD_KEYRING_1.metadata.id, + range: { + from: 5, + to: 5, + }, + }); + + expect(newAccounts).toHaveLength(1); + // 5 calls for range 0-4 + 1 call for account 5. + expect(keyring.createAccounts).toHaveBeenCalledTimes(6); + expect( + isBip44Account(newAccounts[0]) && + newAccounts[0].options.entropy.groupIndex, + ).toBe(5); + }); + + it('throws when trying to create gaps with range', async () => { + const { provider } = setup({ + accounts: [MOCK_HD_ACCOUNT_1], + }); + + const nextGroupIndex = MOCK_HD_ACCOUNT_1.options.entropy.groupIndex + 1; + + const from = 5; + await expect( + provider.createAccounts({ + type: AccountCreationType.Bip44DeriveIndexRange, + entropySource: MOCK_HD_KEYRING_1.metadata.id, + range: { + from, + to: 10, + }, + }), + ).rejects.toThrow( + `Bad account creation request, group index range would create gaps (${from} (from) > ${nextGroupIndex} (next available index))`, + ); + }); + + it('returns existing accounts when range includes already created accounts', async () => { + const accounts = [MOCK_HD_ACCOUNT_1, MOCK_HD_ACCOUNT_2]; + const { provider, keyring } = setup({ + accounts, + }); + + const newAccounts = await provider.createAccounts({ + type: AccountCreationType.Bip44DeriveIndexRange, + entropySource: MOCK_HD_KEYRING_1.metadata.id, + range: { + from: 0, + to: 3, + }, + }); + + // Should return 4 accounts: 2 existing (indices 0,1) + 2 new (indices 2,3). + expect(newAccounts).toHaveLength(4); + expect(newAccounts[0]).toStrictEqual(asKeyringAccount(MOCK_HD_ACCOUNT_1)); + expect(newAccounts[1]).toStrictEqual(asKeyringAccount(MOCK_HD_ACCOUNT_2)); + // Only new accounts (indices 2 and 3) should be created — one call each. + expect(keyring.createAccounts).toHaveBeenCalledTimes(2); + }); + + it('throws when the keyring returns no created account during range creation', async () => { + const { provider, keyring } = setup({ accounts: [] }); + + // Simulate the keyring failing to create an account on the first call. + keyring.createAccounts.mockImplementationOnce(() => []); + + await expect( + provider.createAccounts({ + type: AccountCreationType.Bip44DeriveIndexRange, + entropySource: MOCK_HD_KEYRING_1.metadata.id, + range: { + from: 0, + to: 1, + }, + }), + ).rejects.toThrow('Account creation failed'); + }); + + it('throws when single Bip44DeriveIndex creation returns no account', async () => { + const { provider, keyring } = setup({ accounts: [] }); + + keyring.createAccounts.mockImplementationOnce(() => []); + + await expect( + provider.createAccounts({ + type: AccountCreationType.Bip44DeriveIndex, + entropySource: MOCK_HD_KEYRING_1.metadata.id, + groupIndex: 0, + }), + ).rejects.toThrow('Account creation failed'); + // The provider should not register the account when nothing was created. + expect(provider.getAccounts()).toStrictEqual([]); + }); + + it('throws if the created account is not BIP-44 compatible', async () => { + const accounts = [MOCK_HD_ACCOUNT_1]; + const { provider, mocks } = setup({ + accounts, + }); + + mocks.mockGetAccount.mockReturnValue({ + ...mockAsInternalAccount(MOCK_HD_ACCOUNT_1), + options: {}, // No options, so it cannot be BIP-44 compatible. + }); + + await expect( + provider.createAccounts({ + type: AccountCreationType.Bip44DeriveIndex, + entropySource: MOCK_HD_KEYRING_1.metadata.id, + groupIndex: 0, + }), + ).rejects.toThrow('Created account is not BIP-44 compatible'); + }); + + it('throws when trying to create gaps', async () => { + const { provider } = setup({ + accounts: [MOCK_HD_ACCOUNT_1], + }); + + await expect( + provider.createAccounts({ + type: AccountCreationType.Bip44DeriveIndex, + entropySource: MOCK_HD_KEYRING_1.metadata.id, + groupIndex: 10, + }), + ).rejects.toThrow('Trying to create too many accounts'); + }); + + it('throws if internal account cannot be found', async () => { + const { provider, mocks } = setup({ + accounts: [MOCK_HD_ACCOUNT_1], + }); + + // Simulate an account not found. + mocks.mockGetAccount.mockImplementation(() => undefined); + + await expect( + provider.createAccounts({ + type: AccountCreationType.Bip44DeriveIndex, + entropySource: MOCK_HD_KEYRING_1.metadata.id, + groupIndex: 1, + }), + ).rejects.toThrow('Internal account does not exist'); + }); + + it('throws an error when type is not "bip44:derive-index"', async () => { + const { provider } = setup({ + accounts: [MOCK_HD_ACCOUNT_1], + }); + + await expect( + provider.createAccounts({ + // @ts-expect-error Testing invalid type handling. + type: 'unsupported-type', + entropySource: MOCK_HD_KEYRING_1.metadata.id, + groupIndex: 0, + }), + ).rejects.toThrow( + 'Unsupported create account option type: unsupported-type', + ); + }); + + it('discover accounts at the next group index', async () => { + const { provider } = setup({ + accounts: [], + }); + + const expectedAccount = { + ...asKeyringAccount(makeDerivedHdAccount(0)), + id: expect.any(String), + }; + + expect( + await provider.discoverAccounts({ + entropySource: MOCK_HD_KEYRING_1.metadata.id, + groupIndex: 0, + }), + ).toStrictEqual([expectedAccount]); + + expect(provider.getAccounts()).toStrictEqual([expectedAccount]); + }); + + it('stops discovery gracefully if response is invalid', async () => { + const { provider } = setup({ + accounts: [], + discovery: { + transactionCount: '', // Faking bad hex number. + }, + }); + + const consoleSpy = jest.spyOn(console, 'warn').mockImplementation(); + + expect( + await provider.discoverAccounts({ + entropySource: MOCK_HD_KEYRING_1.metadata.id, + groupIndex: 0, + }), + ).toStrictEqual([]); + + expect(consoleSpy).toHaveBeenCalledWith( + 'Received invalid hex response from "eth_getTransactionCount" request: ""', + ); + }); + + it('stops discovery if there is no transaction activity', async () => { + const { provider, keyring } = setup({ + accounts: [], + discovery: { + transactionCount: '0x0', + }, + }); + + expect( + await provider.discoverAccounts({ + entropySource: MOCK_HD_KEYRING_1.metadata.id, + groupIndex: 0, + }), + ).toStrictEqual([]); + + expect(provider.getAccounts()).toStrictEqual([]); + // Address is peeked via `keyring.root.deriveChild`, so no account + // is created (or deleted) when there is no on-chain activity. + expect(keyring.createAccounts).not.toHaveBeenCalled(); + expect(keyring.deleteAccount).not.toHaveBeenCalled(); + }); + + it('throws during discovery if the keyring returns no created account', async () => { + const { provider, keyring } = setup({ accounts: [] }); + + // Transaction count > 0 (default mock), so discovery proceeds to creation. + keyring.createAccounts.mockImplementationOnce(() => []); + + await expect( + provider.discoverAccounts({ + entropySource: MOCK_HD_KEYRING_1.metadata.id, + groupIndex: 0, + }), + ).rejects.toThrow('Account creation failed'); + }); + + it('retries RPC request up to 3 times if it fails and throws the last error', async () => { + const { provider, mocks } = setup({ + accounts: [MOCK_HD_ACCOUNT_1], + }); + + mocks.mockProviderRequest + .mockImplementationOnce(() => { + throw new Error('RPC request failed 1'); + }) + .mockImplementationOnce(() => { + throw new Error('RPC request failed 2'); + }) + .mockImplementationOnce(() => { + throw new Error('RPC request failed 3'); + }) + .mockImplementationOnce(() => { + throw new Error('RPC request failed 4'); + }); + + await expect( + provider.discoverAccounts({ + entropySource: MOCK_HD_KEYRING_1.metadata.id, + groupIndex: 1, + }), + ).rejects.toThrow('RPC request failed 3'); + }); + + it('throws if the RPC request times out', async () => { + const { provider, mocks } = setup({ + accounts: [MOCK_HD_ACCOUNT_1], + }); + + mocks.mockProviderRequest.mockImplementation(() => { + return new Promise((resolve) => { + setTimeout(() => { + resolve('0x0'); + }, 600); + }); + }); + + await expect( + provider.discoverAccounts({ + entropySource: MOCK_HD_KEYRING_1.metadata.id, + groupIndex: 1, + }), + ).rejects.toThrow(TimeoutError); + }); + + it('returns an existing account if it already exists', async () => { + const { provider } = setup({ + accounts: [MOCK_HD_ACCOUNT_1], + }); + + expect( + await provider.discoverAccounts({ + entropySource: MOCK_HD_KEYRING_1.metadata.id, + groupIndex: 0, + }), + ).toStrictEqual([asKeyringAccount(MOCK_HD_ACCOUNT_1)]); + }); + + it('calls trace callback during account discovery', async () => { + const mockTrace = jest.fn().mockImplementation(async (request, fn) => { + expect(request.name).toBe(TraceName.EvmDiscoverAccounts); + expect(request.data).toStrictEqual({ + provider: EVM_ACCOUNT_PROVIDER_NAME, + }); + return await fn(); + }); + + const { messenger } = setup({ + accounts: [], + }); + + const expectedAccount = { + ...asKeyringAccount(makeDerivedHdAccount(0)), + id: expect.any(String), + }; + + // Create provider with custom trace callback + const providerWithTrace = new EvmAccountProvider( + getMultichainAccountServiceMessenger(messenger), + { + discovery: { + maxAttempts: 3, + timeoutMs: 500, + backOffMs: 500, + }, + }, + mockTrace, + ); + + const result = await providerWithTrace.discoverAccounts({ + entropySource: MOCK_HD_KEYRING_1.metadata.id, + groupIndex: 0, + }); + + expect(result).toStrictEqual([expectedAccount]); + expect(mockTrace).toHaveBeenCalledTimes(1); + }); + + it('uses fallback trace when no trace callback is provided', async () => { + const { provider } = setup({ + accounts: [], + }); + + const expectedAccount = { + ...asKeyringAccount(makeDerivedHdAccount(0)), + id: expect.any(String), + }; + + const result = await provider.discoverAccounts({ + entropySource: MOCK_HD_KEYRING_1.metadata.id, + groupIndex: 0, + }); + + expect(result).toStrictEqual([expectedAccount]); + }); + + it('trace callback is called even when discovery returns empty results', async () => { + const mockTrace = jest.fn().mockImplementation(async (request, fn) => { + expect(request.name).toBe(TraceName.EvmDiscoverAccounts); + expect(request.data).toStrictEqual({ + provider: EVM_ACCOUNT_PROVIDER_NAME, + }); + return await fn(); + }); + + const { messenger } = setup({ + accounts: [], + discovery: { + transactionCount: '0x0', // No transactions, should return empty + }, + }); + + const providerWithTrace = new EvmAccountProvider( + getMultichainAccountServiceMessenger(messenger), + { + discovery: { + maxAttempts: 3, + timeoutMs: 500, + backOffMs: 500, + }, + }, + mockTrace, + ); + + const result = await providerWithTrace.discoverAccounts({ + entropySource: MOCK_HD_KEYRING_1.metadata.id, + groupIndex: 0, + }); + + expect(result).toStrictEqual([]); + expect(mockTrace).toHaveBeenCalledTimes(1); + }); + + it('does not run discovery if disabled', async () => { + const { provider } = setup({ + accounts: [MOCK_HD_ACCOUNT_1, MOCK_HD_ACCOUNT_2], + config: { + ...EVM_ACCOUNT_PROVIDER_DEFAULT_CONFIG, + discovery: { + ...EVM_ACCOUNT_PROVIDER_DEFAULT_CONFIG.discovery, + enabled: false, + }, + }, + }); + + expect( + await provider.discoverAccounts({ + entropySource: MOCK_HD_KEYRING_1.metadata.id, + groupIndex: 0, + }), + ).toStrictEqual([]); + }); + + it('does nothing when re-syncing accounts', async () => { + const { provider } = setup({ + accounts: [], + }); + + expect(await provider.resyncAccounts()).toBeUndefined(); + }); + + describe('deleteAccount', () => { + it('selects the keyring by the account entropy source and calls keyring.deleteAccount', async () => { + const { provider, keyring, messenger } = setup({ + accounts: [MOCK_HD_ACCOUNT_1, MOCK_HD_ACCOUNT_2], + }); + const withKeyringV2Spy = jest.fn(async (_, operation) => + operation({ keyring, metadata: keyring.metadata }), + ); + messenger.unregisterActionHandler('KeyringController:withKeyringV2'); + messenger.registerActionHandler( + 'KeyringController:withKeyringV2', + withKeyringV2Spy, + ); + const deleteAccountSpy = jest.spyOn(keyring, 'deleteAccount'); + + await provider.deleteAccount(MOCK_HD_ACCOUNT_1.id); + + expect(withKeyringV2Spy).toHaveBeenCalledWith( + { id: MOCK_HD_ACCOUNT_1.options.entropy.id }, + expect.any(Function), + ); + expect(deleteAccountSpy).toHaveBeenCalledWith(MOCK_HD_ACCOUNT_1.id); + expect(provider.getAccounts()).toStrictEqual([ + asKeyringAccount(MOCK_HD_ACCOUNT_2), + ]); + }); + + it('throws if the account is not tracked by the provider', async () => { + const { provider } = setup({ + accounts: [MOCK_HD_ACCOUNT_1], + }); + + await expect(provider.deleteAccount('unknown-id')).rejects.toThrow( + 'Unable to find account: unknown-id', + ); + }); + }); + + describe('isAligned', () => { + it('returns true when accountIds is non-empty and every ID is owned by the provider', () => { + const { provider } = setup(); + const accountId = 'test-account-id'; + provider.init([accountId]); + + expect( + provider.isAligned({ entropySource: 'es1', groupIndex: 0 }, [ + accountId, + ]), + ).toBe(true); + }); + + it('returns false when accountIds is empty', () => { + const { provider } = setup(); + provider.init(['some-account-id']); + + expect( + provider.isAligned({ entropySource: 'es1', groupIndex: 0 }, []), + ).toBe(false); + }); + + it('returns false when an accountId is not owned by the provider', () => { + const { provider } = setup(); + provider.init(['owned-id']); + + expect( + provider.isAligned({ entropySource: 'es1', groupIndex: 0 }, [ + 'unknown-id', + ]), + ).toBe(false); + }); + + it('returns false when only some accountIds are owned by the provider', () => { + const { provider } = setup(); + const ownedId = 'owned-id'; + provider.init([ownedId]); + + expect( + provider.isAligned({ entropySource: 'es1', groupIndex: 0 }, [ + ownedId, + 'unknown-id', + ]), + ).toBe(false); + }); + }); +}); diff --git a/packages/multichain-account-service/src/providers/EvmAccountProvider.ts b/packages/multichain-account-service/src/providers/EvmAccountProvider.ts new file mode 100644 index 00000000000..23070e225ec --- /dev/null +++ b/packages/multichain-account-service/src/providers/EvmAccountProvider.ts @@ -0,0 +1,456 @@ +import { publicToAddress } from '@ethereumjs/util'; +import type { Bip44Account } from '@metamask/account-api'; +import type { TraceCallback } from '@metamask/controller-utils'; +import type { HdKeyring } from '@metamask/eth-hd-keyring/v2'; +import type { + CreateAccountOptions, + EntropySourceId, + KeyringAccount, +} from '@metamask/keyring-api'; +import { + AccountCreationType, + assertCreateAccountOptionIsSupported, + EthAccountType, + EthScope, +} from '@metamask/keyring-api'; +import type { KeyringCapabilities, Keyring } from '@metamask/keyring-api/v2'; +import { KeyringTypes } from '@metamask/keyring-controller'; +import type { InternalAccount } from '@metamask/keyring-internal-api'; +import { AccountId } from '@metamask/keyring-utils'; +import type { Provider } from '@metamask/network-controller'; +import { add0x, assert, bytesToHex, isStrictHexString } from '@metamask/utils'; +import type { Hex } from '@metamask/utils'; + +import { traceFallback } from '../analytics/index.js'; +import { TraceName } from '../analytics/traces.js'; +import { projectLogger as log, WARNING_PREFIX } from '../logger.js'; +import type { MultichainAccountServiceMessenger } from '../types.js'; +import { + assertAreBip44Accounts, + assertIsBip44Account, + BaseBip44AccountProvider, +} from './BaseBip44AccountProvider.js'; +import { withRetry, withTimeout } from './utils.js'; + +const ETH_MAINNET_CHAIN_ID = '0x1'; + +/** + * Asserts an internal account exists. + * + * @param account - The internal account to check. + * @throws An error if the internal account does not exist. + */ +function assertInternalAccountExists( + account: InternalAccount | undefined, +): asserts account is InternalAccount { + if (!account) { + throw new Error('Internal account does not exist'); + } +} + +export type EvmAccountProviderConfig = { + discovery: { + enabled?: boolean; + maxAttempts: number; + timeoutMs: number; + backOffMs: number; + }; +}; + +export const EVM_ACCOUNT_PROVIDER_NAME = 'EVM'; + +export const EVM_ACCOUNT_PROVIDER_DEFAULT_CONFIG = { + discovery: { + maxAttempts: 3, + timeoutMs: 500, + backOffMs: 500, + }, +}; + +export class EvmAccountProvider extends BaseBip44AccountProvider { + static NAME = EVM_ACCOUNT_PROVIDER_NAME; + + readonly #config: EvmAccountProviderConfig; + + readonly #trace: TraceCallback; + + readonly capabilities: KeyringCapabilities = { + scopes: [EthScope.Eoa], + bip44: { + deriveIndex: true, + deriveIndexRange: true, + }, + }; + + constructor( + messenger: MultichainAccountServiceMessenger, + config: EvmAccountProviderConfig = EVM_ACCOUNT_PROVIDER_DEFAULT_CONFIG, + trace?: TraceCallback, + ) { + super(messenger); + this.#config = { + ...config, + discovery: { + ...config.discovery, + enabled: config.discovery.enabled ?? true, + }, + }; + this.#trace = trace ?? traceFallback; + } + + isAccountCompatible(account: Bip44Account): boolean { + return ( + account.type === EthAccountType.Eoa && + account.metadata.keyring.type === (KeyringTypes.hd as string) + ); + } + + getName(): string { + return EvmAccountProvider.NAME; + } + + /** + * Get the EVM provider. + * + * @returns The EVM provider. + */ + getEvmProvider(): Provider { + const networkClientId = this.messenger.call( + 'NetworkController:findNetworkClientIdByChainId', + ETH_MAINNET_CHAIN_ID, + ); + const { provider } = this.messenger.call( + 'NetworkController:getNetworkClientById', + networkClientId, + ); + return provider; + } + + /** + * Create an EVM account. + * + * @param opts - The options for the creation of the account. + * @param opts.entropySource - The entropy source to use for the creation of the account. + * @param opts.groupIndex - The index of the group to create the account for. + * @param opts.throwOnGap - Whether to throw an error if the account index is not contiguous. + * @returns The created or existing keyring account(s) at the requested group + * index. Returns an empty array if the keyring did not create an account. + */ + async #createAccount({ + entropySource, + groupIndex, + throwOnGap, + }: { + entropySource: EntropySourceId; + groupIndex: number; + throwOnGap: boolean; + }): Promise { + return await this.withKeyringV2( + { id: entropySource }, + async ({ keyring }) => { + const existing = await keyring.getAccounts(); + if (groupIndex < existing.length) { + return [existing[groupIndex]]; + } + + // If the throwOnGap flag is set, we throw an error to prevent index gaps. + if (throwOnGap && groupIndex !== existing.length) { + throw new Error('Trying to create too many accounts'); + } + + return await keyring.createAccounts({ + type: AccountCreationType.Bip44DeriveIndex, + entropySource, + groupIndex, + }); + }, + ); + } + + /** + * Create accounts for the EVM provider. + * + * @param options - The options for the creation of the accounts. + * @returns The accounts for the EVM provider. + */ + async createAccounts( + options: CreateAccountOptions, + ): Promise[]> { + assertCreateAccountOptionIsSupported(options, [ + `${AccountCreationType.Bip44DeriveIndex}`, + `${AccountCreationType.Bip44DeriveIndexRange}`, + ]); + + const { entropySource } = options; + + if (options.type === AccountCreationType.Bip44DeriveIndexRange) { + const { range } = options; + + // Use a single withKeyring call for the entire range. + const accountIds = await this.withKeyringV2( + { id: entropySource }, + async ({ keyring }) => { + const existing = await keyring.getAccounts(); + + // Validate no gaps: we can only create accounts starting from existing.length. + if (range.from > existing.length) { + throw new Error( + `Bad account creation request, group index range would create gaps (${range.from} (from) > ${existing.length} (next available index))`, + ); + } + + const result: AccountId[] = []; + + // Collect existing accounts within the range. + for ( + let groupIndex = range.from; + groupIndex <= range.to; + groupIndex++ + ) { + if (groupIndex < existing.length) { + // Account already exists. + result.push(existing[groupIndex].id); + } + } + + // Create new accounts one-by-one since HdKeyringV2 only supports + // bip44:derive-index (not bip44:derive-index-range). + for ( + let groupIndex = Math.max(range.from, existing.length); + groupIndex <= range.to; + groupIndex++ + ) { + const [created] = await keyring.createAccounts({ + type: AccountCreationType.Bip44DeriveIndex, + entropySource, + groupIndex, + }); + assert(created, 'Account creation failed'); + result.push(created.id); + } + + return result; + }, + ); + + const accounts: InternalAccount[] = []; + for (const account of this.messenger.call( + 'AccountsController:getAccounts', + accountIds, + )) { + assertInternalAccountExists(account); + this.accounts.add(account.id); + accounts.push(account); + } + + assertAreBip44Accounts(accounts); + return accounts; + } + + // Handle Bip44DeriveIndex (single account creation). + const { groupIndex } = options; + + const [created] = await this.#createAccount({ + entropySource, + groupIndex, + throwOnGap: true, + }); + + assert(created, 'Account creation failed'); + + const account = this.messenger.call( + 'AccountsController:getAccount', + created.id, + ); + + // We MUST have the associated internal account. + assertInternalAccountExists(account); + + const accountsArray = [account]; + assertAreBip44Accounts(accountsArray); + + this.accounts.add(account.id); + return accountsArray; + } + + /** + * Get the address that would be derived for a given group index without + * persisting an account in the keyring. + * + * Peeks at the next address via the HD `root` so discovery can short-circuit + * (and skip the vault write + `stateChange` event) when there is no on-chain + * activity at this index. + * + * @param opts - The options for the address derivation. + * @param opts.entropySource - The entropy source to derive from. + * @param opts.groupIndex - The group index to derive at. + * @returns The derived address for the given group index. + */ + async #getAddressFromGroupIndex({ + entropySource, + groupIndex, + }: { + entropySource: EntropySourceId; + groupIndex: number; + }): Promise { + // NOTE: To avoid exposing this function at keyring level, we just re-use its internal state + // and compute the derivation here. + return await this.withKeyringV2( + { id: entropySource }, + async ({ keyring }) => { + // If the account already exist, do not re-derive and just re-use that account. + const existing = await keyring.getAccounts(); + if (groupIndex < existing.length) { + return existing[groupIndex].address as Hex; + } + + // If not, then we just "peek" the next address to avoid creating the account. + assert(keyring.root, 'Expected HD keyring.root to be set'); + const hdKey = keyring.root.deriveChild(groupIndex); + assert(hdKey.publicKey, 'Expected public key to be set'); + + return add0x( + bytesToHex(publicToAddress(hdKey.publicKey, true)).toLowerCase(), + ); + }, + ); + } + + /** + * Get the transaction count for an EVM account. + * This method uses a retry and timeout mechanism to handle transient failures. + * + * @param provider - The provider to use for the transaction count. + * @param address - The address of the account. + * @returns The transaction count. + */ + async #getTransactionCount( + provider: Provider, + address: string, + ): Promise { + const method = 'eth_getTransactionCount'; + + const response = await withRetry( + () => + withTimeout( + () => + provider.request({ + method, + params: [address, 'latest'], + }), + this.#config.discovery.timeoutMs, + ), + { + maxAttempts: this.#config.discovery.maxAttempts, + backOffMs: this.#config.discovery.backOffMs, + }, + ); + + // Make sure we got the right response format, if not, we fallback to "0x0", to avoid having to deal with `NaN`. + if (!isStrictHexString(response)) { + const message = `Received invalid hex response from "${method}" request: ${JSON.stringify(response)}`; + + log(`${WARNING_PREFIX} ${message}`); + console.warn(message); + + return 0; + } + + return parseInt(response, 16); + } + + /** + * Discover and create accounts for the EVM provider. + * + * @param opts - The options for the discovery and creation of accounts. + * @param opts.entropySource - The entropy source to use for the discovery and creation of accounts. + * @param opts.groupIndex - The index of the group to create the accounts for. + * @returns The accounts for the EVM provider. + */ + async discoverAccounts(opts: { + entropySource: EntropySourceId; + groupIndex: number; + }): Promise[]> { + return this.#trace( + { + name: TraceName.EvmDiscoverAccounts, + data: { + provider: this.getName(), + }, + }, + async () => { + if (!this.#config.discovery.enabled) { + return []; + } + + const provider = this.getEvmProvider(); + const { entropySource, groupIndex } = opts; + + const addressFromGroupIndex = await this.#getAddressFromGroupIndex({ + entropySource, + groupIndex, + }); + + const count = await this.#getTransactionCount( + provider, + addressFromGroupIndex, + ); + if (count === 0) { + return []; + } + + // We have some activity on this address, we try to create the account. + const [created] = await this.#createAccount({ + entropySource, + groupIndex, + throwOnGap: false, + }); + + assert(created, 'Account creation failed'); + assert( + addressFromGroupIndex === created.address, + 'Created account does not match address from group index.', + ); + + const account = this.messenger.call( + 'AccountsController:getAccount', + created.id, + ); + assertInternalAccountExists(account); + assertIsBip44Account(account); + this.accounts.add(account.id); + return [account]; + }, + ); + } + + async resyncAccounts(): Promise { + // No-op for the EVM account provider, since keyring accounts are already on + // the MetaMask side. + } + + /** + * Delete an EVM account by id. + * + * Resolves the account's entropy source from the tracked account, then + * forwards to the v2 HD keyring's `deleteAccount(id)`. When this is the + * last account on a non-primary HD keyring, the keyring controller will + * automatically prune the empty keyring (see + * `KeyringController.#cleanUpEmptiedKeyringsAfter`). + * + * @param id - The id of the account to delete. + */ + async deleteAccount(id: Bip44Account['id']): Promise { + const account = this.getAccount(id); + const entropySource = account.options.entropy.id; + + await this.withKeyringV2( + { id: entropySource }, + async ({ keyring }) => { + await keyring.deleteAccount(id); + }, + ); + + this.accounts.delete(id); + } +} diff --git a/packages/multichain-account-service/src/providers/SnapAccountProvider.test.ts b/packages/multichain-account-service/src/providers/SnapAccountProvider.test.ts new file mode 100644 index 00000000000..dede77b6767 --- /dev/null +++ b/packages/multichain-account-service/src/providers/SnapAccountProvider.test.ts @@ -0,0 +1,1014 @@ +import { isBip44Account } from '@metamask/account-api'; +import type { Bip44Account } from '@metamask/account-api'; +import type { TraceCallback, TraceRequest } from '@metamask/controller-utils'; +import { + AccountCreationType, + assertCreateAccountOptionIsSupported, + BtcScope, + KeyringRpcMethod, + SolScope, + TrxScope, +} from '@metamask/keyring-api'; +import type { + CreateAccountOptions, + DeleteAccountRequest, + GetAccountRequest, +} from '@metamask/keyring-api'; +import type { EntropySourceId, KeyringAccount } from '@metamask/keyring-api'; +import type { KeyringCapabilities } from '@metamask/keyring-api/v2'; +import type { KeyringMetadata } from '@metamask/keyring-controller'; +import type { InternalAccount } from '@metamask/keyring-internal-api'; +import type { JsonRpcRequest, SnapId } from '@metamask/snaps-sdk'; +import deepmerge from 'deepmerge'; + +import { traceFallback } from '../analytics/index.js'; +import type { DeepPartial, RootMessenger } from '../tests/index.js'; +import { + asKeyringAccount, + getMultichainAccountServiceMessenger, + getRootMessenger, + MOCK_HD_ACCOUNT_1, + MOCK_HD_ACCOUNT_2, + MockAccountBuilder, +} from '../tests/index.js'; +import type { MultichainAccountServiceMessenger } from '../types.js'; +import { BtcAccountProvider } from './BtcAccountProvider.js'; +import type { SnapAccountProviderConfig } from './SnapAccountProvider.js'; +import { + isSnapAccountProvider, + SnapAccountProvider, +} from './SnapAccountProvider.js'; +import { SolAccountProvider } from './SolAccountProvider.js'; +import { TrxAccountProvider } from './TrxAccountProvider.js'; +import { TimeoutError } from './utils.js'; + +jest.mock('../analytics', () => { + const actual = jest.requireActual('../analytics'); + return { + ...actual, + traceFallback: jest.fn(), + }; +}); + +const THROTTLED_OPERATION_DELAY_MS = 10; +const TEST_SNAP_ID = 'npm:@metamask/test-snap' as SnapId; +const TEST_ENTROPY_SOURCE = 'test-entropy-source' as EntropySourceId; + +class MockSnapAccountProvider extends SnapAccountProvider { + readonly tracker: { + startLog: number[]; + endLog: number[]; + activeCount: number; + maxActiveCount: number; + }; + + capabilities: KeyringCapabilities = { + scopes: [ + SolScope.Devnet, + SolScope.Testnet, + BtcScope.Testnet, + TrxScope.Shasta, + ], + bip44: { + deriveIndex: true, + }, + }; + + protected readonly v1DiscoveryScopes = []; + + constructor( + snapId: SnapId, + messenger: MultichainAccountServiceMessenger, + config: SnapAccountProviderConfig, + /* istanbul ignore next */ + trace: TraceCallback = traceFallback, + ) { + super(snapId, messenger, config, trace); + + // Tracker to monitor concurrent executions. + this.tracker = { + startLog: [], + endLog: [], + activeCount: 0, + maxActiveCount: 0, + }; + } + + getName(): string { + return 'Test Provider'; + } + + isAccountCompatible(): boolean { + return true; + } + + async discoverAccounts(): Promise[]> { + return []; + } + + async createAccounts( + options: CreateAccountOptions, + ): Promise[]> { + assertCreateAccountOptionIsSupported(options, [ + `${AccountCreationType.Bip44DeriveIndex}`, + ]); + + const { tracker } = this; + + return this.withMaxConcurrency(async () => { + tracker.startLog.push(options.groupIndex); + tracker.activeCount += 1; + tracker.maxActiveCount = Math.max( + tracker.maxActiveCount, + tracker.activeCount, + ); + await new Promise((resolve) => + setTimeout(resolve, THROTTLED_OPERATION_DELAY_MS), + ); + tracker.activeCount -= 1; + tracker.endLog.push(options.groupIndex); + return []; + }); + } + + // Expose protected trace method as public for testing + async trace( + request: TraceRequest, + fn: () => Promise, + ): Promise { + return super.trace(request, fn); + } +} + +const DEFAULT_TEST_CONFIG: SnapAccountProviderConfig = { + createAccounts: { + timeoutMs: 5000, + }, + discovery: { + timeoutMs: 2000, + maxAttempts: 3, + backOffMs: 1000, + }, +}; + +// Helper to create a tracked provider that monitors concurrent execution +const setup = ({ + config: configOverride = {}, + messenger = getRootMessenger(), + accounts = [], + keyring: keyringOverrides = {}, + capabilities = { scopes: [] }, +}: { + config?: DeepPartial; + messenger?: RootMessenger; + accounts?: InternalAccount[]; + keyring?: { type?: string; snapId?: SnapId }; + capabilities?: KeyringCapabilities; +} = {}) => { + const mocks = { + AccountsController: { + listMultichainAccounts: jest.fn(), + }, + ErrorReportingService: { + captureException: jest.fn(), + }, + KeyringController: { + withKeyringV2: jest.fn(), + }, + SnapController: { + handleKeyringRequest: { + getAccount: jest.fn(), + listAccounts: jest.fn(), + deleteAccount: jest.fn(), + }, + handleRequest: jest.fn(), + }, + SnapAccountService: { + ensureReady: jest.fn(), + getCapabilities: jest.fn(), + }, + }; + + messenger.registerActionHandler( + 'AccountsController:listMultichainAccounts', + mocks.AccountsController.listMultichainAccounts, + ); + mocks.AccountsController.listMultichainAccounts.mockReturnValue(accounts); + + messenger.registerActionHandler( + 'SnapAccountService:ensureReady', + mocks.SnapAccountService.ensureReady, + ); + // Make the platform ready right away (having a resolved promise is enough). + mocks.SnapAccountService.ensureReady.mockResolvedValue(undefined); + + messenger.registerActionHandler( + 'SnapAccountService:getCapabilities', + mocks.SnapAccountService.getCapabilities, + ); + mocks.SnapAccountService.getCapabilities.mockResolvedValue(capabilities); + + messenger.registerActionHandler( + 'SnapController:handleRequest', + mocks.SnapController.handleRequest, + ); + mocks.SnapController.handleRequest.mockImplementation( + async ({ request }: { request: JsonRpcRequest }) => { + if (request.method === String(KeyringRpcMethod.GetAccount)) { + return await mocks.SnapController.handleKeyringRequest.getAccount( + (request as GetAccountRequest).params.id, + ); + } else if (request.method === String(KeyringRpcMethod.ListAccounts)) { + return await mocks.SnapController.handleKeyringRequest.listAccounts(); + } else if (request.method === String(KeyringRpcMethod.DeleteAccount)) { + return await mocks.SnapController.handleKeyringRequest.deleteAccount( + (request as DeleteAccountRequest).params.id, + ); + } + throw new Error(`Unhandled method: ${request.method}`); + }, + ); + mocks.SnapController.handleKeyringRequest.getAccount.mockImplementation( + async (id) => + accounts.map(asKeyringAccount).find((account) => account.id === id), + ); + mocks.SnapController.handleKeyringRequest.listAccounts.mockImplementation( + async () => accounts.map(asKeyringAccount), + ); + mocks.SnapController.handleKeyringRequest.deleteAccount.mockResolvedValue( + null, + ); + + const keyring = { + type: keyringOverrides.type ?? 'snap', + snapId: keyringOverrides.snapId ?? TEST_SNAP_ID, + createAccounts: jest.fn(), + deleteAccount: jest.fn().mockResolvedValue(undefined), + lookupByAddress: jest + .fn() + .mockImplementation((address: string) => + accounts.map(asKeyringAccount).find((a) => a.address === address), + ), + }; + const metadata = { id: 'mock-keyring-id', name: '' } as KeyringMetadata; + + mocks.KeyringController.withKeyringV2.mockImplementation( + async (selector, operation) => { + if (selector.filter && !selector.filter(keyring, metadata)) { + throw new Error('No keyring matches the selector'); + } + return await operation({ keyring, metadata }); + }, + ); + messenger.registerActionHandler( + 'KeyringController:withKeyringV2', + mocks.KeyringController.withKeyringV2, + ); + + const serviceMessenger = getMultichainAccountServiceMessenger(messenger); + const config = deepmerge( + DEFAULT_TEST_CONFIG, + configOverride as SnapAccountProviderConfig, + ); + const provider = new MockSnapAccountProvider( + TEST_SNAP_ID, + serviceMessenger, + config, + ); + + return { + messenger, + provider, + tracker: provider.tracker, + keyring, + mocks, + }; +}; + +describe('SnapAccountProvider', () => { + describe('constructor default parameters', () => { + it('creates SolAccountProvider with default trace using 1 parameter', () => { + const { messenger } = setup(); + + const provider = new SolAccountProvider( + getMultichainAccountServiceMessenger(messenger), + ); + expect(provider).toBeDefined(); + expect(provider.snapId).toBe(SolAccountProvider.SOLANA_SNAP_ID); + }); + + it('creates SolAccountProvider with default trace using 2 parameters', () => { + const { messenger } = setup(); + + const provider = new SolAccountProvider( + getMultichainAccountServiceMessenger(messenger), + undefined, + ); + expect(provider).toBeDefined(); + expect(provider.snapId).toBe(SolAccountProvider.SOLANA_SNAP_ID); + }); + + it('creates SolAccountProvider with custom trace using 3 parameters', () => { + const { messenger } = setup(); + + const customTrace = jest.fn(); + const provider = new SolAccountProvider( + getMultichainAccountServiceMessenger(messenger), + undefined, + customTrace, + ); + expect(provider).toBeDefined(); + expect(provider.snapId).toBe(SolAccountProvider.SOLANA_SNAP_ID); + }); + + it('creates SolAccountProvider with custom config and default trace', () => { + const { messenger } = setup(); + + const customConfig = { + discovery: { + timeoutMs: 3000, + maxAttempts: 5, + backOffMs: 2000, + }, + createAccounts: { + timeoutMs: 5000, + }, + }; + const provider = new SolAccountProvider( + getMultichainAccountServiceMessenger(messenger), + customConfig, + ); + expect(provider).toBeDefined(); + expect(provider.snapId).toBe(SolAccountProvider.SOLANA_SNAP_ID); + }); + + it('creates BtcAccountProvider with default trace', () => { + const { messenger } = setup(); + + // Test other subclasses to ensure branch coverage + const btcProvider = new BtcAccountProvider( + getMultichainAccountServiceMessenger(messenger), + ); + + expect(btcProvider).toBeDefined(); + expect(isSnapAccountProvider(btcProvider)).toBe(true); + }); + + it('creates TrxAccountProvider with custom trace', () => { + const { messenger } = setup(); + + const customTrace = jest.fn(); + + // Explicitly test with all three parameters + const trxProvider = new TrxAccountProvider( + getMultichainAccountServiceMessenger(messenger), + undefined, + customTrace, + ); + + expect(trxProvider).toBeDefined(); + expect(isSnapAccountProvider(trxProvider)).toBe(true); + }); + + it('creates provider without trace parameter', () => { + const { messenger } = setup(); + + // Test creating provider without passing trace parameter + const provider = new SolAccountProvider( + getMultichainAccountServiceMessenger(messenger), + undefined, + ); + + expect(provider).toBeDefined(); + }); + + it('tests parameter spreading to trigger branch coverage', () => { + const { messenger } = setup(); + + type SolConfig = ConstructorParameters[1]; + type ProviderArgs = [ + MultichainAccountServiceMessenger, + SolConfig?, + TraceCallback?, + ]; + const args: ProviderArgs = [ + getMultichainAccountServiceMessenger(messenger), + ]; + const provider1 = new SolAccountProvider(...args); + + args.push(undefined); + args.push(jest.fn()); + const provider2 = new SolAccountProvider(...args); + + expect(provider1).toBeDefined(); + expect(provider2).toBeDefined(); + }); + }); + + describe('isSnapAccountProvider', () => { + it('returns false for plain object with snapId property', () => { + const mockProvider = { snapId: 'test-snap-id' }; + + expect(isSnapAccountProvider(mockProvider)).toBe(false); + }); + + it('returns false for null', () => { + expect(isSnapAccountProvider(null)).toBe(false); + }); + + it('returns false for undefined', () => { + expect(isSnapAccountProvider(undefined)).toBe(false); + }); + + it('returns false for object without snapId property', () => { + const mockProvider = { otherProperty: 'value' }; + + expect(isSnapAccountProvider(mockProvider)).toBe(false); + }); + + it('returns false for primitive values', () => { + expect(isSnapAccountProvider('string')).toBe(false); + expect(isSnapAccountProvider(123)).toBe(false); + expect(isSnapAccountProvider(true)).toBe(false); + }); + + it('returns true for actual SnapAccountProvider instance', () => { + const { messenger } = setup(); + + const solProvider = new SolAccountProvider( + getMultichainAccountServiceMessenger(messenger), + ); + expect(isSnapAccountProvider(solProvider)).toBe(true); + }); + }); + + describe('trace functionality', () => { + const traceFallbackMock = traceFallback as jest.MockedFunction< + typeof traceFallback + >; + + beforeEach(() => { + jest.clearAllMocks(); + traceFallbackMock.mockClear(); + }); + + it('uses default trace parameter when only messenger is provided', async () => { + const { messenger } = setup(); + + traceFallbackMock.mockImplementation(async (_request, fn) => fn?.()); + + // Test with default config and trace + const defaultConfig = { + discovery: { + timeoutMs: 2000, + maxAttempts: 3, + backOffMs: 1000, + }, + createAccounts: { + timeoutMs: 3000, + }, + }; + const testProvider = new MockSnapAccountProvider( + TEST_SNAP_ID, + getMultichainAccountServiceMessenger(messenger), + defaultConfig, + ); + const request = { name: 'Test Request', data: {} }; + const fn = jest.fn().mockResolvedValue('defaultResult'); + + await testProvider.trace(request, fn); + + expect(traceFallbackMock).toHaveBeenCalledTimes(1); + expect(traceFallbackMock).toHaveBeenCalledWith( + request, + expect.any(Function), + ); + expect(fn).toHaveBeenCalledTimes(1); + }); + + it('uses custom trace when explicitly provided with all parameters', async () => { + const { messenger } = setup(); + + const customTrace = jest.fn().mockImplementation(async (_request, fn) => { + return await fn(); + }); + + // Test with all parameters including custom trace + const testProvider = new MockSnapAccountProvider( + TEST_SNAP_ID, + getMultichainAccountServiceMessenger(messenger), + { + discovery: { + timeoutMs: 2000, + maxAttempts: 3, + backOffMs: 1000, + }, + createAccounts: { + timeoutMs: 3000, + }, + }, + customTrace, + ); + const request = { name: 'Test Request', data: {} }; + const fn = jest.fn().mockResolvedValue('customResult'); + + const result = await testProvider.trace(request, fn); + + expect(result).toBe('customResult'); + expect(customTrace).toHaveBeenCalledTimes(1); + expect(customTrace).toHaveBeenCalledWith(request, expect.any(Function)); + expect(traceFallbackMock).not.toHaveBeenCalled(); + }); + + it('calls trace callback with the correct arguments', async () => { + const { messenger } = setup(); + + const mockTrace = jest.fn().mockImplementation(async (request, fn) => { + expect(request).toStrictEqual({ + name: 'Test Request', + data: { test: 'data' }, + }); + return await fn(); + }); + + const defaultConfig = { + discovery: { + timeoutMs: 2000, + maxAttempts: 3, + backOffMs: 1000, + }, + createAccounts: { + timeoutMs: 3000, + }, + }; + const testProvider = new MockSnapAccountProvider( + TEST_SNAP_ID, + getMultichainAccountServiceMessenger(messenger), + defaultConfig, + mockTrace, + ); + const request = { name: 'Test Request', data: { test: 'data' } }; + const fn = jest.fn().mockResolvedValue('testResult'); + + const result = await testProvider.trace(request, fn); + + expect(result).toBe('testResult'); + expect(mockTrace).toHaveBeenCalledTimes(1); + expect(fn).toHaveBeenCalledTimes(1); + }); + + it('propagates errors through trace callback', async () => { + const { messenger } = setup(); + + const mockError = new Error('Test error'); + const mockTrace = jest.fn().mockImplementation(async (_request, fn) => { + return await fn(); + }); + + const defaultConfig = { + discovery: { + timeoutMs: 2000, + maxAttempts: 3, + backOffMs: 1000, + }, + createAccounts: { + timeoutMs: 3000, + }, + }; + const testProvider = new MockSnapAccountProvider( + TEST_SNAP_ID, + getMultichainAccountServiceMessenger(messenger), + defaultConfig, + mockTrace, + ); + const request = { name: 'Test Request', data: {} }; + const fn = jest.fn().mockRejectedValue(mockError); + + await expect(testProvider.trace(request, fn)).rejects.toThrow(mockError); + + expect(mockTrace).toHaveBeenCalledTimes(1); + expect(fn).toHaveBeenCalledTimes(1); + }); + + it('handles trace callback returning undefined', async () => { + const { messenger } = setup(); + + const mockTrace = jest.fn().mockImplementation(async (_request, fn) => { + return await fn(); + }); + + const defaultConfig = { + discovery: { + timeoutMs: 2000, + maxAttempts: 3, + backOffMs: 1000, + }, + createAccounts: { + timeoutMs: 3000, + }, + }; + const testProvider = new MockSnapAccountProvider( + TEST_SNAP_ID, + getMultichainAccountServiceMessenger(messenger), + defaultConfig, + mockTrace, + ); + const request = { name: 'Test Request', data: {} }; + const fn = jest.fn().mockResolvedValue(undefined); + + const result = await testProvider.trace(request, fn); + + expect(result).toBeUndefined(); + expect(mockTrace).toHaveBeenCalledTimes(1); + expect(fn).toHaveBeenCalledTimes(1); + }); + }); + + describe('withMaxConcurrency', () => { + afterEach(() => { + jest.clearAllTimers(); + jest.useRealTimers(); + }); + + it('throttles createAccounts when maxConcurrency is finite', async () => { + const { provider, tracker } = setup({ config: { maxConcurrency: 2 } }); // Allow only 2 concurrent operations + + // Start 4 concurrent calls + const promises = [0, 1, 2, 3].map((index) => + provider.createAccounts({ + type: AccountCreationType.Bip44DeriveIndex, + entropySource: TEST_ENTROPY_SOURCE, + groupIndex: index, + }), + ); + + await Promise.all(promises); + + // All operations should complete + expect(tracker.startLog).toHaveLength(4); + expect(tracker.endLog).toHaveLength(4); + + // With maxConcurrency=2, never more than 2 should run concurrently + expect(tracker.maxActiveCount).toBe(2); + + // First 2 should start immediately, next 2 should wait + expect(tracker.startLog.slice(0, 2).sort()).toStrictEqual([0, 1]); + }); + + it('does not throttle when maxConcurrency is Infinity', async () => { + const { provider, tracker } = setup({ + config: { maxConcurrency: Infinity }, + }); // No throttling + + // Start 4 concurrent calls + const promises = [0, 1, 2, 3].map((index) => + provider.createAccounts({ + type: AccountCreationType.Bip44DeriveIndex, + entropySource: TEST_ENTROPY_SOURCE, + groupIndex: index, + }), + ); + + await Promise.all(promises); + + // All 4 operations should complete + expect(tracker.startLog).toHaveLength(4); + + // With no throttling, all 4 should have been able to run concurrently + expect(tracker.maxActiveCount).toBe(4); + }); + + it('respects concurrency limit across multiple calls', async () => { + const { provider, tracker } = setup({ config: { maxConcurrency: 1 } }); // Only 1 concurrent operation + + // Start 3 concurrent calls + const promises = [0, 1, 2].map((index) => + provider.createAccounts({ + type: AccountCreationType.Bip44DeriveIndex, + entropySource: TEST_ENTROPY_SOURCE, + groupIndex: index, + }), + ); + + await Promise.all(promises); + + // Verify all completed + expect(tracker.endLog).toHaveLength(3); + + // With maxConcurrency=1, never more than 1 should run at a time + expect(tracker.maxActiveCount).toBe(1); + }); + + it('defaults to Infinity when maxConcurrency is not provided', async () => { + const { provider, tracker } = setup(); + + // Start 4 concurrent calls + const promises = [0, 1, 2, 3].map((index) => + provider.createAccounts({ + type: AccountCreationType.Bip44DeriveIndex, + entropySource: TEST_ENTROPY_SOURCE, + groupIndex: index, + }), + ); + + await Promise.all(promises); + + // All 4 operations should complete + expect(tracker.startLog).toHaveLength(4); + + // Without maxConcurrency specified, should default to Infinity (no throttling) + // So all 4 should have been able to run concurrently + expect(tracker.maxActiveCount).toBe(4); + }); + + it('throws an error when type is not "bip44:derive-index"', async () => { + const { provider } = setup(); + + await expect( + provider.createAccounts({ + // @ts-expect-error Testing invalid type handling. + type: 'unsupported-type', + entropySource: TEST_ENTROPY_SOURCE, + groupIndex: 0, + }), + ).rejects.toThrow( + 'Unsupported create account option type: unsupported-type', + ); + }); + }); + + describe('resyncAccounts', () => { + const mockAccounts = [ + MockAccountBuilder.from(MOCK_HD_ACCOUNT_1) + .withUuid() + .withSnapId(TEST_SNAP_ID) + .get(), + MockAccountBuilder.from(MOCK_HD_ACCOUNT_2) + .withUuid() + .withSnapId(TEST_SNAP_ID) + .get(), + ].filter(isBip44Account); + + it('does not create any accounts if already in-sync', async () => { + const { provider } = setup({ accounts: mockAccounts }); + + const createAccountsSpy = jest.spyOn(provider, 'createAccounts'); + + await provider.resyncAccounts(mockAccounts); + + expect(createAccountsSpy).not.toHaveBeenCalled(); + }); + + it('creates new accounts if de-synced', async () => { + const { provider } = setup({ + accounts: [mockAccounts[0]], + }); + + const createAccountsSpy = jest.spyOn(provider, 'createAccounts'); + + await provider.resyncAccounts(mockAccounts); + + const desyncedAccount = mockAccounts[1]; + expect(createAccountsSpy).toHaveBeenCalledWith({ + entropySource: desyncedAccount.options.entropy.id, + groupIndex: desyncedAccount.options.entropy.groupIndex, + type: AccountCreationType.Bip44DeriveIndex, + }); + }); + + it('deletes extra Snap accounts when Snap has more accounts than MetaMask', async () => { + const { provider, mocks } = setup({ + accounts: mockAccounts, + config: { resyncAccounts: { autoRemoveExtraSnapAccounts: true } }, + }); + + // Snap has both accounts, but MetaMask only has the first one + await provider.resyncAccounts([mockAccounts[0]]); + + // deleteAccount should be called for the extra account in the Snap + expect( + mocks.SnapController.handleKeyringRequest.deleteAccount, + ).toHaveBeenCalledTimes(1); + expect( + mocks.SnapController.handleKeyringRequest.deleteAccount, + ).toHaveBeenCalledWith(mockAccounts[1].id); + }); + + it('handles deleteAccount errors gracefully when recovering de-synced accounts', async () => { + const { provider, messenger, mocks } = setup({ + accounts: mockAccounts, + config: { resyncAccounts: { autoRemoveExtraSnapAccounts: true } }, + }); + + const captureExceptionSpy = jest.spyOn(messenger, 'captureException'); + const deleteError = new Error('Failed to delete account'); + mocks.SnapController.handleKeyringRequest.deleteAccount.mockRejectedValue( + deleteError, + ); + + // Snap has both accounts, but MetaMask only has the first one + await provider.resyncAccounts([mockAccounts[0]]); + + // Should have attempted to delete the extra account + expect( + mocks.SnapController.handleKeyringRequest.deleteAccount, + ).toHaveBeenCalledWith(mockAccounts[1].id); + + // Should capture the deletion error but not throw + expect(captureExceptionSpy).toHaveBeenCalledWith( + expect.objectContaining({ + message: `Unable to delete de-synced Snap account: ${TEST_SNAP_ID}`, + cause: deleteError, + }), + ); + }); + + it('does not capture exception when deleteAccount times out', async () => { + const { provider, messenger, mocks } = setup({ + accounts: mockAccounts, + config: { resyncAccounts: { autoRemoveExtraSnapAccounts: true } }, + }); + + const captureExceptionSpy = jest.spyOn(messenger, 'captureException'); + const consoleWarnSpy = jest.spyOn(console, 'warn').mockImplementation(); + const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(); + mocks.SnapController.handleKeyringRequest.deleteAccount.mockRejectedValue( + new TimeoutError('Timed out after: 500ms'), + ); + + await provider.resyncAccounts([mockAccounts[0]]); + + expect(captureExceptionSpy).not.toHaveBeenCalled(); + expect(consoleWarnSpy).toHaveBeenCalled(); + expect(consoleErrorSpy).not.toHaveBeenCalled(); + consoleWarnSpy.mockRestore(); + consoleErrorSpy.mockRestore(); + }); + + it('does not delete accounts that exist in both Snap and MetaMask', async () => { + const { provider, mocks } = setup({ accounts: mockAccounts }); + + // Both accounts exist in both Snap and MetaMask + await provider.resyncAccounts(mockAccounts); + + // deleteAccount should not be called since accounts are in sync + expect( + mocks.SnapController.handleKeyringRequest.deleteAccount, + ).not.toHaveBeenCalled(); + }); + + it('handles bidirectional de-sync by deleting extra Snap accounts and recreating missing ones', async () => { + // Create extra accounts that only exist in the Snap + const extraSnapAccount1 = MockAccountBuilder.from(MOCK_HD_ACCOUNT_1) + .withUuid() + .withSnapId(TEST_SNAP_ID) + .get(); + const extraSnapAccount2 = MockAccountBuilder.from(MOCK_HD_ACCOUNT_2) + .withUuid() + .withSnapId(TEST_SNAP_ID) + .get(); + + // Snap has: [mockAccounts[0], extraSnapAccount1, extraSnapAccount2] (3 accounts) + // MetaMask has: [mockAccounts[0], mockAccounts[1]] (2 accounts) + // First condition (2 < 3): delete extraSnapAccount1 and extraSnapAccount2 from Snap + // After deletion: snapAccounts.size = 1, so second condition (2 > 1) triggers + // Second condition: recreate mockAccounts[1] in Snap + const { provider, mocks, keyring } = setup({ + accounts: [mockAccounts[0], extraSnapAccount1, extraSnapAccount2], + config: { resyncAccounts: { autoRemoveExtraSnapAccounts: true } }, + }); + + const createAccountsSpy = jest.spyOn(provider, 'createAccounts'); + + await provider.resyncAccounts(mockAccounts); + + // Should delete the extra Snap accounts + expect( + mocks.SnapController.handleKeyringRequest.deleteAccount, + ).toHaveBeenCalledTimes(2); + expect( + mocks.SnapController.handleKeyringRequest.deleteAccount, + ).toHaveBeenCalledWith(extraSnapAccount1.id); + expect( + mocks.SnapController.handleKeyringRequest.deleteAccount, + ).toHaveBeenCalledWith(extraSnapAccount2.id); + + // Should delete the missing account from the keyring (by id) before recreating it. + expect(keyring.deleteAccount).toHaveBeenCalledWith(mockAccounts[1].id); + expect(createAccountsSpy).toHaveBeenCalledWith({ + entropySource: mockAccounts[1].options.entropy.id, + groupIndex: mockAccounts[1].options.entropy.groupIndex, + type: AccountCreationType.Bip44DeriveIndex, + }); + }); + + it('removes extra Snap accounts when resyncAccounts config is absent (defaults to true)', async () => { + const { provider, mocks } = setup({ accounts: mockAccounts }); + + // Snap has both accounts, but MetaMask only has the first one + await provider.resyncAccounts([mockAccounts[0]]); + + // deleteAccount should be called — the ?? true default kicks in + expect( + mocks.SnapController.handleKeyringRequest.deleteAccount, + ).toHaveBeenCalledTimes(1); + expect( + mocks.SnapController.handleKeyringRequest.deleteAccount, + ).toHaveBeenCalledWith(mockAccounts[1].id); + }); + + it('logs a warning and skips removal when autoRemoveExtraSnapAccounts is false', async () => { + const { provider, mocks } = setup({ + accounts: mockAccounts, + config: { resyncAccounts: { autoRemoveExtraSnapAccounts: false } }, + }); + + // Snap has both accounts, but MetaMask only has the first one + await provider.resyncAccounts([mockAccounts[0]]); + + // deleteAccount should NOT be called + expect( + mocks.SnapController.handleKeyringRequest.deleteAccount, + ).not.toHaveBeenCalled(); + }); + + it('does not throw errors if any provider is not able to re-sync', async () => { + const { provider, messenger } = setup({ accounts: [mockAccounts[0]] }); + + const captureExceptionSpy = jest.spyOn(messenger, 'captureException'); + const createAccountsSpy = jest.spyOn(provider, 'createAccounts'); + + const providerError = new Error('Unable to create accounts'); + createAccountsSpy.mockRejectedValue(providerError); + + await provider.resyncAccounts(mockAccounts); + + expect(createAccountsSpy).toHaveBeenCalled(); + + expect(captureExceptionSpy).toHaveBeenCalledWith( + new Error('Unable to re-sync accounts'), + ); + expect(captureExceptionSpy.mock.lastCall[0]).toHaveProperty( + 'cause', + providerError, + ); + }); + + it('does not capture exception when re-sync times out', async () => { + const { provider, messenger } = setup({ accounts: [mockAccounts[0]] }); + + const captureExceptionSpy = jest.spyOn(messenger, 'captureException'); + const consoleWarnSpy = jest.spyOn(console, 'warn').mockImplementation(); + const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(); + const createAccountsSpy = jest.spyOn(provider, 'createAccounts'); + createAccountsSpy.mockRejectedValue( + new TimeoutError('Timed out after: 500ms'), + ); + + await provider.resyncAccounts(mockAccounts); + + expect(captureExceptionSpy).not.toHaveBeenCalled(); + expect(consoleWarnSpy).toHaveBeenCalled(); + expect(consoleErrorSpy).not.toHaveBeenCalled(); + consoleWarnSpy.mockRestore(); + consoleErrorSpy.mockRestore(); + }); + }); + + describe('ensureReady', () => { + it('delegates Snap platform readiness check to SnapAccountService:ensureReady', async () => { + const { provider, mocks } = setup(); + + await provider.ensureReady(); + + expect(mocks.SnapAccountService.ensureReady).toHaveBeenCalledTimes(1); + }); + }); + + describe('deleteAccount', () => { + it('forwards to SnapKeyring.deleteAccount(id) using the tracked account id', async () => { + const account = MOCK_HD_ACCOUNT_1; + const { provider, keyring, messenger } = setup({ accounts: [account] }); + messenger.registerActionHandler('AccountsController:getAccount', (id) => + id === account.id ? (account as InternalAccount) : undefined, + ); + provider.init([account.id]); + + await provider.deleteAccount(account.id); + + expect(keyring.deleteAccount).toHaveBeenCalledWith(account.id); + // The provider should no longer track the deleted account. + expect(() => provider.getAccount(account.id)).toThrow( + `Unable to find account: ${account.id}`, + ); + }); + + it('throws if the account is not tracked by the provider', async () => { + const { provider } = setup(); + + await expect(provider.deleteAccount('unknown-id')).rejects.toThrow( + 'Unable to find account: unknown-id', + ); + }); + }); +}); diff --git a/packages/multichain-account-service/src/providers/SnapAccountProvider.ts b/packages/multichain-account-service/src/providers/SnapAccountProvider.ts new file mode 100644 index 00000000000..a3181939ebc --- /dev/null +++ b/packages/multichain-account-service/src/providers/SnapAccountProvider.ts @@ -0,0 +1,537 @@ +import { assertIsBip44Account } from '@metamask/account-api'; +import type { Bip44Account } from '@metamask/account-api'; +import type { TraceCallback, TraceRequest } from '@metamask/controller-utils'; +import type { SnapKeyring as SnapKeyringV2 } from '@metamask/eth-snap-keyring/v2'; +import { + EMPTY_CAPABILITIES, + isSnapKeyring, +} from '@metamask/eth-snap-keyring/v2'; +import { + AccountCreationType, + assertCreateAccountOptionIsSupported, +} from '@metamask/keyring-api'; +import type { + CreateAccountBip44DeriveIndexOptions, + CreateAccountBip44DeriveIndexRangeOptions, + CreateAccountBip44DiscoverOptions, + CreateAccountOptions, + EntropySourceId, + KeyringAccount, +} from '@metamask/keyring-api'; +import type { KeyringCapabilities } from '@metamask/keyring-api/v2'; +import type { KeyringMetadata } from '@metamask/keyring-controller'; +import type { InternalAccount } from '@metamask/keyring-internal-api'; +import type { Json, JsonRpcRequest, SnapId } from '@metamask/snaps-sdk'; +import { HandlerType } from '@metamask/snaps-utils'; +import type { CaipChainId } from '@metamask/utils'; +import { Semaphore } from 'async-mutex'; + +import { + toCreateAccountsV2DataTraces, + traceFallback, + TraceName, +} from '../analytics/index.js'; +import { reportError } from '../errors.js'; +import { projectLogger as log, WARNING_PREFIX } from '../logger.js'; +import type { MultichainAccountServiceMessenger } from '../types.js'; +import { BaseBip44AccountProvider } from './BaseBip44AccountProvider.js'; +import { createSnapKeyringClient } from './SnapKeyringClient.js'; +import type { Sender, SnapKeyringClient } from './SnapKeyringClient.js'; +import { withRetry, withTimeout } from './utils.js'; + +/** + * A proxy to the Snap's keyring operations that routes every call through the + * `KeyringController` mutex (via {@link SnapAccountProvider.#withSnapKeyring}). + * Callers receive this object from {@link SnapAccountProvider.withSnap} and + * never interact with the raw keyring or the mutex directly. + */ +export type SnapKeyringProxy = { + createAccounts: SnapKeyringV2['createAccounts']; + deleteAccount: SnapKeyringV2['deleteAccount']; +}; + +export type SnapAccountProviderConfig = { + maxConcurrency?: number; + discovery: { + enabled?: boolean; + maxAttempts: number; + timeoutMs: number; + backOffMs: number; + }; + createAccounts: { + /** + * Timeout for account creation operations. + * + * NOTE: Batching (and thus whether a single call may create multiple + * accounts) is driven by the Snap's declared capabilities, not this config. + * The value might have to be adapted when the Snap supports batching. + */ + timeoutMs: number; + }; + resyncAccounts?: { + /** + * Whether to automatically remove extra Snap accounts when the Snap has + * more accounts than MetaMask. If `false`, a warning is logged instead. + * Defaults to `true`. + */ + autoRemoveExtraSnapAccounts?: boolean; + }; +}; + +export abstract class SnapAccountProvider extends BaseBip44AccountProvider { + readonly snapId: SnapId; + + protected readonly config: SnapAccountProviderConfig; + + /** + * The Snap's keyring capabilities, sourced from `SnapAccountService` (which + * reads them from the Snap's manifest). Populated the first time the client + * is resolved; defaults to an empty capability set until then. + */ + capabilities: KeyringCapabilities = EMPTY_CAPABILITIES; + + /** + * Version-agnostic keyring client, resolved lazily once the Snap is ready and + * its capabilities are known — see {@link SnapAccountProvider.withSnap}. + */ + #client?: SnapKeyringClient; + + readonly #sender: Sender; + + readonly #queue?: Semaphore; + + readonly #trace: TraceCallback; + + /** + * Scopes passed to the v1 `discoverAccounts` client method. Only used on the + * v1 discovery path. + * + * TODO: Remove once all Snaps are fully v2 — discovery is then driven by the + * Snap's own supported scopes via `createAccounts({ bip44:discover })`. + */ + protected abstract readonly v1DiscoveryScopes: CaipChainId[]; + + constructor( + snapId: SnapId, + messenger: MultichainAccountServiceMessenger, + config: SnapAccountProviderConfig, + /* istanbul ignore next */ + trace: TraceCallback = traceFallback, + ) { + super(messenger); + + this.snapId = snapId; + this.#sender = this.#createSender(snapId); + + const maxConcurrency = config.maxConcurrency ?? Infinity; + this.config = { + ...config, + discovery: { + ...config.discovery, + enabled: config.discovery.enabled ?? true, + }, + maxConcurrency, + }; + + // Create semaphore only if concurrency is limited + if (isFinite(maxConcurrency)) { + this.#queue = new Semaphore(maxConcurrency); + } + + this.#trace = trace; + } + + /** + * Ensures that the Snap is ready to be used. + * + * Once this resolves, a Snap keyring for {@link snapId} is guaranteed to + * exist in the `KeyringController`, so subsequent {@link #withSnapKeyring} + * calls will not fail with "No keyring matches the selector". + * + * @returns A promise that resolves when the Snap is ready. + * @throws An error if the Snap could not become ready. + */ + override async ensureReady(): Promise { + return this.messenger.call('SnapAccountService:ensureReady', this.snapId); + } + + /** + * Wraps an async operation with concurrency limiting based on maxConcurrency config. + * If maxConcurrency is Infinity (the default), the operation runs immediately without throttling. + * Otherwise, it's queued through the semaphore to respect the concurrency limit. + * + * @param operation - The async operation to execute. + * @returns The result of the operation. + */ + protected async withMaxConcurrency( + operation: () => Promise, + ): Promise { + if (this.#queue) { + return this.#queue.runExclusive(operation); + } + return operation(); + } + + protected async trace( + request: TraceRequest, + fn: () => Promise, + ): Promise { + return this.#trace(request, fn); + } + + #createSender(snapId: string): Sender { + return { + send: async (request: JsonRpcRequest): Promise => { + const response = await this.messenger.call( + 'SnapController:handleRequest', + { + snapId: snapId as SnapId, + origin: 'metamask', + handler: HandlerType.OnKeyringRequest, + request, + }, + ); + return response as Json; + }, + }; + } + + /** + * Whether the Snap supports the v2 keyring protocol, inferred from its + * declared capabilities (a v2-capable Snap declares BIP-44 capabilities). + * + * @returns `true` if the Snap is v2-capable. + */ + protected isV2(): boolean { + return Boolean(this.capabilities.bip44); + } + + /** + * Resolves the version-agnostic keyring client, fetching the Snap's + * capabilities from `SnapAccountService` on first use and caching both the + * capabilities and the resulting client. + * + * Callers must ensure the Snap is ready (via + * {@link SnapAccountProvider.ensureReady}) beforehand so that the + * capabilities are reliably populated — {@link SnapAccountProvider.withSnap} + * guarantees this ordering. + * + * @returns The resolved {@link SnapKeyringClient}. + */ + async #resolveClient(): Promise { + if (!this.#client) { + this.capabilities = await this.messenger.call( + 'SnapAccountService:getCapabilities', + this.snapId, + ); + this.#client = createSnapKeyringClient(this.#sender, this.isV2()); + } + return this.#client; + } + + async resyncAccounts( + accounts: Bip44Account[], + ): Promise { + await this.withSnap(async ({ client, keyring }) => { + const localSnapAccounts = accounts.filter( + (account) => account.metadata.snap?.id === this.snapId, + ); + const snapAccounts = new Set( + (await client.getAccounts()).map((account) => account.id), + ); + + // NOTE: This should never happen, but if it does, we recover by deleting the + // extra accounts from the Snap to bring it back in sync with MetaMask. + if (localSnapAccounts.length < snapAccounts.size) { + const autoRemoveExtraSnapAccounts = + this.config.resyncAccounts?.autoRemoveExtraSnapAccounts ?? true; + + if (autoRemoveExtraSnapAccounts) { + // Build a set of local account IDs for quick lookup + const localAccountIds = new Set( + localSnapAccounts.map((account) => account.id), + ); + + // Find and delete accounts that exist in Snap but not in MetaMask + await Promise.all( + [...snapAccounts].map(async (snapAccountId) => { + try { + if (!localAccountIds.has(snapAccountId)) { + // This account exists in the Snap but not in MetaMask, delete it from + // the Snap. + await client.deleteAccount(snapAccountId); + // Update the local Set so subsequent checks use the correct size + snapAccounts.delete(snapAccountId); + } + } catch (error) { + reportError( + this.messenger, + `Unable to delete de-synced Snap account: ${this.snapId}`, + error, + { + provider: this.getName(), + snapAccountId, + }, + ); + } + }), + ); + } else { + const message = `Snap "${this.snapId}" has de-synced accounts, Snap has more accounts than MetaMask! (${localSnapAccounts.length} < ${snapAccounts.size})`; + log(`${WARNING_PREFIX} ${message}`); + console.warn(message); + return; + } + } + + // We want this part to be fast, so we only check for sizes, but we might need + // to make a real "diff" between the 2 states to not miss any de-sync. + if (localSnapAccounts.length > snapAccounts.size) { + // We always use the MetaMask list as the main reference here. + await Promise.all( + localSnapAccounts.map(async (account) => { + const { id: entropySource, groupIndex } = account.options.entropy; + + try { + if (!snapAccounts.has(account.id)) { + // We still need to remove the accounts from the Snap keyring since we're + // about to create the same account again, which will use a new ID, but will + // keep using the same address, and the Snap keyring does not allow this. + await keyring.deleteAccount(account.id); + // The Snap has no account in its state for this one, we re-create it. + await this.createAccounts({ + type: AccountCreationType.Bip44DeriveIndex, + entropySource, + groupIndex, + }); + } + } catch (error) { + reportError(this.messenger, 'Unable to re-sync accounts', error, { + provider: this.getName(), + groupIndex, + }); + } + }), + ); + } + }); + } + + async #withSnapKeyring( + operation: ({ + keyring, + metadata, + }: { + keyring: SnapKeyringV2; + metadata: KeyringMetadata; + }) => Promise, + ): Promise { + return this.withKeyringV2( + { + filter: (keyring) => + isSnapKeyring(keyring) && keyring.snapId === this.snapId, + }, + (args) => operation(args), + ); + } + + protected async withSnap( + operation: (snap: { + client: SnapKeyringClient; + keyring: SnapKeyringProxy; + }) => Promise, + ): Promise { + await this.ensureReady(); + const client = await this.#resolveClient(); + const keyring: SnapKeyringProxy = { + createAccounts: (options) => + this.#withSnapKeyring(({ keyring: snapKeyring }) => + snapKeyring.createAccounts(options), + ), + deleteAccount: (id) => + this.#withSnapKeyring(({ keyring: snapKeyring }) => + snapKeyring.deleteAccount(id), + ), + }; + return await operation({ client, keyring }); + } + + abstract isAccountCompatible(account: Bip44Account): boolean; + + protected toBip44Account( + account: KeyringAccount, + _options: { entropySource: EntropySourceId; groupIndex: number }, + ): Bip44Account { + assertIsBip44Account(account); + return account; + } + + protected async createBip44Accounts( + keyring: SnapKeyringProxy, + options: + | CreateAccountBip44DeriveIndexOptions + | CreateAccountBip44DeriveIndexRangeOptions + | CreateAccountBip44DiscoverOptions, + ): Promise[]> { + return this.withMaxConcurrency(async () => { + const { entropySource } = options; + + const snapAccounts = await withTimeout( + () => + this.trace( + { + name: TraceName.ProviderCreateAccounts, + data: { + provider: this.getName(), + ...toCreateAccountsV2DataTraces(options), + }, + }, + () => keyring.createAccounts(options), + ), + this.config.createAccounts.timeoutMs, + ); + + const groupIndexOffset = + options.type === `${AccountCreationType.Bip44DeriveIndexRange}` + ? options.range.from + : options.groupIndex; + + return snapAccounts.map((snapAccount, index) => { + const groupIndex = groupIndexOffset + index; + const account = this.toBip44Account(snapAccount, { + entropySource, + groupIndex, + }); + + this.accounts.add(snapAccount.id); + return account; + }); + }); + } + + async createAccounts( + options: CreateAccountOptions, + ): Promise[]> { + assertCreateAccountOptionIsSupported(options, [ + `${AccountCreationType.Bip44DeriveIndex}`, + `${AccountCreationType.Bip44DeriveIndexRange}`, + ]); + + return this.withSnap(async ({ keyring }) => + this.createBip44Accounts(keyring, options), + ); + } + + /** + * Delete a snap account by id. + * + * Resolves the account's address from the tracked account, then forwards to + * the legacy `SnapKeyring.removeAccount(address)`. The Snap keyring takes + * care of notifying the snap to clean up its own state through the normal + * account-removal flow (same path used by `resyncAccounts`). + * + * @param id - The id of the account to delete. + */ + async deleteAccount(id: Bip44Account['id']): Promise { + const account = this.getAccount(id); + + await this.#withSnapKeyring(async ({ keyring }) => { + await keyring.deleteAccount(account.id); + }); + + this.accounts.delete(id); + } + + /** + * Discovers accounts for the given entropy source and group index. + * + * v2 Snaps drive discovery through `createAccounts({ bip44:discover })`: the + * Snap checks for on-chain activity (using its own supported scopes) and + * returns the created account(s), or nothing once discovery is exhausted. + * + * v1 Snaps use the client's `discoverAccounts` to detect activity on + * {@link v1DiscoveryScopes}, then create the account for the group index. + * + * @param options - The discovery options. + * @param options.entropySource - The entropy source to discover accounts for. + * @param options.groupIndex - The group index to discover accounts for. + * @returns The discovered (and created) accounts, or an empty array when + * there is nothing to discover at this group index. + */ + async discoverAccounts({ + entropySource, + groupIndex, + }: { + entropySource: EntropySourceId; + groupIndex: number; + }): Promise[]> { + return this.withSnap(async ({ client, keyring }) => + this.trace( + { + name: TraceName.SnapDiscoverAccounts, + data: { + provider: this.getName(), + }, + }, + async () => { + if (!this.config.discovery.enabled) { + return []; + } + + if (this.isV2()) { + // The v2 client has no `discoverAccounts`, so discovery is only + // possible when the Snap supports `bip44:discover`. Otherwise there + // is no way to discover and we report nothing. + if (!this.capabilities.bip44?.discover) { + return []; + } + + // v2: the Snap detects on-chain activity and creates the account in + // a single `createAccounts({ bip44:discover })` call. An empty + // result means discovery is exhausted at this group index. + return this.createBip44Accounts(keyring, { + type: AccountCreationType.Bip44Discover, + entropySource, + groupIndex, + }); + } + + // v1: detect activity via the client, then create the account for + // this group index. + const discoveredAccounts = await withRetry( + () => + withTimeout( + () => + client.discoverAccounts( + this.v1DiscoveryScopes, + entropySource, + groupIndex, + ), + this.config.discovery.timeoutMs, + ), + { + maxAttempts: this.config.discovery.maxAttempts, + backOffMs: this.config.discovery.backOffMs, + }, + ); + + if (!discoveredAccounts.length) { + return []; + } + + return this.createBip44Accounts(keyring, { + type: AccountCreationType.Bip44DeriveIndex, + entropySource, + groupIndex, + }); + }, + ), + ); + } +} + +export const isSnapAccountProvider = ( + provider: unknown, +): provider is SnapAccountProvider => { + return provider instanceof SnapAccountProvider; +}; diff --git a/packages/multichain-account-service/src/providers/SnapKeyringClient.test.ts b/packages/multichain-account-service/src/providers/SnapKeyringClient.test.ts new file mode 100644 index 00000000000..1d10d3e36ff --- /dev/null +++ b/packages/multichain-account-service/src/providers/SnapKeyringClient.test.ts @@ -0,0 +1,87 @@ +import { SolScope } from '@metamask/keyring-api'; + +import { createSnapKeyringClient } from './SnapKeyringClient.js'; +import type { Sender } from './SnapKeyringClient.js'; + +/** + * Builds a mock {@link Sender} whose `send` resolves to the given response. + * + * @param response - The value the sender should resolve to. + * @returns The mock sender and its `send` jest mock. + */ +function makeSender(response: unknown = []): { + sender: Sender; + send: jest.Mock; +} { + const send = jest.fn().mockResolvedValue(response); + return { sender: { send } as unknown as Sender, send }; +} + +describe('createSnapKeyringClient', () => { + describe('v1 client', () => { + it('gets accounts', async () => { + const { sender, send } = makeSender([]); + const client = createSnapKeyringClient(sender, false); + + expect(await client.getAccounts()).toStrictEqual([]); + expect(send).toHaveBeenCalledWith( + expect.objectContaining({ method: 'keyring_listAccounts' }), + ); + }); + + it('deletes an account', async () => { + const { sender, send } = makeSender(null); + const client = createSnapKeyringClient(sender, false); + + await client.deleteAccount('account-id'); + expect(send).toHaveBeenCalledWith( + expect.objectContaining({ method: 'keyring_deleteAccount' }), + ); + }); + + it('discovers accounts', async () => { + const { sender, send } = makeSender([]); + const client = createSnapKeyringClient(sender, false); + + expect( + await client.discoverAccounts([SolScope.Mainnet], 'entropy', 0), + ).toStrictEqual([]); + expect(send).toHaveBeenCalledWith( + expect.objectContaining({ method: 'keyring_discoverAccounts' }), + ); + }); + }); + + describe('v2 client', () => { + it('gets accounts via getAccounts', async () => { + const { sender, send } = makeSender([]); + const client = createSnapKeyringClient(sender, true); + + expect(await client.getAccounts()).toStrictEqual([]); + expect(send).toHaveBeenCalledWith( + expect.objectContaining({ method: 'keyring_getAccounts' }), + ); + }); + + it('deletes an account', async () => { + const { sender, send } = makeSender(null); + const client = createSnapKeyringClient(sender, true); + + await client.deleteAccount('account-id'); + expect(send).toHaveBeenCalledWith( + expect.objectContaining({ method: 'keyring_deleteAccount' }), + ); + }); + + it('throws for discoverAccounts (unsupported on v2)', async () => { + const { sender } = makeSender(); + const client = createSnapKeyringClient(sender, true); + + await expect( + client.discoverAccounts([SolScope.Mainnet], 'entropy', 0), + ).rejects.toThrow( + 'discoverAccounts is not supported on the v2 keyring client', + ); + }); + }); +}); diff --git a/packages/multichain-account-service/src/providers/SnapKeyringClient.ts b/packages/multichain-account-service/src/providers/SnapKeyringClient.ts new file mode 100644 index 00000000000..243bdb9631c --- /dev/null +++ b/packages/multichain-account-service/src/providers/SnapKeyringClient.ts @@ -0,0 +1,88 @@ +import type { EntropySourceId, KeyringAccount } from '@metamask/keyring-api'; +import { KeyringClient } from '@metamask/keyring-snap-client'; +import { KeyringClient as KeyringClientV2 } from '@metamask/keyring-snap-client/v2'; +import type { CaipChainId } from '@metamask/utils'; + +/** + * Transport used by both the v1 and v2 keyring clients. Matches the object the + * {@link KeyringClient} constructor expects. + */ +export type Sender = ConstructorParameters[0]; + +type DiscoveredAccount = Awaited< + ReturnType +>[number]; + +/** + * Thin abstraction over the v1 and v2 keyring RPC clients so that provider call + * sites (account re-sync and v1 discovery) don't have to branch on the keyring + * protocol version. The v1 and v2 clients expose different method names (e.g. + * v1 `listAccounts` vs v2 `getAccounts`), and v2 has no `discoverAccounts` — v2 + * discovery flows through `createAccounts({ bip44:discover })` on the bridge + * keyring instead. + */ +export type SnapKeyringClient = { + /** + * Returns the accounts the Snap currently holds. + */ + getAccounts(): Promise; + + /** + * Deletes an account by id from the Snap. + * + * @param id - The id of the account to delete. + */ + deleteAccount(id: string): Promise; + + /** + * Discovers accounts for the given entropy source and group index. + * + * Only supported on the v1 client; the v2 client throws, since v2 discovery + * goes through `createAccounts({ bip44:discover })`. + * + * @param scopes - The scopes to discover accounts on. + * @param entropySource - The entropy source to discover accounts for. + * @param groupIndex - The group index to discover accounts for. + */ + discoverAccounts( + scopes: CaipChainId[], + entropySource: EntropySourceId, + groupIndex: number, + ): Promise; +}; + +/** + * Builds a version-agnostic {@link SnapKeyringClient} backed by either the v1 + * or v2 keyring client, based on the Snap's declared capabilities. + * + * @param sender - The transport used to talk to the Snap. + * @param isV2 - Whether to back the client with the v2 keyring client. + * @returns A {@link SnapKeyringClient}. + */ +export function createSnapKeyringClient( + sender: Sender, + isV2: boolean, +): SnapKeyringClient { + if (isV2) { + const client = new KeyringClientV2(sender); + return { + getAccounts: async () => client.getAccounts(), + deleteAccount: async (id) => client.deleteAccount(id), + discoverAccounts: async (): Promise => { + // v2 discovery is driven by `createAccounts({ bip44:discover })` through + // the bridge keyring, so the client is never used for discovery here. + throw new Error( + 'discoverAccounts is not supported on the v2 keyring client', + ); + }, + }; + } + + const client = new KeyringClient(sender); + return { + getAccounts: async () => client.listAccounts(), + deleteAccount: async (id) => client.deleteAccount(id), + discoverAccounts: async (scopes, entropySource, groupIndex) => + client.discoverAccounts(scopes, entropySource, groupIndex), + }; +} diff --git a/packages/multichain-account-service/src/providers/SolAccountProvider.test.ts b/packages/multichain-account-service/src/providers/SolAccountProvider.test.ts new file mode 100644 index 00000000000..837af6d338e --- /dev/null +++ b/packages/multichain-account-service/src/providers/SolAccountProvider.test.ts @@ -0,0 +1,621 @@ +import { isBip44Account } from '@metamask/account-api'; +import { AccountCreationType, SolScope } from '@metamask/keyring-api'; +import type { KeyringCapabilities } from '@metamask/keyring-api/v2'; +import type { KeyringMetadata } from '@metamask/keyring-controller'; +import type { InternalAccount } from '@metamask/keyring-internal-api'; +import { SnapControllerState } from '@metamask/snaps-controllers'; +import deepmerge from 'deepmerge'; + +import { TraceName } from '../analytics/traces.js'; +import { + getMultichainAccountServiceMessenger, + getRootMessenger, + toGroupIndexRangeArray, + MOCK_HD_ACCOUNT_1, + MOCK_HD_KEYRING_1, + MOCK_SOL_ACCOUNT_1, + MOCK_SOL_DISCOVERED_ACCOUNT_1, + MockAccountBuilder, +} from '../tests/index.js'; +import type { RootMessenger, DeepPartial } from '../tests/index.js'; +import { AccountProviderWrapper } from './AccountProviderWrapper.js'; +import type { SnapAccountProviderConfig } from './SnapAccountProvider.js'; +import { + SOL_ACCOUNT_PROVIDER_DEFAULT_CONFIG, + SOL_ACCOUNT_PROVIDER_NAME, + SolAccountProvider, +} from './SolAccountProvider.js'; + +function asConfig( + partial: DeepPartial, +): SnapAccountProviderConfig { + return deepmerge( + SOL_ACCOUNT_PROVIDER_DEFAULT_CONFIG, + partial, + ) as SnapAccountProviderConfig; +} + +/** + * v2 capabilities as declared by a fully v2-compliant Solana Snap manifest. + * Drives the batched `createAccounts` flow and the v2 discovery path. + */ +const SOL_V2_CAPABILITIES: KeyringCapabilities = { + scopes: [SolScope.Mainnet], + bip44: { + deriveIndex: true, + deriveIndexRange: true, + discover: true, + }, +}; + +class MockSolanaKeyring { + readonly type = 'MockSolanaKeyring'; + + readonly metadata: KeyringMetadata = { + id: 'mock-solana-keyring-id', + name: '', + }; + + readonly accounts: InternalAccount[]; + + constructor(accounts: InternalAccount[]) { + this.accounts = accounts; + } + + createAccounts = jest.fn().mockImplementation((options) => { + const groupIndices = + options.type === 'bip44:derive-index' + ? [options.groupIndex] + : toGroupIndexRangeArray(options.range); + + return groupIndices.map((groupIndex) => { + const found = this.accounts.find( + (account) => + isBip44Account(account) && + account.options.entropy.groupIndex === groupIndex, + ); + + if (found) { + return found; // Idempotent. + } + + const account = MockAccountBuilder.from(MOCK_SOL_ACCOUNT_1) + .withUuid() + .withAddressSuffix(`${groupIndex}`) + .withGroupIndex(groupIndex) + .get(); + this.accounts.push(account); + return account; + }); + }); + + deleteAccount = jest.fn().mockResolvedValue(undefined); +} + +class MockSolAccountProvider extends SolAccountProvider { + override async ensureReady(): Promise { + // Override to avoid waiting during tests. + } +} + +/** + * Sets up a SolAccountProvider for testing. + * + * @param options - Configuration options for setup. + * @param options.messenger - An optional messenger instance to use. Defaults to a new Messenger. + * @param options.accounts - List of accounts to use. + * @param options.config - Provider config. + * @param options.capabilities - The Snap keyring capabilities to expose via `SnapAccountService:getCapabilities`. + * @returns An object containing the controller instance and the messenger. + */ +function setup({ + messenger = getRootMessenger(), + accounts = [], + config, + capabilities = { scopes: [] }, +}: { + messenger?: RootMessenger; + accounts?: InternalAccount[]; + config?: SnapAccountProviderConfig; + capabilities?: KeyringCapabilities; +} = {}): { + provider: AccountProviderWrapper; + messenger: RootMessenger; + keyring: MockSolanaKeyring; + mocks: { + handleRequest: jest.Mock; + keyring: { + createAccounts: jest.Mock; + }; + trace: jest.Mock; + }; +} { + const keyring = new MockSolanaKeyring(accounts); + + messenger.registerActionHandler( + 'AccountsController:getAccounts', + () => accounts, + ); + + messenger.registerActionHandler( + 'SnapController:getState', + () => ({ isReady: true }) as SnapControllerState, + ); + + messenger.registerActionHandler( + 'SnapAccountService:getCapabilities', + async () => capabilities, + ); + + messenger.registerActionHandler( + 'AccountsController:listMultichainAccounts', + () => accounts, + ); + + const mockGetAccount = jest.fn().mockImplementation((id) => { + return keyring.accounts.find((account) => account.id === id); + }); + messenger.registerActionHandler( + 'AccountsController:getAccount', + mockGetAccount, + ); + + const mockHandleRequest = jest + .fn() + .mockImplementation((address: string) => + keyring.accounts.find((account) => account.address === address), + ); + + const mockTrace = jest.fn().mockImplementation(async (_request, fn) => { + return await fn(); + }); + + messenger.registerActionHandler( + 'SnapController:handleRequest', + mockHandleRequest, + ); + + messenger.registerActionHandler( + 'KeyringController:withKeyringV2', + async (_, operation) => + operation({ + keyring, + metadata: keyring.metadata, + }), + ); + + const multichainMessenger = getMultichainAccountServiceMessenger(messenger); + const solProvider = new MockSolAccountProvider( + multichainMessenger, + config, + mockTrace, + ); + const accountIds = accounts.map((account) => account.id); + solProvider.init(accountIds); + const provider = new AccountProviderWrapper(multichainMessenger, solProvider); + + return { + provider, + messenger, + keyring, + mocks: { + handleRequest: mockHandleRequest, + keyring: { + createAccounts: keyring.createAccounts, + }, + trace: mockTrace, + }, + }; +} + +describe('SolAccountProvider', () => { + it('getName returns Solana', () => { + const { provider } = setup({ accounts: [] }); + expect(provider.getName()).toBe('Solana'); + }); + + it('gets accounts', () => { + const accounts = [MOCK_SOL_ACCOUNT_1]; + const { provider } = setup({ + accounts, + }); + + expect(provider.getAccounts()).toStrictEqual(accounts); + }); + + it('gets a specific account', () => { + const account = MOCK_SOL_ACCOUNT_1; + const { provider } = setup({ + accounts: [account], + }); + + expect(provider.getAccount(account.id)).toStrictEqual(account); + }); + + it('throws if account does not exist', () => { + const account = MOCK_SOL_ACCOUNT_1; + const { provider } = setup({ + accounts: [account], + }); + + const unknownAccount = MOCK_HD_ACCOUNT_1; + expect(() => provider.getAccount(unknownAccount.id)).toThrow( + `Unable to find account: ${unknownAccount.id}`, + ); + }); + + it('returns true if an account is compatible', () => { + const account = MOCK_SOL_ACCOUNT_1; + const { provider } = setup({ + accounts: [account], + }); + expect(provider.isAccountCompatible(account)).toBe(true); + }); + + it('returns false if an account is not compatible', () => { + const account = MOCK_HD_ACCOUNT_1; + const { provider } = setup({ + accounts: [account], + }); + expect(provider.isAccountCompatible(account)).toBe(false); + }); + + it('discover accounts at a new group index creates an account (v1 discovery flow)', async () => { + const { provider, mocks } = setup({ accounts: [] }); + + // Simulate one discovered account at the requested index via v1 client.discoverAccounts. + mocks.handleRequest.mockReturnValue([MOCK_SOL_DISCOVERED_ACCOUNT_1]); + + const discovered = await provider.discoverAccounts({ + entropySource: MOCK_HD_KEYRING_1.metadata.id, + groupIndex: 0, + }); + + expect(discovered).toHaveLength(1); + // After v1 discovery, account creation goes through the v2 batched path. + expect(mocks.keyring.createAccounts).toHaveBeenCalled(); + // Provider should now expose one account (newly created) + expect(provider.getAccounts()).toHaveLength(1); + }); + + describe('v2 - batched', () => { + it('creates accounts', async () => { + const accounts = [MOCK_SOL_ACCOUNT_1]; + const { provider, mocks } = setup({ + accounts, + capabilities: SOL_V2_CAPABILITIES, + }); + + const newGroupIndex = accounts.length; // Group-index are 0-based. + const newAccounts = await provider.createAccounts({ + type: AccountCreationType.Bip44DeriveIndex, + entropySource: MOCK_HD_KEYRING_1.metadata.id, + groupIndex: newGroupIndex, + }); + expect(newAccounts).toHaveLength(1); + // Batch endpoint must be called, NOT the singular one. + expect(mocks.keyring.createAccounts).toHaveBeenCalledWith({ + type: AccountCreationType.Bip44DeriveIndex, + entropySource: MOCK_HD_KEYRING_1.metadata.id, + groupIndex: newGroupIndex, + }); + }); + + it('does not re-create accounts (idempotent)', async () => { + const accounts = [MOCK_SOL_ACCOUNT_1]; + const { provider } = setup({ + accounts, + capabilities: SOL_V2_CAPABILITIES, + }); + + const newAccounts = await provider.createAccounts({ + entropySource: MOCK_HD_KEYRING_1.metadata.id, + groupIndex: 0, + type: AccountCreationType.Bip44DeriveIndex, + }); + expect(newAccounts).toHaveLength(1); + expect(newAccounts[0]).toStrictEqual(MOCK_SOL_ACCOUNT_1); + }); + + it('creates multiple accounts using Bip44DeriveIndexRange', async () => { + const accounts = [MOCK_SOL_ACCOUNT_1]; + const { provider, mocks } = setup({ + accounts, + capabilities: SOL_V2_CAPABILITIES, + }); + + const from = 1; + const newAccounts = await provider.createAccounts({ + type: AccountCreationType.Bip44DeriveIndexRange, + entropySource: MOCK_HD_KEYRING_1.metadata.id, + range: { from, to: 3 }, + }); + + expect(newAccounts).toHaveLength(3); + // Single batch call, NOT three individual calls. + expect(mocks.keyring.createAccounts).toHaveBeenCalledTimes(1); + + // Verify each account has the correct group index. + for (const [index, account] of newAccounts.entries()) { + expect(isBip44Account(account)).toBe(true); + expect(account.options.entropy.groupIndex).toBe(from + index); + } + }); + + it('creates accounts with range starting from 0', async () => { + const { provider, mocks } = setup({ + accounts: [], + capabilities: SOL_V2_CAPABILITIES, + }); + + const newAccounts = await provider.createAccounts({ + type: AccountCreationType.Bip44DeriveIndexRange, + entropySource: MOCK_HD_KEYRING_1.metadata.id, + range: { from: 0, to: 2 }, + }); + + expect(newAccounts).toHaveLength(3); + expect(mocks.keyring.createAccounts).toHaveBeenCalledTimes(1); + }); + + it('creates a single account when range from equals to', async () => { + const { provider, mocks } = setup({ + accounts: [], + capabilities: SOL_V2_CAPABILITIES, + }); + + const newAccounts = await provider.createAccounts({ + type: AccountCreationType.Bip44DeriveIndexRange, + entropySource: MOCK_HD_KEYRING_1.metadata.id, + range: { from: 5, to: 5 }, + }); + + expect(newAccounts).toHaveLength(1); + expect(mocks.keyring.createAccounts).toHaveBeenCalledTimes(1); + expect( + isBip44Account(newAccounts[0]) && + newAccounts[0].options.entropy.groupIndex, + ).toBe(5); + }); + + it('throws if the account creation process takes too long', async () => { + const { provider, mocks } = setup({ + accounts: [], + capabilities: SOL_V2_CAPABILITIES, + }); + + mocks.keyring.createAccounts.mockImplementation( + () => + new Promise((resolve) => { + setTimeout(() => resolve([MOCK_SOL_ACCOUNT_1]), 4000); + }), + ); + + await expect( + provider.createAccounts({ + type: AccountCreationType.Bip44DeriveIndex, + entropySource: MOCK_HD_KEYRING_1.metadata.id, + groupIndex: 0, + }), + ).rejects.toThrow('Timed out'); + }); + }); + + it('throws an error when type is not "bip44:derive-index"', async () => { + const { provider } = setup(); + + await expect( + provider.createAccounts({ + // @ts-expect-error Testing invalid type handling. + type: 'unsupported-type', + entropySource: MOCK_HD_KEYRING_1.metadata.id, + groupIndex: 0, + }), + ).rejects.toThrow( + 'Unsupported create account option type: unsupported-type', + ); + }); + + it('returns existing account if it already exists at index', async () => { + const { provider, mocks } = setup({ + accounts: [MOCK_SOL_ACCOUNT_1], + }); + + // Simulate one discovered account — should resolve to the existing one + mocks.handleRequest.mockReturnValue([MOCK_SOL_DISCOVERED_ACCOUNT_1]); + + const discovered = await provider.discoverAccounts({ + entropySource: MOCK_HD_KEYRING_1.metadata.id, + groupIndex: 0, + }); + + expect(discovered).toStrictEqual([MOCK_SOL_ACCOUNT_1]); + }); + + it('does not return any accounts if no account is discovered', async () => { + const { provider, mocks } = setup({ + accounts: [], + }); + + mocks.handleRequest.mockReturnValue([]); + + const discovered = await provider.discoverAccounts({ + entropySource: MOCK_HD_KEYRING_1.metadata.id, + groupIndex: 0, + }); + + expect(discovered).toStrictEqual([]); + }); + + it('returns no accounts when a v2 Snap does not support bip44:discover', async () => { + const { provider, mocks } = setup({ + accounts: [], + capabilities: { + scopes: [SolScope.Mainnet], + bip44: { deriveIndex: true, deriveIndexRange: true }, + }, + }); + + const discovered = await provider.discoverAccounts({ + entropySource: MOCK_HD_KEYRING_1.metadata.id, + groupIndex: 0, + }); + + expect(discovered).toStrictEqual([]); + expect(mocks.keyring.createAccounts).not.toHaveBeenCalled(); + }); + + it('does not run discovery if disabled', async () => { + const { provider } = setup({ + accounts: [MOCK_SOL_ACCOUNT_1], + config: asConfig({ + discovery: { + enabled: false, + }, + }), + }); + + expect( + await provider.discoverAccounts({ + entropySource: MOCK_HD_KEYRING_1.metadata.id, + groupIndex: 0, + }), + ).toStrictEqual([]); + }); + + describe('trace functionality', () => { + it('calls trace callback during account discovery', async () => { + const { messenger, mocks } = setup({ + accounts: [], + }); + + mocks.handleRequest.mockReturnValue([MOCK_SOL_DISCOVERED_ACCOUNT_1]); + + const multichainMessenger = + getMultichainAccountServiceMessenger(messenger); + const solProvider = new MockSolAccountProvider( + multichainMessenger, + undefined, + mocks.trace, + ); + const provider = new AccountProviderWrapper( + multichainMessenger, + solProvider, + ); + + const discovered = await provider.discoverAccounts({ + entropySource: MOCK_HD_KEYRING_1.metadata.id, + groupIndex: 0, + }); + + expect(discovered).toHaveLength(1); + expect(mocks.trace).toHaveBeenCalledWith( + expect.objectContaining({ + name: TraceName.SnapDiscoverAccounts, + data: { provider: SOL_ACCOUNT_PROVIDER_NAME }, + }), + expect.any(Function), + ); + }); + + it('uses fallback trace when no trace callback is provided', async () => { + const { messenger, mocks } = setup({ accounts: [] }); + + mocks.handleRequest.mockReturnValue([MOCK_SOL_DISCOVERED_ACCOUNT_1]); + + const multichainMessenger = + getMultichainAccountServiceMessenger(messenger); + // No trace callback (defaults to `traceFallback`). + const solProvider = new MockSolAccountProvider(multichainMessenger); + const provider = new AccountProviderWrapper( + multichainMessenger, + solProvider, + ); + + const discovered = await provider.discoverAccounts({ + entropySource: MOCK_HD_KEYRING_1.metadata.id, + groupIndex: 0, + }); + + expect(discovered).toHaveLength(1); + }); + + it('trace callback is called even when discovery returns empty results', async () => { + const { messenger, mocks } = setup({ + accounts: [], + }); + + mocks.handleRequest.mockReturnValue([]); + + const multichainMessenger = + getMultichainAccountServiceMessenger(messenger); + const solProvider = new MockSolAccountProvider( + multichainMessenger, + undefined, + mocks.trace, + ); + const provider = new AccountProviderWrapper( + multichainMessenger, + solProvider, + ); + + const discovered = await provider.discoverAccounts({ + entropySource: MOCK_HD_KEYRING_1.metadata.id, + groupIndex: 0, + }); + + expect(discovered).toStrictEqual([]); + expect(mocks.trace).toHaveBeenCalledTimes(1); + }); + + it('trace callback receives error when discovery fails', async () => { + const mockError = new Error('Discovery failed'); + const { messenger, mocks } = setup({ + accounts: [], + }); + + mocks.handleRequest.mockRejectedValue(mockError); + + const multichainMessenger = + getMultichainAccountServiceMessenger(messenger); + const solProvider = new MockSolAccountProvider( + multichainMessenger, + undefined, + mocks.trace, + ); + const provider = new AccountProviderWrapper( + multichainMessenger, + solProvider, + ); + + await expect( + provider.discoverAccounts({ + entropySource: MOCK_HD_KEYRING_1.metadata.id, + groupIndex: 0, + }), + ).rejects.toThrow(mockError); + + expect(mocks.trace).toHaveBeenCalledTimes(1); + }); + }); + + describe('isDisabled', () => { + it('returns false when the provider is enabled (default)', () => { + const { provider } = setup(); + expect(provider.isDisabled()).toBe(false); + }); + + it('returns true after setEnabled(false)', () => { + const { provider } = setup(); + provider.setEnabled(false); + expect(provider.isDisabled()).toBe(true); + }); + + it('returns false after re-enabling', () => { + const { provider } = setup(); + provider.setEnabled(false); + provider.setEnabled(true); + expect(provider.isDisabled()).toBe(false); + }); + }); +}); diff --git a/packages/multichain-account-service/src/providers/SolAccountProvider.ts b/packages/multichain-account-service/src/providers/SolAccountProvider.ts new file mode 100644 index 00000000000..bb9a8327b4d --- /dev/null +++ b/packages/multichain-account-service/src/providers/SolAccountProvider.ts @@ -0,0 +1,91 @@ +import { assertIsBip44Account } from '@metamask/account-api'; +import type { Bip44Account } from '@metamask/account-api'; +import type { TraceCallback } from '@metamask/controller-utils'; +import { + KeyringAccountEntropyTypeOption, + SolAccountType, + SolScope, +} from '@metamask/keyring-api'; +import type { EntropySourceId, KeyringAccount } from '@metamask/keyring-api'; +import { KeyringTypes } from '@metamask/keyring-controller'; +import type { InternalAccount } from '@metamask/keyring-internal-api'; +import type { SnapId } from '@metamask/snaps-sdk'; +import type { CaipChainId } from '@metamask/utils'; + +import { traceFallback } from '../analytics/index.js'; +import type { MultichainAccountServiceMessenger } from '../types.js'; +import { SnapAccountProvider } from './SnapAccountProvider.js'; +import type { SnapAccountProviderConfig } from './SnapAccountProvider.js'; + +export type SolAccountProviderConfig = SnapAccountProviderConfig; + +export const SOL_ACCOUNT_PROVIDER_NAME = 'Solana'; + +export const SOL_ACCOUNT_PROVIDER_DEFAULT_CONFIG: SnapAccountProviderConfig = { + maxConcurrency: 3, + discovery: { + enabled: true, + timeoutMs: 2000, + maxAttempts: 3, + backOffMs: 1000, + }, + createAccounts: { + timeoutMs: 3000, + }, + resyncAccounts: { + autoRemoveExtraSnapAccounts: true, + }, +}; + +export class SolAccountProvider extends SnapAccountProvider { + static NAME = SOL_ACCOUNT_PROVIDER_NAME; + + static SOLANA_SNAP_ID = 'npm:@metamask/solana-wallet-snap' as SnapId; + + // TODO: Remove once the Snap is fully v2 — discovery is then driven by the + // Snap's own supported scopes via `createAccounts({ bip44:discover })`. + protected readonly v1DiscoveryScopes: CaipChainId[] = [SolScope.Mainnet]; + + constructor( + messenger: MultichainAccountServiceMessenger, + config: SolAccountProviderConfig = SOL_ACCOUNT_PROVIDER_DEFAULT_CONFIG, + trace: TraceCallback = traceFallback, + ) { + super(SolAccountProvider.SOLANA_SNAP_ID, messenger, config, trace); + } + + getName(): string { + return SolAccountProvider.NAME; + } + + isAccountCompatible(account: Bip44Account): boolean { + return ( + account.type === SolAccountType.DataAccount && + account.metadata.keyring.type === (KeyringTypes.snap as string) + ); + } + + #getDerivationPath(groupIndex: number): string { + return `m/44'/501'/${groupIndex}'/0'`; + } + + protected override toBip44Account( + account: KeyringAccount, + { + entropySource, + groupIndex, + }: { entropySource: EntropySourceId; groupIndex: number }, + ): Bip44Account { + // Ensure entropy is present before type assertion validation + account.options.entropy = { + type: KeyringAccountEntropyTypeOption.Mnemonic, + id: entropySource, + groupIndex, + derivationPath: this.#getDerivationPath(groupIndex), + }; + + assertIsBip44Account(account); + + return account; + } +} diff --git a/packages/multichain-account-service/src/providers/TrxAccountProvider.test.ts b/packages/multichain-account-service/src/providers/TrxAccountProvider.test.ts new file mode 100644 index 00000000000..7fbe7e8f06d --- /dev/null +++ b/packages/multichain-account-service/src/providers/TrxAccountProvider.test.ts @@ -0,0 +1,604 @@ +import { isBip44Account } from '@metamask/account-api'; +import { AccountCreationType, TrxScope } from '@metamask/keyring-api'; +import type { KeyringCapabilities } from '@metamask/keyring-api/v2'; +import type { KeyringMetadata } from '@metamask/keyring-controller'; +import type { InternalAccount } from '@metamask/keyring-internal-api'; +import { SnapControllerState } from '@metamask/snaps-controllers'; +import deepmerge from 'deepmerge'; + +import { TraceName } from '../analytics/traces.js'; +import { + getMultichainAccountServiceMessenger, + getRootMessenger, + MOCK_HD_ACCOUNT_1, + MOCK_HD_KEYRING_1, + MOCK_TRX_ACCOUNT_1, + MOCK_TRX_DISCOVERED_ACCOUNT_1, + MockAccountBuilder, + toGroupIndexRangeArray, +} from '../tests/index.js'; +import type { RootMessenger, DeepPartial } from '../tests/index.js'; +import { AccountProviderWrapper } from './AccountProviderWrapper.js'; +import type { SnapAccountProviderConfig } from './SnapAccountProvider.js'; +import { + TRX_ACCOUNT_PROVIDER_DEFAULT_CONFIG, + TRX_ACCOUNT_PROVIDER_NAME, + TrxAccountProvider, +} from './TrxAccountProvider.js'; + +function asConfig( + partial: DeepPartial, +): SnapAccountProviderConfig { + return deepmerge( + TRX_ACCOUNT_PROVIDER_DEFAULT_CONFIG, + partial, + ) as SnapAccountProviderConfig; +} + +/** + * v2 capabilities as declared by a fully v2-compliant Tron Snap manifest. + * Drives the batched `createAccounts` flow and the v2 discovery path. + */ +const TRX_V2_CAPABILITIES: KeyringCapabilities = { + scopes: [TrxScope.Mainnet], + bip44: { + deriveIndex: true, + deriveIndexRange: true, + discover: true, + }, +}; + +class MockTronKeyring { + readonly type = 'MockTronKeyring'; + + readonly metadata: KeyringMetadata = { + id: 'mock-tron-keyring-id', + name: '', + }; + + readonly accounts: InternalAccount[]; + + constructor(accounts: InternalAccount[]) { + this.accounts = accounts; + } + + createAccounts = jest.fn().mockImplementation((options) => { + const groupIndices = + options.type === 'bip44:derive-index' + ? [options.groupIndex] + : toGroupIndexRangeArray(options.range); + + return groupIndices.map((groupIndex) => { + const found = this.accounts.find( + (account) => + isBip44Account(account) && + account.options.entropy.groupIndex === groupIndex, + ); + + if (found) { + return found; // Idempotent. + } + + const account = MockAccountBuilder.from(MOCK_TRX_ACCOUNT_1) + .withUuid() + .withAddressSuffix(`${groupIndex}`) + .withGroupIndex(groupIndex) + .get(); + this.accounts.push(account); + return account; + }); + }); + + // Add discoverAccounts method to match the provider's usage + discoverAccounts = jest.fn().mockResolvedValue([]); + + deleteAccount = jest.fn().mockResolvedValue(undefined); +} + +class MockTrxAccountProvider extends TrxAccountProvider { + override async ensureReady(): Promise { + // Override to avoid waiting during tests. + } +} + +/** + * Sets up a TrxAccountProvider for testing. + * + * @param options - Configuration options for setup. + * @param options.messenger - An optional messenger instance to use. Defaults to a new Messenger. + * @param options.accounts - List of accounts to use. + * @param options.config - Provider config. + * @param options.capabilities - The Snap keyring capabilities to expose via `SnapAccountService:getCapabilities`. + * @returns An object containing the controller instance and the messenger. + */ +function setup({ + messenger = getRootMessenger(), + accounts = [], + config, + capabilities = { scopes: [] }, +}: { + messenger?: RootMessenger; + accounts?: InternalAccount[]; + config?: SnapAccountProviderConfig; + capabilities?: KeyringCapabilities; +} = {}): { + provider: AccountProviderWrapper; + messenger: RootMessenger; + keyring: MockTronKeyring; + mocks: { + handleRequest: jest.Mock; + keyring: { + createAccounts: jest.Mock; + discoverAccounts: jest.Mock; + }; + trace: jest.Mock; + }; +} { + const keyring = new MockTronKeyring(accounts); + + messenger.registerActionHandler( + 'AccountsController:getAccounts', + () => accounts, + ); + + messenger.registerActionHandler( + 'SnapController:getState', + () => ({ isReady: true }) as SnapControllerState, + ); + + messenger.registerActionHandler( + 'SnapAccountService:getCapabilities', + async () => capabilities, + ); + + messenger.registerActionHandler( + 'AccountsController:listMultichainAccounts', + () => accounts, + ); + + const mockGetAccount = jest.fn().mockImplementation((id) => { + return keyring.accounts.find((account) => account.id === id); + }); + messenger.registerActionHandler( + 'AccountsController:getAccount', + mockGetAccount, + ); + + const mockHandleRequest = jest.fn().mockImplementation((request) => { + // Handle KeyringClient discoverAccounts calls + if (request.request?.method === 'keyring_discoverAccounts') { + // Return the keyring's discoverAccounts result directly + return keyring.discoverAccounts(); + } + // Handle other requests (fallback for legacy compatibility) + return keyring.accounts.find( + (account) => account.address === request.address, + ); + }); + messenger.registerActionHandler( + 'SnapController:handleRequest', + mockHandleRequest, + ); + + messenger.registerActionHandler( + 'KeyringController:withKeyringV2', + async (_, operation) => + operation({ + keyring, + metadata: keyring.metadata, + }), + ); + + const mockTrace = jest.fn().mockImplementation(async (_request, fn) => { + return await fn(); + }); + + const multichainMessenger = getMultichainAccountServiceMessenger(messenger); + const trxProvider = new MockTrxAccountProvider( + multichainMessenger, + config, + mockTrace, + ); + const accountIds = accounts.map((account) => account.id); + trxProvider.init(accountIds); + const provider = new AccountProviderWrapper(multichainMessenger, trxProvider); + + return { + provider, + messenger, + keyring, + mocks: { + handleRequest: mockHandleRequest, + keyring: { + createAccounts: keyring.createAccounts, + discoverAccounts: keyring.discoverAccounts, + }, + trace: mockTrace, + }, + }; +} + +describe('TrxAccountProvider', () => { + it('getName returns Tron', () => { + const { provider } = setup({ accounts: [] }); + expect(provider.getName()).toBe('Tron'); + }); + + it('gets accounts', () => { + const accounts = [MOCK_TRX_ACCOUNT_1]; + const { provider } = setup({ + accounts, + }); + + expect(provider.getAccounts()).toStrictEqual(accounts); + }); + + it('gets a specific account', () => { + const account = MOCK_TRX_ACCOUNT_1; + const { provider } = setup({ + accounts: [account], + }); + + expect(provider.getAccount(account.id)).toStrictEqual(account); + }); + + it('throws if account does not exist', () => { + const account = MOCK_TRX_ACCOUNT_1; + const { provider } = setup({ + accounts: [account], + }); + + const unknownAccount = MOCK_HD_ACCOUNT_1; + expect(() => provider.getAccount(unknownAccount.id)).toThrow( + `Unable to find account: ${unknownAccount.id}`, + ); + }); + + it('returns true if an account is compatible', () => { + const account = MOCK_TRX_ACCOUNT_1; + const { provider } = setup({ + accounts: [account], + }); + expect(provider.isAccountCompatible(account)).toBe(true); + }); + + it('returns false if an account is not compatible', () => { + const account = MOCK_HD_ACCOUNT_1; + const { provider } = setup({ + accounts: [account], + }); + expect(provider.isAccountCompatible(account)).toBe(false); + }); + + it('discover accounts at a new group index creates an account (v1 discovery flow)', async () => { + const { provider, mocks } = setup({ accounts: [] }); + + // Simulate one discovered account at the requested index via v1 client.discoverAccounts. + mocks.keyring.discoverAccounts.mockResolvedValue([ + MOCK_TRX_DISCOVERED_ACCOUNT_1, + ]); + + const discovered = await provider.discoverAccounts({ + entropySource: MOCK_HD_KEYRING_1.metadata.id, + groupIndex: 0, + }); + + expect(discovered).toHaveLength(1); + // After v1 discovery, account creation goes through the v2 batched path. + expect(mocks.keyring.createAccounts).toHaveBeenCalled(); + // Provider should now expose one account (newly created) + expect(provider.getAccounts()).toHaveLength(1); + }); + + describe('v2 - batched', () => { + it('creates accounts', async () => { + const accounts = [MOCK_TRX_ACCOUNT_1]; + const { provider, mocks } = setup({ + accounts, + capabilities: TRX_V2_CAPABILITIES, + }); + + const newGroupIndex = accounts.length; // Group-index are 0-based. + const newAccounts = await provider.createAccounts({ + type: AccountCreationType.Bip44DeriveIndex, + entropySource: MOCK_HD_KEYRING_1.metadata.id, + groupIndex: newGroupIndex, + }); + expect(newAccounts).toHaveLength(1); + // Batch endpoint must be called, NOT the singular one. + expect(mocks.keyring.createAccounts).toHaveBeenCalledWith({ + type: AccountCreationType.Bip44DeriveIndex, + entropySource: MOCK_HD_KEYRING_1.metadata.id, + groupIndex: newGroupIndex, + }); + }); + + it('does not re-create accounts (idempotent)', async () => { + const accounts = [MOCK_TRX_ACCOUNT_1]; + const { provider } = setup({ + accounts, + capabilities: TRX_V2_CAPABILITIES, + }); + + const newAccounts = await provider.createAccounts({ + entropySource: MOCK_HD_KEYRING_1.metadata.id, + groupIndex: 0, + type: AccountCreationType.Bip44DeriveIndex, + }); + expect(newAccounts).toHaveLength(1); + expect(newAccounts[0]).toStrictEqual(MOCK_TRX_ACCOUNT_1); + }); + + it('creates multiple accounts using Bip44DeriveIndexRange', async () => { + const accounts = [MOCK_TRX_ACCOUNT_1]; + const { provider, mocks } = setup({ + accounts, + capabilities: TRX_V2_CAPABILITIES, + }); + + const from = 1; + const newAccounts = await provider.createAccounts({ + type: AccountCreationType.Bip44DeriveIndexRange, + entropySource: MOCK_HD_KEYRING_1.metadata.id, + range: { from, to: 3 }, + }); + + expect(newAccounts).toHaveLength(3); + // Single batch call, NOT three individual calls. + expect(mocks.keyring.createAccounts).toHaveBeenCalledTimes(1); + + // Verify each account has the correct group index. + for (const [index, account] of newAccounts.entries()) { + expect(isBip44Account(account)).toBe(true); + expect(account.options.entropy.groupIndex).toBe(from + index); + } + }); + + it('creates accounts with range starting from 0', async () => { + const { provider, mocks } = setup({ + accounts: [], + capabilities: TRX_V2_CAPABILITIES, + }); + + const newAccounts = await provider.createAccounts({ + type: AccountCreationType.Bip44DeriveIndexRange, + entropySource: MOCK_HD_KEYRING_1.metadata.id, + range: { from: 0, to: 2 }, + }); + + expect(newAccounts).toHaveLength(3); + expect(mocks.keyring.createAccounts).toHaveBeenCalledTimes(1); + }); + + it('creates a single account when range from equals to', async () => { + const { provider, mocks } = setup({ + accounts: [], + capabilities: TRX_V2_CAPABILITIES, + }); + + const newAccounts = await provider.createAccounts({ + type: AccountCreationType.Bip44DeriveIndexRange, + entropySource: MOCK_HD_KEYRING_1.metadata.id, + range: { from: 5, to: 5 }, + }); + + expect(newAccounts).toHaveLength(1); + expect(mocks.keyring.createAccounts).toHaveBeenCalledTimes(1); + expect( + isBip44Account(newAccounts[0]) && + newAccounts[0].options.entropy.groupIndex, + ).toBe(5); + }); + + it('throws if the account creation process takes too long', async () => { + const { provider, mocks } = setup({ + accounts: [], + capabilities: TRX_V2_CAPABILITIES, + }); + + mocks.keyring.createAccounts.mockImplementation( + () => + new Promise((resolve) => { + setTimeout(() => resolve([MOCK_TRX_ACCOUNT_1]), 4000); + }), + ); + + await expect( + provider.createAccounts({ + type: AccountCreationType.Bip44DeriveIndex, + entropySource: MOCK_HD_KEYRING_1.metadata.id, + groupIndex: 0, + }), + ).rejects.toThrow('Timed out'); + }); + }); + + it('throws an error when type is not "bip44:derive-index"', async () => { + const { provider } = setup(); + + await expect( + provider.createAccounts({ + // @ts-expect-error Testing invalid type handling. + type: 'unsupported-type', + entropySource: MOCK_HD_KEYRING_1.metadata.id, + groupIndex: 0, + }), + ).rejects.toThrow( + 'Unsupported create account option type: unsupported-type', + ); + }); + + it('returns existing account if it already exists at index', async () => { + const { provider, mocks } = setup({ + accounts: [MOCK_TRX_ACCOUNT_1], + }); + + // Simulate one discovered account — should resolve to the existing one + mocks.keyring.discoverAccounts.mockResolvedValue([ + MOCK_TRX_DISCOVERED_ACCOUNT_1, + ]); + + const discovered = await provider.discoverAccounts({ + entropySource: MOCK_HD_KEYRING_1.metadata.id, + groupIndex: 0, + }); + + expect(discovered).toStrictEqual([MOCK_TRX_ACCOUNT_1]); + }); + + it('does not return any accounts if no account is discovered', async () => { + const { provider, mocks } = setup({ + accounts: [], + }); + + mocks.keyring.discoverAccounts.mockResolvedValue([]); + + const discovered = await provider.discoverAccounts({ + entropySource: MOCK_HD_KEYRING_1.metadata.id, + groupIndex: 0, + }); + + expect(discovered).toStrictEqual([]); + }); + + it('returns no accounts when a v2 Snap does not support bip44:discover', async () => { + const { provider, mocks } = setup({ + accounts: [], + capabilities: { + scopes: [TrxScope.Mainnet], + bip44: { deriveIndex: true, deriveIndexRange: true }, + }, + }); + + const discovered = await provider.discoverAccounts({ + entropySource: MOCK_HD_KEYRING_1.metadata.id, + groupIndex: 0, + }); + + expect(discovered).toStrictEqual([]); + expect(mocks.keyring.createAccounts).not.toHaveBeenCalled(); + }); + + it('does not run discovery if disabled', async () => { + const { provider } = setup({ + accounts: [MOCK_TRX_ACCOUNT_1], + config: asConfig({ + discovery: { + enabled: false, + }, + }), + }); + + expect( + await provider.discoverAccounts({ + entropySource: MOCK_HD_KEYRING_1.metadata.id, + groupIndex: 0, + }), + ).toStrictEqual([]); + }); + + describe('trace functionality', () => { + it('calls trace callback during account discovery', async () => { + const { provider, mocks } = setup({ + accounts: [], + }); + + // Simulate one discovered account at the requested index. + mocks.keyring.discoverAccounts.mockResolvedValue([ + MOCK_TRX_DISCOVERED_ACCOUNT_1, + ]); + + const discovered = await provider.discoverAccounts({ + entropySource: MOCK_HD_KEYRING_1.metadata.id, + groupIndex: 0, + }); + + expect(discovered).toHaveLength(1); + expect(mocks.trace).toHaveBeenCalledWith( + expect.objectContaining({ + name: TraceName.SnapDiscoverAccounts, + data: { provider: TRX_ACCOUNT_PROVIDER_NAME }, + }), + expect.any(Function), + ); + }); + + it('uses fallback trace when no trace callback is provided', async () => { + const { messenger, mocks } = setup({ accounts: [] }); + + mocks.keyring.discoverAccounts.mockResolvedValue([ + MOCK_TRX_DISCOVERED_ACCOUNT_1, + ]); + + const multichainMessenger = + getMultichainAccountServiceMessenger(messenger); + // No trace callback (defaults to `traceFallback`). + const trxProvider = new MockTrxAccountProvider(multichainMessenger); + const provider = new AccountProviderWrapper( + multichainMessenger, + trxProvider, + ); + + const discovered = await provider.discoverAccounts({ + entropySource: MOCK_HD_KEYRING_1.metadata.id, + groupIndex: 0, + }); + + expect(discovered).toHaveLength(1); + }); + + it('trace callback is called even when discovery returns empty results', async () => { + const { provider, mocks } = setup({ + accounts: [], + }); + + mocks.keyring.discoverAccounts.mockResolvedValue([]); + + const discovered = await provider.discoverAccounts({ + entropySource: MOCK_HD_KEYRING_1.metadata.id, + groupIndex: 0, + }); + + expect(discovered).toStrictEqual([]); + expect(mocks.trace).toHaveBeenCalledTimes(1); + }); + + it('trace callback receives error when discovery fails', async () => { + const mockError = new Error('Discovery failed'); + const { provider, mocks } = setup({ + accounts: [], + }); + + mocks.keyring.discoverAccounts.mockRejectedValue(mockError); + + await expect( + provider.discoverAccounts({ + entropySource: MOCK_HD_KEYRING_1.metadata.id, + groupIndex: 0, + }), + ).rejects.toThrow(mockError); + + expect(mocks.trace).toHaveBeenCalledTimes(1); + }); + }); + + describe('isDisabled', () => { + it('returns false when the provider is enabled (default)', () => { + const { provider } = setup(); + expect(provider.isDisabled()).toBe(false); + }); + + it('returns true after setEnabled(false)', () => { + const { provider } = setup(); + provider.setEnabled(false); + expect(provider.isDisabled()).toBe(true); + }); + + it('returns false after re-enabling', () => { + const { provider } = setup(); + provider.setEnabled(false); + provider.setEnabled(true); + expect(provider.isDisabled()).toBe(false); + }); + }); +}); diff --git a/packages/multichain-account-service/src/providers/TrxAccountProvider.ts b/packages/multichain-account-service/src/providers/TrxAccountProvider.ts new file mode 100644 index 00000000000..9b119f22d6f --- /dev/null +++ b/packages/multichain-account-service/src/providers/TrxAccountProvider.ts @@ -0,0 +1,61 @@ +import type { Bip44Account } from '@metamask/account-api'; +import type { TraceCallback } from '@metamask/controller-utils'; +import { TrxAccountType, TrxScope } from '@metamask/keyring-api'; +import { KeyringTypes } from '@metamask/keyring-controller'; +import type { InternalAccount } from '@metamask/keyring-internal-api'; +import type { SnapId } from '@metamask/snaps-sdk'; +import type { CaipChainId } from '@metamask/utils'; + +import { traceFallback } from '../analytics/index.js'; +import type { MultichainAccountServiceMessenger } from '../types.js'; +import { SnapAccountProvider } from './SnapAccountProvider.js'; +import type { SnapAccountProviderConfig } from './SnapAccountProvider.js'; + +export type TrxAccountProviderConfig = SnapAccountProviderConfig; + +export const TRX_ACCOUNT_PROVIDER_NAME = 'Tron'; + +export const TRX_ACCOUNT_PROVIDER_DEFAULT_CONFIG: TrxAccountProviderConfig = { + maxConcurrency: 3, + discovery: { + enabled: true, + timeoutMs: 2000, + maxAttempts: 3, + backOffMs: 1000, + }, + createAccounts: { + timeoutMs: 3000, + }, + resyncAccounts: { + autoRemoveExtraSnapAccounts: true, + }, +}; + +export class TrxAccountProvider extends SnapAccountProvider { + static NAME = TRX_ACCOUNT_PROVIDER_NAME; + + static TRX_SNAP_ID = 'npm:@metamask/tron-wallet-snap' as SnapId; + + // TODO: Remove once the Snap is fully v2 — discovery is then driven by the + // Snap's own supported scopes via `createAccounts({ bip44:discover })`. + protected readonly v1DiscoveryScopes: CaipChainId[] = [TrxScope.Mainnet]; + + constructor( + messenger: MultichainAccountServiceMessenger, + config: TrxAccountProviderConfig = TRX_ACCOUNT_PROVIDER_DEFAULT_CONFIG, + trace: TraceCallback = traceFallback, + ) { + super(TrxAccountProvider.TRX_SNAP_ID, messenger, config, trace); + } + + getName(): string { + return TrxAccountProvider.NAME; + } + + isAccountCompatible(account: Bip44Account): boolean { + return ( + account.type === TrxAccountType.Eoa && + account.metadata.keyring.type === (KeyringTypes.snap as string) + ); + } +} diff --git a/packages/multichain-account-service/src/providers/XlmAccountProvider.test.ts b/packages/multichain-account-service/src/providers/XlmAccountProvider.test.ts new file mode 100644 index 00000000000..1475cabd05c --- /dev/null +++ b/packages/multichain-account-service/src/providers/XlmAccountProvider.test.ts @@ -0,0 +1,354 @@ +import { isBip44Account } from '@metamask/account-api'; +import type { SnapKeyring } from '@metamask/eth-snap-keyring'; +import { AccountCreationType, XlmScope } from '@metamask/keyring-api'; +import type { KeyringCapabilities } from '@metamask/keyring-api/v2'; +import type { KeyringMetadata } from '@metamask/keyring-controller'; +import type { InternalAccount } from '@metamask/keyring-internal-api'; +import { SnapControllerState } from '@metamask/snaps-controllers'; +import deepmerge from 'deepmerge'; + +import { + getMultichainAccountServiceMessenger, + getRootMessenger, + MOCK_HD_ACCOUNT_1, + MOCK_HD_KEYRING_2, + MOCK_XLM_ACCOUNT_1, + MOCK_XLM_DISCOVERED_ACCOUNT_1, + MockAccountBuilder, + toGroupIndexRangeArray, +} from '../tests/index.js'; +import type { RootMessenger, DeepPartial } from '../tests/index.js'; +import { AccountProviderWrapper } from './AccountProviderWrapper.js'; +import type { SnapAccountProviderConfig } from './SnapAccountProvider.js'; +import { + XLM_ACCOUNT_PROVIDER_DEFAULT_CONFIG, + XLM_ACCOUNT_PROVIDER_NAME, + XlmAccountProvider, +} from './XlmAccountProvider.js'; + +function asConfig( + partial: DeepPartial, +): SnapAccountProviderConfig { + return deepmerge( + XLM_ACCOUNT_PROVIDER_DEFAULT_CONFIG, + partial, + ) as SnapAccountProviderConfig; +} + +/** + * v2 capabilities as declared by a fully v2-compliant Stellar Snap manifest. + * Drives the batched `createAccounts` flow and the v2 discovery path. + */ +const XLM_V2_CAPABILITIES: KeyringCapabilities = { + scopes: [XlmScope.Pubnet], + bip44: { + deriveIndex: true, + deriveIndexRange: true, + discover: true, + }, +}; + +class MockStellarKeyring { + readonly type = 'MockStellarKeyring'; + + readonly metadata: KeyringMetadata = { + id: 'mock-stellar-keyring-id', + name: '', + }; + + readonly accounts: InternalAccount[]; + + constructor(accounts: InternalAccount[]) { + this.accounts = accounts; + } + + createAccounts: SnapKeyring['createAccounts'] = jest + .fn() + .mockImplementation((options) => { + const groupIndices = + options.type === 'bip44:derive-index-range' + ? toGroupIndexRangeArray(options.range) + : [options.groupIndex]; + + return groupIndices.map((groupIndex) => { + const found = this.accounts.find( + (account) => + isBip44Account(account) && + account.options.entropy.groupIndex === groupIndex, + ); + + if (found) { + return found; + } + + const account = MockAccountBuilder.from(MOCK_XLM_ACCOUNT_1) + .withUuid() + .withAddressSuffix(`${groupIndex}`) + .withGroupIndex(groupIndex) + .get(); + this.accounts.push(account); + return account; + }); + }); + + discoverAccounts = jest.fn().mockResolvedValue([]); + + deleteAccount = jest.fn().mockResolvedValue(undefined); +} + +class MockXlmAccountProvider extends XlmAccountProvider { + override async ensureReady(): Promise { + // Override to avoid waiting during tests. + } +} + +function setup({ + messenger = getRootMessenger(), + accounts = [], + config, + capabilities = { scopes: [] }, +}: { + messenger?: RootMessenger; + accounts?: InternalAccount[]; + config?: SnapAccountProviderConfig; + capabilities?: KeyringCapabilities; +} = {}): { + provider: AccountProviderWrapper; + messenger: RootMessenger; + keyring: MockStellarKeyring; + mocks: { + handleRequest: jest.Mock; + keyring: { + createAccounts: jest.Mock; + discoverAccounts: jest.Mock; + }; + trace: jest.Mock; + }; +} { + const keyring = new MockStellarKeyring(accounts); + + messenger.registerActionHandler( + 'AccountsController:getAccounts', + () => accounts, + ); + + messenger.registerActionHandler( + 'SnapController:getState', + () => ({ isReady: true }) as SnapControllerState, + ); + + messenger.registerActionHandler( + 'SnapAccountService:getCapabilities', + async () => capabilities, + ); + + messenger.registerActionHandler( + 'AccountsController:listMultichainAccounts', + () => accounts, + ); + + const mockGetAccount = jest.fn().mockImplementation((id) => { + return keyring.accounts.find((account) => account.id === id); + }); + messenger.registerActionHandler( + 'AccountsController:getAccount', + mockGetAccount, + ); + + const mockHandleRequest = jest.fn().mockImplementation((request) => { + if (request.request?.method === 'keyring_discoverAccounts') { + return keyring.discoverAccounts(); + } + + return keyring.accounts.find( + (account) => account.address === request.address, + ); + }); + + const mockTrace = jest.fn().mockImplementation(async (_request, fn) => { + return await fn(); + }); + + messenger.registerActionHandler( + 'SnapController:handleRequest', + mockHandleRequest, + ); + + messenger.registerActionHandler( + 'KeyringController:withKeyringV2', + async (_, operation) => + operation({ + keyring, + metadata: keyring.metadata, + }), + ); + + const multichainMessenger = getMultichainAccountServiceMessenger(messenger); + const xlmProvider = new MockXlmAccountProvider( + multichainMessenger, + config, + mockTrace, + ); + const accountIds = accounts.map((account) => account.id); + xlmProvider.init(accountIds); + const provider = new AccountProviderWrapper(multichainMessenger, xlmProvider); + + return { + provider, + messenger, + keyring, + mocks: { + handleRequest: mockHandleRequest, + keyring: { + createAccounts: keyring.createAccounts as jest.Mock, + discoverAccounts: keyring.discoverAccounts, + }, + trace: mockTrace, + }, + }; +} + +describe('XlmAccountProvider', () => { + it('getName returns Stellar', () => { + const { provider } = setup({ accounts: [] }); + expect(provider.getName()).toBe(XLM_ACCOUNT_PROVIDER_NAME); + }); + + it('uses default config and trace callback', () => { + const messenger = getMultichainAccountServiceMessenger(getRootMessenger()); + const provider = new XlmAccountProvider(messenger); + expect(provider.getName()).toBe(XLM_ACCOUNT_PROVIDER_NAME); + }); + + it('returns true if an account is compatible', () => { + const account = MOCK_XLM_ACCOUNT_1; + const { provider } = setup({ + accounts: [account], + }); + expect(provider.isAccountCompatible(account)).toBe(true); + }); + + it('returns false if an account is not compatible', () => { + const account = MOCK_HD_ACCOUNT_1; + const { provider } = setup({ + accounts: [account], + }); + expect(provider.isAccountCompatible(account)).toBe(false); + }); + + it('returns existing account if it already exists at index', async () => { + const { provider, mocks } = setup({ + accounts: [MOCK_XLM_ACCOUNT_1], + capabilities: XLM_V2_CAPABILITIES, + }); + + mocks.keyring.discoverAccounts.mockResolvedValue([ + MOCK_XLM_DISCOVERED_ACCOUNT_1, + ]); + + const discovered = await provider.discoverAccounts({ + entropySource: MOCK_HD_KEYRING_2.metadata.id, + groupIndex: 0, + }); + + expect(discovered).toStrictEqual([MOCK_XLM_ACCOUNT_1]); + }); + + it('does not return any accounts if no account is discovered', async () => { + const { provider, mocks } = setup({ + accounts: [], + }); + + mocks.keyring.discoverAccounts.mockResolvedValue([]); + + const discovered = await provider.discoverAccounts({ + entropySource: MOCK_HD_KEYRING_2.metadata.id, + groupIndex: 0, + }); + + expect(discovered).toStrictEqual([]); + }); + + it('returns no accounts when a v2 Snap does not support bip44:discover', async () => { + const { provider, mocks } = setup({ + accounts: [], + capabilities: { + scopes: [XlmScope.Pubnet], + bip44: { deriveIndex: true, deriveIndexRange: true }, + }, + }); + + const discovered = await provider.discoverAccounts({ + entropySource: MOCK_HD_KEYRING_2.metadata.id, + groupIndex: 0, + }); + + expect(discovered).toStrictEqual([]); + expect(mocks.keyring.createAccounts).not.toHaveBeenCalled(); + }); + + it('does not run discovery if disabled', async () => { + const { provider } = setup({ + accounts: [MOCK_XLM_ACCOUNT_1], + config: asConfig({ + discovery: { + enabled: false, + }, + }), + }); + + expect( + await provider.discoverAccounts({ + entropySource: MOCK_HD_KEYRING_2.metadata.id, + groupIndex: 0, + }), + ).toStrictEqual([]); + }); + + describe('v2 - batched', () => { + it('creates one account via createAccounts', async () => { + const accounts = [MOCK_XLM_ACCOUNT_1]; + const { provider, mocks } = setup({ + accounts, + capabilities: XLM_V2_CAPABILITIES, + }); + + const newGroupIndex = accounts.length; + const newAccounts = await provider.createAccounts({ + type: AccountCreationType.Bip44DeriveIndex, + entropySource: MOCK_HD_KEYRING_2.metadata.id, + groupIndex: newGroupIndex, + }); + + expect(newAccounts).toHaveLength(1); + expect(mocks.keyring.createAccounts).toHaveBeenCalledWith({ + type: AccountCreationType.Bip44DeriveIndex, + entropySource: MOCK_HD_KEYRING_2.metadata.id, + groupIndex: newGroupIndex, + }); + }); + + it('creates multiple accounts using Bip44DeriveIndexRange', async () => { + const accounts = [MOCK_XLM_ACCOUNT_1]; + const { provider, mocks } = setup({ + accounts, + capabilities: XLM_V2_CAPABILITIES, + }); + + const from = 1; + const newAccounts = await provider.createAccounts({ + type: AccountCreationType.Bip44DeriveIndexRange, + entropySource: MOCK_HD_KEYRING_2.metadata.id, + range: { from, to: 3 }, + }); + + expect(newAccounts).toHaveLength(3); + expect(mocks.keyring.createAccounts).toHaveBeenCalledTimes(1); + + for (const [index, account] of newAccounts.entries()) { + expect(isBip44Account(account)).toBe(true); + expect(account.options.entropy.groupIndex).toBe(from + index); + } + }); + }); +}); diff --git a/packages/multichain-account-service/src/providers/XlmAccountProvider.ts b/packages/multichain-account-service/src/providers/XlmAccountProvider.ts new file mode 100644 index 00000000000..ea39099deba --- /dev/null +++ b/packages/multichain-account-service/src/providers/XlmAccountProvider.ts @@ -0,0 +1,61 @@ +import type { Bip44Account } from '@metamask/account-api'; +import type { TraceCallback } from '@metamask/controller-utils'; +import { XlmAccountType, XlmScope } from '@metamask/keyring-api'; +import { KeyringTypes } from '@metamask/keyring-controller'; +import type { InternalAccount } from '@metamask/keyring-internal-api'; +import type { SnapId } from '@metamask/snaps-sdk'; +import type { CaipChainId } from '@metamask/utils'; + +import { traceFallback } from '../analytics/index.js'; +import type { MultichainAccountServiceMessenger } from '../types.js'; +import { SnapAccountProvider } from './SnapAccountProvider.js'; +import type { SnapAccountProviderConfig } from './SnapAccountProvider.js'; + +export type XlmAccountProviderConfig = SnapAccountProviderConfig; + +export const XLM_ACCOUNT_PROVIDER_NAME = 'Stellar'; + +export const XLM_ACCOUNT_PROVIDER_DEFAULT_CONFIG: XlmAccountProviderConfig = { + maxConcurrency: 3, + discovery: { + enabled: true, + timeoutMs: 2000, + maxAttempts: 3, + backOffMs: 1000, + }, + createAccounts: { + timeoutMs: 10000, + }, + resyncAccounts: { + autoRemoveExtraSnapAccounts: true, + }, +}; + +export class XlmAccountProvider extends SnapAccountProvider { + static NAME = XLM_ACCOUNT_PROVIDER_NAME; + + static XLM_SNAP_ID = 'npm:@metamask/stellar-wallet-snap' as SnapId; + + // TODO: Remove once the Snap is fully v2 — discovery is then driven by the + // Snap's own supported scopes via `createAccounts({ bip44:discover })`. + protected readonly v1DiscoveryScopes: CaipChainId[] = [XlmScope.Pubnet]; + + constructor( + messenger: MultichainAccountServiceMessenger, + config: XlmAccountProviderConfig = XLM_ACCOUNT_PROVIDER_DEFAULT_CONFIG, + trace: TraceCallback = traceFallback, + ) { + super(XlmAccountProvider.XLM_SNAP_ID, messenger, config, trace); + } + + getName(): string { + return XlmAccountProvider.NAME; + } + + isAccountCompatible(account: Bip44Account): boolean { + return ( + account.type === XlmAccountType.Account && + account.metadata.keyring.type === (KeyringTypes.snap as string) + ); + } +} diff --git a/packages/multichain-account-service/src/providers/index.ts b/packages/multichain-account-service/src/providers/index.ts new file mode 100644 index 00000000000..3098194fcb6 --- /dev/null +++ b/packages/multichain-account-service/src/providers/index.ts @@ -0,0 +1,13 @@ +export * from './BaseBip44AccountProvider.js'; +export * from './SnapAccountProvider.js'; +export * from './AccountProviderWrapper.js'; + +// Errors that can bubble up outside of provider calls. +export { TimeoutError, isTimeoutError } from './utils.js'; + +// Concrete providers: +export * from './SolAccountProvider.js'; +export * from './EvmAccountProvider.js'; +export * from './BtcAccountProvider.js'; +export * from './TrxAccountProvider.js'; +export * from './XlmAccountProvider.js'; diff --git a/packages/multichain-account-service/src/providers/utils.test.ts b/packages/multichain-account-service/src/providers/utils.test.ts new file mode 100644 index 00000000000..b47d20e6226 --- /dev/null +++ b/packages/multichain-account-service/src/providers/utils.test.ts @@ -0,0 +1,91 @@ +import { + KeyringControllerError, + KeyringControllerErrorMessage, +} from '@metamask/keyring-controller'; + +import { + TimeoutError, + isKeyringControllerLockedError, + isTimeoutError, + withRetry, + withTimeout, +} from './utils.js'; + +describe('utils', () => { + it('retries RPC request up to 3 times if it fails and throws the last error', async () => { + const mockNetworkCall = jest + .fn() + .mockImplementationOnce(() => { + throw new Error('RPC request failed 1'); + }) + .mockImplementationOnce(() => { + throw new Error('RPC request failed 2'); + }) + .mockImplementationOnce(() => { + throw new Error('RPC request failed 3'); + }) + .mockImplementationOnce(() => { + throw new Error('RPC request failed 4'); + }); + + await expect(withRetry(mockNetworkCall)).rejects.toThrow( + 'RPC request failed 3', + ); + }); + + it('throws if the RPC request times out', async () => { + await expect( + withTimeout( + () => + new Promise((resolve) => { + setTimeout(() => { + resolve(null); + }, 600); + }), + ), + ).rejects.toThrow(TimeoutError); + }); + + it('includes the timeout duration in the error message', async () => { + await expect( + withTimeout( + () => + new Promise((resolve) => { + setTimeout(() => { + resolve(null); + }, 600); + }), + 500, + ), + ).rejects.toThrow('Timed out after: 500ms'); + }); + + it('isTimeoutError returns true for TimeoutError instances', () => { + expect(isTimeoutError(new TimeoutError('Timed out after: 500ms'))).toBe( + true, + ); + }); + + it('isTimeoutError returns false for non-TimeoutError instances', () => { + expect(isTimeoutError(new Error('some error'))).toBe(false); + expect(isTimeoutError('string')).toBe(false); + expect(isTimeoutError(null)).toBe(false); + }); + + it('isKeyringControllerLockedError returns true for KeyringControllerLockedError instances', () => { + expect( + isKeyringControllerLockedError( + new KeyringControllerError( + KeyringControllerErrorMessage.ControllerLocked, + ), + ), + ).toBe(true); + }); + + it.each([new Error('some error'), 'string', null])( + 'isKeyringControllerLockedError returns false for %p', + (error) => { + expect(isKeyringControllerLockedError(error)).toBe(false); + }, + ); +}); diff --git a/packages/multichain-account-service/src/providers/utils.ts b/packages/multichain-account-service/src/providers/utils.ts new file mode 100644 index 00000000000..5abfbd381d2 --- /dev/null +++ b/packages/multichain-account-service/src/providers/utils.ts @@ -0,0 +1,99 @@ +import { + KeyringControllerError, + KeyringControllerErrorMessage, +} from '@metamask/keyring-controller'; + +/** Timeout error. */ +export class TimeoutError extends Error { + constructor(message: string) { + super(message); + this.name = 'TimeoutError'; + } +} + +/** + * Check if an error is a `TimeoutError`. + * + * @param error - The error to check. + * @returns `true` if the error is a `TimeoutError`, otherwise `false`. + */ +export function isTimeoutError(error: unknown): error is TimeoutError { + return error instanceof TimeoutError; +} + +/** + * Check if an error is a `KeyringControllerLockedError`. + * + * @param error - The error to check. + * @returns `true` if the error is a `KeyringControllerLockedError`, otherwise `false`. + */ +export function isKeyringControllerLockedError(error: unknown): boolean { + return ( + error instanceof KeyringControllerError && + error.message === KeyringControllerErrorMessage.ControllerLocked + ); +} + +/** + * Execute a function with exponential backoff on transient failures. + * + * @param fnToExecute - The function to execute. + * @param options - The options for the retry. + * @param options.maxAttempts - The maximum number of attempts. + * @param options.backOffMs - The backoff in milliseconds. + * @throws An error if the transaction count cannot be retrieved. + * @returns The result of the function. + */ +export async function withRetry( + fnToExecute: () => Promise, + { + maxAttempts = 3, + backOffMs = 500, + }: { maxAttempts?: number; backOffMs?: number } = {}, +): Promise { + let lastError; + let backOff = backOffMs; + for (let attempt = 1; attempt <= maxAttempts; attempt++) { + try { + return await fnToExecute(); + } catch (error) { + lastError = error; + if (attempt >= maxAttempts) { + break; + } + const delay = backOff; + await new Promise((resolve) => setTimeout(resolve, delay)); + backOff *= 2; + } + } + throw lastError; +} + +/** + * Execute a promise with a timeout. + * + * @param fn - A callback that returns the promise to execute. + * @param timeoutMs - The timeout in milliseconds. + * @returns The result of the promise. + */ +export async function withTimeout( + fn: () => Promise, + timeoutMs: number = 500, +): Promise { + let timer; + try { + return await Promise.race([ + fn(), + new Promise((_resolve, reject) => { + timer = setTimeout( + () => reject(new TimeoutError(`Timed out after: ${timeoutMs}ms`)), + timeoutMs, + ); + }), + ]); + } finally { + if (timer) { + clearTimeout(timer); + } + } +} diff --git a/packages/multichain-account-service/src/tests/accounts.ts b/packages/multichain-account-service/src/tests/accounts.ts new file mode 100644 index 00000000000..501bd9edb9d --- /dev/null +++ b/packages/multichain-account-service/src/tests/accounts.ts @@ -0,0 +1,442 @@ +import type { Bip44Account } from '@metamask/account-api'; +import { isBip44Account } from '@metamask/account-api'; +import type { + DiscoveredAccount, + EntropySourceId, + KeyringAccount, +} from '@metamask/keyring-api'; +import { + BtcAccountType, + BtcMethod, + BtcScope, + EthAccountType, + EthMethod, + EthScope, + KeyringAccountEntropyTypeOption, + SolAccountType, + SolMethod, + SolScope, + TrxAccountType, + TrxMethod, + TrxScope, + XlmAccountType, + XlmMethod, + XlmScope, +} from '@metamask/keyring-api'; +import { KeyringTypes } from '@metamask/keyring-controller'; +import type { InternalAccount } from '@metamask/keyring-internal-api'; +import type { SnapId } from '@metamask/snaps-sdk'; +import { v4 as uuid } from 'uuid'; + +export const ETH_EOA_METHODS = [ + EthMethod.PersonalSign, + EthMethod.Sign, + EthMethod.SignTransaction, + EthMethod.SignTypedDataV1, + EthMethod.SignTypedDataV3, + EthMethod.SignTypedDataV4, +] as const; + +const SOL_METHODS = Object.values(SolMethod); + +export const MOCK_SNAP_1 = { + id: 'local:mock-snap-id-1', + name: 'Mock Snap 1', + enabled: true, + manifest: { + proposedName: 'Mock Snap 1', + }, +}; + +export const MOCK_SNAP_2 = { + id: 'local:mock-snap-id-2', + name: 'Mock Snap 2', + enabled: true, + manifest: { + proposedName: 'Mock Snap 2', + }, +}; + +export const MOCK_ENTROPY_SOURCE_1 = 'mock-keyring-id-1'; +export const MOCK_ENTROPY_SOURCE_2 = 'mock-keyring-id-2'; + +export const MOCK_MNEMONIC = + 'abandon ability able about above absent absorb abstract absurd abuse access accident'; + +export const MOCK_HD_KEYRING_1 = { + type: KeyringTypes.hd, + metadata: { id: MOCK_ENTROPY_SOURCE_1, name: 'HD Keyring 1' }, + accounts: ['0x123'], +}; + +export const MOCK_HD_KEYRING_2 = { + type: KeyringTypes.hd, + metadata: { id: MOCK_ENTROPY_SOURCE_2, name: 'HD Keyring 2' }, + accounts: ['0x456'], +}; + +/** Used when tests need ensureReady to resolve (SnapAccountService waits for Snap keyring). */ +export const MOCK_SNAP_KEYRING = { + type: KeyringTypes.snap, + metadata: { id: 'snap-keyring', name: 'Snap Keyring' }, + accounts: [], +}; + +export const MOCK_HD_ACCOUNT_1: Bip44Account = { + id: 'mock-id-1', + address: '0x123', + options: { + entropy: { + type: KeyringAccountEntropyTypeOption.Mnemonic, + id: MOCK_HD_KEYRING_1.metadata.id, + groupIndex: 0, + derivationPath: '', + }, + }, + methods: [...ETH_EOA_METHODS], + type: EthAccountType.Eoa, + scopes: [EthScope.Eoa], + metadata: { + name: 'Account 1', + keyring: { type: KeyringTypes.hd }, + importTime: 0, + lastSelected: 0, + nameLastUpdatedAt: 0, + }, +}; + +export const MOCK_HD_ACCOUNT_2: Bip44Account = { + id: 'mock-id-2', + address: '0x456', + options: { + entropy: { + type: KeyringAccountEntropyTypeOption.Mnemonic, + id: MOCK_HD_KEYRING_2.metadata.id, + groupIndex: 0, + derivationPath: '', + }, + }, + methods: [...ETH_EOA_METHODS], + type: EthAccountType.Eoa, + scopes: [EthScope.Eoa], + metadata: { + name: 'Account 2', + keyring: { type: KeyringTypes.hd }, + importTime: 0, + lastSelected: 0, + nameLastUpdatedAt: 0, + }, +}; + +export const MOCK_SOL_ACCOUNT_1: Bip44Account = { + id: 'mock-snap-id-1', + address: 'aabbccdd', + options: { + entropy: { + type: KeyringAccountEntropyTypeOption.Mnemonic, + // NOTE: shares entropy with MOCK_HD_ACCOUNT_2 + id: MOCK_HD_KEYRING_2.metadata.id, + groupIndex: 0, + derivationPath: '', + }, + }, + methods: SOL_METHODS, + type: SolAccountType.DataAccount, + scopes: [SolScope.Mainnet, SolScope.Testnet, SolScope.Devnet], + metadata: { + name: 'Solana Account 1', + keyring: { type: KeyringTypes.snap }, + snap: MOCK_SNAP_1, + importTime: 0, + lastSelected: 0, + }, +}; + +const XLM_METHODS = Object.values(XlmMethod); + +export const MOCK_XLM_ACCOUNT_1: Bip44Account = { + id: 'mock-snap-id-1', + address: `G${'A'.repeat(55)}`, + options: { + entropy: { + type: KeyringAccountEntropyTypeOption.Mnemonic, + id: MOCK_HD_KEYRING_2.metadata.id, + groupIndex: 0, + derivationPath: `m/44'/148'/0'`, + }, + }, + methods: XLM_METHODS, + type: XlmAccountType.Account, + scopes: [XlmScope.Pubnet, XlmScope.Testnet], + metadata: { + name: 'Stellar Account 1', + keyring: { type: KeyringTypes.snap }, + snap: MOCK_SNAP_1, + importTime: 0, + lastSelected: 0, + }, +}; + +export const MOCK_TRX_ACCOUNT_1: Bip44Account = { + id: 'mock-snap-id-1', + address: 'aabbccdd', + options: { + entropy: { + type: KeyringAccountEntropyTypeOption.Mnemonic, + // NOTE: shares entropy with MOCK_HD_ACCOUNT_2 + id: MOCK_HD_KEYRING_2.metadata.id, + groupIndex: 0, + derivationPath: '', + }, + }, + methods: [TrxMethod.SignMessageV2, TrxMethod.VerifyMessageV2], + type: TrxAccountType.Eoa, + scopes: [TrxScope.Mainnet], + metadata: { + name: 'Tron Account 1', + keyring: { type: KeyringTypes.snap }, + snap: MOCK_SNAP_1, + importTime: 0, + lastSelected: 0, + }, +}; + +export const MOCK_SOL_DISCOVERED_ACCOUNT_1: DiscoveredAccount = { + type: 'bip44', + scopes: [SolScope.Mainnet], + derivationPath: `m/44'/501'/0'/0'`, +}; + +export const MOCK_TRX_DISCOVERED_ACCOUNT_1: DiscoveredAccount = { + type: 'bip44', + scopes: [TrxScope.Mainnet], + derivationPath: `m/44'/195'/0'/0'`, +}; + +export const MOCK_XLM_DISCOVERED_ACCOUNT_1: DiscoveredAccount = { + type: 'bip44', + scopes: [XlmScope.Pubnet], + derivationPath: `m/44'/148'/0'`, +}; + +export const MOCK_BTC_P2TR_DISCOVERED_ACCOUNT_1: DiscoveredAccount = { + type: 'bip44', + scopes: [BtcScope.Mainnet], + derivationPath: `m/44'/0'/0'/0'`, +}; + +export const MOCK_BTC_P2WPKH_ACCOUNT_1: Bip44Account = { + id: 'b0f030d8-e101-4b5a-a3dd-13f8ca8ec1db', + type: BtcAccountType.P2wpkh, + methods: Object.values(BtcMethod), + address: 'bc1qx8ls07cy8j8nrluy2u0xwn7gh8fxg0rg4s8zze', + options: { + entropy: { + type: KeyringAccountEntropyTypeOption.Mnemonic, + // NOTE: shares entropy with MOCK_HD_ACCOUNT_2 + id: MOCK_HD_KEYRING_2.metadata.id, + groupIndex: 0, + derivationPath: '', + }, + }, + scopes: [BtcScope.Mainnet], + metadata: { + name: 'Bitcoin Native Segwit Account 1', + importTime: 0, + keyring: { + type: KeyringTypes.snap, + }, + snap: { + id: 'mock-btc-snap-id', + }, + }, +}; + +export const MOCK_BTC_P2TR_ACCOUNT_1: Bip44Account = { + id: 'a20c2e1a-6ff6-40ba-b8e0-ccdb6f9933bb', + type: BtcAccountType.P2tr, + methods: Object.values(BtcMethod), + address: 'tb1p5cyxnuxmeuwuvkwfem96lxx9wex9kkf4mt9ll6q60jfsnrzqg4sszkqjnh', + options: { + entropy: { + type: KeyringAccountEntropyTypeOption.Mnemonic, + // NOTE: shares entropy with MOCK_HD_ACCOUNT_2 + id: MOCK_HD_KEYRING_2.metadata.id, + groupIndex: 0, + derivationPath: '', + }, + }, + scopes: [BtcScope.Testnet], + metadata: { + name: 'Bitcoin Taproot Account 1', + importTime: 0, + keyring: { + type: KeyringTypes.snap, + }, + snap: { + id: 'mock-btc-snap-id', + }, + }, +}; + +export const MOCK_SNAP_ACCOUNT_1 = MOCK_SOL_ACCOUNT_1; + +export const MOCK_SNAP_ACCOUNT_2: InternalAccount = { + id: 'mock-snap-id-2', + address: '0x789', + options: {}, + methods: [...ETH_EOA_METHODS], + type: EthAccountType.Eoa, + scopes: [EthScope.Eoa], + metadata: { + name: 'Snap Acc 2', + keyring: { type: KeyringTypes.snap }, + snap: MOCK_SNAP_2, + importTime: 0, + lastSelected: 0, + }, +}; + +export const MOCK_SNAP_ACCOUNT_3 = MOCK_BTC_P2WPKH_ACCOUNT_1; +export const MOCK_SNAP_ACCOUNT_4 = MOCK_BTC_P2TR_ACCOUNT_1; + +export const MOCK_HARDWARE_ACCOUNT_1: InternalAccount = { + id: 'mock-hardware-id-1', + address: '0xABC', + options: {}, + methods: [...ETH_EOA_METHODS], + type: EthAccountType.Eoa, + scopes: [EthScope.Eoa], + metadata: { + name: 'Hardware Acc 1', + keyring: { type: KeyringTypes.ledger }, + importTime: 0, + lastSelected: 0, + }, +}; + +export function isInternalAccount( + account: KeyringAccount, +): account is InternalAccount { + // Meant to be used for testing, so we keep this simple. + return Object.prototype.hasOwnProperty.call(account, 'metadata'); +} + +export function asKeyringAccount( + account: Account, +): KeyringAccount { + const { id, type, address, scopes, options, methods } = account; + + return { + id, + type, + address, + scopes, + options, + methods, + }; +} + +export class MockAccountBuilder { + readonly #account: Account; + + constructor(account: Account) { + // Make a deep-copy to avoid mutating the same ref. + this.#account = JSON.parse(JSON.stringify(account)); + } + + static from( + account: Account, + ): MockAccountBuilder { + return new MockAccountBuilder(account); + } + + withId(id: InternalAccount['id']) { + this.#account.id = id; + return this; + } + + withUuid() { + this.#account.id = uuid(); + return this; + } + + withAddress(address: string): this { + this.#account.address = address; + return this; + } + + withAddressSuffix(suffix: string) { + this.#account.address += suffix; + return this; + } + + withEntropySource(entropySource: EntropySourceId) { + if (isBip44Account(this.#account)) { + this.#account.options.entropy.id = entropySource; + } + return this; + } + + withGroupIndex(groupIndex: number) { + if (isBip44Account(this.#account)) { + this.#account.options.entropy.groupIndex = groupIndex; + } + return this; + } + + withSnapId(snapId: SnapId) { + if (isInternalAccount(this.#account)) { + this.#account.metadata.snap = { + id: snapId, + }; + } + return this; + } + + get() { + return this.#account; + } +} + +export const MOCK_WALLET_1_ENTROPY_SOURCE = MOCK_ENTROPY_SOURCE_1; + +export const MOCK_WALLET_1_EVM_ACCOUNT = MockAccountBuilder.from( + MOCK_HD_ACCOUNT_1, +) + .withEntropySource(MOCK_WALLET_1_ENTROPY_SOURCE) + .withGroupIndex(0) + .get(); +export const MOCK_WALLET_1_SOL_ACCOUNT = MockAccountBuilder.from( + MOCK_SOL_ACCOUNT_1, +) + .withEntropySource(MOCK_WALLET_1_ENTROPY_SOURCE) + .withGroupIndex(0) + .get(); +export const MOCK_WALLET_1_BTC_P2WPKH_ACCOUNT = MockAccountBuilder.from( + MOCK_BTC_P2WPKH_ACCOUNT_1, +) + .withEntropySource(MOCK_WALLET_1_ENTROPY_SOURCE) + .withGroupIndex(0) + .get(); +export const MOCK_WALLET_1_BTC_P2TR_ACCOUNT = MockAccountBuilder.from( + MOCK_BTC_P2TR_ACCOUNT_1, +) + .withEntropySource(MOCK_WALLET_1_ENTROPY_SOURCE) + .withGroupIndex(0) + .get(); + +export function mockAsInternalAccount( + account: KeyringAccount, +): InternalAccount { + return { + ...account, + metadata: { + name: 'Mocked Account', + importTime: Date.now(), + keyring: { + type: 'mock-keyring-type', + }, + }, + }; +} diff --git a/packages/multichain-account-service/src/tests/index.ts b/packages/multichain-account-service/src/tests/index.ts new file mode 100644 index 00000000000..2ecfc9d02d3 --- /dev/null +++ b/packages/multichain-account-service/src/tests/index.ts @@ -0,0 +1,4 @@ +export type * from './types.js'; +export * from './accounts.js'; +export * from './messenger.js'; +export * from './providers.js'; diff --git a/packages/multichain-account-service/src/tests/messenger.ts b/packages/multichain-account-service/src/tests/messenger.ts new file mode 100644 index 00000000000..2380ac2a194 --- /dev/null +++ b/packages/multichain-account-service/src/tests/messenger.ts @@ -0,0 +1,88 @@ +import { Messenger, MOCK_ANY_NAMESPACE } from '@metamask/messenger'; +import type { + MessengerActions, + MessengerEvents, + MockAnyNamespace, +} from '@metamask/messenger'; + +import type { MultichainAccountServiceMessenger } from '../types.js'; + +export type AllMultichainAccountServiceActions = + MessengerActions; + +export type AllMultichainAccountServiceEvents = + MessengerEvents; + +export type RootMessenger = Messenger< + MockAnyNamespace, + AllMultichainAccountServiceActions, + AllMultichainAccountServiceEvents +>; + +/** + * Creates and returns a root messenger for testing + * + * @returns A messenger instance + */ +export function getRootMessenger(): RootMessenger { + return new Messenger({ + namespace: MOCK_ANY_NAMESPACE, + captureException: jest.fn(), + }); +} + +/** + * Retrieves a restricted messenger for the MultichainAccountService. + * + * @param rootMessenger - The root messenger instance. Defaults to a new Messenger created by getRootMessenger(). + * @param extra - Extra messenger options. + * @param extra.actions - Extra actions to delegate. + * @param extra.events - Extra events to delegate. + * @returns The restricted messenger for the MultichainAccountService. + */ +export function getMultichainAccountServiceMessenger( + rootMessenger: RootMessenger, + extra?: { + actions?: AllMultichainAccountServiceActions['type'][]; + events?: AllMultichainAccountServiceEvents['type'][]; + }, +): MultichainAccountServiceMessenger { + const messenger = new Messenger< + 'MultichainAccountService', + AllMultichainAccountServiceActions, + AllMultichainAccountServiceEvents, + RootMessenger + >({ + namespace: 'MultichainAccountService', + parent: rootMessenger, + }); + rootMessenger.delegate({ + messenger, + actions: [ + 'AccountsController:getAccount', + 'AccountsController:getAccountByAddress', + 'AccountsController:listMultichainAccounts', + 'SnapController:handleRequest', + 'KeyringController:withKeyring', + 'KeyringController:withKeyringV2', + 'KeyringController:getState', + 'KeyringController:getKeyringsByType', + 'KeyringController:addNewKeyring', + 'NetworkController:findNetworkClientIdByChainId', + 'NetworkController:getNetworkClientById', + 'KeyringController:createNewVaultAndKeychain', + 'KeyringController:createNewVaultAndRestore', + 'AccountsController:getAccounts', + 'KeyringController:removeAccount', + 'SnapAccountService:ensureReady', + 'SnapAccountService:getCapabilities', + ...(extra?.actions ?? []), + ], + events: [ + 'AccountsController:accountAdded', + 'AccountsController:accountRemoved', + ...(extra?.events ?? []), + ], + }); + return messenger; +} diff --git a/packages/multichain-account-service/src/tests/providers.ts b/packages/multichain-account-service/src/tests/providers.ts new file mode 100644 index 00000000000..4f09c453b29 --- /dev/null +++ b/packages/multichain-account-service/src/tests/providers.ts @@ -0,0 +1,182 @@ +import type { Bip44Account } from '@metamask/account-api'; +import { + BtcScope, + EthScope, + SolScope, + TrxScope, + XlmScope, +} from '@metamask/keyring-api'; +import type { KeyringAccount } from '@metamask/keyring-api'; +import type { KeyringCapabilities } from '@metamask/keyring-api/v2'; + +import { + AccountProviderWrapper, + EvmAccountProvider, +} from '../providers/index.js'; +import { GroupIndexRange } from '../utils.js'; + +export type MockAccountProvider = { + mockAccounts: KeyringAccount[]; + accounts: Set; + capabilities: KeyringCapabilities; + constructor: jest.Mock; + alignAccounts: jest.Mock; + init: jest.Mock; + resyncAccounts: jest.Mock; + getAccount: jest.Mock; + getAccounts: jest.Mock; + createAccounts: jest.Mock; + deleteAccount: jest.Mock; + discoverAccounts: jest.Mock; + isAccountCompatible: jest.Mock; + isAligned: jest.Mock; + getName: jest.Mock; + ensureReady: jest.Mock; + isEnabled: boolean; + isDisabled: jest.Mock; + setEnabled: jest.Mock; +}; + +export function makeMockAccountProvider( + accounts: KeyringAccount[] = [], +): MockAccountProvider { + return { + mockAccounts: accounts, + accounts: new Set(), + capabilities: { + scopes: [ + SolScope.Devnet, + SolScope.Testnet, + BtcScope.Testnet, + TrxScope.Shasta, + XlmScope.Testnet, + EthScope.Eoa, + ], + bip44: { deriveIndex: true }, + }, + constructor: jest.fn(), + alignAccounts: jest.fn(), + init: jest.fn(), + resyncAccounts: jest.fn(), + getAccount: jest.fn(), + getAccounts: jest.fn(), + createAccounts: jest.fn(), + deleteAccount: jest.fn(), + discoverAccounts: jest.fn(), + isAccountCompatible: jest.fn(), + isAligned: jest.fn().mockReturnValue(false), + getName: jest.fn(), + ensureReady: jest.fn().mockResolvedValue(undefined), + isDisabled: jest.fn(), + setEnabled: jest.fn(), + isEnabled: true, + }; +} + +export function setupBip44AccountProvider({ + name = 'Mocked Provider', + accounts, + mocks = makeMockAccountProvider(), + index, +}: { + name?: string; + mocks?: MockAccountProvider; + accounts: KeyringAccount[]; + filter?: (account: KeyringAccount) => boolean; + index?: number; +}): MockAccountProvider { + // You can mock this and all other mocks will re-use that list + // of accounts. + mocks.mockAccounts = accounts; + mocks.accounts = new Set(accounts.map((account) => account.id)); + // Toggle enabled state only + mocks.setEnabled.mockImplementation((enabled: boolean) => { + mocks.isEnabled = enabled; + }); + mocks.isDisabled.mockImplementation(() => !mocks.isEnabled); + + const getAccounts = (): KeyringAccount[] => + mocks.mockAccounts.filter((account) => + [...mocks.accounts].includes(account.id), + ); + + mocks.getName.mockImplementation(() => name); + + mocks.getAccounts.mockImplementation(getAccounts); + mocks.getAccount.mockImplementation( + (id: Bip44Account['id']) => + // Assuming this never fails. + getAccounts().find((account) => account.id === id), + ); + mocks.createAccounts.mockResolvedValue([]); + mocks.init.mockImplementation( + (accountIds: Bip44Account['id'][]) => { + accountIds.forEach((id) => mocks.accounts.add(id)); + }, + ); + + mocks.isAligned.mockImplementation( + ( + _context: { entropySource: string; groupIndex: number }, + accountIds: string[], + ) => + accountIds.length >= 1 && + accountIds.every((id) => mocks.accounts.has(id)), + ); + + if (index === 0) { + // Make the first provider to always be an `EvmAccountProvider`, since we + // check for this pre-condition in some methods. + Object.setPrototypeOf(mocks, EvmAccountProvider.prototype); + } + + if (index !== 0) { + Object.setPrototypeOf(mocks, AccountProviderWrapper.prototype); + } + + return mocks; +} + +/** + * Helper to mock a single createAccounts call while updating the provider's + * internal state so subsequent getAccount/getAccounts can resolve the accounts. + * + * @param provider - The mock provider whose createAccounts call to mock. + * @param created - The accounts to be returned and persisted in the mock state. + */ +export function mockCreateAccountsOnce( + provider: MockAccountProvider, + created: KeyringAccount[], +): void { + provider.createAccounts.mockImplementationOnce(async () => { + // Add newly created accounts to the provider's internal store + for (const acc of created) { + if (!provider.mockAccounts.some((a) => a.id === acc.id)) { + provider.mockAccounts.push(acc); + } + } + // Merge IDs into the visible list used by getAccounts/getAccount + const ids = created.map((a) => a.id); + for (const id of ids) { + provider.accounts.add(id); + } + + return created; + }); +} + +/** + * Helper to convert a group index range to an array of group indices, inclusive of the + * start and end indices. + * + * @param range - The range. + * @param range.from - The starting index of the range (inclusive). + * @param range.to - The ending index of the range (inclusive). + * @returns An array of group indices from `from` to `to`, inclusive. + */ +export function toGroupIndexRangeArray({ + from = 0, + to, +}: GroupIndexRange): number[] { + return Array.from({ length: to - from + 1 }, (_, i) => from + i); +} diff --git a/packages/multichain-account-service/src/tests/types.ts b/packages/multichain-account-service/src/tests/types.ts new file mode 100644 index 00000000000..9fb40a956d7 --- /dev/null +++ b/packages/multichain-account-service/src/tests/types.ts @@ -0,0 +1,14 @@ +/** + * A utility type that makes all properties of a type optional, recursively. + */ +export type DeepPartial = Type extends string + ? Type + : { + [Property in keyof Type]?: Type[Property] extends (infer Value)[] + ? DeepPartial[] + : Type[Property] extends readonly (infer Value)[] + ? readonly DeepPartial[] + : Type[Property] extends object + ? DeepPartial + : Type[Property]; + }; diff --git a/packages/multichain-account-service/src/types.ts b/packages/multichain-account-service/src/types.ts new file mode 100644 index 00000000000..d5d30c85a0f --- /dev/null +++ b/packages/multichain-account-service/src/types.ts @@ -0,0 +1,115 @@ +import type { + Bip44Account, + MultichainAccountGroup, + MultichainAccountWalletId, + MultichainAccountWalletStatus, +} from '@metamask/account-api'; +import type { + AccountsControllerAccountAddedEvent, + AccountsControllerAccountRemovedEvent, + AccountsControllerGetAccountAction, + AccountsControllerGetAccountByAddressAction, + AccountsControllerGetAccountsAction, + AccountsControllerListMultichainAccountsAction, +} from '@metamask/accounts-controller'; +import type { TraceCallback } from '@metamask/controller-utils'; +import type { KeyringAccount } from '@metamask/keyring-api'; +import type { + KeyringControllerAddNewKeyringAction, + KeyringControllerCreateNewVaultAndKeychainAction, + KeyringControllerCreateNewVaultAndRestoreAction, + KeyringControllerGetKeyringsByTypeAction, + KeyringControllerGetStateAction, + KeyringControllerRemoveAccountAction, + KeyringControllerWithKeyringAction, + KeyringControllerWithKeyringV2Action, +} from '@metamask/keyring-controller'; +import type { Messenger } from '@metamask/messenger'; +import type { + NetworkControllerFindNetworkClientIdByChainIdAction, + NetworkControllerGetNetworkClientByIdAction, +} from '@metamask/network-controller'; +import type { + SnapAccountServiceEnsureReadyAction, + SnapAccountServiceGetCapabilitiesAction, +} from '@metamask/snap-account-service'; +import type { SnapControllerHandleRequestAction } from '@metamask/snaps-controllers'; + +import type { MultichainAccountServiceMethodActions } from './MultichainAccountService-method-action-types.js'; +import type { serviceName } from './MultichainAccountService.js'; + +/** + * All actions that {@link MultichainAccountService} registers so that other + * modules can call them. + */ +export type MultichainAccountServiceActions = + MultichainAccountServiceMethodActions; + +export type MultichainAccountServiceMultichainAccountGroupCreatedEvent = { + type: `${typeof serviceName}:multichainAccountGroupCreated`; + payload: [MultichainAccountGroup>]; +}; + +export type MultichainAccountServiceMultichainAccountGroupUpdatedEvent = { + type: `${typeof serviceName}:multichainAccountGroupUpdated`; + payload: [MultichainAccountGroup>]; +}; + +export type MultichainAccountServiceWalletStatusChangeEvent = { + type: `${typeof serviceName}:walletStatusChange`; + payload: [MultichainAccountWalletId, MultichainAccountWalletStatus]; +}; + +/** + * All events that {@link MultichainAccountService} publishes so that other modules + * can subscribe to them. + */ +export type MultichainAccountServiceEvents = + | MultichainAccountServiceMultichainAccountGroupCreatedEvent + | MultichainAccountServiceMultichainAccountGroupUpdatedEvent + | MultichainAccountServiceWalletStatusChangeEvent; + +/** + * All actions registered by other modules that {@link MultichainAccountService} + * calls. + */ +type AllowedActions = + | AccountsControllerListMultichainAccountsAction + | AccountsControllerGetAccountsAction + | AccountsControllerGetAccountAction + | AccountsControllerGetAccountByAddressAction + | KeyringControllerWithKeyringAction + | KeyringControllerWithKeyringV2Action + | KeyringControllerGetStateAction + | KeyringControllerGetKeyringsByTypeAction + | KeyringControllerAddNewKeyringAction + | NetworkControllerGetNetworkClientByIdAction + | NetworkControllerFindNetworkClientIdByChainIdAction + | KeyringControllerCreateNewVaultAndKeychainAction + | KeyringControllerCreateNewVaultAndRestoreAction + | KeyringControllerRemoveAccountAction + | SnapControllerHandleRequestAction + | SnapAccountServiceEnsureReadyAction + | SnapAccountServiceGetCapabilitiesAction; + +/** + * All events published by other modules that {@link MultichainAccountService} + * subscribes to. + */ +type AllowedEvents = + | AccountsControllerAccountAddedEvent + | AccountsControllerAccountRemovedEvent; + +/** + * The messenger restricted to actions and events that + * {@link MultichainAccountService} needs to access. + */ +export type MultichainAccountServiceMessenger = Messenger< + 'MultichainAccountService', + MultichainAccountServiceActions | AllowedActions, + MultichainAccountServiceEvents | AllowedEvents +>; + +export type MultichainAccountServiceConfig = { + trace?: TraceCallback; +}; diff --git a/packages/multichain-account-service/src/utils.test.ts b/packages/multichain-account-service/src/utils.test.ts new file mode 100644 index 00000000000..c6975c98c90 --- /dev/null +++ b/packages/multichain-account-service/src/utils.test.ts @@ -0,0 +1,120 @@ +import { + toErrorMessage, + assertGroupIndexRangeIsValid, + assertGroupIndexIsValid, +} from './utils.js'; + +describe('toErrorMessage', () => { + it('returns the message of an Error instance', () => { + const error = new Error('something went wrong'); + expect(toErrorMessage(error)).toBe(error.message); + }); + + it('returns the string representation of a non-Error value', () => { + expect(toErrorMessage('raw string')).toBe('raw string'); + }); + + it('converts a number to string', () => { + expect(toErrorMessage(42)).toBe('42'); + }); + + it('converts null to string', () => { + expect(toErrorMessage(null)).toBe('null'); + }); + + it('converts undefined to string', () => { + expect(toErrorMessage(undefined)).toBe('undefined'); + }); + + it('converts an object to string', () => { + expect(toErrorMessage({ foo: 'bar' })).toBe('[object Object]'); + }); +}); + +describe('assertGroupIndexRangeIsValid', () => { + describe('when range is valid', () => { + it.each([ + { from: 0, to: 5 }, + { from: 1, to: 10 }, + { from: 5, to: 5 }, + { to: 5 }, + { to: 0 }, + { from: 3, to: 3 }, + ])('does not throw for valid range: %o', (range) => { + expect(() => assertGroupIndexRangeIsValid(range)).not.toThrow(); + }); + }); + + describe('when range is invalid', () => { + it.each([ + { from: -1, to: 5 }, + { from: -10, to: 0 }, + ])('throws when from is negative: %o', (range) => { + expect(() => assertGroupIndexRangeIsValid(range)).toThrow( + `Bad range, from (${range.from}) must be >= 0`, + ); + }); + + it.each([{ from: 0, to: -1 }, { to: -5 }])( + 'throws when to is negative: %o', + (range) => { + expect(() => assertGroupIndexRangeIsValid(range)).toThrow( + `Bad range, to (${range.to}) must be >= 0`, + ); + }, + ); + + it.each([ + { from: 5, to: 3 }, + { from: 10, to: 2 }, + ])('throws when to is less than from: %o', (range) => { + expect(() => assertGroupIndexRangeIsValid(range)).toThrow( + `Bad range, to (${range.to}) must be >= from (${range.from})`, + ); + }); + + it.each([{ from: -1, to: -2 }])( + 'throws when both from and to are negative (prioritizes from validation): %o', + (range) => { + expect(() => assertGroupIndexRangeIsValid(range)).toThrow( + `Bad range, from (${range.from}) must be >= 0`, + ); + }, + ); + }); +}); + +describe('assertGroupIndexIsValid', () => { + describe('when group index is valid', () => { + it.each([ + { groupIndex: 0, nextGroupIndex: 5 }, + { groupIndex: 3, nextGroupIndex: 10 }, + { groupIndex: 5, nextGroupIndex: 5 }, + { groupIndex: 0, nextGroupIndex: 0 }, + ])( + 'does not throw for valid group index: $groupIndex <= $nextGroupIndex', + ({ groupIndex, nextGroupIndex }) => { + expect(() => + assertGroupIndexIsValid(groupIndex, nextGroupIndex), + ).not.toThrow(); + }, + ); + }); + + describe('when group index is invalid', () => { + it.each([ + { groupIndex: 6, nextGroupIndex: 5 }, + { groupIndex: 10, nextGroupIndex: 3 }, + { groupIndex: 1, nextGroupIndex: 0 }, + ])( + 'throws when group index is greater than next group index: $groupIndex > $nextGroupIndex', + ({ groupIndex, nextGroupIndex }) => { + expect(() => + assertGroupIndexIsValid(groupIndex, nextGroupIndex), + ).toThrow( + `Bad group index, groupIndex (${groupIndex}) cannot be higher than the next available one (<= ${nextGroupIndex})`, + ); + }, + ); + }); +}); diff --git a/packages/multichain-account-service/src/utils.ts b/packages/multichain-account-service/src/utils.ts new file mode 100644 index 00000000000..7c326879953 --- /dev/null +++ b/packages/multichain-account-service/src/utils.ts @@ -0,0 +1,101 @@ +/** + * Range-based multichain account creations type. + */ +export type GroupIndexRange = { + from?: number; + to: number; +}; + +/** + * Asserts that a range is valid. + * + * @param range - The range to assert. + * @param range.from - The starting index of the range (inclusive). + * @param range.to - The ending index of the range (inclusive). + */ +export function assertGroupIndexRangeIsValid({ + from = 0, + to, +}: GroupIndexRange): void { + if (from < 0) { + throw new Error(`Bad range, from (${from}) must be >= 0`); + } + + if (to < 0) { + throw new Error(`Bad range, to (${to}) must be >= 0`); + } + + if (to < from) { + throw new Error(`Bad range, to (${to}) must be >= from (${from})`); + } +} + +/** + * Asserts that a group index is valid given the next available group index. + * + * @param groupIndex - The group index to assert. + * @param nextGroupIndex - The next available group index. + */ +export function assertGroupIndexIsValid( + groupIndex: number, + nextGroupIndex: number, +): void { + if (groupIndex > nextGroupIndex) { + throw new Error( + `Bad group index, groupIndex (${groupIndex}) cannot be higher than the next available one (<= ${nextGroupIndex})`, + ); + } +} + +/** + * Augmented `Error` shape produced by {@link createSentryError}. The runtime + * value carries a `cause` and (optionally) a structured `context` payload + * that downstream Sentry tooling can read. + * + * The `TContext` type parameter narrows the shape of `context` for callers + * that know what they put in — most useful in tests when asserting on a + * captured error. + */ +export type SentryError< + TContext extends Record = Record, +> = Error & { + cause: Error; + context?: TContext; +}; + +/** + * Creates a Sentry error from an error message, an inner error and a context. + * + * NOTE: Sentry defaults to a depth of 3 when extracting non-native attributes. + * As such, the context depth shouldn't be too deep. + * + * @param message - The error message to create a Sentry error from. + * @param innerError - The inner error to create a Sentry error from. + * @param context - The context to add to the Sentry error. + * @returns A Sentry error. + */ +export const createSentryError = < + TContext extends Record = Record, +>( + message: string, + innerError: Error, + context?: TContext, +): SentryError => { + const error = new Error(message) as SentryError; + error.cause = innerError; + if (context) { + error.context = context; + } + return error; +}; + +/** + * Converts an unknown error value to a string message. + * + * @param error - The error to convert. + * @returns The error message if the error is an `Error` instance, otherwise + * the string representation of the value. + */ +export function toErrorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} diff --git a/packages/multichain-account-service/tsconfig.build.json b/packages/multichain-account-service/tsconfig.build.json new file mode 100644 index 00000000000..c20f387b447 --- /dev/null +++ b/packages/multichain-account-service/tsconfig.build.json @@ -0,0 +1,29 @@ +{ + "extends": "../../tsconfig.packages.build.json", + "compilerOptions": { + "baseUrl": "./", + "outDir": "./dist", + "rootDir": "./src" + }, + "references": [ + { + "path": "../base-controller/tsconfig.build.json" + }, + { + "path": "../accounts-controller/tsconfig.build.json" + }, + { + "path": "../keyring-controller/tsconfig.build.json" + }, + { + "path": "../snap-account-service/tsconfig.build.json" + }, + { + "path": "../messenger/tsconfig.build.json" + }, + { + "path": "../controller-utils/tsconfig.build.json" + } + ], + "include": ["../../types", "./src"] +} diff --git a/packages/multichain-account-service/tsconfig.json b/packages/multichain-account-service/tsconfig.json new file mode 100644 index 00000000000..3942487f06e --- /dev/null +++ b/packages/multichain-account-service/tsconfig.json @@ -0,0 +1,27 @@ +{ + "extends": "../../tsconfig.packages.json", + "compilerOptions": { + "baseUrl": "./" + }, + "references": [ + { + "path": "../base-controller" + }, + { + "path": "../accounts-controller" + }, + { + "path": "../keyring-controller" + }, + { + "path": "../snap-account-service" + }, + { + "path": "../messenger" + }, + { + "path": "../controller-utils" + } + ], + "include": ["../../types", "./src"] +} diff --git a/packages/multichain-account-service/typedoc.json b/packages/multichain-account-service/typedoc.json new file mode 100644 index 00000000000..c9da015dbf8 --- /dev/null +++ b/packages/multichain-account-service/typedoc.json @@ -0,0 +1,7 @@ +{ + "entryPoints": ["./src/index.ts"], + "excludePrivate": true, + "hideGenerator": true, + "out": "docs", + "tsconfig": "./tsconfig.build.json" +} diff --git a/packages/multichain-api-middleware/CHANGELOG.md b/packages/multichain-api-middleware/CHANGELOG.md new file mode 100644 index 00000000000..067c4e81d72 --- /dev/null +++ b/packages/multichain-api-middleware/CHANGELOG.md @@ -0,0 +1,320 @@ +# 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] + +## [4.0.3] + +### Changed + +- Bump `@metamask/network-controller` from `^35.0.0` to `^36.0.0` ([#9758](https://github.com/MetaMask/core/pull/9758), [#9969](https://github.com/MetaMask/core/pull/9969)) +- Bump `@metamask/accounts-controller` from `^39.0.6` to `^39.1.1` ([#9791](https://github.com/MetaMask/core/pull/9791), [#9807](https://github.com/MetaMask/core/pull/9807), [#9969](https://github.com/MetaMask/core/pull/9969)) + +## [4.0.2] + +### Changed + +- Bump `@metamask/accounts-controller` from `^39.0.4` to `^39.0.6` ([#9470](https://github.com/MetaMask/core/pull/9470), [#9735](https://github.com/MetaMask/core/pull/9735)) +- Bump `@metamask/network-controller` from `^34.0.0` to `^35.0.0` ([#9735](https://github.com/MetaMask/core/pull/9735)) + +## [4.0.1] + +### Changed + +- Bump `@metamask/chain-agnostic-permission` from `^1.6.2` to `^1.7.0` ([#9399](https://github.com/MetaMask/core/pull/9399)) + - This aligns the declared dependency range with the version that provides `getSessionProperties`, which this package requires. + - This change should have been included in 4.0.0. + +## [4.0.0] [DEPRECATED] + +### Added + +- Add `MULTICHAIN_API.md`, a reference for the Multichain API: `wallet_createSession` and the other session methods, supported methods per namespace, error codes, and divergences from the current CAIP-25 spec ([#9258](https://github.com/MetaMask/core/pull/9258)) + +### Changed + +- **BREAKING:** The `wallet_getSession` and `wallet_createSession` handlers now require a `getCapabilities` hook (`(params: { address: string }) => Promise>>`) ([#9294](https://github.com/MetaMask/core/pull/9294)) + - `WalletGetSessionHooks` and `WalletCreateSessionHooks` now include this hook, which must be provided when wiring up the handlers. +- The `wallet_getSession` and `wallet_createSession` handlers now derive the returned `sessionProperties` via `getSessionProperties`, hydrating the persisted session properties with an `eip155Capabilities` record that maps each permitted EVM account address to its per-chain capabilities resolved from the `getCapabilities` hook ([#9294](https://github.com/MetaMask/core/pull/9294)) + - `wallet_getSession` now always includes a `sessionProperties` field in its result (an empty object when there is no active session). +- Bump `@metamask/accounts-controller` from `^39.0.2` to `^39.0.4` ([#9231](https://github.com/MetaMask/core/pull/9231), [#9349](https://github.com/MetaMask/core/pull/9349)) +- Bump `@metamask/network-controller` from `^33.0.0` to `^34.0.0` ([#9349](https://github.com/MetaMask/core/pull/9349)) + +## [3.1.5] + +### Changed + +- Bump `@metamask/accounts-controller` from `^39.0.1` to `^39.0.2` ([#9218](https://github.com/MetaMask/core/pull/9218)) +- Bump `@metamask/controller-utils` from `^12.2.0` to `^12.3.0` ([#9218](https://github.com/MetaMask/core/pull/9218)) +- Bump `@metamask/network-controller` from `^32.0.0` to `^33.0.0` ([#9218](https://github.com/MetaMask/core/pull/9218)) + +## [3.1.4] + +### Changed + +- Bump `@metamask/utils` from `^11.9.0` to `^11.11.0` ([#9074](https://github.com/MetaMask/core/pull/9074)) +- Bump `@metamask/controller-utils` from `^12.1.0` to `^12.2.0` ([#9058](https://github.com/MetaMask/core/pull/9058), [#9083](https://github.com/MetaMask/core/pull/9083)) +- Bump `@metamask/accounts-controller` from `^39.0.0` to `^39.0.1` ([#9058](https://github.com/MetaMask/core/pull/9058)) +- Bump `@metamask/api-specs` from `^0.14.0` to `^0.15.0` ([#9096](https://github.com/MetaMask/core/pull/9096)) +- Bump `@metamask/chain-agnostic-permission` from `^1.6.1` to `^1.6.2` ([#9103](https://github.com/MetaMask/core/pull/9103)) + +## [3.1.3] + +### Changed + +- Bump `@metamask/accounts-controller` from `^38.1.1` to `^39.0.0` ([#8912](https://github.com/MetaMask/core/pull/8912), [#8999](https://github.com/MetaMask/core/pull/8999)) + +## [3.1.2] + +### Changed + +- Bump `@metamask/network-controller` from `^31.0.0` to `^32.0.0` ([#8765](https://github.com/MetaMask/core/pull/8765), [#8774](https://github.com/MetaMask/core/pull/8774)) +- Bump `@metamask/accounts-controller` from `^38.1.0` to `^38.1.1` ([#8774](https://github.com/MetaMask/core/pull/8774)) +- Bump `@metamask/controller-utils` from `^12.0.0` to `^12.1.0` ([#8774](https://github.com/MetaMask/core/pull/8774)) + +## [3.1.1] + +### Changed + +- Bump `@metamask/json-rpc-engine` from `^10.4.0` to `^10.5.0` ([#8753](https://github.com/MetaMask/core/pull/8753)) +- Bump `@metamask/accounts-controller` from `^38.0.0` to `^38.1.0` ([#8755](https://github.com/MetaMask/core/pull/8755)) +- Bump `@metamask/chain-agnostic-permission` from `^1.6.0` to `^1.6.1` ([#8755](https://github.com/MetaMask/core/pull/8755)) +- Bump `@metamask/controller-utils` from `^11.20.0` to `^12.0.0` ([#8755](https://github.com/MetaMask/core/pull/8755)) +- Bump `@metamask/network-controller` from `^30.1.0` to `^31.0.0` ([#8755](https://github.com/MetaMask/core/pull/8755)) +- Bump `@metamask/permission-controller` from `^13.1.0` to `^13.1.1` ([#8755](https://github.com/MetaMask/core/pull/8755)) + +## [3.1.0] + +### Changed + +- Bump `@metamask/chain-agnostic-permission` from `^1.5.0` to `^1.6.0` ([#8749](https://github.com/MetaMask/core/pull/8749)) +- Bump `@metamask/multichain-transactions-controller` from `^7.0.4` to `^7.1.0` ([#8665](https://github.com/MetaMask/core/pull/8665)) +- Bump `@metamask/accounts-controller` from `^37.2.0` to `^38.0.0` ([#8665](https://github.com/MetaMask/core/pull/8665)) +- Bump `@metamask/permission-controller` from `^13.0.0` to `^13.1.0` ([#8722](https://github.com/MetaMask/core/pull/8722)) +- Bump `@metamask/json-rpc-engine` from `^10.3.0` to `^10.4.0` ([#8746](https://github.com/MetaMask/core/pull/8746)) + +## [3.0.0] + +### Added + +- Add `MethodHandlerHooks` type, the intersection of all method handler hook types ([#8583](https://github.com/MetaMask/core/pull/8583)) + - Consumers can use this to type the hooks object passed to `createMethodMiddleware` without restating each handler's hooks individually. + +### Changed + +- **BREAKING:** Consolidate method handlers into a single `methodHandlers` export ([#8583](https://github.com/MetaMask/core/pull/8583)) + - The individual handler exports have been removed. They can still be accessed as properties on the `methodHandlers` export. + - The new handlers follow the format expected by `createMethodMiddleware` from `@metamask/json-rpc-engine@10.3.0`. + - The hook types have been updated to cohere with their corresponding MetaMask controller methods. +- **BREAKING:** Make `trackSessionCreatedEvent` hook required in `wallet_createSession` handler ([#8583](https://github.com/MetaMask/core/pull/8583)) + - If the hook is not required, `null` can be passed instead. +- Bump `@metamask/json-rpc-engine` from `^10.2.3` to `^10.3.0` ([#8317](https://github.com/MetaMask/core/pull/8317), [#8661](https://github.com/MetaMask/core/pull/8661)) +- Bump `@metamask/network-controller` from `^30.0.0` to `^30.1.0` ([#8317](https://github.com/MetaMask/core/pull/8317), [#8636](https://github.com/MetaMask/core/pull/8636)) +- Bump `@metamask/permission-controller` from `^12.2.1` to `^13.0.0` ([#8317](https://github.com/MetaMask/core/pull/8317), [#8661](https://github.com/MetaMask/core/pull/8661)) +- Bump `@metamask/multichain-transactions-controller` from `^7.0.3` to `^7.0.4` ([#8325](https://github.com/MetaMask/core/pull/8325)) +- Bump `@metamask/controller-utils` from `^11.19.0` to `^11.20.0` ([#8344](https://github.com/MetaMask/core/pull/8344)) + +### Fixed + +- `wallet_invokeMethod` fails early with an `invalidParams` error when the `params` object is not an object ([#8583](https://github.com/MetaMask/core/pull/8583)) + - Previously it would fail with a less specific error. +- `wallet_revokeSession` now returns `true` when no active session exists and specific scopes are requested, consistent with its full-revoke behavior ([#8583](https://github.com/MetaMask/core/pull/8583)) + - Previously it would return an internal error. + +## [2.0.0] + +### Added + +- **BREAKING:** Add required `sortAccountIdsByLastSelected` hook to `wallet_getSession`, `wallet_createSession`, and `wallet_invokeMethod` handlers to enable custom account ordering in session scopes ([#8255](https://github.com/MetaMask/core/pull/8255)) + +### Changed + +- Bump `@metamask/chain-agnostic-permission` from `^1.4.0` to `^1.5.0` ([#8290](https://github.com/MetaMask/core/pull/8290)) +- Bump `@metamask/permission-controller` from `^12.2.0` to `^12.2.1` ([#8225](https://github.com/MetaMask/core/pull/8225)) +- Bump `@metamask/json-rpc-engine` from `^10.2.2` to `^10.2.3` ([#8078](https://github.com/MetaMask/core/pull/8078)) + +## [1.2.7] + +### Changed + +- Bump `@metamask/network-controller` from `^29.0.0` to `^30.0.0` ([#7996](https://github.com/MetaMask/core/pull/7996)) +- 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)) +- Bump `@metamask/controller-utils` from `^11.18.0` to `^11.19.0` ([#7995](https://github.com/MetaMask/core/pull/7995)) + +## [1.2.6] + +### Changed + +- Bump `@metamask/json-rpc-engine` from `^10.2.0` to `^10.2.1` ([#7642](https://github.com/MetaMask/core/pull/7642)) +- Upgrade `@metamask/utils` from `^11.8.1` to `^11.9.0` ([#7511](https://github.com/MetaMask/core/pull/7511)) +- Bump `@metamask/network-controller` from `^27.0.0` to `^29.0.0` ([#7534](https://github.com/MetaMask/core/pull/7534), [#7583](https://github.com/MetaMask/core/pull/7583), [#7604](https://github.com/MetaMask/core/pull/7604), [#7642](https://github.com/MetaMask/core/pull/7642)) +- 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)) +- Bump `@metamask/permission-controller` from `^12.1.1` to `^12.2.0` ([#7559](https://github.com/MetaMask/core/pull/7559)) +- Bump `@metamask/chain-agnostic-permission` from `^1.3.0` to `^1.4.0` ([#7567](https://github.com/MetaMask/core/pull/7567)) + +### Fixed + +- Fix `wallet_revokeSession` to handle cases where `params` is not provided ([#7551](https://github.com/MetaMask/core/pull/7551)) + +## [1.2.5] + +### Changed + +- Bump `@metamask/permission-controller` from `^12.1.0` to `^12.1.1` ([#7202](https://github.com/MetaMask/core/pull/7202)) +- Bump `@metamask/network-controller` from `^26.0.0` to `^27.0.0` ([#7202](https://github.com/MetaMask/core/pull/7202), [#7258](https://github.com/MetaMask/core/pull/7258)) +- Bump `@metamask/json-rpc-engine` from `^10.1.1` to `^10.2.0` ([#7202](https://github.com/MetaMask/core/pull/7202)) +- Bump `@metamask/controller-utils` from `^11.15.0` to `^11.16.0` ([#7202](https://github.com/MetaMask/core/pull/7202)) +- Bump `@metamask/chain-agnostic-permission` from `^1.2.2` to `^1.3.0` ([#7322](https://github.com/MetaMask/core/pull/7322)) + +## [1.2.4] + +### Changed + +- Bump `@metamask/permission-controller` from `^12.0.0` to `^12.1.0` ([#6988](https://github.com/MetaMask/core/pull/6988)) + +### Fixed + +- Fix `wallet_revokeSession` error handling ([#6987](https://github.com/MetaMask/core/pull/6987)) + - This was broken in a different way in v1.2.3. Fixed by the update to `@metamask/permission-controller@12.1.0`. + +## [1.2.3] + +### Changed + +- Bump `@metamask/network-controller` from `^24.3.1` to `^25.0.0` ([#6962](https://github.com/MetaMask/core/pull/6962)) +- Bump `@metamask/permission-controller` from `^11.1.1` to `^12.0.0` ([#6962](https://github.com/MetaMask/core/pull/6962)) +- Bump `@metamask/chain-agnostic-permission` from `^1.2.1` to `^1.2.2` ([#6986](https://github.com/MetaMask/core/pull/6986)) + +### Fixed + +- Fix `wallet_revokeSession` error handling in case where different versions of `@metamask/permission-controller` are used ([#6985](https://github.com/MetaMask/core/pull/6985)) + +## [1.2.2] + +### Changed + +- Bump `@metamask/chain-agnostic-permission` from `^1.2.0` to `^1.2.1` ([#6940](https://github.com/MetaMask/core/pull/6940)) +- Bump `@metamask/network-controller` from `^24.2.1` to `^24.3.1` ([#6845](https://github.com/MetaMask/core/pull/6845), [#6883](https://github.com/MetaMask/core/pull/6883), [#6940](https://github.com/MetaMask/core/pull/6940)) +- Bump `@metamask/permission-controller` from `^11.1.0` to `^11.1.1` ([#6940](https://github.com/MetaMask/core/pull/6940)) + +## [1.2.1] + +### Changed + +- Bump `@metamask/chain-agnostic-permission` from `^1.1.1` to `^1.2.0` ([#6807](https://github.com/MetaMask/core/pull/6807)) +- Bump `@metamask/controller-utils` from `^11.14.0` to `^11.14.1` ([#6807](https://github.com/MetaMask/core/pull/6807)) +- Bump `@metamask/json-rpc-engine` from `^10.1.0` to `^10.1.1` ([#6807](https://github.com/MetaMask/core/pull/6807)) +- Bump `@metamask/network-controller` from `^24.2.0` to `^24.2.1` ([#6807](https://github.com/MetaMask/core/pull/6807)) +- Bump `@metamask/permission-controller` from `^11.0.6` to `^11.1.0` ([#6807](https://github.com/MetaMask/core/pull/6807)) + +## [1.2.0] + +### Changed + +- Bump `@metamask/utils` from `^11.8.0` to `^11.8.1` ([#6708](https://github.com/MetaMask/core/pull/6708)) +- `wallet_invokeMethod` no longer fails with unauthorized error if the `isMultichainOrigin` property is false on the requesting origin's CAIP-25 Permission ([#6703](https://github.com/MetaMask/core/pull/6703)) + +## [1.1.0] + +### Changed + +- Add partial permission revoke into `wallet_revokeSession` ([#6668](https://github.com/MetaMask/core/pull/6668)) +- Bump `@metamask/chain-agnostic-permission` from `1.0.0` to `1.1.1` ([#6241](https://github.com/MetaMask/core/pull/6241), [#6345](https://github.com/MetaMask/core/pull/6345)) +- Bump `@metamask/controller-utils` from `^11.10.0` to `^11.14.0` ([#6069](https://github.com/MetaMask/core/pull/6069), [#6303](https://github.com/MetaMask/core/pull/6303), [#6620](https://github.com/MetaMask/core/pull/6620), [#6629](https://github.com/MetaMask/core/pull/6629)) +- Bump `@metamask/network-controller` from `^24.0.0` to `^24.2.0` ([#6148](https://github.com/MetaMask/core/pull/6148), [#6303](https://github.com/MetaMask/core/pull/6303), [#6678](https://github.com/MetaMask/core/pull/6678)) +- Bump `@metamask/utils` from `^11.2.0` to `^11.4.2` ([#6054](https://github.com/MetaMask/core/pull/6054)) +- Bump `@metamask/utils` from `^11.4.2` to `^11.8.0` ([#6588](https://github.com/MetaMask/core/pull/6588)) +- Bump `@metamask/json-rpc-engine` from `^10.0.3` to `^10.1.0` ([#6678](https://github.com/MetaMask/core/pull/6678)) + +## [1.0.0] + +### Changed + +- This package is now considered stable ([#6013](https://github.com/MetaMask/core/pull/6013)) +- Bump `@metamask/multichain-transactions-controller` to `^2.0.0` ([#5888](https://github.com/MetaMask/core/pull/5888)) +- Bump `@metamask/controller-utils` to `^11.10.0` ([#5935](https://github.com/MetaMask/core/pull/5935)) +- Bump `@metamask/network-controller` to `^23.6.0` ([#5935](https://github.com/MetaMask/core/pull/5935), [#5882](https://github.com/MetaMask/core/pull/5882)) +- Bump `@metamask/chain-agnostic-permission` to `^1.0.0` ([#6013](https://github.com/MetaMask/core/pull/6013), [#5982](https://github.com/MetaMask/core/pull/5982), [#6004](https://github.com/MetaMask/core/pull/6004)) +- Bump `@metamask/network-controller` to `^24.0.0` ([#5999](https://github.com/MetaMask/core/pull/5999)) + +## [0.4.0] + +### Added + +- When `wallet_createSession` handler is called with `solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp` (solana mainnet) as a requested scope, but there are not currently any accounts in the wallet supporting this scope, we now add a `promptToCreateSolanaAccount` to a metadata object on the `requestPermissions` call forwarded to the `PermissionsController`. + +## [0.3.0] + +### Added + +- Add more chain-agnostic-permission utility functions from sip-26 usage ([#5609](https://github.com/MetaMask/core/pull/5609)) + +### Changed + +- Bump `@metamask/chain-agnostic-permission` to `^0.7.0` ([#5715](https://github.com/MetaMask/core/pull/5715), [#5760](https://github.com/MetaMask/core/pull/5760), [#5818](https://github.com/MetaMask/core/pull/5818)) +- Bump `@metamask/api-specs` to `^0.14.0` ([#5817](https://github.com/MetaMask/core/pull/5817)) +- Bump `@metamask/controller-utils` to `^11.9.0` ([#5765](https://github.com/MetaMask/core/pull/5765), [#5812](https://github.com/MetaMask/core/pull/5812)) +- Bump `@metamask/network-controller` to `^23.5.0` ([#5765](https://github.com/MetaMask/core/pull/5765), [#5812](https://github.com/MetaMask/core/pull/5812)) + +## [0.2.0] + +### Added + +- Add `wallet_createSession` handler ([#5647](https://github.com/MetaMask/core/pull/5647)) +- Add `Caip25Errors` from `@metamask/chain-agnostic-permission` package ([#5566](https://github.com/MetaMask/core/pull/5566)) + +### Changed + +- Bump `@metamask/chain-agnostic-permission` to `^0.4.0` ([#5674](https://github.com/MetaMask/core/pull/5674)) +- Bump `@metamask/network-controller` to `^23.2.0` ([#5583](https://github.com/MetaMask/core/pull/5583)) + +## [0.1.1] + +### Added + +- Add `MultichainApiNotifications` enum to standardize notification method names ([#5491](https://github.com/MetaMask/core/pull/5491)) + +### Changed + +- Bump `@metamask/network-controller` to `^23.1.0` ([#5507](https://github.com/MetaMask/core/pull/5507), [#5518](https://github.com/MetaMask/core/pull/5518)) +- Bump `@metamask/chain-agnostic-permission` to `^0.2.0` ([#5518](https://github.com/MetaMask/core/pull/5518)) + +## [0.1.0] + +### Added + +- Initial release + +[Unreleased]: https://github.com/MetaMask/core/compare/@metamask/multichain-api-middleware@4.0.3...HEAD +[4.0.3]: https://github.com/MetaMask/core/compare/@metamask/multichain-api-middleware@4.0.2...@metamask/multichain-api-middleware@4.0.3 +[4.0.2]: https://github.com/MetaMask/core/compare/@metamask/multichain-api-middleware@4.0.1...@metamask/multichain-api-middleware@4.0.2 +[4.0.1]: https://github.com/MetaMask/core/compare/@metamask/multichain-api-middleware@4.0.0...@metamask/multichain-api-middleware@4.0.1 +[4.0.0]: https://github.com/MetaMask/core/compare/@metamask/multichain-api-middleware@3.1.5...@metamask/multichain-api-middleware@4.0.0 +[3.1.5]: https://github.com/MetaMask/core/compare/@metamask/multichain-api-middleware@3.1.4...@metamask/multichain-api-middleware@3.1.5 +[3.1.4]: https://github.com/MetaMask/core/compare/@metamask/multichain-api-middleware@3.1.3...@metamask/multichain-api-middleware@3.1.4 +[3.1.3]: https://github.com/MetaMask/core/compare/@metamask/multichain-api-middleware@3.1.2...@metamask/multichain-api-middleware@3.1.3 +[3.1.2]: https://github.com/MetaMask/core/compare/@metamask/multichain-api-middleware@3.1.1...@metamask/multichain-api-middleware@3.1.2 +[3.1.1]: https://github.com/MetaMask/core/compare/@metamask/multichain-api-middleware@3.1.0...@metamask/multichain-api-middleware@3.1.1 +[3.1.0]: https://github.com/MetaMask/core/compare/@metamask/multichain-api-middleware@3.0.0...@metamask/multichain-api-middleware@3.1.0 +[3.0.0]: https://github.com/MetaMask/core/compare/@metamask/multichain-api-middleware@2.0.0...@metamask/multichain-api-middleware@3.0.0 +[2.0.0]: https://github.com/MetaMask/core/compare/@metamask/multichain-api-middleware@1.2.7...@metamask/multichain-api-middleware@2.0.0 +[1.2.7]: https://github.com/MetaMask/core/compare/@metamask/multichain-api-middleware@1.2.6...@metamask/multichain-api-middleware@1.2.7 +[1.2.6]: https://github.com/MetaMask/core/compare/@metamask/multichain-api-middleware@1.2.5...@metamask/multichain-api-middleware@1.2.6 +[1.2.5]: https://github.com/MetaMask/core/compare/@metamask/multichain-api-middleware@1.2.4...@metamask/multichain-api-middleware@1.2.5 +[1.2.4]: https://github.com/MetaMask/core/compare/@metamask/multichain-api-middleware@1.2.3...@metamask/multichain-api-middleware@1.2.4 +[1.2.3]: https://github.com/MetaMask/core/compare/@metamask/multichain-api-middleware@1.2.2...@metamask/multichain-api-middleware@1.2.3 +[1.2.2]: https://github.com/MetaMask/core/compare/@metamask/multichain-api-middleware@1.2.1...@metamask/multichain-api-middleware@1.2.2 +[1.2.1]: https://github.com/MetaMask/core/compare/@metamask/multichain-api-middleware@1.2.0...@metamask/multichain-api-middleware@1.2.1 +[1.2.0]: https://github.com/MetaMask/core/compare/@metamask/multichain-api-middleware@1.1.0...@metamask/multichain-api-middleware@1.2.0 +[1.1.0]: https://github.com/MetaMask/core/compare/@metamask/multichain-api-middleware@1.0.0...@metamask/multichain-api-middleware@1.1.0 +[1.0.0]: https://github.com/MetaMask/core/compare/@metamask/multichain-api-middleware@0.4.0...@metamask/multichain-api-middleware@1.0.0 +[0.4.0]: https://github.com/MetaMask/core/compare/@metamask/multichain-api-middleware@0.3.0...@metamask/multichain-api-middleware@0.4.0 +[0.3.0]: https://github.com/MetaMask/core/compare/@metamask/multichain-api-middleware@0.2.0...@metamask/multichain-api-middleware@0.3.0 +[0.2.0]: https://github.com/MetaMask/core/compare/@metamask/multichain-api-middleware@0.1.1...@metamask/multichain-api-middleware@0.2.0 +[0.1.1]: https://github.com/MetaMask/core/compare/@metamask/multichain-api-middleware@0.1.0...@metamask/multichain-api-middleware@0.1.1 +[0.1.0]: https://github.com/MetaMask/core/releases/tag/@metamask/multichain-api-middleware@0.1.0 diff --git a/packages/multichain-api-middleware/LICENSE b/packages/multichain-api-middleware/LICENSE new file mode 100644 index 00000000000..c259cd7ebcf --- /dev/null +++ b/packages/multichain-api-middleware/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2025 MetaMask + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packages/multichain-api-middleware/MULTICHAIN_API.md b/packages/multichain-api-middleware/MULTICHAIN_API.md new file mode 100644 index 00000000000..ac43df687dc --- /dev/null +++ b/packages/multichain-api-middleware/MULTICHAIN_API.md @@ -0,0 +1,368 @@ +# The MetaMask Multichain API + +This is high-level reference documentation for MetaMask's CAIP-25 / CAIP-27 based +Multichain API. The API is powered by `@metamask/multichain-api-middleware` and +`@metamask/chain-agnostic-permission`, and is implemented on both the MetaMask +extension and mobile clients. + +> **Audience.** This document describes the **wallet's JSON-RPC contract**: the +> requests a caller (dapp / SDK) sends and the responses MetaMask returns. If you +> are integrating a dapp, you usually want the +> [MetaMask Connect SDK](https://github.com/MetaMask/connect-monorepo) instead, +> which wraps this API. This is the layer underneath that. + +> **Source of truth.** Behavior is described from the implementation in this +> package (`src/handlers/*.ts`) and `@metamask/chain-agnostic-permission`. The +> machine-readable schema lives in +> [`@metamask/api-specs`](https://github.com/MetaMask/api-specs) +> (`multichain/openrpc.yaml`). Where this prose and the OpenRPC schema disagree, +> the handler code is authoritative; please file an issue so we can reconcile +> them. + +## Contents + +- [Overview](#overview) +- [Concepts](#concepts) +- [Methods](#methods) + - [`wallet_createSession`](#wallet_createsession) + - [`wallet_getSession`](#wallet_getsession) + - [`wallet_revokeSession`](#wallet_revokesession) + - [`wallet_invokeMethod`](#wallet_invokemethod) +- [Notifications](#notifications) + - [`wallet_sessionChanged`](#wallet_sessionchanged) + - [`wallet_notify`](#wallet_notify) +- [Supported methods & notifications per namespace](#supported-methods--notifications-per-namespace) +- [Error codes](#error-codes) +- [Divergences from current CAIP-25](#divergences-from-current-caip-25) +- [MetaMask-specific behavior](#metamask-specific-behavior) +- [Source-of-truth pointers](#source-of-truth-pointers) + +## Overview + +The Multichain API lets a caller negotiate a single **session** that spans +multiple chains and ecosystems (EVM, Solana, Bitcoin, Tron), and multiple accounts +across those scopes, in one authorization, then invoke methods on any authorized +scope. It replaces the per-chain EIP-1193 model (`eth_requestAccounts` on one chain +at a time) with a chain-agnostic, scope-based model. + +It is built on the CASA Chain Agnostic standards: + +- **[CAIP-25](https://chainagnostic.org/CAIPs/caip-25)**: `wallet_createSession`, session negotiation +- **[CAIP-27](https://chainagnostic.org/CAIPs/caip-27)**: `wallet_invokeMethod`, invoking a method on a scope +- **[CAIP-285](https://chainagnostic.org/CAIPs/caip-285)**: `wallet_revokeSession` +- **[CAIP-311](https://chainagnostic.org/CAIPs/caip-311)**: `wallet_sessionChanged` +- **[CAIP-312](https://chainagnostic.org/CAIPs/caip-312)**: `wallet_getSession` +- **[CAIP-2](https://chainagnostic.org/CAIPs/caip-2)** / **[CAIP-10](https://chainagnostic.org/CAIPs/caip-10)** / **[CAIP-217](https://chainagnostic.org/CAIPs/caip-217)**: chain IDs, account IDs, scope objects + +For MetaMask's design rationale see +[MIP-5](https://github.com/MetaMask/metamask-improvement-proposals/blob/main/MIPs/mip-5.md). +[MIP-6](https://github.com/MetaMask/metamask-improvement-proposals/blob/main/MIPs/mip-6.md) +is **historical**; it predates the current implementation and the upstream CAIP-25 +rewrite, so don't rely on it for current behavior. + +> ⚠️ **CAIP-25 moved; MetaMask has not caught up (yet).** Upstream CAIP-25 was restructured +> in July to August 2025 (single `scopes`, `properties`/`capabilities` renames, bare +> accounts, chain-only scope keys). MetaMask still implements the **pre-rewrite** +> shape (`requiredScopes`/`optionalScopes`, `sessionProperties`, +> CAIP-10 accounts, namespace-scoped keys). See +> [Divergences from current CAIP-25](#divergences-from-current-caip-25). + +## Concepts + +- **Scope string**: a CAIP-2 chain id (`eip155:1`, `solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp`) + or a CAIP-104 namespace-level scope (`wallet`, `wallet:eip155`). Pattern: + `[-a-z0-9]{3,8}(:[-_a-zA-Z0-9]{1,32})?`. +- **Scope object**: per CAIP-217, an object with `methods`, `notifications`, and + (in responses) `accounts`. In requests it may also carry `references` (namespace + shorthand). Keyed by scope string. +- **Account**: a fully-qualified **CAIP-10** id in MetaMask: `eip155:1:0xabc...`, + `solana:5eykt...:6Lm...`. +- **Session**: the set of granted scopes for an origin. MetaMask stores this as a + single CAIP-25 permission caveat per origin and **does not** issue or accept a + `sessionId` (one session per origin, tracked internally). +- **`sessionProperties`**: global session metadata (allowlisted; see below). + +## Methods + +### `wallet_createSession` + +Prompts the user and grants a CAIP-25 session. `paramStructure: by-name`. + +**Params** + +| Field | Type | Required | Notes | +| ------------------- | -------------------------------- | ----------- | ----------------------------------------------------------------------------------------------------------------------------- | +| `requiredScopes` | `{ [scopeString]: ScopeObject }` | conditional | Accepted but **treated as optional** (see divergences). | +| `optionalScopes` | `{ [scopeString]: ScopeObject }` | conditional | | +| `sessionProperties` | `{ [key]: Json }` | no | Allowlist-filtered to [known keys](#supported-methods--notifications-per-namespace). An empty object is rejected with `5302`. | + +At least one of `requiredScopes` / `optionalScopes` must be present and resolve to +a supported scope; a request with neither (or with only unsupported scopes) is +rejected with `5100`. + +`ScopeObject` fields: `methods: string[]`, `notifications: string[]`, +optionally `accounts: CaipAccountId[]` and `references: string[]`. + +**Result** + +```jsonc +{ + "sessionScopes": { "": { "accounts": [...], "methods": [...], "notifications": [...] } }, + "sessionProperties": { /* approved, may be {} */ } +} +``` + +**Example request** + +```jsonc +{ + "id": 1, + "jsonrpc": "2.0", + "method": "wallet_createSession", + "params": { + "optionalScopes": { + "eip155:1": { + "methods": ["eth_sendTransaction", "personal_sign", "eth_getBalance"], + "notifications": ["eth_subscription"], + }, + "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp": { + "methods": ["signMessage", "signAndSendTransaction"], + "notifications": [], + }, + }, + }, +} +``` + +**Example response** + +```jsonc +{ + "id": 1, + "jsonrpc": "2.0", + "result": { + "sessionProperties": {}, + "sessionScopes": { + "eip155:1": { + "accounts": ["eip155:1:0x5cfe73b6021e818b776b421b1c4db2474086a7e1"], + "methods": ["eth_sendTransaction", "personal_sign", "eth_getBalance"], + "notifications": ["eth_subscription"], + }, + }, + }, +} +``` + +**Behavior notes** + +- All requested scopes are treated as optional; unsupported scopes, unknown + methods/notifications, and accounts not held by the wallet are **silently + dropped** rather than erroring. +- If, after filtering, **no** scopes remain, it returns `5100` (Requested scopes + are not supported). + +### `wallet_getSession` + +Returns the active session for the origin. `params: []`. + +**Result:** `{ "sessionScopes": { ... } }`. If there is **no** active session, +returns `{ "sessionScopes": {} }` (does **not** throw). Any `sessionId` param is +ignored. + +### `wallet_revokeSession` + +Revokes the session for the origin. Returns `true`. + +- With no params (or empty `scopes`), revokes the entire CAIP-25 permission. +- Accepts an optional `params.scopes: string[]` for **partial** revocation + (implemented in this middleware handler, `partialRevokePermissions`); each + listed scope is removed; if no permitted accounts + remain afterward, the whole permission is revoked. +- Returns `true` even when there was no active session. Any `sessionId` param is + ignored. + +### `wallet_invokeMethod` + +Invokes a method on a previously authorized scope (CAIP-27). `paramStructure: by-name`. + +**Params** + +| Field | Type | Required | Notes | +| --------- | ----------------------------------- | -------- | --------------------------------------------------- | +| `scope` | `ScopeString` | yes | Must be an authorized scope in the current session. | +| `request` | `{ method: string, params?: Json }` | yes | The wrapped JSON-RPC request. | + +**Result:** whatever the underlying method returns. + +**Behavior notes** + +- If the origin has no CAIP-25 caveat, returns `4100` (unauthorized). +- If `request.method` is not in the authorized scope's `methods`, returns `4100`. +- EVM requests (`eip155:*`, or `wallet` / `wallet:eip155`) are routed to the + resolved `networkClientId` and passed down the middleware stack; non-EVM + requests are dispatched to the multichain router. Any `sessionId` param is + ignored; the origin's single session is used. + +**Example** + +```jsonc +{ + "id": 2, + "jsonrpc": "2.0", + "method": "wallet_invokeMethod", + "params": { + "scope": "eip155:1", + "request": { + "method": "eth_getBalance", + "params": ["0x5cfe...", "latest"], + }, + }, +} +``` + +## Notifications + +### `wallet_sessionChanged` + +Published by the wallet when a session's authorization scopes change (accounts, +scopes added/removed, restoration). `paramStructure: by-name`. Payload: +`{ "sessionScopes": { ... } }` with the full updated scopes. + +### `wallet_notify` + +Delivers a scope-bound notification to the caller. Params: `scope` (an authorized +scope string) and `notification` (`{ method, params }`). Used to forward +subscription events such as `eth_subscription`. + +## Supported methods & notifications per namespace + +How a method gets into a session's `methods` array depends on the namespace. + +### EVM (`eip155`): static, from `api-specs` + +EVM method support is enumerated statically in +`@metamask/chain-agnostic-permission` (`src/scope/constants.ts`). + +| List | Scope | Contents | +| --------------------------------------- | ------------------ | ------------------------------------------------------------------------------------------------------------------- | +| `KnownRpcMethods.eip155` | `eip155:` | All MetaMask JSON-RPC methods from `@metamask/api-specs`, **minus** the wallet-scoped and EIP-1193-only lists below | +| `KnownWalletNamespaceRpcMethods.eip155` | `wallet:eip155` | `wallet_addEthereumChain` | +| `KnownWalletRpcMethods` | `wallet` | `wallet_registerOnboarding`, `wallet_scanQRCode` | +| `KnownNotifications.eip155` | `eip155:` | `eth_subscription` | + +**EIP-1193-only methods** (`Eip1193OnlyMethods`): explicitly **excluded** from the +Multichain API; available only via the injected EIP-1193 provider: +`wallet_switchEthereumChain`, `wallet_getPermissions`, `wallet_requestPermissions`, +`wallet_revokePermissions`, `eth_requestAccounts`, `eth_accounts`, `eth_coinbase`, +`net_version`, `metamask_logWeb3ShimUsage`, `metamask_getProviderState`, +`metamask_sendDomainMetadata`, `wallet_registerOnboarding`. + +### Non-EVM (`solana`, `bip122`, `tron`): dynamic, from Snaps + +`KnownRpcMethods` / `KnownNotifications` are **empty** for non-EVM namespaces. Their +supported methods are resolved **at runtime** through the handler's +`getNonEvmSupportedMethods(scope)` hook, which the wallet wires to the Snaps +subsystem. + +In the extension, that hook calls +`MultichainRoutingService:getSupportedMethods(scope)` +(`@metamask/snaps-controllers`), which returns the **union** of: + +1. **Account-Snap methods**: methods declared by installed account-management + Snaps that hold an account for that scope (via + `AccountsController:listMultichainAccounts`, filtered to runnable Snaps), and +2. **Protocol-Snap methods**: methods declared by protocol Snaps that service the + scope. + +```text +getNonEvmSupportedMethods(scope) + └─ MultichainRoutingService.getSupportedMethods(scope) + = unique( accountSnap.methods[] ∪ protocolSnap.methods[] ) +``` + +Consequently the non-EVM method set depends on which Snaps the user has installed +and which accounts they hold; there is no fixed wallet-wide list. Scope support is +likewise dynamic: `isNonEvmScopeSupported(scope)` is true when at least one Snap can +service the scope. + +**Example (Solana, via the MetaMask Solana Snap).** Methods are exposed using +[Wallet Standard](https://github.com/wallet-standard/wallet-standard) naming, e.g. +`signIn`, `signMessage`, `signTransaction`, `signAndSendTransaction`, +`signAllTransactions`. These are provided by the Snap, not hardcoded here, so treat +the list as illustrative and verify against the installed Snap's manifest. + +### Known `sessionProperties` keys + +`wallet_createSession` filters `sessionProperties` to the `KnownSessionProperties` +allowlist; unknown keys are dropped: + +| Key | Purpose | +| ------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `eip1193-compatible` | Marks the connection as originating from an EIP-1193 client (injected `window.ethereum` middleware or `@metamask/connect-evm`). The extension uses it to gate EVM-connection UX such as the network picker on the dapp connection bar. Newly-created pure Multichain API sessions (even EVM-only ones) do not set it; note the extension also backfills it onto pre-existing connections with any `eip155:*` scope (migration 211), so older Multichain-only EVM connections may carry it. | +| `solana_accountChanged_notifications` | Opt-in to `accountChanged` notifications for Solana scopes. | +| `tron_accountChanged_notifications` | Opt-in to `accountChanged` notifications for Tron scopes. | +| `bip122_accountChanged_notifications` | Opt-in to `accountChanged` notifications for Bitcoin scopes. | + +## Error codes + +| Code | Message | When | +| ------ | ----------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `5000` | Unknown error with request | Generic failure. | +| `5100` | Requested scopes are not supported | Actually returned by `wallet_createSession` when no supported scopes remain after filtering. | +| `5302` | Invalid sessionProperties requested | Returned by `wallet_createSession` when `sessionProperties` is present but an empty object `{}`. | +| `4100` | Unauthorized | Returned by `wallet_invokeMethod` when the origin has no CAIP-25 session, or the requested scope/method is not authorized (`providerErrors.unauthorized()`). | + +The OpenRPC schema and `@metamask/chain-agnostic-permission` define additional codes +(`5101`, `5102`, `5201`, `5202`, `5300`, `5301`) that the current +`wallet_createSession` handler does not emit, so callers should not expect them on +the wire. + +## Divergences from current CAIP-25 + +CAIP-25 was restructured upstream in July to August 2025 (see the spec's own +changelog). MetaMask implements the **pre-rewrite** shape. Verified against the +current [CAIP-25 spec](https://github.com/ChainAgnostic/CAIPs/blob/main/CAIPs/caip-25.md) +and this package's handlers: + +| Concept | Current CAIP-25 | MetaMask implementation | +| --------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Request scopes | Single `scopes` (`optionalScopes` → `scopes`, 2025-07-30; `requiredScopes` removed 2025-07-31) | Still `requiredScopes` + `optionalScopes`; **all treated as optional** | +| Session metadata key | `properties` (renamed from `sessionProperties`, 2025-07-30) | Still `sessionProperties`; **allowlist-filtered** to known keys | +| Per-scope request extras (`scopedProperties`) | Removed from the request: `scopedProperties` became `capabilities` (2025-07-30), merged into the scope object (2025-08-04), then dropped from the request entirely (2025-08-07). A response-only `capabilities` remains. | **Abandoned.** Intended for EIP-3085-style dynamic chain addition; partly implemented then deprioritized. Lives in the OpenRPC schema (error codes `5300`/`5301`) and the `Caip25Authorization` type, but the handler never reads it. Stranded; candidate for removal from `api-specs`. | +| Scope granularity | Chain-scoped only (namespace-scoped removed 2025-08-03) | Uses namespace-scoped objects (`wallet:eip155`) and a `references` shorthand array | +| Accounts format | Bare addresses; CAIP-2 prefix removed (2025-08-07) | Fully-qualified **CAIP-10** (`eip155:1:0x...`) | +| `sessionId` | Optional, supported (CAIP-171 / CAIP-316) | **Not** returned or accepted; one session per origin, tracked internally | +| `chains` shorthand | `chains: string[]` inside the scope object | `references: string[]` (older CAIP-217 shorthand) | +| Invalid input | MAY error | **Silently dropped** (invalid scopes/methods/accounts) | + +## MetaMask-specific behavior + +- **All scopes optional.** `requiredScopes` are not enforced as required; the + handler buckets everything and grants whatever is supported. +- **Lenient filtering.** Malformed scopes and unknown methods/notifications/accounts + are dropped instead of erroring (reduces fingerprinting and breakage). +- **`sessionProperties` allowlist.** Only the keys in `KnownSessionProperties` are + retained; an explicitly empty `sessionProperties: {}` errors with `5302`. +- **Single session per origin.** `sessionId` is ignored across `getSession`, + `revokeSession`, and `invokeMethod`. +- **Graceful no-session results.** `wallet_getSession` returns + `{ sessionScopes: {} }` and `wallet_revokeSession` returns `true` even with no + active session. +- **Partial revoke.** `wallet_revokeSession` accepts an optional `scopes` array to + remove individual scopes; full revoke happens automatically if no accounts remain. + +## Source-of-truth pointers + +- **Handlers:** `src/handlers/wallet-createSession.ts`, `wallet-getSession.ts`, + `wallet-revokeSession.ts`, `wallet-invokeMethod.ts` +- **Scope/permission semantics, constants, error codes:** + [`@metamask/chain-agnostic-permission`](https://github.com/MetaMask/core/tree/main/packages/chain-agnostic-permission) + (`src/scope/constants.ts`, `src/scope/errors.ts`) +- **OpenRPC schema:** + [`@metamask/api-specs`](https://github.com/MetaMask/api-specs) → + `multichain/openrpc.yaml` +- **Design rationale:** + [MIP-5](https://github.com/MetaMask/metamask-improvement-proposals/blob/main/MIPs/mip-5.md) + (MIP-6 is historical) +- **Dapp/SDK consumer docs:** + [MetaMask Connect](https://github.com/MetaMask/connect-monorepo) diff --git a/packages/multichain-api-middleware/README.md b/packages/multichain-api-middleware/README.md new file mode 100644 index 00000000000..3f6d2207ec9 --- /dev/null +++ b/packages/multichain-api-middleware/README.md @@ -0,0 +1,23 @@ +# `@metamask/multichain-api-middleware` + +JSON-RPC methods and middleware to support the the [MetaMask Multichain API](https://github.com/MetaMask/metamask-improvement-proposals/blob/main/MIPs/mip-5.md). + +## Documentation + +See [`MULTICHAIN_API.md`](./MULTICHAIN_API.md) for a readable, wallet-side +reference of the Multichain API as implemented here: `wallet_createSession` inputs +and outputs, supported methods per namespace, error codes, and how MetaMask +currently diverges from the latest CAIP-25. The machine-readable schema lives in +[`@metamask/api-specs`](https://github.com/MetaMask/api-specs). + +## Installation + +`yarn add @metamask/multichain-api-middleware` + +or + +`npm install @metamask/multichain-api-middleware` + +## Contributing + +This package is part of a monorepo. Instructions for contributing can be found in the [monorepo README](https://github.com/MetaMask/core#readme). diff --git a/packages/multichain-api-middleware/jest.config.js b/packages/multichain-api-middleware/jest.config.js new file mode 100644 index 00000000000..a6816cce33a --- /dev/null +++ b/packages/multichain-api-middleware/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: 94.06, + functions: 100, + lines: 100, + statements: 100, + }, + }, +}); diff --git a/packages/multichain-api-middleware/package.json b/packages/multichain-api-middleware/package.json new file mode 100644 index 00000000000..d1d2a92001b --- /dev/null +++ b/packages/multichain-api-middleware/package.json @@ -0,0 +1,88 @@ +{ + "name": "@metamask/multichain-api-middleware", + "version": "4.0.3", + "description": "JSON-RPC methods and middleware to support the MetaMask Multichain API", + "keywords": [ + "Ethereum", + "MetaMask" + ], + "homepage": "https://github.com/MetaMask/core/tree/main/packages/multichain-api-middleware#readme", + "bugs": { + "url": "https://github.com/MetaMask/core/issues" + }, + "license": "MIT", + "repository": { + "type": "git", + "url": "https://github.com/MetaMask/core.git" + }, + "files": [ + "dist/" + ], + "sideEffects": false, + "main": "./dist/index.cjs", + "types": "./dist/index.d.cts", + "exports": { + ".": { + "import": { + "types": "./dist/index.d.mts", + "default": "./dist/index.mjs" + }, + "require": { + "types": "./dist/index.d.cts", + "default": "./dist/index.cjs" + } + }, + "./package.json": "./package.json" + }, + "publishConfig": { + "access": "public", + "registry": "https://registry.npmjs.org/" + }, + "scripts": { + "build": "ts-bridge --project tsconfig.build.json --verbose --clean --no-references", + "build:all": "ts-bridge --project tsconfig.build.json --verbose --clean", + "build:docs": "typedoc", + "changelog:update": "../../scripts/update-changelog.sh @metamask/multichain-api-middleware", + "changelog:validate": "../../scripts/validate-changelog.sh @metamask/multichain-api-middleware", + "lint:tsconfigs": "tsx ../../scripts/lint-tsconfigs/lint-tsconfigs.mts", + "lint:tsconfigs:fix": "tsx ../../scripts/lint-tsconfigs/lint-tsconfigs.mts --fix", + "since-latest-release": "../../scripts/since-latest-release.sh", + "test": "NODE_OPTIONS=--experimental-vm-modules jest --reporters=jest-silent-reporter", + "test:clean": "NODE_OPTIONS=--experimental-vm-modules jest --clearCache", + "test:verbose": "NODE_OPTIONS=--experimental-vm-modules jest --verbose", + "test:watch": "NODE_OPTIONS=--experimental-vm-modules jest --watch" + }, + "dependencies": { + "@metamask/accounts-controller": "^39.1.1", + "@metamask/api-specs": "^0.15.0", + "@metamask/chain-agnostic-permission": "^1.7.0", + "@metamask/controller-utils": "^12.3.0", + "@metamask/json-rpc-engine": "^10.5.0", + "@metamask/network-controller": "^36.0.0", + "@metamask/permission-controller": "^13.1.1", + "@metamask/rpc-errors": "^7.0.2", + "@metamask/snaps-controllers": "^19.0.0", + "@metamask/utils": "^11.11.0", + "@open-rpc/meta-schema": "^1.14.6", + "@open-rpc/schema-utils-js": "^2.0.5", + "jsonschema": "^1.4.1" + }, + "devDependencies": { + "@metamask/auto-changelog": "^6.1.0", + "@metamask/eth-json-rpc-filters": "^9.0.0", + "@metamask/multichain-transactions-controller": "^7.1.2", + "@metamask/safe-event-emitter": "^3.0.0", + "@ts-bridge/cli": "^0.6.4", + "@types/jest": "^30.0.0", + "deepmerge": "^4.2.2", + "jest": "^30.4.2", + "ts-jest": "^29.4.11", + "tsx": "^4.20.5", + "typedoc": "^0.25.13", + "typedoc-plugin-missing-exports": "^2.0.0", + "typescript": "~5.3.3" + }, + "engines": { + "node": "^18.18 || >=20" + } +} diff --git a/packages/multichain-api-middleware/src/handlers/index.test.ts b/packages/multichain-api-middleware/src/handlers/index.test.ts new file mode 100644 index 00000000000..94662dfb8c7 --- /dev/null +++ b/packages/multichain-api-middleware/src/handlers/index.test.ts @@ -0,0 +1,58 @@ +import { createMethodMiddleware } from '@metamask/json-rpc-engine'; + +import { methodHandlers } from './index.js'; +import type { WalletCreateSessionHooks } from './wallet-createSession.js'; +import type { WalletGetSessionHooks } from './wallet-getSession.js'; +import type { WalletInvokeMethodHooks } from './wallet-invokeMethod.js'; +import type { WalletRevokeSessionHooks } from './wallet-revokeSession.js'; + +type Hooks = WalletCreateSessionHooks & + WalletGetSessionHooks & + WalletInvokeMethodHooks & + WalletRevokeSessionHooks; + +/* eslint-disable @typescript-eslint/explicit-function-return-type */ +const makeMockHooks = () => + ({ + listAccounts: () => [ + { + type: 'eip155:eoa', + address: '0x123', + id: '1', + options: {}, + scopes: [], + methods: [], + metadata: { + name: 'Account 1', + importTime: Date.now(), + keyring: { type: 'HD Key Tree' }, + }, + }, + ], + findNetworkClientIdByChainId: (() => + '1') as Hooks['findNetworkClientIdByChainId'], + requestPermissionsForOrigin: () => + Promise.resolve([{}, { id: '1', origin: 'test' }]), + getNonEvmSupportedMethods: () => [], + isNonEvmScopeSupported: () => false, + getNonEvmAccountAddresses: () => [], + sortAccountIdsByLastSelected: () => [], + getCapabilities: () => Promise.resolve({}), + getCaveatForOrigin: (() => ({}) as unknown) as Hooks['getCaveatForOrigin'], + getSelectedNetworkClientId: () => 'mainnet', + handleNonEvmRequestForOrigin: () => Promise.resolve(null), + revokePermissionForOrigin: () => undefined, + updateCaveat: () => undefined, + trackSessionCreatedEvent: null, + }) satisfies Hooks; +/* eslint-enable @typescript-eslint/explicit-function-return-type */ + +describe('methodHandlers', () => { + it('constructs a method middleware from the handlers', () => { + const middleware = createMethodMiddleware({ + handlers: methodHandlers, + hooks: makeMockHooks(), + }); + expect(middleware).toBeDefined(); + }); +}); diff --git a/packages/multichain-api-middleware/src/handlers/index.ts b/packages/multichain-api-middleware/src/handlers/index.ts new file mode 100644 index 00000000000..aa35e794397 --- /dev/null +++ b/packages/multichain-api-middleware/src/handlers/index.ts @@ -0,0 +1,38 @@ +import type { UnionToIntersection } from '@metamask/json-rpc-engine/v2'; + +import type { WalletCreateSessionHooks } from './wallet-createSession.js'; +import { walletCreateSessionHandler } from './wallet-createSession.js'; +import type { WalletGetSessionHooks } from './wallet-getSession.js'; +import { walletGetSessionHandler } from './wallet-getSession.js'; +import type { WalletInvokeMethodHooks } from './wallet-invokeMethod.js'; +import { walletInvokeMethodHandler } from './wallet-invokeMethod.js'; +import type { WalletRevokeSessionHooks } from './wallet-revokeSession.js'; +import { walletRevokeSessionHandler } from './wallet-revokeSession.js'; + +export type MethodHandlerHooks = UnionToIntersection< + | WalletCreateSessionHooks + | WalletGetSessionHooks + | WalletInvokeMethodHooks + | WalletRevokeSessionHooks +>; + +const MethodNames = { + WalletCreateSession: 'wallet_createSession', + WalletGetSession: 'wallet_getSession', + WalletInvokeMethod: 'wallet_invokeMethod', + WalletRevokeSession: 'wallet_revokeSession', +} as const; + +type MethodHandlers = { + [MethodNames.WalletCreateSession]: typeof walletCreateSessionHandler; + [MethodNames.WalletGetSession]: typeof walletGetSessionHandler; + [MethodNames.WalletInvokeMethod]: typeof walletInvokeMethodHandler; + [MethodNames.WalletRevokeSession]: typeof walletRevokeSessionHandler; +}; + +export const methodHandlers: Readonly = { + [MethodNames.WalletCreateSession]: walletCreateSessionHandler, + [MethodNames.WalletGetSession]: walletGetSessionHandler, + [MethodNames.WalletInvokeMethod]: walletInvokeMethodHandler, + [MethodNames.WalletRevokeSession]: walletRevokeSessionHandler, +}; diff --git a/packages/multichain-api-middleware/src/handlers/types.ts b/packages/multichain-api-middleware/src/handlers/types.ts new file mode 100644 index 00000000000..7659c8ba550 --- /dev/null +++ b/packages/multichain-api-middleware/src/handlers/types.ts @@ -0,0 +1,41 @@ +import { + Caip25CaveatType, + Caip25CaveatValue, +} from '@metamask/chain-agnostic-permission'; +import type { + GenericPermissionController, + Caveat, +} from '@metamask/permission-controller'; +import type { MultichainRoutingService } from '@metamask/snaps-controllers'; +import type { CaipAccountId, Hex, Json } from '@metamask/utils'; + +/** + * Multichain API notifications currently supported by/known to the wallet. + */ +export enum MultichainApiNotifications { + sessionChanged = 'wallet_sessionChanged', + walletNotify = 'wallet_notify', +} + +export type Caip25Caveat = Caveat; + +export type GetCaveatForOriginHook = { + getCaveatForOrigin: ( + endowmentPermissionName: string, + caveatType: string, + ) => ReturnType; +}; + +export type GetNonEvmSupportedMethodsHook = { + getNonEvmSupportedMethods: MultichainRoutingService['getSupportedMethods']; +}; + +export type SortAccountIdsByLastSelectedHook = { + sortAccountIdsByLastSelected: (accounts: CaipAccountId[]) => CaipAccountId[]; +}; + +export type GetCapabilitiesHook = { + getCapabilities: (params: { + address: string; + }) => Promise>>; +}; diff --git a/packages/multichain-api-middleware/src/handlers/wallet-createSession.test.ts b/packages/multichain-api-middleware/src/handlers/wallet-createSession.test.ts new file mode 100644 index 00000000000..e016f7ee3fc --- /dev/null +++ b/packages/multichain-api-middleware/src/handlers/wallet-createSession.test.ts @@ -0,0 +1,1316 @@ +import type { + Caip25Authorization, + NormalizedScopesObject, +} from '@metamask/chain-agnostic-permission'; +import { + Caip25CaveatType, + Caip25EndowmentPermissionName, + KnownSessionProperties, +} from '@metamask/chain-agnostic-permission'; +import * as ChainAgnosticPermission from '@metamask/chain-agnostic-permission'; +import { MultichainNetwork } from '@metamask/multichain-transactions-controller'; +import { invalidParams } from '@metamask/permission-controller'; +import { JsonRpcError, rpcErrors } from '@metamask/rpc-errors'; +import type { + Hex, + Json, + JsonRpcRequest, + JsonRpcSuccess, +} from '@metamask/utils'; + +import { walletCreateSessionHandler } from './wallet-createSession.js'; + +jest.mock('@metamask/rpc-errors', () => ({ + ...jest.requireActual('@metamask/rpc-errors'), + rpcErrors: { + invalidParams: jest.fn(), + internal: jest.fn(), + }, +})); + +jest.mock('@metamask/chain-agnostic-permission', () => ({ + ...jest.requireActual('@metamask/chain-agnostic-permission'), + validateAndNormalizeScopes: jest.fn(), + bucketScopes: jest.fn(), + getSessionScopes: jest.fn(), + getSessionProperties: jest.fn(), + getSupportedScopeObjects: jest.fn(), +})); +const MockChainAgnosticPermission = jest.mocked(ChainAgnosticPermission); + +const baseRequest = { + jsonrpc: '2.0' as const, + id: 0, + method: 'wallet_createSession', + origin: 'http://test.com', + params: { + requiredScopes: { + eip155: { + references: ['1', '137'], + methods: [ + 'eth_sendTransaction', + 'eth_signTransaction', + 'eth_sign', + 'get_balance', + 'personal_sign', + ], + notifications: ['accountsChanged', 'chainChanged'], + }, + }, + sessionProperties: { + expiry: 'date', + foo: 'bar', + }, + }, +}; + +const createMockedHandler = (trackSessionEvents: boolean = true) => { + const next = jest.fn(); + const end = jest.fn(); + const requestPermissionsForOrigin = jest.fn().mockResolvedValue([ + { + [Caip25EndowmentPermissionName]: { + caveats: [ + { + type: Caip25CaveatType, + value: { + requiredScopes: {}, + optionalScopes: { + 'wallet:eip155': { + accounts: [ + 'wallet:eip155:0x1', + 'wallet:eip155:0x2', + 'wallet:eip155:0x3', + 'wallet:eip155:0x4', + ], + }, + }, + isMultichainOrigin: true, + }, + }, + ], + }, + }, + ]); + const findNetworkClientIdByChainId = jest.fn().mockReturnValue('mainnet'); + const trackSessionCreatedEvent = trackSessionEvents + ? jest.fn().mockImplementation(undefined) + : null; + const listAccounts = jest.fn().mockReturnValue([]); + const getNonEvmSupportedMethods = jest.fn().mockReturnValue([]); + const isNonEvmScopeSupported = jest.fn().mockReturnValue(false); + const response = { + jsonrpc: '2.0' as const, + id: 0, + } as unknown as JsonRpcSuccess<{ + sessionScopes: NormalizedScopesObject; + sessionProperties?: Record; + }>; + const getNonEvmAccountAddresses = jest.fn().mockReturnValue([]); + const sortAccountIdsByLastSelected = jest.fn((accounts) => accounts); + const getCapabilities = jest.fn().mockResolvedValue({}); + const handler = ( + request: JsonRpcRequest & { origin: string }, + ) => + walletCreateSessionHandler.implementation(request, response, next, end, { + findNetworkClientIdByChainId, + requestPermissionsForOrigin, + listAccounts, + getNonEvmSupportedMethods, + isNonEvmScopeSupported, + getNonEvmAccountAddresses, + sortAccountIdsByLastSelected, + getCapabilities, + trackSessionCreatedEvent, + }); + + return { + response, + next, + end, + trackSessionCreatedEvent, + findNetworkClientIdByChainId, + requestPermissionsForOrigin, + listAccounts, + getNonEvmSupportedMethods, + isNonEvmScopeSupported, + getNonEvmAccountAddresses, + sortAccountIdsByLastSelected, + getCapabilities, + handler, + }; +}; + +describe('wallet_createSession', () => { + beforeEach(() => { + MockChainAgnosticPermission.validateAndNormalizeScopes.mockReturnValue({ + normalizedRequiredScopes: {}, + normalizedOptionalScopes: {}, + }); + MockChainAgnosticPermission.bucketScopes.mockReturnValue({ + supportedScopes: { + 'eip155:1': { + methods: [], + notifications: [], + accounts: ['eip155:1:0x1', 'eip155:1:0x2'], + }, + }, + supportableScopes: {}, + unsupportableScopes: {}, + }); + MockChainAgnosticPermission.getSessionScopes.mockReturnValue({}); + MockChainAgnosticPermission.getSessionProperties.mockResolvedValue({}); + MockChainAgnosticPermission.getSupportedScopeObjects.mockImplementation( + (scopesObject) => scopesObject, + ); + }); + + afterEach(() => { + jest.resetAllMocks(); + }); + + it('throws an error if params is not a plain object', async () => { + const { handler, end } = createMockedHandler(); + const params = ['not_a_plain_object'] as unknown as Caip25Authorization; + await handler({ + ...baseRequest, + params, + }); + expect(end).toHaveBeenCalledWith( + invalidParams({ data: { request: { ...baseRequest, params } } }), + ); + }); + + it('throws an error when session properties is defined but empty', async () => { + const { handler, end } = createMockedHandler(); + await handler({ + ...baseRequest, + params: { + ...baseRequest.params, + sessionProperties: {}, + }, + }); + expect(end).toHaveBeenCalledWith( + new JsonRpcError(5302, 'Invalid sessionProperties requested'), + ); + }); + + it('handles undefined requiredScopes and optionalScopes', async () => { + const { handler, end } = createMockedHandler(); + + const requestWithUndefinedScopes = { + ...baseRequest, + params: { + sessionProperties: { + expiry: 'date', + }, + }, + }; + + MockChainAgnosticPermission.validateAndNormalizeScopes.mockImplementation( + (req, opt) => { + expect(req).toStrictEqual({}); + expect(opt).toStrictEqual({}); + + return { + normalizedRequiredScopes: {}, + normalizedOptionalScopes: {}, + }; + }, + ); + + MockChainAgnosticPermission.bucketScopes.mockReturnValue({ + supportedScopes: { + 'eip155:1': { + methods: [], + notifications: [], + accounts: ['eip155:1:0x1'], + }, + }, + supportableScopes: {}, + unsupportableScopes: {}, + }); + + await handler(requestWithUndefinedScopes as typeof baseRequest); + + expect( + MockChainAgnosticPermission.validateAndNormalizeScopes, + ).toHaveBeenCalledWith({}, {}); + + expect(end).not.toHaveBeenCalledWith(expect.any(Error)); + }); + + it('processes the scopes', async () => { + const { handler } = createMockedHandler(); + await handler({ + ...baseRequest, + params: { + ...baseRequest.params, + optionalScopes: { + foo: { + methods: [], + notifications: [], + }, + }, + }, + }); + + expect( + MockChainAgnosticPermission.validateAndNormalizeScopes, + ).toHaveBeenCalledWith(baseRequest.params.requiredScopes, { + foo: { + methods: [], + notifications: [], + }, + }); + }); + + it('throws an error when processing scopes fails', async () => { + const { handler, end } = createMockedHandler(); + MockChainAgnosticPermission.validateAndNormalizeScopes.mockImplementation( + () => { + throw new Error('failed to process scopes'); + }, + ); + await handler(baseRequest); + expect(end).toHaveBeenCalledWith(new Error('failed to process scopes')); + }); + + it('filters the required scopesObjects', async () => { + const { handler, getNonEvmSupportedMethods } = createMockedHandler(); + MockChainAgnosticPermission.validateAndNormalizeScopes.mockReturnValue({ + normalizedRequiredScopes: { + 'eip155:1': { + methods: ['eth_chainId'], + notifications: ['accountsChanged', 'chainChanged'], + accounts: ['eip155:1:0x1', 'eip155:1:0x2'], + }, + }, + normalizedOptionalScopes: {}, + }); + await handler(baseRequest); + + expect( + MockChainAgnosticPermission.getSupportedScopeObjects, + ).toHaveBeenNthCalledWith( + 1, + { + 'eip155:1': { + methods: ['eth_chainId'], + notifications: ['accountsChanged', 'chainChanged'], + accounts: ['eip155:1:0x1', 'eip155:1:0x2'], + }, + }, + { + getNonEvmSupportedMethods, + }, + ); + }); + + it('filters the optional scopesObjects', async () => { + const { handler, getNonEvmSupportedMethods } = createMockedHandler(); + MockChainAgnosticPermission.validateAndNormalizeScopes.mockReturnValue({ + normalizedRequiredScopes: {}, + normalizedOptionalScopes: { + 'eip155:1': { + methods: ['eth_chainId'], + notifications: ['accountsChanged', 'chainChanged'], + accounts: ['eip155:1:0x1', 'eip155:1:0x2'], + }, + }, + }); + await handler(baseRequest); + + expect( + MockChainAgnosticPermission.getSupportedScopeObjects, + ).toHaveBeenNthCalledWith( + 2, + { + 'eip155:1': { + methods: ['eth_chainId'], + notifications: ['accountsChanged', 'chainChanged'], + accounts: ['eip155:1:0x1', 'eip155:1:0x2'], + }, + }, + { + getNonEvmSupportedMethods, + }, + ); + }); + + it('buckets the required scopes', async () => { + const { handler, getNonEvmSupportedMethods, isNonEvmScopeSupported } = + createMockedHandler(); + MockChainAgnosticPermission.validateAndNormalizeScopes.mockReturnValue({ + normalizedRequiredScopes: { + 'eip155:1': { + methods: ['eth_chainId'], + notifications: ['accountsChanged', 'chainChanged'], + accounts: ['eip155:1:0x1', 'eip155:1:0x2'], + }, + }, + normalizedOptionalScopes: {}, + }); + await handler(baseRequest); + + expect(MockChainAgnosticPermission.bucketScopes).toHaveBeenNthCalledWith( + 1, + { + 'eip155:1': { + methods: ['eth_chainId'], + notifications: ['accountsChanged', 'chainChanged'], + accounts: ['eip155:1:0x1', 'eip155:1:0x2'], + }, + }, + expect.objectContaining({ + isEvmChainIdSupported: expect.any(Function), + isEvmChainIdSupportable: expect.any(Function), + getNonEvmSupportedMethods, + isNonEvmScopeSupported, + }), + ); + + const isEvmChainIdSupportedBody = + MockChainAgnosticPermission.bucketScopes.mock.calls[0][1].isEvmChainIdSupported.toString(); + expect(isEvmChainIdSupportedBody).toContain('findNetworkClientIdByChainId'); + }); + + it('buckets the optional scopes', async () => { + const { handler, getNonEvmSupportedMethods, isNonEvmScopeSupported } = + createMockedHandler(); + MockChainAgnosticPermission.validateAndNormalizeScopes.mockReturnValue({ + normalizedRequiredScopes: {}, + normalizedOptionalScopes: { + 'eip155:100': { + methods: ['eth_chainId'], + notifications: ['accountsChanged', 'chainChanged'], + accounts: ['eip155:100:0x4'], + }, + }, + }); + await handler(baseRequest); + + expect(MockChainAgnosticPermission.bucketScopes).toHaveBeenNthCalledWith( + 2, + { + 'eip155:100': { + methods: ['eth_chainId'], + notifications: ['accountsChanged', 'chainChanged'], + accounts: ['eip155:100:0x4'], + }, + }, + expect.objectContaining({ + isEvmChainIdSupported: expect.any(Function), + isEvmChainIdSupportable: expect.any(Function), + getNonEvmSupportedMethods, + isNonEvmScopeSupported, + }), + ); + + const isEvmChainIdSupportedBody = + MockChainAgnosticPermission.bucketScopes.mock.calls[1][1].isEvmChainIdSupported.toString(); + expect(isEvmChainIdSupportedBody).toContain('findNetworkClientIdByChainId'); + }); + + describe('networkClientExistsForChainId hook', () => { + it('networkClientExistsForChainId should return true if chain id is found', async () => { + const { handler, findNetworkClientIdByChainId } = createMockedHandler(); + + let capturedNetworkClientExistsForChainId: + | ((chainId: Hex) => boolean) + | undefined; + + MockChainAgnosticPermission.bucketScopes.mockImplementation( + (_, options) => { + capturedNetworkClientExistsForChainId = options.isEvmChainIdSupported; + return { + supportedScopes: { + 'eip155:1': { + methods: [], + notifications: [], + accounts: ['eip155:1:0x1'], + }, + }, + supportableScopes: {}, + unsupportableScopes: {}, + }; + }, + ); + + findNetworkClientIdByChainId.mockReturnValueOnce('mainnet'); + + await handler(baseRequest); + + expect(capturedNetworkClientExistsForChainId).toBeDefined(); + const successResult = capturedNetworkClientExistsForChainId?.('0x1'); + expect(successResult).toBe(true); + expect(findNetworkClientIdByChainId).toHaveBeenCalledWith('0x1'); + }); + + it('networkClientExistsForChainId hook call should return false if chain id is not found', async () => { + const { handler, findNetworkClientIdByChainId } = createMockedHandler(); + + let capturedNetworkClientExistsForChainId: + | ((chainId: Hex) => boolean) + | undefined; + + MockChainAgnosticPermission.bucketScopes.mockImplementation( + (_, options) => { + capturedNetworkClientExistsForChainId = options.isEvmChainIdSupported; + return { + supportedScopes: { + 'eip155:1': { + methods: [], + notifications: [], + accounts: ['eip155:1:0x1'], + }, + }, + supportableScopes: {}, + unsupportableScopes: {}, + }; + }, + ); + + findNetworkClientIdByChainId.mockImplementationOnce(() => { + throw new Error('Network not found'); + }); + + await handler(baseRequest); + + expect(capturedNetworkClientExistsForChainId).toBeDefined(); + const errorResult = capturedNetworkClientExistsForChainId?.('0x999'); + expect(errorResult).toBe(false); + expect(findNetworkClientIdByChainId).toHaveBeenCalledWith('0x999'); + }); + }); + + describe('isEvmChainIdSupportable hook', () => { + it('tests isEvmChainIdSupportable function for optional scopes', async () => { + const { handler } = createMockedHandler(); + + let capturedIsEvmChainIdSupportable: + | ((chainId: Hex) => boolean) + | undefined; + + MockChainAgnosticPermission.bucketScopes.mockImplementation( + (_, options) => { + capturedIsEvmChainIdSupportable = options.isEvmChainIdSupportable; + return { + supportedScopes: { + 'eip155:1': { + methods: [], + notifications: [], + accounts: ['eip155:1:0x1'], + }, + }, + supportableScopes: {}, + unsupportableScopes: {}, + }; + }, + ); + + await handler(baseRequest); + + expect(capturedIsEvmChainIdSupportable).toBeDefined(); + + const result = capturedIsEvmChainIdSupportable?.('0x1'); + expect(result).toBe(false); + }); + + it('tests isEvmChainIdSupportable function for required scopes', async () => { + const { handler } = createMockedHandler(); + + let capturedIsEvmChainIdSupportable: + | ((chainId: Hex) => boolean) + | undefined; + + /** + * We mock implementation once, so we only define hook for first call of bucketScopes, to make sure we test function for required scopes + */ + MockChainAgnosticPermission.bucketScopes.mockImplementationOnce( + (_, options) => { + capturedIsEvmChainIdSupportable = options.isEvmChainIdSupportable; + return { + supportedScopes: { + 'eip155:1': { + methods: [], + notifications: [], + accounts: ['eip155:1:0x1'], + }, + }, + supportableScopes: {}, + unsupportableScopes: {}, + }; + }, + ); + + MockChainAgnosticPermission.validateAndNormalizeScopes.mockReturnValue({ + normalizedRequiredScopes: { + 'eip155:1': { + methods: ['eth_chainId'], + notifications: [], + accounts: [], + }, + }, + normalizedOptionalScopes: {}, + }); + + await handler(baseRequest); + + expect(capturedIsEvmChainIdSupportable).toBeDefined(); + + const result = capturedIsEvmChainIdSupportable?.('0x1'); + expect(result).toBe(false); + }); + }); + + it('throws an error when no scopes are supported', async () => { + const { handler, end } = createMockedHandler(); + MockChainAgnosticPermission.bucketScopes + .mockReturnValueOnce({ + supportedScopes: {}, + supportableScopes: {}, + unsupportableScopes: {}, + }) + .mockReturnValueOnce({ + supportedScopes: {}, + supportableScopes: {}, + unsupportableScopes: {}, + }); + await handler(baseRequest); + expect(end).toHaveBeenCalledWith( + new JsonRpcError(5100, 'Requested scopes are not supported'), + ); + }); + + it('gets a list of evm accounts in the wallet', async () => { + const { handler, listAccounts } = createMockedHandler(); + + await handler(baseRequest); + + expect(listAccounts).toHaveBeenCalled(); + }); + + it('gets the account addresses for non evm scopes', async () => { + const { handler, listAccounts, getNonEvmAccountAddresses } = + createMockedHandler(); + listAccounts.mockReturnValue([ + { address: '0x1' }, + { address: '0x3' }, + { address: '0x4' }, + ]); + MockChainAgnosticPermission.bucketScopes + .mockReturnValueOnce({ + supportedScopes: {}, + supportableScopes: {}, + unsupportableScopes: {}, + }) + .mockReturnValueOnce({ + supportedScopes: { + [MultichainNetwork.Solana]: { + methods: [], + notifications: [], + accounts: [ + 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp:EEivRh9T4GTLEJprEaKQyjSQzW13JRb5D7jSpvPQ8296', + ], + }, + 'solana:deadbeef': { + methods: [], + notifications: [], + accounts: [ + 'solana:deadbeef:EEivRh9T4GTLEJprEaKQyjSQzW13JRb5D7jSpvPQ8296', + ], + }, + }, + supportableScopes: {}, + unsupportableScopes: {}, + }); + getNonEvmAccountAddresses.mockReturnValue([]); + + await handler(baseRequest); + + expect(getNonEvmAccountAddresses).toHaveBeenCalledTimes(2); + expect(getNonEvmAccountAddresses).toHaveBeenCalledWith( + MultichainNetwork.Solana, + ); + expect(getNonEvmAccountAddresses).toHaveBeenCalledWith('solana:deadbeef'); + }); + + it('requests approval for account and permitted chains permission based on the supported accounts and scopes in the request', async () => { + const { + handler, + listAccounts, + requestPermissionsForOrigin, + getNonEvmAccountAddresses, + } = createMockedHandler(); + listAccounts.mockReturnValue([ + { address: '0x1' }, + { address: '0x3' }, + { address: '0x4' }, + ]); + MockChainAgnosticPermission.bucketScopes + .mockReturnValueOnce({ + supportedScopes: { + 'eip155:1337': { + methods: [], + notifications: [], + accounts: ['eip155:1:0x1', 'eip155:1:0x2'], + }, + }, + supportableScopes: {}, + unsupportableScopes: {}, + }) + .mockReturnValueOnce({ + supportedScopes: { + 'eip155:100': { + methods: [], + notifications: [], + accounts: ['eip155:2:0x1', 'eip155:2:0x3', 'eip155:2:0xdeadbeef'], + }, + [MultichainNetwork.Solana]: { + methods: [], + notifications: [], + accounts: [ + 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp:EEivRh9T4GTLEJprEaKQyjSQzW13JRb5D7jSpvPQ8296', + 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp:notSupported', + ], + }, + }, + supportableScopes: {}, + unsupportableScopes: {}, + }); + getNonEvmAccountAddresses.mockReturnValue([ + 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp:EEivRh9T4GTLEJprEaKQyjSQzW13JRb5D7jSpvPQ8296', + ]); + + await handler(baseRequest); + + expect(requestPermissionsForOrigin).toHaveBeenCalledWith( + { + [Caip25EndowmentPermissionName]: { + caveats: [ + { + type: Caip25CaveatType, + value: { + requiredScopes: { + 'eip155:1337': { + accounts: ['eip155:1337:0x1', 'eip155:1337:0x3'], + }, + }, + optionalScopes: { + 'eip155:100': { + accounts: ['eip155:100:0x1', 'eip155:100:0x3'], + }, + [MultichainNetwork.Solana]: { + accounts: [ + 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp:EEivRh9T4GTLEJprEaKQyjSQzW13JRb5D7jSpvPQ8296', + ], + }, + }, + isMultichainOrigin: true, + sessionProperties: {}, + }, + }, + ], + }, + }, + { metadata: { promptToCreateSolanaAccount: false } }, + ); + }); + + it('throws an error when requesting account permission approval fails', async () => { + const { handler, requestPermissionsForOrigin, end } = createMockedHandler(); + requestPermissionsForOrigin.mockImplementation(() => { + throw new Error('failed to request account permission approval'); + }); + await handler(baseRequest); + expect(end).toHaveBeenCalledWith( + new Error('failed to request account permission approval'), + ); + }); + + it('ignores trackSessionCreatedEvent hook if it is null', async () => { + const { handler, trackSessionCreatedEvent } = createMockedHandler(false); + await handler(baseRequest); + + expect(trackSessionCreatedEvent).toBeNull(); + }); + it('calls trackSessionCreatedEvent hook if not null', async () => { + const { handler, trackSessionCreatedEvent } = createMockedHandler(); + expect(trackSessionCreatedEvent).not.toBeNull(); + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + trackSessionCreatedEvent!.mockImplementation(() => { + // mock implementation + }); + await handler(baseRequest); + + expect(trackSessionCreatedEvent).toHaveBeenCalled(); + }); + + it('returns the known sessionProperties and approved session scopes', async () => { + const { handler, response } = createMockedHandler(); + MockChainAgnosticPermission.getSessionScopes.mockReturnValue({ + 'eip155:5': { + methods: ['eth_chainId', 'net_version'], + notifications: ['accountsChanged', 'chainChanged'], + accounts: ['eip155:5:0x1', 'eip155:5:0x2'], + }, + 'eip155:100': { + methods: ['eth_sendTransaction'], + notifications: ['chainChanged'], + accounts: ['eip155:100:0x1', 'eip155:100:0x2'], + }, + 'wallet:eip155': { + methods: [], + notifications: [], + accounts: ['wallet:eip155:0x1', 'wallet:eip155:0x2'], + }, + }); + await handler(baseRequest); + + expect(response.result).toStrictEqual({ + sessionProperties: {}, + sessionScopes: { + 'eip155:5': { + methods: ['eth_chainId', 'net_version'], + notifications: ['accountsChanged', 'chainChanged'], + accounts: ['eip155:5:0x1', 'eip155:5:0x2'], + }, + 'eip155:100': { + methods: ['eth_sendTransaction'], + notifications: ['chainChanged'], + accounts: ['eip155:100:0x1', 'eip155:100:0x2'], + }, + 'wallet:eip155': { + methods: [], + notifications: [], + accounts: ['wallet:eip155:0x1', 'wallet:eip155:0x2'], + }, + }, + }); + }); + + it('filters out unknown session properties', async () => { + const { handler, requestPermissionsForOrigin, listAccounts } = + createMockedHandler(); + listAccounts.mockReturnValue([ + { address: '0x1' }, + { address: '0x3' }, + { address: '0x4' }, + ]); + MockChainAgnosticPermission.bucketScopes + .mockReturnValueOnce({ + supportedScopes: { + 'eip155:1337': { + methods: [], + notifications: [], + accounts: ['eip155:1:0x1', 'eip155:1:0x2'], + }, + }, + supportableScopes: {}, + unsupportableScopes: {}, + }) + .mockReturnValueOnce({ + supportedScopes: { + 'eip155:100': { + methods: [], + notifications: [], + accounts: ['eip155:2:0x1', 'eip155:2:0x3', 'eip155:2:0xdeadbeef'], + }, + }, + supportableScopes: {}, + unsupportableScopes: {}, + }); + await handler(baseRequest); + expect(requestPermissionsForOrigin).toHaveBeenCalledWith( + { + [Caip25EndowmentPermissionName]: { + caveats: [ + { + type: Caip25CaveatType, + value: { + requiredScopes: { + 'eip155:1337': { + accounts: ['eip155:1337:0x1', 'eip155:1337:0x3'], + }, + }, + optionalScopes: { + 'eip155:100': { + accounts: ['eip155:100:0x1', 'eip155:100:0x3'], + }, + }, + isMultichainOrigin: true, + sessionProperties: {}, + }, + }, + ], + }, + }, + { metadata: { promptToCreateSolanaAccount: false } }, + ); + }); + + it('preserves known session properties', async () => { + const { handler, response, requestPermissionsForOrigin } = + createMockedHandler(); + requestPermissionsForOrigin.mockReturnValue([ + { + [Caip25EndowmentPermissionName]: { + caveats: [ + { + type: Caip25CaveatType, + value: { + optionalScopes: { + 'eip155:5': { + accounts: ['eip155:5:0x1', 'eip155:5:0x2'], + methods: ['eth_chainId', 'net_version'], + notifications: ['accountsChanged', 'chainChanged'], + }, + }, + sessionProperties: { + [KnownSessionProperties.SolanaAccountChangedNotifications]: true, + }, + }, + }, + ], + }, + }, + ]); + MockChainAgnosticPermission.getSessionScopes.mockReturnValue({ + 'eip155:5': { + methods: ['eth_chainId', 'net_version'], + notifications: ['accountsChanged', 'chainChanged'], + accounts: ['eip155:5:0x1', 'eip155:5:0x2'], + }, + }); + MockChainAgnosticPermission.getSessionProperties.mockResolvedValue({ + [KnownSessionProperties.SolanaAccountChangedNotifications]: true, + }); + await handler({ + ...baseRequest, + params: { + ...baseRequest.params, + sessionProperties: { + [KnownSessionProperties.SolanaAccountChangedNotifications]: true, + }, + }, + }); + + expect(response.result).toStrictEqual({ + sessionProperties: { + [KnownSessionProperties.SolanaAccountChangedNotifications]: true, + }, + sessionScopes: { + 'eip155:5': { + accounts: ['eip155:5:0x1', 'eip155:5:0x2'], + methods: ['eth_chainId', 'net_version'], + notifications: ['accountsChanged', 'chainChanged'], + }, + }, + }); + }); + + it('calls internal RPC error if approved CAIP-25 permission has no CAIP-25 caveat value', async () => { + const { handler, requestPermissionsForOrigin } = createMockedHandler(); + requestPermissionsForOrigin.mockReturnValue([ + { + [Caip25EndowmentPermissionName]: { + caveats: [ + { + type: 'mock', + value: {}, + }, + ], + }, + }, + ]); + + await handler({ + ...baseRequest, + params: { + ...baseRequest.params, + }, + }); + + expect(rpcErrors.internal).toHaveBeenCalled(); + }); + + describe('address case sensitivity', () => { + it('treats EVM addresses as case insensitive but other addresses as case sensitive', async () => { + const { + handler, + listAccounts, + requestPermissionsForOrigin, + getNonEvmAccountAddresses, + } = createMockedHandler(); + + listAccounts.mockReturnValue([ + { address: '0xabc123' }, // Note: lowercase in wallet + ]); + + // Mocking nonEVM account addresses in the wallet + getNonEvmAccountAddresses + // First for Solana scope + .mockReturnValueOnce([ + 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp:address1', + ]) + // Then for Bitcoin scope + .mockReturnValueOnce([ + 'bip122:000000000019d6689c085ae165831e93:address1', + ]); + + // Test both EVM (case-insensitive) and Solana (case-sensitive) and Bitcoin (case-sensitive) behavior + MockChainAgnosticPermission.bucketScopes + .mockReturnValueOnce({ + supportedScopes: { + 'eip155:1': { + methods: [], + notifications: [], + accounts: ['eip155:1:0xABC123'], // Upper case in request + }, + }, + supportableScopes: {}, + unsupportableScopes: {}, + }) + .mockReturnValueOnce({ + supportedScopes: { + [MultichainNetwork.Solana]: { + methods: [], + notifications: [], + accounts: [ + // Solana address in request is different case than what + // getNonEvmAccountAddresses (returns in wallet account address) returns + 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp:ADDRESS1', + ], + }, + [MultichainNetwork.Bitcoin]: { + methods: [], + notifications: [], + accounts: ['bip122:000000000019d6689c085ae165831e93:ADDRESS1'], + }, + }, + supportableScopes: {}, + unsupportableScopes: {}, + }); + + await handler({ + jsonrpc: '2.0', + id: 0, + method: 'wallet_createSession', + origin: 'http://test.com', + params: { + requiredScopes: { + eip155: { + methods: ['eth_accounts'], + notifications: [], + accounts: ['eip155:1:0xABC123'], + }, + }, + optionalScopes: { + [MultichainNetwork.Solana]: { + methods: ['getAccounts'], + notifications: [], + accounts: ['solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp:ADDRESS1'], + }, + [MultichainNetwork.Bitcoin]: { + methods: ['getAccounts'], + notifications: [], + accounts: ['bip122:000000000019d6689c085ae165831e93:ADDRESS1'], + }, + }, + }, + }); + + expect(requestPermissionsForOrigin).toHaveBeenCalledWith( + { + [Caip25EndowmentPermissionName]: { + caveats: [ + { + type: Caip25CaveatType, + value: { + requiredScopes: { + 'eip155:1': { + accounts: ['eip155:1:0xABC123'], // Requested EVM address included + }, + }, + optionalScopes: { + [MultichainNetwork.Solana]: { + accounts: [], // Solana address excluded due to case mismatch + }, + [MultichainNetwork.Bitcoin]: { + accounts: [], // Bitcoin address excluded due to case mismatch + }, + }, + isMultichainOrigin: true, + sessionProperties: {}, + }, + }, + ], + }, + }, + { metadata: { promptToCreateSolanaAccount: false } }, + ); + }); + }); + + describe('promptToCreateSolanaAccount', () => { + const baseRequestWithSolanaScope = { + jsonrpc: '2.0' as const, + id: 0, + method: 'wallet_createSession', + origin: 'http://test.com', + params: { + optionalScopes: { + [MultichainNetwork.Solana]: { + methods: [], + notifications: [], + accounts: [], + }, + }, + sessionProperties: { + [KnownSessionProperties.SolanaAccountChangedNotifications]: true, + }, + }, + }; + + it('prompts to create a solana account if a solana scope is requested and no solana accounts are currently available', async () => { + const { + handler, + requestPermissionsForOrigin, + getNonEvmAccountAddresses, + } = createMockedHandler(); + getNonEvmAccountAddresses.mockReturnValue([]); + MockChainAgnosticPermission.validateAndNormalizeScopes.mockReturnValue({ + normalizedRequiredScopes: { + [MultichainNetwork.Solana]: { + methods: [], + notifications: [], + accounts: [], + }, + }, + normalizedOptionalScopes: {}, + }); + + MockChainAgnosticPermission.bucketScopes + .mockReturnValueOnce({ + supportedScopes: {}, + supportableScopes: {}, + unsupportableScopes: {}, + }) + .mockReturnValueOnce({ + supportedScopes: { + 'eip155:1337': { + methods: [], + notifications: [], + accounts: [], + }, + }, + supportableScopes: {}, + unsupportableScopes: {}, + }); + + await handler(baseRequestWithSolanaScope); + + expect(requestPermissionsForOrigin).toHaveBeenCalledWith( + { + [Caip25EndowmentPermissionName]: { + caveats: [ + { + type: Caip25CaveatType, + value: { + requiredScopes: {}, + optionalScopes: { + 'eip155:1337': { + accounts: [], + }, + }, + isMultichainOrigin: true, + sessionProperties: { + [KnownSessionProperties.SolanaAccountChangedNotifications]: true, + }, + }, + }, + ], + }, + }, + { metadata: { promptToCreateSolanaAccount: true } }, + ); + }); + + it('does not prompt to create a solana account if a solana scope is requested and solana accounts are currently available', async () => { + const { + handler, + requestPermissionsForOrigin, + getNonEvmAccountAddresses, + } = createMockedHandler(); + getNonEvmAccountAddresses.mockReturnValue([ + 'solana:101:0x1', + 'solana:101:0x2', + ]); + MockChainAgnosticPermission.validateAndNormalizeScopes.mockReturnValue({ + normalizedRequiredScopes: {}, + normalizedOptionalScopes: { + [MultichainNetwork.Solana]: { + methods: [], + notifications: [], + accounts: [], + }, + }, + }); + + MockChainAgnosticPermission.bucketScopes + .mockReturnValueOnce({ + supportedScopes: {}, + supportableScopes: {}, + unsupportableScopes: {}, + }) + .mockReturnValueOnce({ + supportedScopes: { + [MultichainNetwork.Solana]: { + methods: [], + notifications: [], + accounts: [], + }, + }, + supportableScopes: {}, + unsupportableScopes: {}, + }); + + await handler(baseRequestWithSolanaScope); + + expect(requestPermissionsForOrigin).toHaveBeenCalledWith( + { + [Caip25EndowmentPermissionName]: { + caveats: [ + { + type: Caip25CaveatType, + value: { + requiredScopes: {}, + optionalScopes: { + [MultichainNetwork.Solana]: { + accounts: [], + }, + }, + isMultichainOrigin: true, + sessionProperties: { + [KnownSessionProperties.SolanaAccountChangedNotifications]: true, + }, + }, + }, + ], + }, + }, + { metadata: { promptToCreateSolanaAccount: false } }, + ); + }); + + it('adds a wallet scope when solana is requested with no accounts and no other valid scopes exist', async () => { + const { + handler, + requestPermissionsForOrigin, + getNonEvmAccountAddresses, + } = createMockedHandler(); + + getNonEvmAccountAddresses.mockReturnValue([]); + + MockChainAgnosticPermission.validateAndNormalizeScopes.mockReturnValue({ + normalizedRequiredScopes: {}, + normalizedOptionalScopes: { + [MultichainNetwork.Solana]: { + methods: [], + notifications: [], + accounts: [], + }, + }, + }); + + MockChainAgnosticPermission.bucketScopes + .mockReturnValueOnce({ + supportedScopes: {}, + supportableScopes: {}, + unsupportableScopes: {}, + }) + .mockReturnValueOnce({ + supportedScopes: {}, + supportableScopes: {}, + unsupportableScopes: {}, + }); + + await handler(baseRequestWithSolanaScope); + + expect(requestPermissionsForOrigin).toHaveBeenCalledWith( + { + [Caip25EndowmentPermissionName]: { + caveats: [ + { + type: Caip25CaveatType, + value: { + requiredScopes: {}, + optionalScopes: { + wallet: { + accounts: [], + }, + }, + isMultichainOrigin: true, + sessionProperties: { + [KnownSessionProperties.SolanaAccountChangedNotifications]: true, + }, + }, + }, + ], + }, + }, + { metadata: { promptToCreateSolanaAccount: true } }, + ); + }); + + it('returns error when no scopes are supported and solana is not requested', async () => { + const { handler, end } = createMockedHandler(); + + // Request with no valid scopes + const requestWithNoValidScopes = { + jsonrpc: '2.0' as const, + id: 0, + method: 'wallet_createSession', + origin: 'http://test.com', + params: { + requiredScopes: { + 'unsupported:chain': { + methods: ['someMethod'], + notifications: [], + }, + }, + }, + }; + + MockChainAgnosticPermission.validateAndNormalizeScopes.mockReturnValue({ + normalizedRequiredScopes: { + 'unsupported:chain': { + methods: ['someMethod'], + notifications: [], + accounts: [], + }, + }, + normalizedOptionalScopes: {}, + }); + + MockChainAgnosticPermission.bucketScopes + .mockReturnValueOnce({ + supportedScopes: {}, + supportableScopes: {}, + unsupportableScopes: {}, + }) + .mockReturnValueOnce({ + supportedScopes: {}, + supportableScopes: {}, + unsupportableScopes: {}, + }); + + await handler(requestWithNoValidScopes); + + expect(end).toHaveBeenCalledWith( + new JsonRpcError(5100, 'Requested scopes are not supported'), + ); + }); + }); +}); diff --git a/packages/multichain-api-middleware/src/handlers/wallet-createSession.ts b/packages/multichain-api-middleware/src/handlers/wallet-createSession.ts new file mode 100644 index 00000000000..65aa9715c5c --- /dev/null +++ b/packages/multichain-api-middleware/src/handlers/wallet-createSession.ts @@ -0,0 +1,328 @@ +import type { AccountsController } from '@metamask/accounts-controller'; +import { + Caip25CaveatType, + Caip25EndowmentPermissionName, + bucketScopes, + validateAndNormalizeScopes, + getInternalScopesObject, + getSessionScopes, + getSessionProperties, + getSupportedScopeObjects, + isKnownSessionPropertyValue, + getCaipAccountIdsFromScopesObjects, + getAllScopesFromScopesObjects, + setNonSCACaipAccountIdsInCaip25CaveatValue, + isNamespaceInScopesObject, +} from '@metamask/chain-agnostic-permission'; +import type { + Caip25Authorization, + NormalizedScopesObject, + Caip25CaveatValue, +} from '@metamask/chain-agnostic-permission'; +import { isEqualCaseInsensitive } from '@metamask/controller-utils'; +import type { + MethodHandler, + JsonRpcEngineEndCallback, + JsonRpcEngineNextCallback, +} from '@metamask/json-rpc-engine'; +import type { NetworkController } from '@metamask/network-controller'; +import { invalidParams } from '@metamask/permission-controller'; +import type { + GenericPermissionController, + RequestedPermissions, +} from '@metamask/permission-controller'; +import { JsonRpcError, rpcErrors } from '@metamask/rpc-errors'; +import type { MultichainRoutingService } from '@metamask/snaps-controllers'; +import { + isPlainObject, + KnownCaipNamespace, + parseCaipAccountId, +} from '@metamask/utils'; +import type { + CaipAccountId, + Hex, + Json, + JsonRpcRequest, + PendingJsonRpcResponse, +} from '@metamask/utils'; + +import type { + GetCapabilitiesHook, + GetNonEvmSupportedMethodsHook, + SortAccountIdsByLastSelectedHook, +} from './types.js'; + +const SOLANA_CAIP_CHAIN_ID = 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp'; + +export type WalletCreateSessionHooks = GetNonEvmSupportedMethodsHook & + SortAccountIdsByLastSelectedHook & + GetCapabilitiesHook & { + listAccounts: AccountsController['listAccounts']; + findNetworkClientIdByChainId: NetworkController['findNetworkClientIdByChainId']; + requestPermissionsForOrigin: ( + requestedPermissions: RequestedPermissions, + metadata?: Record, + ) => ReturnType; + isNonEvmScopeSupported: MultichainRoutingService['isSupportedScope']; + getNonEvmAccountAddresses: MultichainRoutingService['getSupportedAccounts']; + trackSessionCreatedEvent: + | ((approvedCaip25CaveatValue: Caip25CaveatValue) => void) + | null; + }; + +type Params = Caip25Authorization; + +type Result = { + sessionScopes: NormalizedScopesObject; + sessionProperties?: Record; +}; + +/** + * Handler for the `wallet_createSession` RPC method which is responsible + * for prompting for approval and granting a CAIP-25 permission. + * + * This implementation primarily deviates from the CAIP-25 handler + * specification by treating all scopes as optional regardless of + * if they were specified in `requiredScopes` or `optionalScopes`. + * Additionally, provided scopes, methods, notifications, and + * account values that are invalid/malformed are ignored rather than + * causing an error to be returned. + * + * @param req - The request object. + * @param res - The response object. + * @param _next - The next middleware function. + * @param end - The end function. + * @param hooks - The hooks object. + * @param hooks.listAccounts - The hook that returns an array of the wallet's evm accounts. + * @param hooks.findNetworkClientIdByChainId - The hook that returns the networkClientId for a chainId. + * @param hooks.requestPermissionsForOrigin - The hook that approves and grants requested permissions. + * @param hooks.getNonEvmSupportedMethods - The hook that returns the supported methods for a non EVM scope. + * @param hooks.isNonEvmScopeSupported - The hook that returns true if a non EVM scope is supported. + * @param hooks.getNonEvmAccountAddresses - The hook that returns a list of CaipAccountIds that are supported for a CaipChainId. + * @param hooks.sortAccountIdsByLastSelected - A function that accepts an array of CaipAccountId and returns an array of CaipAccountId sorted by last selected. + * @param hooks.getCapabilities - A function that returns the capabilities for a given address. + * @param hooks.trackSessionCreatedEvent - An optional hook for platform specific logic to run. + * @returns A promise with wallet_createSession handler + */ +async function handleWalletCreateSession( + req: JsonRpcRequest & { origin: string }, + res: PendingJsonRpcResponse, + _next: JsonRpcEngineNextCallback, + end: JsonRpcEngineEndCallback, + hooks: WalletCreateSessionHooks, +): Promise { + if (!isPlainObject(req.params)) { + return end(invalidParams({ data: { request: req } })); + } + const { requiredScopes, optionalScopes, sessionProperties } = req.params; + + if (sessionProperties && Object.keys(sessionProperties).length === 0) { + return end(new JsonRpcError(5302, 'Invalid sessionProperties requested')); + } + + const filteredSessionProperties = Object.fromEntries( + Object.entries(sessionProperties ?? {}).filter(([key]) => + isKnownSessionPropertyValue(key), + ), + ); + + try { + const { normalizedRequiredScopes, normalizedOptionalScopes } = + validateAndNormalizeScopes(requiredScopes || {}, optionalScopes || {}); + + const requiredScopesWithSupportedMethodsAndNotifications = + getSupportedScopeObjects(normalizedRequiredScopes, { + getNonEvmSupportedMethods: hooks.getNonEvmSupportedMethods, + }); + const optionalScopesWithSupportedMethodsAndNotifications = + getSupportedScopeObjects(normalizedOptionalScopes, { + getNonEvmSupportedMethods: hooks.getNonEvmSupportedMethods, + }); + + const networkClientExistsForChainId = (chainId: Hex) => { + try { + hooks.findNetworkClientIdByChainId(chainId); + return true; + } catch { + return false; + } + }; + + // if solana is a requested scope but not supported, we add a promptToCreateSolanaAccount flag to request + const isSolanaRequested = + isNamespaceInScopesObject( + requiredScopesWithSupportedMethodsAndNotifications, + KnownCaipNamespace.Solana, + ) || + isNamespaceInScopesObject( + optionalScopesWithSupportedMethodsAndNotifications, + KnownCaipNamespace.Solana, + ); + + let promptToCreateSolanaAccount = false; + if (isSolanaRequested) { + const supportedSolanaAccounts = + hooks.getNonEvmAccountAddresses(SOLANA_CAIP_CHAIN_ID); + promptToCreateSolanaAccount = supportedSolanaAccounts.length === 0; + } + + const { supportedScopes: supportedRequiredScopes } = bucketScopes( + requiredScopesWithSupportedMethodsAndNotifications, + { + isEvmChainIdSupported: networkClientExistsForChainId, + isEvmChainIdSupportable: () => false, // intended for future usage with eip3085 scopedProperties + getNonEvmSupportedMethods: hooks.getNonEvmSupportedMethods, + isNonEvmScopeSupported: hooks.isNonEvmScopeSupported, + }, + ); + + const { supportedScopes: supportedOptionalScopes } = bucketScopes( + optionalScopesWithSupportedMethodsAndNotifications, + { + isEvmChainIdSupported: networkClientExistsForChainId, + isEvmChainIdSupportable: () => false, // intended for future usage with eip3085 scopedProperties + getNonEvmSupportedMethods: hooks.getNonEvmSupportedMethods, + isNonEvmScopeSupported: hooks.isNonEvmScopeSupported, + }, + ); + + const allRequestedAccountAddresses = getCaipAccountIdsFromScopesObjects([ + supportedRequiredScopes, + supportedOptionalScopes, + ]); + + const allSupportedRequestedCaipChainIds = getAllScopesFromScopesObjects([ + supportedRequiredScopes, + supportedOptionalScopes, + ]); + + const existingEvmAddresses = hooks + .listAccounts() + .map((account) => account.address); + + const supportedRequestedAccountAddresses = + allRequestedAccountAddresses.filter( + (requestedAccountAddress: CaipAccountId) => { + const { + address, + chain: { namespace }, + chainId: caipChainId, + } = parseCaipAccountId(requestedAccountAddress); + if (namespace === KnownCaipNamespace.Eip155.toString()) { + return existingEvmAddresses.some((existingEvmAddress) => { + return isEqualCaseInsensitive(address, existingEvmAddress); + }); + } + + // If the namespace is not eip155 (EVM) we do a case sensitive check + return hooks + .getNonEvmAccountAddresses(caipChainId) + .some((existingCaipAddress) => { + return requestedAccountAddress === existingCaipAddress; + }); + }, + ); + + const requestedCaip25CaveatValue = { + requiredScopes: getInternalScopesObject(supportedRequiredScopes), + optionalScopes: getInternalScopesObject(supportedOptionalScopes), + isMultichainOrigin: true, + sessionProperties: filteredSessionProperties, + }; + + const requestedCaip25CaveatValueWithSupportedAccounts = + setNonSCACaipAccountIdsInCaip25CaveatValue( + requestedCaip25CaveatValue, + supportedRequestedAccountAddresses, + ); + + // if `promptToCreateSolanaAccount` is true and there are no other valid scopes requested, + // we add a `wallet` scope to the request in order to get passed the CAIP-25 caveat validator. + // This is very hacky but is necessary because the solana opt-in flow breaks key assumptions + // of the CAIP-25 permission specification - namely that we can have valid requests with no scopes. + if (allSupportedRequestedCaipChainIds.length === 0) { + if (promptToCreateSolanaAccount) { + requestedCaip25CaveatValueWithSupportedAccounts.optionalScopes[ + KnownCaipNamespace.Wallet + ] = { + accounts: [], + }; + } else { + // if solana is not requested and there are no supported scopes, we return an error + return end( + new JsonRpcError(5100, 'Requested scopes are not supported'), + ); + } + } + + const [grantedPermissions] = await hooks.requestPermissionsForOrigin( + { + [Caip25EndowmentPermissionName]: { + caveats: [ + { + type: Caip25CaveatType, + value: requestedCaip25CaveatValueWithSupportedAccounts, + }, + ], + }, + }, + { + metadata: { promptToCreateSolanaAccount }, + }, + ); + + const approvedCaip25Permission = + grantedPermissions[Caip25EndowmentPermissionName]; + const approvedCaip25CaveatValue = approvedCaip25Permission?.caveats?.find( + (caveat) => caveat.type === Caip25CaveatType, + )?.value as Caip25CaveatValue; + if (!approvedCaip25CaveatValue) { + throw rpcErrors.internal(); + } + + const sessionScopes = getSessionScopes(approvedCaip25CaveatValue, { + getNonEvmSupportedMethods: hooks.getNonEvmSupportedMethods, + sortAccountIdsByLastSelected: hooks.sortAccountIdsByLastSelected, + }); + + const approvedSessionProperties = await getSessionProperties( + approvedCaip25CaveatValue, + { + getCapabilities: hooks.getCapabilities, + }, + ); + + hooks.trackSessionCreatedEvent?.(approvedCaip25CaveatValue); + + res.result = { + sessionScopes, + sessionProperties: approvedSessionProperties, + }; + return end(); + } catch (err) { + return end(err); + } +} + +export type WalletCreateSessionHandler = MethodHandler< + WalletCreateSessionHooks, + never, + Params, + Result, + { origin: string } +>; + +export const walletCreateSessionHandler = { + implementation: handleWalletCreateSession, + hookNames: { + findNetworkClientIdByChainId: true, + listAccounts: true, + requestPermissionsForOrigin: true, + getNonEvmSupportedMethods: true, + isNonEvmScopeSupported: true, + getNonEvmAccountAddresses: true, + sortAccountIdsByLastSelected: true, + getCapabilities: true, + trackSessionCreatedEvent: true, + }, +} satisfies WalletCreateSessionHandler; diff --git a/packages/multichain-api-middleware/src/handlers/wallet-getSession.test.ts b/packages/multichain-api-middleware/src/handlers/wallet-getSession.test.ts new file mode 100644 index 00000000000..9ced5262239 --- /dev/null +++ b/packages/multichain-api-middleware/src/handlers/wallet-getSession.test.ts @@ -0,0 +1,227 @@ +import * as chainAgnosticPermissionModule from '@metamask/chain-agnostic-permission'; +import type { JsonRpcRequest } from '@metamask/utils'; + +import { walletGetSessionHandler } from './wallet-getSession.js'; + +jest.mock('@metamask/chain-agnostic-permission', () => ({ + ...jest.requireActual('@metamask/chain-agnostic-permission'), + __esModule: true, +})); + +const { Caip25CaveatType, Caip25EndowmentPermissionName } = + chainAgnosticPermissionModule; + +const baseRequest: JsonRpcRequest & { origin: string } = { + origin: 'http://test.com', + jsonrpc: '2.0' as const, + method: 'wallet_getSession', + params: {}, + id: 1, +}; + +const createMockedHandler = () => { + const next = jest.fn(); + const end = jest.fn(); + const getNonEvmSupportedMethods = jest.fn(); + const sortAccountIdsByLastSelected = jest.fn((accounts) => accounts); + const getCapabilities = jest.fn().mockResolvedValue({}); + const getCaveatForOrigin = jest.fn().mockReturnValue({ + value: { + requiredScopes: { + 'eip155:1': { + accounts: [], + }, + 'eip155:5': { + accounts: [], + }, + }, + optionalScopes: { + 'eip155:1': { + accounts: [], + }, + wallet: { + accounts: [], + }, + }, + }, + }); + const response = { + result: { + sessionScopes: {}, + sessionProperties: {}, + }, + id: 1, + jsonrpc: '2.0' as const, + }; + const handler = (request: JsonRpcRequest & { origin: string }) => + walletGetSessionHandler.implementation(request, response, next, end, { + getCaveatForOrigin, + getNonEvmSupportedMethods, + sortAccountIdsByLastSelected, + getCapabilities, + }); + + return { + next, + response, + end, + getCaveatForOrigin, + getNonEvmSupportedMethods, + sortAccountIdsByLastSelected, + getCapabilities, + handler, + }; +}; + +describe('wallet_getSession', () => { + beforeEach(() => { + jest + .spyOn(chainAgnosticPermissionModule, 'getSessionScopes') + .mockReturnValue({}); + jest + .spyOn(chainAgnosticPermissionModule, 'getSessionProperties') + .mockResolvedValue({}); + }); + + it('gets the authorized scopes from the CAIP-25 endowment permission', async () => { + const { handler, getCaveatForOrigin } = createMockedHandler(); + + await handler(baseRequest); + expect(getCaveatForOrigin).toHaveBeenCalledWith( + Caip25EndowmentPermissionName, + Caip25CaveatType, + ); + }); + + it('returns empty scopes if the CAIP-25 endowment permission does not exist', async () => { + const { handler, response, getCaveatForOrigin } = createMockedHandler(); + getCaveatForOrigin.mockImplementation(() => { + throw new Error('permission not found'); + }); + + await handler(baseRequest); + expect(response.result).toStrictEqual({ + sessionScopes: {}, + sessionProperties: {}, + }); + }); + + it('gets the session scopes from the CAIP-25 caveat value', async () => { + const { handler, getNonEvmSupportedMethods, sortAccountIdsByLastSelected } = + createMockedHandler(); + + await handler(baseRequest); + expect(chainAgnosticPermissionModule.getSessionScopes).toHaveBeenCalledWith( + { + requiredScopes: { + 'eip155:1': { + accounts: [], + }, + 'eip155:5': { + accounts: [], + }, + }, + optionalScopes: { + 'eip155:1': { + accounts: [], + }, + wallet: { + accounts: [], + }, + }, + }, + { + getNonEvmSupportedMethods, + sortAccountIdsByLastSelected, + }, + ); + }); + + it('gets the session properties from the CAIP-25 caveat value', async () => { + const { handler, getCapabilities } = createMockedHandler(); + + await handler(baseRequest); + expect( + chainAgnosticPermissionModule.getSessionProperties, + ).toHaveBeenCalledWith( + { + requiredScopes: { + 'eip155:1': { + accounts: [], + }, + 'eip155:5': { + accounts: [], + }, + }, + optionalScopes: { + 'eip155:1': { + accounts: [], + }, + wallet: { + accounts: [], + }, + }, + }, + { + getCapabilities, + }, + ); + }); + + it('returns the session scopes and session properties', async () => { + const { handler, response } = createMockedHandler(); + + jest + .spyOn(chainAgnosticPermissionModule, 'getSessionScopes') + .mockReturnValue({ + 'eip155:1': { + methods: ['eth_call', 'net_version'], + notifications: ['chainChanged'], + accounts: [], + }, + 'eip155:5': { + methods: ['eth_chainId'], + notifications: [], + accounts: [], + }, + wallet: { + methods: ['wallet_watchAsset'], + notifications: [], + accounts: [], + }, + }); + jest + .spyOn(chainAgnosticPermissionModule, 'getSessionProperties') + .mockResolvedValue({ + eip155Capabilities: { + '0x1': { '0x1': { atomic: { status: 'supported' } } }, + }, + }); + + await handler(baseRequest); + expect(response.result).toStrictEqual({ + sessionScopes: { + 'eip155:1': { + methods: ['eth_call', 'net_version'], + notifications: ['chainChanged'], + accounts: [], + }, + 'eip155:5': { + methods: ['eth_chainId'], + notifications: [], + accounts: [], + }, + wallet: { + methods: ['wallet_watchAsset'], + notifications: [], + accounts: [], + }, + }, + sessionProperties: { + eip155Capabilities: { + '0x1': { '0x1': { atomic: { status: 'supported' } } }, + }, + }, + }); + }); +}); diff --git a/packages/multichain-api-middleware/src/handlers/wallet-getSession.ts b/packages/multichain-api-middleware/src/handlers/wallet-getSession.ts new file mode 100644 index 00000000000..a38e0e783f1 --- /dev/null +++ b/packages/multichain-api-middleware/src/handlers/wallet-getSession.ts @@ -0,0 +1,105 @@ +import type { NormalizedScopesObject } from '@metamask/chain-agnostic-permission'; +import { + Caip25CaveatType, + Caip25EndowmentPermissionName, + getSessionProperties, + getSessionScopes, +} from '@metamask/chain-agnostic-permission'; +import type { + JsonRpcEngineEndCallback, + JsonRpcEngineNextCallback, + MethodHandler, +} from '@metamask/json-rpc-engine'; +import type { + Json, + JsonRpcParams, + JsonRpcRequest, + PendingJsonRpcResponse, +} from '@metamask/utils'; + +import type { + Caip25Caveat, + GetCapabilitiesHook, + GetCaveatForOriginHook, + GetNonEvmSupportedMethodsHook, + SortAccountIdsByLastSelectedHook, +} from './types.js'; + +type WalletGetSessionResult = { + sessionScopes: NormalizedScopesObject; + sessionProperties: Record; +}; + +export type WalletGetSessionHooks = GetCaveatForOriginHook & + GetNonEvmSupportedMethodsHook & + SortAccountIdsByLastSelectedHook & + GetCapabilitiesHook; + +/** + * Handler for the `wallet_getSession` RPC method as specified by [CAIP-312](https://chainagnostic.org/CAIPs/caip-312). + * The implementation below deviates from the linked spec in that it ignores the `sessionId` param entirely, + * and that an empty object is returned for the `sessionScopes` result rather than throwing an error if there + * is no active session for the origin. + * + * @param _request - The request object. + * @param response - The response object. + * @param _next - The next middleware function. Unused. + * @param end - The end function. + * @param hooks - The hooks object. + * @param hooks.getCaveatForOrigin - Function to retrieve a caveat for the origin. + * @param hooks.getNonEvmSupportedMethods - A function that returns the supported methods for a non EVM scope. + * @param hooks.sortAccountIdsByLastSelected - A function that accepts an array of CaipAccountId and returns an array of CaipAccountId sorted by corresponding last selected account in the wallet. + * @param hooks.getCapabilities - A function that returns the capabilities for a given address. + * @returns Nothing. + */ +async function handleWalletGetSession( + _request: JsonRpcRequest & { origin: string }, + response: PendingJsonRpcResponse, + _next: JsonRpcEngineNextCallback, + end: JsonRpcEngineEndCallback, + hooks: WalletGetSessionHooks, +) { + let caveat: Caip25Caveat | undefined; + try { + caveat = hooks.getCaveatForOrigin( + Caip25EndowmentPermissionName, + Caip25CaveatType, + ) as Caip25Caveat | undefined; + } catch { + // noop + } + + if (!caveat) { + response.result = { sessionScopes: {}, sessionProperties: {} }; + return end(); + } + + response.result = { + sessionScopes: getSessionScopes(caveat.value, { + getNonEvmSupportedMethods: hooks.getNonEvmSupportedMethods, + sortAccountIdsByLastSelected: hooks.sortAccountIdsByLastSelected, + }), + sessionProperties: await getSessionProperties(caveat.value, { + getCapabilities: hooks.getCapabilities, + }), + }; + return end(); +} + +export type WalletGetSessionHandler = MethodHandler< + WalletGetSessionHooks, + never, + JsonRpcParams, + WalletGetSessionResult, + { origin: string } +>; + +export const walletGetSessionHandler = { + implementation: handleWalletGetSession, + hookNames: { + getCaveatForOrigin: true, + getNonEvmSupportedMethods: true, + sortAccountIdsByLastSelected: true, + getCapabilities: true, + }, +} satisfies WalletGetSessionHandler; diff --git a/packages/multichain-api-middleware/src/handlers/wallet-invokeMethod.test.ts b/packages/multichain-api-middleware/src/handlers/wallet-invokeMethod.test.ts new file mode 100644 index 00000000000..442c90f3ae2 --- /dev/null +++ b/packages/multichain-api-middleware/src/handlers/wallet-invokeMethod.test.ts @@ -0,0 +1,481 @@ +import * as chainAgnosticPermissionModule from '@metamask/chain-agnostic-permission'; +import { providerErrors, rpcErrors } from '@metamask/rpc-errors'; + +import type { WalletInvokeMethodRequest } from './wallet-invokeMethod.js'; +import { walletInvokeMethodHandler } from './wallet-invokeMethod.js'; + +// Allow individual modules to be mocked +jest.mock('@metamask/chain-agnostic-permission', () => ({ + ...jest.requireActual('@metamask/chain-agnostic-permission'), + __esModule: true, +})); + +const { Caip25CaveatType, Caip25EndowmentPermissionName } = + chainAgnosticPermissionModule; + +const createMockedRequest = () => ({ + jsonrpc: '2.0' as const, + id: 0, + origin: 'http://test.com', + method: 'wallet_invokeMethod', + params: { + scope: 'eip155:1', + request: { + method: 'eth_call', + params: { + foo: 'bar', + }, + }, + }, +}); + +const createMockedHandler = () => { + const next = jest.fn(); + const end = jest.fn(); + const getCaveatForOrigin = jest.fn().mockReturnValue({ + value: { + requiredScopes: { + 'eip155:1': { + accounts: [], + }, + 'eip155:5': { + accounts: [], + }, + }, + optionalScopes: { + 'eip155:1': { + accounts: [], + }, + wallet: { + accounts: [], + }, + }, + isMultichainOrigin: true, + }, + }); + const findNetworkClientIdByChainId = jest.fn().mockReturnValue('mainnet'); + const getSelectedNetworkClientId = jest + .fn() + .mockReturnValue('selectedNetworkClientId'); + const getNonEvmSupportedMethods = jest.fn().mockReturnValue([]); + const sortAccountIdsByLastSelected = jest.fn((accounts) => accounts); + const handleNonEvmRequestForOrigin = jest.fn().mockResolvedValue(null); + const response = { jsonrpc: '2.0' as const, id: 1 }; + const handler = (request: WalletInvokeMethodRequest) => + walletInvokeMethodHandler.implementation(request, response, next, end, { + getCaveatForOrigin, + findNetworkClientIdByChainId, + getSelectedNetworkClientId, + getNonEvmSupportedMethods, + sortAccountIdsByLastSelected, + handleNonEvmRequestForOrigin, + }); + + return { + response, + next, + end, + getCaveatForOrigin, + findNetworkClientIdByChainId, + getSelectedNetworkClientId, + getNonEvmSupportedMethods, + sortAccountIdsByLastSelected, + handleNonEvmRequestForOrigin, + handler, + }; +}; + +describe('wallet_invokeMethod', () => { + beforeEach(() => { + jest + .spyOn(chainAgnosticPermissionModule, 'getSessionScopes') + .mockReturnValue({ + 'eip155:1': { + methods: ['eth_call', 'net_version'], + notifications: [], + accounts: [], + }, + 'eip155:5': { + methods: ['eth_chainId'], + notifications: [], + accounts: [], + }, + wallet: { + methods: ['wallet_watchAsset'], + notifications: [], + accounts: [], + }, + 'wallet:eip155': { + methods: ['wallet_watchAsset'], + notifications: [], + accounts: [], + }, + 'nonevm:scope': { + methods: ['foobar'], + notifications: [], + accounts: ['nonevm:scope:0x1'], + }, + }); + }); + + it('returns invalid params when params is not a plain object', async () => { + const { handler, end, getCaveatForOrigin, next } = createMockedHandler(); + const request = { + ...createMockedRequest(), + params: [ + 'not-a-plain-object', + ] as unknown as WalletInvokeMethodRequest['params'], + }; + await handler(request); + expect(end).toHaveBeenCalledWith( + rpcErrors.invalidParams({ data: { request } }), + ); + expect(getCaveatForOrigin).not.toHaveBeenCalled(); + expect(next).not.toHaveBeenCalled(); + }); + + it('gets the authorized scopes from the CAIP-25 endowment permission', async () => { + const request = createMockedRequest(); + const { handler, getCaveatForOrigin } = createMockedHandler(); + await handler(request); + expect(getCaveatForOrigin).toHaveBeenCalledWith( + Caip25EndowmentPermissionName, + Caip25CaveatType, + ); + }); + + it('gets the session scopes from the CAIP-25 caveat value', async () => { + const request = createMockedRequest(); + const { handler, getNonEvmSupportedMethods, sortAccountIdsByLastSelected } = + createMockedHandler(); + await handler(request); + expect(chainAgnosticPermissionModule.getSessionScopes).toHaveBeenCalledWith( + { + requiredScopes: { + 'eip155:1': { + accounts: [], + }, + 'eip155:5': { + accounts: [], + }, + }, + optionalScopes: { + 'eip155:1': { + accounts: [], + }, + wallet: { + accounts: [], + }, + }, + isMultichainOrigin: true, + }, + { + getNonEvmSupportedMethods, + sortAccountIdsByLastSelected, + }, + ); + }); + + it('throws an unauthorized error when there is no CAIP-25 endowment permission', async () => { + const request = createMockedRequest(); + const { handler, getCaveatForOrigin, end } = createMockedHandler(); + getCaveatForOrigin.mockImplementation(() => { + throw new Error('permission not found'); + }); + await handler(request); + expect(end).toHaveBeenCalledWith(providerErrors.unauthorized()); + }); + + it('throws an unauthorized error if the requested scope is not authorized', async () => { + const request = createMockedRequest(); + const { handler, end } = createMockedHandler(); + + await handler({ + ...request, + params: { + ...request.params, + scope: 'eip155:999', + }, + }); + expect(end).toHaveBeenCalledWith(providerErrors.unauthorized()); + }); + + it('throws an unauthorized error if the requested scope method is not authorized', async () => { + const request = createMockedRequest(); + const { handler, end } = createMockedHandler(); + + await handler({ + ...request, + params: { + ...request.params, + request: { + ...request.params.request, + method: 'unauthorized_method', + }, + }, + }); + expect(end).toHaveBeenCalledWith(providerErrors.unauthorized()); + }); + + describe('ethereum scope', () => { + it('gets the networkClientId for the chainId', async () => { + const request = createMockedRequest(); + const { handler, findNetworkClientIdByChainId } = createMockedHandler(); + + await handler(request); + expect(findNetworkClientIdByChainId).toHaveBeenCalledWith('0x1'); + }); + + it('throws an internal error if a networkClientId does not exist for the chainId', async () => { + const request = createMockedRequest(); + const { handler, findNetworkClientIdByChainId, end } = + createMockedHandler(); + findNetworkClientIdByChainId.mockReturnValue(undefined); + + await handler(request); + expect(end).toHaveBeenCalledWith(rpcErrors.internal()); + }); + + it('sets the networkClientId and unwraps the CAIP-27 request', async () => { + const request = createMockedRequest(); + const { handler, next } = createMockedHandler(); + + await handler(request); + expect(request).toStrictEqual({ + jsonrpc: '2.0' as const, + id: 0, + scope: 'eip155:1', + origin: 'http://test.com', + networkClientId: 'mainnet', + method: 'eth_call', + params: { + foo: 'bar', + }, + }); + expect(next).toHaveBeenCalled(); + }); + }); + + describe('wallet scope', () => { + it('gets the networkClientId for the globally selected network', async () => { + const request = createMockedRequest(); + const { handler, getSelectedNetworkClientId } = createMockedHandler(); + + await handler({ + ...request, + params: { + ...request.params, + scope: 'wallet', + request: { + ...request.params.request, + method: 'wallet_watchAsset', + }, + }, + }); + expect(getSelectedNetworkClientId).toHaveBeenCalled(); + }); + + it('throws an internal error if a networkClientId cannot be retrieved for the globally selected network', async () => { + const request = createMockedRequest(); + const { handler, getSelectedNetworkClientId, end } = + createMockedHandler(); + getSelectedNetworkClientId.mockReturnValue(undefined); + + await handler({ + ...request, + params: { + ...request.params, + scope: 'wallet', + request: { + ...request.params.request, + method: 'wallet_watchAsset', + }, + }, + }); + expect(end).toHaveBeenCalledWith(rpcErrors.internal()); + }); + + it('sets the networkClientId and unwraps the CAIP-27 request', async () => { + const request = createMockedRequest(); + const { handler, next } = createMockedHandler(); + + const walletRequest = { + ...request, + params: { + ...request.params, + scope: 'wallet', + request: { + ...request.params.request, + method: 'wallet_watchAsset', + }, + }, + }; + await handler(walletRequest); + expect(walletRequest).toStrictEqual({ + jsonrpc: '2.0' as const, + id: 0, + scope: 'wallet', + origin: 'http://test.com', + networkClientId: 'selectedNetworkClientId', + method: 'wallet_watchAsset', + params: { + foo: 'bar', + }, + }); + expect(next).toHaveBeenCalled(); + }); + }); + + describe("'wallet:eip155' scope", () => { + it('gets the networkClientId for the globally selected network', async () => { + const request = createMockedRequest(); + const { handler, getSelectedNetworkClientId } = createMockedHandler(); + + await handler({ + ...request, + params: { + ...request.params, + scope: 'wallet:eip155', + request: { + ...request.params.request, + method: 'wallet_watchAsset', + }, + }, + }); + expect(getSelectedNetworkClientId).toHaveBeenCalled(); + }); + + it('throws an internal error if a networkClientId cannot be retrieved for the globally selected network', async () => { + const request = createMockedRequest(); + const { handler, getSelectedNetworkClientId, end } = + createMockedHandler(); + getSelectedNetworkClientId.mockReturnValue(undefined); + + await handler({ + ...request, + params: { + ...request.params, + scope: 'wallet:eip155', + request: { + ...request.params.request, + method: 'wallet_watchAsset', + }, + }, + }); + expect(end).toHaveBeenCalledWith(rpcErrors.internal()); + }); + + it('sets the networkClientId and unwraps the CAIP-27 request', async () => { + const request = createMockedRequest(); + const { handler, next } = createMockedHandler(); + + const walletRequest = { + ...request, + params: { + ...request.params, + scope: 'wallet:eip155', + request: { + ...request.params.request, + method: 'wallet_watchAsset', + }, + }, + }; + await handler(walletRequest); + expect(walletRequest).toStrictEqual({ + jsonrpc: '2.0' as const, + id: 0, + scope: 'wallet:eip155', + origin: 'http://test.com', + networkClientId: 'selectedNetworkClientId', + method: 'wallet_watchAsset', + params: { + foo: 'bar', + }, + }); + expect(next).toHaveBeenCalled(); + }); + }); + + describe('non-evm scope', () => { + it('forwards the unwrapped CAIP-27 request for authorized non-evm scopes to handleNonEvmRequestForOrigin', async () => { + const request = createMockedRequest(); + const { handler, handleNonEvmRequestForOrigin } = createMockedHandler(); + + await handler({ + ...request, + params: { + ...request.params, + scope: 'nonevm:scope', + request: { + ...request.params.request, + method: 'foobar', + }, + }, + }); + + expect(handleNonEvmRequestForOrigin).toHaveBeenCalledWith({ + connectedAddresses: ['nonevm:scope:0x1'], + scope: 'nonevm:scope', + request: { + id: 0, + jsonrpc: '2.0', + method: 'foobar', + origin: 'http://test.com', + params: { + foo: 'bar', + }, + scope: 'nonevm:scope', + }, + }); + }); + + it('sets response.result to the return value from handleNonEvmRequestForOrigin', async () => { + const request = createMockedRequest(); + const { handler, handleNonEvmRequestForOrigin, end, response } = + createMockedHandler(); + handleNonEvmRequestForOrigin.mockResolvedValue('nonEvmResult'); + await handler({ + ...request, + params: { + ...request.params, + scope: 'nonevm:scope', + request: { + ...request.params.request, + method: 'foobar', + }, + }, + }); + + expect(response).toStrictEqual({ + jsonrpc: '2.0', + id: 1, + result: 'nonEvmResult', + }); + expect(end).toHaveBeenCalledWith(); + }); + + it('returns an error if handleNonEvmRequestForOrigin throws', async () => { + const request = createMockedRequest(); + const { handler, handleNonEvmRequestForOrigin, end } = + createMockedHandler(); + handleNonEvmRequestForOrigin.mockRejectedValue( + new Error('handleNonEvemRequest failed'), + ); + await handler({ + ...request, + params: { + ...request.params, + scope: 'nonevm:scope', + request: { + ...request.params.request, + method: 'foobar', + }, + }, + }); + + expect(end).toHaveBeenCalledWith( + new Error('handleNonEvemRequest failed'), + ); + }); + }); +}); diff --git a/packages/multichain-api-middleware/src/handlers/wallet-invokeMethod.ts b/packages/multichain-api-middleware/src/handlers/wallet-invokeMethod.ts new file mode 100644 index 00000000000..aaba8539d0c --- /dev/null +++ b/packages/multichain-api-middleware/src/handlers/wallet-invokeMethod.ts @@ -0,0 +1,185 @@ +import type { ExternalScopeString } from '@metamask/chain-agnostic-permission'; +import { + Caip25CaveatType, + Caip25EndowmentPermissionName, + assertIsInternalScopeString, + getSessionScopes, + parseScopeString, +} from '@metamask/chain-agnostic-permission'; +import type { + JsonRpcEngineEndCallback, + JsonRpcEngineNextCallback, + MethodHandler, +} from '@metamask/json-rpc-engine'; +import type { + NetworkClientId, + NetworkController, +} from '@metamask/network-controller'; +import { providerErrors, rpcErrors } from '@metamask/rpc-errors'; +import type { MultichainRoutingService } from '@metamask/snaps-controllers'; +import { isObject, KnownCaipNamespace, numberToHex } from '@metamask/utils'; +import type { + CaipAccountId, + CaipChainId, + Json, + JsonRpcRequest, + PendingJsonRpcResponse, +} from '@metamask/utils'; + +import type { + Caip25Caveat, + GetCaveatForOriginHook, + GetNonEvmSupportedMethodsHook, + SortAccountIdsByLastSelectedHook, +} from './types.js'; + +export type WalletInvokeMethodParams = { + scope: ExternalScopeString; + request: Pick; +}; + +export type WalletInvokeMethodRequest = + JsonRpcRequest & { + origin: string; + }; + +export type WalletInvokeMethodHooks = GetCaveatForOriginHook & + GetNonEvmSupportedMethodsHook & + SortAccountIdsByLastSelectedHook & { + findNetworkClientIdByChainId: NetworkController['findNetworkClientIdByChainId']; + getSelectedNetworkClientId: () => NetworkClientId; + handleNonEvmRequestForOrigin: (params: { + connectedAddresses: CaipAccountId[]; + scope: CaipChainId; + request: JsonRpcRequest; + }) => ReturnType; + }; + +/** + * Handler for the `wallet_invokeMethod` RPC method as specified by [CAIP-27](https://chainagnostic.org/CAIPs/caip-27). + * The implementation below deviates from the linked spec in that it ignores the `sessionId` param + * and instead uses the singular session for the origin if available. + * + * @param request - The request object. + * @param response - The response object. Unused. + * @param next - The next middleware function. + * @param end - The end function. + * @param hooks - The hooks object. + * @param hooks.getCaveatForOrigin - the hook for getting a caveat from a permission for an origin. + * @param hooks.findNetworkClientIdByChainId - the hook for finding the networkClientId for a chainId. + * @param hooks.getSelectedNetworkClientId - the hook for getting the current globally selected networkClientId. + * @param hooks.getNonEvmSupportedMethods - A function that returns the supported methods for a non EVM scope. + * @param hooks.sortAccountIdsByLastSelected - A function that sorts accounts by their last selected order. + * @param hooks.handleNonEvmRequestForOrigin - A function that sends a request to the MultichainRouter for processing. + * @returns Nothing. + */ +async function handleWalletInvokeMethod( + request: WalletInvokeMethodRequest, + response: PendingJsonRpcResponse, + next: JsonRpcEngineNextCallback, + end: JsonRpcEngineEndCallback, + hooks: WalletInvokeMethodHooks, +) { + if (!isObject(request.params)) { + return end(rpcErrors.invalidParams({ data: { request } })); + } + + const { scope, request: wrappedRequest } = request.params; + assertIsInternalScopeString(scope); + + let caveat: Caip25Caveat | undefined; + try { + caveat = hooks.getCaveatForOrigin( + Caip25EndowmentPermissionName, + Caip25CaveatType, + ) as Caip25Caveat | undefined; + } catch { + // noop + } + if (!caveat) { + return end(providerErrors.unauthorized()); + } + + const scopeObject = getSessionScopes(caveat.value, { + getNonEvmSupportedMethods: hooks.getNonEvmSupportedMethods, + sortAccountIdsByLastSelected: hooks.sortAccountIdsByLastSelected, + })[scope]; + + if (!scopeObject?.methods?.includes(wrappedRequest.method)) { + return end(providerErrors.unauthorized()); + } + + const { namespace, reference } = parseScopeString(scope); + + const isEvmRequest = + (namespace === KnownCaipNamespace.Wallet && + (!reference || reference === KnownCaipNamespace.Eip155)) || + namespace === KnownCaipNamespace.Eip155; + + const unwrappedRequest = { + ...request, + scope, + method: wrappedRequest.method, + params: wrappedRequest.params, + }; + + if (isEvmRequest) { + let networkClientId; + if (namespace === KnownCaipNamespace.Wallet) { + networkClientId = hooks.getSelectedNetworkClientId(); + } else if (namespace === KnownCaipNamespace.Eip155) { + if (reference) { + networkClientId = hooks.findNetworkClientIdByChainId( + numberToHex(parseInt(reference, 10)), + ); + } + } + + if (!networkClientId) { + console.error( + 'failed to resolve network client for wallet_invokeMethod', + request, + ); + return end(rpcErrors.internal()); + } + + Object.assign(request, { + ...unwrappedRequest, + networkClientId, + }); + return next(); + } + + try { + response.result = await hooks.handleNonEvmRequestForOrigin({ + connectedAddresses: scopeObject.accounts, + // Type assertion: We know that scope is not "wallet" by now because it + // is already being handled above. + scope: scope as CaipChainId, + request: unwrappedRequest, + }); + } catch (err) { + return end(err as Error); + } + return end(); +} + +export type WalletInvokeMethodHandler = MethodHandler< + WalletInvokeMethodHooks, + never, + WalletInvokeMethodParams, + Json, + { origin: string } +>; + +export const walletInvokeMethodHandler = { + implementation: handleWalletInvokeMethod, + hookNames: { + getCaveatForOrigin: true, + findNetworkClientIdByChainId: true, + getSelectedNetworkClientId: true, + getNonEvmSupportedMethods: true, + sortAccountIdsByLastSelected: true, + handleNonEvmRequestForOrigin: true, + }, +} satisfies WalletInvokeMethodHandler; diff --git a/packages/multichain-api-middleware/src/handlers/wallet-revokeSession.test.ts b/packages/multichain-api-middleware/src/handlers/wallet-revokeSession.test.ts new file mode 100644 index 00000000000..d4f5035d42d --- /dev/null +++ b/packages/multichain-api-middleware/src/handlers/wallet-revokeSession.test.ts @@ -0,0 +1,239 @@ +import { + Caip25CaveatType, + Caip25EndowmentPermissionName, +} from '@metamask/chain-agnostic-permission'; +import { + PermissionDoesNotExistError, + UnrecognizedSubjectError, +} from '@metamask/permission-controller'; +import { rpcErrors } from '@metamask/rpc-errors'; +import type { JsonRpcRequest } from '@metamask/utils'; + +import { walletRevokeSessionHandler } from './wallet-revokeSession.js'; + +const baseRequest: JsonRpcRequest & { + origin: string; + params: { scopes?: string[] }; +} = { + origin: 'http://test.com', + params: {}, + jsonrpc: '2.0' as const, + id: 1, + method: 'wallet_revokeSession', +}; + +const createMockedHandler = () => { + const next = jest.fn(); + const end = jest.fn(); + const revokePermissionForOrigin = jest.fn(); + const updateCaveat = jest.fn(); + const getCaveatForOrigin = jest.fn(); + const response = { + result: true, + id: 1, + jsonrpc: '2.0' as const, + }; + const handler = ( + request: JsonRpcRequest & { + origin: string; + params: { scopes?: string[] }; + }, + ) => + walletRevokeSessionHandler.implementation(request, response, next, end, { + revokePermissionForOrigin, + updateCaveat, + getCaveatForOrigin, + }); + + return { + next, + response, + end, + revokePermissionForOrigin, + updateCaveat, + getCaveatForOrigin, + handler, + }; +}; + +describe('wallet_revokeSession', () => { + it('revokes the CAIP-25 endowment permission', async () => { + const { handler, revokePermissionForOrigin } = createMockedHandler(); + + await handler(baseRequest); + expect(revokePermissionForOrigin).toHaveBeenCalledWith( + Caip25EndowmentPermissionName, + ); + }); + + it('revokes the CAIP-25 endowment permission when params is not specified', async () => { + const { handler, revokePermissionForOrigin, response } = + createMockedHandler(); + const requestWithoutParams = { + origin: 'http://test.com', + jsonrpc: '2.0' as const, + id: 1, + method: 'wallet_revokeSession', + } as JsonRpcRequest & { + origin: string; + params: { scopes?: string[] }; + }; + + await handler(requestWithoutParams); + expect(revokePermissionForOrigin).toHaveBeenCalledWith( + Caip25EndowmentPermissionName, + ); + expect(response.result).toBe(true); + }); + + it('returns true without revoking if there is no active session and scopes are specified', async () => { + const { + handler, + getCaveatForOrigin, + revokePermissionForOrigin, + updateCaveat, + response, + } = createMockedHandler(); + getCaveatForOrigin.mockReturnValue(undefined); + + await handler({ ...baseRequest, params: { scopes: ['eip155:1'] } }); + + expect(revokePermissionForOrigin).not.toHaveBeenCalled(); + expect(updateCaveat).not.toHaveBeenCalled(); + expect(response.result).toBe(true); + }); + + it('partially revokes the CAIP-25 endowment permission if `scopes` param is passed in', async () => { + const { handler, getCaveatForOrigin, updateCaveat } = createMockedHandler(); + getCaveatForOrigin.mockImplementation(() => ({ + value: { + optionalScopes: { + 'eip155:1': { + accounts: ['eip155:1:0xdeadbeef'], + }, + 'eip155:5': { + accounts: ['eip155:5:0xdeadbeef'], + }, + 'eip155:10': { + accounts: ['eip155:10:0xdeadbeef'], + }, + }, + requiredScopes: {}, + }, + })); + + await handler({ ...baseRequest, params: { scopes: ['eip155:1'] } }); + expect(updateCaveat).toHaveBeenCalledWith( + Caip25EndowmentPermissionName, + Caip25CaveatType, + { + optionalScopes: { + 'eip155:5': { accounts: ['eip155:5:0xdeadbeef'] }, + 'eip155:10': { accounts: ['eip155:10:0xdeadbeef'] }, + }, + requiredScopes: {}, + }, + ); + }); + + it('not call `updateCaveat` if `scopes` param is passed in with non existing permitted scope', async () => { + const { handler, getCaveatForOrigin, updateCaveat } = createMockedHandler(); + getCaveatForOrigin.mockImplementation(() => ({ + value: { + optionalScopes: { + 'eip155:1': { + accounts: [], + }, + }, + requiredScopes: {}, + }, + })); + + await handler({ ...baseRequest, params: { scopes: ['eip155:5'] } }); + expect(updateCaveat).not.toHaveBeenCalled(); + }); + + it('fully revokes permission when all accounts are removed after scope removal', async () => { + const { + handler, + getCaveatForOrigin, + updateCaveat, + revokePermissionForOrigin, + } = createMockedHandler(); + getCaveatForOrigin.mockImplementation(() => ({ + value: { + optionalScopes: { + 'eip155:1': { + accounts: ['eip155:1:0xdeadbeef'], + }, + 'eip155:5': { + accounts: ['eip155:5:0xdeadbeef'], + }, + }, + requiredScopes: {}, + }, + })); + + await handler({ + ...baseRequest, + params: { scopes: ['eip155:1', 'eip155:5'] }, + }); + expect(updateCaveat).not.toHaveBeenCalled(); + expect(revokePermissionForOrigin).toHaveBeenCalledWith( + Caip25EndowmentPermissionName, + ); + }); + + it('returns true if the CAIP-25 endowment permission does not exist', async () => { + const { handler, response, revokePermissionForOrigin } = + createMockedHandler(); + revokePermissionForOrigin.mockImplementation(() => { + throw new PermissionDoesNotExistError( + 'foo.com', + Caip25EndowmentPermissionName, + ); + }); + + await handler(baseRequest); + expect(response.result).toBe(true); + }); + + it('returns true if the subject does not exist', async () => { + const { handler, response, revokePermissionForOrigin } = + createMockedHandler(); + revokePermissionForOrigin.mockImplementation(() => { + throw new UnrecognizedSubjectError('foo.com'); + }); + + await handler(baseRequest); + expect(response.result).toBe(true); + }); + + it('throws an internal RPC error if something unexpected goes wrong with revoking the permission', async () => { + const { handler, revokePermissionForOrigin, end } = createMockedHandler(); + revokePermissionForOrigin.mockImplementation(() => { + throw new Error('revoke failed'); + }); + + await handler(baseRequest); + expect(end).toHaveBeenCalledWith(rpcErrors.internal()); + }); + + it('throws an internal RPC error if a non-error is thrown', async () => { + const { handler, revokePermissionForOrigin, end } = createMockedHandler(); + revokePermissionForOrigin.mockImplementation(() => { + // eslint-disable-next-line @typescript-eslint/only-throw-error + throw 'revoke failed'; + }); + + await handler(baseRequest); + expect(end).toHaveBeenCalledWith(rpcErrors.internal()); + }); + + it('returns true if the permission was revoked', async () => { + const { handler, response } = createMockedHandler(); + + await handler(baseRequest); + expect(response.result).toBe(true); + }); +}); diff --git a/packages/multichain-api-middleware/src/handlers/wallet-revokeSession.ts b/packages/multichain-api-middleware/src/handlers/wallet-revokeSession.ts new file mode 100644 index 00000000000..265a0f7e67e --- /dev/null +++ b/packages/multichain-api-middleware/src/handlers/wallet-revokeSession.ts @@ -0,0 +1,177 @@ +import { + Caip25CaveatMutators, + Caip25CaveatType, + Caip25EndowmentPermissionName, + getCaipAccountIdsFromCaip25CaveatValue, +} from '@metamask/chain-agnostic-permission'; +import type { Caip25CaveatValue } from '@metamask/chain-agnostic-permission'; +import type { + JsonRpcEngineEndCallback, + JsonRpcEngineNextCallback, + MethodHandler, +} from '@metamask/json-rpc-engine'; +import { + CaveatMutatorOperation, + GenericPermissionController, + PermissionDoesNotExistError, + UnrecognizedSubjectError, +} from '@metamask/permission-controller'; +import { rpcErrors } from '@metamask/rpc-errors'; +import { isObject } from '@metamask/utils'; +import type { + Json, + JsonRpcRequest, + PendingJsonRpcResponse, +} from '@metamask/utils'; + +import type { Caip25Caveat, GetCaveatForOriginHook } from './types.js'; + +export type WalletRevokeSessionHooks = GetCaveatForOriginHook & { + revokePermissionForOrigin: ( + permissionName: string, + ) => ReturnType; + updateCaveat: ( + target: string, + caveatType: string, + caveatValue: Caip25CaveatValue, + ) => ReturnType; +}; + +type WalletRevokeSessionParams = { scopes?: string[] }; + +/** + * Check whether the given error is a permission error. + * + * @param error - The error to check. + * @returns Whether the error is a permission error. + */ +function isPermissionError(error: unknown) { + if ( + !isObject(error) || + !('name' in error) || + typeof error.name !== 'string' + ) { + return false; + } + + return [ + UnrecognizedSubjectError.name, + PermissionDoesNotExistError.name, + ].includes(error.name); +} + +/** + * Revokes specific session scopes from an existing caveat. + * Fully revokes permission if no accounts remain permitted after iterating through scopes. + * + * @param scopes - Array of scope strings to remove from the caveat. + * @param hooks - The hooks object. + * @param hooks.revokePermissionForOrigin - The hook for revoking a permission for an origin function. + * @param hooks.updateCaveat - The hook used to conditionally update the caveat rather than fully revoke the permission. + * @param hooks.getCaveatForOrigin - The hook to fetch an existing caveat for the origin of the request. + */ +function partialRevokePermissions( + scopes: string[], + hooks: WalletRevokeSessionHooks, +) { + const caveat = hooks.getCaveatForOrigin( + Caip25EndowmentPermissionName, + Caip25CaveatType, + ) as Caip25Caveat | undefined; + if (!caveat) { + return; + } + + let updatedCaveatValue = caveat.value; + + for (const scopeString of scopes) { + const result = Caip25CaveatMutators[Caip25CaveatType].removeScope( + updatedCaveatValue, + scopeString, + ); + + // If operation is a Noop, it means a scope was passed that was not present in the permission, so we proceed with the loop + if (result.operation === CaveatMutatorOperation.Noop) { + continue; + } + + updatedCaveatValue = result?.value ?? { + requiredScopes: {}, + optionalScopes: {}, + sessionProperties: {}, + isMultichainOrigin: true, + }; + } + + const caipAccountIds = + getCaipAccountIdsFromCaip25CaveatValue(updatedCaveatValue); + + // We fully revoke permission if no accounts are left after scope removal loop. + if (!caipAccountIds.length) { + hooks.revokePermissionForOrigin(Caip25EndowmentPermissionName); + } else { + hooks.updateCaveat( + Caip25EndowmentPermissionName, + Caip25CaveatType, + updatedCaveatValue, + ); + } +} + +/** + * Handler for the `wallet_revokeSession` RPC method as specified by [CAIP-285](https://chainagnostic.org/CAIPs/caip-285). + * The implementation below deviates from the linked spec in that it ignores the `sessionId` param + * and instead revokes the singular session for the origin if available. Additionally, + * the handler also does not return an error if there is currently no active session and instead + * returns true which is the same result returned if an active session was actually revoked. + * + * @param request - The JSON-RPC request object. Unused. + * @param response - The JSON-RPC response object. + * @param _next - The next middleware function. Unused. + * @param end - The end callback function. + * @param hooks - The hooks object. + * @param hooks.revokePermissionForOrigin - The hook for revoking a permission for an origin function. + * @param hooks.updateCaveat - The hook used to conditionally update the caveat rather than fully revoke the permission. + * @param hooks.getCaveatForOrigin - The hook to fetch an existing caveat for the origin of the request. + * @returns Nothing. + */ +async function handleWalletRevokeSession( + request: JsonRpcRequest & { origin: string }, + response: PendingJsonRpcResponse, + _next: JsonRpcEngineNextCallback, + end: JsonRpcEngineEndCallback, + hooks: WalletRevokeSessionHooks, +) { + try { + if (request.params?.scopes?.length) { + partialRevokePermissions(request.params.scopes, hooks); + } else { + hooks.revokePermissionForOrigin(Caip25EndowmentPermissionName); + } + } catch (err) { + if (!isPermissionError(err)) { + console.error(err); + return end(rpcErrors.internal()); + } + } + + response.result = true; + return end(); +} + +export type WalletRevokeSessionHandler = MethodHandler< + WalletRevokeSessionHooks, + never, + WalletRevokeSessionParams, + Json, + { origin: string } +>; + +export const walletRevokeSessionHandler = { + implementation: handleWalletRevokeSession, + hookNames: { + revokePermissionForOrigin: true, + updateCaveat: true, + getCaveatForOrigin: true, + }, +} satisfies WalletRevokeSessionHandler; diff --git a/packages/multichain-api-middleware/src/index.test.ts b/packages/multichain-api-middleware/src/index.test.ts new file mode 100644 index 00000000000..d904fd52fbe --- /dev/null +++ b/packages/multichain-api-middleware/src/index.test.ts @@ -0,0 +1,15 @@ +import * as allExports from './index.js'; + +describe('@metamask/multichain-api-middleware', () => { + it('has expected JavaScript exports', () => { + expect(Object.keys(allExports)).toMatchInlineSnapshot(` + [ + "methodHandlers", + "multichainMethodCallValidatorMiddleware", + "MultichainMiddlewareManager", + "MultichainSubscriptionManager", + "MultichainApiNotifications", + ] + `); + }); +}); diff --git a/packages/multichain-api-middleware/src/index.ts b/packages/multichain-api-middleware/src/index.ts new file mode 100644 index 00000000000..b0b00baab6b --- /dev/null +++ b/packages/multichain-api-middleware/src/index.ts @@ -0,0 +1,7 @@ +export { methodHandlers } from './handlers/index.js'; +export type { MethodHandlerHooks } from './handlers/index.js'; + +export { multichainMethodCallValidatorMiddleware } from './middlewares/multichainMethodCallValidatorMiddleware.js'; +export { MultichainMiddlewareManager } from './middlewares/MultichainMiddlewareManager.js'; +export { MultichainSubscriptionManager } from './middlewares/MultichainSubscriptionManager.js'; +export { MultichainApiNotifications } from './handlers/types.js'; diff --git a/packages/multichain-api-middleware/src/middlewares/MultichainMiddlewareManager.test.ts b/packages/multichain-api-middleware/src/middlewares/MultichainMiddlewareManager.test.ts new file mode 100644 index 00000000000..50f6ca186d4 --- /dev/null +++ b/packages/multichain-api-middleware/src/middlewares/MultichainMiddlewareManager.test.ts @@ -0,0 +1,377 @@ +import { rpcErrors } from '@metamask/rpc-errors'; + +import type { ExtendedJsonRpcMiddleware } from './MultichainMiddlewareManager.js'; +import { MultichainMiddlewareManager } from './MultichainMiddlewareManager.js'; + +const scope = 'eip155:1'; +const origin = 'example.com'; +const tabId = 123; + +describe('MultichainMiddlewareManager', () => { + it('should add middleware and get called for the scope, origin, and tabId if request is "eth_subscribe', () => { + const multichainMiddlewareManager = new MultichainMiddlewareManager(); + const middlewareSpy = jest.fn() as unknown as ExtendedJsonRpcMiddleware; + multichainMiddlewareManager.addMiddleware({ + scope, + origin, + tabId, + middleware: middlewareSpy, + }); + + const middleware = + multichainMiddlewareManager.generateMultichainMiddlewareForOriginAndTabId( + origin, + 123, + ); + + const nextSpy = jest.fn(); + const endSpy = jest.fn(); + + middleware( + { jsonrpc: '2.0' as const, id: 0, method: 'eth_subscribe', scope }, + { jsonrpc: '2.0', id: 0 }, + nextSpy, + endSpy, + ); + expect(middlewareSpy).toHaveBeenCalledWith( + { jsonrpc: '2.0' as const, id: 0, method: 'eth_subscribe', scope }, + { jsonrpc: '2.0', id: 0 }, + nextSpy, + endSpy, + ); + expect(nextSpy).not.toHaveBeenCalled(); + expect(endSpy).not.toHaveBeenCalled(); + }); + + it('should add middleware and get called for the scope, origin, and tabId if request is "eth_unsubscribe', () => { + const multichainMiddlewareManager = new MultichainMiddlewareManager(); + const middlewareSpy = jest.fn() as unknown as ExtendedJsonRpcMiddleware; + multichainMiddlewareManager.addMiddleware({ + scope, + origin, + tabId, + middleware: middlewareSpy, + }); + + const middleware = + multichainMiddlewareManager.generateMultichainMiddlewareForOriginAndTabId( + origin, + 123, + ); + + const nextSpy = jest.fn(); + const endSpy = jest.fn(); + + middleware( + { jsonrpc: '2.0' as const, id: 0, method: 'eth_unsubscribe', scope }, + { jsonrpc: '2.0', id: 0 }, + nextSpy, + endSpy, + ); + expect(middlewareSpy).toHaveBeenCalledWith( + { jsonrpc: '2.0' as const, id: 0, method: 'eth_unsubscribe', scope }, + { jsonrpc: '2.0', id: 0 }, + nextSpy, + endSpy, + ); + expect(nextSpy).not.toHaveBeenCalled(); + expect(endSpy).not.toHaveBeenCalled(); + }); + + it('should add middleware and call next if called for the scope, origin, and tabId but request is not "eth_subscribe" or "eth_unsubscribe"', () => { + const multichainMiddlewareManager = new MultichainMiddlewareManager(); + const middlewareSpy = jest.fn() as unknown as ExtendedJsonRpcMiddleware; + multichainMiddlewareManager.addMiddleware({ + scope, + origin, + tabId, + middleware: middlewareSpy, + }); + + const middleware = + multichainMiddlewareManager.generateMultichainMiddlewareForOriginAndTabId( + origin, + 123, + ); + + const nextSpy = jest.fn(); + const endSpy = jest.fn(); + + middleware( + { jsonrpc: '2.0' as const, id: 0, method: 'method', scope }, + { jsonrpc: '2.0', id: 0 }, + nextSpy, + endSpy, + ); + expect(middlewareSpy).not.toHaveBeenCalled(); + expect(nextSpy).toHaveBeenCalled(); + expect(endSpy).not.toHaveBeenCalled(); + }); + + it('call next if no middleware exists for scope, origin, and tabId and request is not "eth_subscribe" or "eth_unsubscribe', () => { + const multichainMiddlewareManager = new MultichainMiddlewareManager(); + const middleware = + multichainMiddlewareManager.generateMultichainMiddlewareForOriginAndTabId( + origin, + 123, + ); + + const nextSpy = jest.fn(); + const endSpy = jest.fn(); + + middleware( + { jsonrpc: '2.0' as const, id: 0, method: 'method', scope }, + { jsonrpc: '2.0', id: 0 }, + nextSpy, + endSpy, + ); + expect(nextSpy).toHaveBeenCalled(); + expect(endSpy).not.toHaveBeenCalled(); + }); + + it('return error if no middleware exists for scope, origin, and tabId and request is "eth_subscribe"', () => { + const multichainMiddlewareManager = new MultichainMiddlewareManager(); + const middleware = + multichainMiddlewareManager.generateMultichainMiddlewareForOriginAndTabId( + origin, + 123, + ); + + const nextSpy = jest.fn(); + const endSpy = jest.fn(); + + middleware( + { jsonrpc: '2.0' as const, id: 0, method: 'eth_subscribe', scope }, + { jsonrpc: '2.0', id: 0 }, + nextSpy, + endSpy, + ); + expect(nextSpy).not.toHaveBeenCalled(); + expect(endSpy).toHaveBeenCalledWith(rpcErrors.methodNotFound()); + }); + + it('return error if no middleware exists for scope, origin, and tabId and request is "eth_unsubscribe"', () => { + const multichainMiddlewareManager = new MultichainMiddlewareManager(); + const middleware = + multichainMiddlewareManager.generateMultichainMiddlewareForOriginAndTabId( + origin, + 123, + ); + + const nextSpy = jest.fn(); + const endSpy = jest.fn(); + + middleware( + { jsonrpc: '2.0' as const, id: 0, method: 'eth_unsubscribe', scope }, + { jsonrpc: '2.0', id: 0 }, + nextSpy, + endSpy, + ); + expect(nextSpy).not.toHaveBeenCalled(); + expect(endSpy).toHaveBeenCalledWith(rpcErrors.methodNotFound()); + }); + + it('should remove middleware by origin and tabId when the multiplexing middleware is destroyed and the middleware has no destroy function', async () => { + const multichainMiddlewareManager = new MultichainMiddlewareManager(); + const middlewareSpy = jest.fn() as unknown as ExtendedJsonRpcMiddleware; + multichainMiddlewareManager.addMiddleware({ + scope, + origin, + tabId, + middleware: middlewareSpy, + }); + + const middleware = + multichainMiddlewareManager.generateMultichainMiddlewareForOriginAndTabId( + origin, + 123, + ); + + await middleware.destroy?.(); + + const nextSpy = jest.fn(); + const endSpy = jest.fn(); + + middleware( + { jsonrpc: '2.0' as const, id: 0, method: 'method', scope }, + { jsonrpc: '2.0', id: 0 }, + nextSpy, + endSpy, + ); + expect(middlewareSpy).not.toHaveBeenCalled(); + expect(nextSpy).toHaveBeenCalled(); + expect(endSpy).not.toHaveBeenCalled(); + }); + + it('should remove middleware by origin and tabId when the multiplexing middleware is destroyed and the middleware destroy function resolves', async () => { + const multichainMiddlewareManager = new MultichainMiddlewareManager(); + const middlewareSpy = jest.fn() as unknown as ExtendedJsonRpcMiddleware; + // eslint-disable-next-line jest/prefer-spy-on + middlewareSpy.destroy = jest.fn().mockResolvedValue(undefined); + multichainMiddlewareManager.addMiddleware({ + scope, + origin, + tabId, + middleware: middlewareSpy, + }); + + const middleware = + multichainMiddlewareManager.generateMultichainMiddlewareForOriginAndTabId( + origin, + 123, + ); + + await middleware.destroy?.(); + + expect(middlewareSpy.destroy).toHaveBeenCalled(); + + const nextSpy = jest.fn(); + const endSpy = jest.fn(); + + middleware( + { jsonrpc: '2.0' as const, id: 0, method: 'method', scope }, + { jsonrpc: '2.0', id: 0 }, + nextSpy, + endSpy, + ); + expect(middlewareSpy).not.toHaveBeenCalled(); + expect(nextSpy).toHaveBeenCalled(); + expect(endSpy).not.toHaveBeenCalled(); + }); + + it('should remove middleware by origin and tabId when the multiplexing middleware is destroyed and the middleware destroy function rejects', async () => { + const multichainMiddlewareManager = new MultichainMiddlewareManager(); + const middlewareSpy = jest.fn() as unknown as ExtendedJsonRpcMiddleware; + // eslint-disable-next-line jest/prefer-spy-on + middlewareSpy.destroy = jest + .fn() + .mockRejectedValue( + new Error('failed to destroy the actual underlying middleware'), + ); + multichainMiddlewareManager.addMiddleware({ + scope, + origin, + tabId, + middleware: middlewareSpy, + }); + + const middleware = + multichainMiddlewareManager.generateMultichainMiddlewareForOriginAndTabId( + origin, + 123, + ); + + await middleware.destroy?.(); + + expect(middlewareSpy.destroy).toHaveBeenCalled(); + + const nextSpy = jest.fn(); + const endSpy = jest.fn(); + + middleware( + { jsonrpc: '2.0' as const, id: 0, method: 'method', scope }, + { jsonrpc: '2.0', id: 0 }, + nextSpy, + endSpy, + ); + expect(middlewareSpy).not.toHaveBeenCalled(); + expect(nextSpy).toHaveBeenCalled(); + expect(endSpy).not.toHaveBeenCalled(); + }); + + it('should remove middleware by scope', () => { + const multichainMiddlewareManager = new MultichainMiddlewareManager(); + const middlewareSpy = jest.fn() as unknown as ExtendedJsonRpcMiddleware; + multichainMiddlewareManager.addMiddleware({ + scope, + origin, + tabId, + middleware: middlewareSpy, + }); + + multichainMiddlewareManager.removeMiddlewareByScope(scope); + + const middleware = + multichainMiddlewareManager.generateMultichainMiddlewareForOriginAndTabId( + origin, + 123, + ); + + const nextSpy = jest.fn(); + const endSpy = jest.fn(); + + middleware( + { jsonrpc: '2.0' as const, id: 0, method: 'method', scope }, + { jsonrpc: '2.0', id: 0 }, + nextSpy, + endSpy, + ); + expect(middlewareSpy).not.toHaveBeenCalled(); + expect(nextSpy).toHaveBeenCalled(); + expect(endSpy).not.toHaveBeenCalled(); + }); + + it('should remove middleware by scope and origin', () => { + const multichainMiddlewareManager = new MultichainMiddlewareManager(); + const middlewareSpy = jest.fn() as unknown as ExtendedJsonRpcMiddleware; + multichainMiddlewareManager.addMiddleware({ + scope, + origin, + tabId, + middleware: middlewareSpy, + }); + + multichainMiddlewareManager.removeMiddlewareByScopeAndOrigin(scope, origin); + + const middleware = + multichainMiddlewareManager.generateMultichainMiddlewareForOriginAndTabId( + origin, + 123, + ); + + const nextSpy = jest.fn(); + const endSpy = jest.fn(); + + middleware( + { jsonrpc: '2.0' as const, id: 0, method: 'method', scope }, + { jsonrpc: '2.0', id: 0 }, + nextSpy, + endSpy, + ); + expect(middlewareSpy).not.toHaveBeenCalled(); + expect(nextSpy).toHaveBeenCalled(); + expect(endSpy).not.toHaveBeenCalled(); + }); + + it('should remove middleware by origin and tabId', () => { + const multichainMiddlewareManager = new MultichainMiddlewareManager(); + const middlewareSpy = jest.fn() as unknown as ExtendedJsonRpcMiddleware; + multichainMiddlewareManager.addMiddleware({ + scope, + origin, + tabId, + middleware: middlewareSpy, + }); + + multichainMiddlewareManager.removeMiddlewareByOriginAndTabId(origin, tabId); + + const middleware = + multichainMiddlewareManager.generateMultichainMiddlewareForOriginAndTabId( + origin, + 123, + ); + + const nextSpy = jest.fn(); + const endSpy = jest.fn(); + + middleware( + { jsonrpc: '2.0' as const, id: 0, method: 'method', scope }, + { jsonrpc: '2.0', id: 0 }, + nextSpy, + endSpy, + ); + expect(middlewareSpy).not.toHaveBeenCalled(); + expect(nextSpy).toHaveBeenCalled(); + expect(endSpy).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/multichain-api-middleware/src/middlewares/MultichainMiddlewareManager.ts b/packages/multichain-api-middleware/src/middlewares/MultichainMiddlewareManager.ts new file mode 100644 index 00000000000..cb996646c03 --- /dev/null +++ b/packages/multichain-api-middleware/src/middlewares/MultichainMiddlewareManager.ts @@ -0,0 +1,147 @@ +import type { ExternalScopeString } from '@metamask/chain-agnostic-permission'; +import type { + JsonRpcEngineEndCallback, + JsonRpcEngineNextCallback, +} from '@metamask/json-rpc-engine'; +import { rpcErrors } from '@metamask/rpc-errors'; +import type { JsonRpcRequest, PendingJsonRpcResponse } from '@metamask/utils'; + +export type ExtendedJsonRpcMiddleware = { + ( + req: JsonRpcRequest & { scope: string }, + res: PendingJsonRpcResponse, + next: JsonRpcEngineNextCallback, + end: JsonRpcEngineEndCallback, + ): void; + destroy?: () => void | Promise; +}; + +type MiddlewareKey = { + scope: ExternalScopeString; + origin: string; + tabId?: number; +}; +type MiddlewareEntry = MiddlewareKey & { + middleware: ExtendedJsonRpcMiddleware; +}; + +// Methods related to eth_subscriptions +const SubscriptionMethods = ['eth_subscribe', 'eth_unsubscribe']; + +/** + * A helper that facilates registering and calling of provided middleware instances + * in the RPC pipeline based on the incoming request's scope, origin, and tabId. + * The core purpose of this class is to enable and manage multichain subscriptions + * (i.e. eth_subscribe called accross different chains and domains). + * + * Note that only one middleware instance can be registered per scope, origin, tabId key. + */ +export class MultichainMiddlewareManager { + #middlewares: MiddlewareEntry[] = []; + + #getMiddlewareEntry({ + scope, + origin, + tabId, + }: MiddlewareKey): MiddlewareEntry | undefined { + return this.#middlewares.find((middlewareEntry) => { + return ( + middlewareEntry.scope === scope && + middlewareEntry.origin === origin && + middlewareEntry.tabId === tabId + ); + }); + } + + #removeMiddlewareEntry({ scope, origin, tabId }: MiddlewareEntry) { + this.#middlewares = this.#middlewares.filter((middlewareEntry) => { + return ( + middlewareEntry.scope !== scope || + middlewareEntry.origin !== origin || + middlewareEntry.tabId !== tabId + ); + }); + } + + addMiddleware(middlewareEntry: MiddlewareEntry) { + const { scope, origin, tabId } = middlewareEntry; + if (!this.#getMiddlewareEntry({ scope, origin, tabId })) { + this.#middlewares.push(middlewareEntry); + } + } + + #removeMiddleware(middlewareEntry: MiddlewareEntry) { + // When the destroy function on the middleware is async, + // we don't need to wait for it complete + Promise.resolve(middlewareEntry.middleware.destroy?.()).catch(() => { + // do nothing + }); + + this.#removeMiddlewareEntry(middlewareEntry); + } + + removeMiddlewareByScope(scope: ExternalScopeString) { + this.#middlewares.forEach((middlewareEntry) => { + if (middlewareEntry.scope === scope) { + this.#removeMiddleware(middlewareEntry); + } + }); + } + + removeMiddlewareByScopeAndOrigin(scope: ExternalScopeString, origin: string) { + this.#middlewares.forEach((middlewareEntry) => { + if ( + middlewareEntry.scope === scope && + middlewareEntry.origin === origin + ) { + this.#removeMiddleware(middlewareEntry); + } + }); + } + + removeMiddlewareByOriginAndTabId(origin: string, tabId?: number) { + this.#middlewares.forEach((middlewareEntry) => { + if ( + middlewareEntry.origin === origin && + middlewareEntry.tabId === tabId + ) { + this.#removeMiddleware(middlewareEntry); + } + }); + } + + generateMultichainMiddlewareForOriginAndTabId( + origin: string, + tabId?: number, + ) { + const middleware: ExtendedJsonRpcMiddleware = (req, res, next, end) => { + const { scope } = req; + const middlewareEntry = this.#getMiddlewareEntry({ + scope, + origin, + tabId, + }); + + if (SubscriptionMethods.includes(req.method)) { + if (middlewareEntry) { + middlewareEntry.middleware(req, res, next, end); + } else { + // TODO: Temporary safety guard to prevent requests with these methods + // from being forwarded to the RPC endpoint even though this scenario + // should not be possible. + return end(rpcErrors.methodNotFound()); + } + } else { + return next(); + } + return undefined; + }; + middleware.destroy = this.removeMiddlewareByOriginAndTabId.bind( + this, + origin, + tabId, + ); + + return middleware; + } +} diff --git a/packages/multichain-api-middleware/src/middlewares/MultichainSubscriptionManager.test.ts b/packages/multichain-api-middleware/src/middlewares/MultichainSubscriptionManager.test.ts new file mode 100644 index 00000000000..59f5390d37d --- /dev/null +++ b/packages/multichain-api-middleware/src/middlewares/MultichainSubscriptionManager.test.ts @@ -0,0 +1,166 @@ +import createSubscriptionManager from '@metamask/eth-json-rpc-filters/subscriptionManager'; +import type SafeEventEmitter from '@metamask/safe-event-emitter'; + +import { MultichainApiNotifications } from '../handlers/types.js'; +import { MultichainSubscriptionManager } from './MultichainSubscriptionManager.js'; + +jest.mock('@metamask/eth-json-rpc-filters/subscriptionManager', () => + jest.fn(), +); +const MockCreateSubscriptionManager = jest.mocked(createSubscriptionManager); + +const newHeadsNotificationMock = { + method: 'eth_subscription', + params: { + result: { + difficulty: '0x15d9223a23aa', + extraData: '0xd983010305844765746887676f312e342e328777696e646f7773', + gasLimit: '0x47e7c4', + gasUsed: '0x38658', + logsBloom: + '0x00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000', + miner: '0xf8b483dba2c3b7176a3da549ad41a48bb3121069', + nonce: '0x084149998194cc5f', + number: '0x1348c9', + parentHash: + '0x7736fab79e05dc611604d22470dadad26f56fe494421b5b333de816ce1f25701', + receiptRoot: + '0x2fab35823ad00c7bb388595cb46652fe7886e00660a01e867824d3dceb1c8d36', + sha3Uncles: + '0x1dcc4de8dec75d7aab85b567b6ccd41ad312451b948a7413f0a142fd40d49347', + stateRoot: + '0xb3346685172db67de536d8765c43c31009d0eb3bd9c501c9be3229203f15f378', + timestamp: '0x56ffeff8', + }, + }, +}; + +const scope = 'eip155:1'; +const origin = 'example.com'; +const tabId = 123; + +const createMultichainSubscriptionManager = () => { + const mockFindNetworkClientIdByChainId = jest.fn(); + const mockGetNetworkClientById = jest.fn().mockImplementation(() => ({ + blockTracker: {}, + provider: {}, + })); + const multichainSubscriptionManager = new MultichainSubscriptionManager({ + findNetworkClientIdByChainId: mockFindNetworkClientIdByChainId, + getNetworkClientById: mockGetNetworkClientById, + }); + + return { multichainSubscriptionManager }; +}; + +const createMockSubscriptionManager = () => ({ + events: { + on: jest.fn(), + } as unknown as jest.Mocked, + destroy: jest.fn(), + middleware: { + destroy: jest.fn(), + }, +}); + +describe('MultichainSubscriptionManager', () => { + let mockSubscriptionManager = createMockSubscriptionManager(); + + beforeEach(() => { + mockSubscriptionManager = createMockSubscriptionManager(); + MockCreateSubscriptionManager.mockReturnValue(mockSubscriptionManager); + }); + + it('should not create a new subscriptionManager if one matches the passed in subscriptionKey', () => { + const { multichainSubscriptionManager } = + createMultichainSubscriptionManager(); + + const firstSubscription = multichainSubscriptionManager.subscribe({ + scope, + origin, + tabId, + }); + + const secondSubscription = multichainSubscriptionManager.subscribe({ + scope, + origin, + tabId, + }); + + expect(secondSubscription).toBe(firstSubscription); + expect(MockCreateSubscriptionManager).toHaveBeenCalledTimes(1); + }); + + it('should subscribe to a scope, origin, and tabId', () => { + const { multichainSubscriptionManager } = + createMultichainSubscriptionManager(); + multichainSubscriptionManager.subscribe({ scope, origin, tabId }); + const notifySpy = jest.fn(); + multichainSubscriptionManager.on('notification', notifySpy); + + mockSubscriptionManager.events.on.mock.calls[0][1]( + newHeadsNotificationMock, + ); + + expect(notifySpy).toHaveBeenCalledWith(origin, tabId, { + method: MultichainApiNotifications.walletNotify, + params: { + scope, + notification: newHeadsNotificationMock, + }, + }); + }); + + it('should unsubscribe from a scope', () => { + const { multichainSubscriptionManager } = + createMultichainSubscriptionManager(); + multichainSubscriptionManager.subscribe({ scope, origin, tabId }); + multichainSubscriptionManager.unsubscribeByScope(scope); + + expect(mockSubscriptionManager.destroy).toHaveBeenCalled(); + }); + + it('should unsubscribe from a scope and origin', () => { + const { multichainSubscriptionManager } = + createMultichainSubscriptionManager(); + multichainSubscriptionManager.subscribe({ scope, origin, tabId }); + multichainSubscriptionManager.unsubscribeByScopeAndOrigin(scope, origin); + + expect(mockSubscriptionManager.destroy).toHaveBeenCalled(); + }); + + it('should do nothing if an unsubscribe call does not match an existing subscription', () => { + const { multichainSubscriptionManager } = + createMultichainSubscriptionManager(); + multichainSubscriptionManager.subscribe({ scope, origin, tabId }); + multichainSubscriptionManager.unsubscribeByScope('eip155:10'); + multichainSubscriptionManager.unsubscribeByScopeAndOrigin( + scope, + 'other-origin', + ); + multichainSubscriptionManager.unsubscribeByOriginAndTabId( + 'other-origin', + 123, + ); + + expect(mockSubscriptionManager.destroy).not.toHaveBeenCalled(); + }); + + it('should unsubscribe from a origin and tabId', () => { + const { multichainSubscriptionManager } = + createMultichainSubscriptionManager(); + multichainSubscriptionManager.subscribe({ scope, origin, tabId }); + multichainSubscriptionManager.unsubscribeByOriginAndTabId(origin, tabId); + + expect(mockSubscriptionManager.destroy).toHaveBeenCalled(); + }); + + it('should unsubscribe when the middleware is destroyed', () => { + const { multichainSubscriptionManager } = + createMultichainSubscriptionManager(); + multichainSubscriptionManager.subscribe({ scope, origin, tabId }); + mockSubscriptionManager.middleware.destroy(); + + expect(mockSubscriptionManager.destroy).toHaveBeenCalled(); + }); +}); diff --git a/packages/multichain-api-middleware/src/middlewares/MultichainSubscriptionManager.ts b/packages/multichain-api-middleware/src/middlewares/MultichainSubscriptionManager.ts new file mode 100644 index 00000000000..6d3e8c0958f --- /dev/null +++ b/packages/multichain-api-middleware/src/middlewares/MultichainSubscriptionManager.ts @@ -0,0 +1,174 @@ +import type { ExternalScopeString } from '@metamask/chain-agnostic-permission'; +import { toHex } from '@metamask/controller-utils'; +import createSubscriptionManager from '@metamask/eth-json-rpc-filters/subscriptionManager'; +import type { NetworkController } from '@metamask/network-controller'; +import SafeEventEmitter from '@metamask/safe-event-emitter'; +import type { CaipChainId, Hex } from '@metamask/utils'; +import { parseCaipChainId } from '@metamask/utils'; + +import { MultichainApiNotifications } from '../handlers/types.js'; +import type { ExtendedJsonRpcMiddleware } from './MultichainMiddlewareManager.js'; + +export type SubscriptionManager = { + events: SafeEventEmitter; + destroy?: () => void; + middleware: ExtendedJsonRpcMiddleware; +}; + +type SubscriptionNotificationEvent = { + jsonrpc: '2.0'; + method: 'eth_subscription'; + params: { + subscription: Hex; + result: unknown; + }; +}; + +type SubscriptionKey = { + scope: ExternalScopeString; + origin: string; + tabId?: number; +}; +type SubscriptionEntry = SubscriptionKey & { + subscriptionManager: SubscriptionManager; +}; + +type MultichainSubscriptionManagerOptions = { + findNetworkClientIdByChainId: NetworkController['findNetworkClientIdByChainId']; + getNetworkClientById: NetworkController['getNetworkClientById']; +}; + +/** + * A helper that facilates the lifecycle of a SubscriptionManager instance that + * is meant to handle subscriptons for only one specific scope, origin, and tabId combination. + */ +export class MultichainSubscriptionManager extends SafeEventEmitter { + readonly #findNetworkClientIdByChainId: NetworkController['findNetworkClientIdByChainId']; + + readonly #getNetworkClientById: NetworkController['getNetworkClientById']; + + #subscriptions: SubscriptionEntry[] = []; + + /** + * Construct a MultichainSubscriptionManager. + * + * @param options - The controller options. + * @param options.findNetworkClientIdByChainId - The hook to get the networkClientId from a chainId. + * @param options.getNetworkClientById - The hook to get the network client instance by its networkClientId. + */ + constructor(options: MultichainSubscriptionManagerOptions) { + super(); + this.#findNetworkClientIdByChainId = options.findNetworkClientIdByChainId; + this.#getNetworkClientById = options.getNetworkClientById; + } + + notify( + { scope, origin, tabId }: SubscriptionKey, + { method, params }: SubscriptionNotificationEvent, + ) { + this.emit('notification', origin, tabId, { + method: MultichainApiNotifications.walletNotify, + params: { + scope, + notification: { method, params }, + }, + }); + } + + #getSubscriptionEntry({ + scope, + origin, + tabId, + }: SubscriptionKey): SubscriptionEntry | undefined { + return this.#subscriptions.find((subscriptionEntry) => { + return ( + subscriptionEntry.scope === scope && + subscriptionEntry.origin === origin && + subscriptionEntry.tabId === tabId + ); + }); + } + + #removeSubscriptionEntry({ scope, origin, tabId }: SubscriptionEntry) { + this.#subscriptions = this.#subscriptions.filter((subscriptionEntry) => { + return ( + subscriptionEntry.scope !== scope || + subscriptionEntry.origin !== origin || + subscriptionEntry.tabId !== tabId + ); + }); + } + + subscribe(subscriptionKey: SubscriptionKey) { + const subscriptionEntry = this.#getSubscriptionEntry(subscriptionKey); + if (subscriptionEntry) { + return subscriptionEntry.subscriptionManager; + } + + const networkClientId = this.#findNetworkClientIdByChainId( + toHex(parseCaipChainId(subscriptionKey.scope as CaipChainId).reference), + ); + const networkClient = this.#getNetworkClientById(networkClientId); + const subscriptionManager = createSubscriptionManager({ + blockTracker: networkClient.blockTracker, + provider: networkClient.provider, + }); + + subscriptionManager.events.on( + 'notification', + (message: SubscriptionNotificationEvent) => { + this.notify(subscriptionKey, message); + }, + ); + + const newSubscriptionManagerEntry = { + ...subscriptionKey, + subscriptionManager, + }; + subscriptionManager.destroy = subscriptionManager.middleware.destroy; + subscriptionManager.middleware.destroy = this.#unsubscribe.bind( + this, + newSubscriptionManagerEntry, + ); + + this.#subscriptions.push(newSubscriptionManagerEntry); + + return subscriptionManager; + } + + #unsubscribe(subscriptionEntry: SubscriptionEntry) { + subscriptionEntry.subscriptionManager.destroy?.(); + + this.#removeSubscriptionEntry(subscriptionEntry); + } + + unsubscribeByScope(scope: ExternalScopeString) { + this.#subscriptions.forEach((subscriptionEntry) => { + if (subscriptionEntry.scope === scope) { + this.#unsubscribe(subscriptionEntry); + } + }); + } + + unsubscribeByScopeAndOrigin(scope: ExternalScopeString, origin: string) { + this.#subscriptions.forEach((subscriptionEntry) => { + if ( + subscriptionEntry.scope === scope && + subscriptionEntry.origin === origin + ) { + this.#unsubscribe(subscriptionEntry); + } + }); + } + + unsubscribeByOriginAndTabId(origin: string, tabId?: number) { + this.#subscriptions.forEach((subscriptionEntry) => { + if ( + subscriptionEntry.origin === origin && + subscriptionEntry.tabId === tabId + ) { + this.#unsubscribe(subscriptionEntry); + } + }); + } +} diff --git a/packages/multichain-api-middleware/src/middlewares/multichainMethodCallValidatorMiddleware.test.ts b/packages/multichain-api-middleware/src/middlewares/multichainMethodCallValidatorMiddleware.test.ts new file mode 100644 index 00000000000..741e97555f0 --- /dev/null +++ b/packages/multichain-api-middleware/src/middlewares/multichainMethodCallValidatorMiddleware.test.ts @@ -0,0 +1,475 @@ +import type { + JsonRpcError, + JsonRpcRequest, + JsonRpcResponse, +} from '@metamask/utils'; + +import { MultichainApiNotifications } from '../handlers/types.js'; +import { multichainMethodCallValidatorMiddleware } from './multichainMethodCallValidatorMiddleware.js'; + +describe('multichainMethodCallValidatorMiddleware', () => { + const mockNext = jest.fn(); + + describe('"wallet_invokeMethod" request', () => { + it('should pass validation and call next when passed a valid "wallet_invokeMethod" request', async () => { + const request: JsonRpcRequest = { + id: 1, + jsonrpc: '2.0', + method: 'wallet_invokeMethod', + params: { + scope: 'test', + request: { + method: 'test_method', + params: { + test: 'test', + }, + }, + }, + }; + const response = {} as JsonRpcResponse; + + await new Promise((resolve, reject) => { + multichainMethodCallValidatorMiddleware( + request, + response, + mockNext, + (error) => { + reject(error); + }, + ); + + process.nextTick(() => { + try { + expect(mockNext).toHaveBeenCalled(); + resolve(); + } catch (error) { + reject(error); + } + }); + }); + }); + it('should throw an error when passed a "wallet_invokeMethod" request with no scope', async () => { + const request: JsonRpcRequest = { + id: 1, + jsonrpc: '2.0', + method: 'wallet_invokeMethod', + params: { + request: { + method: 'test_method', + params: { + test: 'test', + }, + }, + }, + }; + const response = {} as JsonRpcResponse; + + await new Promise((resolve, reject) => { + multichainMethodCallValidatorMiddleware( + request, + response, + mockNext, + (error) => { + try { + const rpcError = error as JsonRpcError & { data: JsonRpcError[] }; + expect(rpcError.message).toBe('Invalid method parameter(s).'); + expect(rpcError.code).toBe(-32602); + expect(rpcError.data[0].data).toStrictEqual({ + got: undefined, + param: 'scope', + path: [], + schema: { + pattern: '[-a-z0-9]{3,8}(:[-_a-zA-Z0-9]{1,32})?', + type: 'string', + }, + }); + expect(rpcError.data[0].message).toBe( + 'scope is required, but is undefined', + ); + resolve(); + } catch (e) { + reject(e); + } + }, + ); + + process.nextTick(() => { + try { + expect(mockNext).not.toHaveBeenCalled(); + resolve(); + } catch (error) { + reject(error); + } + }); + }); + }); + it('should throw an error for a "wallet_invokeMethod" request without a nested request object', async () => { + const request: JsonRpcRequest = { + id: 1, + jsonrpc: '2.0', + method: 'wallet_invokeMethod', + params: { + scope: 'test', + }, + }; + const response = {} as JsonRpcResponse; + + await new Promise((resolve, reject) => { + multichainMethodCallValidatorMiddleware( + request, + response, + mockNext, + (error) => { + try { + const rpcError = error as JsonRpcError & { data: JsonRpcError[] }; + expect(rpcError.message).toBe('Invalid method parameter(s).'); + expect(rpcError.code).toBe(-32602); + expect(rpcError.data[0].data).toStrictEqual({ + got: undefined, + param: 'request', + path: [], + schema: { + properties: { + method: { + type: 'string', + }, + params: true, + }, + type: 'object', + }, + }); + expect(rpcError.data[0].message).toBe( + 'request is required, but is undefined', + ); + resolve(); + } catch (e) { + reject(e); + } + }, + ); + + process.nextTick(() => { + try { + expect(mockNext).not.toHaveBeenCalled(); + resolve(); + } catch (error) { + reject(error); + } + }); + }); + }); + it('should throw an error for an invalidly formatted "wallet_invokeMethod" request', async () => { + const request: JsonRpcRequest = { + id: 1, + jsonrpc: '2.0', + method: 'wallet_invokeMethod', + params: { + scope: 'test', + request: { + method: {}, // expected to be a string + params: { + test: 'test', + }, + }, + }, + }; + const response = {} as JsonRpcResponse; + + await new Promise((resolve, reject) => { + multichainMethodCallValidatorMiddleware( + request, + response, + mockNext, + (error) => { + try { + const rpcError = error as JsonRpcError & { data: JsonRpcError[] }; + expect(rpcError.message).toBe('Invalid method parameter(s).'); + expect(rpcError.code).toBe(-32602); + expect(rpcError.data[0].data).toStrictEqual({ + got: { + method: {}, + params: { + test: 'test', + }, + }, + param: 'request', + path: ['method'], + schema: { + type: 'string', + }, + }); + expect(rpcError.data[0].message).toBe( + 'request.method is not of a type(s) string', + ); + resolve(); + } catch (e) { + reject(e); + } + }, + ); + + process.nextTick(() => { + try { + expect(mockNext).not.toHaveBeenCalled(); + resolve(); + } catch (error) { + reject(error); + } + }); + }); + }); + }); + + describe('"wallet_notify" request', () => { + it('should pass validation for a "wallet_notify" request and call next', async () => { + const request: JsonRpcRequest = { + id: 2, + jsonrpc: '2.0', + method: MultichainApiNotifications.walletNotify, + params: { + scope: 'test_scope', + notification: { + method: 'test_method', + params: { + data: { + key: 'value', + }, + }, + }, + }, + }; + const response = {} as JsonRpcResponse; + + await new Promise((resolve, reject) => { + multichainMethodCallValidatorMiddleware( + request, + response, + mockNext, + (error) => { + reject(error); + }, + ); + + process.nextTick(() => { + try { + expect(mockNext).toHaveBeenCalled(); + resolve(); + } catch (error) { + reject(error); + } + }); + }); + }); + + it('should throw an error for a "wallet_notify" request with invalid params', async () => { + const request: JsonRpcRequest = { + id: 2, + jsonrpc: '2.0', + method: MultichainApiNotifications.walletNotify, + params: { + scope: 'test_scope', + request: { + data: {}, + }, + }, + }; + const response = {} as JsonRpcResponse; + + await new Promise((resolve, reject) => { + multichainMethodCallValidatorMiddleware( + request, + response, + mockNext, + (error) => { + try { + const rpcError = error as JsonRpcError & { data: JsonRpcError[] }; + expect(rpcError.message).toBe('Invalid method parameter(s).'); + expect(rpcError.code).toBe(-32602); + expect(rpcError.data[0].data).toStrictEqual({ + got: undefined, + param: 'notification', + path: [], + schema: { + properties: { + method: { + type: 'string', + }, + params: true, + }, + type: 'object', + }, + }); + expect(rpcError.data[0].message).toBe( + 'notification is required, but is undefined', + ); + resolve(); + } catch (e) { + reject(e); + } + }, + ); + + process.nextTick(() => { + try { + expect(mockNext).not.toHaveBeenCalled(); + resolve(); + } catch (error) { + reject(error); + } + }); + }); + }); + }); + + describe('"wallet_revokeSession" request', () => { + it('should pass validation and call next when passed a valid "wallet_revokeSession" request', async () => { + const request: JsonRpcRequest = { + id: 3, + jsonrpc: '2.0', + method: 'wallet_revokeSession', + }; + const response = {} as JsonRpcResponse; + + await new Promise((resolve, reject) => { + multichainMethodCallValidatorMiddleware( + request, + response, + mockNext, + (error) => { + reject(error); + }, + ); + + process.nextTick(() => { + try { + expect(mockNext).toHaveBeenCalled(); + resolve(); + } catch (error) { + reject(error); + } + }); + }); + }); + }); + + describe('"wallet_getSession" request', () => { + it('should pass validation and call next when passed a valid "wallet_getSession" request', async () => { + const request: JsonRpcRequest = { + id: 5, + jsonrpc: '2.0', + method: 'wallet_getSession', + params: {}, + }; + const response = {} as JsonRpcResponse; + + await new Promise((resolve, reject) => { + multichainMethodCallValidatorMiddleware( + request, + response, + mockNext, + (error) => { + reject(error); + }, + ); + + process.nextTick(() => { + try { + expect(mockNext).toHaveBeenCalled(); + resolve(); + } catch (error) { + reject(error); + } + }); + }); + }); + }); + + it('should throw an error if the top level params are not an object', async () => { + const request: JsonRpcRequest = { + id: 1, + jsonrpc: '2.0', + method: 'wallet_invokeMethod', + params: ['test'], + }; + const response = {} as JsonRpcResponse; + + await new Promise((resolve, reject) => { + multichainMethodCallValidatorMiddleware( + request, + response, + mockNext, + (error) => { + try { + expect(error).toBeDefined(); + expect((error as JsonRpcError).code).toBe(-32602); + expect((error as JsonRpcError).message).toBe( + 'Invalid method parameter(s).', + ); + resolve(); + } catch (e) { + reject(e); + } + }, + ); + + process.nextTick(() => { + try { + expect(mockNext).not.toHaveBeenCalled(); + resolve(); + } catch (error) { + reject(error); + } + }); + }); + }); + + it('should throw an error when passed an unknown method at the top level', async () => { + const request: JsonRpcRequest = { + id: 1, + jsonrpc: '2.0', + method: 'unknown_method', + params: { + request: { + method: 'test_method', + params: { + test: 'test', + }, + }, + }, + }; + const response = {} as JsonRpcResponse; + + await new Promise((resolve, reject) => { + multichainMethodCallValidatorMiddleware( + request, + response, + mockNext, + (error) => { + try { + const rpcError = error as JsonRpcError & { data: JsonRpcError[] }; + expect(rpcError.message).toBe('Invalid method parameter(s).'); + expect(rpcError.code).toBe(-32602); + expect(rpcError.data[0].data).toStrictEqual({ + method: 'unknown_method', + }); + expect(rpcError.data[0].message).toBe( + 'The method does not exist / is not available.', + ); + resolve(); + } catch (e) { + reject(e); + } + }, + ); + + process.nextTick(() => { + try { + expect(mockNext).not.toHaveBeenCalled(); + resolve(); + } catch (error) { + reject(error); + } + }); + }); + }); +}); diff --git a/packages/multichain-api-middleware/src/middlewares/multichainMethodCallValidatorMiddleware.ts b/packages/multichain-api-middleware/src/middlewares/multichainMethodCallValidatorMiddleware.ts new file mode 100644 index 00000000000..77977930849 --- /dev/null +++ b/packages/multichain-api-middleware/src/middlewares/multichainMethodCallValidatorMiddleware.ts @@ -0,0 +1,108 @@ +import { MultiChainOpenRPCDocument } from '@metamask/api-specs'; +import { createAsyncMiddleware } from '@metamask/json-rpc-engine'; +import { rpcErrors } from '@metamask/rpc-errors'; +import { isObject } from '@metamask/utils'; +import type { JsonRpcError, JsonRpcParams } from '@metamask/utils'; +import type { + ContentDescriptorObject, + MethodObject, + OpenrpcDocument, + ReferenceObject, +} from '@open-rpc/meta-schema'; +import dereferenceDocument from '@open-rpc/schema-utils-js/build/dereference-document'; +import { makeCustomResolver } from '@open-rpc/schema-utils-js/build/parse-open-rpc-document'; +import type { Schema, ValidationError } from 'jsonschema'; +import { Validator } from 'jsonschema'; + +const transformError = ( + error: ValidationError, + param: ContentDescriptorObject, + got: unknown, +) => { + // if there is a path, add it to the message + const message = `${param.name}${ + error.path.length > 0 ? `.${error.path.join('.')}` : '' + } ${error.message}`; + + return rpcErrors.invalidParams({ + message, + data: { + param: param.name, + path: error.path, + schema: error.schema, + got, + }, + }); +}; + +const v = new Validator(); + +const dereffedPromise = dereferenceDocument( + MultiChainOpenRPCDocument as unknown as OpenrpcDocument, + makeCustomResolver({}), +); + +/** + * Helper that utilizes the Multichain method specifications from `@metamask/api-specs` + * to validate the params of a Multichain request. + * + * @param method - The request's method. + * @param params - The request's optional JsonRpcParams object. + * @returns an array of error objects for each validation error or an empty array if no errors. + */ +const multichainMethodCallValidator = async ( + method: string, + params: JsonRpcParams | undefined, +) => { + const dereffed = await dereffedPromise; + + const methodToCheck = dereffed.methods.find( + (m: MethodObject | ReferenceObject) => (m as MethodObject).name === method, + ) as MethodObject | undefined; + + if ( + !methodToCheck || + !isObject(methodToCheck) || + !('params' in methodToCheck) + ) { + return [rpcErrors.methodNotFound({ data: { method } })] as JsonRpcError[]; + } + + const errors: JsonRpcError[] = []; + for (const param of methodToCheck.params) { + if (!isObject(params)) { + return [rpcErrors.invalidParams()] as JsonRpcError[]; + } + const p = param as ContentDescriptorObject; + const paramToCheck = params[p.name]; + + const result = v.validate(paramToCheck, p.schema as unknown as Schema, { + required: p.required, + }); + if (result.errors) { + errors.push( + ...result.errors.map((e) => { + return transformError(e, p, paramToCheck) as JsonRpcError; + }), + ); + } + } + return errors; +}; + +/** + * Middleware that validates the params of a Multichain method request + * using the specifications from `@metamask/api-specs`. + */ +export const multichainMethodCallValidatorMiddleware = createAsyncMiddleware( + async (request, _response, next) => { + const errors = await multichainMethodCallValidator( + request.method, + request.params, + ); + if (errors.length > 0) { + throw rpcErrors.invalidParams({ data: errors }); + } + return await next(); + }, +); diff --git a/packages/multichain-api-middleware/tsconfig.build.json b/packages/multichain-api-middleware/tsconfig.build.json new file mode 100644 index 00000000000..5166896b04a --- /dev/null +++ b/packages/multichain-api-middleware/tsconfig.build.json @@ -0,0 +1,33 @@ +{ + "extends": "../../tsconfig.packages.build.json", + "compilerOptions": { + "baseUrl": "./", + "resolveJsonModule": true, + "outDir": "./dist", + "rootDir": "./src" + }, + "references": [ + { + "path": "../accounts-controller/tsconfig.build.json" + }, + { + "path": "../chain-agnostic-permission/tsconfig.build.json" + }, + { + "path": "../json-rpc-engine/tsconfig.build.json" + }, + { + "path": "../network-controller/tsconfig.build.json" + }, + { + "path": "../permission-controller/tsconfig.build.json" + }, + { + "path": "../controller-utils/tsconfig.build.json" + }, + { + "path": "../multichain-transactions-controller/tsconfig.build.json" + } + ], + "include": ["../../types", "./src"] +} diff --git a/packages/multichain-api-middleware/tsconfig.json b/packages/multichain-api-middleware/tsconfig.json new file mode 100644 index 00000000000..ec6333ee516 --- /dev/null +++ b/packages/multichain-api-middleware/tsconfig.json @@ -0,0 +1,32 @@ +{ + "extends": "../../tsconfig.packages.json", + "compilerOptions": { + "baseUrl": "./", + "resolveJsonModule": true, + "rootDir": "../.." + }, + "references": [ + { + "path": "../accounts-controller" + }, + { + "path": "../chain-agnostic-permission" + }, + { + "path": "../json-rpc-engine" + }, + { + "path": "../network-controller" + }, + { + "path": "../permission-controller" + }, + { + "path": "../multichain-transactions-controller" + }, + { + "path": "../controller-utils" + } + ], + "include": ["../../types", "./src"] +} diff --git a/packages/multichain-api-middleware/typedoc.json b/packages/multichain-api-middleware/typedoc.json new file mode 100644 index 00000000000..c9da015dbf8 --- /dev/null +++ b/packages/multichain-api-middleware/typedoc.json @@ -0,0 +1,7 @@ +{ + "entryPoints": ["./src/index.ts"], + "excludePrivate": true, + "hideGenerator": true, + "out": "docs", + "tsconfig": "./tsconfig.build.json" +} diff --git a/packages/multichain-network-controller/CHANGELOG.md b/packages/multichain-network-controller/CHANGELOG.md new file mode 100644 index 00000000000..c728dcfcce8 --- /dev/null +++ b/packages/multichain-network-controller/CHANGELOG.md @@ -0,0 +1,382 @@ +# 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] + +## [3.2.4] + +### Changed + +- Bump `@metamask/accounts-controller` from `^39.0.7` to `^39.1.1` ([#9807](https://github.com/MetaMask/core/pull/9807), [#9969](https://github.com/MetaMask/core/pull/9969)) +- Bump `@metamask/network-controller` from `^35.0.1` to `^36.0.0` ([#9969](https://github.com/MetaMask/core/pull/9969)) + +## [3.2.3] + +### Changed + +- Bump `@metamask/keyring-api` from `^23.7.0` to `^24.0.0` ([#9754](https://github.com/MetaMask/core/pull/9754)) +- Bump `@metamask/keyring-internal-api` from `^11.0.2` to `^12.0.0` ([#9754](https://github.com/MetaMask/core/pull/9754)) +- Bump `@metamask/network-controller` from `^35.0.0` to `^35.0.1` ([#9758](https://github.com/MetaMask/core/pull/9758)) +- Bump `@metamask/superstruct` from `^3.1.0` to `^3.4.1` ([#9754](https://github.com/MetaMask/core/pull/9754)) +- Bump `@metamask/accounts-controller` from `^39.0.6` to `^39.0.7` ([#9791](https://github.com/MetaMask/core/pull/9791)) + +## [3.2.2] + +### Changed + +- Bump `@metamask/messenger` from `^1.2.0` to `^2.0.0` ([#9392](https://github.com/MetaMask/core/pull/9392)) +- Bump `@metamask/keyring-api` from `^23.3.0` to `^23.7.0` ([#9390](https://github.com/MetaMask/core/pull/9390), [#9676](https://github.com/MetaMask/core/pull/9676)) +- Bump `@metamask/accounts-controller` from `^39.0.4` to `^39.0.6` ([#9470](https://github.com/MetaMask/core/pull/9470), [#9735](https://github.com/MetaMask/core/pull/9735)) +- Bump `@metamask/keyring-internal-api` from `^11.0.1` to `^11.0.2` ([#9676](https://github.com/MetaMask/core/pull/9676)) +- Bump `@metamask/network-controller` from `^34.0.0` to `^35.0.0` ([#9735](https://github.com/MetaMask/core/pull/9735)) + +## [3.2.1] + +### Changed + +- Bump `@metamask/accounts-controller` from `^39.0.3` to `^39.0.4` ([#9349](https://github.com/MetaMask/core/pull/9349)) +- Bump `@metamask/network-controller` from `^33.0.0` to `^34.0.0` ([#9349](https://github.com/MetaMask/core/pull/9349)) + +## [3.2.0] + +### Added + +- Add Stellar pubnet (`stellar:pubnet`) and Stellar testnet (`stellar:testnet`) to multichain network configurations (`AVAILABLE_MULTICHAIN_NETWORK_CONFIGURATIONS`), native asset CAIP-19 constants, metadata, tickers, decimal places, and `SupportedCaipChainId`; register testnet in `NON_EVM_TESTNET_IDS` ([#8831](https://github.com/MetaMask/core/pull/8831)) + +### Changed + +- Bump `@metamask/accounts-controller` from `^39.0.2` to `^39.0.3` ([#9231](https://github.com/MetaMask/core/pull/9231)) +- Bump `@metamask/keyring-api` from `^23.1.0` to `^23.3.0` ([#9249](https://github.com/MetaMask/core/pull/9249)) + +## [3.1.4] + +### Changed + +- Bump `@metamask/utils` from `^11.9.0` to `^11.11.0` ([#9074](https://github.com/MetaMask/core/pull/9074)) +- Bump `@metamask/controller-utils` from `^12.1.0` to `^12.3.0` ([#9058](https://github.com/MetaMask/core/pull/9058), [#9083](https://github.com/MetaMask/core/pull/9083), [#9218](https://github.com/MetaMask/core/pull/9218)) +- Bump `@metamask/accounts-controller` from `^39.0.0` to `^39.0.2` ([#9058](https://github.com/MetaMask/core/pull/9058), [#9218](https://github.com/MetaMask/core/pull/9218)) +- Bump `@metamask/network-controller` from `^32.0.0` to `^33.0.0` ([#9218](https://github.com/MetaMask/core/pull/9218)) + +## [3.1.3] + +### Changed + +- Bump `@metamask/accounts-controller` from `^38.1.1` to `^39.0.0` ([#8912](https://github.com/MetaMask/core/pull/8912), [#8999](https://github.com/MetaMask/core/pull/8999)) + +## [3.1.2] + +### Changed + +- Bump `@metamask/network-controller` from `^31.0.0` to `^32.0.0` ([#8765](https://github.com/MetaMask/core/pull/8765), [#8774](https://github.com/MetaMask/core/pull/8774)) +- Bump `@metamask/accounts-controller` from `^38.1.0` to `^38.1.1` ([#8774](https://github.com/MetaMask/core/pull/8774)) +- Bump `@metamask/controller-utils` from `^12.0.0` to `^12.1.0` ([#8774](https://github.com/MetaMask/core/pull/8774)) + +## [3.1.1] + +### Changed + +- Bump `@metamask/accounts-controller` from `^38.0.0` to `^38.1.0` ([#8755](https://github.com/MetaMask/core/pull/8755)) +- Bump `@metamask/controller-utils` from `^11.20.0` to `^12.0.0` ([#8755](https://github.com/MetaMask/core/pull/8755)) +- Bump `@metamask/network-controller` from `^30.1.0` to `^31.0.0` ([#8755](https://github.com/MetaMask/core/pull/8755)) + +## [3.1.0] + +### Added + +- Export `MultichainNetworkControllerGetNetworksWithTransactionActivityByAccountsAction` ([#8391](https://github.com/MetaMask/core/pull/8391)) + +### Changed + +- Bump `@metamask/keyring-controller` from `^25.3.0` to `^25.4.0` ([#8665](https://github.com/MetaMask/core/pull/8665)) +- Bump `@metamask/accounts-controller` from `^37.1.0` to `^38.0.0` ([#8325](https://github.com/MetaMask/core/pull/8325), [#8363](https://github.com/MetaMask/core/pull/8363), [#8665](https://github.com/MetaMask/core/pull/8665)) +- Bump `@metamask/controller-utils` from `^11.19.0` to `^11.20.0` ([#8344](https://github.com/MetaMask/core/pull/8344)) +- Bump `@metamask/messenger` from `^1.0.0` to `^1.2.0` ([#8364](https://github.com/MetaMask/core/pull/8364), [#8373](https://github.com/MetaMask/core/pull/8373), [#8632](https://github.com/MetaMask/core/pull/8632)) +- Bump `@metamask/base-controller` from `^9.0.1` to `^9.1.0` ([#8457](https://github.com/MetaMask/core/pull/8457)) +- Bump `@metamask/keyring-api` from `^21.6.0` to `^23.1.0` ([#8464](https://github.com/MetaMask/core/pull/8464), [#8647](https://github.com/MetaMask/core/pull/8647)) +- Bump `@metamask/keyring-internal-api` from `^10.0.0` to `^11.0.1` ([#8464](https://github.com/MetaMask/core/pull/8464), [#8584](https://github.com/MetaMask/core/pull/8584), [#8647](https://github.com/MetaMask/core/pull/8647)) +- Bump `@metamask/network-controller` from `^30.0.1` to `^30.1.0` ([#8636](https://github.com/MetaMask/core/pull/8636)) + +## [3.0.6] + +### Changed + +- Bump `@metamask/accounts-controller` from `^37.0.0` to `^37.1.0` ([#8317](https://github.com/MetaMask/core/pull/8317)) +- Bump `@metamask/base-controller` from `^9.0.0` to `^9.0.1` ([#8317](https://github.com/MetaMask/core/pull/8317)) +- Bump `@metamask/messenger` from `^0.3.0` to `^1.0.0` ([#8317](https://github.com/MetaMask/core/pull/8317)) +- Bump `@metamask/network-controller` from `^30.0.0` to `^30.0.1` ([#8317](https://github.com/MetaMask/core/pull/8317)) +- Bump `@metamask/keyring-api` from `^21.5.0` to `^21.6.0` ([#8259](https://github.com/MetaMask/core/pull/8259)) + +## [3.0.5] + +### Changed + +- Bump `@metamask/accounts-controller` from `^36.0.1` to `^37.0.0` ([#8140](https://github.com/MetaMask/core/pull/8140)) + +## [3.0.4] + +### Changed + +- Bump `@metamask/accounts-controller` from `^36.0.0` to `^36.0.1` ([#7996](https://github.com/MetaMask/core/pull/7996)) +- Bump `@metamask/network-controller` from `^29.0.0` to `^30.0.0` ([#7996](https://github.com/MetaMask/core/pull/7996)) +- Bump `@metamask/controller-utils` from `^11.18.0` to `^11.19.0` ([#7995](https://github.com/MetaMask/core/pull/7995)) + +## [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)) + +## [3.0.2] + +### Changed + +- Bump `@metamask/accounts-controller` from `^35.0.1` to `^35.0.2` ([#7642](https://github.com/MetaMask/core/pull/7642)) +- Bump `@metamask/network-controller` from `^28.0.0` to `^29.0.0` ([#7642](https://github.com/MetaMask/core/pull/7642)) + +## [3.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), [#7258](https://github.com/MetaMask/core/pull/7258), [#7534](https://github.com/MetaMask/core/pull/7534), [#7583](https://github.com/MetaMask/core/pull/7583), [#7604](https://github.com/MetaMask/core/pull/7604)) + - The dependencies moved are: + - `@metamask/accounts-controller` (^35.0.1) + - `@metamask/network-controller` (^28.0.0) + - In clients, it is now possible for multiple versions of these packages to exist in the dependency tree. + - For example, this scenario would be valid: a client relies on `@metamask/controller-a` 1.0.0 and `@metamask/controller-b` 1.0.0, and `@metamask/controller-b` depends on `@metamask/controller-a` 1.1.0. + - Note, however, that the versions specified in the client's `package.json` always "win", and you are expected to keep them up to date so as not to break controller and service intercommunication. +- 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)) + +## [3.0.0] + +### Changed + +- Bump `@metamask/controller-utils` from `^11.15.0` to `^11.16.0` ([#7202](https://github.com/MetaMask/core/pull/7202)) +- **BREAKING:** Bump `@metamask/network-controller` from `^25.0.0` to `^26.0.0` ([#7202](https://github.com/MetaMask/core/pull/7202)) +- **BREAKING:** Bump `@metamask/accounts-controller` from `^34.0.0` to `^35.0.0` ([#7202](https://github.com/MetaMask/core/pull/7202)) + +## [2.0.0] + +### Changed + +- **BREAKING:** Use new `Messenger` from `@metamask/messenger` ([#6543](https://github.com/MetaMask/core/pull/6543)) + - Previously, `MultichainNetworkController` accepted a `RestrictedMessenger` instance from `@metamask/base-controller`. +- **BREAKING:** Metadata property `anonymous` renamed to `includeInDebugSnapshot` ([#6543](https://github.com/MetaMask/core/pull/6543)) +- **BREAKING:** Bump `@metamask/accounts-controller` from `^33.0.0` to `^34.0.0` ([#6962](https://github.com/MetaMask/core/pull/6962)) +- **BREAKING:** Bump `@metamask/network-controller` from `^24.0.0` to `^25.0.0` ([#6962](https://github.com/MetaMask/core/pull/6962)) +- Bump `@metamask/base-controller` from `^8.4.2` to `^9.0.0` ([#6962](https://github.com/MetaMask/core/pull/6962)) + +## [1.0.2] + +### Changed + +- Bump `@metamask/base-controller` from `^8.4.1` to `^8.4.2` ([#6917](https://github.com/MetaMask/core/pull/6917)) +- Bump `@metamask/network-controller` from `^24.2.2` to `^24.3.0` ([#6883](https://github.com/MetaMask/core/pull/6883)) + +## [1.0.1] + +### Changed + +- Bump `@metamask/utils` from `^11.8.0` to `^11.8.1` ([#6708](https://github.com/MetaMask/core/pull/6708)) +- Bump `@metamask/base-controller` from `^8.4.0` to `^8.4.1` ([#6807](https://github.com/MetaMask/core/pull/6807)) +- Bump `@metamask/controller-utils` from `^11.14.0` to `^11.14.1` ([#6807](https://github.com/MetaMask/core/pull/6807)) + +## [1.0.0] + +### Added + +- Add two new controller state metadata properties: `includeInStateLogs` and `usedInUi` ([#6525](https://github.com/MetaMask/core/pull/6525)) +- Add Solana Devnet support to multichain network controller ([#6670](https://github.com/MetaMask/core/pull/6670)) + +### Changed + +- Bump package version to v1.0 to mark stabilization ([#6676](https://github.com/MetaMask/core/pull/6676)) +- Bump `@metamask/controller-utils` from `^11.12.0` to `^11.14.0` ([#6620](https://github.com/MetaMask/core/pull/6620), [#6629](https://github.com/MetaMask/core/pull/6629)) +- Bump `@metamask/base-controller` from `^8.1.0` to `^8.4.0` ([#6355](https://github.com/MetaMask/core/pull/6355), [#6465](https://github.com/MetaMask/core/pull/6465), [#6632](https://github.com/MetaMask/core/pull/6632)) +- Bump `@metamask/keyring-api` from `^20.1.0` to `^21.0.0` ([#6560](https://github.com/MetaMask/core/pull/6560)) +- Bump `@metamask/keyring-internal-api` from `^8.1.0` to `^9.0.0` ([#6560](https://github.com/MetaMask/core/pull/6560)) +- Bump `@metamask/utils` from `^11.4.2` to `^11.8.0` ([#6588](https://github.com/MetaMask/core/pull/6588)) + +## [0.12.0] + +### Changed + +- **BREAKING:** Bump peer dependency `@metamask/accounts-controller` from `^32.0.0` to `^33.0.0` ([#6345](https://github.com/MetaMask/core/pull/6345)) +- Bump `@metamask/base-controller` from `^8.0.1` to `^8.1.0` ([#6284](https://github.com/MetaMask/core/pull/6284)) +- Bump `@metamask/controller-utils` from `^11.11.0` to `^11.12.0` ([#6303](https://github.com/MetaMask/core/pull/6303)) +- Bump accounts related packages ([#6309](https://github.com/MetaMask/core/pull/6309)) + - Bump `@metamask/keyring-api` from `^20.0.0` to `^20.1.0` + - Bump `@metamask/keyring-internal-api` from `^8.0.0` to `^8.1.0` + +## [0.11.1] + +### Changed + +- Bump `@metamask/keyring-api` from `^19.0.0` to `^20.0.0` ([#6248](https://github.com/MetaMask/core/pull/6248)) +- Bump `@metamask/keyring-internal-api` from `^7.0.0` to `^8.0.0` ([#6248](https://github.com/MetaMask/core/pull/6248)) + +## [0.11.0] + +### Changed + +- **BREAKING:** Bump peer dependency `@metamask/accounts-controller` from `^31.0.0` to `^32.0.0` ([#6171](https://github.com/MetaMask/core/pull/6171)) +- Bump `@metamask/keyring-api` from `^18.0.0` to `^19.0.0` ([#6146](https://github.com/MetaMask/core/pull/6146)) +- Bump `@metamask/keyring-internal-api` from `^6.2.0` to `^7.0.0` ([#6146](https://github.com/MetaMask/core/pull/6146)) + +## [0.10.0] + +### Changed + +- Bump `@metamask/controller-utils` from `^11.10.0` to `^11.11.0` ([#6069](https://github.com/MetaMask/core/pull/6069)) +- Bump `@metamask/utils` from `^11.2.0` to `^11.4.2` ([#6054](https://github.com/MetaMask/core/pull/6054)) + +### Fixed + +- Use `scopes` instead of `address` to retrieve the network of an account. ([#6072](https://github.com/MetaMask/core/pull/6072)) + +## [0.9.0] + +### Changed + +- **BREAKING:** Bump peer dependency `@metamask/accounts-controller` to `^31.0.0` ([#5999](https://github.com/MetaMask/core/pull/5999)) +- **BREAKING:** Bump peer dependency `@metamask/network-controller` to `^24.0.0` ([#5999](https://github.com/MetaMask/core/pull/5999)) +- Bump `@metamask/controller-utils` to `^11.10.0` ([#5935](https://github.com/MetaMask/core/pull/5935)) + +## [0.8.0] + +### Changed + +- **BREAKING:** Bump `@metamask/accounts-controller` peer dependency to `^30.0.0` ([#5888](https://github.com/MetaMask/core/pull/5888)) +- Bump `@metamask/keyring-api` dependency from `^17.4.0` to `^18.0.0` ([#5871](https://github.com/MetaMask/core/pull/5871)) +- Bump `@metamask/keyring-internal-api` dependency from `^6.0.1` to `^6.2.0` ([#5871](https://github.com/MetaMask/core/pull/5871)) +- Bump `@metamask/controller-utils` to `^11.9.0` ([#5812](https://github.com/MetaMask/core/pull/5812)) + +## [0.7.0] + +### Changed + +- **BREAKING:** bump `@metamask/accounts-controller` peer dependency to `^29.0.0` ([#5802](https://github.com/MetaMask/core/pull/5802)) +- Bump `@metamask/controller-utils` to `^11.8.0` ([#5765](https://github.com/MetaMask/core/pull/5765)) + +## [0.6.0] + +### Changed + +- **BREAKING:** Bump peer dependency `@metamask/accounts-controller` to `^28.0.0` ([#5763](https://github.com/MetaMask/core/pull/5763)) +- Bump `@metamask/base-controller` from ^8.0.0 to ^8.0.1 ([#5722](https://github.com/MetaMask/core/pull/5722)) + +## [0.5.1] + +### Changed + +- Updated to restrict `getNetworksWithTransactionActivityByAccounts` to EVM networks only while non-EVM network endpoint support is being completed. Full multi-chain support will be restored in the coming weeks ([#5677](https://github.com/MetaMask/core/pull/5677)) +- Updated network activity API requests to have batching support to handle URL length limitations, allowing the controller to fetch network activity for any number of accounts ([#5752](https://github.com/MetaMask/core/pull/5752)) + +## [0.5.0] + +### Added + +- Add method `getNetworksWithTransactionActivityByAccounts` to fetch active networks for multiple accounts in a single request ([#5551](https://github.com/MetaMask/core/pull/5551)) +- Add `MultichainNetworkService` for handling network activity fetching ([#5551](https://github.com/MetaMask/core/pull/5551)) +- Add types for network activity state and responses ([#5551](https://github.com/MetaMask/core/pull/5551)) + +### Changed + +- Updated state management for network activity ([#5551](https://github.com/MetaMask/core/pull/5551)) + +## [0.4.0] + +### Added + +- Add Testnet asset IDs as constants ([#5589](https://github.com/MetaMask/core/pull/5589)) +- Add Network specific decimal values and ticker as constants ([#5589](https://github.com/MetaMask/core/pull/5589)) +- Add new method `removeNetwork` that acts as a proxy to remove an EVM network from the `@metamask/network-controller` ([#5516](https://github.com/MetaMask/core/pull/5516)) + +### Changed + +- The `AVAILABLE_MULTICHAIN_NETWORK_CONFIGURATIONS` now includes non-EVM testnets ([#5589](https://github.com/MetaMask/core/pull/5589)) +- Bump `@metamask/keyring-api"` from `^17.2.0` to `^17.4.0` ([#5565](https://github.com/MetaMask/core/pull/5565)) + +### Fixed + +- Fix the condition to update the active network based on the `AccountsController:selectedAccountChange` event ([#5642](https://github.com/MetaMask/core/pull/5642)) + +## [0.3.0] + +### Changed + +- **BREAKING:** Bump peer dependency `@metamask/accounts-controller` to `^27.0.0` ([#5507](https://github.com/MetaMask/core/pull/5507)) +- **BREAKING:** Bump peer dependency `@metamask/network-controller` to `^23.0.0` ([#5507](https://github.com/MetaMask/core/pull/5507)) + +## [0.2.0] + +### Changed + +- **BREAKING:** Bump `@metamask/accounts-controller` peer dependency to `^26.0.0` ([#5439](https://github.com/MetaMask/core/pull/5439)) +- **BREAKING:** Bump `@metamask/accounts-controller` peer dependency to `^25.0.0` ([#5426](https://github.com/MetaMask/core/pull/5426)) + +## [0.1.2] + +### Changed + +- Bump `@metamask/keyring-api"` from `^17.0.0` to `^17.2.0` ([#5366](https://github.com/MetaMask/core/pull/5366)) +- Bump `@metamask/utils` from `^11.1.0` to `^11.2.0` ([#5301](https://github.com/MetaMask/core/pull/5301)) + +## [0.1.1] + +### Fixed + +- Add `MultichainNetworkController:stateChange` to list of subscribable `MultichainNetworkController` messenger events ([#5331](https://github.com/MetaMask/core/pull/5331)) + +## [0.1.0] + +### Added + +- Initial release ([#5215](https://github.com/MetaMask/core/pull/5215)) + - Handle both EVM and non-EVM network and account switching for the associated network. + - Act as a proxy for the `NetworkController` (for EVM network changes). + +[Unreleased]: https://github.com/MetaMask/core/compare/@metamask/multichain-network-controller@3.2.4...HEAD +[3.2.4]: https://github.com/MetaMask/core/compare/@metamask/multichain-network-controller@3.2.3...@metamask/multichain-network-controller@3.2.4 +[3.2.3]: https://github.com/MetaMask/core/compare/@metamask/multichain-network-controller@3.2.2...@metamask/multichain-network-controller@3.2.3 +[3.2.2]: https://github.com/MetaMask/core/compare/@metamask/multichain-network-controller@3.2.1...@metamask/multichain-network-controller@3.2.2 +[3.2.1]: https://github.com/MetaMask/core/compare/@metamask/multichain-network-controller@3.2.0...@metamask/multichain-network-controller@3.2.1 +[3.2.0]: https://github.com/MetaMask/core/compare/@metamask/multichain-network-controller@3.1.4...@metamask/multichain-network-controller@3.2.0 +[3.1.4]: https://github.com/MetaMask/core/compare/@metamask/multichain-network-controller@3.1.3...@metamask/multichain-network-controller@3.1.4 +[3.1.3]: https://github.com/MetaMask/core/compare/@metamask/multichain-network-controller@3.1.2...@metamask/multichain-network-controller@3.1.3 +[3.1.2]: https://github.com/MetaMask/core/compare/@metamask/multichain-network-controller@3.1.1...@metamask/multichain-network-controller@3.1.2 +[3.1.1]: https://github.com/MetaMask/core/compare/@metamask/multichain-network-controller@3.1.0...@metamask/multichain-network-controller@3.1.1 +[3.1.0]: https://github.com/MetaMask/core/compare/@metamask/multichain-network-controller@3.0.6...@metamask/multichain-network-controller@3.1.0 +[3.0.6]: https://github.com/MetaMask/core/compare/@metamask/multichain-network-controller@3.0.5...@metamask/multichain-network-controller@3.0.6 +[3.0.5]: https://github.com/MetaMask/core/compare/@metamask/multichain-network-controller@3.0.4...@metamask/multichain-network-controller@3.0.5 +[3.0.4]: https://github.com/MetaMask/core/compare/@metamask/multichain-network-controller@3.0.3...@metamask/multichain-network-controller@3.0.4 +[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 +[2.0.0]: https://github.com/MetaMask/core/compare/@metamask/multichain-network-controller@1.0.2...@metamask/multichain-network-controller@2.0.0 +[1.0.2]: https://github.com/MetaMask/core/compare/@metamask/multichain-network-controller@1.0.1...@metamask/multichain-network-controller@1.0.2 +[1.0.1]: https://github.com/MetaMask/core/compare/@metamask/multichain-network-controller@1.0.0...@metamask/multichain-network-controller@1.0.1 +[1.0.0]: https://github.com/MetaMask/core/compare/@metamask/multichain-network-controller@0.12.0...@metamask/multichain-network-controller@1.0.0 +[0.12.0]: https://github.com/MetaMask/core/compare/@metamask/multichain-network-controller@0.11.1...@metamask/multichain-network-controller@0.12.0 +[0.11.1]: https://github.com/MetaMask/core/compare/@metamask/multichain-network-controller@0.11.0...@metamask/multichain-network-controller@0.11.1 +[0.11.0]: https://github.com/MetaMask/core/compare/@metamask/multichain-network-controller@0.10.0...@metamask/multichain-network-controller@0.11.0 +[0.10.0]: https://github.com/MetaMask/core/compare/@metamask/multichain-network-controller@0.9.0...@metamask/multichain-network-controller@0.10.0 +[0.9.0]: https://github.com/MetaMask/core/compare/@metamask/multichain-network-controller@0.8.0...@metamask/multichain-network-controller@0.9.0 +[0.8.0]: https://github.com/MetaMask/core/compare/@metamask/multichain-network-controller@0.7.0...@metamask/multichain-network-controller@0.8.0 +[0.7.0]: https://github.com/MetaMask/core/compare/@metamask/multichain-network-controller@0.6.0...@metamask/multichain-network-controller@0.7.0 +[0.6.0]: https://github.com/MetaMask/core/compare/@metamask/multichain-network-controller@0.5.1...@metamask/multichain-network-controller@0.6.0 +[0.5.1]: https://github.com/MetaMask/core/compare/@metamask/multichain-network-controller@0.5.0...@metamask/multichain-network-controller@0.5.1 +[0.5.0]: https://github.com/MetaMask/core/compare/@metamask/multichain-network-controller@0.4.0...@metamask/multichain-network-controller@0.5.0 +[0.4.0]: https://github.com/MetaMask/core/compare/@metamask/multichain-network-controller@0.3.0...@metamask/multichain-network-controller@0.4.0 +[0.3.0]: https://github.com/MetaMask/core/compare/@metamask/multichain-network-controller@0.2.0...@metamask/multichain-network-controller@0.3.0 +[0.2.0]: https://github.com/MetaMask/core/compare/@metamask/multichain-network-controller@0.1.2...@metamask/multichain-network-controller@0.2.0 +[0.1.2]: https://github.com/MetaMask/core/compare/@metamask/multichain-network-controller@0.1.1...@metamask/multichain-network-controller@0.1.2 +[0.1.1]: https://github.com/MetaMask/core/compare/@metamask/multichain-network-controller@0.1.0...@metamask/multichain-network-controller@0.1.1 +[0.1.0]: https://github.com/MetaMask/core/releases/tag/@metamask/multichain-network-controller@0.1.0 diff --git a/packages/multichain-network-controller/LICENSE b/packages/multichain-network-controller/LICENSE new file mode 100644 index 00000000000..c259cd7ebcf --- /dev/null +++ b/packages/multichain-network-controller/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2025 MetaMask + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packages/multichain-network-controller/README.md b/packages/multichain-network-controller/README.md new file mode 100644 index 00000000000..6bdb2c13233 --- /dev/null +++ b/packages/multichain-network-controller/README.md @@ -0,0 +1,15 @@ +# `@metamask/multichain-network-controller` + +... + +## Installation + +`yarn add @metamask/multichain-network-controller` + +or + +`npm install @metamask/multichain-network-controller` + +## Contributing + +This package is part of a monorepo. Instructions for contributing can be found in the [monorepo README](https://github.com/MetaMask/core#readme). diff --git a/packages/multichain-network-controller/jest.config.js b/packages/multichain-network-controller/jest.config.js new file mode 100644 index 00000000000..3b19ddb3f39 --- /dev/null +++ b/packages/multichain-network-controller/jest.config.js @@ -0,0 +1,26 @@ +/* + * For a detailed explanation regarding each configuration property and type check, visit: + * https://jestjs.io/docs/configuration + */ + +const merge = require('deepmerge'); +const path = require('path'); + +const baseConfig = require('../../jest.config.packages'); + +const displayName = path.basename(__dirname); + +module.exports = merge(baseConfig, { + // The display name when running multiple projects + displayName, + + // An object that configures minimum threshold enforcement for coverage results + coverageThreshold: { + global: { + branches: 98.3, + functions: 100, + lines: 100, + statements: 100, + }, + }, +}); diff --git a/packages/multichain-network-controller/package.json b/packages/multichain-network-controller/package.json new file mode 100644 index 00000000000..241b8f47380 --- /dev/null +++ b/packages/multichain-network-controller/package.json @@ -0,0 +1,90 @@ +{ + "name": "@metamask/multichain-network-controller", + "version": "3.2.4", + "description": "Multichain network controller", + "keywords": [ + "Ethereum", + "MetaMask" + ], + "homepage": "https://github.com/MetaMask/core/tree/main/packages/multichain-network-controller#readme", + "bugs": { + "url": "https://github.com/MetaMask/core/issues" + }, + "license": "MIT", + "repository": { + "type": "git", + "url": "https://github.com/MetaMask/core.git" + }, + "files": [ + "dist/" + ], + "sideEffects": false, + "main": "./dist/index.cjs", + "types": "./dist/index.d.cts", + "exports": { + ".": { + "import": { + "types": "./dist/index.d.mts", + "default": "./dist/index.mjs" + }, + "require": { + "types": "./dist/index.d.cts", + "default": "./dist/index.cjs" + } + }, + "./package.json": "./package.json" + }, + "publishConfig": { + "access": "public", + "registry": "https://registry.npmjs.org/" + }, + "scripts": { + "build": "ts-bridge --project tsconfig.build.json --verbose --clean --no-references", + "build:all": "ts-bridge --project tsconfig.build.json --verbose --clean", + "build:docs": "typedoc", + "changelog:update": "../../scripts/update-changelog.sh @metamask/multichain-network-controller", + "changelog:validate": "../../scripts/validate-changelog.sh @metamask/multichain-network-controller", + "lint:tsconfigs": "tsx ../../scripts/lint-tsconfigs/lint-tsconfigs.mts", + "lint:tsconfigs:fix": "tsx ../../scripts/lint-tsconfigs/lint-tsconfigs.mts --fix", + "messenger-action-types:check": "tsx ../../packages/messenger-cli/src/cli.ts --formatter oxfmt --esm --check", + "messenger-action-types:generate": "tsx ../../packages/messenger-cli/src/cli.ts --formatter oxfmt --esm --generate", + "since-latest-release": "../../scripts/since-latest-release.sh", + "test": "NODE_OPTIONS=--experimental-vm-modules jest --reporters=jest-silent-reporter", + "test:clean": "NODE_OPTIONS=--experimental-vm-modules jest --clearCache", + "test:verbose": "NODE_OPTIONS=--experimental-vm-modules jest --verbose", + "test:watch": "NODE_OPTIONS=--experimental-vm-modules jest --watch" + }, + "dependencies": { + "@metamask/accounts-controller": "^39.1.1", + "@metamask/base-controller": "^9.1.0", + "@metamask/controller-utils": "^12.3.0", + "@metamask/keyring-api": "^24.0.0", + "@metamask/keyring-internal-api": "^12.0.0", + "@metamask/messenger": "^2.0.0", + "@metamask/network-controller": "^36.0.0", + "@metamask/superstruct": "^3.4.1", + "@metamask/utils": "^11.11.0", + "@solana/addresses": "^2.0.0", + "lodash": "^4.17.21" + }, + "devDependencies": { + "@metamask/auto-changelog": "^6.1.0", + "@metamask/keyring-controller": "^27.1.1", + "@ts-bridge/cli": "^0.6.4", + "@types/jest": "^30.0.0", + "@types/lodash": "^4.14.191", + "@types/uuid": "^8.3.0", + "deepmerge": "^4.2.2", + "immer": "^9.0.6", + "jest": "^30.4.2", + "nock": "^13.3.1", + "ts-jest": "^29.4.11", + "tsx": "^4.20.5", + "typedoc": "^0.25.13", + "typedoc-plugin-missing-exports": "^2.0.0", + "typescript": "~5.3.3" + }, + "engines": { + "node": "^18.18 || >=20" + } +} diff --git a/packages/multichain-network-controller/src/MultichainNetworkController/MultichainNetworkController-method-action-types.ts b/packages/multichain-network-controller/src/MultichainNetworkController/MultichainNetworkController-method-action-types.ts new file mode 100644 index 00000000000..8096059f4e8 --- /dev/null +++ b/packages/multichain-network-controller/src/MultichainNetworkController/MultichainNetworkController-method-action-types.ts @@ -0,0 +1,36 @@ +/** + * This file is auto generated. + * Do not edit manually. + */ + +import type { MultichainNetworkController } from './MultichainNetworkController.js'; + +/** + * Sets the active network. + * + * @param id - The non-EVM Caip chain ID or EVM client ID of the network to set active. + * @returns - A promise that resolves when the network is set active. + */ +export type MultichainNetworkControllerSetActiveNetworkAction = { + type: `MultichainNetworkController:setActiveNetwork`; + handler: MultichainNetworkController['setActiveNetwork']; +}; + +/** + * Returns the active networks for the available EVM addresses (non-EVM networks will be supported in the future). + * Fetches the data from the API and caches it in state. + * + * @returns A promise that resolves to the active networks for the available addresses + */ +export type MultichainNetworkControllerGetNetworksWithTransactionActivityByAccountsAction = + { + type: `MultichainNetworkController:getNetworksWithTransactionActivityByAccounts`; + handler: MultichainNetworkController['getNetworksWithTransactionActivityByAccounts']; + }; + +/** + * Union of all MultichainNetworkController action types. + */ +export type MultichainNetworkControllerMethodActions = + | MultichainNetworkControllerSetActiveNetworkAction + | MultichainNetworkControllerGetNetworksWithTransactionActivityByAccountsAction; diff --git a/packages/multichain-network-controller/src/MultichainNetworkController/MultichainNetworkController.test.ts b/packages/multichain-network-controller/src/MultichainNetworkController/MultichainNetworkController.test.ts new file mode 100644 index 00000000000..e2fb848a6c2 --- /dev/null +++ b/packages/multichain-network-controller/src/MultichainNetworkController/MultichainNetworkController.test.ts @@ -0,0 +1,1112 @@ +import { deriveStateFromMetadata } from '@metamask/base-controller'; +import { InfuraNetworkType } from '@metamask/controller-utils'; +import { + BtcScope, + SolScope, + EthAccountType, + BtcAccountType, + SolAccountType, + EthScope, + TrxAccountType, +} from '@metamask/keyring-api'; +import type { + AnyAccountType, + KeyringAccountType, + CaipChainId, +} from '@metamask/keyring-api'; +import { Messenger, MOCK_ANY_NAMESPACE } from '@metamask/messenger'; +import type { + MessengerActions, + MessengerEvents, + MockAnyNamespace, +} from '@metamask/messenger'; +import type { + NetworkControllerGetStateAction, + NetworkControllerSetActiveNetworkAction, + NetworkControllerGetSelectedChainIdAction, + NetworkControllerRemoveNetworkAction, + NetworkControllerFindNetworkClientIdByChainIdAction, + NetworkState, +} from '@metamask/network-controller'; +import { KnownCaipNamespace } from '@metamask/utils'; +import type { CaipAccountId } from '@metamask/utils'; + +import { createMockInternalAccount } from '../../tests/utils.js'; +import type { ActiveNetworksResponse } from '../api/accounts-api.js'; +import { getDefaultMultichainNetworkControllerState } from '../constants.js'; +import type { AbstractMultichainNetworkService } from '../MultichainNetworkService/AbstractMultichainNetworkService.js'; +import type { MultichainNetworkControllerMessenger } from '../types.js'; +import { MultichainNetworkController } from './MultichainNetworkController.js'; + +// We exclude the generic account type, since it's used for testing purposes. +type TestKeyringAccountType = Exclude< + KeyringAccountType, + `${AnyAccountType.Account}` +>; + +/** + * Creates a mock network service for testing. + * + * @param mockResponse - The mock response to return from fetchNetworkActivity + * @returns A mock network service that implements the MultichainNetworkService interface. + */ +function createMockNetworkService( + mockResponse: ActiveNetworksResponse = { activeNetworks: [] }, +): AbstractMultichainNetworkService { + return { + fetchNetworkActivity: jest + .fn, [CaipAccountId[]]>() + .mockResolvedValue(mockResponse), + }; +} + +const controllerName = 'MultichainNetworkController'; + +type AllMultichainNetworkControllerActions = + MessengerActions; + +type AllMultichainNetworkControllerEvents = + MessengerEvents; + +type RootMessenger = Messenger< + MockAnyNamespace, + AllMultichainNetworkControllerActions, + AllMultichainNetworkControllerEvents, + RootMessenger +>; + +/** + * Creates and returns a root messenger for testing + * + * @returns A messenger instance + */ +function getRootMessenger(): RootMessenger { + return new Messenger({ + namespace: MOCK_ANY_NAMESPACE, + }); +} + +/** + * Setup a test controller instance. + * + * @param args - Arguments to this function. + * @param args.options - The constructor options for the controller. + * @param args.getNetworkState - Mock for NetworkController:getState action. + * @param args.setActiveNetwork - Mock for NetworkController:setActiveNetwork action. + * @param args.removeNetwork - Mock for NetworkController:removeNetwork action. + * @param args.getSelectedChainId - Mock for NetworkController:getSelectedChainId action. + * @param args.findNetworkClientIdByChainId - Mock for NetworkController:findNetworkClientIdByChainId action. + * @param args.mockNetworkService - Mock for MultichainNetworkService. + * @returns A collection of test controllers and mocks. + */ +function setupController({ + options = {}, + getNetworkState, + setActiveNetwork, + removeNetwork, + getSelectedChainId, + findNetworkClientIdByChainId, + mockNetworkService, +}: { + options?: Partial< + ConstructorParameters[0] + >; + getNetworkState?: jest.Mock< + ReturnType, + Parameters + >; + setActiveNetwork?: jest.Mock< + ReturnType, + Parameters + >; + removeNetwork?: jest.Mock< + ReturnType, + Parameters + >; + getSelectedChainId?: jest.Mock< + ReturnType, + Parameters + >; + findNetworkClientIdByChainId?: jest.Mock< + ReturnType, + Parameters + >; + mockNetworkService?: AbstractMultichainNetworkService; +} = {}): { + messenger: RootMessenger; + controller: MultichainNetworkController; + mockGetNetworkState: jest.Mock< + NetworkState, + Parameters + >; + mockSetActiveNetwork: jest.Mock< + ReturnType, + Parameters + >; + mockRemoveNetwork: jest.Mock< + ReturnType, + Parameters + >; + mockGetSelectedChainId: jest.Mock< + ReturnType, + Parameters + >; + mockFindNetworkClientIdByChainId: jest.Mock< + ReturnType, + Parameters + >; + publishSpy: jest.SpyInstance< + ReturnType, + Parameters + >; + triggerSelectedAccountChange: (accountType: TestKeyringAccountType) => void; + networkService: AbstractMultichainNetworkService; +} { + const messenger = getRootMessenger(); + + // Register action handlers + const mockGetNetworkState = + getNetworkState ?? + jest.fn< + ReturnType, + Parameters + >(); + messenger.registerActionHandler( + 'NetworkController:getState', + mockGetNetworkState, + ); + + const mockSetActiveNetwork = + setActiveNetwork ?? + jest.fn< + ReturnType, + Parameters + >(); + messenger.registerActionHandler( + 'NetworkController:setActiveNetwork', + mockSetActiveNetwork, + ); + + const mockRemoveNetwork = + removeNetwork ?? + jest.fn< + ReturnType, + Parameters + >(); + messenger.registerActionHandler( + 'NetworkController:removeNetwork', + mockRemoveNetwork, + ); + + const mockGetSelectedChainId = + getSelectedChainId ?? + jest.fn< + ReturnType, + Parameters + >(); + messenger.registerActionHandler( + 'NetworkController:getSelectedChainId', + mockGetSelectedChainId, + ); + + const mockFindNetworkClientIdByChainId = + findNetworkClientIdByChainId ?? + jest.fn< + ReturnType< + NetworkControllerFindNetworkClientIdByChainIdAction['handler'] + >, + Parameters + >(); + messenger.registerActionHandler( + 'NetworkController:findNetworkClientIdByChainId', + mockFindNetworkClientIdByChainId, + ); + + const controllerMessenger = new Messenger< + typeof controllerName, + AllMultichainNetworkControllerActions, + AllMultichainNetworkControllerEvents, + RootMessenger + >({ + namespace: controllerName, + parent: messenger, + }); + + messenger.delegate({ + messenger: controllerMessenger, + actions: [ + 'NetworkController:setActiveNetwork', + 'NetworkController:getState', + 'NetworkController:removeNetwork', + 'NetworkController:getSelectedChainId', + 'NetworkController:findNetworkClientIdByChainId', + 'AccountsController:listMultichainAccounts', + ], + events: ['AccountsController:selectedAccountChange'], + }); + + const defaultNetworkService = createMockNetworkService(); + + const controller = new MultichainNetworkController({ + messenger: options.messenger ?? controllerMessenger, + state: { + selectedMultichainNetworkChainId: SolScope.Mainnet, + isEvmSelected: true, + ...options.state, + }, + networkService: mockNetworkService ?? defaultNetworkService, + }); + + const triggerSelectedAccountChange = ( + accountType: TestKeyringAccountType, + ): void => { + const mockAccountAddressByAccountType: Record< + TestKeyringAccountType, + string + > = { + [EthAccountType.Eoa]: '0x2260FAC5E5542a773Aa44fBCfeDf7C193bc2C599', + [EthAccountType.Erc4337]: '0x2260FAC5E5542a773Aa44fBCfeDf7C193bc2C599', + [SolAccountType.DataAccount]: + 'So11111111111111111111111111111111111111112', + [BtcAccountType.P2pkh]: '1AXaVdPBb6zqrTMb6ebrBb9g3JmeAPGeCF', + [BtcAccountType.P2sh]: '3KQPirCGGbVyWJLGuWN6VPC7uLeiarYB7x', + [BtcAccountType.P2wpkh]: 'bc1q4degm5k044n9xv3ds7d8l6hfavydte6wn6sesw', + [BtcAccountType.P2tr]: + 'bc1pxfxst7zrkw39vzh0pchq5ey0q7z6u739cudhz5vmg89wa4kyyp9qzrf5sp', + [TrxAccountType.Eoa]: 'TYvuLYQvTZp56urTbkeM3vDqU2YipJ7eDk', + }; + const mockAccountAddress = mockAccountAddressByAccountType[accountType]; + + const mockAccount = createMockInternalAccount({ + type: accountType, + address: mockAccountAddress, + }); + messenger.publish('AccountsController:selectedAccountChange', mockAccount); + }; + + const publishSpy = jest.spyOn(controllerMessenger, 'publish'); + + return { + messenger, + controller, + mockGetNetworkState, + mockSetActiveNetwork, + mockRemoveNetwork, + mockGetSelectedChainId, + mockFindNetworkClientIdByChainId, + publishSpy, + triggerSelectedAccountChange, + networkService: mockNetworkService ?? defaultNetworkService, + }; +} + +describe('MultichainNetworkController', () => { + describe('constructor', () => { + it('sets default state', () => { + const { controller } = setupController({ + options: { state: getDefaultMultichainNetworkControllerState() }, + }); + expect(controller.state).toStrictEqual( + getDefaultMultichainNetworkControllerState(), + ); + }); + }); + + describe('setActiveNetwork', () => { + it('sets a non-EVM network when same non-EVM chain ID is active', async () => { + // By default, Solana is selected but is NOT active (aka EVM network is active) + const { controller, publishSpy } = setupController(); + + // Set active network to Solana + await controller.setActiveNetwork(SolScope.Mainnet); + + // Check that the Solana is now the selected network + expect(controller.state.selectedMultichainNetworkChainId).toBe( + SolScope.Mainnet, + ); + + // Check that the a non evm network is now active + expect(controller.state.isEvmSelected).toBe(false); + + // Check that the messenger published the correct event + expect(publishSpy).toHaveBeenCalledWith( + 'MultichainNetworkController:networkDidChange', + SolScope.Mainnet, + ); + }); + + it('throws an error when unsupported non-EVM chainId is provided', async () => { + const { controller } = setupController(); + const unsupportedChainId = 'eip155:1' as CaipChainId; + + await expect( + controller.setActiveNetwork(unsupportedChainId), + ).rejects.toThrow(`Unsupported Caip chain ID: ${unsupportedChainId}`); + }); + + it('does nothing when same non-EVM chain ID is set and active', async () => { + // By default, Solana is selected and active + const { controller, publishSpy } = setupController({ + options: { state: { isEvmSelected: false } }, + }); + + // Set active network to Solana + await controller.setActiveNetwork(SolScope.Mainnet); + + expect(controller.state.selectedMultichainNetworkChainId).toBe( + SolScope.Mainnet, + ); + + expect(controller.state.isEvmSelected).toBe(false); + + // Check that the messenger published the correct event + expect(publishSpy).not.toHaveBeenCalled(); + }); + + it('sets a non-EVM network when different non-EVM chain ID is active', async () => { + // By default, Solana is selected but is NOT active (aka EVM network is active) + const { controller, publishSpy } = setupController({ + options: { state: { isEvmSelected: false } }, + }); + + // Set active network to Bitcoin + await controller.setActiveNetwork(BtcScope.Mainnet); + + // Check that the Solana is now the selected network + expect(controller.state.selectedMultichainNetworkChainId).toBe( + BtcScope.Mainnet, + ); + + // Check that BTC network is now active + expect(controller.state.isEvmSelected).toBe(false); + + // Check that the messenger published the correct event + expect(publishSpy).toHaveBeenCalledWith( + 'MultichainNetworkController:networkDidChange', + BtcScope.Mainnet, + ); + }); + + it('sets an EVM network and call NetworkController:setActiveNetwork when same EVM network is selected', async () => { + const selectedNetworkClientId = InfuraNetworkType.mainnet; + + const { controller, mockSetActiveNetwork, publishSpy } = setupController({ + getNetworkState: jest.fn().mockImplementation(() => ({ + selectedNetworkClientId, + })), + options: { state: { isEvmSelected: false } }, + }); + + // Check that EVM network is not selected + expect(controller.state.isEvmSelected).toBe(false); + + await controller.setActiveNetwork(selectedNetworkClientId); + + // Check that EVM network is selected + expect(controller.state.isEvmSelected).toBe(true); + + // Check that the messenger published the correct event + expect(publishSpy).toHaveBeenCalledWith( + 'MultichainNetworkController:networkDidChange', + selectedNetworkClientId, + ); + + // Check that NetworkController:setActiveNetwork was not called + expect(mockSetActiveNetwork).not.toHaveBeenCalled(); + }); + + it('sets an EVM network and call NetworkController:setActiveNetwork when different EVM network is selected', async () => { + const { controller, mockSetActiveNetwork, publishSpy } = setupController({ + getNetworkState: jest.fn().mockImplementation(() => ({ + selectedNetworkClientId: InfuraNetworkType.mainnet, + })), + }); + const evmNetworkClientId = 'linea'; + + await controller.setActiveNetwork(evmNetworkClientId); + + // Check that EVM network is selected + expect(controller.state.isEvmSelected).toBe(true); + + // Check that the messenger published the correct event + expect(publishSpy).toHaveBeenCalledWith( + 'MultichainNetworkController:networkDidChange', + evmNetworkClientId, + ); + + // Check that NetworkController:setActiveNetwork was not called + expect(mockSetActiveNetwork).toHaveBeenCalledWith(evmNetworkClientId); + }); + + it('does nothing when same EVM network is set and active', async () => { + const { controller, publishSpy } = setupController({ + getNetworkState: jest.fn().mockImplementation(() => ({ + selectedNetworkClientId: InfuraNetworkType.mainnet, + })), + options: { state: { isEvmSelected: true } }, + }); + + // EVM network is already active + expect(controller.state.isEvmSelected).toBe(true); + + await controller.setActiveNetwork(InfuraNetworkType.mainnet); + + // EVM network is still active + expect(controller.state.isEvmSelected).toBe(true); + + // Check that the messenger published the correct event + expect(publishSpy).not.toHaveBeenCalled(); + }); + }); + + describe('handle AccountsController:selectedAccountChange event', () => { + it('isEvmSelected should be true when both switching to EVM account and EVM network is already active', async () => { + // By default, Solana is selected but EVM network is active + const { controller, triggerSelectedAccountChange } = setupController(); + + // EVM network is currently active + expect(controller.state.isEvmSelected).toBe(true); + + // Switching to EVM account + triggerSelectedAccountChange(EthAccountType.Eoa); + + // EVM network is still active + expect(controller.state.isEvmSelected).toBe(true); + }); + + it('switches to EVM network if non-EVM network is previously active', async () => { + // By default, Solana is selected and active + const { controller, triggerSelectedAccountChange } = setupController({ + options: { state: { isEvmSelected: false } }, + getNetworkState: jest.fn().mockImplementation(() => ({ + selectedNetworkClientId: InfuraNetworkType.mainnet, + })), + }); + + // non-EVM network is currently active + expect(controller.state.isEvmSelected).toBe(false); + + // Switching to EVM account + triggerSelectedAccountChange(EthAccountType.Eoa); + + // EVM network is now active + expect(controller.state.isEvmSelected).toBe(true); + }); + it('non-EVM network should be active when switching to account of same selected non-EVM network', async () => { + // By default, Solana is selected and active + const { controller, triggerSelectedAccountChange } = setupController({ + options: { + state: { + isEvmSelected: true, + selectedMultichainNetworkChainId: SolScope.Mainnet, + }, + }, + }); + + // EVM network is currently active + expect(controller.state.isEvmSelected).toBe(true); + + expect(controller.state.selectedMultichainNetworkChainId).toBe( + SolScope.Mainnet, + ); + + // Switching to Solana account + triggerSelectedAccountChange(SolAccountType.DataAccount); + + // Solana is still the selected network + expect(controller.state.selectedMultichainNetworkChainId).toBe( + SolScope.Mainnet, + ); + expect(controller.state.isEvmSelected).toBe(false); + }); + + it('non-EVM network should change when switching to account on different non-EVM network', async () => { + // By default, Solana is selected and active + const { controller, triggerSelectedAccountChange } = setupController({ + options: { + state: { + isEvmSelected: false, + selectedMultichainNetworkChainId: SolScope.Mainnet, + }, + }, + }); + + // Solana is currently active + expect(controller.state.isEvmSelected).toBe(false); + expect(controller.state.selectedMultichainNetworkChainId).toBe( + SolScope.Mainnet, + ); + + // Switching to Bitcoin account + triggerSelectedAccountChange(BtcAccountType.P2wpkh); + + // Bitcoin is now the selected network + expect(controller.state.selectedMultichainNetworkChainId).toBe( + BtcScope.Mainnet, + ); + expect(controller.state.isEvmSelected).toBe(false); + }); + + it('does not change the active network if the network is part of the account scope', async () => { + const { controller, triggerSelectedAccountChange } = setupController({ + options: { + state: { + isEvmSelected: false, + selectedMultichainNetworkChainId: SolScope.Devnet, + }, + }, + }); + + expect(controller.state.isEvmSelected).toBe(false); + expect(controller.state.selectedMultichainNetworkChainId).toBe( + SolScope.Devnet, + ); + + triggerSelectedAccountChange(SolAccountType.DataAccount); + + expect(controller.state.selectedMultichainNetworkChainId).toBe( + SolScope.Devnet, + ); + expect(controller.state.isEvmSelected).toBe(false); + }); + }); + + describe('removeEvmNetwork', () => { + it('switches the EVM selected network to Ethereum Mainnet and deletes previous EVM network if the current selected network is non-EVM', async () => { + const { + controller, + mockSetActiveNetwork, + mockRemoveNetwork, + mockFindNetworkClientIdByChainId, + } = setupController({ + options: { state: { isEvmSelected: false } }, + getSelectedChainId: jest.fn().mockImplementation(() => '0x2'), + findNetworkClientIdByChainId: jest + .fn() + .mockImplementation(() => 'ethereum'), + }); + + await controller.removeNetwork('eip155:2'); + expect(mockFindNetworkClientIdByChainId).toHaveBeenCalledWith('0x1'); + expect(mockSetActiveNetwork).toHaveBeenCalledWith('ethereum'); + expect(mockRemoveNetwork).toHaveBeenCalledWith('0x2'); + }); + + it('removes an EVM network when isEvmSelected is false and the removed network is not selected', async () => { + const { + controller, + mockRemoveNetwork, + mockSetActiveNetwork, + mockGetSelectedChainId, + mockFindNetworkClientIdByChainId, + } = setupController({ + options: { state: { isEvmSelected: false } }, + getSelectedChainId: jest.fn().mockImplementation(() => '0x2'), + }); + + await controller.removeNetwork('eip155:3'); + expect(mockGetSelectedChainId).toHaveBeenCalled(); + expect(mockFindNetworkClientIdByChainId).not.toHaveBeenCalled(); + expect(mockSetActiveNetwork).not.toHaveBeenCalled(); + expect(mockRemoveNetwork).toHaveBeenCalledWith('0x3'); + }); + + it('removes an EVM network when isEvmSelected is true and the removed network is not selected', async () => { + const { + controller, + mockRemoveNetwork, + mockSetActiveNetwork, + mockGetSelectedChainId, + mockFindNetworkClientIdByChainId, + } = setupController({ + options: { state: { isEvmSelected: false } }, + getSelectedChainId: jest.fn().mockImplementation(() => '0x2'), + }); + + await controller.removeNetwork('eip155:3'); + expect(mockGetSelectedChainId).toHaveBeenCalled(); + expect(mockFindNetworkClientIdByChainId).not.toHaveBeenCalled(); + expect(mockSetActiveNetwork).not.toHaveBeenCalled(); + expect(mockRemoveNetwork).toHaveBeenCalledWith('0x3'); + }); + + it('throws an error when trying to remove the currently selected network', async () => { + const { controller } = setupController({ + options: { state: { isEvmSelected: true } }, + getSelectedChainId: jest.fn().mockImplementation(() => '0x2'), + }); + + await expect(controller.removeNetwork('eip155:2')).rejects.toThrow( + 'Cannot remove the currently selected network', + ); + }); + + it('throws when trying to remove a non-EVM network', async () => { + const { controller } = setupController({ + options: { state: { isEvmSelected: false } }, + }); + + await expect(controller.removeNetwork(BtcScope.Mainnet)).rejects.toThrow( + 'Removal of non-EVM networks is not supported', + ); + }); + }); + + describe('getNetworksWithTransactionActivityByAccounts', () => { + const MOCK_EVM_ADDRESS = '0x1234567890123456789012345678901234567890'; + const MOCK_EVM_CHAIN_1 = '1'; + const MOCK_EVM_CHAIN_137 = '137'; + + it('returns empty object when no accounts exist', async () => { + const { controller, messenger } = setupController({ + getSelectedChainId: jest.fn().mockReturnValue('0x1'), + }); + + messenger.registerActionHandler( + 'AccountsController:listMultichainAccounts', + () => [], + ); + + const result = + await controller.getNetworksWithTransactionActivityByAccounts(); + expect(result).toStrictEqual({}); + }); + + it('fetches and formats network activity for EVM accounts', async () => { + const mockResponse: ActiveNetworksResponse = { + activeNetworks: [ + `${KnownCaipNamespace.Eip155}:${MOCK_EVM_CHAIN_1}:${MOCK_EVM_ADDRESS}`, + `${KnownCaipNamespace.Eip155}:${MOCK_EVM_CHAIN_137}:${MOCK_EVM_ADDRESS}`, + ], + }; + + const mockNetworkService = createMockNetworkService(mockResponse); + await mockNetworkService.fetchNetworkActivity([ + `${KnownCaipNamespace.Eip155}:${MOCK_EVM_CHAIN_1}:${MOCK_EVM_ADDRESS}`, + ]); + + const { controller, messenger } = setupController({ + mockNetworkService, + }); + + messenger.registerActionHandler( + 'AccountsController:listMultichainAccounts', + () => [ + createMockInternalAccount({ + type: EthAccountType.Eoa, + address: MOCK_EVM_ADDRESS, + scopes: [EthScope.Eoa], + }), + ], + ); + + const result = + await controller.getNetworksWithTransactionActivityByAccounts(); + + expect(mockNetworkService.fetchNetworkActivity).toHaveBeenCalledWith([ + `${KnownCaipNamespace.Eip155}:0:${MOCK_EVM_ADDRESS}`, + ]); + + expect(result).toStrictEqual({ + [MOCK_EVM_ADDRESS]: { + namespace: KnownCaipNamespace.Eip155, + activeChains: [MOCK_EVM_CHAIN_1, MOCK_EVM_CHAIN_137], + }, + }); + }); + }); + + describe('metadata', () => { + it('includes expected state in debug snapshots', () => { + const { controller } = setupController(); + + expect( + deriveStateFromMetadata( + controller.state, + controller.metadata, + 'includeInDebugSnapshot', + ), + ).toMatchInlineSnapshot(` + { + "isEvmSelected": true, + "multichainNetworkConfigurationsByChainId": { + "bip122:000000000019d6689c085ae165831e93": { + "chainId": "bip122:000000000019d6689c085ae165831e93", + "isEvm": false, + "name": "Bitcoin", + "nativeCurrency": "bip122:000000000019d6689c085ae165831e93/slip44:0", + }, + "bip122:000000000933ea01ad0ee984209779ba": { + "chainId": "bip122:000000000933ea01ad0ee984209779ba", + "isEvm": false, + "name": "Bitcoin Testnet", + "nativeCurrency": "bip122:000000000933ea01ad0ee984209779ba/slip44:0", + }, + "bip122:00000000da84f2bafbbc53dee25a72ae": { + "chainId": "bip122:00000000da84f2bafbbc53dee25a72ae", + "isEvm": false, + "name": "Bitcoin Testnet4", + "nativeCurrency": "bip122:00000000da84f2bafbbc53dee25a72ae/slip44:0", + }, + "bip122:00000008819873e925422c1ff0f99f7c": { + "chainId": "bip122:00000008819873e925422c1ff0f99f7c", + "isEvm": false, + "name": "Bitcoin Mutinynet", + "nativeCurrency": "bip122:00000008819873e925422c1ff0f99f7c/slip44:0", + }, + "bip122:regtest": { + "chainId": "bip122:regtest", + "isEvm": false, + "name": "Bitcoin Regtest", + "nativeCurrency": "bip122:regtest/slip44:0", + }, + "solana:4uhcVJyU9pJkvQyS88uRDiswHXSCkY3z": { + "chainId": "solana:4uhcVJyU9pJkvQyS88uRDiswHXSCkY3z", + "isEvm": false, + "name": "Solana Testnet", + "nativeCurrency": "solana:4uhcVJyU9pJkvQyS88uRDiswHXSCkY3z/slip44:501", + }, + "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp": { + "chainId": "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp", + "isEvm": false, + "name": "Solana", + "nativeCurrency": "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/slip44:501", + }, + "solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1": { + "chainId": "solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1", + "isEvm": false, + "name": "Solana Devnet", + "nativeCurrency": "solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1/slip44:501", + }, + "stellar:pubnet": { + "chainId": "stellar:pubnet", + "isEvm": false, + "name": "Stellar", + "nativeCurrency": "stellar:pubnet/slip44:148", + }, + "stellar:testnet": { + "chainId": "stellar:testnet", + "isEvm": false, + "name": "Stellar Testnet", + "nativeCurrency": "stellar:testnet/slip44:148", + }, + "tron:2494104990": { + "chainId": "tron:2494104990", + "isEvm": false, + "name": "Tron Shasta", + "nativeCurrency": "tron:2494104990/slip44:195", + }, + "tron:3448148188": { + "chainId": "tron:3448148188", + "isEvm": false, + "name": "Tron Nile", + "nativeCurrency": "tron:3448148188/slip44:195", + }, + "tron:728126428": { + "chainId": "tron:728126428", + "isEvm": false, + "name": "Tron", + "nativeCurrency": "tron:728126428/slip44:195", + }, + }, + "networksWithTransactionActivity": {}, + "selectedMultichainNetworkChainId": "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp", + } + `); + }); + + it('includes expected state in state logs', () => { + const { controller } = setupController(); + + expect( + deriveStateFromMetadata( + controller.state, + controller.metadata, + 'includeInStateLogs', + ), + ).toMatchInlineSnapshot(` + { + "isEvmSelected": true, + "multichainNetworkConfigurationsByChainId": { + "bip122:000000000019d6689c085ae165831e93": { + "chainId": "bip122:000000000019d6689c085ae165831e93", + "isEvm": false, + "name": "Bitcoin", + "nativeCurrency": "bip122:000000000019d6689c085ae165831e93/slip44:0", + }, + "bip122:000000000933ea01ad0ee984209779ba": { + "chainId": "bip122:000000000933ea01ad0ee984209779ba", + "isEvm": false, + "name": "Bitcoin Testnet", + "nativeCurrency": "bip122:000000000933ea01ad0ee984209779ba/slip44:0", + }, + "bip122:00000000da84f2bafbbc53dee25a72ae": { + "chainId": "bip122:00000000da84f2bafbbc53dee25a72ae", + "isEvm": false, + "name": "Bitcoin Testnet4", + "nativeCurrency": "bip122:00000000da84f2bafbbc53dee25a72ae/slip44:0", + }, + "bip122:00000008819873e925422c1ff0f99f7c": { + "chainId": "bip122:00000008819873e925422c1ff0f99f7c", + "isEvm": false, + "name": "Bitcoin Mutinynet", + "nativeCurrency": "bip122:00000008819873e925422c1ff0f99f7c/slip44:0", + }, + "bip122:regtest": { + "chainId": "bip122:regtest", + "isEvm": false, + "name": "Bitcoin Regtest", + "nativeCurrency": "bip122:regtest/slip44:0", + }, + "solana:4uhcVJyU9pJkvQyS88uRDiswHXSCkY3z": { + "chainId": "solana:4uhcVJyU9pJkvQyS88uRDiswHXSCkY3z", + "isEvm": false, + "name": "Solana Testnet", + "nativeCurrency": "solana:4uhcVJyU9pJkvQyS88uRDiswHXSCkY3z/slip44:501", + }, + "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp": { + "chainId": "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp", + "isEvm": false, + "name": "Solana", + "nativeCurrency": "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/slip44:501", + }, + "solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1": { + "chainId": "solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1", + "isEvm": false, + "name": "Solana Devnet", + "nativeCurrency": "solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1/slip44:501", + }, + "stellar:pubnet": { + "chainId": "stellar:pubnet", + "isEvm": false, + "name": "Stellar", + "nativeCurrency": "stellar:pubnet/slip44:148", + }, + "stellar:testnet": { + "chainId": "stellar:testnet", + "isEvm": false, + "name": "Stellar Testnet", + "nativeCurrency": "stellar:testnet/slip44:148", + }, + "tron:2494104990": { + "chainId": "tron:2494104990", + "isEvm": false, + "name": "Tron Shasta", + "nativeCurrency": "tron:2494104990/slip44:195", + }, + "tron:3448148188": { + "chainId": "tron:3448148188", + "isEvm": false, + "name": "Tron Nile", + "nativeCurrency": "tron:3448148188/slip44:195", + }, + "tron:728126428": { + "chainId": "tron:728126428", + "isEvm": false, + "name": "Tron", + "nativeCurrency": "tron:728126428/slip44:195", + }, + }, + "networksWithTransactionActivity": {}, + "selectedMultichainNetworkChainId": "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp", + } + `); + }); + + it('persists expected state', () => { + const { controller } = setupController(); + + expect( + deriveStateFromMetadata( + controller.state, + controller.metadata, + 'persist', + ), + ).toMatchInlineSnapshot(` + { + "isEvmSelected": true, + "multichainNetworkConfigurationsByChainId": { + "bip122:000000000019d6689c085ae165831e93": { + "chainId": "bip122:000000000019d6689c085ae165831e93", + "isEvm": false, + "name": "Bitcoin", + "nativeCurrency": "bip122:000000000019d6689c085ae165831e93/slip44:0", + }, + "bip122:000000000933ea01ad0ee984209779ba": { + "chainId": "bip122:000000000933ea01ad0ee984209779ba", + "isEvm": false, + "name": "Bitcoin Testnet", + "nativeCurrency": "bip122:000000000933ea01ad0ee984209779ba/slip44:0", + }, + "bip122:00000000da84f2bafbbc53dee25a72ae": { + "chainId": "bip122:00000000da84f2bafbbc53dee25a72ae", + "isEvm": false, + "name": "Bitcoin Testnet4", + "nativeCurrency": "bip122:00000000da84f2bafbbc53dee25a72ae/slip44:0", + }, + "bip122:00000008819873e925422c1ff0f99f7c": { + "chainId": "bip122:00000008819873e925422c1ff0f99f7c", + "isEvm": false, + "name": "Bitcoin Mutinynet", + "nativeCurrency": "bip122:00000008819873e925422c1ff0f99f7c/slip44:0", + }, + "bip122:regtest": { + "chainId": "bip122:regtest", + "isEvm": false, + "name": "Bitcoin Regtest", + "nativeCurrency": "bip122:regtest/slip44:0", + }, + "solana:4uhcVJyU9pJkvQyS88uRDiswHXSCkY3z": { + "chainId": "solana:4uhcVJyU9pJkvQyS88uRDiswHXSCkY3z", + "isEvm": false, + "name": "Solana Testnet", + "nativeCurrency": "solana:4uhcVJyU9pJkvQyS88uRDiswHXSCkY3z/slip44:501", + }, + "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp": { + "chainId": "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp", + "isEvm": false, + "name": "Solana", + "nativeCurrency": "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/slip44:501", + }, + "solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1": { + "chainId": "solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1", + "isEvm": false, + "name": "Solana Devnet", + "nativeCurrency": "solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1/slip44:501", + }, + "stellar:pubnet": { + "chainId": "stellar:pubnet", + "isEvm": false, + "name": "Stellar", + "nativeCurrency": "stellar:pubnet/slip44:148", + }, + "stellar:testnet": { + "chainId": "stellar:testnet", + "isEvm": false, + "name": "Stellar Testnet", + "nativeCurrency": "stellar:testnet/slip44:148", + }, + "tron:2494104990": { + "chainId": "tron:2494104990", + "isEvm": false, + "name": "Tron Shasta", + "nativeCurrency": "tron:2494104990/slip44:195", + }, + "tron:3448148188": { + "chainId": "tron:3448148188", + "isEvm": false, + "name": "Tron Nile", + "nativeCurrency": "tron:3448148188/slip44:195", + }, + "tron:728126428": { + "chainId": "tron:728126428", + "isEvm": false, + "name": "Tron", + "nativeCurrency": "tron:728126428/slip44:195", + }, + }, + "networksWithTransactionActivity": {}, + "selectedMultichainNetworkChainId": "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp", + } + `); + }); + + it('exposes expected state to UI', () => { + const { controller } = setupController(); + + expect( + deriveStateFromMetadata( + controller.state, + controller.metadata, + 'usedInUi', + ), + ).toMatchInlineSnapshot(` + { + "isEvmSelected": true, + "multichainNetworkConfigurationsByChainId": { + "bip122:000000000019d6689c085ae165831e93": { + "chainId": "bip122:000000000019d6689c085ae165831e93", + "isEvm": false, + "name": "Bitcoin", + "nativeCurrency": "bip122:000000000019d6689c085ae165831e93/slip44:0", + }, + "bip122:000000000933ea01ad0ee984209779ba": { + "chainId": "bip122:000000000933ea01ad0ee984209779ba", + "isEvm": false, + "name": "Bitcoin Testnet", + "nativeCurrency": "bip122:000000000933ea01ad0ee984209779ba/slip44:0", + }, + "bip122:00000000da84f2bafbbc53dee25a72ae": { + "chainId": "bip122:00000000da84f2bafbbc53dee25a72ae", + "isEvm": false, + "name": "Bitcoin Testnet4", + "nativeCurrency": "bip122:00000000da84f2bafbbc53dee25a72ae/slip44:0", + }, + "bip122:00000008819873e925422c1ff0f99f7c": { + "chainId": "bip122:00000008819873e925422c1ff0f99f7c", + "isEvm": false, + "name": "Bitcoin Mutinynet", + "nativeCurrency": "bip122:00000008819873e925422c1ff0f99f7c/slip44:0", + }, + "bip122:regtest": { + "chainId": "bip122:regtest", + "isEvm": false, + "name": "Bitcoin Regtest", + "nativeCurrency": "bip122:regtest/slip44:0", + }, + "solana:4uhcVJyU9pJkvQyS88uRDiswHXSCkY3z": { + "chainId": "solana:4uhcVJyU9pJkvQyS88uRDiswHXSCkY3z", + "isEvm": false, + "name": "Solana Testnet", + "nativeCurrency": "solana:4uhcVJyU9pJkvQyS88uRDiswHXSCkY3z/slip44:501", + }, + "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp": { + "chainId": "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp", + "isEvm": false, + "name": "Solana", + "nativeCurrency": "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/slip44:501", + }, + "solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1": { + "chainId": "solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1", + "isEvm": false, + "name": "Solana Devnet", + "nativeCurrency": "solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1/slip44:501", + }, + "stellar:pubnet": { + "chainId": "stellar:pubnet", + "isEvm": false, + "name": "Stellar", + "nativeCurrency": "stellar:pubnet/slip44:148", + }, + "stellar:testnet": { + "chainId": "stellar:testnet", + "isEvm": false, + "name": "Stellar Testnet", + "nativeCurrency": "stellar:testnet/slip44:148", + }, + "tron:2494104990": { + "chainId": "tron:2494104990", + "isEvm": false, + "name": "Tron Shasta", + "nativeCurrency": "tron:2494104990/slip44:195", + }, + "tron:3448148188": { + "chainId": "tron:3448148188", + "isEvm": false, + "name": "Tron Nile", + "nativeCurrency": "tron:3448148188/slip44:195", + }, + "tron:728126428": { + "chainId": "tron:728126428", + "isEvm": false, + "name": "Tron", + "nativeCurrency": "tron:728126428/slip44:195", + }, + }, + "networksWithTransactionActivity": {}, + "selectedMultichainNetworkChainId": "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp", + } + `); + }); + }); +}); diff --git a/packages/multichain-network-controller/src/MultichainNetworkController/MultichainNetworkController.ts b/packages/multichain-network-controller/src/MultichainNetworkController/MultichainNetworkController.ts new file mode 100644 index 00000000000..f3c1aa8620c --- /dev/null +++ b/packages/multichain-network-controller/src/MultichainNetworkController/MultichainNetworkController.ts @@ -0,0 +1,315 @@ +import { BaseController } from '@metamask/base-controller'; +import { isEvmAccountType } from '@metamask/keyring-api'; +import type { InternalAccount } from '@metamask/keyring-internal-api'; +import type { NetworkClientId } from '@metamask/network-controller'; +import { isCaipChainId } from '@metamask/utils'; +import type { CaipChainId } from '@metamask/utils'; + +import { + toAllowedCaipAccountIds, + toActiveNetworksByAddress, +} from '../api/accounts-api.js'; +import type { ActiveNetworksByAddress } from '../api/accounts-api.js'; +import { + AVAILABLE_MULTICHAIN_NETWORK_CONFIGURATIONS, + MULTICHAIN_NETWORK_CONTROLLER_METADATA, + getDefaultMultichainNetworkControllerState, +} from '../constants.js'; +import type { AbstractMultichainNetworkService } from '../MultichainNetworkService/AbstractMultichainNetworkService.js'; +import { MULTICHAIN_NETWORK_CONTROLLER_NAME } from '../types.js'; +import type { + MultichainNetworkControllerState, + MultichainNetworkControllerMessenger, + SupportedCaipChainId, +} from '../types.js'; +import { + checkIfSupportedCaipChainId, + getChainIdForNonEvm, + convertEvmCaipToHexChainId, + isEvmCaipChainId, +} from '../utils.js'; + +const MESSENGER_EXPOSED_METHODS = [ + 'setActiveNetwork', + 'getNetworksWithTransactionActivityByAccounts', +] as const; + +/** + * The MultichainNetworkController is responsible for fetching and caching account + * balances. + */ +export class MultichainNetworkController extends BaseController< + typeof MULTICHAIN_NETWORK_CONTROLLER_NAME, + MultichainNetworkControllerState, + MultichainNetworkControllerMessenger +> { + readonly #networkService: AbstractMultichainNetworkService; + + constructor({ + messenger, + state, + networkService, + }: { + messenger: MultichainNetworkControllerMessenger; + state?: Omit< + Partial, + 'multichainNetworkConfigurationsByChainId' + >; + networkService: AbstractMultichainNetworkService; + }) { + super({ + messenger, + name: MULTICHAIN_NETWORK_CONTROLLER_NAME, + metadata: MULTICHAIN_NETWORK_CONTROLLER_METADATA, + state: { + ...getDefaultMultichainNetworkControllerState(), + ...state, + // We can keep the current network as a hardcoded value + // since it is not expected to add/remove networks yet. + multichainNetworkConfigurationsByChainId: + AVAILABLE_MULTICHAIN_NETWORK_CONFIGURATIONS, + }, + }); + + this.#networkService = networkService; + this.#subscribeToMessageEvents(); + this.#registerMessageHandlers(); + } + + /** + * Sets the active EVM network. + * + * @param id - The client ID of the EVM network to set active. + */ + async #setActiveEvmNetwork(id: NetworkClientId): Promise { + const { selectedNetworkClientId } = this.messenger.call( + 'NetworkController:getState', + ); + + const shouldSetEvmActive = !this.state.isEvmSelected; + const shouldNotifyNetworkChange = id !== selectedNetworkClientId; + + // No changes needed if EVM is active and network is already selected + if (!shouldSetEvmActive && !shouldNotifyNetworkChange) { + return; + } + + // Update EVM selection state if needed + if (shouldSetEvmActive) { + this.update((state) => { + state.isEvmSelected = true; + }); + } + + // Only notify the network controller if the selected evm network is different + if (shouldNotifyNetworkChange) { + await this.messenger.call('NetworkController:setActiveNetwork', id); + } + + // Only publish the networkDidChange event if either the EVM network is different or we're switching between EVM and non-EVM networks + if (shouldSetEvmActive || shouldNotifyNetworkChange) { + this.messenger.publish( + 'MultichainNetworkController:networkDidChange', + id, + ); + } + } + + /** + * Sets the active non-EVM network. + * + * @param id - The chain ID of the non-EVM network to set active. + */ + #setActiveNonEvmNetwork(id: SupportedCaipChainId): void { + if ( + id === this.state.selectedMultichainNetworkChainId && + !this.state.isEvmSelected + ) { + // Same non-EVM network is already selected, no need to update + return; + } + + this.update((state) => { + state.selectedMultichainNetworkChainId = id; + state.isEvmSelected = false; + }); + + // Notify listeners that the network changed + this.messenger.publish('MultichainNetworkController:networkDidChange', id); + } + + /** + * Sets the active network. + * + * @param id - The non-EVM Caip chain ID or EVM client ID of the network to set active. + * @returns - A promise that resolves when the network is set active. + */ + async setActiveNetwork( + id: SupportedCaipChainId | NetworkClientId, + ): Promise { + if (isCaipChainId(id)) { + const isSupportedCaipChainId = checkIfSupportedCaipChainId(id); + if (!isSupportedCaipChainId) { + throw new Error(`Unsupported Caip chain ID: ${String(id)}`); + } + return this.#setActiveNonEvmNetwork(id); + } + + return await this.#setActiveEvmNetwork(id); + } + + /** + * Returns the active networks for the available EVM addresses (non-EVM networks will be supported in the future). + * Fetches the data from the API and caches it in state. + * + * @returns A promise that resolves to the active networks for the available addresses + */ + async getNetworksWithTransactionActivityByAccounts(): Promise { + // TODO: We are filtering out non-EVN accounts for now + // Support for non-EVM networks will be added in the coming weeks + const evmAccounts = this.messenger + .call('AccountsController:listMultichainAccounts') + .filter((account) => isEvmAccountType(account.type)); + + if (!evmAccounts || evmAccounts.length === 0) { + return this.state.networksWithTransactionActivity; + } + + const formattedAccounts = evmAccounts + .map((account: InternalAccount) => toAllowedCaipAccountIds(account)) + .flat(); + + const activeNetworks = + await this.#networkService.fetchNetworkActivity(formattedAccounts); + const formattedNetworks = toActiveNetworksByAddress(activeNetworks); + + this.update((state) => { + state.networksWithTransactionActivity = formattedNetworks; + }); + + return this.state.networksWithTransactionActivity; + } + + /** + * Removes an EVM network from the list of networks. + * This method re-directs the request to the network-controller. + * + * @param chainId - The chain ID of the network to remove. + * @returns - A promise that resolves when the network is removed. + */ + async #removeEvmNetwork(chainId: CaipChainId): Promise { + const hexChainId = convertEvmCaipToHexChainId(chainId); + const selectedChainId = this.messenger.call( + 'NetworkController:getSelectedChainId', + ); + + if (selectedChainId === hexChainId) { + // We prevent removing the currently selected network. + if (this.state.isEvmSelected) { + throw new Error('Cannot remove the currently selected network'); + } + + // If a non-EVM network is selected, we can delete the currently EVM selected network, but + // we automatically switch to EVM mainnet. + const ethereumMainnetHexChainId = '0x1'; // TODO: Should probably be a constant. + const clientId = this.messenger.call( + 'NetworkController:findNetworkClientIdByChainId', + ethereumMainnetHexChainId, + ); + + await this.messenger.call('NetworkController:setActiveNetwork', clientId); + } + + this.messenger.call('NetworkController:removeNetwork', hexChainId); + } + + /** + * Removes a non-EVM network from the list of networks. + * This method is not supported and throws an error. + * + * @param _chainId - The chain ID of the network to remove. + * @throws - An error indicating that removal of non-EVM networks is not supported. + */ + #removeNonEvmNetwork(_chainId: CaipChainId): void { + throw new Error('Removal of non-EVM networks is not supported'); + } + + /** + * Removes a network from the list of networks. + * It only supports EVM networks. + * + * @param chainId - The chain ID of the network to remove. + * @returns - A promise that resolves when the network is removed. + */ + async removeNetwork(chainId: CaipChainId): Promise { + if (isEvmCaipChainId(chainId)) { + return await this.#removeEvmNetwork(chainId); + } + + return this.#removeNonEvmNetwork(chainId); + } + + /** + * Handles switching between EVM and non-EVM networks when an account is changed + * + * @param account - The account that was changed + */ + #handleOnSelectedAccountChange(account: InternalAccount): void { + const { type: accountType, scopes } = account; + const isEvmAccount = isEvmAccountType(accountType); + + // Handle switching to EVM network + if (isEvmAccount) { + if (this.state.isEvmSelected) { + // No need to update if already on evm network + return; + } + + // Make EVM network active + this.update((state) => { + state.isEvmSelected = true; + }); + + return; + } + + // Handle switching to non-EVM network + if (scopes.includes(this.state.selectedMultichainNetworkChainId)) { + // No need to update if the account's scope includes the active network + this.update((state) => { + state.isEvmSelected = false; + }); + return; + } + + const nonEvmChainId = getChainIdForNonEvm(scopes); + this.update((state) => { + state.selectedMultichainNetworkChainId = nonEvmChainId; + state.isEvmSelected = false; + }); + + // No need to publish NetworkController:setActiveNetwork because EVM accounts falls back to use the last selected EVM network + // DO NOT publish MultichainNetworkController:networkDidChange to prevent circular listener loops + } + + /** + * Subscribes to message events. + */ + #subscribeToMessageEvents(): void { + // Handle network switch when account is changed + this.messenger.subscribe( + 'AccountsController:selectedAccountChange', + (account) => this.#handleOnSelectedAccountChange(account), + ); + } + + /** + * Registers message handlers. + */ + #registerMessageHandlers(): void { + this.messenger.registerMethodActionHandlers( + this, + MESSENGER_EXPOSED_METHODS, + ); + } +} diff --git a/packages/multichain-network-controller/src/MultichainNetworkService/AbstractMultichainNetworkService.ts b/packages/multichain-network-controller/src/MultichainNetworkService/AbstractMultichainNetworkService.ts new file mode 100644 index 00000000000..fd1ead65f1d --- /dev/null +++ b/packages/multichain-network-controller/src/MultichainNetworkService/AbstractMultichainNetworkService.ts @@ -0,0 +1,9 @@ +import type { PublicInterface } from '@metamask/utils'; + +import type { MultichainNetworkService } from './MultichainNetworkService.js'; + +/** + * A service object which is responsible for fetching network activity data. + */ +export type AbstractMultichainNetworkService = + PublicInterface; diff --git a/packages/multichain-network-controller/src/MultichainNetworkService/MultichainNetworkService.test.ts b/packages/multichain-network-controller/src/MultichainNetworkService/MultichainNetworkService.test.ts new file mode 100644 index 00000000000..be29e727cf9 --- /dev/null +++ b/packages/multichain-network-controller/src/MultichainNetworkService/MultichainNetworkService.test.ts @@ -0,0 +1,244 @@ +import { KnownCaipNamespace } from '@metamask/utils'; +import type { CaipAccountId } from '@metamask/utils'; +import { chunk } from 'lodash'; + +import { + MULTICHAIN_ACCOUNTS_CLIENT_HEADER, + MULTICHAIN_ACCOUNTS_CLIENT_ID, + MULTICHAIN_ACCOUNTS_BASE_URL, +} from '../api/accounts-api.js'; +import type { ActiveNetworksResponse } from '../api/accounts-api.js'; +import { MultichainNetworkService } from './MultichainNetworkService.js'; + +describe('MultichainNetworkService', () => { + beforeEach(() => { + jest.resetAllMocks(); + }); + + const mockFetch = jest.fn(); + const MOCK_EVM_ADDRESS = '0x1234567890123456789012345678901234567890'; + const MOCK_EVM_CHAIN_1 = '1'; + const MOCK_EVM_CHAIN_137 = '137'; + const DEFAULT_BATCH_SIZE = 20; + const validAccountIds: CaipAccountId[] = [ + `${KnownCaipNamespace.Eip155}:${MOCK_EVM_CHAIN_1}:${MOCK_EVM_ADDRESS}`, + `${KnownCaipNamespace.Eip155}:${MOCK_EVM_CHAIN_137}:${MOCK_EVM_ADDRESS}`, + ]; + + describe('constructor', () => { + it('creates an instance with the provided fetch implementation', () => { + const service = new MultichainNetworkService({ + fetch: mockFetch, + }); + expect(service).toBeInstanceOf(MultichainNetworkService); + }); + + it('accepts a custom batch size', () => { + const customBatchSize = 10; + const service = new MultichainNetworkService({ + fetch: mockFetch, + batchSize: customBatchSize, + }); + expect(service).toBeInstanceOf(MultichainNetworkService); + }); + }); + + describe('fetchNetworkActivity', () => { + it('returns empty response for empty account list without making network requests', async () => { + const service = new MultichainNetworkService({ + fetch: mockFetch, + }); + + const result = await service.fetchNetworkActivity([]); + + expect(mockFetch).not.toHaveBeenCalled(); + expect(result).toStrictEqual({ activeNetworks: [] }); + }); + + it('makes request with correct URL and headers for single batch', async () => { + const mockResponse: ActiveNetworksResponse = { + activeNetworks: [ + `${KnownCaipNamespace.Eip155}:${MOCK_EVM_CHAIN_1}:${MOCK_EVM_ADDRESS}`, + `${KnownCaipNamespace.Eip155}:${MOCK_EVM_CHAIN_137}:${MOCK_EVM_ADDRESS}`, + ], + }; + + mockFetch.mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve(mockResponse), + }); + + const service = new MultichainNetworkService({ + fetch: mockFetch, + }); + const result = await service.fetchNetworkActivity(validAccountIds); + + expect(mockFetch).toHaveBeenCalledWith( + `${MULTICHAIN_ACCOUNTS_BASE_URL}/v2/activeNetworks?accountIds=${encodeURIComponent(validAccountIds.join(','))}`, + { + method: 'GET', + headers: { + [MULTICHAIN_ACCOUNTS_CLIENT_HEADER]: MULTICHAIN_ACCOUNTS_CLIENT_ID, + Accept: 'application/json', + }, + }, + ); + expect(result).toStrictEqual(mockResponse); + }); + + it('batches requests when account IDs exceed the default batch size', async () => { + const manyAccountIds: CaipAccountId[] = []; + for (let i = 1; i <= 30; i++) { + manyAccountIds.push( + `${KnownCaipNamespace.Eip155}:${i}:${MOCK_EVM_ADDRESS}` as CaipAccountId, + ); + } + + const batches = chunk(manyAccountIds, DEFAULT_BATCH_SIZE); + + const firstBatchResponse = { + activeNetworks: batches[0], + }; + const secondBatchResponse = { + activeNetworks: batches[1], + }; + + mockFetch + .mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve(firstBatchResponse), + }) + .mockResolvedValue({ + ok: true, + json: () => Promise.resolve(secondBatchResponse), + }); + + const service = new MultichainNetworkService({ + fetch: mockFetch, + }); + + const result = await service.fetchNetworkActivity(manyAccountIds); + + expect(mockFetch).toHaveBeenCalledTimes(2); + + for (const accountId of manyAccountIds) { + expect(result.activeNetworks).toContain(accountId); + } + }); + + it('batches requests with custom batch size', async () => { + const customBatchSize = 10; + const manyAccountIds: CaipAccountId[] = []; + for (let i = 1; i <= 30; i++) { + manyAccountIds.push( + `${KnownCaipNamespace.Eip155}:${i}:${MOCK_EVM_ADDRESS}` as CaipAccountId, + ); + } + + const batches = chunk(manyAccountIds, customBatchSize); + expect(batches).toHaveLength(3); + + const batchResponses = batches.map((batch) => ({ + activeNetworks: batch, + })); + + mockFetch + .mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve(batchResponses[0]), + }) + .mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve(batchResponses[1]), + }) + .mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve(batchResponses[2]), + }); + + const service = new MultichainNetworkService({ + fetch: mockFetch, + batchSize: customBatchSize, + }); + + const result = await service.fetchNetworkActivity(manyAccountIds); + + expect(mockFetch).toHaveBeenCalledTimes(3); + + for (const accountId of manyAccountIds) { + expect(result.activeNetworks).toContain(accountId); + } + }); + + it('throws error for non-200 response', async () => { + mockFetch.mockResolvedValueOnce({ + ok: false, + status: 404, + }); + + const service = new MultichainNetworkService({ + fetch: mockFetch, + }); + + await expect( + service.fetchNetworkActivity(validAccountIds), + ).rejects.toThrow('HTTP error! status: 404'); + }); + + it('throws error for invalid response format', async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve({ invalidKey: 'invalid data' }), + }); + + const service = new MultichainNetworkService({ + fetch: mockFetch, + }); + + await expect( + service.fetchNetworkActivity(validAccountIds), + ).rejects.toThrow( + 'At path: activeNetworks -- Expected an array value, but received: undefined', + ); + }); + + it('throws timeout error when request is aborted', async () => { + const abortError = new Error('The operation was aborted'); + abortError.name = 'AbortError'; + mockFetch.mockRejectedValueOnce(abortError); + + const service = new MultichainNetworkService({ + fetch: mockFetch, + }); + + await expect( + service.fetchNetworkActivity(validAccountIds), + ).rejects.toThrow('Request timeout: Failed to fetch active networks'); + }); + + it('propagates network errors', async () => { + const networkError = new Error('Network error'); + mockFetch.mockRejectedValueOnce(networkError); + + const service = new MultichainNetworkService({ + fetch: mockFetch, + }); + + await expect( + service.fetchNetworkActivity(validAccountIds), + ).rejects.toThrow(networkError.message); + }); + + it('throws formatted error for non-Error failures', async () => { + mockFetch.mockRejectedValueOnce('Unknown error'); + + const service = new MultichainNetworkService({ + fetch: mockFetch, + }); + + await expect( + service.fetchNetworkActivity(validAccountIds), + ).rejects.toThrow('Failed to fetch active networks: Unknown error'); + }); + }); +}); diff --git a/packages/multichain-network-controller/src/MultichainNetworkService/MultichainNetworkService.ts b/packages/multichain-network-controller/src/MultichainNetworkService/MultichainNetworkService.ts new file mode 100644 index 00000000000..e564c491812 --- /dev/null +++ b/packages/multichain-network-controller/src/MultichainNetworkService/MultichainNetworkService.ts @@ -0,0 +1,105 @@ +import { assert } from '@metamask/superstruct'; +import type { CaipAccountId } from '@metamask/utils'; +import { chunk } from 'lodash'; + +import { + ActiveNetworksResponseStruct, + buildActiveNetworksUrl, + MULTICHAIN_ACCOUNTS_CLIENT_HEADER, + MULTICHAIN_ACCOUNTS_CLIENT_ID, +} from '../api/accounts-api.js'; +import type { ActiveNetworksResponse } from '../api/accounts-api.js'; + +/** + * Service responsible for fetching network activity data from the API. + */ +export class MultichainNetworkService { + readonly #fetch: typeof fetch; + + readonly #batchSize: number; + + constructor({ + fetch: fetchFunction, + batchSize, + }: { + fetch: typeof fetch; + batchSize?: number; + }) { + this.#fetch = fetchFunction; + this.#batchSize = batchSize ?? 20; + } + + /** + * Fetches active networks for the given account IDs. + * Automatically handles batching requests to comply with URL length limitations. + * + * @param accountIds - Array of CAIP-10 account IDs to fetch activity for. + * @returns Promise resolving to the combined active networks response. + * @throws Error if the response format is invalid or the request fails. + */ + async fetchNetworkActivity( + accountIds: CaipAccountId[], + ): Promise { + if (accountIds.length === 0) { + return { activeNetworks: [] }; + } + + if (accountIds.length <= this.#batchSize) { + return this.#fetchNetworkActivityBatch(accountIds); + } + + const batches = chunk(accountIds, this.#batchSize); + const batchResults = await Promise.all( + batches.map((batch) => this.#fetchNetworkActivityBatch(batch)), + ); + + const combinedResponse: ActiveNetworksResponse = { + activeNetworks: batchResults.flatMap( + (response) => response.activeNetworks, + ), + }; + + return combinedResponse; + } + + /** + * Internal method to fetch a single batch of account IDs. + * + * @param accountIds - Batch of account IDs to fetch + * @returns Promise resolving to the active networks response for this batch + * @throws Error if the response format is invalid or the request fails + */ + async #fetchNetworkActivityBatch( + accountIds: CaipAccountId[], + ): Promise { + try { + const url = buildActiveNetworksUrl(accountIds); + + const response = await this.#fetch(url.toString(), { + method: 'GET', + headers: { + [MULTICHAIN_ACCOUNTS_CLIENT_HEADER]: MULTICHAIN_ACCOUNTS_CLIENT_ID, + Accept: 'application/json', + }, + }); + + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); + } + + const data: unknown = await response.json(); + + assert(data, ActiveNetworksResponseStruct); + return data; + } catch (error) { + if (error instanceof Error) { + if (error.name === 'AbortError') { + throw new Error('Request timeout: Failed to fetch active networks'); + } + throw error; + } + + throw new Error(`Failed to fetch active networks: ${String(error)}`); + } + } +} diff --git a/packages/multichain-network-controller/src/api/accounts-api.test.ts b/packages/multichain-network-controller/src/api/accounts-api.test.ts new file mode 100644 index 00000000000..c9bab8bbc58 --- /dev/null +++ b/packages/multichain-network-controller/src/api/accounts-api.test.ts @@ -0,0 +1,249 @@ +import { + BtcScope, + SolScope, + EthScope, + EthAccountType, + BtcAccountType, + SolAccountType, + TrxScope, + TrxAccountType, +} from '@metamask/keyring-api'; +import type { InternalAccount } from '@metamask/keyring-internal-api'; +import { KnownCaipNamespace } from '@metamask/utils'; +import type { + CaipAccountId, + CaipChainId, + CaipReference, +} from '@metamask/utils'; + +import { + toAllowedCaipAccountIds, + toActiveNetworksByAddress, + buildActiveNetworksUrl, + MULTICHAIN_ACCOUNTS_BASE_URL, +} from './accounts-api.js'; +import type { ActiveNetworksResponse } from './accounts-api.js'; + +const MOCK_ADDRESSES = { + evm: '0x1234567890123456789012345678901234567890', + solana: 'EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v', + bitcoin: 'bc1qar0srrr7xfkvy5l643lydnw9re59gtzzwf5mdq', + tron: 'TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t', +} as const; + +const MOCK_CAIP_IDS = { + // Use of scope (CAIP-2) to craft a CAIP-10 identifiers. + evm: `${EthScope.Mainnet}:${MOCK_ADDRESSES.evm}`, + solana: `${SolScope.Mainnet}:${MOCK_ADDRESSES.solana}`, + bitcoin: `${BtcScope.Mainnet}:${MOCK_ADDRESSES.bitcoin}`, + tron: `${TrxScope.Mainnet}:${MOCK_ADDRESSES.tron}`, +} as const; + +describe('toAllowedCaipAccountIds', () => { + const createMockAccount = ( + address: string, + scopes: CaipChainId[], + type: InternalAccount['type'], + ): InternalAccount => ({ + address, + scopes, + type, + id: '1', + options: {}, + methods: [], + metadata: { + name: 'Test Account', + importTime: Date.now(), + keyring: { type: 'test' }, + }, + }); + + it('formats account with EVM scopes', () => { + const account = createMockAccount( + MOCK_ADDRESSES.evm, + [EthScope.Mainnet, EthScope.Testnet], + EthAccountType.Eoa, + ); + + const result = toAllowedCaipAccountIds(account); + expect(result).toStrictEqual([ + `${EthScope.Mainnet}:${MOCK_ADDRESSES.evm}`, + `${EthScope.Testnet}:${MOCK_ADDRESSES.evm}`, + ]); + }); + + it('formats account with BTC scope', () => { + const account = createMockAccount( + MOCK_ADDRESSES.bitcoin, + [BtcScope.Mainnet], + BtcAccountType.P2wpkh, + ); + + const result = toAllowedCaipAccountIds(account); + expect(result).toStrictEqual([ + `${BtcScope.Mainnet}:${MOCK_ADDRESSES.bitcoin}`, + ]); + }); + + it('formats account with Solana scope', () => { + const account = createMockAccount( + MOCK_ADDRESSES.solana, + [SolScope.Mainnet], + SolAccountType.DataAccount, + ); + + const result = toAllowedCaipAccountIds(account); + expect(result).toStrictEqual([ + `${SolScope.Mainnet}:${MOCK_ADDRESSES.solana}`, + ]); + }); + + it('formats account with Tron scope', () => { + const account = createMockAccount( + MOCK_ADDRESSES.tron, + [TrxScope.Mainnet], + TrxAccountType.Eoa, + ); + + const result = toAllowedCaipAccountIds(account); + expect(result).toStrictEqual([ + `${TrxScope.Mainnet}:${MOCK_ADDRESSES.tron}`, + ]); + }); + + it('excludes unsupported scopes', () => { + const account = createMockAccount( + MOCK_ADDRESSES.evm, + [EthScope.Mainnet, 'unsupported:123'], + EthAccountType.Eoa, + ); + + const result = toAllowedCaipAccountIds(account); + expect(result).toStrictEqual([`${EthScope.Mainnet}:${MOCK_ADDRESSES.evm}`]); + }); + + it('returns empty array for account with no supported scopes', () => { + const account = createMockAccount( + MOCK_ADDRESSES.evm, + ['unsupported:123'], + EthAccountType.Eoa, + ); + + const result = toAllowedCaipAccountIds(account); + expect(result).toStrictEqual([]); + }); +}); + +describe('toActiveNetworksByAddress', () => { + const SOLANA_MAINNET: CaipReference = '5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp'; + + it('formats EVM network responses', () => { + const response: ActiveNetworksResponse = { + activeNetworks: [ + `${KnownCaipNamespace.Eip155}:1:${MOCK_ADDRESSES.evm}`, + `${KnownCaipNamespace.Eip155}:137:${MOCK_ADDRESSES.evm}`, + ], + }; + + const result = toActiveNetworksByAddress(response); + + expect(result).toStrictEqual({ + [MOCK_ADDRESSES.evm]: { + namespace: KnownCaipNamespace.Eip155, + activeChains: ['1', '137'], + }, + }); + }); + + it('formats non-EVM network responses', () => { + const response: ActiveNetworksResponse = { + activeNetworks: [ + `${KnownCaipNamespace.Solana}:${SOLANA_MAINNET}:${MOCK_ADDRESSES.solana}`, + ], + }; + + const result = toActiveNetworksByAddress(response); + + expect(result).toStrictEqual({ + [MOCK_ADDRESSES.solana]: { + namespace: KnownCaipNamespace.Solana, + activeChains: [SOLANA_MAINNET], + }, + }); + }); + + it('formats mixed EVM and non-EVM networks', () => { + const response: ActiveNetworksResponse = { + activeNetworks: [ + `${KnownCaipNamespace.Eip155}:1:${MOCK_ADDRESSES.evm}`, + `${KnownCaipNamespace.Solana}:${SOLANA_MAINNET}:${MOCK_ADDRESSES.solana}`, + ], + }; + + const result = toActiveNetworksByAddress(response); + + expect(result).toStrictEqual({ + [MOCK_ADDRESSES.evm]: { + namespace: KnownCaipNamespace.Eip155, + activeChains: ['1'], + }, + [MOCK_ADDRESSES.solana]: { + namespace: KnownCaipNamespace.Solana, + activeChains: [SOLANA_MAINNET], + }, + }); + }); + + it('returns empty object for empty response', () => { + const response: ActiveNetworksResponse = { + activeNetworks: [], + }; + + const result = toActiveNetworksByAddress(response); + + expect(result).toStrictEqual({}); + }); + + it('formats multiple addresses with different networks', () => { + const secondEvmAddress = '0x9876543210987654321098765432109876543210'; + const response: ActiveNetworksResponse = { + activeNetworks: [ + `${KnownCaipNamespace.Eip155}:1:${MOCK_ADDRESSES.evm}`, + `${KnownCaipNamespace.Eip155}:137:${secondEvmAddress}`, + ], + }; + + const result = toActiveNetworksByAddress(response); + + expect(result).toStrictEqual({ + [MOCK_ADDRESSES.evm]: { + namespace: KnownCaipNamespace.Eip155, + activeChains: ['1'], + }, + [secondEvmAddress]: { + namespace: KnownCaipNamespace.Eip155, + activeChains: ['137'], + }, + }); + }); +}); + +describe('buildActiveNetworksUrl', () => { + it('constructs URL with single account ID', () => { + const url = buildActiveNetworksUrl([MOCK_CAIP_IDS.evm]); + expect(url.toString()).toBe( + `${MULTICHAIN_ACCOUNTS_BASE_URL}/v2/activeNetworks?accountIds=${encodeURIComponent(MOCK_CAIP_IDS.evm)}`, + ); + }); + + it('constructs URL with multiple account IDs', () => { + const accountIds: CaipAccountId[] = [ + MOCK_CAIP_IDS.evm, + MOCK_CAIP_IDS.solana, + ]; + const url = buildActiveNetworksUrl(accountIds); + expect(url.toString()).toBe( + `${MULTICHAIN_ACCOUNTS_BASE_URL}/v2/activeNetworks?accountIds=${encodeURIComponent(accountIds.join(','))}`, + ); + }); +}); diff --git a/packages/multichain-network-controller/src/api/accounts-api.ts b/packages/multichain-network-controller/src/api/accounts-api.ts new file mode 100644 index 00000000000..c5f869ef999 --- /dev/null +++ b/packages/multichain-network-controller/src/api/accounts-api.ts @@ -0,0 +1,127 @@ +import { BtcScope, SolScope, EthScope, TrxScope } from '@metamask/keyring-api'; +import type { InternalAccount } from '@metamask/keyring-internal-api'; +import { array, object } from '@metamask/superstruct'; +import type { Infer } from '@metamask/superstruct'; +import { CaipAccountIdStruct, parseCaipAccountId } from '@metamask/utils'; +import type { + CaipAccountAddress, + CaipAccountId, + CaipNamespace, + CaipReference, +} from '@metamask/utils'; + +export const ActiveNetworksResponseStruct = object({ + activeNetworks: array(CaipAccountIdStruct), +}); + +export type ActiveNetworksResponse = Infer; + +/** + * The active networks for the currently selected account. + */ +export type ActiveNetworksByAddress = Record< + CaipAccountAddress, + { + // CAIP-2 namespace of the network. + namespace: CaipNamespace; + // Active chain IDs (CAIP-2 references) on that network (primarily used for EVM networks). + activeChains: CaipReference[]; + } +>; + +/** + * The domain for multichain accounts API. + */ +export const MULTICHAIN_ACCOUNTS_BASE_URL = + 'https://accounts.api.cx.metamask.io'; + +/** + * The client header for the multichain accounts API. + */ +export const MULTICHAIN_ACCOUNTS_CLIENT_HEADER = 'x-metamask-clientproduct'; + +/** + * The client ID for the multichain accounts API. + */ +export const MULTICHAIN_ACCOUNTS_CLIENT_ID = + 'metamask-multichain-network-controller'; + +/** + * The allowed active network scopes for the multichain network controller. + */ +export const MULTICHAIN_ALLOWED_ACTIVE_NETWORK_SCOPES = [ + String(BtcScope.Mainnet), + String(BtcScope.Testnet), + String(BtcScope.Testnet4), + String(BtcScope.Signet), + String(BtcScope.Regtest), + String(SolScope.Mainnet), + String(SolScope.Devnet), + String(EthScope.Mainnet), + String(EthScope.Testnet), + String(EthScope.Eoa), + String(TrxScope.Mainnet), + String(TrxScope.Nile), + String(TrxScope.Shasta), +]; + +/** + * Converts an internal account to an array of CAIP-10 account IDs. + * + * @param account - The internal account to convert + * @returns The CAIP-10 account IDs + */ +export function toAllowedCaipAccountIds( + account: InternalAccount, +): CaipAccountId[] { + const formattedAccounts: CaipAccountId[] = []; + for (const scope of account.scopes) { + if (MULTICHAIN_ALLOWED_ACTIVE_NETWORK_SCOPES.includes(scope)) { + formattedAccounts.push(`${scope}:${account.address}`); + } + } + + return formattedAccounts; +} + +/** + * Formats the API response into our state structure. + * Example input: ["eip155:1:0x123...", "eip155:137:0x123...", "solana:1:0xabc..."] + * + * @param response - The raw API response + * @returns Formatted networks by address + */ +export function toActiveNetworksByAddress( + response: ActiveNetworksResponse, +): ActiveNetworksByAddress { + const networksByAddress: ActiveNetworksByAddress = {}; + + response.activeNetworks.forEach((network) => { + const { + address, + chain: { namespace, reference }, + } = parseCaipAccountId(network); + + if (!networksByAddress[address]) { + networksByAddress[address] = { + namespace, + activeChains: [], + }; + } + networksByAddress[address].activeChains.push(reference); + }); + + return networksByAddress; +} + +/** + * Constructs the URL for the active networks API endpoint. + * + * @param accountIds - Array of account IDs + * @returns URL object for the API endpoint + */ +export function buildActiveNetworksUrl(accountIds: CaipAccountId[]): URL { + const url = new URL(`${MULTICHAIN_ACCOUNTS_BASE_URL}/v2/activeNetworks`); + url.searchParams.append('accountIds', accountIds.join(',')); + return url; +} diff --git a/packages/multichain-network-controller/src/constants.ts b/packages/multichain-network-controller/src/constants.ts new file mode 100644 index 00000000000..860978370d4 --- /dev/null +++ b/packages/multichain-network-controller/src/constants.ts @@ -0,0 +1,239 @@ +import type { StateMetadata } from '@metamask/base-controller'; +import { BtcScope, SolScope, TrxScope, XlmScope } from '@metamask/keyring-api'; +import type { CaipChainId } from '@metamask/keyring-api'; +import { NetworkStatus } from '@metamask/network-controller'; + +import type { + MultichainNetworkConfiguration, + MultichainNetworkControllerState, + MultichainNetworkMetadata, + SupportedCaipChainId, +} from './types.js'; + +export const BTC_NATIVE_ASSET = `${BtcScope.Mainnet}/slip44:0`; +export const BTC_TESTNET_NATIVE_ASSET = `${BtcScope.Testnet}/slip44:0`; +export const BTC_TESTNET4_NATIVE_ASSET = `${BtcScope.Testnet4}/slip44:0`; +export const BTC_SIGNET_NATIVE_ASSET = `${BtcScope.Signet}/slip44:0`; +export const BTC_REGTEST_NATIVE_ASSET = `${BtcScope.Regtest}/slip44:0`; +export const SOL_NATIVE_ASSET = `${SolScope.Mainnet}/slip44:501`; +export const SOL_TESTNET_NATIVE_ASSET = `${SolScope.Testnet}/slip44:501`; +export const SOL_DEVNET_NATIVE_ASSET = `${SolScope.Devnet}/slip44:501`; +export const TRX_NATIVE_ASSET = `${TrxScope.Mainnet}/slip44:195`; +export const TRX_NILE_NATIVE_ASSET = `${TrxScope.Nile}/slip44:195`; +export const TRX_SHASTA_NATIVE_ASSET = `${TrxScope.Shasta}/slip44:195`; +export const XLM_NATIVE_ASSET = `${XlmScope.Pubnet}/slip44:148`; +export const XLM_TESTNET_NATIVE_ASSET = `${XlmScope.Testnet}/slip44:148`; + +/** + * Supported networks by the MultichainNetworkController + */ +export const AVAILABLE_MULTICHAIN_NETWORK_CONFIGURATIONS: Record< + SupportedCaipChainId, + MultichainNetworkConfiguration +> = { + [BtcScope.Mainnet]: { + chainId: BtcScope.Mainnet, + name: 'Bitcoin', + nativeCurrency: BTC_NATIVE_ASSET, + isEvm: false, + }, + [BtcScope.Testnet]: { + chainId: BtcScope.Testnet, + name: 'Bitcoin Testnet', + nativeCurrency: BTC_TESTNET_NATIVE_ASSET, + isEvm: false, + }, + [BtcScope.Testnet4]: { + chainId: BtcScope.Testnet4, + name: 'Bitcoin Testnet4', + nativeCurrency: BTC_TESTNET4_NATIVE_ASSET, + isEvm: false, + }, + [BtcScope.Signet]: { + chainId: BtcScope.Signet, + name: 'Bitcoin Mutinynet', + nativeCurrency: BTC_SIGNET_NATIVE_ASSET, + isEvm: false, + }, + [BtcScope.Regtest]: { + chainId: BtcScope.Regtest, + name: 'Bitcoin Regtest', + nativeCurrency: BTC_REGTEST_NATIVE_ASSET, + isEvm: false, + }, + [SolScope.Mainnet]: { + chainId: SolScope.Mainnet, + name: 'Solana', + nativeCurrency: SOL_NATIVE_ASSET, + isEvm: false, + }, + [SolScope.Testnet]: { + chainId: SolScope.Testnet, + name: 'Solana Testnet', + nativeCurrency: SOL_TESTNET_NATIVE_ASSET, + isEvm: false, + }, + [SolScope.Devnet]: { + chainId: SolScope.Devnet, + name: 'Solana Devnet', + nativeCurrency: SOL_DEVNET_NATIVE_ASSET, + isEvm: false, + }, + [TrxScope.Mainnet]: { + chainId: TrxScope.Mainnet, + name: 'Tron', + nativeCurrency: TRX_NATIVE_ASSET, + isEvm: false, + }, + [TrxScope.Nile]: { + chainId: TrxScope.Nile, + name: 'Tron Nile', + nativeCurrency: TRX_NILE_NATIVE_ASSET, + isEvm: false, + }, + [TrxScope.Shasta]: { + chainId: TrxScope.Shasta, + name: 'Tron Shasta', + nativeCurrency: TRX_SHASTA_NATIVE_ASSET, + isEvm: false, + }, + [XlmScope.Pubnet]: { + chainId: XlmScope.Pubnet, + name: 'Stellar', + nativeCurrency: XLM_NATIVE_ASSET, + isEvm: false, + }, + [XlmScope.Testnet]: { + chainId: XlmScope.Testnet, + name: 'Stellar Testnet', + nativeCurrency: XLM_TESTNET_NATIVE_ASSET, + isEvm: false, + }, +}; + +/** + * Array of all the Non-EVM chain IDs. + * This is a temporary mention until we develop + * a more robust solution to identify testnet networks. + */ +export const NON_EVM_TESTNET_IDS: CaipChainId[] = [ + BtcScope.Testnet, + BtcScope.Testnet4, + BtcScope.Signet, + BtcScope.Regtest, + SolScope.Testnet, + SolScope.Devnet, + TrxScope.Nile, + TrxScope.Shasta, + XlmScope.Testnet, +]; + +/** + * Metadata for the supported networks. + */ +export const NETWORKS_METADATA: Record = { + [BtcScope.Mainnet]: { + features: [], + status: NetworkStatus.Available, + }, + [SolScope.Mainnet]: { + features: [], + status: NetworkStatus.Available, + }, + [TrxScope.Mainnet]: { + features: [], + status: NetworkStatus.Available, + }, + [XlmScope.Pubnet]: { + features: [], + status: NetworkStatus.Available, + }, +}; + +/** + * Default state of the {@link MultichainNetworkController}. + * + * @returns The default state of the {@link MultichainNetworkController}. + */ +export const getDefaultMultichainNetworkControllerState = + (): MultichainNetworkControllerState => ({ + multichainNetworkConfigurationsByChainId: + AVAILABLE_MULTICHAIN_NETWORK_CONFIGURATIONS, + selectedMultichainNetworkChainId: SolScope.Mainnet, + isEvmSelected: true, + networksWithTransactionActivity: {}, + }); + +/** + * {@link MultichainNetworkController}'s metadata. + * + * This allows us to choose if fields of the state should be persisted or not + * using the `persist` flag; and if they can be sent to Sentry or not, using + * the `anonymous` flag. + */ +export const MULTICHAIN_NETWORK_CONTROLLER_METADATA = { + multichainNetworkConfigurationsByChainId: { + includeInStateLogs: true, + persist: true, + includeInDebugSnapshot: true, + usedInUi: true, + }, + selectedMultichainNetworkChainId: { + includeInStateLogs: true, + persist: true, + includeInDebugSnapshot: true, + usedInUi: true, + }, + isEvmSelected: { + includeInStateLogs: true, + persist: true, + includeInDebugSnapshot: true, + usedInUi: true, + }, + networksWithTransactionActivity: { + includeInStateLogs: true, + persist: true, + includeInDebugSnapshot: true, + usedInUi: true, + }, +} satisfies StateMetadata; + +/** + * Multichain network ticker for the supported networks. + * TODO: This should be part of the assets-controllers or the snap itself. + */ +export const MULTICHAIN_NETWORK_TICKER: Record = { + [BtcScope.Mainnet]: 'BTC', + [BtcScope.Testnet]: 'tBTC', + [BtcScope.Testnet4]: 'tBTC', + [BtcScope.Signet]: 'sBTC', + [BtcScope.Regtest]: 'rBTC', + [SolScope.Mainnet]: 'SOL', + [SolScope.Testnet]: 'tSOL', + [SolScope.Devnet]: 'dSOL', + [TrxScope.Mainnet]: 'TRX', + [TrxScope.Nile]: 'tTRX', + [TrxScope.Shasta]: 'sTRX', + [XlmScope.Pubnet]: 'XLM', + [XlmScope.Testnet]: 'tXLM', +} as const; + +/** + * Multichain network asset decimals for the supported networks. + * TODO: This should be part of the assets-controllers or the snap itself. + */ +export const MULTICHAIN_NETWORK_DECIMAL_PLACES: Record = { + [BtcScope.Mainnet]: 8, + [BtcScope.Testnet]: 8, + [BtcScope.Testnet4]: 8, + [BtcScope.Signet]: 8, + [BtcScope.Regtest]: 8, + [SolScope.Mainnet]: 5, + [SolScope.Testnet]: 5, + [SolScope.Devnet]: 5, + [TrxScope.Mainnet]: 6, + [TrxScope.Nile]: 6, + [TrxScope.Shasta]: 6, + [XlmScope.Pubnet]: 7, + [XlmScope.Testnet]: 7, +} as const; diff --git a/packages/multichain-network-controller/src/index.ts b/packages/multichain-network-controller/src/index.ts new file mode 100644 index 00000000000..82bc27a28a6 --- /dev/null +++ b/packages/multichain-network-controller/src/index.ts @@ -0,0 +1,35 @@ +export { MultichainNetworkController } from './MultichainNetworkController/MultichainNetworkController.js'; +export { MultichainNetworkService } from './MultichainNetworkService/MultichainNetworkService.js'; +export { + getDefaultMultichainNetworkControllerState, + NON_EVM_TESTNET_IDS, + MULTICHAIN_NETWORK_TICKER, + MULTICHAIN_NETWORK_DECIMAL_PLACES, + AVAILABLE_MULTICHAIN_NETWORK_CONFIGURATIONS, +} from './constants.js'; +export type { + MultichainNetworkMetadata, + SupportedCaipChainId, + CommonNetworkConfiguration, + NonEvmNetworkConfiguration, + EvmNetworkConfiguration, + MultichainNetworkConfiguration, + MultichainNetworkControllerState, + MultichainNetworkControllerGetStateAction, + MultichainNetworkControllerStateChange, + MultichainNetworkControllerNetworkDidChangeEvent, + MultichainNetworkControllerActions, + MultichainNetworkControllerEvents, + MultichainNetworkControllerMessenger, +} from './types.js'; +export type { + MultichainNetworkControllerSetActiveNetworkAction, + MultichainNetworkControllerGetNetworksWithTransactionActivityByAccountsAction, +} from './MultichainNetworkController/MultichainNetworkController-method-action-types.js'; +export { + checkIfSupportedCaipChainId, + toMultichainNetworkConfiguration, + toMultichainNetworkConfigurationsByChainId, + toEvmCaipChainId, +} from './utils.js'; +export type { ActiveNetworksByAddress } from './api/accounts-api.js'; diff --git a/packages/multichain-network-controller/src/types.ts b/packages/multichain-network-controller/src/types.ts new file mode 100644 index 00000000000..827eab28348 --- /dev/null +++ b/packages/multichain-network-controller/src/types.ts @@ -0,0 +1,194 @@ +import type { AccountsControllerListMultichainAccountsAction } from '@metamask/accounts-controller'; +import type { + ControllerGetStateAction, + ControllerStateChangeEvent, +} from '@metamask/base-controller'; +import type { + BtcScope, + CaipAssetType, + CaipChainId, + SolScope, + TrxScope, + XlmScope, +} from '@metamask/keyring-api'; +import type { InternalAccount } from '@metamask/keyring-internal-api'; +import type { Messenger } from '@metamask/messenger'; +import type { + NetworkStatus, + NetworkControllerSetActiveNetworkAction, + NetworkControllerGetStateAction, + NetworkControllerRemoveNetworkAction, + NetworkControllerGetSelectedChainIdAction, + NetworkControllerFindNetworkClientIdByChainIdAction, + NetworkClientId, +} from '@metamask/network-controller'; + +import type { ActiveNetworksByAddress } from './api/accounts-api.js'; +import type { MultichainNetworkControllerMethodActions } from './MultichainNetworkController/MultichainNetworkController-method-action-types.js'; + +export const MULTICHAIN_NETWORK_CONTROLLER_NAME = 'MultichainNetworkController'; + +export type MultichainNetworkMetadata = { + features: string[]; + status: NetworkStatus; +}; + +export type SupportedCaipChainId = + | BtcScope.Mainnet + | BtcScope.Testnet + | BtcScope.Testnet4 + | BtcScope.Signet + | BtcScope.Regtest + | SolScope.Mainnet + | SolScope.Testnet + | SolScope.Devnet + | TrxScope.Mainnet + | TrxScope.Nile + | TrxScope.Shasta + | XlmScope.Pubnet + | XlmScope.Testnet; + +export type CommonNetworkConfiguration = { + /** + * EVM network flag. + */ + isEvm: boolean; + /** + * The chain ID of the network. + */ + chainId: CaipChainId; + /** + * The name of the network. + */ + name: string; +}; + +export type NonEvmNetworkConfiguration = CommonNetworkConfiguration & { + /** + * EVM network flag. + */ + isEvm: false; + /** + * The native asset type of the network. + */ + nativeCurrency: CaipAssetType; +}; + +// TODO: The controller only supports non-EVM network configurations at the moment +// Once we support Caip chain IDs for EVM networks, we can re-enable EVM network configurations +export type EvmNetworkConfiguration = CommonNetworkConfiguration & { + /** + * EVM network flag. + */ + isEvm: true; + /** + * The native asset type of the network. + * For EVM, this is the network ticker since there is no standard between + * tickers and Caip IDs. + */ + nativeCurrency: string; + /** + * The block explorers of the network. + */ + blockExplorerUrls: string[]; + /** + * The index of the default block explorer URL. + */ + defaultBlockExplorerUrlIndex: number; +}; + +export type MultichainNetworkConfiguration = + | EvmNetworkConfiguration + | NonEvmNetworkConfiguration; + +/** + * State used by the {@link MultichainNetworkController} to cache network configurations. + */ +export type MultichainNetworkControllerState = { + /** + * The network configurations by chain ID. + */ + multichainNetworkConfigurationsByChainId: Record< + CaipChainId, + MultichainNetworkConfiguration + >; + /** + * The chain ID of the selected network. + */ + selectedMultichainNetworkChainId: SupportedCaipChainId; + /** + * Whether EVM or non-EVM network is selected + */ + isEvmSelected: boolean; + /** + * The active networks for the available EVM addresses (non-EVM networks will be supported in the future). + */ + networksWithTransactionActivity: ActiveNetworksByAddress; +}; + +/** + * Returns the state of the {@link MultichainNetworkController}. + */ +export type MultichainNetworkControllerGetStateAction = + ControllerGetStateAction< + typeof MULTICHAIN_NETWORK_CONTROLLER_NAME, + MultichainNetworkControllerState + >; + +/** + * Event emitted when the state of the {@link MultichainNetworkController} changes. + */ +export type MultichainNetworkControllerStateChange = ControllerStateChangeEvent< + typeof MULTICHAIN_NETWORK_CONTROLLER_NAME, + MultichainNetworkControllerState +>; + +export type MultichainNetworkControllerNetworkDidChangeEvent = { + type: `${typeof MULTICHAIN_NETWORK_CONTROLLER_NAME}:networkDidChange`; + payload: [NetworkClientId | SupportedCaipChainId]; +}; + +/** + * Actions exposed by the {@link MultichainNetworkController}. + */ +export type MultichainNetworkControllerActions = + | MultichainNetworkControllerGetStateAction + | MultichainNetworkControllerMethodActions; + +/** + * Events emitted by {@link MultichainNetworkController}. + */ +export type MultichainNetworkControllerEvents = + | MultichainNetworkControllerStateChange + | MultichainNetworkControllerNetworkDidChangeEvent; + +/** + * Actions that this controller is allowed to call. + */ +type AllowedActions = + | NetworkControllerGetStateAction + | NetworkControllerSetActiveNetworkAction + | AccountsControllerListMultichainAccountsAction + | NetworkControllerRemoveNetworkAction + | NetworkControllerGetSelectedChainIdAction + | NetworkControllerFindNetworkClientIdByChainIdAction; + +// Re-define event here to avoid circular dependency with AccountsController +export type AccountsControllerSelectedAccountChangeEvent = { + type: `AccountsController:selectedAccountChange`; + payload: [InternalAccount]; +}; + +/** + * Events that this controller is allowed to subscribe. + */ +type AllowedEvents = AccountsControllerSelectedAccountChangeEvent; + +/** + * Messenger type for the MultichainNetworkController. + */ +export type MultichainNetworkControllerMessenger = Messenger< + typeof MULTICHAIN_NETWORK_CONTROLLER_NAME, + MultichainNetworkControllerActions | AllowedActions, + MultichainNetworkControllerEvents | AllowedEvents +>; diff --git a/packages/multichain-network-controller/src/utils.test.ts b/packages/multichain-network-controller/src/utils.test.ts new file mode 100644 index 00000000000..01b001dfc46 --- /dev/null +++ b/packages/multichain-network-controller/src/utils.test.ts @@ -0,0 +1,225 @@ +import { BtcScope, SolScope, EthScope, XlmScope } from '@metamask/keyring-api'; +import type { CaipChainId } from '@metamask/keyring-api'; +import type { NetworkConfiguration } from '@metamask/network-controller'; +import { KnownCaipNamespace } from '@metamask/utils'; + +import { + isEvmCaipChainId, + toEvmCaipChainId, + convertEvmCaipToHexChainId, + getChainIdForNonEvm, + checkIfSupportedCaipChainId, + toMultichainNetworkConfiguration, + toMultichainNetworkConfigurationsByChainId, + isKnownCaipNamespace, +} from './utils.js'; + +describe('utils', () => { + describe('getChainIdForNonEvm', () => { + it('returns Solana chain ID for Solana scopes', () => { + const scopes = [SolScope.Mainnet, SolScope.Testnet, SolScope.Devnet]; + expect(getChainIdForNonEvm(scopes)).toBe(SolScope.Mainnet); + }); + + it('returns Bitcoin chain ID for Bitcoin scopes', () => { + let scopes = [BtcScope.Mainnet]; + expect(getChainIdForNonEvm(scopes)).toBe(BtcScope.Mainnet); + + scopes = [BtcScope.Testnet]; + expect(getChainIdForNonEvm(scopes)).toBe(BtcScope.Testnet); + }); + + it('returns Stellar chain ID for Stellar scopes', () => { + const scopes = [XlmScope.Pubnet, XlmScope.Testnet]; + expect(getChainIdForNonEvm(scopes)).toBe(XlmScope.Pubnet); + }); + + it('throws error if network is not found', () => { + const scopes = ['unknown:scope' as CaipChainId]; + expect(() => getChainIdForNonEvm(scopes)).toThrow( + 'Unsupported scope: unknown:scope.', + ); + }); + }); + + describe('checkIfSupportedCaipChainId', () => { + it('returns true for supported CAIP chain IDs', () => { + expect(checkIfSupportedCaipChainId(SolScope.Mainnet)).toBe(true); + expect(checkIfSupportedCaipChainId(BtcScope.Mainnet)).toBe(true); + expect(checkIfSupportedCaipChainId(XlmScope.Pubnet)).toBe(true); + expect(checkIfSupportedCaipChainId(XlmScope.Testnet)).toBe(true); + }); + + it('returns false for non-CAIP IDs', () => { + expect(checkIfSupportedCaipChainId('mainnet' as CaipChainId)).toBe(false); + }); + + it('returns false for unsupported CAIP chain IDs', () => { + expect(checkIfSupportedCaipChainId('eip155:1')).toBe(false); + }); + }); + + describe('toMultichainNetworkConfiguration', () => { + it('updates the network configuration for a single EVM network', () => { + const network: NetworkConfiguration = { + chainId: '0x1', + name: 'Ethereum Mainnet', + nativeCurrency: 'ETH', + blockExplorerUrls: ['https://etherscan.io'], + defaultBlockExplorerUrlIndex: 0, + rpcEndpoints: [], + defaultRpcEndpointIndex: 0, + }; + expect(toMultichainNetworkConfiguration(network)).toStrictEqual({ + chainId: 'eip155:1', + isEvm: true, + name: 'Ethereum Mainnet', + nativeCurrency: 'ETH', + blockExplorerUrls: ['https://etherscan.io'], + defaultBlockExplorerUrlIndex: 0, + }); + }); + + it('updates the network configuration for a single non-EVM network with undefined name', () => { + const network: NetworkConfiguration = { + chainId: '0x1', + // @ts-expect-error - set as undefined for test case + name: undefined, + nativeCurrency: 'ETH', + blockExplorerUrls: ['https://etherscan.io'], + defaultBlockExplorerUrlIndex: 0, + rpcEndpoints: [ + { + url: 'https://mainnet.infura.io/', + failoverUrls: [], + networkClientId: 'random-id', + // @ts-expect-error - network-controller does not export RpcEndpointType + type: 'custom', + }, + ], + defaultRpcEndpointIndex: 0, + }; + expect(toMultichainNetworkConfiguration(network)).toStrictEqual({ + chainId: 'eip155:1', + isEvm: true, + name: 'https://mainnet.infura.io/', + nativeCurrency: 'ETH', + blockExplorerUrls: ['https://etherscan.io'], + defaultBlockExplorerUrlIndex: 0, + }); + }); + + it('uses default block explorer index when undefined', () => { + const network: NetworkConfiguration = { + chainId: '0x1', + name: 'Ethereum Mainnet', + nativeCurrency: 'ETH', + blockExplorerUrls: ['https://etherscan.io'], + defaultBlockExplorerUrlIndex: undefined, + rpcEndpoints: [], + defaultRpcEndpointIndex: 0, + }; + expect(toMultichainNetworkConfiguration(network)).toStrictEqual({ + chainId: 'eip155:1', + isEvm: true, + name: 'Ethereum Mainnet', + nativeCurrency: 'ETH', + blockExplorerUrls: ['https://etherscan.io'], + defaultBlockExplorerUrlIndex: 0, + }); + }); + }); + + describe('toMultichainNetworkConfigurationsByChainId', () => { + it('updates the network configurations for multiple EVM networks', () => { + const networks: Record = { + '0x1': { + chainId: '0x1', + name: 'Ethereum Mainnet', + nativeCurrency: 'ETH', + blockExplorerUrls: ['https://etherscan.io'], + defaultBlockExplorerUrlIndex: 0, + rpcEndpoints: [], + defaultRpcEndpointIndex: 0, + }, + '0xe708': { + chainId: '0xe708', + name: 'Linea', + nativeCurrency: 'ETH', + blockExplorerUrls: ['https://lineascan.build'], + defaultBlockExplorerUrlIndex: 0, + rpcEndpoints: [], + defaultRpcEndpointIndex: 0, + }, + }; + expect( + toMultichainNetworkConfigurationsByChainId(networks), + ).toStrictEqual({ + 'eip155:1': { + chainId: 'eip155:1', + isEvm: true, + name: 'Ethereum Mainnet', + nativeCurrency: 'ETH', + blockExplorerUrls: ['https://etherscan.io'], + defaultBlockExplorerUrlIndex: 0, + }, + 'eip155:59144': { + chainId: 'eip155:59144', + isEvm: true, + name: 'Linea', + nativeCurrency: 'ETH', + blockExplorerUrls: ['https://lineascan.build'], + defaultBlockExplorerUrlIndex: 0, + }, + }); + }); + }); + + describe('convertEvmCaipToHexChainId', () => { + it('converts a hex chain ID to a CAIP chain ID', () => { + expect(toEvmCaipChainId('0x1')).toBe('eip155:1'); + expect(toEvmCaipChainId('0xe708')).toBe('eip155:59144'); + expect(toEvmCaipChainId('0x539')).toBe('eip155:1337'); + }); + }); + + describe('convertCaipToHexChainId', () => { + it('converts a CAIP chain ID to a hex chain ID', () => { + expect(convertEvmCaipToHexChainId(EthScope.Mainnet)).toBe('0x1'); + expect(convertEvmCaipToHexChainId('eip155:56')).toBe('0x38'); + expect(convertEvmCaipToHexChainId('eip155:80094')).toBe('0x138de'); + expect(convertEvmCaipToHexChainId('eip155:8453')).toBe('0x2105'); + }); + + it('throws an error given a CAIP chain ID with an unsupported namespace', () => { + expect(() => convertEvmCaipToHexChainId(BtcScope.Mainnet)).toThrow( + 'Unsupported CAIP chain ID namespace: bip122. Only eip155 is supported.', + ); + expect(() => convertEvmCaipToHexChainId(SolScope.Mainnet)).toThrow( + 'Unsupported CAIP chain ID namespace: solana. Only eip155 is supported.', + ); + }); + }); + + describe('isEvmCaipChainId', () => { + it('returns true for EVM chain IDs', () => { + expect(isEvmCaipChainId(EthScope.Mainnet)).toBe(true); + expect(isEvmCaipChainId(SolScope.Mainnet)).toBe(false); + expect(isEvmCaipChainId(BtcScope.Mainnet)).toBe(false); + }); + }); + + describe('isKnownCaipNamespace', () => { + it('returns true for known CAIP namespaces', () => { + expect(isKnownCaipNamespace(KnownCaipNamespace.Eip155)).toBe(true); + expect(isKnownCaipNamespace(KnownCaipNamespace.Bip122)).toBe(true); + expect(isKnownCaipNamespace(KnownCaipNamespace.Solana)).toBe(true); + }); + + it('returns false for unknown namespaces', () => { + expect(isKnownCaipNamespace('unknown')).toBe(false); + expect(isKnownCaipNamespace('cosmos')).toBe(false); + expect(isKnownCaipNamespace('')).toBe(false); + }); + }); +}); diff --git a/packages/multichain-network-controller/src/utils.ts b/packages/multichain-network-controller/src/utils.ts new file mode 100644 index 00000000000..a222ef50062 --- /dev/null +++ b/packages/multichain-network-controller/src/utils.ts @@ -0,0 +1,145 @@ +import type { NetworkConfiguration } from '@metamask/network-controller'; +import { + KnownCaipNamespace, + toCaipChainId, + parseCaipChainId, + hexToNumber, + add0x, +} from '@metamask/utils'; +import type { Hex, CaipChainId } from '@metamask/utils'; + +import { AVAILABLE_MULTICHAIN_NETWORK_CONFIGURATIONS } from './constants.js'; +import type { + SupportedCaipChainId, + MultichainNetworkConfiguration, +} from './types.js'; + +/** + * Checks if the chain ID is EVM. + * + * @param chainId - The account type to check. + * @returns Whether the network is EVM. + */ +export function isEvmCaipChainId(chainId: CaipChainId): boolean { + const { namespace } = parseCaipChainId(chainId); + return namespace === (KnownCaipNamespace.Eip155 as string); +} + +/** + * Returns the chain id of the non-EVM network based on the account scopes. + * + * @param scopes - The scopes to check. + * @returns The caip chain id of the non-EVM network. + */ +export function getChainIdForNonEvm( + scopes: CaipChainId[], +): SupportedCaipChainId { + const supportedScope = scopes.find((scope) => + Object.keys(AVAILABLE_MULTICHAIN_NETWORK_CONFIGURATIONS).includes(scope), + ); + if (supportedScope) { + return supportedScope as SupportedCaipChainId; + } + + throw new Error(`Unsupported scope: ${scopes.join(', ')}.`); +} + +/** + * Checks if the Caip chain ID is supported. + * + * @param id - The Caip chain IDto check. + * @returns Whether the chain ID is supported. + */ +export function checkIfSupportedCaipChainId( + id: CaipChainId, +): id is SupportedCaipChainId { + // Check if the chain id is supported + return Object.keys(AVAILABLE_MULTICHAIN_NETWORK_CONFIGURATIONS).includes(id); +} + +/** + * Converts a hex chain ID to a Caip chain ID. + * + * @param chainId - The hex chain ID to convert. + * @returns The Caip chain ID. + */ +export const toEvmCaipChainId = (chainId: Hex): CaipChainId => + toCaipChainId(KnownCaipNamespace.Eip155, hexToNumber(chainId).toString()); + +/** + * Convert an eip155 CAIP chain ID to a hex chain ID. + * + * @param chainId - The CAIP chain ID to convert. + * @returns The hex chain ID. + */ +export function convertEvmCaipToHexChainId(chainId: CaipChainId): Hex { + const { namespace, reference } = parseCaipChainId(chainId); + if (namespace === (KnownCaipNamespace.Eip155 as string)) { + return add0x(parseInt(reference, 10).toString(16)); + } + + throw new Error( + `Unsupported CAIP chain ID namespace: ${namespace}. Only eip155 is supported.`, + ); +} + +/** + * Updates a network configuration to the format used by the MultichainNetworkController. + * This method is exclusive for EVM networks with hex identifiers from the NetworkController. + * + * @param network - The network configuration to update. + * @returns The updated network configuration. + */ +export const toMultichainNetworkConfiguration = ( + network: NetworkConfiguration, +): MultichainNetworkConfiguration => { + const { + chainId, + name, + rpcEndpoints, + defaultRpcEndpointIndex, + nativeCurrency, + blockExplorerUrls, + defaultBlockExplorerUrlIndex = 0, + } = network; + return { + chainId: toEvmCaipChainId(chainId), + isEvm: true, + name: name || rpcEndpoints[defaultRpcEndpointIndex].url, + nativeCurrency, + blockExplorerUrls, + defaultBlockExplorerUrlIndex, + }; +}; + +/** + * Updates a record of network configurations to the format used by the MultichainNetworkController. + * This method is exclusive for EVM networks with hex identifiers from the NetworkController. + * + * @param networkConfigurationsByChainId - The network configurations to update. + * @returns The updated network configurations. + */ +export const toMultichainNetworkConfigurationsByChainId = ( + networkConfigurationsByChainId: Record, +): Record => + Object.entries(networkConfigurationsByChainId).reduce( + (acc, [, network]) => ({ + ...acc, + [toEvmCaipChainId(network.chainId)]: + toMultichainNetworkConfiguration(network), + }), + {}, + ); + +// TODO: This currently isn't being used anymore but could benefit from being moved to @metamask/utils +/** + * Type guard to check if a namespace is a known CAIP namespace. + * + * @param namespace - The namespace to check + * @returns Whether the namespace is a known CAIP namespace + */ +export function isKnownCaipNamespace( + namespace: string, +): namespace is KnownCaipNamespace { + return Object.values(KnownCaipNamespace).includes(namespace); +} diff --git a/packages/multichain-network-controller/tests/utils.ts b/packages/multichain-network-controller/tests/utils.ts new file mode 100644 index 00000000000..d87ed46f1e9 --- /dev/null +++ b/packages/multichain-network-controller/tests/utils.ts @@ -0,0 +1,110 @@ +import { + EthScope, + BtcScope, + SolScope, + BtcAccountType, + EthAccountType, + SolAccountType, + BtcMethod, + EthMethod, + SolMethod, +} from '@metamask/keyring-api'; +import type { KeyringAccountType } from '@metamask/keyring-api'; +import { KeyringTypes } from '@metamask/keyring-controller'; +import type { InternalAccount } from '@metamask/keyring-internal-api'; + +/** + * Creates a mock internal account. This is a duplicated function from the accounts-controller package + * This exists here to prevent circular dependencies with the accounts-controller package + * + * @param args - Arguments to this function. + * @param args.id - The ID of the account. + * @param args.address - The address of the account. + * @param args.type - The type of the account. + * @param args.name - The name of the account. + * @param args.keyringType - The keyring type of the account. + * @param args.snap - The snap of the account. + * @param args.snap.id - The ID of the snap. + * @param args.snap.enabled - Whether the snap is enabled. + * @param args.snap.name - The name of the snap. + * @param args.importTime - The import time of the account. + * @param args.lastSelected - The last selected time of the account. + * @param args.scopes - The scopes of the account. + * @returns A mock internal account. + */ +export const createMockInternalAccount = ({ + id = 'dummy-id', + address = '0x2990079bcdee240329a520d2444386fc119da21a', + type = EthAccountType.Eoa, + name = 'Account 1', + keyringType = KeyringTypes.hd, + snap, + importTime = Date.now(), + lastSelected = Date.now(), + scopes, +}: { + id?: string; + address?: string; + type?: KeyringAccountType; + name?: string; + keyringType?: KeyringTypes; + snap?: { + id: string; + enabled: boolean; + name: string; + }; + importTime?: number; + lastSelected?: number; + scopes?: string[]; +} = {}): InternalAccount => { + let methods; + let newScopes = scopes; + + switch (type) { + case EthAccountType.Eoa: + methods = [ + EthMethod.PersonalSign, + EthMethod.Sign, + EthMethod.SignTransaction, + EthMethod.SignTypedDataV1, + EthMethod.SignTypedDataV3, + EthMethod.SignTypedDataV4, + ]; + newScopes = [EthScope.Eoa]; + break; + case EthAccountType.Erc4337: + methods = [ + EthMethod.PatchUserOperation, + EthMethod.PrepareUserOperation, + EthMethod.SignUserOperation, + ]; + newScopes = [EthScope.Mainnet]; + break; + case BtcAccountType.P2wpkh: + methods = Object.values(BtcMethod); + newScopes = [BtcScope.Mainnet]; + break; + case SolAccountType.DataAccount: + methods = [SolMethod.SendAndConfirmTransaction]; + newScopes = [SolScope.Mainnet, SolScope.Devnet]; + break; + default: + throw new Error(`Unknown account type: ${type as string}`); + } + + return { + id, + address, + options: {}, + methods, + type, + scopes: newScopes, + metadata: { + name, + keyring: { type: keyringType }, + importTime, + lastSelected, + snap, + }, + } as InternalAccount; +}; diff --git a/packages/multichain-network-controller/tsconfig.build.json b/packages/multichain-network-controller/tsconfig.build.json new file mode 100644 index 00000000000..84af6a8c13b --- /dev/null +++ b/packages/multichain-network-controller/tsconfig.build.json @@ -0,0 +1,29 @@ +{ + "extends": "../../tsconfig.packages.build.json", + "compilerOptions": { + "baseUrl": "./", + "outDir": "./dist", + "rootDir": "./src" + }, + "references": [ + { + "path": "../accounts-controller/tsconfig.build.json" + }, + { + "path": "../base-controller/tsconfig.build.json" + }, + { + "path": "../controller-utils/tsconfig.build.json" + }, + { + "path": "../network-controller/tsconfig.build.json" + }, + { + "path": "../keyring-controller/tsconfig.build.json" + }, + { + "path": "../messenger/tsconfig.build.json" + } + ], + "include": ["../../types", "./src"] +} diff --git a/packages/multichain-network-controller/tsconfig.json b/packages/multichain-network-controller/tsconfig.json new file mode 100644 index 00000000000..93cff5181e6 --- /dev/null +++ b/packages/multichain-network-controller/tsconfig.json @@ -0,0 +1,27 @@ +{ + "extends": "../../tsconfig.packages.json", + "compilerOptions": { + "baseUrl": "./" + }, + "references": [ + { + "path": "../accounts-controller" + }, + { + "path": "../base-controller" + }, + { + "path": "../controller-utils" + }, + { + "path": "../network-controller" + }, + { + "path": "../keyring-controller" + }, + { + "path": "../messenger" + } + ], + "include": ["../../types", "./src", "./tests"] +} diff --git a/packages/multichain-network-controller/typedoc.json b/packages/multichain-network-controller/typedoc.json new file mode 100644 index 00000000000..c9da015dbf8 --- /dev/null +++ b/packages/multichain-network-controller/typedoc.json @@ -0,0 +1,7 @@ +{ + "entryPoints": ["./src/index.ts"], + "excludePrivate": true, + "hideGenerator": true, + "out": "docs", + "tsconfig": "./tsconfig.build.json" +} diff --git a/packages/multichain-transactions-controller/CHANGELOG.md b/packages/multichain-transactions-controller/CHANGELOG.md new file mode 100644 index 00000000000..a16cff3a124 --- /dev/null +++ b/packages/multichain-transactions-controller/CHANGELOG.md @@ -0,0 +1,341 @@ +# 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] + +### Changed + +- Bump `@metamask/accounts-controller` from `^39.0.7` to `^39.1.1` ([#9807](https://github.com/MetaMask/core/pull/9807), [#9969](https://github.com/MetaMask/core/pull/9969)) + +## [7.1.2] + +### Changed + +- Bump `@metamask/utils` from `^11.9.0` to `^11.11.0` ([#9074](https://github.com/MetaMask/core/pull/9074)) +- Bump `@metamask/accounts-controller` from `^39.0.0` to `^39.0.7` ([#9058](https://github.com/MetaMask/core/pull/9058), [#9218](https://github.com/MetaMask/core/pull/9218), [#9231](https://github.com/MetaMask/core/pull/9231), [#9349](https://github.com/MetaMask/core/pull/9349), [#9470](https://github.com/MetaMask/core/pull/9470), [#9735](https://github.com/MetaMask/core/pull/9735), [#9791](https://github.com/MetaMask/core/pull/9791)) +- Bump `@metamask/polling-controller` from `^16.0.6` to `^16.0.9` ([#9218](https://github.com/MetaMask/core/pull/9218), [#9349](https://github.com/MetaMask/core/pull/9349), [#9735](https://github.com/MetaMask/core/pull/9735)) +- Bump `@metamask/keyring-api` from `^23.1.0` to `^24.0.0` ([#9249](https://github.com/MetaMask/core/pull/9249), [#9390](https://github.com/MetaMask/core/pull/9390), [#9676](https://github.com/MetaMask/core/pull/9676), [#9754](https://github.com/MetaMask/core/pull/9754)) +- Bump `@metamask/messenger` from `^1.2.0` to `^2.0.0` ([#9392](https://github.com/MetaMask/core/pull/9392)) +- Bump `@metamask/keyring-snap-client` from `^9.0.2` to `^10.0.0` ([#9390](https://github.com/MetaMask/core/pull/9390), [#9676](https://github.com/MetaMask/core/pull/9676), [#9754](https://github.com/MetaMask/core/pull/9754)) +- Bump `@metamask/keyring-internal-api` from `^11.0.1` to `^12.0.0` ([#9676](https://github.com/MetaMask/core/pull/9676), [#9754](https://github.com/MetaMask/core/pull/9754)) + +## [7.1.1] + +### Changed + +- Bump `@metamask/accounts-controller` from `^38.0.0` to `^39.0.0` ([#8755](https://github.com/MetaMask/core/pull/8755), [#8774](https://github.com/MetaMask/core/pull/8774), [#8912](https://github.com/MetaMask/core/pull/8912), [#8999](https://github.com/MetaMask/core/pull/8999)) +- Bump `@metamask/polling-controller` from `^16.0.4` to `^16.0.6` ([#8755](https://github.com/MetaMask/core/pull/8755), [#8834](https://github.com/MetaMask/core/pull/8834)) + +## [7.1.0] + +### Added + +- Expose `updateTransactionsForAccount` as a messenger action (`MultichainTransactionsController:updateTransactionsForAccount`) ([#8391](https://github.com/MetaMask/core/pull/8391)) + - The new `MultichainTransactionsControllerUpdateTransactionsForAccountAction` type is now exported. + - `MultichainTransactionsControllerActions` union now includes this action. +- Export `MultichainTransactionsControllerActions`, `MultichainTransactionsControllerEvents`, `MultichainTransactionsControllerMessenger` ([#8391](https://github.com/MetaMask/core/pull/8391)) + +### Changed + +- Bump `@metamask/keyring-controller` from `^25.3.0` to `^25.4.0` ([#8665](https://github.com/MetaMask/core/pull/8665)) +- Bump `@metamask/accounts-controller` from `^37.1.1` to `^38.0.0` ([#8363](https://github.com/MetaMask/core/pull/8363), [#8665](https://github.com/MetaMask/core/pull/8665)) +- Bump `@metamask/messenger` from `^1.0.0` to `^1.2.0` ([#8364](https://github.com/MetaMask/core/pull/8364), [#8373](https://github.com/MetaMask/core/pull/8373), [#8632](https://github.com/MetaMask/core/pull/8632)) +- Bump `@metamask/base-controller` from `^9.0.1` to `^9.1.0` ([#8457](https://github.com/MetaMask/core/pull/8457)) +- Bump `@metamask/keyring-api` from `^21.6.0` to `^23.1.0` ([#8464](https://github.com/MetaMask/core/pull/8464), [#8647](https://github.com/MetaMask/core/pull/8647)) +- Bump `@metamask/keyring-internal-api` from `^10.0.0` to `^11.0.1` ([#8464](https://github.com/MetaMask/core/pull/8464), [#8584](https://github.com/MetaMask/core/pull/8584), [#8647](https://github.com/MetaMask/core/pull/8647)) +- Bump `@metamask/keyring-snap-client` from `^8.2.0` to `^9.0.2` ([#8464](https://github.com/MetaMask/core/pull/8464), [#8647](https://github.com/MetaMask/core/pull/8647)) + +## [7.0.4] + +### Changed + +- Bump `@metamask/snaps-controllers` from `^17.2.0` to `^19.0.0` ([#8319](https://github.com/MetaMask/core/pull/8319)) +- Bump `@metamask/snaps-sdk` from `^10.3.0` to `^11.0.0` ([#8319](https://github.com/MetaMask/core/pull/8319)) +- Bump `@metamask/snaps-utils` from `^11.7.0` to `^12.1.2` ([#8319](https://github.com/MetaMask/core/pull/8319)) +- Bump `@metamask/accounts-controller` from `^37.1.0` to `^37.1.1` ([#8325](https://github.com/MetaMask/core/pull/8325)) + +## [7.0.3] + +### Changed + +- Bump `@metamask/accounts-controller` from `^37.0.0` to `^37.1.0` ([#8317](https://github.com/MetaMask/core/pull/8317)) +- Bump `@metamask/base-controller` from `^9.0.0` to `^9.0.1` ([#8317](https://github.com/MetaMask/core/pull/8317)) +- Bump `@metamask/messenger` from `^0.3.0` to `^1.0.0` ([#8317](https://github.com/MetaMask/core/pull/8317)) +- Bump `@metamask/polling-controller` from `^16.0.3` to `^16.0.4` ([#8317](https://github.com/MetaMask/core/pull/8317)) +- Bump `@metamask/keyring-api` from `^21.5.0` to `^21.6.0` ([#8259](https://github.com/MetaMask/core/pull/8259)) + +## [7.0.2] + +### Changed + +- Bump `@metamask/accounts-controller` from `^36.0.0` to `^37.0.0` ([#7996](https://github.com/MetaMask/core/pull/7996), [#8140](https://github.com/MetaMask/core/pull/8140)) +- Bump `@metamask/polling-controller` from `^16.0.2` to `^16.0.3` ([#7996](https://github.com/MetaMask/core/pull/7996)) + +## [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/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), [#7642](https://github.com/MetaMask/core/pull/7642), [#7897](https://github.com/MetaMask/core/pull/7897)) + - The dependencies moved are: + - `@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. + - Note, however, that the versions specified in the client's `package.json` always "win", and you are expected to keep them up to date so as not to break controller and service intercommunication. +- Bump `@metamask/polling-controller` from `^16.0.0` to `^16.0.2` ([#7604](https://github.com/MetaMask/core/pull/7604), [#7642](https://github.com/MetaMask/core/pull/7642)) + +## [7.0.0] + +### Changed + +- Bump `@metamask/polling-controller` from `^15.0.0` to `^16.0.0` ([#7202](https://github.com/MetaMask/core/pull/7202)) +- **BREAKING:** Bump `@metamask/accounts-controller` from `^34.0.0` to `^35.0.0` ([#7202](https://github.com/MetaMask/core/pull/7202)) + +## [6.0.0] + +### Changed + +- **BREAKING:** Use new `Messenger` from `@metamask/messenger` ([#6542](https://github.com/MetaMask/core/pull/6542)) + - Previously, `MultichainTransactionsController` accepted a `RestrictedMessenger` instance from `@metamask/base-controller`. +- **BREAKING:** Metadata property `anonymous` renamed to `includeInDebugSnapshot` ([#6542](https://github.com/MetaMask/core/pull/6542)) +- **BREAKING:** Bump `@metamask/accounts-controller` from `^33.0.0` to `^34.0.0` ([#6962](https://github.com/MetaMask/core/pull/6962)) +- Bump `@metamask/base-controller` from `^8.4.2` to `^9.0.0` ([#6962](https://github.com/MetaMask/core/pull/6962)) + +## [5.1.1] + +### Changed + +- Bump `@metamask/base-controller` from `^8.4.1` to `^8.4.2` ([#6917](https://github.com/MetaMask/core/pull/6917)) +- Bump `@metamask/polling-controller` from `^14.0.1` to `^14.0.2` ([#6940](https://github.com/MetaMask/core/pull/6940)) + +## [5.1.0] + +### Added + +- Add two new controller state metadata properties: `includeInStateLogs` and `usedInUi` ([#6470](https://github.com/MetaMask/core/pull/6470)) + +### Changed + +- Bump `@metamask/base-controller` from `^8.1.0` to `^8.4.1` ([#6355](https://github.com/MetaMask/core/pull/6355), [#6465](https://github.com/MetaMask/core/pull/6465), [#6632](https://github.com/MetaMask/core/pull/6632), [#6807](https://github.com/MetaMask/core/pull/6807)) +- Bump `@metamask/keyring-api` from `^20.1.0` to `^21.0.0` ([#6560](https://github.com/MetaMask/core/pull/6560)) +- Bump `@metamask/keyring-internal-api` from `^8.1.0` to `^9.0.0` ([#6560](https://github.com/MetaMask/core/pull/6560)) +- Bump `@metamask/keyring-snap-client` from `^7.0.0` to `^8.0.0` ([#6560](https://github.com/MetaMask/core/pull/6560)) +- Bump `@metamask/utils` from `^11.4.2` to `^11.8.1` ([#6588](https://github.com/MetaMask/core/pull/6588), [#6708](https://github.com/MetaMask/core/pull/6708)) +- Bump `@metamask/polling-controller` from `^14.0.0` to `^14.0.1` ([#6807](https://github.com/MetaMask/core/pull/6807)) + +## [5.0.0] + +### Changed + +- **BREAKING:** Bump peer dependency `@metamask/accounts-controller` from `^32.0.0` to `^33.0.0` ([#6345](https://github.com/MetaMask/core/pull/6345)) +- Bump `@metamask/base-controller` from `^8.0.1` to `^8.1.0` ([#6284](https://github.com/MetaMask/core/pull/6284)) +- Bump accounts related packages ([#6309](https://github.com/MetaMask/core/pull/6309)) + - Bump `@metamask/keyring-api` from `^20.0.0` to `^20.1.0` + - Bump `@metamask/keyring-internal-api` from `^8.0.0` to `^8.1.0` + +## [4.0.1] + +### Changed + +- Bump `@metamask/keyring-api` from `^19.0.0` to `^20.0.0` ([#6248](https://github.com/MetaMask/core/pull/6248)) +- Bump `@metamask/keyring-internal-api` from `^7.0.0` to `^8.0.0` ([#6248](https://github.com/MetaMask/core/pull/6248)) +- Bump `@metamask/keyring-snap-client` from `^6.0.0` to `^7.0.0` ([#6248](https://github.com/MetaMask/core/pull/6248)) + +## [4.0.0] + +### Changed + +- **BREAKING:** Bump peer dependency `@metamask/accounts-controller` from `^31.0.0` to `^32.0.0` ([#6171](https://github.com/MetaMask/core/pull/6171)) +- **BREAKING:** Bump peer dependency `@metamask/snaps-controllers` from `^12.0.0` to `^14.0.0` ([#6035](https://github.com/MetaMask/core/pull/6035)) +- Bump `@metamask/snaps-sdk` from `^7.1.0` to `^9.0.0` ([#6035](https://github.com/MetaMask/core/pull/6035)) +- Bump `@metamask/snaps-utils` from `^9.4.0` to `^11.0.0` ([#6035](https://github.com/MetaMask/core/pull/6035)) +- Bump `@metamask/keyring-api` from `^18.0.0` to `^19.0.0` ([#6146](https://github.com/MetaMask/core/pull/6146)) +- Bump `@metamask/keyring-internal-api` from `^6.2.0` to `^7.0.0` ([#6146](https://github.com/MetaMask/core/pull/6146)) +- Bump `@metamask/keyring-snap-client` from `^5.0.0` to `^6.0.0` ([#6146](https://github.com/MetaMask/core/pull/6146)) +- Bump `@metamask/utils` from `^11.2.0` to `^11.4.2` ([#6054](https://github.com/MetaMask/core/pull/6054)) + +## [3.0.0] + +### Changed + +- **BREAKING:** Bump peer dependency `@metamask/accounts-controller` to `^31.0.0` ([#5999](https://github.com/MetaMask/core/pull/5999)) +- Bump `@metamask/polling-controller` to `^14.0.0` ([#5999](https://github.com/MetaMask/core/pull/5999)) + +## [2.0.0] + +### Changed + +- **BREAKING:** Bump `@metamask/accounts-controller` peer dependency to `^30.0.0` ([#5888](https://github.com/MetaMask/core/pull/5888)) +- **BREAKING:** Bump `@metamask/snaps-controllers` peer dependency from `^11.0.0` to `^12.0.0` ([#5871](https://github.com/MetaMask/core/pull/5871)) +- Bump `@metamask/keyring-api` peer dependency from `^17.4.0` to `^18.0.0` ([#5871](https://github.com/MetaMask/core/pull/5871)) +- Bump `@metamask/keyring-internal-api` dependency from `^6.0.1` to `^6.2.0` ([#5871](https://github.com/MetaMask/core/pull/5871)) +- Bump `@metamask/keyring-snap-client` dependency from `^4.1.0` to `^5.0.0` ([#5871](https://github.com/MetaMask/core/pull/5871)) +- Bump `@metamask/snaps-sdk` dependency from `^6.22.0` to `^7.0.0` ([#5871](https://github.com/MetaMask/core/pull/5871)) +- Bump `@metamask/snaps-utils` dependency from `^9.2.0` to `^9.4.0` ([#5871](https://github.com/MetaMask/core/pull/5871)) + +## [1.0.0] + +### Changed + +- **BREAKING:** Store transactions by chain IDs ([#5756](https://github.com/MetaMask/core/pull/5756)) +- Remove Solana mainnet filtering to support other Solana networks (devnet, testnet) ([#5756](https://github.com/MetaMask/core/pull/5756)) + +## [0.11.0] + +### Changed + +- **BREAKING:** bump `@metamask/accounts-controller` peer dependency to `^29.0.0` ([#5802](https://github.com/MetaMask/core/pull/5802)) + +## [0.10.0] + +### Changed + +- **BREAKING:** Bump `@metamask/accounts-controllers` peer dependency to `^28.0.0` ([#5763](https://github.com/MetaMask/core/pull/5763)) +- **BREAKING:** Bump `@metamask/snaps-controllers` peer dependency to `^11.0.0` ([#5639](https://github.com/MetaMask/core/pull/5639)) +- Bump `@metamask/base-controller` from `^8.0.0` to `^8.0.1` ([#5722](https://github.com/MetaMask/core/pull/5722)) +- Bump `@metamask/snaps-sdk` from `^6.17.1` to `^6.22.0` ([#5639](https://github.com/MetaMask/core/pull/5639)) +- Bump `@metamask/snaps-utils` from `^8.10.0` to `^9.2.0` ([#5639](https://github.com/MetaMask/core/pull/5639)) + +## [0.9.0] + +### Added + +- Send new `MultichainTransactionsController:transaction{Confirmed,Submitted}` events during transaction updates ([#5587](https://github.com/MetaMask/core/pull/5587)) + +## [0.8.0] + +### Changed + +- **BREAKING:** Bump peer dependency `@metamask/accounts-controller` to `^27.0.0` ([#5507](https://github.com/MetaMask/core/pull/5507)) +- Bump `@metamask/polling-controller` to `^13.0.0` ([#5507](https://github.com/MetaMask/core/pull/5507)) + +## [0.7.2] + +### Fixed + +- Filters out non-mainnet Solana transactions from the transactions update events ([#5497](https://github.com/MetaMask/core/pull/5497)) +- `@metamask/snaps-controllers` peer dependency is no longer also a direct dependency ([#5464](https://github.com/MetaMask/core/pull/5464)) + +## [0.7.1] + +### Fixed + +- Check if `KeyringController` is unlocked before processing account events in `MultichainTransactionsController` ([#5473](https://github.com/MetaMask/core/pull/5473)) + - This is needed since some Snaps might decrypt their state which needs the `KeyringController` to be unlocked. + +## [0.7.0] + +### Changed + +- **BREAKING:** Bump `@metamask/accounts-controller` peer dependency to `^26.0.0` ([#5439](https://github.com/MetaMask/core/pull/5439)) +- **BREAKING:** Bump `@metamask/keyring-internal-api` from `^5.0.0` to `^6.0.0` ([#5347](https://github.com/MetaMask/core/pull/5347)) + +## [0.6.0] + +### Changed + +- **BREAKING:** Bump `@metamask/accounts-controller` peer dependency to `^25.0.0` ([#5426](https://github.com/MetaMask/core/pull/5426)) +- Bump `@metamask/keyring-internal-api` from `^4.0.3` to `^5.0.0` ([#5405](https://github.com/MetaMask/core/pull/5405)) + +## [0.5.0] + +### Changed + +- Sort transactions (newest first) ([#5339](https://github.com/MetaMask/core/pull/5339)) +- Bump `@metamask/keyring-controller"` from `^19.1.0` to `^19.2.0` ([#5357](https://github.com/MetaMask/core/pull/5357)) +- Bump `@metamask/keyring-api"` from `^17.0.0` to `^17.2.0` ([#5366](https://github.com/MetaMask/core/pull/5366)) +- Bump `@metamask/keyring-internal-api` from `^4.0.1` to `^4.0.3` ([#5356](https://github.com/MetaMask/core/pull/5356), [#5366](https://github.com/MetaMask/core/pull/5366)) +- Bump `@metamask/keyring-snap-client` from `^3.0.3` to `^4.0.1` ([#5356](https://github.com/MetaMask/core/pull/5356), [#5366](https://github.com/MetaMask/core/pull/5366)) +- Bump `@metamask/utils` from `^11.1.0` to `^11.2.0` ([#5301](https://github.com/MetaMask/core/pull/5301)) + +### Fixed + +- De-duplicate transactions using their ID ([#5339](https://github.com/MetaMask/core/pull/5339)) + +## [0.4.0] + +### Changed + +- **BREAKING:** Bump `@metamask/accounts-controller` peer dependency from `^23.0.0` to `^24.0.0` ([#5318](https://github.com/MetaMask/core/pull/5318)) + +## [0.3.0] + +### Changed + +- Bump `@metamask/base-controller` from `^7.1.1` to `^8.0.0` ([#5305](https://github.com/MetaMask/core/pull/5305)) +- Bump `@metamask/polling-controller` from `^12.0.2` to `^12.0.3` ([#5305](https://github.com/MetaMask/core/pull/5305)) + +### Removed + +- **BREAKING:** Remove `NETWORK_ASSETS_MAP`, `MultichainNetwork` and `MultichainNativeAsset` from exports, making them no longer available for consumers ([#5295](https://github.com/MetaMask/core/pull/5295)) + +## [0.2.0] + +### Changed + +- **BREAKING:** Bump `@metamask/accounts-controller` peer dependency from `^22.0.0` to `^23.0.0` ([#5292](https://github.com/MetaMask/core/pull/5292)) +- **BREAKING:** Bump `@metamask/snaps-controllers` peer dependency from `^9.10.0` to `^9.19.0` ([#5265](https://github.com/MetaMask/core/pull/5265)) +- Bump `@metamask/snaps-sdk` from `^6.7.0` to `^6.17.1` ([#5220](https://github.com/MetaMask/core/pull/5220), [#5265](https://github.com/MetaMask/core/pull/5265)) +- Bump `@metamask/snaps-utils` from `^8.9.0` to `^8.10.0` ([#5265](https://github.com/MetaMask/core/pull/5265)) +- Bump `@metamask/snaps-controllers` from `^9.10.0` to `^9.19.0` ([#5265](https://github.com/MetaMask/core/pull/5265)) +- Bump `@metamask/keyring-api"` from `^16.1.0` to `^17.0.0` ([#5280](https://github.com/MetaMask/core/pull/5280)) +- Bump `@metamask/utils` from `^11.0.1` to `^11.1.0` ([#5223](https://github.com/MetaMask/core/pull/5223)) +- Removed polling mechanism and now relies on the new `AccountsController:accountTransactionsUpdated` event ([#5221](https://github.com/MetaMask/core/pull/5221)) + +## [0.1.0] + +### Changed + +- **BREAKING:** Bump `@metamask/accounts-controller` peer dependency from `^21.0.0` to `^22.0.0` ([#5218](https://github.com/MetaMask/core/pull/5218)) +- Bump `@metamask/keyring-api` from `^14.0.0` to `^16.1.0` ([#5190](https://github.com/MetaMask/core/pull/5190), [#5208](https://github.com/MetaMask/core/pull/5208)) +- Bump `@metamask/keyring-internal-api` from `^2.0.1` to `^4.0.1` ([#5190](https://github.com/MetaMask/core/pull/5190), [#5208](https://github.com/MetaMask/core/pull/5208)) +- Bump `@metamask/keyring-snap-client` from `^3.0.0` to `^3.0.3` ([#5190](https://github.com/MetaMask/core/pull/5190), [#5208](https://github.com/MetaMask/core/pull/5208)) + +## [0.0.1] + +### Added + +- Initial release ([#5133](https://github.com/MetaMask/core/pull/5133), [#5177](https://github.com/MetaMask/core/pull/5177)) + +[Unreleased]: https://github.com/MetaMask/core/compare/@metamask/multichain-transactions-controller@7.1.2...HEAD +[7.1.2]: https://github.com/MetaMask/core/compare/@metamask/multichain-transactions-controller@7.1.1...@metamask/multichain-transactions-controller@7.1.2 +[7.1.1]: https://github.com/MetaMask/core/compare/@metamask/multichain-transactions-controller@7.1.0...@metamask/multichain-transactions-controller@7.1.1 +[7.1.0]: https://github.com/MetaMask/core/compare/@metamask/multichain-transactions-controller@7.0.4...@metamask/multichain-transactions-controller@7.1.0 +[7.0.4]: https://github.com/MetaMask/core/compare/@metamask/multichain-transactions-controller@7.0.3...@metamask/multichain-transactions-controller@7.0.4 +[7.0.3]: https://github.com/MetaMask/core/compare/@metamask/multichain-transactions-controller@7.0.2...@metamask/multichain-transactions-controller@7.0.3 +[7.0.2]: https://github.com/MetaMask/core/compare/@metamask/multichain-transactions-controller@7.0.1...@metamask/multichain-transactions-controller@7.0.2 +[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 +[5.1.0]: https://github.com/MetaMask/core/compare/@metamask/multichain-transactions-controller@5.0.0...@metamask/multichain-transactions-controller@5.1.0 +[5.0.0]: https://github.com/MetaMask/core/compare/@metamask/multichain-transactions-controller@4.0.1...@metamask/multichain-transactions-controller@5.0.0 +[4.0.1]: https://github.com/MetaMask/core/compare/@metamask/multichain-transactions-controller@4.0.0...@metamask/multichain-transactions-controller@4.0.1 +[4.0.0]: https://github.com/MetaMask/core/compare/@metamask/multichain-transactions-controller@3.0.0...@metamask/multichain-transactions-controller@4.0.0 +[3.0.0]: https://github.com/MetaMask/core/compare/@metamask/multichain-transactions-controller@2.0.0...@metamask/multichain-transactions-controller@3.0.0 +[2.0.0]: https://github.com/MetaMask/core/compare/@metamask/multichain-transactions-controller@1.0.0...@metamask/multichain-transactions-controller@2.0.0 +[1.0.0]: https://github.com/MetaMask/core/compare/@metamask/multichain-transactions-controller@0.11.0...@metamask/multichain-transactions-controller@1.0.0 +[0.11.0]: https://github.com/MetaMask/core/compare/@metamask/multichain-transactions-controller@0.10.0...@metamask/multichain-transactions-controller@0.11.0 +[0.10.0]: https://github.com/MetaMask/core/compare/@metamask/multichain-transactions-controller@0.9.0...@metamask/multichain-transactions-controller@0.10.0 +[0.9.0]: https://github.com/MetaMask/core/compare/@metamask/multichain-transactions-controller@0.8.0...@metamask/multichain-transactions-controller@0.9.0 +[0.8.0]: https://github.com/MetaMask/core/compare/@metamask/multichain-transactions-controller@0.7.2...@metamask/multichain-transactions-controller@0.8.0 +[0.7.2]: https://github.com/MetaMask/core/compare/@metamask/multichain-transactions-controller@0.7.1...@metamask/multichain-transactions-controller@0.7.2 +[0.7.1]: https://github.com/MetaMask/core/compare/@metamask/multichain-transactions-controller@0.7.0...@metamask/multichain-transactions-controller@0.7.1 +[0.7.0]: https://github.com/MetaMask/core/compare/@metamask/multichain-transactions-controller@0.6.0...@metamask/multichain-transactions-controller@0.7.0 +[0.6.0]: https://github.com/MetaMask/core/compare/@metamask/multichain-transactions-controller@0.5.0...@metamask/multichain-transactions-controller@0.6.0 +[0.5.0]: https://github.com/MetaMask/core/compare/@metamask/multichain-transactions-controller@0.4.0...@metamask/multichain-transactions-controller@0.5.0 +[0.4.0]: https://github.com/MetaMask/core/compare/@metamask/multichain-transactions-controller@0.3.0...@metamask/multichain-transactions-controller@0.4.0 +[0.3.0]: https://github.com/MetaMask/core/compare/@metamask/multichain-transactions-controller@0.2.0...@metamask/multichain-transactions-controller@0.3.0 +[0.2.0]: https://github.com/MetaMask/core/compare/@metamask/multichain-transactions-controller@0.1.0...@metamask/multichain-transactions-controller@0.2.0 +[0.1.0]: https://github.com/MetaMask/core/compare/@metamask/multichain-transactions-controller@0.0.1...@metamask/multichain-transactions-controller@0.1.0 +[0.0.1]: https://github.com/MetaMask/core/releases/tag/@metamask/multichain-transactions-controller@0.0.1 diff --git a/packages/multichain-transactions-controller/LICENSE b/packages/multichain-transactions-controller/LICENSE new file mode 100644 index 00000000000..c259cd7ebcf --- /dev/null +++ b/packages/multichain-transactions-controller/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2025 MetaMask + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packages/multichain-transactions-controller/README.md b/packages/multichain-transactions-controller/README.md new file mode 100644 index 00000000000..5ae3333ab00 --- /dev/null +++ b/packages/multichain-transactions-controller/README.md @@ -0,0 +1,15 @@ +# `@metamask/multichain-transactions-controller` + +This package is responsible for getting transactions from our Bitcoin and Solana snaps. + +## Installation + +`yarn add @metamask/multichain-transactions-controller` + +or + +`npm install @metamask/multichain-transactions-controller` + +## Contributing + +This package is part of a monorepo. Instructions for contributing can be found in the [monorepo README](https://github.com/MetaMask/core#readme). diff --git a/packages/multichain-transactions-controller/jest.config.js b/packages/multichain-transactions-controller/jest.config.js new file mode 100644 index 00000000000..61cef5a0b68 --- /dev/null +++ b/packages/multichain-transactions-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: 91.17, + functions: 100, + lines: 100, + statements: 100, + }, + }, +}); diff --git a/packages/multichain-transactions-controller/package.json b/packages/multichain-transactions-controller/package.json new file mode 100644 index 00000000000..8b22b36c793 --- /dev/null +++ b/packages/multichain-transactions-controller/package.json @@ -0,0 +1,89 @@ +{ + "name": "@metamask/multichain-transactions-controller", + "version": "7.1.2", + "description": "This package is responsible for getting transactions from our Bitcoin and Solana snaps", + "keywords": [ + "Ethereum", + "MetaMask" + ], + "homepage": "https://github.com/MetaMask/core/tree/main/packages/multichain-transactions-controller#readme", + "bugs": { + "url": "https://github.com/MetaMask/core/issues" + }, + "license": "MIT", + "repository": { + "type": "git", + "url": "https://github.com/MetaMask/core.git" + }, + "files": [ + "dist/" + ], + "sideEffects": false, + "main": "./dist/index.cjs", + "types": "./dist/index.d.cts", + "exports": { + ".": { + "import": { + "types": "./dist/index.d.mts", + "default": "./dist/index.mjs" + }, + "require": { + "types": "./dist/index.d.cts", + "default": "./dist/index.cjs" + } + }, + "./package.json": "./package.json" + }, + "publishConfig": { + "access": "public", + "registry": "https://registry.npmjs.org/" + }, + "scripts": { + "build": "ts-bridge --project tsconfig.build.json --verbose --clean --no-references", + "build:all": "ts-bridge --project tsconfig.build.json --verbose --clean", + "build:docs": "typedoc", + "changelog:update": "../../scripts/update-changelog.sh @metamask/multichain-transactions-controller", + "changelog:validate": "../../scripts/validate-changelog.sh @metamask/multichain-transactions-controller", + "lint:tsconfigs": "tsx ../../scripts/lint-tsconfigs/lint-tsconfigs.mts", + "lint:tsconfigs:fix": "tsx ../../scripts/lint-tsconfigs/lint-tsconfigs.mts --fix", + "messenger-action-types:check": "tsx ../../packages/messenger-cli/src/cli.ts --formatter oxfmt --esm --check", + "messenger-action-types:generate": "tsx ../../packages/messenger-cli/src/cli.ts --formatter oxfmt --esm --generate", + "since-latest-release": "../../scripts/since-latest-release.sh", + "test": "NODE_OPTIONS=--experimental-vm-modules jest --reporters=jest-silent-reporter", + "test:clean": "NODE_OPTIONS=--experimental-vm-modules jest --clearCache", + "test:verbose": "NODE_OPTIONS=--experimental-vm-modules jest --verbose", + "test:watch": "NODE_OPTIONS=--experimental-vm-modules jest --watch" + }, + "dependencies": { + "@metamask/accounts-controller": "^39.1.1", + "@metamask/base-controller": "^9.1.0", + "@metamask/keyring-api": "^24.0.0", + "@metamask/keyring-internal-api": "^12.0.0", + "@metamask/keyring-snap-client": "^10.0.0", + "@metamask/messenger": "^2.0.0", + "@metamask/polling-controller": "^16.0.9", + "@metamask/snaps-controllers": "^19.0.0", + "@metamask/snaps-sdk": "^11.0.0", + "@metamask/snaps-utils": "^12.1.2", + "@metamask/utils": "^11.11.0", + "@types/uuid": "^8.3.0", + "immer": "^9.0.6", + "uuid": "^8.3.2" + }, + "devDependencies": { + "@metamask/auto-changelog": "^6.1.0", + "@metamask/keyring-controller": "^27.1.1", + "@ts-bridge/cli": "^0.6.4", + "@types/jest": "^30.0.0", + "deepmerge": "^4.2.2", + "jest": "^30.4.2", + "ts-jest": "^29.4.11", + "tsx": "^4.20.5", + "typedoc": "^0.25.13", + "typedoc-plugin-missing-exports": "^2.0.0", + "typescript": "~5.3.3" + }, + "engines": { + "node": "^18.18 || >=20" + } +} diff --git a/packages/multichain-transactions-controller/src/MultichainTransactionsController-method-action-types.ts b/packages/multichain-transactions-controller/src/MultichainTransactionsController-method-action-types.ts new file mode 100644 index 00000000000..ca9e32605d6 --- /dev/null +++ b/packages/multichain-transactions-controller/src/MultichainTransactionsController-method-action-types.ts @@ -0,0 +1,24 @@ +/** + * This file is auto generated. + * Do not edit manually. + */ + +import type { MultichainTransactionsController } from './MultichainTransactionsController.js'; + +/** + * Updates transactions for a specific account. This is used for the initial fetch + * when an account is first added. + * + * @param accountId - The ID of the account to get transactions for. + */ +export type MultichainTransactionsControllerUpdateTransactionsForAccountAction = + { + type: `MultichainTransactionsController:updateTransactionsForAccount`; + handler: MultichainTransactionsController['updateTransactionsForAccount']; + }; + +/** + * Union of all MultichainTransactionsController action types. + */ +export type MultichainTransactionsControllerMethodActions = + MultichainTransactionsControllerUpdateTransactionsForAccountAction; diff --git a/packages/multichain-transactions-controller/src/MultichainTransactionsController.test.ts b/packages/multichain-transactions-controller/src/MultichainTransactionsController.test.ts new file mode 100644 index 00000000000..1de285b33de --- /dev/null +++ b/packages/multichain-transactions-controller/src/MultichainTransactionsController.test.ts @@ -0,0 +1,1066 @@ +import { deriveStateFromMetadata } from '@metamask/base-controller'; +import type { + AccountTransactionsUpdatedEventPayload, + CaipAssetType, + TransactionsPage, +} from '@metamask/keyring-api'; +import { + BtcAccountType, + BtcMethod, + EthAccountType, + EthMethod, + SolAccountType, + SolMethod, + SolScope, +} from '@metamask/keyring-api'; +import { KeyringTypes } from '@metamask/keyring-controller'; +import type { InternalAccount } from '@metamask/keyring-internal-api'; +import { Messenger, MOCK_ANY_NAMESPACE } from '@metamask/messenger'; +import type { + MessengerActions, + MessengerEvents, + MockAnyNamespace, +} from '@metamask/messenger'; +import type { CaipChainId } from '@metamask/utils'; +import { v4 as uuidv4 } from 'uuid'; + +import { MultichainNetwork } from './constants.js'; +import { + MultichainTransactionsController, + getDefaultMultichainTransactionsControllerState, +} from './MultichainTransactionsController.js'; +import type { + MultichainTransactionsControllerState, + MultichainTransactionsControllerMessenger, +} from './MultichainTransactionsController.js'; + +const mockBtcAccount = { + address: 'bc1qssdcp5kvwh6nghzg9tuk99xsflwkdv4hgvq58q', + id: uuidv4(), + metadata: { + name: 'Bitcoin Account 1', + importTime: Date.now(), + keyring: { + type: KeyringTypes.snap, + }, + snap: { + id: 'mock-btc-snap', + name: 'mock-btc-snap', + enabled: true, + }, + lastSelected: 0, + }, + options: {}, + methods: Object.values(BtcMethod), + type: BtcAccountType.P2wpkh, + scopes: [], +}; + +const mockSolAccount = { + address: 'EBBYfhQzVzurZiweJ2keeBWpgGLs1cbWYcz28gjGgi5x', + id: uuidv4(), + metadata: { + name: 'Solana Account 1', + importTime: Date.now(), + keyring: { + type: KeyringTypes.snap, + }, + snap: { + id: 'mock-sol-snap', + name: 'mock-sol-snap', + enabled: true, + }, + lastSelected: 0, + }, + scopes: [SolScope.Devnet], + options: {}, + methods: [SolMethod.SendAndConfirmTransaction], + type: SolAccountType.DataAccount, +}; + +const mockEthAccount = { + address: '0x807dE1cf8f39E83258904b2f7b473E5C506E4aC1', + id: uuidv4(), + metadata: { + name: 'Ethereum Account 1', + importTime: Date.now(), + keyring: { + type: KeyringTypes.snap, + }, + snap: { + id: 'mock-eth-snap', + name: 'mock-eth-snap', + enabled: true, + }, + lastSelected: 0, + }, + options: {}, + methods: [EthMethod.SignTypedDataV4, EthMethod.SignTransaction], + type: EthAccountType.Eoa, + scopes: [], +}; + +const mockTransactionResult = { + data: [ + { + id: '123', + account: mockBtcAccount.id, + chain: 'bip122:000000000019d6689c085ae165831e93' as CaipChainId, + type: 'send' as const, + status: 'confirmed' as const, + timestamp: Date.now(), + from: [{ address: 'from-address', asset: null }], + to: [{ address: 'to-address', asset: null }], + fees: [ + { + type: 'base' as const, + asset: { + unit: 'BTC', + type: 'bip122:000000000019d6689c085ae165831e93/slip44:0' as CaipAssetType, + amount: '1000', + fungible: true as const, + }, + }, + ], + events: [ + { + status: 'confirmed' as const, + timestamp: Date.now(), + }, + ], + }, + ], + next: null, +}; + +const controllerName = 'MultichainTransactionsController'; + +type AllMultichainTransactionsControllerActions = + MessengerActions; + +type AllMultichainTransactionsControllerEvents = + MessengerEvents; + +type RootMessenger = Messenger< + MockAnyNamespace, + AllMultichainTransactionsControllerActions, + AllMultichainTransactionsControllerEvents +>; + +/** + * Creates and returns a root messenger for testing + * + * @returns A messenger instance + */ +function getRootMessenger(): RootMessenger { + return new Messenger({ + namespace: MOCK_ANY_NAMESPACE, + }); +} + +const setupController = ({ + state = getDefaultMultichainTransactionsControllerState(), + mocks, +}: { + state?: MultichainTransactionsControllerState; + mocks?: { + listMultichainAccounts?: InternalAccount[]; + handleRequestReturnValue?: TransactionsPage; + }; +} = {}): { + controller: MultichainTransactionsController; + rootMessenger: RootMessenger; + messenger: MultichainTransactionsControllerMessenger; + mockSnapHandleRequest: jest.Mock; + mockListMultichainAccounts: jest.Mock; + mockGetKeyringState: jest.Mock; +} => { + const rootMessenger = getRootMessenger(); + + const multichainTransactionsControllerMessenger = new Messenger< + typeof controllerName, + AllMultichainTransactionsControllerActions, + AllMultichainTransactionsControllerEvents, + RootMessenger + >({ + namespace: controllerName, + parent: rootMessenger, + }); + rootMessenger.delegate({ + messenger: multichainTransactionsControllerMessenger, + actions: [ + 'SnapController:handleRequest', + 'AccountsController:listMultichainAccounts', + 'KeyringController:getState', + ], + events: [ + 'AccountsController:accountAdded', + 'AccountsController:accountRemoved', + 'AccountsController:accountTransactionsUpdated', + ], + }); + + const mockSnapHandleRequest = jest.fn(); + rootMessenger.registerActionHandler( + 'SnapController:handleRequest', + mockSnapHandleRequest.mockReturnValue( + mocks?.handleRequestReturnValue ?? mockTransactionResult, + ), + ); + + const mockListMultichainAccounts = jest.fn(); + rootMessenger.registerActionHandler( + 'AccountsController:listMultichainAccounts', + mockListMultichainAccounts.mockReturnValue( + mocks?.listMultichainAccounts ?? [mockBtcAccount, mockEthAccount], + ), + ); + + const mockGetKeyringState = jest.fn().mockReturnValue({ + isUnlocked: true, + }); + rootMessenger.registerActionHandler( + 'KeyringController:getState', + mockGetKeyringState, + ); + + const controller = new MultichainTransactionsController({ + messenger: multichainTransactionsControllerMessenger, + state, + }); + + return { + controller, + rootMessenger, + messenger: multichainTransactionsControllerMessenger, + mockSnapHandleRequest, + mockListMultichainAccounts, + mockGetKeyringState, + }; +}; + +/** + * Utility function that waits for all pending promises to be resolved. + * This is necessary when testing asynchronous execution flows that are + * initiated by synchronous calls. + * + * @returns A promise that resolves when all pending promises are completed. + */ +async function waitForAllPromises(): Promise { + // Wait for next tick to flush all pending promises. It's requires since + // we are testing some asynchronous execution flows that are started by + // synchronous calls. + await new Promise(process.nextTick); +} + +const NEW_ACCOUNT_ID = 'new-account-id'; +const TEST_ACCOUNT_ID = 'test-account-id'; + +describe('MultichainTransactionsController', () => { + it('initialize with default state', () => { + const { controller } = setupController({}); + expect(controller.state).toStrictEqual({ nonEvmTransactions: {} }); + }); + + it('updates transactions when "AccountsController:accountAdded" is fired', async () => { + const { controller, rootMessenger, mockListMultichainAccounts } = + setupController({ + mocks: { + listMultichainAccounts: [], + }, + }); + + mockListMultichainAccounts.mockReturnValue([mockBtcAccount]); + rootMessenger.publish('AccountsController:accountAdded', mockBtcAccount); + + await waitForAllPromises(); + + const { chain } = mockTransactionResult.data[0]; + expect( + controller.state.nonEvmTransactions[mockBtcAccount.id][chain], + ).toStrictEqual({ + transactions: mockTransactionResult.data, + next: null, + lastUpdated: expect.any(Number), + }); + }); + + it('updates transactions when "AccountsController:accountRemoved" is fired', async () => { + const { controller, rootMessenger, mockListMultichainAccounts } = + setupController(); + + await controller.updateTransactionsForAccount(mockBtcAccount.id); + + const { chain } = mockTransactionResult.data[0]; + expect( + controller.state.nonEvmTransactions[mockBtcAccount.id][chain], + ).toStrictEqual({ + transactions: mockTransactionResult.data, + next: null, + lastUpdated: expect.any(Number), + }); + + rootMessenger.publish( + 'AccountsController:accountRemoved', + mockBtcAccount.id, + ); + mockListMultichainAccounts.mockReturnValue([]); + + expect(controller.state.nonEvmTransactions).toStrictEqual({}); + }); + + it('does not track balances for EVM accounts', async () => { + const { controller, rootMessenger, mockListMultichainAccounts } = + setupController({ + mocks: { + listMultichainAccounts: [], + }, + }); + + mockListMultichainAccounts.mockReturnValue([mockEthAccount]); + rootMessenger.publish('AccountsController:accountAdded', mockEthAccount); + + expect(controller.state).toStrictEqual({ + nonEvmTransactions: {}, + }); + }); + + it('updates transactions for a specific account', async () => { + const { controller } = setupController(); + await controller.updateTransactionsForAccount(mockBtcAccount.id); + + const { chain } = mockTransactionResult.data[0]; + expect( + controller.state.nonEvmTransactions[mockBtcAccount.id][chain], + ).toStrictEqual({ + transactions: mockTransactionResult.data, + next: null, + lastUpdated: expect.any(Number), + }); + }); + + it('stores transactions by chain for accounts', async () => { + const mockSolTransaction = { + account: mockSolAccount.id, + type: 'send' as const, + status: 'confirmed' as const, + timestamp: Date.now(), + from: [], + to: [], + fees: [], + events: [ + { + status: 'confirmed' as const, + timestamp: Date.now(), + }, + ], + }; + const mockSolTransactions = { + data: [ + { + ...mockSolTransaction, + id: '3', + chain: MultichainNetwork.Solana, + }, + { + ...mockSolTransaction, + id: '1', + chain: MultichainNetwork.SolanaTestnet, + }, + { + ...mockSolTransaction, + id: '2', + chain: MultichainNetwork.SolanaDevnet, + }, + ], + next: null, + }; + + const { controller, mockSnapHandleRequest } = setupController({ + mocks: { + listMultichainAccounts: [mockSolAccount], + }, + }); + mockSnapHandleRequest.mockReturnValueOnce(mockSolTransactions); + + await controller.updateTransactionsForAccount(mockSolAccount.id); + + expect( + Object.keys(controller.state.nonEvmTransactions[mockSolAccount.id]), + ).toHaveLength(4); + + expect( + controller.state.nonEvmTransactions[mockSolAccount.id][ + MultichainNetwork.Solana + ].transactions, + ).toHaveLength(1); + expect( + controller.state.nonEvmTransactions[mockSolAccount.id][ + MultichainNetwork.Solana + ].transactions[0], + ).toStrictEqual(mockSolTransactions.data[0]); + + expect( + controller.state.nonEvmTransactions[mockSolAccount.id][ + MultichainNetwork.SolanaTestnet + ].transactions, + ).toHaveLength(1); + expect( + controller.state.nonEvmTransactions[mockSolAccount.id][ + MultichainNetwork.SolanaTestnet + ].transactions[0], + ).toStrictEqual(mockSolTransactions.data[1]); + + expect( + controller.state.nonEvmTransactions[mockSolAccount.id][ + MultichainNetwork.SolanaDevnet + ].transactions, + ).toHaveLength(1); + expect( + controller.state.nonEvmTransactions[mockSolAccount.id][ + MultichainNetwork.SolanaDevnet + ].transactions[0], + ).toStrictEqual(mockSolTransactions.data[2]); + }); + + it('handles pagination when fetching transactions', async () => { + const firstPage = { + data: [ + { + id: '1', + account: mockBtcAccount.id, + chain: 'bip122:000000000933ea01ad0ee984209779ba', + type: 'send' as const, + status: 'confirmed' as const, + timestamp: Date.now(), + from: [], + to: [], + fees: [], + events: [ + { + status: 'confirmed' as const, + timestamp: Date.now(), + }, + ], + }, + ], + next: 'page2', + }; + + const secondPage = { + data: [ + { + id: '2', + account: mockBtcAccount.id, + chain: 'bip122:000000000933ea01ad0ee984209779ba', + type: 'send' as const, + status: 'confirmed' as const, + timestamp: Date.now(), + from: [], + to: [], + fees: [], + events: [ + { + status: 'confirmed' as const, + timestamp: Date.now(), + }, + ], + }, + ], + next: null, + }; + + const { controller, mockSnapHandleRequest } = setupController(); + mockSnapHandleRequest + .mockReturnValueOnce(firstPage) + .mockReturnValueOnce(secondPage); + + await controller.updateTransactionsForAccount(mockBtcAccount.id); + + expect(mockSnapHandleRequest).toHaveBeenCalledWith( + expect.objectContaining({ + request: expect.objectContaining({ + method: 'keyring_listAccountTransactions', + }), + }), + ); + }); + + it('handles errors gracefully when updating transactions', async () => { + const { controller, mockSnapHandleRequest, mockListMultichainAccounts } = + setupController({ + mocks: { + listMultichainAccounts: [], + }, + }); + + mockSnapHandleRequest.mockReset(); + mockSnapHandleRequest.mockImplementation(() => + Promise.reject(new Error('Failed to fetch')), + ); + mockListMultichainAccounts.mockReturnValue([mockBtcAccount]); + + await controller.updateTransactionsForAccount(mockBtcAccount.id); + await waitForAllPromises(); + + expect(controller.state.nonEvmTransactions).toStrictEqual({}); + }); + + it('handles errors gracefully when constructing the controller', async () => { + // This method will be used in the constructor of that controller. + const updateTransactionsForAccountSpy = jest.spyOn( + MultichainTransactionsController.prototype, + 'updateTransactionsForAccount', + ); + updateTransactionsForAccountSpy.mockRejectedValue( + new Error('Something unexpected happen'), + ); + + const { controller } = setupController({ + mocks: { + listMultichainAccounts: [mockBtcAccount], + }, + }); + + expect(controller.state.nonEvmTransactions).toStrictEqual({}); + }); + + it('updates transactions when receiving "AccountsController:accountTransactionsUpdated" event', async () => { + const mockSolAccountWithId = { + ...mockSolAccount, + id: TEST_ACCOUNT_ID, + }; + + const { chain } = mockTransactionResult.data[0]; + const existingTransaction = { + ...mockTransactionResult.data[0], + id: '123', + status: 'confirmed' as const, + chain, + }; + + const newTransaction = { + ...mockTransactionResult.data[0], + id: '456', + status: 'submitted' as const, + chain, + }; + + const updatedExistingTransaction = { + ...mockTransactionResult.data[0], + id: '123', + status: 'failed' as const, + chain, + }; + + const { controller, rootMessenger } = setupController({ + state: { + nonEvmTransactions: { + [mockSolAccountWithId.id]: { + [chain]: { + transactions: [existingTransaction], + next: null, + lastUpdated: Date.now(), + }, + }, + }, + }, + }); + + rootMessenger.publish('AccountsController:accountTransactionsUpdated', { + transactions: { + [mockSolAccountWithId.id]: [updatedExistingTransaction, newTransaction], + }, + }); + + await waitForAllPromises(); + + const finalTransactions = + controller.state.nonEvmTransactions[mockSolAccountWithId.id][chain] + .transactions; + expect(finalTransactions).toStrictEqual([ + updatedExistingTransaction, + newTransaction, + ]); + }); + + it('handles empty transaction updates gracefully', async () => { + const { chain } = mockTransactionResult.data[0]; + const { controller, rootMessenger } = setupController({ + state: { + nonEvmTransactions: { + [TEST_ACCOUNT_ID]: { + [chain]: { + transactions: [], + next: null, + lastUpdated: Date.now(), + }, + }, + }, + }, + }); + + rootMessenger.publish('AccountsController:accountTransactionsUpdated', { + transactions: {}, + }); + + await waitForAllPromises(); + + expect( + controller.state.nonEvmTransactions[TEST_ACCOUNT_ID][chain], + ).toStrictEqual({ + transactions: [], + next: null, + lastUpdated: expect.any(Number), + }); + }); + + it('initializes new accounts with empty transactions array when receiving updates', async () => { + const { chain } = mockTransactionResult.data[0]; + + const { controller, rootMessenger } = setupController({ + state: { + nonEvmTransactions: {}, + }, + }); + + rootMessenger.publish('AccountsController:accountTransactionsUpdated', { + transactions: { + [NEW_ACCOUNT_ID]: mockTransactionResult.data, + }, + }); + + await waitForAllPromises(); + expect( + controller.state.nonEvmTransactions[NEW_ACCOUNT_ID][chain], + ).toStrictEqual({ + transactions: mockTransactionResult.data, + next: null, + lastUpdated: expect.any(Number), + }); + }); + + it('handles undefined transactions in update payload', async () => { + const { chain } = mockTransactionResult.data[0]; + const { controller, rootMessenger } = setupController({ + state: { + nonEvmTransactions: { + [TEST_ACCOUNT_ID]: { + [chain]: { + transactions: [], + next: null, + lastUpdated: Date.now(), + }, + }, + }, + }, + mocks: { + listMultichainAccounts: [], + handleRequestReturnValue: { + data: [], + next: null, + }, + }, + }); + + const initialStateSnapshot = { + [TEST_ACCOUNT_ID]: { + [chain]: { + ...controller.state.nonEvmTransactions[TEST_ACCOUNT_ID][chain], + lastUpdated: expect.any(Number), + }, + }, + }; + + rootMessenger.publish('AccountsController:accountTransactionsUpdated', { + transactions: undefined, + } as unknown as AccountTransactionsUpdatedEventPayload); + + await waitForAllPromises(); + + expect(controller.state.nonEvmTransactions).toStrictEqual( + initialStateSnapshot, + ); + }); + + it('sorts transactions by timestamp (newest first)', async () => { + const { chain } = mockTransactionResult.data[0]; + const olderTransaction = { + ...mockTransactionResult.data[0], + id: '123', + timestamp: 1000, + }; + const newerTransaction = { + ...mockTransactionResult.data[0], + id: '456', + timestamp: 2000, + }; + + const { controller, rootMessenger } = setupController({ + state: { + nonEvmTransactions: { + [TEST_ACCOUNT_ID]: { + [chain]: { + transactions: [olderTransaction], + next: null, + lastUpdated: Date.now(), + }, + }, + }, + }, + }); + + rootMessenger.publish('AccountsController:accountTransactionsUpdated', { + transactions: { + [TEST_ACCOUNT_ID]: [newerTransaction], + }, + }); + + await waitForAllPromises(); + + const finalTransactions = + controller.state.nonEvmTransactions[TEST_ACCOUNT_ID][chain].transactions; + expect(finalTransactions).toStrictEqual([ + newerTransaction, + olderTransaction, + ]); + }); + + it('sorts transactions by timestamp and handles null timestamps', async () => { + const { chain } = mockTransactionResult.data[0]; + const nullTimestampTx1 = { + ...mockTransactionResult.data[0], + id: '123', + timestamp: null, + }; + const nullTimestampTx2 = { + ...mockTransactionResult.data[0], + id: '456', + timestamp: null, + }; + const withTimestampTx = { + ...mockTransactionResult.data[0], + id: '789', + timestamp: 1000, + }; + + const { controller, rootMessenger } = setupController({ + state: { + nonEvmTransactions: { + [TEST_ACCOUNT_ID]: { + [chain]: { + transactions: [nullTimestampTx1], + next: null, + lastUpdated: Date.now(), + }, + }, + }, + }, + }); + + rootMessenger.publish('AccountsController:accountTransactionsUpdated', { + transactions: { + [TEST_ACCOUNT_ID]: [withTimestampTx, nullTimestampTx2], + }, + }); + + await waitForAllPromises(); + + const finalTransactions = + controller.state.nonEvmTransactions[TEST_ACCOUNT_ID][chain].transactions; + expect(finalTransactions).toStrictEqual([ + withTimestampTx, + nullTimestampTx1, + nullTimestampTx2, + ]); + }); + + it('resumes updating transactions after unlocking KeyringController', async () => { + const { controller, mockGetKeyringState } = setupController(); + + mockGetKeyringState.mockReturnValue({ isUnlocked: false }); + + await controller.updateTransactionsForAccount(mockBtcAccount.id); + expect( + controller.state.nonEvmTransactions[mockBtcAccount.id], + ).toBeUndefined(); + + mockGetKeyringState.mockReturnValue({ isUnlocked: true }); + + await controller.updateTransactionsForAccount(mockBtcAccount.id); + + const { chain } = mockTransactionResult.data[0]; + expect( + controller.state.nonEvmTransactions[mockBtcAccount.id][chain], + ).toStrictEqual({ + transactions: mockTransactionResult.data, + next: null, + lastUpdated: expect.any(Number), + }); + }); + + it('updates transactions by chain when receiving transaction updates', async () => { + const mockSolAccountWithId = { + ...mockSolAccount, + id: TEST_ACCOUNT_ID, + }; + + const mockSolTransaction = { + type: 'send' as const, + status: 'confirmed' as const, + timestamp: Date.now(), + from: [], + to: [], + fees: [], + account: mockSolAccountWithId.id, + events: [ + { + status: 'confirmed' as const, + timestamp: Date.now(), + }, + ], + }; + + const mainnetTransaction = { + ...mockSolTransaction, + id: '1', + chain: MultichainNetwork.Solana, + }; + + const devnetTransaction = { + ...mockSolTransaction, + id: '2', + chain: MultichainNetwork.SolanaDevnet, + }; + + const { controller, rootMessenger } = setupController({ + state: { + nonEvmTransactions: { + [mockSolAccountWithId.id]: { + [MultichainNetwork.Solana]: { + transactions: [], + next: null, + lastUpdated: Date.now(), + }, + }, + }, + }, + }); + + rootMessenger.publish('AccountsController:accountTransactionsUpdated', { + transactions: { + [mockSolAccountWithId.id]: [mainnetTransaction, devnetTransaction], + }, + }); + + await waitForAllPromises(); + + expect( + Object.keys(controller.state.nonEvmTransactions[mockSolAccountWithId.id]), + ).toHaveLength(2); + + expect( + controller.state.nonEvmTransactions[mockSolAccountWithId.id][ + MultichainNetwork.Solana + ].transactions, + ).toHaveLength(1); + expect( + controller.state.nonEvmTransactions[mockSolAccountWithId.id][ + MultichainNetwork.Solana + ].transactions[0], + ).toBe(mainnetTransaction); + + expect( + controller.state.nonEvmTransactions[mockSolAccountWithId.id][ + MultichainNetwork.SolanaDevnet + ].transactions, + ).toHaveLength(1); + expect( + controller.state.nonEvmTransactions[mockSolAccountWithId.id][ + MultichainNetwork.SolanaDevnet + ].transactions[0], + ).toBe(devnetTransaction); + }); + + it('publishes transactionConfirmed event when transaction is confirmed', async () => { + const { rootMessenger, messenger } = setupController(); + + const confirmedTransaction = { + ...mockTransactionResult.data[0], + id: '123', + status: 'confirmed' as const, + }; + + const publishSpy = jest.spyOn(messenger, 'publish'); + + rootMessenger.publish('AccountsController:accountTransactionsUpdated', { + transactions: { + [mockBtcAccount.id]: [confirmedTransaction], + }, + }); + + await waitForAllPromises(); + + expect(publishSpy).toHaveBeenCalledWith( + 'MultichainTransactionsController:transactionConfirmed', + confirmedTransaction, + ); + }); + + it('publishes transactionSubmitted event when transaction is submitted', async () => { + const { rootMessenger, messenger } = setupController(); + + const submittedTransaction = { + ...mockTransactionResult.data[0], + id: '123', + status: 'submitted' as const, + }; + + const publishSpy = jest.spyOn(messenger, 'publish'); + + rootMessenger.publish('AccountsController:accountTransactionsUpdated', { + transactions: { + [mockBtcAccount.id]: [submittedTransaction], + }, + }); + + await waitForAllPromises(); + + expect(publishSpy).toHaveBeenCalledWith( + 'MultichainTransactionsController:transactionSubmitted', + submittedTransaction, + ); + }); + + it('does not publish events for other transaction statuses', async () => { + const { rootMessenger } = setupController(); + + const pendingTransaction = { + ...mockTransactionResult.data[0], + id: '123', + status: 'unconfirmed' as const, + }; + + const publishSpy = jest.spyOn(rootMessenger, 'publish'); + + rootMessenger.publish('AccountsController:accountTransactionsUpdated', { + transactions: { + [mockBtcAccount.id]: [pendingTransaction], + }, + }); + + await waitForAllPromises(); + + expect(publishSpy).not.toHaveBeenCalledWith( + 'MultichainTransactionsController:transactionConfirmed', + expect.anything(), + ); + expect(publishSpy).not.toHaveBeenCalledWith( + 'MultichainTransactionsController:transactionSubmitted', + expect.anything(), + ); + }); + + it('publishes correct events for multiple transactions with different statuses', async () => { + const { rootMessenger, messenger } = setupController(); + + const transactions = [ + { + ...mockTransactionResult.data[0], + id: '123', + status: 'confirmed' as const, + }, + { + ...mockTransactionResult.data[0], + id: '456', + status: 'submitted' as const, + }, + { + ...mockTransactionResult.data[0], + id: '789', + status: 'unconfirmed' as const, + }, + ]; + + const publishSpy = jest.spyOn(messenger, 'publish'); + + rootMessenger.publish('AccountsController:accountTransactionsUpdated', { + transactions: { + [mockBtcAccount.id]: transactions, + }, + }); + + await waitForAllPromises(); + + expect(publishSpy).toHaveBeenCalledWith( + 'MultichainTransactionsController:transactionConfirmed', + transactions[0], + ); + expect(publishSpy).toHaveBeenCalledWith( + 'MultichainTransactionsController:transactionSubmitted', + transactions[1], + ); + }); + + describe('metadata', () => { + it('includes expected state in debug snapshots', () => { + const { controller } = setupController(); + + expect( + deriveStateFromMetadata( + controller.state, + controller.metadata, + 'includeInDebugSnapshot', + ), + ).toMatchInlineSnapshot(`{}`); + }); + + it('includes expected state in state logs', () => { + const { controller } = setupController(); + + expect( + deriveStateFromMetadata( + controller.state, + controller.metadata, + 'includeInStateLogs', + ), + ).toMatchInlineSnapshot(` + { + "nonEvmTransactions": {}, + } + `); + }); + + it('persists expected state', () => { + const { controller } = setupController(); + + expect( + deriveStateFromMetadata( + controller.state, + controller.metadata, + 'persist', + ), + ).toMatchInlineSnapshot(` + { + "nonEvmTransactions": {}, + } + `); + }); + + it('exposes expected state to UI', () => { + const { controller } = setupController(); + + expect( + deriveStateFromMetadata( + controller.state, + controller.metadata, + 'usedInUi', + ), + ).toMatchInlineSnapshot(` + { + "nonEvmTransactions": {}, + } + `); + }); + }); +}); diff --git a/packages/multichain-transactions-controller/src/MultichainTransactionsController.ts b/packages/multichain-transactions-controller/src/MultichainTransactionsController.ts new file mode 100644 index 00000000000..92fd043768b --- /dev/null +++ b/packages/multichain-transactions-controller/src/MultichainTransactionsController.ts @@ -0,0 +1,491 @@ +import type { + AccountsControllerAccountAddedEvent, + AccountsControllerAccountRemovedEvent, + AccountsControllerListMultichainAccountsAction, + AccountsControllerAccountTransactionsUpdatedEvent, +} from '@metamask/accounts-controller'; +import { BaseController } from '@metamask/base-controller'; +import type { + ControllerGetStateAction, + ControllerStateChangeEvent, +} from '@metamask/base-controller'; +import { isEvmAccountType, TransactionStatus } from '@metamask/keyring-api'; +import type { + Transaction, + AccountTransactionsUpdatedEventPayload, +} from '@metamask/keyring-api'; +import type { KeyringControllerGetStateAction } from '@metamask/keyring-controller'; +import type { InternalAccount } from '@metamask/keyring-internal-api'; +import { KeyringClient } from '@metamask/keyring-snap-client'; +import type { Messenger } from '@metamask/messenger'; +import type { SnapControllerHandleRequestAction } from '@metamask/snaps-controllers'; +import type { SnapId } from '@metamask/snaps-sdk'; +import { HandlerType } from '@metamask/snaps-utils'; +import type { CaipChainId, Json, JsonRpcRequest } from '@metamask/utils'; +import type { Draft } from 'immer'; + +import type { MultichainTransactionsControllerMethodActions } from './MultichainTransactionsController-method-action-types.js'; + +const controllerName = 'MultichainTransactionsController'; + +const MESSENGER_EXPOSED_METHODS = ['updateTransactionsForAccount'] as const; + +/** + * PaginationOptions + * + * Represents options for paginating transaction results + * limit - The maximum number of transactions to return + * next - The cursor for the next page of transactions, or null if there is no next page + */ +export type PaginationOptions = { + limit: number; + next?: string | null; +}; + +/** + * State used by the {@link MultichainTransactionsController} to cache account transactions. + */ +export type MultichainTransactionsControllerState = { + nonEvmTransactions: { + [accountId: string]: { + [chain: CaipChainId]: TransactionStateEntry; + }; + }; +}; + +/** + * Constructs the default {@link MultichainTransactionsController} state. + * + * @returns The default {@link MultichainTransactionsController} state. + */ +export function getDefaultMultichainTransactionsControllerState(): MultichainTransactionsControllerState { + return { + nonEvmTransactions: {}, + }; +} + +/** + * Event emitted when a transaction is finalized. + */ +export type MultichainTransactionsControllerTransactionConfirmedEvent = { + type: `${typeof controllerName}:transactionConfirmed`; + payload: [Transaction]; +}; + +/** + * Event emitted when a transaction is submitted. + */ +export type MultichainTransactionsControllerTransactionSubmittedEvent = { + type: `${typeof controllerName}:transactionSubmitted`; + payload: [Transaction]; +}; + +/** + * Returns the state of the {@link MultichainTransactionsController}. + */ +export type MultichainTransactionsControllerGetStateAction = + ControllerGetStateAction< + typeof controllerName, + MultichainTransactionsControllerState + >; + +/** + * Event emitted when the state of the {@link MultichainTransactionsController} changes. + */ +export type MultichainTransactionsControllerStateChange = + ControllerStateChangeEvent< + typeof controllerName, + MultichainTransactionsControllerState + >; + +/** + * Actions exposed by the {@link MultichainTransactionsController}. + */ +export type MultichainTransactionsControllerActions = + | MultichainTransactionsControllerGetStateAction + | MultichainTransactionsControllerMethodActions; + +/** + * Events emitted by {@link MultichainTransactionsController}. + */ +export type MultichainTransactionsControllerEvents = + | MultichainTransactionsControllerStateChange + | MultichainTransactionsControllerTransactionConfirmedEvent + | MultichainTransactionsControllerTransactionSubmittedEvent; + +/** + * Messenger type for the MultichainTransactionsController. + */ +export type MultichainTransactionsControllerMessenger = Messenger< + typeof controllerName, + MultichainTransactionsControllerActions | AllowedActions, + MultichainTransactionsControllerEvents | AllowedEvents +>; + +/** + * Actions that this controller is allowed to call. + */ +type AllowedActions = + | AccountsControllerListMultichainAccountsAction + | KeyringControllerGetStateAction + | SnapControllerHandleRequestAction; + +/** + * Events that this controller is allowed to subscribe. + */ +type AllowedEvents = + | AccountsControllerAccountAddedEvent + | AccountsControllerAccountRemovedEvent + | AccountsControllerAccountTransactionsUpdatedEvent; + +/** + * {@link MultichainTransactionsController}'s metadata. + * + * This allows us to choose if fields of the state should be persisted or not + * using the `persist` flag; and if they can be sent to Sentry or not, using + * the `anonymous` flag. + */ +const multichainTransactionsControllerMetadata = { + nonEvmTransactions: { + includeInStateLogs: true, + persist: true, + includeInDebugSnapshot: false, + usedInUi: true, + }, +}; + +/** + * The state of transactions for a specific chain. + */ +export type TransactionStateEntry = { + transactions: Transaction[]; + next: string | null; + lastUpdated: number; +}; + +/** + * The MultichainTransactionsController is responsible for fetching and caching account + * transactions for non-EVM accounts. + */ +export class MultichainTransactionsController extends BaseController< + typeof controllerName, + MultichainTransactionsControllerState, + MultichainTransactionsControllerMessenger +> { + constructor({ + messenger, + state, + }: { + messenger: MultichainTransactionsControllerMessenger; + state?: Partial; + }) { + super({ + messenger, + name: controllerName, + metadata: multichainTransactionsControllerMetadata, + state: { + ...getDefaultMultichainTransactionsControllerState(), + ...state, + }, + }); + + this.messenger.registerMethodActionHandlers( + this, + MESSENGER_EXPOSED_METHODS, + ); + + // Fetch initial transactions for all non-EVM accounts + for (const account of this.#listAccounts()) { + this.updateTransactionsForAccount(account.id).catch((error) => { + console.error( + `Failed to fetch initial transactions for account ${account.id}:`, + error, + ); + }); + } + + this.messenger.subscribe( + 'AccountsController:accountAdded', + (account: InternalAccount) => this.#handleOnAccountAdded(account), + ); + this.messenger.subscribe( + 'AccountsController:accountRemoved', + (accountId: string) => this.#handleOnAccountRemoved(accountId), + ); + this.messenger.subscribe( + 'AccountsController:accountTransactionsUpdated', + (transactionsUpdate: AccountTransactionsUpdatedEventPayload) => + this.#handleOnAccountTransactionsUpdated(transactionsUpdate), + ); + } + + /** + * Lists the multichain accounts coming from the `AccountsController`. + * + * @returns A list of multichain accounts. + */ + #listMultichainAccounts(): InternalAccount[] { + return this.messenger.call('AccountsController:listMultichainAccounts'); + } + + /** + * Lists the accounts that we should get transactions for. + * + * @returns A list of accounts that we should get transactions for. + */ + #listAccounts(): InternalAccount[] { + const accounts = this.#listMultichainAccounts(); + return accounts.filter((account) => this.#isNonEvmAccount(account)); + } + + /** + * Gets transactions for an account. + * + * @param accountId - The ID of the account to get transactions for. + * @param snapId - The ID of the snap that manages the account. + * @param pagination - Options for paginating transaction results. + * @returns A promise that resolves to the transaction data and pagination info. + */ + async #getTransactions( + accountId: string, + snapId: string, + pagination: PaginationOptions, + ): Promise<{ + data: Transaction[]; + next: string | null; + }> { + return await this.#getClient(snapId).listAccountTransactions( + accountId, + pagination, + ); + } + + /** + * Updates transactions for a specific account. This is used for the initial fetch + * when an account is first added. + * + * @param accountId - The ID of the account to get transactions for. + */ + async updateTransactionsForAccount(accountId: string) { + const { isUnlocked } = this.messenger.call('KeyringController:getState'); + + if (!isUnlocked) { + return; + } + + try { + const account = this.#listAccounts().find( + (accountItem) => accountItem.id === accountId, + ); + + if (account?.metadata.snap) { + const response = await this.#getTransactions( + account.id, + account.metadata.snap.id, + { limit: 10 }, + ); + + const transactionsByChain: Record = {}; + + response.data.forEach((transaction) => { + const { chain } = transaction; + + if (!transactionsByChain[chain]) { + transactionsByChain[chain] = []; + } + transactionsByChain[chain].push(transaction); + }); + + const chainUpdates = Object.entries(transactionsByChain).map( + ([chain, transactions]) => ({ + chain, + entry: { + transactions, + next: response.next, + lastUpdated: Date.now(), + }, + }), + ); + + this.update((state: Draft) => { + if (!state.nonEvmTransactions[account.id]) { + state.nonEvmTransactions[account.id] = {}; + } + + chainUpdates.forEach(({ chain, entry }) => { + state.nonEvmTransactions[account.id][chain as CaipChainId] = entry; + }); + }); + } + } catch (error) { + console.error( + `Failed to fetch transactions for account ${accountId}:`, + error, + ); + } + } + + /** + * Checks for non-EVM accounts. + * + * @param account - The new account to be checked. + * @returns True if the account is a non-EVM account, false otherwise. + */ + #isNonEvmAccount(account: InternalAccount): boolean { + return ( + !isEvmAccountType(account.type) && + // Non-EVM accounts are backed by a Snap for now + account.metadata.snap !== undefined + ); + } + + /** + * Handles changes when a new account has been added. + * + * @param account - The new account being added. + */ + async #handleOnAccountAdded(account: InternalAccount) { + if (!this.#isNonEvmAccount(account)) { + return; + } + + await this.updateTransactionsForAccount(account.id); + } + + /** + * Handles changes when a new account has been removed. + * + * @param accountId - The account ID being removed. + */ + async #handleOnAccountRemoved(accountId: string) { + if (accountId in this.state.nonEvmTransactions) { + this.update((state: Draft) => { + delete state.nonEvmTransactions[accountId]; + }); + } + } + + /** + * Publishes transaction update events. + * + * @param updatedTransaction - The updated transaction. + */ + #publishTransactionUpdateEvent(updatedTransaction: Transaction) { + if (updatedTransaction.status === TransactionStatus.Confirmed) { + this.messenger.publish( + 'MultichainTransactionsController:transactionConfirmed', + updatedTransaction, + ); + } + + if (updatedTransaction.status === TransactionStatus.Submitted) { + this.messenger.publish( + 'MultichainTransactionsController:transactionSubmitted', + updatedTransaction, + ); + } + } + + /** + * Handles transaction updates received from the AccountsController. + * + * @param transactionsUpdate - The transaction update event containing new transactions. + */ + #handleOnAccountTransactionsUpdated( + transactionsUpdate: AccountTransactionsUpdatedEventPayload, + ): void { + const updatedTransactions: Record< + string, + Record + > = {}; + const transactionsToPublish: Transaction[] = []; + + if (!transactionsUpdate?.transactions) { + return; + } + + Object.entries(transactionsUpdate.transactions).forEach( + ([accountId, newTransactions]) => { + updatedTransactions[accountId] = {}; + + newTransactions.forEach((tx) => { + const { chain } = tx; + + if (!updatedTransactions[accountId][chain]) { + updatedTransactions[accountId][chain] = []; + } + + updatedTransactions[accountId][chain].push(tx); + transactionsToPublish.push(tx); + }); + + Object.entries(updatedTransactions[accountId]).forEach( + ([chain, chainTransactions]) => { + // Account might not have any transactions yet, so use `[]` in that case. + const oldTransactions = + this.state.nonEvmTransactions[accountId]?.[chain as CaipChainId] + ?.transactions ?? []; + + // Uses a `Map` to deduplicate transactions by ID, ensuring we keep the latest version + // of each transaction while preserving older transactions and transactions from other accounts. + // Transactions are sorted by timestamp (newest first). + const transactions = new Map(); + + oldTransactions.forEach((tx) => { + transactions.set(tx.id, tx); + }); + + chainTransactions.forEach((tx) => { + transactions.set(tx.id, tx); + }); + + // Sorted by timestamp (newest first). If the timestamp is not provided, those + // transactions will be put in the end of this list. + updatedTransactions[accountId][chain as CaipChainId] = Array.from( + transactions.values(), + ).sort((a, b) => (b.timestamp ?? 0) - (a.timestamp ?? 0)); + }, + ); + }, + ); + + this.update((state) => { + Object.entries(updatedTransactions).forEach(([accountId, chainsData]) => { + if (!state.nonEvmTransactions[accountId]) { + state.nonEvmTransactions[accountId] = {}; + } + + Object.entries(chainsData).forEach(([chain, transactions]) => { + state.nonEvmTransactions[accountId][chain as CaipChainId] = { + transactions, + next: null, + lastUpdated: Date.now(), + }; + }); + }); + }); + + // After we update the state, publish the events for new/updated transactions + transactionsToPublish.forEach((tx) => { + this.#publishTransactionUpdateEvent(tx); + }); + } + + /** + * Gets a `KeyringClient` for a Snap. + * + * @param snapId - ID of the Snap to get the client for. + * @returns A `KeyringClient` for the Snap. + */ + #getClient(snapId: string): KeyringClient { + return new KeyringClient({ + send: async (request: JsonRpcRequest) => + (await this.messenger.call('SnapController:handleRequest', { + snapId: snapId as SnapId, + origin: 'metamask', + handler: HandlerType.OnKeyringRequest, + request, + })) as Promise, + }); + } +} diff --git a/packages/multichain-transactions-controller/src/constants.ts b/packages/multichain-transactions-controller/src/constants.ts new file mode 100644 index 00000000000..b273c68b3a7 --- /dev/null +++ b/packages/multichain-transactions-controller/src/constants.ts @@ -0,0 +1,20 @@ +/** + * The network identifiers for supported networks in CAIP-2 format. + * Note: This is a temporary workaround until we have a more robust + * solution for network identifiers. + */ +export enum MultichainNetwork { + Bitcoin = 'bip122:000000000019d6689c085ae165831e93', + BitcoinTestnet = 'bip122:000000000933ea01ad0ee984209779ba', + Solana = 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp', + SolanaDevnet = 'solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1', + SolanaTestnet = 'solana:4uhcVJyU9pJkvQyS88uRDiswHXSCkY3z', +} + +export enum MultichainNativeAsset { + Bitcoin = `${MultichainNetwork.Bitcoin}/slip44:0`, + BitcoinTestnet = `${MultichainNetwork.BitcoinTestnet}/slip44:0`, + Solana = `${MultichainNetwork.Solana}/slip44:501`, + SolanaDevnet = `${MultichainNetwork.SolanaDevnet}/slip44:501`, + SolanaTestnet = `${MultichainNetwork.SolanaTestnet}/slip44:501`, +} diff --git a/packages/multichain-transactions-controller/src/index.ts b/packages/multichain-transactions-controller/src/index.ts new file mode 100644 index 00000000000..f9e781a0ec1 --- /dev/null +++ b/packages/multichain-transactions-controller/src/index.ts @@ -0,0 +1,15 @@ +export { MultichainTransactionsController } from './MultichainTransactionsController.js'; +export type { + MultichainTransactionsControllerState, + PaginationOptions, + TransactionStateEntry, + MultichainTransactionsControllerStateChange, + MultichainTransactionsControllerGetStateAction, + MultichainTransactionsControllerActions, + MultichainTransactionsControllerEvents, + MultichainTransactionsControllerMessenger, + MultichainTransactionsControllerTransactionSubmittedEvent, + MultichainTransactionsControllerTransactionConfirmedEvent, +} from './MultichainTransactionsController.js'; +export type { MultichainTransactionsControllerUpdateTransactionsForAccountAction } from './MultichainTransactionsController-method-action-types.js'; +export { MultichainNetwork, MultichainNativeAsset } from './constants.js'; diff --git a/packages/multichain-transactions-controller/tsconfig.build.json b/packages/multichain-transactions-controller/tsconfig.build.json new file mode 100644 index 00000000000..695c01cd3df --- /dev/null +++ b/packages/multichain-transactions-controller/tsconfig.build.json @@ -0,0 +1,16 @@ +{ + "extends": "../../tsconfig.packages.build.json", + "compilerOptions": { + "baseUrl": "./", + "outDir": "./dist", + "rootDir": "./src" + }, + "references": [ + { "path": "../accounts-controller/tsconfig.build.json" }, + { "path": "../base-controller/tsconfig.build.json" }, + { "path": "../keyring-controller/tsconfig.build.json" }, + { "path": "../polling-controller/tsconfig.build.json" }, + { "path": "../messenger/tsconfig.build.json" } + ], + "include": ["../../types", "./src"] +} diff --git a/packages/multichain-transactions-controller/tsconfig.json b/packages/multichain-transactions-controller/tsconfig.json new file mode 100644 index 00000000000..ce215a73944 --- /dev/null +++ b/packages/multichain-transactions-controller/tsconfig.json @@ -0,0 +1,14 @@ +{ + "extends": "../../tsconfig.packages.json", + "compilerOptions": { + "baseUrl": "./" + }, + "references": [ + { "path": "../accounts-controller" }, + { "path": "../base-controller" }, + { "path": "../keyring-controller" }, + { "path": "../polling-controller" }, + { "path": "../messenger" } + ], + "include": ["../../types", "./src"] +} diff --git a/packages/multichain-transactions-controller/typedoc.json b/packages/multichain-transactions-controller/typedoc.json new file mode 100644 index 00000000000..c9da015dbf8 --- /dev/null +++ b/packages/multichain-transactions-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/name-controller/CHANGELOG.md b/packages/name-controller/CHANGELOG.md index ab833a00de3..d2aaa9110ea 100644 --- a/packages/name-controller/CHANGELOG.md +++ b/packages/name-controller/CHANGELOG.md @@ -1,4 +1,5 @@ # 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/), @@ -6,20 +7,205 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Changed + +- Bump `@metamask/utils` from `^11.9.0` to `^11.11.0` ([#9074](https://github.com/MetaMask/core/pull/9074)) +- Bump `@metamask/controller-utils` from `^12.0.0` to `^12.3.0` ([#8774](https://github.com/MetaMask/core/pull/8774), [#9058](https://github.com/MetaMask/core/pull/9058), [#9083](https://github.com/MetaMask/core/pull/9083), [#9218](https://github.com/MetaMask/core/pull/9218)) +- Bump `@metamask/messenger` from `^1.2.0` to `^2.0.0` ([#9392](https://github.com/MetaMask/core/pull/9392)) + +## [9.1.2] + +### Changed + +- Bump `@metamask/controller-utils` from `^11.19.0` to `^12.0.0` ([#8344](https://github.com/MetaMask/core/pull/8344), [#8755](https://github.com/MetaMask/core/pull/8755)) +- Bump `@metamask/messenger` from `^1.0.0` to `^1.2.0` ([#8364](https://github.com/MetaMask/core/pull/8364), [#8373](https://github.com/MetaMask/core/pull/8373), [#8632](https://github.com/MetaMask/core/pull/8632)) +- Bump `@metamask/base-controller` from `^9.0.1` to `^9.1.0` ([#8457](https://github.com/MetaMask/core/pull/8457)) + +## [9.1.1] + +### Changed + +- Bump `@metamask/base-controller` from `^9.0.0` to `^9.0.1` ([#8317](https://github.com/MetaMask/core/pull/8317)) +- Bump `@metamask/messenger` from `^0.3.0` to `^1.0.0` ([#8317](https://github.com/MetaMask/core/pull/8317)) + +## [9.1.0] + +### Added + +- Expose missing public `NameController` methods through its messenger ([#8183](https://github.com/MetaMask/core/pull/8183)) + - The following actions are now available: + - `NameController:setName` + - `NameController:updateProposedNames` + - Corresponding action types (e.g. `NameControllerSetNameAction`) are available as well. + +### Changed + +- Upgrade `@metamask/utils` from `^11.8.1` to `^11.9.0` ([#7511](https://github.com/MetaMask/core/pull/7511)) +- Bump `@metamask/controller-utils` from `^11.15.0` to `^11.19.0` ([#7202](https://github.com/MetaMask/core/pull/7202), [#7534](https://github.com/MetaMask/core/pull/7534), [#7583](https://github.com/MetaMask/core/pull/7583), [#7995](https://github.com/MetaMask/core/pull/7995)) + +## [9.0.0] + +### Changed + +- **BREAKING:** Use new `Messenger` from `@metamask/messenger` ([#6541](https://github.com/MetaMask/core/pull/6541)) + - Previously, `NameController` accepted a `RestrictedMessenger` instance from `@metamask/base-controller`. +- **BREAKING:** Metadata property `anonymous` renamed to `includeInDebugSnapshot` ([#6541](https://github.com/MetaMask/core/pull/6541)) +- Bump `@metamask/base-controller` from `^8.4.2` to `^9.0.0` ([#6962](https://github.com/MetaMask/core/pull/6962)) + +## [8.1.1] + +### Changed + +- Bump `@metamask/base-controller` from `^8.4.1` to `^8.4.2` ([#6917](https://github.com/MetaMask/core/pull/6917)) + +## [8.1.0] + +### Added + +- Add two new controller state metadata properties: `includeInStateLogs` and `usedInUi` ([#6473](https://github.com/MetaMask/core/pull/6473)) + +### Changed + +- Bump `@metamask/utils` from `^11.2.0` to `^11.8.1` ([#6054](https://github.com/MetaMask/core/pull/6054)[#6588](https://github.com/MetaMask/core/pull/6588), [#6708](https://github.com/MetaMask/core/pull/6708)) +- Bump `@metamask/base-controller` from `^8.0.0` to `^8.4.1` ([#5722](https://github.com/MetaMask/core/pull/5722), [#6284](https://github.com/MetaMask/core/pull/6284), [#6355](https://github.com/MetaMask/core/pull/6355), [#6465](https://github.com/MetaMask/core/pull/6465), [#6632](https://github.com/MetaMask/core/pull/6632), [#6807](https://github.com/MetaMask/core/pull/6807)) +- Bump `@metamask/controller-utils` from `^11.5.0` to `^11.14.1` ([#5439](https://github.com/MetaMask/core/pull/5439), [#5583](https://github.com/MetaMask/core/pull/5583), [#5765](https://github.com/MetaMask/core/pull/5765), [#5812](https://github.com/MetaMask/core/pull/5812), [#5935](https://github.com/MetaMask/core/pull/5935), [#6069](https://github.com/MetaMask/core/pull/6069), [#6303](https://github.com/MetaMask/core/pull/6303), [#6620](https://github.com/MetaMask/core/pull/6620), [#6629](https://github.com/MetaMask/core/pull/6629), [#6807](https://github.com/MetaMask/core/pull/6807)) + +## [8.0.3] + +### Changed + +- Bump `@metamask/base-controller` from `^7.1.0` to `^8.0.0` ([#5135](https://github.com/MetaMask/core/pull/5135)), ([#5305](https://github.com/MetaMask/core/pull/5305)) +- Bump `@metamask/controller-utils` from `^11.4.4` to `^11.5.0` ([#5135](https://github.com/MetaMask/core/pull/5135)), ([#5272](https://github.com/MetaMask/core/pull/5272)) +- Bump `@metamask/utils` from `^10.0.0` to `^11.1.0` ([#5080](https://github.com/MetaMask/core/pull/5080)), ([#5223](https://github.com/MetaMask/core/pull/5223)) +- Bump `@metamask/base-controller` from `^7.0.0` to `^7.1.0` ([#5079](https://github.com/MetaMask/core/pull/5079)) + +## [8.0.2] + +### Changed + +- Bump `@metamask/controller-utils` from `^11.3.0` to `^11.4.4` ([#4834](https://github.com/MetaMask/core/pull/4834), [#4862](https://github.com/MetaMask/core/pull/4862), [#4870](https://github.com/MetaMask/core/pull/4870), [#4915](https://github.com/MetaMask/core/pull/4915), [#5012](https://github.com/MetaMask/core/pull/5012)) +- Bump `@metamask/utils` from `^9.1.0` to `^10.0.0` ([#4831](https://github.com/MetaMask/core/pull/4831)) +- Bump `@metamask/base-controller` from `^7.0.1` to `^7.0.2` ([#4862](https://github.com/MetaMask/core/pull/4862)) + +## [8.0.1] + +### Changed + +- Bump `@metamask/utils` from `^8.3.0` to `^9.1.0` ([#4516](https://github.com/MetaMask/core/pull/4516), [#4529](https://github.com/MetaMask/core/pull/4529)) +- Bump `@metamask/rpc-errors` from `^6.2.1` to `^6.3.1` ([#4516](https://github.com/MetaMask/core/pull/4516)) +- Bump TypeScript from `~4.9.5` to `~5.2.2` and set `moduleResolution` option to `Node16` ([#3645](https://github.com/MetaMask/core/pull/3645), [#4576](https://github.com/MetaMask/core/pull/4576), [#4584](https://github.com/MetaMask/core/pull/4584)) + +### Fixed + +- Produce and export ESM-compatible TypeScript type declaration files in addition to CommonJS-compatible declaration files ([#4648](https://github.com/MetaMask/core/pull/4648)) + - Previously, this package shipped with only one variant of type declaration + files, and these files were only CommonJS-compatible, and the `exports` + field in `package.json` linked to these files. This is an anti-pattern and + was rightfully flagged by the + ["Are the Types Wrong?"](https://arethetypeswrong.github.io/) tool as + ["masquerading as CJS"](https://github.com/arethetypeswrong/arethetypeswrong.github.io/blob/main/docs/problems/FalseCJS.md). + All of the ATTW checks now pass. +- Remove chunk files ([#4648](https://github.com/MetaMask/core/pull/4648)). + - Previously, the build tool we used to generate JavaScript files extracted + common code to "chunk" files. While this was intended to make this package + more tree-shakeable, it also made debugging more difficult for our + development teams. These chunk files are no longer present. + +## [8.0.0] + +### Changed + +- **BREAKING:** Bump minimum Node version to 18.18 ([#3611](https://github.com/MetaMask/core/pull/3611)) +- Bump `@metamask/base-controller` to `^6.0.0` ([#4352](https://github.com/MetaMask/core/pull/4352)) +- Bump `@metamask/controller-utils` to `^11.0.0` ([#4352](https://github.com/MetaMask/core/pull/4352)) + +## [7.0.0] + +### Changed + +- **BREAKING:** Changed token API endpoint from `*.metafi.codefi.network` to `*.api.cx.metamask.io` ([#4301](https://github.com/MetaMask/core/pull/4301)) +- Bump `@metamask/base-controller` to `^5.0.2` ([#4232](https://github.com/MetaMask/core/pull/4232)) +- Bump `async-mutex` to `^0.5.0` ([#4335](https://github.com/MetaMask/core/pull/4335)) +- Bump `@metamask/controller-utils` to `^10.0.0` ([#4342](https://github.com/MetaMask/core/pull/4342)) + +### Fixed + +- Fix `setName` and `updateProposedNames` methods to protect against prototype-polluting assignments ([#4041](https://github.com/MetaMask/core/pull/4041) + +## [6.0.1] + +### Fixed + +- Fix `types` field in `package.json` ([#4047](https://github.com/MetaMask/core/pull/4047)) + +## [6.0.0] + +### Added + +- **BREAKING**: Add ESM build ([#3998](https://github.com/MetaMask/core/pull/3998)) + - It's no longer possible to import files from `./dist` directly. +- Add support for Linea Sepolia (chain ID `0xe705`) ([#3995](https://github.com/MetaMask/core/pull/3995)) + +### Changed + +- **BREAKING:** Bump `@metamask/base-controller` to `^5.0.0` ([#4039](https://github.com/MetaMask/core/pull/4039)) + - This version has a number of breaking changes. See the changelog for more. +- **BREAKING:** Remove support for Optimism Goerli (chain ID `0x1a4`); replace with support for Optimism Sepolia (chain ID `0xaa37dc`) ([#3999](https://github.com/MetaMask/core/pull/3999)) + +## [5.0.0] + +### Changed + +- **BREAKING:** Add expire limit for proposed names ([#3748](https://github.com/MetaMask/core/pull/3748)) + - Expired names now get removed on every call to `updateProposedNames` +- Bump `@metamask/base-controller` to `^4.1.1` ([#3821](https://github.com/MetaMask/core/pull/3821)) + +## [4.2.0] + +### Added + +- Add `origin` property to `NameEntry` and `SetNameRequest` ([#3751](https://github.com/MetaMask/core/pull/3751)) + +## [4.1.0] + +### Added + +- Add fallback variation for petnames ([#3705](https://github.com/MetaMask/core/pull/3705)) + +## [4.0.1] + +### Changed + +- Bump `@metamask/base-controller` to `^4.0.1` ([#3695](https://github.com/MetaMask/core/pull/3695)) + +## [4.0.0] + +### Changed + +- **BREAKING:** Bump `@metamask/base-controller` to ^4.0.0 ([#2063](https://github.com/MetaMask/core/pull/2063)) + - This is breaking because the type of the `messenger` has backward-incompatible changes. See the changelog for this package for more. +- Bump `@metamask/utils` to ^8.2.0 ([#1957](https://github.com/MetaMask/core/pull/1957)) + ## [3.0.1] + ### Changed + - Bump dependency on `@metamask/utils` to ^8.1.0 ([#1639](https://github.com/MetaMask/core/pull/1639)) - Bump dependency on `@metamask/base-controller` to ^3.2.3 ## [3.0.0] + ### Changed + - **BREAKING**: Normalize addresses and chain IDs ([#1732](https://github.com/MetaMask/core/pull/1732)) - Save addresses and chain IDs as lowercase in state - Remove `getChainId` constructor callback - Require a `variation` property when calling `setName` or `updateProposedNames` with the `ethereumAddress` type ## [2.0.0] + ### Changed + - **BREAKING**: Support rate limiting in name providers ([#1715](https://github.com/MetaMask/core/pull/1715)) - Breaking changes: - Change `proposedNames` property in `NameEntry` type from string array to new `ProposedNamesEntry` type @@ -34,10 +220,30 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Update TypeScript to v4.8.x ([#1718](https://github.com/MetaMask/core/pull/1718)) ## [1.0.0] + ### Added + - Initial Release ([#1647](https://github.com/MetaMask/core/pull/1647)) -[Unreleased]: https://github.com/MetaMask/core/compare/@metamask/name-controller@3.0.1...HEAD +[Unreleased]: https://github.com/MetaMask/core/compare/@metamask/name-controller@9.1.2...HEAD +[9.1.2]: https://github.com/MetaMask/core/compare/@metamask/name-controller@9.1.1...@metamask/name-controller@9.1.2 +[9.1.1]: https://github.com/MetaMask/core/compare/@metamask/name-controller@9.1.0...@metamask/name-controller@9.1.1 +[9.1.0]: https://github.com/MetaMask/core/compare/@metamask/name-controller@9.0.0...@metamask/name-controller@9.1.0 +[9.0.0]: https://github.com/MetaMask/core/compare/@metamask/name-controller@8.1.1...@metamask/name-controller@9.0.0 +[8.1.1]: https://github.com/MetaMask/core/compare/@metamask/name-controller@8.1.0...@metamask/name-controller@8.1.1 +[8.1.0]: https://github.com/MetaMask/core/compare/@metamask/name-controller@8.0.3...@metamask/name-controller@8.1.0 +[8.0.3]: https://github.com/MetaMask/core/compare/@metamask/name-controller@8.0.2...@metamask/name-controller@8.0.3 +[8.0.2]: https://github.com/MetaMask/core/compare/@metamask/name-controller@8.0.1...@metamask/name-controller@8.0.2 +[8.0.1]: https://github.com/MetaMask/core/compare/@metamask/name-controller@8.0.0...@metamask/name-controller@8.0.1 +[8.0.0]: https://github.com/MetaMask/core/compare/@metamask/name-controller@7.0.0...@metamask/name-controller@8.0.0 +[7.0.0]: https://github.com/MetaMask/core/compare/@metamask/name-controller@6.0.1...@metamask/name-controller@7.0.0 +[6.0.1]: https://github.com/MetaMask/core/compare/@metamask/name-controller@6.0.0...@metamask/name-controller@6.0.1 +[6.0.0]: https://github.com/MetaMask/core/compare/@metamask/name-controller@5.0.0...@metamask/name-controller@6.0.0 +[5.0.0]: https://github.com/MetaMask/core/compare/@metamask/name-controller@4.2.0...@metamask/name-controller@5.0.0 +[4.2.0]: https://github.com/MetaMask/core/compare/@metamask/name-controller@4.1.0...@metamask/name-controller@4.2.0 +[4.1.0]: https://github.com/MetaMask/core/compare/@metamask/name-controller@4.0.1...@metamask/name-controller@4.1.0 +[4.0.1]: https://github.com/MetaMask/core/compare/@metamask/name-controller@4.0.0...@metamask/name-controller@4.0.1 +[4.0.0]: https://github.com/MetaMask/core/compare/@metamask/name-controller@3.0.1...@metamask/name-controller@4.0.0 [3.0.1]: https://github.com/MetaMask/core/compare/@metamask/name-controller@3.0.0...@metamask/name-controller@3.0.1 [3.0.0]: https://github.com/MetaMask/core/compare/@metamask/name-controller@2.0.0...@metamask/name-controller@3.0.0 [2.0.0]: https://github.com/MetaMask/core/compare/@metamask/name-controller@1.0.0...@metamask/name-controller@2.0.0 diff --git a/packages/name-controller/LICENSE b/packages/name-controller/LICENSE index b703d6a4a23..e3e71d8cf71 100644 --- a/packages/name-controller/LICENSE +++ b/packages/name-controller/LICENSE @@ -18,3 +18,4 @@ 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/name-controller/package.json b/packages/name-controller/package.json index e3765afe56e..6e21b5bbc27 100644 --- a/packages/name-controller/package.json +++ b/packages/name-controller/package.json @@ -1,55 +1,80 @@ { "name": "@metamask/name-controller", - "version": "3.0.1", + "version": "9.1.2", "description": "Stores and suggests names for values such as Ethereum addresses", "keywords": [ - "MetaMask", - "Ethereum" + "Ethereum", + "MetaMask" ], "homepage": "https://github.com/MetaMask/core/tree/main/packages/name-controller#readme", "bugs": { "url": "https://github.com/MetaMask/core/issues" }, + "license": "MIT", "repository": { "type": "git", "url": "https://github.com/MetaMask/core.git" }, - "license": "MIT", - "main": "./dist/index.js", - "types": "./dist/index.d.ts", "files": [ "dist/" ], + "sideEffects": false, + "main": "./dist/index.cjs", + "types": "./dist/index.d.cts", + "exports": { + ".": { + "import": { + "types": "./dist/index.d.mts", + "default": "./dist/index.mjs" + }, + "require": { + "types": "./dist/index.d.cts", + "default": "./dist/index.cjs" + } + }, + "./package.json": "./package.json" + }, + "publishConfig": { + "access": "public", + "registry": "https://registry.npmjs.org/" + }, "scripts": { + "build": "ts-bridge --project tsconfig.build.json --verbose --clean --no-references", + "build:all": "ts-bridge --project tsconfig.build.json --verbose --clean", "build:docs": "typedoc", + "changelog:update": "../../scripts/update-changelog.sh @metamask/name-controller", "changelog:validate": "../../scripts/validate-changelog.sh @metamask/name-controller", + "lint:tsconfigs": "tsx ../../scripts/lint-tsconfigs/lint-tsconfigs.mts", + "lint:tsconfigs:fix": "tsx ../../scripts/lint-tsconfigs/lint-tsconfigs.mts --fix", + "messenger-action-types:check": "tsx ../../packages/messenger-cli/src/cli.ts --formatter oxfmt --esm --check", + "messenger-action-types:generate": "tsx ../../packages/messenger-cli/src/cli.ts --formatter oxfmt --esm --generate", "prepare-manifest:preview": "../../scripts/prepare-preview-manifest.sh", - "publish:preview": "yarn npm publish --tag preview", - "test": "jest", - "test:clean": "jest --clearCache", - "test:watch": "jest --watch" + "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": "^3.2.3", - "@metamask/utils": "^8.1.0", - "async-mutex": "^0.2.6", - "immer": "^9.0.6" + "@metamask/base-controller": "^9.1.0", + "@metamask/controller-utils": "^12.3.0", + "@metamask/messenger": "^2.0.0", + "@metamask/utils": "^11.11.0", + "async-mutex": "^0.5.0" }, "devDependencies": { - "@metamask/auto-changelog": "^3.1.0", - "@types/jest": "^27.4.1", + "@metamask/auto-changelog": "^6.1.0", + "@ts-bridge/cli": "^0.6.4", + "@types/jest": "^30.0.0", "deepmerge": "^4.2.2", - "jest": "^27.5.1", - "ts-jest": "^27.1.4", - "typedoc": "^0.24.8", + "jest": "^30.4.2", + "ts-jest": "^29.4.11", + "tsx": "^4.20.5", + "typedoc": "^0.25.13", "typedoc-plugin-missing-exports": "^2.0.0", - "typescript": "~4.8.4" + "typescript": "~5.3.3" }, "engines": { - "node": ">=16.0.0" - }, - "publishConfig": { - "access": "public", - "registry": "https://registry.npmjs.org/" + "node": "^18.18 || >=20" } } diff --git a/packages/name-controller/src/NameController-method-action-types.ts b/packages/name-controller/src/NameController-method-action-types.ts new file mode 100644 index 00000000000..13f91e9c04d --- /dev/null +++ b/packages/name-controller/src/NameController-method-action-types.ts @@ -0,0 +1,43 @@ +/** + * This file is auto generated. + * Do not edit manually. + */ + +import type { NameController } from './NameController.js'; + +/** + * Set the user specified name for a value. + * + * @param request - Request object. + * @param request.name - Name to set. + * @param request.sourceId - Optional ID of the source of the proposed name. + * @param request.type - Type of value to set the name for. + * @param request.value - Value to set the name for. + * @param request.variation - Variation of the raw value to set the name for. The chain ID if the type is Ethereum address. + */ +export type NameControllerSetNameAction = { + type: `NameController:setName`; + handler: NameController['setName']; +}; + +/** + * Generate the proposed names for a value using the name providers and store them in the state. + * + * @param request - Request object. + * @param request.value - Value to update the proposed names for. + * @param request.type - Type of value to update the proposed names for. + * @param request.sourceIds - Optional array of source IDs to limit which sources are used by the providers. If not provided, all sources in all providers will be used. + * @param request.variation - Variation of the raw value to update proposed names for. The chain ID if the type is Ethereum address. + * @returns The updated proposed names for the value. + */ +export type NameControllerUpdateProposedNamesAction = { + type: `NameController:updateProposedNames`; + handler: NameController['updateProposedNames']; +}; + +/** + * Union of all NameController action types. + */ +export type NameControllerMethodActions = + | NameControllerSetNameAction + | NameControllerUpdateProposedNamesAction; diff --git a/packages/name-controller/src/NameController.test.ts b/packages/name-controller/src/NameController.test.ts index 31ab2c6846a..496557dd6ce 100644 --- a/packages/name-controller/src/NameController.test.ts +++ b/packages/name-controller/src/NameController.test.ts @@ -1,6 +1,18 @@ -import { NameController } from './NameController'; -import type { NameProvider } from './types'; -import { NameType } from './types'; +import { deriveStateFromMetadata } from '@metamask/base-controller'; + +import type { + SetNameRequest, + UpdateProposedNamesRequest, + NameControllerState, +} from './NameController.js'; +import { + FALLBACK_VARIATION, + NameController, + NameOrigin, + PROPOSED_NAME_EXPIRE_DURATION, +} from './NameController.js'; +import type { NameProvider } from './types.js'; +import { NameType } from './types.js'; const NAME_MOCK = 'TestName'; const PROPOSED_NAME_MOCK = 'TestProposedName'; @@ -13,7 +25,11 @@ const TIME_MOCK = 123; const MESSENGER_MOCK = { registerActionHandler: jest.fn(), + registerMethodActionHandlers: jest.fn(), + registerInitialEventPayload: jest.fn(), publish: jest.fn(), + // TODO: Replace `any` with type + // eslint-disable-next-line @typescript-eslint/no-explicit-any } as any; const CONTROLLER_ARGS_MOCK = { @@ -21,12 +37,6 @@ const CONTROLLER_ARGS_MOCK = { providers: [], }; -// eslint-disable-next-line jest/prefer-spy-on -console.error = jest.fn(); - -// eslint-disable-next-line jest/prefer-spy-on -Date.now = jest.fn().mockReturnValue(TIME_MOCK * 1000); - /** * Creates a mock name provider. * @@ -63,6 +73,14 @@ function createMockProvider( } describe('NameController', () => { + beforeEach(() => { + jest.spyOn(console, 'error').mockImplementation(() => { + // do nothing + }); + + jest.spyOn(Date, 'now').mockReturnValue(TIME_MOCK * 1000); + }); + describe('setName', () => { it('creates an entry if new%s', () => { const provider1 = createMockProvider(1); @@ -80,12 +98,15 @@ describe('NameController', () => { variation: CHAIN_ID_MOCK, }); - expect(controller.state.names).toStrictEqual({ + expect(controller.state.names).toStrictEqual< + NameControllerState['names'] + >({ [NameType.ETHEREUM_ADDRESS]: { [VALUE_MOCK]: { [CHAIN_ID_MOCK]: { name: NAME_MOCK, sourceId: `${SOURCE_ID_MOCK}1`, + origin: NameOrigin.API, proposedNames: {}, }, }, @@ -99,25 +120,27 @@ describe('NameController', () => { const controller = new NameController({ ...CONTROLLER_ARGS_MOCK, providers: [provider1], - }); - - controller.state.names = { - [NameType.ETHEREUM_ADDRESS]: { - [VALUE_MOCK]: { - [CHAIN_ID_MOCK]: { - name: null, - sourceId: null, - proposedNames: { - [SOURCE_ID_MOCK]: { - proposedNames: [PROPOSED_NAME_MOCK, PROPOSED_NAME_2_MOCK], - lastRequestTime: null, - updateDelay: null, + state: { + names: { + [NameType.ETHEREUM_ADDRESS]: { + [VALUE_MOCK]: { + [CHAIN_ID_MOCK]: { + name: null, + sourceId: null, + origin: null, + proposedNames: { + [SOURCE_ID_MOCK]: { + proposedNames: [PROPOSED_NAME_MOCK, PROPOSED_NAME_2_MOCK], + lastRequestTime: null, + updateDelay: null, + }, + }, }, }, }, }, }, - }; + }); controller.setName({ value: VALUE_MOCK, @@ -127,12 +150,15 @@ describe('NameController', () => { variation: CHAIN_ID_MOCK, }); - expect(controller.state.names).toStrictEqual({ + expect(controller.state.names).toStrictEqual< + NameControllerState['names'] + >({ [NameType.ETHEREUM_ADDRESS]: { [VALUE_MOCK]: { [CHAIN_ID_MOCK]: { name: NAME_MOCK, sourceId: `${SOURCE_ID_MOCK}1`, + origin: NameOrigin.API, proposedNames: { [SOURCE_ID_MOCK]: { proposedNames: [PROPOSED_NAME_MOCK, PROPOSED_NAME_2_MOCK], @@ -147,25 +173,29 @@ describe('NameController', () => { }); it('removes source ID from entry if not specified', () => { - const controller = new NameController(CONTROLLER_ARGS_MOCK); - - controller.state.names = { - [NameType.ETHEREUM_ADDRESS]: { - [VALUE_MOCK]: { - [CHAIN_ID_MOCK]: { - name: null, - sourceId: SOURCE_ID_MOCK, - proposedNames: { - [SOURCE_ID_MOCK]: { - proposedNames: [PROPOSED_NAME_MOCK, PROPOSED_NAME_2_MOCK], - lastRequestTime: null, - updateDelay: null, + const controller = new NameController({ + ...CONTROLLER_ARGS_MOCK, + state: { + names: { + [NameType.ETHEREUM_ADDRESS]: { + [VALUE_MOCK]: { + [CHAIN_ID_MOCK]: { + name: null, + sourceId: SOURCE_ID_MOCK, + origin: NameOrigin.API, + proposedNames: { + [SOURCE_ID_MOCK]: { + proposedNames: [PROPOSED_NAME_MOCK, PROPOSED_NAME_2_MOCK], + lastRequestTime: null, + updateDelay: null, + }, + }, }, }, }, }, }, - }; + }); controller.setName({ value: VALUE_MOCK, @@ -174,12 +204,15 @@ describe('NameController', () => { variation: CHAIN_ID_MOCK, }); - expect(controller.state.names).toStrictEqual({ + expect(controller.state.names).toStrictEqual< + NameControllerState['names'] + >({ [NameType.ETHEREUM_ADDRESS]: { [VALUE_MOCK]: { [CHAIN_ID_MOCK]: { name: NAME_MOCK, sourceId: null, + origin: NameOrigin.API, proposedNames: { [SOURCE_ID_MOCK]: { proposedNames: [PROPOSED_NAME_MOCK, PROPOSED_NAME_2_MOCK], @@ -199,25 +232,27 @@ describe('NameController', () => { const controller = new NameController({ ...CONTROLLER_ARGS_MOCK, - }); - - controller.state.names = { - [NameType.ETHEREUM_ADDRESS]: { - [VALUE_MOCK]: { - [CHAIN_ID_MOCK]: { - name: NAME_MOCK, - sourceId: SOURCE_ID_MOCK, - proposedNames: { - [SOURCE_ID_MOCK]: { - proposedNames: [PROPOSED_NAME_MOCK, PROPOSED_NAME_2_MOCK], - lastRequestTime: TIME_MOCK, - updateDelay: null, + state: { + names: { + [NameType.ETHEREUM_ADDRESS]: { + [VALUE_MOCK]: { + [CHAIN_ID_MOCK]: { + name: NAME_MOCK, + sourceId: SOURCE_ID_MOCK, + origin: NameOrigin.API, + proposedNames: { + [SOURCE_ID_MOCK]: { + proposedNames: [PROPOSED_NAME_MOCK, PROPOSED_NAME_2_MOCK], + lastRequestTime: TIME_MOCK, + updateDelay: null, + }, + }, }, }, }, }, }, - }; + }); controller.setName({ value: VALUE_MOCK, @@ -226,12 +261,15 @@ describe('NameController', () => { variation: alternateChainId, }); - expect(controller.state.names).toStrictEqual({ + expect(controller.state.names).toStrictEqual< + NameControllerState['names'] + >({ [NameType.ETHEREUM_ADDRESS]: { [VALUE_MOCK]: { [CHAIN_ID_MOCK]: { name: NAME_MOCK, sourceId: SOURCE_ID_MOCK, + origin: NameOrigin.API, proposedNames: { [SOURCE_ID_MOCK]: { proposedNames: [PROPOSED_NAME_MOCK, PROPOSED_NAME_2_MOCK], @@ -243,6 +281,7 @@ describe('NameController', () => { [alternateChainId]: { name: alternateName, sourceId: null, + origin: NameOrigin.API, proposedNames: {}, }, }, @@ -256,14 +295,44 @@ describe('NameController', () => { const controller = new NameController({ ...CONTROLLER_ARGS_MOCK, providers: [provider1], + state: { + names: { + [NameType.ETHEREUM_ADDRESS]: { + [VALUE_MOCK]: { + [CHAIN_ID_MOCK]: { + name: NAME_MOCK, + sourceId: SOURCE_ID_MOCK, + origin: NameOrigin.API, + proposedNames: { + [SOURCE_ID_MOCK]: { + proposedNames: [PROPOSED_NAME_MOCK, PROPOSED_NAME_2_MOCK], + lastRequestTime: null, + updateDelay: null, + }, + }, + }, + }, + }, + }, + }, }); - controller.state.names = { + controller.setName({ + value: VALUE_MOCK, + type: NameType.ETHEREUM_ADDRESS, + name: null, + variation: CHAIN_ID_MOCK, + }); + + expect(controller.state.names).toStrictEqual< + NameControllerState['names'] + >({ [NameType.ETHEREUM_ADDRESS]: { [VALUE_MOCK]: { [CHAIN_ID_MOCK]: { - name: NAME_MOCK, - sourceId: SOURCE_ID_MOCK, + name: null, + sourceId: null, + origin: null, proposedNames: { [SOURCE_ID_MOCK]: { proposedNames: [PROPOSED_NAME_MOCK, PROPOSED_NAME_2_MOCK], @@ -274,21 +343,54 @@ describe('NameController', () => { }, }, }, - }; + }); + }); + + it('stores address as lowercase', () => { + const provider1 = createMockProvider(1); + + const controller = new NameController({ + ...CONTROLLER_ARGS_MOCK, + providers: [provider1], + state: { + names: { + [NameType.ETHEREUM_ADDRESS]: { + [VALUE_MOCK]: { + [CHAIN_ID_MOCK]: { + name: null, + sourceId: null, + origin: null, + proposedNames: { + [SOURCE_ID_MOCK]: { + proposedNames: [PROPOSED_NAME_MOCK, PROPOSED_NAME_2_MOCK], + lastRequestTime: null, + updateDelay: null, + }, + }, + }, + }, + }, + }, + }, + }); controller.setName({ - value: VALUE_MOCK, + value: 'tESTvALue', type: NameType.ETHEREUM_ADDRESS, - name: null, + name: NAME_MOCK, + sourceId: `${SOURCE_ID_MOCK}1`, variation: CHAIN_ID_MOCK, }); - expect(controller.state.names).toStrictEqual({ + expect(controller.state.names).toStrictEqual< + NameControllerState['names'] + >({ [NameType.ETHEREUM_ADDRESS]: { [VALUE_MOCK]: { [CHAIN_ID_MOCK]: { - name: null, - sourceId: null, + name: NAME_MOCK, + sourceId: `${SOURCE_ID_MOCK}1`, + origin: NameOrigin.API, proposedNames: { [SOURCE_ID_MOCK]: { proposedNames: [PROPOSED_NAME_MOCK, PROPOSED_NAME_2_MOCK], @@ -302,20 +404,52 @@ describe('NameController', () => { }); }); - it('stores address as lowercase', () => { + it('stores origin', () => { const provider1 = createMockProvider(1); const controller = new NameController({ ...CONTROLLER_ARGS_MOCK, providers: [provider1], + state: { + names: { + [NameType.ETHEREUM_ADDRESS]: { + [VALUE_MOCK]: { + [CHAIN_ID_MOCK]: { + name: null, + sourceId: null, + origin: null, + proposedNames: { + [SOURCE_ID_MOCK]: { + proposedNames: [PROPOSED_NAME_MOCK, PROPOSED_NAME_2_MOCK], + lastRequestTime: null, + updateDelay: null, + }, + }, + }, + }, + }, + }, + }, + }); + + controller.setName({ + value: VALUE_MOCK, + type: NameType.ETHEREUM_ADDRESS, + name: NAME_MOCK, + sourceId: `${SOURCE_ID_MOCK}1`, + origin: NameOrigin.ADDRESS_BOOK, + variation: CHAIN_ID_MOCK, }); - controller.state.names = { + expect(controller.state.names).toStrictEqual< + NameControllerState['names'] + >({ [NameType.ETHEREUM_ADDRESS]: { [VALUE_MOCK]: { [CHAIN_ID_MOCK]: { - name: null, - sourceId: null, + name: NAME_MOCK, + sourceId: `${SOURCE_ID_MOCK}1`, + origin: NameOrigin.ADDRESS_BOOK, proposedNames: { [SOURCE_ID_MOCK]: { proposedNames: [PROPOSED_NAME_MOCK, PROPOSED_NAME_2_MOCK], @@ -326,22 +460,54 @@ describe('NameController', () => { }, }, }, - }; + }); + }); + + it('does not update if passed unsafe input', () => { + const provider1 = createMockProvider(1); + + const controller = new NameController({ + ...CONTROLLER_ARGS_MOCK, + providers: [provider1], + state: { + names: { + [NameType.ETHEREUM_ADDRESS]: { + [VALUE_MOCK]: { + [CHAIN_ID_MOCK]: { + name: null, + sourceId: null, + origin: null, + proposedNames: { + [SOURCE_ID_MOCK]: { + proposedNames: [PROPOSED_NAME_MOCK, PROPOSED_NAME_2_MOCK], + lastRequestTime: null, + updateDelay: null, + }, + }, + }, + }, + }, + }, + }, + }); controller.setName({ - value: 'tESTvALue', + value: '__proto__', type: NameType.ETHEREUM_ADDRESS, name: NAME_MOCK, sourceId: `${SOURCE_ID_MOCK}1`, variation: CHAIN_ID_MOCK, }); - expect(controller.state.names).toStrictEqual({ + expect(controller.state.names).toStrictEqual< + NameControllerState['names'] + >({ [NameType.ETHEREUM_ADDRESS]: { [VALUE_MOCK]: { [CHAIN_ID_MOCK]: { - name: NAME_MOCK, - sourceId: `${SOURCE_ID_MOCK}1`, + name: null, + sourceId: null, + origin: null, proposedNames: { [SOURCE_ID_MOCK]: { proposedNames: [PROPOSED_NAME_MOCK, PROPOSED_NAME_2_MOCK], @@ -355,6 +521,19 @@ describe('NameController', () => { }); }); + it('does not throw if variation is fallback and type is Ethereum address', () => { + const controller = new NameController(CONTROLLER_ARGS_MOCK); + + expect(() => { + controller.setName({ + value: VALUE_MOCK, + type: NameType.ETHEREUM_ADDRESS, + name: NAME_MOCK, + variation: FALLBACK_VARIATION, + }); + }).not.toThrow(); + }); + describe('throws if', () => { it.each([ ['missing', undefined], @@ -369,7 +548,7 @@ describe('NameController', () => { type: NameType.ETHEREUM_ADDRESS, name: NAME_MOCK, variation: CHAIN_ID_MOCK, - } as any), + } as SetNameRequest), ).toThrow('Must specify a non-empty string for value.'); }); @@ -387,7 +566,7 @@ describe('NameController', () => { type, name: NAME_MOCK, variation: CHAIN_ID_MOCK, - } as any), + } as SetNameRequest), ).toThrow( `Must specify one of the following types: ${Object.values( NameType, @@ -408,7 +587,7 @@ describe('NameController', () => { type: NameType.ETHEREUM_ADDRESS, name, variation: CHAIN_ID_MOCK, - } as any), + } as SetNameRequest), ).toThrow('Must specify a non-empty string or null for name.'); }); @@ -425,7 +604,7 @@ describe('NameController', () => { name: NAME_MOCK, sourceId, variation: CHAIN_ID_MOCK, - } as any), + } as SetNameRequest), ).toThrow('Must specify a non-empty string for sourceId.'); }); @@ -443,9 +622,9 @@ describe('NameController', () => { type: NameType.ETHEREUM_ADDRESS, name: NAME_MOCK, variation, - } as any), + } as SetNameRequest), ).toThrow( - `Must specify a chain ID in hexidecimal format for variation when using '${NameType.ETHEREUM_ADDRESS}' type.`, + `Must specify a chain ID in hexadecimal format or the fallback, "*", for variation when using 'ethereumAddress' type.`, ); }); @@ -459,7 +638,7 @@ describe('NameController', () => { name: NAME_MOCK, sourceId: SOURCE_ID_MOCK, variation: CHAIN_ID_MOCK, - } as any), + }), ).toThrow( `Unknown source ID for type '${NameType.ETHEREUM_ADDRESS}': ${SOURCE_ID_MOCK}`, ); @@ -475,31 +654,61 @@ describe('NameController', () => { name: null, sourceId: SOURCE_ID_MOCK, variation: CHAIN_ID_MOCK, - } as any), + }), ).toThrow( `Cannot specify a source ID when clearing the saved name: ${SOURCE_ID_MOCK}`, ); }); + + it('origin is unrecognised', () => { + const controller = new NameController(CONTROLLER_ARGS_MOCK); + + expect(() => + controller.setName({ + value: VALUE_MOCK, + type: NameType.ETHEREUM_ADDRESS, + name: NAME_MOCK, + origin: 'invalid origin' as NameOrigin, + variation: CHAIN_ID_MOCK, + }), + ).toThrow(/Must specify one of the following origins/u); + }); + + it('origin is set but name is being cleared', () => { + const controller = new NameController(CONTROLLER_ARGS_MOCK); + + expect(() => + controller.setName({ + value: VALUE_MOCK, + type: NameType.ETHEREUM_ADDRESS, + name: null, + variation: CHAIN_ID_MOCK, + origin: NameOrigin.ADDRESS_BOOK, + }), + ).toThrow( + `Cannot specify an origin when clearing the saved name: ${NameOrigin.ADDRESS_BOOK}`, + ); + }); }); }); describe('updateProposedNames', () => { it.each([ - ['', (controller: NameController) => controller.state.names], - [' and no existing type state', () => ({})], + ['', {}], + [' and no existing type state', { names: {} }], ])( 'creates entry with proposed names if value is new%s', - async (_, getExistingState) => { + async (_, existingState) => { const provider1 = createMockProvider(1); const provider2 = createMockProvider(2, { updateDelay: 3 }); const controller = new NameController({ ...CONTROLLER_ARGS_MOCK, providers: [provider1, provider2], + // @ts-expect-error We are intentionally setting invalid state. + state: existingState, }); - controller.state.names = getExistingState(controller) as any; - const result = await controller.updateProposedNames({ value: VALUE_MOCK, type: NameType.ETHEREUM_ADDRESS, @@ -512,6 +721,7 @@ describe('NameController', () => { [CHAIN_ID_MOCK]: { name: null, sourceId: null, + origin: null, proposedNames: { [`${SOURCE_ID_MOCK}1`]: { proposedNames: [ @@ -563,30 +773,32 @@ describe('NameController', () => { const controller = new NameController({ ...CONTROLLER_ARGS_MOCK, providers: [provider1, provider2], - }); - - controller.state.names = { - [NameType.ETHEREUM_ADDRESS]: { - [VALUE_MOCK]: { - [CHAIN_ID_MOCK]: { - name: null, - sourceId: null, - proposedNames: { - [`${SOURCE_ID_MOCK}1`]: { - proposedNames: ['ShouldBeDeleted1'], - lastRequestTime: 12, - updateDelay: null, - }, - [`${SOURCE_ID_MOCK}2`]: { - proposedNames: ['ShouldBeDeleted2'], - lastRequestTime: 12, - updateDelay: null, + state: { + names: { + [NameType.ETHEREUM_ADDRESS]: { + [VALUE_MOCK]: { + [CHAIN_ID_MOCK]: { + name: null, + sourceId: null, + origin: null, + proposedNames: { + [`${SOURCE_ID_MOCK}1`]: { + proposedNames: ['ShouldBeDeleted1'], + lastRequestTime: 12, + updateDelay: null, + }, + [`${SOURCE_ID_MOCK}2`]: { + proposedNames: ['ShouldBeDeleted2'], + lastRequestTime: 12, + updateDelay: null, + }, + }, }, }, }, }, }, - }; + }); const result = await controller.updateProposedNames({ value: VALUE_MOCK, @@ -594,12 +806,15 @@ describe('NameController', () => { variation: CHAIN_ID_MOCK, }); - expect(controller.state.names).toStrictEqual({ + expect(controller.state.names).toStrictEqual< + NameControllerState['names'] + >({ [NameType.ETHEREUM_ADDRESS]: { [VALUE_MOCK]: { [CHAIN_ID_MOCK]: { name: null, sourceId: null, + origin: null, proposedNames: { [`${SOURCE_ID_MOCK}1`]: { proposedNames: [ @@ -650,25 +865,27 @@ describe('NameController', () => { const controller = new NameController({ ...CONTROLLER_ARGS_MOCK, providers: [provider1, provider2], - }); - - controller.state.names = { - [NameType.ETHEREUM_ADDRESS]: { - [VALUE_MOCK]: { - [CHAIN_ID_MOCK]: { - name: null, - sourceId: null, - proposedNames: { - [`${SOURCE_ID_MOCK}3`]: { - proposedNames: ['ShouldBeDeleted3'], - lastRequestTime: 12, - updateDelay: null, + state: { + names: { + [NameType.ETHEREUM_ADDRESS]: { + [VALUE_MOCK]: { + [CHAIN_ID_MOCK]: { + name: null, + sourceId: null, + origin: null, + proposedNames: { + [`${SOURCE_ID_MOCK}3`]: { + proposedNames: ['ShouldBeDeleted3'], + lastRequestTime: 12, + updateDelay: null, + }, + }, }, }, }, }, }, - }; + }); await controller.updateProposedNames({ value: VALUE_MOCK, @@ -676,12 +893,15 @@ describe('NameController', () => { variation: CHAIN_ID_MOCK, }); - expect(controller.state.names).toStrictEqual({ + expect(controller.state.names).toStrictEqual< + NameControllerState['names'] + >({ [NameType.ETHEREUM_ADDRESS]: { [VALUE_MOCK]: { [CHAIN_ID_MOCK]: { name: null, sourceId: null, + origin: null, proposedNames: { [`${SOURCE_ID_MOCK}1`]: { proposedNames: [ @@ -730,12 +950,15 @@ describe('NameController', () => { variation: CHAIN_ID_MOCK, }); - expect(controller.state.names).toStrictEqual({ + expect(controller.state.names).toStrictEqual< + NameControllerState['names'] + >({ [NameType.ETHEREUM_ADDRESS]: { [VALUE_MOCK]: { [CHAIN_ID_MOCK]: { name: null, sourceId: null, + origin: null, proposedNames: { [`${SOURCE_ID_MOCK}1`]: { proposedNames: [], @@ -780,16 +1003,17 @@ describe('NameController', () => { const controller = new NameController({ ...CONTROLLER_ARGS_MOCK, providers: [provider1, provider2], - }); - - controller.state.nameSources = { - [`${SOURCE_ID_MOCK}3`]: { - label: `${SOURCE_LABEL_MOCK}3`, - }, - [`${SOURCE_ID_MOCK}4`]: { - label: `${SOURCE_LABEL_MOCK}4`, + state: { + nameSources: { + [`${SOURCE_ID_MOCK}3`]: { + label: `${SOURCE_LABEL_MOCK}3`, + }, + [`${SOURCE_ID_MOCK}4`]: { + label: `${SOURCE_LABEL_MOCK}4`, + }, + }, }, - }; + }); await controller.updateProposedNames({ value: VALUE_MOCK, @@ -821,35 +1045,37 @@ describe('NameController', () => { const controller = new NameController({ ...CONTROLLER_ARGS_MOCK, providers: [provider1, provider2], - }); - - controller.state.names = { - [NameType.ETHEREUM_ADDRESS]: { - [VALUE_MOCK]: { - [CHAIN_ID_MOCK]: { - name: null, - sourceId: null, - proposedNames: { - [`${SOURCE_ID_MOCK}1`]: { - proposedNames: ['ShouldNotBeDeleted1'], - lastRequestTime: 12, - updateDelay: null, - }, - [`${SOURCE_ID_MOCK}2`]: { - proposedNames: ['ShouldNotBeDeleted2'], - lastRequestTime: 12, - updateDelay: null, - }, - [`${SOURCE_ID_MOCK}3`]: { - proposedNames: ['ShouldNotBeDeleted3'], - lastRequestTime: 12, - updateDelay: null, + state: { + names: { + [NameType.ETHEREUM_ADDRESS]: { + [VALUE_MOCK]: { + [CHAIN_ID_MOCK]: { + name: null, + sourceId: null, + origin: null, + proposedNames: { + [`${SOURCE_ID_MOCK}1`]: { + proposedNames: ['ShouldNotBeDeleted1'], + lastRequestTime: 12, + updateDelay: null, + }, + [`${SOURCE_ID_MOCK}2`]: { + proposedNames: ['ShouldNotBeDeleted2'], + lastRequestTime: 12, + updateDelay: null, + }, + [`${SOURCE_ID_MOCK}3`]: { + proposedNames: ['ShouldNotBeDeleted3'], + lastRequestTime: 12, + updateDelay: null, + }, + }, }, }, }, }, }, - }; + }); await controller.updateProposedNames({ value: VALUE_MOCK, @@ -857,12 +1083,15 @@ describe('NameController', () => { variation: alternateChainId, }); - expect(controller.state.names).toStrictEqual({ + expect(controller.state.names).toStrictEqual< + NameControllerState['names'] + >({ [NameType.ETHEREUM_ADDRESS]: { [VALUE_MOCK]: { [CHAIN_ID_MOCK]: { name: null, sourceId: null, + origin: null, proposedNames: { [`${SOURCE_ID_MOCK}1`]: { proposedNames: ['ShouldNotBeDeleted1'], @@ -884,6 +1113,7 @@ describe('NameController', () => { [alternateChainId]: { name: null, sourceId: null, + origin: null, proposedNames: { [`${SOURCE_ID_MOCK}1`]: { proposedNames: [ @@ -939,12 +1169,15 @@ describe('NameController', () => { variation: CHAIN_ID_MOCK, }); - expect(controller.state.names).toStrictEqual({ + expect(controller.state.names).toStrictEqual< + NameControllerState['names'] + >({ [NameType.ETHEREUM_ADDRESS]: { [VALUE_MOCK]: { [CHAIN_ID_MOCK]: { name: null, sourceId: null, + origin: null, proposedNames: { [`${SOURCE_ID_MOCK}1`]: { proposedNames: [ @@ -988,6 +1221,8 @@ describe('NameController', () => { ], }, }, + // TODO: Replace `any` with type + // eslint-disable-next-line @typescript-eslint/no-explicit-any } as any); const controller = new NameController({ @@ -1001,12 +1236,15 @@ describe('NameController', () => { variation: CHAIN_ID_MOCK, }); - expect(controller.state.names).toStrictEqual({ + expect(controller.state.names).toStrictEqual< + NameControllerState['names'] + >({ [NameType.ETHEREUM_ADDRESS]: { [VALUE_MOCK]: { [CHAIN_ID_MOCK]: { name: null, sourceId: null, + origin: null, proposedNames: { [`${SOURCE_ID_MOCK}1`]: { proposedNames: [ @@ -1041,25 +1279,27 @@ describe('NameController', () => { const controller = new NameController({ ...CONTROLLER_ARGS_MOCK, providers: [provider1], - }); - - controller.state.names = { - [NameType.ETHEREUM_ADDRESS]: { - [VALUE_MOCK]: { - [CHAIN_ID_MOCK]: { - name: NAME_MOCK, - sourceId: `${SOURCE_ID_MOCK}1`, - proposedNames: { - [`${SOURCE_ID_MOCK}1`]: { - proposedNames: [], - lastRequestTime: null, - updateDelay: null, + state: { + names: { + [NameType.ETHEREUM_ADDRESS]: { + [VALUE_MOCK]: { + [CHAIN_ID_MOCK]: { + name: NAME_MOCK, + sourceId: `${SOURCE_ID_MOCK}1`, + origin: NameOrigin.API, + proposedNames: { + [`${SOURCE_ID_MOCK}1`]: { + proposedNames: [], + lastRequestTime: null, + updateDelay: null, + }, + }, }, }, }, }, }, - }; + }); await controller.updateProposedNames({ value: 'tESTvALue', @@ -1067,12 +1307,15 @@ describe('NameController', () => { variation: CHAIN_ID_MOCK, }); - expect(controller.state.names).toStrictEqual({ + expect(controller.state.names).toStrictEqual< + NameControllerState['names'] + >({ [NameType.ETHEREUM_ADDRESS]: { [VALUE_MOCK]: { [CHAIN_ID_MOCK]: { name: NAME_MOCK, sourceId: `${SOURCE_ID_MOCK}1`, + origin: NameOrigin.API, proposedNames: { [`${SOURCE_ID_MOCK}1`]: { proposedNames: [ @@ -1107,43 +1350,48 @@ describe('NameController', () => { const controller = new NameController({ ...CONTROLLER_ARGS_MOCK, providers: [provider1, provider2], + state: { + names: { + [NameType.ETHEREUM_ADDRESS]: { + [VALUE_MOCK]: { + [CHAIN_ID_MOCK]: { + name: null, + sourceId: null, + origin: null, + proposedNames: { + [`${SOURCE_ID_MOCK}1`]: { + proposedNames: ['ShouldNotBeUpdated1'], + lastRequestTime: 11, + updateDelay: 1, + }, + [`${SOURCE_ID_MOCK}2`]: { + proposedNames: ['ShouldNotBeUpdated2'], + lastRequestTime: 12, + updateDelay: 2, + }, + }, + }, + }, + }, + }, + }, + }); + + await controller.updateProposedNames({ + value: VALUE_MOCK, + type: NameType.ETHEREUM_ADDRESS, + variation: CHAIN_ID_MOCK, }); - controller.state.names = { - [NameType.ETHEREUM_ADDRESS]: { - [VALUE_MOCK]: { - [CHAIN_ID_MOCK]: { - name: null, - sourceId: null, - proposedNames: { - [`${SOURCE_ID_MOCK}1`]: { - proposedNames: ['ShouldNotBeUpdated1'], - lastRequestTime: 11, - updateDelay: 1, - }, - [`${SOURCE_ID_MOCK}2`]: { - proposedNames: ['ShouldNotBeUpdated2'], - lastRequestTime: 12, - updateDelay: 2, - }, - }, - }, - }, - }, - }; - - await controller.updateProposedNames({ - value: VALUE_MOCK, - type: NameType.ETHEREUM_ADDRESS, - variation: CHAIN_ID_MOCK, - }); - - expect(controller.state.names).toStrictEqual({ + expect(controller.state.names).toStrictEqual< + NameControllerState['names'] + >({ [NameType.ETHEREUM_ADDRESS]: { [VALUE_MOCK]: { [CHAIN_ID_MOCK]: { name: null, sourceId: null, + origin: null, proposedNames: { [`${SOURCE_ID_MOCK}1`]: { proposedNames: ['ShouldNotBeUpdated1'], @@ -1177,25 +1425,27 @@ describe('NameController', () => { const controller = new NameController({ ...CONTROLLER_ARGS_MOCK, providers: [provider1], - }); - - controller.state.names = { - [NameType.ETHEREUM_ADDRESS]: { - [VALUE_MOCK]: { - [CHAIN_ID_MOCK]: { - name: null, - sourceId: null, - proposedNames: { - [`${SOURCE_ID_MOCK}1`]: { - proposedNames: ['ShouldNotBeUpdated1'], - lastRequestTime: 11, - updateDelay: 1, + state: { + names: { + [NameType.ETHEREUM_ADDRESS]: { + [VALUE_MOCK]: { + [CHAIN_ID_MOCK]: { + name: null, + sourceId: null, + origin: null, + proposedNames: { + [`${SOURCE_ID_MOCK}1`]: { + proposedNames: ['ShouldNotBeUpdated1'], + lastRequestTime: 11, + updateDelay: 1, + }, + }, }, }, }, }, }, - }; + }); await controller.updateProposedNames({ value: VALUE_MOCK, @@ -1203,12 +1453,15 @@ describe('NameController', () => { variation: CHAIN_ID_MOCK, }); - expect(controller.state.names).toStrictEqual({ + expect(controller.state.names).toStrictEqual< + NameControllerState['names'] + >({ [NameType.ETHEREUM_ADDRESS]: { [VALUE_MOCK]: { [CHAIN_ID_MOCK]: { name: null, sourceId: null, + origin: null, proposedNames: { [`${SOURCE_ID_MOCK}1`]: { proposedNames: ['ShouldNotBeUpdated1'], @@ -1237,25 +1490,27 @@ describe('NameController', () => { const controller = new NameController({ ...CONTROLLER_ARGS_MOCK, providers: [provider1], - }); - - controller.state.names = { - [NameType.ETHEREUM_ADDRESS]: { - [VALUE_MOCK]: { - [CHAIN_ID_MOCK]: { - name: null, - sourceId: null, - proposedNames: { - [`${SOURCE_ID_MOCK}1`]: { - proposedNames: ['ShouldNotBeUpdated1'], - lastRequestTime: 11, - updateDelay: 1, + state: { + names: { + [NameType.ETHEREUM_ADDRESS]: { + [VALUE_MOCK]: { + [CHAIN_ID_MOCK]: { + name: null, + sourceId: null, + origin: null, + proposedNames: { + [`${SOURCE_ID_MOCK}1`]: { + proposedNames: ['ShouldNotBeUpdated1'], + lastRequestTime: 11, + updateDelay: 1, + }, + }, }, }, }, }, }, - }; + }); await controller.updateProposedNames({ value: VALUE_MOCK, @@ -1263,12 +1518,15 @@ describe('NameController', () => { variation: CHAIN_ID_MOCK, }); - expect(controller.state.names).toStrictEqual({ + expect(controller.state.names).toStrictEqual< + NameControllerState['names'] + >({ [NameType.ETHEREUM_ADDRESS]: { [VALUE_MOCK]: { [CHAIN_ID_MOCK]: { name: null, sourceId: null, + origin: null, proposedNames: { [`${SOURCE_ID_MOCK}1`]: { proposedNames: ['ShouldNotBeUpdated1'], @@ -1302,12 +1560,15 @@ describe('NameController', () => { variation: CHAIN_ID_MOCK, }); - expect(controller.state.names).toStrictEqual({ + expect(controller.state.names).toStrictEqual< + NameControllerState['names'] + >({ [NameType.ETHEREUM_ADDRESS]: { [VALUE_MOCK]: { [CHAIN_ID_MOCK]: { name: null, sourceId: null, + origin: null, proposedNames: { [`${SOURCE_ID_MOCK}1`]: { proposedNames: [], @@ -1366,12 +1627,15 @@ describe('NameController', () => { variation: CHAIN_ID_MOCK, }); - expect(controller.state.names).toStrictEqual({ + expect(controller.state.names).toStrictEqual< + NameControllerState['names'] + >({ [NameType.ETHEREUM_ADDRESS]: { [VALUE_MOCK]: { [CHAIN_ID_MOCK]: { name: null, sourceId: null, + origin: null, proposedNames: { [`${SOURCE_ID_MOCK}1`]: { proposedNames: [], @@ -1409,7 +1673,7 @@ describe('NameController', () => { }); }); - it('stores emtpy array if result error while getting proposed name using provider', async () => { + it('stores empty array if result error while getting proposed name using provider', async () => { const provider1 = createMockProvider(1); const provider2 = createMockProvider(2); const error = new Error('TestError'); @@ -1433,12 +1697,15 @@ describe('NameController', () => { variation: CHAIN_ID_MOCK, }); - expect(controller.state.names).toStrictEqual({ + expect(controller.state.names).toStrictEqual< + NameControllerState['names'] + >({ [NameType.ETHEREUM_ADDRESS]: { [VALUE_MOCK]: { [CHAIN_ID_MOCK]: { name: null, sourceId: null, + origin: null, proposedNames: { [`${SOURCE_ID_MOCK}1`]: { proposedNames: [], @@ -1486,35 +1753,37 @@ describe('NameController', () => { const controller = new NameController({ ...CONTROLLER_ARGS_MOCK, providers: [provider1, provider2, provider3], - }); - - controller.state.names = { - [NameType.ETHEREUM_ADDRESS]: { - [VALUE_MOCK]: { - [CHAIN_ID_MOCK]: { - name: null, - sourceId: null, - proposedNames: { - [`${SOURCE_ID_MOCK}1`]: { - proposedNames: ['ShouldNotBeDeleted1'], - lastRequestTime: 12, - updateDelay: null, - }, - [`${SOURCE_ID_MOCK}2`]: { - proposedNames: ['ShouldBeDeleted2'], - lastRequestTime: 12, - updateDelay: null, - }, - [`${SOURCE_ID_MOCK}3`]: { - proposedNames: ['ShouldNotBeDeleted3'], - lastRequestTime: 12, - updateDelay: null, + state: { + names: { + [NameType.ETHEREUM_ADDRESS]: { + [VALUE_MOCK]: { + [CHAIN_ID_MOCK]: { + name: null, + sourceId: null, + origin: null, + proposedNames: { + [`${SOURCE_ID_MOCK}1`]: { + proposedNames: ['ShouldNotBeDeleted1'], + lastRequestTime: 12, + updateDelay: null, + }, + [`${SOURCE_ID_MOCK}2`]: { + proposedNames: ['ShouldBeDeleted2'], + lastRequestTime: 12, + updateDelay: null, + }, + [`${SOURCE_ID_MOCK}3`]: { + proposedNames: ['ShouldNotBeDeleted3'], + lastRequestTime: 12, + updateDelay: null, + }, + }, }, }, }, }, }, - }; + }); const result = await controller.updateProposedNames({ value: VALUE_MOCK, @@ -1523,12 +1792,15 @@ describe('NameController', () => { variation: CHAIN_ID_MOCK, }); - expect(controller.state.names).toStrictEqual({ + expect(controller.state.names).toStrictEqual< + NameControllerState['names'] + >({ [NameType.ETHEREUM_ADDRESS]: { [VALUE_MOCK]: { [CHAIN_ID_MOCK]: { name: null, sourceId: null, + origin: null, proposedNames: { [`${SOURCE_ID_MOCK}1`]: { proposedNames: [`ShouldNotBeDeleted1`], @@ -1641,12 +1913,15 @@ describe('NameController', () => { variation: CHAIN_ID_MOCK, }); - expect(controller.state.names).toStrictEqual({ + expect(controller.state.names).toStrictEqual< + NameControllerState['names'] + >({ [NameType.ETHEREUM_ADDRESS]: { [VALUE_MOCK]: { [CHAIN_ID_MOCK]: { name: null, sourceId: null, + origin: null, proposedNames: { [`${SOURCE_ID_MOCK}1`]: { proposedNames: [ @@ -1694,7 +1969,7 @@ describe('NameController', () => { value, type: NameType.ETHEREUM_ADDRESS, variation: CHAIN_ID_MOCK, - } as any), + } as UpdateProposedNamesRequest), ).rejects.toThrow('Must specify a non-empty string for value.'); }); @@ -1716,7 +1991,7 @@ describe('NameController', () => { value: VALUE_MOCK, type, variation: CHAIN_ID_MOCK, - } as any), + } as UpdateProposedNamesRequest), ).rejects.toThrow( `Must specify one of the following types: ${Object.values( NameType, @@ -1739,9 +2014,11 @@ describe('NameController', () => { value: VALUE_MOCK, type: NameType.ETHEREUM_ADDRESS, variation, + // TODO: Replace `any` with type + // eslint-disable-next-line @typescript-eslint/no-explicit-any } as any), ).rejects.toThrow( - `Must specify a chain ID in hexidecimal format for variation when using '${NameType.ETHEREUM_ADDRESS}' type.`, + `Must specify a chain ID in hexadecimal format or the fallback, "*", for variation when using 'ethereumAddress' type.`, ); }, ); @@ -1799,30 +2076,32 @@ describe('NameController', () => { ...CONTROLLER_ARGS_MOCK, providers: [provider1, provider2], updateDelay: 123, - }); - - controller.state.names = { - [NameType.ETHEREUM_ADDRESS]: { - [VALUE_MOCK]: { - [CHAIN_ID_MOCK]: { - name: null, - sourceId: null, - proposedNames: { - [`${SOURCE_ID_MOCK}1`]: { - proposedNames: ['ShouldNotBeUpdated1'], - lastRequestTime: TIME_MOCK - 122, - updateDelay: null, - }, - [`${SOURCE_ID_MOCK}2`]: { - proposedNames: ['ShouldNotBeUpdated2'], - lastRequestTime: TIME_MOCK - 121, - updateDelay: null, + state: { + names: { + [NameType.ETHEREUM_ADDRESS]: { + [VALUE_MOCK]: { + [CHAIN_ID_MOCK]: { + name: null, + sourceId: null, + origin: null, + proposedNames: { + [`${SOURCE_ID_MOCK}1`]: { + proposedNames: ['ShouldNotBeUpdated1'], + lastRequestTime: TIME_MOCK - 122, + updateDelay: null, + }, + [`${SOURCE_ID_MOCK}2`]: { + proposedNames: ['ShouldNotBeUpdated2'], + lastRequestTime: TIME_MOCK - 121, + updateDelay: null, + }, + }, }, }, }, }, }, - }; + }); const result = await controller.updateProposedNames({ value: VALUE_MOCK, @@ -1831,12 +2110,15 @@ describe('NameController', () => { variation: CHAIN_ID_MOCK, }); - expect(controller.state.names).toStrictEqual({ + expect(controller.state.names).toStrictEqual< + NameControllerState['names'] + >({ [NameType.ETHEREUM_ADDRESS]: { [VALUE_MOCK]: { [CHAIN_ID_MOCK]: { name: null, sourceId: null, + origin: null, proposedNames: { [`${SOURCE_ID_MOCK}1`]: { proposedNames: ['ShouldNotBeUpdated1'], @@ -1866,30 +2148,32 @@ describe('NameController', () => { const controller = new NameController({ ...CONTROLLER_ARGS_MOCK, providers: [provider1, provider2], - }); - - controller.state.names = { - [NameType.ETHEREUM_ADDRESS]: { - [VALUE_MOCK]: { - [CHAIN_ID_MOCK]: { - name: null, - sourceId: null, - proposedNames: { - [`${SOURCE_ID_MOCK}1`]: { - proposedNames: ['ShouldNotBeUpdated1'], - lastRequestTime: TIME_MOCK - 9, - updateDelay: 10, - }, - [`${SOURCE_ID_MOCK}2`]: { - proposedNames: ['ShouldNotBeUpdated2'], - lastRequestTime: TIME_MOCK - 6, - updateDelay: 7, + state: { + names: { + [NameType.ETHEREUM_ADDRESS]: { + [VALUE_MOCK]: { + [CHAIN_ID_MOCK]: { + name: null, + sourceId: null, + origin: null, + proposedNames: { + [`${SOURCE_ID_MOCK}1`]: { + proposedNames: ['ShouldNotBeUpdated1'], + lastRequestTime: TIME_MOCK - 9, + updateDelay: 10, + }, + [`${SOURCE_ID_MOCK}2`]: { + proposedNames: ['ShouldNotBeUpdated2'], + lastRequestTime: TIME_MOCK - 6, + updateDelay: 7, + }, + }, }, }, }, }, }, - }; + }); const result = await controller.updateProposedNames({ value: VALUE_MOCK, @@ -1898,12 +2182,15 @@ describe('NameController', () => { variation: CHAIN_ID_MOCK, }); - expect(controller.state.names).toStrictEqual({ + expect(controller.state.names).toStrictEqual< + NameControllerState['names'] + >({ [NameType.ETHEREUM_ADDRESS]: { [VALUE_MOCK]: { [CHAIN_ID_MOCK]: { name: null, sourceId: null, + origin: null, proposedNames: { [`${SOURCE_ID_MOCK}1`]: { proposedNames: ['ShouldNotBeUpdated1'], @@ -1934,30 +2221,32 @@ describe('NameController', () => { ...CONTROLLER_ARGS_MOCK, providers: [provider1, provider2], updateDelay: 123, - }); - - controller.state.names = { - [NameType.ETHEREUM_ADDRESS]: { - [VALUE_MOCK]: { - [CHAIN_ID_MOCK]: { - name: null, - sourceId: null, - proposedNames: { - [`${SOURCE_ID_MOCK}1`]: { - proposedNames: ['ShouldNotBeUpdated1'], - lastRequestTime: TIME_MOCK - 123, - updateDelay: null, - }, - [`${SOURCE_ID_MOCK}2`]: { - proposedNames: ['ShouldNotBeUpdated2'], - lastRequestTime: TIME_MOCK - 124, - updateDelay: null, + state: { + names: { + [NameType.ETHEREUM_ADDRESS]: { + [VALUE_MOCK]: { + [CHAIN_ID_MOCK]: { + name: null, + sourceId: null, + origin: null, + proposedNames: { + [`${SOURCE_ID_MOCK}1`]: { + proposedNames: ['ShouldNotBeUpdated1'], + lastRequestTime: TIME_MOCK - 123, + updateDelay: null, + }, + [`${SOURCE_ID_MOCK}2`]: { + proposedNames: ['ShouldNotBeUpdated2'], + lastRequestTime: TIME_MOCK - 124, + updateDelay: null, + }, + }, }, }, }, }, }, - }; + }); const result = await controller.updateProposedNames({ value: VALUE_MOCK, @@ -1966,12 +2255,15 @@ describe('NameController', () => { variation: CHAIN_ID_MOCK, }); - expect(controller.state.names).toStrictEqual({ + expect(controller.state.names).toStrictEqual< + NameControllerState['names'] + >({ [NameType.ETHEREUM_ADDRESS]: { [VALUE_MOCK]: { [CHAIN_ID_MOCK]: { name: null, sourceId: null, + origin: null, proposedNames: { [`${SOURCE_ID_MOCK}1`]: { proposedNames: [ @@ -2022,30 +2314,32 @@ describe('NameController', () => { const controller = new NameController({ ...CONTROLLER_ARGS_MOCK, providers: [provider1, provider2], - }); - - controller.state.names = { - [NameType.ETHEREUM_ADDRESS]: { - [VALUE_MOCK]: { - [CHAIN_ID_MOCK]: { - name: null, - sourceId: null, - proposedNames: { - [`${SOURCE_ID_MOCK}1`]: { - proposedNames: ['ShouldNotBeUpdated1'], - lastRequestTime: TIME_MOCK - 10, - updateDelay: 10, - }, - [`${SOURCE_ID_MOCK}2`]: { - proposedNames: ['ShouldNotBeUpdated2'], - lastRequestTime: TIME_MOCK - 16, - updateDelay: 15, + state: { + names: { + [NameType.ETHEREUM_ADDRESS]: { + [VALUE_MOCK]: { + [CHAIN_ID_MOCK]: { + name: null, + sourceId: null, + origin: null, + proposedNames: { + [`${SOURCE_ID_MOCK}1`]: { + proposedNames: ['ShouldNotBeUpdated1'], + lastRequestTime: TIME_MOCK - 10, + updateDelay: 10, + }, + [`${SOURCE_ID_MOCK}2`]: { + proposedNames: ['ShouldNotBeUpdated2'], + lastRequestTime: TIME_MOCK - 16, + updateDelay: 15, + }, + }, }, }, }, }, }, - }; + }); const result = await controller.updateProposedNames({ value: VALUE_MOCK, @@ -2054,12 +2348,15 @@ describe('NameController', () => { variation: CHAIN_ID_MOCK, }); - expect(controller.state.names).toStrictEqual({ + expect(controller.state.names).toStrictEqual< + NameControllerState['names'] + >({ [NameType.ETHEREUM_ADDRESS]: { [VALUE_MOCK]: { [CHAIN_ID_MOCK]: { name: null, sourceId: null, + origin: null, proposedNames: { [`${SOURCE_ID_MOCK}1`]: { proposedNames: [ @@ -2110,10 +2407,12 @@ describe('NameController', () => { const controller = new NameController({ ...CONTROLLER_ARGS_MOCK, providers: [provider1, provider2], + state: { + // @ts-expect-error We are intentionally setting invalid state. + names: {}, + }, }); - controller.state.names = {} as any; - const result = await controller.updateProposedNames({ value: VALUE_MOCK, type: NameType.ETHEREUM_ADDRESS, @@ -2121,12 +2420,15 @@ describe('NameController', () => { variation: CHAIN_ID_MOCK, }); - expect(controller.state.names).toStrictEqual({ + expect(controller.state.names).toStrictEqual< + NameControllerState['names'] + >({ [NameType.ETHEREUM_ADDRESS]: { [VALUE_MOCK]: { [CHAIN_ID_MOCK]: { name: null, sourceId: null, + origin: null, proposedNames: { [`${SOURCE_ID_MOCK}1`]: { proposedNames: [ @@ -2170,5 +2472,369 @@ describe('NameController', () => { }); }); }); + + describe('removes entries', () => { + it('if all proposed names are expired', async () => { + const provider1 = createMockProvider(1); + const provider2 = createMockProvider(2); + + const controller = new NameController({ + ...CONTROLLER_ARGS_MOCK, + providers: [provider1, provider2], + state: { + names: { + [NameType.ETHEREUM_ADDRESS]: { + [VALUE_MOCK]: { + [CHAIN_ID_MOCK]: { + name: null, + sourceId: null, + origin: null, + proposedNames: { + [`${SOURCE_ID_MOCK}1`]: { + proposedNames: ['ExpiredName'], + lastRequestTime: + TIME_MOCK - PROPOSED_NAME_EXPIRE_DURATION - 1, + updateDelay: null, + }, + [`${SOURCE_ID_MOCK}2`]: { + proposedNames: ['AnotherExpiredName'], + lastRequestTime: + TIME_MOCK - PROPOSED_NAME_EXPIRE_DURATION - 2, + updateDelay: null, + }, + }, + }, + }, + }, + }, + }, + }); + + await controller.updateProposedNames({ + value: 'another value', + type: NameType.ETHEREUM_ADDRESS, + variation: CHAIN_ID_MOCK, + }); + + expect( + controller.state.names[NameType.ETHEREUM_ADDRESS][VALUE_MOCK][ + CHAIN_ID_MOCK + ], + ).toBeUndefined(); + }); + + it('if all proposed names are expired then updates entry with new proposed names', async () => { + const provider1 = createMockProvider(1); + const provider2 = createMockProvider(2); + + const controller = new NameController({ + ...CONTROLLER_ARGS_MOCK, + providers: [provider1, provider2], + state: { + names: { + [NameType.ETHEREUM_ADDRESS]: { + [`${VALUE_MOCK}1`]: { + [CHAIN_ID_MOCK]: { + name: null, + sourceId: null, + origin: null, + proposedNames: { + [`${SOURCE_ID_MOCK}1`]: { + proposedNames: ['NotExpiredName'], + lastRequestTime: null, + updateDelay: null, + }, + }, + }, + }, + [VALUE_MOCK]: { + [CHAIN_ID_MOCK]: { + name: null, + sourceId: null, + origin: null, + proposedNames: { + [`${SOURCE_ID_MOCK}1`]: { + proposedNames: ['ExpiredName'], + lastRequestTime: + TIME_MOCK - PROPOSED_NAME_EXPIRE_DURATION - 1, + updateDelay: null, + }, + [`${SOURCE_ID_MOCK}2`]: { + proposedNames: ['AnotherExpiredName'], + lastRequestTime: null, + updateDelay: null, + }, + }, + }, + }, + }, + }, + }, + }); + + await controller.updateProposedNames({ + value: VALUE_MOCK, + type: NameType.ETHEREUM_ADDRESS, + variation: CHAIN_ID_MOCK, + }); + + expect(controller.state.names).toStrictEqual< + NameControllerState['names'] + >({ + [NameType.ETHEREUM_ADDRESS]: { + [`${VALUE_MOCK}1`]: { + [CHAIN_ID_MOCK]: { + name: null, + sourceId: null, + origin: null, + proposedNames: { + [`${SOURCE_ID_MOCK}1`]: { + proposedNames: ['NotExpiredName'], + lastRequestTime: null, + updateDelay: null, + }, + }, + }, + }, + [VALUE_MOCK]: { + [CHAIN_ID_MOCK]: { + name: null, + sourceId: null, + origin: null, + proposedNames: { + [`${SOURCE_ID_MOCK}1`]: { + proposedNames: [ + `${PROPOSED_NAME_MOCK}1`, + `${PROPOSED_NAME_MOCK}1_2`, + ], + lastRequestTime: TIME_MOCK, + updateDelay: null, + }, + [`${SOURCE_ID_MOCK}2`]: { + proposedNames: [ + `${PROPOSED_NAME_MOCK}2`, + `${PROPOSED_NAME_MOCK}2_2`, + ], + lastRequestTime: TIME_MOCK, + updateDelay: null, + }, + }, + }, + }, + }, + }); + }); + }); + + describe('does not remove entries', () => { + it('if any proposed name is not expired yet', async () => { + const provider1 = createMockProvider(1); + const provider2 = createMockProvider(2); + + const controller = new NameController({ + ...CONTROLLER_ARGS_MOCK, + providers: [provider1, provider2], + state: { + names: { + [NameType.ETHEREUM_ADDRESS]: { + [VALUE_MOCK]: { + [CHAIN_ID_MOCK]: { + name: null, + sourceId: null, + origin: null, + proposedNames: { + [`${SOURCE_ID_MOCK}1`]: { + proposedNames: ['ExpiredName'], + lastRequestTime: + TIME_MOCK - PROPOSED_NAME_EXPIRE_DURATION - 1, + updateDelay: null, + }, + [`${SOURCE_ID_MOCK}2`]: { + proposedNames: ['NotExpiredName'], + lastRequestTime: + TIME_MOCK - PROPOSED_NAME_EXPIRE_DURATION + 1, + updateDelay: null, + }, + }, + }, + }, + }, + }, + }, + }); + + await controller.updateProposedNames({ + value: 'another value', + type: NameType.ETHEREUM_ADDRESS, + variation: CHAIN_ID_MOCK, + }); + + expect(controller.state.names[NameType.ETHEREUM_ADDRESS]).toStrictEqual( + expect.objectContaining({ + [VALUE_MOCK]: { + [CHAIN_ID_MOCK]: { + name: null, + sourceId: null, + origin: null, + proposedNames: { + [`${SOURCE_ID_MOCK}1`]: { + proposedNames: ['ExpiredName'], + lastRequestTime: + TIME_MOCK - PROPOSED_NAME_EXPIRE_DURATION - 1, + updateDelay: null, + }, + [`${SOURCE_ID_MOCK}2`]: { + proposedNames: ['NotExpiredName'], + lastRequestTime: + TIME_MOCK - PROPOSED_NAME_EXPIRE_DURATION + 1, + updateDelay: null, + }, + }, + }, + }, + }), + ); + }); + + it('if name is defined', async () => { + const provider1 = createMockProvider(1); + const provider2 = createMockProvider(2); + + const controller = new NameController({ + ...CONTROLLER_ARGS_MOCK, + providers: [provider1, provider2], + state: { + names: { + [NameType.ETHEREUM_ADDRESS]: { + [VALUE_MOCK]: { + [CHAIN_ID_MOCK]: { + name: 'A defined name', + sourceId: null, + origin: null, + proposedNames: { + [`${SOURCE_ID_MOCK}1`]: { + proposedNames: ['ExpiredName'], + lastRequestTime: + TIME_MOCK - PROPOSED_NAME_EXPIRE_DURATION - 1, + updateDelay: null, + }, + }, + }, + }, + }, + }, + }, + }); + + await controller.updateProposedNames({ + value: 'another value', + type: NameType.ETHEREUM_ADDRESS, + variation: CHAIN_ID_MOCK, + }); + + expect(controller.state.names[NameType.ETHEREUM_ADDRESS]).toStrictEqual( + expect.objectContaining({ + [VALUE_MOCK]: { + [CHAIN_ID_MOCK]: { + name: 'A defined name', + sourceId: null, + origin: null, + proposedNames: { + [`${SOURCE_ID_MOCK}1`]: { + proposedNames: ['ExpiredName'], + lastRequestTime: + TIME_MOCK - PROPOSED_NAME_EXPIRE_DURATION - 1, + updateDelay: null, + }, + }, + }, + }, + }), + ); + }); + }); + }); + + describe('metadata', () => { + it('includes expected state in debug snapshots', () => { + const controller = new NameController({ + ...CONTROLLER_ARGS_MOCK, + providers: [createMockProvider(1)], + }); + + expect( + deriveStateFromMetadata( + controller.state, + controller.metadata, + 'includeInDebugSnapshot', + ), + ).toMatchInlineSnapshot(`{}`); + }); + + it('includes expected state in state logs', () => { + const controller = new NameController({ + ...CONTROLLER_ARGS_MOCK, + providers: [createMockProvider(1)], + }); + + expect( + deriveStateFromMetadata( + controller.state, + controller.metadata, + 'includeInStateLogs', + ), + ).toMatchInlineSnapshot(` + { + "nameSources": {}, + "names": { + "ethereumAddress": {}, + }, + } + `); + }); + + it('persists expected state', () => { + const controller = new NameController({ + ...CONTROLLER_ARGS_MOCK, + providers: [createMockProvider(1)], + }); + + expect( + deriveStateFromMetadata( + controller.state, + controller.metadata, + 'persist', + ), + ).toMatchInlineSnapshot(` + { + "nameSources": {}, + "names": { + "ethereumAddress": {}, + }, + } + `); + }); + + it('exposes expected state to UI', () => { + const controller = new NameController({ + ...CONTROLLER_ARGS_MOCK, + providers: [createMockProvider(1)], + }); + + expect( + deriveStateFromMetadata( + controller.state, + controller.metadata, + 'usedInUi', + ), + ).toMatchInlineSnapshot(` + { + "nameSources": {}, + "names": { + "ethereumAddress": {}, + }, + } + `); + }); }); }); diff --git a/packages/name-controller/src/NameController.ts b/packages/name-controller/src/NameController.ts index b867045ce11..e0389fb534d 100644 --- a/packages/name-controller/src/NameController.ts +++ b/packages/name-controller/src/NameController.ts @@ -1,23 +1,57 @@ -import type { RestrictedControllerMessenger } from '@metamask/base-controller'; -import { BaseControllerV2 } from '@metamask/base-controller'; -import type { Patch } from 'immer'; - +import type { + ControllerGetStateAction, + ControllerStateChangeEvent, +} from '@metamask/base-controller'; +import { BaseController } from '@metamask/base-controller'; +import { isSafeDynamicKey } from '@metamask/controller-utils'; +import type { Messenger } from '@metamask/messenger'; + +import type { NameControllerMethodActions } from './NameController-method-action-types.js'; import type { NameProvider, NameProviderRequest, NameProviderResult, NameProviderSourceResult, -} from './types'; -import { NameType } from './types'; +} from './types.js'; +import { NameType } from './types.js'; + +export const FALLBACK_VARIATION = '*'; +export const PROPOSED_NAME_EXPIRE_DURATION = 60 * 60 * 24; // 24 hours + +/** + * Enumerates the possible origins responsible for setting a petname. + */ +export enum NameOrigin { + // Originated from an account identity. + ACCOUNT_IDENTITY = 'account-identity', + // Originated from an address book entry. + ADDRESS_BOOK = 'address-book', + // Originated from the API (NameController.setName). This is the default. + API = 'api', + // Originated from the user taking action in the UI. + UI = 'ui', +} const DEFAULT_UPDATE_DELAY = 60 * 2; // 2 Minutes const DEFAULT_VARIATION = ''; const controllerName = 'NameController'; +const MESSENGER_EXPOSED_METHODS = ['setName', 'updateProposedNames'] as const; + const stateMetadata = { - names: { persist: true, anonymous: false }, - nameSources: { persist: true, anonymous: false }, + names: { + includeInStateLogs: true, + persist: true, + includeInDebugSnapshot: false, + usedInUi: true, + }, + nameSources: { + includeInStateLogs: true, + persist: true, + includeInDebugSnapshot: false, + usedInUi: true, + }, }; const getDefaultState = () => ({ @@ -36,6 +70,7 @@ export type ProposedNamesEntry = { export type NameEntry = { name: string | null; sourceId: string | null; + origin: NameOrigin | null; proposedNames: Record; }; @@ -49,26 +84,24 @@ export type NameControllerState = { nameSources: Record; }; -export type GetNameState = { - type: `${typeof controllerName}:getState`; - handler: () => NameControllerState; -}; +export type GetNameState = ControllerGetStateAction< + typeof controllerName, + NameControllerState +>; -export type NameStateChange = { - type: `${typeof controllerName}:stateChange`; - payload: [NameControllerState, Patch[]]; -}; +export type NameStateChange = ControllerStateChangeEvent< + typeof controllerName, + NameControllerState +>; -export type NameControllerActions = GetNameState; +export type NameControllerActions = GetNameState | NameControllerMethodActions; export type NameControllerEvents = NameStateChange; -export type NameControllerMessenger = RestrictedControllerMessenger< +export type NameControllerMessenger = Messenger< typeof controllerName, NameControllerActions, - NameControllerEvents, - never, - never + NameControllerEvents >; export type NameControllerOptions = { @@ -96,25 +129,26 @@ export type SetNameRequest = { name: string | null; sourceId?: string; variation?: string; + origin?: NameOrigin; }; /** * Controller for storing and deriving names for values such as Ethereum addresses. */ -export class NameController extends BaseControllerV2< +export class NameController extends BaseController< typeof controllerName, NameControllerState, NameControllerMessenger > { - #providers: NameProvider[]; + readonly #providers: NameProvider[]; - #updateDelay: number; + readonly #updateDelay: number; /** * Construct a Name controller. * * @param options - Controller options. - * @param options.messenger - Restricted controller messenger for the name controller. + * @param options.messenger - Restricted messenger for the name controller. * @param options.providers - Array of name provider instances to propose names. * @param options.state - Initial state to set on the controller. * @param options.updateDelay - The delay in seconds before a new request to a source should be made. @@ -132,6 +166,11 @@ export class NameController extends BaseControllerV2< state: { ...getDefaultState(), ...state }, }); + this.messenger.registerMethodActionHandlers( + this, + MESSENGER_EXPOSED_METHODS, + ); + this.#providers = providers; this.#updateDelay = updateDelay ?? DEFAULT_UPDATE_DELAY; } @@ -149,12 +188,23 @@ export class NameController extends BaseControllerV2< setName(request: SetNameRequest) { this.#validateSetNameRequest(request); - const { value, type, name, sourceId: requestSourceId, variation } = request; + const { + value, + type, + name, + sourceId: requestSourceId, + origin: requestOrigin, + variation, + } = request; const sourceId = requestSourceId ?? null; + // If the name is being cleared, the fallback origin should be cleared as well. + const fallbackOrigin = name === null ? null : NameOrigin.API; + const origin = requestOrigin ?? fallbackOrigin; this.#updateEntry(value, type, variation, (entry: NameEntry) => { entry.name = name; entry.sourceId = sourceId; + entry.origin = origin; }); } @@ -183,6 +233,7 @@ export class NameController extends BaseControllerV2< this.#updateProposedNameState(request, providerResponses); this.#updateSourceState(this.#providers); + this.#removeExpiredEntries(); return this.#getUpdateProposedNamesResult(providerResponses); } @@ -359,7 +410,9 @@ export class NameController extends BaseControllerV2< ): NameProviderSourceResult | undefined { const error = result?.error ?? responseError ?? undefined; const updateDelay = result?.updateDelay ?? undefined; - let proposedNames = error ? undefined : result?.proposedNames ?? undefined; + let proposedNames = error + ? undefined + : (result?.proposedNames ?? undefined); if (proposedNames) { proposedNames = proposedNames.filter( @@ -407,6 +460,14 @@ export class NameController extends BaseControllerV2< const normalizedValue = this.#normalizeValue(value, type); const normalizedVariation = this.#normalizeVariation(variationKey, type); + if ( + [normalizedValue, normalizedVariation].some( + (key) => !isSafeDynamicKey(key), + ) + ) { + return; + } + this.update((state) => { const typeEntries = state.names[type] || {}; state.names[type] = typeEntries; @@ -418,6 +479,7 @@ export class NameController extends BaseControllerV2< proposedNames: {}, name: null, sourceId: null, + origin: null, }; variationEntries[normalizedVariation] = entry; @@ -430,7 +492,7 @@ export class NameController extends BaseControllerV2< } #validateSetNameRequest(request: SetNameRequest) { - const { name, value, type, sourceId, variation } = request; + const { name, value, type, sourceId, variation, origin } = request; const errorMessages: string[] = []; this.#validateValue(value, errorMessages); @@ -438,6 +500,7 @@ export class NameController extends BaseControllerV2< this.#validateName(name, errorMessages); this.#validateSourceId(sourceId, type, name, errorMessages); this.#validateVariation(variation, type, errorMessages); + this.#validateOrigin(origin, name, errorMessages); if (errorMessages.length) { throw new Error(errorMessages.join(' ')); @@ -568,10 +631,36 @@ export class NameController extends BaseControllerV2< if ( !variation?.length || typeof variation !== 'string' || - !variation.match(/^0x[0-9A-Fa-f]+$/u) + (!variation.match(/^0x[0-9A-Fa-f]+$/u) && + variation !== FALLBACK_VARIATION) ) { errorMessages.push( - `Must specify a chain ID in hexidecimal format for variation when using '${type}' type.`, + `Must specify a chain ID in hexadecimal format or the fallback, "${FALLBACK_VARIATION}", for variation when using '${type}' type.`, + ); + } + } + + #validateOrigin( + origin: NameOrigin | null | undefined, + name: string | null, + errorMessages: string[], + ) { + if (!origin) { + return; + } + + if (name === null) { + errorMessages.push( + `Cannot specify an origin when clearing the saved name: ${origin}`, + ); + return; + } + + if (!Object.values(NameOrigin).includes(origin)) { + errorMessages.push( + `Must specify one of the following origins: ${Object.values( + NameOrigin, + ).join(', ')}`, ); } } @@ -607,4 +696,46 @@ export class NameController extends BaseControllerV2< delete proposedNames[dormantSourceId]; } } + + #removeExpiredEntries(): void { + const currentTime = this.#getCurrentTimeSeconds(); + + this.update((state: NameControllerState) => { + const entries = this.#getEntriesList(state); + for (const { nameType, value, variation, entry } of entries) { + if (entry.name !== null) { + continue; + } + + const proposedNames = Object.values(entry.proposedNames); + const allProposedNamesExpired = proposedNames.every( + (proposedName: ProposedNamesEntry) => + currentTime - (proposedName.lastRequestTime ?? 0) >= + PROPOSED_NAME_EXPIRE_DURATION, + ); + + if (allProposedNamesExpired) { + delete state.names[nameType][value][variation]; + } + } + }); + } + + #getEntriesList(state: NameControllerState): { + nameType: NameType; + value: string; + variation: string; + entry: NameEntry; + }[] { + return Object.entries(state.names).flatMap(([type, typeEntries]) => + Object.entries(typeEntries).flatMap(([value, variationEntries]) => + Object.entries(variationEntries).map(([variation, entry]) => ({ + entry, + nameType: type as NameType, + value, + variation, + })), + ), + ); + } } diff --git a/packages/name-controller/src/constants.ts b/packages/name-controller/src/constants.ts index 6a5b4767f55..4f86d30669c 100644 --- a/packages/name-controller/src/constants.ts +++ b/packages/name-controller/src/constants.ts @@ -6,7 +6,7 @@ export const CHAIN_IDS = { BSC: '0x38', BSC_TESTNET: '0x61', OPTIMISM: '0xa', - OPTIMISM_TESTNET: '0x1a4', + OPTIMISM_SEPOLIA: '0xaa37dc', POLYGON: '0x89', POLYGON_TESTNET: '0x13881', AVALANCHE: '0xa86a', @@ -15,6 +15,7 @@ export const CHAIN_IDS = { FANTOM_TESTNET: '0xfa2', SEPOLIA: '0xaa36a7', LINEA_GOERLI: '0xe704', + LINEA_SEPOLIA: '0xe705', LINEA_MAINNET: '0xe708', MOONBEAM: '0x504', MOONBEAM_TESTNET: '0x507', @@ -42,6 +43,10 @@ export const ETHERSCAN_SUPPORTED_NETWORKS = { domain: 'lineascan.build', subdomain: 'goerli', }, + [CHAIN_IDS.LINEA_SEPOLIA]: { + domain: 'lineascan.build', + subdomain: 'sepolia', + }, [CHAIN_IDS.LINEA_MAINNET]: { domain: 'lineascan.build', subdomain: DEFAULT_ETHERSCAN_SUBDOMAIN_PREFIX, @@ -58,9 +63,9 @@ export const ETHERSCAN_SUPPORTED_NETWORKS = { domain: DEFAULT_ETHERSCAN_DOMAIN, subdomain: `${DEFAULT_ETHERSCAN_SUBDOMAIN_PREFIX}-optimistic`, }, - [CHAIN_IDS.OPTIMISM_TESTNET]: { + [CHAIN_IDS.OPTIMISM_SEPOLIA]: { domain: DEFAULT_ETHERSCAN_DOMAIN, - subdomain: `${DEFAULT_ETHERSCAN_SUBDOMAIN_PREFIX}-goerli-optimistic`, + subdomain: `${DEFAULT_ETHERSCAN_SUBDOMAIN_PREFIX}-sepolia-optimistic`, }, [CHAIN_IDS.POLYGON]: { domain: 'polygonscan.com', diff --git a/packages/name-controller/src/index.ts b/packages/name-controller/src/index.ts index 4647caa08c1..918c77eeb28 100644 --- a/packages/name-controller/src/index.ts +++ b/packages/name-controller/src/index.ts @@ -1,6 +1,10 @@ -export * from './NameController'; -export * from './types'; -export * from './providers/ens'; -export * from './providers/etherscan'; -export * from './providers/token'; -export * from './providers/lens'; +export * from './NameController.js'; +export type { + NameControllerSetNameAction, + NameControllerUpdateProposedNamesAction, +} from './NameController-method-action-types.js'; +export * from './types.js'; +export * from './providers/ens.js'; +export * from './providers/etherscan.js'; +export * from './providers/token.js'; +export * from './providers/lens.js'; diff --git a/packages/name-controller/src/providers/ens.test.ts b/packages/name-controller/src/providers/ens.test.ts index cd59efe65b5..147044a0d72 100644 --- a/packages/name-controller/src/providers/ens.test.ts +++ b/packages/name-controller/src/providers/ens.test.ts @@ -1,5 +1,5 @@ -import { NameType } from '../types'; -import { ENSNameProvider } from './ens'; +import { NameType } from '../types.js'; +import { ENSNameProvider } from './ens.js'; jest.mock('../util'); @@ -11,13 +11,11 @@ const REVERSE_LOOKUP_MOCK = () => DOMAIN_MOCK; const CONSTRUCTOR_ARGS_MOCK = { reverseLookup: REVERSE_LOOKUP_MOCK, + // TODO: Replace `any` with type + // eslint-disable-next-line @typescript-eslint/no-explicit-any } as any; describe('ENSNameProvider', () => { - beforeEach(() => { - jest.resetAllMocks(); - }); - describe('getMetadata', () => { it('returns the provider metadata', () => { const metadata = new ENSNameProvider(CONSTRUCTOR_ARGS_MOCK).getMetadata(); diff --git a/packages/name-controller/src/providers/ens.ts b/packages/name-controller/src/providers/ens.ts index 20dfa8eadc6..7f96fc7bb33 100644 --- a/packages/name-controller/src/providers/ens.ts +++ b/packages/name-controller/src/providers/ens.ts @@ -1,11 +1,11 @@ -import { projectLogger, createModuleLogger } from '../logger'; +import { projectLogger, createModuleLogger } from '../logger.js'; import type { NameProvider, NameProviderMetadata, NameProviderRequest, NameProviderResult, -} from '../types'; -import { NameType } from '../types'; +} from '../types.js'; +import { NameType } from '../types.js'; export type ReverseLookupCallback = ( address: string, @@ -18,9 +18,9 @@ const LABEL = 'Ethereum Name Service (ENS)'; const log = createModuleLogger(projectLogger, 'ens'); export class ENSNameProvider implements NameProvider { - #isEnabled: () => boolean; + readonly #isEnabled: () => boolean; - #reverseLookup: ReverseLookupCallback; + readonly #reverseLookup: ReverseLookupCallback; constructor({ isEnabled, diff --git a/packages/name-controller/src/providers/etherscan.test.ts b/packages/name-controller/src/providers/etherscan.test.ts index dd8d89eff6e..44e0d394cab 100644 --- a/packages/name-controller/src/providers/etherscan.test.ts +++ b/packages/name-controller/src/providers/etherscan.test.ts @@ -1,7 +1,7 @@ -import { CHAIN_IDS } from '../constants'; -import { NameType } from '../types'; -import { handleFetch } from '../util'; -import { EtherscanNameProvider } from './etherscan'; +import { CHAIN_IDS } from '../constants.js'; +import { NameType } from '../types.js'; +import { handleFetch } from '../util.js'; +import { EtherscanNameProvider } from './etherscan.js'; jest.mock('../util'); @@ -14,10 +14,6 @@ const CONTRACT_NAME_2_MOCK = 'TestContractName2'; describe('EtherscanNameProvider', () => { const handleFetchMock = jest.mocked(handleFetch); - beforeEach(() => { - jest.resetAllMocks(); - }); - describe('getMetadata', () => { it('returns the provider metadata', () => { const metadata = new EtherscanNameProvider().getMetadata(); diff --git a/packages/name-controller/src/providers/etherscan.ts b/packages/name-controller/src/providers/etherscan.ts index 48b5c6f3c08..bb0a81134e2 100644 --- a/packages/name-controller/src/providers/etherscan.ts +++ b/packages/name-controller/src/providers/etherscan.ts @@ -1,15 +1,15 @@ import { Mutex } from 'async-mutex'; -import { ETHERSCAN_SUPPORTED_NETWORKS } from '../constants'; -import { createModuleLogger, projectLogger } from '../logger'; +import { ETHERSCAN_SUPPORTED_NETWORKS } from '../constants.js'; +import { createModuleLogger, projectLogger } from '../logger.js'; import type { NameProvider, NameProviderMetadata, NameProviderRequest, NameProviderResult, -} from '../types'; -import { NameType } from '../types'; -import { handleFetch, assertIsError } from '../util'; +} from '../types.js'; +import { NameType } from '../types.js'; +import { handleFetch, assertIsError } from '../util.js'; const ID = 'etherscan'; const LABEL = 'Etherscan (Verified Contract Name)'; @@ -40,11 +40,11 @@ type EtherscanGetSourceCodeResponse = { }; export class EtherscanNameProvider implements NameProvider { - #isEnabled: () => boolean; + readonly #isEnabled: () => boolean; #lastRequestTime = 0; - #mutex = new Mutex(); + readonly #mutex = new Mutex(); constructor({ isEnabled }: { isEnabled?: () => boolean } = {}) { this.#isEnabled = isEnabled || (() => true); diff --git a/packages/name-controller/src/providers/lens.test.ts b/packages/name-controller/src/providers/lens.test.ts index 729bd504dc4..8540260fc3a 100644 --- a/packages/name-controller/src/providers/lens.test.ts +++ b/packages/name-controller/src/providers/lens.test.ts @@ -1,6 +1,6 @@ -import { NameType } from '../types'; -import { graphQL } from '../util'; -import { LensNameProvider } from './lens'; +import { NameType } from '../types.js'; +import { graphQL } from '../util.js'; +import { LensNameProvider } from './lens.js'; jest.mock('../util'); @@ -13,10 +13,6 @@ const HANDLE_2_MOCK = 'TestHandle2'; describe('LensNameProvider', () => { const graphqlMock = jest.mocked(graphQL); - beforeEach(() => { - jest.resetAllMocks(); - }); - describe('getMetadata', () => { it('returns the provider metadata', () => { const metadata = new LensNameProvider().getMetadata(); diff --git a/packages/name-controller/src/providers/lens.ts b/packages/name-controller/src/providers/lens.ts index 36e05744806..483d1c23705 100644 --- a/packages/name-controller/src/providers/lens.ts +++ b/packages/name-controller/src/providers/lens.ts @@ -1,12 +1,12 @@ -import { createModuleLogger, projectLogger } from '../logger'; +import { createModuleLogger, projectLogger } from '../logger.js'; import type { NameProvider, NameProviderMetadata, NameProviderRequest, NameProviderResult, -} from '../types'; -import { NameType } from '../types'; -import { graphQL } from '../util'; +} from '../types.js'; +import { NameType } from '../types.js'; +import { graphQL } from '../util.js'; const ID = 'lens'; const LABEL = 'Lens Protocol'; @@ -34,7 +34,7 @@ type LensResponse = { }; export class LensNameProvider implements NameProvider { - #isEnabled: () => boolean; + readonly #isEnabled: () => boolean; constructor({ isEnabled }: { isEnabled?: () => boolean } = {}) { this.#isEnabled = isEnabled || (() => true); diff --git a/packages/name-controller/src/providers/token.test.ts b/packages/name-controller/src/providers/token.test.ts index e30b11825e7..a43eb46f747 100644 --- a/packages/name-controller/src/providers/token.test.ts +++ b/packages/name-controller/src/providers/token.test.ts @@ -1,6 +1,6 @@ -import { NameType } from '../types'; -import { handleFetch } from '../util'; -import { TokenNameProvider } from './token'; +import { NameType } from '../types.js'; +import { handleFetch } from '../util.js'; +import { TokenNameProvider } from './token.js'; jest.mock('../util'); @@ -12,10 +12,6 @@ const TOKEN_NAME_MOCK = 'TestTokenName'; describe('TokenNameProvider', () => { const handleFetchMock = jest.mocked(handleFetch); - beforeEach(() => { - jest.resetAllMocks(); - }); - describe('getMetadata', () => { it('returns the provider metadata', () => { const metadata = new TokenNameProvider().getMetadata(); @@ -51,7 +47,7 @@ describe('TokenNameProvider', () => { expect(handleFetchMock).toHaveBeenCalledTimes(1); expect(handleFetchMock).toHaveBeenCalledWith( - `https://token-api.metaswap.codefi.network/token/${CHAIN_ID_MOCK}?address=${VALUE_MOCK}`, + `https://token.api.cx.metamask.io/token/${CHAIN_ID_MOCK}?address=${VALUE_MOCK}`, ); }); diff --git a/packages/name-controller/src/providers/token.ts b/packages/name-controller/src/providers/token.ts index 486b71cc44a..9ca9760acc4 100644 --- a/packages/name-controller/src/providers/token.ts +++ b/packages/name-controller/src/providers/token.ts @@ -1,12 +1,12 @@ -import { createModuleLogger, projectLogger } from '../logger'; +import { createModuleLogger, projectLogger } from '../logger.js'; import type { NameProvider, NameProviderMetadata, NameProviderRequest, NameProviderResult, -} from '../types'; -import { NameType } from '../types'; -import { handleFetch } from '../util'; +} from '../types.js'; +import { NameType } from '../types.js'; +import { handleFetch } from '../util.js'; const ID = 'token'; const LABEL = 'Blockchain (Token Name)'; @@ -14,7 +14,7 @@ const LABEL = 'Blockchain (Token Name)'; const log = createModuleLogger(projectLogger, 'token'); export class TokenNameProvider implements NameProvider { - #isEnabled: () => boolean; + readonly #isEnabled: () => boolean; constructor({ isEnabled }: { isEnabled?: () => boolean } = {}) { this.#isEnabled = isEnabled || (() => true); @@ -43,7 +43,7 @@ export class TokenNameProvider implements NameProvider { } const { value, variation: chainId } = request; - const url = `https://token-api.metaswap.codefi.network/token/${chainId}?address=${value}`; + const url = `https://token.api.cx.metamask.io/token/${chainId}?address=${value}`; log('Sending request', url); diff --git a/packages/name-controller/src/util.ts b/packages/name-controller/src/util.ts index 451b6b51be2..dac27892b6d 100644 --- a/packages/name-controller/src/util.ts +++ b/packages/name-controller/src/util.ts @@ -8,6 +8,8 @@ export async function graphQL( url: string, query: string, + // TODO: Replace `any` with type + // eslint-disable-next-line @typescript-eslint/no-explicit-any variables: Record, ): Promise { const body = JSON.stringify({ diff --git a/packages/name-controller/src/utils.test.ts b/packages/name-controller/src/utils.test.ts index 556af3bb849..d7a7fa472ca 100644 --- a/packages/name-controller/src/utils.test.ts +++ b/packages/name-controller/src/utils.test.ts @@ -1,4 +1,4 @@ -import { assertIsError, graphQL } from './util'; +import { assertIsError, graphQL } from './util.js'; describe('Utils', () => { describe('graphQL', () => { @@ -7,6 +7,8 @@ describe('Utils', () => { const VARIABLES_MOCK = { test: 'value' }; const DATA_MOCK = { test2: 'value2' }; const JSON_MOCK = { data: DATA_MOCK }; + // TODO: Replace `any` with type + // eslint-disable-next-line @typescript-eslint/no-explicit-any const RESPONSE_MOCK = { ok: true, json: () => JSON_MOCK } as any; it('fetches URL with graphQL body', async () => { @@ -42,6 +44,8 @@ describe('Utils', () => { it('throws if response is not ok', async () => { const mockFetch = jest.spyOn(global, 'fetch'); + // TODO: Replace `any` with type + // eslint-disable-next-line @typescript-eslint/no-explicit-any mockFetch.mockResolvedValueOnce({ ok: false, status: 500 } as any); await expect( diff --git a/packages/name-controller/tsconfig.build.json b/packages/name-controller/tsconfig.build.json index 779d385a6ab..fbd362d7997 100644 --- a/packages/name-controller/tsconfig.build.json +++ b/packages/name-controller/tsconfig.build.json @@ -8,6 +8,12 @@ "references": [ { "path": "../base-controller/tsconfig.build.json" + }, + { + "path": "../messenger/tsconfig.build.json" + }, + { + "path": "../controller-utils/tsconfig.build.json" } ], "include": ["../../types", "./src"] diff --git a/packages/name-controller/tsconfig.json b/packages/name-controller/tsconfig.json index f2d7b67ff66..d43f80859c1 100644 --- a/packages/name-controller/tsconfig.json +++ b/packages/name-controller/tsconfig.json @@ -6,6 +6,12 @@ "references": [ { "path": "../base-controller" + }, + { + "path": "../messenger" + }, + { + "path": "../controller-utils" } ], "include": ["../../types", "./src"] diff --git a/packages/network-connection-banner-controller/CHANGELOG.md b/packages/network-connection-banner-controller/CHANGELOG.md new file mode 100644 index 00000000000..c761e273ac8 --- /dev/null +++ b/packages/network-connection-banner-controller/CHANGELOG.md @@ -0,0 +1,53 @@ +# Changelog + +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [Unreleased] + +## [0.2.1] + +### Changed + +- Bump `@metamask/network-enablement-controller` from `^6.0.3` to `^6.0.5` ([#9923](https://github.com/MetaMask/core/pull/9923), [#9969](https://github.com/MetaMask/core/pull/9969)) +- Bump `@metamask/network-controller` from `^35.0.1` to `^36.0.0` ([#9969](https://github.com/MetaMask/core/pull/9969)) + +## [0.2.0] + +### Changed + +- **BREAKING:** `NetworkConnectionBannerControllerMessenger` now requires `ClientController:stateChange` to be delegated instead of `ClientController:stateChanged` ([#9893](https://github.com/MetaMask/core/pull/9893)) +- Bump `@metamask/network-controller` from `^35.0.0` to `^35.0.1` ([#9758](https://github.com/MetaMask/core/pull/9758)) +- Bump `@metamask/network-enablement-controller` from `^6.0.1` to `^6.0.3` ([#9740](https://github.com/MetaMask/core/pull/9740), [#9791](https://github.com/MetaMask/core/pull/9791)) +- Bump `@metamask/keyring-controller` from `^27.1.0` to `^27.1.1` ([#9791](https://github.com/MetaMask/core/pull/9791)) + +## [0.1.2] + +### Changed + +- Bump `@metamask/network-controller` from `^34.0.0` to `^35.0.0` ([#9735](https://github.com/MetaMask/core/pull/9735)) +- Bump `@metamask/network-enablement-controller` from `^6.0.0` to `^6.0.1` ([#9735](https://github.com/MetaMask/core/pull/9735)) + +## [0.1.1] + +### Changed + +- Bump `@metamask/network-enablement-controller` from `^5.4.1` to `^6.0.0` ([#9470](https://github.com/MetaMask/core/pull/9470), [#9520](https://github.com/MetaMask/core/pull/9520), [#9706](https://github.com/MetaMask/core/pull/9706)) + +## [0.1.0] + +### Added + +- Add `NetworkConnectionBannerController`, which evaluates enabled network RPC + health after initialization and manages degraded and unavailable banner state, + dismissal, and switching custom RPC endpoints to an available Infura endpoint + ([#9041](https://github.com/MetaMask/core/pull/9041)) + +[Unreleased]: https://github.com/MetaMask/core/compare/@metamask/network-connection-banner-controller@0.2.1...HEAD +[0.2.1]: https://github.com/MetaMask/core/compare/@metamask/network-connection-banner-controller@0.2.0...@metamask/network-connection-banner-controller@0.2.1 +[0.2.0]: https://github.com/MetaMask/core/compare/@metamask/network-connection-banner-controller@0.1.2...@metamask/network-connection-banner-controller@0.2.0 +[0.1.2]: https://github.com/MetaMask/core/compare/@metamask/network-connection-banner-controller@0.1.1...@metamask/network-connection-banner-controller@0.1.2 +[0.1.1]: https://github.com/MetaMask/core/compare/@metamask/network-connection-banner-controller@0.1.0...@metamask/network-connection-banner-controller@0.1.1 +[0.1.0]: https://github.com/MetaMask/core/releases/tag/@metamask/network-connection-banner-controller@0.1.0 diff --git a/packages/network-connection-banner-controller/LICENSE b/packages/network-connection-banner-controller/LICENSE new file mode 100644 index 00000000000..fe29e78e0fe --- /dev/null +++ b/packages/network-connection-banner-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/network-connection-banner-controller/README.md b/packages/network-connection-banner-controller/README.md new file mode 100644 index 00000000000..c17ce310bd5 --- /dev/null +++ b/packages/network-connection-banner-controller/README.md @@ -0,0 +1,35 @@ +# `@metamask/network-connection-banner-controller` + +NetworkConnectionBannerController decides when and how to surface the network +connection banner based on RPC endpoint health. It encapsulates the rule and +the 5s/30s timer state machine. Both timeouts are configurable via the +`degradedBannerTimeout` and `unavailableBannerTimeout` constructor options +(defaults exported as `DEFAULT_DEGRADED_BANNER_TIMEOUT` and +`DEFAULT_UNAVAILABLE_BANNER_TIMEOUT`); the unavailable timeout is measured +from the same failure start and must be greater than the degraded one. + +## Lifecycle + +The controller stays dormant after construction so the 5s / 30s escalation +timers do not run before a user is actually looking at the wallet (e.g. while +the app is still on the lock screen). It manages its own lifecycle by +subscribing to `ClientController:stateChange` and +`KeyringController:unlock` / `KeyringController:lock`: evaluation runs only +while the client UI is open on an unlocked wallet. When either condition +stops holding, pending timers are cancelled and the banner state resets to +`available`; upstream state changes are ignored until both hold again. + +Clients need no lifecycle wiring beyond keeping `ClientController`'s +`isUiOpen` up to date (via `ClientController:setUiOpen`). + +## Installation + +`yarn add @metamask/network-connection-banner-controller` + +or + +`npm install @metamask/network-connection-banner-controller` + +## Contributing + +This package is part of a monorepo. Instructions for contributing can be found in the [monorepo README](https://github.com/MetaMask/core#readme). diff --git a/packages/network-connection-banner-controller/jest.config.js b/packages/network-connection-banner-controller/jest.config.js new file mode 100644 index 00000000000..f6a03b68807 --- /dev/null +++ b/packages/network-connection-banner-controller/jest.config.js @@ -0,0 +1,29 @@ +/* + * 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, + + // Skip the ambient psl.d.ts shim from coverage — it's a type-only file. + coveragePathIgnorePatterns: ['.*\\.d\\.ts$'], + + // An object that configures minimum threshold enforcement for coverage results + coverageThreshold: { + global: { + branches: 100, + functions: 100, + lines: 100, + statements: 100, + }, + }, +}); diff --git a/packages/network-connection-banner-controller/package.json b/packages/network-connection-banner-controller/package.json new file mode 100644 index 00000000000..5d329ab4f76 --- /dev/null +++ b/packages/network-connection-banner-controller/package.json @@ -0,0 +1,83 @@ +{ + "name": "@metamask/network-connection-banner-controller", + "version": "0.2.1", + "description": "Decides when and how to surface the network connection banner based on RPC endpoint health", + "keywords": [ + "Ethereum", + "MetaMask" + ], + "homepage": "https://github.com/MetaMask/core/tree/main/packages/network-connection-banner-controller#readme", + "bugs": { + "url": "https://github.com/MetaMask/core/issues" + }, + "license": "MIT", + "repository": { + "type": "git", + "url": "https://github.com/MetaMask/core.git" + }, + "files": [ + "dist/" + ], + "sideEffects": false, + "main": "./dist/index.cjs", + "types": "./dist/index.d.cts", + "exports": { + ".": { + "import": { + "types": "./dist/index.d.mts", + "default": "./dist/index.mjs" + }, + "require": { + "types": "./dist/index.d.cts", + "default": "./dist/index.cjs" + } + }, + "./package.json": "./package.json" + }, + "publishConfig": { + "access": "public", + "registry": "https://registry.npmjs.org/" + }, + "scripts": { + "build": "ts-bridge --project tsconfig.build.json --verbose --clean --no-references", + "build:all": "ts-bridge --project tsconfig.build.json --verbose --clean", + "build:docs": "typedoc", + "changelog:update": "../../scripts/update-changelog.sh @metamask/network-connection-banner-controller", + "changelog:validate": "../../scripts/validate-changelog.sh @metamask/network-connection-banner-controller", + "lint:tsconfigs": "tsx ../../scripts/lint-tsconfigs/lint-tsconfigs.mts", + "lint:tsconfigs:fix": "tsx ../../scripts/lint-tsconfigs/lint-tsconfigs.mts --fix", + "messenger-action-types:check": "tsx ../../packages/messenger-cli/src/cli.ts --formatter oxfmt --esm --check", + "messenger-action-types:generate": "tsx ../../packages/messenger-cli/src/cli.ts --formatter oxfmt --esm --generate", + "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.1.0", + "@metamask/client-controller": "^1.0.1", + "@metamask/connectivity-controller": "^0.3.0", + "@metamask/keyring-controller": "^27.1.1", + "@metamask/messenger": "^2.0.0", + "@metamask/network-controller": "^36.0.0", + "@metamask/network-enablement-controller": "^6.0.5", + "@metamask/utils": "^11.11.0", + "reselect": "^5.1.1" + }, + "devDependencies": { + "@metamask/auto-changelog": "^6.1.0", + "@ts-bridge/cli": "^0.6.4", + "@types/jest": "^30.0.0", + "deepmerge": "^4.2.2", + "jest": "^30.4.2", + "ts-jest": "^29.4.11", + "tsx": "^4.20.5", + "typedoc": "^0.25.13", + "typedoc-plugin-missing-exports": "^2.0.0", + "typescript": "~5.3.3" + }, + "engines": { + "node": "^18.18 || >=20" + } +} diff --git a/packages/network-connection-banner-controller/src/NetworkConnectionBannerController-method-action-types.ts b/packages/network-connection-banner-controller/src/NetworkConnectionBannerController-method-action-types.ts new file mode 100644 index 00000000000..980b79d2f9e --- /dev/null +++ b/packages/network-connection-banner-controller/src/NetworkConnectionBannerController-method-action-types.ts @@ -0,0 +1,36 @@ +/** + * This file is auto generated. + * Do not edit manually. + */ + +import type { NetworkConnectionBannerController } from './NetworkConnectionBannerController.js'; + +/** + * Clears the banner state such that the banner will be hidden. + */ +export type NetworkConnectionBannerControllerDismissBannerAction = { + type: `NetworkConnectionBannerController:dismissBanner`; + handler: NetworkConnectionBannerController['dismissBanner']; +}; + +/** + * Switches the chain's default RPC endpoint to its Infura endpoint and + * makes it the active network, causing the banner to clear once the + * network becomes available again. + * + * @param chainId - The chain whose default RPC endpoint should be switched. + * @throws If the chain configuration cannot be found, or if it has no + * Infura endpoint to switch to, or if the default is already Infura. + */ +export type NetworkConnectionBannerControllerSwitchToDefaultInfuraRpcEndpointAction = + { + type: `NetworkConnectionBannerController:switchToDefaultInfuraRpcEndpoint`; + handler: NetworkConnectionBannerController['switchToDefaultInfuraRpcEndpoint']; + }; + +/** + * Union of all NetworkConnectionBannerController action types. + */ +export type NetworkConnectionBannerControllerMethodActions = + | NetworkConnectionBannerControllerDismissBannerAction + | NetworkConnectionBannerControllerSwitchToDefaultInfuraRpcEndpointAction; diff --git a/packages/network-connection-banner-controller/src/NetworkConnectionBannerController.test.ts b/packages/network-connection-banner-controller/src/NetworkConnectionBannerController.test.ts new file mode 100644 index 00000000000..847118212e0 --- /dev/null +++ b/packages/network-connection-banner-controller/src/NetworkConnectionBannerController.test.ts @@ -0,0 +1,1980 @@ +import { deriveStateFromMetadata } from '@metamask/base-controller'; +import { CONNECTIVITY_STATUSES } from '@metamask/connectivity-controller'; +import type { ConnectivityControllerState } from '@metamask/connectivity-controller'; +import { Messenger, MOCK_ANY_NAMESPACE } from '@metamask/messenger'; +import type { + MockAnyNamespace, + MessengerActions, + MessengerEvents, +} from '@metamask/messenger'; +import type { + BuiltInNetworkClientId, + InfuraRpcEndpoint, + NetworkConfiguration, + NetworkState, +} from '@metamask/network-controller'; +import { NetworkStatus, RpcEndpointType } from '@metamask/network-controller'; +import type { NetworkEnablementControllerState } from '@metamask/network-enablement-controller'; +import { KnownCaipNamespace } from '@metamask/utils'; +import type { Hex } from '@metamask/utils'; + +import type { NetworkConnectionBannerControllerMessenger } from './NetworkConnectionBannerController.js'; +import { NetworkConnectionBannerController } from './NetworkConnectionBannerController.js'; + +const TEST_INFURA_PROJECT_ID = 'test-infura-project-id'; +const MAINNET_CLIENT_ID = 'mainnet' satisfies BuiltInNetworkClientId; +const SEPOLIA_CLIENT_ID = 'sepolia' satisfies BuiltInNetworkClientId; +const POLYGON_CUSTOM_CLIENT_ID = 'polygon-custom'; +const ALCHEMY_CLIENT_ID = 'eth-alchemy'; + +function buildNetworkConfiguration( + overrides: Partial & + Pick, +): NetworkConfiguration { + return { + name: 'Ethereum Mainnet', + nativeCurrency: 'ETH', + rpcEndpoints: [ + buildInfuraEndpoint({ + networkClientId: MAINNET_CLIENT_ID, + infuraNetworkType: 'mainnet', + }), + ], + defaultRpcEndpointIndex: 0, + blockExplorerUrls: [], + defaultBlockExplorerUrlIndex: 0, + ...overrides, + }; +} + +describe('NetworkConnectionBannerController', () => { + beforeEach(() => { + jest.useFakeTimers(); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + describe('metadata', () => { + it('keeps banner state ephemeral and surfaces it to debug snapshots and the UI', async () => { + await withController(({ controller }) => { + expect( + deriveStateFromMetadata( + controller.state, + controller.metadata, + 'persist', + ), + ).toMatchInlineSnapshot(`{}`); + expect( + deriveStateFromMetadata( + controller.state, + controller.metadata, + 'includeInDebugSnapshot', + ), + ).toMatchInlineSnapshot(` + { + "networkConnectionBannerNetwork": null, + "networkConnectionBannerStatus": "available", + } + `); + expect( + deriveStateFromMetadata( + controller.state, + controller.metadata, + 'usedInUi', + ), + ).toMatchInlineSnapshot(` + { + "networkConnectionBannerNetwork": null, + "networkConnectionBannerStatus": "available", + } + `); + }); + }); + }); + + describe('default state', () => { + it('starts with status "available" and no network selected', async () => { + await withController(({ controller }) => { + expect(controller.state).toStrictEqual({ + networkConnectionBannerStatus: 'available', + networkConnectionBannerNetwork: null, + }); + }); + }); + }); + + describe('timeout options', () => { + it('honors custom degraded and unavailable timeouts', async () => { + await withController( + { degradedBannerTimeout: 1_000, unavailableBannerTimeout: 3_000 }, + ({ controller, publishNetworkStateChanges }) => { + publishNetworkStateChanges( + buildExternalState({ + networkConfigurationsByChainId: { + '0x89': buildNetworkConfiguration({ + chainId: '0x89', + rpcEndpoints: [ + buildCustomEndpoint({ + networkClientId: POLYGON_CUSTOM_CLIENT_ID, + url: 'https://polygon-rpc.com', + }), + ], + }), + }, + enabledEvmChainIds: ['0x89'], + networksMetadata: { + [POLYGON_CUSTOM_CLIENT_ID]: buildNetworkMetadata( + NetworkStatus.Unavailable, + ), + }, + }), + ); + + jest.advanceTimersByTime(999); + expect(controller.state.networkConnectionBannerStatus).toBe( + 'available', + ); + + jest.advanceTimersByTime(1); + expect(controller.state.networkConnectionBannerStatus).toBe( + 'degraded', + ); + + jest.advanceTimersByTime(1_999); + expect(controller.state.networkConnectionBannerStatus).toBe( + 'degraded', + ); + + jest.advanceTimersByTime(1); + expect(controller.state.networkConnectionBannerStatus).toBe( + 'unavailable', + ); + }, + ); + }); + + it('throws when the unavailable timeout does not exceed the degraded timeout', async () => { + await expect( + withController( + { degradedBannerTimeout: 5_000, unavailableBannerTimeout: 5_000 }, + () => undefined, + ), + ).rejects.toThrow( + '`unavailableBannerTimeout` (5000) must be greater than `degradedBannerTimeout` (5000).', + ); + }); + }); + + describe('lifecycle', () => { + it('does not evaluate existing upstream state before the UI opens on an unlocked wallet', async () => { + const externalState = buildExternalState({ + networkConfigurationsByChainId: { + '0x89': buildNetworkConfiguration({ + chainId: '0x89', + rpcEndpoints: [ + buildCustomEndpoint({ + networkClientId: POLYGON_CUSTOM_CLIENT_ID, + url: 'https://polygon-rpc.com', + }), + ], + }), + }, + enabledEvmChainIds: ['0x89'], + networksMetadata: { + [POLYGON_CUSTOM_CLIENT_ID]: buildNetworkMetadata( + NetworkStatus.Unavailable, + ), + }, + }); + + await withController( + { externalState, start: false }, + ({ controller }) => { + jest.advanceTimersByTime(30_000); + + expect(controller.state.networkConnectionBannerStatus).toBe( + 'available', + ); + }, + ); + }); + + it('evaluates existing upstream state once the UI is open and the wallet unlocked', async () => { + const externalState = buildExternalState({ + networkConfigurationsByChainId: { + '0x89': buildNetworkConfiguration({ + chainId: '0x89', + rpcEndpoints: [ + buildCustomEndpoint({ + networkClientId: POLYGON_CUSTOM_CLIENT_ID, + url: 'https://polygon-rpc.com', + }), + ], + }), + }, + enabledEvmChainIds: ['0x89'], + networksMetadata: { + [POLYGON_CUSTOM_CLIENT_ID]: buildNetworkMetadata( + NetworkStatus.Unavailable, + ), + }, + }); + + await withController( + { externalState, start: false }, + ({ controller, setUiOpen, setKeyringUnlocked }) => { + setUiOpen(true); + setKeyringUnlocked(true); + // Repeated signals must not restart the evaluation. + setKeyringUnlocked(true); + + jest.advanceTimersByTime(5_000); + + expect(controller.state.networkConnectionBannerStatus).toBe( + 'degraded', + ); + }, + ); + }); + + it('ignores upstream state changes while the wallet is locked', async () => { + await withController( + { + externalState: buildExternalState({ enabledEvmChainIds: ['0x89'] }), + start: false, + }, + ({ + controller, + setNetworkControllerState, + setUiOpen, + setKeyringUnlocked, + }) => { + setUiOpen(true); + setNetworkControllerState({ + networkConfigurationsByChainId: { + '0x89': buildNetworkConfiguration({ + chainId: '0x89', + rpcEndpoints: [ + buildCustomEndpoint({ + networkClientId: POLYGON_CUSTOM_CLIENT_ID, + url: 'https://polygon-rpc.com', + }), + ], + }), + }, + networksMetadata: { + [POLYGON_CUSTOM_CLIENT_ID]: buildNetworkMetadata( + NetworkStatus.Unavailable, + ), + }, + }); + + jest.advanceTimersByTime(30_000); + expect(controller.state.networkConnectionBannerStatus).toBe( + 'available', + ); + + setKeyringUnlocked(true); + jest.advanceTimersByTime(5_000); + expect(controller.state.networkConnectionBannerStatus).toBe( + 'degraded', + ); + }, + ); + }); + + it('resumes evaluation when the UI reopens', async () => { + await withController( + { externalState: buildExternalState({ enabledEvmChainIds: ['0x89'] }) }, + ({ controller, setNetworkControllerState, setUiOpen }) => { + setUiOpen(false); + + setNetworkControllerState({ + networkConfigurationsByChainId: { + '0x89': buildNetworkConfiguration({ + chainId: '0x89', + rpcEndpoints: [ + buildCustomEndpoint({ + networkClientId: POLYGON_CUSTOM_CLIENT_ID, + url: 'https://polygon-rpc.com', + }), + ], + }), + }, + networksMetadata: { + [POLYGON_CUSTOM_CLIENT_ID]: buildNetworkMetadata( + NetworkStatus.Unavailable, + ), + }, + }); + + expect(controller.state.networkConnectionBannerStatus).toBe( + 'available', + ); + + setUiOpen(true); + jest.advanceTimersByTime(5_000); + expect(controller.state.networkConnectionBannerStatus).toBe( + 'degraded', + ); + }, + ); + }); + + it('cancels a pending banner and resets state on lock', async () => { + await withController( + { externalState: buildExternalState({ enabledEvmChainIds: ['0x89'] }) }, + ({ controller, setNetworkControllerState, setKeyringUnlocked }) => { + setNetworkControllerState({ + networkConfigurationsByChainId: { + '0x89': buildNetworkConfiguration({ + chainId: '0x89', + rpcEndpoints: [ + buildCustomEndpoint({ + networkClientId: POLYGON_CUSTOM_CLIENT_ID, + url: 'https://polygon-rpc.com', + }), + ], + }), + }, + networksMetadata: { + [POLYGON_CUSTOM_CLIENT_ID]: buildNetworkMetadata( + NetworkStatus.Unavailable, + ), + }, + }); + + jest.advanceTimersByTime(5_000); + expect(controller.state.networkConnectionBannerStatus).toBe( + 'degraded', + ); + + setKeyringUnlocked(false); + jest.advanceTimersByTime(30_000); + expect(controller.state).toStrictEqual({ + networkConnectionBannerStatus: 'available', + networkConnectionBannerNetwork: null, + }); + }, + ); + }); + + it('ignores upstream state changes after the UI closes', async () => { + await withController( + { externalState: buildExternalState({ enabledEvmChainIds: ['0x89'] }) }, + ({ controller, setNetworkControllerState, setUiOpen }) => { + setUiOpen(false); + setNetworkControllerState({ + networkConfigurationsByChainId: { + '0x89': buildNetworkConfiguration({ + chainId: '0x89', + rpcEndpoints: [ + buildCustomEndpoint({ + networkClientId: POLYGON_CUSTOM_CLIENT_ID, + url: 'https://polygon-rpc.com', + }), + ], + }), + }, + networksMetadata: { + [POLYGON_CUSTOM_CLIENT_ID]: buildNetworkMetadata( + NetworkStatus.Unavailable, + ), + }, + }); + + jest.advanceTimersByTime(30_000); + expect(controller.state.networkConnectionBannerStatus).toBe( + 'available', + ); + }, + ); + }); + + it('stays dormant when the wallet locks without ever having started', async () => { + await withController( + { start: false }, + ({ controller, setKeyringUnlocked }) => { + setKeyringUnlocked(false); + setKeyringUnlocked(false); + expect(controller.state).toStrictEqual({ + networkConnectionBannerStatus: 'available', + networkConnectionBannerNetwork: null, + }); + }, + ); + }); + + it('bails out when a stateChanged listener locks the wallet synchronously during refresh', async () => { + await withController( + ({ + controller, + controllerMessenger, + publishNetworkStateChanges, + setKeyringUnlocked, + }) => { + // Escalate the banner to `unavailable` so state is non default and the + // next refresh's pre timer `update` actually mutates state. + publishNetworkStateChanges( + buildExternalState({ + networkConfigurationsByChainId: { + '0x89': buildNetworkConfiguration({ + chainId: '0x89', + rpcEndpoints: [ + buildCustomEndpoint({ + networkClientId: POLYGON_CUSTOM_CLIENT_ID, + url: 'https://polygon-rpc.com', + }), + ], + }), + }, + enabledEvmChainIds: ['0x89'], + networksMetadata: { + [POLYGON_CUSTOM_CLIENT_ID]: buildNetworkMetadata( + NetworkStatus.Unavailable, + ), + }, + }), + ); + jest.advanceTimersByTime(30_000); + expect(controller.state.networkConnectionBannerStatus).toBe( + 'unavailable', + ); + + let stopped = false; + controllerMessenger.subscribe( + 'NetworkConnectionBannerController:stateChanged', + () => { + if (!stopped) { + stopped = true; + setKeyringUnlocked(false); + } + }, + ); + + // Trigger a refresh whose pre timer `update` will fire `stateChanged` + // (previous state was `unavailable`/polygon → available/null). + publishNetworkStateChanges( + buildExternalState({ + networkConfigurationsByChainId: { + '0x1': buildNetworkConfiguration({ + chainId: '0x1', + rpcEndpoints: [ + buildCustomEndpoint({ + networkClientId: ALCHEMY_CLIENT_ID, + url: 'https://eth-mainnet.alchemyapi.io/v2/abc', + }), + ], + }), + }, + enabledEvmChainIds: ['0x1'], + networksMetadata: { + [ALCHEMY_CLIENT_ID]: buildNetworkMetadata( + NetworkStatus.Unavailable, + ), + }, + }), + ); + + jest.advanceTimersByTime(30_000); + expect(controller.state).toStrictEqual({ + networkConnectionBannerStatus: 'available', + networkConnectionBannerNetwork: null, + }); + }, + ); + }); + + it('bails out when a stateChanged listener locks the wallet synchronously at the degraded fire', async () => { + await withController( + ({ + controller, + controllerMessenger, + publishNetworkStateChanges, + setKeyringUnlocked, + }) => { + controllerMessenger.subscribe( + 'NetworkConnectionBannerController:stateChanged', + (state) => { + if (state.networkConnectionBannerStatus === 'degraded') { + setKeyringUnlocked(false); + } + }, + ); + + publishNetworkStateChanges( + buildExternalState({ + networkConfigurationsByChainId: { + '0x89': buildNetworkConfiguration({ + chainId: '0x89', + rpcEndpoints: [ + buildCustomEndpoint({ + networkClientId: POLYGON_CUSTOM_CLIENT_ID, + url: 'https://polygon-rpc.com', + }), + ], + }), + }, + enabledEvmChainIds: ['0x89'], + networksMetadata: { + [POLYGON_CUSTOM_CLIENT_ID]: buildNetworkMetadata( + NetworkStatus.Unavailable, + ), + }, + }), + ); + + // Advance to fire the degraded timer; its `update` triggers the + // listener, which locks the wallet. The guard should bail before + // scheduling the unavailable escalation. + jest.advanceTimersByTime(30_000); + expect(controller.state).toStrictEqual({ + networkConnectionBannerStatus: 'available', + networkConnectionBannerNetwork: null, + }); + }, + ); + }); + }); + + describe('on NetworkController:stateChange', () => { + it('does not show the banner when only one Infura network is failing alongside healthy peers (single-provider blip)', async () => { + await withController(({ controller, publishNetworkStateChanges }) => { + publishNetworkStateChanges( + buildExternalState({ + networkConfigurationsByChainId: { + '0x1': buildNetworkConfiguration({ + chainId: '0x1', + rpcEndpoints: [ + buildInfuraEndpoint({ + networkClientId: MAINNET_CLIENT_ID, + infuraNetworkType: 'mainnet', + }), + ], + }), + '0xaa36a7': buildNetworkConfiguration({ + chainId: '0xaa36a7', + name: 'Sepolia', + nativeCurrency: 'SepoliaETH', + rpcEndpoints: [ + buildInfuraEndpoint({ + networkClientId: SEPOLIA_CLIENT_ID, + infuraNetworkType: 'sepolia', + }), + ], + }), + }, + networksMetadata: { + [MAINNET_CLIENT_ID]: buildNetworkMetadata( + NetworkStatus.Unavailable, + ), + [SEPOLIA_CLIENT_ID]: buildNetworkMetadata( + NetworkStatus.Available, + ), + }, + }), + ); + + jest.advanceTimersByTime(30_000); + + expect(controller.state).toStrictEqual({ + networkConnectionBannerStatus: 'available', + networkConnectionBannerNetwork: null, + }); + }); + }); + + it('does not show the banner when many Infura networks are failing simultaneously alongside a healthy custom peer', async () => { + await withController(({ controller, publishNetworkStateChanges }) => { + publishNetworkStateChanges( + buildExternalState({ + networkConfigurationsByChainId: { + '0x1': buildNetworkConfiguration({ + chainId: '0x1', + rpcEndpoints: [ + buildInfuraEndpoint({ + networkClientId: MAINNET_CLIENT_ID, + infuraNetworkType: 'mainnet', + }), + ], + }), + '0xaa36a7': buildNetworkConfiguration({ + chainId: '0xaa36a7', + name: 'Sepolia', + nativeCurrency: 'SepoliaETH', + rpcEndpoints: [ + buildInfuraEndpoint({ + networkClientId: SEPOLIA_CLIENT_ID, + infuraNetworkType: 'sepolia', + }), + ], + }), + '0x89': buildNetworkConfiguration({ + chainId: '0x89', + rpcEndpoints: [ + buildCustomEndpoint({ + networkClientId: POLYGON_CUSTOM_CLIENT_ID, + url: 'https://polygon-rpc.com', + }), + ], + }), + }, + networksMetadata: { + [MAINNET_CLIENT_ID]: buildNetworkMetadata( + NetworkStatus.Unavailable, + ), + [SEPOLIA_CLIENT_ID]: buildNetworkMetadata( + NetworkStatus.Unavailable, + ), + [POLYGON_CUSTOM_CLIENT_ID]: buildNetworkMetadata( + NetworkStatus.Available, + ), + }, + }), + ); + + jest.advanceTimersByTime(30_000); + + expect(controller.state.networkConnectionBannerStatus).toBe( + 'available', + ); + }); + }); + + it('walks the full degraded-to-unavailable escalation when Infura and custom networks fail together', async () => { + await withController(({ controller, publishNetworkStateChanges }) => { + publishNetworkStateChanges( + buildExternalState({ + networkConfigurationsByChainId: { + '0x1': buildNetworkConfiguration({ + chainId: '0x1', + rpcEndpoints: [ + buildInfuraEndpoint({ + networkClientId: MAINNET_CLIENT_ID, + infuraNetworkType: 'mainnet', + }), + ], + }), + '0xa4b1': buildNetworkConfiguration({ + chainId: '0xa4b1', + name: 'Arbitrum One', + nativeCurrency: 'ETH', + rpcEndpoints: [ + buildCustomEndpoint({ + networkClientId: ALCHEMY_CLIENT_ID, + url: 'https://arb-mainnet.g.alchemy.com/v2/abc', + }), + ], + }), + }, + networksMetadata: { + [MAINNET_CLIENT_ID]: buildNetworkMetadata( + NetworkStatus.Unavailable, + ), + [ALCHEMY_CLIENT_ID]: buildNetworkMetadata( + NetworkStatus.Unavailable, + ), + }, + }), + ); + + // Below the degraded threshold — banner still hidden. + jest.advanceTimersByTime(4_999); + expect(controller.state.networkConnectionBannerStatus).toBe( + 'available', + ); + + // Cross the 5s mark — degraded banner appears. Custom override surfaces + // the Alchemy network so the "Switch to Infura" CTA targets it. + jest.advanceTimersByTime(1); + expect(controller.state.networkConnectionBannerStatus).toBe('degraded'); + expect(controller.state.networkConnectionBannerNetwork).toMatchObject({ + chainId: '0xa4b1', + isInfuraEndpoint: false, + rpcUrl: 'https://arb-mainnet.g.alchemy.com/v2/abc', + }); + + // Cross the 30s mark — escalates to unavailable. + jest.advanceTimersByTime(25_000); + expect(controller.state.networkConnectionBannerStatus).toBe( + 'unavailable', + ); + }); + }); + + it('treats a custom endpoint carrying our substituted Infura URL as Infura (popular network add)', async () => { + await withController(({ controller, publishNetworkStateChanges }) => { + publishNetworkStateChanges( + buildExternalState({ + networkConfigurationsByChainId: { + '0xa86a': buildNetworkConfiguration({ + chainId: '0xa86a', + name: 'Avalanche', + nativeCurrency: 'AVAX', + rpcEndpoints: [ + buildCustomEndpoint({ + networkClientId: 'avalanche-popular', + url: `https://avalanche-mainnet.infura.io/v3/${TEST_INFURA_PROJECT_ID}`, + }), + ], + }), + '0x1': buildNetworkConfiguration({ + chainId: '0x1', + rpcEndpoints: [ + buildInfuraEndpoint({ + networkClientId: MAINNET_CLIENT_ID, + infuraNetworkType: 'mainnet', + }), + ], + }), + }, + enabledEvmChainIds: ['0xa86a', '0x1'], + networksMetadata: { + 'avalanche-popular': buildNetworkMetadata( + NetworkStatus.Unavailable, + ), + [MAINNET_CLIENT_ID]: buildNetworkMetadata( + NetworkStatus.Available, + ), + }, + }), + ); + + // A single failing MetaMask Infura endpoint amid healthy peers is a + // provider blip, not a custom failure, so no banner. + jest.advanceTimersByTime(30_000); + expect(controller.state.networkConnectionBannerStatus).toBe( + 'available', + ); + }); + }); + + it('shows the banner when a single custom RPC fails amid healthy Infura peers (custom override)', async () => { + await withController(({ controller, publishNetworkStateChanges }) => { + publishNetworkStateChanges( + buildExternalState({ + networkConfigurationsByChainId: { + '0x1': buildNetworkConfiguration({ + chainId: '0x1', + rpcEndpoints: [ + buildInfuraEndpoint({ + networkClientId: MAINNET_CLIENT_ID, + infuraNetworkType: 'mainnet', + }), + ], + }), + '0x89': buildNetworkConfiguration({ + chainId: '0x89', + rpcEndpoints: [ + buildCustomEndpoint({ + networkClientId: POLYGON_CUSTOM_CLIENT_ID, + url: 'https://polygon-rpc.com', + }), + ], + }), + }, + networksMetadata: { + [MAINNET_CLIENT_ID]: buildNetworkMetadata( + NetworkStatus.Available, + ), + [POLYGON_CUSTOM_CLIENT_ID]: buildNetworkMetadata( + NetworkStatus.Unavailable, + ), + }, + }), + ); + + jest.advanceTimersByTime(5_000); + + expect(controller.state.networkConnectionBannerStatus).toBe('degraded'); + expect(controller.state.networkConnectionBannerNetwork).toMatchObject({ + chainId: '0x89', + isInfuraEndpoint: false, + }); + }); + }); + + it('shows the banner when every enabled network is failing (all-down escape hatch)', async () => { + await withController(({ controller, publishNetworkStateChanges }) => { + publishNetworkStateChanges( + buildExternalState({ + networkConfigurationsByChainId: { + '0x1': buildNetworkConfiguration({ + chainId: '0x1', + rpcEndpoints: [ + buildInfuraEndpoint({ + networkClientId: MAINNET_CLIENT_ID, + infuraNetworkType: 'mainnet', + }), + ], + }), + }, + enabledEvmChainIds: ['0x1'], + networksMetadata: { + [MAINNET_CLIENT_ID]: buildNetworkMetadata( + NetworkStatus.Unavailable, + ), + }, + }), + ); + + jest.advanceTimersByTime(5_000); + + expect(controller.state.networkConnectionBannerStatus).toBe('degraded'); + expect(controller.state.networkConnectionBannerNetwork).toMatchObject({ + chainId: '0x1', + isInfuraEndpoint: true, + }); + }); + }); + + it('does not show the banner when an Infura network fails while another enabled network still has unknown status', async () => { + await withController(({ controller, publishNetworkStateChanges }) => { + publishNetworkStateChanges( + buildExternalState({ + networkConfigurationsByChainId: { + '0x1': buildNetworkConfiguration({ + chainId: '0x1', + rpcEndpoints: [ + buildInfuraEndpoint({ + networkClientId: MAINNET_CLIENT_ID, + infuraNetworkType: 'mainnet', + }), + ], + }), + '0xaa36a7': buildNetworkConfiguration({ + chainId: '0xaa36a7', + name: 'Sepolia', + nativeCurrency: 'SepoliaETH', + rpcEndpoints: [ + buildInfuraEndpoint({ + networkClientId: SEPOLIA_CLIENT_ID, + infuraNetworkType: 'sepolia', + }), + ], + }), + }, + networksMetadata: { + [MAINNET_CLIENT_ID]: buildNetworkMetadata( + NetworkStatus.Unavailable, + ), + }, + }), + ); + + // The network without metadata has not been looked up yet, so it does + // not count as failed and the all-down escape hatch must not trigger. + jest.advanceTimersByTime(30_000); + + expect(controller.state.networkConnectionBannerStatus).toBe( + 'available', + ); + }); + }); + + it('prefers a custom failure over an Infura one when surfacing the banner network', async () => { + await withController(({ controller, publishNetworkStateChanges }) => { + publishNetworkStateChanges( + buildExternalState({ + networkConfigurationsByChainId: { + '0x1': buildNetworkConfiguration({ + chainId: '0x1', + rpcEndpoints: [ + buildInfuraEndpoint({ + networkClientId: MAINNET_CLIENT_ID, + infuraNetworkType: 'mainnet', + }), + ], + }), + '0x89': buildNetworkConfiguration({ + chainId: '0x89', + rpcEndpoints: [ + buildCustomEndpoint({ + networkClientId: POLYGON_CUSTOM_CLIENT_ID, + url: 'https://polygon-rpc.com', + }), + ], + }), + }, + networksMetadata: { + [MAINNET_CLIENT_ID]: buildNetworkMetadata( + NetworkStatus.Unavailable, + ), + [POLYGON_CUSTOM_CLIENT_ID]: buildNetworkMetadata( + NetworkStatus.Unavailable, + ), + }, + }), + ); + + jest.advanceTimersByTime(5_000); + + expect(controller.state.networkConnectionBannerNetwork).toMatchObject({ + chainId: '0x89', + }); + }); + }); + + it('only updates the failed-network detail (not the timers) when the same chain keeps failing across re-evaluations', async () => { + await withController(({ controller, publishNetworkStateChanges }) => { + const config = buildNetworkConfiguration({ + chainId: '0x1', + rpcEndpoints: [ + buildCustomEndpoint({ + networkClientId: POLYGON_CUSTOM_CLIENT_ID, + url: 'https://polygon-rpc.com', + }), + ], + }); + publishNetworkStateChanges( + buildExternalState({ + networkConfigurationsByChainId: { '0x1': config }, + enabledEvmChainIds: ['0x1'], + networksMetadata: { + [POLYGON_CUSTOM_CLIENT_ID]: buildNetworkMetadata( + NetworkStatus.Unavailable, + ), + }, + }), + ); + + jest.advanceTimersByTime(5_000); + expect(controller.state.networkConnectionBannerStatus).toBe('degraded'); + + // Same chain still failing — should be a no-op update (no timer reset). + publishNetworkStateChanges( + buildExternalState({ + networkConfigurationsByChainId: { '0x1': config }, + enabledEvmChainIds: ['0x1'], + networksMetadata: { + [POLYGON_CUSTOM_CLIENT_ID]: buildNetworkMetadata( + NetworkStatus.Blocked, + ), + }, + }), + ); + + // 25s after the original degraded fire — the unavailable escalation + // should still happen on schedule (timers were not restarted). + jest.advanceTimersByTime(25_000); + expect(controller.state.networkConnectionBannerStatus).toBe( + 'unavailable', + ); + }); + }); + + it('does not restart the degraded timer when the same network fails across re-evaluations', async () => { + await withController(({ controller, publishNetworkStateChanges }) => { + const buildFailingState = (status: NetworkStatus): ExternalState => + buildExternalState({ + networkConfigurationsByChainId: { + '0x89': buildNetworkConfiguration({ + chainId: '0x89', + rpcEndpoints: [ + buildCustomEndpoint({ + networkClientId: POLYGON_CUSTOM_CLIENT_ID, + url: 'https://polygon-rpc.com', + }), + ], + }), + }, + enabledEvmChainIds: ['0x89'], + networksMetadata: { + [POLYGON_CUSTOM_CLIENT_ID]: buildNetworkMetadata(status), + }, + }); + + publishNetworkStateChanges(buildFailingState(NetworkStatus.Unknown)); + jest.advanceTimersByTime(4_000); + + // A changed NetworkController state that still resolves to the same + // failing network must not clear and restart the pending countdown. + // (A republished identical state would be deduped by the + // subscription selector and never reach the controller.) + publishNetworkStateChanges( + buildFailingState(NetworkStatus.Unavailable), + ); + jest.advanceTimersByTime(1_000); + + expect(controller.state.networkConnectionBannerStatus).toBe('degraded'); + }); + }); + + it('cancels the banner if the network recovers between the degraded-timer scheduling and its firing', async () => { + await withController(({ controller, publishNetworkStateChanges }) => { + publishNetworkStateChanges( + buildExternalState({ + networkConfigurationsByChainId: { + '0x89': buildNetworkConfiguration({ + chainId: '0x89', + rpcEndpoints: [ + buildCustomEndpoint({ + networkClientId: POLYGON_CUSTOM_CLIENT_ID, + url: 'https://polygon-rpc.com', + }), + ], + }), + }, + enabledEvmChainIds: ['0x89'], + networksMetadata: { + [POLYGON_CUSTOM_CLIENT_ID]: buildNetworkMetadata( + NetworkStatus.Unavailable, + ), + }, + }), + ); + + // Advance 4s — degraded timer is scheduled but not yet fired. + jest.advanceTimersByTime(4_000); + + // Network recovers in the meantime. The next state-change clears + // the timer. + publishNetworkStateChanges( + buildExternalState({ + networkConfigurationsByChainId: { + '0x89': buildNetworkConfiguration({ + chainId: '0x89', + rpcEndpoints: [ + buildCustomEndpoint({ + networkClientId: POLYGON_CUSTOM_CLIENT_ID, + url: 'https://polygon-rpc.com', + }), + ], + }), + }, + enabledEvmChainIds: ['0x89'], + networksMetadata: { + [POLYGON_CUSTOM_CLIENT_ID]: buildNetworkMetadata( + NetworkStatus.Available, + ), + }, + }), + ); + + jest.advanceTimersByTime(30_000); + expect(controller.state.networkConnectionBannerStatus).toBe( + 'available', + ); + }); + }); + + it('skips enabled chains that have no network configuration', async () => { + await withController(({ controller, publishNetworkStateChanges }) => { + publishNetworkStateChanges({ + NetworkController: { + networkConfigurationsByChainId: {}, + networksMetadata: {}, + }, + NetworkEnablementController: buildNetworkEnablementControllerState({ + enabledNetworkMap: { + [KnownCaipNamespace.Eip155]: { + '0x1': true, + }, + }, + }), + ConnectivityController: { + connectivityStatus: CONNECTIVITY_STATUSES.Online, + }, + }); + jest.advanceTimersByTime(30_000); + expect(controller.state.networkConnectionBannerStatus).toBe( + 'available', + ); + }); + }); + + it('clears banner state when all enabled networks recover', async () => { + await withController(({ controller, publishNetworkStateChanges }) => { + const failingConfig = buildNetworkConfiguration({ + chainId: '0x89', + rpcEndpoints: [ + buildCustomEndpoint({ + networkClientId: POLYGON_CUSTOM_CLIENT_ID, + url: 'https://polygon-rpc.com', + }), + ], + }); + publishNetworkStateChanges( + buildExternalState({ + networkConfigurationsByChainId: { '0x89': failingConfig }, + enabledEvmChainIds: ['0x89'], + networksMetadata: { + [POLYGON_CUSTOM_CLIENT_ID]: buildNetworkMetadata( + NetworkStatus.Unavailable, + ), + }, + }), + ); + + jest.advanceTimersByTime(5_000); + expect(controller.state.networkConnectionBannerStatus).toBe('degraded'); + + publishNetworkStateChanges( + buildExternalState({ + networkConfigurationsByChainId: { '0x89': failingConfig }, + enabledEvmChainIds: ['0x89'], + networksMetadata: { + [POLYGON_CUSTOM_CLIENT_ID]: buildNetworkMetadata( + NetworkStatus.Available, + ), + }, + }), + ); + + expect(controller.state).toStrictEqual({ + networkConnectionBannerStatus: 'available', + networkConnectionBannerNetwork: null, + }); + }); + }); + + it('treats an unparseable RPC URL as non-Infura when classifying failures', async () => { + await withController(({ controller, publishNetworkStateChanges }) => { + publishNetworkStateChanges( + buildExternalState({ + networkConfigurationsByChainId: { + '0x1': buildNetworkConfiguration({ + chainId: '0x1', + rpcEndpoints: [ + buildCustomEndpoint({ + networkClientId: MAINNET_CLIENT_ID, + url: 'not a valid url', + }), + ], + }), + }, + enabledEvmChainIds: ['0x1'], + networksMetadata: { + [MAINNET_CLIENT_ID]: buildNetworkMetadata( + NetworkStatus.Unavailable, + ), + }, + }), + ); + + jest.advanceTimersByTime(5_000); + expect(controller.state.networkConnectionBannerStatus).toBe('degraded'); + expect(controller.state.networkConnectionBannerNetwork).toMatchObject({ + isInfuraEndpoint: false, + }); + }); + }); + + it('keeps the banner hidden when the enablement map has no EVM namespace at all', async () => { + await withController(({ controller, publishNetworkStateChanges }) => { + publishNetworkStateChanges({ + NetworkController: { + networkConfigurationsByChainId: {}, + networksMetadata: {}, + }, + NetworkEnablementController: buildNetworkEnablementControllerState(), + ConnectivityController: { + connectivityStatus: CONNECTIVITY_STATUSES.Online, + }, + }); + jest.advanceTimersByTime(30_000); + expect(controller.state.networkConnectionBannerStatus).toBe( + 'available', + ); + }); + }); + + it('skips configurations whose default RPC endpoint is missing', async () => { + await withController(({ controller, publishNetworkStateChanges }) => { + publishNetworkStateChanges( + buildExternalState({ + networkConfigurationsByChainId: { + '0x1': { + chainId: '0x1', + name: 'Broken', + nativeCurrency: 'ETH', + rpcEndpoints: [], + defaultRpcEndpointIndex: 0, + blockExplorerUrls: [], + defaultBlockExplorerUrlIndex: 0, + }, + }, + enabledEvmChainIds: ['0x1'], + }), + ); + jest.advanceTimersByTime(30_000); + expect(controller.state.networkConnectionBannerStatus).toBe( + 'available', + ); + }); + }); + + it('reports the Infura endpoint to switch to when the failing network has one', async () => { + await withController(({ controller, publishNetworkStateChanges }) => { + publishNetworkStateChanges( + buildExternalState({ + networkConfigurationsByChainId: { + '0x1': buildNetworkConfiguration({ + chainId: '0x1', + rpcEndpoints: [ + buildCustomEndpoint({ + networkClientId: ALCHEMY_CLIENT_ID, + url: 'https://eth-mainnet.alchemyapi.io/v2/abc', + }), + buildInfuraEndpoint({ + networkClientId: MAINNET_CLIENT_ID, + infuraNetworkType: 'mainnet', + }), + ], + }), + }, + enabledEvmChainIds: ['0x1'], + networksMetadata: { + [ALCHEMY_CLIENT_ID]: buildNetworkMetadata( + NetworkStatus.Unavailable, + ), + }, + }), + ); + + jest.advanceTimersByTime(5_000); + + expect(controller.state.networkConnectionBannerNetwork).toMatchObject({ + chainId: '0x1', + isInfuraEndpoint: false, + switchableInfuraNetworkClientId: MAINNET_CLIENT_ID, + // Sanity-check: not null when there's an Infura endpoint to offer. + }); + }); + }); + }); + + describe('on NetworkEnablementController:stateChange', () => { + it('re-evaluates the rule when a failing chain becomes enabled', async () => { + await withController( + { + externalState: buildExternalState({ + networkConfigurationsByChainId: { + '0x89': buildNetworkConfiguration({ + chainId: '0x89', + rpcEndpoints: [ + buildCustomEndpoint({ + networkClientId: POLYGON_CUSTOM_CLIENT_ID, + url: 'https://polygon-rpc.com', + }), + ], + }), + }, + networksMetadata: { + [POLYGON_CUSTOM_CLIENT_ID]: buildNetworkMetadata( + NetworkStatus.Unavailable, + ), + }, + enabledEvmChainIds: [], + }), + }, + ({ controller, setNetworkEnablementControllerState }) => { + jest.advanceTimersByTime(30_000); + expect(controller.state.networkConnectionBannerStatus).toBe( + 'available', + ); + + setNetworkEnablementControllerState( + buildNetworkEnablementControllerState({ + enabledNetworkMap: { + [KnownCaipNamespace.Eip155]: { + '0x89': true, + }, + }, + }), + ); + + jest.advanceTimersByTime(5_000); + expect(controller.state.networkConnectionBannerStatus).toBe( + 'degraded', + ); + }, + ); + }); + + it('clears the banner when the failing chain gets disabled', async () => { + await withController( + { + externalState: buildExternalState({ + networkConfigurationsByChainId: { + '0x89': buildNetworkConfiguration({ + chainId: '0x89', + rpcEndpoints: [ + buildCustomEndpoint({ + networkClientId: POLYGON_CUSTOM_CLIENT_ID, + url: 'https://polygon-rpc.com', + }), + ], + }), + }, + networksMetadata: { + [POLYGON_CUSTOM_CLIENT_ID]: buildNetworkMetadata( + NetworkStatus.Unavailable, + ), + }, + enabledEvmChainIds: ['0x89'], + }), + }, + ({ controller, setNetworkEnablementControllerState }) => { + jest.advanceTimersByTime(30_000); + expect(controller.state.networkConnectionBannerStatus).toBe( + 'unavailable', + ); + + setNetworkEnablementControllerState( + buildNetworkEnablementControllerState({ + enabledNetworkMap: { + [KnownCaipNamespace.Eip155]: { + '0x89': false, + }, + }, + }), + ); + + expect(controller.state).toStrictEqual({ + networkConnectionBannerStatus: 'available', + networkConnectionBannerNetwork: null, + }); + }, + ); + }); + }); + + describe('on ConnectivityController:stateChange', () => { + it('does not touch banner state when going offline while no banner is shown', async () => { + await withController(({ controller, setConnectivityStatus }) => { + const before = controller.state; + setConnectivityStatus(CONNECTIVITY_STATUSES.Offline); + expect(controller.state).toStrictEqual(before); + }); + }); + + it('suppresses the banner while the device is offline and reinstates it when back online', async () => { + await withController( + ({ controller, publishNetworkStateChanges, setConnectivityStatus }) => { + publishNetworkStateChanges( + buildExternalState({ + networkConfigurationsByChainId: { + '0x1': buildNetworkConfiguration({ + chainId: '0x1', + rpcEndpoints: [ + buildCustomEndpoint({ + networkClientId: POLYGON_CUSTOM_CLIENT_ID, + url: 'https://polygon-rpc.com', + }), + ], + }), + }, + enabledEvmChainIds: ['0x1'], + networksMetadata: { + [POLYGON_CUSTOM_CLIENT_ID]: buildNetworkMetadata( + NetworkStatus.Unavailable, + ), + }, + }), + ); + + jest.advanceTimersByTime(5_000); + expect(controller.state.networkConnectionBannerStatus).toBe( + 'degraded', + ); + + setConnectivityStatus(CONNECTIVITY_STATUSES.Offline); + expect(controller.state.networkConnectionBannerStatus).toBe( + 'available', + ); + expect(controller.state.networkConnectionBannerNetwork).toBeNull(); + + setConnectivityStatus(CONNECTIVITY_STATUSES.Online); + jest.advanceTimersByTime(5_000); + expect(controller.state.networkConnectionBannerStatus).toBe( + 'degraded', + ); + }, + ); + }); + }); + + describe('dismissBanner', () => { + it('is a no-op when no banner is currently shown', async () => { + await withController(({ controller }) => { + const before = controller.state; + controller.dismissBanner(); + expect(controller.state).toStrictEqual(before); + }); + }); + + it('clears banner state via direct call', async () => { + await withController(({ controller, publishNetworkStateChanges }) => { + publishNetworkStateChanges( + buildExternalState({ + networkConfigurationsByChainId: { + '0x1': buildNetworkConfiguration({ + chainId: '0x1', + rpcEndpoints: [ + buildCustomEndpoint({ + networkClientId: POLYGON_CUSTOM_CLIENT_ID, + url: 'https://polygon-rpc.com', + }), + ], + }), + }, + enabledEvmChainIds: ['0x1'], + networksMetadata: { + [POLYGON_CUSTOM_CLIENT_ID]: buildNetworkMetadata( + NetworkStatus.Unavailable, + ), + }, + }), + ); + jest.advanceTimersByTime(5_000); + expect(controller.state.networkConnectionBannerStatus).toBe('degraded'); + + controller.dismissBanner(); + expect(controller.state.networkConnectionBannerStatus).toBe( + 'available', + ); + expect(controller.state.networkConnectionBannerNetwork).toBeNull(); + }); + }); + + it('clears banner state via messenger action', async () => { + await withController( + ({ controller, rootMessenger, publishNetworkStateChanges }) => { + publishNetworkStateChanges( + buildExternalState({ + networkConfigurationsByChainId: { + '0x1': buildNetworkConfiguration({ + chainId: '0x1', + rpcEndpoints: [ + buildCustomEndpoint({ + networkClientId: POLYGON_CUSTOM_CLIENT_ID, + url: 'https://polygon-rpc.com', + }), + ], + }), + }, + enabledEvmChainIds: ['0x1'], + networksMetadata: { + [POLYGON_CUSTOM_CLIENT_ID]: buildNetworkMetadata( + NetworkStatus.Unavailable, + ), + }, + }), + ); + jest.advanceTimersByTime(5_000); + + rootMessenger.call('NetworkConnectionBannerController:dismissBanner'); + expect(controller.state.networkConnectionBannerStatus).toBe( + 'available', + ); + }, + ); + }); + }); + + describe('switchToDefaultInfuraRpcEndpoint', () => { + it('makes the Infura endpoint the new default and switches the active network onto it', async () => { + await withController( + async ({ + rootMessenger, + publishNetworkStateChanges, + updateNetwork, + setActiveNetwork, + }) => { + const config = buildNetworkConfiguration({ + chainId: '0x1', + rpcEndpoints: [ + buildCustomEndpoint({ + networkClientId: ALCHEMY_CLIENT_ID, + url: 'https://eth-mainnet.alchemyapi.io/v2/abc', + }), + buildInfuraEndpoint({ + networkClientId: MAINNET_CLIENT_ID, + infuraNetworkType: 'mainnet', + }), + ], + }); + publishNetworkStateChanges( + buildExternalState({ + networkConfigurationsByChainId: { '0x1': config }, + enabledEvmChainIds: ['0x1'], + networksMetadata: { + [ALCHEMY_CLIENT_ID]: buildNetworkMetadata( + NetworkStatus.Unavailable, + ), + }, + }), + ); + + await rootMessenger.call( + 'NetworkConnectionBannerController:switchToDefaultInfuraRpcEndpoint', + '0x1', + ); + + expect(updateNetwork).toHaveBeenCalledTimes(1); + expect(updateNetwork).toHaveBeenCalledWith( + '0x1', + expect.objectContaining({ defaultRpcEndpointIndex: 1 }), + ); + expect(setActiveNetwork).toHaveBeenCalledTimes(1); + expect(setActiveNetwork).toHaveBeenCalledWith(MAINNET_CLIENT_ID); + // The active connection moves first so a partial failure keeps the + // failing default in place and the banner visible. + expect(setActiveNetwork.mock.invocationCallOrder[0]).toBeLessThan( + updateNetwork.mock.invocationCallOrder[0], + ); + }, + ); + }); + + it('does not switch the active network when the Infura endpoint is already selected', async () => { + await withController( + async ({ + rootMessenger, + publishNetworkStateChanges, + setNetworkControllerState, + updateNetwork, + setActiveNetwork, + }) => { + const config = buildNetworkConfiguration({ + chainId: '0x1', + rpcEndpoints: [ + buildCustomEndpoint({ + networkClientId: ALCHEMY_CLIENT_ID, + url: 'https://eth-mainnet.alchemyapi.io/v2/abc', + }), + buildInfuraEndpoint({ + networkClientId: MAINNET_CLIENT_ID, + infuraNetworkType: 'mainnet', + }), + ], + }); + publishNetworkStateChanges( + buildExternalState({ + networkConfigurationsByChainId: { '0x1': config }, + enabledEvmChainIds: ['0x1'], + }), + ); + setNetworkControllerState({ + networkConfigurationsByChainId: { '0x1': config }, + networksMetadata: {}, + selectedNetworkClientId: MAINNET_CLIENT_ID, + }); + + await rootMessenger.call( + 'NetworkConnectionBannerController:switchToDefaultInfuraRpcEndpoint', + '0x1', + ); + + expect(updateNetwork).toHaveBeenCalledTimes(1); + expect(setActiveNetwork).not.toHaveBeenCalled(); + }, + ); + }); + + it('is a no-op when the default is already Infura', async () => { + await withController( + async ({ + rootMessenger, + publishNetworkStateChanges, + updateNetwork, + }) => { + publishNetworkStateChanges( + buildExternalState({ + networkConfigurationsByChainId: { + '0x1': buildNetworkConfiguration({ + chainId: '0x1', + rpcEndpoints: [ + buildInfuraEndpoint({ + networkClientId: MAINNET_CLIENT_ID, + infuraNetworkType: 'mainnet', + }), + ], + }), + }, + enabledEvmChainIds: ['0x1'], + }), + ); + + await rootMessenger.call( + 'NetworkConnectionBannerController:switchToDefaultInfuraRpcEndpoint', + '0x1', + ); + + expect(updateNetwork).not.toHaveBeenCalled(); + }, + ); + }); + + it('throws when no network configuration exists for the chain', async () => { + await withController(async ({ rootMessenger }) => { + await expect( + rootMessenger.call( + 'NetworkConnectionBannerController:switchToDefaultInfuraRpcEndpoint', + '0xdeadbeef', + ), + ).rejects.toThrow(/No network configuration found/u); + }); + }); + + it('throws when the chain has no Infura endpoint to switch to', async () => { + await withController( + async ({ rootMessenger, publishNetworkStateChanges }) => { + publishNetworkStateChanges( + buildExternalState({ + networkConfigurationsByChainId: { + '0x1': buildNetworkConfiguration({ + chainId: '0x1', + rpcEndpoints: [ + buildCustomEndpoint({ + networkClientId: ALCHEMY_CLIENT_ID, + url: 'https://eth-mainnet.alchemyapi.io/v2/abc', + }), + ], + }), + }, + enabledEvmChainIds: ['0x1'], + }), + ); + + await expect( + rootMessenger.call( + 'NetworkConnectionBannerController:switchToDefaultInfuraRpcEndpoint', + '0x1', + ), + ).rejects.toThrow(/No Infura endpoint available/u); + }, + ); + }); + }); +}); + +// --------------------------------------------------------------------------- +// Test helpers +// --------------------------------------------------------------------------- + +function buildNetworkMetadata(status: NetworkStatus): { + // eslint-disable-next-line @typescript-eslint/naming-convention + EIPS: Record; + status: NetworkStatus; +} { + return { EIPS: {}, status }; +} + +type BuildExternalStateArgs = { + networkConfigurationsByChainId?: NetworkState['networkConfigurationsByChainId']; + networksMetadata?: NetworkState['networksMetadata']; + enabledEvmChainIds?: Hex[]; +}; + +// Keys match the messenger namespace of each upstream controller. +/* eslint-disable @typescript-eslint/naming-convention */ +type ExternalState = { + NetworkController: Partial; + NetworkEnablementController: NetworkEnablementControllerState; + ConnectivityController: ConnectivityControllerState; +}; +/* eslint-enable @typescript-eslint/naming-convention */ + +function buildNetworkEnablementControllerState( + overrides: Partial = {}, +): NetworkEnablementControllerState { + return { + enabledNetworkMap: {}, + nativeAssetIdentifiers: {}, + ...overrides, + }; +} + +function buildExternalState({ + networkConfigurationsByChainId = {}, + networksMetadata = {}, + enabledEvmChainIds = Object.keys(networkConfigurationsByChainId) as Hex[], +}: BuildExternalStateArgs = {}): ExternalState { + return { + NetworkController: { + networkConfigurationsByChainId, + networksMetadata, + }, + NetworkEnablementController: buildNetworkEnablementControllerState({ + enabledNetworkMap: { + [KnownCaipNamespace.Eip155]: Object.fromEntries( + enabledEvmChainIds.map((chainId) => [chainId, true]), + ), + }, + }), + ConnectivityController: { + connectivityStatus: CONNECTIVITY_STATUSES.Online, + }, + }; +} + +type AllNetworkConnectionBannerControllerActions = + MessengerActions; +type AllNetworkConnectionBannerControllerEvents = + MessengerEvents; + +type RootMessenger = Messenger< + MockAnyNamespace, + MessengerActions, + MessengerEvents +>; + +type WithControllerCallback = (payload: { + controller: NetworkConnectionBannerController; + rootMessenger: RootMessenger; + controllerMessenger: NetworkConnectionBannerControllerMessenger; + setNetworkControllerState: ( + networkControllerState: Partial, + ) => void; + setNetworkEnablementControllerState: ( + networkEnablementControllerState: NetworkEnablementControllerState, + ) => void; + publishNetworkStateChanges: (state: ExternalState) => void; + setConnectivityStatus: ( + status: ConnectivityControllerState['connectivityStatus'], + ) => void; + setUiOpen: (isUiOpen: boolean) => void; + setKeyringUnlocked: (isUnlocked: boolean) => void; + updateNetwork: jest.Mock; + setActiveNetwork: jest.Mock; +}) => Promise | ReturnValue; + +type WithControllerOptions = { + externalState?: ExternalState; + start?: boolean; + degradedBannerTimeout?: number; + unavailableBannerTimeout?: number; +}; + +async function withController( + ...args: + | [WithControllerCallback] + | [WithControllerOptions, WithControllerCallback] +): Promise { + const [ + { + externalState, + start = true, + degradedBannerTimeout, + unavailableBannerTimeout, + }, + testFunction, + ] = args.length === 2 ? args : [{}, args[0]]; + const rootMessenger: RootMessenger = new Messenger({ + namespace: MOCK_ANY_NAMESPACE, + }); + + let currentState: ExternalState = + externalState ?? + ({ + NetworkController: { + networkConfigurationsByChainId: {}, + networksMetadata: {}, + }, + NetworkEnablementController: buildNetworkEnablementControllerState(), + ConnectivityController: { + connectivityStatus: CONNECTIVITY_STATUSES.Online, + }, + } satisfies ExternalState); + + rootMessenger.registerActionHandler( + 'NetworkController:getState', + () => currentState.NetworkController as NetworkState, + ); + rootMessenger.registerActionHandler( + 'NetworkController:getNetworkConfigurationByChainId', + (chainId) => + currentState.NetworkController.networkConfigurationsByChainId?.[chainId], + ); + const updateNetwork = jest.fn( + async (chainId: Hex): Promise => + currentState.NetworkController.networkConfigurationsByChainId?.[ + chainId + ] ?? buildNetworkConfiguration({ chainId }), + ); + rootMessenger.registerActionHandler( + 'NetworkController:updateNetwork', + updateNetwork, + ); + const setActiveNetwork = jest.fn(async (): Promise => undefined); + rootMessenger.registerActionHandler( + 'NetworkController:setActiveNetwork', + setActiveNetwork, + ); + + rootMessenger.registerActionHandler( + 'NetworkEnablementController:getState', + () => currentState.NetworkEnablementController, + ); + + rootMessenger.registerActionHandler( + 'ConnectivityController:getState', + () => currentState.ConnectivityController, + ); + + const messenger = new Messenger< + 'NetworkConnectionBannerController', + AllNetworkConnectionBannerControllerActions, + AllNetworkConnectionBannerControllerEvents, + RootMessenger + >({ + namespace: 'NetworkConnectionBannerController', + parent: rootMessenger, + }); + + rootMessenger.delegate({ + messenger, + actions: [ + 'NetworkController:getState', + 'NetworkController:getNetworkConfigurationByChainId', + 'NetworkController:updateNetwork', + 'NetworkController:setActiveNetwork', + 'NetworkEnablementController:getState', + 'ConnectivityController:getState', + ], + events: [ + // eslint-disable-next-line no-restricted-syntax -- awaiting upstream :stateChanged migration + 'NetworkController:stateChange', + // eslint-disable-next-line no-restricted-syntax -- awaiting upstream :stateChanged migration + 'NetworkEnablementController:stateChange', + // eslint-disable-next-line no-restricted-syntax -- awaiting upstream :stateChanged migration + 'ConnectivityController:stateChange', + // eslint-disable-next-line no-restricted-syntax -- awaiting upstream :stateChanged migration + 'ClientController:stateChange', + 'KeyringController:unlock', + 'KeyringController:lock', + ], + }); + + const controller = new NetworkConnectionBannerController({ + messenger, + infuraProjectId: TEST_INFURA_PROJECT_ID, + degradedBannerTimeout, + unavailableBannerTimeout, + }); + + const setUiOpen = (isUiOpen: boolean): void => { + rootMessenger.publish('ClientController:stateChange', { isUiOpen }, []); + }; + const setKeyringUnlocked = (isUnlocked: boolean): void => { + rootMessenger.publish( + isUnlocked ? 'KeyringController:unlock' : 'KeyringController:lock', + ); + }; + + if (start) { + setUiOpen(true); + setKeyringUnlocked(true); + } + + const setNetworkControllerState = ( + networkControllerState: Partial, + ): void => { + currentState = { + ...currentState, + NetworkController: networkControllerState, + }; + rootMessenger.publish( + 'NetworkController:stateChange', + currentState.NetworkController as NetworkState, + [], + ); + }; + + const setNetworkEnablementControllerState = ( + networkEnablementControllerState: NetworkEnablementControllerState, + ): void => { + currentState = { + ...currentState, + NetworkEnablementController: networkEnablementControllerState, + }; + rootMessenger.publish( + 'NetworkEnablementController:stateChange', + currentState.NetworkEnablementController, + [], + ); + }; + + // Setup convenience for tests that want to seed both `NetworkController` + // and `NetworkEnablementController` at once. Tests exercising a specific + // peer event should reach for `setNetworkControllerState` / + // `setNetworkEnablementControllerState` instead so the event they publish + // matches the code path they claim to cover. + const publishNetworkStateChanges = (state: ExternalState): void => { + currentState = { + ...currentState, + NetworkController: state.NetworkController, + NetworkEnablementController: state.NetworkEnablementController, + }; + rootMessenger.publish( + 'NetworkController:stateChange', + currentState.NetworkController as NetworkState, + [], + ); + rootMessenger.publish( + 'NetworkEnablementController:stateChange', + currentState.NetworkEnablementController, + [], + ); + }; + + const setConnectivityStatus = ( + status: ConnectivityControllerState['connectivityStatus'], + ): void => { + currentState = { + ...currentState, + ConnectivityController: { connectivityStatus: status }, + }; + rootMessenger.publish( + 'ConnectivityController:stateChange', + currentState.ConnectivityController, + [], + ); + }; + + return await testFunction({ + controller, + rootMessenger, + controllerMessenger: messenger, + setNetworkControllerState, + setNetworkEnablementControllerState, + publishNetworkStateChanges, + setConnectivityStatus, + setUiOpen, + setKeyringUnlocked, + updateNetwork, + setActiveNetwork, + }); +} + +function buildInfuraEndpoint({ + networkClientId, + infuraNetworkType, +}: { + networkClientId: BuiltInNetworkClientId; + infuraNetworkType: BuiltInNetworkClientId; +}): InfuraRpcEndpoint { + return { + networkClientId, + type: RpcEndpointType.Infura, + url: `https://${infuraNetworkType}.infura.io/v3/{infuraProjectId}`, + }; +} + +function buildCustomEndpoint({ + networkClientId, + url, +}: { + networkClientId: string; + url: string; +}): NetworkConfiguration['rpcEndpoints'][number] { + return { + networkClientId, + type: RpcEndpointType.Custom, + url, + }; +} diff --git a/packages/network-connection-banner-controller/src/NetworkConnectionBannerController.ts b/packages/network-connection-banner-controller/src/NetworkConnectionBannerController.ts new file mode 100644 index 00000000000..a3bbdd023e9 --- /dev/null +++ b/packages/network-connection-banner-controller/src/NetworkConnectionBannerController.ts @@ -0,0 +1,846 @@ +import type { + ControllerGetStateAction, + ControllerStateChangedEvent, + StateMetadata, +} from '@metamask/base-controller'; +import { BaseController } from '@metamask/base-controller'; +import { clientControllerSelectors } from '@metamask/client-controller'; +import type { ClientControllerStateChangeEvent } from '@metamask/client-controller'; +import { + CONNECTIVITY_STATUSES, + connectivityControllerSelectors, +} from '@metamask/connectivity-controller'; +import type { + ConnectivityControllerGetStateAction, + ConnectivityControllerState, + ConnectivityControllerStateChangeEvent, +} from '@metamask/connectivity-controller'; +import type { + KeyringControllerLockEvent, + KeyringControllerUnlockEvent, +} from '@metamask/keyring-controller'; +import type { Messenger } from '@metamask/messenger'; +import type { + NetworkConfiguration, + NetworkControllerGetNetworkConfigurationByChainIdAction, + NetworkControllerGetStateAction, + NetworkControllerUpdateNetworkAction, + NetworkControllerSetActiveNetworkAction, + NetworkControllerStateChangeEvent, + NetworkMetadata, + NetworkState, +} from '@metamask/network-controller'; +import { NetworkStatus } from '@metamask/network-controller'; +import type { + NetworkEnablementControllerGetStateAction, + NetworkEnablementControllerState, + NetworkEnablementControllerStateChangeEvent, +} from '@metamask/network-enablement-controller'; +import { selectEnabledNetworkMap } from '@metamask/network-enablement-controller'; +import type { Hex } from '@metamask/utils'; +import { KnownCaipNamespace } from '@metamask/utils'; +import { createSelector } from 'reselect'; + +import type { NetworkConnectionBannerControllerMethodActions } from './NetworkConnectionBannerController-method-action-types.js'; +import { getIsInfuraEndpoint } from './url-utils.js'; + +/** + * The name of the {@link NetworkConnectionBannerController}, used to namespace + * the controller's actions and events and to namespace the controller's state + * data when composed with other controllers. + */ +const CONTROLLER_NAME = 'NetworkConnectionBannerController'; + +/** + * Selects `networksMetadata` from the `NetworkController` state. + * + * @param state - The `NetworkController` state. + * @returns The networks metadata map keyed by network client id. + */ +const selectNetworksMetadata = ( + state: NetworkState, +): NetworkState['networksMetadata'] => state.networksMetadata; + +/** + * Selects `networkConfigurationsByChainId` from the `NetworkController` + * state. + * + * @param state - The `NetworkController` state. + * @returns The network configurations keyed by chain id. + */ +const selectNetworkConfigurationsByChainId = ( + state: NetworkState, +): NetworkState['networkConfigurationsByChainId'] => + state.networkConfigurationsByChainId; + +/** + * Selects the `NetworkController` state fields that influence the banner + * rule. Composed with `createSelector` so the return object stays reference + * stable while unrelated `NetworkController` state (e.g. + * `selectedNetworkClientId`) changes. + * + * @param state - The `NetworkController` state. + * @returns The relevant network fields. + */ +const selectNetworkControllerFields = createSelector( + [selectNetworksMetadata, selectNetworkConfigurationsByChainId], + (networksMetadata, networkConfigurationsByChainId) => ({ + networksMetadata, + networkConfigurationsByChainId, + }), +); + +/** + * Selects the `NetworkEnablementController` state field that influences the + * banner rule. + * + * @param state - The `NetworkEnablementController` state. + * @returns The relevant enablement fields. + */ +const selectNetworkEnablementControllerFields = createSelector( + [selectEnabledNetworkMap], + (enabledNetworkMap) => ({ enabledNetworkMap }), +); + +/** + * Selects the `ConnectivityController` state field that influences the + * banner rule. + * + * @param state - The `ConnectivityController` state. + * @returns The relevant connectivity fields. + */ +const selectConnectivityControllerFields = createSelector( + [connectivityControllerSelectors.selectConnectivityStatus], + (connectivityStatus) => ({ connectivityStatus }), +); + +/** + * Status the banner can be in. `available` means no banner is shown; the + * `degraded` and `unavailable` values mirror the two-tier escalation that the + * UI renders. + */ +export type NetworkConnectionBannerStatus = + | 'available' + | 'degraded' + | 'unavailable'; + +/** + * An enabled network from `NetworkController` state with a default RPC + * endpoint. Used as the input to the failed-network detection pipeline. + * `metadata` is missing until the network's connectivity has been looked up. + */ +type EnabledNetwork = { + chainId: Hex; + name: string; + rpcEndpoints: NetworkConfiguration['rpcEndpoints']; + defaultRpcEndpointIndex: number; + defaultRpcEndpoint: NetworkConfiguration['rpcEndpoints'][number]; + metadata: NetworkMetadata | undefined; +}; + +/** + * Details of a failing network the banner describes. + */ +export type FailedNetwork = { + /** The chain id of the failing network. */ + chainId: Hex; + /** The `networkClientId` of the failing default RPC endpoint. */ + networkClientId: string; + /** The display name for the failing network. */ + name: string; + /** The URL of the failing default RPC endpoint. */ + rpcUrl: string; + /** Whether the failing endpoint is a MetaMask Infura endpoint. */ + isInfuraEndpoint: boolean; + /** + * The networkClientId of an Infura endpoint on the same chain that the user + * can switch to. `null` when the failing endpoint is already Infura or when + * no Infura alternative exists. + */ + switchableInfuraNetworkClientId: string | null; +}; + +/** + * State for the {@link NetworkConnectionBannerController}. + * + * The keys carry the controller-domain prefix (like + * `ConnectivityController`'s `connectivityStatus`) because some clients merge + * all controller states into one flat object, where generic names like + * `status` collide across controllers. + */ +export type NetworkConnectionBannerControllerState = { + networkConnectionBannerStatus: NetworkConnectionBannerStatus; + networkConnectionBannerNetwork: FailedNetwork | null; +}; + +const networkConnectionBannerControllerMetadata = { + networkConnectionBannerStatus: { + persist: false, + includeInDebugSnapshot: true, + includeInStateLogs: true, + usedInUi: true, + }, + networkConnectionBannerNetwork: { + persist: false, + includeInDebugSnapshot: true, + includeInStateLogs: true, + usedInUi: true, + }, +} satisfies StateMetadata; + +/** + * Constructs the default {@link NetworkConnectionBannerController} state. + * + * @returns The default state. + */ +export function getDefaultNetworkConnectionBannerControllerState(): NetworkConnectionBannerControllerState { + return { + networkConnectionBannerStatus: 'available', + networkConnectionBannerNetwork: null, + }; +} + +/** + * The default for how long (in milliseconds) a failing network must remain + * in a "failed" status ("degraded" or "unavailable") before the degraded + * banner appears. + */ +export const DEFAULT_DEGRADED_BANNER_TIMEOUT = 5_000; + +/** + * The default for how long (in milliseconds) a failing network must remain + * in a "failed" status before the banner escalates to "unavailable". + */ +export const DEFAULT_UNAVAILABLE_BANNER_TIMEOUT = 30_000; + +const MESSENGER_EXPOSED_METHODS = [ + 'dismissBanner', + 'switchToDefaultInfuraRpcEndpoint', +] as const; + +/** + * Retrieves the state of the {@link NetworkConnectionBannerController}. + */ +export type NetworkConnectionBannerControllerGetStateAction = + ControllerGetStateAction< + typeof CONTROLLER_NAME, + NetworkConnectionBannerControllerState + >; + +/** + * Actions that {@link NetworkConnectionBannerControllerMessenger} exposes to + * other consumers. + */ +export type NetworkConnectionBannerControllerActions = + | NetworkConnectionBannerControllerGetStateAction + | NetworkConnectionBannerControllerMethodActions; + +/** + * Actions from other messengers that + * {@link NetworkConnectionBannerControllerMessenger} calls. + */ +type AllowedActions = + | NetworkControllerGetStateAction + | NetworkControllerGetNetworkConfigurationByChainIdAction + | NetworkControllerUpdateNetworkAction + | NetworkControllerSetActiveNetworkAction + | NetworkEnablementControllerGetStateAction + | ConnectivityControllerGetStateAction; + +/** + * Published when the state of {@link NetworkConnectionBannerController} + * changes. + */ +export type NetworkConnectionBannerControllerStateChangedEvent = + ControllerStateChangedEvent< + typeof CONTROLLER_NAME, + NetworkConnectionBannerControllerState + >; + +/** + * Events that {@link NetworkConnectionBannerControllerMessenger} exposes to + * other consumers. + */ +export type NetworkConnectionBannerControllerEvents = + NetworkConnectionBannerControllerStateChangedEvent; + +/** + * Events from other messengers that + * {@link NetworkConnectionBannerControllerMessenger} subscribes to. + */ +type AllowedEvents = + | NetworkControllerStateChangeEvent + | NetworkEnablementControllerStateChangeEvent + | ConnectivityControllerStateChangeEvent + | ClientControllerStateChangeEvent + | KeyringControllerUnlockEvent + | KeyringControllerLockEvent; + +/** + * The messenger restricted to actions and events accessed by + * {@link NetworkConnectionBannerController}. + */ +export type NetworkConnectionBannerControllerMessenger = Messenger< + typeof CONTROLLER_NAME, + NetworkConnectionBannerControllerActions | AllowedActions, + NetworkConnectionBannerControllerEvents | AllowedEvents +>; + +/** + * Options for constructing the {@link NetworkConnectionBannerController}. + */ +export type NetworkConnectionBannerControllerOptions = { + /** + * The messenger for inter-controller communication. + */ + messenger: NetworkConnectionBannerControllerMessenger; + + /** + * The wallet's Infura project id, used to recognize MetaMask Infura + * endpoints whose URL was persisted with the id already substituted. + */ + infuraProjectId: string; + + /** + * How long (in milliseconds) a failing network must remain in a "failed" + * status before the degraded banner appears. Defaults to + * {@link DEFAULT_DEGRADED_BANNER_TIMEOUT}. + */ + degradedBannerTimeout?: number; + + /** + * How long (in milliseconds), measured from the same failure start as + * `degradedBannerTimeout`, before the banner escalates to "unavailable". + * Must be greater than `degradedBannerTimeout`. Defaults to + * {@link DEFAULT_UNAVAILABLE_BANNER_TIMEOUT}. + */ + unavailableBannerTimeout?: number; +}; + +/** + * Drives the "network connection banner", a notice that appears within clients + * whose goal is to inform users about networks which have exhibited repeated + * request failures and offer potential workarounds. + * + * Some terminology: A "network" in this case is an RPC endpoint, and a network + * that has exhibited repeated request failures has entered a "failed" state (or + * is a "failed network"). + * + * For simplicity, the banner always represents a single failed network. If + * multiple networks are failing, the first custom network takes priority over + * the first Infura network. + * + * To ensure that the banner is actionable, the banner does not always appear + * even if there is a failed network to display. Instead it appears under these + * conditions: + * + * - The failed network is a custom (non-Infura) endpoint (we want to inform + * users about endpoints they've added so they don't cast blame on MetaMask). + * - Every enabled EVM network has entered a failed state (this indicates a + * broad connectivity issue). + * + * Assuming that these conditions have been met, there are two variants of the + * banner which will be displayed at different times. + * + * - A "degraded" variant which will appear when 5 seconds has elapsed and there + * is an eligible failed network to display + * - An "unavailable" variant which will appear when 30 seconds have elapsed and + * there is an eligible failed network to display + * + * Finally, the controller contains actions that drive interactions with the + * banner. Namely, if the banner represents a custom network, then it will offer + * the user a way to switch to the default Infura network. The controller + * contains the logic to carry out that action. + */ +export class NetworkConnectionBannerController extends BaseController< + typeof CONTROLLER_NAME, + NetworkConnectionBannerControllerState, + NetworkConnectionBannerControllerMessenger +> { + #degradedTimer: ReturnType | undefined; + + #unavailableTimer: ReturnType | undefined; + + #pendingNetworkClientId: string | undefined; + + #isStarted = false; + + /** Whether the client UI is open. Combined with {@link #isUnlocked}. */ + #isUiOpen = false; + + /** Whether the keyring is unlocked. Combined with {@link #isUiOpen}. */ + #isUnlocked = false; + + readonly #infuraProjectId: string; + + readonly #degradedBannerTimeout: number; + + readonly #unavailableBannerTimeout: number; + + /** + * Constructs a new {@link NetworkConnectionBannerController}. + * + * @param args - The arguments to this controller. + * @param args.messenger - The messenger suited for this controller. + * @param args.infuraProjectId - The wallet's Infura project id. + * @param args.degradedBannerTimeout - How long (in milliseconds) a failing + * network must remain failed before the degraded banner appears. + * @param args.unavailableBannerTimeout - How long (in milliseconds) before + * the banner escalates to "unavailable". Must be greater than + * `degradedBannerTimeout`. + * @throws If `unavailableBannerTimeout` is not greater than + * `degradedBannerTimeout`. + */ + constructor({ + messenger, + infuraProjectId, + degradedBannerTimeout = DEFAULT_DEGRADED_BANNER_TIMEOUT, + unavailableBannerTimeout = DEFAULT_UNAVAILABLE_BANNER_TIMEOUT, + }: NetworkConnectionBannerControllerOptions) { + super({ + messenger, + metadata: networkConnectionBannerControllerMetadata, + name: CONTROLLER_NAME, + state: getDefaultNetworkConnectionBannerControllerState(), + }); + + if (unavailableBannerTimeout <= degradedBannerTimeout) { + throw new Error( + `\`unavailableBannerTimeout\` (${unavailableBannerTimeout}) must be greater than \`degradedBannerTimeout\` (${degradedBannerTimeout}).`, + ); + } + + this.#infuraProjectId = infuraProjectId; + this.#degradedBannerTimeout = degradedBannerTimeout; + this.#unavailableBannerTimeout = unavailableBannerTimeout; + + // Upstream controllers still expose :stateChange; switch to :stateChanged + // once those packages migrate their event types. + /* eslint-disable no-restricted-syntax -- awaiting upstream :stateChanged migration */ + this.messenger.subscribe( + 'NetworkController:stateChange', + (networkControllerState) => + this.#refreshState({ + networkControllerState, + networkEnablementControllerState: this.messenger.call( + 'NetworkEnablementController:getState', + ), + connectivityControllerState: this.messenger.call( + 'ConnectivityController:getState', + ), + }), + selectNetworkControllerFields, + ); + this.messenger.subscribe( + 'NetworkEnablementController:stateChange', + (networkEnablementControllerState) => + this.#refreshState({ + networkControllerState: this.messenger.call( + 'NetworkController:getState', + ), + networkEnablementControllerState, + connectivityControllerState: this.messenger.call( + 'ConnectivityController:getState', + ), + }), + selectNetworkEnablementControllerFields, + ); + this.messenger.subscribe( + 'ConnectivityController:stateChange', + (connectivityControllerState) => + this.#refreshState({ + networkControllerState: this.messenger.call( + 'NetworkController:getState', + ), + networkEnablementControllerState: this.messenger.call( + 'NetworkEnablementController:getState', + ), + connectivityControllerState, + }), + selectConnectivityControllerFields, + ); + /* eslint-enable no-restricted-syntax */ + + // Lifecycle: evaluate RPC health (and run the banner escalation timers) + // only while the client UI is open on an unlocked wallet. + this.messenger.subscribe( + // eslint-disable-next-line no-restricted-syntax -- awaiting upstream :stateChanged migration + 'ClientController:stateChange', + (isUiOpen) => { + this.#isUiOpen = isUiOpen; + this.#updateLifecycle(); + }, + clientControllerSelectors.selectIsUiOpen, + ); + this.messenger.subscribe('KeyringController:unlock', () => { + this.#isUnlocked = true; + this.#updateLifecycle(); + }); + this.messenger.subscribe('KeyringController:lock', () => { + this.#isUnlocked = false; + this.#updateLifecycle(); + }); + + this.messenger.registerMethodActionHandlers( + this, + MESSENGER_EXPOSED_METHODS, + ); + } + + #updateLifecycle(): void { + if (this.#isUiOpen && this.#isUnlocked) { + this.#start(); + } else { + this.#stop(); + } + } + + /** + * Look for a failed network, if any, and populate the initial state of the + * banner. Reacts to upstream state changes from this point on. Idempotent. + */ + #start(): void { + if (this.#isStarted) { + return; + } + + this.#isStarted = true; + this.#refreshState({ + networkControllerState: this.messenger.call('NetworkController:getState'), + networkEnablementControllerState: this.messenger.call( + 'NetworkEnablementController:getState', + ), + connectivityControllerState: this.messenger.call( + 'ConnectivityController:getState', + ), + }); + } + + /** + * Stops evaluating network connection state. Clears any pending banner + * timers and resets state to `available`. Idempotent. + */ + #stop(): void { + if (!this.#isStarted) { + return; + } + + this.#isStarted = false; + this.#resetBanner(); + } + + /** + * Clears the banner state such that the banner will be hidden. + */ + dismissBanner(): void { + this.#resetBanner(); + } + + /** + * Switches the chain's default RPC endpoint to its Infura endpoint and + * makes it the active network, causing the banner to clear once the + * network becomes available again. + * + * @param chainId - The chain whose default RPC endpoint should be switched. + * @throws If the chain configuration cannot be found, or if it has no + * Infura endpoint to switch to, or if the default is already Infura. + */ + async switchToDefaultInfuraRpcEndpoint(chainId: Hex): Promise { + const networkConfiguration = this.messenger.call( + 'NetworkController:getNetworkConfigurationByChainId', + chainId, + ); + if (!networkConfiguration) { + throw new Error( + `No network configuration found for chain ID "${chainId}".`, + ); + } + + const infuraEndpointIndex = networkConfiguration.rpcEndpoints.findIndex( + (endpoint) => getIsInfuraEndpoint(endpoint.url, this.#infuraProjectId), + ); + if (infuraEndpointIndex === -1) { + throw new Error( + `No Infura endpoint available for chain ID "${chainId}".`, + ); + } + if (infuraEndpointIndex === networkConfiguration.defaultRpcEndpointIndex) { + // The default is already Infura; nothing to do. + return; + } + + // Move the active connection onto the Infura endpoint first, then make + // it the default. In this order a partial failure leaves the failing + // default in place and the banner visible, rather than hiding the banner + // while the wallet is still connected to the broken endpoint. + const infuraNetworkClientId = + networkConfiguration.rpcEndpoints[infuraEndpointIndex].networkClientId; + const { selectedNetworkClientId } = this.messenger.call( + 'NetworkController:getState', + ); + if (infuraNetworkClientId !== selectedNetworkClientId) { + await this.messenger.call( + 'NetworkController:setActiveNetwork', + infuraNetworkClientId, + ); + } + + await this.messenger.call('NetworkController:updateNetwork', chainId, { + ...networkConfiguration, + defaultRpcEndpointIndex: infuraEndpointIndex, + }); + } + + #refreshState({ + networkControllerState, + networkEnablementControllerState, + connectivityControllerState, + }: { + networkControllerState: Pick< + NetworkState, + 'networkConfigurationsByChainId' | 'networksMetadata' + >; + networkEnablementControllerState: Pick< + NetworkEnablementControllerState, + 'enabledNetworkMap' + >; + connectivityControllerState: Pick< + ConnectivityControllerState, + 'connectivityStatus' + >; + }): void { + if (!this.#isStarted) { + return; + } + + if ( + connectivityControllerState.connectivityStatus === + CONNECTIVITY_STATUSES.Offline + ) { + this.#resetBanner(); + return; + } + + const failedNetwork = this.#findFailedNetwork( + networkControllerState, + networkEnablementControllerState, + ); + if (!failedNetwork) { + this.#resetBanner(); + return; + } + + if ( + this.state.networkConnectionBannerStatus !== 'available' && + this.state.networkConnectionBannerNetwork?.networkClientId === + failedNetwork.networkClientId + ) { + this.update((state) => { + // Even if the network client ID has not changed, save the current + // version of the failed network to state in case the user has updated + // its RPC URL. + state.networkConnectionBannerNetwork = failedNetwork; + }); + return; + } + + // A degraded timer is already counting down for this same network. + // Repeated `stateChange` events must not clear and restart it, or a + // steady stream of updates could postpone the banner indefinitely. + if (this.#pendingNetworkClientId === failedNetwork.networkClientId) { + return; + } + + this.#clearTimers(); + this.update((state) => { + state.networkConnectionBannerStatus = 'available'; + state.networkConnectionBannerNetwork = null; + }); + + // If `stop` is called before scheduling timers, bail early. + if (!this.#isStarted) { + return; + } + + // Remember which network the pending timer is for (see the guard above) + // and capture the failing network at schedule time. If the failure + // resolves or a different network becomes the banner target while we + // wait, the subscription handlers re-enter this method and cancel or + // replace the timer before it fires. + this.#pendingNetworkClientId = failedNetwork.networkClientId; + this.#degradedTimer = setTimeout(() => { + this.#degradedTimer = undefined; + this.#pendingNetworkClientId = undefined; + this.update((state) => { + state.networkConnectionBannerStatus = 'degraded'; + state.networkConnectionBannerNetwork = failedNetwork; + }); + // If `stop` is called before scheduling timers, bail early. + if (!this.#isStarted) { + return; + } + this.#unavailableTimer = setTimeout(() => { + this.#unavailableTimer = undefined; + this.update((state) => { + state.networkConnectionBannerStatus = 'unavailable'; + state.networkConnectionBannerNetwork = failedNetwork; + }); + }, this.#unavailableBannerTimeout - this.#degradedBannerTimeout); + }, this.#degradedBannerTimeout); + } + + /** + * Clears timers and resets banner state to {@link NetworkConnectionBannerStatus|`available`} + * if it isn't there already. + */ + #resetBanner(): void { + this.#clearTimers(); + this.#pendingNetworkClientId = undefined; + if ( + this.state.networkConnectionBannerStatus !== 'available' || + this.state.networkConnectionBannerNetwork !== null + ) { + this.update((state) => { + state.networkConnectionBannerStatus = 'available'; + state.networkConnectionBannerNetwork = null; + }); + } + } + + #clearTimers(): void { + if (this.#degradedTimer !== undefined) { + clearTimeout(this.#degradedTimer); + this.#degradedTimer = undefined; + } + if (this.#unavailableTimer !== undefined) { + clearTimeout(this.#unavailableTimer); + this.#unavailableTimer = undefined; + } + } + + #findFailedNetwork( + networkState: Pick< + NetworkState, + 'networkConfigurationsByChainId' | 'networksMetadata' + >, + enablementState: Pick< + NetworkEnablementControllerState, + 'enabledNetworkMap' + >, + ): FailedNetwork | null { + const enabledNetworks = this.#collectEnabledNetworks( + networkState, + enablementState, + ); + // Networks whose connectivity has not been looked up yet are not failed. + const failedNetworks = enabledNetworks + .filter( + ({ metadata }) => + metadata !== undefined && metadata.status !== NetworkStatus.Available, + ) + .map((network) => this.#buildFailedNetwork(network)); + return this.#pickFailedNetworkToDisplay( + failedNetworks, + enabledNetworks.length, + ); + } + + #getEnabledEvmChainIds( + enabledNetworkMap: NetworkEnablementControllerState['enabledNetworkMap'], + ): Hex[] { + return Object.entries(enabledNetworkMap[KnownCaipNamespace.Eip155] ?? {}) + .filter(([, enabled]) => enabled) + .map(([chainId]) => chainId as Hex); + } + + #collectEnabledNetworks( + { + networkConfigurationsByChainId, + networksMetadata, + }: Pick< + NetworkState, + 'networkConfigurationsByChainId' | 'networksMetadata' + >, + { + enabledNetworkMap, + }: Pick, + ): EnabledNetwork[] { + return this.#getEnabledEvmChainIds(enabledNetworkMap).flatMap((chainId) => { + const networkConfiguration = networkConfigurationsByChainId[chainId]; + if (!networkConfiguration) { + return []; + } + const { rpcEndpoints, defaultRpcEndpointIndex, name } = + networkConfiguration; + const defaultRpcEndpoint = rpcEndpoints[defaultRpcEndpointIndex]; + if (!defaultRpcEndpoint) { + return []; + } + return [ + { + chainId, + name, + rpcEndpoints, + defaultRpcEndpointIndex, + defaultRpcEndpoint, + metadata: networksMetadata[defaultRpcEndpoint.networkClientId], + }, + ]; + }); + } + + #buildFailedNetwork({ + chainId, + name, + rpcEndpoints, + defaultRpcEndpointIndex, + defaultRpcEndpoint, + }: EnabledNetwork): FailedNetwork { + const isInfuraEndpoint = getIsInfuraEndpoint( + defaultRpcEndpoint.url, + this.#infuraProjectId, + ); + + // For custom endpoints (non-Infura), find an Infura endpoint on this + // chain that we could offer to switch to. + let switchableInfuraNetworkClientId: string | null = null; + if (!isInfuraEndpoint) { + const infuraEndpoint = rpcEndpoints.find( + (endpoint, index) => + index !== defaultRpcEndpointIndex && + getIsInfuraEndpoint(endpoint.url, this.#infuraProjectId), + ); + switchableInfuraNetworkClientId = infuraEndpoint?.networkClientId ?? null; + } + + return { + chainId, + networkClientId: defaultRpcEndpoint.networkClientId, + name, + rpcUrl: defaultRpcEndpoint.url, + isInfuraEndpoint, + switchableInfuraNetworkClientId, + }; + } + + #pickFailedNetworkToDisplay( + failedNetworks: FailedNetwork[], + totalEnabledNetworks: number, + ): FailedNetwork | null { + if (failedNetworks.length === 0) { + return null; + } + + const firstCustomFailed = failedNetworks.find( + (entry) => !entry.isInfuraEndpoint, + ); + const areAllEnabledNetworksFailed = + failedNetworks.length === totalEnabledNetworks; + + if (!firstCustomFailed && !areAllEnabledNetworksFailed) { + return null; + } + + return firstCustomFailed ?? failedNetworks[0]; + } +} diff --git a/packages/network-connection-banner-controller/src/index.ts b/packages/network-connection-banner-controller/src/index.ts new file mode 100644 index 00000000000..08b47d81af6 --- /dev/null +++ b/packages/network-connection-banner-controller/src/index.ts @@ -0,0 +1,22 @@ +export type { + NetworkConnectionBannerControllerState, + NetworkConnectionBannerControllerGetStateAction, + NetworkConnectionBannerControllerActions, + NetworkConnectionBannerControllerStateChangedEvent, + NetworkConnectionBannerControllerEvents, + NetworkConnectionBannerControllerMessenger, + NetworkConnectionBannerControllerOptions, + FailedNetwork, + NetworkConnectionBannerStatus, +} from './NetworkConnectionBannerController.js'; +export type { + NetworkConnectionBannerControllerDismissBannerAction, + NetworkConnectionBannerControllerSwitchToDefaultInfuraRpcEndpointAction, +} from './NetworkConnectionBannerController-method-action-types.js'; +export { + NetworkConnectionBannerController, + getDefaultNetworkConnectionBannerControllerState, + DEFAULT_DEGRADED_BANNER_TIMEOUT, + DEFAULT_UNAVAILABLE_BANNER_TIMEOUT, +} from './NetworkConnectionBannerController.js'; +export { networkConnectionBannerControllerSelectors } from './selectors.js'; diff --git a/packages/network-connection-banner-controller/src/selectors.test.ts b/packages/network-connection-banner-controller/src/selectors.test.ts new file mode 100644 index 00000000000..b51be9af5af --- /dev/null +++ b/packages/network-connection-banner-controller/src/selectors.test.ts @@ -0,0 +1,99 @@ +import type { + NetworkConnectionBannerControllerState, + FailedNetwork, +} from './NetworkConnectionBannerController.js'; +import { networkConnectionBannerControllerSelectors } from './selectors.js'; + +const failedNetwork: FailedNetwork = { + chainId: '0x1', + networkClientId: 'mainnet', + name: 'Ethereum Mainnet', + rpcUrl: 'https://mainnet.infura.io/v3/abc', + isInfuraEndpoint: true, + switchableInfuraNetworkClientId: null, +}; + +describe('networkConnectionBannerControllerSelectors', () => { + describe('selectNetworkConnectionBannerStatus', () => { + it.each(['available', 'degraded', 'unavailable'] as const)( + 'returns %s when status is %s', + (status) => { + const state: NetworkConnectionBannerControllerState = { + networkConnectionBannerStatus: status, + networkConnectionBannerNetwork: + status === 'available' ? null : failedNetwork, + }; + + const result = + networkConnectionBannerControllerSelectors.selectNetworkConnectionBannerStatus( + state, + ); + + expect(result).toBe(status); + }, + ); + }); + + describe('selectNetworkConnectionBannerNetwork', () => { + it('returns null when no banner is shown', () => { + const state: NetworkConnectionBannerControllerState = { + networkConnectionBannerStatus: 'available', + networkConnectionBannerNetwork: null, + }; + + const result = + networkConnectionBannerControllerSelectors.selectNetworkConnectionBannerNetwork( + state, + ); + + expect(result).toBeNull(); + }); + + it('returns the failing network details when a banner is shown', () => { + const state: NetworkConnectionBannerControllerState = { + networkConnectionBannerStatus: 'degraded', + networkConnectionBannerNetwork: failedNetwork, + }; + + const result = + networkConnectionBannerControllerSelectors.selectNetworkConnectionBannerNetwork( + state, + ); + + expect(result).toBe(failedNetwork); + }); + }); + + describe('selectIsNetworkConnectionBannerVisible', () => { + it('returns false when status is available', () => { + const state: NetworkConnectionBannerControllerState = { + networkConnectionBannerStatus: 'available', + networkConnectionBannerNetwork: null, + }; + + const result = + networkConnectionBannerControllerSelectors.selectIsNetworkConnectionBannerVisible( + state, + ); + + expect(result).toBe(false); + }); + + it.each(['degraded', 'unavailable'] as const)( + 'returns true when status is %s', + (status) => { + const state: NetworkConnectionBannerControllerState = { + networkConnectionBannerStatus: status, + networkConnectionBannerNetwork: failedNetwork, + }; + + const result = + networkConnectionBannerControllerSelectors.selectIsNetworkConnectionBannerVisible( + state, + ); + + expect(result).toBe(true); + }, + ); + }); +}); diff --git a/packages/network-connection-banner-controller/src/selectors.ts b/packages/network-connection-banner-controller/src/selectors.ts new file mode 100644 index 00000000000..4e15222d572 --- /dev/null +++ b/packages/network-connection-banner-controller/src/selectors.ts @@ -0,0 +1,50 @@ +import { createSelector } from 'reselect'; + +import type { + NetworkConnectionBannerControllerState, + FailedNetwork, + NetworkConnectionBannerStatus, +} from './NetworkConnectionBannerController.js'; + +/** + * Selects the banner status from the controller state. + * + * @param state - The controller state + * @returns The banner status + */ +const selectNetworkConnectionBannerStatus = ( + state: NetworkConnectionBannerControllerState, +): NetworkConnectionBannerStatus => state.networkConnectionBannerStatus; + +/** + * Selects the failing network the banner describes, or `null` when no banner + * is shown. + * + * @param state - The controller state + * @returns The failing network details, or `null` + */ +const selectNetworkConnectionBannerNetwork = ( + state: NetworkConnectionBannerControllerState, +): FailedNetwork | null => state.networkConnectionBannerNetwork; + +/** + * Selects whether the banner is visible (status is `degraded` or + * `unavailable`). + * + * @param state - The controller state + * @returns Whether the banner is visible + */ +const selectIsNetworkConnectionBannerVisible = createSelector( + [selectNetworkConnectionBannerStatus], + (status) => status === 'degraded' || status === 'unavailable', +); + +/** + * Selectors for the NetworkConnectionBannerController state. + * These can be used with Redux or directly with controller state. + */ +export const networkConnectionBannerControllerSelectors = { + selectNetworkConnectionBannerStatus, + selectNetworkConnectionBannerNetwork, + selectIsNetworkConnectionBannerVisible, +}; diff --git a/packages/network-connection-banner-controller/src/url-utils.test.ts b/packages/network-connection-banner-controller/src/url-utils.test.ts new file mode 100644 index 00000000000..304afcad3c0 --- /dev/null +++ b/packages/network-connection-banner-controller/src/url-utils.test.ts @@ -0,0 +1,44 @@ +import { getIsInfuraEndpoint } from './url-utils.js'; + +const INFURA_PROJECT_ID = 'abc123def456'; + +describe('getIsInfuraEndpoint', () => { + it.each([ + // Built-in configurations keep the placeholder. + 'https://mainnet.infura.io/v3/{infuraProjectId}', + 'https://sepolia.infura.io/v3/{infuraProjectId}', + 'https://polygon-mainnet.infura.io/v3/{infuraProjectId}', + // Some flows (e.g. adding a popular network) persist the URL with the + // wallet's own project id already substituted. + `https://avalanche-mainnet.infura.io/v3/${INFURA_PROJECT_ID}`, + ])('returns true for the MetaMask Infura endpoint %s', (url) => { + expect(getIsInfuraEndpoint(url, INFURA_PROJECT_ID)).toBe(true); + }); + + it.each([ + // Custom providers. + 'https://polygon-rpc.com', + 'https://eth-mainnet.alchemyapi.io/v2/abc', + // A different project id means the endpoint runs on the user's own + // Infura account, which behaves like any custom RPC. + 'https://mainnet.infura.io/v3/someone-elses-project-id', + // Wrong scheme, path, or host shape. + 'http://mainnet.infura.io/v3/{infuraProjectId}', + `http://mainnet.infura.io/v3/${INFURA_PROJECT_ID}`, + 'https://mainnet.infura.io/v2/{infuraProjectId}', + 'https://mainnet.evil.io/v3/{infuraProjectId}', + 'https://sub.mainnet.infura.io/v3/{infuraProjectId}', + 'not a url', + ])('returns false for %s', (url) => { + expect(getIsInfuraEndpoint(url, INFURA_PROJECT_ID)).toBe(false); + }); + + it('escapes regex metacharacters in the project id', () => { + expect(getIsInfuraEndpoint('https://mainnet.infura.io/v3/a+b', 'a+b')).toBe( + true, + ); + expect(getIsInfuraEndpoint('https://mainnet.infura.io/v3/aab', 'a+b')).toBe( + false, + ); + }); +}); diff --git a/packages/network-connection-banner-controller/src/url-utils.ts b/packages/network-connection-banner-controller/src/url-utils.ts new file mode 100644 index 00000000000..4474ae58233 --- /dev/null +++ b/packages/network-connection-banner-controller/src/url-utils.ts @@ -0,0 +1,30 @@ +/** + * Escape a string for literal use inside a regular expression. + * + * @param value - The string to escape. + * @returns The escaped string. + */ +function escapeRegExp(value: string): string { + return value.replace(/[.*+?^${}()|[\]\\]/gu, '\\$&'); +} + +/** + * Whether an RPC URL is a MetaMask Infura endpoint. Built-in network + * configurations keep the `{infuraProjectId}` placeholder (substitution + * happens at request time), while some flows (e.g. adding a popular network) + * persist the URL with the wallet's own project id already substituted, so + * both shapes are matched. + * + * @param url - The RPC URL to check. + * @param infuraProjectId - The wallet's Infura project id. + * @returns True if the URL is a MetaMask Infura endpoint. + */ +export function getIsInfuraEndpoint( + url: string, + infuraProjectId: string, +): boolean { + return new RegExp( + `^https://[^./]+\\.infura\\.io/v3/(?:\\{infuraProjectId\\}|${escapeRegExp(infuraProjectId)})$`, + 'u', + ).test(url); +} diff --git a/packages/network-connection-banner-controller/tsconfig.build.json b/packages/network-connection-banner-controller/tsconfig.build.json new file mode 100644 index 00000000000..3ab33a11719 --- /dev/null +++ b/packages/network-connection-banner-controller/tsconfig.build.json @@ -0,0 +1,18 @@ +{ + "extends": "../../tsconfig.packages.build.json", + "compilerOptions": { + "baseUrl": "./", + "outDir": "./dist", + "rootDir": "./src" + }, + "references": [ + { "path": "../base-controller/tsconfig.build.json" }, + { "path": "../client-controller/tsconfig.build.json" }, + { "path": "../connectivity-controller/tsconfig.build.json" }, + { "path": "../keyring-controller/tsconfig.build.json" }, + { "path": "../messenger/tsconfig.build.json" }, + { "path": "../network-controller/tsconfig.build.json" }, + { "path": "../network-enablement-controller/tsconfig.build.json" } + ], + "include": ["../../types", "./src"] +} diff --git a/packages/network-connection-banner-controller/tsconfig.json b/packages/network-connection-banner-controller/tsconfig.json new file mode 100644 index 00000000000..89754a345b8 --- /dev/null +++ b/packages/network-connection-banner-controller/tsconfig.json @@ -0,0 +1,16 @@ +{ + "extends": "../../tsconfig.packages.json", + "compilerOptions": { + "baseUrl": "./" + }, + "references": [ + { "path": "../base-controller" }, + { "path": "../client-controller" }, + { "path": "../connectivity-controller" }, + { "path": "../keyring-controller" }, + { "path": "../messenger" }, + { "path": "../network-controller" }, + { "path": "../network-enablement-controller" } + ], + "include": ["../../types", "./src"] +} diff --git a/packages/network-connection-banner-controller/typedoc.json b/packages/network-connection-banner-controller/typedoc.json new file mode 100644 index 00000000000..c9da015dbf8 --- /dev/null +++ b/packages/network-connection-banner-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/network-controller/CHANGELOG.md b/packages/network-controller/CHANGELOG.md index 25061e28a93..ef1bedd9bd5 100644 --- a/packages/network-controller/CHANGELOG.md +++ b/packages/network-controller/CHANGELOG.md @@ -1,15 +1,951 @@ # 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] + +### Changed + +- Bump `@metamask/remote-feature-flag-controller` from `^6.0.0` to `^6.1.0` ([#9980](https://github.com/MetaMask/core/pull/9980)) + +## [36.0.0] + +### Changed + +- **BREAKING:** `NetworkControllerMessenger` now requires the `ConfigRegistryController:stateChanged` event and `ConfigRegistryController:getNetworkConfigByCaip2ChainId` action to be delegated from the root messenger ([#9879](https://github.com/MetaMask/core/pull/9879)) + - `NetworkController` now depends on the `ConfigRegistryController` to auto-register default networks from the registry. +- Update `init` to auto-register default networks from `ConfigRegistryController` ([#9879](https://github.com/MetaMask/core/pull/9879)) +- Bump `@metamask/remote-feature-flag-controller` from `^5.0.0` to `^6.0.0` ([#9945](https://github.com/MetaMask/core/pull/9945)) +- Bump `@metamask/config-registry-controller` from `^3.0.0` to `^3.1.0` ([#9969](https://github.com/MetaMask/core/pull/9969)) +- Bump `@metamask/eth-json-rpc-middleware` from `^24.0.0` to `^24.0.1` ([#9967](https://github.com/MetaMask/core/pull/9967)) + +## [35.0.1] + +### Changed + +- Bump `@metamask/eth-json-rpc-middleware` from `^23.1.3` to `^24.0.0` ([#9758](https://github.com/MetaMask/core/pull/9758)) + +## [35.0.0] + +### Added + +- **BREAKING:** Add optional `analyticsOptions` constructor option that makes `NetworkController` emit `RPC Service Unavailable` and `RPC Service Degraded` analytics events via the `AnalyticsController:trackEvent` action when an RPC endpoint becomes unavailable or degraded ([#9270](https://github.com/MetaMask/core/pull/9270)) + - The option and its properties are optional: `isRpcEndpointUrlPublic` (decides whether an endpoint URL is safe to report verbatim or as `'custom'`) defaults to `() => false`, and `rpcServiceEventsSampleRate` (the proportion of events to emit, between `0` and `1`) defaults to `0`, which emits nothing. + - `NetworkControllerMessenger` now requires the `AnalyticsController:getState` and `AnalyticsController:trackEvent` actions, which consumers must delegate to the network controller messenger. + - Adds the `NetworkControllerAnalyticsOptions` and `RpcServiceEventName` types. + +### Changed + +- **BREAKING:** `BuiltInNetworkClientId` is now `string` instead of `InfuraNetworkType` ([#9432](https://github.com/MetaMask/core/pull/9432)) + - This type was previously constrained to known Infura network names (e.g. `"mainnet"`, `"sepolia"`). It is now `string` to allow dynamically configured Infura networks whose names are not bundled into the package. + - If you have code that narrows on `BuiltInNetworkClientId`, you will need to remove the narrowing or check the network client type via `NetworkClientType` instead. +- **BREAKING:** `getNetworkClientById` no longer uses the given network client ID to determine RPC endpoint type, prioritizing Infura RPC endpoints over custom RPC endpoints ([#9432](https://github.com/MetaMask/core/pull/9432)) + - If you have an RPC endpoint with a `type` of `custom` but with a `networkClientId` that previously matched a known Infura network name (e.g. `mainnet`), this will now be treated as an Infura network rather than a custom network. + - This method is not only public but is also used internally to resolve network client IDs, so this is a change in behavior across the whole controller. +- `InfuraNetworkClientConfiguration.network` is now `string` instead of `InfuraNetworkType` ([#9432](https://github.com/MetaMask/core/pull/9432)) + - Previously only known Infura network names were accepted. Any valid Infura subdomain string is now accepted. +- Remove validation that prevented a custom RPC endpoint from having a `networkClientId` that matches a known Infura network name ([#9432](https://github.com/MetaMask/core/pull/9432)) +- Remove validation that required an Infura RPC endpoint URL's implied chain ID to match the chain ID of the network configuration it belongs to ([#9432](https://github.com/MetaMask/core/pull/9432)) + - This allows Infura-backed networks to be added and updated dynamically without being constrained to the known Infura network list bundled in `@metamask/controller-utils`. +- Bump `@metamask/messenger` from `^1.2.0` to `^2.0.0` ([#9392](https://github.com/MetaMask/core/pull/9392)) +- Bump `@metamask/connectivity-controller` from `^0.2.0` to `^0.3.0` ([#9435](https://github.com/MetaMask/core/pull/9435)) +- Bump `@metamask/analytics-controller` from `^1.2.1` to `^2.0.0` ([#9735](https://github.com/MetaMask/core/pull/9735)) +- Bump `@metamask/remote-feature-flag-controller` from `^4.2.2` to `^5.0.0` ([#9735](https://github.com/MetaMask/core/pull/9735)) + +## [34.0.0] + +### Changed + +- **BREAKING:** Drive RPC failover from the single `corePlatformRpcFailoverMode` remote feature flag ([#9175](https://github.com/MetaMask/core/pull/9175)) + - The flag is a string with three values: `disabled` (failover off), `enabled` (divert to failover URLs when the primary endpoint is unavailable), and `forced` (Infura endpoints that have failover URLs route all traffic to those URLs, bypassing Infura entirely). Custom endpoints are unaffected, and the value defaults to `disabled` when the flag is absent or unrecognized. + - `NetworkController` no longer reads the `walletFrameworkRpcFailoverEnabled` flag; the `enabled` mode replaces it. Update your remote feature flag configuration to set `corePlatformRpcFailoverMode`. + +### Removed + +- **BREAKING:** Remove the `NetworkController.enableRpcFailover` and `NetworkController.disableRpcFailover` methods, their `NetworkController:enableRpcFailover` / `NetworkController:disableRpcFailover` messenger actions, and the `NetworkControllerEnableRpcFailoverAction` / `NetworkControllerDisableRpcFailoverAction` types ([#9175](https://github.com/MetaMask/core/pull/9175)) + - RPC failover is now driven entirely by the `corePlatformRpcFailoverMode` remote feature flag, so there is no longer an imperative toggle. + +## [33.0.0] + +### Added + +- Add defaults for policy and block tracker options ([#9002](https://github.com/MetaMask/core/pull/9002)) + - The `NetworkController` constructor argument `getRpcServiceOptions` is now optional. + - The default `policyOptions.maxRetries` is now `3`. + - The default `policyOptions.maxConsecutiveFailures` is now `12` for regular RPC endpoints and `40` for fallback RPC endpoints. + - The default `policyOptions.circuitBreakDuration` is now `30` seconds. + - The default `pollingInterval` for the block tracker is now `20` seconds. + - The default `retryTimeout` for the block tracker is now `20` seconds. +- Add `failoverUrls` constructor argument ([#9140](https://github.com/MetaMask/core/pull/9140)) + - These will override `failoverUrls` from state during network client creation. + +### Changed + +- Bump `@metamask/utils` from `^11.9.0` to `^11.11.0` ([#9074](https://github.com/MetaMask/core/pull/9074)) +- Bump `@metamask/controller-utils` from `^12.1.0` to `^12.3.0` ([#9058](https://github.com/MetaMask/core/pull/9058), [#9083](https://github.com/MetaMask/core/pull/9083), [#9218](https://github.com/MetaMask/core/pull/9218)) +- **BREAKING:** Remove deprecated `NetworkControllerGetNetworkConfigurationByNetworkClientId` type ([#9185](https://github.com/MetaMask/core/pull/9185)) + - Use `NetworkControllerGetNetworkConfigurationByNetworkClientIdAction` instead. +- **BREAKING:** Automatically populate `isRpcFailoverEnabled` using `RemoteFeatureFlagController` ([#9013](https://github.com/MetaMask/core/pull/9013)) + - `NetworkController.init` must now be called to fully initialize the controller. + - The constructor argument `isRpcFailoverEnabled` is no longer available. + - `RemoteFeatureFlagController:stateChange` and `RemoteFeatureFlagController:getState` are now required. +- Drop `async-mutex` dependency, which was no longer used in source ([#9064](https://github.com/MetaMask/core/pull/9064)) +- Consider all Infura HTTP errors as service failures except `400` and `429` ([#9123](https://github.com/MetaMask/core/pull/9123)) +- Only consider failover endpoints when using Infura ([#9125](https://github.com/MetaMask/core/pull/9125)) + +### Removed + +- **BREAKING:** Remove `initializeProvider` in favor of `init` ([#9034](https://github.com/MetaMask/core/pull/9034)) + - `init` does not call `lookupNetwork`, if this is required it must be called manually. +- **BREAKING:** Remove `additionalDefaultNetworks` constructor option ([#9035](https://github.com/MetaMask/core/pull/9035), [#9183](https://github.com/MetaMask/core/pull/9183)) + - MegaETH v1 is no longer a default network. + +### Fixed + +- Add defaults for `fetch`, `btoa` and `isOffline` in `RpcServiceOptions` ([#9000](https://github.com/MetaMask/core/pull/9000)) +- Ensure block explorer URLs are populated for default networks ([#9005](https://github.com/MetaMask/core/pull/9005)) + +## [32.0.0] + +### Changed + +- **BREAKING:** Remove Sei, MegaETH, Avalanche, and ZKSync from list of default networks ([#8767](https://github.com/MetaMask/core/pull/8767)) + - You will need to add them as network configurations first before switching to them. +- Bump `@metamask/controller-utils` from `^12.0.0` to `^12.1.0` ([#8774](https://github.com/MetaMask/core/pull/8774)) + +## [31.1.0] + +### Added + +- Add export for `AddNetworkCustomRpcEndpointFields` and `InfuraRpcEndpoint` types ([#8764](https://github.com/MetaMask/core/pull/8764)) + +## [31.0.0] + +### Added + +- **BREAKING:** Add `duration` and `traceId` to `NetworkController:rpcEndpointDegraded` and `NetworkController:rpcEndpointChainDegraded` event payloads ([#8455](https://github.com/MetaMask/core/pull/8455)) + - `duration` contains the policy execution time in milliseconds when the request succeeded but was slow. It is `undefined` when retries were exhausted. + - `traceId` contains the value of the `x-trace-id` response header from the last request attempt, or `undefined` if the header was not present. + - This is breaking because if you're calling `messenger.subscribe` with a handler function you've explicitly typed, you will need to make sure to update that type to cover these two additional payload properties. + +### Changed + +- Bump `@metamask/json-rpc-engine` from `^10.2.4` to `^10.5.0` ([#8661](https://github.com/MetaMask/core/pull/8661), [#8746](https://github.com/MetaMask/core/pull/8746), [#8753](https://github.com/MetaMask/core/pull/8753)) +- Bump `@metamask/controller-utils` from `^11.20.0` to `^12.0.0` ([#8755](https://github.com/MetaMask/core/pull/8755)) + +## [30.1.0] + +### Added + +- Expose missing public `NetworkController` methods through its messenger ([#8350](https://github.com/MetaMask/core/pull/8350)) + - The following actions are now available: + - `NetworkController:enableRpcFailover` + - `NetworkController:disableRpcFailover` + - `NetworkController:getProviderAndBlockTracker` + - `NetworkController:getNetworkClientRegistry` + - `NetworkController:initializeProvider` + - `NetworkController:lookupNetwork` + - `NetworkController:lookupNetworkByClientId` + - `NetworkController:get1559CompatibilityWithNetworkClientId` + - `NetworkController:resetConnection` + - `NetworkController:rollbackToPreviousProvider` + - `NetworkController:loadBackup` + - Corresponding action types are available as well. +- Add `getEthQuery` method to `NetworkController` ([#8350](https://github.com/MetaMask/core/pull/8350)) + +### Changed + +- Bump `@metamask/controller-utils` from `^11.19.0` to `^11.20.0` ([#8344](https://github.com/MetaMask/core/pull/8344)) +- Bump `@metamask/eth-json-rpc-middleware` from `^23.1.1` to `^23.1.3` ([#8550](https://github.com/MetaMask/core/pull/8550), [#8611](https://github.com/MetaMask/core/pull/8611)) +- Bump `@metamask/messenger` from `^1.0.0` to `^1.2.0` ([#8364](https://github.com/MetaMask/core/pull/8364), [#8373](https://github.com/MetaMask/core/pull/8373), [#8632](https://github.com/MetaMask/core/pull/8632)) +- Bump `@metamask/base-controller` from `^9.0.1` to `^9.1.0` ([#8457](https://github.com/MetaMask/core/pull/8457)) + +### Deprecated + +- `NetworkControllerGetNetworkConfigurationByNetworkClientId` type is deprecated in favor of `NetworkControllerGetNetworkConfigurationByNetworkClientIdAction` ([#8350](https://github.com/MetaMask/core/pull/8350)) +- Deprecate `AbstractRpcService` and `RpcServiceRequestable` ([#8475](https://github.com/MetaMask/core/pull/8475)) + - There are no equivalents to these interfaces. If you need to take an "RPC-service-like" argument, it's best to declare which properties you're interested in rather than accepting the entire RPC service interface. + +## [30.0.1] + +### Changed + +- Bump `@metamask/base-controller` from `^9.0.0` to `^9.0.1` ([#8317](https://github.com/MetaMask/core/pull/8317)) +- Bump `@metamask/connectivity-controller` from `^0.1.0` to `^0.2.0` ([#8317](https://github.com/MetaMask/core/pull/8317)) +- Bump `@metamask/eth-json-rpc-middleware` from `^23.1.0` to `^23.1.1` ([#8317](https://github.com/MetaMask/core/pull/8317)) +- Bump `@metamask/eth-json-rpc-provider` from `^6.0.0` to `^6.0.1` ([#8317](https://github.com/MetaMask/core/pull/8317)) +- Bump `@metamask/messenger` from `^0.3.0` to `^1.0.0` ([#8317](https://github.com/MetaMask/core/pull/8317)) +- Bump `@metamask/json-rpc-engine` from `^10.2.2` to `^10.2.4` ([#8078](https://github.com/MetaMask/core/pull/8078), [#8317](https://github.com/MetaMask/core/pull/8317)) + +## [30.0.0] + +### Added + +- Add `rpcMethodName` to `NetworkController:rpcEndpointDegraded` and `NetworkController:rpcEndpointChainDegraded` event payloads ([#7954](https://github.com/MetaMask/core/pull/7954)) + - This field contains the JSON-RPC method name (e.g. `eth_blockNumber`) that was being processed when the event fired, enabling identification of which methods produce the most slow requests or retry exhaustions. +- Add `type` and `retryReason` to `NetworkController:rpcEndpointDegraded` and `NetworkController:rpcEndpointChainDegraded` event payloads ([#7988](https://github.com/MetaMask/core/pull/7988)) + - `type` (`DegradedEventType`) is `'slow_success'` when the request succeeded but was slow, or `'retries_exhausted'` when retries ran out. + - `retryReason` (`RetryReason`, only present when `type` is `'retries_exhausted'`) classifies the error that was retried (e.g. `'non_successful_http_status'`, `'timed_out'`, `'connection_failed'`). + +### Changed + +- **BREAKING:** The `RpcServiceRequestable` type's `onDegraded` listener now receives `rpcMethodName: string` in its data parameter ([#7954](https://github.com/MetaMask/core/pull/7954)) + - Implementors of this interface will need to accept the new field in their `onDegraded` callback signature. +- Bump `@metamask/eth-json-rpc-middleware` from `^23.0.0` to `^23.1.0` ([#7810](https://github.com/MetaMask/core/pull/7810)) +- Bump `@metamask/json-rpc-engine` from `^10.2.1` to `^10.2.2` ([#7856](https://github.com/MetaMask/core/pull/7856)) +- Bump `@metamask/controller-utils` from `^11.18.0` to `^11.19.0` ([#7995](https://github.com/MetaMask/core/pull/7995)) + +## [29.0.0] + +### Added + +- Add dependency `@metamask/connectivity-controller` `^0.1.0` ([#7642](https://github.com/MetaMask/core/pull/7642)) + +### Changed + +- Bump `@metamask/eth-block-tracker` from `^15.0.0` to `^15.0.1` ([#7642](https://github.com/MetaMask/core/pull/7642)) +- Bump `@metamask/json-rpc-engine` from `^10.2.0` to `^10.2.1` ([#7642](https://github.com/MetaMask/core/pull/7642)) +- Bump `@metamask/eth-json-rpc-middleware` from `^22.0.1` to `^23.0.0` ([#7634](https://github.com/MetaMask/core/pull/7634)) +- **BREAKING:** NetworkController now requires `ConnectivityController:getState` action handler to be registered on the messenger ([#7627](https://github.com/MetaMask/core/pull/7627)) + - The `NetworkController` now depends on the `ConnectivityController` to prevent retries and suppress events when the user is offline. + - When offline, `NetworkController:rpcEndpointUnavailable` and `NetworkController:rpcEndpointDegraded` events are suppressed since retries don't occur and circuit breakers don't trigger. + - You must register a `ConnectivityController:getState` action handler on your root messenger that returns an object with a `connectivityStatus` property (`'online'` or `'offline'`). + - You must delegate the `ConnectivityController:getState` action from your root messenger to the `NetworkControllerMessenger` using `rootMessenger.delegate({ messenger: networkControllerMessenger, actions: ['ConnectivityController:getState'] })`. + +## [28.0.0] + +### Changed + +- Corrects the previous 27.2.0 release to document breaking changes that were missed: + - **BREAKING:** Remove dependency on `@metamask/error-reporting-service` ([#7542](https://github.com/MetaMask/core/pull/7542)) + - `ErrorReportingService:captureException` is no longer an allowed action on the NetworkController messenger. You do not need to delegate its `ErrorReportingService:captureException` action to the NetworkController messenger. + +## [27.2.0] [DEPRECATED] + +### Changed + +- Upgrade `@metamask/utils` from `^11.8.1` to `^11.9.0` ([#7511](https://github.com/MetaMask/core/pull/7511)) +- Remove dependency on `@metamask/error-reporting-service` ([#7542](https://github.com/MetaMask/core/pull/7542)) + - The service no longer needs `ErrorReportingService:captureException`. +- Bump `@metamask/controller-utils` from `^11.17.0` to `^11.18.0` ([#7583](https://github.com/MetaMask/core/pull/7583)) + +## [27.1.0] + +### Added + +- Add MegaETH Testnet "v2" as a default custom network ([#7272](https://github.com/MetaMask/core/pull/7272)) + - The URL for this is `https://timothy.megaeth.com/rpc` rather than `https://carrot.megaeth.com/rpc`, and the chain ID has changed from `0x18c6` to `0x18c7`. + - "v1" of this network has not been removed. + +### Changed + +- Bump `@metamask/eth-json-rpc-middleware` from `^22.0.0` to `^22.0.1` ([#7330](https://github.com/MetaMask/core/pull/7330)) +- Bump `@metamask/controller-utils` from `^11.16.0` to `^11.17.0` ([#7534](https://github.com/MetaMask/core/pull/7534)) + +### Fixed + +- Ensure `get1559CompatibilityWithNetworkClientId` updates network metadata with EIP-1559 compatibility data missing ([#7532](https://github.com/MetaMask/core/pull/7532)) + +## [27.0.0] + +### Added + +- Add `NetworkController:rpcEndpointChainAvailable` messenger event ([#7166](https://github.com/MetaMask/core/pull/7166)) + - This is a counterpart to the (new) `NetworkController:rpcEndpointChainUnavailable` and `NetworkController:rpcEndpointChainDegraded` events, but is published when a successful request to an endpoint within a chain of endpoints is made either initially or following a previously established degraded or unavailable status. +- Update `networksMetadata` state property so that networks can now have a possible status of `degraded` ([#7186](https://github.com/MetaMask/core/pull/7186)) + +### Changed + +- **BREAKING:** Split up and update payload data for `NetworkController:rpcEndpointDegraded` and `NetworkController:rpcEndpointUnavailable` ([#7166](https://github.com/MetaMask/core/pull/7166)) + - `NetworkController:rpcEndpointDegraded` and `NetworkController:rpcEndpointUnavailable` still exist and retain the same behavior as before. + - New events are `NetworkController:rpcEndpointChainDegraded` and `NetworkController:rpcEndpointChainUnavailable`, and are designed to represent an entire chain of endpoints. They are also guaranteed to not be published multiple times in a row. In particular, `NetworkController:rpcEndpointChainUnavailable` is published only after trying all of the endpoints for a chain and when the underlying circuit for the last endpoint breaks, not as each primary's or failover's circuit breaks. + - The event payloads have been changed: + - For individual endpoint events (`NetworkController:rpcEndpointUnavailable`, `NetworkController:rpcEndpointDegraded`): `failoverEndpointUrl` has been removed, and `primaryEndpointUrl` has been added. In addition, `networkClientId` has been added to the payload. + - For chain-level events (`NetworkController:rpcEndpointChainUnavailable`, `NetworkController:rpcEndpointChainDegraded`, `NetworkController:rpcEndpointChainAvailable`): These include `chainId`, `networkClientId`, and event-specific fields (e.g., `error`, `endpointUrl`) but do not include `primaryEndpointUrl`. Consumers can derive endpoint information from the `networkClientId` using `NetworkController:getNetworkClientById` or `NetworkController:getNetworkConfigurationByNetworkClientId`. +- **BREAKING:** Rename and update payload data for `NetworkController:rpcEndpointRequestRetried` ([#7166](https://github.com/MetaMask/core/pull/7166)) + - This event is now called `NetworkController:rpcEndpointRetried`. + - The event payload has been changed as well: `failoverEndpointUrl` has been removed, and `primaryEndpointUrl` has been added. In addition, `networkClientId` and `attempt` have been added to the payload. +- **BREAKING:** Update `AbstractRpcService`/`RpcServiceRequestable` to remove `{ isolated: true }` from the `onBreak` event data type ([#7166](https://github.com/MetaMask/core/pull/7166)) + - This represented the error produced when `isolate` is called on a Cockatiel circuit breaker policy. This never happens for our service (we use `isolate` internally, but this error is suppressed and cannot trigger `onBreak`) +- Move peer dependencies for controller and service packages to direct dependencies ([#7209](https://github.com/MetaMask/core/pull/7209)) + - The dependencies moved are: + - `@metamask/error-reporting-service` (^3.0.0) + - In clients, it is now possible for multiple versions of these packages to exist in the dependency tree. + - For example, this scenario would be valid: a client relies on `@metamask/controller-a` 1.0.0 and `@metamask/controller-b` 1.0.0, and `@metamask/controller-b` depends on `@metamask/controller-a` 1.1.0. + - Note, however, that the versions specified in the client's `package.json` always "win", and you are expected to keep them up to date so as not to break controller and service intercommunication. +- Automatically update network status metadata when chain-level RPC events are published ([#7186](https://github.com/MetaMask/core/pull/7186)) + - `NetworkController` now automatically subscribes to `NetworkController:rpcEndpointChainUnavailable`, `NetworkController:rpcEndpointChainDegraded`, and `NetworkController:rpcEndpointChainAvailable` events and updates the corresponding network's status metadata in state when these events are published. + - This enables real-time network status updates without requiring explicit `lookupNetwork` calls, providing more accurate and timely network availability information. + +## [26.0.0] + +### Added + +- Add infura supported networks ([#6972](https://github.com/MetaMask/core/pull/6972)) + +### Changed + +- Bump `@metamask/json-rpc-engine` from `^10.1.1` to `^10.2.0` ([#7202](https://github.com/MetaMask/core/pull/7202)) +- Bump `@metamask/eth-json-rpc-provider` from `^5.0.1` to `^6.0.0` ([#7202](https://github.com/MetaMask/core/pull/7202)) +- Bump `@metamask/eth-json-rpc-middleware` from `^21.0.0` to `^22.0.0` ([#7202](https://github.com/MetaMask/core/pull/7202)) +- Bump `@metamask/eth-block-tracker` from `^14.0.0` to `^15.0.0` ([#7202](https://github.com/MetaMask/core/pull/7202)) +- **BREAKING:** Use `InternalProvider` instead of `SafeEventEmitterProvider` ([#6796](https://github.com/MetaMask/core/pull/6796)) + - Providers accessible either via network clients or global proxies no longer emit events (or inherit from EventEmitter, for that matter). +- **BREAKING:** Make `Provider` type more specific ([#7061](https://github.com/MetaMask/core/pull/7061)) + - The `Provider` type is now an `InternalProvider` with a context type of `{ origin: string; skipCache: boolean } & Record`. +- **BREAKING:** Stop retrying `undefined` results for methods that include a block tag parameter ([#7001](https://github.com/MetaMask/core/pull/7001)) + - The network client middleware, via `@metamask/eth-json-rpc-middleware`, will now throw an error if it encounters an + `undefined` result when dispatching a request with a later block number than the originally requested block number. + - In practice, this should happen rarely if ever. +- **BREAKING:** Migrate `NetworkClient` to `JsonRpcEngineV2` ([#7065](https://github.com/MetaMask/core/pull/7065)) + - This ought to be unobservable, but we mark it as breaking out of an abundance of caution. +- **BREAKING:** Update signature of `request` in `AbstractRpcService` and `RpcServiceRequestable` so that the JSON-RPC request must be frozen ([#7138](https://github.com/MetaMask/core/pull/7138)) +- Bump `@metamask/controller-utils` from `^11.15.0` to `^11.16.0` ([#7003](https://github.com/MetaMask/core/pull/7003), [#7202](https://github.com/MetaMask/core/pull/7202)) + +### Fixed + +- Ensure `networksMetadata` never references old network client IDs ([#7047](https://github.com/MetaMask/core/pull/7047)) + - When removing a network configuration, ensure that metadata for all RPC endpoints in the network configuration are also removed from `networksMetadata` + - When initializing the controller, remove metadata for RPC endpoints in `networksMetadata` that are not present in a network configuration + +## [25.0.0] + +### Changed + +- **BREAKING:** Use new `Messenger` from `@metamask/messenger` ([#6386](https://github.com/MetaMask/core/pull/6386)) + - Previously, `NetworkController` accepted a `RestrictedMessenger` instance from `@metamask/base-controller`. +- **BREAKING:** Bump `@metamask/error-reporting-service` from `^2.0.0` to `^3.0.0` ([#6962](https://github.com/MetaMask/core/pull/6962)) +- Bump `@metamask/base-controller` from `^8.4.2` to `^9.0.0` ([#6962](https://github.com/MetaMask/core/pull/6962)) + +## [24.3.1] + +### Changed + +- Bump `@metamask/base-controller` from `^8.4.1` to `^8.4.2` ([#6917](https://github.com/MetaMask/core/pull/6917)) + +## [24.3.0] + +### Changed + +- Bump `@metamask/eth-json-rpc-middleware` from `^19.0.1` to `^21.0.0` ([#6866](https://github.com/MetaMask/core/pull/6866), [#6883](https://github.com/MetaMask/core/pull/6883)) +- Bump `@metamask/eth-block-tracker` from `^13.0.0` to `^14.0.0` ([#6883](https://github.com/MetaMask/core/pull/6883)) + +## [24.2.2] + +### Changed + +- Bump `@metamask/eth-block-tracker` from `^12.0.1` to `^12.2.1` ([#6811](https://github.com/MetaMask/core/pull/6811)) +- Bump `@metamask/eth-json-rpc-infura` from `^10.2.0` to `^10.3.0` ([#6811](https://github.com/MetaMask/core/pull/6811)) +- Bump `@metamask/eth-json-rpc-middleware` from `^18.0.0` to `^19.0.1` ([#6811](https://github.com/MetaMask/core/pull/6811)) + +## [24.2.1] + +### Changed + +- Bump `@metamask/utils` from `^11.8.0` to `^11.8.1` ([#6708](https://github.com/MetaMask/core/pull/6708)) +- Update `@metamask/eth-json-rpc-middleware` from `^17.0.1` to `^18.0.0` ([#6714](https://github.com/MetaMask/core/pull/6714)) +- Bump `@metamask/error-reporting-service` from `^2.1.0` to `^2.2.0` ([#6782](https://github.com/MetaMask/core/pull/6782)) +- Bump `@metamask/base-controller` from `^8.4.0` to `^8.4.1` ([#6807](https://github.com/MetaMask/core/pull/6807)) +- Bump `@metamask/controller-utils` from `^11.14.0` to `^11.14.1` ([#6807](https://github.com/MetaMask/core/pull/6807)) +- Bump `@metamask/eth-json-rpc-provider` from `^5.0.0` to `^5.0.1` ([#6807](https://github.com/MetaMask/core/pull/6807)) +- Bump `@metamask/json-rpc-engine` from `^10.1.0` to `^10.1.1` ([#6807](https://github.com/MetaMask/core/pull/6807)) + +## [24.2.0] + +### Added + +- Add two new controller state metadata properties: `includeInStateLogs` and `usedInUi` ([#6525](https://github.com/MetaMask/core/pull/6525)) +- Add `lookupNetwork` option to `initializeProvider`, to allow for skipping the request used to populate metadata for the globally selected network ([#6575](https://github.com/MetaMask/core/pull/6575), [#6607](https://github.com/MetaMask/core/pull/6607)) + - If `lookupNetwork` is set to `false`, the function is fully synchronous, and does not return a promise. + +### Changed + +- Bump `@metamask/controller-utils` from `^11.12.0` to `^11.14.0` ([#6620](https://github.com/MetaMask/core/pull/6620), [#6629](https://github.com/MetaMask/core/pull/6629)) +- Bump `@metamask/base-controller` from `^8.1.0` to `^8.4.0` ([#6355](https://github.com/MetaMask/core/pull/6355), [#6465](https://github.com/MetaMask/core/pull/6465), [#6632](https://github.com/MetaMask/core/pull/6632)) +- Rephrase "circuit broken" errors so they are more user-friendly ([#6423](https://github.com/MetaMask/core/pull/6423)) + - These are errors produced when a request is made to an RPC endpoint after it returns too many consecutive 5xx responses and the underlying circuit is open. +- Bump `@metamask/utils` from `^11.4.2` to `^11.8.0` ([#6588](https://github.com/MetaMask/core/pull/6588)) +- Bump `@metamask/json-rpc-engine` from `^10.0.3` to `^10.1.0` ([#6678](https://github.com/MetaMask/core/pull/6678)) +- Bump `@metamask/eth-json-rpc-provider` from `^4.1.8` to `^5.0.0` ([#6678](https://github.com/MetaMask/core/pull/6678)) + +### Deprecated + +- Deprecate `lookupNetworkByClientId` ([#6308](https://github.com/MetaMask/core/pull/6308)) + - `lookupNetwork` already supports passing in a network client ID; please use this going forward instead. + +## [24.1.0] + +### Added + +- The object in the `NetworkController:rpcEndpointDegraded` event payload now includes an `error` property, which can be used to access the error produced by the last request when the maximum number of retries is exceeded ([#6188](https://github.com/MetaMask/core/pull/6188)) + - This `error` property will be `undefined` if the degraded event merely represents a slow request + +### Changed + +- Bump `@metamask/base-controller` from `^8.0.1` to `^8.1.0` ([#6284](https://github.com/MetaMask/core/pull/6284)) +- Bump `@metamask/controller-utils` from `^11.11.0` to `^11.12.0` ([#6303](https://github.com/MetaMask/core/pull/6303)) + - This effectively changes the `onDegraded` property on `AbstractRpcService` so that the event listener payload may be an object with either a `endpointUrl` property, `error` + `endpointUrl` properties, or `value` + `endpointUrl` properties + - **NOTE:** Although `error` and `value` are new, optional properties, this change makes an inadvertent breaking change to the signature of the event listener due to how TypeScript compares function types. We have conciously decided not to re-release this change under a major version, so be advised. + +## [24.0.1] + +### Changed + +- Requests to an RPC endpoint that returns a 502 response ("bad gateway") will now be retried ([#5923](https://github.com/MetaMask/core/pull/5923)) +- All JSON-RPC errors that represent 4xx and 5xx responses from RPC endpoints now include the HTTP status code under `data.httpStatus` ([#5923](https://github.com/MetaMask/core/pull/5923)) +- 3xx responses from RPC endpoints are no longer treated as errors ([#5923](https://github.com/MetaMask/core/pull/5923)) +- Bump `@metamask/controller-utils` from `^11.10.0` to `^11.11.0` ([#6069](https://github.com/MetaMask/core/pull/6069)) +- Bump `@metamask/utils` from `^11.2.0` to `^11.4.2` ([#6054](https://github.com/MetaMask/core/pull/6054)) + +### Fixed + +- If an RPC endpoint returns invalid/unparseable JSON, it is now represented as a JSON-RPC error with code -32700 (parse error) instead of -32603 (internal error) ([#5923](https://github.com/MetaMask/core/pull/5923)) +- If an RPC endpoint returns a 401 response, it is now represented as a JSON-RPC error with code -32006 (unauthorized) instead of -32603 (internal error) ([#5923](https://github.com/MetaMask/core/pull/5923)) +- If an RPC endpoint returns a 405 response, it is now represented as a JSON-RPC error with code -32080 (client error) instead of -32601 (method not found) ([#5923](https://github.com/MetaMask/core/pull/5923)) +- If an RPC endpoint returns a 402, 404, or 5xx response, it is now represented as a JSON-RPC error with code -32002 (resource unavailable error) instead of -32603 (internal error) ([#5923](https://github.com/MetaMask/core/pull/5923)) +- If an RPC endpoint returns a 4xx response besides 401, 402, 404, 405, or 429, it is now represented as a JSON-RPC error with code -32080 (client error) instead of -32603 (internal error) ([#5923](https://github.com/MetaMask/core/pull/5923)) +- Improve detection of partial JSON responses from RPC endpoints ([#5923](https://github.com/MetaMask/core/pull/5923)) +- Fix "Request cannot be constructed from a URL that includes credentials" error when using RPC endpoints with embedded credentials ([#6116](https://github.com/MetaMask/core/pull/6116)) + +## [24.0.0] + +### Changed + +- **BREAKING:** Remove `@metamask/error-reporting-service@^1.0.0` as a direct dependency, add `^2.0.0` as a peer dependency ([#5970](https://github.com/MetaMask/core/pull/5970), [#5999](https://github.com/MetaMask/core/pull/5999)) + +## [23.6.0] + +### Added + +- Add Base network to default infura networks ([#5902](https://github.com/MetaMask/core/pull/5902)) + - Network changes were added in `@metamask/controller-utils` + +### Changed + +- Bump `@metamask/controller-utils` to `^11.10.0` ([#5935](https://github.com/MetaMask/core/pull/5935)) + +## [23.5.1] + +### Changed + +- **BREAKING:** NetworkController messenger now requires the `ErrorReportingService:captureException` action to be allowed ([#5970](https://github.com/MetaMask/core/pull/5970)) + - This change was originally missed when this release was created. It was added to the changelog afterward. +- Block tracker errors will no longer be wrapped under "PollingBlockTracker - encountered an error while attempting to update latest block" ([#5860](https://github.com/MetaMask/core/pull/5860)) +- Bump dependencies ([#5867](https://github.com/MetaMask/core/pull/5867), [#5860](https://github.com/MetaMask/core/pull/5860)) + - Bump `@metamask/eth-block-tracker` to `^12.0.1` + - Bump `@metamask/eth-json-rpc-infura` to `^10.2.0` + - Bump `@metamask/eth-json-rpc-middleware` to `^17.0.1` + +### Fixed + +- Rather than throwing an error, NetworkController now corrects an invalid initial `selectedNetworkClientId` to point to the default RPC endpoint of the first network sorted by chain ID ([#5851](https://github.com/MetaMask/core/pull/5851)) +- Fix the block tracker so that it will now reject if an error is thrown while making the request instead of hanging ([#5860](https://github.com/MetaMask/core/pull/5860)) + +## [23.5.0] + +### Changed + +- Remove obsolete `eth_getBlockByNumber` error handling for load balancer errors ([#5808](https://github.com/MetaMask/core/pull/5808)) +- Bump `@metamask/controller-utils` to `^11.9.0` ([#5812](https://github.com/MetaMask/core/pull/5812)) + +### Fixed + +- Improved handling of HTTP status codes to prevent unnecessary circuit breaker triggers ([#5798](https://github.com/MetaMask/core/pull/5798), [#5809](https://github.com/MetaMask/core/pull/5809)) + - HTTP 4XX responses (e.g. rate limit errors) will no longer trigger the circuit breaker policy. + +## [23.4.0] + +### Added + +- Add Monad Testnet as default network ([#5724](https://github.com/MetaMask/core/pull/5724)) + +### Changed + +- Bump `@metamask/controller-utils` to `^11.8.0` ([#5765](https://github.com/MetaMask/core/pull/5765)) + +## [23.3.0] + +### Added + +- Add optional `getBlockTrackerOptions` argument to NetworkController constructor ([#5702](https://github.com/MetaMask/core/pull/5702)) +- Add optional `rpcFailoverEnabled` option to NetworkController constructor (`false` by default) ([#5668](https://github.com/MetaMask/core/pull/5668)) +- Add `enableRpcFailover` and `disableRpcFailover` methods to NetworkController ([#5668](https://github.com/MetaMask/core/pull/5668)) + +### Changed + +- Bump `@metamask/base-controller` from ^8.0.0 to ^8.0.1 ([#5722](https://github.com/MetaMask/core/pull/5722)) +- Disable the RPC failover behavior by default ([#5668](https://github.com/MetaMask/core/pull/5668)) + - You are free to set the `failoverUrls` property on an RPC endpoint, but it won't have any effect + - To enable this behavior, either pass `rpcFailoverEnabled: true` to the constructor or call `enableRpcFailover` after initialization + +## [23.2.0] + +### Added + +- Add optional `additionalDefaultNetworks` option to `NetworkController` constructor ([#5527](https://github.com/MetaMask/core/pull/5527)) + - This can be used to customize which custom networks the default `networkConfigurationsByChainId` includes. +- Add `getSelectedChainId` method to `NetworkController` ([#5516](https://github.com/MetaMask/core/pull/5516)) + - This is also callable via the messenger. +- Add `DEPRECATED_NETWORKS` constant ([#5560](https://github.com/MetaMask/core/pull/5560)) + +### Changed + +- Remove Goerli and Linea Goerli from set of default networks ([#5560](https://github.com/MetaMask/core/pull/5560)) + - Note that if you do not pass any initial state to NetworkController, this means that `0x5` and `0xe704` will no longer be keys in `networkConfigurationsByChainId`. + - We are not counting this as a breaking change because we don't make any guarantees about what keys are present in `networkConfigurationsByChainId` at runtime — only that they must be valid chain IDs. + - If you want more of a guarantee, you are recommended to persist the NetworkController state and then pass it back through as initial state. +- Update `RpcEndpoint` so that `failoverUrls` is optional ([#5561](https://github.com/MetaMask/core/pull/5561)) + - This property was introduced in 23.0.0 as a breaking change, but this change makes it non-breaking. +- Update `NetworkClientConfiguration` so that `failoverUrls` is optional ([#5561](https://github.com/MetaMask/core/pull/5561)) + - This property was introduced in 23.0.0 as a breaking change, but this change makes it non-breaking. +- Bump `@metamask/controller-utils` to `^11.7.0` ([#5583](https://github.com/MetaMask/core/pull/5583)) + +### Fixed + +- Upgrade `@metamask/eth-json-rpc-infura` to `^10.1.1` and `@metamask/eth-json-rpc-infura` to `^16.0.1` ([#5573](https://github.com/MetaMask/core/pull/5573)) + - This fixes a bug where non-standard unsuccessful JSON-RPC errors were being ignored/discarded + +## [23.1.0] + +### Added + +- The `NetworkController:rpcEndpointDegraded` messenger event now has a new `chainId` property in its data, which is the ID of the chain that the endpoint represents ([#5517](https://github.com/MetaMask/core/pull/5517)) + +## [23.0.0] + +### Added + +- Implement circuit breaker pattern when retrying requests to Infura and custom RPC endpoints ([#5290](https://github.com/MetaMask/core/pull/5290)) + - If the network is perceived to be unavailable after 5 attempts, further retries will be paused for 30 seconds. + - "Unavailable" means the following: + - A failure to reach the network (exact error depending on platform / HTTP client) + - The request responds with a non-JSON-parseable or non-JSON-RPC-compatible body + - The request returns a non-200 response +- Use exponential backoff / jitter when retrying requests to Infura and custom RPC endpoints ([#5290](https://github.com/MetaMask/core/pull/5290)) + - As requests are retried, the delay between retries will increase exponentially (using random variance to prevent bursts). +- Add support for automatic failover when Infura is unavailable ([#5360](https://github.com/MetaMask/core/pull/5360)) + - An Infura RPC endpoint can now be configured with a list of failover URLs via `failoverUrls`. + - If, after many attempts, an Infura network is perceived to be down, the list of failover URLs will be tried in turn. +- Add messenger action `NetworkController:rpcEndpointUnavailable` for responding to when a RPC endpoint becomes unavailable (see above) ([#5492](https://github.com/MetaMask/core/pull/5492), [#5501](https://github.com/MetaMask/core/pull/5501)) + - Also add associated type `NetworkControllerRpcEndpointUnavailableEvent`. +- Add messenger action `NetworkController:rpcEndpointDegraded` for responding to when a RPC endpoint becomes degraded ([#5492](https://github.com/MetaMask/core/pull/5492)) + - Also add associated type `NetworkControllerRpcEndpointDegradedEvent`. +- Add messenger action `NetworkController:rpcEndpointRequestRetried` for responding to when a RPC endpoint is retried following a retriable error ([#5492](https://github.com/MetaMask/core/pull/5492)) + - Also add associated type `NetworkControllerRpcEndpointRequestRetriedEvent`. + - This is mainly useful for tests when mocking timers. +- Export `RpcServiceRequestable` type, which was previously named `AbstractRpcService` ([#5492](https://github.com/MetaMask/core/pull/5492)) +- Export `isConnectionError` utility function ([#5501](https://github.com/MetaMask/core/pull/5501)) + +### Changed + +- **BREAKING:** `NetworkController` constructor now takes a new required option, `getRpcServiceOptions` ([#5290](https://github.com/MetaMask/core/pull/5290), [#5492](https://github.com/MetaMask/core/pull/5492)) + - This can be used to customize how RPC services (which eventually hit RPC endpoints) are constructed. + - For instance, you could set one `circuitBreakDuration` for one class of endpoints, and another `circuitBreakDuration` for another class. + - At minimum you will need to pass `fetch` and `btoa`. + - The `NetworkControllerOptions` also reflects this change. +- **BREAKING:** Add required property `failoverUrls` to `RpcEndpoint` ([#5360](https://github.com/MetaMask/core/pull/5360)) + - The `NetworkControllerState` and the `state` option to `NetworkController` also reflect this change. +- **BREAKING:** Add required property `failoverRpcUrls` to `NetworkClientConfiguration` ([#5360](https://github.com/MetaMask/core/pull/5360)) + - The `configuration` property in the `AutoManagedNetworkClient` and `NetworkClient` types also reflect this change. +- **BREAKING:** The `AbstractRpcService` type now has a non-optional `endpointUrl` property ([#5492](https://github.com/MetaMask/core/pull/5492)) + - The old version of `AbstractRpcService` is now called `RpcServiceRequestable` +- Synchronize retry logic and error handling behavior between Infura and custom RPC endpoints ([#5290](https://github.com/MetaMask/core/pull/5290)) + - A request to a custom endpoint that returns a 418 response will no longer return a JSON-RPC response with the error "Request is being rate limited". + - A request to a custom endpoint that returns a 429 response now returns a JSON-RPC response with the error "Request is being rate limited". + - A request to a custom endpoint that throws an "ECONNRESET" error will now be retried up to 5 times. + - A request to a Infura endpoint that fails more than 5 times in a row will now respond with a JSON-RPC error that encompasses the failure instead of hiding it as "InfuraProvider - cannot complete request. All retries exhausted". + - A request to a Infura endpoint that returns a non-retriable, non-2xx response will now respond with a JSON-RPC error that has the underling message "Non-200 status code: '\'" rather than including the raw response from the endpoint. + - A request to a custom endpoint that fails with a retriable error more than 5 times in a row will now respond with a JSON-RPC error that encompasses the failure instead of returning an empty response. + - A "retriable error" is now regarded as the following: + - A failure to reach the network (exact error depending on platform / HTTP client) + - The request responds with a non-JSON-parseable or non-JSON-RPC-compatible body + - The request returns a 503 or 504 response +- Bump dependencies to support usage of RPC services internally for network requests ([#5290](https://github.com/MetaMask/core/pull/5290)) + - Bump `@metamask/eth-json-rpc-infura` to `^10.1.0` + - Bump `@metamask/eth-json-rpc-middleware` to `^15.1.0` +- Bump `@metamask/controller-utils` to `^11.5.0` ([#5439](https://github.com/MetaMask/core/pull/5439)) +- Bump `@metamask/utils` to `^11.2.0` ([#5301](https://github.com/MetaMask/core/pull/5301)) + +### Fixed + +- Fix `findNetworkClientIdByChainId` to return the network client ID for the chain's configured default RPC endpoint instead of its first listed RPC endpoint ([#5344](https://github.com/MetaMask/core/pull/5344)) + +## [22.2.1] + +### Changed + +- Bump `@metamask/base-controller` from `^7.1.1` to `^8.0.0` ([#5305](https://github.com/MetaMask/core/pull/5305)) + +## [22.2.0] + +### Added + +- Export `AbstractRpcService` type ([#5263](https://github.com/MetaMask/core/pull/5263)) + +### Changed + +- Bump `@metamask/base-controller` from `^7.0.0` to `^7.1.1` ([#5079](https://github.com/MetaMask/core/pull/5079), [#5135](https://github.com/MetaMask/core/pull/5135)) +- Bump `@metamask/controller-utils` from `^11.4.4` to `^11.5.0` ([#5135](https://github.com/MetaMask/core/pull/5135), [#5272](https://github.com/MetaMask/core/pull/5272)) +- Bump `@metamask/eth-json-rpc-provider` from `^4.1.6` to `^4.1.8` ([#5082](https://github.com/MetaMask/core/pull/5082), [#5272](https://github.com/MetaMask/core/pull/5272)) +- Bump `@metamask/json-rpc-engine` from `^10.0.1` to `^10.0.3` ([#5082](https://github.com/MetaMask/core/pull/5082), [#5272](https://github.com/MetaMask/core/pull/5272)) +- Bump `@metamask/rpc-errors` from `^7.0.1` to `^7.0.2` ([#5080](https://github.com/MetaMask/core/pull/5080)) +- Bump `@metamask/utils` from `^10.0.0` to `^11.1.0` ([#5080](https://github.com/MetaMask/core/pull/5080), [#5223](https://github.com/MetaMask/core/pull/5223)) + +### Fixed + +- Fix `lookupNetwork` so that it will no longer throw an error if `networkDidChange` subscriptions have been removed before it returns ([#5116](https://github.com/MetaMask/core/pull/5116)) + - This error could occur if the NetworkController's messenger is cleared of subscriptions, as in a "destroy" step. +- Fix race condition so that after adding a new RPC endpoint to a network, it is possible to access the new endpoint inside of a `stateChange` event listener via `getNetworkConfigurationByNetworkClientId` ([#5122](https://github.com/MetaMask/core/pull/5122)) +- Fix `selectAvailableNetworkClientIds` so that it is properly memoized ([#5193](https://github.com/MetaMask/core/pull/5193)) + +## [22.1.1] + +### Changed + +- Bump `@metamask/eth-json-rpc-middleware` from `^15.0.0` to `^15.0.1` ([#5037](https://github.com/MetaMask/core/pull/5037)) +- Bump `swappable-obj-proxy` from `^2.2.0` to `^2.3.0` ([#5036](https://github.com/MetaMask/core/pull/5036)) +- Bump `@metamask/eth-block-tracker` from `^11.0.2` to `^11.0.3` ([#5025](https://github.com/MetaMask/core/pull/5025)) + +## [22.1.0] + +### Added + +- The `NetworkController:networkRemoved` messenger event will now be emitted when a network is removed ([#4698](https://github.com/MetaMask/core/pull/4698)) +- Add messenger actions `NetworkController:addNetwork`, `NetworkController:removeNetwork`, and `NetworkController:updateNetwork` which call the respective controller methods ([#4698](https://github.com/MetaMask/core/pull/4698)) +- Add `lastUpdatedAt` property to network configurations which will be set to the current time on addition or update ([#4652](https://github.com/MetaMask/core/pull/4652)) + - This was added to support the upcoming network syncing feature. + - This property is optional and will be `undefined` for existing network configurations that have not yet been updated. + +### Changed + +- Add dependency `fast-deep-equal` ([#4652](https://github.com/MetaMask/core/pull/4652)) +- Bump `@metamask/controller-utils` from `^11.4.3` to `^11.4.4` ([#5012](https://github.com/MetaMask/core/pull/5012)) + +### Fixed + +- Remove dependency on Node builtin module `util` to ensure that `@metamask/network-controller` can be used in a strict browser context ([#3672](https://github.com/MetaMask/core/pull/3672)) +- Correct ESM-compatible build so that imports of the following packages that re-export other modules via `export *` are no longer corrupted: ([#5011](https://github.com/MetaMask/core/pull/5011)) + - `@metamask/eth-block-tracker` + - `@metamask/eth-json-rpc-infura` + - `@metamask/eth-json-rpc-middleware` + - `@metamask/eth-query` + - `@metamask/swappable-obj-proxy` + - `fast-deep-equal` + +## [22.0.2] + +### Changed + +- `getDefaultNetworkConfigurationsByChainId` returns the updated display names for mainnet and linea. `Ethereum Mainnet` instead of `Mainnet`, and `Linea` instead of `Linea Mainnet`. ([#4865](https://github.com/MetaMask/core/pull/4865)) +- Bump `@metamask/controller-utils` from `^11.4.2` to `^11.4.3` ([#4915](https://github.com/MetaMask/core/pull/4915)) + +## [22.0.1] + +### Changed + +- Bump `@metamask/base-controller` from `^7.0.1` to `^7.0.2` ([#4862](https://github.com/MetaMask/core/pull/4862)) +- Bump `@metamask/controller-utils` from `^11.4.0` to `^11.4.2` ([#4862](https://github.com/MetaMask/core/pull/4862), [#4870](https://github.com/MetaMask/core/pull/4870)) +- Bump `@metamask/eth-json-rpc-provider` from `^4.1.5` to `^4.1.6` ([#4862](https://github.com/MetaMask/core/pull/4862)) +- Bump `@metamask/json-rpc-engine` from `^10.0.0` to `^10.0.1` ([#4862](https://github.com/MetaMask/core/pull/4862)) +- Bump `@metamask/rpc-errors` from `^7.0.0` to `^7.0.1` ([#4831](https://github.com/MetaMask/core/pull/4831)) + +## [22.0.0] + +### Changed + +- Corrects the previous 21.1.0 release to document breaking changes that were missed: + - **BREAKING:** Bump `@metamask/eth-block-tracker` from `^10.0.0` to `^11.0.2` ([#4769](https://github.com/MetaMask/core/pull/4769)) + - **BREAKING:** Bump `@metamask/eth-json-rpc-middleware` from `^13.0.0` to `^15.0.0` ([#4769](https://github.com/MetaMask/core/pull/4769)) + - **BREAKING:** Bump `@metamask/json-rpc-engine` from `^9.0.3` to `^10.0.0` ([#4769](https://github.com/MetaMask/core/pull/4769)) + - **BREAKING:** Bump `@metamask/rpc-errors` from `^6.3.1` to `^7.0.0` ([#4769](https://github.com/MetaMask/core/pull/4769)) + - **BREAKING:** Bump `@metamask/eth-json-rpc-infura` from `^9.1.0` to `^10.0.0` ([#4769](https://github.com/MetaMask/core/pull/4769)) + - Bump `@metamask/eth-json-rpc-provider` from `^4.1.4` to `^4.1.5` ([#4798](https://github.com/MetaMask/core/pull/4798)) + - This update was recorded in the v21.1.0 changelog, but is listed here again because that release has been deprecated. +- Bump `@metamask/controller-utils` from `^11.3.0` to `^11.4.0` ([#4834](https://github.com/MetaMask/core/pull/4834)) + +## [21.1.0] [DEPRECATED] + +### Changed + +- Bump `@metamask/eth-json-rpc-provider` from `^4.1.4` to `^4.1.5` ([#4798](https://github.com/MetaMask/core/pull/4798)) + +## [21.0.1] + +### Fixed + +- Produce and export ESM-compatible TypeScript type declaration files in addition to CommonJS-compatible declaration files ([#4648](https://github.com/MetaMask/core/pull/4648)) + - Previously, this package shipped with only one variant of type declaration + files, and these files were only CommonJS-compatible, and the `exports` + field in `package.json` linked to these files. This is an anti-pattern and + was rightfully flagged by the + ["Are the Types Wrong?"](https://arethetypeswrong.github.io/) tool as + ["masquerading as CJS"](https://github.com/arethetypeswrong/arethetypeswrong.github.io/blob/main/docs/problems/FalseCJS.md). + All of the ATTW checks now pass. +- Remove chunk files ([#4648](https://github.com/MetaMask/core/pull/4648)) + - Previously, the build tool we used to generate JavaScript files extracted + common code to "chunk" files. While this was intended to make this package + more tree-shakeable, it also made debugging more difficult for our + development teams. These chunk files are no longer present. + +## [21.0.0] + +### Added + +- **BREAKING:** Add `networkConfigurationsByChainId` to `NetworkState` (type: `Record`) ([#4268](https://github.com/MetaMask/core/pull/4268)) + - This property replaces `networkConfigurations`, and, as its name implies, organizes network configurations by chain ID rather than network client ID. + - If no initial state or this property is not included in initial state, the default value of this property will now include configurations for known Infura networks (Mainnet, Goerli, Sepolia, Linea Goerli, Linea Sepolia, and Linea Mainnet) by default. +- Add `getNetworkConfigurationByChainId` method, `NetworkController:getNetworkConfigurationByChainId` messenger action, and `NetworkControllerGetNetworkConfigurationByNetworkClientId` type ([#4268](https://github.com/MetaMask/core/pull/4268)) +- Add `addNetwork`, which replaces one half of `upsertNetworkConfiguration` and can be used to add new network clients for a chain ([#4268](https://github.com/MetaMask/core/pull/4268)) + - It's worth noting that this method now publishes a `NetworkController:networkAdded` event instead of calling a `trackMetaMetricsEvent` callback. It is expected that you will subscribe to this event and create a MetaMetrics event yourself. +- Add `updateNetwork`, which replaces one half of `upsertNetworkConfiguration` and can be used to recreate the network clients for an existing chain based on an updated configuration ([#4268](https://github.com/MetaMask/core/pull/4268)) + - Note that it is not possible to remove the RPC endpoint from a network configuration that is currently represented by the globally selected network client. To prevent an error, you'll need to detect when such a removal is occurring and pass the `replacementSelectedRpcEndpointIndex` to `updateNetwork`. It will then switch to the designated RPC endpoint's network client on your behalf. +- Add `removeNetwork`, which replaces `removeNetworkConfiguration` and can be used to remove existing network clients for a chain ([#4268](https://github.com/MetaMask/core/pull/4268)) +- Add `getDefaultNetworkControllerState` function, which replaces `defaultState` and matches patterns in other controllers ([#4268](https://github.com/MetaMask/core/pull/4268)) +- Add `RpcEndpointType`, `AddNetworkFields`, and `UpdateNetworkFields` types ([#4268](https://github.com/MetaMask/core/pull/4268)) +- Add `getNetworkConfigurations`, `getAvailableNetworkClientIds` and `selectAvailableNetworkClientIds` selectors ([#4268](https://github.com/MetaMask/core/pull/4268)) + - These new selectors can be applied to messenger event subscriptions + +### Changed + +- **BREAKING:** Replace `NetworkConfiguration` type with a new definition ([#4268](https://github.com/MetaMask/core/pull/4268)) + - A network configuration no longer represents a single RPC endpoint but rather a collection of RPC endpoints that can all be used to interface with a single chain. + - The only property that has brought over to this type unchanged is `chainId`. + - `ticker` has been renamed to `nativeCurrency`. + - `nickname` has been renamed to `name`. + - `rpcEndpoints` has been added. This is an an array of objects, where each object has properties `name` (optional), `networkClientId` (optional), `type`, and `url`. + - `defaultRpcEndpointIndex` has been added. This must point to an entry in `rpcEndpoints`. + - The block explorer URL is no longer located in `rpcPrefs` and is no longer restricted to one: `blockExplorerUrls` has been added along with a corresponding property `defaultBlockExplorerUrlIndex`, which must point to an entry in `blockExplorerUrls`. + - `id` has been removed. Previously, this represented the ID of the network client associated with the network configuration. Since network clients are now created from RPC endpoints, the equivalent to this is the `networkClientId` property on an `RpcEndpoint`. +- **BREAKING:** The network controller messenger must now allow the action `NetworkController:getNetworkConfigurationByChainId` ([#4268](https://github.com/MetaMask/core/pull/4268)) +- **BREAKING:** The network controller messenger must now allow the event `NetworkController:networkAdded` ([#4268](https://github.com/MetaMask/core/pull/4268)) +- **BREAKING:** The `NetworkController` constructor will now throw if the initial state provided is invalid ([#4268](https://github.com/MetaMask/core/pull/4268)) + - `networkConfigurationsByChainId` cannot be empty. + - The `chainId` of a network configuration in `networkConfigurationsByChainId` must match the chain ID it is filed under. + - The `defaultRpcEndpointIndex` of a network configuration in `networkConfigurationsByChainId` must point to an entry in its `rpcEndpoints`. + - The `defaultBlockExplorerUrlIndex` of a network configuration in `networkConfigurationsByChainId` must point to an entry in its `blockExplorerUrls`. + - `selectedNetworkClientId` must match the `networkClientId` of an RPC endpoint in `networkConfigurationsByChainId`. +- **BREAKING:** Update `getNetworkConfigurationByNetworkClientId` so that when given an Infura network name (that is, a value from `InfuraNetworkType`), it will return a masked version of the RPC endpoint URL for the associated Infura network ([#4268](https://github.com/MetaMask/core/pull/4268)) + - If you want the unmasked version, you'll need the `url` property from the network _client_ configuration, which you can get by calling `getNetworkClientById` and then accessing the `configuration` property off of the network client. +- **BREAKING:** Update `loadBackup` to take and update `networkConfigurationsByChainId` instead of `networkConfigurations` ([#4268](https://github.com/MetaMask/core/pull/4268)) +- Bump `@metamask/base-controller` from `^6.0.2` to `^7.0.0` ([#4625](https://github.com/MetaMask/core/pull/4625), [#4643](https://github.com/MetaMask/core/pull/4643)) +- Bump `@metamask/controller-utils` from `^11.0.2` to `^11.2.0` ([#4639](https://github.com/MetaMask/core/pull/4639), [#4651](https://github.com/MetaMask/core/pull/4651)) +- Bump `@metamask/eth-block-tracker` from `^9.0.3` to `^10.0.0` ([#4424](https://github.com/MetaMask/core/pull/4424)) +- Bump `@metamask/eth-json-rpc-middleware` from `^12.1.1` to `^13.0.0` ([#4424](https://github.com/MetaMask/core/pull/4424)) + +### Removed + +- **BREAKING:** Remove `networkConfigurations` from `NetworkState`, which has been replaced with `networkConfigurationsByChainId` ([#4268](https://github.com/MetaMask/core/pull/4268)) +- **BREAKING:** Remove `upsertNetworkConfiguration` and `removeNetworkConfiguration`, which have been replaced with `addNetwork`, `updateNetwork`, and `removeNetwork` ([#4268](https://github.com/MetaMask/core/pull/4268)) +- **BREAKING:** Remove `defaultState` variable, which has been replaced with a `getDefaultNetworkControllerState` function ([#4268](https://github.com/MetaMask/core/pull/4268)) +- **BREAKING:** Remove `trackMetaMetricsEvent` option from the NetworkController constructor ([#4268](https://github.com/MetaMask/core/pull/4268)) + - Previously, this was used in `upsertNetworkConfiguration` to create a MetaMetrics event when a new network was added. This can now be achieved by subscribing to the `NetworkController:networkAdded` event and creating the event inside of the event handler. + +## [20.2.0] + +### Changed + +- `upsertNetworkConfiguration` now accepts an optional id property on the NetworkConfiguration param. It allows a network configuration to have its rpcUrl updated in place when an id is specified, but only if that new rpcUrl does not already exist on a different network configuration. ([#4614](https://github.com/MetaMask/core/pull/4614)) +- Bump `@metamask/eth-json-rpc-provider` to `^4.1.3` ([#4607](https://github.com/MetaMask/core/pull/4607)) +- Update TypeScript to 5.2.2 ([#4576](https://github.com/MetaMask/core/pull/4576), [#4584](https://github.com/MetaMask/core/pull/4584)) + +### Fixed + +- `removeNetworkConfiguration` now throws an error if you attempt to remove the selected network ([#4566](https://github.com/MetaMask/core/pull/4566)) + +## [20.1.0] + +### Added + +- Newly export the following types: `AutoManagedNetworkClient`, `InfuraNetworkClientConfiguration`, `CustomNetworkClientConfiguration` ([#3645](https://github.com/MetaMask/core/pull/3645)) + +### Changed + +- Upgrade TypeScript version to `~5.0.4` and set `moduleResolution` option to `Node16` ([#3645](https://github.com/MetaMask/core/pull/3645)) +- Bump `@metamask/base-controller` from `^6.0.0` to `^6.0.2` ([#4517](https://github.com/MetaMask/core/pull/4517), [#4544](https://github.com/MetaMask/core/pull/4544)) +- Bump `@metamask/controller-utils` from `^11.0.0` to `^11.0.2` ([#4517](https://github.com/MetaMask/core/pull/4517), [#4544](https://github.com/MetaMask/core/pull/4544)) +- Bump `@metamask/eth-json-rpc-provider` from `^4.1.0` to `^4.1.2` ([#4519](https://github.com/MetaMask/core/pull/4519), [#4548](https://github.com/MetaMask/core/pull/4548)) +- Bump `@metamask/json-rpc-engine` from `^9.0.0` to `^9.0.2` ([#4517](https://github.com/MetaMask/core/pull/4517), [#4544](https://github.com/MetaMask/core/pull/4544)) +- Bump `@metamask/rpc-errors` from `^6.2.1` to `^6.3.1` ([#4516](https://github.com/MetaMask/core/pull/4516)) +- Bump `@metamask/utils` from `^8.3.0` to `^9.1.0` ([#4516](https://github.com/MetaMask/core/pull/4516), [#4529](https://github.com/MetaMask/core/pull/4529)) + +## [20.0.0] + +### Added + +- Add a new `log` argument to the constructor ([#4440](https://github.com/MetaMask/core/pull/4440)) + - The new `log` argument must be a `Logger` object from the `loglevel` package and will be used to log a message when we fail to connect to a network or the network responds with an unknown error + +### Changed + +- **BREAKING:** Update `networksMetadata` state property so that the keys in the object will only ever be network client IDs and not RPC URLs ([#4254](https://github.com/MetaMask/core/pull/4254)) + - Some keys could have been RPC URLs if the initial network controller state had a `providerConfig` with an empty `id`, but since `providerConfig` is being removed, that won't happen anymore. +- Bump `@metamask/eth-block-tracker` to `^9.0.3` ([#4418](https://github.com/MetaMask/core/pull/4418)) +- Bump `@metamask/eth-json-rpc-provider` to `^4.1.0` ([#4508](https://github.com/MetaMask/core/pull/4508)) + +### Removed + +- **BREAKING:** Remove `providerConfig` property from state along with `ProviderConfig` type and `NetworkController:getProviderConfig` messenger action ([#4254](https://github.com/MetaMask/core/pull/4254)) + - The best way to obtain the equivalent configuration object, e.g. to access the chain ID of the currently selected network, is to get `selectedNetworkClientId` from state, pass this to the `NetworkController:getNetworkClientId` messenger action, and then use the `configuration` property on the network client. + +## [19.0.0] + +### Changed + +- **BREAKING:** Bump minimum Node version to 18.18 ([#3611](https://github.com/MetaMask/core/pull/3611)) +- Bump `@metamask/base-controller` to `^6.0.0` ([#4352](https://github.com/MetaMask/core/pull/4352)) +- Bump `@metamask/controller-utils` to `^11.0.0` ([#4352](https://github.com/MetaMask/core/pull/4352)) +- Bump `@metamask/eth-json-rpc-provider` to `^4.0.0` ([#4352](https://github.com/MetaMask/core/pull/4352)) +- Bump `@metamask/json-rpc-engine` to `^9.0.0` ([#4352](https://github.com/MetaMask/core/pull/4352)) + +## [18.1.3] + +### Changed + +- Bump `async-mutex` to `^0.5.0` ([#4335](https://github.com/MetaMask/core/pull/4335)) +- Bump `@metamask/controller-utils` to `^10.0.0` ([#4342](https://github.com/MetaMask/core/pull/4342)) + +## [18.1.2] + +### Fixed + +- Update from `eth-block-tracker` to `@metamask/eth-block-tracker` `^9.0.2`, mitigating redundant polling loops ([#4309](https://github.com/MetaMask/core/pull/4309)) + +## [18.1.1] + +### Added + +- Export `BuiltInNetworkClientId` and `CustomNetworkClientId` ([#4247](https://github.com/MetaMask/core/pull/4247)) + +### Changed + +- Bump `@metamask/eth-json-rpc-provider` to `^3.0.2` ([#4234](https://github.com/MetaMask/core/pull/4234)) +- Bump `@metamask/json-rpc-engine` to `^8.0.2` ([#4234](https://github.com/MetaMask/core/pull/4234)) +- Bump `@metamask/base-controller` to `^5.0.2` ([#4232](https://github.com/MetaMask/core/pull/4232)) +- Bump `@metamask/controller-utils` to `^9.1.0` ([#4153](https://github.com/MetaMask/core/pull/4153)) + +## [18.1.0] + +### Added + +- Add `getSelectedNetworkClient` method that returns the provider and blockTracker for the currently selected network but with a more easily used type than `getProviderAndBlockTracker` ([#4063](https://github.com/MetaMask/core/pull/4063)) +- Add `NetworkController:getSelectedNetworkClient` action ([#4063](https://github.com/MetaMask/core/pull/4063)) + +### Changed + +- `getProviderAndBlockTracker` is now marked as deprecated and will be removed in a future release. ([#4063](https://github.com/MetaMask/core/pull/4063)) + +## [18.0.1] + +### Fixed + +- Fix `types` field in `package.json` ([#4047](https://github.com/MetaMask/core/pull/4047)) + +## [18.0.0] + +### Added + +- **BREAKING**: Add ESM build ([#3998](https://github.com/MetaMask/core/pull/3998)) + - It's no longer possible to import files from `./dist` directly. +- Add network client for Linea Sepolia (chain ID `0xe705`) ([#3995](https://github.com/MetaMask/core/pull/3995)) + - Bump `@metamask/eth-json-rpc-infura` to `^9.1.0` to bring this change. + +### Changed + +- **BREAKING:** Bump `@metamask/base-controller` to `^5.0.0` ([#4039](https://github.com/MetaMask/core/pull/4039)) + - This version has a number of breaking changes. See the changelog for more. +- Bump `@metamask/controller-utils` to `^9.0.0` ([#4039](https://github.com/MetaMask/core/pull/4039)) + +### Fixed + +- **BREAKING:** Narrow `NetworkControllerMessenger` type parameters `AllowedAction` and `AllowedEvent` from `string` to `never` ([#4031](https://github.com/MetaMask/core/pull/4031)) + - Allowlisting or using any external actions or events will now produce a type error. + +## [17.2.1] + +### Changed + +- Bump `@metamask/controller-utils` to `^8.0.4` ([#4007](https://github.com/MetaMask/core/pull/4007)) +- Bump `@metamask/eth-json-rpc-middleware` to `^12.1.0` ([#3829](https://github.com/MetaMask/core/pull/3829)) +- Bump `@metamask/json-rpc-engine` to `^7.3.3` ([#4007](https://github.com/MetaMask/core/pull/4007)) +- Bump `@metamask/rpc-errors` to `^6.2.1` ([#3970](https://github.com/MetaMask/core/pull/3970), [#3954](https://github.com/MetaMask/core/pull/3954)) + +## [17.2.0] + +### Changed + +- The `setActiveNetwork` method and action now supports built-in network types ([#3764](https://github.com/MetaMask/core/pull/3764)) + - Previously this would only accept a network configuration ID. Now it will accept the type of a built-in network as well, using it like an ID. This lets you switch to a built-in or custom network with a single method/action. +- Deprecate the `setProviderType` method and action ([#3764](https://github.com/MetaMask/core/pull/3764)) + - Use `setActiveNetwork` instead +- Bump `@metamask/swappable-obj-proxy` to `^2.2.0` ([#3784](https://github.com/MetaMask/core/pull/3784)) +- Bump `@metamask/utils` to `^8.3.0` ([#3769](https://github.com/MetaMask/core/pull/3769)) +- Bump `@metamask/base-controller` to `^4.1.1` ([#3760](https://github.com/MetaMask/core/pull/3760), [#3821](https://github.com/MetaMask/core/pull/3821)) +- Bump `@metamask/controller-utils` to `^8.0.2` ([#3821](https://github.com/MetaMask/core/pull/3821)) +- Bump `@metamask/eth-json-rpc-provider` to `^2.3.2` ([#3821](https://github.com/MetaMask/core/pull/3821)) +- Bump `@metamask/json-rpc-engine` to `^7.3.2` ([#3821](https://github.com/MetaMask/core/pull/3821)) + +## [17.1.0] + +### Added + +- Add `getNetworkConfigurationByNetworkClientId` method which can be used to retrieve details for both custom and built-in networks (using the network configuration object shape) ([#2055](https://github.com/MetaMask/core/pull/2055)) +- Add `NetworkController:getNetworkConfigurationByNetworkClientId` messenger action for the previous method ([#2055](https://github.com/MetaMask/core/pull/2055)) + ### Changed + +- Bump `@metamask/base-controller` to `^4.0.1` ([#3695](https://github.com/MetaMask/core/pull/3695)) +- Bump `@metamask/controller-utils` to `^8.0.1` ([#3695](https://github.com/MetaMask/core/pull/3695), [#3678](https://github.com/MetaMask/core/pull/3678), [#3667](https://github.com/MetaMask/core/pull/3667), [#3580](https://github.com/MetaMask/core/pull/3580)) +- Bump `@metamask/eth-json-rpc-provider` to `^2.3.1` ([#3695](https://github.com/MetaMask/core/pull/3695)) +- Bump `@metamask/json-rpc-engine` to `^7.3.1` ([#3695](https://github.com/MetaMask/core/pull/3695)) +- Create new network clients before updating `networkConfigurations` state ([#3679](https://github.com/MetaMask/core/pull/3679)) + - This primarily affects subscribers to the `NetworkController:stateChange` event. It's now safe to use a network client for any network that appears in the `networkConfigurations` state, whereas previously it was possible that synchronous attempts to access a network client in response to this event would fail. +- Add `NetworkState` payload to `NetworkController:networkWillChange` and `NetworkController:networkDidChange` ([#3598](https://github.com/MetaMask/core/pull/3598)) + - Both of these events now include `NetworkState` as the first and only item in the payload + +## [17.0.0] + +### Changed + +- **BREAKING:** Bump `@metamask/base-controller` to ^4.0.0 ([#2063](https://github.com/MetaMask/core/pull/2063)) + - This is breaking because the type of the `messenger` has backward-incompatible changes. See the changelog for this package for more. +- Bump `@metamask/controller-utils` to ^6.0.0 ([#2063](https://github.com/MetaMask/core/pull/2063)) + +## [16.0.0] + +### Changed + +- **BREAKING:** Bump dependency `@metamask/eth-query` from ^3.0.1 to ^4.0.0 ([#2028](https://github.com/MetaMask/core/pull/2028)) + - This is breaking because it changes the type of the EthQuery instance this controller creates internally and exports under the `getEthQuery` action. Please consult the [changelog for `@metamask/eth-query` 4.0.0](https://github.com/MetaMask/eth-query/blob/main/CHANGELOG.md#400) for more. + +## [15.2.0] + +### Changed + +- Update @metamask/eth-json-rpc-middleware in network controller ([#1988](https://github.com/MetaMask/core/pull/1988)) +- Bump dependency on `@metamask/json-rpc-engine` to ^7.2.0 ([#1895](https://github.com/MetaMask/core/pull/1895)) +- Bump @metamask/utils from 8.1.0 to 8.2.0 ([#1957](https://github.com/MetaMask/core/pull/1957)) + +## [15.1.0] + +### Added + +- Add new action handlers and associated types ([#1806](https://github.com/MetaMask/core/pull/1806)) + - `NetworkController:setActiveNetwork` / `NetworkControllerSetActiveNetworkAction` + - `NetworkController:setProviderType` / `NetworkControllerSetProviderTypeAction` + - `NetworkController:findNetworkClientByChainId` / `NetworkControllerFindNetworkClientIdByChainIdAction` +- Add `ticker` to `NetworkClientConfiguration` ([#1794](https://github.com/MetaMask/core/pull/1794)) + +### Changed + - Bump dependency on `@metamask/eth-json-rpc-provider` to ^2.2.0 ([#1738](https://github.com/MetaMask/core/pull/1738)) ## [15.0.0] + ### Changed + - **BREAKING:** Bump dependency on `@metamask/eth-json-rpc-infura` to ^9.0.0 ([#1653](https://github.com/MetaMask/core/pull/1653)) - **BREAKING:** Bump dependency on `@metamask/eth-json-rpc-middleware` to ^12.0.0 ([#1653](https://github.com/MetaMask/core/pull/1653)) - **BREAKING:** Move from `json-rpc-engine` ^7.1.1 to `@metamask/json-rpc-engine` ^8.0.0 ([#1653](https://github.com/MetaMask/core/pull/1653)) @@ -18,24 +954,32 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Move from `eth-rpc-errors` ^4.0.2 to `@metamask/rpc-errors` ^6.1.0 ([#1653](https://github.com/MetaMask/core/pull/1653)) ## [14.0.0] + ### Added + - Add `NetworkController:getEIP1559Compatibility` controller action ([#1673](https://github.com/MetaMask/core/pull/1673)) ### Changed + - **BREAKING:** Rename `get1555CompatibilityWithNetworkClientId` to `get1559CompatibilityWithNetworkClientId` ([#1673](https://github.com/MetaMask/core/pull/1673)) - Bump dependency on `@metamask/utils` to ^8.1.0 ([#1639](https://github.com/MetaMask/core/pull/1639)) - Bump dependency on `@metamask/base-controller` to ^3.2.3 - Bump dependency on `@metamask/controller-utils` to ^5.0.2 ### Fixed + - Update linea goerli explorer url ([#1666](https://github.com/MetaMask/core/pull/1666)) ## [13.0.1] + ### Changed + - Update TypeScript to v4.8.x ([#1718](https://github.com/MetaMask/core/pull/1718)) ## [13.0.0] + ### Changed + - **BREAKING**: Remove `NetworkId` type ([#1633](https://github.com/MetaMask/core/pull/1633)) - **BREAKING**: Remove `networkId` property from `NetworkState` type ([#1633](https://github.com/MetaMask/core/pull/1633)) - Update scaffold RPC middleware for built-in Infura networks to no longer resolve `net_version` locally ([#1633](https://github.com/MetaMask/core/pull/1633)) @@ -43,32 +987,44 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Bump dependency on `@metamask/controller-utils` to ^5.0.0 ## [12.2.0] + ### Added + - Add `NetworkController:getNetworkClientById` action ([#1638](https://github.com/MetaMask/core/pull/1638)) - Add `lookupNetworkByClientId` and `get1555CompatibilityWithNetworkClientId` methods ([#1557](https://github.com/MetaMask/core/pull/1557)) ### Changed -- Add optional `networkClientId` argument to methods `lookupNetwork` and `getEIP1559Compatibility` ([#1557](https://github.com/MetaMask/core/pull/1557)) + +- Add optional `networkClientId` argument to methods `lookupNetwork` and `getEIP1559Compatibility` ([#1557](https://github.com/MetaMask/core/pull/1557)) ## [12.1.2] + ### Changed + - Bump dependency on `@metamask/base-controller` to ^3.2.1 - Bump dependency on `@metamask/controller-utils` to ^4.3.2 ## [12.1.1] + ### Added + - Added an export for NetworkClientId in NetworkController ([#1583](https://github.com/MetaMask/core/pull/1583)) ## [12.1.0] + ### Added + - Add `getNetworkClientById` ([#1562](https://github.com/MetaMask/core/pull/1562)) - Add `findNetworkClientIdByChainId` ([#1571](https://github.com/MetaMask/core/pull/1571)) ## [12.0.0] + ### Added + - Add `NetworksMetadata` type ([#1559](https://github.com/MetaMask/core/pull/1559)) ### Changed + - **BREAKING:** Remove `NetworkDetails` type in favor of `NetworkMetadata` ([#1559](https://github.com/MetaMask/core/pull/1559)) - This new type includes `NetworkDetails` plus a `status` property - **BREAKING:** Add `networksMetadata` to state ([#1559](https://github.com/MetaMask/core/pull/1559)) @@ -79,11 +1035,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Replace `eth-query` ^2.1.2 with `@metamask/eth-query` ^3.0.1 ([#1546](https://github.com/MetaMask/core/pull/1546)) ### Removed + - **BREAKING:** Remove `networkDetails` from state ([#1559](https://github.com/MetaMask/core/pull/1559)) - The data in this state property has been merged into the new `networksMetadata` state property; each value in this object contains an `EIPS` property. ## [11.0.0] + ### Changed + - **BREAKING**: Require `ticker` to be included in the `providerConfig` state ([#1495](https://github.com/MetaMask/core/pull/1495)) - This requires a state migration, setting `providerConfig.ticker` to `ETH` if it's missing. - Update `@metamask/utils` to `^6.2.0` ([#1514](https://github.com/MetaMask/core/pull/1514)) @@ -91,16 +1050,21 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Remove unnecessary `babel-runtime` dependency ([#1504](https://github.com/MetaMask/core/pull/1504)) ## [10.3.1] + ### Changed + - Bump `@metamask/eth-json-rpc-infura` dependency from ^8.0.0 to ^8.1.0 - This extends the types that this package recognizes to include Linea networks ## [10.3.0] + ### Added + - Add `getNetworkClientsById` method ([#1439](https://github.com/MetaMask/core/pull/1439)) - This method returns a registry of available built-in and custom networks, allowing consumers to access multiple networks simultaneously if desired ### Changed + - Network clients are retained and will no longer be destroyed or recreated whenever the network is initialized or switched ([#1439](https://github.com/MetaMask/core/pull/1439)) - This means that cached responses for a network will no longer disappear when a different network is selected - Update `upsertNetworkConfiguration` to keep the network client registry up to date with changes to the set of network configurations ([#1439](https://github.com/MetaMask/core/pull/1439)) @@ -108,22 +1072,30 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - If an existing network configuration is updated, its information will be used to recreate the client for the corresponding network ## [10.2.0] + ### Added + - Expose `BlockTracker` type ([#1443](https://github.com/MetaMask/core/pull/1443)) ## [10.1.0] + ### Added + - Add `loadBackup` method to NetworkController ([#1421](https://github.com/MetaMask/core/pull/1421)) ## [10.0.0] + ### Changed + - **BREAKING:** Update `getEIP1559Compatibility` to return `false` instead of `true` if the provider has not been initialized yet ([#1404](https://github.com/MetaMask/core/pull/1404)) - Update `getEIP1559Compatibility` to not hit the current network if it is known that it does not support EIP-1559 ([#1404](https://github.com/MetaMask/core/pull/1404)) - Update `networkDetails` initial state from `{ EIPS: { 1559: false } }` to `{ EIPS: {} }` ([#1404](https://github.com/MetaMask/core/pull/1404)) - Update lookupNetwork to unset `networkDetails.EIPS[1559]` in state instead of setting it `false` if either of its requests for the network ID or network details fails ([#1403](https://github.com/MetaMask/core/pull/1403)) ## [9.0.0] + ### Added + - The events `networkWillChange` and `networkDidChange` are emitted during `setProviderType`, `setActiveNetwork`, `resetConnection`, and `rollbackToPreviousProvider` ([#1336](https://github.com/MetaMask/core/pull/1336)) - The `networkWillChange` event is emitted before the network is switched (before the network status is cleared), - The `networkDidChange` event is emitted after the new provider is setup (but before it has finished initializing). @@ -132,6 +1104,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Add `NetworkController:getState` action constant ([#1329](https://github.com/MetaMask/core/pull/1329)) ### Changed + - **BREAKING:** Bump to Node 16 ([#1262](https://github.com/MetaMask/core/pull/1262)) - **BREAKING:** The `providerConfig` type and state property have changed. The `chainId` property is now `Hex` rather than a decimal `string` ([#1367](https://github.com/MetaMask/core/pull/1367)) - This requires a state migration @@ -184,20 +1157,25 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Add dependency `json-rpc-engine` ^6.1.0 ([#1116](https://github.com/MetaMask/core/pull/1116)) ### Removed + - **BREAKING:** Remove `providerConfigChange` event ([#1329](https://github.com/MetaMask/core/pull/1329)) - Consumers are encouraged to subscribe to `NetworkController:stateChange` with a selector function that returns `providerConfig` if they want to perform an action when `providerConfig` changes. - **BREAKING:** The built-in "localhost" network has been removed ([#1313](https://github.com/MetaMask/core/pull/1313)) ### Fixed + - Update network details in `lookupNetwork` even when network ID is unchanged ([#1379](https://github.com/MetaMask/core/pull/1379)) - Fix error when `rollbackToPreviousProvider` is called when the previous network is a custom network with a missing or invalid `id` ([#1223](https://github.com/MetaMask/core/pull/1223)) - In that situation, `rollbackToPreviousProvider` used to throw an error. Now it correctly rolls back instead. ## [8.0.0] + ### Added + - Implement `resetConnection` method ([#1131](https://github.com/MetaMask/core/pull/1131), [#1235](https://github.com/MetaMask/core/pull/1235), [#1239](https://github.com/MetaMask/core/pull/1239)) ### Changed + - Update EIP-1559 compatibility during network lookup ([#1236](https://github.com/MetaMask/core/pull/1236)) - EIP-1559 compatibility check is still performed on initialization and after switching networks, like before. This change only impacts direct calls to `lookupNetwork`. - `lookupNetwork` is now making two network calls instead of one, ensuring that the `networkDetails` state is up-to-date. @@ -222,20 +1200,26 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - deps: bump @metamask/utils to 5.0.1 ([#1211](https://github.com/MetaMask/core/pull/1211)) ### Removed + - **BREAKING:** Remove `isCustomNetwork` state ([#1199](https://github.com/MetaMask/core/pull/1199)) - The `providerConfig.type` state will be set to `'rpc'` if the current network is a custom network. Replace all references to the `isCustomNetwork` state by checking the provider config state instead. ## [7.0.0] + ### Changed + - **BREAKING:** Replace `providerConfig` setter with a public `initializeProvider` method ([#1133](https://github.com/MetaMask/core/pull/1133)) - The property `providerConfig` should no longer be set to initialize the provider. That property no longer exists. - The method `initializeProvider` must be called instead to initialize the provider after constructing the network controller. ## [6.0.0] + ### Added + - Add rollbackToPreviousProvider method ([#1132](https://github.com/MetaMask/core/pull/1132)) ### Changed + - **BREAKING:** Migrate network configurations from `PreferencesController` to `NetworkController` ([#1064](https://github.com/MetaMask/core/pull/1064)) - Consumers will need to adapt to reading network data from `NetworkConfigurations` state on `NetworkController` rather than `frequentRpcList` on `PreferencesController`. - `setRpcTarget` becomes `setActiveNetwork` on `NetworkController` and accepts a `networkConfigurationId` argument rather than an `rpcUrl`. @@ -246,50 +1230,124 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - This change is breaking because it removes the provider property from `NetworkController`. Instead, a new method `getProviderAndBlockTracker` method is available for accessing the current provider object. ## [5.0.0] + ### Changed + - **BREAKING:** Rename `properties` property in state object to `networkDetails` ([#1074](https://github.com/MetaMask/controllers/pull/1074)) ### Removed + - **BREAKING:** Remove `isomorphic-fetch` ([#1106](https://github.com/MetaMask/controllers/pull/1106)) - Consumers must now import `isomorphic-fetch` or another polyfill themselves if they are running in an environment without `fetch` ## [4.0.0] + ### Changed + - **BREAKING:** Update type of state object by renaming `properties` property to `networkDetails` ([#1074](https://github.com/MetaMask/core/pull/1074)) - Consumers are recommended to add a state migration for this change. - **BREAKING:** Rename `NetworkProperties` type to `NetworkDetails` ([#1074](https://github.com/MetaMask/core/pull/1074)) - Change `getEIP1559Compatibility` to use async await syntax ([#1084](https://github.com/MetaMask/core/pull/1084)) ## [3.0.0] + ### Added + - Add support for Sepolia as a built-in Infura network ([#1041](https://github.com/MetaMask/controllers/pull/1041)) - Export types for network controller events and actions ([#1039](https://github.com/MetaMask/core/pull/1039)) ### Changed + - **BREAKING:** Make `lookupNetwork` block on completing the lookup ([#1063](https://github.com/MetaMask/controllers/pull/1063)) - This function was always `async`, but it would return before completing any async work. Now it will not return until after the network lookup has been completed. - Rename this repository to `core` ([#1031](https://github.com/MetaMask/controllers/pull/1031)) - Update `@metamask/controller-utils` package ([#1041](https://github.com/MetaMask/controllers/pull/1041)) ### Removed + - **BREAKING:**: Drop support for Ropsten, Rinkeby, and Kovan as built-in Infura networks ([#1041](https://github.com/MetaMask/controllers/pull/1041)) ## [2.0.0] + ### Changed + - **BREAKING:** Update type of state object by renaming `provider` property to `providerConfig` ([#995](https://github.com/MetaMask/core/pull/995)) - Consumers are recommended to add a state migration for this change. - **BREAKING:** Rename `NetworkController:providerChange` messenger event to `NetworkController:providerConfigChange` ([#995](https://github.com/MetaMask/core/pull/995)) - Relax dependencies on `@metamask/base-controller` and `@metamask/controller-utils` (use `^` instead of `~`) ([#998](https://github.com/MetaMask/core/pull/998)) ## [1.0.0] + ### Added + - Initial release - As a result of converting our shared controllers repo into a monorepo ([#831](https://github.com/MetaMask/core/pull/831)), we've created this package from select parts of [`@metamask/controllers` v33.0.0](https://github.com/MetaMask/core/tree/v33.0.0), namely: - Everything in `src/network` (minus `NetworkType` and `NetworksChainId`, which were placed in `@metamask/controller-utils`) All changes listed after this point were applied to this package following the monorepo conversion. -[Unreleased]: https://github.com/MetaMask/core/compare/@metamask/network-controller@15.0.0...HEAD +[Unreleased]: https://github.com/MetaMask/core/compare/@metamask/network-controller@36.0.0...HEAD +[36.0.0]: https://github.com/MetaMask/core/compare/@metamask/network-controller@35.0.1...@metamask/network-controller@36.0.0 +[35.0.1]: https://github.com/MetaMask/core/compare/@metamask/network-controller@35.0.0...@metamask/network-controller@35.0.1 +[35.0.0]: https://github.com/MetaMask/core/compare/@metamask/network-controller@34.0.0...@metamask/network-controller@35.0.0 +[34.0.0]: https://github.com/MetaMask/core/compare/@metamask/network-controller@33.0.0...@metamask/network-controller@34.0.0 +[33.0.0]: https://github.com/MetaMask/core/compare/@metamask/network-controller@32.0.0...@metamask/network-controller@33.0.0 +[32.0.0]: https://github.com/MetaMask/core/compare/@metamask/network-controller@31.1.0...@metamask/network-controller@32.0.0 +[31.1.0]: https://github.com/MetaMask/core/compare/@metamask/network-controller@31.0.0...@metamask/network-controller@31.1.0 +[31.0.0]: https://github.com/MetaMask/core/compare/@metamask/network-controller@30.1.0...@metamask/network-controller@31.0.0 +[30.1.0]: https://github.com/MetaMask/core/compare/@metamask/network-controller@30.0.1...@metamask/network-controller@30.1.0 +[30.0.1]: https://github.com/MetaMask/core/compare/@metamask/network-controller@30.0.0...@metamask/network-controller@30.0.1 +[30.0.0]: https://github.com/MetaMask/core/compare/@metamask/network-controller@29.0.0...@metamask/network-controller@30.0.0 +[29.0.0]: https://github.com/MetaMask/core/compare/@metamask/network-controller@28.0.0...@metamask/network-controller@29.0.0 +[28.0.0]: https://github.com/MetaMask/core/compare/@metamask/network-controller@27.2.0...@metamask/network-controller@28.0.0 +[27.2.0]: https://github.com/MetaMask/core/compare/@metamask/network-controller@27.1.0...@metamask/network-controller@27.2.0 +[27.1.0]: https://github.com/MetaMask/core/compare/@metamask/network-controller@27.0.0...@metamask/network-controller@27.1.0 +[27.0.0]: https://github.com/MetaMask/core/compare/@metamask/network-controller@26.0.0...@metamask/network-controller@27.0.0 +[26.0.0]: https://github.com/MetaMask/core/compare/@metamask/network-controller@25.0.0...@metamask/network-controller@26.0.0 +[25.0.0]: https://github.com/MetaMask/core/compare/@metamask/network-controller@24.3.1...@metamask/network-controller@25.0.0 +[24.3.1]: https://github.com/MetaMask/core/compare/@metamask/network-controller@24.3.0...@metamask/network-controller@24.3.1 +[24.3.0]: https://github.com/MetaMask/core/compare/@metamask/network-controller@24.2.2...@metamask/network-controller@24.3.0 +[24.2.2]: https://github.com/MetaMask/core/compare/@metamask/network-controller@24.2.1...@metamask/network-controller@24.2.2 +[24.2.1]: https://github.com/MetaMask/core/compare/@metamask/network-controller@24.2.0...@metamask/network-controller@24.2.1 +[24.2.0]: https://github.com/MetaMask/core/compare/@metamask/network-controller@24.1.0...@metamask/network-controller@24.2.0 +[24.1.0]: https://github.com/MetaMask/core/compare/@metamask/network-controller@24.0.1...@metamask/network-controller@24.1.0 +[24.0.1]: https://github.com/MetaMask/core/compare/@metamask/network-controller@24.0.0...@metamask/network-controller@24.0.1 +[24.0.0]: https://github.com/MetaMask/core/compare/@metamask/network-controller@23.6.0...@metamask/network-controller@24.0.0 +[23.6.0]: https://github.com/MetaMask/core/compare/@metamask/network-controller@23.5.1...@metamask/network-controller@23.6.0 +[23.5.1]: https://github.com/MetaMask/core/compare/@metamask/network-controller@23.5.0...@metamask/network-controller@23.5.1 +[23.5.0]: https://github.com/MetaMask/core/compare/@metamask/network-controller@23.4.0...@metamask/network-controller@23.5.0 +[23.4.0]: https://github.com/MetaMask/core/compare/@metamask/network-controller@23.3.0...@metamask/network-controller@23.4.0 +[23.3.0]: https://github.com/MetaMask/core/compare/@metamask/network-controller@23.2.0...@metamask/network-controller@23.3.0 +[23.2.0]: https://github.com/MetaMask/core/compare/@metamask/network-controller@23.1.0...@metamask/network-controller@23.2.0 +[23.1.0]: https://github.com/MetaMask/core/compare/@metamask/network-controller@23.0.0...@metamask/network-controller@23.1.0 +[23.0.0]: https://github.com/MetaMask/core/compare/@metamask/network-controller@22.2.1...@metamask/network-controller@23.0.0 +[22.2.1]: https://github.com/MetaMask/core/compare/@metamask/network-controller@22.2.0...@metamask/network-controller@22.2.1 +[22.2.0]: https://github.com/MetaMask/core/compare/@metamask/network-controller@22.1.1...@metamask/network-controller@22.2.0 +[22.1.1]: https://github.com/MetaMask/core/compare/@metamask/network-controller@22.1.0...@metamask/network-controller@22.1.1 +[22.1.0]: https://github.com/MetaMask/core/compare/@metamask/network-controller@22.0.2...@metamask/network-controller@22.1.0 +[22.0.2]: https://github.com/MetaMask/core/compare/@metamask/network-controller@22.0.1...@metamask/network-controller@22.0.2 +[22.0.1]: https://github.com/MetaMask/core/compare/@metamask/network-controller@22.0.0...@metamask/network-controller@22.0.1 +[22.0.0]: https://github.com/MetaMask/core/compare/@metamask/network-controller@21.1.0...@metamask/network-controller@22.0.0 +[21.1.0]: https://github.com/MetaMask/core/compare/@metamask/network-controller@21.0.1...@metamask/network-controller@21.1.0 +[21.0.1]: https://github.com/MetaMask/core/compare/@metamask/network-controller@21.0.0...@metamask/network-controller@21.0.1 +[21.0.0]: https://github.com/MetaMask/core/compare/@metamask/network-controller@20.2.0...@metamask/network-controller@21.0.0 +[20.2.0]: https://github.com/MetaMask/core/compare/@metamask/network-controller@20.1.0...@metamask/network-controller@20.2.0 +[20.1.0]: https://github.com/MetaMask/core/compare/@metamask/network-controller@20.0.0...@metamask/network-controller@20.1.0 +[20.0.0]: https://github.com/MetaMask/core/compare/@metamask/network-controller@19.0.0...@metamask/network-controller@20.0.0 +[19.0.0]: https://github.com/MetaMask/core/compare/@metamask/network-controller@18.1.3...@metamask/network-controller@19.0.0 +[18.1.3]: https://github.com/MetaMask/core/compare/@metamask/network-controller@18.1.2...@metamask/network-controller@18.1.3 +[18.1.2]: https://github.com/MetaMask/core/compare/@metamask/network-controller@18.1.1...@metamask/network-controller@18.1.2 +[18.1.1]: https://github.com/MetaMask/core/compare/@metamask/network-controller@18.1.0...@metamask/network-controller@18.1.1 +[18.1.0]: https://github.com/MetaMask/core/compare/@metamask/network-controller@18.0.1...@metamask/network-controller@18.1.0 +[18.0.1]: https://github.com/MetaMask/core/compare/@metamask/network-controller@18.0.0...@metamask/network-controller@18.0.1 +[18.0.0]: https://github.com/MetaMask/core/compare/@metamask/network-controller@17.2.1...@metamask/network-controller@18.0.0 +[17.2.1]: https://github.com/MetaMask/core/compare/@metamask/network-controller@17.2.0...@metamask/network-controller@17.2.1 +[17.2.0]: https://github.com/MetaMask/core/compare/@metamask/network-controller@17.1.0...@metamask/network-controller@17.2.0 +[17.1.0]: https://github.com/MetaMask/core/compare/@metamask/network-controller@17.0.0...@metamask/network-controller@17.1.0 +[17.0.0]: https://github.com/MetaMask/core/compare/@metamask/network-controller@16.0.0...@metamask/network-controller@17.0.0 +[16.0.0]: https://github.com/MetaMask/core/compare/@metamask/network-controller@15.2.0...@metamask/network-controller@16.0.0 +[15.2.0]: https://github.com/MetaMask/core/compare/@metamask/network-controller@15.1.0...@metamask/network-controller@15.2.0 +[15.1.0]: https://github.com/MetaMask/core/compare/@metamask/network-controller@15.0.0...@metamask/network-controller@15.1.0 [15.0.0]: https://github.com/MetaMask/core/compare/@metamask/network-controller@14.0.0...@metamask/network-controller@15.0.0 [14.0.0]: https://github.com/MetaMask/core/compare/@metamask/network-controller@13.0.1...@metamask/network-controller@14.0.0 [13.0.1]: https://github.com/MetaMask/core/compare/@metamask/network-controller@13.0.0...@metamask/network-controller@13.0.1 diff --git a/packages/network-controller/LICENSE b/packages/network-controller/LICENSE index ddfbecf9020..bbed2e24b91 100644 --- a/packages/network-controller/LICENSE +++ b/packages/network-controller/LICENSE @@ -18,3 +18,4 @@ 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/network-controller/jest.config.js b/packages/network-controller/jest.config.js index 985e7a447d9..e20eff6e4a0 100644 --- a/packages/network-controller/jest.config.js +++ b/packages/network-controller/jest.config.js @@ -17,10 +17,10 @@ module.exports = merge(baseConfig, { // An object that configures minimum threshold enforcement for coverage results coverageThreshold: { global: { - branches: 71.11, - functions: 78.57, - lines: 85.1, - statements: 85.1, + branches: 93.8, + functions: 98.14, + lines: 97.94, + statements: 97.83, }, }, diff --git a/packages/network-controller/package.json b/packages/network-controller/package.json index 86ea910210b..80dc5f4af16 100644 --- a/packages/network-controller/package.json +++ b/packages/network-controller/package.json @@ -1,71 +1,108 @@ { "name": "@metamask/network-controller", - "version": "15.0.0", + "version": "36.0.0", "description": "Provides an interface to the currently selected network via a MetaMask-compatible provider object", "keywords": [ - "MetaMask", - "Ethereum" + "Ethereum", + "MetaMask" ], "homepage": "https://github.com/MetaMask/core/tree/main/packages/network-controller#readme", "bugs": { "url": "https://github.com/MetaMask/core/issues" }, + "license": "MIT", "repository": { "type": "git", "url": "https://github.com/MetaMask/core.git" }, - "license": "MIT", - "main": "./dist/index.js", - "types": "./dist/index.d.ts", "files": [ "dist/" ], + "sideEffects": false, + "main": "./dist/index.cjs", + "types": "./dist/index.d.cts", + "exports": { + ".": { + "import": { + "types": "./dist/index.d.mts", + "default": "./dist/index.mjs" + }, + "require": { + "types": "./dist/index.d.cts", + "default": "./dist/index.cjs" + } + }, + "./package.json": "./package.json" + }, + "publishConfig": { + "access": "public", + "registry": "https://registry.npmjs.org/" + }, "scripts": { + "build": "ts-bridge --project tsconfig.build.json --verbose --clean --no-references", + "build:all": "ts-bridge --project tsconfig.build.json --verbose --clean", "build:docs": "typedoc", + "changelog:update": "../../scripts/update-changelog.sh @metamask/network-controller", "changelog:validate": "../../scripts/validate-changelog.sh @metamask/network-controller", - "publish:preview": "yarn npm publish --tag preview", - "test": "jest", - "test:clean": "jest --clearCache", - "test:watch": "jest --watch" + "lint:tsconfigs": "tsx ../../scripts/lint-tsconfigs/lint-tsconfigs.mts", + "lint:tsconfigs:fix": "tsx ../../scripts/lint-tsconfigs/lint-tsconfigs.mts --fix", + "messenger-action-types:check": "tsx ../../packages/messenger-cli/src/cli.ts --formatter oxfmt --esm --check", + "messenger-action-types:generate": "tsx ../../packages/messenger-cli/src/cli.ts --formatter oxfmt --esm --generate", + "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": "^3.2.3", - "@metamask/controller-utils": "^5.0.2", - "@metamask/eth-json-rpc-infura": "^9.0.0", - "@metamask/eth-json-rpc-middleware": "^12.0.0", - "@metamask/eth-json-rpc-provider": "^2.2.0", - "@metamask/eth-query": "^3.0.1", - "@metamask/json-rpc-engine": "^7.1.1", - "@metamask/rpc-errors": "^6.1.0", - "@metamask/swappable-obj-proxy": "^2.1.0", - "@metamask/utils": "^8.1.0", - "async-mutex": "^0.2.6", - "eth-block-tracker": "^8.0.0", + "@metamask/analytics-controller": "^2.0.0", + "@metamask/base-controller": "^9.1.0", + "@metamask/config-registry-controller": "^3.1.0", + "@metamask/connectivity-controller": "^0.3.0", + "@metamask/controller-utils": "^12.3.0", + "@metamask/eth-block-tracker": "^15.0.1", + "@metamask/eth-json-rpc-infura": "^10.3.0", + "@metamask/eth-json-rpc-middleware": "^24.0.1", + "@metamask/eth-json-rpc-provider": "^6.0.1", + "@metamask/eth-query": "^4.0.0", + "@metamask/json-rpc-engine": "^10.5.0", + "@metamask/messenger": "^2.0.0", + "@metamask/remote-feature-flag-controller": "^6.1.0", + "@metamask/rpc-errors": "^7.0.2", + "@metamask/swappable-obj-proxy": "^2.3.0", + "@metamask/utils": "^11.11.0", + "fast-deep-equal": "^3.1.3", "immer": "^9.0.6", + "loglevel": "^1.8.1", + "reselect": "^5.1.1", + "uri-js": "^4.4.1", "uuid": "^8.3.2" }, "devDependencies": { "@json-rpc-specification/meta-schema": "^1.0.6", - "@metamask/auto-changelog": "^3.1.0", - "@types/jest": "^27.4.1", + "@metamask/auto-changelog": "^6.1.0", + "@ts-bridge/cli": "^0.6.4", + "@types/deep-freeze-strict": "^1.1.0", + "@types/jest": "^30.0.0", "@types/jest-when": "^2.7.3", "@types/lodash": "^4.14.191", + "@types/node-fetch": "^2.6.12", + "cockatiel": "^3.1.2", + "deep-freeze-strict": "^1.1.1", "deepmerge": "^4.2.2", - "jest": "^27.5.1", - "jest-when": "^3.4.2", + "jest": "^30.4.2", + "jest-environment-jsdom": "^30.4.1", + "jest-when": "^3.7.0", "lodash": "^4.17.21", "nock": "^13.3.1", - "sinon": "^9.2.4", - "ts-jest": "^27.1.4", - "typedoc": "^0.24.8", + "node-fetch": "^2.7.0", + "ts-jest": "^29.4.11", + "tsx": "^4.20.5", + "typedoc": "^0.25.13", "typedoc-plugin-missing-exports": "^2.0.0", - "typescript": "~4.8.4" + "typescript": "~5.3.3" }, "engines": { - "node": ">=16.0.0" - }, - "publishConfig": { - "access": "public", - "registry": "https://registry.npmjs.org/" + "node": "^18.18 || >=20" } } diff --git a/packages/network-controller/src/NetworkController-method-action-types.ts b/packages/network-controller/src/NetworkController-method-action-types.ts new file mode 100644 index 00000000000..793f944e68f --- /dev/null +++ b/packages/network-controller/src/NetworkController-method-action-types.ts @@ -0,0 +1,311 @@ +/** + * This file is auto generated. + * Do not edit manually. + */ + +import type { NetworkController } from './NetworkController.js'; + +/** + * Returns the EthQuery instance for the currently selected network. + * + * @returns The EthQuery instance, or undefined if the provider has not been + * initialized. + */ +export type NetworkControllerGetEthQueryAction = { + type: `NetworkController:getEthQuery`; + handler: NetworkController['getEthQuery']; +}; + +/** + * Accesses the provider and block tracker for the currently selected network. + * + * @returns The proxy and block tracker proxies. + * @deprecated This method has been replaced by `getSelectedNetworkClient` (which has a more easily used return type) and will be removed in a future release. + */ +export type NetworkControllerGetProviderAndBlockTrackerAction = { + type: `NetworkController:getProviderAndBlockTracker`; + handler: NetworkController['getProviderAndBlockTracker']; +}; + +/** + * Accesses the provider and block tracker for the currently selected network. + * + * @returns an object with the provider and block tracker proxies for the currently selected network. + */ +export type NetworkControllerGetSelectedNetworkClientAction = { + type: `NetworkController:getSelectedNetworkClient`; + handler: NetworkController['getSelectedNetworkClient']; +}; + +/** + * Accesses the chain ID from the selected network client. + * + * @returns The chain ID of the selected network client in hex format or undefined if there is no network client. + */ +export type NetworkControllerGetSelectedChainIdAction = { + type: `NetworkController:getSelectedChainId`; + handler: NetworkController['getSelectedChainId']; +}; + +/** + * Internally, the Infura and custom network clients are categorized by type + * so that when accessing either kind of network client, TypeScript knows + * which type to assign to the network client. For some cases it's more useful + * to be able to access network clients by ID instead of by type and then ID, + * so this function makes that possible. + * + * @returns The network clients registered so far, keyed by ID. + */ +export type NetworkControllerGetNetworkClientRegistryAction = { + type: `NetworkController:getNetworkClientRegistry`; + handler: NetworkController['getNetworkClientRegistry']; +}; + +/** + * Returns the Infura network client with the given ID. + * + * @param infuraNetworkClientId - An Infura network client ID. + * @returns The Infura network client. + * @throws If an Infura network client does not exist with the given ID. + */ +export type NetworkControllerGetNetworkClientByIdAction = { + type: `NetworkController:getNetworkClientById`; + handler: NetworkController['getNetworkClientById']; +}; + +/** + * Uses a request for the latest block to gather the following information on + * the given or selected network, persisting it to state: + * + * - The connectivity status: whether it is available, geo-blocked (Infura + * only), unavailable, or unknown + * - The capabilities status: whether it supports EIP-1559, whether it does + * not, or whether it is unknown + * + * @param networkClientId - The ID of the network client to inspect. + * If no ID is provided, uses the currently selected network. + */ +export type NetworkControllerLookupNetworkAction = { + type: `NetworkController:lookupNetwork`; + handler: NetworkController['lookupNetwork']; +}; + +/** + * Uses a request for the latest block to gather the following information on + * the given network, persisting it to state: + * + * - The connectivity status: whether the network is available, geo-blocked + * (Infura only), unavailable, or unknown + * - The feature compatibility status: whether the network supports EIP-1559, + * whether it does not, or whether it is unknown + * + * @param networkClientId - The ID of the network client to inspect. + * @deprecated Please use `lookupNetwork` and pass a network client ID + * instead. This method will be removed in a future major version. + */ +export type NetworkControllerLookupNetworkByClientIdAction = { + type: `NetworkController:lookupNetworkByClientId`; + handler: NetworkController['lookupNetworkByClientId']; +}; + +/** + * Convenience method to update provider network type settings. + * + * @param type - Human readable network name. + * @deprecated This has been replaced by `setActiveNetwork`, and will be + * removed in a future release + */ +export type NetworkControllerSetProviderTypeAction = { + type: `NetworkController:setProviderType`; + handler: NetworkController['setProviderType']; +}; + +/** + * Changes the selected network. + * + * @param networkClientId - The ID of a network client that will be used to + * make requests. + * @param options - Options for this method. + * @param options.updateState - Allows for updating state. + * @throws if no network client is associated with the given + * network client ID. + */ +export type NetworkControllerSetActiveNetworkAction = { + type: `NetworkController:setActiveNetwork`; + handler: NetworkController['setActiveNetwork']; +}; + +/** + * Determines whether the network supports EIP-1559 by checking whether the + * latest block has a `baseFeePerGas` property, then updates state + * appropriately. + * + * @param networkClientId - The networkClientId to fetch the correct provider against which to check 1559 compatibility. + * @returns A promise that resolves to true if the network supports EIP-1559 + * , false otherwise, or `undefined` if unable to determine the compatibility. + */ +export type NetworkControllerGetEIP1559CompatibilityAction = { + type: `NetworkController:getEIP1559Compatibility`; + handler: NetworkController['getEIP1559Compatibility']; +}; + +export type NetworkControllerGet1559CompatibilityWithNetworkClientIdAction = { + type: `NetworkController:get1559CompatibilityWithNetworkClientId`; + handler: NetworkController['get1559CompatibilityWithNetworkClientId']; +}; + +/** + * Ensures that the provider and block tracker proxies are pointed to the + * currently selected network and refreshes the metadata for the + */ +export type NetworkControllerResetConnectionAction = { + type: `NetworkController:resetConnection`; + handler: NetworkController['resetConnection']; +}; + +/** + * Returns the network configuration that has been filed under the given chain + * ID. + * + * @param chainId - The chain ID to use as a key. + * @returns The network configuration if one exists, or undefined. + */ +export type NetworkControllerGetNetworkConfigurationByChainIdAction = { + type: `NetworkController:getNetworkConfigurationByChainId`; + handler: NetworkController['getNetworkConfigurationByChainId']; +}; + +/** + * Returns the network configuration that contains an RPC endpoint with the + * given network client ID. + * + * @param networkClientId - The network client ID to use as a key. + * @returns The network configuration if one exists, or undefined. + */ +export type NetworkControllerGetNetworkConfigurationByNetworkClientIdAction = { + type: `NetworkController:getNetworkConfigurationByNetworkClientId`; + handler: NetworkController['getNetworkConfigurationByNetworkClientId']; +}; + +/** + * Creates and registers network clients for the collection of Infura and + * custom RPC endpoints that can be used to make requests for a particular + * chain, storing the given configuration object in state for later reference. + * + * @param fields - The object that describes the new network/chain and lists + * the RPC endpoints which front that chain. + * @returns The newly added network configuration. + * @throws if any part of `fields` would produce invalid state. + * @see {@link NetworkConfiguration} + */ +export type NetworkControllerAddNetworkAction = { + type: `NetworkController:addNetwork`; + handler: NetworkController['addNetwork']; +}; + +/** + * Updates the configuration for a previously stored network filed under the + * given chain ID, creating + registering new network clients to represent RPC + * endpoints that have been added and destroying + unregistering existing + * network clients for RPC endpoints that have been removed. + * + * Note that if `chainId` is changed, then all network clients associated with + * that chain will be removed and re-added, even if none of the RPC endpoints + * have changed. + * + * @param chainId - The chain ID associated with an existing network. + * @param fields - The object that describes the updates to the network/chain, + * including the new set of RPC endpoints which should front that chain. + * @param options - Options to provide. + * @param options.replacementSelectedRpcEndpointIndex - Usually you cannot + * remove an RPC endpoint that is being represented by the currently selected + * network client. This option allows you to specify another RPC endpoint + * (either an existing one or a new one) that should be used to select a new + * network instead. + * @returns The updated network configuration. + * @throws if `chainId` does not refer to an existing network configuration, + * if any part of `fields` would produce invalid state, etc. + * @see {@link NetworkConfiguration} + */ +export type NetworkControllerUpdateNetworkAction = { + type: `NetworkController:updateNetwork`; + handler: NetworkController['updateNetwork']; +}; + +/** + * Destroys and unregisters the network identified by the given chain ID, also + * removing the associated network configuration from state. + * + * @param chainId - The chain ID associated with an existing network. + * @throws if `chainId` does not refer to an existing network configuration, + * or if the currently selected network is being removed. + * @see {@link NetworkConfiguration} + */ +export type NetworkControllerRemoveNetworkAction = { + type: `NetworkController:removeNetwork`; + handler: NetworkController['removeNetwork']; +}; + +/** + * Assuming that the network has been previously switched, switches to this + * new network. + * + * If the network has not been previously switched, this method is equivalent + * to {@link resetConnection}. + */ +export type NetworkControllerRollbackToPreviousProviderAction = { + type: `NetworkController:rollbackToPreviousProvider`; + handler: NetworkController['rollbackToPreviousProvider']; +}; + +/** + * Merges the given backup data into controller state. + * + * @param backup - The data that has been backed up. + * @param backup.networkConfigurationsByChainId - Network configurations, + * keyed by chain ID. + */ +export type NetworkControllerLoadBackupAction = { + type: `NetworkController:loadBackup`; + handler: NetworkController['loadBackup']; +}; + +/** + * Searches for the default RPC endpoint configured for the given chain and + * returns its network client ID. This can then be passed to + * {@link getNetworkClientById} to retrieve the network client. + * + * @param chainId - Chain ID to search for. + * @returns The ID of the network client created for the chain's default RPC + * endpoint. + */ +export type NetworkControllerFindNetworkClientIdByChainIdAction = { + type: `NetworkController:findNetworkClientIdByChainId`; + handler: NetworkController['findNetworkClientIdByChainId']; +}; + +/** + * Union of all NetworkController action types. + */ +export type NetworkControllerMethodActions = + | NetworkControllerGetEthQueryAction + | NetworkControllerGetProviderAndBlockTrackerAction + | NetworkControllerGetSelectedNetworkClientAction + | NetworkControllerGetSelectedChainIdAction + | NetworkControllerGetNetworkClientRegistryAction + | NetworkControllerGetNetworkClientByIdAction + | NetworkControllerLookupNetworkAction + | NetworkControllerLookupNetworkByClientIdAction + | NetworkControllerSetProviderTypeAction + | NetworkControllerSetActiveNetworkAction + | NetworkControllerGetEIP1559CompatibilityAction + | NetworkControllerGet1559CompatibilityWithNetworkClientIdAction + | NetworkControllerResetConnectionAction + | NetworkControllerGetNetworkConfigurationByChainIdAction + | NetworkControllerGetNetworkConfigurationByNetworkClientIdAction + | NetworkControllerAddNetworkAction + | NetworkControllerUpdateNetworkAction + | NetworkControllerRemoveNetworkAction + | NetworkControllerRollbackToPreviousProviderAction + | NetworkControllerLoadBackupAction + | NetworkControllerFindNetworkClientIdByChainIdAction; diff --git a/packages/network-controller/src/NetworkController.ts b/packages/network-controller/src/NetworkController.ts index 5a80db3f1d4..5f0cbc17be1 100644 --- a/packages/network-controller/src/NetworkController.ts +++ b/packages/network-controller/src/NetworkController.ts @@ -1,65 +1,103 @@ -import type { RestrictedControllerMessenger } from '@metamask/base-controller'; -import { BaseControllerV2 } from '@metamask/base-controller'; +import type { + AnalyticsControllerGetStateAction, + AnalyticsControllerTrackEventAction, +} from '@metamask/analytics-controller'; +import type { + ControllerGetStateAction, + ControllerStateChangeEvent, +} from '@metamask/base-controller'; +import { BaseController } from '@metamask/base-controller'; +import type { + ConfigRegistryControllerStateChangedEvent, + ConfigRegistryControllerGetNetworkConfigByCaip2ChainIdAction, + ConfigRegistryControllerGetStateAction, +} from '@metamask/config-registry-controller'; +import { selectEvmAutoEnabledNetworksChainIds } from '@metamask/config-registry-controller'; +import type { ConnectivityControllerGetStateAction } from '@metamask/connectivity-controller'; +import type { Partialize } from '@metamask/controller-utils'; import { - BUILT_IN_NETWORKS, - NetworksTicker, - ChainId, InfuraNetworkType, + CustomNetworkType, NetworkType, isSafeChainId, + isInfuraNetworkType, + ChainId, + NetworksTicker, + NetworkNickname, + BUILT_IN_CUSTOM_NETWORKS_RPC, + BUILT_IN_NETWORKS, + DEFAULT_INFURA_NETWORKS, } from '@metamask/controller-utils'; +import type { PollingBlockTrackerOptions } from '@metamask/eth-block-tracker'; import EthQuery from '@metamask/eth-query'; +import type { Messenger } from '@metamask/messenger'; +import { + RemoteFeatureFlagControllerGetStateAction, + RemoteFeatureFlagControllerStateChangeEvent, +} from '@metamask/remote-feature-flag-controller'; import { errorCodes } from '@metamask/rpc-errors'; -import { createEventEmitterProxy } from '@metamask/swappable-obj-proxy'; +import { + createEventEmitterProxy, + createSwappableProxy, +} from '@metamask/swappable-obj-proxy'; import type { SwappableProxy } from '@metamask/swappable-obj-proxy'; -import type { Hex } from '@metamask/utils'; +import type { CaipChainId, Hex } from '@metamask/utils'; import { - assertIsStrictHexString, hasProperty, isPlainObject, + isStrictHexString, + numberToHex, + parseCaipChainId, } from '@metamask/utils'; -import { strict as assert } from 'assert'; -import type { Patch } from 'immer'; -import { v4 as random } from 'uuid'; +import deepEqual from 'fast-deep-equal'; +import type { Draft } from 'immer'; +import { produce } from 'immer'; +import { cloneDeep } from 'lodash'; +import type { Logger } from 'loglevel'; +import { createSelector } from 'reselect'; +import * as URI from 'uri-js'; +import { v4 as uuidV4 } from 'uuid'; -import { INFURA_BLOCKED_KEY, NetworkStatus } from './constants'; +import { + DEPRECATED_NETWORKS, + INFURA_BLOCKED_KEY, + NetworkStatus, +} from './constants.js'; import type { AutoManagedNetworkClient, ProxyWithAccessibleTarget, -} from './create-auto-managed-network-client'; -import { createAutoManagedNetworkClient } from './create-auto-managed-network-client'; -import { projectLogger, createModuleLogger } from './logger'; -import { NetworkClientType } from './types'; +} from './create-auto-managed-network-client.js'; +import { createAutoManagedNetworkClient } from './create-auto-managed-network-client.js'; +import type { + DegradedEventType, + RetryReason, +} from './create-network-client.js'; +import { projectLogger, createModuleLogger } from './logger.js'; +import type { NetworkControllerMethodActions } from './NetworkController-method-action-types.js'; +import { + trackRpcServiceDegraded, + trackRpcServiceUnavailable, +} from './rpc-service-analytics.js'; +import type { + NetworkControllerAnalyticsOptions, + ResolvedNetworkControllerAnalyticsOptions, +} from './rpc-service-analytics.js'; +import type { RpcServiceOptionsWithDefaults } from './rpc-service/rpc-service.js'; +import { getRpcFailoverMode } from './selectors.js'; +import type { RpcFailoverMode } from './selectors.js'; +import { NetworkClientType } from './types.js'; import type { BlockTracker, Provider, CustomNetworkClientConfiguration, InfuraNetworkClientConfiguration, NetworkClientConfiguration, -} from './types'; +} from './types.js'; -const log = createModuleLogger(projectLogger, 'NetworkController'); +const debugLog = createModuleLogger(projectLogger, 'NetworkController'); -/** - * @type ProviderConfig - * - * Configuration passed to web3-provider-engine - * @property rpcUrl - RPC target URL. - * @property type - Human-readable network name. - * @property chainId - Network ID as per EIP-155. - * @property ticker - Currency ticker. - * @property nickname - Personalized network name. - * @property id - Network Configuration Id. - */ -export type ProviderConfig = { - rpcUrl?: string; - type: NetworkType; - chainId: Hex; - ticker: string; - nickname?: string; - rpcPrefs?: { blockExplorerUrl?: string }; - id?: NetworkConfigurationId; -}; +const INFURA_URL_REGEX = + /^https:\/\/(?[^.]+)\.infura\.io\/v\d+\/(?.+)$/u; export type Block = { baseFeePerGas?: string; @@ -72,6 +110,7 @@ export type NetworkMetadata = { /** * EIPs supported by the network. */ + // eslint-disable-next-line @typescript-eslint/naming-convention EIPS: { [eipNumber: number]: boolean; }; @@ -82,233 +121,244 @@ export type NetworkMetadata = { }; /** - * Custom RPC network information + * The type of an RPC endpoint. * - * @property rpcUrl - RPC target URL. - * @property chainId - Network ID as per EIP-155 - * @property nickname - Personalized network name. - * @property ticker - Currency ticker. - * @property rpcPrefs - Personalized preferences. + * @see {@link CustomRpcEndpoint} + * @see {@link InfuraRpcEndpoint} */ -export type NetworkConfiguration = { - rpcUrl: string; - chainId: Hex; - ticker: string; - nickname?: string; - rpcPrefs?: { - blockExplorerUrl: string; - }; -}; +export enum RpcEndpointType { + Custom = 'custom', + Infura = 'infura', +} /** - * The collection of network configurations in state. + * An Infura RPC endpoint is a reference to a specific network that Infura + * supports as well as an Infura account we own that we allow users to make use + * of for free. We need to disambiguate these endpoints from custom RPC + * endpoints, because while the types for these kinds of object both have the + * same interface, the URL for an Infura endpoint contains the Infura project + * ID, and we don't want this to be present in state. We therefore hide it by + * representing it in the URL as `{infuraProjectId}`, which we replace this when + * create network clients. But we need to know somehow that we only need to do + * this replacement for Infura endpoints and not custom endpoints — hence the + * separate type. */ -type NetworkConfigurations = Record< - NetworkConfigurationId, - NetworkConfiguration & { id: NetworkConfigurationId } ->; +export type InfuraRpcEndpoint = { + /** + * Alternate RPC endpoints to use when this endpoint is down. + */ + failoverUrls?: string[]; + /** + * The optional user-facing nickname of the endpoint. + */ + name?: string; + /** + * The identifier for the network client that has been created for this RPC + * endpoint. This is also used to uniquely identify the RPC endpoint in a + * set of RPC endpoints as well: once assigned, it is used to determine + * whether the `name`, `type`, or `url` of the RPC endpoint has changed. + */ + networkClientId: BuiltInNetworkClientId; + /** + * The type of this endpoint, always "default". + */ + type: RpcEndpointType.Infura; + /** + * The URL of the endpoint. Expected to be a template with the string + * `{infuraProjectId}`, which will get replaced with the Infura project ID + * when the network client is created. + */ + url: `https://${InfuraNetworkType}.infura.io/v3/{infuraProjectId}`; +}; /** - * `Object.keys()` is intentionally generic: it returns the keys of an object, - * but it cannot make guarantees about the contents of that object, so the type - * of the keys is merely `string[]`. While this is technically accurate, it is - * also unnecessary if we have an object that we own and whose contents are - * known exactly. - * - * TODO: Move to @metamask/utils. - * - * @param object - The object. - * @returns The keys of an object, typed according to the type of the object - * itself. + * A custom RPC endpoint is a reference to a user-defined server which fronts an + * EVM chain. It may refer to an Infura network, but only by coincidence. */ -export function knownKeysOf( - object: Partial>, -) { - return Object.keys(object) as K[]; -} +export type CustomRpcEndpoint = { + /** + * Alternate RPC endpoints to use when this endpoint is down. + */ + failoverUrls?: string[]; + /** + * The optional user-facing nickname of the endpoint. + */ + name?: string; + /** + * The identifier for the network client that has been created for this RPC + * endpoint. This is also used to uniquely identify the RPC endpoint in a + * set of RPC endpoints as well: once assigned, it is used to determine + * whether the `name`, `type`, or `url` of the RPC endpoint has changed. + */ + networkClientId: CustomNetworkClientId; + /** + * The type of this endpoint, always "custom". + */ + type: RpcEndpointType.Custom; + /** + * The URL of the endpoint. + */ + url: string; +}; /** - * Asserts that the given value is of the given type if the given validation - * function returns a truthy result. + * An RPC endpoint is a reference to a server which fronts an EVM chain. There + * are two varieties of RPC endpoints: Infura and custom. * - * @param value - The value to validate. - * @param validate - A function used to validate that the value is of the given - * type. Takes the `value` as an argument and is expected to return true or - * false. - * @param message - The message to throw if the function does not return a - * truthy result. - * @throws if the function does not return a truthy result. + * @see {@link CustomRpcEndpoint} + * @see {@link InfuraRpcEndpoint} */ -function assertOfType( - value: unknown, - validate: (value: unknown) => boolean, - message: string, -): asserts value is Type { - assert.ok(validate(value), message); -} +export type RpcEndpoint = InfuraRpcEndpoint | CustomRpcEndpoint; /** - * Returns a portion of the given object with only the given keys. + * From a user perspective, a network configuration holds information about a + * network that a user can select through the client. A "network" in this sense + * can explicitly refer to an EVM chain that the user explicitly adds or doesn't + * need to add (because it comes shipped with the client). The properties here + * therefore directly map to fields that a user sees and can edit for a network + * within the client. * - * @param object - An object. - * @param keys - The keys to pick from the object. - * @returns the portion of the object. + * Internally, a network configuration represents a single conceptual EVM chain, + * which is represented tangibly via multiple RPC endpoints. A "network" is then + * something for which a network client object is created automatically or + * created on demand when it is added to the client. */ -function pick, Keys extends keyof Obj>( - object: Obj, - keys: Keys[], -): Pick { - const pickedObject = keys.reduce>>( - (finalObject, key) => { - return { ...finalObject, [key]: object[key] }; - }, - {}, - ); - assertOfType>( - pickedObject, - () => keys.every((key) => key in pickedObject), - 'The reduce did not produce an object with all of the desired keys.', - ); - return pickedObject; -} +export type NetworkConfiguration = { + /** + * A set of URLs that allows the user to view activity that has occurred on + * the chain. + */ + blockExplorerUrls: string[]; + /** + * The ID of the chain. Represented in hexadecimal format with a leading "0x" + * instead of decimal format so that when viewed out of context it can be + * unambiguously interpreted. + */ + chainId: Hex; + /** + * A reference to a URL that the client will use by default to allow the user + * to view activity that has occurred on the chain. This index must refer to + * an item in `blockExplorerUrls`. + */ + defaultBlockExplorerUrlIndex?: number; + /** + * A reference to an RPC endpoint that all requests will use by default in order to + * interact with the chain. This index must refer to an item in + * `rpcEndpoints`. + */ + defaultRpcEndpointIndex: number; + /** + * The user-facing nickname assigned to the chain. + */ + name: string; + /** + * The name of the currency to use for the chain. + */ + nativeCurrency: string; + /** + * The collection of possible RPC endpoints that the client can use to + * interact with the chain. + */ + rpcEndpoints: RpcEndpoint[]; + /** + * Profile Sync - Network Sync field. + * Allows comparison of local network state with state to sync. + */ + lastUpdatedAt?: number; +}; /** - * Type guard for determining whether the given value is an error object with a - * `code` property, such as an instance of Error. - * - * TODO: Move this to @metamask/utils. + * A custom RPC endpoint in a new network configuration, meant to be used in + * conjunction with `AddNetworkFields`. * - * @param error - The object to check. - * @returns True if `error` has a `code`, false otherwise. + * Custom RPC endpoints do not need a `networkClientId` property because it is + * assumed that they have not already been added and therefore network clients + * do not exist for them yet (and hence IDs need to be generated). */ -function isErrorWithCode(error: unknown): error is { code: string | number } { - return typeof error === 'object' && error !== null && 'code' in error; -} +export type AddNetworkCustomRpcEndpointFields = Omit< + CustomRpcEndpoint, + 'networkClientId' +>; /** - * Returns whether the given argument is a type that our Infura middleware - * recognizes. + * A new network configuration that `addNetwork` takes. * - * @param type - A type to compare. - * @returns True or false, depending on whether the given type is one that our - * Infura middleware recognizes. + * Custom RPC endpoints do not need a `networkClientId` property because it is + * assumed that they have not already been added and are not represented by + * network clients yet. */ -function isInfuraProviderType(type: string): type is InfuraNetworkType { - return Object.keys(InfuraNetworkType).includes(type); -} +export type AddNetworkFields = Omit & { + rpcEndpoints: (InfuraRpcEndpoint | AddNetworkCustomRpcEndpointFields)[]; +}; /** - * Builds an identifier for an Infura network client for lookup purposes. + * A custom RPC endpoint in an updated representation of a network + * configuration, meant to be used in conjunction with `UpdateNetworkFields`. * - * @param infuraNetworkOrProviderConfig - The name of an Infura network or a - * provider config. - * @returns The built identifier. + * Custom RPC endpoints do not need a `networkClientId` property because it is + * assumed that they have not already been added and therefore network clients + * do not exist for them yet (and hence IDs need to be generated). */ -function buildInfuraNetworkClientId( - infuraNetworkOrProviderConfig: - | InfuraNetworkType - | (ProviderConfig & { type: InfuraNetworkType }), -): BuiltInNetworkClientId { - if (typeof infuraNetworkOrProviderConfig === 'string') { - return infuraNetworkOrProviderConfig; - } - return infuraNetworkOrProviderConfig.type; -} +export type UpdateNetworkCustomRpcEndpointFields = Partialize< + CustomRpcEndpoint, + 'networkClientId' +>; /** - * Builds an identifier for a custom network client for lookup purposes. + * An updated representation of an existing network configuration that + * `updateNetwork` takes. * - * @param args - This function can be called two ways: - * 1. The ID of a network configuration. - * 2. A provider config and a set of network configurations. - * @returns The built identifier. + * Custom RPC endpoints may or may not have a `networkClientId` property; if + * they do, then it is assumed that they already exist, and if not, then it is + * assumed that they are new and are not represented by network clients yet. */ -function buildCustomNetworkClientId( - ...args: - | [NetworkConfigurationId] - | [ - ProviderConfig & { type: typeof NetworkType.rpc; rpcUrl: string }, - NetworkConfigurations, - ] -): CustomNetworkClientId { - if (args.length === 1) { - return args[0]; - } - const [{ id, rpcUrl }, networkConfigurations] = args; - if (id === undefined) { - const matchingNetworkConfiguration = Object.values( - networkConfigurations, - ).find((networkConfiguration) => { - return networkConfiguration.rpcUrl === rpcUrl.toLowerCase(); - }); - if (matchingNetworkConfiguration) { - return matchingNetworkConfiguration.id; - } - return rpcUrl.toLowerCase(); - } - return id; -} +export type UpdateNetworkFields = Omit & { + rpcEndpoints: (InfuraRpcEndpoint | UpdateNetworkCustomRpcEndpointFields)[]; +}; /** - * Returns whether the given provider config refers to an Infura network. + * `Object.keys()` is intentionally generic: it returns the keys of an object, + * but it cannot make guarantees about the contents of that object, so the type + * of the keys is merely `string[]`. While this is technically accurate, it is + * also unnecessary if we have an object that we own and whose contents are + * known exactly. * - * @param providerConfig - The provider config. - * @returns True if the provider config refers to an Infura network, false - * otherwise. - */ -function isInfuraProviderConfig( - providerConfig: ProviderConfig, -): providerConfig is ProviderConfig & { type: InfuraNetworkType } { - return isInfuraProviderType(providerConfig.type); -} - -/** - * Returns whether the given provider config refers to an Infura network. + * TODO: Move to @metamask/utils. * - * @param providerConfig - The provider config. - * @returns True if the provider config refers to an Infura network, false - * otherwise. + * @param object - The object. + * @returns The keys of an object, typed according to the type of the object + * itself. */ -function isCustomProviderConfig( - providerConfig: ProviderConfig, -): providerConfig is ProviderConfig & { type: typeof NetworkType.rpc } { - return providerConfig.type === NetworkType.rpc; +export function knownKeysOf( + // TODO: Replace `any` with type + // eslint-disable-next-line @typescript-eslint/no-explicit-any + object: Partial>, +): Key[] { + return Object.keys(object) as Key[]; } /** - * As a provider config represents the settings that are used to interface with - * an RPC endpoint, it must have both a chain ID and an RPC URL if it represents - * a custom network. These properties _should_ be set as they are validated in - * the UI when a user adds a custom network, but just to be safe we validate - * them here. + * Type guard for determining whether the given value is an error object with a + * `code` property, such as an instance of Error. * - * In addition, historically the `rpcUrl` property on the ProviderConfig type - * has been optional, even though it should not be. Making this non-optional - * would be a breaking change, so this function types the provider config - * correctly so that we don't have to check `rpcUrl` in other places. + * TODO: Move this to @metamask/utils. * - * @param providerConfig - A provider config. - * @throws if the provider config does not have a chain ID or an RPC URL. + * @param error - The object to check. + * @returns True if `error` has a `code`, false otherwise. */ -function validateCustomProviderConfig( - providerConfig: ProviderConfig & { type: typeof NetworkType.rpc }, -): asserts providerConfig is typeof providerConfig & { rpcUrl: string } { - if (providerConfig.chainId === undefined) { - throw new Error('chainId must be provided for custom RPC endpoints'); - } - if (providerConfig.rpcUrl === undefined) { - throw new Error('rpcUrl must be provided for custom RPC endpoints'); - } +function isErrorWithCode(error: unknown): error is { code: string | number } { + return typeof error === 'object' && error !== null && 'code' in error; } + /** * The string that uniquely identifies an Infura network client. */ -type BuiltInNetworkClientId = InfuraNetworkType; +export type BuiltInNetworkClientId = string; /** * The string that uniquely identifies a custom network client. */ -type CustomNetworkClientId = string; +export type CustomNetworkClientId = string; /** * The string that uniquely identifies a network client. @@ -316,28 +366,35 @@ type CustomNetworkClientId = string; export type NetworkClientId = BuiltInNetworkClientId | CustomNetworkClientId; /** - * Information about networks not held by any other part of state. + * Extra information about each network, such as whether it is accessible or + * blocked and whether it supports EIP-1559, keyed by network client ID. */ -export type NetworksMetadata = { - [networkClientId: NetworkClientId]: NetworkMetadata; -}; +export type NetworksMetadata = Record; /** - * @type NetworkState - * - * Network controller state - * @property providerConfig - RPC URL and network name provider settings of the currently connected network - * @property properties - an additional set of network properties for the currently connected network - * @property networkConfigurations - the full list of configured networks either preloaded or added by the user. + * The state that NetworkController stores. */ export type NetworkState = { + /** + * The ID of the network client that the proxies returned by + * `getSelectedNetworkClient` currently point to. + */ selectedNetworkClientId: NetworkClientId; - providerConfig: ProviderConfig; - networkConfigurations: NetworkConfigurations; + /** + * The registry of networks and corresponding RPC endpoints that the + * controller can use to make requests for various chains. + * + * @see {@link NetworkConfiguration} + */ + networkConfigurationsByChainId: Record; + /** + * Extra information about each network, such as whether it is accessible or + * blocked and whether it supports EIP-1559, keyed by network client ID. + */ networksMetadata: NetworksMetadata; }; -const name = 'NetworkController'; +const controllerName = 'NetworkController'; /** * Represents the block tracker for the currently selected network. (Note that @@ -359,10 +416,10 @@ export type BlockTrackerProxy = SwappableProxy< */ export type ProviderProxy = SwappableProxy>; -export type NetworkControllerStateChangeEvent = { - type: `NetworkController:stateChange`; - payload: [NetworkState, Patch[]]; -}; +export type NetworkControllerStateChangeEvent = ControllerStateChangeEvent< + typeof controllerName, + NetworkState +>; /** * `networkWillChange` is published when the current network is about to be @@ -371,7 +428,7 @@ export type NetworkControllerStateChangeEvent = { */ export type NetworkControllerNetworkWillChangeEvent = { type: 'NetworkController:networkWillChange'; - payload: []; + payload: [NetworkState]; }; /** @@ -380,7 +437,7 @@ export type NetworkControllerNetworkWillChangeEvent = { */ export type NetworkControllerNetworkDidChangeEvent = { type: 'NetworkController:networkDidChange'; - payload: []; + payload: [NetworkState]; }; /** @@ -403,200 +460,1036 @@ export type NetworkControllerInfuraIsUnblockedEvent = { payload: []; }; -export type NetworkControllerEvents = - | NetworkControllerStateChangeEvent - | NetworkControllerNetworkWillChangeEvent - | NetworkControllerNetworkDidChangeEvent - | NetworkControllerInfuraIsBlockedEvent - | NetworkControllerInfuraIsUnblockedEvent; - -export type NetworkControllerGetStateAction = { - type: `NetworkController:getState`; - handler: () => NetworkState; +/** + * `networkAdded` is published after a network configuration is added to the + * network configuration registry and network clients are created for it. + */ +export type NetworkControllerNetworkAddedEvent = { + type: 'NetworkController:networkAdded'; + payload: [networkConfiguration: NetworkConfiguration]; }; -export type NetworkControllerGetProviderConfigAction = { - type: `NetworkController:getProviderConfig`; - handler: () => ProviderConfig; +/** + * `networkRemoved` is published after a network configuration is removed from the + * network configuration registry and once the network clients have been removed. + */ +export type NetworkControllerNetworkRemovedEvent = { + type: 'NetworkController:networkRemoved'; + payload: [networkConfiguration: NetworkConfiguration]; }; -export type NetworkControllerGetEthQueryAction = { - type: `NetworkController:getEthQuery`; - handler: () => EthQuery | undefined; +/** + * `NetworkController:rpcEndpointChainUnavailable` is published when, after + * trying all endpoints in an endpoint chain, the last failover reaches a + * maximum number of consecutive 5xx responses, breaking the underlying circuit. + * + * In other words, this event will not be published if a failover is available, + * even if the primary is not. + * + * @param payload - The event payload. + * @param payload.chainId - The target network's chain ID. + * @param payload.error - The last error produced by the last failover in the + * endpoint chain. + * @param payload.networkClientId - The target network's client ID. + */ +export type NetworkControllerRpcEndpointChainUnavailableEvent = { + type: 'NetworkController:rpcEndpointChainUnavailable'; + payload: [ + { + chainId: Hex; + error: unknown; + networkClientId: NetworkClientId; + }, + ]; }; -export type NetworkControllerGetNetworkClientByIdAction = { - type: `NetworkController:getNetworkClientById`; - handler: NetworkController['getNetworkClientById']; +/** + * `NetworkController:rpcEndpointUnavailable` is published when any + * endpoint in an endpoint chain reaches a maximum number of consecutive 5xx + * responses, breaking the underlying circuit. + * + * In other words, this event will be published if a primary is not available, + * even if a failover is. + * + * @param payload - The event payload. + * @param payload.chainId - The target network's chain ID. + * @param payload.endpointUrl - The URL of the endpoint which reached the + * maximum number of consecutive 5xx responses. You can compare this to + * `primaryEndpointUrl` to know whether it was a failover or a primary. + * @param payload.error - The last error produced by the endpoint. + * @param payload.networkClientId - The target network's client ID. + * @param payload.primaryEndpointUrl - The endpoint chain's primary URL. + */ +export type NetworkControllerRpcEndpointUnavailableEvent = { + type: 'NetworkController:rpcEndpointUnavailable'; + payload: [ + { + chainId: Hex; + endpointUrl: string; + error: unknown; + networkClientId: NetworkClientId; + primaryEndpointUrl: string; + }, + ]; }; -export type NetworkControllerGetEIP1559CompatibilityAction = { - type: `NetworkController:getEIP1559Compatibility`; - handler: NetworkController['getEIP1559Compatibility']; +/** + * `NetworkController:rpcEndpointChainDegraded` is published for any of the + * endpoints in an endpoint chain when one of the following two conditions hold + * (and the chain is not already in a degraded state): + * + * 1. A successful (2xx) request, even after being retried, cannot be made to + * the endpoint. + * 2. A successful (2xx) request can be made to the endpoint, but it takes + * longer than expected to complete. + * + * Note that this event will be published even if there are local connectivity + * issues which prevent requests from being initiated. This is intentional. + * + * @param payload - The event payload. + * @param payload.chainId - The target network's chain ID. + * @param payload.duration - The duration in milliseconds of the policy + * execution when the request succeeded but was slow. `undefined` when retries + * were exhausted. + * @param payload.error - The last error produced by the endpoint (or + * `undefined` if the request was slow). + * @param payload.networkClientId - The target network's client ID. + * @param payload.rpcMethodName - The JSON-RPC method that was being executed + * when the chain became degraded. + * @param payload.traceId - The value of the `X-Trace-Id` response header from + * the last request attempt, or `undefined` if the header was not present. + */ +export type NetworkControllerRpcEndpointChainDegradedEvent = { + type: 'NetworkController:rpcEndpointChainDegraded'; + payload: [ + { + chainId: Hex; + duration?: number; + error: unknown; + networkClientId: NetworkClientId; + retryReason?: RetryReason; + rpcMethodName: string; + traceId?: string; + type: DegradedEventType; + }, + ]; }; -export type NetworkControllerActions = - | NetworkControllerGetStateAction - | NetworkControllerGetProviderConfigAction - | NetworkControllerGetEthQueryAction - | NetworkControllerGetNetworkClientByIdAction - | NetworkControllerGetEIP1559CompatibilityAction; - -export type NetworkControllerMessenger = RestrictedControllerMessenger< - typeof name, - NetworkControllerActions, - NetworkControllerEvents, - string, - string ->; - -export type NetworkControllerOptions = { - messenger: NetworkControllerMessenger; - trackMetaMetricsEvent: () => void; - infuraProjectId: string; - state?: Partial; +/** + * + * `NetworkController:rpcEndpointDegraded` is published for any of the endpoints + * in an endpoint chain when: + * + * 1. A successful (2xx) request, even after being retried, cannot be made to + * the endpoint. + * 2. A successful (2xx) request can be made to the endpoint, but it takes + * longer than expected to complete. + * + * Note that this event will be published even if there are local connectivity + * issues which prevent requests from being initiated. This is intentional. + * + * @param payload - The event payload. + * @param payload.chainId - The target network's chain ID. + * @param payload.duration - The duration in milliseconds of the policy + * execution when the request succeeded but was slow. `undefined` when retries + * were exhausted. + * @param payload.endpointUrl - The URL of the endpoint for which requests + * failed or were slow to complete. You can compare this to `primaryEndpointUrl` + * to know whether it was a failover or a primary. + * @param payload.error - The last error produced by the endpoint (or + * `undefined` if the request was slow). + * @param payload.networkClientId - The target network's client ID. + * @param payload.primaryEndpointUrl - The endpoint chain's primary URL. + * @param payload.rpcMethodName - The JSON-RPC method that was being executed + * when the endpoint became degraded. + * @param payload.traceId - The value of the `X-Trace-Id` response header from + * the last request attempt, or `undefined` if the header was not present. + */ +export type NetworkControllerRpcEndpointDegradedEvent = { + type: 'NetworkController:rpcEndpointDegraded'; + payload: [ + { + chainId: Hex; + duration?: number; + endpointUrl: string; + error: unknown; + networkClientId: NetworkClientId; + primaryEndpointUrl: string; + retryReason?: RetryReason; + rpcMethodName: string; + traceId?: string; + type: DegradedEventType; + }, + ]; }; -export const defaultState: NetworkState = { - selectedNetworkClientId: NetworkType.mainnet, - providerConfig: { - type: NetworkType.mainnet, - chainId: ChainId.mainnet, - ticker: NetworksTicker.mainnet, - }, - networksMetadata: {}, - networkConfigurations: {}, +/** + * `NetworkController:rpcEndpointChainAvailable` is published in one of two + * cases: + * + * 1. The first time that a 2xx request is made to any of the endpoints in an + * endpoint chain. + * 2. When requests to any of the endpoints previously failed (placing the + * endpoint in a degraded or unavailable status), but are now succeeding again. + * + * @param payload - The event payload. + * @param payload.chainId - The target network's chain ID. + * @param payload.networkClientId - The target network's client ID. + */ +export type NetworkControllerRpcEndpointChainAvailableEvent = { + type: 'NetworkController:rpcEndpointChainAvailable'; + payload: [ + { + chainId: Hex; + networkClientId: NetworkClientId; + }, + ]; }; -type MetaMetricsEventPayload = { - event: string; - category: string; - referrer?: { url: string }; - actionId?: number; - environmentType?: string; - properties?: unknown; - sensitiveProperties?: unknown; - revenue?: number; - currency?: string; - value?: number; +/** + * `NetworkController:rpcEndpointRetried` is published before a request to any + * endpoint in an endpoint chain is retried. + * + * This is mainly useful for tests. + * + * @param payload - The event payload. + * @param payload.attempt - The current attempt counter for the endpoint + * (starting from 0). + * @param payload.chainId - The target network's chain ID. + * @param payload.endpointUrl - The URL of the endpoint being retried. + * @param payload.networkClientId - The target network's client ID. + * @param payload.primaryEndpointUrl - The endpoint chain's primary URL. + * @see {@link RpcService} for the list of retriable errors. + */ +export type NetworkControllerRpcEndpointRetriedEvent = { + type: 'NetworkController:rpcEndpointRetried'; + payload: [ + { + attempt: number; + chainId: Hex; + endpointUrl: string; + networkClientId: NetworkClientId; + primaryEndpointUrl: string; + }, + ]; }; -type NetworkConfigurationId = string; +export type NetworkControllerEvents = + | NetworkControllerStateChangeEvent + | NetworkControllerNetworkWillChangeEvent + | NetworkControllerNetworkDidChangeEvent + | NetworkControllerInfuraIsBlockedEvent + | NetworkControllerInfuraIsUnblockedEvent + | NetworkControllerNetworkAddedEvent + | NetworkControllerNetworkRemovedEvent + | NetworkControllerRpcEndpointChainUnavailableEvent + | NetworkControllerRpcEndpointUnavailableEvent + | NetworkControllerRpcEndpointChainDegradedEvent + | NetworkControllerRpcEndpointDegradedEvent + | NetworkControllerRpcEndpointChainAvailableEvent + | NetworkControllerRpcEndpointRetriedEvent; /** - * The collection of auto-managed network clients that map to Infura networks. + * All events that {@link NetworkController} calls internally. */ -type AutoManagedBuiltInNetworkClientRegistry = Record< - BuiltInNetworkClientId, - AutoManagedNetworkClient +type AllowedEvents = + | RemoteFeatureFlagControllerStateChangeEvent + | ConfigRegistryControllerStateChangedEvent; + +const MESSENGER_EXPOSED_METHODS = [ + 'addNetwork', + 'findNetworkClientIdByChainId', + 'get1559CompatibilityWithNetworkClientId', + 'getEIP1559Compatibility', + 'getEthQuery', + 'getNetworkClientById', + 'getNetworkClientRegistry', + 'getNetworkConfigurationByChainId', + 'getNetworkConfigurationByNetworkClientId', + 'getProviderAndBlockTracker', + 'getSelectedChainId', + 'getSelectedNetworkClient', + 'loadBackup', + 'lookupNetwork', + 'lookupNetworkByClientId', + 'removeNetwork', + 'resetConnection', + 'rollbackToPreviousProvider', + 'setActiveNetwork', + 'setProviderType', + 'updateNetwork', +] as const; + +export type NetworkControllerGetStateAction = ControllerGetStateAction< + typeof controllerName, + NetworkState >; /** - * The collection of auto-managed network clients that map to Infura networks. + * All actions that {@link NetworkController} registers, to be called + * externally. */ -type AutoManagedCustomNetworkClientRegistry = Record< - CustomNetworkClientId, - AutoManagedNetworkClient +export type NetworkControllerActions = + | NetworkControllerGetStateAction + | NetworkControllerMethodActions; + +/** + * All actions that {@link NetworkController} calls internally. + */ +type AllowedActions = + | ConfigRegistryControllerGetNetworkConfigByCaip2ChainIdAction + | ConfigRegistryControllerGetStateAction + | ConnectivityControllerGetStateAction + | RemoteFeatureFlagControllerGetStateAction + | AnalyticsControllerGetStateAction + | AnalyticsControllerTrackEventAction; + +export type NetworkControllerMessenger = Messenger< + typeof controllerName, + NetworkControllerActions | AllowedActions, + NetworkControllerEvents | AllowedEvents >; /** - * The collection of auto-managed network clients that map to Infura networks - * as well as custom networks that users have added. + * Options for the NetworkController constructor. */ -type AutoManagedNetworkClientRegistry = { - [NetworkClientType.Infura]: AutoManagedBuiltInNetworkClientRegistry; - [NetworkClientType.Custom]: AutoManagedCustomNetworkClientRegistry; +export type NetworkControllerOptions = { + /** + * The messenger suited for this controller. + */ + messenger: NetworkControllerMessenger; + /** + * The API key for Infura, used to make requests to Infura. + */ + infuraProjectId: string; + /** + * An optional map of available failover URLs for each chain ID. + */ + failoverUrls?: Record; + /** + * The desired state with which to initialize this controller. + * Missing properties will be filled in with defaults. For instance, if not + * specified, `networkConfigurationsByChainId` will default to a basic set of + * network configurations (see {@link InfuraNetworkType} for the list). + */ + state?: Partial; + /** + * A `loglevel` logger object. + */ + log?: Logger; + /** + * A function that can be used to customize a RPC service constructed for an + * RPC endpoint. The function takes the URL of the endpoint and should return + * an object with type {@link RpcServiceOptionsWithDefaults}, minus `failoverService` + * and `endpointUrl` (as they are filled in automatically). + */ + getRpcServiceOptions?: ( + rpcEndpointUrl: string, + ) => RpcServiceOptionsWithDefaults; + /** + * A function that can be used to customize a block tracker constructed for an + * RPC endpoint. The function takes the URL of the endpoint and should return + * an object of type {@link PollingBlockTrackerOptions}, minus `provider` (as + * it is filled in automatically). + */ + getBlockTrackerOptions?: ( + rpcEndpointUrl: string, + ) => Omit; + /** + * Configuration for the "RPC Service Unavailable" and "RPC Service Degraded" + * analytics events the controller emits via the `AnalyticsController:trackEvent` + * action when an RPC endpoint becomes unavailable or degraded. Both the option + * and its properties are optional; omitted properties default to + * `isRpcEndpointUrlPublic: () => false` and `rpcServiceEventsSampleRate: 0` + * (which emits nothing). The messenger must allow `AnalyticsController:getState` + * and `AnalyticsController:trackEvent` regardless. + */ + analyticsOptions?: NetworkControllerAnalyticsOptions; }; /** - * Controller that creates and manages an Ethereum network provider. + * Constructs a value for the state property `networkConfigurationsByChainId` + * which will be used if it has not been provided to the constructor. + * + * @returns The default value for `networkConfigurationsByChainId`. */ -export class NetworkController extends BaseControllerV2< - typeof name, - NetworkState, - NetworkControllerMessenger +function getDefaultNetworkConfigurationsByChainId(): Record< + Hex, + NetworkConfiguration > { - #ethQuery?: EthQuery; + const infuraNetworks = getDefaultInfuraNetworkConfigurationsByChainId(); + const customNetworks = getDefaultCustomNetworkConfigurationsByChainId(); - #infuraProjectId: string; - - #trackMetaMetricsEvent: (event: MetaMetricsEventPayload) => void; + return { ...customNetworks, ...infuraNetworks }; +} - #previousProviderConfig: ProviderConfig; +/** + * Constructs a `networkConfigurationsByChainId` object for all default Infura networks. + * + * @returns The `networkConfigurationsByChainId` object of all Infura networks. + */ +function getDefaultInfuraNetworkConfigurationsByChainId(): Record< + Hex, + NetworkConfiguration +> { + return DEFAULT_INFURA_NETWORKS.reduce>( + (obj, infuraNetworkType) => { + const chainId = ChainId[infuraNetworkType]; - #providerProxy: ProviderProxy | undefined; + // Skip deprecated network as default network. + if (DEPRECATED_NETWORKS.has(chainId)) { + return obj; + } - #provider: ProxyWithAccessibleTarget | undefined; + const rpcEndpointUrl = + `https://${infuraNetworkType}.infura.io/v3/{infuraProjectId}` as const; + + const { rpcPrefs } = BUILT_IN_NETWORKS[infuraNetworkType]; + + const networkConfiguration: NetworkConfiguration = { + blockExplorerUrls: [rpcPrefs.blockExplorerUrl], + defaultBlockExplorerUrlIndex: 0, + chainId, + defaultRpcEndpointIndex: 0, + name: NetworkNickname[infuraNetworkType], + nativeCurrency: NetworksTicker[infuraNetworkType], + rpcEndpoints: [ + { + failoverUrls: [], + networkClientId: infuraNetworkType, + type: RpcEndpointType.Infura, + url: rpcEndpointUrl, + }, + ], + }; - #blockTrackerProxy: BlockTrackerProxy | undefined; + return { ...obj, [chainId]: networkConfiguration }; + }, + {}, + ); +} - #autoManagedNetworkClientRegistry?: AutoManagedNetworkClientRegistry; +/** + * Constructs a `networkConfigurationsByChainId` object for all default custom networks. + * + * @returns The `networkConfigurationsByChainId` object of all custom networks. + */ +function getDefaultCustomNetworkConfigurationsByChainId(): Record< + Hex, + NetworkConfiguration +> { + // Create the `networkConfigurationsByChainId` objects explicitly, + // Because it is not always guaranteed that the custom networks are included in the + // default networks. + return { + [ChainId['megaeth-testnet-v2']]: getCustomNetworkConfiguration( + CustomNetworkType['megaeth-testnet-v2'], + ), + [ChainId['monad-testnet']]: getCustomNetworkConfiguration( + CustomNetworkType['monad-testnet'], + ), + }; +} + +/** + * Constructs a `NetworkConfiguration` object by `CustomNetworkType`. + * + * @param customNetworkType - The type of the custom network. + * @returns The `NetworkConfiguration` object. + */ +function getCustomNetworkConfiguration( + customNetworkType: CustomNetworkType, +): NetworkConfiguration { + const { ticker, rpcPrefs } = BUILT_IN_NETWORKS[customNetworkType]; + const rpcEndpointUrl = BUILT_IN_CUSTOM_NETWORKS_RPC[customNetworkType]; + + return { + blockExplorerUrls: [rpcPrefs.blockExplorerUrl], + chainId: ChainId[customNetworkType], + defaultRpcEndpointIndex: 0, + defaultBlockExplorerUrlIndex: 0, + name: NetworkNickname[customNetworkType], + nativeCurrency: ticker, + rpcEndpoints: [ + { + failoverUrls: [], + networkClientId: customNetworkType, + type: RpcEndpointType.Custom, + url: rpcEndpointUrl, + }, + ], + }; +} + +/** + * Constructs properties for the NetworkController state whose values will be + * used if not provided to the constructor. + * + * @returns The default NetworkController state. + */ +export function getDefaultNetworkControllerState(): NetworkState { + const networksMetadata = {}; + const networkConfigurationsByChainId = + getDefaultNetworkConfigurationsByChainId(); + + return { + selectedNetworkClientId: InfuraNetworkType.mainnet, + networksMetadata, + networkConfigurationsByChainId, + }; +} + +/** + * Redux selector for getting all network configurations from NetworkController + * state, keyed by chain ID. + * + * @param state - NetworkController state + * @returns All registered network configurations, keyed by chain ID. + */ +const selectNetworkConfigurationsByChainId = ( + state: NetworkState, +): Record<`0x${string}`, NetworkConfiguration> => + state.networkConfigurationsByChainId; + +/** + * Get a list of all network configurations. + * + * @param state - NetworkController state + * @returns A list of all available network configurations + */ +export function getNetworkConfigurations( + state: NetworkState, +): NetworkConfiguration[] { + return Object.values(state.networkConfigurationsByChainId); +} + +/** + * Redux selector for getting a list of all network configurations from + * NetworkController state. + * + * @param state - NetworkController state + * @returns A list of all available network configurations + */ +export const selectNetworkConfigurations = createSelector( + selectNetworkConfigurationsByChainId, + (networkConfigurationsByChainId) => + Object.values(networkConfigurationsByChainId), +); + +/** + * Get a list of all available network client IDs from a list of network + * configurations. + * + * @param networkConfigurations - The array of network configurations + * @returns A list of all available client IDs + */ +export function getAvailableNetworkClientIds( + networkConfigurations: NetworkConfiguration[], +): string[] { + return networkConfigurations.flatMap((networkConfiguration) => + networkConfiguration.rpcEndpoints.map( + (rpcEndpoint) => rpcEndpoint.networkClientId, + ), + ); +} + +/** + * Redux selector for getting a list of all available network client IDs + * from NetworkController state. + * + * @param state - NetworkController state + * @returns A list of all available network client IDs. + */ +export const selectAvailableNetworkClientIds = createSelector( + selectNetworkConfigurations, + getAvailableNetworkClientIds, +); + +/** + * The collection of auto-managed network clients that map to Infura networks. + */ +export type AutoManagedBuiltInNetworkClientRegistry = Record< + BuiltInNetworkClientId, + AutoManagedNetworkClient +>; + +/** + * The collection of auto-managed network clients that map to Infura networks. + */ +export type AutoManagedCustomNetworkClientRegistry = Record< + CustomNetworkClientId, + AutoManagedNetworkClient +>; - constructor({ - messenger, +/** + * The collection of auto-managed network clients that map to Infura networks + * as well as custom networks that users have added. + */ +export type AutoManagedNetworkClientRegistry = { + [NetworkClientType.Infura]: AutoManagedBuiltInNetworkClientRegistry; + [NetworkClientType.Custom]: AutoManagedCustomNetworkClientRegistry; +}; + +/** + * Instructs `addNetwork` and `updateNetwork` to create a network client for an + * RPC endpoint. + * + * @see {@link NetworkClientOperation} + */ +type AddNetworkClientOperation = { + type: 'add'; + rpcEndpoint: RpcEndpoint; +}; + +/** + * Instructs `updateNetwork` and `removeNetwork` to remove a network client for + * an RPC endpoint. + * + * @see {@link NetworkClientOperation} + */ +type RemoveNetworkClientOperation = { + type: 'remove'; + rpcEndpoint: RpcEndpoint; +}; + +/** + * Instructs `addNetwork` and `updateNetwork` to replace the network client for + * an RPC endpoint. + * + * @see {@link NetworkClientOperation} + */ +type ReplaceNetworkClientOperation = { + type: 'replace'; + oldRpcEndpoint: RpcEndpoint; + newRpcEndpoint: RpcEndpoint; +}; + +/** + * Instructs `addNetwork` and `updateNetwork` not to do anything with an RPC + * endpoint, as far as the network client registry is concerned. + * + * @see {@link NetworkClientOperation} + */ +type NoopNetworkClientOperation = { + type: 'noop'; + rpcEndpoint: RpcEndpoint; +}; + +/** + * Instructs `addNetwork`, `updateNetwork`, and `removeNetwork` how to + * update the network client registry. + * + * - When `addNetwork` is called, represents a network client that should be + * created for a new RPC endpoint. + * - When `removeNetwork` is called, represents a network client that should be + * destroyed for a previously existing RPC endpoint. + * - When `updateNetwork` is called, represents either: + * - a network client that should be added for a new RPC endpoint + * - a network client that should be removed for a previously existing RPC + * endpoint + * - a network client that should be replaced for an RPC endpoint that was + * changed in a non-major way, or + * - a network client that should be unchanged for an RPC endpoint that was + * also unchanged. + */ +type NetworkClientOperation = + | AddNetworkClientOperation + | RemoveNetworkClientOperation + | ReplaceNetworkClientOperation + | NoopNetworkClientOperation; + +/** + * Determines whether the given URL is valid by attempting to parse it. + * + * @param url - The URL to test. + * @returns True if the URL is valid, false otherwise. + */ +function isValidUrl(url: string): boolean { + const uri = URI.parse(url); + return ( + uri.error === undefined && (uri.scheme === 'http' || uri.scheme === 'https') + ); +} + +/** + * Given an Infura API URL, extracts the subdomain that identifies the Infura + * network. + * + * @param rpcEndpointUrl - The URL to operate on. + * @returns The Infura network name that the URL references. + * @throws if no Infura network is present in the URL. + */ +function deriveInfuraNetworkNameFromRpcEndpointUrl( + rpcEndpointUrl: string, +): string { + const match = INFURA_URL_REGEX.exec(rpcEndpointUrl); + + if (match?.groups) { + return match.groups.networkName; + } + + throw new Error('Could not derive Infura network from RPC endpoint URL'); +} + +/** + * Performs a series of checks that the given NetworkController state is + * internally consistent — that all parts of state that are supposed to match in + * fact do — so that working with the state later on doesn't cause unexpected + * errors. + * + * In the case of NetworkController, there are several parts of state that need + * to match. For instance, `defaultRpcEndpointIndex` needs to match an entry + * within `rpcEndpoints`, and `selectedNetworkClientId` needs to point to an RPC + * endpoint within a network configuration. + * + * @param state - The NetworkController state to verify. + * @throws if the state is invalid in some way. + */ +function validateInitialState(state: NetworkState): void { + const networkConfigurationEntries = Object.entries( + state.networkConfigurationsByChainId, + ); + const networkClientIds = getAvailableNetworkClientIds( + getNetworkConfigurations(state), + ); + + if (networkConfigurationEntries.length === 0) { + throw new Error( + 'NetworkController state is invalid: `networkConfigurationsByChainId` cannot be empty', + ); + } + + for (const [chainId, networkConfiguration] of networkConfigurationEntries) { + if (chainId !== networkConfiguration.chainId) { + throw new Error( + `NetworkController state has invalid \`networkConfigurationsByChainId\`: Network configuration '${networkConfiguration.name}' is filed under '${chainId}' which does not match its \`chainId\` of '${networkConfiguration.chainId}'`, + ); + } + + const isInvalidDefaultBlockExplorerUrlIndex = + networkConfiguration.blockExplorerUrls.length > 0 + ? networkConfiguration.defaultBlockExplorerUrlIndex === undefined || + networkConfiguration.blockExplorerUrls[ + networkConfiguration.defaultBlockExplorerUrlIndex + ] === undefined + : networkConfiguration.defaultBlockExplorerUrlIndex !== undefined; + + if (isInvalidDefaultBlockExplorerUrlIndex) { + throw new Error( + `NetworkController state has invalid \`networkConfigurationsByChainId\`: Network configuration '${networkConfiguration.name}' has a \`defaultBlockExplorerUrlIndex\` that does not refer to an entry in \`blockExplorerUrls\``, + ); + } + + if ( + networkConfiguration.rpcEndpoints[ + networkConfiguration.defaultRpcEndpointIndex + ] === undefined + ) { + throw new Error( + `NetworkController state has invalid \`networkConfigurationsByChainId\`: Network configuration '${networkConfiguration.name}' has a \`defaultRpcEndpointIndex\` that does not refer to an entry in \`rpcEndpoints\``, + ); + } + } + + if ([...new Set(networkClientIds)].length < networkClientIds.length) { + throw new Error( + 'NetworkController state has invalid `networkConfigurationsByChainId`: Every RPC endpoint across all network configurations must have a unique `networkClientId`', + ); + } +} + +/** + * Checks that the given initial NetworkController state is internally + * consistent similar to `validateInitialState`, but if an anomaly is detected, + * it does its best to correct the state and logs an error to Sentry. + * + * @param state - The NetworkController state to verify. + * @param messenger - The NetworkController messenger. + * @returns The corrected state. + */ +function correctInitialState( + state: NetworkState, + messenger: NetworkControllerMessenger, +): NetworkState { + const networkConfigurationsSortedByChainId = getNetworkConfigurations( state, - infuraProjectId, - trackMetaMetricsEvent, - }: NetworkControllerOptions) { + ).sort((a, b) => a.chainId.localeCompare(b.chainId)); + const availableNetworkClientIds = getAvailableNetworkClientIds( + networkConfigurationsSortedByChainId, + ); + const invalidNetworkClientIdsWithMetadata = Object.keys( + state.networksMetadata, + ).filter( + (networkClientId) => !availableNetworkClientIds.includes(networkClientId), + ); + + return produce(state, (newState) => { + if (!availableNetworkClientIds.includes(state.selectedNetworkClientId)) { + const firstNetworkConfiguration = networkConfigurationsSortedByChainId[0]; + const newSelectedNetworkClientId = + firstNetworkConfiguration.rpcEndpoints[ + firstNetworkConfiguration.defaultRpcEndpointIndex + ].networkClientId; + messenger.captureException?.( + new Error( + `\`selectedNetworkClientId\` '${state.selectedNetworkClientId}' does not refer to an RPC endpoint within a network configuration; correcting to '${newSelectedNetworkClientId}'`, + ), + ); + newState.selectedNetworkClientId = newSelectedNetworkClientId; + } + + if (invalidNetworkClientIdsWithMetadata.length > 0) { + for (const invalidNetworkClientId of invalidNetworkClientIdsWithMetadata) { + delete newState.networksMetadata[invalidNetworkClientId]; + } + messenger.captureException?.( + new Error( + '`networksMetadata` had invalid network client IDs, which have been removed', + ), + ); + } + }); +} + +/** + * Transforms a map of chain ID to network configuration to a map of network + * client ID to network configuration. + * + * @param networkConfigurationsByChainId - The network configurations, keyed by + * chain ID. + * @returns The network configurations, keyed by network client ID. + */ +function buildNetworkConfigurationsByNetworkClientId( + networkConfigurationsByChainId: Record, +): Map { + return new Map( + Object.values(networkConfigurationsByChainId).flatMap( + (networkConfiguration) => { + return networkConfiguration.rpcEndpoints.map((rpcEndpoint) => { + return [rpcEndpoint.networkClientId, networkConfiguration]; + }); + }, + ), + ); +} + +/** + * Controller that creates and manages an Ethereum network provider. + */ +export class NetworkController extends BaseController< + typeof controllerName, + NetworkState, + NetworkControllerMessenger +> { + #ethQuery?: EthQuery; + + readonly #infuraProjectId: string; + + readonly #failoverUrls?: Record; + + #previouslySelectedNetworkClientId: string; + + #providerProxy: ProviderProxy | undefined; + + #blockTrackerProxy: BlockTrackerProxy | undefined; + + #autoManagedNetworkClientRegistry?: AutoManagedNetworkClientRegistry; + + #autoManagedNetworkClient?: + | AutoManagedNetworkClient + | AutoManagedNetworkClient; + + readonly #log: Logger | undefined; + + readonly #getRpcServiceOptions: NetworkControllerOptions['getRpcServiceOptions']; + + readonly #getBlockTrackerOptions: NetworkControllerOptions['getBlockTrackerOptions']; + + readonly #analyticsOptions: ResolvedNetworkControllerAnalyticsOptions; + + #networkConfigurationsByNetworkClientId: Map< + NetworkClientId, + NetworkConfiguration + >; + + #rpcFailoverMode: RpcFailoverMode = 'disabled'; + + /** + * Constructs a NetworkController. + * + * @param options - The options; see {@link NetworkControllerOptions}. + */ + constructor(options: NetworkControllerOptions) { + const { + messenger, + state, + infuraProjectId, + failoverUrls, + log, + getRpcServiceOptions, + getBlockTrackerOptions, + analyticsOptions, + } = options; + const initialState = { + ...getDefaultNetworkControllerState(), + ...state, + }; + validateInitialState(initialState); + const correctedInitialState = correctInitialState(initialState, messenger); + + if (!infuraProjectId || typeof infuraProjectId !== 'string') { + throw new Error('Invalid Infura project ID'); + } + super({ - name, + name: controllerName, metadata: { selectedNetworkClientId: { + includeInStateLogs: true, persist: true, - anonymous: false, + includeInDebugSnapshot: false, + usedInUi: true, }, networksMetadata: { + includeInStateLogs: true, persist: true, - anonymous: false, + includeInDebugSnapshot: false, + usedInUi: true, }, - providerConfig: { + networkConfigurationsByChainId: { + includeInStateLogs: true, persist: true, - anonymous: false, - }, - networkConfigurations: { - persist: true, - anonymous: false, + includeInDebugSnapshot: false, + usedInUi: true, }, }, messenger, - state: { ...defaultState, ...state }, + state: correctedInitialState, }); - if (!infuraProjectId || typeof infuraProjectId !== 'string') { - throw new Error('Invalid Infura project ID'); - } + this.#infuraProjectId = infuraProjectId; - this.#trackMetaMetricsEvent = trackMetaMetricsEvent; - this.messagingSystem.registerActionHandler( - `${this.name}:getProviderConfig`, - () => { - return this.state.providerConfig; - }, + this.#failoverUrls = failoverUrls; + this.#log = log; + this.#getRpcServiceOptions = getRpcServiceOptions; + this.#getBlockTrackerOptions = getBlockTrackerOptions; + this.#analyticsOptions = { + isRpcEndpointUrlPublic: (): boolean => false, + rpcServiceEventsSampleRate: 0, + ...analyticsOptions, + }; + + this.#previouslySelectedNetworkClientId = + this.state.selectedNetworkClientId; + this.#networkConfigurationsByNetworkClientId = + buildNetworkConfigurationsByNetworkClientId( + this.state.networkConfigurationsByChainId, + ); + + this.messenger.registerMethodActionHandlers( + this, + MESSENGER_EXPOSED_METHODS, ); - this.messagingSystem.registerActionHandler( - `${this.name}:getEthQuery`, - () => { - return this.#ethQuery; + this.messenger.subscribe( + `${this.name}:rpcEndpointChainUnavailable`, + ({ networkClientId }) => { + this.#updateMetadataForNetwork(networkClientId, { + networkStatus: NetworkStatus.Unavailable, + }); + }, + ); + this.messenger.subscribe( + `${this.name}:rpcEndpointChainDegraded`, + ({ networkClientId }) => { + this.#updateMetadataForNetwork(networkClientId, { + networkStatus: NetworkStatus.Degraded, + }); }, ); + this.messenger.subscribe( + `${this.name}:rpcEndpointChainAvailable`, + ({ networkClientId }) => { + this.#updateMetadataForNetwork(networkClientId, { + networkStatus: NetworkStatus.Available, + }); + }, + ); + + this.messenger.subscribe(`${this.name}:rpcEndpointUnavailable`, (payload) => + trackRpcServiceUnavailable( + this.messenger, + this.#analyticsOptions, + payload, + ), + ); + this.messenger.subscribe(`${this.name}:rpcEndpointDegraded`, (payload) => + trackRpcServiceDegraded(this.messenger, this.#analyticsOptions, payload), + ); - this.messagingSystem.registerActionHandler( - `${this.name}:getNetworkClientById`, - this.getNetworkClientById.bind(this), + this.messenger.subscribe( + // eslint-disable-next-line no-restricted-syntax + 'RemoteFeatureFlagController:stateChange', + (rpcFailoverMode) => { + this.#updateRpcFailover(rpcFailoverMode); + }, + getRpcFailoverMode, ); - this.messagingSystem.registerActionHandler( - `${this.name}:getEIP1559Compatibility`, - this.getEIP1559Compatibility.bind(this), + this.messenger.subscribe( + 'ConfigRegistryController:stateChanged', + (caipChainIds) => this.#autoAddNetworksFromConfigRegistry(caipChainIds), + selectEvmAutoEnabledNetworksChainIds, ); + } + + /** + * Returns the EthQuery instance for the currently selected network. + * + * @returns The EthQuery instance, or undefined if the provider has not been + * initialized. + */ + getEthQuery(): EthQuery | undefined { + return this.#ethQuery; + } + + /** + * Applies the given RPC failover mode by reconstructing all network clients + * that were configured with failover URLs so that the new mode takes effect. + * Network client IDs are preserved so as not to invalidate state in other + * controllers. + * + * @param newMode - The RPC failover mode to apply. + */ + #updateRpcFailover(newMode: RpcFailoverMode): void { + if (this.#rpcFailoverMode === newMode) { + return; + } + + const autoManagedNetworkClientRegistry = + this.#ensureAutoManagedNetworkClientRegistryPopulated(); + + for (const networkClientsById of Object.values( + autoManagedNetworkClientRegistry, + )) { + for (const networkClientId of Object.keys(networkClientsById)) { + const networkClient = networkClientsById[networkClientId]; + if ( + networkClient.configuration.failoverRpcUrls && + networkClient.configuration.failoverRpcUrls.length > 0 + ) { + networkClient.setRpcFailoverMode(newMode); + } + } + } - this.#previousProviderConfig = this.state.providerConfig; + this.#rpcFailoverMode = newMode; } /** * Accesses the provider and block tracker for the currently selected network. * * @returns The proxy and block tracker proxies. + * @deprecated This method has been replaced by `getSelectedNetworkClient` (which has a more easily used return type) and will be removed in a future release. */ getProviderAndBlockTracker(): { provider: SwappableProxy> | undefined; @@ -611,12 +1504,45 @@ export class NetworkController extends BaseControllerV2< } /** - * Returns all of the network clients that have been created so far, keyed by - * their identifier in the network client registry. This collection represents - * not only built-in networks but also any custom networks that consumers have - * added. + * Accesses the provider and block tracker for the currently selected network. + * + * @returns an object with the provider and block tracker proxies for the currently selected network. + */ + getSelectedNetworkClient(): + | { + provider: SwappableProxy>; + blockTracker: SwappableProxy>; + } + | undefined { + if (this.#providerProxy && this.#blockTrackerProxy) { + return { + provider: this.#providerProxy, + blockTracker: this.#blockTrackerProxy, + }; + } + return undefined; + } + + /** + * Accesses the chain ID from the selected network client. * - * @returns The list of known network clients. + * @returns The chain ID of the selected network client in hex format or undefined if there is no network client. + */ + getSelectedChainId(): Hex | undefined { + const networkConfiguration = this.getNetworkConfigurationByNetworkClientId( + this.state.selectedNetworkClientId, + ); + return networkConfiguration?.chainId; + } + + /** + * Internally, the Infura and custom network clients are categorized by type + * so that when accessing either kind of network client, TypeScript knows + * which type to assign to the network client. For some cases it's more useful + * to be able to access network clients by ID instead of by type and then ID, + * so this function makes that possible. + * + * @returns The network clients registered so far, keyed by ID. */ getNetworkClientRegistry(): AutoManagedBuiltInNetworkClientRegistry & AutoManagedCustomNetworkClientRegistry { @@ -662,16 +1588,11 @@ export class NetworkController extends BaseControllerV2< const autoManagedNetworkClientRegistry = this.#ensureAutoManagedNetworkClientRegistryPopulated(); - if (isInfuraProviderType(networkClientId)) { - const infuraNetworkClient = - autoManagedNetworkClientRegistry[NetworkClientType.Infura][ - networkClientId - ]; - if (!infuraNetworkClient) { - throw new Error( - `No Infura network client was found with the ID "${networkClientId}".`, - ); - } + const infuraNetworkClient = + autoManagedNetworkClientRegistry[NetworkClientType.Infura][ + networkClientId + ]; + if (infuraNetworkClient) { return infuraNetworkClient; } @@ -681,158 +1602,97 @@ export class NetworkController extends BaseControllerV2< ]; if (!customNetworkClient) { throw new Error( - `No custom network client was found with the ID "${networkClientId}".`, + `No network client was found with ID "${networkClientId}".`, ); } return customNetworkClient; } /** - * Executes a series of steps to apply the changes to the provider config: + * Executes a series of steps to switch the network: * - * 1. Notifies subscribers that the network is about to change. - * 2. Looks up a known and preinitialized network client matching the provider - * config and re-points the provider and block tracker proxy to it. - * 3. Notifies subscribers that the network has changed. - */ - async #refreshNetwork() { - this.messagingSystem.publish('NetworkController:networkWillChange'); - this.#applyNetworkSelection(); - this.messagingSystem.publish('NetworkController:networkDidChange'); - await this.lookupNetwork(); - } - - /** - * Populates the network clients and establishes the initial network based on - * the provider configuration in state. - */ - async initializeProvider() { - this.#ensureAutoManagedNetworkClientRegistryPopulated(); - - this.#applyNetworkSelection(); - await this.lookupNetwork(); - } - - /** - * Refreshes the network meta with EIP-1559 support and the network status - * based on the given network client ID. + * 1. Notifies subscribers via the messenger that the network is about to be + * switched (and, really, that the global provider and block tracker proxies + * will be re-pointed to a new network). + * 2. Looks up a known and preinitialized network client matching the given + * ID and uses it to re-point the aforementioned provider and block tracker + * proxies. + * 3. Notifies subscribers via the messenger that the network has switched. + * 4. Captures metadata for the newly switched network in state. * - * @param networkClientId - The ID of the network client to update. + * @param networkClientId - The ID of a network client that requests will be + * routed through (either the name of an Infura network or the ID of a custom + * network configuration). + * @param options - Options for this method. + * @param options.updateState - Allows for updating state. */ - async lookupNetworkByClientId(networkClientId: NetworkClientId) { - const isInfura = isInfuraProviderType(networkClientId); - let updatedNetworkStatus: NetworkStatus; - let updatedIsEIP1559Compatible: boolean | undefined; - - try { - updatedIsEIP1559Compatible = await this.#determineEIP1559Compatibility( - networkClientId, - ); - updatedNetworkStatus = NetworkStatus.Available; - } catch (error) { - if (isErrorWithCode(error)) { - let responseBody; - if ( - isInfura && - hasProperty(error, 'message') && - typeof error.message === 'string' - ) { - try { - responseBody = JSON.parse(error.message); - } catch { - // error.message must not be JSON - } - } - - if ( - isPlainObject(responseBody) && - responseBody.error === INFURA_BLOCKED_KEY - ) { - updatedNetworkStatus = NetworkStatus.Blocked; - } else if (error.code === errorCodes.rpc.internal) { - updatedNetworkStatus = NetworkStatus.Unknown; - } else { - updatedNetworkStatus = NetworkStatus.Unavailable; - } - } else if ( - typeof Error !== 'undefined' && - hasProperty(error as unknown as Error, 'message') && - typeof (error as unknown as Error).message === 'string' && - (error as unknown as Error).message.includes( - 'No custom network client was found with the ID', - ) - ) { - throw error; - } else { - log('NetworkController - could not determine network status', error); - updatedNetworkStatus = NetworkStatus.Unknown; - } - } - this.update((state) => { - if (state.networksMetadata[networkClientId] === undefined) { - state.networksMetadata[networkClientId] = { - status: NetworkStatus.Unknown, - EIPS: {}, - }; - } - const meta = state.networksMetadata[networkClientId]; - meta.status = updatedNetworkStatus; - if (updatedIsEIP1559Compatible === undefined) { - delete meta.EIPS[1559]; - } else { - meta.EIPS[1559] = updatedIsEIP1559Compatible; - } - }); + async #refreshNetwork( + networkClientId: string, + options: { + updateState?: (state: Draft) => void; + } = {}, + ): Promise { + this.messenger.publish('NetworkController:networkWillChange', this.state); + this.#applyNetworkSelection(networkClientId, options); + this.messenger.publish('NetworkController:networkDidChange', this.state); + await this.lookupNetwork(); } /** - * Performs side effects after switching to a network. If the network is - * available, updates the network state with the network ID of the network and - * stores whether the network supports EIP-1559; otherwise clears said - * information about the network that may have been previously stored. - * - * @param networkClientId - (Optional) The ID of the network client to update. - * If no ID is provided, uses the currently selected network. - * @fires infuraIsBlocked if the network is Infura-supported and is blocking - * requests. - * @fires infuraIsUnblocked if the network is Infura-supported and is not - * blocking requests, or if the network is not Infura-supported. + * Initialize the NetworkController: + * - Apply the RPC failover mode from the `corePlatformRpcFailoverMode` remote feature flag; + * - Apply the network selection. + * - Auto-add networks for any chains that are configured to be auto-enabled in ConfigRegistryController. */ - async lookupNetwork(networkClientId?: NetworkClientId) { - if (networkClientId) { - await this.lookupNetworkByClientId(networkClientId); - return; - } - - if (!this.#ethQuery) { - return; - } + init(): void { + const state = this.messenger.call('RemoteFeatureFlagController:getState'); + this.#updateRpcFailover(getRpcFailoverMode(state)); - const isInfura = isInfuraProviderConfig(this.state.providerConfig); - - let networkChanged = false; - const listener = () => { - networkChanged = true; - this.messagingSystem.unsubscribe( - 'NetworkController:networkDidChange', - listener, - ); - }; - this.messagingSystem.subscribe( - 'NetworkController:networkDidChange', - listener, + this.#applyNetworkSelection(this.state.selectedNetworkClientId); + + this.#autoAddNetworksFromConfigRegistry( + selectEvmAutoEnabledNetworksChainIds( + this.messenger.call('ConfigRegistryController:getState'), + ), ); + } + + /** + * Uses a request for the latest block to gather the following information on + * the given network: + * + * - The connectivity status: whether it is available, geo-blocked (Infura + * only), unavailable, or unknown + * - The capabilities status: whether it supports EIP-1559, whether it does + * not, or whether it is unknown + * + * @param networkClientId - The ID of the network client to inspect. + * If no ID is provided, uses the currently selected network. + * @returns The resulting metadata for the network. + */ + async #determineNetworkMetadata(networkClientId: NetworkClientId): Promise<{ + isInfura: boolean; + networkStatus: + | NetworkStatus.Available + | NetworkStatus.Unknown + | NetworkStatus.Unavailable + | NetworkStatus.Blocked; + isEIP1559Compatible: undefined | boolean; + }> { + const networkClient = this.getNetworkClientById(networkClientId); - let updatedNetworkStatus: NetworkStatus; - let updatedIsEIP1559Compatible: boolean | undefined; + const isInfura = + networkClient.configuration.type === NetworkClientType.Infura; + let networkStatus: NetworkStatus; + let isEIP1559Compatible: boolean | undefined; try { - const isEIP1559Compatible = await this.#determineEIP1559Compatibility( - this.state.selectedNetworkClientId, - ); - updatedNetworkStatus = NetworkStatus.Available; - updatedIsEIP1559Compatible = isEIP1559Compatible; + isEIP1559Compatible = + await this.#determineEIP1559Compatibility(networkClientId); + networkStatus = NetworkStatus.Available; } catch (error) { + debugLog('NetworkController: lookupNetwork: ', error); + if (isErrorWithCode(error)) { let responseBody; if ( @@ -844,6 +1704,10 @@ export class NetworkController extends BaseControllerV2< responseBody = JSON.parse(error.message); } catch { // error.message must not be JSON + this.#log?.warn( + 'NetworkController: lookupNetwork: json parse error: ', + error, + ); } } @@ -851,120 +1715,259 @@ export class NetworkController extends BaseControllerV2< isPlainObject(responseBody) && responseBody.error === INFURA_BLOCKED_KEY ) { - updatedNetworkStatus = NetworkStatus.Blocked; + networkStatus = NetworkStatus.Blocked; } else if (error.code === errorCodes.rpc.internal) { - updatedNetworkStatus = NetworkStatus.Unknown; + networkStatus = NetworkStatus.Unknown; + this.#log?.warn( + 'NetworkController: lookupNetwork: rpc internal error: ', + error, + ); } else { - updatedNetworkStatus = NetworkStatus.Unavailable; + networkStatus = NetworkStatus.Unavailable; + this.#log?.warn('NetworkController: lookupNetwork: ', error); } } else { - log('NetworkController - could not determine network status', error); - updatedNetworkStatus = NetworkStatus.Unknown; + debugLog( + 'NetworkController - could not determine network status', + error, + ); + networkStatus = NetworkStatus.Unknown; + this.#log?.warn('NetworkController: lookupNetwork: ', error); } } + return { isInfura, networkStatus, isEIP1559Compatible }; + } + + /** + * Uses a request for the latest block to gather the following information on + * the given or selected network, persisting it to state: + * + * - The connectivity status: whether it is available, geo-blocked (Infura + * only), unavailable, or unknown + * - The capabilities status: whether it supports EIP-1559, whether it does + * not, or whether it is unknown + * + * @param networkClientId - The ID of the network client to inspect. + * If no ID is provided, uses the currently selected network. + */ + async lookupNetwork(networkClientId?: NetworkClientId): Promise { + if (networkClientId) { + await this.#lookupGivenNetwork(networkClientId); + } else { + await this.#lookupSelectedNetwork(); + } + } + + /** + * Uses a request for the latest block to gather the following information on + * the given network, persisting it to state: + * + * - The connectivity status: whether the network is available, geo-blocked + * (Infura only), unavailable, or unknown + * - The feature compatibility status: whether the network supports EIP-1559, + * whether it does not, or whether it is unknown + * + * @param networkClientId - The ID of the network client to inspect. + * @deprecated Please use `lookupNetwork` and pass a network client ID + * instead. This method will be removed in a future major version. + */ + // We are planning on removing this so we aren't interested in testing this + // right now. + /* istanbul ignore next */ + async lookupNetworkByClientId( + networkClientId: NetworkClientId, + ): Promise { + await this.#lookupGivenNetwork(networkClientId); + } + + /** + * Uses a request for the latest block to gather the following information on + * the given network, persisting it to state: + * + * - The connectivity status: whether the network is available, geo-blocked + * (Infura only), unavailable, or unknown + * - The feature compatibility status: whether the network supports EIP-1559, + * whether it does not, or whether it is unknown + * + * @param networkClientId - The ID of the network client to inspect. + */ + async #lookupGivenNetwork(networkClientId: NetworkClientId): Promise { + const { networkStatus, isEIP1559Compatible } = + await this.#determineNetworkMetadata(networkClientId); + + this.#updateMetadataForNetwork(networkClientId, { + networkStatus, + isEIP1559Compatible, + }); + } + + /** + * Uses a request for the latest block to gather the following information on + * the currently selected network, persisting it to state: + * + * - The connectivity status: whether the network is available, geo-blocked + * (Infura only), unavailable, or unknown + * - The feature compatibility status: whether the network supports EIP-1559, + * whether it does not, or whether it is unknown + * + * Note that it is possible for the current network to be switched while this + * method is running. If that is the case, it will exit early (as this method + * will also run for the new network). + */ + async #lookupSelectedNetwork(): Promise { + if (!this.#ethQuery) { + return; + } + + let networkChanged = false; + const listener = (): void => { + networkChanged = true; + try { + this.messenger.unsubscribe( + 'NetworkController:networkDidChange', + listener, + ); + } catch (error) { + // In theory, this `catch` should not be necessary given that this error + // would occur "inside" of the call to `#determineEIP1559Compatibility` + // below and so it should be caught by the `try`/`catch` below (it is + // impossible to reproduce in tests for that reason). However, somehow + // it occurs within Mobile and so we have to add our own `try`/`catch` + // here. + /* istanbul ignore next */ + if ( + !(error instanceof Error) || + error.message !== + 'Subscription not found for event: NetworkController:networkDidChange' + ) { + // Again, this error should not happen and is impossible to reproduce + // in tests. + /* istanbul ignore next */ + throw error; + } + } + }; + this.messenger.subscribe('NetworkController:networkDidChange', listener); + + const { isInfura, networkStatus, isEIP1559Compatible } = + await this.#determineNetworkMetadata(this.state.selectedNetworkClientId); + if (networkChanged) { // If the network has changed, then `lookupNetwork` either has been or is // in the process of being called, so we don't need to go further. return; } - this.messagingSystem.unsubscribe( - 'NetworkController:networkDidChange', - listener, - ); - this.update((state) => { - const meta = state.networksMetadata[state.selectedNetworkClientId]; - meta.status = updatedNetworkStatus; - if (updatedIsEIP1559Compatible === undefined) { - delete meta.EIPS[1559]; - } else { - meta.EIPS[1559] = updatedIsEIP1559Compatible; + try { + this.messenger.unsubscribe( + 'NetworkController:networkDidChange', + listener, + ); + } catch (error) { + if ( + !(error instanceof Error) || + error.message !== + 'Subscription not found for event: NetworkController:networkDidChange' + ) { + throw error; } + } + + this.#updateMetadataForNetwork(this.state.selectedNetworkClientId, { + networkStatus, + isEIP1559Compatible, }); if (isInfura) { - if (updatedNetworkStatus === NetworkStatus.Available) { - this.messagingSystem.publish('NetworkController:infuraIsUnblocked'); - } else if (updatedNetworkStatus === NetworkStatus.Blocked) { - this.messagingSystem.publish('NetworkController:infuraIsBlocked'); + if (networkStatus === NetworkStatus.Available) { + this.messenger.publish('NetworkController:infuraIsUnblocked'); + } else if (networkStatus === NetworkStatus.Blocked) { + this.messenger.publish('NetworkController:infuraIsBlocked'); } } else { // Always publish infuraIsUnblocked regardless of network status to // prevent consumers from being stuck in a blocked state if they were // previously connected to an Infura network that was blocked - this.messagingSystem.publish('NetworkController:infuraIsUnblocked'); + this.messenger.publish('NetworkController:infuraIsUnblocked'); } } /** - * Convenience method to update provider network type settings. + * Updates the metadata for the given network in state. * - * @param type - Human readable network name. + * @param networkClientId - The associated network client ID. + * @param metadata - The metadata to store in state. + * @param metadata.networkStatus - The network status to store in state. + * @param metadata.isEIP1559Compatible - The EIP-1559 compatibility status to + * store in state. */ - async setProviderType(type: InfuraNetworkType) { - assert.notStrictEqual( - type, - NetworkType.rpc, - `NetworkController - cannot call "setProviderType" with type "${NetworkType.rpc}". Use "setActiveNetwork"`, - ); - assert.ok( - isInfuraProviderType(type), - `Unknown Infura provider type "${type}".`, - ); - - this.#previousProviderConfig = this.state.providerConfig; - - // If testnet the ticker symbol should use a testnet prefix - const ticker = - type in NetworksTicker && NetworksTicker[type].length > 0 - ? NetworksTicker[type] - : 'ETH'; + #updateMetadataForNetwork( + networkClientId: NetworkClientId, + metadata: { + networkStatus: NetworkStatus; + isEIP1559Compatible?: boolean | undefined; + }, + ): void { + this.update((state) => { + state.networksMetadata[networkClientId] ??= { + status: NetworkStatus.Unknown, + EIPS: {}, + }; - this.#ensureAutoManagedNetworkClientRegistryPopulated(); + const newMetadata = state.networksMetadata[networkClientId]; + newMetadata.status = metadata.networkStatus; - this.update((state) => { - state.providerConfig.type = type; - state.providerConfig.ticker = ticker; - state.providerConfig.chainId = ChainId[type]; - state.providerConfig.rpcPrefs = BUILT_IN_NETWORKS[type].rpcPrefs; - state.providerConfig.rpcUrl = undefined; - state.providerConfig.nickname = undefined; - state.providerConfig.id = undefined; + if ('isEIP1559Compatible' in metadata) { + if (metadata.isEIP1559Compatible === undefined) { + delete newMetadata.EIPS[1559]; + } else { + newMetadata.EIPS[1559] = metadata.isEIP1559Compatible; + } + } }); - await this.#refreshNetwork(); } /** - * Convenience method to update provider RPC settings. + * Convenience method to update provider network type settings. * - * @param networkConfigurationId - The unique id for the network configuration to set as the active provider. + * @param type - Human readable network name. + * @deprecated This has been replaced by `setActiveNetwork`, and will be + * removed in a future release */ - async setActiveNetwork(networkConfigurationId: string) { - this.#previousProviderConfig = this.state.providerConfig; - - const targetNetwork = - this.state.networkConfigurations[networkConfigurationId]; - - if (!targetNetwork) { + async setProviderType(type: InfuraNetworkType): Promise { + if ((type as unknown) === NetworkType.rpc) { throw new Error( - `networkConfigurationId ${networkConfigurationId} does not match a configured networkConfiguration`, + `NetworkController - cannot call "setProviderType" with type "${NetworkType.rpc}". Use "setActiveNetwork"`, ); } + if (!isInfuraNetworkType(type)) { + throw new Error(`Unknown Infura provider type "${String(type)}".`); + } - this.#ensureAutoManagedNetworkClientRegistryPopulated(); - - this.update((state) => { - state.providerConfig.type = NetworkType.rpc; - state.providerConfig.rpcUrl = targetNetwork.rpcUrl; - state.providerConfig.chainId = targetNetwork.chainId; - state.providerConfig.ticker = targetNetwork.ticker; - state.providerConfig.nickname = targetNetwork.nickname; - state.providerConfig.rpcPrefs = targetNetwork.rpcPrefs; - state.providerConfig.id = targetNetwork.id; - }); + await this.setActiveNetwork(type); + } - await this.#refreshNetwork(); + /** + * Changes the selected network. + * + * @param networkClientId - The ID of a network client that will be used to + * make requests. + * @param options - Options for this method. + * @param options.updateState - Allows for updating state. + * @throws if no network client is associated with the given + * network client ID. + */ + async setActiveNetwork( + networkClientId: string, + options: { + updateState?: (state: Draft) => void; + } = {}, + ): Promise { + this.#previouslySelectedNetworkClientId = + this.state.selectedNetworkClientId; + + await this.#refreshNetwork(networkClientId, options); } /** @@ -974,13 +1977,10 @@ export class NetworkController extends BaseControllerV2< * @returns A promise that either resolves to the block header or null if * there is no latest block, or rejects with an error. */ - #getLatestBlock(networkClientId: NetworkClientId): Promise { - if (networkClientId === undefined) { - networkClientId = this.state.selectedNetworkClientId; - } - + #getLatestBlock( + networkClientId: NetworkClientId = this.state.selectedNetworkClientId, + ): Promise { const networkClient = this.getNetworkClientById(networkClientId); - // @ts-expect-error TODO: Provider type alignment const ethQuery = new EthQuery(networkClient.provider); return new Promise((resolve, reject) => { @@ -988,6 +1988,8 @@ export class NetworkController extends BaseControllerV2< { method: 'eth_getBlockByNumber', params: ['latest', false] }, (error: unknown, block?: unknown) => { if (error) { + // This error comes from JsonRpcEngine, we don't control it. + // eslint-disable-next-line @typescript-eslint/prefer-promise-reject-errors reject(error); } else { // TODO: Validate this type @@ -1007,7 +2009,9 @@ export class NetworkController extends BaseControllerV2< * @returns A promise that resolves to true if the network supports EIP-1559 * , false otherwise, or `undefined` if unable to determine the compatibility. */ - async getEIP1559Compatibility(networkClientId?: NetworkClientId) { + async getEIP1559Compatibility( + networkClientId?: NetworkClientId, + ): Promise { if (networkClientId) { return this.get1559CompatibilityWithNetworkClientId(networkClientId); } @@ -1036,277 +2040,970 @@ export class NetworkController extends BaseControllerV2< async get1559CompatibilityWithNetworkClientId( networkClientId: NetworkClientId, - ) { + ): Promise { let metadata = this.state.networksMetadata[networkClientId]; - if (metadata === undefined) { + if (metadata?.EIPS[1559] === undefined) { await this.lookupNetwork(networkClientId); metadata = this.state.networksMetadata[networkClientId]; } const { EIPS } = metadata; - // may want to include some 'freshness' value - something to make sure we refetch this from time to time - return EIPS[1559]; + // may want to include some 'freshness' value - something to make sure we refetch this from time to time + return EIPS[1559]; + } + + /** + * Retrieves and checks the latest block from the currently selected + * network; if the block has a `baseFeePerGas` property, then we know + * that the network supports EIP-1559; otherwise it doesn't. + * + * @param networkClientId - The networkClientId to fetch the correct provider against which to check 1559 compatibility + * @returns A promise that resolves to `true` if the network supports EIP-1559, + * `false` otherwise, or `undefined` if unable to retrieve the last block. + */ + async #determineEIP1559Compatibility( + networkClientId: NetworkClientId, + ): Promise { + const latestBlock = await this.#getLatestBlock(networkClientId); + + if (!latestBlock) { + return undefined; + } + + return latestBlock.baseFeePerGas !== undefined; + } + + /** + * Ensures that the provider and block tracker proxies are pointed to the + * currently selected network and refreshes the metadata for the + */ + async resetConnection(): Promise { + await this.#refreshNetwork(this.state.selectedNetworkClientId); + } + + /** + * Returns the network configuration that has been filed under the given chain + * ID. + * + * @param chainId - The chain ID to use as a key. + * @returns The network configuration if one exists, or undefined. + */ + getNetworkConfigurationByChainId( + chainId: Hex, + ): NetworkConfiguration | undefined { + return this.state.networkConfigurationsByChainId[chainId]; + } + + /** + * Returns the network configuration that contains an RPC endpoint with the + * given network client ID. + * + * @param networkClientId - The network client ID to use as a key. + * @returns The network configuration if one exists, or undefined. + */ + getNetworkConfigurationByNetworkClientId( + networkClientId: NetworkClientId, + ): NetworkConfiguration | undefined { + return this.#networkConfigurationsByNetworkClientId.get(networkClientId); + } + + /** + * Creates and registers network clients for the collection of Infura and + * custom RPC endpoints that can be used to make requests for a particular + * chain, storing the given configuration object in state for later reference. + * + * @param fields - The object that describes the new network/chain and lists + * the RPC endpoints which front that chain. + * @returns The newly added network configuration. + * @throws if any part of `fields` would produce invalid state. + * @see {@link NetworkConfiguration} + */ + addNetwork(fields: AddNetworkFields): NetworkConfiguration { + const { rpcEndpoints: setOfRpcEndpointFields } = fields; + + const autoManagedNetworkClientRegistry = + this.#ensureAutoManagedNetworkClientRegistryPopulated(); + + this.#validateNetworkFields({ + mode: 'add', + networkFields: fields, + autoManagedNetworkClientRegistry, + }); + + const networkClientOperations = setOfRpcEndpointFields.map( + (defaultOrCustomRpcEndpointFields) => { + const rpcEndpoint = + defaultOrCustomRpcEndpointFields.type === RpcEndpointType.Custom + ? { + ...defaultOrCustomRpcEndpointFields, + networkClientId: uuidV4(), + } + : defaultOrCustomRpcEndpointFields; + return { + type: 'add' as const, + rpcEndpoint, + }; + }, + ); + + const newNetworkConfiguration = + this.#determineNetworkConfigurationToPersist({ + networkFields: fields, + networkClientOperations, + }); + this.#registerNetworkClientsAsNeeded({ + networkFields: fields, + networkClientOperations, + autoManagedNetworkClientRegistry, + }); + this.update((state) => { + this.#updateNetworkConfigurations({ + state, + mode: 'add', + networkFields: fields, + networkConfigurationToPersist: newNetworkConfiguration, + }); + }); + + this.messenger.publish( + `${controllerName}:networkAdded`, + newNetworkConfiguration, + ); + + return newNetworkConfiguration; + } + + /** + * Updates the configuration for a previously stored network filed under the + * given chain ID, creating + registering new network clients to represent RPC + * endpoints that have been added and destroying + unregistering existing + * network clients for RPC endpoints that have been removed. + * + * Note that if `chainId` is changed, then all network clients associated with + * that chain will be removed and re-added, even if none of the RPC endpoints + * have changed. + * + * @param chainId - The chain ID associated with an existing network. + * @param fields - The object that describes the updates to the network/chain, + * including the new set of RPC endpoints which should front that chain. + * @param options - Options to provide. + * @param options.replacementSelectedRpcEndpointIndex - Usually you cannot + * remove an RPC endpoint that is being represented by the currently selected + * network client. This option allows you to specify another RPC endpoint + * (either an existing one or a new one) that should be used to select a new + * network instead. + * @returns The updated network configuration. + * @throws if `chainId` does not refer to an existing network configuration, + * if any part of `fields` would produce invalid state, etc. + * @see {@link NetworkConfiguration} + */ + async updateNetwork( + chainId: Hex, + fields: UpdateNetworkFields, + { + replacementSelectedRpcEndpointIndex, + }: { replacementSelectedRpcEndpointIndex?: number } = {}, + ): Promise { + const existingNetworkConfiguration = + this.state.networkConfigurationsByChainId[chainId]; + + if (existingNetworkConfiguration === undefined) { + throw new Error( + `Could not update network: Cannot find network configuration for chain '${chainId}'`, + ); + } + + const existingChainId = chainId; + const { chainId: newChainId, rpcEndpoints: setOfNewRpcEndpointFields } = + fields; + + const autoManagedNetworkClientRegistry = + this.#ensureAutoManagedNetworkClientRegistryPopulated(); + + this.#validateNetworkFields({ + mode: 'update', + networkFields: fields, + existingNetworkConfiguration, + autoManagedNetworkClientRegistry, + }); + + const networkClientOperations: NetworkClientOperation[] = []; + + for (const newRpcEndpointFields of setOfNewRpcEndpointFields) { + const existingRpcEndpointForNoop = + existingNetworkConfiguration.rpcEndpoints.find((rpcEndpoint) => { + return ( + rpcEndpoint.type === newRpcEndpointFields.type && + rpcEndpoint.url === newRpcEndpointFields.url && + (rpcEndpoint.networkClientId === + newRpcEndpointFields.networkClientId || + newRpcEndpointFields.networkClientId === undefined) + ); + }); + const existingRpcEndpointForReplaceWhenChainChanged = + existingNetworkConfiguration.rpcEndpoints.find((rpcEndpoint) => { + return ( + (rpcEndpoint.type === RpcEndpointType.Infura && + newRpcEndpointFields.type === RpcEndpointType.Infura) || + (rpcEndpoint.type === newRpcEndpointFields.type && + rpcEndpoint.networkClientId === + newRpcEndpointFields.networkClientId && + rpcEndpoint.url === newRpcEndpointFields.url) + ); + }); + const existingRpcEndpointForReplaceWhenChainNotChanged = + existingNetworkConfiguration.rpcEndpoints.find((rpcEndpoint) => { + return ( + rpcEndpoint.type === newRpcEndpointFields.type && + (rpcEndpoint.url === newRpcEndpointFields.url || + rpcEndpoint.networkClientId === + newRpcEndpointFields.networkClientId) + ); + }); + + if ( + newChainId !== existingChainId && + existingRpcEndpointForReplaceWhenChainChanged !== undefined + ) { + const newRpcEndpoint = + newRpcEndpointFields.type === RpcEndpointType.Infura + ? newRpcEndpointFields + : { ...newRpcEndpointFields, networkClientId: uuidV4() }; + + networkClientOperations.push({ + type: 'replace' as const, + oldRpcEndpoint: existingRpcEndpointForReplaceWhenChainChanged, + newRpcEndpoint, + }); + } else if (existingRpcEndpointForNoop !== undefined) { + let newRpcEndpoint; + if (existingRpcEndpointForNoop.type === RpcEndpointType.Infura) { + newRpcEndpoint = existingRpcEndpointForNoop; + } else { + // `networkClientId` shouldn't be missing at this point; if it is, + // that's a mistake, so fill it back in + newRpcEndpoint = Object.assign({}, newRpcEndpointFields, { + networkClientId: existingRpcEndpointForNoop.networkClientId, + }); + } + networkClientOperations.push({ + type: 'noop' as const, + rpcEndpoint: newRpcEndpoint, + }); + } else if ( + existingRpcEndpointForReplaceWhenChainNotChanged === undefined + ) { + const newRpcEndpoint = + newRpcEndpointFields.type === RpcEndpointType.Infura + ? newRpcEndpointFields + : { ...newRpcEndpointFields, networkClientId: uuidV4() }; + const networkClientOperation = { + type: 'add' as const, + rpcEndpoint: newRpcEndpoint, + }; + networkClientOperations.push(networkClientOperation); + } else { + let newRpcEndpoint; + /* istanbul ignore if */ + if (newRpcEndpointFields.type === RpcEndpointType.Infura) { + // This case can't actually happen. If we're here, it means that some + // part of the RPC endpoint changed. But there is no part of an Infura + // RPC endpoint that can be changed (as it would immediately make that + // RPC endpoint self-inconsistent). This is just here to appease + // TypeScript. + newRpcEndpoint = newRpcEndpointFields; + } else { + newRpcEndpoint = { + ...newRpcEndpointFields, + networkClientId: uuidV4(), + }; + } + + networkClientOperations.push({ + type: 'replace' as const, + oldRpcEndpoint: existingRpcEndpointForReplaceWhenChainNotChanged, + newRpcEndpoint, + }); + } + } + + for (const existingRpcEndpoint of existingNetworkConfiguration.rpcEndpoints) { + if ( + !networkClientOperations.some((networkClientOperation) => { + const otherRpcEndpoint = + networkClientOperation.type === 'replace' + ? networkClientOperation.oldRpcEndpoint + : networkClientOperation.rpcEndpoint; + return ( + otherRpcEndpoint.type === existingRpcEndpoint.type && + otherRpcEndpoint.networkClientId === + existingRpcEndpoint.networkClientId && + otherRpcEndpoint.url === existingRpcEndpoint.url + ); + }) + ) { + const networkClientOperation = { + type: 'remove' as const, + rpcEndpoint: existingRpcEndpoint, + }; + networkClientOperations.push(networkClientOperation); + } + } + + const updatedNetworkConfiguration = + this.#determineNetworkConfigurationToPersist({ + networkFields: fields, + networkClientOperations, + }); + + if ( + replacementSelectedRpcEndpointIndex === undefined && + networkClientOperations.some((networkClientOperation) => { + return ( + networkClientOperation.type === 'remove' && + networkClientOperation.rpcEndpoint.networkClientId === + this.state.selectedNetworkClientId + ); + }) && + !networkClientOperations.some((networkClientOperation) => { + return ( + networkClientOperation.type === 'replace' && + networkClientOperation.oldRpcEndpoint.networkClientId === + this.state.selectedNetworkClientId + ); + }) + ) { + throw new Error( + `Could not update network: Cannot update RPC endpoints in such a way that the selected network '${this.state.selectedNetworkClientId}' would be removed without a replacement. Choose a different RPC endpoint as the selected network via the \`replacementSelectedRpcEndpointIndex\` option.`, + ); + } + + this.#registerNetworkClientsAsNeeded({ + networkFields: fields, + networkClientOperations, + autoManagedNetworkClientRegistry, + }); + + const replacementSelectedRpcEndpointWithIndex = networkClientOperations + .map( + (networkClientOperation, index) => + [networkClientOperation, index] as const, + ) + .find(([networkClientOperation, _index]) => { + return ( + networkClientOperation.type === 'replace' && + networkClientOperation.oldRpcEndpoint.networkClientId === + this.state.selectedNetworkClientId + ); + }); + const correctedReplacementSelectedRpcEndpointIndex = + replacementSelectedRpcEndpointIndex ?? + replacementSelectedRpcEndpointWithIndex?.[1]; + + let rpcEndpointToSelect: RpcEndpoint | undefined; + if (correctedReplacementSelectedRpcEndpointIndex !== undefined) { + rpcEndpointToSelect = + updatedNetworkConfiguration.rpcEndpoints[ + correctedReplacementSelectedRpcEndpointIndex + ]; + + if (rpcEndpointToSelect === undefined) { + throw new Error( + `Could not update network: \`replacementSelectedRpcEndpointIndex\` ${correctedReplacementSelectedRpcEndpointIndex} does not refer to an entry in \`rpcEndpoints\``, + ); + } + } + + if ( + rpcEndpointToSelect && + rpcEndpointToSelect.networkClientId !== this.state.selectedNetworkClientId + ) { + await this.setActiveNetwork(rpcEndpointToSelect.networkClientId, { + updateState: (state) => { + this.#updateNetworkConfigurations({ + state, + mode: 'update', + networkFields: fields, + networkConfigurationToPersist: updatedNetworkConfiguration, + existingNetworkConfiguration, + }); + }, + }); + } else { + this.update((state) => { + this.#updateNetworkConfigurations({ + state, + mode: 'update', + networkFields: fields, + networkConfigurationToPersist: updatedNetworkConfiguration, + existingNetworkConfiguration, + }); + }); + } + + this.#unregisterNetworkClientsAsNeeded({ + networkClientOperations, + autoManagedNetworkClientRegistry, + }); + + return updatedNetworkConfiguration; + } + + /** + * Destroys and unregisters the network identified by the given chain ID, also + * removing the associated network configuration from state. + * + * @param chainId - The chain ID associated with an existing network. + * @throws if `chainId` does not refer to an existing network configuration, + * or if the currently selected network is being removed. + * @see {@link NetworkConfiguration} + */ + removeNetwork(chainId: Hex): void { + const existingNetworkConfiguration = + this.state.networkConfigurationsByChainId[chainId]; + + if (existingNetworkConfiguration === undefined) { + throw new Error( + `Cannot find network configuration for chain '${chainId}'`, + ); + } + + if ( + existingNetworkConfiguration.rpcEndpoints.some( + (rpcEndpoint) => + rpcEndpoint.networkClientId === this.state.selectedNetworkClientId, + ) + ) { + throw new Error(`Cannot remove the currently selected network`); + } + + const autoManagedNetworkClientRegistry = + this.#ensureAutoManagedNetworkClientRegistryPopulated(); + + const networkClientOperations = + existingNetworkConfiguration.rpcEndpoints.map((rpcEndpoint) => { + return { + type: 'remove' as const, + rpcEndpoint, + }; + }); + + this.#unregisterNetworkClientsAsNeeded({ + networkClientOperations, + autoManagedNetworkClientRegistry, + }); + this.update((state) => { + this.#updateNetworkConfigurations({ + state, + mode: 'remove', + existingNetworkConfiguration, + }); + + for (const rpcEndpoint of existingNetworkConfiguration.rpcEndpoints) { + delete state.networksMetadata[rpcEndpoint.networkClientId]; + } + }); + + this.messenger.publish( + 'NetworkController:networkRemoved', + existingNetworkConfiguration, + ); } /** - * Retrieves and checks the latest block from the currently selected - * network; if the block has a `baseFeePerGas` property, then we know - * that the network supports EIP-1559; otherwise it doesn't. + * Assuming that the network has been previously switched, switches to this + * new network. * - * @param networkClientId - The networkClientId to fetch the correct provider against which to check 1559 compatibility - * @returns A promise that resolves to `true` if the network supports EIP-1559, - * `false` otherwise, or `undefined` if unable to retrieve the last block. + * If the network has not been previously switched, this method is equivalent + * to {@link resetConnection}. */ - async #determineEIP1559Compatibility( - networkClientId: NetworkClientId, - ): Promise { - const latestBlock = await this.#getLatestBlock(networkClientId); - - if (!latestBlock) { - return undefined; - } - - return latestBlock.baseFeePerGas !== undefined; + async rollbackToPreviousProvider(): Promise { + await this.#refreshNetwork(this.#previouslySelectedNetworkClientId); } /** - * Re-initializes the provider and block tracker for the current network. + * Deactivates the controller, stopping any ongoing polling. + * + * In-progress requests will not be aborted. */ - async resetConnection() { - this.#ensureAutoManagedNetworkClientRegistryPopulated(); - await this.#refreshNetwork(); + // We're intentionally changing the signature of an extended method. + // eslint-disable-next-line @typescript-eslint/no-misused-promises + async destroy(): Promise { + await this.#blockTrackerProxy?.destroy(); } /** - * Adds a new custom network or updates the information for an existing - * network. + * Merges the given backup data into controller state. * - * This may involve updating the `networkConfigurations` property in - * state as well and/or adding a new network client to the network client - * registry. The `rpcUrl` and `chainId` of the given object are used to - * determine which action to take: + * @param backup - The data that has been backed up. + * @param backup.networkConfigurationsByChainId - Network configurations, + * keyed by chain ID. + */ + loadBackup({ + networkConfigurationsByChainId, + }: Pick): void { + this.update((state) => { + state.networkConfigurationsByChainId = { + ...state.networkConfigurationsByChainId, + ...networkConfigurationsByChainId, + }; + }); + } + + /** + * Searches for the default RPC endpoint configured for the given chain and + * returns its network client ID. This can then be passed to + * {@link getNetworkClientById} to retrieve the network client. * - * - If the `rpcUrl` corresponds to an existing network configuration - * (case-insensitively), then it is overwritten with the object. Furthermore, - * if the `chainId` is different from the existing network configuration, then - * the existing network client is replaced with a new one. - * - If the `rpcUrl` does not correspond to an existing network configuration - * (case-insensitively), then the object is used to add a new network - * configuration along with a new network client. + * @param chainId - Chain ID to search for. + * @returns The ID of the network client created for the chain's default RPC + * endpoint. + */ + findNetworkClientIdByChainId(chainId: Hex): NetworkClientId { + const networkConfiguration = + this.state.networkConfigurationsByChainId[chainId]; + + if (!networkConfiguration) { + throw new Error(`Invalid chain ID "${chainId}"`); + } + + const { networkClientId } = + networkConfiguration.rpcEndpoints[ + networkConfiguration.defaultRpcEndpointIndex + ]; + return networkClientId; + } + + /** + * Ensure that the given fields which will be used to either add or update a + * network are valid. * - * @param networkConfiguration - The network configuration to add or update. - * @param options - Additional configuration options. - * @param options.referrer - Used to create a metrics event; the site from which the call originated, or 'metamask' for internal calls. - * @param options.source - Used to create a metrics event; where the event originated (i.e. from a dapp or from the network form). - * @param options.setActive - If true, switches to the network upon adding or updating it (default: false). - * @returns The ID for the added or updated network configuration. - */ - async upsertNetworkConfiguration( - networkConfiguration: NetworkConfiguration, - { - referrer, - source, - setActive = false, - }: { - referrer: string; - source: string; - setActive?: boolean; - }, - ): Promise { - const sanitizedNetworkConfiguration: NetworkConfiguration = pick( - networkConfiguration, - ['rpcUrl', 'chainId', 'ticker', 'nickname', 'rpcPrefs'], - ); - const { rpcUrl, chainId, ticker } = sanitizedNetworkConfiguration; + * @param args - The arguments. + */ + #validateNetworkFields( + args: { + autoManagedNetworkClientRegistry: AutoManagedNetworkClientRegistry; + } & ( + | { + mode: 'add'; + networkFields: AddNetworkFields; + } + | { + mode: 'update'; + existingNetworkConfiguration: NetworkConfiguration; + networkFields: UpdateNetworkFields; + } + ), + ): void { + const { mode, networkFields, autoManagedNetworkClientRegistry } = args; + const existingNetworkConfiguration = + 'existingNetworkConfiguration' in args + ? args.existingNetworkConfiguration + : null; - assertIsStrictHexString(chainId); - if (!isSafeChainId(chainId)) { + const errorMessagePrefix = + mode === 'update' ? 'Could not update network' : 'Could not add network'; + + if ( + !isStrictHexString(networkFields.chainId) || + !isSafeChainId(networkFields.chainId) + ) { throw new Error( - `Invalid chain ID "${chainId}": numerical value greater than max safe value.`, + `${errorMessagePrefix}: Invalid \`chainId\` '${networkFields.chainId}' (must start with "0x" and not exceed the maximum)`, ); } - if (!rpcUrl) { - throw new Error( - 'An rpcUrl is required to add or update network configuration', - ); + + if (networkFields.chainId !== existingNetworkConfiguration?.chainId) { + const existingNetworkConfigurationViaChainId = + this.state.networkConfigurationsByChainId[networkFields.chainId]; + if (existingNetworkConfigurationViaChainId !== undefined) { + if (existingNetworkConfiguration === null) { + throw new Error( + `Could not add network for chain ${args.networkFields.chainId} as another network for that chain already exists ('${existingNetworkConfigurationViaChainId.name}')`, + ); + } else { + throw new Error( + `Cannot move network from chain ${existingNetworkConfiguration.chainId} to ${networkFields.chainId} as another network for that chain already exists ('${existingNetworkConfigurationViaChainId.name}')`, + ); + } + } } - if (!referrer || !source) { + + const isInvalidDefaultBlockExplorerUrlIndex = + networkFields.blockExplorerUrls.length > 0 + ? networkFields.defaultBlockExplorerUrlIndex === undefined || + networkFields.blockExplorerUrls[ + networkFields.defaultBlockExplorerUrlIndex + ] === undefined + : networkFields.defaultBlockExplorerUrlIndex !== undefined; + + if (isInvalidDefaultBlockExplorerUrlIndex) { throw new Error( - 'referrer and source are required arguments for adding or updating a network configuration', + `${errorMessagePrefix}: \`defaultBlockExplorerUrlIndex\` must refer to an entry in \`blockExplorerUrls\``, ); } - try { - new URL(rpcUrl); - } catch (e: any) { - if (e.message.includes('Invalid URL')) { - throw new Error('rpcUrl must be a valid URL'); - } - } - if (!ticker) { + + if (networkFields.rpcEndpoints.length === 0) { throw new Error( - 'A ticker is required to add or update networkConfiguration', + `${errorMessagePrefix}: \`rpcEndpoints\` must be a non-empty array`, ); } + for (const rpcEndpointFields of networkFields.rpcEndpoints) { + if (!isValidUrl(rpcEndpointFields.url)) { + throw new Error( + `${errorMessagePrefix}: An entry in \`rpcEndpoints\` has invalid URL '${rpcEndpointFields.url}'`, + ); + } + const networkClientId = + 'networkClientId' in rpcEndpointFields + ? rpcEndpointFields.networkClientId + : undefined; + + if ( + mode === 'update' && + networkClientId !== undefined && + rpcEndpointFields.type === RpcEndpointType.Custom && + !Object.values(autoManagedNetworkClientRegistry).some( + (networkClientsById) => networkClientId in networkClientsById, + ) + ) { + throw new Error( + `${errorMessagePrefix}: RPC endpoint '${rpcEndpointFields.url}' refers to network client '${networkClientId}' that does not exist`, + ); + } - const autoManagedNetworkClientRegistry = - this.#ensureAutoManagedNetworkClientRegistryPopulated(); - - const existingNetworkConfiguration = Object.values( - this.state.networkConfigurations, - ).find( - (networkConfig) => - networkConfig.rpcUrl.toLowerCase() === rpcUrl.toLowerCase(), - ); - const upsertedNetworkConfigurationId = existingNetworkConfiguration - ? existingNetworkConfiguration.id - : random(); - const networkClientId = buildCustomNetworkClientId( - upsertedNetworkConfigurationId, - ); - - this.update((state) => { - state.networkConfigurations[upsertedNetworkConfigurationId] = { - id: upsertedNetworkConfigurationId, - ...sanitizedNetworkConfiguration, - }; - }); + if ( + networkFields.rpcEndpoints.some( + (otherRpcEndpointFields) => + otherRpcEndpointFields !== rpcEndpointFields && + URI.equal(otherRpcEndpointFields.url, rpcEndpointFields.url), + ) + ) { + throw new Error( + `${errorMessagePrefix}: Each entry in rpcEndpoints must have a unique URL`, + ); + } - const customNetworkClientRegistry = - autoManagedNetworkClientRegistry[NetworkClientType.Custom]; - const existingAutoManagedNetworkClient = - customNetworkClientRegistry[networkClientId]; - const shouldDestroyExistingNetworkClient = - existingAutoManagedNetworkClient && - existingAutoManagedNetworkClient.configuration.chainId !== chainId; - if (shouldDestroyExistingNetworkClient) { - existingAutoManagedNetworkClient.destroy(); + const networkConfigurationsForOtherChains = Object.values( + this.state.networkConfigurationsByChainId, + ).filter((networkConfiguration) => + existingNetworkConfiguration + ? networkConfiguration.chainId !== + existingNetworkConfiguration.chainId + : true, + ); + for (const networkConfiguration of networkConfigurationsForOtherChains) { + const rpcEndpoint = networkConfiguration.rpcEndpoints.find( + (existingRpcEndpoint) => + URI.equal(rpcEndpointFields.url, existingRpcEndpoint.url), + ); + if (rpcEndpoint) { + if (mode === 'update') { + throw new Error( + `Could not update network to point to same RPC endpoint as existing network for chain ${networkConfiguration.chainId} ('${networkConfiguration.name}')`, + ); + } else { + throw new Error( + `Could not add network that points to same RPC endpoint as existing network for chain ${networkConfiguration.chainId} ('${networkConfiguration.name}')`, + ); + } + } + } } + if ( - !existingAutoManagedNetworkClient || - shouldDestroyExistingNetworkClient + [...new Set(networkFields.rpcEndpoints)].length < + networkFields.rpcEndpoints.length ) { - customNetworkClientRegistry[networkClientId] = - createAutoManagedNetworkClient({ - type: NetworkClientType.Custom, - chainId, - rpcUrl, - ticker, - }); + throw new Error( + `${errorMessagePrefix}: Each entry in rpcEndpoints must be unique`, + ); } - if (!existingNetworkConfiguration) { - this.#trackMetaMetricsEvent({ - event: 'Custom Network Added', - category: 'Network', - referrer: { - url: referrer, - }, - properties: { - chain_id: chainId, - symbol: ticker, - source, - }, - }); + const networkClientIds = networkFields.rpcEndpoints + .map((rpcEndpoint) => + 'networkClientId' in rpcEndpoint + ? rpcEndpoint.networkClientId + : undefined, + ) + .filter( + (networkClientId): networkClientId is NetworkClientId => + networkClientId !== undefined, + ); + if ([...new Set(networkClientIds)].length < networkClientIds.length) { + throw new Error( + `${errorMessagePrefix}: Each entry in rpcEndpoints must have a unique networkClientId`, + ); } - if (setActive) { - await this.setActiveNetwork(upsertedNetworkConfigurationId); + const infuraRpcEndpoints = networkFields.rpcEndpoints.filter( + (rpcEndpointFields): rpcEndpointFields is InfuraRpcEndpoint => + rpcEndpointFields.type === RpcEndpointType.Infura, + ); + if (infuraRpcEndpoints.length > 1) { + throw new Error( + `${errorMessagePrefix}: There cannot be more than one Infura RPC endpoint`, + ); } - return upsertedNetworkConfigurationId; - } - - /** - * Removes a custom network from state. - * - * This involves updating the `networkConfigurations` property in state as - * well and removing the network client that corresponds to the network from - * the client registry. - * - * @param networkConfigurationId - The ID of an existing network - * configuration. - */ - removeNetworkConfiguration(networkConfigurationId: string) { - if (!this.state.networkConfigurations[networkConfigurationId]) { + if ( + networkFields.rpcEndpoints[networkFields.defaultRpcEndpointIndex] === + undefined + ) { throw new Error( - `networkConfigurationId ${networkConfigurationId} does not match a configured networkConfiguration`, + `${errorMessagePrefix}: \`defaultRpcEndpointIndex\` must refer to an entry in \`rpcEndpoints\``, ); } - - const autoManagedNetworkClientRegistry = - this.#ensureAutoManagedNetworkClientRegistryPopulated(); - const networkClientId = buildCustomNetworkClientId(networkConfigurationId); - - this.update((state) => { - delete state.networkConfigurations[networkConfigurationId]; - }); - - const customNetworkClientRegistry = - autoManagedNetworkClientRegistry[NetworkClientType.Custom]; - const existingAutoManagedNetworkClient = - customNetworkClientRegistry[networkClientId]; - existingAutoManagedNetworkClient.destroy(); - delete customNetworkClientRegistry[networkClientId]; } /** - * Switches to the previously selected network, assuming that there is one - * (if not and `initializeProvider` has not been previously called, then this - * method is equivalent to calling `resetConnection`). + * Constructs a network configuration that will be persisted to state when + * adding or updating a network. + * + * @param args - The arguments to this function. + * @param args.networkFields - The fields used to add or update a network. + * @param args.networkClientOperations - Operations which were calculated for + * updating the network client registry but which also map back to RPC + * endpoints (and so can be used to save those RPC endpoints). + * @returns The network configuration to persist. */ - async rollbackToPreviousProvider() { - this.#ensureAutoManagedNetworkClientRegistryPopulated(); - - this.update((state) => { - state.providerConfig = this.#previousProviderConfig; - }); + #determineNetworkConfigurationToPersist({ + networkFields, + networkClientOperations, + }: { + networkFields: AddNetworkFields | UpdateNetworkFields; + networkClientOperations: NetworkClientOperation[]; + }): NetworkConfiguration { + const rpcEndpointsToPersist = networkClientOperations + .filter( + ( + networkClientOperation, + ): networkClientOperation is + | AddNetworkClientOperation + | NoopNetworkClientOperation => { + return ( + networkClientOperation.type === 'add' || + networkClientOperation.type === 'noop' + ); + }, + ) + .map((networkClientOperation) => networkClientOperation.rpcEndpoint) + .concat( + networkClientOperations + .filter( + ( + networkClientOperation, + ): networkClientOperation is ReplaceNetworkClientOperation => { + return networkClientOperation.type === 'replace'; + }, + ) + .map( + (networkClientOperation) => networkClientOperation.newRpcEndpoint, + ), + ); - await this.#refreshNetwork(); + return { ...networkFields, rpcEndpoints: rpcEndpointsToPersist }; } /** - * Deactivates the controller, stopping any ongoing polling. + * Creates and registers network clients using the given operations calculated + * as a part of adding or updating a network. * - * In-progress requests will not be aborted. + * @param args - The arguments to this function. + * @param args.networkFields - The fields used to add or update a network. + * @param args.networkClientOperations - Dictate which network clients need to + * be created. + * @param args.autoManagedNetworkClientRegistry - The network client registry + * to update. */ - async destroy() { - await this.#blockTrackerProxy?.destroy(); + #registerNetworkClientsAsNeeded({ + networkFields, + networkClientOperations, + autoManagedNetworkClientRegistry, + }: { + networkFields: AddNetworkFields | UpdateNetworkFields; + networkClientOperations: NetworkClientOperation[]; + autoManagedNetworkClientRegistry: AutoManagedNetworkClientRegistry; + }): void { + const addedRpcEndpoints = networkClientOperations + .filter( + ( + networkClientOperation, + ): networkClientOperation is AddNetworkClientOperation => { + return networkClientOperation.type === 'add'; + }, + ) + .map((networkClientOperation) => networkClientOperation.rpcEndpoint) + .concat( + networkClientOperations + .filter( + ( + networkClientOperation, + ): networkClientOperation is ReplaceNetworkClientOperation => { + return networkClientOperation.type === 'replace'; + }, + ) + .map( + (networkClientOperation) => networkClientOperation.newRpcEndpoint, + ), + ); + + const defaultFailoverUrls = this.#failoverUrls?.[networkFields.chainId]; + for (const addedRpcEndpoint of addedRpcEndpoints) { + if (addedRpcEndpoint.type === RpcEndpointType.Infura) { + autoManagedNetworkClientRegistry[NetworkClientType.Infura][ + addedRpcEndpoint.networkClientId + ] = createAutoManagedNetworkClient({ + networkClientId: addedRpcEndpoint.networkClientId, + networkClientConfiguration: { + type: NetworkClientType.Infura, + chainId: networkFields.chainId, + network: addedRpcEndpoint.networkClientId, + failoverRpcUrls: + defaultFailoverUrls ?? addedRpcEndpoint.failoverUrls, + infuraProjectId: this.#infuraProjectId, + ticker: networkFields.nativeCurrency, + }, + getRpcServiceOptions: this.#getRpcServiceOptions, + getBlockTrackerOptions: this.#getBlockTrackerOptions, + messenger: this.messenger, + rpcFailoverMode: this.#rpcFailoverMode, + logger: this.#log, + }); + } else { + autoManagedNetworkClientRegistry[NetworkClientType.Custom][ + addedRpcEndpoint.networkClientId + ] = createAutoManagedNetworkClient({ + networkClientId: addedRpcEndpoint.networkClientId, + networkClientConfiguration: { + type: NetworkClientType.Custom, + chainId: networkFields.chainId, + failoverRpcUrls: + defaultFailoverUrls ?? addedRpcEndpoint.failoverUrls, + rpcUrl: addedRpcEndpoint.url, + ticker: networkFields.nativeCurrency, + }, + getRpcServiceOptions: this.#getRpcServiceOptions, + getBlockTrackerOptions: this.#getBlockTrackerOptions, + messenger: this.messenger, + rpcFailoverMode: this.#rpcFailoverMode, + logger: this.#log, + }); + } + } } /** - * Updates the controller using the given backup data. + * Destroys and removes network clients using the given operations calculated + * as a part of updating or removing a network. * - * @param backup - The data that has been backed up. - * @param backup.networkConfigurations - Network configurations in the backup. + * @param args - The arguments to this function. + * @param args.networkClientOperations - Dictate which network clients to + * remove. + * @param args.autoManagedNetworkClientRegistry - The network client registry + * to update. */ - loadBackup({ - networkConfigurations, + #unregisterNetworkClientsAsNeeded({ + networkClientOperations, + autoManagedNetworkClientRegistry, }: { - networkConfigurations: NetworkState['networkConfigurations']; + networkClientOperations: NetworkClientOperation[]; + autoManagedNetworkClientRegistry: AutoManagedNetworkClientRegistry; }): void { - this.update((state) => { - state.networkConfigurations = { - ...state.networkConfigurations, - ...networkConfigurations, - }; - }); + const removedRpcEndpoints = networkClientOperations + .filter( + ( + networkClientOperation, + ): networkClientOperation is RemoveNetworkClientOperation => { + return networkClientOperation.type === 'remove'; + }, + ) + .map((networkClientOperation) => networkClientOperation.rpcEndpoint) + .concat( + networkClientOperations + .filter( + ( + networkClientOperation, + ): networkClientOperation is ReplaceNetworkClientOperation => { + return networkClientOperation.type === 'replace'; + }, + ) + .map( + (networkClientOperation) => networkClientOperation.oldRpcEndpoint, + ), + ); + + for (const rpcEndpoint of removedRpcEndpoints) { + const networkClient = this.getNetworkClientById( + rpcEndpoint.networkClientId, + ); + networkClient.destroy(); + delete autoManagedNetworkClientRegistry[networkClient.configuration.type][ + rpcEndpoint.networkClientId + ]; + } } /** - * Searches for a network configuration ID with the given ChainID and returns it. + * Updates `networkConfigurationsByChainId` in state depending on whether a + * network is being added, updated, or removed. + * + * - The existing network configuration will be removed when a network is + * being filed under a different chain or removed. + * - A network configuration will be stored when a network is being added or + * when a network is being updated. * - * @param chainId - ChainId to search for - * @returns networkClientId of the network configuration with the given chainId + * @param args - The arguments to this function. */ - findNetworkClientIdByChainId(chainId: Hex): NetworkClientId { - const networkClients = this.getNetworkClientRegistry(); - const networkClientEntry = Object.entries(networkClients).find( - ([_, networkClient]) => networkClient.configuration.chainId === chainId, - ); - if (networkClientEntry === undefined) { - throw new Error("Couldn't find networkClientId for chainId"); + #updateNetworkConfigurations( + args: { state: Draft } & ( + | { + mode: 'add'; + networkFields: AddNetworkFields; + networkConfigurationToPersist: NetworkConfiguration; + } + | { + mode: 'update'; + networkFields: UpdateNetworkFields; + networkConfigurationToPersist: NetworkConfiguration; + existingNetworkConfiguration: NetworkConfiguration; + } + | { + mode: 'remove'; + existingNetworkConfiguration: NetworkConfiguration; + } + ), + ): void { + const { state, mode } = args; + + if ( + mode === 'remove' || + (mode === 'update' && + args.networkFields.chainId !== + args.existingNetworkConfiguration.chainId) + ) { + delete state.networkConfigurationsByChainId[ + args.existingNetworkConfiguration.chainId + ]; + } + + if (mode === 'add' || mode === 'update') { + if ( + !deepEqual( + state.networkConfigurationsByChainId[args.networkFields.chainId], + args.networkConfigurationToPersist, + ) + ) { + args.networkConfigurationToPersist.lastUpdatedAt = Date.now(); + } + state.networkConfigurationsByChainId[args.networkFields.chainId] = + args.networkConfigurationToPersist; } - return networkClientEntry[0]; + + this.#networkConfigurationsByNetworkClientId = + buildNetworkConfigurationsByNetworkClientId( + cloneDeep(state.networkConfigurationsByChainId), + ); } /** @@ -1318,239 +3015,212 @@ export class NetworkController extends BaseControllerV2< * @returns The populated network client registry. */ #ensureAutoManagedNetworkClientRegistryPopulated(): AutoManagedNetworkClientRegistry { - const autoManagedNetworkClientRegistry = - this.#autoManagedNetworkClientRegistry ?? - this.#createAutoManagedNetworkClientRegistry(); - this.#autoManagedNetworkClientRegistry = autoManagedNetworkClientRegistry; - return autoManagedNetworkClientRegistry; + return (this.#autoManagedNetworkClientRegistry ??= + this.#createAutoManagedNetworkClientRegistry()); } /** - * Constructs the registry of network clients based on the set of built-in - * networks as well as the custom networks in state. + * Constructs the registry of network clients based on the set of default + * and custom networks in state. * * @returns The network clients keyed by ID. */ #createAutoManagedNetworkClientRegistry(): AutoManagedNetworkClientRegistry { - return [ - ...this.#buildIdentifiedInfuraNetworkClientConfigurations(), - ...this.#buildIdentifiedCustomNetworkClientConfigurations(), - ...this.#buildIdentifiedNetworkClientConfigurationsFromProviderConfig(), - ].reduce( + const chainIds = knownKeysOf(this.state.networkConfigurationsByChainId); + const networkClientsWithIds = chainIds.flatMap((chainId) => { + const networkConfiguration = + this.state.networkConfigurationsByChainId[chainId]; + const defaultFailoverUrls = this.#failoverUrls?.[chainId]; + return networkConfiguration.rpcEndpoints.map((rpcEndpoint) => { + if (rpcEndpoint.type === RpcEndpointType.Infura) { + const infuraNetworkName = deriveInfuraNetworkNameFromRpcEndpointUrl( + rpcEndpoint.url, + ); + return [ + rpcEndpoint.networkClientId, + createAutoManagedNetworkClient({ + networkClientId: rpcEndpoint.networkClientId, + networkClientConfiguration: { + type: NetworkClientType.Infura, + network: infuraNetworkName, + failoverRpcUrls: + defaultFailoverUrls ?? rpcEndpoint.failoverUrls, + infuraProjectId: this.#infuraProjectId, + chainId: networkConfiguration.chainId, + ticker: networkConfiguration.nativeCurrency, + }, + getRpcServiceOptions: this.#getRpcServiceOptions, + getBlockTrackerOptions: this.#getBlockTrackerOptions, + messenger: this.messenger, + rpcFailoverMode: this.#rpcFailoverMode, + logger: this.#log, + }), + ] as const; + } + return [ + rpcEndpoint.networkClientId, + createAutoManagedNetworkClient({ + networkClientId: rpcEndpoint.networkClientId, + networkClientConfiguration: { + type: NetworkClientType.Custom, + chainId: networkConfiguration.chainId, + failoverRpcUrls: defaultFailoverUrls ?? rpcEndpoint.failoverUrls, + rpcUrl: rpcEndpoint.url, + ticker: networkConfiguration.nativeCurrency, + }, + getRpcServiceOptions: this.#getRpcServiceOptions, + getBlockTrackerOptions: this.#getBlockTrackerOptions, + messenger: this.messenger, + rpcFailoverMode: this.#rpcFailoverMode, + logger: this.#log, + }), + ] as const; + }); + }); + + return networkClientsWithIds.reduce( ( - registry, - [networkClientType, networkClientId, networkClientConfiguration], + obj: { + [NetworkClientType.Custom]: Partial; + [NetworkClientType.Infura]: Partial; + }, + [networkClientId, networkClient], ) => { - const autoManagedNetworkClient = createAutoManagedNetworkClient( - networkClientConfiguration, - ); - if (networkClientId in registry[networkClientType]) { - return registry; - } return { - ...registry, - [networkClientType]: { - ...registry[networkClientType], - [networkClientId]: autoManagedNetworkClient, + ...obj, + [networkClient.configuration.type]: { + ...obj[networkClient.configuration.type], + [networkClientId]: networkClient, }, }; }, { - [NetworkClientType.Infura]: {}, [NetworkClientType.Custom]: {}, + [NetworkClientType.Infura]: {}, }, ) as AutoManagedNetworkClientRegistry; } /** - * Constructs the list of network clients for built-in networks (that is, - * the subset of the networks we know Infura supports that consumers do not - * need to explicitly add). + * Updates the global provider and block tracker proxies (accessible via + * {@link getSelectedNetworkClient}) to point to the same ones within the + * given network client, thereby magically switching any consumers using these + * proxies to use the new network. + * + * Also refreshes the EthQuery instance accessible via the `getEthQuery` + * action to wrap the provider from the new network client. Note that this is + * not a proxy, so consumers will need to call `getEthQuery` again after the + * network switch. * - * @returns The network clients. - */ - #buildIdentifiedInfuraNetworkClientConfigurations(): [ - NetworkClientType.Infura, - BuiltInNetworkClientId, - InfuraNetworkClientConfiguration, - ][] { - return knownKeysOf(InfuraNetworkType).map((network) => { - const networkClientId = buildInfuraNetworkClientId(network); - const networkClientConfiguration: InfuraNetworkClientConfiguration = { - type: NetworkClientType.Infura, - network, - infuraProjectId: this.#infuraProjectId, - chainId: BUILT_IN_NETWORKS[network].chainId, - ticker: BUILT_IN_NETWORKS[network].ticker, + * @param networkClientId - The ID of a network client that requests will be + * routed through (either the name of an Infura network or the ID of a custom + * network configuration). + * @param options - Options for this method. + * @param options.updateState - Allows for updating state. + * @throws if no network client could be found matching the given ID. + */ + #applyNetworkSelection( + networkClientId: string, + { + updateState, + }: { + updateState?: (state: Draft) => void; + } = {}, + ): void { + this.#autoManagedNetworkClient = this.getNetworkClientById(networkClientId); + + this.update((state) => { + state.selectedNetworkClientId = networkClientId; + state.networksMetadata[networkClientId] ??= { + status: NetworkStatus.Unknown, + EIPS: {}, }; - return [ - NetworkClientType.Infura, - networkClientId, - networkClientConfiguration, - ]; - }); - } - /** - * Constructs the list of network clients for custom networks (that is, those - * which consumers have added via `networkConfigurations`). - * - * @returns The network clients. - */ - #buildIdentifiedCustomNetworkClientConfigurations(): [ - NetworkClientType.Custom, - CustomNetworkClientId, - CustomNetworkClientConfiguration, - ][] { - return Object.entries(this.state.networkConfigurations).map( - ([networkConfigurationId, networkConfiguration]) => { - if (networkConfiguration.chainId === undefined) { - throw new Error('chainId must be provided for custom RPC endpoints'); - } - if (networkConfiguration.rpcUrl === undefined) { - throw new Error('rpcUrl must be provided for custom RPC endpoints'); - } - const networkClientId = buildCustomNetworkClientId( - networkConfigurationId, - ); - const networkClientConfiguration: CustomNetworkClientConfiguration = { - type: NetworkClientType.Custom, - chainId: networkConfiguration.chainId, - rpcUrl: networkConfiguration.rpcUrl, - ticker: networkConfiguration.ticker, - }; - return [ - NetworkClientType.Custom, - networkClientId, - networkClientConfiguration, - ]; - }, - ); - } + updateState?.(state); + }); - /** - * Converts the provider config object in state to a network client - * configuration object. - * - * @returns The network client config. - * @throws If the provider config is of type "rpc" and lacks either a - * `chainId` or an `rpcUrl`. - */ - #buildIdentifiedNetworkClientConfigurationsFromProviderConfig(): - | [ - [ - NetworkClientType.Custom, - CustomNetworkClientId, - CustomNetworkClientConfiguration, - ], - ] - | [] { - const { providerConfig } = this.state; - - if (isCustomProviderConfig(providerConfig)) { - validateCustomProviderConfig(providerConfig); - const networkClientId = buildCustomNetworkClientId( - providerConfig, - this.state.networkConfigurations, + if (this.#providerProxy) { + this.#providerProxy.setTarget(this.#autoManagedNetworkClient.provider); + } else { + this.#providerProxy = createSwappableProxy( + this.#autoManagedNetworkClient.provider, ); - const networkClientConfiguration: CustomNetworkClientConfiguration = { - chainId: providerConfig.chainId, - rpcUrl: providerConfig.rpcUrl, - type: NetworkClientType.Custom, - ticker: providerConfig.ticker, - }; - return [ - [NetworkClientType.Custom, networkClientId, networkClientConfiguration], - ]; } - if (isInfuraProviderConfig(providerConfig)) { - return []; + if (this.#blockTrackerProxy) { + this.#blockTrackerProxy.setTarget( + this.#autoManagedNetworkClient.blockTracker, + ); + } else { + this.#blockTrackerProxy = createEventEmitterProxy( + this.#autoManagedNetworkClient.blockTracker, + { + eventFilter: 'skipInternal', + }, + ); } - throw new Error(`Unrecognized network type: '${providerConfig.type}'`); + this.#ethQuery = new EthQuery(this.#providerProxy); } /** - * Uses the information in the provider config object to look up a known and - * preinitialized network client. Once a network client is found, updates the - * provider and block tracker proxy to point to those from the network client, - * then finally creates an EthQuery that points to the provider proxy. + * Adds networks to state and registers network clients for the given CAIP-2 + * chain IDs if they are not already present. Configurations for these + * networks are retrieved from ConfigRegistryController. * - * @throws If no network client could be found matching the current provider - * config. + * @param caipChainIds - The CAIP-2 chain IDs of the networks to enable. */ - #applyNetworkSelection() { - if (!this.#autoManagedNetworkClientRegistry) { - throw new Error( - 'initializeProvider must be called first in order to switch the network', - ); - } - - const { providerConfig } = this.state; - - let autoManagedNetworkClient: AutoManagedNetworkClient; - - let networkClientId: NetworkClientId; - if (isInfuraProviderConfig(providerConfig)) { - const networkClientType = NetworkClientType.Infura; - networkClientId = buildInfuraNetworkClientId(providerConfig); - const builtInNetworkClientRegistry = - this.#autoManagedNetworkClientRegistry[networkClientType]; - autoManagedNetworkClient = - builtInNetworkClientRegistry[networkClientId as BuiltInNetworkClientId]; - if (!autoManagedNetworkClient) { - throw new Error( - `Could not find custom network matching ${networkClientId}`, + #autoAddNetworksFromConfigRegistry(caipChainIds: CaipChainId[]): void { + for (const caipChainId of caipChainIds) { + try { + const registryNetworkConfig = this.messenger.call( + 'ConfigRegistryController:getNetworkConfigByCaip2ChainId', + caipChainId, ); - } - } else if (isCustomProviderConfig(providerConfig)) { - validateCustomProviderConfig(providerConfig); - const networkClientType = NetworkClientType.Custom; - networkClientId = buildCustomNetworkClientId( - providerConfig, - this.state.networkConfigurations, - ); - const customNetworkClientRegistry = - this.#autoManagedNetworkClientRegistry[networkClientType]; - autoManagedNetworkClient = customNetworkClientRegistry[networkClientId]; - if (!autoManagedNetworkClient) { - throw new Error( - `Could not find built-in network matching ${networkClientId}`, + const hexChainId = numberToHex( + Number(parseCaipChainId(caipChainId).reference), ); - } - } else { - throw new Error('Could not determine type of provider config'); - } - - this.update((state) => { - state.selectedNetworkClientId = networkClientId; - if (state.networksMetadata[networkClientId] === undefined) { - state.networksMetadata[networkClientId] = { - status: NetworkStatus.Unknown, - EIPS: {}, - }; - } - }); - - const { provider, blockTracker } = autoManagedNetworkClient; - if (this.#providerProxy) { - this.#providerProxy.setTarget(provider); - } else { - this.#providerProxy = createEventEmitterProxy(provider); - } - this.#provider = provider; + if ( + !registryNetworkConfig || + this.state.networkConfigurationsByChainId[hexChainId] + ) { + continue; + } - if (this.#blockTrackerProxy) { - this.#blockTrackerProxy.setTarget(blockTracker); - } else { - this.#blockTrackerProxy = createEventEmitterProxy(blockTracker, { - eventFilter: 'skipInternal', - }); + const rpcEndpoint: + | InfuraRpcEndpoint + | AddNetworkCustomRpcEndpointFields = + registryNetworkConfig.rpcProviders.default.type === 'infura' + ? { + type: RpcEndpointType.Infura, + networkClientId: + registryNetworkConfig.rpcProviders.default.networkClientId, + url: registryNetworkConfig.rpcProviders.default + .url as InfuraRpcEndpoint['url'], + } + : { + type: RpcEndpointType.Custom, + url: registryNetworkConfig.rpcProviders.default.url, + }; + + this.addNetwork({ + chainId: hexChainId, + name: registryNetworkConfig.name, + nativeCurrency: registryNetworkConfig.assets.native.symbol, + rpcEndpoints: [rpcEndpoint], + defaultRpcEndpointIndex: 0, + blockExplorerUrls: [registryNetworkConfig.blockExplorerUrls.default], + defaultBlockExplorerUrlIndex: 0, + }); + } catch (error) { + const capturedError = + error instanceof Error ? error : new Error(String(error)); + this.#log?.error( + `Failed to auto-enable network for chain ID ${caipChainId}: ${capturedError}`, + ); + this.messenger.captureException?.(capturedError); + } } - - // @ts-expect-error TODO: Provider type alignment - this.#ethQuery = new EthQuery(this.#providerProxy); } } diff --git a/packages/network-controller/src/constants.ts b/packages/network-controller/src/constants.ts index bcabe0a72fa..1708cc2ebf6 100644 --- a/packages/network-controller/src/constants.ts +++ b/packages/network-controller/src/constants.ts @@ -1,28 +1,43 @@ /** - * Represents the availability state of the currently selected network. + * Represents the availability status of an RPC endpoint. (Regrettably, the + * name of this type is a misnomer.) + * + * The availability status is set both automatically (as requests are made) and + * manually (when `lookupNetwork` is called). */ export enum NetworkStatus { /** - * The network may or may not be able to receive requests, but either no - * attempt has been made to determine this, or an attempt was made but was - * unsuccessful. + * Either the availability status of the RPC endpoint has not been determined, + * or request that `lookupNetwork` performed returned an unknown error. */ Unknown = 'unknown', /** - * The network is able to receive and respond to requests. + * The RPC endpoint is consistently returning successful (2xx) responses. */ Available = 'available', /** - * The network was unable to receive and respond to requests for unknown - * reasons. + * Either the last request to the RPC endpoint was either too slow, or the + * endpoint is consistently returning errors and the number of retries has + * been reached. + */ + Degraded = 'degraded', + /** + * The RPC endpoint is consistently returning enough 5xx errors that requests + * have been paused. */ Unavailable = 'unavailable', /** - * The network is not only unavailable, but is also inaccessible for the user - * specifically based on their location. This state only applies to Infura - * networks. + * The RPC endpoint is inaccessible for the user based on their location. This + * status only applies to Infura networks. */ Blocked = 'blocked', } export const INFURA_BLOCKED_KEY = 'countryBlocked'; + +/** + * A set of deprecated network ChainId. + * The network controller will exclude those the networks begin as default network, + * without the need to remove the network from constant list of controller-utils. + */ +export const DEPRECATED_NETWORKS = new Set(['0xe704', '0x5']); diff --git a/packages/network-controller/src/create-auto-managed-network-client.test.ts b/packages/network-controller/src/create-auto-managed-network-client.test.ts index 8e5eedda61b..8f040abab29 100644 --- a/packages/network-controller/src/create-auto-managed-network-client.test.ts +++ b/packages/network-controller/src/create-auto-managed-network-client.test.ts @@ -1,14 +1,16 @@ import { BUILT_IN_NETWORKS, NetworkType } from '@metamask/controller-utils'; -import { promisify } from 'util'; +import { PollingBlockTrackerOptions } from '@metamask/eth-block-tracker'; -import { mockNetwork } from '../../../tests/mock-network'; -import { createAutoManagedNetworkClient } from './create-auto-managed-network-client'; -import * as createNetworkClientModule from './create-network-client'; +import { mockNetwork } from '../../../tests/mock-network.js'; +import { buildNetworkControllerMessenger } from '../tests/helpers.js'; +import { createAutoManagedNetworkClient } from './create-auto-managed-network-client.js'; +import * as createNetworkClientModule from './create-network-client.js'; +import { RpcServiceOptions } from './rpc-service/rpc-service.js'; import type { CustomNetworkClientConfiguration, InfuraNetworkClientConfiguration, -} from './types'; -import { NetworkClientType } from './types'; +} from './types.js'; +import { NetworkClientType } from './types.js'; describe('createAutoManagedNetworkClient', () => { const networkClientConfigurations: [ @@ -17,24 +19,34 @@ describe('createAutoManagedNetworkClient', () => { ] = [ { type: NetworkClientType.Custom, + failoverRpcUrls: [], rpcUrl: 'https://test.chain', chainId: '0x1337', ticker: 'ETH', - } as const, + }, { type: NetworkClientType.Infura, network: NetworkType.mainnet, chainId: BUILT_IN_NETWORKS[NetworkType.mainnet].chainId, infuraProjectId: 'some-infura-project-id', ticker: BUILT_IN_NETWORKS[NetworkType.mainnet].ticker, - } as const, + failoverRpcUrls: [], + }, ]; for (const networkClientConfiguration of networkClientConfigurations) { describe(`given configuration for a ${networkClientConfiguration.type} network client`, () => { it('allows the network client configuration to be accessed', () => { - const { configuration } = createAutoManagedNetworkClient( + const { configuration } = createAutoManagedNetworkClient({ + networkClientId: 'some-network-client-id', networkClientConfiguration, - ); + getRpcServiceOptions: () => ({ + fetch, + btoa, + isOffline: (): boolean => false, + }), + messenger: buildNetworkControllerMessenger(), + rpcFailoverMode: 'disabled', + }); expect(configuration).toStrictEqual(networkClientConfiguration); }); @@ -42,111 +54,305 @@ describe('createAutoManagedNetworkClient', () => { it('does not make any network requests initially', () => { // If unexpected requests occurred, then Nock would throw expect(() => { - createAutoManagedNetworkClient(networkClientConfiguration); + createAutoManagedNetworkClient({ + networkClientId: 'some-network-client-id', + networkClientConfiguration, + getRpcServiceOptions: () => ({ + fetch, + btoa, + isOffline: (): boolean => false, + }), + messenger: buildNetworkControllerMessenger(), + rpcFailoverMode: 'disabled', + }); }).not.toThrow(); }); it('returns a provider proxy that has the same interface as a provider', () => { - const { provider } = createAutoManagedNetworkClient( + const { provider } = createAutoManagedNetworkClient({ + networkClientId: 'some-network-client-id', networkClientConfiguration, - ); + getRpcServiceOptions: () => ({ + fetch, + btoa, + isOffline: (): boolean => false, + }), + messenger: buildNetworkControllerMessenger(), + rpcFailoverMode: 'disabled', + }); // This also tests the `has` trap in the proxy - expect('addListener' in provider).toBe(true); - expect('on' in provider).toBe(true); - expect('once' in provider).toBe(true); - expect('removeListener' in provider).toBe(true); - expect('off' in provider).toBe(true); - expect('removeAllListeners' in provider).toBe(true); - expect('setMaxListeners' in provider).toBe(true); - expect('getMaxListeners' in provider).toBe(true); - expect('listeners' in provider).toBe(true); - expect('rawListeners' in provider).toBe(true); - expect('emit' in provider).toBe(true); - expect('listenerCount' in provider).toBe(true); - expect('prependListener' in provider).toBe(true); - expect('prependOnceListener' in provider).toBe(true); - expect('eventNames' in provider).toBe(true); expect('send' in provider).toBe(true); expect('sendAsync' in provider).toBe(true); + expect('request' in provider).toBe(true); }); - it('returns a provider proxy that acts like a provider, forwarding requests to the network', async () => { - mockNetwork({ - networkClientConfiguration, - mocks: [ - { - request: { - method: 'test_method', - params: [], + describe('when accessing the provider proxy', () => { + it('forwards requests to the network', async () => { + mockNetwork({ + networkClientConfiguration, + mocks: [ + { + request: { + method: 'test_method', + params: [], + }, + response: { + result: 'test response', + }, }, - response: { - result: 'test response', - }, - }, - ], - }); + ], + }); - const { provider } = createAutoManagedNetworkClient( - networkClientConfiguration, - ); + const { provider } = createAutoManagedNetworkClient({ + networkClientId: 'some-network-client-id', + networkClientConfiguration, + getRpcServiceOptions: () => ({ + fetch, + btoa, + isOffline: (): boolean => false, + }), + messenger: buildNetworkControllerMessenger(), + rpcFailoverMode: 'disabled', + }); - const { result } = await promisify(provider.sendAsync).call(provider, { - id: 1, - jsonrpc: '2.0', - method: 'test_method', - params: [], + const result = await provider.request({ + id: 1, + jsonrpc: '2.0', + method: 'test_method', + params: [], + }); + expect(result).toBe('test response'); }); - expect(result).toBe('test response'); - }); - it('creates the network client only once, even when the provider proxy is used to make requests multiple times', async () => { - mockNetwork({ - networkClientConfiguration, - mocks: [ - { - request: { - method: 'test_method', - params: [], + it('creates the network client only once, even when the provider proxy is used to make requests multiple times', async () => { + mockNetwork({ + networkClientConfiguration, + mocks: [ + { + request: { + method: 'test_method', + params: [], + }, + response: { + result: 'test response', + }, + discardAfterMatching: false, }, - response: { - result: 'test response', - }, - discardAfterMatching: false, - }, - ], + ], + }); + const createNetworkClientMock = jest.spyOn( + createNetworkClientModule, + 'createNetworkClient', + ); + const getRpcServiceOptions = (): Omit< + RpcServiceOptions, + 'failoverService' | 'endpointUrl' + > => ({ + btoa, + fetch, + isOffline: (): boolean => false, + }); + const getBlockTrackerOptions = (): PollingBlockTrackerOptions => ({ + pollingInterval: 5000, + }); + const messenger = buildNetworkControllerMessenger(); + + const { provider } = createAutoManagedNetworkClient({ + networkClientId: 'some-network-client-id', + networkClientConfiguration, + getRpcServiceOptions, + getBlockTrackerOptions, + messenger, + rpcFailoverMode: 'enabled', + }); + + await provider.request({ + id: 1, + jsonrpc: '2.0', + method: 'test_method', + params: [], + }); + await provider.request({ + id: 2, + jsonrpc: '2.0', + method: 'test_method', + params: [], + }); + expect(createNetworkClientMock).toHaveBeenCalledTimes(1); + expect(createNetworkClientMock).toHaveBeenCalledWith({ + id: 'some-network-client-id', + configuration: networkClientConfiguration, + getRpcServiceOptions, + getBlockTrackerOptions, + messenger, + rpcFailoverMode: 'enabled', + }); }); - const createNetworkClientMock = jest.spyOn( - createNetworkClientModule, - 'createNetworkClient', - ); - const { provider } = createAutoManagedNetworkClient( - networkClientConfiguration, - ); + it('allows setting the RPC failover mode to enabled, even after having already accessed the provider', async () => { + mockNetwork({ + networkClientConfiguration, + mocks: [ + { + request: { + method: 'test_method', + params: [], + }, + response: { + result: 'test response', + }, + discardAfterMatching: false, + }, + ], + }); + const createNetworkClientMock = jest.spyOn( + createNetworkClientModule, + 'createNetworkClient', + ); + const getRpcServiceOptions = (): Omit< + RpcServiceOptions, + 'failoverService' | 'endpointUrl' + > => ({ + btoa, + fetch, + isOffline: (): boolean => false, + }); + const getBlockTrackerOptions = (): PollingBlockTrackerOptions => ({ + pollingInterval: 5000, + }); + const messenger = buildNetworkControllerMessenger(); - await promisify(provider.sendAsync).call(provider, { - id: 1, - jsonrpc: '2.0', - method: 'test_method', - params: [], + const autoManagedNetworkClient = createAutoManagedNetworkClient({ + networkClientId: 'some-network-client-id', + networkClientConfiguration, + getRpcServiceOptions, + getBlockTrackerOptions, + messenger, + rpcFailoverMode: 'disabled', + }); + const { provider } = autoManagedNetworkClient; + + await provider.request({ + id: 1, + jsonrpc: '2.0', + method: 'test_method', + params: [], + }); + autoManagedNetworkClient.setRpcFailoverMode('enabled'); + await provider.request({ + id: 1, + jsonrpc: '2.0', + method: 'test_method', + params: [], + }); + + expect(createNetworkClientMock).toHaveBeenNthCalledWith(1, { + id: 'some-network-client-id', + configuration: networkClientConfiguration, + getRpcServiceOptions, + getBlockTrackerOptions, + messenger, + rpcFailoverMode: 'disabled', + }); + expect(createNetworkClientMock).toHaveBeenNthCalledWith(2, { + id: 'some-network-client-id', + configuration: networkClientConfiguration, + getRpcServiceOptions, + getBlockTrackerOptions, + messenger, + rpcFailoverMode: 'enabled', + }); }); - await promisify(provider.sendAsync).call(provider, { - id: 2, - jsonrpc: '2.0', - method: 'test_method', - params: [], + + it('allows setting the RPC failover mode to disabled, even after having accessed the provider', async () => { + mockNetwork({ + networkClientConfiguration, + mocks: [ + { + request: { + method: 'test_method', + params: [], + }, + response: { + result: 'test response', + }, + discardAfterMatching: false, + }, + ], + }); + const createNetworkClientMock = jest.spyOn( + createNetworkClientModule, + 'createNetworkClient', + ); + const getRpcServiceOptions = (): Omit< + RpcServiceOptions, + 'failoverService' | 'endpointUrl' + > => ({ + btoa, + fetch, + isOffline: (): boolean => false, + }); + const getBlockTrackerOptions = (): PollingBlockTrackerOptions => ({ + pollingInterval: 5000, + }); + const messenger = buildNetworkControllerMessenger(); + + const autoManagedNetworkClient = createAutoManagedNetworkClient({ + networkClientId: 'some-network-client-id', + networkClientConfiguration, + getRpcServiceOptions, + getBlockTrackerOptions, + messenger, + rpcFailoverMode: 'enabled', + }); + const { provider } = autoManagedNetworkClient; + + await provider.request({ + id: 1, + jsonrpc: '2.0', + method: 'test_method', + params: [], + }); + autoManagedNetworkClient.setRpcFailoverMode('disabled'); + await provider.request({ + id: 1, + jsonrpc: '2.0', + method: 'test_method', + params: [], + }); + + expect(createNetworkClientMock).toHaveBeenNthCalledWith(1, { + id: 'some-network-client-id', + configuration: networkClientConfiguration, + getRpcServiceOptions, + getBlockTrackerOptions, + messenger, + rpcFailoverMode: 'enabled', + }); + expect(createNetworkClientMock).toHaveBeenNthCalledWith(2, { + id: 'some-network-client-id', + configuration: networkClientConfiguration, + getRpcServiceOptions, + getBlockTrackerOptions, + messenger, + rpcFailoverMode: 'disabled', + }); }); - expect(createNetworkClientMock).toHaveBeenCalledTimes(1); - expect(createNetworkClientMock).toHaveBeenCalledWith( - networkClientConfiguration, - ); }); it('returns a block tracker proxy that has the same interface as a block tracker', () => { - const { blockTracker } = createAutoManagedNetworkClient( + const { blockTracker } = createAutoManagedNetworkClient({ + networkClientId: 'some-network-client-id', networkClientConfiguration, - ); + getRpcServiceOptions: () => ({ + fetch, + btoa, + isOffline: (): boolean => false, + }), + messenger: buildNetworkControllerMessenger(), + rpcFailoverMode: 'disabled', + }); // This also tests the `has` trap in the proxy expect('addListener' in blockTracker).toBe(true); @@ -171,131 +377,461 @@ describe('createAutoManagedNetworkClient', () => { expect('checkForLatestBlock' in blockTracker).toBe(true); }); - it('returns a block tracker proxy that acts like a block tracker, exposing events to be listened to', async () => { - mockNetwork({ - networkClientConfiguration, - mocks: [ - { - request: { - method: 'eth_blockNumber', - params: [], + describe('when accessing the block tracker proxy', () => { + it('exposes events to be listened to', async () => { + mockNetwork({ + networkClientConfiguration, + mocks: [ + { + request: { + method: 'eth_blockNumber', + params: [], + }, + response: { + result: '0x1', + }, }, - response: { - result: '0x1', + { + request: { + method: 'eth_blockNumber', + params: [], + }, + response: { + result: '0x2', + }, }, - }, - { - request: { - method: 'eth_blockNumber', - params: [], + ], + }); + + const { blockTracker } = createAutoManagedNetworkClient({ + networkClientId: 'some-network-client-id', + networkClientConfiguration, + getRpcServiceOptions: () => ({ + fetch, + btoa, + isOffline: (): boolean => false, + }), + messenger: buildNetworkControllerMessenger(), + rpcFailoverMode: 'disabled', + }); + + const blockNumberViaLatest = await new Promise((resolve) => { + blockTracker.once('latest', resolve); + }); + expect(blockNumberViaLatest).toBe('0x1'); + const blockNumberViaSync = await new Promise((resolve) => { + blockTracker.once('sync', resolve); + }); + // False positive. + // eslint-disable-next-line n/no-sync + expect(blockNumberViaSync).toStrictEqual({ + oldBlock: '0x1', + newBlock: '0x2', + }); + }); + + it('creates the network client only once, even when the block tracker proxy is used multiple times', async () => { + mockNetwork({ + networkClientConfiguration, + mocks: [ + { + request: { + method: 'eth_blockNumber', + params: [], + }, + response: { + result: '0x1', + }, }, - response: { - result: '0x2', + { + request: { + method: 'eth_blockNumber', + params: [], + }, + response: { + result: '0x2', + }, }, - }, - ], - }); + { + request: { + method: 'eth_blockNumber', + params: [], + }, + response: { + result: '0x3', + }, + }, + ], + }); + const createNetworkClientMock = jest.spyOn( + createNetworkClientModule, + 'createNetworkClient', + ); + const getRpcServiceOptions = (): Omit< + RpcServiceOptions, + 'failoverService' | 'endpointUrl' + > => ({ + btoa, + fetch, + isOffline: (): boolean => false, + }); + const getBlockTrackerOptions = (): PollingBlockTrackerOptions => ({ + pollingInterval: 5000, + }); + const messenger = buildNetworkControllerMessenger(); - const { blockTracker } = createAutoManagedNetworkClient( - networkClientConfiguration, - ); + const { blockTracker } = createAutoManagedNetworkClient({ + networkClientId: 'some-network-client-id', + networkClientConfiguration, + getRpcServiceOptions, + getBlockTrackerOptions, + messenger, + rpcFailoverMode: 'enabled', + }); - const blockNumberViaLatest = await new Promise((resolve) => { - blockTracker.once('latest', resolve); + await new Promise((resolve) => { + blockTracker.once('latest', resolve); + }); + await new Promise((resolve) => { + blockTracker.once('sync', resolve); + }); + await blockTracker.getLatestBlock(); + await blockTracker.checkForLatestBlock(); + expect(createNetworkClientMock).toHaveBeenCalledTimes(1); + expect(createNetworkClientMock).toHaveBeenCalledWith({ + id: 'some-network-client-id', + configuration: networkClientConfiguration, + getRpcServiceOptions, + getBlockTrackerOptions, + messenger, + rpcFailoverMode: 'enabled', + }); }); - expect(blockNumberViaLatest).toBe('0x1'); - const blockNumberViaSync = await new Promise((resolve) => { - blockTracker.once('sync', resolve); + + it('allows setting the RPC failover mode to enabled, even after having already accessed the block tracker', async () => { + mockNetwork({ + networkClientConfiguration, + mocks: [ + { + request: { + method: 'eth_blockNumber', + params: [], + }, + response: { + result: '0x1', + }, + discardAfterMatching: false, + }, + ], + }); + const createNetworkClientMock = jest.spyOn( + createNetworkClientModule, + 'createNetworkClient', + ); + const getRpcServiceOptions = (): Omit< + RpcServiceOptions, + 'failoverService' | 'endpointUrl' + > => ({ + btoa, + fetch, + isOffline: (): boolean => false, + }); + const getBlockTrackerOptions = (): PollingBlockTrackerOptions => ({ + pollingInterval: 5000, + }); + const messenger = buildNetworkControllerMessenger(); + + const autoManagedNetworkClient = createAutoManagedNetworkClient({ + networkClientId: 'some-network-client-id', + networkClientConfiguration, + getRpcServiceOptions, + getBlockTrackerOptions, + messenger, + rpcFailoverMode: 'disabled', + }); + const { blockTracker } = autoManagedNetworkClient; + + await new Promise((resolve) => { + blockTracker.once('latest', resolve); + }); + autoManagedNetworkClient.setRpcFailoverMode('enabled'); + await new Promise((resolve) => { + blockTracker.once('latest', resolve); + }); + + expect(createNetworkClientMock).toHaveBeenNthCalledWith(1, { + id: 'some-network-client-id', + configuration: networkClientConfiguration, + getRpcServiceOptions, + getBlockTrackerOptions, + messenger, + rpcFailoverMode: 'disabled', + }); + expect(createNetworkClientMock).toHaveBeenNthCalledWith(2, { + id: 'some-network-client-id', + configuration: networkClientConfiguration, + getRpcServiceOptions, + getBlockTrackerOptions, + messenger, + rpcFailoverMode: 'enabled', + }); }); - expect(blockNumberViaSync).toStrictEqual({ - oldBlock: '0x1', - newBlock: '0x2', + + it('allows setting the RPC failover mode to disabled, even after having already accessed the block tracker', async () => { + mockNetwork({ + networkClientConfiguration, + mocks: [ + { + request: { + method: 'eth_blockNumber', + params: [], + }, + response: { + result: '0x1', + }, + discardAfterMatching: false, + }, + ], + }); + const createNetworkClientMock = jest.spyOn( + createNetworkClientModule, + 'createNetworkClient', + ); + const getRpcServiceOptions = (): Omit< + RpcServiceOptions, + 'failoverService' | 'endpointUrl' + > => ({ + btoa, + fetch, + isOffline: (): boolean => false, + }); + const getBlockTrackerOptions = (): PollingBlockTrackerOptions => ({ + pollingInterval: 5000, + }); + const messenger = buildNetworkControllerMessenger(); + + const autoManagedNetworkClient = createAutoManagedNetworkClient({ + networkClientId: 'some-network-client-id', + networkClientConfiguration, + getRpcServiceOptions, + getBlockTrackerOptions, + messenger, + rpcFailoverMode: 'enabled', + }); + const { blockTracker } = autoManagedNetworkClient; + + await new Promise((resolve) => { + blockTracker.once('latest', resolve); + }); + autoManagedNetworkClient.setRpcFailoverMode('disabled'); + await new Promise((resolve) => { + blockTracker.once('latest', resolve); + }); + + expect(createNetworkClientMock).toHaveBeenNthCalledWith(1, { + id: 'some-network-client-id', + configuration: networkClientConfiguration, + getRpcServiceOptions, + getBlockTrackerOptions, + messenger, + rpcFailoverMode: 'enabled', + }); + expect(createNetworkClientMock).toHaveBeenNthCalledWith(2, { + id: 'some-network-client-id', + configuration: networkClientConfiguration, + getRpcServiceOptions, + getBlockTrackerOptions, + messenger, + rpcFailoverMode: 'disabled', + }); }); }); + }); - it('creates the network client only once, even when the block tracker proxy is used multiple times', async () => { - mockNetwork({ - networkClientConfiguration, - mocks: [ - { - request: { - method: 'eth_blockNumber', - params: [], - }, - response: { - result: '0x1', - }, - }, - { - request: { - method: 'eth_blockNumber', - params: [], - }, - response: { - result: '0x2', - }, + it('allows setting the RPC failover mode to forced, even after having already accessed the provider', async () => { + mockNetwork({ + networkClientConfiguration, + mocks: [ + { + request: { + method: 'test_method', + params: [], }, - { - request: { - method: 'eth_blockNumber', - params: [], - }, - response: { - result: '0x3', - }, + response: { + result: 'test response', }, - ], - }); - const createNetworkClientMock = jest.spyOn( - createNetworkClientModule, - 'createNetworkClient', - ); + discardAfterMatching: false, + }, + ], + }); + const createNetworkClientMock = jest.spyOn( + createNetworkClientModule, + 'createNetworkClient', + ); + const getRpcServiceOptions = (): Omit< + RpcServiceOptions, + 'failoverService' | 'endpointUrl' + > => ({ + btoa, + fetch, + isOffline: (): boolean => false, + }); + const getBlockTrackerOptions = (): PollingBlockTrackerOptions => ({ + pollingInterval: 5000, + }); + const messenger = buildNetworkControllerMessenger(); - const { blockTracker } = createAutoManagedNetworkClient( - networkClientConfiguration, - ); + const autoManagedNetworkClient = createAutoManagedNetworkClient({ + networkClientId: 'some-network-client-id', + networkClientConfiguration, + getRpcServiceOptions, + getBlockTrackerOptions, + messenger, + rpcFailoverMode: 'disabled', + }); + const { provider } = autoManagedNetworkClient; - await new Promise((resolve) => { - blockTracker.once('latest', resolve); - }); - await new Promise((resolve) => { - blockTracker.once('sync', resolve); - }); - await blockTracker.getLatestBlock(); - await blockTracker.checkForLatestBlock(); - expect(createNetworkClientMock).toHaveBeenCalledTimes(1); - expect(createNetworkClientMock).toHaveBeenCalledWith( - networkClientConfiguration, - ); + await provider.request({ + id: 1, + jsonrpc: '2.0', + method: 'test_method', + params: [], + }); + autoManagedNetworkClient.setRpcFailoverMode('forced'); + await provider.request({ + id: 1, + jsonrpc: '2.0', + method: 'test_method', + params: [], }); - it('allows the block tracker to be destroyed', () => { - mockNetwork({ - networkClientConfiguration, - mocks: [ - { - request: { - method: 'eth_blockNumber', - params: [], - }, - response: { - result: '0x1', - }, + expect(createNetworkClientMock).toHaveBeenNthCalledWith(1, { + id: 'some-network-client-id', + configuration: networkClientConfiguration, + getRpcServiceOptions, + getBlockTrackerOptions, + messenger, + rpcFailoverMode: 'disabled', + }); + expect(createNetworkClientMock).toHaveBeenNthCalledWith(2, { + id: 'some-network-client-id', + configuration: networkClientConfiguration, + getRpcServiceOptions, + getBlockTrackerOptions, + messenger, + rpcFailoverMode: 'forced', + }); + }); + + it('allows setting the RPC failover mode from forced back to disabled, even after having accessed the provider', async () => { + mockNetwork({ + networkClientConfiguration, + mocks: [ + { + request: { + method: 'test_method', + params: [], }, - ], - }); - const { blockTracker, destroy } = createAutoManagedNetworkClient( - networkClientConfiguration, - ); - // Start the block tracker - blockTracker.on('latest', () => { - // do nothing - }); + response: { + result: 'test response', + }, + discardAfterMatching: false, + }, + ], + }); + const createNetworkClientMock = jest.spyOn( + createNetworkClientModule, + 'createNetworkClient', + ); + const getRpcServiceOptions = (): Omit< + RpcServiceOptions, + 'failoverService' | 'endpointUrl' + > => ({ + btoa, + fetch, + isOffline: (): boolean => false, + }); + const getBlockTrackerOptions = (): PollingBlockTrackerOptions => ({ + pollingInterval: 5000, + }); + const messenger = buildNetworkControllerMessenger(); + + const autoManagedNetworkClient = createAutoManagedNetworkClient({ + networkClientId: 'some-network-client-id', + networkClientConfiguration, + getRpcServiceOptions, + getBlockTrackerOptions, + messenger, + rpcFailoverMode: 'forced', + }); + const { provider } = autoManagedNetworkClient; + + await provider.request({ + id: 1, + jsonrpc: '2.0', + method: 'test_method', + params: [], + }); + autoManagedNetworkClient.setRpcFailoverMode('disabled'); + await provider.request({ + id: 1, + jsonrpc: '2.0', + method: 'test_method', + params: [], + }); - destroy(); + expect(createNetworkClientMock).toHaveBeenNthCalledWith(1, { + id: 'some-network-client-id', + configuration: networkClientConfiguration, + getRpcServiceOptions, + getBlockTrackerOptions, + messenger, + rpcFailoverMode: 'forced', + }); + expect(createNetworkClientMock).toHaveBeenNthCalledWith(2, { + id: 'some-network-client-id', + configuration: networkClientConfiguration, + getRpcServiceOptions, + getBlockTrackerOptions, + messenger, + rpcFailoverMode: 'disabled', + }); + }); - expect(blockTracker.isRunning()).toBe(false); + it('destroys the block tracker when destroyed', () => { + mockNetwork({ + networkClientConfiguration, + mocks: [ + { + request: { + method: 'eth_blockNumber', + params: [], + }, + response: { + result: '0x1', + }, + }, + ], + }); + const { blockTracker, destroy } = createAutoManagedNetworkClient({ + networkClientId: 'some-network-client-id', + networkClientConfiguration, + getRpcServiceOptions: () => ({ + fetch, + btoa, + isOffline: (): boolean => false, + }), + messenger: buildNetworkControllerMessenger(), + rpcFailoverMode: 'disabled', + }); + // Start the block tracker + blockTracker.on('latest', () => { + // do nothing }); + + destroy(); + + expect(blockTracker.isRunning()).toBe(false); }); } }); diff --git a/packages/network-controller/src/create-auto-managed-network-client.ts b/packages/network-controller/src/create-auto-managed-network-client.ts index a6691ba3e36..458fee625d3 100644 --- a/packages/network-controller/src/create-auto-managed-network-client.ts +++ b/packages/network-controller/src/create-auto-managed-network-client.ts @@ -1,10 +1,20 @@ -import type { NetworkClient } from './create-network-client'; -import { createNetworkClient } from './create-network-client'; +import type { PollingBlockTrackerOptions } from '@metamask/eth-block-tracker'; +import { Json } from '@metamask/utils'; +import type { Logger } from 'loglevel'; + +import type { NetworkClient } from './create-network-client.js'; +import { createNetworkClient } from './create-network-client.js'; +import type { + NetworkClientId, + NetworkControllerMessenger, +} from './NetworkController.js'; +import type { RpcServiceOptionsWithDefaults } from './rpc-service/rpc-service.js'; +import type { RpcFailoverMode } from './selectors.js'; import type { BlockTracker, NetworkClientConfiguration, Provider, -} from './types'; +} from './types.js'; /** * The name of the method on both the provider and block tracker proxy which can @@ -38,6 +48,7 @@ export type AutoManagedNetworkClient< provider: ProxyWithAccessibleTarget; blockTracker: ProxyWithAccessibleTarget; destroy: () => void; + setRpcFailoverMode: (rpcFailoverMode: RpcFailoverMode) => void; }; /** @@ -57,30 +68,86 @@ const UNINITIALIZED_TARGET = { __UNINITIALIZED__: true }; * part of the network client is serving as the receiver. The network client is * then cached for subsequent usages. * - * @param networkClientConfiguration - The configuration object that will be + * @param args - The arguments. + * @param args.networkClientId - The ID that will be assigned to the new network + * client in the registry. + * @param args.networkClientConfiguration - The configuration object that will be * used to instantiate the network client when it is needed. + * @param args.getRpcServiceOptions - Factory for constructing RPC service + * options. See {@link NetworkControllerOptions.getRpcServiceOptions}. + * @param args.getBlockTrackerOptions - Factory for constructing block tracker + * options. See {@link NetworkControllerOptions.getBlockTrackerOptions}. + * @param args.messenger - The network controller messenger. + * @param args.rpcFailoverMode - The RPC failover mode to apply: `disabled`, + * `enabled` (divert to the failover URLs when the primary is unavailable), or + * `forced` (route all traffic for Infura endpoints with failover URLs to those + * URLs, bypassing Infura). + * @param args.logger - A `loglevel` logger. * @returns The auto-managed network client. */ export function createAutoManagedNetworkClient< Configuration extends NetworkClientConfiguration, ->( - networkClientConfiguration: Configuration, -): AutoManagedNetworkClient { +>({ + networkClientId, + networkClientConfiguration, + getRpcServiceOptions, + getBlockTrackerOptions = (): Omit< + PollingBlockTrackerOptions, + 'provider' + > => ({}), + messenger, + rpcFailoverMode: givenRpcFailoverMode, + logger, +}: { + networkClientId: NetworkClientId; + networkClientConfiguration: Configuration; + getRpcServiceOptions?: ( + rpcEndpointUrl: string, + ) => RpcServiceOptionsWithDefaults; + getBlockTrackerOptions?: ( + rpcEndpointUrl: string, + ) => Omit; + messenger: NetworkControllerMessenger; + rpcFailoverMode: RpcFailoverMode; + logger?: Logger; +}): AutoManagedNetworkClient { + let rpcFailoverMode = givenRpcFailoverMode; let networkClient: NetworkClient | undefined; + const ensureNetworkClientCreated = (): NetworkClient => { + networkClient ??= createNetworkClient({ + id: networkClientId, + configuration: networkClientConfiguration, + getRpcServiceOptions, + getBlockTrackerOptions, + messenger, + rpcFailoverMode, + logger, + }); + + if (networkClient === undefined) { + throw new Error( + "It looks like `createNetworkClient` didn't return anything. Perhaps it's being mocked?", + ); + } + + return networkClient; + }; + const providerProxy = new Proxy(UNINITIALIZED_TARGET, { - get(_target: any, propertyName: PropertyKey, receiver: unknown) { + get( + _target: unknown, + propertyName: PropertyKey, + receiver: unknown, + ): + | Provider + | ((this: unknown, ...args: unknown[]) => Promise | undefined) + | undefined { if (propertyName === REFLECTIVE_PROPERTY_NAME) { return networkClient?.provider; } - networkClient ??= createNetworkClient(networkClientConfiguration); - if (networkClient === undefined) { - throw new Error( - "It looks like `createNetworkClient` didn't return anything. Perhaps it's being mocked?", - ); - } - const { provider } = networkClient; + const { provider } = ensureNetworkClientCreated(); if (propertyName in provider) { // Typecast: We know that `[propertyName]` is a propertyName on @@ -90,7 +157,7 @@ export function createAutoManagedNetworkClient< // Ensure that the method on the provider is called with `this` as // the target, *not* the proxy (which happens by default) — // this allows private properties to be accessed - return function (this: unknown, ...args: any[]) { + return function (this: unknown, ...args: unknown[]): Promise { // @ts-expect-error We don't care that `this` may not be compatible // with the signature of the method being called, as technically // it can be anything. @@ -103,12 +170,13 @@ export function createAutoManagedNetworkClient< return undefined; }, - has(_target: any, propertyName: PropertyKey) { + // TODO: Replace `any` with type + // eslint-disable-next-line @typescript-eslint/no-explicit-any + has(_target: any, propertyName: PropertyKey): boolean { if (propertyName === REFLECTIVE_PROPERTY_NAME) { return true; } - networkClient ??= createNetworkClient(networkClientConfiguration); - const { provider } = networkClient; + const { provider } = ensureNetworkClientCreated(); return propertyName in provider; }, }); @@ -116,18 +184,19 @@ export function createAutoManagedNetworkClient< const blockTrackerProxy: ProxyWithAccessibleTarget = new Proxy( UNINITIALIZED_TARGET, { - get(_target: any, propertyName: PropertyKey, receiver: unknown) { + get( + _target: unknown, + propertyName: PropertyKey, + receiver: unknown, + ): + | BlockTracker + | ((this: unknown, ...args: unknown[]) => unknown) + | undefined { if (propertyName === REFLECTIVE_PROPERTY_NAME) { return networkClient?.blockTracker; } - networkClient ??= createNetworkClient(networkClientConfiguration); - if (networkClient === undefined) { - throw new Error( - "It looks like createNetworkClient returned undefined. Perhaps it's mocked?", - ); - } - const { blockTracker } = networkClient; + const { blockTracker } = ensureNetworkClientCreated(); if (propertyName in blockTracker) { // Typecast: We know that `[propertyName]` is a propertyName on @@ -137,7 +206,7 @@ export function createAutoManagedNetworkClient< // Ensure that the method on the provider is called with `this` as // the target, *not* the proxy (which happens by default) — // this allows private properties to be accessed - return function (this: unknown, ...args: any[]) { + return function (this: unknown, ...args: unknown[]) { // @ts-expect-error We don't care that `this` may not be // compatible with the signature of the method being called, as // technically it can be anything. @@ -150,25 +219,33 @@ export function createAutoManagedNetworkClient< return undefined; }, - has(_target: any, propertyName: PropertyKey) { + // TODO: Replace `any` with type + // eslint-disable-next-line @typescript-eslint/no-explicit-any + has(_target: any, propertyName: PropertyKey): boolean { if (propertyName === REFLECTIVE_PROPERTY_NAME) { return true; } - networkClient ??= createNetworkClient(networkClientConfiguration); - const { blockTracker } = networkClient; + const { blockTracker } = ensureNetworkClientCreated(); return propertyName in blockTracker; }, }, ); - const destroy = () => { + const destroy = (): void => { networkClient?.destroy(); }; + const setRpcFailoverMode = (newRpcFailoverMode: RpcFailoverMode): void => { + rpcFailoverMode = newRpcFailoverMode; + destroy(); + networkClient = undefined; + }; + return { configuration: networkClientConfiguration, provider: providerProxy, blockTracker: blockTrackerProxy, destroy, + setRpcFailoverMode, }; } diff --git a/packages/network-controller/src/create-network-client-tests/classify-retry-reason.test.ts b/packages/network-controller/src/create-network-client-tests/classify-retry-reason.test.ts new file mode 100644 index 00000000000..2521aeaba96 --- /dev/null +++ b/packages/network-controller/src/create-network-client-tests/classify-retry-reason.test.ts @@ -0,0 +1,61 @@ +import { HttpError } from '@metamask/controller-utils'; +import { FetchError } from 'node-fetch'; + +import { classifyRetryReason } from '../create-network-client.js'; + +describe('classifyRetryReason', () => { + it('returns "connection_failed" for FetchError connection failures', () => { + const error = new FetchError( + 'request to https://example.com failed, reason: connect ECONNREFUSED', + 'system', + ); + expect(classifyRetryReason(error)).toBe('connection_failed'); + }); + + it('returns "connection_failed" for TypeError network errors', () => { + const error = new TypeError('Failed to fetch'); + expect(classifyRetryReason(error)).toBe('connection_failed'); + }); + + it('returns "response_not_json" for SyntaxError (invalid JSON)', () => { + const error = new SyntaxError('Unexpected token < in JSON'); + expect(classifyRetryReason(error)).toBe('response_not_json'); + }); + + it('returns "response_not_json" for "invalid json" error messages', () => { + const error = new Error('invalid json response body'); + expect(classifyRetryReason(error)).toBe('response_not_json'); + }); + + it.each([502, 503, 504])( + 'returns "non_successful_http_status" for %i errors', + (status) => { + expect(classifyRetryReason(new HttpError(status))).toBe( + 'non_successful_http_status', + ); + }, + ); + + it('returns "timed_out" for ETIMEDOUT errors', () => { + const error = new Error('timed out'); + Object.assign(error, { code: 'ETIMEDOUT' }); + expect(classifyRetryReason(error)).toBe('timed_out'); + }); + + it('returns "connection_reset" for ECONNRESET errors', () => { + const error = new Error('connection reset'); + Object.assign(error, { code: 'ECONNRESET' }); + expect(classifyRetryReason(error)).toBe('connection_reset'); + }); + + it('returns "unknown" for unrecognized Error instances', () => { + expect(classifyRetryReason(new Error('something else'))).toBe('unknown'); + }); + + it('returns "unknown" for non-Error values', () => { + expect(classifyRetryReason('a string')).toBe('unknown'); + expect(classifyRetryReason(42)).toBe('unknown'); + expect(classifyRetryReason(null)).toBe('unknown'); + expect(classifyRetryReason(undefined)).toBe('unknown'); + }); +}); diff --git a/packages/network-controller/src/create-network-client-tests/ethereum-spec/block-hash-in-response.test.ts b/packages/network-controller/src/create-network-client-tests/ethereum-spec/block-hash-in-response.test.ts new file mode 100644 index 00000000000..72d0783427c --- /dev/null +++ b/packages/network-controller/src/create-network-client-tests/ethereum-spec/block-hash-in-response.test.ts @@ -0,0 +1,21 @@ +import { testsForRpcMethodsThatCheckForBlockHashInResponse } from '../../../tests/network-client/block-hash-in-response.js'; +import { NetworkClientType } from '../../types.js'; + +describe('createNetworkClient - methods included in the Ethereum JSON-RPC spec - methods with block hashes in their result', () => { + for (const networkClientType of Object.values(NetworkClientType)) { + describe(`${networkClientType}`, () => { + const methodsWithBlockHashInResponse = [ + { name: 'eth_getTransactionByHash', numberOfParameters: 1 }, + { name: 'eth_getTransactionReceipt', numberOfParameters: 1 }, + ]; + methodsWithBlockHashInResponse.forEach(({ name, numberOfParameters }) => { + describe(`${name}`, () => { + testsForRpcMethodsThatCheckForBlockHashInResponse(name, { + numberOfParameters, + providerType: networkClientType, + }); + }); + }); + }); + } +}); diff --git a/packages/network-controller/src/create-network-client-tests/ethereum-spec/block-param.test.ts b/packages/network-controller/src/create-network-client-tests/ethereum-spec/block-param.test.ts new file mode 100644 index 00000000000..a92213abe1c --- /dev/null +++ b/packages/network-controller/src/create-network-client-tests/ethereum-spec/block-param.test.ts @@ -0,0 +1,48 @@ +import { testsForRpcMethodSupportingBlockParam } from '../../../tests/network-client/block-param.js'; +import { NetworkClientType } from '../../types.js'; + +describe('createNetworkClient - methods included in the Ethereum JSON-RPC spec - methods that have a param to specify the block', () => { + for (const networkClientType of Object.values(NetworkClientType)) { + describe(`${networkClientType}`, () => { + const supportingBlockParam = [ + { + name: 'eth_call', + blockParamIndex: 1, + numberOfParameters: 2, + }, + { + name: 'eth_getBalance', + blockParamIndex: 1, + numberOfParameters: 2, + }, + { + name: 'eth_getBlockByNumber', + blockParamIndex: 0, + numberOfParameters: 2, + }, + { name: 'eth_getCode', blockParamIndex: 1, numberOfParameters: 2 }, + { + name: 'eth_getStorageAt', + blockParamIndex: 2, + numberOfParameters: 3, + }, + { + name: 'eth_getTransactionCount', + blockParamIndex: 1, + numberOfParameters: 2, + }, + ]; + supportingBlockParam.forEach( + ({ name, blockParamIndex, numberOfParameters }) => { + describe(`method name: ${name}`, () => { + testsForRpcMethodSupportingBlockParam(name, { + providerType: networkClientType, + blockParamIndex, + numberOfParameters, + }); + }); + }, + ); + }); + } +}); diff --git a/packages/network-controller/src/create-network-client-tests/ethereum-spec/no-block-param.test.ts b/packages/network-controller/src/create-network-client-tests/ethereum-spec/no-block-param.test.ts new file mode 100644 index 00000000000..ef78a00fa2f --- /dev/null +++ b/packages/network-controller/src/create-network-client-tests/ethereum-spec/no-block-param.test.ts @@ -0,0 +1,48 @@ +import { testsForRpcMethodAssumingNoBlockParam } from '../../../tests/network-client/no-block-param.js'; +import { NetworkClientType } from '../../types.js'; + +describe('createNetworkClient - methods included in the Ethereum JSON-RPC spec - methods that assume there is no block param', () => { + for (const networkClientType of Object.values(NetworkClientType)) { + describe(`${networkClientType}`, () => { + const assumingNoBlockParam = [ + { name: 'eth_getFilterLogs', numberOfParameters: 1 }, + { name: 'eth_blockNumber', numberOfParameters: 0 }, + { name: 'eth_estimateGas', numberOfParameters: 2 }, + { name: 'eth_gasPrice', numberOfParameters: 0 }, + { name: 'eth_getBlockByHash', numberOfParameters: 2 }, + { + name: 'eth_getBlockTransactionCountByHash', + numberOfParameters: 1, + }, + { + name: 'eth_getTransactionByBlockHashAndIndex', + numberOfParameters: 2, + }, + { name: 'eth_getUncleByBlockHashAndIndex', numberOfParameters: 2 }, + { name: 'eth_getUncleCountByBlockHash', numberOfParameters: 1 }, + ]; + const blockParamIgnored = [ + { name: 'eth_getUncleCountByBlockNumber', numberOfParameters: 1 }, + { name: 'eth_getUncleByBlockNumberAndIndex', numberOfParameters: 2 }, + { + name: 'eth_getTransactionByBlockNumberAndIndex', + numberOfParameters: 2, + }, + { + name: 'eth_getBlockTransactionCountByNumber', + numberOfParameters: 1, + }, + ]; + assumingNoBlockParam + .concat(blockParamIgnored) + .forEach(({ name, numberOfParameters }) => + describe(`${name}`, () => { + testsForRpcMethodAssumingNoBlockParam(name, { + providerType: networkClientType, + numberOfParameters, + }); + }), + ); + }); + } +}); diff --git a/packages/network-controller/src/create-network-client-tests/ethereum-spec/not-handled-by-middleware.test.ts b/packages/network-controller/src/create-network-client-tests/ethereum-spec/not-handled-by-middleware.test.ts new file mode 100644 index 00000000000..3b1597fe3ca --- /dev/null +++ b/packages/network-controller/src/create-network-client-tests/ethereum-spec/not-handled-by-middleware.test.ts @@ -0,0 +1,48 @@ +import { testsForRpcMethodNotHandledByMiddleware } from '../../../tests/network-client/not-handled-by-middleware.js'; +import { NetworkClientType } from '../../types.js'; + +describe('createNetworkClient - methods included in the Ethereum JSON-RPC spec - methods not handled by middleware', () => { + for (const networkClientType of Object.values(NetworkClientType)) { + describe(`${networkClientType}`, () => { + const notHandledByMiddleware = [ + { name: 'eth_newFilter', numberOfParameters: 1 }, + { name: 'eth_getFilterChanges', numberOfParameters: 1 }, + { name: 'eth_newBlockFilter', numberOfParameters: 0 }, + { name: 'eth_newPendingTransactionFilter', numberOfParameters: 0 }, + { name: 'eth_uninstallFilter', numberOfParameters: 1 }, + + { name: 'eth_sendRawTransaction', numberOfParameters: 1 }, + { name: 'eth_sendTransaction', numberOfParameters: 1 }, + { name: 'eth_createAccessList', numberOfParameters: 2 }, + { name: 'eth_getLogs', numberOfParameters: 1 }, + { name: 'eth_getProof', numberOfParameters: 3 }, + { name: 'eth_getWork', numberOfParameters: 0 }, + { name: 'eth_maxPriorityFeePerGas', numberOfParameters: 0 }, + { name: 'eth_submitHashRate', numberOfParameters: 2 }, + { name: 'eth_submitWork', numberOfParameters: 3 }, + { name: 'eth_syncing', numberOfParameters: 0 }, + { name: 'eth_feeHistory', numberOfParameters: 3 }, + { name: 'debug_getRawHeader', numberOfParameters: 1 }, + { name: 'debug_getRawBlock', numberOfParameters: 1 }, + { name: 'debug_getRawTransaction', numberOfParameters: 1 }, + { name: 'debug_getRawReceipts', numberOfParameters: 1 }, + { name: 'debug_getBadBlocks', numberOfParameters: 0 }, + + { name: 'eth_accounts', numberOfParameters: 0 }, + { name: 'eth_coinbase', numberOfParameters: 0 }, + { name: 'eth_hashrate', numberOfParameters: 0 }, + { name: 'eth_mining', numberOfParameters: 0 }, + + { name: 'eth_signTransaction', numberOfParameters: 1 }, + ]; + notHandledByMiddleware.forEach(({ name, numberOfParameters }) => { + describe(`${name}`, () => { + testsForRpcMethodNotHandledByMiddleware(name, { + providerType: networkClientType, + numberOfParameters, + }); + }); + }); + }); + } +}); diff --git a/packages/network-controller/src/create-network-client-tests/ethereum-spec/other-methods.test.ts b/packages/network-controller/src/create-network-client-tests/ethereum-spec/other-methods.test.ts new file mode 100644 index 00000000000..aee90938e77 --- /dev/null +++ b/packages/network-controller/src/create-network-client-tests/ethereum-spec/other-methods.test.ts @@ -0,0 +1,92 @@ +import { + withMockedCommunications, + withNetworkClient, +} from '../../../tests/network-client/helpers.js'; +import { NetworkClientType } from '../../types.js'; + +describe('createNetworkClient - methods included in the Ethereum JSON-RPC spec - other methods', () => { + for (const networkClientType of Object.values(NetworkClientType)) { + describe(`${networkClientType}`, () => { + describe('eth_getTransactionByHash', () => { + it("refreshes the block tracker's current block if it is less than the block number that comes back in the response", async () => { + const method = 'eth_getTransactionByHash'; + + await withMockedCommunications( + { providerType: networkClientType }, + async (comms) => { + const request = { method }; + + comms.mockNextBlockTrackerRequest({ blockNumber: '0x100' }); + // This is our request. + comms.mockRpcCall({ + request, + response: { + result: { + blockNumber: '0x200', + }, + }, + }); + comms.mockNextBlockTrackerRequest({ blockNumber: '0x300' }); + + await withNetworkClient( + { providerType: networkClientType }, + async ({ makeRpcCall, blockTracker }) => { + await makeRpcCall(request); + expect(blockTracker.getCurrentBlock()).toBe('0x300'); + }, + ); + }, + ); + }); + }); + + describe('eth_getTransactionReceipt', () => { + it("refreshes the block tracker's current block if it is less than the block number that comes back in the response", async () => { + const method = 'eth_getTransactionReceipt'; + + await withMockedCommunications( + { providerType: networkClientType }, + async (comms) => { + const request = { method }; + + comms.mockNextBlockTrackerRequest({ blockNumber: '0x100' }); + // This is our request. + comms.mockRpcCall({ + request, + response: { + result: { + blockNumber: '0x200', + }, + }, + }); + comms.mockNextBlockTrackerRequest({ blockNumber: '0x300' }); + + await withNetworkClient( + { providerType: networkClientType }, + async ({ makeRpcCall, blockTracker }) => { + await makeRpcCall(request); + expect(blockTracker.getCurrentBlock()).toBe('0x300'); + }, + ); + }, + ); + }); + }); + + if (networkClientType === NetworkClientType.Custom) { + describe('eth_chainId', () => { + it('does not hit the RPC endpoint, instead returning the configured chain id', async () => { + const chainId = await withNetworkClient( + { providerType: networkClientType, customChainId: '0x1' }, + ({ makeRpcCall }) => { + return makeRpcCall({ method: 'eth_chainId' }); + }, + ); + + expect(chainId).toBe('0x1'); + }); + }); + } + }); + } +}); diff --git a/packages/network-controller/src/create-network-client-tests/ex-ethereum-spec/no-block-param.test.ts b/packages/network-controller/src/create-network-client-tests/ex-ethereum-spec/no-block-param.test.ts new file mode 100644 index 00000000000..c575356ad7c --- /dev/null +++ b/packages/network-controller/src/create-network-client-tests/ex-ethereum-spec/no-block-param.test.ts @@ -0,0 +1,21 @@ +import { testsForRpcMethodAssumingNoBlockParam } from '../../../tests/network-client/no-block-param.js'; +import { NetworkClientType } from '../../types.js'; + +describe('createNetworkClient - methods not included in the Ethereum JSON-RPC spec - methods that assume there is no block param', () => { + for (const networkClientType of Object.values(NetworkClientType)) { + describe(`${networkClientType}`, () => { + const assumingNoBlockParam = [ + { name: 'web3_clientVersion', numberOfParameters: 0 }, + { name: 'eth_protocolVersion', numberOfParameters: 0 }, + ]; + assumingNoBlockParam.forEach(({ name, numberOfParameters }) => + describe(`${name}`, () => { + testsForRpcMethodAssumingNoBlockParam(name, { + providerType: networkClientType, + numberOfParameters, + }); + }), + ); + }); + } +}); diff --git a/packages/network-controller/src/create-network-client-tests/ex-ethereum-spec/not-handled-by-middleware.test.ts b/packages/network-controller/src/create-network-client-tests/ex-ethereum-spec/not-handled-by-middleware.test.ts new file mode 100644 index 00000000000..3d5a2513369 --- /dev/null +++ b/packages/network-controller/src/create-network-client-tests/ex-ethereum-spec/not-handled-by-middleware.test.ts @@ -0,0 +1,25 @@ +import { testsForRpcMethodNotHandledByMiddleware } from '../../../tests/network-client/not-handled-by-middleware.js'; +import { NetworkClientType } from '../../types.js'; + +describe('createNetworkClient - methods not included in the Ethereum JSON-RPC spec - methods not handled by middleware', () => { + for (const networkClientType of Object.values(NetworkClientType)) { + describe(`${networkClientType}`, () => { + const notHandledByMiddleware = [ + { name: 'net_listening', numberOfParameters: 0 }, + { name: 'eth_subscribe', numberOfParameters: 1 }, + { name: 'eth_unsubscribe', numberOfParameters: 1 }, + { name: 'custom_rpc_method', numberOfParameters: 1 }, + { name: 'net_peerCount', numberOfParameters: 0 }, + { name: 'parity_nextNonce', numberOfParameters: 1 }, + ]; + notHandledByMiddleware.forEach(({ name, numberOfParameters }) => { + describe(`${name}`, () => { + testsForRpcMethodNotHandledByMiddleware(name, { + providerType: networkClientType, + numberOfParameters, + }); + }); + }); + }); + } +}); diff --git a/packages/network-controller/src/create-network-client-tests/ex-ethereum-spec/other-methods.test.ts b/packages/network-controller/src/create-network-client-tests/ex-ethereum-spec/other-methods.test.ts new file mode 100644 index 00000000000..7091a2f6203 --- /dev/null +++ b/packages/network-controller/src/create-network-client-tests/ex-ethereum-spec/other-methods.test.ts @@ -0,0 +1,42 @@ +import { TESTNET } from '../../../tests/helpers.js'; +import { + withMockedCommunications, + withNetworkClient, +} from '../../../tests/network-client/helpers.js'; +import { NetworkClientType } from '../../types.js'; + +describe('createNetworkClient - methods not included in the Ethereum JSON-RPC spec - other methods', () => { + for (const networkClientType of Object.values(NetworkClientType)) { + describe(`${networkClientType}`, () => { + describe('net_version', () => { + const networkArgs = { + providerType: networkClientType, + infuraNetwork: + networkClientType === NetworkClientType.Infura + ? TESTNET.networkType + : undefined, + } as const; + + it('hits the RPC endpoint', async () => { + await withMockedCommunications(networkArgs, async (comms) => { + comms.mockRpcCall({ + request: { method: 'net_version' }, + response: { result: '1' }, + }); + + const networkId = await withNetworkClient( + networkArgs, + ({ makeRpcCall }) => { + return makeRpcCall({ + method: 'net_version', + }); + }, + ); + + expect(networkId).toBe('1'); + }); + }); + }); + }); + } +}); diff --git a/packages/network-controller/src/create-network-client-tests/rpc-endpoint-events.test.ts b/packages/network-controller/src/create-network-client-tests/rpc-endpoint-events.test.ts new file mode 100644 index 00000000000..4822ddbabf6 --- /dev/null +++ b/packages/network-controller/src/create-network-client-tests/rpc-endpoint-events.test.ts @@ -0,0 +1,1690 @@ +import { CONNECTIVITY_STATUSES } from '@metamask/connectivity-controller'; +import { + ConstantBackoff, + DEFAULT_DEGRADED_THRESHOLD, + HttpError, +} from '@metamask/controller-utils'; +import { errorCodes } from '@metamask/rpc-errors'; + +import { buildRootMessenger } from '../../tests/helpers.js'; +import { + withMockedCommunications, + withNetworkClient, +} from '../../tests/network-client/helpers.js'; +import { DEFAULT_MAX_CONSECUTIVE_FAILURES } from '../rpc-service/rpc-service.js'; +import { NetworkClientType } from '../types.js'; + +describe('createNetworkClient - RPC endpoint events', () => { + for (const networkClientType of Object.values(NetworkClientType)) { + describe(`${networkClientType}`, () => { + const blockNumber = '0x100'; + const backoffDuration = 100; + + if (networkClientType !== NetworkClientType.Custom) { + describe('with RPC failover', () => { + it('publishes the NetworkController:rpcEndpointChainUnavailable event only when the max number of consecutive request failures is reached for all of the endpoints in a chain of endpoints', async () => { + const failoverEndpointUrl = 'https://failover.endpoint/'; + const request = { + method: 'eth_gasPrice', + params: [], + }; + const expectedError = createResourceUnavailableError(503); + const expectedUnavailableError = new HttpError(503); + + await withMockedCommunications( + { providerType: networkClientType }, + async (primaryComms) => { + await withMockedCommunications( + { + providerType: 'custom', + customRpcUrl: failoverEndpointUrl, + }, + async (failoverComms) => { + // The first time a block-cacheable request is made, the + // latest block number is retrieved through the block + // tracker first. + primaryComms.mockRpcCall({ + request: { + method: 'eth_blockNumber', + params: [], + }, + times: DEFAULT_MAX_CONSECUTIVE_FAILURES, + response: { + httpStatus: 503, + }, + }); + failoverComms.mockRpcCall({ + request: { + method: 'eth_blockNumber', + params: [], + }, + times: DEFAULT_MAX_CONSECUTIVE_FAILURES, + response: { + httpStatus: 503, + }, + }); + + const messenger = buildRootMessenger(); + const rpcEndpointChainUnavailableEventHandler = jest.fn(); + messenger.subscribe( + 'NetworkController:rpcEndpointChainUnavailable', + rpcEndpointChainUnavailableEventHandler, + ); + + await withNetworkClient( + { + networkClientId: 'AAAA-AAAA-AAAA-AAAA', + providerType: networkClientType, + rpcFailoverMode: 'enabled', + failoverRpcUrls: [failoverEndpointUrl], + messenger, + getRpcServiceOptions: () => ({ + fetch, + btoa, + policyOptions: { + backoff: new ConstantBackoff(backoffDuration), + }, + isOffline: (): boolean => false, + }), + }, + async ({ makeRpcCall, chainId }) => { + messenger.subscribe( + 'NetworkController:rpcEndpointRetried', + () => { + // Ensure that we advance to the next RPC request + // retry, not the next block tracker request. + jest.advanceTimersByTime(backoffDuration); + }, + ); + + // Hit the primary and exceed the max number of retries + await expect(makeRpcCall(request)).rejects.toThrow( + expectedError, + ); + // Hit the primary and exceed the max number of retries + await expect(makeRpcCall(request)).rejects.toThrow( + expectedError, + ); + // Hit the primary and exceed the max number of retries, + // breaking the circuit; then hit the failover and exceed + // the max of retries + await expect(makeRpcCall(request)).rejects.toThrow( + expectedError, + ); + // Hit the failover and exceed the max number of retries + await expect(makeRpcCall(request)).rejects.toThrow( + expectedError, + ); + // Hit the failover and exceed the max number of retries, + // breaking the circuit + await expect(makeRpcCall(request)).rejects.toThrow( + expectedError, + ); + + expect( + rpcEndpointChainUnavailableEventHandler, + ).toHaveBeenCalledTimes(1); + expect( + rpcEndpointChainUnavailableEventHandler, + ).toHaveBeenCalledWith({ + chainId, + error: expectedUnavailableError, + networkClientId: 'AAAA-AAAA-AAAA-AAAA', + }); + }, + ); + }, + ); + }, + ); + }); + + it('publishes the NetworkController:rpcEndpointUnavailable event each time the max number of consecutive request failures is reached for any of the endpoints in a chain of endpoints', async () => { + const failoverEndpointUrl = 'https://failover.endpoint/'; + const request = { + method: 'eth_gasPrice', + params: [], + }; + const expectedError = createResourceUnavailableError(503); + const expectedUnavailableError = new HttpError(503); + + await withMockedCommunications( + { providerType: networkClientType }, + async (primaryComms) => { + await withMockedCommunications( + { + providerType: 'custom', + customRpcUrl: failoverEndpointUrl, + }, + async (failoverComms) => { + // The first time a block-cacheable request is made, the + // latest block number is retrieved through the block + // tracker first. + primaryComms.mockRpcCall({ + request: { + method: 'eth_blockNumber', + params: [], + }, + times: DEFAULT_MAX_CONSECUTIVE_FAILURES, + response: { + httpStatus: 503, + }, + }); + failoverComms.mockRpcCall({ + request: { + method: 'eth_blockNumber', + params: [], + }, + times: DEFAULT_MAX_CONSECUTIVE_FAILURES, + response: { + httpStatus: 503, + }, + }); + + const messenger = buildRootMessenger(); + const rpcEndpointUnavailableEventHandler = jest.fn(); + messenger.subscribe( + 'NetworkController:rpcEndpointUnavailable', + rpcEndpointUnavailableEventHandler, + ); + + await withNetworkClient( + { + providerType: networkClientType, + networkClientId: 'AAAA-AAAA-AAAA-AAAA', + rpcFailoverMode: 'enabled', + failoverRpcUrls: [failoverEndpointUrl], + messenger, + getRpcServiceOptions: () => ({ + fetch, + btoa, + policyOptions: { + backoff: new ConstantBackoff(backoffDuration), + }, + isOffline: (): boolean => false, + }), + }, + async ({ makeRpcCall, chainId, rpcUrl }) => { + messenger.subscribe( + 'NetworkController:rpcEndpointRetried', + () => { + // Ensure that we advance to the next RPC request + // retry, not the next block tracker request. + jest.advanceTimersByTime(backoffDuration); + }, + ); + + // Hit the primary and exceed the max number of retries + await expect(makeRpcCall(request)).rejects.toThrow( + expectedError, + ); + // Hit the primary and exceed the max number of retries + await expect(makeRpcCall(request)).rejects.toThrow( + expectedError, + ); + // Hit the primary and exceed the max number of retries, + // breaking the circuit; then hit the failover and exceed + // the max of retries + await expect(makeRpcCall(request)).rejects.toThrow( + expectedError, + ); + // Hit the failover and exceed the max number of retries + await expect(makeRpcCall(request)).rejects.toThrow( + expectedError, + ); + // Hit the failover and exceed the max number of retries, + // breaking the circuit + await expect(makeRpcCall(request)).rejects.toThrow( + expectedError, + ); + + expect( + rpcEndpointUnavailableEventHandler, + ).toHaveBeenCalledTimes(2); + expect( + rpcEndpointUnavailableEventHandler, + ).toHaveBeenCalledWith({ + chainId, + endpointUrl: rpcUrl, + error: expectedUnavailableError, + networkClientId: 'AAAA-AAAA-AAAA-AAAA', + primaryEndpointUrl: rpcUrl, + }); + expect( + rpcEndpointUnavailableEventHandler, + ).toHaveBeenCalledWith({ + chainId, + endpointUrl: failoverEndpointUrl, + error: expectedUnavailableError, + networkClientId: 'AAAA-AAAA-AAAA-AAAA', + primaryEndpointUrl: rpcUrl, + }); + }, + ); + }, + ); + }, + ); + }); + + it('does not retry requests when user is offline', async () => { + const failoverEndpointUrl = 'https://failover.endpoint/'; + const request = { + method: 'eth_gasPrice', + params: [], + }; + const expectedError = createResourceUnavailableError(503); + + await withMockedCommunications( + { providerType: networkClientType }, + async (primaryComms) => { + await withMockedCommunications( + { + providerType: 'custom', + customRpcUrl: failoverEndpointUrl, + }, + async () => { + // Mock only one failure - if retries were happening, we'd need more + primaryComms.mockRpcCall({ + request: { + method: 'eth_blockNumber', + params: [], + }, + times: 1, + response: { + httpStatus: 503, + }, + }); + + const rootMessenger = buildRootMessenger({ + connectivityStatus: CONNECTIVITY_STATUSES.Offline, + }); + + const rpcEndpointRetriedEventHandler = jest.fn(); + rootMessenger.subscribe( + 'NetworkController:rpcEndpointRetried', + rpcEndpointRetriedEventHandler, + ); + + await withNetworkClient( + { + providerType: networkClientType, + networkClientId: 'AAAA-AAAA-AAAA-AAAA', + rpcFailoverMode: 'enabled', + failoverRpcUrls: [failoverEndpointUrl], + messenger: rootMessenger, + getRpcServiceOptions: () => ({ + policyOptions: { + backoff: new ConstantBackoff(backoffDuration), + }, + }), + }, + async ({ makeRpcCall }) => { + // When offline, errors are not retried, so the request + // should fail immediately without retries + await expect(makeRpcCall(request)).rejects.toThrow( + expectedError, + ); + + // Verify that retry event was not published + expect( + rpcEndpointRetriedEventHandler, + ).not.toHaveBeenCalled(); + }, + ); + }, + ); + }, + ); + }); + + it('suppresses the NetworkController:rpcEndpointUnavailable event when user is offline', async () => { + const failoverEndpointUrl = 'https://failover.endpoint/'; + const request = { + method: 'eth_gasPrice', + params: [], + }; + const expectedError = createResourceUnavailableError(503); + + await withMockedCommunications( + { providerType: networkClientType }, + async (primaryComms) => { + await withMockedCommunications( + { + providerType: 'custom', + customRpcUrl: failoverEndpointUrl, + }, + async (failoverComms) => { + // The first time a block-cacheable request is made, the + // latest block number is retrieved through the block + // tracker first. + primaryComms.mockRpcCall({ + request: { + method: 'eth_blockNumber', + params: [], + }, + times: DEFAULT_MAX_CONSECUTIVE_FAILURES, + response: { + httpStatus: 503, + }, + }); + failoverComms.mockRpcCall({ + request: { + method: 'eth_blockNumber', + params: [], + }, + times: DEFAULT_MAX_CONSECUTIVE_FAILURES, + response: { + httpStatus: 503, + }, + }); + + const rootMessenger = buildRootMessenger({ + connectivityStatus: CONNECTIVITY_STATUSES.Offline, + }); + + const rpcEndpointUnavailableEventHandler = jest.fn(); + rootMessenger.subscribe( + 'NetworkController:rpcEndpointUnavailable', + rpcEndpointUnavailableEventHandler, + ); + + await withNetworkClient( + { + providerType: networkClientType, + networkClientId: 'AAAA-AAAA-AAAA-AAAA', + rpcFailoverMode: 'enabled', + failoverRpcUrls: [failoverEndpointUrl], + messenger: rootMessenger, + getRpcServiceOptions: () => ({ + policyOptions: { + backoff: new ConstantBackoff(backoffDuration), + }, + }), + }, + async ({ makeRpcCall }) => { + rootMessenger.subscribe( + 'NetworkController:rpcEndpointRetried', + () => { + // Ensure that we advance to the next RPC request + // retry, not the next block tracker request. + jest.advanceTimersByTime(backoffDuration); + }, + ); + + // When offline, errors are not retried, so the circuit + // won't break and onServiceBreak won't be called + await expect(makeRpcCall(request)).rejects.toThrow( + expectedError, + ); + await expect(makeRpcCall(request)).rejects.toThrow( + expectedError, + ); + await expect(makeRpcCall(request)).rejects.toThrow( + expectedError, + ); + + // Event should be suppressed when offline because retries + // are prevented, so onServiceBreak is never called + expect( + rpcEndpointUnavailableEventHandler, + ).not.toHaveBeenCalled(); + }, + ); + }, + ); + }, + ); + }); + + it('does not publish the NetworkController:rpcEndpointChainDegraded event again if the max number of retries is reached in making requests to a failover endpoint', async () => { + const failoverEndpointUrl = 'https://failover.endpoint/'; + const request = { + method: 'eth_gasPrice', + params: [], + }; + const expectedError = createResourceUnavailableError(503); + const expectedDegradedError = new HttpError(503); + + await withMockedCommunications( + { providerType: networkClientType }, + async (primaryComms) => { + await withMockedCommunications( + { + providerType: 'custom', + customRpcUrl: failoverEndpointUrl, + }, + async (failoverComms) => { + const messenger = buildRootMessenger(); + const rpcEndpointChainDegradedEventHandler = jest.fn(); + messenger.subscribe( + 'NetworkController:rpcEndpointChainDegraded', + rpcEndpointChainDegradedEventHandler, + ); + + await withNetworkClient( + { + providerType: networkClientType, + networkClientId: 'AAAA-AAAA-AAAA-AAAA', + rpcFailoverMode: 'enabled', + failoverRpcUrls: [failoverEndpointUrl], + messenger, + getRpcServiceOptions: () => ({ + fetch, + btoa, + policyOptions: { + backoff: new ConstantBackoff(backoffDuration), + }, + isOffline: (): boolean => false, + }), + }, + async ({ makeRpcCall, chainId }) => { + // The first time a block-cacheable request is made, the + // latest block number is retrieved through the block + // tracker first. + primaryComms.mockRpcCall({ + request: { + method: 'eth_blockNumber', + params: [], + }, + times: DEFAULT_MAX_CONSECUTIVE_FAILURES, + response: { + httpStatus: 503, + }, + }); + failoverComms.mockRpcCall({ + request: { + method: 'eth_blockNumber', + params: [], + }, + times: 5, + response: { + httpStatus: 503, + }, + }); + + messenger.subscribe( + 'NetworkController:rpcEndpointRetried', + () => { + // Ensure that we advance to the next RPC request + // retry, not the next block tracker request. + jest.advanceTimersByTime(backoffDuration); + }, + ); + + // Hit the primary and exceed the max number of retries + await expect(makeRpcCall(request)).rejects.toThrow( + expectedError, + ); + // Hit the primary and exceed the max number of retries + await expect(makeRpcCall(request)).rejects.toThrow( + expectedError, + ); + // Hit the primary and exceed the max number of retries, + // break the circuit; hit the failover and exceed the max + // number of retries + await expect(makeRpcCall(request)).rejects.toThrow( + expectedError, + ); + + expect( + rpcEndpointChainDegradedEventHandler, + ).toHaveBeenCalledTimes(1); + expect( + rpcEndpointChainDegradedEventHandler, + ).toHaveBeenCalledWith({ + chainId, + type: 'retries_exhausted', + error: expectedDegradedError, + networkClientId: 'AAAA-AAAA-AAAA-AAAA', + retryReason: 'non_successful_http_status', + rpcMethodName: 'eth_blockNumber', + duration: undefined, + traceId: undefined, + }); + }, + ); + }, + ); + }, + ); + }); + + it('does not publish the NetworkController:rpcEndpointChainDegraded event again when the time to complete a request to a failover endpoint is too long', async () => { + const failoverEndpointUrl = 'https://failover.endpoint/'; + const request = { + method: 'eth_gasPrice', + params: [], + }; + const expectedError = createResourceUnavailableError(503); + const expectedDegradedError = new HttpError(503); + + await withMockedCommunications( + { providerType: networkClientType }, + async (primaryComms) => { + await withMockedCommunications( + { + providerType: 'custom', + customRpcUrl: failoverEndpointUrl, + }, + async (failoverComms) => { + const messenger = buildRootMessenger(); + const rpcEndpointChainDegradedEventHandler = jest.fn(); + messenger.subscribe( + 'NetworkController:rpcEndpointChainDegraded', + rpcEndpointChainDegradedEventHandler, + ); + + await withNetworkClient( + { + providerType: networkClientType, + networkClientId: 'AAAA-AAAA-AAAA-AAAA', + rpcFailoverMode: 'enabled', + failoverRpcUrls: [failoverEndpointUrl], + messenger, + getRpcServiceOptions: () => ({ + fetch, + btoa, + policyOptions: { + backoff: new ConstantBackoff(backoffDuration), + }, + isOffline: (): boolean => false, + }), + }, + async ({ makeRpcCall, chainId }) => { + // The first time a block-cacheable request is made, the + // latest block number is retrieved through the block + // tracker first. + primaryComms.mockRpcCall({ + request: { + method: 'eth_blockNumber', + params: [], + }, + times: DEFAULT_MAX_CONSECUTIVE_FAILURES, + response: { + httpStatus: 503, + }, + }); + failoverComms.mockRpcCall({ + request: { + method: 'eth_blockNumber', + params: [], + }, + response: () => { + jest.advanceTimersByTime( + DEFAULT_DEGRADED_THRESHOLD + 1, + ); + return { + result: '0x1', + }; + }, + }); + failoverComms.mockRpcCall({ + request, + response: () => { + jest.advanceTimersByTime( + DEFAULT_DEGRADED_THRESHOLD + 1, + ); + return { + result: 'ok', + }; + }, + }); + + messenger.subscribe( + 'NetworkController:rpcEndpointRetried', + () => { + // Ensure that we advance to the next RPC request + // retry, not the next block tracker request. + jest.advanceTimersByTime(backoffDuration); + }, + ); + + // Hit the primary and exceed the max number of retries + await expect(makeRpcCall(request)).rejects.toThrow( + expectedError, + ); + // Hit the primary and exceed the max number of retries + await expect(makeRpcCall(request)).rejects.toThrow( + expectedError, + ); + // Hit the primary and exceed the max number of retries, + // break the circuit; hit the failover + await makeRpcCall(request); + + expect( + rpcEndpointChainDegradedEventHandler, + ).toHaveBeenCalledTimes(1); + expect( + rpcEndpointChainDegradedEventHandler, + ).toHaveBeenCalledWith({ + chainId, + type: 'retries_exhausted', + error: expectedDegradedError, + networkClientId: 'AAAA-AAAA-AAAA-AAAA', + retryReason: 'non_successful_http_status', + rpcMethodName: 'eth_blockNumber', + duration: undefined, + traceId: undefined, + }); + }, + ); + }, + ); + }, + ); + }); + + it('publishes the NetworkController:rpcEndpointDegraded event again if the max number of retries is reached in making requests to a failover endpoint', async () => { + const failoverEndpointUrl = 'https://failover.endpoint/'; + const request = { + method: 'eth_gasPrice', + params: [], + }; + const expectedError = createResourceUnavailableError(503); + const expectedDegradedError = new HttpError(503); + + await withMockedCommunications( + { providerType: networkClientType }, + async (primaryComms) => { + await withMockedCommunications( + { + providerType: 'custom', + customRpcUrl: failoverEndpointUrl, + }, + async (failoverComms) => { + const messenger = buildRootMessenger(); + const rpcEndpointDegradedEventHandler = jest.fn(); + messenger.subscribe( + 'NetworkController:rpcEndpointDegraded', + rpcEndpointDegradedEventHandler, + ); + + await withNetworkClient( + { + providerType: networkClientType, + networkClientId: 'AAAA-AAAA-AAAA-AAAA', + rpcFailoverMode: 'enabled', + failoverRpcUrls: [failoverEndpointUrl], + messenger, + getRpcServiceOptions: () => ({ + fetch, + btoa, + policyOptions: { + backoff: new ConstantBackoff(backoffDuration), + }, + isOffline: (): boolean => false, + }), + }, + async ({ makeRpcCall, chainId, rpcUrl }) => { + // The first time a block-cacheable request is made, the + // latest block number is retrieved through the block + // tracker first. + primaryComms.mockRpcCall({ + request: { + method: 'eth_blockNumber', + params: [], + }, + times: DEFAULT_MAX_CONSECUTIVE_FAILURES, + response: { + httpStatus: 503, + }, + }); + failoverComms.mockRpcCall({ + request: { + method: 'eth_blockNumber', + params: [], + }, + times: 5, + response: { + httpStatus: 503, + }, + }); + + messenger.subscribe( + 'NetworkController:rpcEndpointRetried', + () => { + // Ensure that we advance to the next RPC request + // retry, not the next block tracker request. + jest.advanceTimersByTime(backoffDuration); + }, + ); + + // Hit the primary and exceed the max number of retries + await expect(makeRpcCall(request)).rejects.toThrow( + expectedError, + ); + // Hit the primary and exceed the max number of retries + await expect(makeRpcCall(request)).rejects.toThrow( + expectedError, + ); + // Hit the primary and exceed the max number of retries, + // break the circuit; hit the failover and exceed the max + // number of retries + await expect(makeRpcCall(request)).rejects.toThrow( + expectedError, + ); + + expect( + rpcEndpointDegradedEventHandler, + ).toHaveBeenCalledTimes(3); + expect( + rpcEndpointDegradedEventHandler, + ).toHaveBeenNthCalledWith(1, { + chainId, + type: 'retries_exhausted', + endpointUrl: rpcUrl, + error: expectedDegradedError, + networkClientId: 'AAAA-AAAA-AAAA-AAAA', + primaryEndpointUrl: rpcUrl, + retryReason: 'non_successful_http_status', + rpcMethodName: 'eth_blockNumber', + duration: undefined, + traceId: undefined, + }); + expect( + rpcEndpointDegradedEventHandler, + ).toHaveBeenNthCalledWith(2, { + chainId, + type: 'retries_exhausted', + endpointUrl: rpcUrl, + error: expectedDegradedError, + networkClientId: 'AAAA-AAAA-AAAA-AAAA', + primaryEndpointUrl: rpcUrl, + retryReason: 'non_successful_http_status', + rpcMethodName: 'eth_blockNumber', + duration: undefined, + traceId: undefined, + }); + expect( + rpcEndpointDegradedEventHandler, + ).toHaveBeenNthCalledWith(3, { + chainId, + type: 'retries_exhausted', + endpointUrl: failoverEndpointUrl, + error: expectedDegradedError, + networkClientId: 'AAAA-AAAA-AAAA-AAAA', + primaryEndpointUrl: rpcUrl, + retryReason: 'non_successful_http_status', + rpcMethodName: 'eth_blockNumber', + duration: undefined, + traceId: undefined, + }); + }, + ); + }, + ); + }, + ); + }); + + it('publishes the NetworkController:rpcEndpointDegraded event again when the time to complete a request to a failover endpoint is too long', async () => { + const failoverEndpointUrl = 'https://failover.endpoint/'; + const request = { + method: 'eth_gasPrice', + params: [], + }; + const expectedError = createResourceUnavailableError(503); + const expectedDegradedError = new HttpError(503); + + await withMockedCommunications( + { providerType: networkClientType }, + async (primaryComms) => { + await withMockedCommunications( + { + providerType: 'custom', + customRpcUrl: failoverEndpointUrl, + }, + async (failoverComms) => { + const messenger = buildRootMessenger(); + const rpcEndpointDegradedEventHandler = jest.fn(); + messenger.subscribe( + 'NetworkController:rpcEndpointDegraded', + rpcEndpointDegradedEventHandler, + ); + + await withNetworkClient( + { + providerType: networkClientType, + networkClientId: 'AAAA-AAAA-AAAA-AAAA', + rpcFailoverMode: 'enabled', + failoverRpcUrls: [failoverEndpointUrl], + messenger, + getRpcServiceOptions: () => ({ + fetch, + btoa, + policyOptions: { + backoff: new ConstantBackoff(backoffDuration), + }, + isOffline: (): boolean => false, + }), + }, + async ({ makeRpcCall, chainId, rpcUrl }) => { + // The first time a block-cacheable request is made, the + // latest block number is retrieved through the block + // tracker first. + primaryComms.mockRpcCall({ + request: { + method: 'eth_blockNumber', + params: [], + }, + times: DEFAULT_MAX_CONSECUTIVE_FAILURES, + response: { + httpStatus: 503, + }, + }); + failoverComms.mockRpcCall({ + request: { + method: 'eth_blockNumber', + params: [], + }, + response: () => { + jest.advanceTimersByTime( + DEFAULT_DEGRADED_THRESHOLD + 1, + ); + return { + result: '0x1', + }; + }, + }); + failoverComms.mockRpcCall({ + request, + response: () => { + jest.advanceTimersByTime( + DEFAULT_DEGRADED_THRESHOLD + 1, + ); + return { + result: 'ok', + }; + }, + }); + + messenger.subscribe( + 'NetworkController:rpcEndpointRetried', + () => { + // Ensure that we advance to the next RPC request + // retry, not the next block tracker request. + jest.advanceTimersByTime(backoffDuration); + }, + ); + + // Hit the primary and exceed the max number of retries + await expect(makeRpcCall(request)).rejects.toThrow( + expectedError, + ); + // Hit the primary and exceed the max number of retries + await expect(makeRpcCall(request)).rejects.toThrow( + expectedError, + ); + // Hit the primary and exceed the max number of retries, + // break the circuit; hit the failover + await makeRpcCall(request); + + expect( + rpcEndpointDegradedEventHandler, + ).toHaveBeenCalledTimes(4); + expect( + rpcEndpointDegradedEventHandler, + ).toHaveBeenNthCalledWith(1, { + chainId, + type: 'retries_exhausted', + endpointUrl: rpcUrl, + error: expectedDegradedError, + networkClientId: 'AAAA-AAAA-AAAA-AAAA', + primaryEndpointUrl: rpcUrl, + retryReason: 'non_successful_http_status', + rpcMethodName: 'eth_blockNumber', + duration: undefined, + traceId: undefined, + }); + expect( + rpcEndpointDegradedEventHandler, + ).toHaveBeenNthCalledWith(2, { + chainId, + type: 'retries_exhausted', + endpointUrl: rpcUrl, + error: expectedDegradedError, + networkClientId: 'AAAA-AAAA-AAAA-AAAA', + primaryEndpointUrl: rpcUrl, + retryReason: 'non_successful_http_status', + rpcMethodName: 'eth_blockNumber', + duration: undefined, + traceId: undefined, + }); + expect( + rpcEndpointDegradedEventHandler, + ).toHaveBeenNthCalledWith(3, { + chainId, + type: 'slow_success', + endpointUrl: failoverEndpointUrl, + error: undefined, + networkClientId: 'AAAA-AAAA-AAAA-AAAA', + primaryEndpointUrl: rpcUrl, + rpcMethodName: 'eth_blockNumber', + duration: expect.any(Number), + traceId: undefined, + }); + expect( + rpcEndpointDegradedEventHandler, + ).toHaveBeenNthCalledWith(4, { + chainId, + type: 'slow_success', + endpointUrl: failoverEndpointUrl, + error: undefined, + networkClientId: 'AAAA-AAAA-AAAA-AAAA', + primaryEndpointUrl: rpcUrl, + rpcMethodName: 'eth_gasPrice', + duration: expect.any(Number), + traceId: undefined, + }); + }, + ); + }, + ); + }, + ); + }); + + it('publishes the NetworkController:rpcEndpointChainAvailable event the first time a successful request to a failover endpoint is made', async () => { + const failoverEndpointUrl = 'https://failover.endpoint/'; + const request = { + method: 'eth_gasPrice', + params: [], + }; + const expectedError = createResourceUnavailableError(503); + + await withMockedCommunications( + { providerType: networkClientType }, + async (primaryComms) => { + await withMockedCommunications( + { + providerType: 'custom', + customRpcUrl: failoverEndpointUrl, + }, + async (failoverComms) => { + // The first time a block-cacheable request is made, the + // latest block number is retrieved through the block + // tracker first. + primaryComms.mockRpcCall({ + request: { + method: 'eth_blockNumber', + params: [], + }, + times: DEFAULT_MAX_CONSECUTIVE_FAILURES, + response: { + httpStatus: 503, + }, + }); + failoverComms.mockRpcCall({ + request: { + method: 'eth_blockNumber', + params: [], + }, + response: { + result: '0x1', + }, + }); + failoverComms.mockRpcCall({ + request, + response: { + result: 'ok', + }, + }); + + const messenger = buildRootMessenger(); + const rpcEndpointChainAvailableEventHandler = jest.fn(); + messenger.subscribe( + 'NetworkController:rpcEndpointChainAvailable', + rpcEndpointChainAvailableEventHandler, + ); + + await withNetworkClient( + { + providerType: networkClientType, + networkClientId: 'AAAA-AAAA-AAAA-AAAA', + rpcFailoverMode: 'enabled', + failoverRpcUrls: [failoverEndpointUrl], + messenger, + getRpcServiceOptions: () => ({ + fetch, + btoa, + policyOptions: { + backoff: new ConstantBackoff(backoffDuration), + }, + isOffline: (): boolean => false, + }), + }, + async ({ makeRpcCall, chainId }) => { + messenger.subscribe( + 'NetworkController:rpcEndpointRetried', + () => { + // Ensure that we advance to the next RPC request + // retry, not the next block tracker request. + jest.advanceTimersByTime(backoffDuration); + }, + ); + + // Hit the endpoint and exceed the max number of retries + await expect(makeRpcCall(request)).rejects.toThrow( + expectedError, + ); + // Hit the endpoint and exceed the max number of retries + await expect(makeRpcCall(request)).rejects.toThrow( + expectedError, + ); + // Hit the endpoint and exceed the max number of retries, + // breaking the circuit; hit the failover + await makeRpcCall(request); + + expect( + rpcEndpointChainAvailableEventHandler, + ).toHaveBeenCalledTimes(1); + expect( + rpcEndpointChainAvailableEventHandler, + ).toHaveBeenCalledWith({ + chainId, + networkClientId: 'AAAA-AAAA-AAAA-AAAA', + }); + }, + ); + }, + ); + }, + ); + }); + }); + } + + describe('without RPC failover', () => { + it('publishes the NetworkController:rpcEndpointChainDegraded event only once, even if the max number of retries is continually reached in making requests to a primary endpoint', async () => { + const request = { + method: 'eth_gasPrice', + params: [], + }; + const expectedError = createResourceUnavailableError(503); + const expectedDegradedError = new HttpError(503); + + await withMockedCommunications( + { providerType: networkClientType }, + async (comms) => { + // The first time a block-cacheable request is made, the + // latest block number is retrieved through the block + // tracker first. + comms.mockRpcCall({ + request: { + method: 'eth_blockNumber', + params: [], + }, + times: DEFAULT_MAX_CONSECUTIVE_FAILURES, + response: { + httpStatus: 503, + }, + }); + + const messenger = buildRootMessenger(); + const rpcEndpointChainDegradedEventHandler = jest.fn(); + messenger.subscribe( + 'NetworkController:rpcEndpointChainDegraded', + rpcEndpointChainDegradedEventHandler, + ); + + await withNetworkClient( + { + providerType: networkClientType, + networkClientId: 'AAAA-AAAA-AAAA-AAAA', + messenger, + getRpcServiceOptions: () => ({ + fetch, + btoa, + policyOptions: { + backoff: new ConstantBackoff(backoffDuration), + }, + isOffline: (): boolean => false, + }), + }, + async ({ makeRpcCall, chainId }) => { + messenger.subscribe( + 'NetworkController:rpcEndpointRetried', + () => { + // Ensure that we advance to the next RPC request + // retry, not the next block tracker request. + jest.advanceTimersByTime(backoffDuration); + }, + ); + + // Hit the endpoint and exceed the max number of retries + await expect(makeRpcCall(request)).rejects.toThrow( + expectedError, + ); + // Hit the endpoint and exceed the max number of retries + await expect(makeRpcCall(request)).rejects.toThrow( + expectedError, + ); + // Hit the endpoint and exceed the max number of retries, + // breaking the circuit + await expect(makeRpcCall(request)).rejects.toThrow( + expectedError, + ); + + expect( + rpcEndpointChainDegradedEventHandler, + ).toHaveBeenCalledTimes(1); + expect( + rpcEndpointChainDegradedEventHandler, + ).toHaveBeenCalledWith({ + chainId, + type: 'retries_exhausted', + error: expectedDegradedError, + networkClientId: 'AAAA-AAAA-AAAA-AAAA', + retryReason: 'non_successful_http_status', + rpcMethodName: 'eth_blockNumber', + duration: undefined, + traceId: undefined, + }); + }, + ); + }, + ); + }); + + it('publishes the NetworkController:rpcEndpointChainDegraded event only once, even if the time to complete a request to a primary endpoint is continually too long', async () => { + const request = { + method: 'eth_gasPrice', + params: [], + }; + + await withMockedCommunications( + { providerType: networkClientType }, + async (comms) => { + const messenger = buildRootMessenger(); + const rpcEndpointChainDegradedEventHandler = jest.fn(); + messenger.subscribe( + 'NetworkController:rpcEndpointChainDegraded', + rpcEndpointChainDegradedEventHandler, + ); + + await withNetworkClient( + { + providerType: networkClientType, + networkClientId: 'AAAA-AAAA-AAAA-AAAA', + messenger, + getRpcServiceOptions: () => ({ + fetch, + btoa, + policyOptions: { + backoff: new ConstantBackoff(backoffDuration), + }, + isOffline: (): boolean => false, + }), + }, + async ({ makeRpcCall, chainId }) => { + // The first time a block-cacheable request is made, the + // latest block number is retrieved through the block + // tracker first. + comms.mockRpcCall({ + request: { + method: 'eth_blockNumber', + params: [], + }, + response: () => { + jest.advanceTimersByTime(DEFAULT_DEGRADED_THRESHOLD + 1); + return { + result: '0x1', + }; + }, + }); + comms.mockRpcCall({ + request, + response: () => { + jest.advanceTimersByTime(DEFAULT_DEGRADED_THRESHOLD + 1); + return { + result: 'ok', + }; + }, + times: 2, + }); + + await makeRpcCall(request); + await makeRpcCall(request); + + expect( + rpcEndpointChainDegradedEventHandler, + ).toHaveBeenCalledTimes(1); + expect( + rpcEndpointChainDegradedEventHandler, + ).toHaveBeenCalledWith({ + chainId, + type: 'slow_success', + error: undefined, + networkClientId: 'AAAA-AAAA-AAAA-AAAA', + rpcMethodName: 'eth_blockNumber', + duration: expect.any(Number), + traceId: undefined, + }); + }, + ); + }, + ); + }); + + it('publishes the NetworkController:rpcEndpointDegraded event each time the max number of retries is reached in making requests to a primary endpoint', async () => { + const request = { + method: 'eth_gasPrice', + params: [], + }; + const expectedError = createResourceUnavailableError(503); + const expectedDegradedError = new HttpError(503); + + await withMockedCommunications( + { providerType: networkClientType }, + async (comms) => { + // The first time a block-cacheable request is made, the + // latest block number is retrieved through the block + // tracker first. + comms.mockRpcCall({ + request: { + method: 'eth_blockNumber', + params: [], + }, + times: DEFAULT_MAX_CONSECUTIVE_FAILURES, + response: { + httpStatus: 503, + }, + }); + + const messenger = buildRootMessenger(); + const rpcEndpointDegradedEventHandler = jest.fn(); + messenger.subscribe( + 'NetworkController:rpcEndpointDegraded', + rpcEndpointDegradedEventHandler, + ); + + await withNetworkClient( + { + providerType: networkClientType, + networkClientId: 'AAAA-AAAA-AAAA-AAAA', + messenger, + getRpcServiceOptions: () => ({ + fetch, + btoa, + policyOptions: { + backoff: new ConstantBackoff(backoffDuration), + }, + isOffline: (): boolean => false, + }), + }, + async ({ makeRpcCall, chainId, rpcUrl }) => { + messenger.subscribe( + 'NetworkController:rpcEndpointRetried', + () => { + // Ensure that we advance to the next RPC request + // retry, not the next block tracker request. + jest.advanceTimersByTime(backoffDuration); + }, + ); + + // Hit the endpoint and exceed the max number of retries + await expect(makeRpcCall(request)).rejects.toThrow( + expectedError, + ); + // Hit the endpoint and exceed the max number of retries + await expect(makeRpcCall(request)).rejects.toThrow( + expectedError, + ); + // Hit the endpoint and exceed the max number of retries, + // breaking the circuit + await expect(makeRpcCall(request)).rejects.toThrow( + expectedError, + ); + + expect(rpcEndpointDegradedEventHandler).toHaveBeenCalledTimes( + 2, + ); + expect(rpcEndpointDegradedEventHandler).toHaveBeenCalledWith({ + chainId, + type: 'retries_exhausted', + endpointUrl: rpcUrl, + error: expectedDegradedError, + networkClientId: 'AAAA-AAAA-AAAA-AAAA', + primaryEndpointUrl: rpcUrl, + retryReason: 'non_successful_http_status', + rpcMethodName: 'eth_blockNumber', + duration: undefined, + traceId: undefined, + }); + expect(rpcEndpointDegradedEventHandler).toHaveBeenCalledWith({ + chainId, + type: 'retries_exhausted', + endpointUrl: rpcUrl, + error: expectedDegradedError, + networkClientId: 'AAAA-AAAA-AAAA-AAAA', + primaryEndpointUrl: rpcUrl, + retryReason: 'non_successful_http_status', + rpcMethodName: 'eth_blockNumber', + duration: undefined, + traceId: undefined, + }); + }, + ); + }, + ); + }); + + it('does not retry requests when user is offline (degraded scenario)', async () => { + const request = { + method: 'eth_gasPrice', + params: [], + }; + const expectedError = createResourceUnavailableError(503); + + await withMockedCommunications( + { providerType: networkClientType }, + async (comms) => { + // Mock only one failure - if retries were happening, we'd need more + comms.mockRpcCall({ + request: { + method: 'eth_blockNumber', + params: [], + }, + times: 1, + response: { + httpStatus: 503, + }, + }); + comms.mockRpcCall({ + request: { + method: 'eth_gasPrice', + params: [], + }, + times: 1, + response: { + httpStatus: 503, + }, + }); + + const rootMessenger = buildRootMessenger({ + connectivityStatus: CONNECTIVITY_STATUSES.Offline, + }); + + const rpcEndpointRetriedEventHandler = jest.fn(); + rootMessenger.subscribe( + 'NetworkController:rpcEndpointRetried', + rpcEndpointRetriedEventHandler, + ); + + await withNetworkClient( + { + providerType: networkClientType, + networkClientId: 'AAAA-AAAA-AAAA-AAAA', + messenger: rootMessenger, + getRpcServiceOptions: () => ({ + policyOptions: { + backoff: new ConstantBackoff(backoffDuration), + }, + }), + }, + async ({ makeRpcCall }) => { + // When offline, errors are not retried, so the request + // should fail immediately without retries + await expect(makeRpcCall(request)).rejects.toThrow( + expectedError, + ); + + // Verify that retry event was not published + expect(rpcEndpointRetriedEventHandler).not.toHaveBeenCalled(); + }, + ); + }, + ); + }); + + it('suppresses the NetworkController:rpcEndpointDegraded event when user is offline', async () => { + const request = { + method: 'eth_gasPrice', + params: [], + }; + const expectedError = createResourceUnavailableError(503); + + await withMockedCommunications( + { providerType: networkClientType }, + async (comms) => { + // The first time a block-cacheable request is made, the + // latest block number is retrieved through the block + // tracker first. + comms.mockRpcCall({ + request: { + method: 'eth_blockNumber', + params: [], + }, + times: DEFAULT_MAX_CONSECUTIVE_FAILURES, + response: { + httpStatus: 503, + }, + }); + + const rootMessenger = buildRootMessenger({ + connectivityStatus: CONNECTIVITY_STATUSES.Offline, + }); + + const rpcEndpointDegradedEventHandler = jest.fn(); + rootMessenger.subscribe( + 'NetworkController:rpcEndpointDegraded', + rpcEndpointDegradedEventHandler, + ); + + await withNetworkClient( + { + providerType: networkClientType, + networkClientId: 'AAAA-AAAA-AAAA-AAAA', + messenger: rootMessenger, + getRpcServiceOptions: () => ({ + policyOptions: { + backoff: new ConstantBackoff(backoffDuration), + }, + }), + }, + async ({ makeRpcCall }) => { + rootMessenger.subscribe( + 'NetworkController:rpcEndpointRetried', + () => { + // Ensure that we advance to the next RPC request + // retry, not the next block tracker request. + jest.advanceTimersByTime(backoffDuration); + }, + ); + + // When offline, errors are not retried, so the circuit + // won't accumulate failures and onServiceDegraded won't be called + await expect(makeRpcCall(request)).rejects.toThrow( + expectedError, + ); + await expect(makeRpcCall(request)).rejects.toThrow( + expectedError, + ); + await expect(makeRpcCall(request)).rejects.toThrow( + expectedError, + ); + + // Event should be suppressed when offline because retries + // are prevented, so onServiceDegraded is never called + expect( + rpcEndpointDegradedEventHandler, + ).not.toHaveBeenCalled(); + }, + ); + }, + ); + }); + + it('publishes the NetworkController:rpcEndpointDegraded event when the time to complete a request to a primary endpoint is continually too long', async () => { + const request = { + method: 'eth_gasPrice', + params: [], + }; + + await withMockedCommunications( + { providerType: networkClientType }, + async (comms) => { + const messenger = buildRootMessenger(); + const rpcEndpointDegradedEventHandler = jest.fn(); + messenger.subscribe( + 'NetworkController:rpcEndpointDegraded', + rpcEndpointDegradedEventHandler, + ); + + await withNetworkClient( + { + providerType: networkClientType, + networkClientId: 'AAAA-AAAA-AAAA-AAAA', + messenger, + getBlockTrackerOptions: () => ({ + pollingInterval: 10000, + }), + getRpcServiceOptions: () => ({ + fetch, + btoa, + policyOptions: { + backoff: new ConstantBackoff(backoffDuration), + }, + isOffline: (): boolean => false, + }), + }, + async ({ makeRpcCall, chainId, rpcUrl }) => { + // The first time a block-cacheable request is made, the + // latest block number is retrieved through the block + // tracker first. + comms.mockRpcCall({ + request: { + method: 'eth_blockNumber', + params: [], + }, + response: () => { + jest.advanceTimersByTime(DEFAULT_DEGRADED_THRESHOLD + 1); + return { + result: '0x1', + }; + }, + }); + comms.mockRpcCall({ + request, + response: () => { + jest.advanceTimersByTime(DEFAULT_DEGRADED_THRESHOLD + 1); + return { + result: 'ok', + }; + }, + }); + + await makeRpcCall(request); + + expect(rpcEndpointDegradedEventHandler).toHaveBeenCalledTimes( + 2, + ); + expect(rpcEndpointDegradedEventHandler).toHaveBeenCalledWith({ + chainId, + type: 'slow_success', + endpointUrl: rpcUrl, + error: undefined, + networkClientId: 'AAAA-AAAA-AAAA-AAAA', + primaryEndpointUrl: rpcUrl, + rpcMethodName: 'eth_blockNumber', + duration: expect.any(Number), + traceId: undefined, + }); + expect(rpcEndpointDegradedEventHandler).toHaveBeenCalledWith({ + chainId, + type: 'slow_success', + endpointUrl: rpcUrl, + error: undefined, + networkClientId: 'AAAA-AAAA-AAAA-AAAA', + primaryEndpointUrl: rpcUrl, + rpcMethodName: 'eth_gasPrice', + duration: expect.any(Number), + traceId: undefined, + }); + }, + ); + }, + ); + }); + + it('publishes the NetworkController:rpcEndpointChainAvailable event the first time a successful request to a (primary) RPC endpoint is made', async () => { + const request = { + method: 'eth_gasPrice', + params: [], + }; + + await withMockedCommunications( + { providerType: networkClientType }, + async (comms) => { + // The first time a block-cacheable request is made, the + // latest block number is retrieved through the block + // tracker first. + comms.mockNextBlockTrackerRequest({ + blockNumber, + }); + comms.mockRpcCall({ + request, + response: { + result: 'ok', + }, + }); + + const messenger = buildRootMessenger(); + const rpcEndpointChainAvailableEventHandler = jest.fn(); + messenger.subscribe( + 'NetworkController:rpcEndpointChainAvailable', + rpcEndpointChainAvailableEventHandler, + ); + + await withNetworkClient( + { + providerType: networkClientType, + networkClientId: 'AAAA-AAAA-AAAA-AAAA', + messenger, + getRpcServiceOptions: () => ({ + fetch, + btoa, + policyOptions: { + backoff: new ConstantBackoff(backoffDuration), + }, + isOffline: (): boolean => false, + }), + }, + async ({ makeRpcCall, chainId }) => { + await makeRpcCall(request); + + expect( + rpcEndpointChainAvailableEventHandler, + ).toHaveBeenCalledTimes(1); + expect( + rpcEndpointChainAvailableEventHandler, + ).toHaveBeenCalledWith({ + chainId, + networkClientId: 'AAAA-AAAA-AAAA-AAAA', + }); + }, + ); + }, + ); + }); + }); + }); + } +}); + +/** + * Creates a "resource unavailable" RPC error for testing. + * + * @param httpStatus - The HTTP status that the error represents. + * @returns The RPC error. + */ +function createResourceUnavailableError(httpStatus: number): Error { + return expect.objectContaining({ + code: errorCodes.rpc.resourceUnavailable, + message: 'RPC endpoint not found or unavailable.', + data: expect.objectContaining({ + httpStatus, + }), + }); +} diff --git a/packages/network-controller/src/create-network-client-tests/rpc-endpoint-failover.test.ts b/packages/network-controller/src/create-network-client-tests/rpc-endpoint-failover.test.ts new file mode 100644 index 00000000000..fa6f9d5e0c0 --- /dev/null +++ b/packages/network-controller/src/create-network-client-tests/rpc-endpoint-failover.test.ts @@ -0,0 +1,135 @@ +import { buildRootMessenger } from '../../tests/helpers.js'; +import { + withMockedCommunications, + withNetworkClient, +} from '../../tests/network-client/helpers.js'; + +describe('createNetworkClient - RPC endpoint failover (forced)', () => { + describe('when rpcFailoverMode is forced and providerType is infura', () => { + it('routes requests to the failover endpoint instead of Infura when failover URLs are provided', async () => { + const failoverUrl = 'https://failover.example.com'; + + // Only mock the failover URL — if Infura is hit, nock will throw because + // there is no matching mock for it. + // eth_gasPrice is not served by local middleware so it actually reaches + // the RPC endpoint, letting us confirm which host received the request. + await withMockedCommunications( + { + providerType: 'custom', + customRpcUrl: failoverUrl, + }, + async (failoverComms) => { + failoverComms.mockNextBlockTrackerRequest({ blockNumber: '0x1' }); + failoverComms.mockRpcCall({ + request: { method: 'eth_gasPrice', params: [] }, + response: { result: '0xabc' }, + }); + + const messenger = buildRootMessenger(); + + const result = await withNetworkClient( + { + providerType: 'infura', + failoverRpcUrls: [failoverUrl], + rpcFailoverMode: 'forced', + messenger, + getRpcServiceOptions: () => ({ + fetch, + btoa, + isOffline: (): boolean => false, + }), + }, + async ({ makeRpcCall }) => { + return await makeRpcCall({ method: 'eth_gasPrice', params: [] }); + }, + ); + + expect(result).toBe('0xabc'); + }, + ); + }); + + it('falls back to Infura when no failover URLs are provided', async () => { + // Only mock Infura — if any failover were hit, nock would throw. + await withMockedCommunications( + { + providerType: 'infura', + }, + async (infuraComms) => { + infuraComms.mockNextBlockTrackerRequest({ blockNumber: '0x1' }); + infuraComms.mockRpcCall({ + request: { method: 'eth_gasPrice', params: [] }, + response: { result: '0xdef' }, + }); + + const messenger = buildRootMessenger(); + + const result = await withNetworkClient( + { + providerType: 'infura', + failoverRpcUrls: [], + rpcFailoverMode: 'forced', + messenger, + getRpcServiceOptions: () => ({ + fetch, + btoa, + isOffline: (): boolean => false, + }), + }, + async ({ makeRpcCall }) => { + return await makeRpcCall({ method: 'eth_gasPrice', params: [] }); + }, + ); + + expect(result).toBe('0xdef'); + }, + ); + }); + }); + + describe('when rpcFailoverMode is forced and providerType is custom', () => { + it('still routes requests to the custom primary endpoint, not the failover', async () => { + const customRpcUrl = 'https://custom.example.com'; + const failoverUrl = 'https://failover.example.com'; + + // Only mock the custom URL — if failover is hit, nock will throw. + // eth_gasPrice is not served by local middleware so it actually reaches + // the RPC endpoint, letting us confirm which host received the request. + await withMockedCommunications( + { + providerType: 'custom', + customRpcUrl, + }, + async (customComms) => { + customComms.mockNextBlockTrackerRequest({ blockNumber: '0x1' }); + customComms.mockRpcCall({ + request: { method: 'eth_gasPrice', params: [] }, + response: { result: '0xabc' }, + }); + + const messenger = buildRootMessenger(); + + const result = await withNetworkClient( + { + providerType: 'custom', + customRpcUrl, + failoverRpcUrls: [failoverUrl], + rpcFailoverMode: 'forced', + messenger, + getRpcServiceOptions: () => ({ + fetch, + btoa, + isOffline: (): boolean => false, + }), + }, + async ({ makeRpcCall }) => { + return await makeRpcCall({ method: 'eth_gasPrice', params: [] }); + }, + ); + + expect(result).toBe('0xabc'); + }, + ); + }); + }); +}); diff --git a/packages/network-controller/src/create-network-client.ts b/packages/network-controller/src/create-network-client.ts index 99eddd9bf7a..c83df15bc59 100644 --- a/packages/network-controller/src/create-network-client.ts +++ b/packages/network-controller/src/create-network-client.ts @@ -1,5 +1,11 @@ -import type { InfuraNetworkType } from '@metamask/controller-utils'; -import { ChainId } from '@metamask/controller-utils'; +import { CONNECTIVITY_STATUSES } from '@metamask/connectivity-controller'; +import type { CockatielFailureReason } from '@metamask/controller-utils'; +import { + DEFAULT_MAX_CONSECUTIVE_FAILURES, + DEFAULT_MAX_RETRIES, +} from '@metamask/controller-utils'; +import type { PollingBlockTrackerOptions } from '@metamask/eth-block-tracker'; +import { PollingBlockTracker } from '@metamask/eth-block-tracker'; import { createInfuraMiddleware } from '@metamask/eth-json-rpc-infura'; import { createBlockCacheMiddleware, @@ -10,30 +16,88 @@ import { createFetchMiddleware, createRetryOnEmptyMiddleware, } from '@metamask/eth-json-rpc-middleware'; -import type { SafeEventEmitterProvider } from '@metamask/eth-json-rpc-provider'; -import { - providerFromEngine, - providerFromMiddleware, -} from '@metamask/eth-json-rpc-provider'; +import { InternalProvider } from '@metamask/eth-json-rpc-provider'; +import { providerFromMiddlewareV2 } from '@metamask/eth-json-rpc-provider'; +import { asV2Middleware } from '@metamask/json-rpc-engine'; import { - createAsyncMiddleware, createScaffoldMiddleware, - JsonRpcEngine, - mergeMiddleware, -} from '@metamask/json-rpc-engine'; -import type { JsonRpcMiddleware } from '@metamask/json-rpc-engine'; -import type { Hex, Json, JsonRpcParams } from '@metamask/utils'; -import { PollingBlockTracker } from 'eth-block-tracker'; + JsonRpcEngineV2, +} from '@metamask/json-rpc-engine/v2'; +import type { + JsonRpcMiddleware, + MiddlewareContext, +} from '@metamask/json-rpc-engine/v2'; +import { inMilliseconds, Duration } from '@metamask/utils'; +import type { Hex, Json, JsonRpcRequest } from '@metamask/utils'; +import type { Logger } from 'loglevel'; +import type { + NetworkClientId, + NetworkControllerMessenger, +} from './NetworkController.js'; +import { RpcServiceChain } from './rpc-service/rpc-service-chain.js'; +import type { RpcServiceOptionsWithDefaults } from './rpc-service/rpc-service.js'; +import { + isConnectionError, + isConnectionResetError, + isJsonParseError, + isHttpServerError, + isTimeoutError, +} from './rpc-service/rpc-service.js'; +import type { RpcFailoverMode } from './selectors.js'; import type { BlockTracker, NetworkClientConfiguration, Provider, -} from './types'; -import { NetworkClientType } from './types'; +} from './types.js'; +import { NetworkClientType } from './types.js'; const SECOND = 1000; +/** + * Why the degraded event was emitted. + */ +export type DegradedEventType = 'slow_success' | 'retries_exhausted'; + +/** + * The category of error that was retried until retries were exhausted. + */ +export type RetryReason = + | 'connection_failed' + | 'response_not_json' + | 'non_successful_http_status' + | 'timed_out' + | 'connection_reset' + | 'unknown'; + +/** + * Classifies the error that was being retried when retries were exhausted. + * + * @param error - The error from the last retry attempt. + * @returns A classification string. + */ +export function classifyRetryReason(error: unknown): RetryReason { + if (!(error instanceof Error)) { + return 'unknown'; + } + if (isConnectionError(error)) { + return 'connection_failed'; + } + if (isJsonParseError(error)) { + return 'response_not_json'; + } + if (isHttpServerError(error)) { + return 'non_successful_http_status'; + } + if (isTimeoutError(error)) { + return 'timed_out'; + } + if (isConnectionResetError(error)) { + return 'connection_reset'; + } + return 'unknown'; +} + /** * The pair of provider / block tracker that can be used to interface with the * network and respond to new activity. @@ -45,66 +109,442 @@ export type NetworkClient = { destroy: () => void; }; +type RpcApiMiddleware = JsonRpcMiddleware< + JsonRpcRequest, + Json, + MiddlewareContext<{ origin: string }> +>; + /** * Create a JSON RPC network client for a specific network. * - * @param networkConfig - The network configuration. + * @param args - The arguments. + * @param args.id - The ID that will be assigned to the new network client in + * the registry. + * @param args.configuration - The network configuration. + * @param args.getRpcServiceOptions - Factory for constructing RPC service + * options. See {@link NetworkControllerOptions.getRpcServiceOptions}. + * @param args.getBlockTrackerOptions - Factory for constructing block tracker + * options. See {@link NetworkControllerOptions.getBlockTrackerOptions}. + * @param args.messenger - The network controller messenger. + * @param args.rpcFailoverMode - The RPC failover mode to apply: `disabled` + * (failover off), `enabled` (divert to the configured failover URLs when the + * primary endpoint is unavailable), or `forced` (Infura endpoints that have + * failover URLs route all traffic to those URLs, bypassing Infura entirely). + * @param args.logger - A `loglevel` logger. * @returns The network client. */ -export function createNetworkClient( - networkConfig: NetworkClientConfiguration, -): NetworkClient { - const rpcApiMiddleware = - networkConfig.type === NetworkClientType.Infura - ? createInfuraMiddleware({ - network: networkConfig.network, - projectId: networkConfig.infuraProjectId, - maxAttempts: 5, +export function createNetworkClient({ + id, + configuration, + getRpcServiceOptions, + getBlockTrackerOptions, + messenger, + rpcFailoverMode, + logger, +}: { + id: NetworkClientId; + configuration: NetworkClientConfiguration; + getRpcServiceOptions?: ( + rpcEndpointUrl: string, + ) => RpcServiceOptionsWithDefaults; + getBlockTrackerOptions: ( + rpcEndpointUrl: string, + ) => Omit; + messenger: NetworkControllerMessenger; + rpcFailoverMode: RpcFailoverMode; + logger?: Logger; +}): NetworkClient { + const primaryEndpointUrl = + configuration.type === NetworkClientType.Infura + ? `https://${configuration.network}.infura.io/v3/${configuration.infuraProjectId}` + : configuration.rpcUrl; + const rpcServiceChain = createRpcServiceChain({ + id, + primaryEndpointUrl, + configuration, + getRpcServiceOptions, + messenger, + rpcFailoverMode, + logger, + }); + + let rpcApiMiddleware: RpcApiMiddleware; + if (configuration.type === NetworkClientType.Infura) { + rpcApiMiddleware = asV2Middleware( + createInfuraMiddleware({ + rpcService: rpcServiceChain, + options: { source: 'metamask', - }) - : createFetchMiddleware({ - btoa: global.btoa, - fetch: global.fetch, - rpcUrl: networkConfig.rpcUrl, - }); + }, + }), + ); + } else { + rpcApiMiddleware = createFetchMiddleware({ rpcService: rpcServiceChain }); + } - const rpcProvider = providerFromMiddleware(rpcApiMiddleware); + const rpcProvider = providerFromMiddlewareV2(rpcApiMiddleware); - const blockTrackerOpts = - // eslint-disable-next-line n/no-process-env - process.env.IN_TEST && networkConfig.type === 'custom' - ? { pollingInterval: SECOND } - : {}; - const blockTracker = new PollingBlockTracker({ - ...blockTrackerOpts, + const blockTracker = createBlockTracker({ + networkClientType: configuration.type, + endpointUrl: primaryEndpointUrl, + getOptions: getBlockTrackerOptions, provider: rpcProvider, }); const networkMiddleware = - networkConfig.type === NetworkClientType.Infura + configuration.type === NetworkClientType.Infura ? createInfuraNetworkMiddleware({ blockTracker, - network: networkConfig.network, + chainId: configuration.chainId, rpcProvider, rpcApiMiddleware, }) : createCustomNetworkMiddleware({ blockTracker, - chainId: networkConfig.chainId, + chainId: configuration.chainId, rpcApiMiddleware, }); - const engine = new JsonRpcEngine(); + const provider: Provider = new InternalProvider({ + engine: JsonRpcEngineV2.create({ + middleware: [networkMiddleware], + }), + }); - engine.push(networkMiddleware); + const destroy = (): void => { + // 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(); + }; - const provider = providerFromEngine(engine); + return { configuration, provider, blockTracker, destroy }; +} - const destroy = () => { - blockTracker.destroy(); +/** + * Determines the ordered list of endpoints that make up the RPC service chain + * for a network, honoring the RPC failover flags. + * + * @param args - The arguments. + * @param args.primaryEndpointUrl - The primary endpoint URL for the network. + * @param args.failoverRpcUrls - The configured failover URLs, if any. + * @param args.rpcFailoverMode - The RPC failover mode to apply: `disabled`, + * `enabled` (divert to the failover URLs when the primary is unavailable), or + * `forced` (route all traffic for Infura endpoints with failover URLs to those + * URLs, bypassing Infura). + * @returns The endpoints to use, each flagged as primary or failover. + */ +function getAvailableEndpoints({ + primaryEndpointUrl, + failoverRpcUrls, + rpcFailoverMode, +}: { + primaryEndpointUrl: string; + failoverRpcUrls: string[] | undefined; + rpcFailoverMode: RpcFailoverMode; +}): { url: string; isFailover: boolean }[] { + const failoverEndpoints = (failoverRpcUrls ?? []).map((url) => ({ + url, + isFailover: true, + })); + // We explicitly check the URL since some networks have been added with invalid configuration types in the past. + const isInfura = new URL(primaryEndpointUrl).hostname.endsWith('.infura.io'); + + if ( + rpcFailoverMode === 'forced' && + isInfura && + failoverEndpoints.length > 0 + ) { + // Forced mode for an Infura endpoint with failovers: bypass Infura entirely + // and route all traffic (including block polling) to failovers. The first + // failover becomes the positional primary of the chain, so + // availability/degraded events will report that failover URL as the primary + // endpoint (there is no Infura primary in this mode). + return failoverEndpoints; + } + if (rpcFailoverMode === 'enabled' && isInfura) { + return [ + { url: primaryEndpointUrl, isFailover: false }, + ...failoverEndpoints, + ]; + } + return [{ url: primaryEndpointUrl, isFailover: false }]; +} + +/** + * Creates an RPC service chain, which represents the primary endpoint URL for + * the network as well as its failover URLs. + * + * @param args - The arguments. + * @param args.id - The ID that will be assigned to the new network client in + * the registry. + * @param args.primaryEndpointUrl - The primary endpoint URL. + * @param args.configuration - The network configuration. + * @param args.getRpcServiceOptions - Factory for constructing RPC service + * options. See {@link NetworkControllerOptions.getRpcServiceOptions}. + * @param args.messenger - The network controller messenger. + * @param args.rpcFailoverMode - The RPC failover mode to apply: `disabled` + * (failover off), `enabled` (divert to the configured failover URLs when the + * primary endpoint is unavailable), or `forced` (Infura endpoints that have + * failover URLs route all traffic to those URLs, bypassing Infura entirely). + * @param args.logger - A `loglevel` logger. + * @returns The RPC service chain. + */ +function createRpcServiceChain({ + id, + primaryEndpointUrl, + configuration, + getRpcServiceOptions, + messenger, + rpcFailoverMode, + logger, +}: { + id: NetworkClientId; + primaryEndpointUrl: string; + configuration: NetworkClientConfiguration; + getRpcServiceOptions?: ( + rpcEndpointUrl: string, + ) => RpcServiceOptionsWithDefaults; + messenger: NetworkControllerMessenger; + rpcFailoverMode: RpcFailoverMode; + logger?: Logger; +}): RpcServiceChain { + const availableEndpoints = getAvailableEndpoints({ + primaryEndpointUrl, + failoverRpcUrls: configuration.failoverRpcUrls, + rpcFailoverMode, + }); + + const isOffline = (): boolean => { + const connectivityState = messenger.call('ConnectivityController:getState'); + return ( + connectivityState.connectivityStatus === CONNECTIVITY_STATUSES.Offline + ); }; - return { configuration: networkConfig, provider, blockTracker, destroy }; + // Ensure that if the endpoint continually responds with errors, we + // break the circuit relatively fast (but not prematurely). + // + // Note that the circuit will break much faster if the errors are + // retriable (e.g. 503) than if not (e.g. 500), so we attempt to strike + // a balance here. + const maxConsecutiveFailures = DEFAULT_MAX_CONSECUTIVE_FAILURES; + + // The number of rounds of retries that will break the circuit, + // triggering a "cooldown". + // + // When we fail over to QuickNode, we expect it to be down at first + // while it is being automatically activated, and we don't want to + // activate the "cooldown" accidentally. + const maxConsecutiveFailuresFailover = (DEFAULT_MAX_RETRIES + 1) * 10; + + const rpcServiceConfigurations = availableEndpoints.map((endpoint) => { + const overriddenOptions = getRpcServiceOptions?.(endpoint.url) ?? {}; + return { + fetch: globalThis.fetch.bind(globalThis), + btoa: globalThis.btoa.bind(globalThis), + isOffline, + policyOptions: { + maxRetries: DEFAULT_MAX_RETRIES, + circuitBreakDuration: inMilliseconds(30, Duration.Second), + maxConsecutiveFailures: endpoint.isFailover + ? maxConsecutiveFailuresFailover + : maxConsecutiveFailures, + ...(overriddenOptions.policyOptions ?? {}), + }, + ...overriddenOptions, + endpointUrl: endpoint.url, + logger, + }; + }); + + /** + * Extracts the error from Cockatiel's `FailureReason` type received in + * circuit breaker event handlers. + * + * The `FailureReason` object can have two possible shapes: + * - `{ error: Error }` - When the RPC service throws an error (the common + * case for RPC failures). + * - `{ value: T }` - When the RPC service returns a value that the retry + * filter policy considers a failure. + * + * @param value - The event data object from the circuit breaker event + * listener (after destructuring known properties like `endpointUrl`). This + * represents Cockatiel's `FailureReason` type. + * @returns The error or failure value, or `undefined` if neither property + * exists (which shouldn't happen in practice unless the circuit breaker is + * manually isolated). + */ + const getError = ( + value: CockatielFailureReason | Record, + ): Error | unknown | undefined => { + if ('error' in value) { + return value.error; + } else if ('value' in value) { + return value.value; + } + return undefined; + }; + + const rpcServiceChain = new RpcServiceChain([ + rpcServiceConfigurations[0], + ...rpcServiceConfigurations.slice(1), + ]); + + rpcServiceChain.onBreak((data) => { + const error = getError(data); + + if (error === undefined) { + // This error shouldn't happen in practice because we never call `.isolate` + // on the circuit breaker policy, but we need to appease TypeScript. + throw new Error('Could not make request to endpoint.'); + } + + messenger.publish('NetworkController:rpcEndpointChainUnavailable', { + chainId: configuration.chainId, + networkClientId: id, + error, + }); + }); + + rpcServiceChain.onServiceBreak( + ({ + endpointUrl, + primaryEndpointUrl: primaryEndpointUrlFromEvent, + ...rest + }) => { + const error = getError(rest); + + if (error === undefined) { + // This error shouldn't happen in practice because we never call `.isolate` + // on the circuit breaker policy, but we need to appease TypeScript. + throw new Error('Could not make request to endpoint.'); + } + + messenger.publish('NetworkController:rpcEndpointUnavailable', { + chainId: configuration.chainId, + networkClientId: id, + primaryEndpointUrl: primaryEndpointUrlFromEvent, + endpointUrl, + error, + }); + }, + ); + + rpcServiceChain.onDegraded( + ({ rpcMethodName, duration, traceId, ...rest }) => { + const error = getError(rest); + const type: DegradedEventType = + error === undefined ? 'slow_success' : 'retries_exhausted'; + messenger.publish('NetworkController:rpcEndpointChainDegraded', { + chainId: configuration.chainId, + networkClientId: id, + error, + rpcMethodName, + duration, + traceId, + type, + retryReason: + error === undefined ? undefined : classifyRetryReason(error), + }); + }, + ); + + rpcServiceChain.onServiceDegraded( + ({ + endpointUrl, + primaryEndpointUrl: primaryEndpointUrlFromEvent, + rpcMethodName, + duration, + traceId, + ...rest + }) => { + const error = getError(rest); + const type: DegradedEventType = + error === undefined ? 'slow_success' : 'retries_exhausted'; + + messenger.publish('NetworkController:rpcEndpointDegraded', { + chainId: configuration.chainId, + networkClientId: id, + primaryEndpointUrl: primaryEndpointUrlFromEvent, + endpointUrl, + error, + rpcMethodName, + duration, + traceId, + type, + retryReason: + error === undefined ? undefined : classifyRetryReason(error), + }); + }, + ); + + rpcServiceChain.onAvailable(() => { + messenger.publish('NetworkController:rpcEndpointChainAvailable', { + chainId: configuration.chainId, + networkClientId: id, + }); + }); + + rpcServiceChain.onServiceRetry( + ({ + attempt, + endpointUrl, + primaryEndpointUrl: primaryEndpointUrlFromEvent, + }) => { + messenger.publish('NetworkController:rpcEndpointRetried', { + chainId: configuration.chainId, + networkClientId: id, + primaryEndpointUrl: primaryEndpointUrlFromEvent, + endpointUrl, + attempt, + }); + }, + ); + + return rpcServiceChain; +} + +/** + * Create the block tracker for the network. + * + * @param args - The arguments. + * @param args.networkClientType - The type of the network client ("infura" or + * "custom"). + * @param args.endpointUrl - The URL of the endpoint. + * @param args.getOptions - Factory for the block tracker options. + * @param args.provider - The EIP-1193 provider for the network's JSON-RPC + * middleware stack. + * @returns The created block tracker. + */ +function createBlockTracker({ + networkClientType, + endpointUrl, + getOptions, + provider, +}: { + networkClientType: NetworkClientType; + endpointUrl: string; + getOptions: ( + rpcEndpointUrl: string, + ) => Omit; + provider: InternalProvider; +}): PollingBlockTracker { + const defaultOptions = { + pollingInterval: + // Needed for testing. + // eslint-disable-next-line no-restricted-globals + process.env.IN_TEST && networkClientType === NetworkClientType.Custom + ? inMilliseconds(1, Duration.Second) + : inMilliseconds(20, Duration.Second), + retryTimeout: inMilliseconds(20, Duration.Second), + }; + + return new PollingBlockTracker({ + ...defaultOptions, + ...getOptions(endpointUrl), + provider, + }); } /** @@ -112,57 +552,62 @@ export function createNetworkClient( * * @param args - The arguments. * @param args.blockTracker - The block tracker to use. - * @param args.network - The Infura network to use. + * @param args.chainId - The chain id to use. * @param args.rpcProvider - The RPC provider to use. * @param args.rpcApiMiddleware - Additional middleware. * @returns The collection of middleware that makes up the Infura client. */ function createInfuraNetworkMiddleware({ blockTracker, - network, + chainId, rpcProvider, rpcApiMiddleware, }: { blockTracker: PollingBlockTracker; - network: InfuraNetworkType; - rpcProvider: SafeEventEmitterProvider; - rpcApiMiddleware: JsonRpcMiddleware; -}) { - return mergeMiddleware([ - createNetworkAndChainIdMiddleware({ network }), - createBlockCacheMiddleware({ blockTracker }), - createInflightCacheMiddleware(), - createBlockRefMiddleware({ blockTracker, provider: rpcProvider }), - createRetryOnEmptyMiddleware({ blockTracker, provider: rpcProvider }), - createBlockTrackerInspectorMiddleware({ blockTracker }), - rpcApiMiddleware, - ]); + chainId: string; + rpcProvider: InternalProvider; + rpcApiMiddleware: RpcApiMiddleware; +}): JsonRpcMiddleware< + JsonRpcRequest, + Json, + MiddlewareContext<{ origin: string; skipCache: boolean }> +> { + return JsonRpcEngineV2.create({ + middleware: [ + createNetworkAndChainIdMiddleware({ chainId }), + createBlockCacheMiddleware({ blockTracker }), + createInflightCacheMiddleware(), + createBlockRefMiddleware({ blockTracker, provider: rpcProvider }), + createRetryOnEmptyMiddleware({ blockTracker, provider: rpcProvider }), + createBlockTrackerInspectorMiddleware({ blockTracker }), + rpcApiMiddleware, + ], + }).asMiddleware(); } /** * Creates static method middleware. * * @param args - The Arguments. - * @param args.network - The Infura network to use. + * @param args.chainId - The chain id to use. * @returns The middleware that implements the eth_chainId method. */ function createNetworkAndChainIdMiddleware({ - network, + chainId, }: { - network: InfuraNetworkType; -}) { + chainId: string; +}): JsonRpcMiddleware { return createScaffoldMiddleware({ - eth_chainId: ChainId[network], + eth_chainId: chainId, }); } const createChainIdMiddleware = ( chainId: Hex, -): JsonRpcMiddleware => { - return (req, res, next, end) => { - if (req.method === 'eth_chainId') { - res.result = chainId; - return end(); +): JsonRpcMiddleware => { + return ({ request, next }) => { + if (request.method === 'eth_chainId') { + return chainId; } return next(); }; @@ -184,22 +629,29 @@ function createCustomNetworkMiddleware({ }: { blockTracker: PollingBlockTracker; chainId: Hex; - rpcApiMiddleware: JsonRpcMiddleware; -}): JsonRpcMiddleware { - // eslint-disable-next-line n/no-process-env + rpcApiMiddleware: RpcApiMiddleware; +}): JsonRpcMiddleware< + JsonRpcRequest, + Json, + MiddlewareContext<{ origin: string; skipCache: boolean }> +> { + // Needed for testing. + // eslint-disable-next-line no-restricted-globals const testMiddlewares = process.env.IN_TEST ? [createEstimateGasDelayTestMiddleware()] : []; - return mergeMiddleware([ - ...testMiddlewares, - createChainIdMiddleware(chainId), - createBlockRefRewriteMiddleware({ blockTracker }), - createBlockCacheMiddleware({ blockTracker }), - createInflightCacheMiddleware(), - createBlockTrackerInspectorMiddleware({ blockTracker }), - rpcApiMiddleware, - ]); + return JsonRpcEngineV2.create({ + middleware: [ + ...testMiddlewares, + createChainIdMiddleware(chainId), + createBlockRefRewriteMiddleware({ blockTracker }), + createBlockCacheMiddleware({ blockTracker }), + createInflightCacheMiddleware(), + createBlockTrackerInspectorMiddleware({ blockTracker }), + rpcApiMiddleware, + ], + }).asMiddleware(); } /** @@ -208,11 +660,14 @@ function createCustomNetworkMiddleware({ * * @returns The middleware for delaying gas estimation calls by 2 seconds when in test. */ -function createEstimateGasDelayTestMiddleware() { - return createAsyncMiddleware(async (req, _, next) => { - if (req.method === 'eth_estimateGas') { +function createEstimateGasDelayTestMiddleware(): JsonRpcMiddleware< + JsonRpcRequest, + Json +> { + return async ({ request, next }) => { + if (request.method === 'eth_estimateGas') { await new Promise((resolve) => setTimeout(resolve, SECOND * 2)); } return next(); - }); + }; } diff --git a/packages/network-controller/src/index.ts b/packages/network-controller/src/index.ts index a21eebd9bc7..e2909f14a9d 100644 --- a/packages/network-controller/src/index.ts +++ b/packages/network-controller/src/index.ts @@ -1,5 +1,86 @@ -export * from './NetworkController'; -export * from './constants'; -export type { BlockTracker, Provider } from './types'; -export type { NetworkClientConfiguration } from './types'; -export { NetworkClientType } from './types'; +export type { AutoManagedNetworkClient } from './create-auto-managed-network-client.js'; +export type { + Block, + NetworkMetadata, + NetworkConfiguration, + BuiltInNetworkClientId, + CustomNetworkClientId, + NetworkClientId, + NetworksMetadata, + NetworkState, + BlockTrackerProxy, + ProviderProxy, + AddNetworkCustomRpcEndpointFields, + AddNetworkFields, + UpdateNetworkFields, + InfuraRpcEndpoint, + NetworkControllerStateChangeEvent, + NetworkControllerNetworkWillChangeEvent, + NetworkControllerNetworkDidChangeEvent, + NetworkControllerInfuraIsBlockedEvent, + NetworkControllerInfuraIsUnblockedEvent, + NetworkControllerNetworkAddedEvent, + NetworkControllerNetworkRemovedEvent, + NetworkControllerEvents, + NetworkControllerGetStateAction, + NetworkControllerActions, + NetworkControllerMessenger, + NetworkControllerOptions, + NetworkControllerRpcEndpointChainUnavailableEvent, + NetworkControllerRpcEndpointUnavailableEvent, + NetworkControllerRpcEndpointChainDegradedEvent, + NetworkControllerRpcEndpointDegradedEvent, + NetworkControllerRpcEndpointChainAvailableEvent, + NetworkControllerRpcEndpointRetriedEvent, +} from './NetworkController.js'; +export { + getDefaultNetworkControllerState, + selectAvailableNetworkClientIds, + knownKeysOf, + NetworkController, + RpcEndpointType, +} from './NetworkController.js'; +export * from './constants.js'; +export type { BlockTracker, Provider } from './types.js'; +export type { + NetworkClientConfiguration, + InfuraNetworkClientConfiguration, + CustomNetworkClientConfiguration, +} from './types.js'; +export { NetworkClientType } from './types.js'; +export type { NetworkClient } from './create-network-client.js'; +export type { AbstractRpcService } from './rpc-service/abstract-rpc-service.js'; +export type { RpcServiceRequestable } from './rpc-service/rpc-service-requestable.js'; +export type { + DegradedEventType, + RetryReason, +} from './create-network-client.js'; +export { classifyRetryReason } from './create-network-client.js'; +export type { + NetworkControllerAnalyticsOptions, + RpcServiceEventName, +} from './rpc-service-analytics.js'; +export { isConnectionError } from './rpc-service/rpc-service.js'; +export type { + NetworkControllerGetEthQueryAction, + NetworkControllerGetNetworkClientByIdAction, + NetworkControllerGetSelectedNetworkClientAction, + NetworkControllerGetSelectedChainIdAction, + NetworkControllerGetEIP1559CompatibilityAction, + NetworkControllerFindNetworkClientIdByChainIdAction, + NetworkControllerSetProviderTypeAction, + NetworkControllerSetActiveNetworkAction, + NetworkControllerGetNetworkConfigurationByChainIdAction, + NetworkControllerGetNetworkConfigurationByNetworkClientIdAction, + NetworkControllerAddNetworkAction, + NetworkControllerRemoveNetworkAction, + NetworkControllerUpdateNetworkAction, + NetworkControllerGetProviderAndBlockTrackerAction, + NetworkControllerGetNetworkClientRegistryAction, + NetworkControllerLookupNetworkAction, + NetworkControllerLookupNetworkByClientIdAction, + NetworkControllerGet1559CompatibilityWithNetworkClientIdAction, + NetworkControllerResetConnectionAction, + NetworkControllerRollbackToPreviousProviderAction, + NetworkControllerLoadBackupAction, +} from './NetworkController-method-action-types.js'; diff --git a/packages/network-controller/src/rpc-service-analytics.test.ts b/packages/network-controller/src/rpc-service-analytics.test.ts new file mode 100644 index 00000000000..381bd6217d0 --- /dev/null +++ b/packages/network-controller/src/rpc-service-analytics.test.ts @@ -0,0 +1,163 @@ +import type { Hex } from '@metamask/utils'; + +import { + buildRpcServiceDegradedAnalyticsTrackingEvent, + buildRpcServiceUnavailableAnalyticsTrackingEvent, + sanitizeRpcEndpointUrl, +} from './rpc-service-analytics.js'; + +const PUBLIC_URL = 'https://mainnet.infura.io/v3/the-key'; +const isPublic = (): boolean => true; + +const UNAVAILABLE_PAYLOAD = { + chainId: '0x1' as Hex, + endpointUrl: PUBLIC_URL, + error: undefined as unknown, + networkClientId: 'mainnet', + primaryEndpointUrl: PUBLIC_URL, +}; + +const DEGRADED_PAYLOAD = { + chainId: '0x1' as Hex, + endpointUrl: PUBLIC_URL, + error: undefined as unknown, + networkClientId: 'mainnet', + primaryEndpointUrl: PUBLIC_URL, + rpcMethodName: 'eth_blockNumber', + type: 'slow_success' as const, +}; + +describe('sanitizeRpcEndpointUrl', () => { + it('returns the host of the URL when the endpoint is public', () => { + expect(sanitizeRpcEndpointUrl(PUBLIC_URL, true)).toBe('mainnet.infura.io'); + }); + + it('returns "custom" when the endpoint is not public', () => { + expect( + sanitizeRpcEndpointUrl('https://private.example.com/secret', false), + ).toBe('custom'); + }); + + it('returns "custom" when the endpoint is public but cannot be parsed', () => { + expect(sanitizeRpcEndpointUrl('not a url', true)).toBe('custom'); + }); +}); + +describe('buildRpcServiceUnavailableAnalyticsTrackingEvent', () => { + it('builds the event with base properties from a public endpoint', () => { + expect( + buildRpcServiceUnavailableAnalyticsTrackingEvent( + UNAVAILABLE_PAYLOAD, + isPublic, + ), + ).toStrictEqual({ + name: 'RPC Service Unavailable', + properties: { + chain_id_caip: 'eip155:1', + rpc_domain: 'mainnet.infura.io', + rpc_endpoint_url: 'mainnet.infura.io', + }, + sensitiveProperties: {}, + saveDataRecording: false, + hasProperties: true, + }); + }); + + it('reports the domain as "custom" when the endpoint is not public', () => { + const event = buildRpcServiceUnavailableAnalyticsTrackingEvent( + { ...UNAVAILABLE_PAYLOAD, endpointUrl: 'https://private.example.com/x' }, + () => false, + ); + + expect(event.properties).toMatchObject({ + rpc_domain: 'custom', + rpc_endpoint_url: 'custom', + }); + }); + + it('converts the chain ID to a CAIP decimal value', () => { + const event = buildRpcServiceUnavailableAnalyticsTrackingEvent( + { ...UNAVAILABLE_PAYLOAD, chainId: '0xe708' }, + isPublic, + ); + + expect(event.properties).toMatchObject({ chain_id_caip: 'eip155:59144' }); + }); + + it('includes http_status when the error carries a JSON-serializable httpStatus', () => { + const event = buildRpcServiceUnavailableAnalyticsTrackingEvent( + { ...UNAVAILABLE_PAYLOAD, error: { httpStatus: 503 } }, + isPublic, + ); + + expect(event.properties).toMatchObject({ http_status: 503 }); + }); + + it('omits http_status when the error has no httpStatus', () => { + const event = buildRpcServiceUnavailableAnalyticsTrackingEvent( + { ...UNAVAILABLE_PAYLOAD, error: new Error('boom') }, + isPublic, + ); + + expect(event.properties).not.toHaveProperty('http_status'); + }); + + it('omits http_status when the error is not an object', () => { + const event = buildRpcServiceUnavailableAnalyticsTrackingEvent( + { ...UNAVAILABLE_PAYLOAD, error: 'a string error' }, + isPublic, + ); + + expect(event.properties).not.toHaveProperty('http_status'); + }); +}); + +describe('buildRpcServiceDegradedAnalyticsTrackingEvent', () => { + it('builds the event with all degraded-specific properties', () => { + expect( + buildRpcServiceDegradedAnalyticsTrackingEvent( + { + ...DEGRADED_PAYLOAD, + duration: 1234, + error: { httpStatus: 503 }, + retryReason: 'connection_failed' as const, + traceId: 'trace-1', + type: 'retries_exhausted', + }, + isPublic, + ), + ).toStrictEqual({ + name: 'RPC Service Degraded', + properties: { + chain_id_caip: 'eip155:1', + rpc_domain: 'mainnet.infura.io', + rpc_endpoint_url: 'mainnet.infura.io', + rpc_method_name: 'eth_blockNumber', + type: 'retries_exhausted', + retry_reason: 'connection_failed', + duration_ms: 1234, + trace_id: 'trace-1', + http_status: 503, + }, + sensitiveProperties: {}, + saveDataRecording: false, + hasProperties: true, + }); + }); + + it('omits the optional properties when they are not present', () => { + const event = buildRpcServiceDegradedAnalyticsTrackingEvent( + DEGRADED_PAYLOAD, + isPublic, + ); + + expect(event.properties).toMatchObject({ + rpc_method_name: 'eth_blockNumber', + type: 'slow_success', + }); + expect(event.properties).not.toHaveProperty('duration_ms'); + expect(event.properties).not.toHaveProperty('retry_reason'); + expect(event.properties).not.toHaveProperty('trace_id'); + expect(event.properties).not.toHaveProperty('http_status'); + }); +}); diff --git a/packages/network-controller/src/rpc-service-analytics.ts b/packages/network-controller/src/rpc-service-analytics.ts new file mode 100644 index 00000000000..8af6daa6217 --- /dev/null +++ b/packages/network-controller/src/rpc-service-analytics.ts @@ -0,0 +1,301 @@ +import type { AnalyticsTrackingEvent } from '@metamask/analytics-controller'; +import { generateDeterministicRandomNumber } from '@metamask/remote-feature-flag-controller'; +import type { Hex, Json } from '@metamask/utils'; +import { + hasProperty, + hexToNumber, + isObject, + isValidJson, + wrapError, +} from '@metamask/utils'; + +import type { + NetworkControllerMessenger, + NetworkControllerRpcEndpointDegradedEvent, + NetworkControllerRpcEndpointUnavailableEvent, +} from './NetworkController.js'; +import { isConnectionError } from './rpc-service/rpc-service.js'; + +type RpcEndpointUnavailablePayload = + NetworkControllerRpcEndpointUnavailableEvent['payload'][0]; + +type RpcEndpointDegradedPayload = + NetworkControllerRpcEndpointDegradedEvent['payload'][0]; + +/** + * The names of the analytics events that {@link NetworkController} emits when an + * RPC endpoint becomes unavailable or degraded. + */ +export type RpcServiceEventName = + | 'RPC Service Unavailable' + | 'RPC Service Degraded'; + +/** + * Configuration that enables {@link NetworkController} to emit analytics events + * for unavailable or degraded RPC endpoints. + * + * The pieces here are client-specific and cannot be derived inside the + * controller: deciding whether an endpoint URL is safe to report depends on the + * client's lists of known networks, and the sample rate depends on the client's + * build environment. + */ +export type NetworkControllerAnalyticsOptions = { + /** + * Returns `true` if the given RPC endpoint URL is safe to report verbatim (a + * "public" endpoint), or `false` if it must be reported as the literal string + * `'custom'` to avoid leaking private servers. Defaults to `() => false`. + */ + isRpcEndpointUrlPublic?: (endpointUrl: string) => boolean; + /** + * The proportion of events to emit, between 0 and 1. `1` emits every event, + * `0` emits none. Clients typically use a small value (e.g. `0.01`) in + * production to stay within their analytics quota, and `1` in development. + * Defaults to `0`. + */ + rpcServiceEventsSampleRate?: number; +}; + +/** + * {@link NetworkControllerAnalyticsOptions} with defaults applied, as used + * internally once the controller has filled in any omitted properties. + */ +export type ResolvedNetworkControllerAnalyticsOptions = + Required; + +/** + * Hides any API key contained in an RPC endpoint URL by reducing it to its + * host, but only when the endpoint is considered public. Non-public endpoints + * (and URLs that cannot be parsed) are reported as the literal string + * `'custom'`. + * + * @param endpointUrl - The URL of the RPC endpoint. + * @param isPublic - Whether the endpoint is safe to report verbatim. + * @returns The sanitized value to report. + */ +export function sanitizeRpcEndpointUrl( + endpointUrl: string, + isPublic: boolean, +): string { + if (!isPublic) { + return 'custom'; + } + + try { + return new URL(endpointUrl).host; + } catch { + return 'custom'; + } +} + +/** + * Wraps a name and properties into the shape expected by the + * `AnalyticsController:trackEvent` action. + * + * @param name - The analytics event name. + * @param properties - The analytics event properties. + * @returns The analytics tracking event. + */ +function toAnalyticsTrackingEvent( + name: RpcServiceEventName, + properties: Record, +): AnalyticsTrackingEvent { + return { + name, + properties, + sensitiveProperties: {}, + saveDataRecording: false, + hasProperties: Object.keys(properties).length > 0, + }; +} + +/** + * Builds the properties common to both RPC service events. + * + * @param args - The arguments. + * @param args.chainId - The chain ID that the endpoint represents. + * @param args.endpointUrl - The URL of the endpoint. + * @param args.error - The connection or response error encountered. + * @param args.isRpcEndpointUrlPublic - Returns whether the endpoint URL is safe + * to report verbatim. + * @returns The common analytics event properties. + */ +function buildCommonRpcServiceEventProperties({ + chainId, + endpointUrl, + error, + isRpcEndpointUrlPublic, +}: { + chainId: Hex; + endpointUrl: string; + error: unknown; + isRpcEndpointUrlPublic: (endpointUrl: string) => boolean; +}): Record { + const sanitizedUrl = sanitizeRpcEndpointUrl( + endpointUrl, + isRpcEndpointUrlPublic(endpointUrl), + ); + + // The names of analytics properties have a particular case. + return { + chain_id_caip: `eip155:${hexToNumber(chainId)}`, + rpc_domain: sanitizedUrl, + rpc_endpoint_url: sanitizedUrl, // @deprecated - Will be removed in a future release. + ...(isObject(error) && + hasProperty(error, 'httpStatus') && + isValidJson(error.httpStatus) + ? { http_status: error.httpStatus } + : {}), + }; +} + +/** + * Builds the "RPC Service Unavailable" analytics tracking event. + * + * @param payload - The `rpcEndpointUnavailable` event payload. + * @param isRpcEndpointUrlPublic - Returns whether the endpoint URL is safe to + * report verbatim. + * @returns The analytics tracking event. + */ +export function buildRpcServiceUnavailableAnalyticsTrackingEvent( + payload: RpcEndpointUnavailablePayload, + isRpcEndpointUrlPublic: (endpointUrl: string) => boolean, +): AnalyticsTrackingEvent { + return toAnalyticsTrackingEvent( + 'RPC Service Unavailable', + buildCommonRpcServiceEventProperties({ + chainId: payload.chainId, + endpointUrl: payload.endpointUrl, + error: payload.error, + isRpcEndpointUrlPublic, + }), + ); +} + +/** + * Builds the "RPC Service Degraded" analytics tracking event. + * + * @param payload - The `rpcEndpointDegraded` event payload. + * @param isRpcEndpointUrlPublic - Returns whether the endpoint URL is safe to + * report verbatim. + * @returns The analytics tracking event. + */ +export function buildRpcServiceDegradedAnalyticsTrackingEvent( + payload: RpcEndpointDegradedPayload, + isRpcEndpointUrlPublic: (endpointUrl: string) => boolean, +): AnalyticsTrackingEvent { + const { duration, retryReason, rpcMethodName, traceId, type } = payload; + + // The names of analytics properties have a particular case. + return toAnalyticsTrackingEvent('RPC Service Degraded', { + ...buildCommonRpcServiceEventProperties({ + chainId: payload.chainId, + endpointUrl: payload.endpointUrl, + error: payload.error, + isRpcEndpointUrlPublic, + }), + rpc_method_name: rpcMethodName, + type, + ...(retryReason ? { retry_reason: retryReason } : {}), + ...(duration === undefined ? {} : { duration_ms: duration }), + ...(traceId === undefined ? {} : { trace_id: traceId }), + }); +} + +/** + * Delivers an RPC service analytics event via the + * `AnalyticsController:trackEvent` action, skipping local connection errors, + * users without an analytics ID, and events that fall outside the configured + * sample. Failures never propagate to the caller. + * + * @param args - The arguments. + * @param args.messenger - The controller messenger. + * @param args.analyticsOptions - The analytics configuration. + * @param args.error - The error encountered, used to skip local connection errors. + * @param args.buildTrackingEvent - Builds the event to deliver. Called lazily + * inside the `try` so that a throw from the client's `isRpcEndpointUrlPublic` is + * captured rather than propagated, and so the event is not built when it will + * not be sent. + */ +function trackRpcServiceEvent({ + messenger, + analyticsOptions, + error, + buildTrackingEvent, +}: { + messenger: NetworkControllerMessenger; + analyticsOptions: ResolvedNetworkControllerAnalyticsOptions; + error: unknown; + buildTrackingEvent: () => AnalyticsTrackingEvent; +}): void { + try { + if (isConnectionError(error)) { + return; + } + + const { analyticsId } = messenger.call('AnalyticsController:getState'); + if (!analyticsId) { + return; + } + + if ( + generateDeterministicRandomNumber(analyticsId) >= + analyticsOptions.rpcServiceEventsSampleRate + ) { + return; + } + + messenger.call('AnalyticsController:trackEvent', buildTrackingEvent()); + } catch (caughtError) { + messenger.captureException?.( + wrapError(caughtError, 'Could not create analytics event'), + ); + } +} + +/** + * Emits an "RPC Service Unavailable" analytics event. + * + * @param messenger - The controller messenger. + * @param analyticsOptions - The analytics configuration. + * @param payload - The `rpcEndpointUnavailable` event payload. + */ +export function trackRpcServiceUnavailable( + messenger: NetworkControllerMessenger, + analyticsOptions: ResolvedNetworkControllerAnalyticsOptions, + payload: RpcEndpointUnavailablePayload, +): void { + trackRpcServiceEvent({ + messenger, + analyticsOptions, + error: payload.error, + buildTrackingEvent: () => + buildRpcServiceUnavailableAnalyticsTrackingEvent( + payload, + analyticsOptions.isRpcEndpointUrlPublic, + ), + }); +} + +/** + * Emits an "RPC Service Degraded" analytics event. + * + * @param messenger - The controller messenger. + * @param analyticsOptions - The analytics configuration. + * @param payload - The `rpcEndpointDegraded` event payload. + */ +export function trackRpcServiceDegraded( + messenger: NetworkControllerMessenger, + analyticsOptions: ResolvedNetworkControllerAnalyticsOptions, + payload: RpcEndpointDegradedPayload, +): void { + trackRpcServiceEvent({ + messenger, + analyticsOptions, + error: payload.error, + buildTrackingEvent: () => + buildRpcServiceDegradedAnalyticsTrackingEvent( + payload, + analyticsOptions.isRpcEndpointUrlPublic, + ), + }); +} diff --git a/packages/network-controller/src/rpc-service/abstract-rpc-service.ts b/packages/network-controller/src/rpc-service/abstract-rpc-service.ts new file mode 100644 index 00000000000..9a23d93bd0f --- /dev/null +++ b/packages/network-controller/src/rpc-service/abstract-rpc-service.ts @@ -0,0 +1,17 @@ +import type { RpcServiceRequestable } from './rpc-service-requestable.js'; + +/** + * The interface for a service class responsible for making a request to an RPC + * endpoint or a group of RPC endpoints. + * + * @deprecated Don't use this interface (it will be removed in an upcoming major + * version). If you need to take an "RPC-service-like" argument, it's best to + * declare which properties you're interested in rather than accepting the + * entire RPC service interface. + */ +export type AbstractRpcService = RpcServiceRequestable & { + /** + * The URL of the RPC endpoint. + */ + endpointUrl: URL; +}; diff --git a/packages/network-controller/src/rpc-service/rpc-service-chain.test.ts b/packages/network-controller/src/rpc-service/rpc-service-chain.test.ts new file mode 100644 index 00000000000..702cf7f3f2e --- /dev/null +++ b/packages/network-controller/src/rpc-service/rpc-service-chain.test.ts @@ -0,0 +1,2396 @@ +import { + DEFAULT_CIRCUIT_BREAK_DURATION, + DEFAULT_DEGRADED_THRESHOLD, + HttpError, +} from '@metamask/controller-utils'; +import { errorCodes } from '@metamask/rpc-errors'; +import nock from 'nock'; + +import { RpcServiceChain } from './rpc-service-chain.js'; +import { + DEFAULT_MAX_CONSECUTIVE_FAILURES, + DEFAULT_MAX_RETRIES, +} from './rpc-service.js'; + +/** + * The number of fetch requests made for a single request to an RPC service, using default max + * retry attempts. + */ +const DEFAULT_REQUEST_ATTEMPTS = 1 + DEFAULT_MAX_RETRIES; + +/** + * Number of attempts required to break the circuit of an RPC service using default retry attempts + * and max consecutive failures. + * + * Note: This calculation and later ones assume that there is no remainder. + */ +const DEFAULT_RPC_SERVICE_ATTEMPTS_UNTIL_BREAK = + DEFAULT_MAX_CONSECUTIVE_FAILURES / DEFAULT_REQUEST_ATTEMPTS; + +/** + * Number of attempts required to break the circuit of an RPC service chain (with a single + * failover) that uses default retry attempts and max consecutive failures. + * + * The value is one less than double the number of attempts needed to break a single circuit + * because on failure of the primary, the request gets forwarded to the failover immediately. + */ +const DEFAULT_RPC_CHAIN_ATTEMPTS_UNTIL_BREAK = + 2 * DEFAULT_RPC_SERVICE_ATTEMPTS_UNTIL_BREAK - 1; + +describe('RpcServiceChain', () => { + beforeEach(() => { + jest.useFakeTimers({ doNotFake: ['nextTick', 'queueMicrotask'] }); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + describe('onServiceRetry', () => { + it('returns a listener which can be disposed', () => { + const rpcServiceChain = new RpcServiceChain([ + { + fetch, + btoa, + isOffline: (): boolean => false, + endpointUrl: 'https://rpc.example.chain', + }, + ]); + + const onServiceRetryListener = rpcServiceChain.onServiceRetry(() => { + // do whatever + }); + expect(onServiceRetryListener.dispose()).toBeUndefined(); + }); + }); + + describe('onBreak', () => { + it('returns a listener which can be disposed', () => { + const rpcServiceChain = new RpcServiceChain([ + { + fetch, + btoa, + isOffline: (): boolean => false, + endpointUrl: 'https://rpc.example.chain', + }, + ]); + + const onBreakListener = rpcServiceChain.onBreak(() => { + // do whatever + }); + expect(onBreakListener.dispose()).toBeUndefined(); + }); + }); + + describe('onServiceBreak', () => { + it('returns a listener which can be disposed', () => { + const rpcServiceChain = new RpcServiceChain([ + { + fetch, + btoa, + isOffline: (): boolean => false, + endpointUrl: 'https://rpc.example.chain', + }, + ]); + + const onServiceBreakListener = rpcServiceChain.onServiceBreak(() => { + // do whatever + }); + expect(onServiceBreakListener.dispose()).toBeUndefined(); + }); + }); + + describe('onDegraded', () => { + it('returns a listener which can be disposed', () => { + const rpcServiceChain = new RpcServiceChain([ + { + fetch, + btoa, + isOffline: (): boolean => false, + endpointUrl: 'https://rpc.example.chain', + }, + ]); + + const onDegradedListener = rpcServiceChain.onDegraded(() => { + // do whatever + }); + expect(onDegradedListener.dispose()).toBeUndefined(); + }); + }); + + describe('onServiceDegraded', () => { + it('returns a listener which can be disposed', () => { + const rpcServiceChain = new RpcServiceChain([ + { + fetch, + btoa, + isOffline: (): boolean => false, + endpointUrl: 'https://rpc.example.chain', + }, + ]); + + const onServiceDegradedListener = rpcServiceChain.onServiceDegraded( + () => { + // do whatever + }, + ); + expect(onServiceDegradedListener.dispose()).toBeUndefined(); + }); + }); + + describe('onAvailable', () => { + it('returns a listener which can be disposed', () => { + const rpcServiceChain = new RpcServiceChain([ + { + fetch, + btoa, + isOffline: (): boolean => false, + endpointUrl: 'https://rpc.example.chain', + }, + ]); + + const onAvailableListener = rpcServiceChain.onAvailable(() => { + // do whatever + }); + expect(onAvailableListener.dispose()).toBeUndefined(); + }); + }); + + describe('request', () => { + it('returns what the first RPC service in the chain returns, if it succeeds', async () => { + nock('https://first.endpoint') + .post('/', { + id: 1, + jsonrpc: '2.0', + method: 'eth_chainId', + params: [], + }) + .reply(200, { + id: 1, + jsonrpc: '2.0', + result: 'ok', + }); + + const rpcServiceChain = new RpcServiceChain([ + { + fetch, + btoa, + isOffline: (): boolean => false, + endpointUrl: 'https://first.endpoint', + }, + { + fetch, + btoa, + isOffline: (): boolean => false, + endpointUrl: 'https://second.endpoint', + fetchOptions: { + headers: { + 'X-Foo': 'Bar', + }, + }, + }, + { + fetch, + btoa, + isOffline: (): boolean => false, + endpointUrl: 'https://third.chain', + }, + ]); + + const response = await rpcServiceChain.request({ + id: 1, + jsonrpc: '2.0', + method: 'eth_chainId', + params: [], + }); + + expect(response).toStrictEqual({ + id: 1, + jsonrpc: '2.0', + result: 'ok', + }); + }); + + it('returns what a failover service returns, if the primary is unavailable and the failover is not', async () => { + nock('https://first.endpoint') + .post('/', { + id: 1, + jsonrpc: '2.0', + method: 'eth_chainId', + params: [], + }) + .times(DEFAULT_MAX_CONSECUTIVE_FAILURES) + .reply(503); + nock('https://second.endpoint') + .post('/', { + id: 1, + jsonrpc: '2.0', + method: 'eth_chainId', + params: [], + }) + .times(DEFAULT_MAX_CONSECUTIVE_FAILURES) + .reply(503); + nock('https://third.chain') + .post('/', { + id: 1, + jsonrpc: '2.0', + method: 'eth_chainId', + params: [], + }) + .reply(200, { + id: 1, + jsonrpc: '2.0', + result: 'ok', + }); + const expectedError = createResourceUnavailableError(503); + const rpcServiceChain = new RpcServiceChain([ + { + fetch, + btoa, + isOffline: (): boolean => false, + endpointUrl: 'https://first.endpoint', + }, + { + fetch, + btoa, + isOffline: (): boolean => false, + endpointUrl: 'https://second.endpoint', + }, + { + fetch, + btoa, + isOffline: (): boolean => false, + endpointUrl: 'https://third.chain', + }, + ]); + rpcServiceChain.onServiceRetry(() => { + jest.advanceTimersToNextTimer(); + }); + + const jsonRpcRequest = { + id: 1, + jsonrpc: '2.0' as const, + method: 'eth_chainId', + params: [], + }; + // Retry the first endpoint until max retries is hit. + await expect(rpcServiceChain.request(jsonRpcRequest)).rejects.toThrow( + expectedError, + ); + // Retry the first endpoint again, until max retries is hit. + await expect(rpcServiceChain.request(jsonRpcRequest)).rejects.toThrow( + expectedError, + ); + // Retry the first endpoint for a third time, until max retries is hit. + // The circuit will break on the last time, and the second endpoint will + // be retried, until max retries is hit. + await expect(rpcServiceChain.request(jsonRpcRequest)).rejects.toThrow( + expectedError, + ); + // Try the first endpoint, see that the circuit is broken, and retry the + // second endpoint, until max retries is hit. + await expect(rpcServiceChain.request(jsonRpcRequest)).rejects.toThrow( + expectedError, + ); + // Try the first endpoint, see that the circuit is broken, and retry the + // second endpoint, until max retries is hit. + // The circuit will break on the last time, and the third endpoint will + // be hit. This is finally a success. + const response = await rpcServiceChain.request(jsonRpcRequest); + + expect(response).toStrictEqual({ + id: 1, + jsonrpc: '2.0', + result: 'ok', + }); + }); + + it("allows each RPC service's fetch options to be configured separately, yet passes the fetch options given to request to all of them", async () => { + const firstEndpointScope = nock('https://first.endpoint', { + reqheaders: { + 'X-Fizz': 'Buzz', + }, + }) + .post('/', { + id: 1, + jsonrpc: '2.0', + method: 'eth_chainId', + params: [], + }) + .times(DEFAULT_MAX_CONSECUTIVE_FAILURES) + .reply(503); + const secondEndpointScope = nock('https://second.endpoint', { + reqheaders: { + 'X-Fizz': 'Buzz', + }, + }) + .post('/', { + id: 1, + jsonrpc: '2.0', + method: 'eth_chainId', + params: [], + }) + .times(DEFAULT_MAX_CONSECUTIVE_FAILURES) + .reply(503); + const thirdEndpointScope = nock('https://third.chain', { + reqheaders: { + 'X-Fizz': 'Buzz', + }, + }) + .post('/', { + id: 1, + jsonrpc: '2.0', + method: 'eth_chainId', + params: [], + }) + .reply(200, { + id: 1, + jsonrpc: '2.0', + result: 'ok', + }); + const expectedError = createResourceUnavailableError(503); + const rpcServiceChain = new RpcServiceChain([ + { + fetch, + btoa, + isOffline: (): boolean => false, + endpointUrl: 'https://first.endpoint', + }, + { + fetch, + btoa, + isOffline: (): boolean => false, + endpointUrl: 'https://second.endpoint', + fetchOptions: { + headers: { + 'X-Foo': 'Bar', + }, + }, + }, + { + fetch, + btoa, + isOffline: (): boolean => false, + endpointUrl: 'https://third.chain', + fetchOptions: { + referrer: 'https://some.referrer', + }, + }, + ]); + rpcServiceChain.onServiceRetry(() => { + jest.advanceTimersToNextTimer(); + }); + + const jsonRpcRequest = { + id: 1, + jsonrpc: '2.0' as const, + method: 'eth_chainId', + params: [], + }; + const fetchOptions = { + headers: { + 'X-Fizz': 'Buzz', + }, + }; + // Retry the first endpoint until max retries is hit. + await expect( + rpcServiceChain.request(jsonRpcRequest, fetchOptions), + ).rejects.toThrow(expectedError); + // Retry the first endpoint again, until max retries is hit. + await expect( + rpcServiceChain.request(jsonRpcRequest, fetchOptions), + ).rejects.toThrow(expectedError); + // Retry the first endpoint for a third time, until max retries is hit. + // The circuit will break on the last time, and the second endpoint will + // be retried, until max retries is hit. + await expect( + rpcServiceChain.request(jsonRpcRequest, fetchOptions), + ).rejects.toThrow(expectedError); + // Try the first endpoint, see that the circuit is broken, and retry the + // second endpoint, until max retries is hit. + await expect( + rpcServiceChain.request(jsonRpcRequest, fetchOptions), + ).rejects.toThrow(expectedError); + // Try the first endpoint, see that the circuit is broken, and retry the + // second endpoint, until max retries is hit. + // The circuit will break on the last time, and the third endpoint will + // be hit. This is finally a success. + await rpcServiceChain.request(jsonRpcRequest, fetchOptions); + + expect(firstEndpointScope.isDone()).toBe(true); + expect(secondEndpointScope.isDone()).toBe(true); + expect(thirdEndpointScope.isDone()).toBe(true); + }); + + it("throws a custom error if a request is attempted while a service's circuit is open", async () => { + const endpointUrl = 'https://some.endpoint'; + nock(endpointUrl) + .post('/', { + id: 1, + jsonrpc: '2.0', + method: 'eth_chainId', + params: [], + }) + .times(DEFAULT_MAX_CONSECUTIVE_FAILURES) + .reply(503); + const expectedError = createResourceUnavailableError(503); + const rpcServiceChain = new RpcServiceChain([ + { + fetch, + btoa, + isOffline: (): boolean => false, + endpointUrl, + }, + ]); + const onBreakListener = jest.fn(); + rpcServiceChain.onServiceRetry(() => { + jest.advanceTimersToNextTimer(); + }); + rpcServiceChain.onBreak(onBreakListener); + + const jsonRpcRequest = { + id: 1, + jsonrpc: '2.0' as const, + method: 'eth_chainId', + params: [], + }; + // Retry the endpoint until max retries is hit. + await expect(rpcServiceChain.request(jsonRpcRequest)).rejects.toThrow( + expectedError, + ); + // Retry the endpoint again, until max retries is hit. + await expect(rpcServiceChain.request(jsonRpcRequest)).rejects.toThrow( + expectedError, + ); + // Retry the endpoint for a third time, until max retries is hit. + // The circuit will break on the last time. + await expect(rpcServiceChain.request(jsonRpcRequest)).rejects.toThrow( + expectedError, + ); + // Attempt the endpoint again. + await expect(rpcServiceChain.request(jsonRpcRequest)).rejects.toThrow( + 'RPC endpoint returned too many errors', + ); + }); + + it('calls onServiceRetry each time an RPC service in the chain retries its request', async () => { + const primaryEndpointUrl = 'https://first.endpoint'; + const secondaryEndpointUrl = 'https://second.endpoint'; + const tertiaryEndpointUrl = 'https://third.chain'; + nock(primaryEndpointUrl) + .post('/', { + id: 1, + jsonrpc: '2.0', + method: 'eth_chainId', + params: [], + }) + .times(DEFAULT_MAX_CONSECUTIVE_FAILURES) + .reply(503); + nock(secondaryEndpointUrl) + .post('/', { + id: 1, + jsonrpc: '2.0', + method: 'eth_chainId', + params: [], + }) + .times(DEFAULT_MAX_CONSECUTIVE_FAILURES) + .reply(503); + nock(tertiaryEndpointUrl) + .post('/', { + id: 1, + jsonrpc: '2.0', + method: 'eth_chainId', + params: [], + }) + .reply(200, { + id: 1, + jsonrpc: '2.0', + result: 'ok', + }); + const expectedError = createResourceUnavailableError(503); + const expectedRetryError = new HttpError(503); + const rpcServiceChain = new RpcServiceChain([ + { + fetch, + btoa, + isOffline: (): boolean => false, + endpointUrl: primaryEndpointUrl, + }, + { + fetch, + btoa, + isOffline: (): boolean => false, + endpointUrl: secondaryEndpointUrl, + fetchOptions: { + headers: { + 'X-Foo': 'Bar', + }, + }, + }, + { + fetch, + btoa, + isOffline: (): boolean => false, + endpointUrl: tertiaryEndpointUrl, + }, + ]); + const onServiceRetryListener = jest.fn(() => { + jest.advanceTimersToNextTimer(); + }); + rpcServiceChain.onServiceRetry(onServiceRetryListener); + + const jsonRpcRequest = { + id: 1, + jsonrpc: '2.0' as const, + method: 'eth_chainId', + params: [], + }; + // Retry the first endpoint until max retries is hit. + await expect(rpcServiceChain.request(jsonRpcRequest)).rejects.toThrow( + expectedError, + ); + // Retry the first endpoint again, until max retries is hit. + await expect(rpcServiceChain.request(jsonRpcRequest)).rejects.toThrow( + expectedError, + ); + // Retry the first endpoint for a third time, until max retries is hit. + // The circuit will break on the last time, and the second endpoint will + // be retried, until max retries is hit. + await expect(rpcServiceChain.request(jsonRpcRequest)).rejects.toThrow( + expectedError, + ); + // Try the first endpoint, see that the circuit is broken, and retry the + // second endpoint, until max retries is hit. + await expect(rpcServiceChain.request(jsonRpcRequest)).rejects.toThrow( + expectedError, + ); + // Try the first endpoint, see that the circuit is broken, and retry the + // second endpoint, until max retries is hit. + // The circuit will break on the last time, and the third endpoint will + // be hit. This is finally a success. + await rpcServiceChain.request(jsonRpcRequest); + + for (let attempt = 0; attempt < 24; attempt++) { + expect(onServiceRetryListener).toHaveBeenNthCalledWith(attempt + 1, { + primaryEndpointUrl: `${primaryEndpointUrl}/`, + endpointUrl: + attempt >= 12 + ? `${secondaryEndpointUrl}/` + : `${primaryEndpointUrl}/`, + attempt: (attempt % 4) + 1, + delay: expect.any(Number), + error: expectedRetryError, + }); + } + }); + + it('does not call onBreak if the primary service circuit breaks and the request to its failover fails but its circuit has not broken yet', async () => { + const primaryEndpointUrl = 'https://first.endpoint'; + const secondaryEndpointUrl = 'https://second.endpoint'; + nock(primaryEndpointUrl) + .post('/', { + id: 1, + jsonrpc: '2.0', + method: 'eth_chainId', + params: [], + }) + .times(DEFAULT_MAX_CONSECUTIVE_FAILURES) + .reply(503); + nock(secondaryEndpointUrl) + .post('/', { + id: 1, + jsonrpc: '2.0', + method: 'eth_chainId', + params: [], + }) + .reply(500); + const rpcServiceChain = new RpcServiceChain([ + { + fetch, + btoa, + isOffline: (): boolean => false, + endpointUrl: primaryEndpointUrl, + }, + { + fetch, + btoa, + isOffline: (): boolean => false, + endpointUrl: secondaryEndpointUrl, + }, + ]); + const onBreakListener = jest.fn(); + rpcServiceChain.onServiceRetry(() => { + jest.advanceTimersToNextTimer(); + }); + rpcServiceChain.onBreak(onBreakListener); + + const jsonRpcRequest = { + id: 1, + jsonrpc: '2.0' as const, + method: 'eth_chainId', + params: [], + }; + // Retry the first endpoint until max retries is hit. + await expect(rpcServiceChain.request(jsonRpcRequest)).rejects.toThrow( + createResourceUnavailableError(503), + ); + // Retry the first endpoint again, until max retries is hit. + await expect(rpcServiceChain.request(jsonRpcRequest)).rejects.toThrow( + createResourceUnavailableError(503), + ); + // Retry the first endpoint for a third time, until max retries is hit. + // The circuit will break on the last time, and the second endpoint will + // be hit (unsuccessfully). + await expect(rpcServiceChain.request(jsonRpcRequest)).rejects.toThrow( + createResourceUnavailableError(500), + ); + + expect(onBreakListener).not.toHaveBeenCalled(); + }); + + it("calls onBreak when all of the RPC services' circuits have broken", async () => { + const primaryEndpointUrl = 'https://first.endpoint'; + const secondaryEndpointUrl = 'https://second.endpoint'; + nock(primaryEndpointUrl) + .post('/', { + id: 1, + jsonrpc: '2.0', + method: 'eth_chainId', + params: [], + }) + .times(DEFAULT_MAX_CONSECUTIVE_FAILURES) + .reply(503); + nock(secondaryEndpointUrl) + .post('/', { + id: 1, + jsonrpc: '2.0', + method: 'eth_chainId', + params: [], + }) + .times(DEFAULT_MAX_CONSECUTIVE_FAILURES) + .reply(503); + const expectedError = createResourceUnavailableError(503); + const rpcServiceChain = new RpcServiceChain([ + { + fetch, + btoa, + isOffline: (): boolean => false, + endpointUrl: primaryEndpointUrl, + }, + { + fetch, + btoa, + isOffline: (): boolean => false, + endpointUrl: secondaryEndpointUrl, + }, + ]); + const onBreakListener = jest.fn(); + rpcServiceChain.onServiceRetry(() => { + jest.advanceTimersToNextTimer(); + }); + rpcServiceChain.onBreak(onBreakListener); + + const jsonRpcRequest = { + id: 1, + jsonrpc: '2.0' as const, + method: 'eth_chainId', + params: [], + }; + // Retry the first endpoint until max retries is hit. + await expect(rpcServiceChain.request(jsonRpcRequest)).rejects.toThrow( + expectedError, + ); + // Retry the first endpoint again, until max retries is hit. + await expect(rpcServiceChain.request(jsonRpcRequest)).rejects.toThrow( + expectedError, + ); + // Retry the first endpoint for a third time, until max retries is hit. + // The circuit will break on the last time, and the second endpoint will + // be retried, until max retries is hit. + await expect(rpcServiceChain.request(jsonRpcRequest)).rejects.toThrow( + expectedError, + ); + // Try the first endpoint, see that the circuit is broken, and retry the + // second endpoint, until max retries is hit. + await expect(rpcServiceChain.request(jsonRpcRequest)).rejects.toThrow( + expectedError, + ); + // Try the first endpoint, see that the circuit is broken, and retry the + // second endpoint, until max retries is hit. The circuit will break on + // the last time. + await expect(rpcServiceChain.request(jsonRpcRequest)).rejects.toThrow( + expectedError, + ); + + expect(onBreakListener).toHaveBeenCalledTimes(1); + expect(onBreakListener).toHaveBeenCalledWith({ + error: new Error("Fetch failed with status '503'"), + }); + }); + + it("calls onBreak again if all services' circuits break, the primary service responds successfully, and all services' circuits break again", async () => { + const primaryEndpointUrl = 'https://first.endpoint'; + const secondaryEndpointUrl = 'https://second.endpoint'; + nock(primaryEndpointUrl) + .post('/', { + id: 1, + jsonrpc: '2.0', + method: 'eth_chainId', + params: [], + }) + .times(DEFAULT_MAX_CONSECUTIVE_FAILURES) + .reply(503); + nock(primaryEndpointUrl) + .post('/', { + id: 1, + jsonrpc: '2.0', + method: 'eth_chainId', + params: [], + }) + .reply(200, { + id: 1, + jsonrpc: '2.0', + result: 'ok', + }); + nock(primaryEndpointUrl) + .post('/', { + id: 1, + jsonrpc: '2.0', + method: 'eth_chainId', + params: [], + }) + .times(DEFAULT_MAX_CONSECUTIVE_FAILURES) + .reply(503); + nock(secondaryEndpointUrl) + .post('/', { + id: 1, + jsonrpc: '2.0', + method: 'eth_chainId', + params: [], + }) + .times(30) + .reply(503); + const expectedError = createResourceUnavailableError(503); + const rpcServiceChain = new RpcServiceChain([ + { + fetch, + btoa, + isOffline: (): boolean => false, + endpointUrl: primaryEndpointUrl, + }, + { + fetch, + btoa, + isOffline: (): boolean => false, + endpointUrl: secondaryEndpointUrl, + }, + ]); + const onBreakListener = jest.fn(); + const onAvailableListener = jest.fn(); + rpcServiceChain.onServiceRetry(() => { + jest.advanceTimersToNextTimer(); + }); + rpcServiceChain.onBreak(onBreakListener); + rpcServiceChain.onAvailable(onAvailableListener); + + const jsonRpcRequest = { + id: 1, + jsonrpc: '2.0' as const, + method: 'eth_chainId', + params: [], + }; + // Retry the first endpoint until its circuit breaks, then retry the + // second endpoint until *its* circuit breaks. + for (let i = 0; i < DEFAULT_RPC_CHAIN_ATTEMPTS_UNTIL_BREAK; i++) { + await expect(rpcServiceChain.request(jsonRpcRequest)).rejects.toThrow( + expectedError, + ); + } + // Wait until the circuit break duration passes, try the first endpoint + // and see that it succeeds. + jest.advanceTimersByTime(DEFAULT_CIRCUIT_BREAK_DURATION); + await rpcServiceChain.request(jsonRpcRequest); + // Do it again: retry the first endpoint until its circuit breaks, then + // retry the second endpoint until *its* circuit breaks. + for (let i = 0; i < DEFAULT_RPC_CHAIN_ATTEMPTS_UNTIL_BREAK; i++) { + await expect(rpcServiceChain.request(jsonRpcRequest)).rejects.toThrow( + expectedError, + ); + } + + expect(onBreakListener).toHaveBeenCalledTimes(2); + expect(onBreakListener).toHaveBeenNthCalledWith(1, { + error: new Error("Fetch failed with status '503'"), + }); + expect(onBreakListener).toHaveBeenNthCalledWith(2, { + error: new Error("Fetch failed with status '503'"), + }); + }); + + it("calls onBreak again if all services' circuits break, the primary service responds successfully but slowly, and all circuits break again", async () => { + const primaryEndpointUrl = 'https://first.endpoint'; + const secondaryEndpointUrl = 'https://second.endpoint'; + nock(primaryEndpointUrl) + .post('/', { + id: 1, + jsonrpc: '2.0', + method: 'eth_chainId', + params: [], + }) + .times(DEFAULT_MAX_CONSECUTIVE_FAILURES) + .reply(503); + nock(primaryEndpointUrl) + .post('/', { + id: 1, + jsonrpc: '2.0', + method: 'eth_chainId', + params: [], + }) + .reply(200, () => { + jest.advanceTimersByTime(DEFAULT_DEGRADED_THRESHOLD + 1); + return { + id: 1, + jsonrpc: '2.0', + result: '0x1', + }; + }); + nock(primaryEndpointUrl) + .post('/', { + id: 1, + jsonrpc: '2.0', + method: 'eth_chainId', + params: [], + }) + .times(DEFAULT_MAX_CONSECUTIVE_FAILURES) + .reply(503); + nock(secondaryEndpointUrl) + .post('/', { + id: 1, + jsonrpc: '2.0', + method: 'eth_chainId', + params: [], + }) + .times(30) + .reply(503); + const expectedError = createResourceUnavailableError(503); + const rpcServiceChain = new RpcServiceChain([ + { + fetch, + btoa, + isOffline: (): boolean => false, + endpointUrl: primaryEndpointUrl, + }, + { + fetch, + btoa, + isOffline: (): boolean => false, + endpointUrl: secondaryEndpointUrl, + }, + ]); + const onBreakListener = jest.fn(); + rpcServiceChain.onServiceRetry(() => { + jest.advanceTimersToNextTimer(); + }); + rpcServiceChain.onBreak(onBreakListener); + + const jsonRpcRequest = { + id: 1, + jsonrpc: '2.0' as const, + method: 'eth_chainId', + params: [], + }; + // Retry the first endpoint until its circuit breaks, then retry the + // second endpoint until *its* circuit breaks. + for (let i = 0; i < DEFAULT_RPC_CHAIN_ATTEMPTS_UNTIL_BREAK; i++) { + await expect(rpcServiceChain.request(jsonRpcRequest)).rejects.toThrow( + expectedError, + ); + } + // Wait until the circuit break duration passes, try the first endpoint + // and see that it succeeds. + jest.advanceTimersByTime(DEFAULT_CIRCUIT_BREAK_DURATION); + await rpcServiceChain.request(jsonRpcRequest); + // Do it again: retry the first endpoint until its circuit breaks, then + // retry the second endpoint until *its* circuit breaks. + for (let i = 0; i < DEFAULT_RPC_CHAIN_ATTEMPTS_UNTIL_BREAK; i++) { + await expect(rpcServiceChain.request(jsonRpcRequest)).rejects.toThrow( + expectedError, + ); + } + + expect(onBreakListener).toHaveBeenCalledTimes(2); + expect(onBreakListener).toHaveBeenNthCalledWith(1, { + error: new Error("Fetch failed with status '503'"), + }); + expect(onBreakListener).toHaveBeenNthCalledWith(2, { + error: new Error("Fetch failed with status '503'"), + }); + }); + + it('calls onServiceBreak each time the circuit of an RPC service in the chain breaks', async () => { + const primaryEndpointUrl = 'https://first.endpoint'; + const secondaryEndpointUrl = 'https://second.endpoint'; + const tertiaryEndpointUrl = 'https://third.endpoint'; + nock(primaryEndpointUrl) + .post('/', { + id: 1, + jsonrpc: '2.0', + method: 'eth_chainId', + params: [], + }) + .times(DEFAULT_MAX_CONSECUTIVE_FAILURES) + .reply(503); + nock(secondaryEndpointUrl) + .post('/', { + id: 1, + jsonrpc: '2.0', + method: 'eth_chainId', + params: [], + }) + .times(DEFAULT_MAX_CONSECUTIVE_FAILURES) + .reply(503); + nock(tertiaryEndpointUrl) + .post('/', { + id: 1, + jsonrpc: '2.0', + method: 'eth_chainId', + params: [], + }) + .times(DEFAULT_MAX_CONSECUTIVE_FAILURES) + .reply(503); + const expectedError = createResourceUnavailableError(503); + const rpcServiceChain = new RpcServiceChain([ + { + fetch, + btoa, + isOffline: (): boolean => false, + endpointUrl: primaryEndpointUrl, + }, + { + fetch, + btoa, + isOffline: (): boolean => false, + endpointUrl: secondaryEndpointUrl, + }, + { + fetch, + btoa, + isOffline: (): boolean => false, + endpointUrl: tertiaryEndpointUrl, + }, + ]); + const onServiceBreakListener = jest.fn(); + rpcServiceChain.onServiceRetry(() => { + jest.advanceTimersToNextTimer(); + }); + rpcServiceChain.onServiceBreak(onServiceBreakListener); + + const jsonRpcRequest = { + id: 1, + jsonrpc: '2.0' as const, + method: 'eth_chainId', + params: [], + }; + // Retry the first endpoint until max retries is hit. + await expect(rpcServiceChain.request(jsonRpcRequest)).rejects.toThrow( + expectedError, + ); + // Retry the first endpoint again, until max retries is hit. + await expect(rpcServiceChain.request(jsonRpcRequest)).rejects.toThrow( + expectedError, + ); + // Retry the first endpoint for a third time, until max retries is hit. + // The circuit will break on the last time, and the second endpoint will + // be hit, until max retries is hit. + await expect(rpcServiceChain.request(jsonRpcRequest)).rejects.toThrow( + expectedError, + ); + // Retry the second endpoint again, until max retries is hit. + await expect(rpcServiceChain.request(jsonRpcRequest)).rejects.toThrow( + expectedError, + ); + // Retry the second endpoint for a third time, until max retries is hit. + // The circuit will break on the last time, and the third endpoint will + // be hit, until max retries is hit. + await expect(rpcServiceChain.request(jsonRpcRequest)).rejects.toThrow( + expectedError, + ); + // Retry the third endpoint again, until max retries is hit. + await expect(rpcServiceChain.request(jsonRpcRequest)).rejects.toThrow( + expectedError, + ); + // Retry the third endpoint for a third time, until max retries is hit. + // The circuit will break on the last time. + await expect(rpcServiceChain.request(jsonRpcRequest)).rejects.toThrow( + expectedError, + ); + + expect(onServiceBreakListener).toHaveBeenCalledTimes(3); + expect(onServiceBreakListener).toHaveBeenNthCalledWith(1, { + primaryEndpointUrl: `${primaryEndpointUrl}/`, + endpointUrl: `${primaryEndpointUrl}/`, + error: new Error("Fetch failed with status '503'"), + }); + expect(onServiceBreakListener).toHaveBeenNthCalledWith(2, { + primaryEndpointUrl: `${primaryEndpointUrl}/`, + endpointUrl: `${secondaryEndpointUrl}/`, + error: new Error("Fetch failed with status '503'"), + }); + expect(onServiceBreakListener).toHaveBeenNthCalledWith(3, { + primaryEndpointUrl: `${primaryEndpointUrl}/`, + endpointUrl: `${tertiaryEndpointUrl}/`, + error: new Error("Fetch failed with status '503'"), + }); + }); + + it("calls onDegraded only once even if a service's maximum number of retries is reached multiple times", async () => { + const endpointUrl = 'https://some.endpoint'; + nock(endpointUrl) + .post('/', { + id: 1, + jsonrpc: '2.0', + method: 'eth_chainId', + params: [], + }) + .times(DEFAULT_MAX_CONSECUTIVE_FAILURES) + .reply(503); + const expectedError = createResourceUnavailableError(503); + const expectedDegradedError = new HttpError(503); + const rpcServiceChain = new RpcServiceChain([ + { + fetch, + btoa, + isOffline: (): boolean => false, + endpointUrl, + }, + ]); + const onDegradedListener = jest.fn(); + rpcServiceChain.onServiceRetry(() => { + jest.advanceTimersToNextTimer(); + }); + rpcServiceChain.onDegraded(onDegradedListener); + + const jsonRpcRequest = { + id: 1, + jsonrpc: '2.0' as const, + method: 'eth_chainId', + params: [], + }; + // Retry the endpoint until max retries is hit. + await expect(rpcServiceChain.request(jsonRpcRequest)).rejects.toThrow( + expectedError, + ); + // Retry the endpoint again, until max retries is hit. + await expect(rpcServiceChain.request(jsonRpcRequest)).rejects.toThrow( + expectedError, + ); + // Retry the endpoint for a third time, until max retries is hit. + // The circuit will break on the last time. + await expect(rpcServiceChain.request(jsonRpcRequest)).rejects.toThrow( + expectedError, + ); + + expect(onDegradedListener).toHaveBeenCalledTimes(1); + expect(onDegradedListener).toHaveBeenCalledWith({ + error: expectedDegradedError, + rpcMethodName: 'eth_chainId', + duration: undefined, + traceId: undefined, + }); + }); + + it('calls onDegraded only once even if the time to complete a request via a service is continually slow', async () => { + const endpointUrl = 'https://some.endpoint'; + nock(endpointUrl) + .post('/', { + id: 1, + jsonrpc: '2.0', + method: 'eth_chainId', + params: [], + }) + .times(2) + .reply(200, () => { + jest.advanceTimersByTime(DEFAULT_DEGRADED_THRESHOLD + 1); + return { + id: 1, + jsonrpc: '2.0', + result: '0x1', + }; + }); + const rpcServiceChain = new RpcServiceChain([ + { + fetch, + btoa, + isOffline: (): boolean => false, + endpointUrl, + }, + ]); + const onDegradedListener = jest.fn(); + rpcServiceChain.onServiceRetry(() => { + jest.advanceTimersToNextTimer(); + }); + rpcServiceChain.onDegraded(onDegradedListener); + + const jsonRpcRequest = { + id: 1, + jsonrpc: '2.0' as const, + method: 'eth_chainId', + params: [], + }; + await rpcServiceChain.request(jsonRpcRequest); + await rpcServiceChain.request(jsonRpcRequest); + + expect(onDegradedListener).toHaveBeenCalledTimes(1); + expect(onDegradedListener).toHaveBeenCalledWith({ + rpcMethodName: 'eth_chainId', + duration: expect.any(Number), + traceId: undefined, + }); + }); + + it('calls onDegraded only once even if a service runs out of retries and then responds successfully but slowly, or vice versa', async () => { + const endpointUrl = 'https://some.endpoint'; + nock(endpointUrl) + .post('/', { + id: 1, + jsonrpc: '2.0', + method: 'eth_chainId', + params: [], + }) + .times(5) + .reply(503); + nock(endpointUrl) + .post('/', { + id: 1, + jsonrpc: '2.0', + method: 'eth_chainId', + params: [], + }) + .reply(200, () => { + jest.advanceTimersByTime(DEFAULT_DEGRADED_THRESHOLD + 1); + return { + id: 1, + jsonrpc: '2.0', + result: '0x1', + }; + }); + nock(endpointUrl) + .post('/', { + id: 1, + jsonrpc: '2.0', + method: 'eth_chainId', + params: [], + }) + .times(5) + .reply(503); + const expectedError = createResourceUnavailableError(503); + const expectedDegradedError = new HttpError(503); + const rpcServiceChain = new RpcServiceChain([ + { + fetch, + btoa, + isOffline: (): boolean => false, + endpointUrl, + }, + ]); + const onDegradedListener = jest.fn(); + rpcServiceChain.onServiceRetry(() => { + jest.advanceTimersToNextTimer(); + }); + rpcServiceChain.onDegraded(onDegradedListener); + + const jsonRpcRequest = { + id: 1, + jsonrpc: '2.0' as const, + method: 'eth_chainId', + params: [], + }; + // Retry the endpoint until max retries is hit. + await expect(rpcServiceChain.request(jsonRpcRequest)).rejects.toThrow( + expectedError, + ); + // Try the endpoint again, and see that it succeeds. + await rpcServiceChain.request(jsonRpcRequest); + // Retry the endpoint again until max retries is hit. + await expect(rpcServiceChain.request(jsonRpcRequest)).rejects.toThrow( + expectedError, + ); + + expect(onDegradedListener).toHaveBeenCalledTimes(1); + expect(onDegradedListener).toHaveBeenCalledWith({ + error: expectedDegradedError, + rpcMethodName: 'eth_chainId', + duration: undefined, + traceId: undefined, + }); + }); + + it('reports only the first RPC method that triggered the degraded condition when different methods fail or respond slowly', async () => { + const endpointUrl = 'https://some.endpoint'; + // First request: eth_blockNumber runs out of retries (triggers degraded) + nock(endpointUrl) + .post('/', { + id: 1, + jsonrpc: '2.0', + method: 'eth_blockNumber', + params: [], + }) + .times(5) + .reply(503); + // Second request: eth_gasPrice responds slowly (already degraded, no new event) + nock(endpointUrl) + .post('/', { + id: 2, + jsonrpc: '2.0', + method: 'eth_gasPrice', + params: [], + }) + .reply(200, () => { + jest.advanceTimersByTime(DEFAULT_DEGRADED_THRESHOLD + 1); + return { + id: 2, + jsonrpc: '2.0', + result: '0x1', + }; + }); + const expectedError = createResourceUnavailableError(503); + const expectedDegradedError = new HttpError(503); + const rpcServiceChain = new RpcServiceChain([ + { + fetch, + btoa, + isOffline: (): boolean => false, + endpointUrl, + }, + ]); + const onDegradedListener = jest.fn(); + rpcServiceChain.onServiceRetry(() => { + jest.advanceTimersToNextTimer(); + }); + rpcServiceChain.onDegraded(onDegradedListener); + + // eth_blockNumber exhausts retries, triggering degraded + await expect( + rpcServiceChain.request({ + id: 1, + jsonrpc: '2.0' as const, + method: 'eth_blockNumber', + params: [], + }), + ).rejects.toThrow(expectedError); + // eth_gasPrice responds slowly, but chain is already degraded + await rpcServiceChain.request({ + id: 2, + jsonrpc: '2.0' as const, + method: 'eth_gasPrice', + params: [], + }); + + expect(onDegradedListener).toHaveBeenCalledTimes(1); + expect(onDegradedListener).toHaveBeenCalledWith({ + error: expectedDegradedError, + rpcMethodName: 'eth_blockNumber', + duration: undefined, + traceId: undefined, + }); + }); + + it("does not call onDegraded again when the primary service's circuit breaks and its failover responds successfully but slowly", async () => { + const primaryEndpointUrl = 'https://first.endpoint'; + const secondaryEndpointUrl = 'https://second.endpoint'; + nock(primaryEndpointUrl) + .post('/', { + id: 1, + jsonrpc: '2.0', + method: 'eth_chainId', + params: [], + }) + .times(DEFAULT_MAX_CONSECUTIVE_FAILURES) + .reply(503); + nock(secondaryEndpointUrl) + .post('/', { + id: 1, + jsonrpc: '2.0', + method: 'eth_chainId', + params: [], + }) + .reply(200, () => { + jest.advanceTimersByTime(DEFAULT_DEGRADED_THRESHOLD + 1); + return { + id: 1, + jsonrpc: '2.0', + result: '0x1', + }; + }); + const expectedError = createResourceUnavailableError(503); + const expectedDegradedError = new HttpError(503); + const rpcServiceChain = new RpcServiceChain([ + { + fetch, + btoa, + isOffline: (): boolean => false, + endpointUrl: primaryEndpointUrl, + }, + { + fetch, + btoa, + isOffline: (): boolean => false, + endpointUrl: secondaryEndpointUrl, + }, + ]); + const onBreakListener = jest.fn(); + const onDegradedListener = jest.fn(); + rpcServiceChain.onServiceRetry(() => { + jest.advanceTimersToNextTimer(); + }); + rpcServiceChain.onBreak(onBreakListener); + rpcServiceChain.onDegraded(onDegradedListener); + + const jsonRpcRequest = { + id: 1, + jsonrpc: '2.0' as const, + method: 'eth_chainId', + params: [], + }; + // Retry the first endpoint until max retries is hit. + await expect(rpcServiceChain.request(jsonRpcRequest)).rejects.toThrow( + expectedError, + ); + // Retry the first endpoint again, until max retries is hit. + await expect(rpcServiceChain.request(jsonRpcRequest)).rejects.toThrow( + expectedError, + ); + // Retry the first endpoint for a third time, until max retries is hit. + // The circuit will break on the last time, and the second endpoint will + // be hit, albeit slowly. + await rpcServiceChain.request(jsonRpcRequest); + + expect(onDegradedListener).toHaveBeenCalledTimes(1); + expect(onDegradedListener).toHaveBeenCalledWith({ + error: expectedDegradedError, + rpcMethodName: 'eth_chainId', + duration: undefined, + traceId: undefined, + }); + }); + + it("calls onDegraded again when a service's underlying circuit breaks, and then after waiting, the service responds successfully but slowly", async () => { + const endpointUrl = 'https://some.endpoint'; + nock(endpointUrl) + .post('/', { + id: 1, + jsonrpc: '2.0', + method: 'eth_chainId', + params: [], + }) + .times(DEFAULT_MAX_CONSECUTIVE_FAILURES) + .reply(503); + nock(endpointUrl) + .post('/', { + id: 1, + jsonrpc: '2.0', + method: 'eth_chainId', + params: [], + }) + .reply(200, () => { + jest.advanceTimersByTime(DEFAULT_DEGRADED_THRESHOLD + 1); + return { + id: 1, + jsonrpc: '2.0', + result: '0x1', + }; + }); + const expectedError = createResourceUnavailableError(503); + const expectedDegradedError = new HttpError(503); + const rpcServiceChain = new RpcServiceChain([ + { + fetch, + btoa, + isOffline: (): boolean => false, + endpointUrl, + }, + ]); + const onDegradedListener = jest.fn(); + rpcServiceChain.onServiceRetry(() => { + jest.advanceTimersToNextTimer(); + }); + rpcServiceChain.onDegraded(onDegradedListener); + + const jsonRpcRequest = { + id: 1, + jsonrpc: '2.0' as const, + method: 'eth_chainId', + params: [], + }; + // Retry the endpoint until max retries is hit. + await expect(rpcServiceChain.request(jsonRpcRequest)).rejects.toThrow( + expectedError, + ); + // Retry the endpoint again, until max retries is hit. + await expect(rpcServiceChain.request(jsonRpcRequest)).rejects.toThrow( + expectedError, + ); + // Retry the endpoint for a third time, until max retries is hit. + // The circuit will break on the last time. + await expect(rpcServiceChain.request(jsonRpcRequest)).rejects.toThrow( + expectedError, + ); + // Wait until the circuit break duration passes, try the endpoint again, + // and see that it succeeds, but slowly. + jest.advanceTimersByTime(DEFAULT_CIRCUIT_BREAK_DURATION); + await rpcServiceChain.request(jsonRpcRequest); + + expect(onDegradedListener).toHaveBeenCalledTimes(2); + expect(onDegradedListener).toHaveBeenNthCalledWith(1, { + error: expectedDegradedError, + rpcMethodName: 'eth_chainId', + duration: undefined, + traceId: undefined, + }); + expect(onDegradedListener).toHaveBeenNthCalledWith(2, { + rpcMethodName: 'eth_chainId', + duration: expect.any(Number), + traceId: undefined, + }); + }); + + it("calls onDegraded again when a failover service's underlying circuit breaks, and then after waiting, the primary responds successfully but slowly", async () => { + const primaryEndpointUrl = 'https://first.endpoint'; + const secondaryEndpointUrl = 'https://second.endpoint'; + nock(primaryEndpointUrl) + .post('/', { + id: 1, + jsonrpc: '2.0', + method: 'eth_chainId', + params: [], + }) + .times(DEFAULT_MAX_CONSECUTIVE_FAILURES) + .reply(503); + nock(primaryEndpointUrl) + .post('/', { + id: 1, + jsonrpc: '2.0', + method: 'eth_chainId', + params: [], + }) + .reply(200, () => { + jest.advanceTimersByTime(DEFAULT_DEGRADED_THRESHOLD + 1); + return { + id: 1, + jsonrpc: '2.0', + result: '0x1', + }; + }); + nock(secondaryEndpointUrl) + .post('/', { + id: 1, + jsonrpc: '2.0', + method: 'eth_chainId', + params: [], + }) + .times(DEFAULT_MAX_CONSECUTIVE_FAILURES) + .reply(503); + const expectedError = createResourceUnavailableError(503); + const expectedDegradedError = new HttpError(503); + const rpcServiceChain = new RpcServiceChain([ + { + fetch, + btoa, + isOffline: (): boolean => false, + endpointUrl: primaryEndpointUrl, + }, + { + fetch, + btoa, + isOffline: (): boolean => false, + endpointUrl: secondaryEndpointUrl, + }, + ]); + const onDegradedListener = jest.fn(); + rpcServiceChain.onServiceRetry(() => { + jest.advanceTimersToNextTimer(); + }); + rpcServiceChain.onDegraded(onDegradedListener); + + const jsonRpcRequest = { + id: 1, + jsonrpc: '2.0' as const, + method: 'eth_chainId', + params: [], + }; + // Retry the first endpoint until max retries is hit. + await expect(rpcServiceChain.request(jsonRpcRequest)).rejects.toThrow( + expectedError, + ); + // Retry the first endpoint again, until max retries is hit. + await expect(rpcServiceChain.request(jsonRpcRequest)).rejects.toThrow( + expectedError, + ); + // Retry the first endpoint for a third time, until max retries is hit. + // The circuit will break on the last time, and the second endpoint will + // be hit, until max retries is hit. + await expect(rpcServiceChain.request(jsonRpcRequest)).rejects.toThrow( + expectedError, + ); + // Retry the second endpoint again, until max retries is hit. + await expect(rpcServiceChain.request(jsonRpcRequest)).rejects.toThrow( + expectedError, + ); + // Retry the second endpoint for a third time, until max retries is hit. + // The circuit will break on the last time. + await expect(rpcServiceChain.request(jsonRpcRequest)).rejects.toThrow( + expectedError, + ); + jest.advanceTimersByTime(DEFAULT_CIRCUIT_BREAK_DURATION); + // Hit the first endpoint again, and see that it succeeds, but slowly + await rpcServiceChain.request(jsonRpcRequest); + + expect(onDegradedListener).toHaveBeenCalledTimes(2); + expect(onDegradedListener).toHaveBeenNthCalledWith(1, { + error: expectedDegradedError, + rpcMethodName: 'eth_chainId', + duration: undefined, + traceId: undefined, + }); + expect(onDegradedListener).toHaveBeenNthCalledWith(2, { + rpcMethodName: 'eth_chainId', + duration: expect.any(Number), + traceId: undefined, + }); + }); + + it('calls onServiceDegraded each time a service continually runs out of retries (but before its circuit breaks)', async () => { + const endpointUrl = 'https://some.endpoint'; + nock(endpointUrl) + .post('/', { + id: 1, + jsonrpc: '2.0', + method: 'eth_chainId', + params: [], + }) + .times(DEFAULT_MAX_CONSECUTIVE_FAILURES) + .reply(503); + const expectedError = createResourceUnavailableError(503); + const expectedDegradedError = new HttpError(503); + const rpcServiceChain = new RpcServiceChain([ + { + fetch, + btoa, + isOffline: (): boolean => false, + endpointUrl, + }, + ]); + const onServiceDegradedListener = jest.fn(); + rpcServiceChain.onServiceRetry(() => { + jest.advanceTimersToNextTimer(); + }); + rpcServiceChain.onServiceDegraded(onServiceDegradedListener); + + const jsonRpcRequest = { + id: 1, + jsonrpc: '2.0' as const, + method: 'eth_chainId', + params: [], + }; + // Retry the endpoint until max retries is hit. + await expect(rpcServiceChain.request(jsonRpcRequest)).rejects.toThrow( + expectedError, + ); + // Retry the endpoint again, until max retries is hit. + await expect(rpcServiceChain.request(jsonRpcRequest)).rejects.toThrow( + expectedError, + ); + // Retry the endpoint for a third time, until max retries is hit. + // The circuit will break on the last time. + await expect(rpcServiceChain.request(jsonRpcRequest)).rejects.toThrow( + expectedError, + ); + + expect(onServiceDegradedListener).toHaveBeenCalledTimes(2); + expect(onServiceDegradedListener).toHaveBeenNthCalledWith(1, { + primaryEndpointUrl: `${endpointUrl}/`, + endpointUrl: `${endpointUrl}/`, + error: expectedDegradedError, + rpcMethodName: 'eth_chainId', + duration: undefined, + traceId: undefined, + }); + expect(onServiceDegradedListener).toHaveBeenNthCalledWith(2, { + primaryEndpointUrl: `${endpointUrl}/`, + endpointUrl: `${endpointUrl}/`, + error: expectedDegradedError, + rpcMethodName: 'eth_chainId', + duration: undefined, + traceId: undefined, + }); + }); + + it('calls onServiceDegraded each time a service continually responds slowly', async () => { + const endpointUrl = 'https://some.endpoint'; + nock(endpointUrl) + .post('/', { + id: 1, + jsonrpc: '2.0', + method: 'eth_chainId', + params: [], + }) + .times(2) + .reply(200, () => { + jest.advanceTimersByTime(DEFAULT_DEGRADED_THRESHOLD + 1); + return { + id: 1, + jsonrpc: '2.0', + result: '0x1', + }; + }); + const rpcServiceChain = new RpcServiceChain([ + { + fetch, + btoa, + isOffline: (): boolean => false, + endpointUrl, + }, + ]); + const onServiceDegradedListener = jest.fn(); + rpcServiceChain.onServiceRetry(() => { + jest.advanceTimersToNextTimer(); + }); + rpcServiceChain.onServiceDegraded(onServiceDegradedListener); + + const jsonRpcRequest = { + id: 1, + jsonrpc: '2.0' as const, + method: 'eth_chainId', + params: [], + }; + await rpcServiceChain.request(jsonRpcRequest); + await rpcServiceChain.request(jsonRpcRequest); + + expect(onServiceDegradedListener).toHaveBeenCalledTimes(2); + expect(onServiceDegradedListener).toHaveBeenNthCalledWith(1, { + primaryEndpointUrl: `${endpointUrl}/`, + endpointUrl: `${endpointUrl}/`, + rpcMethodName: 'eth_chainId', + duration: expect.any(Number), + traceId: undefined, + }); + expect(onServiceDegradedListener).toHaveBeenNthCalledWith(2, { + primaryEndpointUrl: `${endpointUrl}/`, + endpointUrl: `${endpointUrl}/`, + rpcMethodName: 'eth_chainId', + duration: expect.any(Number), + traceId: undefined, + }); + }); + + it('calls onServiceDegraded each time a service runs out of retries and then responds successfully but slowly, or vice versa', async () => { + const endpointUrl = 'https://some.endpoint'; + nock(endpointUrl) + .post('/', { + id: 1, + jsonrpc: '2.0', + method: 'eth_chainId', + params: [], + }) + .times(5) + .reply(503); + nock(endpointUrl) + .post('/', { + id: 1, + jsonrpc: '2.0', + method: 'eth_chainId', + params: [], + }) + .reply(200, () => { + jest.advanceTimersByTime(DEFAULT_DEGRADED_THRESHOLD + 1); + return { + id: 1, + jsonrpc: '2.0', + result: '0x1', + }; + }); + nock(endpointUrl) + .post('/', { + id: 1, + jsonrpc: '2.0', + method: 'eth_chainId', + params: [], + }) + .times(5) + .reply(503); + const expectedError = createResourceUnavailableError(503); + const expectedDegradedError = new HttpError(503); + const rpcServiceChain = new RpcServiceChain([ + { + fetch, + btoa, + isOffline: (): boolean => false, + endpointUrl, + }, + ]); + const onServiceDegradedListener = jest.fn(); + rpcServiceChain.onServiceRetry(() => { + jest.advanceTimersToNextTimer(); + }); + rpcServiceChain.onServiceDegraded(onServiceDegradedListener); + + const jsonRpcRequest = { + id: 1, + jsonrpc: '2.0' as const, + method: 'eth_chainId', + params: [], + }; + // Retry the endpoint until max retries is hit. + await expect(rpcServiceChain.request(jsonRpcRequest)).rejects.toThrow( + expectedError, + ); + // Try the endpoint again, and see that it succeeds. + await rpcServiceChain.request(jsonRpcRequest); + // Retry the endpoint again until max retries is hit. + await expect(rpcServiceChain.request(jsonRpcRequest)).rejects.toThrow( + expectedError, + ); + + expect(onServiceDegradedListener).toHaveBeenCalledTimes(3); + expect(onServiceDegradedListener).toHaveBeenNthCalledWith(1, { + primaryEndpointUrl: `${endpointUrl}/`, + endpointUrl: `${endpointUrl}/`, + error: expectedDegradedError, + rpcMethodName: 'eth_chainId', + duration: undefined, + traceId: undefined, + }); + expect(onServiceDegradedListener).toHaveBeenNthCalledWith(2, { + primaryEndpointUrl: `${endpointUrl}/`, + endpointUrl: `${endpointUrl}/`, + rpcMethodName: 'eth_chainId', + duration: expect.any(Number), + traceId: undefined, + }); + expect(onServiceDegradedListener).toHaveBeenNthCalledWith(3, { + primaryEndpointUrl: `${endpointUrl}/`, + endpointUrl: `${endpointUrl}/`, + error: expectedDegradedError, + rpcMethodName: 'eth_chainId', + duration: undefined, + traceId: undefined, + }); + }); + + it("calls onServiceDegraded again when the primary service's circuit breaks and its failover responds successfully but slowly", async () => { + const primaryEndpointUrl = 'https://first.endpoint'; + const secondaryEndpointUrl = 'https://second.endpoint'; + nock(primaryEndpointUrl) + .post('/', { + id: 1, + jsonrpc: '2.0', + method: 'eth_chainId', + params: [], + }) + .times(DEFAULT_MAX_CONSECUTIVE_FAILURES) + .reply(503); + nock(secondaryEndpointUrl) + .post('/', { + id: 1, + jsonrpc: '2.0', + method: 'eth_chainId', + params: [], + }) + .reply(200, () => { + jest.advanceTimersByTime(DEFAULT_DEGRADED_THRESHOLD + 1); + return { + id: 1, + jsonrpc: '2.0', + result: '0x1', + }; + }); + const expectedError = createResourceUnavailableError(503); + const expectedDegradedError = new HttpError(503); + const rpcServiceChain = new RpcServiceChain([ + { + fetch, + btoa, + isOffline: (): boolean => false, + endpointUrl: primaryEndpointUrl, + }, + { + fetch, + btoa, + isOffline: (): boolean => false, + endpointUrl: secondaryEndpointUrl, + }, + ]); + const onBreakListener = jest.fn(); + const onServiceDegradedListener = jest.fn(); + rpcServiceChain.onServiceRetry(() => { + jest.advanceTimersToNextTimer(); + }); + rpcServiceChain.onBreak(onBreakListener); + rpcServiceChain.onServiceDegraded(onServiceDegradedListener); + + const jsonRpcRequest = { + id: 1, + jsonrpc: '2.0' as const, + method: 'eth_chainId', + params: [], + }; + // Retry the first endpoint until max retries is hit. + await expect(rpcServiceChain.request(jsonRpcRequest)).rejects.toThrow( + expectedError, + ); + // Retry the first endpoint again, until max retries is hit. + await expect(rpcServiceChain.request(jsonRpcRequest)).rejects.toThrow( + expectedError, + ); + // Retry the first endpoint for a third time, until max retries is hit. + // The circuit will break on the last time, and the second endpoint will + // be hit, albeit slowly. + await rpcServiceChain.request(jsonRpcRequest); + + expect(onServiceDegradedListener).toHaveBeenCalledTimes(3); + expect(onServiceDegradedListener).toHaveBeenNthCalledWith(1, { + primaryEndpointUrl: `${primaryEndpointUrl}/`, + endpointUrl: `${primaryEndpointUrl}/`, + error: expectedDegradedError, + rpcMethodName: 'eth_chainId', + duration: undefined, + traceId: undefined, + }); + expect(onServiceDegradedListener).toHaveBeenNthCalledWith(2, { + primaryEndpointUrl: `${primaryEndpointUrl}/`, + endpointUrl: `${primaryEndpointUrl}/`, + error: expectedDegradedError, + rpcMethodName: 'eth_chainId', + duration: undefined, + traceId: undefined, + }); + expect(onServiceDegradedListener).toHaveBeenNthCalledWith(3, { + primaryEndpointUrl: `${primaryEndpointUrl}/`, + endpointUrl: `${secondaryEndpointUrl}/`, + rpcMethodName: 'eth_chainId', + duration: expect.any(Number), + traceId: undefined, + }); + }); + + it("calls onServiceDegraded again when a service's underlying circuit breaks, and then after waiting, the service responds successfully but slowly", async () => { + const endpointUrl = 'https://first.endpoint'; + nock(endpointUrl) + .post('/', { + id: 1, + jsonrpc: '2.0', + method: 'eth_chainId', + params: [], + }) + .times(DEFAULT_MAX_CONSECUTIVE_FAILURES) + .reply(503); + nock(endpointUrl) + .post('/', { + id: 1, + jsonrpc: '2.0', + method: 'eth_chainId', + params: [], + }) + .reply(200, () => { + jest.advanceTimersByTime(DEFAULT_DEGRADED_THRESHOLD + 1); + return { + id: 1, + jsonrpc: '2.0', + result: '0x1', + }; + }); + const expectedError = createResourceUnavailableError(503); + const expectedDegradedError = new HttpError(503); + const rpcServiceChain = new RpcServiceChain([ + { + fetch, + btoa, + isOffline: (): boolean => false, + endpointUrl, + }, + ]); + const onServiceDegradedListener = jest.fn(); + rpcServiceChain.onServiceRetry(() => { + jest.advanceTimersToNextTimer(); + }); + rpcServiceChain.onServiceDegraded(onServiceDegradedListener); + + const jsonRpcRequest = { + id: 1, + jsonrpc: '2.0' as const, + method: 'eth_chainId', + params: [], + }; + // Retry the endpoint until max retries is hit. + await expect(rpcServiceChain.request(jsonRpcRequest)).rejects.toThrow( + expectedError, + ); + // Retry the endpoint again, until max retries is hit. + await expect(rpcServiceChain.request(jsonRpcRequest)).rejects.toThrow( + expectedError, + ); + // Retry the endpoint for a third time, until max retries is hit. + // The circuit will break on the last time. + await expect(rpcServiceChain.request(jsonRpcRequest)).rejects.toThrow( + expectedError, + ); + // Wait until the circuit break duration passes, try the endpoint again, + // and see that it succeeds, but slowly. + jest.advanceTimersByTime(DEFAULT_CIRCUIT_BREAK_DURATION); + await rpcServiceChain.request(jsonRpcRequest); + + expect(onServiceDegradedListener).toHaveBeenCalledTimes(3); + expect(onServiceDegradedListener).toHaveBeenNthCalledWith(1, { + primaryEndpointUrl: `${endpointUrl}/`, + endpointUrl: `${endpointUrl}/`, + error: expectedDegradedError, + rpcMethodName: 'eth_chainId', + duration: undefined, + traceId: undefined, + }); + expect(onServiceDegradedListener).toHaveBeenNthCalledWith(2, { + primaryEndpointUrl: `${endpointUrl}/`, + endpointUrl: `${endpointUrl}/`, + error: expectedDegradedError, + rpcMethodName: 'eth_chainId', + duration: undefined, + traceId: undefined, + }); + expect(onServiceDegradedListener).toHaveBeenNthCalledWith(3, { + primaryEndpointUrl: `${endpointUrl}/`, + endpointUrl: `${endpointUrl}/`, + rpcMethodName: 'eth_chainId', + duration: expect.any(Number), + traceId: undefined, + }); + }); + + it("calls onServiceDegraded again when a failover service's underlying circuit breaks, and then after waiting, the primary responds successfully but slowly", async () => { + const primaryEndpointUrl = 'https://first.endpoint'; + const secondaryEndpointUrl = 'https://second.endpoint'; + nock(primaryEndpointUrl) + .post('/', { + id: 1, + jsonrpc: '2.0', + method: 'eth_chainId', + params: [], + }) + .times(DEFAULT_MAX_CONSECUTIVE_FAILURES) + .reply(503); + nock(primaryEndpointUrl) + .post('/', { + id: 1, + jsonrpc: '2.0', + method: 'eth_chainId', + params: [], + }) + .reply(200, () => { + jest.advanceTimersByTime(DEFAULT_DEGRADED_THRESHOLD + 1); + return { + id: 1, + jsonrpc: '2.0', + result: '0x1', + }; + }); + nock(secondaryEndpointUrl) + .post('/', { + id: 1, + jsonrpc: '2.0', + method: 'eth_chainId', + params: [], + }) + .times(DEFAULT_MAX_CONSECUTIVE_FAILURES) + .reply(503); + const expectedError = createResourceUnavailableError(503); + const expectedDegradedError = new HttpError(503); + const rpcServiceChain = new RpcServiceChain([ + { + fetch, + btoa, + isOffline: (): boolean => false, + endpointUrl: primaryEndpointUrl, + }, + { + fetch, + btoa, + isOffline: (): boolean => false, + endpointUrl: secondaryEndpointUrl, + }, + ]); + const onServiceDegradedListener = jest.fn(); + rpcServiceChain.onServiceRetry(() => { + jest.advanceTimersToNextTimer(); + }); + rpcServiceChain.onServiceDegraded(onServiceDegradedListener); + + const jsonRpcRequest = { + id: 1, + jsonrpc: '2.0' as const, + method: 'eth_chainId', + params: [], + }; + // Retry the first endpoint until max retries is hit. + await expect(rpcServiceChain.request(jsonRpcRequest)).rejects.toThrow( + expectedError, + ); + // Retry the first endpoint again, until max retries is hit. + await expect(rpcServiceChain.request(jsonRpcRequest)).rejects.toThrow( + expectedError, + ); + // Retry the first endpoint for a third time, until max retries is hit. + // The circuit will break on the last time, and the second endpoint will + // be hit, until max retries is hit. + await expect(rpcServiceChain.request(jsonRpcRequest)).rejects.toThrow( + expectedError, + ); + // Retry the second endpoint again, until max retries is hit. + await expect(rpcServiceChain.request(jsonRpcRequest)).rejects.toThrow( + expectedError, + ); + // Retry the second endpoint for a third time, until max retries is hit. + // The circuit will break on the last time. + await expect(rpcServiceChain.request(jsonRpcRequest)).rejects.toThrow( + expectedError, + ); + jest.advanceTimersByTime(DEFAULT_CIRCUIT_BREAK_DURATION); + // Hit the first endpoint again, and see that it succeeds, but slowly + await rpcServiceChain.request(jsonRpcRequest); + + expect(onServiceDegradedListener).toHaveBeenCalledTimes(5); + expect(onServiceDegradedListener).toHaveBeenNthCalledWith(1, { + primaryEndpointUrl: `${primaryEndpointUrl}/`, + endpointUrl: `${primaryEndpointUrl}/`, + error: expectedDegradedError, + rpcMethodName: 'eth_chainId', + duration: undefined, + traceId: undefined, + }); + expect(onServiceDegradedListener).toHaveBeenNthCalledWith(2, { + primaryEndpointUrl: `${primaryEndpointUrl}/`, + endpointUrl: `${primaryEndpointUrl}/`, + error: expectedDegradedError, + rpcMethodName: 'eth_chainId', + duration: undefined, + traceId: undefined, + }); + expect(onServiceDegradedListener).toHaveBeenNthCalledWith(3, { + primaryEndpointUrl: `${primaryEndpointUrl}/`, + endpointUrl: `${secondaryEndpointUrl}/`, + error: expectedDegradedError, + rpcMethodName: 'eth_chainId', + duration: undefined, + traceId: undefined, + }); + expect(onServiceDegradedListener).toHaveBeenNthCalledWith(4, { + primaryEndpointUrl: `${primaryEndpointUrl}/`, + endpointUrl: `${secondaryEndpointUrl}/`, + error: expectedDegradedError, + rpcMethodName: 'eth_chainId', + duration: undefined, + traceId: undefined, + }); + expect(onServiceDegradedListener).toHaveBeenNthCalledWith(5, { + primaryEndpointUrl: `${primaryEndpointUrl}/`, + endpointUrl: `${primaryEndpointUrl}/`, + rpcMethodName: 'eth_chainId', + duration: expect.any(Number), + traceId: undefined, + }); + }); + + it('forwards duration and traceId from the underlying service via onDegraded', async () => { + const endpointUrl = 'https://some.endpoint'; + nock(endpointUrl) + .post('/', { + id: 1, + jsonrpc: '2.0', + method: 'eth_chainId', + params: [], + }) + .reply( + 200, + () => { + jest.advanceTimersByTime(DEFAULT_DEGRADED_THRESHOLD + 1); + return { + id: 1, + jsonrpc: '2.0', + result: '0x1', + }; + }, + { 'x-trace-id': 'some-trace' }, + ); + const rpcServiceChain = new RpcServiceChain([ + { + fetch, + btoa, + isOffline: (): boolean => false, + endpointUrl, + }, + ]); + const onDegradedListener = jest.fn(); + rpcServiceChain.onDegraded(onDegradedListener); + + await rpcServiceChain.request({ + id: 1, + jsonrpc: '2.0' as const, + method: 'eth_chainId', + params: [], + }); + + expect(onDegradedListener).toHaveBeenCalledTimes(1); + expect(onDegradedListener).toHaveBeenCalledWith({ + rpcMethodName: 'eth_chainId', + duration: expect.any(Number), + traceId: 'some-trace', + }); + }); + + it('forwards duration and traceId from the underlying service via onServiceDegraded', async () => { + const endpointUrl = 'https://some.endpoint'; + nock(endpointUrl) + .post('/', { + id: 1, + jsonrpc: '2.0', + method: 'eth_chainId', + params: [], + }) + .reply( + 200, + () => { + jest.advanceTimersByTime(DEFAULT_DEGRADED_THRESHOLD + 1); + return { + id: 1, + jsonrpc: '2.0', + result: '0x1', + }; + }, + { 'x-trace-id': 'some-trace' }, + ); + const rpcServiceChain = new RpcServiceChain([ + { + fetch, + btoa, + isOffline: (): boolean => false, + endpointUrl, + }, + ]); + const onServiceDegradedListener = jest.fn(); + rpcServiceChain.onServiceDegraded(onServiceDegradedListener); + + await rpcServiceChain.request({ + id: 1, + jsonrpc: '2.0' as const, + method: 'eth_chainId', + params: [], + }); + + expect(onServiceDegradedListener).toHaveBeenCalledTimes(1); + expect(onServiceDegradedListener).toHaveBeenCalledWith({ + primaryEndpointUrl: `${endpointUrl}/`, + endpointUrl: `${endpointUrl}/`, + rpcMethodName: 'eth_chainId', + duration: expect.any(Number), + traceId: 'some-trace', + }); + }); + + it('calls onAvailable only once, even if a service continually responds successfully', async () => { + const endpointUrl = 'https://first.endpoint'; + nock(endpointUrl) + .post('/', { + id: 1, + jsonrpc: '2.0', + method: 'eth_chainId', + params: [], + }) + .times(3) + .reply(200, { + id: 1, + jsonrpc: '2.0', + result: '0x1', + }); + const rpcServiceChain = new RpcServiceChain([ + { + fetch, + btoa, + isOffline: (): boolean => false, + endpointUrl, + }, + ]); + const onAvailableListener = jest.fn(); + rpcServiceChain.onServiceRetry(() => { + jest.advanceTimersToNextTimer(); + }); + rpcServiceChain.onAvailable(onAvailableListener); + + const jsonRpcRequest = { + id: 1, + jsonrpc: '2.0' as const, + method: 'eth_chainId', + params: [], + }; + await rpcServiceChain.request(jsonRpcRequest); + await rpcServiceChain.request(jsonRpcRequest); + await rpcServiceChain.request(jsonRpcRequest); + + expect(onAvailableListener).toHaveBeenCalledTimes(1); + expect(onAvailableListener).toHaveBeenCalledWith({}); + }); + + it("calls onAvailable once, after the primary service's circuit has broken, the request to the failover succeeds", async () => { + const primaryEndpointUrl = 'https://first.endpoint'; + const secondaryEndpointUrl = 'https://second.endpoint'; + nock(primaryEndpointUrl) + .post('/', { + id: 1, + jsonrpc: '2.0', + method: 'eth_chainId', + params: [], + }) + .times(DEFAULT_MAX_CONSECUTIVE_FAILURES) + .reply(503); + nock(secondaryEndpointUrl) + .post('/', { + id: 1, + jsonrpc: '2.0', + method: 'eth_chainId', + params: [], + }) + .reply(200, { + id: 1, + jsonrpc: '2.0', + result: 'ok', + }); + + const rpcServiceChain = new RpcServiceChain([ + { + fetch, + btoa, + isOffline: (): boolean => false, + endpointUrl: primaryEndpointUrl, + }, + { + fetch, + btoa, + isOffline: (): boolean => false, + endpointUrl: secondaryEndpointUrl, + }, + ]); + const onAvailableListener = jest.fn(); + rpcServiceChain.onServiceRetry(() => { + jest.advanceTimersToNextTimer(); + }); + rpcServiceChain.onAvailable(onAvailableListener); + + const jsonRpcRequest = { + id: 1, + jsonrpc: '2.0' as const, + method: 'eth_chainId', + params: [], + }; + // Retry the first endpoint until max retries is hit. + await expect(rpcServiceChain.request(jsonRpcRequest)).rejects.toThrow( + createResourceUnavailableError(503), + ); + // Retry the first endpoint again, until max retries is hit. + await expect(rpcServiceChain.request(jsonRpcRequest)).rejects.toThrow( + createResourceUnavailableError(503), + ); + // Retry the first endpoint for a third time, until max retries is hit. + // The circuit will break on the last time, and the second endpoint will + // be hit. + await rpcServiceChain.request(jsonRpcRequest); + + expect(onAvailableListener).toHaveBeenCalledTimes(1); + expect(onAvailableListener).toHaveBeenNthCalledWith(1, {}); + }); + + it('calls onAvailable when a service becomes degraded by responding slowly, and then recovers', async () => { + const endpointUrl = 'https://first.endpoint'; + nock(endpointUrl) + .post('/', { + id: 1, + jsonrpc: '2.0', + method: 'eth_chainId', + params: [], + }) + .reply(200, () => { + jest.advanceTimersByTime(DEFAULT_DEGRADED_THRESHOLD + 1); + return { + id: 1, + jsonrpc: '2.0', + result: '0x1', + }; + }); + nock(endpointUrl) + .post('/', { + id: 1, + jsonrpc: '2.0', + method: 'eth_chainId', + params: [], + }) + .reply(200, { + id: 1, + jsonrpc: '2.0', + result: '0x1', + }); + const rpcServiceChain = new RpcServiceChain([ + { + fetch, + btoa, + isOffline: (): boolean => false, + endpointUrl, + }, + ]); + const onDegradedListener = jest.fn(); + const onAvailableListener = jest.fn(); + rpcServiceChain.onServiceRetry(() => { + jest.advanceTimersToNextTimer(); + }); + rpcServiceChain.onDegraded(onDegradedListener); + rpcServiceChain.onAvailable(onAvailableListener); + + const jsonRpcRequest = { + id: 1, + jsonrpc: '2.0' as const, + method: 'eth_chainId', + params: [], + }; + await rpcServiceChain.request(jsonRpcRequest); + await rpcServiceChain.request(jsonRpcRequest); + + // Verify degradation occurred after the first (slow) request + expect(onDegradedListener).toHaveBeenCalledTimes(1); + expect(onDegradedListener).toHaveBeenCalledWith({ + rpcMethodName: 'eth_chainId', + duration: expect.any(Number), + traceId: undefined, + }); + + // Verify recovery occurred after the second (fast) request + expect(onAvailableListener).toHaveBeenCalledTimes(1); + expect(onAvailableListener).toHaveBeenCalledWith({}); + + // Verify onDegraded was called before onAvailable (degradation then recovery) + expect(onDegradedListener.mock.invocationCallOrder[0]).toBeLessThan( + onAvailableListener.mock.invocationCallOrder[0], + ); + }); + }); +}); + +/** + * Creates a "resource unavailable" RPC error for testing. + * + * @param httpStatus - The HTTP status that the error represents. + * @returns The RPC error. + */ +function createResourceUnavailableError(httpStatus: number): Error { + return expect.objectContaining({ + code: errorCodes.rpc.resourceUnavailable, + message: 'RPC endpoint not found or unavailable.', + data: { + httpStatus, + }, + }); +} diff --git a/packages/network-controller/src/rpc-service/rpc-service-chain.ts b/packages/network-controller/src/rpc-service/rpc-service-chain.ts new file mode 100644 index 00000000000..89f6ba5cd2c --- /dev/null +++ b/packages/network-controller/src/rpc-service/rpc-service-chain.ts @@ -0,0 +1,481 @@ +import { + CircuitState, + CockatielEventEmitter, +} from '@metamask/controller-utils'; +import type { + Json, + JsonRpcParams, + JsonRpcRequest, + JsonRpcResponse, +} from '@metamask/utils'; +import { IDisposable } from 'cockatiel'; + +import { projectLogger, createModuleLogger } from '../logger.js'; +import { RpcService } from './rpc-service.js'; +import type { RpcServiceOptions } from './rpc-service.js'; +import type { + CockatielEventToEventListenerWithData, + ExcludeCockatielEventData, + ExtractCockatielEventData, + FetchOptions, +} from './shared.js'; + +const log = createModuleLogger(projectLogger, 'RpcServiceChain'); + +/** + * Statuses that the RPC service chain can be in. + */ +const STATUSES = { + Available: 'available', + Degraded: 'degraded', + Unknown: 'unknown', + Unavailable: 'unavailable', +} as const; + +/** + * Statuses that the RPC service chain can be in. + */ +type Status = (typeof STATUSES)[keyof typeof STATUSES]; + +/** + * This class constructs and manages requests to a chain of RpcService objects + * which represent RPC endpoints with which to access a particular network. The + * first service in the chain is intended to be the primary way of hitting the + * network and the remaining services are used as failovers. + */ +export class RpcServiceChain { + /** + * The event emitter for the `onAvailable` event. + */ + readonly #onAvailableEventEmitter: CockatielEventEmitter< + ExcludeCockatielEventData< + ExtractCockatielEventData, + 'endpointUrl' + > + >; + + /** + * The event emitter for the `onBreak` event. + */ + readonly #onBreakEventEmitter: CockatielEventEmitter< + ExcludeCockatielEventData< + ExtractCockatielEventData, + 'endpointUrl' + > + >; + + /** + * The event emitter for the `onDegraded` event. + */ + readonly #onDegradedEventEmitter: CockatielEventEmitter< + ExcludeCockatielEventData< + ExtractCockatielEventData, + 'endpointUrl' + > + >; + + /** + * The first RPC service that requests will be sent to. + */ + readonly #primaryService: RpcService; + + /** + * The RPC services in the chain. + */ + readonly #services: RpcService[]; + + /** + * The status of the RPC service chain. + */ + #status: Status; + + /** + * Constructs a new RpcServiceChain object. + * + * @param rpcServiceConfigurations - The options for the RPC services + * that you want to construct. Each object in this array is the same as + * {@link RpcServiceOptions}. + */ + constructor( + rpcServiceConfigurations: [RpcServiceOptions, ...RpcServiceOptions[]], + ) { + this.#services = rpcServiceConfigurations.map( + (rpcServiceConfiguration) => new RpcService(rpcServiceConfiguration), + ); + this.#primaryService = this.#services[0]; + + this.#status = STATUSES.Unknown; + this.#onBreakEventEmitter = new CockatielEventEmitter< + ExcludeCockatielEventData< + ExtractCockatielEventData, + 'endpointUrl' + > + >(); + + this.#onDegradedEventEmitter = new CockatielEventEmitter< + ExcludeCockatielEventData< + ExtractCockatielEventData, + 'endpointUrl' + > + >(); + for (const service of this.#services) { + service.onDegraded((data) => { + if (this.#status !== STATUSES.Degraded) { + log('Updating status to "degraded"', data); + this.#status = STATUSES.Degraded; + const { endpointUrl, ...rest } = data; + this.#onDegradedEventEmitter.emit(rest); + } + }); + } + + this.#onAvailableEventEmitter = new CockatielEventEmitter< + ExcludeCockatielEventData< + ExtractCockatielEventData, + 'endpointUrl' + > + >(); + for (const service of this.#services) { + service.onAvailable((data) => { + if (this.#status !== STATUSES.Available) { + log('Updating status to "available"', data); + this.#status = STATUSES.Available; + const { endpointUrl, ...rest } = data; + this.#onAvailableEventEmitter.emit(rest); + } + }); + } + } + + /** + * Calls the provided callback when any of the RPC services is retried. + * + * This is mainly useful for tests. + * + * @param listener - The callback to be called. + * @returns An object with a `dispose` method which can be used to unregister + * the event listener. + */ + onServiceRetry( + listener: CockatielEventToEventListenerWithData< + RpcService['onRetry'], + { primaryEndpointUrl: string } + >, + ): { dispose(): void } { + const disposables = this.#services.map((service) => + service.onRetry((data) => { + listener({ + ...data, + primaryEndpointUrl: this.#primaryService.endpointUrl.toString(), + }); + }), + ); + + return { + dispose(): void { + disposables.forEach((disposable) => disposable.dispose()); + }, + }; + } + + /** + * Calls the provided callback only when the maximum number of failed + * consecutive attempts to receive a 2xx response has been reached for all + * RPC services in the chain, and all services' underlying circuits have + * broken. + * + * The callback will not be called if a service's circuit breaks but its + * failover does not. Use `onServiceBreak` if you'd like a lower level of + * granularity. + * + * @param listener - The callback to be called. + * @returns An object with a `dispose` method which can be used to unregister + * the callback. + */ + onBreak( + listener: ( + data: ExcludeCockatielEventData< + ExtractCockatielEventData, + 'endpointUrl' + >, + ) => void, + ): IDisposable { + return this.#onBreakEventEmitter.addListener(listener); + } + + /** + * Calls the provided callback each time when, for *any* of the RPC services + * in this chain, the maximum number of failed consecutive attempts to receive + * a 2xx response has been reached and the underlying circuit has broken. A + * more granular version of `onBreak`. + * + * @param listener - The callback to be called. + * @returns An object with a `dispose` method which can be used to unregister + * the callback. + */ + onServiceBreak( + listener: CockatielEventToEventListenerWithData< + RpcService['onBreak'], + { primaryEndpointUrl: string } + >, + ): IDisposable { + const disposables = this.#services.map((service) => + service.onBreak((data) => { + listener({ + ...data, + primaryEndpointUrl: this.#primaryService.endpointUrl.toString(), + }); + }), + ); + + return { + dispose(): void { + disposables.forEach((disposable) => disposable.dispose()); + }, + }; + } + + /** + * Calls the provided callback if no requests have been initiated yet or + * all requests to RPC services in this chain have responded successfully in a + * timely fashion, and then one of the two conditions apply: + * + * 1. When a retriable error is encountered making a request to an RPC + * service, and the request is retried until a set maximum is reached. + * 2. When a RPC service responds successfully, but the request takes longer + * than a set number of seconds to complete. + * + * Note that the callback will be called even if there are local connectivity + * issues which prevent requests from being initiated. This is intentional. + * + * Also note this callback will only be called if the RPC service chain as a + * whole is in a "degraded" state, and will then only be called once (e.g., it + * will not be called if a failover service falls into a degraded state, then + * the primary comes back online, but it is slow). Use `onServiceDegraded` if + * you'd like a lower level of granularity. + * + * @param listener - The callback to be called. + * @returns An object with a `dispose` method which can be used to unregister + * the callback. + */ + onDegraded( + listener: ( + data: ExcludeCockatielEventData< + ExtractCockatielEventData, + 'endpointUrl' + >, + ) => void, + ): IDisposable { + return this.#onDegradedEventEmitter.addListener(listener); + } + + /** + * Calls the provided callback each time one of the two conditions apply: + * + * 1. When a retriable error is encountered making a request to an RPC + * service, and the request is retried until a set maximum is reached. + * 2. When a RPC service responds successfully, but the request takes longer + * than a set number of seconds to complete. + * + * Note that the callback will be called even if there are local connectivity + * issues which prevent requests from being initiated. This is intentional. + * + * This is a more granular version of `onDegraded`. The callback will be + * called for each slow request to an RPC service. It may also be called again + * if a failover service falls into a degraded state, then the primary comes + * back online, but it is slow. + * + * @param listener - The callback to be called. + * @returns An object with a `dispose` method which can be used to unregister + * the callback. + */ + onServiceDegraded( + listener: CockatielEventToEventListenerWithData< + RpcService['onDegraded'], + { primaryEndpointUrl: string } + >, + ): IDisposable { + const disposables = this.#services.map((service) => + service.onDegraded((data) => { + listener({ + ...data, + primaryEndpointUrl: this.#primaryService.endpointUrl.toString(), + }); + }), + ); + + return { + dispose(): void { + disposables.forEach((disposable) => disposable.dispose()); + }, + }; + } + + /** + * Calls the provided callback in one of the following two conditions: + * + * 1. The first time that a 2xx request is made to any of the RPC services in + * this chain. + * 2. When requests to any the failover RPC services in this chain were + * failing such that they were degraded or their underyling circuits broke, + * but the first request to the primary succeeds again. + * + * Note this callback will only be called if the RPC service chain as a whole + * is in an "available" state. + * + * @param listener - The callback to be called. + * @returns An object with a `dispose` method which can be used to unregister + * the callback. + */ + onAvailable( + listener: ( + data: ExcludeCockatielEventData< + ExtractCockatielEventData, + 'endpointUrl' + >, + ) => void, + ): IDisposable { + return this.#onAvailableEventEmitter.addListener(listener); + } + + /** + * Uses the RPC services in the chain to make a request, using each service + * after the first as a fallback to the previous one as necessary. + * + * This overload is specifically designed for `eth_getBlockByNumber`, which + * can return a `result` of `null` despite an expected `Result` being + * provided. + * + * @param jsonRpcRequest - The JSON-RPC request to send to the endpoint. + * @param fetchOptions - An options bag for {@link fetch} which further + * specifies the request. + * @returns The decoded JSON-RPC response from the endpoint. + * @throws A 401 error if the response status is 401. + * @throws A "rate limiting" error if the response HTTP status is 429. + * @throws A "resource unavailable" error if the response status is 402, 404, or any 5xx. + * @throws A generic HTTP client error (-32100) for any other 4xx status codes. + * @throws A "parse" error if the response is not valid JSON. + */ + async request( + jsonRpcRequest: Readonly> & { + method: 'eth_getBlockByNumber'; + }, + fetchOptions?: FetchOptions, + ): Promise | JsonRpcResponse>; + + /** + * Uses the RPC services in the chain to make a request, using each service + * after the first as a fallback to the previous one as necessary. + * + * This overload is designed for all RPC methods except for + * `eth_getBlockByNumber`, which are expected to return a `result` of the + * expected `Result`. + * + * @param jsonRpcRequest - The JSON-RPC request to send to the endpoint. + * @param fetchOptions - An options bag for {@link fetch} which further + * specifies the request. + * @returns The decoded JSON-RPC response from the endpoint. + * @throws A 401 error if the response status is 401. + * @throws A "rate limiting" error if the response HTTP status is 429. + * @throws A "resource unavailable" error if the response status is 402, 404, or any 5xx. + * @throws A generic HTTP client error (-32100) for any other 4xx status codes. + * @throws A "parse" error if the response is not valid JSON. + */ + async request( + jsonRpcRequest: Readonly>, + fetchOptions?: FetchOptions, + ): Promise>; + + async request( + jsonRpcRequest: Readonly>, + fetchOptions: FetchOptions = {}, + ): Promise> { + // Start with the primary (first) service and switch to failovers as the + // need arises. This is a bit confusing, so keep reading for more on how + // this works. + + let availableServiceIndex: number | undefined; + let response: JsonRpcResponse | undefined; + + for (const [i, service] of this.#services.entries()) { + log(`Trying service #${i + 1}...`); + const previousCircuitState = service.getCircuitState(); + + try { + // Try making the request through the service. + response = await service.request( + jsonRpcRequest, + fetchOptions, + ); + log('Service successfully received request.'); + availableServiceIndex = i; + break; + } catch (error) { + // Oops, that didn't work. + // Capture this error so that we can handle it later. + + const { lastError } = service; + const isCircuitOpen = service.getCircuitState() === CircuitState.Open; + + log('Service failed! error =', error, 'lastError = ', lastError); + + if (isCircuitOpen) { + if (i < this.#services.length - 1) { + log( + "This service's circuit is open. Proceeding to next service...", + ); + continue; + } + + if ( + previousCircuitState !== CircuitState.Open && + this.#status !== STATUSES.Unavailable && + lastError !== undefined + ) { + // If the service's circuit just broke and it's the last one in the + // chain, then trigger the onBreak event. (But if for some reason we + // have already done this, then don't do it.) + log( + 'This service\'s circuit just opened and it is the last service. Updating status to "unavailable" and triggering onBreak.', + ); + this.#status = STATUSES.Unavailable; + this.#onBreakEventEmitter.emit({ + error: lastError, + }); + } + } + + // The service failed, and we throw whatever the error is. The calling + // code can try again if it so desires. + log( + `${isCircuitOpen ? '' : "This service's circuit is closed. "}Re-throwing error.`, + ); + throw error; + } + } + + if (response) { + // If one of the services is available, reset all of the circuits of the + // following services. If we didn't do this and the service became + // unavailable in the future, and any of the failovers' circuits were + // open (due to previous failures), we would receive a "circuit broken" + // error when we attempted to divert traffic to the failovers again. + // + if (availableServiceIndex !== undefined) { + for (const [i, service] of [...this.#services.entries()].slice( + availableServiceIndex + 1, + )) { + log(`Resetting policy for service #${i + 1}.`); + service.resetPolicy(); + } + } + + return response; + } + + // The only way we can end up here is if there are no services to loop over. + // That is not possible due to the types on the constructor, but TypeScript + // doesn't know this, so we have to appease it. + throw new Error('Nothing to return'); + } +} diff --git a/packages/network-controller/src/rpc-service/rpc-service-requestable.ts b/packages/network-controller/src/rpc-service/rpc-service-requestable.ts new file mode 100644 index 00000000000..7d8e2764d6c --- /dev/null +++ b/packages/network-controller/src/rpc-service/rpc-service-requestable.ts @@ -0,0 +1,98 @@ +import type { ServicePolicy } from '@metamask/controller-utils'; +import type { + Json, + JsonRpcParams, + JsonRpcRequest, + JsonRpcResponse, +} from '@metamask/utils'; + +import type { + CockatielEventToEventListenerWithData, + ExcludeCockatielEventData, + ExtendCockatielEventData, + ExtractCockatielEventData, + FetchOptions, +} from './shared.js'; + +/** + * The interface for a service class responsible for making a request to a + * target, whether that is a single RPC endpoint or an RPC endpoint in an RPC + * service chain. + * + * @deprecated Don't use this interface (it will be removed in an upcoming major + * version). If you need to take an "RPC-service-like" argument, it's best to + * declare which properties you're interested in rather than accepting the + * entire RPC service interface. + */ +export type RpcServiceRequestable = { + /** + * Listens for when the RPC service retries the request. + * + * @param listener - The callback to be called when the retry occurs. + * @returns What {@link ServicePolicy.onRetry} returns. + * @see {@link createServicePolicy} + */ + onRetry( + listener: CockatielEventToEventListenerWithData< + ServicePolicy['onRetry'], + { endpointUrl: string } + >, + ): ReturnType; + + /** + * Listens for when the RPC service retries the request too many times in a + * row. + * + * @param listener - The callback to be called when the circuit is broken. + * @returns What {@link ServicePolicy.onBreak} returns. + * @see {@link createServicePolicy} + */ + onBreak( + listener: ( + data: ExcludeCockatielEventData< + ExtendCockatielEventData< + ExtractCockatielEventData, + { endpointUrl: string } + >, + 'isolated' + >, + ) => void, + ): ReturnType; + + /** + * Listens for when the policy underlying this RPC service detects a slow + * request. + * + * @param listener - The callback to be called when the request is slow. + * @returns What {@link ServicePolicy.onDegraded} returns. + * @see {@link createServicePolicy} + */ + onDegraded( + listener: CockatielEventToEventListenerWithData< + ServicePolicy['onDegraded'], + { endpointUrl: string; rpcMethodName: string } + >, + ): ReturnType; + + /** + * Listens for when the policy underlying this RPC service is available. + * + * @param listener - The callback to be called when the request is available. + * @returns What {@link ServicePolicy.onDegraded} returns. + * @see {@link createServicePolicy} + */ + onAvailable( + listener: CockatielEventToEventListenerWithData< + ServicePolicy['onAvailable'], + { endpointUrl: string } + >, + ): ReturnType; + + /** + * Makes a request to the target. + */ + request( + jsonRpcRequest: Readonly>, + fetchOptions?: FetchOptions, + ): Promise>; +}; diff --git a/packages/network-controller/src/rpc-service/rpc-service.test.ts b/packages/network-controller/src/rpc-service/rpc-service.test.ts new file mode 100644 index 00000000000..2e98f9c8094 --- /dev/null +++ b/packages/network-controller/src/rpc-service/rpc-service.test.ts @@ -0,0 +1,2265 @@ +import { + DEFAULT_CIRCUIT_BREAK_DURATION, + DEFAULT_DEGRADED_THRESHOLD, + HttpError, +} from '@metamask/controller-utils'; +import { errorCodes } from '@metamask/rpc-errors'; +import { CircuitState } from 'cockatiel'; +import deepFreeze from 'deep-freeze-strict'; +import nock from 'nock'; +import { FetchError } from 'node-fetch'; + +import { + CUSTOM_RPC_ERRORS, + DEFAULT_MAX_RETRIES, + RpcService, +} from './rpc-service.js'; + +describe('RpcService', () => { + beforeEach(() => { + jest.useFakeTimers({ doNotFake: ['nextTick', 'queueMicrotask'] }); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + describe('resetPolicy', () => { + it('resets the state of the circuit to "closed"', async () => { + const endpointUrl = 'https://rpc.example.chain'; + nock(endpointUrl) + .post('/', { + id: 1, + jsonrpc: '2.0', + method: 'eth_chainId', + params: [], + }) + .times(15) + .reply(503); + nock(endpointUrl) + .post('/', { + id: 1, + jsonrpc: '2.0', + method: 'eth_chainId', + params: [], + }) + .reply(200, { + id: 1, + jsonrpc: '2.0', + result: 'ok', + }); + const service = new RpcService({ + fetch, + btoa, + endpointUrl, + isOffline: (): boolean => false, + }); + service.onRetry(() => { + jest.advanceTimersToNextTimer(); + }); + + const jsonRpcRequest = { + id: 1, + jsonrpc: '2.0' as const, + method: 'eth_chainId', + params: [], + }; + // Get through the first two rounds of retries + await ignoreRejection(service.request(jsonRpcRequest)); + await ignoreRejection(service.request(jsonRpcRequest)); + // The last retry breaks the circuit + await ignoreRejection(service.request(jsonRpcRequest)); + expect(service.getCircuitState()).toBe(CircuitState.Open); + + service.resetPolicy(); + + expect(service.getCircuitState()).toBe(CircuitState.Closed); + }); + + it('allows making a successful request to the service if its circuit has broken', async () => { + const endpointUrl = 'https://rpc.example.chain'; + nock(endpointUrl) + .post('/', { + id: 1, + jsonrpc: '2.0', + method: 'eth_chainId', + params: [], + }) + .times(15) + .reply(503); + nock(endpointUrl) + .post('/', { + id: 1, + jsonrpc: '2.0', + method: 'eth_chainId', + params: [], + }) + .reply(200, { + id: 1, + jsonrpc: '2.0', + result: 'ok', + }); + const service = new RpcService({ + fetch, + btoa, + endpointUrl, + isOffline: (): boolean => false, + }); + service.onRetry(() => { + jest.advanceTimersToNextTimer(); + }); + + const jsonRpcRequest = { + id: 1, + jsonrpc: '2.0' as const, + method: 'eth_chainId', + params: [], + }; + // Get through the first two rounds of retries + await ignoreRejection(service.request(jsonRpcRequest)); + await ignoreRejection(service.request(jsonRpcRequest)); + // The last retry breaks the circuit + await ignoreRejection(service.request(jsonRpcRequest)); + + service.resetPolicy(); + + expect(await service.request(jsonRpcRequest)).toStrictEqual({ + id: 1, + jsonrpc: '2.0', + result: 'ok', + }); + }); + + it('calls onAvailable listeners if the service was executed successfully, its circuit broke, it was reset, and executes successfully again', async () => { + const endpointUrl = 'https://rpc.example.chain'; + nock(endpointUrl) + .post('/', { + id: 1, + jsonrpc: '2.0', + method: 'eth_chainId', + params: [], + }) + .reply(200, { + id: 1, + jsonrpc: '2.0', + result: 'ok', + }); + nock(endpointUrl) + .post('/', { + id: 1, + jsonrpc: '2.0', + method: 'eth_chainId', + params: [], + }) + .times(15) + .reply(503); + nock(endpointUrl) + .post('/', { + id: 1, + jsonrpc: '2.0', + method: 'eth_chainId', + params: [], + }) + .reply(200, { + id: 1, + jsonrpc: '2.0', + result: 'ok', + }); + const onAvailableListener = jest.fn(); + const service = new RpcService({ + fetch, + btoa, + endpointUrl, + isOffline: (): boolean => false, + }); + service.onRetry(() => { + jest.advanceTimersToNextTimer(); + }); + service.onAvailable(onAvailableListener); + + const jsonRpcRequest = { + id: 1, + jsonrpc: '2.0' as const, + method: 'eth_chainId', + params: [], + }; + + // Make a successful requst + await service.request(jsonRpcRequest); + expect(onAvailableListener).toHaveBeenCalledTimes(1); + + // Get through the first two rounds of retries + await ignoreRejection(service.request(jsonRpcRequest)); + await ignoreRejection(service.request(jsonRpcRequest)); + // The last retry breaks the circuit + await ignoreRejection(service.request(jsonRpcRequest)); + + service.resetPolicy(); + + // Make another successful requst + await service.request(jsonRpcRequest); + expect(onAvailableListener).toHaveBeenCalledTimes(2); + }); + + it('allows making an unsuccessful request to the service if its circuit has broken', async () => { + const endpointUrl = 'https://rpc.example.chain'; + nock(endpointUrl) + .post('/', { + id: 1, + jsonrpc: '2.0', + method: 'eth_chainId', + params: [], + }) + .times(15) + .reply(503); + nock(endpointUrl) + .post('/', { + id: 1, + jsonrpc: '2.0', + method: 'eth_chainId', + params: [], + }) + .reply(500); + const service = new RpcService({ + fetch, + btoa, + endpointUrl, + isOffline: (): boolean => false, + }); + service.onRetry(() => { + jest.advanceTimersToNextTimer(); + }); + + const jsonRpcRequest = { + id: 1, + jsonrpc: '2.0' as const, + method: 'eth_chainId', + params: [], + }; + // Get through the first two rounds of retries + await ignoreRejection(service.request(jsonRpcRequest)); + await ignoreRejection(service.request(jsonRpcRequest)); + // The last retry breaks the circuit + await ignoreRejection(service.request(jsonRpcRequest)); + + service.resetPolicy(); + + await expect(service.request(jsonRpcRequest)).rejects.toThrow( + 'RPC endpoint not found or unavailable', + ); + }); + + it('does not call onBreak listeners', async () => { + const endpointUrl = 'https://rpc.example.chain'; + nock(endpointUrl) + .post('/', { + id: 1, + jsonrpc: '2.0', + method: 'eth_chainId', + params: [], + }) + .times(15) + .reply(503); + nock(endpointUrl) + .post('/', { + id: 1, + jsonrpc: '2.0', + method: 'eth_chainId', + params: [], + }) + .reply(500); + const onBreakListener = jest.fn(); + const service = new RpcService({ + fetch, + btoa, + endpointUrl, + isOffline: (): boolean => false, + }); + service.onRetry(() => { + jest.advanceTimersToNextTimer(); + }); + service.onBreak(onBreakListener); + + const jsonRpcRequest = { + id: 1, + jsonrpc: '2.0' as const, + method: 'eth_chainId', + params: [], + }; + + // Get through the first two rounds of retries + await ignoreRejection(service.request(jsonRpcRequest)); + await ignoreRejection(service.request(jsonRpcRequest)); + // The last retry breaks the circuit + await ignoreRejection(service.request(jsonRpcRequest)); + expect(onBreakListener).toHaveBeenCalledTimes(1); + + service.resetPolicy(); + expect(onBreakListener).toHaveBeenCalledTimes(1); + }); + }); + + describe('getCircuitState', () => { + it('returns the state of the underlying circuit', async () => { + const jsonRpcRequest = { + id: 1, + jsonrpc: '2.0' as const, + method: 'eth_chainId', + params: [], + }; + const endpointUrl = 'https://rpc.example.chain'; + nock(endpointUrl).post('/', jsonRpcRequest).times(15).reply(503); + nock(endpointUrl) + .post('/', { + id: 1, + jsonrpc: '2.0', + method: 'eth_chainId', + params: [], + }) + .reply(500); + const service = new RpcService({ + fetch, + btoa, + endpointUrl, + isOffline: (): boolean => false, + }); + service.onRetry(() => { + jest.advanceTimersToNextTimer(); + }); + + expect(service.getCircuitState()).toBe(CircuitState.Closed); + + // Retry until we break the circuit + await ignoreRejection(service.request(jsonRpcRequest)); + await ignoreRejection(service.request(jsonRpcRequest)); + await ignoreRejection(service.request(jsonRpcRequest)); + await ignoreRejection(service.request(jsonRpcRequest)); + expect(service.getCircuitState()).toBe(CircuitState.Open); + + jest.advanceTimersByTime(DEFAULT_CIRCUIT_BREAK_DURATION); + const promise = ignoreRejection(service.request(jsonRpcRequest)); + expect(service.getCircuitState()).toBe(CircuitState.HalfOpen); + await promise; + expect(service.getCircuitState()).toBe(CircuitState.Open); + }); + }); + + describe('treating errors as service failures', () => { + const jsonRpcRequest = { + id: 1, + jsonrpc: '2.0' as const, + method: 'eth_chainId', + params: [], + }; + + describe('when the endpoint is an Infura URL', () => { + const endpointUrl = 'https://mainnet.infura.io'; + + it.each([400, 429])( + 'does not break the circuit when the endpoint responds with %d', + async (httpStatus) => { + nock(endpointUrl) + .post('/', jsonRpcRequest) + .times(3) + .reply(httpStatus); + const service = new RpcService({ + fetch, + btoa, + endpointUrl, + isOffline: (): boolean => false, + policyOptions: { maxConsecutiveFailures: 2 }, + }); + + // Make more requests than the max consecutive failures so that the + // circuit would open if these errors were treated as failures. + await ignoreRejection(service.request(jsonRpcRequest)); + await ignoreRejection(service.request(jsonRpcRequest)); + await ignoreRejection(service.request(jsonRpcRequest)); + + expect(service.getCircuitState()).toBe(CircuitState.Closed); + }, + ); + + it.each([401, 500])( + 'breaks the circuit when the endpoint responds with %d', + async (httpStatus) => { + nock(endpointUrl) + .post('/', jsonRpcRequest) + .times(2) + .reply(httpStatus); + const service = new RpcService({ + fetch, + btoa, + endpointUrl, + isOffline: (): boolean => false, + policyOptions: { maxConsecutiveFailures: 2 }, + }); + + await ignoreRejection(service.request(jsonRpcRequest)); + await ignoreRejection(service.request(jsonRpcRequest)); + + expect(service.getCircuitState()).toBe(CircuitState.Open); + }, + ); + }); + + describe('when the endpoint is not an Infura URL', () => { + const endpointUrl = 'https://rpc.example.chain'; + + it('does not break the circuit for a 4xx response that is not a server error', async () => { + nock(endpointUrl).post('/', jsonRpcRequest).times(3).reply(401); + const service = new RpcService({ + fetch, + btoa, + endpointUrl, + isOffline: (): boolean => false, + policyOptions: { maxConsecutiveFailures: 2 }, + }); + + // Make more requests than the max consecutive failures so that the + // circuit would open if these errors were treated as failures. + await ignoreRejection(service.request(jsonRpcRequest)); + await ignoreRejection(service.request(jsonRpcRequest)); + await ignoreRejection(service.request(jsonRpcRequest)); + + expect(service.getCircuitState()).toBe(CircuitState.Closed); + }); + }); + }); + + describe('request', () => { + // NOTE: Keep this list synced with CONNECTION_ERRORS + describe.each([ + { + constructorName: 'TypeError', + message: 'network error', + }, + { + constructorName: 'TypeError', + message: 'Failed to fetch', + }, + { + constructorName: 'TypeError', + message: 'NetworkError when attempting to fetch resource.', + }, + { + constructorName: 'TypeError', + message: 'The Internet connection appears to be offline.', + }, + { + constructorName: 'TypeError', + message: 'Load failed', + }, + { + constructorName: 'TypeError', + message: 'Network request failed', + }, + { + constructorName: 'FetchError', + message: 'request to https://foo.com failed', + }, + { + constructorName: 'TypeError', + message: 'fetch failed', + }, + { + constructorName: 'TypeError', + message: 'terminated', + }, + ])( + `if making the request throws the "$message" error`, + ({ constructorName, message }) => { + let error; + switch (constructorName) { + case 'FetchError': + error = new FetchError(message, 'system'); + break; + case 'TypeError': + error = new TypeError(message); + break; + default: + throw new Error(`Unknown constructor ${constructorName}`); + } + testsForRetriableFetchErrors({ + producedError: error, + expectedError: error, + }); + }, + ); + + describe.each(['ETIMEDOUT', 'ECONNRESET'])( + 'if making the request throws a "%s" error', + (errorCode) => { + const error = new Error('timed out'); + // @ts-expect-error `code` does not exist on the Error type, but is + // still used by Node. + error.code = errorCode; + + testsForRetriableFetchErrors({ + producedError: error, + expectedError: error, + }); + }, + ); + + describe('if the endpoint URL was not mocked via Nock', () => { + testsForNonRetriableErrors({ + expectedError: 'Nock: Disallowed net connect', + }); + }); + + describe('if the endpoint URL was mocked via Nock, but not the RPC method', () => { + testsForNonRetriableErrors({ + beforeCreateService: ({ endpointUrl }) => { + nock(endpointUrl) + .post('/', { + id: 1, + jsonrpc: '2.0', + method: 'eth_incorrectMethod', + params: [], + }) + .reply(500); + }, + rpcMethod: 'eth_chainId', + expectedError: 'Nock: No match for request', + }); + }); + + describe('if making the request throws an unknown error', () => { + testsForNonRetriableErrors({ + createService: ({ endpointUrl, expectedError }) => { + return new RpcService({ + fetch: (): never => { + // This error could be anything. + // eslint-disable-next-line @typescript-eslint/only-throw-error + throw expectedError; + }, + btoa, + endpointUrl, + isOffline: (): boolean => false, + }); + }, + expectedError: new Error('oops'), + }); + }); + + describe.each([502, 503, 504])( + 'if the endpoint has a %d response', + (httpStatus) => { + testsForRetriableResponses({ + httpStatus, + expectedError: expect.objectContaining({ + code: errorCodes.rpc.resourceUnavailable, + message: 'RPC endpoint not found or unavailable.', + data: { + httpStatus, + }, + }), + expectedOnBreakError: new HttpError(httpStatus), + }); + }, + ); + + describe('if the endpoint has a 401 response', () => { + testsForNonRetriableErrors({ + beforeCreateService: ({ endpointUrl, rpcMethod }) => { + nock(endpointUrl) + .post('/', { + id: 1, + jsonrpc: '2.0', + method: rpcMethod, + params: [], + }) + .reply(401); + }, + expectedError: expect.objectContaining({ + code: CUSTOM_RPC_ERRORS.unauthorized, + message: 'Unauthorized.', + data: { + httpStatus: 401, + }, + }), + }); + }); + + describe.each([402, 404, 500, 501, 505, 506, 507, 508, 510, 511])( + 'if the endpoint has a %d response', + (httpStatus) => { + testsForNonRetriableErrors({ + beforeCreateService: ({ endpointUrl, rpcMethod }) => { + nock(endpointUrl) + .post('/', { + id: 1, + jsonrpc: '2.0', + method: rpcMethod, + params: [], + }) + .reply(httpStatus); + }, + expectedError: expect.objectContaining({ + code: errorCodes.rpc.resourceUnavailable, + message: 'RPC endpoint not found or unavailable.', + data: { + httpStatus, + }, + }), + }); + }, + ); + + describe('if the endpoint has a 429 response', () => { + const httpStatus = 429; + + testsForNonRetriableErrors({ + beforeCreateService: ({ endpointUrl, rpcMethod }) => { + nock(endpointUrl) + .post('/', { + id: 1, + jsonrpc: '2.0', + method: rpcMethod, + params: [], + }) + .reply(httpStatus); + }, + expectedError: expect.objectContaining({ + code: errorCodes.rpc.limitExceeded, + message: 'Request is being rate limited.', + data: { + httpStatus, + }, + }), + }); + }); + + describe('when the endpoint has a 4xx response that is not 401, 402, 404, or 429', () => { + const httpStatus = 422; + + testsForNonRetriableErrors({ + beforeCreateService: ({ endpointUrl, rpcMethod }) => { + nock(endpointUrl) + .post('/', { + id: 1, + jsonrpc: '2.0', + method: rpcMethod, + params: [], + }) + .reply(httpStatus); + }, + expectedError: expect.objectContaining({ + code: CUSTOM_RPC_ERRORS.httpClientError, + message: 'RPC endpoint returned HTTP client error.', + data: { + httpStatus, + }, + }), + }); + }); + + describe.each([ + 'invalid JSON', + '{"foo": "ba', + '

Clearly an HTML response

', + ])( + 'if the endpoint consistently responds with invalid JSON %o', + (responseBody) => { + testsForRetriableResponses({ + httpStatus: 200, + responseBody, + expectedError: expect.objectContaining({ + code: -32700, + message: 'RPC endpoint did not return JSON.', + }), + expectedOnBreakError: expect.objectContaining({ + message: expect.stringContaining('invalid json'), + }), + }); + }, + ); + + describe('when offline', () => { + it('does not retry when offline, only makes one fetch call', async () => { + const expectedError = new TypeError('Failed to fetch'); + const mockFetch = jest.fn(() => { + throw expectedError; + }); + const service = new RpcService({ + fetch: mockFetch, + btoa, + endpointUrl: 'https://rpc.example.chain', + isOffline: (): boolean => true, + }); + service.onRetry(() => { + jest.advanceTimersToNextTimer(); + }); + + const jsonRpcRequest = { + id: 1, + jsonrpc: '2.0' as const, + method: 'eth_chainId', + params: [], + }; + await expect(service.request(jsonRpcRequest)).rejects.toThrow( + expectedError, + ); + // When offline, no retries should happen, so only 1 fetch call + expect(mockFetch).toHaveBeenCalledTimes(1); + }); + + it('does not call onDegraded when offline', async () => { + const expectedError = new TypeError('Failed to fetch'); + const mockFetch = jest.fn(() => { + throw expectedError; + }); + const endpointUrl = 'https://rpc.example.chain'; + const onDegradedListener = jest.fn(); + const service = new RpcService({ + fetch: mockFetch, + btoa, + endpointUrl, + isOffline: (): boolean => true, + }); + service.onRetry(() => { + jest.advanceTimersToNextTimer(); + }); + service.onDegraded(onDegradedListener); + + const jsonRpcRequest = { + id: 1, + jsonrpc: '2.0' as const, + method: 'eth_chainId', + params: [], + }; + await expect(service.request(jsonRpcRequest)).rejects.toThrow( + expectedError, + ); + + // When offline, retries don't happen, so onDegraded should not be called + expect(onDegradedListener).not.toHaveBeenCalled(); + }); + + it('does not call onBreak when offline', async () => { + const expectedError = new TypeError('Failed to fetch'); + const mockFetch = jest.fn(() => { + throw expectedError; + }); + const endpointUrl = 'https://rpc.example.chain'; + const onBreakListener = jest.fn(); + const service = new RpcService({ + fetch: mockFetch, + btoa, + endpointUrl, + isOffline: (): boolean => true, + }); + service.onRetry(() => { + jest.advanceTimersToNextTimer(); + }); + service.onBreak(onBreakListener); + + const jsonRpcRequest = { + id: 1, + jsonrpc: '2.0' as const, + method: 'eth_chainId', + params: [], + }; + // Make multiple requests - even though we'd normally break the circuit, + // when offline, no retries happen so circuit won't break + await expect(service.request(jsonRpcRequest)).rejects.toThrow( + expectedError, + ); + await expect(service.request(jsonRpcRequest)).rejects.toThrow( + expectedError, + ); + await expect(service.request(jsonRpcRequest)).rejects.toThrow( + expectedError, + ); + + // When offline, retries don't happen, so circuit won't break and onBreak + // should not be called + expect(onBreakListener).not.toHaveBeenCalled(); + }); + }); + + it('removes non-JSON-RPC-compliant properties from the request body before sending it to the endpoint', async () => { + const endpointUrl = 'https://rpc.example.chain'; + nock(endpointUrl) + .post('/', { + id: 1, + jsonrpc: '2.0', + method: 'eth_chainId', + params: [], + }) + .reply(200, { + id: 1, + jsonrpc: '2.0', + result: '0x1', + }); + const service = new RpcService({ + fetch, + btoa, + endpointUrl, + isOffline: (): boolean => false, + }); + + // @ts-expect-error Intentionally passing bad input. + const response = await service.request({ + id: 1, + jsonrpc: '2.0', + method: 'eth_chainId', + params: [], + some: 'extra', + properties: 'here', + }); + + expect(response).toStrictEqual({ + id: 1, + jsonrpc: '2.0', + result: '0x1', + }); + }); + + it('extracts a username and password from the URL to the Authorization header', async () => { + const scope = nock('https://rpc.example.chain', { + reqheaders: { + Authorization: 'Basic dXNlcm5hbWU6cGFzc3dvcmQ=', + }, + }) + .post('/', { + id: 1, + jsonrpc: '2.0', + method: 'eth_chainId', + params: [], + }) + .reply(200, { + id: 1, + jsonrpc: '2.0', + result: '0x1', + }); + const promiseForRequestUrl = new Promise((resolve) => { + scope.on('request', (request) => { + resolve(request.options.href); + }); + }); + const service = new RpcService({ + fetch, + btoa, + endpointUrl: 'https://username:password@rpc.example.chain', + isOffline: (): boolean => false, + }); + + const response = await service.request({ + id: 1, + jsonrpc: '2.0', + method: 'eth_chainId', + params: [], + }); + + expect(response).toStrictEqual({ + id: 1, + jsonrpc: '2.0', + result: '0x1', + }); + expect(await promiseForRequestUrl).toBe('https://rpc.example.chain/'); + }); + + it('makes the request with Accept and Content-Type headers by default', async () => { + const scope = nock('https://rpc.example.chain', { + reqheaders: { + Accept: 'application/json', + 'Content-Type': 'application/json', + }, + }) + .post('/', { + id: 1, + jsonrpc: '2.0', + method: 'eth_chainId', + params: [], + }) + .reply(200, { + id: 1, + jsonrpc: '2.0', + result: '0x1', + }); + const service = new RpcService({ + fetch, + btoa, + endpointUrl: 'https://username:password@rpc.example.chain', + isOffline: (): boolean => false, + }); + + await service.request({ + id: 1, + jsonrpc: '2.0', + method: 'eth_chainId', + params: [], + }); + + expect(scope.isDone()).toBe(true); + }); + + it('mixes the given request options into the default request options', async () => { + const scope = nock('https://rpc.example.chain', { + reqheaders: { + Accept: 'application/json', + 'Content-Type': 'application/json', + 'X-Foo': 'Bar', + }, + }) + .post('/', { + id: 1, + jsonrpc: '2.0', + method: 'eth_chainId', + params: [], + }) + .reply(200, { + id: 1, + jsonrpc: '2.0', + result: '0x1', + }); + const service = new RpcService({ + fetch, + btoa, + endpointUrl: 'https://username:password@rpc.example.chain', + isOffline: (): boolean => false, + }); + + await service.request( + { + id: 1, + jsonrpc: '2.0', + method: 'eth_chainId', + params: [], + }, + { + headers: { + 'X-Foo': 'Bar', + }, + }, + ); + + expect(scope.isDone()).toBe(true); + }); + + it('returns the JSON-decoded response if the request succeeds', async () => { + const endpointUrl = 'https://rpc.example.chain'; + nock(endpointUrl) + .post('/', { + id: 1, + jsonrpc: '2.0', + method: 'eth_getBlockByNumber', + params: ['0x68b3', false], + }) + .reply(200, { + id: 1, + jsonrpc: '2.0', + result: { + number: '0x68b3', + hash: '0xd5f1812548be429cbdc6376b29611fc49e06f1359758c4ceaaa3b393e2239f9c', + nonce: '0x378da40ff335b070', + gasLimit: '0x47e7c4', + gasUsed: '0x37993', + timestamp: '0x5835c54d', + transactions: [ + '0xa0807e117a8dd124ab949f460f08c36c72b710188f01609595223b325e58e0fc', + '0xeae6d797af50cb62a596ec3939114d63967c374fa57de9bc0f4e2b576ed6639d', + ], + baseFeePerGas: '0x7', + }, + }); + const service = new RpcService({ + fetch, + btoa, + endpointUrl, + isOffline: (): boolean => false, + }); + + const response = await service.request({ + id: 1, + jsonrpc: '2.0', + method: 'eth_getBlockByNumber', + params: ['0x68b3', false], + }); + + expect(response).toStrictEqual({ + id: 1, + jsonrpc: '2.0', + result: { + number: '0x68b3', + hash: '0xd5f1812548be429cbdc6376b29611fc49e06f1359758c4ceaaa3b393e2239f9c', + nonce: '0x378da40ff335b070', + gasLimit: '0x47e7c4', + gasUsed: '0x37993', + timestamp: '0x5835c54d', + transactions: [ + '0xa0807e117a8dd124ab949f460f08c36c72b710188f01609595223b325e58e0fc', + '0xeae6d797af50cb62a596ec3939114d63967c374fa57de9bc0f4e2b576ed6639d', + ], + baseFeePerGas: '0x7', + }, + }); + }); + + it('handles deeply frozen JSON-RPC requests', async () => { + const endpointUrl = 'https://rpc.example.chain'; + nock(endpointUrl) + .post('/', { + id: 1, + jsonrpc: '2.0', + method: 'eth_blockNumber', + params: [], + }) + .reply(200, { + id: 1, + jsonrpc: '2.0', + result: '0x1', + }); + const service = new RpcService({ + fetch, + btoa, + endpointUrl, + isOffline: (): boolean => false, + }); + + const response = await service.request( + deepFreeze({ + id: 1, + jsonrpc: '2.0', + method: 'eth_blockNumber', + params: [], + }), + ); + + expect(response).toStrictEqual({ + id: 1, + jsonrpc: '2.0', + result: '0x1', + }); + }); + + it('does not throw if the endpoint returns an unsuccessful JSON-RPC response', async () => { + const endpointUrl = 'https://rpc.example.chain'; + nock(endpointUrl) + .post('/', { + id: 1, + jsonrpc: '2.0', + method: 'eth_chainId', + params: [], + }) + .reply(200, { + id: 1, + jsonrpc: '2.0', + error: { + code: -32000, + message: 'oops', + }, + }); + const service = new RpcService({ + fetch, + btoa, + endpointUrl, + isOffline: (): boolean => false, + }); + + const response = await service.request({ + id: 1, + jsonrpc: '2.0', + method: 'eth_chainId', + params: [], + }); + + expect(response).toStrictEqual({ + id: 1, + jsonrpc: '2.0', + error: { + code: -32000, + message: 'oops', + }, + }); + }); + + it('calls the onDegraded callback if the endpoint takes more than 5 seconds to respond', async () => { + const endpointUrl = 'https://rpc.example.chain'; + nock(endpointUrl) + .post('/', { + id: 1, + jsonrpc: '2.0', + method: 'eth_chainId', + params: [], + }) + .reply(200, () => { + jest.advanceTimersByTime(DEFAULT_DEGRADED_THRESHOLD + 1); + return { + id: 1, + jsonrpc: '2.0', + result: '0x1', + }; + }); + const onDegradedListener = jest.fn(); + const service = new RpcService({ + fetch, + btoa, + endpointUrl, + isOffline: (): boolean => false, + }); + service.onDegraded(onDegradedListener); + + await service.request({ + id: 1, + jsonrpc: '2.0', + method: 'eth_chainId', + params: [], + }); + + expect(onDegradedListener).toHaveBeenCalledTimes(1); + expect(onDegradedListener).toHaveBeenCalledWith({ + endpointUrl: `${endpointUrl}/`, + rpcMethodName: 'eth_chainId', + duration: expect.any(Number), + traceId: undefined, + }); + }); + + it('calls onDegraded twice with the correct rpcMethodName when two concurrent requests to different methods both respond slowly', async () => { + const endpointUrl = 'https://rpc.example.chain'; + nock(endpointUrl) + .post('/', { + id: 1, + jsonrpc: '2.0', + method: 'eth_blockNumber', + params: [], + }) + .reply(200, () => { + jest.advanceTimersByTime(DEFAULT_DEGRADED_THRESHOLD + 1); + return { + id: 1, + jsonrpc: '2.0', + result: '0x1', + }; + }); + nock(endpointUrl) + .post('/', { + id: 2, + jsonrpc: '2.0', + method: 'eth_gasPrice', + params: [], + }) + .reply(200, () => { + jest.advanceTimersByTime(DEFAULT_DEGRADED_THRESHOLD + 1); + return { + id: 2, + jsonrpc: '2.0', + result: '0x100', + }; + }); + const onDegradedListener = jest.fn(); + const service = new RpcService({ + fetch, + btoa, + endpointUrl, + isOffline: (): boolean => false, + }); + service.onDegraded(onDegradedListener); + + // Start both requests concurrently + await Promise.all([ + service.request({ + id: 1, + jsonrpc: '2.0', + method: 'eth_blockNumber', + params: [], + }), + service.request({ + id: 2, + jsonrpc: '2.0', + method: 'eth_gasPrice', + params: [], + }), + ]); + + expect(onDegradedListener).toHaveBeenCalledTimes(2); + expect(onDegradedListener).toHaveBeenCalledWith({ + endpointUrl: `${endpointUrl}/`, + rpcMethodName: 'eth_blockNumber', + duration: expect.any(Number), + traceId: undefined, + }); + expect(onDegradedListener).toHaveBeenCalledWith({ + endpointUrl: `${endpointUrl}/`, + rpcMethodName: 'eth_gasPrice', + duration: expect.any(Number), + traceId: undefined, + }); + }); + + it('calls onDegraded twice with the correct rpcMethodName when two concurrent requests to different methods fail — one slow, one retriable', async () => { + const endpointUrl = 'https://rpc.example.chain'; + // eth_blockNumber: responds slowly + nock(endpointUrl) + .post('/', { + id: 1, + jsonrpc: '2.0', + method: 'eth_blockNumber', + params: [], + }) + .reply(200, () => { + jest.advanceTimersByTime(DEFAULT_DEGRADED_THRESHOLD + 1); + return { + id: 1, + jsonrpc: '2.0', + result: '0x1', + }; + }); + // eth_gasPrice: retries exhausted (5 x 503) + nock(endpointUrl) + .post('/', { + id: 2, + jsonrpc: '2.0', + method: 'eth_gasPrice', + params: [], + }) + .times(5) + .reply(503); + const onDegradedListener = jest.fn(); + const service = new RpcService({ + fetch, + btoa, + endpointUrl, + isOffline: (): boolean => false, + }); + service.onRetry(() => { + jest.advanceTimersToNextTimer(); + }); + service.onDegraded(onDegradedListener); + + // Start both requests concurrently + await Promise.allSettled([ + service.request({ + id: 1, + jsonrpc: '2.0', + method: 'eth_blockNumber', + params: [], + }), + service.request({ + id: 2, + jsonrpc: '2.0', + method: 'eth_gasPrice', + params: [], + }), + ]); + + expect(onDegradedListener).toHaveBeenCalledTimes(2); + expect(onDegradedListener).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ + rpcMethodName: 'eth_blockNumber', + }), + ); + expect(onDegradedListener).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ + rpcMethodName: 'eth_gasPrice', + }), + ); + }); + + it('calls the onDegraded callback with a trace ID if the endpoint takes more than 5 seconds to respond and a trace ID is available', async () => { + const endpointUrl = 'https://rpc.example.chain'; + nock(endpointUrl) + .post('/', { + id: 1, + jsonrpc: '2.0', + method: 'eth_chainId', + params: [], + }) + .reply( + 200, + () => { + jest.advanceTimersByTime(DEFAULT_DEGRADED_THRESHOLD + 1); + return { + id: 1, + jsonrpc: '2.0', + result: '0x1', + }; + }, + { 'X-Trace-Id': 'abc-123-trace' }, + ); + const onDegradedListener = jest.fn(); + const service = new RpcService({ + fetch, + btoa, + endpointUrl, + isOffline: (): boolean => false, + }); + service.onDegraded(onDegradedListener); + + await service.request({ + id: 1, + jsonrpc: '2.0', + method: 'eth_chainId', + params: [], + }); + + expect(onDegradedListener).toHaveBeenCalledTimes(1); + expect(onDegradedListener).toHaveBeenCalledWith( + expect.objectContaining({ + traceId: 'abc-123-trace', + duration: expect.any(Number), + }), + ); + }); + + it('calls the onAvailable callback the first time a successful request occurs', async () => { + const endpointUrl = 'https://rpc.example.chain'; + nock(endpointUrl) + .post('/', { + id: 1, + jsonrpc: '2.0', + method: 'eth_chainId', + params: [], + }) + .reply(200, () => { + return { + id: 1, + jsonrpc: '2.0', + result: '0x1', + }; + }); + const onAvailableListener = jest.fn(); + const service = new RpcService({ + fetch, + btoa, + endpointUrl, + isOffline: (): boolean => false, + }); + service.onAvailable(onAvailableListener); + + await service.request({ + id: 1, + jsonrpc: '2.0', + method: 'eth_chainId', + params: [], + }); + + expect(onAvailableListener).toHaveBeenCalledTimes(1); + expect(onAvailableListener).toHaveBeenCalledWith({ + endpointUrl: `${endpointUrl}/`, + }); + }); + + it('calls the onAvailable callback if the endpoint takes more than 5 seconds to respond and then speeds up again', async () => { + const endpointUrl = 'https://rpc.example.chain'; + nock(endpointUrl) + .post('/', { + id: 1, + jsonrpc: '2.0', + method: 'eth_chainId', + params: [], + }) + .reply(200, () => { + jest.advanceTimersByTime(DEFAULT_DEGRADED_THRESHOLD + 1); + return { + id: 1, + jsonrpc: '2.0', + result: '0x1', + }; + }) + .post('/', { + id: 1, + jsonrpc: '2.0', + method: 'eth_chainId', + params: [], + }) + .reply(200, () => { + return { + id: 1, + jsonrpc: '2.0', + result: '0x1', + }; + }); + const onAvailableListener = jest.fn(); + const service = new RpcService({ + fetch, + btoa, + endpointUrl, + isOffline: (): boolean => false, + }); + service.onAvailable(onAvailableListener); + + await service.request({ + id: 1, + jsonrpc: '2.0', + method: 'eth_chainId', + params: [], + }); + await service.request({ + id: 1, + jsonrpc: '2.0', + method: 'eth_chainId', + params: [], + }); + + expect(onAvailableListener).toHaveBeenCalledTimes(1); + expect(onAvailableListener).toHaveBeenCalledWith({ + endpointUrl: `${endpointUrl}/`, + }); + }); + }); +}); + +/** + * Some tests involve a rejected promise that is not necessarily the focus of + * the test. In these cases we don't want to ignore the error in case the + * promise _isn't_ rejected, but we don't want to highlight the assertion, + * either. + * + * @param promiseOrFn - A promise that rejects, or a function that returns a + * promise that rejects. + */ +async function ignoreRejection( + promiseOrFn: Promise | (() => Type | Promise), +): Promise { + await expect(promiseOrFn).rejects.toThrow(expect.any(Error)); +} + +/** + * These are tests that exercise logic for cases in which the request cannot be + * made because some kind of error is thrown, and the request is not retried. + * + * @param args - The arguments. + * @param args.beforeCreateService - A function that is run before the service + * is created. + * @param args.createService - A function that is run to create the service. + * @param args.endpointUrl - The URL that is hit. + * @param args.rpcMethod - The RPC method that is used. (Defaults to + * `eth_chainId`). + * @param args.expectedError - The error that a call to the service's `request` + * method is expected to produce. + */ +function testsForNonRetriableErrors({ + beforeCreateService = (): void => { + // do nothing + }, + createService = (args): RpcService => { + return new RpcService({ + fetch, + btoa, + endpointUrl: args.endpointUrl, + isOffline: (): boolean => false, + }); + }, + endpointUrl = 'https://rpc.example.chain', + rpcMethod = `eth_chainId`, + expectedError, +}: { + beforeCreateService?: (args: { + endpointUrl: string; + rpcMethod: string; + }) => void; + createService?: (args: { + endpointUrl: string; + expectedError: string | RegExp | Error | jest.Constructable | undefined; + }) => RpcService; + endpointUrl?: string; + rpcMethod?: string; + expectedError: string | RegExp | Error | jest.Constructable | undefined; +}): void { + /* eslint-disable jest/require-top-level-describe */ + + it('re-throws the error without retrying the request', async () => { + beforeCreateService({ endpointUrl, rpcMethod }); + const service = createService({ endpointUrl, expectedError }); + + const promise = service.request({ + id: 1, + jsonrpc: '2.0', + method: rpcMethod, + params: [], + }); + + await expect(promise).rejects.toThrow(expectedError); + }); + + it('does not call onRetry', async () => { + beforeCreateService({ endpointUrl, rpcMethod }); + const onRetryListener = jest.fn(); + const service = createService({ endpointUrl, expectedError }); + service.onRetry(onRetryListener); + + await ignoreRejection( + service.request({ + id: 1, + jsonrpc: '2.0', + method: rpcMethod, + params: [], + }), + ); + expect(onRetryListener).not.toHaveBeenCalled(); + }); + + it('does not call onBreak', async () => { + beforeCreateService({ endpointUrl, rpcMethod }); + const onBreakListener = jest.fn(); + const service = createService({ endpointUrl, expectedError }); + service.onBreak(onBreakListener); + + await ignoreRejection( + service.request({ + id: 1, + jsonrpc: '2.0', + method: rpcMethod, + params: [], + }), + ); + expect(onBreakListener).not.toHaveBeenCalled(); + }); + + it('does not call onDegraded', async () => { + beforeCreateService({ endpointUrl, rpcMethod }); + const onDegradedListener = jest.fn(); + const service = createService({ endpointUrl, expectedError }); + service.onDegraded(onDegradedListener); + + await ignoreRejection( + service.request({ + id: 1, + jsonrpc: '2.0', + method: rpcMethod, + params: [], + }), + ); + expect(onDegradedListener).not.toHaveBeenCalled(); + }); + + it('does not call onAvailable', async () => { + beforeCreateService({ endpointUrl, rpcMethod }); + const onAvailableListener = jest.fn(); + const service = createService({ endpointUrl, expectedError }); + service.onAvailable(onAvailableListener); + + await ignoreRejection( + service.request({ + id: 1, + jsonrpc: '2.0', + method: rpcMethod, + params: [], + }), + ); + expect(onAvailableListener).not.toHaveBeenCalled(); + }); + + /* eslint-enable jest/require-top-level-describe */ +} + +/** + * These are tests that exercise logic for cases in which the request cannot be + * made because the `fetch` calls throws a specific error. + * + * @param args - The arguments + * @param args.producedError - The error produced when `fetch` is called. + * @param args.expectedError - The error that a call to the service's `request` + * method is expected to produce. + */ +function testsForRetriableFetchErrors({ + producedError, + expectedError, +}: { + producedError: Error; + expectedError: string | jest.Constructable | RegExp | Error; +}): void { + // This function is designed to be used inside of a describe, so this won't be + // a problem in practice. + /* eslint-disable jest/require-top-level-describe */ + + it('retries a constantly failing request up to 4 more times before re-throwing the error, if `request` is only called once', async () => { + const mockFetch = jest.fn(() => { + throw producedError; + }); + const service = new RpcService({ + fetch: mockFetch, + btoa, + endpointUrl: 'https://rpc.example.chain', + isOffline: (): boolean => false, + }); + service.onRetry(() => { + jest.advanceTimersToNextTimer(); + }); + + const jsonRpcRequest = { + id: 1, + jsonrpc: '2.0' as const, + method: 'eth_chainId', + params: [], + }; + await expect(service.request(jsonRpcRequest)).rejects.toThrow( + expectedError, + ); + expect(mockFetch).toHaveBeenCalledTimes(5); + }); + + it('calls the onDegraded callback once for each retry round', async () => { + const mockFetch = jest.fn(() => { + throw producedError; + }); + const endpointUrl = 'https://rpc.example.chain'; + const onDegradedListener = jest.fn(); + const service = new RpcService({ + fetch: mockFetch, + btoa, + endpointUrl, + isOffline: (): boolean => false, + }); + service.onRetry(() => { + jest.advanceTimersToNextTimer(); + }); + + service.onDegraded(onDegradedListener); + + const jsonRpcRequest = { + id: 1, + jsonrpc: '2.0' as const, + method: 'eth_chainId', + params: [], + }; + await ignoreRejection(service.request(jsonRpcRequest)); + await ignoreRejection(service.request(jsonRpcRequest)); + + expect(onDegradedListener).toHaveBeenCalledTimes(2); + expect(onDegradedListener).toHaveBeenCalledWith({ + endpointUrl: `${endpointUrl}/`, + error: expectedError, + rpcMethodName: 'eth_chainId', + duration: undefined, + traceId: undefined, + }); + }); + + it('still re-throws the error even after the circuit breaks', async () => { + const mockFetch = jest.fn(() => { + throw producedError; + }); + const service = new RpcService({ + fetch: mockFetch, + btoa, + endpointUrl: 'https://rpc.example.chain', + isOffline: (): boolean => false, + }); + service.onRetry(() => { + jest.advanceTimersToNextTimer(); + }); + + const jsonRpcRequest = { + id: 1, + jsonrpc: '2.0' as const, + method: 'eth_chainId', + params: [], + }; + await ignoreRejection(service.request(jsonRpcRequest)); + await ignoreRejection(service.request(jsonRpcRequest)); + // The last retry breaks the circuit + await expect(service.request(jsonRpcRequest)).rejects.toThrow( + expectedError, + ); + }); + + it('calls the onBreak callback once after the circuit breaks', async () => { + const mockFetch = jest.fn(() => { + throw producedError; + }); + const endpointUrl = 'https://rpc.example.chain'; + const onBreakListener = jest.fn(); + const service = new RpcService({ + fetch: mockFetch, + btoa, + endpointUrl, + isOffline: (): boolean => false, + }); + service.onRetry(() => { + jest.advanceTimersToNextTimer(); + }); + service.onBreak(onBreakListener); + + const jsonRpcRequest = { + id: 1, + jsonrpc: '2.0' as const, + method: 'eth_chainId', + params: [], + }; + await ignoreRejection(service.request(jsonRpcRequest)); + await ignoreRejection(service.request(jsonRpcRequest)); + // The last retry breaks the circuit + await ignoreRejection(service.request(jsonRpcRequest)); + + expect(onBreakListener).toHaveBeenCalledTimes(1); + expect(onBreakListener).toHaveBeenCalledWith({ + error: expectedError, + endpointUrl: `${endpointUrl}/`, + }); + }); + + it('throws an error that includes the number of minutes until the circuit is re-closed if a request is attempted while the circuit is open', async () => { + const mockFetch = jest.fn(() => { + throw producedError; + }); + const endpointUrl = 'https://rpc.example.chain'; + const logger = { warn: jest.fn() }; + const service = new RpcService({ + fetch: mockFetch, + btoa, + endpointUrl, + logger, + isOffline: (): boolean => false, + }); + service.onRetry(() => { + jest.advanceTimersToNextTimer(); + }); + + const jsonRpcRequest = { + id: 1, + jsonrpc: '2.0' as const, + method: 'eth_chainId', + params: [], + }; + // Get through the first two rounds of retries + await ignoreRejection(service.request(jsonRpcRequest)); + await ignoreRejection(service.request(jsonRpcRequest)); + // The last retry breaks the circuit + await ignoreRejection(service.request(jsonRpcRequest)); + + // Advance a minute to test that the message updates dynamically as time passes + jest.advanceTimersByTime(60000); + await expect(service.request(jsonRpcRequest)).rejects.toThrow( + expect.objectContaining({ + code: errorCodes.rpc.resourceUnavailable, + message: + 'RPC endpoint returned too many errors, retrying in 29 minutes. Consider using a different RPC endpoint.', + }), + ); + }); + + it('logs the original CircuitBreakError if a request is attempted while the circuit is open', async () => { + const mockFetch = jest.fn(() => { + throw producedError; + }); + const endpointUrl = 'https://rpc.example.chain'; + const logger = { warn: jest.fn() }; + const service = new RpcService({ + fetch: mockFetch, + btoa, + endpointUrl, + logger, + isOffline: (): boolean => false, + }); + service.onRetry(() => { + jest.advanceTimersToNextTimer(); + }); + + const jsonRpcRequest = { + id: 1, + jsonrpc: '2.0' as const, + method: 'eth_chainId', + params: [], + }; + await ignoreRejection(service.request(jsonRpcRequest)); + await ignoreRejection(service.request(jsonRpcRequest)); + await ignoreRejection(service.request(jsonRpcRequest)); + await ignoreRejection(service.request(jsonRpcRequest)); + + expect(logger.warn).toHaveBeenCalledWith( + expect.objectContaining({ + message: 'Execution prevented because the circuit breaker is open', + }), + ); + }); + + it('calls the onAvailable callback if the endpoint becomes degraded via errors and then recovers', async () => { + let invocationIndex = -1; + const mockFetch = jest.fn(async () => { + invocationIndex += 1; + if (invocationIndex === DEFAULT_MAX_RETRIES + 1) { + // Only used for testing. + // eslint-disable-next-line no-restricted-globals + return new Response( + JSON.stringify({ + id: 1, + jsonrpc: '2.0', + result: { some: 'data' }, + }), + ); + } + throw producedError; + }); + const endpointUrl = 'https://rpc.example.chain'; + const onAvailableListener = jest.fn(); + const service = new RpcService({ + fetch: mockFetch, + btoa, + endpointUrl, + isOffline: (): boolean => false, + }); + service.onAvailable(onAvailableListener); + service.onRetry(() => { + jest.advanceTimersToNextTimer(); + }); + + const jsonRpcRequest = { + id: 1, + jsonrpc: '2.0' as const, + method: 'eth_chainId', + params: [], + }; + // Cause the retry policy to give up + await ignoreRejection(service.request(jsonRpcRequest)); + await service.request(jsonRpcRequest); + + expect(onAvailableListener).toHaveBeenCalledTimes(1); + }); + + /* eslint-enable jest/require-top-level-describe */ +} + +/** + * These are tests that exercise logic for cases in which the request returns a + * response that is retriable. + * + * @param args - The arguments + * @param args.httpStatus - The HTTP status code that the response will have. + * @param args.responseBody - The body that the response will have. + * @param args.expectedError - The error that a call to the service's `request` + * method is expected to produce. + * @param args.expectedOnBreakError - The error expected by the `onBreak` handler when there is a + * circuit break. Defaults to `expectedError` if not provided. + */ +function testsForRetriableResponses({ + httpStatus, + responseBody = '', + expectedError, + expectedOnBreakError = expectedError, +}: { + httpStatus: number; + responseBody?: string; + expectedError: string | jest.Constructable | RegExp | Error; + expectedOnBreakError?: string | jest.Constructable | RegExp | Error; +}): void { + // This function is designed to be used inside of a describe, so this won't be + // a problem in practice. + /* eslint-disable jest/require-top-level-describe,jest/no-identical-title */ + + it('retries a constantly failing request up to 4 more times before re-throwing the error, if `request` is only called once', async () => { + const scope = nock('https://rpc.example.chain') + .post('/', { + id: 1, + jsonrpc: '2.0', + method: 'eth_chainId', + params: [], + }) + .times(5) + .reply(httpStatus, responseBody); + const service = new RpcService({ + fetch, + btoa, + endpointUrl: 'https://rpc.example.chain', + isOffline: (): boolean => false, + }); + service.onRetry(() => { + jest.advanceTimersToNextTimer(); + }); + + const jsonRpcRequest = { + id: 1, + jsonrpc: '2.0' as const, + method: 'eth_chainId', + params: [], + }; + await expect(service.request(jsonRpcRequest)).rejects.toThrow( + expectedError, + ); + expect(scope.isDone()).toBe(true); + }); + + it('calls the onDegraded callback once for each retry round', async () => { + nock('https://rpc.example.chain') + .post('/', { + id: 1, + jsonrpc: '2.0', + method: 'eth_chainId', + params: [], + }) + .times(10) + .reply(httpStatus, responseBody); + const endpointUrl = 'https://rpc.example.chain'; + const onDegradedListener = jest.fn(); + const service = new RpcService({ + fetch, + btoa, + endpointUrl, + isOffline: (): boolean => false, + }); + service.onRetry(() => { + jest.advanceTimersToNextTimer(); + }); + + service.onDegraded(onDegradedListener); + + const jsonRpcRequest = { + id: 1, + jsonrpc: '2.0' as const, + method: 'eth_chainId', + params: [], + }; + await ignoreRejection(service.request(jsonRpcRequest)); + await ignoreRejection(service.request(jsonRpcRequest)); + + expect(onDegradedListener).toHaveBeenCalledTimes(2); + expect(onDegradedListener).toHaveBeenCalledWith({ + endpointUrl: `${endpointUrl}/`, + error: expectedOnBreakError, + rpcMethodName: 'eth_chainId', + duration: undefined, + traceId: undefined, + }); + }); + + it('calls the onDegraded callback with a trace ID if one is available', async () => { + nock('https://rpc.example.chain') + .post('/', { + id: 1, + jsonrpc: '2.0', + method: 'eth_chainId', + params: [], + }) + .times(10) + .reply(httpStatus, responseBody, { 'X-Trace-Id': 'abc-123-trace' }); + const endpointUrl = 'https://rpc.example.chain'; + const onDegradedListener = jest.fn(); + const service = new RpcService({ + fetch, + btoa, + endpointUrl, + isOffline: (): boolean => false, + }); + service.onRetry(() => { + jest.advanceTimersToNextTimer(); + }); + + service.onDegraded(onDegradedListener); + + const jsonRpcRequest = { + id: 1, + jsonrpc: '2.0' as const, + method: 'eth_chainId', + params: [], + }; + await ignoreRejection(service.request(jsonRpcRequest)); + await ignoreRejection(service.request(jsonRpcRequest)); + + expect(onDegradedListener).toHaveBeenCalledTimes(2); + expect(onDegradedListener).toHaveBeenCalledWith({ + endpointUrl: `${endpointUrl}/`, + error: expectedOnBreakError, + rpcMethodName: 'eth_chainId', + duration: undefined, + traceId: 'abc-123-trace', + }); + }); + + it('does not leak a stale trace ID when the last retry attempt throws before receiving a response', async () => { + const endpointUrl = 'https://rpc.example.chain'; + const scope = nock(endpointUrl); + for (let i = 0; i < DEFAULT_MAX_RETRIES; i++) { + scope + .post('/', { + id: 1, + jsonrpc: '2.0', + method: 'eth_chainId', + params: [], + }) + .reply(httpStatus, responseBody, { + 'X-Trace-Id': `trace-attempt-${i}`, + }); + } + scope + .post('/', { + id: 1, + jsonrpc: '2.0', + method: 'eth_chainId', + params: [], + }) + .replyWithError('Connection refused'); + const onDegradedListener = jest.fn(); + const service = new RpcService({ + fetch, + btoa, + endpointUrl, + isOffline: (): boolean => false, + }); + service.onRetry(() => { + jest.advanceTimersToNextTimer(); + }); + + service.onDegraded(onDegradedListener); + + const jsonRpcRequest = { + id: 1, + jsonrpc: '2.0' as const, + method: 'eth_chainId', + params: [], + }; + await ignoreRejection(service.request(jsonRpcRequest)); + + expect(onDegradedListener).toHaveBeenCalledTimes(1); + expect(onDegradedListener).toHaveBeenCalledWith({ + endpointUrl: `${endpointUrl}/`, + error: expect.objectContaining({ + message: expect.stringContaining('Connection refused'), + }), + rpcMethodName: 'eth_chainId', + duration: undefined, + traceId: undefined, + }); + }); + + it('still re-throws the error even after the circuit breaks', async () => { + nock('https://rpc.example.chain') + .post('/', { + id: 1, + jsonrpc: '2.0', + method: 'eth_chainId', + params: [], + }) + .times(15) + .reply(httpStatus, responseBody); + const service = new RpcService({ + fetch, + btoa, + endpointUrl: 'https://rpc.example.chain', + isOffline: (): boolean => false, + }); + service.onRetry(() => { + jest.advanceTimersToNextTimer(); + }); + + const jsonRpcRequest = { + id: 1, + jsonrpc: '2.0' as const, + method: 'eth_chainId', + params: [], + }; + await ignoreRejection(service.request(jsonRpcRequest)); + await ignoreRejection(service.request(jsonRpcRequest)); + // The last retry breaks the circuit + await expect(service.request(jsonRpcRequest)).rejects.toThrow( + expectedError, + ); + }); + + it('calls the onBreak callback once after the circuit breaks', async () => { + const endpointUrl = 'https://rpc.example.chain'; + nock(endpointUrl) + .post('/', { + id: 1, + jsonrpc: '2.0', + method: 'eth_chainId', + params: [], + }) + .times(15) + .reply(httpStatus, responseBody); + const onBreakListener = jest.fn(); + const service = new RpcService({ + fetch, + btoa, + endpointUrl, + isOffline: (): boolean => false, + }); + service.onRetry(() => { + jest.advanceTimersToNextTimer(); + }); + service.onBreak(onBreakListener); + + const jsonRpcRequest = { + id: 1, + jsonrpc: '2.0' as const, + method: 'eth_chainId', + params: [], + }; + await ignoreRejection(service.request(jsonRpcRequest)); + await ignoreRejection(service.request(jsonRpcRequest)); + // The last retry breaks the circuit + await ignoreRejection(service.request(jsonRpcRequest)); + + expect(onBreakListener).toHaveBeenCalledTimes(1); + expect(onBreakListener).toHaveBeenCalledWith({ + error: expectedOnBreakError, + endpointUrl: `${endpointUrl}/`, + }); + }); + + it('throws an error that includes the number of minutes until the circuit is re-closed if a request is attempted while the circuit is open', async () => { + const endpointUrl = 'https://rpc.example.chain'; + nock(endpointUrl) + .post('/', { + id: 1, + jsonrpc: '2.0', + method: 'eth_chainId', + params: [], + }) + .times(15) + .reply(httpStatus, responseBody); + const onBreakListener = jest.fn(); + const service = new RpcService({ + fetch, + btoa, + endpointUrl, + isOffline: (): boolean => false, + }); + service.onRetry(() => { + jest.advanceTimersToNextTimer(); + }); + service.onBreak(onBreakListener); + + const jsonRpcRequest = { + id: 1, + jsonrpc: '2.0' as const, + method: 'eth_chainId', + params: [], + }; + // Get through the first two rounds of retries + await ignoreRejection(service.request(jsonRpcRequest)); + await ignoreRejection(service.request(jsonRpcRequest)); + // The last retry breaks the circuit + await ignoreRejection(service.request(jsonRpcRequest)); + + // Advance a minute to test that the message updates dynamically as time passes + jest.advanceTimersByTime(60000); + await expect(service.request(jsonRpcRequest)).rejects.toThrow( + expect.objectContaining({ + code: errorCodes.rpc.resourceUnavailable, + message: + 'RPC endpoint returned too many errors, retrying in 29 minutes. Consider using a different RPC endpoint.', + }), + ); + }); + + it('logs the original CircuitBreakError if a request is attempted while the circuit is open', async () => { + const endpointUrl = 'https://rpc.example.chain'; + nock(endpointUrl) + .post('/', { + id: 1, + jsonrpc: '2.0', + method: 'eth_chainId', + params: [], + }) + .times(15) + .reply(httpStatus, responseBody); + const logger = { warn: jest.fn() }; + const onBreakListener = jest.fn(); + const service = new RpcService({ + fetch, + btoa, + endpointUrl, + logger, + isOffline: (): boolean => false, + }); + service.onRetry(() => { + jest.advanceTimersToNextTimer(); + }); + service.onBreak(onBreakListener); + + const jsonRpcRequest = { + id: 1, + jsonrpc: '2.0' as const, + method: 'eth_chainId', + params: [], + }; + await ignoreRejection(service.request(jsonRpcRequest)); + await ignoreRejection(service.request(jsonRpcRequest)); + await ignoreRejection(service.request(jsonRpcRequest)); + await ignoreRejection(service.request(jsonRpcRequest)); + + expect(logger.warn).toHaveBeenCalledWith( + expect.objectContaining({ + message: 'Execution prevented because the circuit breaker is open', + }), + ); + }); + + it('does not retry when offline, only makes one request', async () => { + const scope = nock('https://rpc.example.chain') + .post('/', { + id: 1, + jsonrpc: '2.0', + method: 'eth_chainId', + params: [], + }) + .times(1) + .reply(httpStatus, responseBody); + const service = new RpcService({ + fetch, + btoa, + endpointUrl: 'https://rpc.example.chain', + isOffline: (): boolean => true, + }); + service.onRetry(() => { + jest.advanceTimersToNextTimer(); + }); + + const jsonRpcRequest = { + id: 1, + jsonrpc: '2.0' as const, + method: 'eth_chainId', + params: [], + }; + await expect(service.request(jsonRpcRequest)).rejects.toThrow( + expectedError, + ); + // When offline, no retries should happen, so only 1 request + expect(scope.isDone()).toBe(true); + }); + + it('does not call onBreak when offline', async () => { + const scope = nock('https://rpc.example.chain') + .post('/', { + id: 1, + jsonrpc: '2.0', + method: 'eth_chainId', + params: [], + }) + .times(3) + .reply(httpStatus, responseBody); + const endpointUrl = 'https://rpc.example.chain'; + const onBreakListener = jest.fn(); + const service = new RpcService({ + fetch, + btoa, + endpointUrl, + isOffline: (): boolean => true, + }); + service.onRetry(() => { + jest.advanceTimersToNextTimer(); + }); + service.onBreak(onBreakListener); + + const jsonRpcRequest = { + id: 1, + jsonrpc: '2.0' as const, + method: 'eth_chainId', + params: [], + }; + // Make multiple requests - even though we'd normally break the circuit, + // when offline, no retries happen so circuit won't break + await expect(service.request(jsonRpcRequest)).rejects.toThrow( + expectedError, + ); + await expect(service.request(jsonRpcRequest)).rejects.toThrow( + expectedError, + ); + await expect(service.request(jsonRpcRequest)).rejects.toThrow( + expectedError, + ); + + // When offline, retries don't happen, so circuit won't break and onBreak + // should not be called + expect(onBreakListener).not.toHaveBeenCalled(); + expect(scope.isDone()).toBe(true); + }); + + /* eslint-enable jest/require-top-level-describe,jest/no-identical-title */ +} diff --git a/packages/network-controller/src/rpc-service/rpc-service.ts b/packages/network-controller/src/rpc-service/rpc-service.ts new file mode 100644 index 00000000000..1eef498ba30 --- /dev/null +++ b/packages/network-controller/src/rpc-service/rpc-service.ts @@ -0,0 +1,810 @@ +import type { + CreateServicePolicyOptions, + ServicePolicy, +} from '@metamask/controller-utils'; +import { + BrokenCircuitError, + HttpError, + createServicePolicy, + handleWhen, +} from '@metamask/controller-utils'; +import { JsonRpcError, rpcErrors } from '@metamask/rpc-errors'; +import { Duration, getErrorMessage, hasProperty } from '@metamask/utils'; +import type { + Json, + JsonRpcParams, + JsonRpcRequest, + JsonRpcResponse, +} from '@metamask/utils'; +import { CircuitState } from 'cockatiel'; +import deepmerge from 'deepmerge'; +import type { Logger } from 'loglevel'; + +import { projectLogger, createModuleLogger } from '../logger.js'; +import type { + CockatielEventToEventListenerWithData, + ExcludeCockatielEventData, + ExtendCockatielEventData, + ExtractCockatielEventData, + FetchOptions, +} from './shared.js'; + +/** + * Options for the RpcService constructor with some properties omitted and made optional as they have defaults. + */ +export type RpcServiceOptionsWithDefaults = Omit< + RpcServiceOptions, + 'failoverService' | 'endpointUrl' | 'isOffline' | 'btoa' | 'fetch' +> & + Partial>; + +/** + * Options for the RpcService constructor. + */ +export type RpcServiceOptions = { + /** + * A function that can be used to convert a binary string into a + * base64-encoded ASCII string. Used to encode authorization credentials. + */ + btoa: typeof btoa; + /** + * The URL of the RPC endpoint to hit. + */ + endpointUrl: URL | string; + /** + * A function that can be used to make an HTTP request. If your JavaScript + * environment supports `fetch` natively, you'll probably want to pass that; + * otherwise you can pass an equivalent (such as `fetch` via `node-fetch`). + */ + fetch: typeof fetch; + /** + * A common set of options that will be used to make every request. Can be + * overridden on the request level (e.g. to add headers). + */ + fetchOptions?: FetchOptions; + /** + * A `loglevel` logger. + */ + logger?: Pick; + /** + * Options to pass to `createServicePolicy`. Note that `retryFilterPolicy` and `isServiceFailure` + * are not accepted, as they are overwritten. See {@link createServicePolicy}. + */ + policyOptions?: Omit< + CreateServicePolicyOptions, + 'retryFilterPolicy' | 'isServiceFailure' + >; + /** + * A function that checks if the user is currently offline. If it returns true, + * connection errors will not be retried, preventing degraded and break + * callbacks from being triggered. + */ + isOffline: () => boolean; +}; + +const log = createModuleLogger(projectLogger, 'RpcService'); + +/** + * The maximum number of times that a failing service should be re-run before + * giving up. + * + * Note: This is not used in production and should be removed. + */ +export const DEFAULT_MAX_RETRIES = 4; + +/** + * The maximum number of times that the service is allowed to fail before + * pausing further retries. This is set to a value such that if given a + * service that continually fails, the policy needs to be executed 3 times + * before further retries are paused. + */ +export const DEFAULT_MAX_CONSECUTIVE_FAILURES = (1 + DEFAULT_MAX_RETRIES) * 3; + +/** + * The list of error messages that represent a failure to connect to the network. + * + * This list was derived from Sindre Sorhus's `is-network-error` package: + * + */ +export const CONNECTION_ERRORS = [ + // Chrome + { + constructorName: 'TypeError', + pattern: /network error/u, + }, + // Chrome + { + constructorName: 'TypeError', + pattern: /Failed to fetch/u, + }, + // Firefox + { + constructorName: 'TypeError', + pattern: /NetworkError when attempting to fetch resource\./u, + }, + // Safari 16 + { + constructorName: 'TypeError', + pattern: /The Internet connection appears to be offline\./u, + }, + // Safari 17+ + { + constructorName: 'TypeError', + pattern: /Load failed/u, + }, + // `cross-fetch` + { + constructorName: 'TypeError', + pattern: /Network request failed/u, + }, + // `node-fetch` + { + constructorName: 'FetchError', + pattern: /request to (.+) failed/u, + }, + // Undici (Node.js) + { + constructorName: 'TypeError', + pattern: /fetch failed/u, + }, + // Undici (Node.js) + { + constructorName: 'TypeError', + pattern: /terminated/u, + }, +]; + +/** + * Custom JSON-RPC error codes for specific cases. + * + * These should be moved to `@metamask/rpc-errors` eventually. + */ +export const CUSTOM_RPC_ERRORS = { + unauthorized: -32006, + httpClientError: -32080, +} as const; + +/** + * Determines whether the given error represents a failure to reach the network + * after request parameters have been validated. + * + * This is somewhat difficult to verify because JavaScript engines (and in + * some cases libraries) produce slightly different error messages for this + * particular scenario, and we need to account for this. + * + * @param error - The error. + * @returns True if the error indicates that the network cannot be connected to, + * and false otherwise. + */ +export function isConnectionError(error: unknown): boolean { + if (!(typeof error === 'object' && error !== null && 'message' in error)) { + return false; + } + + const { message } = error; + + return ( + typeof message === 'string' && + !isNockError(message) && + CONNECTION_ERRORS.some(({ constructorName, pattern }) => { + return ( + error.constructor.name === constructorName && pattern.test(message) + ); + }) + ); +} + +/** + * Determines whether the given error message refers to a Nock error. + * + * It's important that if we failed to mock a request in a test, the resulting + * error does not cause the request to be retried so that we can see it right + * away. + * + * @param message - The error message to test. + * @returns True if the message indicates a missing Nock mock, false otherwise. + */ +function isNockError(message: string): boolean { + return message.includes('Nock:'); +} + +/** + * Determine whether the given error message indicates a failure to parse JSON. + * + * This is different in tests vs. implementation code because it may manifest as + * a FetchError or a SyntaxError. + * + * @param error - The error object to test. + * @returns True if the error indicates a JSON parse error, false otherwise. + */ +export function isJsonParseError(error: unknown): boolean { + return ( + error instanceof SyntaxError || + /invalid json/iu.test(getErrorMessage(error)) + ); +} + +/** + * Determines whether the given error represents a HTTP server error + * (502, 503, or 504) that should be retried. + * + * @param error - The error object to test. + * @returns True if the error has an httpStatus of 502, 503, or 504. + */ +export function isHttpServerError(error: Error): boolean { + return ( + 'httpStatus' in error && + (error.httpStatus === 502 || + error.httpStatus === 503 || + error.httpStatus === 504) + ); +} + +/** + * Determines whether the given error has a `code` property of `ETIMEDOUT`. + * + * @param error - The error object to test. + * @returns True if the error code is `ETIMEDOUT`. + */ +export function isTimeoutError(error: Error): boolean { + return hasProperty(error, 'code') && error.code === 'ETIMEDOUT'; +} + +/** + * Determines whether the given error has a `code` property of `ECONNRESET`. + * + * @param error - The error object to test. + * @returns True if the error code is `ECONNRESET`. + */ +export function isConnectionResetError(error: Error): boolean { + return hasProperty(error, 'code') && error.code === 'ECONNRESET'; +} + +/** + * Guarantees a URL, even given a string. This is useful for checking components + * of that URL. + * + * @param endpointUrlOrUrlString - Either a URL object or a string that + * represents the URL of an endpoint. + * @returns A URL object. + */ +function getNormalizedEndpointUrl(endpointUrlOrUrlString: URL | string): URL { + return endpointUrlOrUrlString instanceof URL + ? endpointUrlOrUrlString + : new URL(endpointUrlOrUrlString); +} + +/** + * Strips username and password from a URL. + * + * @param url - The URL to strip credentials from. + * @returns A new URL object with credentials removed. + */ +function stripCredentialsFromUrl(url: URL): URL { + const strippedUrl = new URL(url.toString()); + strippedUrl.username = ''; + strippedUrl.password = ''; + return strippedUrl; +} + +const INFURA_NON_FAILURE_HTTP_STATUS_CODES = [400, 429]; + +/** + * Predicate function that determines if an error from Infura is treated as a service failure. + * + * @param error - The error. + * @returns True if the error should be treated as a service policy failure. Most errors are treated like failures, + * with the exception of certain HTTP status codes. + */ +function isServiceFailureInfura(error: unknown): boolean { + if ( + typeof error === 'object' && + error !== null && + hasProperty(error, 'httpStatus') && + typeof error.httpStatus === 'number' + ) { + return !INFURA_NON_FAILURE_HTTP_STATUS_CODES.includes(error.httpStatus); + } + + // If the error is not an object, or doesn't have a numeric httpStatus + // property, consider it a service failure (e.g., network errors, timeouts, + // etc.) + return true; +} + +/** + * This class is responsible for making a request to an endpoint that implements + * the JSON-RPC protocol. It is designed to gracefully handle network and server + * failures, retrying requests using exponential backoff. It also offers a hook + * which can used to respond to slow requests. + */ +export class RpcService { + /** + * The URL of the RPC endpoint. + */ + readonly endpointUrl: URL; + + /** + * The last error that the retry policy captured (or `undefined` if the last + * execution of the service was successful). + */ + lastError: Error | undefined; + + /** + * The RPC method name of the current request being processed. This is passed + * to `onDegraded` event listeners. + * + * Initialised to `''` so the type is `string` throughout the event chain. + * The empty string is unreachable in practice because the method name is + * guaranteed to be set after the current request is completed but before + * any `onDegraded` callbacks are called. + */ + #currentRpcMethodName = ''; + + /** + * The trace ID from the `X-Trace-Id` response header of the most recent + * request. Passed to `onDegraded` event listeners for debugging. + * + * `undefined` when no response has been received yet or when the response + * did not include the header. + */ + #currentTraceId: string | undefined; + + /** + * The function used to make an HTTP request. + */ + readonly #fetch: typeof fetch; + + /** + * A common set of options that the request options will extend. + */ + readonly #fetchOptions: FetchOptions; + + /** + * A `loglevel` logger. + */ + readonly #logger: RpcServiceOptions['logger']; + + /** + * The policy that wraps the request. + */ + readonly #policy: ServicePolicy; + + /** + * Constructs a new RpcService object. + * + * @param options - The options. See {@link RpcServiceOptions}. + */ + constructor(options: RpcServiceOptions) { + const { + btoa: givenBtoa, + endpointUrl, + fetch: givenFetch, + logger, + fetchOptions = {}, + policyOptions = {}, + isOffline, + } = options; + + this.#fetch = givenFetch; + const normalizedUrl = getNormalizedEndpointUrl(endpointUrl); + this.#fetchOptions = this.#getDefaultFetchOptions( + normalizedUrl, + fetchOptions, + givenBtoa, + ); + this.endpointUrl = stripCredentialsFromUrl(normalizedUrl); + this.#logger = logger; + + const isInfura = normalizedUrl.hostname.endsWith('.infura.io'); + + this.#policy = createServicePolicy({ + maxRetries: DEFAULT_MAX_RETRIES, + maxConsecutiveFailures: DEFAULT_MAX_CONSECUTIVE_FAILURES, + ...policyOptions, + isServiceFailure: isInfura ? isServiceFailureInfura : undefined, + retryFilterPolicy: handleWhen((error) => { + // If user is offline, don't retry any errors + // This prevents degraded/break callbacks from being triggered + if (isOffline()) { + return false; + } + + return ( + // Ignore errors where the request failed to establish + isConnectionError(error) || + // Ignore server sent HTML error pages or truncated JSON responses + isJsonParseError(error) || + // Ignore server overload errors + isHttpServerError(error) || + // Ignore timeout errors + isTimeoutError(error) || + // Ignore connection reset errors + isConnectionResetError(error) + ); + }), + }); + } + + /** + * Resets the underlying composite Cockatiel policy. + * + * This is useful in a collection of RpcServices where some act as failovers + * for others where you effectively want to invalidate the failovers when the + * primary recovers. + */ + resetPolicy(): void { + this.#policy.reset(); + } + + /** + * @returns The state of the underlying circuit. + */ + getCircuitState(): CircuitState { + return this.#policy.getCircuitState(); + } + + /** + * Listens for when the RPC service retries the request. + * + * @param listener - The callback to be called when the retry occurs. + * @returns What {@link ServicePolicy.onRetry} returns. + * @see {@link createServicePolicy} + */ + onRetry( + listener: CockatielEventToEventListenerWithData< + ServicePolicy['onRetry'], + { endpointUrl: string } + >, + ): ReturnType { + return this.#policy.onRetry((data) => { + listener({ ...data, endpointUrl: this.endpointUrl.toString() }); + }); + } + + /** + * Listens for when the RPC service retries the request too many times in a + * row, causing the underlying circuit to break. + * + * @param listener - The callback to be called when the circuit is broken. + * @returns What {@link ServicePolicy.onBreak} returns. + * @see {@link createServicePolicy} + */ + onBreak( + listener: ( + data: ExcludeCockatielEventData< + ExtendCockatielEventData< + ExtractCockatielEventData, + { endpointUrl: string } + >, + 'isolated' + >, + ) => void, + ): ReturnType { + return this.#policy.onBreak((data) => { + // `{ isolated: true }` is a special object that shows up when `isolate` + // is called on the circuit breaker. Usually `isolate` is used to hold the + // circuit open, but we (ab)use this method in `createServicePolicy` to + // reset the circuit breaker policy. When we do this, we don't want to + // call `onBreak` handlers, because then it causes + // `NetworkController:rpcEndpointUnavailable` and + // `NetworkController:rpcEndpointChainUnavailable` to be published. So we + // have to ignore that object here. The consequence is that `isolate` + // doesn't function the way it is intended, at least in the context of an + // RpcService. However, we are making a bet that we won't need to use it + // other than how we are already using it. + if (!('isolated' in data)) { + listener({ + ...data, + endpointUrl: this.endpointUrl.toString(), + }); + } + }); + } + + /** + * Listens for when the policy underlying this RPC service detects a slow + * request. + * + * @param listener - The callback to be called when the request is slow. + * @returns What {@link ServicePolicy.onDegraded} returns. + * @see {@link createServicePolicy} + */ + onDegraded( + listener: CockatielEventToEventListenerWithData< + ServicePolicy['onDegraded'], + { + duration?: number; + endpointUrl: string; + rpcMethodName: string; + traceId?: string; + } + >, + ): ReturnType { + return this.#policy.onDegraded((data) => { + listener({ + ...data, + endpointUrl: this.endpointUrl.toString(), + rpcMethodName: this.#currentRpcMethodName, + traceId: this.#currentTraceId, + }); + }); + } + + /** + * Listens for when the policy underlying this RPC service is available. + * + * @param listener - The callback to be called when the request is available. + * @returns What {@link ServicePolicy.onAvailable} returns. + * @see {@link createServicePolicy} + */ + onAvailable( + listener: CockatielEventToEventListenerWithData< + ServicePolicy['onAvailable'], + { endpointUrl: string } + >, + ): ReturnType { + return this.#policy.onAvailable(() => { + listener({ endpointUrl: this.endpointUrl.toString() }); + }); + } + + /** + * Makes a request to the RPC endpoint. + * + * This overload is specifically designed for `eth_getBlockByNumber`, which + * can return a `result` of `null` despite an expected `Result` being + * provided. + * + * @param jsonRpcRequest - The JSON-RPC request to send to the endpoint. + * @param fetchOptions - An options bag for {@link fetch} which further + * specifies the request. + * @returns The decoded JSON-RPC response from the endpoint. + * @throws An "authorized" JSON-RPC error (code -32006) if the response HTTP status is 401. + * @throws A "rate limiting" JSON-RPC error (code -32005) if the response HTTP status is 429. + * @throws A "resource unavailable" JSON-RPC error (code -32002) if the response HTTP status is 402, 404, or any 5xx. + * @throws A generic HTTP client JSON-RPC error (code -32050) for any other 4xx HTTP status codes. + * @throws A "parse" JSON-RPC error (code -32700) if the response is not valid JSON. + */ + async request( + jsonRpcRequest: JsonRpcRequest & { method: 'eth_getBlockByNumber' }, + fetchOptions?: FetchOptions, + ): Promise | JsonRpcResponse>; + + /** + * Makes a request to the RPC endpoint. + * + * This overload is designed for all RPC methods except for + * `eth_getBlockByNumber`, which are expected to return a `result` of the + * expected `Result`. + * + * @param jsonRpcRequest - The JSON-RPC request to send to the endpoint. + * @param fetchOptions - An options bag for {@link fetch} which further + * specifies the request. + * @returns The decoded JSON-RPC response from the endpoint. + * @throws An "authorized" JSON-RPC error (code -32006) if the response HTTP status is 401. + * @throws A "rate limiting" JSON-RPC error (code -32005) if the response HTTP status is 429. + * @throws A "resource unavailable" JSON-RPC error (code -32002) if the response HTTP status is 402, 404, or any 5xx. + * @throws A generic HTTP client JSON-RPC error (code -32050) for any other 4xx HTTP status codes. + * @throws A "parse" JSON-RPC error (code -32700) if the response is not valid JSON. + */ + async request( + jsonRpcRequest: JsonRpcRequest, + fetchOptions?: FetchOptions, + ): Promise>; + + async request( + // The request object may be frozen and must not be mutated. + jsonRpcRequest: Readonly>, + fetchOptions: FetchOptions = {}, + ): Promise> { + const completeFetchOptions = this.#getCompleteFetchOptions( + jsonRpcRequest, + fetchOptions, + ); + return await this.#executeAndProcessRequest( + completeFetchOptions, + jsonRpcRequest.method, + ); + } + + /** + * Constructs a default set of options to `fetch`. + * + * If a username and password are present in the URL, they are extracted to an + * Authorization header. + * + * @param endpointUrl - The endpoint URL. + * @param fetchOptions - The options to `fetch`. + * @param givenBtoa - An implementation of `btoa`. + * @returns The default fetch options. + */ + #getDefaultFetchOptions( + endpointUrl: URL, + fetchOptions: FetchOptions, + givenBtoa: (stringToEncode: string) => string, + ): FetchOptions { + if (endpointUrl.username && endpointUrl.password) { + const authString = `${endpointUrl.username}:${endpointUrl.password}`; + const encodedCredentials = givenBtoa(authString); + return deepmerge(fetchOptions, { + headers: { Authorization: `Basic ${encodedCredentials}` }, + }); + } + + return fetchOptions; + } + + /** + * Constructs a final set of options to pass to `fetch`. Note that the method + * defaults to `post`, and the JSON-RPC request is automatically JSON-encoded. + * + * @param jsonRpcRequest - The JSON-RPC request. + * @param fetchOptions - Custom `fetch` options. + * @returns The complete set of `fetch` options. + */ + #getCompleteFetchOptions( + jsonRpcRequest: Readonly>, + fetchOptions: FetchOptions, + ): FetchOptions { + const defaultOptions = { + method: 'POST', + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + }, + }; + const mergedOptions = deepmerge( + defaultOptions, + deepmerge(this.#fetchOptions, fetchOptions), + ); + + const { id, jsonrpc, method, params } = jsonRpcRequest; + const body = JSON.stringify({ + id, + jsonrpc, + method, + params, + }); + + return { ...mergedOptions, body }; + } + + /** + * Makes the request using the Cockatiel policy that this service creates. + * + * @param fetchOptions - The options for `fetch`; will be combined with the + * fetch options passed to the constructor + * @param rpcMethodName - The JSON-RPC method name of the current request. + * @returns The decoded JSON-RPC response from the endpoint. + * @throws An "authorized" JSON-RPC error (code -32006) if the response HTTP status is 401. + * @throws A "rate limiting" JSON-RPC error (code -32005) if the response HTTP status is 429. + * @throws A "resource unavailable" JSON-RPC error (code -32002) if the response HTTP status is 402, 404, or any 5xx. + * @throws A generic HTTP client JSON-RPC error (code -32050) for any other 4xx HTTP status codes. + * @throws A "parse" JSON-RPC error (code -32700) if the response is not valid JSON. + */ + async #executeAndProcessRequest( + fetchOptions: FetchOptions, + rpcMethodName: string, + ): Promise | JsonRpcResponse> { + let response: Response | undefined; + try { + log( + `[${this.endpointUrl}] Circuit state`, + this.#policy.getCircuitState(), + ); + const jsonDecodedResponse = await this.#policy.execute( + async (context) => { + // Reset response so that if this attempt throws before + // assigning a new response, the finally block does not read + // a stale response from a previous attempt. + response = undefined; + try { + log( + 'REQUEST INITIATED:', + this.endpointUrl.toString(), + '::', + fetchOptions, + // @ts-expect-error This property _is_ here, the type of + // ServicePolicy is just wrong. + `(attempt ${context.attempt + 1})`, + ); + response = await this.#fetch(this.endpointUrl, fetchOptions); + if (!response.ok) { + throw new HttpError(response.status); + } + log( + 'REQUEST SUCCESSFUL:', + this.endpointUrl.toString(), + response.status, + ); + return await response.json(); + } finally { + // Track the RPC method and trace ID for the request that has just + // taken place. We pass these properties to `onDegraded` event + // listeners. + // + // We set these properties after the request completes and not + // before the request starts to account for race conditions. That + // is, if there are two requests that are being performed + // concurrently, and the second request fails fast but the first + // request succeeds slowly, when `onDegraded` is called we want it + // to include the first request as the RPC method, not the second. + // + // Also, we set these properties within a `finally` block inside of + // the function passed to `policy.execute` to ensure that they are + // set before `onDegraded` gets called, no matter the outcome of + // the request. + this.#currentRpcMethodName = rpcMethodName; + this.#currentTraceId = + response?.headers.get('X-Trace-Id') ?? undefined; + } + }, + ); + this.lastError = undefined; + return jsonDecodedResponse; + } catch (error) { + log('REQUEST ERROR:', this.endpointUrl.toString(), error); + + this.lastError = + error instanceof Error ? error : new Error(getErrorMessage(error)); + + if (error instanceof HttpError) { + const status = error.httpStatus; + if (status === 401) { + throw new JsonRpcError( + CUSTOM_RPC_ERRORS.unauthorized, + 'Unauthorized.', + { + httpStatus: status, + }, + ); + } + if (status === 429) { + throw rpcErrors.limitExceeded({ + message: 'Request is being rate limited.', + data: { + httpStatus: status, + }, + }); + } + if (status >= 500 || status === 402 || status === 404) { + throw rpcErrors.resourceUnavailable({ + message: 'RPC endpoint not found or unavailable.', + data: { + httpStatus: status, + }, + }); + } + + // Handle all other 4xx errors as generic HTTP client errors + throw new JsonRpcError( + CUSTOM_RPC_ERRORS.httpClientError, + 'RPC endpoint returned HTTP client error.', + { + httpStatus: status, + }, + ); + } else if (isJsonParseError(error)) { + throw rpcErrors.parse({ + message: 'RPC endpoint did not return JSON.', + }); + } else if (error instanceof BrokenCircuitError) { + this.#logger?.warn(error); + const remainingCircuitOpenDuration = + this.#policy.getRemainingCircuitOpenDuration(); + const formattedRemainingCircuitOpenDuration = Intl.NumberFormat( + undefined, + { maximumFractionDigits: 2 }, + ).format( + (remainingCircuitOpenDuration ?? this.#policy.circuitBreakDuration) / + Duration.Minute, + ); + throw rpcErrors.resourceUnavailable({ + message: `RPC endpoint returned too many errors, retrying in ${formattedRemainingCircuitOpenDuration} minutes. Consider using a different RPC endpoint.`, + }); + } + throw error; + } + } +} diff --git a/packages/network-controller/src/rpc-service/shared.ts b/packages/network-controller/src/rpc-service/shared.ts new file mode 100644 index 00000000000..c66cb1082c8 --- /dev/null +++ b/packages/network-controller/src/rpc-service/shared.ts @@ -0,0 +1,58 @@ +import type { + CockatielEvent, + CockatielEventEmitter, +} from '@metamask/controller-utils'; + +/** + * Equivalent to the built-in `FetchOptions` type, but renamed for clarity. + */ +export type FetchOptions = RequestInit; + +/** + * Converts a Cockatiel event type to an event emitter type. + */ +export type CockatielEventToEventEmitter = + Event extends CockatielEvent + ? CockatielEventEmitter + : never; + +/** + * Obtains the event data type from a Cockatiel event or event listener type. + */ +export type ExtractCockatielEventData = + CockatielEventOrEventListener extends CockatielEvent + ? Data + : CockatielEventOrEventListener extends (data: infer Data) => void + ? Data + : never; + +/** + * Extends the data that a Cockatiel event listener is called with additional + * data. + */ +export type ExtendCockatielEventData = + OriginalData extends void ? AdditionalData : OriginalData & AdditionalData; + +/** + * Removes keys from the data that a Cockatiel event listner is called with. + */ +export type ExcludeCockatielEventData< + OriginalData, + Keys extends PropertyKey, +> = OriginalData extends void ? void : Omit; + +/** + * Converts a Cockatiel event type to an event listener type, but adding the + * requested data. + */ +export type CockatielEventToEventListenerWithData = ( + data: ExtendCockatielEventData, Data>, +) => void; + +/** + * Converts a Cockatiel event listener type to an event emitter type. + */ +export type CockatielEventToEventEmitterWithData = + CockatielEventEmitter< + ExtendCockatielEventData, Data> + >; diff --git a/packages/network-controller/src/selectors.test.ts b/packages/network-controller/src/selectors.test.ts new file mode 100644 index 00000000000..0fed844f57e --- /dev/null +++ b/packages/network-controller/src/selectors.test.ts @@ -0,0 +1,42 @@ +import { getRpcFailoverMode } from './selectors.js'; + +/** + * Builds a remote feature flag controller state with the given failover mode. + * + * @param mode - The value to set for `corePlatformRpcFailoverMode`, if any. + * @returns The state object. + */ +function buildState(mode?: unknown): { + remoteFeatureFlags: Record; + cacheTimestamp: number; +} { + return { + remoteFeatureFlags: + mode === undefined ? {} : { corePlatformRpcFailoverMode: mode }, + cacheTimestamp: 0, + }; +} + +describe('getRpcFailoverMode', () => { + it('returns "enabled" when the flag is "enabled"', () => { + expect(getRpcFailoverMode(buildState('enabled') as never)).toBe('enabled'); + }); + + it('returns "forced" when the flag is "forced"', () => { + expect(getRpcFailoverMode(buildState('forced') as never)).toBe('forced'); + }); + + it('returns "disabled" when the flag is "disabled"', () => { + expect(getRpcFailoverMode(buildState('disabled') as never)).toBe( + 'disabled', + ); + }); + + it('returns "disabled" when the flag is absent', () => { + expect(getRpcFailoverMode(buildState() as never)).toBe('disabled'); + }); + + it('returns "disabled" when the flag is an unrecognized value', () => { + expect(getRpcFailoverMode(buildState('yes') as never)).toBe('disabled'); + }); +}); diff --git a/packages/network-controller/src/selectors.ts b/packages/network-controller/src/selectors.ts new file mode 100644 index 00000000000..60406079d8a --- /dev/null +++ b/packages/network-controller/src/selectors.ts @@ -0,0 +1,28 @@ +import { RemoteFeatureFlagControllerState } from '@metamask/remote-feature-flag-controller'; + +/** + * The RPC failover behavior for Infura networks, controlled by the + * `corePlatformRpcFailoverMode` remote feature flag. + * + * - `disabled`: failover URLs are ignored; traffic stays on the primary + * endpoint. + * - `enabled`: traffic automatically diverts to failover URLs when the primary + * endpoint is unavailable. + * - `forced`: Infura endpoints that have failover URLs route all traffic to + * those failover URLs, bypassing Infura entirely. + */ +export type RpcFailoverMode = 'disabled' | 'enabled' | 'forced'; + +/** + * Reads the RPC failover mode from the remote feature flags, defaulting to + * `disabled` when the flag is absent or not a recognized value. + * + * @param state - The remote feature flag controller state. + * @returns The RPC failover mode. + */ +export function getRpcFailoverMode( + state: RemoteFeatureFlagControllerState, +): RpcFailoverMode { + const mode = state.remoteFeatureFlags.corePlatformRpcFailoverMode; + return mode === 'enabled' || mode === 'forced' ? mode : 'disabled'; +} diff --git a/packages/network-controller/src/types.ts b/packages/network-controller/src/types.ts index 8d84503b4c8..92ed2126acd 100644 --- a/packages/network-controller/src/types.ts +++ b/packages/network-controller/src/types.ts @@ -1,9 +1,13 @@ -import type { InfuraNetworkType } from '@metamask/controller-utils'; -import type { SafeEventEmitterProvider } from '@metamask/eth-json-rpc-provider'; +import type { BlockTracker as BaseBlockTracker } from '@metamask/eth-block-tracker'; +import type { InternalProvider } from '@metamask/eth-json-rpc-provider'; +import type { MiddlewareContext } from '@metamask/json-rpc-engine/v2'; import type { Hex } from '@metamask/utils'; -import type { BlockTracker as BaseBlockTracker } from 'eth-block-tracker'; -export type Provider = SafeEventEmitterProvider; +export type Provider = InternalProvider< + MiddlewareContext< + { origin: string; skipCache: boolean } & Record + > +>; export type BlockTracker = BaseBlockTracker & { checkForLatestBlock(): Promise; @@ -18,27 +22,34 @@ export enum NetworkClientType { } /** - * A configuration object that can be used to create a client for a custom - * network. + * A configuration object that can be used to create a client for a network. */ -export type CustomNetworkClientConfiguration = { +type CommonNetworkClientConfiguration = { chainId: Hex; - rpcUrl: string; + failoverRpcUrls?: string[]; ticker: string; - type: NetworkClientType.Custom; }; +/** + * A configuration object that can be used to create a client for a custom + * network. + */ +export type CustomNetworkClientConfiguration = + CommonNetworkClientConfiguration & { + rpcUrl: string; + type: NetworkClientType.Custom; + }; + /** * A configuration object that can be used to create a client for an Infura * network. */ -export type InfuraNetworkClientConfiguration = { - chainId: Hex; - network: InfuraNetworkType; - infuraProjectId: string; - ticker: string; - type: NetworkClientType.Infura; -}; +export type InfuraNetworkClientConfiguration = + CommonNetworkClientConfiguration & { + network: string; + infuraProjectId: string; + type: NetworkClientType.Infura; + }; /** * A configuration object that can be used to create a client for a network. diff --git a/packages/network-controller/tests/NetworkController.analytics.test.ts b/packages/network-controller/tests/NetworkController.analytics.test.ts new file mode 100644 index 00000000000..017d0a861e6 --- /dev/null +++ b/packages/network-controller/tests/NetworkController.analytics.test.ts @@ -0,0 +1,234 @@ +import type { Hex } from '@metamask/utils'; + +import { NetworkController } from '../src/index.js'; +import type { NetworkControllerAnalyticsOptions } from '../src/rpc-service-analytics.js'; +import { + buildNetworkControllerMessenger, + buildRootMessenger, +} from './helpers.js'; +import type { RootMessenger } from './helpers.js'; + +const PUBLIC_ENDPOINT_URL = 'https://mainnet.infura.io/v3/the-key'; + +const DEFAULT_ANALYTICS_OPTIONS: NetworkControllerAnalyticsOptions = { + isRpcEndpointUrlPublic: () => true, + rpcServiceEventsSampleRate: 1, +}; + +const UNAVAILABLE_PAYLOAD = { + chainId: '0x1' as Hex, + endpointUrl: PUBLIC_ENDPOINT_URL, + error: undefined, + networkClientId: 'mainnet', + primaryEndpointUrl: PUBLIC_ENDPOINT_URL, +}; + +const DEGRADED_PAYLOAD = { + chainId: '0x1' as Hex, + duration: 1234, + endpointUrl: PUBLIC_ENDPOINT_URL, + error: { httpStatus: 503 }, + networkClientId: 'mainnet', + primaryEndpointUrl: PUBLIC_ENDPOINT_URL, + retryReason: 'connection_failed' as const, + rpcMethodName: 'eth_blockNumber', + traceId: 'trace-1', + type: 'retries_exhausted' as const, +}; + +/** + * Builds a NetworkController wired to a messenger, without initializing it (the + * analytics subscriptions are registered in the constructor). + * + * @param args - The arguments. + * @param args.analyticsOptions - The analytics options to pass. + * @param args.analyticsId - The analytics ID that `AnalyticsController:getState` + * returns. + * @returns The controller, messengers, and the `AnalyticsController:trackEvent` + * mock. + */ +function buildController({ + analyticsOptions = DEFAULT_ANALYTICS_OPTIONS, + analyticsId, +}: { + analyticsOptions?: NetworkControllerAnalyticsOptions; + analyticsId?: string; +} = {}): { + controller: NetworkController; + rootMessenger: RootMessenger; + networkControllerMessenger: ReturnType< + typeof buildNetworkControllerMessenger + >; + trackEvent: jest.Mock; +} { + const trackEvent = jest.fn(); + const rootMessenger = buildRootMessenger({ + trackEvent, + ...(analyticsId === undefined ? {} : { analyticsId }), + }); + const networkControllerMessenger = + buildNetworkControllerMessenger(rootMessenger); + const controller = new NetworkController({ + messenger: networkControllerMessenger, + infuraProjectId: 'infura-project-id', + analyticsOptions, + }); + return { controller, rootMessenger, networkControllerMessenger, trackEvent }; +} + +describe('NetworkController analytics', () => { + it('emits "RPC Service Unavailable" when an endpoint becomes unavailable', () => { + const { networkControllerMessenger, trackEvent } = buildController({ + analyticsOptions: DEFAULT_ANALYTICS_OPTIONS, + }); + + networkControllerMessenger.publish( + 'NetworkController:rpcEndpointUnavailable', + UNAVAILABLE_PAYLOAD, + ); + + expect(trackEvent).toHaveBeenCalledWith({ + name: 'RPC Service Unavailable', + properties: { + chain_id_caip: 'eip155:1', + rpc_domain: 'mainnet.infura.io', + rpc_endpoint_url: 'mainnet.infura.io', + }, + sensitiveProperties: {}, + saveDataRecording: false, + hasProperties: true, + }); + }); + + it('emits "RPC Service Degraded" with the degraded-specific properties', () => { + const { networkControllerMessenger, trackEvent } = buildController({ + analyticsOptions: DEFAULT_ANALYTICS_OPTIONS, + }); + + networkControllerMessenger.publish( + 'NetworkController:rpcEndpointDegraded', + DEGRADED_PAYLOAD, + ); + + expect(trackEvent).toHaveBeenCalledWith({ + name: 'RPC Service Degraded', + properties: { + chain_id_caip: 'eip155:1', + rpc_domain: 'mainnet.infura.io', + rpc_endpoint_url: 'mainnet.infura.io', + rpc_method_name: 'eth_blockNumber', + type: 'retries_exhausted', + retry_reason: 'connection_failed', + duration_ms: 1234, + trace_id: 'trace-1', + http_status: 503, + }, + sensitiveProperties: {}, + saveDataRecording: false, + hasProperties: true, + }); + }); + + it('does not emit when the error is a local connection error', () => { + const { networkControllerMessenger, trackEvent } = buildController({ + analyticsOptions: DEFAULT_ANALYTICS_OPTIONS, + }); + + networkControllerMessenger.publish( + 'NetworkController:rpcEndpointUnavailable', + { ...UNAVAILABLE_PAYLOAD, error: new TypeError('network error') }, + ); + + expect(trackEvent).not.toHaveBeenCalled(); + }); + + it('does not emit when there is no analytics ID', () => { + const { networkControllerMessenger, trackEvent } = buildController({ + analyticsOptions: DEFAULT_ANALYTICS_OPTIONS, + analyticsId: '', + }); + + networkControllerMessenger.publish( + 'NetworkController:rpcEndpointUnavailable', + UNAVAILABLE_PAYLOAD, + ); + + expect(trackEvent).not.toHaveBeenCalled(); + }); + + it('defaults isRpcEndpointUrlPublic to reporting the endpoint as "custom"', () => { + const { networkControllerMessenger, trackEvent } = buildController({ + analyticsOptions: { rpcServiceEventsSampleRate: 1 }, + }); + + networkControllerMessenger.publish( + 'NetworkController:rpcEndpointUnavailable', + UNAVAILABLE_PAYLOAD, + ); + + expect(trackEvent).toHaveBeenCalledWith( + expect.objectContaining({ + properties: expect.objectContaining({ + rpc_domain: 'custom', + rpc_endpoint_url: 'custom', + }), + }), + ); + }); + + it('defaults the sample rate to 0, emitting nothing', () => { + const { networkControllerMessenger, trackEvent } = buildController({ + analyticsOptions: { isRpcEndpointUrlPublic: () => true }, + }); + + networkControllerMessenger.publish( + 'NetworkController:rpcEndpointUnavailable', + UNAVAILABLE_PAYLOAD, + ); + + expect(trackEvent).not.toHaveBeenCalled(); + }); + + it('does not emit when the event falls outside the sample', () => { + const { networkControllerMessenger, trackEvent } = buildController({ + analyticsOptions: { + isRpcEndpointUrlPublic: () => true, + rpcServiceEventsSampleRate: 0, + }, + }); + + networkControllerMessenger.publish( + 'NetworkController:rpcEndpointUnavailable', + UNAVAILABLE_PAYLOAD, + ); + + expect(trackEvent).not.toHaveBeenCalled(); + }); + + it('captures the exception when delivering the event throws', () => { + const trackError = new Error('analytics blew up'); + const { rootMessenger, networkControllerMessenger } = buildController({ + analyticsOptions: { + isRpcEndpointUrlPublic: () => { + throw trackError; + }, + rpcServiceEventsSampleRate: 1, + }, + }); + const captureExceptionSpy = jest.spyOn(rootMessenger, 'captureException'); + + expect(() => { + networkControllerMessenger.publish( + 'NetworkController:rpcEndpointUnavailable', + UNAVAILABLE_PAYLOAD, + ); + }).not.toThrow(); + + expect(captureExceptionSpy).toHaveBeenCalledWith( + expect.objectContaining({ + message: 'Could not create analytics event', + cause: trackError, + }), + ); + }); +}); diff --git a/packages/network-controller/tests/NetworkController.provider.test.ts b/packages/network-controller/tests/NetworkController.provider.test.ts new file mode 100644 index 00000000000..8456454907c --- /dev/null +++ b/packages/network-controller/tests/NetworkController.provider.test.ts @@ -0,0 +1,986 @@ +import { + DEFAULT_DEGRADED_THRESHOLD, + InfuraNetworkType, +} from '@metamask/controller-utils'; +import { Duration, inMilliseconds } from '@metamask/utils'; +import nock from 'nock'; + +import { NetworkStatus } from '../src/constants.js'; +import { + buildCustomNetworkConfiguration, + buildCustomRpcEndpoint, + buildInfuraNetworkConfiguration, + buildInfuraRpcEndpoint, + withController, +} from './helpers.js'; + +describe('NetworkController provider tests', () => { + beforeEach(() => { + jest.useFakeTimers({ doNotFake: ['nextTick', 'queueMicrotask'] }); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + it('sets the status of a network client to "available" the first time its (sole) RPC endpoint returns a 2xx response', async () => { + const endpointUrl = 'https://some.endpoint'; + const networkClientId = 'AAAA-AAAA-AAAA-AAAA'; + const rpcMethod = 'eth_gasPrice'; + + nock(endpointUrl) + .post('/', { + id: /^\d+$/u, + jsonrpc: '2.0', + method: 'eth_blockNumber', + params: [], + }) + .reply(200, { + id: 1, + jsonrpc: '2.0', + result: '0x1', + }) + .post('/', { + id: 1, + jsonrpc: '2.0', + method: rpcMethod, + params: [], + }) + .reply(200, { + id: 1, + jsonrpc: '2.0', + result: 'ok', + }); + + await withController( + { + rpcFailoverMode: 'enabled', + state: { + networkConfigurationsByChainId: { + '0x1337': buildCustomNetworkConfiguration({ + chainId: '0x1337', + name: 'Test Network', + rpcEndpoints: [ + buildCustomRpcEndpoint({ + networkClientId, + url: endpointUrl, + }), + ], + }), + }, + networksMetadata: { + [networkClientId]: { + EIPS: {}, + status: NetworkStatus.Unknown, + }, + }, + selectedNetworkClientId: networkClientId, + }, + }, + async ({ controller }) => { + const { provider } = controller.getNetworkClientById(networkClientId); + + await provider.request({ + id: 1, + jsonrpc: '2.0', + method: rpcMethod, + params: [], + }); + + expect(controller.state.networksMetadata[networkClientId].status).toBe( + NetworkStatus.Available, + ); + }, + ); + }); + + it('sets the status of a network client to "degraded" when its (sole) RPC endpoint responds with 2xx but slowly', async () => { + const endpointUrl = 'https://some.endpoint'; + const networkClientId = 'AAAA-AAAA-AAAA-AAAA'; + const rpcMethod = 'eth_gasPrice'; + + nock(endpointUrl) + .post('/', { + id: /^\d+$/u, + jsonrpc: '2.0', + method: 'eth_blockNumber', + params: [], + }) + .reply(() => { + jest.advanceTimersByTime(DEFAULT_DEGRADED_THRESHOLD + 1); + return [ + 200, + { + id: 1, + jsonrpc: '2.0', + result: 'ok', + }, + ]; + }) + .post('/', { + id: 1, + jsonrpc: '2.0', + method: rpcMethod, + params: [], + }) + .reply(() => { + jest.advanceTimersByTime(DEFAULT_DEGRADED_THRESHOLD + 1); + return [ + 200, + { + id: 1, + jsonrpc: '2.0', + result: 'ok', + }, + ]; + }); + + await withController( + { + rpcFailoverMode: 'enabled', + state: { + networkConfigurationsByChainId: { + '0x1337': buildCustomNetworkConfiguration({ + chainId: '0x1337', + name: 'Test Network', + rpcEndpoints: [ + buildCustomRpcEndpoint({ + networkClientId, + url: endpointUrl, + }), + ], + }), + }, + networksMetadata: { + [networkClientId]: { + EIPS: {}, + status: NetworkStatus.Unknown, + }, + }, + selectedNetworkClientId: networkClientId, + }, + }, + async ({ controller }) => { + const { provider } = controller.getNetworkClientById(networkClientId); + + await provider.request({ + id: 1, + jsonrpc: '2.0', + method: rpcMethod, + params: [], + }); + + expect(controller.state.networksMetadata[networkClientId].status).toBe( + NetworkStatus.Degraded, + ); + }, + ); + }); + + it('sets the status of a network client to "degraded" when failed requests to its (sole) RPC endpoint reach the max number of retries', async () => { + const endpointUrl = 'https://some.endpoint'; + const networkClientId = 'AAAA-AAAA-AAAA-AAAA'; + const rpcMethod = 'eth_gasPrice'; + + nock(endpointUrl) + .post('/', { + id: /^\d+$/u, + jsonrpc: '2.0', + method: 'eth_blockNumber', + params: [], + }) + .times(5) + .reply(503); + + await withController( + { + rpcFailoverMode: 'enabled', + state: { + networkConfigurationsByChainId: { + '0x1337': buildCustomNetworkConfiguration({ + chainId: '0x1337', + name: 'Test Network', + rpcEndpoints: [ + buildCustomRpcEndpoint({ + networkClientId, + url: endpointUrl, + }), + ], + }), + }, + networksMetadata: { + [networkClientId]: { + EIPS: {}, + status: NetworkStatus.Unknown, + }, + }, + selectedNetworkClientId: networkClientId, + }, + }, + async ({ controller, messenger }) => { + messenger.subscribe('NetworkController:rpcEndpointRetried', () => { + jest.advanceTimersToNextTimer(); + }); + const { provider } = controller.getNetworkClientById(networkClientId); + + await expect( + provider.request({ + id: 1, + jsonrpc: '2.0', + method: rpcMethod, + params: [], + }), + ).rejects.toThrow('RPC endpoint not found or unavailable'); + + expect(controller.state.networksMetadata[networkClientId].status).toBe( + NetworkStatus.Degraded, + ); + }, + ); + }); + + it('transitions the status of a network client from "degraded" to "available" the first time a failover is activated and returns a 2xx response', async () => { + const primaryEndpointUrl = 'https://mainnet.infura.io'; + const primaryEndpointPath = '/v3/infura-project-id'; + const secondaryEndpointUrl = 'https://second.endpoint'; + const networkClientId = InfuraNetworkType.mainnet; + const rpcMethod = 'eth_gasPrice'; + + nock(primaryEndpointUrl) + .post(primaryEndpointPath, { + id: /^\d+$/u, + jsonrpc: '2.0', + method: 'eth_blockNumber', + params: [], + }) + .times(15) + .reply(503); + nock(secondaryEndpointUrl) + .post('/', { + id: /^\d+$/u, + jsonrpc: '2.0', + method: 'eth_blockNumber', + params: [], + }) + .reply(200, { + id: 1, + jsonrpc: '2.0', + result: '0x1', + }) + .post('/', { + id: 1, + jsonrpc: '2.0', + method: rpcMethod, + params: [], + }) + .reply(200, { + id: 1, + jsonrpc: '2.0', + result: 'ok', + }); + + await withController( + { + rpcFailoverMode: 'enabled', + state: { + networkConfigurationsByChainId: { + '0x1': buildInfuraNetworkConfiguration(InfuraNetworkType.mainnet, { + rpcEndpoints: [ + buildInfuraRpcEndpoint(InfuraNetworkType.mainnet, { + failoverUrls: [secondaryEndpointUrl], + }), + ], + }), + }, + networksMetadata: { + [networkClientId]: { + EIPS: {}, + status: NetworkStatus.Unknown, + }, + }, + selectedNetworkClientId: networkClientId, + }, + }, + async ({ controller, messenger }) => { + messenger.subscribe('NetworkController:rpcEndpointRetried', () => { + jest.advanceTimersToNextTimer(); + }); + const stateChangeListener = jest.fn(); + messenger.subscribe( + 'NetworkController:stateChange', + stateChangeListener, + ); + const { provider } = controller.getNetworkClientById(networkClientId); + const request = { + id: 1, + jsonrpc: '2.0' as const, + method: rpcMethod, + params: [], + }; + const expectedError = 'RPC endpoint not found or unavailable'; + + // Hit the primary, run out of retries. + await expect(provider.request(request)).rejects.toThrow(expectedError); + // Hit the primary, run out of retries. + await expect(provider.request(request)).rejects.toThrow(expectedError); + // Hit the primary, break the circuit, fail over to the secondary. + await provider.request(request); + + expect(stateChangeListener).toHaveBeenCalledTimes(2); + expect(stateChangeListener).toHaveBeenNthCalledWith( + 1, + expect.any(Object), + [ + { + op: 'replace', + path: ['networksMetadata', networkClientId, 'status'], + value: 'degraded', + }, + ], + ); + expect(stateChangeListener).toHaveBeenNthCalledWith( + 2, + expect.any(Object), + [ + { + op: 'replace', + path: ['networksMetadata', networkClientId, 'status'], + value: 'available', + }, + ], + ); + }, + ); + }); + + it('does not transition the status of a network client from "degraded" the first time a failover is activated if it returns a non-2xx response', async () => { + const primaryEndpointUrl = 'https://mainnet.infura.io'; + const primaryEndpointPath = '/v3/infura-project-id'; + const secondaryEndpointUrl = 'https://second.endpoint'; + const networkClientId = InfuraNetworkType.mainnet; + const rpcMethod = 'eth_gasPrice'; + + nock(primaryEndpointUrl) + .post(primaryEndpointPath, { + id: /^\d+$/u, + jsonrpc: '2.0', + method: 'eth_blockNumber', + params: [], + }) + .times(15) + .reply(503); + nock(secondaryEndpointUrl) + .post('/', { + id: /^\d+$/u, + jsonrpc: '2.0', + method: 'eth_blockNumber', + params: [], + }) + .times(5) + .reply(503); + + await withController( + { + rpcFailoverMode: 'enabled', + state: { + networkConfigurationsByChainId: { + '0x1': buildInfuraNetworkConfiguration(InfuraNetworkType.mainnet, { + rpcEndpoints: [ + buildInfuraRpcEndpoint(InfuraNetworkType.mainnet, { + failoverUrls: [secondaryEndpointUrl], + }), + ], + }), + }, + networksMetadata: { + [networkClientId]: { + EIPS: {}, + status: NetworkStatus.Unknown, + }, + }, + selectedNetworkClientId: networkClientId, + }, + }, + async ({ controller, messenger }) => { + messenger.subscribe('NetworkController:rpcEndpointRetried', () => { + jest.advanceTimersToNextTimer(); + }); + const stateChangeListener = jest.fn(); + messenger.subscribe( + 'NetworkController:stateChange', + stateChangeListener, + ); + const { provider } = controller.getNetworkClientById(networkClientId); + const request = { + id: 1, + jsonrpc: '2.0' as const, + method: rpcMethod, + params: [], + }; + const expectedError = 'RPC endpoint not found or unavailable'; + + // Hit the primary, run out of retries. + await expect(provider.request(request)).rejects.toThrow(expectedError); + // Hit the primary, run out of retries. + await expect(provider.request(request)).rejects.toThrow(expectedError); + // Hit the primary, break the circuit, fail over to the secondary, + // run out of retries. + await expect(provider.request(request)).rejects.toThrow(expectedError); + + expect(controller.state.networksMetadata[networkClientId].status).toBe( + NetworkStatus.Degraded, + ); + }, + ); + }); + + it('does not transition the status of a network client from "degraded" the first time a failover is activated if requests are slow to complete', async () => { + const primaryEndpointUrl = 'https://mainnet.infura.io'; + const primaryEndpointPath = '/v3/infura-project-id'; + const secondaryEndpointUrl = 'https://second.endpoint'; + const networkClientId = InfuraNetworkType.mainnet; + const rpcMethod = 'eth_gasPrice'; + + nock(primaryEndpointUrl) + .post(primaryEndpointPath, { + id: /^\d+$/u, + jsonrpc: '2.0', + method: 'eth_blockNumber', + params: [], + }) + .times(15) + .reply(503); + nock(secondaryEndpointUrl) + .post('/', { + id: /^\d+$/u, + jsonrpc: '2.0', + method: 'eth_blockNumber', + params: [], + }) + .reply(() => { + jest.advanceTimersByTime(DEFAULT_DEGRADED_THRESHOLD + 1); + return [ + 200, + { + id: 1, + jsonrpc: '2.0', + result: '0x1', + }, + ]; + }) + .post('/', { + id: 1, + jsonrpc: '2.0', + method: rpcMethod, + params: [], + }) + .reply(() => { + jest.advanceTimersByTime(DEFAULT_DEGRADED_THRESHOLD + 1); + return [ + 200, + { + id: 1, + jsonrpc: '2.0', + result: 'ok', + }, + ]; + }); + + await withController( + { + rpcFailoverMode: 'enabled', + state: { + networkConfigurationsByChainId: { + '0x1': buildInfuraNetworkConfiguration(InfuraNetworkType.mainnet, { + rpcEndpoints: [ + buildInfuraRpcEndpoint(InfuraNetworkType.mainnet, { + failoverUrls: [secondaryEndpointUrl], + }), + ], + }), + }, + networksMetadata: { + [networkClientId]: { + EIPS: {}, + status: NetworkStatus.Unknown, + }, + }, + selectedNetworkClientId: networkClientId, + }, + }, + async ({ controller, messenger }) => { + messenger.subscribe('NetworkController:rpcEndpointRetried', () => { + jest.advanceTimersToNextTimer(); + }); + const stateChangeListener = jest.fn(); + messenger.subscribe( + 'NetworkController:stateChange', + stateChangeListener, + ); + const { provider } = controller.getNetworkClientById(networkClientId); + const request = { + id: 1, + jsonrpc: '2.0' as const, + method: rpcMethod, + params: [], + }; + const expectedError = 'RPC endpoint not found or unavailable'; + + // Hit the primary, run out of retries. + await expect(provider.request(request)).rejects.toThrow(expectedError); + // Hit the primary, run out of retries. + await expect(provider.request(request)).rejects.toThrow(expectedError); + // Hit the primary, break the circuit, fail over to the secondary. + await provider.request(request); + + expect(controller.state.networksMetadata[networkClientId].status).toBe( + NetworkStatus.Degraded, + ); + }, + ); + }); + + it('sets the status of a network client to "unavailable" when all of its RPC endpoints consistently return 5xx errors, reaching the max consecutive number of failures', async () => { + const primaryEndpointUrl = 'https://mainnet.infura.io'; + const primaryEndpointPath = '/v3/infura-project-id'; + const secondaryEndpointUrl = 'https://second.endpoint'; + const networkClientId = InfuraNetworkType.mainnet; + const rpcMethod = 'eth_gasPrice'; + + nock(primaryEndpointUrl) + .post(primaryEndpointPath, { + id: /^\d+$/u, + jsonrpc: '2.0', + method: 'eth_blockNumber', + params: [], + }) + .times(12) + .reply(503); + nock(secondaryEndpointUrl) + .post('/', { + id: /^\d+$/u, + jsonrpc: '2.0', + method: 'eth_blockNumber', + params: [], + }) + .times(40) + .reply(503); + + await withController( + { + rpcFailoverMode: 'enabled', + state: { + networkConfigurationsByChainId: { + '0x1': buildInfuraNetworkConfiguration(InfuraNetworkType.mainnet, { + rpcEndpoints: [ + buildInfuraRpcEndpoint(InfuraNetworkType.mainnet, { + failoverUrls: [secondaryEndpointUrl], + }), + ], + }), + }, + networksMetadata: { + [networkClientId]: { + EIPS: {}, + status: NetworkStatus.Unknown, + }, + }, + selectedNetworkClientId: networkClientId, + }, + }, + async ({ controller, messenger }) => { + messenger.subscribe('NetworkController:rpcEndpointRetried', () => { + jest.advanceTimersToNextTimer(); + }); + const { provider } = controller.getNetworkClientById(networkClientId); + const request = { + id: 1, + jsonrpc: '2.0' as const, + method: rpcMethod, + params: [], + }; + const expectedError = 'RPC endpoint not found or unavailable'; + + // Hit the primary, run out of retries. + await expect(provider.request(request)).rejects.toThrow(expectedError); + // Hit the primary, run out of retries. + await expect(provider.request(request)).rejects.toThrow(expectedError); + // Hit the primary, break the circuit, fail over to the secondary, + // run out of retries. + await expect(provider.request(request)).rejects.toThrow(expectedError); + + for (let i = 0; i < 8; i++) { + // Hit the secondary, run out of retries. + await expect(provider.request(request)).rejects.toThrow( + expectedError, + ); + } + + // Hit the secondary, break the circuit. + await expect(provider.request(request)).rejects.toThrow(expectedError); + + expect(controller.state.networksMetadata[networkClientId].status).toBe( + NetworkStatus.Unavailable, + ); + }, + ); + }); + + it('does not fail over when the selected RPC endpoint of a network is custom, even if failover URLs are configured and failover is enabled', async () => { + const customEndpointUrl = 'https://custom.endpoint'; + const failoverEndpointUrl = 'https://failover.endpoint'; + const networkClientId = 'custom-network-client-id'; + const rpcMethod = 'eth_gasPrice'; + + // The selected (custom) endpoint always errors. + nock(customEndpointUrl) + .post('/', { + id: /^\d+$/u, + jsonrpc: '2.0', + method: 'eth_blockNumber', + params: [], + }) + .times(15) + .reply(503); + // The failover endpoint would happily serve requests. If failover were + // (wrongly) honored for a custom endpoint, the request would divert here + // and succeed instead of throwing. We assert below that it is never hit. + const failoverScope = nock(failoverEndpointUrl) + .post('/', { + id: /^\d+$/u, + jsonrpc: '2.0', + method: 'eth_blockNumber', + params: [], + }) + .reply(200, { + id: 1, + jsonrpc: '2.0', + result: '0x1', + }) + .post('/', { + id: 1, + jsonrpc: '2.0', + method: rpcMethod, + params: [], + }) + .reply(200, { + id: 1, + jsonrpc: '2.0', + result: 'ok', + }); + + await withController( + { + rpcFailoverMode: 'enabled', + state: { + networkConfigurationsByChainId: { + // The network offers both an Infura and a custom endpoint, with the + // custom one selected. + '0x1': buildInfuraNetworkConfiguration(InfuraNetworkType.mainnet, { + rpcEndpoints: [ + buildInfuraRpcEndpoint(InfuraNetworkType.mainnet), + buildCustomRpcEndpoint({ + networkClientId, + url: customEndpointUrl, + failoverUrls: [failoverEndpointUrl], + }), + ], + defaultRpcEndpointIndex: 1, + }), + }, + networksMetadata: { + [networkClientId]: { + EIPS: {}, + status: NetworkStatus.Unknown, + }, + }, + selectedNetworkClientId: networkClientId, + }, + }, + async ({ controller, messenger }) => { + messenger.subscribe('NetworkController:rpcEndpointRetried', () => { + jest.advanceTimersToNextTimer(); + }); + const { provider } = controller.getNetworkClientById(networkClientId); + const request = { + id: 1, + jsonrpc: '2.0' as const, + method: rpcMethod, + params: [], + }; + const expectedError = 'RPC endpoint not found or unavailable'; + + // Hit the primary, run out of retries. + await expect(provider.request(request)).rejects.toThrow(expectedError); + // Hit the primary, run out of retries. + await expect(provider.request(request)).rejects.toThrow(expectedError); + // Hit the primary, break the circuit. Since failover is not honored for + // a custom endpoint, there is nowhere to divert to, so this still + // throws rather than succeeding via the failover endpoint. + await expect(provider.request(request)).rejects.toThrow(expectedError); + + // The failover endpoint was never contacted. + expect(failoverScope.isDone()).toBe(false); + }, + ); + }); + + it('transitions the status of a network client from "unavailable" to "available" when its (sole) RPC endpoint consistently returns 5xx errors for a while and then recovers', async () => { + const endpointUrl = 'https://some.endpoint'; + const networkClientId = 'AAAA-AAAA-AAAA-AAAA'; + const rpcMethod = 'eth_gasPrice'; + + nock(endpointUrl) + .post('/', { + id: /^\d+$/u, + jsonrpc: '2.0', + method: 'eth_blockNumber', + params: [], + }) + .times(12) + .reply(503) + .post('/', { + id: /^\d+$/u, + jsonrpc: '2.0', + method: 'eth_blockNumber', + params: [], + }) + .reply(200, { + id: 1, + jsonrpc: '2.0', + result: '0x1', + }) + .post('/', { + id: 1, + jsonrpc: '2.0', + method: rpcMethod, + params: [], + }) + .reply(200, { + id: 1, + jsonrpc: '2.0', + result: 'ok', + }); + + await withController( + { + rpcFailoverMode: 'enabled', + state: { + networkConfigurationsByChainId: { + '0x1337': buildCustomNetworkConfiguration({ + chainId: '0x1337', + name: 'Test Network', + rpcEndpoints: [ + buildCustomRpcEndpoint({ + networkClientId, + url: endpointUrl, + }), + ], + }), + }, + networksMetadata: { + [networkClientId]: { + EIPS: {}, + status: NetworkStatus.Unknown, + }, + }, + selectedNetworkClientId: networkClientId, + }, + }, + async ({ controller, messenger }) => { + messenger.subscribe('NetworkController:rpcEndpointRetried', () => { + jest.advanceTimersToNextTimer(); + }); + const stateChangeListener = jest.fn(); + messenger.subscribe( + 'NetworkController:stateChange', + stateChangeListener, + ); + const { provider } = controller.getNetworkClientById(networkClientId); + const request = { + id: 1, + jsonrpc: '2.0' as const, + method: rpcMethod, + params: [], + }; + const expectedError = 'RPC endpoint not found or unavailable'; + + // Hit the endpoint, run out of retries. + await expect(provider.request(request)).rejects.toThrow(expectedError); + // Hit the endpoint, run out of retries. + await expect(provider.request(request)).rejects.toThrow(expectedError); + // Hit the endpoint, break the circuit. + await expect(provider.request(request)).rejects.toThrow(expectedError); + // Wait until the circuit break duration passes and hit the endpoint + // again. + jest.advanceTimersByTime(inMilliseconds(30, Duration.Second)); + await provider.request(request); + + expect(stateChangeListener).toHaveBeenCalledTimes(3); + expect(stateChangeListener).toHaveBeenNthCalledWith( + 1, + expect.any(Object), + [ + { + op: 'replace', + path: ['networksMetadata', 'AAAA-AAAA-AAAA-AAAA', 'status'], + value: 'degraded', + }, + ], + ); + expect(stateChangeListener).toHaveBeenNthCalledWith( + 2, + expect.any(Object), + [ + { + op: 'replace', + path: ['networksMetadata', 'AAAA-AAAA-AAAA-AAAA', 'status'], + value: 'unavailable', + }, + ], + ); + expect(stateChangeListener).toHaveBeenNthCalledWith( + 3, + expect.any(Object), + [ + { + op: 'replace', + path: ['networksMetadata', 'AAAA-AAAA-AAAA-AAAA', 'status'], + value: 'available', + }, + ], + ); + }, + ); + }); + + it('transitions the status of a network client from "available" to "unavailable" when its (sole) RPC endpoint responds with 2xx and then returns too many 5xx responses, reaching the max number of consecutive failures', async () => { + const endpointUrl = 'https://some.endpoint'; + const networkClientId = 'AAAA-AAAA-AAAA-AAAA'; + const rpcMethod = 'eth_gasPrice'; + + nock(endpointUrl) + .post('/', { + id: /^\d+$/u, + jsonrpc: '2.0', + method: 'eth_blockNumber', + params: [], + }) + .reply(200, { + id: 1, + jsonrpc: '2.0', + result: '0x1', + }) + .post('/', { + id: /^\d+$/u, + jsonrpc: '2.0', + method: 'eth_blockNumber', + params: [], + }) + .times(12) + .reply(503) + .post('/', { + id: 1, + jsonrpc: '2.0', + method: rpcMethod, + params: [], + }) + .reply(200, { + id: 1, + jsonrpc: '2.0', + result: 'ok', + }); + + await withController( + { + rpcFailoverMode: 'enabled', + state: { + networkConfigurationsByChainId: { + '0x1337': buildCustomNetworkConfiguration({ + chainId: '0x1337', + name: 'Test Network', + rpcEndpoints: [ + buildCustomRpcEndpoint({ + networkClientId, + url: endpointUrl, + }), + ], + }), + }, + networksMetadata: { + [networkClientId]: { + EIPS: {}, + status: NetworkStatus.Unknown, + }, + }, + selectedNetworkClientId: networkClientId, + }, + }, + async ({ controller, messenger }) => { + messenger.subscribe('NetworkController:rpcEndpointRetried', () => { + jest.advanceTimersToNextTimer(); + }); + const stateChangeListener = jest.fn(); + messenger.subscribe( + 'NetworkController:stateChange', + stateChangeListener, + ); + const { provider } = controller.getNetworkClientById(networkClientId); + const request = { + id: 1, + jsonrpc: '2.0' as const, + method: rpcMethod, + params: [], + }; + const expectedError = 'RPC endpoint not found or unavailable'; + + // Hit the endpoint and see that it is successful. + await provider.request(request); + // Wait for the block tracker to reset the cache. (For some reason, + // multiple timers exist.) + jest.runAllTimers(); + // Hit the endpoint, run out of retries. + await expect(provider.request(request)).rejects.toThrow(expectedError); + // Hit the endpoint, run out of retries. + await expect(provider.request(request)).rejects.toThrow(expectedError); + // Hit the endpoint, break the circuit. + await expect(provider.request(request)).rejects.toThrow(expectedError); + + expect(stateChangeListener).toHaveBeenCalledTimes(3); + expect(stateChangeListener).toHaveBeenNthCalledWith( + 1, + expect.any(Object), + [ + { + op: 'replace', + path: ['networksMetadata', 'AAAA-AAAA-AAAA-AAAA', 'status'], + value: 'available', + }, + ], + ); + expect(stateChangeListener).toHaveBeenNthCalledWith( + 2, + expect.any(Object), + [ + { + op: 'replace', + path: ['networksMetadata', 'AAAA-AAAA-AAAA-AAAA', 'status'], + value: 'degraded', + }, + ], + ); + expect(stateChangeListener).toHaveBeenNthCalledWith( + 3, + expect.any(Object), + [ + { + op: 'replace', + path: ['networksMetadata', 'AAAA-AAAA-AAAA-AAAA', 'status'], + value: 'unavailable', + }, + ], + ); + }, + ); + }); +}); diff --git a/packages/network-controller/tests/NetworkController.test.ts b/packages/network-controller/tests/NetworkController.test.ts index 1a768073590..5ef7bc6b351 100644 --- a/packages/network-controller/tests/NetworkController.test.ts +++ b/packages/network-controller/tests/NetworkController.test.ts @@ -1,36 +1,72 @@ -import { ControllerMessenger } from '@metamask/base-controller'; +import { deriveStateFromMetadata } from '@metamask/base-controller'; import { - BUILT_IN_NETWORKS, ChainId, InfuraNetworkType, + isInfuraNetworkType, MAX_SAFE_CHAIN_ID, + NetworkNickname, + NetworksTicker, NetworkType, toHex, } from '@metamask/controller-utils'; +import { PollingBlockTrackerOptions } from '@metamask/eth-block-tracker'; import { rpcErrors } from '@metamask/rpc-errors'; +import type { Hex } from '@metamask/utils'; import assert from 'assert'; import type { Patch } from 'immer'; -import { when, resetAllWhenMocks } from 'jest-when'; +import { when, resetAllWhenMocks, WhenMock } from 'jest-when'; import { inspect, isDeepStrictEqual, promisify } from 'util'; -import { v4 } from 'uuid'; - -import { FakeBlockTracker } from '../../../tests/fake-block-tracker'; -import { NetworkStatus } from '../src/constants'; -import type { NetworkClient } from '../src/create-network-client'; -import { createNetworkClient } from '../src/create-network-client'; +import { v4 as uuidV4 } from 'uuid'; + +import { FakeBlockTracker } from '../../../tests/fake-block-tracker.js'; +import type { FakeProviderStub } from '../../../tests/fake-provider.js'; +import { FakeProvider } from '../../../tests/fake-provider.js'; +import { NetworkStatus } from '../src/constants.js'; +import * as createAutoManagedNetworkClientModule from '../src/create-auto-managed-network-client.js'; +import type { AutoManagedNetworkClient } from '../src/create-auto-managed-network-client.js'; +import type { NetworkClient } from '../src/create-network-client.js'; +import { createNetworkClient } from '../src/create-network-client.js'; import type { - NetworkControllerActions, + AutoManagedBuiltInNetworkClientRegistry, + AutoManagedCustomNetworkClientRegistry, + InfuraRpcEndpoint, + NetworkClientId, + NetworkConfiguration, NetworkControllerEvents, - NetworkControllerOptions, NetworkControllerStateChangeEvent, NetworkState, - ProviderConfig, -} from '../src/NetworkController'; -import { NetworkController } from '../src/NetworkController'; -import type { Provider } from '../src/types'; -import { NetworkClientType } from '../src/types'; -import type { FakeProviderStub } from './fake-provider'; -import { FakeProvider } from './fake-provider'; +} from '../src/NetworkController.js'; +import { + getAvailableNetworkClientIds, + getDefaultNetworkControllerState, + getNetworkConfigurations, + NetworkController, + RpcEndpointType, + selectAvailableNetworkClientIds, + selectNetworkConfigurations, +} from '../src/NetworkController.js'; +import type { RpcServiceOptions } from '../src/rpc-service/rpc-service.js'; +import type { NetworkClientConfiguration, Provider } from '../src/types.js'; +import { NetworkClientType } from '../src/types.js'; +import { + buildAddNetworkCustomRpcEndpointFields, + buildAddNetworkFields, + buildCustomNetworkClientConfiguration, + buildCustomNetworkConfiguration, + buildCustomRpcEndpoint, + buildInfuraNetworkClientConfiguration, + buildInfuraNetworkConfiguration, + buildInfuraRpcEndpoint, + buildMockConfigRegistryControllerNetwork, + buildNetworkConfiguration, + buildNetworkControllerMessenger, + buildRootMessenger, + buildUpdateNetworkCustomRpcEndpointFields, + INFURA_NETWORKS, + TESTNET, + withController, +} from './helpers.js'; +import type { RootMessenger } from './helpers.js'; jest.mock('../src/create-network-client'); @@ -39,7 +75,7 @@ jest.mock('uuid', () => { return { ...actual, - v4: jest.fn().mockReturnValue('UUID'), + v4: jest.fn(), }; }); @@ -55,7 +91,7 @@ type Block = { }; const createNetworkClientMock = jest.mocked(createNetworkClient); -const uuidV4Mock = jest.mocked(v4); +const uuidV4Mock = jest.mocked(uuidV4); /** * A dummy block that matches the pre-EIP-1559 format (i.e. it doesn't have the @@ -80,43 +116,6 @@ const POST_1559_BLOCK: Block = { */ const BLOCK: Block = POST_1559_BLOCK; -/** - * The networks that NetworkController recognizes as built-in Infura networks, - * along with information we expect to be true for those networks. - */ -const INFURA_NETWORKS = [ - { - networkType: NetworkType['linea-goerli'], - chainId: toHex(59140), - ticker: 'LineaETH', - blockExplorerUrl: 'https://goerli.lineascan.build', - }, - { - networkType: NetworkType['linea-mainnet'], - chainId: toHex(59144), - ticker: 'ETH', - blockExplorerUrl: 'https://lineascan.build', - }, - { - networkType: NetworkType.mainnet, - chainId: toHex(1), - ticker: 'ETH', - blockExplorerUrl: 'https://etherscan.io', - }, - { - networkType: NetworkType.goerli, - chainId: toHex(5), - ticker: 'GoerliETH', - blockExplorerUrl: 'https://goerli.etherscan.io', - }, - { - networkType: NetworkType.sepolia, - chainId: toHex(11155111), - ticker: 'SepoliaETH', - blockExplorerUrl: 'https://sepolia.etherscan.io', - }, -]; - /** * A response object for a successful request to `eth_getBlockByNumber`. It is * assumed that the block number here is insignificant to the test. @@ -140,9 +139,24 @@ const GENERIC_JSON_RPC_ERROR = rpcErrors.internal( JSON.stringify({ error: 'oops' }), ); +/** + * We are mocking/faking `Date.now` calls for test consistency + */ +const FAKE_DATE_NOW_MS = 1732114339518; + describe('NetworkController', () => { + let uuidCounter = 0; + beforeEach(() => { - jest.resetAllMocks(); + uuidV4Mock.mockImplementation(() => { + const uuid = `UUID-${uuidCounter}`; + uuidCounter += 1; + return uuid; + }); + + createNetworkClientMock.mockReturnValue(buildFakeClient()); + + jest.spyOn(Date, 'now').mockReturnValue(FAKE_DATE_NOW_MS); }); afterEach(() => { @@ -150,17 +164,335 @@ describe('NetworkController', () => { }); describe('constructor', () => { + it('throws given an empty networkConfigurationsByChainId collection', () => { + const messenger = buildRootMessenger(); + const controllerMessenger = buildNetworkControllerMessenger(messenger); + expect( + () => + new NetworkController({ + messenger: controllerMessenger, + state: { + networkConfigurationsByChainId: {}, + }, + infuraProjectId: 'infura-project-id', + getRpcServiceOptions: (): Omit< + RpcServiceOptions, + 'failoverService' | 'endpointUrl' + > => ({ + fetch, + btoa, + isOffline: (): boolean => false, + }), + }), + ).toThrow( + 'NetworkController state is invalid: `networkConfigurationsByChainId` cannot be empty', + ); + }); + + it('throws if the key under which a network configuration is filed does not match the chain ID of that network configuration', () => { + const messenger = buildRootMessenger(); + const controllerMessenger = buildNetworkControllerMessenger(messenger); + expect( + () => + new NetworkController({ + messenger: controllerMessenger, + state: { + networkConfigurationsByChainId: { + '0x1337': buildCustomNetworkConfiguration({ + chainId: '0x1338', + name: 'Test Network', + }), + }, + }, + infuraProjectId: 'infura-project-id', + getRpcServiceOptions: (): Omit< + RpcServiceOptions, + 'failoverService' | 'endpointUrl' + > => ({ + fetch, + btoa, + isOffline: (): boolean => false, + }), + }), + ).toThrow( + "NetworkController state has invalid `networkConfigurationsByChainId`: Network configuration 'Test Network' is filed under '0x1337' which does not match its `chainId` of '0x1338'", + ); + }); + + it('throws if a network configuration has a defaultBlockExplorerUrlIndex that does not refer to an entry in blockExplorerUrls', () => { + const messenger = buildRootMessenger(); + const controllerMessenger = buildNetworkControllerMessenger(messenger); + expect( + () => + new NetworkController({ + messenger: controllerMessenger, + state: { + networkConfigurationsByChainId: { + '0x1337': buildCustomNetworkConfiguration({ + blockExplorerUrls: [], + defaultBlockExplorerUrlIndex: 99999, + chainId: '0x1337', + name: 'Test Network', + rpcEndpoints: [ + buildCustomRpcEndpoint({ + url: 'https://some.endpoint', + }), + ], + }), + }, + }, + infuraProjectId: 'infura-project-id', + getRpcServiceOptions: (): Omit< + RpcServiceOptions, + 'failoverService' | 'endpointUrl' + > => ({ + fetch, + btoa, + isOffline: (): boolean => false, + }), + }), + ).toThrow( + "NetworkController state has invalid `networkConfigurationsByChainId`: Network configuration 'Test Network' has a `defaultBlockExplorerUrlIndex` that does not refer to an entry in `blockExplorerUrls`", + ); + }); + + it('throws if a network configuration has a non-empty blockExplorerUrls but an absent defaultBlockExplorerUrlIndex', () => { + const messenger = buildRootMessenger(); + const controllerMessenger = buildNetworkControllerMessenger(messenger); + expect( + () => + new NetworkController({ + messenger: controllerMessenger, + state: { + networkConfigurationsByChainId: { + '0x1337': buildCustomNetworkConfiguration({ + blockExplorerUrls: ['https://block.explorer'], + chainId: '0x1337', + name: 'Test Network', + rpcEndpoints: [ + buildCustomRpcEndpoint({ + url: 'https://some.endpoint', + }), + ], + }), + }, + }, + infuraProjectId: 'infura-project-id', + getRpcServiceOptions: (): Omit< + RpcServiceOptions, + 'failoverService' | 'endpointUrl' + > => ({ + fetch, + btoa, + isOffline: (): boolean => false, + }), + }), + ).toThrow( + "NetworkController state has invalid `networkConfigurationsByChainId`: Network configuration 'Test Network' has a `defaultBlockExplorerUrlIndex` that does not refer to an entry in `blockExplorerUrls`", + ); + }); + + it('throws if a network configuration has an invalid defaultRpcEndpointIndex', () => { + const messenger = buildRootMessenger(); + const controllerMessenger = buildNetworkControllerMessenger(messenger); + expect( + () => + new NetworkController({ + messenger: controllerMessenger, + state: { + networkConfigurationsByChainId: { + '0x1337': buildCustomNetworkConfiguration({ + chainId: '0x1337', + name: 'Test Network', + defaultRpcEndpointIndex: 99999, + rpcEndpoints: [ + buildCustomRpcEndpoint({ + url: 'https://some.endpoint', + }), + ], + }), + }, + }, + infuraProjectId: 'infura-project-id', + getRpcServiceOptions: (): Omit< + RpcServiceOptions, + 'failoverService' | 'endpointUrl' + > => ({ + fetch, + btoa, + isOffline: (): boolean => false, + }), + }), + ).toThrow( + "NetworkController state has invalid `networkConfigurationsByChainId`: Network configuration 'Test Network' has a `defaultRpcEndpointIndex` that does not refer to an entry in `rpcEndpoints`", + ); + }); + + it('throws if more than one RPC endpoint across network configurations has the same networkClientId', () => { + const messenger = buildRootMessenger(); + const controllerMessenger = buildNetworkControllerMessenger(messenger); + expect( + () => + new NetworkController({ + messenger: controllerMessenger, + state: { + networkConfigurationsByChainId: { + '0x1337': buildCustomNetworkConfiguration({ + chainId: '0x1337', + name: 'Test Network 1', + rpcEndpoints: [ + buildCustomRpcEndpoint({ + networkClientId: 'AAAA-AAAA-AAAA-AAAA', + url: 'https://test.endpoint/1', + }), + ], + }), + '0x2448': buildCustomNetworkConfiguration({ + chainId: '0x2448', + name: 'Test Network 2', + rpcEndpoints: [ + buildCustomRpcEndpoint({ + networkClientId: 'AAAA-AAAA-AAAA-AAAA', + url: 'https://test.endpoint/2', + }), + ], + }), + }, + }, + infuraProjectId: 'infura-project-id', + getRpcServiceOptions: (): Omit< + RpcServiceOptions, + 'failoverService' | 'endpointUrl' + > => ({ + fetch, + btoa, + isOffline: (): boolean => false, + }), + }), + ).toThrow( + 'NetworkController state has invalid `networkConfigurationsByChainId`: Every RPC endpoint across all network configurations must have a unique `networkClientId`', + ); + }); + + it('corrects an invalid selectedNetworkClientId to the default RPC endpoint of the first chain, logging this fact', () => { + const messenger = buildRootMessenger(); + const captureExceptionSpy = jest.spyOn(messenger, 'captureException'); + const controllerMessenger = buildNetworkControllerMessenger(messenger); + + const controller = new NetworkController({ + messenger: controllerMessenger, + state: { + selectedNetworkClientId: 'nonexistent', + networkConfigurationsByChainId: { + '0x1': buildCustomNetworkConfiguration({ + chainId: '0x1', + defaultRpcEndpointIndex: 1, + rpcEndpoints: [ + buildCustomRpcEndpoint({ + networkClientId: 'AAAA-AAAA-AAAA-AAAA', + }), + buildCustomRpcEndpoint({ + networkClientId: 'BBBB-BBBB-BBBB-BBBB', + }), + ], + }), + '0x2': buildCustomNetworkConfiguration({ chainId: '0x2' }), + '0x3': buildCustomNetworkConfiguration({ chainId: '0x3' }), + }, + }, + infuraProjectId: 'infura-project-id', + getRpcServiceOptions: (): Omit< + RpcServiceOptions, + 'failoverService' | 'endpointUrl' + > => ({ + fetch, + btoa, + isOffline: (): boolean => false, + }), + }); + + expect(controller.state.selectedNetworkClientId).toBe( + 'BBBB-BBBB-BBBB-BBBB', + ); + expect(captureExceptionSpy).toHaveBeenCalledWith( + new Error( + "`selectedNetworkClientId` 'nonexistent' does not refer to an RPC endpoint within a network configuration; correcting to 'BBBB-BBBB-BBBB-BBBB'", + ), + ); + }); + + it('removes invalid network client IDs from networksMetadata, logging this fact', () => { + const messenger = buildRootMessenger(); + const captureExceptionSpy = jest.spyOn(messenger, 'captureException'); + const controllerMessenger = buildNetworkControllerMessenger(messenger); + + const controller = new NetworkController({ + messenger: controllerMessenger, + state: { + selectedNetworkClientId: InfuraNetworkType.sepolia, + networkConfigurationsByChainId: { + [ChainId.sepolia]: buildInfuraNetworkConfiguration( + InfuraNetworkType.sepolia, + ), + }, + networksMetadata: { + [InfuraNetworkType.sepolia]: { + status: NetworkStatus.Available, + EIPS: {}, + }, + 'AAAA-AAAA-AAAA-AAAA': { + status: NetworkStatus.Available, + EIPS: {}, + }, + 'BBBB-BBBB-BBBB-BBBB': { + status: NetworkStatus.Available, + EIPS: {}, + }, + 'CCCC-CCCC-CCCC-CCCC': { + status: NetworkStatus.Available, + EIPS: {}, + }, + }, + }, + infuraProjectId: 'infura-project-id', + getRpcServiceOptions: (): Omit< + RpcServiceOptions, + 'failoverService' | 'endpointUrl' + > => ({ + fetch, + btoa, + isOffline: (): boolean => false, + }), + }); + + expect(controller.state.networksMetadata).not.toHaveProperty( + 'AAAA-AAAA-AAAA-AAAA', + ); + expect(controller.state.networksMetadata).not.toHaveProperty( + 'BBBB-BBBB-BBBB-BBBB', + ); + expect(controller.state.networksMetadata).not.toHaveProperty( + 'CCCC-CCCC-CCCC-CCCC', + ); + expect(captureExceptionSpy).toHaveBeenCalledWith( + new Error( + '`networksMetadata` had invalid network client IDs, which have been removed', + ), + ); + }); + const invalidInfuraProjectIds = [undefined, null, {}, 1]; invalidInfuraProjectIds.forEach((invalidProjectId) => { it(`throws given an invalid Infura ID of "${inspect( invalidProjectId, )}"`, () => { - const messenger = buildMessenger(); - const restrictedMessenger = buildNetworkControllerMessenger(messenger); + const messenger = buildRootMessenger(); + const controllerMessenger = buildNetworkControllerMessenger(messenger); expect( () => new NetworkController({ - messenger: restrictedMessenger, + messenger: controllerMessenger, + state: {}, // @ts-expect-error We are intentionally passing bad input. infuraProjectId: invalidProjectId, }), @@ -169,35 +501,263 @@ describe('NetworkController', () => { }); it('initializes the state with some defaults', async () => { - await withController(({ controller }) => { - expect(controller.state).toMatchInlineSnapshot(` - Object { - "networkConfigurations": Object {}, - "networksMetadata": Object {}, - "providerConfig": Object { - "chainId": "0x1", - "ticker": "ETH", - "type": "mainnet", - }, - "selectedNetworkClientId": "mainnet", - } - `); - }); + await withController( + { initializeController: false }, + ({ controller }) => { + expect(controller.state).toMatchInlineSnapshot(` + { + "networkConfigurationsByChainId": { + "0x1": { + "blockExplorerUrls": [ + "https://etherscan.io", + ], + "chainId": "0x1", + "defaultBlockExplorerUrlIndex": 0, + "defaultRpcEndpointIndex": 0, + "name": "Ethereum", + "nativeCurrency": "ETH", + "rpcEndpoints": [ + { + "failoverUrls": [], + "networkClientId": "mainnet", + "type": "infura", + "url": "https://mainnet.infura.io/v3/{infuraProjectId}", + }, + ], + }, + "0x18c7": { + "blockExplorerUrls": [ + "https://megaeth-testnet-v2.blockscout.com", + ], + "chainId": "0x18c7", + "defaultBlockExplorerUrlIndex": 0, + "defaultRpcEndpointIndex": 0, + "name": "MegaETH Testnet", + "nativeCurrency": "MegaETH", + "rpcEndpoints": [ + { + "failoverUrls": [], + "networkClientId": "megaeth-testnet-v2", + "type": "custom", + "url": "https://carrot.megaeth.com/rpc", + }, + ], + }, + "0x2105": { + "blockExplorerUrls": [ + "https://basescan.org", + ], + "chainId": "0x2105", + "defaultBlockExplorerUrlIndex": 0, + "defaultRpcEndpointIndex": 0, + "name": "Base", + "nativeCurrency": "ETH", + "rpcEndpoints": [ + { + "failoverUrls": [], + "networkClientId": "base-mainnet", + "type": "infura", + "url": "https://base-mainnet.infura.io/v3/{infuraProjectId}", + }, + ], + }, + "0x279f": { + "blockExplorerUrls": [ + "https://testnet.monadexplorer.com", + ], + "chainId": "0x279f", + "defaultBlockExplorerUrlIndex": 0, + "defaultRpcEndpointIndex": 0, + "name": "Monad Testnet", + "nativeCurrency": "MON", + "rpcEndpoints": [ + { + "failoverUrls": [], + "networkClientId": "monad-testnet", + "type": "custom", + "url": "https://testnet-rpc.monad.xyz", + }, + ], + }, + "0x38": { + "blockExplorerUrls": [ + "https://bscscan.com", + ], + "chainId": "0x38", + "defaultBlockExplorerUrlIndex": 0, + "defaultRpcEndpointIndex": 0, + "name": "BNB Chain", + "nativeCurrency": "BNB", + "rpcEndpoints": [ + { + "failoverUrls": [], + "networkClientId": "bsc-mainnet", + "type": "infura", + "url": "https://bsc-mainnet.infura.io/v3/{infuraProjectId}", + }, + ], + }, + "0x89": { + "blockExplorerUrls": [ + "https://polygonscan.com", + ], + "chainId": "0x89", + "defaultBlockExplorerUrlIndex": 0, + "defaultRpcEndpointIndex": 0, + "name": "Polygon", + "nativeCurrency": "POL", + "rpcEndpoints": [ + { + "failoverUrls": [], + "networkClientId": "polygon-mainnet", + "type": "infura", + "url": "https://polygon-mainnet.infura.io/v3/{infuraProjectId}", + }, + ], + }, + "0x8f": { + "blockExplorerUrls": [ + "https://monadscan.com", + ], + "chainId": "0x8f", + "defaultBlockExplorerUrlIndex": 0, + "defaultRpcEndpointIndex": 0, + "name": "Monad", + "nativeCurrency": "MON", + "rpcEndpoints": [ + { + "failoverUrls": [], + "networkClientId": "monad-mainnet", + "type": "infura", + "url": "https://monad-mainnet.infura.io/v3/{infuraProjectId}", + }, + ], + }, + "0xa": { + "blockExplorerUrls": [ + "https://optimistic.etherscan.io", + ], + "chainId": "0xa", + "defaultBlockExplorerUrlIndex": 0, + "defaultRpcEndpointIndex": 0, + "name": "OP", + "nativeCurrency": "ETH", + "rpcEndpoints": [ + { + "failoverUrls": [], + "networkClientId": "optimism-mainnet", + "type": "infura", + "url": "https://optimism-mainnet.infura.io/v3/{infuraProjectId}", + }, + ], + }, + "0xa4b1": { + "blockExplorerUrls": [ + "https://arbiscan.io", + ], + "chainId": "0xa4b1", + "defaultBlockExplorerUrlIndex": 0, + "defaultRpcEndpointIndex": 0, + "name": "Arbitrum", + "nativeCurrency": "ETH", + "rpcEndpoints": [ + { + "failoverUrls": [], + "networkClientId": "arbitrum-mainnet", + "type": "infura", + "url": "https://arbitrum-mainnet.infura.io/v3/{infuraProjectId}", + }, + ], + }, + "0xaa36a7": { + "blockExplorerUrls": [ + "https://sepolia.etherscan.io", + ], + "chainId": "0xaa36a7", + "defaultBlockExplorerUrlIndex": 0, + "defaultRpcEndpointIndex": 0, + "name": "Sepolia", + "nativeCurrency": "SepoliaETH", + "rpcEndpoints": [ + { + "failoverUrls": [], + "networkClientId": "sepolia", + "type": "infura", + "url": "https://sepolia.infura.io/v3/{infuraProjectId}", + }, + ], + }, + "0xe705": { + "blockExplorerUrls": [ + "https://sepolia.lineascan.build", + ], + "chainId": "0xe705", + "defaultBlockExplorerUrlIndex": 0, + "defaultRpcEndpointIndex": 0, + "name": "Linea Sepolia", + "nativeCurrency": "LineaETH", + "rpcEndpoints": [ + { + "failoverUrls": [], + "networkClientId": "linea-sepolia", + "type": "infura", + "url": "https://linea-sepolia.infura.io/v3/{infuraProjectId}", + }, + ], + }, + "0xe708": { + "blockExplorerUrls": [ + "https://lineascan.build", + ], + "chainId": "0xe708", + "defaultBlockExplorerUrlIndex": 0, + "defaultRpcEndpointIndex": 0, + "name": "Linea", + "nativeCurrency": "ETH", + "rpcEndpoints": [ + { + "failoverUrls": [], + "networkClientId": "linea-mainnet", + "type": "infura", + "url": "https://linea-mainnet.infura.io/v3/{infuraProjectId}", + }, + ], + }, + }, + "networksMetadata": {}, + "selectedNetworkClientId": "mainnet", + } + `); + }, + ); }); it('merges the given state into the default state', async () => { await withController( { state: { - providerConfig: { - type: 'rpc', - rpcUrl: 'http://example-custom-rpc.metamask.io', - chainId: '0x9999' as const, - nickname: 'Test initial state', - ticker: 'TEST', + selectedNetworkClientId: TESTNET.networkType, + networkConfigurationsByChainId: { + [TESTNET.chainId]: { + blockExplorerUrls: ['https://block.explorer'], + chainId: TESTNET.chainId, + defaultBlockExplorerUrlIndex: 0, + defaultRpcEndpointIndex: 0, + name: TESTNET.name, + nativeCurrency: TESTNET.nativeCurrency, + rpcEndpoints: [ + { + failoverUrls: ['https://failover.endpoint'], + name: TESTNET.name, + networkClientId: TESTNET.networkType, + type: RpcEndpointType.Infura, + url: 'https://sepolia.infura.io/v3/{infuraProjectId}', + }, + ], + }, }, networksMetadata: { - mainnet: { + [TESTNET.networkType]: { EIPS: { 1559: true }, status: NetworkStatus.Unknown, }, @@ -206,24 +766,39 @@ describe('NetworkController', () => { }, ({ controller }) => { expect(controller.state).toMatchInlineSnapshot(` - Object { - "networkConfigurations": Object {}, - "networksMetadata": Object { - "mainnet": Object { - "EIPS": Object { + { + "networkConfigurationsByChainId": { + "0xaa36a7": { + "blockExplorerUrls": [ + "https://block.explorer", + ], + "chainId": "0xaa36a7", + "defaultBlockExplorerUrlIndex": 0, + "defaultRpcEndpointIndex": 0, + "name": "Sepolia", + "nativeCurrency": "SepoliaETH", + "rpcEndpoints": [ + { + "failoverUrls": [ + "https://failover.endpoint", + ], + "name": "Sepolia", + "networkClientId": "sepolia", + "type": "infura", + "url": "https://sepolia.infura.io/v3/{infuraProjectId}", + }, + ], + }, + }, + "networksMetadata": { + "sepolia": { + "EIPS": { "1559": true, }, "status": "unknown", }, }, - "providerConfig": Object { - "chainId": "0x9999", - "nickname": "Test initial state", - "rpcUrl": "http://example-custom-rpc.metamask.io", - "ticker": "TEST", - "type": "rpc", - }, - "selectedNetworkClientId": "mainnet", + "selectedNetworkClientId": "sepolia", } `); }, @@ -231,2957 +806,2767 @@ describe('NetworkController', () => { }); }); - describe('destroy', () => { - it('does not throw if called before the provider is initialized', async () => { - await withController(async ({ controller }) => { - expect(await controller.destroy()).toBeUndefined(); - }); - }); - - it('stops the block tracker for the currently selected network as long as the provider has been initialized', async () => { - await withController(async ({ controller }) => { - const fakeProvider = buildFakeProvider(); - const fakeNetworkClient = buildFakeClient(fakeProvider); - mockCreateNetworkClient().mockReturnValue(fakeNetworkClient); - await controller.initializeProvider(); - const { blockTracker } = controller.getProviderAndBlockTracker(); - assert(blockTracker, 'Block tracker is somehow unset'); - // The block tracker starts running after a listener is attached - blockTracker.addListener('latest', () => { - // do nothing + describe('RemoteFeatureFlagController:stateChange (rpcFailoverMode forced)', () => { + it('calls setRpcFailoverMode on clients with failover URLs when the flag turns forced', async () => { + const originalCreateAutoManagedNetworkClient = + createAutoManagedNetworkClientModule.createAutoManagedNetworkClient; + const autoManagedNetworkClients: AutoManagedNetworkClient[] = + []; + jest + .spyOn( + createAutoManagedNetworkClientModule, + 'createAutoManagedNetworkClient', + ) + .mockImplementation((...args) => { + const autoManagedNetworkClient = + originalCreateAutoManagedNetworkClient(...args); + jest.spyOn(autoManagedNetworkClient, 'setRpcFailoverMode'); + autoManagedNetworkClients.push(autoManagedNetworkClient); + return autoManagedNetworkClient; }); - expect(blockTracker.isRunning()).toBe(true); - await controller.destroy(); + await withController( + { + rpcFailoverMode: 'disabled', + state: { + selectedNetworkClientId: 'AAAA-AAAA-AAAA-AAAA', + networkConfigurationsByChainId: { + [ChainId.mainnet]: buildInfuraNetworkConfiguration( + InfuraNetworkType.mainnet, + { + rpcEndpoints: [ + buildInfuraRpcEndpoint(InfuraNetworkType.mainnet, { + failoverUrls: [], + }), + ], + }, + ), + '0x200': buildCustomNetworkConfiguration({ + chainId: '0x200', + rpcEndpoints: [ + buildCustomRpcEndpoint({ + networkClientId: 'AAAA-AAAA-AAAA-AAAA', + url: 'https://test.network/1', + failoverUrls: ['https://failover.endpoint/1'], + }), + ], + }), + }, + }, + }, + async ({ messenger }) => { + messenger.publish( + 'RemoteFeatureFlagController:stateChange', + { + remoteFeatureFlags: { + corePlatformRpcFailoverMode: 'forced', + }, + cacheTimestamp: 0, + }, + [], + ); - expect(blockTracker.isRunning()).toBe(false); - }); + expect(autoManagedNetworkClients).toHaveLength(2); + expect( + autoManagedNetworkClients[0].setRpcFailoverMode, + ).not.toHaveBeenCalledWith('forced'); + expect( + autoManagedNetworkClients[1].setRpcFailoverMode, + ).toHaveBeenCalledWith('forced'); + }, + ); }); - }); - describe('initializeProvider', () => { - describe('when the type in the provider config is invalid', () => { - it('throws', async () => { - const invalidProviderConfig = {}; - await withController( - /* @ts-expect-error We're intentionally passing bad input. */ - { - state: { - providerConfig: invalidProviderConfig, + it('picks up the initial forced value during init()', async () => { + const originalCreateAutoManagedNetworkClient = + createAutoManagedNetworkClientModule.createAutoManagedNetworkClient; + const autoManagedNetworkClients: AutoManagedNetworkClient[] = + []; + jest + .spyOn( + createAutoManagedNetworkClientModule, + 'createAutoManagedNetworkClient', + ) + .mockImplementation((...args) => { + const autoManagedNetworkClient = + originalCreateAutoManagedNetworkClient(...args); + jest.spyOn(autoManagedNetworkClient, 'setRpcFailoverMode'); + autoManagedNetworkClients.push(autoManagedNetworkClient); + return autoManagedNetworkClient; + }); + + await withController( + { + rpcFailoverMode: 'forced', + state: { + selectedNetworkClientId: 'AAAA-AAAA-AAAA-AAAA', + networkConfigurationsByChainId: { + [ChainId.mainnet]: buildInfuraNetworkConfiguration( + InfuraNetworkType.mainnet, + { + rpcEndpoints: [ + buildInfuraRpcEndpoint(InfuraNetworkType.mainnet, { + failoverUrls: ['https://failover.endpoint/1'], + }), + ], + }, + ), + '0x200': buildCustomNetworkConfiguration({ + chainId: '0x200', + rpcEndpoints: [ + buildCustomRpcEndpoint({ + networkClientId: 'AAAA-AAAA-AAAA-AAAA', + url: 'https://test.network/1', + failoverUrls: [], + }), + ], + }), }, }, - async ({ controller }) => { - await expect(async () => { - await controller.initializeProvider(); - }).rejects.toThrow("Unrecognized network type: 'undefined'"); + }, + async () => { + expect(autoManagedNetworkClients).toHaveLength(2); + expect( + autoManagedNetworkClients[0].setRpcFailoverMode, + ).toHaveBeenCalledWith('forced'); + expect( + autoManagedNetworkClients[1].setRpcFailoverMode, + ).not.toHaveBeenCalledWith('forced'); + }, + ); + }); + + it('calls setRpcFailoverMode with forced but not enabled when only the forced mode is set', async () => { + const originalCreateAutoManagedNetworkClient = + createAutoManagedNetworkClientModule.createAutoManagedNetworkClient; + const autoManagedNetworkClients: AutoManagedNetworkClient[] = + []; + jest + .spyOn( + createAutoManagedNetworkClientModule, + 'createAutoManagedNetworkClient', + ) + .mockImplementation((...args) => { + const autoManagedNetworkClient = + originalCreateAutoManagedNetworkClient(...args); + jest.spyOn(autoManagedNetworkClient, 'setRpcFailoverMode'); + autoManagedNetworkClients.push(autoManagedNetworkClient); + return autoManagedNetworkClient; + }); + + await withController( + { + rpcFailoverMode: 'disabled', + state: { + selectedNetworkClientId: 'AAAA-AAAA-AAAA-AAAA', + networkConfigurationsByChainId: { + '0x200': buildCustomNetworkConfiguration({ + chainId: '0x200', + rpcEndpoints: [ + buildCustomRpcEndpoint({ + networkClientId: 'AAAA-AAAA-AAAA-AAAA', + url: 'https://test.network/1', + failoverUrls: ['https://failover.endpoint/1'], + }), + ], + }), + }, }, - ); + }, + async ({ messenger }) => { + messenger.publish( + 'RemoteFeatureFlagController:stateChange', + { + remoteFeatureFlags: { + corePlatformRpcFailoverMode: 'forced', + }, + cacheTimestamp: 0, + }, + [], + ); + + expect(autoManagedNetworkClients).toHaveLength(1); + expect( + autoManagedNetworkClients[0].setRpcFailoverMode, + ).toHaveBeenCalledWith('forced'); + expect( + autoManagedNetworkClients[0].setRpcFailoverMode, + ).not.toHaveBeenCalledWith('enabled'); + }, + ); + }); + }); + + describe('init', () => { + it('auto-enables networks that are set as auto-enabled in the config registry', async () => { + const networkConfig = buildMockConfigRegistryControllerNetwork({ + chainId: 'eip155:9999', + config: { + ...buildMockConfigRegistryControllerNetwork().config, + isAutoEnabled: true, + }, }); + await withController( + { + initializeController: false, + configRegistryNetworkConfigs: [networkConfig], + }, + ({ controller }) => { + expect( + controller.state.networkConfigurationsByChainId, + ).not.toHaveProperty('0x270f'); + + controller.init(); + + expect( + controller.state.networkConfigurationsByChainId, + ).toHaveProperty( + '0x270f', + expect.objectContaining({ + chainId: '0x270f', + name: networkConfig.name, + nativeCurrency: networkConfig.assets.native.symbol, + blockExplorerUrls: [networkConfig.blockExplorerUrls.default], + defaultBlockExplorerUrlIndex: 0, + rpcEndpoints: [ + expect.objectContaining({ + networkClientId: + networkConfig.rpcProviders.default.networkClientId, + url: networkConfig.rpcProviders.default.url, + type: networkConfig.rpcProviders.default.type, + }), + ], + defaultRpcEndpointIndex: 0, + }), + ); + }, + ); }); + }); - for (const { networkType } of INFURA_NETWORKS) { - describe(`when the type in the provider config is "${networkType}"`, () => { - it(`does not create another network client for the ${networkType} Infura network, since it is built in`, async () => { - await withController( + describe('ConfigRegistryController:stateChanged', () => { + it('enables Infura chains that are set as auto-enabled in the config registry', async () => { + const networkConfig = buildMockConfigRegistryControllerNetwork({ + chainId: 'eip155:9999', + config: { + ...buildMockConfigRegistryControllerNetwork().config, + isAutoEnabled: true, + }, + }); + await withController( + { configRegistryNetworkConfigs: [networkConfig] }, + async ({ controller, messenger }) => { + messenger.publish( + 'ConfigRegistryController:stateChanged', { - state: { - providerConfig: buildProviderConfig({ - type: networkType, + configs: { networks: { 'eip155:9999': networkConfig } }, + lastFetched: 0, + etag: 'etag', + version: '1', + }, + [], + ); + + expect( + controller.state.networkConfigurationsByChainId, + ).toHaveProperty( + '0x270f', + expect.objectContaining({ + chainId: '0x270f', + name: networkConfig.name, + nativeCurrency: networkConfig.assets.native.symbol, + blockExplorerUrls: [networkConfig.blockExplorerUrls.default], + defaultBlockExplorerUrlIndex: 0, + rpcEndpoints: [ + expect.objectContaining({ + networkClientId: + networkConfig.rpcProviders.default.networkClientId, + url: networkConfig.rpcProviders.default.url, + type: networkConfig.rpcProviders.default.type, }), - }, - infuraProjectId: 'some-infura-project-id', + ], + defaultRpcEndpointIndex: 0, + }), + ); + }, + ); + }); + + it('enables custom networks that are set as auto-enabled in the config registry', async () => { + const networkConfig = buildMockConfigRegistryControllerNetwork({ + chainId: 'eip155:9999', + rpcProviders: { + default: buildCustomRpcEndpoint({ + url: 'https://test.network/1', + type: RpcEndpointType.Custom, + }), + fallbacks: [], + }, + config: { + ...buildMockConfigRegistryControllerNetwork().config, + isAutoEnabled: true, + }, + }); + await withController( + { configRegistryNetworkConfigs: [networkConfig] }, + async ({ controller, messenger }) => { + messenger.publish( + 'ConfigRegistryController:stateChanged', + { + configs: { networks: { 'eip155:9999': networkConfig } }, + lastFetched: 0, + etag: 'etag', + version: '1', }, - async ({ controller }) => { - const fakeNetworkClient = buildFakeClient(); - createNetworkClientMock.mockReturnValue(fakeNetworkClient); + [], + ); - await controller.initializeProvider(); + expect( + controller.state.networkConfigurationsByChainId, + ).toHaveProperty( + '0x270f', + expect.objectContaining({ + chainId: '0x270f', + name: networkConfig.name, + nativeCurrency: networkConfig.assets.native.symbol, + blockExplorerUrls: [networkConfig.blockExplorerUrls.default], + defaultBlockExplorerUrlIndex: 0, + rpcEndpoints: [ + expect.objectContaining({ + url: networkConfig.rpcProviders.default.url, + type: networkConfig.rpcProviders.default.type, + }), + ], + defaultRpcEndpointIndex: 0, + }), + ); + }, + ); + }); - expect(createNetworkClientMock).toHaveBeenCalledWith({ - network: networkType, - infuraProjectId: 'some-infura-project-id', - type: NetworkClientType.Infura, - chainId: BUILT_IN_NETWORKS[networkType].chainId, - ticker: BUILT_IN_NETWORKS[networkType].ticker, - }); - expect(createNetworkClientMock).toHaveBeenCalledTimes(1); + it('gracefully handles errors thrown when enabling networks from the config registry', async () => { + const networkConfigs = [ + buildMockConfigRegistryControllerNetwork({ + chainId: 'eip155:9997', + rpcProviders: { + default: buildCustomRpcEndpoint({ + url: 'https://test.network/2', + type: RpcEndpointType.Custom, + }), + fallbacks: [], + }, + config: { + ...buildMockConfigRegistryControllerNetwork().config, + isAutoEnabled: true, + }, + }), + buildMockConfigRegistryControllerNetwork({ + chainId: 'eip155:9998', + rpcProviders: { + default: buildCustomRpcEndpoint({ + url: 'https://test.network/2', + type: RpcEndpointType.Custom, + }), + fallbacks: [], + }, + config: { + ...buildMockConfigRegistryControllerNetwork().config, + isAutoEnabled: true, + }, + }), + buildMockConfigRegistryControllerNetwork({ + chainId: 'eip155:9999', + rpcProviders: { + default: buildCustomRpcEndpoint({ + url: 'https://test.network/1', + type: RpcEndpointType.Custom, + }), + fallbacks: [], + }, + config: { + ...buildMockConfigRegistryControllerNetwork().config, + isAutoEnabled: true, + }, + }), + ]; + await withController( + { configRegistryNetworkConfigs: networkConfigs }, + async ({ controller, messenger, networkControllerMessenger }) => { + messenger.publish( + 'ConfigRegistryController:stateChanged', + { + configs: { networks: { 'eip155:9999': networkConfigs[1] } }, + lastFetched: 0, + etag: 'etag', + version: '1', }, + [], + ); + + expect( + networkControllerMessenger.captureException, + ).toHaveBeenCalled(); + expect( + controller.state.networkConfigurationsByChainId, + ).not.toHaveProperty('0x270e'); + expect( + controller.state.networkConfigurationsByChainId, + ).toHaveProperty( + '0x270f', + expect.objectContaining({ + chainId: '0x270f', + name: networkConfigs[2].name, + nativeCurrency: networkConfigs[2].assets.native.symbol, + blockExplorerUrls: [networkConfigs[2].blockExplorerUrls.default], + defaultBlockExplorerUrlIndex: 0, + rpcEndpoints: [ + expect.objectContaining({ + url: networkConfigs[2].rpcProviders.default.url, + type: networkConfigs[2].rpcProviders.default.type, + }), + ], + defaultRpcEndpointIndex: 0, + }), ); + }, + ); + }); + }); + + describe('destroy', () => { + it('does not throw if called before the provider is initialized', async () => { + await withController(async ({ controller }) => { + expect(await controller.destroy()).toBeUndefined(); + }); + }); + + it('stops the block tracker for the currently selected network as long as the provider has been initialized', async () => { + await withController(async ({ controller }) => { + const fakeProvider = buildFakeProvider([ + { + request: { method: 'eth_blockNumber' }, + response: { result: '0x1' }, + }, + ]); + const fakeNetworkClient = buildFakeClient(fakeProvider); + mockCreateNetworkClient().mockReturnValue(fakeNetworkClient); + await controller.lookupNetwork(); + const { blockTracker } = controller.getProviderAndBlockTracker(); + assert(blockTracker, 'Block tracker is somehow unset'); + // The block tracker starts running after a listener is attached + blockTracker.addListener('latest', () => { + // do nothing }); + expect(blockTracker.isRunning()).toBe(true); + + await controller.destroy(); + + expect(blockTracker.isRunning()).toBe(false); + }); + }); + }); + + describe('getProviderAndBlockTracker', () => { + it('returns objects that proxy to the provider and block tracker as long as the provider has been initialized', async () => { + await withController(async ({ controller }) => { + const fakeProvider = buildFakeProvider(); + const fakeNetworkClient = buildFakeClient(fakeProvider); + mockCreateNetworkClient().mockReturnValue(fakeNetworkClient); + await controller.lookupNetwork(); + + const { provider, blockTracker } = + controller.getProviderAndBlockTracker(); + + expect(provider).toHaveProperty('request'); + expect(blockTracker).toHaveProperty('checkForLatestBlock'); + }); + }); + + it("returns undefined for both the provider and block tracker if the provider hasn't been initialized yet", async () => { + await withController( + { initializeController: false }, + async ({ controller }) => { + const { provider, blockTracker } = + controller.getProviderAndBlockTracker(); + + expect(provider).toBeUndefined(); + expect(blockTracker).toBeUndefined(); + }, + ); + }); + + for (const infuraNetworkType of INFURA_NETWORKS) { + const infuraChainId = ChainId[infuraNetworkType]; + const infuraNetworkNickname = NetworkNickname[infuraNetworkType]; + + describe(`when the selectedNetworkClientId is changed to represent the Infura network "${infuraNetworkType}"`, () => { + it(`returns a provider object that was pointed to another network before the switch and is now pointed to ${infuraNetworkNickname} afterward`, async () => { + const infuraProjectId = 'some-infura-project-id'; - it('captures the resulting provider of the matching network client', async () => { await withController( { state: { - providerConfig: buildProviderConfig({ - type: networkType, - }), + selectedNetworkClientId: 'AAAA-AAAA-AAAA-AAAA', + networkConfigurationsByChainId: { + '0x1337': buildCustomNetworkConfiguration({ + nativeCurrency: 'TEST', + rpcEndpoints: [ + buildCustomRpcEndpoint({ + networkClientId: 'AAAA-AAAA-AAAA-AAAA', + }), + ], + }), + [infuraChainId]: + buildInfuraNetworkConfiguration(infuraNetworkType), + }, }, - infuraProjectId: 'some-infura-project-id', + infuraProjectId, }, async ({ controller }) => { - const fakeProvider = buildFakeProvider([ - { - request: { - method: 'test_method', - params: [], + const fakeProviders = [ + buildFakeProvider([ + { + request: { + method: 'test_method', + }, + response: { + result: 'test response 1', + }, }, - response: { - result: 'test response', + ]), + buildFakeProvider([ + { + request: { + method: 'test_method', + }, + response: { + result: 'test response 2', + }, }, + ]), + ]; + const fakeNetworkClients = [ + buildFakeClient(fakeProviders[0]), + buildFakeClient(fakeProviders[1]), + ]; + createNetworkClientMock.mockImplementation( + ({ configuration }) => { + if (configuration.chainId === '0x1337') { + return fakeNetworkClients[0]; + } else if (configuration.chainId === infuraChainId) { + return fakeNetworkClients[1]; + } + throw new Error( + `Unknown network client configuration ${JSON.stringify( + configuration, + )}`, + ); }, - ]); - const fakeNetworkClient = buildFakeClient(fakeProvider); - createNetworkClientMock.mockReturnValue(fakeNetworkClient); + ); + await controller.lookupNetwork(); + const { provider } = controller.getProviderAndBlockTracker(); + assert(provider, 'Provider not set'); - await controller.initializeProvider(); + const result1 = await provider.request({ + id: '1', + jsonrpc: '2.0', + method: 'test_method', + }); + expect(result1).toBe('test response 1'); - const { provider } = controller.getProviderAndBlockTracker(); - assert(provider, 'Provider is not set'); - const { result } = await promisify(provider.sendAsync).call( - provider, - { - id: 1, - jsonrpc: '2.0', - method: 'test_method', - params: [], - }, - ); - expect(result).toBe('test response'); + await controller.setActiveNetwork(infuraNetworkType); + const result2 = await provider.request({ + id: '2', + jsonrpc: '2.0', + method: 'test_method', + }); + expect(result2).toBe('test response 2'); }, ); }); - - lookupNetworkTests({ - expectedProviderConfig: buildProviderConfig({ type: networkType }), - initialState: { - providerConfig: buildProviderConfig({ type: networkType }), - }, - operation: async (controller: NetworkController) => { - await controller.initializeProvider(); - }, - }); }); } - describe('when the type in the provider config is "rpc"', () => { - describe('if the provider config points to a network configuration', () => { - it('creates a network client for the custom RPC endpoint described by the network configuration, not the provider config', async () => { - await withController( - { - state: { - providerConfig: { - type: NetworkType.rpc, - chainId: toHex(2), - rpcUrl: 'https://test.network.2', - id: 'AAAA-AAAA-AAAA-AAAA', - ticker: 'TEST', - }, - networkConfigurations: { - 'AAAA-AAAA-AAAA-AAAA': { - id: 'AAAA-AAAA-AAAA-AAAA', - rpcUrl: 'https://test.network.1', - chainId: toHex(1), - ticker: 'TEST', - }, - }, + describe('when the selectedNetworkClientId is changed to represent a custom RPC endpoint', () => { + it('returns a provider object that was pointed to another network before the switch and is now pointed to the new network', async () => { + const infuraProjectId = 'some-infura-project-id'; + + await withController( + { + state: { + selectedNetworkClientId: TESTNET.networkType, + networkConfigurationsByChainId: { + [TESTNET.chainId]: buildInfuraNetworkConfiguration( + TESTNET.networkType, + ), + '0x1337': buildCustomNetworkConfiguration({ + chainId: '0x1337', + rpcEndpoints: [ + buildCustomRpcEndpoint({ + networkClientId: 'AAAA-AAAA-AAAA-AAAA', + }), + ], + }), }, }, - async ({ controller }) => { - const fakeProvider = buildFakeProvider([ + infuraProjectId, + }, + async ({ controller }) => { + const fakeProviders = [ + buildFakeProvider([ { request: { method: 'test_method', - params: [], }, response: { - result: 'test response', + result: 'test response 1', }, }, - ]); - const fakeNetworkClient = buildFakeClient(fakeProvider); - createNetworkClientMock.mockReturnValue(fakeNetworkClient); - - await controller.initializeProvider(); - - expect(createNetworkClientMock).toHaveBeenCalledWith({ - chainId: toHex(1), - rpcUrl: 'https://test.network.1', - type: NetworkClientType.Custom, - ticker: 'TEST', - }); - expect(createNetworkClientMock).toHaveBeenCalledTimes(1); - }, - ); - }); - - it('captures the resulting provider of the new network client', async () => { - await withController( - { - state: { - providerConfig: { - type: NetworkType.rpc, - chainId: toHex(2), - rpcUrl: 'https://test.network.2', - id: 'AAAA-AAAA-AAAA-AAAA', - ticker: 'TEST', - }, - networkConfigurations: { - 'AAAA-AAAA-AAAA-AAAA': { - id: 'AAAA-AAAA-AAAA-AAAA', - rpcUrl: 'https://test.network.1', - chainId: toHex(1), - ticker: 'TEST', - }, - }, - }, - }, - async ({ controller }) => { - const fakeProvider = buildFakeProvider([ + ]), + buildFakeProvider([ { request: { method: 'test_method', - params: [], }, response: { - result: 'test response', + result: 'test response 2', }, }, - ]); - const fakeNetworkClient = buildFakeClient(fakeProvider); - createNetworkClientMock.mockReturnValue(fakeNetworkClient); + ]), + ]; + const fakeNetworkClients = [ + buildFakeClient(fakeProviders[0]), + buildFakeClient(fakeProviders[1]), + ]; + createNetworkClientMock.mockImplementation(({ configuration }) => { + if (configuration.chainId === TESTNET.chainId) { + return fakeNetworkClients[0]; + } else if (configuration.chainId === '0x1337') { + return fakeNetworkClients[1]; + } + throw new Error( + `Unknown network client configuration ${JSON.stringify( + configuration, + )}`, + ); + }); + await controller.lookupNetwork(); + const { provider } = controller.getProviderAndBlockTracker(); + assert(provider, 'Provider not set'); - await controller.initializeProvider(); + const result1 = await provider.request({ + id: '1', + jsonrpc: '2.0', + method: 'test_method', + }); + expect(result1).toBe('test response 1'); - const { provider } = controller.getProviderAndBlockTracker(); - assert(provider, 'Provider is not set'); - const { result } = await promisify(provider.sendAsync).call( - provider, - { - id: 1, - jsonrpc: '2.0', - method: 'test_method', - params: [], - }, - ); - expect(result).toBe('test response'); + await controller.setActiveNetwork('AAAA-AAAA-AAAA-AAAA'); + const result2 = await provider.request({ + id: '2', + jsonrpc: '2.0', + method: 'test_method', + }); + expect(result2).toBe('test response 2'); + }, + ); + }); + }); + }); + + describe.each([ + [ + 'findNetworkClientIdByChainId', + ( + { + controller, + }: { + controller: NetworkController; + }, + args: Parameters, + ): ReturnType => + controller.findNetworkClientIdByChainId(...args), + ], + [ + 'NetworkController:findNetworkClientIdByChainId', + ( + { + messenger, + }: { + messenger: RootMessenger; + }, + args: Parameters, + ): ReturnType => + messenger.call( + 'NetworkController:findNetworkClientIdByChainId', + ...args, + ), + ], + ])('%s', (_desc, findNetworkClientIdByChainId) => { + it('returns the ID of the network client corresponding to the default RPC endpoint for the given chain', async () => { + await withController( + { + state: buildNetworkControllerStateWithDefaultSelectedNetworkClientId({ + networkConfigurationsByChainId: { + '0x1337': buildCustomNetworkConfiguration({ + chainId: '0x1337' as const, + defaultRpcEndpointIndex: 1, + rpcEndpoints: [ + buildCustomRpcEndpoint({ + networkClientId: 'AAAA-AAAA-AAAA-AAAA', + }), + buildCustomRpcEndpoint({ + networkClientId: 'BBBB-BBBB-BBBB-BBBB', + }), + ], + }), }, + }), + }, + ({ controller, messenger }) => { + const networkClientId = findNetworkClientIdByChainId( + { controller, messenger }, + ['0x1337'], ); - }); + + expect(networkClientId).toBe('BBBB-BBBB-BBBB-BBBB'); + }, + ); + }); + + it('throws if there are no network clients registered for the given chain', async () => { + await withController(({ controller, messenger }) => { + expect(() => + findNetworkClientIdByChainId({ controller, messenger }, ['0x999999']), + ).toThrow('Invalid chain ID "0x999999"'); }); + }); + }); + + describe('getNetworkClientById', () => { + describe('if passed an Infura network client ID', () => { + describe('if the ID refers to an existing Infura network client', () => { + it('returns the network client', async () => { + const infuraProjectId = 'some-infura-project-id'; - describe('if the provider config does not point to a network configuration, but matches one based on RPC URL (exactly)', () => { - it('creates a network client for the custom RPC endpoint described by the network configuration, not the provider config', async () => { await withController( { - state: { - providerConfig: { - type: NetworkType.rpc, - chainId: toHex(2), - rpcUrl: 'https://test.network', - ticker: 'TEST', - }, - networkConfigurations: { - 'AAAA-AAAA-AAAA-AAAA': { - id: 'AAAA-AAAA-AAAA-AAAA', - rpcUrl: 'https://test.network', - chainId: toHex(1), - ticker: 'TEST', - }, - }, - }, + infuraProjectId, }, async ({ controller }) => { - const fakeProvider = buildFakeProvider([ - { - request: { - method: 'test_method', - params: [], - }, - response: { - result: 'test response', - }, - }, - ]); - const fakeNetworkClient = buildFakeClient(fakeProvider); - createNetworkClientMock.mockReturnValue(fakeNetworkClient); - - await controller.initializeProvider(); + const networkClient = controller.getNetworkClientById( + NetworkType.mainnet, + ); - expect(createNetworkClientMock).toHaveBeenCalledWith({ - chainId: toHex(1), - rpcUrl: 'https://test.network', - type: NetworkClientType.Custom, - ticker: 'TEST', + expect(networkClient.configuration).toStrictEqual({ + chainId: ChainId[InfuraNetworkType.mainnet], + failoverRpcUrls: [], + infuraProjectId, + network: InfuraNetworkType.mainnet, + ticker: NetworksTicker[InfuraNetworkType.mainnet], + type: NetworkClientType.Infura, }); - expect(createNetworkClientMock).toHaveBeenCalledTimes(1); }, ); }); + }); + + describe('if the ID does not refer to an existing Infura network client', () => { + it('throws', async () => { + const infuraProjectId = 'some-infura-project-id'; - it('captures the resulting provider of the new network client', async () => { await withController( { - state: { - providerConfig: { - type: NetworkType.rpc, - chainId: toHex(2), - rpcUrl: 'https://test.network', - ticker: 'TEST', - }, - networkConfigurations: { - 'AAAA-AAAA-AAAA-AAAA': { - id: 'AAAA-AAAA-AAAA-AAAA', - rpcUrl: 'https://test.network', - chainId: toHex(1), - ticker: 'TEST', + state: + buildNetworkControllerStateWithDefaultSelectedNetworkClientId({ + networkConfigurationsByChainId: { + '0x1337': buildCustomNetworkConfiguration(), }, - }, - }, + }), + infuraProjectId, }, async ({ controller }) => { - const fakeProvider = buildFakeProvider([ - { - request: { - method: 'test_method', - params: [], - }, - response: { - result: 'test response', - }, - }, - ]); - const fakeNetworkClient = buildFakeClient(fakeProvider); - createNetworkClientMock.mockReturnValue(fakeNetworkClient); - - await controller.initializeProvider(); - - const { provider } = controller.getProviderAndBlockTracker(); - assert(provider, 'Provider is not set'); - const { result } = await promisify(provider.sendAsync).call( - provider, - { - id: 1, - jsonrpc: '2.0', - method: 'test_method', - params: [], - }, - ); - expect(result).toBe('test response'); + expect(() => + controller.getNetworkClientById(NetworkType.mainnet), + ).toThrow('No network client was found with ID "mainnet".'); }, ); }); }); + }); - describe('if the provider config does not point to a network configuration, but matches one based on RPC URL (case-insensitively)', () => { - it('creates a network client for the custom RPC endpoint described by the network configuration, not the provider config', async () => { + describe('if passed a custom network client ID', () => { + describe('if the ID refers to an existing custom network client', () => { + it('returns the network client', async () => { await withController( { - state: { - providerConfig: { - type: NetworkType.rpc, - chainId: toHex(2), - rpcUrl: 'HTTPS://TEST.NETWORK', - ticker: 'TEST', - }, - networkConfigurations: { - 'AAAA-AAAA-AAAA-AAAA': { - id: 'AAAA-AAAA-AAAA-AAAA', - rpcUrl: 'https://test.network', - chainId: toHex(1), - ticker: 'TEST', + state: + buildNetworkControllerStateWithDefaultSelectedNetworkClientId({ + networkConfigurationsByChainId: { + '0x1337': buildCustomNetworkConfiguration({ + chainId: '0x1337', + nativeCurrency: 'TEST', + rpcEndpoints: [ + buildCustomRpcEndpoint({ + failoverUrls: ['https://failover.endpoint'], + networkClientId: 'AAAA-AAAA-AAAA-AAAA', + url: 'https://test.network', + }), + ], + }), }, - }, - }, + }), + infuraProjectId: 'some-infura-project-id', }, async ({ controller }) => { - const fakeProvider = buildFakeProvider([ - { - request: { - method: 'test_method', - params: [], - }, - response: { - result: 'test response', - }, - }, - ]); - const fakeNetworkClient = buildFakeClient(fakeProvider); - createNetworkClientMock.mockReturnValue(fakeNetworkClient); - - await controller.initializeProvider(); + const networkClient = controller.getNetworkClientById( + 'AAAA-AAAA-AAAA-AAAA', + ); - expect(createNetworkClientMock).toHaveBeenCalledWith({ - chainId: toHex(1), + expect(networkClient.configuration).toStrictEqual({ + chainId: '0x1337', + failoverRpcUrls: ['https://failover.endpoint'], rpcUrl: 'https://test.network', - type: NetworkClientType.Custom, ticker: 'TEST', + type: NetworkClientType.Custom, }); - expect(createNetworkClientMock).toHaveBeenCalledTimes(1); }, ); }); + }); - it('captures the resulting provider of the new network client', async () => { + describe('if the ID does not refer to an existing custom network client', () => { + it('throws', async () => { await withController( { - state: { - providerConfig: { - type: NetworkType.rpc, - chainId: toHex(2), - rpcUrl: 'HTTPS://TEST.NETWORK', - ticker: 'TEST', - }, - networkConfigurations: { - 'AAAA-AAAA-AAAA-AAAA': { - id: 'AAAA-AAAA-AAAA-AAAA', - rpcUrl: 'https://test.network', - chainId: toHex(1), - ticker: 'TEST', + state: + buildNetworkControllerStateWithDefaultSelectedNetworkClientId({ + networkConfigurationsByChainId: { + '0x2448': buildCustomNetworkConfiguration({ + chainId: '0x2448', + }), }, - }, - }, + }), }, async ({ controller }) => { - const fakeProvider = buildFakeProvider([ - { - request: { - method: 'test_method', - params: [], - }, - response: { - result: 'test response', - }, - }, - ]); - const fakeNetworkClient = buildFakeClient(fakeProvider); - createNetworkClientMock.mockReturnValue(fakeNetworkClient); - - await controller.initializeProvider(); - - const { provider } = controller.getProviderAndBlockTracker(); - assert(provider, 'Provider is not set'); - const { result } = await promisify(provider.sendAsync).call( - provider, - { - id: 1, - jsonrpc: '2.0', - method: 'test_method', - params: [], - }, + expect(() => controller.getNetworkClientById('0x1337')).toThrow( + 'No network client was found with ID "0x1337".', ); - expect(result).toBe('test response'); }, ); }); }); + }); - describe('if the provider config does not point to or match a network configuration', () => { - describe('if the provider config has a chain ID and RPC URL', () => { - it('creates a network client for a custom RPC endpoint using the provider config', async () => { - await withController( - { - state: { - providerConfig: { - type: NetworkType.rpc, - chainId: toHex(1337), - rpcUrl: 'http://example.com', - ticker: 'TEST', - }, - }, - }, - async ({ controller }) => { - const fakeProvider = buildFakeProvider([ - { - request: { - method: 'test_method', - params: [], - }, - response: { - result: 'test response', - }, - }, - ]); - const fakeNetworkClient = buildFakeClient(fakeProvider); - createNetworkClientMock.mockReturnValue(fakeNetworkClient); - - await controller.initializeProvider(); + describe('if the controller was initialized with failoverUrls', () => { + it('applies the chain-level failover URLs to an Infura network client, overriding the endpoint value', async () => { + const infuraProjectId = 'some-infura-project-id'; - expect(createNetworkClientMock).toHaveBeenCalledWith({ - chainId: toHex(1337), - rpcUrl: 'http://example.com', - type: NetworkClientType.Custom, - ticker: 'TEST', - }); - expect(createNetworkClientMock).toHaveBeenCalledTimes(1); - }, + await withController( + { + infuraProjectId, + failoverUrls: { + [ChainId[InfuraNetworkType.mainnet]]: ['https://chain.failover'], + }, + }, + async ({ controller }) => { + const networkClient = controller.getNetworkClientById( + NetworkType.mainnet, ); - }); - - it('captures the resulting provider of the new network client', async () => { - await withController( - { - state: { - providerConfig: { - type: NetworkType.rpc, - chainId: toHex(1337), - rpcUrl: 'http://example.com', - ticker: 'TEST', - }, - }, - }, - async ({ controller }) => { - const fakeProvider = buildFakeProvider([ - { - request: { - method: 'test_method', - params: [], - }, - response: { - result: 'test response', - }, - }, - ]); - const fakeNetworkClient = buildFakeClient(fakeProvider); - createNetworkClientMock.mockReturnValue(fakeNetworkClient); - await controller.initializeProvider(); + expect(networkClient.configuration).toStrictEqual({ + chainId: ChainId[InfuraNetworkType.mainnet], + failoverRpcUrls: ['https://chain.failover'], + infuraProjectId, + network: InfuraNetworkType.mainnet, + ticker: NetworksTicker[InfuraNetworkType.mainnet], + type: NetworkClientType.Infura, + }); + }, + ); + }); - const { provider } = controller.getProviderAndBlockTracker(); - assert(provider, 'Provider is not set'); - const { result } = await promisify(provider.sendAsync).call( - provider, - { - id: 1, - jsonrpc: '2.0', - method: 'test_method', - params: [], - }, - ); - expect(result).toBe('test response'); - }, + it('applies the chain-level failover URLs to a custom network client, overriding the endpoint value', async () => { + await withController( + { + state: + buildNetworkControllerStateWithDefaultSelectedNetworkClientId({ + networkConfigurationsByChainId: { + '0x1337': buildCustomNetworkConfiguration({ + chainId: '0x1337', + nativeCurrency: 'TEST', + rpcEndpoints: [ + buildCustomRpcEndpoint({ + failoverUrls: ['https://endpoint.failover'], + networkClientId: 'AAAA-AAAA-AAAA-AAAA', + url: 'https://test.network', + }), + ], + }), + }, + }), + infuraProjectId: 'some-infura-project-id', + failoverUrls: { + '0x1337': ['https://chain.failover'], + }, + }, + async ({ controller }) => { + const networkClient = controller.getNetworkClientById( + 'AAAA-AAAA-AAAA-AAAA', ); - }); - lookupNetworkTests({ - expectedProviderConfig: buildProviderConfig({ - type: NetworkType.rpc, - }), - initialState: { - providerConfig: buildProviderConfig({ - type: NetworkType.rpc, + expect(networkClient.configuration).toStrictEqual({ + chainId: '0x1337', + failoverRpcUrls: ['https://chain.failover'], + rpcUrl: 'https://test.network', + ticker: 'TEST', + type: NetworkClientType.Custom, + }); + }, + ); + }); + + it('falls back to the endpoint failover URLs when no entry exists for the chain', async () => { + await withController( + { + state: + buildNetworkControllerStateWithDefaultSelectedNetworkClientId({ + networkConfigurationsByChainId: { + '0x1337': buildCustomNetworkConfiguration({ + chainId: '0x1337', + nativeCurrency: 'TEST', + rpcEndpoints: [ + buildCustomRpcEndpoint({ + failoverUrls: ['https://endpoint.failover'], + networkClientId: 'AAAA-AAAA-AAAA-AAAA', + url: 'https://test.network', + }), + ], + }), + }, }), + infuraProjectId: 'some-infura-project-id', + failoverUrls: { + '0x9999': ['https://chain.failover'], }, - operation: async (controller: NetworkController) => { - await controller.initializeProvider(); - }, - }); - }); + }, + async ({ controller }) => { + const networkClient = controller.getNetworkClientById( + 'AAAA-AAAA-AAAA-AAAA', + ); - describe('if the chain ID is missing from the provider config', () => { - it('throws', async () => { - await withController( - { - state: { - providerConfig: buildProviderConfig({ - type: NetworkType.rpc, - chainId: undefined, - }), + expect(networkClient.configuration).toStrictEqual({ + chainId: '0x1337', + failoverRpcUrls: ['https://endpoint.failover'], + rpcUrl: 'https://test.network', + ticker: 'TEST', + type: NetworkClientType.Custom, + }); + }, + ); + }); + }); + }); + + describe('getNetworkClientRegistry', () => { + describe('if no network configurations were specified at initialization', () => { + it('returns network clients for default RPC endpoints, keyed by network client ID', async () => { + const infuraProjectId = 'some-infura-project-id'; + + await withController( + { + infuraProjectId, + }, + async ({ controller }) => { + mockCreateNetworkClient().mockReturnValue(buildFakeClient()); + + expect(controller.getNetworkClientRegistry()).toStrictEqual({ + 'arbitrum-mainnet': { + blockTracker: expect.anything(), + configuration: { + type: NetworkClientType.Infura, + failoverRpcUrls: [], + infuraProjectId, + chainId: '0xa4b1', + ticker: 'ETH', + network: InfuraNetworkType['arbitrum-mainnet'], }, + provider: expect.anything(), + destroy: expect.any(Function), + setRpcFailoverMode: expect.any(Function), }, - async ({ controller }) => { - const fakeProvider = buildFakeProvider(); - const fakeNetworkClient = buildFakeClient(fakeProvider); - createNetworkClientMock.mockReturnValue(fakeNetworkClient); - - await expect(() => - controller.initializeProvider(), - ).rejects.toThrow( - 'chainId must be provided for custom RPC endpoints', - ); + 'base-mainnet': { + blockTracker: expect.anything(), + configuration: { + type: NetworkClientType.Infura, + failoverRpcUrls: [], + infuraProjectId, + chainId: '0x2105', + ticker: 'ETH', + network: InfuraNetworkType['base-mainnet'], + }, + provider: expect.anything(), + destroy: expect.any(Function), + setRpcFailoverMode: expect.any(Function), }, - ); - }); - - it('does not create a network client or capture a provider', async () => { - await withController( - { - state: { - providerConfig: buildProviderConfig({ - type: NetworkType.rpc, - chainId: undefined, - }), + 'bsc-mainnet': { + blockTracker: expect.anything(), + configuration: { + type: NetworkClientType.Infura, + failoverRpcUrls: [], + infuraProjectId, + chainId: '0x38', + ticker: 'BNB', + network: InfuraNetworkType['bsc-mainnet'], }, + provider: expect.anything(), + destroy: expect.any(Function), + setRpcFailoverMode: expect.any(Function), }, - async ({ controller }) => { - const fakeProvider = buildFakeProvider(); - const fakeNetworkClient = buildFakeClient(fakeProvider); - createNetworkClientMock.mockReturnValue(fakeNetworkClient); - - try { - await controller.initializeProvider(); - } catch { - // ignore the error - } - - expect(createNetworkClientMock).not.toHaveBeenCalled(); - const { provider, blockTracker } = - controller.getProviderAndBlockTracker(); - expect(provider).toBeUndefined(); - expect(blockTracker).toBeUndefined(); + 'linea-mainnet': { + blockTracker: expect.anything(), + configuration: { + type: NetworkClientType.Infura, + failoverRpcUrls: [], + infuraProjectId, + chainId: '0xe708', + ticker: 'ETH', + network: InfuraNetworkType['linea-mainnet'], + }, + provider: expect.anything(), + destroy: expect.any(Function), + setRpcFailoverMode: expect.any(Function), }, - ); - }); - }); + 'linea-sepolia': { + blockTracker: expect.anything(), + configuration: { + type: NetworkClientType.Infura, + failoverRpcUrls: [], + infuraProjectId, + chainId: '0xe705', + ticker: 'LineaETH', + network: InfuraNetworkType['linea-sepolia'], + }, + provider: expect.anything(), + destroy: expect.any(Function), + setRpcFailoverMode: expect.any(Function), + }, + mainnet: { + blockTracker: expect.anything(), + configuration: { + type: NetworkClientType.Infura, + failoverRpcUrls: [], + infuraProjectId, + chainId: '0x1', + ticker: 'ETH', + network: InfuraNetworkType.mainnet, + }, + provider: expect.anything(), + destroy: expect.any(Function), + setRpcFailoverMode: expect.any(Function), + }, + 'megaeth-testnet-v2': { + blockTracker: expect.anything(), + configuration: { + type: NetworkClientType.Custom, + failoverRpcUrls: [], + chainId: '0x18c7', + ticker: 'MegaETH', + rpcUrl: 'https://carrot.megaeth.com/rpc', + }, + provider: expect.anything(), + destroy: expect.any(Function), + setRpcFailoverMode: expect.any(Function), + }, + 'monad-mainnet': { + blockTracker: expect.anything(), + configuration: { + type: NetworkClientType.Infura, + failoverRpcUrls: [], + infuraProjectId, + chainId: '0x8f', + ticker: 'MON', + network: InfuraNetworkType['monad-mainnet'], + }, + provider: expect.anything(), + destroy: expect.any(Function), + setRpcFailoverMode: expect.any(Function), + }, + 'monad-testnet': { + blockTracker: expect.anything(), + configuration: { + type: NetworkClientType.Custom, + failoverRpcUrls: [], + chainId: '0x279f', + ticker: 'MON', + rpcUrl: 'https://testnet-rpc.monad.xyz', + }, + provider: expect.anything(), + destroy: expect.any(Function), + setRpcFailoverMode: expect.any(Function), + }, + 'optimism-mainnet': { + blockTracker: expect.anything(), + configuration: { + type: NetworkClientType.Infura, + failoverRpcUrls: [], + infuraProjectId, + chainId: '0xa', + ticker: 'ETH', + network: InfuraNetworkType['optimism-mainnet'], + }, + provider: expect.anything(), + destroy: expect.any(Function), + setRpcFailoverMode: expect.any(Function), + }, + 'polygon-mainnet': { + blockTracker: expect.anything(), + configuration: { + type: NetworkClientType.Infura, + failoverRpcUrls: [], + infuraProjectId, + chainId: '0x89', + ticker: 'POL', + network: InfuraNetworkType['polygon-mainnet'], + }, + provider: expect.anything(), + destroy: expect.any(Function), + setRpcFailoverMode: expect.any(Function), + }, + sepolia: { + blockTracker: expect.anything(), + configuration: { + type: NetworkClientType.Infura, + failoverRpcUrls: [], + infuraProjectId, + chainId: '0xaa36a7', + ticker: 'SepoliaETH', + network: InfuraNetworkType.sepolia, + }, + provider: expect.anything(), + destroy: expect.any(Function), + setRpcFailoverMode: expect.any(Function), + }, + }); + }, + ); + }); + }); - describe('if the RPC URL is missing from the provider config', () => { - it('throws', async () => { - await withController( - { - state: { - providerConfig: buildProviderConfig({ - type: NetworkType.rpc, - rpcUrl: undefined, + describe('if some network configurations were specified at initialization', () => { + it('returns network clients for all RPC endpoints within any defined network configurations, keyed by network client ID, and does not include Infura-supported chains by default', async () => { + await withController( + { + state: + buildNetworkControllerStateWithDefaultSelectedNetworkClientId({ + networkConfigurationsByChainId: { + '0x1337': buildCustomNetworkConfiguration({ + chainId: '0x1337', + nativeCurrency: 'TOKEN1', + rpcEndpoints: [ + buildCustomRpcEndpoint({ + failoverUrls: ['https://first.failover.endpoint'], + networkClientId: 'AAAA-AAAA-AAAA-AAAA', + url: 'https://test.network/1', + }), + ], + }), + '0x2448': buildCustomNetworkConfiguration({ + chainId: '0x2448', + nativeCurrency: 'TOKEN2', + rpcEndpoints: [ + buildCustomRpcEndpoint({ + failoverUrls: ['https://second.failover.endpoint'], + networkClientId: 'BBBB-BBBB-BBBB-BBBB', + url: 'https://test.network/2', + }), + ], }), }, - }, - async ({ controller }) => { - const fakeProvider = buildFakeProvider(); - const fakeNetworkClient = buildFakeClient(fakeProvider); - createNetworkClientMock.mockReturnValue(fakeNetworkClient); + }), + }, + async ({ controller }) => { + mockCreateNetworkClient().mockReturnValue(buildFakeClient()); - await expect(() => - controller.initializeProvider(), - ).rejects.toThrow( - 'rpcUrl must be provided for custom RPC endpoints', - ); + expect(controller.getNetworkClientRegistry()).toStrictEqual({ + 'AAAA-AAAA-AAAA-AAAA': { + blockTracker: expect.anything(), + configuration: { + chainId: '0x1337', + failoverRpcUrls: ['https://first.failover.endpoint'], + rpcUrl: 'https://test.network/1', + ticker: 'TOKEN1', + type: NetworkClientType.Custom, + }, + provider: expect.anything(), + destroy: expect.any(Function), + setRpcFailoverMode: expect.any(Function), }, - ); - }); + 'BBBB-BBBB-BBBB-BBBB': { + blockTracker: expect.anything(), + configuration: { + chainId: '0x2448', + failoverRpcUrls: ['https://second.failover.endpoint'], + rpcUrl: 'https://test.network/2', + ticker: 'TOKEN2', + type: NetworkClientType.Custom, + }, + provider: expect.anything(), + destroy: expect.any(Function), + setRpcFailoverMode: expect.any(Function), + }, + }); + }, + ); + }); + }); - it('does not create a network client or capture a provider', async () => { - await withController( - { - state: { - providerConfig: buildProviderConfig({ - type: NetworkType.rpc, - rpcUrl: undefined, + describe('if the controller was initialized with failoverUrls', () => { + it('applies the chain-level failover URLs to every endpoint on a matched chain, keeping endpoint URLs for unmatched chains', async () => { + await withController( + { + state: + buildNetworkControllerStateWithDefaultSelectedNetworkClientId({ + networkConfigurationsByChainId: { + '0x1337': buildCustomNetworkConfiguration({ + chainId: '0x1337', + nativeCurrency: 'TOKEN1', + rpcEndpoints: [ + buildCustomRpcEndpoint({ + failoverUrls: ['https://first.endpoint.failover'], + networkClientId: 'AAAA-AAAA-AAAA-AAAA', + url: 'https://test.network/1', + }), + buildCustomRpcEndpoint({ + failoverUrls: ['https://second.endpoint.failover'], + networkClientId: 'BBBB-BBBB-BBBB-BBBB', + url: 'https://test.network/2', + }), + ], + }), + '0x2448': buildCustomNetworkConfiguration({ + chainId: '0x2448', + nativeCurrency: 'TOKEN2', + rpcEndpoints: [ + buildCustomRpcEndpoint({ + failoverUrls: ['https://third.endpoint.failover'], + networkClientId: 'CCCC-CCCC-CCCC-CCCC', + url: 'https://test.network/3', + }), + ], }), }, - }, - async ({ controller }) => { - const fakeProvider = buildFakeProvider(); - const fakeNetworkClient = buildFakeClient(fakeProvider); - createNetworkClientMock.mockReturnValue(fakeNetworkClient); + }), + failoverUrls: { + '0x1337': ['https://chain.failover'], + }, + }, + async ({ controller }) => { + mockCreateNetworkClient().mockReturnValue(buildFakeClient()); - try { - await controller.initializeProvider(); - } catch { - // ignore the error - } + const registry = controller.getNetworkClientRegistry(); - expect(createNetworkClientMock).not.toHaveBeenCalled(); - const { provider, blockTracker } = - controller.getProviderAndBlockTracker(); - expect(provider).toBeUndefined(); - expect(blockTracker).toBeUndefined(); - }, - ); - }); - }); + expect( + registry['AAAA-AAAA-AAAA-AAAA'].configuration.failoverRpcUrls, + ).toStrictEqual(['https://chain.failover']); + expect( + registry['BBBB-BBBB-BBBB-BBBB'].configuration.failoverRpcUrls, + ).toStrictEqual(['https://chain.failover']); + expect( + registry['CCCC-CCCC-CCCC-CCCC'].configuration.failoverRpcUrls, + ).toStrictEqual(['https://third.endpoint.failover']); + }, + ); }); }); }); - describe('getProviderAndBlockTracker', () => { - it('returns objects that proxy to the provider and block tracker as long as the provider has been initialized', async () => { - await withController(async ({ controller }) => { - const fakeProvider = buildFakeProvider(); - const fakeNetworkClient = buildFakeClient(fakeProvider); - mockCreateNetworkClient().mockReturnValue(fakeNetworkClient); - await controller.initializeProvider(); + describe('lookupNetwork', () => { + for (const infuraNetworkType of INFURA_NETWORKS) { + describe(`given a network client ID that represents the Infura network "${infuraNetworkType}"`, () => { + lookupNetworkTests({ + expectedNetworkClientType: NetworkClientType.Infura, + expectedNetworkClientId: infuraNetworkType, + operation: async (controller) => { + await controller.lookupNetwork(infuraNetworkType); + }, + shouldTestInfuraMessengerEvents: false, + }); + }); + } - const { provider, blockTracker } = - controller.getProviderAndBlockTracker(); + describe('given a network client that represents a custom RPC endpoint', () => { + const networkClientId = 'BBBB-BBBB-BBBB-BBBB'; - expect(provider).toHaveProperty('sendAsync'); - expect(blockTracker).toHaveProperty('checkForLatestBlock'); + lookupNetworkTests({ + expectedNetworkClientType: NetworkClientType.Custom, + expectedNetworkClientId: networkClientId, + initialState: { + selectedNetworkClientId: 'AAAA-AAAA-AAAA-AAAA', + networkConfigurationsByChainId: { + '0x1337': buildCustomNetworkConfiguration({ + chainId: '0x1337', + nativeCurrency: 'TEST', + rpcEndpoints: [ + buildCustomRpcEndpoint({ + networkClientId: 'AAAA-AAAA-AAAA-AAAA', + url: 'https://test.network/1', + }), + buildCustomRpcEndpoint({ + networkClientId: 'BBBB-BBBB-BBBB-BBBB', + url: 'https://test.network/2', + }), + ], + }), + }, + }, + operation: async (controller) => { + await controller.lookupNetwork(networkClientId); + }, + shouldTestInfuraMessengerEvents: false, }); }); - it("returns undefined for both the provider and block tracker if the provider hasn't been initialized yet", async () => { - await withController(async ({ controller }) => { - const { provider, blockTracker } = - controller.getProviderAndBlockTracker(); - - expect(provider).toBeUndefined(); - expect(blockTracker).toBeUndefined(); + describe('given an invalid network client ID', () => { + it('throws an error', async () => { + await withController(async ({ controller }) => { + await expect(() => + controller.lookupNetwork('non-existent-network-id'), + ).rejects.toThrow( + 'No network client was found with ID "non-existent-network-id".', + ); + }); }); }); - for (const { networkType } of INFURA_NETWORKS) { - describe(`when the type in the provider configuration is changed to "${networkType}"`, () => { - it(`returns a provider object that was pointed to another network before the switch and is pointed to "${networkType}" afterward`, async () => { - await withController( - { - state: { - providerConfig: { - type: 'rpc', - rpcUrl: 'https://mock-rpc-url', - chainId: '0x1337', - ticker: 'TEST', - }, - }, - infuraProjectId: 'some-infura-project-id', - }, - async ({ controller }) => { - const fakeProviders = [ - buildFakeProvider([ - { - request: { - method: 'test', - }, - response: { - result: 'test response 1', - }, - }, - ]), - buildFakeProvider([ - { - request: { - method: 'test', - }, - response: { - result: 'test response 2', + describe('not given a network client ID', () => { + for (const infuraNetworkType of INFURA_NETWORKS) { + const infuraChainId = ChainId[infuraNetworkType]; + + describe(`when the selected network client represents the Infura network "${infuraNetworkType}"`, () => { + describe('if the provider has been not been initialized yet', () => { + it('does not update state', async () => { + await withController( + { + initializeController: false, + state: { + selectedNetworkClientId: infuraNetworkType, + networkConfigurationsByChainId: { + [infuraChainId]: + buildInfuraNetworkConfiguration(infuraNetworkType), }, }, - ]), - ]; - const fakeNetworkClients = [ - buildFakeClient(fakeProviders[0]), - buildFakeClient(fakeProviders[1]), - ]; - mockCreateNetworkClient() - .calledWith({ - chainId: '0x1337', - rpcUrl: 'https://mock-rpc-url', - type: NetworkClientType.Custom, - ticker: 'TEST', - }) - .mockReturnValue(fakeNetworkClients[0]) - .calledWith({ - network: networkType, - infuraProjectId: 'some-infura-project-id', - type: NetworkClientType.Infura, - chainId: BUILT_IN_NETWORKS[networkType].chainId, - ticker: BUILT_IN_NETWORKS[networkType].ticker, - }) - .mockReturnValue(fakeNetworkClients[1]); - await controller.initializeProvider(); - const { provider } = controller.getProviderAndBlockTracker(); - assert(provider, 'Provider is somehow unset'); + }, + async ({ controller, messenger }) => { + const stateChangeListener = jest.fn(); + messenger.subscribe( + 'NetworkController:stateChange', + stateChangeListener, + ); - const promisifiedSendAsync1 = promisify(provider.sendAsync).bind( - provider, - ); - const response1 = await promisifiedSendAsync1({ - id: '1', - jsonrpc: '2.0', - method: 'test', - }); - expect(response1.result).toBe('test response 1'); + await controller.lookupNetwork(); - await controller.setProviderType(networkType); - const promisifiedSendAsync2 = promisify(provider.sendAsync).bind( - provider, + expect(stateChangeListener).not.toHaveBeenCalled(); + }, ); - const response2 = await promisifiedSendAsync2({ - id: '2', - jsonrpc: '2.0', - method: 'test', - }); - expect(response2.result).toBe('test response 2'); - }, - ); - }); - }); - } + }); - describe('when the type in the provider configuration is changed to "rpc"', () => { - it('returns a provider object that was pointed to another network before the switch and is pointed to the new network', async () => { - await withController( - { - state: { - providerConfig: { - type: 'goerli', - // NOTE: This doesn't need to match the logical chain ID of - // the network selected, it just needs to exist - chainId: '0x9999999', - ticker: 'TEST', - }, - networkConfigurations: { - testNetworkConfigurationId: { - rpcUrl: 'https://mock-rpc-url', - chainId: '0x1337', - ticker: 'ABC', - id: 'testNetworkConfigurationId', - }, - }, - }, - infuraProjectId: 'some-infura-project-id', - }, - async ({ controller }) => { - const fakeProviders = [ - buildFakeProvider([ - { - request: { - method: 'test', - }, - response: { - result: 'test response 1', - }, - }, - ]), - buildFakeProvider([ + it('does not publish NetworkController:infuraIsUnblocked', async () => { + await withController( { - request: { - method: 'test', - }, - response: { - result: 'test response 2', + initializeController: false, + state: { + selectedNetworkClientId: infuraNetworkType, + networkConfigurationsByChainId: { + [infuraChainId]: + buildInfuraNetworkConfiguration(infuraNetworkType), + }, }, }, - ]), - ]; - const fakeNetworkClients = [ - buildFakeClient(fakeProviders[0]), - buildFakeClient(fakeProviders[1]), - ]; - mockCreateNetworkClient() - .calledWith({ - network: NetworkType.goerli, - infuraProjectId: 'some-infura-project-id', - type: NetworkClientType.Infura, - chainId: BUILT_IN_NETWORKS[NetworkType.goerli].chainId, - ticker: BUILT_IN_NETWORKS[InfuraNetworkType.goerli].ticker, - }) - .mockReturnValue(fakeNetworkClients[0]) - .calledWith({ - chainId: '0x1337', - rpcUrl: 'https://mock-rpc-url', - type: NetworkClientType.Custom, - ticker: 'ABC', - }) - .mockReturnValue(fakeNetworkClients[1]); - await controller.initializeProvider(); - const { provider } = controller.getProviderAndBlockTracker(); - assert(provider, 'Provider is somehow unset'); + async ({ controller, messenger }) => { + const infuraIsUnblockedListener = jest.fn(); + messenger.subscribe( + 'NetworkController:infuraIsUnblocked', + infuraIsUnblockedListener, + ); - const promisifiedSendAsync1 = promisify(provider.sendAsync).bind( - provider, - ); - const response1 = await promisifiedSendAsync1({ - id: '1', - jsonrpc: '2.0', - method: 'test', - }); - expect(response1.result).toBe('test response 1'); + await controller.lookupNetwork(); - await controller.setActiveNetwork('testNetworkConfigurationId'); - const promisifiedSendAsync2 = promisify(provider.sendAsync).bind( - provider, - ); - const response2 = await promisifiedSendAsync2({ - id: '2', - jsonrpc: '2.0', - method: 'test', + expect(infuraIsUnblockedListener).not.toHaveBeenCalled(); + }, + ); }); - expect(response2.result).toBe('test response 2'); - }, - ); - }); - }); - }); - - describe('findNetworkConfigurationByChainId', () => { - it('returns the network configuration for the given chainId', async () => { - await withController( - { infuraProjectId: 'some-infura-project-id' }, - async ({ controller }) => { - const fakeNetworkClient = buildFakeClient(); - mockCreateNetworkClient().mockReturnValue(fakeNetworkClient); - const networkClientId = - controller.findNetworkClientIdByChainId('0x1'); - expect(networkClientId).toBe('mainnet'); - }, - ); - }); + it('does not publish NetworkController:infuraIsBlocked', async () => { + await withController( + { + initializeController: false, + state: { + selectedNetworkClientId: infuraNetworkType, + networkConfigurationsByChainId: { + [infuraChainId]: + buildInfuraNetworkConfiguration(infuraNetworkType), + }, + }, + }, + async ({ controller, messenger }) => { + const infuraIsBlockedListener = jest.fn(); + messenger.subscribe( + 'NetworkController:infuraIsBlocked', + infuraIsBlockedListener, + ); - it('throws if the chainId doesnt exist in the configuration', async () => { - await withController( - { infuraProjectId: 'some-infura-project-id' }, - async ({ controller }) => { - const fakeNetworkClient = buildFakeClient(); - mockCreateNetworkClient().mockReturnValue(fakeNetworkClient); - expect(() => - controller.findNetworkClientIdByChainId('0xdeadbeef'), - ).toThrow("Couldn't find networkClientId for chainId"); - }, - ); - }); - }); + await controller.lookupNetwork(); - describe('getNetworkClientById', () => { - describe('If passed an existing networkClientId', () => { - it('returns a valid built-in Infura NetworkClient', async () => { - await withController( - { infuraProjectId: 'some-infura-project-id' }, - async ({ controller }) => { - const fakeNetworkClient = buildFakeClient(); - mockCreateNetworkClient().mockReturnValue(fakeNetworkClient); + expect(infuraIsBlockedListener).not.toHaveBeenCalled(); + }, + ); + }); + }); - const networkClientRegistry = controller.getNetworkClientRegistry(); - const networkClient = controller.getNetworkClientById( - NetworkType.mainnet, - ); + describe('if the network was switched after the eth_getBlockByNumber request started but before it completed', () => { + it('stores the network status of the second network, not the first', async () => { + const infuraProjectId = 'some-infura-project-id'; - expect(networkClient).toBe( - networkClientRegistry[NetworkType.mainnet], - ); - }, - ); - }); + await withController( + { + state: { + selectedNetworkClientId: infuraNetworkType, + networkConfigurationsByChainId: { + [infuraChainId]: + buildInfuraNetworkConfiguration(infuraNetworkType), + '0x1337': buildCustomNetworkConfiguration({ + chainId: '0x1337', + rpcEndpoints: [ + buildCustomRpcEndpoint({ + networkClientId: 'AAAA-AAAA-AAAA-AAAA', + }), + ], + }), + }, + }, + infuraProjectId, + }, + async ({ controller }) => { + const fakeProviders = [ + buildFakeProvider([ + // Called during provider initialization + { + request: { + method: 'eth_getBlockByNumber', + }, + response: SUCCESSFUL_ETH_GET_BLOCK_BY_NUMBER_RESPONSE, + }, + // Called via `lookupNetwork` directly + { + request: { + method: 'eth_getBlockByNumber', + }, + response: SUCCESSFUL_ETH_GET_BLOCK_BY_NUMBER_RESPONSE, + beforeCompleting: (): void => { + // We are purposefully not awaiting this promise. + // eslint-disable-next-line @typescript-eslint/no-floating-promises + controller.setActiveNetwork('AAAA-AAAA-AAAA-AAAA'); + }, + }, + ]), + buildFakeProvider([ + // Called when switching networks + { + request: { + method: 'eth_getBlockByNumber', + }, + error: GENERIC_JSON_RPC_ERROR, + }, + ]), + ]; + const fakeNetworkClients = [ + buildFakeClient(fakeProviders[0]), + buildFakeClient(fakeProviders[1]), + ]; + createNetworkClientMock.mockImplementation( + ({ configuration }) => { + if (configuration.chainId === infuraChainId) { + return fakeNetworkClients[0]; + } else if (configuration.chainId === '0x1337') { + return fakeNetworkClients[1]; + } + throw new Error( + `Unknown network client configuration ${JSON.stringify( + configuration, + )}`, + ); + }, + ); + await controller.lookupNetwork(); + expect( + controller.state.networksMetadata[infuraNetworkType].status, + ).toBe('available'); - it('returns a valid built-in Infura NetworkClient with a chainId in configuration', async () => { - await withController( - { infuraProjectId: 'some-infura-project-id' }, - async ({ controller }) => { - const fakeNetworkClient = buildFakeClient(); - mockCreateNetworkClient().mockReturnValue(fakeNetworkClient); + await controller.lookupNetwork(); - const networkClientRegistry = controller.getNetworkClientRegistry(); - const networkClient = controller.getNetworkClientById( - NetworkType.mainnet, - ); + expect( + controller.state.networksMetadata['AAAA-AAAA-AAAA-AAAA'] + .status, + ).toBe('unknown'); + }, + ); + }); - expect(networkClient.configuration.chainId).toBe('0x1'); - expect(networkClientRegistry.mainnet.configuration.chainId).toBe( - '0x1', - ); - }, - ); - }); + it('stores the EIP-1559 support of the second network, not the first', async () => { + const infuraProjectId = 'some-infura-project-id'; - it('returns a valid custom NetworkClient', async () => { - await withController( - { - state: { - networkConfigurations: { - testNetworkConfigurationId: { - rpcUrl: 'https://mock-rpc-url', - chainId: '0x1337', - ticker: 'ABC', - id: 'testNetworkConfigurationId', + await withController( + { + state: { + selectedNetworkClientId: infuraNetworkType, + networkConfigurationsByChainId: { + [infuraChainId]: + buildInfuraNetworkConfiguration(infuraNetworkType), + '0x1337': buildCustomNetworkConfiguration({ + chainId: '0x1337', + rpcEndpoints: [ + buildCustomRpcEndpoint({ + networkClientId: 'AAAA-AAAA-AAAA-AAAA', + }), + ], + }), + }, + }, + infuraProjectId, }, - }, - }, - infuraProjectId: 'some-infura-project-id', - }, - async ({ controller }) => { - const fakeNetworkClient = buildFakeClient(); - mockCreateNetworkClient().mockReturnValue(fakeNetworkClient); - - const networkClientRegistry = controller.getNetworkClientRegistry(); - const networkClient = controller.getNetworkClientById( - 'testNetworkConfigurationId', - ); + async ({ controller }) => { + const fakeProviders = [ + buildFakeProvider([ + // Called during provider initialization + { + request: { + method: 'eth_getBlockByNumber', + }, + response: { + result: POST_1559_BLOCK, + }, + }, + // Called via `lookupNetwork` directly + { + request: { + method: 'eth_getBlockByNumber', + }, + response: { + result: POST_1559_BLOCK, + }, + beforeCompleting: (): void => { + // We are purposefully not awaiting this promise. + // eslint-disable-next-line @typescript-eslint/no-floating-promises + controller.setActiveNetwork('AAAA-AAAA-AAAA-AAAA'); + }, + }, + ]), + buildFakeProvider([ + // Called when switching networks + { + request: { + method: 'eth_getBlockByNumber', + }, + response: { + result: PRE_1559_BLOCK, + }, + }, + ]), + ]; + const fakeNetworkClients = [ + buildFakeClient(fakeProviders[0]), + buildFakeClient(fakeProviders[1]), + ]; + createNetworkClientMock.mockImplementation( + ({ configuration }) => { + if (configuration.chainId === infuraChainId) { + return fakeNetworkClients[0]; + } else if (configuration.chainId === '0x1337') { + return fakeNetworkClients[1]; + } + throw new Error( + `Unknown network client configuration ${JSON.stringify( + configuration, + )}`, + ); + }, + ); + await controller.lookupNetwork(); + expect( + controller.state.networksMetadata[infuraNetworkType] + .EIPS[1559], + ).toBe(true); - expect(networkClient).toBe( - networkClientRegistry.testNetworkConfigurationId, - ); - }, - ); - }); - }); + await controller.lookupNetwork(); - describe('If passed a networkClientId that does not match a NetworkClient in the registry', () => { - it('throws an error', async () => { - await withController( - { infuraProjectId: 'some-infura-project-id' }, - async ({ controller }) => { - const fakeNetworkClient = buildFakeClient(); - mockCreateNetworkClient().mockReturnValue(fakeNetworkClient); + expect( + controller.state.networksMetadata['AAAA-AAAA-AAAA-AAAA'] + .EIPS[1559], + ).toBe(false); + }, + ); + }); - expect(() => - controller.getNetworkClientById('non-existent-network-id'), - ).toThrow( - 'No custom network client was found with the ID "non-existent-network-id', - ); - }, - ); - }); - }); + it('emits infuraIsUnblocked, not infuraIsBlocked, assuming that the first network was blocked', async () => { + const infuraProjectId = 'some-infura-project-id'; - describe('If not passed a networkClientId', () => { - it('throws an error', async () => { - await withController( - { infuraProjectId: 'some-infura-project-id' }, - async ({ controller }) => { - const fakeNetworkClient = buildFakeClient(); - mockCreateNetworkClient().mockReturnValue(fakeNetworkClient); - - expect(() => - // @ts-expect-error Intentionally passing invalid type - controller.getNetworkClientById(), - ).toThrow('No network client ID was provided.'); - }, - ); - }); - }); - }); + await withController( + { + state: { + selectedNetworkClientId: infuraNetworkType, + networkConfigurationsByChainId: { + [infuraChainId]: + buildInfuraNetworkConfiguration(infuraNetworkType), + '0x1337': buildCustomNetworkConfiguration({ + chainId: '0x1337', + nativeCurrency: 'TEST', + rpcEndpoints: [ + buildCustomRpcEndpoint({ + networkClientId: 'AAAA-AAAA-AAAA-AAAA', + url: 'https://test.network', + }), + ], + }), + }, + }, + infuraProjectId, + }, + async ({ controller, messenger }) => { + const fakeProviders = [ + buildFakeProvider([ + // Called during provider initialization + { + request: { + method: 'eth_getBlockByNumber', + }, + response: SUCCESSFUL_ETH_GET_BLOCK_BY_NUMBER_RESPONSE, + }, + // Called via `lookupNetwork` directly + { + request: { + method: 'eth_getBlockByNumber', + }, + error: BLOCKED_INFURA_JSON_RPC_ERROR, + beforeCompleting: (): void => { + // We are purposefully not awaiting this promise. + // eslint-disable-next-line @typescript-eslint/no-floating-promises + controller.setActiveNetwork('AAAA-AAAA-AAAA-AAAA'); + }, + }, + ]), + buildFakeProvider([ + // Called when switching networks + { + request: { + method: 'eth_getBlockByNumber', + }, + response: SUCCESSFUL_ETH_GET_BLOCK_BY_NUMBER_RESPONSE, + }, + ]), + ]; + const fakeNetworkClients = [ + buildFakeClient(fakeProviders[0]), + buildFakeClient(fakeProviders[1]), + ]; + createNetworkClientMock.mockImplementation( + ({ configuration }) => { + if (configuration.chainId === infuraChainId) { + return fakeNetworkClients[0]; + } else if (configuration.chainId === '0x1337') { + return fakeNetworkClients[1]; + } + throw new Error( + `Unknown network client configuration ${JSON.stringify( + configuration, + )}`, + ); + }, + ); + await controller.lookupNetwork(); + const promiseForInfuraIsUnblockedEvents = + waitForPublishedEvents({ + messenger, + eventType: 'NetworkController:infuraIsUnblocked', + }); + const promiseForNoInfuraIsBlockedEvents = + waitForPublishedEvents({ + messenger, + eventType: 'NetworkController:infuraIsBlocked', + count: 0, + }); - describe('getNetworkClientRegistry', () => { - describe('if neither a provider config nor network configurations are present in state', () => { - it('returns the built-in Infura networks by default', async () => { - await withController( - { infuraProjectId: 'some-infura-project-id' }, - async ({ controller }) => { - const fakeNetworkClient = buildFakeClient(); - mockCreateNetworkClient().mockReturnValue(fakeNetworkClient); + await waitForStateChanges({ + messenger, + propertyPath: [ + 'networksMetadata', + 'AAAA-AAAA-AAAA-AAAA', + 'status', + ], + operation: async () => { + await controller.lookupNetwork(); + }, + }); - const networkClients = controller.getNetworkClientRegistry(); - const simplifiedNetworkClients = Object.entries(networkClients) - .map( - ([networkClientId, networkClient]) => - [networkClientId, networkClient.configuration] as const, - ) - .sort( - ( - [networkClientId1, _networkClient1], - [networkClientId2, _networkClient2], - ) => { - return networkClientId1.localeCompare(networkClientId2); + await expect( + promiseForInfuraIsUnblockedEvents, + ).toBeFulfilled(); + await expect( + promiseForNoInfuraIsBlockedEvents, + ).toBeFulfilled(); }, ); + }); + }); - expect(simplifiedNetworkClients).toStrictEqual([ - [ - 'goerli', - { - type: NetworkClientType.Infura, - infuraProjectId: 'some-infura-project-id', - chainId: BUILT_IN_NETWORKS[NetworkType.goerli].chainId, - ticker: BUILT_IN_NETWORKS[NetworkType.goerli].ticker, - network: InfuraNetworkType.goerli, - }, - ], - [ - 'linea-goerli', - { - type: NetworkClientType.Infura, - infuraProjectId: 'some-infura-project-id', - chainId: - BUILT_IN_NETWORKS[NetworkType['linea-goerli']].chainId, - ticker: BUILT_IN_NETWORKS[NetworkType['linea-goerli']].ticker, - network: InfuraNetworkType['linea-goerli'], - }, - ], - [ - 'linea-mainnet', - { - type: NetworkClientType.Infura, - infuraProjectId: 'some-infura-project-id', - chainId: - BUILT_IN_NETWORKS[NetworkType['linea-mainnet']].chainId, - ticker: - BUILT_IN_NETWORKS[NetworkType['linea-mainnet']].ticker, - network: InfuraNetworkType['linea-mainnet'], - }, - ], - [ - 'mainnet', - { - type: NetworkClientType.Infura, - infuraProjectId: 'some-infura-project-id', - chainId: BUILT_IN_NETWORKS[NetworkType.mainnet].chainId, - ticker: BUILT_IN_NETWORKS[NetworkType.mainnet].ticker, - network: InfuraNetworkType.mainnet, - }, - ], - [ - 'sepolia', - { - type: NetworkClientType.Infura, - infuraProjectId: 'some-infura-project-id', - chainId: BUILT_IN_NETWORKS[NetworkType.sepolia].chainId, - ticker: BUILT_IN_NETWORKS[NetworkType.sepolia].ticker, - network: InfuraNetworkType.sepolia, - }, - ], - ]); - }, - ); - }); - }); + describe('if all subscriptions are removed from the messenger before the call to lookupNetwork completes', () => { + it('does not throw an error', async () => { + const infuraProjectId = 'some-infura-project-id'; - describe('if network configurations are present in state', () => { - it('incorporates them into the list of network clients, using the network configuration ID for identification', async () => { - await withController( - { - state: { - networkConfigurations: { - 'AAAA-AAAA-AAAA-AAAA': { - id: 'AAAA-AAAA-AAAA-AAAA', - rpcUrl: 'https://test.network.1', - chainId: toHex(1), - ticker: 'TEST1', - }, - 'BBBB-BBBB-BBBB-BBBB': { - id: 'BBBB-BBBB-BBBB-BBBB', - rpcUrl: 'https://test.network.2', - chainId: toHex(2), - ticker: 'TEST2', + await withController( + { + state: { + selectedNetworkClientId: infuraNetworkType, + }, + infuraProjectId, }, - }, - }, - infuraProjectId: 'some-infura-project-id', - }, - async ({ controller }) => { - const fakeNetworkClient = buildFakeClient(); - mockCreateNetworkClient().mockReturnValue(fakeNetworkClient); + async ({ controller, messenger }) => { + const fakeProvider = buildFakeProvider([ + // Called during provider initialization + { + request: { + method: 'eth_getBlockByNumber', + }, + response: SUCCESSFUL_ETH_GET_BLOCK_BY_NUMBER_RESPONSE, + }, + // Called via `lookupNetwork` directly + { + request: { + method: 'eth_getBlockByNumber', + }, + response: SUCCESSFUL_ETH_GET_BLOCK_BY_NUMBER_RESPONSE, + }, + ]); + const fakeNetworkClient = buildFakeClient(fakeProvider); + createNetworkClientMock.mockReturnValue(fakeNetworkClient); + await controller.lookupNetwork(); - const networkClients = controller.getNetworkClientRegistry(); - const simplifiedNetworkClients = Object.entries(networkClients) - .map( - ([networkClientId, networkClient]) => - [networkClientId, networkClient.configuration] as const, - ) - .sort( - ( - [networkClientId1, _networkClient1], - [networkClientId2, _networkClient2], - ) => { - return networkClientId1.localeCompare(networkClientId2); + const lookupNetworkPromise = controller.lookupNetwork(); + messenger.clearSubscriptions(); + expect(await lookupNetworkPromise).toBeUndefined(); }, ); + }); + }); - expect(simplifiedNetworkClients).toStrictEqual([ - [ - 'AAAA-AAAA-AAAA-AAAA', - { - type: NetworkClientType.Custom, - ticker: 'TEST1', - chainId: toHex(1), - rpcUrl: 'https://test.network.1', - }, - ], - [ - 'BBBB-BBBB-BBBB-BBBB', - { - type: NetworkClientType.Custom, - ticker: 'TEST2', - chainId: toHex(2), - rpcUrl: 'https://test.network.2', - }, - ], - [ - 'goerli', - { - type: NetworkClientType.Infura, - infuraProjectId: 'some-infura-project-id', - chainId: BUILT_IN_NETWORKS[NetworkType.goerli].chainId, - ticker: BUILT_IN_NETWORKS[InfuraNetworkType.goerli].ticker, - network: InfuraNetworkType.goerli, - }, - ], - [ - 'linea-goerli', - { - type: NetworkClientType.Infura, - infuraProjectId: 'some-infura-project-id', - chainId: - BUILT_IN_NETWORKS[NetworkType['linea-goerli']].chainId, - ticker: BUILT_IN_NETWORKS[NetworkType['linea-goerli']].ticker, - network: InfuraNetworkType['linea-goerli'], - }, - ], - [ - 'linea-mainnet', - { - type: NetworkClientType.Infura, - infuraProjectId: 'some-infura-project-id', - chainId: - BUILT_IN_NETWORKS[NetworkType['linea-mainnet']].chainId, - ticker: - BUILT_IN_NETWORKS[NetworkType['linea-mainnet']].ticker, - network: InfuraNetworkType['linea-mainnet'], - }, - ], - [ - 'mainnet', + describe('if removing the networkDidChange subscription fails for an unknown reason', () => { + it('re-throws the error', async () => { + const infuraProjectId = 'some-infura-project-id'; + + await withController( { - type: NetworkClientType.Infura, - infuraProjectId: 'some-infura-project-id', - chainId: BUILT_IN_NETWORKS[NetworkType.mainnet].chainId, - ticker: BUILT_IN_NETWORKS[NetworkType.mainnet].ticker, - network: InfuraNetworkType.mainnet, + state: { + selectedNetworkClientId: infuraNetworkType, + }, + infuraProjectId, }, - ], - [ - 'sepolia', - { - type: NetworkClientType.Infura, - infuraProjectId: 'some-infura-project-id', - chainId: BUILT_IN_NETWORKS[NetworkType.sepolia].chainId, - ticker: BUILT_IN_NETWORKS[NetworkType.sepolia].ticker, - network: InfuraNetworkType.sepolia, + async ({ controller, networkControllerMessenger }) => { + const fakeProvider = buildFakeProvider([ + // Called during provider initialization + { + request: { + method: 'eth_getBlockByNumber', + }, + response: SUCCESSFUL_ETH_GET_BLOCK_BY_NUMBER_RESPONSE, + }, + // Called via `lookupNetwork` directly + { + request: { + method: 'eth_getBlockByNumber', + }, + response: SUCCESSFUL_ETH_GET_BLOCK_BY_NUMBER_RESPONSE, + }, + ]); + const fakeNetworkClient = buildFakeClient(fakeProvider); + createNetworkClientMock.mockReturnValue(fakeNetworkClient); + await controller.lookupNetwork(); + + const lookupNetworkPromise = controller.lookupNetwork(); + const error = new Error('oops'); + jest + .spyOn(networkControllerMessenger, 'unsubscribe') + .mockImplementation((eventType) => { + if (eventType === 'NetworkController:networkDidChange') { + throw error; + } + }); + await expect(lookupNetworkPromise).rejects.toThrow(error); }, - ], - ]); - for (const networkClient of Object.values(networkClients)) { - expect(networkClient.provider).toHaveProperty('sendAsync'); - expect(networkClient.blockTracker).toHaveProperty( - 'checkForLatestBlock', ); - } - }, - ); - }); - }); + }); + }); - describe('if a provider config representing a built-in network is present in state', () => { - it('does not incorporate the network into the list of network clients since it is already present', async () => { - await withController( - { - state: { - providerConfig: { - type: NetworkType.mainnet, - chainId: ChainId.mainnet, - ticker: 'TEST', - }, + lookupNetworkTests({ + expectedNetworkClientType: NetworkClientType.Infura, + expectedNetworkClientId: infuraNetworkType, + initialState: { + selectedNetworkClientId: infuraNetworkType, }, - infuraProjectId: 'some-infura-project-id', - }, - async ({ controller }) => { - const fakeNetworkClient = buildFakeClient(); - mockCreateNetworkClient().mockReturnValue(fakeNetworkClient); + operation: async (controller) => { + await controller.lookupNetwork(); + }, + }); + }); + } - const networkClients = controller.getNetworkClientRegistry(); - const simplifiedNetworkClients = Object.entries(networkClients) - .map( - ([networkClientId, networkClient]) => - [networkClientId, networkClient.configuration] as const, - ) - .sort( - ( - [networkClientId1, _networkClient1], - [networkClientId2, _networkClient2], - ) => { - return networkClientId1.localeCompare(networkClientId2); + describe('when the selected network client represents a custom RPC endpoint', () => { + describe('if the provider has been not been initialized yet', () => { + it('does not update state', async () => { + await withController( + { + initializeController: false, + state: { + selectedNetworkClientId: 'AAAA-AAAA-AAAA-AAAA', + networkConfigurationsByChainId: { + '0x1337': buildCustomNetworkConfiguration({ + chainId: '0x1337', + rpcEndpoints: [ + buildCustomRpcEndpoint({ + networkClientId: 'AAAA-AAAA-AAAA-AAAA', + }), + ], + }), + }, }, - ); + }, + async ({ controller, messenger }) => { + const stateChangeListener = jest.fn(); + messenger.subscribe( + 'NetworkController:stateChange', + stateChangeListener, + ); - expect(simplifiedNetworkClients).toStrictEqual([ - [ - 'goerli', - { - type: NetworkClientType.Infura, - infuraProjectId: 'some-infura-project-id', - chainId: BUILT_IN_NETWORKS[InfuraNetworkType.goerli].chainId, - ticker: BUILT_IN_NETWORKS[InfuraNetworkType.goerli].ticker, - network: InfuraNetworkType.goerli, - }, - ], - [ - 'linea-goerli', - { - type: NetworkClientType.Infura, - infuraProjectId: 'some-infura-project-id', - chainId: - BUILT_IN_NETWORKS[InfuraNetworkType['linea-goerli']] - .chainId, - ticker: - BUILT_IN_NETWORKS[InfuraNetworkType['linea-goerli']].ticker, - network: InfuraNetworkType['linea-goerli'], + await controller.lookupNetwork(); + + expect(stateChangeListener).not.toHaveBeenCalled(); + }, + ); + }); + + it('does not publish NetworkController:infuraIsUnblocked', async () => { + await withController( + { + initializeController: false, + state: { + selectedNetworkClientId: 'AAAA-AAAA-AAAA-AAAA', + networkConfigurationsByChainId: { + '0x1337': buildCustomNetworkConfiguration({ + chainId: '0x1337', + rpcEndpoints: [ + buildCustomRpcEndpoint({ + networkClientId: 'AAAA-AAAA-AAAA-AAAA', + }), + ], + }), + }, }, - ], - [ - 'linea-mainnet', - { - type: NetworkClientType.Infura, - infuraProjectId: 'some-infura-project-id', - chainId: - BUILT_IN_NETWORKS[InfuraNetworkType['linea-mainnet']] - .chainId, - ticker: - BUILT_IN_NETWORKS[InfuraNetworkType['linea-mainnet']] - .ticker, - network: InfuraNetworkType['linea-mainnet'], - }, - ], - [ - 'mainnet', - { - type: NetworkClientType.Infura, - infuraProjectId: 'some-infura-project-id', - chainId: BUILT_IN_NETWORKS[InfuraNetworkType.mainnet].chainId, - ticker: BUILT_IN_NETWORKS[InfuraNetworkType.mainnet].ticker, - network: InfuraNetworkType.mainnet, - }, - ], - [ - 'sepolia', - { - type: NetworkClientType.Infura, - infuraProjectId: 'some-infura-project-id', - chainId: BUILT_IN_NETWORKS[InfuraNetworkType.sepolia].chainId, - ticker: BUILT_IN_NETWORKS[InfuraNetworkType.sepolia].ticker, - network: InfuraNetworkType.sepolia, - }, - ], - ]); - }, - ); - }); - }); + }, + async ({ controller, messenger }) => { + const infuraIsUnblockedListener = jest.fn(); + messenger.subscribe( + 'NetworkController:infuraIsUnblocked', + infuraIsUnblockedListener, + ); + + await controller.lookupNetwork(); + + expect(infuraIsUnblockedListener).not.toHaveBeenCalled(); + }, + ); + }); - describe('if a provider config representing a custom network is present in state', () => { - describe('if it does not point to a network configuration', () => { - describe("if it does not match an existing network configuration's RPC URL", () => { - it('incorporates the network into the list of network clients, using the chain ID and lowercased RPC URL for identification', async () => { + it('does not publish NetworkController:infuraIsBlocked', async () => { await withController( { + initializeController: false, state: { - providerConfig: { - type: NetworkType.rpc, - chainId: toHex(2), - rpcUrl: 'HTTPS://TEST.NETWORK.2', - ticker: 'TEST', - }, - networkConfigurations: { - 'AAAA-AAAA-AAAA-AAAA': { - id: 'AAAA-AAAA-AAAA-AAAA', - rpcUrl: 'https://test.network.1', - chainId: toHex(1), - ticker: 'TEST', - }, + selectedNetworkClientId: 'AAAA-AAAA-AAAA-AAAA', + networkConfigurationsByChainId: { + '0x1337': buildCustomNetworkConfiguration({ + chainId: '0x1337', + rpcEndpoints: [ + buildCustomRpcEndpoint({ + networkClientId: 'AAAA-AAAA-AAAA-AAAA', + }), + ], + }), }, }, - infuraProjectId: 'some-infura-project-id', }, - async ({ controller }) => { - const fakeNetworkClient = buildFakeClient(); - mockCreateNetworkClient().mockReturnValue(fakeNetworkClient); - - const networkClients = controller.getNetworkClientRegistry(); - const simplifiedNetworkClients = Object.entries(networkClients) - .map( - ([networkClientId, networkClient]) => - [networkClientId, networkClient.configuration] as const, - ) - .sort( - ( - [networkClientId1, _networkClient1], - [networkClientId2, _networkClient2], - ) => { - return networkClientId1.localeCompare(networkClientId2); - }, - ); + async ({ controller, messenger }) => { + const infuraIsBlockedListener = jest.fn(); + messenger.subscribe( + 'NetworkController:infuraIsBlocked', + infuraIsBlockedListener, + ); - expect(simplifiedNetworkClients).toStrictEqual([ - [ - 'AAAA-AAAA-AAAA-AAAA', - { - type: NetworkClientType.Custom, - ticker: 'TEST', - chainId: toHex(1), - rpcUrl: 'https://test.network.1', - }, - ], - [ - 'goerli', - { - type: NetworkClientType.Infura, - infuraProjectId: 'some-infura-project-id', - chainId: - BUILT_IN_NETWORKS[InfuraNetworkType.goerli].chainId, - ticker: - BUILT_IN_NETWORKS[InfuraNetworkType.goerli].ticker, - network: InfuraNetworkType.goerli, - }, - ], - [ - 'https://test.network.2', - { - type: NetworkClientType.Custom, - ticker: 'TEST', - chainId: toHex(2), - rpcUrl: 'HTTPS://TEST.NETWORK.2', - }, - ], - [ - 'linea-goerli', - { - type: NetworkClientType.Infura, - infuraProjectId: 'some-infura-project-id', - chainId: - BUILT_IN_NETWORKS[InfuraNetworkType['linea-goerli']] - .chainId, - ticker: - BUILT_IN_NETWORKS[InfuraNetworkType['linea-goerli']] - .ticker, - network: InfuraNetworkType['linea-goerli'], - }, - ], - [ - 'linea-mainnet', - { - type: NetworkClientType.Infura, - infuraProjectId: 'some-infura-project-id', - chainId: - BUILT_IN_NETWORKS[InfuraNetworkType['linea-mainnet']] - .chainId, - ticker: - BUILT_IN_NETWORKS[InfuraNetworkType['linea-mainnet']] - .ticker, - network: InfuraNetworkType['linea-mainnet'], - }, - ], - [ - 'mainnet', - { - type: NetworkClientType.Infura, - infuraProjectId: 'some-infura-project-id', - chainId: - BUILT_IN_NETWORKS[InfuraNetworkType.mainnet].chainId, - ticker: - BUILT_IN_NETWORKS[InfuraNetworkType.mainnet].ticker, - network: InfuraNetworkType.mainnet, - }, - ], - [ - 'sepolia', - { - type: NetworkClientType.Infura, - infuraProjectId: 'some-infura-project-id', - chainId: - BUILT_IN_NETWORKS[InfuraNetworkType.sepolia].chainId, - ticker: - BUILT_IN_NETWORKS[InfuraNetworkType.sepolia].ticker, - network: InfuraNetworkType.sepolia, - }, - ], - ]); + await controller.lookupNetwork(); + + expect(infuraIsBlockedListener).not.toHaveBeenCalled(); }, ); }); }); - describe("if it matches an existing network configuration's RPC URL exactly", () => { - it('does not incorporate the network into the list of network clients again, prioritizing the network configuration instead', async () => { + describe('if the network was switched after the eth_getBlockByNumber request started but before it completed', () => { + it('stores the network status of the second network, not the first', async () => { + const infuraProjectId = 'some-infura-project-id'; + await withController( { state: { - providerConfig: { - type: NetworkType.rpc, - chainId: toHex(2), - rpcUrl: 'https://test.network', - ticker: 'TEST', - }, - networkConfigurations: { - 'AAAA-AAAA-AAAA-AAAA': { - id: 'AAAA-AAAA-AAAA-AAAA', - rpcUrl: 'https://test.network', - chainId: toHex(1), - ticker: 'TEST', - }, + selectedNetworkClientId: 'AAAA-AAAA-AAAA-AAAA', + networkConfigurationsByChainId: { + [TESTNET.chainId]: buildInfuraNetworkConfiguration( + TESTNET.networkType, + ), + '0x1337': buildCustomNetworkConfiguration({ + chainId: '0x1337', + rpcEndpoints: [ + buildCustomRpcEndpoint({ + networkClientId: 'AAAA-AAAA-AAAA-AAAA', + }), + ], + }), }, }, - infuraProjectId: 'some-infura-project-id', + infuraProjectId, }, async ({ controller }) => { - const fakeNetworkClient = buildFakeClient(); - mockCreateNetworkClient().mockReturnValue(fakeNetworkClient); - - const networkClients = controller.getNetworkClientRegistry(); - const simplifiedNetworkClients = Object.entries(networkClients) - .map( - ([networkClientId, networkClient]) => - [networkClientId, networkClient.configuration] as const, - ) - .sort( - ( - [networkClientId1, _networkClient1], - [networkClientId2, _networkClient2], - ) => { - return networkClientId1.localeCompare(networkClientId2); - }, - ); - - expect(simplifiedNetworkClients).toStrictEqual([ - [ - 'AAAA-AAAA-AAAA-AAAA', - { - type: NetworkClientType.Custom, - ticker: 'TEST', - chainId: '0x1', - rpcUrl: 'https://test.network', - }, - ], - [ - 'goerli', - { - type: NetworkClientType.Infura, - infuraProjectId: 'some-infura-project-id', - chainId: - BUILT_IN_NETWORKS[InfuraNetworkType.goerli].chainId, - ticker: - BUILT_IN_NETWORKS[InfuraNetworkType.goerli].ticker, - network: InfuraNetworkType.goerli, - }, - ], - [ - 'linea-goerli', - { - type: NetworkClientType.Infura, - infuraProjectId: 'some-infura-project-id', - chainId: - BUILT_IN_NETWORKS[InfuraNetworkType['linea-goerli']] - .chainId, - ticker: - BUILT_IN_NETWORKS[InfuraNetworkType['linea-goerli']] - .ticker, - network: InfuraNetworkType['linea-goerli'], - }, - ], - [ - 'linea-mainnet', + const fakeProviders = [ + buildFakeProvider([ + // Called during provider initialization { - type: NetworkClientType.Infura, - infuraProjectId: 'some-infura-project-id', - chainId: - BUILT_IN_NETWORKS[InfuraNetworkType['linea-mainnet']] - .chainId, - ticker: - BUILT_IN_NETWORKS[InfuraNetworkType['linea-mainnet']] - .ticker, - network: InfuraNetworkType['linea-mainnet'], + request: { + method: 'eth_getBlockByNumber', + }, + response: SUCCESSFUL_ETH_GET_BLOCK_BY_NUMBER_RESPONSE, }, - ], - [ - 'mainnet', + // Called via `lookupNetwork` directly { - type: NetworkClientType.Infura, - chainId: - BUILT_IN_NETWORKS[InfuraNetworkType.mainnet].chainId, - ticker: - BUILT_IN_NETWORKS[InfuraNetworkType.mainnet].ticker, - infuraProjectId: 'some-infura-project-id', - network: InfuraNetworkType.mainnet, + request: { + method: 'eth_getBlockByNumber', + }, + response: SUCCESSFUL_ETH_GET_BLOCK_BY_NUMBER_RESPONSE, + beforeCompleting: (): void => { + // We are purposefully not awaiting this promise. + // eslint-disable-next-line @typescript-eslint/no-floating-promises + controller.setProviderType(TESTNET.networkType); + }, }, - ], - [ - 'sepolia', + ]), + buildFakeProvider([ + // Called when switching networks { - type: NetworkClientType.Infura, - infuraProjectId: 'some-infura-project-id', - chainId: - BUILT_IN_NETWORKS[InfuraNetworkType.sepolia].chainId, - ticker: - BUILT_IN_NETWORKS[InfuraNetworkType.sepolia].ticker, - network: InfuraNetworkType.sepolia, + request: { + method: 'eth_getBlockByNumber', + }, + error: GENERIC_JSON_RPC_ERROR, }, - ], - ]); + ]), + ]; + const fakeNetworkClients = [ + buildFakeClient(fakeProviders[0]), + buildFakeClient(fakeProviders[1]), + ]; + createNetworkClientMock.mockImplementation( + ({ configuration }) => { + if (configuration.chainId === '0x1337') { + return fakeNetworkClients[0]; + } else if ( + configuration.chainId === ChainId[TESTNET.networkType] + ) { + return fakeNetworkClients[1]; + } + throw new Error( + `Unknown network client configuration ${JSON.stringify( + configuration, + )}`, + ); + }, + ); + await controller.lookupNetwork(); + expect( + controller.state.networksMetadata['AAAA-AAAA-AAAA-AAAA'] + .status, + ).toBe('available'); + + await controller.lookupNetwork(); + + expect( + controller.state.networksMetadata[TESTNET.networkType].status, + ).toBe('unknown'); }, ); }); - }); - describe("if it matches an existing network configuration's RPC URL case-insensitively", () => { - it('does not incorporate the network into the list of network clients again, prioritizing the network configuration instead', async () => { + it('stores the EIP-1559 support of the second network, not the first', async () => { + const infuraProjectId = 'some-infura-project-id'; + await withController( { state: { - providerConfig: { - type: NetworkType.rpc, - chainId: toHex(2), - rpcUrl: 'https://TEST.NETWORK', - ticker: 'TEST', - }, - networkConfigurations: { - 'AAAA-AAAA-AAAA-AAAA': { - id: 'AAAA-AAAA-AAAA-AAAA', - rpcUrl: 'https://test.network', - chainId: toHex(1), - ticker: 'TEST', - }, + selectedNetworkClientId: 'AAAA-AAAA-AAAA-AAAA', + networkConfigurationsByChainId: { + [TESTNET.chainId]: buildInfuraNetworkConfiguration( + TESTNET.networkType, + ), + '0x1337': buildCustomNetworkConfiguration({ + chainId: '0x1337', + rpcEndpoints: [ + buildCustomRpcEndpoint({ + networkClientId: 'AAAA-AAAA-AAAA-AAAA', + }), + ], + }), }, }, - infuraProjectId: 'some-infura-project-id', + infuraProjectId, }, async ({ controller }) => { - const fakeNetworkClient = buildFakeClient(); - mockCreateNetworkClient().mockReturnValue(fakeNetworkClient); - - const networkClients = controller.getNetworkClientRegistry(); - const simplifiedNetworkClients = Object.entries(networkClients) - .map( - ([networkClientId, networkClient]) => - [networkClientId, networkClient.configuration] as const, - ) - .sort( - ( - [networkClientId1, _networkClient1], - [networkClientId2, _networkClient2], - ) => { - return networkClientId1.localeCompare(networkClientId2); - }, - ); - - expect(simplifiedNetworkClients).toStrictEqual([ - [ - 'AAAA-AAAA-AAAA-AAAA', + const fakeProviders = [ + buildFakeProvider([ + // Called during provider initialization { - type: NetworkClientType.Custom, - ticker: 'TEST', - chainId: toHex(1), - rpcUrl: 'https://test.network', + request: { + method: 'eth_getBlockByNumber', + }, + response: { + result: POST_1559_BLOCK, + }, }, - ], - [ - 'goerli', + // Called via `lookupNetwork` directly { - type: NetworkClientType.Infura, - infuraProjectId: 'some-infura-project-id', - chainId: - BUILT_IN_NETWORKS[InfuraNetworkType.goerli].chainId, - ticker: - BUILT_IN_NETWORKS[InfuraNetworkType.goerli].ticker, - network: InfuraNetworkType.goerli, + request: { + method: 'eth_getBlockByNumber', + }, + response: { + result: POST_1559_BLOCK, + }, + beforeCompleting: (): void => { + // We are purposefully not awaiting this promise. + // eslint-disable-next-line @typescript-eslint/no-floating-promises + controller.setProviderType(TESTNET.networkType); + }, }, - ], - [ - 'linea-goerli', + ]), + buildFakeProvider([ + // Called when switching networks { - type: NetworkClientType.Infura, - infuraProjectId: 'some-infura-project-id', - chainId: - BUILT_IN_NETWORKS[InfuraNetworkType['linea-goerli']] - .chainId, - ticker: - BUILT_IN_NETWORKS[InfuraNetworkType['linea-goerli']] - .ticker, - network: InfuraNetworkType['linea-goerli'], + request: { + method: 'eth_getBlockByNumber', + }, + response: { + result: PRE_1559_BLOCK, + }, }, - ], - [ - 'linea-mainnet', + ]), + ]; + const fakeNetworkClients = [ + buildFakeClient(fakeProviders[0]), + buildFakeClient(fakeProviders[1]), + ]; + createNetworkClientMock.mockImplementation( + ({ configuration }) => { + if (configuration.chainId === '0x1337') { + return fakeNetworkClients[0]; + } else if ( + configuration.chainId === ChainId[TESTNET.networkType] + ) { + return fakeNetworkClients[1]; + } + throw new Error( + `Unknown network client configuration ${JSON.stringify( + configuration, + )}`, + ); + }, + ); + await controller.lookupNetwork(); + expect( + controller.state.networksMetadata['AAAA-AAAA-AAAA-AAAA'] + .EIPS[1559], + ).toBe(true); + + await controller.lookupNetwork(); + + expect( + controller.state.networksMetadata[TESTNET.networkType] + .EIPS[1559], + ).toBe(false); + expect( + controller.state.networksMetadata['AAAA-AAAA-AAAA-AAAA'] + .EIPS[1559], + ).toBe(true); + }, + ); + }); + + it('emits infuraIsBlocked, not infuraIsUnblocked, if the second network was blocked and the first network was not', async () => { + const infuraProjectId = 'some-infura-project-id'; + + await withController( + { + state: { + selectedNetworkClientId: 'AAAA-AAAA-AAAA-AAAA', + networkConfigurationsByChainId: { + [TESTNET.chainId]: buildInfuraNetworkConfiguration( + TESTNET.networkType, + ), + '0x1337': buildCustomNetworkConfiguration({ + chainId: '0x1337', + rpcEndpoints: [ + buildCustomRpcEndpoint({ + networkClientId: 'AAAA-AAAA-AAAA-AAAA', + }), + ], + }), + }, + }, + infuraProjectId, + }, + async ({ controller, messenger }) => { + const fakeProviders = [ + buildFakeProvider([ + // Called during provider initialization { - type: NetworkClientType.Infura, - infuraProjectId: 'some-infura-project-id', - chainId: - BUILT_IN_NETWORKS[InfuraNetworkType['linea-mainnet']] - .chainId, - ticker: - BUILT_IN_NETWORKS[InfuraNetworkType['linea-mainnet']] - .ticker, - network: InfuraNetworkType['linea-mainnet'], + request: { + method: 'eth_getBlockByNumber', + }, + response: SUCCESSFUL_ETH_GET_BLOCK_BY_NUMBER_RESPONSE, }, - ], - [ - 'mainnet', + // Called via `lookupNetwork` directly { - type: NetworkClientType.Infura, - chainId: - BUILT_IN_NETWORKS[InfuraNetworkType.mainnet].chainId, - ticker: - BUILT_IN_NETWORKS[InfuraNetworkType.mainnet].ticker, - infuraProjectId: 'some-infura-project-id', - network: InfuraNetworkType.mainnet, + request: { + method: 'eth_getBlockByNumber', + }, + response: SUCCESSFUL_ETH_GET_BLOCK_BY_NUMBER_RESPONSE, + beforeCompleting: (): void => { + // We are purposefully not awaiting this promise. + // eslint-disable-next-line @typescript-eslint/no-floating-promises + controller.setProviderType(TESTNET.networkType); + }, }, - ], - [ - 'sepolia', + ]), + buildFakeProvider([ + // Called when switching networks { - type: NetworkClientType.Infura, - infuraProjectId: 'some-infura-project-id', - chainId: - BUILT_IN_NETWORKS[InfuraNetworkType.sepolia].chainId, - ticker: - BUILT_IN_NETWORKS[InfuraNetworkType.sepolia].ticker, - network: InfuraNetworkType.sepolia, + request: { + method: 'eth_getBlockByNumber', + }, + error: BLOCKED_INFURA_JSON_RPC_ERROR, }, - ], - ]); + ]), + ]; + const fakeNetworkClients = [ + buildFakeClient(fakeProviders[0]), + buildFakeClient(fakeProviders[1]), + ]; + createNetworkClientMock.mockImplementation( + ({ configuration }) => { + if (configuration.chainId === '0x1337') { + return fakeNetworkClients[0]; + } else if ( + configuration.chainId === ChainId[TESTNET.networkType] + ) { + return fakeNetworkClients[1]; + } + throw new Error( + `Unknown network client configuration ${JSON.stringify( + configuration, + )}`, + ); + }, + ); + await controller.lookupNetwork(); + const promiseForNoInfuraIsUnblockedEvents = + waitForPublishedEvents({ + messenger, + eventType: 'NetworkController:infuraIsUnblocked', + count: 0, + }); + const promiseForInfuraIsBlockedEvents = waitForPublishedEvents({ + messenger, + eventType: 'NetworkController:infuraIsBlocked', + }); + + await controller.lookupNetwork(); + + await expect( + promiseForNoInfuraIsUnblockedEvents, + ).toBeFulfilled(); + await expect(promiseForInfuraIsBlockedEvents).toBeFulfilled(); }, ); }); }); - }); - describe('if it points to a network configuration', () => { - it('does not incorporate the network into the list of network clients again, prioritizing the network configuration', async () => { - await withController( - { - state: { - providerConfig: { - type: NetworkType.rpc, - chainId: toHex(2), - rpcUrl: 'https://test.network.2', - id: 'AAAA-AAAA-AAAA-AAAA', - ticker: 'TEST', - }, - networkConfigurations: { - 'AAAA-AAAA-AAAA-AAAA': { - id: 'AAAA-AAAA-AAAA-AAAA', - rpcUrl: 'https://test.network.1', - chainId: toHex(1), - ticker: 'TEST', + describe('if all subscriptions are removed from the messenger before the call to lookupNetwork completes', () => { + it('does not throw an error', async () => { + const infuraProjectId = 'some-infura-project-id'; + + await withController( + { + state: { + selectedNetworkClientId: 'AAAA-AAAA-AAAA-AAAA', + networkConfigurationsByChainId: { + '0x1337': buildCustomNetworkConfiguration({ + chainId: '0x1337', + rpcEndpoints: [ + buildCustomRpcEndpoint({ + networkClientId: 'AAAA-AAAA-AAAA-AAAA', + }), + ], + }), }, }, + infuraProjectId, }, - infuraProjectId: 'some-infura-project-id', - }, - async ({ controller }) => { - const fakeNetworkClient = buildFakeClient(); - mockCreateNetworkClient().mockReturnValue(fakeNetworkClient); - - const networkClients = controller.getNetworkClientRegistry(); - const simplifiedNetworkClients = Object.entries(networkClients) - .map( - ([networkClientId, networkClient]) => - [networkClientId, networkClient.configuration] as const, - ) - .sort( - ( - [networkClientId1, _networkClient1], - [networkClientId2, _networkClient2], - ) => { - return networkClientId1.localeCompare(networkClientId2); - }, - ); - - expect(simplifiedNetworkClients).toStrictEqual([ - [ - 'AAAA-AAAA-AAAA-AAAA', - { - type: NetworkClientType.Custom, - ticker: 'TEST', - chainId: toHex(1), - rpcUrl: 'https://test.network.1', - }, - ], - [ - 'goerli', + async ({ controller, messenger }) => { + const fakeProvider = buildFakeProvider([ + // Called during provider initialization { - type: NetworkClientType.Infura, - infuraProjectId: 'some-infura-project-id', - chainId: - BUILT_IN_NETWORKS[InfuraNetworkType.goerli].chainId, - ticker: BUILT_IN_NETWORKS[InfuraNetworkType.goerli].ticker, - network: InfuraNetworkType.goerli, + request: { + method: 'eth_getBlockByNumber', + }, + response: SUCCESSFUL_ETH_GET_BLOCK_BY_NUMBER_RESPONSE, }, - ], - [ - 'linea-goerli', + // Called via `lookupNetwork` directly { - type: NetworkClientType.Infura, - infuraProjectId: 'some-infura-project-id', - chainId: - BUILT_IN_NETWORKS[InfuraNetworkType['linea-goerli']] - .chainId, - ticker: - BUILT_IN_NETWORKS[InfuraNetworkType['linea-goerli']] - .ticker, - network: InfuraNetworkType['linea-goerli'], + request: { + method: 'eth_getBlockByNumber', + }, + response: SUCCESSFUL_ETH_GET_BLOCK_BY_NUMBER_RESPONSE, }, - ], - [ - 'linea-mainnet', - { - type: NetworkClientType.Infura, - infuraProjectId: 'some-infura-project-id', - chainId: - BUILT_IN_NETWORKS[InfuraNetworkType['linea-mainnet']] - .chainId, - ticker: - BUILT_IN_NETWORKS[InfuraNetworkType['linea-mainnet']] - .ticker, - network: InfuraNetworkType['linea-mainnet'], + ]); + const fakeNetworkClient = buildFakeClient(fakeProvider); + createNetworkClientMock.mockReturnValue(fakeNetworkClient); + await controller.lookupNetwork(); + + const lookupNetworkPromise = controller.lookupNetwork(); + messenger.clearSubscriptions(); + expect(await lookupNetworkPromise).toBeUndefined(); + }, + ); + }); + }); + + describe('if removing the networkDidChange subscription fails for an unknown reason', () => { + it('re-throws the error', async () => { + const infuraProjectId = 'some-infura-project-id'; + + await withController( + { + state: { + selectedNetworkClientId: 'AAAA-AAAA-AAAA-AAAA', + networkConfigurationsByChainId: { + '0x1337': buildCustomNetworkConfiguration({ + chainId: '0x1337', + rpcEndpoints: [ + buildCustomRpcEndpoint({ + networkClientId: 'AAAA-AAAA-AAAA-AAAA', + }), + ], + }), }, - ], - [ - 'mainnet', + }, + infuraProjectId, + }, + async ({ controller, networkControllerMessenger }) => { + const fakeProvider = buildFakeProvider([ + // Called during provider initialization { - type: NetworkClientType.Infura, - chainId: - BUILT_IN_NETWORKS[InfuraNetworkType.mainnet].chainId, - ticker: BUILT_IN_NETWORKS[InfuraNetworkType.mainnet].ticker, - infuraProjectId: 'some-infura-project-id', - network: InfuraNetworkType.mainnet, + request: { + method: 'eth_getBlockByNumber', + }, + response: SUCCESSFUL_ETH_GET_BLOCK_BY_NUMBER_RESPONSE, }, - ], - [ - 'sepolia', + // Called via `lookupNetwork` directly { - type: NetworkClientType.Infura, - infuraProjectId: 'some-infura-project-id', - chainId: - BUILT_IN_NETWORKS[InfuraNetworkType.sepolia].chainId, - ticker: BUILT_IN_NETWORKS[InfuraNetworkType.sepolia].ticker, - network: InfuraNetworkType.sepolia, + request: { + method: 'eth_getBlockByNumber', + }, + response: SUCCESSFUL_ETH_GET_BLOCK_BY_NUMBER_RESPONSE, }, + ]); + const fakeNetworkClient = buildFakeClient(fakeProvider); + createNetworkClientMock.mockReturnValue(fakeNetworkClient); + await controller.lookupNetwork(); + + const lookupNetworkPromise = controller.lookupNetwork(); + const error = new Error('oops'); + jest + .spyOn(networkControllerMessenger, 'unsubscribe') + .mockImplementation((eventType) => { + if (eventType === 'NetworkController:networkDidChange') { + throw error; + } + }); + await expect(lookupNetworkPromise).rejects.toThrow(error); + }, + ); + }); + }); + + lookupNetworkTests({ + expectedNetworkClientType: NetworkClientType.Custom, + expectedNetworkClientId: 'AAAA-AAAA-AAAA-AAAA', + initialState: { + selectedNetworkClientId: 'AAAA-AAAA-AAAA-AAAA', + networkConfigurationsByChainId: { + '0x1337': buildCustomNetworkConfiguration({ + chainId: '0x1337', + nativeCurrency: 'TEST', + rpcEndpoints: [ + buildCustomRpcEndpoint({ + networkClientId: 'AAAA-AAAA-AAAA-AAAA', + url: 'https://test.network', + }), ], - ]); + }), }, - ); + }, + operation: async (controller) => { + await controller.lookupNetwork(); + }, }); }); }); }); - describe('lookupNetwork', () => { - describe('if a networkClientId param is passed', () => { - it('updates the network status', async () => { + describe('setProviderType', () => { + for (const infuraNetworkType of INFURA_NETWORKS) { + describe(`given the Infura network "${infuraNetworkType}"`, () => { + refreshNetworkTests({ + expectedNetworkClientConfiguration: + buildInfuraNetworkClientConfiguration(infuraNetworkType), + expectedNetworkClientId: infuraNetworkType, + operation: async (controller) => { + await controller.setProviderType(infuraNetworkType); + }, + }); + }); + + it(`sets selectedNetworkClientId in state to "${infuraNetworkType}"`, async () => { + await withController(async ({ controller }) => { + mockCreateNetworkClient().mockReturnValue(buildFakeClient()); + + await controller.setProviderType(infuraNetworkType); + + expect(controller.state.selectedNetworkClientId).toBe( + infuraNetworkType, + ); + }); + }); + } + + describe('given "rpc"', () => { + it('throws because there is no way to switch to a custom RPC endpoint using this method', async () => { await withController( - { infuraProjectId: 'some-infura-project-id' }, + { + state: { + selectedNetworkClientId: 'mainnet', + }, + }, async ({ controller }) => { - const fakeNetworkClient = buildFakeClient(); - mockCreateNetworkClient().mockReturnValue(fakeNetworkClient); - await controller.lookupNetwork('mainnet'); - - expect(controller.state.networksMetadata.mainnet.status).toBe( - 'available', + await expect(() => + // @ts-expect-error Intentionally passing invalid type + controller.setProviderType(NetworkType.rpc), + ).rejects.toThrow( + 'NetworkController - cannot call "setProviderType" with type "rpc". Use "setActiveNetwork"', ); }, ); }); - it('throws an error if the network is not found', async () => { + + it("doesn't set a provider", async () => { await withController( - { infuraProjectId: 'some-infura-project-id' }, + { initializeController: false }, async ({ controller }) => { - await expect(() => - controller.lookupNetwork('non-existent-network-id'), - ).rejects.toThrow( - 'No custom network client was found with the ID "non-existent-network-id".', - ); + const fakeProvider = buildFakeProvider(); + const fakeNetworkClient = buildFakeClient(fakeProvider); + createNetworkClientMock.mockReturnValue(fakeNetworkClient); + + try { + // @ts-expect-error Intentionally passing invalid type + await controller.setProviderType(NetworkType.rpc); + } catch { + // catch the rejection (it is tested above) + } + + expect(createNetworkClientMock).not.toHaveBeenCalled(); + expect( + controller.getProviderAndBlockTracker().provider, + ).toBeUndefined(); }, ); }); - }); - [NetworkType.mainnet, NetworkType.goerli, NetworkType.sepolia].forEach( - (networkType) => { - describe(`when the provider config in state contains a network type of "${networkType}"`, () => { - describe('if the network was switched after the eth_getBlockByNumber request started but before it completed', () => { - it('stores the network status of the second network, not the first', async () => { - await withController( - { - state: { - providerConfig: buildProviderConfig({ type: networkType }), - networkConfigurations: { - testNetworkConfigurationId: { - id: 'testNetworkConfigurationId', - rpcUrl: 'https://mock-rpc-url', - chainId: toHex(1337), - ticker: 'ABC', - }, - }, - }, - infuraProjectId: 'some-infura-project-id', + it('does not update networksMetadata[...].EIPS in state', async () => { + await withController(async ({ controller }) => { + const fakeProvider = buildFakeProvider([ + { + request: { + method: 'eth_getBlockByNumber', + params: ['latest', false], + }, + response: { + result: { + baseFeePerGas: '0x1', }, - async ({ controller, messenger }) => { - const fakeProviders = [ - buildFakeProvider([ - // Called during provider initialization - { - request: { - method: 'eth_getBlockByNumber', - }, - response: SUCCESSFUL_ETH_GET_BLOCK_BY_NUMBER_RESPONSE, - }, - // Called via `lookupNetwork` directly - { - request: { - method: 'eth_getBlockByNumber', - }, - response: SUCCESSFUL_ETH_GET_BLOCK_BY_NUMBER_RESPONSE, - beforeCompleting: () => { - controller.setActiveNetwork( - 'testNetworkConfigurationId', - ); - }, - }, - ]), - buildFakeProvider([ - // Called when switching networks - { - request: { - method: 'eth_getBlockByNumber', - }, - error: GENERIC_JSON_RPC_ERROR, - }, - ]), - ]; - const fakeNetworkClients = [ - buildFakeClient(fakeProviders[0]), - buildFakeClient(fakeProviders[1]), - ]; - mockCreateNetworkClient() - .calledWith({ - network: networkType, - infuraProjectId: 'some-infura-project-id', - type: NetworkClientType.Infura, - chainId: BUILT_IN_NETWORKS[networkType].chainId, - ticker: BUILT_IN_NETWORKS[networkType].ticker, - }) - .mockReturnValue(fakeNetworkClients[0]) - .calledWith({ - chainId: toHex(1337), - rpcUrl: 'https://mock-rpc-url', - type: NetworkClientType.Custom, - ticker: 'ABC', - }) - .mockReturnValue(fakeNetworkClients[1]); - await controller.initializeProvider(); - expect( - controller.state.networksMetadata[networkType].status, - ).toBe('available'); + }, + }, + ]); + const fakeNetworkClient = buildFakeClient(fakeProvider); + createNetworkClientMock.mockReturnValue(fakeNetworkClient); - await waitForStateChanges({ - messenger, - propertyPath: [ - 'networksMetadata', - 'testNetworkConfigurationId', - 'status', - ], - operation: async () => { - await controller.lookupNetwork(); - }, - }); + const detailsPre = + controller.state.networksMetadata[ + controller.state.selectedNetworkClientId + ]; - expect( - controller.state.networksMetadata[ - controller.state.selectedNetworkClientId - ].status, - ).toBe('unknown'); - }, - ); - }); + try { + // @ts-expect-error Intentionally passing invalid type + await controller.setProviderType(NetworkType.rpc); + } catch { + // catch the rejection (it is tested above) + } - it('stores the EIP-1559 support of the second network, not the first', async () => { - await withController( - { - state: { - providerConfig: buildProviderConfig({ type: networkType }), - networkConfigurations: { - testNetworkConfigurationId: { - id: 'testNetworkConfigurationId', - rpcUrl: 'https://mock-rpc-url', - chainId: toHex(1337), - ticker: 'ABC', - }, - }, - }, - infuraProjectId: 'some-infura-project-id', - }, - async ({ controller, messenger }) => { - const fakeProviders = [ - buildFakeProvider([ - // Called during provider initialization - { - request: { - method: 'eth_getBlockByNumber', - }, - response: { - result: POST_1559_BLOCK, - }, - }, - // Called via `lookupNetwork` directly - { - request: { - method: 'eth_getBlockByNumber', - }, - response: { - result: POST_1559_BLOCK, - }, - beforeCompleting: () => { - controller.setActiveNetwork( - 'testNetworkConfigurationId', - ); - }, - }, - ]), - buildFakeProvider([ - // Called when switching networks - { - request: { - method: 'eth_getBlockByNumber', - }, - response: { - result: PRE_1559_BLOCK, - }, - }, - ]), - ]; - const fakeNetworkClients = [ - buildFakeClient(fakeProviders[0]), - buildFakeClient(fakeProviders[1]), - ]; - mockCreateNetworkClient() - .calledWith({ - network: networkType, - infuraProjectId: 'some-infura-project-id', - type: NetworkClientType.Infura, - chainId: BUILT_IN_NETWORKS[networkType].chainId, - ticker: BUILT_IN_NETWORKS[networkType].ticker, - }) - .mockReturnValue(fakeNetworkClients[0]) - .calledWith({ - chainId: toHex(1337), - rpcUrl: 'https://mock-rpc-url', - type: NetworkClientType.Custom, - ticker: 'ABC', - }) - .mockReturnValue(fakeNetworkClients[1]); - await controller.initializeProvider(); - expect( - controller.state.networksMetadata[networkType].EIPS[1559], - ).toBe(true); + const detailsPost = + controller.state.networksMetadata[ + controller.state.selectedNetworkClientId + ]; - await waitForStateChanges({ - messenger, - propertyPath: [ - 'networksMetadata', - 'testNetworkConfigurationId', - 'EIPS', - ], - operation: async () => { - await controller.lookupNetwork(); - }, - }); + expect(detailsPost).toBe(detailsPre); + }); + }); + }); - expect( - controller.state.networksMetadata.testNetworkConfigurationId - .EIPS[1559], - ).toBe(false); - }, - ); - }); + describe('given an invalid Infura network name', () => { + it('throws', async () => { + await withController(async ({ controller }) => { + await expect(() => + // @ts-expect-error Intentionally passing invalid type + controller.setProviderType('invalid-infura-network'), + ).rejects.toThrow( + new Error('Unknown Infura provider type "invalid-infura-network".'), + ); + }); + }); + }); - it('emits infuraIsUnblocked, not infuraIsBlocked, assuming that the first network was blocked', async () => { - await withController( - { - state: { - providerConfig: buildProviderConfig({ type: networkType }), - networkConfigurations: { - testNetworkConfigurationId: { - id: 'testNetworkConfigurationId', - rpcUrl: 'https://mock-rpc-url', - chainId: toHex(1337), - ticker: 'ABC', - }, - }, - }, - infuraProjectId: 'some-infura-project-id', - }, - async ({ controller, messenger }) => { - const fakeProviders = [ - buildFakeProvider([ - // Called during provider initialization - { - request: { - method: 'eth_getBlockByNumber', - }, - response: SUCCESSFUL_ETH_GET_BLOCK_BY_NUMBER_RESPONSE, - }, - // Called via `lookupNetwork` directly - { - request: { - method: 'eth_getBlockByNumber', - }, - error: BLOCKED_INFURA_JSON_RPC_ERROR, - beforeCompleting: () => { - controller.setActiveNetwork( - 'testNetworkConfigurationId', - ); - }, - }, - ]), - buildFakeProvider([ - // Called when switching networks - { - request: { - method: 'eth_getBlockByNumber', - }, - response: SUCCESSFUL_ETH_GET_BLOCK_BY_NUMBER_RESPONSE, - }, - ]), - ]; - const fakeNetworkClients = [ - buildFakeClient(fakeProviders[0]), - buildFakeClient(fakeProviders[1]), - ]; - mockCreateNetworkClient() - .calledWith({ - network: networkType, - infuraProjectId: 'some-infura-project-id', - chainId: BUILT_IN_NETWORKS[networkType].chainId, - ticker: BUILT_IN_NETWORKS[networkType].ticker, - type: NetworkClientType.Infura, - }) - .mockReturnValue(fakeNetworkClients[0]) - .calledWith({ - chainId: toHex(1337), - rpcUrl: 'https://mock-rpc-url', - type: NetworkClientType.Custom, - ticker: 'ABC', - }) - .mockReturnValue(fakeNetworkClients[1]); - await controller.initializeProvider(); - const promiseForInfuraIsUnblockedEvents = - waitForPublishedEvents({ - messenger, - eventType: 'NetworkController:infuraIsUnblocked', - }); - const promiseForNoInfuraIsBlockedEvents = - waitForPublishedEvents({ - messenger, - eventType: 'NetworkController:infuraIsBlocked', - count: 0, - }); + it('is callable from the controller messenger', async () => { + await withController({}, async ({ controller, messenger }) => { + const fakeProvider = buildFakeProvider(); + const fakeNetworkClient = buildFakeClient(fakeProvider); + mockCreateNetworkClient().mockReturnValue(fakeNetworkClient); - await waitForStateChanges({ - messenger, - propertyPath: [ - 'networksMetadata', - 'testNetworkConfigurationId', - 'status', - ], - operation: async () => { - await controller.lookupNetwork(); - }, - }); + await messenger.call( + 'NetworkController:setProviderType', + TESTNET.networkType, + ); - await expect( - promiseForInfuraIsUnblockedEvents, - ).toBeFulfilled(); - await expect( - promiseForNoInfuraIsBlockedEvents, - ).toBeFulfilled(); - }, - ); - }); - }); + expect(controller.state.selectedNetworkClientId).toBe( + TESTNET.networkType, + ); + }); + }); + }); - lookupNetworkTests({ - expectedProviderConfig: buildProviderConfig({ type: networkType }), - initialState: { - providerConfig: buildProviderConfig({ type: networkType }), - }, - operation: async (controller) => { - await controller.lookupNetwork(); + describe('setActiveNetwork', () => { + describe('if the given ID does not refer to an existing network client', () => { + it('throws', async () => { + await withController(async ({ controller }) => { + await expect(() => + controller.setActiveNetwork('invalid-network-client-id'), + ).rejects.toThrow( + new Error( + 'No network client was found with ID "invalid-network-client-id".', + ), + ); + }); + }); + }); + + for (const infuraNetworkType of INFURA_NETWORKS) { + const infuraChainId = ChainId[infuraNetworkType]; + + describe(`if the ID refers to a network client created for the Infura network "${infuraNetworkType}"`, () => { + refreshNetworkTests({ + expectedNetworkClientConfiguration: + buildInfuraNetworkClientConfiguration(infuraNetworkType), + expectedNetworkClientId: infuraNetworkType, + initialState: { + selectedNetworkClientId: 'AAAA-AAAA-AAAA-AAAA', + networkConfigurationsByChainId: { + [infuraChainId]: + buildInfuraNetworkConfiguration(infuraNetworkType), + '0x1337': buildCustomNetworkConfiguration({ + chainId: '0x1337', + rpcEndpoints: [ + buildCustomRpcEndpoint({ + networkClientId: 'AAAA-AAAA-AAAA-AAAA', + }), + ], + }), }, + }, + operation: async (controller) => { + await controller.setActiveNetwork(infuraNetworkType); + }, + }); + + it(`sets selectedNetworkClientId in state to "${infuraNetworkType}"`, async () => { + await withController({}, async ({ controller }) => { + mockCreateNetworkClient().mockReturnValue(buildFakeClient()); + + await controller.setActiveNetwork(infuraNetworkType); + + expect(controller.state.selectedNetworkClientId).toStrictEqual( + infuraNetworkType, + ); }); }); - }, - ); + }); + } - describe(`when the provider config in state contains a network type of "rpc"`, () => { - describe('if the network was switched after the eth_getBlockByNumber request started but before it completed', () => { - it('stores the network status of the second network, not the first', async () => { - await withController( - { - state: { - providerConfig: buildProviderConfig({ - type: NetworkType.rpc, - chainId: toHex(1337), - rpcUrl: 'https://mock-rpc-url', + describe('if the ID refers to a custom network client', () => { + refreshNetworkTests({ + expectedNetworkClientConfiguration: + buildCustomNetworkClientConfiguration({ + rpcUrl: 'https://test.network', + chainId: '0x1337', + ticker: 'TEST', + }), + expectedNetworkClientId: 'AAAA-AAAA-AAAA-AAAA', + initialState: { + selectedNetworkClientId: InfuraNetworkType.mainnet, + networkConfigurationsByChainId: { + [ChainId.mainnet]: buildInfuraNetworkConfiguration( + InfuraNetworkType.mainnet, + ), + '0x1337': buildCustomNetworkConfiguration({ + chainId: '0x1337', + nativeCurrency: 'TEST', + rpcEndpoints: [ + buildCustomRpcEndpoint({ + networkClientId: 'AAAA-AAAA-AAAA-AAAA', + url: 'https://test.network', + }), + ], + }), + }, + }, + operation: async (controller) => { + await controller.setActiveNetwork('AAAA-AAAA-AAAA-AAAA'); + }, + }); + + it('assigns selectedNetworkClientId in state to the ID', async () => { + const testNetworkClientId = 'AAAA-AAAA-AAAA-AAAA'; + await withController( + { + state: { + selectedNetworkClientId: InfuraNetworkType.mainnet, + networkConfigurationsByChainId: { + [ChainId.mainnet]: buildInfuraNetworkConfiguration( + InfuraNetworkType.mainnet, + ), + '0x1337': buildCustomNetworkConfiguration({ + chainId: '0x1337', + nativeCurrency: 'TEST', + rpcEndpoints: [ + buildCustomRpcEndpoint({ + networkClientId: 'AAAA-AAAA-AAAA-AAAA', + url: 'https://test.network', + }), + ], }), }, - infuraProjectId: 'some-infura-project-id', }, - async ({ controller, messenger }) => { - const fakeProviders = [ - buildFakeProvider([ - // Called during provider initialization - { - request: { - method: 'eth_getBlockByNumber', - }, - response: SUCCESSFUL_ETH_GET_BLOCK_BY_NUMBER_RESPONSE, - }, - // Called via `lookupNetwork` directly - { - request: { - method: 'eth_getBlockByNumber', - }, - response: SUCCESSFUL_ETH_GET_BLOCK_BY_NUMBER_RESPONSE, - beforeCompleting: () => { - controller.setProviderType(NetworkType.goerli); - }, - }, - ]), - buildFakeProvider([ - // Called when switching networks - { - request: { - method: 'eth_getBlockByNumber', - }, - error: GENERIC_JSON_RPC_ERROR, - }, - ]), - ]; - const fakeNetworkClients = [ - buildFakeClient(fakeProviders[0]), - buildFakeClient(fakeProviders[1]), - ]; - mockCreateNetworkClient() - .calledWith({ - chainId: toHex(1337), - rpcUrl: 'https://mock-rpc-url', - type: NetworkClientType.Custom, - ticker: 'TEST', - }) - .mockReturnValue(fakeNetworkClients[0]) - .calledWith({ - network: NetworkType.goerli, - infuraProjectId: 'some-infura-project-id', - chainId: BUILT_IN_NETWORKS[NetworkType.goerli].chainId, - ticker: BUILT_IN_NETWORKS[InfuraNetworkType.goerli].ticker, - type: NetworkClientType.Infura, - }) - .mockReturnValue(fakeNetworkClients[1]); - await controller.initializeProvider(); - expect( - controller.state.networksMetadata['https://mock-rpc-url'] - .status, - ).toBe('available'); + }, + async ({ controller }) => { + mockCreateNetworkClient().mockReturnValue(buildFakeClient()); - await waitForStateChanges({ - messenger, - propertyPath: [ - 'networksMetadata', - NetworkType.goerli, - 'status', - ], - operation: async () => { - await controller.lookupNetwork(); - }, - }); + await controller.setActiveNetwork(testNetworkClientId); - expect( - controller.state.networksMetadata[NetworkType.goerli].status, - ).toBe('unknown'); + expect(controller.state.selectedNetworkClientId).toStrictEqual( + testNetworkClientId, + ); + }, + ); + }); + }); + + it('is able to be called via messenger action', async () => { + await withController( + { + state: { + selectedNetworkClientId: InfuraNetworkType.mainnet, + networkConfigurationsByChainId: { + [ChainId.mainnet]: buildInfuraNetworkConfiguration( + InfuraNetworkType.mainnet, + ), + '0x1337': buildCustomNetworkConfiguration({ + chainId: '0x1337', + nativeCurrency: 'TEST', + rpcEndpoints: [ + buildCustomRpcEndpoint({ + networkClientId: 'AAAA-AAAA-AAAA-AAAA', + url: 'https://test.network', + }), + ], + }), }, + }, + }, + async ({ controller, messenger }) => { + mockCreateNetworkClient().mockReturnValue(buildFakeClient()); + + await messenger.call( + 'NetworkController:setActiveNetwork', + 'AAAA-AAAA-AAAA-AAAA', ); - }); - it('stores the EIP-1559 support of the second network, not the first', async () => { - await withController( - { - state: { - providerConfig: buildProviderConfig({ - type: NetworkType.rpc, - chainId: toHex(1337), - rpcUrl: 'https://mock-rpc-url', - }), - }, - infuraProjectId: 'some-infura-project-id', - }, - async ({ controller, messenger }) => { - const fakeProviders = [ - buildFakeProvider([ - // Called during provider initialization - { - request: { - method: 'eth_getBlockByNumber', - }, - response: { - result: POST_1559_BLOCK, - }, - }, - // Called via `lookupNetwork` directly - { - request: { - method: 'eth_getBlockByNumber', - }, - response: { - result: POST_1559_BLOCK, - }, - beforeCompleting: () => { - controller.setProviderType(NetworkType.goerli); - }, - }, - ]), - buildFakeProvider([ - // Called when switching networks - { - request: { - method: 'eth_getBlockByNumber', - }, - response: { - result: PRE_1559_BLOCK, - }, - }, - ]), - ]; - const fakeNetworkClients = [ - buildFakeClient(fakeProviders[0]), - buildFakeClient(fakeProviders[1]), - ]; - mockCreateNetworkClient() - .calledWith({ - chainId: toHex(1337), - rpcUrl: 'https://mock-rpc-url', - type: NetworkClientType.Custom, - ticker: 'TEST', - }) - .mockReturnValue(fakeNetworkClients[0]) - .calledWith({ - network: NetworkType.goerli, - infuraProjectId: 'some-infura-project-id', - chainId: BUILT_IN_NETWORKS[NetworkType.goerli].chainId, - ticker: BUILT_IN_NETWORKS[InfuraNetworkType.goerli].ticker, - type: NetworkClientType.Infura, - }) - .mockReturnValue(fakeNetworkClients[1]); - await controller.initializeProvider(); - expect( - controller.state.networksMetadata['https://mock-rpc-url'] - .EIPS[1559], - ).toBe(true); - - await waitForStateChanges({ - messenger, - propertyPath: ['networksMetadata', NetworkType.goerli, 'EIPS'], - operation: async () => { - await controller.lookupNetwork(); - }, - }); - - expect( - controller.state.networksMetadata[NetworkType.goerli] - .EIPS[1559], - ).toBe(false); - expect( - controller.state.networksMetadata['https://mock-rpc-url'] - .EIPS[1559], - ).toBe(true); - }, + expect(controller.state.selectedNetworkClientId).toBe( + 'AAAA-AAAA-AAAA-AAAA', ); - }); + }, + ); + }); + }); - it('emits infuraIsBlocked, not infuraIsUnblocked, if the second network was blocked and the first network was not', async () => { - await withController( - { - state: { - providerConfig: buildProviderConfig({ - type: NetworkType.rpc, - chainId: toHex(1337), - rpcUrl: 'https://mock-rpc-url', - }), + describe('getEIP1559Compatibility', () => { + describe('if no provider has been set yet', () => { + it('does not make any state changes', async () => { + await withController( + { initializeController: false }, + async ({ controller, messenger }) => { + const promiseForNoStateChanges = waitForStateChanges({ + messenger, + count: 0, + operation: async () => { + await controller.getEIP1559Compatibility(); }, - infuraProjectId: 'some-infura-project-id', - }, - async ({ controller, messenger }) => { - const fakeProviders = [ - buildFakeProvider([ - // Called during provider initialization - { - request: { - method: 'eth_getBlockByNumber', - }, - response: SUCCESSFUL_ETH_GET_BLOCK_BY_NUMBER_RESPONSE, - }, - // Called via `lookupNetwork` directly - { - request: { - method: 'eth_getBlockByNumber', - }, - response: SUCCESSFUL_ETH_GET_BLOCK_BY_NUMBER_RESPONSE, - beforeCompleting: () => { - controller.setProviderType(NetworkType.goerli); - }, - }, - ]), - buildFakeProvider([ - // Called when switching networks - { - request: { - method: 'eth_getBlockByNumber', - }, - error: BLOCKED_INFURA_JSON_RPC_ERROR, - }, - ]), - ]; - const fakeNetworkClients = [ - buildFakeClient(fakeProviders[0]), - buildFakeClient(fakeProviders[1]), - ]; - mockCreateNetworkClient() - .calledWith({ - chainId: toHex(1337), - rpcUrl: 'https://mock-rpc-url', - type: NetworkClientType.Custom, - ticker: 'TEST', - }) - .mockReturnValue(fakeNetworkClients[0]) - .calledWith({ - network: NetworkType.goerli, - infuraProjectId: 'some-infura-project-id', - chainId: BUILT_IN_NETWORKS[NetworkType.goerli].chainId, - ticker: BUILT_IN_NETWORKS[InfuraNetworkType.goerli].ticker, - type: NetworkClientType.Infura, - }) - .mockReturnValue(fakeNetworkClients[1]); - await controller.initializeProvider(); - const promiseForNoInfuraIsUnblockedEvents = - waitForPublishedEvents({ - messenger, - eventType: 'NetworkController:infuraIsUnblocked', - count: 0, - }); - const promiseForInfuraIsBlockedEvents = waitForPublishedEvents({ - messenger, - eventType: 'NetworkController:infuraIsBlocked', - }); - - await waitForStateChanges({ - messenger, - propertyPath: [ - 'networksMetadata', - NetworkType.goerli, - 'status', - ], - operation: async () => { - await controller.lookupNetwork(); - }, - }); + }); - await expect(promiseForNoInfuraIsUnblockedEvents).toBeFulfilled(); - await expect(promiseForInfuraIsBlockedEvents).toBeFulfilled(); - }, - ); - }); + expect(Boolean(promiseForNoStateChanges)).toBe(true); + }, + ); }); - lookupNetworkTests({ - expectedProviderConfig: buildProviderConfig({ type: NetworkType.rpc }), - initialState: { - providerConfig: buildProviderConfig({ type: NetworkType.rpc }), - }, - operation: async (controller) => { - await controller.lookupNetwork(); - }, - }); - }); - }); + it('returns false', async () => { + await withController(async ({ controller }) => { + const isEIP1559Compatible = + await controller.getEIP1559Compatibility(); - describe('setProviderType', () => { - for (const { - networkType, - chainId, - ticker, - blockExplorerUrl, - } of INFURA_NETWORKS) { - describe(`given a network type of "${networkType}"`, () => { - refreshNetworkTests({ - expectedProviderConfig: buildProviderConfig({ - type: networkType, - }), - operation: async (controller) => { - await controller.setProviderType(networkType); - }, + expect(isEIP1559Compatible).toBe(false); }); }); + }); - it(`overwrites the provider configuration using a predetermined chainId, ticker, and blockExplorerUrl for "${networkType}", clearing id, rpcUrl, and nickname`, async () => { + describe('if a networkClientId is passed in', () => { + it('uses the built in state for networksMetadata', async () => { await withController( { state: { - providerConfig: { - type: 'rpc', - rpcUrl: 'https://mock-rpc-url', - chainId: '0x1337', - nickname: 'test-chain', - ticker: 'TEST', - rpcPrefs: { - blockExplorerUrl: 'https://test-block-explorer.com', + networksMetadata: { + 'linea-mainnet': { + EIPS: { + 1559: true, + }, + status: NetworkStatus.Unknown, }, }, }, }, async ({ controller }) => { - const fakeProvider = buildFakeProvider(); - const fakeNetworkClient = buildFakeClient(fakeProvider); - mockCreateNetworkClient().mockReturnValue(fakeNetworkClient); - - await controller.setProviderType(networkType); + const isEIP1559Compatible = + await controller.getEIP1559Compatibility('linea-mainnet'); - expect(controller.state.providerConfig).toStrictEqual({ - type: networkType, - rpcUrl: undefined, - chainId, - ticker, - nickname: undefined, - rpcPrefs: { blockExplorerUrl }, - id: undefined, - }); + expect(isEIP1559Compatible).toBe(true); }, ); }); - - it(`updates state.selectedNetworkClientId, setting it to ${networkType}`, async () => { - await withController({}, async ({ controller }) => { - const fakeProvider = buildFakeProvider(); - const fakeNetworkClient = buildFakeClient(fakeProvider); - mockCreateNetworkClient().mockReturnValue(fakeNetworkClient); - - await controller.setProviderType(networkType); - - expect(controller.state.selectedNetworkClientId).toStrictEqual( - networkType, - ); - }); - }); - } - - describe('given a network type of "rpc"', () => { - it('throws because there is no way to switch to a custom RPC endpoint using this method', async () => { + it('uses the built in false state for networksMetadata', async () => { await withController( { state: { - providerConfig: { - type: NetworkType.rpc, - rpcUrl: 'http://somethingexisting.com', - chainId: toHex(99999), - ticker: 'something existing', - nickname: 'something existing', + networksMetadata: { + 'linea-mainnet': { + EIPS: { + 1559: false, + }, + status: NetworkStatus.Unknown, + }, }, }, }, async ({ controller }) => { - await expect(() => - // @ts-expect-error Intentionally passing invalid type - controller.setProviderType(NetworkType.rpc), - ).rejects.toThrow( - 'NetworkController - cannot call "setProviderType" with type "rpc". Use "setActiveNetwork"', - ); + const isEIP1559Compatible = + await controller.getEIP1559Compatibility('linea-mainnet'); + + expect(isEIP1559Compatible).toBe(false); }, ); }); - - it("doesn't set a provider", async () => { - await withController(async ({ controller }) => { - const fakeProvider = buildFakeProvider(); - const fakeNetworkClient = buildFakeClient(fakeProvider); - createNetworkClientMock.mockReturnValue(fakeNetworkClient); - - try { - // @ts-expect-error Intentionally passing invalid type - await controller.setProviderType(NetworkType.rpc); - } catch { - // catch the rejection (it is tested above) - } - - expect(createNetworkClientMock).not.toHaveBeenCalled(); - expect( - controller.getProviderAndBlockTracker().provider, - ).toBeUndefined(); - }); - }); - - it('does not update networksMetadata[...].EIPS in state', async () => { - await withController(async ({ controller }) => { - const fakeProvider = buildFakeProvider([ - { - request: { - method: 'eth_getBlockByNumber', - params: ['latest', false], - }, - response: { - result: { - baseFeePerGas: '0x1', + it('updates network metadata if undefined', async () => { + await withController( + { + infuraProjectId: 'some-infura-project-id', + }, + async ({ controller }) => { + await setFakeProvider(controller, { + stubs: [ + { + request: { + method: 'eth_getBlockByNumber', + params: ['latest', false], + }, + response: { + result: POST_1559_BLOCK, + }, }, - }, - }, - ]); - const fakeNetworkClient = buildFakeClient(fakeProvider); - createNetworkClientMock.mockReturnValue(fakeNetworkClient); - - const detailsPre = - controller.state.networksMetadata[ - controller.state.selectedNetworkClientId - ]; - - try { - // @ts-expect-error Intentionally passing invalid type - await controller.setProviderType(NetworkType.rpc); - } catch { - // catch the rejection (it is tested above) - } - - const detailsPost = - controller.state.networksMetadata[ - controller.state.selectedNetworkClientId - ]; - - expect(detailsPost).toBe(detailsPre); - }); - }); - }); - - describe('given an invalid Infura network name', () => { - it('throws', async () => { - await withController(async ({ controller }) => { - await expect(() => - // @ts-expect-error Intentionally passing invalid type - controller.setProviderType('invalid-infura-network'), - ).rejects.toThrow( - new Error('Unknown Infura provider type "invalid-infura-network".'), - ); - }); - }); - }); - }); - - describe('setActiveNetwork', () => { - refreshNetworkTests({ - expectedProviderConfig: { - rpcUrl: 'https://mock-rpc-url', - chainId: toHex(111), - ticker: 'TEST', - nickname: 'something existing', - id: 'testNetworkConfigurationId', - rpcPrefs: undefined, - type: NetworkType.rpc, - }, - initialState: { - networkConfigurations: { - testNetworkConfigurationId: { - rpcUrl: 'https://mock-rpc-url', - chainId: toHex(111), - ticker: 'TEST', - nickname: 'something existing', - id: 'testNetworkConfigurationId', - rpcPrefs: undefined, - }, - }, - }, - operation: async (controller) => { - await controller.setActiveNetwork('testNetworkConfigurationId'); - }, - }); - - describe('if the given ID does not match a network configuration in networkConfigurations', () => { - it('throws', async () => { - await withController( - { - state: { - networkConfigurations: { - testNetworkConfigurationId: { - rpcUrl: 'https://mock-rpc-url', - chainId: toHex(111), - ticker: 'TEST', - id: 'testNetworkConfigurationId', - }, - }, - }, - }, - async ({ controller }) => { - const fakeProvider = buildFakeProvider(); - const fakeNetworkClient = buildFakeClient(fakeProvider); - mockCreateNetworkClient().mockReturnValue(fakeNetworkClient); - - await expect(() => - controller.setActiveNetwork('invalidNetworkConfigurationId'), - ).rejects.toThrow( - new Error( - 'networkConfigurationId invalidNetworkConfigurationId does not match a configured networkConfiguration', - ), - ); - }, - ); - }); - }); - - describe('if the network config does not contain an RPC URL', () => { - it('throws', async () => { - await withController( - // @ts-expect-error RPC URL intentionally omitted - { - state: { - providerConfig: { - type: NetworkType.rpc, - rpcUrl: 'https://mock-rpc-url', - chainId: toHex(111), - ticker: 'TEST', - nickname: 'something existing', - rpcPrefs: undefined, - }, - networkConfigurations: { - testNetworkConfigurationId1: { - rpcUrl: 'https://mock-rpc-url', - chainId: toHex(111), - ticker: 'TEST', - nickname: 'something existing', - id: 'testNetworkConfigurationId1', - rpcPrefs: undefined, - }, - testNetworkConfigurationId2: { - rpcUrl: undefined, - chainId: toHex(222), - ticker: 'something existing', - nickname: 'something existing', - id: 'testNetworkConfigurationId2', - rpcPrefs: undefined, - }, - }, - }, - }, - async ({ controller }) => { - const fakeProvider = buildFakeProvider(); - const fakeNetworkClient = buildFakeClient(fakeProvider); - createNetworkClientMock.mockReturnValue(fakeNetworkClient); - - await expect(() => - controller.setActiveNetwork('testNetworkConfigurationId2'), - ).rejects.toThrow( - 'rpcUrl must be provided for custom RPC endpoints', - ); - - expect(createNetworkClientMock).not.toHaveBeenCalled(); - const { provider, blockTracker } = - controller.getProviderAndBlockTracker(); - expect(provider).toBeUndefined(); - expect(blockTracker).toBeUndefined(); - }, - ); - }); - }); - - describe('if the network config does not contain a chain ID', () => { - it('throws', async () => { - await withController( - // @ts-expect-error chain ID intentionally omitted - { - state: { - providerConfig: { - type: NetworkType.rpc, - rpcUrl: 'https://mock-rpc-url', - chainId: toHex(111), - ticker: 'TEST', - nickname: 'something existing', - rpcPrefs: undefined, - }, - networkConfigurations: { - testNetworkConfigurationId1: { - rpcUrl: 'https://mock-rpc-url', - chainId: toHex(111), - ticker: 'TEST', - nickname: 'something existing', - id: 'testNetworkConfigurationId1', - rpcPrefs: undefined, - }, - testNetworkConfigurationId2: { - rpcUrl: 'http://somethingexisting.com', - chainId: undefined, - ticker: 'something existing', - nickname: 'something existing', - id: 'testNetworkConfigurationId2', - rpcPrefs: undefined, - }, - }, - }, - }, - async ({ controller }) => { - const fakeProvider = buildFakeProvider(); - const fakeNetworkClient = buildFakeClient(fakeProvider); - createNetworkClientMock.mockReturnValue(fakeNetworkClient); - - await expect(() => - controller.setActiveNetwork('testNetworkConfigurationId2'), - ).rejects.toThrow( - 'chainId must be provided for custom RPC endpoints', - ); - - expect(createNetworkClientMock).not.toHaveBeenCalled(); - const { provider, blockTracker } = - controller.getProviderAndBlockTracker(); - expect(provider).toBeUndefined(); - expect(blockTracker).toBeUndefined(); - }, - ); - }); - }); - - it('overwrites the provider configuration given a networkConfigurationId that matches a configured networkConfiguration', async () => { - await withController( - { - state: { - networkConfigurations: { - testNetworkConfigurationId: { - rpcUrl: 'https://mock-rpc-url', - chainId: toHex(111), - ticker: 'TEST', - nickname: 'something existing', - id: 'testNetworkConfigurationId', - rpcPrefs: { - blockExplorerUrl: 'https://test-block-explorer-2.com', - }, - }, - }, - }, - }, - async ({ controller }) => { - const fakeProvider = buildFakeProvider(); - const fakeNetworkClient = buildFakeClient(fakeProvider); - mockCreateNetworkClient() - .calledWith({ - rpcUrl: 'https://mock-rpc-url', - chainId: toHex(111), - type: NetworkClientType.Custom, - ticker: 'TEST', - }) - .mockReturnValue(fakeNetworkClient); - - await controller.setActiveNetwork('testNetworkConfigurationId'); - - expect(controller.state.providerConfig).toStrictEqual({ - type: 'rpc', - rpcUrl: 'https://mock-rpc-url', - chainId: toHex(111), - ticker: 'TEST', - nickname: 'something existing', - id: 'testNetworkConfigurationId', - rpcPrefs: { - blockExplorerUrl: 'https://test-block-explorer-2.com', - }, - }); - }, - ); - }); - - it('updates state.selectedNetworkClientId setting it to the networkConfiguration.id', async () => { - const testNetworkClientId = 'testNetworkConfigurationId'; - await withController( - { - state: { - networkConfigurations: { - [testNetworkClientId]: { - rpcUrl: 'https://mock-rpc-url', - chainId: toHex(111), - ticker: 'TEST', - nickname: 'something existing', - id: testNetworkClientId, - rpcPrefs: { - blockExplorerUrl: 'https://test-block-explorer-2.com', - }, - }, - }, - }, - }, - async ({ controller }) => { - const fakeProvider = buildFakeProvider(); - const fakeNetworkClient = buildFakeClient(fakeProvider); - mockCreateNetworkClient() - .calledWith({ - rpcUrl: 'https://mock-rpc-url', - chainId: toHex(111), - type: NetworkClientType.Custom, - ticker: 'TEST', - }) - .mockReturnValue(fakeNetworkClient); - - await controller.setActiveNetwork(testNetworkClientId); - - expect(controller.state.selectedNetworkClientId).toStrictEqual( - testNetworkClientId, - ); - }, - ); - }); - }); - - describe('getEIP1559Compatibility', () => { - describe('if no provider has been set yet', () => { - it('does not make any state changes', async () => { - await withController(async ({ controller, messenger }) => { - const promiseForNoStateChanges = waitForStateChanges({ - messenger, - count: 0, - operation: async () => { - await controller.getEIP1559Compatibility(); - }, - }); - - expect(Boolean(promiseForNoStateChanges)).toBe(true); - }); - }); - - it('returns false', async () => { - await withController(async ({ controller }) => { - const isEIP1559Compatible = - await controller.getEIP1559Compatibility(); - - expect(isEIP1559Compatible).toBe(false); - }); - }); - }); - - describe('if a networkClientId is passed in', () => { - it('uses the built in state for networksMetadata', async () => { - await withController( - { - state: { - networksMetadata: { - 'linea-mainnet': { - EIPS: { - 1559: true, + { + request: { + method: 'eth_getBlockByNumber', + params: ['latest', false], + }, + response: { + result: POST_1559_BLOCK, }, - status: NetworkStatus.Unknown, }, - }, - }, - }, - async ({ controller }) => { + ], + }); const isEIP1559Compatible = await controller.getEIP1559Compatibility('linea-mainnet'); - expect(isEIP1559Compatible).toBe(true); }, ); }); - it('uses the built in false state for networksMetadata', async () => { + it('updates network metadata if EIP-1559 compatibility is missing', async () => { await withController( { + infuraProjectId: 'some-infura-project-id', state: { networksMetadata: { 'linea-mainnet': { - EIPS: { - 1559: false, - }, + EIPS: {}, status: NetworkStatus.Unknown, }, }, }, }, - async ({ controller }) => { - const isEIP1559Compatible = - await controller.getEIP1559Compatibility('linea-mainnet'); - - expect(isEIP1559Compatible).toBe(false); - }, - ); - }); - it('calls provider of the networkClientId and returns true', async () => { - await withController( - { - infuraProjectId: 'some-infura-project-id', - }, async ({ controller }) => { await setFakeProvider(controller, { stubs: [ @@ -3229,6 +3614,8 @@ describe('NetworkController', () => { }, }, async ({ controller, messenger }) => { + // TODO: Either fix this lint violation or explain why it's necessary to ignore. + // eslint-disable-next-line @typescript-eslint/no-floating-promises setFakeProvider(controller, { stubLookupNetworkWhileSetting: true, }); @@ -3260,6 +3647,8 @@ describe('NetworkController', () => { }, }, async ({ controller }) => { + // TODO: Either fix this lint violation or explain why it's necessary to ignore. + // eslint-disable-next-line @typescript-eslint/no-floating-promises setFakeProvider(controller, { stubLookupNetworkWhileSetting: true, }); @@ -3278,6 +3667,8 @@ describe('NetworkController', () => { describe('if the latest block has a "baseFeePerGas" property', () => { it('sets the "1559" property to true', async () => { await withController(async ({ controller }) => { + // TODO: Either fix this lint violation or explain why it's necessary to ignore. + // eslint-disable-next-line @typescript-eslint/no-floating-promises setFakeProvider(controller, { stubs: [ { @@ -3305,6 +3696,8 @@ describe('NetworkController', () => { it('returns true', async () => { await withController(async ({ controller }) => { + // TODO: Either fix this lint violation or explain why it's necessary to ignore. + // eslint-disable-next-line @typescript-eslint/no-floating-promises setFakeProvider(controller, { stubs: [ { @@ -3331,6 +3724,8 @@ describe('NetworkController', () => { describe('if the latest block does not have a "baseFeePerGas" property', () => { it('sets the "1559" property to false', async () => { await withController(async ({ controller }) => { + // TODO: Either fix this lint violation or explain why it's necessary to ignore. + // eslint-disable-next-line @typescript-eslint/no-floating-promises setFakeProvider(controller, { stubs: [ { @@ -3358,6 +3753,8 @@ describe('NetworkController', () => { it('returns false', async () => { await withController(async ({ controller }) => { + // TODO: Either fix this lint violation or explain why it's necessary to ignore. + // eslint-disable-next-line @typescript-eslint/no-floating-promises setFakeProvider(controller, { stubs: [ { @@ -3393,6 +3790,8 @@ describe('NetworkController', () => { }; it('keeps the "1559" property as undefined', async () => { await withController(async ({ controller }) => { + // TODO: Either fix this lint violation or explain why it's necessary to ignore. + // eslint-disable-next-line @typescript-eslint/no-floating-promises setFakeProvider(controller, { stubs: [latestBlockRespondsNull], stubLookupNetworkWhileSetting: true, @@ -3410,6 +3809,8 @@ describe('NetworkController', () => { it('returns undefined', async () => { await withController(async ({ controller }) => { + // TODO: Either fix this lint violation or explain why it's necessary to ignore. + // eslint-disable-next-line @typescript-eslint/no-floating-promises setFakeProvider(controller, { stubs: [latestBlockRespondsNull], stubLookupNetworkWhileSetting: true, @@ -3427,6 +3828,8 @@ describe('NetworkController', () => { describe('if the request for the latest block is unsuccessful', () => { it('does not make any state changes', async () => { await withController(async ({ controller, messenger }) => { + // TODO: Either fix this lint violation or explain why it's necessary to ignore. + // eslint-disable-next-line @typescript-eslint/no-floating-promises setFakeProvider(controller, { stubs: [ { @@ -3446,7 +3849,7 @@ describe('NetworkController', () => { operation: async () => { try { await controller.getEIP1559Compatibility(); - } catch (error) { + } catch { // ignore error } }, @@ -3460,27 +3863,45 @@ describe('NetworkController', () => { }); describe('resetConnection', () => { - [NetworkType.mainnet, NetworkType.goerli, NetworkType.sepolia].forEach( - (networkType) => { - describe(`when the type in the provider configuration is "${networkType}"`, () => { - refreshNetworkTests({ - expectedProviderConfig: buildProviderConfig({ type: networkType }), - initialState: { - providerConfig: buildProviderConfig({ type: networkType }), - }, - operation: async (controller) => { - await controller.resetConnection(); - }, - }); + for (const infuraNetworkType of INFURA_NETWORKS) { + describe(`when the selected network client represents the Infura network "${infuraNetworkType}"`, () => { + refreshNetworkTests({ + expectedNetworkClientConfiguration: + buildInfuraNetworkClientConfiguration(infuraNetworkType), + expectedNetworkClientId: infuraNetworkType, + initialState: { + selectedNetworkClientId: infuraNetworkType, + }, + operation: async (controller) => { + await controller.resetConnection(); + }, }); - }, - ); + }); + } - describe(`when the type in the provider configuration is "rpc"`, () => { + describe('when the selected network client represents a custom RPC endpoint', () => { refreshNetworkTests({ - expectedProviderConfig: buildProviderConfig({ type: NetworkType.rpc }), + expectedNetworkClientConfiguration: + buildCustomNetworkClientConfiguration({ + rpcUrl: 'https://test.network', + chainId: '0x1337', + ticker: 'TEST', + }), + expectedNetworkClientId: 'AAAA-AAAA-AAAA-AAAA', initialState: { - providerConfig: buildProviderConfig({ type: NetworkType.rpc }), + selectedNetworkClientId: 'AAAA-AAAA-AAAA-AAAA', + networkConfigurationsByChainId: { + '0x1337': buildCustomNetworkConfiguration({ + chainId: '0x1337', + nativeCurrency: 'TEST', + rpcEndpoints: [ + buildCustomRpcEndpoint({ + networkClientId: 'AAAA-AAAA-AAAA-AAAA', + url: 'https://test.network', + }), + ], + }), + }, }, operation: async (controller) => { await controller.resetConnection(); @@ -3489,31 +3910,6 @@ describe('NetworkController', () => { }); }); - describe('NetworkController:getProviderConfig action', () => { - it('returns the provider config in state', async () => { - await withController( - { - state: { - providerConfig: { - type: NetworkType.mainnet, - ...BUILT_IN_NETWORKS.mainnet, - }, - }, - }, - async ({ messenger }) => { - const providerConfig = await messenger.call( - 'NetworkController:getProviderConfig', - ); - - expect(providerConfig).toStrictEqual({ - type: NetworkType.mainnet, - ...BUILT_IN_NETWORKS.mainnet, - }); - }, - ); - }); - }); - describe('NetworkController:getEthQuery action', () => { it('returns a EthQuery object that can be used to make requests to the currently selected network', async () => { await withController(async ({ controller, messenger }) => { @@ -3534,8 +3930,8 @@ describe('NetworkController', () => { const ethQuery = messenger.call('NetworkController:getEthQuery'); assert(ethQuery, 'ethQuery is not set'); - const promisifiedSendAsync = promisify(ethQuery.sendAsync).bind( - ethQuery, + const promisifiedSendAsync = promisify( + ethQuery.sendAsync.bind(ethQuery), ); const result = await promisifiedSendAsync({ id: 1, @@ -3548,7 +3944,7 @@ describe('NetworkController', () => { }); it('returns undefined if the provider has not been set yet', async () => { - await withController(({ messenger }) => { + await withController({ initializeController: false }, ({ messenger }) => { const ethQuery = messenger.call('NetworkController:getEthQuery'); expect(ethQuery).toBeUndefined(); @@ -3556,2371 +3952,11680 @@ describe('NetworkController', () => { }); }); - describe('upsertNetworkConfiguration', () => { - describe('when the rpcUrl of the given network configuration does not match an existing network configuration', () => { - it('adds the network configuration to state without updating or removing any existing network configurations', async () => { - await withController( - { - state: { - networkConfigurations: { - 'AAAA-AAAA-AAAA-AAAA': { - rpcUrl: 'https://test.network.1', - chainId: toHex(111), - ticker: 'TICKER1', - id: 'AAAA-AAAA-AAAA-AAAA', - }, - }, - }, - }, - async ({ controller }) => { - uuidV4Mock.mockReturnValue('BBBB-BBBB-BBBB-BBBB'); - - await controller.upsertNetworkConfiguration( + for (const [name, getNetworkConfigurationByChainId] of [ + [ + 'getNetworkConfigurationByChainId', + ({ + controller, + chainId, + }: { + controller: NetworkController; + chainId: Hex; + }): NetworkConfiguration | undefined => + controller.getNetworkConfigurationByChainId(chainId), + ], + [ + 'NetworkController:getNetworkConfigurationByChainId', + ({ + messenger, + chainId, + }: { + messenger: RootMessenger; + chainId: Hex; + }): NetworkConfiguration | undefined => + messenger.call( + 'NetworkController:getNetworkConfigurationByChainId', + chainId, + ), + ], + ] as const) { + // This is a string! + // eslint-disable-next-line jest/valid-title + describe(name, () => { + for (const infuraNetworkType of INFURA_NETWORKS) { + const infuraChainId = ChainId[infuraNetworkType]; + + describe(`given the ID of the Infura-supported chain "${infuraNetworkType}" that a network configuration is filed under`, () => { + it('returns the network configuration', async () => { + const registeredNetworkConfiguration = + buildInfuraNetworkConfiguration(infuraNetworkType); + await withController( { - rpcUrl: 'https://test.network.2', - chainId: toHex(222), - ticker: 'TICKER2', - nickname: 'test network 2', - rpcPrefs: { - blockExplorerUrl: 'https://testchainscan.io', - }, + state: + buildNetworkControllerStateWithDefaultSelectedNetworkClientId( + { + networkConfigurationsByChainId: { + [infuraChainId]: registeredNetworkConfiguration, + }, + }, + ), }, - { - referrer: 'https://test-dapp.com', - source: 'dapp', + ({ controller, messenger }) => { + const returnedNetworkConfiguration = + getNetworkConfigurationByChainId({ + controller, + messenger, + chainId: infuraChainId, + }); + + expect(returnedNetworkConfiguration).toBe( + registeredNetworkConfiguration, + ); }, ); + }); + }); + } - expect(controller.state.networkConfigurations).toStrictEqual({ - 'AAAA-AAAA-AAAA-AAAA': { - rpcUrl: 'https://test.network.1', - chainId: toHex(111), - ticker: 'TICKER1', - id: 'AAAA-AAAA-AAAA-AAAA', - }, - 'BBBB-BBBB-BBBB-BBBB': { - rpcUrl: 'https://test.network.2', - chainId: toHex(222), - ticker: 'TICKER2', - nickname: 'test network 2', - rpcPrefs: { - blockExplorerUrl: 'https://testchainscan.io', - }, - id: 'BBBB-BBBB-BBBB-BBBB', - }, + describe('given the ID of a non-Infura-supported chain that a network configuration is filed under', () => { + it('returns the network configuration', async () => { + const registeredNetworkConfiguration = + buildCustomNetworkConfiguration({ + chainId: '0x1337', }); - }, - ); - }); - - it('removes properties not specific to the NetworkConfiguration interface before persisting it to state', async function () { - await withController(async ({ controller }) => { - uuidV4Mock.mockReturnValue('AAAA-AAAA-AAAA-AAAA'); - - await controller.upsertNetworkConfiguration( + await withController( { - rpcUrl: 'https://test.network', - chainId: toHex(111), - ticker: 'TICKER', - nickname: 'test network', - rpcPrefs: { - blockExplorerUrl: 'https://testchainscan.io', - }, - // @ts-expect-error We are intentionally passing bad input. - invalidKey: 'some value', + state: + buildNetworkControllerStateWithDefaultSelectedNetworkClientId({ + networkConfigurationsByChainId: { + '0x1337': registeredNetworkConfiguration, + }, + }), }, - { - referrer: 'https://test-dapp.com', - source: 'dapp', + ({ controller, messenger }) => { + const returnedNetworkConfiguration = + getNetworkConfigurationByChainId({ + controller, + messenger, + chainId: '0x1337', + }); + + expect(returnedNetworkConfiguration).toBe( + registeredNetworkConfiguration, + ); }, ); + }); + }); - expect(controller.state.networkConfigurations).toStrictEqual({ - 'AAAA-AAAA-AAAA-AAAA': { - rpcUrl: 'https://test.network', - chainId: toHex(111), - ticker: 'TICKER', - nickname: 'test network', - rpcPrefs: { - blockExplorerUrl: 'https://testchainscan.io', - }, - id: 'AAAA-AAAA-AAAA-AAAA', - }, + describe('given the ID of a chain that no network configuration is filed under', () => { + it('returns undefined', async () => { + await withController(({ controller, messenger }) => { + const returnedNetworkConfiguration = + getNetworkConfigurationByChainId({ + controller, + messenger, + chainId: '0x9999999999999', + }); + + expect(returnedNetworkConfiguration).toBeUndefined(); }); }); }); + }); + } - it('creates a new network client for the network configuration and adds it to the registry', async () => { - await withController( - { infuraProjectId: 'some-infura-project-id' }, - async ({ controller }) => { - uuidV4Mock.mockReturnValue('AAAA-AAAA-AAAA-AAAA'); - const newCustomNetworkClient = buildFakeClient(); - mockCreateNetworkClientWithDefaultsForBuiltInNetworkClients({ - infuraProjectId: 'some-infura-project-id', - }) - .calledWith({ - chainId: toHex(111), - rpcUrl: 'https://test.network', - type: NetworkClientType.Custom, - ticker: 'TICKER', - }) - .mockReturnValue(newCustomNetworkClient); - - await controller.upsertNetworkConfiguration( + for (const [name, getNetworkConfigurationByNetworkClientId] of [ + [ + 'getNetworkConfigurationByNetworkClientId', + ({ + controller, + networkClientId, + }: { + controller: NetworkController; + networkClientId: NetworkClientId; + }): NetworkConfiguration | undefined => + controller.getNetworkConfigurationByNetworkClientId(networkClientId), + ], + [ + 'NetworkController:getNetworkConfigurationByNetworkClientId', + ({ + messenger, + networkClientId, + }: { + messenger: RootMessenger; + networkClientId: NetworkClientId; + }): NetworkConfiguration | undefined => + messenger.call( + 'NetworkController:getNetworkConfigurationByNetworkClientId', + networkClientId, + ), + ], + ] as const) { + // This is a string! + // eslint-disable-next-line jest/valid-title + describe(name, () => { + for (const infuraNetworkType of INFURA_NETWORKS) { + const infuraChainId = ChainId[infuraNetworkType]; + + describe(`given the ID of a network client that corresponds to an RPC endpoint for the Infura network "${infuraNetworkType}" in a network configuration`, () => { + it('returns the network configuration', async () => { + const registeredNetworkConfiguration = + buildInfuraNetworkConfiguration(infuraNetworkType); + await withController( { - rpcUrl: 'https://test.network', - chainId: toHex(111), - ticker: 'TICKER', + state: { + selectedNetworkClientId: infuraNetworkType, + networkConfigurationsByChainId: { + [infuraChainId]: registeredNetworkConfiguration, + }, + }, }, - { - referrer: 'https://test-dapp.com', - source: 'dapp', + ({ controller, messenger }) => { + const returnedNetworkConfiguration = + getNetworkConfigurationByNetworkClientId({ + controller, + messenger, + networkClientId: infuraNetworkType, + }); + + expect(returnedNetworkConfiguration).toBe( + registeredNetworkConfiguration, + ); }, ); + }); + }); + } - const networkClients = controller.getNetworkClientRegistry(); - expect(Object.keys(networkClients)).toHaveLength(6); - expect(networkClients).toMatchObject({ - 'AAAA-AAAA-AAAA-AAAA': expect.objectContaining({ - configuration: { - chainId: toHex(111), - rpcUrl: 'https://test.network', - type: NetworkClientType.Custom, - ticker: 'TICKER', - }, - }), + describe('given the ID of a network client that corresponds to a custom RPC endpoint in a network configuration', () => { + it('returns the network configuration', async () => { + const registeredNetworkConfiguration = + buildCustomNetworkConfiguration({ + chainId: '0x1337', + rpcEndpoints: [ + buildCustomRpcEndpoint({ + networkClientId: 'AAAA-AAAA-AAAA-AAAA', + }), + ], }); - }, - ); - }); - - describe('if the setActive option is not given', () => { - it('does not update the provider config to the new network configuration by default', async () => { - const originalProvider = { - type: NetworkType.rpc, - rpcUrl: 'https://mock-rpc-url', - chainId: toHex(111), - ticker: 'TICKER', - id: 'testNetworkConfigurationId', - }; - await withController( { state: { - providerConfig: originalProvider, + selectedNetworkClientId: 'AAAA-AAAA-AAAA-AAAA', + networkConfigurationsByChainId: { + '0x1337': registeredNetworkConfiguration, + }, }, }, - async ({ controller }) => { - uuidV4Mock.mockReturnValue('AAAA-AAAA-AAAA-AAAA'); - - await controller.upsertNetworkConfiguration( - { - rpcUrl: 'https://test.network', - chainId: toHex(111), - ticker: 'TICKER', - }, - { - referrer: 'https://test-dapp.com', - source: 'dapp', - }, - ); + ({ controller, messenger }) => { + const returnedNetworkConfiguration = + getNetworkConfigurationByNetworkClientId({ + controller, + messenger, + networkClientId: 'AAAA-AAAA-AAAA-AAAA', + }); - expect(controller.state.providerConfig).toStrictEqual( - originalProvider, + expect(returnedNetworkConfiguration).toBe( + registeredNetworkConfiguration, ); }, ); }); + }); - it('does not set the new network to active by default', async () => { - await withController( - { infuraProjectId: 'some-infura-project-id' }, - async ({ controller }) => { - uuidV4Mock.mockReturnValue('AAAA-AAAA-AAAA-AAAA'); - const builtInNetworkProvider = buildFakeProvider([ - { - request: { - method: 'test_method', - params: [], - }, - response: { - result: 'test response from built-in network', - }, - }, - ]); - const builtInNetworkClient = buildFakeClient( - builtInNetworkProvider, - ); - const newCustomNetworkProvider = buildFakeProvider([ - { - request: { - method: 'test_method', - params: [], - }, - response: { - result: 'test response from custom network', - }, - }, - ]); - const newCustomNetworkClient = buildFakeClient( - newCustomNetworkProvider, - ); - mockCreateNetworkClientWithDefaultsForBuiltInNetworkClients({ - builtInNetworkClient, - infuraProjectId: 'some-infura-project-id', - }) - .calledWith({ - chainId: toHex(111), - rpcUrl: 'https://test.network', - type: NetworkClientType.Custom, - ticker: 'TEST', - }) - .mockReturnValue(newCustomNetworkClient); - // Will use mainnet by default - await controller.initializeProvider(); - - await controller.upsertNetworkConfiguration( - { - rpcUrl: 'https://test.network', - chainId: toHex(111), - ticker: 'TICKER', - }, - { - referrer: 'https://test-dapp.com', - source: 'dapp', - }, - ); + describe('given the ID of a network client that does not correspond to any RPC endpoint in a network configuration', () => { + it('returns undefined', async () => { + await withController(({ controller, messenger }) => { + const returnedNetworkConfiguration = + getNetworkConfigurationByNetworkClientId({ + controller, + messenger, + networkClientId: 'nonexistent', + }); - const { provider } = controller.getProviderAndBlockTracker(); - assert(provider, 'Provider is not set'); - const { result } = await promisify(provider.sendAsync).call( - provider, - { - id: 1, - jsonrpc: '2.0', - method: 'test_method', - params: [], - }, - ); - expect(result).toBe('test response from built-in network'); - }, - ); + expect(returnedNetworkConfiguration).toBeUndefined(); + }); }); }); + }); + } - describe('if the setActive option is false', () => { - it('does not update the provider config to the new network configuration by default', async () => { - const originalProvider = { - type: NetworkType.rpc, - rpcUrl: 'https://mock-rpc-url', - chainId: toHex(111), - ticker: 'TICKER', - id: 'testNetworkConfigurationId', - }; + describe('addNetwork', () => { + it('throws if the chainId field is a string, but not a 0x-prefixed hex number', async () => { + await withController(({ controller }) => { + expect(() => + controller.addNetwork( + buildAddNetworkFields({ + // @ts-expect-error Intentionally passing bad input + chainId: '12345', + }), + ), + ).toThrow( + new Error( + `Could not add network: Invalid \`chainId\` '12345' (must start with "0x" and not exceed the maximum)`, + ), + ); + }); + }); - await withController( - { - state: { - providerConfig: originalProvider, - }, - }, - async ({ controller }) => { - uuidV4Mock.mockReturnValue('AAAA-AAAA-AAAA-AAAA'); + it('throws if the chainId field is greater than the maximum allowed chain ID', async () => { + await withController(({ controller }) => { + expect(() => + controller.addNetwork( + buildAddNetworkFields({ + chainId: toHex(MAX_SAFE_CHAIN_ID + 1), + }), + ), + ).toThrow( + new Error( + `Could not add network: Invalid \`chainId\` '0xfffffffffffed' (must start with "0x" and not exceed the maximum)`, + ), + ); + }); + }); - await controller.upsertNetworkConfiguration( - { - rpcUrl: 'https://test.network', - chainId: toHex(111), - ticker: 'TICKER', - }, - { - setActive: false, - referrer: 'https://test-dapp.com', - source: 'dapp', - }, - ); + it('throws if defaultBlockExplorerUrlIndex does not refer to an entry in blockExplorerUrls', async () => { + await withController(({ controller }) => { + expect(() => + controller.addNetwork( + buildAddNetworkFields({ + blockExplorerUrls: [], + defaultBlockExplorerUrlIndex: 99999, + }), + ), + ).toThrow( + new Error( + 'Could not add network: `defaultBlockExplorerUrlIndex` must refer to an entry in `blockExplorerUrls`', + ), + ); + }); + }); - expect(controller.state.providerConfig).toStrictEqual( - originalProvider, - ); - }, - ); - }); + it('throws if blockExplorerUrls is non-empty, but defaultBlockExplorerUrlIndex is missing', async () => { + await withController(({ controller }) => { + expect(() => + controller.addNetwork( + buildAddNetworkFields({ + blockExplorerUrls: ['https://block.explorer'], + }), + ), + ).toThrow( + new Error( + 'Could not add network: `defaultBlockExplorerUrlIndex` must refer to an entry in `blockExplorerUrls`', + ), + ); + }); + }); - it('does not set the new network to active by default', async () => { - await withController( - { infuraProjectId: 'some-infura-project-id' }, - async ({ controller }) => { - uuidV4Mock.mockReturnValue('AAAA-AAAA-AAAA-AAAA'); - const builtInNetworkProvider = buildFakeProvider([ - { - request: { - method: 'test_method', - params: [], - }, - response: { - result: 'test response from built-in network', - }, - }, - ]); - const builtInNetworkClient = buildFakeClient( - builtInNetworkProvider, - ); - const newCustomNetworkProvider = buildFakeProvider([ - { - request: { - method: 'test_method', - params: [], - }, - response: { - result: 'test response from custom network', - }, - }, - ]); - const newCustomNetworkClient = buildFakeClient( - newCustomNetworkProvider, - ); - mockCreateNetworkClientWithDefaultsForBuiltInNetworkClients({ - builtInNetworkClient, - infuraProjectId: 'some-infura-project-id', - }) - .calledWith({ - chainId: toHex(111), - rpcUrl: 'https://test.network', - type: NetworkClientType.Custom, - ticker: 'TEST', - }) - .mockReturnValue(newCustomNetworkClient); - // Will use mainnet by default - await controller.initializeProvider(); + it('throws if the rpcEndpoints field is an empty array', async () => { + await withController(({ controller }) => { + expect(() => + controller.addNetwork( + buildAddNetworkFields({ + rpcEndpoints: [], + }), + ), + ).toThrow( + new Error( + 'Could not add network: `rpcEndpoints` must be a non-empty array', + ), + ); + }); + }); - await controller.upsertNetworkConfiguration( - { - rpcUrl: 'https://test.network', - chainId: toHex(111), - ticker: 'TICKER', - }, - { - setActive: false, - referrer: 'https://test-dapp.com', - source: 'dapp', - }, - ); + it('throws if one of the rpcEndpoints has an invalid url property', async () => { + await withController(({ controller }) => { + expect(() => + controller.addNetwork( + buildAddNetworkFields({ + rpcEndpoints: [ + buildAddNetworkCustomRpcEndpointFields({ + url: 'clearly-not-a-url', + }), + ], + }), + ), + ).toThrow( + new Error( + "Could not add network: An entry in `rpcEndpoints` has invalid URL 'clearly-not-a-url'", + ), + ); + }); + }); - const { provider } = controller.getProviderAndBlockTracker(); - assert(provider, 'Provider is not set'); - const { result } = await promisify(provider.sendAsync).call( - provider, - { - id: 1, - jsonrpc: '2.0', - method: 'test_method', - params: [], - }, - ); - expect(result).toBe('test response from built-in network'); - }, - ); - }); + it('throws if the URLs of two or more RPC endpoints have similar schemes (comparing case-insensitively)', async () => { + await withController(({ controller }) => { + expect(() => + controller.addNetwork( + buildAddNetworkFields({ + rpcEndpoints: [ + buildAddNetworkCustomRpcEndpointFields({ + url: 'https://foo.com/bar', + }), + buildAddNetworkCustomRpcEndpointFields({ + url: 'HTTPS://foo.com/bar', + }), + ], + }), + ), + ).toThrow( + new Error( + 'Could not add network: Each entry in rpcEndpoints must have a unique URL', + ), + ); }); + }); - describe('if the setActive option is true', () => { - it('updates the provider config to the new network configuration', async () => { - await withController(async ({ controller }) => { - uuidV4Mock.mockReturnValue('AAAA-AAAA-AAAA-AAAA'); - const newCustomNetworkClient = buildFakeClient(); - mockCreateNetworkClientWithDefaultsForBuiltInNetworkClients() - .calledWith({ - chainId: toHex(111), - rpcUrl: 'https://test.network', - type: NetworkClientType.Custom, - ticker: 'TICKER', - }) - .mockReturnValue(newCustomNetworkClient); + it('throws if the URLs of two or more RPC endpoints have similar hostnames (comparing case-insensitively)', async () => { + await withController(({ controller }) => { + expect(() => + controller.addNetwork( + buildAddNetworkFields({ + rpcEndpoints: [ + buildAddNetworkCustomRpcEndpointFields({ + url: 'https://foo.com/bar', + }), + buildAddNetworkCustomRpcEndpointFields({ + url: 'https://fOo.CoM/bar', + }), + ], + }), + ), + ).toThrow( + new Error( + 'Could not add network: Each entry in rpcEndpoints must have a unique URL', + ), + ); + }); + }); - await controller.upsertNetworkConfiguration( - { - rpcUrl: 'https://test.network', - chainId: toHex(111), - ticker: 'TICKER', - nickname: 'test network', - rpcPrefs: { - blockExplorerUrl: 'https://some.chainscan.io', + it('does not throw if the URLs of two or more RPC endpoints have similar paths (comparing case-insensitively)', async () => { + await withController(({ controller }) => { + expect(() => + controller.addNetwork( + buildAddNetworkFields({ + rpcEndpoints: [ + buildAddNetworkCustomRpcEndpointFields({ + url: 'https://foo.com/bar', + }), + buildAddNetworkCustomRpcEndpointFields({ + url: 'https://foo.com/BAR', + }), + ], + }), + ), + ).not.toThrow(); + }); + }); + + for (const infuraNetworkType of INFURA_NETWORKS) { + const infuraNetworkNickname = NetworkNickname[infuraNetworkType]; + const infuraChainId = ChainId[infuraNetworkType]; + + it(`throws if rpcEndpoints contains an Infura RPC endpoint which is already present in the network configuration for the Infura-supported chain ${infuraChainId}`, async () => { + const infuraRpcEndpoint = buildInfuraRpcEndpoint(infuraNetworkType); + + await withController( + { + state: + buildNetworkControllerStateWithDefaultSelectedNetworkClientId({ + networkConfigurationsByChainId: { + [infuraChainId]: buildInfuraNetworkConfiguration( + infuraNetworkType, + { + rpcEndpoints: [infuraRpcEndpoint], + }, + ), }, - }, - { - setActive: true, - referrer: 'https://test-dapp.com', - source: 'dapp', - }, + }), + }, + ({ controller }) => { + expect(() => + controller.addNetwork( + buildAddNetworkFields({ + chainId: '0x1337', + rpcEndpoints: [infuraRpcEndpoint], + }), + ), + ).toThrow( + `Could not add network that points to same RPC endpoint as existing network for chain ${infuraChainId} ('${infuraNetworkNickname}')`, ); + }, + ); + }); + } - expect(controller.state.providerConfig).toStrictEqual({ - type: NetworkType.rpc, - rpcUrl: 'https://test.network', - chainId: toHex(111), - ticker: 'TICKER', - nickname: 'test network', - rpcPrefs: { - blockExplorerUrl: 'https://some.chainscan.io', - }, - id: 'AAAA-AAAA-AAAA-AAAA', - }); - }); - }); + it('throws if rpcEndpoints contains a custom RPC endpoint which is already present in another network configuration (comparing URLs case-insensitively)', async () => { + await withController( + { + state: buildNetworkControllerStateWithDefaultSelectedNetworkClientId({ + networkConfigurationsByChainId: { + '0x2448': buildNetworkConfiguration({ + chainId: '0x2448', + rpcEndpoints: [ + buildCustomRpcEndpoint({ + url: 'http://test.endpoint/bar', + }), + ], + }), + }, + }), + }, + ({ controller }) => { + expect(() => + controller.addNetwork( + buildAddNetworkFields({ + chainId: '0x1337', + rpcEndpoints: [ + buildAddNetworkCustomRpcEndpointFields({ + url: 'http://test.endpoint/foo', + }), + buildAddNetworkCustomRpcEndpointFields({ + url: 'HTTP://TEST.ENDPOINT/bar', + }), + ], + }), + ), + ).toThrow( + "Could not add network that points to same RPC endpoint as existing network for chain 0x2448 ('Some Network')", + ); + }, + ); + }); - refreshNetworkTests({ - expectedProviderConfig: { - type: NetworkType.rpc, - rpcUrl: 'https://some.other.network', - chainId: toHex(222), - ticker: 'TICKER2', - id: 'BBBB-BBBB-BBBB-BBBB', - nickname: undefined, - rpcPrefs: undefined, - }, - initialState: { - networkConfigurations: { - 'AAAA-AAAA-AAAA-AAAA': { - rpcUrl: 'https://test.network', - chainId: toHex(111), - ticker: 'TICKER1', - id: 'AAAA-AAAA-AAAA-AAAA', - }, + it('throws if two or more RPC endpoints are exactly the same object', async () => { + await withController(({ controller }) => { + const rpcEndpoint = buildAddNetworkCustomRpcEndpointFields(); + expect(() => + controller.addNetwork( + buildAddNetworkFields({ + chainId: '0x1337', + rpcEndpoints: [rpcEndpoint, rpcEndpoint], + }), + ), + ).toThrow( + 'Could not add network: Each entry in rpcEndpoints must be unique', + ); + }); + }); + + it('throws if there are two or more different Infura RPC endpoints', async () => { + await withController( + { + state: buildNetworkControllerStateWithDefaultSelectedNetworkClientId({ + networkConfigurationsByChainId: { + '0x1337': buildCustomNetworkConfiguration({ + chainId: '0x1337', + }), }, - }, - operation: async (controller) => { - uuidV4Mock.mockReturnValue('BBBB-BBBB-BBBB-BBBB'); + }), + }, + ({ controller }) => { + const mainnetRpcEndpoint = buildInfuraRpcEndpoint( + InfuraNetworkType.mainnet, + ); + const testnetRpcEndpoint = buildInfuraRpcEndpoint( + TESTNET.networkType, + ); + expect(() => + controller.addNetwork( + buildAddNetworkFields({ + chainId: ChainId.mainnet, + rpcEndpoints: [mainnetRpcEndpoint, testnetRpcEndpoint], + }), + ), + ).toThrow( + 'Could not add network: There cannot be more than one Infura RPC endpoint', + ); + }, + ); + }); - await controller.upsertNetworkConfiguration( - { - rpcUrl: 'https://some.other.network', - chainId: toHex(222), - ticker: 'TICKER2', - }, - { - setActive: true, - referrer: 'https://test-dapp.com', - source: 'dapp', - }, - ); - }, - }); + it('throws if defaultRpcEndpointIndex does not refer to an entry in rpcEndpoints', async () => { + await withController(({ controller }) => { + expect(() => + controller.addNetwork( + buildAddNetworkFields({ + defaultRpcEndpointIndex: 99999, + rpcEndpoints: [ + buildUpdateNetworkCustomRpcEndpointFields({ + url: 'https://foo.com', + }), + buildCustomRpcEndpoint({ + url: 'https://bar.com', + }), + ], + }), + ), + ).toThrow( + new Error( + 'Could not add network: `defaultRpcEndpointIndex` must refer to an entry in `rpcEndpoints`', + ), + ); }); + }); - it('calls trackMetaMetricsEvent with details about the new network', async () => { - const trackMetaMetricsEventSpy = jest.fn(); + for (const infuraNetworkType of INFURA_NETWORKS) { + const infuraNetworkNickname = NetworkNickname[infuraNetworkType]; + const infuraChainId = ChainId[infuraNetworkType]; + it(`throws if a network configuration for the Infura network "${infuraNetworkNickname}" is already registered under the given chain ID`, async () => { await withController( { - trackMetaMetricsEvent: trackMetaMetricsEventSpy, + state: + buildNetworkControllerStateWithDefaultSelectedNetworkClientId({ + networkConfigurationsByChainId: { + [infuraChainId]: + buildInfuraNetworkConfiguration(infuraNetworkType), + }, + }), }, - async ({ controller }) => { - uuidV4Mock.mockReturnValue('AAAA-AAAA-AAAA-AAAA'); - - await controller.upsertNetworkConfiguration( - { - rpcUrl: 'https://test.network', - chainId: toHex(111), - ticker: 'TICKER', - }, - { - referrer: 'https://test-dapp.com', - source: 'dapp', - }, + ({ controller }) => { + expect(() => + controller.addNetwork( + buildAddNetworkFields({ + chainId: infuraChainId, + }), + ), + ).toThrow( + `Could not add network for chain ${infuraChainId} as another network for that chain already exists ('${infuraNetworkNickname}')`, ); - - expect(trackMetaMetricsEventSpy).toHaveBeenCalledWith({ - event: 'Custom Network Added', - category: 'Network', - referrer: { - url: 'https://test-dapp.com', - }, - properties: { - chain_id: toHex(111), - symbol: 'TICKER', - source: 'dapp', - }, - }); }, ); }); + } + + it('throws if a custom network is already registered under the given chain ID', async () => { + await withController( + { + state: buildNetworkControllerStateWithDefaultSelectedNetworkClientId({ + networkConfigurationsByChainId: { + '0x1337': buildCustomNetworkConfiguration({ + chainId: '0x1337', + name: 'Some Network', + }), + }, + }), + }, + ({ controller }) => { + expect(() => + controller.addNetwork( + buildAddNetworkFields({ + chainId: '0x1337', + }), + ), + ).toThrow( + `Could not add network for chain 0x1337 as another network for that chain already exists ('Some Network')`, + ); + }, + ); }); - describe.each([ - ['case-sensitively', 'https://test.network', 'https://test.network'], - ['case-insensitively', 'https://test.network', 'https://TEST.NETWORK'], - ])( - 'when the rpcUrl of the given network configuration matches an existing network configuration in state (%s)', - (_qualifier, oldRpcUrl, newRpcUrl) => { - it('completely overwrites the existing network configuration in state, but does not update or remove any other network configurations', async () => { + for (const infuraNetworkType of INFURA_NETWORKS) { + const infuraChainId = ChainId[infuraNetworkType]; + const infuraNetworkNickname = NetworkNickname[infuraNetworkType]; + const infuraNativeTokenName = NetworksTicker[infuraNetworkType]; + + describe(`given the ID of the Infura-supported chain ${infuraChainId}`, () => { + it('creates a new network client for not only each custom RPC endpoint, but also the Infura RPC endpoint', async () => { + uuidV4Mock + .mockReturnValueOnce('BBBB-BBBB-BBBB-BBBB') + .mockReturnValueOnce('CCCC-CCCC-CCCC-CCCC'); + const createAutoManagedNetworkClientSpy = jest.spyOn( + createAutoManagedNetworkClientModule, + 'createAutoManagedNetworkClient', + ); + const infuraProjectId = 'some-infura-project-id'; + const getRpcServiceOptions = (): Omit< + RpcServiceOptions, + 'failoverService' | 'endpointUrl' + > => ({ + btoa, + fetch, + isOffline: (): boolean => false, + fetchOptions: { + headers: { + 'X-Foo': 'Bar', + }, + }, + policyOptions: { + maxRetries: 2, + maxConsecutiveFailures: 10, + }, + }); + const getBlockTrackerOptions = (): PollingBlockTrackerOptions => ({ + pollingInterval: 2000, + }); + await withController( { - state: { - networkConfigurations: { - 'AAAA-AAAA-AAAA-AAAA': { - rpcUrl: 'https://test.network.1', - chainId: toHex(111), - ticker: 'TICKER1', - id: 'AAAA-AAAA-AAAA-AAAA', - }, - 'BBBB-BBBB-BBBB-BBBB': { - rpcUrl: oldRpcUrl, - chainId: toHex(222), - ticker: 'TICKER2', - id: 'BBBB-BBBB-BBBB-BBBB', + state: + buildNetworkControllerStateWithDefaultSelectedNetworkClientId({ + networkConfigurationsByChainId: { + '0x1337': buildCustomNetworkConfiguration({ + chainId: '0x1337', + rpcEndpoints: [ + buildCustomRpcEndpoint({ + networkClientId: 'AAAA-AAAA-AAAA-AAAA', + url: 'https://test.endpoint/1', + }), + ], + }), }, - }, - }, + }), + infuraProjectId, + getRpcServiceOptions, + getBlockTrackerOptions, + rpcFailoverMode: 'enabled', }, - async ({ controller }) => { - await controller.upsertNetworkConfiguration( + ({ controller, networkControllerMessenger }) => { + const defaultRpcEndpoint: InfuraRpcEndpoint = { + failoverUrls: ['https://first.failover.endpoint'], + name: infuraNetworkNickname, + networkClientId: infuraNetworkType, + type: RpcEndpointType.Infura as const, + url: `https://${infuraNetworkType}.infura.io/v3/{infuraProjectId}` as const, + }; + + controller.addNetwork({ + blockExplorerUrls: [], + chainId: infuraChainId, + defaultRpcEndpointIndex: 1, + name: infuraNetworkType, + nativeCurrency: infuraNativeTokenName, + rpcEndpoints: [ + defaultRpcEndpoint, + { + failoverUrls: ['https://second.failover.endpoint'], + name: 'Test Network 1', + type: RpcEndpointType.Custom, + url: 'https://test.endpoint/2', + }, + { + failoverUrls: ['https://third.failover.endpoint'], + name: 'Test Network 2', + type: RpcEndpointType.Custom, + url: 'https://test.endpoint/3', + }, + ], + }); + + // Skipping the 1st call because it's for the initial state + expect(createAutoManagedNetworkClientSpy).toHaveBeenNthCalledWith( + 2, { - rpcUrl: newRpcUrl, - chainId: toHex(999), - ticker: 'NEW_TICKER', - nickname: 'test network 2', - rpcPrefs: { - blockExplorerUrl: 'https://testchainscan.io', + networkClientId: infuraNetworkType, + networkClientConfiguration: { + infuraProjectId, + failoverRpcUrls: ['https://first.failover.endpoint'], + chainId: infuraChainId, + network: infuraNetworkType, + ticker: infuraNativeTokenName, + type: NetworkClientType.Infura, }, + getRpcServiceOptions, + getBlockTrackerOptions, + messenger: networkControllerMessenger, + rpcFailoverMode: 'enabled', }, + ); + expect(createAutoManagedNetworkClientSpy).toHaveBeenNthCalledWith( + 3, { - referrer: 'https://test-dapp.com', - source: 'dapp', + networkClientId: 'BBBB-BBBB-BBBB-BBBB', + networkClientConfiguration: { + chainId: infuraChainId, + failoverRpcUrls: ['https://second.failover.endpoint'], + rpcUrl: 'https://test.endpoint/2', + ticker: infuraNativeTokenName, + type: NetworkClientType.Custom, + }, + getRpcServiceOptions, + getBlockTrackerOptions, + messenger: networkControllerMessenger, + rpcFailoverMode: 'enabled', }, ); - - expect(controller.state.networkConfigurations).toStrictEqual({ - 'AAAA-AAAA-AAAA-AAAA': { - rpcUrl: 'https://test.network.1', - chainId: toHex(111), - ticker: 'TICKER1', - id: 'AAAA-AAAA-AAAA-AAAA', - }, - 'BBBB-BBBB-BBBB-BBBB': { - rpcUrl: newRpcUrl, - chainId: toHex(999), - ticker: 'NEW_TICKER', - nickname: 'test network 2', - rpcPrefs: { - blockExplorerUrl: 'https://testchainscan.io', + expect(createAutoManagedNetworkClientSpy).toHaveBeenNthCalledWith( + 4, + { + networkClientId: 'CCCC-CCCC-CCCC-CCCC', + networkClientConfiguration: { + chainId: infuraChainId, + failoverRpcUrls: ['https://third.failover.endpoint'], + rpcUrl: 'https://test.endpoint/3', + ticker: infuraNativeTokenName, + type: NetworkClientType.Custom, }, - id: 'BBBB-BBBB-BBBB-BBBB', + getRpcServiceOptions, + getBlockTrackerOptions, + messenger: networkControllerMessenger, + rpcFailoverMode: 'enabled', }, + ); + const networkConfigurationsByNetworkClientId = + getNetworkConfigurationsByNetworkClientId( + controller.getNetworkClientRegistry(), + ); + expect( + networkConfigurationsByNetworkClientId[infuraNetworkType], + ).toStrictEqual({ + chainId: infuraChainId, + infuraProjectId, + failoverRpcUrls: ['https://first.failover.endpoint'], + network: infuraNetworkType, + ticker: infuraNativeTokenName, + type: NetworkClientType.Infura, + }); + expect( + networkConfigurationsByNetworkClientId['BBBB-BBBB-BBBB-BBBB'], + ).toStrictEqual({ + chainId: infuraChainId, + failoverRpcUrls: ['https://second.failover.endpoint'], + rpcUrl: 'https://test.endpoint/2', + ticker: infuraNativeTokenName, + type: NetworkClientType.Custom, + }); + expect( + networkConfigurationsByNetworkClientId['CCCC-CCCC-CCCC-CCCC'], + ).toStrictEqual({ + chainId: infuraChainId, + failoverRpcUrls: ['https://third.failover.endpoint'], + rpcUrl: 'https://test.endpoint/3', + ticker: infuraNativeTokenName, + type: NetworkClientType.Custom, }); }, ); }); - it('removes properties not specific to the NetworkConfiguration interface before persisting it to state', async function () { + it('overrides the per-endpoint failover URLs with the chain-level failoverUrls when the controller was initialized with them', async () => { + uuidV4Mock + .mockReturnValueOnce('BBBB-BBBB-BBBB-BBBB') + .mockReturnValueOnce('CCCC-CCCC-CCCC-CCCC'); + const createAutoManagedNetworkClientSpy = jest.spyOn( + createAutoManagedNetworkClientModule, + 'createAutoManagedNetworkClient', + ); + const infuraProjectId = 'some-infura-project-id'; + await withController( { - state: { - networkConfigurations: { - 'AAAA-AAAA-AAAA-AAAA': { - rpcUrl: oldRpcUrl, - chainId: toHex(111), - ticker: 'TICKER', - id: 'AAAA-AAAA-AAAA-AAAA', + state: + buildNetworkControllerStateWithDefaultSelectedNetworkClientId({ + networkConfigurationsByChainId: { + '0x1337': buildCustomNetworkConfiguration({ + chainId: '0x1337', + rpcEndpoints: [ + buildCustomRpcEndpoint({ + networkClientId: 'AAAA-AAAA-AAAA-AAAA', + url: 'https://test.endpoint/1', + }), + ], + }), }, - }, + }), + infuraProjectId, + failoverUrls: { + [infuraChainId]: ['https://chain.failover'], }, + rpcFailoverMode: 'enabled', }, - async ({ controller }) => { - await controller.upsertNetworkConfiguration( - { - rpcUrl: newRpcUrl, - chainId: toHex(999), - ticker: 'NEW_TICKER', - nickname: 'test network', - rpcPrefs: { - blockExplorerUrl: 'https://testchainscan.io', - }, - // @ts-expect-error We are intentionally passing bad input. - invalidKey: 'some value', - }, - { - referrer: 'https://test-dapp.com', - source: 'dapp', - }, - ); + ({ controller }) => { + const defaultRpcEndpoint: InfuraRpcEndpoint = { + failoverUrls: ['https://first.failover.endpoint'], + name: infuraNetworkNickname, + networkClientId: infuraNetworkType, + type: RpcEndpointType.Infura as const, + url: `https://${infuraNetworkType}.infura.io/v3/{infuraProjectId}` as const, + }; - expect(controller.state.networkConfigurations).toStrictEqual({ - 'AAAA-AAAA-AAAA-AAAA': { - rpcUrl: newRpcUrl, - chainId: toHex(999), - ticker: 'NEW_TICKER', - nickname: 'test network', - rpcPrefs: { - blockExplorerUrl: 'https://testchainscan.io', + controller.addNetwork({ + blockExplorerUrls: [], + chainId: infuraChainId, + defaultRpcEndpointIndex: 1, + name: infuraNetworkType, + nativeCurrency: infuraNativeTokenName, + rpcEndpoints: [ + defaultRpcEndpoint, + { + failoverUrls: ['https://second.failover.endpoint'], + name: 'Test Network 1', + type: RpcEndpointType.Custom, + url: 'https://test.endpoint/2', }, - id: 'AAAA-AAAA-AAAA-AAAA', - }, + ], }); + + // Skipping the 1st call because it's for the initial state + expect(createAutoManagedNetworkClientSpy).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ + networkClientId: infuraNetworkType, + networkClientConfiguration: expect.objectContaining({ + failoverRpcUrls: ['https://chain.failover'], + type: NetworkClientType.Infura, + }), + }), + ); + expect(createAutoManagedNetworkClientSpy).toHaveBeenNthCalledWith( + 3, + expect.objectContaining({ + networkClientId: 'BBBB-BBBB-BBBB-BBBB', + networkClientConfiguration: expect.objectContaining({ + failoverRpcUrls: ['https://chain.failover'], + rpcUrl: 'https://test.endpoint/2', + type: NetworkClientType.Custom, + }), + }), + ); }, ); }); - describe('if at least the chain ID is being updated', () => { - it('destroys and removes the existing network client for the old network configuration', async () => { - await withController( - { - state: { - networkConfigurations: { - 'AAAA-AAAA-AAAA-AAAA': { - rpcUrl: oldRpcUrl, - chainId: toHex(111), - ticker: 'TICKER', - id: 'AAAA-AAAA-AAAA-AAAA', - }, - }, - }, - infuraProjectId: 'some-infura-project-id', - }, - async ({ controller }) => { - const newCustomNetworkClient = buildFakeClient(); - mockCreateNetworkClientWithDefaultsForBuiltInNetworkClients({ - infuraProjectId: 'some-infura-project-id', - }) - .calledWith({ - chainId: toHex(111), - rpcUrl: 'https://test.network', - type: NetworkClientType.Custom, - ticker: 'TEST', - }) - .mockReturnValue(newCustomNetworkClient); - const networkClientToDestroy = Object.values( - controller.getNetworkClientRegistry(), - ).find(({ configuration }) => { - return ( - configuration.type === NetworkClientType.Custom && - configuration.chainId === toHex(111) && - configuration.rpcUrl === 'https://test.network' - ); - }); - assert(networkClientToDestroy); - jest.spyOn(networkClientToDestroy, 'destroy'); + it('adds the network configuration to state under the chain ID', async () => { + uuidV4Mock.mockReturnValueOnce('BBBB-BBBB-BBBB-BBBB'); - await controller.upsertNetworkConfiguration( + await withController( + { + state: + buildNetworkControllerStateWithDefaultSelectedNetworkClientId({ + networkConfigurationsByChainId: { + '0x1337': buildCustomNetworkConfiguration({ + chainId: '0x1337', + rpcEndpoints: [ + buildCustomRpcEndpoint({ + networkClientId: 'AAAA-AAAA-AAAA-AAAA', + url: 'https://test.endpoint/1', + }), + ], + }), + }, + }), + }, + ({ controller }) => { + controller.addNetwork({ + blockExplorerUrls: ['https://block.explorer'], + chainId: infuraChainId, + defaultBlockExplorerUrlIndex: 0, + defaultRpcEndpointIndex: 0, + name: 'Some Network', + nativeCurrency: 'TOKEN', + rpcEndpoints: [ { - rpcUrl: newRpcUrl, - chainId: toHex(999), - ticker: 'TICKER', + failoverUrls: ['https://first.failover.endpoint'], + name: infuraNetworkNickname, + networkClientId: infuraNetworkType, + type: RpcEndpointType.Infura as const, + url: `https://${infuraNetworkType}.infura.io/v3/{infuraProjectId}` as const, }, { - referrer: 'https://test-dapp.com', - source: 'dapp', + failoverUrls: ['https://second.failover.endpoint'], + name: 'Test Network', + type: RpcEndpointType.Custom, + url: 'https://test.endpoint/2', }, - ); - - const networkClients = controller.getNetworkClientRegistry(); - expect(networkClientToDestroy.destroy).toHaveBeenCalled(); - expect(Object.keys(networkClients)).toHaveLength(6); - expect(networkClients).not.toMatchObject({ - [oldRpcUrl]: expect.objectContaining({ - configuration: { - chainId: toHex(111), - rpcUrl: oldRpcUrl, - type: NetworkClientType.Custom, - ticker: 'TEST', - }, - }), - }); - }, - ); - }); + ], + }); - it('creates a new network client for the network configuration and adds it to the registry', async () => { - await withController( - { - state: { - networkConfigurations: { - 'AAAA-AAAA-AAAA-AAAA': { - rpcUrl: oldRpcUrl, - chainId: toHex(111), - ticker: 'TICKER', - id: 'AAAA-AAAA-AAAA-AAAA', - }, - }, - }, - infuraProjectId: 'some-infura-project-id', - }, - async ({ controller }) => { - const newCustomNetworkClient = buildFakeClient(); - mockCreateNetworkClientWithDefaultsForBuiltInNetworkClients({ - infuraProjectId: 'some-infura-project-id', - }) - .calledWith({ - chainId: toHex(999), - rpcUrl: newRpcUrl, - type: NetworkClientType.Custom, - ticker: 'TICKER', - }) - .mockReturnValue(newCustomNetworkClient); - - await controller.upsertNetworkConfiguration( - { - rpcUrl: newRpcUrl, - chainId: toHex(999), - ticker: 'TICKER', + expect( + controller.state.networkConfigurationsByChainId, + ).toHaveProperty(infuraChainId); + expect( + controller.state.networkConfigurationsByChainId[infuraChainId], + ).toStrictEqual({ + blockExplorerUrls: ['https://block.explorer'], + chainId: infuraChainId, + defaultBlockExplorerUrlIndex: 0, + defaultRpcEndpointIndex: 0, + name: 'Some Network', + nativeCurrency: 'TOKEN', + rpcEndpoints: [ + { + failoverUrls: ['https://first.failover.endpoint'], + name: infuraNetworkNickname, + networkClientId: infuraNetworkType, + type: RpcEndpointType.Infura as const, + url: `https://${infuraNetworkType}.infura.io/v3/{infuraProjectId}`, }, { - referrer: 'https://test-dapp.com', - source: 'dapp', + failoverUrls: ['https://second.failover.endpoint'], + name: 'Test Network', + networkClientId: 'BBBB-BBBB-BBBB-BBBB', + type: RpcEndpointType.Custom, + url: 'https://test.endpoint/2', }, - ); - - const networkClients = controller.getNetworkClientRegistry(); - expect(Object.keys(networkClients)).toHaveLength(6); - expect(networkClients).toMatchObject({ - 'AAAA-AAAA-AAAA-AAAA': expect.objectContaining({ - configuration: { - chainId: toHex(999), - rpcUrl: newRpcUrl, - type: NetworkClientType.Custom, - ticker: 'TICKER', - }, - }), - }); - }, - ); - }); + ], + lastUpdatedAt: FAKE_DATE_NOW_MS, + }); + }, + ); }); - describe('if the chain ID is not being updated', () => { - it('does not update the network client registry', async () => { - await withController( - { - state: { - networkConfigurations: { - 'AAAA-AAAA-AAAA-AAAA': { - rpcUrl: oldRpcUrl, - chainId: toHex(111), - ticker: 'TICKER', - id: 'AAAA-AAAA-AAAA-AAAA', - }, + it('emits the NetworkController:networkAdded event', async () => { + await withController( + { + state: + buildNetworkControllerStateWithDefaultSelectedNetworkClientId({ + networkConfigurationsByChainId: { + '0x1337': buildCustomNetworkConfiguration({ + chainId: '0x1337', + rpcEndpoints: [ + buildCustomRpcEndpoint({ + networkClientId: 'AAAA-AAAA-AAAA-AAAA', + url: 'https://test.endpoint/1', + }), + ], + }), }, - }, - infuraProjectId: 'some-infura-project-id', - }, - async ({ controller }) => { - const newCustomNetworkClient = buildFakeClient(); - mockCreateNetworkClientWithDefaultsForBuiltInNetworkClients({ - infuraProjectId: 'some-infura-project-id', - }) - .calledWith({ - chainId: toHex(111), - rpcUrl: 'https://test.network', - type: NetworkClientType.Custom, - ticker: 'TEST', - }) - .mockReturnValue(newCustomNetworkClient); - const networkClientsBefore = - controller.getNetworkClientRegistry(); + }), + }, + ({ controller, messenger }) => { + const networkAddedEventListener = jest.fn(); + messenger.subscribe( + 'NetworkController:networkAdded', + networkAddedEventListener, + ); - await controller.upsertNetworkConfiguration( + controller.addNetwork({ + blockExplorerUrls: ['https://block.explorer'], + chainId: infuraChainId, + defaultBlockExplorerUrlIndex: 0, + defaultRpcEndpointIndex: 0, + name: 'Some Network', + nativeCurrency: 'TOKEN', + rpcEndpoints: [ { - rpcUrl: newRpcUrl, - chainId: toHex(111), - ticker: 'NEW_TICKER', + failoverUrls: ['https://some.failover.endpoint'], + name: infuraNetworkNickname, + networkClientId: infuraNetworkType, + type: RpcEndpointType.Infura as const, + url: `https://${infuraNetworkType}.infura.io/v3/{infuraProjectId}` as const, }, + ], + }); + + expect(networkAddedEventListener).toHaveBeenCalledWith({ + blockExplorerUrls: ['https://block.explorer'], + chainId: infuraChainId, + defaultBlockExplorerUrlIndex: 0, + defaultRpcEndpointIndex: 0, + name: 'Some Network', + nativeCurrency: 'TOKEN', + rpcEndpoints: [ { - referrer: 'https://test-dapp.com', - source: 'dapp', + failoverUrls: ['https://some.failover.endpoint'], + name: infuraNetworkNickname, + networkClientId: infuraNetworkType, + type: RpcEndpointType.Infura as const, + url: `https://${infuraNetworkType}.infura.io/v3/{infuraProjectId}`, }, - ); - - const networkClientsAfter = - controller.getNetworkClientRegistry(); - expect(networkClientsBefore).toStrictEqual(networkClientsAfter); - }, - ); - }); + ], + lastUpdatedAt: FAKE_DATE_NOW_MS, + }); + }, + ); }); - it('does not call trackMetaMetricsEvent', async () => { - const trackMetaMetricsEventSpy = jest.fn(); - + it('returns the newly added network configuration', async () => { await withController( { - state: { - networkConfigurations: { - 'AAAA-AAAA-AAAA-AAAA': { - rpcUrl: oldRpcUrl, - chainId: toHex(111), - ticker: 'TICKER', - id: 'AAAA-AAAA-AAAA-AAAA', + state: + buildNetworkControllerStateWithDefaultSelectedNetworkClientId({ + networkConfigurationsByChainId: { + '0x1337': buildCustomNetworkConfiguration({ + chainId: '0x1337', + rpcEndpoints: [ + buildCustomRpcEndpoint({ + networkClientId: 'AAAA-AAAA-AAAA-AAAA', + url: 'https://test.endpoint/1', + }), + ], + }), }, - }, - }, - infuraProjectId: 'some-infura-project-id', - trackMetaMetricsEvent: trackMetaMetricsEventSpy, + }), }, - async ({ controller }) => { - await controller.upsertNetworkConfiguration( - { - rpcUrl: newRpcUrl, - chainId: toHex(111), - ticker: 'NEW_TICKER', - }, - { - referrer: 'https://test-dapp.com', - source: 'dapp', - }, - ); + ({ controller }) => { + const newNetworkConfiguration = controller.addNetwork({ + blockExplorerUrls: ['https://block.explorer'], + chainId: infuraChainId, + defaultBlockExplorerUrlIndex: 0, + defaultRpcEndpointIndex: 0, + name: 'Some Network', + nativeCurrency: 'TOKEN', + rpcEndpoints: [ + { + failoverUrls: ['https://some.failover.endpoint'], + name: infuraNetworkNickname, + networkClientId: infuraNetworkType, + type: RpcEndpointType.Infura as const, + url: `https://${infuraNetworkType}.infura.io/v3/{infuraProjectId}` as const, + }, + ], + }); - expect(trackMetaMetricsEventSpy).not.toHaveBeenCalled(); + expect(newNetworkConfiguration).toStrictEqual({ + blockExplorerUrls: ['https://block.explorer'], + chainId: infuraChainId, + defaultBlockExplorerUrlIndex: 0, + defaultRpcEndpointIndex: 0, + name: 'Some Network', + nativeCurrency: 'TOKEN', + rpcEndpoints: [ + { + failoverUrls: ['https://some.failover.endpoint'], + name: infuraNetworkNickname, + networkClientId: infuraNetworkType, + type: RpcEndpointType.Infura as const, + url: `https://${infuraNetworkType}.infura.io/v3/{infuraProjectId}`, + }, + ], + lastUpdatedAt: FAKE_DATE_NOW_MS, + }); }, ); }); - }, - ); + }); + } - it('throws if the given chain ID is not a 0x-prefixed hex number', async () => { - await withController(async ({ controller }) => { - await expect( - controller.upsertNetworkConfiguration( - { - rpcUrl: 'https://test.network', - // @ts-expect-error We are intentionally passing bad input. - chainId: '1', - ticker: 'TICKER', - }, - { - referrer: 'https://test-dapp.com', - source: 'dapp', - }, - ), - ).rejects.toThrow( - new Error('Value must be a hexadecimal string, starting with "0x".'), - ); + describe('given the ID of a non-Infura-supported chain', () => { + it('creates a new network client for each given RPC endpoint', async () => { + uuidV4Mock + .mockReturnValueOnce('AAAA-AAAA-AAAA-AAAA') + .mockReturnValueOnce('BBBB-BBBB-BBBB-BBBB'); + + await withController(({ controller }) => { + controller.addNetwork({ + blockExplorerUrls: [], + chainId: '0x1337', + defaultRpcEndpointIndex: 0, + name: 'Some Network', + nativeCurrency: 'TOKEN', + rpcEndpoints: [ + { + failoverUrls: ['https://first.failover.endpoint'], + name: 'Test Network 1', + type: RpcEndpointType.Custom, + url: 'https://test.endpoint/1', + }, + { + failoverUrls: ['https://second.failover.endpoint'], + name: 'Test Network 2', + type: RpcEndpointType.Custom, + url: 'https://test.endpoint/2', + }, + ], + }); + + const networkClient1 = controller.getNetworkClientById( + 'AAAA-AAAA-AAAA-AAAA', + ); + expect(networkClient1.configuration).toStrictEqual({ + chainId: '0x1337', + failoverRpcUrls: ['https://first.failover.endpoint'], + rpcUrl: 'https://test.endpoint/1', + ticker: 'TOKEN', + type: NetworkClientType.Custom, + }); + const networkClient2 = controller.getNetworkClientById( + 'BBBB-BBBB-BBBB-BBBB', + ); + expect(networkClient2.configuration).toStrictEqual({ + chainId: '0x1337', + failoverRpcUrls: ['https://second.failover.endpoint'], + rpcUrl: 'https://test.endpoint/2', + ticker: 'TOKEN', + type: NetworkClientType.Custom, + }); + }); }); - }); - it('throws if the given chain ID is greater than the maximum allowed ID', async () => { - await withController(async ({ controller }) => { - await expect( - controller.upsertNetworkConfiguration( - { - rpcUrl: 'https://test.network', - chainId: toHex(MAX_SAFE_CHAIN_ID + 1), - ticker: 'TICKER', - }, - { - referrer: 'https://test-dapp.com', - source: 'dapp', - }, - ), - ).rejects.toThrow( - new Error( - 'Invalid chain ID "0xfffffffffffed": numerical value greater than max safe value.', - ), - ); + it('adds the network configuration to state under the chain ID', async () => { + uuidV4Mock + .mockReturnValueOnce('AAAA-AAAA-AAAA-AAAA') + .mockReturnValueOnce('BBBB-BBBB-BBBB-BBBB'); + + await withController(({ controller }) => { + controller.addNetwork({ + blockExplorerUrls: ['https://block.explorer'], + chainId: '0x1337', + defaultBlockExplorerUrlIndex: 0, + defaultRpcEndpointIndex: 0, + name: 'Some Network', + nativeCurrency: 'TOKEN', + rpcEndpoints: [ + { + failoverUrls: ['https://first.failover.endpoint'], + name: 'Test Network 1', + type: RpcEndpointType.Custom, + url: 'https://test.endpoint/1', + }, + { + failoverUrls: ['https://second.failover.endpoint'], + name: 'Test Network 2', + type: RpcEndpointType.Custom, + url: 'https://test.endpoint/2', + }, + ], + }); + + expect( + controller.state.networkConfigurationsByChainId['0x1337'], + ).toStrictEqual({ + blockExplorerUrls: ['https://block.explorer'], + chainId: '0x1337', + defaultBlockExplorerUrlIndex: 0, + defaultRpcEndpointIndex: 0, + name: 'Some Network', + nativeCurrency: 'TOKEN', + rpcEndpoints: [ + { + failoverUrls: ['https://first.failover.endpoint'], + name: 'Test Network 1', + networkClientId: 'AAAA-AAAA-AAAA-AAAA', + type: RpcEndpointType.Custom, + url: 'https://test.endpoint/1', + }, + { + failoverUrls: ['https://second.failover.endpoint'], + name: 'Test Network 2', + networkClientId: 'BBBB-BBBB-BBBB-BBBB', + type: RpcEndpointType.Custom, + url: 'https://test.endpoint/2', + }, + ], + lastUpdatedAt: FAKE_DATE_NOW_MS, + }); + }); }); - }); - it('throws if a falsy rpcUrl is given', async () => { - await withController(async ({ controller }) => { - await expect(() => - controller.upsertNetworkConfiguration( - { - // @ts-expect-error We are intentionally passing bad input. - rpcUrl: false, - chainId: toHex(111), - ticker: 'TICKER', - }, - { - referrer: 'https://test-dapp.com', - source: 'dapp', - }, - ), - ).rejects.toThrow( - new Error( - 'An rpcUrl is required to add or update network configuration', - ), - ); + it('emits the NetworkController:networkAdded event', async () => { + uuidV4Mock.mockReturnValueOnce('AAAA-AAAA-AAAA-AAAA'); + + await withController(({ controller, messenger }) => { + const networkAddedEventListener = jest.fn(); + messenger.subscribe( + 'NetworkController:networkAdded', + networkAddedEventListener, + ); + + controller.addNetwork({ + blockExplorerUrls: ['https://block.explorer'], + chainId: '0x1337', + defaultBlockExplorerUrlIndex: 0, + defaultRpcEndpointIndex: 0, + name: 'Some Network', + nativeCurrency: 'TOKEN', + rpcEndpoints: [ + { + failoverUrls: ['https://failover.endpoint'], + name: 'Test Network', + type: RpcEndpointType.Custom, + url: 'https://test.endpoint', + }, + ], + }); + + expect(networkAddedEventListener).toHaveBeenCalledWith({ + blockExplorerUrls: ['https://block.explorer'], + chainId: '0x1337', + defaultBlockExplorerUrlIndex: 0, + defaultRpcEndpointIndex: 0, + name: 'Some Network', + nativeCurrency: 'TOKEN', + rpcEndpoints: [ + { + failoverUrls: ['https://failover.endpoint'], + name: 'Test Network', + networkClientId: 'AAAA-AAAA-AAAA-AAAA', + type: RpcEndpointType.Custom, + url: 'https://test.endpoint', + }, + ], + lastUpdatedAt: FAKE_DATE_NOW_MS, + }); + }); }); - }); - it('throws if no rpcUrl is given', async () => { - await withController(async ({ controller }) => { - await expect( - controller.upsertNetworkConfiguration( - // @ts-expect-error We are intentionally passing bad input. - { - chainId: toHex(111), - ticker: 'TICKER', - }, - { - referrer: 'https://test-dapp.com', - source: 'dapp', - }, - ), - ).rejects.toThrow( - new Error( - 'An rpcUrl is required to add or update network configuration', - ), - ); + it('returns the newly added network configuration', async () => { + uuidV4Mock.mockReturnValueOnce('AAAA-AAAA-AAAA-AAAA'); + + await withController(({ controller, messenger }) => { + const networkAddedEventListener = jest.fn(); + messenger.subscribe( + 'NetworkController:networkAdded', + networkAddedEventListener, + ); + + const newNetworkConfiguration = controller.addNetwork({ + blockExplorerUrls: ['https://block.explorer'], + chainId: '0x1337', + defaultBlockExplorerUrlIndex: 0, + defaultRpcEndpointIndex: 0, + name: 'Some Network', + nativeCurrency: 'TOKEN', + rpcEndpoints: [ + { + failoverUrls: ['https://failover.endpoint'], + name: 'Test Network', + type: RpcEndpointType.Custom, + url: 'https://test.endpoint', + }, + ], + }); + + expect(newNetworkConfiguration).toStrictEqual({ + blockExplorerUrls: ['https://block.explorer'], + chainId: '0x1337', + defaultBlockExplorerUrlIndex: 0, + defaultRpcEndpointIndex: 0, + name: 'Some Network', + nativeCurrency: 'TOKEN', + rpcEndpoints: [ + { + failoverUrls: ['https://failover.endpoint'], + name: 'Test Network', + networkClientId: 'AAAA-AAAA-AAAA-AAAA', + type: RpcEndpointType.Custom, + url: 'https://test.endpoint', + }, + ], + lastUpdatedAt: FAKE_DATE_NOW_MS, + }); + }); }); - }); - it('throws if the rpcUrl given is not a valid URL', async () => { - await withController(async ({ controller }) => { - await expect( - controller.upsertNetworkConfiguration( - { - rpcUrl: 'test', - chainId: toHex(111), - ticker: 'TICKER', - }, + it('is callable from the controller messenger', async () => { + uuidV4Mock.mockReturnValueOnce('AAAA-AAAA-AAAA-AAAA'); + + await withController(({ messenger }) => { + const networkAddedEventListener = jest.fn(); + messenger.subscribe( + 'NetworkController:networkAdded', + networkAddedEventListener, + ); + + const newNetworkConfiguration = messenger.call( + 'NetworkController:addNetwork', { - referrer: 'https://test-dapp.com', - source: 'dapp', + blockExplorerUrls: ['https://block.explorer'], + chainId: '0x1337', + defaultBlockExplorerUrlIndex: 0, + defaultRpcEndpointIndex: 0, + name: 'Some Network', + nativeCurrency: 'TOKEN', + rpcEndpoints: [ + { + failoverUrls: ['https://failover.endpoint'], + name: 'Test Network', + type: RpcEndpointType.Custom, + url: 'https://test.endpoint', + }, + ], + lastUpdatedAt: FAKE_DATE_NOW_MS, }, - ), - ).rejects.toThrow(new Error('rpcUrl must be a valid URL')); + ); + + expect(newNetworkConfiguration).toStrictEqual({ + blockExplorerUrls: ['https://block.explorer'], + chainId: '0x1337', + defaultBlockExplorerUrlIndex: 0, + defaultRpcEndpointIndex: 0, + name: 'Some Network', + nativeCurrency: 'TOKEN', + rpcEndpoints: [ + { + failoverUrls: ['https://failover.endpoint'], + name: 'Test Network', + networkClientId: 'AAAA-AAAA-AAAA-AAAA', + type: RpcEndpointType.Custom, + url: 'https://test.endpoint', + }, + ], + lastUpdatedAt: FAKE_DATE_NOW_MS, + }); + }); }); }); + }); - it('throws if a falsy referrer is given', async () => { + describe('updateNetwork', () => { + it('throws if the given chain ID does not refer to an existing network configuration', async () => { await withController(async ({ controller }) => { await expect( - controller.upsertNetworkConfiguration( - { - rpcUrl: 'https://test.network', - chainId: toHex(111), - ticker: 'TICKER', - }, - { - // @ts-expect-error We are intentionally passing bad input. - referrer: false, - source: 'dapp', - }, + controller.updateNetwork( + '0x1337', + buildCustomNetworkConfiguration({ + chainId: '0x1337', + }), ), ).rejects.toThrow( new Error( - 'referrer and source are required arguments for adding or updating a network configuration', + "Could not update network: Cannot find network configuration for chain '0x1337'", ), ); }); }); - it('throws if no referrer is given', async () => { - await withController(async ({ controller }) => { - await expect( - controller.upsertNetworkConfiguration( - { - rpcUrl: 'https://test.network', - chainId: toHex(111), - ticker: 'TICKER', - }, - // @ts-expect-error We are intentionally passing bad input. - { - source: 'dapp', - }, - ), - ).rejects.toThrow( - new Error( - 'referrer and source are required arguments for adding or updating a network configuration', - ), - ); + it('throws if defaultBlockExplorerUrlIndex does not refer to an entry in blockExplorerUrls', async () => { + const networkConfigurationToUpdate = buildCustomNetworkConfiguration({ + chainId: '0x1337', }); - }); - it('throws if a falsy source is given', async () => { - await withController(async ({ controller }) => { - await expect( - controller.upsertNetworkConfiguration( - { - rpcUrl: 'https://test.network', - chainId: toHex(111), - ticker: 'TICKER', - }, - { - referrer: 'https://test-dapp.com', - // @ts-expect-error We are intentionally passing bad input. - source: false, + await withController( + { + state: buildNetworkControllerStateWithDefaultSelectedNetworkClientId({ + networkConfigurationsByChainId: { + '0x1337': networkConfigurationToUpdate, }, - ), - ).rejects.toThrow( - new Error( - 'referrer and source are required arguments for adding or updating a network configuration', - ), - ); - }); + }), + }, + async ({ controller }) => { + await expect(() => + controller.updateNetwork( + '0x1337', + buildCustomNetworkConfiguration({ + blockExplorerUrls: [], + defaultBlockExplorerUrlIndex: 99999, + }), + ), + ).rejects.toThrow( + new Error( + 'Could not update network: `defaultBlockExplorerUrlIndex` must refer to an entry in `blockExplorerUrls`', + ), + ); + }, + ); }); - it('throws if no source is given', async () => { - await withController(async ({ controller }) => { - await expect( - controller.upsertNetworkConfiguration( - { - rpcUrl: 'https://test.network', - chainId: toHex(111), - ticker: 'TICKER', - }, - // @ts-expect-error We are intentionally passing bad input. - { - referrer: 'https://test-dapp.com', - }, - ), - ).rejects.toThrow( - new Error( - 'referrer and source are required arguments for adding or updating a network configuration', - ), - ); + it('throws if blockExplorerUrls is non-empty, but defaultBlockExplorerUrlIndex is cleared', async () => { + const networkConfigurationToUpdate = buildCustomNetworkConfiguration({ + blockExplorerUrls: ['https://block.explorer'], + chainId: '0x1337', + defaultBlockExplorerUrlIndex: 0, }); - }); - it('throws if a falsy ticker is given', async () => { - await withController(async ({ controller }) => { - await expect( - controller.upsertNetworkConfiguration( - { - rpcUrl: 'https://test.network', - chainId: toHex(111), - // @ts-expect-error We are intentionally passing bad input. - ticker: false, - }, - { - referrer: 'https://test-dapp.com', - source: 'dapp', + await withController( + { + state: buildNetworkControllerStateWithDefaultSelectedNetworkClientId({ + networkConfigurationsByChainId: { + '0x1337': networkConfigurationToUpdate, }, - ), - ).rejects.toThrow( - new Error( - 'A ticker is required to add or update networkConfiguration', - ), - ); - }); + }), + }, + async ({ controller }) => { + await expect(() => + controller.updateNetwork( + '0x1337', + buildCustomNetworkConfiguration({ + ...networkConfigurationToUpdate, + defaultBlockExplorerUrlIndex: undefined, + }), + ), + ).rejects.toThrow( + new Error( + 'Could not update network: `defaultBlockExplorerUrlIndex` must refer to an entry in `blockExplorerUrls`', + ), + ); + }, + ); }); - it('throws if no ticker is given', async () => { - await withController(async ({ controller }) => { - await expect( - controller.upsertNetworkConfiguration( - // @ts-expect-error We are intentionally passing bad input. - { - rpcUrl: 'https://test.network', - chainId: toHex(111), - }, - { - referrer: 'https://test-dapp.com', - source: 'dapp', - }, - ), - ).rejects.toThrow( - new Error( - 'A ticker is required to add or update networkConfiguration', - ), - ); + it('throws if the new chainId field is a string, but not a 0x-prefixed hex number', async () => { + const networkConfigurationToUpdate = buildCustomNetworkConfiguration({ + chainId: '0x1337', }); - }); - }); - describe('removeNetworkConfiguration', () => { - describe('given an ID that identifies a network configuration in state', () => { - it('removes the network configuration from state', async () => { - await withController( - { - state: { - networkConfigurations: { - 'AAAA-AAAA-AAAA-AAAA': { - rpcUrl: 'https://test.network', - ticker: 'TICKER', - chainId: toHex(111), - id: 'AAAA-AAAA-AAAA-AAAA', - }, - }, + await withController( + { + state: buildNetworkControllerStateWithDefaultSelectedNetworkClientId({ + networkConfigurationsByChainId: { + '0x1337': networkConfigurationToUpdate, }, - }, - async ({ controller }) => { - controller.removeNetworkConfiguration('AAAA-AAAA-AAAA-AAAA'); + }), + }, + async ({ controller }) => { + await expect( + controller.updateNetwork( + '0x1337', + buildCustomNetworkConfiguration({ + // @ts-expect-error Intentionally passing bad input + chainId: '12345', + }), + ), + ).rejects.toThrow( + new Error( + `Could not update network: Invalid \`chainId\` '12345' (must start with "0x" and not exceed the maximum)`, + ), + ); + }, + ); + }); - expect(controller.state.networkConfigurations).toStrictEqual({}); - }, - ); + it('throws if the new chainId field is greater than the maximum allowed chain ID', async () => { + const networkConfigurationToUpdate = buildCustomNetworkConfiguration({ + chainId: '0x1337', }); - it('destroys and removes the network client in the network client registry that corresponds to the given ID', async () => { - await withController( - { - state: { - networkConfigurations: { - 'AAAA-AAAA-AAAA-AAAA': { - rpcUrl: 'https://test.network', - ticker: 'TICKER', - chainId: toHex(111), - id: 'AAAA-AAAA-AAAA-AAAA', - }, - }, + await withController( + { + state: buildNetworkControllerStateWithDefaultSelectedNetworkClientId({ + networkConfigurationsByChainId: { + '0x1337': networkConfigurationToUpdate, }, - }, - async ({ controller }) => { - mockCreateNetworkClientWithDefaultsForBuiltInNetworkClients() - .calledWith({ - chainId: toHex(111), - rpcUrl: 'https://test.network', - type: NetworkClientType.Custom, - ticker: 'TEST', - }) - .mockReturnValue(buildFakeClient()); - const networkClientToDestroy = Object.values( - controller.getNetworkClientRegistry(), - ).find(({ configuration }) => { - return ( - configuration.type === NetworkClientType.Custom && - configuration.chainId === toHex(111) && - configuration.rpcUrl === 'https://test.network' - ); - }); - assert(networkClientToDestroy); - jest.spyOn(networkClientToDestroy, 'destroy'); + }), + }, + async ({ controller }) => { + await expect( + controller.updateNetwork( + '0x1337', + buildCustomNetworkConfiguration({ + chainId: toHex(MAX_SAFE_CHAIN_ID + 1), + }), + ), + ).rejects.toThrow( + new Error( + `Could not update network: Invalid \`chainId\` '0xfffffffffffed' (must start with "0x" and not exceed the maximum)`, + ), + ); + }, + ); + }); - controller.removeNetworkConfiguration('AAAA-AAAA-AAAA-AAAA'); + it('throws if the new rpcEndpoints field is an empty array', async () => { + const networkConfigurationToUpdate = buildCustomNetworkConfiguration({ + chainId: '0x1337', + }); - expect(networkClientToDestroy.destroy).toHaveBeenCalled(); - expect(controller.getNetworkClientRegistry()).not.toMatchObject({ - 'https://test.network': expect.objectContaining({ - configuration: { - chainId: toHex(111), - rpcUrl: 'https://test.network', - type: NetworkClientType.Custom, - ticker: 'TEST', - }, + await withController( + { + state: buildNetworkControllerStateWithDefaultSelectedNetworkClientId({ + networkConfigurationsByChainId: { + '0x1337': networkConfigurationToUpdate, + }, + }), + }, + async ({ controller }) => { + await expect( + controller.updateNetwork( + '0x1337', + buildNetworkConfiguration({ + rpcEndpoints: [], }), - }); - }, - ); - }); + ), + ).rejects.toThrow( + new Error( + 'Could not update network: `rpcEndpoints` must be a non-empty array', + ), + ); + }, + ); }); - describe('given an ID that does not identify a network configuration in state', () => { - it('throws', async () => { - await withController(async ({ controller }) => { - expect(() => - controller.removeNetworkConfiguration('NONEXISTENT'), - ).toThrow( - `networkConfigurationId NONEXISTENT does not match a configured networkConfiguration`, + it('throws if one of the new rpcEndpoints has an invalid url property', async () => { + const networkConfigurationToUpdate = buildCustomNetworkConfiguration({ + chainId: '0x1337', + }); + + await withController( + { + state: buildNetworkControllerStateWithDefaultSelectedNetworkClientId({ + networkConfigurationsByChainId: { + '0x1337': networkConfigurationToUpdate, + }, + }), + }, + async ({ controller }) => { + await expect( + controller.updateNetwork('0x1337', { + ...networkConfigurationToUpdate, + rpcEndpoints: [ + buildUpdateNetworkCustomRpcEndpointFields({ + url: 'clearly-not-a-url', + }), + ], + }), + ).rejects.toThrow( + new Error( + "Could not update network: An entry in `rpcEndpoints` has invalid URL 'clearly-not-a-url'", + ), ); - }); + }, + ); + }); + + it('throws if one of the new RPC endpoints has a networkClientId that does not refer to a registered network client', async () => { + const networkConfigurationToUpdate = buildCustomNetworkConfiguration({ + chainId: '0x1337', }); - it('does not update the network client registry', async () => { - await withController(async ({ controller }) => { - mockCreateNetworkClientWithDefaultsForBuiltInNetworkClients(); - const networkClients = controller.getNetworkClientRegistry(); + await withController( + { + state: buildNetworkControllerStateWithDefaultSelectedNetworkClientId({ + networkConfigurationsByChainId: { + '0x1337': networkConfigurationToUpdate, + }, + }), + }, + async ({ controller }) => { + await expect( + controller.updateNetwork('0x1337', { + ...networkConfigurationToUpdate, + rpcEndpoints: [ + buildUpdateNetworkCustomRpcEndpointFields({ + url: 'https://foo.com', + networkClientId: 'not-a-real-network-client-id', + }), + ], + }), + ).rejects.toThrow( + new Error( + "Could not update network: RPC endpoint 'https://foo.com' refers to network client 'not-a-real-network-client-id' that does not exist", + ), + ); + }, + ); + }); - try { - controller.removeNetworkConfiguration('NONEXISTENT'); - } catch { - // ignore error (it is tested elsewhere) - } + it('throws if the URLs of two or more RPC endpoints have similar schemes (comparing case-insensitively)', async () => { + const networkConfigurationToUpdate = buildNetworkConfiguration(); - expect(controller.getNetworkClientRegistry()).toStrictEqual( - networkClients, + await withController( + { + state: buildNetworkControllerStateWithDefaultSelectedNetworkClientId({ + networkConfigurationsByChainId: { + '0x1337': networkConfigurationToUpdate, + }, + }), + }, + async ({ controller }) => { + await expect( + controller.updateNetwork('0x1337', { + ...networkConfigurationToUpdate, + rpcEndpoints: [ + buildUpdateNetworkCustomRpcEndpointFields({ + url: 'https://foo.com/bar', + }), + buildUpdateNetworkCustomRpcEndpointFields({ + url: 'HTTPS://foo.com/bar', + }), + ], + }), + ).rejects.toThrow( + new Error( + 'Could not update network: Each entry in rpcEndpoints must have a unique URL', + ), ); - }); - }); + }, + ); }); - }); - describe('rollbackToPreviousProvider', () => { - describe('if a provider has not been set', () => { - [NetworkType.mainnet, NetworkType.goerli, NetworkType.sepolia].forEach( - (networkType) => { - describe(`when the type in the provider configuration is "${networkType}"`, () => { - refreshNetworkTests({ - expectedProviderConfig: buildProviderConfig({ - type: networkType, - }), - initialState: { - providerConfig: buildProviderConfig({ type: networkType }), - }, - operation: async (controller) => { - await controller.rollbackToPreviousProvider(); - }, - }); - }); + it('throws if the URLs of two or more RPC endpoints have similar hostnames (comparing case-insensitively)', async () => { + const networkConfigurationToUpdate = buildNetworkConfiguration(); + + await withController( + { + state: buildNetworkControllerStateWithDefaultSelectedNetworkClientId({ + networkConfigurationsByChainId: { + '0x1337': networkConfigurationToUpdate, + }, + }), + }, + async ({ controller }) => { + await expect( + controller.updateNetwork('0x1337', { + ...networkConfigurationToUpdate, + rpcEndpoints: [ + buildUpdateNetworkCustomRpcEndpointFields({ + url: 'https://foo.com/bar', + }), + buildUpdateNetworkCustomRpcEndpointFields({ + url: 'https://fOo.CoM/bar', + }), + ], + }), + ).rejects.toThrow( + new Error( + 'Could not update network: Each entry in rpcEndpoints must have a unique URL', + ), + ); }, ); + }); - describe(`when the type in the provider configuration is "rpc"`, () => { - refreshNetworkTests({ - expectedProviderConfig: buildProviderConfig({ - type: NetworkType.rpc, + it('does not throw if the URLs of two or more RPC endpoints have similar paths (comparing case-insensitively)', async () => { + const networkConfigurationToUpdate = buildNetworkConfiguration({ + chainId: '0x1337', + rpcEndpoints: [ + buildCustomRpcEndpoint({ + url: 'https://foo.com/bar', }), - initialState: { - providerConfig: buildProviderConfig({ type: NetworkType.rpc }), - }, - operation: async (controller) => { - await controller.rollbackToPreviousProvider(); - }, - }); + ], }); - }); - describe('if a provider has been set', () => { - for (const { networkType } of INFURA_NETWORKS) { - describe(`if the previous provider configuration had a type of "${networkType}"`, () => { - it('emits networkWillChange', async () => { - await withController( - { - state: { - providerConfig: buildProviderConfig({ - type: networkType, - }), - networkConfigurations: { - testNetworkConfiguration: { - id: 'testNetworkConfiguration', - rpcUrl: 'https://mock-rpc-url', - chainId: toHex(1337), - ticker: 'TEST', - nickname: 'test network', - rpcPrefs: { - blockExplorerUrl: 'https://test-block-explorer.com', - }, - }, - }, - }, - }, - async ({ controller, messenger }) => { - const fakeProvider = buildFakeProvider(); - const fakeNetworkClient = buildFakeClient(fakeProvider); - mockCreateNetworkClient().mockReturnValue(fakeNetworkClient); - await controller.setActiveNetwork('testNetworkConfiguration'); + await withController( + { + state: buildNetworkControllerStateWithDefaultSelectedNetworkClientId({ + networkConfigurationsByChainId: { + '0x1337': networkConfigurationToUpdate, + }, + }), + }, + async ({ controller }) => { + const result = await controller.updateNetwork('0x1337', { + ...networkConfigurationToUpdate, + defaultRpcEndpointIndex: 0, + rpcEndpoints: [ + networkConfigurationToUpdate.rpcEndpoints[0], + buildUpdateNetworkCustomRpcEndpointFields({ + url: 'https://foo.com/BAR', + }), + ], + }); - const networkWillChange = waitForPublishedEvents({ - messenger, - eventType: 'NetworkController:networkWillChange', - operation: () => { - // Intentionally not awaited because we're capturing an event - // emitted partway through the operation - controller.rollbackToPreviousProvider(); - }, - }); + expect(result).toBeDefined(); + }, + ); + }); - await expect(networkWillChange).toBeFulfilled(); - }, - ); - }); + for (const infuraNetworkType of INFURA_NETWORKS) { + const infuraNetworkNickname = NetworkNickname[infuraNetworkType]; + const infuraChainId = ChainId[infuraNetworkType]; - it('emits networkDidChange', async () => { - await withController( - { - state: { - providerConfig: buildProviderConfig({ - type: networkType, - }), - networkConfigurations: { - testNetworkConfiguration: { - id: 'testNetworkConfiguration', - rpcUrl: 'https://mock-rpc-url', - chainId: toHex(1337), - ticker: 'TEST', - nickname: 'test network', - rpcPrefs: { - blockExplorerUrl: 'https://test-block-explorer.com', - }, + it(`throws if an Infura RPC endpoint is being added which is already present in the network configuration for the Infura-supported chain ${infuraChainId}`, async () => { + const networkConfigurationToUpdate = buildCustomNetworkConfiguration({ + chainId: '0x1337', + }); + const infuraRpcEndpoint = buildInfuraRpcEndpoint(infuraNetworkType); + + await withController( + { + state: + buildNetworkControllerStateWithDefaultSelectedNetworkClientId({ + networkConfigurationsByChainId: { + '0x1337': networkConfigurationToUpdate, + [infuraChainId]: buildInfuraNetworkConfiguration( + infuraNetworkType, + { + rpcEndpoints: [infuraRpcEndpoint], }, - }, + ), }, - }, - async ({ controller, messenger }) => { - const fakeProvider = buildFakeProvider(); - const fakeNetworkClient = buildFakeClient(fakeProvider); - mockCreateNetworkClient().mockReturnValue(fakeNetworkClient); - await controller.setActiveNetwork('testNetworkConfiguration'); - - const networkDidChange = waitForPublishedEvents({ - messenger, - eventType: 'NetworkController:networkDidChange', - operation: () => { - // Intentionally not awaited because we're capturing an event - // emitted partway through the operation - controller.rollbackToPreviousProvider(); - }, - }); - - await expect(networkDidChange).toBeFulfilled(); - }, + }), + }, + async ({ controller }) => { + await expect( + controller.updateNetwork('0x1337', { + ...networkConfigurationToUpdate, + rpcEndpoints: [infuraRpcEndpoint], + }), + ).rejects.toThrow( + `Could not update network to point to same RPC endpoint as existing network for chain ${infuraChainId} ('${infuraNetworkNickname}')`, ); - }); + }, + ); + }); + } - it('overwrites the the current provider configuration with the previous provider configuration', async () => { - await withController( - { - state: { - providerConfig: buildProviderConfig({ - type: networkType, + it('throws if a custom RPC endpoint is being added which is already present in another network configuration (comparing URLs case-insensitively)', async () => { + const networkConfigurationToUpdate = buildNetworkConfiguration({ + chainId: '0x1337', + rpcEndpoints: [ + buildCustomRpcEndpoint({ + url: 'http://test.endpoint/foo', + }), + ], + }); + + await withController( + { + state: buildNetworkControllerStateWithDefaultSelectedNetworkClientId({ + networkConfigurationsByChainId: { + '0x1337': networkConfigurationToUpdate, + '0x2448': buildNetworkConfiguration({ + chainId: '0x2448', + rpcEndpoints: [ + buildCustomRpcEndpoint({ + url: 'http://test.endpoint/bar', }), - networkConfigurations: { - testNetworkConfiguration: { - id: 'testNetworkConfiguration', - rpcUrl: 'https://mock-rpc-url', - chainId: toHex(1337), - ticker: 'TEST', - nickname: 'test network', - rpcPrefs: { - blockExplorerUrl: 'https://test-block-explorer.com', - }, - }, - }, - }, - infuraProjectId: 'some-infura-project-id', - }, - async ({ controller }) => { - const fakeProviders = [ - buildFakeProvider(), - buildFakeProvider(), - ]; - const fakeNetworkClients = [ - buildFakeClient(fakeProviders[0]), - buildFakeClient(fakeProviders[1]), - ]; - mockCreateNetworkClient() - .calledWith({ - rpcUrl: 'https://mock-rpc-url', - chainId: toHex(1337), - type: NetworkClientType.Custom, - ticker: 'TEST', - }) - .mockReturnValue(fakeNetworkClients[0]) - .calledWith({ - network: networkType, - infuraProjectId: 'some-infura-project-id', - chainId: BUILT_IN_NETWORKS[networkType].chainId, - ticker: BUILT_IN_NETWORKS[networkType].ticker, - type: NetworkClientType.Infura, - }) - .mockReturnValue(fakeNetworkClients[1]); - await controller.setActiveNetwork('testNetworkConfiguration'); - expect(controller.state.providerConfig).toStrictEqual({ - type: 'rpc', - id: 'testNetworkConfiguration', - rpcUrl: 'https://mock-rpc-url', - chainId: toHex(1337), - ticker: 'TEST', - nickname: 'test network', - rpcPrefs: { - blockExplorerUrl: 'https://test-block-explorer.com', - }, - }); + ], + }), + }, + }), + }, + async ({ controller }) => { + await expect( + controller.updateNetwork('0x1337', { + ...networkConfigurationToUpdate, + rpcEndpoints: [ + buildUpdateNetworkCustomRpcEndpointFields({ + url: 'http://test.endpoint/foo', + }), + buildUpdateNetworkCustomRpcEndpointFields({ + url: 'HTTP://TEST.ENDPOINT/bar', + }), + ], + }), + ).rejects.toThrow( + new Error( + "Could not update network to point to same RPC endpoint as existing network for chain 0x2448 ('Some Network')", + ), + ); + }, + ); + }); - await controller.rollbackToPreviousProvider(); + it('throws if two or more RPC endpoints are exactly the same object', async () => { + const networkConfigurationToUpdate = buildNetworkConfiguration(); - expect(controller.state.providerConfig).toStrictEqual( - buildProviderConfig({ - type: networkType, - }), - ); + await withController( + { + state: buildNetworkControllerStateWithDefaultSelectedNetworkClientId({ + networkConfigurationsByChainId: { + '0x1337': networkConfigurationToUpdate, + }, + }), + }, + async ({ controller }) => { + const rpcEndpoint = buildUpdateNetworkCustomRpcEndpointFields(); + await expect( + controller.updateNetwork('0x1337', { + ...networkConfigurationToUpdate, + defaultRpcEndpointIndex: 0, + rpcEndpoints: [rpcEndpoint, rpcEndpoint], + }), + ).rejects.toThrow( + new Error( + 'Could not update network: Each entry in rpcEndpoints must be unique', + ), + ); + }, + ); + }); + + it('throws if two or more RPC endpoints have the same networkClientId', async () => { + const rpcEndpoint = buildCustomRpcEndpoint({ + url: 'https://test.endpoint', + networkClientId: 'AAAA-AAAA-AAAA-AAAA', + }); + const networkConfigurationToUpdate = buildNetworkConfiguration({ + rpcEndpoints: [rpcEndpoint], + }); + + await withController( + { + state: buildNetworkControllerStateWithDefaultSelectedNetworkClientId({ + networkConfigurationsByChainId: { + '0x1337': networkConfigurationToUpdate, + }, + }), + }, + async ({ controller }) => { + await expect( + controller.updateNetwork('0x1337', { + ...networkConfigurationToUpdate, + rpcEndpoints: [ + rpcEndpoint, + buildUpdateNetworkCustomRpcEndpointFields({ + url: 'https://test.endpoint/2', + networkClientId: rpcEndpoint.networkClientId, + }), + ], + }), + ).rejects.toThrow( + new Error( + 'Could not update network: Each entry in rpcEndpoints must have a unique networkClientId', + ), + ); + }, + ); + }); + + it('throws (albeit for a different reason) if there are two or more different Infura RPC endpoints', async () => { + const [mainnetRpcEndpoint, testnetRpcEndpoint] = [ + buildInfuraRpcEndpoint(InfuraNetworkType.mainnet), + buildInfuraRpcEndpoint(TESTNET.networkType), + ]; + const networkConfigurationToUpdate = buildNetworkConfiguration({ + name: 'Mainnet', + chainId: ChainId.mainnet, + rpcEndpoints: [mainnetRpcEndpoint], + }); + + await withController( + { + state: buildNetworkControllerStateWithDefaultSelectedNetworkClientId({ + networkConfigurationsByChainId: { + [ChainId.mainnet]: networkConfigurationToUpdate, + [TESTNET.chainId]: buildNetworkConfiguration({ + name: TESTNET.name, + chainId: TESTNET.chainId, + rpcEndpoints: [testnetRpcEndpoint], + }), + }, + }), + }, + async ({ controller }) => { + await expect( + controller.updateNetwork(ChainId.mainnet, { + ...networkConfigurationToUpdate, + rpcEndpoints: [mainnetRpcEndpoint, testnetRpcEndpoint], + }), + ).rejects.toThrow( + new Error( + `Could not update network to point to same RPC endpoint as existing network for chain ${TESTNET.chainId} ('${TESTNET.name}')`, + ), + ); + }, + ); + }); + + it('throws if the new defaultRpcEndpointIndex does not refer to an entry in rpcEndpoints', async () => { + const networkConfigurationToUpdate = buildCustomNetworkConfiguration({ + chainId: '0x1337', + rpcEndpoints: [ + buildCustomRpcEndpoint({ + url: 'https://foo.com', + }), + buildCustomRpcEndpoint({ + url: 'https://bar.com', + }), + ], + }); + + await withController( + { + state: buildNetworkControllerStateWithDefaultSelectedNetworkClientId({ + networkConfigurationsByChainId: { + '0x1337': networkConfigurationToUpdate, + }, + }), + }, + async ({ controller }) => { + await expect( + controller.updateNetwork('0x1337', { + ...networkConfigurationToUpdate, + defaultRpcEndpointIndex: 99999, + }), + ).rejects.toThrow( + new Error( + 'Could not update network: `defaultRpcEndpointIndex` must refer to an entry in `rpcEndpoints`', + ), + ); + }, + ); + }); + + it('throws if a RPC endpoint being removed is represented by the selected network client, and replacementSelectedRpcEndpointIndex is not specified', async () => { + const networkConfigurationToUpdate = buildCustomNetworkConfiguration({ + chainId: '0x1337', + rpcEndpoints: [ + buildCustomRpcEndpoint({ + networkClientId: 'AAAA-AAAA-AAAA-AAAA', + url: 'https://foo.com', + }), + buildCustomRpcEndpoint({ + networkClientId: 'BBBB-BBBB-BBBB-BBBB', + url: 'https://bar.com', + }), + ], + }); + + await withController( + { + state: { + selectedNetworkClientId: 'AAAA-AAAA-AAAA-AAAA', + networkConfigurationsByChainId: { + '0x1337': networkConfigurationToUpdate, + }, + }, + }, + async ({ controller }) => { + await expect( + controller.updateNetwork('0x1337', { + ...networkConfigurationToUpdate, + rpcEndpoints: [networkConfigurationToUpdate.rpcEndpoints[1]], + }), + ).rejects.toThrow( + new Error( + "Could not update network: Cannot update RPC endpoints in such a way that the selected network 'AAAA-AAAA-AAAA-AAAA' would be removed without a replacement. Choose a different RPC endpoint as the selected network via the `replacementSelectedRpcEndpointIndex` option.", + ), + ); + }, + ); + }); + + it('throws if a RPC endpoint being removed is represented by the selected network client, and an invalid replacementSelectedRpcEndpointIndex is not specified', async () => { + const networkConfigurationToUpdate = buildCustomNetworkConfiguration({ + chainId: '0x1337', + rpcEndpoints: [ + buildCustomRpcEndpoint({ + networkClientId: 'AAAA-AAAA-AAAA-AAAA', + url: 'https://foo.com', + }), + buildCustomRpcEndpoint({ + networkClientId: 'BBBB-BBBB-BBBB-BBBB', + url: 'https://bar.com', + }), + ], + }); + + await withController( + { + state: { + selectedNetworkClientId: 'AAAA-AAAA-AAAA-AAAA', + networkConfigurationsByChainId: { + '0x1337': networkConfigurationToUpdate, + }, + }, + }, + async ({ controller }) => { + await expect( + controller.updateNetwork( + '0x1337', + { + ...networkConfigurationToUpdate, + rpcEndpoints: [networkConfigurationToUpdate.rpcEndpoints[1]], + }, + { replacementSelectedRpcEndpointIndex: 9999 }, + ), + ).rejects.toThrow( + new Error( + `Could not update network: \`replacementSelectedRpcEndpointIndex\` 9999 does not refer to an entry in \`rpcEndpoints\``, + ), + ); + }, + ); + }); + + it('is callable from the controller messenger', async () => { + const originalNetwork = buildCustomNetworkConfiguration({ + chainId: '0x1337', + rpcEndpoints: [ + buildCustomRpcEndpoint({ + networkClientId: 'AAAA-AAAA-AAAA-AAAA', + url: 'https://rpc.network', + }), + ], + }); + + const networkToUpdate = buildCustomNetworkConfiguration({ + chainId: '0x1337', + rpcEndpoints: [ + buildCustomRpcEndpoint({ + name: 'Custom Name', + networkClientId: 'AAAA-AAAA-AAAA-AAAA', + url: 'https://rpc.network', + }), + ], + }); + + const controllerState = + buildNetworkControllerStateWithDefaultSelectedNetworkClientId({ + networkConfigurationsByChainId: { + [originalNetwork.chainId]: originalNetwork, + }, + networksMetadata: { + 'AAAA-AAAA-AAAA-AAAA': { + EIPS: { + '1559': true, }, + status: NetworkStatus.Available, + }, + }, + }); + + await withController( + { state: controllerState }, + async ({ controller, messenger }) => { + await messenger.call( + 'NetworkController:updateNetwork', + networkToUpdate.chainId, + networkToUpdate, + ); + expect( + controller.state.networkConfigurationsByChainId['0x1337'] + .rpcEndpoints[0].name, + ).toBe('Custom Name'); + }, + ); + }); + + for (const infuraNetworkType of INFURA_NETWORKS) { + const infuraChainId = ChainId[infuraNetworkType]; + const infuraNativeTokenName = NetworksTicker[infuraNetworkType]; + + describe(`if the existing chain ID is the Infura-supported chain ${infuraChainId} and is not being changed`, () => { + describe('when a new Infura RPC endpoint is being added', () => { + it('creates and registers a new network client for the RPC endpoint', async () => { + const createAutoManagedNetworkClientSpy = jest.spyOn( + createAutoManagedNetworkClientModule, + 'createAutoManagedNetworkClient', ); - }); + const networkConfigurationToUpdate = + buildInfuraNetworkConfiguration(infuraNetworkType, { + rpcEndpoints: [ + buildCustomRpcEndpoint({ + networkClientId: 'AAAA-AAAA-AAAA-AAAA', + url: 'https://rpc.network', + }), + ], + }); + const infuraProjectId = 'some-infura-project-id'; + const getRpcServiceOptions = (): Omit< + RpcServiceOptions, + 'failoverService' | 'endpointUrl' + > => ({ + btoa, + fetch, + isOffline: (): boolean => false, + fetchOptions: { + headers: { + 'X-Foo': 'Bar', + }, + }, + policyOptions: { + maxRetries: 2, + maxConsecutiveFailures: 10, + }, + }); + const getBlockTrackerOptions = (): PollingBlockTrackerOptions => ({ + pollingInterval: 2000, + }); - it('resets the network status to "unknown" before updating the provider', async () => { await withController( { state: { - providerConfig: buildProviderConfig({ - type: networkType, - }), - networkConfigurations: { - testNetworkConfiguration: { - id: 'testNetworkConfiguration', - rpcUrl: 'https://mock-rpc-url', - chainId: toHex(1337), - ticker: 'TEST', - }, + networkConfigurationsByChainId: { + [infuraChainId]: networkConfigurationToUpdate, + '0x9999': buildCustomNetworkConfiguration({ + chainId: '0x9999', + nativeCurrency: 'TEST-9999', + rpcEndpoints: [ + buildCustomRpcEndpoint({ + networkClientId: 'ZZZZ-ZZZZ-ZZZZ-ZZZZ', + url: 'https://selected.endpoint', + }), + ], + }), }, + selectedNetworkClientId: 'ZZZZ-ZZZZ-ZZZZ-ZZZZ', }, - infuraProjectId: 'some-infura-project-id', + infuraProjectId, + getRpcServiceOptions, + getBlockTrackerOptions, + rpcFailoverMode: 'enabled', }, - async ({ controller, messenger }) => { - const fakeProviders = [ - buildFakeProvider([ - { - request: { - method: 'eth_getBlockByNumber', - }, - response: SUCCESSFUL_ETH_GET_BLOCK_BY_NUMBER_RESPONSE, - }, - ]), - buildFakeProvider(), - ]; - const fakeNetworkClients = [ - buildFakeClient(fakeProviders[0]), - buildFakeClient(fakeProviders[1]), - ]; - mockCreateNetworkClient() - .calledWith({ - rpcUrl: 'https://mock-rpc-url', - chainId: toHex(1337), - type: NetworkClientType.Custom, - ticker: 'TEST', - }) - .mockReturnValue(fakeNetworkClients[0]) - .calledWith({ - network: networkType, - chainId: BUILT_IN_NETWORKS[networkType].chainId, - ticker: BUILT_IN_NETWORKS[networkType].ticker, - infuraProjectId: 'some-infura-project-id', - type: NetworkClientType.Infura, - }) - .mockReturnValue(fakeNetworkClients[1]); - await controller.setActiveNetwork('testNetworkConfiguration'); - expect( - controller.state.networksMetadata[ - controller.state.selectedNetworkClientId - ].status, - ).toBe('available'); + async ({ controller, networkControllerMessenger }) => { + const infuraRpcEndpoint: InfuraRpcEndpoint = { + failoverUrls: ['https://failover.endpoint'], + networkClientId: infuraNetworkType, + url: `https://${infuraNetworkType}.infura.io/v3/{infuraProjectId}`, + type: RpcEndpointType.Infura, + }; + + await controller.updateNetwork(infuraChainId, { + ...networkConfigurationToUpdate, + rpcEndpoints: [ + ...networkConfigurationToUpdate.rpcEndpoints, + infuraRpcEndpoint, + ], + }); - await waitForStateChanges({ - messenger, - propertyPath: ['networksMetadata', networkType, 'status'], - // We only care about the first state change, because it - // happens before networkDidChange - count: 1, - operation: () => { - // Intentionally not awaited because we want to check state - // while this operation is in-progress - controller.rollbackToPreviousProvider(); - }, - beforeResolving: () => { - expect( - controller.state.networksMetadata[ - controller.state.selectedNetworkClientId - ].status, - ).toBe('unknown'); + // Skipping network client creation for existing RPC endpoints + expect( + createAutoManagedNetworkClientSpy, + ).toHaveBeenNthCalledWith(3, { + networkClientId: infuraNetworkType, + networkClientConfiguration: { + chainId: infuraChainId, + failoverRpcUrls: ['https://failover.endpoint'], + infuraProjectId, + network: infuraNetworkType, + ticker: infuraNativeTokenName, + type: NetworkClientType.Infura, }, + getRpcServiceOptions, + getBlockTrackerOptions, + messenger: networkControllerMessenger, + rpcFailoverMode: 'enabled', + }); + + const networkConfigurationsByNetworkClientId = + getNetworkConfigurationsByNetworkClientId( + controller.getNetworkClientRegistry(), + ); + expect( + networkConfigurationsByNetworkClientId[infuraNetworkType], + ).toStrictEqual({ + chainId: infuraChainId, + failoverRpcUrls: ['https://failover.endpoint'], + infuraProjectId, + network: infuraNetworkType, + ticker: infuraNativeTokenName, + type: NetworkClientType.Infura, }); }, ); }); - it(`initializes a provider pointed to the "${networkType}" Infura network`, async () => { + it('overrides the endpoint failover URLs with the chain-level failoverUrls when the controller was initialized with them', async () => { + const createAutoManagedNetworkClientSpy = jest.spyOn( + createAutoManagedNetworkClientModule, + 'createAutoManagedNetworkClient', + ); + const networkConfigurationToUpdate = + buildInfuraNetworkConfiguration(infuraNetworkType, { + rpcEndpoints: [ + buildCustomRpcEndpoint({ + networkClientId: 'AAAA-AAAA-AAAA-AAAA', + url: 'https://rpc.network', + }), + ], + }); + const infuraProjectId = 'some-infura-project-id'; + await withController( { state: { - providerConfig: buildProviderConfig({ - type: networkType, - }), - networkConfigurations: { - testNetworkConfiguration: { - id: 'testNetworkConfiguration', - rpcUrl: 'https://mock-rpc-url', - chainId: toHex(1337), - ticker: 'TEST', - }, + networkConfigurationsByChainId: { + [infuraChainId]: networkConfigurationToUpdate, + '0x9999': buildCustomNetworkConfiguration({ + chainId: '0x9999', + nativeCurrency: 'TEST-9999', + rpcEndpoints: [ + buildCustomRpcEndpoint({ + networkClientId: 'ZZZZ-ZZZZ-ZZZZ-ZZZZ', + url: 'https://selected.endpoint', + }), + ], + }), }, + selectedNetworkClientId: 'ZZZZ-ZZZZ-ZZZZ-ZZZZ', }, - infuraProjectId: 'some-infura-project-id', + infuraProjectId, + failoverUrls: { + [infuraChainId]: ['https://chain.failover'], + }, + rpcFailoverMode: 'enabled', }, async ({ controller }) => { - const fakeProviders = [ - buildFakeProvider(), - buildFakeProvider([ - { - request: { - method: 'test', - }, - response: { - result: 'test response', - }, - }, - ]), - ]; - const fakeNetworkClients = [ - buildFakeClient(fakeProviders[0]), - buildFakeClient(fakeProviders[1]), - ]; - mockCreateNetworkClient() - .calledWith({ - rpcUrl: 'https://mock-rpc-url', - chainId: toHex(1337), - type: NetworkClientType.Custom, - ticker: 'TEST', - }) - .mockReturnValue(fakeNetworkClients[0]) - .calledWith({ - network: networkType, - infuraProjectId: 'some-infura-project-id', - chainId: BUILT_IN_NETWORKS[networkType].chainId, - ticker: BUILT_IN_NETWORKS[networkType].ticker, - type: NetworkClientType.Infura, - }) - .mockReturnValue(fakeNetworkClients[1]); - await controller.setActiveNetwork('testNetworkConfiguration'); - - await controller.rollbackToPreviousProvider(); + const infuraRpcEndpoint: InfuraRpcEndpoint = { + failoverUrls: ['https://failover.endpoint'], + networkClientId: infuraNetworkType, + url: `https://${infuraNetworkType}.infura.io/v3/{infuraProjectId}`, + type: RpcEndpointType.Infura, + }; + + await controller.updateNetwork(infuraChainId, { + ...networkConfigurationToUpdate, + rpcEndpoints: [ + ...networkConfigurationToUpdate.rpcEndpoints, + infuraRpcEndpoint, + ], + }); - const { provider } = controller.getProviderAndBlockTracker(); - assert(provider, 'Provider is somehow unset'); - const promisifiedSendAsync = promisify(provider.sendAsync).bind( - provider, + expect( + createAutoManagedNetworkClientSpy, + ).toHaveBeenNthCalledWith( + 3, + expect.objectContaining({ + networkClientId: infuraNetworkType, + networkClientConfiguration: expect.objectContaining({ + failoverRpcUrls: ['https://chain.failover'], + type: NetworkClientType.Infura, + }), + }), ); - const response = await promisifiedSendAsync({ - id: '1', - jsonrpc: '2.0', - method: 'test', - }); - expect(response.result).toBe('test response'); + + const networkConfigurationsByNetworkClientId = + getNetworkConfigurationsByNetworkClientId( + controller.getNetworkClientRegistry(), + ); + expect( + networkConfigurationsByNetworkClientId[infuraNetworkType] + .failoverRpcUrls, + ).toStrictEqual(['https://chain.failover']); }, ); }); - it('replaces the provider object underlying the provider proxy without creating a new instance of the proxy itself', async () => { + it('stores the network configuration with the new RPC endpoint in state', async () => { + const networkConfigurationToUpdate = + buildInfuraNetworkConfiguration(infuraNetworkType, { + rpcEndpoints: [ + buildCustomRpcEndpoint({ + networkClientId: 'AAAA-AAAA-AAAA-AAAA', + url: 'https://rpc.network', + }), + ], + }); + await withController( { state: { - providerConfig: buildProviderConfig({ - type: networkType, - }), - networkConfigurations: { - testNetworkConfiguration: { - id: 'testNetworkConfiguration', - rpcUrl: 'https://mock-rpc-url', - chainId: toHex(1337), - ticker: 'TEST', - }, + networkConfigurationsByChainId: { + [infuraChainId]: networkConfigurationToUpdate, + '0x9999': buildCustomNetworkConfiguration({ + chainId: '0x9999', + nativeCurrency: 'TEST-9999', + rpcEndpoints: [ + buildCustomRpcEndpoint({ + networkClientId: 'ZZZZ-ZZZZ-ZZZZ-ZZZZ', + url: 'https://selected.endpoint', + }), + ], + }), }, + selectedNetworkClientId: 'ZZZZ-ZZZZ-ZZZZ-ZZZZ', }, infuraProjectId: 'some-infura-project-id', }, async ({ controller }) => { - const fakeProviders = [ - buildFakeProvider(), - buildFakeProvider(), - ]; - const fakeNetworkClients = [ - buildFakeClient(fakeProviders[0]), - buildFakeClient(fakeProviders[1]), - ]; - mockCreateNetworkClient() - .calledWith({ - rpcUrl: 'https://mock-rpc-url', - chainId: toHex(1337), - type: NetworkClientType.Custom, - ticker: 'TEST', - }) - .mockReturnValue(fakeNetworkClients[0]) - .calledWith({ - network: networkType, - infuraProjectId: 'some-infura-project-id', - chainId: BUILT_IN_NETWORKS[networkType].chainId, - ticker: BUILT_IN_NETWORKS[networkType].ticker, - type: NetworkClientType.Infura, - }) - .mockReturnValue(fakeNetworkClients[1]); - await controller.setActiveNetwork('testNetworkConfiguration'); - const { provider: providerBefore } = - controller.getProviderAndBlockTracker(); - - await controller.rollbackToPreviousProvider(); + const infuraRpcEndpoint: InfuraRpcEndpoint = { + failoverUrls: ['https://failover.endpoint'], + networkClientId: infuraNetworkType, + url: `https://${infuraNetworkType}.infura.io/v3/{infuraProjectId}`, + type: RpcEndpointType.Infura, + }; + + await controller.updateNetwork(infuraChainId, { + ...networkConfigurationToUpdate, + rpcEndpoints: [ + ...networkConfigurationToUpdate.rpcEndpoints, + infuraRpcEndpoint, + ], + }); - const { provider: providerAfter } = - controller.getProviderAndBlockTracker(); - expect(providerBefore).toBe(providerAfter); + expect( + controller.state.networkConfigurationsByChainId[ + infuraChainId + ], + ).toStrictEqual({ + ...networkConfigurationToUpdate, + rpcEndpoints: [ + ...networkConfigurationToUpdate.rpcEndpoints, + infuraRpcEndpoint, + ], + lastUpdatedAt: FAKE_DATE_NOW_MS, + }); }, ); }); - it('emits infuraIsBlocked or infuraIsUnblocked, depending on whether Infura is blocking requests for the previous network', async () => { - await withController( - { - state: { - providerConfig: buildProviderConfig({ - type: networkType, + it('returns the updated network configuration', async () => { + const networkConfigurationToUpdate = + buildInfuraNetworkConfiguration(infuraNetworkType, { + rpcEndpoints: [ + buildCustomRpcEndpoint({ + networkClientId: 'AAAA-AAAA-AAAA-AAAA', + url: 'https://rpc.network', }), - networkConfigurations: { - testNetworkConfiguration: { - id: 'testNetworkConfiguration', - rpcUrl: 'https://mock-rpc-url', - chainId: toHex(1337), - ticker: 'TEST', - }, + ], + }); + + await withController( + { + state: { + networkConfigurationsByChainId: { + [infuraChainId]: networkConfigurationToUpdate, + '0x9999': buildCustomNetworkConfiguration({ + chainId: '0x9999', + nativeCurrency: 'TEST-9999', + rpcEndpoints: [ + buildCustomRpcEndpoint({ + networkClientId: 'ZZZZ-ZZZZ-ZZZZ-ZZZZ', + url: 'https://selected.endpoint', + }), + ], + }), }, + selectedNetworkClientId: 'ZZZZ-ZZZZ-ZZZZ-ZZZZ', }, infuraProjectId: 'some-infura-project-id', }, - async ({ controller, messenger }) => { - const fakeProviders = [ - buildFakeProvider(), - buildFakeProvider([ - { - request: { - method: 'eth_getBlockByNumber', - }, - error: BLOCKED_INFURA_JSON_RPC_ERROR, - }, - ]), - ]; - const fakeNetworkClients = [ - buildFakeClient(fakeProviders[0]), - buildFakeClient(fakeProviders[1]), - ]; - mockCreateNetworkClient() - .calledWith({ - rpcUrl: 'https://mock-rpc-url', - chainId: toHex(1337), - type: NetworkClientType.Custom, - ticker: 'TEST', - }) - .mockReturnValue(fakeNetworkClients[0]) - .calledWith({ - network: networkType, - infuraProjectId: 'some-infura-project-id', - chainId: BUILT_IN_NETWORKS[networkType].chainId, - ticker: BUILT_IN_NETWORKS[networkType].ticker, - type: NetworkClientType.Infura, - }) - .mockReturnValue(fakeNetworkClients[1]); - await controller.setActiveNetwork('testNetworkConfiguration'); - const promiseForNoInfuraIsUnblockedEvents = - waitForPublishedEvents({ - messenger, - eventType: 'NetworkController:infuraIsUnblocked', - count: 0, + async ({ controller }) => { + const infuraRpcEndpoint: InfuraRpcEndpoint = { + failoverUrls: ['https://failover.endpoint'], + networkClientId: infuraNetworkType, + url: `https://${infuraNetworkType}.infura.io/v3/{infuraProjectId}`, + type: RpcEndpointType.Infura, + }; + + const updatedNetworkConfiguration = + await controller.updateNetwork(infuraChainId, { + ...networkConfigurationToUpdate, + rpcEndpoints: [ + ...networkConfigurationToUpdate.rpcEndpoints, + infuraRpcEndpoint, + ], }); - const promiseForInfuraIsBlocked = waitForPublishedEvents({ - messenger, - eventType: 'NetworkController:infuraIsBlocked', - }); - - await controller.rollbackToPreviousProvider(); - await expect( - promiseForNoInfuraIsUnblockedEvents, - ).toBeFulfilled(); - await expect(promiseForInfuraIsBlocked).toBeFulfilled(); + expect(updatedNetworkConfiguration).toStrictEqual({ + ...networkConfigurationToUpdate, + rpcEndpoints: [ + ...networkConfigurationToUpdate.rpcEndpoints, + infuraRpcEndpoint, + ], + lastUpdatedAt: FAKE_DATE_NOW_MS, + }); }, ); }); + }); + + describe('when new custom RPC endpoints are being added', () => { + it('creates and registers new network clients for each RPC endpoint', async () => { + uuidV4Mock + .mockReturnValueOnce('AAAA-AAAA-AAAA-AAAA') + .mockReturnValueOnce('BBBB-BBBB-BBBB-BBBB'); + const createAutoManagedNetworkClientSpy = jest.spyOn( + createAutoManagedNetworkClientModule, + 'createAutoManagedNetworkClient', + ); + const infuraRpcEndpoint = buildInfuraRpcEndpoint(infuraNetworkType); + const networkConfigurationToUpdate = + buildInfuraNetworkConfiguration(infuraNetworkType); + const getRpcServiceOptions = (): Omit< + RpcServiceOptions, + 'failoverService' | 'endpointUrl' + > => ({ + btoa, + fetch, + isOffline: (): boolean => false, + fetchOptions: { + headers: { + 'X-Foo': 'Bar', + }, + }, + policyOptions: { + maxRetries: 2, + maxConsecutiveFailures: 10, + }, + }); + const getBlockTrackerOptions = (): PollingBlockTrackerOptions => ({ + pollingInterval: 2000, + }); - it('checks the status of the previous network again and updates state accordingly', async () => { await withController( { state: { - providerConfig: buildProviderConfig({ - type: networkType, - }), - networkConfigurations: { - testNetworkConfiguration: { - id: 'testNetworkConfiguration', - rpcUrl: 'https://mock-rpc-url', - chainId: toHex(1337), - ticker: 'TEST', - }, + networkConfigurationsByChainId: { + [infuraChainId]: networkConfigurationToUpdate, + '0x9999': buildCustomNetworkConfiguration({ + chainId: '0x9999', + nativeCurrency: 'TEST-9999', + rpcEndpoints: [ + buildCustomRpcEndpoint({ + networkClientId: 'ZZZZ-ZZZZ-ZZZZ-ZZZZ', + url: 'https://selected.endpoint', + }), + ], + }), }, + selectedNetworkClientId: 'ZZZZ-ZZZZ-ZZZZ-ZZZZ', }, infuraProjectId: 'some-infura-project-id', + getRpcServiceOptions, + getBlockTrackerOptions, + rpcFailoverMode: 'enabled', }, - async ({ controller, messenger }) => { - const fakeProviders = [ - buildFakeProvider([ - { - request: { - method: 'eth_getBlockByNumber', - }, - error: rpcErrors.methodNotFound(), - }, - ]), - buildFakeProvider([ - { - request: { - method: 'eth_getBlockByNumber', - }, - response: SUCCESSFUL_ETH_GET_BLOCK_BY_NUMBER_RESPONSE, - }, - ]), - ]; - const fakeNetworkClients = [ - buildFakeClient(fakeProviders[0]), - buildFakeClient(fakeProviders[1]), + async ({ controller, networkControllerMessenger }) => { + const [rpcEndpoint1, rpcEndpoint2] = [ + buildUpdateNetworkCustomRpcEndpointFields({ + failoverUrls: ['https://first.failover.endpoint'], + name: 'Endpoint 1', + url: 'https://rpc.endpoint/1', + }), + buildUpdateNetworkCustomRpcEndpointFields({ + failoverUrls: ['https://second.failover.endpoint'], + name: 'Endpoint 2', + url: 'https://rpc.endpoint/2', + }), ]; - mockCreateNetworkClient() - .calledWith({ - rpcUrl: 'https://mock-rpc-url', - chainId: toHex(1337), + await controller.updateNetwork(infuraChainId, { + ...networkConfigurationToUpdate, + defaultRpcEndpointIndex: 0, + rpcEndpoints: [infuraRpcEndpoint, rpcEndpoint1, rpcEndpoint2], + }); + + // Skipping network client creation for existing RPC endpoints + expect( + createAutoManagedNetworkClientSpy, + ).toHaveBeenNthCalledWith(3, { + networkClientId: 'AAAA-AAAA-AAAA-AAAA', + networkClientConfiguration: { + chainId: infuraChainId, + failoverRpcUrls: ['https://first.failover.endpoint'], + rpcUrl: 'https://rpc.endpoint/1', + ticker: infuraNativeTokenName, type: NetworkClientType.Custom, - ticker: 'TEST', - }) - .mockReturnValue(fakeNetworkClients[0]) - .calledWith({ - network: networkType, - infuraProjectId: 'some-infura-project-id', - chainId: BUILT_IN_NETWORKS[networkType].chainId, - ticker: BUILT_IN_NETWORKS[networkType].ticker, - type: NetworkClientType.Infura, - }) - .mockReturnValue(fakeNetworkClients[1]); - await controller.setActiveNetwork('testNetworkConfiguration'); + }, + getRpcServiceOptions, + getBlockTrackerOptions, + messenger: networkControllerMessenger, + rpcFailoverMode: 'enabled', + }); expect( - controller.state.networksMetadata[ - controller.state.selectedNetworkClientId - ].status, - ).toBe('unavailable'); - - await waitForStateChanges({ - messenger, - propertyPath: ['networksMetadata', networkType, 'status'], - operation: async () => { - await controller.rollbackToPreviousProvider(); + createAutoManagedNetworkClientSpy, + ).toHaveBeenNthCalledWith(4, { + networkClientId: 'BBBB-BBBB-BBBB-BBBB', + networkClientConfiguration: { + chainId: infuraChainId, + failoverRpcUrls: ['https://second.failover.endpoint'], + rpcUrl: 'https://rpc.endpoint/2', + ticker: infuraNativeTokenName, + type: NetworkClientType.Custom, }, + getRpcServiceOptions, + getBlockTrackerOptions, + messenger: networkControllerMessenger, + rpcFailoverMode: 'enabled', }); + + const networkConfigurationsByNetworkClientId = + getNetworkConfigurationsByNetworkClientId( + controller.getNetworkClientRegistry(), + ); expect( - controller.state.networksMetadata[ - controller.state.selectedNetworkClientId - ].status, - ).toBe('available'); + networkConfigurationsByNetworkClientId['AAAA-AAAA-AAAA-AAAA'], + ).toStrictEqual({ + chainId: infuraChainId, + failoverRpcUrls: ['https://first.failover.endpoint'], + rpcUrl: 'https://rpc.endpoint/1', + ticker: infuraNativeTokenName, + type: NetworkClientType.Custom, + }); + expect( + networkConfigurationsByNetworkClientId['BBBB-BBBB-BBBB-BBBB'], + ).toStrictEqual({ + chainId: infuraChainId, + failoverRpcUrls: ['https://second.failover.endpoint'], + rpcUrl: 'https://rpc.endpoint/2', + ticker: infuraNativeTokenName, + type: NetworkClientType.Custom, + }); }, ); }); - it('checks whether the previous network supports EIP-1559 again and updates state accordingly', async () => { + it('assigns the ID of the created network client to each RPC endpoint in state', async () => { + uuidV4Mock + .mockReturnValueOnce('AAAA-AAAA-AAAA-AAAA') + .mockReturnValueOnce('BBBB-BBBB-BBBB-BBBB'); + const networkConfigurationToUpdate = + buildInfuraNetworkConfiguration(infuraNetworkType, { + rpcEndpoints: [buildInfuraRpcEndpoint(infuraNetworkType)], + }); + await withController( { state: { - providerConfig: buildProviderConfig({ - type: networkType, - }), - networkConfigurations: { - testNetworkConfiguration: { - id: 'testNetworkConfiguration', - rpcUrl: 'https://mock-rpc-url', - chainId: toHex(1337), - ticker: 'TEST', - }, + networkConfigurationsByChainId: { + [infuraChainId]: networkConfigurationToUpdate, + '0x9999': buildCustomNetworkConfiguration({ + chainId: '0x9999', + nativeCurrency: 'TEST-9999', + rpcEndpoints: [ + buildCustomRpcEndpoint({ + networkClientId: 'ZZZZ-ZZZZ-ZZZZ-ZZZZ', + url: 'https://selected.endpoint', + }), + ], + }), }, + selectedNetworkClientId: 'ZZZZ-ZZZZ-ZZZZ-ZZZZ', }, infuraProjectId: 'some-infura-project-id', }, - async ({ controller, messenger }) => { - const fakeProviders = [ - buildFakeProvider([ + async ({ controller }) => { + await controller.updateNetwork(infuraChainId, { + ...networkConfigurationToUpdate, + defaultRpcEndpointIndex: 0, + rpcEndpoints: [ + ...networkConfigurationToUpdate.rpcEndpoints, + buildUpdateNetworkCustomRpcEndpointFields({ + failoverUrls: ['https://first.failover.endpoint'], + name: 'Endpoint 2', + url: 'https://rpc.endpoint/2', + }), + buildUpdateNetworkCustomRpcEndpointFields({ + failoverUrls: ['https://second.failover.endpoint'], + name: 'Endpoint 3', + url: 'https://rpc.endpoint/3', + }), + ], + }); + + expect( + controller.state.networkConfigurationsByChainId[ + infuraChainId + ], + ).toStrictEqual({ + ...networkConfigurationToUpdate, + rpcEndpoints: [ + ...networkConfigurationToUpdate.rpcEndpoints, { - request: { - method: 'eth_getBlockByNumber', - }, - response: { - result: PRE_1559_BLOCK, - }, + failoverUrls: ['https://first.failover.endpoint'], + name: 'Endpoint 2', + networkClientId: 'AAAA-AAAA-AAAA-AAAA', + type: RpcEndpointType.Custom, + url: 'https://rpc.endpoint/2', }, - ]), - buildFakeProvider([ { - request: { - method: 'eth_getBlockByNumber', - }, - response: { - result: POST_1559_BLOCK, - }, + failoverUrls: ['https://second.failover.endpoint'], + name: 'Endpoint 3', + networkClientId: 'BBBB-BBBB-BBBB-BBBB', + type: RpcEndpointType.Custom, + url: 'https://rpc.endpoint/3', }, - ]), - ]; - const fakeNetworkClients = [ - buildFakeClient(fakeProviders[0]), - buildFakeClient(fakeProviders[1]), - ]; - mockCreateNetworkClient() - .calledWith({ - rpcUrl: 'https://mock-rpc-url', - chainId: toHex(1337), - type: NetworkClientType.Custom, - ticker: 'TEST', - }) - .mockReturnValue(fakeNetworkClients[0]) - .calledWith({ - network: networkType, - infuraProjectId: 'some-infura-project-id', - chainId: BUILT_IN_NETWORKS[networkType].chainId, - ticker: BUILT_IN_NETWORKS[networkType].ticker, - type: NetworkClientType.Infura, - }) - .mockReturnValue(fakeNetworkClients[1]); - await controller.setActiveNetwork('testNetworkConfiguration'); - expect( - controller.state.networksMetadata[ - controller.state.selectedNetworkClientId - ].EIPS[1559], - ).toBe(false); + ], + lastUpdatedAt: FAKE_DATE_NOW_MS, + }); + }, + ); + }); - await waitForStateChanges({ - messenger, - propertyPath: ['networksMetadata', networkType, 'EIPS'], - count: 2, - operation: async () => { - await controller.rollbackToPreviousProvider(); + it('returns the updated network configuration', async () => { + uuidV4Mock + .mockReturnValueOnce('AAAA-AAAA-AAAA-AAAA') + .mockReturnValueOnce('BBBB-BBBB-BBBB-BBBB'); + const networkConfigurationToUpdate = + buildInfuraNetworkConfiguration(infuraNetworkType, { + rpcEndpoints: [buildInfuraRpcEndpoint(infuraNetworkType)], + }); + + await withController( + { + state: { + networkConfigurationsByChainId: { + [infuraChainId]: networkConfigurationToUpdate, + '0x9999': buildCustomNetworkConfiguration({ + chainId: '0x9999', + nativeCurrency: 'TEST-9999', + rpcEndpoints: [ + buildCustomRpcEndpoint({ + networkClientId: 'ZZZZ-ZZZZ-ZZZZ-ZZZZ', + url: 'https://selected.endpoint', + }), + ], + }), }, + selectedNetworkClientId: 'ZZZZ-ZZZZ-ZZZZ-ZZZZ', + }, + infuraProjectId: 'some-infura-project-id', + }, + async ({ controller }) => { + const updatedNetworkConfiguration = + await controller.updateNetwork(infuraChainId, { + ...networkConfigurationToUpdate, + defaultRpcEndpointIndex: 0, + rpcEndpoints: [ + ...networkConfigurationToUpdate.rpcEndpoints, + buildUpdateNetworkCustomRpcEndpointFields({ + failoverUrls: ['https://first.failover.endpoint'], + name: 'Endpoint 2', + url: 'https://rpc.endpoint/2', + }), + buildUpdateNetworkCustomRpcEndpointFields({ + failoverUrls: ['https://second.failover.endpoint'], + name: 'Endpoint 3', + url: 'https://rpc.endpoint/3', + }), + ], + }); + + expect(updatedNetworkConfiguration).toStrictEqual({ + ...networkConfigurationToUpdate, + rpcEndpoints: [ + ...networkConfigurationToUpdate.rpcEndpoints, + { + failoverUrls: ['https://first.failover.endpoint'], + name: 'Endpoint 2', + networkClientId: 'AAAA-AAAA-AAAA-AAAA', + type: RpcEndpointType.Custom, + url: 'https://rpc.endpoint/2', + }, + { + failoverUrls: ['https://second.failover.endpoint'], + name: 'Endpoint 3', + networkClientId: 'BBBB-BBBB-BBBB-BBBB', + type: RpcEndpointType.Custom, + url: 'https://rpc.endpoint/3', + }, + ], + lastUpdatedAt: FAKE_DATE_NOW_MS, }); - expect( - controller.state.networksMetadata[ - controller.state.selectedNetworkClientId - ].EIPS[1559], - ).toBe(true); }, ); }); }); - } - describe(`if the previous provider configuration had a type of "rpc"`, () => { - it('emits networkWillChange', async () => { - await withController( - { - state: { - providerConfig: buildProviderConfig({ - type: NetworkType.rpc, - }), - }, - }, - async ({ controller, messenger }) => { - const fakeProvider = buildFakeProvider(); - const fakeNetworkClient = buildFakeClient(fakeProvider); - mockCreateNetworkClient().mockReturnValue(fakeNetworkClient); - await controller.setProviderType(InfuraNetworkType.goerli); + describe('when some custom RPC endpoints are being removed', () => { + it('destroys and unregisters existing network clients for the RPC endpoints', async () => { + const [rpcEndpoint1, rpcEndpoint2] = [ + buildCustomRpcEndpoint({ + name: 'Endpoint 1', + networkClientId: 'AAAA-AAAA-AAAA-AAAA', + url: 'https://rpc.endpoint/1', + }), + buildCustomRpcEndpoint({ + name: 'Endpoint 2', + networkClientId: 'BBBB-BBBB-BBBB-BBBB', + url: 'https://rpc.endpoint/2', + }), + ]; + const networkConfigurationToUpdate = + buildInfuraNetworkConfiguration(infuraNetworkType, { + rpcEndpoints: [rpcEndpoint1, rpcEndpoint2], + }); - const networkWillChange = waitForPublishedEvents({ - messenger, - eventType: 'NetworkController:networkWillChange', - operation: () => { - // Intentionally not awaited because we're capturing an event - // emitted partway through the operation - controller.rollbackToPreviousProvider(); + await withController( + { + state: { + selectedNetworkClientId: 'BBBB-BBBB-BBBB-BBBB', + networkConfigurationsByChainId: { + [infuraChainId]: networkConfigurationToUpdate, + }, }, - }); + }, + async ({ controller }) => { + const existingNetworkClient = controller.getNetworkClientById( + 'AAAA-AAAA-AAAA-AAAA', + ); + const destroySpy = jest.spyOn(existingNetworkClient, 'destroy'); - await expect(networkWillChange).toBeFulfilled(); - }, - ); - }); + await controller.updateNetwork(infuraChainId, { + ...networkConfigurationToUpdate, + defaultRpcEndpointIndex: 0, + rpcEndpoints: [rpcEndpoint2], + }); - it('emits networkDidChange', async () => { - await withController( - { - state: { - providerConfig: buildProviderConfig({ - type: NetworkType.rpc, - }), + expect(destroySpy).toHaveBeenCalled(); + const networkClientRegistry = + controller.getNetworkClientRegistry(); + expect(networkClientRegistry).not.toHaveProperty( + 'AAAA-AAAA-AAAA-AAAA', + ); }, - }, - async ({ controller, messenger }) => { - const fakeProvider = buildFakeProvider(); - const fakeNetworkClient = buildFakeClient(fakeProvider); - mockCreateNetworkClient().mockReturnValue(fakeNetworkClient); - await controller.setProviderType(InfuraNetworkType.goerli); + ); + }); - const networkDidChange = waitForPublishedEvents({ - messenger, - eventType: 'NetworkController:networkDidChange', - operation: () => { - // Intentionally not awaited because we're capturing an event - // emitted partway through the operation - controller.rollbackToPreviousProvider(); - }, + it('updates the network configuration in state', async () => { + const [rpcEndpoint1, rpcEndpoint2] = [ + buildCustomRpcEndpoint({ + name: 'Endpoint 1', + networkClientId: 'AAAA-AAAA-AAAA-AAAA', + url: 'https://rpc.endpoint/1', + }), + buildCustomRpcEndpoint({ + name: 'Endpoint 2', + networkClientId: 'BBBB-BBBB-BBBB-BBBB', + url: 'https://rpc.endpoint/2', + }), + ]; + const networkConfigurationToUpdate = + buildInfuraNetworkConfiguration(infuraNetworkType, { + rpcEndpoints: [rpcEndpoint1, rpcEndpoint2], }); - await expect(networkDidChange).toBeFulfilled(); - }, - ); - }); - - it('overwrites the the current provider configuration with the previous provider configuration', async () => { - await withController( - { - state: { - providerConfig: buildProviderConfig({ - type: NetworkType.rpc, - rpcUrl: 'https://mock-rpc-url', - chainId: toHex(1337), - nickname: 'network', - ticker: 'TEST', - rpcPrefs: { - blockExplorerUrl: 'https://test-block-explorer.com', + await withController( + { + state: { + selectedNetworkClientId: 'BBBB-BBBB-BBBB-BBBB', + networkConfigurationsByChainId: { + [infuraChainId]: networkConfigurationToUpdate, }, - }), + }, }, - infuraProjectId: 'some-infura-project-id', - }, - async ({ controller }) => { - const fakeProviders = [buildFakeProvider(), buildFakeProvider()]; - const fakeNetworkClients = [ - buildFakeClient(fakeProviders[0]), - buildFakeClient(fakeProviders[1]), - ]; - mockCreateNetworkClient() - .calledWith({ - network: InfuraNetworkType.goerli, - infuraProjectId: 'some-infura-project-id', - type: NetworkClientType.Infura, - chainId: BUILT_IN_NETWORKS[InfuraNetworkType.goerli].chainId, - ticker: BUILT_IN_NETWORKS[InfuraNetworkType.goerli].ticker, - }) - .mockReturnValue(fakeNetworkClients[0]) - .calledWith({ - rpcUrl: 'https://mock-rpc-url', - chainId: toHex(1337), - type: NetworkClientType.Custom, - ticker: 'TEST', - }) - .mockReturnValue(fakeNetworkClients[1]); - await controller.setProviderType('goerli'); - expect(controller.state.providerConfig).toStrictEqual({ - type: 'goerli', - rpcUrl: undefined, - chainId: toHex(5), - ticker: 'GoerliETH', - nickname: undefined, - rpcPrefs: { - blockExplorerUrl: 'https://goerli.etherscan.io', - }, - id: undefined, + async ({ controller }) => { + await controller.updateNetwork(infuraChainId, { + ...networkConfigurationToUpdate, + defaultRpcEndpointIndex: 0, + rpcEndpoints: [rpcEndpoint2], + }); + + expect( + controller.state.networkConfigurationsByChainId[ + infuraChainId + ], + ).toStrictEqual({ + ...networkConfigurationToUpdate, + defaultRpcEndpointIndex: 0, + rpcEndpoints: [rpcEndpoint2], + lastUpdatedAt: FAKE_DATE_NOW_MS, + }); + }, + ); + }); + + it('returns the updated network configuration', async () => { + const [rpcEndpoint1, rpcEndpoint2] = [ + buildCustomRpcEndpoint({ + name: 'Endpoint 1', + networkClientId: 'AAAA-AAAA-AAAA-AAAA', + url: 'https://rpc.endpoint/1', + }), + buildCustomRpcEndpoint({ + name: 'Endpoint 2', + networkClientId: 'BBBB-BBBB-BBBB-BBBB', + url: 'https://rpc.endpoint/2', + }), + ]; + const networkConfigurationToUpdate = + buildInfuraNetworkConfiguration(infuraNetworkType, { + rpcEndpoints: [rpcEndpoint1, rpcEndpoint2], }); - await controller.rollbackToPreviousProvider(); - expect(controller.state.providerConfig).toStrictEqual( - buildProviderConfig({ - type: 'rpc', - rpcUrl: 'https://mock-rpc-url', - chainId: toHex(1337), - nickname: 'network', - ticker: 'TEST', - rpcPrefs: { - blockExplorerUrl: 'https://test-block-explorer.com', + await withController( + { + state: { + selectedNetworkClientId: 'BBBB-BBBB-BBBB-BBBB', + networkConfigurationsByChainId: { + [infuraChainId]: networkConfigurationToUpdate, }, - }), - ); - }, - ); - }); + }, + }, + async ({ controller }) => { + const updatedNetworkConfiguration = + await controller.updateNetwork(infuraChainId, { + ...networkConfigurationToUpdate, + defaultRpcEndpointIndex: 0, + rpcEndpoints: [rpcEndpoint2], + }); - it('resets the network state to "unknown" before updating the provider', async () => { - await withController( - { - state: { - providerConfig: buildProviderConfig({ - type: NetworkType.rpc, - rpcUrl: 'https://mock-rpc-url', - chainId: toHex(1337), - }), + expect(updatedNetworkConfiguration).toStrictEqual({ + ...networkConfigurationToUpdate, + defaultRpcEndpointIndex: 0, + rpcEndpoints: [rpcEndpoint2], + lastUpdatedAt: FAKE_DATE_NOW_MS, + }); }, - infuraProjectId: 'some-infura-project-id', - }, - async ({ controller, messenger }) => { - const fakeProviders = [ - buildFakeProvider([ + ); + }); + + describe('when one is represented by the selected network client (and a replacement is specified)', () => { + describe('if the new replacement RPC endpoint already exists', () => { + it('selects the network client that represents the replacement RPC endpoint', async () => { + const networkConfigurationToUpdate = + buildInfuraNetworkConfiguration(infuraNetworkType, { + rpcEndpoints: [ + buildCustomRpcEndpoint({ + networkClientId: 'AAAA-AAAA-AAAA-AAAA', + url: 'https://test.network/1', + }), + buildCustomRpcEndpoint({ + networkClientId: 'BBBB-BBBB-BBBB-BBBB', + url: 'https://test.network/2', + }), + ], + }); + + await withController( { - request: { - method: 'eth_getBlockByNumber', + state: { + selectedNetworkClientId: 'AAAA-AAAA-AAAA-AAAA', + networkConfigurationsByChainId: { + [infuraChainId]: networkConfigurationToUpdate, + }, }, - response: SUCCESSFUL_ETH_GET_BLOCK_BY_NUMBER_RESPONSE, }, - ]), - buildFakeProvider(), - ]; - const fakeNetworkClients = [ - buildFakeClient(fakeProviders[0]), - buildFakeClient(fakeProviders[1]), - ]; - mockCreateNetworkClient() - .calledWith({ - network: InfuraNetworkType.goerli, - infuraProjectId: 'some-infura-project-id', - type: NetworkClientType.Infura, - chainId: BUILT_IN_NETWORKS[InfuraNetworkType.goerli].chainId, - ticker: BUILT_IN_NETWORKS[InfuraNetworkType.goerli].ticker, - }) - .mockReturnValue(fakeNetworkClients[0]) - .calledWith({ - rpcUrl: 'https://mock-rpc-url', - chainId: toHex(1337), - type: NetworkClientType.Custom, - ticker: 'TEST', - }) - .mockReturnValue(fakeNetworkClients[1]); - await controller.setProviderType('goerli'); - expect( - controller.state.networksMetadata[ - controller.state.selectedNetworkClientId - ].status, - ).toBe('available'); + async ({ controller }) => { + const fakeProviders = [ + buildFakeProvider([ + { + request: { + method: 'test', + }, + response: { + result: 'test response from 1', + }, + }, + ]), + buildFakeProvider([ + { + request: { + method: 'test', + }, + response: { + result: 'test response from 2', + }, + }, + ]), + ]; + const fakeNetworkClients = [ + buildFakeClient(fakeProviders[0]), + buildFakeClient(fakeProviders[1]), + ]; + createNetworkClientMock.mockImplementation( + ({ configuration }) => { + if ( + configuration.type === NetworkClientType.Custom && + configuration.rpcUrl === 'https://test.network/1' + ) { + return fakeNetworkClients[0]; + } else if ( + configuration.type === NetworkClientType.Custom && + configuration.rpcUrl === 'https://test.network/2' + ) { + return fakeNetworkClients[1]; + } + throw new Error( + `Unknown network client configuration ${JSON.stringify( + configuration, + )}`, + ); + }, + ); + await controller.lookupNetwork(); + expect(controller.state.selectedNetworkClientId).toBe( + 'AAAA-AAAA-AAAA-AAAA', + ); + const networkClient1 = + controller.getSelectedNetworkClient(); + assert(networkClient1, 'Network client is somehow unset'); + const result1 = await networkClient1.provider.request({ + method: 'test', + }); + expect(result1).toBe('test response from 1'); - await waitForStateChanges({ - messenger, - propertyPath: [ - 'networksMetadata', - 'https://mock-rpc-url', - 'status', - ], - // We only care about the first state change, because it - // happens before networkDidChange - count: 1, - operation: () => { - // Intentionally not awaited because we want to check state - // while this operation is in-progress - controller.rollbackToPreviousProvider(); - }, - beforeResolving: () => { - expect( - controller.state.networksMetadata[ - controller.state.selectedNetworkClientId - ].status, - ).toBe('unknown'); - }, + await controller.updateNetwork( + infuraChainId, + { + ...networkConfigurationToUpdate, + rpcEndpoints: [ + networkConfigurationToUpdate.rpcEndpoints[1], + ], + }, + { + replacementSelectedRpcEndpointIndex: 0, + }, + ); + expect(controller.state.selectedNetworkClientId).toBe( + 'BBBB-BBBB-BBBB-BBBB', + ); + const networkClient2 = + controller.getSelectedNetworkClient(); + assert(networkClient2, 'Network client is somehow unset'); + const result2 = await networkClient2.provider.request({ + method: 'test', + }); + expect(result2).toBe('test response from 2'); + }, + ); }); - }, - ); - }); - it('initializes a provider pointed to the given RPC URL', async () => { - await withController( - { - state: { - providerConfig: buildProviderConfig({ - type: NetworkType.rpc, - rpcUrl: 'https://mock-rpc-url', - chainId: toHex(1337), - }), - }, - infuraProjectId: 'some-infura-project-id', - }, - async ({ controller }) => { - const fakeProviders = [ - buildFakeProvider(), - buildFakeProvider([ + it('updates selectedNetworkClientId and networkConfigurationsByChainId at the same time', async () => { + const networkConfigurationToUpdate = + buildInfuraNetworkConfiguration(infuraNetworkType, { + rpcEndpoints: [ + buildCustomRpcEndpoint({ + networkClientId: 'AAAA-AAAA-AAAA-AAAA', + url: 'https://test.network/1', + }), + buildCustomRpcEndpoint({ + networkClientId: 'BBBB-BBBB-BBBB-BBBB', + url: 'https://test.network/2', + }), + ], + }); + + await withController( { - request: { - method: 'test', - }, - response: { - result: 'test response', + state: { + selectedNetworkClientId: 'AAAA-AAAA-AAAA-AAAA', + networkConfigurationsByChainId: { + [infuraChainId]: networkConfigurationToUpdate, + }, }, }, - ]), - ]; - const fakeNetworkClients = [ - buildFakeClient(fakeProviders[0]), - buildFakeClient(fakeProviders[1]), - ]; - mockCreateNetworkClient() - .calledWith({ - network: InfuraNetworkType.goerli, - infuraProjectId: 'some-infura-project-id', - type: NetworkClientType.Infura, - chainId: BUILT_IN_NETWORKS[InfuraNetworkType.goerli].chainId, - ticker: BUILT_IN_NETWORKS[InfuraNetworkType.goerli].ticker, - }) - .mockReturnValue(fakeNetworkClients[0]) - .calledWith({ - rpcUrl: 'https://mock-rpc-url', - chainId: toHex(1337), - type: NetworkClientType.Custom, - ticker: 'TEST', - }) - .mockReturnValue(fakeNetworkClients[1]); - await controller.setProviderType('goerli'); + async ({ controller, messenger }) => { + const fakeProviders = [ + buildFakeProvider([ + { + request: { + method: 'test', + }, + response: { + result: 'test response from 1', + }, + }, + ]), + buildFakeProvider([ + { + request: { + method: 'test', + }, + response: { + result: 'test response from 2', + }, + }, + ]), + ]; + const fakeNetworkClients = [ + buildFakeClient(fakeProviders[0]), + buildFakeClient(fakeProviders[1]), + ]; + createNetworkClientMock.mockImplementation( + ({ configuration }) => { + if ( + configuration.type === NetworkClientType.Custom && + configuration.rpcUrl === 'https://test.network/1' + ) { + return fakeNetworkClients[0]; + } else if ( + configuration.type === NetworkClientType.Custom && + configuration.rpcUrl === 'https://test.network/2' + ) { + return fakeNetworkClients[1]; + } + throw new Error( + `Unknown network client configuration ${JSON.stringify( + configuration, + )}`, + ); + }, + ); + await controller.lookupNetwork(); - await controller.rollbackToPreviousProvider(); + const promiseForStateChanges = waitForStateChanges({ + messenger, + count: 1, + }); - const { provider } = controller.getProviderAndBlockTracker(); - assert(provider, 'Provider is somehow unset'); - const promisifiedSendAsync = promisify(provider.sendAsync).bind( - provider, - ); - const response = await promisifiedSendAsync({ - id: '1', - jsonrpc: '2.0', - method: 'test', + await controller.updateNetwork( + infuraChainId, + { + ...networkConfigurationToUpdate, + rpcEndpoints: [ + networkConfigurationToUpdate.rpcEndpoints[1], + ], + }, + { + replacementSelectedRpcEndpointIndex: 0, + }, + ); + const stateChanges = await promiseForStateChanges; + expect(stateChanges).toStrictEqual([ + [ + expect.any(Object), + expect.arrayContaining([ + expect.objectContaining({ + op: 'replace', + path: ['selectedNetworkClientId'], + value: 'BBBB-BBBB-BBBB-BBBB', + }), + expect.objectContaining({ + op: 'replace', + path: [ + 'networkConfigurationsByChainId', + infuraChainId, + ], + }), + ]), + ], + ]); + }, + ); }); - expect(response.result).toBe('test response'); - }, - ); - }); - - it('replaces the provider object underlying the provider proxy without creating a new instance of the proxy itself', async () => { - await withController( - { - state: { - providerConfig: buildProviderConfig({ - type: NetworkType.rpc, - rpcUrl: 'https://mock-rpc-url', - chainId: toHex(1337), - }), - }, - infuraProjectId: 'some-infura-project-id', - }, - async ({ controller }) => { - const fakeProviders = [buildFakeProvider(), buildFakeProvider()]; - const fakeNetworkClients = [ - buildFakeClient(fakeProviders[0]), - buildFakeClient(fakeProviders[1]), - ]; - mockCreateNetworkClient() - .calledWith({ - network: InfuraNetworkType.goerli, - infuraProjectId: 'some-infura-project-id', - type: NetworkClientType.Infura, - chainId: BUILT_IN_NETWORKS[InfuraNetworkType.goerli].chainId, - ticker: BUILT_IN_NETWORKS[InfuraNetworkType.goerli].ticker, - }) - .mockReturnValue(fakeNetworkClients[0]) - .calledWith({ - rpcUrl: 'https://mock-rpc-url', - chainId: toHex(1337), - type: NetworkClientType.Custom, - ticker: 'TEST', - }) - .mockReturnValue(fakeNetworkClients[1]); - await controller.setProviderType('goerli'); - const { provider: providerBefore } = - controller.getProviderAndBlockTracker(); - - await controller.rollbackToPreviousProvider(); + }); - const { provider: providerAfter } = - controller.getProviderAndBlockTracker(); - expect(providerBefore).toBe(providerAfter); - }, - ); - }); + describe('if the replacement RPC endpoint is being added', () => { + it('selects the network client that represents the replacement RPC endpoint', async () => { + uuidV4Mock.mockReturnValueOnce('CCCC-CCCC-CCCC-CCCC'); + const networkConfigurationToUpdate = + buildInfuraNetworkConfiguration(infuraNetworkType, { + rpcEndpoints: [ + buildCustomRpcEndpoint({ + networkClientId: 'AAAA-AAAA-AAAA-AAAA', + url: 'https://test.network/1', + }), + buildCustomRpcEndpoint({ + networkClientId: 'BBBB-BBBB-BBBB-BBBB', + url: 'https://test.network/2', + }), + ], + }); - it('emits infuraIsUnblocked', async () => { - await withController( - { - state: { - providerConfig: buildProviderConfig({ - type: NetworkType.rpc, - rpcUrl: 'https://mock-rpc-url', - chainId: toHex(1337), - }), - }, - infuraProjectId: 'some-infura-project-id', - }, - async ({ controller, messenger }) => { - const fakeProviders = [buildFakeProvider(), buildFakeProvider()]; - const fakeNetworkClients = [ - buildFakeClient(fakeProviders[0]), - buildFakeClient(fakeProviders[1]), - ]; - mockCreateNetworkClient() - .calledWith({ - network: InfuraNetworkType.goerli, - infuraProjectId: 'some-infura-project-id', - chainId: BUILT_IN_NETWORKS[InfuraNetworkType.goerli].chainId, - ticker: BUILT_IN_NETWORKS[InfuraNetworkType.goerli].ticker, - type: NetworkClientType.Infura, - }) - .mockReturnValue(fakeNetworkClients[0]) - .calledWith({ - rpcUrl: 'https://mock-rpc-url', - chainId: toHex(1337), - type: NetworkClientType.Custom, - ticker: 'TEST', - }) - .mockReturnValue(fakeNetworkClients[1]); - await controller.setProviderType('goerli'); + await withController( + { + state: { + selectedNetworkClientId: 'AAAA-AAAA-AAAA-AAAA', + networkConfigurationsByChainId: { + [infuraChainId]: networkConfigurationToUpdate, + }, + }, + }, + async ({ controller }) => { + const fakeProviders = [ + buildFakeProvider([ + { + request: { + method: 'test', + }, + response: { + result: 'test response from 1', + }, + }, + ]), + buildFakeProvider([ + { + request: { + method: 'test', + }, + response: { + result: 'test response from 2', + }, + }, + ]), + buildFakeProvider([ + { + request: { + method: 'test', + }, + response: { + result: 'test response from 3', + }, + }, + ]), + ]; + const fakeNetworkClients = [ + buildFakeClient(fakeProviders[0]), + buildFakeClient(fakeProviders[1]), + buildFakeClient(fakeProviders[2]), + ]; + createNetworkClientMock.mockImplementation( + ({ configuration }) => { + if ( + configuration.type === NetworkClientType.Custom && + configuration.rpcUrl === 'https://test.network/1' + ) { + return fakeNetworkClients[0]; + } else if ( + configuration.type === NetworkClientType.Custom && + configuration.rpcUrl === 'https://test.network/2' + ) { + return fakeNetworkClients[1]; + } else if ( + configuration.type === NetworkClientType.Custom && + configuration.rpcUrl === 'https://test.network/3' + ) { + return fakeNetworkClients[2]; + } + throw new Error( + `Unknown network client configuration ${JSON.stringify( + configuration, + )}`, + ); + }, + ); + await controller.lookupNetwork(); + expect(controller.state.selectedNetworkClientId).toBe( + 'AAAA-AAAA-AAAA-AAAA', + ); + const networkClient1 = + controller.getSelectedNetworkClient(); + assert(networkClient1, 'Network client is somehow unset'); + const result1 = await networkClient1.provider.request({ + method: 'test', + }); + expect(result1).toBe('test response from 1'); - const promiseForInfuraIsUnblocked = waitForPublishedEvents({ - messenger, - eventType: 'NetworkController:infuraIsUnblocked', - operation: async () => { - await controller.rollbackToPreviousProvider(); - }, + await controller.updateNetwork( + infuraChainId, + { + ...networkConfigurationToUpdate, + rpcEndpoints: [ + buildUpdateNetworkCustomRpcEndpointFields({ + url: 'https://test.network/3', + }), + networkConfigurationToUpdate.rpcEndpoints[1], + ], + }, + { + replacementSelectedRpcEndpointIndex: 0, + }, + ); + expect(controller.state.selectedNetworkClientId).toBe( + 'CCCC-CCCC-CCCC-CCCC', + ); + const networkClient2 = + controller.getSelectedNetworkClient(); + assert(networkClient2, 'Network client is somehow unset'); + const result2 = await networkClient2.provider.request({ + method: 'test', + }); + expect(result2).toBe('test response from 3'); + }, + ); }); - await expect(promiseForInfuraIsUnblocked).toBeFulfilled(); - }, - ); - }); + it('updates selectedNetworkClientId and networkConfigurationsByChainId at the same time', async () => { + uuidV4Mock.mockReturnValueOnce('CCCC-CCCC-CCCC-CCCC'); + const networkConfigurationToUpdate = + buildInfuraNetworkConfiguration(infuraNetworkType, { + rpcEndpoints: [ + buildCustomRpcEndpoint({ + networkClientId: 'AAAA-AAAA-AAAA-AAAA', + url: 'https://test.network/1', + }), + buildCustomRpcEndpoint({ + networkClientId: 'BBBB-BBBB-BBBB-BBBB', + url: 'https://test.network/2', + }), + ], + }); - it('checks the status of the previous network again and updates state accordingly', async () => { - await withController( - { - state: { - providerConfig: buildProviderConfig({ - type: NetworkType.rpc, - rpcUrl: 'https://mock-rpc-url', - chainId: toHex(1337), - }), - }, - infuraProjectId: 'some-infura-project-id', - }, - async ({ controller }) => { - const fakeProviders = [ - buildFakeProvider([ - { - request: { - method: 'eth_getBlockByNumber', - }, - error: rpcErrors.methodNotFound(), - }, - ]), - buildFakeProvider([ + await withController( { - request: { - method: 'eth_getBlockByNumber', + state: { + selectedNetworkClientId: 'AAAA-AAAA-AAAA-AAAA', + networkConfigurationsByChainId: { + [infuraChainId]: networkConfigurationToUpdate, + }, }, - response: SUCCESSFUL_ETH_GET_BLOCK_BY_NUMBER_RESPONSE, }, - ]), - ]; - const fakeNetworkClients = [ - buildFakeClient(fakeProviders[0]), - buildFakeClient(fakeProviders[1]), - ]; - mockCreateNetworkClient() - .calledWith({ - network: InfuraNetworkType.goerli, - infuraProjectId: 'some-infura-project-id', - chainId: BUILT_IN_NETWORKS[InfuraNetworkType.goerli].chainId, - ticker: BUILT_IN_NETWORKS[InfuraNetworkType.goerli].ticker, - type: NetworkClientType.Infura, - }) - .mockReturnValue(fakeNetworkClients[0]) - .calledWith({ - rpcUrl: 'https://mock-rpc-url', - chainId: toHex(1337), - type: NetworkClientType.Custom, - ticker: 'TEST', - }) - .mockReturnValue(fakeNetworkClients[1]); - await controller.setProviderType('goerli'); - expect( - controller.state.networksMetadata[ - controller.state.selectedNetworkClientId - ].status, - ).toBe('unavailable'); + async ({ controller, messenger }) => { + const fakeProviders = [ + buildFakeProvider([ + { + request: { + method: 'test', + }, + response: { + result: 'test response from 1', + }, + }, + ]), + buildFakeProvider([ + { + request: { + method: 'test', + }, + response: { + result: 'test response from 2', + }, + }, + ]), + buildFakeProvider([ + { + request: { + method: 'test', + }, + response: { + result: 'test response from 3', + }, + }, + ]), + ]; + const fakeNetworkClients = [ + buildFakeClient(fakeProviders[0]), + buildFakeClient(fakeProviders[1]), + buildFakeClient(fakeProviders[2]), + ]; + createNetworkClientMock.mockImplementation( + ({ configuration }) => { + if ( + configuration.type === NetworkClientType.Custom && + configuration.rpcUrl === 'https://test.network/1' + ) { + return fakeNetworkClients[0]; + } else if ( + configuration.type === NetworkClientType.Custom && + configuration.rpcUrl === 'https://test.network/2' + ) { + return fakeNetworkClients[1]; + } else if ( + configuration.type === NetworkClientType.Custom && + configuration.rpcUrl === 'https://test.network/3' + ) { + return fakeNetworkClients[2]; + } + throw new Error( + `Unknown network client configuration ${JSON.stringify( + configuration, + )}`, + ); + }, + ); + await controller.lookupNetwork(); - await controller.rollbackToPreviousProvider(); - expect( - controller.state.networksMetadata[ - controller.state.selectedNetworkClientId - ].status, - ).toBe('available'); - }, - ); + const promiseForStateChanges = waitForStateChanges({ + messenger, + count: 1, + }); + + await controller.updateNetwork( + infuraChainId, + { + ...networkConfigurationToUpdate, + rpcEndpoints: [ + buildUpdateNetworkCustomRpcEndpointFields({ + url: 'https://test.network/3', + }), + networkConfigurationToUpdate.rpcEndpoints[1], + ], + }, + { + replacementSelectedRpcEndpointIndex: 0, + }, + ); + const stateChanges = await promiseForStateChanges; + expect(stateChanges).toStrictEqual([ + [ + expect.any(Object), + expect.arrayContaining([ + expect.objectContaining({ + op: 'replace', + path: ['selectedNetworkClientId'], + value: 'CCCC-CCCC-CCCC-CCCC', + }), + expect.objectContaining({ + op: 'replace', + path: [ + 'networkConfigurationsByChainId', + infuraChainId, + ], + }), + ]), + ], + ]); + }, + ); + }); + }); + }); }); - it('checks whether the previous network supports EIP-1559 again and updates state accordingly', async () => { - await withController( - { - state: { - providerConfig: buildProviderConfig({ - type: NetworkType.rpc, - rpcUrl: 'https://mock-rpc-url', - chainId: toHex(1337), - }), - }, - infuraProjectId: 'some-infura-project-id', - }, - async ({ controller }) => { - const fakeProviders = [ - buildFakeProvider([ - { - request: { - method: 'eth_getBlockByNumber', + describe('when the URL of an RPC endpoint is changed (using networkClientId as identification)', () => { + it('destroys and unregisters the network client for the previous version of the RPC endpoint', async () => { + uuidV4Mock.mockReturnValueOnce('BBBB-BBBB-BBBB-BBBB'); + const customRpcEndpoint = buildCustomRpcEndpoint({ + name: 'Endpoint 1', + networkClientId: 'AAAA-AAAA-AAAA-AAAA', + url: 'https://rpc.endpoint', + }); + const networkConfigurationToUpdate = + buildInfuraNetworkConfiguration(infuraNetworkType, { + rpcEndpoints: [customRpcEndpoint], + }); + + await withController( + { + state: { + networkConfigurationsByChainId: { + [infuraChainId]: networkConfigurationToUpdate, + '0x9999': buildCustomNetworkConfiguration({ + chainId: '0x9999', + nativeCurrency: 'TEST-9999', + rpcEndpoints: [ + buildCustomRpcEndpoint({ + networkClientId: 'ZZZZ-ZZZZ-ZZZZ-ZZZZ', + url: 'https://selected.endpoint', + }), + ], + }), + }, + selectedNetworkClientId: 'ZZZZ-ZZZZ-ZZZZ-ZZZZ', + }, + }, + async ({ controller }) => { + createNetworkClientMock.mockImplementation( + ({ configuration }) => { + if ( + configuration.chainId === infuraChainId && + configuration.type === NetworkClientType.Custom && + configuration.rpcUrl === 'https://some.other.url' + ) { + return buildFakeClient(); + } + throw new Error( + `Unknown network client configuration ${JSON.stringify( + configuration, + )}`, + ); + }, + ); + const existingNetworkClient = controller.getNetworkClientById( + 'AAAA-AAAA-AAAA-AAAA', + ); + const destroySpy = jest.spyOn(existingNetworkClient, 'destroy'); + + await controller.updateNetwork(infuraChainId, { + ...networkConfigurationToUpdate, + rpcEndpoints: [ + { + ...customRpcEndpoint, + url: 'https://some.other.url', }, - response: { - result: PRE_1559_BLOCK, + ], + }); + + expect(destroySpy).toHaveBeenCalled(); + const networkClientRegistry = + controller.getNetworkClientRegistry(); + expect(networkClientRegistry).not.toHaveProperty( + 'AAAA-AAAA-AAAA-AAAA', + ); + }, + ); + }); + + it('creates and registers a network client for the new version of the RPC endpoint', async () => { + uuidV4Mock.mockReturnValueOnce('BBBB-BBBB-BBBB-BBBB'); + const createAutoManagedNetworkClientSpy = jest.spyOn( + createAutoManagedNetworkClientModule, + 'createAutoManagedNetworkClient', + ); + const customRpcEndpoint = buildCustomRpcEndpoint({ + name: 'Endpoint 1', + networkClientId: 'AAAA-AAAA-AAAA-AAAA', + url: 'https://rpc.endpoint', + }); + const networkConfigurationToUpdate = + buildInfuraNetworkConfiguration(infuraNetworkType, { + rpcEndpoints: [customRpcEndpoint], + }); + const getRpcServiceOptions = (): Omit< + RpcServiceOptions, + 'failoverService' | 'endpointUrl' + > => ({ + btoa, + fetch, + isOffline: (): boolean => false, + fetchOptions: { + headers: { + 'X-Foo': 'Bar', + }, + }, + policyOptions: { + maxRetries: 2, + maxConsecutiveFailures: 10, + }, + }); + const getBlockTrackerOptions = (): PollingBlockTrackerOptions => ({ + pollingInterval: 2000, + }); + + await withController( + { + state: { + networkConfigurationsByChainId: { + [infuraChainId]: networkConfigurationToUpdate, + '0x9999': buildCustomNetworkConfiguration({ + chainId: '0x9999', + nativeCurrency: 'TEST-9999', + rpcEndpoints: [ + buildCustomRpcEndpoint({ + networkClientId: 'ZZZZ-ZZZZ-ZZZZ-ZZZZ', + url: 'https://selected.endpoint', + }), + ], + }), + }, + selectedNetworkClientId: 'ZZZZ-ZZZZ-ZZZZ-ZZZZ', + }, + getRpcServiceOptions, + getBlockTrackerOptions, + rpcFailoverMode: 'enabled', + }, + async ({ controller, networkControllerMessenger }) => { + createNetworkClientMock.mockReturnValue(buildFakeClient()); + + await controller.updateNetwork(infuraChainId, { + ...networkConfigurationToUpdate, + rpcEndpoints: [ + { + ...customRpcEndpoint, + url: 'https://some.other.url', + failoverUrls: ['https://failover.endpoint'], }, + ], + }); + + expect( + createAutoManagedNetworkClientSpy, + ).toHaveBeenNthCalledWith(3, { + networkClientId: 'BBBB-BBBB-BBBB-BBBB', + networkClientConfiguration: { + chainId: infuraChainId, + failoverRpcUrls: ['https://failover.endpoint'], + rpcUrl: 'https://some.other.url', + ticker: infuraNativeTokenName, + type: NetworkClientType.Custom, }, - ]), - buildFakeProvider([ - { - request: { - method: 'eth_getBlockByNumber', + getRpcServiceOptions, + getBlockTrackerOptions, + messenger: networkControllerMessenger, + rpcFailoverMode: 'enabled', + }); + const networkConfigurationsByNetworkClientId = + getNetworkConfigurationsByNetworkClientId( + controller.getNetworkClientRegistry(), + ); + expect( + networkConfigurationsByNetworkClientId['BBBB-BBBB-BBBB-BBBB'], + ).toStrictEqual({ + chainId: infuraChainId, + failoverRpcUrls: ['https://failover.endpoint'], + rpcUrl: 'https://some.other.url', + ticker: infuraNativeTokenName, + type: NetworkClientType.Custom, + }); + }, + ); + }); + + it('updates the network configuration in state with a new network client ID for the RPC endpoint', async () => { + uuidV4Mock.mockReturnValueOnce('BBBB-BBBB-BBBB-BBBB'); + const customRpcEndpoint = buildCustomRpcEndpoint({ + name: 'Endpoint 1', + networkClientId: 'AAAA-AAAA-AAAA-AAAA', + url: 'https://rpc.endpoint', + }); + const networkConfigurationToUpdate = + buildInfuraNetworkConfiguration(infuraNetworkType, { + rpcEndpoints: [customRpcEndpoint], + }); + + await withController( + { + state: { + networkConfigurationsByChainId: { + [infuraChainId]: networkConfigurationToUpdate, + '0x9999': buildCustomNetworkConfiguration({ + chainId: '0x9999', + nativeCurrency: 'TEST-9999', + rpcEndpoints: [ + buildCustomRpcEndpoint({ + networkClientId: 'ZZZZ-ZZZZ-ZZZZ-ZZZZ', + url: 'https://selected.endpoint', + }), + ], + }), + }, + selectedNetworkClientId: 'ZZZZ-ZZZZ-ZZZZ-ZZZZ', + }, + }, + async ({ controller }) => { + createNetworkClientMock.mockReturnValue(buildFakeClient()); + + await controller.updateNetwork(infuraChainId, { + ...networkConfigurationToUpdate, + rpcEndpoints: [ + { + ...customRpcEndpoint, + url: 'https://some.other.url', }, - response: { - result: POST_1559_BLOCK, + ], + }); + + expect( + controller.state.networkConfigurationsByChainId[ + infuraChainId + ], + ).toStrictEqual({ + ...networkConfigurationToUpdate, + rpcEndpoints: [ + { + ...customRpcEndpoint, + networkClientId: 'BBBB-BBBB-BBBB-BBBB', + url: 'https://some.other.url', }, + ], + lastUpdatedAt: FAKE_DATE_NOW_MS, + }); + }, + ); + }); + + it('returns the updated network configuration', async () => { + uuidV4Mock.mockReturnValueOnce('BBBB-BBBB-BBBB-BBBB'); + const customRpcEndpoint = buildCustomRpcEndpoint({ + name: 'Endpoint 1', + networkClientId: 'AAAA-AAAA-AAAA-AAAA', + url: 'https://rpc.endpoint', + }); + const networkConfigurationToUpdate = + buildInfuraNetworkConfiguration(infuraNetworkType, { + rpcEndpoints: [customRpcEndpoint], + }); + + await withController( + { + state: { + networkConfigurationsByChainId: { + [infuraChainId]: networkConfigurationToUpdate, + '0x9999': buildCustomNetworkConfiguration({ + chainId: '0x9999', + nativeCurrency: 'TEST-9999', + rpcEndpoints: [ + buildCustomRpcEndpoint({ + networkClientId: 'ZZZZ-ZZZZ-ZZZZ-ZZZZ', + url: 'https://selected.endpoint', + }), + ], + }), }, - ]), - ]; - const fakeNetworkClients = [ - buildFakeClient(fakeProviders[0]), - buildFakeClient(fakeProviders[1]), - ]; - mockCreateNetworkClient() - .calledWith({ - network: InfuraNetworkType.goerli, - infuraProjectId: 'some-infura-project-id', - chainId: BUILT_IN_NETWORKS[InfuraNetworkType.goerli].chainId, - ticker: BUILT_IN_NETWORKS[InfuraNetworkType.goerli].ticker, - type: NetworkClientType.Infura, - }) - .mockReturnValue(fakeNetworkClients[0]) - .calledWith({ - rpcUrl: 'https://mock-rpc-url', - chainId: toHex(1337), - type: NetworkClientType.Custom, - ticker: 'TEST', - }) - .mockReturnValue(fakeNetworkClients[1]); - await controller.setProviderType('goerli'); - expect( - controller.state.networksMetadata[ - controller.state.selectedNetworkClientId - ].EIPS[1559], - ).toBe(false); + selectedNetworkClientId: 'ZZZZ-ZZZZ-ZZZZ-ZZZZ', + }, + }, + async ({ controller }) => { + createNetworkClientMock.mockReturnValue(buildFakeClient()); - await controller.rollbackToPreviousProvider(); - expect( - controller.state.networksMetadata[ - controller.state.selectedNetworkClientId - ].EIPS[1559], - ).toBe(true); - }, - ); + const updatedNetworkConfiguration = + await controller.updateNetwork(infuraChainId, { + ...networkConfigurationToUpdate, + rpcEndpoints: [ + { + ...customRpcEndpoint, + url: 'https://some.other.url', + }, + ], + }); + + expect(updatedNetworkConfiguration).toStrictEqual({ + ...networkConfigurationToUpdate, + rpcEndpoints: [ + { + ...customRpcEndpoint, + networkClientId: 'BBBB-BBBB-BBBB-BBBB', + url: 'https://some.other.url', + }, + ], + lastUpdatedAt: FAKE_DATE_NOW_MS, + }); + }, + ); + }); + + describe('if the previous version of the RPC endpoint was represented by the selected network client', () => { + it('invisibly selects the network client for the new RPC endpoint', async () => { + uuidV4Mock.mockReturnValueOnce('BBBB-BBBB-BBBB-BBBB'); + const networkConfigurationToUpdate = + buildInfuraNetworkConfiguration(infuraNetworkType, { + rpcEndpoints: [ + buildCustomRpcEndpoint({ + name: 'Endpoint 1', + networkClientId: 'AAAA-AAAA-AAAA-AAAA', + url: 'https://rpc.endpoint', + }), + ], + }); + + await withController( + { + state: { + selectedNetworkClientId: 'AAAA-AAAA-AAAA-AAAA', + networkConfigurationsByChainId: { + [infuraChainId]: networkConfigurationToUpdate, + }, + }, + }, + async ({ controller }) => { + const fakeProviders = [ + buildFakeProvider([ + { + request: { + method: 'test', + }, + response: { + result: 'test response from 1', + }, + }, + ]), + buildFakeProvider([ + { + request: { + method: 'test', + }, + response: { + result: 'test response from 2', + }, + }, + ]), + ]; + const fakeNetworkClients = [ + buildFakeClient(fakeProviders[0]), + buildFakeClient(fakeProviders[1]), + ]; + createNetworkClientMock.mockImplementation( + ({ configuration }) => { + if ( + configuration.type === NetworkClientType.Custom && + configuration.rpcUrl === 'https://rpc.endpoint' + ) { + return fakeNetworkClients[0]; + } else if ( + configuration.type === NetworkClientType.Custom && + configuration.rpcUrl === 'https://some.other.url' + ) { + return fakeNetworkClients[1]; + } + throw new Error( + `Unknown network client configuration ${JSON.stringify( + configuration, + )}`, + ); + }, + ); + await controller.lookupNetwork(); + expect(controller.state.selectedNetworkClientId).toBe( + 'AAAA-AAAA-AAAA-AAAA', + ); + const networkClient1 = controller.getSelectedNetworkClient(); + assert(networkClient1, 'Network client is somehow unset'); + const result1 = await networkClient1.provider.request({ + method: 'test', + }); + expect(result1).toBe('test response from 1'); + + await controller.updateNetwork(infuraChainId, { + ...networkConfigurationToUpdate, + rpcEndpoints: [ + buildCustomRpcEndpoint({ + name: 'Endpoint 1', + networkClientId: 'AAAA-AAAA-AAAA-AAAA', + url: 'https://some.other.url', + }), + ], + }); + expect(controller.state.selectedNetworkClientId).toBe( + 'BBBB-BBBB-BBBB-BBBB', + ); + const networkClient2 = controller.getSelectedNetworkClient(); + assert(networkClient2, 'Network client is somehow unset'); + const result2 = await networkClient2.provider.request({ + method: 'test', + }); + expect(result2).toBe('test response from 2'); + }, + ); + }); + + it('updates selectedNetworkClientId and networkConfigurationsByChainId at the same time', async () => { + uuidV4Mock.mockReturnValueOnce('BBBB-BBBB-BBBB-BBBB'); + const networkConfigurationToUpdate = + buildInfuraNetworkConfiguration(infuraNetworkType, { + rpcEndpoints: [ + buildCustomRpcEndpoint({ + name: 'Endpoint 1', + networkClientId: 'AAAA-AAAA-AAAA-AAAA', + url: 'https://rpc.endpoint', + }), + ], + }); + + await withController( + { + state: { + selectedNetworkClientId: 'AAAA-AAAA-AAAA-AAAA', + networkConfigurationsByChainId: { + [infuraChainId]: networkConfigurationToUpdate, + }, + }, + }, + async ({ controller, messenger }) => { + const fakeProviders = [ + buildFakeProvider([ + { + request: { + method: 'test', + }, + response: { + result: 'test response from 1', + }, + }, + ]), + buildFakeProvider([ + { + request: { + method: 'test', + }, + response: { + result: 'test response from 2', + }, + }, + ]), + ]; + const fakeNetworkClients = [ + buildFakeClient(fakeProviders[0]), + buildFakeClient(fakeProviders[1]), + ]; + createNetworkClientMock.mockImplementation( + ({ configuration }) => { + if ( + configuration.type === NetworkClientType.Custom && + configuration.rpcUrl === 'https://rpc.endpoint' + ) { + return fakeNetworkClients[0]; + } else if ( + configuration.type === NetworkClientType.Custom && + configuration.rpcUrl === 'https://some.other.url' + ) { + return fakeNetworkClients[1]; + } + throw new Error( + `Unknown network client configuration ${JSON.stringify( + configuration, + )}`, + ); + }, + ); + await controller.lookupNetwork(); + + const promiseForStateChanges = waitForStateChanges({ + messenger, + count: 1, + }); + + await controller.updateNetwork(infuraChainId, { + ...networkConfigurationToUpdate, + rpcEndpoints: [ + buildCustomRpcEndpoint({ + name: 'Endpoint 1', + networkClientId: 'AAAA-AAAA-AAAA-AAAA', + url: 'https://some.other.url', + }), + ], + }); + const stateChanges = await promiseForStateChanges; + expect(stateChanges).toStrictEqual([ + [ + expect.any(Object), + expect.arrayContaining([ + expect.objectContaining({ + op: 'replace', + path: ['selectedNetworkClientId'], + value: 'BBBB-BBBB-BBBB-BBBB', + }), + expect.objectContaining({ + op: 'replace', + path: [ + 'networkConfigurationsByChainId', + infuraChainId, + ], + }), + ]), + ], + ]); + }, + ); + }); + }); }); - }); - }); + + describe('when all of the RPC endpoints are simply being shuffled', () => { + it('does not touch the network client registry', async () => { + const [rpcEndpoint1, rpcEndpoint2, rpcEndpoint3] = [ + buildInfuraRpcEndpoint(infuraNetworkType), + buildCustomRpcEndpoint({ + name: 'Endpoint 1', + networkClientId: 'AAAA-AAAA-AAAA-AAAA', + url: 'https://rpc.endpoint/1', + }), + buildCustomRpcEndpoint({ + name: 'Endpoint 2', + networkClientId: 'BBBB-BBBB-BBBB-BBBB', + url: 'https://rpc.endpoint/2', + }), + ]; + const networkConfigurationToUpdate = + buildInfuraNetworkConfiguration(infuraNetworkType, { + rpcEndpoints: [rpcEndpoint1, rpcEndpoint2, rpcEndpoint3], + }); + + await withController( + { + state: { + networkConfigurationsByChainId: { + [infuraChainId]: networkConfigurationToUpdate, + '0x9999': buildCustomNetworkConfiguration({ + chainId: '0x9999', + nativeCurrency: 'TEST-9999', + rpcEndpoints: [ + buildCustomRpcEndpoint({ + networkClientId: 'ZZZZ-ZZZZ-ZZZZ-ZZZZ', + url: 'https://selected.endpoint', + }), + ], + }), + }, + selectedNetworkClientId: 'ZZZZ-ZZZZ-ZZZZ-ZZZZ', + }, + }, + async ({ controller }) => { + mockCreateNetworkClient().mockReturnValue(buildFakeClient()); + const networkClientRegistry = + controller.getNetworkClientRegistry(); + + await controller.updateNetwork(infuraChainId, { + ...networkConfigurationToUpdate, + rpcEndpoints: [rpcEndpoint3, rpcEndpoint1, rpcEndpoint2], + }); + + expect(controller.getNetworkClientRegistry()).toStrictEqual( + networkClientRegistry, + ); + }, + ); + }); + + it('updates the network configuration in state with the new order of RPC endpoints', async () => { + const [rpcEndpoint1, rpcEndpoint2, rpcEndpoint3] = [ + buildInfuraRpcEndpoint(infuraNetworkType), + buildCustomRpcEndpoint({ + name: 'Endpoint 1', + networkClientId: 'AAAA-AAAA-AAAA-AAAA', + url: 'https://rpc.endpoint/1', + }), + buildCustomRpcEndpoint({ + name: 'Endpoint 2', + networkClientId: 'BBBB-BBBB-BBBB-BBBB', + url: 'https://rpc.endpoint/2', + }), + ]; + const networkConfigurationToUpdate = + buildInfuraNetworkConfiguration(infuraNetworkType, { + rpcEndpoints: [rpcEndpoint1, rpcEndpoint2, rpcEndpoint3], + }); + + await withController( + { + state: { + networkConfigurationsByChainId: { + [infuraChainId]: networkConfigurationToUpdate, + '0x9999': buildCustomNetworkConfiguration({ + chainId: '0x9999', + nativeCurrency: 'TEST-9999', + rpcEndpoints: [ + buildCustomRpcEndpoint({ + networkClientId: 'ZZZZ-ZZZZ-ZZZZ-ZZZZ', + url: 'https://selected.endpoint', + }), + ], + }), + }, + selectedNetworkClientId: 'ZZZZ-ZZZZ-ZZZZ-ZZZZ', + }, + }, + async ({ controller }) => { + await controller.updateNetwork(infuraChainId, { + ...networkConfigurationToUpdate, + rpcEndpoints: [rpcEndpoint3, rpcEndpoint1, rpcEndpoint2], + }); + + expect( + controller.state.networkConfigurationsByChainId[ + infuraChainId + ], + ).toStrictEqual({ + ...networkConfigurationToUpdate, + rpcEndpoints: [rpcEndpoint3, rpcEndpoint1, rpcEndpoint2], + lastUpdatedAt: FAKE_DATE_NOW_MS, + }); + }, + ); + }); + + it('returns the network configuration with the new order of RPC endpoints', async () => { + const [rpcEndpoint1, rpcEndpoint2, rpcEndpoint3] = [ + buildInfuraRpcEndpoint(infuraNetworkType), + buildCustomRpcEndpoint({ + name: 'Endpoint 1', + networkClientId: 'AAAA-AAAA-AAAA-AAAA', + url: 'https://rpc.endpoint/1', + }), + buildCustomRpcEndpoint({ + name: 'Endpoint 2', + networkClientId: 'BBBB-BBBB-BBBB-BBBB', + url: 'https://rpc.endpoint/2', + }), + ]; + const networkConfigurationToUpdate = + buildInfuraNetworkConfiguration(infuraNetworkType, { + rpcEndpoints: [rpcEndpoint1, rpcEndpoint2, rpcEndpoint3], + }); + + await withController( + { + state: { + networkConfigurationsByChainId: { + [infuraChainId]: networkConfigurationToUpdate, + '0x9999': buildCustomNetworkConfiguration({ + chainId: '0x9999', + nativeCurrency: 'TEST-9999', + rpcEndpoints: [ + buildCustomRpcEndpoint({ + networkClientId: 'ZZZZ-ZZZZ-ZZZZ-ZZZZ', + url: 'https://selected.endpoint', + }), + ], + }), + }, + selectedNetworkClientId: 'ZZZZ-ZZZZ-ZZZZ-ZZZZ', + }, + }, + async ({ controller }) => { + const updatedNetworkConfiguration = + await controller.updateNetwork(infuraChainId, { + ...networkConfigurationToUpdate, + rpcEndpoints: [rpcEndpoint3, rpcEndpoint1, rpcEndpoint2], + }); + + expect(updatedNetworkConfiguration).toStrictEqual({ + ...networkConfigurationToUpdate, + rpcEndpoints: [rpcEndpoint3, rpcEndpoint1, rpcEndpoint2], + lastUpdatedAt: FAKE_DATE_NOW_MS, + }); + }, + ); + }); + }); + + describe('when the networkClientId of some custom RPC endpoints are being cleared', () => { + it('does not touch the network client registry', async () => { + const [rpcEndpoint1, rpcEndpoint2] = [ + buildCustomRpcEndpoint({ + name: 'Endpoint 1', + networkClientId: 'AAAA-AAAA-AAAA-AAAA', + url: 'https://rpc.endpoint/1', + }), + buildCustomRpcEndpoint({ + name: 'Endpoint 2', + networkClientId: 'BBBB-BBBB-BBBB-BBBB', + url: 'https://rpc.endpoint/2', + }), + ]; + const networkConfigurationToUpdate = + buildInfuraNetworkConfiguration(infuraNetworkType, { + rpcEndpoints: [rpcEndpoint1, rpcEndpoint2], + }); + + await withController( + { + state: { + networkConfigurationsByChainId: { + [infuraChainId]: networkConfigurationToUpdate, + '0x9999': buildCustomNetworkConfiguration({ + chainId: '0x9999', + nativeCurrency: 'TEST-9999', + rpcEndpoints: [ + buildCustomRpcEndpoint({ + networkClientId: 'ZZZZ-ZZZZ-ZZZZ-ZZZZ', + url: 'https://selected.endpoint', + }), + ], + }), + }, + selectedNetworkClientId: 'ZZZZ-ZZZZ-ZZZZ-ZZZZ', + }, + }, + async ({ controller }) => { + mockCreateNetworkClient().mockReturnValue(buildFakeClient()); + const networkClientRegistry = + controller.getNetworkClientRegistry(); + + await controller.updateNetwork(infuraChainId, { + ...networkConfigurationToUpdate, + rpcEndpoints: [ + rpcEndpoint1, + { ...rpcEndpoint2, networkClientId: undefined }, + ], + }); + + expect(controller.getNetworkClientRegistry()).toStrictEqual( + networkClientRegistry, + ); + }, + ); + }); + + it('does not touch the network configuration in state, as if the network client IDs had not been cleared', async () => { + const [rpcEndpoint1, rpcEndpoint2] = [ + buildCustomRpcEndpoint({ + name: 'Endpoint 1', + networkClientId: 'AAAA-AAAA-AAAA-AAAA', + url: 'https://rpc.endpoint/1', + }), + buildCustomRpcEndpoint({ + name: 'Endpoint 2', + networkClientId: 'BBBB-BBBB-BBBB-BBBB', + url: 'https://rpc.endpoint/2', + }), + ]; + const networkConfigurationToUpdate = + buildInfuraNetworkConfiguration(infuraNetworkType, { + rpcEndpoints: [rpcEndpoint1, rpcEndpoint2], + }); + + await withController( + { + state: { + networkConfigurationsByChainId: { + [infuraChainId]: networkConfigurationToUpdate, + '0x9999': buildCustomNetworkConfiguration({ + chainId: '0x9999', + nativeCurrency: 'TEST-9999', + rpcEndpoints: [ + buildCustomRpcEndpoint({ + networkClientId: 'ZZZZ-ZZZZ-ZZZZ-ZZZZ', + url: 'https://selected.endpoint', + }), + ], + }), + }, + selectedNetworkClientId: 'ZZZZ-ZZZZ-ZZZZ-ZZZZ', + }, + }, + async ({ controller }) => { + const previousNetworkConfigurationsByChainId = + controller.state.networkConfigurationsByChainId; + + await controller.updateNetwork(infuraChainId, { + ...networkConfigurationToUpdate, + rpcEndpoints: [ + rpcEndpoint1, + { ...rpcEndpoint2, networkClientId: undefined }, + ], + }); + + expect( + controller.state.networkConfigurationsByChainId, + ).toStrictEqual(previousNetworkConfigurationsByChainId); + }, + ); + }); + + it('returns the network configuration, untouched', async () => { + const [rpcEndpoint1, rpcEndpoint2] = [ + buildCustomRpcEndpoint({ + name: 'Endpoint 1', + networkClientId: 'AAAA-AAAA-AAAA-AAAA', + url: 'https://rpc.endpoint/1', + }), + buildCustomRpcEndpoint({ + name: 'Endpoint 2', + networkClientId: 'BBBB-BBBB-BBBB-BBBB', + url: 'https://rpc.endpoint/2', + }), + ]; + const networkConfigurationToUpdate = + buildInfuraNetworkConfiguration(infuraNetworkType, { + rpcEndpoints: [rpcEndpoint1, rpcEndpoint2], + }); + + await withController( + { + state: { + networkConfigurationsByChainId: { + [infuraChainId]: networkConfigurationToUpdate, + '0x9999': buildCustomNetworkConfiguration({ + chainId: '0x9999', + nativeCurrency: 'TEST-9999', + rpcEndpoints: [ + buildCustomRpcEndpoint({ + networkClientId: 'ZZZZ-ZZZZ-ZZZZ-ZZZZ', + url: 'https://selected.endpoint', + }), + ], + }), + }, + selectedNetworkClientId: 'ZZZZ-ZZZZ-ZZZZ-ZZZZ', + }, + }, + async ({ controller }) => { + const updatedNetworkConfiguration = + await controller.updateNetwork(infuraChainId, { + ...networkConfigurationToUpdate, + rpcEndpoints: [ + rpcEndpoint1, + { ...rpcEndpoint2, networkClientId: undefined }, + ], + }); + + expect(updatedNetworkConfiguration).toStrictEqual( + networkConfigurationToUpdate, + ); + }, + ); + }); + }); + + describe('when no RPC endpoints are being changed', () => { + it('does not touch the network client registry', async () => { + const networkConfigurationToUpdate = + buildInfuraNetworkConfiguration(infuraNetworkType, { + name: 'Some Name', + rpcEndpoints: [buildInfuraRpcEndpoint(infuraNetworkType)], + }); + + await withController( + { + state: { + networkConfigurationsByChainId: { + [infuraChainId]: networkConfigurationToUpdate, + '0x9999': buildCustomNetworkConfiguration({ + chainId: '0x9999', + nativeCurrency: 'TEST-9999', + rpcEndpoints: [ + buildCustomRpcEndpoint({ + networkClientId: 'ZZZZ-ZZZZ-ZZZZ-ZZZZ', + url: 'https://selected.endpoint', + }), + ], + }), + }, + selectedNetworkClientId: 'ZZZZ-ZZZZ-ZZZZ-ZZZZ', + }, + }, + async ({ controller }) => { + mockCreateNetworkClient().mockReturnValue(buildFakeClient()); + const networkClientRegistry = + controller.getNetworkClientRegistry(); + + await controller.updateNetwork(infuraChainId, { + ...networkConfigurationToUpdate, + name: 'Some Other Name', + }); + + expect(controller.getNetworkClientRegistry()).toStrictEqual( + networkClientRegistry, + ); + }, + ); + }); + }); + }); + } + + describe('if the existing chain ID is a non-Infura-supported chain and is not being changed', () => { + it('throws (albeit for a different reason) if an Infura RPC endpoint is being added that represents a different chain than the one being updated', async () => { + const defaultRpcEndpoint = buildInfuraRpcEndpoint( + InfuraNetworkType.mainnet, + ); + const networkConfigurationToUpdate = buildNetworkConfiguration({ + chainId: '0x1337', + }); + + await withController( + { + state: { + networkConfigurationsByChainId: { + '0x1337': networkConfigurationToUpdate, + [ChainId.mainnet]: buildInfuraNetworkConfiguration( + InfuraNetworkType.mainnet, + ), + }, + selectedNetworkClientId: InfuraNetworkType.mainnet, + }, + }, + async ({ controller }) => { + await expect( + controller.updateNetwork('0x1337', { + ...networkConfigurationToUpdate, + rpcEndpoints: [ + ...networkConfigurationToUpdate.rpcEndpoints, + defaultRpcEndpoint, + ], + }), + ).rejects.toThrow( + "Could not update network to point to same RPC endpoint as existing network for chain 0x1 ('Ethereum')", + ); + }, + ); + }); + + describe('when new custom RPC endpoints are being added', () => { + it('creates and registers new network clients for each RPC endpoint', async () => { + uuidV4Mock + .mockReturnValueOnce('BBBB-BBBB-BBBB-BBBB') + .mockReturnValueOnce('CCCC-CCCC-CCCC-CCCC'); + const createAutoManagedNetworkClientSpy = jest.spyOn( + createAutoManagedNetworkClientModule, + 'createAutoManagedNetworkClient', + ); + const rpcEndpoint1 = buildCustomRpcEndpoint({ + failoverUrls: [], + name: 'Endpoint 1', + networkClientId: 'AAAA-AAAA-AAAA-AAAA', + url: 'https://rpc.endpoint/1', + }); + const networkConfigurationToUpdate = buildNetworkConfiguration({ + chainId: '0x1337', + nativeCurrency: 'TOKEN', + rpcEndpoints: [rpcEndpoint1], + }); + const getRpcServiceOptions = (): Omit< + RpcServiceOptions, + 'failoverService' | 'endpointUrl' + > => ({ + btoa, + fetch, + isOffline: (): boolean => false, + fetchOptions: { + headers: { + 'X-Foo': 'Bar', + }, + }, + policyOptions: { + maxRetries: 2, + maxConsecutiveFailures: 10, + }, + }); + const getBlockTrackerOptions = (): PollingBlockTrackerOptions => ({ + pollingInterval: 2000, + }); + + await withController( + { + state: { + networkConfigurationsByChainId: { + '0x1337': networkConfigurationToUpdate, + '0x9999': buildCustomNetworkConfiguration({ + chainId: '0x9999', + nativeCurrency: 'TEST-9999', + rpcEndpoints: [ + buildCustomRpcEndpoint({ + networkClientId: 'ZZZZ-ZZZZ-ZZZZ-ZZZZ', + url: 'https://selected.endpoint', + }), + ], + }), + }, + selectedNetworkClientId: 'ZZZZ-ZZZZ-ZZZZ-ZZZZ', + }, + getRpcServiceOptions, + getBlockTrackerOptions, + rpcFailoverMode: 'enabled', + }, + async ({ controller, networkControllerMessenger }) => { + await controller.updateNetwork('0x1337', { + ...networkConfigurationToUpdate, + defaultRpcEndpointIndex: 0, + rpcEndpoints: [ + rpcEndpoint1, + buildUpdateNetworkCustomRpcEndpointFields({ + failoverUrls: ['https://first.failover.endpoint'], + name: 'Endpoint 2', + url: 'https://rpc.endpoint/2', + }), + buildUpdateNetworkCustomRpcEndpointFields({ + failoverUrls: ['https://second.failover.endpoint'], + name: 'Endpoint 3', + url: 'https://rpc.endpoint/3', + }), + ], + }); + + expect(createAutoManagedNetworkClientSpy).toHaveBeenNthCalledWith( + 3, + { + networkClientId: 'BBBB-BBBB-BBBB-BBBB', + networkClientConfiguration: { + chainId: '0x1337', + failoverRpcUrls: ['https://first.failover.endpoint'], + rpcUrl: 'https://rpc.endpoint/2', + ticker: 'TOKEN', + type: NetworkClientType.Custom, + }, + getRpcServiceOptions, + getBlockTrackerOptions, + messenger: networkControllerMessenger, + rpcFailoverMode: 'enabled', + }, + ); + expect(createAutoManagedNetworkClientSpy).toHaveBeenNthCalledWith( + 4, + { + networkClientId: 'CCCC-CCCC-CCCC-CCCC', + networkClientConfiguration: { + chainId: '0x1337', + failoverRpcUrls: ['https://second.failover.endpoint'], + rpcUrl: 'https://rpc.endpoint/3', + ticker: 'TOKEN', + type: NetworkClientType.Custom, + }, + getRpcServiceOptions, + getBlockTrackerOptions, + messenger: networkControllerMessenger, + rpcFailoverMode: 'enabled', + }, + ); + + const networkConfigurationsByNetworkClientId = + getNetworkConfigurationsByNetworkClientId( + controller.getNetworkClientRegistry(), + ); + expect( + networkConfigurationsByNetworkClientId['AAAA-AAAA-AAAA-AAAA'], + ).toStrictEqual({ + chainId: '0x1337', + failoverRpcUrls: [], + rpcUrl: 'https://rpc.endpoint/1', + ticker: 'TOKEN', + type: NetworkClientType.Custom, + }); + expect( + networkConfigurationsByNetworkClientId['BBBB-BBBB-BBBB-BBBB'], + ).toStrictEqual({ + chainId: '0x1337', + failoverRpcUrls: ['https://first.failover.endpoint'], + rpcUrl: 'https://rpc.endpoint/2', + ticker: 'TOKEN', + type: NetworkClientType.Custom, + }); + expect( + networkConfigurationsByNetworkClientId['CCCC-CCCC-CCCC-CCCC'], + ).toStrictEqual({ + chainId: '0x1337', + failoverRpcUrls: ['https://second.failover.endpoint'], + rpcUrl: 'https://rpc.endpoint/3', + ticker: 'TOKEN', + type: NetworkClientType.Custom, + }); + }, + ); + }); + + it('assigns the ID of the created network client to each RPC endpoint in state', async () => { + uuidV4Mock + .mockReturnValueOnce('BBBB-BBBB-BBBB-BBBB') + .mockReturnValueOnce('CCCC-CCCC-CCCC-CCCC'); + const rpcEndpoint1 = buildCustomRpcEndpoint({ + name: 'Endpoint 1', + networkClientId: 'AAAA-AAAA-AAAA-AAAA', + url: 'https://rpc.endpoint/1', + }); + const networkConfigurationToUpdate = buildNetworkConfiguration({ + chainId: '0x1337', + rpcEndpoints: [rpcEndpoint1], + }); + + await withController( + { + state: { + networkConfigurationsByChainId: { + '0x1337': networkConfigurationToUpdate, + '0x9999': buildCustomNetworkConfiguration({ + chainId: '0x9999', + nativeCurrency: 'TEST-9999', + rpcEndpoints: [ + buildCustomRpcEndpoint({ + networkClientId: 'ZZZZ-ZZZZ-ZZZZ-ZZZZ', + url: 'https://selected.endpoint', + }), + ], + }), + }, + selectedNetworkClientId: 'ZZZZ-ZZZZ-ZZZZ-ZZZZ', + }, + }, + async ({ controller }) => { + await controller.updateNetwork('0x1337', { + ...networkConfigurationToUpdate, + defaultRpcEndpointIndex: 0, + rpcEndpoints: [ + rpcEndpoint1, + buildUpdateNetworkCustomRpcEndpointFields({ + failoverUrls: ['https://first.failover.endpoint'], + name: 'Endpoint 2', + url: 'https://rpc.endpoint/2', + }), + buildUpdateNetworkCustomRpcEndpointFields({ + failoverUrls: ['https://second.failover.endpoint'], + name: 'Endpoint 3', + url: 'https://rpc.endpoint/3', + }), + ], + }); + + expect( + controller.state.networkConfigurationsByChainId['0x1337'], + ).toStrictEqual({ + ...networkConfigurationToUpdate, + rpcEndpoints: [ + rpcEndpoint1, + { + failoverUrls: ['https://first.failover.endpoint'], + name: 'Endpoint 2', + networkClientId: 'BBBB-BBBB-BBBB-BBBB', + type: RpcEndpointType.Custom, + url: 'https://rpc.endpoint/2', + }, + { + failoverUrls: ['https://second.failover.endpoint'], + name: 'Endpoint 3', + networkClientId: 'CCCC-CCCC-CCCC-CCCC', + type: RpcEndpointType.Custom, + url: 'https://rpc.endpoint/3', + }, + ], + lastUpdatedAt: FAKE_DATE_NOW_MS, + }); + }, + ); + }); + + it('returns the updated network configuration', async () => { + uuidV4Mock + .mockReturnValueOnce('BBBB-BBBB-BBBB-BBBB') + .mockReturnValueOnce('CCCC-CCCC-CCCC-CCCC'); + const rpcEndpoint1 = buildCustomRpcEndpoint({ + name: 'Endpoint 1', + networkClientId: 'AAAA-AAAA-AAAA-AAAA', + url: 'https://rpc.endpoint/1', + }); + const networkConfigurationToUpdate = buildNetworkConfiguration({ + chainId: '0x1337', + rpcEndpoints: [rpcEndpoint1], + }); + + await withController( + { + state: { + networkConfigurationsByChainId: { + '0x1337': networkConfigurationToUpdate, + '0x9999': buildCustomNetworkConfiguration({ + chainId: '0x9999', + nativeCurrency: 'TEST-9999', + rpcEndpoints: [ + buildCustomRpcEndpoint({ + networkClientId: 'ZZZZ-ZZZZ-ZZZZ-ZZZZ', + url: 'https://selected.endpoint', + }), + ], + }), + }, + selectedNetworkClientId: 'ZZZZ-ZZZZ-ZZZZ-ZZZZ', + }, + }, + async ({ controller }) => { + const updatedNetworkConfiguration = + await controller.updateNetwork('0x1337', { + ...networkConfigurationToUpdate, + defaultRpcEndpointIndex: 0, + rpcEndpoints: [ + rpcEndpoint1, + buildUpdateNetworkCustomRpcEndpointFields({ + failoverUrls: ['https://first.failover.endpoint'], + name: 'Endpoint 2', + url: 'https://rpc.endpoint/2', + }), + buildUpdateNetworkCustomRpcEndpointFields({ + failoverUrls: ['https://second.failover.endpoint'], + name: 'Endpoint 3', + url: 'https://rpc.endpoint/3', + }), + ], + }); + + expect(updatedNetworkConfiguration).toStrictEqual({ + ...networkConfigurationToUpdate, + rpcEndpoints: [ + rpcEndpoint1, + { + failoverUrls: ['https://first.failover.endpoint'], + name: 'Endpoint 2', + networkClientId: 'BBBB-BBBB-BBBB-BBBB', + type: RpcEndpointType.Custom, + url: 'https://rpc.endpoint/2', + }, + { + failoverUrls: ['https://second.failover.endpoint'], + name: 'Endpoint 3', + networkClientId: 'CCCC-CCCC-CCCC-CCCC', + type: RpcEndpointType.Custom, + url: 'https://rpc.endpoint/3', + }, + ], + lastUpdatedAt: FAKE_DATE_NOW_MS, + }); + }, + ); + }); + }); + + describe('when some custom RPC endpoints are being removed', () => { + it('destroys and unregisters existing network clients for the RPC endpoints', async () => { + const [rpcEndpoint1, rpcEndpoint2] = [ + buildCustomRpcEndpoint({ + name: 'Endpoint 1', + networkClientId: 'AAAA-AAAA-AAAA-AAAA', + url: 'https://rpc.endpoint/1', + }), + buildCustomRpcEndpoint({ + name: 'Endpoint 2', + networkClientId: 'BBBB-BBBB-BBBB-BBBB', + url: 'https://rpc.endpoint/2', + }), + ]; + const networkConfigurationToUpdate = buildNetworkConfiguration({ + chainId: '0x1337', + rpcEndpoints: [rpcEndpoint1, rpcEndpoint2], + }); + + await withController( + { + state: { + selectedNetworkClientId: 'BBBB-BBBB-BBBB-BBBB', + networkConfigurationsByChainId: { + '0x1337': networkConfigurationToUpdate, + }, + }, + }, + async ({ controller }) => { + const existingNetworkClient = controller.getNetworkClientById( + 'AAAA-AAAA-AAAA-AAAA', + ); + const destroySpy = jest.spyOn(existingNetworkClient, 'destroy'); + + await controller.updateNetwork('0x1337', { + ...networkConfigurationToUpdate, + defaultRpcEndpointIndex: 0, + rpcEndpoints: [rpcEndpoint2], + }); + + expect(destroySpy).toHaveBeenCalled(); + const networkClientRegistry = + controller.getNetworkClientRegistry(); + expect(networkClientRegistry).not.toHaveProperty( + 'AAAA-AAAA-AAAA-AAAA', + ); + }, + ); + }); + + it('updates the network configuration in state', async () => { + const [rpcEndpoint1, rpcEndpoint2] = [ + buildCustomRpcEndpoint({ + name: 'Endpoint 1', + networkClientId: 'AAAA-AAAA-AAAA-AAAA', + url: 'https://rpc.endpoint/1', + }), + buildCustomRpcEndpoint({ + name: 'Endpoint 2', + networkClientId: 'BBBB-BBBB-BBBB-BBBB', + url: 'https://rpc.endpoint/2', + }), + ]; + const networkConfigurationToUpdate = buildNetworkConfiguration({ + chainId: '0x1337', + rpcEndpoints: [rpcEndpoint1, rpcEndpoint2], + }); + + await withController( + { + state: { + selectedNetworkClientId: 'BBBB-BBBB-BBBB-BBBB', + networkConfigurationsByChainId: { + '0x1337': networkConfigurationToUpdate, + }, + }, + }, + async ({ controller }) => { + await controller.updateNetwork('0x1337', { + ...networkConfigurationToUpdate, + defaultRpcEndpointIndex: 0, + rpcEndpoints: [rpcEndpoint2], + }); + + expect( + controller.state.networkConfigurationsByChainId['0x1337'], + ).toStrictEqual({ + ...networkConfigurationToUpdate, + defaultRpcEndpointIndex: 0, + rpcEndpoints: [rpcEndpoint2], + lastUpdatedAt: FAKE_DATE_NOW_MS, + }); + }, + ); + }); + + it('returns the updated network configuration', async () => { + const [rpcEndpoint1, rpcEndpoint2] = [ + buildCustomRpcEndpoint({ + name: 'Endpoint 1', + networkClientId: 'AAAA-AAAA-AAAA-AAAA', + url: 'https://rpc.endpoint/1', + }), + buildCustomRpcEndpoint({ + name: 'Endpoint 2', + networkClientId: 'BBBB-BBBB-BBBB-BBBB', + url: 'https://rpc.endpoint/2', + }), + ]; + const networkConfigurationToUpdate = buildNetworkConfiguration({ + chainId: '0x1337', + rpcEndpoints: [rpcEndpoint1, rpcEndpoint2], + }); + + await withController( + { + state: { + selectedNetworkClientId: 'BBBB-BBBB-BBBB-BBBB', + networkConfigurationsByChainId: { + '0x1337': networkConfigurationToUpdate, + }, + }, + }, + async ({ controller }) => { + const updatedNetworkConfiguration = + await controller.updateNetwork('0x1337', { + ...networkConfigurationToUpdate, + defaultRpcEndpointIndex: 0, + rpcEndpoints: [rpcEndpoint2], + }); + + expect(updatedNetworkConfiguration).toStrictEqual({ + ...networkConfigurationToUpdate, + defaultRpcEndpointIndex: 0, + rpcEndpoints: [rpcEndpoint2], + lastUpdatedAt: FAKE_DATE_NOW_MS, + }); + }, + ); + }); + + describe('when one is represented by the selected network client (and a replacement is specified)', () => { + describe('if the replacement RPC endpoint already exists', () => { + it('selects the network client that represents the replacement RPC endpoint', async () => { + const networkConfigurationToUpdate = + buildCustomNetworkConfiguration({ + chainId: '0x1337', + nativeCurrency: 'TOKEN', + rpcEndpoints: [ + buildCustomRpcEndpoint({ + networkClientId: 'AAAA-AAAA-AAAA-AAAA', + url: 'https://test.network/1', + }), + buildCustomRpcEndpoint({ + networkClientId: 'BBBB-BBBB-BBBB-BBBB', + url: 'https://test.network/2', + }), + ], + }); + + await withController( + { + state: { + selectedNetworkClientId: 'AAAA-AAAA-AAAA-AAAA', + networkConfigurationsByChainId: { + '0x1337': networkConfigurationToUpdate, + }, + }, + }, + async ({ controller }) => { + const fakeProviders = [ + buildFakeProvider([ + { + request: { + method: 'test', + }, + response: { + result: 'test response from 1', + }, + }, + ]), + buildFakeProvider([ + { + request: { + method: 'test', + }, + response: { + result: 'test response from 2', + }, + }, + ]), + ]; + const fakeNetworkClients = [ + buildFakeClient(fakeProviders[0]), + buildFakeClient(fakeProviders[1]), + ]; + createNetworkClientMock.mockImplementation( + ({ configuration }) => { + if ( + configuration.type === NetworkClientType.Custom && + configuration.rpcUrl === 'https://test.network/1' + ) { + return fakeNetworkClients[0]; + } else if ( + configuration.type === NetworkClientType.Custom && + configuration.rpcUrl === 'https://test.network/2' + ) { + return fakeNetworkClients[1]; + } + throw new Error( + `Unknown network client configuration ${JSON.stringify( + configuration, + )}`, + ); + }, + ); + await controller.lookupNetwork(); + expect(controller.state.selectedNetworkClientId).toBe( + 'AAAA-AAAA-AAAA-AAAA', + ); + const networkClient1 = controller.getSelectedNetworkClient(); + assert(networkClient1, 'Network client is somehow unset'); + const result1 = await networkClient1.provider.request({ + method: 'test', + }); + expect(result1).toBe('test response from 1'); + + await controller.updateNetwork( + '0x1337', + { + ...networkConfigurationToUpdate, + rpcEndpoints: [ + networkConfigurationToUpdate.rpcEndpoints[1], + ], + }, + { + replacementSelectedRpcEndpointIndex: 0, + }, + ); + expect(controller.state.selectedNetworkClientId).toBe( + 'BBBB-BBBB-BBBB-BBBB', + ); + const networkClient2 = controller.getSelectedNetworkClient(); + assert(networkClient2, 'Network client is somehow unset'); + const result2 = await networkClient2.provider.request({ + method: 'test', + }); + expect(result2).toBe('test response from 2'); + }, + ); + }); + + it('updates selectedNetworkClientId and networkConfigurationsByChainId at the same time', async () => { + const networkConfigurationToUpdate = + buildCustomNetworkConfiguration({ + chainId: '0x1337', + nativeCurrency: 'TOKEN', + rpcEndpoints: [ + buildCustomRpcEndpoint({ + networkClientId: 'AAAA-AAAA-AAAA-AAAA', + url: 'https://test.network/1', + }), + buildCustomRpcEndpoint({ + networkClientId: 'BBBB-BBBB-BBBB-BBBB', + url: 'https://test.network/2', + }), + ], + }); + + await withController( + { + state: { + selectedNetworkClientId: 'AAAA-AAAA-AAAA-AAAA', + networkConfigurationsByChainId: { + '0x1337': networkConfigurationToUpdate, + }, + }, + }, + async ({ controller, messenger }) => { + const fakeProviders = [ + buildFakeProvider([ + { + request: { + method: 'test', + }, + response: { + result: 'test response from 1', + }, + }, + ]), + buildFakeProvider([ + { + request: { + method: 'test', + }, + response: { + result: 'test response from 2', + }, + }, + ]), + ]; + const fakeNetworkClients = [ + buildFakeClient(fakeProviders[0]), + buildFakeClient(fakeProviders[1]), + ]; + createNetworkClientMock.mockImplementation( + ({ configuration }) => { + if ( + configuration.type === NetworkClientType.Custom && + configuration.rpcUrl === 'https://test.network/1' + ) { + return fakeNetworkClients[0]; + } else if ( + configuration.type === NetworkClientType.Custom && + configuration.rpcUrl === 'https://test.network/2' + ) { + return fakeNetworkClients[1]; + } + throw new Error( + `Unknown network client configuration ${JSON.stringify( + configuration, + )}`, + ); + }, + ); + await controller.lookupNetwork(); + + const promiseForStateChanges = waitForStateChanges({ + messenger, + count: 1, + }); + + await controller.updateNetwork( + '0x1337', + { + ...networkConfigurationToUpdate, + rpcEndpoints: [ + networkConfigurationToUpdate.rpcEndpoints[1], + ], + }, + { + replacementSelectedRpcEndpointIndex: 0, + }, + ); + const stateChanges = await promiseForStateChanges; + expect(stateChanges).toStrictEqual([ + [ + expect.any(Object), + expect.arrayContaining([ + expect.objectContaining({ + op: 'replace', + path: ['selectedNetworkClientId'], + value: 'BBBB-BBBB-BBBB-BBBB', + }), + expect.objectContaining({ + op: 'replace', + path: ['networkConfigurationsByChainId', '0x1337'], + }), + ]), + ], + ]); + }, + ); + }); + }); + + describe('if the replacement RPC endpoint is being added', () => { + it('selects the network client that represents the replacement RPC endpoint', async () => { + uuidV4Mock.mockReturnValueOnce('CCCC-CCCC-CCCC-CCCC'); + const networkConfigurationToUpdate = + buildCustomNetworkConfiguration({ + chainId: '0x1337', + nativeCurrency: 'TOKEN', + rpcEndpoints: [ + buildCustomRpcEndpoint({ + networkClientId: 'AAAA-AAAA-AAAA-AAAA', + url: 'https://test.network/1', + }), + buildCustomRpcEndpoint({ + networkClientId: 'BBBB-BBBB-BBBB-BBBB', + url: 'https://test.network/2', + }), + ], + }); + + await withController( + { + state: { + selectedNetworkClientId: 'AAAA-AAAA-AAAA-AAAA', + networkConfigurationsByChainId: { + '0x1337': networkConfigurationToUpdate, + }, + }, + }, + async ({ controller }) => { + const fakeProviders = [ + buildFakeProvider([ + { + request: { + method: 'test', + }, + response: { + result: 'test response from 1', + }, + }, + ]), + buildFakeProvider([ + { + request: { + method: 'test', + }, + response: { + result: 'test response from 2', + }, + }, + ]), + buildFakeProvider([ + { + request: { + method: 'test', + }, + response: { + result: 'test response from 3', + }, + }, + ]), + ]; + const fakeNetworkClients = [ + buildFakeClient(fakeProviders[0]), + buildFakeClient(fakeProviders[1]), + buildFakeClient(fakeProviders[2]), + ]; + createNetworkClientMock.mockImplementation( + ({ configuration }) => { + if ( + configuration.type === NetworkClientType.Custom && + configuration.rpcUrl === 'https://test.network/1' + ) { + return fakeNetworkClients[0]; + } else if ( + configuration.type === NetworkClientType.Custom && + configuration.rpcUrl === 'https://test.network/2' + ) { + return fakeNetworkClients[1]; + } else if ( + configuration.type === NetworkClientType.Custom && + configuration.rpcUrl === 'https://test.network/3' + ) { + return fakeNetworkClients[2]; + } + throw new Error( + `Unknown network client configuration ${JSON.stringify( + configuration, + )}`, + ); + }, + ); + await controller.lookupNetwork(); + expect(controller.state.selectedNetworkClientId).toBe( + 'AAAA-AAAA-AAAA-AAAA', + ); + const networkClient1 = controller.getSelectedNetworkClient(); + assert(networkClient1, 'Network client is somehow unset'); + const result1 = await networkClient1.provider.request({ + method: 'test', + }); + expect(result1).toBe('test response from 1'); + + await controller.updateNetwork( + '0x1337', + { + ...networkConfigurationToUpdate, + rpcEndpoints: [ + buildUpdateNetworkCustomRpcEndpointFields({ + url: 'https://test.network/3', + }), + networkConfigurationToUpdate.rpcEndpoints[1], + ], + }, + { + replacementSelectedRpcEndpointIndex: 0, + }, + ); + expect(controller.state.selectedNetworkClientId).toBe( + 'CCCC-CCCC-CCCC-CCCC', + ); + const networkClient2 = controller.getSelectedNetworkClient(); + assert(networkClient2, 'Network client is somehow unset'); + const result2 = await networkClient2.provider.request({ + method: 'test', + }); + expect(result2).toBe('test response from 3'); + }, + ); + }); + + it('updates selectedNetworkClientId and networkConfigurationsByChainId at the same time', async () => { + uuidV4Mock.mockReturnValueOnce('CCCC-CCCC-CCCC-CCCC'); + const networkConfigurationToUpdate = + buildCustomNetworkConfiguration({ + chainId: '0x1337', + nativeCurrency: 'TOKEN', + rpcEndpoints: [ + buildCustomRpcEndpoint({ + networkClientId: 'AAAA-AAAA-AAAA-AAAA', + url: 'https://test.network/1', + }), + buildCustomRpcEndpoint({ + networkClientId: 'BBBB-BBBB-BBBB-BBBB', + url: 'https://test.network/2', + }), + ], + }); + + await withController( + { + state: { + selectedNetworkClientId: 'AAAA-AAAA-AAAA-AAAA', + networkConfigurationsByChainId: { + '0x1337': networkConfigurationToUpdate, + }, + }, + }, + async ({ controller, messenger }) => { + const fakeProviders = [ + buildFakeProvider([ + { + request: { + method: 'test', + }, + response: { + result: 'test response from 1', + }, + }, + ]), + buildFakeProvider([ + { + request: { + method: 'test', + }, + response: { + result: 'test response from 2', + }, + }, + ]), + buildFakeProvider([ + { + request: { + method: 'test', + }, + response: { + result: 'test response from 3', + }, + }, + ]), + ]; + const fakeNetworkClients = [ + buildFakeClient(fakeProviders[0]), + buildFakeClient(fakeProviders[1]), + buildFakeClient(fakeProviders[2]), + ]; + createNetworkClientMock.mockImplementation( + ({ configuration }) => { + if ( + configuration.type === NetworkClientType.Custom && + configuration.rpcUrl === 'https://test.network/1' + ) { + return fakeNetworkClients[0]; + } else if ( + configuration.type === NetworkClientType.Custom && + configuration.rpcUrl === 'https://test.network/2' + ) { + return fakeNetworkClients[1]; + } else if ( + configuration.type === NetworkClientType.Custom && + configuration.rpcUrl === 'https://test.network/3' + ) { + return fakeNetworkClients[2]; + } + throw new Error( + `Unknown network client configuration ${JSON.stringify( + configuration, + )}`, + ); + }, + ); + await controller.lookupNetwork(); + + const promiseForStateChanges = waitForStateChanges({ + messenger, + count: 1, + }); + + await controller.updateNetwork( + '0x1337', + { + ...networkConfigurationToUpdate, + rpcEndpoints: [ + buildUpdateNetworkCustomRpcEndpointFields({ + url: 'https://test.network/3', + }), + networkConfigurationToUpdate.rpcEndpoints[1], + ], + }, + { + replacementSelectedRpcEndpointIndex: 0, + }, + ); + const stateChanges = await promiseForStateChanges; + expect(stateChanges).toStrictEqual([ + [ + expect.any(Object), + expect.arrayContaining([ + expect.objectContaining({ + op: 'replace', + path: ['selectedNetworkClientId'], + value: 'CCCC-CCCC-CCCC-CCCC', + }), + expect.objectContaining({ + op: 'replace', + path: ['networkConfigurationsByChainId', '0x1337'], + }), + ]), + ], + ]); + }, + ); + }); + }); + }); + }); + + describe('when the URL of an RPC endpoint is changed (using networkClientId as identification)', () => { + it('destroys and unregisters the network client for the previous version of the RPC endpoint', async () => { + uuidV4Mock.mockReturnValueOnce('BBBB-BBBB-BBBB-BBBB'); + const networkConfigurationToUpdate = buildCustomNetworkConfiguration({ + nativeCurrency: 'TOKEN', + rpcEndpoints: [ + buildCustomRpcEndpoint({ + name: 'Endpoint 1', + networkClientId: 'AAAA-AAAA-AAAA-AAAA', + url: 'https://rpc.endpoint', + }), + ], + }); + + await withController( + { + state: { + networkConfigurationsByChainId: { + '0x1337': networkConfigurationToUpdate, + '0x9999': buildCustomNetworkConfiguration({ + chainId: '0x9999', + nativeCurrency: 'TEST-9999', + rpcEndpoints: [ + buildCustomRpcEndpoint({ + networkClientId: 'ZZZZ-ZZZZ-ZZZZ-ZZZZ', + url: 'https://selected.endpoint', + }), + ], + }), + }, + selectedNetworkClientId: 'ZZZZ-ZZZZ-ZZZZ-ZZZZ', + }, + }, + async ({ controller }) => { + createNetworkClientMock.mockReturnValue(buildFakeClient()); + const existingNetworkClient = controller.getNetworkClientById( + 'AAAA-AAAA-AAAA-AAAA', + ); + const destroySpy = jest.spyOn(existingNetworkClient, 'destroy'); + + await controller.updateNetwork('0x1337', { + ...networkConfigurationToUpdate, + rpcEndpoints: [ + buildCustomRpcEndpoint({ + name: 'Endpoint 1', + networkClientId: 'AAAA-AAAA-AAAA-AAAA', + url: 'https://some.other.url', + }), + ], + }); + + expect(destroySpy).toHaveBeenCalled(); + const networkClientRegistry = + controller.getNetworkClientRegistry(); + expect(networkClientRegistry).not.toHaveProperty( + 'AAAA-AAAA-AAAA-AAAA', + ); + }, + ); + }); + + it('creates and registers a network client for the new version of the RPC endpoint', async () => { + uuidV4Mock.mockReturnValueOnce('BBBB-BBBB-BBBB-BBBB'); + const createAutoManagedNetworkClientSpy = jest.spyOn( + createAutoManagedNetworkClientModule, + 'createAutoManagedNetworkClient', + ); + const networkConfigurationToUpdate = buildCustomNetworkConfiguration({ + nativeCurrency: 'TOKEN', + rpcEndpoints: [ + buildCustomRpcEndpoint({ + name: 'Endpoint 1', + networkClientId: 'AAAA-AAAA-AAAA-AAAA', + url: 'https://rpc.endpoint', + }), + ], + }); + const getRpcServiceOptions = (): Omit< + RpcServiceOptions, + 'failoverService' | 'endpointUrl' + > => ({ + btoa, + fetch, + isOffline: (): boolean => false, + fetchOptions: { + headers: { + 'X-Foo': 'Bar', + }, + }, + policyOptions: { + maxRetries: 2, + maxConsecutiveFailures: 10, + }, + }); + const getBlockTrackerOptions = (): PollingBlockTrackerOptions => ({ + pollingInterval: 2000, + }); + + await withController( + { + state: { + networkConfigurationsByChainId: { + '0x1337': networkConfigurationToUpdate, + '0x9999': buildCustomNetworkConfiguration({ + chainId: '0x9999', + nativeCurrency: 'TEST-9999', + rpcEndpoints: [ + buildCustomRpcEndpoint({ + networkClientId: 'ZZZZ-ZZZZ-ZZZZ-ZZZZ', + url: 'https://selected.endpoint', + }), + ], + }), + }, + selectedNetworkClientId: 'ZZZZ-ZZZZ-ZZZZ-ZZZZ', + }, + getRpcServiceOptions, + getBlockTrackerOptions, + rpcFailoverMode: 'enabled', + }, + async ({ controller, networkControllerMessenger }) => { + createNetworkClientMock.mockImplementation( + ({ configuration }) => { + if ( + configuration.type === NetworkClientType.Custom && + configuration.rpcUrl === 'https://some.other.url' + ) { + return buildFakeClient(); + } + throw new Error( + `Unknown network client configuration ${JSON.stringify( + configuration, + )}`, + ); + }, + ); + + await controller.updateNetwork('0x1337', { + ...networkConfigurationToUpdate, + rpcEndpoints: [ + buildCustomRpcEndpoint({ + failoverUrls: ['https://failover.endpoint'], + name: 'Endpoint 1', + networkClientId: 'AAAA-AAAA-AAAA-AAAA', + url: 'https://some.other.url', + }), + ], + }); + + expect(createAutoManagedNetworkClientSpy).toHaveBeenCalledWith({ + networkClientId: 'BBBB-BBBB-BBBB-BBBB', + networkClientConfiguration: { + chainId: '0x1337', + failoverRpcUrls: ['https://failover.endpoint'], + rpcUrl: 'https://some.other.url', + ticker: 'TOKEN', + type: NetworkClientType.Custom, + }, + getRpcServiceOptions, + getBlockTrackerOptions, + messenger: networkControllerMessenger, + rpcFailoverMode: 'enabled', + }); + expect( + getNetworkConfigurationsByNetworkClientId( + controller.getNetworkClientRegistry(), + ), + ).toMatchObject({ + 'BBBB-BBBB-BBBB-BBBB': { + chainId: '0x1337', + failoverRpcUrls: ['https://failover.endpoint'], + rpcUrl: 'https://some.other.url', + ticker: 'TOKEN', + type: NetworkClientType.Custom, + }, + }); + }, + ); + }); + + it('updates the network configuration in state with a new network client ID for the RPC endpoint', async () => { + uuidV4Mock.mockReturnValueOnce('BBBB-BBBB-BBBB-BBBB'); + const customRpcEndpoint = buildCustomRpcEndpoint({ + name: 'Endpoint 1', + networkClientId: 'AAAA-AAAA-AAAA-AAAA', + url: 'https://rpc.endpoint', + }); + const networkConfigurationToUpdate = buildCustomNetworkConfiguration({ + nativeCurrency: 'TOKEN', + rpcEndpoints: [customRpcEndpoint], + }); + + await withController( + { + state: { + networkConfigurationsByChainId: { + '0x1337': networkConfigurationToUpdate, + '0x9999': buildCustomNetworkConfiguration({ + chainId: '0x9999', + nativeCurrency: 'TEST-9999', + rpcEndpoints: [ + buildCustomRpcEndpoint({ + networkClientId: 'ZZZZ-ZZZZ-ZZZZ-ZZZZ', + url: 'https://selected.endpoint', + }), + ], + }), + }, + selectedNetworkClientId: 'ZZZZ-ZZZZ-ZZZZ-ZZZZ', + }, + }, + async ({ controller }) => { + createNetworkClientMock.mockImplementation( + ({ configuration }) => { + if ( + configuration.type === NetworkClientType.Custom && + configuration.rpcUrl === 'https://some.other.url' + ) { + return buildFakeClient(); + } + throw new Error( + `Unknown network client configuration ${JSON.stringify( + configuration, + )}`, + ); + }, + ); + + await controller.updateNetwork('0x1337', { + ...networkConfigurationToUpdate, + rpcEndpoints: [ + { + ...customRpcEndpoint, + url: 'https://some.other.url', + }, + ], + }); + + expect( + controller.state.networkConfigurationsByChainId['0x1337'], + ).toStrictEqual({ + ...networkConfigurationToUpdate, + rpcEndpoints: [ + { + ...customRpcEndpoint, + networkClientId: 'BBBB-BBBB-BBBB-BBBB', + url: 'https://some.other.url', + }, + ], + lastUpdatedAt: FAKE_DATE_NOW_MS, + }); + }, + ); + }); + + it('returns the updated network configuration', async () => { + uuidV4Mock.mockReturnValueOnce('BBBB-BBBB-BBBB-BBBB'); + const customRpcEndpoint = buildCustomRpcEndpoint({ + name: 'Endpoint 1', + networkClientId: 'AAAA-AAAA-AAAA-AAAA', + url: 'https://rpc.endpoint', + }); + const networkConfigurationToUpdate = buildCustomNetworkConfiguration({ + nativeCurrency: 'TOKEN', + rpcEndpoints: [customRpcEndpoint], + }); + + await withController( + { + state: { + networkConfigurationsByChainId: { + '0x1337': networkConfigurationToUpdate, + '0x9999': buildCustomNetworkConfiguration({ + chainId: '0x9999', + nativeCurrency: 'TEST-9999', + rpcEndpoints: [ + buildCustomRpcEndpoint({ + networkClientId: 'ZZZZ-ZZZZ-ZZZZ-ZZZZ', + url: 'https://selected.endpoint', + }), + ], + }), + }, + selectedNetworkClientId: 'ZZZZ-ZZZZ-ZZZZ-ZZZZ', + }, + }, + async ({ controller }) => { + createNetworkClientMock.mockImplementation( + ({ configuration }) => { + if ( + configuration.type === NetworkClientType.Custom && + configuration.rpcUrl === 'https://some.other.url' + ) { + return buildFakeClient(); + } + throw new Error( + `Unknown network client configuration ${JSON.stringify( + configuration, + )}`, + ); + }, + ); + + const updatedNetworkConfiguration = + await controller.updateNetwork('0x1337', { + ...networkConfigurationToUpdate, + rpcEndpoints: [ + { + ...customRpcEndpoint, + url: 'https://some.other.url', + }, + ], + }); + + expect(updatedNetworkConfiguration).toStrictEqual({ + ...networkConfigurationToUpdate, + rpcEndpoints: [ + { + ...customRpcEndpoint, + networkClientId: 'BBBB-BBBB-BBBB-BBBB', + url: 'https://some.other.url', + }, + ], + lastUpdatedAt: FAKE_DATE_NOW_MS, + }); + }, + ); + }); + + describe('if the previous version of the RPC endpoint was represented by the selected network client', () => { + it('invisibly selects the network client for the new RPC endpoint', async () => { + uuidV4Mock.mockReturnValueOnce('BBBB-BBBB-BBBB-BBBB'); + const networkConfigurationToUpdate = + buildCustomNetworkConfiguration({ + nativeCurrency: 'TOKEN', + rpcEndpoints: [ + buildCustomRpcEndpoint({ + name: 'Endpoint 1', + networkClientId: 'AAAA-AAAA-AAAA-AAAA', + url: 'https://rpc.endpoint', + }), + ], + }); + + await withController( + { + state: { + selectedNetworkClientId: 'AAAA-AAAA-AAAA-AAAA', + networkConfigurationsByChainId: { + '0x1337': networkConfigurationToUpdate, + }, + }, + }, + async ({ controller }) => { + const fakeProviders = [ + buildFakeProvider([ + { + request: { + method: 'test', + }, + response: { + result: 'test response from 1', + }, + }, + ]), + buildFakeProvider([ + { + request: { + method: 'test', + }, + response: { + result: 'test response from 2', + }, + }, + ]), + ]; + const fakeNetworkClients = [ + buildFakeClient(fakeProviders[0]), + buildFakeClient(fakeProviders[1]), + ]; + createNetworkClientMock.mockImplementation( + ({ configuration }) => { + if ( + configuration.type === NetworkClientType.Custom && + configuration.rpcUrl === 'https://rpc.endpoint' + ) { + return fakeNetworkClients[0]; + } else if ( + configuration.type === NetworkClientType.Custom && + configuration.rpcUrl === 'https://some.other.url' + ) { + return fakeNetworkClients[1]; + } + throw new Error( + `Unknown network client configuration ${JSON.stringify( + configuration, + )}`, + ); + }, + ); + await controller.lookupNetwork(); + expect(controller.state.selectedNetworkClientId).toBe( + 'AAAA-AAAA-AAAA-AAAA', + ); + const networkClient1 = controller.getSelectedNetworkClient(); + assert(networkClient1, 'Network client is somehow unset'); + const result1 = await networkClient1.provider.request({ + method: 'test', + }); + expect(result1).toBe('test response from 1'); + + await controller.updateNetwork('0x1337', { + ...networkConfigurationToUpdate, + rpcEndpoints: [ + buildCustomRpcEndpoint({ + name: 'Endpoint 1', + networkClientId: 'AAAA-AAAA-AAAA-AAAA', + url: 'https://some.other.url', + }), + ], + }); + + expect(controller.state.selectedNetworkClientId).toBe( + 'BBBB-BBBB-BBBB-BBBB', + ); + const networkClient2 = controller.getSelectedNetworkClient(); + assert(networkClient2, 'Network client is somehow unset'); + const result2 = await networkClient1.provider.request({ + method: 'test', + }); + expect(result2).toBe('test response from 2'); + }, + ); + }); + + it('updates selectedNetworkClientId and networkConfigurationsByChainId at the same time', async () => { + uuidV4Mock.mockReturnValueOnce('BBBB-BBBB-BBBB-BBBB'); + const networkConfigurationToUpdate = + buildCustomNetworkConfiguration({ + nativeCurrency: 'TOKEN', + rpcEndpoints: [ + buildCustomRpcEndpoint({ + name: 'Endpoint 1', + networkClientId: 'AAAA-AAAA-AAAA-AAAA', + url: 'https://rpc.endpoint', + }), + ], + }); + + await withController( + { + state: { + selectedNetworkClientId: 'AAAA-AAAA-AAAA-AAAA', + networkConfigurationsByChainId: { + '0x1337': networkConfigurationToUpdate, + }, + }, + }, + async ({ controller, messenger }) => { + const fakeProviders = [ + buildFakeProvider([ + { + request: { + method: 'test', + }, + response: { + result: 'test response from 1', + }, + }, + ]), + buildFakeProvider([ + { + request: { + method: 'test', + }, + response: { + result: 'test response from 2', + }, + }, + ]), + ]; + const fakeNetworkClients = [ + buildFakeClient(fakeProviders[0]), + buildFakeClient(fakeProviders[1]), + ]; + createNetworkClientMock.mockImplementation( + ({ configuration }) => { + if ( + configuration.type === NetworkClientType.Custom && + configuration.rpcUrl === 'https://rpc.endpoint' + ) { + return fakeNetworkClients[0]; + } else if ( + configuration.type === NetworkClientType.Custom && + configuration.rpcUrl === 'https://some.other.url' + ) { + return fakeNetworkClients[1]; + } + throw new Error( + `Unknown network client configuration ${JSON.stringify( + configuration, + )}`, + ); + }, + ); + await controller.lookupNetwork(); + + const promiseForStateChanges = waitForStateChanges({ + messenger, + count: 1, + }); + + await controller.updateNetwork('0x1337', { + ...networkConfigurationToUpdate, + rpcEndpoints: [ + buildCustomRpcEndpoint({ + name: 'Endpoint 1', + networkClientId: 'AAAA-AAAA-AAAA-AAAA', + url: 'https://some.other.url', + }), + ], + }); + const stateChanges = await promiseForStateChanges; + expect(stateChanges).toStrictEqual([ + [ + expect.any(Object), + expect.arrayContaining([ + expect.objectContaining({ + op: 'replace', + path: ['selectedNetworkClientId'], + value: 'BBBB-BBBB-BBBB-BBBB', + }), + expect.objectContaining({ + op: 'replace', + path: ['networkConfigurationsByChainId', '0x1337'], + }), + ]), + ], + ]); + }, + ); + }); + }); + }); + + describe('when all of the RPC endpoints are simply being shuffled', () => { + it('does not touch the network client registry', async () => { + const [rpcEndpoint1, rpcEndpoint2, rpcEndpoint3] = [ + buildCustomRpcEndpoint({ + name: 'Endpoint 1', + networkClientId: 'AAAA-AAAA-AAAA-AAAA', + url: 'https://rpc.endpoint/1', + }), + buildCustomRpcEndpoint({ + name: 'Endpoint 2', + networkClientId: 'BBBB-BBBB-BBBB-BBBB', + url: 'https://rpc.endpoint/2', + }), + buildCustomRpcEndpoint({ + name: 'Endpoint 3', + networkClientId: 'CCCC-CCCC-CCCC-CCCC', + url: 'https://rpc.endpoint/3', + }), + ]; + const networkConfigurationToUpdate = buildCustomNetworkConfiguration({ + chainId: '0x1337', + rpcEndpoints: [rpcEndpoint1, rpcEndpoint2, rpcEndpoint3], + }); + + await withController( + { + state: { + networkConfigurationsByChainId: { + '0x1337': networkConfigurationToUpdate, + '0x9999': buildCustomNetworkConfiguration({ + chainId: '0x9999', + nativeCurrency: 'TEST-9999', + rpcEndpoints: [ + buildCustomRpcEndpoint({ + networkClientId: 'ZZZZ-ZZZZ-ZZZZ-ZZZZ', + url: 'https://selected.endpoint', + }), + ], + }), + }, + selectedNetworkClientId: 'ZZZZ-ZZZZ-ZZZZ-ZZZZ', + }, + }, + async ({ controller }) => { + mockCreateNetworkClient().mockReturnValue(buildFakeClient()); + const networkClientRegistry = + controller.getNetworkClientRegistry(); + + await controller.updateNetwork('0x1337', { + ...networkConfigurationToUpdate, + rpcEndpoints: [rpcEndpoint3, rpcEndpoint1, rpcEndpoint2], + }); + + expect(controller.getNetworkClientRegistry()).toStrictEqual( + networkClientRegistry, + ); + }, + ); + }); + + it('updates the network configuration in state with the new order of RPC endpoints', async () => { + const [rpcEndpoint1, rpcEndpoint2, rpcEndpoint3] = [ + buildCustomRpcEndpoint({ + name: 'Endpoint 1', + networkClientId: 'AAAA-AAAA-AAAA-AAAA', + url: 'https://rpc.endpoint/1', + }), + buildCustomRpcEndpoint({ + name: 'Endpoint 2', + networkClientId: 'BBBB-BBBB-BBBB-BBBB', + url: 'https://rpc.endpoint/2', + }), + buildCustomRpcEndpoint({ + name: 'Endpoint 3', + networkClientId: 'CCCC-CCCC-CCCC-CCCC', + url: 'https://rpc.endpoint/3', + }), + ]; + const networkConfigurationToUpdate = buildCustomNetworkConfiguration({ + chainId: '0x1337', + rpcEndpoints: [rpcEndpoint1, rpcEndpoint2, rpcEndpoint3], + }); + + await withController( + { + state: { + networkConfigurationsByChainId: { + '0x1337': networkConfigurationToUpdate, + '0x9999': buildCustomNetworkConfiguration({ + chainId: '0x9999', + nativeCurrency: 'TEST-9999', + rpcEndpoints: [ + buildCustomRpcEndpoint({ + networkClientId: 'ZZZZ-ZZZZ-ZZZZ-ZZZZ', + url: 'https://selected.endpoint', + }), + ], + }), + }, + selectedNetworkClientId: 'ZZZZ-ZZZZ-ZZZZ-ZZZZ', + }, + }, + async ({ controller }) => { + await controller.updateNetwork('0x1337', { + ...networkConfigurationToUpdate, + rpcEndpoints: [rpcEndpoint3, rpcEndpoint1, rpcEndpoint2], + }); + + expect( + controller.state.networkConfigurationsByChainId['0x1337'], + ).toStrictEqual({ + ...networkConfigurationToUpdate, + rpcEndpoints: [rpcEndpoint3, rpcEndpoint1, rpcEndpoint2], + lastUpdatedAt: FAKE_DATE_NOW_MS, + }); + }, + ); + }); + + it('returns the network configuration with the new order of RPC endpoints', async () => { + const [rpcEndpoint1, rpcEndpoint2, rpcEndpoint3] = [ + buildCustomRpcEndpoint({ + name: 'Endpoint 1', + networkClientId: 'AAAA-AAAA-AAAA-AAAA', + url: 'https://rpc.endpoint/1', + }), + buildCustomRpcEndpoint({ + name: 'Endpoint 2', + networkClientId: 'BBBB-BBBB-BBBB-BBBB', + url: 'https://rpc.endpoint/2', + }), + buildCustomRpcEndpoint({ + name: 'Endpoint 3', + networkClientId: 'CCCC-CCCC-CCCC-CCCC', + url: 'https://rpc.endpoint/3', + }), + ]; + const networkConfigurationToUpdate = buildCustomNetworkConfiguration({ + chainId: '0x1337', + rpcEndpoints: [rpcEndpoint1, rpcEndpoint2, rpcEndpoint3], + }); + + await withController( + { + state: { + networkConfigurationsByChainId: { + '0x1337': networkConfigurationToUpdate, + '0x9999': buildCustomNetworkConfiguration({ + chainId: '0x9999', + nativeCurrency: 'TEST-9999', + rpcEndpoints: [ + buildCustomRpcEndpoint({ + networkClientId: 'ZZZZ-ZZZZ-ZZZZ-ZZZZ', + url: 'https://selected.endpoint', + }), + ], + }), + }, + selectedNetworkClientId: 'ZZZZ-ZZZZ-ZZZZ-ZZZZ', + }, + }, + async ({ controller }) => { + const updatedNetworkConfiguration = + await controller.updateNetwork('0x1337', { + ...networkConfigurationToUpdate, + rpcEndpoints: [rpcEndpoint3, rpcEndpoint1, rpcEndpoint2], + }); + + expect(updatedNetworkConfiguration).toStrictEqual({ + ...networkConfigurationToUpdate, + rpcEndpoints: [rpcEndpoint3, rpcEndpoint1, rpcEndpoint2], + lastUpdatedAt: FAKE_DATE_NOW_MS, + }); + }, + ); + }); + }); + + describe('when the networkClientId of some custom RPC endpoints are being cleared', () => { + it('does not touch the network client registry', async () => { + const [rpcEndpoint1, rpcEndpoint2] = [ + buildCustomRpcEndpoint({ + name: 'Endpoint 1', + networkClientId: 'AAAA-AAAA-AAAA-AAAA', + url: 'https://rpc.endpoint/1', + }), + buildCustomRpcEndpoint({ + name: 'Endpoint 2', + networkClientId: 'BBBB-BBBB-BBBB-BBBB', + url: 'https://rpc.endpoint/2', + }), + ]; + const networkConfigurationToUpdate = buildNetworkConfiguration({ + chainId: '0x1337', + rpcEndpoints: [rpcEndpoint1, rpcEndpoint2], + }); + + await withController( + { + state: { + networkConfigurationsByChainId: { + '0x1337': networkConfigurationToUpdate, + '0x9999': buildCustomNetworkConfiguration({ + chainId: '0x9999', + nativeCurrency: 'TEST-9999', + rpcEndpoints: [ + buildCustomRpcEndpoint({ + networkClientId: 'ZZZZ-ZZZZ-ZZZZ-ZZZZ', + url: 'https://selected.endpoint', + }), + ], + }), + }, + selectedNetworkClientId: 'ZZZZ-ZZZZ-ZZZZ-ZZZZ', + }, + }, + async ({ controller }) => { + mockCreateNetworkClient().mockReturnValue(buildFakeClient()); + const networkClientRegistry = + controller.getNetworkClientRegistry(); + + await controller.updateNetwork('0x1337', { + ...networkConfigurationToUpdate, + rpcEndpoints: [ + rpcEndpoint1, + { ...rpcEndpoint2, networkClientId: undefined }, + ], + }); + + expect(controller.getNetworkClientRegistry()).toStrictEqual( + networkClientRegistry, + ); + }, + ); + }); + + it('does not touch the network configuration in state, as if the network client IDs had not been cleared', async () => { + const [rpcEndpoint1, rpcEndpoint2] = [ + buildCustomRpcEndpoint({ + name: 'Endpoint 1', + networkClientId: 'AAAA-AAAA-AAAA-AAAA', + url: 'https://rpc.endpoint/1', + }), + buildCustomRpcEndpoint({ + name: 'Endpoint 2', + networkClientId: 'BBBB-BBBB-BBBB-BBBB', + url: 'https://rpc.endpoint/2', + }), + ]; + const networkConfigurationToUpdate = buildNetworkConfiguration({ + chainId: '0x1337', + rpcEndpoints: [rpcEndpoint1, rpcEndpoint2], + }); + + await withController( + { + state: { + networkConfigurationsByChainId: { + '0x1337': networkConfigurationToUpdate, + '0x9999': buildCustomNetworkConfiguration({ + chainId: '0x9999', + nativeCurrency: 'TEST-9999', + rpcEndpoints: [ + buildCustomRpcEndpoint({ + networkClientId: 'ZZZZ-ZZZZ-ZZZZ-ZZZZ', + url: 'https://selected.endpoint', + }), + ], + }), + }, + selectedNetworkClientId: 'ZZZZ-ZZZZ-ZZZZ-ZZZZ', + }, + }, + async ({ controller }) => { + const previousNetworkConfigurationsByChainId = + controller.state.networkConfigurationsByChainId; + + await controller.updateNetwork('0x1337', { + ...networkConfigurationToUpdate, + rpcEndpoints: [ + rpcEndpoint1, + { ...rpcEndpoint2, networkClientId: undefined }, + ], + }); + + expect( + controller.state.networkConfigurationsByChainId, + ).toStrictEqual(previousNetworkConfigurationsByChainId); + }, + ); + }); + + it('returns the network configuration, untouched', async () => { + const [rpcEndpoint1, rpcEndpoint2] = [ + buildCustomRpcEndpoint({ + name: 'Endpoint 1', + networkClientId: 'AAAA-AAAA-AAAA-AAAA', + url: 'https://rpc.endpoint/1', + }), + buildCustomRpcEndpoint({ + name: 'Endpoint 2', + networkClientId: 'BBBB-BBBB-BBBB-BBBB', + url: 'https://rpc.endpoint/2', + }), + ]; + const networkConfigurationToUpdate = buildNetworkConfiguration({ + chainId: '0x1337', + rpcEndpoints: [rpcEndpoint1, rpcEndpoint2], + }); + + await withController( + { + state: { + networkConfigurationsByChainId: { + '0x1337': networkConfigurationToUpdate, + '0x9999': buildCustomNetworkConfiguration({ + chainId: '0x9999', + nativeCurrency: 'TEST-9999', + rpcEndpoints: [ + buildCustomRpcEndpoint({ + networkClientId: 'ZZZZ-ZZZZ-ZZZZ-ZZZZ', + url: 'https://selected.endpoint', + }), + ], + }), + }, + selectedNetworkClientId: 'ZZZZ-ZZZZ-ZZZZ-ZZZZ', + }, + }, + async ({ controller }) => { + const updatedNetworkConfiguration = + await controller.updateNetwork('0x1337', { + ...networkConfigurationToUpdate, + rpcEndpoints: [ + rpcEndpoint1, + { ...rpcEndpoint2, networkClientId: undefined }, + ], + }); + + expect(updatedNetworkConfiguration).toStrictEqual( + networkConfigurationToUpdate, + ); + }, + ); + }); + }); + + describe('when no RPC endpoints are being changed', () => { + it('does not touch the network client registry', async () => { + const networkConfigurationToUpdate = buildCustomNetworkConfiguration({ + chainId: '0x1337', + rpcEndpoints: [ + buildCustomRpcEndpoint({ + networkClientId: 'AAAA-AAAA-AAAA-AAAA', + url: 'https://test.endpoint/1', + }), + ], + }); + + await withController( + { + state: { + networkConfigurationsByChainId: { + '0x1337': networkConfigurationToUpdate, + '0x9999': buildCustomNetworkConfiguration({ + chainId: '0x9999', + nativeCurrency: 'TEST-9999', + rpcEndpoints: [ + buildCustomRpcEndpoint({ + networkClientId: 'ZZZZ-ZZZZ-ZZZZ-ZZZZ', + url: 'https://selected.endpoint', + }), + ], + }), + }, + selectedNetworkClientId: 'ZZZZ-ZZZZ-ZZZZ-ZZZZ', + }, + }, + async ({ controller }) => { + mockCreateNetworkClient().mockReturnValue(buildFakeClient()); + const networkClientRegistry = + controller.getNetworkClientRegistry(); + + await controller.updateNetwork('0x1337', { + ...networkConfigurationToUpdate, + name: 'Some Other Name', + }); + + expect(controller.getNetworkClientRegistry()).toStrictEqual( + networkClientRegistry, + ); + }, + ); + }); + }); + }); + + const possibleInfuraNetworkTypes = INFURA_NETWORKS; + possibleInfuraNetworkTypes.forEach( + (infuraNetworkType, infuraNetworkTypeIndex) => { + const infuraNetworkNickname = NetworkNickname[infuraNetworkType]; + const infuraChainId = ChainId[infuraNetworkType]; + const anotherInfuraNetworkType = + possibleInfuraNetworkTypes[ + (infuraNetworkTypeIndex + 1) % possibleInfuraNetworkTypes.length + ]; + const anotherInfuraChainId = ChainId[anotherInfuraNetworkType]; + const anotherInfuraNativeTokenName = + NetworksTicker[anotherInfuraNetworkType]; + const anotherInfuraNetworkNickname = + NetworkNickname[anotherInfuraNetworkType]; + + describe(`if the chain ID is being changed from a non-Infura-supported chain to the Infura-supported chain ${infuraChainId}`, () => { + it(`throws if a network configuration for the Infura network "${infuraNetworkNickname}" is already registered under the new chain ID`, async () => { + const networkConfigurationToUpdate = + buildCustomNetworkConfiguration({ chainId: '0x1337' }); + + await withController( + { + state: { + networkConfigurationsByChainId: { + '0x1337': networkConfigurationToUpdate, + [infuraChainId]: + buildInfuraNetworkConfiguration(infuraNetworkType), + '0x9999': buildCustomNetworkConfiguration({ + chainId: '0x9999', + nativeCurrency: 'TEST-9999', + rpcEndpoints: [ + buildCustomRpcEndpoint({ + networkClientId: 'ZZZZ-ZZZZ-ZZZZ-ZZZZ', + url: 'https://selected.endpoint', + }), + ], + }), + }, + selectedNetworkClientId: 'ZZZZ-ZZZZ-ZZZZ-ZZZZ', + }, + }, + async ({ controller }) => { + await expect( + controller.updateNetwork('0x1337', { + ...networkConfigurationToUpdate, + chainId: infuraChainId, + }), + ).rejects.toThrow( + `Cannot move network from chain 0x1337 to ${infuraChainId} as another network for that chain already exists ('${infuraNetworkNickname}')`, + ); + }, + ); + }); + + it('throws (albeit for a different reason) if an Infura RPC endpoint is being added that represents a different chain than the one being changed to', async () => { + const networkConfigurationToUpdate = + buildCustomNetworkConfiguration({ chainId: '0x1337' }); + + await withController( + { + state: { + networkConfigurationsByChainId: { + '0x1337': networkConfigurationToUpdate, + [anotherInfuraChainId]: buildInfuraNetworkConfiguration( + anotherInfuraNetworkType, + ), + '0x9999': buildCustomNetworkConfiguration({ + chainId: '0x9999', + nativeCurrency: 'TEST-9999', + rpcEndpoints: [ + buildCustomRpcEndpoint({ + networkClientId: 'ZZZZ-ZZZZ-ZZZZ-ZZZZ', + url: 'https://selected.endpoint', + }), + ], + }), + }, + selectedNetworkClientId: 'ZZZZ-ZZZZ-ZZZZ-ZZZZ', + }, + }, + async ({ controller }) => { + await expect( + controller.updateNetwork('0x1337', { + ...networkConfigurationToUpdate, + chainId: infuraChainId, + rpcEndpoints: [ + ...networkConfigurationToUpdate.rpcEndpoints, + buildInfuraRpcEndpoint(anotherInfuraNetworkType), + ], + }), + ).rejects.toThrow( + new Error( + `Could not update network to point to same RPC endpoint as existing network for chain ${anotherInfuraChainId} ('${anotherInfuraNetworkNickname}')`, + ), + ); + }, + ); + }); + + it('re-files the existing network configuration from under the old chain ID to under the new one, regenerating network client IDs for each RPC endpoint', async () => { + uuidV4Mock + .mockReturnValueOnce('CCCC-CCCC-CCCC-CCCC') + .mockReturnValueOnce('DDDD-DDDD-DDDD-DDDD'); + + const [rpcEndpoint1, rpcEndpoint2] = [ + buildCustomRpcEndpoint({ + networkClientId: 'AAAA-AAAA-AAAA-AAAA', + url: 'https://test.endpoint/1', + }), + buildCustomRpcEndpoint({ + networkClientId: 'BBBB-BBBB-BBBB-BBBB', + url: 'https://test.endpoint/2', + }), + ]; + const networkConfigurationToUpdate = buildNetworkConfiguration({ + chainId: '0x1337', + rpcEndpoints: [rpcEndpoint1, rpcEndpoint2], + }); + + await withController( + { + state: { + networkConfigurationsByChainId: { + '0x1337': networkConfigurationToUpdate, + '0x9999': buildCustomNetworkConfiguration({ + chainId: '0x9999', + nativeCurrency: 'TEST-9999', + rpcEndpoints: [ + buildCustomRpcEndpoint({ + networkClientId: 'ZZZZ-ZZZZ-ZZZZ-ZZZZ', + url: 'https://selected.endpoint', + }), + ], + }), + }, + selectedNetworkClientId: 'ZZZZ-ZZZZ-ZZZZ-ZZZZ', + }, + }, + async ({ controller }) => { + createNetworkClientMock.mockImplementation( + ({ configuration }) => { + if ( + configuration.type === NetworkClientType.Custom && + configuration.rpcUrl === 'https://test.endpoint/1' + ) { + return buildFakeClient(); + } + throw new Error( + `Unknown network client configuration ${JSON.stringify( + configuration, + )}`, + ); + }, + ); + + await controller.updateNetwork('0x1337', { + ...networkConfigurationToUpdate, + chainId: infuraChainId, + }); + + expect( + controller.state.networkConfigurationsByChainId, + ).not.toHaveProperty('0x1337'); + expect( + controller.state.networkConfigurationsByChainId, + ).toHaveProperty(infuraChainId); + expect( + controller.state.networkConfigurationsByChainId[ + infuraChainId + ], + ).toStrictEqual({ + ...networkConfigurationToUpdate, + chainId: infuraChainId, + rpcEndpoints: [ + { + ...rpcEndpoint1, + networkClientId: 'CCCC-CCCC-CCCC-CCCC', + }, + { + ...rpcEndpoint2, + networkClientId: 'DDDD-DDDD-DDDD-DDDD', + }, + ], + lastUpdatedAt: FAKE_DATE_NOW_MS, + }); + }, + ); + }); + + it('destroys and unregisters every network client for each of the RPC endpoints (even if none of the endpoint URLs were changed)', async () => { + const networkConfigurationToUpdate = buildNetworkConfiguration({ + chainId: '0x1337', + rpcEndpoints: [ + buildCustomRpcEndpoint({ + name: 'Test Network 1', + networkClientId: 'AAAA-AAAA-AAAA-AAAA', + url: 'https://test.endpoint/1', + }), + buildCustomRpcEndpoint({ + name: 'Test Network 2', + networkClientId: 'BBBB-BBBB-BBBB-BBBB', + url: 'https://test.endpoint/2', + }), + ], + }); + + await withController( + { + state: { + networkConfigurationsByChainId: { + '0x1337': networkConfigurationToUpdate, + '0x9999': buildCustomNetworkConfiguration({ + chainId: '0x9999', + nativeCurrency: 'TEST-9999', + rpcEndpoints: [ + buildCustomRpcEndpoint({ + networkClientId: 'ZZZZ-ZZZZ-ZZZZ-ZZZZ', + url: 'https://selected.endpoint', + }), + ], + }), + }, + selectedNetworkClientId: 'ZZZZ-ZZZZ-ZZZZ-ZZZZ', + }, + }, + async ({ controller }) => { + createNetworkClientMock.mockImplementation( + ({ configuration }) => { + if ( + configuration.type === NetworkClientType.Custom && + configuration.rpcUrl === 'https://test.endpoint/1' + ) { + return buildFakeClient(); + } + throw new Error( + `Unknown network client configuration ${JSON.stringify( + configuration, + )}`, + ); + }, + ); + const existingNetworkClient1 = controller.getNetworkClientById( + 'AAAA-AAAA-AAAA-AAAA', + ); + const destroySpy1 = jest.spyOn( + existingNetworkClient1, + 'destroy', + ); + const existingNetworkClient2 = controller.getNetworkClientById( + 'BBBB-BBBB-BBBB-BBBB', + ); + const destroySpy2 = jest.spyOn( + existingNetworkClient2, + 'destroy', + ); + + await controller.updateNetwork('0x1337', { + ...networkConfigurationToUpdate, + chainId: infuraChainId, + }); + + expect(destroySpy1).toHaveBeenCalled(); + expect(destroySpy2).toHaveBeenCalled(); + const networkClientRegistry = + controller.getNetworkClientRegistry(); + expect(networkClientRegistry).not.toHaveProperty( + 'AAAA-AAAA-AAAA-AAAA', + ); + expect(networkClientRegistry).not.toHaveProperty( + 'BBBB-BBBB-BBBB-BBBB', + ); + }, + ); + }); + + it('creates and registers new network clients for each of the given RPC endpoints', async () => { + uuidV4Mock + .mockReturnValueOnce('CCCC-CCCC-CCCC-CCCC') + .mockReturnValueOnce('DDDD-DDDD-DDDD-DDDD'); + const createAutoManagedNetworkClientSpy = jest.spyOn( + createAutoManagedNetworkClientModule, + 'createAutoManagedNetworkClient', + ); + const networkConfigurationToUpdate = buildNetworkConfiguration({ + chainId: '0x1337', + nativeCurrency: 'TOKEN', + rpcEndpoints: [ + buildCustomRpcEndpoint({ + failoverUrls: ['https://first.failover.endpoint'], + name: 'Test Network 1', + networkClientId: 'AAAA-AAAA-AAAA-AAAA', + url: 'https://test.endpoint/1', + }), + buildCustomRpcEndpoint({ + failoverUrls: ['https://second.failover.endpoint'], + name: 'Test Network 2', + networkClientId: 'BBBB-BBBB-BBBB-BBBB', + url: 'https://test.endpoint/2', + }), + ], + }); + const getRpcServiceOptions = (): Omit< + RpcServiceOptions, + 'failoverService' | 'endpointUrl' + > => ({ + btoa, + fetch, + isOffline: (): boolean => false, + fetchOptions: { + headers: { + 'X-Foo': 'Bar', + }, + }, + policyOptions: { + maxRetries: 2, + maxConsecutiveFailures: 10, + }, + }); + const getBlockTrackerOptions = (): PollingBlockTrackerOptions => ({ + pollingInterval: 2000, + }); + + await withController( + { + state: { + networkConfigurationsByChainId: { + '0x1337': networkConfigurationToUpdate, + '0x9999': buildCustomNetworkConfiguration({ + chainId: '0x9999', + nativeCurrency: 'TEST-9999', + rpcEndpoints: [ + buildCustomRpcEndpoint({ + networkClientId: 'ZZZZ-ZZZZ-ZZZZ-ZZZZ', + url: 'https://selected.endpoint', + }), + ], + }), + }, + selectedNetworkClientId: 'ZZZZ-ZZZZ-ZZZZ-ZZZZ', + }, + getRpcServiceOptions, + getBlockTrackerOptions, + rpcFailoverMode: 'enabled', + }, + async ({ controller, networkControllerMessenger }) => { + createNetworkClientMock.mockImplementation( + ({ configuration }) => { + if ( + configuration.type === NetworkClientType.Custom && + configuration.rpcUrl === 'https://test.endpoint/1' + ) { + return buildFakeClient(); + } + throw new Error( + `Unknown network client configuration ${JSON.stringify( + configuration, + )}`, + ); + }, + ); + + await controller.updateNetwork('0x1337', { + ...networkConfigurationToUpdate, + chainId: infuraChainId, + }); + + expect( + createAutoManagedNetworkClientSpy, + ).toHaveBeenNthCalledWith(4, { + networkClientId: 'CCCC-CCCC-CCCC-CCCC', + networkClientConfiguration: { + chainId: infuraChainId, + failoverRpcUrls: ['https://first.failover.endpoint'], + rpcUrl: 'https://test.endpoint/1', + ticker: 'TOKEN', + type: NetworkClientType.Custom, + }, + getRpcServiceOptions, + getBlockTrackerOptions, + messenger: networkControllerMessenger, + rpcFailoverMode: 'enabled', + }); + expect( + createAutoManagedNetworkClientSpy, + ).toHaveBeenNthCalledWith(5, { + networkClientId: 'DDDD-DDDD-DDDD-DDDD', + networkClientConfiguration: { + chainId: infuraChainId, + failoverRpcUrls: ['https://second.failover.endpoint'], + rpcUrl: 'https://test.endpoint/2', + ticker: 'TOKEN', + type: NetworkClientType.Custom, + }, + getRpcServiceOptions, + getBlockTrackerOptions, + messenger: networkControllerMessenger, + rpcFailoverMode: 'enabled', + }); + + const networkConfigurationsByNetworkClientId = + getNetworkConfigurationsByNetworkClientId( + controller.getNetworkClientRegistry(), + ); + expect( + networkConfigurationsByNetworkClientId['CCCC-CCCC-CCCC-CCCC'], + ).toStrictEqual({ + chainId: infuraChainId, + failoverRpcUrls: ['https://first.failover.endpoint'], + rpcUrl: 'https://test.endpoint/1', + ticker: 'TOKEN', + type: NetworkClientType.Custom, + }); + expect( + networkConfigurationsByNetworkClientId['DDDD-DDDD-DDDD-DDDD'], + ).toStrictEqual({ + chainId: infuraChainId, + failoverRpcUrls: ['https://second.failover.endpoint'], + rpcUrl: 'https://test.endpoint/2', + ticker: 'TOKEN', + type: NetworkClientType.Custom, + }); + }, + ); + }); + + it('returns the updated network configuration', async () => { + uuidV4Mock + .mockReturnValueOnce('CCCC-CCCC-CCCC-CCCC') + .mockReturnValueOnce('DDDD-DDDD-DDDD-DDDD'); + + const [rpcEndpoint1, rpcEndpoint2] = [ + buildCustomRpcEndpoint({ + networkClientId: 'AAAA-AAAA-AAAA-AAAA', + url: 'https://test.endpoint/1', + }), + buildCustomRpcEndpoint({ + networkClientId: 'BBBB-BBBB-BBBB-BBBB', + url: 'https://test.endpoint/2', + }), + ]; + const networkConfigurationToUpdate = buildNetworkConfiguration({ + chainId: '0x1337', + rpcEndpoints: [rpcEndpoint1, rpcEndpoint2], + }); + + await withController( + { + state: { + networkConfigurationsByChainId: { + '0x1337': networkConfigurationToUpdate, + '0x9999': buildCustomNetworkConfiguration({ + chainId: '0x9999', + nativeCurrency: 'TEST-9999', + rpcEndpoints: [ + buildCustomRpcEndpoint({ + networkClientId: 'ZZZZ-ZZZZ-ZZZZ-ZZZZ', + url: 'https://selected.endpoint', + }), + ], + }), + }, + selectedNetworkClientId: 'ZZZZ-ZZZZ-ZZZZ-ZZZZ', + }, + }, + async ({ controller }) => { + createNetworkClientMock.mockImplementation( + ({ configuration }) => { + if ( + configuration.type === NetworkClientType.Custom && + configuration.rpcUrl === 'https://test.endpoint/1' + ) { + return buildFakeClient(); + } + throw new Error( + `Unknown network client configuration ${JSON.stringify( + configuration, + )}`, + ); + }, + ); + + const updatedNetworkConfiguration = + await controller.updateNetwork('0x1337', { + ...networkConfigurationToUpdate, + chainId: infuraChainId, + }); + + expect(updatedNetworkConfiguration).toStrictEqual({ + ...networkConfigurationToUpdate, + chainId: infuraChainId, + rpcEndpoints: [ + { + ...rpcEndpoint1, + networkClientId: 'CCCC-CCCC-CCCC-CCCC', + }, + { + ...rpcEndpoint2, + networkClientId: 'DDDD-DDDD-DDDD-DDDD', + }, + ], + lastUpdatedAt: FAKE_DATE_NOW_MS, + }); + }, + ); + }); + + describe('if one of the RPC endpoints was represented by the selected network client', () => { + it('invisibly selects the network client created for the RPC endpoint', async () => { + uuidV4Mock.mockReturnValueOnce('BBBB-BBBB-BBBB-BBBB'); + const networkConfigurationToUpdate = + buildCustomNetworkConfiguration({ + nativeCurrency: 'TOKEN', + rpcEndpoints: [ + buildCustomRpcEndpoint({ + name: 'Endpoint 1', + networkClientId: 'AAAA-AAAA-AAAA-AAAA', + url: 'https://rpc.endpoint', + }), + ], + }); + + await withController( + { + state: { + selectedNetworkClientId: 'AAAA-AAAA-AAAA-AAAA', + networkConfigurationsByChainId: { + '0x1337': networkConfigurationToUpdate, + }, + }, + }, + async ({ controller }) => { + const fakeProviders = [ + buildFakeProvider([ + { + request: { + method: 'test', + }, + response: { + result: 'test response from 1', + }, + }, + ]), + buildFakeProvider([ + { + request: { + method: 'test', + }, + response: { + result: 'test response from 2', + }, + }, + ]), + ]; + const fakeNetworkClients = [ + buildFakeClient(fakeProviders[0]), + buildFakeClient(fakeProviders[1]), + ]; + createNetworkClientMock.mockImplementation( + ({ configuration }) => { + if (configuration.chainId === '0x1337') { + return fakeNetworkClients[0]; + } else if (configuration.chainId === infuraChainId) { + return fakeNetworkClients[1]; + } + throw new Error( + `Unknown network client configuration ${JSON.stringify( + configuration, + )}`, + ); + }, + ); + await controller.lookupNetwork(); + expect(controller.state.selectedNetworkClientId).toBe( + 'AAAA-AAAA-AAAA-AAAA', + ); + const networkClient1 = controller.getSelectedNetworkClient(); + assert(networkClient1, 'Network client is somehow unset'); + const result1 = await networkClient1.provider.request({ + method: 'test', + }); + expect(result1).toBe('test response from 1'); + + await controller.updateNetwork('0x1337', { + ...networkConfigurationToUpdate, + chainId: infuraChainId, + }); + + expect(controller.state.selectedNetworkClientId).toBe( + 'BBBB-BBBB-BBBB-BBBB', + ); + const networkClient2 = controller.getSelectedNetworkClient(); + assert(networkClient2, 'Network client is somehow unset'); + const result2 = await networkClient1.provider.request({ + method: 'test', + }); + expect(result2).toBe('test response from 2'); + }, + ); + }); + + it('updates selectedNetworkClientId and networkConfigurationsByChainId at the same time', async () => { + uuidV4Mock.mockReturnValueOnce('BBBB-BBBB-BBBB-BBBB'); + const networkConfigurationToUpdate = + buildCustomNetworkConfiguration({ + nativeCurrency: 'TOKEN', + rpcEndpoints: [ + buildCustomRpcEndpoint({ + name: 'Endpoint 1', + networkClientId: 'AAAA-AAAA-AAAA-AAAA', + url: 'https://rpc.endpoint', + }), + ], + }); + + await withController( + { + state: { + selectedNetworkClientId: 'AAAA-AAAA-AAAA-AAAA', + networkConfigurationsByChainId: { + '0x1337': networkConfigurationToUpdate, + }, + }, + }, + async ({ controller, messenger }) => { + const fakeProviders = [ + buildFakeProvider([ + { + request: { + method: 'test', + }, + response: { + result: 'test response from 1', + }, + }, + ]), + buildFakeProvider([ + { + request: { + method: 'test', + }, + response: { + result: 'test response from 2', + }, + }, + ]), + ]; + const fakeNetworkClients = [ + buildFakeClient(fakeProviders[0]), + buildFakeClient(fakeProviders[1]), + ]; + createNetworkClientMock.mockImplementation( + ({ configuration }) => { + if (configuration.chainId === '0x1337') { + return fakeNetworkClients[0]; + } else if (configuration.chainId === infuraChainId) { + return fakeNetworkClients[1]; + } + throw new Error( + `Unknown network client configuration ${JSON.stringify( + configuration, + )}`, + ); + }, + ); + await controller.lookupNetwork(); + + const promiseForStateChanges = waitForStateChanges({ + messenger, + count: 1, + }); + + await controller.updateNetwork('0x1337', { + ...networkConfigurationToUpdate, + chainId: infuraChainId, + }); + const stateChanges = await promiseForStateChanges; + expect(stateChanges).toStrictEqual([ + [ + expect.any(Object), + expect.arrayContaining([ + expect.objectContaining({ + op: 'replace', + path: ['selectedNetworkClientId'], + value: 'BBBB-BBBB-BBBB-BBBB', + }), + expect.objectContaining({ + op: 'remove', + path: ['networkConfigurationsByChainId', '0x1337'], + }), + expect.objectContaining({ + op: 'add', + path: [ + 'networkConfigurationsByChainId', + infuraChainId, + ], + }), + ]), + ], + ]); + }, + ); + }); + }); + }); + + describe(`if the chain ID is being changed from the Infura-supported chain ${infuraChainId} to a non-Infura-supported chain`, () => { + it('throws if a network configuration for a custom network is already registered under the new chain ID', async () => { + const networkConfigurationToUpdate = + buildInfuraNetworkConfiguration(infuraNetworkType); + + await withController( + { + state: { + networkConfigurationsByChainId: { + [infuraChainId]: networkConfigurationToUpdate, + '0x1337': buildCustomNetworkConfiguration({ + chainId: '0x1337', + name: 'Some Network', + }), + '0x9999': buildCustomNetworkConfiguration({ + chainId: '0x9999', + nativeCurrency: 'TEST-9999', + rpcEndpoints: [ + buildCustomRpcEndpoint({ + networkClientId: 'ZZZZ-ZZZZ-ZZZZ-ZZZZ', + url: 'https://selected.endpoint', + }), + ], + }), + }, + selectedNetworkClientId: 'ZZZZ-ZZZZ-ZZZZ-ZZZZ', + }, + }, + async ({ controller }) => { + await expect( + controller.updateNetwork(infuraChainId, { + ...networkConfigurationToUpdate, + chainId: '0x1337', + }), + ).rejects.toThrow( + `Cannot move network from chain ${infuraChainId} to 0x1337 as another network for that chain already exists ('Some Network')`, + ); + }, + ); + }); + + it('re-files the existing network configuration from under the old chain ID to under the new one, regenerating network client IDs for each custom RPC endpoint', async () => { + uuidV4Mock + .mockReturnValueOnce('CCCC-CCCC-CCCC-CCCC') + .mockReturnValueOnce('DDDD-DDDD-DDDD-DDDD'); + + const [defaultRpcEndpoint, customRpcEndpoint1, customRpcEndpoint2] = + [ + buildInfuraRpcEndpoint(infuraNetworkType), + buildCustomRpcEndpoint({ + networkClientId: 'AAAA-AAAA-AAAA-AAAA', + url: 'https://test.endpoint/1', + }), + buildCustomRpcEndpoint({ + networkClientId: 'BBBB-BBBB-BBBB-BBBB', + url: 'https://test.endpoint/2', + }), + ]; + const networkConfigurationToUpdate = + buildInfuraNetworkConfiguration(infuraNetworkType, { + rpcEndpoints: [ + defaultRpcEndpoint, + customRpcEndpoint1, + customRpcEndpoint2, + ], + }); + + await withController( + { + state: { + networkConfigurationsByChainId: { + [infuraChainId]: networkConfigurationToUpdate, + '0x9999': buildCustomNetworkConfiguration({ + chainId: '0x9999', + nativeCurrency: 'TEST-9999', + rpcEndpoints: [ + buildCustomRpcEndpoint({ + networkClientId: 'ZZZZ-ZZZZ-ZZZZ-ZZZZ', + url: 'https://selected.endpoint', + }), + ], + }), + }, + selectedNetworkClientId: 'ZZZZ-ZZZZ-ZZZZ-ZZZZ', + }, + }, + async ({ controller }) => { + createNetworkClientMock.mockImplementation( + ({ configuration }) => { + if ( + configuration.type === NetworkClientType.Custom && + configuration.rpcUrl === 'https://test.endpoint/1' + ) { + return buildFakeClient(); + } + throw new Error( + `Unknown network client configuration ${JSON.stringify( + configuration, + )}`, + ); + }, + ); + + await controller.updateNetwork( + infuraChainId, + { + ...networkConfigurationToUpdate, + chainId: '0x1337', + defaultRpcEndpointIndex: 0, + nativeCurrency: 'TOKEN', + rpcEndpoints: [customRpcEndpoint1, customRpcEndpoint2], + }, + { replacementSelectedRpcEndpointIndex: 0 }, + ); + + expect( + controller.state.networkConfigurationsByChainId, + ).not.toHaveProperty(infuraChainId); + expect( + controller.state.networkConfigurationsByChainId, + ).toHaveProperty('0x1337'); + expect( + controller.state.networkConfigurationsByChainId['0x1337'], + ).toStrictEqual({ + ...networkConfigurationToUpdate, + chainId: '0x1337', + defaultRpcEndpointIndex: 0, + nativeCurrency: 'TOKEN', + rpcEndpoints: [ + { + ...customRpcEndpoint1, + networkClientId: 'CCCC-CCCC-CCCC-CCCC', + }, + { + ...customRpcEndpoint2, + networkClientId: 'DDDD-DDDD-DDDD-DDDD', + }, + ], + lastUpdatedAt: FAKE_DATE_NOW_MS, + }); + }, + ); + }); + + it('destroys and unregisters every network client for each of the custom RPC endpoints (even if none of the endpoint URLs were changed)', async () => { + const [defaultRpcEndpoint, customRpcEndpoint1, customRpcEndpoint2] = + [ + buildInfuraRpcEndpoint(infuraNetworkType), + buildCustomRpcEndpoint({ + networkClientId: 'AAAA-AAAA-AAAA-AAAA', + url: 'https://test.endpoint/1', + }), + buildCustomRpcEndpoint({ + networkClientId: 'BBBB-BBBB-BBBB-BBBB', + url: 'https://test.endpoint/2', + }), + ]; + const networkConfigurationToUpdate = + buildInfuraNetworkConfiguration(infuraNetworkType, { + rpcEndpoints: [ + defaultRpcEndpoint, + customRpcEndpoint1, + customRpcEndpoint2, + ], + }); + + await withController( + { + state: { + networkConfigurationsByChainId: { + [infuraChainId]: networkConfigurationToUpdate, + '0x9999': buildCustomNetworkConfiguration({ + chainId: '0x9999', + nativeCurrency: 'TEST-9999', + rpcEndpoints: [ + buildCustomRpcEndpoint({ + networkClientId: 'ZZZZ-ZZZZ-ZZZZ-ZZZZ', + url: 'https://selected.endpoint', + }), + ], + }), + }, + selectedNetworkClientId: 'ZZZZ-ZZZZ-ZZZZ-ZZZZ', + }, + }, + async ({ controller }) => { + createNetworkClientMock.mockImplementation( + ({ configuration }) => { + if ( + configuration.type === NetworkClientType.Custom && + configuration.rpcUrl === 'https://test.endpoint/1' + ) { + return buildFakeClient(); + } + throw new Error( + `Unknown network client configuration ${JSON.stringify( + configuration, + )}`, + ); + }, + ); + const existingNetworkClient1 = controller.getNetworkClientById( + 'AAAA-AAAA-AAAA-AAAA', + ); + const destroySpy1 = jest.spyOn( + existingNetworkClient1, + 'destroy', + ); + const existingNetworkClient2 = controller.getNetworkClientById( + 'BBBB-BBBB-BBBB-BBBB', + ); + const destroySpy2 = jest.spyOn( + existingNetworkClient2, + 'destroy', + ); + + await controller.updateNetwork( + infuraChainId, + { + ...networkConfigurationToUpdate, + chainId: '0x1337', + defaultRpcEndpointIndex: 0, + nativeCurrency: 'TOKEN', + rpcEndpoints: [customRpcEndpoint1, customRpcEndpoint2], + }, + { replacementSelectedRpcEndpointIndex: 0 }, + ); + + expect(destroySpy1).toHaveBeenCalled(); + expect(destroySpy2).toHaveBeenCalled(); + const networkClientRegistry = + controller.getNetworkClientRegistry(); + expect(networkClientRegistry).not.toHaveProperty( + 'AAAA-AAAA-AAAA-AAAA', + ); + expect(networkClientRegistry).not.toHaveProperty( + 'BBBB-BBBB-BBBB-BBBB', + ); + }, + ); + }); + + it('creates and registers new network clients for each of the given custom RPC endpoints', async () => { + uuidV4Mock + .mockReturnValueOnce('CCCC-CCCC-CCCC-CCCC') + .mockReturnValueOnce('DDDD-DDDD-DDDD-DDDD'); + + const createAutoManagedNetworkClientSpy = jest.spyOn( + createAutoManagedNetworkClientModule, + 'createAutoManagedNetworkClient', + ); + + const [defaultRpcEndpoint, customRpcEndpoint1, customRpcEndpoint2] = + [ + buildInfuraRpcEndpoint(infuraNetworkType), + buildCustomRpcEndpoint({ + failoverUrls: ['https://first.failover.endpoint'], + networkClientId: 'AAAA-AAAA-AAAA-AAAA', + url: 'https://test.endpoint/1', + }), + buildCustomRpcEndpoint({ + failoverUrls: ['https://second.failover.endpoint'], + networkClientId: 'BBBB-BBBB-BBBB-BBBB', + url: 'https://test.endpoint/2', + }), + ]; + const networkConfigurationToUpdate = + buildInfuraNetworkConfiguration(infuraNetworkType, { + nativeCurrency: 'ETH', + rpcEndpoints: [ + defaultRpcEndpoint, + customRpcEndpoint1, + customRpcEndpoint2, + ], + }); + const getRpcServiceOptions = (): Omit< + RpcServiceOptions, + 'failoverService' | 'endpointUrl' + > => ({ + btoa, + fetch, + isOffline: (): boolean => false, + fetchOptions: { + headers: { + 'X-Foo': 'Bar', + }, + }, + policyOptions: { + maxRetries: 2, + maxConsecutiveFailures: 10, + }, + }); + const getBlockTrackerOptions = (): PollingBlockTrackerOptions => ({ + pollingInterval: 2000, + }); + + await withController( + { + state: { + networkConfigurationsByChainId: { + [infuraChainId]: networkConfigurationToUpdate, + '0x9999': buildCustomNetworkConfiguration({ + chainId: '0x9999', + nativeCurrency: 'TEST-9999', + rpcEndpoints: [ + buildCustomRpcEndpoint({ + networkClientId: 'ZZZZ-ZZZZ-ZZZZ-ZZZZ', + url: 'https://selected.endpoint', + }), + ], + }), + }, + selectedNetworkClientId: 'ZZZZ-ZZZZ-ZZZZ-ZZZZ', + }, + getRpcServiceOptions, + getBlockTrackerOptions, + rpcFailoverMode: 'enabled', + }, + async ({ controller, networkControllerMessenger }) => { + createNetworkClientMock.mockImplementation( + ({ configuration }) => { + if ( + configuration.type === NetworkClientType.Custom && + configuration.rpcUrl === 'https://test.endpoint/1' + ) { + return buildFakeClient(); + } + throw new Error( + `Unknown network client configuration ${JSON.stringify( + configuration, + )}`, + ); + }, + ); + + await controller.updateNetwork( + infuraChainId, + { + ...networkConfigurationToUpdate, + chainId: '0x1337', + defaultRpcEndpointIndex: 0, + nativeCurrency: 'TOKEN', + rpcEndpoints: [customRpcEndpoint1, customRpcEndpoint2], + }, + { replacementSelectedRpcEndpointIndex: 0 }, + ); + + expect(createAutoManagedNetworkClientSpy).toHaveBeenCalledWith({ + networkClientId: 'CCCC-CCCC-CCCC-CCCC', + networkClientConfiguration: { + chainId: '0x1337', + failoverRpcUrls: ['https://first.failover.endpoint'], + rpcUrl: 'https://test.endpoint/1', + ticker: 'TOKEN', + type: NetworkClientType.Custom, + }, + getRpcServiceOptions, + getBlockTrackerOptions, + messenger: networkControllerMessenger, + rpcFailoverMode: 'enabled', + }); + expect(createAutoManagedNetworkClientSpy).toHaveBeenCalledWith({ + networkClientId: 'DDDD-DDDD-DDDD-DDDD', + networkClientConfiguration: { + chainId: '0x1337', + failoverRpcUrls: ['https://second.failover.endpoint'], + rpcUrl: 'https://test.endpoint/2', + ticker: 'TOKEN', + type: NetworkClientType.Custom, + }, + getRpcServiceOptions, + getBlockTrackerOptions, + messenger: networkControllerMessenger, + rpcFailoverMode: 'enabled', + }); + + expect( + getNetworkConfigurationsByNetworkClientId( + controller.getNetworkClientRegistry(), + ), + ).toMatchObject({ + 'CCCC-CCCC-CCCC-CCCC': { + chainId: '0x1337', + failoverRpcUrls: ['https://first.failover.endpoint'], + rpcUrl: 'https://test.endpoint/1', + ticker: 'TOKEN', + type: NetworkClientType.Custom, + }, + 'DDDD-DDDD-DDDD-DDDD': { + chainId: '0x1337', + failoverRpcUrls: ['https://second.failover.endpoint'], + rpcUrl: 'https://test.endpoint/2', + ticker: 'TOKEN', + type: NetworkClientType.Custom, + }, + }); + }, + ); + }); + + it('returns the updated network configuration', async () => { + uuidV4Mock + .mockReturnValueOnce('CCCC-CCCC-CCCC-CCCC') + .mockReturnValueOnce('DDDD-DDDD-DDDD-DDDD'); + + const [defaultRpcEndpoint, customRpcEndpoint1, customRpcEndpoint2] = + [ + buildInfuraRpcEndpoint(infuraNetworkType), + buildCustomRpcEndpoint({ + networkClientId: 'AAAA-AAAA-AAAA-AAAA', + url: 'https://test.endpoint/1', + }), + buildCustomRpcEndpoint({ + networkClientId: 'BBBB-BBBB-BBBB-BBBB', + url: 'https://test.endpoint/2', + }), + ]; + const networkConfigurationToUpdate = + buildInfuraNetworkConfiguration(infuraNetworkType, { + rpcEndpoints: [ + defaultRpcEndpoint, + customRpcEndpoint1, + customRpcEndpoint2, + ], + }); + + await withController( + { + state: { + networkConfigurationsByChainId: { + [infuraChainId]: networkConfigurationToUpdate, + '0x9999': buildCustomNetworkConfiguration({ + chainId: '0x9999', + nativeCurrency: 'TEST-9999', + rpcEndpoints: [ + buildCustomRpcEndpoint({ + networkClientId: 'ZZZZ-ZZZZ-ZZZZ-ZZZZ', + url: 'https://selected.endpoint', + }), + ], + }), + }, + selectedNetworkClientId: 'ZZZZ-ZZZZ-ZZZZ-ZZZZ', + }, + }, + async ({ controller }) => { + createNetworkClientMock.mockImplementation( + ({ configuration }) => { + if ( + configuration.type === NetworkClientType.Custom && + configuration.rpcUrl === 'https://test.endpoint/1' + ) { + return buildFakeClient(); + } + throw new Error( + `Unknown network client configuration ${JSON.stringify( + configuration, + )}`, + ); + }, + ); + + const updatedNetworkConfiguration = + await controller.updateNetwork( + infuraChainId, + { + ...networkConfigurationToUpdate, + chainId: '0x1337', + defaultRpcEndpointIndex: 0, + nativeCurrency: 'TOKEN', + rpcEndpoints: [customRpcEndpoint1, customRpcEndpoint2], + }, + { replacementSelectedRpcEndpointIndex: 0 }, + ); + + expect(updatedNetworkConfiguration).toStrictEqual({ + ...networkConfigurationToUpdate, + chainId: '0x1337', + defaultRpcEndpointIndex: 0, + nativeCurrency: 'TOKEN', + rpcEndpoints: [ + { + ...customRpcEndpoint1, + networkClientId: 'CCCC-CCCC-CCCC-CCCC', + }, + { + ...customRpcEndpoint2, + networkClientId: 'DDDD-DDDD-DDDD-DDDD', + }, + ], + lastUpdatedAt: FAKE_DATE_NOW_MS, + }); + }, + ); + }); + + describe('if one of the RPC endpoints was represented by the selected network client', () => { + it('invisibly selects the network client created for the RPC endpoint', async () => { + uuidV4Mock.mockReturnValueOnce('BBBB-BBBB-BBBB-BBBB'); + const networkConfigurationToUpdate = + buildInfuraNetworkConfiguration(infuraNetworkType, { + nativeCurrency: 'TOKEN', + rpcEndpoints: [ + buildCustomRpcEndpoint({ + name: 'Endpoint 1', + networkClientId: 'AAAA-AAAA-AAAA-AAAA', + url: 'https://rpc.endpoint', + }), + ], + }); + + await withController( + { + state: { + selectedNetworkClientId: 'AAAA-AAAA-AAAA-AAAA', + networkConfigurationsByChainId: { + [infuraChainId]: networkConfigurationToUpdate, + }, + }, + }, + async ({ controller }) => { + const fakeProviders = [ + buildFakeProvider([ + { + request: { + method: 'test', + }, + response: { + result: 'test response from 1', + }, + }, + ]), + buildFakeProvider([ + { + request: { + method: 'test', + }, + response: { + result: 'test response from 2', + }, + }, + ]), + ]; + const fakeNetworkClients = [ + buildFakeClient(fakeProviders[0]), + buildFakeClient(fakeProviders[1]), + ]; + createNetworkClientMock.mockImplementation( + ({ configuration }) => { + if (configuration.chainId === infuraChainId) { + return fakeNetworkClients[0]; + } else if (configuration.chainId === '0x1337') { + return fakeNetworkClients[1]; + } + throw new Error( + `Unknown network client configuration ${JSON.stringify( + configuration, + )}`, + ); + }, + ); + await controller.lookupNetwork(); + expect(controller.state.selectedNetworkClientId).toBe( + 'AAAA-AAAA-AAAA-AAAA', + ); + const networkClient1 = controller.getSelectedNetworkClient(); + assert(networkClient1, 'Network client is somehow unset'); + const result1 = await networkClient1.provider.request({ + method: 'test', + }); + expect(result1).toBe('test response from 1'); + + await controller.updateNetwork(infuraChainId, { + ...networkConfigurationToUpdate, + chainId: '0x1337', + }); + + expect(controller.state.selectedNetworkClientId).toBe( + 'BBBB-BBBB-BBBB-BBBB', + ); + const networkClient2 = controller.getSelectedNetworkClient(); + assert(networkClient2, 'Network client is somehow unset'); + const result2 = await networkClient1.provider.request({ + method: 'test', + }); + expect(result2).toBe('test response from 2'); + }, + ); + }); + + it('updates selectedNetworkClientId and networkConfigurationsByChainId at the same time', async () => { + uuidV4Mock.mockReturnValueOnce('BBBB-BBBB-BBBB-BBBB'); + const networkConfigurationToUpdate = + buildInfuraNetworkConfiguration(infuraNetworkType, { + nativeCurrency: 'TOKEN', + rpcEndpoints: [ + buildCustomRpcEndpoint({ + name: 'Endpoint 1', + networkClientId: 'AAAA-AAAA-AAAA-AAAA', + url: 'https://rpc.endpoint', + }), + ], + }); + + await withController( + { + state: { + selectedNetworkClientId: 'AAAA-AAAA-AAAA-AAAA', + networkConfigurationsByChainId: { + [infuraChainId]: networkConfigurationToUpdate, + }, + }, + }, + async ({ controller, messenger }) => { + const fakeProviders = [ + buildFakeProvider([ + { + request: { + method: 'test', + }, + response: { + result: 'test response from 1', + }, + }, + ]), + buildFakeProvider([ + { + request: { + method: 'test', + }, + response: { + result: 'test response from 2', + }, + }, + ]), + ]; + const fakeNetworkClients = [ + buildFakeClient(fakeProviders[0]), + buildFakeClient(fakeProviders[1]), + ]; + createNetworkClientMock.mockImplementation( + ({ configuration }) => { + if (configuration.chainId === infuraChainId) { + return fakeNetworkClients[0]; + } else if (configuration.chainId === '0x1337') { + return fakeNetworkClients[1]; + } + throw new Error( + `Unknown network client configuration ${JSON.stringify( + configuration, + )}`, + ); + }, + ); + await controller.lookupNetwork(); + + const promiseForStateChanges = waitForStateChanges({ + messenger, + count: 1, + }); + + await controller.updateNetwork(infuraChainId, { + ...networkConfigurationToUpdate, + chainId: '0x1337', + }); + const stateChanges = await promiseForStateChanges; + expect(stateChanges).toStrictEqual([ + [ + expect.any(Object), + expect.arrayContaining([ + expect.objectContaining({ + op: 'replace', + path: ['selectedNetworkClientId'], + value: 'BBBB-BBBB-BBBB-BBBB', + }), + expect.objectContaining({ + op: 'remove', + path: [ + 'networkConfigurationsByChainId', + infuraChainId, + ], + }), + expect.objectContaining({ + op: 'add', + path: ['networkConfigurationsByChainId', '0x1337'], + }), + ]), + ], + ]); + }, + ); + }); + }); + }); + + describe(`if the chain ID is being changed from the Infura-supported chain ${infuraChainId} to a different Infura-supported chain ${anotherInfuraChainId}`, () => { + it(`throws if a network configuration for the Infura network "${infuraNetworkNickname}" is already registered under the new chain ID`, async () => { + const networkConfigurationToUpdate = + buildInfuraNetworkConfiguration(infuraNetworkType); + + await withController( + { + state: { + networkConfigurationsByChainId: { + [infuraChainId]: networkConfigurationToUpdate, + [anotherInfuraChainId]: buildInfuraNetworkConfiguration( + anotherInfuraNetworkType, + ), + '0x9999': buildCustomNetworkConfiguration({ + chainId: '0x9999', + nativeCurrency: 'TEST-9999', + rpcEndpoints: [ + buildCustomRpcEndpoint({ + networkClientId: 'ZZZZ-ZZZZ-ZZZZ-ZZZZ', + url: 'https://selected.endpoint', + }), + ], + }), + }, + selectedNetworkClientId: 'ZZZZ-ZZZZ-ZZZZ-ZZZZ', + }, + }, + async ({ controller }) => { + await expect( + controller.updateNetwork(infuraChainId, { + ...networkConfigurationToUpdate, + chainId: anotherInfuraChainId, + }), + ).rejects.toThrow( + `Cannot move network from chain ${infuraChainId} to ${anotherInfuraChainId} as another network for that chain already exists ('${anotherInfuraNetworkNickname}')`, + ); + }, + ); + }); + + it('re-files the existing network configuration from under the old chain ID to under the new one, regenerating network client IDs for each custom RPC endpoint', async () => { + uuidV4Mock + .mockReturnValueOnce('CCCC-CCCC-CCCC-CCCC') + .mockReturnValueOnce('DDDD-DDDD-DDDD-DDDD'); + + const [defaultRpcEndpoint, customRpcEndpoint1, customRpcEndpoint2] = + [ + buildInfuraRpcEndpoint(infuraNetworkType), + buildCustomRpcEndpoint({ + networkClientId: 'AAAA-AAAA-AAAA-AAAA', + url: 'https://test.endpoint/1', + }), + buildCustomRpcEndpoint({ + networkClientId: 'BBBB-BBBB-BBBB-BBBB', + url: 'https://test.endpoint/2', + }), + ]; + const networkConfigurationToUpdate = + buildInfuraNetworkConfiguration(infuraNetworkType, { + rpcEndpoints: [ + defaultRpcEndpoint, + customRpcEndpoint1, + customRpcEndpoint2, + ], + }); + + await withController( + { + state: { + networkConfigurationsByChainId: { + [infuraChainId]: networkConfigurationToUpdate, + '0x9999': buildCustomNetworkConfiguration({ + chainId: '0x9999', + nativeCurrency: 'TEST-9999', + rpcEndpoints: [ + buildCustomRpcEndpoint({ + networkClientId: 'ZZZZ-ZZZZ-ZZZZ-ZZZZ', + url: 'https://selected.endpoint', + }), + ], + }), + }, + selectedNetworkClientId: 'ZZZZ-ZZZZ-ZZZZ-ZZZZ', + }, + infuraProjectId: 'some-infura-project-id', + }, + async ({ controller }) => { + createNetworkClientMock.mockImplementation( + ({ configuration }) => { + if (configuration.chainId === anotherInfuraChainId) { + return buildFakeClient(); + } + throw new Error( + `Unknown network client configuration ${JSON.stringify( + configuration, + )}`, + ); + }, + ); + + const anotherInfuraRpcEndpoint = buildInfuraRpcEndpoint( + anotherInfuraNetworkType, + ); + await controller.updateNetwork(infuraChainId, { + ...networkConfigurationToUpdate, + chainId: anotherInfuraChainId, + defaultRpcEndpointIndex: 0, + nativeCurrency: anotherInfuraNativeTokenName, + rpcEndpoints: [ + anotherInfuraRpcEndpoint, + customRpcEndpoint1, + customRpcEndpoint2, + ], + }); + + expect( + controller.state.networkConfigurationsByChainId, + ).not.toHaveProperty(infuraChainId); + expect( + controller.state.networkConfigurationsByChainId, + ).toHaveProperty(anotherInfuraChainId); + expect( + controller.state.networkConfigurationsByChainId[ + anotherInfuraChainId + ], + ).toStrictEqual({ + ...networkConfigurationToUpdate, + chainId: anotherInfuraChainId, + nativeCurrency: anotherInfuraNativeTokenName, + rpcEndpoints: [ + anotherInfuraRpcEndpoint, + { + ...customRpcEndpoint1, + networkClientId: 'CCCC-CCCC-CCCC-CCCC', + }, + { + ...customRpcEndpoint2, + networkClientId: 'DDDD-DDDD-DDDD-DDDD', + }, + ], + lastUpdatedAt: FAKE_DATE_NOW_MS, + }); + }, + ); + }); + + it('destroys and unregisters every network client for each of the custom RPC endpoints (even if none of the endpoint URLs were changed)', async () => { + uuidV4Mock + .mockReturnValueOnce('CCCC-CCCC-CCCC-CCCC') + .mockReturnValueOnce('DDDD-DDDD-DDDD-DDDD'); + + const [defaultRpcEndpoint, customRpcEndpoint1, customRpcEndpoint2] = + [ + buildInfuraRpcEndpoint(infuraNetworkType), + buildCustomRpcEndpoint({ + networkClientId: 'AAAA-AAAA-AAAA-AAAA', + url: 'https://test.endpoint/1', + }), + buildCustomRpcEndpoint({ + networkClientId: 'BBBB-BBBB-BBBB-BBBB', + url: 'https://test.endpoint/2', + }), + ]; + const networkConfigurationToUpdate = + buildInfuraNetworkConfiguration(infuraNetworkType, { + rpcEndpoints: [ + defaultRpcEndpoint, + customRpcEndpoint1, + customRpcEndpoint2, + ], + }); + + await withController( + { + state: { + networkConfigurationsByChainId: { + [infuraChainId]: networkConfigurationToUpdate, + '0x9999': buildCustomNetworkConfiguration({ + chainId: '0x9999', + nativeCurrency: 'TEST-9999', + rpcEndpoints: [ + buildCustomRpcEndpoint({ + networkClientId: 'ZZZZ-ZZZZ-ZZZZ-ZZZZ', + url: 'https://selected.endpoint', + }), + ], + }), + }, + selectedNetworkClientId: 'ZZZZ-ZZZZ-ZZZZ-ZZZZ', + }, + infuraProjectId: 'some-infura-project-id', + }, + async ({ controller }) => { + createNetworkClientMock.mockImplementation( + ({ configuration }) => { + if (configuration.chainId === anotherInfuraChainId) { + return buildFakeClient(); + } + throw new Error( + `Unknown network client configuration ${JSON.stringify( + configuration, + )}`, + ); + }, + ); + const existingNetworkClient1 = controller.getNetworkClientById( + 'AAAA-AAAA-AAAA-AAAA', + ); + const destroySpy1 = jest.spyOn( + existingNetworkClient1, + 'destroy', + ); + const existingNetworkClient2 = controller.getNetworkClientById( + 'BBBB-BBBB-BBBB-BBBB', + ); + const destroySpy2 = jest.spyOn( + existingNetworkClient2, + 'destroy', + ); + + await controller.updateNetwork(infuraChainId, { + ...networkConfigurationToUpdate, + chainId: anotherInfuraChainId, + defaultRpcEndpointIndex: 0, + nativeCurrency: anotherInfuraNativeTokenName, + rpcEndpoints: [ + buildInfuraRpcEndpoint(anotherInfuraNetworkType), + customRpcEndpoint1, + customRpcEndpoint2, + ], + }); + + expect(destroySpy1).toHaveBeenCalled(); + expect(destroySpy2).toHaveBeenCalled(); + const networkClientRegistry = + controller.getNetworkClientRegistry(); + expect(networkClientRegistry).not.toHaveProperty( + 'AAAA-AAAA-AAAA-AAAA', + ); + expect(networkClientRegistry).not.toHaveProperty( + 'BBBB-BBBB-BBBB-BBBB', + ); + }, + ); + }); + + it('creates and registers new network clients for each of the given custom RPC endpoints', async () => { + uuidV4Mock + .mockReturnValueOnce('CCCC-CCCC-CCCC-CCCC') + .mockReturnValueOnce('DDDD-DDDD-DDDD-DDDD'); + const createAutoManagedNetworkClientSpy = jest.spyOn( + createAutoManagedNetworkClientModule, + 'createAutoManagedNetworkClient', + ); + const [defaultRpcEndpoint, customRpcEndpoint1, customRpcEndpoint2] = + [ + buildInfuraRpcEndpoint(infuraNetworkType), + buildCustomRpcEndpoint({ + failoverUrls: ['https://first.failover.endpoint'], + networkClientId: 'AAAA-AAAA-AAAA-AAAA', + url: 'https://test.endpoint/1', + }), + buildCustomRpcEndpoint({ + failoverUrls: ['https://second.failover.endpoint'], + networkClientId: 'BBBB-BBBB-BBBB-BBBB', + url: 'https://test.endpoint/2', + }), + ]; + const networkConfigurationToUpdate = + buildInfuraNetworkConfiguration(infuraNetworkType, { + nativeCurrency: 'ETH', + rpcEndpoints: [ + defaultRpcEndpoint, + customRpcEndpoint1, + customRpcEndpoint2, + ], + }); + const getRpcServiceOptions = (): Omit< + RpcServiceOptions, + 'failoverService' | 'endpointUrl' + > => ({ + btoa, + fetch, + isOffline: (): boolean => false, + fetchOptions: { + headers: { + 'X-Foo': 'Bar', + }, + }, + policyOptions: { + maxRetries: 2, + maxConsecutiveFailures: 10, + }, + }); + const getBlockTrackerOptions = (): PollingBlockTrackerOptions => ({ + pollingInterval: 2000, + }); + + await withController( + { + state: { + networkConfigurationsByChainId: { + [infuraChainId]: networkConfigurationToUpdate, + '0x9999': buildCustomNetworkConfiguration({ + chainId: '0x9999', + nativeCurrency: 'TEST-9999', + rpcEndpoints: [ + buildCustomRpcEndpoint({ + networkClientId: 'ZZZZ-ZZZZ-ZZZZ-ZZZZ', + url: 'https://selected.endpoint', + }), + ], + }), + }, + selectedNetworkClientId: 'ZZZZ-ZZZZ-ZZZZ-ZZZZ', + }, + infuraProjectId: 'some-infura-project-id', + getRpcServiceOptions, + getBlockTrackerOptions, + rpcFailoverMode: 'enabled', + }, + async ({ controller, networkControllerMessenger }) => { + createNetworkClientMock.mockImplementation( + ({ configuration }) => { + if (configuration.chainId === anotherInfuraChainId) { + return buildFakeClient(); + } + throw new Error( + `Unknown network client configuration ${JSON.stringify( + configuration, + )}`, + ); + }, + ); + + await controller.updateNetwork(infuraChainId, { + ...networkConfigurationToUpdate, + chainId: anotherInfuraChainId, + defaultRpcEndpointIndex: 0, + nativeCurrency: anotherInfuraNativeTokenName, + rpcEndpoints: [ + buildInfuraRpcEndpoint(anotherInfuraNetworkType), + customRpcEndpoint1, + customRpcEndpoint2, + ], + }); + + expect( + createAutoManagedNetworkClientSpy, + ).toHaveBeenNthCalledWith(6, { + networkClientId: 'CCCC-CCCC-CCCC-CCCC', + networkClientConfiguration: { + chainId: anotherInfuraChainId, + failoverRpcUrls: ['https://first.failover.endpoint'], + rpcUrl: 'https://test.endpoint/1', + ticker: anotherInfuraNativeTokenName, + type: NetworkClientType.Custom, + }, + getRpcServiceOptions, + getBlockTrackerOptions, + messenger: networkControllerMessenger, + rpcFailoverMode: 'enabled', + }); + expect( + createAutoManagedNetworkClientSpy, + ).toHaveBeenNthCalledWith(7, { + networkClientId: 'DDDD-DDDD-DDDD-DDDD', + networkClientConfiguration: { + chainId: anotherInfuraChainId, + failoverRpcUrls: ['https://second.failover.endpoint'], + rpcUrl: 'https://test.endpoint/2', + ticker: anotherInfuraNativeTokenName, + type: NetworkClientType.Custom, + }, + getRpcServiceOptions, + getBlockTrackerOptions, + messenger: networkControllerMessenger, + rpcFailoverMode: 'enabled', + }); + + const networkConfigurationsByChainId = + getNetworkConfigurationsByNetworkClientId( + controller.getNetworkClientRegistry(), + ); + expect( + networkConfigurationsByChainId['CCCC-CCCC-CCCC-CCCC'], + ).toStrictEqual({ + chainId: anotherInfuraChainId, + failoverRpcUrls: ['https://first.failover.endpoint'], + rpcUrl: 'https://test.endpoint/1', + ticker: anotherInfuraNativeTokenName, + type: NetworkClientType.Custom, + }); + expect( + networkConfigurationsByChainId['DDDD-DDDD-DDDD-DDDD'], + ).toStrictEqual({ + chainId: anotherInfuraChainId, + failoverRpcUrls: ['https://second.failover.endpoint'], + rpcUrl: 'https://test.endpoint/2', + ticker: anotherInfuraNativeTokenName, + type: NetworkClientType.Custom, + }); + }, + ); + }); + + it('returns the updated network configuration', async () => { + uuidV4Mock + .mockReturnValueOnce('CCCC-CCCC-CCCC-CCCC') + .mockReturnValueOnce('DDDD-DDDD-DDDD-DDDD'); + + const [defaultRpcEndpoint, customRpcEndpoint1, customRpcEndpoint2] = + [ + buildInfuraRpcEndpoint(infuraNetworkType), + buildCustomRpcEndpoint({ + networkClientId: 'AAAA-AAAA-AAAA-AAAA', + url: 'https://test.endpoint/1', + }), + buildCustomRpcEndpoint({ + networkClientId: 'BBBB-BBBB-BBBB-BBBB', + url: 'https://test.endpoint/2', + }), + ]; + const networkConfigurationToUpdate = + buildInfuraNetworkConfiguration(infuraNetworkType, { + rpcEndpoints: [ + defaultRpcEndpoint, + customRpcEndpoint1, + customRpcEndpoint2, + ], + }); + + await withController( + { + state: { + networkConfigurationsByChainId: { + [infuraChainId]: networkConfigurationToUpdate, + '0x9999': buildCustomNetworkConfiguration({ + chainId: '0x9999', + nativeCurrency: 'TEST-9999', + rpcEndpoints: [ + buildCustomRpcEndpoint({ + networkClientId: 'ZZZZ-ZZZZ-ZZZZ-ZZZZ', + url: 'https://selected.endpoint', + }), + ], + }), + }, + selectedNetworkClientId: 'ZZZZ-ZZZZ-ZZZZ-ZZZZ', + }, + infuraProjectId: 'some-infura-project-id', + }, + async ({ controller }) => { + createNetworkClientMock.mockImplementation( + ({ configuration }) => { + if (configuration.chainId === anotherInfuraChainId) { + return buildFakeClient(); + } + throw new Error( + `Unknown network client configuration ${JSON.stringify( + configuration, + )}`, + ); + }, + ); + + const anotherInfuraRpcEndpoint = buildInfuraRpcEndpoint( + anotherInfuraNetworkType, + ); + const updatedNetworkConfiguration = + await controller.updateNetwork(infuraChainId, { + ...networkConfigurationToUpdate, + chainId: anotherInfuraChainId, + defaultRpcEndpointIndex: 0, + nativeCurrency: anotherInfuraNativeTokenName, + rpcEndpoints: [ + anotherInfuraRpcEndpoint, + customRpcEndpoint1, + customRpcEndpoint2, + ], + }); + + expect(updatedNetworkConfiguration).toStrictEqual({ + ...networkConfigurationToUpdate, + chainId: anotherInfuraChainId, + defaultRpcEndpointIndex: 0, + nativeCurrency: anotherInfuraNativeTokenName, + rpcEndpoints: [ + anotherInfuraRpcEndpoint, + { + ...customRpcEndpoint1, + networkClientId: 'CCCC-CCCC-CCCC-CCCC', + }, + { + ...customRpcEndpoint2, + networkClientId: 'DDDD-DDDD-DDDD-DDDD', + }, + ], + lastUpdatedAt: FAKE_DATE_NOW_MS, + }); + }, + ); + }); + + describe('if one of the RPC endpoints was represented by the selected network client', () => { + it('invisibly selects the network client created for the RPC endpoint', async () => { + uuidV4Mock.mockReturnValueOnce('BBBB-BBBB-BBBB-BBBB'); + const networkConfigurationToUpdate = + buildInfuraNetworkConfiguration(infuraNetworkType, { + nativeCurrency: 'TOKEN', + rpcEndpoints: [ + buildCustomRpcEndpoint({ + name: 'Endpoint 1', + networkClientId: 'AAAA-AAAA-AAAA-AAAA', + url: 'https://rpc.endpoint', + }), + ], + }); + + await withController( + { + state: { + selectedNetworkClientId: 'AAAA-AAAA-AAAA-AAAA', + networkConfigurationsByChainId: { + [infuraChainId]: networkConfigurationToUpdate, + }, + }, + }, + async ({ controller }) => { + const fakeProviders = [ + buildFakeProvider([ + { + request: { + method: 'test', + }, + response: { + result: 'test response from 1', + }, + }, + ]), + buildFakeProvider([ + { + request: { + method: 'test', + }, + response: { + result: 'test response from 2', + }, + }, + ]), + ]; + const fakeNetworkClients = [ + buildFakeClient(fakeProviders[0]), + buildFakeClient(fakeProviders[1]), + ]; + createNetworkClientMock.mockImplementation( + ({ configuration }) => { + if (configuration.chainId === infuraChainId) { + return fakeNetworkClients[0]; + } else if ( + configuration.chainId === anotherInfuraChainId + ) { + return fakeNetworkClients[1]; + } + throw new Error( + `Unknown network client configuration ${JSON.stringify( + configuration, + )}`, + ); + }, + ); + await controller.lookupNetwork(); + expect(controller.state.selectedNetworkClientId).toBe( + 'AAAA-AAAA-AAAA-AAAA', + ); + const networkClient1 = controller.getSelectedNetworkClient(); + assert(networkClient1, 'Network client is somehow unset'); + const result1 = await networkClient1.provider.request({ + method: 'test', + }); + expect(result1).toBe('test response from 1'); + + await controller.updateNetwork(infuraChainId, { + ...networkConfigurationToUpdate, + chainId: anotherInfuraChainId, + }); + + expect(controller.state.selectedNetworkClientId).toBe( + 'BBBB-BBBB-BBBB-BBBB', + ); + const networkClient2 = controller.getSelectedNetworkClient(); + assert(networkClient2, 'Network client is somehow unset'); + const result2 = await networkClient1.provider.request({ + method: 'test', + }); + expect(result2).toBe('test response from 2'); + }, + ); + }); + + it('updates selectedNetworkClientId and networkConfigurationsByChainId at the same time', async () => { + uuidV4Mock.mockReturnValueOnce('BBBB-BBBB-BBBB-BBBB'); + const networkConfigurationToUpdate = + buildInfuraNetworkConfiguration(infuraNetworkType, { + nativeCurrency: 'TOKEN', + rpcEndpoints: [ + buildCustomRpcEndpoint({ + name: 'Endpoint 1', + networkClientId: 'AAAA-AAAA-AAAA-AAAA', + url: 'https://rpc.endpoint', + }), + ], + }); + + await withController( + { + state: { + selectedNetworkClientId: 'AAAA-AAAA-AAAA-AAAA', + networkConfigurationsByChainId: { + [infuraChainId]: networkConfigurationToUpdate, + }, + }, + }, + async ({ controller, messenger }) => { + const fakeProviders = [ + buildFakeProvider([ + { + request: { + method: 'test', + }, + response: { + result: 'test response from 1', + }, + }, + ]), + buildFakeProvider([ + { + request: { + method: 'test', + }, + response: { + result: 'test response from 2', + }, + }, + ]), + ]; + const fakeNetworkClients = [ + buildFakeClient(fakeProviders[0]), + buildFakeClient(fakeProviders[1]), + ]; + createNetworkClientMock.mockImplementation( + ({ configuration }) => { + if (configuration.chainId === infuraChainId) { + return fakeNetworkClients[0]; + } else if ( + configuration.chainId === anotherInfuraChainId + ) { + return fakeNetworkClients[1]; + } + throw new Error( + `Unknown network client configuration ${JSON.stringify( + configuration, + )}`, + ); + }, + ); + await controller.lookupNetwork(); + + const promiseForStateChanges = waitForStateChanges({ + messenger, + count: 1, + }); + + await controller.updateNetwork(infuraChainId, { + ...networkConfigurationToUpdate, + chainId: anotherInfuraChainId, + }); + const stateChanges = await promiseForStateChanges; + expect(stateChanges).toStrictEqual([ + [ + expect.any(Object), + expect.arrayContaining([ + expect.objectContaining({ + op: 'replace', + path: ['selectedNetworkClientId'], + value: 'BBBB-BBBB-BBBB-BBBB', + }), + expect.objectContaining({ + op: 'remove', + path: [ + 'networkConfigurationsByChainId', + infuraChainId, + ], + }), + expect.objectContaining({ + op: 'add', + path: [ + 'networkConfigurationsByChainId', + anotherInfuraChainId, + ], + }), + ]), + ], + ]); + }, + ); + }); + }); + }); + }, + ); + + describe('if the chain ID is being changed from one non-Infura-supported chain to another', () => { + it('throws if a network configuration for a custom network is already registered under the new chain ID', async () => { + const networkConfigurationToUpdate = buildCustomNetworkConfiguration({ + chainId: '0x1337', + }); + + await withController( + { + state: { + networkConfigurationsByChainId: { + '0x1337': networkConfigurationToUpdate, + '0x2448': buildNetworkConfiguration({ + name: 'Some Network', + chainId: '0x2448', + }), + '0x9999': buildCustomNetworkConfiguration({ + chainId: '0x9999', + nativeCurrency: 'TEST-9999', + rpcEndpoints: [ + buildCustomRpcEndpoint({ + networkClientId: 'ZZZZ-ZZZZ-ZZZZ-ZZZZ', + url: 'https://selected.endpoint', + }), + ], + }), + }, + selectedNetworkClientId: 'ZZZZ-ZZZZ-ZZZZ-ZZZZ', + }, + }, + async ({ controller }) => { + await expect(() => + controller.updateNetwork('0x1337', { + ...networkConfigurationToUpdate, + chainId: '0x2448', + }), + ).rejects.toThrow( + "Cannot move network from chain 0x1337 to 0x2448 as another network for that chain already exists ('Some Network')", + ); + }, + ); + }); + + it('throws (albeit for a different reason) if an Infura RPC endpoint is being added that represents a different chain than the one being changed to', async () => { + const networkConfigurationToUpdate = buildCustomNetworkConfiguration({ + chainId: '0x1337', + }); + + await withController( + { + state: { + networkConfigurationsByChainId: { + '0x1337': networkConfigurationToUpdate, + [TESTNET.chainId]: buildInfuraNetworkConfiguration( + TESTNET.networkType, + ), + '0x9999': buildCustomNetworkConfiguration({ + chainId: '0x9999', + nativeCurrency: 'TEST-9999', + rpcEndpoints: [ + buildCustomRpcEndpoint({ + networkClientId: 'ZZZZ-ZZZZ-ZZZZ-ZZZZ', + url: 'https://selected.endpoint', + }), + ], + }), + }, + selectedNetworkClientId: 'ZZZZ-ZZZZ-ZZZZ-ZZZZ', + }, + }, + async ({ controller }) => { + const newRpcEndpoint = buildInfuraRpcEndpoint(TESTNET.networkType); + await expect(() => + controller.updateNetwork('0x1337', { + ...networkConfigurationToUpdate, + chainId: '0x2448', + rpcEndpoints: [newRpcEndpoint], + }), + ).rejects.toThrow( + new Error( + `Could not update network to point to same RPC endpoint as existing network for chain ${TESTNET.chainId} ('${TESTNET.name}')`, + ), + ); + }, + ); + }); + + it('re-files the existing network configuration from under the old chain ID to under the new one, regenerating network client IDs for each RPC endpoint', async () => { + uuidV4Mock + .mockReturnValueOnce('CCCC-CCCC-CCCC-CCCC') + .mockReturnValueOnce('DDDD-DDDD-DDDD-DDDD'); + + const [rpcEndpoint1, rpcEndpoint2] = [ + buildCustomRpcEndpoint({ + networkClientId: 'AAAA-AAAA-AAAA-AAAA', + url: 'https://test.endpoint/1', + }), + buildCustomRpcEndpoint({ + networkClientId: 'BBBB-BBBB-BBBB-BBBB', + url: 'https://test.endpoint/2', + }), + ]; + const networkConfigurationToUpdate = buildNetworkConfiguration({ + chainId: '0x1337', + rpcEndpoints: [rpcEndpoint1, rpcEndpoint2], + }); + + await withController( + { + state: { + networkConfigurationsByChainId: { + '0x1337': networkConfigurationToUpdate, + '0x9999': buildCustomNetworkConfiguration({ + chainId: '0x9999', + nativeCurrency: 'TEST-9999', + rpcEndpoints: [ + buildCustomRpcEndpoint({ + networkClientId: 'ZZZZ-ZZZZ-ZZZZ-ZZZZ', + url: 'https://selected.endpoint', + }), + ], + }), + }, + selectedNetworkClientId: 'ZZZZ-ZZZZ-ZZZZ-ZZZZ', + }, + }, + async ({ controller }) => { + createNetworkClientMock.mockImplementation(({ configuration }) => { + if ( + configuration.type === NetworkClientType.Custom && + configuration.rpcUrl === 'https://test.endpoint/1' + ) { + return buildFakeClient(); + } + throw new Error( + `Unknown network client configuration ${JSON.stringify( + configuration, + )}`, + ); + }); + + await controller.updateNetwork('0x1337', { + ...networkConfigurationToUpdate, + chainId: '0x2448', + }); + + expect( + controller.state.networkConfigurationsByChainId, + ).not.toHaveProperty('0x1337'); + expect( + controller.state.networkConfigurationsByChainId, + ).toHaveProperty('0x2448'); + expect( + controller.state.networkConfigurationsByChainId['0x2448'], + ).toStrictEqual({ + ...networkConfigurationToUpdate, + chainId: '0x2448', + rpcEndpoints: [ + { + ...rpcEndpoint1, + networkClientId: 'CCCC-CCCC-CCCC-CCCC', + }, + { + ...rpcEndpoint2, + networkClientId: 'DDDD-DDDD-DDDD-DDDD', + }, + ], + lastUpdatedAt: FAKE_DATE_NOW_MS, + }); + }, + ); + }); + + it('destroys and unregisters every network client for each of the RPC endpoints (even if none of the endpoint URLs were changed)', async () => { + const networkConfigurationToUpdate = buildNetworkConfiguration({ + chainId: '0x1337', + rpcEndpoints: [ + buildCustomRpcEndpoint({ + name: 'Test Network 1', + networkClientId: 'AAAA-AAAA-AAAA-AAAA', + url: 'https://test.endpoint/1', + }), + buildCustomRpcEndpoint({ + name: 'Test Network 2', + networkClientId: 'BBBB-BBBB-BBBB-BBBB', + url: 'https://test.endpoint/2', + }), + ], + }); + + await withController( + { + state: { + networkConfigurationsByChainId: { + '0x1337': networkConfigurationToUpdate, + '0x9999': buildCustomNetworkConfiguration({ + chainId: '0x9999', + nativeCurrency: 'TEST-9999', + rpcEndpoints: [ + buildCustomRpcEndpoint({ + networkClientId: 'ZZZZ-ZZZZ-ZZZZ-ZZZZ', + url: 'https://selected.endpoint', + }), + ], + }), + }, + selectedNetworkClientId: 'ZZZZ-ZZZZ-ZZZZ-ZZZZ', + }, + }, + async ({ controller }) => { + createNetworkClientMock.mockImplementation(({ configuration }) => { + if ( + configuration.type === NetworkClientType.Custom && + configuration.rpcUrl === 'https://test.endpoint/1' + ) { + return buildFakeClient(); + } + throw new Error( + `Unknown network client configuration ${JSON.stringify( + configuration, + )}`, + ); + }); + const existingNetworkClient1 = controller.getNetworkClientById( + 'AAAA-AAAA-AAAA-AAAA', + ); + const destroySpy1 = jest.spyOn(existingNetworkClient1, 'destroy'); + const existingNetworkClient2 = controller.getNetworkClientById( + 'BBBB-BBBB-BBBB-BBBB', + ); + const destroySpy2 = jest.spyOn(existingNetworkClient2, 'destroy'); + + await controller.updateNetwork('0x1337', { + ...networkConfigurationToUpdate, + chainId: '0x2448', + }); + + expect(destroySpy1).toHaveBeenCalled(); + expect(destroySpy2).toHaveBeenCalled(); + const networkClientRegistry = controller.getNetworkClientRegistry(); + expect(networkClientRegistry).not.toHaveProperty( + 'AAAA-AAAA-AAAA-AAAA', + ); + expect(networkClientRegistry).not.toHaveProperty( + 'BBBB-BBBB-BBBB-BBBB', + ); + }, + ); + }); + + it('creates and registers new network clients for each of the given RPC endpoints', async () => { + uuidV4Mock + .mockReturnValueOnce('CCCC-CCCC-CCCC-CCCC') + .mockReturnValueOnce('DDDD-DDDD-DDDD-DDDD'); + const createAutoManagedNetworkClientSpy = jest.spyOn( + createAutoManagedNetworkClientModule, + 'createAutoManagedNetworkClient', + ); + const networkConfigurationToUpdate = buildNetworkConfiguration({ + chainId: '0x1337', + nativeCurrency: 'TOKEN', + rpcEndpoints: [ + buildCustomRpcEndpoint({ + failoverUrls: ['https://first.failover.endpoint'], + name: 'Test Network 1', + networkClientId: 'AAAA-AAAA-AAAA-AAAA', + url: 'https://test.endpoint/1', + }), + buildCustomRpcEndpoint({ + failoverUrls: ['https://second.failover.endpoint'], + name: 'Test Network 2', + networkClientId: 'BBBB-BBBB-BBBB-BBBB', + url: 'https://test.endpoint/2', + }), + ], + }); + const getRpcServiceOptions = (): Omit< + RpcServiceOptions, + 'failoverService' | 'endpointUrl' + > => ({ + btoa, + fetch, + isOffline: (): boolean => false, + fetchOptions: { + headers: { + 'X-Foo': 'Bar', + }, + }, + policyOptions: { + maxRetries: 2, + maxConsecutiveFailures: 10, + }, + }); + const getBlockTrackerOptions = (): PollingBlockTrackerOptions => ({ + pollingInterval: 2000, + }); + + await withController( + { + state: { + networkConfigurationsByChainId: { + '0x1337': networkConfigurationToUpdate, + '0x9999': buildCustomNetworkConfiguration({ + chainId: '0x9999', + nativeCurrency: 'TEST-9999', + rpcEndpoints: [ + buildCustomRpcEndpoint({ + networkClientId: 'ZZZZ-ZZZZ-ZZZZ-ZZZZ', + url: 'https://selected.endpoint', + }), + ], + }), + }, + selectedNetworkClientId: 'ZZZZ-ZZZZ-ZZZZ-ZZZZ', + }, + getRpcServiceOptions, + getBlockTrackerOptions, + rpcFailoverMode: 'enabled', + }, + async ({ controller, networkControllerMessenger }) => { + createNetworkClientMock.mockImplementation(({ configuration }) => { + if ( + configuration.type === NetworkClientType.Custom && + configuration.rpcUrl === 'https://test.endpoint/1' + ) { + return buildFakeClient(); + } + throw new Error( + `Unknown network client configuration ${JSON.stringify( + configuration, + )}`, + ); + }); + + await controller.updateNetwork('0x1337', { + ...networkConfigurationToUpdate, + chainId: '0x2448', + }); + + expect(createAutoManagedNetworkClientSpy).toHaveBeenNthCalledWith( + 4, + { + networkClientId: 'CCCC-CCCC-CCCC-CCCC', + networkClientConfiguration: { + chainId: '0x2448', + failoverRpcUrls: ['https://first.failover.endpoint'], + rpcUrl: 'https://test.endpoint/1', + ticker: 'TOKEN', + type: NetworkClientType.Custom, + }, + getRpcServiceOptions, + getBlockTrackerOptions, + messenger: networkControllerMessenger, + rpcFailoverMode: 'enabled', + }, + ); + expect(createAutoManagedNetworkClientSpy).toHaveBeenNthCalledWith( + 5, + { + networkClientId: 'DDDD-DDDD-DDDD-DDDD', + networkClientConfiguration: { + chainId: '0x2448', + failoverRpcUrls: ['https://second.failover.endpoint'], + rpcUrl: 'https://test.endpoint/2', + ticker: 'TOKEN', + type: NetworkClientType.Custom, + }, + getRpcServiceOptions, + getBlockTrackerOptions, + messenger: networkControllerMessenger, + rpcFailoverMode: 'enabled', + }, + ); + + const networkConfigurationsByChainId = + getNetworkConfigurationsByNetworkClientId( + controller.getNetworkClientRegistry(), + ); + expect( + networkConfigurationsByChainId['CCCC-CCCC-CCCC-CCCC'], + ).toStrictEqual({ + chainId: '0x2448', + failoverRpcUrls: ['https://first.failover.endpoint'], + rpcUrl: 'https://test.endpoint/1', + ticker: 'TOKEN', + type: NetworkClientType.Custom, + }); + expect( + networkConfigurationsByChainId['DDDD-DDDD-DDDD-DDDD'], + ).toStrictEqual({ + chainId: '0x2448', + failoverRpcUrls: ['https://second.failover.endpoint'], + rpcUrl: 'https://test.endpoint/2', + ticker: 'TOKEN', + type: NetworkClientType.Custom, + }); + }, + ); + }); + + it('returns the updated network configuration', async () => { + uuidV4Mock + .mockReturnValueOnce('CCCC-CCCC-CCCC-CCCC') + .mockReturnValueOnce('DDDD-DDDD-DDDD-DDDD'); + + const [rpcEndpoint1, rpcEndpoint2] = [ + buildCustomRpcEndpoint({ + networkClientId: 'AAAA-AAAA-AAAA-AAAA', + url: 'https://test.endpoint/1', + }), + buildCustomRpcEndpoint({ + networkClientId: 'BBBB-BBBB-BBBB-BBBB', + url: 'https://test.endpoint/2', + }), + ]; + const networkConfigurationToUpdate = buildNetworkConfiguration({ + chainId: '0x1337', + rpcEndpoints: [rpcEndpoint1, rpcEndpoint2], + }); + + await withController( + { + state: { + networkConfigurationsByChainId: { + '0x1337': networkConfigurationToUpdate, + '0x9999': buildCustomNetworkConfiguration({ + chainId: '0x9999', + nativeCurrency: 'TEST-9999', + rpcEndpoints: [ + buildCustomRpcEndpoint({ + networkClientId: 'ZZZZ-ZZZZ-ZZZZ-ZZZZ', + url: 'https://selected.endpoint', + }), + ], + }), + }, + selectedNetworkClientId: 'ZZZZ-ZZZZ-ZZZZ-ZZZZ', + }, + }, + async ({ controller }) => { + createNetworkClientMock.mockImplementation(({ configuration }) => { + if ( + configuration.type === NetworkClientType.Custom && + configuration.rpcUrl === 'https://test.endpoint/1' + ) { + return buildFakeClient(); + } + throw new Error( + `Unknown network client configuration ${JSON.stringify( + configuration, + )}`, + ); + }); + + const updatedNetworkConfiguration = await controller.updateNetwork( + '0x1337', + { + ...networkConfigurationToUpdate, + chainId: '0x2448', + }, + ); + + expect(updatedNetworkConfiguration).toStrictEqual({ + ...networkConfigurationToUpdate, + chainId: '0x2448', + rpcEndpoints: [ + { + ...rpcEndpoint1, + networkClientId: 'CCCC-CCCC-CCCC-CCCC', + }, + { + ...rpcEndpoint2, + networkClientId: 'DDDD-DDDD-DDDD-DDDD', + }, + ], + lastUpdatedAt: FAKE_DATE_NOW_MS, + }); + }, + ); + }); + + describe('if one of the RPC endpoints was represented by the selected network client', () => { + it('invisibly selects the network client created for the RPC endpoint', async () => { + uuidV4Mock.mockReturnValueOnce('BBBB-BBBB-BBBB-BBBB'); + const networkConfigurationToUpdate = buildCustomNetworkConfiguration({ + nativeCurrency: 'TOKEN', + rpcEndpoints: [ + buildCustomRpcEndpoint({ + name: 'Endpoint 1', + networkClientId: 'AAAA-AAAA-AAAA-AAAA', + url: 'https://rpc.endpoint', + }), + ], + }); + + await withController( + { + state: { + selectedNetworkClientId: 'AAAA-AAAA-AAAA-AAAA', + networkConfigurationsByChainId: { + '0x1337': networkConfigurationToUpdate, + }, + }, + }, + async ({ controller }) => { + const fakeProviders = [ + buildFakeProvider([ + { + request: { + method: 'test', + }, + response: { + result: 'test response from 1', + }, + }, + ]), + buildFakeProvider([ + { + request: { + method: 'test', + }, + response: { + result: 'test response from 2', + }, + }, + ]), + ]; + const fakeNetworkClients = [ + buildFakeClient(fakeProviders[0]), + buildFakeClient(fakeProviders[1]), + ]; + createNetworkClientMock.mockImplementation( + ({ configuration }) => { + if (configuration.chainId === '0x1337') { + return fakeNetworkClients[0]; + } else if (configuration.chainId === '0x2448') { + return fakeNetworkClients[1]; + } + throw new Error( + `Unknown network client configuration ${JSON.stringify( + configuration, + )}`, + ); + }, + ); + await controller.lookupNetwork(); + expect(controller.state.selectedNetworkClientId).toBe( + 'AAAA-AAAA-AAAA-AAAA', + ); + const networkClient1 = controller.getSelectedNetworkClient(); + assert(networkClient1, 'Network client is somehow unset'); + const result1 = await networkClient1.provider.request({ + method: 'test', + }); + expect(result1).toBe('test response from 1'); + + await controller.updateNetwork('0x1337', { + ...networkConfigurationToUpdate, + chainId: '0x2448', + }); + + expect(controller.state.selectedNetworkClientId).toBe( + 'BBBB-BBBB-BBBB-BBBB', + ); + const networkClient2 = controller.getSelectedNetworkClient(); + assert(networkClient2, 'Network client is somehow unset'); + const result2 = await networkClient1.provider.request({ + method: 'test', + }); + expect(result2).toBe('test response from 2'); + }, + ); + }); + + it('updates selectedNetworkClientId and networkConfigurationsByChainId at the same time', async () => { + uuidV4Mock.mockReturnValueOnce('BBBB-BBBB-BBBB-BBBB'); + const networkConfigurationToUpdate = buildCustomNetworkConfiguration({ + nativeCurrency: 'TOKEN', + rpcEndpoints: [ + buildCustomRpcEndpoint({ + name: 'Endpoint 1', + networkClientId: 'AAAA-AAAA-AAAA-AAAA', + url: 'https://rpc.endpoint', + }), + ], + }); + + await withController( + { + state: { + selectedNetworkClientId: 'AAAA-AAAA-AAAA-AAAA', + networkConfigurationsByChainId: { + '0x1337': networkConfigurationToUpdate, + }, + }, + }, + async ({ controller, messenger }) => { + const fakeProviders = [ + buildFakeProvider([ + { + request: { + method: 'test', + }, + response: { + result: 'test response from 1', + }, + }, + ]), + buildFakeProvider([ + { + request: { + method: 'test', + }, + response: { + result: 'test response from 2', + }, + }, + ]), + ]; + const fakeNetworkClients = [ + buildFakeClient(fakeProviders[0]), + buildFakeClient(fakeProviders[1]), + ]; + createNetworkClientMock.mockImplementation( + ({ configuration }) => { + if (configuration.chainId === '0x1337') { + return fakeNetworkClients[0]; + } else if (configuration.chainId === '0x2448') { + return fakeNetworkClients[1]; + } + throw new Error( + `Unknown network client configuration ${JSON.stringify( + configuration, + )}`, + ); + }, + ); + await controller.lookupNetwork(); + + const promiseForStateChanges = waitForStateChanges({ + messenger, + count: 1, + }); + + await controller.updateNetwork('0x1337', { + ...networkConfigurationToUpdate, + chainId: '0x2448', + }); + const stateChanges = await promiseForStateChanges; + expect(stateChanges).toStrictEqual([ + [ + expect.any(Object), + expect.arrayContaining([ + expect.objectContaining({ + op: 'replace', + path: ['selectedNetworkClientId'], + value: 'BBBB-BBBB-BBBB-BBBB', + }), + expect.objectContaining({ + op: 'remove', + path: ['networkConfigurationsByChainId', '0x1337'], + }), + expect.objectContaining({ + op: 'add', + path: ['networkConfigurationsByChainId', '0x2448'], + }), + ]), + ], + ]); + }, + ); + }); + }); + }); + + describe('if nothing is being changed', () => { + for (const infuraNetworkType of INFURA_NETWORKS) { + const infuraChainId = ChainId[infuraNetworkType]; + + describe(`given the ID of the Infura-supported chain ${infuraChainId}`, () => { + it('makes no updates to state', async () => { + const existingNetworkConfiguration = + buildInfuraNetworkConfiguration(infuraNetworkType); + + await withController( + { + state: { + networkConfigurationsByChainId: { + [infuraChainId]: existingNetworkConfiguration, + '0x9999': buildCustomNetworkConfiguration({ + chainId: '0x9999', + nativeCurrency: 'TEST-9999', + rpcEndpoints: [ + buildCustomRpcEndpoint({ + networkClientId: 'ZZZZ-ZZZZ-ZZZZ-ZZZZ', + url: 'https://selected.endpoint', + }), + ], + }), + }, + selectedNetworkClientId: 'ZZZZ-ZZZZ-ZZZZ-ZZZZ', + }, + }, + async ({ controller }) => { + await controller.updateNetwork( + infuraChainId, + existingNetworkConfiguration, + ); + + expect( + controller.state.networkConfigurationsByChainId[ + infuraChainId + ], + ).toStrictEqual(existingNetworkConfiguration); + }, + ); + }); + + it('does not destroy any existing clients for the network', async () => { + const existingNetworkConfiguration = + buildInfuraNetworkConfiguration(infuraNetworkType); + + await withController( + { + state: { + networkConfigurationsByChainId: { + [infuraChainId]: existingNetworkConfiguration, + '0x9999': buildCustomNetworkConfiguration({ + chainId: '0x9999', + nativeCurrency: 'TEST-9999', + rpcEndpoints: [ + buildCustomRpcEndpoint({ + networkClientId: 'ZZZZ-ZZZZ-ZZZZ-ZZZZ', + url: 'https://selected.endpoint', + }), + ], + }), + }, + selectedNetworkClientId: 'ZZZZ-ZZZZ-ZZZZ-ZZZZ', + }, + }, + async ({ controller }) => { + const existingNetworkClient = + controller.getNetworkClientById(infuraNetworkType); + const destroySpy = jest.spyOn(existingNetworkClient, 'destroy'); + + await controller.updateNetwork( + infuraChainId, + existingNetworkConfiguration, + ); + + expect(destroySpy).not.toHaveBeenCalled(); + }, + ); + }); + + it('does not create any new clients for the network', async () => { + const existingNetworkConfiguration = + buildInfuraNetworkConfiguration(infuraNetworkType); + + const createAutoManagedNetworkClientSpy = jest.spyOn( + createAutoManagedNetworkClientModule, + 'createAutoManagedNetworkClient', + ); + + await withController( + { + state: { + networkConfigurationsByChainId: { + [infuraChainId]: existingNetworkConfiguration, + '0x9999': buildCustomNetworkConfiguration({ + chainId: '0x9999', + nativeCurrency: 'TEST-9999', + rpcEndpoints: [ + buildCustomRpcEndpoint({ + networkClientId: 'ZZZZ-ZZZZ-ZZZZ-ZZZZ', + url: 'https://selected.endpoint', + }), + ], + }), + }, + selectedNetworkClientId: 'ZZZZ-ZZZZ-ZZZZ-ZZZZ', + }, + }, + async ({ controller }) => { + await controller.updateNetwork( + infuraChainId, + existingNetworkConfiguration, + ); + + // 2 times for existing RPC endpoints, but no more + expect(createAutoManagedNetworkClientSpy).toHaveBeenCalledTimes( + 2, + ); + }, + ); + }); + }); + } + + describe('given the ID of a non-Infura-supported chain', () => { + it('makes no updates to state', async () => { + const existingNetworkConfiguration = buildCustomNetworkConfiguration({ + chainId: '0x1337', + }); + + await withController( + { + state: { + networkConfigurationsByChainId: { + '0x1337': existingNetworkConfiguration, + '0x9999': buildCustomNetworkConfiguration({ + chainId: '0x9999', + nativeCurrency: 'TEST-9999', + rpcEndpoints: [ + buildCustomRpcEndpoint({ + networkClientId: 'ZZZZ-ZZZZ-ZZZZ-ZZZZ', + url: 'https://selected.endpoint', + }), + ], + }), + }, + selectedNetworkClientId: 'ZZZZ-ZZZZ-ZZZZ-ZZZZ', + }, + }, + async ({ controller }) => { + await controller.updateNetwork( + '0x1337', + existingNetworkConfiguration, + ); + + expect( + controller.state.networkConfigurationsByChainId['0x1337'], + ).toStrictEqual(existingNetworkConfiguration); + }, + ); + }); + + it('does not destroy any existing clients for the network', async () => { + const existingNetworkConfiguration = buildCustomNetworkConfiguration({ + chainId: '0x1337', + rpcEndpoints: [ + buildCustomRpcEndpoint({ + networkClientId: 'AAAA-AAAA-AAAA-AAAA', + }), + ], + }); + + await withController( + { + state: { + networkConfigurationsByChainId: { + '0x1337': existingNetworkConfiguration, + '0x9999': buildCustomNetworkConfiguration({ + chainId: '0x9999', + nativeCurrency: 'TEST-9999', + rpcEndpoints: [ + buildCustomRpcEndpoint({ + networkClientId: 'ZZZZ-ZZZZ-ZZZZ-ZZZZ', + url: 'https://selected.endpoint', + }), + ], + }), + }, + selectedNetworkClientId: 'ZZZZ-ZZZZ-ZZZZ-ZZZZ', + }, + }, + async ({ controller }) => { + const existingNetworkClient = controller.getNetworkClientById( + 'AAAA-AAAA-AAAA-AAAA', + ); + const destroySpy = jest.spyOn(existingNetworkClient, 'destroy'); + + await controller.updateNetwork( + '0x1337', + existingNetworkConfiguration, + ); + + expect(destroySpy).not.toHaveBeenCalled(); + }, + ); + }); + + it('does not create any new clients for the network', async () => { + const existingNetworkConfiguration = buildCustomNetworkConfiguration({ + chainId: '0x1337', + }); + + const createAutoManagedNetworkClientSpy = jest.spyOn( + createAutoManagedNetworkClientModule, + 'createAutoManagedNetworkClient', + ); + + await withController( + { + state: { + networkConfigurationsByChainId: { + '0x1337': existingNetworkConfiguration, + '0x9999': buildCustomNetworkConfiguration({ + chainId: '0x9999', + nativeCurrency: 'TEST-9999', + rpcEndpoints: [ + buildCustomRpcEndpoint({ + networkClientId: 'ZZZZ-ZZZZ-ZZZZ-ZZZZ', + url: 'https://selected.endpoint', + }), + ], + }), + }, + selectedNetworkClientId: 'ZZZZ-ZZZZ-ZZZZ-ZZZZ', + }, + }, + async ({ controller }) => { + await controller.updateNetwork( + '0x1337', + existingNetworkConfiguration, + ); + + // 2 times for existing RPC endpoints, but no more + expect(createAutoManagedNetworkClientSpy).toHaveBeenCalledTimes( + 2, + ); + }, + ); + }); + }); + }); + + it('allows calling `getNetworkConfigurationByNetworkClientId` when subscribing to state changes containing new endpoints', async () => { + const network = buildCustomNetworkConfiguration({ + chainId: '0x1' as Hex, + name: 'mainnet', + nativeCurrency: 'ETH', + blockExplorerUrls: [], + defaultRpcEndpointIndex: 0, + rpcEndpoints: [ + { + failoverUrls: [], + type: RpcEndpointType.Custom, + url: 'https://test.endpoint/1', + networkClientId: 'client1', + }, + ], + }); + + await withController( + { + state: { + selectedNetworkClientId: 'client1', + networkConfigurationsByChainId: { '0x1': network }, + }, + }, + async ({ controller, messenger }) => { + const stateChangePromise = new Promise< + NetworkConfiguration | undefined + >((resolve) => { + messenger.subscribe('NetworkController:stateChange', (state) => { + const { networkClientId } = + state.networkConfigurationsByChainId['0x1'].rpcEndpoints[1]; + + resolve( + controller.getNetworkConfigurationByNetworkClientId( + networkClientId, + ), + ); + }); + }); + + // Add a new endpoint + await controller.updateNetwork('0x1', { + ...network, + rpcEndpoints: [ + ...network.rpcEndpoints, + { + failoverUrls: [], + type: RpcEndpointType.Custom, + url: 'https://test.endpoint/2', + }, + ], + }); + + const networkConfiguration = await stateChangePromise; + expect(networkConfiguration).toBeDefined(); + }, + ); + }); + }); + + describe('removeNetwork', () => { + it('throws if the given chain ID does not refer to an existing network configuration', async () => { + await withController(({ controller }) => { + expect(() => controller.removeNetwork('0x1337')).toThrow( + new Error("Cannot find network configuration for chain '0x1337'"), + ); + }); + }); + + it('throws if selectedNetworkClientId matches the networkClientId of any RPC endpoint in the existing network configuration', async () => { + await withController( + { + state: { + selectedNetworkClientId: 'AAAA-AAAA-AAAA-AAAA', + networkConfigurationsByChainId: { + '0x1337': buildCustomNetworkConfiguration({ + rpcEndpoints: [ + buildCustomRpcEndpoint({ + networkClientId: 'AAAA-AAAA-AAAA-AAAA', + }), + ], + }), + }, + }, + }, + ({ controller }) => { + expect(() => controller.removeNetwork('0x1337')).toThrow( + 'Cannot remove the currently selected network', + ); + }, + ); + }); + + for (const infuraNetworkType of INFURA_NETWORKS) { + const infuraChainId = ChainId[infuraNetworkType]; + + describe(`given the ID of the Infura-supported chain ${infuraChainId}`, () => { + it('removes the existing network configuration from state', async () => { + await withController( + { + state: { + selectedNetworkClientId: 'AAAA-AAAA-AAAA-AAAA', + networkConfigurationsByChainId: { + [infuraChainId]: + buildInfuraNetworkConfiguration(infuraNetworkType), + '0x1337': buildCustomNetworkConfiguration({ + chainId: '0x1337', + rpcEndpoints: [ + buildCustomRpcEndpoint({ + networkClientId: 'AAAA-AAAA-AAAA-AAAA', + }), + ], + }), + }, + }, + }, + ({ controller }) => { + expect( + controller.state.networkConfigurationsByChainId, + ).toHaveProperty(infuraChainId); + + controller.removeNetwork(infuraChainId); + + expect( + controller.state.networkConfigurationsByChainId, + ).not.toHaveProperty(infuraChainId); + }, + ); + }); + + it('removes the existing metadata for the network from state', async () => { + await withController( + { + state: { + selectedNetworkClientId: 'AAAA-AAAA-AAAA-AAAA', + networkConfigurationsByChainId: { + [infuraChainId]: + buildInfuraNetworkConfiguration(infuraNetworkType), + '0x1337': buildCustomNetworkConfiguration({ + chainId: '0x1337', + rpcEndpoints: [ + buildCustomRpcEndpoint({ + networkClientId: 'AAAA-AAAA-AAAA-AAAA', + }), + ], + }), + }, + networksMetadata: { + [infuraNetworkType]: { + status: NetworkStatus.Available, + EIPS: {}, + }, + }, + }, + }, + ({ controller }) => { + controller.removeNetwork(infuraChainId); + + expect(controller.state.networksMetadata).not.toHaveProperty( + infuraNetworkType, + ); + }, + ); + }); + + it('destroys and unregisters the network clients for each of the RPC endpoints defined in the network configuration (even the Infura endpoint)', async () => { + const defaultRpcEndpoint = buildInfuraRpcEndpoint(infuraNetworkType); + + await withController( + { + state: { + selectedNetworkClientId: 'BBBB-BBBB-BBBB-BBBB', + networkConfigurationsByChainId: { + [infuraChainId]: buildInfuraNetworkConfiguration( + infuraNetworkType, + { + rpcEndpoints: [ + defaultRpcEndpoint, + buildCustomRpcEndpoint({ + name: 'Test Network', + networkClientId: 'AAAA-AAAA-AAAA-AAAA', + url: 'https://test.endpoint', + }), + ], + }, + ), + '0x1337': buildCustomNetworkConfiguration({ + chainId: '0x1337', + rpcEndpoints: [ + buildCustomRpcEndpoint({ + networkClientId: 'BBBB-BBBB-BBBB-BBBB', + }), + ], + }), + }, + }, + }, + ({ controller }) => { + const existingNetworkClient1 = + controller.getNetworkClientById(infuraNetworkType); + const destroySpy1 = jest.spyOn(existingNetworkClient1, 'destroy'); + const existingNetworkClient2 = controller.getNetworkClientById( + 'AAAA-AAAA-AAAA-AAAA', + ); + const destroySpy2 = jest.spyOn(existingNetworkClient2, 'destroy'); + + controller.removeNetwork(infuraChainId); + + expect(destroySpy1).toHaveBeenCalled(); + expect(destroySpy2).toHaveBeenCalled(); + const networkClientRegistry = + controller.getNetworkClientRegistry(); + expect(networkClientRegistry).not.toHaveProperty( + infuraNetworkType, + ); + expect(networkClientRegistry).not.toHaveProperty( + 'AAAA-AAAA-AAAA-AAAA', + ); + }, + ); + }); + }); + } + + describe('given the ID of a non-Infura-supported chain', () => { + it('removes the existing network configuration from state', async () => { + await withController( + { + state: { + selectedNetworkClientId: TESTNET.networkType, + networkConfigurationsByChainId: { + '0x1337': buildCustomNetworkConfiguration(), + [TESTNET.chainId]: buildInfuraNetworkConfiguration( + TESTNET.networkType, + ), + }, + }, + }, + ({ controller }) => { + expect( + controller.state.networkConfigurationsByChainId, + ).toHaveProperty('0x1337'); + + controller.removeNetwork('0x1337'); + + expect( + controller.state.networkConfigurationsByChainId, + ).not.toHaveProperty('0x1337'); + }, + ); + }); + + it('removes the existing metadata for all RPC endpoints in the network from state', async () => { + await withController( + { + state: { + selectedNetworkClientId: TESTNET.networkType, + networkConfigurationsByChainId: { + '0x1337': buildCustomNetworkConfiguration({ + rpcEndpoints: [ + buildCustomRpcEndpoint({ + networkClientId: 'AAAA-AAAA-AAAA-AAAA', + }), + buildCustomRpcEndpoint({ + networkClientId: 'BBBB-BBBB-BBBB-BBBB', + }), + buildCustomRpcEndpoint({ + networkClientId: 'CCCC-CCCC-CCCC-CCCC', + }), + ], + }), + [TESTNET.chainId]: buildInfuraNetworkConfiguration( + TESTNET.networkType, + ), + }, + networksMetadata: { + 'AAAA-AAAA-AAAA-AAAA': { + status: NetworkStatus.Available, + EIPS: {}, + }, + 'BBBB-BBBB-BBBB-BBBB': { + status: NetworkStatus.Available, + EIPS: {}, + }, + 'CCCC-CCCC-CCCC-CCCC': { + status: NetworkStatus.Available, + EIPS: {}, + }, + }, + }, + }, + ({ controller }) => { + controller.removeNetwork('0x1337'); + + expect(controller.state.networksMetadata).not.toHaveProperty( + 'AAAA-AAAA-AAAA-AAAA', + ); + expect(controller.state.networksMetadata).not.toHaveProperty( + 'BBBB-BBBB-BBBB-BBBB', + ); + expect(controller.state.networksMetadata).not.toHaveProperty( + 'CCCC-CCCC-CCCC-CCCC', + ); + }, + ); + }); + + it('destroys the network clients for each of the RPC endpoints defined in the network configuration', async () => { + await withController( + { + state: { + selectedNetworkClientId: TESTNET.networkType, + networkConfigurationsByChainId: { + '0x1337': buildCustomNetworkConfiguration({ + rpcEndpoints: [ + buildCustomRpcEndpoint({ + name: 'Test Network 1', + networkClientId: 'AAAA-AAAA-AAAA-AAAA', + url: 'https://test.endpoint/1', + }), + buildCustomRpcEndpoint({ + name: 'Test Network 2', + networkClientId: 'BBBB-BBBB-BBBB-BBBB', + url: 'https://test.endpoint/2', + }), + ], + }), + [TESTNET.chainId]: buildInfuraNetworkConfiguration( + TESTNET.networkType, + ), + }, + }, + }, + ({ controller }) => { + const existingNetworkClient1 = controller.getNetworkClientById( + 'AAAA-AAAA-AAAA-AAAA', + ); + const destroySpy1 = jest.spyOn(existingNetworkClient1, 'destroy'); + const existingNetworkClient2 = controller.getNetworkClientById( + 'BBBB-BBBB-BBBB-BBBB', + ); + const destroySpy2 = jest.spyOn(existingNetworkClient2, 'destroy'); + + controller.removeNetwork('0x1337'); + + expect(destroySpy1).toHaveBeenCalled(); + expect(destroySpy2).toHaveBeenCalled(); + const networkClientRegistry = controller.getNetworkClientRegistry(); + expect(networkClientRegistry).not.toHaveProperty( + 'AAAA-AAAA-AAAA-AAAA', + ); + expect(networkClientRegistry).not.toHaveProperty( + 'BBBB-BBBB-BBBB-BBBB', + ); + }, + ); + }); + + it('is callable from the controller messenger', async () => { + await withController( + { + state: { + selectedNetworkClientId: TESTNET.networkType, + networkConfigurationsByChainId: { + '0x1337': buildCustomNetworkConfiguration(), + [TESTNET.chainId]: buildInfuraNetworkConfiguration( + TESTNET.networkType, + ), + }, + }, + }, + ({ controller, messenger }) => { + messenger.call('NetworkController:removeNetwork', '0x1337'); + expect( + controller.state.networkConfigurationsByChainId, + ).not.toHaveProperty('0x1337'); + }, + ); + }); + + it('emits the NetworkController:networkRemoved event', async () => { + const networkConfig = buildCustomNetworkConfiguration(); + await withController( + { + state: { + selectedNetworkClientId: TESTNET.networkType, + networkConfigurationsByChainId: { + '0x1337': networkConfig, + [TESTNET.chainId]: buildInfuraNetworkConfiguration( + TESTNET.networkType, + ), + }, + }, + }, + ({ controller, messenger }) => { + const networkRemovedListener = jest.fn(); + messenger.subscribe( + 'NetworkController:networkRemoved', + networkRemovedListener, + ); + + controller.removeNetwork('0x1337'); + + expect(networkRemovedListener).toHaveBeenCalledWith(networkConfig); + }, + ); + }); + }); + }); + + describe('rollbackToPreviousProvider', () => { + describe('when called not following any network switches', () => { + for (const infuraNetworkType of INFURA_NETWORKS) { + describe(`when the selected network client represents the Infura network "${infuraNetworkType}"`, () => { + refreshNetworkTests({ + expectedNetworkClientConfiguration: + buildInfuraNetworkClientConfiguration(infuraNetworkType), + expectedNetworkClientId: infuraNetworkType, + initialState: { + selectedNetworkClientId: infuraNetworkType, + }, + operation: async (controller) => { + await controller.rollbackToPreviousProvider(); + }, + }); + }); + } + + describe('when the selected network client represents a custom RPC endpoint', () => { + refreshNetworkTests({ + expectedNetworkClientConfiguration: + buildCustomNetworkClientConfiguration({ + rpcUrl: 'https://test.network', + chainId: '0x1337', + ticker: 'TEST', + }), + expectedNetworkClientId: 'AAAA-AAAA-AAAA-AAAA', + initialState: { + selectedNetworkClientId: 'AAAA-AAAA-AAAA-AAAA', + networkConfigurationsByChainId: { + '0x1337': buildCustomNetworkConfiguration({ + chainId: '0x1337', + nativeCurrency: 'TEST', + rpcEndpoints: [ + buildCustomRpcEndpoint({ + networkClientId: 'AAAA-AAAA-AAAA-AAAA', + url: 'https://test.network', + }), + ], + }), + }, + }, + operation: async (controller) => { + await controller.rollbackToPreviousProvider(); + }, + }); + }); + }); + + for (const infuraNetworkType of INFURA_NETWORKS) { + const infuraChainId = ChainId[infuraNetworkType]; + + describe(`when called following a switch away from the Infura network "${infuraNetworkType}"`, () => { + it('emits networkWillChange with state payload', async () => { + await withController( + { + state: { + selectedNetworkClientId: infuraNetworkType, + networkConfigurationsByChainId: { + [infuraChainId]: + buildInfuraNetworkConfiguration(infuraNetworkType), + '0x1337': buildCustomNetworkConfiguration({ + chainId: '0x1337', + rpcEndpoints: [ + buildCustomRpcEndpoint({ + networkClientId: 'AAAA-AAAA-AAAA-AAAA', + }), + ], + }), + }, + }, + }, + async ({ controller, messenger }) => { + const fakeProvider = buildFakeProvider(); + const fakeNetworkClient = buildFakeClient(fakeProvider); + mockCreateNetworkClient().mockReturnValue(fakeNetworkClient); + await controller.setActiveNetwork('AAAA-AAAA-AAAA-AAAA'); + + const networkWillChange = waitForPublishedEvents({ + messenger, + eventType: 'NetworkController:networkWillChange', + filter: ([networkState]) => networkState === controller.state, + operation: () => { + // Intentionally not awaited because we're capturing an event + // emitted partway through the operation + // eslint-disable-next-line @typescript-eslint/no-floating-promises + controller.rollbackToPreviousProvider(); + }, + }); + + await expect(networkWillChange).toBeFulfilled(); + }, + ); + }); + + it('emits networkDidChange with state payload', async () => { + await withController( + { + state: { + selectedNetworkClientId: infuraNetworkType, + networkConfigurationsByChainId: { + [infuraChainId]: + buildInfuraNetworkConfiguration(infuraNetworkType), + '0x1337': buildCustomNetworkConfiguration({ + chainId: '0x1337', + rpcEndpoints: [ + buildCustomRpcEndpoint({ + networkClientId: 'AAAA-AAAA-AAAA-AAAA', + }), + ], + }), + }, + }, + }, + async ({ controller, messenger }) => { + const fakeProvider = buildFakeProvider(); + const fakeNetworkClient = buildFakeClient(fakeProvider); + mockCreateNetworkClient().mockReturnValue(fakeNetworkClient); + await controller.setActiveNetwork('AAAA-AAAA-AAAA-AAAA'); + + const networkDidChange = waitForPublishedEvents({ + messenger, + eventType: 'NetworkController:networkDidChange', + filter: ([networkState]) => networkState === controller.state, + operation: () => { + // Intentionally not awaited because we're capturing an event + // emitted partway through the operation + // TODO: Either fix this lint violation or explain why it's necessary to ignore. + // eslint-disable-next-line @typescript-eslint/no-floating-promises + controller.rollbackToPreviousProvider(); + }, + }); + + await expect(networkDidChange).toBeFulfilled(); + }, + ); + }); + + it('sets selectedNetworkClientId in state to the previous version', async () => { + const infuraProjectId = 'some-infura-project-id'; + + await withController( + { + state: { + selectedNetworkClientId: infuraNetworkType, + networkConfigurationsByChainId: { + [infuraChainId]: + buildInfuraNetworkConfiguration(infuraNetworkType), + '0x1337': buildCustomNetworkConfiguration({ + chainId: '0x1337', + rpcEndpoints: [ + buildCustomRpcEndpoint({ + networkClientId: 'AAAA-AAAA-AAAA-AAAA', + }), + ], + }), + }, + }, + infuraProjectId, + }, + async ({ controller }) => { + const fakeProviders = [buildFakeProvider(), buildFakeProvider()]; + const fakeNetworkClients = [ + buildFakeClient(fakeProviders[0]), + buildFakeClient(fakeProviders[1]), + ]; + createNetworkClientMock.mockImplementation( + ({ configuration }) => { + if (configuration.chainId === '0x1337') { + return fakeNetworkClients[0]; + } else if (configuration.chainId === infuraChainId) { + return fakeNetworkClients[1]; + } + throw new Error( + `Unknown network client configuration ${JSON.stringify( + configuration, + )}`, + ); + }, + ); + await controller.setActiveNetwork('AAAA-AAAA-AAAA-AAAA'); + expect(controller.state.selectedNetworkClientId).toBe( + 'AAAA-AAAA-AAAA-AAAA', + ); + + await controller.rollbackToPreviousProvider(); + + expect(controller.state.selectedNetworkClientId).toBe( + infuraNetworkType, + ); + }, + ); + }); + + it('resets the network status to "unknown" before updating the provider', async () => { + const infuraProjectId = 'some-infura-project-id'; + + await withController( + { + initializeController: false, + state: { + selectedNetworkClientId: infuraNetworkType, + networkConfigurationsByChainId: { + [infuraChainId]: + buildInfuraNetworkConfiguration(infuraNetworkType), + '0x1337': buildCustomNetworkConfiguration({ + chainId: '0x1337', + rpcEndpoints: [ + buildCustomRpcEndpoint({ + networkClientId: 'AAAA-AAAA-AAAA-AAAA', + }), + ], + }), + }, + }, + infuraProjectId, + }, + async ({ controller, messenger }) => { + const fakeProviders = [ + buildFakeProvider([ + { + request: { + method: 'eth_getBlockByNumber', + }, + response: SUCCESSFUL_ETH_GET_BLOCK_BY_NUMBER_RESPONSE, + }, + ]), + buildFakeProvider(), + ]; + const fakeNetworkClients = [ + buildFakeClient(fakeProviders[0]), + buildFakeClient(fakeProviders[1]), + ]; + createNetworkClientMock.mockImplementation( + ({ configuration }) => { + if (configuration.chainId === '0x1337') { + return fakeNetworkClients[0]; + } else if (configuration.chainId === infuraChainId) { + return fakeNetworkClients[1]; + } + throw new Error( + `Unknown network client configuration ${JSON.stringify( + configuration, + )}`, + ); + }, + ); + await controller.setActiveNetwork('AAAA-AAAA-AAAA-AAAA'); + expect( + controller.state.networksMetadata['AAAA-AAAA-AAAA-AAAA'].status, + ).toBe('available'); + + await waitForStateChanges({ + messenger, + propertyPath: ['networksMetadata', infuraNetworkType, 'status'], + // We only care about the first state change, because it + // happens before networkDidChange + count: 1, + operation: () => { + // Intentionally not awaited because we want to check state + // while this operation is in-progress + // eslint-disable-next-line @typescript-eslint/no-floating-promises + controller.rollbackToPreviousProvider(); + }, + beforeResolving: () => { + expect( + controller.state.networksMetadata[infuraNetworkType].status, + ).toBe('unknown'); + }, + }); + }, + ); + }); + + it(`initializes a provider pointed to the "${infuraNetworkType}" Infura network`, async () => { + const infuraProjectId = 'some-infura-project-id'; + + await withController( + { + state: { + selectedNetworkClientId: infuraNetworkType, + networkConfigurationsByChainId: { + [infuraChainId]: + buildInfuraNetworkConfiguration(infuraNetworkType), + '0x1337': buildCustomNetworkConfiguration({ + chainId: '0x1337', + rpcEndpoints: [ + buildCustomRpcEndpoint({ + networkClientId: 'AAAA-AAAA-AAAA-AAAA', + }), + ], + }), + }, + }, + infuraProjectId, + }, + async ({ controller }) => { + const fakeProviders = [ + buildFakeProvider(), + buildFakeProvider([ + { + request: { + method: 'test', + }, + response: { + result: 'test response', + }, + }, + ]), + ]; + const fakeNetworkClients = [ + buildFakeClient(fakeProviders[0]), + buildFakeClient(fakeProviders[1]), + ]; + createNetworkClientMock.mockImplementation( + ({ configuration }) => { + if (configuration.chainId === '0x1337') { + return fakeNetworkClients[0]; + } else if (configuration.chainId === infuraChainId) { + return fakeNetworkClients[1]; + } + throw new Error( + `Unknown network client configuration ${JSON.stringify( + configuration, + )}`, + ); + }, + ); + await controller.setActiveNetwork('AAAA-AAAA-AAAA-AAAA'); + + await controller.rollbackToPreviousProvider(); + + const networkClient = controller.getSelectedNetworkClient(); + assert(networkClient, 'Network client is somehow unset'); + const result = await networkClient.provider.request({ + id: '1', + jsonrpc: '2.0', + method: 'test', + }); + expect(result).toBe('test response'); + }, + ); + }); + + it('replaces the provider object underlying the provider proxy without creating a new instance of the proxy itself', async () => { + const infuraProjectId = 'some-infura-project-id'; + + await withController( + { + state: { + selectedNetworkClientId: infuraNetworkType, + networkConfigurationsByChainId: { + [infuraChainId]: + buildInfuraNetworkConfiguration(infuraNetworkType), + '0x1337': buildCustomNetworkConfiguration({ + chainId: '0x1337', + rpcEndpoints: [ + buildCustomRpcEndpoint({ + networkClientId: 'AAAA-AAAA-AAAA-AAAA', + }), + ], + }), + }, + }, + infuraProjectId, + }, + async ({ controller }) => { + const fakeProviders = [buildFakeProvider(), buildFakeProvider()]; + const fakeNetworkClients = [ + buildFakeClient(fakeProviders[0]), + buildFakeClient(fakeProviders[1]), + ]; + createNetworkClientMock.mockImplementation( + ({ configuration }) => { + if (configuration.chainId === '0x1337') { + return fakeNetworkClients[0]; + } else if (configuration.chainId === infuraChainId) { + return fakeNetworkClients[1]; + } + throw new Error( + `Unknown network client configuration ${JSON.stringify( + configuration, + )}`, + ); + }, + ); + await controller.setActiveNetwork('AAAA-AAAA-AAAA-AAAA'); + const networkClientBefore = controller.getSelectedNetworkClient(); + assert(networkClientBefore, 'Network client is somehow unset'); + + await controller.rollbackToPreviousProvider(); + + const networkClientAfter = controller.getSelectedNetworkClient(); + assert(networkClientAfter, 'Network client is somehow unset'); + expect(networkClientBefore.provider).toBe( + networkClientAfter.provider, + ); + }, + ); + }); + + it('emits infuraIsBlocked or infuraIsUnblocked, depending on whether Infura is blocking requests for the previous network', async () => { + const infuraProjectId = 'some-infura-project-id'; + + await withController( + { + state: { + selectedNetworkClientId: infuraNetworkType, + networkConfigurationsByChainId: { + [infuraChainId]: + buildInfuraNetworkConfiguration(infuraNetworkType), + '0x1337': buildCustomNetworkConfiguration({ + chainId: '0x1337', + rpcEndpoints: [ + buildCustomRpcEndpoint({ + networkClientId: 'AAAA-AAAA-AAAA-AAAA', + }), + ], + }), + }, + }, + infuraProjectId, + }, + async ({ controller, messenger }) => { + const fakeProviders = [ + buildFakeProvider(), + buildFakeProvider([ + { + request: { + method: 'eth_getBlockByNumber', + }, + error: BLOCKED_INFURA_JSON_RPC_ERROR, + }, + ]), + ]; + const fakeNetworkClients = [ + buildFakeClient(fakeProviders[0]), + buildFakeClient(fakeProviders[1]), + ]; + createNetworkClientMock.mockImplementation( + ({ configuration }) => { + if (configuration.chainId === '0x1337') { + return fakeNetworkClients[0]; + } else if (configuration.chainId === infuraChainId) { + return fakeNetworkClients[1]; + } + throw new Error( + `Unknown network client configuration ${JSON.stringify( + configuration, + )}`, + ); + }, + ); + await controller.setActiveNetwork('AAAA-AAAA-AAAA-AAAA'); + const promiseForNoInfuraIsUnblockedEvents = + waitForPublishedEvents({ + messenger, + eventType: 'NetworkController:infuraIsUnblocked', + count: 0, + }); + const promiseForInfuraIsBlocked = waitForPublishedEvents({ + messenger, + eventType: 'NetworkController:infuraIsBlocked', + }); + + await controller.rollbackToPreviousProvider(); + + await expect(promiseForNoInfuraIsUnblockedEvents).toBeFulfilled(); + await expect(promiseForInfuraIsBlocked).toBeFulfilled(); + }, + ); + }); + + it('checks the status of the previous network again and updates state accordingly', async () => { + const infuraProjectId = 'some-infura-project-id'; + + await withController( + { + state: { + selectedNetworkClientId: infuraNetworkType, + networkConfigurationsByChainId: { + [infuraChainId]: + buildInfuraNetworkConfiguration(infuraNetworkType), + '0x1337': buildCustomNetworkConfiguration({ + chainId: '0x1337', + rpcEndpoints: [ + buildCustomRpcEndpoint({ + networkClientId: 'AAAA-AAAA-AAAA-AAAA', + }), + ], + }), + }, + }, + infuraProjectId, + }, + async ({ controller, messenger }) => { + const fakeProviders = [ + buildFakeProvider([ + { + request: { + method: 'eth_getBlockByNumber', + }, + error: rpcErrors.methodNotFound(), + }, + ]), + buildFakeProvider([ + { + request: { + method: 'eth_getBlockByNumber', + }, + response: SUCCESSFUL_ETH_GET_BLOCK_BY_NUMBER_RESPONSE, + }, + ]), + ]; + const fakeNetworkClients = [ + buildFakeClient(fakeProviders[0]), + buildFakeClient(fakeProviders[1]), + ]; + createNetworkClientMock.mockImplementation( + ({ configuration }) => { + if (configuration.chainId === '0x1337') { + return fakeNetworkClients[0]; + } else if (configuration.chainId === infuraChainId) { + return fakeNetworkClients[1]; + } + throw new Error( + `Unknown network client configuration ${JSON.stringify( + configuration, + )}`, + ); + }, + ); + await controller.setActiveNetwork('AAAA-AAAA-AAAA-AAAA'); + expect( + controller.state.networksMetadata['AAAA-AAAA-AAAA-AAAA'].status, + ).toBe('unavailable'); + + await waitForStateChanges({ + messenger, + propertyPath: ['networksMetadata', infuraNetworkType, 'status'], + operation: async () => { + await controller.rollbackToPreviousProvider(); + }, + }); + expect( + controller.state.networksMetadata[infuraNetworkType].status, + ).toBe('available'); + }, + ); + }); + + it('checks whether the previous network supports EIP-1559 again and updates state accordingly', async () => { + const infuraProjectId = 'some-infura-project-id'; + + await withController( + { + initializeController: false, + state: { + selectedNetworkClientId: infuraNetworkType, + networkConfigurationsByChainId: { + [infuraChainId]: + buildInfuraNetworkConfiguration(infuraNetworkType), + '0x1337': buildCustomNetworkConfiguration({ + chainId: '0x1337', + rpcEndpoints: [ + buildCustomRpcEndpoint({ + networkClientId: 'AAAA-AAAA-AAAA-AAAA', + }), + ], + }), + }, + }, + infuraProjectId, + }, + async ({ controller, messenger }) => { + const fakeProviders = [ + buildFakeProvider([ + { + request: { + method: 'eth_getBlockByNumber', + }, + response: { + result: PRE_1559_BLOCK, + }, + }, + ]), + buildFakeProvider([ + { + request: { + method: 'eth_getBlockByNumber', + }, + response: { + result: POST_1559_BLOCK, + }, + }, + ]), + ]; + const fakeNetworkClients = [ + buildFakeClient(fakeProviders[0]), + buildFakeClient(fakeProviders[1]), + ]; + createNetworkClientMock.mockImplementation( + ({ configuration }) => { + if (configuration.chainId === '0x1337') { + return fakeNetworkClients[0]; + } else if (configuration.chainId === infuraChainId) { + return fakeNetworkClients[1]; + } + throw new Error( + `Unknown network client configuration ${JSON.stringify( + configuration, + )}`, + ); + }, + ); + await controller.setActiveNetwork('AAAA-AAAA-AAAA-AAAA'); + expect( + controller.state.networksMetadata['AAAA-AAAA-AAAA-AAAA'] + .EIPS[1559], + ).toBe(false); + + await waitForStateChanges({ + messenger, + propertyPath: ['networksMetadata', infuraNetworkType, 'EIPS'], + count: 2, + operation: async () => { + await controller.rollbackToPreviousProvider(); + }, + }); + expect( + controller.state.networksMetadata[infuraNetworkType].EIPS[1559], + ).toBe(true); + }, + ); + }); + }); + } + + describe('when called following a switch away from a custom RPC endpoint', () => { + it('emits networkWillChange with state payload', async () => { + await withController( + { + state: { + selectedNetworkClientId: 'AAAA-AAAA-AAAA-AAAA', + networkConfigurationsByChainId: { + '0x1337': buildCustomNetworkConfiguration({ + chainId: '0x1337', + nativeCurrency: 'TEST', + rpcEndpoints: [ + buildCustomRpcEndpoint({ + networkClientId: 'AAAA-AAAA-AAAA-AAAA', + url: 'https://test.network', + }), + ], + }), + [TESTNET.chainId]: buildInfuraNetworkConfiguration( + TESTNET.networkType, + ), + }, + }, + }, + async ({ controller, messenger }) => { + const fakeProvider = buildFakeProvider(); + const fakeNetworkClient = buildFakeClient(fakeProvider); + mockCreateNetworkClient().mockReturnValue(fakeNetworkClient); + await controller.setActiveNetwork(TESTNET.networkType); + + const networkWillChange = waitForPublishedEvents({ + messenger, + eventType: 'NetworkController:networkWillChange', + filter: ([networkState]) => networkState === controller.state, + operation: () => { + // Intentionally not awaited because we're capturing an event + // emitted partway through the operation + // eslint-disable-next-line @typescript-eslint/no-floating-promises + controller.rollbackToPreviousProvider(); + }, + }); + + await expect(networkWillChange).toBeFulfilled(); + }, + ); + }); + + it('emits networkDidChange with state payload', async () => { + await withController( + { + state: { + selectedNetworkClientId: 'AAAA-AAAA-AAAA-AAAA', + networkConfigurationsByChainId: { + '0x1337': buildCustomNetworkConfiguration({ + chainId: '0x1337', + nativeCurrency: 'TEST', + rpcEndpoints: [ + buildCustomRpcEndpoint({ + networkClientId: 'AAAA-AAAA-AAAA-AAAA', + url: 'https://test.network', + }), + ], + }), + [TESTNET.chainId]: buildInfuraNetworkConfiguration( + TESTNET.networkType, + ), + }, + }, + }, + async ({ controller, messenger }) => { + const fakeProvider = buildFakeProvider(); + const fakeNetworkClient = buildFakeClient(fakeProvider); + mockCreateNetworkClient().mockReturnValue(fakeNetworkClient); + await controller.setActiveNetwork(TESTNET.networkType); + + const networkDidChange = waitForPublishedEvents({ + messenger, + eventType: 'NetworkController:networkDidChange', + filter: ([networkState]) => networkState === controller.state, + operation: () => { + // Intentionally not awaited because we're capturing an event + // emitted partway through the operation + // eslint-disable-next-line @typescript-eslint/no-floating-promises + controller.rollbackToPreviousProvider(); + }, + }); + + await expect(networkDidChange).toBeFulfilled(); + }, + ); + }); + + it('sets selectedNetworkClientId to the previous version', async () => { + const infuraProjectId = 'some-infura-project-id'; + + await withController( + { + state: { + selectedNetworkClientId: 'AAAA-AAAA-AAAA-AAAA', + networkConfigurationsByChainId: { + '0x1337': buildCustomNetworkConfiguration({ + chainId: '0x1337', + rpcEndpoints: [ + buildCustomRpcEndpoint({ + networkClientId: 'AAAA-AAAA-AAAA-AAAA', + }), + ], + }), + [TESTNET.chainId]: buildInfuraNetworkConfiguration( + TESTNET.networkType, + ), + }, + }, + infuraProjectId, + }, + async ({ controller }) => { + const fakeProviders = [buildFakeProvider(), buildFakeProvider()]; + const fakeNetworkClients = [ + buildFakeClient(fakeProviders[0]), + buildFakeClient(fakeProviders[1]), + ]; + createNetworkClientMock.mockImplementation(({ configuration }) => { + if (configuration.chainId === TESTNET.chainId) { + return fakeNetworkClients[0]; + } else if (configuration.chainId === '0x1337') { + return fakeNetworkClients[1]; + } + throw new Error( + `Unknown network client configuration ${JSON.stringify( + configuration, + )}`, + ); + }); + await controller.setActiveNetwork(TESTNET.networkType); + expect(controller.state.selectedNetworkClientId).toBe( + TESTNET.networkType, + ); + + await controller.rollbackToPreviousProvider(); + expect(controller.state.selectedNetworkClientId).toBe( + 'AAAA-AAAA-AAAA-AAAA', + ); + }, + ); + }); + + it('resets the network state to "unknown" before updating the provider', async () => { + const infuraProjectId = 'some-infura-project-id'; + + await withController( + { + initializeController: false, + state: { + selectedNetworkClientId: 'AAAA-AAAA-AAAA-AAAA', + networkConfigurationsByChainId: { + '0x1337': buildCustomNetworkConfiguration({ + chainId: '0x1337', + rpcEndpoints: [ + buildCustomRpcEndpoint({ + networkClientId: 'AAAA-AAAA-AAAA-AAAA', + }), + ], + }), + [TESTNET.chainId]: buildInfuraNetworkConfiguration( + TESTNET.networkType, + ), + }, + }, + infuraProjectId, + }, + async ({ controller, messenger }) => { + const fakeProviders = [ + buildFakeProvider([ + { + request: { + method: 'eth_getBlockByNumber', + }, + response: SUCCESSFUL_ETH_GET_BLOCK_BY_NUMBER_RESPONSE, + }, + ]), + buildFakeProvider(), + ]; + const fakeNetworkClients = [ + buildFakeClient(fakeProviders[0]), + buildFakeClient(fakeProviders[1]), + ]; + createNetworkClientMock.mockImplementation(({ configuration }) => { + if (configuration.chainId === TESTNET.chainId) { + return fakeNetworkClients[0]; + } else if (configuration.chainId === '0x1337') { + return fakeNetworkClients[1]; + } + throw new Error( + `Unknown network client configuration ${JSON.stringify( + configuration, + )}`, + ); + }); + await controller.setActiveNetwork(TESTNET.networkType); + expect( + controller.state.networksMetadata[ + controller.state.selectedNetworkClientId + ].status, + ).toBe('available'); + + await waitForStateChanges({ + messenger, + propertyPath: [ + 'networksMetadata', + 'AAAA-AAAA-AAAA-AAAA', + 'status', + ], + // We only care about the first state change, because it + // happens before networkDidChange + count: 1, + operation: () => { + // Intentionally not awaited because we want to check state + // while this operation is in-progress + // eslint-disable-next-line @typescript-eslint/no-floating-promises + controller.rollbackToPreviousProvider(); + }, + beforeResolving: () => { + expect( + controller.state.networksMetadata['AAAA-AAAA-AAAA-AAAA'] + .status, + ).toBe('unknown'); + }, + }); + }, + ); + }); + + it('initializes a provider pointed to the given RPC URL', async () => { + const infuraProjectId = 'some-infura-project-id'; + + await withController( + { + state: { + selectedNetworkClientId: 'AAAA-AAAA-AAAA-AAAA', + networkConfigurationsByChainId: { + '0x1337': buildCustomNetworkConfiguration({ + chainId: '0x1337', + rpcEndpoints: [ + buildCustomRpcEndpoint({ + networkClientId: 'AAAA-AAAA-AAAA-AAAA', + }), + ], + }), + [TESTNET.chainId]: buildInfuraNetworkConfiguration( + TESTNET.networkType, + ), + }, + }, + infuraProjectId, + }, + async ({ controller }) => { + const fakeProviders = [ + buildFakeProvider(), + buildFakeProvider([ + { + request: { + method: 'test_method', + }, + response: { + result: 'test response', + }, + }, + ]), + ]; + const fakeNetworkClients = [ + buildFakeClient(fakeProviders[0]), + buildFakeClient(fakeProviders[1]), + ]; + createNetworkClientMock.mockImplementation(({ configuration }) => { + if (configuration.chainId === TESTNET.chainId) { + return fakeNetworkClients[0]; + } else if (configuration.chainId === '0x1337') { + return fakeNetworkClients[1]; + } + throw new Error( + `Unknown network client configuration ${JSON.stringify( + configuration, + )}`, + ); + }); + await controller.setActiveNetwork(TESTNET.networkType); + + await controller.rollbackToPreviousProvider(); + + const networkClient = controller.getSelectedNetworkClient(); + assert(networkClient, 'Network client is somehow unset'); + const result = await networkClient.provider.request({ + id: '1', + jsonrpc: '2.0', + method: 'test_method', + }); + expect(result).toBe('test response'); + }, + ); + }); + + it('replaces the provider object underlying the provider proxy without creating a new instance of the proxy itself', async () => { + const infuraProjectId = 'some-infura-project-id'; + + await withController( + { + state: { + selectedNetworkClientId: 'AAAA-AAAA-AAAA-AAAA', + networkConfigurationsByChainId: { + '0x1337': buildCustomNetworkConfiguration({ + chainId: '0x1337', + rpcEndpoints: [ + buildCustomRpcEndpoint({ + networkClientId: 'AAAA-AAAA-AAAA-AAAA', + }), + ], + }), + [TESTNET.chainId]: buildInfuraNetworkConfiguration( + TESTNET.networkType, + ), + }, + }, + infuraProjectId, + }, + async ({ controller }) => { + const fakeProviders = [buildFakeProvider(), buildFakeProvider()]; + const fakeNetworkClients = [ + buildFakeClient(fakeProviders[0]), + buildFakeClient(fakeProviders[1]), + ]; + createNetworkClientMock.mockImplementation(({ configuration }) => { + if (configuration.chainId === TESTNET.chainId) { + return fakeNetworkClients[0]; + } else if (configuration.chainId === '0x1337') { + return fakeNetworkClients[1]; + } + throw new Error( + `Unknown network client configuration ${JSON.stringify( + configuration, + )}`, + ); + }); + await controller.setActiveNetwork(TESTNET.networkType); + const networkClientBefore = controller.getSelectedNetworkClient(); + assert(networkClientBefore, 'Network client is somehow unset'); + + await controller.rollbackToPreviousProvider(); + + const networkClientAfter = controller.getSelectedNetworkClient(); + assert(networkClientAfter, 'Network client is somehow unset'); + expect(networkClientBefore.provider).toBe( + networkClientAfter.provider, + ); + }, + ); + }); + + it('emits infuraIsUnblocked', async () => { + const infuraProjectId = 'some-infura-project-id'; + + await withController( + { + state: { + selectedNetworkClientId: 'AAAA-AAAA-AAAA-AAAA', + networkConfigurationsByChainId: { + '0x1337': buildCustomNetworkConfiguration({ + chainId: '0x1337', + rpcEndpoints: [ + buildCustomRpcEndpoint({ + networkClientId: 'AAAA-AAAA-AAAA-AAAA', + }), + ], + }), + [TESTNET.chainId]: buildInfuraNetworkConfiguration( + TESTNET.networkType, + ), + }, + }, + infuraProjectId, + }, + async ({ controller, messenger }) => { + const fakeProviders = [buildFakeProvider(), buildFakeProvider()]; + const fakeNetworkClients = [ + buildFakeClient(fakeProviders[0]), + buildFakeClient(fakeProviders[1]), + ]; + createNetworkClientMock.mockImplementation(({ configuration }) => { + if (configuration.chainId === TESTNET.chainId) { + return fakeNetworkClients[0]; + } else if (configuration.chainId === '0x1337') { + return fakeNetworkClients[1]; + } + throw new Error( + `Unknown network client configuration ${JSON.stringify( + configuration, + )}`, + ); + }); + await controller.setActiveNetwork(TESTNET.networkType); + + const promiseForInfuraIsUnblocked = waitForPublishedEvents({ + messenger, + eventType: 'NetworkController:infuraIsUnblocked', + operation: async () => { + await controller.rollbackToPreviousProvider(); + }, + }); + + await expect(promiseForInfuraIsUnblocked).toBeFulfilled(); + }, + ); + }); + + it('checks the status of the previous network again and updates state accordingly', async () => { + const infuraProjectId = 'some-infura-project-id'; + + await withController( + { + state: { + selectedNetworkClientId: 'AAAA-AAAA-AAAA-AAAA', + networkConfigurationsByChainId: { + '0x1337': buildCustomNetworkConfiguration({ + chainId: '0x1337', + rpcEndpoints: [ + buildCustomRpcEndpoint({ + networkClientId: 'AAAA-AAAA-AAAA-AAAA', + }), + ], + }), + [TESTNET.chainId]: buildInfuraNetworkConfiguration( + TESTNET.networkType, + ), + }, + }, + infuraProjectId, + }, + async ({ controller }) => { + const fakeProviders = [ + buildFakeProvider([ + { + request: { + method: 'eth_getBlockByNumber', + }, + error: rpcErrors.methodNotFound(), + }, + ]), + buildFakeProvider([ + { + request: { + method: 'eth_getBlockByNumber', + }, + response: SUCCESSFUL_ETH_GET_BLOCK_BY_NUMBER_RESPONSE, + }, + ]), + ]; + const fakeNetworkClients = [ + buildFakeClient(fakeProviders[0]), + buildFakeClient(fakeProviders[1]), + ]; + createNetworkClientMock.mockImplementation(({ configuration }) => { + if (configuration.chainId === TESTNET.chainId) { + return fakeNetworkClients[0]; + } else if (configuration.chainId === '0x1337') { + return fakeNetworkClients[1]; + } + throw new Error( + `Unknown network client configuration ${JSON.stringify( + configuration, + )}`, + ); + }); + await controller.setActiveNetwork(TESTNET.networkType); + expect( + controller.state.networksMetadata[TESTNET.networkType].status, + ).toBe('unavailable'); + + await controller.rollbackToPreviousProvider(); + expect( + controller.state.networksMetadata['AAAA-AAAA-AAAA-AAAA'].status, + ).toBe('available'); + }, + ); + }); + + it('checks whether the previous network supports EIP-1559 again and updates state accordingly', async () => { + const infuraProjectId = 'some-infura-project-id'; + + await withController( + { + state: { + selectedNetworkClientId: 'AAAA-AAAA-AAAA-AAAA', + networkConfigurationsByChainId: { + '0x1337': buildCustomNetworkConfiguration({ + chainId: '0x1337', + rpcEndpoints: [ + buildCustomRpcEndpoint({ + networkClientId: 'AAAA-AAAA-AAAA-AAAA', + }), + ], + }), + [TESTNET.chainId]: buildInfuraNetworkConfiguration( + TESTNET.networkType, + ), + }, + }, + infuraProjectId, + }, + async ({ controller }) => { + const fakeProviders = [ + buildFakeProvider([ + { + request: { + method: 'eth_getBlockByNumber', + }, + response: { + result: PRE_1559_BLOCK, + }, + }, + ]), + buildFakeProvider([ + { + request: { + method: 'eth_getBlockByNumber', + }, + response: { + result: POST_1559_BLOCK, + }, + }, + ]), + ]; + const fakeNetworkClients = [ + buildFakeClient(fakeProviders[0]), + buildFakeClient(fakeProviders[1]), + ]; + createNetworkClientMock.mockImplementation(({ configuration }) => { + if (configuration.chainId === TESTNET.chainId) { + return fakeNetworkClients[0]; + } else if (configuration.chainId === '0x1337') { + return fakeNetworkClients[1]; + } + throw new Error( + `Unknown network client configuration ${JSON.stringify( + configuration, + )}`, + ); + }); + await controller.setActiveNetwork(TESTNET.networkType); + expect( + controller.state.networksMetadata[TESTNET.networkType].EIPS[1559], + ).toBe(false); + + await controller.rollbackToPreviousProvider(); + expect( + controller.state.networksMetadata['AAAA-AAAA-AAAA-AAAA'] + .EIPS[1559], + ).toBe(true); + }, + ); + }); + }); + }); + + describe('loadBackup', () => { + it('merges the network configurations from the given backup into state', async () => { + await withController( + { + state: buildNetworkControllerStateWithDefaultSelectedNetworkClientId({ + networkConfigurationsByChainId: { + '0x1337': { + blockExplorerUrls: [], + chainId: '0x1337' as const, + defaultRpcEndpointIndex: 0, + name: 'Test Network 1', + nativeCurrency: 'TOKEN1', + rpcEndpoints: [ + { + failoverUrls: [], + name: 'Test Endpoint', + networkClientId: 'AAAA-AAAA-AAAA-AAAA', + url: 'https://test.network/1', + type: RpcEndpointType.Custom, + }, + ], + }, + }, + }), + }, + ({ controller }) => { + controller.loadBackup({ + networkConfigurationsByChainId: { + '0x2448': { + blockExplorerUrls: [], + chainId: '0x2448' as const, + defaultRpcEndpointIndex: 0, + name: 'Test Network 2', + nativeCurrency: 'TOKEN2', + rpcEndpoints: [ + { + failoverUrls: [], + name: 'Test Endpoint', + networkClientId: 'BBBB-BBBB-BBBB-BBBB', + url: 'https://test.network/2', + type: RpcEndpointType.Custom, + }, + ], + }, + }, + }); + + expect(controller.state.networkConfigurationsByChainId).toStrictEqual( + { + '0x1337': { + blockExplorerUrls: [], + chainId: '0x1337' as const, + defaultRpcEndpointIndex: 0, + name: 'Test Network 1', + nativeCurrency: 'TOKEN1', + rpcEndpoints: [ + { + failoverUrls: [], + name: 'Test Endpoint', + networkClientId: 'AAAA-AAAA-AAAA-AAAA', + url: 'https://test.network/1', + type: RpcEndpointType.Custom, + }, + ], + }, + '0x2448': { + blockExplorerUrls: [], + chainId: '0x2448' as const, + defaultRpcEndpointIndex: 0, + name: 'Test Network 2', + nativeCurrency: 'TOKEN2', + rpcEndpoints: [ + { + failoverUrls: [], + name: 'Test Endpoint', + networkClientId: 'BBBB-BBBB-BBBB-BBBB', + url: 'https://test.network/2', + type: RpcEndpointType.Custom, + }, + ], + }, + }, + ); + }, + ); + }); + }); + + describe('getSelectedNetworkClient', () => { + it('returns the selected network provider and blockTracker proxy when initialized', async () => { + await withController(async ({ controller }) => { + const fakeProvider = buildFakeProvider(); + const fakeNetworkClient = buildFakeClient(fakeProvider); + mockCreateNetworkClient().mockReturnValue(fakeNetworkClient); + await controller.lookupNetwork(); + const defaultNetworkClient = controller.getProviderAndBlockTracker(); + + const selectedNetworkClient = controller.getSelectedNetworkClient(); + expect(defaultNetworkClient.provider).toBe( + selectedNetworkClient?.provider, + ); + expect(defaultNetworkClient.blockTracker).toBe( + selectedNetworkClient?.blockTracker, + ); + }); + }); + + it('returns undefined when the selected network provider and blockTracker proxy are not initialized', async () => { + await withController( + { initializeController: false }, + async ({ controller }) => { + const selectedNetworkClient = controller.getSelectedNetworkClient(); + expect(selectedNetworkClient).toBeUndefined(); + }, + ); + }); + }); + + 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( + { initializeController: false }, + ({ controller }) => { + expect( + deriveStateFromMetadata( + controller.state, + controller.metadata, + 'includeInStateLogs', + ), + ).toMatchInlineSnapshot(` + { + "networkConfigurationsByChainId": { + "0x1": { + "blockExplorerUrls": [ + "https://etherscan.io", + ], + "chainId": "0x1", + "defaultBlockExplorerUrlIndex": 0, + "defaultRpcEndpointIndex": 0, + "name": "Ethereum", + "nativeCurrency": "ETH", + "rpcEndpoints": [ + { + "failoverUrls": [], + "networkClientId": "mainnet", + "type": "infura", + "url": "https://mainnet.infura.io/v3/{infuraProjectId}", + }, + ], + }, + "0x18c7": { + "blockExplorerUrls": [ + "https://megaeth-testnet-v2.blockscout.com", + ], + "chainId": "0x18c7", + "defaultBlockExplorerUrlIndex": 0, + "defaultRpcEndpointIndex": 0, + "name": "MegaETH Testnet", + "nativeCurrency": "MegaETH", + "rpcEndpoints": [ + { + "failoverUrls": [], + "networkClientId": "megaeth-testnet-v2", + "type": "custom", + "url": "https://carrot.megaeth.com/rpc", + }, + ], + }, + "0x2105": { + "blockExplorerUrls": [ + "https://basescan.org", + ], + "chainId": "0x2105", + "defaultBlockExplorerUrlIndex": 0, + "defaultRpcEndpointIndex": 0, + "name": "Base", + "nativeCurrency": "ETH", + "rpcEndpoints": [ + { + "failoverUrls": [], + "networkClientId": "base-mainnet", + "type": "infura", + "url": "https://base-mainnet.infura.io/v3/{infuraProjectId}", + }, + ], + }, + "0x279f": { + "blockExplorerUrls": [ + "https://testnet.monadexplorer.com", + ], + "chainId": "0x279f", + "defaultBlockExplorerUrlIndex": 0, + "defaultRpcEndpointIndex": 0, + "name": "Monad Testnet", + "nativeCurrency": "MON", + "rpcEndpoints": [ + { + "failoverUrls": [], + "networkClientId": "monad-testnet", + "type": "custom", + "url": "https://testnet-rpc.monad.xyz", + }, + ], + }, + "0x38": { + "blockExplorerUrls": [ + "https://bscscan.com", + ], + "chainId": "0x38", + "defaultBlockExplorerUrlIndex": 0, + "defaultRpcEndpointIndex": 0, + "name": "BNB Chain", + "nativeCurrency": "BNB", + "rpcEndpoints": [ + { + "failoverUrls": [], + "networkClientId": "bsc-mainnet", + "type": "infura", + "url": "https://bsc-mainnet.infura.io/v3/{infuraProjectId}", + }, + ], + }, + "0x89": { + "blockExplorerUrls": [ + "https://polygonscan.com", + ], + "chainId": "0x89", + "defaultBlockExplorerUrlIndex": 0, + "defaultRpcEndpointIndex": 0, + "name": "Polygon", + "nativeCurrency": "POL", + "rpcEndpoints": [ + { + "failoverUrls": [], + "networkClientId": "polygon-mainnet", + "type": "infura", + "url": "https://polygon-mainnet.infura.io/v3/{infuraProjectId}", + }, + ], + }, + "0x8f": { + "blockExplorerUrls": [ + "https://monadscan.com", + ], + "chainId": "0x8f", + "defaultBlockExplorerUrlIndex": 0, + "defaultRpcEndpointIndex": 0, + "name": "Monad", + "nativeCurrency": "MON", + "rpcEndpoints": [ + { + "failoverUrls": [], + "networkClientId": "monad-mainnet", + "type": "infura", + "url": "https://monad-mainnet.infura.io/v3/{infuraProjectId}", + }, + ], + }, + "0xa": { + "blockExplorerUrls": [ + "https://optimistic.etherscan.io", + ], + "chainId": "0xa", + "defaultBlockExplorerUrlIndex": 0, + "defaultRpcEndpointIndex": 0, + "name": "OP", + "nativeCurrency": "ETH", + "rpcEndpoints": [ + { + "failoverUrls": [], + "networkClientId": "optimism-mainnet", + "type": "infura", + "url": "https://optimism-mainnet.infura.io/v3/{infuraProjectId}", + }, + ], + }, + "0xa4b1": { + "blockExplorerUrls": [ + "https://arbiscan.io", + ], + "chainId": "0xa4b1", + "defaultBlockExplorerUrlIndex": 0, + "defaultRpcEndpointIndex": 0, + "name": "Arbitrum", + "nativeCurrency": "ETH", + "rpcEndpoints": [ + { + "failoverUrls": [], + "networkClientId": "arbitrum-mainnet", + "type": "infura", + "url": "https://arbitrum-mainnet.infura.io/v3/{infuraProjectId}", + }, + ], + }, + "0xaa36a7": { + "blockExplorerUrls": [ + "https://sepolia.etherscan.io", + ], + "chainId": "0xaa36a7", + "defaultBlockExplorerUrlIndex": 0, + "defaultRpcEndpointIndex": 0, + "name": "Sepolia", + "nativeCurrency": "SepoliaETH", + "rpcEndpoints": [ + { + "failoverUrls": [], + "networkClientId": "sepolia", + "type": "infura", + "url": "https://sepolia.infura.io/v3/{infuraProjectId}", + }, + ], + }, + "0xe705": { + "blockExplorerUrls": [ + "https://sepolia.lineascan.build", + ], + "chainId": "0xe705", + "defaultBlockExplorerUrlIndex": 0, + "defaultRpcEndpointIndex": 0, + "name": "Linea Sepolia", + "nativeCurrency": "LineaETH", + "rpcEndpoints": [ + { + "failoverUrls": [], + "networkClientId": "linea-sepolia", + "type": "infura", + "url": "https://linea-sepolia.infura.io/v3/{infuraProjectId}", + }, + ], + }, + "0xe708": { + "blockExplorerUrls": [ + "https://lineascan.build", + ], + "chainId": "0xe708", + "defaultBlockExplorerUrlIndex": 0, + "defaultRpcEndpointIndex": 0, + "name": "Linea", + "nativeCurrency": "ETH", + "rpcEndpoints": [ + { + "failoverUrls": [], + "networkClientId": "linea-mainnet", + "type": "infura", + "url": "https://linea-mainnet.infura.io/v3/{infuraProjectId}", + }, + ], + }, + }, + "networksMetadata": {}, + "selectedNetworkClientId": "mainnet", + } + `); + }, + ); + }); + + it('persists expected state', async () => { + await withController( + { initializeController: false }, + ({ controller }) => { + expect( + deriveStateFromMetadata( + controller.state, + controller.metadata, + 'persist', + ), + ).toMatchInlineSnapshot(` + { + "networkConfigurationsByChainId": { + "0x1": { + "blockExplorerUrls": [ + "https://etherscan.io", + ], + "chainId": "0x1", + "defaultBlockExplorerUrlIndex": 0, + "defaultRpcEndpointIndex": 0, + "name": "Ethereum", + "nativeCurrency": "ETH", + "rpcEndpoints": [ + { + "failoverUrls": [], + "networkClientId": "mainnet", + "type": "infura", + "url": "https://mainnet.infura.io/v3/{infuraProjectId}", + }, + ], + }, + "0x18c7": { + "blockExplorerUrls": [ + "https://megaeth-testnet-v2.blockscout.com", + ], + "chainId": "0x18c7", + "defaultBlockExplorerUrlIndex": 0, + "defaultRpcEndpointIndex": 0, + "name": "MegaETH Testnet", + "nativeCurrency": "MegaETH", + "rpcEndpoints": [ + { + "failoverUrls": [], + "networkClientId": "megaeth-testnet-v2", + "type": "custom", + "url": "https://carrot.megaeth.com/rpc", + }, + ], + }, + "0x2105": { + "blockExplorerUrls": [ + "https://basescan.org", + ], + "chainId": "0x2105", + "defaultBlockExplorerUrlIndex": 0, + "defaultRpcEndpointIndex": 0, + "name": "Base", + "nativeCurrency": "ETH", + "rpcEndpoints": [ + { + "failoverUrls": [], + "networkClientId": "base-mainnet", + "type": "infura", + "url": "https://base-mainnet.infura.io/v3/{infuraProjectId}", + }, + ], + }, + "0x279f": { + "blockExplorerUrls": [ + "https://testnet.monadexplorer.com", + ], + "chainId": "0x279f", + "defaultBlockExplorerUrlIndex": 0, + "defaultRpcEndpointIndex": 0, + "name": "Monad Testnet", + "nativeCurrency": "MON", + "rpcEndpoints": [ + { + "failoverUrls": [], + "networkClientId": "monad-testnet", + "type": "custom", + "url": "https://testnet-rpc.monad.xyz", + }, + ], + }, + "0x38": { + "blockExplorerUrls": [ + "https://bscscan.com", + ], + "chainId": "0x38", + "defaultBlockExplorerUrlIndex": 0, + "defaultRpcEndpointIndex": 0, + "name": "BNB Chain", + "nativeCurrency": "BNB", + "rpcEndpoints": [ + { + "failoverUrls": [], + "networkClientId": "bsc-mainnet", + "type": "infura", + "url": "https://bsc-mainnet.infura.io/v3/{infuraProjectId}", + }, + ], + }, + "0x89": { + "blockExplorerUrls": [ + "https://polygonscan.com", + ], + "chainId": "0x89", + "defaultBlockExplorerUrlIndex": 0, + "defaultRpcEndpointIndex": 0, + "name": "Polygon", + "nativeCurrency": "POL", + "rpcEndpoints": [ + { + "failoverUrls": [], + "networkClientId": "polygon-mainnet", + "type": "infura", + "url": "https://polygon-mainnet.infura.io/v3/{infuraProjectId}", + }, + ], + }, + "0x8f": { + "blockExplorerUrls": [ + "https://monadscan.com", + ], + "chainId": "0x8f", + "defaultBlockExplorerUrlIndex": 0, + "defaultRpcEndpointIndex": 0, + "name": "Monad", + "nativeCurrency": "MON", + "rpcEndpoints": [ + { + "failoverUrls": [], + "networkClientId": "monad-mainnet", + "type": "infura", + "url": "https://monad-mainnet.infura.io/v3/{infuraProjectId}", + }, + ], + }, + "0xa": { + "blockExplorerUrls": [ + "https://optimistic.etherscan.io", + ], + "chainId": "0xa", + "defaultBlockExplorerUrlIndex": 0, + "defaultRpcEndpointIndex": 0, + "name": "OP", + "nativeCurrency": "ETH", + "rpcEndpoints": [ + { + "failoverUrls": [], + "networkClientId": "optimism-mainnet", + "type": "infura", + "url": "https://optimism-mainnet.infura.io/v3/{infuraProjectId}", + }, + ], + }, + "0xa4b1": { + "blockExplorerUrls": [ + "https://arbiscan.io", + ], + "chainId": "0xa4b1", + "defaultBlockExplorerUrlIndex": 0, + "defaultRpcEndpointIndex": 0, + "name": "Arbitrum", + "nativeCurrency": "ETH", + "rpcEndpoints": [ + { + "failoverUrls": [], + "networkClientId": "arbitrum-mainnet", + "type": "infura", + "url": "https://arbitrum-mainnet.infura.io/v3/{infuraProjectId}", + }, + ], + }, + "0xaa36a7": { + "blockExplorerUrls": [ + "https://sepolia.etherscan.io", + ], + "chainId": "0xaa36a7", + "defaultBlockExplorerUrlIndex": 0, + "defaultRpcEndpointIndex": 0, + "name": "Sepolia", + "nativeCurrency": "SepoliaETH", + "rpcEndpoints": [ + { + "failoverUrls": [], + "networkClientId": "sepolia", + "type": "infura", + "url": "https://sepolia.infura.io/v3/{infuraProjectId}", + }, + ], + }, + "0xe705": { + "blockExplorerUrls": [ + "https://sepolia.lineascan.build", + ], + "chainId": "0xe705", + "defaultBlockExplorerUrlIndex": 0, + "defaultRpcEndpointIndex": 0, + "name": "Linea Sepolia", + "nativeCurrency": "LineaETH", + "rpcEndpoints": [ + { + "failoverUrls": [], + "networkClientId": "linea-sepolia", + "type": "infura", + "url": "https://linea-sepolia.infura.io/v3/{infuraProjectId}", + }, + ], + }, + "0xe708": { + "blockExplorerUrls": [ + "https://lineascan.build", + ], + "chainId": "0xe708", + "defaultBlockExplorerUrlIndex": 0, + "defaultRpcEndpointIndex": 0, + "name": "Linea", + "nativeCurrency": "ETH", + "rpcEndpoints": [ + { + "failoverUrls": [], + "networkClientId": "linea-mainnet", + "type": "infura", + "url": "https://linea-mainnet.infura.io/v3/{infuraProjectId}", + }, + ], + }, + }, + "networksMetadata": {}, + "selectedNetworkClientId": "mainnet", + } + `); + }, + ); + }); + + it('exposes expected state to UI', async () => { + await withController( + { initializeController: false }, + ({ controller }) => { + expect( + deriveStateFromMetadata( + controller.state, + controller.metadata, + 'usedInUi', + ), + ).toMatchInlineSnapshot(` + { + "networkConfigurationsByChainId": { + "0x1": { + "blockExplorerUrls": [ + "https://etherscan.io", + ], + "chainId": "0x1", + "defaultBlockExplorerUrlIndex": 0, + "defaultRpcEndpointIndex": 0, + "name": "Ethereum", + "nativeCurrency": "ETH", + "rpcEndpoints": [ + { + "failoverUrls": [], + "networkClientId": "mainnet", + "type": "infura", + "url": "https://mainnet.infura.io/v3/{infuraProjectId}", + }, + ], + }, + "0x18c7": { + "blockExplorerUrls": [ + "https://megaeth-testnet-v2.blockscout.com", + ], + "chainId": "0x18c7", + "defaultBlockExplorerUrlIndex": 0, + "defaultRpcEndpointIndex": 0, + "name": "MegaETH Testnet", + "nativeCurrency": "MegaETH", + "rpcEndpoints": [ + { + "failoverUrls": [], + "networkClientId": "megaeth-testnet-v2", + "type": "custom", + "url": "https://carrot.megaeth.com/rpc", + }, + ], + }, + "0x2105": { + "blockExplorerUrls": [ + "https://basescan.org", + ], + "chainId": "0x2105", + "defaultBlockExplorerUrlIndex": 0, + "defaultRpcEndpointIndex": 0, + "name": "Base", + "nativeCurrency": "ETH", + "rpcEndpoints": [ + { + "failoverUrls": [], + "networkClientId": "base-mainnet", + "type": "infura", + "url": "https://base-mainnet.infura.io/v3/{infuraProjectId}", + }, + ], + }, + "0x279f": { + "blockExplorerUrls": [ + "https://testnet.monadexplorer.com", + ], + "chainId": "0x279f", + "defaultBlockExplorerUrlIndex": 0, + "defaultRpcEndpointIndex": 0, + "name": "Monad Testnet", + "nativeCurrency": "MON", + "rpcEndpoints": [ + { + "failoverUrls": [], + "networkClientId": "monad-testnet", + "type": "custom", + "url": "https://testnet-rpc.monad.xyz", + }, + ], + }, + "0x38": { + "blockExplorerUrls": [ + "https://bscscan.com", + ], + "chainId": "0x38", + "defaultBlockExplorerUrlIndex": 0, + "defaultRpcEndpointIndex": 0, + "name": "BNB Chain", + "nativeCurrency": "BNB", + "rpcEndpoints": [ + { + "failoverUrls": [], + "networkClientId": "bsc-mainnet", + "type": "infura", + "url": "https://bsc-mainnet.infura.io/v3/{infuraProjectId}", + }, + ], + }, + "0x89": { + "blockExplorerUrls": [ + "https://polygonscan.com", + ], + "chainId": "0x89", + "defaultBlockExplorerUrlIndex": 0, + "defaultRpcEndpointIndex": 0, + "name": "Polygon", + "nativeCurrency": "POL", + "rpcEndpoints": [ + { + "failoverUrls": [], + "networkClientId": "polygon-mainnet", + "type": "infura", + "url": "https://polygon-mainnet.infura.io/v3/{infuraProjectId}", + }, + ], + }, + "0x8f": { + "blockExplorerUrls": [ + "https://monadscan.com", + ], + "chainId": "0x8f", + "defaultBlockExplorerUrlIndex": 0, + "defaultRpcEndpointIndex": 0, + "name": "Monad", + "nativeCurrency": "MON", + "rpcEndpoints": [ + { + "failoverUrls": [], + "networkClientId": "monad-mainnet", + "type": "infura", + "url": "https://monad-mainnet.infura.io/v3/{infuraProjectId}", + }, + ], + }, + "0xa": { + "blockExplorerUrls": [ + "https://optimistic.etherscan.io", + ], + "chainId": "0xa", + "defaultBlockExplorerUrlIndex": 0, + "defaultRpcEndpointIndex": 0, + "name": "OP", + "nativeCurrency": "ETH", + "rpcEndpoints": [ + { + "failoverUrls": [], + "networkClientId": "optimism-mainnet", + "type": "infura", + "url": "https://optimism-mainnet.infura.io/v3/{infuraProjectId}", + }, + ], + }, + "0xa4b1": { + "blockExplorerUrls": [ + "https://arbiscan.io", + ], + "chainId": "0xa4b1", + "defaultBlockExplorerUrlIndex": 0, + "defaultRpcEndpointIndex": 0, + "name": "Arbitrum", + "nativeCurrency": "ETH", + "rpcEndpoints": [ + { + "failoverUrls": [], + "networkClientId": "arbitrum-mainnet", + "type": "infura", + "url": "https://arbitrum-mainnet.infura.io/v3/{infuraProjectId}", + }, + ], + }, + "0xaa36a7": { + "blockExplorerUrls": [ + "https://sepolia.etherscan.io", + ], + "chainId": "0xaa36a7", + "defaultBlockExplorerUrlIndex": 0, + "defaultRpcEndpointIndex": 0, + "name": "Sepolia", + "nativeCurrency": "SepoliaETH", + "rpcEndpoints": [ + { + "failoverUrls": [], + "networkClientId": "sepolia", + "type": "infura", + "url": "https://sepolia.infura.io/v3/{infuraProjectId}", + }, + ], + }, + "0xe705": { + "blockExplorerUrls": [ + "https://sepolia.lineascan.build", + ], + "chainId": "0xe705", + "defaultBlockExplorerUrlIndex": 0, + "defaultRpcEndpointIndex": 0, + "name": "Linea Sepolia", + "nativeCurrency": "LineaETH", + "rpcEndpoints": [ + { + "failoverUrls": [], + "networkClientId": "linea-sepolia", + "type": "infura", + "url": "https://linea-sepolia.infura.io/v3/{infuraProjectId}", + }, + ], + }, + "0xe708": { + "blockExplorerUrls": [ + "https://lineascan.build", + ], + "chainId": "0xe708", + "defaultBlockExplorerUrlIndex": 0, + "defaultRpcEndpointIndex": 0, + "name": "Linea", + "nativeCurrency": "ETH", + "rpcEndpoints": [ + { + "failoverUrls": [], + "networkClientId": "linea-mainnet", + "type": "infura", + "url": "https://linea-mainnet.infura.io/v3/{infuraProjectId}", + }, + ], + }, + }, + "networksMetadata": {}, + "selectedNetworkClientId": "mainnet", + } + `); + }, + ); + }); + }); +}); + +describe('getNetworkConfigurations', () => { + it('returns network configurations available in the state', () => { + const state = getDefaultNetworkControllerState(); + + expect(getNetworkConfigurations(state)).toStrictEqual( + Object.values(state.networkConfigurationsByChainId), + ); + }); +}); + +describe('selectNetworkConfigurations', () => { + it('returns network configurations available in the state', () => { + const state = getDefaultNetworkControllerState(); + + expect(selectNetworkConfigurations(state)).toStrictEqual( + Object.values(state.networkConfigurationsByChainId), + ); + }); + + it('is memoized', () => { + const state = getDefaultNetworkControllerState(); + + expect(selectNetworkConfigurations(state)).toBe( + selectNetworkConfigurations(state), + ); + }); +}); + +describe('getAvailableNetworkClientIds', () => { + it('returns network client ids available in the state', () => { + const networkConfigurations = [ + { + rpcEndpoints: [ + { + networkClientId: 'foo', + }, + ], + }, + { + rpcEndpoints: [ + { + networkClientId: 'bar', + }, + ], + }, + ] as NetworkConfiguration[]; + + expect(getAvailableNetworkClientIds(networkConfigurations)).toStrictEqual([ + 'foo', + 'bar', + ]); }); +}); - describe('loadBackup', () => { - it('merges the network configurations from the given backup into state', async () => { - await withController( - { - state: { - networkConfigurations: { - networkConfigurationId1: { - id: 'networkConfigurationId1', - rpcUrl: 'https://rpc-url1.com', - chainId: toHex(1), - ticker: 'TEST1', - }, +describe('selectAvailableNetworkClientIds', () => { + it('selects all network client ids available in the state', () => { + const state = { + ...getDefaultNetworkControllerState(), + networkConfigurationsByChainId: { + '0x12': { + rpcEndpoints: [ + { + networkClientId: 'foo', }, - }, - }, - ({ controller }) => { - controller.loadBackup({ - networkConfigurations: { - networkConfigurationId2: { - id: 'networkConfigurationId2', - rpcUrl: 'https://rpc-url2.com', - chainId: toHex(2), - ticker: 'TEST2', - }, + ], + } as NetworkConfiguration, + '0x34': { + rpcEndpoints: [ + { + networkClientId: 'bar', }, - }); + ], + } as NetworkConfiguration, + }, + }; - expect(controller.state.networkConfigurations).toStrictEqual({ - networkConfigurationId1: { - id: 'networkConfigurationId1', - rpcUrl: 'https://rpc-url1.com', - chainId: toHex(1), - ticker: 'TEST1', - }, - networkConfigurationId2: { - id: 'networkConfigurationId2', - rpcUrl: 'https://rpc-url2.com', - chainId: toHex(2), - ticker: 'TEST2', - }, - }); - }, - ); - }); + expect(selectAvailableNetworkClientIds(state)).toStrictEqual([ + 'foo', + 'bar', + ]); }); }); @@ -5932,7 +15637,7 @@ describe('NetworkController', () => { * * @returns The mocked version of `createNetworkClient`. */ -function mockCreateNetworkClient() { +function mockCreateNetworkClient(): WhenMock { return when(createNetworkClientMock).mockImplementation((options) => { const inspectedOptions = inspect(options, { depth: null, compact: true }); const lines = [ @@ -5948,70 +15653,32 @@ function mockCreateNetworkClient() { }); } -/** - * Creates a mocked version of `createNetworkClient` where multiple mock - * invocations can be specified. Requests for built-in networks are already - * mocked. - * - * @param options - The options. - * @param options.builtInNetworkClient - The network client to use for requests - * to built-in networks. - * @param options.infuraProjectId - The Infura project ID that each network - * client is expected to be created with. - * @returns The mocked version of `createNetworkClient`. - */ -function mockCreateNetworkClientWithDefaultsForBuiltInNetworkClients({ - builtInNetworkClient = buildFakeClient(), - infuraProjectId = 'infura-project-id', -} = {}) { - return mockCreateNetworkClient() - .calledWith({ - network: NetworkType.mainnet, - infuraProjectId, - type: NetworkClientType.Infura, - chainId: BUILT_IN_NETWORKS[InfuraNetworkType.mainnet].chainId, - ticker: BUILT_IN_NETWORKS[InfuraNetworkType.mainnet].ticker, - }) - .mockReturnValue(builtInNetworkClient) - .calledWith({ - network: NetworkType.goerli, - infuraProjectId, - chainId: BUILT_IN_NETWORKS[InfuraNetworkType.goerli].chainId, - ticker: BUILT_IN_NETWORKS[InfuraNetworkType.goerli].ticker, - type: NetworkClientType.Infura, - }) - .mockReturnValue(builtInNetworkClient) - .calledWith({ - network: NetworkType.sepolia, - infuraProjectId, - chainId: BUILT_IN_NETWORKS[InfuraNetworkType.sepolia].chainId, - ticker: BUILT_IN_NETWORKS[InfuraNetworkType.sepolia].ticker, - type: NetworkClientType.Infura, - }) - .mockReturnValue(builtInNetworkClient); -} - /** * Test an operation that performs a `#refreshNetwork` call with the given * provider configuration. All effects of the `#refreshNetwork` call should be * covered by these tests. * * @param args - Arguments. - * @param args.expectedProviderConfig - The provider configuration that the - * operation is expected to set. + * @param args.expectedNetworkClientConfiguration - The network client + * configuration that the operation is expected to set. + * @param args.expectedNetworkClientId - The ID of the network client that the + * operation is expected to involve. * @param args.initialState - The initial state of the network controller. * @param args.operation - The operation to test. */ function refreshNetworkTests({ - expectedProviderConfig, + expectedNetworkClientConfiguration, + expectedNetworkClientId, initialState, operation, }: { - expectedProviderConfig: ProviderConfig; + expectedNetworkClientConfiguration: NetworkClientConfiguration; + expectedNetworkClientId: NetworkClientId; initialState?: Partial; operation: (controller: NetworkController) => Promise; -}) { - it('emits networkWillChange', async () => { +}): void { + // eslint-disable-next-line jest/require-top-level-describe + it('emits networkWillChange with state payload', async () => { await withController( { state: initialState, @@ -6021,244 +15688,592 @@ function refreshNetworkTests({ const fakeNetworkClient = buildFakeClient(fakeProvider); mockCreateNetworkClient().mockReturnValue(fakeNetworkClient); - const networkWillChange = waitForPublishedEvents({ - messenger, - eventType: 'NetworkController:networkWillChange', - operation: () => { - // Intentionally not awaited because we're capturing an event - // emitted partway through the operation - operation(controller); + const networkWillChange = waitForPublishedEvents({ + messenger, + eventType: 'NetworkController:networkWillChange', + filter: ([networkState]) => networkState === controller.state, + operation: () => { + // Intentionally not awaited because we're capturing an event + // emitted partway through the operation + // TODO: Either fix this lint violation or explain why it's necessary to ignore. + // eslint-disable-next-line @typescript-eslint/no-floating-promises + operation(controller); + }, + }); + + await expect(networkWillChange).toBeFulfilled(); + }, + ); + }); + + // eslint-disable-next-line jest/require-top-level-describe + it('emits networkDidChange with state payload', async () => { + await withController( + { + state: initialState, + }, + async ({ controller, messenger }) => { + const fakeProvider = buildFakeProvider(); + const fakeNetworkClient = buildFakeClient(fakeProvider); + mockCreateNetworkClient().mockReturnValue(fakeNetworkClient); + + const networkDidChange = waitForPublishedEvents({ + messenger, + eventType: 'NetworkController:networkDidChange', + filter: ([networkState]) => networkState === controller.state, + operation: () => { + // Intentionally not awaited because we're capturing an event + // emitted partway through the operation + // TODO: Either fix this lint violation or explain why it's necessary to ignore. + // eslint-disable-next-line @typescript-eslint/no-floating-promises + operation(controller); + }, + }); + + await expect(networkDidChange).toBeFulfilled(); + }, + ); + }); + + if (expectedNetworkClientConfiguration.type === NetworkClientType.Custom) { + // eslint-disable-next-line jest/require-top-level-describe + it('sets the provider to a custom RPC provider initialized with the RPC target and chain ID', async () => { + await withController( + { + state: initialState, + }, + async ({ controller }) => { + const fakeProvider = buildFakeProvider([ + { + request: { + method: 'eth_chainId', + }, + response: { + result: toHex(111), + }, + }, + ]); + const fakeNetworkClient = buildFakeClient(fakeProvider); + createNetworkClientMock.mockReturnValue(fakeNetworkClient); + + await operation(controller); + + expect(createNetworkClientMock).toHaveBeenCalledWith( + expect.objectContaining({ + configuration: expectedNetworkClientConfiguration, + }), + ); + const { provider } = controller.getProviderAndBlockTracker(); + assert(provider); + const chainIdResult = await provider.request({ + id: 1, + jsonrpc: '2.0', + method: 'eth_chainId', + params: [], + }); + expect(chainIdResult).toBe(toHex(111)); + }, + ); + }); + } else { + // eslint-disable-next-line jest/require-top-level-describe + it(`sets the provider to an Infura provider pointed to ${expectedNetworkClientConfiguration.network}`, async () => { + await withController( + { + infuraProjectId: 'infura-project-id', + state: initialState, + }, + async ({ controller }) => { + const fakeProvider = buildFakeProvider([ + { + request: { + method: 'eth_chainId', + }, + response: { + result: toHex(1337), + }, + }, + ]); + const fakeNetworkClient = buildFakeClient(fakeProvider); + createNetworkClientMock.mockReturnValue(fakeNetworkClient); + + await operation(controller); + + expect(createNetworkClientMock).toHaveBeenCalledWith( + expect.objectContaining({ + configuration: { + ...expectedNetworkClientConfiguration, + infuraProjectId: 'infura-project-id', + }, + }), + ); + const { provider } = controller.getProviderAndBlockTracker(); + assert(provider); + const chainIdResult = await provider.request({ + id: 1, + jsonrpc: '2.0', + method: 'eth_chainId', + params: [], + }); + expect(chainIdResult).toBe(toHex(1337)); + }, + ); + }); + } + + // eslint-disable-next-line jest/require-top-level-describe + it('replaces the provider object underlying the provider proxy without creating a new instance of the proxy itself', async () => { + await withController( + { + infuraProjectId: 'infura-project-id', + state: initialState, + }, + async ({ controller }) => { + const fakeProviders = [buildFakeProvider(), buildFakeProvider()]; + const fakeNetworkClients = [ + buildFakeClient(fakeProviders[0]), + buildFakeClient(fakeProviders[1]), + ]; + const { selectedNetworkClientId } = controller.state; + let initializationNetworkClientConfiguration: + | Parameters[0]['configuration'] + | undefined; + + for (const matchingNetworkConfiguration of Object.values( + controller.state.networkConfigurationsByChainId, + )) { + const matchingRpcEndpoint = + matchingNetworkConfiguration.rpcEndpoints.find( + (rpcEndpoint) => + rpcEndpoint.networkClientId === selectedNetworkClientId, + ); + if (matchingRpcEndpoint) { + if (isInfuraNetworkType(selectedNetworkClientId)) { + initializationNetworkClientConfiguration = { + chainId: ChainId[selectedNetworkClientId], + failoverRpcUrls: [], + infuraProjectId: 'infura-project-id', + network: selectedNetworkClientId, + ticker: NetworksTicker[selectedNetworkClientId], + type: NetworkClientType.Infura, + }; + } else { + initializationNetworkClientConfiguration = { + chainId: matchingNetworkConfiguration.chainId, + failoverRpcUrls: [], + rpcUrl: matchingRpcEndpoint.url, + ticker: matchingNetworkConfiguration.nativeCurrency, + type: NetworkClientType.Custom, + }; + } + } + } + + if (initializationNetworkClientConfiguration === undefined) { + throw new Error( + 'Could not set initializationNetworkClientConfiguration', + ); + } + + const operationNetworkClientConfiguration: Parameters< + typeof createNetworkClient + >[0]['configuration'] = + expectedNetworkClientConfiguration.type === NetworkClientType.Custom + ? expectedNetworkClientConfiguration + : { + ...expectedNetworkClientConfiguration, + infuraProjectId: 'infura-project-id', + }; + createNetworkClientMock.mockImplementation(({ configuration }) => { + if ( + isDeepStrictEqual( + configuration, + initializationNetworkClientConfiguration, + ) + ) { + return fakeNetworkClients[0]; + } else if ( + isDeepStrictEqual( + configuration, + operationNetworkClientConfiguration, + ) + ) { + return fakeNetworkClients[1]; + } + throw new Error( + `Unknown network client configuration ${JSON.stringify( + configuration, + )}`, + ); + }); + await controller.lookupNetwork(); + const { provider: providerBefore } = + controller.getProviderAndBlockTracker(); + + await operation(controller); + + const { provider: providerAfter } = + controller.getProviderAndBlockTracker(); + expect(providerBefore).toBe(providerAfter); + }, + ); + }); + + lookupNetworkTests({ + expectedNetworkClientType: expectedNetworkClientConfiguration.type, + expectedNetworkClientId, + initialState, + operation, + }); +} + +/** + * Test an operation that performs a `lookupNetwork` call with the given + * provider configuration. All effects of the `lookupNetwork` call should be + * covered by these tests. + * + * @param args - Arguments. + * @param args.expectedNetworkClientType - The type of the network client + * that the operation is expected to involve. + * @param args.expectedNetworkClientId - The ID of the network client that the + * operation is expected to involve. + * @param args.initialState - The initial state of the network controller. + * @param args.operation - The operation to test. + * @param args.shouldTestInfuraMessengerEvents - Whether to test whether + * Infura-related messenger events are published. This is useful when the + * operation involves the currently selected network. + */ +function lookupNetworkTests({ + expectedNetworkClientType, + expectedNetworkClientId, + initialState, + operation, + shouldTestInfuraMessengerEvents = true, +}: { + expectedNetworkClientType: NetworkClientType; + expectedNetworkClientId: NetworkClientId; + initialState?: Partial; + operation: (controller: NetworkController) => Promise; + shouldTestInfuraMessengerEvents?: boolean; +}): void { + describe('if the network details request resolves successfully', () => { + describe('if the new network details of the target network are different from the ones in state', () => { + it('updates state to match', async () => { + await withController( + { + state: { + ...initialState, + networksMetadata: { + [expectedNetworkClientId]: { + EIPS: { 1559: false }, + status: NetworkStatus.Unknown, + }, + }, + }, + }, + async ({ controller }) => { + await setFakeProvider(controller, { + stubs: [ + { + request: { + method: 'eth_getBlockByNumber', + params: ['latest', false], + }, + response: { + result: { + baseFeePerGas: '0x1', + }, + }, + }, + ], + stubLookupNetworkWhileSetting: true, + }); + + await operation(controller); + + expect( + controller.state.networksMetadata[expectedNetworkClientId] + .EIPS[1559], + ).toBe(true); + }, + ); + }); + }); + + describe('if the new network details of the target network are the same as the ones in state', () => { + it('does not update state', async () => { + await withController( + { + state: { + ...initialState, + networksMetadata: { + [expectedNetworkClientId]: { + EIPS: { 1559: true }, + status: NetworkStatus.Unknown, + }, + }, + }, + }, + async ({ controller }) => { + await setFakeProvider(controller, { + stubs: [ + { + request: { + method: 'eth_getBlockByNumber', + params: ['latest', false], + }, + response: { + result: { + baseFeePerGas: '0x1', + }, + }, + }, + ], + stubLookupNetworkWhileSetting: true, + }); + + await operation(controller); + + expect( + controller.state.networksMetadata[expectedNetworkClientId] + .EIPS[1559], + ).toBe(true); }, - }); + ); + }); + }); - await expect(networkWillChange).toBeFulfilled(); - }, - ); - }); + if (shouldTestInfuraMessengerEvents) { + it('emits infuraIsUnblocked', async () => { + await withController( + { + state: initialState, + }, + async ({ controller, messenger }) => { + await setFakeProvider(controller, { + stubLookupNetworkWhileSetting: true, + }); - it('emits networkDidChange', async () => { - await withController( - { - state: initialState, - }, - async ({ controller, messenger }) => { - const fakeProvider = buildFakeProvider(); - const fakeNetworkClient = buildFakeClient(fakeProvider); - mockCreateNetworkClient().mockReturnValue(fakeNetworkClient); + const infuraIsUnblocked = waitForPublishedEvents({ + messenger, + eventType: 'NetworkController:infuraIsUnblocked', + operation: async () => { + await operation(controller); + }, + }); - const networkDidChange = waitForPublishedEvents({ - messenger, - eventType: 'NetworkController:networkDidChange', - operation: () => { - // Intentionally not awaited because we're capturing an event - // emitted partway through the operation - operation(controller); + await expect(infuraIsUnblocked).toBeFulfilled(); }, - }); + ); + }); - await expect(networkDidChange).toBeFulfilled(); - }, - ); + it('does not emit infuraIsBlocked', async () => { + await withController( + { + state: initialState, + }, + async ({ controller, messenger }) => { + await setFakeProvider(controller, { + stubLookupNetworkWhileSetting: true, + }); + + const infuraIsBlocked = waitForPublishedEvents({ + messenger, + eventType: 'NetworkController:infuraIsBlocked', + count: 0, + operation: async () => { + await operation(controller); + }, + }); + + await expect(infuraIsBlocked).toBeFulfilled(); + }, + ); + }); + } }); - if (expectedProviderConfig.type === NetworkType.rpc) { - it('sets the provider to a custom RPC provider initialized with the RPC target and chain ID', async () => { + describe('if the network details request produces a JSON-RPC error that is not internal and not a country blocked error', () => { + it('updates the network in state to "unavailable"', async () => { await withController( { - infuraProjectId: 'infura-project-id', state: initialState, }, async ({ controller }) => { - const fakeProvider = buildFakeProvider([ - { - request: { - method: 'eth_chainId', - }, - response: { - result: toHex(111), + await setFakeProvider(controller, { + stubs: [ + { + request: { + method: 'eth_getBlockByNumber', + params: ['latest', false], + }, + error: rpcErrors.limitExceeded('some error'), }, - }, - ]); - const fakeNetworkClient = buildFakeClient(fakeProvider); - createNetworkClientMock.mockReturnValue(fakeNetworkClient); + ], + stubLookupNetworkWhileSetting: true, + }); await operation(controller); - expect(createNetworkClientMock).toHaveBeenCalledWith({ - chainId: expectedProviderConfig.chainId, - rpcUrl: expectedProviderConfig.rpcUrl, - type: NetworkClientType.Custom, - ticker: expectedProviderConfig.ticker, - }); - const { provider } = controller.getProviderAndBlockTracker(); - assert(provider); - const promisifiedSendAsync = promisify(provider.sendAsync).bind( - provider, - ); - const chainIdResult = await promisifiedSendAsync({ - id: 1, - jsonrpc: '2.0', - method: 'eth_chainId', - params: [], - }); - expect(chainIdResult.result).toBe(toHex(111)); + expect( + controller.state.networksMetadata[expectedNetworkClientId].status, + ).toBe(NetworkStatus.Unavailable); }, ); }); - } else { - it(`sets the provider to an Infura provider pointed to ${expectedProviderConfig.type}`, async () => { + + it('resets the network details in state', async () => { await withController( { - infuraProjectId: 'infura-project-id', state: initialState, }, async ({ controller }) => { - const fakeProvider = buildFakeProvider([ - { - request: { - method: 'eth_chainId', + await setFakeProvider(controller, { + stubs: [ + // Called during provider initialization + { + request: { + method: 'eth_getBlockByNumber', + params: ['latest', false], + }, + response: { + result: PRE_1559_BLOCK, + }, }, - response: { - result: toHex(1337), + // Called when calling the operation directly + { + request: { + method: 'eth_getBlockByNumber', + params: ['latest', false], + }, + error: rpcErrors.limitExceeded('some error'), }, - }, - ]); - const fakeNetworkClient = buildFakeClient(fakeProvider); - createNetworkClientMock.mockReturnValue(fakeNetworkClient); + ], + }); + expect( + controller.state.networksMetadata[ + controller.state.selectedNetworkClientId + ].EIPS[1559], + ).toBe(false); await operation(controller); - expect(createNetworkClientMock).toHaveBeenCalledWith({ - network: expectedProviderConfig.type, - infuraProjectId: 'infura-project-id', - chainId: BUILT_IN_NETWORKS[expectedProviderConfig.type].chainId, - ticker: BUILT_IN_NETWORKS[expectedProviderConfig.type].ticker, - type: NetworkClientType.Infura, - }); - const { provider } = controller.getProviderAndBlockTracker(); - assert(provider); - const promisifiedSendAsync = promisify(provider.sendAsync).bind( - provider, - ); - const chainIdResult = await promisifiedSendAsync({ - id: 1, - jsonrpc: '2.0', - method: 'eth_chainId', - params: [], - }); - expect(chainIdResult.result).toBe(toHex(1337)); + expect( + controller.state.networksMetadata[expectedNetworkClientId].EIPS, + ).toStrictEqual({}); }, ); }); - } - it('replaces the provider object underlying the provider proxy without creating a new instance of the proxy itself', async () => { - await withController( - { - infuraProjectId: 'infura-project-id', - state: initialState, - }, - async ({ controller }) => { - const fakeProviders = [buildFakeProvider(), buildFakeProvider()]; - const fakeNetworkClients = [ - buildFakeClient(fakeProviders[0]), - buildFakeClient(fakeProviders[1]), - ]; - const initializationNetworkClientOptions: Parameters< - typeof createNetworkClient - >[0] = - controller.state.providerConfig.type === NetworkType.rpc - ? { - chainId: toHex(controller.state.providerConfig.chainId), - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - rpcUrl: controller.state.providerConfig.rpcUrl!, - type: NetworkClientType.Custom, - ticker: controller.state.providerConfig.ticker, - } - : { - network: controller.state.providerConfig.type, - infuraProjectId: 'infura-project-id', - chainId: - BUILT_IN_NETWORKS[controller.state.providerConfig.type] - .chainId, - ticker: - BUILT_IN_NETWORKS[controller.state.providerConfig.type] - .ticker, - type: NetworkClientType.Infura, - }; - const operationNetworkClientOptions: Parameters< - typeof createNetworkClient - >[0] = - expectedProviderConfig.type === NetworkType.rpc - ? { - chainId: toHex(expectedProviderConfig.chainId), - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - rpcUrl: expectedProviderConfig.rpcUrl!, - type: NetworkClientType.Custom, - ticker: expectedProviderConfig.ticker, - } - : { - network: expectedProviderConfig.type, - infuraProjectId: 'infura-project-id', - chainId: BUILT_IN_NETWORKS[expectedProviderConfig.type].chainId, - ticker: BUILT_IN_NETWORKS[expectedProviderConfig.type].ticker, - type: NetworkClientType.Infura, - }; - mockCreateNetworkClient() - .calledWith(initializationNetworkClientOptions) - .mockReturnValue(fakeNetworkClients[0]) - .calledWith(operationNetworkClientOptions) - .mockReturnValue(fakeNetworkClients[1]); - await controller.initializeProvider(); - const { provider: providerBefore } = - controller.getProviderAndBlockTracker(); + if (shouldTestInfuraMessengerEvents) { + if (expectedNetworkClientType === NetworkClientType.Custom) { + it('emits infuraIsUnblocked', async () => { + await withController( + { + state: initialState, + }, + async ({ controller, messenger }) => { + await setFakeProvider(controller, { + stubs: [ + { + request: { + method: 'eth_getBlockByNumber', + params: ['latest', false], + }, + error: rpcErrors.limitExceeded('some error'), + }, + ], + stubLookupNetworkWhileSetting: true, + }); - await operation(controller); + const infuraIsUnblocked = waitForPublishedEvents({ + messenger, + eventType: 'NetworkController:infuraIsUnblocked', + operation: async () => { + await operation(controller); + }, + }); - const { provider: providerAfter } = - controller.getProviderAndBlockTracker(); - expect(providerBefore).toBe(providerAfter); - }, - ); - }); + await expect(infuraIsUnblocked).toBeFulfilled(); + }, + ); + }); + } else { + it('does not emit infuraIsUnblocked', async () => { + await withController( + { + state: initialState, + }, + async ({ controller, messenger }) => { + await setFakeProvider(controller, { + stubs: [ + { + request: { + method: 'eth_getBlockByNumber', + params: ['latest', false], + }, + error: rpcErrors.limitExceeded('some error'), + }, + ], + stubLookupNetworkWhileSetting: true, + }); - lookupNetworkTests({ expectedProviderConfig, initialState, operation }); -} + const infuraIsUnblocked = waitForPublishedEvents({ + messenger, + eventType: 'NetworkController:infuraIsUnblocked', + count: 0, + operation: async () => { + await operation(controller); + }, + }); -/** - * Test an operation that performs a `lookupNetwork` call with the given - * provider configuration. All effects of the `lookupNetwork` call should be - * covered by these tests. - * - * @param args - Arguments. - * @param args.expectedProviderConfig - The provider configuration that the - * operation is expected to set. - * @param args.initialState - The initial state of the network controller. - * @param args.operation - The operation to test. - */ -function lookupNetworkTests({ - expectedProviderConfig, - initialState, - operation, -}: { - expectedProviderConfig: ProviderConfig; - initialState?: Partial; - operation: (controller: NetworkController) => Promise; -}) { - describe('if the network details request resolve successfully', () => { - describe('if the network details of the current network are different from the network details in state', () => { - it('updates the network in state to match', async () => { + await expect(infuraIsUnblocked).toBeFulfilled(); + }, + ); + }); + } + + it('does not emit infuraIsBlocked', async () => { await withController( { - state: { - ...initialState, - networksMetadata: { - mainnet: { - EIPS: { 1559: false }, - status: NetworkStatus.Unknown, + state: initialState, + }, + async ({ controller, messenger }) => { + await setFakeProvider(controller, { + stubs: [ + { + request: { + method: 'eth_getBlockByNumber', + params: ['latest', false], + }, + error: rpcErrors.limitExceeded('some error'), }, + ], + stubLookupNetworkWhileSetting: true, + }); + + const infuraIsBlocked = waitForPublishedEvents({ + messenger, + eventType: 'NetworkController:infuraIsBlocked', + count: 0, + operation: async () => { + await operation(controller); }, - }, + }); + + await expect(infuraIsBlocked).toBeFulfilled(); + }, + ); + }); + } + }); + + describe('if the network details request produces a country blocked error', () => { + if (expectedNetworkClientType === NetworkClientType.Custom) { + it('updates the network in state to "unknown"', async () => { + await withController( + { + state: initialState, }, async ({ controller }) => { await setFakeProvider(controller, { @@ -6268,11 +16283,7 @@ function lookupNetworkTests({ method: 'eth_getBlockByNumber', params: ['latest', false], }, - response: { - result: { - baseFeePerGas: '0x1', - }, - }, + error: BLOCKED_INFURA_JSON_RPC_ERROR, }, ], stubLookupNetworkWhileSetting: true, @@ -6281,28 +16292,83 @@ function lookupNetworkTests({ await operation(controller); expect( - controller.state.networksMetadata[ - controller.state.selectedNetworkClientId - ].EIPS[1559], - ).toBe(true); + controller.state.networksMetadata[expectedNetworkClientId].status, + ).toBe(NetworkStatus.Unknown); }, ); }); - }); - describe('if the network details of the current network are the same as the network details in state', () => { - it('does not change network details in state', async () => { - await withController( - { - state: { - ...initialState, - networksMetadata: { - mainnet: { - EIPS: { 1559: true }, - status: NetworkStatus.Unknown, + if (shouldTestInfuraMessengerEvents) { + it('emits infuraIsUnblocked', async () => { + await withController( + { + state: initialState, + }, + async ({ controller, messenger }) => { + await setFakeProvider(controller, { + stubs: [ + { + request: { + method: 'eth_getBlockByNumber', + params: ['latest', false], + }, + error: BLOCKED_INFURA_JSON_RPC_ERROR, + }, + ], + stubLookupNetworkWhileSetting: true, + }); + + const infuraIsUnblocked = waitForPublishedEvents({ + messenger, + eventType: 'NetworkController:infuraIsUnblocked', + operation: async () => { + await operation(controller); }, - }, + }); + + await expect(infuraIsUnblocked).toBeFulfilled(); + }, + ); + }); + + it('does not emit infuraIsBlocked', async () => { + await withController( + { + state: initialState, + }, + async ({ controller, messenger }) => { + await setFakeProvider(controller, { + stubs: [ + { + request: { + method: 'eth_getBlockByNumber', + params: ['latest', false], + }, + error: BLOCKED_INFURA_JSON_RPC_ERROR, + }, + ], + stubLookupNetworkWhileSetting: true, + }); + + const infuraIsBlocked = waitForPublishedEvents({ + messenger, + eventType: 'NetworkController:infuraIsBlocked', + count: 0, + operation: async () => { + await operation(controller); + }, + }); + + await expect(infuraIsBlocked).toBeFulfilled(); }, + ); + }); + } + } else { + it('updates the network in state to "blocked"', async () => { + await withController( + { + state: initialState, }, async ({ controller }) => { await setFakeProvider(controller, { @@ -6312,11 +16378,7 @@ function lookupNetworkTests({ method: 'eth_getBlockByNumber', params: ['latest', false], }, - response: { - result: { - baseFeePerGas: '0x1', - }, - }, + error: BLOCKED_INFURA_JSON_RPC_ERROR, }, ], stubLookupNetworkWhileSetting: true, @@ -6325,65 +16387,126 @@ function lookupNetworkTests({ await operation(controller); expect( - controller.state.networksMetadata[ - controller.state.selectedNetworkClientId - ].EIPS[1559], - ).toBe(true); + controller.state.networksMetadata[expectedNetworkClientId].status, + ).toBe(NetworkStatus.Blocked); }, ); }); - }); - it('emits infuraIsUnblocked', async () => { - await withController( - { - state: initialState, - }, - async ({ controller, messenger }) => { - await setFakeProvider(controller, { - stubLookupNetworkWhileSetting: true, - }); + if (shouldTestInfuraMessengerEvents) { + it('does not emit infuraIsUnblocked', async () => { + await withController( + { + state: initialState, + }, + async ({ controller, messenger }) => { + await setFakeProvider(controller, { + stubs: [ + { + request: { + method: 'eth_getBlockByNumber', + params: ['latest', false], + }, + error: BLOCKED_INFURA_JSON_RPC_ERROR, + }, + ], + stubLookupNetworkWhileSetting: true, + }); + + const infuraIsUnblocked = waitForPublishedEvents({ + messenger, + eventType: 'NetworkController:infuraIsUnblocked', + count: 0, + operation: async () => { + await operation(controller); + }, + }); - const infuraIsUnblocked = waitForPublishedEvents({ - messenger, - eventType: 'NetworkController:infuraIsUnblocked', - operation: async () => { - await operation(controller); + await expect(infuraIsUnblocked).toBeFulfilled(); }, - }); + ); + }); - await expect(infuraIsUnblocked).toBeFulfilled(); - }, - ); - }); + it('emits infuraIsBlocked', async () => { + await withController( + { + state: initialState, + }, + async ({ controller, messenger }) => { + await setFakeProvider(controller, { + stubs: [ + { + request: { + method: 'eth_getBlockByNumber', + params: ['latest', false], + }, + error: BLOCKED_INFURA_JSON_RPC_ERROR, + }, + ], + stubLookupNetworkWhileSetting: true, + }); + + const infuraIsBlocked = waitForPublishedEvents({ + messenger, + eventType: 'NetworkController:infuraIsBlocked', + operation: async () => { + await operation(controller); + }, + }); + + await expect(infuraIsBlocked).toBeFulfilled(); + }, + ); + }); + } + } - it('does not emit infuraIsBlocked', async () => { + it('resets the network details in state', async () => { await withController( { state: initialState, }, - async ({ controller, messenger }) => { + async ({ controller }) => { await setFakeProvider(controller, { - stubLookupNetworkWhileSetting: true, + stubs: [ + // Called during provider initialization + { + request: { + method: 'eth_getBlockByNumber', + params: ['latest', false], + }, + response: { + result: PRE_1559_BLOCK, + }, + }, + // Called when calling the operation directly + { + request: { + method: 'eth_getBlockByNumber', + params: ['latest', false], + }, + error: BLOCKED_INFURA_JSON_RPC_ERROR, + }, + ], }); + expect( + controller.state.networksMetadata[ + controller.state.selectedNetworkClientId + ].EIPS[1559], + ).toBe(false); - const infuraIsBlocked = waitForPublishedEvents({ - messenger, - eventType: 'NetworkController:infuraIsBlocked', - count: 0, - operation: async () => { - await operation(controller); - }, - }); + await operation(controller); - await expect(infuraIsBlocked).toBeFulfilled(); + expect( + controller.state.networksMetadata[expectedNetworkClientId].EIPS, + ).toStrictEqual({}); }, ); }); }); - describe('if an RPC error is encountered while retrieving the network details of the current network', () => { - it('updates the network in state to "unavailable"', async () => { + describe('if the network details request produces an internal JSON-RPC error', () => { + it('updates the network in state to "unknown"', async () => { await withController( { state: initialState, @@ -6396,7 +16519,7 @@ function lookupNetworkTests({ method: 'eth_getBlockByNumber', params: ['latest', false], }, - error: rpcErrors.limitExceeded('some error'), + error: GENERIC_JSON_RPC_ERROR, }, ], stubLookupNetworkWhileSetting: true, @@ -6405,10 +16528,8 @@ function lookupNetworkTests({ await operation(controller); expect( - controller.state.networksMetadata[ - controller.state.selectedNetworkClientId - ].status, - ).toBe(NetworkStatus.Unavailable); + controller.state.networksMetadata[expectedNetworkClientId].status, + ).toBe(NetworkStatus.Unknown); }, ); }); @@ -6437,7 +16558,7 @@ function lookupNetworkTests({ method: 'eth_getBlockByNumber', params: ['latest', false], }, - error: rpcErrors.limitExceeded('some error'), + error: GENERIC_JSON_RPC_ERROR, }, ], }); @@ -6450,276 +16571,81 @@ function lookupNetworkTests({ await operation(controller); expect( - controller.state.networksMetadata[ - controller.state.selectedNetworkClientId - ].EIPS, + controller.state.networksMetadata[expectedNetworkClientId].EIPS, ).toStrictEqual({}); }, ); }); - if (expectedProviderConfig.type === NetworkType.rpc) { - it('emits infuraIsUnblocked', async () => { - await withController( - { - state: initialState, - }, - async ({ controller, messenger }) => { - await setFakeProvider(controller, { - stubs: [ - { - request: { - method: 'eth_getBlockByNumber', - params: ['latest', false], - }, - error: rpcErrors.limitExceeded('some error'), - }, - ], - stubLookupNetworkWhileSetting: true, - }); - - const infuraIsUnblocked = waitForPublishedEvents({ - messenger, - eventType: 'NetworkController:infuraIsUnblocked', - operation: async () => { - await operation(controller); - }, - }); - - await expect(infuraIsUnblocked).toBeFulfilled(); - }, - ); - }); - } else { - it('does not emit infuraIsUnblocked', async () => { - await withController( - { - state: initialState, - }, - async ({ controller, messenger }) => { - await setFakeProvider(controller, { - stubs: [ - { - request: { - method: 'eth_getBlockByNumber', - params: ['latest', false], - }, - error: rpcErrors.limitExceeded('some error'), - }, - ], - stubLookupNetworkWhileSetting: true, - }); - - const infuraIsUnblocked = waitForPublishedEvents({ - messenger, - eventType: 'NetworkController:infuraIsUnblocked', - count: 0, - operation: async () => { - await operation(controller); - }, - }); - - await expect(infuraIsUnblocked).toBeFulfilled(); - }, - ); - }); - } - - it('does not emit infuraIsBlocked', async () => { - await withController( - { - state: initialState, - }, - async ({ controller, messenger }) => { - await setFakeProvider(controller, { - stubs: [ - { - request: { - method: 'eth_getBlockByNumber', - params: ['latest', false], - }, - error: rpcErrors.limitExceeded('some error'), - }, - ], - stubLookupNetworkWhileSetting: true, - }); - - const infuraIsBlocked = waitForPublishedEvents({ - messenger, - eventType: 'NetworkController:infuraIsBlocked', - count: 0, - operation: async () => { - await operation(controller); + if (shouldTestInfuraMessengerEvents) { + if (expectedNetworkClientType === NetworkClientType.Custom) { + it('emits infuraIsUnblocked', async () => { + await withController( + { + state: initialState, }, - }); - - await expect(infuraIsBlocked).toBeFulfilled(); - }, - ); - }); - }); - - describe('if a country blocked error is encountered while retrieving the network details of the current network', () => { - if (expectedProviderConfig.type === NetworkType.rpc) { - it('updates the network in state to "unknown"', async () => { - await withController( - { - state: initialState, - }, - async ({ controller }) => { - await setFakeProvider(controller, { - stubs: [ - { - request: { - method: 'eth_getBlockByNumber', - params: ['latest', false], - }, - error: BLOCKED_INFURA_JSON_RPC_ERROR, - }, - ], - stubLookupNetworkWhileSetting: true, - }); - - await operation(controller); - - expect( - controller.state.networksMetadata[ - controller.state.selectedNetworkClientId - ].status, - ).toBe(NetworkStatus.Unknown); - }, - ); - }); - - it('emits infuraIsUnblocked', async () => { - await withController( - { - state: initialState, - }, - async ({ controller, messenger }) => { - await setFakeProvider(controller, { - stubs: [ - { - request: { - method: 'eth_getBlockByNumber', - params: ['latest', false], - }, - error: BLOCKED_INFURA_JSON_RPC_ERROR, - }, - ], - stubLookupNetworkWhileSetting: true, - }); - - const infuraIsUnblocked = waitForPublishedEvents({ - messenger, - eventType: 'NetworkController:infuraIsUnblocked', - operation: async () => { - await operation(controller); - }, - }); - - await expect(infuraIsUnblocked).toBeFulfilled(); - }, - ); - }); - - it('does not emit infuraIsBlocked', async () => { - await withController( - { - state: initialState, - }, - async ({ controller, messenger }) => { - await setFakeProvider(controller, { - stubs: [ - { - request: { - method: 'eth_getBlockByNumber', - params: ['latest', false], + async ({ controller, messenger }) => { + await setFakeProvider(controller, { + stubs: [ + { + request: { + method: 'eth_getBlockByNumber', + params: ['latest', false], + }, + error: GENERIC_JSON_RPC_ERROR, }, - error: BLOCKED_INFURA_JSON_RPC_ERROR, - }, - ], - stubLookupNetworkWhileSetting: true, - }); - - const infuraIsBlocked = waitForPublishedEvents({ - messenger, - eventType: 'NetworkController:infuraIsBlocked', - count: 0, - operation: async () => { - await operation(controller); - }, - }); + ], + stubLookupNetworkWhileSetting: true, + }); - await expect(infuraIsBlocked).toBeFulfilled(); - }, - ); - }); - } else { - it('updates the network in state to "blocked"', async () => { - await withController( - { - state: initialState, - }, - async ({ controller }) => { - await setFakeProvider(controller, { - stubs: [ - { - request: { - method: 'eth_getBlockByNumber', - params: ['latest', false], - }, - error: BLOCKED_INFURA_JSON_RPC_ERROR, + const infuraIsUnblocked = waitForPublishedEvents({ + messenger, + eventType: 'NetworkController:infuraIsUnblocked', + operation: async () => { + await operation(controller); }, - ], - stubLookupNetworkWhileSetting: true, - }); - - await operation(controller); - - expect( - controller.state.networksMetadata[ - controller.state.selectedNetworkClientId - ].status, - ).toBe(NetworkStatus.Blocked); - }, - ); - }); + }); - it('does not emit infuraIsUnblocked', async () => { - await withController( - { - state: initialState, - }, - async ({ controller, messenger }) => { - await setFakeProvider(controller, { - stubs: [ - { - request: { - method: 'eth_getBlockByNumber', - params: ['latest', false], + await expect(infuraIsUnblocked).toBeFulfilled(); + }, + ); + }); + } else { + it('does not emit infuraIsUnblocked', async () => { + await withController( + { + state: initialState, + }, + async ({ controller, messenger }) => { + await setFakeProvider(controller, { + stubs: [ + { + request: { + method: 'eth_getBlockByNumber', + params: ['latest', false], + }, + error: GENERIC_JSON_RPC_ERROR, }, - error: BLOCKED_INFURA_JSON_RPC_ERROR, - }, - ], - stubLookupNetworkWhileSetting: true, - }); + ], + stubLookupNetworkWhileSetting: true, + }); - const infuraIsUnblocked = waitForPublishedEvents({ - messenger, - eventType: 'NetworkController:infuraIsUnblocked', - count: 0, - operation: async () => { - await operation(controller); - }, - }); + const infuraIsUnblocked = waitForPublishedEvents({ + messenger, + eventType: 'NetworkController:infuraIsUnblocked', + count: 0, + operation: async () => { + await operation(controller); + }, + }); - await expect(infuraIsUnblocked).toBeFulfilled(); - }, - ); - }); + await expect(infuraIsUnblocked).toBeFulfilled(); + }, + ); + }); + } - it('emits infuraIsBlocked', async () => { + it('does not emit infuraIsBlocked', async () => { await withController( { state: initialState, @@ -6732,7 +16658,7 @@ function lookupNetworkTests({ method: 'eth_getBlockByNumber', params: ['latest', false], }, - error: BLOCKED_INFURA_JSON_RPC_ERROR, + error: GENERIC_JSON_RPC_ERROR, }, ], stubLookupNetworkWhileSetting: true, @@ -6741,6 +16667,7 @@ function lookupNetworkTests({ const infuraIsBlocked = waitForPublishedEvents({ messenger, eventType: 'NetworkController:infuraIsBlocked', + count: 0, operation: async () => { await operation(controller); }, @@ -6751,54 +16678,9 @@ function lookupNetworkTests({ ); }); } - - it('resets the network details in state', async () => { - await withController( - { - state: initialState, - }, - async ({ controller }) => { - await setFakeProvider(controller, { - stubs: [ - // Called during provider initialization - { - request: { - method: 'eth_getBlockByNumber', - params: ['latest', false], - }, - response: { - result: PRE_1559_BLOCK, - }, - }, - // Called when calling the operation directly - { - request: { - method: 'eth_getBlockByNumber', - params: ['latest', false], - }, - error: BLOCKED_INFURA_JSON_RPC_ERROR, - }, - ], - }); - expect( - controller.state.networksMetadata[ - controller.state.selectedNetworkClientId - ].EIPS[1559], - ).toBe(false); - - await operation(controller); - - expect( - controller.state.networksMetadata[ - controller.state.selectedNetworkClientId - ].EIPS, - ).toStrictEqual({}); - }, - ); - }); }); - describe('if an internal error is encountered while retrieving the network details of the current network', () => { + describe('if the network details request produces a non-JSON-RPC error', () => { it('updates the network in state to "unknown"', async () => { await withController( { @@ -6812,7 +16694,7 @@ function lookupNetworkTests({ method: 'eth_getBlockByNumber', params: ['latest', false], }, - error: GENERIC_JSON_RPC_ERROR, + error: 'oops', }, ], stubLookupNetworkWhileSetting: true, @@ -6821,9 +16703,7 @@ function lookupNetworkTests({ await operation(controller); expect( - controller.state.networksMetadata[ - controller.state.selectedNetworkClientId - ].status, + controller.state.networksMetadata[expectedNetworkClientId].status, ).toBe(NetworkStatus.Unknown); }, ); @@ -6866,48 +16746,81 @@ function lookupNetworkTests({ await operation(controller); expect( - controller.state.networksMetadata[ - controller.state.selectedNetworkClientId - ].EIPS, + controller.state.networksMetadata[expectedNetworkClientId].EIPS, ).toStrictEqual({}); }, ); }); - if (expectedProviderConfig.type === NetworkType.rpc) { - it('emits infuraIsUnblocked', async () => { - await withController( - { - state: initialState, - }, - async ({ controller, messenger }) => { - await setFakeProvider(controller, { - stubs: [ - { - request: { - method: 'eth_getBlockByNumber', - params: ['latest', false], + if (shouldTestInfuraMessengerEvents) { + if (expectedNetworkClientType === NetworkClientType.Custom) { + it('emits infuraIsUnblocked', async () => { + await withController( + { + state: initialState, + }, + async ({ controller, messenger }) => { + await setFakeProvider(controller, { + stubs: [ + { + request: { + method: 'eth_getBlockByNumber', + params: ['latest', false], + }, + error: GENERIC_JSON_RPC_ERROR, }, - error: GENERIC_JSON_RPC_ERROR, + ], + stubLookupNetworkWhileSetting: true, + }); + + const infuraIsUnblocked = waitForPublishedEvents({ + messenger, + eventType: 'NetworkController:infuraIsUnblocked', + operation: async () => { + await operation(controller); }, - ], - stubLookupNetworkWhileSetting: true, - }); + }); - const infuraIsUnblocked = waitForPublishedEvents({ - messenger, - eventType: 'NetworkController:infuraIsUnblocked', - operation: async () => { - await operation(controller); - }, - }); + await expect(infuraIsUnblocked).toBeFulfilled(); + }, + ); + }); + } else { + it('does not emit infuraIsUnblocked', async () => { + await withController( + { + state: initialState, + }, + async ({ controller, messenger }) => { + await setFakeProvider(controller, { + stubs: [ + { + request: { + method: 'eth_getBlockByNumber', + params: ['latest', false], + }, + error: GENERIC_JSON_RPC_ERROR, + }, + ], + stubLookupNetworkWhileSetting: true, + }); - await expect(infuraIsUnblocked).toBeFulfilled(); - }, - ); - }); - } else { - it('does not emit infuraIsUnblocked', async () => { + const infuraIsUnblocked = waitForPublishedEvents({ + messenger, + eventType: 'NetworkController:infuraIsUnblocked', + count: 0, + operation: async () => { + await operation(controller); + }, + }); + + await expect(infuraIsUnblocked).toBeFulfilled(); + }, + ); + }); + } + + it('does not emit infuraIsBlocked', async () => { await withController( { state: initialState, @@ -6926,165 +16839,21 @@ function lookupNetworkTests({ stubLookupNetworkWhileSetting: true, }); - const infuraIsUnblocked = waitForPublishedEvents({ + const infuraIsBlocked = waitForPublishedEvents({ messenger, - eventType: 'NetworkController:infuraIsUnblocked', + eventType: 'NetworkController:infuraIsBlocked', count: 0, operation: async () => { await operation(controller); }, }); - await expect(infuraIsUnblocked).toBeFulfilled(); + await expect(infuraIsBlocked).toBeFulfilled(); }, ); }); } - - it('does not emit infuraIsBlocked', async () => { - await withController( - { - state: initialState, - }, - async ({ controller, messenger }) => { - await setFakeProvider(controller, { - stubs: [ - { - request: { - method: 'eth_getBlockByNumber', - params: ['latest', false], - }, - error: GENERIC_JSON_RPC_ERROR, - }, - ], - stubLookupNetworkWhileSetting: true, - }); - - const infuraIsBlocked = waitForPublishedEvents({ - messenger, - eventType: 'NetworkController:infuraIsBlocked', - count: 0, - operation: async () => { - await operation(controller); - }, - }); - - await expect(infuraIsBlocked).toBeFulfilled(); - }, - ); - }); - }); -} - -/** - * Build a controller messenger that includes all events used by the network - * controller. - * - * @returns The controller messenger. - */ -function buildMessenger() { - return new ControllerMessenger< - NetworkControllerActions, - NetworkControllerEvents - >(); -} - -/** - * Build a restricted controller messenger for the network controller. - * - * @param messenger - A controller messenger. - * @returns The network controller restricted messenger. - */ -function buildNetworkControllerMessenger(messenger = buildMessenger()) { - return messenger.getRestricted({ - name: 'NetworkController', - allowedActions: [ - 'NetworkController:getProviderConfig', - 'NetworkController:getEthQuery', - ], - allowedEvents: [ - 'NetworkController:stateChange', - 'NetworkController:infuraIsBlocked', - 'NetworkController:infuraIsUnblocked', - 'NetworkController:networkDidChange', - 'NetworkController:networkWillChange', - ], - }); -} - -type WithControllerCallback = ({ - controller, -}: { - controller: NetworkController; - messenger: ControllerMessenger< - NetworkControllerActions, - NetworkControllerEvents - >; -}) => Promise | ReturnValue; - -type WithControllerOptions = Partial; - -type WithControllerArgs = - | [WithControllerCallback] - | [WithControllerOptions, WithControllerCallback]; - -/** - * Builds a controller based on the given options, and calls the given function - * with that controller. - * - * @param args - Either a function, or an options bag + a function. The options - * bag is equivalent to the options that NetworkController takes (although - * `messenger` and `infuraProjectId` are filled in if not given); the function - * will be called with the built controller. - * @returns Whatever the callback returns. - */ -async function withController( - ...args: WithControllerArgs -): Promise { - const [{ ...rest }, fn] = args.length === 2 ? args : [{}, args[0]]; - const messenger = buildMessenger(); - const restrictedMessenger = buildNetworkControllerMessenger(messenger); - const controller = new NetworkController({ - messenger: restrictedMessenger, - trackMetaMetricsEvent: jest.fn(), - infuraProjectId: 'infura-project-id', - ...rest, }); - try { - return await fn({ controller, messenger }); - } finally { - const { blockTracker } = controller.getProviderAndBlockTracker(); - blockTracker?.destroy(); - } -} - -/** - * Builds a complete ProviderConfig object, filling in values that are not - * provided with defaults. - * - * @param config - An incomplete ProviderConfig object. - * @returns The complete ProviderConfig object. - */ -function buildProviderConfig( - config: Partial = {}, -): ProviderConfig { - if (config.type && config.type !== NetworkType.rpc) { - return { - ...BUILT_IN_NETWORKS[config.type], - // This is redundant with the spread operation below, but this was - // required for TypeScript to understand that this property was set to an - // Infura type. - type: config.type, - ...config, - }; - } - return { - type: NetworkType.rpc, - chainId: toHex(1337), - rpcUrl: 'http://doesntmatter.com', - ticker: 'TEST', - ...config, - }; } /** @@ -7098,14 +16867,17 @@ function buildFakeClient( ): NetworkClient { return { configuration: { + failoverRpcUrls: [], type: NetworkClientType.Custom, ticker: 'TEST', chainId: '0x1', rpcUrl: 'https://test.network', }, provider, - blockTracker: new FakeBlockTracker(), - destroy: () => { + blockTracker: new FakeBlockTracker({ + provider, + }), + destroy: (): void => { // do nothing }, }; @@ -7117,7 +16889,8 @@ function buildFakeClient( * optionally provided for certain RPC methods. * * @param stubs - The list of RPC methods you want to stub along with their - * responses. `eth_getBlockByNumber` will be stubbed by default. + * responses. `eth_getBlockByNumber` and `eth_blockNumber will be stubbed by + * default. * @returns The object. */ function buildFakeProvider(stubs: FakeProviderStub[] = []): Provider { @@ -7171,7 +16944,7 @@ async function setFakeProvider( lookupNetworkMock.mockResolvedValue(undefined); } - await controller.initializeProvider(); + await controller.lookupNetwork(); assert(controller.getProviderAndBlockTracker().provider); if (stubLookupNetworkWhileSetting) { @@ -7204,48 +16977,49 @@ async function setFakeProvider( * @returns A promise that resolves to the list of payloads for the set of * events, optionally filtered, when a specific number of them have occurred. */ -async function waitForPublishedEvents({ +async function waitForPublishedEvents({ messenger, eventType, count: expectedNumberOfEvents = 1, - filter: isEventPayloadInteresting = () => true, + filter: isEventPayloadInteresting = (): boolean => true, wait: timeBeforeAssumingNoMoreEvents = 150, - operation = () => { + operation = (): void => { // do nothing }, - beforeResolving = async () => { + beforeResolving = async (): Promise => { // do nothing }, }: { - messenger: ControllerMessenger< - NetworkControllerActions, - NetworkControllerEvents - >; - eventType: E['type']; + messenger: RootMessenger; + eventType: Events['type']; count?: number; - filter?: (payload: E['payload']) => boolean; + filter?: (payload: Events['payload']) => boolean; wait?: number; operation?: () => void | Promise; beforeResolving?: () => void | Promise; -}): Promise { - const promiseForEventPayloads = new Promise( +}): Promise { + const promiseForEventPayloads = new Promise( (resolve, reject) => { let timer: NodeJS.Timeout | undefined; - const allEventPayloads: E['payload'][] = []; - const interestingEventPayloads: E['payload'][] = []; + const allEventPayloads: Events['payload'][] = []; + const interestingEventPayloads: Events['payload'][] = []; let alreadyEnded = false; // We're using `any` here because there seems to be some mismatch between // the signature of `subscribe` and the way that we're using it. Try // changing `any` to either `((...args: E['payload']) => void)` or // `ExtractEventHandler` to see the issue. - const eventListener: any = (...payload: E['payload']) => { + // TODO: Replace `any` with type + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const eventListener: any = (...payload: Events['payload']) => { allEventPayloads.push(payload); if (isEventPayloadInteresting(payload)) { interestingEventPayloads.push(payload); if (interestingEventPayloads.length === expectedNumberOfEvents) { stopTimer(); + // TODO: Either fix this lint violation or explain why it's necessary to ignore. + // eslint-disable-next-line @typescript-eslint/no-floating-promises end(); } else { resetTimer(); @@ -7256,7 +17030,7 @@ async function waitForPublishedEvents({ /** * Stop listening for published events. */ - async function end() { + async function end(): Promise { if (!alreadyEnded) { messenger.unsubscribe(eventType, eventListener); @@ -7265,17 +17039,21 @@ async function waitForPublishedEvents({ if (interestingEventPayloads.length === expectedNumberOfEvents) { resolve(interestingEventPayloads); } else { - // Using a string instead of an Error leads to better backtraces. - /* eslint-disable-next-line prefer-promise-reject-errors */ reject( - `Expected to receive ${expectedNumberOfEvents} ${eventType} event(s), but received ${ - interestingEventPayloads.length - } after ${timeBeforeAssumingNoMoreEvents}ms.\n\nAll payloads:\n\n${inspect( - allEventPayloads, - { depth: null }, - )}`, + new Error( + `Expected to receive ${expectedNumberOfEvents} ${String( + eventType, + )} event(s), but received ${ + interestingEventPayloads.length + } after ${timeBeforeAssumingNoMoreEvents}ms.\n\nAll payloads:\n\n${inspect( + allEventPayloads, + { depth: null }, + )}`, + ), ); } + + // eslint-disable-next-line require-atomic-updates alreadyEnded = true; } } @@ -7283,7 +17061,7 @@ async function waitForPublishedEvents({ /** * Stop the timer used to detect a timeout when listening for published events. */ - function stopTimer() { + function stopTimer(): void { if (timer) { clearTimeout(timer); } @@ -7292,9 +17070,11 @@ async function waitForPublishedEvents({ /** * Reset the timer used to detect a timeout when listening for published events. */ - function resetTimer() { + function resetTimer(): void { stopTimer(); timer = setTimeout(() => { + // TODO: Either fix this lint violation or explain why it's necessary to ignore. + // eslint-disable-next-line @typescript-eslint/no-floating-promises end(); }, timeBeforeAssumingNoMoreEvents); } @@ -7343,10 +17123,7 @@ async function waitForStateChanges({ operation, beforeResolving, }: { - messenger: ControllerMessenger< - NetworkControllerActions, - NetworkControllerEvents - >; + messenger: RootMessenger; propertyPath?: string[]; count?: number; wait?: number; @@ -7355,8 +17132,8 @@ async function waitForStateChanges({ }): Promise<[NetworkState, Patch[]][]> { const filter = propertyPath === undefined - ? () => true - : ([_newState, patches]: [NetworkState, Patch[]]) => + ? (): boolean => true + : ([_newState, patches]: [NetworkState, Patch[]]): boolean => didPropertyChange(patches, propertyPath); return await waitForPublishedEvents({ @@ -7389,3 +17166,76 @@ function didPropertyChange(patches: Patch[], propertyPath: string[]): boolean { ); }); } + +/** + * Extracts the network client configurations from a network client registry so + * that it is easier to test without having to ignore every property in + * NetworkClient but `configuration`. + * + * @param networkClientRegistry - The network client registry. + * @returns A map of network client ID to network client configuration. + */ +function getNetworkConfigurationsByNetworkClientId( + networkClientRegistry: AutoManagedBuiltInNetworkClientRegistry & + AutoManagedCustomNetworkClientRegistry, +): Record { + return Object.entries(networkClientRegistry).reduce( + ( + obj: Partial>, + [networkClientId, networkClient], + ) => { + return { + ...obj, + [networkClientId]: networkClient.configuration, + }; + }, + {}, + ) as Record; +} + +/** + * When initializing NetworkController with state, the `selectedNetworkClientId` + * property must match the `networkClientId` of an RPC endpoint in + * `networkConfigurationsByChainId`. Sometimes when writing tests we care about + * what the `selectedNetworkClientId` is, but sometimes we don't and we'd rather + * have this property automatically filled in for us. + * + * This function takes care of filling in the `selectedNetworkClientId` using + * the first RPC endpoint of the first network configuration given. + * + * @param networkControllerState - The desired NetworkController state + * overrides. + * @param networkControllerState.networkConfigurationsByChainId - The desired + * `networkConfigurationsByChainId`. + * @param networkControllerState.selectedNetworkClientId - The desired + * `selectedNetworkClientId`; if not provided, then will be set to the + * `networkClientId` of the first RPC endpoint in + * `networkConfigurationsByChainId`. + * @returns The complete NetworkController state with `selectedNetworkClientId` + * properly filled in. + */ +function buildNetworkControllerStateWithDefaultSelectedNetworkClientId({ + networkConfigurationsByChainId, + selectedNetworkClientId: givenSelectedNetworkClientId, + ...rest +}: Partial> & + Pick): Partial { + if (givenSelectedNetworkClientId === undefined) { + const networkConfigurations = Object.values(networkConfigurationsByChainId); + const selectedNetworkClientId = + networkConfigurations.length > 0 + ? networkConfigurations[0].rpcEndpoints[0].networkClientId + : undefined; + return { + networkConfigurationsByChainId, + selectedNetworkClientId, + ...rest, + }; + } + + return { + networkConfigurationsByChainId, + selectedNetworkClientId: givenSelectedNetworkClientId, + ...rest, + }; +} diff --git a/packages/network-controller/tests/create-network-client.test.ts b/packages/network-controller/tests/create-network-client.test.ts deleted file mode 100644 index 6e425f4a3d0..00000000000 --- a/packages/network-controller/tests/create-network-client.test.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { NetworkClientType } from '../src/types'; -import { testsForProviderType } from './provider-api-tests/shared-tests'; - -for (const clientType of Object.values(NetworkClientType)) { - describe(`createNetworkClient - ${clientType}`, () => { - testsForProviderType(clientType); - }); -} diff --git a/packages/network-controller/tests/fake-provider.ts b/packages/network-controller/tests/fake-provider.ts deleted file mode 100644 index e5ea8af6329..00000000000 --- a/packages/network-controller/tests/fake-provider.ts +++ /dev/null @@ -1,207 +0,0 @@ -import { SafeEventEmitterProvider } from '@metamask/eth-json-rpc-provider'; -import { JsonRpcEngine } from '@metamask/json-rpc-engine'; -import type { JsonRpcRequest, JsonRpcResponse } from '@metamask/utils'; -import { inspect, isDeepStrictEqual } from 'util'; - -// Store this in case it gets stubbed later -const originalSetTimeout = global.setTimeout; - -/** - * Represents the type of the `response` property in a fake provider stub. - */ -export type FakeProviderResponse = { result: any } | { error: string }; - -/** - * An object that allows specifying the behavior of a specific invocation of - * `sendAsync`. The `method` always identifies the stub, but the behavior - * may be specified multiple ways: `sendAsync` can either return a promise or - * throw an error, and if it returns a promise, that promise can either be - * resolved with a response object or reject with an error. - * - * @property request - Looks for a request matching these specifications. - * @property request.method - The RPC method to which this stub will be matched. - * @property request.params - The params to which this stub will be matched. - * @property response - Instructs `sendAsync` to return a promise that resolves - * with a response object. - * @property response.result - Specifies a successful response, with this as the - * `result`. - * @property response.error - Specifies an error response, with this as the - * `error`. - * @property error - Instructs `sendAsync` to return a promise that rejects with - * this error. - * @property implementation - Allows overriding `sendAsync` entirely. Useful if - * you want it to throw an error. - * @property delay - The amount of time that will pass after the callback is - * called with the response. - * @property discardAfterMatching - Usually after the stub matches a request, it - * is discarded, but setting this to true prevents that from happening. True by - * default. - * @property beforeCompleting - Sometimes it is useful to do something after the - * request is kicked off but before it ends (or, in terms of a `fetch` promise, - * when the promise is initiated but before it is resolved). You can pass an - * (async) function for this option to do this. - */ -export type FakeProviderStub = { - request: { - method: string; - params?: any[]; - }; - delay?: number; - discardAfterMatching?: boolean; - beforeCompleting?: () => void | Promise; -} & ( - | { - response: FakeProviderResponse; - } - | { - error: unknown; - } - | { - implementation: () => void; - } -); - -/** - * The set of options that the FakeProvider constructor takes. - * - * @property stubs - A set of objects that allow specifying the behavior - * of specific invocations of `sendAsync` matching a `method`. - */ -interface FakeProviderEngineOptions { - stubs?: FakeProviderStub[]; -} - -/** - * An implementation of the provider that NetworkController exposes, which is - * actually an instance of SafeEventEmitterProvider (from the - * `@metamask/eth-json-rpc-provider` package). Hence it supports the same - * interface as SafeEventEmitterProvider, except that fake responses for any RPC - * methods that are accessed can be supplied via an API that is more succinct - * than using Jest's mocking API. - */ -// NOTE: We shouldn't need to extend from the "real" provider here, but -// we'd need a `SafeEventEmitterProvider` _interface_ and that doesn't exist (at -// least not yet). -export class FakeProvider extends SafeEventEmitterProvider { - calledStubs: FakeProviderStub[]; - - #originalStubs: FakeProviderStub[]; - - #stubs: FakeProviderStub[]; - - /** - * Makes a new instance of the fake provider. - * - * @param options - The options. - * @param options.stubs - A set of objects that allow specifying the behavior - * of specific invocations of `sendAsync` matching a `method`. - */ - constructor({ stubs = [] }: FakeProviderEngineOptions) { - super({ engine: new JsonRpcEngine() }); - this.#originalStubs = stubs; - this.#stubs = this.#originalStubs.slice(); - this.calledStubs = []; - } - - send = ( - payload: JsonRpcRequest, - callback: (error: unknown, response?: JsonRpcResponse) => void, - ) => { - return this.#handleSend(payload, callback); - }; - - sendAsync = ( - payload: JsonRpcRequest, - callback: (error: unknown, response?: JsonRpcResponse) => void, - ) => { - return this.#handleSend(payload, callback); - }; - - #handleSend( - payload: JsonRpcRequest, - callback: (error: unknown, response?: JsonRpcResponse) => void, - ) { - if (Array.isArray(payload)) { - throw new Error("Arrays aren't supported"); - } - - const index = this.#stubs.findIndex((stub) => { - return ( - stub.request.method === payload.method && - (!('params' in stub.request) || - isDeepStrictEqual(stub.request.params, payload.params)) - ); - }); - - if (index === -1) { - const matchingCalledStubs = this.calledStubs.filter((stub) => { - return ( - stub.request.method === payload.method && - (!('params' in stub.request) || - isDeepStrictEqual(stub.request.params, payload.params)) - ); - }); - let message = `Could not find any stubs matching: ${inspect(payload, { - depth: null, - })}`; - if (matchingCalledStubs.length > 0) { - message += `\n\nIt appears the following stubs were defined, but have been called already:\n\n${inspect( - matchingCalledStubs, - { depth: null }, - )}`; - } - - throw new Error(message); - } else { - const stub = this.#stubs[index]; - - if (stub.discardAfterMatching !== false) { - this.#stubs.splice(index, 1); - } - - if (stub.delay) { - originalSetTimeout(() => { - this.#handleRequest(stub, callback); - }, stub.delay); - } else { - this.#handleRequest(stub, callback); - } - - this.calledStubs.push({ ...stub }); - } - } - - async #handleRequest( - stub: FakeProviderStub, - callback: (error: unknown, response?: JsonRpcResponse) => void, - ) { - if (stub.beforeCompleting) { - await stub.beforeCompleting(); - } - - if ('implementation' in stub) { - stub.implementation(); - } else if ('response' in stub) { - if ('result' in stub.response) { - return callback(null, { - jsonrpc: '2.0', - id: 1, - result: stub.response.result, - }); - } else if ('error' in stub.response) { - return callback(null, { - jsonrpc: '2.0', - id: 1, - error: { - code: -999, - message: stub.response.error, - }, - }); - } - } else if ('error' in stub) { - return callback(stub.error); - } - - return undefined; - } -} diff --git a/packages/network-controller/tests/helpers.ts b/packages/network-controller/tests/helpers.ts new file mode 100644 index 00000000000..5717a8f9d2e --- /dev/null +++ b/packages/network-controller/tests/helpers.ts @@ -0,0 +1,800 @@ +import { getDefaultAnalyticsControllerState } from '@metamask/analytics-controller'; +import { RegistryNetworkConfig } from '@metamask/config-registry-controller'; +import { CONNECTIVITY_STATUSES } from '@metamask/connectivity-controller'; +import type { ConnectivityStatus } from '@metamask/connectivity-controller'; +import { + ChainId, + InfuraNetworkType, + NetworkNickname, + NetworksTicker, + toHex, +} from '@metamask/controller-utils'; +import type { InternalProvider } from '@metamask/eth-json-rpc-provider'; +import { Messenger, MOCK_ANY_NAMESPACE } from '@metamask/messenger'; +import type { + MockAnyNamespace, + MessengerActions, + MessengerEvents, +} from '@metamask/messenger'; +import type { CaipChainId, Hex } from '@metamask/utils'; +import { v4 as uuidV4 } from 'uuid'; + +import { FakeBlockTracker } from '../../../tests/fake-block-tracker.js'; +import { FakeProvider } from '../../../tests/fake-provider.js'; +import type { FakeProviderStub } from '../../../tests/fake-provider.js'; +import { buildTestObject } from '../../../tests/helpers.js'; +import type { AutoManagedNetworkClient } from '../src/create-auto-managed-network-client.js'; +import { NetworkController } from '../src/index.js'; +import type { + BuiltInNetworkClientId, + CustomNetworkClientId, + NetworkClient, + NetworkClientConfiguration, + NetworkClientId, + NetworkConfiguration, +} from '../src/index.js'; +import type { + AddNetworkCustomRpcEndpointFields, + AddNetworkFields, + CustomRpcEndpoint, + InfuraRpcEndpoint, + NetworkControllerMessenger, + NetworkControllerOptions, + UpdateNetworkCustomRpcEndpointFields, +} from '../src/NetworkController.js'; +import { RpcEndpointType } from '../src/NetworkController.js'; +import { RpcServiceOptions } from '../src/rpc-service/rpc-service.js'; +import type { RpcFailoverMode } from '../src/selectors.js'; +import type { + CustomNetworkClientConfiguration, + InfuraNetworkClientConfiguration, +} from '../src/types.js'; +import { NetworkClientType } from '../src/types.js'; + +export type AllNetworkControllerActions = + MessengerActions; + +export type AllNetworkControllerEvents = + MessengerEvents; + +export type RootMessenger = Messenger< + MockAnyNamespace, + AllNetworkControllerActions, + AllNetworkControllerEvents +>; + +/** + * A list of active InfuraNetworkType that are used in many tests + * + * TODO: Base this off of InfuraNetworkType when Goerli is removed. + */ +export const INFURA_NETWORKS = [ + InfuraNetworkType.mainnet, + InfuraNetworkType.sepolia, + InfuraNetworkType['linea-mainnet'], + InfuraNetworkType['linea-sepolia'], +]; + +/** + * A object that contains the configuration for a network that begining used in many tests + */ +export const TESTNET = { + networkType: InfuraNetworkType.sepolia, + chainId: ChainId.sepolia, + name: 'Sepolia', + nativeCurrency: 'SepoliaETH', +}; + +/** + * Build a root messenger that includes all events used by the network + * controller. + * + * @param options - Optional configuration. + * @param options.connectivityStatus - The connectivity status to return by default. + * If not provided, defaults to Online. + * @param options.rpcFailoverMode - The RPC failover mode to return, defaults to `disabled`. + * @param options.analyticsId - The analytics ID that `AnalyticsController:getState` + * returns by default. Defaults to a fixed valid UUIDv4. + * @param options.trackEvent - The handler registered for + * `AnalyticsController:trackEvent`. Defaults to a Jest mock so tests can assert + * on it. + * @param options.configRegistryNetworkConfigs - The network config that + * `ConfigRegistryController:getNetworkConfigByCaip2ChainId` returns by default. Defaults to + * a mock network config for the chain ID `eip155:9999`. + * @returns The messenger. + */ +export function buildRootMessenger({ + connectivityStatus = CONNECTIVITY_STATUSES.Online, + rpcFailoverMode = 'disabled', + analyticsId = '11111111-1111-4111-8111-111111111111', + trackEvent = jest.fn(), + configRegistryNetworkConfigs = [buildMockConfigRegistryControllerNetwork()], +}: { + connectivityStatus?: ConnectivityStatus; + rpcFailoverMode?: RpcFailoverMode; + analyticsId?: string; + trackEvent?: jest.Mock; + configRegistryNetworkConfigs?: RegistryNetworkConfig[]; +} = {}): RootMessenger { + const rootMessenger = new Messenger< + MockAnyNamespace, + MessengerActions, + MessengerEvents + >({ namespace: MOCK_ANY_NAMESPACE, captureException: jest.fn() }); + + rootMessenger.registerActionHandler( + 'ConnectivityController:getState', + () => ({ + connectivityStatus, + }), + ); + + rootMessenger.registerActionHandler( + 'RemoteFeatureFlagController:getState', + () => ({ + remoteFeatureFlags: { + corePlatformRpcFailoverMode: rpcFailoverMode, + }, + cacheTimestamp: 0, + }), + ); + + rootMessenger.registerActionHandler('AnalyticsController:getState', () => ({ + ...getDefaultAnalyticsControllerState(), + analyticsId, + })); + + rootMessenger.registerActionHandler( + 'AnalyticsController:trackEvent', + trackEvent, + ); + + rootMessenger.registerActionHandler( + 'ConfigRegistryController:getNetworkConfigByCaip2ChainId', + (caipChainId) => + configRegistryNetworkConfigs.find( + (config) => config.chainId === caipChainId, + ), + ); + + rootMessenger.registerActionHandler( + 'ConfigRegistryController:getState', + () => ({ + configs: { + networks: configRegistryNetworkConfigs.reduce< + Record + >((acc, config) => { + acc[config.chainId] = config; + return acc; + }, {}), + }, + version: '0', + lastFetched: 0, + etag: '', + }), + ); + + return rootMessenger; +} + +/** + * Build a messenger for the network controller. + * + * @param rootMessenger - The root messenger. + * @returns The network controller messenger. + */ +export function buildNetworkControllerMessenger( + rootMessenger = buildRootMessenger(), +): NetworkControllerMessenger { + const networkControllerMessenger = new Messenger< + 'NetworkController', + AllNetworkControllerActions, + AllNetworkControllerEvents, + typeof rootMessenger + >({ + namespace: 'NetworkController', + parent: rootMessenger, + }); + + rootMessenger.delegate({ + messenger: networkControllerMessenger, + actions: [ + 'ConfigRegistryController:getNetworkConfigByCaip2ChainId', + 'ConfigRegistryController:getState', + 'ConnectivityController:getState', + 'RemoteFeatureFlagController:getState', + 'AnalyticsController:getState', + 'AnalyticsController:trackEvent', + ], + events: [ + // eslint-disable-next-line no-restricted-syntax + 'RemoteFeatureFlagController:stateChange', + 'ConfigRegistryController:stateChanged', + ], + }); + + return networkControllerMessenger; +} + +/** + * Builds an object that satisfies the NetworkClient shape, but using a fake + * provider and block tracker which doesn't make any requests. + * + * @param args - Arguments to this function. + * @param args.configuration - The desired network client configuration. + * @param args.providerStubs - Objects that allow for stubbing specific provider + * requests. + * @returns The fake network client. + */ +function buildFakeNetworkClient({ + configuration, + providerStubs = [], +}: { + configuration: NetworkClientConfiguration; + providerStubs?: FakeProviderStub[]; +}): NetworkClient { + const provider = new FakeProvider({ stubs: providerStubs }); + return { + configuration, + provider, + blockTracker: new FakeBlockTracker({ + provider: provider as unknown as InternalProvider, + }), + destroy: (): void => { + // do nothing + }, + }; +} + +/** + * The `getNetworkClientById` method on NetworkController (and thus, the + * `NetworkController:getNetworkClientById` controller action) is difficult to + * mock because it needs to be able to return either an Infura network client or + * a custom network client. However, a test may want to return specific network + * clients with specific network client configurations for specific network + * client IDs. This function makes that easier by allowing the consumer to + * specify a map of network client ID to network client configuration, handling + * the logic appropriately as well as defining the correct overloads for the + * mock version of `getNetworkClientById`. + * + * @param mockNetworkClientConfigurationsByNetworkClientId - Allows for defining + * the network client configuration — and thus the network client itself — that + * belongs to a particular network client ID. + * @returns The mock version of `getNetworkClientById`. + */ +export function buildMockGetNetworkClientById( + mockNetworkClientConfigurationsByNetworkClientId: Record< + NetworkClientId, + NetworkClientConfiguration + > = {}, +): NetworkController['getNetworkClientById'] { + // Since we might want to access these network client IDs so often in tests, + // register the network client configurations for all Infura networks by + // default. This does introduce a bit of magic as we don't expect to actually + // have a NetworkController in a test, but if we did, then we'd be able to + // make the same assumption anyway (i.e., that we'd be able to access any + // Infura network without having to add it explicitly to the controller). So + // pre-registering these network client IDs provides consistency from a mental + // model perspective at the expense of debuggability. + const defaultMockNetworkClientConfigurationsByNetworkClientId = Object.values( + InfuraNetworkType, + ).reduce((obj, infuraNetworkType) => { + return { + ...obj, + [infuraNetworkType]: + buildInfuraNetworkClientConfiguration(infuraNetworkType), + }; + }, {}); + const mergedMockNetworkClientConfigurationsByNetworkClientId: Record< + NetworkClientId, + NetworkClientConfiguration + > = { + ...defaultMockNetworkClientConfigurationsByNetworkClientId, + ...mockNetworkClientConfigurationsByNetworkClientId, + }; + + function getNetworkClientById( + networkClientId: BuiltInNetworkClientId, + ): AutoManagedNetworkClient; + function getNetworkClientById( + networkClientId: CustomNetworkClientId, + ): AutoManagedNetworkClient; + + function getNetworkClientById(networkClientId: string): NetworkClient { + const mockNetworkClientConfiguration = + mergedMockNetworkClientConfigurationsByNetworkClientId[networkClientId]; + + if (mockNetworkClientConfiguration === undefined) { + throw new Error( + `Unknown network client ID '${networkClientId}'. Please add it to mockNetworkClientConfigurationsByNetworkClientId.`, + ); + } + + return buildFakeNetworkClient({ + configuration: mockNetworkClientConfiguration, + }); + } + + return getNetworkClientById; +} + +/** + * Builds a mock version of the `findNetworkClientIdByChainId` method on + * NetworkController. + * + * @param mockNetworkClientConfigurationsByNetworkClientId - Allows for defining + * the network client configuration — and thus the network client itself — that + * belongs to a particular network client ID. + * @returns The mock version of `findNetworkClientIdByChainId`. + */ +export function buildMockFindNetworkClientIdByChainId( + mockNetworkClientConfigurationsByNetworkClientId: Record< + Hex, + NetworkClientConfiguration + > = {}, +): NetworkController['findNetworkClientIdByChainId'] { + const defaultMockNetworkClientConfigurationsByNetworkClientId = Object.values( + InfuraNetworkType, + ).reduce((obj, infuraNetworkType) => { + const testNetworkClientConfig = + buildInfuraNetworkClientConfiguration(infuraNetworkType); + return { + ...obj, + [testNetworkClientConfig.chainId]: testNetworkClientConfig, + }; + }, {}); + const mergedMockNetworkClientConfigurationsByNetworkClientId: Record< + Hex, + InfuraNetworkClientConfiguration + > = { + ...defaultMockNetworkClientConfigurationsByNetworkClientId, + ...mockNetworkClientConfigurationsByNetworkClientId, + }; + + function findNetworkClientIdByChainId(chainId: Hex): NetworkClientId; + + function findNetworkClientIdByChainId(chainId: Hex): NetworkClientId { + const networkClientConfigForChainId = + mergedMockNetworkClientConfigurationsByNetworkClientId[chainId]; + if (!networkClientConfigForChainId) { + throw new Error( + `Unknown chainId '${chainId}'. Please add it to mockNetworkClientConfigurationsByNetworkClientId.`, + ); + } + + return networkClientConfigForChainId.network; + } + return findNetworkClientIdByChainId; +} + +/** + * Builds a configuration object for an Infura network client based on the name + * of an Infura network. + * + * @param network - The name of an Infura network. + * @param overrides - Properties to merge into the configuration object. + * @returns the complete Infura network client configuration. + */ +export function buildInfuraNetworkClientConfiguration( + network: InfuraNetworkType, + overrides: Partial = {}, +): InfuraNetworkClientConfiguration { + return { + type: NetworkClientType.Infura, + network, + failoverRpcUrls: [], + infuraProjectId: 'test-infura-project-id', + chainId: ChainId[network], + ticker: NetworksTicker[network], + ...overrides, + }; +} + +/** + * Builds a configuration object for a custom network client based on any + * overrides provided. + * + * @param overrides - Properties to merge into the configuration object. + * @returns the complete custom network client configuration. + */ +export function buildCustomNetworkClientConfiguration( + overrides: Partial = {}, +): CustomNetworkClientConfiguration { + // `Object.assign` allows for properties to be `undefined` in `overrides`, + // and will copy them over + return Object.assign( + { + chainId: toHex(1337), + failoverRpcUrls: [], + rpcUrl: 'https://example.test', + ticker: 'TEST', + }, + overrides, + { + type: NetworkClientType.Custom, + }, + ); +} + +/** + * Constructs a NetworkConfiguration object for use in testing, providing + * defaults and allowing properties to be overridden at will. + * + * @param overrides - The properties to override the new + * NetworkConfiguration with. + * @param defaultRpcEndpointType - The type of the RPC endpoint you want to + * use by default. + * @returns The complete NetworkConfiguration object. + */ +export function buildNetworkConfiguration( + overrides: Partial = {}, + defaultRpcEndpointType: RpcEndpointType = RpcEndpointType.Custom, +): NetworkConfiguration { + return buildTestObject( + { + blockExplorerUrls: () => [], + chainId: () => '0x1337', + // @ts-expect-error We will make sure that this property is set below. + defaultRpcEndpointIndex: () => undefined, + name: () => 'Some Network', + nativeCurrency: () => 'TOKEN', + rpcEndpoints: () => [ + defaultRpcEndpointType === RpcEndpointType.Infura + ? buildInfuraRpcEndpoint(TESTNET.networkType) + : buildCustomRpcEndpoint({ url: 'https://test.endpoint' }), + ], + }, + overrides, + (object) => { + if ( + object.defaultRpcEndpointIndex === undefined && + object.rpcEndpoints.length > 0 + ) { + return { + ...object, + defaultRpcEndpointIndex: 0, + }; + } + return object; + }, + ); +} + +/** + * Constructs a NetworkConfiguration object preloaded with a custom RPC endpoint + * for use in testing, providing defaults and allowing properties to be + * overridden at will. + * + * @param overrides - The properties to override the new NetworkConfiguration + * with. + * @returns The complete NetworkConfiguration object. + */ +export function buildCustomNetworkConfiguration( + overrides: Partial = {}, +): NetworkConfiguration { + return buildTestObject( + { + blockExplorerUrls: () => [], + chainId: () => '0x1337' as const, + // @ts-expect-error We will make sure that this property is set below. + defaultRpcEndpointIndex: () => undefined, + name: () => 'Some Network', + nativeCurrency: () => 'TOKEN', + rpcEndpoints: () => [ + buildCustomRpcEndpoint({ + url: generateCustomRpcEndpointUrl(), + }), + ], + }, + overrides, + (object) => { + if ( + object.defaultRpcEndpointIndex === undefined && + object.rpcEndpoints.length > 0 + ) { + return { + ...object, + defaultRpcEndpointIndex: 0, + }; + } + return object; + }, + ); +} + +/** + * Constructs a NetworkConfiguration object preloaded with an Infura RPC + * endpoint for use in testing. + * + * @param infuraNetworkType - The Infura network type from which to create the + * NetworkConfiguration. + * @param overrides - The properties to override the new NetworkConfiguration + * with. + * @param overrides.rpcEndpoints - Extra RPC endpoints. + * @returns The complete NetworkConfiguration object. + */ +export function buildInfuraNetworkConfiguration( + infuraNetworkType: InfuraNetworkType, + overrides: Partial = {}, +): NetworkConfiguration { + const defaultRpcEndpoint = buildInfuraRpcEndpoint(infuraNetworkType); + return buildTestObject( + { + blockExplorerUrls: () => [], + chainId: () => ChainId[infuraNetworkType], + // @ts-expect-error We will make sure that this property is set below. + defaultRpcEndpointIndex: () => undefined, + name: () => NetworkNickname[infuraNetworkType], + nativeCurrency: () => NetworksTicker[infuraNetworkType], + rpcEndpoints: () => [defaultRpcEndpoint], + }, + overrides, + (object) => { + if ( + object.defaultRpcEndpointIndex === undefined && + object.rpcEndpoints.length > 0 + ) { + return { + ...object, + defaultRpcEndpointIndex: 0, + }; + } + return object; + }, + ); +} + +/** + * Constructs a InfuraRpcEndpoint object for use in testing. + * + * @param infuraNetworkType - The Infura network type from which to create the + * InfuraRpcEndpoint. + * @param options - Options. + * @param options.failoverUrls - The failover URLs to use. + * @returns The created InfuraRpcEndpoint object. + */ +export function buildInfuraRpcEndpoint( + infuraNetworkType: InfuraNetworkType, + { failoverUrls = [] }: { failoverUrls?: string[] } = {}, +): InfuraRpcEndpoint { + return { + failoverUrls, + networkClientId: infuraNetworkType, + type: RpcEndpointType.Infura as const, + url: `https://${infuraNetworkType}.infura.io/v3/{infuraProjectId}`, + }; +} + +/** + * Constructs an CustomRpcEndpoint object for use in testing, providing defaults + * and allowing properties to be overridden at will. + * + * @param overrides - The properties to override the new CustomRpcEndpoint with. + * @returns The complete CustomRpcEndpoint object. + */ +export function buildCustomRpcEndpoint( + overrides: Partial = {}, +): CustomRpcEndpoint { + return buildTestObject( + { + failoverUrls: () => [], + networkClientId: () => uuidV4(), + type: () => RpcEndpointType.Custom as const, + url: () => generateCustomRpcEndpointUrl(), + }, + overrides, + ); +} + +/** + * Constructs an AddNetworkFields object for use in testing, providing defaults + * and allowing properties to be overridden at will. + * + * @param overrides - The properties to override the new AddNetworkFields with. + * @returns The complete AddNetworkFields object. + */ +export function buildAddNetworkFields( + overrides: Partial = {}, +): AddNetworkFields { + return buildTestObject( + { + blockExplorerUrls: () => [], + chainId: () => '0x1337' as const, + // @ts-expect-error We will make sure that this property is set below. + defaultRpcEndpointIndex: () => undefined, + name: () => 'Some Network', + nativeCurrency: () => 'TOKEN', + rpcEndpoints: () => [ + buildAddNetworkCustomRpcEndpointFields({ + url: generateCustomRpcEndpointUrl(), + }), + ], + }, + overrides, + (object) => { + if ( + object.defaultRpcEndpointIndex === undefined && + object.rpcEndpoints.length > 0 + ) { + return { + ...object, + defaultRpcEndpointIndex: 0, + }; + } + return object; + }, + ); +} + +/** + * Constructs an AddNetworkCustomRpcEndpointFields object for use in testing, + * providing defaults and allowing properties to be overridden at will. + * + * @param overrides - The properties to override the new + * AddNetworkCustomRpcEndpointFields with. + * @returns The complete AddNetworkCustomRpcEndpointFields object. + */ +export function buildAddNetworkCustomRpcEndpointFields( + overrides: Partial = {}, +): AddNetworkCustomRpcEndpointFields { + return buildTestObject( + { + failoverUrls: () => [], + type: () => RpcEndpointType.Custom as const, + url: () => generateCustomRpcEndpointUrl(), + }, + overrides, + ); +} + +/** + * Constructs an UpdateNetworkCustomRpcEndpointFields object for use in testing, + * providing defaults and allowing properties to be overridden at will. + * + * @param overrides - The properties to override the new + * UpdateNetworkCustomRpcEndpointFields with. + * @returns The complete UpdateNetworkCustomRpcEndpointFields object. + */ +export function buildUpdateNetworkCustomRpcEndpointFields( + overrides: Partial = {}, +): UpdateNetworkCustomRpcEndpointFields { + return buildTestObject( + { + failoverUrls: () => [], + type: () => RpcEndpointType.Custom as const, + url: () => generateCustomRpcEndpointUrl(), + }, + overrides, + ); +} + +let testEndpointCounter = 0; + +/** + * Generates a unique custom RPC endpoint URL for testing. + * + * @returns The generated RPC endpoint URL. + */ +function generateCustomRpcEndpointUrl(): string { + const url = `https://test.endpoint/${testEndpointCounter}`; + testEndpointCounter += 1; + return url; +} + +/** + * Builds a mock RegistryNetworkConfig object for use in testing, providing defaults + * and allowing properties to be overridden at will. + * + * @param override - The properties to override the new RegistryNetworkConfig with. + * @returns The complete RegistryNetworkConfig object. + */ +export function buildMockConfigRegistryControllerNetwork( + override: Partial = {}, +): RegistryNetworkConfig { + return { + chainId: 'eip155:9999', + imageUrl: 'https://example.com/network-logo.png', + coingeckoPlatformId: 'ethereum', + name: 'Ethereum Mainnet', + assets: { + native: { + assetId: 'eip155:1/slip44:60', + imageUrl: 'https://example.com/eth-logo.png', + name: 'Ether', + symbol: 'ETH', + decimals: 18, + }, + }, + rpcProviders: { + default: { + type: RpcEndpointType.Infura, + url: 'https://my-network.infura.io/v3/{infuraProjectId}', + networkClientId: 'my-network', + }, + fallbacks: [], + }, + blockExplorerUrls: { + default: 'https://etherscan.io', + fallbacks: [], + }, + config: { + isActive: true, + isTestnet: false, + isDefault: false, + isDeprecated: false, + isDeletable: true, + isFeatured: false, + priority: 0, + }, + ...override, + }; +} + +type WithControllerCallback = ({ + controller, +}: { + controller: NetworkController; + messenger: RootMessenger; + networkControllerMessenger: NetworkControllerMessenger; +}) => Promise | ReturnValue; + +type WithControllerOptions = Partial & { + rpcFailoverMode?: RpcFailoverMode; + configRegistryNetworkConfigs?: RegistryNetworkConfig[]; + initializeController?: boolean; +}; + +type WithControllerArgs = + | [WithControllerCallback] + | [WithControllerOptions, WithControllerCallback]; + +/** + * Builds a controller based on the given options, and calls the given function + * with that controller. + * + * @param args - Either a function, or an options bag + a function. The options + * bag is equivalent to the options that NetworkController takes (although + * `messenger` and `infuraProjectId` are filled in if not given); the function + * will be called with the built controller. + * @returns Whatever the callback returns. + */ +export async function withController( + ...args: WithControllerArgs +): Promise { + const [{ ...rest }, fn] = args.length === 2 ? args : [{}, args[0]]; + const { + rpcFailoverMode, + initializeController = true, + configRegistryNetworkConfigs = [buildMockConfigRegistryControllerNetwork()], + ...controllerOptions + } = rest; + const messenger = buildRootMessenger({ + rpcFailoverMode, + configRegistryNetworkConfigs, + }); + const networkControllerMessenger = buildNetworkControllerMessenger(messenger); + const controller = new NetworkController({ + messenger: networkControllerMessenger, + infuraProjectId: 'infura-project-id', + getRpcServiceOptions: (): Omit< + RpcServiceOptions, + 'failoverService' | 'endpointUrl' + > => ({ + fetch, + btoa, + isOffline: (): boolean => false, + }), + ...controllerOptions, + }); + + if (initializeController) { + controller.init(); + } + + try { + return await fn({ controller, messenger, networkControllerMessenger }); + } finally { + const { blockTracker } = controller.getProviderAndBlockTracker(); + await blockTracker?.__target__?.destroy(); + } +} diff --git a/packages/network-controller/tests/network-client/block-hash-in-response.ts b/packages/network-controller/tests/network-client/block-hash-in-response.ts new file mode 100644 index 00000000000..aeaa641bbf2 --- /dev/null +++ b/packages/network-controller/tests/network-client/block-hash-in-response.ts @@ -0,0 +1,811 @@ +import { errorCodes, rpcErrors } from '@metamask/rpc-errors'; + +import { CUSTOM_RPC_ERRORS } from '../../src/rpc-service/rpc-service.js'; +import { NetworkClientType } from '../../src/types.js'; +import type { ProviderType } from './helpers.js'; +import { + waitForPromiseToBeFulfilledAfterRunningAllTimers, + withMockedCommunications, + withNetworkClient, +} from './helpers.js'; +import { testsForRpcFailoverBehavior } from './rpc-failover.js'; + +type TestsForRpcMethodThatCheckForBlockHashInResponseOptions = { + providerType: ProviderType; + numberOfParameters: number; +}; + +/** + * Defines tests which exercise the behavior exhibited by an RPC method that + * use `blockHash` in the response data to determine whether the response is + * cacheable. + * + * @param method - The name of the RPC method under test. + * @param additionalArgs - Additional arguments. + * @param additionalArgs.numberOfParameters - The number of parameters supported + * by the method under test. + * @param additionalArgs.providerType - The type of provider being tested; + * either `infura` or `custom`. + */ +export function testsForRpcMethodsThatCheckForBlockHashInResponse( + method: string, + { + numberOfParameters, + providerType, + }: TestsForRpcMethodThatCheckForBlockHashInResponseOptions, +): void { + it('does not hit the RPC endpoint more than once for identical requests and it has a valid blockHash', async () => { + const requests = [{ method }, { method }]; + const mockResult = { blockHash: '0x1' }; + + await withMockedCommunications({ providerType }, async (comms) => { + // The first time a block-cacheable request is made, the latest block + // number is retrieved through the block tracker first. It doesn't + // matter what this is — it's just used as a cache key. + comms.mockNextBlockTrackerRequest(); + comms.mockRpcCall({ + request: requests[0], + response: { result: mockResult }, + }); + + const results = await withNetworkClient( + { providerType }, + ({ makeRpcCallsInSeries }) => makeRpcCallsInSeries(requests), + ); + + expect(results).toStrictEqual([mockResult, mockResult]); + }); + }); + + it('hits the RPC endpoint and does not reuse the result of a previous request if the latest block number was updated since', async () => { + const pollingInterval = 1234; + const requests = [{ method }, { method }]; + const mockResults = [{ blockHash: '0x100' }, { blockHash: '0x200' }]; + + await withMockedCommunications({ providerType }, async (comms) => { + comms.mockNextBlockTrackerRequest({ blockNumber: '0x1' }); + comms.mockRpcCall({ + request: requests[0], + response: { result: mockResults[0] }, + }); + comms.mockNextBlockTrackerRequest({ blockNumber: '0x2' }); + comms.mockRpcCall({ + request: requests[1], + response: { result: mockResults[1] }, + }); + + const results = await withNetworkClient( + { + providerType, + getBlockTrackerOptions: () => ({ + pollingInterval, + }), + }, + async ({ blockTracker, makeRpcCall }) => { + const waitForTwoBlocks = new Promise((resolve) => { + let numberOfBlocks = 0; + + // Start the block tracker + blockTracker.on('latest', () => { + numberOfBlocks += 1; + if (numberOfBlocks === 2) { + resolve(); + } + }); + }); + + const firstResult = await makeRpcCall(requests[0]); + // Proceed to the next iteration of the block tracker so that a new + // block is fetched and the current block is updated. + await jest.advanceTimersByTimeAsync(pollingInterval); + await waitForTwoBlocks; + const secondResult = await makeRpcCall(requests[1]); + return [firstResult, secondResult]; + }, + ); + + expect(results).toStrictEqual(mockResults); + }); + }); + + it('does not reuse the result of a previous request if result.blockHash was null', async () => { + const requests = [{ method }, { method }]; + const mockResults = [ + { blockHash: null, extra: 'some value' }, + { blockHash: '0x100', extra: 'some other value' }, + ]; + + await withMockedCommunications({ providerType }, async (comms) => { + // The first time a block-cacheable request is made, the latest block + // number is retrieved through the block tracker first. It doesn't + // matter what this is — it's just used as a cache key. + comms.mockNextBlockTrackerRequest(); + comms.mockRpcCall({ + request: requests[0], + response: { result: mockResults[0] }, + }); + comms.mockRpcCall({ + request: requests[1], + response: { result: mockResults[1] }, + }); + + const results = await withNetworkClient( + { providerType }, + ({ makeRpcCallsInSeries }) => makeRpcCallsInSeries(requests), + ); + + expect(results).toStrictEqual(mockResults); + }); + }); + + it('does not reuse the result of a previous request if result.blockHash was undefined', async () => { + const requests = [{ method }, { method }]; + const mockResults = [ + { extra: 'some value' }, + { blockHash: '0x100', extra: 'some other value' }, + ]; + + await withMockedCommunications({ providerType }, async (comms) => { + // The first time a block-cacheable request is made, the latest block + // number is retrieved through the block tracker first. It doesn't + // matter what this is — it's just used as a cache key. + comms.mockNextBlockTrackerRequest(); + comms.mockRpcCall({ + request: requests[0], + response: { result: mockResults[0] }, + }); + comms.mockRpcCall({ + request: requests[1], + response: { result: mockResults[1] }, + }); + + const results = await withNetworkClient( + { providerType }, + ({ makeRpcCallsInSeries }) => makeRpcCallsInSeries(requests), + ); + + expect(results).toStrictEqual(mockResults); + }); + }); + + it('does not reuse the result of a previous request if result.blockHash was "0x0000000000000000000000000000000000000000000000000000000000000000"', async () => { + const requests = [{ method }, { method }]; + const mockResults = [ + { + blockHash: + '0x0000000000000000000000000000000000000000000000000000000000000000', + extra: 'some value', + }, + { blockHash: '0x100', extra: 'some other value' }, + ]; + + await withMockedCommunications({ providerType }, async (comms) => { + // The first time a block-cacheable request is made, the latest block + // number is retrieved through the block tracker first. It doesn't + // matter what this is — it's just used as a cache key. + comms.mockNextBlockTrackerRequest(); + comms.mockRpcCall({ + request: requests[0], + response: { result: mockResults[0] }, + }); + comms.mockRpcCall({ + request: requests[1], + response: { result: mockResults[1] }, + }); + + const results = await withNetworkClient( + { providerType }, + ({ makeRpcCallsInSeries }) => makeRpcCallsInSeries(requests), + ); + + expect(results).toStrictEqual(mockResults); + }); + }); + + for (const emptyValue of [null, '\u003cnil\u003e']) { + it(`does not retry an empty response of "${emptyValue}"`, async () => { + const request = { method }; + const mockResult = emptyValue; + + await withMockedCommunications({ providerType }, async (comms) => { + // The first time a block-cacheable request is made, the latest block + // number is retrieved through the block tracker first. It doesn't + // matter what this is — it's just used as a cache key. + comms.mockNextBlockTrackerRequest(); + comms.mockRpcCall({ + request, + response: { result: mockResult }, + }); + + const result = await withNetworkClient( + { providerType }, + ({ makeRpcCall }) => makeRpcCall(request), + ); + + expect(result).toStrictEqual(mockResult); + }); + }); + + it(`does not reuse the result of a previous request if it was "${emptyValue}"`, async () => { + const requests = [{ method }, { method }]; + const mockResults = [emptyValue, { blockHash: '0x100' }]; + + await withMockedCommunications({ providerType }, async (comms) => { + // The first time a block-cacheable request is made, the latest block + // number is retrieved through the block tracker first. It doesn't + // matter what this is — it's just used as a cache key. + comms.mockNextBlockTrackerRequest(); + comms.mockRpcCall({ + request: requests[0], + response: { result: mockResults[0] }, + }); + comms.mockRpcCall({ + request: requests[1], + response: { result: mockResults[1] }, + }); + + const results = await withNetworkClient( + { providerType }, + ({ makeRpcCallsInSeries }) => makeRpcCallsInSeries(requests), + ); + + expect(results).toStrictEqual(mockResults); + }); + }); + } + + for (const paramIndex of [...Array(numberOfParameters).keys()]) { + it(`does not reuse the result of a previous request with a valid blockHash if parameter at index "${paramIndex}" differs`, async () => { + const firstMockParams = [ + ...new Array(numberOfParameters).fill('some value'), + ]; + const secondMockParams = firstMockParams.slice(); + secondMockParams[paramIndex] = 'another value'; + const requests = [ + { + method, + params: firstMockParams, + }, + { method, params: secondMockParams }, + ]; + const mockResults = [{ blockHash: '0x100' }, { blockHash: '0x200' }]; + + await withMockedCommunications({ providerType }, async (comms) => { + // The first time a block-cacheable request is made, the latest block + // number is retrieved through the block tracker first. It doesn't + // matter what this is — it's just used as a cache key. + comms.mockNextBlockTrackerRequest(); + comms.mockRpcCall({ + request: requests[0], + response: { result: mockResults[0] }, + }); + comms.mockRpcCall({ + request: requests[1], + response: { result: mockResults[1] }, + }); + + const results = await withNetworkClient( + { providerType }, + ({ makeRpcCallsInSeries }) => makeRpcCallsInSeries(requests), + ); + + expect(results).toStrictEqual([mockResults[0], mockResults[1]]); + }); + }); + } + + it('does not discard an error in a non-standard JSON-RPC error response, but throws it', async () => { + const request = { method, params: [] }; + const error = { + code: -32000, + data: { + foo: 'bar', + }, + message: 'VM Exception while processing transaction: revert', + name: 'RuntimeError', + stack: + 'RuntimeError: VM Exception while processing transaction: revert at exactimate (/Users/elliot/code/metamask/metamask-mobile/node_modules/ganache/dist/node/webpack:/Ganache/ethereum/ethereum/lib/src/helpers/gas-estimator.js:257:23)', + }; + + await withMockedCommunications({ providerType }, async (comms) => { + // The first time a block-cacheable request is made, the latest block + // number is retrieved through the block tracker first. It doesn't + // matter what this is — it's just used as a cache key. + comms.mockNextBlockTrackerRequest(); + comms.mockRpcCall({ + request, + response: { + error, + }, + }); + + const promise = withNetworkClient( + { providerType }, + async ({ provider }) => { + return await provider.request(request); + }, + ); + + if (providerType === NetworkClientType.Infura) { + // This is not ideal, but we can refactor this later. + // eslint-disable-next-line jest/no-conditional-expect + await expect(promise).rejects.toThrow( + rpcErrors.internal({ + message: error.message, + data: { cause: error }, + }), + ); + } else { + // This is not ideal, but we can refactor this later. + // eslint-disable-next-line jest/no-conditional-expect + await expect(promise).rejects.toThrow( + rpcErrors.internal({ data: error }), + ); + } + }); + }); + + describe.each([ + [401, CUSTOM_RPC_ERRORS.unauthorized], + [402, errorCodes.rpc.resourceUnavailable], + [404, errorCodes.rpc.resourceUnavailable], + [422, CUSTOM_RPC_ERRORS.httpClientError], + [429, errorCodes.rpc.limitExceeded], + ])( + 'if the RPC endpoint returns a %d response', + (httpStatus, rpcErrorCode) => { + const expectedError = expect.objectContaining({ + code: rpcErrorCode, + }); + + it('throws a custom error without retrying the request', async () => { + await withMockedCommunications({ providerType }, async (comms) => { + const request = { method }; + + // The first time a block-cacheable request is made, the latest block + // number is retrieved through the block tracker first. It doesn't + // matter what this is — it's just used as a cache key. + comms.mockNextBlockTrackerRequest(); + comms.mockRpcCall({ + request, + response: { + httpStatus, + }, + }); + const promiseForResult = withNetworkClient( + { providerType }, + async ({ makeRpcCall }) => makeRpcCall(request), + ); + + await expect(promiseForResult).rejects.toThrow(expectedError); + }); + }); + + // NOTE: We do not test the RPC failover behavior here because only 5xx + // errors break the circuit and cause a failover. + }, + ); + + describe.each([500, 501, 505, 506, 507, 508, 510, 511])( + 'if the RPC endpoint returns a %d response', + (httpStatus) => { + const expectedError = expect.objectContaining({ + code: errorCodes.rpc.resourceUnavailable, + }); + + it('throws a custom error without retrying the request', async () => { + await withMockedCommunications({ providerType }, async (comms) => { + const request = { method }; + + // The first time a block-cacheable request is made, the latest block + // number is retrieved through the block tracker first. It doesn't + // matter what this is — it's just used as a cache key. + comms.mockNextBlockTrackerRequest(); + comms.mockRpcCall({ + request, + response: { + httpStatus, + }, + }); + const promiseForResult = withNetworkClient( + { providerType }, + async ({ makeRpcCall }) => makeRpcCall(request), + ); + + await expect(promiseForResult).rejects.toThrow(expectedError); + }); + }); + + testsForRpcFailoverBehavior({ + providerType, + requestToCall: { + method, + params: [], + }, + getRequestToMock: () => ({ + method, + params: [], + }), + failure: { + httpStatus, + }, + isRetriableFailure: false, + getExpectedError: () => expectedError, + getExpectedBreakError: () => + expect.objectContaining({ + message: `Fetch failed with status '${httpStatus}'`, + }), + }); + }, + ); + + describe.each([502, 503, 504])( + 'if the RPC endpoint returns a %d response', + (httpStatus) => { + const expectedError = expect.objectContaining({ + code: errorCodes.rpc.resourceUnavailable, + }); + + it('retries the request up to 4 times until there is a 200 response', async () => { + await withMockedCommunications({ providerType }, async (comms) => { + const request = { method }; + + // The first time a block-cacheable request is made, the latest block + // number is retrieved through the block tracker first. It doesn't + // matter what this is — it's just used as a cache key. + comms.mockNextBlockTrackerRequest(); + // Here we have the request fail for the first 4 tries, then succeed + // on the 5th try. + comms.mockRpcCall({ + request, + response: { + error: 'Some error', + httpStatus, + }, + times: 3, + }); + comms.mockRpcCall({ + request, + response: { + result: 'the result', + httpStatus: 200, + }, + }); + const result = await withNetworkClient( + { providerType }, + async ({ makeRpcCall }) => { + return await waitForPromiseToBeFulfilledAfterRunningAllTimers( + makeRpcCall(request), + ); + }, + ); + + expect(result).toBe('the result'); + }); + }); + + it(`throws a custom error if the response continues to be ${httpStatus} after 5 retries`, async () => { + await withMockedCommunications({ providerType }, async (comms) => { + const request = { method }; + + // The first time a block-cacheable request is made, the latest block + // number is retrieved through the block tracker first. It doesn't + // matter what this is — it's just used as a cache key. + comms.mockNextBlockTrackerRequest(); + comms.mockRpcCall({ + request, + response: { + error: 'Some error', + httpStatus, + }, + times: 5, + }); + comms.mockNextBlockTrackerRequest(); + const promiseForResult = withNetworkClient( + { providerType }, + async ({ makeRpcCall }) => { + return await waitForPromiseToBeFulfilledAfterRunningAllTimers( + makeRpcCall(request), + ); + }, + ); + await expect(promiseForResult).rejects.toThrow(expectedError); + }); + }); + + testsForRpcFailoverBehavior({ + providerType, + requestToCall: { + method, + params: [], + }, + getRequestToMock: () => ({ + method, + params: [], + }), + failure: { + httpStatus, + }, + isRetriableFailure: true, + getExpectedError: () => expectedError, + getExpectedBreakError: () => + expect.objectContaining({ + message: expect.stringContaining( + `Fetch failed with status '${httpStatus}'`, + ), + }), + }); + }, + ); + + describe.each(['ETIMEDOUT', 'ECONNRESET'])( + 'if a %s error is thrown while making the request', + (errorCode) => { + const error = new Error(errorCode); + // @ts-expect-error `code` does not exist on the Error type, but is + // still used by Node. + error.code = errorCode; + + it('retries the request up to 4 times until it is successful', async () => { + await withMockedCommunications({ providerType }, async (comms) => { + const request = { method }; + + // The first time a block-cacheable request is made, the latest block + // number is retrieved through the block tracker first. It doesn't + // matter what this is — it's just used as a cache key. + comms.mockNextBlockTrackerRequest(); + // Here we have the request fail for the first 4 tries, then succeed + // on the 5th try. + comms.mockRpcCall({ + request, + error, + times: 3, + }); + comms.mockRpcCall({ + request, + response: { + result: 'the result', + httpStatus: 200, + }, + }); + + const result = await withNetworkClient( + { providerType }, + async ({ makeRpcCall }) => { + return await waitForPromiseToBeFulfilledAfterRunningAllTimers( + makeRpcCall(request), + ); + }, + ); + + expect(result).toBe('the result'); + }); + }); + + it('re-throws the error if it persists after 5 retries', async () => { + await withMockedCommunications({ providerType }, async (comms) => { + const request = { method }; + + // The first time a block-cacheable request is made, the latest block + // number is retrieved through the block tracker first. It doesn't + // matter what this is — it's just used as a cache key. + comms.mockNextBlockTrackerRequest(); + comms.mockRpcCall({ + request, + error, + times: 5, + }); + const promiseForResult = withNetworkClient( + { providerType }, + async ({ makeRpcCall }) => { + return await waitForPromiseToBeFulfilledAfterRunningAllTimers( + makeRpcCall(request), + ); + }, + ); + + await expect(promiseForResult).rejects.toThrow(error.message); + }); + }); + + testsForRpcFailoverBehavior({ + providerType, + requestToCall: { + method, + params: [], + }, + getRequestToMock: () => ({ + method, + params: [], + }), + failure: error, + isRetriableFailure: true, + getExpectedError: (url: string) => + expect.objectContaining({ + message: `request to ${url} failed, reason: ${errorCode}`, + }), + }); + }, + ); + + describe('if the RPC endpoint responds with invalid JSON', () => { + const expectedError = expect.objectContaining({ + code: errorCodes.rpc.parse, + }); + + it('retries the request up to 4 times until it responds with valid JSON', async () => { + await withMockedCommunications({ providerType }, async (comms) => { + const request = { method }; + + // The first time a block-cacheable request is made, the latest block + // number is retrieved through the block tracker first. It doesn't + // matter what this is — it's just used as a cache key. + comms.mockNextBlockTrackerRequest(); + // Here we have the request fail for the first 4 tries, then succeed + // on the 5th try. + comms.mockRpcCall({ + request, + response: { + body: 'invalid JSON', + }, + times: 3, + }); + comms.mockRpcCall({ + request, + response: { + result: 'the result', + httpStatus: 200, + }, + }); + + const result = await withNetworkClient( + { providerType }, + async ({ makeRpcCall, clock }) => { + return await waitForPromiseToBeFulfilledAfterRunningAllTimers( + makeRpcCall(request), + clock, + ); + }, + ); + + expect(result).toBe('the result'); + }); + }); + + it('throws a custom error if the result is still non-JSON-parseable after 5 retries', async () => { + await withMockedCommunications({ providerType }, async (comms) => { + const request = { method }; + + // The first time a block-cacheable request is made, the latest block + // number is retrieved through the block tracker first. It doesn't + // matter what this is — it's just used as a cache key. + comms.mockNextBlockTrackerRequest(); + comms.mockRpcCall({ + request, + response: { + body: 'invalid JSON', + }, + times: 5, + }); + const promiseForResult = withNetworkClient( + { providerType }, + async ({ makeRpcCall, clock }) => { + return await waitForPromiseToBeFulfilledAfterRunningAllTimers( + makeRpcCall(request), + clock, + ); + }, + ); + + await expect(promiseForResult).rejects.toThrow(expectedError); + }); + }); + + testsForRpcFailoverBehavior({ + providerType, + requestToCall: { + method, + params: [], + }, + getRequestToMock: () => ({ + method, + params: [], + }), + failure: { + body: 'invalid JSON', + }, + isRetriableFailure: true, + getExpectedError: () => expectedError, + getExpectedBreakError: () => + expect.objectContaining({ + message: expect.stringContaining('invalid json'), + }), + }); + }); + + describe('if making the request throws a connection error', () => { + const error = new TypeError('Failed to fetch'); + + it('retries the request up to 4 times until there is no connection error', async () => { + await withMockedCommunications({ providerType }, async (comms) => { + const request = { method }; + + // The first time a block-cacheable request is made, the latest block + // number is retrieved through the block tracker first. It doesn't + // matter what this is — it's just used as a cache key. + comms.mockNextBlockTrackerRequest(); + // Here we have the request fail for the first 4 tries, then succeed + // on the 5th try. + comms.mockRpcCall({ + request, + error, + times: 3, + }); + comms.mockRpcCall({ + request, + response: { + result: 'the result', + httpStatus: 200, + }, + }); + + const result = await withNetworkClient( + { providerType }, + async ({ makeRpcCall, clock }) => { + return await waitForPromiseToBeFulfilledAfterRunningAllTimers( + makeRpcCall(request), + clock, + ); + }, + ); + + expect(result).toBe('the result'); + }); + }); + + it('re-throws the error if it persists after 5 retries', async () => { + await withMockedCommunications({ providerType }, async (comms) => { + const request = { method }; + + // The first time a block-cacheable request is made, the latest block + // number is retrieved through the block tracker first. It doesn't + // matter what this is — it's just used as a cache key. + comms.mockNextBlockTrackerRequest(); + comms.mockRpcCall({ + request, + error, + times: 5, + }); + const promiseForResult = withNetworkClient( + { providerType }, + async ({ makeRpcCall, clock }) => { + return await waitForPromiseToBeFulfilledAfterRunningAllTimers( + makeRpcCall(request), + clock, + ); + }, + ); + + await expect(promiseForResult).rejects.toThrow(error.message); + }); + }); + + testsForRpcFailoverBehavior({ + providerType, + requestToCall: { + method, + params: [], + }, + getRequestToMock: () => ({ + method, + params: [], + }), + failure: error, + isRetriableFailure: true, + getExpectedError: (url: string) => + expect.objectContaining({ + message: `request to ${url} failed, reason: ${error.message}`, + }), + }); + }); +} diff --git a/packages/network-controller/tests/network-client/block-param.ts b/packages/network-controller/tests/network-client/block-param.ts new file mode 100644 index 00000000000..5b0e206c7ee --- /dev/null +++ b/packages/network-controller/tests/network-client/block-param.ts @@ -0,0 +1,1658 @@ +import { errorCodes, rpcErrors } from '@metamask/rpc-errors'; +import type { Hex } from '@metamask/utils'; + +import { CUSTOM_RPC_ERRORS } from '../../src/rpc-service/rpc-service.js'; +import { NetworkClientType } from '../../src/types.js'; +import type { MockRequest, ProviderType } from './helpers.js'; +import { + buildMockParams, + buildRequestWithReplacedBlockParam, + waitForPromiseToBeFulfilledAfterRunningAllTimers, + withMockedCommunications, + withNetworkClient, +} from './helpers.js'; +import { testsForRpcFailoverBehavior } from './rpc-failover.js'; + +type TestsForRpcMethodSupportingBlockParam = { + providerType: ProviderType; + blockParamIndex: number; + numberOfParameters: number; +}; + +/** + * Defines tests which exercise the behavior exhibited by an RPC method that + * takes a block parameter. The value of this parameter can be either a block + * number or a block tag ("latest", "earliest", or "pending") and affects how + * the method is cached. + * + * @param method - The name of the RPC method under test. + * @param additionalArgs - Additional arguments. + * @param additionalArgs.blockParamIndex - The index of the block parameter. + * @param additionalArgs.numberOfParameters - The number of parameters + * supported by the method under test. + * @param additionalArgs.providerType - The type of provider being tested. + * either `infura` or `custom`. + */ +export function testsForRpcMethodSupportingBlockParam( + method: string, + { + blockParamIndex, + numberOfParameters, + providerType, + }: TestsForRpcMethodSupportingBlockParam, +): void { + describe.each([ + ['given no block tag', undefined], + ['given a block tag of "latest"', 'latest'], + ])('%s', (_desc, blockParam) => { + it('does not hit the RPC endpoint more than once for identical requests', async () => { + const requests = [ + { + method, + params: buildMockParams({ blockParamIndex, blockParam }), + }, + { method, params: buildMockParams({ blockParamIndex, blockParam }) }, + ]; + const mockResults = ['first result', 'second result']; + + await withMockedCommunications({ providerType }, async (comms) => { + // The first time a block-cacheable request is made, the block-cache + // middleware will request the latest block number through the block + // tracker to determine the cache key. Later, the block-ref + // middleware will request the latest block number again to resolve + // the value of "latest", but the block number is cached once made, + // so we only need to mock the request once. + comms.mockNextBlockTrackerRequest({ blockNumber: '0x100' }); + // The block-ref middleware will make the request as specified + // except that the block param is replaced with the latest block + // number. + comms.mockRpcCall({ + request: buildRequestWithReplacedBlockParam( + requests[0], + blockParamIndex, + '0x100', + ), + response: { result: mockResults[0] }, + }); + + const results = await withNetworkClient( + { providerType }, + ({ makeRpcCallsInSeries }) => makeRpcCallsInSeries(requests), + ); + + expect(results).toStrictEqual([mockResults[0], mockResults[0]]); + }); + }); + + for (const paramIndex of [...Array(numberOfParameters).keys()]) { + if (paramIndex === blockParamIndex) { + // testing changes in block param is covered under later tests + continue; + } + it(`does not reuse the result of a previous request if parameter at index "${paramIndex}" differs`, async () => { + const firstMockParams = [ + ...new Array(numberOfParameters).fill('some value'), + ]; + firstMockParams[blockParamIndex] = blockParam; + const secondMockParams = firstMockParams.slice(); + secondMockParams[paramIndex] = 'another value'; + const requests = [ + { + method, + params: firstMockParams, + }, + { method, params: secondMockParams }, + ]; + const mockResults = ['first result', 'second result']; + + await withMockedCommunications({ providerType }, async (comms) => { + // The first time a block-cacheable request is made, the block-cache + // middleware will request the latest block number through the block + // tracker to determine the cache key. Later, the block-ref + // middleware will request the latest block number again to resolve + // the value of "latest", but the block number is cached once made, + // so we only need to mock the request once. + comms.mockNextBlockTrackerRequest({ blockNumber: '0x100' }); + // The block-ref middleware will make the request as specified + // except that the block param is replaced with the latest block + // number. + comms.mockRpcCall({ + request: buildRequestWithReplacedBlockParam( + requests[0], + blockParamIndex, + '0x100', + ), + response: { result: mockResults[0] }, + }); + comms.mockRpcCall({ + request: buildRequestWithReplacedBlockParam( + requests[1], + blockParamIndex, + '0x100', + ), + response: { result: mockResults[1] }, + }); + + const results = await withNetworkClient( + { providerType }, + ({ makeRpcCallsInSeries }) => makeRpcCallsInSeries(requests), + ); + + expect(results).toStrictEqual([mockResults[0], mockResults[1]]); + }); + }); + } + + it('hits the RPC endpoint and does not reuse the result of a previous request if the latest block number was updated since', async () => { + const pollingInterval = 1234; + const requests = [ + { method, params: buildMockParams({ blockParamIndex, blockParam }) }, + { method, params: buildMockParams({ blockParamIndex, blockParam }) }, + ]; + const mockResults = ['first result', 'second result']; + + await withMockedCommunications({ providerType }, async (comms) => { + comms.mockNextBlockTrackerRequest({ blockNumber: '0x100' }); + // The block-ref middleware will make the request as specified + // except that the block param is replaced with the latest block + // number. + comms.mockRpcCall({ + request: buildRequestWithReplacedBlockParam( + requests[0], + blockParamIndex, + '0x100', + ), + response: { result: mockResults[0] }, + }); + comms.mockNextBlockTrackerRequest({ blockNumber: '0x200' }); + comms.mockRpcCall({ + request: buildRequestWithReplacedBlockParam( + requests[1], + blockParamIndex, + '0x200', + ), + response: { result: mockResults[1] }, + }); + + const results = await withNetworkClient( + { + providerType, + getBlockTrackerOptions: () => ({ + pollingInterval, + }), + }, + async ({ blockTracker, makeRpcCall }) => { + const waitForTwoBlocks = new Promise((resolve) => { + let numberOfBlocks = 0; + + // Start the block tracker + blockTracker.on('latest', () => { + numberOfBlocks += 1; + if (numberOfBlocks === 2) { + resolve(); + } + }); + }); + + const firstResult = await makeRpcCall(requests[0]); + // Proceed to the next iteration of the block tracker so that a new + // block is fetched and the current block is updated. + await jest.advanceTimersByTimeAsync(pollingInterval); + await waitForTwoBlocks; + const secondResult = await makeRpcCall(requests[1]); + return [firstResult, secondResult]; + }, + ); + + expect(results).toStrictEqual(mockResults); + }); + }); + + for (const emptyValue of [null, '\u003cnil\u003e']) { + it(`does not retry an empty response of "${emptyValue}"`, async () => { + const request = { + method, + params: buildMockParams({ blockParamIndex, blockParam }), + }; + const mockResult = emptyValue; + + await withMockedCommunications({ providerType }, async (comms) => { + // The first time a block-cacheable request is made, the + // block-cache middleware will request the latest block number + // through the block tracker to determine the cache key. Later, + // the block-ref middleware will request the latest block number + // again to resolve the value of "latest", but the block number is + // cached once made, so we only need to mock the request once. + comms.mockNextBlockTrackerRequest({ blockNumber: '0x100' }); + // The block-ref middleware will make the request as specified + // except that the block param is replaced with the latest block + // number. + comms.mockRpcCall({ + request: buildRequestWithReplacedBlockParam( + request, + blockParamIndex, + '0x100', + ), + response: { result: mockResult }, + }); + + const result = await withNetworkClient( + { providerType }, + ({ makeRpcCall }) => makeRpcCall(request), + ); + + expect(result).toStrictEqual(mockResult); + }); + }); + + it(`does not reuse the result of a previous request if it was "${emptyValue}"`, async () => { + const requests = [ + { method, params: buildMockParams({ blockParamIndex, blockParam }) }, + { method, params: buildMockParams({ blockParamIndex, blockParam }) }, + ]; + const mockResults = [emptyValue, 'some result']; + + await withMockedCommunications({ providerType }, async (comms) => { + // The first time a block-cacheable request is made, the + // block-cache middleware will request the latest block number + // through the block tracker to determine the cache key. Later, + // the block-ref middleware will request the latest block number + // again to resolve the value of "latest", but the block number is + // cached once made, so we only need to mock the request once. + comms.mockNextBlockTrackerRequest({ blockNumber: '0x100' }); + // The block-ref middleware will make the request as specified + // except that the block param is replaced with the latest block + // number. + comms.mockRpcCall({ + request: buildRequestWithReplacedBlockParam( + requests[0], + blockParamIndex, + '0x100', + ), + response: { result: mockResults[0] }, + }); + comms.mockRpcCall({ + request: buildRequestWithReplacedBlockParam( + requests[1], + blockParamIndex, + '0x100', + ), + response: { result: mockResults[1] }, + }); + + const results = await withNetworkClient( + { providerType }, + ({ makeRpcCallsInSeries }) => makeRpcCallsInSeries(requests), + ); + + expect(results).toStrictEqual(mockResults); + }); + }); + } + + it('queues requests while a previous identical call is still pending, then runs the queue when it finishes, reusing the result from the first request', async () => { + const requests = [ + { method, params: buildMockParams({ blockParamIndex, blockParam }) }, + { method, params: buildMockParams({ blockParamIndex, blockParam }) }, + { method, params: buildMockParams({ blockParamIndex, blockParam }) }, + ]; + const mockResults = ['first result', 'second result', 'third result']; + + await withMockedCommunications({ providerType }, async (comms) => { + // The first time a block-cacheable request is made, the + // block-cache middleware will request the latest block number + // through the block tracker to determine the cache key. Later, + // the block-ref middleware will request the latest block number + // again to resolve the value of "latest", but the block number is + // cached once made, so we only need to mock the request once. + comms.mockNextBlockTrackerRequest({ blockNumber: '0x100' }); + // The block-ref middleware will make the request as specified + // except that the block param is replaced with the latest block + // number, and we delay it. + comms.mockRpcCall({ + delay: 100, + request: buildRequestWithReplacedBlockParam( + requests[0], + blockParamIndex, + '0x100', + ), + response: { result: mockResults[0] }, + }); + // The previous two requests will happen again, in the same order. + comms.mockRpcCall({ + request: buildRequestWithReplacedBlockParam( + requests[1], + blockParamIndex, + '0x100', + ), + response: { result: mockResults[1] }, + }); + comms.mockRpcCall({ + request: buildRequestWithReplacedBlockParam( + requests[2], + blockParamIndex, + '0x100', + ), + response: { result: mockResults[2] }, + }); + + const results = await withNetworkClient( + { providerType }, + async (client) => { + const resultPromises = [ + client.makeRpcCall(requests[0]), + client.makeRpcCall(requests[1]), + client.makeRpcCall(requests[2]), + ]; + const firstResult = await resultPromises[0]; + // The inflight cache middleware uses setTimeout to run the + // handlers, so run them now + jest.runAllTimers(); + const remainingResults = await Promise.all(resultPromises.slice(1)); + return [firstResult, ...remainingResults]; + }, + ); + + expect(results).toStrictEqual([ + mockResults[0], + mockResults[0], + mockResults[0], + ]); + }); + }); + + it('does not discard an error in a non-standard JSON-RPC error response, but throws it', async () => { + const request = { + method, + params: buildMockParams({ blockParamIndex, blockParam }), + }; + const error = { + code: -32000, + data: { + foo: 'bar', + }, + message: 'VM Exception while processing transaction: revert', + name: 'RuntimeError', + stack: + 'RuntimeError: VM Exception while processing transaction: revert at exactimate (/Users/elliot/code/metamask/metamask-mobile/node_modules/ganache/dist/node/webpack:/Ganache/ethereum/ethereum/lib/src/helpers/gas-estimator.js:257:23)', + }; + + await withMockedCommunications({ providerType }, async (comms) => { + // The first time a block-cacheable request is made, the + // block-cache middleware will request the latest block number + // through the block tracker to determine the cache key. Later, + // the block-ref middleware will request the latest block number + // again to resolve the value of "latest", but the block number is + // cached once made, so we only need to mock the request once. + comms.mockNextBlockTrackerRequest({ blockNumber: '0x100' }); + comms.mockRpcCall({ + request: buildRequestWithReplacedBlockParam( + request, + blockParamIndex, + '0x100', + ), + response: { + error, + }, + }); + + const promise = withNetworkClient( + { providerType }, + async ({ provider }) => { + return await provider.request(request); + }, + ); + + if (providerType === NetworkClientType.Infura) { + // This is not ideal, but we can refactor this later. + // eslint-disable-next-line jest/no-conditional-expect + await expect(promise).rejects.toThrow( + rpcErrors.internal({ + message: error.message, + data: { cause: error }, + }), + ); + } else { + // This is not ideal, but we can refactor this later. + // eslint-disable-next-line jest/no-conditional-expect + await expect(promise).rejects.toThrow( + rpcErrors.internal({ data: error }), + ); + } + }); + }); + + describe.each([ + [401, CUSTOM_RPC_ERRORS.unauthorized], + [402, errorCodes.rpc.resourceUnavailable], + [404, errorCodes.rpc.resourceUnavailable], + [422, CUSTOM_RPC_ERRORS.httpClientError], + [429, errorCodes.rpc.limitExceeded], + ])( + 'if the RPC endpoint returns a %d response', + (httpStatus, rpcErrorCode) => { + const expectedError = expect.objectContaining({ + code: rpcErrorCode, + }); + + it('throws a custom error without retrying the request', async () => { + await withMockedCommunications({ providerType }, async (comms) => { + const request = { + method, + params: buildMockParams({ blockParam, blockParamIndex }), + }; + + // The first time a block-cacheable request is made, the + // block-cache middleware will request the latest block number + // through the block tracker to determine the cache key. Later, + // the block-ref middleware will request the latest block number + // again to resolve the value of "latest", but the block number is + // cached once made, so we only need to mock the request once. + comms.mockNextBlockTrackerRequest({ blockNumber: '0x100' }); + // The block-ref middleware will make the request as specified + // except that the block param is replaced with the latest block + // number. + comms.mockRpcCall({ + request: buildRequestWithReplacedBlockParam( + request, + blockParamIndex, + '0x100', + ), + response: { + httpStatus, + }, + }); + const promiseForResult = withNetworkClient( + { providerType }, + async ({ makeRpcCall }) => makeRpcCall(request), + ); + + await expect(promiseForResult).rejects.toThrow(expectedError); + }); + }); + + // NOTE: We do not test the RPC failover behavior here because only 5xx + // errors break the circuit and cause a failover. + }, + ); + + describe.each([500, 501, 505, 506, 507, 508, 510, 511])( + 'if the RPC endpoint returns a %d response', + (httpStatus) => { + const expectedError = expect.objectContaining({ + code: errorCodes.rpc.resourceUnavailable, + }); + + it('throws a generic, undescriptive error', async () => { + await withMockedCommunications({ providerType }, async (comms) => { + const request = { + method, + params: buildMockParams({ blockParam, blockParamIndex }), + }; + + // The first time a block-cacheable request is made, the + // block-cache middleware will request the latest block number + // through the block tracker to determine the cache key. Later, + // the block-ref middleware will request the latest block number + // again to resolve the value of "latest", but the block number is + // cached once made, so we only need to mock the request once. + comms.mockNextBlockTrackerRequest({ blockNumber: '0x100' }); + // The block-ref middleware will make the request as specified + // except that the block param is replaced with the latest block + // number. + comms.mockRpcCall({ + request: buildRequestWithReplacedBlockParam( + request, + blockParamIndex, + '0x100', + ), + response: { + httpStatus, + }, + }); + const promiseForResult = withNetworkClient( + { providerType }, + async ({ makeRpcCall }) => makeRpcCall(request), + ); + + await expect(promiseForResult).rejects.toThrow(expectedError); + }); + }); + + testsForRpcFailoverBehavior({ + providerType, + requestToCall: { + method, + params: buildMockParams({ blockParam, blockParamIndex }), + }, + getRequestToMock: (request: MockRequest, blockNumber: Hex) => { + return buildRequestWithReplacedBlockParam( + request, + blockParamIndex, + blockNumber, + ); + }, + failure: { + httpStatus, + }, + isRetriableFailure: false, + getExpectedError: () => expectedError, + getExpectedBreakError: () => + expect.objectContaining({ + message: `Fetch failed with status '${httpStatus}'`, + }), + }); + }, + ); + + describe.each([502, 503, 504])( + 'if the RPC endpoint returns a %d response', + (httpStatus) => { + const expectedError = expect.objectContaining({ + code: errorCodes.rpc.resourceUnavailable, + }); + + it('retries the request up to 4 times until there is a 200 response', async () => { + await withMockedCommunications({ providerType }, async (comms) => { + const request = { + method, + params: buildMockParams({ blockParam, blockParamIndex }), + }; + + // The first time a block-cacheable request is made, the + // block-cache middleware will request the latest block number + // through the block tracker to determine the cache key. Later, + // the block-ref middleware will request the latest block number + // again to resolve the value of "latest", but the block number is + // cached once made, so we only need to mock the request once. + comms.mockNextBlockTrackerRequest({ blockNumber: '0x100' }); + // The block-ref middleware will make the request as specified + // except that the block param is replaced with the latest block + // number. + // + // Here we have the request fail for the first 4 tries, then succeed + // on the 5th try. + comms.mockRpcCall({ + request: buildRequestWithReplacedBlockParam( + request, + blockParamIndex, + '0x100', + ), + response: { + error: 'some error', + httpStatus, + }, + times: 3, + }); + comms.mockRpcCall({ + request: buildRequestWithReplacedBlockParam( + request, + blockParamIndex, + '0x100', + ), + response: { + result: 'the result', + httpStatus: 200, + }, + }); + const result = await withNetworkClient( + { providerType }, + async ({ makeRpcCall }) => { + return await waitForPromiseToBeFulfilledAfterRunningAllTimers( + makeRpcCall(request), + ); + }, + ); + + expect(result).toBe('the result'); + }); + }); + + it(`throws a custom error if the response continues to be ${httpStatus} after 5 retries`, async () => { + await withMockedCommunications({ providerType }, async (comms) => { + const request = { + method, + params: buildMockParams({ blockParam, blockParamIndex }), + }; + + // The first time a block-cacheable request is made, the + // block-cache middleware will request the latest block number + // through the block tracker to determine the cache key. Later, + // the block-ref middleware will request the latest block number + // again to resolve the value of "latest", but the block number is + // cached once made, so we only need to mock the request once. + comms.mockNextBlockTrackerRequest({ blockNumber: '0x100' }); + // The block-ref middleware will make the request as specified + // except that the block param is replaced with the latest block + // number. + comms.mockRpcCall({ + request: buildRequestWithReplacedBlockParam( + request, + blockParamIndex, + '0x100', + ), + response: { + error: 'Some error', + httpStatus, + }, + times: 5, + }); + const promiseForResult = withNetworkClient( + { providerType }, + async ({ makeRpcCall }) => { + return await waitForPromiseToBeFulfilledAfterRunningAllTimers( + makeRpcCall(request), + ); + }, + ); + await expect(promiseForResult).rejects.toThrow(expectedError); + }); + }); + + testsForRpcFailoverBehavior({ + providerType, + requestToCall: { + method, + params: buildMockParams({ blockParam, blockParamIndex }), + }, + getRequestToMock: (request: MockRequest, blockNumber: Hex) => { + return buildRequestWithReplacedBlockParam( + request, + blockParamIndex, + blockNumber, + ); + }, + failure: { + httpStatus, + }, + isRetriableFailure: true, + getExpectedError: () => expectedError, + getExpectedBreakError: () => + expect.objectContaining({ + message: expect.stringContaining( + `Fetch failed with status '${httpStatus}'`, + ), + }), + }); + }, + ); + + describe.each(['ETIMEDOUT', 'ECONNRESET'])( + 'if a %s error is thrown while making the request', + (errorCode) => { + const error = new Error(errorCode); + // @ts-expect-error `code` does not exist on the Error type, but is + // still used by Node. + error.code = errorCode; + + it('retries the request up to 4 times until it is successful', async () => { + await withMockedCommunications({ providerType }, async (comms) => { + const request = { + method, + params: buildMockParams({ blockParam, blockParamIndex }), + }; + + // The first time a block-cacheable request is made, the + // block-cache middleware will request the latest block number + // through the block tracker to determine the cache key. Later, + // the block-ref middleware will request the latest block number + // again to resolve the value of "latest", but the block number is + // cached once made, so we only need to mock the request once. + comms.mockNextBlockTrackerRequest({ blockNumber: '0x100' }); + // The block-ref middleware will make the request as specified + // except that the block param is replaced with the latest block + // number. + // + // Here we have the request fail for the first 4 tries, then + // succeed on the 5th try. + comms.mockRpcCall({ + request: buildRequestWithReplacedBlockParam( + request, + blockParamIndex, + '0x100', + ), + error, + times: 3, + }); + comms.mockRpcCall({ + request: buildRequestWithReplacedBlockParam( + request, + blockParamIndex, + '0x100', + ), + response: { + result: 'the result', + httpStatus: 200, + }, + }); + + const result = await withNetworkClient( + { providerType }, + async ({ makeRpcCall }) => { + return await waitForPromiseToBeFulfilledAfterRunningAllTimers( + makeRpcCall(request), + ); + }, + ); + + expect(result).toBe('the result'); + }); + }); + + it('re-throws the error if it persists after 5 retries', async () => { + await withMockedCommunications({ providerType }, async (comms) => { + const request = { + method, + params: buildMockParams({ blockParam, blockParamIndex }), + }; + + // The first time a block-cacheable request is made, the + // block-cache middleware will request the latest block number + // through the block tracker to determine the cache key. Later, + // the block-ref middleware will request the latest block number + // again to resolve the value of "latest", but the block number is + // cached once made, so we only need to mock the request once. + comms.mockNextBlockTrackerRequest({ blockNumber: '0x100' }); + // The block-ref middleware will make the request as specified + // except that the block param is replaced with the latest block + // number. + comms.mockRpcCall({ + request: buildRequestWithReplacedBlockParam( + request, + blockParamIndex, + '0x100', + ), + error, + times: 5, + }); + + const promiseForResult = withNetworkClient( + { providerType }, + async ({ makeRpcCall }) => { + return await waitForPromiseToBeFulfilledAfterRunningAllTimers( + makeRpcCall(request), + ); + }, + ); + + await expect(promiseForResult).rejects.toThrow(error.message); + }); + }); + + testsForRpcFailoverBehavior({ + providerType, + requestToCall: { + method, + params: buildMockParams({ blockParam, blockParamIndex }), + }, + getRequestToMock: (request: MockRequest, blockNumber: Hex) => { + return buildRequestWithReplacedBlockParam( + request, + blockParamIndex, + blockNumber, + ); + }, + failure: error, + isRetriableFailure: true, + getExpectedError: (url: string) => + expect.objectContaining({ + message: `request to ${url} failed, reason: ${errorCode}`, + }), + }); + }, + ); + + describe('if the RPC endpoint responds with invalid JSON', () => { + const expectedError = expect.objectContaining({ + code: errorCodes.rpc.parse, + }); + + it('retries the request up to 4 times until it responds with valid JSON', async () => { + await withMockedCommunications({ providerType }, async (comms) => { + const request = { + method, + params: buildMockParams({ blockParam, blockParamIndex }), + }; + + // The first time a block-cacheable request is made, the + // block-cache middleware will request the latest block number + // through the block tracker to determine the cache key. Later, + // the block-ref middleware will request the latest block number + // again to resolve the value of "latest", but the block number is + // cached once made, so we only need to mock the request once. + comms.mockNextBlockTrackerRequest({ blockNumber: '0x100' }); + // The block-ref middleware will make the request as specified + // except that the block param is replaced with the latest block + // number. + // + // Here we have the request fail for the first 4 tries, then + // succeed on the 5th try. + comms.mockRpcCall({ + request: buildRequestWithReplacedBlockParam( + request, + blockParamIndex, + '0x100', + ), + response: { + body: 'invalid JSON', + }, + times: 3, + }); + comms.mockRpcCall({ + request: buildRequestWithReplacedBlockParam( + request, + blockParamIndex, + '0x100', + ), + response: { + result: 'the result', + httpStatus: 200, + }, + }); + const result = await withNetworkClient( + { providerType }, + async ({ makeRpcCall }) => { + return await waitForPromiseToBeFulfilledAfterRunningAllTimers( + makeRpcCall(request), + ); + }, + ); + + expect(result).toBe('the result'); + }); + }); + + it('throws a custom error if the result is still non-JSON-parseable after 5 retries', async () => { + await withMockedCommunications({ providerType }, async (comms) => { + const request = { + method, + params: buildMockParams({ blockParam, blockParamIndex }), + }; + + // The first time a block-cacheable request is made, the + // block-cache middleware will request the latest block number + // through the block tracker to determine the cache key. Later, + // the block-ref middleware will request the latest block number + // again to resolve the value of "latest", but the block number is + // cached once made, so we only need to mock the request once. + comms.mockNextBlockTrackerRequest({ blockNumber: '0x100' }); + // The block-ref middleware will make the request as specified + // except that the block param is replaced with the latest block + // number. + comms.mockRpcCall({ + request: buildRequestWithReplacedBlockParam( + request, + blockParamIndex, + '0x100', + ), + response: { + body: 'invalid JSON', + }, + times: 5, + }); + + const promiseForResult = withNetworkClient( + { providerType }, + async ({ makeRpcCall }) => { + return await waitForPromiseToBeFulfilledAfterRunningAllTimers( + makeRpcCall(request), + ); + }, + ); + + await expect(promiseForResult).rejects.toThrow(expectedError); + }); + }); + + testsForRpcFailoverBehavior({ + providerType, + requestToCall: { + method, + params: buildMockParams({ blockParam, blockParamIndex }), + }, + getRequestToMock: (request: MockRequest, blockNumber: Hex) => { + return buildRequestWithReplacedBlockParam( + request, + blockParamIndex, + blockNumber, + ); + }, + failure: { + body: 'invalid JSON', + }, + isRetriableFailure: true, + getExpectedError: () => expectedError, + getExpectedBreakError: () => + expect.objectContaining({ + message: expect.stringContaining('invalid json'), + }), + }); + }); + + describe('if making the request throws a connection error', () => { + const error = new TypeError('Failed to fetch'); + + it('retries the request up to 4 times until there is no connection error', async () => { + await withMockedCommunications({ providerType }, async (comms) => { + const request = { + method, + params: buildMockParams({ blockParam, blockParamIndex }), + }; + + // The first time a block-cacheable request is made, the + // block-cache middleware will request the latest block number + // through the block tracker to determine the cache key. Later, + // the block-ref middleware will request the latest block number + // again to resolve the value of "latest", but the block number is + // cached once made, so we only need to mock the request once. + comms.mockNextBlockTrackerRequest({ blockNumber: '0x100' }); + // The block-ref middleware will make the request as specified + // except that the block param is replaced with the latest block + // number. + // + // Here we have the request fail for the first 4 tries, then + // succeed on the 5th try. + comms.mockRpcCall({ + request: buildRequestWithReplacedBlockParam( + request, + blockParamIndex, + '0x100', + ), + error, + times: 3, + }); + comms.mockRpcCall({ + request: buildRequestWithReplacedBlockParam( + request, + blockParamIndex, + '0x100', + ), + response: { + result: 'the result', + httpStatus: 200, + }, + }); + + const result = await withNetworkClient( + { providerType }, + async ({ makeRpcCall }) => { + return await waitForPromiseToBeFulfilledAfterRunningAllTimers( + makeRpcCall(request), + ); + }, + ); + + expect(result).toBe('the result'); + }); + }); + + it('re-throws the error if it persists after 5 retries', async () => { + await withMockedCommunications({ providerType }, async (comms) => { + const request = { + method, + params: buildMockParams({ blockParam, blockParamIndex }), + }; + + // The first time a block-cacheable request is made, the + // block-cache middleware will request the latest block number + // through the block tracker to determine the cache key. Later, + // the block-ref middleware will request the latest block number + // again to resolve the value of "latest", but the block number is + // cached once made, so we only need to mock the request once. + comms.mockNextBlockTrackerRequest({ blockNumber: '0x100' }); + // The block-ref middleware will make the request as specified + // except that the block param is replaced with the latest block + // number. + comms.mockRpcCall({ + request: buildRequestWithReplacedBlockParam( + request, + blockParamIndex, + '0x100', + ), + error, + times: 5, + }); + const promiseForResult = withNetworkClient( + { providerType }, + async ({ makeRpcCall }) => { + return await waitForPromiseToBeFulfilledAfterRunningAllTimers( + makeRpcCall(request), + ); + }, + ); + + await expect(promiseForResult).rejects.toThrow(error.message); + }); + }); + + testsForRpcFailoverBehavior({ + providerType, + requestToCall: { + method, + params: buildMockParams({ blockParam, blockParamIndex }), + }, + getRequestToMock: (request: MockRequest, blockNumber: Hex) => { + return buildRequestWithReplacedBlockParam( + request, + blockParamIndex, + blockNumber, + ); + }, + failure: error, + isRetriableFailure: true, + getExpectedError: (url: string) => + expect.objectContaining({ + message: `request to ${url} failed, reason: ${error.message}`, + }), + }); + }); + }); + + describe.each([ + ['given a block tag of "earliest"', 'earliest', 'earliest'], + ['given a block number', 'block number', '0x100'], + ])('%s', (_desc, blockParamType, blockParam) => { + it('does not hit the RPC endpoint more than once for identical requests', async () => { + const requests = [ + { + method, + params: buildMockParams({ blockParamIndex, blockParam }), + }, + { + method, + params: buildMockParams({ blockParamIndex, blockParam }), + }, + ]; + const mockResults = ['first result', 'second result']; + + await withMockedCommunications({ providerType }, async (comms) => { + // The first time a block-cacheable request is made, the block-cache + // middleware will request the latest block number through the block + // tracker to determine the cache key. This block number doesn't + // matter. + comms.mockNextBlockTrackerRequest(); + comms.mockRpcCall({ + request: requests[0], + response: { result: mockResults[0] }, + }); + + const results = await withNetworkClient( + { providerType }, + ({ makeRpcCallsInSeries }) => makeRpcCallsInSeries(requests), + ); + + expect(results).toStrictEqual([mockResults[0], mockResults[0]]); + }); + }); + + for (const paramIndex of [...Array(numberOfParameters).keys()]) { + if (paramIndex === blockParamIndex) { + // testing changes in block param is covered under later tests + continue; + } + + it(`does not reuse the result of a previous request if parameter at index "${paramIndex}" differs`, async () => { + const firstMockParams = [ + ...new Array(numberOfParameters).fill('some value'), + ]; + firstMockParams[blockParamIndex] = blockParam; + const secondMockParams = firstMockParams.slice(); + secondMockParams[paramIndex] = 'another value'; + const requests = [ + { + method, + params: firstMockParams, + }, + { method, params: secondMockParams }, + ]; + const mockResults = ['first result', 'second result']; + + await withMockedCommunications({ providerType }, async (comms) => { + // The first time a block-cacheable request is made, the block-cache + // middleware will request the latest block number through the block + // tracker to determine the cache key. Later, the block-ref + // middleware will request the latest block number again to resolve + // the value of "latest", but the block number is cached once made, + // so we only need to mock the request once. + comms.mockNextBlockTrackerRequest({ blockNumber: '0x100' }); + // The block-ref middleware will make the request as specified + // except that the block param is replaced with the latest block + // number. + comms.mockRpcCall({ + request: requests[0], + response: { result: mockResults[0] }, + }); + comms.mockRpcCall({ + request: requests[1], + response: { result: mockResults[1] }, + }); + + const results = await withNetworkClient( + { providerType }, + ({ makeRpcCallsInSeries }) => makeRpcCallsInSeries(requests), + ); + + expect(results).toStrictEqual([mockResults[0], mockResults[1]]); + }); + }); + } + + it('reuses the result of a previous request even if the latest block number was updated since', async () => { + const requests = [ + { + method, + params: buildMockParams({ blockParamIndex, blockParam }), + }, + { + method, + params: buildMockParams({ blockParamIndex, blockParam }), + }, + ]; + const mockResults = ['first result', 'second result']; + + await withMockedCommunications({ providerType }, async (comms) => { + // Note that we have to mock these requests in a specific order. The + // first block tracker request occurs because of the first RPC + // request. The second block tracker request, however, does not + // occur because of the second RPC request, but rather because we + // call `jest.runAllTimers()` below. + comms.mockNextBlockTrackerRequest({ blockNumber: '0x1' }); + comms.mockRpcCall({ + request: requests[0], + response: { result: mockResults[0] }, + }); + comms.mockNextBlockTrackerRequest({ blockNumber: '0x2' }); + comms.mockRpcCall({ + request: requests[1], + response: { result: mockResults[1] }, + }); + + const results = await withNetworkClient( + { providerType }, + async (client) => { + const firstResult = await client.makeRpcCall(requests[0]); + // Proceed to the next iteration of the block tracker so that a + // new block is fetched and the current block is updated. + jest.runAllTimers(); + const secondResult = await client.makeRpcCall(requests[1]); + return [firstResult, secondResult]; + }, + ); + + expect(results).toStrictEqual([mockResults[0], mockResults[0]]); + }); + }); + + if (blockParamType === 'earliest') { + it('treats "0x00" as a synonym for "earliest"', async () => { + const requests = [ + { + method, + params: buildMockParams({ blockParamIndex, blockParam }), + }, + { + method, + params: buildMockParams({ blockParamIndex, blockParam: '0x00' }), + }, + ]; + const mockResults = ['first result', 'second result']; + + await withMockedCommunications({ providerType }, async (comms) => { + // The first time a block-cacheable request is made, the latest + // block number is retrieved through the block tracker first. It + // doesn't matter what this is — it's just used as a cache key. + comms.mockNextBlockTrackerRequest(); + comms.mockRpcCall({ + request: requests[0], + response: { result: mockResults[0] }, + }); + + const results = await withNetworkClient( + { providerType }, + ({ makeRpcCallsInSeries }) => makeRpcCallsInSeries(requests), + ); + + expect(results).toStrictEqual([mockResults[0], mockResults[0]]); + }); + }); + + for (const emptyValue of [null, '\u003cnil\u003e']) { + it(`does not retry an empty response of "${emptyValue}"`, async () => { + const request = { + method, + params: buildMockParams({ blockParamIndex, blockParam }), + }; + const mockResult = emptyValue; + + await withMockedCommunications({ providerType }, async (comms) => { + // The first time a block-cacheable request is made, the latest block + // number is retrieved through the block tracker first. It doesn't + // matter what this is — it's just used as a cache key. + comms.mockNextBlockTrackerRequest(); + comms.mockRpcCall({ + request, + response: { result: mockResult }, + }); + + const result = await withNetworkClient( + { providerType }, + ({ makeRpcCall }) => makeRpcCall(request), + ); + + expect(result).toStrictEqual(mockResult); + }); + }); + + it(`does not reuse the result of a previous request if it was "${emptyValue}"`, async () => { + const requests = [ + { + method, + params: buildMockParams({ blockParamIndex, blockParam }), + }, + { + method, + params: buildMockParams({ blockParamIndex, blockParam }), + }, + ]; + const mockResults = [emptyValue, 'some result']; + + await withMockedCommunications({ providerType }, async (comms) => { + // The first time a block-cacheable request is made, the latest block + // number is retrieved through the block tracker first. It doesn't + // matter what this is — it's just used as a cache key. + comms.mockNextBlockTrackerRequest(); + comms.mockRpcCall({ + request: requests[0], + response: { result: mockResults[0] }, + }); + comms.mockRpcCall({ + request: requests[1], + response: { result: mockResults[1] }, + }); + + const results = await withNetworkClient( + { providerType }, + ({ makeRpcCallsInSeries }) => makeRpcCallsInSeries(requests), + ); + + expect(results).toStrictEqual(mockResults); + }); + }); + } + } + + if (blockParamType === 'block number') { + it('does not reuse the result of a previous request if it was made with different arguments than this one', async () => { + await withMockedCommunications({ providerType }, async (comms) => { + const requests = [ + { + method, + params: buildMockParams({ blockParamIndex, blockParam: '0x100' }), + }, + { + method, + params: buildMockParams({ blockParamIndex, blockParam: '0x200' }), + }, + ]; + + // The first time a block-cacheable request is made, the latest block + // number is retrieved through the block tracker first. It doesn't + // matter what this is — it's just used as a cache key. + comms.mockNextBlockTrackerRequest(); + comms.mockRpcCall({ + request: requests[0], + response: { result: 'first result' }, + }); + comms.mockRpcCall({ + request: requests[1], + response: { result: 'second result' }, + }); + + const results = await withNetworkClient( + { providerType }, + ({ makeRpcCallsInSeries }) => makeRpcCallsInSeries(requests), + ); + + expect(results).toStrictEqual(['first result', 'second result']); + }); + }); + + for (const [nestedDesc, currentBlockNumber] of [ + ['less than the current block number', '0x200'], + ['equal to the curent block number', '0x100'], + ]) { + describe(`${nestedDesc}`, () => { + it('makes an additional request to the RPC endpoint', async () => { + await withMockedCommunications({ providerType }, async (comms) => { + const request = { + method, + // Note that `blockParam` is `0x100` here + params: buildMockParams({ blockParamIndex, blockParam }), + }; + + // The first time a block-cacheable request is made, the latest + // block number is retrieved through the block tracker first. + comms.mockNextBlockTrackerRequest({ + blockNumber: currentBlockNumber, + }); + comms.mockRpcCall({ + request, + response: { result: 'the result' }, + }); + + const result = await withNetworkClient( + { providerType }, + ({ makeRpcCall }) => makeRpcCall(request), + ); + + expect(result).toBe('the result'); + }); + }); + + for (const emptyValue of [null, '\u003cnil\u003e']) { + if (providerType === 'infura') { + it(`retries up to 10 times if a "${emptyValue}" response is returned, returning successful non-empty response if there is one on the 10th try`, async () => { + const request = { + method, + // Note that `blockParam` is `0x100` here + params: buildMockParams({ blockParamIndex, blockParam }), + }; + + await withMockedCommunications( + { providerType }, + async (comms) => { + // The first time a block-cacheable request is made, the latest block + // number is retrieved through the block tracker first. + comms.mockNextBlockTrackerRequest({ + blockNumber: currentBlockNumber, + }); + comms.mockRpcCall({ + request, + response: { result: emptyValue }, + times: 9, + }); + comms.mockRpcCall({ + request, + response: { result: 'some value' }, + }); + + const result = await withNetworkClient( + { providerType }, + ({ makeRpcCall }) => + waitForPromiseToBeFulfilledAfterRunningAllTimers( + makeRpcCall(request), + ), + ); + + expect(result).toBe('some value'); + }, + ); + }); + + it(`retries up to 10 times if a "${emptyValue}" response is returned, failing after the 10th try`, async () => { + const request = { + method, + // Note that `blockParam` is `0x100` here + params: buildMockParams({ blockParamIndex, blockParam }), + }; + const mockResult = emptyValue; + + await withMockedCommunications( + { providerType }, + async (comms) => { + // The first time a block-cacheable request is made, the latest block + // number is retrieved through the block tracker first. + comms.mockNextBlockTrackerRequest({ + blockNumber: currentBlockNumber, + }); + comms.mockRpcCall({ + request, + response: { result: mockResult }, + times: 10, + }); + + const promiseForResult = withNetworkClient( + { providerType }, + ({ makeRpcCall }) => + waitForPromiseToBeFulfilledAfterRunningAllTimers( + makeRpcCall(request), + ), + ); + + await expect(promiseForResult).rejects.toThrow( + 'RetryOnEmptyMiddleware - retries exhausted', + ); + }, + ); + }); + } else { + it(`does not retry an empty response of "${emptyValue}"`, async () => { + const request = { + method, + // Note that `blockParam` is `0x100` here + params: buildMockParams({ blockParamIndex, blockParam }), + }; + const mockResult = emptyValue; + + await withMockedCommunications( + { providerType }, + async (comms) => { + // The first time a block-cacheable request is made, the latest block + // number is retrieved through the block tracker first. + comms.mockNextBlockTrackerRequest({ + blockNumber: currentBlockNumber, + }); + comms.mockRpcCall({ + request: buildRequestWithReplacedBlockParam( + request, + blockParamIndex, + '0x100', + ), + response: { result: mockResult }, + }); + + const result = await withNetworkClient( + { providerType }, + ({ makeRpcCall }) => makeRpcCall(request), + ); + + expect(result).toStrictEqual(mockResult); + }, + ); + }); + + it(`does not reuse the result of a previous request if it was "${emptyValue}"`, async () => { + const requests = [ + { + method, + // Note that `blockParam` is `0x100` here + params: buildMockParams({ blockParamIndex, blockParam }), + }, + { + method, + // Note that `blockParam` is `0x100` here + params: buildMockParams({ blockParamIndex, blockParam }), + }, + ]; + const mockResults = [emptyValue, { blockHash: '0x100' }]; + + await withMockedCommunications( + { providerType }, + async (comms) => { + // The first time a block-cacheable request is made, the latest block + // number is retrieved through the block tracker first. + comms.mockNextBlockTrackerRequest({ + blockNumber: currentBlockNumber, + }); + comms.mockRpcCall({ + request: buildRequestWithReplacedBlockParam( + requests[0], + blockParamIndex, + '0x100', + ), + response: { result: mockResults[0] }, + }); + comms.mockRpcCall({ + request: buildRequestWithReplacedBlockParam( + requests[1], + blockParamIndex, + '0x100', + ), + response: { result: mockResults[1] }, + }); + + const results = await withNetworkClient( + { providerType }, + ({ makeRpcCallsInSeries }) => + makeRpcCallsInSeries(requests), + ); + + expect(results).toStrictEqual(mockResults); + }, + ); + }); + } + } + }); + } + + describe('greater than the current block number', () => { + it('makes an additional request to the RPC endpoint', async () => { + await withMockedCommunications({ providerType }, async (comms) => { + const request = { + method, + // Note that `blockParam` is `0x100` here + params: buildMockParams({ blockParamIndex, blockParam }), + }; + + // The first time a block-cacheable request is made, the latest + // block number is retrieved through the block tracker first. + comms.mockNextBlockTrackerRequest({ blockNumber: '0x42' }); + comms.mockRpcCall({ + request, + response: { result: 'the result' }, + }); + + const result = await withNetworkClient( + { providerType }, + ({ makeRpcCall }) => makeRpcCall(request), + ); + + expect(result).toBe('the result'); + }); + }); + + for (const emptyValue of [null, '\u003cnil\u003e']) { + it(`does not retry an empty response of "${emptyValue}"`, async () => { + const request = { + method, + // Note that `blockParam` is `0x100` here + params: buildMockParams({ blockParamIndex, blockParam }), + }; + const mockResult = emptyValue; + + await withMockedCommunications({ providerType }, async (comms) => { + // The first time a block-cacheable request is made, the latest block + // number is retrieved through the block tracker first. + comms.mockNextBlockTrackerRequest({ blockNumber: '0x42' }); + comms.mockRpcCall({ + request: buildRequestWithReplacedBlockParam( + request, + blockParamIndex, + '0x100', + ), + response: { result: mockResult }, + }); + + const result = await withNetworkClient( + { providerType }, + ({ makeRpcCall }) => makeRpcCall(request), + ); + + expect(result).toStrictEqual(mockResult); + }); + }); + + it(`does not reuse the result of a previous request if it was "${emptyValue}"`, async () => { + const requests = [ + { + method, + // Note that `blockParam` is `0x100` here + params: buildMockParams({ blockParamIndex, blockParam }), + }, + { + method, + // Note that `blockParam` is `0x100` here + params: buildMockParams({ blockParamIndex, blockParam }), + }, + ]; + const mockResults = [emptyValue, { blockHash: '0x100' }]; + + await withMockedCommunications({ providerType }, async (comms) => { + // The first time a block-cacheable request is made, the latest block + // number is retrieved through the block tracker first. + comms.mockNextBlockTrackerRequest({ blockNumber: '0x42' }); + comms.mockRpcCall({ + request: buildRequestWithReplacedBlockParam( + requests[0], + blockParamIndex, + '0x100', + ), + response: { result: mockResults[0] }, + }); + comms.mockRpcCall({ + request: buildRequestWithReplacedBlockParam( + requests[1], + blockParamIndex, + '0x100', + ), + response: { result: mockResults[1] }, + }); + + const results = await withNetworkClient( + { providerType }, + ({ makeRpcCallsInSeries }) => makeRpcCallsInSeries(requests), + ); + + expect(results).toStrictEqual(mockResults); + }); + }); + } + }); + } + }); + + describe('given a block tag of "pending"', () => { + const params = buildMockParams({ blockParamIndex, blockParam: 'pending' }); + + it('hits the RPC endpoint on all calls and does not cache anything', async () => { + const requests = [ + { method, params }, + { method, params }, + ]; + const mockResults = ['first result', 'second result']; + + await withMockedCommunications({ providerType }, async (comms) => { + // The first time a block-cacheable request is made, the latest + // block number is retrieved through the block tracker first. It + // doesn't matter what this is — it's just used as a cache key. + comms.mockNextBlockTrackerRequest(); + comms.mockRpcCall({ + request: requests[0], + response: { result: mockResults[0] }, + }); + comms.mockRpcCall({ + request: requests[1], + response: { result: mockResults[1] }, + }); + + const results = await withNetworkClient( + { providerType }, + ({ makeRpcCallsInSeries }) => makeRpcCallsInSeries(requests), + ); + + expect(results).toStrictEqual(mockResults); + }); + }); + }); +} diff --git a/packages/network-controller/tests/network-client/helpers.ts b/packages/network-controller/tests/network-client/helpers.ts new file mode 100644 index 00000000000..850026dc21c --- /dev/null +++ b/packages/network-controller/tests/network-client/helpers.ts @@ -0,0 +1,645 @@ +import type { JSONRPCResponse } from '@json-rpc-specification/meta-schema'; +import type { InfuraNetworkType } from '@metamask/controller-utils'; +import { BUILT_IN_NETWORKS } from '@metamask/controller-utils'; +import type { + BlockTracker, + PollingBlockTrackerOptions, +} from '@metamask/eth-block-tracker'; +import EthQuery from '@metamask/eth-query'; +import type { Hex, Json, JsonRpcRequest } from '@metamask/utils'; +import nock, { isDone as nockIsDone } from 'nock'; +import type { Scope as NockScope } from 'nock'; + +import { createNetworkClient } from '../../src/create-network-client.js'; +import type { + NetworkClientId, + NetworkControllerOptions, +} from '../../src/NetworkController.js'; +import type { RpcServiceOptions } from '../../src/rpc-service/rpc-service.js'; +import type { RpcFailoverMode } from '../../src/selectors.js'; +import type { NetworkClientConfiguration, Provider } from '../../src/types.js'; +import { NetworkClientType } from '../../src/types.js'; +import type { RootMessenger } from '../helpers.js'; +import { + buildNetworkControllerMessenger, + buildRootMessenger, +} from '../helpers.js'; + +/** + * A dummy value for the `infuraProjectId` option that `createInfuraClient` + * needs. (Infura should not be hit during tests, but just in case, this should + * not refer to a real project ID.) + */ +const MOCK_INFURA_PROJECT_ID = 'abc123'; + +/** + * A dummy value for the `rpcUrl` option that `createJsonRpcClient` needs. (This + * should not be hit during tests, but just in case, this should also not refer + * to a real Infura URL.) + */ +const MOCK_RPC_URL = 'http://foo.com/'; + +/** + * A default value for the `eth_blockNumber` request that the block tracker + * makes. + */ +const DEFAULT_LATEST_BLOCK_NUMBER = '0x42'; + +/** + * If you're having trouble writing a test and you're wondering why the test + * keeps failing, you can set `process.env.DEBUG_PROVIDER_TESTS` to `1`. This + * will turn on some extra logging. + * + * @param args - The arguments that `console.log` takes. + */ +function debug(...args: unknown[]): void { + /* eslint-disable-next-line n/no-process-env */ + if (process.env.DEBUG_PROVIDER_TESTS === '1') { + console.log(...args); + } +} + +/** + * Builds a Nock scope object for mocking provider requests. + * + * @param rpcUrl - The URL of the RPC endpoint. + * @param headers - Headers with which to mock the request. + * @returns The nock scope. + */ +function buildScopeForMockingRequests( + rpcUrl: string, + headers: Record, +): NockScope { + return nock(rpcUrl, { reqheaders: headers }).filteringRequestBody((body) => { + debug('Nock Received Request: ', body); + return body; + }); +} + +// TODO: Replace `any` with type +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export type MockRequest = { method: string; params?: any[] }; +type Response = { + id?: number | string; + jsonrpc?: '2.0'; + // TODO: Replace `any` with type + // eslint-disable-next-line @typescript-eslint/no-explicit-any + error?: any; + // TODO: Replace `any` with type + // eslint-disable-next-line @typescript-eslint/no-explicit-any + result?: any; + httpStatus?: number; +}; +export type MockResponse = + | { body: JSONRPCResponse | string } + | Response + | (() => Response | Promise); +type CurriedMockRpcCallOptions = { + request: MockRequest; + // The response data. + response?: MockResponse; + /** + * An error to throw while making the request. + * Takes precedence over `response`. + */ + error?: Error | string; + /** + * The amount of time that should pass before the + * request resolves with the response. + */ + delay?: number; + /** + * The number of times that the request is + * expected to be made. + */ + times?: number; +}; + +type MockRpcCallOptions = { + // A nock scope (a set of mocked requests scoped to a certain base URL). + nockScope: nock.Scope; +} & CurriedMockRpcCallOptions; + +type MockRpcCallResult = nock.Interceptor | nock.Scope; + +/** + * Mocks a JSON-RPC request sent to the provider with the given response. + * Provider type is inferred from the base url set on the nockScope. + * + * @param args - The arguments. + * @param args.nockScope - A nock scope (a set of mocked requests scoped to a + * certain base URL). + * @param args.request - The request data. + * @param args.response - Information concerning the response that the request + * should have. If a `body` property is present, this is taken as the complete + * response body. If an `httpStatus` property is present, then it is taken as + * the HTTP status code to respond with. Properties other than these two are + * used to build a complete response body (including `id` and `jsonrpc` + * properties). + * @param args.error - An error to throw while making the request. Takes + * precedence over `response`. + * @param args.delay - The amount of time that should pass before the request + * resolves with the response. + * @param args.times - The number of times that the request is expected to be + * made. + * @returns The nock scope. + */ +function mockRpcCall({ + nockScope, + request, + response, + error, + delay, + times, +}: MockRpcCallOptions): MockRpcCallResult { + // eth-query always passes `params`, so even if we don't supply this property, + // for consistency with makeRpcCall, assume that the `body` contains it + const { method, params = [], ...rest } = request; + const httpStatus = + (typeof response === 'object' && + 'httpStatus' in response && + // Using nullish coalescing here breaks the tests. + // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing + response.httpStatus) || + 200; + + /* @ts-expect-error The types for Nock do not include `basePath` in the interface for Nock.Scope. */ + const url = nockScope.basePath.includes('infura.io') + ? `/v3/${MOCK_INFURA_PROJECT_ID}` + : '/'; + + debug('Mocking request:', { + url, + method, + params, + response, + error, + ...rest, + times, + }); + + let nockRequest = nockScope.post(url, { + id: /\d*/u, + jsonrpc: '2.0', + method, + params, + ...rest, + }); + + if (delay !== undefined) { + nockRequest = nockRequest.delay(delay); + } + + if (times !== undefined) { + nockRequest = nockRequest.times(times); + } + + if (error !== undefined) { + return nockRequest.replyWithError(error); + } + + return nockRequest.reply(async (_uri, requestBody) => { + const jsonRpcRequest = requestBody as JsonRpcRequest; + let resolvedResponse: Response | string | JSONRPCResponse | undefined; + if (typeof response === 'function') { + resolvedResponse = await response(); + } else if (response !== undefined && 'body' in response) { + resolvedResponse = response.body; + } else { + resolvedResponse = response; + } + + if ( + typeof resolvedResponse === 'string' || + resolvedResponse === undefined + ) { + return [httpStatus, resolvedResponse]; + } + + const { + id: jsonRpcId = jsonRpcRequest.id, + jsonrpc: jsonRpcVersion = jsonRpcRequest.jsonrpc, + result: jsonRpcResult, + error: jsonRpcError, + } = resolvedResponse; + + const completeResponse = { + id: jsonRpcId, + jsonrpc: jsonRpcVersion, + result: jsonRpcResult, + error: jsonRpcError, + }; + debug('Nock returning Response', completeResponse); + return [httpStatus, completeResponse]; + }); +} + +type MockBlockTrackerRequestOptions = { + /** + * A nock scope (a set of mocked requests scoped to a certain base url). + */ + nockScope: NockScope; + /** + * The block number that the block tracker should report, as a 0x-prefixed hex + * string. + */ + blockNumber: string; +}; + +/** + * Mocks the next request for the latest block that the block tracker will make. + * + * @param args - The arguments. + * @param args.nockScope - A nock scope (a set of mocked requests scoped to a + * certain base URL). + * @param args.blockNumber - The block number that the block tracker should + * report, as a 0x-prefixed hex string. + */ +function mockNextBlockTrackerRequest({ + nockScope, + blockNumber = DEFAULT_LATEST_BLOCK_NUMBER, +}: MockBlockTrackerRequestOptions): void { + mockRpcCall({ + nockScope, + request: { method: 'eth_blockNumber', params: [] }, + response: { result: blockNumber }, + }); +} + +/** + * Mocks all requests for the latest block that the block tracker will make. + * + * @param args - The arguments. + * @param args.nockScope - A nock scope (a set of mocked requests scoped to a + * certain base URL). + * @param args.blockNumber - The block number that the block tracker should + * report, as a 0x-prefixed hex string. + */ +async function mockAllBlockTrackerRequests({ + nockScope, + blockNumber = DEFAULT_LATEST_BLOCK_NUMBER, +}: MockBlockTrackerRequestOptions): Promise { + const result = mockRpcCall({ + nockScope, + request: { method: 'eth_blockNumber', params: [] }, + response: { result: blockNumber }, + }); + + if ('persist' in result) { + result.persist(); + } +} + +/** + * Makes a JSON-RPC call through the given eth-query object. + * + * @param ethQuery - The eth-query object. + * @param request - The request data. + * @returns A promise that either resolves with the result from the JSON-RPC + * response if it is successful or rejects with the error from the JSON-RPC + * response otherwise. + */ +function makeRpcCall( + ethQuery: EthQuery, + request: MockRequest, +): Promise { + return new Promise((resolve, reject) => { + debug('[makeRpcCall] making request', request); + // TODO: Replace `any` with type + // eslint-disable-next-line @typescript-eslint/no-explicit-any + ethQuery.sendAsync(request, (error: any, result: any) => { + debug('[makeRpcCall > ethQuery handler] error', error, 'result', result); + if (error) { + // This should be an error, but we will allow it to be whatever it is. + // eslint-disable-next-line @typescript-eslint/prefer-promise-reject-errors + reject(error); + } else { + resolve(result); + } + }); + }); +} + +export type ProviderType = 'infura' | 'custom'; + +export type MockOptions = { + infuraNetwork?: InfuraNetworkType; + failoverRpcUrls?: string[]; + providerType: ProviderType; + customRpcUrl?: string; + customChainId?: Hex; + customTicker?: string; + getRpcServiceOptions?: NetworkControllerOptions['getRpcServiceOptions']; + getBlockTrackerOptions?: NetworkControllerOptions['getBlockTrackerOptions']; + expectedHeaders?: Record; + messenger?: RootMessenger; + networkClientId?: NetworkClientId; + rpcFailoverMode?: RpcFailoverMode; +}; + +export type MockCommunications = { + // TODO: Replace `any` with type + // eslint-disable-next-line @typescript-eslint/no-explicit-any + mockNextBlockTrackerRequest: (options?: any) => void; + // TODO: Replace `any` with type + // eslint-disable-next-line @typescript-eslint/no-explicit-any + mockAllBlockTrackerRequests: (options?: any) => void; + mockRpcCall: (options: CurriedMockRpcCallOptions) => MockRpcCallResult; + rpcUrl: string; + infuraNetwork: InfuraNetworkType; +}; + +/** + * Sets up request mocks for requests to the provider. + * + * @param options - An options bag. + * @param options.providerType - The type of network client being tested. + * @param options.infuraNetwork - The name of the Infura network being tested, + * assuming that `providerType` is "infura" (default: "mainnet"). + * @param options.customRpcUrl - The URL of the custom RPC endpoint, assuming + * that `providerType` is "custom". + * @param options.expectedHeaders - Headers with which to mock the request. + * @param fn - A function which will be called with an object that allows + * interaction with the network client. + * @returns The return value of the given function. + */ +export async function withMockedCommunications( + { + providerType, + infuraNetwork = 'mainnet', + customRpcUrl = MOCK_RPC_URL, + expectedHeaders = {}, + }: MockOptions, + fn: (comms: MockCommunications) => Promise, +): Promise { + const rpcUrl = + providerType === 'infura' + ? `https://${infuraNetwork}.infura.io` + : customRpcUrl; + const nockScope = buildScopeForMockingRequests(rpcUrl, expectedHeaders); + const curriedMockNextBlockTrackerRequest = ( + localOptions: Omit, + ): void => mockNextBlockTrackerRequest({ nockScope, ...localOptions }); + const curriedMockAllBlockTrackerRequests = ( + localOptions: Omit, + ): Promise => + mockAllBlockTrackerRequests({ nockScope, ...localOptions }); + const curriedMockRpcCall = ( + localOptions: Omit, + ): MockRpcCallResult => mockRpcCall({ nockScope, ...localOptions }); + + const comms = { + mockNextBlockTrackerRequest: curriedMockNextBlockTrackerRequest, + mockAllBlockTrackerRequests: curriedMockAllBlockTrackerRequests, + mockRpcCall: curriedMockRpcCall, + rpcUrl, + infuraNetwork, + }; + + try { + return await fn(comms); + } finally { + nockIsDone(); + } +} + +type MockNetworkClient = { + blockTracker: BlockTracker; + provider: Provider; + // TODO: Replace `any` with type + // eslint-disable-next-line @typescript-eslint/no-explicit-any + makeRpcCall: (request: MockRequest) => Promise; + // TODO: Replace `any` with type + // eslint-disable-next-line @typescript-eslint/no-explicit-any + makeRpcCallsInSeries: (requests: MockRequest[]) => Promise; + messenger: RootMessenger; + chainId: Hex; + rpcUrl: string; +}; + +/** + * Some middleware contain logic which retries the request if some condition + * applies. This retrying always happens out of band via `setTimeout`, and + * because we are stubbing time via Jest's fake timers, we have to manually + * advance the clock so that the `setTimeout` handlers get fired. We don't know + * when these timers will get created, however, so we have to keep advancing + * timers until the request has been made an appropriate number of times. + * Unfortunately we don't have a good way to know how many times a request has + * been retried, but the good news is that the middleware won't end, and thus + * the promise which the RPC call returns won't get fulfilled, until all retries + * have been made. + * + * @param promise - The promise which is returned by the RPC call. + * @returns The given promise. + */ +export async function waitForPromiseToBeFulfilledAfterRunningAllTimers( + promise: Promise, +): Promise { + let hasPromiseBeenFulfilled = false; + let numTimesClockHasBeenAdvanced = 0; + + promise + .catch((error: unknown) => { + // This is used to silence Node.js warnings about the rejection + // being handled asynchronously. The error is handled later when + // `promise` is awaited, but we log it here anyway in case it gets + // swallowed. + debug(error); + }) + .finally(() => { + hasPromiseBeenFulfilled = true; + }); + + // `hasPromiseBeenFulfilled` is modified asynchronously. + /* eslint-disable-next-line no-unmodified-loop-condition */ + while (!hasPromiseBeenFulfilled && numTimesClockHasBeenAdvanced < 30) { + await jest.runAllTimersAsync(); + numTimesClockHasBeenAdvanced += 1; + } + + return promise; +} + +/** + * Builds a provider from the middleware (for the provider type) along with a + * block tracker, runs the given function with those two things, and then + * ensures the block tracker is stopped at the end. + * + * @param options - An options bag. + * @param options.providerType - The type of network client being tested. + * @param options.failoverRpcUrls - The list of failover endpoint + * URLs to use. + * @param options.infuraNetwork - The name of the Infura network being tested, + * assuming that `providerType` is "infura" (default: "mainnet"). + * @param options.customRpcUrl - The URL of the custom RPC endpoint, assuming + * that `providerType` is "custom". + * @param options.customChainId - The chain id belonging to the custom RPC + * endpoint, assuming that `providerType` is "custom" (default: "0x1"). + * @param options.customTicker - The ticker of the custom RPC endpoint, assuming + * that `providerType` is "custom" (default: "ETH"). + * @param options.getRpcServiceOptions - RPC service options factory. + * @param options.getBlockTrackerOptions - Block tracker options factory. + * @param options.messenger - The root messenger to use in tests. + * @param options.networkClientId - The ID of the new network client. + * @param options.rpcFailoverMode - The RPC failover mode to apply, defaults to + * `disabled`. + * @param fn - A function which will be called with an object that allows + * interaction with the network client. + * @returns The return value of the given function. + */ +export async function withNetworkClient( + { + providerType, + failoverRpcUrls = [], + infuraNetwork = 'mainnet', + customRpcUrl = MOCK_RPC_URL, + customChainId = '0x1', + customTicker = 'ETH', + getRpcServiceOptions = (): Omit< + RpcServiceOptions, + 'failoverService' | 'endpointUrl' + > => ({ fetch, btoa, isOffline: (): boolean => false }), + getBlockTrackerOptions = (): PollingBlockTrackerOptions => ({}), + messenger = buildRootMessenger(), + networkClientId = 'some-network-client-id', + rpcFailoverMode = 'disabled', + }: MockOptions, + fn: (client: MockNetworkClient) => Promise, +): Promise { + // Faking timers ends up doing two things: + // 1. Halting the block tracker (which depends on `setTimeout` to periodically + // request the latest block) set up in `eth-json-rpc-middleware` + // 2. Halting the retry logic in `@metamask/eth-json-rpc-infura` (which also + // depends on `setTimeout`) + jest.useFakeTimers({ doNotFake: ['nextTick', 'queueMicrotask'] }); + + const networkControllerMessenger = buildNetworkControllerMessenger(messenger); + + // The JSON-RPC client wraps `eth_estimateGas` so that it takes 2 seconds longer + // than it usually would to complete. Or at least it should — this doesn't + // appear to be working correctly. Unset `IN_TEST` on `process.env` to prevent + // this behavior. + /* eslint-disable-next-line n/no-process-env */ + const inTest = process.env.IN_TEST; + /* eslint-disable-next-line n/no-process-env */ + delete process.env.IN_TEST; + const networkClientConfiguration: NetworkClientConfiguration = + providerType === 'infura' + ? { + network: infuraNetwork, + failoverRpcUrls, + infuraProjectId: MOCK_INFURA_PROJECT_ID, + type: NetworkClientType.Infura, + chainId: BUILT_IN_NETWORKS[infuraNetwork].chainId, + ticker: BUILT_IN_NETWORKS[infuraNetwork].ticker, + } + : { + chainId: customChainId, + failoverRpcUrls, + rpcUrl: customRpcUrl, + type: NetworkClientType.Custom, + ticker: customTicker, + }; + + const { chainId } = networkClientConfiguration; + + const rpcUrl = + providerType === 'custom' + ? customRpcUrl + : `https://${infuraNetwork}.infura.io/v3/${MOCK_INFURA_PROJECT_ID}`; + + const networkClient = createNetworkClient({ + id: networkClientId, + configuration: networkClientConfiguration, + getRpcServiceOptions, + getBlockTrackerOptions, + messenger: networkControllerMessenger, + rpcFailoverMode, + }); + /* eslint-disable-next-line n/no-process-env */ + process.env.IN_TEST = inTest; + + const { provider, blockTracker } = networkClient; + + const ethQuery = new EthQuery(provider); + const curriedMakeRpcCall = (request: MockRequest): Promise => + makeRpcCall(ethQuery, request); + const makeRpcCallsInSeries = async ( + requests: MockRequest[], + ): Promise => { + const responses: unknown[] = []; + for (const request of requests) { + responses.push(await curriedMakeRpcCall(request)); + } + return responses; + }; + + const client = { + blockTracker, + provider, + makeRpcCall: curriedMakeRpcCall, + makeRpcCallsInSeries, + messenger, + chainId, + rpcUrl, + }; + + try { + return await fn(client); + } finally { + await blockTracker.destroy(); + + jest.useRealTimers(); + } +} + +type BuildMockParamsOptions = { + blockParam?: Json; + blockParamIndex: number; +}; + +/** + * Build mock parameters for a JSON-RPC call. + * + * The string 'some value' is used as the default value for each entry. The + * block parameter index determines the number of parameters to generate. + * + * The block parameter can be set to a custom value. If no value is given, it + * is set as undefined. + * + * @param args - Arguments. + * @param args.blockParamIndex - The index of the block parameter. + * @param args.blockParam - The block parameter value to set. + * @returns The mock params. + */ +export function buildMockParams({ + blockParam, + blockParamIndex, +}: BuildMockParamsOptions): Json[] { + const params = new Array(blockParamIndex).fill('some value'); + params[blockParamIndex] = blockParam; + + return params; +} + +/** + * Returns a partial JSON-RPC request object, with the "block" param replaced + * with the given value. + * + * @param request - The request object. + * @param request.method - The request method. + * @param request.params - The request params. + * @param blockParamIndex - The index within the `params` array of the block + * param. + * @param blockParam - The desired block param value. + * @returns The updated request object. + */ +export function buildRequestWithReplacedBlockParam( + { method, params = [] }: MockRequest, + blockParamIndex: number, + blockParam: unknown, +): { method: string; params: unknown[] } { + const updatedParams = params.slice(); + updatedParams[blockParamIndex] = blockParam; + return { method, params: updatedParams }; +} diff --git a/packages/network-controller/tests/network-client/no-block-param.ts b/packages/network-controller/tests/network-client/no-block-param.ts new file mode 100644 index 00000000000..14d5efb8fe2 --- /dev/null +++ b/packages/network-controller/tests/network-client/no-block-param.ts @@ -0,0 +1,764 @@ +import { errorCodes, rpcErrors } from '@metamask/rpc-errors'; + +import { CUSTOM_RPC_ERRORS } from '../../src/rpc-service/rpc-service.js'; +import { NetworkClientType } from '../../src/types.js'; +import type { ProviderType } from './helpers.js'; +import { + waitForPromiseToBeFulfilledAfterRunningAllTimers, + withMockedCommunications, + withNetworkClient, +} from './helpers.js'; +import { testsForRpcFailoverBehavior } from './rpc-failover.js'; + +type TestsForRpcMethodAssumingNoBlockParamOptions = { + providerType: ProviderType; + numberOfParameters: number; +}; + +/** + * Defines tests which exercise the behavior exhibited by an RPC method which is + * assumed to not take a block parameter. Even if it does, the value of this + * parameter will not be used in determining how to cache the method. + * + * @param method - The name of the RPC method under test. + * @param additionalArgs - Additional arguments. + * @param additionalArgs.numberOfParameters - The number of parameters + * supported by the method under test. + * @param additionalArgs.providerType - The type of provider being tested; + * either `infura` or `custom`. + */ +export function testsForRpcMethodAssumingNoBlockParam( + method: string, + { + numberOfParameters, + providerType, + }: TestsForRpcMethodAssumingNoBlockParamOptions, +): void { + it('does not hit the RPC endpoint more than once for identical requests', async () => { + const requests = [{ method }, { method }]; + const mockResults = ['first result', 'second result']; + + await withMockedCommunications({ providerType }, async (comms) => { + // The first time a block-cacheable request is made, the latest block + // number is retrieved through the block tracker first. It doesn't + // matter what this is — it's just used as a cache key. + comms.mockNextBlockTrackerRequest(); + comms.mockRpcCall({ + request: requests[0], + response: { result: mockResults[0] }, + }); + + const results = await withNetworkClient( + { providerType }, + ({ makeRpcCallsInSeries }) => makeRpcCallsInSeries(requests), + ); + + expect(results).toStrictEqual([mockResults[0], mockResults[0]]); + }); + }); + + for (const paramIndex of [...Array(numberOfParameters).keys()]) { + it(`does not reuse the result of a previous request if parameter at index "${paramIndex}" differs`, async () => { + const firstMockParams = [ + ...new Array(numberOfParameters).fill('some value'), + ]; + const secondMockParams = firstMockParams.slice(); + secondMockParams[paramIndex] = 'another value'; + const requests = [ + { + method, + params: firstMockParams, + }, + { method, params: secondMockParams }, + ]; + const mockResults = ['some result', 'another result']; + + await withMockedCommunications({ providerType }, async (comms) => { + // The first time a block-cacheable request is made, the latest block + // number is retrieved through the block tracker first. It doesn't + // matter what this is — it's just used as a cache key. + comms.mockNextBlockTrackerRequest(); + comms.mockRpcCall({ + request: requests[0], + response: { result: mockResults[0] }, + }); + comms.mockRpcCall({ + request: requests[1], + response: { result: mockResults[1] }, + }); + + const results = await withNetworkClient( + { providerType }, + ({ makeRpcCallsInSeries }) => makeRpcCallsInSeries(requests), + ); + + expect(results).toStrictEqual([mockResults[0], mockResults[1]]); + }); + }); + } + + it('hits the RPC endpoint and does not reuse the result of a previous request if the latest block number was updated since', async () => { + const pollingInterval = 1234; + const requests = [{ method }, { method }]; + const mockResults = ['first result', 'second result']; + + await withMockedCommunications({ providerType }, async (comms) => { + comms.mockNextBlockTrackerRequest({ blockNumber: '0x1' }); + comms.mockRpcCall({ + request: requests[0], + response: { result: mockResults[0] }, + }); + comms.mockNextBlockTrackerRequest({ blockNumber: '0x2' }); + comms.mockRpcCall({ + request: requests[1], + response: { result: mockResults[1] }, + }); + + const results = await withNetworkClient( + { + providerType, + getBlockTrackerOptions: () => ({ + pollingInterval, + }), + }, + async ({ blockTracker, makeRpcCall }) => { + const waitForTwoBlocks = new Promise((resolve) => { + let numberOfBlocks = 0; + + // Start the block tracker + blockTracker.on('latest', () => { + numberOfBlocks += 1; + if (numberOfBlocks === 2) { + resolve(); + } + }); + }); + + const firstResult = await makeRpcCall(requests[0]); + // Proceed to the next iteration of the block tracker so that a new + // block is fetched and the current block is updated. + await jest.advanceTimersByTimeAsync(pollingInterval); + await waitForTwoBlocks; + const secondResult = await makeRpcCall(requests[1]); + return [firstResult, secondResult]; + }, + ); + + expect(results).toStrictEqual(mockResults); + }); + }); + + for (const emptyValue of [null, '\u003cnil\u003e']) { + it(`does not retry an empty response of "${emptyValue}"`, async () => { + const request = { method }; + const mockResult = emptyValue; + + await withMockedCommunications({ providerType }, async (comms) => { + // The first time a block-cacheable request is made, the latest block + // number is retrieved through the block tracker first. It doesn't + // matter what this is — it's just used as a cache key. + comms.mockNextBlockTrackerRequest(); + comms.mockRpcCall({ + request, + response: { result: mockResult }, + }); + + const result = await withNetworkClient( + { providerType }, + ({ makeRpcCall }) => makeRpcCall(request), + ); + + expect(result).toStrictEqual(mockResult); + }); + }); + + it(`does not reuse the result of a previous request if it was "${emptyValue}"`, async () => { + const requests = [{ method }, { method }]; + const mockResults = [emptyValue, 'some result']; + + await withMockedCommunications({ providerType }, async (comms) => { + // The first time a block-cacheable request is made, the latest block + // number is retrieved through the block tracker first. It doesn't + // matter what this is — it's just used as a cache key. + comms.mockNextBlockTrackerRequest(); + comms.mockRpcCall({ + request: requests[0], + response: { result: mockResults[0] }, + }); + comms.mockRpcCall({ + request: requests[1], + response: { result: mockResults[1] }, + }); + + const results = await withNetworkClient( + { providerType }, + ({ makeRpcCallsInSeries }) => makeRpcCallsInSeries(requests), + ); + + expect(results).toStrictEqual(mockResults); + }); + }); + } + + it('queues requests while a previous identical call is still pending, then runs the queue when it finishes, reusing the result from the first request', async () => { + const requests = [{ method }, { method }, { method }]; + const mockResults = ['first result', 'second result', 'third result']; + + await withMockedCommunications({ providerType }, async (comms) => { + // The first time a block-cacheable request is made, the latest block + // number is retrieved through the block tracker first. It doesn't + // matter what this is — it's just used as a cache key. + comms.mockNextBlockTrackerRequest(); + comms.mockRpcCall({ + request: requests[0], + response: { result: mockResults[0] }, + delay: 100, + }); + + comms.mockRpcCall({ + request: requests[1], + response: { result: mockResults[1] }, + }); + + comms.mockRpcCall({ + request: requests[2], + response: { result: mockResults[2] }, + }); + + const results = await withNetworkClient( + { providerType }, + async (client) => { + const resultPromises = [ + client.makeRpcCall(requests[0]), + client.makeRpcCall(requests[1]), + client.makeRpcCall(requests[2]), + ]; + const firstResult = await resultPromises[0]; + // The inflight cache middleware uses setTimeout to run the handlers, + // so run them now + jest.runAllTimers(); + const remainingResults = await Promise.all(resultPromises.slice(1)); + return [firstResult, ...remainingResults]; + }, + ); + + expect(results).toStrictEqual([ + mockResults[0], + mockResults[0], + mockResults[0], + ]); + }); + }); + + it('does not discard an error in a non-standard JSON-RPC error response, but throws it', async () => { + const request = { method, params: [] }; + const error = { + code: -32000, + data: { + foo: 'bar', + }, + message: 'VM Exception while processing transaction: revert', + name: 'RuntimeError', + stack: + 'RuntimeError: VM Exception while processing transaction: revert at exactimate (/Users/elliot/code/metamask/metamask-mobile/node_modules/ganache/dist/node/webpack:/Ganache/ethereum/ethereum/lib/src/helpers/gas-estimator.js:257:23)', + }; + + await withMockedCommunications({ providerType }, async (comms) => { + // The first time a block-cacheable request is made, the latest block + // number is retrieved through the block tracker first. It doesn't + // matter what this is — it's just used as a cache key. + comms.mockNextBlockTrackerRequest(); + comms.mockRpcCall({ + request, + response: { + error, + }, + }); + + const promise = withNetworkClient( + { providerType }, + async ({ provider }) => { + return await provider.request(request); + }, + ); + + if (providerType === NetworkClientType.Infura) { + // This is not ideal, but we can refactor this later. + // eslint-disable-next-line jest/no-conditional-expect + await expect(promise).rejects.toThrow( + rpcErrors.internal({ + message: error.message, + data: { cause: error }, + }), + ); + } else { + // This is not ideal, but we can refactor this later. + // eslint-disable-next-line jest/no-conditional-expect + await expect(promise).rejects.toThrow( + rpcErrors.internal({ data: error }), + ); + } + }); + }); + + describe.each([ + [401, CUSTOM_RPC_ERRORS.unauthorized], + [402, errorCodes.rpc.resourceUnavailable], + [404, errorCodes.rpc.resourceUnavailable], + [422, CUSTOM_RPC_ERRORS.httpClientError], + [429, errorCodes.rpc.limitExceeded], + ])( + 'if the RPC endpoint returns a %d response', + (httpStatus, rpcErrorCode) => { + const expectedError = expect.objectContaining({ + code: rpcErrorCode, + }); + + it('throws a custom error without retrying the request', async () => { + await withMockedCommunications({ providerType }, async (comms) => { + const request = { method }; + + // The first time a block-cacheable request is made, the latest block + // number is retrieved through the block tracker first. It doesn't + // matter what this is — it's just used as a cache key. + comms.mockNextBlockTrackerRequest(); + comms.mockRpcCall({ + request, + response: { + httpStatus, + }, + }); + const promiseForResult = withNetworkClient( + { providerType }, + async ({ makeRpcCall }) => makeRpcCall(request), + ); + + await expect(promiseForResult).rejects.toThrow(expectedError); + }); + }); + }, + ); + + describe.each([500, 501, 505, 506, 507, 508, 510, 511])( + 'if the RPC endpoint returns a %d response', + (httpStatus) => { + const expectedError = expect.objectContaining({ + code: errorCodes.rpc.resourceUnavailable, + }); + + it('throws a generic, undescriptive error', async () => { + await withMockedCommunications({ providerType }, async (comms) => { + const request = { method }; + + // The first time a block-cacheable request is made, the latest block + // number is retrieved through the block tracker first. It doesn't + // matter what this is — it's just used as a cache key. + comms.mockNextBlockTrackerRequest(); + comms.mockRpcCall({ + request, + response: { + httpStatus, + }, + }); + const promiseForResult = withNetworkClient( + { providerType }, + async ({ makeRpcCall }) => makeRpcCall(request), + ); + + await expect(promiseForResult).rejects.toThrow(expectedError); + }); + }); + + testsForRpcFailoverBehavior({ + providerType, + requestToCall: { + method, + params: [], + }, + getRequestToMock: () => ({ + method, + params: [], + }), + failure: { + httpStatus, + }, + isRetriableFailure: false, + getExpectedError: () => expectedError, + getExpectedBreakError: () => + expect.objectContaining({ + message: `Fetch failed with status '${httpStatus}'`, + }), + }); + }, + ); + + describe.each([502, 503, 504])( + 'if the RPC endpoint returns a %d response', + (httpStatus) => { + const expectedError = expect.objectContaining({ + code: errorCodes.rpc.resourceUnavailable, + }); + + it('retries the request up to 4 times until there is a 200 response', async () => { + await withMockedCommunications({ providerType }, async (comms) => { + const request = { method }; + + // The first time a block-cacheable request is made, the latest block + // number is retrieved through the block tracker first. It doesn't + // matter what this is — it's just used as a cache key. + comms.mockNextBlockTrackerRequest(); + // Here we have the request fail for the first 4 tries, then succeed + // on the 5th try. + comms.mockRpcCall({ + request, + response: { + error: 'Some error', + httpStatus, + }, + times: 3, + }); + comms.mockRpcCall({ + request, + response: { + result: 'the result', + httpStatus: 200, + }, + }); + const result = await withNetworkClient( + { providerType }, + async ({ makeRpcCall }) => { + return await waitForPromiseToBeFulfilledAfterRunningAllTimers( + makeRpcCall(request), + ); + }, + ); + + expect(result).toBe('the result'); + }); + }); + + it(`throws a custom error if the response continues to be ${httpStatus} after 5 retries`, async () => { + await withMockedCommunications({ providerType }, async (comms) => { + const request = { method }; + + // The first time a block-cacheable request is made, the latest block + // number is retrieved through the block tracker first. It doesn't + // matter what this is — it's just used as a cache key. + comms.mockNextBlockTrackerRequest(); + comms.mockRpcCall({ + request, + response: { + error: 'Some error', + httpStatus, + }, + times: 5, + }); + comms.mockNextBlockTrackerRequest(); + const promiseForResult = withNetworkClient( + { providerType }, + async ({ makeRpcCall }) => { + return await waitForPromiseToBeFulfilledAfterRunningAllTimers( + makeRpcCall(request), + ); + }, + ); + await expect(promiseForResult).rejects.toThrow(expectedError); + }); + }); + + testsForRpcFailoverBehavior({ + providerType, + requestToCall: { + method, + params: [], + }, + getRequestToMock: () => ({ + method, + params: [], + }), + failure: { + httpStatus, + }, + isRetriableFailure: true, + getExpectedError: () => expectedError, + getExpectedBreakError: () => + expect.objectContaining({ + message: expect.stringContaining( + `Fetch failed with status '${httpStatus}'`, + ), + }), + }); + }, + ); + + describe.each(['ETIMEDOUT', 'ECONNRESET'])( + 'if a %s error is thrown while making the request', + (errorCode) => { + const error = new Error(errorCode); + // @ts-expect-error `code` does not exist on the Error type, but is + // still used by Node. + error.code = errorCode; + + it('retries the request up to 4 times until it is successful', async () => { + await withMockedCommunications({ providerType }, async (comms) => { + const request = { method }; + + // The first time a block-cacheable request is made, the latest block + // number is retrieved through the block tracker first. It doesn't + // matter what this is — it's just used as a cache key. + comms.mockNextBlockTrackerRequest(); + // Here we have the request fail for the first 4 tries, then succeed + // on the 5th try. + comms.mockRpcCall({ + request, + error, + times: 3, + }); + comms.mockRpcCall({ + request, + response: { + result: 'the result', + httpStatus: 200, + }, + }); + + const result = await withNetworkClient( + { providerType }, + async ({ makeRpcCall }) => { + return await waitForPromiseToBeFulfilledAfterRunningAllTimers( + makeRpcCall(request), + ); + }, + ); + + expect(result).toBe('the result'); + }); + }); + + it('re-throws the error if it persists after 5 retries', async () => { + await withMockedCommunications({ providerType }, async (comms) => { + const request = { method }; + + // The first time a block-cacheable request is made, the latest block + // number is retrieved through the block tracker first. It doesn't + // matter what this is — it's just used as a cache key. + comms.mockNextBlockTrackerRequest(); + comms.mockRpcCall({ + request, + error, + times: 5, + }); + const promiseForResult = withNetworkClient( + { providerType }, + async ({ makeRpcCall }) => { + return await waitForPromiseToBeFulfilledAfterRunningAllTimers( + makeRpcCall(request), + ); + }, + ); + + await expect(promiseForResult).rejects.toThrow(error.message); + }); + }); + + testsForRpcFailoverBehavior({ + providerType, + requestToCall: { + method, + params: [], + }, + getRequestToMock: () => ({ + method, + params: [], + }), + failure: error, + isRetriableFailure: true, + getExpectedError: (url: string) => + expect.objectContaining({ + message: `request to ${url} failed, reason: ${errorCode}`, + }), + }); + }, + ); + + describe('if the RPC endpoint responds with invalid JSON', () => { + const expectedError = expect.objectContaining({ + code: errorCodes.rpc.parse, + }); + + it('retries the request up to 4 times until it responds with valid JSON', async () => { + await withMockedCommunications({ providerType }, async (comms) => { + const request = { method }; + + // The first time a block-cacheable request is made, the latest block + // number is retrieved through the block tracker first. It doesn't + // matter what this is — it's just used as a cache key. + comms.mockNextBlockTrackerRequest(); + // Here we have the request fail for the first 4 tries, then succeed + // on the 5th try. + comms.mockRpcCall({ + request, + response: { + body: 'invalid JSON', + }, + times: 3, + }); + comms.mockRpcCall({ + request, + response: { + result: 'the result', + httpStatus: 200, + }, + }); + + const result = await withNetworkClient( + { providerType }, + async ({ makeRpcCall, clock }) => { + return await waitForPromiseToBeFulfilledAfterRunningAllTimers( + makeRpcCall(request), + clock, + ); + }, + ); + + expect(result).toBe('the result'); + }); + }); + + it('throws a custom error if the result is still non-JSON-parseable after 5 retries', async () => { + await withMockedCommunications({ providerType }, async (comms) => { + const request = { method }; + + // The first time a block-cacheable request is made, the latest block + // number is retrieved through the block tracker first. It doesn't + // matter what this is — it's just used as a cache key. + comms.mockNextBlockTrackerRequest(); + comms.mockRpcCall({ + request, + response: { + body: 'invalid JSON', + }, + times: 5, + }); + const promiseForResult = withNetworkClient( + { providerType }, + async ({ makeRpcCall, clock }) => { + return await waitForPromiseToBeFulfilledAfterRunningAllTimers( + makeRpcCall(request), + clock, + ); + }, + ); + + await expect(promiseForResult).rejects.toThrow(expectedError); + }); + }); + + testsForRpcFailoverBehavior({ + providerType, + requestToCall: { + method, + params: [], + }, + getRequestToMock: () => ({ + method, + params: [], + }), + failure: { + body: 'invalid JSON', + }, + isRetriableFailure: true, + getExpectedError: () => expectedError, + getExpectedBreakError: () => + expect.objectContaining({ + message: expect.stringContaining('invalid json'), + }), + }); + }); + + describe('if making the request throws a connection error', () => { + const error = new TypeError('Failed to fetch'); + + it('retries the request up to 4 times until there is no connection error', async () => { + await withMockedCommunications({ providerType }, async (comms) => { + const request = { method }; + + // The first time a block-cacheable request is made, the latest block + // number is retrieved through the block tracker first. It doesn't + // matter what this is — it's just used as a cache key. + comms.mockNextBlockTrackerRequest(); + // Here we have the request fail for the first 4 tries, then succeed + // on the 5th try. + comms.mockRpcCall({ + request, + error, + times: 3, + }); + comms.mockRpcCall({ + request, + response: { + result: 'the result', + httpStatus: 200, + }, + }); + + const result = await withNetworkClient( + { providerType }, + async ({ makeRpcCall, clock }) => { + return await waitForPromiseToBeFulfilledAfterRunningAllTimers( + makeRpcCall(request), + clock, + ); + }, + ); + + expect(result).toBe('the result'); + }); + }); + + it('re-throws the error if it persists after 5 retries', async () => { + await withMockedCommunications({ providerType }, async (comms) => { + const request = { method }; + + // The first time a block-cacheable request is made, the latest block + // number is retrieved through the block tracker first. It doesn't + // matter what this is — it's just used as a cache key. + comms.mockNextBlockTrackerRequest(); + comms.mockRpcCall({ + request, + error, + times: 5, + }); + const promiseForResult = withNetworkClient( + { providerType }, + async ({ makeRpcCall, clock }) => { + return await waitForPromiseToBeFulfilledAfterRunningAllTimers( + makeRpcCall(request), + clock, + ); + }, + ); + + await expect(promiseForResult).rejects.toThrow(error.message); + }); + }); + + testsForRpcFailoverBehavior({ + providerType, + requestToCall: { + method, + params: [], + }, + getRequestToMock: () => ({ + method, + params: [], + }), + failure: error, + isRetriableFailure: true, + getExpectedError: (url: string) => + expect.objectContaining({ + message: `request to ${url} failed, reason: ${error.message}`, + }), + }); + }); +} diff --git a/packages/network-controller/tests/provider-api-tests/not-handled-by-middleware.ts b/packages/network-controller/tests/network-client/not-handled-by-middleware.ts similarity index 95% rename from packages/network-controller/tests/provider-api-tests/not-handled-by-middleware.ts rename to packages/network-controller/tests/network-client/not-handled-by-middleware.ts index bb76b5a1e42..c4ee0afec87 100644 --- a/packages/network-controller/tests/provider-api-tests/not-handled-by-middleware.ts +++ b/packages/network-controller/tests/network-client/not-handled-by-middleware.ts @@ -1,7 +1,7 @@ import { fill } from 'lodash'; -import type { ProviderType } from './helpers'; -import { withMockedCommunications, withNetworkClient } from './helpers'; +import type { ProviderType } from './helpers.js'; +import { withMockedCommunications, withNetworkClient } from './helpers.js'; type TestsForRpcMethodNotHandledByMiddlewareOptions = { providerType: ProviderType; @@ -25,7 +25,7 @@ export function testsForRpcMethodNotHandledByMiddleware( providerType, numberOfParameters, }: TestsForRpcMethodNotHandledByMiddlewareOptions, -) { +): void { it('attempts to pass the request off to the RPC endpoint', async () => { const request = { method, diff --git a/packages/network-controller/tests/network-client/rpc-failover.ts b/packages/network-controller/tests/network-client/rpc-failover.ts new file mode 100644 index 00000000000..e0eec3c98ef --- /dev/null +++ b/packages/network-controller/tests/network-client/rpc-failover.ts @@ -0,0 +1,304 @@ +import { ConstantBackoff } from '@metamask/controller-utils'; +import type { Hex } from '@metamask/utils'; + +import { ignoreRejection } from '../../../../tests/helpers.js'; +import { buildRootMessenger } from '../helpers.js'; +import type { MockRequest, MockResponse, ProviderType } from './helpers.js'; +import { withMockedCommunications, withNetworkClient } from './helpers.js'; + +/** + * Tests for RPC failover behavior. + * + * @param args - The arguments. + * @param args.providerType - The provider type. + * @param args.requestToCall - The request to call. + * @param args.getRequestToMock - Factory returning the request to mock. + * @param args.failure - The failure mock response to use. + * @param args.isRetriableFailure - Whether the failure gets retried. + * @param args.getExpectedError - Factory returning the expected error. + * @param args.getExpectedBreakError - Factory returning the expected error + * upon circuit break. Defaults to using `getExpectedError`. + */ +export function testsForRpcFailoverBehavior({ + providerType, + requestToCall, + getRequestToMock, + failure, + isRetriableFailure, + getExpectedError, +}: { + providerType: ProviderType; + requestToCall: MockRequest; + getRequestToMock: (request: MockRequest, blockNumber: Hex) => MockRequest; + failure: MockResponse | Error | string; + isRetriableFailure: boolean; + getExpectedError: (url: string) => Error | jest.Constructable; + getExpectedBreakError?: (url: string) => Error | jest.Constructable; +}): void { + if (providerType === 'custom') { + return; + } + + const blockNumber = '0x100'; + const backoffDuration = 100; + const maxConsecutiveFailures = 15; + const maxRetries = 4; + const numRequestsToMake = isRetriableFailure + ? maxConsecutiveFailures / (maxRetries + 1) + : maxConsecutiveFailures; + + describe('if RPC failover functionality is enabled', () => { + it(`fails over to the provided alternate RPC endpoint after ${maxConsecutiveFailures} unsuccessful attempts`, async () => { + await withMockedCommunications({ providerType }, async (primaryComms) => { + await withMockedCommunications( + { + providerType: 'custom', + customRpcUrl: 'https://failover.endpoint', + }, + async (failoverComms) => { + const request = requestToCall; + const requestToMock = getRequestToMock(request, blockNumber); + const additionalMockRpcCallOptions = + failure instanceof Error || typeof failure === 'string' + ? { error: failure } + : { response: failure }; + + // The first time a block-cacheable request is made, the + // latest block number is retrieved through the block + // tracker first. + primaryComms.mockNextBlockTrackerRequest({ + blockNumber, + }); + primaryComms.mockRpcCall({ + request: requestToMock, + times: maxConsecutiveFailures, + ...additionalMockRpcCallOptions, + }); + failoverComms.mockRpcCall({ + request: requestToMock, + response: { + result: 'ok', + }, + }); + + const messenger = buildRootMessenger(); + + const result = await withNetworkClient( + { + providerType, + rpcFailoverMode: 'enabled', + failoverRpcUrls: ['https://failover.endpoint'], + messenger, + getRpcServiceOptions: () => ({ + fetch, + btoa, + isOffline: (): boolean => false, + policyOptions: { + backoff: new ConstantBackoff(backoffDuration), + }, + }), + }, + async ({ makeRpcCall }) => { + messenger.subscribe( + 'NetworkController:rpcEndpointRetried', + () => { + // Ensure that we advance to the next RPC request + // retry, not the next block tracker request. + // We also don't need to await this, it just needs to + // be added to the promise queue. + // eslint-disable-next-line @typescript-eslint/no-floating-promises + jest.advanceTimersByTimeAsync(backoffDuration); + }, + ); + + for (let i = 0; i < numRequestsToMake - 1; i++) { + await ignoreRejection(makeRpcCall(request)); + } + return await makeRpcCall(request); + }, + ); + + expect(result).toBe('ok'); + }, + ); + }); + }); + + it('allows RPC service options to be customized', async () => { + const customMaxConsecutiveFailures = 6; + const customMaxRetries = 2; + const customNumRequestsToMake = isRetriableFailure + ? customMaxConsecutiveFailures / (customMaxRetries + 1) + : customMaxConsecutiveFailures; + + await withMockedCommunications( + { + providerType, + expectedHeaders: { + 'X-Foo': 'Bar', + }, + }, + async (primaryComms) => { + await withMockedCommunications( + { + providerType: 'custom', + customRpcUrl: 'https://failover.endpoint', + expectedHeaders: { + 'X-Baz': 'Qux', + }, + }, + async (failoverComms) => { + const request = requestToCall; + const requestToMock = getRequestToMock(request, blockNumber); + const additionalMockRpcCallOptions = + failure instanceof Error || typeof failure === 'string' + ? { error: failure } + : { response: failure }; + + // The first time a block-cacheable request is made, the + // latest block number is retrieved through the block + // tracker first. + primaryComms.mockNextBlockTrackerRequest({ + blockNumber, + }); + primaryComms.mockRpcCall({ + request: requestToMock, + times: customMaxConsecutiveFailures, + ...additionalMockRpcCallOptions, + }); + failoverComms.mockRpcCall({ + request: requestToMock, + response: { + result: 'ok', + }, + }); + + const messenger = buildRootMessenger(); + + const result = await withNetworkClient( + { + providerType, + rpcFailoverMode: 'enabled', + failoverRpcUrls: ['https://failover.endpoint'], + messenger, + getRpcServiceOptions: (rpcEndpointUrl) => { + const commonOptions = { + fetch, + btoa, + isOffline: (): boolean => false, + }; + if (rpcEndpointUrl === 'https://failover.endpoint') { + const headers: HeadersInit = { + 'X-Baz': 'Qux', + }; + return { + ...commonOptions, + fetchOptions: { + headers, + }, + }; + } + const headers: HeadersInit = { + 'X-Foo': 'Bar', + }; + return { + ...commonOptions, + fetchOptions: { + headers, + }, + policyOptions: { + backoff: new ConstantBackoff(backoffDuration), + maxRetries: customMaxRetries, + maxConsecutiveFailures: customMaxConsecutiveFailures, + }, + }; + }, + }, + async ({ makeRpcCall }) => { + messenger.subscribe( + 'NetworkController:rpcEndpointRetried', + () => { + // Ensure that we advance to the next RPC request + // retry, not the next block tracker request. + // We also don't need to await this, it just needs to + // be added to the promise queue. + // eslint-disable-next-line @typescript-eslint/no-floating-promises + jest.advanceTimersByTimeAsync(backoffDuration); + }, + ); + + for (let i = 0; i < customNumRequestsToMake - 1; i++) { + await ignoreRejection(makeRpcCall(request)); + } + return await makeRpcCall(request); + }, + ); + + expect(result).toBe('ok'); + }, + ); + }, + ); + }); + }); + + describe('if RPC failover functionality is not enabled', () => { + it(`throws even after ${maxConsecutiveFailures} unsuccessful attempts`, async () => { + await withMockedCommunications({ providerType }, async (comms) => { + const request = requestToCall; + const requestToMock = getRequestToMock(request, blockNumber); + const additionalMockRpcCallOptions = + failure instanceof Error || typeof failure === 'string' + ? { error: failure } + : { response: failure }; + + // The first time a block-cacheable request is made, the latest block + // number is retrieved through the block tracker first. + comms.mockNextBlockTrackerRequest({ blockNumber }); + comms.mockRpcCall({ + request: requestToMock, + times: maxConsecutiveFailures, + ...additionalMockRpcCallOptions, + }); + + const messenger = buildRootMessenger(); + + await withNetworkClient( + { + providerType, + rpcFailoverMode: 'disabled', + failoverRpcUrls: ['https://failover.endpoint'], + messenger, + getRpcServiceOptions: () => ({ + fetch, + btoa, + isOffline: (): boolean => false, + policyOptions: { + backoff: new ConstantBackoff(backoffDuration), + }, + }), + }, + async ({ makeRpcCall, rpcUrl }) => { + messenger.subscribe('NetworkController:rpcEndpointRetried', () => { + // Ensure that we advance to the next RPC request + // retry, not the next block tracker request. + // We also don't need to await this, it just needs to + // be added to the promise queue. + // eslint-disable-next-line @typescript-eslint/no-floating-promises + jest.advanceTimersByTimeAsync(backoffDuration); + }); + + for (let i = 0; i < numRequestsToMake - 1; i++) { + await ignoreRejection(makeRpcCall(request)); + } + const promiseForResult = makeRpcCall(request); + + await expect(promiseForResult).rejects.toThrow( + getExpectedError(rpcUrl), + ); + }, + ); + }); + }); + }); +} diff --git a/packages/network-controller/tests/provider-api-tests/block-hash-in-response.ts b/packages/network-controller/tests/provider-api-tests/block-hash-in-response.ts deleted file mode 100644 index 022ff32783a..00000000000 --- a/packages/network-controller/tests/provider-api-tests/block-hash-in-response.ts +++ /dev/null @@ -1,273 +0,0 @@ -import type { ProviderType } from './helpers'; -import { withMockedCommunications, withNetworkClient } from './helpers'; - -type TestsForRpcMethodThatCheckForBlockHashInResponseOptions = { - providerType: ProviderType; - numberOfParameters: number; -}; - -/** - * Defines tests which exercise the behavior exhibited by an RPC method that - * use `blockHash` in the response data to determine whether the response is - * cacheable. - * - * @param method - The name of the RPC method under test. - * @param additionalArgs - Additional arguments. - * @param additionalArgs.numberOfParameters - The number of parameters supported - * by the method under test. - * @param additionalArgs.providerType - The type of provider being tested; - * either `infura` or `custom`. - */ -export function testsForRpcMethodsThatCheckForBlockHashInResponse( - method: string, - { - numberOfParameters, - providerType, - }: TestsForRpcMethodThatCheckForBlockHashInResponseOptions, -) { - it('does not hit the RPC endpoint more than once for identical requests and it has a valid blockHash', async () => { - const requests = [{ method }, { method }]; - const mockResult = { blockHash: '0x1' }; - - await withMockedCommunications({ providerType }, async (comms) => { - // The first time a block-cacheable request is made, the latest block - // number is retrieved through the block tracker first. It doesn't - // matter what this is — it's just used as a cache key. - comms.mockNextBlockTrackerRequest(); - comms.mockRpcCall({ - request: requests[0], - response: { result: mockResult }, - }); - - const results = await withNetworkClient( - { providerType }, - ({ makeRpcCallsInSeries }) => makeRpcCallsInSeries(requests), - ); - - expect(results).toStrictEqual([mockResult, mockResult]); - }); - }); - - it('hits the RPC endpoint and does not reuse the result of a previous request if the latest block number was updated since', async () => { - const requests = [{ method }, { method }]; - const mockResults = [{ blockHash: '0x100' }, { blockHash: '0x200' }]; - - await withMockedCommunications({ providerType }, async (comms) => { - // Note that we have to mock these requests in a specific order. The - // first block tracker request occurs because of the first RPC - // request. The second block tracker request, however, does not occur - // because of the second RPC request, but rather because we call - // `clock.runAll()` below. - comms.mockNextBlockTrackerRequest({ blockNumber: '0x1' }); - comms.mockRpcCall({ - request: requests[0], - response: { result: mockResults[0] }, - }); - comms.mockNextBlockTrackerRequest({ blockNumber: '0x2' }); - comms.mockRpcCall({ - request: requests[1], - response: { result: mockResults[1] }, - }); - - const results = await withNetworkClient( - { providerType }, - async (client) => { - const firstResult = await client.makeRpcCall(requests[0]); - // Proceed to the next iteration of the block tracker so that a new - // block is fetched and the current block is updated. - client.clock.runAll(); - const secondResult = await client.makeRpcCall(requests[1]); - return [firstResult, secondResult]; - }, - ); - - expect(results).toStrictEqual(mockResults); - }); - }); - - it('does not reuse the result of a previous request if result.blockHash was null', async () => { - const requests = [{ method }, { method }]; - const mockResults = [ - { blockHash: null, extra: 'some value' }, - { blockHash: '0x100', extra: 'some other value' }, - ]; - - await withMockedCommunications({ providerType }, async (comms) => { - // The first time a block-cacheable request is made, the latest block - // number is retrieved through the block tracker first. It doesn't - // matter what this is — it's just used as a cache key. - comms.mockNextBlockTrackerRequest(); - comms.mockRpcCall({ - request: requests[0], - response: { result: mockResults[0] }, - }); - comms.mockRpcCall({ - request: requests[1], - response: { result: mockResults[1] }, - }); - - const results = await withNetworkClient( - { providerType }, - ({ makeRpcCallsInSeries }) => makeRpcCallsInSeries(requests), - ); - - expect(results).toStrictEqual(mockResults); - }); - }); - - it('does not reuse the result of a previous request if result.blockHash was undefined', async () => { - const requests = [{ method }, { method }]; - const mockResults = [ - { extra: 'some value' }, - { blockHash: '0x100', extra: 'some other value' }, - ]; - - await withMockedCommunications({ providerType }, async (comms) => { - // The first time a block-cacheable request is made, the latest block - // number is retrieved through the block tracker first. It doesn't - // matter what this is — it's just used as a cache key. - comms.mockNextBlockTrackerRequest(); - comms.mockRpcCall({ - request: requests[0], - response: { result: mockResults[0] }, - }); - comms.mockRpcCall({ - request: requests[1], - response: { result: mockResults[1] }, - }); - - const results = await withNetworkClient( - { providerType }, - ({ makeRpcCallsInSeries }) => makeRpcCallsInSeries(requests), - ); - - expect(results).toStrictEqual(mockResults); - }); - }); - - it('does not reuse the result of a previous request if result.blockHash was "0x0000000000000000000000000000000000000000000000000000000000000000"', async () => { - const requests = [{ method }, { method }]; - const mockResults = [ - { - blockHash: - '0x0000000000000000000000000000000000000000000000000000000000000000', - extra: 'some value', - }, - { blockHash: '0x100', extra: 'some other value' }, - ]; - - await withMockedCommunications({ providerType }, async (comms) => { - // The first time a block-cacheable request is made, the latest block - // number is retrieved through the block tracker first. It doesn't - // matter what this is — it's just used as a cache key. - comms.mockNextBlockTrackerRequest(); - comms.mockRpcCall({ - request: requests[0], - response: { result: mockResults[0] }, - }); - comms.mockRpcCall({ - request: requests[1], - response: { result: mockResults[1] }, - }); - - const results = await withNetworkClient( - { providerType }, - ({ makeRpcCallsInSeries }) => makeRpcCallsInSeries(requests), - ); - - expect(results).toStrictEqual(mockResults); - }); - }); - - for (const emptyValue of [null, undefined, '\u003cnil\u003e']) { - it(`does not retry an empty response of "${emptyValue}"`, async () => { - const request = { method }; - const mockResult = emptyValue; - - await withMockedCommunications({ providerType }, async (comms) => { - // The first time a block-cacheable request is made, the latest block - // number is retrieved through the block tracker first. It doesn't - // matter what this is — it's just used as a cache key. - comms.mockNextBlockTrackerRequest(); - comms.mockRpcCall({ - request, - response: { result: mockResult }, - }); - - const result = await withNetworkClient( - { providerType }, - ({ makeRpcCall }) => makeRpcCall(request), - ); - - expect(result).toStrictEqual(mockResult); - }); - }); - - it(`does not reuse the result of a previous request if it was "${emptyValue}"`, async () => { - const requests = [{ method }, { method }]; - const mockResults = [emptyValue, { blockHash: '0x100' }]; - - await withMockedCommunications({ providerType }, async (comms) => { - // The first time a block-cacheable request is made, the latest block - // number is retrieved through the block tracker first. It doesn't - // matter what this is — it's just used as a cache key. - comms.mockNextBlockTrackerRequest(); - comms.mockRpcCall({ - request: requests[0], - response: { result: mockResults[0] }, - }); - comms.mockRpcCall({ - request: requests[1], - response: { result: mockResults[1] }, - }); - - const results = await withNetworkClient( - { providerType }, - ({ makeRpcCallsInSeries }) => makeRpcCallsInSeries(requests), - ); - - expect(results).toStrictEqual(mockResults); - }); - }); - } - - for (const paramIndex of [...Array(numberOfParameters).keys()]) { - it(`does not reuse the result of a previous request with a valid blockHash if parameter at index "${paramIndex}" differs`, async () => { - const firstMockParams = [ - ...new Array(numberOfParameters).fill('some value'), - ]; - const secondMockParams = firstMockParams.slice(); - secondMockParams[paramIndex] = 'another value'; - const requests = [ - { - method, - params: firstMockParams, - }, - { method, params: secondMockParams }, - ]; - const mockResults = [{ blockHash: '0x100' }, { blockHash: '0x200' }]; - - await withMockedCommunications({ providerType }, async (comms) => { - // The first time a block-cacheable request is made, the latest block - // number is retrieved through the block tracker first. It doesn't - // matter what this is — it's just used as a cache key. - comms.mockNextBlockTrackerRequest(); - comms.mockRpcCall({ - request: requests[0], - response: { result: mockResults[0] }, - }); - comms.mockRpcCall({ - request: requests[1], - response: { result: mockResults[1] }, - }); - - const results = await withNetworkClient( - { providerType }, - ({ makeRpcCallsInSeries }) => makeRpcCallsInSeries(requests), - ); - - expect(results).toStrictEqual([mockResults[0], mockResults[1]]); - }); - }); - } -} diff --git a/packages/network-controller/tests/provider-api-tests/block-param.ts b/packages/network-controller/tests/provider-api-tests/block-param.ts deleted file mode 100644 index 51481fad738..00000000000 --- a/packages/network-controller/tests/provider-api-tests/block-param.ts +++ /dev/null @@ -1,2068 +0,0 @@ -import type { ProviderType } from './helpers'; -import { - buildMockParams, - buildRequestWithReplacedBlockParam, - waitForPromiseToBeFulfilledAfterRunningAllTimers, - withMockedCommunications, - withNetworkClient, -} from './helpers'; -import { - buildFetchFailedErrorMessage, - buildInfuraClientRetriesExhaustedErrorMessage, - buildJsonRpcEngineEmptyResponseErrorMessage, -} from './shared-tests'; - -type TestsForRpcMethodSupportingBlockParam = { - providerType: ProviderType; - blockParamIndex: number; - numberOfParameters: number; -}; - -/** - * Defines tests which exercise the behavior exhibited by an RPC method that - * takes a block parameter. The value of this parameter can be either a block - * number or a block tag ("latest", "earliest", or "pending") and affects how - * the method is cached. - * - * @param method - The name of the RPC method under test. - * @param additionalArgs - Additional arguments. - * @param additionalArgs.blockParamIndex - The index of the block parameter. - * @param additionalArgs.numberOfParameters - The number of parameters - * supported by the method under test. - * @param additionalArgs.providerType - The type of provider being tested. - * either `infura` or `custom`. - */ -export function testsForRpcMethodSupportingBlockParam( - method: string, - { - blockParamIndex, - numberOfParameters, - providerType, - }: TestsForRpcMethodSupportingBlockParam, -) { - describe.each([ - ['given no block tag', undefined], - ['given a block tag of "latest"', 'latest'], - ])('%s', (_desc, blockParam) => { - it('does not hit the RPC endpoint more than once for identical requests', async () => { - const requests = [ - { - method, - params: buildMockParams({ blockParamIndex, blockParam }), - }, - { method, params: buildMockParams({ blockParamIndex, blockParam }) }, - ]; - const mockResults = ['first result', 'second result']; - - await withMockedCommunications({ providerType }, async (comms) => { - // The first time a block-cacheable request is made, the block-cache - // middleware will request the latest block number through the block - // tracker to determine the cache key. Later, the block-ref - // middleware will request the latest block number again to resolve - // the value of "latest", but the block number is cached once made, - // so we only need to mock the request once. - comms.mockNextBlockTrackerRequest({ blockNumber: '0x100' }); - // The block-ref middleware will make the request as specified - // except that the block param is replaced with the latest block - // number. - comms.mockRpcCall({ - request: buildRequestWithReplacedBlockParam( - requests[0], - blockParamIndex, - '0x100', - ), - response: { result: mockResults[0] }, - }); - - const results = await withNetworkClient( - { providerType }, - ({ makeRpcCallsInSeries }) => makeRpcCallsInSeries(requests), - ); - - expect(results).toStrictEqual([mockResults[0], mockResults[0]]); - }); - }); - - for (const paramIndex of [...Array(numberOfParameters).keys()]) { - if (paramIndex === blockParamIndex) { - // testing changes in block param is covered under later tests - continue; - } - it(`does not reuse the result of a previous request if parameter at index "${paramIndex}" differs`, async () => { - const firstMockParams = [ - ...new Array(numberOfParameters).fill('some value'), - ]; - firstMockParams[blockParamIndex] = blockParam; - const secondMockParams = firstMockParams.slice(); - secondMockParams[paramIndex] = 'another value'; - const requests = [ - { - method, - params: firstMockParams, - }, - { method, params: secondMockParams }, - ]; - const mockResults = ['first result', 'second result']; - - await withMockedCommunications({ providerType }, async (comms) => { - // The first time a block-cacheable request is made, the block-cache - // middleware will request the latest block number through the block - // tracker to determine the cache key. Later, the block-ref - // middleware will request the latest block number again to resolve - // the value of "latest", but the block number is cached once made, - // so we only need to mock the request once. - comms.mockNextBlockTrackerRequest({ blockNumber: '0x100' }); - // The block-ref middleware will make the request as specified - // except that the block param is replaced with the latest block - // number. - comms.mockRpcCall({ - request: buildRequestWithReplacedBlockParam( - requests[0], - blockParamIndex, - '0x100', - ), - response: { result: mockResults[0] }, - }); - comms.mockRpcCall({ - request: buildRequestWithReplacedBlockParam( - requests[1], - blockParamIndex, - '0x100', - ), - response: { result: mockResults[1] }, - }); - - const results = await withNetworkClient( - { providerType }, - ({ makeRpcCallsInSeries }) => makeRpcCallsInSeries(requests), - ); - - expect(results).toStrictEqual([mockResults[0], mockResults[1]]); - }); - }); - } - - it('hits the RPC endpoint and does not reuse the result of a previous request if the latest block number was updated since', async () => { - const requests = [ - { method, params: buildMockParams({ blockParamIndex, blockParam }) }, - { method, params: buildMockParams({ blockParamIndex, blockParam }) }, - ]; - const mockResults = ['first result', 'second result']; - - await withMockedCommunications({ providerType }, async (comms) => { - // Note that we have to mock these requests in a specific order. - // The first block tracker request occurs because of the first RPC - // request. The second block tracker request, however, does not - // occur because of the second RPC request, but rather because we - // call `clock.runAll()` below. - comms.mockNextBlockTrackerRequest({ blockNumber: '0x100' }); - // The block-ref middleware will make the request as specified - // except that the block param is replaced with the latest block - // number. - comms.mockRpcCall({ - request: buildRequestWithReplacedBlockParam( - requests[0], - blockParamIndex, - '0x100', - ), - response: { result: mockResults[0] }, - }); - comms.mockNextBlockTrackerRequest({ blockNumber: '0x200' }); - comms.mockRpcCall({ - request: buildRequestWithReplacedBlockParam( - requests[1], - blockParamIndex, - '0x200', - ), - response: { result: mockResults[1] }, - }); - - const results = await withNetworkClient( - { providerType }, - async (client) => { - const firstResult = await client.makeRpcCall(requests[0]); - // Proceed to the next iteration of the block tracker so that a - // new block is fetched and the current block is updated. - client.clock.runAll(); - const secondResult = await client.makeRpcCall(requests[1]); - return [firstResult, secondResult]; - }, - ); - - expect(results).toStrictEqual(mockResults); - }); - }); - - for (const emptyValue of [null, undefined, '\u003cnil\u003e']) { - it(`does not retry an empty response of "${emptyValue}"`, async () => { - const request = { - method, - params: buildMockParams({ blockParamIndex, blockParam }), - }; - const mockResult = emptyValue; - - await withMockedCommunications({ providerType }, async (comms) => { - // The first time a block-cacheable request is made, the - // block-cache middleware will request the latest block number - // through the block tracker to determine the cache key. Later, - // the block-ref middleware will request the latest block number - // again to resolve the value of "latest", but the block number is - // cached once made, so we only need to mock the request once. - comms.mockNextBlockTrackerRequest({ blockNumber: '0x100' }); - // The block-ref middleware will make the request as specified - // except that the block param is replaced with the latest block - // number. - comms.mockRpcCall({ - request: buildRequestWithReplacedBlockParam( - request, - blockParamIndex, - '0x100', - ), - response: { result: mockResult }, - }); - - const result = await withNetworkClient( - { providerType }, - ({ makeRpcCall }) => makeRpcCall(request), - ); - - expect(result).toStrictEqual(mockResult); - }); - }); - - it(`does not reuse the result of a previous request if it was "${emptyValue}"`, async () => { - const requests = [ - { method, params: buildMockParams({ blockParamIndex, blockParam }) }, - { method, params: buildMockParams({ blockParamIndex, blockParam }) }, - ]; - const mockResults = [emptyValue, 'some result']; - - await withMockedCommunications({ providerType }, async (comms) => { - // The first time a block-cacheable request is made, the - // block-cache middleware will request the latest block number - // through the block tracker to determine the cache key. Later, - // the block-ref middleware will request the latest block number - // again to resolve the value of "latest", but the block number is - // cached once made, so we only need to mock the request once. - comms.mockNextBlockTrackerRequest({ blockNumber: '0x100' }); - // The block-ref middleware will make the request as specified - // except that the block param is replaced with the latest block - // number. - comms.mockRpcCall({ - request: buildRequestWithReplacedBlockParam( - requests[0], - blockParamIndex, - '0x100', - ), - response: { result: mockResults[0] }, - }); - comms.mockRpcCall({ - request: buildRequestWithReplacedBlockParam( - requests[1], - blockParamIndex, - '0x100', - ), - response: { result: mockResults[1] }, - }); - - const results = await withNetworkClient( - { providerType }, - ({ makeRpcCallsInSeries }) => makeRpcCallsInSeries(requests), - ); - - expect(results).toStrictEqual(mockResults); - }); - }); - } - - it('queues requests while a previous identical call is still pending, then runs the queue when it finishes, reusing the result from the first request', async () => { - const requests = [ - { method, params: buildMockParams({ blockParamIndex, blockParam }) }, - { method, params: buildMockParams({ blockParamIndex, blockParam }) }, - { method, params: buildMockParams({ blockParamIndex, blockParam }) }, - ]; - const mockResults = ['first result', 'second result', 'third result']; - - await withMockedCommunications({ providerType }, async (comms) => { - // The first time a block-cacheable request is made, the - // block-cache middleware will request the latest block number - // through the block tracker to determine the cache key. Later, - // the block-ref middleware will request the latest block number - // again to resolve the value of "latest", but the block number is - // cached once made, so we only need to mock the request once. - comms.mockNextBlockTrackerRequest({ blockNumber: '0x100' }); - // The block-ref middleware will make the request as specified - // except that the block param is replaced with the latest block - // number, and we delay it. - comms.mockRpcCall({ - delay: 100, - request: buildRequestWithReplacedBlockParam( - requests[0], - blockParamIndex, - '0x100', - ), - response: { result: mockResults[0] }, - }); - // The previous two requests will happen again, in the same order. - comms.mockRpcCall({ - request: buildRequestWithReplacedBlockParam( - requests[1], - blockParamIndex, - '0x100', - ), - response: { result: mockResults[1] }, - }); - comms.mockRpcCall({ - request: buildRequestWithReplacedBlockParam( - requests[2], - blockParamIndex, - '0x100', - ), - response: { result: mockResults[2] }, - }); - - const results = await withNetworkClient( - { providerType }, - async (client) => { - const resultPromises = [ - client.makeRpcCall(requests[0]), - client.makeRpcCall(requests[1]), - client.makeRpcCall(requests[2]), - ]; - const firstResult = await resultPromises[0]; - // The inflight cache middleware uses setTimeout to run the - // handlers, so run them now - client.clock.runAll(); - const remainingResults = await Promise.all(resultPromises.slice(1)); - return [firstResult, ...remainingResults]; - }, - ); - - expect(results).toStrictEqual([ - mockResults[0], - mockResults[0], - mockResults[0], - ]); - }); - }); - - it('throws an error with a custom message if the request to the RPC endpoint returns a 405 response', async () => { - await withMockedCommunications({ providerType }, async (comms) => { - const request = { method }; - - // The first time a block-cacheable request is made, the - // block-cache middleware will request the latest block number - // through the block tracker to determine the cache key. Later, - // the block-ref middleware will request the latest block number - // again to resolve the value of "latest", but the block number is - // cached once made, so we only need to mock the request once. - comms.mockNextBlockTrackerRequest({ blockNumber: '0x100' }); - // The block-ref middleware will make the request as specified - // except that the block param is replaced with the latest block - // number. - comms.mockRpcCall({ - request: buildRequestWithReplacedBlockParam( - request, - blockParamIndex, - '0x100', - ), - response: { - httpStatus: 405, - }, - }); - const promiseForResult = withNetworkClient( - { providerType }, - async ({ makeRpcCall }) => makeRpcCall(request), - ); - - await expect(promiseForResult).rejects.toThrow( - 'The method does not exist / is not available', - ); - }); - }); - - // There is a difference in how we are testing the Infura middleware vs. the - // custom RPC middleware (or, more specifically, the fetch middleware) - // because of what both middleware treat as rate limiting errors. In this - // case, the fetch middleware treats a 418 response from the RPC endpoint as - // such an error, whereas to the Infura middleware, it is a 429 response. - if (providerType === 'infura') { - it('throws a generic, undescriptive error if the request to the RPC endpoint returns a 418 response', async () => { - await withMockedCommunications({ providerType }, async (comms) => { - const request = { - id: 123, - method, - params: buildMockParams({ blockParam, blockParamIndex }), - }; - - // The first time a block-cacheable request is made, the - // block-cache middleware will request the latest block number - // through the block tracker to determine the cache key. Later, - // the block-ref middleware will request the latest block number - // again to resolve the value of "latest", but the block number is - // cached once made, so we only need to mock the request once. - comms.mockNextBlockTrackerRequest({ blockNumber: '0x100' }); - // The block-ref middleware will make the request as specified - // except that the block param is replaced with the latest block - // number. - comms.mockRpcCall({ - request: buildRequestWithReplacedBlockParam( - request, - blockParamIndex, - '0x100', - ), - response: { - httpStatus: 418, - }, - }); - const promiseForResult = withNetworkClient( - { providerType }, - async ({ makeRpcCall }) => makeRpcCall(request), - ); - - await expect(promiseForResult).rejects.toThrow( - '{"id":123,"jsonrpc":"2.0"}', - ); - }); - }); - - it('throws an error with a custom message if the request to the RPC endpoint returns a 429 response', async () => { - await withMockedCommunications({ providerType }, async (comms) => { - const request = { - method, - params: buildMockParams({ blockParam, blockParamIndex }), - }; - - // The first time a block-cacheable request is made, the - // block-cache middleware will request the latest block number - // through the block tracker to determine the cache key. Later, - // the block-ref middleware will request the latest block number - // again to resolve the value of "latest", but the block number is - // cached once made, so we only need to mock the request once. - comms.mockNextBlockTrackerRequest({ blockNumber: '0x100' }); - // The block-ref middleware will make the request as specified - // except that the block param is replaced with the latest block - // number. - comms.mockRpcCall({ - request: buildRequestWithReplacedBlockParam( - request, - blockParamIndex, - '0x100', - ), - response: { - httpStatus: 429, - }, - }); - const promiseForResult = withNetworkClient( - { providerType }, - async ({ makeRpcCall }) => makeRpcCall(request), - ); - - await expect(promiseForResult).rejects.toThrow( - 'Request is being rate limited', - ); - }); - }); - } else { - it('throws an error with a custom message if the request to the RPC endpoint returns a 418 response', async () => { - await withMockedCommunications({ providerType }, async (comms) => { - const request = { - method, - params: buildMockParams({ blockParam, blockParamIndex }), - }; - - // The first time a block-cacheable request is made, the - // block-cache middleware will request the latest block number - // through the block tracker to determine the cache key. Later, - // the block-ref middleware will request the latest block number - // again to resolve the value of "latest", but the block number is - // cached once made, so we only need to mock the request once. - comms.mockNextBlockTrackerRequest({ blockNumber: '0x100' }); - // The block-ref middleware will make the request as specified - // except that the block param is replaced with the latest block - // number. - comms.mockRpcCall({ - request: buildRequestWithReplacedBlockParam( - request, - blockParamIndex, - '0x100', - ), - response: { - httpStatus: 418, - }, - }); - const promiseForResult = withNetworkClient( - { providerType }, - async ({ makeRpcCall }) => makeRpcCall(request), - ); - - await expect(promiseForResult).rejects.toThrow( - 'Request is being rate limited.', - ); - }); - }); - - it('throws an undescriptive error if the request to the RPC endpoint returns a 429 response', async () => { - await withMockedCommunications({ providerType }, async (comms) => { - const request = { - method, - params: buildMockParams({ blockParam, blockParamIndex }), - }; - - // The first time a block-cacheable request is made, the - // block-cache middleware will request the latest block number - // through the block tracker to determine the cache key. Later, - // the block-ref middleware will request the latest block number - // again to resolve the value of "latest", but the block number is - // cached once made, so we only need to mock the request once. - comms.mockNextBlockTrackerRequest({ blockNumber: '0x100' }); - // The block-ref middleware will make the request as specified - // except that the block param is replaced with the latest block - // number. - comms.mockRpcCall({ - request: buildRequestWithReplacedBlockParam( - request, - blockParamIndex, - '0x100', - ), - response: { - httpStatus: 429, - }, - }); - const promiseForResult = withNetworkClient( - { providerType }, - async ({ makeRpcCall }) => makeRpcCall(request), - ); - - await expect(promiseForResult).rejects.toThrow( - "Non-200 status code: '429'", - ); - }); - }); - } - - it('throws an undescriptive error message if the request to the RPC endpoint returns a response that is not 405, 418, 429, 503, or 504', async () => { - await withMockedCommunications({ providerType }, async (comms) => { - const request = { method }; - - // The first time a block-cacheable request is made, the - // block-cache middleware will request the latest block number - // through the block tracker to determine the cache key. Later, - // the block-ref middleware will request the latest block number - // again to resolve the value of "latest", but the block number is - // cached once made, so we only need to mock the request once. - comms.mockNextBlockTrackerRequest({ blockNumber: '0x100' }); - // The block-ref middleware will make the request as specified - // except that the block param is replaced with the latest block - // number. - comms.mockRpcCall({ - request: buildRequestWithReplacedBlockParam( - request, - blockParamIndex, - '0x100', - ), - response: { - id: 12345, - jsonrpc: '2.0', - error: 'some error', - httpStatus: 420, - }, - }); - const promiseForResult = withNetworkClient( - { providerType }, - async ({ makeRpcCall }) => makeRpcCall(request), - ); - - const msg = - providerType === 'infura' - ? '{"id":12345,"jsonrpc":"2.0","error":"some error"}' - : "Non-200 status code: '420'"; - await expect(promiseForResult).rejects.toThrow(msg); - }); - }); - - [503, 504].forEach((httpStatus) => { - it(`retries the request to the RPC endpoint up to 5 times if it returns a ${httpStatus} response, returning the successful result if there is one on the 5th try`, async () => { - await withMockedCommunications({ providerType }, async (comms) => { - const request = { - method, - params: buildMockParams({ blockParam, blockParamIndex }), - }; - - // The first time a block-cacheable request is made, the - // block-cache middleware will request the latest block number - // through the block tracker to determine the cache key. Later, - // the block-ref middleware will request the latest block number - // again to resolve the value of "latest", but the block number is - // cached once made, so we only need to mock the request once. - comms.mockNextBlockTrackerRequest({ blockNumber: '0x100' }); - // The block-ref middleware will make the request as specified - // except that the block param is replaced with the latest block - // number. - // - // Here we have the request fail for the first 4 tries, then succeed - // on the 5th try. - comms.mockRpcCall({ - request: buildRequestWithReplacedBlockParam( - request, - blockParamIndex, - '0x100', - ), - response: { - error: 'some error', - httpStatus, - }, - times: 4, - }); - comms.mockRpcCall({ - request: buildRequestWithReplacedBlockParam( - request, - blockParamIndex, - '0x100', - ), - response: { - result: 'the result', - httpStatus: 200, - }, - }); - const result = await withNetworkClient( - { providerType }, - async ({ makeRpcCall, clock }) => { - return await waitForPromiseToBeFulfilledAfterRunningAllTimers( - makeRpcCall(request), - clock, - ); - }, - ); - - expect(result).toBe('the result'); - }); - }); - - // Both the Infura middleware and custom RPC middleware detect a 503 or - // 504 response and retry the request to the RPC endpoint automatically - // but differ in what sort of response is returned when the number of - // retries is exhausted. - if (providerType === 'infura') { - it(`causes a request to fail with a custom error if the request to the RPC endpoint returns a ${httpStatus} response 5 times in a row`, async () => { - await withMockedCommunications({ providerType }, async (comms) => { - const request = { - method, - params: buildMockParams({ blockParam, blockParamIndex }), - }; - - // The first time a block-cacheable request is made, the - // block-cache middleware will request the latest block number - // through the block tracker to determine the cache key. Later, - // the block-ref middleware will request the latest block number - // again to resolve the value of "latest", but the block number is - // cached once made, so we only need to mock the request once. - comms.mockNextBlockTrackerRequest({ blockNumber: '0x100' }); - // The block-ref middleware will make the request as specified - // except that the block param is replaced with the latest block - // number. - comms.mockRpcCall({ - request: buildRequestWithReplacedBlockParam( - request, - blockParamIndex, - '0x100', - ), - response: { - error: 'Some error', - httpStatus, - }, - times: 5, - }); - const promiseForResult = withNetworkClient( - { providerType }, - async ({ makeRpcCall, clock }) => { - return await waitForPromiseToBeFulfilledAfterRunningAllTimers( - makeRpcCall(request), - clock, - ); - }, - ); - await expect(promiseForResult).rejects.toThrow( - buildInfuraClientRetriesExhaustedErrorMessage('Gateway timeout'), - ); - }); - }); - } else { - it(`produces an empty response if the request to the RPC endpoint returns a ${httpStatus} response 5 times in a row`, async () => { - await withMockedCommunications({ providerType }, async (comms) => { - const request = { - method, - params: buildMockParams({ blockParam, blockParamIndex }), - }; - - // The first time a block-cacheable request is made, the - // block-cache middleware will request the latest block number - // through the block tracker to determine the cache key. Later, - // the block-ref middleware will request the latest block number - // again to resolve the value of "latest", but the block number is - // cached once made, so we only need to mock the request once. - comms.mockNextBlockTrackerRequest({ blockNumber: '0x100' }); - // The block-ref middleware will make the request as specified - // except that the block param is replaced with the latest block - // number. - comms.mockRpcCall({ - request: buildRequestWithReplacedBlockParam( - request, - blockParamIndex, - '0x100', - ), - response: { - error: 'Some error', - httpStatus, - }, - times: 5, - }); - const promiseForResult = withNetworkClient( - { providerType }, - async ({ makeRpcCall, clock }) => { - return await waitForPromiseToBeFulfilledAfterRunningAllTimers( - makeRpcCall(request), - clock, - ); - }, - ); - await expect(promiseForResult).rejects.toThrow( - buildJsonRpcEngineEmptyResponseErrorMessage(method), - ); - }); - }); - } - }); - - it('retries the request to the RPC endpoint up to 5 times if an "ETIMEDOUT" error is thrown while making the request, returning the successful result if there is one on the 5th try', async () => { - await withMockedCommunications({ providerType }, async (comms) => { - const request = { - method, - params: buildMockParams({ blockParam, blockParamIndex }), - }; - - // The first time a block-cacheable request is made, the - // block-cache middleware will request the latest block number - // through the block tracker to determine the cache key. Later, - // the block-ref middleware will request the latest block number - // again to resolve the value of "latest", but the block number is - // cached once made, so we only need to mock the request once. - comms.mockNextBlockTrackerRequest({ blockNumber: '0x100' }); - // The block-ref middleware will make the request as specified - // except that the block param is replaced with the latest block - // number. - // - // Here we have the request fail for the first 4 tries, then - // succeed on the 5th try. - comms.mockRpcCall({ - request: buildRequestWithReplacedBlockParam( - request, - blockParamIndex, - '0x100', - ), - error: 'ETIMEDOUT: Some message', - times: 4, - }); - comms.mockRpcCall({ - request: buildRequestWithReplacedBlockParam( - request, - blockParamIndex, - '0x100', - ), - response: { - result: 'the result', - httpStatus: 200, - }, - }); - - const result = await withNetworkClient( - { providerType }, - async ({ makeRpcCall, clock }) => { - return await waitForPromiseToBeFulfilledAfterRunningAllTimers( - makeRpcCall(request), - clock, - ); - }, - ); - - expect(result).toBe('the result'); - }); - }); - - // Both the Infura and fetch middleware detect ETIMEDOUT errors and will - // automatically retry the request to the RPC endpoint in question, but each - // produces a different error if the number of retries is exhausted. - if (providerType === 'infura') { - it('causes a request to fail with a custom error if an "ETIMEDOUT" error is thrown while making the request to the RPC endpoint 5 times in a row', async () => { - await withMockedCommunications({ providerType }, async (comms) => { - const request = { method }; - const errorMessage = 'ETIMEDOUT: Some message'; - - // The first time a block-cacheable request is made, the - // block-cache middleware will request the latest block number - // through the block tracker to determine the cache key. Later, - // the block-ref middleware will request the latest block number - // again to resolve the value of "latest", but the block number is - // cached once made, so we only need to mock the request once. - comms.mockNextBlockTrackerRequest({ blockNumber: '0x100' }); - // The block-ref middleware will make the request as specified - // except that the block param is replaced with the latest block - // number. - - comms.mockRpcCall({ - request: buildRequestWithReplacedBlockParam( - request, - blockParamIndex, - '0x100', - ), - error: errorMessage, - times: 5, - }); - - const promiseForResult = withNetworkClient( - { providerType }, - async ({ makeRpcCall, clock }) => { - return await waitForPromiseToBeFulfilledAfterRunningAllTimers( - makeRpcCall(request), - clock, - ); - }, - ); - - await expect(promiseForResult).rejects.toThrow( - buildInfuraClientRetriesExhaustedErrorMessage(errorMessage), - ); - }); - }); - } else { - it('produces an empty response if an "ETIMEDOUT" error is thrown while making the request to the RPC endpoint 5 times in a row', async () => { - await withMockedCommunications({ providerType }, async (comms) => { - const request = { - method, - params: buildMockParams({ blockParam, blockParamIndex }), - }; - const errorMessage = 'ETIMEDOUT: Some message'; - - // The first time a block-cacheable request is made, the - // block-cache middleware will request the latest block number - // through the block tracker to determine the cache key. Later, - // the block-ref middleware will request the latest block number - // again to resolve the value of "latest", but the block number is - // cached once made, so we only need to mock the request once. - comms.mockNextBlockTrackerRequest({ blockNumber: '0x100' }); - // The block-ref middleware will make the request as specified - // except that the block param is replaced with the latest block - // number. - - comms.mockRpcCall({ - request: buildRequestWithReplacedBlockParam( - request, - blockParamIndex, - '0x100', - ), - error: errorMessage, - times: 5, - }); - - const promiseForResult = withNetworkClient( - { providerType }, - async ({ makeRpcCall, clock }) => { - return await waitForPromiseToBeFulfilledAfterRunningAllTimers( - makeRpcCall(request), - clock, - ); - }, - ); - - await expect(promiseForResult).rejects.toThrow( - buildJsonRpcEngineEmptyResponseErrorMessage(method), - ); - }); - }); - } - - // The Infura middleware treats a response that contains an ECONNRESET - // message as an innocuous error that is likely to disappear on a retry. The - // custom RPC middleware, on the other hand, does not specially handle this - // error. - if (providerType === 'infura') { - it('retries the request to the RPC endpoint up to 5 times if an "ECONNRESET" error is thrown while making the request, returning the successful result if there is one on the 5th try', async () => { - await withMockedCommunications({ providerType }, async (comms) => { - const request = { - method, - params: buildMockParams({ blockParam, blockParamIndex }), - }; - - // The first time a block-cacheable request is made, the - // block-cache middleware will request the latest block number - // through the block tracker to determine the cache key. Later, - // the block-ref middleware will request the latest block number - // again to resolve the value of "latest", but the block number is - // cached once made, so we only need to mock the request once. - comms.mockNextBlockTrackerRequest({ blockNumber: '0x100' }); - // The block-ref middleware will make the request as specified - // except that the block param is replaced with the latest block - // number. - // - // Here we have the request fail for the first 4 tries, then - // succeed on the 5th try. - comms.mockRpcCall({ - request: buildRequestWithReplacedBlockParam( - request, - blockParamIndex, - '0x100', - ), - error: 'ECONNRESET: Some message', - times: 4, - }); - comms.mockRpcCall({ - request: buildRequestWithReplacedBlockParam( - request, - blockParamIndex, - '0x100', - ), - response: { - result: 'the result', - httpStatus: 200, - }, - }); - - const result = await withNetworkClient( - { providerType }, - async ({ makeRpcCall, clock }) => { - return await waitForPromiseToBeFulfilledAfterRunningAllTimers( - makeRpcCall(request), - clock, - ); - }, - ); - - expect(result).toBe('the result'); - }); - }); - - it('causes a request to fail with a custom error if an "ECONNRESET" error is thrown while making the request to the RPC endpoint 5 times in a row', async () => { - await withMockedCommunications({ providerType }, async (comms) => { - const request = { - method, - params: buildMockParams({ blockParam, blockParamIndex }), - }; - const errorMessage = 'ECONNRESET: Some message'; - - // The first time a block-cacheable request is made, the - // block-cache middleware will request the latest block number - // through the block tracker to determine the cache key. Later, - // the block-ref middleware will request the latest block number - // again to resolve the value of "latest", but the block number is - // cached once made, so we only need to mock the request once. - comms.mockNextBlockTrackerRequest({ blockNumber: '0x100' }); - // The block-ref middleware will make the request as specified - // except that the block param is replaced with the latest block - // number. - comms.mockRpcCall({ - request: buildRequestWithReplacedBlockParam( - request, - blockParamIndex, - '0x100', - ), - error: errorMessage, - times: 5, - }); - - const promiseForResult = withNetworkClient( - { providerType }, - async ({ makeRpcCall, clock }) => { - return await waitForPromiseToBeFulfilledAfterRunningAllTimers( - makeRpcCall(request), - clock, - ); - }, - ); - - await expect(promiseForResult).rejects.toThrow( - buildInfuraClientRetriesExhaustedErrorMessage(errorMessage), - ); - }); - }); - } else { - it('does not retry the request to the RPC endpoint, but throws immediately, if an "ECONNRESET" error is thrown while making the request', async () => { - const customRpcUrl = 'http://example.com'; - - await withMockedCommunications( - { providerType, customRpcUrl }, - async (comms) => { - const request = { - method, - params: buildMockParams({ blockParam, blockParamIndex }), - }; - const errorMessage = 'ECONNRESET: Some message'; - - // The first time a block-cacheable request is made, the - // block-cache middleware will request the latest block number - // through the block tracker to determine the cache key. Later, - // the block-ref middleware will request the latest block number - // again to resolve the value of "latest", but the block number is - // cached once made, so we only need to mock the request once. - comms.mockNextBlockTrackerRequest({ blockNumber: '0x100' }); - // The block-ref middleware will make the request as specified - // except that the block param is replaced with the latest block - // number. - comms.mockRpcCall({ - request: buildRequestWithReplacedBlockParam( - request, - blockParamIndex, - '0x100', - ), - error: errorMessage, - }); - - const promiseForResult = withNetworkClient( - { providerType, customRpcUrl }, - async ({ makeRpcCall }) => makeRpcCall(request), - ); - - await expect(promiseForResult).rejects.toThrow( - buildFetchFailedErrorMessage(customRpcUrl, errorMessage), - ); - }, - ); - }); - } - - // Both the Infura and fetch middleware will attempt to parse the response - // body as JSON, and if this step produces an error, both middleware will - // also attempt to retry the request. However, this error handling code is - // slightly different between the two. As the error in this case is a - // SyntaxError, the Infura middleware will catch it immediately, whereas the - // custom RPC middleware will catch it and re-throw a separate error, which - // it then catches later. - if (providerType === 'infura') { - it('retries the request to the RPC endpoint up to 5 times if a "SyntaxError" error is thrown while making the request, returning the successful result if there is one on the 5th try', async () => { - await withMockedCommunications({ providerType }, async (comms) => { - const request = { - method, - params: buildMockParams({ blockParam, blockParamIndex }), - }; - - // The first time a block-cacheable request is made, the - // block-cache middleware will request the latest block number - // through the block tracker to determine the cache key. Later, - // the block-ref middleware will request the latest block number - // again to resolve the value of "latest", but the block number is - // cached once made, so we only need to mock the request once. - comms.mockNextBlockTrackerRequest({ blockNumber: '0x100' }); - // The block-ref middleware will make the request as specified - // except that the block param is replaced with the latest block - // number. - // - // Here we have the request fail for the first 4 tries, then - // succeed on the 5th try. - comms.mockRpcCall({ - request: buildRequestWithReplacedBlockParam( - request, - blockParamIndex, - '0x100', - ), - error: 'SyntaxError: Some message', - times: 4, - }); - comms.mockRpcCall({ - request: buildRequestWithReplacedBlockParam( - request, - blockParamIndex, - '0x100', - ), - response: { - result: 'the result', - httpStatus: 200, - }, - }); - const result = await withNetworkClient( - { providerType }, - async ({ makeRpcCall, clock }) => { - return await waitForPromiseToBeFulfilledAfterRunningAllTimers( - makeRpcCall(request), - clock, - ); - }, - ); - - expect(result).toBe('the result'); - }); - }); - - it('causes a request to fail with a custom error if a "SyntaxError" error is thrown while making the request to the RPC endpoint 5 times in a row', async () => { - await withMockedCommunications({ providerType }, async (comms) => { - const request = { - method, - params: buildMockParams({ blockParam, blockParamIndex }), - }; - const errorMessage = 'SyntaxError: Some message'; - - // The first time a block-cacheable request is made, the - // block-cache middleware will request the latest block number - // through the block tracker to determine the cache key. Later, - // the block-ref middleware will request the latest block number - // again to resolve the value of "latest", but the block number is - // cached once made, so we only need to mock the request once. - comms.mockNextBlockTrackerRequest({ blockNumber: '0x100' }); - // The block-ref middleware will make the request as specified - // except that the block param is replaced with the latest block - // number. - comms.mockRpcCall({ - request: buildRequestWithReplacedBlockParam( - request, - blockParamIndex, - '0x100', - ), - error: errorMessage, - times: 5, - }); - - const promiseForResult = withNetworkClient( - { providerType }, - async ({ makeRpcCall, clock }) => { - return await waitForPromiseToBeFulfilledAfterRunningAllTimers( - makeRpcCall(request), - clock, - ); - }, - ); - - await expect(promiseForResult).rejects.toThrow( - buildInfuraClientRetriesExhaustedErrorMessage(errorMessage), - ); - }); - }); - - it('does not retry the request to the RPC endpoint, but throws immediately, if a "failed to parse response body" error is thrown while making the request', async () => { - await withMockedCommunications({ providerType }, async (comms) => { - const request = { - method, - params: buildMockParams({ blockParam, blockParamIndex }), - }; - const errorMessage = 'failed to parse response body: Some message'; - - // The first time a block-cacheable request is made, the - // block-cache middleware will request the latest block number - // through the block tracker to determine the cache key. Later, - // the block-ref middleware will request the latest block number - // again to resolve the value of "latest", but the block number is - // cached once made, so we only need to mock the request once. - comms.mockNextBlockTrackerRequest({ blockNumber: '0x100' }); - // The block-ref middleware will make the request as specified - // except that the block param is replaced with the latest block - // number. - comms.mockRpcCall({ - request: buildRequestWithReplacedBlockParam( - request, - blockParamIndex, - '0x100', - ), - error: errorMessage, - }); - - const promiseForResult = withNetworkClient( - { providerType, infuraNetwork: comms.infuraNetwork }, - async ({ makeRpcCall }) => makeRpcCall(request), - ); - - await expect(promiseForResult).rejects.toThrow( - buildFetchFailedErrorMessage(comms.rpcUrl, errorMessage), - ); - }); - }); - } else { - it('does not retry the request to the RPC endpoint, but throws immediately, if a "SyntaxError" error is thrown while making the request', async () => { - const customRpcUrl = 'http://example.com'; - - await withMockedCommunications( - { providerType, customRpcUrl }, - async (comms) => { - const request = { - method, - params: buildMockParams({ blockParam, blockParamIndex }), - }; - const errorMessage = 'SyntaxError: Some message'; - - // The first time a block-cacheable request is made, the - // block-cache middleware will request the latest block number - // through the block tracker to determine the cache key. Later, - // the block-ref middleware will request the latest block number - // again to resolve the value of "latest", but the block number is - // cached once made, so we only need to mock the request once. - comms.mockNextBlockTrackerRequest({ blockNumber: '0x100' }); - // The block-ref middleware will make the request as specified - // except that the block param is replaced with the latest block - // number. - comms.mockRpcCall({ - request: buildRequestWithReplacedBlockParam( - request, - blockParamIndex, - '0x100', - ), - error: errorMessage, - }); - - const promiseForResult = withNetworkClient( - { providerType, customRpcUrl }, - async ({ makeRpcCall }) => makeRpcCall(request), - ); - - await expect(promiseForResult).rejects.toThrow( - buildFetchFailedErrorMessage(customRpcUrl, errorMessage), - ); - }, - ); - }); - - it('retries the request to the RPC endpoint up to 5 times if a "failed to parse response body" error is thrown while making the request, returning the successful result if there is one on the 5th try', async () => { - await withMockedCommunications({ providerType }, async (comms) => { - const request = { - method, - params: buildMockParams({ blockParam, blockParamIndex }), - }; - - // The first time a block-cacheable request is made, the - // block-cache middleware will request the latest block number - // through the block tracker to determine the cache key. Later, - // the block-ref middleware will request the latest block number - // again to resolve the value of "latest", but the block number is - // cached once made, so we only need to mock the request once. - comms.mockNextBlockTrackerRequest({ blockNumber: '0x100' }); - // The block-ref middleware will make the request as specified - // except that the block param is replaced with the latest block - // number. - // - // Here we have the request fail for the first 4 tries, then - // succeed on the 5th try. - comms.mockRpcCall({ - request: buildRequestWithReplacedBlockParam( - request, - blockParamIndex, - '0x100', - ), - error: 'failed to parse response body: Some message', - times: 4, - }); - comms.mockRpcCall({ - request: buildRequestWithReplacedBlockParam( - request, - blockParamIndex, - '0x100', - ), - response: { - result: 'the result', - httpStatus: 200, - }, - }); - - const result = await withNetworkClient( - { providerType }, - async ({ makeRpcCall, clock }) => { - return await waitForPromiseToBeFulfilledAfterRunningAllTimers( - makeRpcCall(request), - clock, - ); - }, - ); - - expect(result).toBe('the result'); - }); - }); - - it('produces an empty response if a "failed to parse response body" error is thrown while making the request to the RPC endpoint 5 times in a row', async () => { - await withMockedCommunications({ providerType }, async (comms) => { - const request = { - method, - params: buildMockParams({ blockParam, blockParamIndex }), - }; - const errorMessage = 'failed to parse response body: some message'; - - // The first time a block-cacheable request is made, the - // block-cache middleware will request the latest block number - // through the block tracker to determine the cache key. Later, - // the block-ref middleware will request the latest block number - // again to resolve the value of "latest", but the block number is - // cached once made, so we only need to mock the request once. - comms.mockNextBlockTrackerRequest({ blockNumber: '0x100' }); - // The block-ref middleware will make the request as specified - // except that the block param is replaced with the latest block - // number. - comms.mockRpcCall({ - request: buildRequestWithReplacedBlockParam( - request, - blockParamIndex, - '0x100', - ), - error: errorMessage, - times: 5, - }); - const promiseForResult = withNetworkClient( - { providerType }, - async ({ makeRpcCall, clock }) => { - return await waitForPromiseToBeFulfilledAfterRunningAllTimers( - makeRpcCall(request), - clock, - ); - }, - ); - - await expect(promiseForResult).rejects.toThrow( - buildJsonRpcEngineEmptyResponseErrorMessage(method), - ); - }); - }); - } - - // Only the custom RPC middleware will detect a "Failed to fetch" error and - // attempt to retry the request to the RPC endpoint; the Infura middleware - // does not. - if (providerType === 'infura') { - it('does not retry the request to the RPC endpoint, but throws immediately, if a "Failed to fetch" error is thrown while making the request', async () => { - await withMockedCommunications({ providerType }, async (comms) => { - const request = { - method, - params: buildMockParams({ blockParam, blockParamIndex }), - }; - const errorMessage = 'Failed to fetch: Some message'; - - // The first time a block-cacheable request is made, the - // block-cache middleware will request the latest block number - // through the block tracker to determine the cache key. Later, - // the block-ref middleware will request the latest block number - // again to resolve the value of "latest", but the block number is - // cached once made, so we only need to mock the request once. - comms.mockNextBlockTrackerRequest({ blockNumber: '0x100' }); - // The block-ref middleware will make the request as specified - // except that the block param is replaced with the latest block - // number. - comms.mockRpcCall({ - request: buildRequestWithReplacedBlockParam( - request, - blockParamIndex, - '0x100', - ), - error: errorMessage, - }); - - const promiseForResult = withNetworkClient( - { providerType, infuraNetwork: comms.infuraNetwork }, - async ({ makeRpcCall }) => makeRpcCall(request), - ); - - await expect(promiseForResult).rejects.toThrow( - buildFetchFailedErrorMessage(comms.rpcUrl, errorMessage), - ); - }); - }); - } else { - it('retries the request to the RPC endpoint up to 5 times if a "Failed to fetch" error is thrown while making the request, returning the successful result if there is one on the 5th try', async () => { - await withMockedCommunications({ providerType }, async (comms) => { - const request = { - method, - params: buildMockParams({ blockParam, blockParamIndex }), - }; - - // The first time a block-cacheable request is made, the - // block-cache middleware will request the latest block number - // through the block tracker to determine the cache key. Later, - // the block-ref middleware will request the latest block number - // again to resolve the value of "latest", but the block number is - // cached once made, so we only need to mock the request once. - comms.mockNextBlockTrackerRequest({ blockNumber: '0x100' }); - // The block-ref middleware will make the request as specified - // except that the block param is replaced with the latest block - // number. - // - // Here we have the request fail for the first 4 tries, then - // succeed on the 5th try. - comms.mockRpcCall({ - request: buildRequestWithReplacedBlockParam( - request, - blockParamIndex, - '0x100', - ), - error: 'Failed to fetch: Some message', - times: 4, - }); - comms.mockRpcCall({ - request: buildRequestWithReplacedBlockParam( - request, - blockParamIndex, - '0x100', - ), - response: { - result: 'the result', - httpStatus: 200, - }, - }); - - const result = await withNetworkClient( - { providerType }, - async ({ makeRpcCall, clock }) => { - return await waitForPromiseToBeFulfilledAfterRunningAllTimers( - makeRpcCall(request), - clock, - ); - }, - ); - - expect(result).toBe('the result'); - }); - }); - - it('produces an empty response if a "Failed to fetch" error is thrown while making the request to the RPC endpoint 5 times in a row', async () => { - await withMockedCommunications({ providerType }, async (comms) => { - const request = { method }; - const errorMessage = 'Failed to fetch: some message'; - - // The first time a block-cacheable request is made, the - // block-cache middleware will request the latest block number - // through the block tracker to determine the cache key. Later, - // the block-ref middleware will request the latest block number - // again to resolve the value of "latest", but the block number is - // cached once made, so we only need to mock the request once. - comms.mockNextBlockTrackerRequest({ blockNumber: '0x100' }); - // The block-ref middleware will make the request as specified - // except that the block param is replaced with the latest block - // number. - comms.mockRpcCall({ - request: buildRequestWithReplacedBlockParam( - request, - blockParamIndex, - '0x100', - ), - error: errorMessage, - times: 5, - }); - const promiseForResult = withNetworkClient( - { providerType }, - async ({ makeRpcCall, clock }) => { - return await waitForPromiseToBeFulfilledAfterRunningAllTimers( - makeRpcCall(request), - clock, - ); - }, - ); - - await expect(promiseForResult).rejects.toThrow( - buildJsonRpcEngineEmptyResponseErrorMessage(method), - ); - }); - }); - } - }); - - describe.each([ - ['given a block tag of "earliest"', 'earliest', 'earliest'], - ['given a block number', 'block number', '0x100'], - ])('%s', (_desc, blockParamType, blockParam) => { - // This lint rule gets confused by `describe.each` - // eslint-disable-next-line jest/no-identical-title - it('does not hit the RPC endpoint more than once for identical requests', async () => { - const requests = [ - { - method, - params: buildMockParams({ blockParamIndex, blockParam }), - }, - { - method, - params: buildMockParams({ blockParamIndex, blockParam }), - }, - ]; - const mockResults = ['first result', 'second result']; - - await withMockedCommunications({ providerType }, async (comms) => { - // The first time a block-cacheable request is made, the block-cache - // middleware will request the latest block number through the block - // tracker to determine the cache key. This block number doesn't - // matter. - comms.mockNextBlockTrackerRequest(); - comms.mockRpcCall({ - request: requests[0], - response: { result: mockResults[0] }, - }); - - const results = await withNetworkClient( - { providerType }, - ({ makeRpcCallsInSeries }) => makeRpcCallsInSeries(requests), - ); - - expect(results).toStrictEqual([mockResults[0], mockResults[0]]); - }); - }); - - for (const paramIndex of [...Array(numberOfParameters).keys()]) { - if (paramIndex === blockParamIndex) { - // testing changes in block param is covered under later tests - continue; - } - - it(`does not reuse the result of a previous request if parameter at index "${paramIndex}" differs`, async () => { - const firstMockParams = [ - ...new Array(numberOfParameters).fill('some value'), - ]; - firstMockParams[blockParamIndex] = blockParam; - const secondMockParams = firstMockParams.slice(); - secondMockParams[paramIndex] = 'another value'; - const requests = [ - { - method, - params: firstMockParams, - }, - { method, params: secondMockParams }, - ]; - const mockResults = ['first result', 'second result']; - - await withMockedCommunications({ providerType }, async (comms) => { - // The first time a block-cacheable request is made, the block-cache - // middleware will request the latest block number through the block - // tracker to determine the cache key. Later, the block-ref - // middleware will request the latest block number again to resolve - // the value of "latest", but the block number is cached once made, - // so we only need to mock the request once. - comms.mockNextBlockTrackerRequest({ blockNumber: '0x100' }); - // The block-ref middleware will make the request as specified - // except that the block param is replaced with the latest block - // number. - comms.mockRpcCall({ - request: requests[0], - response: { result: mockResults[0] }, - }); - comms.mockRpcCall({ - request: requests[1], - response: { result: mockResults[1] }, - }); - - const results = await withNetworkClient( - { providerType }, - ({ makeRpcCallsInSeries }) => makeRpcCallsInSeries(requests), - ); - - expect(results).toStrictEqual([mockResults[0], mockResults[1]]); - }); - }); - } - - it('reuses the result of a previous request even if the latest block number was updated since', async () => { - const requests = [ - { - method, - params: buildMockParams({ blockParamIndex, blockParam }), - }, - { - method, - params: buildMockParams({ blockParamIndex, blockParam }), - }, - ]; - const mockResults = ['first result', 'second result']; - - await withMockedCommunications({ providerType }, async (comms) => { - // Note that we have to mock these requests in a specific order. The - // first block tracker request occurs because of the first RPC - // request. The second block tracker request, however, does not - // occur because of the second RPC request, but rather because we - // call `clock.runAll()` below. - comms.mockNextBlockTrackerRequest({ blockNumber: '0x1' }); - comms.mockRpcCall({ - request: requests[0], - response: { result: mockResults[0] }, - }); - comms.mockNextBlockTrackerRequest({ blockNumber: '0x2' }); - comms.mockRpcCall({ - request: requests[1], - response: { result: mockResults[1] }, - }); - - const results = await withNetworkClient( - { providerType }, - async (client) => { - const firstResult = await client.makeRpcCall(requests[0]); - // Proceed to the next iteration of the block tracker so that a - // new block is fetched and the current block is updated. - client.clock.runAll(); - const secondResult = await client.makeRpcCall(requests[1]); - return [firstResult, secondResult]; - }, - ); - - expect(results).toStrictEqual([mockResults[0], mockResults[0]]); - }); - }); - - if (blockParamType === 'earliest') { - it('treats "0x00" as a synonym for "earliest"', async () => { - const requests = [ - { - method, - params: buildMockParams({ blockParamIndex, blockParam }), - }, - { - method, - params: buildMockParams({ blockParamIndex, blockParam: '0x00' }), - }, - ]; - const mockResults = ['first result', 'second result']; - - await withMockedCommunications({ providerType }, async (comms) => { - // The first time a block-cacheable request is made, the latest - // block number is retrieved through the block tracker first. It - // doesn't matter what this is — it's just used as a cache key. - comms.mockNextBlockTrackerRequest(); - comms.mockRpcCall({ - request: requests[0], - response: { result: mockResults[0] }, - }); - - const results = await withNetworkClient( - { providerType }, - ({ makeRpcCallsInSeries }) => makeRpcCallsInSeries(requests), - ); - - expect(results).toStrictEqual([mockResults[0], mockResults[0]]); - }); - }); - - for (const emptyValue of [null, undefined, '\u003cnil\u003e']) { - it(`does not retry an empty response of "${emptyValue}"`, async () => { - const request = { - method, - params: buildMockParams({ blockParamIndex, blockParam }), - }; - const mockResult = emptyValue; - - await withMockedCommunications({ providerType }, async (comms) => { - // The first time a block-cacheable request is made, the latest block - // number is retrieved through the block tracker first. It doesn't - // matter what this is — it's just used as a cache key. - comms.mockNextBlockTrackerRequest(); - comms.mockRpcCall({ - request, - response: { result: mockResult }, - }); - - const result = await withNetworkClient( - { providerType }, - ({ makeRpcCall }) => makeRpcCall(request), - ); - - expect(result).toStrictEqual(mockResult); - }); - }); - - it(`does not reuse the result of a previous request if it was "${emptyValue}"`, async () => { - const requests = [ - { - method, - params: buildMockParams({ blockParamIndex, blockParam }), - }, - { - method, - params: buildMockParams({ blockParamIndex, blockParam }), - }, - ]; - const mockResults = [emptyValue, 'some result']; - - await withMockedCommunications({ providerType }, async (comms) => { - // The first time a block-cacheable request is made, the latest block - // number is retrieved through the block tracker first. It doesn't - // matter what this is — it's just used as a cache key. - comms.mockNextBlockTrackerRequest(); - comms.mockRpcCall({ - request: requests[0], - response: { result: mockResults[0] }, - }); - comms.mockRpcCall({ - request: requests[1], - response: { result: mockResults[1] }, - }); - - const results = await withNetworkClient( - { providerType }, - ({ makeRpcCallsInSeries }) => makeRpcCallsInSeries(requests), - ); - - expect(results).toStrictEqual(mockResults); - }); - }); - } - } - - if (blockParamType === 'block number') { - it('does not reuse the result of a previous request if it was made with different arguments than this one', async () => { - await withMockedCommunications({ providerType }, async (comms) => { - const requests = [ - { - method, - params: buildMockParams({ blockParamIndex, blockParam: '0x100' }), - }, - { - method, - params: buildMockParams({ blockParamIndex, blockParam: '0x200' }), - }, - ]; - - // The first time a block-cacheable request is made, the latest block - // number is retrieved through the block tracker first. It doesn't - // matter what this is — it's just used as a cache key. - comms.mockNextBlockTrackerRequest(); - comms.mockRpcCall({ - request: requests[0], - response: { result: 'first result' }, - }); - comms.mockRpcCall({ - request: requests[1], - response: { result: 'second result' }, - }); - - const results = await withNetworkClient( - { providerType }, - ({ makeRpcCallsInSeries }) => makeRpcCallsInSeries(requests), - ); - - expect(results).toStrictEqual(['first result', 'second result']); - }); - }); - - for (const [nestedDesc, currentBlockNumber] of [ - ['less than the current block number', '0x200'], - ['equal to the curent block number', '0x100'], - ]) { - describe(`${nestedDesc}`, () => { - it('makes an additional request to the RPC endpoint', async () => { - await withMockedCommunications({ providerType }, async (comms) => { - const request = { - method, - // Note that `blockParam` is `0x100` here - params: buildMockParams({ blockParamIndex, blockParam }), - }; - - // The first time a block-cacheable request is made, the latest - // block number is retrieved through the block tracker first. - comms.mockNextBlockTrackerRequest({ - blockNumber: currentBlockNumber, - }); - comms.mockRpcCall({ - request, - response: { result: 'the result' }, - }); - - const result = await withNetworkClient( - { providerType }, - ({ makeRpcCall }) => makeRpcCall(request), - ); - - expect(result).toBe('the result'); - }); - }); - - for (const emptyValue of [null, undefined, '\u003cnil\u003e']) { - if (providerType === 'infura') { - it(`retries up to 10 times if a "${emptyValue}" response is returned, returning successful non-empty response if there is one on the 10th try`, async () => { - const request = { - method, - // Note that `blockParam` is `0x100` here - params: buildMockParams({ blockParamIndex, blockParam }), - }; - - await withMockedCommunications( - { providerType }, - async (comms) => { - // The first time a block-cacheable request is made, the latest block - // number is retrieved through the block tracker first. - comms.mockNextBlockTrackerRequest({ - blockNumber: currentBlockNumber, - }); - comms.mockRpcCall({ - request, - response: { result: emptyValue }, - times: 9, - }); - comms.mockRpcCall({ - request, - response: { result: 'some value' }, - }); - - const result = await withNetworkClient( - { providerType }, - ({ makeRpcCall, clock }) => - waitForPromiseToBeFulfilledAfterRunningAllTimers( - makeRpcCall(request), - clock, - ), - ); - - expect(result).toBe('some value'); - }, - ); - }); - - it(`retries up to 10 times if a "${emptyValue}" response is returned, failing after the 10th try`, async () => { - const request = { - method, - // Note that `blockParam` is `0x100` here - params: buildMockParams({ blockParamIndex, blockParam }), - }; - const mockResult = emptyValue; - - await withMockedCommunications( - { providerType }, - async (comms) => { - // The first time a block-cacheable request is made, the latest block - // number is retrieved through the block tracker first. - comms.mockNextBlockTrackerRequest({ - blockNumber: currentBlockNumber, - }); - comms.mockRpcCall({ - request, - response: { result: mockResult }, - times: 10, - }); - - const promiseForResult = withNetworkClient( - { providerType }, - ({ makeRpcCall, clock }) => - waitForPromiseToBeFulfilledAfterRunningAllTimers( - makeRpcCall(request), - clock, - ), - ); - - await expect(promiseForResult).rejects.toThrow( - 'RetryOnEmptyMiddleware - retries exhausted', - ); - }, - ); - }); - } else { - it(`does not retry an empty response of "${emptyValue}"`, async () => { - const request = { - method, - // Note that `blockParam` is `0x100` here - params: buildMockParams({ blockParamIndex, blockParam }), - }; - const mockResult = emptyValue; - - await withMockedCommunications( - { providerType }, - async (comms) => { - // The first time a block-cacheable request is made, the latest block - // number is retrieved through the block tracker first. - comms.mockNextBlockTrackerRequest({ - blockNumber: currentBlockNumber, - }); - comms.mockRpcCall({ - request: buildRequestWithReplacedBlockParam( - request, - blockParamIndex, - '0x100', - ), - response: { result: mockResult }, - }); - - const result = await withNetworkClient( - { providerType }, - ({ makeRpcCall }) => makeRpcCall(request), - ); - - expect(result).toStrictEqual(mockResult); - }, - ); - }); - - it(`does not reuse the result of a previous request if it was "${emptyValue}"`, async () => { - const requests = [ - { - method, - // Note that `blockParam` is `0x100` here - params: buildMockParams({ blockParamIndex, blockParam }), - }, - { - method, - // Note that `blockParam` is `0x100` here - params: buildMockParams({ blockParamIndex, blockParam }), - }, - ]; - const mockResults = [emptyValue, { blockHash: '0x100' }]; - - await withMockedCommunications( - { providerType }, - async (comms) => { - // The first time a block-cacheable request is made, the latest block - // number is retrieved through the block tracker first. - comms.mockNextBlockTrackerRequest({ - blockNumber: currentBlockNumber, - }); - comms.mockRpcCall({ - request: buildRequestWithReplacedBlockParam( - requests[0], - blockParamIndex, - '0x100', - ), - response: { result: mockResults[0] }, - }); - comms.mockRpcCall({ - request: buildRequestWithReplacedBlockParam( - requests[1], - blockParamIndex, - '0x100', - ), - response: { result: mockResults[1] }, - }); - - const results = await withNetworkClient( - { providerType }, - ({ makeRpcCallsInSeries }) => - makeRpcCallsInSeries(requests), - ); - - expect(results).toStrictEqual(mockResults); - }, - ); - }); - } - } - }); - } - - describe('greater than the current block number', () => { - it('makes an additional request to the RPC endpoint', async () => { - await withMockedCommunications({ providerType }, async (comms) => { - const request = { - method, - // Note that `blockParam` is `0x100` here - params: buildMockParams({ blockParamIndex, blockParam }), - }; - - // The first time a block-cacheable request is made, the latest - // block number is retrieved through the block tracker first. - comms.mockNextBlockTrackerRequest({ blockNumber: '0x42' }); - comms.mockRpcCall({ - request, - response: { result: 'the result' }, - }); - - const result = await withNetworkClient( - { providerType }, - ({ makeRpcCall }) => makeRpcCall(request), - ); - - expect(result).toBe('the result'); - }); - }); - - for (const emptyValue of [null, undefined, '\u003cnil\u003e']) { - it(`does not retry an empty response of "${emptyValue}"`, async () => { - const request = { - method, - // Note that `blockParam` is `0x100` here - params: buildMockParams({ blockParamIndex, blockParam }), - }; - const mockResult = emptyValue; - - await withMockedCommunications({ providerType }, async (comms) => { - // The first time a block-cacheable request is made, the latest block - // number is retrieved through the block tracker first. - comms.mockNextBlockTrackerRequest({ blockNumber: '0x42' }); - comms.mockRpcCall({ - request: buildRequestWithReplacedBlockParam( - request, - blockParamIndex, - '0x100', - ), - response: { result: mockResult }, - }); - - const result = await withNetworkClient( - { providerType }, - ({ makeRpcCall }) => makeRpcCall(request), - ); - - expect(result).toStrictEqual(mockResult); - }); - }); - - it(`does not reuse the result of a previous request if it was "${emptyValue}"`, async () => { - const requests = [ - { - method, - // Note that `blockParam` is `0x100` here - params: buildMockParams({ blockParamIndex, blockParam }), - }, - { - method, - // Note that `blockParam` is `0x100` here - params: buildMockParams({ blockParamIndex, blockParam }), - }, - ]; - const mockResults = [emptyValue, { blockHash: '0x100' }]; - - await withMockedCommunications({ providerType }, async (comms) => { - // The first time a block-cacheable request is made, the latest block - // number is retrieved through the block tracker first. - comms.mockNextBlockTrackerRequest({ blockNumber: '0x42' }); - comms.mockRpcCall({ - request: buildRequestWithReplacedBlockParam( - requests[0], - blockParamIndex, - '0x100', - ), - response: { result: mockResults[0] }, - }); - comms.mockRpcCall({ - request: buildRequestWithReplacedBlockParam( - requests[1], - blockParamIndex, - '0x100', - ), - response: { result: mockResults[1] }, - }); - - const results = await withNetworkClient( - { providerType }, - ({ makeRpcCallsInSeries }) => makeRpcCallsInSeries(requests), - ); - - expect(results).toStrictEqual(mockResults); - }); - }); - } - }); - } - }); - - describe('given a block tag of "pending"', () => { - const params = buildMockParams({ blockParamIndex, blockParam: 'pending' }); - - it('hits the RPC endpoint on all calls and does not cache anything', async () => { - const requests = [ - { method, params }, - { method, params }, - ]; - const mockResults = ['first result', 'second result']; - - await withMockedCommunications({ providerType }, async (comms) => { - // The first time a block-cacheable request is made, the latest - // block number is retrieved through the block tracker first. It - // doesn't matter what this is — it's just used as a cache key. - comms.mockNextBlockTrackerRequest(); - comms.mockRpcCall({ - request: requests[0], - response: { result: mockResults[0] }, - }); - comms.mockRpcCall({ - request: requests[1], - response: { result: mockResults[1] }, - }); - - const results = await withNetworkClient( - { providerType }, - ({ makeRpcCallsInSeries }) => makeRpcCallsInSeries(requests), - ); - - expect(results).toStrictEqual(mockResults); - }); - }); - }); -} diff --git a/packages/network-controller/tests/provider-api-tests/helpers.ts b/packages/network-controller/tests/provider-api-tests/helpers.ts deleted file mode 100644 index bd1730dd7ce..00000000000 --- a/packages/network-controller/tests/provider-api-tests/helpers.ts +++ /dev/null @@ -1,544 +0,0 @@ -import type { JSONRPCResponse } from '@json-rpc-specification/meta-schema'; -import type { InfuraNetworkType } from '@metamask/controller-utils'; -import { BUILT_IN_NETWORKS } from '@metamask/controller-utils'; -import EthQuery from '@metamask/eth-query'; -import type { Hex } from '@metamask/utils'; -import nock from 'nock'; -import type { Scope as NockScope } from 'nock'; -import sinon from 'sinon'; - -import { createNetworkClient } from '../../src/create-network-client'; -import { NetworkClientType } from '../../src/types'; - -/** - * A dummy value for the `infuraProjectId` option that `createInfuraClient` - * needs. (Infura should not be hit during tests, but just in case, this should - * not refer to a real project ID.) - */ -const MOCK_INFURA_PROJECT_ID = 'abc123'; - -/** - * A dummy value for the `rpcUrl` option that `createJsonRpcClient` needs. (This - * should not be hit during tests, but just in case, this should also not refer - * to a real Infura URL.) - */ -const MOCK_RPC_URL = 'http://foo.com'; - -/** - * A default value for the `eth_blockNumber` request that the block tracker - * makes. - */ -const DEFAULT_LATEST_BLOCK_NUMBER = '0x42'; - -/** - * A reference to the original `setTimeout` function so that we can use it even - * when using fake timers. - */ -const originalSetTimeout = setTimeout; - -/** - * If you're having trouble writing a test and you're wondering why the test - * keeps failing, you can set `process.env.DEBUG_PROVIDER_TESTS` to `1`. This - * will turn on some extra logging. - * - * @param args - The arguments that `console.log` takes. - */ -function debug(...args: any) { - /* eslint-disable-next-line n/no-process-env */ - if (process.env.DEBUG_PROVIDER_TESTS === '1') { - console.log(...args); - } -} - -/** - * Builds a Nock scope object for mocking provider requests. - * - * @param rpcUrl - The URL of the RPC endpoint. - * @returns The nock scope. - */ -function buildScopeForMockingRequests(rpcUrl: string): NockScope { - return nock(rpcUrl).filteringRequestBody((body) => { - debug('Nock Received Request: ', body); - return body; - }); -} - -type Request = { method: string; params?: any[] }; -type Response = { - id?: number | string; - jsonrpc?: '2.0'; - error?: any; - result?: any; - httpStatus?: number; -}; -type ResponseBody = { body: JSONRPCResponse }; -type BodyOrResponse = ResponseBody | Response; -type CurriedMockRpcCallOptions = { - request: Request; - // The response data. - response?: BodyOrResponse; - /** - * An error to throw while making the request. - * Takes precedence over `response`. - */ - error?: Error | string; - /** - * The amount of time that should pass before the - * request resolves with the response. - */ - delay?: number; - /** - * The number of times that the request is - * expected to be made. - */ - times?: number; -}; - -type MockRpcCallOptions = { - // A nock scope (a set of mocked requests scoped to a certain base URL). - nockScope: nock.Scope; -} & CurriedMockRpcCallOptions; - -type MockRpcCallResult = nock.Interceptor | nock.Scope; - -/** - * Mocks a JSON-RPC request sent to the provider with the given response. - * Provider type is inferred from the base url set on the nockScope. - * - * @param args - The arguments. - * @param args.nockScope - A nock scope (a set of mocked requests scoped to a - * certain base URL). - * @param args.request - The request data. - * @param args.response - Information concerning the response that the request - * should have. If a `body` property is present, this is taken as the complete - * response body. If an `httpStatus` property is present, then it is taken as - * the HTTP status code to respond with. Properties other than these two are - * used to build a complete response body (including `id` and `jsonrpc` - * properties). - * @param args.error - An error to throw while making the request. Takes - * precedence over `response`. - * @param args.delay - The amount of time that should pass before the request - * resolves with the response. - * @param args.times - The number of times that the request is expected to be - * made. - * @returns The nock scope. - */ -function mockRpcCall({ - nockScope, - request, - response, - error, - delay, - times, -}: MockRpcCallOptions): MockRpcCallResult { - // eth-query always passes `params`, so even if we don't supply this property, - // for consistency with makeRpcCall, assume that the `body` contains it - const { method, params = [], ...rest } = request; - let httpStatus = 200; - let completeResponse: JSONRPCResponse = { id: 2, jsonrpc: '2.0' }; - if (response !== undefined) { - if ('body' in response) { - completeResponse = response.body; - } else { - if (response.error) { - completeResponse.error = response.error; - } else { - completeResponse.result = response.result; - } - if (response.httpStatus) { - httpStatus = response.httpStatus; - } - } - } - /* @ts-expect-error The types for Nock do not include `basePath` in the interface for Nock.Scope. */ - const url = nockScope.basePath.includes('infura.io') - ? `/v3/${MOCK_INFURA_PROJECT_ID}` - : '/'; - - debug('Mocking request:', { - url, - method, - params, - response, - error, - ...rest, - times, - }); - - let nockRequest = nockScope.post(url, { - id: /\d*/u, - jsonrpc: '2.0', - method, - params, - ...rest, - }); - - if (delay !== undefined) { - nockRequest = nockRequest.delay(delay); - } - - if (times !== undefined) { - nockRequest = nockRequest.times(times); - } - - if (error !== undefined) { - return nockRequest.replyWithError(error); - } else if (completeResponse !== undefined) { - return nockRequest.reply(httpStatus, (_, requestBody: any) => { - if (response !== undefined && !('body' in response)) { - if (response.id === undefined) { - completeResponse.id = requestBody.id; - } else { - completeResponse.id = response.id; - } - } - debug('Nock returning Response', completeResponse); - return completeResponse; - }); - } - return nockRequest; -} - -type MockBlockTrackerRequestOptions = { - /** - * A nock scope (a set of mocked requests scoped to a certain base url). - */ - nockScope: NockScope; - /** - * The block number that the block tracker should report, as a 0x-prefixed hex - * string. - */ - blockNumber: string; -}; - -/** - * Mocks the next request for the latest block that the block tracker will make. - * - * @param args - The arguments. - * @param args.nockScope - A nock scope (a set of mocked requests scoped to a - * certain base URL). - * @param args.blockNumber - The block number that the block tracker should - * report, as a 0x-prefixed hex string. - */ -function mockNextBlockTrackerRequest({ - nockScope, - blockNumber = DEFAULT_LATEST_BLOCK_NUMBER, -}: MockBlockTrackerRequestOptions) { - mockRpcCall({ - nockScope, - request: { method: 'eth_blockNumber', params: [] }, - response: { result: blockNumber }, - }); -} - -/** - * Mocks all requests for the latest block that the block tracker will make. - * - * @param args - The arguments. - * @param args.nockScope - A nock scope (a set of mocked requests scoped to a - * certain base URL). - * @param args.blockNumber - The block number that the block tracker should - * report, as a 0x-prefixed hex string. - */ -async function mockAllBlockTrackerRequests({ - nockScope, - blockNumber = DEFAULT_LATEST_BLOCK_NUMBER, -}: MockBlockTrackerRequestOptions) { - const result = await mockRpcCall({ - nockScope, - request: { method: 'eth_blockNumber', params: [] }, - response: { result: blockNumber }, - }); - - if ('persist' in result) { - result.persist(); - } -} - -/** - * Makes a JSON-RPC call through the given eth-query object. - * - * @param ethQuery - The eth-query object. - * @param request - The request data. - * @returns A promise that either resolves with the result from the JSON-RPC - * response if it is successful or rejects with the error from the JSON-RPC - * response otherwise. - */ -function makeRpcCall(ethQuery: EthQuery, request: Request) { - return new Promise((resolve, reject) => { - debug('[makeRpcCall] making request', request); - ethQuery.sendAsync(request, (error: any, result: any) => { - debug('[makeRpcCall > ethQuery handler] error', error, 'result', result); - if (error) { - reject(error); - } else { - resolve(result); - } - }); - }); -} - -export type ProviderType = 'infura' | 'custom'; - -export type MockOptions = { - infuraNetwork?: InfuraNetworkType; - providerType: ProviderType; - customRpcUrl?: string; - customChainId?: Hex; - customTicker?: string; -}; - -export type MockCommunications = { - mockNextBlockTrackerRequest: (options?: any) => void; - mockAllBlockTrackerRequests: (options?: any) => void; - mockRpcCall: (options: CurriedMockRpcCallOptions) => MockRpcCallResult; - rpcUrl: string; - infuraNetwork: InfuraNetworkType; -}; - -/** - * Sets up request mocks for requests to the provider. - * - * @param options - An options bag. - * @param options.providerType - The type of network client being tested. - * @param options.infuraNetwork - The name of the Infura network being tested, - * assuming that `providerType` is "infura" (default: "mainnet"). - * @param options.customRpcUrl - The URL of the custom RPC endpoint, assuming - * that `providerType` is "custom". - * @param fn - A function which will be called with an object that allows - * interaction with the network client. - * @returns The return value of the given function. - */ -export async function withMockedCommunications( - { - providerType, - infuraNetwork = 'mainnet', - customRpcUrl = MOCK_RPC_URL, - }: MockOptions, - fn: (comms: MockCommunications) => Promise, -) { - const rpcUrl = - providerType === 'infura' - ? `https://${infuraNetwork}.infura.io` - : customRpcUrl; - const nockScope = buildScopeForMockingRequests(rpcUrl); - const curriedMockNextBlockTrackerRequest = (localOptions: any) => - mockNextBlockTrackerRequest({ nockScope, ...localOptions }); - const curriedMockAllBlockTrackerRequests = (localOptions: any) => - mockAllBlockTrackerRequests({ nockScope, ...localOptions }); - const curriedMockRpcCall = (localOptions: any) => - mockRpcCall({ nockScope, ...localOptions }); - - const comms = { - mockNextBlockTrackerRequest: curriedMockNextBlockTrackerRequest, - mockAllBlockTrackerRequests: curriedMockAllBlockTrackerRequests, - mockRpcCall: curriedMockRpcCall, - rpcUrl, - infuraNetwork, - }; - - try { - return await fn(comms); - } finally { - nock.isDone(); - } -} - -type MockNetworkClient = { - blockTracker: any; - clock: sinon.SinonFakeTimers; - makeRpcCall: (request: Request) => Promise; - makeRpcCallsInSeries: (requests: Request[]) => Promise; -}; - -/** - * Some middleware contain logic which retries the request if some condition - * applies. This retrying always happens out of band via `setTimeout`, and - * because we are stubbing time via Jest's fake timers, we have to manually - * advance the clock so that the `setTimeout` handlers get fired. We don't know - * when these timers will get created, however, so we have to keep advancing - * timers until the request has been made an appropriate number of times. - * Unfortunately we don't have a good way to know how many times a request has - * been retried, but the good news is that the middleware won't end, and thus - * the promise which the RPC call returns won't get fulfilled, until all retries - * have been made. - * - * @param promise - The promise which is returned by the RPC call. - * @param clock - A Sinon clock object which can be used to advance to the next - * `setTimeout` handler. - * @returns The given promise. - */ -export async function waitForPromiseToBeFulfilledAfterRunningAllTimers( - promise: any, - clock: any, -) { - let hasPromiseBeenFulfilled = false; - let numTimesClockHasBeenAdvanced = 0; - - promise - .catch((error: any) => { - // This is used to silence Node.js warnings about the rejection - // being handled asynchronously. The error is handled later when - // `promise` is awaited, but we log it here anyway in case it gets - // swallowed. - debug(error); - }) - .finally(() => { - hasPromiseBeenFulfilled = true; - }); - - // `hasPromiseBeenFulfilled` is modified asynchronously. - /* eslint-disable-next-line no-unmodified-loop-condition */ - while (!hasPromiseBeenFulfilled && numTimesClockHasBeenAdvanced < 15) { - clock.runAll(); - await new Promise((resolve) => originalSetTimeout(resolve, 10)); - numTimesClockHasBeenAdvanced += 1; - } - - return promise; -} - -/** - * Builds a provider from the middleware (for the provider type) along with a - * block tracker, runs the given function with those two things, and then - * ensures the block tracker is stopped at the end. - * - * @param options - An options bag. - * @param options.providerType - The type of network client being tested. - * @param options.infuraNetwork - The name of the Infura network being tested, - * assuming that `providerType` is "infura" (default: "mainnet"). - * @param options.customRpcUrl - The URL of the custom RPC endpoint, assuming - * that `providerType` is "custom". - * @param options.customChainId - The chain id belonging to the custom RPC - * endpoint, assuming that `providerType` is "custom" (default: "0x1"). - * @param options.customTicker - The ticker of the custom RPC endpoint, assuming - * that `providerType` is "custom" (default: "ETH"). - * @param fn - A function which will be called with an object that allows - * interaction with the network client. - * @returns The return value of the given function. - */ -export async function withNetworkClient( - { - providerType, - infuraNetwork = 'mainnet', - customRpcUrl = MOCK_RPC_URL, - customChainId = '0x1', - customTicker = 'ETH', - }: MockOptions, - fn: (client: MockNetworkClient) => Promise, -) { - // Faking timers ends up doing two things: - // 1. Halting the block tracker (which depends on `setTimeout` to periodically - // request the latest block) set up in `eth-json-rpc-middleware` - // 2. Halting the retry logic in `@metamask/eth-json-rpc-infura` (which also - // depends on `setTimeout`) - const clock = sinon.useFakeTimers(); - - // The JSON-RPC client wraps `eth_estimateGas` so that it takes 2 seconds longer - // than it usually would to complete. Or at least it should — this doesn't - // appear to be working correctly. Unset `IN_TEST` on `process.env` to prevent - // this behavior. - /* eslint-disable-next-line n/no-process-env */ - const inTest = process.env.IN_TEST; - /* eslint-disable-next-line n/no-process-env */ - delete process.env.IN_TEST; - const clientUnderTest = - providerType === 'infura' - ? createNetworkClient({ - network: infuraNetwork, - infuraProjectId: MOCK_INFURA_PROJECT_ID, - type: NetworkClientType.Infura, - chainId: BUILT_IN_NETWORKS[infuraNetwork].chainId, - ticker: BUILT_IN_NETWORKS[infuraNetwork].ticker, - }) - : createNetworkClient({ - chainId: customChainId, - rpcUrl: customRpcUrl, - type: NetworkClientType.Custom, - ticker: customTicker, - }); - /* eslint-disable-next-line n/no-process-env */ - process.env.IN_TEST = inTest; - - const { provider, blockTracker } = clientUnderTest; - - // @ts-expect-error TODO: Provider type alignment - const ethQuery = new EthQuery(provider); - const curriedMakeRpcCall = (request: Request) => - makeRpcCall(ethQuery, request); - const makeRpcCallsInSeries = async (requests: Request[]) => { - const responses = []; - for (const request of requests) { - responses.push(await curriedMakeRpcCall(request)); - } - return responses; - }; - - const client = { - blockTracker, - clock, - makeRpcCall: curriedMakeRpcCall, - makeRpcCallsInSeries, - }; - - try { - return await fn(client); - } finally { - await blockTracker.destroy(); - - clock.restore(); - } -} - -type BuildMockParamsOptions = { - // The block parameter value to set. - blockParam: any; - // The index of the block parameter. - blockParamIndex: number; -}; - -/** - * Build mock parameters for a JSON-RPC call. - * - * The string 'some value' is used as the default value for each entry. The - * block parameter index determines the number of parameters to generate. - * - * The block parameter can be set to a custom value. If no value is given, it - * is set as undefined. - * - * @param args - Arguments. - * @param args.blockParamIndex - The index of the block parameter. - * @param args.blockParam - The block parameter value to set. - * @returns The mock params. - */ -export function buildMockParams({ - blockParam, - blockParamIndex, -}: BuildMockParamsOptions) { - const params = new Array(blockParamIndex).fill('some value'); - params[blockParamIndex] = blockParam; - - return params; -} - -/** - * Returns a partial JSON-RPC request object, with the "block" param replaced - * with the given value. - * - * @param request - The request object. - * @param request.method - The request method. - * @param request.params - The request params. - * @param blockParamIndex - The index within the `params` array of the block - * param. - * @param blockParam - The desired block param value. - * @returns The updated request object. - */ -export function buildRequestWithReplacedBlockParam( - { method, params = [] }: Request, - blockParamIndex: number, - blockParam: any, -) { - const updatedParams = params.slice(); - updatedParams[blockParamIndex] = blockParam; - return { method, params: updatedParams }; -} diff --git a/packages/network-controller/tests/provider-api-tests/no-block-param.ts b/packages/network-controller/tests/provider-api-tests/no-block-param.ts deleted file mode 100644 index d9fb35c4b18..00000000000 --- a/packages/network-controller/tests/provider-api-tests/no-block-param.ts +++ /dev/null @@ -1,970 +0,0 @@ -import type { ProviderType } from './helpers'; -import { - waitForPromiseToBeFulfilledAfterRunningAllTimers, - withMockedCommunications, - withNetworkClient, -} from './helpers'; -import { - buildFetchFailedErrorMessage, - buildInfuraClientRetriesExhaustedErrorMessage, - buildJsonRpcEngineEmptyResponseErrorMessage, -} from './shared-tests'; - -type TestsForRpcMethodAssumingNoBlockParamOptions = { - providerType: ProviderType; - numberOfParameters: number; -}; - -/** - * Defines tests which exercise the behavior exhibited by an RPC method which is - * assumed to not take a block parameter. Even if it does, the value of this - * parameter will not be used in determining how to cache the method. - * - * @param method - The name of the RPC method under test. - * @param additionalArgs - Additional arguments. - * @param additionalArgs.numberOfParameters - The number of parameters - * supported by the method under test. - * @param additionalArgs.providerType - The type of provider being tested; - * either `infura` or `custom`. - */ -export function testsForRpcMethodAssumingNoBlockParam( - method: string, - { - numberOfParameters, - providerType, - }: TestsForRpcMethodAssumingNoBlockParamOptions, -) { - it('does not hit the RPC endpoint more than once for identical requests', async () => { - const requests = [{ method }, { method }]; - const mockResults = ['first result', 'second result']; - - await withMockedCommunications({ providerType }, async (comms) => { - // The first time a block-cacheable request is made, the latest block - // number is retrieved through the block tracker first. It doesn't - // matter what this is — it's just used as a cache key. - comms.mockNextBlockTrackerRequest(); - comms.mockRpcCall({ - request: requests[0], - response: { result: mockResults[0] }, - }); - - const results = await withNetworkClient( - { providerType }, - ({ makeRpcCallsInSeries }) => makeRpcCallsInSeries(requests), - ); - - expect(results).toStrictEqual([mockResults[0], mockResults[0]]); - }); - }); - - for (const paramIndex of [...Array(numberOfParameters).keys()]) { - it(`does not reuse the result of a previous request if parameter at index "${paramIndex}" differs`, async () => { - const firstMockParams = [ - ...new Array(numberOfParameters).fill('some value'), - ]; - const secondMockParams = firstMockParams.slice(); - secondMockParams[paramIndex] = 'another value'; - const requests = [ - { - method, - params: firstMockParams, - }, - { method, params: secondMockParams }, - ]; - const mockResults = ['some result', 'another result']; - - await withMockedCommunications({ providerType }, async (comms) => { - // The first time a block-cacheable request is made, the latest block - // number is retrieved through the block tracker first. It doesn't - // matter what this is — it's just used as a cache key. - comms.mockNextBlockTrackerRequest(); - comms.mockRpcCall({ - request: requests[0], - response: { result: mockResults[0] }, - }); - comms.mockRpcCall({ - request: requests[1], - response: { result: mockResults[1] }, - }); - - const results = await withNetworkClient( - { providerType }, - ({ makeRpcCallsInSeries }) => makeRpcCallsInSeries(requests), - ); - - expect(results).toStrictEqual([mockResults[0], mockResults[1]]); - }); - }); - } - - it('hits the RPC endpoint and does not reuse the result of a previous request if the latest block number was updated since', async () => { - const requests = [{ method }, { method }]; - const mockResults = ['first result', 'second result']; - - await withMockedCommunications({ providerType }, async (comms) => { - // Note that we have to mock these requests in a specific order. The - // first block tracker request occurs because of the first RPC request. - // The second block tracker request, however, does not occur because of - // the second RPC request, but rather because we call `clock.runAll()` - // below. - comms.mockNextBlockTrackerRequest({ blockNumber: '0x1' }); - comms.mockRpcCall({ - request: requests[0], - response: { result: mockResults[0] }, - }); - comms.mockNextBlockTrackerRequest({ blockNumber: '0x2' }); - comms.mockRpcCall({ - request: requests[1], - response: { result: mockResults[1] }, - }); - - const results = await withNetworkClient( - { providerType }, - async (client) => { - const firstResult = await client.makeRpcCall(requests[0]); - // Proceed to the next iteration of the block tracker so that a new - // block is fetched and the current block is updated. - client.clock.runAll(); - const secondResult = await client.makeRpcCall(requests[1]); - return [firstResult, secondResult]; - }, - ); - - expect(results).toStrictEqual(mockResults); - }); - }); - - for (const emptyValue of [null, undefined, '\u003cnil\u003e']) { - it(`does not retry an empty response of "${emptyValue}"`, async () => { - const request = { method }; - const mockResult = emptyValue; - - await withMockedCommunications({ providerType }, async (comms) => { - // The first time a block-cacheable request is made, the latest block - // number is retrieved through the block tracker first. It doesn't - // matter what this is — it's just used as a cache key. - comms.mockNextBlockTrackerRequest(); - comms.mockRpcCall({ - request, - response: { result: mockResult }, - }); - - const result = await withNetworkClient( - { providerType }, - ({ makeRpcCall }) => makeRpcCall(request), - ); - - expect(result).toStrictEqual(mockResult); - }); - }); - - it(`does not reuse the result of a previous request if it was "${emptyValue}"`, async () => { - const requests = [{ method }, { method }]; - const mockResults = [emptyValue, 'some result']; - - await withMockedCommunications({ providerType }, async (comms) => { - // The first time a block-cacheable request is made, the latest block - // number is retrieved through the block tracker first. It doesn't - // matter what this is — it's just used as a cache key. - comms.mockNextBlockTrackerRequest(); - comms.mockRpcCall({ - request: requests[0], - response: { result: mockResults[0] }, - }); - comms.mockRpcCall({ - request: requests[1], - response: { result: mockResults[1] }, - }); - - const results = await withNetworkClient( - { providerType }, - ({ makeRpcCallsInSeries }) => makeRpcCallsInSeries(requests), - ); - - expect(results).toStrictEqual(mockResults); - }); - }); - } - - it('queues requests while a previous identical call is still pending, then runs the queue when it finishes, reusing the result from the first request', async () => { - const requests = [{ method }, { method }, { method }]; - const mockResults = ['first result', 'second result', 'third result']; - - await withMockedCommunications({ providerType }, async (comms) => { - // The first time a block-cacheable request is made, the latest block - // number is retrieved through the block tracker first. It doesn't - // matter what this is — it's just used as a cache key. - comms.mockNextBlockTrackerRequest(); - comms.mockRpcCall({ - request: requests[0], - response: { result: mockResults[0] }, - delay: 100, - }); - - comms.mockRpcCall({ - request: requests[1], - response: { result: mockResults[1] }, - }); - - comms.mockRpcCall({ - request: requests[2], - response: { result: mockResults[2] }, - }); - - const results = await withNetworkClient( - { providerType }, - async (client) => { - const resultPromises = [ - client.makeRpcCall(requests[0]), - client.makeRpcCall(requests[1]), - client.makeRpcCall(requests[2]), - ]; - const firstResult = await resultPromises[0]; - // The inflight cache middleware uses setTimeout to run the handlers, - // so run them now - client.clock.runAll(); - const remainingResults = await Promise.all(resultPromises.slice(1)); - return [firstResult, ...remainingResults]; - }, - ); - - expect(results).toStrictEqual([ - mockResults[0], - mockResults[0], - mockResults[0], - ]); - }); - }); - - it('throws a custom error if the request to the RPC endpoint returns a 405 response', async () => { - await withMockedCommunications({ providerType }, async (comms) => { - const request = { method }; - - // The first time a block-cacheable request is made, the latest block - // number is retrieved through the block tracker first. It doesn't - // matter what this is — it's just used as a cache key. - comms.mockNextBlockTrackerRequest(); - comms.mockRpcCall({ - request, - response: { - httpStatus: 405, - }, - }); - const promiseForResult = withNetworkClient( - { providerType }, - async ({ makeRpcCall }) => makeRpcCall(request), - ); - - await expect(promiseForResult).rejects.toThrow( - 'The method does not exist / is not available', - ); - }); - }); - - // There is a difference in how we are testing the Infura middleware vs. the - // custom RPC middleware (or, more specifically, the fetch middleware) because - // of what both middleware treat as rate limiting errors. In this case, the - // fetch middleware treats a 418 response from the RPC endpoint as such an - // error, whereas to the Infura middleware, it is a 429 response. - if (providerType === 'infura') { - it('throws an undescriptive error if the request to the RPC endpoint returns a 418 response', async () => { - await withMockedCommunications({ providerType }, async (comms) => { - const request = { id: 123, method }; - - // The first time a block-cacheable request is made, the latest block - // number is retrieved through the block tracker first. It doesn't - // matter what this is — it's just used as a cache key. - comms.mockNextBlockTrackerRequest(); - comms.mockRpcCall({ - request, - response: { - httpStatus: 418, - }, - }); - const promiseForResult = withNetworkClient( - { providerType }, - async ({ makeRpcCall }) => makeRpcCall(request), - ); - - await expect(promiseForResult).rejects.toThrow( - '{"id":123,"jsonrpc":"2.0"}', - ); - }); - }); - - it('throws an error with a custom message if the request to the RPC endpoint returns a 429 response', async () => { - await withMockedCommunications({ providerType }, async (comms) => { - const request = { method }; - - // The first time a block-cacheable request is made, the latest block - // number is retrieved through the block tracker first. It doesn't - // matter what this is — it's just used as a cache key. - comms.mockNextBlockTrackerRequest(); - comms.mockRpcCall({ - request, - response: { - httpStatus: 429, - }, - }); - const promiseForResult = withNetworkClient( - { providerType }, - async ({ makeRpcCall }) => makeRpcCall(request), - ); - - await expect(promiseForResult).rejects.toThrow( - 'Request is being rate limited', - ); - }); - }); - } else { - it('throws a custom error if the request to the RPC endpoint returns a 418 response', async () => { - await withMockedCommunications({ providerType }, async (comms) => { - const request = { method }; - - // The first time a block-cacheable request is made, the latest block - // number is retrieved through the block tracker first. It doesn't - // matter what this is — it's just used as a cache key. - comms.mockNextBlockTrackerRequest(); - comms.mockRpcCall({ - request, - response: { - httpStatus: 418, - }, - }); - const promiseForResult = withNetworkClient( - { providerType }, - async ({ makeRpcCall }) => makeRpcCall(request), - ); - - await expect(promiseForResult).rejects.toThrow( - 'Request is being rate limited.', - ); - }); - }); - - it('throws an undescriptive error if the request to the RPC endpoint returns a 429 response', async () => { - await withMockedCommunications({ providerType }, async (comms) => { - const request = { method }; - - // The first time a block-cacheable request is made, the latest block - // number is retrieved through the block tracker first. It doesn't - // matter what this is — it's just used as a cache key. - comms.mockNextBlockTrackerRequest(); - comms.mockRpcCall({ - request, - response: { - httpStatus: 429, - }, - }); - const promiseForResult = withNetworkClient( - { providerType }, - async ({ makeRpcCall }) => makeRpcCall(request), - ); - - await expect(promiseForResult).rejects.toThrow( - "Non-200 status code: '429'", - ); - }); - }); - } - - it('throws a generic, undescriptive error if the request to the RPC endpoint returns a response that is not 405, 418, 429, 503, or 504', async () => { - await withMockedCommunications({ providerType }, async (comms) => { - const request = { method }; - - // The first time a block-cacheable request is made, the latest block - // number is retrieved through the block tracker first. It doesn't - // matter what this is — it's just used as a cache key. - comms.mockNextBlockTrackerRequest(); - comms.mockRpcCall({ - request, - response: { - id: 12345, - jsonrpc: '2.0', - error: 'some error', - httpStatus: 420, - }, - }); - const promiseForResult = withNetworkClient( - { providerType }, - async ({ makeRpcCall }) => makeRpcCall(request), - ); - - const errorMessage = - providerType === 'infura' - ? '{"id":12345,"jsonrpc":"2.0","error":"some error"}' - : "Non-200 status code: '420'"; - await expect(promiseForResult).rejects.toThrow(errorMessage); - }); - }); - - [503, 504].forEach((httpStatus) => { - it(`retries the request to the RPC endpoint up to 5 times if it returns a ${httpStatus} response, returning the successful result if there is one on the 5th try`, async () => { - await withMockedCommunications({ providerType }, async (comms) => { - const request = { method }; - - // The first time a block-cacheable request is made, the latest block - // number is retrieved through the block tracker first. It doesn't - // matter what this is — it's just used as a cache key. - comms.mockNextBlockTrackerRequest(); - // Here we have the request fail for the first 4 tries, then succeed - // on the 5th try. - comms.mockRpcCall({ - request, - response: { - error: 'Some error', - httpStatus, - }, - times: 4, - }); - comms.mockRpcCall({ - request, - response: { - result: 'the result', - httpStatus: 200, - }, - }); - const result = await withNetworkClient( - { providerType }, - async ({ makeRpcCall, clock }) => { - return await waitForPromiseToBeFulfilledAfterRunningAllTimers( - makeRpcCall(request), - clock, - ); - }, - ); - - expect(result).toBe('the result'); - }); - }); - - it(`causes a request to fail with a custom error if the request to the RPC endpoint returns a ${httpStatus} response 5 times in a row`, async () => { - await withMockedCommunications({ providerType }, async (comms) => { - const request = { method }; - - // The first time a block-cacheable request is made, the latest block - // number is retrieved through the block tracker first. It doesn't - // matter what this is — it's just used as a cache key. - comms.mockNextBlockTrackerRequest(); - comms.mockRpcCall({ - request, - response: { - error: 'Some error', - httpStatus, - }, - times: 5, - }); - comms.mockNextBlockTrackerRequest(); - const promiseForResult = withNetworkClient( - { providerType }, - async ({ makeRpcCall, clock }) => { - return await waitForPromiseToBeFulfilledAfterRunningAllTimers( - makeRpcCall(request), - clock, - ); - }, - ); - const err = - providerType === 'infura' - ? buildInfuraClientRetriesExhaustedErrorMessage('Gateway timeout') - : buildJsonRpcEngineEmptyResponseErrorMessage(method); - await expect(promiseForResult).rejects.toThrow(err); - }); - }); - }); - - it('retries the request to the RPC endpoint up to 5 times if an "ETIMEDOUT" error is thrown while making the request, returning the successful result if there is one on the 5th try', async () => { - await withMockedCommunications({ providerType }, async (comms) => { - const request = { method }; - - // The first time a block-cacheable request is made, the latest block - // number is retrieved through the block tracker first. It doesn't - // matter what this is — it's just used as a cache key. - comms.mockNextBlockTrackerRequest(); - // Here we have the request fail for the first 4 tries, then succeed - // on the 5th try. - comms.mockRpcCall({ - request, - error: 'ETIMEDOUT: Some message', - times: 4, - }); - comms.mockRpcCall({ - request, - response: { - result: 'the result', - httpStatus: 200, - }, - }); - - const result = await withNetworkClient( - { providerType }, - async ({ makeRpcCall, clock }) => { - return await waitForPromiseToBeFulfilledAfterRunningAllTimers( - makeRpcCall(request), - clock, - ); - }, - ); - - expect(result).toBe('the result'); - }); - }); - - // Both the Infura and fetch middleware detect ETIMEDOUT errors and will - // automatically retry the request to the RPC endpoint in question, but both - // produce a different error if the number of retries is exhausted. - if (providerType === 'infura') { - it('causes a request to fail with a custom error if an "ETIMEDOUT" error is thrown while making the request to the RPC endpoint 5 times in a row', async () => { - await withMockedCommunications({ providerType }, async (comms) => { - const request = { method }; - const errorMessage = 'ETIMEDOUT: Some message'; - - // The first time a block-cacheable request is made, the latest block - // number is retrieved through the block tracker first. It doesn't - // matter what this is — it's just used as a cache key. - comms.mockNextBlockTrackerRequest(); - comms.mockRpcCall({ - request, - error: errorMessage, - times: 5, - }); - const promiseForResult = withNetworkClient( - { providerType }, - async ({ makeRpcCall, clock }) => { - return await waitForPromiseToBeFulfilledAfterRunningAllTimers( - makeRpcCall(request), - clock, - ); - }, - ); - - await expect(promiseForResult).rejects.toThrow( - buildInfuraClientRetriesExhaustedErrorMessage(errorMessage), - ); - }); - }); - } else { - it('returns an empty response if an "ETIMEDOUT" error is thrown while making the request to the RPC endpoint 5 times in a row', async () => { - await withMockedCommunications({ providerType }, async (comms) => { - const request = { method }; - const errorMessage = 'ETIMEDOUT: Some message'; - - // The first time a block-cacheable request is made, the latest block - // number is retrieved through the block tracker first. It doesn't - // matter what this is — it's just used as a cache key. - comms.mockNextBlockTrackerRequest(); - comms.mockRpcCall({ - request, - error: errorMessage, - times: 5, - }); - const promiseForResult = withNetworkClient( - { providerType }, - async ({ makeRpcCall, clock }) => { - return await waitForPromiseToBeFulfilledAfterRunningAllTimers( - makeRpcCall(request), - clock, - ); - }, - ); - - await expect(promiseForResult).rejects.toThrow( - buildJsonRpcEngineEmptyResponseErrorMessage(method), - ); - }); - }); - } - - // The Infura middleware treats a response that contains an ECONNRESET message - // as an innocuous error that is likely to disappear on a retry. The custom - // RPC middleware, on the other hand, does not specially handle this error. - if (providerType === 'infura') { - it('retries the request to the RPC endpoint up to 5 times if an "ECONNRESET" error is thrown while making the request, returning the successful result if there is one on the 5th try', async () => { - await withMockedCommunications({ providerType }, async (comms) => { - const request = { method }; - - // The first time a block-cacheable request is made, the latest block - // number is retrieved through the block tracker first. It doesn't - // matter what this is — it's just used as a cache key. - comms.mockNextBlockTrackerRequest(); - // Here we have the request fail for the first 4 tries, then succeed - // on the 5th try. - comms.mockRpcCall({ - request, - error: 'ECONNRESET: Some message', - times: 4, - }); - comms.mockRpcCall({ - request, - response: { - result: 'the result', - httpStatus: 200, - }, - }); - - const result = await withNetworkClient( - { providerType }, - async ({ makeRpcCall, clock }) => { - return await waitForPromiseToBeFulfilledAfterRunningAllTimers( - makeRpcCall(request), - clock, - ); - }, - ); - - expect(result).toBe('the result'); - }); - }); - - it('causes a request to fail with a custom error if an "ECONNRESET" error is thrown while making the request to the RPC endpoint 5 times in a row', async () => { - await withMockedCommunications({ providerType }, async (comms) => { - const request = { method }; - const errorMessage = 'ECONNRESET: Some message'; - - // The first time a block-cacheable request is made, the latest block - // number is retrieved through the block tracker first. It doesn't - // matter what this is — it's just used as a cache key. - comms.mockNextBlockTrackerRequest(); - comms.mockRpcCall({ - request, - error: errorMessage, - times: 5, - }); - const promiseForResult = withNetworkClient( - { providerType }, - async ({ makeRpcCall, clock }) => { - return await waitForPromiseToBeFulfilledAfterRunningAllTimers( - makeRpcCall(request), - clock, - ); - }, - ); - - await expect(promiseForResult).rejects.toThrow( - buildInfuraClientRetriesExhaustedErrorMessage(errorMessage), - ); - }); - }); - } else { - it('does not retry the request to the RPC endpoint, but throws immediately, if an "ECONNRESET" error is thrown while making the request', async () => { - const customRpcUrl = 'http://example.com'; - - await withMockedCommunications( - { providerType, customRpcUrl }, - async (comms) => { - const request = { method }; - const errorMessage = 'ECONNRESET: Some message'; - - // The first time a block-cacheable request is made, the latest block - // number is retrieved through the block tracker first. It doesn't - // matter what this is — it's just used as a cache key. - comms.mockNextBlockTrackerRequest(); - comms.mockRpcCall({ - request, - error: errorMessage, - }); - const promiseForResult = withNetworkClient( - { providerType, customRpcUrl }, - async ({ makeRpcCall }) => makeRpcCall(request), - ); - - await expect(promiseForResult).rejects.toThrow( - buildFetchFailedErrorMessage(customRpcUrl, errorMessage), - ); - }, - ); - }); - } - - // Both the Infura and fetch middleware will attempt to parse the response - // body as JSON, and if this step produces an error, both middleware will also - // attempt to retry the request. However, this error handling code is slightly - // different between the two. As the error in this case is a SyntaxError, the - // Infura middleware will catch it immediately, whereas the custom RPC - // middleware will catch it and re-throw a separate error, which it then - // catches later. - if (providerType === 'infura') { - it('retries the request to the RPC endpoint up to 5 times if an "SyntaxError" error is thrown while making the request, returning the successful result if there is one on the 5th try', async () => { - await withMockedCommunications({ providerType }, async (comms) => { - const request = { method }; - - // The first time a block-cacheable request is made, the latest block - // number is retrieved through the block tracker first. It doesn't - // matter what this is — it's just used as a cache key. - comms.mockNextBlockTrackerRequest(); - // Here we have the request fail for the first 4 tries, then succeed - // on the 5th try. - comms.mockRpcCall({ - request, - error: 'SyntaxError: Some message', - times: 4, - }); - comms.mockRpcCall({ - request, - response: { - result: 'the result', - httpStatus: 200, - }, - }); - - const result = await withNetworkClient( - { providerType }, - async ({ makeRpcCall, clock }) => { - return await waitForPromiseToBeFulfilledAfterRunningAllTimers( - makeRpcCall(request), - clock, - ); - }, - ); - - expect(result).toBe('the result'); - }); - }); - - it('causes a request to fail with a custom error if an "SyntaxError" error is thrown while making the request to the RPC endpoint 5 times in a row', async () => { - await withMockedCommunications({ providerType }, async (comms) => { - const request = { method }; - const errorMessage = 'SyntaxError: Some message'; - - // The first time a block-cacheable request is made, the latest block - // number is retrieved through the block tracker first. It doesn't - // matter what this is — it's just used as a cache key. - comms.mockNextBlockTrackerRequest(); - comms.mockRpcCall({ - request, - error: errorMessage, - times: 5, - }); - const promiseForResult = withNetworkClient( - { providerType }, - async ({ makeRpcCall, clock }) => { - return await waitForPromiseToBeFulfilledAfterRunningAllTimers( - makeRpcCall(request), - clock, - ); - }, - ); - - await expect(promiseForResult).rejects.toThrow( - buildInfuraClientRetriesExhaustedErrorMessage(errorMessage), - ); - }); - }); - - it('does not retry the request to the RPC endpoint, but throws immediately, if a "failed to parse response body" error is thrown while making the request', async () => { - await withMockedCommunications({ providerType }, async (comms) => { - const request = { method }; - const errorMessage = 'failed to parse response body: some message'; - - // The first time a block-cacheable request is made, the latest block - // number is retrieved through the block tracker first. It doesn't - // matter what this is — it's just used as a cache key. - comms.mockNextBlockTrackerRequest(); - comms.mockRpcCall({ - request, - error: errorMessage, - }); - const promiseForResult = withNetworkClient( - { providerType, infuraNetwork: comms.infuraNetwork }, - async ({ makeRpcCall }) => makeRpcCall(request), - ); - - await expect(promiseForResult).rejects.toThrow( - buildFetchFailedErrorMessage(comms.rpcUrl, errorMessage), - ); - }); - }); - } else { - it('does not retry the request to the RPC endpoint, but throws immediately, if a "SyntaxError" error is thrown while making the request', async () => { - const customRpcUrl = 'http://example.com'; - - await withMockedCommunications( - { providerType, customRpcUrl }, - async (comms) => { - const request = { method }; - const errorMessage = 'SyntaxError: Some message'; - - // The first time a block-cacheable request is made, the latest block - // number is retrieved through the block tracker first. It doesn't - // matter what this is — it's just used as a cache key. - comms.mockNextBlockTrackerRequest(); - comms.mockRpcCall({ - request, - error: errorMessage, - }); - const promiseForResult = withNetworkClient( - { providerType, customRpcUrl }, - async ({ makeRpcCall }) => makeRpcCall(request), - ); - - await expect(promiseForResult).rejects.toThrow( - buildFetchFailedErrorMessage(customRpcUrl, errorMessage), - ); - }, - ); - }); - - it('retries the request to the RPC endpoint up to 5 times if a "failed to parse response body" error is thrown while making the request, returning the successful result if there is one on the 5th try', async () => { - await withMockedCommunications({ providerType }, async (comms) => { - const request = { method }; - - // The first time a block-cacheable request is made, the latest block - // number is retrieved through the block tracker first. It doesn't - // matter what this is — it's just used as a cache key. - comms.mockNextBlockTrackerRequest(); - // Here we have the request fail for the first 4 tries, then succeed - // on the 5th try. - comms.mockRpcCall({ - request, - error: 'failed to parse response body: some message', - times: 4, - }); - comms.mockRpcCall({ - request, - response: { - result: 'the result', - httpStatus: 200, - }, - }); - - const result = await withNetworkClient( - { providerType }, - async ({ makeRpcCall, clock }) => { - return await waitForPromiseToBeFulfilledAfterRunningAllTimers( - makeRpcCall(request), - clock, - ); - }, - ); - - expect(result).toBe('the result'); - }); - }); - - it('returns an empty response if a "failed to parse response body" error is thrown while making the request to the RPC endpoint 5 times in a row', async () => { - await withMockedCommunications({ providerType }, async (comms) => { - const request = { method }; - const errorMessage = 'failed to parse response body: some message'; - - // The first time a block-cacheable request is made, the latest block - // number is retrieved through the block tracker first. It doesn't - // matter what this is — it's just used as a cache key. - comms.mockNextBlockTrackerRequest(); - comms.mockRpcCall({ - request, - error: errorMessage, - times: 5, - }); - const promiseForResult = withNetworkClient( - { providerType }, - async ({ makeRpcCall, clock }) => { - return await waitForPromiseToBeFulfilledAfterRunningAllTimers( - makeRpcCall(request), - clock, - ); - }, - ); - - await expect(promiseForResult).rejects.toThrow( - buildJsonRpcEngineEmptyResponseErrorMessage(method), - ); - }); - }); - } - - // Only the custom RPC middleware will detect a "Failed to fetch" error and - // attempt to retry the request to the RPC endpoint; the Infura middleware - // does not. - if (providerType === 'infura') { - it('does not retry the request to the RPC endpoint, but throws immediately, if a "Failed to fetch" error is thrown while making the request', async () => { - await withMockedCommunications({ providerType }, async (comms) => { - const request = { method }; - const errorMessage = 'Failed to fetch: some message'; - - // The first time a block-cacheable request is made, the latest block - // number is retrieved through the block tracker first. It doesn't - // matter what this is — it's just used as a cache key. - comms.mockNextBlockTrackerRequest(); - comms.mockRpcCall({ - request, - error: errorMessage, - }); - const promiseForResult = withNetworkClient( - { providerType, infuraNetwork: comms.infuraNetwork }, - async ({ makeRpcCall }) => makeRpcCall(request), - ); - - await expect(promiseForResult).rejects.toThrow( - buildFetchFailedErrorMessage(comms.rpcUrl, errorMessage), - ); - }); - }); - } else { - it('retries the request to the RPC endpoint up to 5 times if a "Failed to fetch" error is thrown while making the request, returning the successful result if there is one on the 5th try', async () => { - await withMockedCommunications({ providerType }, async (comms) => { - const request = { method }; - - // The first time a block-cacheable request is made, the latest block - // number is retrieved through the block tracker first. It doesn't - // matter what this is — it's just used as a cache key. - comms.mockNextBlockTrackerRequest(); - // Here we have the request fail for the first 4 tries, then succeed - // on the 5th try. - comms.mockRpcCall({ - request, - error: 'Failed to fetch: some message', - times: 4, - }); - comms.mockRpcCall({ - request, - response: { - result: 'the result', - httpStatus: 200, - }, - }); - - const result = await withNetworkClient( - { providerType }, - async ({ makeRpcCall, clock }) => { - return await waitForPromiseToBeFulfilledAfterRunningAllTimers( - makeRpcCall(request), - clock, - ); - }, - ); - - expect(result).toBe('the result'); - }); - }); - - it('returns an empty response if a "Failed to fetch" error is thrown while making the request to the RPC endpoint 5 times in a row', async () => { - await withMockedCommunications({ providerType }, async (comms) => { - const request = { method }; - const errorMessage = 'Failed to fetch: some message'; - - // The first time a block-cacheable request is made, the latest block - // number is retrieved through the block tracker first. It doesn't - // matter what this is — it's just used as a cache key. - comms.mockNextBlockTrackerRequest(); - comms.mockRpcCall({ - request, - error: errorMessage, - times: 5, - }); - const promiseForResult = withNetworkClient( - { providerType }, - async ({ makeRpcCall, clock }) => { - return await waitForPromiseToBeFulfilledAfterRunningAllTimers( - makeRpcCall(request), - clock, - ); - }, - ); - - await expect(promiseForResult).rejects.toThrow( - buildJsonRpcEngineEmptyResponseErrorMessage(method), - ); - }); - }); - } -} diff --git a/packages/network-controller/tests/provider-api-tests/shared-tests.ts b/packages/network-controller/tests/provider-api-tests/shared-tests.ts deleted file mode 100644 index 10e8d34ad6a..00000000000 --- a/packages/network-controller/tests/provider-api-tests/shared-tests.ts +++ /dev/null @@ -1,345 +0,0 @@ -import { testsForRpcMethodsThatCheckForBlockHashInResponse } from './block-hash-in-response'; -import { testsForRpcMethodSupportingBlockParam } from './block-param'; -import type { ProviderType } from './helpers'; -import { withMockedCommunications, withNetworkClient } from './helpers'; -import { testsForRpcMethodAssumingNoBlockParam } from './no-block-param'; -import { testsForRpcMethodNotHandledByMiddleware } from './not-handled-by-middleware'; - -/** - * Constructs an error message that the Infura client would produce in the event - * that it has attempted to retry the request to Infura and has failed. - * - * @param reason - The exact reason for failure. - * @returns The error message. - */ -export function buildInfuraClientRetriesExhaustedErrorMessage(reason: string) { - return new RegExp( - `^InfuraProvider - cannot complete request. All retries exhausted\\..+${reason}`, - 'us', - ); -} - -/** - * Constructs an error message that JsonRpcEngine would produce in the event - * that the response object is empty as it leaves the middleware. - * - * @param method - The RPC method. - * @returns The error message. - */ -export function buildJsonRpcEngineEmptyResponseErrorMessage(method: string) { - return new RegExp( - `^JsonRpcEngine: Response has no error or result for request:.+"method": "${method}"`, - 'us', - ); -} - -/** - * Constructs an error message that `fetch` with throw if it cannot make a - * request. - * - * @param url - The URL being fetched - * @param reason - The reason. - * @returns The error message. - */ -export function buildFetchFailedErrorMessage(url: string, reason: string) { - return new RegExp( - `^request to ${url}(/[^/ ]*)+ failed, reason: ${reason}`, - 'us', - ); -} - -/** - * Defines tests that are common to both the Infura and JSON-RPC network client. - * - * @param providerType - The type of provider being tested, which determines - * which suite of middleware is being tested. If `infura`, then the middleware - * exposed by `createInfuraClient` is tested; if `custom`, then the middleware - * exposed by `createJsonRpcClient` will be tested. - */ -export function testsForProviderType(providerType: ProviderType) { - // Ethereum JSON-RPC spec: - // Infura documentation: - describe('methods included in the Ethereum JSON-RPC spec', () => { - describe('methods not handled by middleware', () => { - const notHandledByMiddleware = [ - { name: 'eth_newFilter', numberOfParameters: 1 }, - { name: 'eth_getFilterChanges', numberOfParameters: 1 }, - { name: 'eth_newBlockFilter', numberOfParameters: 0 }, - { name: 'eth_newPendingTransactionFilter', numberOfParameters: 0 }, - { name: 'eth_uninstallFilter', numberOfParameters: 1 }, - - { name: 'eth_sendRawTransaction', numberOfParameters: 1 }, - { name: 'eth_sendTransaction', numberOfParameters: 1 }, - { name: 'eth_sign', numberOfParameters: 2 }, - - { name: 'eth_createAccessList', numberOfParameters: 2 }, - { name: 'eth_getLogs', numberOfParameters: 1 }, - { name: 'eth_getProof', numberOfParameters: 3 }, - { name: 'eth_getWork', numberOfParameters: 0 }, - { name: 'eth_maxPriorityFeePerGas', numberOfParameters: 0 }, - { name: 'eth_submitHashRate', numberOfParameters: 2 }, - { name: 'eth_submitWork', numberOfParameters: 3 }, - { name: 'eth_syncing', numberOfParameters: 0 }, - { name: 'eth_feeHistory', numberOfParameters: 3 }, - { name: 'debug_getRawHeader', numberOfParameters: 1 }, - { name: 'debug_getRawBlock', numberOfParameters: 1 }, - { name: 'debug_getRawTransaction', numberOfParameters: 1 }, - { name: 'debug_getRawReceipts', numberOfParameters: 1 }, - { name: 'debug_getBadBlocks', numberOfParameters: 0 }, - - { name: 'eth_accounts', numberOfParameters: 0 }, - { name: 'eth_coinbase', numberOfParameters: 0 }, - { name: 'eth_hashrate', numberOfParameters: 0 }, - { name: 'eth_mining', numberOfParameters: 0 }, - - { name: 'eth_signTransaction', numberOfParameters: 1 }, - ]; - notHandledByMiddleware.forEach(({ name, numberOfParameters }) => { - describe(`method name: ${name}`, () => { - testsForRpcMethodNotHandledByMiddleware(name, { - providerType, - numberOfParameters, - }); - }); - }); - }); - - describe('methods with block hashes in their result', () => { - const methodsWithBlockHashInResponse = [ - { name: 'eth_getTransactionByHash', numberOfParameters: 1 }, - { name: 'eth_getTransactionReceipt', numberOfParameters: 1 }, - ]; - methodsWithBlockHashInResponse.forEach(({ name, numberOfParameters }) => { - describe(`method name: ${name}`, () => { - testsForRpcMethodsThatCheckForBlockHashInResponse(name, { - numberOfParameters, - providerType, - }); - }); - }); - }); - - describe('methods that assume there is no block param', () => { - const assumingNoBlockParam = [ - { name: 'eth_getFilterLogs', numberOfParameters: 1 }, - { name: 'eth_blockNumber', numberOfParameters: 0 }, - { name: 'eth_estimateGas', numberOfParameters: 2 }, - { name: 'eth_gasPrice', numberOfParameters: 0 }, - { name: 'eth_getBlockByHash', numberOfParameters: 2 }, - { - name: 'eth_getBlockTransactionCountByHash', - numberOfParameters: 1, - }, - { - name: 'eth_getTransactionByBlockHashAndIndex', - numberOfParameters: 2, - }, - { name: 'eth_getUncleByBlockHashAndIndex', numberOfParameters: 2 }, - { name: 'eth_getUncleCountByBlockHash', numberOfParameters: 1 }, - ]; - const blockParamIgnored = [ - { name: 'eth_getUncleCountByBlockNumber', numberOfParameters: 1 }, - { name: 'eth_getUncleByBlockNumberAndIndex', numberOfParameters: 2 }, - { - name: 'eth_getTransactionByBlockNumberAndIndex', - numberOfParameters: 2, - }, - { - name: 'eth_getBlockTransactionCountByNumber', - numberOfParameters: 1, - }, - ]; - assumingNoBlockParam - .concat(blockParamIgnored) - .forEach(({ name, numberOfParameters }) => - describe(`method name: ${name}`, () => { - testsForRpcMethodAssumingNoBlockParam(name, { - providerType, - numberOfParameters, - }); - }), - ); - }); - - describe('methods that have a param to specify the block', () => { - const supportingBlockParam = [ - { - name: 'eth_call', - blockParamIndex: 1, - numberOfParameters: 2, - }, - { - name: 'eth_getBalance', - blockParamIndex: 1, - numberOfParameters: 2, - }, - { - name: 'eth_getBlockByNumber', - blockParamIndex: 0, - numberOfParameters: 2, - }, - { name: 'eth_getCode', blockParamIndex: 1, numberOfParameters: 2 }, - { - name: 'eth_getStorageAt', - blockParamIndex: 2, - numberOfParameters: 3, - }, - { - name: 'eth_getTransactionCount', - blockParamIndex: 1, - numberOfParameters: 2, - }, - ]; - supportingBlockParam.forEach( - ({ name, blockParamIndex, numberOfParameters }) => { - describe(`method name: ${name}`, () => { - testsForRpcMethodSupportingBlockParam(name, { - providerType, - blockParamIndex, - numberOfParameters, - }); - }); - }, - ); - }); - - describe('other methods', () => { - describe('eth_getTransactionByHash', () => { - it("refreshes the block tracker's current block if it is less than the block number that comes back in the response", async () => { - const method = 'eth_getTransactionByHash'; - - await withMockedCommunications({ providerType }, async (comms) => { - const request = { method }; - - comms.mockNextBlockTrackerRequest({ blockNumber: '0x100' }); - // This is our request. - comms.mockRpcCall({ - request, - response: { - result: { - blockNumber: '0x200', - }, - }, - }); - comms.mockNextBlockTrackerRequest({ blockNumber: '0x300' }); - - await withNetworkClient( - { providerType }, - async ({ makeRpcCall, blockTracker }) => { - await makeRpcCall(request); - expect(blockTracker.getCurrentBlock()).toBe('0x300'); - }, - ); - }); - }); - }); - - describe('eth_getTransactionReceipt', () => { - it("refreshes the block tracker's current block if it is less than the block number that comes back in the response", async () => { - const method = 'eth_getTransactionReceipt'; - - await withMockedCommunications({ providerType }, async (comms) => { - const request = { method }; - - comms.mockNextBlockTrackerRequest({ blockNumber: '0x100' }); - // This is our request. - comms.mockRpcCall({ - request, - response: { - result: { - blockNumber: '0x200', - }, - }, - }); - comms.mockNextBlockTrackerRequest({ blockNumber: '0x300' }); - - await withNetworkClient( - { providerType }, - async ({ makeRpcCall, blockTracker }) => { - await makeRpcCall(request); - expect(blockTracker.getCurrentBlock()).toBe('0x300'); - }, - ); - }); - }); - }); - - describe('eth_chainId', () => { - it('does not hit the RPC endpoint, instead returning the configured chain id', async () => { - const chainId = await withNetworkClient( - { providerType: 'custom', customChainId: '0x1' }, - ({ makeRpcCall }) => { - return makeRpcCall({ method: 'eth_chainId' }); - }, - ); - - expect(chainId).toBe('0x1'); - }); - }); - }); - }); - - describe('methods not included in the Ethereum JSON-RPC spec', () => { - describe('methods not handled by middleware', () => { - const notHandledByMiddleware = [ - { name: 'net_listening', numberOfParameters: 0 }, - { name: 'eth_subscribe', numberOfParameters: 1 }, - { name: 'eth_unsubscribe', numberOfParameters: 1 }, - { name: 'custom_rpc_method', numberOfParameters: 1 }, - { name: 'net_peerCount', numberOfParameters: 0 }, - { name: 'parity_nextNonce', numberOfParameters: 1 }, - ]; - notHandledByMiddleware.forEach(({ name, numberOfParameters }) => { - describe(`method name: ${name}`, () => { - testsForRpcMethodNotHandledByMiddleware(name, { - providerType, - numberOfParameters, - }); - }); - }); - }); - - describe('methods that assume there is no block param', () => { - const assumingNoBlockParam = [ - { name: 'web3_clientVersion', numberOfParameters: 0 }, - { name: 'eth_protocolVersion', numberOfParameters: 0 }, - ]; - assumingNoBlockParam.forEach(({ name, numberOfParameters }) => - describe(`method name: ${name}`, () => { - testsForRpcMethodAssumingNoBlockParam(name, { - providerType, - numberOfParameters, - }); - }), - ); - }); - - describe('other methods', () => { - describe('net_version', () => { - const networkArgs = { - providerType, - infuraNetwork: providerType === 'infura' ? 'goerli' : undefined, - } as const; - it('hits the RPC endpoint', async () => { - await withMockedCommunications(networkArgs, async (comms) => { - comms.mockRpcCall({ - request: { method: 'net_version' }, - response: { result: '1' }, - }); - - const networkId = await withNetworkClient( - networkArgs, - ({ makeRpcCall }) => { - return makeRpcCall({ - method: 'net_version', - }); - }, - ); - - expect(networkId).toBe('1'); - }); - }); - }); - }); - }); -} diff --git a/packages/network-controller/tsconfig.build.json b/packages/network-controller/tsconfig.build.json index b10a75f70d4..0ce20a4381b 100644 --- a/packages/network-controller/tsconfig.build.json +++ b/packages/network-controller/tsconfig.build.json @@ -6,9 +6,17 @@ "rootDir": "./src" }, "references": [ + { "path": "../analytics-controller/tsconfig.build.json" }, { "path": "../base-controller/tsconfig.build.json" }, + { "path": "../config-registry-controller/tsconfig.build.json" }, { "path": "../controller-utils/tsconfig.build.json" }, - { "path": "../eth-json-rpc-provider/tsconfig.build.json" } + { "path": "../connectivity-controller/tsconfig.build.json" }, + { "path": "../eth-block-tracker/tsconfig.build.json" }, + { "path": "../eth-json-rpc-middleware/tsconfig.build.json" }, + { "path": "../eth-json-rpc-provider/tsconfig.build.json" }, + { "path": "../json-rpc-engine/tsconfig.build.json" }, + { "path": "../messenger/tsconfig.build.json" }, + { "path": "../remote-feature-flag-controller/tsconfig.build.json" } ], "include": ["../../types", "./src"] } diff --git a/packages/network-controller/tsconfig.json b/packages/network-controller/tsconfig.json index 5459f391846..543c1616011 100644 --- a/packages/network-controller/tsconfig.json +++ b/packages/network-controller/tsconfig.json @@ -5,15 +5,17 @@ "rootDir": "../.." }, "references": [ - { - "path": "../base-controller" - }, - { - "path": "../controller-utils" - }, - { - "path": "../eth-json-rpc-provider" - } + { "path": "../analytics-controller" }, + { "path": "../base-controller" }, + { "path": "../config-registry-controller" }, + { "path": "../controller-utils" }, + { "path": "../connectivity-controller" }, + { "path": "../eth-block-tracker" }, + { "path": "../eth-json-rpc-middleware" }, + { "path": "../eth-json-rpc-provider" }, + { "path": "../json-rpc-engine" }, + { "path": "../messenger" }, + { "path": "../remote-feature-flag-controller" } ], "include": ["../../types", "../../tests", "./src", "./tests"] } diff --git a/packages/network-enablement-controller/CHANGELOG.md b/packages/network-enablement-controller/CHANGELOG.md new file mode 100644 index 00000000000..678bbbfea0a --- /dev/null +++ b/packages/network-enablement-controller/CHANGELOG.md @@ -0,0 +1,453 @@ +# 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] + +## [6.0.5] + +### Changed + +- Bump `@metamask/transaction-controller` from `^69.5.2` to `^69.6.1` ([#9960](https://github.com/MetaMask/core/pull/9960), [#9969](https://github.com/MetaMask/core/pull/9969)) +- Bump `@metamask/config-registry-controller` from `^3.0.0` to `^3.1.0` ([#9969](https://github.com/MetaMask/core/pull/9969)) +- Bump `@metamask/multichain-network-controller` from `^3.2.3` to `^3.2.4` ([#9969](https://github.com/MetaMask/core/pull/9969)) +- Bump `@metamask/network-controller` from `^35.0.1` to `^36.0.0` ([#9969](https://github.com/MetaMask/core/pull/9969)) + +## [6.0.4] + +### Changed + +- Bump `@metamask/transaction-controller` from `^69.5.0` to `^69.5.2` ([#9798](https://github.com/MetaMask/core/pull/9798), [#9823](https://github.com/MetaMask/core/pull/9823)) +- Bump `@metamask/config-registry-controller` from `^2.0.1` to `^3.0.0` ([#9923](https://github.com/MetaMask/core/pull/9923)) + +## [6.0.3] + +### Changed + +- Bump `@metamask/keyring-api` from `^23.7.0` to `^24.0.0` ([#9754](https://github.com/MetaMask/core/pull/9754)) +- Bump `@metamask/transaction-controller` from `^69.4.0` to `^69.5.0` ([#9780](https://github.com/MetaMask/core/pull/9780)) +- Bump `@metamask/config-registry-controller` from `^2.0.0` to `^2.0.1` ([#9779](https://github.com/MetaMask/core/pull/9779)) +- Bump `@metamask/network-controller` from `^35.0.0` to `^35.0.1` ([#9758](https://github.com/MetaMask/core/pull/9758)) +- Bump `@metamask/multichain-network-controller` from `^3.2.2` to `^3.2.3` ([#9791](https://github.com/MetaMask/core/pull/9791)) + +## [6.0.2] + +### Changed + +- Bump `@metamask/config-registry-controller` from `^1.0.1` to `^2.0.0` ([#9740](https://github.com/MetaMask/core/pull/9740)) + +## [6.0.1] + +### Changed + +- Bump `@metamask/config-registry-controller` from `^1.0.0` to `^1.0.1` ([#9735](https://github.com/MetaMask/core/pull/9735)) +- Bump `@metamask/multichain-network-controller` from `^3.2.1` to `^3.2.2` ([#9735](https://github.com/MetaMask/core/pull/9735)) +- Bump `@metamask/network-controller` from `^34.0.0` to `^35.0.0` ([#9735](https://github.com/MetaMask/core/pull/9735)) +- Bump `@metamask/transaction-controller` from `^69.3.0` to `^69.4.0` ([#9735](https://github.com/MetaMask/core/pull/9735)) + +## [6.0.0] + +### Added + +- Expose missing public `NetworkEnablementController` method through its messenger ([#9660](https://github.com/MetaMask/core/pull/9660)) + - The following action is now available: + - `NetworkEnablementController:restoreEnabledNetworkMap` + - Corresponding action type (`NetworkEnablementControllerRestoreEnabledNetworkMapAction`) is available as well. + +### Changed + +- **BREAKING:** Popular-network classification is now augmented by `ConfigRegistryController` ([#9611](https://github.com/MetaMask/core/pull/9611)) + - `NetworkEnablementControllerMessenger` now requires the `ConfigRegistryController:getState` action to be available. +- Bump `@metamask/transaction-controller` from `^69.0.0` to `^69.3.0` ([#9568](https://github.com/MetaMask/core/pull/9568), [#9589](https://github.com/MetaMask/core/pull/9589), [#9593](https://github.com/MetaMask/core/pull/9593), [#9693](https://github.com/MetaMask/core/pull/9693)) +- Bump `@metamask/keyring-api` from `^23.5.0` to `^23.7.0` ([#9676](https://github.com/MetaMask/core/pull/9676)) +- Bump `@metamask/config-registry-controller` from `^0.4.1` to `^1.0.0` ([#9706](https://github.com/MetaMask/core/pull/9706)) + +## [5.6.0] + +### Added + +- Add `restoreEnabledNetworkMap` on `NetworkEnablementController` to restore a previously snapshotted `enabledNetworkMap` when adding a network without switching the active network filter. Not exposed as a messenger action ([#9480](https://github.com/MetaMask/core/pull/9480)) + +## [5.5.0] + +### Added + +- Added Robinhood Chain (`0x1237`) to `POPULAR_NETWORKS` ([#9461](https://github.com/MetaMask/core/pull/9461)) + +### Changed + +- Restore Stellar to the Network Enablement Controller enabled network map by reverting the temporary rollback, as the corresponding Extension issue has already been resolved ([#9385](https://github.com/MetaMask/core/pull/9385)) +- Bump `@metamask/messenger` from `^1.2.0` to `^2.0.0` ([#9392](https://github.com/MetaMask/core/pull/9392)) +- Bump `@metamask/transaction-controller` from `^68.2.2` to `^69.0.0` ([#9421](https://github.com/MetaMask/core/pull/9421), [#9456](https://github.com/MetaMask/core/pull/9456), [#9470](https://github.com/MetaMask/core/pull/9470)) +- Bump `@metamask/keyring-api` from `^23.3.0` to `^23.5.0` ([#9390](https://github.com/MetaMask/core/pull/9390)) + +## [5.4.1] + +### Added + +- Add Stellar network enablement: default `enabledNetworkMap` entries for Stellar namespace (pubnet on, testnet off), enable Stellar pubnet during popular-network init when it exists in `MultichainNetworkController`, and include Stellar pubnet in `listPopularMultichainNetworks` ([#8832](https://github.com/MetaMask/core/pull/8832)) + +### Changed + +- Bump `@metamask/keyring-api` from `^23.1.0` to `^23.3.0` ([#9249](https://github.com/MetaMask/core/pull/9249)) +- Bump `@metamask/transaction-controller` from `^68.1.1` to `^68.2.2` ([#9253](https://github.com/MetaMask/core/pull/9253), [#9337](https://github.com/MetaMask/core/pull/9337), [#9349](https://github.com/MetaMask/core/pull/9349)) +- Bump `@metamask/multichain-network-controller` from `^3.1.4` to `^3.2.1` ([#9264](https://github.com/MetaMask/core/pull/9264), [#9349](https://github.com/MetaMask/core/pull/9349)) +- Bump `@metamask/network-controller` from `^33.0.0` to `^34.0.0` ([#9349](https://github.com/MetaMask/core/pull/9349)) + +## [5.4.0] + +### Added + +- Export `NetworkEnablementControllerStateChangeEvent` type from the package root ([#9084](https://github.com/MetaMask/core/pull/9084)) + +### Changed + +- Bump `@metamask/utils` from `^11.9.0` to `^11.11.0` ([#9074](https://github.com/MetaMask/core/pull/9074)) +- Bump `@metamask/controller-utils` from `^12.1.0` to `^12.3.0` ([#9058](https://github.com/MetaMask/core/pull/9058), [#9083](https://github.com/MetaMask/core/pull/9083), [#9218](https://github.com/MetaMask/core/pull/9218)) +- Bump `@metamask/transaction-controller` from `^66.0.1` to `^68.1.1` ([#9021](https://github.com/MetaMask/core/pull/9021), [#9066](https://github.com/MetaMask/core/pull/9066), [#9089](https://github.com/MetaMask/core/pull/9089), [#9177](https://github.com/MetaMask/core/pull/9177), [#9203](https://github.com/MetaMask/core/pull/9203), [#9218](https://github.com/MetaMask/core/pull/9218)) +- Bump `@metamask/multichain-network-controller` from `^3.1.3` to `^3.1.4` ([#9218](https://github.com/MetaMask/core/pull/9218)) +- Bump `@metamask/network-controller` from `^32.0.0` to `^33.0.0` ([#9218](https://github.com/MetaMask/core/pull/9218)) + +## [5.3.0] + +### Added + +- Added Arc (`0x13b2`) to `POPULAR_NETWORKS` ([#8997](https://github.com/MetaMask/core/pull/8997)) + +### Changed + +- Bump `@metamask/transaction-controller` from `^65.4.0` to `^66.0.1` ([#8848](https://github.com/MetaMask/core/pull/8848), [#8999](https://github.com/MetaMask/core/pull/8999)) +- Bump `@metamask/multichain-network-controller` from `^3.1.2` to `^3.1.3` ([#8999](https://github.com/MetaMask/core/pull/8999)) + +## [5.2.0] + +### Added + +- Add Monad mainnet (`0x8f`, chain ID 143) to the default enabled network map for new users ([#8743](https://github.com/MetaMask/core/pull/8743)) + +### Changed + +- Bump `@metamask/network-controller` from `^31.0.0` to `^32.0.0` ([#8765](https://github.com/MetaMask/core/pull/8765), [#8774](https://github.com/MetaMask/core/pull/8774)) +- Bump `@metamask/controller-utils` from `^12.0.0` to `^12.1.0` ([#8774](https://github.com/MetaMask/core/pull/8774)) +- Bump `@metamask/transaction-controller` from `^65.3.0` to `^65.4.0` ([#8796](https://github.com/MetaMask/core/pull/8796)) +- Bump `@metamask/multichain-network-controller` from `^3.1.1` to `^3.1.2` ([#8834](https://github.com/MetaMask/core/pull/8834)) + +## [5.1.1] + +### Changed + +- Bump `@metamask/transaction-controller` from `^65.0.0` to `^65.3.0` ([#8691](https://github.com/MetaMask/core/pull/8691), [#8722](https://github.com/MetaMask/core/pull/8722), [#8755](https://github.com/MetaMask/core/pull/8755)) +- Bump `@metamask/controller-utils` from `^11.20.0` to `^12.0.0` ([#8755](https://github.com/MetaMask/core/pull/8755)) +- Bump `@metamask/multichain-network-controller` from `^3.1.0` to `^3.1.1` ([#8755](https://github.com/MetaMask/core/pull/8755)) +- Bump `@metamask/network-controller` from `^30.1.0` to `^31.0.0` ([#8755](https://github.com/MetaMask/core/pull/8755)) + +## [5.1.0] + +### Changed + +- Bump `@metamask/multichain-network-controller` from `^3.0.6` to `^3.1.0` ([#8665](https://github.com/MetaMask/core/pull/8665)) +- Bump `@metamask/messenger` from `^1.0.0` to `^1.2.0` ([#8364](https://github.com/MetaMask/core/pull/8364), [#8373](https://github.com/MetaMask/core/pull/8373), [#8632](https://github.com/MetaMask/core/pull/8632)) +- Bump `@metamask/transaction-controller` from `^64.0.0` to `^65.0.0` ([#8432](https://github.com/MetaMask/core/pull/8432), [#8447](https://github.com/MetaMask/core/pull/8447), [#8482](https://github.com/MetaMask/core/pull/8482), [#8585](https://github.com/MetaMask/core/pull/8585), [#8613](https://github.com/MetaMask/core/pull/8613)) +- Bump `@metamask/base-controller` from `^9.0.1` to `^9.1.0` ([#8457](https://github.com/MetaMask/core/pull/8457)) +- Bump `@metamask/keyring-api` from `^21.6.0` to `^23.1.0` ([#8464](https://github.com/MetaMask/core/pull/8464), [#8647](https://github.com/MetaMask/core/pull/8647)) +- Bump `@metamask/network-controller` from `^30.0.1` to `^30.1.0` ([#8636](https://github.com/MetaMask/core/pull/8636)) + +## [5.0.2] + +### Changed + +- Bump `@metamask/transaction-controller` from `^63.3.1` to `^64.0.0` ([#8359](https://github.com/MetaMask/core/pull/8359)) +- Bump `@metamask/controller-utils` from `^11.19.0` to `^11.20.0` ([#8344](https://github.com/MetaMask/core/pull/8344)) + +## [5.0.1] + +### Changed + +- Bump `@metamask/base-controller` from `^9.0.0` to `^9.0.1` ([#8317](https://github.com/MetaMask/core/pull/8317)) +- Bump `@metamask/messenger` from `^0.3.0` to `^1.0.0` ([#8317](https://github.com/MetaMask/core/pull/8317)) +- Bump `@metamask/multichain-network-controller` from `^3.0.5` to `^3.0.6` ([#8317](https://github.com/MetaMask/core/pull/8317)) +- Bump `@metamask/network-controller` from `^30.0.0` to `^30.0.1` ([#8317](https://github.com/MetaMask/core/pull/8317)) +- Bump `@metamask/keyring-api` from `^21.5.0` to `^21.6.0` ([#8259](https://github.com/MetaMask/core/pull/8259)) +- Bump `@metamask/transaction-controller` from `^63.0.0` to `^63.3.1` ([#8272](https://github.com/MetaMask/core/pull/8272), [#8301](https://github.com/MetaMask/core/pull/8301), [#8313](https://github.com/MetaMask/core/pull/8313), [#8317](https://github.com/MetaMask/core/pull/8317)) + +## [5.0.0] + +### Added + +- Expose missing public `NetworkEnablementController` methods through its messenger ([#8164](https://github.com/MetaMask/core/pull/8164)) + - The following actions are now available: + - `NetworkEnablementController:init` + - `NetworkEnablementController:initNativeAssetIdentifiers` + - `NetworkEnablementController:enableNetworkInNamespace` + - `NetworkEnablementController:enableAllPopularNetworks` + - `NetworkEnablementController:isNetworkEnabled` + - Corresponding action type (`NetworkEnablementControllerIsNetworkEnabledAction`) is available as well. + +### Changed + +- **BREAKING:** Standardize names of `NetworkEnablementController` messenger action types ([#8164](https://github.com/MetaMask/core/pull/8164)) + - The `NetworkEnablementController` action `NetworkEnablementControllerSetEnabledNetworksAction` has been renamed to `NetworkEnablementControllerEnableNetworkAction` so it matches the method name. + - These changes only affect the types. The action type strings themselves have not changed, so you do not need to update the list of actions you pass when initializing `NetworkEnablementController` messengers. +- Bump `@metamask/multichain-network-controller` from `^3.0.4` to `^3.0.5` ([#8140](https://github.com/MetaMask/core/pull/8140)) +- Bump `@metamask/transaction-controller` from `^62.20.0` to `^63.0.0` ([#8140](https://github.com/MetaMask/core/pull/8140), [#8217](https://github.com/MetaMask/core/pull/8217), [#8225](https://github.com/MetaMask/core/pull/8225)) + +## [4.2.0] + +### Added + +- Add `listPopularNetworks()` method and `NetworkEnablementController:listPopularNetworks` messenger action. Returns CAIP-2 chain IDs for popular EVM networks (from POPULAR_NETWORKS) and Bitcoin, Solana, and Tron mainnets that are present in NetworkController `networkConfigurationsByChainId` and MultichainNetworkController `multichainNetworkConfigurationsByChainId` respectively. ([#8105](https://github.com/MetaMask/core/pull/8105)) +- Add `listPopularEvmNetworks()` and `NetworkEnablementController:listPopularEvmNetworks` for popular EVM networks only (returns hex chain IDs); add `listPopularMultichainNetworks()` and `NetworkEnablementController:listPopularMultichainNetworks` for Bitcoin, Solana, and Tron mainnets only (each restricted to configured networks in the corresponding controller state). ([#8105](https://github.com/MetaMask/core/pull/8105)) + +### Changed + +- `listPopularEvmNetworks()` now returns `Hex[]` (e.g. `'0x1'`, `'0x89'`) instead of CAIP-2 chain IDs; `listPopularNetworks()` still returns CAIP-2 for the full combined list. ([#8105](https://github.com/MetaMask/core/pull/8105)) +- Bump `@metamask/transaction-controller` from `^62.17.1` to `^62.20.0` ([#8005](https://github.com/MetaMask/core/pull/8005), [#8031](https://github.com/MetaMask/core/pull/8031), [#8104](https://github.com/MetaMask/core/pull/8104)) + +## [4.1.2] + +### Changed + +- Bump `@metamask/multichain-network-controller` from `^3.0.3` to `^3.0.4` ([#7996](https://github.com/MetaMask/core/pull/7996)) +- Bump `@metamask/network-controller` from `^29.0.0` to `^30.0.0` ([#7996](https://github.com/MetaMask/core/pull/7996)) +- Bump `@metamask/transaction-controller` from `^62.17.0` to `^62.17.1` ([#7996](https://github.com/MetaMask/core/pull/7996)) +- Bump `@metamask/controller-utils` from `^11.18.0` to `^11.19.0` ([#7995](https://github.com/MetaMask/core/pull/7995)) + +## [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.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] + +### Added + +- Add `nativeAssetIdentifiers` state property that maps CAIP-2 chain IDs to CAIP-19-like native asset identifiers (e.g., `eip155:1/slip44:60`) ([#7609](https://github.com/MetaMask/core/pull/7609)) +- Add `initNativeAssetIdentifiers` method to populate `nativeAssetIdentifiers` state property ([#7609](https://github.com/MetaMask/core/pull/7609)) + - This is designed to be called during controller initialization. +- Add `Slip44Service` to look up SLIP-44 coin types by native currency symbol ([#7609](https://github.com/MetaMask/core/pull/7609)) +- Add `@metamask/slip44` dependency for SLIP-44 coin type lookups ([#7609](https://github.com/MetaMask/core/pull/7609)) +- Subscribe to `NetworkController:stateChange` to update `nativeAssetIdentifiers` when a network's native currency changes ([#7609](https://github.com/MetaMask/core/pull/7609)) + +### 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), [#7220](https://github.com/MetaMask/core/pull/7220), [#7236](https://github.com/MetaMask/core/pull/7236), [#7257](https://github.com/MetaMask/core/pull/7257), [#7258](https://github.com/MetaMask/core/pull/7258), [#7289](https://github.com/MetaMask/core/pull/7289), [#7325](https://github.com/MetaMask/core/pull/7325), [#7430](https://github.com/MetaMask/core/pull/7430), [#7494](https://github.com/MetaMask/core/pull/7494), [#7534](https://github.com/MetaMask/core/pull/7534), [#7583](https://github.com/MetaMask/core/pull/7583), [#7596](https://github.com/MetaMask/core/pull/7596), [#7602](https://github.com/MetaMask/core/pull/7602), [#7604](https://github.com/MetaMask/core/pull/7604), [#7642](https://github.com/MetaMask/core/pull/7642)) + - The dependencies moved are: + - `@metamask/multichain-network-controller` (^3.0.2) + - `@metamask/network-controller` (^29.0.0) + - `@metamask/transaction-controller` (^62.9.2) + - In clients, it is now possible for multiple versions of these packages to exist in the dependency tree. + - For example, this scenario would be valid: a client relies on `@metamask/controller-a` 1.0.0 and `@metamask/controller-b` 1.0.0, and `@metamask/controller-b` depends on `@metamask/controller-a` 1.1.0. + - Note, however, that the versions specified in the client's `package.json` always "win", and you are expected to keep them up to date so as not to break controller and service intercommunication. +- Bump `@metamask/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)) + +### Fixed + +- Clean up Slip44Service ([#7626](https://github.com/MetaMask/core/pull/7626)) +- Add missing MegaETH to POPULARE_NETWORKS list ([#7286](https://github.com/MetaMask/core/pull/7286)) + +## [4.0.0] + +### Changed + +- Bump `@metamask/controller-utils` from `^11.15.0` to `^11.16.0` ([#7202](https://github.com/MetaMask/core/pull/7202)) +- **BREAKING:** Bump `@metamask/transaction-controller` from `^61.0.0` to `^62.0.0` ([#7202](https://github.com/MetaMask/core/pull/7202)) +- **BREAKING:** Bump `@metamask/network-controller` from `^25.0.0` to `^26.0.0` ([#7202](https://github.com/MetaMask/core/pull/7202)) +- **BREAKING:** Bump `@metamask/multichain-network-controller` from `^2.0.0` to `^3.0.0` ([#7202](https://github.com/MetaMask/core/pull/7202)) + +### Fixed + +- include additional popular networks now enabled by default ([#7014](https://github.com/MetaMask/core/pull/7014)) + +## [3.1.0] + +### Added + +- Add Monad network into constant POPULAR_NETWORKS ([#6978](https://github.com/MetaMask/core/pull/6978)) + +## [3.0.0] + +### Changed + +- **BREAKING:** Use new `Messenger` from `@metamask/messenger` ([#6540](https://github.com/MetaMask/core/pull/6540)) + - Previously, `NetworkEnablementController` accepted a `RestrictedMessenger` instance from `@metamask/base-controller`. +- **BREAKING:** Metadata property `anonymous` renamed to `includeInDebugSnapshot` ([#6540](https://github.com/MetaMask/core/pull/6540)) +- **BREAKING:** Bump `@metamask/multichain-network-controller` from `^1.0.0` to `^2.0.0` ([#6962](https://github.com/MetaMask/core/pull/6962)) +- **BREAKING:** Bump `@metamask/network-controller` from `^24.0.0` to `^25.0.0` ([#6962](https://github.com/MetaMask/core/pull/6962)) +- **BREAKING:** Bump `@metamask/transaction-controller` from `^60.0.0` to `^61.0.0` ([#6962](https://github.com/MetaMask/core/pull/6962)) +- Bump `@metamask/base-controller` from `^8.4.2` to `^9.0.0` ([#6962](https://github.com/MetaMask/core/pull/6962)) + +## [2.1.2] + +### Changed + +- Bump `@metamask/base-controller` from `^8.4.1` to `^8.4.2` ([#6917](https://github.com/MetaMask/core/pull/6917)) +- Bump `@metamask/network-controller` from `^24.2.2` to `^24.3.0` ([#6883](https://github.com/MetaMask/core/pull/6883)) +- Bump `@metamask/transaction-controller` from `^60.7.0` to `^60.8.0` ([#6883](https://github.com/MetaMask/core/pull/6883)) + +## [2.1.1] + +### Changed + +- Bump `@metamask/base-controller` from `^8.4.0` to `^8.4.1` ([#6807](https://github.com/MetaMask/core/pull/6807)) +- Bump `@metamask/controller-utils` from `^11.14.0` to `^11.14.1` ([#6807](https://github.com/MetaMask/core/pull/6807)) + +## [2.1.0] + +### Added + +- Add Tron network support ([#6734](https://github.com/MetaMask/core/pull/6734)) + - Adds Tron namespace to the enabled networks map + - Reuses the Keyring API types instead of redeclaring them in the controller + +### Changed + +- Bump `@metamask/utils` from `^11.8.0` to `^11.8.1` ([#6708](https://github.com/MetaMask/core/pull/6708)) +- Improved network addition logic — if multiple popular networks are enabled and the user is in popular networks mode, adding another popular network keeps the current selection; otherwise, it switches to the newly added network. ([#6693](https://github.com/MetaMask/core/pull/6693)) + +## [2.0.0] + +### Changed + +- **BREAKING:** Bump peer dependency `@metamask/multichain-network-controller` from `^0.11.0` to `^1.0.0` ([#6652](https://github.com/MetaMask/core/pull/6652), [#6676](https://github.com/MetaMask/core/pull/6676)) + +## [1.2.0] + +### Changed + +- Bump `@metamask/controller-utils` from `^11.12.0` to `^11.14.0` ([#6620](https://github.com/MetaMask/core/pull/6620), [#6629](https://github.com/MetaMask/core/pull/6629)) +- Bump `@metamask/base-controller` from `^8.3.0` to `^8.4.0` ([#6632](https://github.com/MetaMask/core/pull/6632)) + +### Fixed + +- Fix `init()` method to preserve existing user network settings instead of resetting them, while syncing with NetworkController and MultichainNetworkController states ([#6658](https://github.com/MetaMask/core/pull/6658)) + +## [1.1.0] + +### Added + +- Add `enableNetworkInNamespace()` method to enable a network within a specific namespace while disabling all other networks in that same namespace, providing namespace-specific exclusive behavior ([#6602](https://github.com/MetaMask/core/pull/6602)) + +## [1.0.0] + +### Changed + +- Bump `@metamask/utils` from `^11.4.2` to `^11.8.0` ([#6588](https://github.com/MetaMask/core/pull/6588)) +- **BREAKING:** `enableNetwork()` and `enableAllPopularNetworks()` now disable networks across all namespaces instead of only within the same namespace, implementing truly exclusive network selection across all blockchain types ([#6591](https://github.com/MetaMask/core/pull/6591)) + +## [0.6.0] + +### Added + +- Add two new controller state metadata properties: `includeInStateLogs` and `usedInUi` ([#6472](https://github.com/MetaMask/core/pull/6472)) + +## [0.5.0] + +### Added + +- Add Solana and Bitcoin testnet support with the default values disabled ([#6532](https://github.com/MetaMask/core/pull/6532)) +- Add Bitcoin network support with automatic enablement when configured in MultichainNetworkController ([#6455](https://github.com/MetaMask/core/pull/6455)) +- Add `BtcScope` enum for Bitcoin mainnet and testnet caip chain IDs ([#6455](https://github.com/MetaMask/core/pull/6455)) +- Add Bitcoin network enablement logic to `init()` and `enableAllPopularNetworks()` methods ([#6455](https://github.com/MetaMask/core/pull/6455)) + +### Changed + +- Add Bitcoin testnet and signet networks with default disabled state, with only mainnet enabled by default ([#6474](https://github.com/MetaMask/core/pull/6474)) +- **BREAKING:** Allow disabling the last remaining network in a namespace to align with BIP-44, where account groups shouldn't be forced to always keep at least one active network ([#6499](https://github.com/MetaMask/core/pull/6499)) +- Bump `@metamask/base-controller` from `^8.2.0` to `^8.3.0` ([#6465](https://github.com/MetaMask/core/pull/6465)) + +## [0.4.0] + +### Added + +- Add `enableAllPopularNetworks()` method to enable all popular networks and Solana mainnet simultaneously ([#6367](https://github.com/MetaMask/core/pull/6367)) + +### Changed + +- **BREAKING:** `enableNetwork()` now implements exclusive behavior - disables all other networks in the same namespace before enabling the target network ([#6367](https://github.com/MetaMask/core/pull/6367)) +- Bump `@metamask/base-controller` from `^8.1.0` to `^8.2.0` ([#6355](https://github.com/MetaMask/core/pull/6355)) + +## [0.3.0] + +### Changed + +- **BREAKING:** Bump peer dependency `@metamask/transaction-controller` from `^59.0.0` to `^60.0.0` ([#6345](https://github.com/MetaMask/core/pull/6345)) + +## [0.2.0] + +### Added + +- Add `init()` method to safely initialize network enablement state from controller configurations ([#6329](https://github.com/MetaMask/core/pull/6329)) + +### Changed + +- Change transaction listener from `TransactionController:transactionConfirmed` to `TransactionController:transactionSubmitted` for earlier network enablement ([#6329](https://github.com/MetaMask/core/pull/6329)) +- Update transaction event handler to properly access chainId from nested transactionMeta structure ([#6329](https://github.com/MetaMask/core/pull/6329)) +- Bump `@metamask/controller-utils` from `^11.11.0` to `^11.12.0` ([#6303](https://github.com/MetaMask/core/pull/6303)) + +## [0.1.1] + +### Added + +- add `isNetworkEnabled` method to check if network is enabled ([#6287](https://github.com/MetaMask/core/pull/6287)) +- add `Palm network` and `HypeEVM` network to list of popular network ([#6287](https://github.com/MetaMask/core/pull/6287)) + +### Changed + +- Bump `@metamask/base-controller` from `^8.0.1` to `^8.1.0` ([#6284](https://github.com/MetaMask/core/pull/6284)) + +## [0.1.0] + +### Added + +- Initial release ([#6028](https://github.com/MetaMask/core/pull/6028)) + +[Unreleased]: https://github.com/MetaMask/core/compare/@metamask/network-enablement-controller@6.0.5...HEAD +[6.0.5]: https://github.com/MetaMask/core/compare/@metamask/network-enablement-controller@6.0.4...@metamask/network-enablement-controller@6.0.5 +[6.0.4]: https://github.com/MetaMask/core/compare/@metamask/network-enablement-controller@6.0.3...@metamask/network-enablement-controller@6.0.4 +[6.0.3]: https://github.com/MetaMask/core/compare/@metamask/network-enablement-controller@6.0.2...@metamask/network-enablement-controller@6.0.3 +[6.0.2]: https://github.com/MetaMask/core/compare/@metamask/network-enablement-controller@6.0.1...@metamask/network-enablement-controller@6.0.2 +[6.0.1]: https://github.com/MetaMask/core/compare/@metamask/network-enablement-controller@6.0.0...@metamask/network-enablement-controller@6.0.1 +[6.0.0]: https://github.com/MetaMask/core/compare/@metamask/network-enablement-controller@5.6.0...@metamask/network-enablement-controller@6.0.0 +[5.6.0]: https://github.com/MetaMask/core/compare/@metamask/network-enablement-controller@5.5.0...@metamask/network-enablement-controller@5.6.0 +[5.5.0]: https://github.com/MetaMask/core/compare/@metamask/network-enablement-controller@5.4.1...@metamask/network-enablement-controller@5.5.0 +[5.4.1]: https://github.com/MetaMask/core/compare/@metamask/network-enablement-controller@5.4.0...@metamask/network-enablement-controller@5.4.1 +[5.4.0]: https://github.com/MetaMask/core/compare/@metamask/network-enablement-controller@5.3.0...@metamask/network-enablement-controller@5.4.0 +[5.3.0]: https://github.com/MetaMask/core/compare/@metamask/network-enablement-controller@5.2.0...@metamask/network-enablement-controller@5.3.0 +[5.2.0]: https://github.com/MetaMask/core/compare/@metamask/network-enablement-controller@5.1.1...@metamask/network-enablement-controller@5.2.0 +[5.1.1]: https://github.com/MetaMask/core/compare/@metamask/network-enablement-controller@5.1.0...@metamask/network-enablement-controller@5.1.1 +[5.1.0]: https://github.com/MetaMask/core/compare/@metamask/network-enablement-controller@5.0.2...@metamask/network-enablement-controller@5.1.0 +[5.0.2]: https://github.com/MetaMask/core/compare/@metamask/network-enablement-controller@5.0.1...@metamask/network-enablement-controller@5.0.2 +[5.0.1]: https://github.com/MetaMask/core/compare/@metamask/network-enablement-controller@5.0.0...@metamask/network-enablement-controller@5.0.1 +[5.0.0]: https://github.com/MetaMask/core/compare/@metamask/network-enablement-controller@4.2.0...@metamask/network-enablement-controller@5.0.0 +[4.2.0]: https://github.com/MetaMask/core/compare/@metamask/network-enablement-controller@4.1.2...@metamask/network-enablement-controller@4.2.0 +[4.1.2]: https://github.com/MetaMask/core/compare/@metamask/network-enablement-controller@4.1.1...@metamask/network-enablement-controller@4.1.2 +[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 +[3.0.0]: https://github.com/MetaMask/core/compare/@metamask/network-enablement-controller@2.1.2...@metamask/network-enablement-controller@3.0.0 +[2.1.2]: https://github.com/MetaMask/core/compare/@metamask/network-enablement-controller@2.1.1...@metamask/network-enablement-controller@2.1.2 +[2.1.1]: https://github.com/MetaMask/core/compare/@metamask/network-enablement-controller@2.1.0...@metamask/network-enablement-controller@2.1.1 +[2.1.0]: https://github.com/MetaMask/core/compare/@metamask/network-enablement-controller@2.0.0...@metamask/network-enablement-controller@2.1.0 +[2.0.0]: https://github.com/MetaMask/core/compare/@metamask/network-enablement-controller@1.2.0...@metamask/network-enablement-controller@2.0.0 +[1.2.0]: https://github.com/MetaMask/core/compare/@metamask/network-enablement-controller@1.1.0...@metamask/network-enablement-controller@1.2.0 +[1.1.0]: https://github.com/MetaMask/core/compare/@metamask/network-enablement-controller@1.0.0...@metamask/network-enablement-controller@1.1.0 +[1.0.0]: https://github.com/MetaMask/core/compare/@metamask/network-enablement-controller@0.6.0...@metamask/network-enablement-controller@1.0.0 +[0.6.0]: https://github.com/MetaMask/core/compare/@metamask/network-enablement-controller@0.5.0...@metamask/network-enablement-controller@0.6.0 +[0.5.0]: https://github.com/MetaMask/core/compare/@metamask/network-enablement-controller@0.4.0...@metamask/network-enablement-controller@0.5.0 +[0.4.0]: https://github.com/MetaMask/core/compare/@metamask/network-enablement-controller@0.3.0...@metamask/network-enablement-controller@0.4.0 +[0.3.0]: https://github.com/MetaMask/core/compare/@metamask/network-enablement-controller@0.2.0...@metamask/network-enablement-controller@0.3.0 +[0.2.0]: https://github.com/MetaMask/core/compare/@metamask/network-enablement-controller@0.1.1...@metamask/network-enablement-controller@0.2.0 +[0.1.1]: https://github.com/MetaMask/core/compare/@metamask/network-enablement-controller@0.1.0...@metamask/network-enablement-controller@0.1.1 +[0.1.0]: https://github.com/MetaMask/core/releases/tag/@metamask/network-enablement-controller@0.1.0 diff --git a/packages/network-enablement-controller/LICENSE b/packages/network-enablement-controller/LICENSE new file mode 100644 index 00000000000..c259cd7ebcf --- /dev/null +++ b/packages/network-enablement-controller/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2025 MetaMask + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packages/network-enablement-controller/README.md b/packages/network-enablement-controller/README.md new file mode 100644 index 00000000000..ffad912635a --- /dev/null +++ b/packages/network-enablement-controller/README.md @@ -0,0 +1,182 @@ +# Network Enablement Controller + +A MetaMask controller for managing network enablement state across different blockchain networks. + +## Overview + +The NetworkEnablementController tracks which networks are enabled/disabled for the user and provides methods to toggle network states. It supports both EVM (EIP-155) and non-EVM networks like Solana. + +## Installation + +```bash +npm install @metamask/network-enablement-controller +``` + +```bash +yarn add @metamask/network-enablement-controller +``` + +## Usage + +### Basic Controller Usage + +```typescript +import { NetworkEnablementController } from '@metamask/network-enablement-controller'; + +// Create controller instance +const controller = new NetworkEnablementController({ + messenger, + state: { + enabledNetworkMap: { + eip155: { + '0x1': true, // Ethereum mainnet enabled + '0xa': false, // Optimism disabled + }, + solana: { + 'solana:mainnet': true, + }, + }, + }, +}); + +// Enable a network +controller.setEnabledNetwork('0x1'); // Hex format for EVM +controller.setEnabledNetwork('eip155:1'); // CAIP-2 format for EVM +controller.setEnabledNetwork('solana:mainnet'); // CAIP-2 format for Solana + +// Disable a network +controller.setDisabledNetwork('0xa'); + +// Check if network is enabled +const isEnabled = controller.isNetworkEnabled('0x1'); + +// Get all enabled networks for a namespace +const evmNetworks = controller.getEnabledNetworksForNamespace('eip155'); + +// Get all enabled networks across all namespaces +const allNetworks = controller.getAllEnabledNetworks(); +``` + +### Using Selectors (Redux-style) + +The controller also provides selectors that can be used in Redux contexts or any state management system: + +```typescript +import { + selectIsNetworkEnabled, + selectAllEnabledNetworks, + selectEnabledNetworksForNamespace, + selectEnabledEvmNetworks, + selectEnabledSolanaNetworks, +} from '@metamask/network-enablement-controller'; + +// Get controller state +const state = controller.state; + +// Check if a specific network is enabled +const isEthereumEnabled = selectIsNetworkEnabled('0x1')(state); +const isSolanaEnabled = selectIsNetworkEnabled('solana:mainnet')(state); + +// Get all enabled networks across all namespaces +const allEnabledNetworks = selectAllEnabledNetworks(state); +// Returns: { eip155: ['0x1'], solana: ['solana:mainnet'] } + +// Get enabled networks for a specific namespace +const evmNetworks = selectEnabledNetworksForNamespace('eip155')(state); +const solanaNetworks = selectEnabledNetworksForNamespace('solana')(state); + +// Convenience selectors for specific network types +const enabledEvmNetworks = selectEnabledEvmNetworks(state); +const enabledSolanaNetworks = selectEnabledSolanaNetworks(state); + +// Get total count of enabled networks +const totalEnabled = selectEnabledNetworksCount(state); + +// Check if any networks are enabled for a namespace +const hasEvmNetworks = selectHasEnabledNetworksForNamespace('eip155')(state); +``` + +## API Reference + +### Controller Methods + +#### `setEnabledNetwork(chainId: Hex | CaipChainId): void` + +Enables a network for the user. Accepts either Hex chain IDs (for EVM networks) or CAIP-2 chain IDs (for any blockchain network). + +#### `setDisabledNetwork(chainId: Hex | CaipChainId): void` + +Disables a network for the user. Prevents disabling the last remaining enabled network. + +#### `isNetworkEnabled(chainId: Hex | CaipChainId): boolean` + +Checks if a network is currently enabled. Returns false for unknown networks. + +#### `getEnabledNetworksForNamespace(namespace: CaipNamespace): string[]` + +Gets all enabled networks for a specific namespace. + +#### `getAllEnabledNetworks(): Record` + +Gets all enabled networks across all namespaces. + +### Selectors + +#### `selectIsNetworkEnabled(chainId: Hex | CaipChainId)` + +Returns a selector function that checks if a specific network is enabled. + +#### `selectAllEnabledNetworks` + +Returns a selector function that gets all enabled networks across all namespaces. + +#### `selectEnabledNetworksForNamespace(namespace: CaipNamespace)` + +Returns a selector function that gets enabled networks for a specific namespace. + +#### `selectEnabledNetworksCount` + +Returns a selector function that gets the total count of enabled networks. + +#### `selectHasEnabledNetworksForNamespace(namespace: CaipNamespace)` + +Returns a selector function that checks if any networks are enabled for a namespace. + +#### `selectEnabledEvmNetworks` + +Returns a selector function that gets all enabled EVM networks. + +#### `selectEnabledSolanaNetworks` + +Returns a selector function that gets all enabled Solana networks. + +## Chain ID Formats + +The controller supports two chain ID formats: + +1. **Hex format**: Traditional EVM chain IDs (e.g., `'0x1'` for Ethereum mainnet) +2. **CAIP-2 format**: Chain Agnostic Improvement Proposal format (e.g., `'eip155:1'` for Ethereum mainnet, `'solana:mainnet'` for Solana) + +## Network Types + +### EVM Networks (eip155 namespace) + +- Ethereum Mainnet: `'0x1'` or `'eip155:1'` +- Optimism: `'0xa'` or `'eip155:10'` +- Arbitrum One: `'0xa4b1'` or `'eip155:42161'` + +### Solana Networks (solana namespace) + +- Solana Mainnet: `'solana:mainnet'` +- Solana Testnet: `'solana:testnet'` + +## State Persistence + +The controller state is automatically persisted and restored between sessions. The `enabledNetworkMap` is stored anonymously to protect user privacy. + +## Safety Features + +- **At least one network enabled**: The controller ensures at least one network is always enabled +- **Unknown network protection**: Prevents enabling networks not configured in the system +- **Exclusive mode**: When enabling non-popular networks, all other networks are disabled +- **Last network protection**: Prevents disabling the last remaining enabled network diff --git a/packages/network-enablement-controller/jest.config.js b/packages/network-enablement-controller/jest.config.js new file mode 100644 index 00000000000..8df1ea4fd9d --- /dev/null +++ b/packages/network-enablement-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: 95.18, + functions: 100, + lines: 100, + statements: 100, + }, + }, +}); diff --git a/packages/network-enablement-controller/package.json b/packages/network-enablement-controller/package.json new file mode 100644 index 00000000000..02246997bcd --- /dev/null +++ b/packages/network-enablement-controller/package.json @@ -0,0 +1,85 @@ +{ + "name": "@metamask/network-enablement-controller", + "version": "6.0.5", + "description": "Provides an interface to the currently enabled network using a MetaMask-compatible provider object", + "keywords": [ + "Ethereum", + "MetaMask" + ], + "homepage": "https://github.com/MetaMask/core/tree/main/packages/network-enablement-controller#readme", + "bugs": { + "url": "https://github.com/MetaMask/core/issues" + }, + "license": "MIT", + "repository": { + "type": "git", + "url": "https://github.com/MetaMask/core.git" + }, + "files": [ + "dist/" + ], + "sideEffects": false, + "main": "./dist/index.cjs", + "types": "./dist/index.d.cts", + "exports": { + ".": { + "import": { + "types": "./dist/index.d.mts", + "default": "./dist/index.mjs" + }, + "require": { + "types": "./dist/index.d.cts", + "default": "./dist/index.cjs" + } + }, + "./package.json": "./package.json" + }, + "publishConfig": { + "access": "public", + "registry": "https://registry.npmjs.org/" + }, + "scripts": { + "build": "ts-bridge --project tsconfig.build.json --verbose --clean --no-references", + "build:all": "ts-bridge --project tsconfig.build.json --verbose --clean", + "build:docs": "typedoc", + "changelog:update": "../../scripts/update-changelog.sh @metamask/network-enablement-controller", + "changelog:validate": "../../scripts/validate-changelog.sh @metamask/network-enablement-controller", + "lint:tsconfigs": "tsx ../../scripts/lint-tsconfigs/lint-tsconfigs.mts", + "lint:tsconfigs:fix": "tsx ../../scripts/lint-tsconfigs/lint-tsconfigs.mts --fix", + "messenger-action-types:check": "tsx ../../packages/messenger-cli/src/cli.ts --formatter oxfmt --esm --check", + "messenger-action-types:generate": "tsx ../../packages/messenger-cli/src/cli.ts --formatter oxfmt --esm --generate", + "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.1.0", + "@metamask/config-registry-controller": "^3.1.0", + "@metamask/controller-utils": "^12.3.0", + "@metamask/keyring-api": "^24.0.0", + "@metamask/messenger": "^2.0.0", + "@metamask/multichain-network-controller": "^3.2.4", + "@metamask/network-controller": "^36.0.0", + "@metamask/slip44": "^4.3.0", + "@metamask/transaction-controller": "^69.6.1", + "@metamask/utils": "^11.11.0", + "reselect": "^5.1.1" + }, + "devDependencies": { + "@metamask/auto-changelog": "^6.1.0", + "@ts-bridge/cli": "^0.6.4", + "@types/jest": "^30.0.0", + "deepmerge": "^4.2.2", + "jest": "^30.4.2", + "ts-jest": "^29.4.11", + "tsx": "^4.20.5", + "typedoc": "^0.25.13", + "typedoc-plugin-missing-exports": "^2.0.0", + "typescript": "~5.3.3" + }, + "engines": { + "node": "^18.18 || >=20" + } +} diff --git a/packages/network-enablement-controller/src/NetworkEnablementController-method-action-types.ts b/packages/network-enablement-controller/src/NetworkEnablementController-method-action-types.ts new file mode 100644 index 00000000000..14dd1cb4b07 --- /dev/null +++ b/packages/network-enablement-controller/src/NetworkEnablementController-method-action-types.ts @@ -0,0 +1,208 @@ +/** + * This file is auto generated. + * Do not edit manually. + */ + +import type { NetworkEnablementController } from './NetworkEnablementController.js'; + +/** + * Enables or disables a network for the user. + * + * This method accepts either a Hex chain ID (for EVM networks) or a CAIP-2 chain ID + * (for any blockchain network). The method will automatically convert Hex chain IDs + * to CAIP-2 format internally. This dual parameter support allows for backward + * compatibility with existing EVM chain ID formats while supporting newer + * multi-chain standards. + * + * When enabling a non-popular network, this method will disable all other networks + * to ensure only one network is active at a time (exclusive mode). + * + * @param chainId - The chain ID of the network to enable or disable. Can be either: + * - A Hex string (e.g., '0x1' for Ethereum mainnet) for EVM networks + * - A CAIP-2 chain ID (e.g., 'eip155:1' for Ethereum mainnet, 'solana:mainnet' for Solana) + */ +export type NetworkEnablementControllerEnableNetworkAction = { + type: `NetworkEnablementController:enableNetwork`; + handler: NetworkEnablementController['enableNetwork']; +}; + +/** + * Enables a network for the user within a specific namespace. + * + * This method accepts either a Hex chain ID (for EVM networks) or a CAIP-2 chain ID + * (for any blockchain network) and enables it within the specified namespace. + * The method validates that the chainId belongs to the specified namespace for safety. + * + * Before enabling the target network, this method disables all other networks + * in the same namespace to ensure exclusive behavior within the namespace. + * + * @param chainId - The chain ID of the network to enable. Can be either: + * - A Hex string (e.g., '0x1' for Ethereum mainnet) for EVM networks + * - A CAIP-2 chain ID (e.g., 'eip155:1' for Ethereum mainnet, 'solana:mainnet' for Solana) + * @param namespace - The CAIP namespace where the network should be enabled + * @throws Error if the chainId's derived namespace doesn't match the provided namespace + */ +export type NetworkEnablementControllerEnableNetworkInNamespaceAction = { + type: `NetworkEnablementController:enableNetworkInNamespace`; + handler: NetworkEnablementController['enableNetworkInNamespace']; +}; + +/** + * Enables all popular networks and Solana mainnet. + * + * This method first disables all networks across all namespaces, then enables + * all networks defined in POPULAR_NETWORKS (EVM networks), Solana mainnet, and + * Bitcoin mainnet. This provides exclusive behavior - only popular networks will + * be enabled after calling this method. + * + * Popular networks that don't exist in NetworkController or MultichainNetworkController configurations will be skipped silently. + */ +export type NetworkEnablementControllerEnableAllPopularNetworksAction = { + type: `NetworkEnablementController:enableAllPopularNetworks`; + handler: NetworkEnablementController['enableAllPopularNetworks']; +}; + +/** + * Initializes the network enablement state from network controller configurations. + * + * This method reads the current network configurations from both NetworkController + * and MultichainNetworkController and syncs the enabled network map and nativeAssetIdentifiers accordingly. + * It ensures proper namespace buckets exist for all configured networks and only + * adds missing networks with a default value of false, preserving existing user settings. + * + * This method should be called after the NetworkController and MultichainNetworkController + * have been initialized and their configurations are available. + */ +export type NetworkEnablementControllerInitAction = { + type: `NetworkEnablementController:init`; + handler: NetworkEnablementController['init']; +}; + +/** + * Initializes the native asset identifiers from network configurations. + * This method should be called from the client during controller initialization + * to populate the nativeAssetIdentifiers state based on actual network configurations. + * + * @param networks - Array of network configurations with chainId and nativeCurrency + * @example + * ```typescript + * const evmNetworks = Object.values(networkControllerState.networkConfigurationsByChainId) + * .map(config => ({ + * chainId: toEvmCaipChainId(config.chainId), + * nativeCurrency: config.nativeCurrency, + * })); + * + * const multichainNetworks = Object.values(multichainState.multichainNetworkConfigurationsByChainId) + * .map(config => ({ + * chainId: config.chainId, + * nativeCurrency: config.nativeCurrency, + * })); + * + * await controller.initNativeAssetIdentifiers([...evmNetworks, ...multichainNetworks]); + * ``` + */ +export type NetworkEnablementControllerInitNativeAssetIdentifiersAction = { + type: `NetworkEnablementController:initNativeAssetIdentifiers`; + handler: NetworkEnablementController['initNativeAssetIdentifiers']; +}; + +/** + * Disables a network for the user. + * + * This method accepts either a Hex chain ID (for EVM networks) or a CAIP-2 chain ID + * (for any blockchain network). The method will automatically convert Hex chain IDs + * to CAIP-2 format internally. + * + * Note: This method will prevent disabling the last remaining enabled network + * to ensure at least one network is always available. + * + * @param chainId - The chain ID of the network to disable. Can be either: + * - A Hex string (e.g., '0x1' for Ethereum mainnet) for EVM networks + * - A CAIP-2 chain ID (e.g., 'eip155:1' for Ethereum mainnet, 'solana:mainnet' for Solana) + */ +export type NetworkEnablementControllerDisableNetworkAction = { + type: `NetworkEnablementController:disableNetwork`; + handler: NetworkEnablementController['disableNetwork']; +}; + +/** + * Restores the enabled network map to a previously snapshotted state. + * + * Not a general merge API: only updates keys already present in the current + * map. Missing snapshot values default to `false`. Intended for callers with + * direct controller access (e.g. extension) to undo `#onAddNetwork` filter + * switches when adding a network without changing the active selection. + * + * @param enabledNetworkMap - Previously snapshotted enabledNetworkMap. + */ +export type NetworkEnablementControllerRestoreEnabledNetworkMapAction = { + type: `NetworkEnablementController:restoreEnabledNetworkMap`; + handler: NetworkEnablementController['restoreEnabledNetworkMap']; +}; + +/** + * Checks if a network is enabled. + * + * @param chainId - The chain ID of the network to check. Can be either: + * - A Hex string (e.g., '0x1' for Ethereum mainnet) for EVM networks + * - A CAIP-2 chain ID (e.g., 'eip155:1' for Ethereum mainnet, 'solana:mainnet' for Solana) + * @returns True if the network is enabled, false otherwise + */ +export type NetworkEnablementControllerIsNetworkEnabledAction = { + type: `NetworkEnablementController:isNetworkEnabled`; + handler: NetworkEnablementController['isNetworkEnabled']; +}; + +/** + * Returns popular EVM network chain IDs in hex form, restricted to networks + * that exist in NetworkController (networkConfigurationsByChainId). Source is + * the bundled `POPULAR_NETWORKS` unioned with registry-featured EVM chains. + * + * @returns Hex chain IDs for popular EVM networks that are configured. + */ +export type NetworkEnablementControllerListPopularEvmNetworksAction = { + type: `NetworkEnablementController:listPopularEvmNetworks`; + handler: NetworkEnablementController['listPopularEvmNetworks']; +}; + +/** + * Returns popular multichain (Bitcoin, Solana, Tron, Stellar) mainnet chain IDs in + * CAIP-2 form, restricted to networks that exist in MultichainNetworkController + * (multichainNetworkConfigurationsByChainId). + * + * @returns CAIP-2 chain IDs for Bitcoin, Solana, Tron, and Stellar mainnets that are configured. + */ +export type NetworkEnablementControllerListPopularMultichainNetworksAction = { + type: `NetworkEnablementController:listPopularMultichainNetworks`; + handler: NetworkEnablementController['listPopularMultichainNetworks']; +}; + +/** + * Returns the list of popular network chain IDs in CAIP-2 form, restricted to + * networks that exist in NetworkController (networkConfigurationsByChainId) and + * MultichainNetworkController (multichainNetworkConfigurationsByChainId). EVM + * popular networks come from POPULAR_NETWORKS; multichain popular are Bitcoin, + * Solana, Tron, and Stellar mainnets. + * + * @returns CAIP-2 chain IDs for popular EVM networks and multichain mainnets that are configured. + */ +export type NetworkEnablementControllerListPopularNetworksAction = { + type: `NetworkEnablementController:listPopularNetworks`; + handler: NetworkEnablementController['listPopularNetworks']; +}; + +/** + * Union of all NetworkEnablementController action types. + */ +export type NetworkEnablementControllerMethodActions = + | NetworkEnablementControllerEnableNetworkAction + | NetworkEnablementControllerEnableNetworkInNamespaceAction + | NetworkEnablementControllerEnableAllPopularNetworksAction + | NetworkEnablementControllerInitAction + | NetworkEnablementControllerInitNativeAssetIdentifiersAction + | NetworkEnablementControllerDisableNetworkAction + | NetworkEnablementControllerRestoreEnabledNetworkMapAction + | NetworkEnablementControllerIsNetworkEnabledAction + | NetworkEnablementControllerListPopularEvmNetworksAction + | NetworkEnablementControllerListPopularMultichainNetworksAction + | NetworkEnablementControllerListPopularNetworksAction; diff --git a/packages/network-enablement-controller/src/NetworkEnablementController.test.ts b/packages/network-enablement-controller/src/NetworkEnablementController.test.ts new file mode 100644 index 00000000000..4fbbd7e23dd --- /dev/null +++ b/packages/network-enablement-controller/src/NetworkEnablementController.test.ts @@ -0,0 +1,3570 @@ +import { deriveStateFromMetadata } from '@metamask/base-controller'; +import { + ConfigRegistryControllerGetStateAction, + RegistryNetworkConfig, +} from '@metamask/config-registry-controller'; +import { BuiltInNetworkName, ChainId } from '@metamask/controller-utils'; +import { BtcScope, SolScope, TrxScope, XlmScope } from '@metamask/keyring-api'; +import { Messenger, MOCK_ANY_NAMESPACE } from '@metamask/messenger'; +import type { + MessengerActions, + MessengerEvents, + MockAnyNamespace, +} from '@metamask/messenger'; +import { + MultichainNetworkControllerGetStateAction, + toEvmCaipChainId, +} from '@metamask/multichain-network-controller'; +import { + NetworkControllerGetStateAction, + RpcEndpointType, +} from '@metamask/network-controller'; +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 { jestAdvanceTime } from '../../../tests/helpers.js'; +import { POPULAR_NETWORKS } from './constants.js'; +import { NetworkEnablementController } from './NetworkEnablementController.js'; +import type { + NetworkEnablementControllerMessenger, + NativeAssetIdentifiersMap, +} from './NetworkEnablementController.js'; +import { Slip44Service } from './services/index.js'; + +// Known chainId mappings from chainid.network for mocking +const chainIdToSlip44: Record = { + 1: 60, // Ethereum + 10: 60, // Optimism + 56: 714, // BNB Chain + 137: 966, // Polygon + 43114: 9000, // Avalanche + 42161: 60, // Arbitrum + 8453: 60, // Base + 59144: 60, // Linea + 1329: 60, // Sei (uses ETH as native) +}; + +const controllerName = 'NetworkEnablementController'; + +/** + * Returns the default nativeAssetIdentifiers state for testing. + * + * @returns The default nativeAssetIdentifiers with all pre-configured networks. + */ +// Default nativeAssetIdentifiers is empty - should be populated by client using initNativeAssetIdentifiers() +function getDefaultNativeAssetIdentifiers(): NativeAssetIdentifiersMap { + return {}; +} + +type AllNetworkEnablementControllerActions = + MessengerActions; + +type AllNetworkEnablementControllerEvents = + MessengerEvents; + +type RootMessenger = Messenger< + MockAnyNamespace, + AllNetworkEnablementControllerActions, + AllNetworkEnablementControllerEvents +>; + +/** + * Creates and returns a root messenger for testing + * + * @returns A messenger instance + */ +function getRootMessenger(): RootMessenger { + return new Messenger({ + namespace: MOCK_ANY_NAMESPACE, + }); +} + +/** + * Creates a mock RegistryNetworkConfig object with default values, which can be overridden by the provided `overrides` parameter. + * + * @param overrides - Optional properties to override in the default config. + * @returns A mock RegistryNetworkConfig object. + */ +function createMockRegistryNetworkConfig( + overrides: Partial = {}, +): RegistryNetworkConfig { + const base: RegistryNetworkConfig = { + chainId: 'eip155:1', + name: 'Ethereum Mainnet', + imageUrl: + 'https://token.api.cx.metamask.io/assets/networkLogos/ethereum.svg', + coingeckoPlatformId: 'ethereum', + geckoTerminalPlatformId: 'eth', + assets: { + listUrl: 'https://tokens.api.cx.metamask.io/v3/chains/eip155:1/assets', + native: { + assetId: 'eip155:1/slip44:60', + imageUrl: + 'https://static.cx.metamask.io/api/v2/tokenIcons/assets/eip155/1/slip44/60.png', + name: 'Ether', + symbol: 'ETH', + decimals: 18, + coingeckoCoinId: 'ethereum', + }, + }, + rpcProviders: { + default: { + url: 'https://mainnet.infura.io/v3/{infuraProjectId}', + type: 'infura', + networkClientId: 'mainnet', + }, + fallbacks: [], + }, + blockExplorerUrls: { + default: 'https://etherscan.io', + fallbacks: [], + }, + config: { + isActive: true, + isTestnet: false, + isDefault: true, + isFeatured: true, + isDeprecated: false, + isDeletable: false, + priority: 0, + }, + }; + const { config: configOverride, ...rest } = overrides; + return { + ...base, + ...rest, + config: configOverride + ? { ...base.config, ...configOverride } + : base.config, + }; +} + +const setupController = ({ + config, +}: { + config?: Partial< + ConstructorParameters[0] + >; +} = {}): { + controller: NetworkEnablementController; + rootMessenger: RootMessenger; + messenger: NetworkEnablementControllerMessenger; + configRegistryControllerGetStateMock: jest.MockedFunction< + ConfigRegistryControllerGetStateAction['handler'] + >; + networkControllerGetStateMock: jest.MockedFunction< + NetworkControllerGetStateAction['handler'] + >; + multichainNetworkControllerGetStateMock: jest.MockedFunction< + MultichainNetworkControllerGetStateAction['handler'] + >; +} => { + const rootMessenger = getRootMessenger(); + + const networkEnablementControllerMessenger = new Messenger< + typeof controllerName, + AllNetworkEnablementControllerActions, + AllNetworkEnablementControllerEvents, + RootMessenger + >({ + namespace: controllerName, + parent: rootMessenger, + }); + + rootMessenger.delegate({ + messenger: networkEnablementControllerMessenger, + actions: [ + 'ConfigRegistryController:getState', + 'NetworkController:getState', + 'MultichainNetworkController:getState', + ], + events: [ + 'NetworkController:networkAdded', + 'NetworkController:networkRemoved', + 'NetworkController:stateChange', + 'TransactionController:transactionSubmitted', + ], + }); + + const networkControllerGetStateMock: jest.MockedFunction< + NetworkControllerGetStateAction['handler'] + > = jest.fn().mockImplementation(() => ({ + networkConfigurationsByChainId: { + '0x1': { + defaultRpcEndpointIndex: 0, + rpcEndpoints: [{}], + }, + '0xe708': { + defaultRpcEndpointIndex: 0, + rpcEndpoints: [{}], + }, + '0x2105': { + defaultRpcEndpointIndex: 0, + rpcEndpoints: [{}], + }, + }, + })); + + rootMessenger.registerActionHandler( + 'NetworkController:getState', + networkControllerGetStateMock, + ); + + const multichainNetworkControllerGetStateMock: jest.MockedFunction< + MultichainNetworkControllerGetStateAction['handler'] + > = jest.fn().mockImplementation(() => ({ + multichainNetworkConfigurationsByChainId: { + [BtcScope.Mainnet]: { chainId: BtcScope.Mainnet, name: 'Bitcoin' }, + [SolScope.Mainnet]: { chainId: SolScope.Mainnet, name: 'Solana' }, + [TrxScope.Mainnet]: { chainId: TrxScope.Mainnet, name: 'Tron' }, + [XlmScope.Pubnet]: { chainId: XlmScope.Pubnet, name: 'Stellar' }, + }, + selectedMultichainNetworkChainId: 'eip155:1', + isEvmSelected: true, + networksWithTransactionActivity: {}, + })); + + rootMessenger.registerActionHandler( + 'MultichainNetworkController:getState', + jest.fn().mockImplementation(multichainNetworkControllerGetStateMock), + ); + + const configRegistryControllerGetStateMock: jest.MockedFunction< + ConfigRegistryControllerGetStateAction['handler'] + > = jest.fn().mockImplementation(() => ({ + configs: { + networks: { + 'eip155:9999': { + chainId: 'eip155:9999', + config: { + isDefault: false, + isFeatured: true, + isActive: true, + isTestnet: false, + }, + }, + }, + }, + })); + + rootMessenger.registerActionHandler( + 'ConfigRegistryController:getState', + configRegistryControllerGetStateMock, + ); + + const controller = new NetworkEnablementController({ + messenger: networkEnablementControllerMessenger, + ...config, + }); + + return { + controller, + rootMessenger, + messenger: networkEnablementControllerMessenger, + configRegistryControllerGetStateMock, + multichainNetworkControllerGetStateMock, + networkControllerGetStateMock, + }; +}; + +describe('NetworkEnablementController', () => { + beforeEach(() => { + jest.useFakeTimers({ doNotFake: ['nextTick', 'queueMicrotask'] }); + // Mock Slip44Service.getEvmSlip44 to avoid network calls + jest + .spyOn(Slip44Service, 'getEvmSlip44') + .mockImplementation(async (chainId) => { + return chainIdToSlip44[chainId] ?? 60; + }); + }); + + afterEach(() => { + jest.useRealTimers(); + jest.restoreAllMocks(); + }); + + it('initializes with default state', () => { + const { controller } = setupController(); + + expect(controller.state).toStrictEqual({ + enabledNetworkMap: { + [KnownCaipNamespace.Eip155]: { + [ChainId[BuiltInNetworkName.Mainnet]]: true, + [ChainId[BuiltInNetworkName.LineaMainnet]]: true, + [ChainId[BuiltInNetworkName.BaseMainnet]]: true, + [ChainId[BuiltInNetworkName.ArbitrumOne]]: true, + [ChainId[BuiltInNetworkName.BscMainnet]]: true, + [ChainId[BuiltInNetworkName.OptimismMainnet]]: true, + [ChainId[BuiltInNetworkName.PolygonMainnet]]: true, + [ChainId[BuiltInNetworkName.SeiMainnet]]: true, + [ChainId[BuiltInNetworkName.MonadMainnet]]: true, + }, + [KnownCaipNamespace.Solana]: { + [SolScope.Mainnet]: true, + [SolScope.Testnet]: false, + [SolScope.Devnet]: false, + }, + [KnownCaipNamespace.Bip122]: { + [BtcScope.Mainnet]: true, + [BtcScope.Testnet]: false, + [BtcScope.Signet]: false, + }, + [KnownCaipNamespace.Tron]: { + [TrxScope.Mainnet]: true, + [TrxScope.Nile]: false, + [TrxScope.Shasta]: false, + }, + [KnownCaipNamespace.Stellar]: { + [XlmScope.Pubnet]: true, + [XlmScope.Testnet]: false, + }, + }, + nativeAssetIdentifiers: getDefaultNativeAssetIdentifiers(), + }); + }); + + it('subscribes to NetworkController:networkAdded', async () => { + const { controller, rootMessenger } = setupController(); + + // Publish an update with avax network added + // Avalanche is a popular network, and we already have >2 popular networks enabled + // So the new behavior should keep current selection (add but don't enable) + rootMessenger.publish('NetworkController:networkAdded', { + chainId: '0xa86a', + blockExplorerUrls: [], + defaultRpcEndpointIndex: 0, + name: 'Avalanche', + nativeCurrency: 'AVAX', + rpcEndpoints: [ + { + url: 'https://api.avax.network/ext/bc/C/rpc', + networkClientId: 'id', + type: RpcEndpointType.Custom, + }, + ], + }); + + await jestAdvanceTime({ duration: 1 }); + + expect(controller.state).toStrictEqual({ + enabledNetworkMap: { + [KnownCaipNamespace.Eip155]: { + [ChainId[BuiltInNetworkName.Mainnet]]: true, // Ethereum Mainnet + [ChainId[BuiltInNetworkName.LineaMainnet]]: true, // Linea Mainnet + [ChainId[BuiltInNetworkName.BaseMainnet]]: true, // Base Mainnet + [ChainId[BuiltInNetworkName.ArbitrumOne]]: true, + [ChainId[BuiltInNetworkName.BscMainnet]]: true, + [ChainId[BuiltInNetworkName.OptimismMainnet]]: true, + [ChainId[BuiltInNetworkName.PolygonMainnet]]: true, + [ChainId[BuiltInNetworkName.SeiMainnet]]: true, + [ChainId[BuiltInNetworkName.MonadMainnet]]: true, + '0xa86a': true, // Avalanche network added and enabled (keeps current selection) + }, + [KnownCaipNamespace.Solana]: { + [SolScope.Mainnet]: true, + [SolScope.Testnet]: false, + [SolScope.Devnet]: false, + }, + [KnownCaipNamespace.Bip122]: { + [BtcScope.Mainnet]: true, + [BtcScope.Testnet]: false, + [BtcScope.Signet]: false, + }, + [KnownCaipNamespace.Tron]: { + [TrxScope.Mainnet]: true, + [TrxScope.Nile]: false, + [TrxScope.Shasta]: false, + }, + [KnownCaipNamespace.Stellar]: { + [XlmScope.Pubnet]: true, + [XlmScope.Testnet]: false, + }, + }, + nativeAssetIdentifiers: { + ...getDefaultNativeAssetIdentifiers(), + 'eip155:43114': 'eip155:43114/slip44:9000', // AVAX + }, + }); + }); + + it('subscribes to NetworkController:networkRemoved', async () => { + const { controller, rootMessenger } = setupController(); + + // Publish an update with linea network removed + rootMessenger.publish('NetworkController:networkRemoved', { + chainId: '0xe708', // Linea Mainnet + blockExplorerUrls: [], + defaultRpcEndpointIndex: 0, + name: 'Linea', + nativeCurrency: 'ETH', + rpcEndpoints: [ + { + url: 'https://linea-mainnet.infura.io/v3/1234567890', + networkClientId: 'id', + type: RpcEndpointType.Custom, + }, + ], + }); + + await jestAdvanceTime({ duration: 1 }); + + // Create expected nativeAssetIdentifiers without Linea + const expectedNativeAssetIdentifiers = { + ...getDefaultNativeAssetIdentifiers(), + }; + delete expectedNativeAssetIdentifiers[ + toEvmCaipChainId(ChainId[BuiltInNetworkName.LineaMainnet]) + ]; + + expect(controller.state).toStrictEqual({ + enabledNetworkMap: { + [KnownCaipNamespace.Eip155]: { + [ChainId[BuiltInNetworkName.Mainnet]]: true, // Ethereum Mainnet + [ChainId[BuiltInNetworkName.BaseMainnet]]: true, // Base Mainnet (Linea removed) + [ChainId[BuiltInNetworkName.ArbitrumOne]]: true, + [ChainId[BuiltInNetworkName.BscMainnet]]: true, + [ChainId[BuiltInNetworkName.OptimismMainnet]]: true, + [ChainId[BuiltInNetworkName.PolygonMainnet]]: true, + [ChainId[BuiltInNetworkName.SeiMainnet]]: true, + [ChainId[BuiltInNetworkName.MonadMainnet]]: true, + }, + [KnownCaipNamespace.Solana]: { + [SolScope.Mainnet]: true, + [SolScope.Testnet]: false, + [SolScope.Devnet]: false, + }, + [KnownCaipNamespace.Bip122]: { + [BtcScope.Mainnet]: true, + [BtcScope.Testnet]: false, + [BtcScope.Signet]: false, + }, + [KnownCaipNamespace.Tron]: { + [TrxScope.Mainnet]: true, + [TrxScope.Nile]: false, + [TrxScope.Shasta]: false, + }, + [KnownCaipNamespace.Stellar]: { + [XlmScope.Pubnet]: true, + [XlmScope.Testnet]: false, + }, + }, + nativeAssetIdentifiers: expectedNativeAssetIdentifiers, + }); + }); + + it('handles TransactionController:transactionSubmitted with missing chainId gracefully', async () => { + const { controller, rootMessenger } = setupController(); + + const initialState = { ...controller.state }; + + // Publish a transaction submitted event without chainId + rootMessenger.publish('TransactionController:transactionSubmitted', { + transactionMeta: { + networkClientId: 'test-network', + id: 'test-tx-id', + status: TransactionStatus.submitted, + time: Date.now(), + txParams: { + from: '0x123', + to: '0x456', + value: '0x0', + }, + // chainId is missing + } as TransactionMeta, // Simplified structure for testing + }); + + await jestAdvanceTime({ duration: 1 }); + + // State should remain unchanged + expect(controller.state).toStrictEqual(initialState); + }); + + it('handles TransactionController:transactionSubmitted with malformed structure gracefully', async () => { + const { controller, rootMessenger } = setupController(); + + const initialState = { ...controller.state }; + + // Publish a transaction submitted event with malformed structure + // @ts-expect-error - Testing runtime safety for malformed payload + rootMessenger.publish('TransactionController:transactionSubmitted', { + // Missing transactionMeta entirely + }); + + await jestAdvanceTime({ duration: 1 }); + + // State should remain unchanged + expect(controller.state).toStrictEqual(initialState); + }); + + it('handles TransactionController:transactionSubmitted with null/undefined transactionMeta gracefully', async () => { + const { controller, rootMessenger } = setupController(); + + const initialState = { ...controller.state }; + + // Test with null transactionMeta + rootMessenger.publish('TransactionController:transactionSubmitted', { + // @ts-expect-error - Testing runtime safety for null transactionMeta + transactionMeta: null, + }); + + await jestAdvanceTime({ duration: 1 }); + + // State should remain unchanged + expect(controller.state).toStrictEqual(initialState); + + // Test with undefined transactionMeta + rootMessenger.publish('TransactionController:transactionSubmitted', { + // @ts-expect-error - Testing runtime safety for undefined transactionMeta + transactionMeta: undefined, + }); + + await jestAdvanceTime({ duration: 1 }); + + // State should still remain unchanged + expect(controller.state).toStrictEqual(initialState); + }); + + it('does fallback to ethereum when removing the last enabled network', async () => { + const { controller, rootMessenger } = setupController(); + + // disable all networks except linea + controller.disableNetwork('0x1'); // Ethereum Mainnet + controller.disableNetwork('0x2105'); // Base Mainnet + controller.disableNetwork('0xa4b1'); // Arbitrum One + controller.disableNetwork('0x38'); // BSC Mainnet + controller.disableNetwork('0xa'); // Optimism Mainnet + controller.disableNetwork('0x89'); // Polygon Mainnet + controller.disableNetwork('0x531'); // Sei Mainnet + controller.disableNetwork('0x8f'); // Monad Mainnet + + // Publish an update with linea network removed + rootMessenger.publish('NetworkController:networkRemoved', { + chainId: '0xe708', // Linea Mainnet + blockExplorerUrls: [], + defaultRpcEndpointIndex: 0, + name: 'Linea', + nativeCurrency: 'ETH', + rpcEndpoints: [ + { + url: 'https://linea-mainnet.infura.io/v3/1234567890', + networkClientId: 'id', + type: RpcEndpointType.Custom, + }, + ], + }); + + await jestAdvanceTime({ duration: 1 }); + + // Create expected nativeAssetIdentifiers without Linea + const expectedNativeAssetIdentifiersForFallback = { + ...getDefaultNativeAssetIdentifiers(), + }; + delete expectedNativeAssetIdentifiersForFallback[ + toEvmCaipChainId(ChainId[BuiltInNetworkName.LineaMainnet]) + ]; + + expect(controller.state).toStrictEqual({ + enabledNetworkMap: { + [KnownCaipNamespace.Eip155]: { + [ChainId[BuiltInNetworkName.Mainnet]]: true, // Ethereum Mainnet (fallback enabled) + [ChainId[BuiltInNetworkName.BaseMainnet]]: false, // Base Mainnet (still disabled) + [ChainId[BuiltInNetworkName.ArbitrumOne]]: false, + [ChainId[BuiltInNetworkName.BscMainnet]]: false, + [ChainId[BuiltInNetworkName.OptimismMainnet]]: false, + [ChainId[BuiltInNetworkName.PolygonMainnet]]: false, + [ChainId[BuiltInNetworkName.SeiMainnet]]: false, + [ChainId[BuiltInNetworkName.MonadMainnet]]: false, + }, + [KnownCaipNamespace.Solana]: { + [SolScope.Mainnet]: true, + [SolScope.Testnet]: false, + [SolScope.Devnet]: false, + }, + [KnownCaipNamespace.Bip122]: { + [BtcScope.Mainnet]: true, + [BtcScope.Testnet]: false, + [BtcScope.Signet]: false, + }, + [KnownCaipNamespace.Tron]: { + [TrxScope.Mainnet]: true, + [TrxScope.Nile]: false, + [TrxScope.Shasta]: false, + }, + [KnownCaipNamespace.Stellar]: { + [XlmScope.Pubnet]: true, + [XlmScope.Testnet]: false, + }, + }, + nativeAssetIdentifiers: expectedNativeAssetIdentifiersForFallback, + }); + }); + + describe('init', () => { + it('initializes network enablement state from controller configurations', async () => { + const { controller, messenger } = setupController(); + + jest + .spyOn(messenger, 'call') + // eslint-disable-next-line @typescript-eslint/no-explicit-any + .mockImplementation((actionType: string, ..._args: any[]): any => { + if (actionType === 'NetworkController:getState') { + return { + selectedNetworkClientId: 'mainnet', + networkConfigurationsByChainId: { + '0x1': { + chainId: '0x1', + name: 'Ethereum Mainnet', + nativeCurrency: 'ETH', + }, + '0xe708': { + chainId: '0xe708', + name: 'Linea Mainnet', + nativeCurrency: 'ETH', + }, + '0x2105': { + chainId: '0x2105', + name: 'Base Mainnet', + nativeCurrency: 'ETH', + }, + }, + networksMetadata: {}, + }; + } + if (actionType === 'MultichainNetworkController:getState') { + return { + multichainNetworkConfigurationsByChainId: { + 'eip155:1': { + chainId: 'eip155:1', + name: 'Ethereum Mainnet', + nativeCurrency: 'ETH', + }, + 'eip155:59144': { + chainId: 'eip155:59144', + name: 'Linea Mainnet', + nativeCurrency: 'ETH', + }, + 'eip155:8453': { + chainId: 'eip155:8453', + name: 'Base Mainnet', + nativeCurrency: 'ETH', + }, + 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp': { + chainId: 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp', + name: 'Solana Mainnet', + nativeCurrency: 'SOL', + }, + }, + selectedMultichainNetworkChainId: 'eip155:1', + isEvmSelected: true, + networksWithTransactionActivity: {}, + }; + } + throw new Error(`Unexpected action type: ${actionType}`); + }); + + // Initialize from configurations + await controller.init(); + + // Should only enable popular networks that exist in NetworkController config + // (0x1, 0xe708, 0x2105 exist in default NetworkController mock) + expect(controller.state).toStrictEqual({ + enabledNetworkMap: { + [KnownCaipNamespace.Eip155]: { + [ChainId[BuiltInNetworkName.Mainnet]]: true, // Ethereum Mainnet (exists in default config) + [ChainId[BuiltInNetworkName.LineaMainnet]]: true, // Linea Mainnet (exists in default config) + [ChainId[BuiltInNetworkName.BaseMainnet]]: true, // Base Mainnet (exists in default config) + [ChainId[BuiltInNetworkName.ArbitrumOne]]: true, + [ChainId[BuiltInNetworkName.BscMainnet]]: true, + [ChainId[BuiltInNetworkName.OptimismMainnet]]: true, + [ChainId[BuiltInNetworkName.PolygonMainnet]]: true, + [ChainId[BuiltInNetworkName.SeiMainnet]]: true, + [ChainId[BuiltInNetworkName.MonadMainnet]]: true, + }, + [KnownCaipNamespace.Solana]: { + [SolScope.Mainnet]: true, // Solana Mainnet (exists in multichain config) + [SolScope.Testnet]: false, + [SolScope.Devnet]: false, + }, + [KnownCaipNamespace.Bip122]: { + [BtcScope.Mainnet]: true, + [BtcScope.Testnet]: false, + [BtcScope.Signet]: false, + }, + [KnownCaipNamespace.Tron]: { + [TrxScope.Mainnet]: true, + [TrxScope.Nile]: false, + [TrxScope.Shasta]: false, + }, + [KnownCaipNamespace.Stellar]: { + [XlmScope.Pubnet]: true, + [XlmScope.Testnet]: false, + }, + }, + // init() populates nativeAssetIdentifiers from NetworkController (EVM networks only) + nativeAssetIdentifiers: { + 'eip155:1': 'eip155:1/slip44:60', + 'eip155:59144': 'eip155:59144/slip44:60', + 'eip155:8453': 'eip155:8453/slip44:60', + }, + }); + }); + + it('only enables popular networks that exist in NetworkController configurations', async () => { + // Create a separate controller setup for this test to avoid handler conflicts + const { controller, messenger } = setupController({ + config: { + state: { + enabledNetworkMap: { + [KnownCaipNamespace.Eip155]: {}, + [KnownCaipNamespace.Solana]: {}, + }, + nativeAssetIdentifiers: {}, + }, + }, + }); + + jest.spyOn(messenger, 'call').mockImplementation( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (actionType: string, ..._args: any[]): any => { + if (actionType === 'NetworkController:getState') { + return { + selectedNetworkClientId: 'mainnet', + networkConfigurationsByChainId: { + '0x1': { + chainId: '0x1', + name: 'Ethereum Mainnet', + nativeCurrency: 'ETH', + }, + '0xe708': { + chainId: '0xe708', + name: 'Linea Mainnet', + nativeCurrency: 'ETH', + }, + // Missing other popular networks + }, + networksMetadata: {}, + }; + } + if (actionType === 'MultichainNetworkController:getState') { + return { + multichainNetworkConfigurationsByChainId: { + 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp': { + chainId: 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp', + name: 'Solana Mainnet', + nativeCurrency: 'SOL', + }, + }, + selectedMultichainNetworkChainId: + 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp', + isEvmSelected: false, + networksWithTransactionActivity: {}, + }; + } + throw new Error(`Unexpected action type: ${actionType}`); + }, + ); + + // Initialize from configurations + await controller.init(); + + // Should only enable networks that exist in configurations + expect(controller.state).toStrictEqual({ + enabledNetworkMap: { + [KnownCaipNamespace.Eip155]: { + '0x1': false, // Ethereum Mainnet (exists in config) + '0xe708': false, // Linea Mainnet (exists in config) + // Other popular networks not enabled because they don't exist in config + }, + [KnownCaipNamespace.Solana]: { + [SolScope.Mainnet]: false, // Solana Mainnet (exists in config) + }, + }, + nativeAssetIdentifiers: { + 'eip155:1': 'eip155:1/slip44:60', // ETH + 'eip155:59144': 'eip155:59144/slip44:60', // ETH (Linea uses ETH) + // Multichain networks don't populate nativeAssetIdentifiers in init() because + // the mock doesn't include the required nativeCurrency for non-EVM networks + }, + }); + }); + + it('handles missing MultichainNetworkController gracefully', async () => { + const { controller, messenger } = setupController(); + + jest + .spyOn(messenger, 'call') + // eslint-disable-next-line @typescript-eslint/no-explicit-any + .mockImplementation((actionType: string, ..._args: any[]): any => { + if (actionType === 'NetworkController:getState') { + return { + selectedNetworkClientId: 'mainnet', + networkConfigurationsByChainId: { + '0x1': { + chainId: '0x1', + name: 'Ethereum Mainnet', + nativeCurrency: 'ETH', + }, + '0xe708': { + chainId: '0xe708', + name: 'Linea Mainnet', + nativeCurrency: 'ETH', + }, + '0x2105': { + chainId: '0x2105', + name: 'Base Mainnet', + nativeCurrency: 'ETH', + }, + }, + networksMetadata: {}, + }; + } + if (actionType === 'MultichainNetworkController:getState') { + return { + multichainNetworkConfigurationsByChainId: {}, + selectedMultichainNetworkChainId: 'eip155:1', + isEvmSelected: true, + networksWithTransactionActivity: {}, + }; + } + throw new Error(`Unexpected action type: ${actionType}`); + }); + + // Should not throw + await controller.init(); + + // Should still enable popular networks from NetworkController + expect(controller.isNetworkEnabled('0x1')).toBe(true); + expect(controller.isNetworkEnabled('0xe708')).toBe(true); + expect(controller.isNetworkEnabled('0x2105')).toBe(true); + }); + + it('creates namespace buckets for all configured networks', async () => { + const { controller, messenger } = setupController(); + + jest + .spyOn(messenger, 'call') + // eslint-disable-next-line @typescript-eslint/no-explicit-any + .mockImplementation((actionType: string, ..._args: any[]): any => { + if (actionType === 'NetworkController:getState') { + return { + selectedNetworkClientId: 'mainnet', + networkConfigurationsByChainId: { + '0x1': { + chainId: '0x1', + name: 'Ethereum', + nativeCurrency: 'ETH', + }, + '0x89': { + chainId: '0x89', + name: 'Polygon', + nativeCurrency: 'MATIC', + }, + }, + networksMetadata: {}, + }; + } + if (actionType === 'MultichainNetworkController:getState') { + return { + multichainNetworkConfigurationsByChainId: { + 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp': { + chainId: 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp', + name: 'Solana', + nativeCurrency: 'SOL', + }, + 'bip122:000000000019d6689c085ae165831e93': { + chainId: 'bip122:000000000019d6689c085ae165831e93', + name: 'Bitcoin', + nativeCurrency: 'BTC', + }, + }, + selectedMultichainNetworkChainId: + 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp', + isEvmSelected: false, + networksWithTransactionActivity: {}, + }; + } + throw new Error(`Unexpected action type: ${actionType}`); + }); + + await controller.init(); + + // Should have created namespace buckets for all network types + expect(controller.state.enabledNetworkMap).toHaveProperty( + KnownCaipNamespace.Eip155, + ); + expect(controller.state.enabledNetworkMap).toHaveProperty( + KnownCaipNamespace.Solana, + ); + expect(controller.state.enabledNetworkMap).toHaveProperty( + KnownCaipNamespace.Bip122, + ); + }); + + it('creates new namespace buckets for networks that do not exist', async () => { + const { controller, messenger } = setupController(); + + // Start with empty state to test namespace bucket creation + // eslint-disable-next-line dot-notation + controller['update']((state) => { + state.enabledNetworkMap = {}; + }); + + jest + .spyOn(messenger, 'call') + // eslint-disable-next-line @typescript-eslint/no-explicit-any + .mockImplementation((actionType: string, ..._args: unknown[]): any => { + const responses = { + 'NetworkController:getState': { + selectedNetworkClientId: 'mainnet', + networkConfigurationsByChainId: { + '0x1': { + chainId: '0x1' as Hex, + name: 'Ethereum', + blockExplorerUrls: [], + defaultRpcEndpointIndex: 0, + nativeCurrency: 'ETH', + rpcEndpoints: [], + }, + }, + networksMetadata: {}, + }, + 'MultichainNetworkController:getState': { + multichainNetworkConfigurationsByChainId: { + 'cosmos:cosmoshub-4': { + chainId: 'cosmos:cosmoshub-4' as CaipChainId, + name: 'Cosmos Hub', + isEvm: false as const, + nativeCurrency: + 'cosmos:cosmoshub-4/slip44:118' as `${string}:${string}/${string}:${string}`, + }, + }, + selectedMultichainNetworkChainId: + 'cosmos:cosmoshub-4' as CaipChainId, + isEvmSelected: false, + networksWithTransactionActivity: {}, + }, + }; + return responses[actionType as keyof typeof responses]; + }); + + await controller.init(); + + // Should have created namespace buckets for both EIP-155 and Cosmos + expect(controller.state.enabledNetworkMap).toHaveProperty( + KnownCaipNamespace.Eip155, + ); + expect(controller.state.enabledNetworkMap).toHaveProperty('cosmos'); + }); + + it('sets Bitcoin testnet to false when it exists in MultichainNetworkController configurations', async () => { + const { controller, messenger } = setupController(); + + // Mock MultichainNetworkController to include Bitcoin testnet BEFORE calling init + jest + .spyOn(messenger, 'call') + // eslint-disable-next-line @typescript-eslint/no-explicit-any + .mockImplementation((actionType: string, ..._args: any[]): any => { + if (actionType === 'NetworkController:getState') { + return { + selectedNetworkClientId: 'mainnet', + networkConfigurationsByChainId: { + '0x1': { + chainId: '0x1', + name: 'Ethereum Mainnet', + nativeCurrency: 'ETH', + }, + }, + networksMetadata: {}, + }; + } + if (actionType === 'MultichainNetworkController:getState') { + return { + multichainNetworkConfigurationsByChainId: { + [BtcScope.Mainnet]: { + chainId: BtcScope.Mainnet, + name: 'Bitcoin Mainnet', + }, + [BtcScope.Testnet]: { + chainId: BtcScope.Testnet, + name: 'Bitcoin Testnet', + }, + }, + selectedMultichainNetworkChainId: BtcScope.Mainnet, + isEvmSelected: false, + networksWithTransactionActivity: {}, + }; + } + throw new Error(`Unexpected action type: ${actionType}`); + }); + + // Initialize the controller to trigger line 378 (init() method sets testnet to false) + await controller.init(); + + // Verify Bitcoin testnet is set to false by init() - line 378 + expect(controller.isNetworkEnabled(BtcScope.Testnet)).toBe(false); + expect( + controller.state.enabledNetworkMap[KnownCaipNamespace.Bip122][ + BtcScope.Testnet + ], + ).toBe(false); + }); + + it('sets Bitcoin signet to false when it exists in MultichainNetworkController configurations', async () => { + const { controller, messenger } = setupController(); + + // Mock MultichainNetworkController to include Bitcoin signet BEFORE calling init + jest + .spyOn(messenger, 'call') + // eslint-disable-next-line @typescript-eslint/no-explicit-any + .mockImplementation((actionType: string, ..._args: any[]): any => { + if (actionType === 'NetworkController:getState') { + return { + selectedNetworkClientId: 'mainnet', + networkConfigurationsByChainId: { + '0x1': { + chainId: '0x1', + name: 'Ethereum Mainnet', + nativeCurrency: 'ETH', + }, + }, + networksMetadata: {}, + }; + } + if (actionType === 'MultichainNetworkController:getState') { + return { + multichainNetworkConfigurationsByChainId: { + [BtcScope.Mainnet]: { + chainId: BtcScope.Mainnet, + name: 'Bitcoin Mainnet', + nativeCurrency: 'BTC', + }, + [BtcScope.Signet]: { + chainId: BtcScope.Signet, + name: 'Bitcoin Signet', + nativeCurrency: 'BTC', + }, + }, + selectedMultichainNetworkChainId: BtcScope.Mainnet, + isEvmSelected: false, + networksWithTransactionActivity: {}, + }; + } + throw new Error(`Unexpected action type: ${actionType}`); + }); + + // Initialize the controller to trigger line 391 (init() method sets signet to false) + await controller.init(); + + // Verify Bitcoin signet is set to false by init() - line 391 + expect(controller.isNetworkEnabled(BtcScope.Signet)).toBe(false); + expect( + controller.state.enabledNetworkMap[KnownCaipNamespace.Bip122][ + BtcScope.Signet + ], + ).toBe(false); + }); + + it('skips networks that already have nativeAssetIdentifiers in state', async () => { + // Create controller with existing nativeAssetIdentifiers + const { controller, messenger } = setupController({ + config: { + state: { + enabledNetworkMap: { + [KnownCaipNamespace.Eip155]: {}, + }, + nativeAssetIdentifiers: { + // Pre-existing nativeAssetIdentifier with custom value + 'eip155:1': 'eip155:1/slip44:999' as const, + }, + }, + }, + }); + + jest + .spyOn(messenger, 'call') + // eslint-disable-next-line @typescript-eslint/no-explicit-any + .mockImplementation((actionType: string, ..._args: any[]): any => { + if (actionType === 'NetworkController:getState') { + return { + selectedNetworkClientId: 'mainnet', + networkConfigurationsByChainId: { + '0x1': { + chainId: '0x1', + name: 'Ethereum Mainnet', + nativeCurrency: 'ETH', + }, + '0x38': { + chainId: '0x38', + name: 'BNB Chain', + nativeCurrency: 'BNB', + }, + }, + networksMetadata: {}, + }; + } + if (actionType === 'MultichainNetworkController:getState') { + return { + multichainNetworkConfigurationsByChainId: {}, + selectedMultichainNetworkChainId: 'eip155:1', + isEvmSelected: true, + networksWithTransactionActivity: {}, + }; + } + throw new Error(`Unexpected action type: ${actionType}`); + }); + + await controller.init(); + + // Existing nativeAssetIdentifier should be preserved (not overwritten) + expect(controller.state.nativeAssetIdentifiers['eip155:1']).toBe( + 'eip155:1/slip44:999', + ); + + // New network should be added + expect(controller.state.nativeAssetIdentifiers['eip155:56']).toBe( + 'eip155:56/slip44:714', + ); + }); + + it('defaults to slip44:60 for EVM networks with unknown chainId and symbol', async () => { + const { controller, messenger } = setupController(); + + jest + .spyOn(messenger, 'call') + // eslint-disable-next-line @typescript-eslint/no-explicit-any + .mockImplementation((actionType: string, ..._args: any[]): any => { + if (actionType === 'NetworkController:getState') { + return { + selectedNetworkClientId: 'mainnet', + networkConfigurationsByChainId: { + // Use an unknown chainId (99999 = 0x1869F) and unknown symbol + '0x1869f': { + chainId: '0x1869f', + name: 'Unknown Network', + nativeCurrency: 'UNKNOWN_SYMBOL_XYZ', + }, + }, + networksMetadata: {}, + }; + } + if (actionType === 'MultichainNetworkController:getState') { + return { + multichainNetworkConfigurationsByChainId: {}, + selectedMultichainNetworkChainId: 'eip155:1', + isEvmSelected: true, + networksWithTransactionActivity: {}, + }; + } + throw new Error(`Unexpected action type: ${actionType}`); + }); + + await controller.init(); + + // Should default to slip44:60 when no mapping is found + expect(controller.state.nativeAssetIdentifiers['eip155:99999']).toBe( + 'eip155:99999/slip44:60', + ); + }); + }); + + describe('initNativeAssetIdentifiers', () => { + it('populates nativeAssetIdentifiers from network configurations', async () => { + const { controller } = setupController(); + + const networks = [ + { chainId: 'eip155:1' as const, nativeCurrency: 'ETH' }, + { chainId: 'eip155:56' as const, nativeCurrency: 'BNB' }, + { + chainId: 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp' as const, + nativeCurrency: 'SOL', + }, + ]; + + await controller.initNativeAssetIdentifiers(networks); + + expect(controller.state.nativeAssetIdentifiers).toStrictEqual({ + 'eip155:1': 'eip155:1/slip44:60', + 'eip155:56': 'eip155:56/slip44:714', + 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp': + 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/slip44:501', + }); + }); + + it('defaults to slip44:60 for EVM networks with unknown symbols', async () => { + const { controller } = setupController(); + + const networks = [ + { chainId: 'eip155:1' as const, nativeCurrency: 'ETH' }, + { chainId: 'eip155:999' as const, nativeCurrency: 'UNKNOWN_XYZ' }, + ]; + + await controller.initNativeAssetIdentifiers(networks); + + expect(controller.state.nativeAssetIdentifiers['eip155:1']).toBe( + 'eip155:1/slip44:60', + ); + // EVM networks default to slip44:60 (Ethereum) when no specific mapping is found + expect(controller.state.nativeAssetIdentifiers['eip155:999']).toBe( + 'eip155:999/slip44:60', + ); + }); + + it('does not modify state for empty input', async () => { + const { controller } = setupController(); + + await controller.initNativeAssetIdentifiers([]); + + expect(controller.state.nativeAssetIdentifiers).toStrictEqual({}); + }); + + it('handles CAIP-19 format nativeCurrency from MultichainNetworkController', async () => { + const { controller } = setupController(); + + // Non-EVM networks from MultichainNetworkController use CAIP-19 format for nativeCurrency + const networks = [ + // EVM networks use simple symbols + { chainId: 'eip155:1' as const, nativeCurrency: 'ETH' }, + // Non-EVM networks use full CAIP-19 format + { + chainId: 'bip122:000000000019d6689c085ae165831e93' as const, + nativeCurrency: 'bip122:000000000019d6689c085ae165831e93/slip44:0', + }, + { + chainId: 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp' as const, + nativeCurrency: 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/slip44:501', + }, + { + chainId: 'tron:728126428' as const, + nativeCurrency: 'tron:728126428/slip44:195', + }, + ]; + + await controller.initNativeAssetIdentifiers(networks); + + expect(controller.state.nativeAssetIdentifiers).toStrictEqual({ + 'eip155:1': 'eip155:1/slip44:60', + 'bip122:000000000019d6689c085ae165831e93': + 'bip122:000000000019d6689c085ae165831e93/slip44:0', + 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp': + 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/slip44:501', + 'tron:728126428': 'tron:728126428/slip44:195', + }); + }); + }); + + describe('enableAllPopularNetworks', () => { + it('enables all popular networks that exist in controller configurations and Solana mainnet', () => { + const { + controller, + networkControllerGetStateMock, + multichainNetworkControllerGetStateMock, + } = setupController(); + networkControllerGetStateMock.mockReturnValue({ + selectedNetworkClientId: 'mainnet', + networkConfigurationsByChainId: { + '0x1': { + chainId: '0x1', + name: 'Ethereum Mainnet', + nativeCurrency: 'ETH', + blockExplorerUrls: [], + defaultRpcEndpointIndex: 0, + rpcEndpoints: [], + }, + '0xe708': { + chainId: '0xe708', + name: 'Linea Mainnet', + nativeCurrency: 'ETH', + blockExplorerUrls: [], + defaultRpcEndpointIndex: 0, + rpcEndpoints: [], + }, + '0x2105': { + chainId: '0x2105', + name: 'Base Mainnet', + nativeCurrency: 'ETH', + blockExplorerUrls: [], + defaultRpcEndpointIndex: 0, + rpcEndpoints: [], + }, + }, + networksMetadata: {}, + }); + multichainNetworkControllerGetStateMock.mockReturnValue({ + multichainNetworkConfigurationsByChainId: { + [SolScope.Mainnet]: { + chainId: SolScope.Mainnet, + name: 'Solana Mainnet', + isEvm: false, + nativeCurrency: + 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/slip44:501', + }, + [BtcScope.Mainnet]: { + chainId: BtcScope.Mainnet, + name: 'Bitcoin Mainnet', + isEvm: false, + nativeCurrency: 'bip122:000000000019d6689c085ae165831e93/slip44:0', + }, + [TrxScope.Mainnet]: { + chainId: TrxScope.Mainnet, + name: 'Tron Mainnet', + isEvm: false, + nativeCurrency: 'tron:728126428/slip44:195', + }, + [XlmScope.Pubnet]: { + chainId: XlmScope.Pubnet, + name: 'Stellar Mainnet', + isEvm: false, + nativeCurrency: 'stellar:pubnet/slip44:148', + }, + }, + selectedMultichainNetworkChainId: SolScope.Mainnet, + isEvmSelected: false, + networksWithTransactionActivity: {}, + }); + + // Initially disable some networks + controller.disableNetwork('0xe708'); // Linea + controller.disableNetwork('0x2105'); // Base + + expect(controller.state).toStrictEqual({ + enabledNetworkMap: { + [KnownCaipNamespace.Eip155]: { + '0x1': true, // Ethereum Mainnet + '0xe708': false, // Linea Mainnet (disabled) + '0x2105': false, // Base Mainnet (disabled) + [ChainId[BuiltInNetworkName.ArbitrumOne]]: true, + [ChainId[BuiltInNetworkName.BscMainnet]]: true, + [ChainId[BuiltInNetworkName.OptimismMainnet]]: true, + [ChainId[BuiltInNetworkName.PolygonMainnet]]: true, + [ChainId[BuiltInNetworkName.SeiMainnet]]: true, + [ChainId[BuiltInNetworkName.MonadMainnet]]: true, + }, + [KnownCaipNamespace.Solana]: { + [SolScope.Mainnet]: true, + [SolScope.Testnet]: false, + [SolScope.Devnet]: false, + }, + [KnownCaipNamespace.Bip122]: { + [BtcScope.Mainnet]: true, + [BtcScope.Testnet]: false, + [BtcScope.Signet]: false, + }, + [KnownCaipNamespace.Tron]: { + [TrxScope.Mainnet]: true, + [TrxScope.Nile]: false, + [TrxScope.Shasta]: false, + }, + [KnownCaipNamespace.Stellar]: { + [XlmScope.Pubnet]: true, + [XlmScope.Testnet]: false, + }, + }, + nativeAssetIdentifiers: getDefaultNativeAssetIdentifiers(), + }); + + // Enable all popular networks + controller.enableAllPopularNetworks(); + + expect(controller.state).toStrictEqual({ + enabledNetworkMap: { + [KnownCaipNamespace.Eip155]: { + '0x1': true, // Ethereum Mainnet + '0xe708': true, // Linea Mainnet + '0x2105': true, // Base Mainnet + [ChainId[BuiltInNetworkName.ArbitrumOne]]: false, // Not in mocked config + [ChainId[BuiltInNetworkName.BscMainnet]]: false, // Not in mocked config + [ChainId[BuiltInNetworkName.OptimismMainnet]]: false, // Not in mocked config + [ChainId[BuiltInNetworkName.PolygonMainnet]]: false, // Not in mocked config + [ChainId[BuiltInNetworkName.SeiMainnet]]: false, // Not in mocked config + [ChainId[BuiltInNetworkName.MonadMainnet]]: false, // Not in mocked config + }, + [KnownCaipNamespace.Solana]: { + [SolScope.Mainnet]: true, // Solana + [SolScope.Testnet]: false, + [SolScope.Devnet]: false, + }, + [KnownCaipNamespace.Bip122]: { + [BtcScope.Mainnet]: true, + [BtcScope.Testnet]: false, + [BtcScope.Signet]: false, + }, + [KnownCaipNamespace.Tron]: { + [TrxScope.Mainnet]: true, + [TrxScope.Nile]: false, + [TrxScope.Shasta]: false, + }, + [KnownCaipNamespace.Stellar]: { + [XlmScope.Pubnet]: true, + [XlmScope.Testnet]: false, + }, + }, + nativeAssetIdentifiers: getDefaultNativeAssetIdentifiers(), + }); + }); + + it('enables all popular networks from constants', () => { + const { + controller, + multichainNetworkControllerGetStateMock, + networkControllerGetStateMock, + } = setupController(); + + multichainNetworkControllerGetStateMock.mockReturnValue({ + multichainNetworkConfigurationsByChainId: { + [SolScope.Mainnet]: { + chainId: SolScope.Mainnet, + name: 'Solana Mainnet', + isEvm: false, + nativeCurrency: + 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/slip44:501', + }, + [BtcScope.Mainnet]: { + chainId: BtcScope.Mainnet, + name: 'Bitcoin Mainnet', + isEvm: false, + nativeCurrency: 'bip122:000000000019d6689c085ae165831e93/slip44:0', + }, + }, + selectedMultichainNetworkChainId: SolScope.Mainnet, + isEvmSelected: false, + networksWithTransactionActivity: {}, + }); + + networkControllerGetStateMock.mockReturnValue({ + selectedNetworkClientId: 'mainnet', + networksMetadata: {}, + networkConfigurationsByChainId: POPULAR_NETWORKS.reduce( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (acc: any, chainId: string) => { + acc[chainId] = { chainId, name: `Network ${chainId}` }; + return acc; + }, + {}, + ), + }); + // The function should enable all popular networks defined in constants + expect(() => controller.enableAllPopularNetworks()).not.toThrow(); + + // Should enable all popular networks and Solana + const expectedEip155Networks = POPULAR_NETWORKS.reduce( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (acc: any, chainId: string) => { + acc[chainId] = true; + return acc; + }, + {}, + ); + + expect(controller.state).toStrictEqual({ + enabledNetworkMap: { + [KnownCaipNamespace.Eip155]: expectedEip155Networks, + [KnownCaipNamespace.Solana]: { + [SolScope.Mainnet]: true, // Solana Mainnet + [SolScope.Testnet]: false, + [SolScope.Devnet]: false, + }, + [KnownCaipNamespace.Bip122]: { + [BtcScope.Mainnet]: true, + [BtcScope.Testnet]: false, + [BtcScope.Signet]: false, + }, + [KnownCaipNamespace.Tron]: { + [TrxScope.Mainnet]: false, + [TrxScope.Nile]: false, + [TrxScope.Shasta]: false, + }, + [KnownCaipNamespace.Stellar]: { + [XlmScope.Pubnet]: false, + [XlmScope.Testnet]: false, + }, + }, + nativeAssetIdentifiers: getDefaultNativeAssetIdentifiers(), + }); + }); + + it('enables all popular networks from config registry controller', () => { + const { + controller, + networkControllerGetStateMock, + configRegistryControllerGetStateMock, + } = setupController({ + config: { + state: { + enabledNetworkMap: { + [KnownCaipNamespace.Eip155]: {}, + }, + }, + }, + }); + + networkControllerGetStateMock.mockReturnValue({ + selectedNetworkClientId: 'mainnet', + networksMetadata: {}, + networkConfigurationsByChainId: { + '0x270f': { + chainId: '0x270f', + name: 'Some Network', + nativeCurrency: 'SNET', + blockExplorerUrls: [], + defaultRpcEndpointIndex: 0, + rpcEndpoints: [], + }, + }, + }); + + configRegistryControllerGetStateMock.mockReturnValue({ + configs: { + networks: { + 'eip155:9999': createMockRegistryNetworkConfig({ + chainId: 'eip155:9999', + }), + }, + }, + version: '1', + lastFetched: Date.now(), + etag: 'mock-etag', + }); + + controller.enableAllPopularNetworks(); + + expect(controller.isNetworkEnabled('0x270f')).toBe(true); + expect( + controller.state.enabledNetworkMap[KnownCaipNamespace.Eip155]['0x270f'], + ).toBe(true); + }); + + it.each([ + { + isActive: false, + isFeatured: true, + isTestnet: false, + }, + { + isActive: true, + isFeatured: false, + isTestnet: false, + isDefault: true, + isDeletable: true, + isDeprecated: false, + priority: 1, + }, + { + isActive: true, + isFeatured: true, + isTestnet: true, + isDefault: true, + isDeletable: true, + isDeprecated: false, + priority: 1, + }, + ])( + 'disables all networks that are set as not popular in the config registry controller', + (config) => { + const { + controller, + networkControllerGetStateMock, + configRegistryControllerGetStateMock, + } = setupController({ + config: { + state: { + enabledNetworkMap: { + [KnownCaipNamespace.Eip155]: {}, + }, + }, + }, + }); + + networkControllerGetStateMock.mockReturnValue({ + selectedNetworkClientId: 'mainnet', + networksMetadata: {}, + networkConfigurationsByChainId: { + '0x270f': { + chainId: '0x270f', + name: 'Some Network', + nativeCurrency: 'SNET', + blockExplorerUrls: [], + defaultRpcEndpointIndex: 0, + rpcEndpoints: [], + }, + }, + }); + + configRegistryControllerGetStateMock.mockReturnValue({ + configs: { + networks: { + 'eip155:9999': createMockRegistryNetworkConfig({ + chainId: 'eip155:9999', + config: { + ...config, + isDefault: true, + isDeletable: true, + isDeprecated: false, + priority: 1, + }, + }), + }, + }, + version: '1', + lastFetched: Date.now(), + etag: 'mock-etag', + }); + + controller.enableAllPopularNetworks(); + + expect(controller.isNetworkEnabled('0x270f')).toBe(false); + expect( + controller.state.enabledNetworkMap[KnownCaipNamespace.Eip155][ + '0x270f' + ], + ).toBeUndefined(); + }, + ); + + it('disables existing networks and enables only popular networks (exclusive behavior)', async () => { + const { + controller, + rootMessenger, + networkControllerGetStateMock, + multichainNetworkControllerGetStateMock, + } = setupController(); + networkControllerGetStateMock.mockReturnValue({ + selectedNetworkClientId: 'mainnet', + networkConfigurationsByChainId: { + '0x1': { + chainId: '0x1', + name: 'Ethereum Mainnet', + nativeCurrency: 'ETH', + blockExplorerUrls: [], + defaultRpcEndpointIndex: 0, + rpcEndpoints: [], + }, + '0xe708': { + chainId: '0xe708', + name: 'Linea Mainnet', + nativeCurrency: 'ETH', + blockExplorerUrls: [], + defaultRpcEndpointIndex: 0, + rpcEndpoints: [], + }, + '0x2105': { + chainId: '0x2105', + name: 'Base Mainnet', + nativeCurrency: 'ETH', + blockExplorerUrls: [], + defaultRpcEndpointIndex: 0, + rpcEndpoints: [], + }, + // Non-popular network + '0x2': { + chainId: '0x2', + name: 'Test Network', + nativeCurrency: 'TEST', + blockExplorerUrls: [], + defaultRpcEndpointIndex: 0, + rpcEndpoints: [], + }, + }, + networksMetadata: {}, + }); + multichainNetworkControllerGetStateMock.mockReturnValue({ + multichainNetworkConfigurationsByChainId: { + [SolScope.Mainnet]: { + chainId: SolScope.Mainnet, + name: 'Solana Mainnet', + isEvm: false as const, + nativeCurrency: + 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/slip44:501', + }, + [BtcScope.Mainnet]: { + chainId: BtcScope.Mainnet, + name: 'Bitcoin Mainnet', + isEvm: false as const, + nativeCurrency: 'bip122:000000000019d6689c085ae165831e93/slip44:0', + }, + }, + selectedMultichainNetworkChainId: SolScope.Mainnet, + isEvmSelected: false, + networksWithTransactionActivity: {}, + }); + + // Add a non-popular network + rootMessenger.publish('NetworkController:networkAdded', { + chainId: '0x2', // A network not in POPULAR_NETWORKS + blockExplorerUrls: [], + defaultRpcEndpointIndex: 0, + name: 'Test Network', + nativeCurrency: 'TEST', + rpcEndpoints: [ + { + url: 'https://test.network/rpc', + networkClientId: 'test-id', + type: RpcEndpointType.Custom, + }, + ], + }); + + await jestAdvanceTime({ duration: 1 }); + + // The added network should be enabled (exclusive behavior of network addition) + expect(controller.isNetworkEnabled('0x2')).toBe(true); + // Popular networks should be disabled due to exclusive behavior + expect(controller.isNetworkEnabled('0x1')).toBe(false); + expect(controller.isNetworkEnabled('0xe708')).toBe(false); + expect(controller.isNetworkEnabled('0x2105')).toBe(false); + + // Enable all popular networks - this should disable the non-popular network (exclusive behavior) + controller.enableAllPopularNetworks(); + + // All popular networks should now be enabled (with exclusive behavior) + expect(controller.isNetworkEnabled('0x1')).toBe(true); // Ethereum + expect(controller.isNetworkEnabled('0xe708')).toBe(true); // Linea + expect(controller.isNetworkEnabled('0x2105')).toBe(true); // Base + expect( + controller.isNetworkEnabled('solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp'), + ).toBe(true); // Solana + expect(controller.isNetworkEnabled(BtcScope.Mainnet)).toBe(true); // Bitcoin + // The non-popular network should be disabled due to exclusive behavior + expect(controller.isNetworkEnabled('0x2')).toBe(false); // Test network + }); + + it('enables Bitcoin mainnet when configured in MultichainNetworkController', () => { + const { controller, multichainNetworkControllerGetStateMock } = + setupController({ + config: { + state: { + enabledNetworkMap: { + [KnownCaipNamespace.Bip122]: { + [BtcScope.Mainnet]: false, // Initially disabled + }, + }, + }, + }, + }); + multichainNetworkControllerGetStateMock.mockReturnValue({ + multichainNetworkConfigurationsByChainId: { + [BtcScope.Mainnet]: { + chainId: BtcScope.Mainnet, + name: 'Bitcoin Mainnet', + isEvm: false as const, + nativeCurrency: + 'bip122:000000000019d6689c085ae165831e93/slip44:0' as `${string}:${string}/${string}:${string}`, + }, + }, + selectedMultichainNetworkChainId: BtcScope.Mainnet, + isEvmSelected: false, + networksWithTransactionActivity: {}, + }); + expect(controller.isNetworkEnabled(BtcScope.Mainnet)).toBe(false); + + // enableAllPopularNetworks should re-enable Bitcoin when it exists in config + controller.enableAllPopularNetworks(); + + expect(controller.isNetworkEnabled(BtcScope.Mainnet)).toBe(true); + }); + }); + + describe('enableNetwork', () => { + it('enables a network and clears all others in all namespaces', () => { + const { controller } = setupController(); + + // Disable a popular network (Ethereum Mainnet) + controller.disableNetwork('0x1'); + + expect(controller.state).toStrictEqual({ + enabledNetworkMap: { + [KnownCaipNamespace.Eip155]: { + '0x1': false, // Ethereum Mainnet (disabled) + '0xe708': true, // Linea Mainnet + '0x2105': true, // Base Mainnet + [ChainId[BuiltInNetworkName.ArbitrumOne]]: true, + [ChainId[BuiltInNetworkName.BscMainnet]]: true, + [ChainId[BuiltInNetworkName.OptimismMainnet]]: true, + [ChainId[BuiltInNetworkName.PolygonMainnet]]: true, + [ChainId[BuiltInNetworkName.SeiMainnet]]: true, + [ChainId[BuiltInNetworkName.MonadMainnet]]: true, + }, + [KnownCaipNamespace.Solana]: { + [SolScope.Mainnet]: true, + [SolScope.Testnet]: false, + [SolScope.Devnet]: false, + }, + [KnownCaipNamespace.Bip122]: { + [BtcScope.Mainnet]: true, + [BtcScope.Testnet]: false, + [BtcScope.Signet]: false, + }, + [KnownCaipNamespace.Tron]: { + [TrxScope.Mainnet]: true, + [TrxScope.Nile]: false, + [TrxScope.Shasta]: false, + }, + [KnownCaipNamespace.Stellar]: { + [XlmScope.Pubnet]: true, + [XlmScope.Testnet]: false, + }, + }, + nativeAssetIdentifiers: getDefaultNativeAssetIdentifiers(), + }); + + // Enable the network again - this should disable all others in all namespaces + controller.enableNetwork('0x1'); + + expect(controller.state).toStrictEqual({ + enabledNetworkMap: { + [KnownCaipNamespace.Eip155]: { + [ChainId[BuiltInNetworkName.Mainnet]]: true, // Ethereum Mainnet (re-enabled) + [ChainId[BuiltInNetworkName.LineaMainnet]]: false, // Linea Mainnet (disabled) + [ChainId[BuiltInNetworkName.BaseMainnet]]: false, // Base Mainnet (disabled) + [ChainId[BuiltInNetworkName.ArbitrumOne]]: false, + [ChainId[BuiltInNetworkName.BscMainnet]]: false, + [ChainId[BuiltInNetworkName.OptimismMainnet]]: false, + [ChainId[BuiltInNetworkName.PolygonMainnet]]: false, + [ChainId[BuiltInNetworkName.SeiMainnet]]: false, + [ChainId[BuiltInNetworkName.MonadMainnet]]: false, + }, + [KnownCaipNamespace.Solana]: { + [SolScope.Mainnet]: false, // Now disabled (cross-namespace behavior) + [SolScope.Testnet]: false, + [SolScope.Devnet]: false, + }, + [KnownCaipNamespace.Bip122]: { + [BtcScope.Mainnet]: false, // Now disabled (cross-namespace behavior) + [BtcScope.Testnet]: false, + [BtcScope.Signet]: false, + }, + [KnownCaipNamespace.Tron]: { + [TrxScope.Mainnet]: false, + [TrxScope.Nile]: false, + [TrxScope.Shasta]: false, + }, + [KnownCaipNamespace.Stellar]: { + [XlmScope.Pubnet]: false, + [XlmScope.Testnet]: false, + }, + }, + nativeAssetIdentifiers: getDefaultNativeAssetIdentifiers(), + }); + }); + + it('enables any network and clears all others (exclusive behavior)', async () => { + const { controller, rootMessenger } = setupController(); + + // Add a non-popular network + rootMessenger.publish('NetworkController:networkAdded', { + chainId: '0x2', + blockExplorerUrls: [], + defaultRpcEndpointIndex: 0, + name: 'Polygon', + nativeCurrency: 'MATIC', + rpcEndpoints: [ + { + url: 'https://polygon-mainnet.infura.io/v3/1234567890', + networkClientId: 'id', + type: RpcEndpointType.Custom, + }, + ], + }); + + await jestAdvanceTime({ duration: 1 }); + + expect(controller.state).toStrictEqual({ + enabledNetworkMap: { + [KnownCaipNamespace.Eip155]: { + '0x1': false, + '0xe708': false, + '0x2105': false, + '0x2': true, + [ChainId[BuiltInNetworkName.ArbitrumOne]]: false, + [ChainId[BuiltInNetworkName.BscMainnet]]: false, + [ChainId[BuiltInNetworkName.OptimismMainnet]]: false, + [ChainId[BuiltInNetworkName.PolygonMainnet]]: false, + [ChainId[BuiltInNetworkName.SeiMainnet]]: false, + [ChainId[BuiltInNetworkName.MonadMainnet]]: false, + }, + [KnownCaipNamespace.Solana]: { + [SolScope.Mainnet]: false, // Disabled due to cross-namespace behavior + [SolScope.Testnet]: false, + [SolScope.Devnet]: false, + }, + [KnownCaipNamespace.Bip122]: { + [BtcScope.Mainnet]: false, // Disabled due to cross-namespace behavior + [BtcScope.Testnet]: false, + [BtcScope.Signet]: false, + }, + [KnownCaipNamespace.Tron]: { + [TrxScope.Mainnet]: false, + [TrxScope.Nile]: false, + [TrxScope.Shasta]: false, + }, + [KnownCaipNamespace.Stellar]: { + [XlmScope.Pubnet]: false, + [XlmScope.Testnet]: false, + }, + }, + nativeAssetIdentifiers: { + ...getDefaultNativeAssetIdentifiers(), + 'eip155:2': 'eip155:2/slip44:60', // Defaults to 60 as chainId 2 is not in chainid.network + }, + }); + + // Enable one of the popular networks - only this one will be enabled + controller.enableNetwork('0x2105'); + + expect(controller.state).toStrictEqual({ + enabledNetworkMap: { + [KnownCaipNamespace.Eip155]: { + '0x1': false, + '0xe708': false, + '0x2105': true, + '0x2': false, + [ChainId[BuiltInNetworkName.ArbitrumOne]]: false, + [ChainId[BuiltInNetworkName.BscMainnet]]: false, + [ChainId[BuiltInNetworkName.OptimismMainnet]]: false, + [ChainId[BuiltInNetworkName.PolygonMainnet]]: false, + [ChainId[BuiltInNetworkName.SeiMainnet]]: false, + [ChainId[BuiltInNetworkName.MonadMainnet]]: false, + }, + [KnownCaipNamespace.Solana]: { + [SolScope.Mainnet]: false, // Now disabled (cross-namespace behavior) + [SolScope.Testnet]: false, + [SolScope.Devnet]: false, + }, + [KnownCaipNamespace.Bip122]: { + [BtcScope.Mainnet]: false, // Now disabled (cross-namespace behavior) + [BtcScope.Testnet]: false, + [BtcScope.Signet]: false, + }, + [KnownCaipNamespace.Tron]: { + [TrxScope.Mainnet]: false, + [TrxScope.Nile]: false, + [TrxScope.Shasta]: false, + }, + [KnownCaipNamespace.Stellar]: { + [XlmScope.Pubnet]: false, + [XlmScope.Testnet]: false, + }, + }, + nativeAssetIdentifiers: { + ...getDefaultNativeAssetIdentifiers(), + 'eip155:2': 'eip155:2/slip44:60', // Defaults to 60 as chainId 2 is not in chainid.network + }, + }); + + // Enable the non-popular network again - it will disable all others + controller.enableNetwork('0x2'); + + expect(controller.state).toStrictEqual({ + enabledNetworkMap: { + [KnownCaipNamespace.Eip155]: { + '0x1': false, + '0xe708': false, + '0x2105': false, + '0x2': true, + [ChainId[BuiltInNetworkName.ArbitrumOne]]: false, + [ChainId[BuiltInNetworkName.BscMainnet]]: false, + [ChainId[BuiltInNetworkName.OptimismMainnet]]: false, + [ChainId[BuiltInNetworkName.PolygonMainnet]]: false, + [ChainId[BuiltInNetworkName.SeiMainnet]]: false, + [ChainId[BuiltInNetworkName.MonadMainnet]]: false, + }, + [KnownCaipNamespace.Solana]: { + [SolScope.Mainnet]: false, // Now disabled (cross-namespace behavior) + [SolScope.Testnet]: false, + [SolScope.Devnet]: false, + }, + [KnownCaipNamespace.Bip122]: { + [BtcScope.Mainnet]: false, // Now disabled (cross-namespace behavior) + [BtcScope.Testnet]: false, + [BtcScope.Signet]: false, + }, + [KnownCaipNamespace.Tron]: { + [TrxScope.Mainnet]: false, + [TrxScope.Nile]: false, + [TrxScope.Shasta]: false, + }, + [KnownCaipNamespace.Stellar]: { + [XlmScope.Pubnet]: false, + [XlmScope.Testnet]: false, + }, + }, + nativeAssetIdentifiers: { + ...getDefaultNativeAssetIdentifiers(), + 'eip155:2': 'eip155:2/slip44:60', // Defaults to 60 as chainId 2 is not in chainid.network + }, + }); + }); + + it('handles invalid chain ID gracefully', () => { + const { controller } = setupController(); + + // @ts-expect-error Intentionally passing an invalid chain ID + expect(() => controller.enableNetwork('invalid')).toThrow( + 'Value must be a hexadecimal string.', + ); + }); + + it('handles enabling a network that is not added', () => { + const { controller } = setupController(); + + controller.enableNetwork('bip122:000000000019d6689c085ae165831e93'); + + expect(controller.state).toStrictEqual({ + enabledNetworkMap: { + [KnownCaipNamespace.Eip155]: { + [ChainId[BuiltInNetworkName.Mainnet]]: false, // Disabled due to cross-namespace behavior + [ChainId[BuiltInNetworkName.LineaMainnet]]: false, // Disabled due to cross-namespace behavior + [ChainId[BuiltInNetworkName.BaseMainnet]]: false, // Disabled due to cross-namespace behavior + [ChainId[BuiltInNetworkName.ArbitrumOne]]: false, + [ChainId[BuiltInNetworkName.BscMainnet]]: false, + [ChainId[BuiltInNetworkName.OptimismMainnet]]: false, + [ChainId[BuiltInNetworkName.PolygonMainnet]]: false, + [ChainId[BuiltInNetworkName.SeiMainnet]]: false, + [ChainId[BuiltInNetworkName.MonadMainnet]]: false, + }, + [KnownCaipNamespace.Solana]: { + [SolScope.Mainnet]: false, // Disabled due to cross-namespace behavior + [SolScope.Testnet]: false, + [SolScope.Devnet]: false, + }, + [KnownCaipNamespace.Bip122]: { + [BtcScope.Mainnet]: true, // This network was enabled (even though namespace doesn't exist) + [BtcScope.Testnet]: false, + [BtcScope.Signet]: false, + }, + [KnownCaipNamespace.Tron]: { + [TrxScope.Mainnet]: false, + [TrxScope.Nile]: false, + [TrxScope.Shasta]: false, + }, + [KnownCaipNamespace.Stellar]: { + [XlmScope.Pubnet]: false, + [XlmScope.Testnet]: false, + }, + }, + nativeAssetIdentifiers: getDefaultNativeAssetIdentifiers(), + }); + }); + + it('handles enabling a network in non-existent namespace gracefully', () => { + const { controller } = setupController(); + + // Remove the BIP122 namespace to test the early return + // eslint-disable-next-line dot-notation + controller['update']((state) => { + delete state.enabledNetworkMap[KnownCaipNamespace.Bip122]; + }); + + // Try to enable a Bitcoin network when the namespace doesn't exist + controller.enableNetwork('bip122:000000000933ea01ad0ee984209779ba'); + + // All existing networks should be disabled due to cross-namespace behavior, even though target network couldn't be enabled + // slip44Map is not affected by enabledNetworkMap changes, so it still contains all the original entries + expect(controller.state).toStrictEqual({ + enabledNetworkMap: { + [KnownCaipNamespace.Eip155]: { + [ChainId[BuiltInNetworkName.Mainnet]]: false, + [ChainId[BuiltInNetworkName.LineaMainnet]]: false, + [ChainId[BuiltInNetworkName.BaseMainnet]]: false, + [ChainId[BuiltInNetworkName.ArbitrumOne]]: false, + [ChainId[BuiltInNetworkName.BscMainnet]]: false, + [ChainId[BuiltInNetworkName.OptimismMainnet]]: false, + [ChainId[BuiltInNetworkName.PolygonMainnet]]: false, + [ChainId[BuiltInNetworkName.SeiMainnet]]: false, + [ChainId[BuiltInNetworkName.MonadMainnet]]: false, + }, + [KnownCaipNamespace.Solana]: { + [SolScope.Mainnet]: false, + [SolScope.Testnet]: false, + [SolScope.Devnet]: false, + }, + [KnownCaipNamespace.Tron]: { + [TrxScope.Mainnet]: false, + [TrxScope.Nile]: false, + [TrxScope.Shasta]: false, + }, + [KnownCaipNamespace.Stellar]: { + [XlmScope.Pubnet]: false, + [XlmScope.Testnet]: false, + }, + }, + nativeAssetIdentifiers: getDefaultNativeAssetIdentifiers(), + }); + }); + + it('handle no namespace bucket', async () => { + const { controller, rootMessenger } = setupController(); + + // add new network with no namespace bucket + rootMessenger.publish('NetworkController:networkAdded', { + // @ts-expect-error Intentionally passing an invalid chain ID + chainId: 'bip122:000000000019d6689c085ae165831e93', + blockExplorerUrls: [], + defaultRpcEndpointIndex: 0, + name: 'Bitcoin', + nativeCurrency: 'BTC', + rpcEndpoints: [ + { + url: 'https://api.blockcypher.com/v1/btc/main', + networkClientId: 'id', + type: RpcEndpointType.Custom, + }, + ], + }); + + await jestAdvanceTime({ duration: 1 }); + + expect(controller.state).toStrictEqual({ + enabledNetworkMap: { + [KnownCaipNamespace.Eip155]: { + [ChainId[BuiltInNetworkName.Mainnet]]: false, // Disabled due to cross-namespace behavior + [ChainId[BuiltInNetworkName.LineaMainnet]]: false, // Disabled due to cross-namespace behavior + [ChainId[BuiltInNetworkName.BaseMainnet]]: false, // Disabled due to cross-namespace behavior + [ChainId[BuiltInNetworkName.ArbitrumOne]]: false, + [ChainId[BuiltInNetworkName.BscMainnet]]: false, + [ChainId[BuiltInNetworkName.OptimismMainnet]]: false, + [ChainId[BuiltInNetworkName.PolygonMainnet]]: false, + [ChainId[BuiltInNetworkName.SeiMainnet]]: false, + [ChainId[BuiltInNetworkName.MonadMainnet]]: false, + }, + [KnownCaipNamespace.Solana]: { + [SolScope.Mainnet]: false, // Disabled due to cross-namespace behavior + [SolScope.Testnet]: false, + [SolScope.Devnet]: false, + }, + [KnownCaipNamespace.Bip122]: { + 'bip122:000000000019d6689c085ae165831e93': true, + [BtcScope.Testnet]: false, + [BtcScope.Signet]: false, + }, + [KnownCaipNamespace.Tron]: { + [TrxScope.Mainnet]: false, + [TrxScope.Nile]: false, + [TrxScope.Shasta]: false, + }, + [KnownCaipNamespace.Stellar]: { + [XlmScope.Pubnet]: false, + [XlmScope.Testnet]: false, + }, + }, + nativeAssetIdentifiers: { + ...getDefaultNativeAssetIdentifiers(), + // Note: This is testing invalid input (non-EVM chainId to EVM event handler) + // getEvmSlip44 defaults to 60 for unknown chainIds + 'bip122:000000000019d6689c085ae165831e93': + 'bip122:000000000019d6689c085ae165831e93/slip44:60', + }, + }); + }); + }); + + describe('disableNetwork', () => { + it('disables an EVM network using hex chain ID', () => { + const { controller } = setupController(); + + // Disable a network (but not the last one) + controller.disableNetwork('0xe708'); // Linea Mainnet + + expect(controller.state).toStrictEqual({ + enabledNetworkMap: { + [KnownCaipNamespace.Eip155]: { + '0x1': true, + '0xe708': false, + '0x2105': true, + [ChainId[BuiltInNetworkName.ArbitrumOne]]: true, + [ChainId[BuiltInNetworkName.BscMainnet]]: true, + [ChainId[BuiltInNetworkName.OptimismMainnet]]: true, + [ChainId[BuiltInNetworkName.PolygonMainnet]]: true, + [ChainId[BuiltInNetworkName.SeiMainnet]]: true, + [ChainId[BuiltInNetworkName.MonadMainnet]]: true, + }, + [KnownCaipNamespace.Solana]: { + [SolScope.Mainnet]: true, + [SolScope.Testnet]: false, + [SolScope.Devnet]: false, + }, + [KnownCaipNamespace.Bip122]: { + [BtcScope.Mainnet]: true, + [BtcScope.Testnet]: false, + [BtcScope.Signet]: false, + }, + [KnownCaipNamespace.Tron]: { + [TrxScope.Mainnet]: true, + [TrxScope.Nile]: false, + [TrxScope.Shasta]: false, + }, + [KnownCaipNamespace.Stellar]: { + [XlmScope.Pubnet]: true, + [XlmScope.Testnet]: false, + }, + }, + nativeAssetIdentifiers: getDefaultNativeAssetIdentifiers(), + }); + }); + + it('does disable a Solana network using CAIP chain ID as it is the only enabled network on the namespace', () => { + const { controller } = setupController(); + + // Try to disable a Solana network using CAIP chain ID + expect(() => + controller.disableNetwork('solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp'), + ).not.toThrow(); + }); + + it('disables the last active network for an EVM namespace', () => { + const { controller } = setupController(); + + // disable all networks except one + controller.disableNetwork('0xe708'); // Linea Mainnet + controller.disableNetwork('0x2105'); // Base Mainnet + + expect(controller.state).toStrictEqual({ + enabledNetworkMap: { + [KnownCaipNamespace.Eip155]: { + '0x1': true, + '0xe708': false, + '0x2105': false, + [ChainId[BuiltInNetworkName.ArbitrumOne]]: true, + [ChainId[BuiltInNetworkName.BscMainnet]]: true, + [ChainId[BuiltInNetworkName.OptimismMainnet]]: true, + [ChainId[BuiltInNetworkName.PolygonMainnet]]: true, + [ChainId[BuiltInNetworkName.SeiMainnet]]: true, + [ChainId[BuiltInNetworkName.MonadMainnet]]: true, + }, + [KnownCaipNamespace.Solana]: { + [SolScope.Mainnet]: true, + [SolScope.Testnet]: false, + [SolScope.Devnet]: false, + }, + [KnownCaipNamespace.Bip122]: { + [BtcScope.Mainnet]: true, + [BtcScope.Testnet]: false, + [BtcScope.Signet]: false, + }, + [KnownCaipNamespace.Tron]: { + [TrxScope.Mainnet]: true, + [TrxScope.Nile]: false, + [TrxScope.Shasta]: false, + }, + [KnownCaipNamespace.Stellar]: { + [XlmScope.Pubnet]: true, + [XlmScope.Testnet]: false, + }, + }, + nativeAssetIdentifiers: getDefaultNativeAssetIdentifiers(), + }); + + // Try to disable the last active network + expect(() => controller.disableNetwork('0x1')).not.toThrow(); + }); + + it('handles disabling non-existent network gracefully', () => { + const { controller } = setupController(); + + // Try to disable a non-existent network + expect(() => controller.disableNetwork('0x999')).not.toThrow(); + }); + + it('handles invalid chain ID gracefully', () => { + const { controller } = setupController(); + + // @ts-expect-error Intentionally passing an invalid chain ID + expect(() => controller.disableNetwork('invalid')).toThrow( + 'Value must be a hexadecimal string.', + ); + }); + }); + + describe('restoreEnabledNetworkMap', () => { + it('restores the enabled network map to a previously snapshotted state', () => { + const { controller } = setupController(); + const previousEnabledNetworkMap = Object.fromEntries( + Object.entries(controller.state.enabledNetworkMap).map( + ([namespace, networks]) => [namespace, { ...networks }], + ), + ) as typeof controller.state.enabledNetworkMap; + + controller.enableNetwork('0xa4b1'); + + expect(controller.isNetworkEnabled('0xa4b1')).toBe(true); + expect(controller.isNetworkEnabled('0x1')).toBe(false); + + controller.restoreEnabledNetworkMap(previousEnabledNetworkMap); + + expect(controller.state.enabledNetworkMap).toStrictEqual( + previousEnabledNetworkMap, + ); + }); + + it('disables networks missing from the restored snapshot', () => { + const { controller } = setupController(); + + expect(controller.isNetworkEnabled('0x1')).toBe(true); + + controller.restoreEnabledNetworkMap({}); + + expect(controller.isNetworkEnabled('0x1')).toBe(false); + expect(controller.isNetworkEnabled('0xe708')).toBe(false); + expect( + controller.isNetworkEnabled('solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp'), + ).toBe(false); + }); + + it('undoes the filter switch after networkAdded when adding without setActive', async () => { + const { controller, rootMessenger } = setupController(); + + controller.disableNetwork('0xe708'); + controller.disableNetwork('0x2105'); + + const snapshot = Object.fromEntries( + Object.entries(controller.state.enabledNetworkMap).map( + ([namespace, networks]) => [namespace, { ...networks }], + ), + ) as typeof controller.state.enabledNetworkMap; + + rootMessenger.publish('NetworkController:networkAdded', { + chainId: '0x999', + blockExplorerUrls: [], + defaultRpcEndpointIndex: 0, + name: 'Custom Network', + nativeCurrency: 'CUSTOM', + rpcEndpoints: [ + { + url: 'https://custom.network/rpc', + networkClientId: 'id', + type: RpcEndpointType.Custom, + }, + ], + }); + + await jestAdvanceTime({ duration: 1 }); + + expect(controller.isNetworkEnabled('0x999')).toBe(true); + expect(controller.isNetworkEnabled('0x1')).toBe(false); + + controller.restoreEnabledNetworkMap(snapshot); + + expect(controller.isNetworkEnabled('0x1')).toBe(true); + expect(controller.isNetworkEnabled('0xe708')).toBe(false); + expect(controller.isNetworkEnabled('0x2105')).toBe(false); + expect(controller.isNetworkEnabled('0x999')).toBe(false); + }); + }); + + describe('isNetworkEnabled', () => { + it('returns true for enabled networks using hex chain ID', () => { + const { controller } = setupController(); + + // Test default enabled networks + expect(controller.isNetworkEnabled('0x1')).toBe(true); // Ethereum Mainnet + expect(controller.isNetworkEnabled('0xe708')).toBe(true); // Linea Mainnet + expect(controller.isNetworkEnabled('0x2105')).toBe(true); // Base Mainnet + }); + + it('returns false for disabled networks using hex chain ID', () => { + const { controller } = setupController(); + + // Disable a network and test + controller.disableNetwork('0xe708'); // Linea Mainnet (not the last one) + expect(controller.isNetworkEnabled('0xe708')).toBe(false); + + // Test networks that were never enabled + expect(controller.isNetworkEnabled('0xa86a')).toBe(false); // Avalanche (not in default state) + expect(controller.isNetworkEnabled('0x999')).toBe(false); // Non-existent network + }); + + it('returns true for enabled networks using CAIP chain ID', () => { + const { controller } = setupController(); + + // Test EVM networks with CAIP format + expect(controller.isNetworkEnabled('eip155:1')).toBe(true); // Ethereum Mainnet + expect(controller.isNetworkEnabled('eip155:59144')).toBe(true); // Linea Mainnet + expect(controller.isNetworkEnabled('eip155:8453')).toBe(true); // Base Mainnet + + // Test Solana network + expect( + controller.isNetworkEnabled('solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp'), + ).toBe(true); + }); + + it('returns false for disabled networks using CAIP chain ID', () => { + const { controller } = setupController(); + + // Disable a network using hex and test with CAIP + controller.disableNetwork('0xe708'); // Linea Mainnet (not the last one) + expect(controller.isNetworkEnabled('eip155:59144')).toBe(false); + + // Test networks that were never enabled + expect(controller.isNetworkEnabled('eip155:43114')).toBe(false); // Avalanche (not in default state) + expect(controller.isNetworkEnabled('eip155:999')).toBe(false); // Non-existent network + }); + + it('handles non-existent networks gracefully', () => { + const { controller } = setupController(); + + // Test networks that don't exist in the state + expect(controller.isNetworkEnabled('0x999')).toBe(false); + expect(controller.isNetworkEnabled('eip155:999')).toBe(false); + expect( + controller.isNetworkEnabled('bip122:000000000019d6689c085ae165831e93'), + ).toBe(true); + }); + + it('returns false for networks in non-existent namespaces', () => { + const { controller } = setupController(); + + // Test a network in a namespace that doesn't exist yet + expect(controller.isNetworkEnabled('cosmos:cosmoshub-4')).toBe(false); + expect( + controller.isNetworkEnabled( + 'polkadot:91b171bb158e2d3848fa23a9f1c25182', + ), + ).toBe(false); + }); + + it('works correctly after enabling/disabling networks', () => { + const { controller } = setupController(); + + // Initially enabled + expect(controller.isNetworkEnabled('0xe708')).toBe(true); + + // Disable and check (not the last network) + controller.disableNetwork('0xe708'); + expect(controller.isNetworkEnabled('0xe708')).toBe(false); + + // Re-enable and check + controller.enableNetwork('0xe708'); + expect(controller.isNetworkEnabled('0xe708')).toBe(true); + }); + + it('maintains consistency between hex and CAIP formats for same network', () => { + const { controller } = setupController(); + + // Both formats should return the same result for the same network + expect(controller.isNetworkEnabled('0x1')).toBe( + controller.isNetworkEnabled('eip155:1'), + ); + expect(controller.isNetworkEnabled('0xe708')).toBe( + controller.isNetworkEnabled('eip155:59144'), + ); + expect(controller.isNetworkEnabled('0x2105')).toBe( + controller.isNetworkEnabled('eip155:8453'), + ); + + // Test after disabling (not the last network) + controller.disableNetwork('0xe708'); + expect(controller.isNetworkEnabled('0xe708')).toBe( + controller.isNetworkEnabled('eip155:59144'), + ); + expect(controller.isNetworkEnabled('0xe708')).toBe(false); + }); + + it('works with dynamically added networks', async () => { + const { controller, rootMessenger } = setupController(); + + // Initially, Avalanche network should not be enabled (doesn't exist) + expect(controller.isNetworkEnabled('0xa86a')).toBe(false); + + // Add Avalanche network (popular network in popular mode) + // Should keep current selection (add but don't enable) + rootMessenger.publish('NetworkController:networkAdded', { + chainId: '0xa86a', + blockExplorerUrls: [], + defaultRpcEndpointIndex: 0, + name: 'Avalanche', + nativeCurrency: 'AVAX', + rpcEndpoints: [ + { + url: 'https://api.avax.network/ext/bc/C/rpc', + networkClientId: 'id', + type: RpcEndpointType.Custom, + }, + ], + }); + + await jestAdvanceTime({ duration: 1 }); + + // Now it should be added but not enabled (keeps current selection in popular mode) + expect(controller.isNetworkEnabled('0xa86a')).toBe(true); + expect(controller.isNetworkEnabled('eip155:43114')).toBe(true); + }); + + it('handles disabling networks across different namespaces independently, but adding networks has exclusive behavior', async () => { + const { controller, rootMessenger } = setupController(); + + // EVM networks should not affect Solana network status when disabling + expect( + controller.isNetworkEnabled('solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp'), + ).toBe(true); + + // Disable all EVM networks (should not affect Solana) + controller.disableNetwork('0xe708'); // Linea + controller.disableNetwork('0x2105'); // Base + + // Solana should still be enabled + expect( + controller.isNetworkEnabled('solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp'), + ).toBe(true); + + // Add a Bitcoin network (this triggers enabling, which disables all others) + rootMessenger.publish('NetworkController:networkAdded', { + // @ts-expect-error Intentionally testing with Bitcoin network + chainId: 'bip122:000000000019d6689c085ae165831e93', + blockExplorerUrls: [], + defaultRpcEndpointIndex: 0, + name: 'Bitcoin', + nativeCurrency: 'BTC', + rpcEndpoints: [ + { + url: 'https://api.blockcypher.com/v1/btc/main', + networkClientId: 'id', + type: RpcEndpointType.Custom, + }, + ], + }); + + await jestAdvanceTime({ duration: 1 }); + + // Bitcoin should be enabled, all others should be disabled due to exclusive behavior + expect( + controller.isNetworkEnabled('bip122:000000000019d6689c085ae165831e93'), + ).toBe(true); + expect( + controller.isNetworkEnabled('solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp'), + ).toBe(false); // Now disabled due to exclusive behavior + expect(controller.isNetworkEnabled('0xe708')).toBe(false); + expect(controller.isNetworkEnabled('0x2105')).toBe(false); + expect(controller.isNetworkEnabled('0x1')).toBe(false); + }); + + it('handles invalid chain IDs gracefully', () => { + const { controller } = setupController(); + + // @ts-expect-error Intentionally passing invalid chain IDs + expect(() => controller.isNetworkEnabled('invalid')).toThrow( + 'Value must be a hexadecimal string.', + ); + + // @ts-expect-error Intentionally passing undefined + expect(() => controller.isNetworkEnabled(undefined)).toThrow( + 'Value must be a hexadecimal string.', + ); + + // @ts-expect-error Intentionally passing null + expect(() => controller.isNetworkEnabled(null)).toThrow( + 'Value must be a hexadecimal string.', + ); + }); + }); + + describe('listPopularNetworks', () => { + it('returns only popular EVM networks that exist in NetworkController and multichain mainnets that exist in MultichainNetworkController', () => { + const { controller } = setupController(); + const result = controller.listPopularNetworks(); + + // Default setup: 3 EVM (0x1, 0xe708, 0x2105) + 4 multichain (Btc, Sol, Trx, Stellar) + expect(result).toContain('eip155:1'); + expect(result).toContain('eip155:59144'); + expect(result).toContain('eip155:8453'); + expect(result).toContain(BtcScope.Mainnet); + expect(result).toContain(SolScope.Mainnet); + expect(result).toContain(TrxScope.Mainnet); + expect(result).toContain(XlmScope.Pubnet); + expect(result).toHaveLength(7); + }); + + it('excludes multichain mainnets when not in MultichainNetworkController state', () => { + const { controller, multichainNetworkControllerGetStateMock } = + setupController({}); + multichainNetworkControllerGetStateMock.mockReturnValue({ + multichainNetworkConfigurationsByChainId: {}, + selectedMultichainNetworkChainId: SolScope.Devnet, + isEvmSelected: true, + networksWithTransactionActivity: {}, + }); + const result = controller.listPopularNetworks(); + + expect(result).toContain('eip155:1'); + expect(result).toContain('eip155:59144'); + expect(result).toContain('eip155:8453'); + expect(result).not.toContain(BtcScope.Mainnet); + expect(result).not.toContain(SolScope.Mainnet); + expect(result).not.toContain(TrxScope.Mainnet); + expect(result).not.toContain(XlmScope.Pubnet); + expect(result).toHaveLength(3); + }); + + it('returns same result as calling messenger action when registered', () => { + const { controller, rootMessenger } = setupController(); + + const viaAction = rootMessenger.call( + 'NetworkEnablementController:listPopularNetworks', + ); + const viaMethod = controller.listPopularNetworks(); + expect(viaAction).toStrictEqual(viaMethod); + }); + + it('listPopularNetworks equals listPopularEvmNetworks (as CAIP-2) + listPopularMultichainNetworks', () => { + const { controller } = setupController(); + const all = controller.listPopularNetworks(); + const evmHex = controller.listPopularEvmNetworks(); + const multichain = controller.listPopularMultichainNetworks(); + const evmCaip = evmHex.map((chainIdHex) => toEvmCaipChainId(chainIdHex)); + expect(all).toStrictEqual([...evmCaip, ...multichain]); + }); + }); + + describe('listPopularEvmNetworks', () => { + it('returns only popular EVM chain IDs in hex that exist in NetworkController state', () => { + const { controller } = setupController(); + const result = controller.listPopularEvmNetworks(); + + expect(result).toContain('0x1'); + expect(result).toContain('0xe708'); + expect(result).toContain('0x2105'); + expect(result).toHaveLength(3); + expect(result.every((id) => id.startsWith('0x'))).toBe(true); + }); + + it('returns same result as calling messenger action when registered', () => { + const { controller, rootMessenger } = setupController(); + + const viaAction = rootMessenger.call( + 'NetworkEnablementController:listPopularEvmNetworks', + ); + const viaMethod = controller.listPopularEvmNetworks(); + expect(viaAction).toStrictEqual(viaMethod); + }); + }); + + describe('listPopularMultichainNetworks', () => { + it('returns only Bitcoin, Solana, Tron, Stellar mainnets that exist in MultichainNetworkController state', () => { + const { controller } = setupController(); + const result = controller.listPopularMultichainNetworks(); + + expect(result).toContain(BtcScope.Mainnet); + expect(result).toContain(SolScope.Mainnet); + expect(result).toContain(TrxScope.Mainnet); + expect(result).toContain(XlmScope.Pubnet); + expect(result).toHaveLength(4); + }); + + it('returns empty when none of the multichain mainnets are configured', () => { + const { controller, multichainNetworkControllerGetStateMock } = + setupController(); + multichainNetworkControllerGetStateMock.mockReturnValue({ + multichainNetworkConfigurationsByChainId: {}, + selectedMultichainNetworkChainId: SolScope.Devnet, + isEvmSelected: true, + networksWithTransactionActivity: {}, + }); + + const result = controller.listPopularMultichainNetworks(); + expect(result).toStrictEqual([]); + }); + + it('returns same result as calling messenger action when registered', () => { + const { controller, rootMessenger } = setupController(); + + const viaAction = rootMessenger.call( + 'NetworkEnablementController:listPopularMultichainNetworks', + ); + const viaMethod = controller.listPopularMultichainNetworks(); + expect(viaAction).toStrictEqual(viaMethod); + }); + }); + + describe('Bitcoin Support', () => { + it('initializes with only Bitcoin mainnet enabled by default', () => { + const { controller } = setupController(); + + // Only Bitcoin mainnet should be enabled by default + expect(controller.isNetworkEnabled(BtcScope.Mainnet)).toBe(true); + expect(controller.isNetworkEnabled(BtcScope.Testnet)).toBe(false); + expect(controller.isNetworkEnabled(BtcScope.Signet)).toBe(false); + + expect( + controller.state.enabledNetworkMap[KnownCaipNamespace.Bip122], + ).toStrictEqual({ + [BtcScope.Mainnet]: true, + [BtcScope.Testnet]: false, + [BtcScope.Signet]: false, + }); + }); + + it('enables and disables Bitcoin networks using CAIP chain IDs with exclusive behavior', () => { + const { controller } = setupController(); + + // Initially only Bitcoin mainnet is enabled + expect(controller.isNetworkEnabled(BtcScope.Mainnet)).toBe(true); + expect(controller.isNetworkEnabled(BtcScope.Testnet)).toBe(false); + expect(controller.isNetworkEnabled(BtcScope.Signet)).toBe(false); + + // Enable Bitcoin testnet (should disable all others in all namespaces due to exclusive behavior) + controller.enableNetwork(BtcScope.Testnet); + expect(controller.isNetworkEnabled(BtcScope.Testnet)).toBe(true); + expect(controller.isNetworkEnabled(BtcScope.Mainnet)).toBe(false); + expect(controller.isNetworkEnabled(BtcScope.Signet)).toBe(false); + // Check that EVM and Solana networks are also disabled + expect(controller.isNetworkEnabled('0x1')).toBe(false); + expect(controller.isNetworkEnabled('0xe708')).toBe(false); + expect(controller.isNetworkEnabled('0x2105')).toBe(false); + expect( + controller.isNetworkEnabled('solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp'), + ).toBe(false); + + // Enable Bitcoin signet (should disable testnet and all other networks) + controller.enableNetwork(BtcScope.Signet); + expect(controller.isNetworkEnabled(BtcScope.Signet)).toBe(true); + expect(controller.isNetworkEnabled(BtcScope.Testnet)).toBe(false); + expect(controller.isNetworkEnabled(BtcScope.Mainnet)).toBe(false); + // EVM and Solana networks should remain disabled + expect(controller.isNetworkEnabled('0x1')).toBe(false); + expect( + controller.isNetworkEnabled('solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp'), + ).toBe(false); + + // Re-enable mainnet (should disable signet and all other networks) + controller.enableNetwork(BtcScope.Mainnet); + expect(controller.isNetworkEnabled(BtcScope.Mainnet)).toBe(true); + expect(controller.isNetworkEnabled(BtcScope.Signet)).toBe(false); + // EVM and Solana networks should remain disabled + expect(controller.isNetworkEnabled('0x1')).toBe(false); + expect( + controller.isNetworkEnabled('solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp'), + ).toBe(false); + }); + + it('allows disabling Bitcoin networks when multiple are enabled', () => { + const { controller } = setupController(); + + // Initially only Bitcoin mainnet is enabled + expect(controller.isNetworkEnabled(BtcScope.Mainnet)).toBe(true); + expect(controller.isNetworkEnabled(BtcScope.Testnet)).toBe(false); + expect(controller.isNetworkEnabled(BtcScope.Signet)).toBe(false); + + // Enable testnet (this will disable mainnet and all other networks due to exclusive behavior) + controller.enableNetwork(BtcScope.Testnet); + expect(controller.isNetworkEnabled(BtcScope.Testnet)).toBe(true); + expect(controller.isNetworkEnabled(BtcScope.Mainnet)).toBe(false); + // EVM and Solana networks should also be disabled + expect(controller.isNetworkEnabled('0x1')).toBe(false); + expect( + controller.isNetworkEnabled('solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp'), + ).toBe(false); + + // Now enable mainnet again (this will disable testnet and all other networks) + controller.enableNetwork(BtcScope.Mainnet); + expect(controller.isNetworkEnabled(BtcScope.Mainnet)).toBe(true); + expect(controller.isNetworkEnabled(BtcScope.Testnet)).toBe(false); + // EVM and Solana networks should remain disabled + expect(controller.isNetworkEnabled('0x1')).toBe(false); + expect( + controller.isNetworkEnabled('solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp'), + ).toBe(false); + + // Enable signet (this will disable mainnet and all other networks) + controller.enableNetwork(BtcScope.Signet); + expect(controller.isNetworkEnabled(BtcScope.Signet)).toBe(true); + expect(controller.isNetworkEnabled(BtcScope.Mainnet)).toBe(false); + // EVM and Solana networks should remain disabled + expect(controller.isNetworkEnabled('0x1')).toBe(false); + expect( + controller.isNetworkEnabled('solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp'), + ).toBe(false); + }); + + it('prevents disabling the last remaining Bitcoin network', () => { + const { controller } = setupController(); + + // Only Bitcoin mainnet is enabled by default + expect(controller.isNetworkEnabled(BtcScope.Mainnet)).toBe(true); + expect(controller.isNetworkEnabled(BtcScope.Testnet)).toBe(false); + expect(controller.isNetworkEnabled(BtcScope.Signet)).toBe(false); + + // Should not be able to disable the last remaining Bitcoin network + expect(() => controller.disableNetwork(BtcScope.Mainnet)).not.toThrow(); + }); + + it('allows disabling the last Bitcoin network', () => { + const { controller } = setupController(); + + // Only Bitcoin mainnet is enabled by default in the BIP122 namespace + expect(() => controller.disableNetwork(BtcScope.Mainnet)).not.toThrow(); + }); + + it('handles all Bitcoin testnet variants', () => { + const { controller } = setupController(); + + // Test each Bitcoin testnet variant + const testnets = [ + { scope: BtcScope.Testnet, name: 'Testnet' }, + { scope: BtcScope.Signet, name: 'Signet' }, + ]; + + testnets.forEach(({ scope }) => { + // Enable the testnet (should disable all others in all namespaces due to exclusive behavior) + controller.enableNetwork(scope); + expect(controller.isNetworkEnabled(scope)).toBe(true); + expect(controller.isNetworkEnabled(BtcScope.Mainnet)).toBe(false); + + // Check that EVM and Solana networks are also disabled + expect(controller.isNetworkEnabled('0x1')).toBe(false); + expect(controller.isNetworkEnabled('0xe708')).toBe(false); + expect(controller.isNetworkEnabled('0x2105')).toBe(false); + expect( + controller.isNetworkEnabled( + 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp', + ), + ).toBe(false); + + // Verify other testnets are also disabled + testnets.forEach(({ scope: otherScope }) => { + expect(controller.isNetworkEnabled(otherScope)).toBe( + otherScope === scope, + ); + }); + }); + }); + + it('handles Bitcoin network addition dynamically', async () => { + const { controller, rootMessenger } = setupController(); + + // Add Bitcoin testnet dynamically + rootMessenger.publish('NetworkController:networkAdded', { + // @ts-expect-error Testing with Bitcoin network + chainId: BtcScope.Testnet, + blockExplorerUrls: [], + defaultRpcEndpointIndex: 0, + name: 'Bitcoin Testnet', + nativeCurrency: 'tBTC', + rpcEndpoints: [ + { + url: 'https://api.blockcypher.com/v1/btc/test3', + networkClientId: 'btc-testnet', + type: RpcEndpointType.Custom, + }, + ], + }); + + 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); + expect(controller.isNetworkEnabled(BtcScope.Mainnet)).toBe(false); + expect(controller.isNetworkEnabled(BtcScope.Signet)).toBe(false); + // EVM and Solana networks should also be disabled + expect(controller.isNetworkEnabled('0x1')).toBe(false); + expect(controller.isNetworkEnabled('0xe708')).toBe(false); + expect(controller.isNetworkEnabled('0x2105')).toBe(false); + expect( + controller.isNetworkEnabled('solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp'), + ).toBe(false); + }); + + it('maintains Bitcoin network state independently when disabling networks from other namespaces', () => { + const { controller } = setupController(); + + // Disable EVM networks (disableNetwork should not affect other namespaces) + controller.disableNetwork('0x1'); + controller.disableNetwork('0xe708'); + + // Bitcoin mainnet should still be enabled, testnets remain disabled + expect(controller.isNetworkEnabled(BtcScope.Mainnet)).toBe(true); + expect(controller.isNetworkEnabled(BtcScope.Testnet)).toBe(false); + expect(controller.isNetworkEnabled(BtcScope.Signet)).toBe(false); + + // Disable Solana network - this should not affect Bitcoin networks + expect(() => + controller.disableNetwork('solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp'), + ).not.toThrow(); + + // Bitcoin mainnet should still be enabled, testnets remain disabled + expect(controller.isNetworkEnabled(BtcScope.Mainnet)).toBe(true); + expect(controller.isNetworkEnabled(BtcScope.Testnet)).toBe(false); + expect(controller.isNetworkEnabled(BtcScope.Signet)).toBe(false); + }); + + it('validates Bitcoin network chain IDs are correct', () => { + const { controller } = setupController(); + + // Test that Bitcoin networks have the correct chain IDs and default states + expect( + controller.isNetworkEnabled('bip122:000000000019d6689c085ae165831e93'), + ).toBe(true); // Mainnet (enabled by default) + expect( + controller.isNetworkEnabled('bip122:000000000933ea01ad0ee984209779ba'), + ).toBe(false); // Testnet (disabled by default) + expect( + controller.isNetworkEnabled('bip122:00000008819873e925422c1ff0f99f7c'), + ).toBe(false); // Signet (disabled by default) + }); + }); + + describe('Tron Support', () => { + it('initializes with only Tron mainnet enabled by default', () => { + const { controller } = setupController(); + + // Only Tron mainnet should be enabled by default + expect(controller.isNetworkEnabled(TrxScope.Mainnet)).toBe(true); + expect(controller.isNetworkEnabled(TrxScope.Nile)).toBe(false); + expect(controller.isNetworkEnabled(TrxScope.Shasta)).toBe(false); + + expect( + controller.state.enabledNetworkMap[KnownCaipNamespace.Tron], + ).toStrictEqual({ + [TrxScope.Mainnet]: true, + [TrxScope.Nile]: false, + [TrxScope.Shasta]: false, + }); + }); + + it('enables and disables Tron networks using CAIP chain IDs with exclusive behavior', () => { + const { controller } = setupController(); + + // Initially only Tron mainnet is enabled + expect(controller.isNetworkEnabled(TrxScope.Mainnet)).toBe(true); + expect(controller.isNetworkEnabled(TrxScope.Nile)).toBe(false); + expect(controller.isNetworkEnabled(TrxScope.Shasta)).toBe(false); + + // Enable Tron Nile (should disable all others in all namespaces due to exclusive behavior) + controller.enableNetwork(TrxScope.Nile); + expect(controller.isNetworkEnabled(TrxScope.Nile)).toBe(true); + expect(controller.isNetworkEnabled(TrxScope.Mainnet)).toBe(false); + expect(controller.isNetworkEnabled(TrxScope.Shasta)).toBe(false); + // Check that EVM, Solana, and Bitcoin networks are also disabled + expect(controller.isNetworkEnabled('0x1')).toBe(false); + expect(controller.isNetworkEnabled('0xe708')).toBe(false); + expect(controller.isNetworkEnabled('0x2105')).toBe(false); + expect( + controller.isNetworkEnabled('solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp'), + ).toBe(false); + expect(controller.isNetworkEnabled(BtcScope.Mainnet)).toBe(false); + + // Enable Tron Shasta (should disable Nile and all other networks) + controller.enableNetwork(TrxScope.Shasta); + expect(controller.isNetworkEnabled(TrxScope.Shasta)).toBe(true); + expect(controller.isNetworkEnabled(TrxScope.Nile)).toBe(false); + expect(controller.isNetworkEnabled(TrxScope.Mainnet)).toBe(false); + // EVM, Solana, and Bitcoin networks should remain disabled + expect(controller.isNetworkEnabled('0x1')).toBe(false); + expect( + controller.isNetworkEnabled('solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp'), + ).toBe(false); + expect(controller.isNetworkEnabled(BtcScope.Mainnet)).toBe(false); + + // Re-enable mainnet (should disable Shasta and all other networks) + controller.enableNetwork(TrxScope.Mainnet); + expect(controller.isNetworkEnabled(TrxScope.Mainnet)).toBe(true); + expect(controller.isNetworkEnabled(TrxScope.Shasta)).toBe(false); + // EVM, Solana, and Bitcoin networks should remain disabled + expect(controller.isNetworkEnabled('0x1')).toBe(false); + expect( + controller.isNetworkEnabled('solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp'), + ).toBe(false); + expect(controller.isNetworkEnabled(BtcScope.Mainnet)).toBe(false); + }); + + it('allows disabling Tron networks when multiple are enabled', () => { + const { controller } = setupController(); + + // Initially only Tron mainnet is enabled + expect(controller.isNetworkEnabled(TrxScope.Mainnet)).toBe(true); + expect(controller.isNetworkEnabled(TrxScope.Nile)).toBe(false); + expect(controller.isNetworkEnabled(TrxScope.Shasta)).toBe(false); + + // Enable Nile (this will disable mainnet and all other networks due to exclusive behavior) + controller.enableNetwork(TrxScope.Nile); + expect(controller.isNetworkEnabled(TrxScope.Nile)).toBe(true); + expect(controller.isNetworkEnabled(TrxScope.Mainnet)).toBe(false); + // EVM, Solana, and Bitcoin networks should also be disabled + expect(controller.isNetworkEnabled('0x1')).toBe(false); + expect( + controller.isNetworkEnabled('solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp'), + ).toBe(false); + expect(controller.isNetworkEnabled(BtcScope.Mainnet)).toBe(false); + + // Now enable mainnet again (this will disable Nile and all other networks) + controller.enableNetwork(TrxScope.Mainnet); + expect(controller.isNetworkEnabled(TrxScope.Mainnet)).toBe(true); + expect(controller.isNetworkEnabled(TrxScope.Nile)).toBe(false); + // EVM, Solana, and Bitcoin networks should remain disabled + expect(controller.isNetworkEnabled('0x1')).toBe(false); + expect( + controller.isNetworkEnabled('solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp'), + ).toBe(false); + expect(controller.isNetworkEnabled(BtcScope.Mainnet)).toBe(false); + + // Enable Shasta (this will disable mainnet and all other networks) + controller.enableNetwork(TrxScope.Shasta); + expect(controller.isNetworkEnabled(TrxScope.Shasta)).toBe(true); + expect(controller.isNetworkEnabled(TrxScope.Mainnet)).toBe(false); + // EVM, Solana, and Bitcoin networks should remain disabled + expect(controller.isNetworkEnabled('0x1')).toBe(false); + expect( + controller.isNetworkEnabled('solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp'), + ).toBe(false); + expect(controller.isNetworkEnabled(BtcScope.Mainnet)).toBe(false); + }); + + it('prevents disabling the last remaining Tron network', () => { + const { controller } = setupController(); + + // Only Tron mainnet is enabled by default + expect(controller.isNetworkEnabled(TrxScope.Mainnet)).toBe(true); + expect(controller.isNetworkEnabled(TrxScope.Nile)).toBe(false); + expect(controller.isNetworkEnabled(TrxScope.Shasta)).toBe(false); + + // Should not be able to disable the last remaining Tron network + expect(() => controller.disableNetwork(TrxScope.Mainnet)).not.toThrow(); + }); + + it('allows disabling the last Tron network', () => { + const { controller } = setupController(); + + // Only Tron mainnet is enabled by default in the Tron namespace + expect(() => controller.disableNetwork(TrxScope.Mainnet)).not.toThrow(); + }); + + it('handles all Tron testnet variants', () => { + const { controller } = setupController(); + + // Test each Tron testnet variant + const testnets = [ + { scope: TrxScope.Nile, name: 'Nile' }, + { scope: TrxScope.Shasta, name: 'Shasta' }, + ]; + + testnets.forEach(({ scope }) => { + // Enable the testnet (should disable all others in all namespaces due to exclusive behavior) + controller.enableNetwork(scope); + expect(controller.isNetworkEnabled(scope)).toBe(true); + expect(controller.isNetworkEnabled(TrxScope.Mainnet)).toBe(false); + + // Check that EVM, Solana, and Bitcoin networks are also disabled + expect(controller.isNetworkEnabled('0x1')).toBe(false); + expect(controller.isNetworkEnabled('0xe708')).toBe(false); + expect(controller.isNetworkEnabled('0x2105')).toBe(false); + expect( + controller.isNetworkEnabled( + 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp', + ), + ).toBe(false); + expect(controller.isNetworkEnabled(BtcScope.Mainnet)).toBe(false); + + // Verify other testnets are also disabled + testnets.forEach(({ scope: otherScope }) => { + expect(controller.isNetworkEnabled(otherScope)).toBe( + otherScope === scope, + ); + }); + }); + }); + + it('handles Tron network addition dynamically', async () => { + const { controller, rootMessenger } = setupController(); + + // Add Tron Nile dynamically + rootMessenger.publish('NetworkController:networkAdded', { + // @ts-expect-error Testing with Tron network + chainId: TrxScope.Nile, + blockExplorerUrls: [], + defaultRpcEndpointIndex: 0, + name: 'Tron Nile', + nativeCurrency: 'TRX', + rpcEndpoints: [ + { + url: 'https://nile.trongrid.io', + networkClientId: 'trx-nile', + type: RpcEndpointType.Custom, + }, + ], + }); + + 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); + expect(controller.isNetworkEnabled(TrxScope.Mainnet)).toBe(false); + expect(controller.isNetworkEnabled(TrxScope.Shasta)).toBe(false); + // EVM, Solana, and Bitcoin networks should also be disabled + expect(controller.isNetworkEnabled('0x1')).toBe(false); + expect(controller.isNetworkEnabled('0xe708')).toBe(false); + expect(controller.isNetworkEnabled('0x2105')).toBe(false); + expect( + controller.isNetworkEnabled('solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp'), + ).toBe(false); + expect(controller.isNetworkEnabled(BtcScope.Mainnet)).toBe(false); + }); + + it('maintains Tron network state independently when disabling networks from other namespaces', () => { + const { controller } = setupController(); + + // Disable EVM networks (disableNetwork should not affect other namespaces) + controller.disableNetwork('0x1'); + controller.disableNetwork('0xe708'); + + // Tron mainnet should still be enabled, testnets remain disabled + expect(controller.isNetworkEnabled(TrxScope.Mainnet)).toBe(true); + expect(controller.isNetworkEnabled(TrxScope.Nile)).toBe(false); + expect(controller.isNetworkEnabled(TrxScope.Shasta)).toBe(false); + + // Disable Solana network - this should not affect Tron networks + expect(() => + controller.disableNetwork('solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp'), + ).not.toThrow(); + + // Tron mainnet should still be enabled, testnets remain disabled + expect(controller.isNetworkEnabled(TrxScope.Mainnet)).toBe(true); + expect(controller.isNetworkEnabled(TrxScope.Nile)).toBe(false); + expect(controller.isNetworkEnabled(TrxScope.Shasta)).toBe(false); + }); + + it('validates Tron network chain IDs are correct', () => { + const { controller } = setupController(); + + // Test that Tron networks have the correct chain IDs and default states + expect(controller.isNetworkEnabled('tron:728126428')).toBe(true); // Mainnet (enabled by default) + expect(controller.isNetworkEnabled('tron:3448148188')).toBe(false); // Nile (disabled by default) + expect(controller.isNetworkEnabled('tron:2494104990')).toBe(false); // Shasta (disabled by default) + }); + + it('enables a Tron network in the Tron namespace', () => { + const { controller } = setupController(); + + // Enable Tron Nile in the Tron namespace + controller.enableNetworkInNamespace( + TrxScope.Nile, + KnownCaipNamespace.Tron, + ); + + // Only Tron Nile should be enabled in Tron namespace + expect(controller.isNetworkEnabled(TrxScope.Nile)).toBe(true); + expect(controller.isNetworkEnabled(TrxScope.Mainnet)).toBe(false); + expect(controller.isNetworkEnabled(TrxScope.Shasta)).toBe(false); + + // Other namespaces should remain unchanged + expect(controller.isNetworkEnabled('0x1')).toBe(true); + expect(controller.isNetworkEnabled('0xe708')).toBe(true); + expect(controller.isNetworkEnabled('0x2105')).toBe(true); + expect(controller.isNetworkEnabled(SolScope.Mainnet)).toBe(true); + expect(controller.isNetworkEnabled(BtcScope.Mainnet)).toBe(true); + }); + + it('throws error when Tron chainId namespace does not match provided namespace', () => { + const { controller } = setupController(); + + // Try to enable Tron network in Solana namespace + expect(() => { + controller.enableNetworkInNamespace( + TrxScope.Mainnet, + KnownCaipNamespace.Solana, + ); + }).toThrow( + `Chain ID ${TrxScope.Mainnet} belongs to namespace tron, but namespace solana was specified`, + ); + + // Try to enable Ethereum network in Tron namespace + expect(() => { + controller.enableNetworkInNamespace('0x1', KnownCaipNamespace.Tron); + }).toThrow( + 'Chain ID 0x1 belongs to namespace eip155, but namespace tron was specified', + ); + }); + }); + + describe('enableNetworkInNamespace', () => { + it('enables a network in the specified namespace and disables others in same namespace', () => { + const { controller } = setupController(); + + // Initially multiple EVM networks are enabled + expect(controller.isNetworkEnabled('0x1')).toBe(true); + expect(controller.isNetworkEnabled('0xe708')).toBe(true); + expect(controller.isNetworkEnabled('0x2105')).toBe(true); + + // Enable only Ethereum mainnet in EIP-155 namespace + controller.enableNetworkInNamespace('0x1', KnownCaipNamespace.Eip155); + + // Only Ethereum mainnet should be enabled in EIP-155 namespace + expect(controller.isNetworkEnabled('0x1')).toBe(true); + expect(controller.isNetworkEnabled('0xe708')).toBe(false); + expect(controller.isNetworkEnabled('0x2105')).toBe(false); + + // Other namespaces should remain unchanged + expect(controller.isNetworkEnabled(SolScope.Mainnet)).toBe(true); + expect(controller.isNetworkEnabled(BtcScope.Mainnet)).toBe(true); + expect(controller.isNetworkEnabled(TrxScope.Mainnet)).toBe(true); + }); + + it('enables a network using CAIP chain ID in the specified namespace', () => { + const { controller } = setupController(); + + // Enable Ethereum mainnet using CAIP format + controller.enableNetworkInNamespace( + 'eip155:1', + KnownCaipNamespace.Eip155, + ); + + // Only Ethereum mainnet should be enabled in EIP-155 namespace + expect(controller.isNetworkEnabled('0x1')).toBe(true); + expect(controller.isNetworkEnabled('0xe708')).toBe(false); + expect(controller.isNetworkEnabled('0x2105')).toBe(false); + }); + + it('enables a Solana network in the Solana namespace', () => { + const { controller } = setupController(); + + // Enable Solana testnet in the Solana namespace + controller.enableNetworkInNamespace( + SolScope.Testnet, + KnownCaipNamespace.Solana, + ); + + // Only Solana testnet should be enabled in Solana namespace + expect(controller.isNetworkEnabled(SolScope.Testnet)).toBe(true); + expect(controller.isNetworkEnabled(SolScope.Mainnet)).toBe(false); + expect(controller.isNetworkEnabled(SolScope.Devnet)).toBe(false); + + // Other namespaces should remain unchanged + expect(controller.isNetworkEnabled('0x1')).toBe(true); + expect(controller.isNetworkEnabled('0xe708')).toBe(true); + expect(controller.isNetworkEnabled('0x2105')).toBe(true); + expect(controller.isNetworkEnabled(BtcScope.Mainnet)).toBe(true); + }); + + it('enables a Bitcoin network in the Bitcoin namespace', () => { + const { controller } = setupController(); + + // Enable Bitcoin testnet in the Bitcoin namespace + controller.enableNetworkInNamespace( + BtcScope.Testnet, + KnownCaipNamespace.Bip122, + ); + + // Only Bitcoin testnet should be enabled in Bitcoin namespace + expect(controller.isNetworkEnabled(BtcScope.Testnet)).toBe(true); + expect(controller.isNetworkEnabled(BtcScope.Mainnet)).toBe(false); + expect(controller.isNetworkEnabled(BtcScope.Signet)).toBe(false); + + // Other namespaces should remain unchanged + expect(controller.isNetworkEnabled('0x1')).toBe(true); + expect(controller.isNetworkEnabled('0xe708')).toBe(true); + expect(controller.isNetworkEnabled('0x2105')).toBe(true); + expect(controller.isNetworkEnabled(SolScope.Mainnet)).toBe(true); + }); + + it('throws error when chainId namespace does not match provided namespace', () => { + const { controller } = setupController(); + + // Try to enable Ethereum network in Solana namespace + expect(() => { + controller.enableNetworkInNamespace('0x1', KnownCaipNamespace.Solana); + }).toThrow( + 'Chain ID 0x1 belongs to namespace eip155, but namespace solana was specified', + ); + + // Try to enable Solana network in EIP-155 namespace + expect(() => { + controller.enableNetworkInNamespace( + SolScope.Mainnet, + KnownCaipNamespace.Eip155, + ); + }).toThrow( + `Chain ID ${SolScope.Mainnet} belongs to namespace solana, but namespace eip155 was specified`, + ); + + // Try to enable Bitcoin network in Solana namespace + expect(() => { + controller.enableNetworkInNamespace( + BtcScope.Mainnet, + KnownCaipNamespace.Solana, + ); + }).toThrow( + `Chain ID ${BtcScope.Mainnet} belongs to namespace bip122, but namespace solana was specified`, + ); + }); + + it('throws error with CAIP chain ID when namespace does not match', () => { + const { controller } = setupController(); + + // Try to enable Ethereum network using CAIP format in Solana namespace + expect(() => { + controller.enableNetworkInNamespace( + 'eip155:1', + KnownCaipNamespace.Solana, + ); + }).toThrow( + 'Chain ID eip155:1 belongs to namespace eip155, but namespace solana was specified', + ); + }); + it('handles enabling an already enabled network', () => { + const { controller } = setupController(); + + // Ethereum mainnet is already enabled + expect(controller.isNetworkEnabled('0x1')).toBe(true); + + const initialState = { ...controller.state }; + + // Enable it again - should disable other networks in the namespace + controller.enableNetworkInNamespace('0x1', KnownCaipNamespace.Eip155); + + // Only Ethereum mainnet should be enabled in EIP-155 namespace + expect(controller.isNetworkEnabled('0x1')).toBe(true); + expect(controller.isNetworkEnabled('0xe708')).toBe(false); + expect(controller.isNetworkEnabled('0x2105')).toBe(false); + + // Should be different from initial state due to disabling other networks + expect(controller.state).not.toStrictEqual(initialState); + }); + + it('enables network that does not exist in current state', () => { + const { controller } = setupController(); + + // Try to enable a network that doesn't exist in the state yet + controller.enableNetworkInNamespace('0x89', KnownCaipNamespace.Eip155); + + // Network should be enabled (namespace bucket should be created) + expect(controller.isNetworkEnabled('0x89')).toBe(true); + expect( + controller.state.enabledNetworkMap[KnownCaipNamespace.Eip155]['0x89'], + ).toBe(true); + }); + + it('maintains consistency between hex and CAIP formats', () => { + const { controller } = setupController(); + + // Enable using hex format + controller.enableNetworkInNamespace('0x1', KnownCaipNamespace.Eip155); + + // Both formats should show the same result + expect(controller.isNetworkEnabled('0x1')).toBe( + controller.isNetworkEnabled('eip155:1'), + ); + expect(controller.isNetworkEnabled('0x1')).toBe(true); + + // Enable using CAIP format + controller.enableNetworkInNamespace( + 'eip155:59144', + KnownCaipNamespace.Eip155, + ); + + // Both formats should show the same result + expect(controller.isNetworkEnabled('0xe708')).toBe( + controller.isNetworkEnabled('eip155:59144'), + ); + expect(controller.isNetworkEnabled('0xe708')).toBe(true); + expect(controller.isNetworkEnabled('0x1')).toBe(false); // Should be disabled + }); + + it('handles custom namespace creation for new blockchain', () => { + const { controller } = setupController(); + + // Try to enable a network in a custom namespace that doesn't exist yet + const customChainId = 'cosmos:cosmoshub-4' as CaipChainId; + const customNamespace = 'cosmos' as CaipNamespace; + + controller.enableNetworkInNamespace(customChainId, customNamespace); + + // Custom namespace should be created and network enabled + expect(controller.state.enabledNetworkMap[customNamespace]).toBeDefined(); + expect( + controller.state.enabledNetworkMap[customNamespace][customChainId], + ).toBe(true); + expect(controller.isNetworkEnabled(customChainId)).toBe(true); + }); + }); + + describe('metadata', () => { + it('includes expected state in debug snapshots', () => { + const { controller } = setupController(); + + const derivedState = deriveStateFromMetadata( + controller.state, + controller.metadata, + 'includeInDebugSnapshot', + ); + + expect(derivedState).toHaveProperty('enabledNetworkMap'); + expect(derivedState).toHaveProperty('nativeAssetIdentifiers'); + }); + + it('includes expected state in state logs', () => { + const { controller } = setupController(); + + const derivedState = deriveStateFromMetadata( + controller.state, + controller.metadata, + 'includeInStateLogs', + ); + + expect(derivedState).toHaveProperty('enabledNetworkMap'); + expect(derivedState).toHaveProperty('nativeAssetIdentifiers'); + }); + + it('persists expected state', () => { + const { controller } = setupController(); + + const derivedState = deriveStateFromMetadata( + controller.state, + controller.metadata, + 'persist', + ); + + expect(derivedState).toHaveProperty('enabledNetworkMap'); + expect(derivedState).toHaveProperty('nativeAssetIdentifiers'); + }); + + it('exposes expected state to UI', () => { + const { controller } = setupController(); + + const derivedState = deriveStateFromMetadata( + controller.state, + controller.metadata, + 'usedInUi', + ); + + expect(derivedState).toHaveProperty('enabledNetworkMap'); + expect(derivedState).toHaveProperty('nativeAssetIdentifiers'); + }); + }); + + describe('new onAddNetwork behavior', () => { + it('switches to newly added popular network when NOT in popular networks mode', async () => { + const { controller, rootMessenger } = setupController(); + + // Start with only 1 popular network enabled (not in popular networks mode) + controller.disableNetwork('0xe708'); // Disable Linea + controller.disableNetwork('0x2105'); // Disable Base + // Now only Ethereum is enabled (1 popular network < 3 threshold) + + expect(controller.isNetworkEnabled('0x1')).toBe(true); + expect(controller.isNetworkEnabled('0xe708')).toBe(false); + expect(controller.isNetworkEnabled('0x2105')).toBe(false); + + // Add Avalanche (popular network) when NOT in popular networks mode + rootMessenger.publish('NetworkController:networkAdded', { + chainId: '0xa86a', // Avalanche - popular network + blockExplorerUrls: [], + defaultRpcEndpointIndex: 0, + name: 'Avalanche', + nativeCurrency: 'AVAX', + rpcEndpoints: [ + { + url: 'https://api.avax.network/ext/bc/C/rpc', + networkClientId: 'id', + type: RpcEndpointType.Custom, + }, + ], + }); + + await jestAdvanceTime({ duration: 1 }); + + // Should switch to Avalanche (disable all others, enable Avalanche) + expect(controller.isNetworkEnabled('0xa86a')).toBe(true); + expect(controller.isNetworkEnabled('0x1')).toBe(false); + expect(controller.isNetworkEnabled('0xe708')).toBe(false); + expect(controller.isNetworkEnabled('0x2105')).toBe(false); + }); + + it('switches to newly added non-popular network even when in popular networks mode', async () => { + const { controller, rootMessenger } = setupController(); + + // Default state has 3 popular networks enabled (in popular networks mode) + expect(controller.isNetworkEnabled('0x1')).toBe(true); + expect(controller.isNetworkEnabled('0xe708')).toBe(true); + expect(controller.isNetworkEnabled('0x2105')).toBe(true); + + // Add a non-popular network when in popular networks mode + rootMessenger.publish('NetworkController:networkAdded', { + chainId: '0x999', // Non-popular network + blockExplorerUrls: [], + defaultRpcEndpointIndex: 0, + name: 'Custom Network', + nativeCurrency: 'CUSTOM', + rpcEndpoints: [ + { + url: 'https://custom.network/rpc', + networkClientId: 'id', + type: RpcEndpointType.Custom, + }, + ], + }); + + await jestAdvanceTime({ duration: 1 }); + + // Should switch to the non-popular network (disable all others, enable new one) + expect(controller.isNetworkEnabled('0x999')).toBe(true); + expect(controller.isNetworkEnabled('0x1')).toBe(false); + expect(controller.isNetworkEnabled('0xe708')).toBe(false); + expect(controller.isNetworkEnabled('0x2105')).toBe(false); + }); + + it('keeps current selection when adding popular network in popular networks mode', async () => { + const { controller, rootMessenger } = setupController(); + + // Default state has 3 popular networks enabled (in popular networks mode) + expect(controller.isNetworkEnabled('0x1')).toBe(true); + expect(controller.isNetworkEnabled('0xe708')).toBe(true); + expect(controller.isNetworkEnabled('0x2105')).toBe(true); + + // Add another popular network when in popular networks mode + rootMessenger.publish('NetworkController:networkAdded', { + chainId: '0x89', // Polygon - popular network + blockExplorerUrls: [], + defaultRpcEndpointIndex: 0, + name: 'Polygon', + nativeCurrency: 'MATIC', + rpcEndpoints: [ + { + url: 'https://polygon-mainnet.infura.io/v3/1234567890', + networkClientId: 'id', + type: RpcEndpointType.Custom, + }, + ], + }); + + await jestAdvanceTime({ duration: 1 }); + + // Should keep current selection (add Polygon but don't enable it) + expect(controller.isNetworkEnabled('0x89')).toBe(true); // Polygon enabled + expect(controller.isNetworkEnabled('0x1')).toBe(true); // Ethereum still enabled + expect(controller.isNetworkEnabled('0xe708')).toBe(true); // Linea still enabled + expect(controller.isNetworkEnabled('0x2105')).toBe(true); // Base still enabled + }); + + it('handles edge case: exactly 2 popular networks enabled (not in popular mode)', async () => { + const { controller, rootMessenger } = setupController(); + + // Start with exactly 2 popular networks enabled (not >2, so not in popular mode) + controller.disableNetwork('0x2105'); // Disable Base, keep only Ethereum and Linea + expect(controller.isNetworkEnabled('0x1')).toBe(true); + expect(controller.isNetworkEnabled('0xe708')).toBe(true); + expect(controller.isNetworkEnabled('0x2105')).toBe(false); + + // Add another popular network when NOT in popular networks mode (exactly 2 enabled) + rootMessenger.publish('NetworkController:networkAdded', { + chainId: '0xa86a', // Avalanche - popular network + blockExplorerUrls: [], + defaultRpcEndpointIndex: 0, + name: 'Avalanche', + nativeCurrency: 'AVAX', + rpcEndpoints: [ + { + url: 'https://api.avax.network/ext/bc/C/rpc', + networkClientId: 'id', + type: RpcEndpointType.Custom, + }, + ], + }); + + 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); + expect(controller.isNetworkEnabled('0x1')).toBe(true); + expect(controller.isNetworkEnabled('0xe708')).toBe(true); + expect(controller.isNetworkEnabled('0x2105')).toBe(false); + }); + }); +}); diff --git a/packages/network-enablement-controller/src/NetworkEnablementController.ts b/packages/network-enablement-controller/src/NetworkEnablementController.ts new file mode 100644 index 00000000000..26d7340c32a --- /dev/null +++ b/packages/network-enablement-controller/src/NetworkEnablementController.ts @@ -0,0 +1,892 @@ +import { BaseController } from '@metamask/base-controller'; +import type { + ControllerGetStateAction, + ControllerStateChangeEvent, +} from '@metamask/base-controller'; +import type { ConfigRegistryControllerGetStateAction } from '@metamask/config-registry-controller'; +import { BuiltInNetworkName, ChainId, toHex } from '@metamask/controller-utils'; +import { BtcScope, SolScope, TrxScope, XlmScope } from '@metamask/keyring-api'; +import type { Messenger } from '@metamask/messenger'; +import type { MultichainNetworkControllerGetStateAction } from '@metamask/multichain-network-controller'; +import { toEvmCaipChainId } from '@metamask/multichain-network-controller'; +import type { + NetworkControllerGetStateAction, + NetworkControllerNetworkAddedEvent, + NetworkControllerNetworkRemovedEvent, + NetworkControllerStateChangeEvent, +} from '@metamask/network-controller'; +import type { TransactionControllerTransactionSubmittedEvent } from '@metamask/transaction-controller'; +import type { CaipChainId, CaipNamespace, Hex } from '@metamask/utils'; +import { KnownCaipNamespace, parseCaipChainId } from '@metamask/utils'; + +import { POPULAR_NETWORKS } from './constants.js'; +import type { NetworkEnablementControllerMethodActions } from './NetworkEnablementController-method-action-types.js'; +import { Slip44Service } from './services/index.js'; +import { deriveKeys, isOnlyNetworkEnabledInNamespace } from './utils.js'; + +const controllerName = 'NetworkEnablementController'; + +const MESSENGER_EXPOSED_METHODS = [ + 'init', + 'initNativeAssetIdentifiers', + 'enableNetwork', + 'disableNetwork', + 'enableNetworkInNamespace', + 'enableAllPopularNetworks', + 'isNetworkEnabled', + 'listPopularNetworks', + 'listPopularEvmNetworks', + 'listPopularMultichainNetworks', + 'restoreEnabledNetworkMap', +] as const; + +/** + * Information about an ordered network. + */ +export type NetworksInfo = { + /** + * The network's chain id + */ + networkId: CaipChainId; +}; + +/** + * A map of enabled networks by CAIP namespace and chain ID. + * For EIP-155 networks, the keys are Hex chain IDs. + * For other networks, the keys are CAIP chain IDs. + */ +type EnabledMap = Record>; + +/** + * A native asset identifier in CAIP-19-like format. + * Format: `{caip2ChainId}/slip44:{coinType}` + * + * @example + * - `eip155:1/slip44:60` for Ethereum mainnet (ETH) + * - `solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/slip44:501` for Solana mainnet (SOL) + * - `bip122:000000000019d6689c085ae165831e93/slip44:0` for Bitcoin mainnet (BTC) + */ +export type NativeAssetIdentifier = `${CaipChainId}/slip44:${number}`; + +/** + * A map of CAIP-2 chain IDs to their native asset identifiers. + * Uses CAIP-19-like format to identify the native asset for each chain. + * + * @see https://github.com/satoshilabs/slips/blob/master/slip-0044.md + */ +export type NativeAssetIdentifiersMap = Record< + CaipChainId, + NativeAssetIdentifier +>; + +// State shape for NetworkEnablementController +export type NetworkEnablementControllerState = { + enabledNetworkMap: EnabledMap; + nativeAssetIdentifiers: NativeAssetIdentifiersMap; +}; + +export type NetworkEnablementControllerGetStateAction = + ControllerGetStateAction< + typeof controllerName, + NetworkEnablementControllerState + >; +/** + * All actions that {@link NetworkEnablementController} calls internally. + */ +export type AllowedActions = + | NetworkControllerGetStateAction + | MultichainNetworkControllerGetStateAction + | ConfigRegistryControllerGetStateAction; + +export type NetworkEnablementControllerActions = + | NetworkEnablementControllerGetStateAction + | NetworkEnablementControllerMethodActions; + +export type NetworkEnablementControllerStateChangeEvent = + ControllerStateChangeEvent< + typeof controllerName, + NetworkEnablementControllerState + >; + +export type NetworkEnablementControllerEvents = + NetworkEnablementControllerStateChangeEvent; + +/** + * All events that {@link NetworkEnablementController} subscribes to internally. + */ +export type AllowedEvents = + | NetworkControllerNetworkAddedEvent + | NetworkControllerNetworkRemovedEvent + | NetworkControllerStateChangeEvent + | TransactionControllerTransactionSubmittedEvent; + +export type NetworkEnablementControllerMessenger = Messenger< + typeof controllerName, + NetworkEnablementControllerActions | AllowedActions, + NetworkEnablementControllerEvents | AllowedEvents +>; + +/** + * Builds a native asset identifier in CAIP-19-like format. + * + * @param caipChainId - The CAIP-2 chain ID (e.g., 'eip155:1', 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp') + * @param slip44CoinType - The SLIP-44 coin type number + * @returns The native asset identifier string (e.g., 'eip155:1/slip44:60') + */ +function buildNativeAssetIdentifier( + caipChainId: CaipChainId, + slip44CoinType: number, +): NativeAssetIdentifier { + return `${caipChainId}/slip44:${slip44CoinType}`; +} + +/** + * Network configuration with chain ID and native currency symbol. + * Used to initialize native asset identifiers. + */ +export type NetworkConfig = { + chainId: CaipChainId; + nativeCurrency: string; +}; + +/** + * Gets the default state for the NetworkEnablementController. + * + * @returns The default state with pre-enabled networks. + */ +const getDefaultNetworkEnablementControllerState = + (): NetworkEnablementControllerState => ({ + enabledNetworkMap: { + [KnownCaipNamespace.Eip155]: { + [ChainId[BuiltInNetworkName.Mainnet]]: true, + [ChainId[BuiltInNetworkName.LineaMainnet]]: true, + [ChainId[BuiltInNetworkName.BaseMainnet]]: true, + [ChainId[BuiltInNetworkName.ArbitrumOne]]: true, + [ChainId[BuiltInNetworkName.BscMainnet]]: true, + [ChainId[BuiltInNetworkName.OptimismMainnet]]: true, + [ChainId[BuiltInNetworkName.PolygonMainnet]]: true, + [ChainId[BuiltInNetworkName.SeiMainnet]]: true, + [ChainId[BuiltInNetworkName.MonadMainnet]]: true, + }, + [KnownCaipNamespace.Solana]: { + [SolScope.Mainnet]: true, + [SolScope.Testnet]: false, + [SolScope.Devnet]: false, + }, + [KnownCaipNamespace.Bip122]: { + [BtcScope.Mainnet]: true, + [BtcScope.Testnet]: false, + [BtcScope.Signet]: false, + }, + [KnownCaipNamespace.Tron]: { + [TrxScope.Mainnet]: true, + [TrxScope.Nile]: false, + [TrxScope.Shasta]: false, + }, + [KnownCaipNamespace.Stellar]: { + [XlmScope.Pubnet]: true, + [XlmScope.Testnet]: false, + }, + }, + // nativeAssetIdentifiers is initialized as empty and should be populated + // by the client using initNativeAssetIdentifiers() during controller init + nativeAssetIdentifiers: {}, + }); + +// Metadata for the controller state +const metadata = { + enabledNetworkMap: { + includeInStateLogs: true, + persist: true, + includeInDebugSnapshot: true, + usedInUi: true, + }, + nativeAssetIdentifiers: { + includeInStateLogs: true, + persist: true, + includeInDebugSnapshot: true, + usedInUi: true, + }, +}; + +/** + * Controller responsible for managing network enablement state across different blockchain networks. + * + * This controller tracks which networks are enabled/disabled for the user and provides methods + * to toggle network states. It supports both EVM (EIP-155) and non-EVM networks like Solana. + * + * The controller maintains a map of enabled networks organized by namespace (e.g., 'eip155', 'solana') + * and provides methods to query and modify network enablement states. + */ +export class NetworkEnablementController extends BaseController< + typeof controllerName, + NetworkEnablementControllerState, + NetworkEnablementControllerMessenger +> { + /** + * Creates a NetworkEnablementController 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. + */ + constructor({ + messenger, + state, + }: { + messenger: NetworkEnablementControllerMessenger; + state?: Partial; + }) { + super({ + messenger, + metadata, + name: controllerName, + state: { + ...getDefaultNetworkEnablementControllerState(), + ...state, + }, + }); + + messenger.registerMethodActionHandlers(this, MESSENGER_EXPOSED_METHODS); + + messenger.subscribe('NetworkController:networkAdded', ({ chainId }) => { + // eslint-disable-next-line no-void + void this.#onAddNetwork(chainId); + }); + + messenger.subscribe('NetworkController:networkRemoved', ({ chainId }) => { + this.#removeNetworkEntry(chainId); + }); + } + + /** + * Enables or disables a network for the user. + * + * This method accepts either a Hex chain ID (for EVM networks) or a CAIP-2 chain ID + * (for any blockchain network). The method will automatically convert Hex chain IDs + * to CAIP-2 format internally. This dual parameter support allows for backward + * compatibility with existing EVM chain ID formats while supporting newer + * multi-chain standards. + * + * When enabling a non-popular network, this method will disable all other networks + * to ensure only one network is active at a time (exclusive mode). + * + * @param chainId - The chain ID of the network to enable or disable. Can be either: + * - A Hex string (e.g., '0x1' for Ethereum mainnet) for EVM networks + * - A CAIP-2 chain ID (e.g., 'eip155:1' for Ethereum mainnet, 'solana:mainnet' for Solana) + */ + enableNetwork(chainId: Hex | CaipChainId): void { + const { namespace, storageKey } = deriveKeys(chainId); + + this.update((state) => { + // disable all networks in all namespaces first + Object.keys(state.enabledNetworkMap).forEach((ns) => { + Object.keys(state.enabledNetworkMap[ns]).forEach((key) => { + state.enabledNetworkMap[ns][key as CaipChainId | Hex] = false; + }); + }); + + // if the namespace bucket does not exist, return + // new nemespace are added only when a new network is added + if (!state.enabledNetworkMap[namespace]) { + return; + } + + // enable the network + state.enabledNetworkMap[namespace][storageKey] = true; + }); + } + + /** + * Enables a network for the user within a specific namespace. + * + * This method accepts either a Hex chain ID (for EVM networks) or a CAIP-2 chain ID + * (for any blockchain network) and enables it within the specified namespace. + * The method validates that the chainId belongs to the specified namespace for safety. + * + * Before enabling the target network, this method disables all other networks + * in the same namespace to ensure exclusive behavior within the namespace. + * + * @param chainId - The chain ID of the network to enable. Can be either: + * - A Hex string (e.g., '0x1' for Ethereum mainnet) for EVM networks + * - A CAIP-2 chain ID (e.g., 'eip155:1' for Ethereum mainnet, 'solana:mainnet' for Solana) + * @param namespace - The CAIP namespace where the network should be enabled + * @throws Error if the chainId's derived namespace doesn't match the provided namespace + */ + enableNetworkInNamespace( + chainId: Hex | CaipChainId, + namespace: CaipNamespace, + ): void { + const { namespace: derivedNamespace, storageKey } = deriveKeys(chainId); + + // Validate that the derived namespace matches the provided namespace + if (derivedNamespace !== namespace) { + throw new Error( + `Chain ID ${chainId} belongs to namespace ${derivedNamespace}, but namespace ${namespace} was specified`, + ); + } + + this.update((state) => { + // Ensure the namespace bucket exists + this.#ensureNamespaceBucket(state, namespace); + + // Disable all networks in the specified namespace first + if (state.enabledNetworkMap[namespace]) { + Object.keys(state.enabledNetworkMap[namespace]).forEach((key) => { + state.enabledNetworkMap[namespace][key as CaipChainId | Hex] = false; + }); + } + + // Enable the target network in the specified namespace + state.enabledNetworkMap[namespace][storageKey] = true; + }); + } + + /** + * Enables all popular networks and Solana mainnet. + * + * This method first disables all networks across all namespaces, then enables + * all networks defined in POPULAR_NETWORKS (EVM networks), Solana mainnet, and + * Bitcoin mainnet. This provides exclusive behavior - only popular networks will + * be enabled after calling this method. + * + * Popular networks that don't exist in NetworkController or MultichainNetworkController configurations will be skipped silently. + */ + enableAllPopularNetworks(): void { + this.update((state) => { + // First disable all networks across all namespaces + Object.keys(state.enabledNetworkMap).forEach((ns) => { + Object.keys(state.enabledNetworkMap[ns]).forEach((key) => { + state.enabledNetworkMap[ns][key as CaipChainId | Hex] = false; + }); + }); + + // Get current network configurations to check if networks exist + const networkControllerState = this.messenger.call( + 'NetworkController:getState', + ); + const multichainState = this.messenger.call( + 'MultichainNetworkController:getState', + ); + + // Enable all popular EVM networks that exist in NetworkController configurations + this.#getPopularEvmChainIds().forEach((chainId) => { + const { namespace, storageKey } = deriveKeys(chainId); + + // Check if network exists in NetworkController configurations + if (networkControllerState.networkConfigurationsByChainId[chainId]) { + // Ensure namespace bucket exists + this.#ensureNamespaceBucket(state, namespace); + // Enable the network + state.enabledNetworkMap[namespace][storageKey] = true; + } + }); + + // Enable Solana mainnet if it exists in MultichainNetworkController configurations + const solanaKeys = deriveKeys(SolScope.Mainnet as CaipChainId); + if ( + multichainState.multichainNetworkConfigurationsByChainId[ + SolScope.Mainnet + ] + ) { + // Ensure namespace bucket exists + this.#ensureNamespaceBucket(state, solanaKeys.namespace); + // Enable Solana mainnet + state.enabledNetworkMap[solanaKeys.namespace][solanaKeys.storageKey] = + true; + } + + // Enable Bitcoin mainnet if it exists in MultichainNetworkController configurations + const bitcoinKeys = deriveKeys(BtcScope.Mainnet as CaipChainId); + if ( + multichainState.multichainNetworkConfigurationsByChainId[ + BtcScope.Mainnet + ] + ) { + // Ensure namespace bucket exists + this.#ensureNamespaceBucket(state, bitcoinKeys.namespace); + // Enable Bitcoin mainnet + state.enabledNetworkMap[bitcoinKeys.namespace][bitcoinKeys.storageKey] = + true; + } + + // Enable Tron mainnet if it exists in MultichainNetworkController configurations + const tronKeys = deriveKeys(TrxScope.Mainnet as CaipChainId); + if ( + multichainState.multichainNetworkConfigurationsByChainId[ + TrxScope.Mainnet + ] + ) { + // Ensure namespace bucket exists + this.#ensureNamespaceBucket(state, tronKeys.namespace); + // Enable Tron mainnet + state.enabledNetworkMap[tronKeys.namespace][tronKeys.storageKey] = true; + } + + // Enable Stellar mainnet if it exists in MultichainNetworkController configurations + const stellarKeys = deriveKeys(XlmScope.Pubnet as CaipChainId); + if ( + multichainState.multichainNetworkConfigurationsByChainId[ + XlmScope.Pubnet + ] + ) { + this.#ensureNamespaceBucket(state, stellarKeys.namespace); + state.enabledNetworkMap[stellarKeys.namespace][stellarKeys.storageKey] = + true; + } + }); + } + + /** + * Initializes the network enablement state from network controller configurations. + * + * This method reads the current network configurations from both NetworkController + * and MultichainNetworkController and syncs the enabled network map and nativeAssetIdentifiers accordingly. + * It ensures proper namespace buckets exist for all configured networks and only + * adds missing networks with a default value of false, preserving existing user settings. + * + * This method should be called after the NetworkController and MultichainNetworkController + * have been initialized and their configurations are available. + */ + async init(): Promise { + // Get network configurations from NetworkController (EVM networks) + const networkControllerState = this.messenger.call( + 'NetworkController:getState', + ); + + // Get network configurations from MultichainNetworkController (all networks) + const multichainState = this.messenger.call( + 'MultichainNetworkController:getState', + ); + + // Build nativeAssetIdentifiers for EVM networks using chainid.network + const evmNativeAssetUpdates: { + caipChainId: CaipChainId; + identifier: NativeAssetIdentifier; + }[] = []; + + for (const [chainId] of Object.entries( + networkControllerState.networkConfigurationsByChainId, + )) { + const { caipChainId } = deriveKeys(chainId as Hex); + + // Skip if already in state + if (this.state.nativeAssetIdentifiers[caipChainId] !== undefined) { + continue; + } + + // Parse hex chainId to number for chainid.network lookup + const numericChainId = parseInt(chainId, 16); + + // EVM networks: use getEvmSlip44 (chainid.network data) + const slip44CoinType = await Slip44Service.getEvmSlip44(numericChainId); + + evmNativeAssetUpdates.push({ + caipChainId, + identifier: buildNativeAssetIdentifier(caipChainId, slip44CoinType), + }); + } + + // Update state synchronously + this.update((state) => { + // Initialize namespace buckets for EVM networks from NetworkController + Object.entries( + networkControllerState.networkConfigurationsByChainId, + ).forEach(([chainId]) => { + const { namespace, storageKey } = deriveKeys(chainId as Hex); + this.#ensureNamespaceBucket(state, namespace); + + // Only add network if it doesn't already exist in state (preserves user settings) + state.enabledNetworkMap[namespace][storageKey] ??= false; + }); + + // Apply nativeAssetIdentifier updates + for (const { caipChainId, identifier } of evmNativeAssetUpdates) { + state.nativeAssetIdentifiers[caipChainId] = identifier; + } + + // Initialize namespace buckets for all networks from MultichainNetworkController + Object.keys( + multichainState.multichainNetworkConfigurationsByChainId, + ).forEach((chainId) => { + const { namespace, storageKey } = deriveKeys(chainId as CaipChainId); + this.#ensureNamespaceBucket(state, namespace); + + // Only add network if it doesn't already exist in state (preserves user settings) + state.enabledNetworkMap[namespace][storageKey] ??= false; + }); + }); + } + + /** + * Initializes the native asset identifiers from network configurations. + * This method should be called from the client during controller initialization + * to populate the nativeAssetIdentifiers state based on actual network configurations. + * + * @param networks - Array of network configurations with chainId and nativeCurrency + * @example + * ```typescript + * const evmNetworks = Object.values(networkControllerState.networkConfigurationsByChainId) + * .map(config => ({ + * chainId: toEvmCaipChainId(config.chainId), + * nativeCurrency: config.nativeCurrency, + * })); + * + * const multichainNetworks = Object.values(multichainState.multichainNetworkConfigurationsByChainId) + * .map(config => ({ + * chainId: config.chainId, + * nativeCurrency: config.nativeCurrency, + * })); + * + * await controller.initNativeAssetIdentifiers([...evmNetworks, ...multichainNetworks]); + * ``` + */ + async initNativeAssetIdentifiers(networks: NetworkConfig[]): Promise { + // Process networks and collect updates + const updates: { + chainId: CaipChainId; + identifier: NativeAssetIdentifier; + }[] = []; + + for (const { chainId, nativeCurrency } of networks) { + // Check if nativeCurrency is already in CAIP-19 format (e.g., "bip122:.../slip44:0") + // Non-EVM networks from MultichainNetworkController use this format + if (nativeCurrency.includes('/slip44:')) { + updates.push({ + chainId, + identifier: nativeCurrency as NativeAssetIdentifier, + }); + continue; + } + + // Extract namespace from CAIP-2 chainId + const [namespace, reference] = chainId.split(':'); + let slip44CoinType: number | undefined; + + if (namespace === 'eip155') { + // EVM networks: use getEvmSlip44 (chainid.network data) + const numericChainId = parseInt(reference, 10); + slip44CoinType = await Slip44Service.getEvmSlip44(numericChainId); + } else { + // Non-EVM networks: use getSlip44BySymbol (@metamask/slip44 package) + slip44CoinType = Slip44Service.getSlip44BySymbol(nativeCurrency); + } + + if (slip44CoinType !== undefined) { + updates.push({ + chainId, + identifier: buildNativeAssetIdentifier(chainId, slip44CoinType), + }); + } + } + + // Apply all updates synchronously + this.update((state) => { + for (const { chainId, identifier } of updates) { + state.nativeAssetIdentifiers[chainId] = identifier; + } + }); + } + + /** + * Disables a network for the user. + * + * This method accepts either a Hex chain ID (for EVM networks) or a CAIP-2 chain ID + * (for any blockchain network). The method will automatically convert Hex chain IDs + * to CAIP-2 format internally. + * + * Note: This method will prevent disabling the last remaining enabled network + * to ensure at least one network is always available. + * + * @param chainId - The chain ID of the network to disable. Can be either: + * - A Hex string (e.g., '0x1' for Ethereum mainnet) for EVM networks + * - A CAIP-2 chain ID (e.g., 'eip155:1' for Ethereum mainnet, 'solana:mainnet' for Solana) + */ + disableNetwork(chainId: Hex | CaipChainId): void { + const derivedKeys = deriveKeys(chainId); + const { namespace, storageKey } = derivedKeys; + + this.update((state) => { + state.enabledNetworkMap[namespace][storageKey] = false; + }); + } + + /** + * Restores the enabled network map to a previously snapshotted state. + * + * Not a general merge API: only updates keys already present in the current + * map. Missing snapshot values default to `false`. Intended for callers with + * direct controller access (e.g. extension) to undo `#onAddNetwork` filter + * switches when adding a network without changing the active selection. + * + * @param enabledNetworkMap - Previously snapshotted enabledNetworkMap. + */ + restoreEnabledNetworkMap( + enabledNetworkMap: NetworkEnablementControllerState['enabledNetworkMap'], + ): void { + this.update((state) => { + Object.entries(state.enabledNetworkMap).forEach( + ([namespace, currentNetworks]) => { + Object.keys(currentNetworks).forEach((chainId) => { + const storageKey = chainId as CaipChainId | Hex; + const previousValue = enabledNetworkMap[namespace]?.[storageKey]; + state.enabledNetworkMap[namespace][storageKey] = + previousValue ?? false; + }); + }, + ); + }); + } + + /** + * Checks if a network is enabled. + * + * @param chainId - The chain ID of the network to check. Can be either: + * - A Hex string (e.g., '0x1' for Ethereum mainnet) for EVM networks + * - A CAIP-2 chain ID (e.g., 'eip155:1' for Ethereum mainnet, 'solana:mainnet' for Solana) + * @returns True if the network is enabled, false otherwise + */ + isNetworkEnabled(chainId: Hex | CaipChainId): boolean { + const derivedKeys = deriveKeys(chainId); + const { namespace, storageKey } = derivedKeys; + return this.state.enabledNetworkMap[namespace]?.[storageKey] ?? false; + } + + /** + * Ensures that a namespace bucket exists in the state. + * + * This method creates the namespace entry in the enabledNetworkMap if it doesn't + * already exist. This is used to prepare the state structure before adding + * network entries. + * + * @param state - The current controller state + * @param ns - The CAIP namespace to ensure exists + */ + #ensureNamespaceBucket( + state: NetworkEnablementControllerState, + ns: CaipNamespace, + ): void { + if (!state.enabledNetworkMap[ns]) { + state.enabledNetworkMap[ns] = {}; + } + } + + /** + * Checks if popular networks mode is active (more than 2 popular networks enabled). + * + * This method counts how many networks defined in POPULAR_NETWORKS are currently + * enabled in the state and returns true if more than 2 are enabled. It only checks + * networks that actually exist in the NetworkController configurations. + * + * @returns True if more than 2 popular networks are enabled, false otherwise + */ + #isInPopularNetworksMode(): boolean { + // Get current network configurations to check which popular networks exist + const networkControllerState = this.messenger.call( + 'NetworkController:getState', + ); + + // Count how many popular networks are enabled + const enabledPopularNetworksCount = this.#getPopularEvmChainIds().reduce( + (count, chainId) => { + // Only check networks that actually exist in NetworkController configurations + if (!networkControllerState.networkConfigurationsByChainId[chainId]) { + return count; // Skip networks that don't exist + } + + const { namespace, storageKey } = deriveKeys(chainId); + const isEnabled = this.state.enabledNetworkMap[namespace]?.[storageKey]; + return isEnabled ? count + 1 : count; + }, + 0, + ); + + // Return true if more than 2 popular networks are enabled + return enabledPopularNetworksCount > 1; + } + + /** + * Removes a network entry from the state. + * + * This method is called when a network is removed from the system. It cleans up + * the network entry from both enabledNetworkMap and nativeAssetIdentifiers, and ensures that + * at least one network remains enabled. + * + * @param chainId - The chain ID to remove (Hex or CAIP-2 format) + */ + #removeNetworkEntry(chainId: Hex | CaipChainId): void { + const derivedKeys = deriveKeys(chainId); + const { namespace, storageKey, caipChainId } = derivedKeys; + + this.update((state) => { + // fallback and enable ethereum mainnet + if (isOnlyNetworkEnabledInNamespace(this.state, derivedKeys)) { + state.enabledNetworkMap[namespace][ + ChainId[BuiltInNetworkName.Mainnet] + ] = true; + } + + if (namespace in state.enabledNetworkMap) { + delete state.enabledNetworkMap[namespace][storageKey]; + } + + // Remove from nativeAssetIdentifiers as well + delete state.nativeAssetIdentifiers[caipChainId]; + }); + } + + /** + * Handles the addition of a new EVM network to the controller. + * + * @param chainId - The chain ID to add (Hex format) + * + * @description + * - If in popular networks mode (>2 popular networks enabled) AND adding a popular network: + * - Keep current selection (add but don't enable the new network) + * - Otherwise: + * - Switch to the newly added network (disable all others, enable this one) + * - Also updates the nativeAssetIdentifiers with the CAIP-19-like identifier + */ + async #onAddNetwork(chainId: Hex): Promise { + const { namespace, storageKey, reference, caipChainId } = + deriveKeys(chainId); + + // Parse reference (decimal string from CAIP-2) to number for chainid.network lookup + const numericChainId = parseInt(reference, 10); + + // EVM networks: use getEvmSlip44 (chainid.network data) + const slip44CoinType = await Slip44Service.getEvmSlip44(numericChainId); + + this.update((state) => { + // Ensure the namespace bucket exists + this.#ensureNamespaceBucket(state, namespace); + + // Check if popular networks mode is active (>2 popular networks enabled) + const inPopularNetworksMode = this.#isInPopularNetworksMode(); + + // Check if the network being added is a popular network + const isAddedNetworkPopular = + this.#getPopularEvmChainIds().includes(chainId); + + // Keep current selection only if in popular networks mode AND adding a popular network + const shouldKeepCurrentSelection = + inPopularNetworksMode && isAddedNetworkPopular; + + if (shouldKeepCurrentSelection) { + // Add the popular network but don't enable it (keep current selection) + state.enabledNetworkMap[namespace][storageKey] = true; + } else { + // Switch to the newly added network (disable all others, enable this one) + Object.keys(state.enabledNetworkMap).forEach((ns) => { + Object.keys(state.enabledNetworkMap[ns]).forEach((key) => { + state.enabledNetworkMap[ns][key as CaipChainId | Hex] = false; + }); + }); + // Enable the newly added network + state.enabledNetworkMap[namespace][storageKey] = true; + } + + // Update nativeAssetIdentifiers with the CAIP-19-like identifier + state.nativeAssetIdentifiers[caipChainId] = buildNativeAssetIdentifier( + caipChainId, + slip44CoinType, + ); + }); + } + + /** + * Returns the hex chain IDs of EVM networks the config registry currently + * marks as "popular" (i.e. featured + active + non-testnet). + * + * @returns Hex chain IDs of registry-featured EVM networks. + */ + #getRegistryPopularEvmChainIds(): Hex[] { + const { + configs: { networks }, + } = this.messenger.call('ConfigRegistryController:getState'); + return Object.values(networks).reduce((popularChains, network) => { + if ( + network.config.isFeatured && + network.config.isActive && + !network.config.isTestnet && + network.chainId.startsWith('eip155:') + ) { + const hexChainId = toHex(parseCaipChainId(network.chainId).reference); + popularChains.push(hexChainId); + } + return popularChains; + }, []); + } + + /** + * Returns the set of popular EVM chain IDs using registry configs as primary source + * and the bundled `POPULAR_NETWORKS` as fallback. This ensures that the list of popular networks is always available, + * even if the registry is unavailable or doesn't have any featured networks. + * + * @returns De-duplicated hex chain IDs considered popular. + */ + #getPopularEvmChainIds(): Hex[] { + return [ + ...new Set([ + // `toHex` is used to normalize chain IDs from the bundled POPULAR_NETWORKS. + ...POPULAR_NETWORKS.map((chainId) => toHex(chainId)), + ...this.#getRegistryPopularEvmChainIds(), + ]), + ]; + } + + /** + * Returns popular EVM network chain IDs in hex form, restricted to networks + * that exist in NetworkController (networkConfigurationsByChainId). Source is + * the bundled `POPULAR_NETWORKS` unioned with registry-featured EVM chains. + * + * @returns Hex chain IDs for popular EVM networks that are configured. + */ + listPopularEvmNetworks(): Hex[] { + const networkControllerState = this.messenger.call( + 'NetworkController:getState', + ); + return this.#getPopularEvmChainIds().filter( + (chainIdHex) => + networkControllerState.networkConfigurationsByChainId[chainIdHex], + ); + } + + /** + * Returns popular multichain (Bitcoin, Solana, Tron, Stellar) mainnet chain IDs in + * CAIP-2 form, restricted to networks that exist in MultichainNetworkController + * (multichainNetworkConfigurationsByChainId). + * + * @returns CAIP-2 chain IDs for Bitcoin, Solana, Tron, and Stellar mainnets that are configured. + */ + listPopularMultichainNetworks(): CaipChainId[] { + const multichainState = this.messenger.call( + 'MultichainNetworkController:getState', + ); + const multichainMainnets = [ + BtcScope.Mainnet, + SolScope.Mainnet, + TrxScope.Mainnet, + XlmScope.Pubnet, + ] as const; + return multichainMainnets.filter( + (chainId) => + multichainState.multichainNetworkConfigurationsByChainId[chainId], + ); + } + + /** + * Returns the list of popular network chain IDs in CAIP-2 form, restricted to + * networks that exist in NetworkController (networkConfigurationsByChainId) and + * MultichainNetworkController (multichainNetworkConfigurationsByChainId). EVM + * popular networks come from POPULAR_NETWORKS; multichain popular are Bitcoin, + * Solana, Tron, and Stellar mainnets. + * + * @returns CAIP-2 chain IDs for popular EVM networks and multichain mainnets that are configured. + */ + listPopularNetworks(): CaipChainId[] { + const evmHex = this.listPopularEvmNetworks(); + const evmCaip = evmHex.map((chainIdHex) => toEvmCaipChainId(chainIdHex)); + return [...evmCaip, ...this.listPopularMultichainNetworks()]; + } +} diff --git a/packages/network-enablement-controller/src/constants.ts b/packages/network-enablement-controller/src/constants.ts new file mode 100644 index 00000000000..b0aac1489e4 --- /dev/null +++ b/packages/network-enablement-controller/src/constants.ts @@ -0,0 +1,20 @@ +import type { Hex } from '@metamask/utils'; + +export const POPULAR_NETWORKS: readonly Hex[] = [ + '0x1', // Ethereum Mainnet + '0xe708', // Linea (59144) + '0x2105', // Base (8453) + '0xa4b1', // Arbitrum One (42161) + '0xa86a', // Avalanche C-Chain (43114) + '0x38', // BNB Smart Chain (56) + '0xa', // Optimism (10) + '0x89', // Polygon (137) + '0x531', // Sei (Assuming 1329 used in EVM context) + '0x144', // zkSync Era (324) + '0x2a15c308d', // Palm (11297108109) + '0x3e7', // HyperEVM (999) + '0x8f', // Monad (143) + '0x10e6', // MegaETH (4326) + '0x13b2', // Arc (5042) + '0x1237', // Robinhood (4663) +]; diff --git a/packages/network-enablement-controller/src/index.ts b/packages/network-enablement-controller/src/index.ts new file mode 100644 index 00000000000..9299edcb900 --- /dev/null +++ b/packages/network-enablement-controller/src/index.ts @@ -0,0 +1,42 @@ +export { NetworkEnablementController } from './NetworkEnablementController.js'; + +export type { + NetworkEnablementControllerState, + NetworkEnablementControllerGetStateAction, + NetworkEnablementControllerActions, + NetworkEnablementControllerEvents, + NetworkEnablementControllerStateChangeEvent, + NetworkEnablementControllerMessenger, + NativeAssetIdentifier, + NativeAssetIdentifiersMap, + NetworkConfig, +} from './NetworkEnablementController.js'; + +export type { + NetworkEnablementControllerEnableNetworkAction, + NetworkEnablementControllerEnableNetworkInNamespaceAction, + NetworkEnablementControllerEnableAllPopularNetworksAction, + NetworkEnablementControllerInitAction, + NetworkEnablementControllerInitNativeAssetIdentifiersAction, + NetworkEnablementControllerDisableNetworkAction, + NetworkEnablementControllerIsNetworkEnabledAction, + NetworkEnablementControllerListPopularEvmNetworksAction, + NetworkEnablementControllerListPopularMultichainNetworksAction, + NetworkEnablementControllerListPopularNetworksAction, +} from './NetworkEnablementController-method-action-types.js'; + +export { + selectEnabledNetworkMap, + selectIsNetworkEnabled, + createSelectorForEnabledNetworksForNamespace, + selectAllEnabledNetworks, + selectEnabledNetworksCount, + selectEnabledEvmNetworks, + selectEnabledSolanaNetworks, +} from './selectors.js'; + +export { + Slip44Service, + getEvmSlip44, + getSlip44BySymbol, +} from './services/index.js'; diff --git a/packages/network-enablement-controller/src/selectors.test.ts b/packages/network-enablement-controller/src/selectors.test.ts new file mode 100644 index 00000000000..ad6cda8fab2 --- /dev/null +++ b/packages/network-enablement-controller/src/selectors.test.ts @@ -0,0 +1,117 @@ +import { KnownCaipNamespace } from '@metamask/utils'; + +import type { NetworkEnablementControllerState } from './NetworkEnablementController.js'; +import { + selectEnabledNetworkMap, + selectIsNetworkEnabled, + createSelectorForEnabledNetworksForNamespace, + selectAllEnabledNetworks, + selectEnabledNetworksCount, + selectEnabledEvmNetworks, + selectEnabledSolanaNetworks, +} from './selectors.js'; + +describe('NetworkEnablementController Selectors', () => { + const mockState: NetworkEnablementControllerState = { + enabledNetworkMap: { + [KnownCaipNamespace.Eip155]: { + '0x1': true, // Ethereum mainnet + '0xa': false, // Optimism (disabled) + '0xa4b1': true, // Arbitrum One + }, + [KnownCaipNamespace.Solana]: { + 'solana:mainnet': true, + 'solana:testnet': false, + }, + }, + nativeAssetIdentifiers: {}, + }; + + describe('selectEnabledNetworkMap', () => { + it('returns the enabled network map', () => { + const result = selectEnabledNetworkMap(mockState); + expect(result).toBe(mockState.enabledNetworkMap); + }); + }); + + describe('selectIsNetworkEnabled', () => { + it('returns true for enabled EVM network with hex chain ID', () => { + const selector = selectIsNetworkEnabled('0x1'); + const result = selector(mockState); + expect(result).toBe(true); + }); + + it('returns true for enabled EVM network with CAIP chain ID', () => { + const selector = selectIsNetworkEnabled('eip155:1'); + const result = selector(mockState); + expect(result).toBe(true); + }); + + it('returns true for enabled Solana network', () => { + const selector = selectIsNetworkEnabled('solana:mainnet'); + const result = selector(mockState); + expect(result).toBe(true); + }); + + it('returns false for unknown network', () => { + const selector = selectIsNetworkEnabled('0x999'); + const result = selector(mockState); + expect(result).toBe(false); + }); + }); + + describe('createSelectorForEnabledNetworksForNamespace', () => { + it('returns enabled EVM networks', () => { + const selector = createSelectorForEnabledNetworksForNamespace( + KnownCaipNamespace.Eip155, + ); + const result = selector(mockState); + expect(result).toStrictEqual(['0x1', '0xa4b1']); + }); + + it('returns enabled Solana networks', () => { + const selector = createSelectorForEnabledNetworksForNamespace( + KnownCaipNamespace.Solana, + ); + const result = selector(mockState); + expect(result).toStrictEqual(['solana:mainnet']); + }); + + it('returns empty array for unknown namespace', () => { + const selector = createSelectorForEnabledNetworksForNamespace('unknown'); + const result = selector(mockState); + expect(result).toStrictEqual([]); + }); + }); + + describe('selectAllEnabledNetworks', () => { + it('returns all enabled networks across namespaces', () => { + const result = selectAllEnabledNetworks(mockState); + expect(result).toStrictEqual({ + [KnownCaipNamespace.Eip155]: ['0x1', '0xa4b1'], + [KnownCaipNamespace.Solana]: ['solana:mainnet'], + }); + }); + }); + + describe('selectEnabledNetworksCount', () => { + it('returns the total count of enabled networks', () => { + const result = selectEnabledNetworksCount(mockState); + expect(result).toBe(3); // 2 EVM + 1 Solana + }); + }); + + describe('selectEnabledEvmNetworks', () => { + it('returns enabled EVM networks', () => { + const result = selectEnabledEvmNetworks(mockState); + expect(result).toStrictEqual(['0x1', '0xa4b1']); + }); + }); + + describe('selectEnabledSolanaNetworks', () => { + it('returns enabled Solana networks', () => { + const result = selectEnabledSolanaNetworks(mockState); + expect(result).toStrictEqual(['solana:mainnet']); + }); + }); +}); diff --git a/packages/network-enablement-controller/src/selectors.ts b/packages/network-enablement-controller/src/selectors.ts new file mode 100644 index 00000000000..553940e6d5e --- /dev/null +++ b/packages/network-enablement-controller/src/selectors.ts @@ -0,0 +1,113 @@ +import type { CaipChainId, CaipNamespace, Hex } from '@metamask/utils'; +import { KnownCaipNamespace } from '@metamask/utils'; +import { createSelector } from 'reselect'; + +import type { NetworkEnablementControllerState } from './NetworkEnablementController.js'; +import { deriveKeys } from './utils.js'; + +/** + * Base selector to get the enabled network map from the controller state. + * + * @param state - The NetworkEnablementController state + * @returns The enabled network map + */ +export const selectEnabledNetworkMap = ( + state: NetworkEnablementControllerState, +) => state.enabledNetworkMap; + +/** + * Selector to check if a specific network is enabled. + * + * This selector accepts either a Hex chain ID (for EVM networks) or a CAIP-2 chain ID + * (for any blockchain network) and returns whether the network is currently enabled. + * It returns false for unknown networks or if there's an error parsing the chain ID. + * + * @param chainId - The chain ID to check (Hex or CAIP-2 format) + * @returns A selector function that returns true if the network is enabled, false otherwise + */ +export const selectIsNetworkEnabled = (chainId: Hex | CaipChainId) => + createSelector(selectEnabledNetworkMap, (enabledNetworkMap) => { + const { namespace, storageKey } = deriveKeys(chainId); + + return ( + namespace in enabledNetworkMap && + storageKey in enabledNetworkMap[namespace] && + enabledNetworkMap[namespace][storageKey] + ); + }); + +/** + * Selector builder to get all enabled networks for a specific namespace. + * + * The selector returned by this function returns an array of chain IDs (as strings) for all enabled networks + * within the specified namespace (e.g., 'eip155' for EVM networks, 'solana' for Solana). + * + * @param namespace - The CAIP namespace to get enabled networks for (e.g., 'eip155', 'solana') + * @returns A selector function that returns an array of chain ID strings for enabled networks in the namespace + */ +export const createSelectorForEnabledNetworksForNamespace = ( + namespace: CaipNamespace, +) => + createSelector(selectEnabledNetworkMap, (enabledNetworkMap) => { + return Object.entries(enabledNetworkMap[namespace] ?? {}) + .filter(([, enabled]) => enabled) + .map(([id]) => id); + }); + +/** + * Selector to get all enabled networks across all namespaces. + * + * This selector returns a record where keys are CAIP namespaces and values are arrays + * of enabled chain IDs within each namespace. + * + * @returns A selector function that returns a record mapping namespace to array of enabled chain IDs + */ +export const selectAllEnabledNetworks = createSelector( + selectEnabledNetworkMap, + (enabledNetworkMap) => { + return Object.keys(enabledNetworkMap).reduce< + Record + >((acc, ns) => { + acc[ns] = Object.entries(enabledNetworkMap[ns]) + .filter(([, enabled]) => enabled) + .map(([id]) => id); + return acc; + }, {}); + }, +); + +/** + * Selector to get the total count of enabled networks across all namespaces. + * + * @returns A selector function that returns the total number of enabled networks + */ +export const selectEnabledNetworksCount = createSelector( + selectAllEnabledNetworks, + (allEnabledNetworks) => { + return Object.values(allEnabledNetworks).flat().length; + }, +); + +/** + * Selector to get all enabled EVM networks. + * + * This is a convenience selector that specifically targets EIP-155 networks. + * + * @returns A selector function that returns an array of enabled EVM chain IDs + */ +export const selectEnabledEvmNetworks = createSelector( + createSelectorForEnabledNetworksForNamespace(KnownCaipNamespace.Eip155), + (enabledEvmNetworks) => enabledEvmNetworks, +); + +/** + * Selector to get all enabled Solana networks. + * + * This is a convenience selector that specifically targets Solana networks. + * + * @returns A selector function that returns an array of enabled Solana chain IDs + */ +export const selectEnabledSolanaNetworks = createSelector( + createSelectorForEnabledNetworksForNamespace(KnownCaipNamespace.Solana), + (enabledSolanaNetworks) => enabledSolanaNetworks, +); diff --git a/packages/network-enablement-controller/src/services/Slip44Service.test.ts b/packages/network-enablement-controller/src/services/Slip44Service.test.ts new file mode 100644 index 00000000000..0f60a7afe8d --- /dev/null +++ b/packages/network-enablement-controller/src/services/Slip44Service.test.ts @@ -0,0 +1,279 @@ +import { fetchWithErrorHandling } from '@metamask/controller-utils'; + +import { Slip44Service } from './Slip44Service.js'; + +jest.mock('@metamask/controller-utils', () => ({ + fetchWithErrorHandling: jest.fn(), +})); + +const mockFetchWithErrorHandling = + fetchWithErrorHandling as jest.MockedFunction; + +describe('Slip44Service', () => { + beforeEach(() => { + // Clear cache before each test to ensure clean state + Slip44Service.clearCache(); + jest.clearAllMocks(); + }); + + describe('getSlip44BySymbol', () => { + it('returns 60 for ETH symbol', () => { + const result = Slip44Service.getSlip44BySymbol('ETH'); + expect(result).toBe(60); + }); + + it('returns 0 for BTC symbol', () => { + const result = Slip44Service.getSlip44BySymbol('BTC'); + expect(result).toBe(0); + }); + + it('returns 501 for SOL symbol', () => { + const result = Slip44Service.getSlip44BySymbol('SOL'); + expect(result).toBe(501); + }); + + it('returns 195 for TRX symbol', () => { + const result = Slip44Service.getSlip44BySymbol('TRX'); + expect(result).toBe(195); + }); + + it('returns 2 for LTC symbol', () => { + const result = Slip44Service.getSlip44BySymbol('LTC'); + expect(result).toBe(2); + }); + + it('returns 3 for DOGE symbol', () => { + const result = Slip44Service.getSlip44BySymbol('DOGE'); + expect(result).toBe(3); + }); + + it('returns undefined for unknown symbol', () => { + const result = Slip44Service.getSlip44BySymbol('UNKNOWNCOIN'); + expect(result).toBeUndefined(); + }); + + it('is case-insensitive for symbols', () => { + const lowerResult = Slip44Service.getSlip44BySymbol('eth'); + const upperResult = Slip44Service.getSlip44BySymbol('ETH'); + const mixedResult = Slip44Service.getSlip44BySymbol('Eth'); + + expect(lowerResult).toBe(60); + expect(upperResult).toBe(60); + expect(mixedResult).toBe(60); + }); + + it('caches the result for repeated lookups', () => { + // First lookup + const firstResult = Slip44Service.getSlip44BySymbol('ETH'); + // Second lookup (should come from cache) + const secondResult = Slip44Service.getSlip44BySymbol('ETH'); + + expect(firstResult).toBe(60); + expect(secondResult).toBe(60); + }); + + it('caches undefined for unknown symbols', () => { + // First lookup + const firstResult = Slip44Service.getSlip44BySymbol('UNKNOWNCOIN'); + // Second lookup (should come from cache) + const secondResult = Slip44Service.getSlip44BySymbol('UNKNOWNCOIN'); + + expect(firstResult).toBeUndefined(); + expect(secondResult).toBeUndefined(); + }); + + it('returns coin type 1 for empty string (Testnet)', () => { + // The SLIP-44 data has an entry with empty symbol for "Testnet (all coins)" at index 1 + const result = Slip44Service.getSlip44BySymbol(''); + expect(result).toBe(1); + }); + }); + + describe('clearCache', () => { + it('clears the cache so lookups are performed again', () => { + // Perform initial lookup to populate cache + Slip44Service.getSlip44BySymbol('ETH'); + + // Clear the cache + Slip44Service.clearCache(); + + // Perform another lookup - should work correctly + const result = Slip44Service.getSlip44BySymbol('ETH'); + expect(result).toBe(60); + }); + + it('clears cached undefined values', () => { + // Perform initial lookup for unknown symbol + Slip44Service.getSlip44BySymbol('UNKNOWNCOIN'); + + // Clear the cache + Slip44Service.clearCache(); + + // Verify cache is cleared (no error thrown) + const result = Slip44Service.getSlip44BySymbol('UNKNOWNCOIN'); + expect(result).toBeUndefined(); + }); + }); + + describe('real-world network symbols', () => { + it('correctly maps common EVM network native currencies', () => { + // All EVM networks use ETH or similar tokens with coin type 60 + expect(Slip44Service.getSlip44BySymbol('ETH')).toBe(60); + }); + + it('correctly maps Polygon MATIC symbol', () => { + const result = Slip44Service.getSlip44BySymbol('MATIC'); + // MATIC has coin type 966 + expect(result).toBe(966); + }); + + it('correctly maps BNB symbol', () => { + const result = Slip44Service.getSlip44BySymbol('BNB'); + // BNB has coin type 714 + expect(result).toBe(714); + }); + }); + + describe('getEvmSlip44', () => { + it('returns slip44 from chainid.network data when available', async () => { + // Mock chainid.network response with Ethereum data + mockFetchWithErrorHandling.mockResolvedValueOnce([ + { chainId: 1, slip44: 60 }, + { chainId: 56, slip44: 714 }, + ]); + + const result = await Slip44Service.getEvmSlip44(1); + + expect(result).toBe(60); + expect(mockFetchWithErrorHandling).toHaveBeenCalledWith({ + url: 'https://chainid.network/chains.json', + timeout: 10000, + }); + }); + + it('returns cached value on subsequent calls without re-fetching', async () => { + // Mock chainid.network response + mockFetchWithErrorHandling.mockResolvedValueOnce([ + { chainId: 1, slip44: 60 }, + { chainId: 56, slip44: 714 }, + ]); + + // First call - fetches data + const result1 = await Slip44Service.getEvmSlip44(1); + // Second call - should use cache (line 144) + const result2 = await Slip44Service.getEvmSlip44(56); + + expect(result1).toBe(60); + expect(result2).toBe(714); + // Should only fetch once + expect(mockFetchWithErrorHandling).toHaveBeenCalledTimes(1); + }); + + it('handles concurrent calls by reusing the fetch promise (line 82)', async () => { + // Mock chainid.network response with a delay + mockFetchWithErrorHandling.mockImplementation( + () => + new Promise((resolve) => { + setTimeout(() => { + resolve([{ chainId: 1, slip44: 60 }]); + }, 10); + }), + ); + + // Make concurrent calls + const [result1, result2, result3] = await Promise.all([ + Slip44Service.getEvmSlip44(1), + Slip44Service.getEvmSlip44(1), + Slip44Service.getEvmSlip44(1), + ]); + + expect(result1).toBe(60); + expect(result2).toBe(60); + expect(result3).toBe(60); + // Should only fetch once despite concurrent calls + expect(mockFetchWithErrorHandling).toHaveBeenCalledTimes(1); + }); + + it('defaults to 60 when chainId not found in cache', async () => { + // Mock chainid.network response without the requested chainId + mockFetchWithErrorHandling.mockResolvedValueOnce([ + { chainId: 1, slip44: 60 }, + ]); + + // Request a chainId not in the response + const result = await Slip44Service.getEvmSlip44(12345); + + expect(result).toBe(60); // Defaults to 60 (Ethereum) + }); + + it('handles invalid response by initializing empty cache and defaults to 60', async () => { + // Mock invalid response (not an array) + mockFetchWithErrorHandling.mockResolvedValueOnce('invalid response'); + + // Should not throw, defaults to 60 + const result = await Slip44Service.getEvmSlip44(1); + + expect(result).toBe(60); + }); + + it('handles null response by initializing empty cache and defaults to 60', async () => { + // Mock null response + mockFetchWithErrorHandling.mockResolvedValueOnce(null); + + // Should not throw, defaults to 60 + const result = await Slip44Service.getEvmSlip44(1); + + expect(result).toBe(60); + }); + + it('handles network error by initializing empty cache and defaults to 60', async () => { + // Mock network error + mockFetchWithErrorHandling.mockRejectedValueOnce( + new Error('Network error'), + ); + + // Should not throw, defaults to 60 + const result = await Slip44Service.getEvmSlip44(1); + + 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([ + { chainId: 1, slip44: 60 }, + { chainId: 2 }, // No slip44 field + { chainId: 3, slip44: undefined }, // Explicit undefined + { chainId: 56, slip44: 714 }, + ]); + + const result1 = await Slip44Service.getEvmSlip44(1); + const result2 = await Slip44Service.getEvmSlip44(2); + const result3 = await Slip44Service.getEvmSlip44(3); + const result56 = await Slip44Service.getEvmSlip44(56); + + expect(result1).toBe(60); + expect(result2).toBe(60); // Not in cache, defaults to 60 + expect(result3).toBe(60); // Not in cache, defaults to 60 + expect(result56).toBe(714); + }); + }); +}); diff --git a/packages/network-enablement-controller/src/services/Slip44Service.ts b/packages/network-enablement-controller/src/services/Slip44Service.ts new file mode 100644 index 00000000000..affebea52c7 --- /dev/null +++ b/packages/network-enablement-controller/src/services/Slip44Service.ts @@ -0,0 +1,223 @@ +import { fetchWithErrorHandling } from '@metamask/controller-utils'; +// @ts-expect-error: No type definitions for '@metamask/slip44' +import slip44 from '@metamask/slip44'; + +const CHAINID_NETWORK_URL = 'https://chainid.network/chains.json'; + +/** + * Represents a single SLIP-44 entry with its metadata. + */ +export type Slip44Entry = { + index: string; + symbol: string; + name: string; +}; + +/** + * Chain data from chainid.network + */ +type ChainIdNetworkEntry = { + chainId: number; + slip44?: number; + nativeCurrency?: { + symbol: string; + }; +}; + +/** + * Internal type for SLIP-44 data from the @metamask/slip44 package. + * Includes the hex field which we don't expose externally. + */ +type Slip44DataEntry = Slip44Entry & { + // eslint-disable-next-line id-denylist + hex: `0x${string}`; +}; + +/** + * The SLIP-44 mapping type from the @metamask/slip44 package. + */ +type Slip44Data = Record; + +/** + * Service for looking up SLIP-44 coin type identifiers. + * + * SLIP-44 defines registered coin types used in BIP-44 derivation paths. + * + * This service provides two lookup methods: + * 1. `getEvmSlip44` - For EVM networks, uses chainid.network data (recommended for eip155) + * 2. `getSlip44BySymbol` - Fallback method using @metamask/slip44 package + * + * @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. + */ + static #chainIdCache: Map | null = null; + + /** + * Whether a fetch is currently in progress. + */ + static #fetchPromise: Promise | null = null; + + /** + * Cache for symbol to slip44 index lookups. + * This avoids iterating through all entries on repeated lookups. + */ + static readonly #symbolCache: Map = new Map(); + + /** + * Fetches and caches chain data from chainid.network. + * This is called automatically by getEvmSlip44. + */ + static async #fetchChainData(): Promise { + if (this.#chainIdCache !== null) { + return; + } + + // Avoid duplicate fetches + if (this.#fetchPromise) { + await this.#fetchPromise; + return; + } + + this.#fetchPromise = (async (): Promise => { + try { + const chains: ChainIdNetworkEntry[] | undefined = + await fetchWithErrorHandling({ + url: CHAINID_NETWORK_URL, + timeout: 10000, + }); + + if (chains && Array.isArray(chains)) { + this.#chainIdCache = new Map( + chains + .filter( + (chain): chain is ChainIdNetworkEntry & { slip44: number } => + chain.slip44 !== undefined, + ) + .map((chain) => [chain.chainId, chain.slip44]), + ); + } else { + // Invalid response, initialize empty cache + this.#chainIdCache = new Map(); + } + } catch { + // Network failed, initialize empty cache so we fall back to symbol lookup + this.#chainIdCache = new Map(); + } + })(); + + await this.#fetchPromise; + this.#fetchPromise = null; + } + + /** + * Gets the SLIP-44 coin type identifier for an EVM network by chain ID. + * + * **IMPORTANT: This method is for EVM networks only (eip155 namespace).** + * For non-EVM networks (Bitcoin, Solana, Tron, etc.), use `getSlip44BySymbol`. + * + * This method checks chainid.network data (which maps chainId directly + * to slip44). If not found, defaults to 60 (Ethereum). + * + * @param chainId - The EVM chain ID as a number (e.g., 1 for Ethereum, 56 for BNB Chain) + * @returns The SLIP-44 coin type number (defaults to 60 if not found) + * @example + * ```typescript + * // For EVM networks only + * const ethCoinType = await Slip44Service.getEvmSlip44(1); + * // Returns 60 + * + * const bnbCoinType = await Slip44Service.getEvmSlip44(56); + * // Returns 714 + * + * const unknownEvmChain = await Slip44Service.getEvmSlip44(99999); + * // Returns 60 (default for EVM) + * ``` + */ + 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(); + + // Check chainId cache first + const cached = this.#chainIdCache?.get(chainId); + if (cached !== undefined) { + return cached; + } + + // Default to 60 (Ethereum) for EVM networks without specific mapping + return 60; + } + + /** + * Gets the SLIP-44 coin type identifier for a non-EVM network by symbol. + * + * **IMPORTANT: This method is for non-EVM networks only (Bitcoin, Solana, Tron, etc.).** + * For EVM networks (eip155 namespace), use `getEvmSlip44` instead. + * + * Note: Symbol lookup may return incorrect results for duplicate symbols + * (e.g., CPC is both CPChain and Capricoin). + * + * @param symbol - The network symbol (e.g., 'ETH', 'BTC', 'SOL') + * @returns The SLIP-44 coin type number, or undefined if not found + * @example + * ```typescript + * const ethCoinType = Slip44Service.getSlip44BySymbol('ETH'); + * // Returns 60 + * + * const btcCoinType = Slip44Service.getSlip44BySymbol('BTC'); + * // Returns 0 + * ``` + */ + static getSlip44BySymbol(symbol: string): number | undefined { + // Check cache first + if (this.#symbolCache.has(symbol)) { + return this.#symbolCache.get(symbol); + } + + const slip44Data = slip44 as Slip44Data; + const upperSymbol = symbol.toUpperCase(); + + // Iterate through all entries to find matching symbol + // Note: Object.keys returns numeric keys in ascending order, + // so for duplicate symbols we get the lowest coin type first + // (which is the convention for resolving duplicates) + for (const key of Object.keys(slip44Data)) { + const entry = slip44Data[key]; + if (entry.symbol.toUpperCase() === upperSymbol) { + const coinType = parseInt(key, 10); + this.#symbolCache.set(symbol, coinType); + return coinType; + } + } + + // Cache the miss as well to avoid repeated lookups + this.#symbolCache.set(symbol, undefined); + return undefined; + } + + /** + * Clears all internal caches. + * Useful for testing or if the underlying data might change. + */ + static clearCache(): void { + this.#symbolCache.clear(); + this.#chainIdCache = null; + this.#fetchPromise = null; + } +} diff --git a/packages/network-enablement-controller/src/services/index.ts b/packages/network-enablement-controller/src/services/index.ts new file mode 100644 index 00000000000..72bc55b9a5e --- /dev/null +++ b/packages/network-enablement-controller/src/services/index.ts @@ -0,0 +1,10 @@ +import { Slip44Service } from './Slip44Service.js'; + +export { Slip44Service }; + +// Re-export static methods as standalone functions for convenience +// getEvmSlip44: For EVM networks (eip155) - uses chainId lookup, defaults to 60 +export const getEvmSlip44 = Slip44Service.getEvmSlip44.bind(Slip44Service); +// getSlip44BySymbol: For non-EVM networks (Bitcoin, Solana, Tron) - uses symbol lookup +export const getSlip44BySymbol = + Slip44Service.getSlip44BySymbol.bind(Slip44Service); diff --git a/packages/network-enablement-controller/src/utils.test.ts b/packages/network-enablement-controller/src/utils.test.ts new file mode 100644 index 00000000000..2cda85549ab --- /dev/null +++ b/packages/network-enablement-controller/src/utils.test.ts @@ -0,0 +1,263 @@ +import { KnownCaipNamespace } from '@metamask/utils'; + +import type { NetworkEnablementControllerState } from './NetworkEnablementController.js'; +import { deriveKeys, isOnlyNetworkEnabledInNamespace } from './utils.js'; + +describe('Utils', () => { + describe('deriveKeys', () => { + describe('EVM networks', () => { + it('derives keys from hex chain ID', () => { + const result = deriveKeys('0x1'); + + expect(result).toStrictEqual({ + namespace: 'eip155', + storageKey: '0x1', + caipChainId: 'eip155:1', + reference: '1', + }); + }); + + it('derives keys from CAIP chain ID with decimal reference', () => { + const result = deriveKeys('eip155:1'); + + expect(result).toStrictEqual({ + namespace: 'eip155', + storageKey: '0x1', + caipChainId: 'eip155:1', + reference: '1', + }); + }); + + it('derives keys from CAIP chain ID with large decimal reference', () => { + const result = deriveKeys('eip155:42161'); + + expect(result).toStrictEqual({ + namespace: 'eip155', + storageKey: '0xa4b1', + caipChainId: 'eip155:42161', + reference: '42161', + }); + }); + }); + + describe('non-EVM networks', () => { + it('derives keys from Solana CAIP chain ID', () => { + const result = deriveKeys('solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp'); + + expect(result).toStrictEqual({ + namespace: 'solana', + storageKey: 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp', + caipChainId: 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp', + reference: '5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp', + }); + }); + + it('derives keys from Bitcoin CAIP chain ID', () => { + const result = deriveKeys('bip122:000000000019d6689c085ae165831e93'); + + expect(result).toStrictEqual({ + namespace: 'bip122', + storageKey: 'bip122:000000000019d6689c085ae165831e93', + caipChainId: 'bip122:000000000019d6689c085ae165831e93', + reference: '000000000019d6689c085ae165831e93', + }); + }); + }); + }); + + describe('isOnlyNetworkEnabledInNamespace', () => { + const createMockState = ( + enabledNetworkMap: NetworkEnablementControllerState['enabledNetworkMap'], + ): NetworkEnablementControllerState => ({ + enabledNetworkMap, + nativeAssetIdentifiers: {}, + }); + + describe('EVM namespace scenarios', () => { + it('returns true when network is the only enabled EVM network (hex chain ID)', () => { + const state = createMockState({ + [KnownCaipNamespace.Eip155]: { + '0x1': true, + '0xa': false, + '0xa4b1': false, + }, + }); + + const derivedKeys = deriveKeys('0x1'); + const result = isOnlyNetworkEnabledInNamespace(state, derivedKeys); + + expect(result).toBe(true); + }); + + it('returns true when network is the only enabled EVM network (CAIP chain ID)', () => { + const state = createMockState({ + [KnownCaipNamespace.Eip155]: { + '0x1': true, + '0xa': false, + '0xa4b1': false, + }, + }); + + const derivedKeys = deriveKeys('eip155:1'); + const result = isOnlyNetworkEnabledInNamespace(state, derivedKeys); + + expect(result).toBe(true); + }); + + it('returns false when there are multiple enabled EVM networks', () => { + const state = createMockState({ + [KnownCaipNamespace.Eip155]: { + '0x1': true, + '0xa': true, + '0xa4b1': false, + }, + }); + + const derivedKeys = deriveKeys('0x1'); + const result = isOnlyNetworkEnabledInNamespace(state, derivedKeys); + + expect(result).toBe(false); + }); + + it('returns false when no EVM networks are enabled', () => { + const state = createMockState({ + [KnownCaipNamespace.Eip155]: { + '0x1': false, + '0xa': false, + '0xa4b1': false, + }, + }); + + const derivedKeys = deriveKeys('0x1'); + const result = isOnlyNetworkEnabledInNamespace(state, derivedKeys); + + expect(result).toBe(false); + }); + + it('returns false when target network is not the only enabled one', () => { + const state = createMockState({ + [KnownCaipNamespace.Eip155]: { + '0x1': false, + '0xa': true, + '0xa4b1': false, + }, + }); + + const derivedKeys = deriveKeys('0x1'); + const result = isOnlyNetworkEnabledInNamespace(state, derivedKeys); + + expect(result).toBe(false); + }); + }); + + describe('Solana namespace scenarios', () => { + it('returns true when network is the only enabled Solana network', () => { + const state = createMockState({ + [KnownCaipNamespace.Solana]: { + 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp': true, + 'solana:4uhcVJyU9pJkvQyS88uRDiswHXSCkY3z': false, + }, + }); + + const derivedKeys = deriveKeys( + 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp', + ); + const result = isOnlyNetworkEnabledInNamespace(state, derivedKeys); + + expect(result).toBe(true); + }); + + it('returns false when there are multiple enabled Solana networks', () => { + const state = createMockState({ + [KnownCaipNamespace.Solana]: { + 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp': true, + 'solana:4uhcVJyU9pJkvQyS88uRDiswHXSCkY3z': true, + }, + }); + + const derivedKeys = deriveKeys( + 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp', + ); + const result = isOnlyNetworkEnabledInNamespace(state, derivedKeys); + + expect(result).toBe(false); + }); + + it('returns false when no Solana networks are enabled', () => { + const state = createMockState({ + [KnownCaipNamespace.Solana]: { + 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp': false, + 'solana:4uhcVJyU9pJkvQyS88uRDiswHXSCkY3z': false, + }, + }); + + const derivedKeys = deriveKeys( + 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp', + ); + const result = isOnlyNetworkEnabledInNamespace(state, derivedKeys); + + expect(result).toBe(false); + }); + + it('returns false when target network is not the only enabled one', () => { + const state = createMockState({ + [KnownCaipNamespace.Solana]: { + 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp': false, + 'solana:4uhcVJyU9pJkvQyS88uRDiswHXSCkY3z': true, + }, + }); + + const derivedKeys = deriveKeys( + 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp', + ); + const result = isOnlyNetworkEnabledInNamespace(state, derivedKeys); + + expect(result).toBe(false); + }); + }); + + describe('Non-existent namespace scenarios', () => { + it('returns false when namespace does not exist', () => { + const state = createMockState({}); + + const derivedKeys = deriveKeys('0x1'); + const result = isOnlyNetworkEnabledInNamespace(state, derivedKeys); + + expect(result).toBe(false); + }); + + it('returns false when namespace exists but is empty', () => { + const state = createMockState({ + [KnownCaipNamespace.Eip155]: {}, + }); + + const derivedKeys = deriveKeys('0x1'); + const result = isOnlyNetworkEnabledInNamespace(state, derivedKeys); + + expect(result).toBe(false); + }); + }); + + describe('Cross-format compatibility', () => { + it('should return consistent results for hex and CAIP formats of the same network', () => { + const state = createMockState({ + [KnownCaipNamespace.Eip155]: { + '0x1': true, + '0xa': false, + '0xa4b1': false, + }, + }); + + const hexKeys = deriveKeys('0x1'); + const hexResult = isOnlyNetworkEnabledInNamespace(state, hexKeys); + + const caipKeys = deriveKeys('eip155:1'); + const caipResult = isOnlyNetworkEnabledInNamespace(state, caipKeys); + + expect(hexResult).toBe(true); + expect(caipResult).toBe(true); + expect(hexResult).toBe(caipResult); + }); + }); + }); +}); diff --git a/packages/network-enablement-controller/src/utils.ts b/packages/network-enablement-controller/src/utils.ts new file mode 100644 index 00000000000..a8b8f8cce2d --- /dev/null +++ b/packages/network-enablement-controller/src/utils.ts @@ -0,0 +1,86 @@ +import { toHex } from '@metamask/controller-utils'; +import { toEvmCaipChainId } from '@metamask/multichain-network-controller'; +import type { CaipChainId, CaipNamespace, Hex } from '@metamask/utils'; +import { + isCaipChainId, + isHexString, + KnownCaipNamespace, + parseCaipChainId, +} from '@metamask/utils'; + +import type { NetworkEnablementControllerState } from './NetworkEnablementController.js'; + +/** + * Represents the parsed keys derived from a chain ID. + */ +export type DerivedKeys = { + namespace: CaipNamespace; + storageKey: Hex | CaipChainId; + caipChainId: CaipChainId; + reference: string; +}; + +/** + * Derives the namespace, storage key, and CAIP chain ID from a given chain ID. + * + * This utility function handles the conversion between different chain ID formats. + * For EVM networks, it converts Hex chain IDs to CAIP-2 format and determines + * the appropriate storage key. For non-EVM networks, it parses the CAIP-2 chain ID + * and uses the full chain ID as the storage key. + * + * @param chainId - The chain ID to derive keys from (Hex or CAIP-2 format) + * @returns An object containing namespace, storageKey, and caipId + * @throws Error if the chain ID cannot be parsed + */ +export function deriveKeys(chainId: Hex | CaipChainId): DerivedKeys { + const caipChainId = isCaipChainId(chainId) + ? chainId + : toEvmCaipChainId(chainId); + + const { namespace, reference } = parseCaipChainId(caipChainId); + let storageKey; + if (namespace === (KnownCaipNamespace.Eip155 as string)) { + storageKey = isHexString(chainId) ? chainId : toHex(reference); + } else { + storageKey = caipChainId; + } + return { namespace, storageKey, caipChainId, reference }; +} + +/** + * Checks if the specified network is the only enabled network in its namespace. + * + * This function is used to prevent unnecessary state updates when trying to enable + * This method is used to prevent the last network in a namespace from being removed. + * + * @param state - The current controller state + * @param derivedKeys - The parsed keys object containing namespace and storageKey + * @returns True if the network is the only enabled network in the namespace, false otherwise + */ +export function isOnlyNetworkEnabledInNamespace( + state: NetworkEnablementControllerState, + derivedKeys: DerivedKeys, +): boolean { + const { namespace, storageKey } = derivedKeys; + + // Early return if namespace doesn't exist + if (!state.enabledNetworkMap[namespace]) { + return false; + } + + const networks = state.enabledNetworkMap[namespace]; + + // Get all enabled networks in this namespace + const enabledNetworks = Object.entries(networks).filter( + ([_, enabled]) => enabled, + ); + + // Check if there's exactly one enabled network and it matches our target + if (enabledNetworks.length === 1) { + const [onlyEnabledKey] = enabledNetworks[0]; + return onlyEnabledKey === storageKey; + } + + // Return false if there are zero or multiple enabled networks + return false; +} diff --git a/packages/network-enablement-controller/tsconfig.build.json b/packages/network-enablement-controller/tsconfig.build.json new file mode 100644 index 00000000000..0a086401e35 --- /dev/null +++ b/packages/network-enablement-controller/tsconfig.build.json @@ -0,0 +1,18 @@ +{ + "extends": "../../tsconfig.packages.build.json", + "compilerOptions": { + "baseUrl": "./", + "outDir": "./dist", + "rootDir": "./src" + }, + "references": [ + { "path": "../base-controller/tsconfig.build.json" }, + { "path": "../config-registry-controller/tsconfig.build.json" }, + { "path": "../network-controller/tsconfig.build.json" }, + { "path": "../multichain-network-controller/tsconfig.build.json" }, + { "path": "../controller-utils/tsconfig.build.json" }, + { "path": "../transaction-controller/tsconfig.build.json" }, + { "path": "../messenger/tsconfig.build.json" } + ], + "include": ["../../types", "./src"] +} diff --git a/packages/network-enablement-controller/tsconfig.json b/packages/network-enablement-controller/tsconfig.json new file mode 100644 index 00000000000..c2fded4422a --- /dev/null +++ b/packages/network-enablement-controller/tsconfig.json @@ -0,0 +1,17 @@ +{ + "extends": "../../tsconfig.packages.json", + "compilerOptions": { + "baseUrl": "./", + "rootDir": "../.." + }, + "references": [ + { "path": "../base-controller" }, + { "path": "../config-registry-controller" }, + { "path": "../network-controller" }, + { "path": "../multichain-network-controller" }, + { "path": "../controller-utils" }, + { "path": "../transaction-controller" }, + { "path": "../messenger" } + ], + "include": ["../../types", "./src"] +} diff --git a/packages/network-enablement-controller/typedoc.json b/packages/network-enablement-controller/typedoc.json new file mode 100644 index 00000000000..c9da015dbf8 --- /dev/null +++ b/packages/network-enablement-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/notification-controller/CHANGELOG.md b/packages/notification-controller/CHANGELOG.md deleted file mode 100644 index 74466e9b94e..00000000000 --- a/packages/notification-controller/CHANGELOG.md +++ /dev/null @@ -1,62 +0,0 @@ -# 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] - -## [3.1.3] -### Changed -- Bump dependency on `@metamask/utils` to ^8.1.0 ([#1639](https://github.com/MetaMask/core/pull/1639)) -- Bump dependency on `@metamask/base-controller` to ^3.2.3 - -## [3.1.2] -### Changed -- Update TypeScript to v4.8.x ([#1718](https://github.com/MetaMask/core/pull/1718)) - -## [3.1.1] -### Changed -- Bump dependency on `@metamask/base-controller` to ^3.2.1 - -## [3.1.0] -### Changed -- Update `@metamask/utils` to `^6.2.0` ([#1514](https://github.com/MetaMask/core/pull/1514)) - -## [3.0.0] -### Changed -- **BREAKING:** Bump to Node 16 ([#1262](https://github.com/MetaMask/core/pull/1262)) -- Add `@metamask/utils` dependency ([#1275](https://github.com/MetaMask/core/pull/1275)) - -## [2.0.0] -### Removed -- **BREAKING:** Remove `isomorphic-fetch` ([#1106](https://github.com/MetaMask/controllers/pull/1106)) - - Consumers must now import `isomorphic-fetch` or another polyfill themselves if they are running in an environment without `fetch` - -## [1.0.2] -### Changed -- Rename this repository to `core` ([#1031](https://github.com/MetaMask/controllers/pull/1031)) -- Update `@metamask/controller-utils` package ([#1041](https://github.com/MetaMask/controllers/pull/1041)) - -## [1.0.1] -### Changed -- Relax dependencies on `@metamask/base-controller` and `@metamask/controller-utils` (use `^` instead of `~`) ([#998](https://github.com/MetaMask/core/pull/998)) - -## [1.0.0] -### Added -- Initial release - - As a result of converting our shared controllers repo into a monorepo ([#831](https://github.com/MetaMask/core/pull/831)), we've created this package from select parts of [`@metamask/controllers` v33.0.0](https://github.com/MetaMask/core/tree/v33.0.0), namely: - - Everything in `src/notification` - - All changes listed after this point were applied to this package following the monorepo conversion. - -[Unreleased]: https://github.com/MetaMask/core/compare/@metamask/notification-controller@3.1.3...HEAD -[3.1.3]: https://github.com/MetaMask/core/compare/@metamask/notification-controller@3.1.2...@metamask/notification-controller@3.1.3 -[3.1.2]: https://github.com/MetaMask/core/compare/@metamask/notification-controller@3.1.1...@metamask/notification-controller@3.1.2 -[3.1.1]: https://github.com/MetaMask/core/compare/@metamask/notification-controller@3.1.0...@metamask/notification-controller@3.1.1 -[3.1.0]: https://github.com/MetaMask/core/compare/@metamask/notification-controller@3.0.0...@metamask/notification-controller@3.1.0 -[3.0.0]: https://github.com/MetaMask/core/compare/@metamask/notification-controller@2.0.0...@metamask/notification-controller@3.0.0 -[2.0.0]: https://github.com/MetaMask/core/compare/@metamask/notification-controller@1.0.2...@metamask/notification-controller@2.0.0 -[1.0.2]: https://github.com/MetaMask/core/compare/@metamask/notification-controller@1.0.1...@metamask/notification-controller@1.0.2 -[1.0.1]: https://github.com/MetaMask/core/compare/@metamask/notification-controller@1.0.0...@metamask/notification-controller@1.0.1 -[1.0.0]: https://github.com/MetaMask/core/releases/tag/@metamask/notification-controller@1.0.0 diff --git a/packages/notification-controller/LICENSE b/packages/notification-controller/LICENSE deleted file mode 100644 index ddfbecf9020..00000000000 --- a/packages/notification-controller/LICENSE +++ /dev/null @@ -1,20 +0,0 @@ -MIT License - -Copyright (c) 2018 MetaMask - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE diff --git a/packages/notification-controller/README.md b/packages/notification-controller/README.md deleted file mode 100644 index fd07ff253b6..00000000000 --- a/packages/notification-controller/README.md +++ /dev/null @@ -1,15 +0,0 @@ -# `@metamask/notification-controller` - -Manages display of notifications within MetaMask. - -## Installation - -`yarn add @metamask/notification-controller` - -or - -`npm install @metamask/notification-controller` - -## Contributing - -This package is part of a monorepo. Instructions for contributing can be found in the [monorepo README](https://github.com/MetaMask/core#readme). diff --git a/packages/notification-controller/package.json b/packages/notification-controller/package.json deleted file mode 100644 index bd5c0e47d93..00000000000 --- a/packages/notification-controller/package.json +++ /dev/null @@ -1,54 +0,0 @@ -{ - "name": "@metamask/notification-controller", - "version": "3.1.3", - "description": "Manages display of notifications within MetaMask", - "keywords": [ - "MetaMask", - "Ethereum" - ], - "homepage": "https://github.com/MetaMask/core/tree/main/packages/notification-controller#readme", - "bugs": { - "url": "https://github.com/MetaMask/core/issues" - }, - "repository": { - "type": "git", - "url": "https://github.com/MetaMask/core.git" - }, - "license": "MIT", - "main": "./dist/index.js", - "types": "./dist/index.d.ts", - "files": [ - "dist/" - ], - "scripts": { - "build:docs": "typedoc", - "changelog:validate": "../../scripts/validate-changelog.sh @metamask/notification-controller", - "publish:preview": "yarn npm publish --tag preview", - "test": "jest", - "test:clean": "jest --clearCache", - "test:watch": "jest --watch" - }, - "dependencies": { - "@metamask/base-controller": "^3.2.3", - "@metamask/utils": "^8.1.0", - "immer": "^9.0.6", - "nanoid": "^3.1.31" - }, - "devDependencies": { - "@metamask/auto-changelog": "^3.1.0", - "@types/jest": "^27.4.1", - "deepmerge": "^4.2.2", - "jest": "^27.5.1", - "ts-jest": "^27.1.4", - "typedoc": "^0.24.8", - "typedoc-plugin-missing-exports": "^2.0.0", - "typescript": "~4.8.4" - }, - "engines": { - "node": ">=16.0.0" - }, - "publishConfig": { - "access": "public", - "registry": "https://registry.npmjs.org/" - } -} diff --git a/packages/notification-controller/src/NotificationController.test.ts b/packages/notification-controller/src/NotificationController.test.ts deleted file mode 100644 index 25c9872f802..00000000000 --- a/packages/notification-controller/src/NotificationController.test.ts +++ /dev/null @@ -1,134 +0,0 @@ -import { ControllerMessenger } from '@metamask/base-controller'; - -import type { - NotificationControllerActions, - NotificationControllerStateChange, -} from './NotificationController'; -import { NotificationController } from './NotificationController'; - -const name = 'NotificationController'; - -/** - * Constructs a unrestricted controller messenger. - * - * @returns A unrestricted controller messenger. - */ -function getUnrestrictedMessenger() { - return new ControllerMessenger< - NotificationControllerActions, - NotificationControllerStateChange - >(); -} - -/** - * Constructs a restricted controller messenger. - * - * @param controllerMessenger - An optional unrestricted messenger - * @returns A restricted controller messenger. - */ -function getRestrictedMessenger( - controllerMessenger = getUnrestrictedMessenger(), -) { - return controllerMessenger.getRestricted({ - name, - }); -} - -const origin = 'snap_test'; -const message = 'foo'; - -describe('NotificationController', () => { - it('action: NotificationController:show', async () => { - const unrestricted = getUnrestrictedMessenger(); - const messenger = getRestrictedMessenger(unrestricted); - - const controller = new NotificationController({ - messenger, - }); - - expect( - await unrestricted.call('NotificationController:show', origin, message), - ).toBeUndefined(); - const notifications = Object.values(controller.state.notifications); - expect(notifications).toHaveLength(1); - expect(notifications).toContainEqual({ - createdDate: expect.any(Number), - id: expect.any(String), - message, - origin, - readDate: null, - }); - }); - - it('action: NotificationController:markViewed', async () => { - const unrestricted = getUnrestrictedMessenger(); - const messenger = getRestrictedMessenger(unrestricted); - - const controller = new NotificationController({ - messenger, - }); - - expect( - await unrestricted.call('NotificationController:show', origin, message), - ).toBeUndefined(); - const notifications = Object.values(controller.state.notifications); - expect(notifications).toHaveLength(1); - expect( - await unrestricted.call('NotificationController:markRead', [ - notifications[0].id, - 'foo', - ]), - ).toBeUndefined(); - - const newNotifications = Object.values(controller.state.notifications); - expect(newNotifications).toContainEqual({ - ...notifications[0], - readDate: expect.any(Number), - }); - - expect(newNotifications).toHaveLength(1); - }); - - it('action: NotificationController:dismiss', async () => { - const unrestricted = getUnrestrictedMessenger(); - const messenger = getRestrictedMessenger(unrestricted); - - const controller = new NotificationController({ - messenger, - }); - - expect( - await unrestricted.call('NotificationController:show', origin, message), - ).toBeUndefined(); - const notifications = Object.values(controller.state.notifications); - expect(notifications).toHaveLength(1); - expect( - await unrestricted.call('NotificationController:dismiss', [ - notifications[0].id, - 'foo', - ]), - ).toBeUndefined(); - - expect(Object.values(controller.state.notifications)).toHaveLength(0); - }); - - it('action: NotificationController:clear', async () => { - const unrestricted = getUnrestrictedMessenger(); - const messenger = getRestrictedMessenger(unrestricted); - - const controller = new NotificationController({ - messenger, - }); - - expect( - await unrestricted.call('NotificationController:show', origin, message), - ).toBeUndefined(); - const notifications = Object.values(controller.state.notifications); - expect(notifications).toHaveLength(1); - expect( - await unrestricted.call('NotificationController:clear'), - ).toBeUndefined(); - - expect(Object.values(controller.state.notifications)).toHaveLength(0); - }); -}); diff --git a/packages/notification-controller/src/NotificationController.ts b/packages/notification-controller/src/NotificationController.ts deleted file mode 100644 index db63fb998aa..00000000000 --- a/packages/notification-controller/src/NotificationController.ts +++ /dev/null @@ -1,194 +0,0 @@ -import type { RestrictedControllerMessenger } from '@metamask/base-controller'; -import { BaseControllerV2 } from '@metamask/base-controller'; -import { hasProperty } from '@metamask/utils'; -import type { Patch } from 'immer'; -import { nanoid } from 'nanoid'; - -/** - * @typedef NotificationControllerState - * @property notifications - Stores existing notifications to be shown in the UI - */ -export type NotificationControllerState = { - notifications: Record; -}; - -/** - * @typedef Notification - Stores information about in-app notifications, to be shown in the UI - * @property id - A UUID that identifies the notification - * @property origin - The origin that requested the notification - * @property createdDate - The notification creation date in milliseconds elapsed since the UNIX epoch - * @property readDate - The notification read date in milliseconds elapsed since the UNIX epoch or null if unread - * @property message - The notification message - */ -export type Notification = { - id: string; - origin: string; - createdDate: number; - readDate: number | null; - message: string; -}; - -const name = 'NotificationController'; - -export type NotificationControllerStateChange = { - type: `${typeof name}:stateChange`; - payload: [NotificationControllerState, Patch[]]; -}; - -export type GetNotificationControllerState = { - type: `${typeof name}:getState`; - handler: () => NotificationControllerState; -}; - -export type ShowNotification = { - type: `${typeof name}:show`; - handler: NotificationController['show']; -}; - -export type DismissNotification = { - type: `${typeof name}:dismiss`; - handler: NotificationController['dismiss']; -}; - -export type MarkNotificationRead = { - type: `${typeof name}:markRead`; - handler: NotificationController['markRead']; -}; - -export type ClearNotifications = { - type: `${typeof name}:clear`; - handler: NotificationController['clear']; -}; - -export type NotificationControllerActions = - | GetNotificationControllerState - | ShowNotification - | DismissNotification - | MarkNotificationRead - | ClearNotifications; - -export type NotificationControllerMessenger = RestrictedControllerMessenger< - typeof name, - NotificationControllerActions, - NotificationControllerStateChange, - never, - never ->; - -const metadata = { - notifications: { persist: true, anonymous: false }, -}; - -const defaultState = { - notifications: {}, -}; - -/** - * Controller that handles storing notifications and showing them to the user - */ -export class NotificationController extends BaseControllerV2< - typeof name, - NotificationControllerState, - NotificationControllerMessenger -> { - /** - * Creates a NotificationController instance. - * - * @param options - Constructor options. - * @param options.messenger - A reference to the messaging system. - * @param options.state - Initial state to set on this controller. - */ - constructor({ - messenger, - state, - }: { - messenger: NotificationControllerMessenger; - state?: Partial; - }) { - super({ - name, - metadata, - messenger, - state: { ...defaultState, ...state }, - }); - - this.messagingSystem.registerActionHandler( - `${name}:show` as const, - (origin: string, message: string) => this.show(origin, message), - ); - - this.messagingSystem.registerActionHandler( - `${name}:dismiss` as const, - (ids: string[]) => this.dismiss(ids), - ); - - this.messagingSystem.registerActionHandler( - `${name}:markRead` as const, - (ids: string[]) => this.markRead(ids), - ); - - this.messagingSystem.registerActionHandler(`${name}:clear` as const, () => - this.clear(), - ); - } - - /** - * Shows a notification. - * - * @param origin - The origin trying to send a notification - * @param message - A message to show on the notification - */ - show(origin: string, message: string) { - const id = nanoid(); - const notification = { - id, - origin, - createdDate: Date.now(), - readDate: null, - message, - }; - this.update((state) => { - state.notifications[id] = notification; - }); - } - - /** - * Dimisses a list of notifications. - * - * @param ids - A list of notification IDs - */ - dismiss(ids: string[]) { - this.update((state) => { - for (const id of ids) { - if (hasProperty(state.notifications, id)) { - delete state.notifications[id]; - } - } - }); - } - - /** - * Marks a list of notifications as read. - * - * @param ids - A list of notification IDs - */ - markRead(ids: string[]) { - this.update((state) => { - for (const id of ids) { - if (hasProperty(state.notifications, id)) { - state.notifications[id].readDate = Date.now(); - } - } - }); - } - - /** - * Clears the state of the controller, removing all notifications. - * - */ - clear() { - this.update(() => { - return { ...defaultState }; - }); - } -} diff --git a/packages/notification-controller/src/index.ts b/packages/notification-controller/src/index.ts deleted file mode 100644 index 6c896d48262..00000000000 --- a/packages/notification-controller/src/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from './NotificationController'; diff --git a/packages/notification-controller/tsconfig.build.json b/packages/notification-controller/tsconfig.build.json deleted file mode 100644 index bbfe057a207..00000000000 --- a/packages/notification-controller/tsconfig.build.json +++ /dev/null @@ -1,13 +0,0 @@ -{ - "extends": "../../tsconfig.packages.build.json", - "compilerOptions": { - "baseUrl": "./", - "outDir": "./dist", - "rootDir": "./src" - }, - "references": [ - { "path": "../base-controller/tsconfig.build.json" }, - { "path": "../controller-utils/tsconfig.build.json" } - ], - "include": ["../../types", "./src"] -} diff --git a/packages/notification-controller/tsconfig.json b/packages/notification-controller/tsconfig.json deleted file mode 100644 index 7ee9852347a..00000000000 --- a/packages/notification-controller/tsconfig.json +++ /dev/null @@ -1,11 +0,0 @@ -{ - "extends": "../../tsconfig.packages.json", - "compilerOptions": { - "baseUrl": "./" - }, - "references": [ - { "path": "../base-controller" }, - { "path": "../controller-utils" } - ], - "include": ["../../types", "./src"] -} diff --git a/packages/notification-services-controller/CHANGELOG.md b/packages/notification-services-controller/CHANGELOG.md new file mode 100644 index 00000000000..80a6fc263ff --- /dev/null +++ b/packages/notification-services-controller/CHANGELOG.md @@ -0,0 +1,933 @@ +# 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] + +### Changed + +- Bump `@metamask/keyring-controller` from `^27.1.0` to `^27.1.1` ([#9791](https://github.com/MetaMask/core/pull/9791)) +- Bump `@metamask/authenticated-user-storage` from `^3.0.1` to `^3.0.2` ([#9972](https://github.com/MetaMask/core/pull/9972)) + +## [26.0.1] + +### Changed + +- Bump `@metamask/profile-sync-controller` from `^28.3.0` to `^29.0.0` ([#9779](https://github.com/MetaMask/core/pull/9779)) + +## [26.0.0] + +### Added + +- Add `PushAnalyticsPayload` type carrying the first-class push notification fields (`notification_id`, `notification_type`, `notification_subtype`, `chain_id`, `deeplink`). ([#8944](https://github.com/MetaMask/core/pull/8944)) +- Add `getNotificationSubtype` helper that derives a normalised `notification_subtype` from an `INotification`, so both clients pull the subtype from one place. ([#8944](https://github.com/MetaMask/core/pull/8944)) +- Add a `notification_subtype` field to the generated platform notification schema (`PlatformNotification`), surfaced via `getNotificationSubtype` so platform notifications report their server-set subtype (e.g. `position_liquidated`) instead of the generic `platform` label. ([#8944](https://github.com/MetaMask/core/pull/8944)) +- Export `toPushAnalyticsPayload` from `@metamask/notification-services-controller/push-services` so web and mobile clients can parse FCM analytics fields from a shared helper. ([#8944](https://github.com/MetaMask/core/pull/8944)) + +### Changed + +- **BREAKING:** The `NotificationServicesPushController:onNewNotifications` and `NotificationServicesPushController:pushNotificationClicked` messenger events now carry `PushAnalyticsPayload` instead of `INotification`. ([#8944](https://github.com/MetaMask/core/pull/8944)) + - The push payload no longer carries the full notification body; clients construct their analytics events directly from the first-class fields. + - The `onReceivedHandler` / `onClickHandler` callbacks passed to `createSubscribeToPushNotifications` now receive a `PushAnalyticsPayload` instead of an `INotification`. +- On push receive, the controller now re-fetches the notifications list from the API rather than inserting the push payload, since the push payload no longer contains the notification body. ([#8944](https://github.com/MetaMask/core/pull/8944)) +- Bump `@metamask/authenticated-user-storage` from `^3.0.0` to `^3.0.1` ([#9458](https://github.com/MetaMask/core/pull/9458)) +- Bump `@metamask/profile-sync-controller` from `^28.2.0` to `^28.3.0` ([#9463](https://github.com/MetaMask/core/pull/9463)) + +### Removed + +- **BREAKING:** Remove the nested `data["data"]` / `metadata` FCM payload parsing path; push payloads are now read from the top-level FCM fields written by push-services. ([#8944](https://github.com/MetaMask/core/pull/8944)) + +### Fixed + +- Update `isOnChainRawNotification` to detect on-chain notifications using the v4 `notification_type` discriminator instead of legacy payload field checks, fixing web push notification handling after the v4 API migration ([#9407](https://github.com/MetaMask/core/pull/9407)) + +## [25.0.0] + +### Added + +- Export `isOnChainNotification` and `isPlatformNotification` type guards for discriminating v4 API notification shapes ([#9384](https://github.com/MetaMask/core/pull/9384)) +- Export `PlatformNotification` and `OnChainNotification` types derived from the v4 Notification API schema ([#9384](https://github.com/MetaMask/core/pull/9384)) + +### Changed + +- **BREAKING:** Moved Notification API from v3 to v4 ([#9384](https://github.com/MetaMask/core/pull/9384)) + - API Endpoint Changes: Updated from `/api/v3/notifications` to `/api/v4/notifications` for listing notifications and marking as read + - Response Structure: `notification_type` and `notification_subtype` now reflect producer-set database fields instead of fixed enum values + - On-chain notifications: `notification_type` is now `"wallet_activity"` (was `"on-chain"`), with `notification_subtype` set to the on-chain kind (e.g. `"metamask_swap_completed"`) + - Platform notifications: `notification_type` is now a producer-set value (e.g. `"perps"`, was `"platform"`), with `notification_subtype` set to the platform subtype (e.g. `"position_liquidated"`) + - Clients should use the `isOnChainNotification` / `isPlatformNotification` type guards to distinguish on-chain vs platform notifications + - Type System: + - `UnprocessedRawNotification` now uses `NotificationOutputV4` shapes (`PlatformNotificationV4` | `OnChainNotificationV4`) + - `toRawAPINotification()` now normalises v4 notifications, mapping on-chain `notification_subtype` to the `type` field + - Regenerated `schema.ts` from the latest Notification API OpenAPI spec, including v4 paths and legacy v1–v3 schemas + - `AppPlatform` now includes `"portfolio"` in addition to `"extension"` and `"mobile"` +- Bump `@metamask/messenger` from `^1.2.0` to `^2.0.0` ([#9392](https://github.com/MetaMask/core/pull/9392)) + +## [24.3.0] + +### Added + +- Add `DEFAULT_PRICE_ALERT_PREFERENCES` and initialize `priceAlerts` when building fresh notification preferences via `NotificationServicesController` ([#9316](https://github.com/MetaMask/core/pull/9316)) + - Re-export `DEFAULT_PRICE_ALERT_PREFERENCES` from `@metamask/authenticated-user-storage`. + +### Changed + +- Bump `@metamask/authenticated-user-storage` from `^2.1.0` to `^3.0.0` ([#9348](https://github.com/MetaMask/core/pull/9348)) + +## [24.2.0] + +### Added + +- Add `DEFAULT_AGENTIC_CLI_PREFERENCES` and initialize `agenticCli` when building fresh notification preferences via `NotificationServicesController` ([#8933](https://github.com/MetaMask/core/pull/8933)) + - Re-export `DEFAULT_AGENTIC_CLI_PREFERENCES` from `@metamask/authenticated-user-storage`. + +### Changed + +- Agentic CLI notification delivery is gated by the Agentic backend using AUS `agenticCli` preferences; `NotificationServicesController` does not filter Agentic CLI notifications at fetch time (same as `perps` and `socialAI`) ([#8933](https://github.com/MetaMask/core/pull/8933)) +- Bump `@metamask/utils` from `^11.9.0` to `^11.11.0` ([#9074](https://github.com/MetaMask/core/pull/9074)) +- Bump `@metamask/controller-utils` from `^12.1.1` to `^12.3.0` ([#9083](https://github.com/MetaMask/core/pull/9083), [#9218](https://github.com/MetaMask/core/pull/9218)) +- Bump `@metamask/profile-sync-controller` from `^28.1.1` to `^28.2.0` ([#9119](https://github.com/MetaMask/core/pull/9119)) +- Bump `@metamask/keyring-controller` from `^27.0.0` to `^27.1.0` ([#9129](https://github.com/MetaMask/core/pull/9129)) +- Bump `@metamask/authenticated-user-storage` from `^2.0.0` to `^2.1.0` ([#9220](https://github.com/MetaMask/core/pull/9220)) + +## [24.1.3] + +### Changed + +- Bump `@metamask/controller-utils` from `^12.1.0` to `^12.1.1` ([#9058](https://github.com/MetaMask/core/pull/9058)) +- Bump `@metamask/keyring-controller` from `^26.0.0` to `^27.0.0` ([#9058](https://github.com/MetaMask/core/pull/9058)) + +## [24.1.2] + +### Changed + +- Bump `@metamask/keyring-controller` from `^25.5.0` to `^26.0.0` ([#8912](https://github.com/MetaMask/core/pull/8912)) +- Bump `@metamask/profile-sync-controller` from `^28.1.0` to `^28.1.1` ([#8912](https://github.com/MetaMask/core/pull/8912)) + +## [24.1.1] + +### Changed + +- Fetch feature announcements based on AUS `marketing.inAppNotificationsEnabled` preferences instead of the deprecated `isFeatureAnnouncementsEnabled` controller state. ([#8861](https://github.com/MetaMask/core/pull/8861)) + +## [24.1.0] + +### Added + +- Add `registerPushNotifications` to `NotificationServicesControllerEnableNotificationsOptions` so clients can enable MetaMask notifications without registering push notifications. ([#8782](https://github.com/MetaMask/core/pull/8782)) +- Add optional mobile OS and app version metadata to push token registrations so clients can provide Firebase error attribution data. ([#8782](https://github.com/MetaMask/core/pull/8782)) + +## [24.0.0] + +### Added + +- Add `productAnnouncementEnabled` to `NotificationServicesControllerEnableNotificationsOptions`. ([#8784](https://github.com/MetaMask/core/pull/8784)) + +### Changed + +- **BREAKING:** Enrich notification settings using Authenticated User Storage. ([#8784](https://github.com/MetaMask/core/pull/8784)) + - Replace Trigger API notification settings with AUS notification preferences as the source of truth. + - `NotificationServicesController` now requires AUS messenger actions for notification setup. + - When AUS has no stored preferences, `createOnChainTriggers` writes a complete preferences blob for `walletActivity`, `marketing`, `perps`, and `socialAI`. + - Wallet activity accounts are seeded from the current Trigger API config when at least one current account is already enabled; otherwise all current accounts are initialized as enabled for first-time notification setup. + - Marketing push notifications are initialized from `hasMarketingConsent`, while marketing in-app notifications are initialized from `productAnnouncementEnabled`. +- Bump `@metamask/controller-utils` from `^12.0.0` to `^12.1.0` ([#8774](https://github.com/MetaMask/core/pull/8774)) +- Bump `@metamask/profile-sync-controller` from `^28.0.2` to `^28.1.0` ([#8783](https://github.com/MetaMask/core/pull/8783)) +- Bump `@metamask/authenticated-user-storage` from `^1.0.1` to `^2.0.0` ([#8802](https://github.com/MetaMask/core/pull/8802)) + +### Removed + +- **BREAKING:** Remove unused `resetNotifications` option from `NotificationServicesControllerEnableNotificationsOptions`. ([#8784](https://github.com/MetaMask/core/pull/8784)) + +## [23.1.1] + +### Changed + +- Bump `@metamask/base-controller` from `^9.0.1` to `^9.1.0` ([#8457](https://github.com/MetaMask/core/pull/8457)) +- Bump `@metamask/messenger` from `^1.1.1` to `^1.2.0` ([#8632](https://github.com/MetaMask/core/pull/8632)) +- Bump `@metamask/keyring-controller` from `^25.2.0` to `^25.5.0` ([#8634](https://github.com/MetaMask/core/pull/8634), [#8665](https://github.com/MetaMask/core/pull/8665), [#8722](https://github.com/MetaMask/core/pull/8722)) +- Bump `@metamask/controller-utils` from `^11.20.0` to `^12.0.0` ([#8755](https://github.com/MetaMask/core/pull/8755)) + +## [23.1.0] + +### Added + +- Add backend push token links for newly added notification accounts using the existing device token, so account additions trigger additive `POST /api/v2/token` registration for both primary and imported SRPs ([#8449](https://github.com/MetaMask/core/pull/8449)) + - Add the `NotificationServicesPushController:addPushNotificationLinks` messenger action and export the corresponding `NotificationServicesPushControllerAddPushNotificationLinksAction` type so clients can allow and type the new additive push-link flow. + +### Changed + +- Bump `@metamask/keyring-controller` from `^25.1.1` to `^25.2.0` ([#8363](https://github.com/MetaMask/core/pull/8363)) +- Bump `@metamask/profile-sync-controller` from `^28.0.1` to `^28.0.2` ([#8325](https://github.com/MetaMask/core/pull/8325)) +- Bump `@metamask/controller-utils` from `^11.19.0` to `^11.20.0` ([#8344](https://github.com/MetaMask/core/pull/8344)) +- Bump `@metamask/messenger` from `^1.0.0` to `^1.1.1` ([#8364](https://github.com/MetaMask/core/pull/8364), [#8373](https://github.com/MetaMask/core/pull/8373)) + +## [23.0.1] + +### Changed + +- Bump `@metamask/base-controller` from `^9.0.0` to `^9.0.1` ([#8317](https://github.com/MetaMask/core/pull/8317)) +- Bump `@metamask/keyring-controller` from `^25.1.0` to `^25.1.1` ([#8317](https://github.com/MetaMask/core/pull/8317)) +- Bump `@metamask/messenger` from `^0.3.0` to `^1.0.0` ([#8317](https://github.com/MetaMask/core/pull/8317)) +- Bump `@metamask/profile-sync-controller` from `^28.0.0` to `^28.0.1` ([#8317](https://github.com/MetaMask/core/pull/8317)) + +## [23.0.0] + +### Added + +- Expose missing public `NotificationServicesController` methods through its messenger ([#8176](https://github.com/MetaMask/core/pull/8176)) + - The following actions are now available: + - `NotificationServicesController:init` + - `NotificationServicesController:enablePushNotification` + - `NotificationServicesController:disablePushNotification` + - `NotificationServicesController:checkAccountsPresence` + - `NotificationServicesController:setFeatureAnnouncementsEnabled` + - `NotificationServicesController:createOnChainTriggers` + - `NotificationServicesController:enableMetamaskNotifications` + - `NotificationServicesController:disableAccounts` + - `NotificationServicesController:enableAccounts` + - `NotificationServicesController:fetchAndUpdateMetamaskNotifications` + - `NotificationServicesController:deleteNotificationById` + - `NotificationServicesController:markMetamaskNotificationsAsRead` + - `NotificationServicesController:sendPerpPlaceOrderNotification` + - Corresponding action types (e.g. `NotificationServicesControllerEnablePushNotificationAction`) are available as well. + +### Changed + +- **BREAKING:** Standardize names of `NotificationServicesController` and `NotificationServicesPushController` messenger action types ([#8176](https://github.com/MetaMask/core/pull/8176)) + - All existing types for messenger actions have been renamed so they end in `Action` (e.g. `NotificationServicesControllerUpdateMetamaskNotificationsList` -> `NotificationServicesControllerUpdateMetamaskNotificationsListAction`). You will need to update imports appropriately. + - The `NotificationServicesPushController` action `NotificationServicesPushControllerSubscribeToNotificationsAction` has been renamed to `NotificationServicesPushControllerSubscribeToPushNotificationsAction` so it matches the method name. + - These changes only affect the types. The action type strings themselves have not changed, so you do not need to update the list of actions you pass when initializing `NotificationServicesController` and `NotificationServicesPushController` messengers. +- Register notification accounts from all keyrings instead of only the first HD keyring, so notification setup now includes addresses from HD, hardware, imported, and snap keyrings ([#8108](https://github.com/MetaMask/core/pull/8108)) +- Add push token unlink support for account removal by deleting `/api/v2/token` links for `{ address, platform }` pairs when notification accounts are disabled (for example during SRP removal) ([#8108](https://github.com/MetaMask/core/pull/8108)) + +## [22.1.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)) +- Bump `@metamask/controller-utils` from `^11.18.0` to `^11.19.0` ([#7995](https://github.com/MetaMask/core/pull/7995)) +- Bump `@metamask/profile-sync-controller` from `^27.1.0` to `^28.0.0` ([#8162](https://github.com/MetaMask/core/pull/8162)) + +## [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: + - `@metamask/keyring-controller` (^25.1.0) + - `@metamask/profile-sync-controller` (^27.1.0) + - In clients, it is now possible for multiple versions of these packages to exist in the dependency tree. + - For example, this scenario would be valid: a client relies on `@metamask/controller-a` 1.0.0 and `@metamask/controller-b` 1.0.0, and `@metamask/controller-b` depends on `@metamask/controller-a` 1.1.0. + - Note, however, that the versions specified in the client's `package.json` always "win", and you are expected to keep them up to date so as not to break controller and service intercommunication. +- 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 + +- **BREAKING:** Removed the `"./notification-services/ui"` subpath export from `package.json` ([#7840](https://github.com/MetaMask/core/pull/7840)) + - Consumers that import from `@metamask/notification-services-controller/notification-services/ui` must switch to network config provided by the backend. + +### Fixed + +- Remove non-actionable internal `log.error` calls for expected silent-failure notification paths, while preserving thrown errors where propagation is required ([#7885](https://github.com/MetaMask/core/pull/7885)) +- Fix `createOnChainTriggers` to preserve user preferences on notification re-subscriptions ([#7423](https://github.com/MetaMask/core/pull/7423)) + - Previously, `isFeatureAnnouncementsEnabled` was unconditionally set to `true` on every re-subscription, overriding user preferences + - Now, existing preferences are preserved when `isNotificationServicesEnabled` is already `true` + +## [21.0.0] + +### Changed + +- Bump `@metamask/controller-utils` from `^11.15.0` to `^11.16.0` ([#7202](https://github.com/MetaMask/core/pull/7202)) +- **BREAKING:** Bump `@metamask/profile-sync-controller` from `^26.0.0` to `^27.0.0` ([#7202](https://github.com/MetaMask/core/pull/7202)) +- **BREAKING:** Bump `@metamask/keyring-controller` from `^24.0.0` to `^25.0.0` ([#7202](https://github.com/MetaMask/core/pull/7202)) +- Add optional `env` parameter to the `NotificationServicesController` and `NotificationServicesPushController` + to support different environments (`prd`, `uat`, `dev`). ([#7175](https://github.com/MetaMask/core/pull/7175)) + +## [20.0.0] + +### Changed + +- **BREAKING:** Moved Notification API from v2 to v3 ([#7102](https://github.com/MetaMask/core/pull/7102)) + - API Endpoint Changes: Updated from `/api/v2/notifications` to `/api/v3/notifications` for listing notifications and marking as read + - Request Format: The list notifications endpoint now expects `{ addresses: string[], locale?: string }` instead of `{ address: string }[]` + - Response Structure: Notifications now include a `notification_type` field ('on-chain' or 'platform') and nested payload structure + - On-chain notifications: data moved from root level to `payload.data` + - Platform notifications: new type with `template` containing localized content (`title`, `body`, `image_url`, `cta`) + - Type System Overhaul: + - `OnChainRawNotification` → `NormalisedAPINotification` (union of on-chain and platform) + - `UnprocessedOnChainRawNotification` → `UnprocessedRawNotification` + - Removed specific DeFi notification types (Aave, ENS, Lido rewards, etc.) - now will be handled generically + - Added `TRIGGER_TYPES.PLATFORM` for platform notifications + - Function Signatures: + - `getOnChainNotifications()` → `getAPINotifications()` with new `locale` parameter + - `getOnChainNotificationsConfigCached()` → `getNotificationsApiConfigCached()` + - `processOnChainNotification()` → `processAPINotifications()` + - Service Imports: Update imports from `onchain-notifications` to `api-notifications` + - Auto-expiry: Reduced from 90 days to 30 days for notification auto-expiry + - Locale Support: Added locale parameter to controller constructor for localized server notifications + +## [19.0.0] + +### Changed + +- **BREAKING:** Use new `Messenger` from `@metamask/messenger` ([#6538](https://github.com/MetaMask/core/pull/6538)) + - Previously, `NotificationServicesController` and `NotificationServicesPushController` accepted a `RestrictedMessenger` instance from `@metamask/base-controller`. +- **BREAKING:** Metadata property `anonymous` renamed to `includeInDebugSnapshot` ([#6538](https://github.com/MetaMask/core/pull/6538)) +- **BREAKING:** Bump `@metamask/keyring-controller` from `^23.0.0` to `^24.0.0` ([#6962](https://github.com/MetaMask/core/pull/6962)) +- **BREAKING:** Bump `@metamask/profile-sync-controller` from `^25.0.0` to `^26.0.0` ([#6962](https://github.com/MetaMask/core/pull/6962)) +- Bump `@metamask/base-controller` from `^8.4.2` to `^9.0.0` ([#6962](https://github.com/MetaMask/core/pull/6962)) + +### Removed + +- **BREAKING:** Remove package-level exports of `AllowedActions` and `AllowedEvents` from `NotificationServicesController` and `NotificationServicesPushController` ([#6538](https://github.com/MetaMask/core/pull/6538)) + +## [18.3.1] + +### Changed + +- Bump `@metamask/base-controller` from `^8.4.1` to `^8.4.2` ([#6917](https://github.com/MetaMask/core/pull/6917)) + +## [18.3.0] + +### Added + +- Add exported util `isVersionInBounds` to validate version number is in bounds ([#6793](https://github.com/MetaMask/core/pull/6793)) + +### Changed + +- Bump `@metamask/base-controller` from `^8.4.0` to `^8.4.1` ([#6807](https://github.com/MetaMask/core/pull/6807)) +- Bump `@metamask/controller-utils` from `^11.14.0` to `^11.14.1` ([#6807](https://github.com/MetaMask/core/pull/6807)) + +## [18.2.0] + +### Added + +- Add max bound version segmentation for feature announcements ([#6773](https://github.com/MetaMask/core/pull/6773)) + - Add `extensionMaximumVersionNumber` and `mobileMaximumVersionNumber` properties to feature announcements +- Add optional `platformVersion` property to `NotificationServicesController` `FeatureAnnouncementEnv` type ([#6568](https://github.com/MetaMask/core/pull/6568)) +- Filtering logic to filter feature annonucements by version number ([#6568](https://github.com/MetaMask/core/pull/6568)) +- Add package `semver@^7.7.2` to handle semver version comparisons for announcement notification filtering ([#6568](https://github.com/MetaMask/core/pull/6568)) +- Add two new controller state metadata properties: `includeInStateLogs` and `usedInUi` ([#6583](https://github.com/MetaMask/core/pull/6583)) + +### Changed + +- Bump `@metamask/controller-utils` from `^11.12.0` to `^11.14.0` ([#6620](https://github.com/MetaMask/core/pull/6620), [#6629](https://github.com/MetaMask/core/pull/6629)) +- Bump `@metamask/utils` from `^11.4.2` to `^11.8.1` ([#6588](https://github.com/MetaMask/core/pull/6588), [#6708](https://github.com/MetaMask/core/pull/6708)) +- Bump `@metamask/base-controller` from `^8.3.0` to `^8.4.0` ([#6632](https://github.com/MetaMask/core/pull/6632)) + +## [18.1.0] + +### Added + +- Add `extensionMinimumVersionNumber` and `mobileMinimumVersionNumber` properties to feature annoucements ([#6554](https://github.com/MetaMask/core/pull/6554)) + +## [18.0.0] + +### Added + +- Add `sendPerpPlaceOrderNotification` method to `NotificationServicesController` ([#6464](https://github.com/MetaMask/core/pull/6464)) +- Add `createPerpOrderNotification` function to invoke perp notification service ([#6464](https://github.com/MetaMask/core/pull/6464)) +- Add `perps/schema.ts` file from perp notification OpenAPI types ([#6464](https://github.com/MetaMask/core/pull/6464)) +- Add exported `OrderInput` type ([#6464](https://github.com/MetaMask/core/pull/6464)) + +### Changed + +- **BREAKING:** Bump peer dependency `@metamask/profile-sync-controller` from `^24.0.0` to `^25.0.0` ([#6558](https://github.com/MetaMask/core/pull/6558)) +- Bump `@metamask/base-controller` from `^8.1.0` to `^8.3.0` ([#6355](https://github.com/MetaMask/core/pull/6355), [#6465](https://github.com/MetaMask/core/pull/6465)) + +## [17.0.0] + +### Changed + +- **BREAKING:** Bump peer dependency `@metamask/keyring-controller` from `^22.0.0` to `^23.0.0` ([#6345](https://github.com/MetaMask/core/pull/6345)) +- **BREAKING:** Bump peer dependency `@metamask/profile-sync-controller` from `^23.0.0` to `^24.0.0` ([#6345](https://github.com/MetaMask/core/pull/6345)) +- Bump `@metamask/base-controller` from `^8.0.1` to `^8.1.0` ([#6284](https://github.com/MetaMask/core/pull/6284)) +- Bump `@metamask/controller-utils` from `^11.11.0` to `^11.12.0` ([#6303](https://github.com/MetaMask/core/pull/6303)) + +## [16.0.0] + +### Changed + +- **BREAKING:** Bump peer dependency `@metamask/profile-sync-controller` to `^23.0.0` ([#6213](https://github.com/MetaMask/core/pull/6213)) + +## [15.0.0] + +### Added + +- Add `BASE` chain to notification UI config in `ui/constants.ts` ([#6124](https://github.com/MetaMask/core/pull/6124)) + +### Changed + +- **BREAKING:** Bump peer dependency `@metamask/profile-sync-controller` to `^22.0.0` ([#6171](https://github.com/MetaMask/core/pull/6171)) +- Update push notification utility `getChainSymbol` in `get-notification-message.ts` to use UI constants ([#6124](https://github.com/MetaMask/core/pull/6124)) + +### Removed + +- **BREAKING:** Cleanup old config/constants ([#6124](https://github.com/MetaMask/core/pull/6124)) + - Remove `NOTIFICATION_CHAINS` constant from `notification-schema.ts` + - Remove `CHAIN_SYMBOLS` constant from `notification-schema.ts` + - Remove `SUPPORTED_CHAINS` constant from `notification-schema.ts` + - Remove `Trigger` type from `notification-schema.ts` + - Remove `TRIGGERS` constant from `notification-schema.ts` + +## [14.0.0] + +### Changed + +- **BREAKING:** Bump peer dependency `@metamask/profile-sync-controller` to `^21.0.0` ([#6100](https://github.com/MetaMask/core/pull/6100)) + +## [13.0.0] + +### Changed + +- **BREAKING:** Bump peer dependency `@metamask/profile-sync-controller` to `^20.0.0` ([#6071](https://github.com/MetaMask/core/pull/6071)) + +## [12.0.1] + +### Changed + +- Bump `@metamask/controller-utils` from `^11.10.0` to `^11.11.0` ([#6069](https://github.com/MetaMask/core/pull/6069)) + - This upgrade includes performance improvements to checksum hex address normalization +- Bump `@metamask/utils` from `^11.2.0` to `^11.4.2` ([#6054](https://github.com/MetaMask/core/pull/6054)) + +## [12.0.0] + +### Changed + +- **BREAKING:** Bump peer dependency `@metamask/profile-sync-controller` to `^19.0.0` ([#5999](https://github.com/MetaMask/core/pull/5999)) + +## [11.0.0] + +### Added + +- SEI network to supported networks for notifications ([#5945](https://github.com/MetaMask/core/pull/5945)) + - Added `SEI` to `NOTIFICATION_CHAINS_ID` constant + - Added `Sei Network` to default `NOTIFICATION_NETWORK_CURRENCY_NAME` constant + - Added `SEI` to default `NOTIFICATION_NETWORK_CURRENCY_SYMBOL` constant + - Added SEI block explorer to default `SUPPORTED_NOTIFICATION_BLOCK_EXPLORERS` constant + +### Changed + +- **BREAKING:** bump `@metamask/profile-sync-controller` peer dependency to `^18.0.0` ([#5996](https://github.com/MetaMask/core/pull/5996)) +- **BREAKING:** Migrated to notification v2 endpoints ([#5945](https://github.com/MetaMask/core/pull/5945)) + - `https://trigger.api.cx.metamask.io/api/v1` to `https://trigger.api.cx.metamask.io/api/v2` for managing out notification subscriptions + - `https://notification.api.cx.metamask.io/api/v1` to `https://notification.api.cx.metamask.io/api/v2` for fetching notifications (in-app notifications) + - `https://push.api.cx.metamask.io/v1` to `https://push.api.cx.metamask.io/v2` for subscribing push notifications + - Renamed method `updateOnChainTriggersByAccount` to `enableAccounts` in `NotificationServicesController` + - Renamed method `deleteOnChainTriggersByAccount` to `disableAccounts` in `NotificationServicesController` + - Deprecated `updateTriggerPushNotifications` from `NotificationServicesPushController` and will be removed in a subsequent release. +- Bump `@metamask/controller-utils` to `^11.10.0` ([#5935](https://github.com/MetaMask/core/pull/5935)) + +### Removed + +- **BREAKING:** Migrated to notification v2 endpoints ([#5945](https://github.com/MetaMask/core/pull/5945)) + - removed `NotificationServicesPushController:updateTriggerPushNotifications` action from `NotificationServicesController` + - removed `UserStorageController:getStorageKey` action from `NotificationServicesController` + - removed `UserStorageController:performGetStorage` action from `NotificationServicesController` + - removed `UserStorageController:performSetStorage` action from `NotificationServicesController` + - removed UserStorage notification utilities: `initializeUserStorage`, `cleanUserStorage`, `traverseUserStorageTriggers`, `checkAccountsPresence`, `inferEnabledKinds`, `getUUIDsForAccount`, `getAllUUIDs`, `getUUIDsForKinds`, `getUUIDsForAccountByKinds`, `upsertAddressTriggers`, `upsertTriggerTypeTriggers`, `toggleUserStorageTriggerStatus`. + +## [10.0.0] + +### Changed + +- **BREAKING:** bump `@metamask/profile-sync-controller` peer dependency to `^17.0.0` ([#5906](https://github.com/MetaMask/core/pull/5906)) + +## [9.0.0] + +### Changed + +- **BREAKING:** bump `@metamask/profile-sync-controller` peer dependency to `^16.0.0` ([#5802](https://github.com/MetaMask/core/pull/5802)) +- Bump `@metamask/controller-utils` to `^11.9.0` ([#5812](https://github.com/MetaMask/core/pull/5812)) + +## [8.0.0] + +### Changed + +- **BREAKING:** bump `@metamask/keyring-controller` peer dependency to `^22.0.0` ([#5802](https://github.com/MetaMask/core/pull/5802)) +- **BREAKING:** bump `@metamask/profile-sync-controller` peer dependency to `^15.0.0` ([#5802](https://github.com/MetaMask/core/pull/5802)) +- Bump peer dependency `@metamask/profile-sync-controller` to `^14.0.0` ([#5789](https://github.com/MetaMask/core/pull/5789)) + - While `@metamask/profile-sync-controller@14.0.0` contains breaking changes for clients, they are not breaking as a peer dependency here as the changes do not impact `@metamask/notification-services-controller` +- replaced `KeyringController:withKeyring` with `KeyringController:getState` to get the first HD keyring for notifications ([#5764](https://github.com/MetaMask/core/pull/5764)) +- Bump `@metamask/controller-utils` to `^11.8.0` ([#5765](https://github.com/MetaMask/core/pull/5765)) + +### Removed + +- **BREAKING** removed `KeyringController:withKeyring` allowed action in `NotificationServicesController` ([#5764](https://github.com/MetaMask/core/pull/5764)) + +## [7.0.0] + +### Changed + +- **BREAKING:** Bump peer dependency `@metamask/profile-sync-controller` to `^13.0.0` ([#5763](https://github.com/MetaMask/core/pull/5763)) + +## [6.0.1] + +### Changed + +- Bump `@metamask/base-controller` from ^8.0.0 to ^8.0.1 ([#5722](https://github.com/MetaMask/core/pull/5722)) + +### Fixed + +- add a check inside the `KeyringController:stateChange` subscription inside `NotificationServicesController` to prevent infinite updates ([#5731](https://github.com/MetaMask/core/pull/5731)) + - As we invoke a `KeyringController:withKeyring` inside the `KeyringController:stateChange` event subscription, + we are causing many infinite updates which block other controllers from performing state updates. + - We now check the size of keyrings from the `KeyringController:stateChange` to better assume when keyrings have been added + +## [6.0.0] + +### Changed + +- **BREAKING:** Bump peer dependency `@metamask/profile-sync-controller` to `^12.0.0` ([#5644](https://github.com/MetaMask/core/pull/5644)) +- Bump `@metamask/controller-utils` to `^11.7.0` ([#5583](https://github.com/MetaMask/core/pull/5583)) + +## [5.0.1] + +### Fixed + +- add guard if `KeyringController:withKeyring` fails when called in `NotificationServicesController` ([#5514](https://github.com/MetaMask/core/pull/5514)) + +## [5.0.0] + +### Changed + +- Bump peer dependency `@metamask/profile-sync-controller` to `^11.0.0` ([#5507](https://github.com/MetaMask/core/pull/5507)) + +## [4.0.0] + +### Changed + +- **BREAKING** split `NotificationServiceController` constructor and initialization methods ([#5504](https://github.com/MetaMask/core/pull/5504)) + - Now requires calling `.init()` to finalize initialization, making it compatible with the Modular Controller Initialization architecture. + +### Fixed + +- use `withKeyring` to get main keyring accounts for enabling notifications ([#5459](https://github.com/MetaMask/core/pull/5459)) +- add support for fetching shared announcements cross platforms ([#5441](https://github.com/MetaMask/core/pull/5441)) + +## [3.0.0] + +### Changed + +- **BREAKING** Bump `@metamask/keyring-controller` peer dependency to `^21.0.0` ([#5439](https://github.com/MetaMask/core/pull/5439)) +- **BREAKING** Bump `@metamask/profile-sync-controller` peer dependency to `^10.0.0` ([#5439](https://github.com/MetaMask/core/pull/5439)) + +## [2.0.0] + +### Added + +- Add support for locales on push notifications ([#5392](https://github.com/MetaMask/core/pull/5392)) + +### Changed + +- **BREAKING:** Bump `@metamask/keyring-controller` peer dependency to `^20.0.0` ([#5426](https://github.com/MetaMask/core/pull/5426)) +- **BREAKING:** Bump `@metamask/profile-sync-controller` peer dependency to `^9.0.0` ([#5426](https://github.com/MetaMask/core/pull/5426)) + +## [1.0.0] + +### Added + +- added new public methods `enablePushNotifications` and `disablePushNotification` on `NotificationServicesController` ([#5120](https://github.com/MetaMask/core/pull/5120)) +- added `isPushEnabled` and `isUpdatingFCMToken` to `NotificationServicesPushController` state ([#5120](https://github.com/MetaMask/core/pull/5120)) +- added `/push-services/web` subpath export to make it easier to import web helpers ([#5120](https://github.com/MetaMask/core/pull/5120)) + +### Changed + +- **BREAKING**: updated `NotificationServicesPushController` constructor config to require a push interface ([#5120](https://github.com/MetaMask/core/pull/5120)) +- Optimized API calls for creating push notification links ([#5358](https://github.com/MetaMask/core/pull/5358)) +- Bump `@metamask/utils` from `^11.1.0` to `^11.2.0` ([#5301](https://github.com/MetaMask/core/pull/5301)) + +### Fixed + +- only allow hex addresses when creating notifications ([#5343](https://github.com/MetaMask/core/pull/5343)) + +## [0.21.0] + +### Added + +- Lock conditional checks when initializing accounts inside the `NotificationServicesController` ([#5323](https://github.com/MetaMask/core/pull/5323)) +- Accounts initialize call when the wallet is unlocked ([#5323](https://github.com/MetaMask/core/pull/5323)) + +### Changed + +- **BREAKING:** Bump `@metamask/profile-sync-controller` peer dependency from `^7.0.0` to `^8.0.0` ([#5318](https://github.com/MetaMask/core/pull/5318)) + +## [0.20.1] + +### Changed + +- Bump `@metamask/base-controller` from `^7.1.1` to `^8.0.0` ([#5305](https://github.com/MetaMask/core/pull/5305)) + +## [0.20.0] + +### Changed + +- **BREAKING:** Bump peer dependency `@metamask/profile-sync-controller` from `^6.0.0` to `^7.0.0` ([#5292](https://github.com/MetaMask/core/pull/5292)) + +## [0.19.0] + +### Changed + +- Improve logic & dependencies between profile sync, auth, user storage & notifications ([#5275](https://github.com/MetaMask/core/pull/5275)) +- Rename `ControllerMessenger` to `Messenger` ([#5242](https://github.com/MetaMask/core/pull/5242)) +- Bump @metamask/utils to v11.1.0 ([#5223](https://github.com/MetaMask/core/pull/5223)) + +## [0.18.0] + +### Changed + +- **BREAKING:** Bump peer dependency `@metamask/profile-sync-controller` from `^4.0.0` to `^5.0.0` ([#5218](https://github.com/MetaMask/core/pull/5218)) + +## [0.17.0] + +### Changed + +- Bump `firebase` from `^10.11.0` to `^11.2.0` ([#5196](https://github.com/MetaMask/core/pull/5196)) + +## [0.16.0] + +### Changed + +- **BREAKING:** Bump peer dependency `@metamask/profile-sync-controller` from `^3.0.0` to `^4.0.0` ([#5140](https://github.com/MetaMask/core/pull/5140)) +- Bump `@metamask/base-controller` from `^7.0.0` to `^7.1.0` ([#5079](https://github.com/MetaMask/core/pull/5079)) + +## [0.15.0] + +### Changed + +- **BREAKING:** Bump peer dependency `@metamask/profile-sync-controller` from `^2.0.0` to `^3.0.0` ([#5012](https://github.com/MetaMask/core/pull/5012)) +- Bump `@metamask/controller-utils` from `^11.4.3` to `^11.4.4` ([#5012](https://github.com/MetaMask/core/pull/5012)) + +### Fixed + +- Correct ESM-compatible build so that imports of the following packages that re-export other modules via `export *` are no longer corrupted: ([#5011](https://github.com/MetaMask/core/pull/5011)) + - `loglevel` + - `nock` + +## [0.14.0] + +### Changed + +- **BREAKING:** Bump `@metamask/keyring-controller` peer dependency from `^18.0.0` to `^19.0.0` ([#4195](https://github.com/MetaMask/core/pull/4195)) +- **BREAKING:** Bump `@metamask/profile-sync-controller` peer dependency from `^1.0.0` to `^2.0.0` ([#4195](https://github.com/MetaMask/core/pull/4195)) + +## [0.13.0] + +### Changed + +- **BREAKING:** Bump `@metamask/keyring-controller` peer dependency from `^17.0.0` to `^18.0.0` ([#4195](https://github.com/MetaMask/core/pull/4195)) +- **BREAKING:** Bump `@metamask/profile-sync-controller` peer dependency from `^0.9.7` to `^1.0.0` ([#4902](https://github.com/MetaMask/core/pull/4902)) +- Bump `@metamask/controller-utils` from `^11.4.2` to `^11.4.3` ([#4195](https://github.com/MetaMask/core/pull/4195)) + +## [0.12.1] + +### Changed + +- chore: Bump `@metamask/utils` from `^9.1.0` to `^10.0.0` ([#4831](https://github.com/MetaMask/core/pull/4831)) + +### Fixed + +- fix: allow snap notifications to be visible when controller is disabled ([#4890](https://github.com/MetaMask/core/pull/4890)) + - Most notification services are switched off when the controller is disabled, but since snaps are "local notifications", they need to be visible irrespective to the controller disabled state. + +## [0.12.0] + +### Added + +- Export snap types ([#4836](https://github.com/MetaMask/core/pull/4836)) + +### Fixed + +- fix: add publish event in `deleteNotificationsById` ([#4836](https://github.com/MetaMask/core/pull/4836)) + +## [0.11.0] + +### Added + +- Added support for an optional FCM token parameter for push notifications on mobile platforms, allowing native handling of FCM token creation through the Firebase SDK ([#4823](https://github.com/MetaMask/core/pull/4823)) + +### Changed + +- update the types described in `types/on-chain-notification/schema` and `types/on-chain-notification/on-chain-notification` ([#4818](https://github.com/MetaMask/core/pull/4818)) + - adds new notifications: aave_v3_health_factor; ens_expiration; lido_staking_rewards; notional_loan_expiration; rocketpool_staking_rewards; spark_fi_health_factor + - splits Wallet Notifications from Web 3 Notifications +- updated and added new notification mocks ([#4818](https://github.com/MetaMask/core/pull/4818)) + - can be accessed through `@metamask/notification-services-controller/notification-services/mocks` + +### Fixed + +- made `updateMetamaskNotificationsList` function work correctly by making the message handler async and moving the publish call outside of the update function. This ensures the `NotificationServicesController:notificationsListUpdated` event is received by the extension ([#4826](https://github.com/MetaMask/core/pull/4826)) + +## [0.10.0] + +### Added + +- added the ability for the `fetchFeatureAnnouncementNotifications` function, within the `notification-services-controller`, to fetch draft content from Contentful. This is made possible by passing a `previewToken` parameter ([#4790](https://github.com/MetaMask/core/pull/4790)) + +### Changed + +- update `createMockNotification` functions to provide more realistic data for use in tests and component rendering in Storybook ([#4791](https://github.com/MetaMask/core/pull/4791)) + +## [0.9.0] + +### Added + +- Add new functions to create mock notifications ([#4780](https://github.com/MetaMask/core/pull/4780)) + - `createMockNotificationAaveV3HealthFactor`: this function generates a mock notification related to the health factor of an Aave V3 position + - `createMockNotificationEnsExpiration`: this function creates a mock notification for the expiration of an ENS (Ethereum Name Service) domain + - `createMockNotificationLidoStakingRewards`: this function produces a mock notification for Lido staking rewards + - `createMockNotificationNotionalLoanExpiration`: this function generates a mock notification for the expiration of a Notional loan + - `createMockNotificationSparkFiHealthFactor`: This function produces a mock notification related to the health factor of a SparkFi position + +## [0.8.2] + +### Added + +- Add `resetNotifications` option during the notification creation flow ([#4738](https://github.com/MetaMask/core/pull/4738)) + +## [0.8.1] + +### Changed + +- Bump `@metamask/keyring-controller` from `^17.2.1` to `^17.2.2`. ([#4731](https://github.com/MetaMask/core/pull/4731)) +- Bump `@metamask/profile-sync-controller` from `^0.9.1` to `^0.9.2`. ([#4731](https://github.com/MetaMask/core/pull/4731)) + +## [0.8.0] + +### Changed + +- Update UI export from MATIC to POL ([#4720](https://github.com/MetaMask/core/pull/4720)) +- Bump `@metamask/profile-sync-controller` from `^0.8.0` to `^0.8.1` ([#4722]https://github.com/MetaMask/core/pull/4720) + +## [0.7.0] + +### Changed + +- Bump `@metamask/profile-sync-controller` from `^0.7.0` to `^0.8.0` ([#4712](https://github.com/MetaMask/core/pull/4712)) + +### Fixed + +- **BREAKING** use new profile-sync notification settings path hash ([#4711](https://github.com/MetaMask/core/pull/4711)) + - changing this path also means the underlying storage hash has changed. But this will align with our existing solutions that are in prod. + +## [0.6.0] + +### Changed + +- update subpath exports to use new .d.cts definition files. ([#4709](https://github.com/MetaMask/core/pull/4709)) +- Bump `@metamask/profile-sync-controller` from `^0.6.0` to `^0.7.0` ([#4710](https://github.com/MetaMask/core/pull/4710)) + +## [0.5.1] + +### Fixed + +- Produce and export ESM-compatible TypeScript type declaration files in addition to CommonJS-compatible declaration files ([#4648](https://github.com/MetaMask/core/pull/4648)) + - Previously, this package shipped with only one variant of type declaration + files, and these files were only CommonJS-compatible, and the `exports` + field in `package.json` linked to these files. This is an anti-pattern and + was rightfully flagged by the + ["Are the Types Wrong?"](https://arethetypeswrong.github.io/) tool as + ["masquerading as CJS"](https://github.com/arethetypeswrong/arethetypeswrong.github.io/blob/main/docs/problems/FalseCJS.md). + All of the ATTW checks now pass. +- Remove chunk files. ([#4648](https://github.com/MetaMask/core/pull/4648)) + - Previously, the build tool we used to generate JavaScript files extracted + common code to "chunk" files. While this was intended to make this package + more tree-shakeable, it also made debugging more difficult for our + development teams. These chunk files are no longer present. + +## [0.5.0] + +### Changed + +- move contentful as a dev dependency ([#4673](https://github.com/MetaMask/core/pull/4673)) +- update polygon symbol from MATIC to POL ([#4672](https://github.com/MetaMask/core/pull/4672)) +- Bump `@metamask/profile-sync-controller` from `^0.4.0` to `^0.5.0` ([#4678](https://github.com/MetaMask/core/pull/4678)) + +## [0.4.1] + +### Fixed + +- fix: keep push subscription when wallet is locked ([#4653](https://github.com/MetaMask/core/pull/4653)) + - add `NotificationServicesPushController:subscribeToPushNotifications` event and allowedEvent in `NotificationServicesController` + - add else check to continue to subscribe to push notifications when wallet is locked + +## [0.4.0] + +### Changed + +- Bump `@metamask/profile-sync-controller` from `^0.3.0` to `^0.4.0` ([#4661](https://github.com/MetaMask/core/pull/4661)) + +## [0.3.0] + +### Added + +- passed notification parameter to the `NotificationServicesPushController` `onPushNotificationClicked` config ([#4613](https://github.com/MetaMask/core/pull/4613)) +- Define and export new type `NotificationServicesControllerGetStateAction` ([#4633](https://github.com/MetaMask/core/pull/4633)) +- Add and export types `NotificationServicesPushControllerGetStateAction`, `NotificationServicesPushControllerStateChangeEvent` ([#4641](https://github.com/MetaMask/core/pull/4641)) +- add subpath exports to `@metamask/notification-services-controller` ([#4604](https://github.com/MetaMask/core/pull/4604)) + - add `@metamask/notification-services-controller/notification-services` export + - add `@metamask/notification-services-controller/push-services` export +- add `TypeExternalLinkFields`, `TypePortfolioLinkFields`, and `TypeMobileLinkFields` types to handle different types of links in feature announcements ([#4620](https://github.com/MetaMask/core/pull/4620)) + +### Changed + +- Bump `typescript` from `~5.1.6` to `~5.2.2` ([#4584](https://github.com/MetaMask/core/pull/4584)) +- Bump `@metamask/profile-sync-controller` from `^0.2.1` to `^0.3.0` ([#4657](https://github.com/MetaMask/core/pull/4657)) +- Bump `contentful` from `^10.3.6` to `^10.15.0` ([#4637](https://github.com/MetaMask/core/pull/4637)) +- **BREAKING:** Rename `NotificationServicesPushControllerPushNotificationClicked` type to `NotificationServicesPushControllerPushNotificationClickedEvent` ([#4641](https://github.com/MetaMask/core/pull/4641)) +- **BREAKING:** Narrow `AllowedEvents` type for `NotificationServicesPushControllerMessenger` to `never` ([#4641](https://github.com/MetaMask/core/pull/4641)) +- updated `NotificationServicesPushControllerMessenger` must allow internal event `NotificationServicesPushControllerStateChangeEvent` ([#4641](https://github.com/MetaMask/core/pull/4641)) +- updated `FeatureAnnouncementRawNotificationData` to include fields for external, portfolio, and mobile links ([#4620](https://github.com/MetaMask/core/pull/4620)) +- updated `TypeFeatureAnnouncementFields` to include fields for external, portfolio, and mobile links ([#4620](https://github.com/MetaMask/core/pull/4620)) +- updated `fetchFeatureAnnouncementNotifications` to handle the new link types and include them in the notification data. + +### Fixed + +- Replace `getState` action in `NotificationServicesControllerActions` with correctly-defined `NotificationServicesControllerGetStateAction` type ([#4633](https://github.com/MetaMask/core/pull/4633)) +- **BREAKING:** Fix package-level export for `NotificationServicesPushController` from "NotificationsServicesPushController" to "NotificationsServicesPushController" ([#4641](https://github.com/MetaMask/core/pull/4641)) +- **BREAKING:** Replace incorrectly-defined `getState` action in the `Actions` type for `NotificationServicesPushControllerMessenger` with new `NotificationServicesPushControllerGetStateAction` type ([#4641](https://github.com/MetaMask/core/pull/4641)) +- update subpath exports internal `package.json` files to resolve `jest-haste-map` errors ([#4650](https://github.com/MetaMask/core/pull/4650)) +- removed unnecessary subpath exports ([#4650](https://github.com/MetaMask/core/pull/4650)) + - removed `/constants`, `/services`, `/processors`, `/utils` sub paths as these are navigable from root. + +## [0.2.1] + +### Added + +- new controller events when notifications list is updated or notifications are read ([#4573](https://github.com/MetaMask/core/pull/4573)) +- unlock checks for when controller methods are called ([#4569](https://github.com/MetaMask/core/pull/4569)) + +### Changed + +- updated controller event type names ([#4592](https://github.com/MetaMask/core/pull/4592)) +- Bump `typescript` from `~5.0.4` to `~5.1.6` ([#4576](https://github.com/MetaMask/core/pull/4576)) + +## [0.2.0] + +### Added + +- Add and export type `BlockExplorerConfig` and object `SUPPORTED_NOTIFICATION_BLOCK_EXPLORERS`, which is a collection of block explorers for chains on which notifications are supported ([#4552](https://github.com/MetaMask/core/pull/4552)) + +### Changed + +- **BREAKING:** Bump peerDependency `@metamask/profile-sync-controller` from `^0.1.4` to `^0.2.0` ([#4548](https://github.com/MetaMask/core/pull/4548)) +- Remove `@metamask/keyring-controller` and `@metamask/profile-sync-controller` dependencies [#4556](https://github.com/MetaMask/core/pull/4556) + - These were listed under `peerDependencies` already, so they were redundant as dependencies. +- Upgrade TypeScript version to `~5.0.4` and set `moduleResolution` option to `Node16` ([#3645](https://github.com/MetaMask/core/pull/3645)) +- Bump `@metamask/base-controller` from `^6.0.1` to `^6.0.2` ([#4544](https://github.com/MetaMask/core/pull/4544)) +- Bump `@metamask/controller-utils` from `^11.0.1` to `^11.0.2` ([#4544](https://github.com/MetaMask/core/pull/4544)) + +## [0.1.2] + +### Added + +- added catch statements in NotificationServicesController to silently fail push notifications ([#4536](https://github.com/MetaMask/core/pull/4536)) +- added checks to see feature announcement environments before fetching announcements ([#4530](https://github.com/MetaMask/core/pull/4530)) + +### Removed + +- removed retries when fetching announcements and wallet notifications. Clients are to handle retries now. ([#4531](https://github.com/MetaMask/core/pull/4531)) + +## [0.1.1] + +### Added + +- export `defaultState` for `NotificationServicesController` and `NotificationServicesPushController`. ([#4441](https://github.com/MetaMask/core/pull/4441)) +- export `NOTIFICATION_CHAINS_ID` which is a const-asserted version of `NOTIFICATION_CHAINS` ([#4441](https://github.com/MetaMask/core/pull/4441)) +- export `NOTIFICATION_NETWORK_CURRENCY_NAME` and `NOTIFICATION_NETWORK_CURRENCY_SYMBOL`. Allows consistent currency names and symbols for supported notification services ([#4441](https://github.com/MetaMask/core/pull/4441)) +- add `isPushIntegrated` as an optional env property in the `NotificationServicesController` constructor (defaults to true) ([#4441](https://github.com/MetaMask/core/pull/4441)) + +### Fixed + +- `NotificationServicesPushController` - removed global `self` calls for mobile compatibility ([#4441](https://github.com/MetaMask/core/pull/4441)) + +## [0.1.0] + +### Added + +- Initial release + +[Unreleased]: https://github.com/MetaMask/core/compare/@metamask/notification-services-controller@26.0.1...HEAD +[26.0.1]: https://github.com/MetaMask/core/compare/@metamask/notification-services-controller@26.0.0...@metamask/notification-services-controller@26.0.1 +[26.0.0]: https://github.com/MetaMask/core/compare/@metamask/notification-services-controller@25.0.0...@metamask/notification-services-controller@26.0.0 +[25.0.0]: https://github.com/MetaMask/core/compare/@metamask/notification-services-controller@24.3.0...@metamask/notification-services-controller@25.0.0 +[24.3.0]: https://github.com/MetaMask/core/compare/@metamask/notification-services-controller@24.2.0...@metamask/notification-services-controller@24.3.0 +[24.2.0]: https://github.com/MetaMask/core/compare/@metamask/notification-services-controller@24.1.3...@metamask/notification-services-controller@24.2.0 +[24.1.3]: https://github.com/MetaMask/core/compare/@metamask/notification-services-controller@24.1.2...@metamask/notification-services-controller@24.1.3 +[24.1.2]: https://github.com/MetaMask/core/compare/@metamask/notification-services-controller@24.1.1...@metamask/notification-services-controller@24.1.2 +[24.1.1]: https://github.com/MetaMask/core/compare/@metamask/notification-services-controller@24.1.0...@metamask/notification-services-controller@24.1.1 +[24.1.0]: https://github.com/MetaMask/core/compare/@metamask/notification-services-controller@24.0.0...@metamask/notification-services-controller@24.1.0 +[24.0.0]: https://github.com/MetaMask/core/compare/@metamask/notification-services-controller@23.1.1...@metamask/notification-services-controller@24.0.0 +[23.1.1]: https://github.com/MetaMask/core/compare/@metamask/notification-services-controller@23.1.0...@metamask/notification-services-controller@23.1.1 +[23.1.0]: https://github.com/MetaMask/core/compare/@metamask/notification-services-controller@23.0.1...@metamask/notification-services-controller@23.1.0 +[23.0.1]: https://github.com/MetaMask/core/compare/@metamask/notification-services-controller@23.0.0...@metamask/notification-services-controller@23.0.1 +[23.0.0]: https://github.com/MetaMask/core/compare/@metamask/notification-services-controller@22.1.0...@metamask/notification-services-controller@23.0.0 +[22.1.0]: https://github.com/MetaMask/core/compare/@metamask/notification-services-controller@22.0.0...@metamask/notification-services-controller@22.1.0 +[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 +[18.3.1]: https://github.com/MetaMask/core/compare/@metamask/notification-services-controller@18.3.0...@metamask/notification-services-controller@18.3.1 +[18.3.0]: https://github.com/MetaMask/core/compare/@metamask/notification-services-controller@18.2.0...@metamask/notification-services-controller@18.3.0 +[18.2.0]: https://github.com/MetaMask/core/compare/@metamask/notification-services-controller@18.1.0...@metamask/notification-services-controller@18.2.0 +[18.1.0]: https://github.com/MetaMask/core/compare/@metamask/notification-services-controller@18.0.0...@metamask/notification-services-controller@18.1.0 +[18.0.0]: https://github.com/MetaMask/core/compare/@metamask/notification-services-controller@17.0.0...@metamask/notification-services-controller@18.0.0 +[17.0.0]: https://github.com/MetaMask/core/compare/@metamask/notification-services-controller@16.0.0...@metamask/notification-services-controller@17.0.0 +[16.0.0]: https://github.com/MetaMask/core/compare/@metamask/notification-services-controller@15.0.0...@metamask/notification-services-controller@16.0.0 +[15.0.0]: https://github.com/MetaMask/core/compare/@metamask/notification-services-controller@14.0.0...@metamask/notification-services-controller@15.0.0 +[14.0.0]: https://github.com/MetaMask/core/compare/@metamask/notification-services-controller@13.0.0...@metamask/notification-services-controller@14.0.0 +[13.0.0]: https://github.com/MetaMask/core/compare/@metamask/notification-services-controller@12.0.1...@metamask/notification-services-controller@13.0.0 +[12.0.1]: https://github.com/MetaMask/core/compare/@metamask/notification-services-controller@12.0.0...@metamask/notification-services-controller@12.0.1 +[12.0.0]: https://github.com/MetaMask/core/compare/@metamask/notification-services-controller@11.0.0...@metamask/notification-services-controller@12.0.0 +[11.0.0]: https://github.com/MetaMask/core/compare/@metamask/notification-services-controller@10.0.0...@metamask/notification-services-controller@11.0.0 +[10.0.0]: https://github.com/MetaMask/core/compare/@metamask/notification-services-controller@9.0.0...@metamask/notification-services-controller@10.0.0 +[9.0.0]: https://github.com/MetaMask/core/compare/@metamask/notification-services-controller@8.0.0...@metamask/notification-services-controller@9.0.0 +[8.0.0]: https://github.com/MetaMask/core/compare/@metamask/notification-services-controller@7.0.0...@metamask/notification-services-controller@8.0.0 +[7.0.0]: https://github.com/MetaMask/core/compare/@metamask/notification-services-controller@6.0.1...@metamask/notification-services-controller@7.0.0 +[6.0.1]: https://github.com/MetaMask/core/compare/@metamask/notification-services-controller@6.0.0...@metamask/notification-services-controller@6.0.1 +[6.0.0]: https://github.com/MetaMask/core/compare/@metamask/notification-services-controller@5.0.1...@metamask/notification-services-controller@6.0.0 +[5.0.1]: https://github.com/MetaMask/core/compare/@metamask/notification-services-controller@5.0.0...@metamask/notification-services-controller@5.0.1 +[5.0.0]: https://github.com/MetaMask/core/compare/@metamask/notification-services-controller@4.0.0...@metamask/notification-services-controller@5.0.0 +[4.0.0]: https://github.com/MetaMask/core/compare/@metamask/notification-services-controller@3.0.0...@metamask/notification-services-controller@4.0.0 +[3.0.0]: https://github.com/MetaMask/core/compare/@metamask/notification-services-controller@2.0.0...@metamask/notification-services-controller@3.0.0 +[2.0.0]: https://github.com/MetaMask/core/compare/@metamask/notification-services-controller@1.0.0...@metamask/notification-services-controller@2.0.0 +[1.0.0]: https://github.com/MetaMask/core/compare/@metamask/notification-services-controller@0.21.0...@metamask/notification-services-controller@1.0.0 +[0.21.0]: https://github.com/MetaMask/core/compare/@metamask/notification-services-controller@0.20.1...@metamask/notification-services-controller@0.21.0 +[0.20.1]: https://github.com/MetaMask/core/compare/@metamask/notification-services-controller@0.20.0...@metamask/notification-services-controller@0.20.1 +[0.20.0]: https://github.com/MetaMask/core/compare/@metamask/notification-services-controller@0.19.0...@metamask/notification-services-controller@0.20.0 +[0.19.0]: https://github.com/MetaMask/core/compare/@metamask/notification-services-controller@0.18.0...@metamask/notification-services-controller@0.19.0 +[0.18.0]: https://github.com/MetaMask/core/compare/@metamask/notification-services-controller@0.17.0...@metamask/notification-services-controller@0.18.0 +[0.17.0]: https://github.com/MetaMask/core/compare/@metamask/notification-services-controller@0.16.0...@metamask/notification-services-controller@0.17.0 +[0.16.0]: https://github.com/MetaMask/core/compare/@metamask/notification-services-controller@0.15.0...@metamask/notification-services-controller@0.16.0 +[0.15.0]: https://github.com/MetaMask/core/compare/@metamask/notification-services-controller@0.14.0...@metamask/notification-services-controller@0.15.0 +[0.14.0]: https://github.com/MetaMask/core/compare/@metamask/notification-services-controller@0.13.0...@metamask/notification-services-controller@0.14.0 +[0.13.0]: https://github.com/MetaMask/core/compare/@metamask/notification-services-controller@0.12.1...@metamask/notification-services-controller@0.13.0 +[0.12.1]: https://github.com/MetaMask/core/compare/@metamask/notification-services-controller@0.12.0...@metamask/notification-services-controller@0.12.1 +[0.12.0]: https://github.com/MetaMask/core/compare/@metamask/notification-services-controller@0.11.0...@metamask/notification-services-controller@0.12.0 +[0.11.0]: https://github.com/MetaMask/core/compare/@metamask/notification-services-controller@0.10.0...@metamask/notification-services-controller@0.11.0 +[0.10.0]: https://github.com/MetaMask/core/compare/@metamask/notification-services-controller@0.9.0...@metamask/notification-services-controller@0.10.0 +[0.9.0]: https://github.com/MetaMask/core/compare/@metamask/notification-services-controller@0.8.2...@metamask/notification-services-controller@0.9.0 +[0.8.2]: https://github.com/MetaMask/core/compare/@metamask/notification-services-controller@0.8.1...@metamask/notification-services-controller@0.8.2 +[0.8.1]: https://github.com/MetaMask/core/compare/@metamask/notification-services-controller@0.8.0...@metamask/notification-services-controller@0.8.1 +[0.8.0]: https://github.com/MetaMask/core/compare/@metamask/notification-services-controller@0.7.0...@metamask/notification-services-controller@0.8.0 +[0.7.0]: https://github.com/MetaMask/core/compare/@metamask/notification-services-controller@0.6.0...@metamask/notification-services-controller@0.7.0 +[0.6.0]: https://github.com/MetaMask/core/compare/@metamask/notification-services-controller@0.5.1...@metamask/notification-services-controller@0.6.0 +[0.5.1]: https://github.com/MetaMask/core/compare/@metamask/notification-services-controller@0.5.0...@metamask/notification-services-controller@0.5.1 +[0.5.0]: https://github.com/MetaMask/core/compare/@metamask/notification-services-controller@0.4.1...@metamask/notification-services-controller@0.5.0 +[0.4.1]: https://github.com/MetaMask/core/compare/@metamask/notification-services-controller@0.4.0...@metamask/notification-services-controller@0.4.1 +[0.4.0]: https://github.com/MetaMask/core/compare/@metamask/notification-services-controller@0.3.0...@metamask/notification-services-controller@0.4.0 +[0.3.0]: https://github.com/MetaMask/core/compare/@metamask/notification-services-controller@0.2.1...@metamask/notification-services-controller@0.3.0 +[0.2.1]: https://github.com/MetaMask/core/compare/@metamask/notification-services-controller@0.2.0...@metamask/notification-services-controller@0.2.1 +[0.2.0]: https://github.com/MetaMask/core/compare/@metamask/notification-services-controller@0.1.2...@metamask/notification-services-controller@0.2.0 +[0.1.2]: https://github.com/MetaMask/core/compare/@metamask/notification-services-controller@0.1.1...@metamask/notification-services-controller@0.1.2 +[0.1.1]: https://github.com/MetaMask/core/compare/@metamask/notification-services-controller@0.1.0...@metamask/notification-services-controller@0.1.1 +[0.1.0]: https://github.com/MetaMask/core/releases/tag/@metamask/notification-services-controller@0.1.0 diff --git a/packages/notification-services-controller/LICENSE b/packages/notification-services-controller/LICENSE new file mode 100644 index 00000000000..37484ffd950 --- /dev/null +++ b/packages/notification-services-controller/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2024 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/notification-services-controller/README.md b/packages/notification-services-controller/README.md new file mode 100644 index 00000000000..ef03fb43050 --- /dev/null +++ b/packages/notification-services-controller/README.md @@ -0,0 +1,49 @@ +# `@metamask/notification-services-controller` + +Manages the notification and push notification services used in MetaMask. This includes: + +- Wallet Notifications +- Feature Announcements +- Snap Notifications + +## Installation + +`yarn add @metamask/notification-services-controller` + +or + +`npm install @metamask/notification-services-controller` + +## Usage + +This package uses subpath exports, which helps to minimize the amount of code you need to import. It also helps to keep specific modules isolated and can be used to import specific code (e.g., mocks or platform-specific code). You can see all the exports in the [`package.json`](./package.json), but here are a few examples: + +Importing specific controllers/modules: + +```ts +// Import the NotificationServicesController and its associated types/utilities. +import { ... } from '@metamask/notification-services-controller/notification-services' + +// Import the NotificationServicesPushController and its associated types/utilities. +import { ... } from '@metamask/notification-services-controller/push-services' +``` + +Importing mock creation functions: + +```ts +// Import and use mock creation functions (designed to mirror the actual types). +// Useful for testing or Storybook development. +import { ... } from '@metamask/notification-services-controller/notification-services/mocks' +import { ... } from '@metamask/notification-services-controller/push-services/mocks' +``` + +Importing platform specific code: + +```ts +// Some controllers provide interfaces for injecting platform-specific code, tailored to different clients (e.g., web or mobile). +import { ... } from '@metamask/notification-services-controller/push-services/web' +``` + +## 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/notification-services-controller/jest.config.js b/packages/notification-services-controller/jest.config.js new file mode 100644 index 00000000000..24f98707e42 --- /dev/null +++ b/packages/notification-services-controller/jest.config.js @@ -0,0 +1,36 @@ +/* + * 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: 80.88, + functions: 92.42, + lines: 92.93, + statements: 93.11, + }, + }, + + coveragePathIgnorePatterns: [ + ...baseConfig.coveragePathIgnorePatterns, + '/__fixtures__/', + '/mocks/', + 'index.ts', + ], + + // These tests rely on the Crypto API + testEnvironment: '/jest.environment.js', +}); diff --git a/packages/notification-services-controller/jest.environment.js b/packages/notification-services-controller/jest.environment.js new file mode 100644 index 00000000000..f67bb89df5d --- /dev/null +++ b/packages/notification-services-controller/jest.environment.js @@ -0,0 +1,29 @@ +const { TestEnvironment } = require('jest-environment-jsdom'); + +/** + * ProfileSync SDK & Controllers depends on @noble/hashes, which as of 1.3.2 relies on the + * Web Crypto API in Node and browsers. + * + * There are also EIP6963 utils that utilize window + */ +class CustomTestEnvironment extends TestEnvironment { + async setup() { + await super.setup(); + + // jest runs in a node environment, so need to polyfil webAPIs + // eslint-disable-next-line no-shadow, n/prefer-global/text-encoder, n/prefer-global/text-decoder + const { TextEncoder, TextDecoder } = require('util'); + this.global.TextEncoder = TextEncoder; + this.global.TextDecoder = TextDecoder; + this.global.ArrayBuffer = ArrayBuffer; + this.global.Uint8Array = Uint8Array; + + if (typeof this.global.crypto === 'undefined') { + // jest runs in a node environment, so need to polyfil webAPIs + // eslint-disable-next-line n/no-unsupported-features/node-builtins + this.global.crypto = require('crypto').webcrypto; + } + } +} + +module.exports = CustomTestEnvironment; diff --git a/packages/notification-services-controller/notification-services/mocks/package.json b/packages/notification-services-controller/notification-services/mocks/package.json new file mode 100644 index 00000000000..1e3f4b9ec18 --- /dev/null +++ b/packages/notification-services-controller/notification-services/mocks/package.json @@ -0,0 +1,9 @@ +{ + "version": "1.0.0", + "private": true, + "description": "", + "license": "MIT", + "sideEffects": false, + "main": "../../dist/NotificationServicesController/mocks/index.cjs", + "types": "../../dist/NotificationServicesController/mocks/index.d.cts" +} diff --git a/packages/notification-services-controller/notification-services/package.json b/packages/notification-services-controller/notification-services/package.json new file mode 100644 index 00000000000..929af09dc87 --- /dev/null +++ b/packages/notification-services-controller/notification-services/package.json @@ -0,0 +1,9 @@ +{ + "version": "1.0.0", + "private": true, + "description": "", + "license": "MIT", + "sideEffects": false, + "main": "../dist/NotificationServicesController/index.cjs", + "types": "../dist/NotificationServicesController/index.d.cts" +} diff --git a/packages/notification-services-controller/package.json b/packages/notification-services-controller/package.json new file mode 100644 index 00000000000..7063a96b1d4 --- /dev/null +++ b/packages/notification-services-controller/package.json @@ -0,0 +1,148 @@ +{ + "name": "@metamask/notification-services-controller", + "version": "26.0.1", + "description": "Manages New MetaMask decentralized Notification system", + "keywords": [ + "Ethereum", + "MetaMask" + ], + "homepage": "https://github.com/MetaMask/core/tree/main/packages/notification-services-controller#readme", + "bugs": { + "url": "https://github.com/MetaMask/core/issues" + }, + "license": "MIT", + "repository": { + "type": "git", + "url": "https://github.com/MetaMask/core.git" + }, + "files": [ + "dist/", + "notification-services/", + "push-services/" + ], + "sideEffects": false, + "main": "./dist/index.cjs", + "types": "./dist/index.d.cts", + "exports": { + ".": { + "import": { + "types": "./dist/index.d.mts", + "default": "./dist/index.mjs" + }, + "require": { + "types": "./dist/index.d.cts", + "default": "./dist/index.cjs" + } + }, + "./notification-services": { + "import": { + "types": "./dist/NotificationServicesController/index.d.mts", + "default": "./dist/NotificationServicesController/index.mjs" + }, + "require": { + "types": "./dist/NotificationServicesController/index.d.cts", + "default": "./dist/NotificationServicesController/index.cjs" + } + }, + "./notification-services/mocks": { + "import": { + "types": "./dist/NotificationServicesController/mocks/index.d.mts", + "default": "./dist/NotificationServicesController/mocks/index.mjs" + }, + "require": { + "types": "./dist/NotificationServicesController/mocks/index.d.cts", + "default": "./dist/NotificationServicesController/mocks/index.cjs" + } + }, + "./push-services": { + "import": { + "types": "./dist/NotificationServicesPushController/index.d.mts", + "default": "./dist/NotificationServicesPushController/index.mjs" + }, + "require": { + "types": "./dist/NotificationServicesPushController/index.d.cts", + "default": "./dist/NotificationServicesPushController/index.cjs" + } + }, + "./push-services/web": { + "import": { + "types": "./dist/NotificationServicesPushController/web/index.d.mts", + "default": "./dist/NotificationServicesPushController/web/index.mjs" + }, + "require": { + "types": "./dist/NotificationServicesPushController/web/index.d.cts", + "default": "./dist/NotificationServicesPushController/web/index.cjs" + } + }, + "./push-services/mocks": { + "import": { + "types": "./dist/NotificationServicesPushController/mocks/index.d.mts", + "default": "./dist/NotificationServicesPushController/mocks/index.mjs" + }, + "require": { + "types": "./dist/NotificationServicesPushController/mocks/index.d.cts", + "default": "./dist/NotificationServicesPushController/mocks/index.cjs" + } + }, + "./package.json": "./package.json" + }, + "publishConfig": { + "access": "public", + "registry": "https://registry.npmjs.org/" + }, + "scripts": { + "build": "ts-bridge --project tsconfig.build.json --verbose --clean --no-references", + "build:all": "ts-bridge --project tsconfig.build.json --verbose --clean", + "build:docs": "typedoc", + "changelog:update": "../../scripts/update-changelog.sh @metamask/notification-services-controller", + "changelog:validate": "../../scripts/validate-changelog.sh @metamask/notification-services-controller", + "lint:tsconfigs": "tsx ../../scripts/lint-tsconfigs/lint-tsconfigs.mts", + "lint:tsconfigs:fix": "tsx ../../scripts/lint-tsconfigs/lint-tsconfigs.mts --fix", + "messenger-action-types:check": "tsx ../../packages/messenger-cli/src/cli.ts --formatter oxfmt --esm --check", + "messenger-action-types:generate": "tsx ../../packages/messenger-cli/src/cli.ts --formatter oxfmt --esm --generate", + "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": { + "@contentful/rich-text-html-renderer": "^16.5.2", + "@metamask/authenticated-user-storage": "^3.0.2", + "@metamask/base-controller": "^9.1.0", + "@metamask/controller-utils": "^12.3.0", + "@metamask/keyring-controller": "^27.1.1", + "@metamask/messenger": "^2.0.0", + "@metamask/profile-sync-controller": "^29.0.0", + "@metamask/utils": "^11.11.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" + }, + "devDependencies": { + "@lavamoat/allow-scripts": "^3.0.4", + "@lavamoat/preinstall-always-fail": "^2.1.0", + "@metamask/auto-changelog": "^6.1.0", + "@ts-bridge/cli": "^0.6.4", + "@types/jest": "^30.0.0", + "@types/lodash": "^4.14.191", + "@types/readable-stream": "^2.3.0", + "@types/semver": "^7", + "contentful": "^10.15.0", + "deepmerge": "^4.2.2", + "jest": "^30.4.2", + "jest-environment-jsdom": "^30.4.1", + "nock": "^13.3.1", + "ts-jest": "^29.4.11", + "tsx": "^4.20.5", + "typedoc": "^0.25.13", + "typedoc-plugin-missing-exports": "^2.0.0", + "typescript": "~5.3.3" + }, + "engines": { + "node": "^18.18 || >=20" + } +} diff --git a/packages/notification-services-controller/push-services/mocks/package.json b/packages/notification-services-controller/push-services/mocks/package.json new file mode 100644 index 00000000000..240fbd60a70 --- /dev/null +++ b/packages/notification-services-controller/push-services/mocks/package.json @@ -0,0 +1,9 @@ +{ + "version": "1.0.0", + "private": true, + "description": "", + "license": "MIT", + "sideEffects": false, + "main": "../../dist/NotificationServicesPushController/mocks/index.cjs", + "types": "../../dist/NotificationServicesPushController/mocks/index.d.cts" +} diff --git a/packages/notification-services-controller/push-services/package.json b/packages/notification-services-controller/push-services/package.json new file mode 100644 index 00000000000..c3c2fa9aee6 --- /dev/null +++ b/packages/notification-services-controller/push-services/package.json @@ -0,0 +1,9 @@ +{ + "version": "1.0.0", + "private": true, + "description": "", + "license": "MIT", + "sideEffects": false, + "main": "../dist/NotificationServicesPushController/index.cjs", + "types": "../dist/NotificationServicesPushController/index.d.cts" +} diff --git a/packages/notification-services-controller/push-services/web/package.json b/packages/notification-services-controller/push-services/web/package.json new file mode 100644 index 00000000000..426491b59ac --- /dev/null +++ b/packages/notification-services-controller/push-services/web/package.json @@ -0,0 +1,9 @@ +{ + "version": "1.0.0", + "private": true, + "description": "", + "license": "MIT", + "sideEffects": false, + "main": "../../dist/NotificationServicesPushController/web/index.cjs", + "types": "../../dist/NotificationServicesPushController/web/index.d.cts" +} diff --git a/packages/notification-services-controller/src/NotificationServicesController/NotificationServicesController-method-action-types.ts b/packages/notification-services-controller/src/NotificationServicesController/NotificationServicesController-method-action-types.ts new file mode 100644 index 00000000000..c4f85cec6cb --- /dev/null +++ b/packages/notification-services-controller/src/NotificationServicesController/NotificationServicesController-method-action-types.ts @@ -0,0 +1,246 @@ +/** + * This file is auto generated. + * Do not edit manually. + */ + +import type { NotificationServicesController } from './NotificationServicesController.js'; + +export type NotificationServicesControllerInitAction = { + type: `NotificationServicesController:init`; + handler: NotificationServicesController['init']; +}; + +/** + * Public method to expose enabling push notifications + */ +export type NotificationServicesControllerEnablePushNotificationsAction = { + type: `NotificationServicesController:enablePushNotifications`; + handler: NotificationServicesController['enablePushNotifications']; +}; + +/** + * Public method to expose disabling push notifications + */ +export type NotificationServicesControllerDisablePushNotificationsAction = { + type: `NotificationServicesController:disablePushNotifications`; + handler: NotificationServicesController['disablePushNotifications']; +}; + +export type NotificationServicesControllerCheckAccountsPresenceAction = { + type: `NotificationServicesController:checkAccountsPresence`; + handler: NotificationServicesController['checkAccountsPresence']; +}; + +/** + * Sets the enabled state of feature announcements. + * + * **Action** - used in the notification settings to enable/disable feature announcements. + * + * @param featureAnnouncementsEnabled - A boolean value indicating the desired enabled state of the feature announcements. + * @async + * @throws {Error} If fails to update + */ +export type NotificationServicesControllerSetFeatureAnnouncementsEnabledAction = + { + type: `NotificationServicesController:setFeatureAnnouncementsEnabled`; + handler: NotificationServicesController['setFeatureAnnouncementsEnabled']; + }; + +/** + * This creates/re-creates on-chain triggers defined in User Storage. + * + * **Action** - Used during Sign In / Enabling of notifications. + * + * Notification preferences are initialized only when + * {@link AuthenticatedUserStorageService} has no stored preferences yet. + * Existing preferences are left as-is. + * + * @param opts - optional options to mutate this functionality + * @param opts.hasMarketingConsent - The user's marketing-consent flag. + * Used only during initialization to seed marketing push notifications. + * @param opts.productAnnouncementEnabled - The user's product-announcement flag. + * Used only during initialization to seed marketing in-app notifications. + * @param opts.registerPushNotifications - Whether to attempt FCM/device push registration. + * @returns The updated or newly created user storage. + * @throws {Error} Throws an error if unauthenticated or from other operations. + */ +export type NotificationServicesControllerCreateOnChainTriggersAction = { + type: `NotificationServicesController:createOnChainTriggers`; + handler: NotificationServicesController['createOnChainTriggers']; +}; + +/** + * Enables all MetaMask notifications for the user. + * This is identical flow when initializing notifications for the first time. + * + * @param opts - Optional options to mutate this functionality. + * @throws {Error} If there is an error during the process of enabling notifications. + */ +export type NotificationServicesControllerEnableMetamaskNotificationsAction = { + type: `NotificationServicesController:enableMetamaskNotifications`; + handler: NotificationServicesController['enableMetamaskNotifications']; +}; + +/** + * Disables all MetaMask notifications for the user. + * This method ensures that the user is authenticated, retrieves all linked accounts, + * and disables on-chain triggers for each account. It also sets the global notification + * settings for MetaMask, feature announcements to false. + * + * @throws {Error} If the user is not authenticated or if there is an error during the process. + */ +export type NotificationServicesControllerDisableNotificationServicesAction = { + type: `NotificationServicesController:disableNotificationServices`; + handler: NotificationServicesController['disableNotificationServices']; +}; + +/** + * Deletes on-chain triggers associated with a specific account/s. + * This method performs several key operations: + * 1. Validates Auth + * 2. Deletes accounts + * (note) We do not need to look through push notifications as we've deleted triggers + * + * **Action** - When a user disables notifications for a given account in settings. + * + * @param accounts - The account for which on-chain triggers are to be deleted. + * @returns A promise that resolves to void or an object containing a success message. + * @throws {Error} Throws an error if unauthenticated or from other operations. + */ +export type NotificationServicesControllerDisableAccountsAction = { + type: `NotificationServicesController:disableAccounts`; + handler: NotificationServicesController['disableAccounts']; +}; + +/** + * Updates/Creates on-chain triggers for a specific account. + * + * This method performs several key operations: + * 1. Validates Auth & Storage + * 2. Finds and creates any missing triggers associated with the account + * 3. Enables any related push notifications + * 4. Updates Storage to reflect new state. + * + * **Action** - When a user enables notifications for an account + * + * @param accounts - List of accounts you want to update. + * @returns A promise that resolves to the updated user storage. + * @throws {Error} Throws an error if unauthenticated or from other operations. + */ +export type NotificationServicesControllerEnableAccountsAction = { + type: `NotificationServicesController:enableAccounts`; + handler: NotificationServicesController['enableAccounts']; +}; + +/** + * Fetches the list of metamask notifications. + * This includes OnChain notifications; Feature Announcements; and Snap Notifications. + * + * **Action** - When a user views the notification list page/dropdown + * + * @param previewToken - the preview token to use if needed + * @returns A promise that resolves to the list of notifications. + * @throws {Error} Throws an error if unauthenticated or from other operations. + */ +export type NotificationServicesControllerFetchAndUpdateMetamaskNotificationsAction = + { + type: `NotificationServicesController:fetchAndUpdateMetamaskNotifications`; + handler: NotificationServicesController['fetchAndUpdateMetamaskNotifications']; + }; + +/** + * Gets the specified type of notifications from state. + * + * @param type - The trigger type. + * @returns An array of notifications of the passed in type. + * @throws Throws an error if an invalid trigger type is passed. + */ +export type NotificationServicesControllerGetNotificationsByTypeAction = { + type: `NotificationServicesController:getNotificationsByType`; + handler: NotificationServicesController['getNotificationsByType']; +}; + +/** + * Used to delete a notification by id. + * + * Note: This function should only be used for notifications that are stored + * in this controller directly, currently only snaps notifications. + * + * @param id - The id of the notification to delete. + */ +export type NotificationServicesControllerDeleteNotificationByIdAction = { + type: `NotificationServicesController:deleteNotificationById`; + handler: NotificationServicesController['deleteNotificationById']; +}; + +/** + * Used to batch delete notifications by id. + * + * Note: This function should only be used for notifications that are stored + * in this controller directly, currently only snaps notifications. + * + * @param ids - The ids of the notifications to delete. + */ +export type NotificationServicesControllerDeleteNotificationsByIdAction = { + type: `NotificationServicesController:deleteNotificationsById`; + handler: NotificationServicesController['deleteNotificationsById']; +}; + +/** + * Marks specified metamask notifications as read. + * + * @param notifications - An array of notifications to be marked as read. Each notification should include its type and read status. + * @returns A promise that resolves when the operation is complete. + */ +export type NotificationServicesControllerMarkMetamaskNotificationsAsReadAction = + { + type: `NotificationServicesController:markMetamaskNotificationsAsRead`; + handler: NotificationServicesController['markMetamaskNotificationsAsRead']; + }; + +/** + * Updates the list of MetaMask notifications by adding a new notification at the beginning of the list. + * This method ensures that the most recent notification is displayed first in the UI. + * + * @param notification - The new notification object to be added to the list. + * @returns A promise that resolves when the notification list has been successfully updated. + */ +export type NotificationServicesControllerUpdateMetamaskNotificationsListAction = + { + type: `NotificationServicesController:updateMetamaskNotificationsList`; + handler: NotificationServicesController['updateMetamaskNotificationsList']; + }; + +/** + * Creates an perp order notification subscription. + * Requires notifications and auth to be enabled to start receiving this notifications + * + * @param input perp input + */ +export type NotificationServicesControllerSendPerpPlaceOrderNotificationAction = + { + type: `NotificationServicesController:sendPerpPlaceOrderNotification`; + handler: NotificationServicesController['sendPerpPlaceOrderNotification']; + }; + +/** + * Union of all NotificationServicesController action types. + */ +export type NotificationServicesControllerMethodActions = + | NotificationServicesControllerInitAction + | NotificationServicesControllerEnablePushNotificationsAction + | NotificationServicesControllerDisablePushNotificationsAction + | NotificationServicesControllerCheckAccountsPresenceAction + | NotificationServicesControllerSetFeatureAnnouncementsEnabledAction + | NotificationServicesControllerCreateOnChainTriggersAction + | NotificationServicesControllerEnableMetamaskNotificationsAction + | NotificationServicesControllerDisableNotificationServicesAction + | NotificationServicesControllerDisableAccountsAction + | NotificationServicesControllerEnableAccountsAction + | NotificationServicesControllerFetchAndUpdateMetamaskNotificationsAction + | NotificationServicesControllerGetNotificationsByTypeAction + | NotificationServicesControllerDeleteNotificationByIdAction + | NotificationServicesControllerDeleteNotificationsByIdAction + | NotificationServicesControllerMarkMetamaskNotificationsAsReadAction + | NotificationServicesControllerUpdateMetamaskNotificationsListAction + | NotificationServicesControllerSendPerpPlaceOrderNotificationAction; diff --git a/packages/notification-services-controller/src/NotificationServicesController/NotificationServicesController.test.ts b/packages/notification-services-controller/src/NotificationServicesController/NotificationServicesController.test.ts new file mode 100644 index 00000000000..86f39be5c25 --- /dev/null +++ b/packages/notification-services-controller/src/NotificationServicesController/NotificationServicesController.test.ts @@ -0,0 +1,2207 @@ +import type { + AuthenticatedUserStorageServiceGetNotificationPreferencesAction, + AuthenticatedUserStorageServicePutNotificationPreferencesAction, + NotificationPreferences, +} from '@metamask/authenticated-user-storage'; +import { deriveStateFromMetadata } from '@metamask/base-controller'; +import * as ControllerUtils from '@metamask/controller-utils'; +import { KeyringTypes } from '@metamask/keyring-controller'; +import type { + KeyringControllerGetStateAction, + KeyringControllerState, +} from '@metamask/keyring-controller'; +import { Messenger, MOCK_ANY_NAMESPACE } from '@metamask/messenger'; +import type { + MessengerActions, + MessengerEvents, + MockAnyNamespace, +} from '@metamask/messenger'; +import { AuthenticationController } from '@metamask/profile-sync-controller'; +import log from 'loglevel'; +import type nock from 'nock'; + +import type { + NotificationServicesPushControllerAddPushNotificationLinksAction, + NotificationServicesPushControllerDisablePushNotificationsAction, + NotificationServicesPushControllerDeletePushNotificationLinksAction, + NotificationServicesPushControllerEnablePushNotificationsAction, + NotificationServicesPushControllerSubscribeToPushNotificationsAction, +} from '../NotificationServicesPushController/index.js'; +import { + ADDRESS_1, + ADDRESS_2, + ADDRESS_3, +} from './__fixtures__/mockAddresses.js'; +import { + mockGetOnChainNotificationsConfig, + mockGetAPINotifications, + mockFetchFeatureAnnouncementNotifications, + mockMarkNotificationsAsRead, + mockCreatePerpNotification, +} from './__fixtures__/mockServices.js'; +import { waitFor } from './__fixtures__/test-utils.js'; +import { TRIGGER_TYPES } from './constants/index.js'; +import { createMockSnapNotification } from './mocks/index.js'; +import { + createMockFeatureAnnouncementAPIResult, + createMockFeatureAnnouncementRaw, +} from './mocks/mock-feature-announcements.js'; +import { createMockNotificationEthSent } from './mocks/mock-raw-notifications.js'; +import { + DEFAULT_AGENTIC_CLI_PREFERENCES, + DEFAULT_PERPS_PREFERENCES, + DEFAULT_PRICE_ALERT_PREFERENCES, + DEFAULT_SOCIAL_AI_PREFERENCES, + NotificationServicesController, + ACCOUNTS_UPDATE_DEBOUNCE_TIME_MS, + defaultState, +} from './NotificationServicesController.js'; +import type { + NotificationServicesControllerMessenger, + NotificationServicesControllerState, +} from './NotificationServicesController.js'; +import { processFeatureAnnouncement } from './processors/index.js'; +import { processNotification } from './processors/process-notifications.js'; +import { processSnapNotification } from './processors/process-snap-notifications.js'; +import { notificationsConfigCache } from './services/notification-config-cache.js'; +import type { INotification, OrderInput } from './types/index.js'; + +// Mock type used for testing purposes +// eslint-disable-next-line @typescript-eslint/no-explicit-any +type MockVar = any; + +const featureAnnouncementsEnv = { + spaceId: ':space_id', + accessToken: ':access_token', + platform: 'extension' as const, +}; + +// Testing util to clean up verbose logs when testing errors +const mockErrorLog = (): jest.SpyInstance => + jest.spyOn(log, 'error').mockImplementation(jest.fn()); +const mockWarnLog = (): jest.SpyInstance => + jest.spyOn(log, 'warn').mockImplementation(jest.fn()); + +// Removing caches to avoid interference +const clearAPICache = (): void => { + notificationsConfigCache.clear(); +}; + +const prefsFromAddresses = ( + accounts: { address: string; enabled: boolean }[], +): NotificationPreferences => ({ + walletActivity: { + inAppNotificationsEnabled: true, + pushNotificationsEnabled: true, + accounts: accounts.map((a) => ({ + address: a.address.toLowerCase() as `0x${string}`, + enabled: a.enabled, + })), + }, + marketing: { + inAppNotificationsEnabled: false, + pushNotificationsEnabled: false, + }, + perps: { + inAppNotificationsEnabled: true, + pushNotificationsEnabled: true, + }, + socialAI: { + inAppNotificationsEnabled: true, + pushNotificationsEnabled: true, + mutedTraderProfileIds: [], + }, + agenticCli: { ...DEFAULT_AGENTIC_CLI_PREFERENCES }, + priceAlerts: { ...DEFAULT_PRICE_ALERT_PREFERENCES }, +}); + +const prefsFromAddressesWithMarketingInAppNotifications = ( + accounts: { address: string; enabled: boolean }[], + inAppNotificationsEnabled: boolean, +): NotificationPreferences => ({ + ...prefsFromAddresses(accounts), + marketing: { + inAppNotificationsEnabled, + pushNotificationsEnabled: false, + }, +}); + +describe('NotificationServicesController', () => { + afterEach(() => { + clearAPICache(); + }); + + describe('constructor', () => { + it('initializes state & override state', () => { + const controller1 = new NotificationServicesController({ + messenger: mockNotificationMessenger().messenger, + env: { featureAnnouncements: featureAnnouncementsEnv }, + }); + expect(controller1.state).toStrictEqual(defaultState); + + const controller2 = new NotificationServicesController({ + messenger: mockNotificationMessenger().messenger, + env: { featureAnnouncements: featureAnnouncementsEnv }, + state: { + ...defaultState, + isNotificationServicesEnabled: true, + }, + }); + expect(controller2.state.isNotificationServicesEnabled).toBe(true); + }); + }); + + describe('init', () => { + const arrangeMocks = (): ReturnType => { + const messengerMocks = mockNotificationMessenger(); + jest + .spyOn(ControllerUtils, 'toChecksumHexAddress') + .mockImplementation((address) => address); + + return messengerMocks; + }; + + const actPublishKeyringStateChange = async ( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + messenger: any, + accounts: string[] = ['0x111', '0x222'], + ): Promise => { + messenger.publish( + 'KeyringController:stateChange', + { + keyrings: [{ accounts }], + } as KeyringControllerState, + [], + ); + }; + + 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: [], + type: KeyringTypes.hd, + metadata: { + id: '123', + name: '', + }, + }, + ], + }); + + 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(); + + // Cleanup mocks for next act/assert + cleanup(); + }; + + return { act, actMultiple, 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, + }); + + // 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], + }); + + // 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 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(); + }); + }); + + 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 - 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(); + }, + ); + }); + }); + + const arrangeActInitialisePushNotifications = ( + modifications?: (mocks: ReturnType) => void, + ): ReturnType & { + mockAPIGetNotificationConfig: jest.Mock; + } => { + // Arrange + const mocks = arrangeMocks(); + const mockAPIGetNotificationConfig = mocks.mockGetNotificationPreferences; + modifications?.(mocks); + + // Act + const controller = new NotificationServicesController({ + messenger: mocks.messenger, + env: { featureAnnouncements: featureAnnouncementsEnv }, + state: { isNotificationServicesEnabled: true }, + }); + + controller.init(); + + return { ...mocks, mockAPIGetNotificationConfig }; + }; + + it('initialises push notifications', async () => { + const { mockEnablePushNotifications } = + arrangeActInitialisePushNotifications(); + + await waitFor(() => { + expect(mockEnablePushNotifications).toHaveBeenCalled(); + }); + }); + + it('does not initialise push notifications if the wallet is locked', async () => { + const { mockEnablePushNotifications, mockSubscribeToPushNotifications } = + arrangeActInitialisePushNotifications((mocks) => { + mocks.mockKeyringControllerGetState.mockReturnValue({ + isUnlocked: false, // Wallet Locked + } as MockVar); + }); + + await waitFor(() => { + expect(mockEnablePushNotifications).not.toHaveBeenCalled(); + }); + await waitFor(() => { + expect(mockSubscribeToPushNotifications).toHaveBeenCalled(); + }); + }); + + it('should re-initialise push notifications if wallet was locked, and then is unlocked', async () => { + // Test Wallet Lock + const { + globalMessenger, + mockEnablePushNotifications, + mockSubscribeToPushNotifications, + mockKeyringControllerGetState, + } = arrangeActInitialisePushNotifications((mocks) => { + mocks.mockKeyringControllerGetState.mockReturnValue({ + isUnlocked: false, // Wallet Locked + keyrings: [], + }); + }); + + await waitFor(() => { + expect(mockEnablePushNotifications).not.toHaveBeenCalled(); + }); + await waitFor(() => { + expect(mockSubscribeToPushNotifications).toHaveBeenCalled(); + }); + + // Test Wallet Unlock + jest.clearAllMocks(); + mockKeyringControllerGetState.mockReturnValue({ + isUnlocked: true, + keyrings: [ + { + accounts: ['0xde55a0F2591d7823486e211710f53dADdb173Cee'], + type: KeyringTypes.hd, + }, + ] as MockVar, + }); + globalMessenger.publish('KeyringController:unlock'); + await waitFor(() => { + expect(mockEnablePushNotifications).toHaveBeenCalled(); + }); + await waitFor(() => { + expect(mockSubscribeToPushNotifications).not.toHaveBeenCalled(); + }); + }); + + it('queues one re-fetch (not concurrent) when pushes arrive while a fetch is in-flight', async () => { + const mocks = arrangeMocks(); + let resolveFetch!: (v: INotification[]) => void; + const controller = new NotificationServicesController({ + messenger: mocks.messenger, + env: { featureAnnouncements: featureAnnouncementsEnv }, + state: { isNotificationServicesEnabled: true }, + }); + controller.init(); + + const fetchSpy = jest + .spyOn(controller, 'fetchAndUpdateMetamaskNotifications') + .mockImplementationOnce( + () => + new Promise((resolve) => { + resolveFetch = resolve; + }), + ) + .mockResolvedValue([]); + + // First push — starts the in-flight fetch + mocks.globalMessenger.publish( + 'NotificationServicesPushController:onNewNotifications', + [], + ); + expect(fetchSpy).toHaveBeenCalledTimes(1); + + // Second and third pushes arrive while fetch is still in-flight — both coalesced into one pending refetch + mocks.globalMessenger.publish( + 'NotificationServicesPushController:onNewNotifications', + [], + ); + mocks.globalMessenger.publish( + 'NotificationServicesPushController:onNewNotifications', + [], + ); + expect(fetchSpy).toHaveBeenCalledTimes(1); + + // Resolve the first fetch — should trigger exactly one queued re-fetch + resolveFetch([]); + await waitFor(() => { + expect(fetchSpy).toHaveBeenCalledTimes(2); + }); + }); + }); + + // See /utils for more in-depth testing + describe('checkAccountsPresence', () => { + it('returns Record with accounts that have notifications enabled', async () => { + const mocks = mockNotificationMessenger(); + mocks.mockGetNotificationPreferences.mockResolvedValueOnce( + prefsFromAddresses([ + { address: ADDRESS_1, enabled: true }, + { address: ADDRESS_2, enabled: false }, + ]), + ); + + const controller = new NotificationServicesController({ + messenger: mocks.messenger, + env: { featureAnnouncements: featureAnnouncementsEnv }, + }); + const result = await controller.checkAccountsPresence([ + ADDRESS_1, + ADDRESS_2, + ]); + + expect(mocks.mockGetNotificationPreferences).toHaveBeenCalled(); + expect(result).toStrictEqual({ + [ADDRESS_1]: true, + [ADDRESS_2]: false, + }); + }); + }); + + describe('createOnChainTriggers', () => { + const arrangeMocks = (overrides?: { + configurePrefs?: (mock: jest.Mock) => void; + }): ReturnType & { + mockGetConfig: jest.Mock; + mockUpdateNotifications: jest.Mock; + } => { + const messengerMocks = mockNotificationMessenger(); + const mockGetConfig = messengerMocks.mockGetNotificationPreferences; + const mockUpdateNotifications = + messengerMocks.mockPutNotificationPreferences; + overrides?.configurePrefs?.(mockGetConfig); + return { + ...messengerMocks, + mockGetConfig, + mockUpdateNotifications, + }; + }; + + describe('when AUS preferences are not initialized (preferences are null)', () => { + it('writes a fresh preferences blob using hardcoded defaults, current Trigger API wallet account state, and supplied marketing flags', async () => { + const { + messenger, + mockEnablePushNotifications, + mockGetConfig, + mockUpdateNotifications, + mockKeyringControllerGetState, + } = arrangeMocks({ + configurePrefs: (mock) => mock.mockResolvedValueOnce(null), + }); + + mockKeyringControllerGetState.mockReturnValue({ + isUnlocked: true, + keyrings: [ + { + accounts: [ADDRESS_1, ADDRESS_2], + type: KeyringTypes.hd, + metadata: { id: 'srp-1', name: 'SRP 1' }, + }, + ], + }); + const mockTriggerQuery = mockGetOnChainNotificationsConfig({ + status: 200, + body: [ + { address: ADDRESS_1.toLowerCase(), enabled: true }, + { address: ADDRESS_2.toLowerCase(), enabled: false }, + ], + }); + + const controller = new NotificationServicesController({ + messenger, + env: { featureAnnouncements: featureAnnouncementsEnv }, + }); + + await controller.createOnChainTriggers({ + hasMarketingConsent: true, + productAnnouncementEnabled: false, + }); + + expect(mockGetConfig).toHaveBeenCalled(); + expect(mockTriggerQuery.isDone()).toBe(true); + expect(mockUpdateNotifications).toHaveBeenCalledTimes(1); + const [writtenPrefs, writtenPlatform] = + mockUpdateNotifications.mock.calls[0]; + expect(writtenPlatform).toBe(featureAnnouncementsEnv.platform); + expect(writtenPrefs).toStrictEqual({ + walletActivity: { + inAppNotificationsEnabled: true, + pushNotificationsEnabled: true, + accounts: [ + { + address: ADDRESS_1.toLowerCase(), + enabled: true, + }, + { + address: ADDRESS_2.toLowerCase(), + enabled: false, + }, + ], + }, + marketing: { + inAppNotificationsEnabled: false, + pushNotificationsEnabled: true, + }, + perps: { ...DEFAULT_PERPS_PREFERENCES }, + socialAI: { ...DEFAULT_SOCIAL_AI_PREFERENCES }, + agenticCli: { ...DEFAULT_AGENTIC_CLI_PREFERENCES }, + priceAlerts: { ...DEFAULT_PRICE_ALERT_PREFERENCES }, + }); + expect(mockEnablePushNotifications).toHaveBeenCalledWith([ + ADDRESS_1.toLowerCase(), + ]); + }); + + it('skips push registration when registerPushNotifications is false', async () => { + const { + messenger, + mockEnablePushNotifications, + mockGetConfig, + mockUpdateNotifications, + mockKeyringControllerGetState, + } = arrangeMocks({ + configurePrefs: (mock) => mock.mockResolvedValueOnce(null), + }); + + mockKeyringControllerGetState.mockReturnValue({ + isUnlocked: true, + keyrings: [ + { + accounts: [ADDRESS_1], + type: KeyringTypes.hd, + metadata: { id: 'srp-1', name: 'SRP 1' }, + }, + ], + }); + const mockTriggerQuery = mockGetOnChainNotificationsConfig(); + + const controller = new NotificationServicesController({ + messenger, + env: { featureAnnouncements: featureAnnouncementsEnv }, + }); + + await controller.createOnChainTriggers({ + registerPushNotifications: false, + }); + + expect(mockGetConfig).toHaveBeenCalled(); + expect(mockTriggerQuery.isDone()).toBe(true); + expect(mockUpdateNotifications).toHaveBeenCalled(); + expect(controller.state.isNotificationServicesEnabled).toBe(true); + expect(mockEnablePushNotifications).not.toHaveBeenCalled(); + }); + + it('enables all wallet-activity accounts when Trigger API has no enabled accounts for first-time setup', async () => { + const { + messenger, + mockEnablePushNotifications, + mockUpdateNotifications, + mockKeyringControllerGetState, + } = arrangeMocks({ + configurePrefs: (mock) => mock.mockResolvedValueOnce(null), + }); + + mockKeyringControllerGetState.mockReturnValue({ + isUnlocked: true, + keyrings: [ + { + accounts: [ADDRESS_1, ADDRESS_2], + type: KeyringTypes.hd, + metadata: { id: 'srp-1', name: 'SRP 1' }, + }, + ], + }); + const mockTriggerQuery = mockGetOnChainNotificationsConfig({ + status: 200, + body: [ + { address: ADDRESS_1.toLowerCase(), enabled: false }, + { address: ADDRESS_2.toLowerCase(), enabled: false }, + ], + }); + + const controller = new NotificationServicesController({ + messenger, + env: { featureAnnouncements: featureAnnouncementsEnv }, + }); + + await controller.createOnChainTriggers(); + + expect(mockTriggerQuery.isDone()).toBe(true); + const [writtenPrefs] = mockUpdateNotifications.mock.calls[0]; + expect(writtenPrefs.walletActivity.accounts).toStrictEqual([ + { address: ADDRESS_1.toLowerCase(), enabled: true }, + { address: ADDRESS_2.toLowerCase(), enabled: true }, + ]); + expect(mockEnablePushNotifications).toHaveBeenCalledWith([ + ADDRESS_1.toLowerCase(), + ADDRESS_2.toLowerCase(), + ]); + }); + + it('defaults marketing notifications to disabled when no consent is supplied', async () => { + const { messenger, mockUpdateNotifications } = arrangeMocks({ + configurePrefs: (mock) => mock.mockResolvedValueOnce(null), + }); + mockGetOnChainNotificationsConfig(); + + const controller = new NotificationServicesController({ + messenger, + env: { featureAnnouncements: featureAnnouncementsEnv }, + }); + + await controller.createOnChainTriggers(); + + const [writtenPrefs] = mockUpdateNotifications.mock.calls[0]; + expect(writtenPrefs.marketing).toStrictEqual({ + inAppNotificationsEnabled: false, + pushNotificationsEnabled: false, + }); + }); + + it('enables marketing in-app notifications when product announcements are enabled', async () => { + const { messenger, mockUpdateNotifications } = arrangeMocks({ + configurePrefs: (mock) => mock.mockResolvedValueOnce(null), + }); + mockGetOnChainNotificationsConfig(); + + const controller = new NotificationServicesController({ + messenger, + env: { featureAnnouncements: featureAnnouncementsEnv }, + }); + + await controller.createOnChainTriggers({ + productAnnouncementEnabled: true, + }); + + const [writtenPrefs] = mockUpdateNotifications.mock.calls[0]; + expect(writtenPrefs.marketing).toStrictEqual({ + inAppNotificationsEnabled: true, + pushNotificationsEnabled: false, + }); + }); + + it('tracks accounts from all keyrings when creating triggers', async () => { + const { + messenger, + mockGetConfig, + mockUpdateNotifications, + mockKeyringControllerGetState, + } = arrangeMocks({ + configurePrefs: (mock) => mock.mockResolvedValueOnce(null), + }); + + mockKeyringControllerGetState.mockReturnValue({ + isUnlocked: true, + keyrings: [ + { + accounts: [ADDRESS_1], + type: KeyringTypes.hd, + metadata: { id: 'srp-1', name: 'SRP 1' }, + }, + { + accounts: [ADDRESS_2], + type: KeyringTypes.hd, + metadata: { id: 'srp-2', name: 'SRP 2' }, + }, + ], + }); + const mockTriggerQuery = mockGetOnChainNotificationsConfig({ + status: 200, + body: [ + { address: ADDRESS_1.toLowerCase(), enabled: true }, + { address: ADDRESS_2.toLowerCase(), enabled: true }, + ], + }); + + const controller = new NotificationServicesController({ + messenger, + env: { featureAnnouncements: featureAnnouncementsEnv }, + }); + + await controller.createOnChainTriggers(); + + expect(mockGetConfig).toHaveBeenCalled(); + expect(mockTriggerQuery.isDone()).toBe(true); + expect(mockUpdateNotifications).toHaveBeenCalled(); + expect(controller.state.subscriptionAccountsSeen).toStrictEqual([ + ADDRESS_1, + ADDRESS_2, + ]); + }); + + it('deduplicates and filters non-Ethereum accounts when creating triggers', async () => { + const { + messenger, + mockGetConfig, + mockUpdateNotifications, + mockKeyringControllerGetState, + } = arrangeMocks({ + configurePrefs: (mock) => mock.mockResolvedValueOnce(null), + }); + + mockKeyringControllerGetState.mockReturnValue({ + isUnlocked: true, + keyrings: [ + { + accounts: [ADDRESS_1, ADDRESS_1.toLowerCase(), 'NotAnAddress'], + type: KeyringTypes.hd, + metadata: { id: 'srp-1', name: 'SRP 1' }, + }, + { + accounts: [ + ADDRESS_2, + '7xKXtg2CW6y7J2wMmkf8VbM8dYb6u3H3V8bLxT64d4oR', + ], + type: KeyringTypes.hd, + metadata: { id: 'srp-2', name: 'SRP 2' }, + }, + ], + }); + const mockTriggerQuery = mockGetOnChainNotificationsConfig({ + status: 200, + body: [ + { address: ADDRESS_1.toLowerCase(), enabled: true }, + { address: ADDRESS_2.toLowerCase(), enabled: true }, + ], + }); + + const controller = new NotificationServicesController({ + messenger, + env: { featureAnnouncements: featureAnnouncementsEnv }, + }); + + await controller.createOnChainTriggers(); + + expect(mockGetConfig).toHaveBeenCalled(); + expect(mockTriggerQuery.isDone()).toBe(true); + expect(mockUpdateNotifications).toHaveBeenCalled(); + expect(controller.state.subscriptionAccountsSeen).toStrictEqual([ + ADDRESS_1, + ADDRESS_2, + ]); + }); + + it('normalizes non-checksummed mixed-case addresses before filtering', async () => { + const { + messenger, + mockGetConfig, + mockUpdateNotifications, + mockKeyringControllerGetState, + } = arrangeMocks({ + configurePrefs: (mock) => mock.mockResolvedValueOnce(null), + }); + + const nonChecksummedMixedCaseAddress = + '0xd8Da6bf26964af9d7eeD9e03E53415D37aa96045'; + + mockKeyringControllerGetState.mockReturnValue({ + isUnlocked: true, + keyrings: [ + { + accounts: [nonChecksummedMixedCaseAddress], + type: KeyringTypes.hd, + metadata: { id: 'srp-1', name: 'SRP 1' }, + }, + ], + }); + const mockTriggerQuery = mockGetOnChainNotificationsConfig({ + status: 200, + body: [{ address: ADDRESS_1.toLowerCase(), enabled: true }], + }); + + const controller = new NotificationServicesController({ + messenger, + env: { featureAnnouncements: featureAnnouncementsEnv }, + }); + + await controller.createOnChainTriggers(); + + expect(mockGetConfig).toHaveBeenCalled(); + expect(mockTriggerQuery.isDone()).toBe(true); + expect(mockUpdateNotifications).toHaveBeenCalled(); + expect(controller.state.subscriptionAccountsSeen).toStrictEqual([ + ADDRESS_1, + ]); + }); + }); + + describe('when AUS preferences are fully initialized', () => { + it('does not register notifications when notifications already exist and not resetting (however does update push registrations)', async () => { + const { + messenger, + mockEnablePushNotifications, + mockGetConfig, + mockUpdateNotifications, + } = arrangeMocks({ + configurePrefs: (mock) => + mock.mockResolvedValueOnce( + prefsFromAddresses([{ address: ADDRESS_1, enabled: true }]), + ), + }); + + const controller = new NotificationServicesController({ + messenger, + env: { featureAnnouncements: featureAnnouncementsEnv }, + }); + + await controller.createOnChainTriggers(); + + expect(mockGetConfig).toHaveBeenCalled(); + expect(mockUpdateNotifications).not.toHaveBeenCalled(); + expect(mockEnablePushNotifications).toHaveBeenCalled(); + }); + + it('preserves user preferences when re-subscribing using enableMetamaskNotifications', async () => { + const { + messenger, + mockEnablePushNotifications, + mockGetConfig, + mockUpdateNotifications, + } = arrangeMocks({ + configurePrefs: (mock) => + mock.mockResolvedValueOnce( + prefsFromAddresses([{ address: ADDRESS_1, enabled: true }]), + ), + }); + + const controller = new NotificationServicesController({ + messenger, + env: { featureAnnouncements: featureAnnouncementsEnv }, + state: { + isNotificationServicesEnabled: true, + isFeatureAnnouncementsEnabled: false, + }, + }); + + await controller.enableMetamaskNotifications(); + + expect(controller.state.isFeatureAnnouncementsEnabled).toBe(false); + expect(controller.state.isNotificationServicesEnabled).toBe(true); + expect(mockGetConfig).toHaveBeenCalled(); + expect(mockUpdateNotifications).not.toHaveBeenCalled(); + expect(mockEnablePushNotifications).toHaveBeenCalled(); + }); + }); + + it('throws if not given a valid auth & bearer token', async () => { + const mocks = arrangeMocks(); + mockErrorLog(); + const controller = new NotificationServicesController({ + messenger: mocks.messenger, + env: { featureAnnouncements: featureAnnouncementsEnv }, + }); + + const testScenarios = { + ...arrangeFailureAuthAssertions(mocks), + }; + + for (const mockFailureAction of Object.values(testScenarios)) { + mockFailureAction(); + await expect(controller.createOnChainTriggers()).rejects.toThrow( + expect.any(Error), + ); + } + }); + }); + + describe('disableAccounts', () => { + const arrangeMocks = (): ReturnType & { + mockUpdateNotifications: jest.Mock; + } => { + const messengerMocks = mockNotificationMessenger(); + const mockUpdateNotifications = + messengerMocks.mockPutNotificationPreferences; + return { ...messengerMocks, mockUpdateNotifications }; + }; + + it('disables notifications for given accounts', async () => { + const { + messenger, + mockUpdateNotifications, + mockDeletePushNotificationLinks, + } = arrangeMocks(); + const controller = new NotificationServicesController({ + messenger, + env: { featureAnnouncements: featureAnnouncementsEnv }, + }); + + await controller.disableAccounts([ADDRESS_1]); + + expect(mockUpdateNotifications).toHaveBeenCalled(); + expect(mockDeletePushNotificationLinks).toHaveBeenCalledWith([ADDRESS_1]); + }); + + it('throws errors when invalid auth', async () => { + const mocks = arrangeMocks(); + mockErrorLog(); + const controller = new NotificationServicesController({ + messenger: mocks.messenger, + env: { featureAnnouncements: featureAnnouncementsEnv }, + }); + + const testScenarios = { + ...arrangeFailureAuthAssertions(mocks), + }; + + for (const mockFailureAction of Object.values(testScenarios)) { + mockFailureAction(); + await expect(controller.disableAccounts([ADDRESS_1])).rejects.toThrow( + expect.any(Error), + ); + } + }); + }); + + describe('enableAccounts', () => { + const arrangeMocks = (): ReturnType & { + mockUpdateNotifications: jest.Mock; + } => { + const messengerMocks = mockNotificationMessenger(); + const mockUpdateNotifications = + messengerMocks.mockPutNotificationPreferences; + return { ...messengerMocks, mockUpdateNotifications }; + }; + + it('enables notifications for given accounts', async () => { + const { + messenger, + mockAddPushNotificationLinks, + mockUpdateNotifications, + } = arrangeMocks(); + const controller = new NotificationServicesController({ + messenger, + env: { featureAnnouncements: featureAnnouncementsEnv }, + }); + + await controller.enableAccounts([ADDRESS_1]); + + expect(mockUpdateNotifications).toHaveBeenCalled(); + expect(mockAddPushNotificationLinks).toHaveBeenCalledWith([ADDRESS_1]); + }); + + it('throws errors when invalid auth', async () => { + const mocks = arrangeMocks(); + mockErrorLog(); + const controller = new NotificationServicesController({ + messenger: mocks.messenger, + env: { featureAnnouncements: featureAnnouncementsEnv }, + }); + + const testScenarios = { + ...arrangeFailureAuthAssertions(mocks), + }; + + for (const mockFailureAction of Object.values(testScenarios)) { + mockFailureAction(); + await expect(controller.enableAccounts([ADDRESS_1])).rejects.toThrow( + expect.any(Error), + ); + } + }); + }); + + describe('fetchAndUpdateMetamaskNotifications', () => { + const arrangeMocks = (): ReturnType & { + mockFeatureAnnouncementAPIResult: ReturnType< + typeof createMockFeatureAnnouncementAPIResult + >; + mockFeatureAnnouncementsAPI: nock.Scope; + mockOnChainNotificationsAPIResult: ReturnType< + typeof createMockNotificationEthSent + >[]; + mockOnChainNotificationsAPI: nock.Scope; + } => { + const messengerMocks = mockNotificationMessenger(); + messengerMocks.mockGetNotificationPreferences.mockResolvedValue( + prefsFromAddressesWithMarketingInAppNotifications( + [{ address: '0xTestAddress', enabled: true }], + true, + ), + ); + + const mockFeatureAnnouncementAPIResult = + createMockFeatureAnnouncementAPIResult(); + const mockFeatureAnnouncementsAPI = + mockFetchFeatureAnnouncementNotifications({ + status: 200, + body: mockFeatureAnnouncementAPIResult, + }); + + const mockOnChainNotificationsAPIResult = [ + createMockNotificationEthSent(), + ]; + const mockOnChainNotificationsAPI = mockGetAPINotifications({ + status: 200, + body: mockOnChainNotificationsAPIResult, + }); + + return { + ...messengerMocks, + mockFeatureAnnouncementAPIResult, + mockFeatureAnnouncementsAPI, + mockOnChainNotificationsAPIResult, + mockOnChainNotificationsAPI, + }; + }; + + const arrangeController = ( + messenger: NotificationServicesControllerMessenger, + overrideState?: Partial, + ): NotificationServicesController => { + const controller = new NotificationServicesController({ + messenger, + env: { featureAnnouncements: featureAnnouncementsEnv }, + state: { + ...defaultState, + isNotificationServicesEnabled: true, + isFeatureAnnouncementsEnabled: true, + ...overrideState, + }, + }); + + return controller; + }; + + it('processes and shows all notifications (announcements, wallet, and snap notifications)', async () => { + const { messenger } = arrangeMocks(); + const controller = arrangeController(messenger, { + metamaskNotificationsList: [ + processSnapNotification(createMockSnapNotification()), + ], + }); + + const result = await controller.fetchAndUpdateMetamaskNotifications(); + + // Should have 1 feature announcement + expect( + result.filter( + (notification) => + notification.type === TRIGGER_TYPES.FEATURES_ANNOUNCEMENT, + ), + ).toHaveLength(1); + + // Should have 1 Wallet Notification + expect( + result.filter( + (notification) => notification.type === TRIGGER_TYPES.ETH_SENT, + ), + ).toHaveLength(1); + + // Should have 1 Snap Notification + expect( + result.filter( + (notification) => notification.type === TRIGGER_TYPES.SNAP, + ), + ).toHaveLength(1); + + // Total notification length = 3 + expect(result).toHaveLength(3); + }); + + it('does not fetch feature announcements or wallet notifications if notifications are disabled globally', async () => { + const { messenger, ...mocks } = arrangeMocks(); + const controller = arrangeController(messenger, { + isNotificationServicesEnabled: false, + metamaskNotificationsList: [ + processSnapNotification(createMockSnapNotification()), + ], + }); + + const result = await controller.fetchAndUpdateMetamaskNotifications(); + + // Should only contain snap notification + // As this is not controlled by the global notification switch + expect(result).toHaveLength(1); + expect( + result.filter( + (notification) => notification.type === TRIGGER_TYPES.SNAP, + ), + ).toHaveLength(1); + + // APIs should not have been called + expect(mocks.mockFeatureAnnouncementsAPI.isDone()).toBe(false); + expect(mocks.mockOnChainNotificationsAPI.isDone()).toBe(false); + }); + + it('should fetch feature announcements if AUS marketing in-app notifications are enabled', async () => { + const { messenger, ...mocks } = arrangeMocks(); + const controller = arrangeController(messenger); + + const result = await controller.fetchAndUpdateMetamaskNotifications(); + + expect( + result.filter( + (notification) => + notification.type === TRIGGER_TYPES.FEATURES_ANNOUNCEMENT, + ), + ).toHaveLength(1); + expect(mocks.mockFeatureAnnouncementsAPI.isDone()).toBe(true); + }); + + it('should not fetch feature announcements if AUS marketing in-app notifications are disabled', async () => { + const { messenger, ...mocks } = arrangeMocks(); + mocks.mockGetNotificationPreferences.mockResolvedValue( + prefsFromAddressesWithMarketingInAppNotifications( + [{ address: '0xTestAddress', enabled: true }], + false, + ), + ); + const controller = arrangeController(messenger); + + const result = await controller.fetchAndUpdateMetamaskNotifications(); + + // Should not have any feature announcements + expect( + result.filter( + (notification) => + notification.type === TRIGGER_TYPES.FEATURES_ANNOUNCEMENT, + ), + ).toHaveLength(0); + + // Should not have called feature announcement API + expect(mocks.mockFeatureAnnouncementsAPI.isDone()).toBe(false); + }); + + it('should handle errors gracefully when fetching notifications', async () => { + const { messenger, mockGetNotificationPreferences } = + mockNotificationMessenger(); + mockGetNotificationPreferences.mockResolvedValue( + prefsFromAddressesWithMarketingInAppNotifications( + [{ address: '0xTestAddress', enabled: true }], + true, + ), + ); + + // Mock APIs to fail + mockFetchFeatureAnnouncementNotifications({ status: 500 }); + mockGetAPINotifications({ status: 500 }); + + const controller = arrangeController(messenger); + + const result = await controller.fetchAndUpdateMetamaskNotifications(); + + // Should still return empty array and not throw + expect(Array.isArray(result)).toBe(true); + }); + }); + + describe('getNotificationsByType', () => { + it('can fetch notifications by their type', async () => { + const { messenger } = mockNotificationMessenger(); + const controller = new NotificationServicesController({ + messenger, + env: { featureAnnouncements: featureAnnouncementsEnv }, + }); + + const processedSnapNotification = processSnapNotification( + createMockSnapNotification(), + ); + const processedFeatureAnnouncement = processFeatureAnnouncement( + createMockFeatureAnnouncementRaw(), + ); + + await controller.updateMetamaskNotificationsList( + processedSnapNotification, + ); + await controller.updateMetamaskNotificationsList( + processedFeatureAnnouncement, + ); + + expect(controller.state.metamaskNotificationsList).toHaveLength(2); + + const filteredNotifications = controller.getNotificationsByType( + TRIGGER_TYPES.SNAP, + ); + + expect(filteredNotifications).toHaveLength(1); + expect(filteredNotifications).toStrictEqual([ + { + type: TRIGGER_TYPES.SNAP, + notification_subtype: TRIGGER_TYPES.SNAP, + id: expect.any(String), + createdAt: expect.any(String), + isRead: false, + readDate: null, + data: { + message: 'fooBar', + origin: '@metamask/example-snap', + detailedView: { + title: 'Detailed View', + interfaceId: '1', + footerLink: { + text: 'Go Home', + href: 'metamask://client/', + }, + }, + }, + }, + ]); + }); + }); + + describe('deleteNotificationsById', () => { + it('will delete a notification by its id', async () => { + const { messenger } = mockNotificationMessenger(); + const processedSnapNotification = processSnapNotification( + createMockSnapNotification(), + ); + const controller = new NotificationServicesController({ + messenger, + env: { featureAnnouncements: featureAnnouncementsEnv }, + state: { metamaskNotificationsList: [processedSnapNotification] }, + }); + + await controller.deleteNotificationsById([processedSnapNotification.id]); + + expect(controller.state.metamaskNotificationsList).toHaveLength(0); + }); + + it('will batch delete notifications', async () => { + const { messenger } = mockNotificationMessenger(); + const processedSnapNotification1 = processSnapNotification( + createMockSnapNotification(), + ); + const processedSnapNotification2 = processSnapNotification( + createMockSnapNotification(), + ); + const controller = new NotificationServicesController({ + messenger, + env: { featureAnnouncements: featureAnnouncementsEnv }, + state: { + metamaskNotificationsList: [ + processedSnapNotification1, + processedSnapNotification2, + ], + }, + }); + + await controller.deleteNotificationsById([ + processedSnapNotification1.id, + processedSnapNotification2.id, + ]); + + expect(controller.state.metamaskNotificationsList).toHaveLength(0); + }); + + it('will throw if a notification is not found', async () => { + const { messenger } = mockNotificationMessenger(); + const processedSnapNotification = processSnapNotification( + createMockSnapNotification(), + ); + const controller = new NotificationServicesController({ + messenger, + env: { featureAnnouncements: featureAnnouncementsEnv }, + state: { metamaskNotificationsList: [processedSnapNotification] }, + }); + + await expect(controller.deleteNotificationsById(['foo'])).rejects.toThrow( + 'The notification to be deleted does not exist.', + ); + + expect(controller.state.metamaskNotificationsList).toHaveLength(1); + }); + + it('will throw if the notification to be deleted is not locally persisted', async () => { + const { messenger } = mockNotificationMessenger(); + const processedSnapNotification = processSnapNotification( + createMockSnapNotification(), + ); + const processedFeatureAnnouncement = processFeatureAnnouncement( + createMockFeatureAnnouncementRaw(), + ); + const controller = new NotificationServicesController({ + messenger, + env: { featureAnnouncements: featureAnnouncementsEnv }, + state: { + metamaskNotificationsList: [ + processedFeatureAnnouncement, + processedSnapNotification, + ], + }, + }); + + await expect( + controller.deleteNotificationsById([processedFeatureAnnouncement.id]), + ).rejects.toThrow( + 'The notification type of "features_announcement" is not locally persisted, only the following types can use this function: snap.', + ); + + expect(controller.state.metamaskNotificationsList).toHaveLength(2); + }); + }); + + describe('markMetamaskNotificationsAsRead', () => { + const arrangeMocks = (options?: { + onChainMarkAsReadFails: boolean; + }): ReturnType & { + mockMarkAsReadAPI: nock.Scope; + } => { + const messengerMocks = mockNotificationMessenger(); + + const mockMarkAsReadAPI = mockMarkNotificationsAsRead({ + status: options?.onChainMarkAsReadFails ? 500 : 200, + }); + + return { + ...messengerMocks, + mockMarkAsReadAPI, + }; + }; + + it('updates feature announcements as read', async () => { + const { messenger } = arrangeMocks(); + const controller = new NotificationServicesController({ + messenger, + env: { featureAnnouncements: featureAnnouncementsEnv }, + }); + + await controller.markMetamaskNotificationsAsRead([ + processNotification(createMockFeatureAnnouncementRaw()), + processNotification(createMockNotificationEthSent()), + ]); + + // Should see 1 item in controller read state (feature announcement) + expect(controller.state.metamaskNotificationsReadList).toHaveLength(1); + }); + + it('should at least mark feature announcements locally if external updates fail', async () => { + const { messenger } = arrangeMocks({ onChainMarkAsReadFails: true }); + const controller = new NotificationServicesController({ + messenger, + env: { featureAnnouncements: featureAnnouncementsEnv }, + }); + mockErrorLog(); + mockWarnLog(); + + await controller.markMetamaskNotificationsAsRead([ + processNotification(createMockFeatureAnnouncementRaw()), + processNotification(createMockNotificationEthSent()), + ]); + + // Should see 1 item in controller read state. + // This is because on-chain failed. + expect(controller.state.metamaskNotificationsReadList).toHaveLength(1); + }); + + it('updates snap notifications as read', async () => { + const { messenger } = arrangeMocks(); + const processedSnapNotification = processSnapNotification( + createMockSnapNotification(), + ); + const controller = new NotificationServicesController({ + messenger, + env: { featureAnnouncements: featureAnnouncementsEnv }, + state: { + metamaskNotificationsList: [processedSnapNotification], + }, + }); + + await controller.markMetamaskNotificationsAsRead([ + { + type: TRIGGER_TYPES.SNAP, + id: processedSnapNotification.id, + isRead: false, + }, + ]); + + // Should see 1 item in controller read state + expect(controller.state.metamaskNotificationsReadList).toHaveLength(1); + + // The notification should have a read date + expect( + // @ts-expect-error readDate property is guaranteed to exist + // as we're dealing with a snap notification + controller.state.metamaskNotificationsList[0].readDate, + ).not.toBeNull(); + }); + }); + + describe('enableMetamaskNotifications', () => { + const arrangeMocks = (overrides?: { + configurePrefs?: (mock: jest.Mock) => void; + }): ReturnType & { + mockGetConfig: jest.Mock; + mockUpdateNotifications: jest.Mock; + } => { + const messengerMocks = mockNotificationMessenger(); + const mockGetConfig = messengerMocks.mockGetNotificationPreferences; + const mockUpdateNotifications = + messengerMocks.mockPutNotificationPreferences; + overrides?.configurePrefs?.(mockGetConfig); + + messengerMocks.mockKeyringControllerGetState.mockReturnValue({ + isUnlocked: true, + keyrings: [ + { + accounts: [ADDRESS_1], + type: KeyringTypes.hd, + metadata: { + id: '123', + name: '', + }, + }, + ], + }); + + return { + ...messengerMocks, + mockGetConfig, + mockUpdateNotifications, + }; + }; + + it('should sign a user in if not already signed in', async () => { + const mocks = arrangeMocks(); + mocks.mockIsSignedIn.mockReturnValue(false); // mock that auth is not enabled + const controller = new NotificationServicesController({ + messenger: mocks.messenger, + env: { featureAnnouncements: featureAnnouncementsEnv }, + }); + + await controller.enableMetamaskNotifications(); + + expect(mocks.mockIsSignedIn).toHaveBeenCalled(); + expect(mocks.mockAuthPerformSignIn).toHaveBeenCalled(); + expect(mocks.mockIsSignedIn()).toBe(true); + }); + + it('create new notifications when switched on and no existing notifications', async () => { + const mocks = arrangeMocks({ + // No AUS preferences yet — fresh initialization. + configurePrefs: (mock) => mock.mockResolvedValueOnce(null), + }); + + const controller = new NotificationServicesController({ + messenger: mocks.messenger, + env: { featureAnnouncements: featureAnnouncementsEnv }, + }); + + const promise = controller.enableMetamaskNotifications(); + + // Act - intermediate state + expect(controller.state.isUpdatingMetamaskNotifications).toBe(true); + + await promise; + + // Act - final state + expect(controller.state.isUpdatingMetamaskNotifications).toBe(false); + expect(controller.state.isNotificationServicesEnabled).toBe(true); + + // Act - services called + expect(mocks.mockGetConfig).toHaveBeenCalled(); + expect(mocks.mockUpdateNotifications).toHaveBeenCalled(); + }); + + it('forwards registerPushNotifications false when enabling MetaMask notifications', async () => { + const mocks = arrangeMocks({ + configurePrefs: (mock) => mock.mockResolvedValueOnce(null), + }); + const mockTriggerQuery = mockGetOnChainNotificationsConfig(); + + const controller = new NotificationServicesController({ + messenger: mocks.messenger, + env: { featureAnnouncements: featureAnnouncementsEnv }, + }); + + await controller.enableMetamaskNotifications({ + registerPushNotifications: false, + }); + + expect(mocks.mockGetConfig).toHaveBeenCalled(); + expect(mockTriggerQuery.isDone()).toBe(true); + expect(mocks.mockUpdateNotifications).toHaveBeenCalled(); + expect(controller.state.isNotificationServicesEnabled).toBe(true); + expect(mocks.mockEnablePushNotifications).not.toHaveBeenCalled(); + }); + + it('should not create new notification subscriptions when enabling an account that already has notifications', async () => { + const mocks = arrangeMocks({ + // Mock fully-initialized existing notifications + configurePrefs: (mock) => + mock.mockResolvedValueOnce( + prefsFromAddresses([{ address: ADDRESS_1, enabled: true }]), + ), + }); + + const controller = new NotificationServicesController({ + messenger: mocks.messenger, + env: { featureAnnouncements: featureAnnouncementsEnv }, + }); + + await controller.enableMetamaskNotifications(); + + expect(mocks.mockGetConfig).toHaveBeenCalled(); + expect(mocks.mockUpdateNotifications).not.toHaveBeenCalled(); + }); + }); + + describe('disableNotificationServices', () => { + it('disable notifications and turn off push notifications', async () => { + const { messenger, mockDisablePushNotifications } = + mockNotificationMessenger(); + const controller = new NotificationServicesController({ + messenger, + env: { featureAnnouncements: featureAnnouncementsEnv }, + state: { + isNotificationServicesEnabled: true, + metamaskNotificationsList: [ + createMockFeatureAnnouncementRaw() as INotification, + createMockSnapNotification() as INotification, + ], + }, + }); + + const promise = controller.disableNotificationServices(); + + // Act - intermediate state + expect(controller.state.isUpdatingMetamaskNotifications).toBe(true); + + await promise; + + // Act - final state + expect(controller.state.isUpdatingMetamaskNotifications).toBe(false); + expect(controller.state.isNotificationServicesEnabled).toBe(false); + expect(controller.state.isFeatureAnnouncementsEnabled).toBe(false); + expect(controller.state.metamaskNotificationsList).toStrictEqual([ + createMockSnapNotification(), + ]); + + expect(mockDisablePushNotifications).toHaveBeenCalled(); + }); + }); + + describe('updateMetamaskNotificationsList', () => { + it('can add and process a new notification to the notifications list', async () => { + const { messenger } = mockNotificationMessenger(); + const controller = new NotificationServicesController({ + messenger, + env: { featureAnnouncements: featureAnnouncementsEnv }, + state: { isNotificationServicesEnabled: true }, + }); + const processedSnapNotification = processSnapNotification( + createMockSnapNotification(), + ); + await controller.updateMetamaskNotificationsList( + processedSnapNotification, + ); + expect(controller.state.metamaskNotificationsList).toStrictEqual([ + { + type: TRIGGER_TYPES.SNAP, + notification_subtype: TRIGGER_TYPES.SNAP, + id: expect.any(String), + createdAt: expect.any(String), + readDate: null, + isRead: false, + data: { + message: 'fooBar', + origin: '@metamask/example-snap', + detailedView: { + title: 'Detailed View', + interfaceId: '1', + footerLink: { + text: 'Go Home', + href: 'metamask://client/', + }, + }, + }, + }, + ]); + }); + }); + + describe('enablePushNotifications', () => { + const arrangeMocks = (): ReturnType & { + mockGetConfig: jest.Mock; + } => { + const messengerMocks = mockNotificationMessenger(); + const mockGetConfig = messengerMocks.mockGetNotificationPreferences; + mockGetConfig.mockResolvedValueOnce( + prefsFromAddresses([ + { address: ADDRESS_1, enabled: true }, + { address: ADDRESS_2, enabled: true }, + ]), + ); + return { ...messengerMocks, mockGetConfig }; + }; + + it('calls push controller and enables notifications for accounts that have subscribed to notifications', async () => { + const { messenger, mockGetConfig, mockEnablePushNotifications } = + arrangeMocks(); + const controller = new NotificationServicesController({ + messenger, + env: { featureAnnouncements: featureAnnouncementsEnv }, + state: { isNotificationServicesEnabled: true }, + }); + + // Act + await controller.enablePushNotifications(); + + // Assert + expect(mockGetConfig).toHaveBeenCalled(); + // Addresses are stored lower-cased in AUS preferences. + expect(mockEnablePushNotifications).toHaveBeenCalledWith([ + ADDRESS_1.toLowerCase(), + ADDRESS_2.toLowerCase(), + ]); + }); + + it('handles errors gracefully when fetching notification config fails', async () => { + const mocks = mockNotificationMessenger(); + mocks.mockGetNotificationPreferences.mockRejectedValueOnce( + new Error('mock api failure'), + ); + mockErrorLog(); + + const controller = new NotificationServicesController({ + messenger: mocks.messenger, + env: { featureAnnouncements: featureAnnouncementsEnv }, + state: { isNotificationServicesEnabled: true }, + }); + + // Should not throw error + await controller.enablePushNotifications(); + expect(mocks.mockEnablePushNotifications).not.toHaveBeenCalled(); + }); + }); + + describe('disablePushNotifications', () => { + it('calls push controller to disable push notifications', async () => { + const { messenger, mockDisablePushNotifications } = + mockNotificationMessenger(); + const controller = new NotificationServicesController({ + messenger, + env: { featureAnnouncements: featureAnnouncementsEnv }, + state: { isNotificationServicesEnabled: true }, + }); + + // Act + await controller.disablePushNotifications(); + + // Assert + expect(mockDisablePushNotifications).toHaveBeenCalled(); + }); + }); + + describe('sendPerpPlaceOrderNotification', () => { + const arrangeMocks = (): ReturnType & { + mockCreatePerpAPI: nock.Scope; + } => { + const messengerMocks = mockNotificationMessenger(); + const mockCreatePerpAPI = mockCreatePerpNotification({ + status: 200, + body: { success: true }, + }); + return { ...messengerMocks, mockCreatePerpAPI }; + }; + + const mockOrderInput: OrderInput = { + user_id: '0x111', // User Address + coin: '0x222', // Asset address + }; + + it('should successfully send perp order notification when authenticated', async () => { + const { messenger, mockCreatePerpAPI } = arrangeMocks(); + const controller = new NotificationServicesController({ + messenger, + env: { featureAnnouncements: featureAnnouncementsEnv }, + }); + + await controller.sendPerpPlaceOrderNotification(mockOrderInput); + + expect(mockCreatePerpAPI.isDone()).toBe(true); + }); + + it('should handle authentication errors gracefully', async () => { + const mocks = arrangeMocks(); + mocks.mockIsSignedIn.mockReturnValue(false); + + const controller = new NotificationServicesController({ + messenger: mocks.messenger, + env: { featureAnnouncements: featureAnnouncementsEnv }, + }); + + await controller.sendPerpPlaceOrderNotification(mockOrderInput); + + expect(mocks.mockCreatePerpAPI.isDone()).toBe(false); + }); + + it('should handle bearer token retrieval errors gracefully', async () => { + const mocks = arrangeMocks(); + mocks.mockGetBearerToken.mockRejectedValueOnce( + new Error('Failed to get bearer token'), + ); + + const controller = new NotificationServicesController({ + messenger: mocks.messenger, + env: { featureAnnouncements: featureAnnouncementsEnv }, + }); + + await controller.sendPerpPlaceOrderNotification(mockOrderInput); + + expect(mocks.mockCreatePerpAPI.isDone()).toBe(false); + }); + + it('should handle API call failures gracefully', async () => { + const { messenger } = mockNotificationMessenger(); + // Mock API to fail + const mockCreatePerpAPI = mockCreatePerpNotification({ status: 500 }); + const mockConsoleError = jest + .spyOn(console, 'error') + .mockImplementation(jest.fn()); + + const controller = new NotificationServicesController({ + messenger, + env: { featureAnnouncements: featureAnnouncementsEnv }, + }); + + await controller.sendPerpPlaceOrderNotification(mockOrderInput); + expect(mockCreatePerpAPI.isDone()).toBe(true); + expect(mockConsoleError).toHaveBeenCalled(); + }); + }); + + describe('metadata', () => { + it('includes expected state in debug snapshots', () => { + const { messenger } = mockNotificationMessenger(); + const controller = new NotificationServicesController({ + messenger, + env: { featureAnnouncements: featureAnnouncementsEnv }, + }); + + expect( + deriveStateFromMetadata( + controller.state, + controller.metadata, + 'includeInDebugSnapshot', + ), + ).toMatchInlineSnapshot(` + { + "metamaskNotificationsList": [], + "metamaskNotificationsReadList": [], + "subscriptionAccountsSeen": [], + } + `); + }); + + it('includes expected state in state logs', () => { + const { messenger } = mockNotificationMessenger(); + const controller = new NotificationServicesController({ + messenger, + env: { featureAnnouncements: featureAnnouncementsEnv }, + }); + + expect( + deriveStateFromMetadata( + controller.state, + controller.metadata, + 'includeInStateLogs', + ), + ).toMatchInlineSnapshot(` + { + "isFeatureAnnouncementsEnabled": false, + "isMetamaskNotificationsFeatureSeen": false, + "isNotificationServicesEnabled": false, + "metamaskNotificationsList": [], + "subscriptionAccountsSeen": [], + } + `); + }); + + it('persists expected state', () => { + const { messenger } = mockNotificationMessenger(); + const controller = new NotificationServicesController({ + messenger, + env: { featureAnnouncements: featureAnnouncementsEnv }, + }); + + expect( + deriveStateFromMetadata( + controller.state, + controller.metadata, + 'persist', + ), + ).toMatchInlineSnapshot(` + { + "isFeatureAnnouncementsEnabled": false, + "isMetamaskNotificationsFeatureSeen": false, + "isNotificationServicesEnabled": false, + "metamaskNotificationsList": [], + "metamaskNotificationsReadList": [], + "subscriptionAccountsSeen": [], + } + `); + }); + + it('includes expected state in UI', () => { + const { messenger } = mockNotificationMessenger(); + const controller = new NotificationServicesController({ + messenger, + env: { featureAnnouncements: featureAnnouncementsEnv }, + }); + + expect( + deriveStateFromMetadata( + controller.state, + controller.metadata, + 'usedInUi', + ), + ).toMatchInlineSnapshot(` + { + "isCheckingAccountsPresence": false, + "isFeatureAnnouncementsEnabled": false, + "isFetchingMetamaskNotifications": false, + "isMetamaskNotificationsFeatureSeen": false, + "isNotificationServicesEnabled": false, + "isUpdatingMetamaskNotifications": false, + "isUpdatingMetamaskNotificationsAccount": [], + "metamaskNotificationsList": [], + "metamaskNotificationsReadList": [], + "subscriptionAccountsSeen": [], + } + `); + }); + }); +}); + +// Type-Computation - we are extracting args and parameters from a generic type utility +// Thus this `AnyFunc` can be used to help constrain the generic parameters correctly +// eslint-disable-next-line @typescript-eslint/no-explicit-any +type AnyFunc = (...args: any[]) => any; +const typedMockAction = (): jest.Mock< + ReturnType, + Parameters +> => jest.fn, Parameters>(); + +const controllerName = 'NotificationServicesController'; + +type AllNotificationServicesControllerActions = + MessengerActions; + +type AllNotificationServicesControllerEvents = + MessengerEvents; + +type RootMessenger = Messenger< + MockAnyNamespace, + AllNotificationServicesControllerActions, + AllNotificationServicesControllerEvents +>; + +/** + * Creates and returns a root messenger for testing + * + * @returns A messenger instance + */ +function getRootMessenger(): RootMessenger { + return new Messenger({ + namespace: MOCK_ANY_NAMESPACE, + }); +} + +/** + * Jest Mock Utility - Mock Notification Messenger + * + * @returns mock notification messenger and other messenger mocks + */ +function mockNotificationMessenger(): { + globalMessenger: RootMessenger; + messenger: NotificationServicesControllerMessenger; + mockGetBearerToken: jest.Mock; + mockIsSignedIn: jest.Mock; + mockAuthPerformSignIn: jest.Mock; + mockAddPushNotificationLinks: jest.Mock; + mockDisablePushNotifications: jest.Mock; + mockDeletePushNotificationLinks: jest.Mock; + mockEnablePushNotifications: jest.Mock; + mockSubscribeToPushNotifications: jest.Mock; + mockKeyringControllerGetState: jest.Mock; + mockGetNotificationPreferences: jest.Mock; + mockPutNotificationPreferences: jest.Mock; +} { + const globalMessenger = getRootMessenger(); + + const messenger = new Messenger< + typeof controllerName, + AllNotificationServicesControllerActions, + AllNotificationServicesControllerEvents, + RootMessenger + >({ + namespace: controllerName, + parent: globalMessenger, + }); + + globalMessenger.delegate({ + messenger, + actions: [ + 'KeyringController:getState', + 'AuthenticationController:getBearerToken', + 'AuthenticationController:isSignedIn', + 'AuthenticationController:performSignIn', + 'NotificationServicesPushController:addPushNotificationLinks', + 'NotificationServicesPushController:disablePushNotifications', + 'NotificationServicesPushController:deletePushNotificationLinks', + 'NotificationServicesPushController:enablePushNotifications', + 'NotificationServicesPushController:subscribeToPushNotifications', + 'AuthenticatedUserStorageService:getNotificationPreferences', + 'AuthenticatedUserStorageService:putNotificationPreferences', + ], + events: [ + 'KeyringController:stateChange', + 'KeyringController:lock', + 'KeyringController:unlock', + 'NotificationServicesPushController:onNewNotifications', + 'NotificationServicesPushController:stateChange', + ], + }); + + const mockGetBearerToken = + typedMockAction().mockResolvedValue( + AuthenticationController.Mocks.MOCK_OATH_TOKEN_RESPONSE.access_token, + ); + + const mockIsSignedIn = + typedMockAction().mockReturnValue( + true, + ); + + const mockAuthPerformSignIn = + typedMockAction().mockResolvedValue( + ['New Access Token'], + ); + + const mockAddPushNotificationLinks = + typedMockAction().mockResolvedValue( + true, + ); + + const mockDisablePushNotifications = + typedMockAction(); + + const mockDeletePushNotificationLinks = + typedMockAction().mockResolvedValue( + true, + ); + + const mockEnablePushNotifications = + typedMockAction(); + + const mockSubscribeToPushNotifications = + typedMockAction(); + + const mockKeyringControllerGetState = + typedMockAction().mockReturnValue({ + isUnlocked: true, + keyrings: [ + { + accounts: ['0xde55a0F2591d7823486e211710f53dADdb173Cee'], + type: KeyringTypes.hd, + }, + ], + } as MockVar); + + const mockGetNotificationPreferences = + typedMockAction().mockResolvedValue( + prefsFromAddresses([{ address: '0xTestAddress', enabled: true }]), + ); + + const mockPutNotificationPreferences = + typedMockAction().mockResolvedValue( + undefined, + ); + + jest.spyOn(messenger, 'call').mockImplementation((...args) => { + const [actionType] = args; + + // This mock implementation does not have a nice discriminate union where types/parameters can be correctly inferred + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const [, ...params]: any[] = args; + + if (actionType === 'KeyringController:getState') { + return mockKeyringControllerGetState(); + } + + if (actionType === 'AuthenticationController:getBearerToken') { + return mockGetBearerToken(); + } + + if (actionType === 'AuthenticationController:isSignedIn') { + return mockIsSignedIn(); + } + + if (actionType === 'AuthenticationController:performSignIn') { + mockIsSignedIn.mockReturnValue(true); + return mockAuthPerformSignIn(); + } + + if ( + actionType === + 'NotificationServicesPushController:addPushNotificationLinks' + ) { + return mockAddPushNotificationLinks(params[0]); + } + + if ( + actionType === + 'NotificationServicesPushController:disablePushNotifications' + ) { + return mockDisablePushNotifications(); + } + + if ( + actionType === + 'NotificationServicesPushController:deletePushNotificationLinks' + ) { + return mockDeletePushNotificationLinks(params[0]); + } + + if ( + actionType === + 'NotificationServicesPushController:enablePushNotifications' + ) { + return mockEnablePushNotifications(params[0]); + } + + if ( + actionType === + 'NotificationServicesPushController:subscribeToPushNotifications' + ) { + return mockSubscribeToPushNotifications(); + } + + if ( + actionType === + 'AuthenticatedUserStorageService:getNotificationPreferences' + ) { + return mockGetNotificationPreferences(); + } + + if ( + actionType === + 'AuthenticatedUserStorageService:putNotificationPreferences' + ) { + return mockPutNotificationPreferences(params[0], params[1]); + } + + throw new Error( + `MOCK_FAIL - unsupported messenger call: ${actionType as string}`, + ); + }); + + return { + globalMessenger, + messenger, + mockGetBearerToken, + mockIsSignedIn, + mockAuthPerformSignIn, + mockAddPushNotificationLinks, + mockDisablePushNotifications, + mockDeletePushNotificationLinks, + mockEnablePushNotifications, + mockSubscribeToPushNotifications, + mockKeyringControllerGetState, + mockGetNotificationPreferences, + mockPutNotificationPreferences, + }; +} + +/** + * Jest Mock Utility - Mock Auth Failure Assertions + * + * @param mocks - mock messenger + * @returns mock test auth scenarios + */ +function arrangeFailureAuthAssertions( + mocks: ReturnType, +): { + notLoggedIn: () => jest.Mock; + noBearerToken: () => jest.Mock; + rejectedBearerToken: () => jest.Mock; +} { + const testScenarios = { + notLoggedIn: (): jest.Mock => mocks.mockIsSignedIn.mockReturnValue(false), + + // unlikely, but in case it returns null + noBearerToken: (): jest.Mock => + mocks.mockGetBearerToken.mockResolvedValueOnce(null as unknown as string), + + rejectedBearerToken: (): jest.Mock => + mocks.mockGetBearerToken.mockRejectedValueOnce( + new Error('MOCK - no bearer token'), + ), + }; + + return testScenarios; +} diff --git a/packages/notification-services-controller/src/NotificationServicesController/NotificationServicesController.ts b/packages/notification-services-controller/src/NotificationServicesController/NotificationServicesController.ts new file mode 100644 index 00000000000..5fac52146a2 --- /dev/null +++ b/packages/notification-services-controller/src/NotificationServicesController/NotificationServicesController.ts @@ -0,0 +1,1620 @@ +import type { + AuthenticatedUserStorageServiceGetNotificationPreferencesAction, + AuthenticatedUserStorageServicePutNotificationPreferencesAction, + NotificationPreferences, + PerpsPreference, + SocialAIPreference, + WalletActivityAccount, +} from '@metamask/authenticated-user-storage'; +import { + DEFAULT_AGENTIC_CLI_PREFERENCES, + DEFAULT_PRICE_ALERT_PREFERENCES, +} from '@metamask/authenticated-user-storage'; +import type { + ControllerGetStateAction, + ControllerStateChangeEvent, + StateMetadata, +} from '@metamask/base-controller'; +import { BaseController } from '@metamask/base-controller'; +import { + isValidHexAddress, + toChecksumHexAddress, +} from '@metamask/controller-utils'; +import type { + KeyringControllerStateChangeEvent, + KeyringControllerGetStateAction, + KeyringControllerLockEvent, + KeyringControllerUnlockEvent, + KeyringControllerState, +} from '@metamask/keyring-controller'; +import type { Messenger } from '@metamask/messenger'; +import type { AuthenticationController } from '@metamask/profile-sync-controller'; +import type { Hex } from '@metamask/utils'; +import { assert } from '@metamask/utils'; +import { debounce } from 'lodash'; +import log from 'loglevel'; + +import type { + NotificationServicesPushControllerStateChangeEvent, + NotificationServicesPushControllerOnNewNotificationEvent, +} from '../NotificationServicesPushController/index.js'; +import type { NotificationServicesPushControllerMethodActions } from '../NotificationServicesPushController/NotificationServicesPushController-method-action-types.js'; +import { TRIGGER_TYPES } from './constants/notification-schema.js'; +import type { NormalisedAPINotification } from './index.js'; +import type { NotificationServicesControllerMethodActions } from './NotificationServicesController-method-action-types.js'; +import { + processAndFilterNotifications, + safeProcessNotification, +} from './processors/process-notifications.js'; +import type { ENV } from './services/api-notifications.js'; +import { + getAPINotifications, + getNotificationsApiConfigCached, + markNotificationsAsRead, +} from './services/api-notifications.js'; +import { getFeatureAnnouncementNotifications } from './services/feature-announcements.js'; +import { createPerpOrderNotification } from './services/perp-notifications.js'; +import type { + INotification, + MarkAsReadNotificationsParam, +} from './types/notification/notification.js'; +import type { OrderInput } from './types/perps/index.js'; + +// Unique name for the controller +const controllerName = 'NotificationServicesController'; + +export const ACCOUNTS_UPDATE_DEBOUNCE_TIME_MS = 1000; + +/** + * State shape for NotificationServicesController + */ +export type NotificationServicesControllerState = { + /** + * We store and manage accounts that have been seen/visited through the + * account subscription. This allows us to track and add notifications for new accounts and not previous accounts added. + */ + subscriptionAccountsSeen: string[]; + + /** + * Flag that indicates if the metamask notifications feature has been seen + */ + isMetamaskNotificationsFeatureSeen: boolean; + + /** + * Flag that indicates if the metamask notifications are enabled + */ + isNotificationServicesEnabled: boolean; + + /** + * Flag that indicates if the feature announcements are enabled + */ + isFeatureAnnouncementsEnabled: boolean; + + /** + * List of metamask notifications + */ + metamaskNotificationsList: INotification[]; + + /** + * List of read metamask notifications + */ + metamaskNotificationsReadList: string[]; + /** + * Flag that indicates that the creating notifications is in progress + */ + isUpdatingMetamaskNotifications: boolean; + /** + * Flag that indicates that the fetching notifications is in progress + * This is used to show a loading spinner in the UI + * when fetching notifications + */ + isFetchingMetamaskNotifications: boolean; + /** + * Flag that indicates that the updating notifications for a specific address is in progress + */ + isUpdatingMetamaskNotificationsAccount: string[]; + /** + * Flag that indicates that the checking accounts presence is in progress + */ + isCheckingAccountsPresence: boolean; +}; + +const metadata: StateMetadata = { + subscriptionAccountsSeen: { + includeInStateLogs: true, + persist: true, + includeInDebugSnapshot: true, + usedInUi: true, + }, + + isMetamaskNotificationsFeatureSeen: { + includeInStateLogs: true, + persist: true, + includeInDebugSnapshot: false, + usedInUi: true, + }, + isNotificationServicesEnabled: { + includeInStateLogs: true, + persist: true, + includeInDebugSnapshot: false, + usedInUi: true, + }, + isFeatureAnnouncementsEnabled: { + includeInStateLogs: true, + persist: true, + includeInDebugSnapshot: false, + usedInUi: true, + }, + metamaskNotificationsList: { + includeInStateLogs: true, + persist: true, + includeInDebugSnapshot: true, + usedInUi: true, + }, + metamaskNotificationsReadList: { + includeInStateLogs: false, + persist: true, + includeInDebugSnapshot: true, + usedInUi: true, + }, + isUpdatingMetamaskNotifications: { + includeInStateLogs: false, + persist: false, + includeInDebugSnapshot: false, + usedInUi: true, + }, + isFetchingMetamaskNotifications: { + includeInStateLogs: false, + persist: false, + includeInDebugSnapshot: false, + usedInUi: true, + }, + isUpdatingMetamaskNotificationsAccount: { + includeInStateLogs: false, + persist: false, + includeInDebugSnapshot: false, + usedInUi: true, + }, + isCheckingAccountsPresence: { + includeInStateLogs: false, + persist: false, + includeInDebugSnapshot: false, + usedInUi: true, + }, +}; +export const defaultState: NotificationServicesControllerState = { + subscriptionAccountsSeen: [], + isMetamaskNotificationsFeatureSeen: false, + isNotificationServicesEnabled: false, + isFeatureAnnouncementsEnabled: false, + metamaskNotificationsList: [], + metamaskNotificationsReadList: [], + isUpdatingMetamaskNotifications: false, + isFetchingMetamaskNotifications: false, + isUpdatingMetamaskNotificationsAccount: [], + isCheckingAccountsPresence: false, +}; + +export type NotificationServicesControllerEnableNotificationsOptions = { + /** + * Whether the user has consented to marketing notifications. Used only when + * notification preferences are being initialized for the first time to seed + * marketing push notifications. + */ + hasMarketingConsent?: boolean; + /** + * Whether product announcements are enabled. Used only when notification + * preferences are being initialized for the first time to seed marketing + * in-app notifications. + */ + productAnnouncementEnabled?: boolean; + /** + * Whether to attempt FCM/device push registration after notification + * preferences are initialized or refreshed. This does not request OS push + * permission. + * + * @default true + */ + registerPushNotifications?: boolean; +}; + +export type NotificationServicesControllerCreateOnChainTriggersOptions = + NotificationServicesControllerEnableNotificationsOptions; + +export type NotificationServicesControllerEnableMetamaskNotificationsOptions = + NotificationServicesControllerEnableNotificationsOptions; + +const locallyPersistedNotificationTypes = new Set([ + TRIGGER_TYPES.SNAP, +]); + +/** + * Hardcoded default Perps notification preferences. Applied when notification + * preferences are initialized for the first time. + */ +export const DEFAULT_PERPS_PREFERENCES: PerpsPreference = { + inAppNotificationsEnabled: true, + pushNotificationsEnabled: true, +}; + +/** + * Hardcoded default Social AI notification preferences. Applied when + * notification preferences are initialized for the first time. + */ +export const DEFAULT_SOCIAL_AI_PREFERENCES: Required = { + inAppNotificationsEnabled: true, + pushNotificationsEnabled: true, + txAmountLimit: 500, + mutedTraderProfileIds: [], +}; + +export { + DEFAULT_AGENTIC_CLI_PREFERENCES, + DEFAULT_PRICE_ALERT_PREFERENCES, +} from '@metamask/authenticated-user-storage'; + +/** + * Builds wallet-activity preferences from the keyring's current accounts. + * + * @param accounts - The keyring accounts to build wallet-activity entries for. + * @returns An array of wallet-activity account entries (lower-cased addresses). + */ +const buildWalletActivityAccounts = ( + accounts: { address: string; enabled: boolean }[], +): WalletActivityAccount[] => + accounts.map(({ address, enabled }) => { + const lowercased = address.toLowerCase(); + return { + address: lowercased as Hex, + enabled, + }; + }); + +/** + * Builds wallet-activity initialization from the Trigger API. If Trigger has no + * enabled entries for the current keyring accounts, this is a first-time + * notification setup and all current accounts should start enabled. + * + * @param bearerToken - JWT used to query Trigger API. + * @param accounts - The keyring accounts to initialize. + * @param env - The environment to use for the Trigger API call. + * @returns Wallet-activity account initialization entries. + */ +const buildWalletActivityAccountsFromTriggerConfig = async ( + bearerToken: string, + accounts: string[], + env: ENV, +): Promise<{ address: string; enabled: boolean }[]> => { + const triggerConfig = await getNotificationsApiConfigCached( + bearerToken, + accounts, + env, + ); + const triggerConfigByAddress = new Map( + triggerConfig.map(({ address, enabled }) => [ + address.toLowerCase(), + enabled, + ]), + ); + const hasEnabledTriggerAccount = accounts.some( + (address) => triggerConfigByAddress.get(address.toLowerCase()) === true, + ); + + return accounts.map((address) => ({ + address, + enabled: hasEnabledTriggerAccount + ? (triggerConfigByAddress.get(address.toLowerCase()) ?? false) + : true, + })); +}; + +/** + * Builds a fresh `NotificationPreferences` blob using hardcoded defaults for + * Perps, Social AI, and Agentic CLI, the supplied wallet-activity accounts and + * the user's marketing/product-announcement flags. + * + * @param walletActivityAccounts - The wallet-activity account config to initialize. + * @param hasMarketingConsent - Whether marketing push notifications should be enabled. + * @param productAnnouncementEnabled - Whether marketing in-app notifications should be enabled. + * @returns A complete `NotificationPreferences` object. + */ +const buildFreshPreferences = ( + walletActivityAccounts: { address: string; enabled: boolean }[], + hasMarketingConsent: boolean, + productAnnouncementEnabled: boolean, +): NotificationPreferences => ({ + walletActivity: { + inAppNotificationsEnabled: true, + pushNotificationsEnabled: true, + accounts: buildWalletActivityAccounts(walletActivityAccounts), + }, + marketing: { + inAppNotificationsEnabled: productAnnouncementEnabled, + pushNotificationsEnabled: hasMarketingConsent, + }, + perps: { ...DEFAULT_PERPS_PREFERENCES }, + socialAI: { ...DEFAULT_SOCIAL_AI_PREFERENCES }, + agenticCli: { ...DEFAULT_AGENTIC_CLI_PREFERENCES }, + priceAlerts: { ...DEFAULT_PRICE_ALERT_PREFERENCES }, +}); + +const MESSENGER_EXPOSED_METHODS = [ + 'init', + 'enablePushNotifications', + 'disablePushNotifications', + 'checkAccountsPresence', + 'setFeatureAnnouncementsEnabled', + 'createOnChainTriggers', + 'enableMetamaskNotifications', + 'disableNotificationServices', + 'disableAccounts', + 'enableAccounts', + 'fetchAndUpdateMetamaskNotifications', + 'getNotificationsByType', + 'deleteNotificationById', + 'deleteNotificationsById', + 'markMetamaskNotificationsAsRead', + 'updateMetamaskNotificationsList', + 'sendPerpPlaceOrderNotification', +] as const; + +export type NotificationServicesControllerGetStateAction = + ControllerGetStateAction< + typeof controllerName, + NotificationServicesControllerState + >; + +// Messenger Actions +export type Actions = + | NotificationServicesControllerGetStateAction + | NotificationServicesControllerMethodActions; + +// Allowed Actions +type AllowedActions = + // Keyring Controller Requests + | KeyringControllerGetStateAction + // Auth Controller Requests + | AuthenticationController.AuthenticationControllerGetBearerTokenAction + | AuthenticationController.AuthenticationControllerIsSignedInAction + | AuthenticationController.AuthenticationControllerPerformSignInAction + // Authenticated User Storage Requests + | AuthenticatedUserStorageServiceGetNotificationPreferencesAction + | AuthenticatedUserStorageServicePutNotificationPreferencesAction + // Push Notifications Controller Requests + | NotificationServicesPushControllerMethodActions; + +// Events +export type NotificationServicesControllerStateChangeEvent = + ControllerStateChangeEvent< + typeof controllerName, + NotificationServicesControllerState + >; + +export type NotificationListUpdatedEvent = { + type: `${typeof controllerName}:notificationsListUpdated`; + payload: [INotification[]]; +}; + +export type MarkNotificationsAsReadEvent = { + type: `${typeof controllerName}:markNotificationsAsRead`; + payload: [INotification[]]; +}; + +// Events +export type Events = + | NotificationServicesControllerStateChangeEvent + | NotificationListUpdatedEvent + | MarkNotificationsAsReadEvent; + +// Allowed Events +type AllowedEvents = + // Keyring Events + | KeyringControllerStateChangeEvent + | KeyringControllerLockEvent + | KeyringControllerUnlockEvent + // Push Notification Events + | NotificationServicesPushControllerOnNewNotificationEvent + | NotificationServicesPushControllerStateChangeEvent; + +// Type for the messenger of NotificationServicesController +export type NotificationServicesControllerMessenger = Messenger< + typeof controllerName, + Actions | AllowedActions, + Events | AllowedEvents +>; + +type FeatureAnnouncementEnv = { + spaceId: string; + accessToken: string; + platform: 'extension' | 'mobile'; + platformVersion?: string; +}; + +/** + * Controller that enables wallet notifications and feature announcements + */ +export class NotificationServicesController extends BaseController< + typeof controllerName, + NotificationServicesControllerState, + NotificationServicesControllerMessenger +> { + readonly #keyringController = { + isUnlocked: false, + + setupLockedStateSubscriptions: (onUnlock: () => Promise): void => { + const { isUnlocked } = this.messenger.call('KeyringController:getState'); + this.#keyringController.isUnlocked = isUnlocked; + + this.messenger.subscribe('KeyringController:unlock', (): void => { + this.#keyringController.isUnlocked = true; + // messaging system cannot await promises + // we don't need to wait for a result on this. + // eslint-disable-next-line @typescript-eslint/no-floating-promises + onUnlock(); + }); + + this.messenger.subscribe('KeyringController:lock', (): void => { + this.#keyringController.isUnlocked = false; + }); + }, + }; + + readonly #auth = { + getBearerToken: async (): Promise => { + return await this.messenger.call( + 'AuthenticationController:getBearerToken', + ); + }, + isSignedIn: (): boolean => { + return this.messenger.call('AuthenticationController:isSignedIn'); + }, + signIn: async (): Promise => { + return await this.messenger.call( + 'AuthenticationController:performSignIn', + ); + }, + }; + + readonly #pushNotifications = { + // Flag to check is notifications have been setup when the browser/extension is initialized. + // We want to re-initialize push notifications when the browser/extension is refreshed + // To ensure we subscribe to the most up-to-date notifications + isSetup: false, + + subscribeToPushNotifications: async (): Promise => { + await this.messenger.call( + 'NotificationServicesPushController:subscribeToPushNotifications', + ); + }, + enablePushNotifications: async (addresses: string[]): Promise => { + try { + await this.messenger.call( + 'NotificationServicesPushController:enablePushNotifications', + addresses, + ); + } catch { + // Do nothing, failing silently. + } + }, + addPushNotificationLinks: async (addresses: string[]): Promise => { + try { + await this.messenger.call( + 'NotificationServicesPushController:addPushNotificationLinks', + addresses, + ); + } catch { + // Do nothing, failing silently. + } + }, + disablePushNotifications: async (): Promise => { + try { + await this.messenger.call( + 'NotificationServicesPushController:disablePushNotifications', + ); + } catch { + // Do nothing, failing silently. + } + }, + deletePushNotificationLinks: async (addresses: string[]): Promise => { + try { + await this.messenger.call( + 'NotificationServicesPushController:deletePushNotificationLinks', + addresses, + ); + } catch { + // Do nothing, failing silently. + } + }, + subscribe: (): void => { + // Coalesce pushes: at most one fetch in-flight, one queued. + // Re-fetch after completion because the new row may not be visible yet when the push arrives. + let pushFetchInFlight = false; + let pendingPushRefetch = false; + + const fetchOnPush = async (): Promise => { + pushFetchInFlight = true; + try { + await this.fetchAndUpdateMetamaskNotifications(); + } finally { + pushFetchInFlight = false; + if (pendingPushRefetch) { + pendingPushRefetch = false; + // eslint-disable-next-line @typescript-eslint/no-floating-promises + fetchOnPush(); + } + } + }; + + this.messenger.subscribe( + 'NotificationServicesPushController:onNewNotifications', + (): void => { + if (pushFetchInFlight) { + pendingPushRefetch = true; + return; + } + // eslint-disable-next-line @typescript-eslint/no-floating-promises + fetchOnPush(); + }, + ); + }, + initializePushNotifications: async (): Promise => { + if (!this.state.isNotificationServicesEnabled) { + return; + } + if (this.#pushNotifications.isSetup) { + return; + } + + // If wallet is unlocked, we can create a fresh push subscription + // Otherwise we can subscribe to original subscription + try { + if (!this.#keyringController.isUnlocked) { + throw new Error('Keyring is locked'); + } + await this.enablePushNotifications(); + this.#pushNotifications.isSetup = true; + } catch { + await this.#pushNotifications + .subscribeToPushNotifications() + .catch(() => { + // do nothing + }); + } + }, + }; + + readonly #accounts = { + // Flag to ensure we only setup once + isNotificationAccountsSetup: false, + + getNotificationAccounts: (): string[] | null => { + const { keyrings } = this.messenger.call('KeyringController:getState'); + const keyringAccounts = [ + ...new Set( + keyrings + .flatMap((keyring) => keyring.accounts) + .map((address) => { + try { + return toChecksumHexAddress(address); + } catch { + return null; + } + }) + .filter( + (address): address is string => + address !== null && isValidHexAddress(address), + ), + ), + ]; + return keyringAccounts.length > 0 ? keyringAccounts : null; + }, + + /** + * Used to get list of addresses from keyring (wallet addresses) + * + * @returns addresses removed, added, and latest list of addresses + */ + listAccounts: (): { + accountsAdded: string[]; + accountsRemoved: string[]; + accounts: string[]; + } => { + // Get previous and current account sets + const nonChecksumAccounts = this.#accounts.getNotificationAccounts(); + if (!nonChecksumAccounts) { + return { + accountsAdded: [], + accountsRemoved: [], + accounts: [], + }; + } + + const accounts = nonChecksumAccounts + .map((address) => toChecksumHexAddress(address)) + .filter((address) => isValidHexAddress(address)); + const currentAccountsSet = new Set(accounts); + const prevAccountsSet = new Set(this.state.subscriptionAccountsSeen); + + // Invalid value you cannot have zero accounts + // Only occurs when the Accounts controller is initializing. + if (accounts.length === 0) { + return { + accountsAdded: [], + accountsRemoved: [], + accounts: [], + }; + } + + // Calculate added and removed addresses + const accountsAdded = accounts.filter( + (account) => !prevAccountsSet.has(account), + ); + const accountsRemoved = [...prevAccountsSet.values()].filter( + (account) => !currentAccountsSet.has(account), + ); + + // Update accounts seen + this.update((state) => { + state.subscriptionAccountsSeen = [...currentAccountsSet]; + }); + + return { + accountsAdded, + accountsRemoved, + accounts, + }; + }, + + /** + * Initializes the cache/previous list. This is handy so we have an accurate in-mem state of the previous list of accounts. + */ + initialize: (): void => { + if ( + this.#keyringController.isUnlocked && + !this.#accounts.isNotificationAccountsSetup + ) { + this.#accounts.listAccounts(); + this.#accounts.isNotificationAccountsSetup = true; + } + }, + + /** + * Subscription to any state change in the keyring controller (aka wallet accounts). + * We can call the `listAccounts` defined above to find out about any accounts added, removed + * And call effects to subscribe/unsubscribe to notifications. + */ + subscribe: (): void => { + const debouncedUpdateAccountNotifications = debounce( + async ( + totalAccounts?: number, + prevTotalAccounts?: number, + ): Promise => { + const hasTotalAccountsChanged = totalAccounts !== prevTotalAccounts; + if ( + !this.state.isNotificationServicesEnabled || + !hasTotalAccountsChanged + ) { + return; + } + + const { accountsAdded, accountsRemoved } = + this.#accounts.listAccounts(); + + const promises: Promise[] = []; + if (accountsAdded.length > 0) { + promises.push(this.enableAccounts(accountsAdded)); + } + if (accountsRemoved.length > 0) { + promises.push(this.disableAccounts(accountsRemoved)); + } + await Promise.allSettled(promises); + }, + 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 + ); + }, + ); + }, + }; + + readonly #locale: () => string; + + readonly #featureAnnouncementEnv: FeatureAnnouncementEnv; + + readonly #env: ENV; + + /** + * Creates a NotificationServicesController 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.env - environment variables for a given controller. + * @param args.env.featureAnnouncements - env variables for feature announcements. + * @param args.env.locale - users locale for better dynamic server notifications + * @param args.env.env - the environment to use for the controller + */ + constructor({ + messenger, + state, + env, + }: { + messenger: NotificationServicesControllerMessenger; + state?: Partial; + env: { + featureAnnouncements: FeatureAnnouncementEnv; + locale?: () => string; + env?: ENV; + }; + }) { + super({ + messenger, + metadata, + name: controllerName, + state: { ...defaultState, ...state }, + }); + + this.#featureAnnouncementEnv = env.featureAnnouncements; + this.#locale = env.locale ?? ((): string => 'en'); + this.#env = env.env ?? 'prd'; + + this.messenger.registerMethodActionHandlers( + this, + MESSENGER_EXPOSED_METHODS, + ); + + this.#clearLoadingStates(); + } + + init(): void { + this.#keyringController.setupLockedStateSubscriptions( + async (): Promise => { + this.#accounts.initialize(); + await this.#pushNotifications.initializePushNotifications(); + }, + ); + + this.#accounts.initialize(); + // eslint-disable-next-line @typescript-eslint/no-floating-promises + this.#pushNotifications.initializePushNotifications(); + this.#accounts.subscribe(); + this.#pushNotifications.subscribe(); + } + + #clearLoadingStates(): void { + this.update((state) => { + state.isUpdatingMetamaskNotifications = false; + state.isCheckingAccountsPresence = false; + state.isFetchingMetamaskNotifications = false; + state.isUpdatingMetamaskNotificationsAccount = []; + }); + } + + #assertAuthEnabled(): void { + if (!this.#auth.isSignedIn()) { + this.update((state) => { + state.isNotificationServicesEnabled = false; + }); + throw new Error('User is not signed in.'); + } + } + + async #enableAuth(): Promise { + const isSignedIn = this.#auth.isSignedIn(); + if (!isSignedIn) { + await this.#auth.signIn(); + } + } + + async #getBearerToken(): Promise<{ bearerToken: string }> { + this.#assertAuthEnabled(); + + const bearerToken = await this.#auth.getBearerToken(); + + if (!bearerToken) { + throw new Error('Missing BearerToken'); + } + + return { bearerToken }; + } + + /** + * Updates the `walletActivity.accounts` entries in the user's + * notification-preferences blob in {@link AuthenticatedUserStorageService}. + * + * `putNotificationPreferences` replaces the entire blob, so we read the + * current preferences, merge the supplied updates into + * `walletActivity.accounts`, and write the result back. This helper is only + * meant to be used for incremental updates (enable/disable individual + * accounts) after the preferences blob has already been initialized via + * {@link createOnChainTriggers}; callers should not rely on it to perform + * first-time initialization. + * + * @param updates - Addresses to register, each with the desired `enabled` flag + */ + async #registerWalletActivityAddresses( + updates: { address: string; enabled: boolean }[], + ): Promise { + if (updates.length === 0) { + return; + } + + const currentPreferences = await this.messenger + .call('AuthenticatedUserStorageService:getNotificationPreferences') + .catch((error) => { + log.error( + 'Failed to get notification preferences. Re-initializing them instead.', + error, + ); + // TODO: return null once validation error captured + // return null; + throw error; + }); + + if (!currentPreferences) { + log.warn( + 'Preferences blob not yet initialized; run `createOnChainTriggers` first.', + ); + return; + } + + const accountsByAddress = new Map( + currentPreferences.walletActivity.accounts.map((account) => [ + account.address.toLowerCase(), + { ...account, address: account.address.toLowerCase() as Hex }, + ]), + ); + for (const update of updates) { + const address = update.address.toLowerCase() as Hex; + accountsByAddress.set(address, { address, enabled: update.enabled }); + } + + const nextPreferences: NotificationPreferences = { + ...currentPreferences, + walletActivity: { + ...currentPreferences.walletActivity, + accounts: [...accountsByAddress.values()], + }, + }; + + await this.messenger.call( + 'AuthenticatedUserStorageService:putNotificationPreferences', + nextPreferences, + this.#featureAnnouncementEnv.platform, + ); + } + + /** + * Sets the state of notification creation process. + * + * This method updates the `isUpdatingMetamaskNotifications` state, which can be used to indicate + * whether the notification creation process is currently active or not. This is useful + * for UI elements that need to reflect the state of ongoing operations, such as loading + * indicators or disabled buttons during processing. + * + * @param isUpdatingMetamaskNotifications - A boolean value representing the new state of the notification creation process. + */ + #setIsUpdatingMetamaskNotifications( + isUpdatingMetamaskNotifications: boolean, + ): void { + this.update((state) => { + state.isUpdatingMetamaskNotifications = isUpdatingMetamaskNotifications; + }); + } + + /** + * Updates the state to indicate whether fetching of MetaMask notifications is in progress. + * + * This method is used to set the `isFetchingMetamaskNotifications` state, which can be utilized + * to show or hide loading indicators in the UI when notifications are being fetched. + * + * @param isFetchingMetamaskNotifications - A boolean value representing the fetching state. + */ + #setIsFetchingMetamaskNotifications( + isFetchingMetamaskNotifications: boolean, + ): void { + this.update((state) => { + state.isFetchingMetamaskNotifications = isFetchingMetamaskNotifications; + }); + } + + /** + * Updates the state to indicate that the checking of accounts presence is in progress. + * + * This method modifies the `isCheckingAccountsPresence` state, which can be used to manage UI elements + * that depend on the status of account presence checks, such as displaying loading indicators or disabling + * buttons while the check is ongoing. + * + * @param isCheckingAccountsPresence - A boolean value indicating whether the account presence check is currently active. + */ + #setIsCheckingAccountsPresence(isCheckingAccountsPresence: boolean): void { + this.update((state) => { + state.isCheckingAccountsPresence = isCheckingAccountsPresence; + }); + } + + /** + * Updates the state to indicate that account updates are in progress. + * Removes duplicate accounts before updating the state. + * + * @param accounts - The accounts being updated. + */ + #updateUpdatingAccountsState(accounts: string[]): void { + this.update((state) => { + const uniqueAccounts = new Set([ + ...state.isUpdatingMetamaskNotificationsAccount, + ...accounts, + ]); + state.isUpdatingMetamaskNotificationsAccount = Array.from(uniqueAccounts); + }); + } + + /** + * Clears the state indicating that account updates are complete. + * + * @param accounts - The accounts that have finished updating. + */ + #clearUpdatingAccountsState(accounts: string[]): void { + this.update((state) => { + state.isUpdatingMetamaskNotificationsAccount = + state.isUpdatingMetamaskNotificationsAccount.filter( + (existingAccount) => !accounts.includes(existingAccount), + ); + }); + } + + /** + * Public method to expose enabling push notifications + */ + public async enablePushNotifications(): Promise { + try { + const preferences = await this.messenger.call( + 'AuthenticatedUserStorageService:getNotificationPreferences', + ); + const enabledAddresses = (preferences?.walletActivity.accounts ?? []) + .filter((account) => account.enabled) + .map((account) => account.address); + if (enabledAddresses.length > 0) { + await this.#pushNotifications.enablePushNotifications(enabledAddresses); + } + } catch { + // Do nothing, failing silently. + } + } + + /** + * Public method to expose disabling push notifications + */ + public async disablePushNotifications(): Promise { + await this.#pushNotifications.disablePushNotifications(); + } + + public async checkAccountsPresence( + accounts: string[], + ): Promise> { + try { + this.#setIsCheckingAccountsPresence(true); + + const preferences = await this.messenger.call( + 'AuthenticatedUserStorageService:getNotificationPreferences', + ); + const enabledByAddress = new Map( + (preferences?.walletActivity.accounts ?? []).map((account) => [ + account.address.toLowerCase(), + account.enabled, + ]), + ); + + const result: Record = {}; + for (const address of accounts) { + result[address] = enabledByAddress.get(address.toLowerCase()) ?? false; + } + return result; + } catch (error) { + log.error('Failed to check accounts presence', error); + throw error; + } finally { + this.#setIsCheckingAccountsPresence(false); + } + } + + /** + * Sets the enabled state of feature announcements. + * + * **Action** - used in the notification settings to enable/disable feature announcements. + * + * @param featureAnnouncementsEnabled - A boolean value indicating the desired enabled state of the feature announcements. + * @async + * @throws {Error} If fails to update + */ + public async setFeatureAnnouncementsEnabled( + featureAnnouncementsEnabled: boolean, + ): Promise { + try { + this.update((state) => { + state.isFeatureAnnouncementsEnabled = featureAnnouncementsEnabled; + }); + } catch (error) { + log.error('Unable to toggle feature announcements', error); + throw new Error('Unable to toggle feature announcements'); + } + } + + /** + * This creates/re-creates on-chain triggers defined in User Storage. + * + * **Action** - Used during Sign In / Enabling of notifications. + * + * Notification preferences are initialized only when + * {@link AuthenticatedUserStorageService} has no stored preferences yet. + * Existing preferences are left as-is. + * + * @param opts - optional options to mutate this functionality + * @param opts.hasMarketingConsent - The user's marketing-consent flag. + * Used only during initialization to seed marketing push notifications. + * @param opts.productAnnouncementEnabled - The user's product-announcement flag. + * Used only during initialization to seed marketing in-app notifications. + * @param opts.registerPushNotifications - Whether to attempt FCM/device push registration. + * @returns The updated or newly created user storage. + * @throws {Error} Throws an error if unauthenticated or from other operations. + */ + public async createOnChainTriggers( + opts: NotificationServicesControllerCreateOnChainTriggersOptions = {}, + ): Promise { + try { + this.#setIsUpdatingMetamaskNotifications(true); + + const { bearerToken } = await this.#getBearerToken(); + + const { accounts } = this.#accounts.listAccounts(); + + // 1. Read existing AUS notification preferences and initialize only if absent. + const preferences = await this.messenger.call( + 'AuthenticatedUserStorageService:getNotificationPreferences', + ); + + const hasMarketingConsent = Boolean(opts?.hasMarketingConsent); + const productAnnouncementEnabled = Boolean( + opts?.productAnnouncementEnabled, + ); + let nextPreferences: NotificationPreferences | undefined; + + if (preferences === null) { + const walletActivityAccounts = + await buildWalletActivityAccountsFromTriggerConfig( + bearerToken, + accounts, + this.#env, + ); + + nextPreferences = buildFreshPreferences( + walletActivityAccounts, + hasMarketingConsent, + productAnnouncementEnabled, + ); + } + + if (nextPreferences) { + await this.messenger.call( + 'AuthenticatedUserStorageService:putNotificationPreferences', + nextPreferences, + this.#featureAnnouncementEnv.platform, + ); + } + + const effectivePreferences = nextPreferences ?? preferences; + const accountsWithNotifications = ( + effectivePreferences?.walletActivity.accounts ?? [] + ) + .filter((account) => account.enabled) + .map((account) => account.address); + + if (opts.registerPushNotifications ?? true) { + // Attempt FCM/device registration only; clients must request OS permission separately. + this.#pushNotifications + .enablePushNotifications(accountsWithNotifications) + .catch(() => { + // Do Nothing + }); + } + + // Update the state of the controller + this.update((state) => { + // User is re-subscribing (daily resub to get latest notifications) + if (state.isNotificationServicesEnabled) { + // Keep their existing preferences on re-subscribe + // No state updates needed - preserving user's current settings + } else { + // User is turning on notifications from a disabled state + state.isNotificationServicesEnabled = true; + state.isFeatureAnnouncementsEnabled = true; + state.isMetamaskNotificationsFeatureSeen = true; + } + }); + } catch (error) { + log.error('Failed to create On Chain triggers', error); + throw new Error('Failed to create On Chain triggers'); + } finally { + this.#setIsUpdatingMetamaskNotifications(false); + } + } + + /** + * Enables all MetaMask notifications for the user. + * This is identical flow when initializing notifications for the first time. + * + * @param opts - Optional options to mutate this functionality. + * @throws {Error} If there is an error during the process of enabling notifications. + */ + public async enableMetamaskNotifications( + opts: NotificationServicesControllerEnableMetamaskNotificationsOptions = {}, + ): Promise { + try { + this.#setIsUpdatingMetamaskNotifications(true); + await this.#enableAuth(); + await this.createOnChainTriggers(opts); + } catch (error) { + log.error('Unable to enable notifications', error); + throw new Error('Unable to enable notifications'); + } finally { + this.#setIsUpdatingMetamaskNotifications(false); + } + } + + /** + * Disables all MetaMask notifications for the user. + * This method ensures that the user is authenticated, retrieves all linked accounts, + * and disables on-chain triggers for each account. It also sets the global notification + * settings for MetaMask, feature announcements to false. + * + * @throws {Error} If the user is not authenticated or if there is an error during the process. + */ + public async disableNotificationServices(): Promise { + this.#setIsUpdatingMetamaskNotifications(true); + + // Attempt Disable Push Notifications + try { + await this.#pushNotifications.disablePushNotifications(); + } catch { + // Do nothing + } + + // Update State: remove non-permitted notifications & disable flags + const snapNotifications = this.state.metamaskNotificationsList.filter( + (notification) => notification.type === TRIGGER_TYPES.SNAP, + ); + this.update((state) => { + state.isNotificationServicesEnabled = false; + state.isFeatureAnnouncementsEnabled = false; + // reassigning the notifications list with just snaps + // since the disable shouldn't affect snaps notifications + state.metamaskNotificationsList = snapNotifications; + }); + + // Finish Updating State + this.#setIsUpdatingMetamaskNotifications(false); + } + + /** + * Deletes on-chain triggers associated with a specific account/s. + * This method performs several key operations: + * 1. Validates Auth + * 2. Deletes accounts + * (note) We do not need to look through push notifications as we've deleted triggers + * + * **Action** - When a user disables notifications for a given account in settings. + * + * @param accounts - The account for which on-chain triggers are to be deleted. + * @returns A promise that resolves to void or an object containing a success message. + * @throws {Error} Throws an error if unauthenticated or from other operations. + */ + public async disableAccounts(accounts: string[]): Promise { + try { + this.#updateUpdatingAccountsState(accounts); + // Sign-in gate. + await this.#getBearerToken(); + + await this.#registerWalletActivityAddresses( + accounts.map((address) => ({ address, enabled: false })), + ); + + await this.#pushNotifications.deletePushNotificationLinks(accounts); + } catch { + throw new Error('Failed to delete OnChain triggers'); + } finally { + this.#clearUpdatingAccountsState(accounts); + } + } + + /** + * Updates/Creates on-chain triggers for a specific account. + * + * This method performs several key operations: + * 1. Validates Auth & Storage + * 2. Finds and creates any missing triggers associated with the account + * 3. Enables any related push notifications + * 4. Updates Storage to reflect new state. + * + * **Action** - When a user enables notifications for an account + * + * @param accounts - List of accounts you want to update. + * @returns A promise that resolves to the updated user storage. + * @throws {Error} Throws an error if unauthenticated or from other operations. + */ + public async enableAccounts(accounts: string[]): Promise { + try { + this.#updateUpdatingAccountsState(accounts); + + // Sign-in gate. + await this.#getBearerToken(); + await this.#registerWalletActivityAddresses( + accounts.map((address) => ({ address, enabled: true })), + ); + + await this.#pushNotifications.addPushNotificationLinks(accounts); + } catch (error) { + log.error('Failed to update OnChain triggers', error); + throw new Error('Failed to update OnChain triggers'); + } finally { + this.#clearUpdatingAccountsState(accounts); + } + } + + /** + * Fetches the list of metamask notifications. + * This includes OnChain notifications; Feature Announcements; and Snap Notifications. + * + * **Action** - When a user views the notification list page/dropdown + * + * @param previewToken - the preview token to use if needed + * @returns A promise that resolves to the list of notifications. + * @throws {Error} Throws an error if unauthenticated or from other operations. + */ + public async fetchAndUpdateMetamaskNotifications( + previewToken?: string, + ): Promise { + try { + this.#setIsFetchingMetamaskNotifications(true); + + // This is used by Feature Announcement & On Chain + // Not used by Snaps + const isGlobalNotifsEnabled = this.state.isNotificationServicesEnabled; + const notificationPreferences = isGlobalNotifsEnabled + ? await this.messenger + .call('AuthenticatedUserStorageService:getNotificationPreferences') + .catch(() => null) + : null; + + // Raw Feature Notifications + const rawAnnouncements = + isGlobalNotifsEnabled && + notificationPreferences?.marketing.inAppNotificationsEnabled + ? await getFeatureAnnouncementNotifications( + this.#featureAnnouncementEnv, + previewToken, + ).catch(() => []) + : []; + + // Raw On Chain Notifications + const rawOnChainNotifications: NormalisedAPINotification[] = []; + if (isGlobalNotifsEnabled) { + try { + const { bearerToken } = await this.#getBearerToken(); + const addressesWithNotifications = ( + notificationPreferences?.walletActivity.accounts ?? [] + ) + .filter((account) => account.enabled) + .map((account) => account.address); + const notifications = await getAPINotifications( + bearerToken, + addressesWithNotifications, + this.#locale(), + this.#featureAnnouncementEnv.platform, + this.#env, + ).catch(() => []); + rawOnChainNotifications.push(...notifications); + } catch { + // Do nothing + } + } + + // Snap Notifications (original) + // We do not want to remove them + const snapNotifications = this.state.metamaskNotificationsList.filter( + (notification) => notification.type === TRIGGER_TYPES.SNAP, + ); + + const readIds = this.state.metamaskNotificationsReadList; + + // Combine Notifications + const metamaskNotifications: INotification[] = [ + ...processAndFilterNotifications(rawAnnouncements, readIds), + ...processAndFilterNotifications(rawOnChainNotifications, readIds), + ...snapNotifications, + ]; + + // Sort Notifications + metamaskNotifications.sort( + (notificationA, notificationB) => + new Date(notificationB.createdAt).getTime() - + new Date(notificationA.createdAt).getTime(), + ); + + // Update State + this.update((state) => { + state.metamaskNotificationsList = metamaskNotifications; + }); + + this.messenger.publish( + `${controllerName}:notificationsListUpdated`, + this.state.metamaskNotificationsList, + ); + + this.#setIsFetchingMetamaskNotifications(false); + return metamaskNotifications; + } catch (error) { + this.#setIsFetchingMetamaskNotifications(false); + log.error('Failed to fetch notifications', error); + throw new Error('Failed to fetch notifications'); + } + } + + /** + * Gets the specified type of notifications from state. + * + * @param type - The trigger type. + * @returns An array of notifications of the passed in type. + * @throws Throws an error if an invalid trigger type is passed. + */ + public getNotificationsByType(type: TRIGGER_TYPES): INotification[] { + assert( + Object.values(TRIGGER_TYPES).includes(type), + 'Invalid trigger type.', + ); + return this.state.metamaskNotificationsList.filter( + (notification) => notification.type === type, + ); + } + + /** + * Used to delete a notification by id. + * + * Note: This function should only be used for notifications that are stored + * in this controller directly, currently only snaps notifications. + * + * @param id - The id of the notification to delete. + */ + public async deleteNotificationById(id: string): Promise { + const fetchedNotification = this.state.metamaskNotificationsList.find( + (notification) => notification.id === id, + ); + + assert( + fetchedNotification, + 'The notification to be deleted does not exist.', + ); + + assert( + locallyPersistedNotificationTypes.has(fetchedNotification.type), + `The notification type of "${ + // notifications are guaranteed to have type properties which equate to strings + fetchedNotification.type as string + }" is not locally persisted, only the following types can use this function: ${[ + ...locallyPersistedNotificationTypes, + ].join(', ')}.`, + ); + + const newList = this.state.metamaskNotificationsList.filter( + (notification) => notification.id !== id, + ); + + this.update((state) => { + state.metamaskNotificationsList = newList; + }); + } + + /** + * Used to batch delete notifications by id. + * + * Note: This function should only be used for notifications that are stored + * in this controller directly, currently only snaps notifications. + * + * @param ids - The ids of the notifications to delete. + */ + public async deleteNotificationsById(ids: string[]): Promise { + for (const id of ids) { + await this.deleteNotificationById(id); + } + + this.messenger.publish( + `${controllerName}:notificationsListUpdated`, + this.state.metamaskNotificationsList, + ); + } + + /** + * Marks specified metamask notifications as read. + * + * @param notifications - An array of notifications to be marked as read. Each notification should include its type and read status. + * @returns A promise that resolves when the operation is complete. + */ + public async markMetamaskNotificationsAsRead( + notifications: MarkAsReadNotificationsParam, + ): Promise { + let onchainNotificationIds: string[] = []; + let featureAnnouncementNotificationIds: string[] = []; + let snapNotificationIds: string[] = []; + + try { + const [ + onChainNotifications, + featureAnnouncementNotifications, + snapNotifications, + ] = notifications.reduce< + [ + MarkAsReadNotificationsParam, + MarkAsReadNotificationsParam, + MarkAsReadNotificationsParam, + ] + >( + (allNotifications, notification) => { + if (!notification.isRead) { + switch (notification.type) { + case TRIGGER_TYPES.FEATURES_ANNOUNCEMENT: + allNotifications[1].push(notification); + break; + case TRIGGER_TYPES.SNAP: + allNotifications[2].push(notification); + break; + default: + allNotifications[0].push(notification); + } + } + return allNotifications; + }, + [[], [], []], + ); + + // Mark On-Chain Notifications as Read + if (onChainNotifications.length > 0) { + const bearerToken = await this.#auth.getBearerToken(); + + if (bearerToken) { + onchainNotificationIds = onChainNotifications.map( + (notification) => notification.id, + ); + await markNotificationsAsRead( + bearerToken, + onchainNotificationIds, + this.#env, + ).catch(() => { + onchainNotificationIds = []; + log.warn('Unable to mark onchain notifications as read'); + }); + } + } + + // Mark Off-Chain notifications as Read + if (featureAnnouncementNotifications.length > 0) { + featureAnnouncementNotificationIds = + featureAnnouncementNotifications.map( + (notification) => notification.id, + ); + } + + if (snapNotifications.length > 0) { + snapNotificationIds = snapNotifications.map( + (notification) => notification.id, + ); + } + } catch (error) { + log.warn('Something failed when marking notifications as read', error); + } + + // Update the state (state is also used on counter & badge) + this.update((state) => { + const currentReadList = state.metamaskNotificationsReadList; + const newReadIds = [ + ...featureAnnouncementNotificationIds, + ...snapNotificationIds, + ]; + state.metamaskNotificationsReadList = [ + ...new Set([...currentReadList, ...newReadIds]), + ]; + + state.metamaskNotificationsList = state.metamaskNotificationsList.map( + (notification: INotification) => { + if ( + newReadIds.includes(notification.id) || + onchainNotificationIds.includes(notification.id) + ) { + if (notification.type === TRIGGER_TYPES.SNAP) { + return { + ...notification, + isRead: true, + readDate: new Date().toISOString(), + }; + } + return { ...notification, isRead: true }; + } + return notification; + }, + ); + }); + + this.messenger.publish( + `${controllerName}:markNotificationsAsRead`, + this.state.metamaskNotificationsList, + ); + } + + /** + * Updates the list of MetaMask notifications by adding a new notification at the beginning of the list. + * This method ensures that the most recent notification is displayed first in the UI. + * + * @param notification - The new notification object to be added to the list. + * @returns A promise that resolves when the notification list has been successfully updated. + */ + public async updateMetamaskNotificationsList( + notification: INotification, + ): Promise { + if ( + this.state.metamaskNotificationsList.some( + (existingNotification) => existingNotification.id === notification.id, + ) + ) { + return; + } + + const processedNotification = safeProcessNotification(notification); + + if (processedNotification) { + this.update((state) => { + const existingNotificationIds = new Set( + state.metamaskNotificationsList.map( + (existingNotification) => existingNotification.id, + ), + ); + // Add the new notification only if its ID is not already present in the list + if (!existingNotificationIds.has(processedNotification.id)) { + state.metamaskNotificationsList = [ + processedNotification, + ...state.metamaskNotificationsList, + ]; + } + }); + + this.messenger.publish( + `${controllerName}:notificationsListUpdated`, + this.state.metamaskNotificationsList, + ); + } + } + + /** + * Creates an perp order notification subscription. + * Requires notifications and auth to be enabled to start receiving this notifications + * + * @param input perp input + */ + public async sendPerpPlaceOrderNotification( + input: OrderInput, + ): Promise { + try { + const { bearerToken } = await this.#getBearerToken(); + await createPerpOrderNotification(bearerToken, input); + } catch { + // Do Nothing + } + } +} diff --git a/packages/notification-services-controller/src/NotificationServicesController/__fixtures__/mockAddresses.ts b/packages/notification-services-controller/src/NotificationServicesController/__fixtures__/mockAddresses.ts new file mode 100644 index 00000000000..d4d0a1d1501 --- /dev/null +++ b/packages/notification-services-controller/src/NotificationServicesController/__fixtures__/mockAddresses.ts @@ -0,0 +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/__fixtures__/mockServices.ts b/packages/notification-services-controller/src/NotificationServicesController/__fixtures__/mockServices.ts new file mode 100644 index 00000000000..bdab54c3f6d --- /dev/null +++ b/packages/notification-services-controller/src/NotificationServicesController/__fixtures__/mockServices.ts @@ -0,0 +1,77 @@ +import nock from 'nock'; + +import { + getMockOnChainNotificationsConfig, + getMockFeatureAnnouncementResponse, + getMockListNotificationsResponse, + getMockMarkNotificationsAsReadResponse, + getMockCreatePerpOrderNotification, +} from '../mocks/mockResponses.js'; + +type MockReply = { + status: nock.StatusCode; + body?: nock.Body; +}; + +export const mockFetchFeatureAnnouncementNotifications = ( + mockReply?: MockReply, +): nock.Scope => { + const mockResponse = getMockFeatureAnnouncementResponse(); + const reply = mockReply ?? { status: 200, body: mockResponse.response }; + const mockEndpoint = nock(mockResponse.url) + .get('') + .query(true) + .reply(reply.status, reply.body); + + return mockEndpoint; +}; + +export const mockGetOnChainNotificationsConfig = ( + mockReply?: MockReply, +): nock.Scope => { + const mockResponse = getMockOnChainNotificationsConfig(); + const reply = mockReply ?? { status: 200, body: mockResponse.response }; + + const mockEndpoint = nock(mockResponse.url) + .post('') + .reply(reply.status, reply.body); + + return mockEndpoint; +}; + +export const mockGetAPINotifications = (mockReply?: MockReply): nock.Scope => { + const mockResponse = getMockListNotificationsResponse(); + const reply = mockReply ?? { status: 200, body: mockResponse.response }; + + const mockEndpoint = nock(mockResponse.url) + .post('') + .query(true) + .reply(reply.status, reply.body); + + return mockEndpoint; +}; + +export const mockMarkNotificationsAsRead = ( + mockReply?: MockReply, +): nock.Scope => { + const mockResponse = getMockMarkNotificationsAsReadResponse(); + const reply = mockReply ?? { status: 200 }; + + const mockEndpoint = nock(mockResponse.url) + .post('') + .reply(reply.status, reply.body); + + return mockEndpoint; +}; + +export const mockCreatePerpNotification = ( + mockReply?: MockReply, +): nock.Scope => { + const mockResponse = getMockCreatePerpOrderNotification(); + const reply = mockReply ?? { status: 201 }; + const mockEndpoint = nock(mockResponse.url) + .persist() + .post('') + .reply(reply.status); + return mockEndpoint; +}; diff --git a/packages/notification-services-controller/src/NotificationServicesController/__fixtures__/test-utils.ts b/packages/notification-services-controller/src/NotificationServicesController/__fixtures__/test-utils.ts new file mode 100644 index 00000000000..f97f266894f --- /dev/null +++ b/packages/notification-services-controller/src/NotificationServicesController/__fixtures__/test-utils.ts @@ -0,0 +1,35 @@ +type WaitForOptions = { + intervalMs?: number; + timeoutMs?: number; +}; + +/** + * Testing Utility - waitFor. Waits for and checks (at an interval) if assertion is reached. + * + * @param assertionFn - assertion function + * @param options - set wait for options + * @returns promise that you need to await in tests + */ +export const waitFor = async ( + assertionFn: () => void, + options: WaitForOptions = {}, +): Promise => { + const { intervalMs = 50, timeoutMs = 2000 } = options; + + const startTime = Date.now(); + + return new Promise((resolve, reject) => { + const intervalId = setInterval(() => { + try { + assertionFn(); + clearInterval(intervalId); + resolve(); + } catch { + if (Date.now() - startTime >= timeoutMs) { + clearInterval(intervalId); + reject(new Error(`waitFor: timeout reached after ${timeoutMs}ms`)); + } + } + }, intervalMs); + }); +}; diff --git a/packages/notification-services-controller/src/NotificationServicesController/constants/index.ts b/packages/notification-services-controller/src/NotificationServicesController/constants/index.ts new file mode 100644 index 00000000000..4d8d6f37b13 --- /dev/null +++ b/packages/notification-services-controller/src/NotificationServicesController/constants/index.ts @@ -0,0 +1 @@ +export * from './notification-schema.js'; diff --git a/packages/notification-services-controller/src/NotificationServicesController/constants/notification-schema.ts b/packages/notification-services-controller/src/NotificationServicesController/constants/notification-schema.ts new file mode 100644 index 00000000000..12644d658b7 --- /dev/null +++ b/packages/notification-services-controller/src/NotificationServicesController/constants/notification-schema.ts @@ -0,0 +1,42 @@ +// Using SCREAMING_SNAKE_CASE for enum name and members to mirror snake_case API string values for readability +/* eslint-disable @typescript-eslint/naming-convention */ +export enum TRIGGER_TYPES { + FEATURES_ANNOUNCEMENT = 'features_announcement', + METAMASK_SWAP_COMPLETED = 'metamask_swap_completed', + ERC20_SENT = 'erc20_sent', + ERC20_RECEIVED = 'erc20_received', + ETH_SENT = 'eth_sent', + ETH_RECEIVED = 'eth_received', + ROCKETPOOL_STAKE_COMPLETED = 'rocketpool_stake_completed', + ROCKETPOOL_UNSTAKE_COMPLETED = 'rocketpool_unstake_completed', + LIDO_STAKE_COMPLETED = 'lido_stake_completed', + LIDO_WITHDRAWAL_REQUESTED = 'lido_withdrawal_requested', + LIDO_WITHDRAWAL_COMPLETED = 'lido_withdrawal_completed', + LIDO_STAKE_READY_TO_BE_WITHDRAWN = 'lido_stake_ready_to_be_withdrawn', + ERC721_SENT = 'erc721_sent', + ERC721_RECEIVED = 'erc721_received', + ERC1155_SENT = 'erc1155_sent', + ERC1155_RECEIVED = 'erc1155_received', + SNAP = 'snap', + PLATFORM = 'platform', +} +/* eslint-enable @typescript-eslint/naming-convention */ + +export const NOTIFICATION_API_TRIGGER_TYPES_SET: Set = new Set([ + TRIGGER_TYPES.METAMASK_SWAP_COMPLETED, + TRIGGER_TYPES.ERC20_SENT, + TRIGGER_TYPES.ERC20_RECEIVED, + TRIGGER_TYPES.ETH_SENT, + TRIGGER_TYPES.ETH_RECEIVED, + TRIGGER_TYPES.ROCKETPOOL_STAKE_COMPLETED, + TRIGGER_TYPES.ROCKETPOOL_UNSTAKE_COMPLETED, + TRIGGER_TYPES.LIDO_STAKE_COMPLETED, + TRIGGER_TYPES.LIDO_WITHDRAWAL_REQUESTED, + TRIGGER_TYPES.LIDO_WITHDRAWAL_COMPLETED, + TRIGGER_TYPES.LIDO_STAKE_READY_TO_BE_WITHDRAWN, + TRIGGER_TYPES.ERC721_SENT, + TRIGGER_TYPES.ERC721_RECEIVED, + TRIGGER_TYPES.ERC1155_SENT, + TRIGGER_TYPES.ERC1155_RECEIVED, + TRIGGER_TYPES.PLATFORM, +]); diff --git a/packages/notification-services-controller/src/NotificationServicesController/index.ts b/packages/notification-services-controller/src/NotificationServicesController/index.ts new file mode 100644 index 00000000000..08fa0e6df85 --- /dev/null +++ b/packages/notification-services-controller/src/NotificationServicesController/index.ts @@ -0,0 +1,35 @@ +import { NotificationServicesController } from './NotificationServicesController.js'; + +export { NotificationServicesController as Controller }; +export default NotificationServicesController; +export * from './NotificationServicesController.js'; +export type * as Types from './types/index.js'; +export type * from './types/index.js'; +export * as Processors from './processors/index.js'; +export * from './processors/index.js'; +export * as Constants from './constants/index.js'; +export * from './constants/index.js'; +export * as Mocks from './mocks/index.js'; +export * from '../shared/index.js'; +export { isVersionInBounds } from './utils/isVersionInBounds.js'; +export { getNotificationSubtype } from './utils/get-notification-subtype.js'; + +export type { + NotificationServicesControllerInitAction, + NotificationServicesControllerEnablePushNotificationsAction, + NotificationServicesControllerDisablePushNotificationsAction, + NotificationServicesControllerCheckAccountsPresenceAction, + NotificationServicesControllerSetFeatureAnnouncementsEnabledAction, + NotificationServicesControllerCreateOnChainTriggersAction, + NotificationServicesControllerEnableMetamaskNotificationsAction, + NotificationServicesControllerDisableNotificationServicesAction, + NotificationServicesControllerDisableAccountsAction, + NotificationServicesControllerEnableAccountsAction, + NotificationServicesControllerFetchAndUpdateMetamaskNotificationsAction, + NotificationServicesControllerGetNotificationsByTypeAction, + NotificationServicesControllerDeleteNotificationByIdAction, + NotificationServicesControllerDeleteNotificationsByIdAction, + NotificationServicesControllerMarkMetamaskNotificationsAsReadAction, + NotificationServicesControllerUpdateMetamaskNotificationsListAction, + NotificationServicesControllerSendPerpPlaceOrderNotificationAction, +} from './NotificationServicesController-method-action-types.js'; diff --git a/packages/notification-services-controller/src/NotificationServicesController/mocks/index.ts b/packages/notification-services-controller/src/NotificationServicesController/mocks/index.ts new file mode 100644 index 00000000000..7d3df3199ff --- /dev/null +++ b/packages/notification-services-controller/src/NotificationServicesController/mocks/index.ts @@ -0,0 +1,4 @@ +export * from './mock-feature-announcements.js'; +export * from './mock-raw-notifications.js'; +export * from './mockResponses.js'; +export * from './mock-snap-notification.js'; 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 new file mode 100644 index 00000000000..21e56537a2e --- /dev/null +++ b/packages/notification-services-controller/src/NotificationServicesController/mocks/mock-feature-announcements.ts @@ -0,0 +1,215 @@ +import { TRIGGER_TYPES } from '../constants/notification-schema.js'; +import type { ContentfulResult } from '../services/feature-announcements.js'; +import type { FeatureAnnouncementRawNotification } from '../types/feature-announcement/feature-announcement.js'; + +/** + * Mocking Utility - create a mock normalized feature announcement + * + * @returns Mock Normalized Feature Announcement + */ +export function createMockFeatureAnnouncementAPIResult(): ContentfulResult { + return { + sys: { + type: 'Array', + }, + total: 17, + skip: 0, + limit: 1, + items: [ + { + metadata: { + tags: [], + }, + sys: { + space: { + sys: { + type: 'Link', + linkType: 'Space', + id: 'jdkgyfmyd9sw', + }, + }, + id: '1ABRmHaNCgmxROKXXLXsMu', + type: 'Entry', + 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', + type: 'Link', + linkType: 'Environment', + }, + }, + revision: 1, + contentType: { + sys: { + type: 'Link', + linkType: 'ContentType', + id: 'productAnnouncement', + }, + }, + locale: 'en-US', + }, + fields: { + title: 'Don’t miss out on airdrops and new NFT mints!', + id: 'dont-miss-out-on-airdrops-and-new-nft-mints', + category: 'ANNOUNCEMENT', + shortDescription: + 'Check your airdrop eligibility and see trending NFT drops. Head over to the Explore tab to get started. ', + image: { + sys: { + type: 'Link', + linkType: 'Asset', + id: '5jqq8sFeLc6XEoeWlpI3aB', + }, + }, + longDescription: { + data: {}, + content: [ + { + data: {}, + content: [ + { + data: {}, + marks: [], + value: + 'You can now verify if any of your connected addresses are eligible for airdrops and other ERC-20 claims in a secure and convenient way. We’ve also added trending NFT mints based on creators you’ve minted from before or other tokens you hold. Head over to the Explore tab to get started. \n', + nodeType: 'text', + }, + ], + nodeType: 'paragraph', + }, + ], + nodeType: 'document', + }, + link: { + sys: { + type: 'Link', + linkType: 'Entry', + id: '62xKYM2ydo4F1mS5q97K5q', + }, + }, + }, + }, + ], + includes: { + Entry: [ + { + metadata: { + tags: [], + }, + sys: { + space: { + sys: { + type: 'Link', + linkType: 'Space', + id: 'jdkgyfmyd9sw', + }, + }, + id: '62xKYM2ydo4F1mS5q97K5q', + type: 'Entry', + createdAt: '2024-04-09T13:23:03.636Z', + updatedAt: '2024-04-09T13:23:03.636Z', + environment: { + sys: { + id: 'master', + type: 'Link', + linkType: 'Environment', + }, + }, + revision: 1, + contentType: { + sys: { + type: 'Link', + linkType: 'ContentType', + id: 'link', + }, + }, + locale: 'en-US', + }, + fields: { + extensionLinkText: 'Try now', + extensionLinkRoute: 'home.html', + }, + }, + ], + Asset: [ + { + metadata: { + tags: [], + }, + sys: { + space: { + sys: { + type: 'Link', + linkType: 'Space', + id: 'jdkgyfmyd9sw', + }, + }, + id: '5jqq8sFeLc6XEoeWlpI3aB', + type: 'Asset', + createdAt: '2024-04-09T13:23:13.327Z', + updatedAt: '2024-04-09T13:23:13.327Z', + environment: { + sys: { + id: 'master', + type: 'Link', + linkType: 'Environment', + }, + }, + revision: 1, + locale: 'en-US', + }, + fields: { + title: 'PDAPP notification image Airdrops & NFT mints', + description: '', + file: { + url: '//images.ctfassets.net/jdkgyfmyd9sw/5jqq8sFeLc6XEoeWlpI3aB/73ee0f1afa9916c3a7538b0bbee09c26/PDAPP_notification_image_Airdrops___NFT_mints.png', + details: { + size: 797731, + image: { + width: 2880, + height: 1921, + }, + }, + fileName: 'PDAPP notification image_Airdrops & NFT mints.png', + contentType: 'image/png', + }, + }, + }, + ], + }, + } as unknown as ContentfulResult; +} + +/** + * Mocking Utility - create a mock raw feature announcement + * + * @returns Mock Raw Feature Announcement + */ +export function createMockFeatureAnnouncementRaw(): FeatureAnnouncementRawNotification { + return { + type: TRIGGER_TYPES.FEATURES_ANNOUNCEMENT, + createdAt: '2999-04-09T13:24:01.872Z', + data: { + id: 'dont-miss-out-on-airdrops-and-new-nft-mints', + category: 'ANNOUNCEMENT', + title: 'Don’t miss out on airdrops and new NFT mints!', + longDescription: `

You can now verify if any of your connected addresses are eligible for airdrops and other ERC-20 claims in a secure and convenient way. We’ve also added trending NFT mints based on creators you’ve minted from before or other tokens you hold. Head over to the Explore tab to get started.

`, + shortDescription: + 'Check your airdrop eligibility and see trending NFT drops. Head over to the Explore tab to get started.', + image: { + title: 'PDAPP notification image Airdrops & NFT mints', + description: '', + url: '//images.ctfassets.net/jdkgyfmyd9sw/5jqq8sFeLc6XEoeWlpI3aB/73ee0f1afa9916c3a7538b0bbee09c26/PDAPP_notification_image_Airdrops___NFT_mints.png', + }, + extensionLink: { + extensionLinkText: 'Try now', + extensionLinkRoute: 'home.html', + }, + }, + }; +} diff --git a/packages/notification-services-controller/src/NotificationServicesController/mocks/mock-raw-notifications.ts b/packages/notification-services-controller/src/NotificationServicesController/mocks/mock-raw-notifications.ts new file mode 100644 index 00000000000..5e493fe25c7 --- /dev/null +++ b/packages/notification-services-controller/src/NotificationServicesController/mocks/mock-raw-notifications.ts @@ -0,0 +1,882 @@ +import { TRIGGER_TYPES } from '../constants/notification-schema.js'; +import type { NormalisedAPINotification } from '../types/notification-api/notification-api.js'; + +/** + * Mocking Utility - create a mock Eth sent notification + * + * @returns Mock raw Eth sent notification + */ +export function createMockNotificationEthSent(): NormalisedAPINotification { + const mockNotification: NormalisedAPINotification = { + type: TRIGGER_TYPES.ETH_SENT, + notification_type: 'wallet_activity', + notification_subtype: 'eth_sent', + id: '3fa85f64-5717-4562-b3fc-2c963f66afa7', + unread: true, + created_at: '2022-03-01T00:00:00Z', + payload: { + chain_id: 1, + block_number: 17485840, + block_timestamp: '2022-03-01T00:00:00Z', + tx_hash: + '0xb2256b183f2fb3872f99294ab55fb03e6a479b0d4aca556a3b27568b712505a6', + address: '0x881D40237659C251811CEC9c364ef91dC08D300C', + network: { + name: 'Ethereum', + native_symbol: 'ETH', + block_explorer: { + url: 'https://etherscan.io', + name: 'Etherscan', + }, + }, + data: { + kind: 'eth_sent', + network_fee: { + gas_price: '207806259583', + native_token_price_in_usd: '0.83', + }, + from: '0x881D40237659C251811CEC9c364ef91dC08D300C', + to: '0x881D40237659C251811CEC9c364ef91dC08D300D', + amount: { + usd: '670.64', + eth: '0.005', + }, + }, + }, + }; + + return mockNotification; +} + +/** + * Mocking Utility - create a mock Eth Received notification + * + * @returns Mock raw Eth Received notification + */ +export function createMockNotificationEthReceived(): NormalisedAPINotification { + const mockNotification: NormalisedAPINotification = { + type: TRIGGER_TYPES.ETH_RECEIVED, + notification_type: 'wallet_activity', + notification_subtype: 'eth_received', + id: '3fa85f64-5717-4562-b3fc-2c963f66afa8', + unread: true, + created_at: '2022-03-01T00:00:00Z', + payload: { + chain_id: 1, + block_number: 17485840, + block_timestamp: '2022-03-01T00:00:00Z', + tx_hash: + '0xb2256b183f2fb3872f99294ab55fb03e6a479b0d4aca556a3b27568b712505a6', + address: '0x881D40237659C251811CEC9c364ef91dC08D300C', + network: { + name: 'Ethereum', + native_symbol: 'ETH', + block_explorer: { + url: 'https://etherscan.io', + name: 'Etherscan', + }, + }, + data: { + kind: 'eth_received', + network_fee: { + gas_price: '207806259583', + native_token_price_in_usd: '0.83', + }, + from: '0x881D40237659C251811CEC9c364ef91dC08D300C', + to: '0x881D40237659C251811CEC9c364ef91dC08D300D', + amount: { + usd: '670.64', + eth: '808.000000000000000000', + }, + }, + }, + }; + + return mockNotification; +} + +/** + * Mocking Utility - create a mock ERC20 sent notification + * + * @returns Mock raw ERC20 sent notification + */ +export function createMockNotificationERC20Sent(): NormalisedAPINotification { + const mockNotification: NormalisedAPINotification = { + type: TRIGGER_TYPES.ERC20_SENT, + notification_type: 'wallet_activity', + notification_subtype: 'erc20_sent', + id: '3fa85f64-5717-4562-b3fc-2c963f66afa9', + unread: true, + created_at: '2022-03-01T00:00:00Z', + payload: { + chain_id: 1, + block_number: 17485840, + block_timestamp: '2022-03-01T00:00:00Z', + tx_hash: + '0xb2256b183f2fb3872f99294ab55fb03e6a479b0d4aca556a3b27568b712505a6', + address: '0x881D40237659C251811CEC9c364ef91dC08D300C', + network: { + name: 'Ethereum', + native_symbol: 'ETH', + block_explorer: { + url: 'https://etherscan.io', + name: 'Etherscan', + }, + }, + data: { + kind: 'erc20_sent', + network_fee: { + gas_price: '207806259583', + native_token_price_in_usd: '0.83', + }, + to: '0xecc19e177d24551aa7ed6bc6fe566eca726cc8a9', + from: '0x1231deb6f5749ef6ce6943a275a1d3e7486f4eae', + token: { + usd: '1.00', + name: 'USDC', + image: + 'https://raw.githubusercontent.com/MetaMask/contract-metadata/master/images/usdc.svg', + amount: '4956250000', + symbol: 'USDC', + address: '0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48', + decimals: '6', + }, + }, + }, + }; + + return mockNotification; +} + +/** + * Mocking Utility - create a mock ERC20 received notification + * + * @returns Mock raw ERC20 received notification + */ +export function createMockNotificationERC20Received(): NormalisedAPINotification { + const mockNotification: NormalisedAPINotification = { + type: TRIGGER_TYPES.ERC20_RECEIVED, + notification_type: 'wallet_activity', + notification_subtype: 'erc20_received', + id: '3fa85f64-5717-4562-b3fc-2c963f66afa6', + unread: true, + created_at: '2022-03-01T00:00:00Z', + payload: { + chain_id: 1, + block_number: 17485840, + block_timestamp: '2022-03-01T00:00:00Z', + tx_hash: + '0xb2256b183f2fb3872f99294ab55fb03e6a479b0d4aca556a3b27568b712505a6', + address: '0x881D40237659C251811CEC9c364ef91dC08D300C', + network: { + name: 'Ethereum', + native_symbol: 'ETH', + block_explorer: { + url: 'https://etherscan.io', + name: 'Etherscan', + }, + }, + data: { + kind: 'erc20_received', + network_fee: { + gas_price: '207806259583', + native_token_price_in_usd: '0.83', + }, + to: '0xeae7380dd4cef6fbd1144f49e4d1e6964258a4f4', + from: '0x51c72848c68a965f66fa7a88855f9f7784502a7f', + token: { + usd: '0.00', + name: 'SHIBA INU', + image: + 'https://raw.githubusercontent.com/MetaMask/contract-metadata/master/images/shib.svg', + amount: '8382798736999999457296646144', + symbol: 'SHIB', + address: '0x95ad61b0a150d79219dcf64e1e6cc01f0b64c4ce', + decimals: '18', + }, + }, + }, + }; + + return mockNotification; +} + +/** + * Mocking Utility - create a mock ERC721 sent notification + * + * @returns Mock raw ERC721 sent notification + */ +export function createMockNotificationERC721Sent(): NormalisedAPINotification { + const mockNotification: NormalisedAPINotification = { + type: TRIGGER_TYPES.ERC721_SENT, + notification_type: 'wallet_activity', + notification_subtype: 'erc721_sent', + id: 'a4193058-9814-537e-9df4-79dcac727fb6', + created_at: '2023-11-15T11:08:17.895407Z', + unread: true, + payload: { + block_number: 18576643, + block_timestamp: '1700043467', + chain_id: 1, + address: '0x881D40237659C251811CEC9c364ef91dC08D300C', + network: { + name: 'Ethereum', + native_symbol: 'ETH', + block_explorer: { + url: 'https://etherscan.io', + name: 'Etherscan', + }, + }, + data: { + to: '0xf47f628fe3bd2595e9ab384bfffc3859b448e451', + nft: { + name: 'Captainz #8680', + image: + 'https://i.seadn.io/s/raw/files/ae0fc06714ff7fb40217340d8a242c0e.gif?w=500&auto=format', + token_id: '8680', + collection: { + name: 'The Captainz', + image: + 'https://i.seadn.io/gcs/files/6df4d75778066bce740050615bc84e21.png?w=500&auto=format', + symbol: 'Captainz', + address: '0x769272677fab02575e84945f03eca517acc544cc', + }, + }, + from: '0x24a0bb54b7e7a8e406e9b28058a9fd6c49e6df4f', + kind: 'erc721_sent', + network_fee: { + gas_price: '24550653274', + native_token_price_in_usd: '1986.61', + }, + }, + tx_hash: + '0x0833c69fb41cf972a0f031fceca242939bc3fcf82b964b74606649abcad371bd', + }, + }; + + return mockNotification; +} + +/** + * Mocking Utility - create a mock ERC721 received notification + * + * @returns Mock raw ERC721 received notification + */ +export function createMockNotificationERC721Received(): NormalisedAPINotification { + const mockNotification: NormalisedAPINotification = { + type: TRIGGER_TYPES.ERC721_RECEIVED, + notification_type: 'wallet_activity', + notification_subtype: 'erc721_received', + id: '00a79d24-befa-57ed-a55a-9eb8696e1654', + created_at: '2023-11-14T17:40:52.319281Z', + unread: true, + payload: { + block_number: 18571446, + block_timestamp: '1699980623', + chain_id: 1, + address: '0x881D40237659C251811CEC9c364ef91dC08D300C', + network: { + name: 'Ethereum', + native_symbol: 'ETH', + block_explorer: { + url: 'https://etherscan.io', + name: 'Etherscan', + }, + }, + data: { + to: '0xba7f3daa8adfdad686574406ab9bd5d2f0a49d2e', + nft: { + name: 'The Plague #2722', + image: + 'https://i.seadn.io/s/raw/files/a96f90ec8ebf55a2300c66a0c46d6a16.png?w=500&auto=format', + token_id: '2722', + collection: { + name: 'The Plague NFT', + image: + 'https://i.seadn.io/gcs/files/4577987a5ca45ca5118b2e31559ee4d1.jpg?w=500&auto=format', + symbol: 'FROG', + address: '0xc379e535caff250a01caa6c3724ed1359fe5c29b', + }, + }, + from: '0x24a0bb54b7e7a8e406e9b28058a9fd6c49e6df4f', + kind: 'erc721_received', + network_fee: { + gas_price: '53701898538', + native_token_price_in_usd: '2047.01', + }, + }, + tx_hash: + '0xe554c9e29e6eeca8ba94da4d047334ba08b8eb9ca3b801dd69cec08dfdd4ae43', + }, + }; + + return mockNotification; +} + +/** + * Mocking Utility - create a mock ERC1155 sent notification + * + * @returns Mock raw ERC1155 sent notification + */ +export function createMockNotificationERC1155Sent(): NormalisedAPINotification { + const mockNotification: NormalisedAPINotification = { + type: TRIGGER_TYPES.ERC1155_SENT, + notification_type: 'wallet_activity', + notification_subtype: 'erc1155_sent', + id: 'a09ff9d1-623a-52ab-a3d4-c7c8c9a58362', + created_at: '2023-11-20T20:44:10.110706Z', + unread: true, + payload: { + block_number: 18615206, + block_timestamp: '1700510003', + chain_id: 1, + address: '0x881D40237659C251811CEC9c364ef91dC08D300C', + network: { + name: 'Ethereum', + native_symbol: 'ETH', + block_explorer: { + url: 'https://etherscan.io', + name: 'Etherscan', + }, + }, + data: { + to: '0x15bd77ccacf2da39b84f0c31fee2e451225bb190', + nft: { + name: 'IlluminatiNFT DAO', + image: + 'https://i.seadn.io/gcs/files/79a77cb37c7b2f1069f752645d29fea7.jpg?w=500&auto=format', + token_id: '1', + collection: { + name: 'IlluminatiNFT DAO', + image: + 'https://i.seadn.io/gae/LTKz3om2eCQfn3M6PkqEmY7KhLtdMCOm0QVch2318KJq7-KyToCH7NBTMo4UuJ0AZI-oaBh1HcgrAEIEWYbXY3uMcYpuGXunaXEh?w=500&auto=format', + symbol: 'TRUTH', + address: '0xe25f0fe686477f9df3c2876c4902d3b85f75f33a', + }, + }, + from: '0x0000000000000000000000000000000000000000', + kind: 'erc1155_sent', + network_fee: { + gas_price: '33571446596', + native_token_price_in_usd: '2038.88', + }, + }, + tx_hash: + '0x03381aba290facbaf71c123e263c8dc3dd550aac00ef589cce395182eaeff76f', + }, + }; + + return mockNotification; +} + +/** + * Mocking Utility - create a mock ERC1155 received notification + * + * @returns Mock raw ERC1155 received notification + */ +export function createMockNotificationERC1155Received(): NormalisedAPINotification { + const mockNotification: NormalisedAPINotification = { + type: TRIGGER_TYPES.ERC1155_RECEIVED, + notification_type: 'wallet_activity', + notification_subtype: 'erc1155_received', + id: 'b6b93c84-e8dc-54ed-9396-7ea50474843a', + created_at: '2023-11-20T20:44:10.110706Z', + unread: true, + payload: { + block_number: 18615206, + block_timestamp: '1700510003', + chain_id: 1, + address: '0x881D40237659C251811CEC9c364ef91dC08D300C', + network: { + name: 'Ethereum', + native_symbol: 'ETH', + block_explorer: { + url: 'https://etherscan.io', + name: 'Etherscan', + }, + }, + data: { + to: '0x15bd77ccacf2da39b84f0c31fee2e451225bb190', + nft: { + name: 'IlluminatiNFT DAO', + image: + 'https://i.seadn.io/gcs/files/79a77cb37c7b2f1069f752645d29fea7.jpg?w=500&auto=format', + token_id: '1', + collection: { + name: 'IlluminatiNFT DAO', + image: + 'https://i.seadn.io/gae/LTKz3om2eCQfn3M6PkqEmY7KhLtdMCOm0QVch2318KJq7-KyToCH7NBTMo4UuJ0AZI-oaBh1HcgrAEIEWYbXY3uMcYpuGXunaXEh?w=500&auto=format', + symbol: 'TRUTH', + address: '0xe25f0fe686477f9df3c2876c4902d3b85f75f33a', + }, + }, + from: '0x0000000000000000000000000000000000000000', + kind: 'erc1155_received', + network_fee: { + gas_price: '33571446596', + native_token_price_in_usd: '2038.88', + }, + }, + tx_hash: + '0x03381aba290facbaf71c123e263c8dc3dd550aac00ef589cce395182eaeff76f', + }, + }; + + return mockNotification; +} + +/** + * Mocking Utility - create a mock MetaMask Swaps notification + * + * @returns Mock raw MetaMask Swaps notification + */ +export function createMockNotificationMetaMaskSwapsCompleted(): NormalisedAPINotification { + const mockNotification: NormalisedAPINotification = { + type: TRIGGER_TYPES.METAMASK_SWAP_COMPLETED, + notification_type: 'wallet_activity', + notification_subtype: 'metamask_swap_completed', + id: '7ddfe6a1-ac52-5ffe-aa40-f04242db4b8b', + created_at: '2023-10-18T13:58:49.854596Z', + unread: true, + payload: { + block_number: 18377666, + block_timestamp: '1697637275', + chain_id: 1, + address: '0x881D40237659C251811CEC9c364ef91dC08D300C', + network: { + name: 'Ethereum', + native_symbol: 'ETH', + block_explorer: { + url: 'https://etherscan.io', + name: 'Etherscan', + }, + }, + data: { + kind: 'metamask_swap_completed', + rate: '1558.27', + token_in: { + usd: '1576.73', + image: + 'https://token.api.cx.metamask.io/assets/nativeCurrencyLogos/ethereum.svg', + amount: '9000000000000000', + symbol: 'ETH', + address: '0x0000000000000000000000000000000000000000', + decimals: '18', + name: 'Ethereum', + }, + token_out: { + usd: '1.00', + image: + 'https://raw.githubusercontent.com/MetaMask/contract-metadata/master/images/usdt.svg', + amount: '14024419', + symbol: 'USDT', + address: '0xdac17f958d2ee523a2206206994597c13d831ec7', + decimals: '6', + name: 'USDT', + }, + network_fee: { + gas_price: '15406129273', + native_token_price_in_usd: '1576.73', + }, + }, + tx_hash: + '0xf69074290f3aa11bce567aabc9ca0df7a12559dfae1b80ba1a124e9dfe19ecc5', + }, + }; + + return mockNotification; +} + +/** + * Mocking Utility - create a mock RocketPool Stake Completed notification + * + * @returns Mock raw RocketPool Stake Completed notification + */ +export function createMockNotificationRocketPoolStakeCompleted(): NormalisedAPINotification { + const mockNotification: NormalisedAPINotification = { + type: TRIGGER_TYPES.ROCKETPOOL_STAKE_COMPLETED, + notification_type: 'wallet_activity', + notification_subtype: 'rocketpool_stake_completed', + id: 'c2a2f225-b2fb-5d6c-ba56-e27a5c71ffb9', + created_at: '2023-11-20T12:02:48.796824Z', + unread: true, + payload: { + block_number: 18585057, + block_timestamp: '1700145059', + chain_id: 1, + address: '0x881D40237659C251811CEC9c364ef91dC08D300C', + network: { + name: 'Ethereum', + native_symbol: 'ETH', + block_explorer: { + url: 'https://etherscan.io', + name: 'Etherscan', + }, + }, + data: { + kind: 'rocketpool_stake_completed', + stake_in: { + usd: '2031.86', + name: 'Ethereum', + image: + 'https://token.api.cx.metamask.io/assets/nativeCurrencyLogos/ethereum.svg', + amount: '190690478063438272', + symbol: 'ETH', + address: '0x0000000000000000000000000000000000000000', + decimals: '18', + }, + stake_out: { + usd: '2226.49', + name: 'Rocket Pool ETH', + image: + 'https://raw.githubusercontent.com/MetaMask/contract-metadata/master/images/rETH.svg', + amount: '175024360778165879', + symbol: 'RETH', + address: '0xae78736Cd615f374D3085123A210448E74Fc6393', + decimals: '18', + }, + network_fee: { + gas_price: '36000000000', + native_token_price_in_usd: '2031.86', + }, + }, + tx_hash: + '0xcfc0693bf47995907b0f46ef0644cf16dd9a0de797099b2e00fd481e1b2117d3', + }, + }; + + return mockNotification; +} + +/** + * Mocking Utility - create a mock RocketPool Un-staked notification + * + * @returns Mock raw RocketPool Un-staked notification + */ +export function createMockNotificationRocketPoolUnStakeCompleted(): NormalisedAPINotification { + const mockNotification: NormalisedAPINotification = { + type: TRIGGER_TYPES.ROCKETPOOL_UNSTAKE_COMPLETED, + notification_type: 'wallet_activity', + notification_subtype: 'rocketpool_unstake_completed', + id: '291ec897-f569-4837-b6c0-21001b198dff', + created_at: '2023-10-19T13:11:10.623042Z', + unread: true, + payload: { + block_number: 18384336, + block_timestamp: '1697718011', + chain_id: 1, + address: '0x881D40237659C251811CEC9c364ef91dC08D300C', + network: { + name: 'Ethereum', + native_symbol: 'ETH', + block_explorer: { + url: 'https://etherscan.io', + name: 'Etherscan', + }, + }, + data: { + kind: 'rocketpool_unstake_completed', + stake_in: { + usd: '1686.34', + image: + 'https://raw.githubusercontent.com/MetaMask/contract-metadata/master/images/rETH.svg', + amount: '66608041413696770', + symbol: 'RETH', + address: '0xae78736Cd615f374D3085123A210448E74Fc6393', + decimals: '18', + name: 'Rocketpool Eth', + }, + stake_out: { + usd: '1553.75', + image: + 'https://token.api.cx.metamask.io/assets/nativeCurrencyLogos/ethereum.svg', + amount: '72387843427700824', + symbol: 'ETH', + address: '0x0000000000000000000000000000000000000000', + decimals: '18', + name: 'Ethereum', + }, + network_fee: { + gas_price: '5656322987', + native_token_price_in_usd: '1553.75', + }, + }, + tx_hash: + '0xc7972a7e409abfc62590ec90e633acd70b9b74e76ad02305be8bf133a0e22d5f', + }, + }; + + return mockNotification; +} + +/** + * Mocking Utility - create a mock Lido Stake Completed notification + * + * @returns Mock raw Lido Stake Completed notification + */ +export function createMockNotificationLidoStakeCompleted(): NormalisedAPINotification { + const mockNotification: NormalisedAPINotification = { + type: TRIGGER_TYPES.LIDO_STAKE_COMPLETED, + notification_type: 'wallet_activity', + notification_subtype: 'lido_stake_completed', + id: 'ec10d66a-f78f-461f-83c9-609aada8cc50', + created_at: '2023-11-02T22:28:49.970865Z', + unread: true, + payload: { + block_number: 18487118, + block_timestamp: '1698961091', + chain_id: 1, + address: '0x881D40237659C251811CEC9c364ef91dC08D300C', + network: { + name: 'Ethereum', + native_symbol: 'ETH', + block_explorer: { + url: 'https://etherscan.io', + name: 'Etherscan', + }, + }, + data: { + kind: 'lido_stake_completed', + stake_in: { + usd: '1806.33', + name: 'Ethereum', + image: + 'https://token.api.cx.metamask.io/assets/nativeCurrencyLogos/ethereum.svg', + amount: '330303634023928032', + symbol: 'ETH', + address: '0x0000000000000000000000000000000000000000', + decimals: '18', + }, + stake_out: { + usd: '1801.30', + name: 'Liquid staked Ether 2.0', + image: + 'https://raw.githubusercontent.com/MetaMask/contract-metadata/master/images/stETH.svg', + amount: '330303634023928032', + symbol: 'STETH', + address: '0xae7ab96520DE3A18E5e111B5EaAb095312D7fE84', + decimals: '18', + }, + network_fee: { + gas_price: '26536359866', + native_token_price_in_usd: '1806.33', + }, + }, + tx_hash: + '0x8cc0fa805f7c3b1743b14f3b91c6b824113b094f26d4ccaf6a71ad8547ce6a0f', + }, + }; + + return mockNotification; +} + +/** + * Mocking Utility - create a mock Lido Withdrawal Requested notification + * + * @returns Mock raw Lido Withdrawal Requested notification + */ +export function createMockNotificationLidoWithdrawalRequested(): NormalisedAPINotification { + const mockNotification: NormalisedAPINotification = { + type: TRIGGER_TYPES.LIDO_WITHDRAWAL_REQUESTED, + notification_type: 'wallet_activity', + notification_subtype: 'lido_withdrawal_requested', + id: 'ef003925-3379-4ba7-9e2d-8218690cadc9', + created_at: '2023-10-18T15:04:02.482526Z', + unread: true, + payload: { + block_number: 18377760, + block_timestamp: '1697638415', + chain_id: 1, + address: '0x881D40237659C251811CEC9c364ef91dC08D300C', + network: { + name: 'Ethereum', + native_symbol: 'ETH', + block_explorer: { + url: 'https://etherscan.io', + name: 'Etherscan', + }, + }, + data: { + kind: 'lido_withdrawal_requested', + stake_in: { + usd: '1568.54', + image: + 'https://raw.githubusercontent.com/MetaMask/contract-metadata/master/images/stETH.svg', + amount: '97180668792218669859', + symbol: 'STETH', + address: '0xae7ab96520DE3A18E5e111B5EaAb095312D7fE84', + decimals: '18', + name: 'Staked Eth', + }, + stake_out: { + usd: '1576.73', + image: + 'https://token.api.cx.metamask.io/assets/nativeCurrencyLogos/ethereum.svg', + amount: '97180668792218669859', + symbol: 'ETH', + address: '0x0000000000000000000000000000000000000000', + decimals: '18', + name: 'Ethereum', + }, + network_fee: { + gas_price: '11658906980', + native_token_price_in_usd: '1576.73', + }, + }, + tx_hash: + '0x58b5f82e084cb750ea174e02b20fbdfd2ba8d78053deac787f34fc38e5d427aa', + }, + }; + + return mockNotification; +} + +/** + * Mocking Utility - create a mock Lido Withdrawal Completed notification + * + * @returns Mock raw Lido Withdrawal Completed notification + */ +export function createMockNotificationLidoWithdrawalCompleted(): NormalisedAPINotification { + const mockNotification: NormalisedAPINotification = { + type: TRIGGER_TYPES.LIDO_WITHDRAWAL_COMPLETED, + notification_type: 'wallet_activity', + notification_subtype: 'lido_withdrawal_completed', + id: 'd73df14d-ce73-4f38-bad3-ab028154042f', + created_at: '2023-10-18T16:35:03.147606Z', + unread: true, + payload: { + block_number: 18378208, + block_timestamp: '1697643851', + chain_id: 1, + address: '0x881D40237659C251811CEC9c364ef91dC08D300C', + network: { + name: 'Ethereum', + native_symbol: 'ETH', + block_explorer: { + url: 'https://etherscan.io', + name: 'Etherscan', + }, + }, + data: { + kind: 'lido_withdrawal_completed', + stake_in: { + usd: '1570.23', + image: + 'https://raw.githubusercontent.com/MetaMask/contract-metadata/master/images/stETH.svg', + amount: '35081997661451346', + symbol: 'STETH', + address: '0xae7ab96520DE3A18E5e111B5EaAb095312D7fE84', + decimals: '18', + name: 'Staked Eth', + }, + stake_out: { + usd: '1571.74', + image: + 'https://token.api.cx.metamask.io/assets/nativeCurrencyLogos/ethereum.svg', + amount: '35081997661451346', + symbol: 'ETH', + address: '0x0000000000000000000000000000000000000000', + decimals: '18', + name: 'Ethereum', + }, + network_fee: { + gas_price: '12699495150', + native_token_price_in_usd: '1571.74', + }, + }, + tx_hash: + '0xe6d210d2e601ef3dd1075c48e71452cf35f2daae3886911e964e3babad8ac657', + }, + }; + + return mockNotification; +} + +/** + * Mocking Utility - create a mock Lido Withdrawal Ready notification + * + * @returns Mock raw Lido Withdrawal Ready notification + */ +export function createMockNotificationLidoReadyToBeWithdrawn(): NormalisedAPINotification { + const mockNotification: NormalisedAPINotification = { + type: TRIGGER_TYPES.LIDO_STAKE_READY_TO_BE_WITHDRAWN, + notification_type: 'wallet_activity', + notification_subtype: 'lido_stake_ready_to_be_withdrawn', + id: 'd73df14d-ce73-4f38-bad3-ab028154042e', + created_at: '2023-10-18T16:35:03.147606Z', + unread: true, + payload: { + block_number: 18378208, + block_timestamp: '1697643851', + chain_id: 1, + address: '0x881D40237659C251811CEC9c364ef91dC08D300C', + network: { + name: 'Ethereum', + native_symbol: 'ETH', + block_explorer: { + url: 'https://etherscan.io', + name: 'Etherscan', + }, + }, + data: { + kind: 'lido_stake_ready_to_be_withdrawn', + request_id: '123456789', + staked_eth: { + address: '0x881D40237659C251811CEC9c364ef91dC08D300F', + symbol: 'ETH', + name: 'Ethereum', + amount: '2.5', + decimals: '18', + image: + 'https://token.api.cx.metamask.io/assets/nativeCurrencyLogos/ethereum.svg', + usd: '10000.00', + }, + }, + tx_hash: + '0xe6d210d2e601ef3dd1075c48e71452cf35f2daae3886911e964e3babad8ac657', + }, + }; + + return mockNotification; +} + +/** + * Mocking Utility - create a mock Generic Platform notification + * + * @returns Mock raw Generic Platform notification + */ +export function createMockPlatformNotification(): NormalisedAPINotification { + const mockNotification: NormalisedAPINotification = { + type: TRIGGER_TYPES.PLATFORM, + notification_type: 'perps', + notification_subtype: 'position_liquidated', + id: '3fa85f64-5717-4562-b3fc-2c963f66afa6', + unread: true, + created_at: '2025-10-09T09:45:34.202Z', + template: { + image_url: + 'https://images.ctfassets.net/clixtyxoaeas/4rnpEzy1ATWRKVBOLxZ1Fm/a74dc1eed36d23d7ea6030383a4d5163/MetaMask-icon-fox.svg', + title: 'This is a Platform Notification!', + body: 'Teams can now build out their own notifications, and add an optional CTA (like this one below).', + cta: { + content: 'Get Started', + link: 'https://metamask.io/get-started', + }, + }, + }; + + return mockNotification; +} + +/** + * Mocking Utility - creates an array of raw on-chain notifications + * + * @returns Array of raw on-chain notifications + */ +export function createMockRawOnChainNotifications(): NormalisedAPINotification[] { + return [1, 2, 3].map((id) => { + const notification = createMockNotificationEthSent(); + notification.id += `-${id}`; + return notification; + }); +} diff --git a/packages/notification-services-controller/src/NotificationServicesController/mocks/mock-snap-notification.ts b/packages/notification-services-controller/src/NotificationServicesController/mocks/mock-snap-notification.ts new file mode 100644 index 00000000000..39c688857fe --- /dev/null +++ b/packages/notification-services-controller/src/NotificationServicesController/mocks/mock-snap-notification.ts @@ -0,0 +1,26 @@ +import { TRIGGER_TYPES } from '../constants/index.js'; +import type { RawSnapNotification } from '../types/snaps/index.js'; + +/** + * Mocking Utility - create a mock raw snap notification + * + * @returns Mock Raw Snap Notification + */ +export function createMockSnapNotification(): RawSnapNotification { + return { + type: TRIGGER_TYPES.SNAP, + readDate: null, + data: { + message: 'fooBar', + origin: '@metamask/example-snap', + detailedView: { + title: 'Detailed View', + interfaceId: '1', + footerLink: { + text: 'Go Home', + href: 'metamask://client/', + }, + }, + }, + }; +} diff --git a/packages/notification-services-controller/src/NotificationServicesController/mocks/mockResponses.ts b/packages/notification-services-controller/src/NotificationServicesController/mocks/mockResponses.ts new file mode 100644 index 00000000000..279067a5f0b --- /dev/null +++ b/packages/notification-services-controller/src/NotificationServicesController/mocks/mockResponses.ts @@ -0,0 +1,62 @@ +import { + NOTIFICATION_API_LIST_ENDPOINT, + NOTIFICATION_API_MARK_ALL_AS_READ_ENDPOINT, + TRIGGER_API_NOTIFICATIONS_QUERY_ENDPOINT, +} from '../services/api-notifications.js'; +import { FEATURE_ANNOUNCEMENT_API } from '../services/feature-announcements.js'; +import { PERPS_API_CREATE_ORDERS } from '../services/perp-notifications.js'; +import { createMockFeatureAnnouncementAPIResult } from './mock-feature-announcements.js'; +import { createMockRawOnChainNotifications } from './mock-raw-notifications.js'; + +type MockResponse = { + url: string; + requestMethod: 'GET' | 'POST' | 'PUT' | 'DELETE'; + response: unknown; +}; + +export const CONTENTFUL_RESPONSE = createMockFeatureAnnouncementAPIResult(); + +// Using `satisfies` to preserve narrow return types while ensuring type safety; explicit return types would widen to MockResponse +export const getMockFeatureAnnouncementResponse = (): MockResponse => { + return { + url: FEATURE_ANNOUNCEMENT_API, + requestMethod: 'GET', + response: CONTENTFUL_RESPONSE, + } satisfies MockResponse; +}; + +export const getMockOnChainNotificationsConfig = (): MockResponse => { + return { + url: TRIGGER_API_NOTIFICATIONS_QUERY_ENDPOINT(), + requestMethod: 'POST', + response: [{ address: '0xTestAddress', enabled: true }], + } satisfies MockResponse; +}; + +export const MOCK_RAW_ON_CHAIN_NOTIFICATIONS = + createMockRawOnChainNotifications(); + +// Using `satisfies` to preserve narrow return types while ensuring type safety; explicit return types would widen to MockResponse +export const getMockListNotificationsResponse = (): MockResponse => { + return { + url: NOTIFICATION_API_LIST_ENDPOINT(), + requestMethod: 'POST', + response: MOCK_RAW_ON_CHAIN_NOTIFICATIONS, + } satisfies MockResponse; +}; + +export const getMockMarkNotificationsAsReadResponse = (): MockResponse => { + return { + url: NOTIFICATION_API_MARK_ALL_AS_READ_ENDPOINT(), + requestMethod: 'POST', + response: null, + } satisfies MockResponse; +}; + +export const getMockCreatePerpOrderNotification = (): MockResponse => { + return { + url: PERPS_API_CREATE_ORDERS, + requestMethod: 'POST', + response: null, + } satisfies MockResponse; +}; diff --git a/packages/notification-services-controller/src/NotificationServicesController/processors/index.ts b/packages/notification-services-controller/src/NotificationServicesController/processors/index.ts new file mode 100644 index 00000000000..2848622e294 --- /dev/null +++ b/packages/notification-services-controller/src/NotificationServicesController/processors/index.ts @@ -0,0 +1,3 @@ +export * from './process-feature-announcement.js'; +export * from './process-notifications.js'; +export * from './process-api-notifications.js'; diff --git a/packages/notification-services-controller/src/NotificationServicesController/processors/process-api-notifications.test.ts b/packages/notification-services-controller/src/NotificationServicesController/processors/process-api-notifications.test.ts new file mode 100644 index 00000000000..7cc7d06633a --- /dev/null +++ b/packages/notification-services-controller/src/NotificationServicesController/processors/process-api-notifications.test.ts @@ -0,0 +1,53 @@ +import { + createMockNotificationEthSent, + createMockNotificationEthReceived, + createMockNotificationERC20Sent, + createMockNotificationERC20Received, + createMockNotificationERC721Sent, + createMockNotificationERC721Received, + createMockNotificationERC1155Sent, + createMockNotificationERC1155Received, + createMockNotificationMetaMaskSwapsCompleted, + createMockNotificationRocketPoolStakeCompleted, + createMockNotificationRocketPoolUnStakeCompleted, + createMockNotificationLidoStakeCompleted, + createMockNotificationLidoWithdrawalRequested, + createMockNotificationLidoWithdrawalCompleted, + createMockNotificationLidoReadyToBeWithdrawn, + createMockPlatformNotification, +} from '../mocks/mock-raw-notifications.js'; +import { processAPINotifications } from './process-api-notifications.js'; + +const rawNotifications = [ + createMockNotificationEthSent(), + createMockNotificationEthReceived(), + createMockNotificationERC20Sent(), + createMockNotificationERC20Received(), + createMockNotificationERC721Sent(), + createMockNotificationERC721Received(), + createMockNotificationERC1155Sent(), + createMockNotificationERC1155Received(), + createMockNotificationMetaMaskSwapsCompleted(), + createMockNotificationRocketPoolStakeCompleted(), + createMockNotificationRocketPoolUnStakeCompleted(), + createMockNotificationLidoStakeCompleted(), + createMockNotificationLidoWithdrawalRequested(), + createMockNotificationLidoWithdrawalCompleted(), + createMockNotificationLidoReadyToBeWithdrawn(), + createMockPlatformNotification(), +]; + +const rawNotificationTestSuite = rawNotifications.map( + (notification) => [notification.type, notification] as const, +); + +describe('process-onchain-notifications - processOnChainNotification()', () => { + it.each(rawNotificationTestSuite)( + 'converts Raw On-Chain Notification (%s) to a shared Notification Type', + (_, rawNotification) => { + const result = processAPINotifications(rawNotification); + expect(result.id).toBe(rawNotification.id); + expect(result.type).toBe(rawNotification.type); + }, + ); +}); diff --git a/packages/notification-services-controller/src/NotificationServicesController/processors/process-api-notifications.ts b/packages/notification-services-controller/src/NotificationServicesController/processors/process-api-notifications.ts new file mode 100644 index 00000000000..2eb9a7307cc --- /dev/null +++ b/packages/notification-services-controller/src/NotificationServicesController/processors/process-api-notifications.ts @@ -0,0 +1,25 @@ +import type { NormalisedAPINotification } from '../types/notification-api/notification-api.js'; +import type { INotification } from '../types/notification/notification.js'; +import { getNotificationSubtype } from '../utils/get-notification-subtype.js'; +import { shouldAutoExpire } from '../utils/should-auto-expire.js'; + +/** + * Processes API notifications to a normalized INotification shape + * + * @param notification - API Notification (On-Chain or Platform Notification) + * @returns Normalized Notification + */ +export function processAPINotifications( + notification: NormalisedAPINotification, +): INotification { + const createdAtDate = new Date(notification.created_at); + const expired = shouldAutoExpire(createdAtDate); + + return { + ...notification, + id: notification.id, + notification_subtype: getNotificationSubtype(notification), + createdAt: createdAtDate.toISOString(), + isRead: expired || !notification.unread, + }; +} diff --git a/packages/notification-services-controller/src/NotificationServicesController/processors/process-feature-announcement.test.ts b/packages/notification-services-controller/src/NotificationServicesController/processors/process-feature-announcement.test.ts new file mode 100644 index 00000000000..8ed9c84ccf3 --- /dev/null +++ b/packages/notification-services-controller/src/NotificationServicesController/processors/process-feature-announcement.test.ts @@ -0,0 +1,56 @@ +import { TRIGGER_TYPES } from '../constants/notification-schema.js'; +import type { INotification } from '../index.js'; +import { createMockFeatureAnnouncementRaw } from '../mocks/mock-feature-announcements.js'; +import { + isFeatureAnnouncementRead, + processFeatureAnnouncement, +} from './process-feature-announcement.js'; + +describe('process-feature-announcement - isFeatureAnnouncementRead()', () => { + const MOCK_NOTIFICATION_ID = 'MOCK_NOTIFICATION_ID'; + + it('returns true if a given notificationId is within list of read platform notifications', () => { + const notification = { + id: MOCK_NOTIFICATION_ID, + createdAt: new Date().toString(), + }; + + const result1 = isFeatureAnnouncementRead(notification, [ + 'id-1', + 'id-2', + MOCK_NOTIFICATION_ID, + ]); + expect(result1).toBe(true); + + const result2 = isFeatureAnnouncementRead(notification, ['id-1', 'id-2']); + expect(result2).toBe(false); + }); + + it('returns isRead if notification is older than 90 days', () => { + const mockDate = new Date(); + mockDate.setDate(mockDate.getDate() - 100); + + const notification = { + id: MOCK_NOTIFICATION_ID, + createdAt: mockDate.toString(), + }; + + const result = isFeatureAnnouncementRead(notification, []); + expect(result).toBe(true); + }); +}); + +describe('process-feature-announcement - processFeatureAnnouncement()', () => { + it('processes a Raw Feature Announcement to a shared Notification Type', () => { + const rawNotification = createMockFeatureAnnouncementRaw(); + const result = processFeatureAnnouncement(rawNotification) as Extract< + INotification, + { type: TRIGGER_TYPES.FEATURES_ANNOUNCEMENT } + >; + + expect(result.id).toBe(rawNotification.data.id); + expect(result.type).toBe(TRIGGER_TYPES.FEATURES_ANNOUNCEMENT); + expect(result.isRead).toBe(false); + expect(result.data).toBeDefined(); + }); +}); diff --git a/packages/notification-services-controller/src/NotificationServicesController/processors/process-feature-announcement.ts b/packages/notification-services-controller/src/NotificationServicesController/processors/process-feature-announcement.ts new file mode 100644 index 00000000000..e6217876d39 --- /dev/null +++ b/packages/notification-services-controller/src/NotificationServicesController/processors/process-feature-announcement.ts @@ -0,0 +1,41 @@ +import type { FeatureAnnouncementRawNotification } from '../types/feature-announcement/feature-announcement.js'; +import type { INotification } from '../types/notification/notification.js'; +import { getNotificationSubtype } from '../utils/get-notification-subtype.js'; +import { shouldAutoExpire } from '../utils/should-auto-expire.js'; + +/** + * Checks if a feature announcement should be read. + * Checks feature announcement state (from param), as well as if the notification is "expired" + * + * @param notification - notification to check + * @param readPlatformNotificationsList - list of read notifications + * @returns boolean if notification should be marked as read or unread + */ +export function isFeatureAnnouncementRead( + notification: Pick, + readPlatformNotificationsList: string[], +): boolean { + if (readPlatformNotificationsList.includes(notification.id)) { + return true; + } + return shouldAutoExpire(new Date(notification.createdAt)); +} + +/** + * Processes a feature announcement into a shared/normalised notification shape. + * + * @param notification - raw feature announcement + * @returns a normalised feature announcement + */ +export function processFeatureAnnouncement( + notification: FeatureAnnouncementRawNotification, +): INotification { + return { + type: notification.type, + id: notification.data.id, + notification_subtype: getNotificationSubtype(notification), + createdAt: new Date(notification.createdAt).toISOString(), + data: notification.data, + isRead: false, + }; +} diff --git a/packages/notification-services-controller/src/NotificationServicesController/processors/process-notifications.test.ts b/packages/notification-services-controller/src/NotificationServicesController/processors/process-notifications.test.ts new file mode 100644 index 00000000000..62b29605e3b --- /dev/null +++ b/packages/notification-services-controller/src/NotificationServicesController/processors/process-notifications.test.ts @@ -0,0 +1,65 @@ +import type { TRIGGER_TYPES } from '../constants/notification-schema.js'; +import { createMockFeatureAnnouncementRaw } from '../mocks/mock-feature-announcements.js'; +import { + createMockNotificationEthSent, + createMockPlatformNotification, +} from '../mocks/mock-raw-notifications.js'; +import { createMockSnapNotification } from '../mocks/mock-snap-notification.js'; +import { + processNotification, + safeProcessNotification, +} from './process-notifications.js'; + +describe('process-notifications - processNotification()', () => { + // More thorough tests are found in the specific process + it('maps Feature Announcement to shared Notification Type', () => { + const result = processNotification(createMockFeatureAnnouncementRaw()); + expect(result).toBeDefined(); + }); + + // More thorough tests are found in the specific process + it('maps On Chain Notification to shared Notification Type', () => { + const result = processNotification(createMockNotificationEthSent()); + expect(result).toBeDefined(); + }); + + // More thorough tests are found in the specific process + it('maps Platform Notification to a shared Notification Type', () => { + const result = processNotification(createMockPlatformNotification()); + expect(result).toBeDefined(); + }); + + // More thorough tests are found in the specific process + it('maps Snap Notification to shared Notification Type', () => { + const result = processNotification(createMockSnapNotification()); + expect(result).toBeDefined(); + }); + + it('throws on invalid notification to process', () => { + const rawNotification = createMockNotificationEthSent(); + + // Testing Mock with invalid notification type + rawNotification.type = 'FAKE_NOTIFICATION_TYPE' as TRIGGER_TYPES.ETH_SENT; + + expect(() => processNotification(rawNotification)).toThrow( + expect.any(Error), + ); + }); +}); + +describe('process-notifications - safeProcessNotification()', () => { + // More thorough tests are found in the specific process + it('maps On Chain Notification to shared Notification Type', () => { + const result = safeProcessNotification(createMockNotificationEthSent()); + expect(result).toBeDefined(); + }); + + it('returns undefined for a notification unable to process', () => { + const rawNotification = createMockNotificationEthSent(); + + // Testing Mock with invalid notification type + rawNotification.type = 'FAKE_NOTIFICATION_TYPE' as TRIGGER_TYPES.ETH_SENT; + const result = safeProcessNotification(rawNotification); + expect(result).toBeUndefined(); + }); +}); diff --git a/packages/notification-services-controller/src/NotificationServicesController/processors/process-notifications.ts b/packages/notification-services-controller/src/NotificationServicesController/processors/process-notifications.ts new file mode 100644 index 00000000000..de04fe8d93c --- /dev/null +++ b/packages/notification-services-controller/src/NotificationServicesController/processors/process-notifications.ts @@ -0,0 +1,100 @@ +import { + TRIGGER_TYPES, + NOTIFICATION_API_TRIGGER_TYPES_SET, +} from '../constants/notification-schema.js'; +import type { FeatureAnnouncementRawNotification } from '../types/feature-announcement/feature-announcement.js'; +import type { NormalisedAPINotification } from '../types/notification-api/notification-api.js'; +import type { + INotification, + RawNotificationUnion, +} from '../types/notification/notification.js'; +import type { RawSnapNotification } from '../types/snaps/index.js'; +import { processAPINotifications } from './process-api-notifications.js'; +import { + isFeatureAnnouncementRead, + processFeatureAnnouncement, +} from './process-feature-announcement.js'; +import { processSnapNotification } from './process-snap-notifications.js'; + +const isAPINotification = ( + notification: RawNotificationUnion, +): notification is NormalisedAPINotification => + NOTIFICATION_API_TRIGGER_TYPES_SET.has(notification.type); + +const isFeatureAnnouncement = ( + notification: RawNotificationUnion, +): notification is FeatureAnnouncementRawNotification => + notification.type === TRIGGER_TYPES.FEATURES_ANNOUNCEMENT; + +const isSnapNotification = ( + notification: RawNotificationUnion, +): notification is RawSnapNotification => + notification.type === TRIGGER_TYPES.SNAP; + +/** + * Process feature announcement and wallet notifications into a shared/normalised notification shape. + * We can still differentiate notifications by the `type` property + * + * @param notification - a feature announcement or on chain notification + * @param readNotifications - all read notifications currently + * @returns a processed notification + */ +export function processNotification( + notification: RawNotificationUnion, + readNotifications: string[] = [], +): INotification { + const exhaustedAllCases = (_uncheckedCase: never): never => { + const type: string = notification?.type; + throw new Error(`No processor found for notification kind ${type}`); + }; + + if (isFeatureAnnouncement(notification)) { + const processedNotification = processFeatureAnnouncement(notification); + processedNotification.isRead = isFeatureAnnouncementRead( + processedNotification, + readNotifications, + ); + return processedNotification; + } + + if (isSnapNotification(notification)) { + return processSnapNotification(notification); + } + + if (isAPINotification(notification)) { + return processAPINotifications(notification); + } + + return exhaustedAllCases(notification); +} + +/** + * Safe version of processing a notification. Rather than throwing an error if failed to process, it will return the Notification or undefined + * + * @param notification - notification to processes + * @param readNotifications - all read notifications currently + * @returns a process notification or undefined if failed to process + */ +export function safeProcessNotification( + notification: RawNotificationUnion, + readNotifications: string[] = [], +): INotification | undefined { + try { + const processedNotification = processNotification( + notification, + readNotifications, + ); + return processedNotification; + } catch { + return undefined; + } +} + +const isNotUndefined = (item?: Item): item is Item => Boolean(item); +export const processAndFilterNotifications = ( + notifications: RawNotificationUnion[], + readIds: string[], +): INotification[] => + notifications + .map((notification) => safeProcessNotification(notification, readIds)) + .filter(isNotUndefined); diff --git a/packages/notification-services-controller/src/NotificationServicesController/processors/process-snap-notifications.test.ts b/packages/notification-services-controller/src/NotificationServicesController/processors/process-snap-notifications.test.ts new file mode 100644 index 00000000000..bbfa0b08143 --- /dev/null +++ b/packages/notification-services-controller/src/NotificationServicesController/processors/process-snap-notifications.test.ts @@ -0,0 +1,19 @@ +import { TRIGGER_TYPES } from '../constants/index.js'; +import type { INotification } from '../index.js'; +import { createMockSnapNotification } from '../mocks/index.js'; +import { processSnapNotification } from './process-snap-notifications.js'; + +describe('process-snap-notifications - processSnapNotification()', () => { + it('processes a Raw Snap Notification to a shared Notification Type', () => { + const rawNotification = createMockSnapNotification(); + const result = processSnapNotification(rawNotification) as Extract< + INotification, + { type: TRIGGER_TYPES.SNAP } + >; + + expect(result.type).toBe(TRIGGER_TYPES.SNAP); + expect(result.isRead).toBe(false); + expect(result.data).toBeDefined(); + expect(result.readDate).toBeNull(); + }); +}); diff --git a/packages/notification-services-controller/src/NotificationServicesController/processors/process-snap-notifications.ts b/packages/notification-services-controller/src/NotificationServicesController/processors/process-snap-notifications.ts new file mode 100644 index 00000000000..37a85f336cb --- /dev/null +++ b/packages/notification-services-controller/src/NotificationServicesController/processors/process-snap-notifications.ts @@ -0,0 +1,26 @@ +import { v4 as uuid } from 'uuid'; + +import type { INotification } from '../types/index.js'; +import type { RawSnapNotification } from '../types/snaps/index.js'; +import { getNotificationSubtype } from '../utils/get-notification-subtype.js'; + +/** + * Processes a snap notification into a normalized shape. + * + * @param snapNotification - A raw snap notification. + * @returns a normalized snap notification. + */ +export const processSnapNotification = ( + snapNotification: RawSnapNotification, +): INotification => { + const { data, type, readDate } = snapNotification; + return { + id: uuid(), + notification_subtype: getNotificationSubtype(snapNotification), + readDate, + createdAt: new Date().toISOString(), + isRead: false, + type, + data, + }; +}; diff --git a/packages/notification-services-controller/src/NotificationServicesController/services/api-notifications.test.ts b/packages/notification-services-controller/src/NotificationServicesController/services/api-notifications.test.ts new file mode 100644 index 00000000000..c2253737853 --- /dev/null +++ b/packages/notification-services-controller/src/NotificationServicesController/services/api-notifications.test.ts @@ -0,0 +1,164 @@ +import { + mockGetOnChainNotificationsConfig, + mockGetAPINotifications, + mockMarkNotificationsAsRead, +} from '../__fixtures__/mockServices.js'; +import { + createMockNotificationERC20Sent, + createMockPlatformNotification, +} from '../mocks/index.js'; +import * as OnChainNotifications from './api-notifications.js'; + +const MOCK_BEARER_TOKEN = 'MOCK_BEARER_TOKEN'; +const MOCK_ADDRESSES = ['0x123', '0x456', '0x789']; + +describe('On Chain Notifications - getAPINotificationsConfig()', () => { + it('should return notification config for addresses', async () => { + const mockEndpoint = mockGetOnChainNotificationsConfig({ + status: 200, + body: [{ address: '0xTestAddress', enabled: true }], + }); + + const result = await OnChainNotifications.getNotificationsApiConfigCached( + MOCK_BEARER_TOKEN, + MOCK_ADDRESSES, + ); + + expect(mockEndpoint.isDone()).toBe(true); + expect(result).toStrictEqual([{ address: '0xTestAddress', enabled: true }]); + }); + + it('should bail early if given a list of empty addresses', async () => { + const mockEndpoint = mockGetOnChainNotificationsConfig(); + + const result = await OnChainNotifications.getNotificationsApiConfigCached( + MOCK_BEARER_TOKEN, + [], + ); + + expect(mockEndpoint.isDone()).toBe(false); // bailed early before API was called + expect(result).toStrictEqual([]); + }); + + it('should return [] if endpoint fails', async () => { + const mockBadEndpoint = mockGetOnChainNotificationsConfig({ + status: 500, + body: { error: 'mock api failure' }, + }); + + const result = await OnChainNotifications.getNotificationsApiConfigCached( + MOCK_BEARER_TOKEN, + MOCK_ADDRESSES, + ); + + expect(mockBadEndpoint.isDone()).toBe(true); + expect(result).toStrictEqual([]); + }); +}); + +describe('On Chain Notifications - getAPINotifications()', () => { + it('should return a list of notifications', async () => { + const mockEndpoint = mockGetAPINotifications(); + + const result = await OnChainNotifications.getAPINotifications( + MOCK_BEARER_TOKEN, + MOCK_ADDRESSES, + 'en', + 'extension', + ); + + expect(mockEndpoint.isDone()).toBe(true); + expect(result.length).toBeGreaterThan(0); + }); + + it('should bail early when a list of empty addresses is provided', async () => { + const mockEndpoint = mockGetAPINotifications(); + const result = await OnChainNotifications.getAPINotifications( + MOCK_BEARER_TOKEN, + [], + 'en', + 'extension', + ); + + expect(mockEndpoint.isDone()).toBe(false); // API was not called + expect(result).toHaveLength(0); + }); + + it('should return an empty array if endpoint fails', async () => { + const mockBadEndpoint = mockGetAPINotifications({ + status: 500, + body: { error: 'mock api failure' }, + }); + + const result = await OnChainNotifications.getAPINotifications( + MOCK_BEARER_TOKEN, + MOCK_ADDRESSES, + 'en', + 'extension', + ); + + expect(mockBadEndpoint.isDone()).toBe(true); + expect(Array.isArray(result)).toBe(true); + expect(result).toHaveLength(0); + }); + + it('should send correct request body format with addresses', async () => { + const mockEndpoint = mockGetAPINotifications(); + + const result = await OnChainNotifications.getAPINotifications( + MOCK_BEARER_TOKEN, + MOCK_ADDRESSES, + 'en', + 'extension', + ); + + expect(mockEndpoint.isDone()).toBe(true); + expect(result.length > 0).toBe(true); + }); + + it('should filter out notifications invalid notifications', async () => { + const mockEndpoint = mockGetAPINotifications({ + status: 200, + body: [ + createMockNotificationERC20Sent(), + { + id: '2', + data: {}, // missing kind + }, + createMockPlatformNotification(), + ], + }); + + const result = await OnChainNotifications.getAPINotifications( + MOCK_BEARER_TOKEN, + MOCK_ADDRESSES, + 'en', + 'extension', + ); + + expect(mockEndpoint.isDone()).toBe(true); + expect(result).toHaveLength(2); // Should filter out the invalid notification + }); +}); + +describe('On Chain Notifications - markNotificationsAsRead()', () => { + it('should successfully call endpoint to mark notifications as read', async () => { + const mockEndpoint = mockMarkNotificationsAsRead(); + + await OnChainNotifications.markNotificationsAsRead(MOCK_BEARER_TOKEN, [ + 'notification_1', + 'notification_2', + ]); + + expect(mockEndpoint.isDone()).toBe(true); + }); + + it('should bail early if no notification IDs provided', async () => { + const mockEndpoint = mockMarkNotificationsAsRead(); + + await OnChainNotifications.markNotificationsAsRead(MOCK_BEARER_TOKEN, []); + + // Should not call the endpoint when no IDs provided + expect(mockEndpoint.isDone()).toBe(false); + }); +}); diff --git a/packages/notification-services-controller/src/NotificationServicesController/services/api-notifications.ts b/packages/notification-services-controller/src/NotificationServicesController/services/api-notifications.ts new file mode 100644 index 00000000000..3fcbc95a0d5 --- /dev/null +++ b/packages/notification-services-controller/src/NotificationServicesController/services/api-notifications.ts @@ -0,0 +1,188 @@ +import log from 'loglevel'; + +import { toRawAPINotification } from '../../shared/to-raw-notification.js'; +import type { + NormalisedAPINotification, + Schema, + UnprocessedRawNotification, +} from '../types/notification-api/index.js'; +import { makeApiCall } from '../utils/utils.js'; +import { notificationsConfigCache } from './notification-config-cache.js'; + +export type ENV = 'prd' | 'uat' | 'dev'; + +const TRIGGER_API_ENV = { + dev: 'https://trigger.dev-api.cx.metamask.io', + uat: 'https://trigger.uat-api.cx.metamask.io', + prd: 'https://trigger.api.cx.metamask.io', +} satisfies Record; + +export const TRIGGER_API = (env: ENV = 'prd'): string => + TRIGGER_API_ENV[env] ?? TRIGGER_API_ENV.prd; + +const NOTIFICATION_API_ENV = { + dev: 'https://notification.dev-api.cx.metamask.io', + uat: 'https://notification.uat-api.cx.metamask.io', + prd: 'https://notification.api.cx.metamask.io', +}; + +export const NOTIFICATION_API = (env: ENV = 'prd'): string => + NOTIFICATION_API_ENV[env] ?? NOTIFICATION_API_ENV.prd; + +// Gets notification settings for each account provided +export const TRIGGER_API_NOTIFICATIONS_QUERY_ENDPOINT = ( + env: ENV = 'prd', +): string => `${TRIGGER_API(env)}/api/v2/notifications/query`; + +// Lists notifications for each address provided +export const NOTIFICATION_API_LIST_ENDPOINT = (env: ENV = 'prd'): string => + `${NOTIFICATION_API(env)}/api/v4/notifications`; + +// Marks notifications as read +export const NOTIFICATION_API_MARK_ALL_AS_READ_ENDPOINT = ( + env: ENV = 'prd', +): string => `${NOTIFICATION_API(env)}/api/v4/notifications/mark-as-read`; + +/** + * fetches notification config (accounts enabled vs disabled) + * + * @param bearerToken - jwt + * @param addresses - list of addresses to check + * @param env - the environment to use for the API call + * NOTE the API will return addresses config with false if they have not been created before. + * NOTE this is cached for 1s to prevent multiple update calls + * @returns object of notification config, or null if missing + */ +export async function getNotificationsApiConfigCached( + bearerToken: string, + addresses: string[], + env: ENV = 'prd', +): Promise<{ address: string; enabled: boolean }[]> { + if (addresses.length === 0) { + return []; + } + + const normalizedAddresses = addresses.map((addr) => addr.toLowerCase()); + + const cached = notificationsConfigCache.get(normalizedAddresses); + if (cached) { + return cached; + } + + type RequestBody = { address: string }[]; + type Response = { address: string; enabled: boolean }[]; + const body: RequestBody = normalizedAddresses.map((address) => ({ address })); + const apiResponse = await makeApiCall( + bearerToken, + TRIGGER_API_NOTIFICATIONS_QUERY_ENDPOINT(env), + 'POST', + body, + ) + .then((response) => (response.ok ? response.json() : null)) + .catch(() => null); + + const result = apiResponse ?? []; + + if (result.length > 0) { + notificationsConfigCache.set(result); + } + + return result; +} + +/** + * Fetches on-chain notifications for the given addresses + * + * @param bearerToken - The JSON Web Token used for authentication in the API call. + * @param addresses - List of addresses + * @param locale - to generate translated notifications + * @param platform - filter notifications for specific platforms ('extension' | 'mobile') + * @param env - the environment to use for the API call + * @returns An array of {@link NormalisedAPINotification}. Returns an empty array on transport or parse errors. + */ +export async function getAPINotifications( + bearerToken: string, + addresses: string[], + locale: string, + platform: 'extension' | 'mobile', + env: ENV = 'prd', +): Promise { + if (addresses.length === 0) { + return []; + } + + type RequestBody = + Schema.paths['/api/v4/notifications']['post']['requestBody']['content']['application/json']; + type APIResponse = + Schema.paths['/api/v4/notifications']['post']['responses']['200']['content']['application/json']; + + const body: RequestBody = { + addresses: addresses.map((addr) => addr.toLowerCase()), + locale, + platform, + }; + const notifications = await makeApiCall( + bearerToken, + NOTIFICATION_API_LIST_ENDPOINT(env), + 'POST', + body, + ) + .then((response) => + response.ok ? response.json() : null, + ) + .catch(() => null); + + // Transform and sort notifications + const transformedNotifications = notifications + ?.map((notification): UnprocessedRawNotification | undefined => { + if (!notification.notification_type) { + return undefined; + } + + try { + return toRawAPINotification(notification); + } catch { + return undefined; + } + }) + .filter((item): item is NormalisedAPINotification => Boolean(item)); + + return transformedNotifications ?? []; +} + +/** + * Marks the specified notifications as read. + * This method sends a POST request to the notifications service to mark the provided notification IDs as read. + * If the operation is successful, it completes without error. If the operation fails, it throws an error with details. + * + * @param bearerToken - The JSON Web Token used for authentication in the API call. + * @param notificationIds - An array of notification IDs to be marked as read. + * @param env - the environment to use for the API call + * @returns A promise that resolves to void. The promise will reject if there's an error during the API call or if the response status is not 200. + */ +export async function markNotificationsAsRead( + bearerToken: string, + notificationIds: string[], + env: ENV = 'prd', +): Promise { + if (notificationIds.length === 0) { + return; + } + + type ResponseBody = + Schema.paths['/api/v4/notifications/mark-as-read']['post']['requestBody']['content']['application/json']; + const body: ResponseBody = { + ids: notificationIds, + }; + + try { + await makeApiCall( + bearerToken, + NOTIFICATION_API_MARK_ALL_AS_READ_ENDPOINT(env), + 'POST', + body, + ); + } catch (error) { + log.error('Error marking notifications as read:', error); + } +} 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 new file mode 100644 index 00000000000..dcfe6ccbfc3 --- /dev/null +++ b/packages/notification-services-controller/src/NotificationServicesController/services/feature-announcements.test.ts @@ -0,0 +1,426 @@ +import { mockFetchFeatureAnnouncementNotifications } from '../__fixtures__/mockServices.js'; +import { TRIGGER_TYPES } from '../constants/notification-schema.js'; +import type { INotification } from '../index.js'; +import { createMockFeatureAnnouncementAPIResult } from '../mocks/mock-feature-announcements.js'; +import { + ContentfulResult, + getFeatureAnnouncementNotifications, + getFeatureAnnouncementUrl, +} from './feature-announcements.js'; + +// Mocked type for testing, allows overwriting TS to test erroneous values +// eslint-disable-next-line @typescript-eslint/no-explicit-any +type MockedType = any; + +jest.mock('@contentful/rich-text-html-renderer', () => ({ + documentToHtmlString: jest + .fn() + .mockImplementation((richText: string) => `

${richText}

`), +})); + +const featureAnnouncementsEnv = { + spaceId: ':space_id', + accessToken: ':access_token', + platform: 'extension' as 'extension' | 'mobile', +}; + +describe('Feature Announcement Notifications', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('should return an empty array if invalid environment provided', async () => { + mockFetchFeatureAnnouncementNotifications(); + + const assertEnvEmpty = async ( + override: Partial, + ): Promise => { + const result = await getFeatureAnnouncementNotifications({ + ...featureAnnouncementsEnv, + ...override, + }); + expect(result).toHaveLength(0); + }; + + await assertEnvEmpty({ accessToken: null as MockedType }); + await assertEnvEmpty({ platform: null as MockedType }); + await assertEnvEmpty({ spaceId: null as MockedType }); + await assertEnvEmpty({ accessToken: '' }); + await assertEnvEmpty({ platform: '' as MockedType }); + await assertEnvEmpty({ spaceId: '' }); + }); + + it('should return an empty array if fetch fails', async () => { + const mockEndpoint = mockFetchFeatureAnnouncementNotifications({ + status: 500, + }); + + const notifications = await getFeatureAnnouncementNotifications( + featureAnnouncementsEnv, + ); + mockEndpoint.done(); + expect(notifications).toStrictEqual([]); + }); + + it('should return an empty array if data is not available', async () => { + const mockEndpoint = mockFetchFeatureAnnouncementNotifications({ + status: 200, + body: { items: [] }, + }); + + const notifications = await getFeatureAnnouncementNotifications( + featureAnnouncementsEnv, + ); + mockEndpoint.done(); + 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, + body: createMockFeatureAnnouncementAPIResult(), + }); + + const notifications = await getFeatureAnnouncementNotifications( + featureAnnouncementsEnv, + ); + expect(notifications).toHaveLength(1); + mockEndpoint.done(); + + const resultNotification = notifications[0] as Extract< + INotification, + { type: TRIGGER_TYPES.FEATURES_ANNOUNCEMENT } + >; + expect(resultNotification).toStrictEqual( + expect.objectContaining({ + id: 'dont-miss-out-on-airdrops-and-new-nft-mints', + type: TRIGGER_TYPES.FEATURES_ANNOUNCEMENT, + createdAt: expect.any(String), + isRead: expect.any(Boolean), + }), + ); + + expect(resultNotification.data).toBeDefined(); + }); + + const testPlatforms = [ + { + platform: 'extension' as const, + minVersionField: 'extensionMinimumVersionNumber' as const, + maxVersionField: 'extensionMaximumVersionNumber' as const, + }, + { + platform: 'mobile' as const, + minVersionField: 'mobileMinimumVersionNumber' as const, + maxVersionField: 'mobileMaximumVersionNumber' as const, + }, + ]; + + describe.each(testPlatforms)( + 'Feature Announcement $platform filtering', + ({ platform, minVersionField, maxVersionField }) => { + // current platform version is 7.57.0 for all tests + const currentPlatformVersion = '7.57.0'; + + const arrangeAct = async ( + minimumVersion: string | undefined, + maximumVersion: string | undefined, + platformVersion: string | undefined, + ): Promise => { + const apiResponse = createMockFeatureAnnouncementAPIResult(); + if (apiResponse.items?.[0]) { + apiResponse.items[0].fields.extensionMinimumVersionNumber = undefined; + apiResponse.items[0].fields.mobileMinimumVersionNumber = undefined; + apiResponse.items[0].fields.extensionMaximumVersionNumber = undefined; + apiResponse.items[0].fields.mobileMaximumVersionNumber = undefined; + + if (minimumVersion !== undefined) { + apiResponse.items[0].fields[minVersionField] = minimumVersion; + } + if (maximumVersion !== undefined) { + apiResponse.items[0].fields[maxVersionField] = maximumVersion; + } + } + const mockEndpoint = mockFetchFeatureAnnouncementNotifications({ + status: 200, + body: apiResponse, + }); + const notifications = await getFeatureAnnouncementNotifications({ + ...featureAnnouncementsEnv, + platform, + platformVersion, + }); + mockEndpoint.done(); + return notifications; + }; + + const minimumVersionSchema = [ + { + testName: 'shows notification when platform version is above minimum', + minimumVersion: '7.56.0', + platformVersion: currentPlatformVersion, + length: 1, + }, + { + testName: 'hides notification when platform version equals minimum', + minimumVersion: '7.57.0', + platformVersion: currentPlatformVersion, + length: 0, + }, + { + testName: 'hides notification when platform version is below minimum', + minimumVersion: '7.58.0', + platformVersion: currentPlatformVersion, + length: 0, + }, + { + testName: 'shows notification when no minimum version is specified', + minimumVersion: undefined, + platformVersion: currentPlatformVersion, + length: 1, + }, + { + testName: 'shows notification when no platform version is provided', + minimumVersion: '7.56.0', + platformVersion: undefined, + length: 1, + }, + { + testName: 'hides notification when minimum version is malformed', + minimumVersion: 'invalid-version', + platformVersion: currentPlatformVersion, + length: 0, + }, + ]; + + it.each(minimumVersionSchema)( + 'minimum version test - $testName', + async ({ minimumVersion, platformVersion, length }) => { + const notifications = await arrangeAct( + minimumVersion, + undefined, + platformVersion, + ); + expect(notifications).toHaveLength(length); + }, + ); + + const maximumVersionSchema = [ + { + testName: 'shows notification when platform version is below maximum', + maximumVersion: '7.58.0', + platformVersion: currentPlatformVersion, + length: 1, + }, + { + testName: 'hides notification when platform version equals maximum', + maximumVersion: '7.57.0', + platformVersion: currentPlatformVersion, + length: 0, + }, + { + testName: 'hides notification when platform version is above maximum', + maximumVersion: '7.56.0', + platformVersion: currentPlatformVersion, + length: 0, + }, + { + testName: 'shows notification when no maximum version is specified', + maximumVersion: undefined, + platformVersion: currentPlatformVersion, + length: 1, + }, + { + testName: 'shows notification when no platform version is provided', + maximumVersion: '7.58.0', + platformVersion: undefined, + length: 1, + }, + { + testName: 'hides notification when maximum version is malformed', + maximumVersion: 'invalid-version', + platformVersion: currentPlatformVersion, + length: 0, + }, + ]; + + it.each(maximumVersionSchema)( + 'maximum version test - $testName', + async ({ maximumVersion, platformVersion, length }) => { + const notifications = await arrangeAct( + undefined, + maximumVersion, + platformVersion, + ); + expect(notifications).toHaveLength(length); + }, + ); + + const minMaxVersionSchema = [ + { + testName: + 'shows notification when version is within both bounds (min < current < max)', + minimumVersion: '7.56.0', + maximumVersion: '7.58.0', + platformVersion: currentPlatformVersion, + length: 1, + }, + { + testName: + 'shows notification when version is above minimum and below maximum', + minimumVersion: '7.56.5', + maximumVersion: '7.57.5', + platformVersion: currentPlatformVersion, + length: 1, + }, + { + testName: 'hides notification when version equals minimum bound', + minimumVersion: '7.57.0', + maximumVersion: '7.58.0', + platformVersion: currentPlatformVersion, + length: 0, + }, + { + testName: 'hides notification when version equals maximum bound', + minimumVersion: '7.56.0', + maximumVersion: '7.57.0', + platformVersion: currentPlatformVersion, + length: 0, + }, + { + testName: 'hides notification when version is below minimum bound', + minimumVersion: '7.58.0', + maximumVersion: '7.59.0', + platformVersion: currentPlatformVersion, + length: 0, + }, + { + testName: 'hides notification when version is above maximum bound', + minimumVersion: '7.55.0', + maximumVersion: '7.56.0', + platformVersion: currentPlatformVersion, + length: 0, + }, + { + testName: 'shows notification when both bounds are undefined', + minimumVersion: undefined, + maximumVersion: undefined, + platformVersion: currentPlatformVersion, + length: 1, + }, + { + testName: + 'shows notification when only minimum is defined and version is above it', + minimumVersion: '7.56.0', + maximumVersion: undefined, + platformVersion: currentPlatformVersion, + length: 1, + }, + { + testName: + 'shows notification when only maximum is defined and version is below it', + minimumVersion: undefined, + maximumVersion: '7.58.0', + platformVersion: currentPlatformVersion, + length: 1, + }, + { + testName: + 'shows notification when no platform version is provided regardless of bounds', + minimumVersion: '7.56.0', + maximumVersion: '7.58.0', + platformVersion: undefined, + length: 1, + }, + { + testName: + 'hides notification when minimum is malformed but maximum excludes current version', + minimumVersion: 'malformed', + maximumVersion: '7.56.0', + platformVersion: currentPlatformVersion, + length: 0, + }, + { + testName: + 'hides notification when maximum is malformed but minimum excludes current version', + minimumVersion: '7.58.0', + maximumVersion: 'malformed', + platformVersion: currentPlatformVersion, + length: 0, + }, + ]; + + it.each(minMaxVersionSchema)( + 'min & max version bounds test - $testName', + async ({ minimumVersion, maximumVersion, platformVersion, length }) => { + const notifications = await arrangeAct( + minimumVersion, + maximumVersion, + platformVersion, + ); + expect(notifications).toHaveLength(length); + }, + ); + }, + ); +}); + +describe('getFeatureAnnouncementUrl', () => { + it('should construct the correct URL for the default domain', () => { + const url = getFeatureAnnouncementUrl(featureAnnouncementsEnv); + expect(url).toBe( + `https://cdn.contentful.com/spaces/:space_id/environments/master/entries?access_token=:access_token&content_type=productAnnouncement&include=10&fields.clients%5Bin%5D=extension`, + ); + }); + + it('should construct the correct URL for the preview domain', () => { + const url = getFeatureAnnouncementUrl( + featureAnnouncementsEnv, + ':preview_token', + ); + expect(url).toBe( + `https://preview.contentful.com/spaces/:space_id/environments/master/entries?access_token=:preview_token&content_type=productAnnouncement&include=10&fields.clients%5Bin%5D=extension`, + ); + }); +}); diff --git a/packages/notification-services-controller/src/NotificationServicesController/services/feature-announcements.ts b/packages/notification-services-controller/src/NotificationServicesController/services/feature-announcements.ts new file mode 100644 index 00000000000..caddc8e8001 --- /dev/null +++ b/packages/notification-services-controller/src/NotificationServicesController/services/feature-announcements.ts @@ -0,0 +1,235 @@ +import { documentToHtmlString } from '@contentful/rich-text-html-renderer'; +import type { Entry, Asset, EntryCollection } from 'contentful'; + +import { TRIGGER_TYPES } from '../constants/notification-schema.js'; +import { processFeatureAnnouncement } from '../processors/process-feature-announcement.js'; +import type { FeatureAnnouncementRawNotification } from '../types/feature-announcement/feature-announcement.js'; +import type { + ImageFields, + TypeFeatureAnnouncement, +} from '../types/feature-announcement/type-feature-announcement.js'; +import type { + TypeExternalLinkFields, + TypePortfolioLinkFields, + TypeExtensionLinkFields, + TypeMobileLinkFields, +} from '../types/feature-announcement/type-links.js'; +import type { INotification } from '../types/notification/notification.js'; +import { isVersionInBounds } from '../utils/isVersionInBounds.js'; + +// 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'; +const DEFAULT_DOMAIN = 'cdn.contentful.com'; +const PREVIEW_DOMAIN = 'preview.contentful.com'; +export const FEATURE_ANNOUNCEMENT_API = `https://${DEFAULT_DOMAIN}/spaces/${DEFAULT_SPACE_ID}/environments/master/entries`; +export const FEATURE_ANNOUNCEMENT_URL = `${FEATURE_ANNOUNCEMENT_API}?access_token=${DEFAULT_ACCESS_TOKEN}&content_type=productAnnouncement&include=10&fields.clients[in]=${DEFAULT_CLIENT_ID}`; + +type Env = { + spaceId: string; + accessToken: string; + platform: 'extension' | 'mobile'; + platformVersion?: string; +}; + +/** + * Contentful API Response Shape + */ +export type ContentfulResult = { + includes?: { + // Property names match Contentful API response structure + // eslint-disable-next-line @typescript-eslint/naming-convention + Entry?: Entry[]; + // eslint-disable-next-line @typescript-eslint/naming-convention + Asset?: Asset[]; + }; + items?: TypeFeatureAnnouncement[]; +}; + +export const getFeatureAnnouncementUrl = ( + env: Env, + previewToken?: string, +): string => { + const domain = previewToken ? PREVIEW_DOMAIN : DEFAULT_DOMAIN; + const replacedUrl = FEATURE_ANNOUNCEMENT_URL.replace( + DEFAULT_SPACE_ID, + env.spaceId, + ) + .replace(DEFAULT_ACCESS_TOKEN, previewToken ?? env.accessToken) + .replace(DEFAULT_CLIENT_ID, env.platform) + .replace(DEFAULT_DOMAIN, domain); + return encodeURI(replacedUrl); +}; + +const fetchFeatureAnnouncementNotifications = async ( + env: Env, + previewToken?: string, +): Promise => { + const url = getFeatureAnnouncementUrl(env, previewToken); + + const data = await fetch(url) + .then((response) => response.json()) + .catch(() => null); + + if (!data) { + return []; + } + + const findIncludedItem = ( + sysId: string, + ): + | ImageFields['fields'] + | TypeExtensionLinkFields['fields'] + | TypePortfolioLinkFields['fields'] + | TypeMobileLinkFields['fields'] + | TypeExternalLinkFields['fields'] + | null => { + const typedData: EntryCollection< + | ImageFields + | TypeExtensionLinkFields + | TypePortfolioLinkFields + | TypeMobileLinkFields + | TypeExternalLinkFields + > = data; + const item = + typedData?.includes?.Entry?.find( + (entry: Entry) => entry?.sys?.id === sysId, + ) ?? + typedData?.includes?.Asset?.find( + (asset: Asset) => asset?.sys?.id === sysId, + ); + return item ? item?.fields : null; + }; + + const contentfulNotifications = data?.items ?? []; + const rawNotifications: FeatureAnnouncementRawNotification[] = + 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, + }, + }; + + return notification; + }); + + const versionKeys = { + extension: { + min: 'extensionMinimumVersionNumber', + max: 'extensionMaximumVersionNumber', + }, + mobile: { + min: 'mobileMinimumVersionNumber', + max: 'mobileMaximumVersionNumber', + }, + } as const; + + const filteredRawNotifications = rawNotifications.filter( + (rawNotification) => { + const minVersion = rawNotification.data?.[versionKeys[env.platform].min]; + const maxVersion = rawNotification.data?.[versionKeys[env.platform].max]; + return isVersionInBounds({ + currentVersion: env.platformVersion, + minVersion, + maxVersion, + }); + }, + ); + + return filteredRawNotifications; +}; + +/** + * Gets Feature Announcement from our services + * + * @param env - environment for feature announcements + * @param previewToken - the preview token to use if needed + * @returns Raw Feature Announcements + */ +export async function getFeatureAnnouncementNotifications( + env: Env, + previewToken?: string, +): Promise { + if (env?.accessToken && env?.spaceId && env?.platform) { + const rawNotifications = await fetchFeatureAnnouncementNotifications( + env, + previewToken, + ); + const notifications = rawNotifications.map((notification) => + processFeatureAnnouncement(notification), + ); + + return notifications; + } + + return []; +} diff --git a/packages/notification-services-controller/src/NotificationServicesController/services/notification-config-cache.test.ts b/packages/notification-services-controller/src/NotificationServicesController/services/notification-config-cache.test.ts new file mode 100644 index 00000000000..9abe67f64f8 --- /dev/null +++ b/packages/notification-services-controller/src/NotificationServicesController/services/notification-config-cache.test.ts @@ -0,0 +1,244 @@ +import { + OnChainNotificationsCache, + NotificationConfigCacheTTL, +} from './notification-config-cache.js'; + +describe('OnChainNotificationsCache', () => { + // Create a fresh instance for each test to avoid interference + let cache: OnChainNotificationsCache; + + beforeEach(() => { + jest.useFakeTimers(); + cache = new OnChainNotificationsCache(); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + describe('get', () => { + it('should return null when cache is empty', () => { + const result = cache.get(['0x123']); + expect(result).toBeNull(); + }); + + it('should return null when cache is expired', () => { + // Set some data + cache.set([{ address: '0x123', enabled: true }]); + + // Fast-forward time past TTL + jest.advanceTimersByTime(NotificationConfigCacheTTL + 1); + + const result = cache.get(['0x123']); + expect(result).toBeNull(); + }); + + it('should return null when not all requested addresses are in cache', () => { + cache.set([{ address: '0x123', enabled: true }]); + + const result = cache.get(['0x123', '0x456']); + expect(result).toBeNull(); + }); + + it('should return cached data when all addresses are available and not expired', () => { + const testData = [ + { address: '0x123', enabled: true }, + { address: '0x456', enabled: false }, + ]; + cache.set(testData); + + const result = cache.get(['0x123', '0x456']); + expect(result).toStrictEqual(testData); + }); + + it('should return data in the order requested', () => { + cache.set([ + { address: '0x123', enabled: true }, + { address: '0x456', enabled: false }, + ]); + + const result = cache.get(['0x456', '0x123']); + expect(result).toStrictEqual([ + { address: '0x456', enabled: false }, + { address: '0x123', enabled: true }, + ]); + }); + + it('should return false for addresses not in cache when some addresses are cached', () => { + cache.set([{ address: '0x123', enabled: true }]); + + // This should return null because not all addresses are cached + const result = cache.get(['0x123', '0x456']); + expect(result).toBeNull(); + }); + }); + + describe('set', () => { + it('should store data in cache', () => { + const testData = [{ address: '0x123', enabled: true }]; + cache.set(testData); + + const result = cache.get(['0x123']); + expect(result).toStrictEqual(testData); + }); + + it('should merge with existing non-expired cache data', () => { + // Set initial data + cache.set([{ address: '0x123', enabled: true }]); + + // Add more data (within TTL) + jest.advanceTimersByTime(NotificationConfigCacheTTL / 2); + cache.set([{ address: '0x456', enabled: false }]); + + const result = cache.get(['0x123', '0x456']); + expect(result).toStrictEqual([ + { address: '0x123', enabled: true }, + { address: '0x456', enabled: false }, + ]); + }); + + it('should update existing addresses in cache', () => { + // Set initial data + cache.set([{ address: '0x123', enabled: true }]); + + // Update the same address + cache.set([{ address: '0x123', enabled: false }]); + + const result = cache.get(['0x123']); + expect(result).toStrictEqual([{ address: '0x123', enabled: false }]); + }); + + it('should not merge with expired cache data', () => { + // Set initial data + cache.set([{ address: '0x123', enabled: true }]); + + // Fast-forward time past TTL + jest.advanceTimersByTime(NotificationConfigCacheTTL + 1); + + // Set new data + cache.set([{ address: '0x456', enabled: false }]); + + // Should only have the new data, not the expired data + const result = cache.get(['0x456']); + expect(result).toStrictEqual([{ address: '0x456', enabled: false }]); + + const expiredResult = cache.get(['0x123']); + expect(expiredResult).toBeNull(); + }); + + it('should handle empty data array', () => { + cache.set([]); + + const result = cache.get(['0x123']); + expect(result).toBeNull(); + }); + }); + + describe('clear', () => { + it('should clear all cache data', () => { + cache.set([{ address: '0x123', enabled: true }]); + + cache.clear(); + + const result = cache.get(['0x123']); + expect(result).toBeNull(); + }); + + it('should handle clearing empty cache', () => { + cache.clear(); + + const result = cache.get(['0x123']); + expect(result).toBeNull(); + }); + }); + + describe('TTL behavior', () => { + it('should respect TTL for cache expiration', () => { + cache.set([{ address: '0x123', enabled: true }]); + + // Should be available immediately + expect(cache.get(['0x123'])).toStrictEqual([ + { address: '0x123', enabled: true }, + ]); + + // Should still be available just before expiration + jest.advanceTimersByTime(NotificationConfigCacheTTL / 2); + expect(cache.get(['0x123'])).toStrictEqual([ + { address: '0x123', enabled: true }, + ]); + + // Should be expired after TTL + jest.advanceTimersByTime(NotificationConfigCacheTTL); + expect(cache.get(['0x123'])).toBeNull(); + }); + + it('should handle multiple cache operations within TTL window', () => { + // Set initial data + cache.set([{ address: '0x123', enabled: true }]); + + // Advance under TTL + jest.advanceTimersByTime(NotificationConfigCacheTTL / 2); + + // Add more data (should merge with existing) + cache.set([{ address: '0x456', enabled: false }]); + + // Both should be available + expect(cache.get(['0x123', '0x456'])).toStrictEqual([ + { address: '0x123', enabled: true }, + { address: '0x456', enabled: false }, + ]); + + // Advance past TTL + jest.advanceTimersByTime(NotificationConfigCacheTTL + 1); + + // Cache should be expired now + expect(cache.get(['0x123', '0x456'])).toBeNull(); + }); + + it('should reset TTL on each cache operation', () => { + // Set initial data + cache.set([{ address: '0x123', enabled: true }]); + + // Advance under TTL (almost ended) + jest.advanceTimersByTime(NotificationConfigCacheTTL * 0.9); + + // Update cache (should reset TTL) + cache.set([{ address: '0x456', enabled: false }]); + + // Advance TTL (it should be past TTL, but cache was reset) + jest.advanceTimersByTime(NotificationConfigCacheTTL * 0.9); + + // Should still be available because TTL was reset + expect(cache.get(['0x123', '0x456'])).toStrictEqual([ + { address: '0x123', enabled: true }, + { address: '0x456', enabled: false }, + ]); + + // Advance past TTL (added from previous timer makes this past TTL) + jest.advanceTimersByTime(NotificationConfigCacheTTL * 0.9); + + // Now should be expired + expect(cache.get(['0x123', '0x456'])).toBeNull(); + }); + }); + + describe('User Flows', () => { + it('should correctly perform settings change user flow', () => { + // First we make a GET call to fetch notification settings, so cache is set + cache.set([ + { address: '0x111', enabled: true }, + { address: '0x222', enabled: true }, + ]); + + // Then we switch off an account, so cache is updated + cache.set([{ address: '0x222', enabled: false }]); + + // Then we perform a GET to get the updated settings, and fetch notifications only for active accounts + const result = cache.get(['0x111', '0x222']); + expect(result).toStrictEqual([ + { address: '0x111', enabled: true }, + { address: '0x222', enabled: false }, + ]); + }); + }); +}); diff --git a/packages/notification-services-controller/src/NotificationServicesController/services/notification-config-cache.ts b/packages/notification-services-controller/src/NotificationServicesController/services/notification-config-cache.ts new file mode 100644 index 00000000000..fac64dc304e --- /dev/null +++ b/packages/notification-services-controller/src/NotificationServicesController/services/notification-config-cache.ts @@ -0,0 +1,59 @@ +type NotificationConfigCache = { + data: Map; + timestamp: number; +}; + +export const NotificationConfigCacheTTL = 1000 * 60; // 60 seconds + +export class OnChainNotificationsCache { + #cache: NotificationConfigCache | null = null; + + readonly #ttl = NotificationConfigCacheTTL; + + #isExpired(): boolean { + return !this.#cache || Date.now() - this.#cache.timestamp > this.#ttl; + } + + #hasAllAddresses(addresses: string[]): boolean { + if (!this.#cache) { + return false; + } + return addresses.every((address) => this.#cache?.data.has(address)); + } + + get(addresses: string[]): { address: string; enabled: boolean }[] | null { + if (this.#isExpired() || !this.#hasAllAddresses(addresses)) { + return null; + } + + return addresses.map((address) => ({ + address, + enabled: this.#cache?.data.get(address) ?? false, + })); + } + + set(data: { address: string; enabled: boolean }[]): void { + let map: Map = new Map(); + + // If we have existing cache, preserve it and update with new data + if (this.#cache && !this.#isExpired()) { + map = new Map(this.#cache.data); + } + + // Update with new data + data.forEach(({ address, enabled }) => { + map.set(address, enabled); + }); + + this.#cache = { + data: map, + timestamp: Date.now(), + }; + } + + clear(): void { + this.#cache = null; + } +} + +export const notificationsConfigCache = new OnChainNotificationsCache(); diff --git a/packages/notification-services-controller/src/NotificationServicesController/services/perp-notifications.test.ts b/packages/notification-services-controller/src/NotificationServicesController/services/perp-notifications.test.ts new file mode 100644 index 00000000000..3b1662fcb62 --- /dev/null +++ b/packages/notification-services-controller/src/NotificationServicesController/services/perp-notifications.test.ts @@ -0,0 +1,48 @@ +import { mockCreatePerpNotification } from '../__fixtures__/mockServices.js'; +import type { OrderInput } from '../types/perps/index.js'; +import { createPerpOrderNotification } from './perp-notifications.js'; + +const mockOrderInput = (): OrderInput => ({ + user_id: '0x111', // User Address + coin: '0x222', // Asset address +}); + +const mockBearerToken = 'mock-jwt-token'; + +describe('Perps Service - createPerpOrderNotification', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + const arrangeMocks = (): { + consoleErrorSpy: jest.SpyInstance>; + } => { + const consoleErrorSpy = jest + .spyOn(console, 'error') + .mockImplementation(jest.fn()); + + return { consoleErrorSpy }; + }; + + it('should successfully create a perp order notification', async () => { + const { consoleErrorSpy } = arrangeMocks(); + const mockEndpoint = mockCreatePerpNotification(); + await createPerpOrderNotification(mockBearerToken, mockOrderInput()); + + expect(mockEndpoint.isDone()).toBe(true); + expect(consoleErrorSpy).not.toHaveBeenCalled(); + }); + + it('should handle fetch errors gracefully', async () => { + const { consoleErrorSpy } = arrangeMocks(); + const mockEndpoint = mockCreatePerpNotification({ status: 500 }); + let numberOfRequests = 0; + mockEndpoint.on('request', () => (numberOfRequests += 1)); + + await createPerpOrderNotification(mockBearerToken, mockOrderInput()); + + expect(mockEndpoint.isDone()).toBe(true); + expect(consoleErrorSpy).toHaveBeenCalled(); + expect(numberOfRequests).toBe(4); // 4 requests made - 1 initial + 3 retries + }); +}); diff --git a/packages/notification-services-controller/src/NotificationServicesController/services/perp-notifications.ts b/packages/notification-services-controller/src/NotificationServicesController/services/perp-notifications.ts new file mode 100644 index 00000000000..a0e5c3e55d7 --- /dev/null +++ b/packages/notification-services-controller/src/NotificationServicesController/services/perp-notifications.ts @@ -0,0 +1,35 @@ +import { + createServicePolicy, + successfulFetch, +} from '@metamask/controller-utils'; + +import type { OrderInput } from '../types/index.js'; + +export const PERPS_API = 'https://perps.api.cx.metamask.io'; +export const PERPS_API_CREATE_ORDERS = `${PERPS_API}/api/v1/orders`; + +/** + * Sends a perp order to our API to create a perp order subscription + * + * @param bearerToken - JWT for authentication + * @param orderInput - order input shape + */ +export async function createPerpOrderNotification( + bearerToken: string, + orderInput: OrderInput, +): Promise { + try { + await createServicePolicy().execute(async () => { + return successfulFetch(PERPS_API_CREATE_ORDERS, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${bearerToken}`, + }, + body: JSON.stringify(orderInput), + }); + }); + } catch (error) { + console.error('Failed to create perp order notification', error); + } +} diff --git a/packages/notification-services-controller/src/NotificationServicesController/types/feature-announcement/feature-announcement.ts b/packages/notification-services-controller/src/NotificationServicesController/types/feature-announcement/feature-announcement.ts new file mode 100644 index 00000000000..fb125814f69 --- /dev/null +++ b/packages/notification-services-controller/src/NotificationServicesController/types/feature-announcement/feature-announcement.ts @@ -0,0 +1,49 @@ +import type { TRIGGER_TYPES } from '../../constants/notification-schema.js'; +import type { TypeFeatureAnnouncement } from './type-feature-announcement.js'; + +export type FeatureAnnouncementRawNotificationData = Omit< + TypeFeatureAnnouncement['fields'], + | 'image' + | 'longDescription' + | 'extensionLink' + | 'portfolioLink' + | 'externalLink' + | 'mobileLink' +> & { + longDescription: string; + image: { + title?: string; + description?: string; + url: string; + }; + + // External Link + externalLink?: { + externalLinkText: string; + externalLinkUrl: string; + }; + + // Portfolio Link + portfolioLink?: { + portfolioLinkText: string; + portfolioLinkUrl: string; + }; + + // Extension Link + extensionLink?: { + extensionLinkText: string; + extensionLinkRoute: string; + }; + + // Mobile Link + mobileLink?: { + mobileLinkText: string; + mobileLinkUrl: string; + }; +}; + +export type FeatureAnnouncementRawNotification = { + type: TRIGGER_TYPES.FEATURES_ANNOUNCEMENT; + createdAt: string; + data: FeatureAnnouncementRawNotificationData; +}; diff --git a/packages/notification-services-controller/src/NotificationServicesController/types/feature-announcement/index.ts b/packages/notification-services-controller/src/NotificationServicesController/types/feature-announcement/index.ts new file mode 100644 index 00000000000..4f0811d8fcf --- /dev/null +++ b/packages/notification-services-controller/src/NotificationServicesController/types/feature-announcement/index.ts @@ -0,0 +1,3 @@ +export type * from './feature-announcement.js'; +export type * from './type-links.js'; +export type * from './type-feature-announcement.js'; diff --git a/packages/notification-services-controller/src/NotificationServicesController/types/feature-announcement/type-feature-announcement.ts b/packages/notification-services-controller/src/NotificationServicesController/types/feature-announcement/type-feature-announcement.ts new file mode 100644 index 00000000000..7a5a518f608 --- /dev/null +++ b/packages/notification-services-controller/src/NotificationServicesController/types/feature-announcement/type-feature-announcement.ts @@ -0,0 +1,63 @@ +import type { Entry, EntryFieldTypes } from 'contentful'; + +import type { + TypeExternalLinkFields, + TypeExtensionLinkFields, + TypePortfolioLinkFields, + TypeMobileLinkFields, +} from './type-links.js'; + +export type ImageFields = { + fields: { + title?: string; + description?: string; + file?: { + url: string; + fileName: string; + contentType: string; + details: { + size: number; + image?: { + width: number; + height: number; + }; + }; + }; + }; + contentTypeId: 'Image'; +}; + +export type TypeFeatureAnnouncementFields = { + fields: { + title: EntryFieldTypes.Text; + id: EntryFieldTypes.Symbol; + category: EntryFieldTypes.Text; // E.g. Announcement, etc. + shortDescription: EntryFieldTypes.Text; + image: EntryFieldTypes.EntryLink; + longDescription: EntryFieldTypes.RichText; + + // External Link + externalLink?: EntryFieldTypes.EntryLink; + // Portfolio Link + portfolioLink?: EntryFieldTypes.EntryLink; + // Extension Link + extensionLink?: EntryFieldTypes.EntryLink; + // Mobile Link + mobileLink?: EntryFieldTypes.EntryLink; + + clients?: EntryFieldTypes.Text<'extension' | 'mobile' | 'portfolio'>; + + // Min Versions + extensionMinimumVersionNumber?: EntryFieldTypes.Text; + mobileMinimumVersionNumber?: EntryFieldTypes.Text; + // Max Versions + extensionMaximumVersionNumber?: EntryFieldTypes.Text; + mobileMaximumVersionNumber?: EntryFieldTypes.Text; + }; + contentTypeId: 'productAnnouncement'; +}; + +export type TypeFeatureAnnouncement = Entry< + TypeFeatureAnnouncementFields, + 'WITHOUT_UNRESOLVABLE_LINKS' +>; diff --git a/packages/notification-services-controller/src/NotificationServicesController/types/feature-announcement/type-links.ts b/packages/notification-services-controller/src/NotificationServicesController/types/feature-announcement/type-links.ts new file mode 100644 index 00000000000..5153c481127 --- /dev/null +++ b/packages/notification-services-controller/src/NotificationServicesController/types/feature-announcement/type-links.ts @@ -0,0 +1,39 @@ +// Generic External Link +// We use this to show a link than will open an external web page +export type TypeExternalLinkFields = { + fields: { + externalLinkText: string; + externalLinkUrl: string; + }; + contentTypeId: 'externalLink'; +}; + +// Extension Link +// We use this to show a link than will open an extension tab +export type TypeExtensionLinkFields = { + fields: { + extensionLinkText: string; + extensionLinkRoute: string; + }; + contentTypeId: 'extensionLink'; +}; + +// Portfolio Link +// We use this to show a link than will open a portfolio page +export type TypePortfolioLinkFields = { + fields: { + portfolioLinkText: string; + portfolioLinkUrl: string; + }; + contentTypeId: 'portfolioLink'; +}; + +// Mobile Link +// We use this to show a link than will open an application page +export type TypeMobileLinkFields = { + fields: { + mobileLinkText: string; + mobileLinkUrl: string; + }; + contentTypeId: 'mobileLink'; +}; diff --git a/packages/notification-services-controller/src/NotificationServicesController/types/index.ts b/packages/notification-services-controller/src/NotificationServicesController/types/index.ts new file mode 100644 index 00000000000..3e4c92c416c --- /dev/null +++ b/packages/notification-services-controller/src/NotificationServicesController/types/index.ts @@ -0,0 +1,5 @@ +export type * from './feature-announcement/index.js'; +export type * from './notification-api/index.js'; +export type * from './notification/index.js'; +export type * from './snaps/index.js'; +export type * from './perps/index.js'; diff --git a/packages/notification-services-controller/src/NotificationServicesController/types/notification-api/index.ts b/packages/notification-services-controller/src/NotificationServicesController/types/notification-api/index.ts new file mode 100644 index 00000000000..8aa21282dd7 --- /dev/null +++ b/packages/notification-services-controller/src/NotificationServicesController/types/notification-api/index.ts @@ -0,0 +1,2 @@ +export type * from './notification-api.js'; +export type * as Schema from './schema.js'; diff --git a/packages/notification-services-controller/src/NotificationServicesController/types/notification-api/notification-api.ts b/packages/notification-services-controller/src/NotificationServicesController/types/notification-api/notification-api.ts new file mode 100644 index 00000000000..08fd046f4f3 --- /dev/null +++ b/packages/notification-services-controller/src/NotificationServicesController/types/notification-api/notification-api.ts @@ -0,0 +1,92 @@ +import { TRIGGER_TYPES } from '../../constants/notification-schema.js'; +import type { Compute } from '../type-utils.js'; +// Types derived from external Notification API schema - naming follows API conventions +/* eslint-disable @typescript-eslint/naming-convention */ +import type { components } from './schema.js'; + +export type Data_MetamaskSwapCompleted = + components['schemas']['Data_MetamaskSwapCompleted']; +export type Data_LidoStakeReadyToBeWithdrawn = + components['schemas']['Data_LidoStakeReadyToBeWithdrawn']; +export type Data_LidoStakeCompleted = + components['schemas']['Data_LidoStakeCompleted']; +export type Data_LidoWithdrawalRequested = + components['schemas']['Data_LidoWithdrawalRequested']; +export type Data_LidoWithdrawalCompleted = + components['schemas']['Data_LidoWithdrawalCompleted']; +export type Data_RocketPoolStakeCompleted = + components['schemas']['Data_RocketPoolStakeCompleted']; +export type Data_RocketPoolUnstakeCompleted = + components['schemas']['Data_RocketPoolUnstakeCompleted']; +export type Data_ETHSent = components['schemas']['Data_ETHSent']; +export type Data_ETHReceived = components['schemas']['Data_ETHReceived']; +export type Data_ERC20Sent = components['schemas']['Data_ERC20Sent']; +export type Data_ERC20Received = components['schemas']['Data_ERC20Received']; +export type Data_ERC721Sent = components['schemas']['Data_ERC721Sent']; +export type Data_ERC721Received = components['schemas']['Data_ERC721Received']; +export type NetworkMetadata = components['schemas']['NetworkMetadata']; +export type BlockExplorer = components['schemas']['BlockExplorer']; + +export type UnprocessedRawNotification = + components['schemas']['NotificationOutputV4'][number]; +export type PlatformNotification = + components['schemas']['PlatformNotificationV4']; +export type OnChainNotification = + components['schemas']['OnChainNotificationV4']; + +type ConvertToEnum = { + [K in TRIGGER_TYPES]: Kind extends `${K}` ? K : never; +}[TRIGGER_TYPES]; + +/** + * Type-Computation. + * Adds a `type` field to on-chain notifications for easier enum checking. + * Preserves the original nested payload structure. + */ +type NormalizeOnChainNotification< + N extends OnChainNotification = OnChainNotification, + NotificationDataKinds extends string = NonNullable< + N['payload']['data'] + >['kind'], +> = { + [K in NotificationDataKinds]: Compute< + Omit & { + type: ConvertToEnum; + payload: Compute< + Omit & { + data: Extract, { kind: K }>; + } + >; + } + >; +}[NotificationDataKinds]; + +/** + * Type-Computation. + * Adds a `type` field to platform notifications for easier enum checking. + * Preserves the original nested payload structure. + */ +type NormalizePlatformNotification< + N extends PlatformNotification = PlatformNotification, +> = Compute< + N & { + type: TRIGGER_TYPES.PLATFORM; + } +>; + +export type OnChainRawNotification = Compute< + NormalizeOnChainNotification +>; + +export type PlatformRawNotification = Compute< + NormalizePlatformNotification +>; + +export type NormalisedAPINotification = + | OnChainRawNotification + | PlatformRawNotification; + +export type OnChainRawNotificationsWithNetworkFields = Extract< + OnChainRawNotification, + { payload: { data: { network_fee: unknown } } } +>; diff --git a/packages/notification-services-controller/src/NotificationServicesController/types/notification-api/schema.ts b/packages/notification-services-controller/src/NotificationServicesController/types/notification-api/schema.ts new file mode 100644 index 00000000000..619a061b260 --- /dev/null +++ b/packages/notification-services-controller/src/NotificationServicesController/types/notification-api/schema.ts @@ -0,0 +1,792 @@ +/* eslint-disable jsdoc/tag-lines */ +// Auto-generated from OpenAPI spec - naming follows API schema conventions +/* eslint-disable @typescript-eslint/naming-convention */ + +/** + * This file was auto-generated by openapi-typescript. + * Do not make direct changes to the file. + * Script: `npx openapi-typescript -o ./schema.ts` + */ + +export type paths = { + '/api/v4/notifications': { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * List both platform and on-chain notifications for a certain user/address(es) + * @description Same behaviour as /api/v3/notifications, but the returned notification_type and notification_subtype are taken directly from the producer-set database fields: platform notifications expose platform_notifications.notification_type / notification_subtype, while on-chain notifications expose the constant "wallet_activity" as notification_type and notifications_part.kind as notification_subtype. Clients should distinguish the two shapes structurally (presence of "payload" for on-chain vs "template" for platform). + */ + post: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + 'application/json': components['schemas']['NotificationInputV3']; + }; + }; + responses: { + /** @description Notifications listed successfully */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['NotificationOutputV4']; + }; + }; + /** @description Bad request */ + 400: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Internal server error */ + 500: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + '/api/v4/notifications/mark-as-read': { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Mark notifications as read */ + post: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + 'application/json': { + ids?: string[]; + }; + }; + }; + responses: { + /** @description Successfully marked notifications as read */ + 200: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + '/api/v3/notifications': { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** List both platform and on-chain notifications for a certain user/address(es) */ + post: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + 'application/json': components['schemas']['NotificationInputV3']; + }; + }; + responses: { + /** @description Notifications listed successfully */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['NotificationOutputV3']; + }; + }; + /** @description Bad request */ + 400: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Internal server error */ + 500: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + '/api/v3/notifications/mark-as-read': { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Mark notifications as read */ + post: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + 'application/json': { + ids?: string[]; + }; + }; + }; + responses: { + /** @description Successfully marked notifications as read */ + 200: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + '/api/v2/notifications': { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** List all notifications for a certain user/address(es) */ + post: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + 'application/json': components['schemas']['NotificationInput'][]; + }; + }; + responses: { + /** @description Notifications listed successfully */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['WalletNotification'][]; + }; + }; + /** @description Bad request */ + 400: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Internal server error */ + 500: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + '/api/v2/notifications/mark-as-read': { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Mark notifications as read */ + post: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + 'application/json': { + ids?: string[]; + }; + }; + }; + responses: { + /** @description Successfully marked notifications as read */ + 200: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + '/api/v1/notifications': { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** List all notifications ordered by most recent */ + post: { + parameters: { + query?: { + /** @description Page number for pagination */ + page?: number; + /** @description Number of notifications per page for pagination */ + per_page?: number; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: { + content: { + 'application/json': { + trigger_ids: string[]; + chain_ids?: number[]; + kinds?: string[]; + unread?: boolean; + }; + }; + }; + responses: { + /** @description Successfully fetched a list of notifications */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['WalletNotification'][]; + }; + }; + }; + }; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + '/api/v1/notifications/mark-as-read': { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Mark notifications as read */ + post: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + 'application/json': { + ids?: string[]; + }; + }; + }; + responses: { + /** @description Successfully marked notifications as read */ + 200: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; +}; +export type webhooks = Record; +export type components = { + schemas: { + /** + * @example mobile + * @enum {string} + */ + AppPlatform: 'portfolio' | 'extension' | 'mobile'; + NotificationInputV3: { + /** @example en-US */ + locale: string; + platform: components['schemas']['AppPlatform']; + addresses: string[]; + }; + NotificationOutputV3: ( + | components['schemas']['PlatformNotification'] + | components['schemas']['OnChainNotification'] + )[]; + PlatformNotification: { + /** + * Format: uuid + * @example 3fa85f64-5717-4562-b3fc-2c963f66afa6 + */ + id: string; + /** @enum {string} */ + notification_type: 'platform'; + /** @example position_liquidated */ + notification_subtype: string; + /** @example false */ + unread: boolean; + template: components['schemas']['LocalizedNotification']; + /** + * Format: date-time + * @example 2025-10-09T09:45:34.202Z + */ + created_at: string; + }; + LocalizedNotification: { + image_url: string; + cta?: components['schemas']['LocalizedNotificationCTA']; + title: string; + body: string; + }; + LocalizedNotificationCTA: { + content: string; + link: string; + }; + OnChainNotification: { + /** + * Format: uuid + * @example 3fa85f64-5717-4562-b3fc-2c963f66afa6 + */ + id: string; + /** @enum {string} */ + notification_type: 'on-chain'; + /** @example false */ + unread: boolean; + /** + * Format: date-time + * @example 2025-10-09T09:45:34.202Z + */ + created_at: string; + payload: components['schemas']['OnChainPayload']; + }; + OnChainPayload: { + /** @example 1 */ + chain_id: number; + /** @example 17485840 */ + block_number: number; + block_timestamp: string; + /** @example 0x881D40237659C251811CEC9c364ef91dC08D300C */ + tx_hash: string; + address: string; + network: components['schemas']['NetworkMetadata']; + data?: + | components['schemas']['Data_MetamaskSwapCompleted'] + | components['schemas']['Data_LidoStakeReadyToBeWithdrawn'] + | components['schemas']['Data_LidoStakeCompleted'] + | components['schemas']['Data_LidoWithdrawalRequested'] + | components['schemas']['Data_LidoWithdrawalCompleted'] + | components['schemas']['Data_RocketPoolStakeCompleted'] + | components['schemas']['Data_RocketPoolUnstakeCompleted'] + | components['schemas']['Data_ETHSent'] + | components['schemas']['Data_ETHReceived'] + | components['schemas']['Data_ERC20Sent'] + | components['schemas']['Data_ERC20Received'] + | components['schemas']['Data_ERC721Sent'] + | components['schemas']['Data_ERC721Received'] + | components['schemas']['Data_ERC1155Sent'] + | components['schemas']['Data_ERC1155Received']; + }; + /** @description A heterogeneous list of platform and on-chain notifications. The two shapes are discriminated by `notification_type`: a value of "wallet_activity" identifies an OnChainNotificationV4, any other value identifies a PlatformNotificationV4. "wallet_activity" is a reserved value and MUST NOT be used as a producer-set platform notification_type. (This cannot be modelled as a formal OpenAPI discriminator because the platform notification_type set is open-ended.) */ + NotificationOutputV4: ( + | components['schemas']['PlatformNotificationV4'] + | components['schemas']['OnChainNotificationV4'] + )[]; + PlatformNotificationV4: { + /** + * Format: uuid + * @example 3fa85f64-5717-4562-b3fc-2c963f66afa6 + */ + id: string; + /** + * @description Producer-set platform_notifications.notification_type value. + * @example perps + */ + notification_type: string; + /** + * @description Producer-set platform_notifications.notification_subtype value. + * @example position_liquidated + */ + notification_subtype: string; + /** @example false */ + unread: boolean; + template: components['schemas']['LocalizedNotification']; + /** + * Format: date-time + * @example 2025-10-09T09:45:34.202Z + */ + created_at: string; + }; + OnChainNotificationV4: { + /** + * Format: uuid + * @example 3fa85f64-5717-4562-b3fc-2c963f66afa6 + */ + id: string; + /** @enum {string} */ + notification_type: 'wallet_activity'; + /** + * @description notifications_part.kind value. + * @example metamask_swap_completed + */ + notification_subtype: string; + /** @example false */ + unread: boolean; + /** + * Format: date-time + * @example 2025-10-09T09:45:34.202Z + */ + created_at: string; + payload: components['schemas']['OnChainPayload']; + }; + NotificationInput: { + /** Format: address */ + address: string; + }; + WalletNotification: { + /** Format: uuid */ + id: string; + /** Format: uuid */ + trigger_id: string; + /** @example 1 */ + chain_id: number; + /** @example 17485840 */ + block_number: number; + block_timestamp: string; + /** + * Format: address + * @example 0x881D40237659C251811CEC9c364ef91dC08D300C + */ + tx_hash: string; + /** @example false */ + unread: boolean; + /** Format: date-time */ + created_at: string; + /** Format: address */ + address: string; + data?: + | components['schemas']['Data_MetamaskSwapCompleted'] + | components['schemas']['Data_LidoStakeReadyToBeWithdrawn'] + | components['schemas']['Data_LidoStakeCompleted'] + | components['schemas']['Data_LidoWithdrawalRequested'] + | components['schemas']['Data_LidoWithdrawalCompleted'] + | components['schemas']['Data_RocketPoolStakeCompleted'] + | components['schemas']['Data_RocketPoolUnstakeCompleted'] + | components['schemas']['Data_ETHSent'] + | components['schemas']['Data_ETHReceived'] + | components['schemas']['Data_ERC20Sent'] + | components['schemas']['Data_ERC20Received'] + | components['schemas']['Data_ERC721Sent'] + | components['schemas']['Data_ERC721Received'] + | components['schemas']['Data_ERC1155Sent'] + | components['schemas']['Data_ERC1155Received']; + }; + Data_MetamaskSwapCompleted: { + /** @enum {string} */ + kind: 'metamask_swap_completed'; + network_fee: components['schemas']['NetworkFee']; + /** Format: decimal */ + rate: string; + token_in: components['schemas']['Token']; + token_out: components['schemas']['Token']; + }; + Data_LidoStakeCompleted: { + /** @enum {string} */ + kind: 'lido_stake_completed'; + network_fee: components['schemas']['NetworkFee']; + stake_in: components['schemas']['Stake']; + stake_out: components['schemas']['Stake']; + }; + Data_LidoWithdrawalRequested: { + /** @enum {string} */ + kind: 'lido_withdrawal_requested'; + network_fee: components['schemas']['NetworkFee']; + stake_in: components['schemas']['Stake']; + stake_out: components['schemas']['Stake']; + }; + Data_LidoStakeReadyToBeWithdrawn: { + /** @enum {string} */ + kind: 'lido_stake_ready_to_be_withdrawn'; + /** Format: decimal */ + request_id: string; + staked_eth: components['schemas']['Stake']; + }; + Data_LidoWithdrawalCompleted: { + /** @enum {string} */ + kind: 'lido_withdrawal_completed'; + network_fee: components['schemas']['NetworkFee']; + stake_in: components['schemas']['Stake']; + stake_out: components['schemas']['Stake']; + }; + Data_RocketPoolStakeCompleted: { + /** @enum {string} */ + kind: 'rocketpool_stake_completed'; + network_fee: components['schemas']['NetworkFee']; + stake_in: components['schemas']['Stake']; + stake_out: components['schemas']['Stake']; + }; + Data_RocketPoolUnstakeCompleted: { + /** @enum {string} */ + kind: 'rocketpool_unstake_completed'; + network_fee: components['schemas']['NetworkFee']; + stake_in: components['schemas']['Stake']; + stake_out: components['schemas']['Stake']; + }; + Data_ETHSent: { + /** @enum {string} */ + kind: 'eth_sent'; + network_fee: components['schemas']['NetworkFee']; + /** Format: address */ + from: string; + /** Format: address */ + to: string; + amount: { + /** Format: decimal */ + usd: string; + /** Format: decimal */ + eth: string; + }; + }; + Data_ETHReceived: { + /** @enum {string} */ + kind: 'eth_received'; + network_fee: components['schemas']['NetworkFee']; + /** Format: address */ + from: string; + /** Format: address */ + to: string; + amount: { + /** Format: decimal */ + usd: string; + /** Format: decimal */ + eth: string; + }; + }; + Data_ERC20Sent: { + /** @enum {string} */ + kind: 'erc20_sent'; + network_fee: components['schemas']['NetworkFee']; + /** Format: address */ + from: string; + /** Format: address */ + to: string; + token: components['schemas']['Token']; + }; + Data_ERC20Received: { + /** @enum {string} */ + kind: 'erc20_received'; + network_fee: components['schemas']['NetworkFee']; + /** Format: address */ + from: string; + /** Format: address */ + to: string; + token: components['schemas']['Token']; + }; + Data_ERC721Sent: { + /** @enum {string} */ + kind: 'erc721_sent'; + network_fee: components['schemas']['NetworkFee']; + /** Format: address */ + from: string; + /** Format: address */ + to: string; + nft: components['schemas']['NFT']; + }; + Data_ERC721Received: { + /** @enum {string} */ + kind: 'erc721_received'; + network_fee: components['schemas']['NetworkFee']; + /** Format: address */ + from: string; + /** Format: address */ + to: string; + nft: components['schemas']['NFT']; + }; + Data_ERC1155Sent: { + /** @enum {string} */ + kind: 'erc1155_sent'; + network_fee: components['schemas']['NetworkFee']; + /** Format: address */ + from: string; + /** Format: address */ + to: string; + nft?: components['schemas']['NFT']; + }; + Data_ERC1155Received: { + /** @enum {string} */ + kind: 'erc1155_received'; + network_fee: components['schemas']['NetworkFee']; + /** Format: address */ + from: string; + /** Format: address */ + to: string; + nft?: components['schemas']['NFT']; + }; + NetworkFee: { + /** Format: decimal */ + gas_price: string; + /** Format: decimal */ + native_token_price_in_usd: string; + }; + Token: { + /** Format: address */ + address: string; + symbol: string; + name: string; + /** Format: decimal */ + amount: string; + /** Format: int32 */ + decimals: string; + /** Format: uri */ + image: string; + /** Format: decimal */ + usd: string; + }; + NFT: { + name: string; + token_id: string; + /** Format: uri */ + image: string; + collection: { + /** Format: address */ + address: string; + name: string; + symbol: string; + /** Format: uri */ + image: string; + }; + }; + Stake: { + /** Format: address */ + address: string; + symbol: string; + name: string; + /** Format: decimal */ + amount: string; + /** Format: int32 */ + decimals: string; + /** Format: uri */ + image: string; + /** Format: decimal */ + usd: string; + }; + BlockExplorer: { + /** + * Format: uri + * @example https://etherscan.io + */ + url: string; + /** @example Etherscan */ + name: string; + }; + NetworkMetadata: { + /** + * @description Human-readable network name + * @example Ethereum + */ + name: string; + /** + * @description Native token symbol + * @example ETH + */ + native_symbol: string; + block_explorer: components['schemas']['BlockExplorer']; + }; + }; + responses: never; + parameters: never; + requestBodies: never; + headers: never; + pathItems: never; +}; +export type $defs = Record; +export type operations = Record; diff --git a/packages/notification-services-controller/src/NotificationServicesController/types/notification/index.ts b/packages/notification-services-controller/src/NotificationServicesController/types/notification/index.ts new file mode 100644 index 00000000000..99762f3a1c6 --- /dev/null +++ b/packages/notification-services-controller/src/NotificationServicesController/types/notification/index.ts @@ -0,0 +1 @@ +export type * from './notification.js'; diff --git a/packages/notification-services-controller/src/NotificationServicesController/types/notification/notification.ts b/packages/notification-services-controller/src/NotificationServicesController/types/notification/notification.ts new file mode 100644 index 00000000000..b2c163a8b0e --- /dev/null +++ b/packages/notification-services-controller/src/NotificationServicesController/types/notification/notification.ts @@ -0,0 +1,34 @@ +import type { FeatureAnnouncementRawNotification } from '../feature-announcement/feature-announcement.js'; +import type { NormalisedAPINotification } from '../notification-api/notification-api.js'; +import type { RawSnapNotification } from '../snaps/index.js'; +import type { Compute } from '../type-utils.js'; + +export type BaseNotification = { + id: string; + // eslint-disable-next-line @typescript-eslint/naming-convention + notification_subtype: string; + createdAt: string; + isRead: boolean; +}; + +export type RawNotificationUnion = + | NormalisedAPINotification + | FeatureAnnouncementRawNotification + | RawSnapNotification; + +/** + * The shape of a "generic" notification. + * Other than the fields listed below, tt will also contain: + * - `type` field (declared in the Raw shapes) + * - `data` field (declared in the Raw shapes) + */ +export type INotification = Compute< + | (FeatureAnnouncementRawNotification & BaseNotification) + | (NormalisedAPINotification & BaseNotification) + | (RawSnapNotification & BaseNotification & { readDate?: string | null }) +>; + +export type MarkAsReadNotificationsParam = Pick< + INotification, + 'id' | 'type' | 'isRead' +>[]; diff --git a/packages/notification-services-controller/src/NotificationServicesController/types/perps/index.ts b/packages/notification-services-controller/src/NotificationServicesController/types/perps/index.ts new file mode 100644 index 00000000000..38f4e234987 --- /dev/null +++ b/packages/notification-services-controller/src/NotificationServicesController/types/perps/index.ts @@ -0,0 +1 @@ +export type * from './perp-types.js'; diff --git a/packages/notification-services-controller/src/NotificationServicesController/types/perps/perp-types.ts b/packages/notification-services-controller/src/NotificationServicesController/types/perps/perp-types.ts new file mode 100644 index 00000000000..2396105a341 --- /dev/null +++ b/packages/notification-services-controller/src/NotificationServicesController/types/perps/perp-types.ts @@ -0,0 +1,3 @@ +import type { components } from './schema.js'; + +export type OrderInput = components['schemas']['OrderInput']; diff --git a/packages/notification-services-controller/src/NotificationServicesController/types/perps/schema.ts b/packages/notification-services-controller/src/NotificationServicesController/types/perps/schema.ts new file mode 100644 index 00000000000..e4ef787f43a --- /dev/null +++ b/packages/notification-services-controller/src/NotificationServicesController/types/perps/schema.ts @@ -0,0 +1,138 @@ +/* eslint-disable jsdoc/tag-lines */ +// This file is auto-generated from OpenAPI spec and uses snake_case property names from the external API +/* eslint-disable @typescript-eslint/naming-convention */ + +/** + * This file was auto-generated by openapi-typescript. + * Do not make direct changes to the file. + * Script: `npx openapi-typescript -o ./schema.d.ts` + */ + +/** + * This file was auto-generated by openapi-typescript. + * Do not make direct changes to the file. + */ + +export type paths = { + '/api/v1/orders': { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Create a new trading order + * @description Creates a new trading order for a specific user. + * + * Supports optional stop-loss (sl_price) and take-profit (tp_price) levels. + * + * **Authentication Required**: This endpoint requires JWT authentication. + * + */ + post: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + 'application/json': components['schemas']['OrderInput']; + }; + }; + responses: { + /** @description Order successfully created */ + 201: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Invalid request - malformed JSON or missing required fields */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['Error']; + }; + }; + /** @description Unauthorized - invalid or missing JWT token */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['Error']; + }; + }; + /** @description Internal server error */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['Error']; + }; + }; + }; + }; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; +}; +export type webhooks = Record; +export type components = { + schemas: { + OrderInput: { + /** + * @description User's Ethereum address + * @example 0x1234567890abcdef1234567890abcdef12345678 + */ + user_id: string; + /** + * @description Coin symbol (e.g., BTC, ETH, DOGE) + * @example BTC + */ + coin: string; + /** + * Format: double + * @description Optional stop-loss price level + * @example 45000.5 + */ + sl_price?: number; + /** + * Format: double + * @description Optional take-profit price level + * @example 55000.75 + */ + tp_price?: number; + }; + Error: { + /** + * @description Human-readable error message + * @example Invalid request format + */ + message?: string; + /** + * @description Technical error details + * @example validation error + */ + error?: string; + }; + }; + responses: never; + parameters: never; + requestBodies: never; + headers: never; + pathItems: never; +}; +export type $defs = Record; +export type operations = Record; diff --git a/packages/notification-services-controller/src/NotificationServicesController/types/snaps/index.ts b/packages/notification-services-controller/src/NotificationServicesController/types/snaps/index.ts new file mode 100644 index 00000000000..e5d6174dff5 --- /dev/null +++ b/packages/notification-services-controller/src/NotificationServicesController/types/snaps/index.ts @@ -0,0 +1 @@ +export type * from './snaps.js'; diff --git a/packages/notification-services-controller/src/NotificationServicesController/types/snaps/snaps.ts b/packages/notification-services-controller/src/NotificationServicesController/types/snaps/snaps.ts new file mode 100644 index 00000000000..7681dbddf5d --- /dev/null +++ b/packages/notification-services-controller/src/NotificationServicesController/types/snaps/snaps.ts @@ -0,0 +1,20 @@ +import type { TRIGGER_TYPES } from '../../constants/index.js'; + +export type ExpandedView = { + title: string; + interfaceId: string; + footerLink?: { href: string; text: string }; +}; + +export type RawSnapNotificationData = + | { + message: string; + origin: string; + } + | { message: string; origin: string; detailedView: ExpandedView }; + +export type RawSnapNotification = { + type: TRIGGER_TYPES.SNAP; + data: RawSnapNotificationData; + readDate: string | null; +}; diff --git a/packages/notification-services-controller/src/NotificationServicesController/types/type-utils.ts b/packages/notification-services-controller/src/NotificationServicesController/types/type-utils.ts new file mode 100644 index 00000000000..64557dcd0ce --- /dev/null +++ b/packages/notification-services-controller/src/NotificationServicesController/types/type-utils.ts @@ -0,0 +1,6 @@ +/** + * Computes and combines intersection types for a more "prettier" type (more human readable) + */ +export type Compute = Item extends Item + ? { [K in keyof Item]: Item[K] } + : never; diff --git a/packages/notification-services-controller/src/NotificationServicesController/utils/get-notification-subtype.test.ts b/packages/notification-services-controller/src/NotificationServicesController/utils/get-notification-subtype.test.ts new file mode 100644 index 00000000000..0c6992a7172 --- /dev/null +++ b/packages/notification-services-controller/src/NotificationServicesController/utils/get-notification-subtype.test.ts @@ -0,0 +1,39 @@ +import { TRIGGER_TYPES } from '../constants/notification-schema.js'; +import { createMockFeatureAnnouncementRaw } from '../mocks/mock-feature-announcements.js'; +import { + createMockNotificationEthReceived, + createMockPlatformNotification, +} from '../mocks/mock-raw-notifications.js'; +import { createMockSnapNotification } from '../mocks/mock-snap-notification.js'; +import { processNotification } from '../processors/process-notifications.js'; +import { getNotificationSubtype } from './get-notification-subtype.js'; + +describe('getNotificationSubtype', () => { + it('returns the trigger kind for on-chain notifications', () => { + const notification = processNotification( + createMockNotificationEthReceived(), + ); + expect(getNotificationSubtype(notification)).toBe( + TRIGGER_TYPES.ETH_RECEIVED, + ); + }); + + it('returns the server-set notification_subtype for platform notifications', () => { + const notification = processNotification(createMockPlatformNotification()); + expect(getNotificationSubtype(notification)).toBe('position_liquidated'); + }); + + it('returns the snap subtype for snap notifications', () => { + const notification = processNotification(createMockSnapNotification()); + expect(getNotificationSubtype(notification)).toBe(TRIGGER_TYPES.SNAP); + }); + + it('returns a stable label for feature-announcement notifications', () => { + const notification = processNotification( + createMockFeatureAnnouncementRaw(), + ); + expect(getNotificationSubtype(notification)).toBe( + TRIGGER_TYPES.FEATURES_ANNOUNCEMENT, + ); + }); +}); diff --git a/packages/notification-services-controller/src/NotificationServicesController/utils/get-notification-subtype.ts b/packages/notification-services-controller/src/NotificationServicesController/utils/get-notification-subtype.ts new file mode 100644 index 00000000000..d0c99acbcd2 --- /dev/null +++ b/packages/notification-services-controller/src/NotificationServicesController/utils/get-notification-subtype.ts @@ -0,0 +1,34 @@ +import { isOnChainRawNotification } from '../../shared/is-onchain-notification.js'; +import { TRIGGER_TYPES } from '../constants/notification-schema.js'; +import type { RawNotificationUnion } from '../types/notification/notification.js'; + +/** + * Derives the normalised `notification_subtype` for a processed in-app + * notification. This is the team-owned axis (e.g. `eth_received`) and is + * always derivable from an `INotification`, so every consumer (both clients) + * pulls it from one place rather than recomputing a fallback chain. + * + * - on-chain: the trigger kind (`payload.data.kind`, e.g. `eth_received`). + * - platform: the server-set `notification_subtype` from the inbox API. + * - everything else (snap, feature-announcement): the top-level `type` + * (`snap` / `features_announcement`). + * + * @param notification - a raw or processed notification. + * @returns the normalised subtype string. + */ +export function getNotificationSubtype( + notification: RawNotificationUnion, +): string { + // On-chain: the trigger kind (e.g. `eth_received`). + if (isOnChainRawNotification(notification)) { + return notification.payload.data.kind; + } + + // Platform: the server-set `notification_subtype` from the inbox API. + if (notification.type === TRIGGER_TYPES.PLATFORM) { + return notification.notification_subtype; + } + + // Fallback (snap, feature-announcement): the top-level `type`. + return notification.type; +} diff --git a/packages/notification-services-controller/src/NotificationServicesController/utils/isVersionInBounds.test.ts b/packages/notification-services-controller/src/NotificationServicesController/utils/isVersionInBounds.test.ts new file mode 100644 index 00000000000..8bda023ecc1 --- /dev/null +++ b/packages/notification-services-controller/src/NotificationServicesController/utils/isVersionInBounds.test.ts @@ -0,0 +1,210 @@ +import { isVersionInBounds } from './isVersionInBounds.js'; + +describe('isVersionInBounds', () => { + const version = '7.57.0'; + + const minimumVersionSchema = [ + { + testName: 'returns true when current version is above minimum', + minVersion: '7.56.0', + currentVersion: version, + expected: true, + }, + { + testName: 'returns false when current version equals minimum', + minVersion: '7.57.0', + currentVersion: version, + expected: false, + }, + { + testName: 'returns false when current version is below minimum', + minVersion: '7.58.0', + currentVersion: version, + expected: false, + }, + { + testName: 'returns true when no minimum version is specified', + minVersion: undefined, + currentVersion: version, + expected: true, + }, + { + testName: 'returns true when no current version is provided', + minVersion: '7.56.0', + currentVersion: undefined, + expected: true, + }, + { + testName: 'returns false when minimum version is malformed', + minVersion: 'invalid-version', + currentVersion: version, + expected: false, + }, + ]; + + it.each(minimumVersionSchema)( + 'minimum version test - $testName', + ({ minVersion, currentVersion, expected }) => { + const result = isVersionInBounds({ + currentVersion, + minVersion, + }); + expect(result).toBe(expected); + }, + ); + + const maximumVersionSchema = [ + { + testName: 'returns true when current version is below maximum', + maxVersion: '7.58.0', + currentVersion: version, + expected: true, + }, + { + testName: 'returns false when current version equals maximum', + maxVersion: '7.57.0', + currentVersion: version, + expected: false, + }, + { + testName: 'returns false when current version is above maximum', + maxVersion: '7.56.0', + currentVersion: version, + expected: false, + }, + { + testName: 'returns true when no maximum version is specified', + maxVersion: undefined, + currentVersion: version, + expected: true, + }, + { + testName: 'returns true when no current version is provided', + maxVersion: '7.58.0', + currentVersion: undefined, + expected: true, + }, + { + testName: 'returns false when maximum version is malformed', + maxVersion: 'invalid-version', + currentVersion: version, + expected: false, + }, + ]; + + it.each(maximumVersionSchema)( + 'maximum version test - $testName', + ({ maxVersion, currentVersion, expected }) => { + const result = isVersionInBounds({ + currentVersion, + maxVersion, + }); + expect(result).toBe(expected); + }, + ); + + const minMaxVersionSchema = [ + { + testName: + 'returns true when version is within both bounds (min < current < max)', + minVersion: '7.56.0', + maxVersion: '7.58.0', + currentVersion: version, + expected: true, + }, + { + testName: 'returns true when version is above minimum and below maximum', + minVersion: '7.56.5', + maxVersion: '7.57.5', + currentVersion: version, + expected: true, + }, + { + testName: 'returns false when version equals minimum bound', + minVersion: '7.57.0', + maxVersion: '7.58.0', + currentVersion: version, + expected: false, + }, + { + testName: 'returns false when version equals maximum bound', + minVersion: '7.56.0', + maxVersion: '7.57.0', + currentVersion: version, + expected: false, + }, + { + testName: 'returns false when version is below minimum bound', + minVersion: '7.58.0', + maxVersion: '7.59.0', + currentVersion: version, + expected: false, + }, + { + testName: 'returns false when version is above maximum bound', + minVersion: '7.55.0', + maxVersion: '7.56.0', + currentVersion: version, + expected: false, + }, + { + testName: 'returns true when both bounds are undefined', + minVersion: undefined, + maxVersion: undefined, + currentVersion: version, + expected: true, + }, + { + testName: + 'returns true when only minimum is defined and version is above it', + minVersion: '7.56.0', + maxVersion: undefined, + currentVersion: version, + expected: true, + }, + { + testName: + 'returns true when only maximum is defined and version is below it', + minVersion: undefined, + maxVersion: '7.58.0', + currentVersion: version, + expected: true, + }, + { + testName: + 'returns true when no current version is provided regardless of bounds', + minVersion: '7.56.0', + maxVersion: '7.58.0', + currentVersion: undefined, + expected: true, + }, + { + testName: + 'returns false when minimum is malformed but maximum excludes current version', + minVersion: 'malformed', + maxVersion: '7.56.0', + currentVersion: version, + expected: false, + }, + { + testName: + 'returns false when maximum is malformed but minimum excludes current version', + minVersion: '7.58.0', + maxVersion: 'malformed', + currentVersion: version, + expected: false, + }, + ]; + + it.each(minMaxVersionSchema)( + 'min & max version bounds test - $testName', + ({ minVersion, maxVersion, currentVersion, expected }) => { + const result = isVersionInBounds({ + currentVersion, + minVersion, + maxVersion, + }); + expect(result).toBe(expected); + }, + ); +}); diff --git a/packages/notification-services-controller/src/NotificationServicesController/utils/isVersionInBounds.ts b/packages/notification-services-controller/src/NotificationServicesController/utils/isVersionInBounds.ts new file mode 100644 index 00000000000..a8394dd92df --- /dev/null +++ b/packages/notification-services-controller/src/NotificationServicesController/utils/isVersionInBounds.ts @@ -0,0 +1,46 @@ +import { gt, lt } from 'semver'; + +type IsVersionInBounds = { + currentVersion?: string; + minVersion?: string; + maxVersion?: string; +}; + +/** + * Checks if a given version is within bounds against a min and max bound + * Uses semver strings + * + * @param params - Object param containing current/min/max versions + * @param params.currentVersion - (optional) current version of application + * @param params.minVersion - (optional) exclusive min bounds + * @param params.maxVersion - (optional) exclusive max bounds + * @returns boolean is version provided is within bounds + */ +export function isVersionInBounds({ + currentVersion, + minVersion, + maxVersion, +}: IsVersionInBounds): boolean { + if (!currentVersion) { + return true; + } + + try { + let showNotification = true; + + // Check minimum version: current version must be greater than minimum + if (minVersion) { + showNotification = showNotification && gt(currentVersion, minVersion); + } + + // Check maximum version: current version must be less than maximum + if (maxVersion) { + showNotification = showNotification && lt(currentVersion, maxVersion); + } + + return showNotification; + } catch { + // something went wrong checking bounds + return false; + } +} diff --git a/packages/notification-services-controller/src/NotificationServicesController/utils/should-auto-expire.ts b/packages/notification-services-controller/src/NotificationServicesController/utils/should-auto-expire.ts new file mode 100644 index 00000000000..88d6fe070ac --- /dev/null +++ b/packages/notification-services-controller/src/NotificationServicesController/utils/should-auto-expire.ts @@ -0,0 +1,8 @@ +const ONE_DAY_MS = 1000 * 60 * 60 * 24; +const MAX_DAYS = 30; + +export const shouldAutoExpire = (oldDate: Date): boolean => { + const differenceInTime = Date.now() - oldDate.getTime(); + const differenceInDays = differenceInTime / ONE_DAY_MS; + return differenceInDays >= MAX_DAYS; +}; diff --git a/packages/notification-services-controller/src/NotificationServicesController/utils/utils.ts b/packages/notification-services-controller/src/NotificationServicesController/utils/utils.ts new file mode 100644 index 00000000000..068782514c5 --- /dev/null +++ b/packages/notification-services-controller/src/NotificationServicesController/utils/utils.ts @@ -0,0 +1,26 @@ +/** + * Performs an API call with automatic retries on failure. + * + * @param bearerToken - The JSON Web Token for authorization. + * @param endpoint - The URL of the API endpoint to call. + * @param method - The HTTP method ('POST' or 'DELETE'). + * @param body - The body of the request. It should be an object that can be serialized to JSON. + * @returns A Promise that resolves to the response of the fetch request. + */ +export async function makeApiCall( + bearerToken: string, + endpoint: string, + method: 'POST' | 'DELETE', + body: Body, +): Promise { + const options: RequestInit = { + method, + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${bearerToken}`, + }, + body: JSON.stringify(body), + }; + + return await fetch(endpoint, options); +} diff --git a/packages/notification-services-controller/src/NotificationServicesPushController/NotificationServicesPushController-method-action-types.ts b/packages/notification-services-controller/src/NotificationServicesPushController/NotificationServicesPushController-method-action-types.ts new file mode 100644 index 00000000000..156391eca31 --- /dev/null +++ b/packages/notification-services-controller/src/NotificationServicesPushController/NotificationServicesPushController-method-action-types.ts @@ -0,0 +1,88 @@ +/** + * This file is auto generated. + * Do not edit manually. + */ + +import type { NotificationServicesPushController } from './NotificationServicesPushController.js'; + +export type NotificationServicesPushControllerSubscribeToPushNotificationsAction = + { + type: `NotificationServicesPushController:subscribeToPushNotifications`; + handler: NotificationServicesPushController['subscribeToPushNotifications']; + }; + +/** + * Enables push notifications for the application. + * + * This method sets up the necessary infrastructure for handling push notifications by: + * 1. Registering the service worker to listen for messages. + * 2. Fetching the Firebase Cloud Messaging (FCM) token from Firebase. + * 3. Sending the FCM token to the server responsible for sending notifications, to register the device. + * + * @param addresses - An array of addresses to enable push notifications for. + */ +export type NotificationServicesPushControllerEnablePushNotificationsAction = { + type: `NotificationServicesPushController:enablePushNotifications`; + handler: NotificationServicesPushController['enablePushNotifications']; +}; + +/** + * Disables push notifications for the application. + * This removes the registration token on this device, and ensures we unsubscribe from any listeners + */ +export type NotificationServicesPushControllerDisablePushNotificationsAction = { + type: `NotificationServicesPushController:disablePushNotifications`; + handler: NotificationServicesPushController['disablePushNotifications']; +}; + +/** + * Adds backend push notification links for the given addresses using the current FCM token. + * This is used when accounts are added after push notifications have already been enabled, + * so backend can link the existing device token to the newly added addresses. + * + * @param addresses - Addresses that should be linked to push notifications. + * @returns Whether the add request succeeded. + */ +export type NotificationServicesPushControllerAddPushNotificationLinksAction = { + type: `NotificationServicesPushController:addPushNotificationLinks`; + handler: NotificationServicesPushController['addPushNotificationLinks']; +}; + +/** + * Deletes backend push notification links for the given addresses on the current platform. + * This is used when accounts are removed (for example SRP removal), so backend can remove + * all associated FCM tokens for those address/platform pairs. + * + * @param addresses - Addresses that should be unlinked from push notifications. + * @returns Whether the delete request succeeded. + */ +export type NotificationServicesPushControllerDeletePushNotificationLinksAction = + { + type: `NotificationServicesPushController:deletePushNotificationLinks`; + handler: NotificationServicesPushController['deletePushNotificationLinks']; + }; + +/** + * Updates the triggers for push notifications. + * This method is responsible for updating the server with the new set of addresses that should trigger push notifications. + * It uses the current FCM token and a BearerToken for authentication. + * + * @param addresses - An array of addresses that should trigger push notifications. + * @deprecated - this is not used anymore and will most likely be removed + */ +export type NotificationServicesPushControllerUpdateTriggerPushNotificationsAction = + { + type: `NotificationServicesPushController:updateTriggerPushNotifications`; + handler: NotificationServicesPushController['updateTriggerPushNotifications']; + }; + +/** + * Union of all NotificationServicesPushController action types. + */ +export type NotificationServicesPushControllerMethodActions = + | NotificationServicesPushControllerSubscribeToPushNotificationsAction + | NotificationServicesPushControllerEnablePushNotificationsAction + | NotificationServicesPushControllerDisablePushNotificationsAction + | NotificationServicesPushControllerAddPushNotificationLinksAction + | NotificationServicesPushControllerDeletePushNotificationLinksAction + | NotificationServicesPushControllerUpdateTriggerPushNotificationsAction; diff --git a/packages/notification-services-controller/src/NotificationServicesPushController/NotificationServicesPushController.test.ts b/packages/notification-services-controller/src/NotificationServicesPushController/NotificationServicesPushController.test.ts new file mode 100644 index 00000000000..4169406ab9d --- /dev/null +++ b/packages/notification-services-controller/src/NotificationServicesPushController/NotificationServicesPushController.test.ts @@ -0,0 +1,631 @@ +import { deriveStateFromMetadata } from '@metamask/base-controller'; +import type { AuthenticationController } from '@metamask/profile-sync-controller'; +import log from 'loglevel'; + +import { buildPushPlatformNotificationsControllerMessenger } from './__fixtures__/mockMessenger.js'; +import { NotificationServicesPushController } from './NotificationServicesPushController.js'; +import type { + ControllerConfig, + NotificationServicesPushControllerMessenger, +} from './NotificationServicesPushController.js'; +import * as services from './services/services.js'; +import type { PushNotificationEnv } from './types/index.js'; + +const MOCK_JWT = 'mockJwt'; +const MOCK_FCM_TOKEN = 'mockFcmToken'; +const MOCK_ADDRESSES = ['0x123', '0x456', '0x789']; + +// Testing util to clean up verbose logs when testing errors +const mockErrorLog = (): jest.SpyInstance => + jest.spyOn(log, 'error').mockImplementation(jest.fn()); + +describe('NotificationServicesPushController', () => { + const arrangeServicesMocks = ( + token?: string, + ): { + activatePushNotificationsMock: jest.SpyInstance; + deactivatePushNotificationsMock: jest.SpyInstance; + updateLinksAPIMock: jest.SpyInstance; + deleteLinksAPIMock: jest.SpyInstance; + } => { + const activatePushNotificationsMock = jest + .spyOn(services, 'activatePushNotifications') + .mockResolvedValue(token ?? MOCK_FCM_TOKEN); + + const deactivatePushNotificationsMock = jest + .spyOn(services, 'deactivatePushNotifications') + .mockResolvedValue(true); + + const updateLinksAPIMock = jest + .spyOn(services, 'updateLinksAPI') + .mockResolvedValue(true); + + const deleteLinksAPIMock = jest + .spyOn(services, 'deleteLinksAPI') + .mockResolvedValue(true); + + return { + activatePushNotificationsMock, + deactivatePushNotificationsMock, + updateLinksAPIMock, + deleteLinksAPIMock, + }; + }; + + describe('subscribeToPushNotifications', () => { + afterEach(() => { + jest.clearAllMocks(); + }); + + it('should unsubscribe to old listeners and subscribe to new listeners if called multiple times', async () => { + const mockUnsubscribe = jest.fn(); + const mockSubscribe = jest.fn().mockReturnValue(mockUnsubscribe); + const { controller } = arrangeMockMessenger({ + pushService: { + createRegToken: jest.fn(), + deleteRegToken: jest.fn(), + subscribeToPushNotifications: mockSubscribe, + }, + }); + + await controller.subscribeToPushNotifications(); + expect(mockSubscribe).toHaveBeenCalledTimes(1); + expect(mockUnsubscribe).not.toHaveBeenCalled(); + + await controller.subscribeToPushNotifications(); + expect(mockSubscribe).toHaveBeenCalledTimes(2); + expect(mockUnsubscribe).toHaveBeenCalledTimes(1); + }); + }); + + describe('enablePushNotifications', () => { + afterEach(() => { + jest.clearAllMocks(); + }); + + it('should update the state with the fcmToken', async () => { + arrangeServicesMocks(); + const { controller, messenger } = arrangeMockMessenger(); + mockAuthBearerTokenCall(messenger); + + const promise = controller.enablePushNotifications(MOCK_ADDRESSES); + expect(controller.state.isUpdatingFCMToken).toBe(true); + + await promise; + expect(controller.state.fcmToken).toBe(MOCK_FCM_TOKEN); + expect(controller.state.isPushEnabled).toBe(true); + expect(controller.state.isUpdatingFCMToken).toBe(false); + }); + + it('should call activatePushNotifications with correct parameters including oldToken', async () => { + const mocks = arrangeServicesMocks(); + const { controller, messenger } = arrangeMockMessenger({ + state: { + fcmToken: 'existing-token', + isPushEnabled: true, + isUpdatingFCMToken: false, + }, + }); + mockAuthBearerTokenCall(messenger); + + await controller.enablePushNotifications(MOCK_ADDRESSES); + + expect(mocks.activatePushNotificationsMock).toHaveBeenCalledWith({ + bearerToken: MOCK_JWT, + addresses: MOCK_ADDRESSES, + env: expect.any(Object), + createRegToken: expect.any(Function), + regToken: { + platform: 'extension', + locale: 'en', + oldToken: 'existing-token', + }, + controllerEnv: 'prd', + }); + }); + + it('should call activatePushNotifications with mobile OS and app version metadata', async () => { + const mocks = arrangeServicesMocks(); + const { controller, messenger } = arrangeMockMessenger({ + platform: 'mobile', + os: 'android', + appVersion: '7.42.0', + }); + mockAuthBearerTokenCall(messenger); + + await controller.enablePushNotifications(MOCK_ADDRESSES); + + expect(mocks.activatePushNotificationsMock).toHaveBeenCalledWith({ + bearerToken: MOCK_JWT, + addresses: MOCK_ADDRESSES, + env: expect.any(Object), + createRegToken: expect.any(Function), + regToken: { + platform: 'mobile', + locale: 'en', + oldToken: '', + os: 'android', + appVersion: '7.42.0', + }, + controllerEnv: 'prd', + }); + }); + + it('should not activate push notifications triggers if there is no auth bearer token', async () => { + const mocks = arrangeServicesMocks(); + const { controller, messenger } = arrangeMockMessenger(); + const mockBearerTokenCall = mockAuthBearerTokenCall(messenger); + mockBearerTokenCall.mockRejectedValue(new Error('TEST ERROR')); + + await controller.enablePushNotifications(MOCK_ADDRESSES); + expect(mocks.activatePushNotificationsMock).not.toHaveBeenCalled(); + expect(controller.state.isUpdatingFCMToken).toBe(false); + }); + + it('should not update reg token if push service fails', async () => { + const mocks = arrangeServicesMocks(); + const { controller, messenger, initialState } = arrangeMockMessenger(); + mockAuthBearerTokenCall(messenger); + mocks.activatePushNotificationsMock.mockRejectedValue( + new Error('TEST ERROR'), + ); + + await controller.enablePushNotifications(MOCK_ADDRESSES); + expect(controller.state.fcmToken).toBe(initialState.fcmToken); + expect(controller.state.isUpdatingFCMToken).toBe(false); + }); + }); + + describe('disablePushNotifications', () => { + afterEach(() => { + jest.clearAllMocks(); + }); + + it('should update the state removing the fcmToken', async () => { + arrangeServicesMocks(); + const { controller } = arrangeMockMessenger(); + const promise = controller.disablePushNotifications(); + expect(controller.state.isUpdatingFCMToken).toBe(true); + + await promise; + expect(controller.state.fcmToken).toBe(''); + expect(controller.state.isPushEnabled).toBe(false); + expect(controller.state.isUpdatingFCMToken).toBe(false); + }); + + it('should bail early if push is not enabled', async () => { + const mocks = arrangeServicesMocks(); + const { controller, messenger } = arrangeMockMessenger({ + isPushFeatureEnabled: false, + }); + mockAuthBearerTokenCall(messenger); + + await controller.disablePushNotifications(); + expect(mocks.deactivatePushNotificationsMock).not.toHaveBeenCalled(); + expect(controller.state.isUpdatingFCMToken).toBe(false); + }); + + it('should fail if fails to delete FCM token', async () => { + const mocks = arrangeServicesMocks(); + mocks.deactivatePushNotificationsMock.mockRejectedValue( + new Error('TEST ERROR'), + ); + mockErrorLog(); + const { controller, messenger } = arrangeMockMessenger(); + mockAuthBearerTokenCall(messenger).mockResolvedValue( + null as unknown as string, + ); + await expect(controller.disablePushNotifications()).rejects.toThrow( + expect.any(Error), + ); + expect(controller.state.isUpdatingFCMToken).toBe(false); + }); + }); + + describe('updateTriggerPushNotifications', () => { + afterEach(() => { + jest.clearAllMocks(); + }); + + it('should call activatePushNotifications with the correct parameters and update state', async () => { + const mocks = arrangeServicesMocks(); + const { controller, messenger } = arrangeMockMessenger(); + mockAuthBearerTokenCall(messenger); + + const promise = controller.updateTriggerPushNotifications(MOCK_ADDRESSES); + // Assert - loading + expect(controller.state.isUpdatingFCMToken).toBe(true); + + await promise; + + // Assert - update called with correct params + expect(mocks.activatePushNotificationsMock).toHaveBeenCalledWith({ + bearerToken: MOCK_JWT, + addresses: MOCK_ADDRESSES, + env: expect.any(Object), + createRegToken: expect.any(Function), + regToken: { + platform: 'extension', + locale: 'en', + oldToken: '', + }, + controllerEnv: 'prd', + }); + + // Assert - state + expect(controller.state.isPushEnabled).toBe(true); + expect(controller.state.fcmToken).toBe(MOCK_FCM_TOKEN); + expect(controller.state.isUpdatingFCMToken).toBe(false); + }); + + it('should bail early if push is not enabled', async () => { + const mocks = arrangeServicesMocks(); + const { controller, messenger } = arrangeMockMessenger({ + isPushFeatureEnabled: false, + }); + mockAuthBearerTokenCall(messenger); + + await controller.updateTriggerPushNotifications(MOCK_ADDRESSES); + expect(mocks.activatePushNotificationsMock).not.toHaveBeenCalled(); + expect(controller.state.isUpdatingFCMToken).toBe(false); + }); + + it('should throw error if fails to update trigger push notifications', async () => { + mockErrorLog(); + const mocks = arrangeServicesMocks(); + const { controller, messenger, initialState } = arrangeMockMessenger(); + mockAuthBearerTokenCall(messenger); + + // Arrange - service throws + mocks.activatePushNotificationsMock.mockRejectedValue( + new Error('TEST FAILURE'), + ); + + // Act / Assert Rejection + await expect(() => + controller.updateTriggerPushNotifications(MOCK_ADDRESSES), + ).rejects.toThrow(expect.any(Error)); + + // Assert state did not change + expect(controller.state).toStrictEqual(initialState); + expect(controller.state.isUpdatingFCMToken).toBe(false); + }); + + it('should pass existing fcmToken as oldToken when updating triggers', async () => { + const mocks = arrangeServicesMocks(); + const { controller, messenger } = arrangeMockMessenger({ + state: { + fcmToken: 'existing-fcm-token', + isPushEnabled: true, + isUpdatingFCMToken: false, + }, + }); + mockAuthBearerTokenCall(messenger); + + await controller.updateTriggerPushNotifications(MOCK_ADDRESSES); + + expect(mocks.activatePushNotificationsMock).toHaveBeenCalledWith({ + bearerToken: MOCK_JWT, + addresses: MOCK_ADDRESSES, + env: expect.any(Object), + createRegToken: expect.any(Function), + regToken: { + platform: 'extension', + locale: 'en', + oldToken: 'existing-fcm-token', + }, + controllerEnv: 'prd', + }); + }); + }); + + describe('deletePushNotificationLinks', () => { + afterEach(() => { + jest.clearAllMocks(); + }); + + it('should call deleteLinksAPI with addresses and platform', async () => { + const mocks = arrangeServicesMocks(); + const { controller, messenger } = arrangeMockMessenger({ + state: { + fcmToken: MOCK_FCM_TOKEN, + isPushEnabled: true, + isUpdatingFCMToken: false, + }, + }); + mockAuthBearerTokenCall(messenger); + + const result = + await controller.deletePushNotificationLinks(MOCK_ADDRESSES); + + expect(mocks.deleteLinksAPIMock).toHaveBeenCalledWith({ + bearerToken: MOCK_JWT, + addresses: MOCK_ADDRESSES, + platform: 'extension', + token: MOCK_FCM_TOKEN, + env: 'prd', + }); + expect(result).toBe(true); + }); + + it('should return false when push feature is disabled', async () => { + const mocks = arrangeServicesMocks(); + const { controller } = arrangeMockMessenger({ + isPushFeatureEnabled: false, + }); + + const result = + await controller.deletePushNotificationLinks(MOCK_ADDRESSES); + + expect(mocks.deleteLinksAPIMock).not.toHaveBeenCalled(); + expect(result).toBe(false); + }); + + it('should return false when there is no token to delete', async () => { + const mocks = arrangeServicesMocks(); + const { controller, messenger } = arrangeMockMessenger({ + state: { + fcmToken: '', + isPushEnabled: true, + isUpdatingFCMToken: false, + }, + }); + mockAuthBearerTokenCall(messenger); + + const result = + await controller.deletePushNotificationLinks(MOCK_ADDRESSES); + + expect(mocks.deleteLinksAPIMock).not.toHaveBeenCalled(); + expect(result).toBe(false); + }); + }); + + describe('addPushNotificationLinks', () => { + afterEach(() => { + jest.clearAllMocks(); + }); + + it('should call updateLinksAPI with addresses and the existing token', async () => { + const mocks = arrangeServicesMocks(); + const { controller, messenger } = arrangeMockMessenger({ + state: { + fcmToken: MOCK_FCM_TOKEN, + isPushEnabled: true, + isUpdatingFCMToken: false, + }, + }); + mockAuthBearerTokenCall(messenger); + + const result = await controller.addPushNotificationLinks(MOCK_ADDRESSES); + + expect(mocks.updateLinksAPIMock).toHaveBeenCalledWith({ + bearerToken: MOCK_JWT, + addresses: MOCK_ADDRESSES, + regToken: { + token: MOCK_FCM_TOKEN, + platform: 'extension', + locale: 'en', + }, + env: 'prd', + }); + expect(result).toBe(true); + }); + + it('should call updateLinksAPI with mobile OS and app version metadata', async () => { + const mocks = arrangeServicesMocks(); + const { controller, messenger } = arrangeMockMessenger({ + platform: 'mobile', + os: 'ios', + appVersion: '7.42.0', + state: { + fcmToken: MOCK_FCM_TOKEN, + isPushEnabled: true, + isUpdatingFCMToken: false, + }, + }); + mockAuthBearerTokenCall(messenger); + + const result = await controller.addPushNotificationLinks(MOCK_ADDRESSES); + + expect(mocks.updateLinksAPIMock).toHaveBeenCalledWith({ + bearerToken: MOCK_JWT, + addresses: MOCK_ADDRESSES, + regToken: { + token: MOCK_FCM_TOKEN, + platform: 'mobile', + locale: 'en', + os: 'ios', + appVersion: '7.42.0', + }, + env: 'prd', + }); + expect(result).toBe(true); + }); + + it('should return false when push feature is disabled', async () => { + const mocks = arrangeServicesMocks(); + const { controller } = arrangeMockMessenger({ + isPushFeatureEnabled: false, + }); + + const result = await controller.addPushNotificationLinks(MOCK_ADDRESSES); + + expect(mocks.updateLinksAPIMock).not.toHaveBeenCalled(); + expect(result).toBe(false); + }); + + it('should return false when there is no token to add', async () => { + const mocks = arrangeServicesMocks(); + const { controller, messenger } = arrangeMockMessenger({ + state: { + fcmToken: '', + isPushEnabled: true, + isUpdatingFCMToken: false, + }, + }); + mockAuthBearerTokenCall(messenger); + + const result = await controller.addPushNotificationLinks(MOCK_ADDRESSES); + + expect(mocks.updateLinksAPIMock).not.toHaveBeenCalled(); + expect(result).toBe(false); + }); + }); + + describe('metadata', () => { + it('includes expected state in debug snapshots', () => { + const { controller } = arrangeMockMessenger(); + + expect( + deriveStateFromMetadata( + controller.state, + controller.metadata, + 'includeInDebugSnapshot', + ), + ).toMatchInlineSnapshot(` + { + "fcmToken": "", + "isPushEnabled": true, + "isUpdatingFCMToken": false, + } + `); + }); + + it('includes expected state in state logs', () => { + const { controller } = arrangeMockMessenger(); + + expect( + deriveStateFromMetadata( + controller.state, + controller.metadata, + 'includeInStateLogs', + ), + ).toMatchInlineSnapshot(` + { + "isPushEnabled": true, + } + `); + }); + + it('persists expected state', () => { + const { controller } = arrangeMockMessenger(); + + expect( + deriveStateFromMetadata( + controller.state, + controller.metadata, + 'persist', + ), + ).toMatchInlineSnapshot(` + { + "fcmToken": "", + "isPushEnabled": true, + } + `); + }); + + it('includes expected state in UI', () => { + const { controller } = arrangeMockMessenger(); + + expect( + deriveStateFromMetadata( + controller.state, + controller.metadata, + 'usedInUi', + ), + ).toMatchInlineSnapshot(` + { + "fcmToken": "", + "isPushEnabled": true, + "isUpdatingFCMToken": false, + } + `); + }); + }); +}); + +/** + * Jest Mock Utility - mock messenger + * + * @param controllerConfig - provide a partial override controller config for testing + * @returns a mock messenger and other helpful mocks + */ +function arrangeMockMessenger( + controllerConfig?: Partial< + ControllerConfig & { + state?: Partial; + } + >, +): { + controller: NotificationServicesPushController; + initialState: NotificationServicesPushController['state']; + messenger: NotificationServicesPushControllerMessenger; +} { + const { state: stateOverride, ...configOverride } = controllerConfig ?? {}; + + const config: ControllerConfig = { + isPushFeatureEnabled: true, + pushService: { + createRegToken: jest.fn(), + deleteRegToken: jest.fn(), + subscribeToPushNotifications: jest.fn(), + }, + platform: 'extension', + ...configOverride, + }; + + const defaultState = { + fcmToken: '', + isPushEnabled: true, + isUpdatingFCMToken: false, + }; + const state = { ...defaultState, ...stateOverride }; + + const messenger = buildPushPlatformNotificationsControllerMessenger(); + const controller = new NotificationServicesPushController({ + messenger, + state, + env: {} as PushNotificationEnv, + config, + }); + + return { + controller, + initialState: controller.state, + messenger, + }; +} + +/** + * Jest Mock Utility - mock auth get bearer token + * + * @param messenger - mock messenger + * @returns mock getBearerAuth function + */ +function mockAuthBearerTokenCall( + messenger: NotificationServicesPushControllerMessenger, +): jest.Mock< + ReturnType< + AuthenticationController.AuthenticationControllerGetBearerTokenAction['handler'] + >, + Parameters< + AuthenticationController.AuthenticationControllerGetBearerTokenAction['handler'] + > +> { + type Fn = + AuthenticationController.AuthenticationControllerGetBearerTokenAction['handler']; + const mockAuthGetBearerToken = jest + .fn, Parameters>() + .mockResolvedValue(MOCK_JWT); + + jest.spyOn(messenger, 'call').mockImplementation((...args) => { + const [actionType] = args; + if (actionType === 'AuthenticationController:getBearerToken') { + return mockAuthGetBearerToken(); + } + + throw new Error('MOCK - unsupported messenger call mock'); + }); + + return mockAuthGetBearerToken; +} diff --git a/packages/notification-services-controller/src/NotificationServicesPushController/NotificationServicesPushController.ts b/packages/notification-services-controller/src/NotificationServicesPushController/NotificationServicesPushController.ts new file mode 100644 index 00000000000..077286e79c1 --- /dev/null +++ b/packages/notification-services-controller/src/NotificationServicesPushController/NotificationServicesPushController.ts @@ -0,0 +1,511 @@ +import type { + ControllerGetStateAction, + ControllerStateChangeEvent, + StateMetadata, +} from '@metamask/base-controller'; +import { BaseController } from '@metamask/base-controller'; +import type { Messenger } from '@metamask/messenger'; +import type { AuthenticationController } from '@metamask/profile-sync-controller'; +import log from 'loglevel'; + +import type { NotificationServicesPushControllerMethodActions } from './NotificationServicesPushController-method-action-types.js'; +import type { ENV } from './services/endpoints.js'; +import type { RegToken } from './services/services.js'; +import { + activatePushNotifications, + deleteLinksAPI, + deactivatePushNotifications, + updateLinksAPI, +} from './services/services.js'; +import type { + PushAnalyticsPayload, + PushNotificationEnv, +} from './types/index.js'; +import type { PushService } from './types/push-service-interface.js'; + +const controllerName = 'NotificationServicesPushController'; + +export type NotificationServicesPushControllerState = { + isPushEnabled: boolean; + fcmToken: string; + isUpdatingFCMToken: boolean; +}; + +const MESSENGER_EXPOSED_METHODS = [ + 'subscribeToPushNotifications', + 'enablePushNotifications', + 'addPushNotificationLinks', + 'disablePushNotifications', + 'updateTriggerPushNotifications', + 'deletePushNotificationLinks', +] as const; + +export type NotificationServicesPushControllerGetStateAction = + ControllerGetStateAction< + typeof controllerName, + NotificationServicesPushControllerState + >; + +export type Actions = + | NotificationServicesPushControllerGetStateAction + | NotificationServicesPushControllerMethodActions; + +type AllowedActions = + AuthenticationController.AuthenticationControllerGetBearerTokenAction; + +export type NotificationServicesPushControllerStateChangeEvent = + ControllerStateChangeEvent< + typeof controllerName, + NotificationServicesPushControllerState + >; + +export type NotificationServicesPushControllerOnNewNotificationEvent = { + type: `${typeof controllerName}:onNewNotifications`; + payload: [PushAnalyticsPayload]; +}; + +export type NotificationServicesPushControllerPushNotificationClickedEvent = { + type: `${typeof controllerName}:pushNotificationClicked`; + payload: [PushAnalyticsPayload]; +}; + +export type Events = + | NotificationServicesPushControllerStateChangeEvent + | NotificationServicesPushControllerOnNewNotificationEvent + | NotificationServicesPushControllerPushNotificationClickedEvent; + +export type NotificationServicesPushControllerMessenger = Messenger< + typeof controllerName, + Actions | AllowedActions, + Events +>; + +export const defaultState: NotificationServicesPushControllerState = { + isPushEnabled: true, + fcmToken: '', + isUpdatingFCMToken: false, +}; +const metadata: StateMetadata = { + isPushEnabled: { + includeInStateLogs: true, + persist: true, + includeInDebugSnapshot: true, + usedInUi: true, + }, + fcmToken: { + includeInStateLogs: false, + persist: true, + includeInDebugSnapshot: true, + usedInUi: true, + }, + isUpdatingFCMToken: { + includeInStateLogs: false, + persist: false, + includeInDebugSnapshot: true, + usedInUi: true, + }, +}; + +const defaultPushEnv: PushNotificationEnv = { + apiKey: '', + authDomain: '', + storageBucket: '', + projectId: '', + messagingSenderId: '', + appId: '', + measurementId: '', + vapidKey: '', +}; + +export type ControllerConfig = { + /** + * User locale for server push notifications + */ + getLocale?: () => string; + + /** + * App or extension version to include when registering push tokens. + */ + appVersion?: string; + + /** + * Global switch to determine to use push notifications + * Allows us to control Builds on extension (MV2 vs MV3) + */ + isPushFeatureEnabled?: boolean; + + /** + * determine the config used for push notification services + */ + platform: 'extension' | 'mobile'; + + /** + * Mobile operating system to include when registering push tokens. + */ + os?: 'android' | 'ios'; + + /** + * Push Service Interface + * - create reg token + * - delete reg token + * - subscribe to push notifications + */ + pushService: PushService; + + env?: ENV; +}; + +type StateCommand = + | { type: 'enable'; fcmToken: string } + | { type: 'disable' } + | { type: 'update'; fcmToken: string }; + +type RegistrationTokenMetadata = Pick< + RegToken, + 'appVersion' | 'locale' | 'os' | 'platform' +>; + +/** + * Manages push notifications for the application, including enabling, disabling, and updating triggers for push notifications. + * This controller integrates with Firebase Cloud Messaging (FCM) to handle the registration and management of push notifications. + * It is responsible for registering and unregistering the service worker that listens for push notifications, + * managing the FCM token, and communicating with the server to register or unregister the device for push notifications. + * Additionally, it provides functionality to update the server with new UUIDs that should trigger push notifications. + */ +export class NotificationServicesPushController extends BaseController< + typeof controllerName, + NotificationServicesPushControllerState, + NotificationServicesPushControllerMessenger +> { + #pushListenerUnsubscribe: (() => void) | undefined = undefined; + + readonly #env: PushNotificationEnv; + + readonly #config: ControllerConfig; + + constructor({ + messenger, + state, + env, + config, + }: { + messenger: NotificationServicesPushControllerMessenger; + state: NotificationServicesPushControllerState; + /** Push Environment is only required for extension */ + env?: PushNotificationEnv; + config: ControllerConfig; + }) { + super({ + messenger, + metadata, + name: controllerName, + state: { ...defaultState, ...state }, + }); + + this.#env = env ?? defaultPushEnv; + this.#config = config; + + this.messenger.registerMethodActionHandlers( + this, + MESSENGER_EXPOSED_METHODS, + ); + + this.#clearLoadingStates(); + } + + #clearLoadingStates(): void { + this.update((state) => { + state.isUpdatingFCMToken = false; + }); + } + + async #getAndAssertBearerToken(): Promise { + const bearerToken = await this.messenger.call( + 'AuthenticationController:getBearerToken', + ); + if (!bearerToken) { + throw new Error('BearerToken token is missing'); + } + + return bearerToken; + } + + #updatePushState(command: StateCommand): void { + if (command.type === 'enable') { + this.update((state) => { + state.isPushEnabled = true; + state.fcmToken = command.fcmToken; + state.isUpdatingFCMToken = false; + }); + } + + if (command.type === 'disable') { + this.update((state) => { + // Note we do not want to clear the old FCM token + // We can send it as an old token to our backend to cleanup next time turned on + state.isPushEnabled = false; + state.isUpdatingFCMToken = false; + }); + } + + if (command.type === 'update') { + this.update((state) => { + state.isPushEnabled = true; + state.fcmToken = command.fcmToken; + state.isUpdatingFCMToken = false; + }); + } + } + + #getRegistrationTokenMetadata(): RegistrationTokenMetadata { + const tokenMetadata: RegistrationTokenMetadata = { + platform: this.#config.platform, + locale: this.#config.getLocale?.() ?? 'en', + }; + + if (this.#config.os) { + tokenMetadata.os = this.#config.os; + } + + if (this.#config.appVersion) { + tokenMetadata.appVersion = this.#config.appVersion; + } + + return tokenMetadata; + } + + public async subscribeToPushNotifications(): Promise { + if (!this.#config.isPushFeatureEnabled) { + return; + } + + if (this.#pushListenerUnsubscribe) { + this.#pushListenerUnsubscribe(); + this.#pushListenerUnsubscribe = undefined; + } + + try { + this.#pushListenerUnsubscribe = + (await this.#config.pushService.subscribeToPushNotifications( + this.#env, + )) ?? undefined; + } catch { + // Do nothing, we are silently failing if push notification registration fails + } + } + + /** + * Enables push notifications for the application. + * + * This method sets up the necessary infrastructure for handling push notifications by: + * 1. Registering the service worker to listen for messages. + * 2. Fetching the Firebase Cloud Messaging (FCM) token from Firebase. + * 3. Sending the FCM token to the server responsible for sending notifications, to register the device. + * + * @param addresses - An array of addresses to enable push notifications for. + */ + public async enablePushNotifications(addresses: string[]): Promise { + if (!this.#config.isPushFeatureEnabled) { + return; + } + + this.update((state) => { + state.isUpdatingFCMToken = true; + }); + + // Handle creating new reg token (if available) + try { + const bearerToken = await this.#getAndAssertBearerToken().catch( + () => null, + ); + + // If there is a bearer token, lets try to refresh/create new reg token + if (bearerToken) { + // Activate Push Notifications + const fcmToken = await activatePushNotifications({ + bearerToken, + addresses, + env: this.#env, + createRegToken: this.#config.pushService.createRegToken, + regToken: { + ...this.#getRegistrationTokenMetadata(), + oldToken: this.state.fcmToken, + }, + controllerEnv: this.#config.env ?? 'prd', + }); + + if (fcmToken) { + this.#updatePushState({ type: 'enable', fcmToken }); + } + } + } catch { + // Do nothing, we are silently failing + } + + // New token created, (re)subscribe to push notifications + try { + await this.subscribeToPushNotifications(); + } catch { + // Do nothing we are silently failing + } + + this.update((state) => { + state.isUpdatingFCMToken = false; + }); + } + + /** + * Disables push notifications for the application. + * This removes the registration token on this device, and ensures we unsubscribe from any listeners + */ + public async disablePushNotifications(): Promise { + if (!this.#config.isPushFeatureEnabled) { + return; + } + + this.update((state) => { + state.isUpdatingFCMToken = true; + }); + + try { + // Send a request to the server to unregister the token/device + await deactivatePushNotifications({ + env: this.#env, + deleteRegToken: this.#config.pushService.deleteRegToken, + regToken: this.state.fcmToken, + }); + } catch (error) { + const errorMessage = `Failed to disable push notifications: ${ + error as string + }`; + log.error(errorMessage); + throw new Error(errorMessage); + } finally { + this.update((state) => { + state.isUpdatingFCMToken = false; + }); + } + + // Unsubscribe from push notifications + this.#pushListenerUnsubscribe?.(); + + // Update State + this.#updatePushState({ type: 'disable' }); + } + + /** + * Adds backend push notification links for the given addresses using the current FCM token. + * This is used when accounts are added after push notifications have already been enabled, + * so backend can link the existing device token to the newly added addresses. + * + * @param addresses - Addresses that should be linked to push notifications. + * @returns Whether the add request succeeded. + */ + public async addPushNotificationLinks(addresses: string[]): Promise { + if ( + !this.#config.isPushFeatureEnabled || + addresses.length === 0 || + !this.state.fcmToken + ) { + return false; + } + + try { + const bearerToken = await this.#getAndAssertBearerToken(); + return await updateLinksAPI({ + bearerToken, + addresses, + regToken: { + token: this.state.fcmToken, + ...this.#getRegistrationTokenMetadata(), + }, + env: this.#config.env ?? 'prd', + }); + } catch { + return false; + } + } + + /** + * Deletes backend push notification links for the given addresses on the current platform. + * This is used when accounts are removed (for example SRP removal), so backend can remove + * all associated FCM tokens for those address/platform pairs. + * + * @param addresses - Addresses that should be unlinked from push notifications. + * @returns Whether the delete request succeeded. + */ + public async deletePushNotificationLinks( + addresses: string[], + ): Promise { + if ( + !this.#config.isPushFeatureEnabled || + addresses.length === 0 || + !this.state.fcmToken + ) { + return false; + } + + try { + const bearerToken = await this.#getAndAssertBearerToken(); + return await deleteLinksAPI({ + bearerToken, + addresses, + platform: this.#config.platform, + token: this.state.fcmToken, + env: this.#config.env ?? 'prd', + }); + } catch { + return false; + } + } + + /** + * Updates the triggers for push notifications. + * This method is responsible for updating the server with the new set of addresses that should trigger push notifications. + * It uses the current FCM token and a BearerToken for authentication. + * + * @param addresses - An array of addresses that should trigger push notifications. + * @deprecated - this is not used anymore and will most likely be removed + */ + public async updateTriggerPushNotifications( + addresses: string[], + ): Promise { + if (!this.#config.isPushFeatureEnabled) { + return; + } + + this.update((state) => { + state.isUpdatingFCMToken = true; + }); + + try { + const bearerToken = await this.#getAndAssertBearerToken(); + const fcmToken = await activatePushNotifications({ + bearerToken, + addresses, + env: this.#env, + createRegToken: this.#config.pushService.createRegToken, + regToken: { + ...this.#getRegistrationTokenMetadata(), + oldToken: this.state.fcmToken, + }, + controllerEnv: this.#config.env ?? 'prd', + }); + + // update the state with the new FCM token + if (fcmToken) { + this.#updatePushState({ type: 'update', fcmToken }); + } + } catch (error) { + const errorMessage = `Failed to update triggers for push notifications: ${ + error as string + }`; + log.error(errorMessage); + throw new Error(errorMessage); + } finally { + this.update((state) => { + state.isUpdatingFCMToken = false; + }); + } + } +} diff --git a/packages/notification-services-controller/src/NotificationServicesPushController/__fixtures__/mockMessenger.ts b/packages/notification-services-controller/src/NotificationServicesPushController/__fixtures__/mockMessenger.ts new file mode 100644 index 00000000000..ac2187a41a5 --- /dev/null +++ b/packages/notification-services-controller/src/NotificationServicesPushController/__fixtures__/mockMessenger.ts @@ -0,0 +1,56 @@ +import { Messenger, MOCK_ANY_NAMESPACE } from '@metamask/messenger'; +import type { + MessengerActions, + MessengerEvents, + MockAnyNamespace, +} from '@metamask/messenger'; + +import type { NotificationServicesPushControllerMessenger } from '../index.js'; + +const controllerName = 'NotificationServicesPushController'; + +type AllNotificationServicesPushControllerActions = + MessengerActions; + +type AllNotificationServicesPushControllerEvents = + MessengerEvents; + +type RootMessenger = Messenger< + MockAnyNamespace, + AllNotificationServicesPushControllerActions, + AllNotificationServicesPushControllerEvents +>; + +/** + * Creates and returns a root messenger for testing + * + * @returns A messenger instance + */ +function getRootMessenger(): RootMessenger { + return new Messenger({ + namespace: MOCK_ANY_NAMESPACE, + }); +} + +export const buildPushPlatformNotificationsControllerMessenger = + (): NotificationServicesPushControllerMessenger => { + const rootMessenger = getRootMessenger(); + + const messenger = new Messenger< + typeof controllerName, + AllNotificationServicesPushControllerActions, + AllNotificationServicesPushControllerEvents, + RootMessenger + >({ + namespace: controllerName, + parent: rootMessenger, + }); + + rootMessenger.delegate({ + messenger, + actions: ['AuthenticationController:getBearerToken'], + events: [], + }); + + return messenger; + }; diff --git a/packages/notification-services-controller/src/NotificationServicesPushController/__fixtures__/mockServices.ts b/packages/notification-services-controller/src/NotificationServicesPushController/__fixtures__/mockServices.ts new file mode 100644 index 00000000000..2e448161946 --- /dev/null +++ b/packages/notification-services-controller/src/NotificationServicesPushController/__fixtures__/mockServices.ts @@ -0,0 +1,47 @@ +import nock from 'nock'; + +import { + getMockDeletePushNotificationLinksResponse, + getMockUpdatePushNotificationLinksResponse, +} from '../mocks/mockResponse.js'; + +type MockReply = { + status: nock.StatusCode; + body?: nock.Body; +}; + +export const mockEndpointUpdatePushNotificationLinks = ( + mockReply?: MockReply, + requestBody?: nock.RequestBodyMatcher, +): nock.Scope => { + const mockResponse = getMockUpdatePushNotificationLinksResponse(); + const reply = mockReply ?? { + status: 204, + body: mockResponse.response, + }; + + const endpoint = nock(mockResponse.url); + const mockEndpoint = + requestBody === undefined + ? endpoint.post('') + : endpoint.post('', requestBody); + + return mockEndpoint.reply(reply.status); +}; + +export const mockEndpointDeletePushNotificationLinks = ( + mockReply?: MockReply, + requestBody?: nock.RequestBodyMatcher, +): nock.Scope => { + const mockResponse = getMockDeletePushNotificationLinksResponse(); + const reply = mockReply ?? { + status: 204, + body: mockResponse.response, + }; + + const mockEndpoint = nock(mockResponse.url) + .delete('', requestBody) + .reply(reply.status); + + return mockEndpoint; +}; diff --git a/packages/notification-services-controller/src/NotificationServicesPushController/index.ts b/packages/notification-services-controller/src/NotificationServicesPushController/index.ts new file mode 100644 index 00000000000..381724b8d77 --- /dev/null +++ b/packages/notification-services-controller/src/NotificationServicesPushController/index.ts @@ -0,0 +1,20 @@ +import { NotificationServicesPushController } from './NotificationServicesPushController.js'; + +export { NotificationServicesPushController as Controller }; +export default NotificationServicesPushController; +export * from './NotificationServicesPushController.js'; +export type * as Types from './types/index.js'; +export type * from './types/index.js'; +export * as Utils from './utils/index.js'; +export * from './utils/index.js'; +export * as Mocks from './mocks/index.js'; + +export type { + NotificationServicesPushControllerSubscribeToPushNotificationsAction, + NotificationServicesPushControllerEnablePushNotificationsAction, + NotificationServicesPushControllerAddPushNotificationLinksAction, + NotificationServicesPushControllerDisablePushNotificationsAction, + NotificationServicesPushControllerUpdateTriggerPushNotificationsAction, + NotificationServicesPushControllerDeletePushNotificationLinksAction, + NotificationServicesPushControllerMethodActions, +} from './NotificationServicesPushController-method-action-types.js'; diff --git a/packages/notification-services-controller/src/NotificationServicesPushController/mocks/index.ts b/packages/notification-services-controller/src/NotificationServicesPushController/mocks/index.ts new file mode 100644 index 00000000000..090815e2482 --- /dev/null +++ b/packages/notification-services-controller/src/NotificationServicesPushController/mocks/index.ts @@ -0,0 +1 @@ +export * from './mockResponse.js'; diff --git a/packages/notification-services-controller/src/NotificationServicesPushController/mocks/mockResponse.ts b/packages/notification-services-controller/src/NotificationServicesPushController/mocks/mockResponse.ts new file mode 100644 index 00000000000..6f8f2011dbd --- /dev/null +++ b/packages/notification-services-controller/src/NotificationServicesPushController/mocks/mockResponse.ts @@ -0,0 +1,52 @@ +import { REGISTRATION_TOKENS_ENDPOINT } from '../services/endpoints.js'; + +type MockResponse = { + url: string | RegExp; + requestMethod: 'GET' | 'POST' | 'PUT' | 'DELETE'; + response: unknown; +}; + +export const MOCK_REG_TOKEN = 'REG_TOKEN'; + +export const getMockUpdatePushNotificationLinksResponse = (): MockResponse => { + return { + url: REGISTRATION_TOKENS_ENDPOINT(), + requestMethod: 'POST', + response: null, + } satisfies MockResponse; +}; + +export const getMockDeletePushNotificationLinksResponse = (): MockResponse => { + return { + url: REGISTRATION_TOKENS_ENDPOINT(), + requestMethod: 'DELETE', + response: null, + } satisfies MockResponse; +}; + +export const MOCK_FCM_RESPONSE = { + name: '', + token: 'fcm-token', + web: { + endpoint: '', + p256dh: '', + auth: '', + applicationPubKey: '', + }, +}; + +export const getMockCreateFCMRegistrationTokenResponse = (): MockResponse => { + return { + url: /^https:\/\/fcmregistrations\.googleapis\.com\/v1\/projects\/.*$/u, + requestMethod: 'POST', + response: MOCK_FCM_RESPONSE, + } satisfies MockResponse; +}; + +export const getMockDeleteFCMRegistrationTokenResponse = (): MockResponse => { + return { + url: /^https:\/\/fcmregistrations\.googleapis\.com\/v1\/projects\/.*$/u, + requestMethod: 'POST', + response: {}, + } satisfies MockResponse; +}; diff --git a/packages/notification-services-controller/src/NotificationServicesPushController/services/endpoints.ts b/packages/notification-services-controller/src/NotificationServicesPushController/services/endpoints.ts new file mode 100644 index 00000000000..d5d432c2720 --- /dev/null +++ b/packages/notification-services-controller/src/NotificationServicesPushController/services/endpoints.ts @@ -0,0 +1,13 @@ +export type ENV = 'prd' | 'uat' | 'dev'; + +const PUSH_API_ENV = { + dev: 'https://push.dev-api.cx.metamask.io', + uat: 'https://push.uat-api.cx.metamask.io', + prd: 'https://push.api.cx.metamask.io', +} satisfies Record; + +export const PUSH_API = (env: ENV = 'prd'): string => + PUSH_API_ENV[env] ?? PUSH_API_ENV.prd; + +export const REGISTRATION_TOKENS_ENDPOINT = (env: ENV = 'prd'): string => + `${PUSH_API(env)}/api/v2/token`; diff --git a/packages/notification-services-controller/src/NotificationServicesPushController/services/services.test.ts b/packages/notification-services-controller/src/NotificationServicesPushController/services/services.test.ts new file mode 100644 index 00000000000..efef3b2aaca --- /dev/null +++ b/packages/notification-services-controller/src/NotificationServicesPushController/services/services.test.ts @@ -0,0 +1,311 @@ +import log from 'loglevel'; + +import { + mockEndpointDeletePushNotificationLinks, + mockEndpointUpdatePushNotificationLinks, +} from '../__fixtures__/mockServices.js'; +import type { PushNotificationEnv } from '../types/firebase.js'; +import { + activatePushNotifications, + deactivatePushNotifications, + deleteLinksAPI, + updateLinksAPI, +} from './services.js'; +import type { RegToken } from './services.js'; + +// Testing util to clean up verbose logs when testing errors +const mockErrorLog = (): jest.SpyInstance => + jest.spyOn(log, 'error').mockImplementation(jest.fn()); + +const MOCK_REG_TOKEN = 'REG_TOKEN'; +const MOCK_NEW_REG_TOKEN = 'NEW_REG_TOKEN'; +const MOCK_ADDRESSES = ['0x123', '0x456', '0x789']; +const MOCK_JWT = 'MOCK_JWT'; + +type CreateRegTokenMock = jest.Mock< + Promise, + [PushNotificationEnv] +>; + +type ArrangeMocksParams = { + bearerToken: string; + addresses: string[]; + createRegToken: CreateRegTokenMock; + regToken: { + platform: Platform; + locale: string; + }; + env: PushNotificationEnv; +}; + +type ArrangeMocksResult = { + params: ArrangeMocksParams<'extension'>; + mobileParams: ArrangeMocksParams<'mobile'>; + apis: { + mockPut: ReturnType; + }; +}; + +describe('NotificationServicesPushController Services', () => { + describe('updateLinksAPI', () => { + const act = async ( + regTokenOverrides?: Partial, + ): Promise => + await updateLinksAPI({ + bearerToken: MOCK_JWT, + addresses: MOCK_ADDRESSES, + regToken: { + token: MOCK_NEW_REG_TOKEN, + platform: 'extension', + locale: 'en', + ...regTokenOverrides, + }, + }); + + it('should return true if links are successfully updated', async () => { + const mockAPI = mockEndpointUpdatePushNotificationLinks(); + const result = await act(); + expect(mockAPI.isDone()).toBe(true); + expect(result).toBe(true); + }); + + it('should return false if the links API update fails', async () => { + const mockAPI = mockEndpointUpdatePushNotificationLinks({ status: 500 }); + const result = await act(); + expect(mockAPI.isDone()).toBe(true); + expect(result).toBe(false); + }); + + it('should return false if an error is thrown', async () => { + jest + .spyOn(global, 'fetch') + .mockRejectedValue(new Error('MOCK FAIL FETCH')); + const result = await act(); + expect(result).toBe(false); + }); + + it('should include mobile metadata when provided', async () => { + const mockAPI = mockEndpointUpdatePushNotificationLinks(undefined, { + addresses: MOCK_ADDRESSES, + registration_token: { + token: MOCK_NEW_REG_TOKEN, + platform: 'mobile', + locale: 'en', + os: 'ios', + appVersion: '7.42.0', + }, + }); + + const result = await act({ + platform: 'mobile', + os: 'ios', + appVersion: '7.42.0', + }); + + expect(mockAPI.isDone()).toBe(true); + expect(result).toBe(true); + }); + }); + + describe('activatePushNotifications', () => { + const arrangeMocks = (override?: { + mockPut?: { status: number }; + requestBody?: Parameters< + typeof mockEndpointUpdatePushNotificationLinks + >[1]; + }): ArrangeMocksResult => { + const createRegToken: CreateRegTokenMock = jest + .fn, [PushNotificationEnv]>() + .mockResolvedValue(MOCK_NEW_REG_TOKEN); + const params = { + bearerToken: MOCK_JWT, + addresses: MOCK_ADDRESSES, + createRegToken, + regToken: { + platform: 'extension' as const, + locale: 'en', + }, + env: {} as PushNotificationEnv, + }; + + const mobileParams = { + ...params, + regToken: { + ...params.regToken, + platform: 'mobile' as const, + }, + }; + + return { + params, + mobileParams, + apis: { + mockPut: mockEndpointUpdatePushNotificationLinks( + override?.mockPut, + override?.requestBody, + ), + }, + }; + }; + + it('should successfully call APIs and add new registration token', async () => { + const { params, apis } = arrangeMocks(); + const result = await activatePushNotifications(params); + + expect(params.createRegToken).toHaveBeenCalled(); + expect(apis.mockPut.isDone()).toBe(true); + + expect(result).toBe(MOCK_NEW_REG_TOKEN); + }); + + it('should return null if unable to create new registration token', async () => { + const { params, apis } = arrangeMocks(); + params.createRegToken.mockRejectedValue(new Error('MOCK ERROR')); + + const result = await activatePushNotifications(params); + + expect(params.createRegToken).toHaveBeenCalled(); + expect(apis.mockPut.isDone()).toBe(false); + + expect(result).toBeNull(); + }); + + it('should handle oldToken parameter when provided', async () => { + const { params, apis } = arrangeMocks(); + const paramsWithOldToken = { + ...params, + regToken: { + ...params.regToken, + oldToken: 'OLD_TOKEN', + }, + }; + + const result = await activatePushNotifications(paramsWithOldToken); + + expect(params.createRegToken).toHaveBeenCalled(); + expect(apis.mockPut.isDone()).toBe(true); + expect(result).toBe(MOCK_NEW_REG_TOKEN); + }); + + it('should pass mobile metadata when provided', async () => { + const { mobileParams, apis } = arrangeMocks({ + requestBody: { + addresses: MOCK_ADDRESSES, + registration_token: { + token: MOCK_NEW_REG_TOKEN, + platform: 'mobile', + locale: 'en', + os: 'android', + appVersion: '7.42.0', + }, + }, + }); + const paramsWithMetadata = { + ...mobileParams, + regToken: { + ...mobileParams.regToken, + os: 'android' as const, + appVersion: '7.42.0', + }, + }; + + const result = await activatePushNotifications(paramsWithMetadata); + + expect(mobileParams.createRegToken).toHaveBeenCalled(); + expect(apis.mockPut.isDone()).toBe(true); + expect(result).toBe(MOCK_NEW_REG_TOKEN); + }); + }); + + describe('deleteLinksAPI', () => { + const act = async (): Promise => + await deleteLinksAPI({ + bearerToken: MOCK_JWT, + addresses: MOCK_ADDRESSES, + platform: 'extension', + token: MOCK_REG_TOKEN, + }); + + it('should return true if links are successfully deleted', async () => { + const mockAPI = mockEndpointDeletePushNotificationLinks(undefined, { + addresses: MOCK_ADDRESSES, + registration_token: { + platform: 'extension', + token: MOCK_REG_TOKEN, + }, + }); + const result = await act(); + expect(mockAPI.isDone()).toBe(true); + expect(result).toBe(true); + }); + + it('should return false if the links API delete fails', async () => { + const mockAPI = mockEndpointDeletePushNotificationLinks( + { status: 500 }, + { + addresses: MOCK_ADDRESSES, + registration_token: { + platform: 'extension', + token: MOCK_REG_TOKEN, + }, + }, + ); + const result = await act(); + expect(mockAPI.isDone()).toBe(true); + expect(result).toBe(false); + }); + + it('should return false if an error is thrown', async () => { + jest + .spyOn(global, 'fetch') + .mockRejectedValue(new Error('MOCK FAIL FETCH')); + const result = await act(); + expect(result).toBe(false); + }); + }); + + describe('deactivatePushNotifications', () => { + // Internal testing utility - return type is inferred + // eslint-disable-next-line @typescript-eslint/explicit-function-return-type + const arrangeMocks = () => { + const params = { + regToken: MOCK_REG_TOKEN, + deleteRegToken: jest.fn().mockResolvedValue(true), + env: {} as PushNotificationEnv, + }; + + return { + params, + }; + }; + + it('should successfully delete the registration token', async () => { + const { params } = arrangeMocks(); + const result = await deactivatePushNotifications(params); + + expect(params.deleteRegToken).toHaveBeenCalled(); + expect(result).toBe(true); + }); + + it('should return early when there is no registration token to delete', async () => { + const { params } = arrangeMocks(); + mockErrorLog(); + const result = await deactivatePushNotifications({ + ...params, + regToken: '', + }); + + expect(params.deleteRegToken).not.toHaveBeenCalled(); + expect(result).toBe(true); + }); + + it('should return false when unable to delete the existing reg token', async () => { + const { params } = arrangeMocks(); + params.deleteRegToken.mockResolvedValue(false); + const result = await deactivatePushNotifications(params); + + expect(params.deleteRegToken).toHaveBeenCalled(); + expect(result).toBe(false); + }); + }); +}); diff --git a/packages/notification-services-controller/src/NotificationServicesPushController/services/services.ts b/packages/notification-services-controller/src/NotificationServicesPushController/services/services.ts new file mode 100644 index 00000000000..a6eea864e63 --- /dev/null +++ b/packages/notification-services-controller/src/NotificationServicesPushController/services/services.ts @@ -0,0 +1,207 @@ +import type { PushNotificationEnv } from '../types/index.js'; +import type { + CreateRegToken, + DeleteRegToken, +} from '../types/push-service-interface.js'; +import type { ENV } from './endpoints.js'; +import * as endpoints from './endpoints.js'; + +export type RegToken = { + token: string; + platform: 'extension' | 'mobile' | 'portfolio'; + locale: string; + os?: 'android' | 'ios'; + appVersion?: string; + oldToken?: string; +}; + +export type RegistrationPlatform = 'extension' | 'mobile'; + +/** + * Links API Response Shape + */ +export type PushTokenRequest = { + addresses: string[]; + // API response uses snake_case for this property + // eslint-disable-next-line @typescript-eslint/naming-convention + registration_token: { + token: string; + platform: 'extension' | 'mobile' | 'portfolio'; + locale: string; + os?: 'android' | 'ios'; + appVersion?: string; + oldToken?: string; + }; +}; + +export type DeletePushTokenRequest = { + addresses: string[]; + // API request uses snake_case for this property + // eslint-disable-next-line @typescript-eslint/naming-convention + registration_token: { + platform: RegistrationPlatform; + token: string; + }; +}; + +type UpdatePushTokenParams = { + bearerToken: string; + addresses: string[]; + regToken: RegToken; + env?: ENV; +}; + +/** + * Updates the push notification links on a remote API. + * + * @param params - params for invoking update reg token + * @returns A promise that resolves with true if the update was successful, false otherwise. + */ +export async function updateLinksAPI( + params: UpdatePushTokenParams, +): Promise { + try { + const body: PushTokenRequest = { + addresses: params.addresses, + registration_token: params.regToken, + }; + const response = await fetch( + endpoints.REGISTRATION_TOKENS_ENDPOINT(params.env), + { + method: 'POST', + headers: { + Authorization: `Bearer ${params.bearerToken}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify(body), + }, + ); + return response.ok; + } catch { + return false; + } +} + +type DeletePushTokenParams = { + bearerToken: string; + addresses: string[]; + platform: RegistrationPlatform; + token: string; + env?: ENV; +}; + +/** + * Deletes push notification links for addresses and platform. + * + * @param params - params for deleting registration links + * @returns A promise that resolves with true if the delete request was successful, false otherwise. + */ +export async function deleteLinksAPI( + params: DeletePushTokenParams, +): Promise { + try { + const body: DeletePushTokenRequest = { + addresses: params.addresses, + registration_token: { + platform: params.platform, + token: params.token, + }, + }; + const response = await fetch( + endpoints.REGISTRATION_TOKENS_ENDPOINT(params.env), + { + method: 'DELETE', + headers: { + Authorization: `Bearer ${params.bearerToken}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify(body), + }, + ); + return response.ok; + } catch { + return false; + } +} + +type ActivatePushNotificationsParams = { + // Create Push Token + env: PushNotificationEnv; + createRegToken: CreateRegToken; + controllerEnv?: ENV; + + // Other Request Parameters + bearerToken: string; + addresses: string[]; + regToken: Pick< + RegToken, + 'appVersion' | 'locale' | 'oldToken' | 'os' | 'platform' + >; +}; + +/** + * Enables push notifications by registering the device and linking triggers. + * + * @param params - Activate Push Params + * @returns A promise that resolves with an object containing the success status and the BearerToken token. + */ +export async function activatePushNotifications( + params: ActivatePushNotificationsParams, +): Promise { + const { env, createRegToken } = params; + + const regToken = await createRegToken(env).catch(() => null); + if (!regToken) { + return null; + } + + await updateLinksAPI({ + bearerToken: params.bearerToken, + addresses: params.addresses, + regToken: { + token: regToken, + platform: params.regToken.platform, + locale: params.regToken.locale, + os: params.regToken.os, + appVersion: params.regToken.appVersion, + oldToken: params.regToken.oldToken, + }, + env: params.controllerEnv, + }); + + return regToken; +} + +type DeactivatePushNotificationsParams = { + // Push Links + regToken: string; + + // Push Un-registration + env: PushNotificationEnv; + deleteRegToken: DeleteRegToken; +}; + +/** + * Disables push notifications by removing the registration token + * We do not need to unlink triggers, and remove old reg tokens (this is cleaned up in the back-end) + * + * @param params - Deactivate Push Params + * @returns A promise that resolves with true if push notifications were successfully disabled, false otherwise. + */ +export async function deactivatePushNotifications( + params: DeactivatePushNotificationsParams, +): Promise { + const { regToken, env, deleteRegToken } = params; + + // if we don't have a reg token, then we can early return + if (!regToken) { + return true; + } + + const isTokenRemovedFromFCM = await deleteRegToken(env); + if (!isTokenRemovedFromFCM) { + return false; + } + + return true; +} diff --git a/packages/notification-services-controller/src/NotificationServicesPushController/types/firebase.ts b/packages/notification-services-controller/src/NotificationServicesPushController/types/firebase.ts new file mode 100644 index 00000000000..e861dcec8fa --- /dev/null +++ b/packages/notification-services-controller/src/NotificationServicesPushController/types/firebase.ts @@ -0,0 +1,57 @@ +export type PushNotificationEnv = { + apiKey: string; + authDomain: string; + storageBucket: string; + projectId: string; + messagingSenderId: string; + appId: string; + measurementId: string; + vapidKey: string; +}; + +export type Messaging = { + app: FirebaseApp; +}; + +export type FirebaseApp = { + readonly name: string; + readonly options: FirebaseOptions; + automaticDataCollectionEnabled: boolean; +}; + +export type FirebaseOptions = { + apiKey?: string; + authDomain?: string; + databaseURL?: string; + projectId?: string; + storageBucket?: string; + messagingSenderId?: string; + appId?: string; + measurementId?: string; +}; + +export type NotificationPayload = { + title?: string; + body?: string; + image?: string; + icon?: string; +}; + +export type FcmOptions = { + link?: string; + analyticsLabel?: string; +}; + +export type MessagePayload = { + notification?: NotificationPayload; + data?: { [key: string]: string }; + fcmOptions?: FcmOptions; + from: string; + collapseKey: string; + messageId: string; +}; + +export type GetTokenOptions = { + vapidKey?: string; + serviceWorkerRegistration?: ServiceWorkerRegistration; +}; diff --git a/packages/notification-services-controller/src/NotificationServicesPushController/types/index.ts b/packages/notification-services-controller/src/NotificationServicesPushController/types/index.ts new file mode 100644 index 00000000000..4eb9bbf1b5a --- /dev/null +++ b/packages/notification-services-controller/src/NotificationServicesPushController/types/index.ts @@ -0,0 +1,3 @@ +export type * from './firebase.js'; +export type * from './push-analytics.js'; +export type * from './push-service-interface.js'; diff --git a/packages/notification-services-controller/src/NotificationServicesPushController/types/push-analytics.ts b/packages/notification-services-controller/src/NotificationServicesPushController/types/push-analytics.ts new file mode 100644 index 00000000000..1e3190876d0 --- /dev/null +++ b/packages/notification-services-controller/src/NotificationServicesPushController/types/push-analytics.ts @@ -0,0 +1,20 @@ +// snake_case mirrors the FCM payload and Segment schema keys +/* eslint-disable @typescript-eslint/naming-convention */ + +/** + * Analytics fields carried by the `NotificationServicesPushController` messenger + * events (`onNewNotifications`, `pushNotificationClicked`). Read directly from + * top-level FCM payload keys, so clients build Segment events without fallback + * chains or parsing a `metadata` blob. + */ +export type PushAnalyticsPayload = { + notification_id: string; + /** Free-form snake_case label set by the producer. */ + notification_type: string; + /** Team-owned, open-ended (e.g. `eth_received`). */ + notification_subtype: string; + /** Only present when the notification has a chain context. */ + chain_id?: number; + /** Platform notifications only; the CTA link to route to on tap. */ + deeplink?: string; +}; diff --git a/packages/notification-services-controller/src/NotificationServicesPushController/types/push-service-interface.ts b/packages/notification-services-controller/src/NotificationServicesPushController/types/push-service-interface.ts new file mode 100644 index 00000000000..cc1d669ccd8 --- /dev/null +++ b/packages/notification-services-controller/src/NotificationServicesPushController/types/push-service-interface.ts @@ -0,0 +1,39 @@ +import type { PushNotificationEnv } from './index.js'; + +type Unsubscribe = () => void; + +/** + * Firebase - allows creating of a registration token for push notifications + */ +export type CreateRegToken = ( + env: PushNotificationEnv, +) => Promise; + +/** + * Firebase - allows deleting a reg token. Returns true if successful, otherwise false if failed + */ +export type DeleteRegToken = (env: PushNotificationEnv) => Promise; + +/** + * Firebase + Platform Specific Logic. + * Will be used to subscribe to the `onMessage` and `onBackgroundMessage` handlers + * But will also need client specific logic for showing a notification and clicking a notification + * (browser APIs for web, and Notifee on mobile) + * + * We can either create "creator"/"builder" function in platform specific files (see push-web.ts), + * Or the platform needs to correctly handle: + * - subscriptions + * - click events + * - publishing PushController events using it's messenger + */ +export type SubscribeToPushNotifications = ( + env: PushNotificationEnv, +) => Promise; + +export type PushService = { + createRegToken: CreateRegToken; + + deleteRegToken: DeleteRegToken; + + subscribeToPushNotifications: SubscribeToPushNotifications; +}; diff --git a/packages/notification-services-controller/src/NotificationServicesPushController/utils/get-notification-data.test.ts b/packages/notification-services-controller/src/NotificationServicesPushController/utils/get-notification-data.test.ts new file mode 100644 index 00000000000..c6659584bfc --- /dev/null +++ b/packages/notification-services-controller/src/NotificationServicesPushController/utils/get-notification-data.test.ts @@ -0,0 +1,80 @@ +import { + formatAmount, + getAmount, + getLeadingZeroCount, +} from './get-notification-data.js'; + +describe('getNotificationData - formatAmount() tests', () => { + it('should format large numbers', () => { + expect(formatAmount(1000)).toBe('1K'); + expect(formatAmount(1500)).toBe('1.5K'); + expect(formatAmount(1000000)).toBe('1M'); + expect(formatAmount(1000000000)).toBe('1B'); + expect(formatAmount(1000000000000)).toBe('1T'); + expect(formatAmount(1234567)).toBe('1.23M'); + }); + + it('should format smaller numbers (<1000) with custom decimal place', () => { + const formatOptions = { decimalPlaces: 18 }; + expect(formatAmount(100.0012, formatOptions)).toBe('100.0012'); + expect(formatAmount(100.001200001, formatOptions)).toBe('100.001200001'); + expect(formatAmount(1e-18, formatOptions)).toBe('0.000000000000000001'); + expect(formatAmount(1e-19, formatOptions)).toBe('0'); // number is smaller than decimals given, hence 0 + }); + + it('should format small numbers (<1000) up to 4 decimals otherwise uses ellipses', () => { + const formatOptions = { shouldEllipse: true }; + expect(formatAmount(100.1, formatOptions)).toBe('100.1'); + expect(formatAmount(100.01, formatOptions)).toBe('100.01'); + expect(formatAmount(100.001, formatOptions)).toBe('100.001'); + expect(formatAmount(100.0001, formatOptions)).toBe('100.0001'); + expect(formatAmount(100.00001, formatOptions)).toBe('100.0000...'); // since number is has >4 decimals, it will be truncated + expect(formatAmount(0.00001, formatOptions)).toBe('0.0000...'); // since number is has >4 decimals, it will be truncated + }); + + it('should format small numbers (<1000) to custom decimal places and ellipse', () => { + const formatOptions = { decimalPlaces: 2, shouldEllipse: true }; + expect(formatAmount(100.1, formatOptions)).toBe('100.1'); + expect(formatAmount(100.01, formatOptions)).toBe('100.01'); + expect(formatAmount(100.001, formatOptions)).toBe('100.00...'); + expect(formatAmount(100.0001, formatOptions)).toBe('100.00...'); + expect(formatAmount(100.00001, formatOptions)).toBe('100.00...'); // since number is has >2 decimals, it will be truncated + expect(formatAmount(0.00001, formatOptions)).toBe('0.00...'); // since number is has >2 decimals, it will be truncated + }); +}); + +describe('getNotificationData - getAmount() tests', () => { + it('should get formatted amount for larger numbers', () => { + expect(getAmount('1', '2')).toBe('0.01'); + expect(getAmount('10', '2')).toBe('0.1'); + expect(getAmount('100', '2')).toBe('1'); + expect(getAmount('1000', '2')).toBe('10'); + expect(getAmount('10000', '2')).toBe('100'); + expect(getAmount('100000', '2')).toBe('1K'); + expect(getAmount('1000000', '2')).toBe('10K'); + }); + it('should get formatted amount for small/decimal numbers', () => { + const formatOptions = { shouldEllipse: true }; + expect(getAmount('100000', '5', formatOptions)).toBe('1'); + expect(getAmount('100001', '5', formatOptions)).toBe('1.0000...'); + expect(getAmount('10000', '5', formatOptions)).toBe('0.1'); + expect(getAmount('1000', '5', formatOptions)).toBe('0.01'); + expect(getAmount('100', '5', formatOptions)).toBe('0.001'); + expect(getAmount('10', '5', formatOptions)).toBe('0.0001'); + expect(getAmount('1', '5', formatOptions)).toBe('0.0000...'); + }); +}); + +describe('getNotificationData - getLeadingZeroCount() tests', () => { + it('should handle all test cases', () => { + expect(getLeadingZeroCount(0)).toBe(0); + expect(getLeadingZeroCount(-1)).toBe(0); + expect(getLeadingZeroCount(1e-1)).toBe(0); + + expect(getLeadingZeroCount('1.01')).toBe(1); + expect(getLeadingZeroCount('3e-2')).toBe(1); + expect(getLeadingZeroCount('100.001e1')).toBe(1); + + expect(getLeadingZeroCount('0.00120043')).toBe(2); + }); +}); diff --git a/packages/notification-services-controller/src/NotificationServicesPushController/utils/get-notification-data.ts b/packages/notification-services-controller/src/NotificationServicesPushController/utils/get-notification-data.ts new file mode 100644 index 00000000000..4bf160c2991 --- /dev/null +++ b/packages/notification-services-controller/src/NotificationServicesPushController/utils/get-notification-data.ts @@ -0,0 +1,104 @@ +import { BigNumber } from 'bignumber.js'; + +type FormatOptions = { + decimalPlaces?: number; + shouldEllipse?: boolean; +}; +const defaultFormatOptions = { + decimalPlaces: 4, +}; + +/** + * Calculates the token amount based on the given value and decimals. + * + * @param value - The value to calculate the token amount from. + * @param decimals - The number of decimals to use for the calculation. + * @returns The calculated token amount. + */ +export function calcTokenAmount(value: string, decimals: number): BigNumber { + const multiplier = Math.pow(10, Number(decimals || 0)); + return new BigNumber(String(value)).div(multiplier); +} + +/** + * Calculates the number of leading zeros in the fractional part of a number. + * + * This function converts a number or a string representation of a number into + * its decimal form and then counts the number of leading zeros present in the + * fractional part of the number. This is useful for determining the precision + * of very small numbers. + * + * @param numericValue - The number to analyze, which can be in the form + * of a number or a string. + * @returns The count of leading zeros in the fractional part of the number. + */ +export const getLeadingZeroCount = (numericValue: number | string): number => { + const numToString = new BigNumber(numericValue, 10).toString(10); + const fractionalPart = numToString.split('.')[1] ?? ''; + return fractionalPart.match(/^0*/u)?.[0]?.length ?? 0; +}; + +/** + * This formats a number using Intl + * It abbreviates large numbers (using K, M, B, T) + * And abbreviates small numbers in 2 ways: + * - Will format to the given number of decimal places + * - Will format up to 4 decimal places + * - Will ellipse the number if longer than given decimal places + * + * @param numericAmount - The number to format + * @param opts - The options to use when formatting + * @returns The formatted number + */ +export const formatAmount = ( + numericAmount: number, + opts?: FormatOptions, +): string => { + // create options with defaults + const options = { ...defaultFormatOptions, ...opts }; + + const leadingZeros = getLeadingZeroCount(numericAmount); + const isDecimal = numericAmount.toString().includes('.') || leadingZeros > 0; + const isLargeNumber = numericAmount > 999; + + const handleShouldEllipse = (decimalPlaces: number): boolean => + Boolean(options?.shouldEllipse) && leadingZeros >= decimalPlaces; + + if (isLargeNumber) { + return Intl.NumberFormat('en-US', { + notation: 'compact', + compactDisplay: 'short', + maximumFractionDigits: 2, + }).format(numericAmount); + } + + if (isDecimal) { + const ellipse = handleShouldEllipse(options.decimalPlaces); + const formattedValue = Intl.NumberFormat('en-US', { + minimumFractionDigits: ellipse ? options.decimalPlaces : undefined, + maximumFractionDigits: options.decimalPlaces, + }).format(numericAmount); + + return ellipse ? `${formattedValue}...` : formattedValue; + } + + // Default to showing the raw amount + return numericAmount.toString(); +}; + +export const getAmount = ( + amount: string, + decimals: string, + options?: FormatOptions, +): string => { + if (!amount || !decimals) { + return ''; + } + + const numericAmount = calcTokenAmount( + amount, + parseFloat(decimals), + ).toNumber(); + + return formatAmount(numericAmount, options); +}; diff --git a/packages/notification-services-controller/src/NotificationServicesPushController/utils/get-notification-message.test.ts b/packages/notification-services-controller/src/NotificationServicesPushController/utils/get-notification-message.test.ts new file mode 100644 index 00000000000..fcefa9ca37d --- /dev/null +++ b/packages/notification-services-controller/src/NotificationServicesPushController/utils/get-notification-message.test.ts @@ -0,0 +1,271 @@ +import { Processors } from '../../NotificationServicesController/index.js'; +import { + createMockNotificationERC1155Received, + createMockNotificationERC1155Sent, + createMockNotificationERC20Received, + createMockNotificationERC20Sent, + createMockNotificationERC721Received, + createMockNotificationERC721Sent, + createMockNotificationEthReceived, + createMockNotificationEthSent, + createMockNotificationLidoReadyToBeWithdrawn, + createMockNotificationLidoStakeCompleted, + createMockNotificationLidoWithdrawalCompleted, + createMockNotificationLidoWithdrawalRequested, + createMockNotificationMetaMaskSwapsCompleted, + createMockNotificationRocketPoolStakeCompleted, + createMockNotificationRocketPoolUnStakeCompleted, +} from '../../NotificationServicesController/mocks/index.js'; +import type { TranslationKeys } from './get-notification-message.js'; +import { createOnChainPushNotificationMessage } from './get-notification-message.js'; + +const mockTranslations: TranslationKeys = { + pushPlatformNotificationsFundsSentTitle: () => 'Funds sent', + pushPlatformNotificationsFundsSentDescriptionDefault: () => + 'You successfully sent some tokens', + pushPlatformNotificationsFundsSentDescription: (amount, token) => + `You successfully sent ${amount} ${token}`, + pushPlatformNotificationsFundsReceivedTitle: () => 'Funds received', + pushPlatformNotificationsFundsReceivedDescriptionDefault: () => + 'You received some tokens', + pushPlatformNotificationsFundsReceivedDescription: (amount, token) => + `You received ${amount} ${token}`, + pushPlatformNotificationsSwapCompletedTitle: () => 'Swap completed', + pushPlatformNotificationsSwapCompletedDescription: () => + 'Your MetaMask Swap was successful', + pushPlatformNotificationsNftSentTitle: () => 'NFT sent', + pushPlatformNotificationsNftSentDescription: () => + 'You have successfully sent an NFT', + pushPlatformNotificationsNftReceivedTitle: () => 'NFT received', + pushPlatformNotificationsNftReceivedDescription: () => + 'You received new NFTs', + pushPlatformNotificationsStakingRocketpoolStakeCompletedTitle: () => + 'Stake complete', + pushPlatformNotificationsStakingRocketpoolStakeCompletedDescription: () => + 'Your RocketPool stake was successful', + pushPlatformNotificationsStakingRocketpoolUnstakeCompletedTitle: () => + 'Unstake complete', + pushPlatformNotificationsStakingRocketpoolUnstakeCompletedDescription: () => + 'Your RocketPool unstake was successful', + pushPlatformNotificationsStakingLidoStakeCompletedTitle: () => + 'Stake complete', + pushPlatformNotificationsStakingLidoStakeCompletedDescription: () => + 'Your Lido stake was successful', + pushPlatformNotificationsStakingLidoStakeReadyToBeWithdrawnTitle: () => + 'Stake ready for withdrawal', + pushPlatformNotificationsStakingLidoStakeReadyToBeWithdrawnDescription: () => + 'Your Lido stake is now ready to be withdrawn', + pushPlatformNotificationsStakingLidoWithdrawalRequestedTitle: () => + 'Withdrawal requested', + pushPlatformNotificationsStakingLidoWithdrawalRequestedDescription: () => + 'Your Lido withdrawal request was submitted', + pushPlatformNotificationsStakingLidoWithdrawalCompletedTitle: () => + 'Withdrawal completed', + pushPlatformNotificationsStakingLidoWithdrawalCompletedDescription: () => + 'Your Lido withdrawal was successful', +}; + +const { processNotification } = Processors; + +describe('notification-message tests', () => { + it('displays erc20 sent notification', () => { + const notification = processNotification(createMockNotificationERC20Sent()); + const result = createOnChainPushNotificationMessage( + notification, + mockTranslations, + ); + + expect(result?.title).toBe('Funds sent'); + expect(result?.description).toContain('You successfully sent 4.96K USDC'); + }); + + it('displays erc20 received notification', () => { + const notification = processNotification( + createMockNotificationERC20Received(), + ); + const result = createOnChainPushNotificationMessage( + notification, + mockTranslations, + ); + + expect(result?.title).toBe('Funds received'); + expect(result?.description).toContain('You received 8.38B SHIB'); + }); + + it('displays eth/native sent notification', () => { + const notification = processNotification(createMockNotificationEthSent()); + const result = createOnChainPushNotificationMessage( + notification, + mockTranslations, + ); + + expect(result?.title).toBe('Funds sent'); + expect(result?.description).toContain('You successfully sent 0.005 ETH'); + }); + + it('displays eth/native received notification', () => { + const notification = processNotification( + createMockNotificationEthReceived(), + ); + const result = createOnChainPushNotificationMessage( + notification, + mockTranslations, + ); + + expect(result?.title).toBe('Funds received'); + expect(result?.description).toContain('You received 808 ETH'); + }); + + it('displays metamask swap completed notification', () => { + const notification = processNotification( + createMockNotificationMetaMaskSwapsCompleted(), + ); + const result = createOnChainPushNotificationMessage( + notification, + mockTranslations, + ); + + expect(result?.title).toBe('Swap completed'); + expect(result?.description).toContain('Your MetaMask Swap was successful'); + }); + + it('displays erc721 sent notification', () => { + const notification = processNotification( + createMockNotificationERC721Sent(), + ); + const result = createOnChainPushNotificationMessage( + notification, + mockTranslations, + ); + + expect(result?.title).toBe('NFT sent'); + expect(result?.description).toContain('You have successfully sent an NFT'); + }); + + it('displays erc721 received notification', () => { + const notification = processNotification( + createMockNotificationERC721Received(), + ); + const result = createOnChainPushNotificationMessage( + notification, + mockTranslations, + ); + + expect(result?.title).toBe('NFT received'); + expect(result?.description).toContain('You received new NFTs'); + }); + + it('displays erc1155 sent notification', () => { + const notification = processNotification( + createMockNotificationERC1155Sent(), + ); + const result = createOnChainPushNotificationMessage( + notification, + mockTranslations, + ); + + expect(result?.title).toBe('NFT sent'); + expect(result?.description).toContain('You have successfully sent an NFT'); + }); + + it('displays erc1155 received notification', () => { + const notification = processNotification( + createMockNotificationERC1155Received(), + ); + const result = createOnChainPushNotificationMessage( + notification, + mockTranslations, + ); + + expect(result?.title).toBe('NFT received'); + expect(result?.description).toContain('You received new NFTs'); + }); + + it('displays rocketpool stake completed notification', () => { + const notification = processNotification( + createMockNotificationRocketPoolStakeCompleted(), + ); + const result = createOnChainPushNotificationMessage( + notification, + mockTranslations, + ); + + expect(result?.title).toBe('Stake complete'); + expect(result?.description).toContain( + 'Your RocketPool stake was successful', + ); + }); + + it('displays rocketpool unstake completed notification', () => { + const notification = processNotification( + createMockNotificationRocketPoolUnStakeCompleted(), + ); + const result = createOnChainPushNotificationMessage( + notification, + mockTranslations, + ); + + expect(result?.title).toBe('Unstake complete'); + expect(result?.description).toContain( + 'Your RocketPool unstake was successful', + ); + }); + + it('displays lido stake completed notification', () => { + const notification = processNotification( + createMockNotificationLidoStakeCompleted(), + ); + const result = createOnChainPushNotificationMessage( + notification, + mockTranslations, + ); + + expect(result?.title).toBe('Stake complete'); + expect(result?.description).toContain('Your Lido stake was successful'); + }); + + it('displays lido stake ready to be withdrawn notification', () => { + const notification = processNotification( + createMockNotificationLidoReadyToBeWithdrawn(), + ); + const result = createOnChainPushNotificationMessage( + notification, + mockTranslations, + ); + + expect(result?.title).toBe('Stake ready for withdrawal'); + expect(result?.description).toContain( + 'Your Lido stake is now ready to be withdrawn', + ); + }); + + it('displays lido withdrawal requested notification', () => { + const notification = processNotification( + createMockNotificationLidoWithdrawalRequested(), + ); + const result = createOnChainPushNotificationMessage( + notification, + mockTranslations, + ); + + expect(result?.title).toBe('Withdrawal requested'); + expect(result?.description).toContain( + 'Your Lido withdrawal request was submitted', + ); + }); + + it('displays lido withdrawal completed notification', () => { + const notification = processNotification( + createMockNotificationLidoWithdrawalCompleted(), + ); + const result = createOnChainPushNotificationMessage( + notification, + mockTranslations, + ); + + expect(result?.title).toBe('Withdrawal completed'); + expect(result?.description).toContain( + 'Your Lido withdrawal was successful', + ); + }); +}); diff --git a/packages/notification-services-controller/src/NotificationServicesPushController/utils/get-notification-message.ts b/packages/notification-services-controller/src/NotificationServicesPushController/utils/get-notification-message.ts new file mode 100644 index 00000000000..5a2f347303d --- /dev/null +++ b/packages/notification-services-controller/src/NotificationServicesPushController/utils/get-notification-message.ts @@ -0,0 +1,306 @@ +import type { Types } from '../../NotificationServicesController/index.js'; +import type { Constants } from '../../NotificationServicesController/index.js'; +import { getAmount, formatAmount } from './get-notification-data.js'; + +export type TranslationKeys = { + pushPlatformNotificationsFundsSentTitle: () => string; + pushPlatformNotificationsFundsSentDescriptionDefault: () => string; + pushPlatformNotificationsFundsSentDescription: ( + ...args: [string, string] + ) => string; + pushPlatformNotificationsFundsReceivedTitle: () => string; + pushPlatformNotificationsFundsReceivedDescriptionDefault: () => string; + pushPlatformNotificationsFundsReceivedDescription: ( + ...args: [string, string] + ) => string; + pushPlatformNotificationsSwapCompletedTitle: () => string; + pushPlatformNotificationsSwapCompletedDescription: () => string; + pushPlatformNotificationsNftSentTitle: () => string; + pushPlatformNotificationsNftSentDescription: () => string; + pushPlatformNotificationsNftReceivedTitle: () => string; + pushPlatformNotificationsNftReceivedDescription: () => string; + pushPlatformNotificationsStakingRocketpoolStakeCompletedTitle: () => string; + pushPlatformNotificationsStakingRocketpoolStakeCompletedDescription: () => string; + pushPlatformNotificationsStakingRocketpoolUnstakeCompletedTitle: () => string; + pushPlatformNotificationsStakingRocketpoolUnstakeCompletedDescription: () => string; + pushPlatformNotificationsStakingLidoStakeCompletedTitle: () => string; + pushPlatformNotificationsStakingLidoStakeCompletedDescription: () => string; + pushPlatformNotificationsStakingLidoStakeReadyToBeWithdrawnTitle: () => string; + pushPlatformNotificationsStakingLidoStakeReadyToBeWithdrawnDescription: () => string; + pushPlatformNotificationsStakingLidoWithdrawalRequestedTitle: () => string; + pushPlatformNotificationsStakingLidoWithdrawalRequestedDescription: () => string; + pushPlatformNotificationsStakingLidoWithdrawalCompletedTitle: () => string; + pushPlatformNotificationsStakingLidoWithdrawalCompletedDescription: () => string; +}; + +type PushNotificationMessage = { + title: string; + description: string; + ctaLink?: string; +}; + +type NotificationMessage = { + title: (notification: TNotification) => string | null; + defaultDescription: (notification: TNotification) => string | null; + getDescription?: (notification: TNotification) => string | null; + link?: (notification: TNotification) => string | null; +}; + +type NotificationMessageDict = { + [TriggerType in Constants.TRIGGER_TYPES]?: NotificationMessage< + Extract + >; +}; + +/** + * On Chain Push Notification Messages. + * This is a list of all the push notifications we support. Update this for synced notifications on mobile and extension + * + * @param translationKeys - all translations supported + * @returns A translation push message object. + */ +export const createOnChainPushNotificationMessages = ( + translationKeys: TranslationKeys, +): NotificationMessageDict => { + type TranslationFn = ( + ...args: [TKey, ...Parameters] + ) => string; + const translate: TranslationFn = (...args) => { + const [key, ...otherArgs] = args; + + // Coerce types for the translation function + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const fn: any = translationKeys[key]; + return fn(...otherArgs); + }; + + return { + erc20_sent: { + title: (): string | null => + translate('pushPlatformNotificationsFundsSentTitle'), + defaultDescription: (): string | null => + translate('pushPlatformNotificationsFundsSentDescriptionDefault'), + getDescription: (notification): string | null => { + const symbol = notification?.payload?.data?.token?.symbol; + const tokenAmount = notification?.payload?.data?.token?.amount; + const tokenDecimals = notification?.payload?.data?.token?.decimals; + if (!symbol || !tokenAmount || !tokenDecimals) { + return null; + } + + const amount = getAmount(tokenAmount, tokenDecimals, { + shouldEllipse: true, + }); + return translate( + 'pushPlatformNotificationsFundsSentDescription', + amount, + symbol, + ); + }, + }, + eth_sent: { + title: (): string | null => + translate('pushPlatformNotificationsFundsSentTitle'), + defaultDescription: (): string | null => + translate('pushPlatformNotificationsFundsSentDescriptionDefault'), + getDescription: (notification): string | null => { + const symbol = notification?.payload?.network?.native_symbol; + const tokenAmount = notification?.payload?.data?.amount?.eth; + if (!symbol || !tokenAmount) { + return null; + } + + const amount = formatAmount(parseFloat(tokenAmount), { + shouldEllipse: true, + }); + return translate( + 'pushPlatformNotificationsFundsSentDescription', + amount, + symbol, + ); + }, + }, + erc20_received: { + title: (): string | null => + translate('pushPlatformNotificationsFundsReceivedTitle'), + defaultDescription: (): string | null => + translate('pushPlatformNotificationsFundsReceivedDescriptionDefault'), + getDescription: (notification): string | null => { + const symbol = notification?.payload?.data?.token?.symbol; + const tokenAmount = notification?.payload?.data?.token?.amount; + const tokenDecimals = notification?.payload?.data?.token?.decimals; + if (!symbol || !tokenAmount || !tokenDecimals) { + return null; + } + + const amount = getAmount(tokenAmount, tokenDecimals, { + shouldEllipse: true, + }); + return translate( + 'pushPlatformNotificationsFundsReceivedDescription', + amount, + symbol, + ); + }, + }, + eth_received: { + title: (): string | null => + translate('pushPlatformNotificationsFundsReceivedTitle'), + defaultDescription: (): string | null => + translate('pushPlatformNotificationsFundsReceivedDescriptionDefault'), + getDescription: (notification): string | null => { + const symbol = notification?.payload?.network?.native_symbol; + const tokenAmount = notification?.payload?.data?.amount?.eth; + if (!symbol || !tokenAmount) { + return null; + } + + const amount = formatAmount(parseFloat(tokenAmount), { + shouldEllipse: true, + }); + return translate( + 'pushPlatformNotificationsFundsReceivedDescription', + amount, + symbol, + ); + }, + }, + metamask_swap_completed: { + title: (): string | null => + translate('pushPlatformNotificationsSwapCompletedTitle'), + defaultDescription: (): string | null => + translate('pushPlatformNotificationsSwapCompletedDescription'), + }, + erc721_sent: { + title: (): string | null => + translate('pushPlatformNotificationsNftSentTitle'), + defaultDescription: (): string | null => + translate('pushPlatformNotificationsNftSentDescription'), + }, + erc1155_sent: { + title: (): string | null => + translate('pushPlatformNotificationsNftSentTitle'), + defaultDescription: (): string | null => + translate('pushPlatformNotificationsNftSentDescription'), + }, + erc721_received: { + title: (): string | null => + translate('pushPlatformNotificationsNftReceivedTitle'), + defaultDescription: (): string | null => + translate('pushPlatformNotificationsNftReceivedDescription'), + }, + erc1155_received: { + title: (): string | null => + translate('pushPlatformNotificationsNftReceivedTitle'), + defaultDescription: (): string | null => + translate('pushPlatformNotificationsNftReceivedDescription'), + }, + rocketpool_stake_completed: { + title: (): string | null => + translate( + 'pushPlatformNotificationsStakingRocketpoolStakeCompletedTitle', + ), + defaultDescription: (): string | null => + translate( + 'pushPlatformNotificationsStakingRocketpoolStakeCompletedDescription', + ), + }, + rocketpool_unstake_completed: { + title: (): string | null => + translate( + 'pushPlatformNotificationsStakingRocketpoolUnstakeCompletedTitle', + ), + defaultDescription: (): string | null => + translate( + 'pushPlatformNotificationsStakingRocketpoolUnstakeCompletedDescription', + ), + }, + lido_stake_completed: { + title: (): string | null => + translate('pushPlatformNotificationsStakingLidoStakeCompletedTitle'), + defaultDescription: (): string | null => + translate( + 'pushPlatformNotificationsStakingLidoStakeCompletedDescription', + ), + }, + lido_stake_ready_to_be_withdrawn: { + title: (): string | null => + translate( + 'pushPlatformNotificationsStakingLidoStakeReadyToBeWithdrawnTitle', + ), + defaultDescription: (): string | null => + translate( + 'pushPlatformNotificationsStakingLidoStakeReadyToBeWithdrawnDescription', + ), + }, + lido_withdrawal_requested: { + title: (): string | null => + translate( + 'pushPlatformNotificationsStakingLidoWithdrawalRequestedTitle', + ), + defaultDescription: (): string | null => + translate( + 'pushPlatformNotificationsStakingLidoWithdrawalRequestedDescription', + ), + }, + lido_withdrawal_completed: { + title: (): string | null => + translate( + 'pushPlatformNotificationsStakingLidoWithdrawalCompletedTitle', + ), + defaultDescription: (): string | null => + translate( + 'pushPlatformNotificationsStakingLidoWithdrawalCompletedDescription', + ), + }, + platform: { + title: (notification): string | null => notification.template.title, + defaultDescription: (notification): string | null => + notification.template.body, + getDescription: (notification): string | null => + notification.template.body, + }, + }; +}; + +/** + * Creates a push notification message based on the given on-chain raw notification. + * + * @param notification - processed notification. + * @param translations - translates keys into text + * @returns The push notification message object, or null if the notification is invalid. + */ +export function createOnChainPushNotificationMessage( + notification: Types.INotification, + translations: TranslationKeys, +): PushNotificationMessage | null { + if (!notification?.type) { + return null; + } + const notificationMessage = + createOnChainPushNotificationMessages(translations)[notification.type]; + + if (!notificationMessage) { + return null; + } + + let description: string | null = null; + try { + description = + // eslint-disable-next-line @typescript-eslint/no-explicit-any + notificationMessage?.getDescription?.(notification as any) ?? + // eslint-disable-next-line @typescript-eslint/no-explicit-any + notificationMessage.defaultDescription?.(notification as any) ?? + null; + } catch { + description = + // eslint-disable-next-line @typescript-eslint/no-explicit-any + notificationMessage.defaultDescription?.(notification as any) ?? null; + } + + return { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + title: notificationMessage?.title?.(notification as any) ?? '', // Ensure title is always a string + description: description ?? '', // Fallback to empty string if null + }; +} diff --git a/packages/notification-services-controller/src/NotificationServicesPushController/utils/index.ts b/packages/notification-services-controller/src/NotificationServicesPushController/utils/index.ts new file mode 100644 index 00000000000..d2b55816814 --- /dev/null +++ b/packages/notification-services-controller/src/NotificationServicesPushController/utils/index.ts @@ -0,0 +1,3 @@ +export * from './get-notification-data.js'; +export * from './get-notification-message.js'; +export * from './to-push-analytics-payload.js'; diff --git a/packages/notification-services-controller/src/NotificationServicesPushController/utils/to-push-analytics-payload.test.ts b/packages/notification-services-controller/src/NotificationServicesPushController/utils/to-push-analytics-payload.test.ts new file mode 100644 index 00000000000..0eb0fed1f54 --- /dev/null +++ b/packages/notification-services-controller/src/NotificationServicesPushController/utils/to-push-analytics-payload.test.ts @@ -0,0 +1,52 @@ +import type { PushAnalyticsPayload } from '../types/index.js'; +import { toPushAnalyticsPayload } from './to-push-analytics-payload.js'; + +const mockFcmData = { + notification_id: 'test-notification-id', + notification_type: 'wallet_activity', + notification_subtype: 'eth_received', + chain_id: '1', + deeplink: 'https://example.com/deeplink', +}; + +const expectedAnalyticsPayload: PushAnalyticsPayload = { + notification_id: 'test-notification-id', + notification_type: 'wallet_activity', + notification_subtype: 'eth_received', + chain_id: 1, + deeplink: 'https://example.com/deeplink', +}; + +describe('toPushAnalyticsPayload() tests', () => { + it('should build the analytics payload from FCM data', () => { + expect(toPushAnalyticsPayload(mockFcmData)).toStrictEqual( + expectedAnalyticsPayload, + ); + }); + + it('should default notification_subtype to an empty string when absent', () => { + const { notification_subtype: _, ...dataWithoutSubtype } = mockFcmData; + + expect(toPushAnalyticsPayload(dataWithoutSubtype)).toStrictEqual({ + ...expectedAnalyticsPayload, + notification_subtype: '', + }); + }); + + it.each([ + undefined, + null, + 'not an object', + { notification_id: 'test-id' }, + { notification_type: 'wallet_activity' }, + ] as const)( + 'should return null for invalid FCM data payload - %p', + (data) => { + expect( + toPushAnalyticsPayload( + data as unknown as Record | undefined, + ), + ).toBeNull(); + }, + ); +}); diff --git a/packages/notification-services-controller/src/NotificationServicesPushController/utils/to-push-analytics-payload.ts b/packages/notification-services-controller/src/NotificationServicesPushController/utils/to-push-analytics-payload.ts new file mode 100644 index 00000000000..9d84958fd91 --- /dev/null +++ b/packages/notification-services-controller/src/NotificationServicesPushController/utils/to-push-analytics-payload.ts @@ -0,0 +1,26 @@ +import type { PushAnalyticsPayload } from '../types/index.js'; + +/** + * Builds the first-class push analytics payload from the top-level FCM `data` + * keys written by push-services. Returns `null` when the required identity + * fields are missing (e.g. a malformed or legacy payload), so callers can + * safely bail out. + * + * @param data - the top-level FCM `data` map (all values are strings). + * @returns the analytics payload, or `null` if required fields are absent. + */ +export function toPushAnalyticsPayload( + data: Record | undefined, +): PushAnalyticsPayload | null { + if (!data?.notification_id || !data?.notification_type) { + return null; + } + + return { + notification_id: data.notification_id, + notification_type: data.notification_type, + notification_subtype: data.notification_subtype ?? '', + chain_id: data.chain_id ? Number(data.chain_id) : undefined, + deeplink: data.deeplink || undefined, + }; +} diff --git a/packages/notification-services-controller/src/NotificationServicesPushController/web/index.ts b/packages/notification-services-controller/src/NotificationServicesPushController/web/index.ts new file mode 100644 index 00000000000..041670781d9 --- /dev/null +++ b/packages/notification-services-controller/src/NotificationServicesPushController/web/index.ts @@ -0,0 +1,5 @@ +export { + createRegToken, + deleteRegToken, + createSubscribeToPushNotifications, +} from './push-utils.js'; diff --git a/packages/notification-services-controller/src/NotificationServicesPushController/web/push-utils.test.ts b/packages/notification-services-controller/src/NotificationServicesPushController/web/push-utils.test.ts new file mode 100644 index 00000000000..552f0566a7d --- /dev/null +++ b/packages/notification-services-controller/src/NotificationServicesPushController/web/push-utils.test.ts @@ -0,0 +1,424 @@ +import * as FirebaseAppModule from 'firebase/app'; +import * as FirebaseMessagingModule from 'firebase/messaging'; +import * as FirebaseMessagingSWModule from 'firebase/messaging/sw'; + +import { buildPushPlatformNotificationsControllerMessenger } from '../__fixtures__/mockMessenger.js'; +import type { PushAnalyticsPayload } from '../types/index.js'; +import { + createRegToken, + deleteRegToken, + createSubscribeToPushNotifications, +} from './push-utils.js'; +import * as PushWebModule from './push-utils.js'; + +jest.mock('firebase/app'); +jest.mock('firebase/messaging'); +jest.mock('firebase/messaging/sw'); + +const mockEnv = { + apiKey: 'test-apiKey', + authDomain: 'test-authDomain', + storageBucket: 'test-storageBucket', + projectId: 'test-projectId', + messagingSenderId: 'test-messagingSenderId', + appId: 'test-appId', + measurementId: 'test-measurementId', + vapidKey: 'test-vapidKey', +}; + +const mockFcmData = { + notification_id: 'test-notification-id', + notification_type: 'wallet_activity', + notification_subtype: 'eth_received', + profile_id: 'test-profile-id', + chain_id: '1', + deeplink: 'https://example.com/deeplink', +}; + +const expectedAnalyticsPayload: PushAnalyticsPayload = { + notification_id: 'test-notification-id', + notification_type: 'wallet_activity', + notification_subtype: 'eth_received', + chain_id: 1, + deeplink: 'https://example.com/deeplink', +}; + +const firebaseApp: FirebaseAppModule.FirebaseApp = { + name: '', + automaticDataCollectionEnabled: false, + options: mockEnv, +}; + +function arrangeFirebaseAppMocks(): { + mockGetApp: jest.SpyInstance; + mockInitializeApp: jest.SpyInstance; +} { + const mockGetApp = jest + .spyOn(FirebaseAppModule, 'getApp') + .mockReturnValue(firebaseApp); + + const mockInitializeApp = jest + .spyOn(FirebaseAppModule, 'initializeApp') + .mockReturnValue(firebaseApp); + + return { mockGetApp, mockInitializeApp }; +} + +function arrangeFirebaseMessagingSWMocks(): { + mockIsSupported: jest.SpyInstance; + mockGetMessaging: jest.SpyInstance; + mockOnBackgroundMessage: jest.SpyInstance; + mockOnBackgroundMessageUnsub: jest.Mock; +} { + const mockIsSupported = jest + .spyOn(FirebaseMessagingSWModule, 'isSupported') + .mockResolvedValue(true); + + const mockGetMessaging = jest + .spyOn(FirebaseMessagingSWModule, 'getMessaging') + .mockReturnValue({ app: firebaseApp }); + + const mockOnBackgroundMessageUnsub = jest.fn(); + const mockOnBackgroundMessage = jest + .spyOn(FirebaseMessagingSWModule, 'onBackgroundMessage') + .mockReturnValue(mockOnBackgroundMessageUnsub); + + return { + mockIsSupported, + mockGetMessaging, + mockOnBackgroundMessage, + mockOnBackgroundMessageUnsub, + }; +} + +function arrangeFirebaseMessagingMocks(): { + mockGetToken: jest.SpyInstance; + mockDeleteToken: jest.SpyInstance; +} { + const mockGetToken = jest + .spyOn(FirebaseMessagingModule, 'getToken') + .mockResolvedValue('test-token'); + + const mockDeleteToken = jest + .spyOn(FirebaseMessagingModule, 'deleteToken') + .mockResolvedValue(true); + + return { mockGetToken, mockDeleteToken }; +} + +describe('createRegToken() tests', () => { + const TEST_TOKEN = 'test-token'; + + function arrange(): ReturnType & + ReturnType & + ReturnType { + const firebaseMocks = { + ...arrangeFirebaseAppMocks(), + ...arrangeFirebaseMessagingSWMocks(), + ...arrangeFirebaseMessagingMocks(), + }; + + firebaseMocks.mockGetToken.mockResolvedValue(TEST_TOKEN); + + return { + ...firebaseMocks, + }; + } + + afterEach(() => { + jest.clearAllMocks(); + + // TODO - replace with jest.replaceProperty once we upgrade jest. + Object.defineProperty(PushWebModule, 'supportedCache', { value: null }); + }); + + it('should return a registration token when Firebase is supported', async () => { + const { mockGetApp, mockGetToken } = arrange(); + + const token = await createRegToken(mockEnv); + + expect(mockGetApp).toHaveBeenCalled(); + expect(mockGetToken).toHaveBeenCalled(); + expect(token).toBe(TEST_TOKEN); + }); + + it('should return null when Firebase is not supported', async () => { + const { mockIsSupported } = arrange(); + mockIsSupported.mockResolvedValueOnce(false); + + const token = await createRegToken(mockEnv); + + expect(token).toBeNull(); + }); + + it('should return null if an error occurs', async () => { + const { mockGetToken } = arrange(); + mockGetToken.mockRejectedValueOnce(new Error('Error getting token')); + + const token = await createRegToken(mockEnv); + + expect(token).toBeNull(); + }); + + it('should initialize firebase if has not been created yet', async () => { + const { mockGetApp, mockInitializeApp, mockGetToken } = arrange(); + mockGetApp.mockImplementation(() => { + throw new Error('mock Firebase GetApp failure'); + }); + + const token = await createRegToken(mockEnv); + + expect(mockGetApp).toHaveBeenCalled(); + expect(mockInitializeApp).toHaveBeenCalled(); + expect(mockGetToken).toHaveBeenCalled(); + expect(token).toBe(TEST_TOKEN); + }); +}); + +describe('deleteRegToken() tests', () => { + function arrange(): ReturnType & + ReturnType & + ReturnType { + return { + ...arrangeFirebaseAppMocks(), + ...arrangeFirebaseMessagingSWMocks(), + ...arrangeFirebaseMessagingMocks(), + }; + } + + afterEach(() => { + jest.clearAllMocks(); + + // TODO - replace with jest.replaceProperty once we upgrade jest. + Object.defineProperty(PushWebModule, 'supportedCache', { value: null }); + }); + + it('should return true when the token is successfully deleted', async () => { + const { mockGetApp, mockDeleteToken } = arrange(); + + const result = await deleteRegToken(mockEnv); + + expect(mockGetApp).toHaveBeenCalled(); + expect(mockDeleteToken).toHaveBeenCalled(); + expect(result).toBe(true); + }); + + it('should return true when Firebase is not supported', async () => { + const { mockIsSupported, mockDeleteToken } = arrange(); + mockIsSupported.mockResolvedValueOnce(false); + + const result = await deleteRegToken(mockEnv); + + expect(result).toBe(true); + expect(mockDeleteToken).not.toHaveBeenCalled(); + }); + + it('should return false if an error occurs', async () => { + const { mockDeleteToken } = arrange(); + mockDeleteToken.mockRejectedValueOnce(new Error('Error deleting token')); + + const result = await deleteRegToken(mockEnv); + + expect(result).toBe(false); + }); +}); + +describe('createSubscribeToPushNotifications() tests', () => { + function arrangeMessengerMocks(): { + messenger: ReturnType< + typeof buildPushPlatformNotificationsControllerMessenger + >; + onNewNotificationsListener: jest.Mock; + pushNotificationClickedListener: jest.Mock; + } { + const messenger = buildPushPlatformNotificationsControllerMessenger(); + + const onNewNotificationsListener = jest.fn(); + messenger.subscribe( + 'NotificationServicesPushController:onNewNotifications', + onNewNotificationsListener, + ); + + const pushNotificationClickedListener = jest.fn(); + messenger.subscribe( + 'NotificationServicesPushController:pushNotificationClicked', + pushNotificationClickedListener, + ); + + return { + messenger, + onNewNotificationsListener, + pushNotificationClickedListener, + }; + } + + function arrangeClickListenerMocks(): { + mockAddEventListener: jest.SpyInstance; + mockRemoveEventListener: jest.SpyInstance; + } { + // Testing service worker functionality requires using the 'self' global + // eslint-disable-next-line no-restricted-globals + const mockAddEventListener = jest.spyOn(self, 'addEventListener'); + // eslint-disable-next-line no-restricted-globals + const mockRemoveEventListener = jest.spyOn(self, 'removeEventListener'); + + return { + mockAddEventListener, + mockRemoveEventListener, + }; + } + + function arrange(): ReturnType & + ReturnType & + ReturnType & + ReturnType & { + mockOnReceivedHandler: jest.Mock; + mockOnClickHandler: jest.Mock; + } { + const firebaseMocks = { + ...arrangeFirebaseAppMocks(), + ...arrangeFirebaseMessagingSWMocks(), + }; + + return { + ...firebaseMocks, + ...arrangeMessengerMocks(), + ...arrangeClickListenerMocks(), + mockOnReceivedHandler: jest.fn(), + mockOnClickHandler: jest.fn(), + }; + } + + async function actCreateSubscription( + mocks: ReturnType, + ): Promise<() => void> { + const unsubscribe = await createSubscribeToPushNotifications({ + messenger: mocks.messenger, + onReceivedHandler: mocks.mockOnReceivedHandler, + onClickHandler: mocks.mockOnClickHandler, + })(mockEnv); + + return unsubscribe; + } + + afterEach(() => { + jest.clearAllMocks(); + + // TODO - replace with jest.replaceProperty once we upgrade jest. + Object.defineProperty(PushWebModule, 'supportedCache', { value: null }); + }); + + it('should initialize subscriptions', async () => { + const mocks = arrange(); + + await actCreateSubscription(mocks); + + // Assert - Firebase Calls + expect(mocks.mockGetApp).toHaveBeenCalled(); + expect(mocks.mockGetMessaging).toHaveBeenCalled(); + expect(mocks.mockOnBackgroundMessage).toHaveBeenCalled(); + + // Assert - Click Listener Created + expect(mocks.mockAddEventListener).toHaveBeenCalled(); + }); + + it('should destroy subscriptions', async () => { + const mocks = arrange(); + + const unsubscribe = await actCreateSubscription(mocks); + + // Assert - subscriptions not destroyed + expect(mocks.mockOnBackgroundMessageUnsub).not.toHaveBeenCalled(); + expect(mocks.mockRemoveEventListener).not.toHaveBeenCalled(); + + // Act - Unsubscribe + unsubscribe(); + + // Assert - subscriptions destroyed + expect(mocks.mockOnBackgroundMessageUnsub).toHaveBeenCalled(); + expect(mocks.mockRemoveEventListener).toHaveBeenCalled(); + }); + + async function arrangeActNotificationReceived( + testData: unknown, + ): Promise> { + const mocks = arrange(); + await actCreateSubscription(mocks); + + const firebaseCallback = mocks.mockOnBackgroundMessage.mock + .lastCall[1] as FirebaseMessagingModule.NextFn; + const payload = { + data: testData, + } as unknown as FirebaseMessagingSWModule.MessagePayload; + + firebaseCallback(payload); + + return mocks; + } + + it('should invoke handler with the parsed analytics payload when notifications are received', async () => { + const mocks = await arrangeActNotificationReceived(mockFcmData); + + // Assert New Notification Event & Handler Calls carry the analytics payload + expect(mocks.onNewNotificationsListener).toHaveBeenCalledWith( + expectedAnalyticsPayload, + ); + expect(mocks.mockOnReceivedHandler).toHaveBeenCalledWith( + expectedAnalyticsPayload, + ); + + // Assert Click Notification Event & Handler Calls + expect(mocks.pushNotificationClickedListener).not.toHaveBeenCalled(); + expect(mocks.mockOnClickHandler).not.toHaveBeenCalled(); + }); + + const invalidNotificationDataPayloadsTests = [ + { data: undefined }, + { data: null }, + { data: 'not an object' }, + // Missing the required `notification_type` field. + { data: { notification_id: 'test-id' } }, + // Missing the required `notification_id` field. + { data: { notification_type: 'wallet_activity' } }, + ]; + + it.each(invalidNotificationDataPayloadsTests)( + 'should fail to invoke handler if provided invalid push notification data payload - data $data', + async ({ data }) => { + const mocks = await arrangeActNotificationReceived(data); + expect(mocks.mockOnReceivedHandler).not.toHaveBeenCalled(); + }, + ); + + it('should invoke handler when notifications are clicked', async () => { + const mocks = arrange(); + // We do not want to mock this, as we will dispatch the notification click event + mocks.mockAddEventListener.mockRestore(); + + await actCreateSubscription(mocks); + + const mockNotificationEvent = new Event( + 'notificationclick', + ) as NotificationEvent; + Object.assign(mockNotificationEvent, { + notification: { data: expectedAnalyticsPayload }, + }); + + // Act - Testing service worker notification click event + // eslint-disable-next-line no-restricted-globals + self.dispatchEvent(mockNotificationEvent); + + // Assert Click Notification Event & Handler Calls carry the analytics payload + expect(mocks.pushNotificationClickedListener).toHaveBeenCalledWith( + expectedAnalyticsPayload, + ); + expect(mocks.mockOnClickHandler).toHaveBeenCalledWith( + expect.any(Event), + expectedAnalyticsPayload, + ); + + // Assert New Notification Event & Handler Calls + expect(mocks.onNewNotificationsListener).not.toHaveBeenCalled(); + expect(mocks.mockOnReceivedHandler).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/notification-services-controller/src/NotificationServicesPushController/web/push-utils.ts b/packages/notification-services-controller/src/NotificationServicesPushController/web/push-utils.ts new file mode 100644 index 00000000000..3724aebe11f --- /dev/null +++ b/packages/notification-services-controller/src/NotificationServicesPushController/web/push-utils.ts @@ -0,0 +1,220 @@ +// We are defining that this file uses a webworker global scope. +// eslint-disable-next-line spaced-comment +/// +import type { FirebaseApp } from 'firebase/app'; +import { getApp, initializeApp } from 'firebase/app'; +import { getToken, deleteToken } from 'firebase/messaging'; +import { + getMessaging, + onBackgroundMessage, + isSupported, +} from 'firebase/messaging/sw'; +import type { Messaging, MessagePayload } from 'firebase/messaging/sw'; +import log from 'loglevel'; + +import type { NotificationServicesPushControllerMessenger } from '../NotificationServicesPushController.js'; +import type { PushNotificationEnv } from '../types/firebase.js'; +import type { PushAnalyticsPayload } from '../types/index.js'; +import { toPushAnalyticsPayload } from '../utils/to-push-analytics-payload.js'; + +declare const self: ServiceWorkerGlobalScope; + +// Exported to help testing +// eslint-disable-next-line import-x/no-mutable-exports +export let supportedCache: boolean | null = null; + +const getPushAvailability = async (): Promise => { + // Race condition is acceptable here - worst case is isSupported() is called + // multiple times during initialization, which is harmless for caching a boolean + // eslint-disable-next-line require-atomic-updates + supportedCache ??= await isSupported(); + return supportedCache; +}; + +const createFirebaseApp = async ( + env: PushNotificationEnv, +): Promise => { + try { + return getApp(); + } catch { + const firebaseConfig = { + apiKey: env.apiKey, + authDomain: env.authDomain, + storageBucket: env.storageBucket, + projectId: env.projectId, + messagingSenderId: env.messagingSenderId, + appId: env.appId, + measurementId: env.measurementId, + }; + return initializeApp(firebaseConfig); + } +}; + +const getFirebaseMessaging = async ( + env: PushNotificationEnv, +): Promise => { + const supported = await getPushAvailability(); + if (!supported) { + return null; + } + + const app = await createFirebaseApp(env); + return getMessaging(app); +}; + +/** + * Creates a registration token for Firebase Cloud Messaging. + * + * @param env - env to configure push notifications + * @returns A promise that resolves with the registration token or null if an error occurs. + */ +export async function createRegToken( + env: PushNotificationEnv, +): Promise { + try { + const messaging = await getFirebaseMessaging(env); + if (!messaging) { + return null; + } + + const token = await getToken(messaging, { + serviceWorkerRegistration: self.registration, + vapidKey: env.vapidKey, + }); + return token; + } catch { + return null; + } +} + +/** + * Deletes the Firebase Cloud Messaging registration token. + * + * @param env - env to configure push notifications + * @returns A promise that resolves with true if the token was successfully deleted, false otherwise. + */ +export async function deleteRegToken( + env: PushNotificationEnv, +): Promise { + try { + const messaging = await getFirebaseMessaging(env); + if (!messaging) { + return true; + } + + await deleteToken(messaging); + return true; + } catch { + return false; + } +} + +/** + * Service Worker Listener for when push notifications are received. + * + * @param env - push notification environment + * @param handler - handler to actually showing notification, MUST BE PROVIDED + * @returns unsubscribe handler + */ +async function listenToPushNotificationsReceived( + env: PushNotificationEnv, + handler?: (payload: PushAnalyticsPayload) => void | Promise, +): Promise<(() => void) | null> { + const messaging = await getFirebaseMessaging(env); + if (!messaging) { + return null; + } + + const unsubscribePushNotifications = onBackgroundMessage( + messaging, + // eslint-disable-next-line @typescript-eslint/no-misused-promises + async (payload: MessagePayload): Promise => { + try { + const analyticsPayload = toPushAnalyticsPayload(payload?.data); + + if (!analyticsPayload) { + return; + } + + await handler?.(analyticsPayload); + } catch (error) { + // Do Nothing, cannot handle a bad notification + log.error('Unable to handle push notification:', { + notification: payload?.data, + error, + }); + } + }, + ); + + const unsubscribe = (): void => unsubscribePushNotifications(); + return unsubscribe; +} + +/** + * Service Worker Listener for when a notification is clicked + * + * @param handler - listen to NotificationEvent from the service worker + * @returns unsubscribe handler + */ +function listenToPushNotificationsClicked( + handler: (e: NotificationEvent, payload: PushAnalyticsPayload) => void, +): () => void { + const clickHandler = (event: NotificationEvent): void => { + // Get Data + const data: PushAnalyticsPayload = event?.notification?.data; + handler(event, data); + }; + + self.addEventListener('notificationclick', clickHandler); + const unsubscribe = (): void => + self.removeEventListener('notificationclick', clickHandler); + return unsubscribe; +} + +/** + * A creator function that assists creating web-specific push notification subscription: + * 1. Creates subscriptions for receiving and clicking notifications + * 2. Creates click events when a notification is clicked + * 3. Publishes controller messenger events + * + * @param props - props for this creator function. + * @param props.onReceivedHandler - allows the developer to handle showing a notification + * @param props.onClickHandler - allows the developer to handle clicking the notification + * @param props.messenger - the controller messenger to publish the `onNewNotifications` and `pushNotificationsClicked` events + * @returns a function that can be used by the controller + */ +export function createSubscribeToPushNotifications(props: { + onReceivedHandler: (payload: PushAnalyticsPayload) => void | Promise; + onClickHandler: (e: NotificationEvent, payload: PushAnalyticsPayload) => void; + messenger: NotificationServicesPushControllerMessenger; +}): (env: PushNotificationEnv) => Promise<() => void> { + return async function (env: PushNotificationEnv): Promise<() => void> { + const onBackgroundMessageSub = await listenToPushNotificationsReceived( + env, + async (analyticsPayload): Promise => { + props.messenger.publish( + 'NotificationServicesPushController:onNewNotifications', + analyticsPayload, + ); + await props.onReceivedHandler(analyticsPayload); + }, + ); + const onClickSub = listenToPushNotificationsClicked( + (event, analyticsPayload): void => { + props.messenger.publish( + 'NotificationServicesPushController:pushNotificationClicked', + analyticsPayload, + ); + props.onClickHandler(event, analyticsPayload); + }, + ); + + const unsubscribe = (): void => { + onBackgroundMessageSub?.(); + onClickSub(); + }; + + return unsubscribe; + }; +} diff --git a/packages/notification-services-controller/src/index.ts b/packages/notification-services-controller/src/index.ts new file mode 100644 index 00000000000..8786acee486 --- /dev/null +++ b/packages/notification-services-controller/src/index.ts @@ -0,0 +1,8 @@ +export * as NotificationServicesController from './NotificationServicesController/index.js'; +export * as NotificationServicesPushController from './NotificationServicesPushController/index.js'; +export { + DEFAULT_AGENTIC_CLI_PREFERENCES, + DEFAULT_PERPS_PREFERENCES, + DEFAULT_PRICE_ALERT_PREFERENCES, + DEFAULT_SOCIAL_AI_PREFERENCES, +} from './NotificationServicesController/index.js'; diff --git a/packages/notification-services-controller/src/shared/index.ts b/packages/notification-services-controller/src/shared/index.ts new file mode 100644 index 00000000000..76c4ceac70a --- /dev/null +++ b/packages/notification-services-controller/src/shared/index.ts @@ -0,0 +1,3 @@ +export * from './is-onchain-notification.js'; +export * from './notification-api-type-guards.js'; +export * from './to-raw-notification.js'; diff --git a/packages/notification-services-controller/src/shared/is-onchain-notification.test.ts b/packages/notification-services-controller/src/shared/is-onchain-notification.test.ts new file mode 100644 index 00000000000..3787bb46493 --- /dev/null +++ b/packages/notification-services-controller/src/shared/is-onchain-notification.test.ts @@ -0,0 +1,24 @@ +import { + createMockFeatureAnnouncementRaw, + createMockPlatformNotification, + createMockNotificationEthSent, +} from '../NotificationServicesController/mocks/index.js'; +import { isOnChainRawNotification } from './index.js'; + +describe('is-onchain-notification - isOnChainRawNotification()', () => { + it('returns true if OnChainRawNotification', () => { + const notification = createMockNotificationEthSent(); + const result = isOnChainRawNotification(notification); + expect(result).toBe(true); + }); + it('returns false if not OnChainRawNotification', () => { + const testNotifications = [ + createMockFeatureAnnouncementRaw(), + createMockPlatformNotification(), + ]; + testNotifications.forEach((notification) => { + const result = isOnChainRawNotification(notification); + expect(result).toBe(false); + }); + }); +}); diff --git a/packages/notification-services-controller/src/shared/is-onchain-notification.ts b/packages/notification-services-controller/src/shared/is-onchain-notification.ts new file mode 100644 index 00000000000..31239e0bb91 --- /dev/null +++ b/packages/notification-services-controller/src/shared/is-onchain-notification.ts @@ -0,0 +1,17 @@ +import type { + UnprocessedRawNotification, + OnChainNotification, +} from '../NotificationServicesController/types/notification-api/index.js'; +import { isOnChainNotification } from './notification-api-type-guards.js'; + +/** + * Checks if the given value is an on-chain notification using the v4 `notification_type` discriminator. + * + * @param notification - The value to check. + * @returns True if the value is an on-chain notification, false otherwise. + */ +export function isOnChainRawNotification( + notification: unknown, +): notification is OnChainNotification { + return isOnChainNotification(notification as UnprocessedRawNotification); +} diff --git a/packages/notification-services-controller/src/shared/notification-api-type-guards.ts b/packages/notification-services-controller/src/shared/notification-api-type-guards.ts new file mode 100644 index 00000000000..29a43f6854d --- /dev/null +++ b/packages/notification-services-controller/src/shared/notification-api-type-guards.ts @@ -0,0 +1,29 @@ +import type { + UnprocessedRawNotification, + OnChainNotification, + PlatformNotification, +} from '../NotificationServicesController/types/notification-api/index.js'; + +/** + * Narrows a v4 API notification to an on-chain notification. + * + * @param notification - Unprocessed v4 API notification. + * @returns Whether the notification is an on-chain notification. + */ +export function isOnChainNotification( + notification: UnprocessedRawNotification, +): notification is OnChainNotification { + return notification.notification_type === 'wallet_activity'; +} + +/** + * Narrows a v4 API notification to a platform notification. + * + * @param notification - Unprocessed v4 API notification. + * @returns Whether the notification is a platform notification. + */ +export function isPlatformNotification( + notification: UnprocessedRawNotification, +): notification is PlatformNotification { + return !isOnChainNotification(notification); +} diff --git a/packages/notification-services-controller/src/shared/to-raw-notification.ts b/packages/notification-services-controller/src/shared/to-raw-notification.ts new file mode 100644 index 00000000000..9363964500a --- /dev/null +++ b/packages/notification-services-controller/src/shared/to-raw-notification.ts @@ -0,0 +1,38 @@ +import type { + UnprocessedRawNotification, + NormalisedAPINotification, + OnChainRawNotification, + PlatformRawNotification, +} from 'src/NotificationServicesController/types/notification-api'; + +import { TRIGGER_TYPES } from '../NotificationServicesController/constants/notification-schema.js'; +import { isOnChainNotification } from './notification-api-type-guards.js'; + +/** + * A true "raw notification" does not have some fields that exist on this type. E.g. the `type` field. + * This is retro-actively added when we fetch notifications to be able to easily type-discriminate notifications. + * We use this to ensure that the correct missing fields are added to the raw shapes + * + * @param data - raw onchain notification + * @returns a complete raw onchain notification + */ +export function toRawAPINotification( + data: UnprocessedRawNotification, +): NormalisedAPINotification { + if (isOnChainNotification(data)) { + if (!data.payload.data?.kind) { + throw new Error( + 'toRawAPINotification - No kind found for on-chain notification', + ); + } + return { + ...data, + type: data.payload.data.kind, + } as OnChainRawNotification; + } + + return { + ...data, + type: TRIGGER_TYPES.PLATFORM, + } as PlatformRawNotification; +} diff --git a/packages/notification-services-controller/tsconfig.build.json b/packages/notification-services-controller/tsconfig.build.json new file mode 100644 index 00000000000..ad51c6570e1 --- /dev/null +++ b/packages/notification-services-controller/tsconfig.build.json @@ -0,0 +1,36 @@ +{ + "extends": "../../tsconfig.packages.build.json", + "compilerOptions": { + "baseUrl": "./", + "outDir": "./dist", + "rootDir": "./src", + "skipLibCheck": true + }, + "references": [ + { + "path": "../authenticated-user-storage/tsconfig.build.json" + }, + { + "path": "../base-controller/tsconfig.build.json" + }, + { + "path": "../profile-sync-controller/tsconfig.build.json" + }, + { + "path": "../keyring-controller/tsconfig.build.json" + }, + { + "path": "../messenger/tsconfig.build.json" + }, + { + "path": "../controller-utils/tsconfig.build.json" + } + ], + "include": ["../../types", "./src"], + "exclude": [ + "./jest.config.packages.ts", + "**/*.test.ts", + "**/jest.config.ts", + "**/__fixtures__/" + ] +} diff --git a/packages/notification-services-controller/tsconfig.json b/packages/notification-services-controller/tsconfig.json new file mode 100644 index 00000000000..1872d403aef --- /dev/null +++ b/packages/notification-services-controller/tsconfig.json @@ -0,0 +1,27 @@ +{ + "extends": "../../tsconfig.packages.json", + "compilerOptions": { + "baseUrl": "./" + }, + "references": [ + { + "path": "../authenticated-user-storage" + }, + { + "path": "../base-controller" + }, + { + "path": "../profile-sync-controller" + }, + { + "path": "../keyring-controller" + }, + { + "path": "../messenger" + }, + { + "path": "../controller-utils" + } + ], + "include": ["../../types", "./src"] +} diff --git a/packages/notification-services-controller/typedoc.json b/packages/notification-services-controller/typedoc.json new file mode 100644 index 00000000000..c9da015dbf8 --- /dev/null +++ b/packages/notification-services-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/passkey-controller/CHANGELOG.md b/packages/passkey-controller/CHANGELOG.md new file mode 100644 index 00000000000..08beb833c39 --- /dev/null +++ b/packages/passkey-controller/CHANGELOG.md @@ -0,0 +1,121 @@ +# 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] + +## [3.1.0] + +### Added + +- Added new util, `getAAGUIDFromRegistrationResponse` to read the authenticator AAGUID from a `navigator.credentials.create()` result. ([#9951](https://github.com/MetaMask/core/pull/9951)) + +### Changed + +- Bump `@metamask/keyring-controller` from `^27.1.0` to `^27.1.1` ([#9791](https://github.com/MetaMask/core/pull/9791)) + +## [3.0.0] + +### Added + +- Orchestrated passkey product flows on `PasskeyController` and their messenger actions. ([#9548](https://github.com/MetaMask/core/pull/9548)) + - `unlockWithPasskey` + - `removePasskeyWithPasskeyVerification` + - `removePasskeyWithPasswordVerification` + - `changePasswordWithPasskeyVerification` + - `exportSeedPhraseWithPasskey` + - `exportAccountsWithPasskey` +- `PasskeyControllerOptions` with required `getIsOnboardingCompleted` constructor callback for enrollment step-up gating. ([#9548](https://github.com/MetaMask/core/pull/9548)) +- Added new error constants, `PasskeyControllerErrorCode.VaultKeyRenewalFailed`, `PasskeyControllerErrorCode.EnrollmentPasswordRequired`, `PasskeyControllerErrorMessage.VaultKeyRenewalFailed` and `PasskeyControllerErrorMessage.EnrollmentPasswordRequired`. ([#9548](https://github.com/MetaMask/core/pull/9548)) +- `@metamask/keyring-controller` dependency for KeyringController messenger action types used during orchestration. ([#9548](https://github.com/MetaMask/core/pull/9548)) + +### Changed + +- **BREAKING:** `protectVaultKeyWithPasskey` no longer accepts `vaultKey`; the controller fetches the current encryption key via `KeyringController:exportEncryptionKey` and optionally verifies the wallet password when onboarding is complete. (([#9548](https://github.com/MetaMask/core/pull/9548))) +- **BREAKING:** `PasskeyController` constructor requires `getIsOnboardingCompleted`. (([#9548](https://github.com/MetaMask/core/pull/9548))) +- **BREAKING:** `removePasskey` and `PasskeyController:removePasskey` are no longer public; use `removePasskeyWithPasskeyVerification`, `removePasskeyWithPasswordVerification`, or `clearState`. ([#9548](https://github.com/MetaMask/core/pull/9548)) +- `PasskeyControllerMessenger` may call a fixed set of KeyringController actions during orchestrated flows ([#9548](https://github.com/MetaMask/core/pull/9548)) +- Orchestrated async passkey operations are serialized with an internal mutex to prevent concurrent vault/keyring races. ([#9548](https://github.com/MetaMask/core/pull/9548)) + +## [2.1.0] + +### Added + +- Expose public `PasskeyController` methods through its messenger ([#9515](https://github.com/MetaMask/core/pull/9515)) + - The following actions are now available: + - `PasskeyController:isPasskeyEnrolled` + - `PasskeyController:generateRegistrationOptions` + - `PasskeyController:verifyRegistrationResponse` + - `PasskeyController:generatePostRegistrationAuthenticationOptions` + - `PasskeyController:generateAuthenticationOptions` + - `PasskeyController:verifyAuthenticationResponse` + - `PasskeyController:protectVaultKeyWithPasskey` + - `PasskeyController:retrieveVaultKeyWithPasskey` + - `PasskeyController:verifyPasskeyAuthentication` + - `PasskeyController:renewVaultKeyProtection` + - `PasskeyController:removePasskey` + - `PasskeyController:clearState` + - `PasskeyController:destroy` + - Corresponding action types (e.g. `PasskeyControllerIsPasskeyEnrolledAction`) are available as well. + +### Changed + +- Bump `@metamask/utils` from `^11.9.0` to `^11.11.0` ([#9074](https://github.com/MetaMask/core/pull/9074)) +- Bump `@metamask/messenger` from `^1.2.0` to `^2.0.0` ([#9392](https://github.com/MetaMask/core/pull/9392)) + +## [2.0.1] + +### Changed + +- `PasskeyController` verifies registration and authentication responses with `requireUserVerification: true`, so the WebAuthn user verification (UV) flag must be set; assertions with user presence only no longer pass verification ([#8696](https://github.com/MetaMask/core/pull/8696)) + +### Fixed + +- `generateAuthenticationOptions` now sets `userVerification: 'required'` so client WebAuthn requests align with server-side verification requirements and do not fail on authenticators that skip UV when set to `'preferred'` ([#8696](https://github.com/MetaMask/core/pull/8696)) + +## [2.0.0] + +### Added + +- `generatePostRegistrationAuthenticationOptions` to issue `navigator.credentials.get()` options after `navigator.credentials.create()`, keyed to the in-flight registration ceremony (including PRF eval when a salt was used) ([#8663](https://github.com/MetaMask/core/pull/8663)) +- `already_enrolled` (`PasskeyControllerErrorCode.AlreadyEnrolled`) when calling `protectVaultKeyWithPasskey` while a passkey is already enrolled ([#8663](https://github.com/MetaMask/core/pull/8663)) + +### Changed + +- **BREAKING:** Enrollment completes in three steps: `generateRegistrationOptions` → `create()` → `generatePostRegistrationAuthenticationOptions` → `get()` → `protectVaultKeyWithPasskey`; `protectVaultKeyWithPasskey` now **requires** `authenticationResponse`, and the vault wrapping key is derived from that post-registration assertion (same path as unlock: PRF when present, otherwise `userHandle`) ([#8663](https://github.com/MetaMask/core/pull/8663)) +- **BREAKING:** `PasskeyController` constructor option `rpID` is replaced with `expectedRPID: string | string[]` (normalized to a string array, which may be empty). Optional `rpId` sets `rp.id` / `rpId` in generated WebAuthn options; when omitted, those fields are omitted. Verification passes that array to `verifyRegistrationResponse` / `verifyAuthenticationResponse` as `expectedRPIDs` ([#8663](https://github.com/MetaMask/core/pull/8663)) +- **BREAKING:** `verifyRegistrationResponse` and `verifyAuthenticationResponse` now take `expectedRPIDs: string[]` instead of `expectedRPID: string` ([#8663](https://github.com/MetaMask/core/pull/8663)) +- `verifyRegistrationResponse` / `verifyAuthenticationResponse` accept an empty `expectedRPIDs` array to skip RP ID hash allowlist matching; successful authentication then reports `authenticationInfo.rpID` as an empty string ([#8663](https://github.com/MetaMask/core/pull/8663)) +- Increase `CEREMONY_TTL_SLACK_MS` to 2 minutes so in-flight ceremony state (`CEREMONY_MAX_AGE_MS`, 3 minutes including WebAuthn timeout) tolerates longer gaps between WebAuthn options and completion (e.g. post-registration authentication) ([#8663](https://github.com/MetaMask/core/pull/8663)) +- Bump `@metamask/messenger` from `^1.1.1` to `^1.2.0` ([#8632](https://github.com/MetaMask/core/pull/8632)) + +### Fixed + +- `protectVaultKeyWithPasskey` rejects post-registration assertions whose `userHandle` is missing or does not match the in-flight registration ceremony when using `userHandle` key derivation (assertion `userHandle` is not signature-bound) ([#8663](https://github.com/MetaMask/core/pull/8663)) + +## [1.0.0] + +### Added + +- Initial `@metamask/passkey-controller`: `PasskeyController` for WebAuthn passkey vault key protection (HKDF-derived keys, AES-256-GCM wrap/unwrap), PRF or `userHandle` derivation, challenge-keyed `CeremonyManager`, enrollment/unlock/renewal flows, `verifyPasskeyAuthentication`, selectors, and exported ceremony timing constants. ([#8422](https://github.com/MetaMask/core/pull/8422)) +- `PasskeyControllerError` with stable `code`, optional `cause` / `context`, `toJSON`, and `toString`; `PasskeyControllerErrorCode`, `PasskeyControllerErrorMessage`, and `controllerName`. Replaces `PasskeyAuthenticationRejectedError`—use `PasskeyControllerError` and `code` for auth failures. +- **BREAKING:** Operational error messages are prefixed with `PasskeyController - `; prefer `code` or `instanceof PasskeyControllerError` over matching raw strings. +- `renewVaultKeyProtection` uses the same `vault_key_decryption_failed` code as `retrieveVaultKeyWithPasskey` when AES-GCM decrypt fails. +- Thrown failures from `verifyRegistrationResponse` / `verifyAuthenticationResponse` are wrapped in `PasskeyControllerError` with `registration_verification_failed` / `authentication_verification_failed` and the underlying error as `cause` (aligned with the `verified: false` path). +- Debug logging (via `@metamask/utils`) for registration/authentication verification failures, missing ceremony state, vault decrypt failures, and vault key mismatch during renewal. + +### Fixed + +- Registration verification requires the credential `id`/`rawId` to match the credential id in authenticator data; vault wrapping key derivation uses that verified credential id so enrollment keys align with the stored credential. +- Registration options request attestation conveyance `'none'` so clients are not asked for direct attestation formats the verifier does not implement (`none` and self-attested `packed` only). + +[Unreleased]: https://github.com/MetaMask/core/compare/@metamask/passkey-controller@3.1.0...HEAD +[3.1.0]: https://github.com/MetaMask/core/compare/@metamask/passkey-controller@3.0.0...@metamask/passkey-controller@3.1.0 +[3.0.0]: https://github.com/MetaMask/core/compare/@metamask/passkey-controller@2.1.0...@metamask/passkey-controller@3.0.0 +[2.1.0]: https://github.com/MetaMask/core/compare/@metamask/passkey-controller@2.0.1...@metamask/passkey-controller@2.1.0 +[2.0.1]: https://github.com/MetaMask/core/compare/@metamask/passkey-controller@2.0.0...@metamask/passkey-controller@2.0.1 +[2.0.0]: https://github.com/MetaMask/core/compare/@metamask/passkey-controller@1.0.0...@metamask/passkey-controller@2.0.0 +[1.0.0]: https://github.com/MetaMask/core/releases/tag/@metamask/passkey-controller@1.0.0 diff --git a/packages/passkey-controller/LICENSE b/packages/passkey-controller/LICENSE new file mode 100644 index 00000000000..c259cd7ebcf --- /dev/null +++ b/packages/passkey-controller/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2025 MetaMask + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packages/passkey-controller/README.md b/packages/passkey-controller/README.md new file mode 100644 index 00000000000..1d4e962e867 --- /dev/null +++ b/packages/passkey-controller/README.md @@ -0,0 +1,235 @@ +# `@metamask/passkey-controller` + +Manages passkey-based vault key protection using [WebAuthn](https://www.w3.org/TR/webauthn-3/). Orchestrates the full passkey lifecycle: generating WebAuthn ceremony options, verifying authenticator responses, and protecting/retrieving the vault encryption key via AES-256-GCM wrapping with HKDF-derived keys. + +## Installation + +`yarn add @metamask/passkey-controller` + +or + +`npm install @metamask/passkey-controller` + +## Overview + +The controller follows a two-phase ceremony pattern for unlock (authentication) and a three-step pattern for enrollment: registration options → post-registration authentication options → combined verify and protect. + +1. **Generate options** — call a synchronous method that returns options JSON and records **in-flight ceremony** state (challenge-keyed; not a user login session). +2. **Verify response** — pass the authenticator's response back to the controller, which verifies the WebAuthn signature and performs the cryptographic operation (protect or retrieve the vault key). + +For enrollment, the wrapping key is always derived from the **post-registration** `get()` response (same path as unlock), not from the `create()` response alone. + +### Key derivation strategies + +The controller supports two key derivation methods, selected automatically during enrollment: + +| Strategy | When used | Input key material | +| -------------- | --------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------- | +| **PRF** | Post-registration assertion includes non-empty [PRF extension](https://w3c.github.io/webauthn/#prf-extension) output and registration used PRF salt | PRF evaluation output from the assertion (ceremony `prfSalt` is stored on the record) | +| **userHandle** | Otherwise | Random `userHandle` from registration (asserted on the post-registration `get()`) | + +Both strategies feed the input key material through **HKDF-SHA256** with the credential ID as salt and a fixed info string to produce the 32-byte AES-256 wrapping key. + +## Usage + +### Setting up the controller + +The restricted messenger must allow the KeyringController actions listed in [Keyring integration](#keyring-integration). Onboarding completion is supplied via constructor callback (not via messenger). + +```typescript +import { PasskeyController } from '@metamask/passkey-controller'; +import type { PasskeyControllerMessenger } from '@metamask/passkey-controller'; + +const messenger: PasskeyControllerMessenger = /* create via root messenger */; + +const controller = new PasskeyController({ + messenger, + rpId: 'example.com', + // Or multiple verification candidates: expectedRPID: ['a.example', 'b.example'] + expectedRPID: 'example.com', + rpName: 'My Wallet', + expectedOrigin: 'chrome-extension://abcdef1234567890', + // Optional — both default to `rpName` when omitted. + userName: 'My Wallet', + userDisplayName: 'My Wallet', + getIsOnboardingCompleted: () => onboardingController.state.completedOnboarding, +}); +``` + +`expectedRPID` is a string or string array used to verify the authenticator `rpIdHash`. Optional `rpId`, when set, is sent as `rp.id` / `rpId` in generated WebAuthn options; when omitted, those fields are omitted so the client uses its default RP ID behavior. + +### Passkey enrollment (registration) + +`protectVaultKeyWithPasskey` fetches the current vault encryption key from KeyringController. When onboarding is complete, pass the wallet `password` for step-up verification first. + +```typescript +// 1. Generate registration options (synchronous) +const regOptions = controller.generateRegistrationOptions(); + +// 2. Create the passkey in the browser +const regResponse = await navigator.credentials.create({ + publicKey: regOptions, +}); + +// 3. Post-registration authentication (same wrapping-key path as unlock) +const authOptions = controller.generatePostRegistrationAuthenticationOptions({ + registrationResponse: regResponse, +}); +const authResponse = await navigator.credentials.get({ + publicKey: authOptions, +}); + +// 4. Verify registration + post-registration auth, then persist +await controller.protectVaultKeyWithPasskey({ + registrationResponse: regResponse, + authenticationResponse: authResponse, + password: settingsEnroll ? walletPassword : undefined, +}); +``` + +### Passkey unlock (authentication) + +Prefer `unlockWithPasskey`, which verifies the assertion and submits the vault key to KeyringController. + +```typescript +const options = controller.generateAuthenticationOptions(); +const response = await navigator.credentials.get({ publicKey: options }); + +await controller.unlockWithPasskey(response); +``` + +### Orchestrated product flows + +These methods combine passkey verification with KeyringController calls. Use them from UI layers that already performed `navigator.credentials.get()`. + +| Method | Purpose | +| --------------------------------------- | --------------------------------------------------------- | +| `unlockWithPasskey` | Unlock keyring after passkey assertion | +| `removePasskeyWithPasskeyVerification` | Remove passkey after assertion step-up | +| `removePasskeyWithPasswordVerification` | Remove passkey after password step-up | +| `changePasswordWithPasskeyVerification` | Change password; re-wrap vault key by default | +| `exportSeedPhraseWithPasskey` | Export SRP bytes after assertion step-up | +| `exportAccountsWithPasskey` | Export private keys for addresses after assertion step-up | + +```typescript +// Change password (re-wraps passkey protection by default) +await controller.changePasswordWithPasskeyVerification({ + newPassword: 'new-secret', + authenticationResponse: response, + options: { renewVaultKeyProtection: true }, +}); + +// Export seed phrase (raw Uint8Array; format in your app layer) +const seedPhrase = await controller.exportSeedPhraseWithPasskey( + response, + keyringId, +); + +// Export private keys for multiple addresses (one assertion) +const privateKeys = await controller.exportAccountsWithPasskey(response, [ + '0xabc…', + '0xdef…', +]); +``` + +### Low-level methods + +`retrieveVaultKeyWithPasskey` and `renewVaultKeyProtection` remain available for advanced composition. Prefer the orchestrated methods above for standard product flows. Use `clearState` for wallet reset lifecycle. + +### Checking enrollment and removing a passkey + +```typescript +controller.isPasskeyEnrolled(); // boolean + +await controller.removePasskeyWithPasskeyVerification(response); +await controller.removePasskeyWithPasswordVerification(password); + +controller.clearState(); // persisted reset + clears in-flight ceremony state; use for app lifecycle (e.g. wallet reset) +``` + +### Selectors + +For Redux selectors and other code paths without access to the controller +instance, use the exported selector(s): + +```typescript +import { passkeyControllerSelectors } from '@metamask/passkey-controller'; + +passkeyControllerSelectors.selectIsPasskeyEnrolled(state); // boolean +``` + +### Errors + +`PasskeyControllerError` is thrown for controller failures. Expected operational +cases use a stable `code` from `PasskeyControllerErrorCode` (for example: +`not_enrolled`, `already_enrolled`, `no_registration_ceremony`, +`authentication_verification_failed`, `missing_key_material`, `vault_key_decryption_failed`, +`vault_key_mismatch`, `vault_key_renewal_failed`, `enrollment_password_required`). Human-readable strings +live on `PasskeyControllerErrorMessage`. Use `instanceof PasskeyControllerError` +and a defined `error.code` to tell these apart from malformed WebAuthn payloads +and other `Error` values. Thrown errors from the internal WebAuthn verify helpers +are also surfaced as `PasskeyControllerError` with the same `registration_verification_failed` +or `authentication_verification_failed` code and the original error as `cause`. +`verifyPasskeyAuthentication` returns `false` only for +those controller errors (with `code`) and rethrows everything else. + +## API + +### State + +| Property | Type | Description | +| --------------- | ----------------------- | --------------------------------------------------------------------------------------------- | +| `passkeyRecord` | `PasskeyRecord \| null` | Enrolled passkey credential data and encrypted vault key. `null` when no passkey is enrolled. | + +### Messenger actions + +| Action | Purpose | +| ----------------------------------------------------------------- | ------------------------------------------ | +| `PasskeyController:getState` | Persisted `passkeyRecord` | +| `PasskeyController:isPasskeyEnrolled` | Enrollment boolean | +| `PasskeyController:generateRegistrationOptions` | WebAuthn `create()` options | +| `PasskeyController:generatePostRegistrationAuthenticationOptions` | WebAuthn `get()` after `create()` | +| `PasskeyController:generateAuthenticationOptions` | WebAuthn `get()` for unlock / step-up | +| `PasskeyController:protectVaultKeyWithPasskey` | Enroll: verify ceremonies + wrap vault key | +| `PasskeyController:retrieveVaultKeyWithPasskey` | Verify assertion + return vault key | +| `PasskeyController:unlockWithPasskey` | Unlock keyring via passkey | +| `PasskeyController:exportSeedPhraseWithPasskey` | Export SRP after passkey step-up | +| `PasskeyController:exportAccountsWithPasskey` | Export private keys after passkey step-up | +| `PasskeyController:verifyPasskeyAuthentication` | Boolean assertion verification | +| `PasskeyController:renewVaultKeyProtection` | Re-wrap vault key after rotation | +| `PasskeyController:changePasswordWithPasskeyVerification` | Change password with passkey step-up | +| `PasskeyController:removePasskeyWithPasskeyVerification` | Remove passkey after assertion step-up | +| `PasskeyController:removePasskeyWithPasswordVerification` | Remove passkey after password step-up | +| `PasskeyController:clearState` | Reset state | +| `PasskeyController:destroy` | Tear down messenger + ceremony state | + +Corresponding `PasskeyController*Action` types are exported from the package entry point. + +For derived enrollment status outside of components that hold a controller +reference, use `passkeyControllerSelectors.selectIsPasskeyEnrolled` (see +[Selectors](#selectors)). + +### Messenger events + +| Event | Payload | +| -------------------------------- | ------------------------------------------------------------ | +| `PasskeyController:stateChanged` | Emitted when state changes (standard `BaseController` event) | + +### Keyring integration + +`PasskeyController` calls these KeyringController actions during orchestrated flows. Allow them on the restricted messenger at init: + +| Messenger action | +| --------------------------------------- | +| `KeyringController:verifyPassword` | +| `KeyringController:exportEncryptionKey` | +| `KeyringController:submitEncryptionKey` | +| `KeyringController:changePassword` | +| `KeyringController:exportSeedPhrase` | +| `KeyringController:exportAccount` | + +Onboarding completion is **not** read via messenger. Pass `getIsOnboardingCompleted` in the constructor (see [Setting up the controller](#setting-up-the-controller)). + +## Contributing + +This package is part of a monorepo. Instructions for contributing can be found in the [monorepo README](https://github.com/MetaMask/core#readme). diff --git a/packages/passkey-controller/jest.config.js b/packages/passkey-controller/jest.config.js new file mode 100644 index 00000000000..b402d0883c2 --- /dev/null +++ b/packages/passkey-controller/jest.config.js @@ -0,0 +1,19 @@ +const merge = require('deepmerge'); +const path = require('path'); + +const baseConfig = require('../../jest.config.packages'); + +const displayName = path.basename(__dirname); + +module.exports = merge(baseConfig, { + displayName, + testEnvironment: '/jest.environment.js', + coverageThreshold: { + global: { + branches: 99.27, + functions: 100, + lines: 100, + statements: 100, + }, + }, +}); diff --git a/packages/passkey-controller/jest.environment.js b/packages/passkey-controller/jest.environment.js new file mode 100644 index 00000000000..c3b47d5c246 --- /dev/null +++ b/packages/passkey-controller/jest.environment.js @@ -0,0 +1,17 @@ +const { TestEnvironment } = require('jest-environment-node'); + +/** + * Passkey orchestration uses the Web Crypto API (`crypto.getRandomValues`) in Node tests. + */ +class CustomTestEnvironment extends TestEnvironment { + async setup() { + await super.setup(); + if (typeof this.global.crypto === 'undefined') { + // Only used for testing. + // eslint-disable-next-line n/no-unsupported-features/node-builtins + this.global.crypto = require('crypto').webcrypto; + } + } +} + +module.exports = CustomTestEnvironment; diff --git a/packages/passkey-controller/package.json b/packages/passkey-controller/package.json new file mode 100644 index 00000000000..d63f453d973 --- /dev/null +++ b/packages/passkey-controller/package.json @@ -0,0 +1,85 @@ +{ + "name": "@metamask/passkey-controller", + "version": "3.1.0", + "description": "Controller and utilities for passkey-based wallet unlock", + "keywords": [ + "Ethereum", + "MetaMask" + ], + "homepage": "https://github.com/MetaMask/core/tree/main/packages/passkey-controller#readme", + "bugs": { + "url": "https://github.com/MetaMask/core/issues" + }, + "license": "MIT", + "repository": { + "type": "git", + "url": "https://github.com/MetaMask/core.git" + }, + "files": [ + "dist/" + ], + "sideEffects": false, + "main": "./dist/index.cjs", + "types": "./dist/index.d.cts", + "exports": { + ".": { + "import": { + "types": "./dist/index.d.mts", + "default": "./dist/index.mjs" + }, + "require": { + "types": "./dist/index.d.cts", + "default": "./dist/index.cjs" + } + }, + "./package.json": "./package.json" + }, + "publishConfig": { + "access": "public", + "registry": "https://registry.npmjs.org/" + }, + "scripts": { + "build": "ts-bridge --project tsconfig.build.json --verbose --clean --no-references", + "build:all": "ts-bridge --project tsconfig.build.json --verbose --clean", + "build:docs": "typedoc", + "changelog:update": "../../scripts/update-changelog.sh @metamask/passkey-controller", + "changelog:validate": "../../scripts/validate-changelog.sh @metamask/passkey-controller", + "lint:tsconfigs": "tsx ../../scripts/lint-tsconfigs/lint-tsconfigs.mts", + "lint:tsconfigs:fix": "tsx ../../scripts/lint-tsconfigs/lint-tsconfigs.mts --fix", + "messenger-action-types:check": "tsx ../../packages/messenger-cli/src/cli.ts --formatter oxfmt --esm --check", + "messenger-action-types:generate": "tsx ../../packages/messenger-cli/src/cli.ts --formatter oxfmt --esm --generate", + "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": { + "@levischuck/tiny-cbor": "^0.3.3", + "@metamask/base-controller": "^9.1.0", + "@metamask/keyring-controller": "^27.1.1", + "@metamask/messenger": "^2.0.0", + "@metamask/utils": "^11.11.0", + "@noble/ciphers": "^1.3.0", + "@noble/curves": "^1.9.2", + "@noble/hashes": "^1.8.0", + "async-mutex": "^0.5.0" + }, + "devDependencies": { + "@metamask/auto-changelog": "^6.1.0", + "@ts-bridge/cli": "^0.6.4", + "@types/jest": "^30.0.0", + "deepmerge": "^4.2.2", + "jest": "^30.4.2", + "jest-environment-node": "^30.4.1", + "ts-jest": "^29.4.11", + "tsx": "^4.20.5", + "typedoc": "^0.25.13", + "typedoc-plugin-missing-exports": "^2.0.0", + "typescript": "~5.3.3" + }, + "engines": { + "node": "^18.18 || >=20" + } +} diff --git a/packages/passkey-controller/src/PasskeyController-method-action-types.ts b/packages/passkey-controller/src/PasskeyController-method-action-types.ts new file mode 100644 index 00000000000..c0793f1acbb --- /dev/null +++ b/packages/passkey-controller/src/PasskeyController-method-action-types.ts @@ -0,0 +1,238 @@ +/** + * This file is auto generated. + * Do not edit manually. + */ + +import type { PasskeyController } from './PasskeyController.js'; + +/** + * Whether a passkey is enrolled and vault key material is stored. + * + * @returns `true` if enrolled, otherwise `false`. + */ +export type PasskeyControllerIsPasskeyEnrolledAction = { + type: `PasskeyController:isPasskeyEnrolled`; + handler: PasskeyController['isPasskeyEnrolled']; +}; + +/** + * Builds WebAuthn credential creation options for passkey enrollment. + * + * @param creationOptionsConfig - Optional creation behavior. + * @param creationOptionsConfig.prfAvailable - Request the PRF extension unless `false`. Defaults to `true`. + * @returns Public key credential creation options for `navigator.credentials.create()`. + */ +export type PasskeyControllerGenerateRegistrationOptionsAction = { + type: `PasskeyController:generateRegistrationOptions`; + handler: PasskeyController['generateRegistrationOptions']; +}; + +/** + * Builds WebAuthn credential request options for the post-registration + * authentication step (between `create` and {@link protectVaultKeyWithPasskey}). + * + * @param params - Input for the pending registration ceremony. + * @param params.registrationResponse - Result of `navigator.credentials.create()`. + * @returns Public key credential request options for `navigator.credentials.get()`. + */ +export type PasskeyControllerGeneratePostRegistrationAuthenticationOptionsAction = + { + type: `PasskeyController:generatePostRegistrationAuthenticationOptions`; + handler: PasskeyController['generatePostRegistrationAuthenticationOptions']; + }; + +/** + * Builds WebAuthn credential request options for the enrolled passkey. + * + * @returns Public key credential request options for `navigator.credentials.get()`. + */ +export type PasskeyControllerGenerateAuthenticationOptionsAction = { + type: `PasskeyController:generateAuthenticationOptions`; + handler: PasskeyController['generateAuthenticationOptions']; +}; + +/** + * Verifies registration and post-registration authentication, then stores the + * vault key encrypted under the new passkey. + * + * Fetches the current vault encryption key from KeyringController before wrapping. + * When onboarding is complete, requires `password` for step-up verification first. + * + * @param params - Enrollment completion inputs. + * @param params.registrationResponse - Result of `navigator.credentials.create()`. + * @param params.authenticationResponse - Result of `navigator.credentials.get()` after {@link generatePostRegistrationAuthenticationOptions}. + * @param params.password - Wallet password when onboarding is complete (step-up). + * @returns Resolves when enrollment completes. + */ +export type PasskeyControllerProtectVaultKeyWithPasskeyAction = { + type: `PasskeyController:protectVaultKeyWithPasskey`; + handler: PasskeyController['protectVaultKeyWithPasskey']; +}; + +/** + * Verifies an authentication assertion and returns the decrypted vault key. + * + * Prefer orchestrated methods ({@link unlockWithPasskey}, + * {@link exportSeedPhraseWithPasskey}, {@link exportAccountsWithPasskey}) for product + * flows instead of calling KeyringController with the returned key manually. + * + * @param authenticationResponse - Result of `navigator.credentials.get()`. + * @returns The plaintext vault encryption key. + */ +export type PasskeyControllerRetrieveVaultKeyWithPasskeyAction = { + type: `PasskeyController:retrieveVaultKeyWithPasskey`; + handler: PasskeyController['retrieveVaultKeyWithPasskey']; +}; + +/** + * Unlocks the keyring using a passkey authentication assertion. + * + * @param authenticationResponse - Result of `navigator.credentials.get()`. + * @returns Resolves when the keyring is unlocked. + */ +export type PasskeyControllerUnlockWithPasskeyAction = { + type: `PasskeyController:unlockWithPasskey`; + handler: PasskeyController['unlockWithPasskey']; +}; + +/** + * Exports the seed phrase after passkey step-up authentication. + * + * @param authenticationResponse - Result of `navigator.credentials.get()`. + * @param keyringId - Optional keyring id; defaults to the primary HD keyring. + * @returns Raw seed phrase bytes from KeyringController. + */ +export type PasskeyControllerExportSeedPhraseWithPasskeyAction = { + type: `PasskeyController:exportSeedPhraseWithPasskey`; + handler: PasskeyController['exportSeedPhraseWithPasskey']; +}; + +/** + * Exports private keys for the given addresses after passkey step-up authentication. + * + * @param authenticationResponse - Result of `navigator.credentials.get()`. + * @param addresses - Account addresses to export. + * @returns Private keys in the same order as `addresses`. + */ +export type PasskeyControllerExportAccountsWithPasskeyAction = { + type: `PasskeyController:exportAccountsWithPasskey`; + handler: PasskeyController['exportAccountsWithPasskey']; +}; + +/** + * Checks whether the given authentication assertion is valid for the enrolled passkey. + * + * On failure, returns `false` for {@link PasskeyControllerError} with a `code`; + * other errors propagate. + * + * @param authenticationResponse - Result of `navigator.credentials.get()`. + * @returns `true` if verification succeeds, otherwise `false`. + */ +export type PasskeyControllerVerifyPasskeyAuthenticationAction = { + type: `PasskeyController:verifyPasskeyAuthentication`; + handler: PasskeyController['verifyPasskeyAuthentication']; +}; + +/** + * Re-wraps the vault key after rotation. Updates persisted `encryptedVaultKey` on success. + * + * Does not verify WebAuthn or ceremony state—call only after your layer has authenticated + * the user (passkey `get()` + verified assertion, or verified password). On passkey paths, + * pass the same `authenticationResponse` you just verified (e.g. from + * {@link retrieveVaultKeyWithPasskey} / {@link verifyPasskeyAuthentication}). + * + * For password change with passkey step-up, prefer + * {@link changePasswordWithPasskeyVerification}, which orchestrates keyring export, + * `changePassword`, and re-wrap in one call. + * + * @param params - Re-wrap inputs. + * @param params.authenticationResponse - Used to derive the wrapping key. + * @param params.oldVaultKey - Expected current vault key. + * @param params.newVaultKey - New vault key to encrypt under the passkey. + * @returns Resolves when the passkey record is updated. + */ +export type PasskeyControllerRenewVaultKeyProtectionAction = { + type: `PasskeyController:renewVaultKeyProtection`; + handler: PasskeyController['renewVaultKeyProtection']; +}; + +/** + * Changes the wallet password after passkey step-up authentication. + * + * When `renewVaultKeyProtection` is `true` (default), re-wraps the vault key under the + * passkey after rotation. When `false`, removes the passkey instead. + * + * @param params - Change-password inputs. + * @param params.newPassword - New wallet password. + * @param params.authenticationResponse - Result of `navigator.credentials.get()`. + * @param params.options - Optional flow controls. + * @param params.options.renewVaultKeyProtection - Re-wrap vault key after password change. + * @returns Resolves when the password change completes. + */ +export type PasskeyControllerChangePasswordWithPasskeyVerificationAction = { + type: `PasskeyController:changePasswordWithPasskeyVerification`; + handler: PasskeyController['changePasswordWithPasskeyVerification']; +}; + +/** + * Removes the enrolled passkey after verifying a passkey authentication assertion. + * + * @param authenticationResponse - Result of `navigator.credentials.get()`. + * @returns Resolves when the passkey is removed. + */ +export type PasskeyControllerRemovePasskeyWithPasskeyVerificationAction = { + type: `PasskeyController:removePasskeyWithPasskeyVerification`; + handler: PasskeyController['removePasskeyWithPasskeyVerification']; +}; + +/** + * Removes the enrolled passkey after verifying the wallet password. + * + * @param password - Wallet password for step-up verification. + * @returns Resolves when the passkey is removed. + */ +export type PasskeyControllerRemovePasskeyWithPasswordVerificationAction = { + type: `PasskeyController:removePasskeyWithPasswordVerification`; + handler: PasskeyController['removePasskeyWithPasswordVerification']; +}; + +/** + * Resets state and clears in-flight registration/authentication ceremonies. + * + * For user-facing passkey removal with step-up, use + * {@link removePasskeyWithPasskeyVerification} or + * {@link removePasskeyWithPasswordVerification}. + */ +export type PasskeyControllerClearStateAction = { + type: `PasskeyController:clearState`; + handler: PasskeyController['clearState']; +}; + +/** + * Releases all in-flight ceremony state and tears down the messenger. + */ +export type PasskeyControllerDestroyAction = { + type: `PasskeyController:destroy`; + handler: PasskeyController['destroy']; +}; + +/** + * Union of all PasskeyController action types. + */ +export type PasskeyControllerMethodActions = + | PasskeyControllerIsPasskeyEnrolledAction + | PasskeyControllerGenerateRegistrationOptionsAction + | PasskeyControllerGeneratePostRegistrationAuthenticationOptionsAction + | PasskeyControllerGenerateAuthenticationOptionsAction + | PasskeyControllerProtectVaultKeyWithPasskeyAction + | PasskeyControllerRetrieveVaultKeyWithPasskeyAction + | PasskeyControllerUnlockWithPasskeyAction + | PasskeyControllerExportSeedPhraseWithPasskeyAction + | PasskeyControllerExportAccountsWithPasskeyAction + | PasskeyControllerVerifyPasskeyAuthenticationAction + | PasskeyControllerRenewVaultKeyProtectionAction + | PasskeyControllerChangePasswordWithPasskeyVerificationAction + | PasskeyControllerRemovePasskeyWithPasskeyVerificationAction + | PasskeyControllerRemovePasskeyWithPasswordVerificationAction + | PasskeyControllerClearStateAction + | PasskeyControllerDestroyAction; diff --git a/packages/passkey-controller/src/PasskeyController.test.ts b/packages/passkey-controller/src/PasskeyController.test.ts new file mode 100644 index 00000000000..7e0477c8eea --- /dev/null +++ b/packages/passkey-controller/src/PasskeyController.test.ts @@ -0,0 +1,2848 @@ +import { Messenger } from '@metamask/messenger'; +import { Mutex } from 'async-mutex'; + +import { createMockPasskeyControllerMessenger } from '../tests/mocks/passkey-controller-messenger.js'; +import { + CEREMONY_MAX_AGE_MS, + WEBAUTHN_TIMEOUT_MS, +} from './ceremony-manager.js'; +import { + PasskeyControllerErrorCode, + PasskeyControllerErrorMessage, +} from './constants.js'; +import { PasskeyControllerError } from './errors.js'; +import { + getDefaultPasskeyControllerState, + passkeyControllerSelectors, + PasskeyController, +} from './PasskeyController.js'; +import type { + PasskeyControllerMessenger, + PasskeyControllerOptions, + PasskeyControllerState, +} from './types.js'; +import type { PasskeyRecord, PrfClientExtensionResults } from './types.js'; +import * as passkeyCrypto from './utils/crypto.js'; +import type { + PasskeyRegistrationResponse, + PasskeyAuthenticationResponse, +} from './webauthn/types.js'; + +type ExtOutputsWithPrf = Record & PrfClientExtensionResults; + +function prfResults(first: string, enabled?: boolean): ExtOutputsWithPrf { + if (enabled === undefined) { + return { prf: { results: { first } } } as ExtOutputsWithPrf; + } + return { prf: { enabled, results: { first } } } as ExtOutputsWithPrf; +} + +// --------------------------------------------------------------------------- +// Mocks +// --------------------------------------------------------------------------- + +const mockVerifyRegistrationResponse = jest.fn(); +const mockVerifyAuthenticationResponse = jest.fn(); + +jest.mock('./webauthn/verify-registration-response', () => ({ + ...jest.requireActual('./webauthn/verify-registration-response'), + verifyRegistrationResponse: (...args: unknown[]): unknown => + mockVerifyRegistrationResponse(...args), +})); + +jest.mock('./webauthn/verify-authentication-response', () => ({ + ...jest.requireActual('./webauthn/verify-authentication-response'), + verifyAuthenticationResponse: (...args: unknown[]): unknown => + mockVerifyAuthenticationResponse(...args), +})); + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function bytesToBase64URL(bytes: Uint8Array): string { + const binary = String.fromCharCode(...bytes); + return btoa(binary) + .replace(/\+/gu, '-') + .replace(/\//gu, '_') + .replace(/[=]+$/u, ''); +} + +const TEST_RP_ID = 'example.com'; +const TEST_ORIGIN = 'https://example.com'; +const TEST_CREDENTIAL_ID = 'QUJDREVGR0hJSktMTU5PUFFSU1RVVldYWVo'; +const TEST_PUBLIC_KEY = bytesToBase64URL(new Uint8Array(32).fill(0xaa)); +const TEST_CHALLENGE = 'dGVzdC1jaGFsbGVuZ2U'; + +function getPasskeyMessenger(): PasskeyControllerMessenger { + return new Messenger({ + namespace: 'PasskeyController', + }) as PasskeyControllerMessenger; +} + +const TEST_RP_NAME = 'Test RP'; +const DEFAULT_TEST_VAULT_KEY = 'test-vault-key'; + +type CreateControllerOptions = Partial & { + vaultKey?: string; + exportEncryptionKey?: jest.Mock; +}; + +function createController( + overrides?: CreateControllerOptions, +): PasskeyController { + const { + vaultKey = DEFAULT_TEST_VAULT_KEY, + exportEncryptionKey, + messenger, + ...rest + } = overrides ?? {}; + + const resolvedMessenger = + messenger ?? + createMockPasskeyControllerMessenger({ + exportEncryptionKey: + exportEncryptionKey ?? jest.fn().mockResolvedValue(vaultKey), + }).messenger; + + return new PasskeyController({ + messenger: resolvedMessenger, + expectedRPID: TEST_RP_ID, + rpId: TEST_RP_ID, + rpName: TEST_RP_NAME, + expectedOrigin: TEST_ORIGIN, + getIsOnboardingCompleted: jest.fn().mockReturnValue(false), + ...rest, + }); +} + +function minimalRegistrationResponse( + overrides?: Partial, + challenge: string = TEST_CHALLENGE, +): PasskeyRegistrationResponse { + return { + id: TEST_CREDENTIAL_ID, + rawId: TEST_CREDENTIAL_ID, + type: 'public-key', + response: { + clientDataJSON: bytesToBase64URL( + new TextEncoder().encode( + JSON.stringify({ + type: 'webauthn.create', + challenge, + origin: TEST_ORIGIN, + }), + ), + ), + attestationObject: bytesToBase64URL(new Uint8Array([0, 1, 2])), + }, + clientExtensionResults: {}, + authenticatorAttachment: 'platform', + ...overrides, + } as PasskeyRegistrationResponse; +} + +function minimalAuthenticationResponse( + userHandle?: string, + overrides?: Partial, + challenge: string = TEST_CHALLENGE, +): PasskeyAuthenticationResponse { + return { + id: TEST_CREDENTIAL_ID, + rawId: TEST_CREDENTIAL_ID, + type: 'public-key', + response: { + clientDataJSON: bytesToBase64URL( + new TextEncoder().encode( + JSON.stringify({ + type: 'webauthn.get', + challenge, + origin: TEST_ORIGIN, + }), + ), + ), + authenticatorData: bytesToBase64URL(new Uint8Array([0])), + signature: bytesToBase64URL(new Uint8Array([0])), + ...(userHandle === undefined ? {} : { userHandle }), + }, + clientExtensionResults: {}, + authenticatorAttachment: 'platform', + ...overrides, + } as PasskeyAuthenticationResponse; +} + +function setupRegistrationMocks(): void { + mockVerifyRegistrationResponse.mockResolvedValue({ + verified: true, + registrationInfo: { + credentialId: TEST_CREDENTIAL_ID, + publicKey: new Uint8Array(32).fill(0xaa), + counter: 0, + transports: ['internal'], + aaguid: '00000000-0000-0000-0000-000000000000', + attestationFormat: 'none', + userVerified: true, + }, + }); +} + +function setupAuthenticationMocks(): void { + mockVerifyAuthenticationResponse.mockResolvedValue({ + verified: true, + authenticationInfo: { + credentialId: TEST_CREDENTIAL_ID, + newCounter: 0, + userVerified: true, + origin: TEST_ORIGIN, + rpID: TEST_RP_ID, + }, + }); +} + +async function enrollWithPostRegistrationAuth( + controller: PasskeyController, + options: { + registrationResponse: PasskeyRegistrationResponse; + /** Assertion userHandle when using userHandle wrapping (must match registration `user.id`). */ + userHandle?: string; + /** PRF (or other) extension results on the post-registration authentication response. */ + authClientExtensionResults?: Record; + password?: string; + }, +): Promise { + const { + registrationResponse, + userHandle, + authClientExtensionResults, + password, + } = options; + const authOpts = controller.generatePostRegistrationAuthenticationOptions({ + registrationResponse, + }); + const authResp = minimalAuthenticationResponse( + userHandle, + { + clientExtensionResults: authClientExtensionResults ?? {}, + }, + authOpts.challenge, + ); + await controller.protectVaultKeyWithPasskey({ + registrationResponse, + authenticationResponse: authResp, + password, + }); +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +describe('PasskeyController', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe('getDefaultPasskeyControllerState', () => { + it('returns null passkeyRecord', () => { + expect(getDefaultPasskeyControllerState()).toStrictEqual({ + passkeyRecord: null, + }); + }); + }); + + describe('constructor', () => { + it('allows expectedRPID to be an empty array', () => { + expect( + () => + new PasskeyController({ + messenger: getPasskeyMessenger(), + expectedRPID: [], + rpName: TEST_RP_NAME, + expectedOrigin: TEST_ORIGIN, + getIsOnboardingCompleted: jest.fn().mockReturnValue(false), + }), + ).not.toThrow(); + }); + + it('merges partial initial state with defaults', () => { + const record: PasskeyRecord = { + credential: { + id: TEST_CREDENTIAL_ID, + publicKey: TEST_PUBLIC_KEY, + counter: 0, + transports: ['internal'], + aaguid: '00000000-0000-0000-0000-000000000000', + }, + encryptedVaultKey: { + ciphertext: 'YQ==', + iv: 'YWFhYWFhYWFhYQ==', + }, + keyDerivation: { method: 'userHandle' }, + }; + const controller = createController({ + state: { passkeyRecord: record }, + }); + expect(controller.state.passkeyRecord).toStrictEqual(record); + }); + }); + + describe('messenger', () => { + it('delegates allowed KeyringController actions from the restricted messenger', async () => { + const { messenger, mocks } = createMockPasskeyControllerMessenger({ + verifyPassword: jest.fn().mockResolvedValue(undefined), + exportEncryptionKey: jest.fn().mockResolvedValue('test-vault-key'), + submitEncryptionKey: jest.fn().mockResolvedValue(undefined), + changePassword: jest.fn().mockResolvedValue(undefined), + exportSeedPhrase: jest.fn().mockResolvedValue(new Uint8Array([1, 2])), + exportAccount: jest.fn().mockResolvedValue('0xdeadbeef'), + }); + + createController({ messenger }); + + expect( + await messenger.call('KeyringController:verifyPassword', 'password'), + ).toBeUndefined(); + expect( + await messenger.call('KeyringController:exportEncryptionKey'), + ).toBe('test-vault-key'); + expect( + await messenger.call( + 'KeyringController:submitEncryptionKey', + 'vault-key', + ), + ).toBeUndefined(); + expect( + await messenger.call( + 'KeyringController:changePassword', + 'new-password', + ), + ).toBeUndefined(); + expect( + await messenger.call( + 'KeyringController:exportSeedPhrase', + { encryptionKey: 'vault-key' }, + 'keyring-id', + ), + ).toStrictEqual(new Uint8Array([1, 2])); + expect( + await messenger.call( + 'KeyringController:exportAccount', + { encryptionKey: 'vault-key' }, + '0xabc', + ), + ).toBe('0xdeadbeef'); + + expect(mocks.verifyPassword).toHaveBeenCalledWith('password'); + expect(mocks.exportEncryptionKey).toHaveBeenCalledTimes(1); + expect(mocks.submitEncryptionKey).toHaveBeenCalledWith('vault-key'); + expect(mocks.changePassword).toHaveBeenCalledWith('new-password'); + expect(mocks.exportSeedPhrase).toHaveBeenCalledWith( + { encryptionKey: 'vault-key' }, + 'keyring-id', + ); + expect(mocks.exportAccount).toHaveBeenCalledWith( + { encryptionKey: 'vault-key' }, + '0xabc', + ); + }); + }); + + describe('isPasskeyEnrolled', () => { + it('returns false when no record is stored', () => { + const controller = createController(); + expect(controller.isPasskeyEnrolled()).toBe(false); + }); + }); + + describe('generateRegistrationOptions', () => { + it('returns options with PRF extension and challenge', () => { + const controller = createController(); + + const options = controller.generateRegistrationOptions(); + + expect(options.rp).toStrictEqual({ + name: TEST_RP_NAME, + id: TEST_RP_ID, + }); + expect(options.challenge).toBeDefined(); + expect(options.challenge.length).toBeGreaterThan(0); + expect(options.pubKeyCredParams).toStrictEqual([ + { alg: -8, type: 'public-key' }, + { alg: -7, type: 'public-key' }, + { alg: -257, type: 'public-key' }, + ]); + expect(options.attestation).toBe('none'); + expect(options.timeout).toBe(WEBAUTHN_TIMEOUT_MS); + expect(options.authenticatorSelection).toStrictEqual({ + userVerification: 'required', + authenticatorAttachment: 'platform', + residentKey: 'preferred', + }); + expect( + (options.extensions as Record)?.prf, + ).toBeDefined(); + }); + + it('uses expectedRPID and rpName from constructor', () => { + const controller = createController({ + expectedRPID: 'custom-rp.io', + rpName: 'Custom RP', + rpId: undefined, + }); + const options = controller.generateRegistrationOptions(); + expect(options.rp).toStrictEqual({ + name: 'Custom RP', + id: undefined, + }); + }); + + it('uses optional rpId for WebAuthn rp.id when set', () => { + const controller = createController({ + expectedRPID: ['first.example', 'second.example'], + rpId: 'second.example', + }); + const options = controller.generateRegistrationOptions(); + expect(options.rp.id).toBe('second.example'); + }); + + it('includes PRF extension when prfAvailable is true', () => { + const controller = createController(); + const options = controller.generateRegistrationOptions({ + prfAvailable: true, + }); + expect( + (options.extensions as Record)?.prf, + ).toBeDefined(); + }); + + it('includes PRF extension when prfAvailable is undefined (default)', () => { + const controller = createController(); + const options = controller.generateRegistrationOptions(); + expect( + (options.extensions as Record)?.prf, + ).toBeDefined(); + }); + + it('omits PRF extension when prfAvailable is false', () => { + const controller = createController(); + const options = controller.generateRegistrationOptions({ + prfAvailable: false, + }); + expect(options.extensions).toBeUndefined(); + }); + + it('throws when passkey is already enrolled', async () => { + setupRegistrationMocks(); + setupAuthenticationMocks(); + const controller = createController(); + const regOptions = controller.generateRegistrationOptions(); + await enrollWithPostRegistrationAuth(controller, { + registrationResponse: minimalRegistrationResponse( + undefined, + regOptions.challenge, + ), + userHandle: regOptions.user.id, + }); + expect(controller.isPasskeyEnrolled()).toBe(true); + expect(() => controller.generateRegistrationOptions()).toThrow( + PasskeyControllerErrorMessage.AlreadyEnrolled, + ); + }); + + it('uses userHandle derivation for the full round-trip when prfAvailable is false', async () => { + setupRegistrationMocks(); + setupAuthenticationMocks(); + + const vaultKey = 'no-prf-vault-key'; + const controller = createController({ vaultKey }); + + const regOptions = controller.generateRegistrationOptions({ + prfAvailable: false, + }); + expect(regOptions.extensions).toBeUndefined(); + + const userHandle = regOptions.user.id; + await enrollWithPostRegistrationAuth(controller, { + registrationResponse: minimalRegistrationResponse( + undefined, + regOptions.challenge, + ), + userHandle, + }); + + expect(controller.state.passkeyRecord?.keyDerivation).toStrictEqual({ + method: 'userHandle', + }); + + const authOptions = controller.generateAuthenticationOptions(); + expect(authOptions.extensions).toStrictEqual({}); + + const retrieved = await controller.retrieveVaultKeyWithPasskey( + minimalAuthenticationResponse( + userHandle, + undefined, + authOptions.challenge, + ), + ); + expect(retrieved).toBe(vaultKey); + }); + }); + + describe('generatePostRegistrationAuthenticationOptions', () => { + it('throws when there is no active registration ceremony', () => { + const controller = createController(); + expect(() => + controller.generatePostRegistrationAuthenticationOptions({ + registrationResponse: minimalRegistrationResponse(), + }), + ).toThrow(PasskeyControllerErrorMessage.NoRegistrationCeremony); + }); + + it('returns options with userVerification required', () => { + const controller = createController(); + const regOpts = controller.generateRegistrationOptions(); + const authOpts = controller.generatePostRegistrationAuthenticationOptions( + { + registrationResponse: minimalRegistrationResponse( + undefined, + regOpts.challenge, + ), + }, + ); + + expect(authOpts.userVerification).toBe('required'); + }); + }); + + describe('generateAuthenticationOptions', () => { + it('throws when passkey is not enrolled', () => { + const controller = createController(); + expect(() => controller.generateAuthenticationOptions()).toThrow( + PasskeyControllerErrorMessage.NotEnrolled, + ); + }); + + it('returns options with PRF for prf-enrolled credentials', async () => { + setupRegistrationMocks(); + setupAuthenticationMocks(); + + const prfFirst = bytesToBase64URL(new Uint8Array(32).fill(9)); + const controller = createController(); + + const regOpts = controller.generateRegistrationOptions(); + await enrollWithPostRegistrationAuth(controller, { + registrationResponse: minimalRegistrationResponse( + { + clientExtensionResults: prfResults(prfFirst, true), + }, + regOpts.challenge, + ), + authClientExtensionResults: prfResults(prfFirst, true), + }); + + const authOpts = controller.generateAuthenticationOptions(); + + expect(authOpts.rpId).toBe(TEST_RP_ID); + expect(authOpts.allowCredentials).toStrictEqual([ + expect.objectContaining({ + id: TEST_CREDENTIAL_ID, + type: 'public-key', + }), + ]); + expect( + (authOpts.extensions as Record)?.prf, + ).toBeDefined(); + }); + + it('returns options with userVerification required', async () => { + setupRegistrationMocks(); + setupAuthenticationMocks(); + const controller = createController(); + const regOpts = controller.generateRegistrationOptions(); + + await enrollWithPostRegistrationAuth(controller, { + registrationResponse: minimalRegistrationResponse( + undefined, + regOpts.challenge, + ), + userHandle: regOpts.user.id, + }); + + const authOpts = controller.generateAuthenticationOptions(); + + expect(authOpts.userVerification).toBe('required'); + }); + }); + + describe('protectVaultKeyWithPasskey', () => { + it('throws when onboarding is complete and password is omitted', async () => { + const controller = createController({ + getIsOnboardingCompleted: () => true, + }); + + await expect( + controller.protectVaultKeyWithPasskey({ + registrationResponse: minimalRegistrationResponse(), + authenticationResponse: minimalAuthenticationResponse(), + }), + ).rejects.toMatchObject({ + code: PasskeyControllerErrorCode.EnrollmentPasswordRequired, + message: PasskeyControllerErrorMessage.EnrollmentPasswordRequired, + }); + }); + + it('verifies password when onboarding is complete', async () => { + const verifyPassword = jest.fn().mockResolvedValue(undefined); + const { messenger } = createMockPasskeyControllerMessenger({ + verifyPassword, + }); + const controller = createController({ + messenger, + getIsOnboardingCompleted: () => true, + }); + + await expect( + controller.protectVaultKeyWithPasskey({ + registrationResponse: minimalRegistrationResponse(), + authenticationResponse: minimalAuthenticationResponse(), + password: 'secret', + }), + ).rejects.toThrow(PasskeyControllerErrorMessage.NoRegistrationCeremony); + + expect(verifyPassword).toHaveBeenCalledWith('secret'); + }); + + it('fetches vault encryption key from KeyringController during enrollment', async () => { + setupRegistrationMocks(); + setupAuthenticationMocks(); + const exportEncryptionKey = jest + .fn() + .mockResolvedValue('keyring-vault-key'); + const { messenger } = createMockPasskeyControllerMessenger({ + exportEncryptionKey, + }); + const controller = createController({ messenger }); + const regOpts = controller.generateRegistrationOptions(); + + await enrollWithPostRegistrationAuth(controller, { + registrationResponse: minimalRegistrationResponse( + undefined, + regOpts.challenge, + ), + userHandle: regOpts.user.id, + }); + + expect(exportEncryptionKey).toHaveBeenCalledTimes(1); + expect(controller.isPasskeyEnrolled()).toBe(true); + + const authOpts = controller.generateAuthenticationOptions(); + const retrieved = await controller.retrieveVaultKeyWithPasskey( + minimalAuthenticationResponse( + regOpts.user.id, + undefined, + authOpts.challenge, + ), + ); + expect(retrieved).toBe('keyring-vault-key'); + }); + + it('verifies password before exporting encryption key when onboarding is complete', async () => { + setupRegistrationMocks(); + setupAuthenticationMocks(); + const callOrder: string[] = []; + const verifyPassword = jest.fn().mockImplementation(async () => { + callOrder.push('verifyPassword'); + }); + const exportEncryptionKey = jest.fn().mockImplementation(async () => { + callOrder.push('exportEncryptionKey'); + return 'k'; + }); + const { messenger } = createMockPasskeyControllerMessenger({ + verifyPassword, + exportEncryptionKey, + }); + const controller = createController({ + messenger, + getIsOnboardingCompleted: () => true, + }); + const regOpts = controller.generateRegistrationOptions(); + + await enrollWithPostRegistrationAuth(controller, { + registrationResponse: minimalRegistrationResponse( + undefined, + regOpts.challenge, + ), + userHandle: regOpts.user.id, + password: 'secret', + }); + + expect(callOrder).toStrictEqual([ + 'verifyPassword', + 'exportEncryptionKey', + ]); + }); + + it('throws when there is no active registration ceremony', async () => { + const controller = createController(); + await expect( + controller.protectVaultKeyWithPasskey({ + registrationResponse: minimalRegistrationResponse(), + authenticationResponse: minimalAuthenticationResponse(), + }), + ).rejects.toThrow(PasskeyControllerErrorMessage.NoRegistrationCeremony); + }); + + it('throws when passkey is already enrolled', async () => { + setupRegistrationMocks(); + const regOpts = createController().generateRegistrationOptions(); + const controller = createController({ + state: { + passkeyRecord: { + credential: { + id: TEST_CREDENTIAL_ID, + publicKey: TEST_PUBLIC_KEY, + counter: 0, + transports: ['internal'], + aaguid: '00000000-0000-0000-0000-000000000000', + }, + encryptedVaultKey: { ciphertext: 'YQ', iv: 'Yg' }, + keyDerivation: { method: 'userHandle' }, + }, + }, + }); + await expect( + controller.protectVaultKeyWithPasskey({ + registrationResponse: minimalRegistrationResponse( + undefined, + regOpts.challenge, + ), + authenticationResponse: minimalAuthenticationResponse( + regOpts.user.id, + undefined, + TEST_CHALLENGE, + ), + }), + ).rejects.toMatchObject({ + code: PasskeyControllerErrorCode.AlreadyEnrolled, + }); + }); + + it('throws when verification fails', async () => { + mockVerifyRegistrationResponse.mockResolvedValue({ + verified: false, + }); + + const controller = createController(); + const regOpts = controller.generateRegistrationOptions(); + const regResp = minimalRegistrationResponse(undefined, regOpts.challenge); + const authOpts = controller.generatePostRegistrationAuthenticationOptions( + { + registrationResponse: regResp, + }, + ); + const authResp = minimalAuthenticationResponse( + regOpts.user.id, + undefined, + authOpts.challenge, + ); + + await expect( + controller.protectVaultKeyWithPasskey({ + registrationResponse: regResp, + authenticationResponse: authResp, + }), + ).rejects.toThrow( + PasskeyControllerErrorMessage.RegistrationVerificationFailed, + ); + }); + + it('wraps non-Error verifyRegistrationResponse rejection in RegistrationVerificationFailed', async () => { + mockVerifyRegistrationResponse.mockRejectedValue('verify-string-error'); + + const controller = createController(); + const regOpts = controller.generateRegistrationOptions(); + const regResp = minimalRegistrationResponse(undefined, regOpts.challenge); + const authOpts = controller.generatePostRegistrationAuthenticationOptions( + { + registrationResponse: regResp, + }, + ); + const authResp = minimalAuthenticationResponse( + regOpts.user.id, + undefined, + authOpts.challenge, + ); + + await expect( + controller.protectVaultKeyWithPasskey({ + registrationResponse: regResp, + authenticationResponse: authResp, + }), + ).rejects.toMatchObject({ + code: PasskeyControllerErrorCode.RegistrationVerificationFailed, + cause: expect.objectContaining({ message: 'verify-string-error' }), + }); + }); + + it('wraps verifyRegistrationResponse rejection in RegistrationVerificationFailed and clears ceremony state', async () => { + mockVerifyRegistrationResponse.mockRejectedValue( + new Error('verify-error'), + ); + + const controller = createController(); + const regOpts = controller.generateRegistrationOptions(); + const regResp = minimalRegistrationResponse(undefined, regOpts.challenge); + const authOpts = controller.generatePostRegistrationAuthenticationOptions( + { + registrationResponse: regResp, + }, + ); + const authResp = minimalAuthenticationResponse( + regOpts.user.id, + undefined, + authOpts.challenge, + ); + + await expect( + controller.protectVaultKeyWithPasskey({ + registrationResponse: regResp, + authenticationResponse: authResp, + }), + ).rejects.toMatchObject({ + code: PasskeyControllerErrorCode.RegistrationVerificationFailed, + message: PasskeyControllerErrorMessage.RegistrationVerificationFailed, + cause: expect.objectContaining({ message: 'verify-error' }), + }); + + await expect( + controller.protectVaultKeyWithPasskey({ + registrationResponse: regResp, + authenticationResponse: minimalAuthenticationResponse( + regOpts.user.id, + ), + }), + ).rejects.toThrow(PasskeyControllerErrorMessage.NoRegistrationCeremony); + }); + + it('stores passkey record with publicKey after successful verification', async () => { + setupRegistrationMocks(); + setupAuthenticationMocks(); + const controller = createController(); + const regOpts = controller.generateRegistrationOptions(); + + await enrollWithPostRegistrationAuth(controller, { + registrationResponse: minimalRegistrationResponse( + undefined, + regOpts.challenge, + ), + userHandle: regOpts.user.id, + }); + + expect(controller.isPasskeyEnrolled()).toBe(true); + const record = controller.state.passkeyRecord; + expect(record?.credential.id).toBe(TEST_CREDENTIAL_ID); + expect(record?.credential.publicKey).toBe(TEST_PUBLIC_KEY); + expect(record?.credential.transports).toStrictEqual(['internal']); + expect(record?.credential.aaguid).toBe( + '00000000-0000-0000-0000-000000000000', + ); + expect(record?.keyDerivation.method).toBe('userHandle'); + }); + + it('throws when post-registration assertion userHandle does not match the registration ceremony', async () => { + setupRegistrationMocks(); + setupAuthenticationMocks(); + const controller = createController(); + const regOpts = controller.generateRegistrationOptions(); + const regResp = minimalRegistrationResponse(undefined, regOpts.challenge); + const authOpts = controller.generatePostRegistrationAuthenticationOptions( + { + registrationResponse: regResp, + }, + ); + const wrongUserHandle = bytesToBase64URL(new Uint8Array(64).fill(0xbb)); + + await expect( + controller.protectVaultKeyWithPasskey({ + registrationResponse: regResp, + authenticationResponse: minimalAuthenticationResponse( + wrongUserHandle, + undefined, + authOpts.challenge, + ), + }), + ).rejects.toMatchObject({ + code: PasskeyControllerErrorCode.AuthenticationVerificationFailed, + }); + + expect(controller.isPasskeyEnrolled()).toBe(false); + }); + + it('throws when userHandle derivation is required but assertion omits userHandle', async () => { + setupRegistrationMocks(); + setupAuthenticationMocks(); + const controller = createController(); + const regOpts = controller.generateRegistrationOptions({ + prfAvailable: false, + }); + const regResp = minimalRegistrationResponse(undefined, regOpts.challenge); + const authOpts = controller.generatePostRegistrationAuthenticationOptions( + { + registrationResponse: regResp, + }, + ); + const authResp = minimalAuthenticationResponse( + undefined, + undefined, + authOpts.challenge, + ); + + await expect( + controller.protectVaultKeyWithPasskey({ + registrationResponse: regResp, + authenticationResponse: authResp, + }), + ).rejects.toMatchObject({ + code: PasskeyControllerErrorCode.AuthenticationVerificationFailed, + }); + }); + + it('uses prf derivation when extension results include PRF output', async () => { + setupRegistrationMocks(); + setupAuthenticationMocks(); + const controller = createController(); + const regOpts = controller.generateRegistrationOptions(); + + const prfFirst = bytesToBase64URL(new Uint8Array(32).fill(9)); + await enrollWithPostRegistrationAuth(controller, { + registrationResponse: minimalRegistrationResponse( + { + clientExtensionResults: prfResults(prfFirst, true), + }, + regOpts.challenge, + ), + authClientExtensionResults: prfResults(prfFirst, true), + }); + + expect(controller.state.passkeyRecord?.keyDerivation.method).toBe('prf'); + expect(controller.state.passkeyRecord?.keyDerivation).toMatchObject({ + method: 'prf', + prfSalt: expect.any(String), + }); + }); + + it('uses userHandle derivation when PRF was requested but registration returns no PRF output bytes', async () => { + setupRegistrationMocks(); + setupAuthenticationMocks(); + + const vaultKey = 'vault-prf-requested-no-output'; + const controller = createController({ vaultKey }); + + const regOptions = controller.generateRegistrationOptions({ + prfAvailable: true, + }); + expect( + (regOptions.extensions as Record)?.prf, + ).toBeDefined(); + + const userHandle = regOptions.user.id; + await enrollWithPostRegistrationAuth(controller, { + registrationResponse: minimalRegistrationResponse( + { + clientExtensionResults: { + prf: { enabled: true }, + } as ExtOutputsWithPrf, + }, + regOptions.challenge, + ), + userHandle, + }); + + expect(controller.state.passkeyRecord?.keyDerivation).toStrictEqual({ + method: 'userHandle', + }); + + const authOptions = controller.generateAuthenticationOptions(); + expect(authOptions.extensions).toStrictEqual({}); + + const retrieved = await controller.retrieveVaultKeyWithPasskey( + minimalAuthenticationResponse( + userHandle, + undefined, + authOptions.challenge, + ), + ); + expect(retrieved).toBe(vaultKey); + }); + }); + + describe('retrieveVaultKeyWithPasskey', () => { + it('throws when passkey is not enrolled', async () => { + setupAuthenticationMocks(); + const controller = createController(); + await expect( + controller.retrieveVaultKeyWithPasskey( + minimalAuthenticationResponse('uh'), + ), + ).rejects.toThrow(PasskeyControllerErrorMessage.NotEnrolled); + }); + + it('throws when there is no authentication ceremony', async () => { + setupRegistrationMocks(); + setupAuthenticationMocks(); + const controller = createController(); + const regOpts = controller.generateRegistrationOptions(); + await enrollWithPostRegistrationAuth(controller, { + registrationResponse: minimalRegistrationResponse( + undefined, + regOpts.challenge, + ), + userHandle: regOpts.user.id, + }); + + await expect( + controller.retrieveVaultKeyWithPasskey( + minimalAuthenticationResponse('uh'), + ), + ).rejects.toThrow(PasskeyControllerErrorMessage.NoAuthenticationCeremony); + }); + + it('throws when verification fails', async () => { + setupRegistrationMocks(); + setupAuthenticationMocks(); + const controller = createController(); + const regOpts = controller.generateRegistrationOptions(); + await enrollWithPostRegistrationAuth(controller, { + registrationResponse: minimalRegistrationResponse( + undefined, + regOpts.challenge, + ), + userHandle: regOpts.user.id, + }); + + mockVerifyAuthenticationResponse.mockResolvedValue({ + verified: false, + }); + + const authOpts = controller.generateAuthenticationOptions(); + + await expect( + controller.retrieveVaultKeyWithPasskey( + minimalAuthenticationResponse('uh', undefined, authOpts.challenge), + ), + ).rejects.toThrow( + PasskeyControllerErrorMessage.AuthenticationVerificationFailed, + ); + }); + + it('wraps non-Error verifyAuthenticationResponse rejection in AuthenticationVerificationFailed', async () => { + setupRegistrationMocks(); + setupAuthenticationMocks(); + const controller = createController(); + const regOpts = controller.generateRegistrationOptions(); + await enrollWithPostRegistrationAuth(controller, { + registrationResponse: minimalRegistrationResponse( + undefined, + regOpts.challenge, + ), + userHandle: regOpts.user.id, + }); + + mockVerifyAuthenticationResponse.mockRejectedValue('auth-string-error'); + + const authOpts = controller.generateAuthenticationOptions(); + + await expect( + controller.retrieveVaultKeyWithPasskey( + minimalAuthenticationResponse('uh', undefined, authOpts.challenge), + ), + ).rejects.toMatchObject({ + code: PasskeyControllerErrorCode.AuthenticationVerificationFailed, + cause: expect.objectContaining({ message: 'auth-string-error' }), + }); + }); + + it('wraps verifyAuthenticationResponse rejection in AuthenticationVerificationFailed', async () => { + setupRegistrationMocks(); + setupAuthenticationMocks(); + const controller = createController(); + const regOpts = controller.generateRegistrationOptions(); + await enrollWithPostRegistrationAuth(controller, { + registrationResponse: minimalRegistrationResponse( + undefined, + regOpts.challenge, + ), + userHandle: regOpts.user.id, + }); + + mockVerifyAuthenticationResponse.mockRejectedValue( + new Error('auth-verify-error'), + ); + + const authOpts = controller.generateAuthenticationOptions(); + + await expect( + controller.retrieveVaultKeyWithPasskey( + minimalAuthenticationResponse('uh', undefined, authOpts.challenge), + ), + ).rejects.toMatchObject({ + code: PasskeyControllerErrorCode.AuthenticationVerificationFailed, + message: PasskeyControllerErrorMessage.AuthenticationVerificationFailed, + cause: expect.objectContaining({ message: 'auth-verify-error' }), + }); + }); + + it('wraps non-Error decrypt failure in VaultKeyDecryptionFailed when retrieving vault key', async () => { + setupRegistrationMocks(); + setupAuthenticationMocks(); + + const controller = createController(); + const prfFirst = bytesToBase64URL(new Uint8Array(32).fill(42)); + + const regOpts = controller.generateRegistrationOptions(); + await enrollWithPostRegistrationAuth(controller, { + registrationResponse: minimalRegistrationResponse( + { + clientExtensionResults: prfResults(prfFirst), + }, + regOpts.challenge, + ), + authClientExtensionResults: prfResults(prfFirst), + }); + + const decryptSpy = jest + .spyOn(passkeyCrypto, 'decryptWithKey') + .mockImplementation(() => { + // eslint-disable-next-line @typescript-eslint/only-throw-error -- exercise non-Error rejection normalization + throw 'decrypt-string-fail'; + }); + + const authOpts = controller.generateAuthenticationOptions(); + await expect( + controller.retrieveVaultKeyWithPasskey( + minimalAuthenticationResponse( + undefined, + { + clientExtensionResults: prfResults(prfFirst), + }, + authOpts.challenge, + ), + ), + ).rejects.toMatchObject({ + code: PasskeyControllerErrorCode.VaultKeyDecryptionFailed, + cause: expect.objectContaining({ + message: 'decrypt-string-fail', + }), + }); + + decryptSpy.mockRestore(); + }); + + it('throws when passkey record disappears while persisting counter after auth', async () => { + setupRegistrationMocks(); + setupAuthenticationMocks(); + + const controller = createController(); + const prfFirst = bytesToBase64URL(new Uint8Array(32).fill(42)); + + const regOpts = controller.generateRegistrationOptions(); + await enrollWithPostRegistrationAuth(controller, { + registrationResponse: minimalRegistrationResponse( + { + clientExtensionResults: prfResults(prfFirst), + }, + regOpts.challenge, + ), + authClientExtensionResults: prfResults(prfFirst), + }); + + const updateSpy = jest.spyOn(controller, 'update' as never); + (updateSpy as unknown as jest.Mock).mockImplementation( + (updater: (state: PasskeyControllerState) => void) => { + updater({ + ...getDefaultPasskeyControllerState(), + passkeyRecord: null, + } as PasskeyControllerState); + }, + ); + + const authOpts = controller.generateAuthenticationOptions(); + await expect( + controller.retrieveVaultKeyWithPasskey( + minimalAuthenticationResponse( + undefined, + { + clientExtensionResults: prfResults(prfFirst), + }, + authOpts.challenge, + ), + ), + ).rejects.toThrow(PasskeyControllerError); + + updateSpy.mockRestore(); + }); + + it('clears the authentication ceremony after successful retrieval (prf)', async () => { + setupRegistrationMocks(); + setupAuthenticationMocks(); + + const controller = createController(); + const prfFirst = bytesToBase64URL(new Uint8Array(32).fill(99)); + + const regOpts = controller.generateRegistrationOptions(); + await enrollWithPostRegistrationAuth(controller, { + registrationResponse: minimalRegistrationResponse( + { + clientExtensionResults: prfResults(prfFirst), + }, + regOpts.challenge, + ), + authClientExtensionResults: prfResults(prfFirst), + }); + + const authOpts = controller.generateAuthenticationOptions(); + await controller.retrieveVaultKeyWithPasskey( + minimalAuthenticationResponse( + undefined, + { + clientExtensionResults: prfResults(prfFirst), + }, + authOpts.challenge, + ), + ); + + await expect( + controller.retrieveVaultKeyWithPasskey( + minimalAuthenticationResponse(undefined, { + clientExtensionResults: prfResults(prfFirst), + }), + ), + ).rejects.toThrow(PasskeyControllerErrorMessage.NoAuthenticationCeremony); + }); + }); + + describe('unlockWithPasskey', () => { + it('throws when passkey is not enrolled', async () => { + setupAuthenticationMocks(); + const controller = createController(); + await expect( + controller.unlockWithPasskey(minimalAuthenticationResponse('uh')), + ).rejects.toThrow(PasskeyControllerErrorMessage.NotEnrolled); + }); + + it('submits the retrieved vault encryption key to KeyringController', async () => { + setupRegistrationMocks(); + setupAuthenticationMocks(); + const vaultKey = 'unlock-vault-key'; + const submitEncryptionKey = jest.fn().mockResolvedValue(undefined); + const { messenger } = createMockPasskeyControllerMessenger({ + exportEncryptionKey: jest.fn().mockResolvedValue(vaultKey), + submitEncryptionKey, + }); + const controller = createController({ messenger, vaultKey }); + const regOpts = controller.generateRegistrationOptions(); + await enrollWithPostRegistrationAuth(controller, { + registrationResponse: minimalRegistrationResponse( + undefined, + regOpts.challenge, + ), + userHandle: regOpts.user.id, + }); + + const authOpts = controller.generateAuthenticationOptions(); + await controller.unlockWithPasskey( + minimalAuthenticationResponse( + regOpts.user.id, + undefined, + authOpts.challenge, + ), + ); + + expect(submitEncryptionKey).toHaveBeenCalledWith(vaultKey); + }); + }); + + describe('exportSeedPhraseWithPasskey', () => { + it('throws when passkey is not enrolled', async () => { + setupAuthenticationMocks(); + const controller = createController(); + await expect( + controller.exportSeedPhraseWithPasskey( + minimalAuthenticationResponse('uh'), + ), + ).rejects.toThrow(PasskeyControllerErrorMessage.NotEnrolled); + }); + + it('exports seed phrase using the retrieved vault encryption key', async () => { + setupRegistrationMocks(); + setupAuthenticationMocks(); + const vaultKey = 'export-seed-vault-key'; + const seedPhrase = new Uint8Array([1, 2, 3]); + const exportSeedPhrase = jest.fn().mockResolvedValue(seedPhrase); + const { messenger } = createMockPasskeyControllerMessenger({ + exportEncryptionKey: jest.fn().mockResolvedValue(vaultKey), + exportSeedPhrase, + }); + const controller = createController({ messenger, vaultKey }); + const regOpts = controller.generateRegistrationOptions(); + await enrollWithPostRegistrationAuth(controller, { + registrationResponse: minimalRegistrationResponse( + undefined, + regOpts.challenge, + ), + userHandle: regOpts.user.id, + }); + + const authOpts = controller.generateAuthenticationOptions(); + const result = await controller.exportSeedPhraseWithPasskey( + minimalAuthenticationResponse( + regOpts.user.id, + undefined, + authOpts.challenge, + ), + 'keyring-id', + ); + + expect(result).toStrictEqual(seedPhrase); + expect(exportSeedPhrase).toHaveBeenCalledWith( + { encryptionKey: vaultKey }, + 'keyring-id', + ); + }); + }); + + describe('exportAccountsWithPasskey', () => { + it('throws when passkey is not enrolled', async () => { + setupAuthenticationMocks(); + const controller = createController(); + await expect( + controller.exportAccountsWithPasskey( + minimalAuthenticationResponse('uh'), + ['0xabc'], + ), + ).rejects.toThrow(PasskeyControllerErrorMessage.NotEnrolled); + }); + + it('exports private keys for each address using one vault key retrieval', async () => { + setupRegistrationMocks(); + setupAuthenticationMocks(); + const vaultKey = 'export-account-vault-key'; + const exportAccount = jest + .fn() + .mockResolvedValueOnce('0xprivate1') + .mockResolvedValueOnce('0xprivate2'); + const { messenger } = createMockPasskeyControllerMessenger({ + exportEncryptionKey: jest.fn().mockResolvedValue(vaultKey), + exportAccount, + }); + const controller = createController({ messenger, vaultKey }); + const regOpts = controller.generateRegistrationOptions(); + await enrollWithPostRegistrationAuth(controller, { + registrationResponse: minimalRegistrationResponse( + undefined, + regOpts.challenge, + ), + userHandle: regOpts.user.id, + }); + + const authOpts = controller.generateAuthenticationOptions(); + const addresses = ['0xabc', '0xdef']; + const result = await controller.exportAccountsWithPasskey( + minimalAuthenticationResponse( + regOpts.user.id, + undefined, + authOpts.challenge, + ), + addresses, + ); + + expect(result).toStrictEqual(['0xprivate1', '0xprivate2']); + expect(exportAccount).toHaveBeenNthCalledWith( + 1, + { encryptionKey: vaultKey }, + '0xabc', + ); + expect(exportAccount).toHaveBeenNthCalledWith( + 2, + { encryptionKey: vaultKey }, + '0xdef', + ); + }); + }); + + describe('verifyPasskeyAuthentication', () => { + it('returns false when passkey is not enrolled', async () => { + setupAuthenticationMocks(); + const controller = createController(); + expect( + await controller.verifyPasskeyAuthentication( + minimalAuthenticationResponse('uh'), + ), + ).toBe(false); + }); + + it('returns false when there is no authentication ceremony', async () => { + setupRegistrationMocks(); + setupAuthenticationMocks(); + const controller = createController(); + const regOpts = controller.generateRegistrationOptions(); + await enrollWithPostRegistrationAuth(controller, { + registrationResponse: minimalRegistrationResponse( + undefined, + regOpts.challenge, + ), + userHandle: regOpts.user.id, + }); + + expect( + await controller.verifyPasskeyAuthentication( + minimalAuthenticationResponse('uh'), + ), + ).toBe(false); + }); + + it('returns false when verification fails', async () => { + setupRegistrationMocks(); + setupAuthenticationMocks(); + const controller = createController(); + const regOpts = controller.generateRegistrationOptions(); + await enrollWithPostRegistrationAuth(controller, { + registrationResponse: minimalRegistrationResponse( + undefined, + regOpts.challenge, + ), + userHandle: regOpts.user.id, + }); + + mockVerifyAuthenticationResponse.mockResolvedValue({ + verified: false, + }); + + const authOpts = controller.generateAuthenticationOptions(); + + expect( + await controller.verifyPasskeyAuthentication( + minimalAuthenticationResponse('uh', undefined, authOpts.challenge), + ), + ).toBe(false); + }); + + it('rethrows non-operational errors (e.g. malformed clientDataJSON)', async () => { + setupRegistrationMocks(); + setupAuthenticationMocks(); + const controller = createController(); + const regOpts = controller.generateRegistrationOptions(); + await enrollWithPostRegistrationAuth(controller, { + registrationResponse: minimalRegistrationResponse( + undefined, + regOpts.challenge, + ), + userHandle: regOpts.user.id, + }); + controller.generateAuthenticationOptions(); + + const badClientData = bytesToBase64URL( + new TextEncoder().encode('not-json'), + ); + await expect( + controller.verifyPasskeyAuthentication( + minimalAuthenticationResponse('uh', { + response: { + ...minimalAuthenticationResponse('uh').response, + clientDataJSON: badClientData, + }, + }), + ), + ).rejects.toThrow(SyntaxError); + }); + + it('returns true on successful authentication (prf)', async () => { + setupRegistrationMocks(); + setupAuthenticationMocks(); + + const vaultKey = 'verify-bool-ok'; + const controller = createController({ vaultKey }); + const prfFirst = bytesToBase64URL(new Uint8Array(32).fill(7)); + + const regOpts = controller.generateRegistrationOptions(); + await enrollWithPostRegistrationAuth(controller, { + registrationResponse: minimalRegistrationResponse( + { + clientExtensionResults: prfResults(prfFirst), + }, + regOpts.challenge, + ), + authClientExtensionResults: prfResults(prfFirst), + }); + + const authOpts = controller.generateAuthenticationOptions(); + expect( + await controller.verifyPasskeyAuthentication( + minimalAuthenticationResponse( + undefined, + { + clientExtensionResults: prfResults(prfFirst), + }, + authOpts.challenge, + ), + ), + ).toBe(true); + }); + }); + + describe('registration and authentication round-trip (userHandle)', () => { + it('retrieves vault key using userHandle derivation', async () => { + setupRegistrationMocks(); + setupAuthenticationMocks(); + + const vaultKey = 'userhandle-roundtrip-key'; + const controller = createController({ vaultKey }); + + const regOpts = controller.generateRegistrationOptions(); + + await enrollWithPostRegistrationAuth(controller, { + registrationResponse: minimalRegistrationResponse( + undefined, + regOpts.challenge, + ), + userHandle: regOpts.user.id, + }); + + expect(controller.state.passkeyRecord?.keyDerivation.method).toBe( + 'userHandle', + ); + + let authOpts = controller.generateAuthenticationOptions(); + await expect( + controller.retrieveVaultKeyWithPasskey( + minimalAuthenticationResponse( + 'bWlzbWF0Y2hlZFVzZXJIYW5kbGU', + undefined, + authOpts.challenge, + ), + ), + ).rejects.toThrow(PasskeyControllerErrorMessage.VaultKeyDecryptionFailed); + + authOpts = controller.generateAuthenticationOptions(); + await expect( + controller.retrieveVaultKeyWithPasskey( + minimalAuthenticationResponse( + undefined, + undefined, + authOpts.challenge, + ), + ), + ).rejects.toThrow(PasskeyControllerErrorMessage.MissingKeyMaterial); + }); + }); + + describe('registration and authentication round-trip (prf)', () => { + it('retrieves vault key when auth response repeats the same PRF output', async () => { + setupRegistrationMocks(); + setupAuthenticationMocks(); + + const prfFirst = bytesToBase64URL(new Uint8Array(32).fill(42)); + const vaultKey = 'prf-roundtrip-key'; + const controller = createController({ vaultKey }); + + const regOpts = controller.generateRegistrationOptions(); + await enrollWithPostRegistrationAuth(controller, { + registrationResponse: minimalRegistrationResponse( + { + clientExtensionResults: prfResults(prfFirst), + }, + regOpts.challenge, + ), + authClientExtensionResults: prfResults(prfFirst), + }); + + const authOpts = controller.generateAuthenticationOptions(); + const out = await controller.retrieveVaultKeyWithPasskey( + minimalAuthenticationResponse( + undefined, + { + clientExtensionResults: prfResults(prfFirst), + }, + authOpts.challenge, + ), + ); + + expect(out).toBe(vaultKey); + }); + }); + + describe('renewVaultKeyProtection', () => { + it('throws when passkey is not enrolled', async () => { + setupAuthenticationMocks(); + const controller = createController(); + await expect( + controller.renewVaultKeyProtection({ + authenticationResponse: minimalAuthenticationResponse('uh'), + oldVaultKey: 'old', + newVaultKey: 'new', + }), + ).rejects.toThrow(PasskeyControllerErrorMessage.NotEnrolled); + }); + + it('updates the passkey wrap when before/after vault keys match', async () => { + setupRegistrationMocks(); + setupAuthenticationMocks(); + + const beforeKey = 'vault-key-before-password'; + const controller = createController({ vaultKey: beforeKey }); + const prfFirst = bytesToBase64URL(new Uint8Array(32).fill(42)); + + const regOpts = controller.generateRegistrationOptions(); + await enrollWithPostRegistrationAuth(controller, { + registrationResponse: minimalRegistrationResponse( + { + clientExtensionResults: prfResults(prfFirst), + }, + regOpts.challenge, + ), + authClientExtensionResults: prfResults(prfFirst), + }); + + let authOpts = controller.generateAuthenticationOptions(); + const afterKey = 'vault-key-after-password'; + await controller.renewVaultKeyProtection({ + authenticationResponse: minimalAuthenticationResponse( + undefined, + { + clientExtensionResults: prfResults(prfFirst), + }, + authOpts.challenge, + ), + oldVaultKey: beforeKey, + newVaultKey: afterKey, + }); + + authOpts = controller.generateAuthenticationOptions(); + const unwrapped = await controller.retrieveVaultKeyWithPasskey( + minimalAuthenticationResponse( + undefined, + { + clientExtensionResults: prfResults(prfFirst), + }, + authOpts.challenge, + ), + ); + expect(unwrapped).toBe(afterKey); + }); + + it('throws when the old vault key does not match', async () => { + setupRegistrationMocks(); + setupAuthenticationMocks(); + + const controller = createController(); + const prfFirst = bytesToBase64URL(new Uint8Array(32).fill(42)); + + const regOpts = controller.generateRegistrationOptions(); + await enrollWithPostRegistrationAuth(controller, { + registrationResponse: minimalRegistrationResponse( + { + clientExtensionResults: prfResults(prfFirst), + }, + regOpts.challenge, + ), + authClientExtensionResults: prfResults(prfFirst), + }); + + const authOpts = controller.generateAuthenticationOptions(); + + await expect( + controller.renewVaultKeyProtection({ + authenticationResponse: minimalAuthenticationResponse( + undefined, + { + clientExtensionResults: prfResults(prfFirst), + }, + authOpts.challenge, + ), + oldVaultKey: 'wrong-expected-key', + newVaultKey: 'new-key', + }), + ).rejects.toThrow(PasskeyControllerErrorMessage.VaultKeyMismatch); + }); + + it('throws when decrypting the wrapped vault key fails during renewal', async () => { + setupRegistrationMocks(); + setupAuthenticationMocks(); + + const controller = createController(); + const prfFirst = bytesToBase64URL(new Uint8Array(32).fill(42)); + + const regOpts = controller.generateRegistrationOptions(); + await enrollWithPostRegistrationAuth(controller, { + registrationResponse: minimalRegistrationResponse( + { + clientExtensionResults: prfResults(prfFirst), + }, + regOpts.challenge, + ), + authClientExtensionResults: prfResults(prfFirst), + }); + + const decryptSpy = jest + .spyOn(passkeyCrypto, 'decryptWithKey') + .mockImplementation(() => { + throw new Error('decrypt failed'); + }); + + const authOpts = controller.generateAuthenticationOptions(); + await expect( + controller.renewVaultKeyProtection({ + authenticationResponse: minimalAuthenticationResponse( + undefined, + { + clientExtensionResults: prfResults(prfFirst), + }, + authOpts.challenge, + ), + oldVaultKey: 'wrapped-key', + newVaultKey: 'new-key', + }), + ).rejects.toMatchObject({ + code: PasskeyControllerErrorCode.VaultKeyDecryptionFailed, + }); + + decryptSpy.mockRestore(); + }); + + it('wraps non-Error decrypt failure in VaultKeyDecryptionFailed during renewal', async () => { + setupRegistrationMocks(); + setupAuthenticationMocks(); + + const controller = createController(); + const prfFirst = bytesToBase64URL(new Uint8Array(32).fill(42)); + + const regOpts = controller.generateRegistrationOptions(); + await enrollWithPostRegistrationAuth(controller, { + registrationResponse: minimalRegistrationResponse( + { + clientExtensionResults: prfResults(prfFirst), + }, + regOpts.challenge, + ), + authClientExtensionResults: prfResults(prfFirst), + }); + + const decryptSpy = jest + .spyOn(passkeyCrypto, 'decryptWithKey') + .mockImplementation(() => { + // eslint-disable-next-line @typescript-eslint/only-throw-error -- exercise non-Error rejection normalization + throw 'renew-decrypt-fail'; + }); + + const authOpts = controller.generateAuthenticationOptions(); + await expect( + controller.renewVaultKeyProtection({ + authenticationResponse: minimalAuthenticationResponse( + undefined, + { + clientExtensionResults: prfResults(prfFirst), + }, + authOpts.challenge, + ), + oldVaultKey: 'wrapped-key', + newVaultKey: 'new-key', + }), + ).rejects.toMatchObject({ + code: PasskeyControllerErrorCode.VaultKeyDecryptionFailed, + cause: expect.objectContaining({ message: 'renew-decrypt-fail' }), + }); + + decryptSpy.mockRestore(); + }); + + it('throws when passkey record disappears while persisting renewed ciphertext', async () => { + setupRegistrationMocks(); + setupAuthenticationMocks(); + + const controller = createController({ vaultKey: 'wrapped' }); + const prfFirst = bytesToBase64URL(new Uint8Array(32).fill(42)); + + const regOpts = controller.generateRegistrationOptions(); + await enrollWithPostRegistrationAuth(controller, { + registrationResponse: minimalRegistrationResponse( + { + clientExtensionResults: prfResults(prfFirst), + }, + regOpts.challenge, + ), + authClientExtensionResults: prfResults(prfFirst), + }); + + const updateSpy = jest.spyOn(controller, 'update' as never); + (updateSpy as unknown as jest.Mock).mockImplementation( + (updater: (state: PasskeyControllerState) => void) => { + updater({ + ...getDefaultPasskeyControllerState(), + passkeyRecord: null, + } as PasskeyControllerState); + }, + ); + + const authOpts = controller.generateAuthenticationOptions(); + await expect( + controller.renewVaultKeyProtection({ + authenticationResponse: minimalAuthenticationResponse( + undefined, + { + clientExtensionResults: prfResults(prfFirst), + }, + authOpts.challenge, + ), + oldVaultKey: 'wrapped', + newVaultKey: 'next', + }), + ).rejects.toThrow(PasskeyControllerError); + + updateSpy.mockRestore(); + }); + + it('completes renewal without an active authentication ceremony (prf)', async () => { + setupRegistrationMocks(); + setupAuthenticationMocks(); + + const controller = createController({ vaultKey: 'wrapped' }); + const prfFirst = bytesToBase64URL(new Uint8Array(32).fill(42)); + + const regOpts = controller.generateRegistrationOptions(); + await enrollWithPostRegistrationAuth(controller, { + registrationResponse: minimalRegistrationResponse( + { + clientExtensionResults: prfResults(prfFirst), + }, + regOpts.challenge, + ), + authClientExtensionResults: prfResults(prfFirst), + }); + + await controller.renewVaultKeyProtection({ + authenticationResponse: minimalAuthenticationResponse( + undefined, + { + clientExtensionResults: prfResults(prfFirst), + }, + TEST_CHALLENGE, + ), + oldVaultKey: 'wrapped', + newVaultKey: 'new', + }); + + const authOpts = controller.generateAuthenticationOptions(); + const unwrapped = await controller.retrieveVaultKeyWithPasskey( + minimalAuthenticationResponse( + undefined, + { + clientExtensionResults: prfResults(prfFirst), + }, + authOpts.challenge, + ), + ); + expect(unwrapped).toBe('new'); + }); + + it('does not invoke verifyAuthenticationResponse', async () => { + setupRegistrationMocks(); + setupAuthenticationMocks(); + + const controller = createController({ vaultKey: 'wrapped' }); + const prfFirst = bytesToBase64URL(new Uint8Array(32).fill(42)); + + const regOpts = controller.generateRegistrationOptions(); + await enrollWithPostRegistrationAuth(controller, { + registrationResponse: minimalRegistrationResponse( + { + clientExtensionResults: prfResults(prfFirst), + }, + regOpts.challenge, + ), + authClientExtensionResults: prfResults(prfFirst), + }); + + mockVerifyAuthenticationResponse.mockClear(); + + const authOpts = controller.generateAuthenticationOptions(); + await controller.renewVaultKeyProtection({ + authenticationResponse: minimalAuthenticationResponse( + undefined, + { + clientExtensionResults: prfResults(prfFirst), + }, + authOpts.challenge, + ), + oldVaultKey: 'wrapped', + newVaultKey: 'rotated', + }); + + expect(mockVerifyAuthenticationResponse).not.toHaveBeenCalled(); + }); + }); + + describe('changePasswordWithPasskeyVerification', () => { + it('throws when passkey is not enrolled', async () => { + setupAuthenticationMocks(); + const controller = createController(); + await expect( + controller.changePasswordWithPasskeyVerification({ + newPassword: 'new-password', + authenticationResponse: minimalAuthenticationResponse('uh'), + }), + ).rejects.toThrow(PasskeyControllerErrorMessage.NotEnrolled); + }); + + it('throws when passkey authentication verification fails', async () => { + setupRegistrationMocks(); + setupAuthenticationMocks(); + const controller = createController(); + const regOpts = controller.generateRegistrationOptions(); + await enrollWithPostRegistrationAuth(controller, { + registrationResponse: minimalRegistrationResponse( + undefined, + regOpts.challenge, + ), + userHandle: regOpts.user.id, + }); + + mockVerifyAuthenticationResponse.mockResolvedValue({ + verified: false, + }); + const authOpts = controller.generateAuthenticationOptions(); + + await expect( + controller.changePasswordWithPasskeyVerification({ + newPassword: 'new-password', + authenticationResponse: minimalAuthenticationResponse( + 'uh', + undefined, + authOpts.challenge, + ), + }), + ).rejects.toThrow( + PasskeyControllerErrorMessage.AuthenticationVerificationFailed, + ); + }); + + it('changes password and renews vault key protection by default', async () => { + setupRegistrationMocks(); + setupAuthenticationMocks(); + const beforeKey = 'vault-before-password'; + const afterKey = 'vault-after-password'; + const changePassword = jest.fn().mockResolvedValue(undefined); + const exportEncryptionKey = jest + .fn() + .mockResolvedValueOnce(beforeKey) + .mockResolvedValueOnce(beforeKey) + .mockResolvedValueOnce(afterKey); + const { messenger } = createMockPasskeyControllerMessenger({ + changePassword, + exportEncryptionKey, + }); + const controller = createController({ messenger, vaultKey: beforeKey }); + const regOpts = controller.generateRegistrationOptions(); + await enrollWithPostRegistrationAuth(controller, { + registrationResponse: minimalRegistrationResponse( + undefined, + regOpts.challenge, + ), + userHandle: regOpts.user.id, + }); + + const authOpts = controller.generateAuthenticationOptions(); + await controller.changePasswordWithPasskeyVerification({ + newPassword: 'new-password', + authenticationResponse: minimalAuthenticationResponse( + regOpts.user.id, + undefined, + authOpts.challenge, + ), + }); + + expect(exportEncryptionKey).toHaveBeenCalledTimes(3); + expect(changePassword).toHaveBeenCalledWith('new-password'); + expect(controller.isPasskeyEnrolled()).toBe(true); + + const authOptsAfter = controller.generateAuthenticationOptions(); + const unwrapped = await controller.retrieveVaultKeyWithPasskey( + minimalAuthenticationResponse( + regOpts.user.id, + undefined, + authOptsAfter.challenge, + ), + ); + expect(unwrapped).toBe(afterKey); + }); + + it('removes passkey when renewVaultKeyProtection is false', async () => { + setupRegistrationMocks(); + setupAuthenticationMocks(); + const exportEncryptionKey = jest + .fn() + .mockResolvedValue(DEFAULT_TEST_VAULT_KEY); + const changePassword = jest.fn().mockResolvedValue(undefined); + const { messenger } = createMockPasskeyControllerMessenger({ + changePassword, + exportEncryptionKey, + }); + const controller = createController({ messenger }); + const regOpts = controller.generateRegistrationOptions(); + await enrollWithPostRegistrationAuth(controller, { + registrationResponse: minimalRegistrationResponse( + undefined, + regOpts.challenge, + ), + userHandle: regOpts.user.id, + }); + + const authOpts = controller.generateAuthenticationOptions(); + await controller.changePasswordWithPasskeyVerification({ + newPassword: 'new-password', + authenticationResponse: minimalAuthenticationResponse( + regOpts.user.id, + undefined, + authOpts.challenge, + ), + options: { renewVaultKeyProtection: false }, + }); + + expect(changePassword).toHaveBeenCalledWith('new-password'); + expect(exportEncryptionKey).toHaveBeenCalledTimes(1); + expect(controller.isPasskeyEnrolled()).toBe(false); + }); + + it('removes passkey and throws VaultKeyRenewalFailed when renewal fails', async () => { + setupRegistrationMocks(); + setupAuthenticationMocks(); + const beforeKey = 'vault-before-password'; + const changePassword = jest.fn().mockResolvedValue(undefined); + const exportEncryptionKey = jest + .fn() + .mockResolvedValueOnce(beforeKey) + .mockResolvedValueOnce('wrong-before-key') + .mockResolvedValueOnce('vault-after-password'); + const { messenger } = createMockPasskeyControllerMessenger({ + changePassword, + exportEncryptionKey, + }); + const controller = createController({ messenger, vaultKey: beforeKey }); + const regOpts = controller.generateRegistrationOptions(); + await enrollWithPostRegistrationAuth(controller, { + registrationResponse: minimalRegistrationResponse( + undefined, + regOpts.challenge, + ), + userHandle: regOpts.user.id, + }); + + const authOpts = controller.generateAuthenticationOptions(); + await expect( + controller.changePasswordWithPasskeyVerification({ + newPassword: 'new-password', + authenticationResponse: minimalAuthenticationResponse( + regOpts.user.id, + undefined, + authOpts.challenge, + ), + }), + ).rejects.toMatchObject({ + code: PasskeyControllerErrorCode.VaultKeyRenewalFailed, + cause: expect.objectContaining({ + code: PasskeyControllerErrorCode.VaultKeyMismatch, + }), + }); + + expect(controller.isPasskeyEnrolled()).toBe(false); + }); + + it('wraps Error renewal failures in VaultKeyRenewalFailed', async () => { + setupRegistrationMocks(); + setupAuthenticationMocks(); + const beforeKey = 'vault-before-password'; + const changePassword = jest.fn().mockResolvedValue(undefined); + const exportEncryptionKey = jest + .fn() + .mockResolvedValueOnce(beforeKey) + .mockResolvedValueOnce(beforeKey) + .mockResolvedValueOnce('vault-after-password'); + const { messenger } = createMockPasskeyControllerMessenger({ + changePassword, + exportEncryptionKey, + }); + const controller = createController({ messenger, vaultKey: beforeKey }); + const regOpts = controller.generateRegistrationOptions(); + await enrollWithPostRegistrationAuth(controller, { + registrationResponse: minimalRegistrationResponse( + undefined, + regOpts.challenge, + ), + userHandle: regOpts.user.id, + }); + + const encryptSpy = jest + .spyOn(passkeyCrypto, 'encryptWithKey') + .mockImplementationOnce(() => { + throw new Error('renew-error'); + }); + + const authOpts = controller.generateAuthenticationOptions(); + await expect( + controller.changePasswordWithPasskeyVerification({ + newPassword: 'new-password', + authenticationResponse: minimalAuthenticationResponse( + regOpts.user.id, + undefined, + authOpts.challenge, + ), + }), + ).rejects.toMatchObject({ + code: PasskeyControllerErrorCode.VaultKeyRenewalFailed, + cause: expect.objectContaining({ message: 'renew-error' }), + }); + + expect(controller.isPasskeyEnrolled()).toBe(false); + encryptSpy.mockRestore(); + }); + + it('wraps non-Error renewal failures in VaultKeyRenewalFailed', async () => { + setupRegistrationMocks(); + setupAuthenticationMocks(); + const beforeKey = 'vault-before-password'; + const changePassword = jest.fn().mockResolvedValue(undefined); + const exportEncryptionKey = jest + .fn() + .mockResolvedValueOnce(beforeKey) + .mockResolvedValueOnce(beforeKey) + .mockRejectedValueOnce('string-fail'); + const { messenger } = createMockPasskeyControllerMessenger({ + changePassword, + exportEncryptionKey, + }); + const controller = createController({ messenger, vaultKey: beforeKey }); + const regOpts = controller.generateRegistrationOptions(); + await enrollWithPostRegistrationAuth(controller, { + registrationResponse: minimalRegistrationResponse( + undefined, + regOpts.challenge, + ), + userHandle: regOpts.user.id, + }); + + const authOpts = controller.generateAuthenticationOptions(); + await expect( + controller.changePasswordWithPasskeyVerification({ + newPassword: 'new-password', + authenticationResponse: minimalAuthenticationResponse( + regOpts.user.id, + undefined, + authOpts.challenge, + ), + }), + ).rejects.toMatchObject({ + code: PasskeyControllerErrorCode.VaultKeyRenewalFailed, + cause: expect.objectContaining({ message: 'string-fail' }), + }); + + expect(controller.isPasskeyEnrolled()).toBe(false); + }); + }); + + describe('removePasskeyWithPasskeyVerification', () => { + it('throws when passkey is not enrolled', async () => { + setupAuthenticationMocks(); + const controller = createController(); + await expect( + controller.removePasskeyWithPasskeyVerification( + minimalAuthenticationResponse('uh'), + ), + ).rejects.toThrow(PasskeyControllerErrorMessage.NotEnrolled); + }); + + it('throws when passkey authentication verification fails', async () => { + setupRegistrationMocks(); + setupAuthenticationMocks(); + const controller = createController(); + const regOpts = controller.generateRegistrationOptions(); + await enrollWithPostRegistrationAuth(controller, { + registrationResponse: minimalRegistrationResponse( + undefined, + regOpts.challenge, + ), + userHandle: regOpts.user.id, + }); + + mockVerifyAuthenticationResponse.mockResolvedValue({ + verified: false, + }); + const authOpts = controller.generateAuthenticationOptions(); + + await expect( + controller.removePasskeyWithPasskeyVerification( + minimalAuthenticationResponse('uh', undefined, authOpts.challenge), + ), + ).rejects.toThrow( + PasskeyControllerErrorMessage.AuthenticationVerificationFailed, + ); + expect(controller.isPasskeyEnrolled()).toBe(true); + }); + + it('removes the passkey when authentication verification succeeds', async () => { + setupRegistrationMocks(); + setupAuthenticationMocks(); + const controller = createController(); + const regOpts = controller.generateRegistrationOptions(); + await enrollWithPostRegistrationAuth(controller, { + registrationResponse: minimalRegistrationResponse( + undefined, + regOpts.challenge, + ), + userHandle: regOpts.user.id, + }); + + const authOpts = controller.generateAuthenticationOptions(); + await controller.removePasskeyWithPasskeyVerification( + minimalAuthenticationResponse( + regOpts.user.id, + undefined, + authOpts.challenge, + ), + ); + + expect(controller.isPasskeyEnrolled()).toBe(false); + expect(controller.state.passkeyRecord).toBeNull(); + }); + }); + + describe('removePasskeyWithPasswordVerification', () => { + it('throws when passkey is not enrolled', async () => { + const controller = createController(); + await expect( + controller.removePasskeyWithPasswordVerification('secret'), + ).rejects.toThrow(PasskeyControllerErrorMessage.NotEnrolled); + }); + + it('verifies password and removes the passkey', async () => { + setupRegistrationMocks(); + setupAuthenticationMocks(); + const verifyPassword = jest.fn().mockResolvedValue(undefined); + const { messenger } = createMockPasskeyControllerMessenger({ + verifyPassword, + }); + const controller = createController({ messenger }); + const regOpts = controller.generateRegistrationOptions(); + await enrollWithPostRegistrationAuth(controller, { + registrationResponse: minimalRegistrationResponse( + undefined, + regOpts.challenge, + ), + userHandle: regOpts.user.id, + }); + + await controller.removePasskeyWithPasswordVerification('secret'); + + expect(verifyPassword).toHaveBeenCalledWith('secret'); + expect(controller.isPasskeyEnrolled()).toBe(false); + expect(controller.state.passkeyRecord).toBeNull(); + }); + }); + + describe('clearState', () => { + it('clears in-flight registration ceremonies', async () => { + setupRegistrationMocks(); + const controller = createController(); + const regOpts = controller.generateRegistrationOptions(); + controller.clearState(); + + await expect( + controller.protectVaultKeyWithPasskey({ + registrationResponse: minimalRegistrationResponse( + undefined, + regOpts.challenge, + ), + authenticationResponse: minimalAuthenticationResponse( + regOpts.user.id, + ), + }), + ).rejects.toThrow(PasskeyControllerErrorMessage.NoRegistrationCeremony); + }); + + it('clears stored record and resets enrollment', async () => { + setupRegistrationMocks(); + setupAuthenticationMocks(); + const controller = createController(); + const regOpts = controller.generateRegistrationOptions(); + await enrollWithPostRegistrationAuth(controller, { + registrationResponse: minimalRegistrationResponse( + undefined, + regOpts.challenge, + ), + userHandle: regOpts.user.id, + }); + expect(controller.isPasskeyEnrolled()).toBe(true); + + controller.clearState(); + expect(controller.isPasskeyEnrolled()).toBe(false); + expect(controller.state.passkeyRecord).toBeNull(); + }); + }); + + describe('destroy', () => { + it('clears in-flight ceremony state', async () => { + setupRegistrationMocks(); + const controller = createController(); + const regOpts = controller.generateRegistrationOptions(); + + controller.destroy(); + + await expect( + controller.protectVaultKeyWithPasskey({ + registrationResponse: minimalRegistrationResponse( + undefined, + regOpts.challenge, + ), + authenticationResponse: minimalAuthenticationResponse( + regOpts.user.id, + ), + }), + ).rejects.toThrow(PasskeyControllerErrorMessage.NoRegistrationCeremony); + }); + }); + + describe('passkeyControllerSelectors', () => { + describe('selectIsPasskeyEnrolled', () => { + it('returns false when no record is stored', () => { + expect( + passkeyControllerSelectors.selectIsPasskeyEnrolled({ + passkeyRecord: null, + }), + ).toBe(false); + }); + + it('returns true when a record is stored', () => { + const record: PasskeyRecord = { + credential: { + id: TEST_CREDENTIAL_ID, + publicKey: TEST_PUBLIC_KEY, + counter: 0, + transports: ['internal'], + aaguid: '00000000-0000-0000-0000-000000000000', + }, + encryptedVaultKey: { ciphertext: 'YQ==', iv: 'YWFhYWFhYWFhYQ==' }, + keyDerivation: { method: 'userHandle' }, + }; + expect( + passkeyControllerSelectors.selectIsPasskeyEnrolled({ + passkeyRecord: record, + }), + ).toBe(true); + }); + }); + }); + + describe('verifyRegistrationResponse parameters', () => { + it('passes expectedOrigin and expectedRPID to verification', async () => { + setupRegistrationMocks(); + setupAuthenticationMocks(); + const controller = createController({ + expectedRPID: 'custom-rp.com', + expectedOrigin: 'chrome-extension://abc123', + rpId: undefined, + }); + + const regOpts = controller.generateRegistrationOptions(); + await enrollWithPostRegistrationAuth(controller, { + registrationResponse: minimalRegistrationResponse( + undefined, + regOpts.challenge, + ), + userHandle: regOpts.user.id, + }); + + expect(mockVerifyRegistrationResponse).toHaveBeenCalledWith( + expect.objectContaining({ + expectedOrigin: 'chrome-extension://abc123', + expectedRPIDs: ['custom-rp.com'], + requireUserVerification: true, + }), + ); + }); + }); + + describe('verifyAuthenticationResponse parameters', () => { + it('passes credential with publicKey and stored counter to verification', async () => { + setupRegistrationMocks(); + setupAuthenticationMocks(); + + const controller = createController(); + const prfFirst = bytesToBase64URL(new Uint8Array(32).fill(42)); + + const regOpts = controller.generateRegistrationOptions(); + await enrollWithPostRegistrationAuth(controller, { + registrationResponse: minimalRegistrationResponse( + { + clientExtensionResults: prfResults(prfFirst), + }, + regOpts.challenge, + ), + authClientExtensionResults: prfResults(prfFirst), + }); + + const authOpts = controller.generateAuthenticationOptions(); + + try { + await controller.retrieveVaultKeyWithPasskey( + minimalAuthenticationResponse( + undefined, + { + clientExtensionResults: prfResults(prfFirst), + }, + authOpts.challenge, + ), + ); + } catch { + // key derivation result doesn't matter here + } + + expect(mockVerifyAuthenticationResponse).toHaveBeenCalledWith( + expect.objectContaining({ + expectedOrigin: TEST_ORIGIN, + expectedRPIDs: [TEST_RP_ID], + credential: expect.objectContaining({ + id: TEST_CREDENTIAL_ID, + counter: 0, + }), + requireUserVerification: true, + }), + ); + }); + + it('persists newCounter from authentication and passes it on next auth', async () => { + setupRegistrationMocks(); + setupAuthenticationMocks(); + const controller = createController(); + const prfFirst = bytesToBase64URL(new Uint8Array(32).fill(42)); + + const regOpts = controller.generateRegistrationOptions(); + await enrollWithPostRegistrationAuth(controller, { + registrationResponse: minimalRegistrationResponse( + { + clientExtensionResults: prfResults(prfFirst), + }, + regOpts.challenge, + ), + authClientExtensionResults: prfResults(prfFirst), + }); + + expect(controller.state.passkeyRecord?.credential.counter).toBe(0); + + mockVerifyAuthenticationResponse.mockResolvedValue({ + verified: true, + authenticationInfo: { + credentialId: TEST_CREDENTIAL_ID, + newCounter: 5, + userVerified: true, + origin: TEST_ORIGIN, + rpID: TEST_RP_ID, + }, + }); + + let authOpts = controller.generateAuthenticationOptions(); + await controller.retrieveVaultKeyWithPasskey( + minimalAuthenticationResponse( + undefined, + { + clientExtensionResults: prfResults(prfFirst), + }, + authOpts.challenge, + ), + ); + + expect(controller.state.passkeyRecord?.credential.counter).toBe(5); + + mockVerifyAuthenticationResponse.mockResolvedValue({ + verified: true, + authenticationInfo: { + credentialId: TEST_CREDENTIAL_ID, + newCounter: 10, + userVerified: true, + origin: TEST_ORIGIN, + rpID: TEST_RP_ID, + }, + }); + + authOpts = controller.generateAuthenticationOptions(); + await controller.retrieveVaultKeyWithPasskey( + minimalAuthenticationResponse( + undefined, + { + clientExtensionResults: prfResults(prfFirst), + }, + authOpts.challenge, + ), + ); + + expect(mockVerifyAuthenticationResponse).toHaveBeenLastCalledWith( + expect.objectContaining({ + credential: expect.objectContaining({ + counter: 5, + }), + }), + ); + expect(controller.state.passkeyRecord?.credential.counter).toBe(10); + }); + }); + + describe('operation mutex', () => { + it('serializes concurrent orchestrated operations', async () => { + setupRegistrationMocks(); + setupAuthenticationMocks(); + + const beforeKey = 'vault-before-password'; + const afterKey = 'vault-after-password'; + const callOrder: string[] = []; + let releaseExportBefore!: () => void; + const exportBeforeHold = new Promise((resolve) => { + releaseExportBefore = resolve; + }); + const changePassword = jest.fn().mockImplementation(async () => { + callOrder.push('changePassword'); + }); + const submitEncryptionKey = jest.fn().mockImplementation(async () => { + callOrder.push('submitEncryptionKey'); + }); + const exportEncryptionKey = jest.fn().mockImplementation(async () => { + const callCount = exportEncryptionKey.mock.calls.length; + if (callCount === 2) { + callOrder.push('export:before-await'); + await exportBeforeHold; + callOrder.push('export:before-done'); + } + return callCount >= 3 ? afterKey : beforeKey; + }); + const { messenger } = createMockPasskeyControllerMessenger({ + changePassword, + exportEncryptionKey, + submitEncryptionKey, + }); + const controller = createController({ messenger, vaultKey: beforeKey }); + const regOpts = controller.generateRegistrationOptions(); + await enrollWithPostRegistrationAuth(controller, { + registrationResponse: minimalRegistrationResponse( + undefined, + regOpts.challenge, + ), + userHandle: regOpts.user.id, + }); + + const changeAuthOpts = controller.generateAuthenticationOptions(); + const changePromise = controller.changePasswordWithPasskeyVerification({ + newPassword: 'new-password', + authenticationResponse: minimalAuthenticationResponse( + regOpts.user.id, + undefined, + changeAuthOpts.challenge, + ), + }); + + await Promise.resolve(); + + const unlockAuthOpts = controller.generateAuthenticationOptions(); + const unlockPromise = controller.unlockWithPasskey( + minimalAuthenticationResponse( + regOpts.user.id, + undefined, + unlockAuthOpts.challenge, + ), + ); + + await new Promise((resolve) => { + const waitForExportBlock = (): void => { + if (callOrder.includes('export:before-await')) { + resolve(); + return; + } + setImmediate(waitForExportBlock); + }; + waitForExportBlock(); + }); + expect(submitEncryptionKey).not.toHaveBeenCalled(); + expect(callOrder).toStrictEqual(['export:before-await']); + + releaseExportBefore(); + await Promise.all([changePromise, unlockPromise]); + + expect(callOrder).toStrictEqual([ + 'export:before-await', + 'export:before-done', + 'changePassword', + 'submitEncryptionKey', + ]); + }); + + it('acquires the operation mutex for orchestrated methods', async () => { + const runExclusiveSpy = jest.spyOn(Mutex.prototype, 'runExclusive'); + setupRegistrationMocks(); + setupAuthenticationMocks(); + const controller = createController(); + const regOpts = controller.generateRegistrationOptions(); + await enrollWithPostRegistrationAuth(controller, { + registrationResponse: minimalRegistrationResponse( + undefined, + regOpts.challenge, + ), + userHandle: regOpts.user.id, + }); + + const authOpts = controller.generateAuthenticationOptions(); + await controller.unlockWithPasskey( + minimalAuthenticationResponse( + regOpts.user.id, + undefined, + authOpts.challenge, + ), + ); + + expect(runExclusiveSpy).toHaveBeenCalled(); + runExclusiveSpy.mockRestore(); + }); + }); + + describe('concurrent WebAuthn ceremonies', () => { + it('completes authentication using the first challenge after a second generateAuthenticationOptions', async () => { + setupRegistrationMocks(); + setupAuthenticationMocks(); + + const vaultKey = 'multi-auth-ceremony'; + const controller = createController({ vaultKey }); + const prfFirst = bytesToBase64URL(new Uint8Array(32).fill(7)); + + const regOpts = controller.generateRegistrationOptions(); + await enrollWithPostRegistrationAuth(controller, { + registrationResponse: minimalRegistrationResponse( + { + clientExtensionResults: prfResults(prfFirst), + }, + regOpts.challenge, + ), + authClientExtensionResults: prfResults(prfFirst), + }); + + const authOpts1 = controller.generateAuthenticationOptions(); + const authOpts2 = controller.generateAuthenticationOptions(); + + const retrieved = await controller.retrieveVaultKeyWithPasskey( + minimalAuthenticationResponse( + undefined, + { + clientExtensionResults: prfResults(prfFirst), + }, + authOpts1.challenge, + ), + ); + + expect(retrieved).toBe(vaultKey); + + const authOpts3 = controller.generateAuthenticationOptions(); + expect( + await controller.retrieveVaultKeyWithPasskey( + minimalAuthenticationResponse( + undefined, + { + clientExtensionResults: prfResults(prfFirst), + }, + authOpts2.challenge, + ), + ), + ).toBe(vaultKey); + + expect( + await controller.retrieveVaultKeyWithPasskey( + minimalAuthenticationResponse( + undefined, + { + clientExtensionResults: prfResults(prfFirst), + }, + authOpts3.challenge, + ), + ), + ).toBe(vaultKey); + }); + + it('does not overwrite passkey fields updated while authentication verification awaits', async () => { + setupRegistrationMocks(); + setupAuthenticationMocks(); + const vaultKey = 'vault-concurrent-field'; + const controller = createController({ vaultKey }); + const prfFirst = bytesToBase64URL(new Uint8Array(32).fill(99)); + + const regOpts = controller.generateRegistrationOptions(); + await enrollWithPostRegistrationAuth(controller, { + registrationResponse: minimalRegistrationResponse( + { + clientExtensionResults: prfResults(prfFirst), + }, + regOpts.challenge, + ), + authClientExtensionResults: prfResults(prfFirst), + }); + + let finishVerify!: (value: unknown) => void; + mockVerifyAuthenticationResponse.mockImplementationOnce( + () => + new Promise((resolve) => { + finishVerify = resolve; + }), + ); + + const authOpts = controller.generateAuthenticationOptions(); + const retrievePromise = controller.retrieveVaultKeyWithPasskey( + minimalAuthenticationResponse( + undefined, + { + clientExtensionResults: prfResults(prfFirst), + }, + authOpts.challenge, + ), + ); + + await Promise.resolve(); + + const concurrentTransports = ['hybrid', 'internal'] as const; + expect( + controller.state.passkeyRecord?.credential.transports, + ).toStrictEqual(['internal']); + + ( + controller as unknown as { + update: (callback: (state: PasskeyControllerState) => void) => void; + } + ).update((state) => { + if (!state.passkeyRecord) { + return; + } + state.passkeyRecord.credential.transports = [...concurrentTransports]; + }); + + finishVerify({ + verified: true, + authenticationInfo: { + credentialId: TEST_CREDENTIAL_ID, + newCounter: 3, + userVerified: true, + origin: TEST_ORIGIN, + rpID: TEST_RP_ID, + }, + }); + + await retrievePromise; + + expect( + controller.state.passkeyRecord?.credential.transports, + ).toStrictEqual([...concurrentTransports]); + expect(controller.state.passkeyRecord?.credential.counter).toBe(3); + }); + + it('completes registration using the first challenge after a second generateRegistrationOptions', async () => { + setupRegistrationMocks(); + setupAuthenticationMocks(); + const vaultKey = 'multi-reg-ceremony'; + const controller = createController({ vaultKey }); + + const regOpts1 = controller.generateRegistrationOptions(); + controller.generateRegistrationOptions(); + + await enrollWithPostRegistrationAuth(controller, { + registrationResponse: minimalRegistrationResponse( + undefined, + regOpts1.challenge, + ), + userHandle: regOpts1.user.id, + }); + + expect(controller.isPasskeyEnrolled()).toBe(true); + expect(controller.state.passkeyRecord).not.toBeNull(); + }); + }); + + describe('ceremony TTL', () => { + it('drops expired registration ceremonies before protectVaultKeyWithPasskey', async () => { + jest.useFakeTimers(); + jest.setSystemTime(1_000_000); + setupRegistrationMocks(); + const controller = createController(); + const regOpts = controller.generateRegistrationOptions(); + + jest.setSystemTime(1_000_000 + CEREMONY_MAX_AGE_MS + 1); + + await expect( + controller.protectVaultKeyWithPasskey({ + registrationResponse: minimalRegistrationResponse( + undefined, + regOpts.challenge, + ), + authenticationResponse: minimalAuthenticationResponse( + regOpts.user.id, + ), + }), + ).rejects.toThrow(PasskeyControllerErrorMessage.NoRegistrationCeremony); + + jest.useRealTimers(); + }); + + it('removes authentication ceremony entry when verification fails', async () => { + setupRegistrationMocks(); + setupAuthenticationMocks(); + const controller = createController(); + const regOpts = controller.generateRegistrationOptions(); + const userHandle = regOpts.user.id; + await enrollWithPostRegistrationAuth(controller, { + registrationResponse: minimalRegistrationResponse( + undefined, + regOpts.challenge, + ), + userHandle, + }); + + mockVerifyAuthenticationResponse.mockResolvedValue({ + verified: false, + }); + + const authOpts = controller.generateAuthenticationOptions(); + + await expect( + controller.retrieveVaultKeyWithPasskey( + minimalAuthenticationResponse( + userHandle, + undefined, + authOpts.challenge, + ), + ), + ).rejects.toThrow( + PasskeyControllerErrorMessage.AuthenticationVerificationFailed, + ); + + mockVerifyAuthenticationResponse.mockResolvedValue({ + verified: true, + authenticationInfo: { + credentialId: TEST_CREDENTIAL_ID, + newCounter: 0, + userVerified: true, + origin: TEST_ORIGIN, + rpID: TEST_RP_ID, + }, + }); + + const authOptsRetry = controller.generateAuthenticationOptions(); + expect( + await controller.retrieveVaultKeyWithPasskey( + minimalAuthenticationResponse( + userHandle, + undefined, + authOptsRetry.challenge, + ), + ), + ).toBe(DEFAULT_TEST_VAULT_KEY); + }); + }); +}); diff --git a/packages/passkey-controller/src/PasskeyController.ts b/packages/passkey-controller/src/PasskeyController.ts new file mode 100644 index 00000000000..09796529173 --- /dev/null +++ b/packages/passkey-controller/src/PasskeyController.ts @@ -0,0 +1,1043 @@ +import type { StateMetadata } from '@metamask/base-controller'; +import { BaseController } from '@metamask/base-controller'; +import { areUint8ArraysEqual, stringToBytes } from '@metamask/utils'; +import { Mutex } from 'async-mutex'; + +import { WEBAUTHN_TIMEOUT_MS, CeremonyManager } from './ceremony-manager.js'; +import { + controllerName, + PasskeyControllerErrorCode, + PasskeyControllerErrorMessage, +} from './constants.js'; +import { PasskeyControllerError } from './errors.js'; +import { deriveKeyFromAuthenticationResponse } from './key-derivation.js'; +import { createModuleLogger, projectLogger } from './logger.js'; +import type { + AuthenticatorTransportFuture, + PasskeyControllerMessenger, + PasskeyControllerOptions, + PasskeyControllerState, + PasskeyCredentialInfo, + PasskeyKeyDerivation, + PasskeyRecord, + PrfClientExtensionResults, +} from './types.js'; +import { + decryptWithKey, + encryptWithKey, + randomBytesToBase64URL, +} from './utils/crypto.js'; +import { base64URLToBytes, bytesToBase64URL } from './utils/encoding.js'; +import { COSEALG } from './webauthn/constants.js'; +import { decodeClientDataJSON } from './webauthn/decode-client-data-json.js'; +import type { + PasskeyAuthenticationOptions, + PasskeyAuthenticationResponse, + PasskeyRegistrationOptions, + PasskeyRegistrationResponse, +} from './webauthn/types.js'; +import { verifyAuthenticationResponse } from './webauthn/verify-authentication-response.js'; +import { verifyRegistrationResponse } from './webauthn/verify-registration-response.js'; + +export type { + PasskeyControllerActions, + PasskeyControllerAllowedActions, + PasskeyControllerEvents, + PasskeyControllerGetStateAction, + PasskeyControllerMessenger, + PasskeyControllerOptions, + PasskeyControllerState, + PasskeyControllerStateChangedEvent, +} from './types.js'; + +/** + * Returns the default (empty) state for {@link PasskeyController}. + * + * @returns A fresh state object with no enrolled passkey. + */ +export function getDefaultPasskeyControllerState(): PasskeyControllerState { + return { passkeyRecord: null }; +} + +const passkeyControllerMetadata = { + passkeyRecord: { + persist: true, + includeInDebugSnapshot: false, + includeInStateLogs: false, + usedInUi: true, + }, +} satisfies StateMetadata; + +const log = createModuleLogger(projectLogger, controllerName); + +/** + * Selectors for {@link PasskeyControllerState}. + * + * Use these instead of dedicated getter methods on the controller, so that + * derived values can be consumed from Redux selectors and other places that + * only have access to a state object. + */ +export const passkeyControllerSelectors = { + selectIsPasskeyEnrolled: (state: PasskeyControllerState): boolean => + state.passkeyRecord !== null, +}; + +const MESSENGER_EXPOSED_METHODS = [ + 'isPasskeyEnrolled', + 'generateRegistrationOptions', + 'generatePostRegistrationAuthenticationOptions', + 'generateAuthenticationOptions', + 'protectVaultKeyWithPasskey', + 'retrieveVaultKeyWithPasskey', + 'unlockWithPasskey', + 'verifyPasskeyAuthentication', + 'renewVaultKeyProtection', + 'changePasswordWithPasskeyVerification', + 'exportSeedPhraseWithPasskey', + 'exportAccountsWithPasskey', + 'removePasskeyWithPasskeyVerification', + 'removePasskeyWithPasswordVerification', + 'clearState', + 'destroy', +] as const; + +/** + * Controller that enrolls a WebAuthn passkey and uses it to protect and unlock + * the vault encryption key. + */ +export class PasskeyController extends BaseController< + typeof controllerName, + PasskeyControllerState, + PasskeyControllerMessenger +> { + readonly #ceremonyManager = new CeremonyManager(); + + readonly #expectedRPIDs: string[]; + + readonly #rpId: string | undefined; + + readonly #rpName: string; + + readonly #expectedOrigin: string | string[]; + + readonly #userName: string; + + readonly #userDisplayName: string; + + readonly #getIsOnboardingCompleted: () => boolean; + + readonly #operationMutex = new Mutex(); + + /** + * Creates a passkey controller with WebAuthn relying-party settings. + * + * @param options - Constructor options. + * @param options.messenger - The messenger to use for communication. + * @param options.state - The initial state of the controller. + * @param options.rpId - The relying party ID to use for the passkey. + * @param options.expectedRPID - The expected relying party ID to use for the passkey. + * @param options.rpName - The relying party name to use for the passkey. + * @param options.expectedOrigin - The expected origin to use for the passkey. + * @param options.userName - The user name to use for the passkey. + * @param options.userDisplayName - The user display name to use for the passkey. + * @param options.getIsOnboardingCompleted - The callback to use to check if onboarding is complete. + */ + constructor({ + messenger, + state = {}, + rpId, + expectedRPID, + rpName, + expectedOrigin, + userName, + userDisplayName, + getIsOnboardingCompleted, + }: PasskeyControllerOptions) { + super({ + messenger, + metadata: passkeyControllerMetadata, + name: controllerName, + state: { ...getDefaultPasskeyControllerState(), ...state }, + }); + + const expectedRPIDs = Array.isArray(expectedRPID) + ? expectedRPID + : [expectedRPID]; + this.#expectedRPIDs = [...expectedRPIDs]; + this.#rpId = rpId; + this.#rpName = rpName; + this.#expectedOrigin = expectedOrigin; + this.#userName = userName ?? rpName; + this.#userDisplayName = userDisplayName ?? rpName; + this.#getIsOnboardingCompleted = getIsOnboardingCompleted; + + this.messenger.registerMethodActionHandlers( + this, + MESSENGER_EXPOSED_METHODS, + ); + } + + /** + * Whether a passkey is enrolled and vault key material is stored. + * + * @returns `true` if enrolled, otherwise `false`. + */ + isPasskeyEnrolled(): boolean { + return passkeyControllerSelectors.selectIsPasskeyEnrolled(this.state); + } + + /** + * Builds WebAuthn credential creation options for passkey enrollment. + * + * @param creationOptionsConfig - Optional creation behavior. + * @param creationOptionsConfig.prfAvailable - Request the PRF extension unless `false`. Defaults to `true`. + * @returns Public key credential creation options for `navigator.credentials.create()`. + */ + generateRegistrationOptions(creationOptionsConfig?: { + prfAvailable?: boolean; + }): PasskeyRegistrationOptions { + if (this.isPasskeyEnrolled()) { + throw new PasskeyControllerError( + PasskeyControllerErrorMessage.AlreadyEnrolled, + { code: PasskeyControllerErrorCode.AlreadyEnrolled }, + ); + } + + const includePrf = creationOptionsConfig?.prfAvailable !== false; + const prfSalt = includePrf ? randomBytesToBase64URL(32) : undefined; + const userHandle = randomBytesToBase64URL(64); + const challenge = randomBytesToBase64URL(32); + + const extensions: Record = {}; + if (prfSalt) { + extensions.prf = { eval: { first: prfSalt } }; + } + + const options: PasskeyRegistrationOptions = { + rp: { + name: this.#rpName, + id: this.#rpId, + }, + user: { + id: userHandle, + name: this.#userName, + displayName: this.#userDisplayName, + }, + challenge, + pubKeyCredParams: [ + { alg: COSEALG.EdDSA, type: 'public-key' }, + { alg: COSEALG.ES256, type: 'public-key' }, + { alg: COSEALG.RS256, type: 'public-key' }, + ], + timeout: WEBAUTHN_TIMEOUT_MS, + authenticatorSelection: { + userVerification: 'required', + authenticatorAttachment: 'platform', + residentKey: 'preferred', + }, + hints: ['client-device', 'hybrid'], + attestation: 'none', + ...(Object.keys(extensions).length > 0 ? { extensions } : {}), + }; + + this.#ceremonyManager.saveRegistrationCeremony(challenge, { + userHandle, + prfSalt, + challenge, + createdAt: Date.now(), + }); + + return options; + } + + /** + * Builds WebAuthn credential request options for the post-registration + * authentication step (between `create` and {@link protectVaultKeyWithPasskey}). + * + * @param params - Input for the pending registration ceremony. + * @param params.registrationResponse - Result of `navigator.credentials.create()`. + * @returns Public key credential request options for `navigator.credentials.get()`. + */ + generatePostRegistrationAuthenticationOptions(params: { + registrationResponse: PasskeyRegistrationResponse; + }): PasskeyAuthenticationOptions { + // get registration ceremony + const { registrationResponse } = params; + const regChallenge = this.#getChallengeFromClientData( + registrationResponse.response.clientDataJSON, + ); + const registrationCeremony = + this.#ceremonyManager.getRegistrationCeremony(regChallenge); + if (!registrationCeremony) { + log('No active passkey registration ceremony for challenge'); + throw new PasskeyControllerError( + PasskeyControllerErrorMessage.NoRegistrationCeremony, + { code: PasskeyControllerErrorCode.NoRegistrationCeremony }, + ); + } + + // build auth options + const challenge = randomBytesToBase64URL(32); + const extensions: Record = {}; + if (registrationCeremony.prfSalt) { + extensions.prf = { eval: { first: registrationCeremony.prfSalt } }; + } + const options: PasskeyAuthenticationOptions = { + challenge, + rpId: this.#rpId, + allowCredentials: [ + { + id: registrationResponse.id, + type: 'public-key', + transports: registrationResponse.response.transports as + | AuthenticatorTransportFuture[] + | undefined, + }, + ], + userVerification: 'required', + hints: ['client-device', 'hybrid'], + timeout: WEBAUTHN_TIMEOUT_MS, + extensions, + }; + + // save auth ceremony + this.#ceremonyManager.saveAuthenticationCeremony(challenge, { + challenge, + createdAt: Date.now(), + }); + + return options; + } + + /** + * Builds WebAuthn credential request options for the enrolled passkey. + * + * @returns Public key credential request options for `navigator.credentials.get()`. + */ + generateAuthenticationOptions(): PasskeyAuthenticationOptions { + const record = this.#requireEnrolled(); + + const challenge = randomBytesToBase64URL(32); + + const extensions: Record = {}; + if (record.keyDerivation.method === 'prf') { + extensions.prf = { eval: { first: record.keyDerivation.prfSalt } }; + } + + const options: PasskeyAuthenticationOptions = { + challenge, + rpId: this.#rpId, + allowCredentials: [ + { + id: record.credential.id, + type: 'public-key', + transports: record.credential.transports, + }, + ], + userVerification: 'required', + hints: ['client-device', 'hybrid'], + timeout: WEBAUTHN_TIMEOUT_MS, + extensions, + }; + + this.#ceremonyManager.saveAuthenticationCeremony(challenge, { + challenge, + createdAt: Date.now(), + }); + + return options; + } + + /** + * Verifies registration and post-registration authentication, then stores the + * vault key encrypted under the new passkey. + * + * Fetches the current vault encryption key from KeyringController before wrapping. + * When onboarding is complete, requires `password` for step-up verification first. + * + * @param params - Enrollment completion inputs. + * @param params.registrationResponse - Result of `navigator.credentials.create()`. + * @param params.authenticationResponse - Result of `navigator.credentials.get()` after {@link generatePostRegistrationAuthenticationOptions}. + * @param params.password - Wallet password when onboarding is complete (step-up). + * @returns Resolves when enrollment completes. + */ + async protectVaultKeyWithPasskey(params: { + registrationResponse: PasskeyRegistrationResponse; + authenticationResponse: PasskeyAuthenticationResponse; + password?: string; + }): Promise { + return this.#withOperationLock(() => + this.#protectVaultKeyWithPasskey(params), + ); + } + + async #protectVaultKeyWithPasskey(params: { + registrationResponse: PasskeyRegistrationResponse; + authenticationResponse: PasskeyAuthenticationResponse; + password?: string; + }): Promise { + if (this.isPasskeyEnrolled()) { + throw new PasskeyControllerError( + PasskeyControllerErrorMessage.AlreadyEnrolled, + { code: PasskeyControllerErrorCode.AlreadyEnrolled }, + ); + } + + await this.#assertEnrollmentAllowed(params.password); + const vaultKey = await this.messenger.call( + 'KeyringController:exportEncryptionKey', + ); + + const { registrationResponse, authenticationResponse } = params; + + // get registration ceremony + const challenge = this.#getChallengeFromClientData( + registrationResponse.response.clientDataJSON, + ); + const registrationCeremony = + this.#ceremonyManager.getRegistrationCeremony(challenge); + if (!registrationCeremony) { + log('No active passkey registration ceremony for challenge'); + throw new PasskeyControllerError( + PasskeyControllerErrorMessage.NoRegistrationCeremony, + { code: PasskeyControllerErrorCode.NoRegistrationCeremony }, + ); + } + + try { + // verify registration response + const { verified, registrationInfo } = await verifyRegistrationResponse({ + response: registrationResponse, + expectedChallenge: registrationCeremony.challenge, + expectedOrigin: this.#expectedOrigin, + expectedRPIDs: this.#expectedRPIDs, + requireUserVerification: true, + }).catch((error) => { + log('Error verifying passkey registration response', error); + throw new PasskeyControllerError( + PasskeyControllerErrorMessage.RegistrationVerificationFailed, + { + code: PasskeyControllerErrorCode.RegistrationVerificationFailed, + cause: error instanceof Error ? error : new Error(String(error)), + }, + ); + }); + if (!verified || !registrationInfo) { + log( + 'Passkey registration verification returned unverified or missing registration info', + ); + throw new PasskeyControllerError( + PasskeyControllerErrorMessage.RegistrationVerificationFailed, + { code: PasskeyControllerErrorCode.RegistrationVerificationFailed }, + ); + } + + // verify authentication response + const credential = { + id: registrationInfo.credentialId, + publicKey: bytesToBase64URL(registrationInfo.publicKey), + counter: registrationInfo.counter, + transports: registrationInfo.transports, + aaguid: registrationInfo.aaguid, + }; + const { newCounter } = await this.#verifyAuthenticationResponse( + authenticationResponse, + credential, + ); + + // determine key derivation method + const prfFirst = ( + authenticationResponse.clientExtensionResults as PrfClientExtensionResults + )?.prf?.results?.first; + const authHasPrfOutput = + typeof prfFirst === 'string' && prfFirst.length > 0; + const keyDerivation: PasskeyKeyDerivation = + authHasPrfOutput && registrationCeremony.prfSalt + ? { method: 'prf', prfSalt: registrationCeremony.prfSalt } + : { method: 'userHandle' }; + + if ( + keyDerivation.method === 'userHandle' && + authenticationResponse.response.userHandle !== + registrationCeremony.userHandle + ) { + log( + 'Post-registration assertion userHandle does not match registration ceremony', + ); + throw new PasskeyControllerError( + PasskeyControllerErrorMessage.AuthenticationVerificationFailed, + { code: PasskeyControllerErrorCode.AuthenticationVerificationFailed }, + ); + } + + // derive key and encrypt vault key + const encKey = deriveKeyFromAuthenticationResponse( + authenticationResponse, + { credential, keyDerivation }, + ); + const { ciphertext, iv } = encryptWithKey(vaultKey, encKey); + + // persist passkey record + this.update((state) => { + state.passkeyRecord = { + credential: { + ...credential, + counter: Math.max(newCounter, credential.counter), + }, + encryptedVaultKey: { ciphertext, iv }, + keyDerivation, + }; + }); + } finally { + // delete registration ceremony + this.#ceremonyManager.deleteRegistrationCeremony(challenge); + } + } + + /** + * Verifies an authentication assertion and returns the decrypted vault key. + * + * Prefer orchestrated methods ({@link unlockWithPasskey}, + * {@link exportSeedPhraseWithPasskey}, {@link exportAccountsWithPasskey}) for product + * flows instead of calling KeyringController with the returned key manually. + * + * @param authenticationResponse - Result of `navigator.credentials.get()`. + * @returns The plaintext vault encryption key. + */ + async retrieveVaultKeyWithPasskey( + authenticationResponse: PasskeyAuthenticationResponse, + ): Promise { + return this.#withOperationLock(() => + this.#retrieveVaultKeyWithPasskey(authenticationResponse), + ); + } + + async #retrieveVaultKeyWithPasskey( + authenticationResponse: PasskeyAuthenticationResponse, + ): Promise { + const passkeyRecord = this.#requireEnrolled(); + + // verify authentication response and update counter + const { newCounter } = await this.#verifyAuthenticationResponse( + authenticationResponse, + passkeyRecord.credential, + ); + this.update((state) => { + if (!state.passkeyRecord) { + throw new PasskeyControllerError( + PasskeyControllerErrorMessage.NotEnrolled, + { code: PasskeyControllerErrorCode.NotEnrolled }, + ); + } + state.passkeyRecord.credential.counter = Math.max( + newCounter, + state.passkeyRecord.credential.counter, + ); + }); + + // derive key + const encKey = deriveKeyFromAuthenticationResponse( + authenticationResponse, + passkeyRecord, + ); + + // decrypt vault key + try { + const vaultKey = decryptWithKey( + passkeyRecord.encryptedVaultKey.ciphertext, + passkeyRecord.encryptedVaultKey.iv, + encKey, + ); + return vaultKey; + } catch (cause) { + log( + 'Error decrypting vault key with passkey', + cause instanceof Error ? cause : new Error(String(cause)), + ); + throw new PasskeyControllerError( + PasskeyControllerErrorMessage.VaultKeyDecryptionFailed, + { + code: PasskeyControllerErrorCode.VaultKeyDecryptionFailed, + cause: cause instanceof Error ? cause : new Error(String(cause)), + }, + ); + } + } + + /** + * Unlocks the keyring using a passkey authentication assertion. + * + * @param authenticationResponse - Result of `navigator.credentials.get()`. + * @returns Resolves when the keyring is unlocked. + */ + async unlockWithPasskey( + authenticationResponse: PasskeyAuthenticationResponse, + ): Promise { + return this.#withOperationLock(async () => { + const vaultKey = await this.#retrieveVaultKeyWithPasskey( + authenticationResponse, + ); + await this.messenger.call( + 'KeyringController:submitEncryptionKey', + vaultKey, + ); + }); + } + + /** + * Exports the seed phrase after passkey step-up authentication. + * + * @param authenticationResponse - Result of `navigator.credentials.get()`. + * @param keyringId - Optional keyring id; defaults to the primary HD keyring. + * @returns Raw seed phrase bytes from KeyringController. + */ + async exportSeedPhraseWithPasskey( + authenticationResponse: PasskeyAuthenticationResponse, + keyringId?: string, + ): Promise { + return this.#withOperationLock(async () => { + const vaultKey = await this.#retrieveVaultKeyWithPasskey( + authenticationResponse, + ); + return await this.messenger.call( + 'KeyringController:exportSeedPhrase', + { encryptionKey: vaultKey }, + keyringId, + ); + }); + } + + /** + * Exports private keys for the given addresses after passkey step-up authentication. + * + * @param authenticationResponse - Result of `navigator.credentials.get()`. + * @param addresses - Account addresses to export. + * @returns Private keys in the same order as `addresses`. + */ + async exportAccountsWithPasskey( + authenticationResponse: PasskeyAuthenticationResponse, + addresses: string[], + ): Promise { + return this.#withOperationLock(async () => { + const vaultKey = await this.#retrieveVaultKeyWithPasskey( + authenticationResponse, + ); + + const privateKeys: string[] = []; + for (const address of addresses) { + privateKeys.push( + await this.messenger.call( + 'KeyringController:exportAccount', + { encryptionKey: vaultKey }, + address, + ), + ); + } + return privateKeys; + }); + } + + /** + * Checks whether the given authentication assertion is valid for the enrolled passkey. + * + * On failure, returns `false` for {@link PasskeyControllerError} with a `code`; + * other errors propagate. + * + * @param authenticationResponse - Result of `navigator.credentials.get()`. + * @returns `true` if verification succeeds, otherwise `false`. + */ + async verifyPasskeyAuthentication( + authenticationResponse: PasskeyAuthenticationResponse, + ): Promise { + return this.#withOperationLock(() => + this.#verifyPasskeyAuthentication(authenticationResponse), + ); + } + + async #verifyPasskeyAuthentication( + authenticationResponse: PasskeyAuthenticationResponse, + ): Promise { + try { + await this.#retrieveVaultKeyWithPasskey(authenticationResponse); + return true; + } catch (error: unknown) { + if (error instanceof PasskeyControllerError && error.code !== undefined) { + return false; + } + throw error; + } + } + + /** + * Re-wraps the vault key after rotation. Updates persisted `encryptedVaultKey` on success. + * + * Does not verify WebAuthn or ceremony state—call only after your layer has authenticated + * the user (passkey `get()` + verified assertion, or verified password). On passkey paths, + * pass the same `authenticationResponse` you just verified (e.g. from + * {@link retrieveVaultKeyWithPasskey} / {@link verifyPasskeyAuthentication}). + * + * For password change with passkey step-up, prefer + * {@link changePasswordWithPasskeyVerification}, which orchestrates keyring export, + * `changePassword`, and re-wrap in one call. + * + * @param params - Re-wrap inputs. + * @param params.authenticationResponse - Used to derive the wrapping key. + * @param params.oldVaultKey - Expected current vault key. + * @param params.newVaultKey - New vault key to encrypt under the passkey. + * @returns Resolves when the passkey record is updated. + */ + async renewVaultKeyProtection(params: { + authenticationResponse: PasskeyAuthenticationResponse; + oldVaultKey: string; + newVaultKey: string; + }): Promise { + return this.#withOperationLock(() => this.#renewVaultKeyProtection(params)); + } + + async #renewVaultKeyProtection(params: { + authenticationResponse: PasskeyAuthenticationResponse; + oldVaultKey: string; + newVaultKey: string; + }): Promise { + const { authenticationResponse } = params; + const passkeyRecord = this.#requireEnrolled(); + + // derive key + const encKey = deriveKeyFromAuthenticationResponse( + authenticationResponse, + passkeyRecord, + ); + + // decrypt vault key + let decryptedVaultKey: string; + try { + decryptedVaultKey = decryptWithKey( + passkeyRecord.encryptedVaultKey.ciphertext, + passkeyRecord.encryptedVaultKey.iv, + encKey, + ); + } catch (error) { + log( + 'Error decrypting vault key during passkey vault key renewal', + error instanceof Error ? error : new Error(String(error)), + ); + throw new PasskeyControllerError( + PasskeyControllerErrorMessage.VaultKeyDecryptionFailed, + { + code: PasskeyControllerErrorCode.VaultKeyDecryptionFailed, + cause: error instanceof Error ? error : new Error(String(error)), + }, + ); + } + + // check if vault key matches + const { oldVaultKey, newVaultKey } = params; + if ( + !areUint8ArraysEqual( + stringToBytes(decryptedVaultKey), + stringToBytes(oldVaultKey), + ) + ) { + log( + 'Passkey renewal rejected: decrypted vault key does not match oldVaultKey', + ); + throw new PasskeyControllerError( + PasskeyControllerErrorMessage.VaultKeyMismatch, + { code: PasskeyControllerErrorCode.VaultKeyMismatch }, + ); + } + + // encrypt new vault key + const { ciphertext, iv } = encryptWithKey(newVaultKey, encKey); + + // persist passkey record (mutate current state only for vault key material) + this.update((state) => { + if (!state.passkeyRecord) { + throw new PasskeyControllerError( + PasskeyControllerErrorMessage.NotEnrolled, + { + code: PasskeyControllerErrorCode.NotEnrolled, + }, + ); + } + state.passkeyRecord.encryptedVaultKey = { ciphertext, iv }; + }); + } + + /** + * Changes the wallet password after passkey step-up authentication. + * + * When `renewVaultKeyProtection` is `true` (default), re-wraps the vault key under the + * passkey after rotation. When `false`, removes the passkey instead. + * + * @param params - Change-password inputs. + * @param params.newPassword - New wallet password. + * @param params.authenticationResponse - Result of `navigator.credentials.get()`. + * @param params.options - Optional flow controls. + * @param params.options.renewVaultKeyProtection - Re-wrap vault key after password change. + * @returns Resolves when the password change completes. + */ + async changePasswordWithPasskeyVerification(params: { + newPassword: string; + authenticationResponse: PasskeyAuthenticationResponse; + options?: { renewVaultKeyProtection?: boolean }; + }): Promise { + return this.#withOperationLock(() => + this.#changePasswordWithPasskeyVerification(params), + ); + } + + async #changePasswordWithPasskeyVerification(params: { + newPassword: string; + authenticationResponse: PasskeyAuthenticationResponse; + options?: { renewVaultKeyProtection?: boolean }; + }): Promise { + this.#requireEnrolled(); + + const verified = await this.#verifyPasskeyAuthentication( + params.authenticationResponse, + ); + if (!verified) { + throw new PasskeyControllerError( + PasskeyControllerErrorMessage.AuthenticationVerificationFailed, + { code: PasskeyControllerErrorCode.AuthenticationVerificationFailed }, + ); + } + + const renewVaultKeyProtection = + params.options?.renewVaultKeyProtection ?? true; + + if (!renewVaultKeyProtection) { + await this.messenger.call( + 'KeyringController:changePassword', + params.newPassword, + ); + this.#removePasskey(); + return; + } + + const vaultKeyBefore = await this.messenger.call( + 'KeyringController:exportEncryptionKey', + ); + await this.messenger.call( + 'KeyringController:changePassword', + params.newPassword, + ); + + try { + const vaultKeyAfter = await this.messenger.call( + 'KeyringController:exportEncryptionKey', + ); + await this.#renewVaultKeyProtection({ + authenticationResponse: params.authenticationResponse, + oldVaultKey: vaultKeyBefore, + newVaultKey: vaultKeyAfter, + }); + } catch (error) { + this.#removePasskey(); + throw new PasskeyControllerError( + PasskeyControllerErrorMessage.VaultKeyRenewalFailed, + { + code: PasskeyControllerErrorCode.VaultKeyRenewalFailed, + cause: error instanceof Error ? error : new Error(String(error)), + }, + ); + } + } + + /** + * Removes the enrolled passkey after verifying a passkey authentication assertion. + * + * @param authenticationResponse - Result of `navigator.credentials.get()`. + * @returns Resolves when the passkey is removed. + */ + async removePasskeyWithPasskeyVerification( + authenticationResponse: PasskeyAuthenticationResponse, + ): Promise { + return this.#withOperationLock(() => + this.#removePasskeyWithPasskeyVerification(authenticationResponse), + ); + } + + async #removePasskeyWithPasskeyVerification( + authenticationResponse: PasskeyAuthenticationResponse, + ): Promise { + this.#requireEnrolled(); + + const verified = await this.#verifyPasskeyAuthentication( + authenticationResponse, + ); + if (!verified) { + throw new PasskeyControllerError( + PasskeyControllerErrorMessage.AuthenticationVerificationFailed, + { code: PasskeyControllerErrorCode.AuthenticationVerificationFailed }, + ); + } + + this.#removePasskey(); + } + + /** + * Removes the enrolled passkey after verifying the wallet password. + * + * @param password - Wallet password for step-up verification. + * @returns Resolves when the passkey is removed. + */ + async removePasskeyWithPasswordVerification(password: string): Promise { + return this.#withOperationLock(() => + this.#removePasskeyWithPasswordVerification(password), + ); + } + + async #removePasskeyWithPasswordVerification( + password: string, + ): Promise { + this.#requireEnrolled(); + await this.messenger.call('KeyringController:verifyPassword', password); + this.#removePasskey(); + } + + /** + * Resets state and clears in-flight registration/authentication ceremonies. + * + * For user-facing passkey removal with step-up, use + * {@link removePasskeyWithPasskeyVerification} or + * {@link removePasskeyWithPasswordVerification}. + */ + clearState(): void { + this.#removePasskey(); + } + + /** + * Releases all in-flight ceremony state and tears down the messenger. + */ + destroy(): void { + this.#ceremonyManager.clear(); + super.destroy(); + } + + /** + * Validates a WebAuthn authentication response against stored credential data. + * + * @param authenticationResponse - Parsed authentication response from the client. + * @param credential - Credential identifiers and public key material for verification. + * @returns Updated authenticator signature counter. + */ + async #verifyAuthenticationResponse( + authenticationResponse: PasskeyAuthenticationResponse, + credential: PasskeyCredentialInfo, + ): Promise<{ newCounter: number }> { + // get challenge + const challenge = this.#getChallengeFromClientData( + authenticationResponse.response.clientDataJSON, + ); + + // get authentication ceremony + const authenticationCeremony = + this.#ceremonyManager.getAuthenticationCeremony(challenge); + if (!authenticationCeremony) { + log('No active passkey authentication ceremony for challenge'); + throw new PasskeyControllerError( + PasskeyControllerErrorMessage.NoAuthenticationCeremony, + { code: PasskeyControllerErrorCode.NoAuthenticationCeremony }, + ); + } + + try { + // verify authentication response + const result = await verifyAuthenticationResponse({ + response: authenticationResponse, + expectedChallenge: authenticationCeremony.challenge, + expectedOrigin: this.#expectedOrigin, + expectedRPIDs: this.#expectedRPIDs, + credential: { + id: credential.id, + publicKey: base64URLToBytes(credential.publicKey), + counter: credential.counter, + transports: credential.transports, + }, + requireUserVerification: true, + }).catch((error) => { + log( + 'Error verifying passkey authentication response', + error instanceof Error ? error : new Error(String(error)), + ); + throw new PasskeyControllerError( + PasskeyControllerErrorMessage.AuthenticationVerificationFailed, + { + code: PasskeyControllerErrorCode.AuthenticationVerificationFailed, + cause: error instanceof Error ? error : new Error(String(error)), + }, + ); + }); + if (!result.verified) { + log('Passkey authentication verification returned unverified'); + throw new PasskeyControllerError( + PasskeyControllerErrorMessage.AuthenticationVerificationFailed, + { + code: PasskeyControllerErrorCode.AuthenticationVerificationFailed, + }, + ); + } + + return { newCounter: result.authenticationInfo.newCounter }; + } finally { + // delete authentication ceremony + this.#ceremonyManager.deleteAuthenticationCeremony(challenge); + } + } + + /** + * Serializes orchestrated passkey operations that mutate state or call KeyringController. + * + * @param callback - Operation to run while the mutex is held. + * @returns The result of the callback. + */ + async #withOperationLock( + callback: () => Promise, + ): Promise { + return this.#operationMutex.runExclusive(callback); + } + + async #assertEnrollmentAllowed(password?: string): Promise { + if (!this.#getIsOnboardingCompleted()) { + return; + } + + if (!password) { + throw new PasskeyControllerError( + PasskeyControllerErrorMessage.EnrollmentPasswordRequired, + { + code: PasskeyControllerErrorCode.EnrollmentPasswordRequired, + }, + ); + } + + await this.messenger.call('KeyringController:verifyPassword', password); + } + + #requireEnrolled(): PasskeyRecord { + const record = this.state.passkeyRecord; + if (!record) { + throw new PasskeyControllerError( + PasskeyControllerErrorMessage.NotEnrolled, + { + code: PasskeyControllerErrorCode.NotEnrolled, + }, + ); + } + return record; + } + + #getChallengeFromClientData(clientDataJSON: string): string { + return decodeClientDataJSON(clientDataJSON).challenge; + } + + /** + * Clears enrolled passkey state and in-flight ceremonies. + */ + #removePasskey(): void { + this.update(() => getDefaultPasskeyControllerState()); + this.#ceremonyManager.clear(); + } +} diff --git a/packages/passkey-controller/src/ceremony-manager.test.ts b/packages/passkey-controller/src/ceremony-manager.test.ts new file mode 100644 index 00000000000..90d21e143b8 --- /dev/null +++ b/packages/passkey-controller/src/ceremony-manager.test.ts @@ -0,0 +1,176 @@ +import { + CEREMONY_MAX_AGE_MS, + CeremonyManager, + MAX_CONCURRENT_PASSKEY_CEREMONIES, +} from './ceremony-manager.js'; + +describe('CeremonyManager', () => { + const baseReg = { userHandle: 'u' }; + + beforeEach(() => { + jest.useFakeTimers(); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + describe('registration flow', () => { + it('save stores ceremony state retrievable by challenge', () => { + const manager = new CeremonyManager(); + const now = 1_000_000; + jest.setSystemTime(now); + manager.saveRegistrationCeremony('chal1', { + ...baseReg, + challenge: 'chal1', + createdAt: now, + }); + expect(manager.getRegistrationCeremony('chal1')).toMatchObject({ + challenge: 'chal1', + createdAt: now, + }); + }); + + it('getRegistrationCeremony prunes entries older than CEREMONY_MAX_AGE_MS before lookup', () => { + const manager = new CeremonyManager(); + const tOld = 100_000; + const tNew = 150_000; + const pruneAt = tOld + CEREMONY_MAX_AGE_MS + 1; + jest.setSystemTime(tOld); + manager.saveRegistrationCeremony('old', { + ...baseReg, + challenge: 'old', + createdAt: tOld, + }); + jest.setSystemTime(tNew); + manager.saveRegistrationCeremony('new', { + ...baseReg, + challenge: 'new', + createdAt: tNew, + }); + jest.setSystemTime(pruneAt); + expect(pruneAt - tOld).toBeGreaterThan(CEREMONY_MAX_AGE_MS); + expect(manager.getRegistrationCeremony('new')).toMatchObject({ + challenge: 'new', + createdAt: tNew, + }); + expect(manager.getRegistrationCeremony('old')).toBeUndefined(); + }); + + it('evicts oldest createdAt when at capacity', () => { + const manager = new CeremonyManager(); + const cap = MAX_CONCURRENT_PASSKEY_CEREMONIES; + for (let i = 0; i <= cap; i += 1) { + const createdAt = 10 + i; + jest.setSystemTime(createdAt); + manager.saveRegistrationCeremony(`k${i}`, { + ...baseReg, + challenge: `k${i}`, + createdAt, + }); + } + expect(manager.getRegistrationCeremony('k0')).toBeUndefined(); + expect(manager.getRegistrationCeremony('k1')).toMatchObject({ + challenge: 'k1', + createdAt: 11, + }); + }); + + it('still saves when at capacity and all existing ceremonies have NaN createdAt', () => { + const manager = new CeremonyManager(); + const cap = MAX_CONCURRENT_PASSKEY_CEREMONIES; + for (let i = 0; i < cap; i += 1) { + jest.setSystemTime(1000 + i); + manager.saveRegistrationCeremony(`k${i}`, { + ...baseReg, + challenge: `k${i}`, + createdAt: Number.NaN, + }); + } + jest.setSystemTime(5000); + manager.saveRegistrationCeremony('newest', { + ...baseReg, + challenge: 'newest', + createdAt: 5000, + }); + expect(manager.getRegistrationCeremony('newest')).toMatchObject({ + challenge: 'newest', + createdAt: 5000, + }); + }); + + it('delete removes a single entry', () => { + const manager = new CeremonyManager(); + jest.setSystemTime(0); + manager.saveRegistrationCeremony('x', { + ...baseReg, + challenge: 'x', + createdAt: 0, + }); + expect(manager.deleteRegistrationCeremony('x')).toBe(true); + expect(manager.getRegistrationCeremony('x')).toBeUndefined(); + expect(manager.deleteRegistrationCeremony('missing')).toBe(false); + }); + + it('clear removes registration entries', () => { + const manager = new CeremonyManager(); + jest.setSystemTime(0); + manager.saveRegistrationCeremony('a', { + ...baseReg, + challenge: 'a', + createdAt: 0, + }); + manager.saveRegistrationCeremony('b', { + ...baseReg, + challenge: 'b', + createdAt: 0, + }); + manager.clear(); + expect(manager.getRegistrationCeremony('a')).toBeUndefined(); + expect(manager.getRegistrationCeremony('b')).toBeUndefined(); + }); + }); + + it('registration and authentication maps are independent', () => { + const manager = new CeremonyManager(); + const now = 1_000_000; + jest.setSystemTime(now); + + manager.saveRegistrationCeremony('reg-chal', { + userHandle: 'uh', + challenge: 'reg-chal', + createdAt: now, + }); + manager.saveAuthenticationCeremony('auth-chal', { + challenge: 'auth-chal', + createdAt: now, + }); + + expect(manager.getRegistrationCeremony('reg-chal')).toBeDefined(); + expect(manager.getAuthenticationCeremony('auth-chal')).toBeDefined(); + + jest.setSystemTime(now + CEREMONY_MAX_AGE_MS + 1); + expect(manager.getRegistrationCeremony('reg-chal')).toBeUndefined(); + + jest.setSystemTime(now); + manager.saveRegistrationCeremony('reg2', { + userHandle: 'uh2', + challenge: 'reg2', + createdAt: now, + }); + jest.setSystemTime(now + CEREMONY_MAX_AGE_MS + 1); + expect(manager.getAuthenticationCeremony('auth-chal')).toBeUndefined(); + + jest.setSystemTime(now); + manager.saveAuthenticationCeremony('auth2', { + challenge: 'auth2', + createdAt: now, + }); + expect(manager.deleteRegistrationCeremony('reg2')).toBe(true); + expect(manager.deleteAuthenticationCeremony('auth2')).toBe(true); + + manager.clear(); + expect(manager.getRegistrationCeremony('reg2')).toBeUndefined(); + expect(manager.getAuthenticationCeremony('auth2')).toBeUndefined(); + }); +}); diff --git a/packages/passkey-controller/src/ceremony-manager.ts b/packages/passkey-controller/src/ceremony-manager.ts new file mode 100644 index 00000000000..71bce5f686f --- /dev/null +++ b/packages/passkey-controller/src/ceremony-manager.ts @@ -0,0 +1,171 @@ +import type { + PasskeyAuthenticationCeremony, + PasskeyRegistrationCeremony, +} from './types.js'; + +/** WebAuthn `timeout` for credential creation and assertion (ms). */ +export const WEBAUTHN_TIMEOUT_MS = 60_000; + +/** + * Extra allowance beyond {@link WEBAUTHN_TIMEOUT_MS} before in-memory + * ceremony state is discarded (covers slow UX, multi-step enrollment, and + * clock skew). + */ +export const CEREMONY_TTL_SLACK_MS = 120_000; + +/** + * Maximum age for in-flight registration or authentication ceremony state + * (between options and verified response). This bounds the lifetime of a + * single WebAuthn ceremony only; it is not a user login session timeout. + */ +export const CEREMONY_MAX_AGE_MS = WEBAUTHN_TIMEOUT_MS + CEREMONY_TTL_SLACK_MS; + +/** + * Upper bound on concurrent in-memory ceremonies per flow type (registration + * vs authentication), for abuse / leak protection. + */ +export const MAX_CONCURRENT_PASSKEY_CEREMONIES = 16; + +type CeremonyFlow = 'registration' | 'authentication'; + +/** + * In-memory store for in-flight WebAuthn ceremonies (registration vs authentication), + * keyed by base64url challenge. Enforces TTL and a per-flow size cap; not user session state. + */ +export class CeremonyManager { + readonly #registrationMap = new Map(); + + readonly #authenticationMap = new Map< + string, + PasskeyAuthenticationCeremony + >(); + + /** + * Challenge-keyed map for prune/capacity helpers. + * + * @param ceremonyType - Which in-flight ceremony map to use. + * @returns The registration or authentication ceremony map for the given flow. + */ + #getMap( + ceremonyType: CeremonyFlow, + ): Map { + return ceremonyType === 'registration' + ? this.#registrationMap + : this.#authenticationMap; + } + + #pruneExpired(ceremonyType: CeremonyFlow): void { + const now = Date.now(); + const map = this.#getMap(ceremonyType); + for (const [key, ceremony] of map) { + if (now - ceremony.createdAt > CEREMONY_MAX_AGE_MS) { + map.delete(key); + } + } + } + + /** + * Removes the oldest entry (by `createdAt`) until size is below the cap. + * + * @param ceremonyType - Which in-flight ceremony map to evict from. + */ + #enforceCapacity(ceremonyType: CeremonyFlow): void { + const map = this.#getMap(ceremonyType); + while (map.size >= MAX_CONCURRENT_PASSKEY_CEREMONIES) { + let oldestKey: string | undefined; + let oldestTime = Infinity; + for (const [mapKey, ceremony] of map) { + if (ceremony.createdAt < oldestTime) { + oldestTime = ceremony.createdAt; + oldestKey = mapKey; + } + } + if (oldestKey === undefined) { + break; + } + map.delete(oldestKey); + } + } + + /** + * Records registration ceremony state after pruning expired rows and evicting oldest if at cap. + * + * @param challenge - Same base64url challenge as in the creation options `challenge` field. + * @param ceremony - Payload to retrieve when the registration response returns. + */ + saveRegistrationCeremony( + challenge: string, + ceremony: PasskeyRegistrationCeremony, + ): void { + this.#pruneExpired('registration'); + this.#enforceCapacity('registration'); + this.#registrationMap.set(challenge, ceremony); + } + + /** + * Records authentication ceremony state after pruning expired rows and evicting oldest if at cap. + * + * @param challenge - Same base64url challenge as in the request options `challenge` field. + * @param ceremony - Payload to retrieve when the assertion response returns. + */ + saveAuthenticationCeremony( + challenge: string, + ceremony: PasskeyAuthenticationCeremony, + ): void { + this.#pruneExpired('authentication'); + this.#enforceCapacity('authentication'); + this.#authenticationMap.set(challenge, ceremony); + } + + /** + * Returns registration ceremony for a challenge, pruning expired entries on this map first. + * + * @param challenge - Base64url challenge from decoded `clientDataJSON` (matches stored key). + * @returns Stored ceremony, or `undefined` if none or expired. + */ + getRegistrationCeremony( + challenge: string, + ): PasskeyRegistrationCeremony | undefined { + this.#pruneExpired('registration'); + return this.#registrationMap.get(challenge); + } + + /** + * Returns authentication ceremony for a challenge, pruning expired entries on this map first. + * + * @param challenge - Base64url challenge from decoded `clientDataJSON` (matches stored key). + * @returns Stored ceremony, or `undefined` if none or expired. + */ + getAuthenticationCeremony( + challenge: string, + ): PasskeyAuthenticationCeremony | undefined { + this.#pruneExpired('authentication'); + return this.#authenticationMap.get(challenge); + } + + /** + * Removes a registration ceremony by challenge. + * + * @param challenge - Map key for the ceremony to remove. + * @returns Whether an entry was deleted. + */ + deleteRegistrationCeremony(challenge: string): boolean { + return this.#registrationMap.delete(challenge); + } + + /** + * Removes an authentication ceremony by challenge. + * + * @param challenge - Map key for the ceremony to remove. + * @returns Whether an entry was deleted. + */ + deleteAuthenticationCeremony(challenge: string): boolean { + return this.#authenticationMap.delete(challenge); + } + + /** Drops all in-flight registration and authentication ceremonies. */ + clear(): void { + this.#registrationMap.clear(); + this.#authenticationMap.clear(); + } +} diff --git a/packages/passkey-controller/src/constants.ts b/packages/passkey-controller/src/constants.ts new file mode 100644 index 00000000000..098f10787bb --- /dev/null +++ b/packages/passkey-controller/src/constants.ts @@ -0,0 +1,39 @@ +export const controllerName = 'PasskeyController'; + +/** + * Stable programmatic codes for {@link PasskeyControllerError}. + * Use these instead of matching `message` strings. + */ +export const PasskeyControllerErrorCode = { + NotEnrolled: 'not_enrolled', + AlreadyEnrolled: 'already_enrolled', + NoRegistrationCeremony: 'no_registration_ceremony', + RegistrationVerificationFailed: 'registration_verification_failed', + NoAuthenticationCeremony: 'no_authentication_ceremony', + AuthenticationVerificationFailed: 'authentication_verification_failed', + MissingKeyMaterial: 'missing_key_material', + VaultKeyDecryptionFailed: 'vault_key_decryption_failed', + VaultKeyMismatch: 'vault_key_mismatch', + VaultKeyRenewalFailed: 'vault_key_renewal_failed', + EnrollmentPasswordRequired: 'enrollment_password_required', +} as const; + +export type PasskeyControllerErrorCode = + (typeof PasskeyControllerErrorCode)[keyof typeof PasskeyControllerErrorCode]; + +/** + * Human-readable messages for {@link PasskeyControllerError}. + */ +export enum PasskeyControllerErrorMessage { + NotEnrolled = `${controllerName} - Passkey is not enrolled`, + AlreadyEnrolled = `${controllerName} - Passkey is already enrolled`, + NoRegistrationCeremony = `${controllerName} - No active passkey registration ceremony`, + RegistrationVerificationFailed = `${controllerName} - Passkey registration verification failed`, + NoAuthenticationCeremony = `${controllerName} - No active passkey authentication ceremony`, + AuthenticationVerificationFailed = `${controllerName} - Passkey authentication verification failed`, + MissingKeyMaterial = `${controllerName} - Passkey assertion missing required key material`, + VaultKeyDecryptionFailed = `${controllerName} - Passkey vault key decryption failed`, + VaultKeyMismatch = `${controllerName} - Passkey authentication does not match the current vault key`, + EnrollmentPasswordRequired = `${controllerName} - Password required to register passkey`, + VaultKeyRenewalFailed = `${controllerName} - Passkey vault key renewal failed`, +} diff --git a/packages/passkey-controller/src/errors.test.ts b/packages/passkey-controller/src/errors.test.ts new file mode 100644 index 00000000000..ccc06f504ab --- /dev/null +++ b/packages/passkey-controller/src/errors.test.ts @@ -0,0 +1,79 @@ +import { + PasskeyControllerErrorCode, + PasskeyControllerErrorMessage, +} from './constants.js'; +import { PasskeyControllerError } from './errors.js'; + +describe('PasskeyControllerError', () => { + it('sets code and cause from options', () => { + const cause = new Error('inner'); + const controllerError = new PasskeyControllerError( + PasskeyControllerErrorMessage.VaultKeyDecryptionFailed, + { + code: PasskeyControllerErrorCode.VaultKeyDecryptionFailed, + cause, + }, + ); + expect(controllerError.code).toBe( + PasskeyControllerErrorCode.VaultKeyDecryptionFailed, + ); + expect(controllerError.cause).toBe(cause); + expect(controllerError.toJSON()).toMatchObject({ + name: 'PasskeyControllerError', + code: PasskeyControllerErrorCode.VaultKeyDecryptionFailed, + message: PasskeyControllerErrorMessage.VaultKeyDecryptionFailed, + }); + }); + + it('supports Error as second argument for cause', () => { + const inner = new Error('x'); + const controllerError = new PasskeyControllerError('msg', inner); + expect(controllerError.cause).toBe(inner); + }); + + it('sets context from options', () => { + const controllerError = new PasskeyControllerError('msg', { + code: PasskeyControllerErrorCode.NotEnrolled, + context: { detail: 'x' }, + }); + expect(controllerError.context).toStrictEqual({ detail: 'x' }); + expect(controllerError.toJSON().context).toStrictEqual({ detail: 'x' }); + }); + + it('serializes cause in toJSON', () => { + const cause = new Error('inner'); + const controllerError = new PasskeyControllerError('msg', { + code: PasskeyControllerErrorCode.NotEnrolled, + cause, + }); + expect(controllerError.toJSON().cause).toMatchObject({ + name: 'Error', + message: 'inner', + }); + }); + + it('toString includes code when set', () => { + const controllerError = new PasskeyControllerError('msg', { + code: PasskeyControllerErrorCode.NotEnrolled, + }); + expect(controllerError.toString()).toContain('[not_enrolled]'); + }); + + it('toString includes cause when set', () => { + const cause = new Error('inner'); + const controllerError = new PasskeyControllerError('msg', { cause }); + expect(controllerError.toString()).toContain('Caused by:'); + expect(controllerError.toString()).toContain('inner'); + }); + + it('toString includes code and cause when both are set', () => { + const cause = new Error('inner'); + const controllerError = new PasskeyControllerError('msg', { + code: PasskeyControllerErrorCode.NotEnrolled, + cause, + }); + const text = controllerError.toString(); + expect(text).toContain('[not_enrolled]'); + expect(text).toContain('Caused by:'); + }); +}); diff --git a/packages/passkey-controller/src/errors.ts b/packages/passkey-controller/src/errors.ts new file mode 100644 index 00000000000..f6c08c25abb --- /dev/null +++ b/packages/passkey-controller/src/errors.ts @@ -0,0 +1,86 @@ +import type { PasskeyControllerErrorCode as PasskeyControllerErrorCodeType } from './constants.js'; + +/** + * Options for creating a {@link PasskeyControllerError}. + */ +export type PasskeyControllerErrorOptions = { + /** + * The underlying error that caused this error (for error chaining). + */ + cause?: Error; + /** + * Stable code for programmatic handling (see {@link PasskeyControllerErrorCode}). + */ + code?: PasskeyControllerErrorCodeType; + /** + * Additional context for debugging or reporting. + */ + context?: Record; +}; + +/** + * Error class for PasskeyController-related errors. + */ +export class PasskeyControllerError extends Error { + code?: PasskeyControllerErrorCodeType; + + context?: Record; + + cause?: Error; + + /** + * @param message - The error message. + * @param options - Error options or an `Error` instance used as `cause` (Keyring-style overload). + */ + constructor( + message: string, + options?: PasskeyControllerErrorOptions | Error, + ) { + super(message); + this.name = 'PasskeyControllerError'; + + const cause = options instanceof Error ? options : options?.cause; + const code = options instanceof Error ? undefined : options?.code; + const context = options instanceof Error ? undefined : options?.context; + + if (cause) { + this.cause = cause; + } + if (code) { + this.code = code; + } + if (context) { + this.context = context; + } + + Object.setPrototypeOf(this, PasskeyControllerError.prototype); + } + + toJSON(): Record { + return { + name: this.name, + message: this.message, + code: this.code, + context: this.context, + stack: this.stack, + cause: this.cause + ? { + name: this.cause.name, + message: this.cause.message, + stack: this.cause.stack, + } + : undefined, + }; + } + + override toString(): string { + let result = `${this.name}: ${this.message}`; + if (this.code) { + result += ` [${this.code}]`; + } + if (this.cause) { + result += `\n Caused by: ${this.cause}`; + } + return result; + } +} diff --git a/packages/passkey-controller/src/index.ts b/packages/passkey-controller/src/index.ts new file mode 100644 index 00000000000..aead60c4c12 --- /dev/null +++ b/packages/passkey-controller/src/index.ts @@ -0,0 +1,50 @@ +export { + PasskeyControllerErrorCode, + PasskeyControllerErrorMessage, +} from './constants.js'; +export { PasskeyControllerError } from './errors.js'; +export { + PasskeyController, + getDefaultPasskeyControllerState, + passkeyControllerSelectors, +} from './PasskeyController.js'; +export type { + PasskeyControllerState, + PasskeyControllerMessenger, + PasskeyControllerOptions, + PasskeyControllerGetStateAction, + PasskeyControllerActions, + PasskeyControllerStateChangedEvent, + PasskeyControllerEvents, + PasskeyCredentialInfo, + PasskeyDerivationMethod, + PasskeyKeyDerivation, + PasskeyRecord, + PrfEvalExtension, + PrfClientExtensionResults, +} from './types.js'; +export type { + PasskeyRegistrationOptions, + PasskeyRegistrationResponse, + PasskeyAuthenticationOptions, + PasskeyAuthenticationResponse, +} from './webauthn/types.js'; +export { getAAGUIDFromRegistrationResponse } from './webauthn/verify-registration-response.js'; +export type { + PasskeyControllerIsPasskeyEnrolledAction, + PasskeyControllerGenerateRegistrationOptionsAction, + PasskeyControllerGeneratePostRegistrationAuthenticationOptionsAction, + PasskeyControllerGenerateAuthenticationOptionsAction, + PasskeyControllerProtectVaultKeyWithPasskeyAction, + PasskeyControllerRetrieveVaultKeyWithPasskeyAction, + PasskeyControllerUnlockWithPasskeyAction, + PasskeyControllerExportSeedPhraseWithPasskeyAction, + PasskeyControllerExportAccountsWithPasskeyAction, + PasskeyControllerVerifyPasskeyAuthenticationAction, + PasskeyControllerRenewVaultKeyProtectionAction, + PasskeyControllerChangePasswordWithPasskeyVerificationAction, + PasskeyControllerRemovePasskeyWithPasskeyVerificationAction, + PasskeyControllerRemovePasskeyWithPasswordVerificationAction, + PasskeyControllerClearStateAction, + PasskeyControllerDestroyAction, +} from './PasskeyController-method-action-types.js'; diff --git a/packages/passkey-controller/src/key-derivation.test.ts b/packages/passkey-controller/src/key-derivation.test.ts new file mode 100644 index 00000000000..d8b89b205df --- /dev/null +++ b/packages/passkey-controller/src/key-derivation.test.ts @@ -0,0 +1,154 @@ +import { PasskeyControllerErrorMessage } from './constants.js'; +import { deriveKeyFromAuthenticationResponse } from './key-derivation.js'; +import { PasskeyRecord } from './types.js'; +import { deriveEncryptionKey } from './utils/crypto.js'; +import { base64URLToBytes } from './utils/encoding.js'; +import type { PasskeyAuthenticationResponse } from './webauthn/types.js'; + +function b64url(str: string): string { + return btoa(str) + .replace(/\+/gu, '-') + .replace(/\//gu, '_') + .replace(/[=]+$/u, ''); +} + +const CREDENTIAL_ID = b64url('credential-id-bytes'); +const USER_HANDLE = b64url('user-handle-bytes'); +const PRF_SALT = b64url('prf-salt-bytes'); +const PRF_FIRST = b64url('prf-output-bytes'); + +function makeAuthenticationResponse( + extensionResults: Record, + userHandle?: string, +): PasskeyAuthenticationResponse { + return { + id: CREDENTIAL_ID, + rawId: CREDENTIAL_ID, + type: 'public-key', + response: { + clientDataJSON: '', + authenticatorData: '', + signature: '', + userHandle, + }, + clientExtensionResults: extensionResults, + }; +} + +function makeRecord( + derivationMethod: 'prf' | 'userHandle', +): Pick { + return { + credential: { + id: CREDENTIAL_ID, + publicKey: 'pubkey', + counter: 0, + aaguid: '00000000-0000-0000-0000-000000000000', + }, + keyDerivation: + derivationMethod === 'prf' + ? { method: 'prf', prfSalt: PRF_SALT } + : { method: 'userHandle' }, + }; +} + +describe('deriveKeyFromAuthenticationResponse', () => { + it('uses PRF output when keyDerivation.method is prf', () => { + const response = makeAuthenticationResponse( + { prf: { results: { first: PRF_FIRST } } }, + USER_HANDLE, + ); + + const encKey = deriveKeyFromAuthenticationResponse( + response, + makeRecord('prf'), + ); + + expect(encKey).toBeInstanceOf(Uint8Array); + expect(encKey).toHaveLength(32); + }); + + it('uses userHandle when keyDerivation.method is userHandle', () => { + const response = makeAuthenticationResponse({}, USER_HANDLE); + + const encKey = deriveKeyFromAuthenticationResponse( + response, + makeRecord('userHandle'), + ); + + expect(encKey).toBeInstanceOf(Uint8Array); + expect(encKey).toHaveLength(32); + }); + + it('throws when userHandle derivation is needed but userHandle is missing', () => { + const response = makeAuthenticationResponse({}); + + expect(() => + deriveKeyFromAuthenticationResponse(response, makeRecord('userHandle')), + ).toThrow(PasskeyControllerErrorMessage.MissingKeyMaterial); + }); + + it('throws when PRF derivation is needed but PRF output is missing', () => { + const response = makeAuthenticationResponse({}); + + expect(() => + deriveKeyFromAuthenticationResponse(response, makeRecord('prf')), + ).toThrow(PasskeyControllerErrorMessage.MissingKeyMaterial); + }); + + it('throws when PRF derivation is needed but prf.results.first is empty', () => { + const response = makeAuthenticationResponse({ + prf: { results: { first: '' } }, + }); + + expect(() => + deriveKeyFromAuthenticationResponse(response, makeRecord('prf')), + ).toThrow(PasskeyControllerErrorMessage.MissingKeyMaterial); + }); + + it('userHandle wrapping key matches HKDF of assertion userHandle and credential id', () => { + const response = makeAuthenticationResponse({}, USER_HANDLE); + const expected = deriveEncryptionKey( + base64URLToBytes(USER_HANDLE), + base64URLToBytes(CREDENTIAL_ID), + ); + const encKey = deriveKeyFromAuthenticationResponse( + response, + makeRecord('userHandle'), + ); + expect(encKey).toStrictEqual(expected); + }); + + it('prf wrapping key matches HKDF of PRF output and credential id', () => { + const response = makeAuthenticationResponse({ + prf: { results: { first: PRF_FIRST } }, + }); + const expected = deriveEncryptionKey( + base64URLToBytes(PRF_FIRST), + base64URLToBytes(CREDENTIAL_ID), + ); + const encKey = deriveKeyFromAuthenticationResponse( + response, + makeRecord('prf'), + ); + expect(encKey).toStrictEqual(expected); + }); + + it('produces different keys for PRF vs userHandle', () => { + const prfResponse = makeAuthenticationResponse({ + prf: { results: { first: PRF_FIRST } }, + }); + const uhResponse = makeAuthenticationResponse({}, USER_HANDLE); + + const prfKey = deriveKeyFromAuthenticationResponse( + prfResponse, + makeRecord('prf'), + ); + const uhKey = deriveKeyFromAuthenticationResponse( + uhResponse, + makeRecord('userHandle'), + ); + + expect(prfKey).not.toStrictEqual(uhKey); + }); +}); diff --git a/packages/passkey-controller/src/key-derivation.ts b/packages/passkey-controller/src/key-derivation.ts new file mode 100644 index 00000000000..b9381d7ec56 --- /dev/null +++ b/packages/passkey-controller/src/key-derivation.ts @@ -0,0 +1,56 @@ +import { + PasskeyControllerErrorCode, + PasskeyControllerErrorMessage, +} from './constants.js'; +import { PasskeyControllerError } from './errors.js'; +import type { PasskeyRecord, PrfClientExtensionResults } from './types.js'; +import { deriveEncryptionKey } from './utils/crypto.js'; +import { base64URLToBytes } from './utils/encoding.js'; +import type { PasskeyAuthenticationResponse } from './webauthn/types.js'; + +/** + * Derives an AES-256 wrapping key from a WebAuthn authentication ceremony + * response. + * + * The derivation method is determined by `record.keyDerivation`: + * - `prf` -- uses the PRF evaluation result from `clientExtensionResults`. + * - `userHandle` -- uses the `userHandle` returned in the assertion. + * + * @param authenticationResponse - The authentication credential result + * from `navigator.credentials.get()`. + * @param record - Credential id and key derivation parameters (ciphertext is + * not read). + * @returns The derived 32-byte AES wrapping key. + * @throws {@link PasskeyControllerError} with code `missing_key_material` if the + * required key material (PRF result or userHandle) is missing from the response. + */ +export function deriveKeyFromAuthenticationResponse( + authenticationResponse: PasskeyAuthenticationResponse, + record: Pick, +): Uint8Array { + const { userHandle } = authenticationResponse.response; + const prfFirst = ( + authenticationResponse.clientExtensionResults as PrfClientExtensionResults + )?.prf?.results?.first; + const hasPrfOutput = typeof prfFirst === 'string' && prfFirst.length > 0; + + let ikm: Uint8Array; + if (record.keyDerivation.method === 'prf') { + if (!hasPrfOutput) { + throw new PasskeyControllerError( + PasskeyControllerErrorMessage.MissingKeyMaterial, + { code: PasskeyControllerErrorCode.MissingKeyMaterial }, + ); + } + ikm = base64URLToBytes(prfFirst); + } else if (userHandle) { + ikm = base64URLToBytes(userHandle); + } else { + throw new PasskeyControllerError( + PasskeyControllerErrorMessage.MissingKeyMaterial, + { code: PasskeyControllerErrorCode.MissingKeyMaterial }, + ); + } + + return deriveEncryptionKey(ikm, base64URLToBytes(record.credential.id)); +} diff --git a/packages/passkey-controller/src/logger.ts b/packages/passkey-controller/src/logger.ts new file mode 100644 index 00000000000..daf14c8fe3d --- /dev/null +++ b/packages/passkey-controller/src/logger.ts @@ -0,0 +1,9 @@ +/* istanbul ignore file */ + +import { createProjectLogger, createModuleLogger } from '@metamask/utils'; + +import { controllerName } from './constants.js'; + +export const projectLogger = createProjectLogger(controllerName); + +export { createModuleLogger }; diff --git a/packages/passkey-controller/src/types.ts b/packages/passkey-controller/src/types.ts new file mode 100644 index 00000000000..4a6ba925a32 --- /dev/null +++ b/packages/passkey-controller/src/types.ts @@ -0,0 +1,185 @@ +import type { + ControllerGetStateAction, + ControllerStateChangedEvent, +} from '@metamask/base-controller'; +import type { + KeyringControllerChangePasswordAction, + KeyringControllerExportAccountAction, + KeyringControllerExportEncryptionKeyAction, + KeyringControllerExportSeedPhraseAction, + KeyringControllerSubmitEncryptionKeyAction, + KeyringControllerVerifyPasswordAction, +} from '@metamask/keyring-controller'; +import type { Messenger } from '@metamask/messenger'; + +import { controllerName } from './constants.js'; +import type { PasskeyControllerMethodActions } from './PasskeyController-method-action-types.js'; + +export type Base64String = string; + +export type Base64URLString = string; + +export type AuthenticatorTransportFuture = + | 'ble' + | 'cable' + | 'hybrid' + | 'internal' + | 'nfc' + | 'smart-card' + | 'usb'; + +/** + * WebAuthn credential metadata used to identify the passkey and verify + * subsequent assertions. + */ +export type PasskeyCredentialInfo = { + /** WebAuthn credential ID (base64url). */ + id: Base64URLString; + /** COSE-encoded credential public key (base64url) used to verify assertions. */ + publicKey: Base64URLString; + /** Authenticator signature counter for replay/clone detection. */ + counter: number; + /** Authenticator transports hint for `allowCredentials`. */ + transports?: AuthenticatorTransportFuture[]; + /** Authenticator AAGUID captured from attested credential data at registration. */ + aaguid: string; +}; + +/** + * Vault key wrapped under the passkey-derived AES-256-GCM key. + */ +export type EncryptedVaultKey = { + /** Base64-encoded AES-256-GCM ciphertext of the vault key. */ + ciphertext: Base64String; + /** Base64-encoded AES-GCM IV used during encryption. */ + iv: Base64String; +}; + +/** + * Parameters needed to reproduce the AES-256 wrapping key at unlock time. + * + * Encoded as a discriminated union so PRF-only fields (e.g. `prfSalt`) can + * only exist on the PRF branch, removing the "optional but actually + * required" footgun. + */ +export type PasskeyKeyDerivation = + | { + method: 'prf'; + /** + * PRF salt sent in `get()` extension options to reproduce the same PRF + * output that was generated at registration. + */ + prfSalt: Base64URLString; + } + | { method: 'userHandle' }; + +/** Discriminator value for {@link PasskeyKeyDerivation}. */ +export type PasskeyDerivationMethod = PasskeyKeyDerivation['method']; + +export type PasskeyRecord = { + /** WebAuthn credential metadata used for assertion verification & re-discovery. */ + credential: PasskeyCredentialInfo; + /** Vault key wrapped under the passkey-derived key. */ + encryptedVaultKey: EncryptedVaultKey; + /** How the wrapping key is reconstructed at unlock time. */ + keyDerivation: PasskeyKeyDerivation; +}; + +/** + * In-memory state for one **in-flight** WebAuthn **registration** ceremony + * (from `create()` options until `protectVaultKeyWithPasskey` completes). This is + * not a user login session; it is keyed by challenge and distinct from the full + * spec ceremony (which includes the authenticator round-trip). + */ +export type PasskeyRegistrationCeremony = { + userHandle: Base64URLString; + prfSalt?: Base64URLString; + challenge: Base64URLString; + /** When this ceremony was started (ms since epoch); used for TTL pruning. */ + createdAt: number; +}; + +/** + * In-memory state for one **in-flight** WebAuthn **authentication** ceremony + * (`get()` options until the assertion is verified). Not a user login session. + */ +export type PasskeyAuthenticationCeremony = { + challenge: Base64URLString; + /** When this ceremony was started (ms since epoch); used for TTL pruning. */ + createdAt: number; +}; + +/** + * PRF extension types not covered by DOM typings. + */ +export type PrfEvalExtension = { + eval: { + first: Base64URLString; + }; +}; + +export type PrfClientExtensionResults = { + prf?: { + enabled?: boolean; + results?: { first?: Base64URLString }; + }; +}; + +export type PasskeyControllerState = { + passkeyRecord: PasskeyRecord | null; +}; + +export type PasskeyControllerGetStateAction = ControllerGetStateAction< + typeof controllerName, + PasskeyControllerState +>; + +/** + * KeyringController actions that {@link PasskeyController} may call during + * orchestrated passkey flows. The restricted messenger must allow these at init. + */ +export type PasskeyControllerAllowedActions = + | KeyringControllerVerifyPasswordAction + | KeyringControllerExportEncryptionKeyAction + | KeyringControllerSubmitEncryptionKeyAction + | KeyringControllerChangePasswordAction + | KeyringControllerExportSeedPhraseAction + | KeyringControllerExportAccountAction; + +/** + * Actions exposed by {@link PasskeyController} on its messenger, including + * `:getState`, enrollment/unlock ceremony methods, and lifecycle helpers. + */ +export type PasskeyControllerActions = + | PasskeyControllerGetStateAction + | PasskeyControllerMethodActions; + +export type PasskeyControllerStateChangedEvent = ControllerStateChangedEvent< + typeof controllerName, + PasskeyControllerState +>; + +export type PasskeyControllerEvents = PasskeyControllerStateChangedEvent; + +export type PasskeyControllerMessenger = Messenger< + typeof controllerName, + PasskeyControllerActions | PasskeyControllerAllowedActions, + PasskeyControllerEvents +>; + +export type PasskeyControllerOptions = { + messenger: PasskeyControllerMessenger; + state?: Partial; + rpId?: string; + expectedRPID: string | string[]; + rpName: string; + expectedOrigin: string | string[]; + userName?: string; + userDisplayName?: string; + /** + * Returns whether wallet onboarding is complete. When `true`, enrollment + * requires password step-up. The integrator typically supplies + * `() => onboardingController.state.completedOnboarding`. + */ + getIsOnboardingCompleted: () => boolean; +}; diff --git a/packages/passkey-controller/src/utils/crypto.test.ts b/packages/passkey-controller/src/utils/crypto.test.ts new file mode 100644 index 00000000000..fe40d1165a3 --- /dev/null +++ b/packages/passkey-controller/src/utils/crypto.test.ts @@ -0,0 +1,50 @@ +import { + decryptWithKey, + deriveEncryptionKey, + encryptWithKey, + randomBytesToBase64URL, +} from './crypto.js'; +import { base64URLToBytes } from './encoding.js'; + +describe('crypto', () => { + describe('randomBytesToBase64URL', () => { + it('returns base64url whose decoded length matches byteLength', () => { + const encoded = randomBytesToBase64URL(32); + expect(base64URLToBytes(encoded)).toHaveLength(32); + }); + + it('returns distinct values on successive calls', () => { + const a = randomBytesToBase64URL(32); + const b = randomBytesToBase64URL(32); + expect(a).not.toBe(b); + }); + }); + + describe('encryptWithKey / decryptWithKey', () => { + it('round-trips the encryption key with a derived key', () => { + const ikm = new Uint8Array(32); + ikm.fill(11); + const credentialId = new Uint8Array(16); + credentialId.fill(22); + + const key = deriveEncryptionKey(ikm, credentialId); + const plaintext = 'vault-encryption-key-material'; + const { ciphertext, iv } = encryptWithKey(plaintext, key); + const recovered = decryptWithKey(ciphertext, iv, key); + expect(recovered).toBe(plaintext); + }); + + it('fails decryption when a different key is used', () => { + const keyA = deriveEncryptionKey( + new Uint8Array(32).fill(1), + new Uint8Array(8).fill(2), + ); + const keyB = deriveEncryptionKey( + new Uint8Array(32).fill(3), + new Uint8Array(8).fill(4), + ); + const { ciphertext, iv } = encryptWithKey('secret', keyA); + expect(() => decryptWithKey(ciphertext, iv, keyB)).toThrow('aes/gcm'); + }); + }); +}); diff --git a/packages/passkey-controller/src/utils/crypto.ts b/packages/passkey-controller/src/utils/crypto.ts new file mode 100644 index 00000000000..69dab28e277 --- /dev/null +++ b/packages/passkey-controller/src/utils/crypto.ts @@ -0,0 +1,77 @@ +import { bytesToBase64, base64ToBytes } from '@metamask/utils'; +import { gcm } from '@noble/ciphers/aes'; +import { randomBytes } from '@noble/ciphers/webcrypto'; +import { hkdf } from '@noble/hashes/hkdf'; +import { sha256 } from '@noble/hashes/sha2'; + +import { bytesToBase64URL } from './encoding.js'; + +const PASSKEY_HKDF_INFO = 'metamask:passkey:encryption-key:v1'; + +const AES_GCM_IV_LENGTH = 12; + +/** + * Generates random bytes and returns them as a base64url string (no padding). + * + * @param byteLength - Number of bytes to generate (e.g. WebAuthn challenge length). + * @returns Base64url-encoded random bytes. + */ +export function randomBytesToBase64URL(byteLength: number): string { + return bytesToBase64URL(randomBytes(byteLength)); +} + +/** + * Derives an AES-256 encryption key from input key material and a credential ID + * using HKDF-SHA256. + * + * @param ikm - Input key material (e.g. PRF output or userHandle). + * @param salt - HKDF salt. + * @returns 32-byte derived encryption key. + */ +export function deriveEncryptionKey( + ikm: Uint8Array, + salt: Uint8Array, +): Uint8Array { + return hkdf(sha256, ikm, salt, PASSKEY_HKDF_INFO, 32); +} + +/** + * Encrypts plaintext with an AES-256-GCM key. + * + * @param plaintext - UTF-8 string to encrypt. + * @param key - 32-byte AES-256 key from {@link deriveEncryptionKey}. + * @returns Base64-encoded ciphertext and IV. + */ +export function encryptWithKey( + plaintext: string, + key: Uint8Array, +): { ciphertext: string; iv: string } { + const iv = randomBytes(AES_GCM_IV_LENGTH); + const encoded = new TextEncoder().encode(plaintext); + const ciphertextBytes = gcm(key, iv).encrypt(encoded); + + return { + ciphertext: bytesToBase64(ciphertextBytes), + iv: bytesToBase64(iv), + }; +} + +/** + * Decrypts AES-256-GCM ciphertext with the given key. + * + * @param ciphertext - Base64-encoded ciphertext. + * @param iv - Base64-encoded initialization vector. + * @param key - 32-byte AES-256 key from {@link deriveEncryptionKey}. + * @returns Decrypted UTF-8 string. + */ +export function decryptWithKey( + ciphertext: string, + iv: string, + key: Uint8Array, +): string { + const ciphertextBytes = base64ToBytes(ciphertext); + const ivBytes = base64ToBytes(iv); + const plaintext = gcm(key, ivBytes).decrypt(ciphertextBytes); + + return new TextDecoder().decode(plaintext); +} diff --git a/packages/passkey-controller/src/utils/encoding.test.ts b/packages/passkey-controller/src/utils/encoding.test.ts new file mode 100644 index 00000000000..cf670000943 --- /dev/null +++ b/packages/passkey-controller/src/utils/encoding.test.ts @@ -0,0 +1,48 @@ +import { bytesToBase64URL, base64URLToBytes } from './encoding.js'; + +describe('encoding', () => { + describe('bytesToBase64URL', () => { + it('encodes an empty array', () => { + expect(bytesToBase64URL(new Uint8Array([]))).toBe(''); + }); + + it('encodes bytes without padding', () => { + const bytes = new Uint8Array([72, 101, 108, 108, 111]); + expect(bytesToBase64URL(bytes)).toBe('SGVsbG8'); + }); + + it('uses url-safe characters', () => { + const bytes = new Uint8Array([0xff, 0xfe, 0xfd]); + const result = bytesToBase64URL(bytes); + expect(result).not.toContain('+'); + expect(result).not.toContain('/'); + expect(result).not.toContain('='); + }); + }); + + describe('base64URLToBytes', () => { + it('decodes a base64url string', () => { + const original = new Uint8Array([72, 101, 108, 108, 111]); + const encoded = bytesToBase64URL(original); + const decoded = base64URLToBytes(encoded); + expect(new Uint8Array(decoded)).toStrictEqual(original); + }); + + it('handles url-safe characters', () => { + const original = new Uint8Array([0xff, 0xfe, 0xfd]); + const encoded = bytesToBase64URL(original); + const decoded = base64URLToBytes(encoded); + expect(new Uint8Array(decoded)).toStrictEqual(original); + }); + + it('round-trips arbitrary bytes', () => { + const original = new Uint8Array(256); + for (let i = 0; i < 256; i++) { + original[i] = i; + } + const encoded = bytesToBase64URL(original); + const decoded = base64URLToBytes(encoded); + expect(new Uint8Array(decoded)).toStrictEqual(original); + }); + }); +}); diff --git a/packages/passkey-controller/src/utils/encoding.ts b/packages/passkey-controller/src/utils/encoding.ts new file mode 100644 index 00000000000..03f2ba14ef0 --- /dev/null +++ b/packages/passkey-controller/src/utils/encoding.ts @@ -0,0 +1,38 @@ +import { bytesToBase64, base64ToBytes } from '@metamask/utils'; + +/** + * Encode a byte array as a base64url string (RFC 4648 §5). + * + * @param bytes - The bytes to encode. + * @returns Base64url-encoded string without padding. + */ +export function bytesToBase64URL(bytes: Uint8Array): string { + return bytesToBase64(bytes) + .replace(/\+/gu, '-') + .replace(/\//gu, '_') + .replace(/[=]+$/u, ''); +} + +/** + * Decode a base64url string (RFC 4648 §5) into bytes. + * + * @param value - Base64url-encoded string. + * @returns Decoded bytes. + */ +export function base64URLToBytes(value: string): Uint8Array { + const standard = value.replace(/-/gu, '+').replace(/_/gu, '/'); + const padLength = (4 - (standard.length % 4)) % 4; + return Uint8Array.from(base64ToBytes(standard + '='.repeat(padLength))); +} + +/** + * Encode a byte array as a hexadecimal string. + * + * @param bytes - The bytes to encode. + * @returns Hex-encoded string. + */ +export function bytesToHex(bytes: Uint8Array): string { + return Array.from(bytes) + .map((byte) => byte.toString(16).padStart(2, '0')) + .join(''); +} diff --git a/packages/passkey-controller/src/webauthn/constants.ts b/packages/passkey-controller/src/webauthn/constants.ts new file mode 100644 index 00000000000..13c3ee706fb --- /dev/null +++ b/packages/passkey-controller/src/webauthn/constants.ts @@ -0,0 +1,72 @@ +/** + * COSE Algorithms + * + * @see https://www.iana.org/assignments/cose/cose.xhtml#algorithms + */ +export enum COSEALG { + ES256 = -7, + EdDSA = -8, + ES384 = -35, + ES512 = -36, + PS256 = -37, + PS384 = -38, + PS512 = -39, + ES256K = -47, + RS256 = -257, + RS384 = -258, + RS512 = -259, + RS1 = -65535, +} + +/** + * COSE Key Types + * + * @see https://www.iana.org/assignments/cose/cose.xhtml#key-type + */ +export enum COSEKTY { + OKP = 1, + EC2 = 2, + RSA = 3, +} + +/** + * COSE Curves + * + * @see https://www.iana.org/assignments/cose/cose.xhtml#elliptic-curves + */ +export enum COSECRV { + P256 = 1, + P384 = 2, + P521 = 3, + ED25519 = 6, + SECP256K1 = 8, +} + +/** + * COSE Key common and type-specific parameter labels. + * + * EC2 and RSA re-use the same numeric labels (-1, -2, -3) with different + * semantics, so this is a plain object instead of an enum to avoid + * duplicate-value violations. + * + * @see https://www.iana.org/assignments/cose/cose.xhtml#key-common-parameters + * @see https://www.iana.org/assignments/cose/cose.xhtml#key-type-parameters + */ +export const COSEKEYS = { + /** Key Type (common) */ + Kty: 1, + /** Algorithm (common) */ + Alg: 3, + + /** EC2 / OKP: curve identifier */ + Crv: -1, + /** EC2: x-coordinate / OKP: public key */ + X: -2, + /** EC2: y-coordinate */ + Y: -3, + + /** RSA: modulus n (shares numeric label with Crv) */ + N: -1, + /** RSA: exponent e (shares numeric label with X) */ + E: -2, +} as const; diff --git a/packages/passkey-controller/src/webauthn/decode-attestation-object.test.ts b/packages/passkey-controller/src/webauthn/decode-attestation-object.test.ts new file mode 100644 index 00000000000..fdabcb05c84 --- /dev/null +++ b/packages/passkey-controller/src/webauthn/decode-attestation-object.test.ts @@ -0,0 +1,46 @@ +import { base64URLToBytes } from '../utils/encoding.js'; +import { decodeAttestationObject } from './decode-attestation-object.js'; + +describe('decodeAttestationObject', () => { + it('decodes base64url-encoded indirect attestationObject', () => { + const decoded = decodeAttestationObject( + base64URLToBytes( + 'o2NmbXRkbm9uZWdhdHRTdG10oGhhdXRoRGF0YVjEAbElFazplpnc037DORGDZNjDq86cN9vm6' + + '+APoAM20wtBAAAAAAAAAAAAAAAAAAAAAAAAAAAAQKmPuEwByQJ3e89TccUSrCGDkNWquhevjLLn/' + + 'KNZZaxQQ0steueoG2g12dvnUNbiso8kVJDyLa+6UiA34eniujWlAQIDJiABIVggiUk8wN2j' + + '+3fkKI7KSiLBkKzs3FfhPZxHgHPnGLvOY/YiWCBv7+XyTqArnMVtQ947/8Xk8fnVCdLMRWJGM1VbNevVcQ==', + ), + ); + + expect(decoded.get('fmt')).toBe('none'); + expect(decoded.get('attStmt')).toStrictEqual(new Map()); + expect(Boolean(decoded.get('authData'))).toBe(true); + }); + + it('decodes base64url-encoded direct attestationObject', () => { + const decoded = decodeAttestationObject( + base64URLToBytes( + 'o2NmbXRoZmlkby11MmZnYXR0U3RtdKJjc2lnWEgwRgIhAK40WxA0t7py7AjEXvwGwTlmqlvrOk' + + 's5g9lf+9zXzRiVAiEA3bv60xyXveKDOusYzniD7CDSostCet9PYK7FLdnTdZNjeDVjgVkCwTCCAr0wggGloAMCAQICBCrn' + + 'YmMwDQYJKoZIhvcNAQELBQAwLjEsMCoGA1UEAxMjWXViaWNvIFUyRiBSb290IENBIFNlcmlhbCA0NTcyMDA2MzEwIBcNMT' + + 'QwODAxMDAwMDAwWhgPMjA1MDA5MDQwMDAwMDBaMG4xCzAJBgNVBAYTAlNFMRIwEAYDVQQKDAlZdWJpY28gQUIxIjAgBgNV' + + 'BAsMGUF1dGhlbnRpY2F0b3IgQXR0ZXN0YXRpb24xJzAlBgNVBAMMHll1YmljbyBVMkYgRUUgU2VyaWFsIDcxOTgwNzA3NT' + + 'BZMBMGByqGSM49AgEGCCqGSM49AwEHA0IABCoDhl5gQ9meEf8QqiVUV4S/Ca+Oax47MhcpIW9VEhqM2RDTmd3HaL3+SnvH' + + '49q8YubSRp/1Z1uP+okMynSGnj+jbDBqMCIGCSsGAQQBgsQKAgQVMS4zLjYuMS40LjEuNDE0ODIuMS4xMBMGCysGAQQBgu' + + 'UcAgEBBAQDAgQwMCEGCysGAQQBguUcAQEEBBIEEG1Eupv27C5JuTAMj+kgy3MwDAYDVR0TAQH/BAIwADANBgkqhkiG9w0B' + + 'AQsFAAOCAQEAclfQPNzD4RVphJDW+A75W1MHI3PZ5kcyYysR3Nx3iuxr1ZJtB+F7nFQweI3jL05HtFh2/4xVIgKb6Th4eV' + + 'cjMecncBaCinEbOcdP1sEli9Hk2eVm1XB5A0faUjXAPw/+QLFCjgXG6ReZ5HVUcWkB7riLsFeJNYitiKrTDXFPLy+sNtVN' + + 'utcQnFsCerDKuM81TvEAigkIbKCGlq8M/NvBg5j83wIxbCYiyV7mIr3RwApHieShzLdJo1S6XydgQjC+/64G5r8C+8AVvN' + + 'FR3zXXCpio5C3KRIj88HEEIYjf6h1fdLfqeIsq+cUUqbq5T+c4nNoZUZCysTB9v5EY4akp+GhhdXRoRGF0YVjEAbElFazp' + + 'lpnc037DORGDZNjDq86cN9vm6+APoAM20wtBAAAAAAAAAAAAAAAAAAAAAAAAAAAAQGFYevaR71ptU5YtXOSnVzPQTsGgK+' + + 'gLiBKnqPWBmZXNRvjISqlLxiwApzlrfkTc3lEMYMatjeACCnsijOkNEGOlAQIDJiABIVggdWLG6UvGyHFw/k/bv6/k6z/L' + + 'LgSO5KXzXw2EcUxkEX8iWCBeaVLz/cbyoKvRIg/q+q7tan0VN+i3WR0BOBCcuNP7yw==', + ), + ); + + expect(decoded.get('fmt')).toBe('fido-u2f'); + expect(Boolean(decoded.get('attStmt').get('sig'))).toBe(true); + expect(Boolean(decoded.get('attStmt').get('x5c'))).toBe(true); + expect(Boolean(decoded.get('authData'))).toBe(true); + }); +}); diff --git a/packages/passkey-controller/src/webauthn/decode-attestation-object.ts b/packages/passkey-controller/src/webauthn/decode-attestation-object.ts new file mode 100644 index 00000000000..d7c06418217 --- /dev/null +++ b/packages/passkey-controller/src/webauthn/decode-attestation-object.ts @@ -0,0 +1,18 @@ +import { decodePartialCBOR } from '@levischuck/tiny-cbor'; + +import type { AttestationObject } from './types.js'; + +/** + * CBOR-decode an attestationObject buffer into a Map with `fmt`, `attStmt`, + * and `authData` entries. + * + * @param attestationObject - Raw attestation object bytes. + * @returns Decoded AttestationObject map. + */ +export function decodeAttestationObject( + attestationObject: Uint8Array, +): AttestationObject { + const copy = new Uint8Array(attestationObject); + const [decoded] = decodePartialCBOR(copy, 0) as [AttestationObject, number]; + return decoded; +} diff --git a/packages/passkey-controller/src/webauthn/decode-client-data-json.test.ts b/packages/passkey-controller/src/webauthn/decode-client-data-json.test.ts new file mode 100644 index 00000000000..81837f6a7b4 --- /dev/null +++ b/packages/passkey-controller/src/webauthn/decode-client-data-json.test.ts @@ -0,0 +1,16 @@ +import { decodeClientDataJSON } from './decode-client-data-json.js'; + +describe('decodeClientDataJSON', () => { + it('converts base64url-encoded attestation clientDataJSON to JSON', () => { + expect( + decodeClientDataJSON( + 'eyJ0eXBlIjoid2ViYXV0aG4uY3JlYXRlIiwiY2hhbGxlbmdlIjoiWko0YW12QnpOUGVMb3lLVE04bDlqamFmMDhXc0V0TG5OSENGZnhacGEybjlfU21NUnR5VjZlYlNPSUFfUGNsOHBaUjl5Y1ZhaW5SdV9rUDhRaTZiemciLCJvcmlnaW4iOiJodHRwczovL3dlYmF1dGhuLmlvIn0', + ), + ).toStrictEqual({ + type: 'webauthn.create', + challenge: + 'ZJ4amvBzNPeLoyKTM8l9jjaf08WsEtLnNHCFfxZpa2n9_SmMRtyV6ebSOIA_Pcl8pZR9ycVainRu_kP8Qi6bzg', + origin: 'https://webauthn.io', + }); + }); +}); diff --git a/packages/passkey-controller/src/webauthn/decode-client-data-json.ts b/packages/passkey-controller/src/webauthn/decode-client-data-json.ts new file mode 100644 index 00000000000..204246a2efb --- /dev/null +++ b/packages/passkey-controller/src/webauthn/decode-client-data-json.ts @@ -0,0 +1,14 @@ +import { base64URLToBytes } from '../utils/encoding.js'; +import type { ClientDataJSON } from './types.js'; + +/** + * Decode an authenticator's base64url-encoded clientDataJSON to JSON. + * + * @param data - Base64url-encoded clientDataJSON string. + * @returns Parsed ClientDataJSON object. + */ +export function decodeClientDataJSON(data: string): ClientDataJSON { + const bytes = base64URLToBytes(data); + const text = new TextDecoder().decode(bytes); + return JSON.parse(text) as ClientDataJSON; +} diff --git a/packages/passkey-controller/src/webauthn/match-expected-rp-id.test.ts b/packages/passkey-controller/src/webauthn/match-expected-rp-id.test.ts new file mode 100644 index 00000000000..95e8232a22a --- /dev/null +++ b/packages/passkey-controller/src/webauthn/match-expected-rp-id.test.ts @@ -0,0 +1,33 @@ +import { sha256 } from '@noble/hashes/sha2'; + +import { matchExpectedRPID } from './match-expected-rp-id.js'; + +describe('matchExpectedRPID', () => { + it('throws when no RP ID matches', () => { + const rpIdHash = sha256(new TextEncoder().encode('example.com')); + expect(() => matchExpectedRPID(rpIdHash, ['wrong.com'])).toThrow( + 'Unexpected RP ID hash', + ); + }); + + it('returns matching RP ID', () => { + const rpIdHash = sha256(new TextEncoder().encode('example.com')); + expect(matchExpectedRPID(rpIdHash, ['example.com'])).toBe('example.com'); + }); + + it('constant-time compare rejects different lengths', () => { + // Pass a 16-byte rpIdHash to trigger the areEqual length-mismatch branch + // (sha256 always produces 32 bytes, so the comparison short-circuits) + const shortHash = new Uint8Array(16).fill(0xaa); + expect(() => matchExpectedRPID(shortHash, ['example.com'])).toThrow( + 'Unexpected RP ID hash', + ); + }); + + it('matches second candidate in array', () => { + const rpIdHash = sha256(new TextEncoder().encode('example.com')); + expect(matchExpectedRPID(rpIdHash, ['wrong.com', 'example.com'])).toBe( + 'example.com', + ); + }); +}); diff --git a/packages/passkey-controller/src/webauthn/match-expected-rp-id.ts b/packages/passkey-controller/src/webauthn/match-expected-rp-id.ts new file mode 100644 index 00000000000..e7930abc6f8 --- /dev/null +++ b/packages/passkey-controller/src/webauthn/match-expected-rp-id.ts @@ -0,0 +1,26 @@ +import { areUint8ArraysEqual } from '@metamask/utils'; +import { sha256 } from '@noble/hashes/sha2'; + +import { bytesToHex } from '../utils/encoding.js'; + +/** + * Verify that an authenticator data rpIdHash matches one of the expected + * RP IDs by SHA-256 hashing each candidate and comparing. + * + * @param rpIdHash - The rpIdHash from authenticatorData (32 bytes). + * @param expectedRPIDs - One or more RP ID strings to check against. + * @returns The matching RP ID string. + * @throws If no expected RP ID matches. + */ +export function matchExpectedRPID( + rpIdHash: Uint8Array, + expectedRPIDs: string[], +): string { + for (const rpID of expectedRPIDs) { + const expectedHash = sha256(new TextEncoder().encode(rpID)); + if (areUint8ArraysEqual(rpIdHash, expectedHash)) { + return rpID; + } + } + throw new Error(`Unexpected RP ID hash: received ${bytesToHex(rpIdHash)}`); +} diff --git a/packages/passkey-controller/src/webauthn/parse-authenticator-data.test.ts b/packages/passkey-controller/src/webauthn/parse-authenticator-data.test.ts new file mode 100644 index 00000000000..2d1083beed9 --- /dev/null +++ b/packages/passkey-controller/src/webauthn/parse-authenticator-data.test.ts @@ -0,0 +1,144 @@ +import { encodeCBOR } from '@levischuck/tiny-cbor'; +import { base64ToBytes, bytesToBase64 } from '@metamask/utils'; +import { sha256 } from '@noble/hashes/sha2'; + +import { bytesToBase64URL } from '../utils/encoding.js'; +import { parseAuthenticatorData } from './parse-authenticator-data.js'; + +/** + * Conformance vectors from SimpleWebAuthn `parseAuthenticatorData.test.ts` + * (base64-decoded the same way: `isoBase64URL.toBuffer(..., 'base64')`). + * + * The Firefox 117 malformed COSE case from upstream is omitted here: that + * parser patches bad CBOR and re-encodes the public key; this implementation + * does not, and throws "Leftover bytes detected..." on that buffer. + */ + +// Includes attested credential data (AT) +const authDataWithAT = Uint8Array.from( + base64ToBytes( + 'SZYN5YgOjGh0NBcPZHZgW4/krrmihjLHmVzzuoMdl2NBAAAAJch83ZdWwUm4niTLNjZU81AAIHa7Ksm5br3hAh3UjxP9+4rqu8BEsD+7SZ2xWe1/yHv6pAEDAzkBACBZAQDcxA7Ehs9goWB2Hbl6e9v+aUub9rvy2M7Hkvf+iCzMGE63e3sCEW5Ru33KNy4um46s9jalcBHtZgtEnyeRoQvszis+ws5o4Da0vQfuzlpBmjWT1dV6LuP+vs9wrfObW4jlA5bKEIhv63+jAxOtdXGVzo75PxBlqxrmrr5IR9n8Fw7clwRsDkjgRHaNcQVbwq/qdNwU5H3hZKu9szTwBS5NGRq01EaDF2014YSTFjwtAmZ3PU1tcO/QD2U2zg6eB5grfWDeAJtRE8cbndDWc8aLL0aeC37Q36+TVsGe6AhBgHEw6eO3I3NW5r9v/26CqMPBDwmEundeq1iGyKfMloobIUMBAAE=', + ), +); + +// Includes extension data (ED) +const authDataWithED = Uint8Array.from( + base64ToBytes( + 'SZYN5YgOjGh0NBcPZHZgW4/krrmihjLHmVzzuoMdl2OBAAAAjaFxZXhhbXBsZS5leHRlbnNpb254dlRoaXMgaXMgYW4gZXhhbXBsZSBleHRlbnNpb24hIElmIHlvdSByZWFkIHRoaXMgbWVzc2FnZSwgeW91IHByb2JhYmx5IHN1Y2Nlc3NmdWxseSBwYXNzaW5nIGNvbmZvcm1hbmNlIHRlc3RzLiBHb29kIGpvYiE=', + ), +); + +const TEST_RP_ID = 'example.com'; + +describe('parseAuthenticatorData', () => { + it('parses flags', () => { + const parsed = parseAuthenticatorData(authDataWithED); + const { flags } = parsed; + + expect(flags.up).toBe(true); + expect(flags.uv).toBe(false); + expect(flags.be).toBe(false); + expect(flags.bs).toBe(false); + expect(flags.at).toBe(false); + expect(flags.ed).toBe(true); + }); + + it('parses attestation data', () => { + const parsed = parseAuthenticatorData(authDataWithAT); + const { credentialID, credentialPublicKey, aaguid, counter } = parsed; + + if ( + credentialID === undefined || + credentialPublicKey === undefined || + aaguid === undefined + ) { + throw new Error('expected credentialID, credentialPublicKey, and aaguid'); + } + + expect(bytesToBase64URL(credentialID)).toBe( + 'drsqybluveECHdSPE_37iuq7wESwP7tJnbFZ7X_Ie_o', + ); + expect(bytesToBase64(credentialPublicKey)).toBe( + 'pAEDAzkBACBZAQDcxA7Ehs9goWB2Hbl6e9v+aUub9rvy2M7Hkvf+iCzMGE63e3sCEW5Ru33KNy4um46s9jalcBHtZgtEnyeRoQvszis+ws5o4Da0vQfuzlpBmjWT1dV6LuP+vs9wrfObW4jlA5bKEIhv63+jAxOtdXGVzo75PxBlqxrmrr5IR9n8Fw7clwRsDkjgRHaNcQVbwq/qdNwU5H3hZKu9szTwBS5NGRq01EaDF2014YSTFjwtAmZ3PU1tcO/QD2U2zg6eB5grfWDeAJtRE8cbndDWc8aLL0aeC37Q36+TVsGe6AhBgHEw6eO3I3NW5r9v/26CqMPBDwmEundeq1iGyKfMloobIUMBAAE=', + ); + expect(bytesToBase64(aaguid)).toBe('yHzdl1bBSbieJMs2NlTzUA=='); + expect(counter).toBe(37); + }); + + it('parses extension data', () => { + const parsed = parseAuthenticatorData(authDataWithED); + const { extensionsData } = parsed; + + expect(extensionsData).toStrictEqual( + new Map([ + [ + 'example.extension', + 'This is an example extension! If you read this message, you probably successfully passing conformance tests. Good job!', + ], + ]), + ); + }); +}); + +describe('parseAuthenticatorData edge cases', () => { + it('throws for authenticator data shorter than 37 bytes', () => { + expect(() => parseAuthenticatorData(new Uint8Array(36))).toThrow( + 'authenticatorData is 36 bytes, expected at least 37', + ); + }); + + it('parses extension data when ED flag is set', () => { + const rpIdHash = sha256(new TextEncoder().encode(TEST_RP_ID)); + const flags = 0x81; + const counter = new Uint8Array(4); + + const extMap = new Map(); + extMap.set('credProtect', 2); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const extCBOR = encodeCBOR(extMap as any); + + const authData = new Uint8Array(37 + extCBOR.length); + authData.set(rpIdHash, 0); + authData[32] = flags; + authData.set(counter, 33); + authData.set(extCBOR, 37); + + const result = parseAuthenticatorData(authData); + expect(result.flags.ed).toBe(true); + expect(result.extensionsData).toBeDefined(); + expect(result.extensionsData?.get('credProtect')).toBe(2); + }); + + it('throws on leftover bytes after parsing', () => { + const rpIdHash = sha256(new TextEncoder().encode(TEST_RP_ID)); + const authData = new Uint8Array(42); + authData.set(rpIdHash, 0); + authData[32] = 0x01; + authData.set(new Uint8Array([0xde, 0xad, 0xbe, 0xef, 0x00]), 37); + + expect(() => parseAuthenticatorData(authData)).toThrow( + 'Leftover bytes detected while parsing authenticator data', + ); + }); + + it('parses authenticator data without attested credential or extensions', () => { + const rpIdHash = sha256(new TextEncoder().encode(TEST_RP_ID)); + const authData = new Uint8Array(37); + authData.set(rpIdHash, 0); + authData[32] = 0x05; + + const counterView = new DataView(authData.buffer, 33, 4); + counterView.setUint32(0, 42, false); + + const result = parseAuthenticatorData(authData); + expect(result.flags.up).toBe(true); + expect(result.flags.uv).toBe(true); + expect(result.flags.at).toBe(false); + expect(result.flags.ed).toBe(false); + expect(result.counter).toBe(42); + expect(result.aaguid).toBeUndefined(); + expect(result.credentialID).toBeUndefined(); + expect(result.credentialPublicKey).toBeUndefined(); + expect(result.extensionsData).toBeUndefined(); + }); +}); diff --git a/packages/passkey-controller/src/webauthn/parse-authenticator-data.ts b/packages/passkey-controller/src/webauthn/parse-authenticator-data.ts new file mode 100644 index 00000000000..d88c71b83e7 --- /dev/null +++ b/packages/passkey-controller/src/webauthn/parse-authenticator-data.ts @@ -0,0 +1,103 @@ +import { decodePartialCBOR } from '@levischuck/tiny-cbor'; + +import type { + ParsedAuthenticatorData, + AuthenticatorDataFlags, +} from './types.js'; + +/* eslint-disable no-bitwise */ + +/** + * Parse an authenticator data buffer per §6.1 of the WebAuthn spec. + * + * @param authData - Raw authenticator data bytes. + * @returns Parsed authenticator data with flags, rpIdHash, counter, and + * optional attested credential data. + */ +export function parseAuthenticatorData( + authData: Uint8Array, +): ParsedAuthenticatorData { + if (authData.byteLength < 37) { + throw new Error( + `authenticatorData is ${authData.byteLength} bytes, expected at least 37`, + ); + } + + let pointer = 0; + + const rpIdHash = authData.slice(pointer, pointer + 32); + pointer += 32; + + const flagsByte = authData[pointer]; + const flags: AuthenticatorDataFlags = { + up: Boolean(flagsByte & (1 << 0)), + uv: Boolean(flagsByte & (1 << 2)), + be: Boolean(flagsByte & (1 << 3)), + bs: Boolean(flagsByte & (1 << 4)), + at: Boolean(flagsByte & (1 << 6)), + ed: Boolean(flagsByte & (1 << 7)), + flagsByte, + }; + pointer += 1; + + const counterView = new DataView( + authData.buffer, + authData.byteOffset + pointer, + 4, + ); + const counter = counterView.getUint32(0, false); + pointer += 4; + + const result: ParsedAuthenticatorData = { + rpIdHash, + flags, + counter, + }; + + if (flags.at) { + const aaguid = authData.slice(pointer, pointer + 16); + pointer += 16; + + const credIDLenView = new DataView( + authData.buffer, + authData.byteOffset + pointer, + 2, + ); + const credIDLen = credIDLenView.getUint16(0, false); + pointer += 2; + + const credentialID = authData.slice(pointer, pointer + credIDLen); + pointer += credIDLen; + + const pubKeyBytes = authData.slice(pointer); + const [, nextOffset] = decodePartialCBOR( + new Uint8Array(pubKeyBytes), + 0, + ) as [unknown, number]; + const credentialPublicKey = authData.slice(pointer, pointer + nextOffset); + pointer += nextOffset; + + result.aaguid = aaguid; + result.credentialID = credentialID; + result.credentialPublicKey = credentialPublicKey; + } + + if (flags.ed) { + const remaining = authData.slice(pointer); + const [decoded, consumed] = decodePartialCBOR( + new Uint8Array(remaining), + 0, + ) as [Map, number]; + result.extensionsData = decoded; + result.extensionsDataBuffer = remaining.slice(0, consumed); + pointer += consumed; + } + + if (authData.byteLength > pointer) { + throw new Error('Leftover bytes detected while parsing authenticator data'); + } + + return result; +} + +/* eslint-enable no-bitwise */ diff --git a/packages/passkey-controller/src/webauthn/types.ts b/packages/passkey-controller/src/webauthn/types.ts new file mode 100644 index 00000000000..5a294fe2805 --- /dev/null +++ b/packages/passkey-controller/src/webauthn/types.ts @@ -0,0 +1,131 @@ +import type { + AuthenticatorTransportFuture, + Base64URLString as Base64URL, +} from '../types.js'; + +export type PublicKeyCredentialDescriptorJSON = { + id: Base64URL; + type: 'public-key'; + transports?: AuthenticatorTransportFuture[]; +}; + +export type PublicKeyCredentialHint = + | 'hybrid' + | 'security-key' + | 'client-device'; + +export type PasskeyRegistrationOptions = { + rp: { name: string; id?: string }; + user: { + id: Base64URL; + name: string; + displayName: string; + }; + challenge: Base64URL; + pubKeyCredParams: { alg: number; type: 'public-key' }[]; + timeout?: number; + excludeCredentials?: PublicKeyCredentialDescriptorJSON[]; + authenticatorSelection?: { + authenticatorAttachment?: 'cross-platform' | 'platform'; + residentKey?: 'discouraged' | 'preferred' | 'required'; + requireResidentKey?: boolean; + userVerification?: 'discouraged' | 'preferred' | 'required'; + }; + hints?: PublicKeyCredentialHint[]; + attestation?: 'direct' | 'enterprise' | 'indirect' | 'none'; + extensions?: Record; +}; + +export type PasskeyRegistrationResponse = { + id: Base64URL; + rawId: Base64URL; + type: 'public-key'; + response: { + clientDataJSON: Base64URL; + attestationObject: Base64URL; + transports?: string[]; + publicKeyAlgorithm?: number; + publicKey?: Base64URL; + authenticatorData?: Base64URL; + }; + authenticatorAttachment?: 'cross-platform' | 'platform'; + clientExtensionResults: Record; +}; + +export type PasskeyAuthenticationOptions = { + challenge: Base64URL; + timeout?: number; + rpId?: string; + allowCredentials?: PublicKeyCredentialDescriptorJSON[]; + userVerification?: 'discouraged' | 'preferred' | 'required'; + hints?: PublicKeyCredentialHint[]; + extensions?: Record; +}; + +export type PasskeyAuthenticationResponse = { + id: Base64URL; + rawId: Base64URL; + type: 'public-key'; + response: { + clientDataJSON: Base64URL; + authenticatorData: Base64URL; + signature: Base64URL; + userHandle?: Base64URL; + }; + authenticatorAttachment?: 'cross-platform' | 'platform'; + clientExtensionResults: Record; +}; + +export type ClientDataJSON = { + type: string; + challenge: string; + origin: string; + crossOrigin?: boolean; + tokenBinding?: { + id?: string; + status: 'present' | 'supported' | 'not-supported'; + }; +}; + +export type AttestationFormat = + | 'fido-u2f' + | 'packed' + | 'android-safetynet' + | 'android-key' + | 'tpm' + | 'apple' + | 'none'; + +export type AttestationObject = { + get(key: 'fmt'): AttestationFormat; + get(key: 'attStmt'): AttestationStatement; + get(key: 'authData'): Uint8Array; +}; + +export type AttestationStatement = { + get(key: 'sig'): Uint8Array | undefined; + get(key: 'x5c'): Uint8Array[] | undefined; + get(key: 'alg'): number | undefined; + readonly size: number; +}; + +export type AuthenticatorDataFlags = { + up: boolean; + uv: boolean; + be: boolean; + bs: boolean; + at: boolean; + ed: boolean; + flagsByte: number; +}; + +export type ParsedAuthenticatorData = { + rpIdHash: Uint8Array; + flags: AuthenticatorDataFlags; + counter: number; + aaguid?: Uint8Array; + credentialID?: Uint8Array; + credentialPublicKey?: Uint8Array; + extensionsData?: Map; + extensionsDataBuffer?: Uint8Array; +}; diff --git a/packages/passkey-controller/src/webauthn/verify-authentication-response.test.ts b/packages/passkey-controller/src/webauthn/verify-authentication-response.test.ts new file mode 100644 index 00000000000..bf4152aa1c6 --- /dev/null +++ b/packages/passkey-controller/src/webauthn/verify-authentication-response.test.ts @@ -0,0 +1,518 @@ +import { encodeCBOR } from '@levischuck/tiny-cbor'; +import { concatBytes } from '@metamask/utils'; +import { p256 } from '@noble/curves/p256'; +import { sha256 } from '@noble/hashes/sha2'; + +import { bytesToBase64URL, base64URLToBytes } from '../utils/encoding.js'; +import { COSEALG, COSECRV, COSEKEYS, COSEKTY } from './constants.js'; +import { decodeClientDataJSON } from './decode-client-data-json.js'; +import type { PasskeyAuthenticationResponse } from './types.js'; +import { verifyAuthenticationResponse } from './verify-authentication-response.js'; + +const EXPECTED_ORIGIN = 'https://dev.dontneeda.pw'; +const EXPECTED_RP_ID = 'dev.dontneeda.pw'; + +const assertionResponse: PasskeyAuthenticationResponse = { + id: 'KEbWNCc7NgaYnUyrNeFGX9_3Y-8oJ3KwzjnaiD1d1LVTxR7v3CaKfCz2Vy_g_MHSh7yJ8yL0Pxg6jo_o0hYiew', + rawId: + 'KEbWNCc7NgaYnUyrNeFGX9_3Y-8oJ3KwzjnaiD1d1LVTxR7v3CaKfCz2Vy_g_MHSh7yJ8yL0Pxg6jo_o0hYiew', + response: { + authenticatorData: 'PdxHEOnAiLIp26idVjIguzn3Ipr_RlsKZWsa-5qK-KABAAAAkA==', + clientDataJSON: + 'eyJjaGFsbGVuZ2UiOiJkRzkwWVd4c2VWVnVhWEYxWlZaaGJIVmxSWFpsY25sVWFXMWwiLCJj' + + 'bGllbnRFeHRlbnNpb25zIjp7fSwiaGFzaEFsZ29yaXRobSI6IlNIQS0yNTYiLCJvcmlnaW4iOiJodHRwczovL2Rldi5k' + + 'b250bmVlZGEucHciLCJ0eXBlIjoid2ViYXV0aG4uZ2V0In0=', + signature: + 'MEUCIQDYXBOpCWSWq2Ll4558GJKD2RoWg958lvJSB_GdeokxogIgWuEVQ7ee6AswQY0OsuQ6y8Ks6' + + 'jhd45bDx92wjXKs900=', + }, + clientExtensionResults: {}, + type: 'public-key', +}; + +const credential = { + publicKey: base64URLToBytes( + 'pQECAyYgASFYIIheFp-u6GvFT2LNGovf3ZrT0iFVBsA_76rRysxRG9A1Ilgg8WGeA6hPmnab0HAViUYVRkwTNcN77QBf_RR0dv3lIvQ', + ), + id: 'KEbWNCc7NgaYnUyrNeFGX9_3Y-8oJ3KwzjnaiD1d1LVTxR7v3CaKfCz2Vy_g_MHSh7yJ8yL0Pxg6jo_o0hYiew', + counter: 143, +}; + +const assertionFirstTimeUsedResponse: PasskeyAuthenticationResponse = { + id: 'wSisR0_4hlzw3Y1tj4uNwwifIhRa-ZxWJwWbnfror0pVK9qPdBPO5pW3gasPqn6wXHb0LNhXB_IrA1nFoSQJ9A', + rawId: + 'wSisR0_4hlzw3Y1tj4uNwwifIhRa-ZxWJwWbnfror0pVK9qPdBPO5pW3gasPqn6wXHb0LNhXB_IrA1nFoSQJ9A', + response: { + authenticatorData: 'PdxHEOnAiLIp26idVjIguzn3Ipr_RlsKZWsa-5qK-KABAAAAAA', + clientDataJSON: + 'eyJjaGFsbGVuZ2UiOiJkRzkwWVd4c2VWVnVhWEYxWlZaaGJIVmxSWFpsY25sQmMzTmxjblJwYjI0IiwiY2xpZW50RXh0ZW5zaW9ucyI6e30sImhhc2hBbGdvcml0aG0iOiJTSEEtMjU2Iiwib3JpZ2luIjoiaHR0cHM6Ly9kZXYuZG9udG5lZWRhLnB3IiwidHlwZSI6IndlYmF1dGhuLmdldCJ9', + signature: + 'MEQCIBu6M-DGzu1O8iocGHEj0UaAZm0HmxTeRIE6-nS3_CPjAiBDsmIzy5sacYwwzgpXqfwRt_2vl5yiQZ_OAqWJQBGVsQ', + }, + type: 'public-key', + clientExtensionResults: {}, +}; + +const authenticatorFirstTimeUsed = { + publicKey: base64URLToBytes( + 'pQECAyYgASFYIGmaxR4mBbukc2QhtW2ldhAAd555r-ljlGQN8MbcTnPPIlgg9CyUlE-0AB2fbzZbNgBvJuRa7r6o2jPphOmtyNPR_kY', + ), + id: 'wSisR0_4hlzw3Y1tj4uNwwifIhRa-ZxWJwWbnfror0pVK9qPdBPO5pW3gasPqn6wXHb0LNhXB_IrA1nFoSQJ9A', + counter: 0, +}; + +const assertionChallenge = decodeClientDataJSON( + assertionResponse.response.clientDataJSON, +).challenge; + +/** + * Re-signs the assertion fixture's authenticator data over the given client + * data, using a freshly generated ES256 credential. + * + * Needed whenever a test alters `clientDataJSON`, since the fixture signature + * covers `authenticatorData || SHA-256(clientDataJSON)` and would otherwise no + * longer verify. + * + * @param clientData - Client data to serialize and sign over. + * @returns The signed assertion and the credential that signed it. + */ +function buildSignedAssertion(clientData: Record): { + response: PasskeyAuthenticationResponse; + credential: { id: string; publicKey: Uint8Array; counter: number }; +} { + const privateKey = p256.utils.randomPrivateKey(); + const publicKeyRaw = p256.getPublicKey(privateKey, false); + const coseKey = new Map(); + coseKey.set(COSEKEYS.Kty, COSEKTY.EC2); + coseKey.set(COSEKEYS.Alg, COSEALG.ES256); + coseKey.set(COSEKEYS.Crv, COSECRV.P256); + coseKey.set(COSEKEYS.X, publicKeyRaw.slice(1, 33)); + coseKey.set(COSEKEYS.Y, publicKeyRaw.slice(33, 65)); + + const { authenticatorData } = assertionResponse.response; + const clientDataJSON = bytesToBase64URL( + new TextEncoder().encode(JSON.stringify(clientData)), + ); + const signatureBase = concatBytes([ + base64URLToBytes(authenticatorData), + sha256(base64URLToBytes(clientDataJSON)), + ]); + const signature = p256 + .sign(sha256(signatureBase), privateKey) + .toDERRawBytes(); + + return { + response: { + ...assertionResponse, + response: { + authenticatorData, + clientDataJSON, + signature: bytesToBase64URL(signature), + }, + }, + credential: { + id: assertionResponse.id, + publicKey: encodeCBOR(coseKey), + counter: credential.counter, + }, + }; +} + +const assertionFirstTimeUsedChallenge = decodeClientDataJSON( + assertionFirstTimeUsedResponse.response.clientDataJSON, +).challenge; + +describe('verifyAuthenticationResponse', () => { + it('verifies when expectedRPIDs is empty', async () => { + const verification = await verifyAuthenticationResponse({ + response: assertionResponse, + expectedChallenge: assertionChallenge, + expectedOrigin: EXPECTED_ORIGIN, + expectedRPIDs: [], + credential, + requireUserVerification: false, + }); + + expect(verification.verified).toBe(true); + if (!verification.verified) { + return; + } + expect(verification.authenticationInfo.rpID).toBe(''); + }); + + it('verifies an assertion response', async () => { + const verification = await verifyAuthenticationResponse({ + response: assertionResponse, + expectedChallenge: assertionChallenge, + expectedOrigin: EXPECTED_ORIGIN, + expectedRPIDs: [EXPECTED_RP_ID], + credential, + requireUserVerification: false, + }); + + expect(verification.verified).toBe(true); + }); + + it('returns authenticator info after verification', async () => { + const verification = await verifyAuthenticationResponse({ + response: assertionResponse, + expectedChallenge: assertionChallenge, + expectedOrigin: EXPECTED_ORIGIN, + expectedRPIDs: [EXPECTED_RP_ID], + credential, + requireUserVerification: false, + }); + + expect(verification.verified).toBe(true); + if (!verification.verified) { + return; + } + expect(verification.authenticationInfo.newCounter).toBe(144); + expect(verification.authenticationInfo.credentialId).toBe(credential.id); + expect(verification.authenticationInfo.origin).toBe(EXPECTED_ORIGIN); + expect(verification.authenticationInfo.rpID).toBe(EXPECTED_RP_ID); + }); + + it('throws when response challenge is not expected value', async () => { + await expect( + verifyAuthenticationResponse({ + response: assertionResponse, + expectedChallenge: 'shouldhavebeenthisvalue', + expectedOrigin: EXPECTED_ORIGIN, + expectedRPIDs: [EXPECTED_RP_ID], + credential, + }), + ).rejects.toThrow('Unexpected authentication response challenge'); + }); + + it('throws when response origin is not expected value', async () => { + await expect( + verifyAuthenticationResponse({ + response: assertionResponse, + expectedChallenge: assertionChallenge, + expectedOrigin: 'https://different.address', + expectedRPIDs: [EXPECTED_RP_ID], + credential, + }), + ).rejects.toThrow('Unexpected authentication response origin'); + }); + + it('returns { verified: false } when signature does not verify', async () => { + const signatureBytes = base64URLToBytes( + assertionResponse.response.signature, + ); + signatureBytes[0] = ((signatureBytes[0] ?? 0) + 1) % 256; + + const result = await verifyAuthenticationResponse({ + response: { + ...assertionResponse, + response: { + ...assertionResponse.response, + signature: bytesToBase64URL(signatureBytes), + }, + }, + expectedChallenge: assertionChallenge, + expectedOrigin: EXPECTED_ORIGIN, + expectedRPIDs: [EXPECTED_RP_ID], + credential, + }); + + expect(result.verified).toBe(false); + expect(result.authenticationInfo).toBeUndefined(); + }); + + it('throws when authentication type is not webauthn.get', async () => { + const badTypeClientData = bytesToBase64URL( + new TextEncoder().encode( + JSON.stringify({ + ...decodeClientDataJSON(assertionResponse.response.clientDataJSON), + type: 'webauthn.badtype', + }), + ), + ); + + await expect( + verifyAuthenticationResponse({ + response: { + ...assertionResponse, + response: { + ...assertionResponse.response, + clientDataJSON: badTypeClientData, + }, + }, + expectedChallenge: assertionChallenge, + expectedOrigin: EXPECTED_ORIGIN, + expectedRPIDs: [EXPECTED_RP_ID], + credential, + }), + ).rejects.toThrow('Unexpected authentication response type'); + }); + + it('throws when RP ID is not expected value', async () => { + await expect( + verifyAuthenticationResponse({ + response: assertionResponse, + expectedChallenge: assertionChallenge, + expectedOrigin: EXPECTED_ORIGIN, + expectedRPIDs: ['wrong-rp.com'], + credential, + }), + ).rejects.toThrow('Unexpected RP ID hash'); + }); + + it('throws when credential ID is missing in response', async () => { + await expect( + verifyAuthenticationResponse({ + response: { + ...assertionResponse, + id: '', + rawId: '', + }, + expectedChallenge: assertionChallenge, + expectedOrigin: EXPECTED_ORIGIN, + expectedRPIDs: [EXPECTED_RP_ID], + credential, + }), + ).rejects.toThrow('Missing credential ID'); + }); + + it('throws when id and rawId differ', async () => { + await expect( + verifyAuthenticationResponse({ + response: { + ...assertionResponse, + rawId: 'different-raw-id', + }, + expectedChallenge: assertionChallenge, + expectedOrigin: EXPECTED_ORIGIN, + expectedRPIDs: [EXPECTED_RP_ID], + credential, + }), + ).rejects.toThrow('Credential ID was not base64url-encoded'); + }); + + it('throws when credential type is not public-key', async () => { + await expect( + verifyAuthenticationResponse({ + response: { + ...assertionResponse, + type: 'not-public-key', + } as unknown as PasskeyAuthenticationResponse, + expectedChallenge: assertionChallenge, + expectedOrigin: EXPECTED_ORIGIN, + expectedRPIDs: [EXPECTED_RP_ID], + credential, + }), + ).rejects.toThrow('Unexpected credential type'); + }); + + it('throws error if user was not present', async () => { + const authData = base64URLToBytes( + assertionResponse.response.authenticatorData, + ); + authData[32] = 0x00; + + await expect( + verifyAuthenticationResponse({ + response: { + ...assertionResponse, + response: { + ...assertionResponse.response, + authenticatorData: bytesToBase64URL(authData), + }, + }, + expectedChallenge: assertionChallenge, + expectedOrigin: EXPECTED_ORIGIN, + expectedRPIDs: [EXPECTED_RP_ID], + credential, + }), + ).rejects.toThrow('User not present during authentication'); + }); + + it('throws error when response counter equals stored counter and monotonicity applies', async () => { + await expect( + verifyAuthenticationResponse({ + response: assertionResponse, + expectedChallenge: assertionChallenge, + expectedOrigin: EXPECTED_ORIGIN, + expectedRPIDs: [EXPECTED_RP_ID], + credential: { + ...credential, + counter: 144, + }, + requireUserVerification: false, + }), + ).rejects.toThrow( + 'Response counter value 144 must be greater than stored counter 144', + ); + }); + + it('throws error when response counter is lower than stored counter', async () => { + await expect( + verifyAuthenticationResponse({ + response: assertionResponse, + expectedChallenge: assertionChallenge, + expectedOrigin: EXPECTED_ORIGIN, + expectedRPIDs: [EXPECTED_RP_ID], + credential: { + ...credential, + counter: 200, + }, + requireUserVerification: false, + }), + ).rejects.toThrow( + 'Response counter value 144 must be greater than stored counter 200', + ); + }); + + it('does not compare counters if both are 0', async () => { + const verification = await verifyAuthenticationResponse({ + response: assertionFirstTimeUsedResponse, + expectedChallenge: assertionFirstTimeUsedChallenge, + expectedOrigin: EXPECTED_ORIGIN, + expectedRPIDs: [EXPECTED_RP_ID], + credential: authenticatorFirstTimeUsed, + requireUserVerification: false, + }); + + expect(verification.verified).toBe(true); + }); + + it('throws if user verification is required but uv is false', async () => { + await expect( + verifyAuthenticationResponse({ + response: assertionResponse, + expectedChallenge: assertionChallenge, + expectedOrigin: EXPECTED_ORIGIN, + expectedRPIDs: [EXPECTED_RP_ID], + credential, + requireUserVerification: true, + }), + ).rejects.toThrow( + 'User verification required, but user could not be verified', + ); + }); + + it('accepts expectedOrigin as array', async () => { + const verification = await verifyAuthenticationResponse({ + response: assertionResponse, + expectedChallenge: assertionChallenge, + expectedOrigin: ['https://other.com', EXPECTED_ORIGIN], + expectedRPIDs: [EXPECTED_RP_ID], + credential, + requireUserVerification: false, + }); + + expect(verification.verified).toBe(true); + }); + + it('throws when clientDataJSON is not a string', async () => { + await expect( + verifyAuthenticationResponse({ + response: { + ...assertionResponse, + response: { + ...assertionResponse.response, + clientDataJSON: 1 as unknown as string, + }, + }, + expectedChallenge: assertionChallenge, + expectedOrigin: EXPECTED_ORIGIN, + expectedRPIDs: [EXPECTED_RP_ID], + credential, + }), + ).rejects.toThrow('Credential response clientDataJSON was not a string'); + }); + + it('throws when userHandle is not a string', async () => { + await expect( + verifyAuthenticationResponse({ + response: { + ...assertionResponse, + response: { + ...assertionResponse.response, + userHandle: 1 as unknown as string, + }, + }, + expectedChallenge: assertionChallenge, + expectedOrigin: EXPECTED_ORIGIN, + expectedRPIDs: [EXPECTED_RP_ID], + credential, + }), + ).rejects.toThrow('Credential response userHandle was not a string'); + }); + + it('throws when tokenBinding is not an object', async () => { + const clientDataJSON = bytesToBase64URL( + new TextEncoder().encode( + JSON.stringify({ + ...decodeClientDataJSON(assertionResponse.response.clientDataJSON), + tokenBinding: 'invalid', + }), + ), + ); + + await expect( + verifyAuthenticationResponse({ + response: { + ...assertionResponse, + response: { + ...assertionResponse.response, + clientDataJSON, + }, + }, + expectedChallenge: assertionChallenge, + expectedOrigin: EXPECTED_ORIGIN, + expectedRPIDs: [EXPECTED_RP_ID], + credential, + }), + ).rejects.toThrow('ClientDataJSON tokenBinding was not an object'); + }); + + it('throws when tokenBinding status is invalid', async () => { + const clientDataJSON = bytesToBase64URL( + new TextEncoder().encode( + JSON.stringify({ + ...decodeClientDataJSON(assertionResponse.response.clientDataJSON), + tokenBinding: { status: 'invalid-status' }, + }), + ), + ); + + await expect( + verifyAuthenticationResponse({ + response: { + ...assertionResponse, + response: { + ...assertionResponse.response, + clientDataJSON, + }, + }, + expectedChallenge: assertionChallenge, + expectedOrigin: EXPECTED_ORIGIN, + expectedRPIDs: [EXPECTED_RP_ID], + credential, + }), + ).rejects.toThrow('Unexpected tokenBinding status'); + }); + + it.each(['present', 'supported', 'not-supported'])( + 'verifies an assertion whose tokenBinding status is %s', + async (status) => { + const signedAssertion = buildSignedAssertion({ + ...decodeClientDataJSON(assertionResponse.response.clientDataJSON), + tokenBinding: { status }, + }); + + const verification = await verifyAuthenticationResponse({ + response: signedAssertion.response, + expectedChallenge: assertionChallenge, + expectedOrigin: EXPECTED_ORIGIN, + expectedRPIDs: [EXPECTED_RP_ID], + credential: signedAssertion.credential, + }); + + expect(verification.verified).toBe(true); + }, + ); +}); diff --git a/packages/passkey-controller/src/webauthn/verify-authentication-response.ts b/packages/passkey-controller/src/webauthn/verify-authentication-response.ts new file mode 100644 index 00000000000..67db9d9c368 --- /dev/null +++ b/packages/passkey-controller/src/webauthn/verify-authentication-response.ts @@ -0,0 +1,217 @@ +import { decodePartialCBOR } from '@levischuck/tiny-cbor'; +import { concatBytes } from '@metamask/utils'; +import { sha256 } from '@noble/hashes/sha2'; + +import type { AuthenticatorTransportFuture } from '../types.js'; +import { base64URLToBytes } from '../utils/encoding.js'; +import { decodeClientDataJSON } from './decode-client-data-json.js'; +import { matchExpectedRPID } from './match-expected-rp-id.js'; +import { parseAuthenticatorData } from './parse-authenticator-data.js'; +import type { ParsedAuthenticatorData } from './types.js'; +import type { PasskeyAuthenticationResponse } from './types.js'; +import { verifySignature } from './verify-signature.js'; + +export type VerifiedAuthenticationResponse = + | { verified: false; authenticationInfo?: never } + | { + verified: true; + authenticationInfo: { + credentialId: string; + newCounter: number; + userVerified: boolean; + origin: string; + /** Matched RP ID, or `""` when `expectedRPIDs` is empty (RP ID hash check skipped). */ + rpID: string; + }; + }; + +/** + * Verifies a WebAuthn authentication (assertion) response per + * W3C WebAuthn Level 3 §7.2. + * + * Performs the following checks in order: + * 1. Credential ID presence, base64url consistency, and type. + * 2. `clientDataJSON` -- type is `"webauthn.get"`, challenge and origin + * match. + * 3. `authenticatorData` -- RP ID hash matches, user-presence flag is + * set, and optional user-verification flag is checked. + * 4. Signature verification -- `signature` is verified over + * `authData || SHA-256(clientDataJSON)` using the stored credential + * public key (COSE-encoded). + * 5. Counter monotonicity -- if either the stored or returned counter + * is non-zero, the new counter must exceed the stored value. + * + * @param opts - Verification options. + * @param opts.response - The `PublicKeyCredential` result from + * `navigator.credentials.get()`, serialized as JSON. + * @param opts.expectedChallenge - The base64url challenge that was issued + * for this ceremony. + * @param opts.expectedOrigin - One or more acceptable origins. + * @param opts.expectedRPIDs - Relying Party ID strings to match against `rpIdHash`. + * @param opts.credential - The stored credential record to verify against. + * @param opts.credential.id - The credential ID (base64url). + * @param opts.credential.publicKey - The COSE-encoded public key bytes + * persisted during registration. + * @param opts.credential.counter - The last known signature counter value. + * @param opts.credential.transports - Optional authenticator transports. + * @param opts.requireUserVerification - When `true`, verification fails + * if the UV flag is not set. Defaults to `false`. + * @returns Verification result containing `verified` status and parsed + * authentication info (new counter, origin, RP ID). + */ +export async function verifyAuthenticationResponse(opts: { + response: PasskeyAuthenticationResponse; + expectedChallenge: string; + expectedOrigin: string | string[]; + expectedRPIDs: string[]; + credential: { + id: string; + publicKey: Uint8Array; + counter: number; + transports?: AuthenticatorTransportFuture[]; + }; + requireUserVerification?: boolean; +}): Promise { + const { + response, + expectedChallenge, + expectedOrigin, + expectedRPIDs, + credential, + requireUserVerification = false, + } = opts; + + const { + id, + rawId, + type: credentialType, + response: assertionResponse, + } = response; + + // Ensure credential specified an ID + if (!id) { + throw new Error('Missing credential ID'); + } + + // Ensure ID is base64url-encoded + if (id !== rawId) { + throw new Error('Credential ID was not base64url-encoded'); + } + + // Make sure credential type is public-key + if (credentialType !== 'public-key') { + throw new Error( + `Unexpected credential type ${String(credentialType)}, expected "public-key"`, + ); + } + + if (typeof assertionResponse?.clientDataJSON !== 'string') { + throw new Error('Credential response clientDataJSON was not a string'); + } + + const clientDataJSON = decodeClientDataJSON(assertionResponse.clientDataJSON); + const { type, challenge, origin, tokenBinding } = clientDataJSON; + + // Make sure we're handling an authentication + if (type !== 'webauthn.get') { + throw new Error(`Unexpected authentication response type: ${type}`); + } + + // Ensure the device provided the challenge we gave it + if (challenge !== expectedChallenge) { + throw new Error( + `Unexpected authentication response challenge "${challenge}", expected "${expectedChallenge}"`, + ); + } + + // Check that the origin is our site + const expectedOrigins = Array.isArray(expectedOrigin) + ? expectedOrigin + : [expectedOrigin]; + if (!expectedOrigins.includes(origin)) { + throw new Error( + `Unexpected authentication response origin "${origin}", expected one of: ${expectedOrigins.join(', ')}`, + ); + } + + if ( + assertionResponse.userHandle && + typeof assertionResponse.userHandle !== 'string' + ) { + throw new Error('Credential response userHandle was not a string'); + } + + if (tokenBinding) { + if (typeof tokenBinding !== 'object') { + throw new Error('ClientDataJSON tokenBinding was not an object'); + } + + if ( + !['present', 'supported', 'not-supported'].includes(tokenBinding.status) + ) { + throw new Error(`Unexpected tokenBinding status ${tokenBinding.status}`); + } + } + + const authDataBuffer = base64URLToBytes(assertionResponse.authenticatorData); + const parsedAuthData: ParsedAuthenticatorData = + parseAuthenticatorData(authDataBuffer); + const { rpIdHash, flags, counter } = parsedAuthData; + + const matchedRPID = + expectedRPIDs.length > 0 ? matchExpectedRPID(rpIdHash, expectedRPIDs) : ''; + + // WebAuthn only requires the user presence flag be true + if (!flags.up) { + throw new Error('User not present during authentication'); + } + + // Enforce user verification if required + if (requireUserVerification && !flags.uv) { + throw new Error( + 'User verification required, but user could not be verified', + ); + } + + const clientDataHash = sha256( + base64URLToBytes(assertionResponse.clientDataJSON), + ); + const signatureBase = concatBytes([authDataBuffer, clientDataHash]); + + const signature = base64URLToBytes(assertionResponse.signature); + + const cosePublicKey = decodePartialCBOR( + new Uint8Array(credential.publicKey), + 0, + )[0] as Map; + + const verified = await verifySignature({ + cosePublicKey, + signature, + data: signatureBase, + }); + + if (!verified) { + return { verified: false }; + } + + if ( + (counter > 0 || credential.counter > 0) && + counter <= credential.counter + ) { + throw new Error( + `Response counter value ${counter} must be greater than stored counter ${credential.counter}`, + ); + } + + return { + verified: true, + authenticationInfo: { + credentialId: credential.id, + newCounter: counter, + userVerified: flags.uv, + origin: clientDataJSON.origin, + rpID: matchedRPID, + }, + }; +} diff --git a/packages/passkey-controller/src/webauthn/verify-registration-response.test.ts b/packages/passkey-controller/src/webauthn/verify-registration-response.test.ts new file mode 100644 index 00000000000..42c76556e65 --- /dev/null +++ b/packages/passkey-controller/src/webauthn/verify-registration-response.test.ts @@ -0,0 +1,1076 @@ +import { encodeCBOR } from '@levischuck/tiny-cbor'; +import { p256 } from '@noble/curves/p256'; +import { sha256 } from '@noble/hashes/sha2'; + +import { base64URLToBytes } from '../utils/encoding.js'; +import { bytesToBase64URL } from '../utils/encoding.js'; +import { COSEALG, COSECRV, COSEKEYS, COSEKTY } from './constants.js'; +import { decodeClientDataJSON } from './decode-client-data-json.js'; +import * as parseAuthenticatorDataModule from './parse-authenticator-data.js'; +import type { PasskeyRegistrationResponse } from './types.js'; +import { + getAAGUIDFromRegistrationResponse, + verifyRegistrationResponse, +} from './verify-registration-response.js'; + +const EXPECTED_ORIGIN = 'https://dev.dontneeda.pw'; +const EXPECTED_RP_ID = 'dev.dontneeda.pw'; + +const attestationNone: PasskeyRegistrationResponse = { + id: 'AdKXJEch1aV5Wo7bj7qLHskVY4OoNaj9qu8TPdJ7kSAgUeRxWNngXlcNIGt4gexZGKVGcqZpqqWordXb_he1izY', + rawId: + 'AdKXJEch1aV5Wo7bj7qLHskVY4OoNaj9qu8TPdJ7kSAgUeRxWNngXlcNIGt4gexZGKVGcqZpqqWordXb_he1izY', + response: { + attestationObject: + 'o2NmbXRkbm9uZWdhdHRTdG10oGhhdXRoRGF0YVjFPdxHEOnAiLIp26idVjIguzn3I' + + 'pr_RlsKZWsa-5qK-KBFAAAAAAAAAAAAAAAAAAAAAAAAAAAAQQHSlyRHIdWleVqO24-6ix7JFWODqDWo_arvEz3Se' + + '5EgIFHkcVjZ4F5XDSBreIHsWRilRnKmaaqlqK3V2_4XtYs2pQECAyYgASFYID5PQTZQQg6haZFQWFzqfAOyQ_ENs' + + 'MH8xxQ4GRiNPsqrIlggU8IVUOV8qpgk_Jh-OTaLuZL52KdX1fTht07X4DiQPow', + clientDataJSON: + 'eyJ0eXBlIjoid2ViYXV0aG4uY3JlYXRlIiwiY2hhbGxlbmdlIjoiYUVWalkxQlhkWHBw' + + 'VURBd1NEQndOV2Q0YURKZmRUVmZVRU0wVG1WWloyUSIsIm9yaWdpbiI6Imh0dHBzOlwvXC9kZXYuZG9udG5lZWRh' + + 'LnB3IiwiYW5kcm9pZFBhY2thZ2VOYW1lIjoib3JnLm1vemlsbGEuZmlyZWZveCJ9', + transports: [], + }, + type: 'public-key', + clientExtensionResults: {}, +}; + +const attestationFIDOU2F: PasskeyRegistrationResponse = { + id: 'VHzbxaYaJu2P8m1Y2iHn2gRNHrgK0iYbn9E978L3Qi7Q-chFeicIHwYCRophz5lth2nCgEVKcgWirxlgidgbUQ', + rawId: + 'VHzbxaYaJu2P8m1Y2iHn2gRNHrgK0iYbn9E978L3Qi7Q-chFeicIHwYCRophz5lth2nCgEVKcgWirxlgidgbUQ', + response: { + attestationObject: + 'o2NmbXRoZmlkby11MmZnYXR0U3RtdKJjc2lnWEcwRQIgRYUftNUmhT0VWTZmIgDmrOoP26Pcre-kL3DLnCrXbegCIQCOu_x5gqp-Rej76zeBuXlk8e7J-9WM_i-wZmCIbIgCGmN4NWOBWQLBMIICvTCCAaWgAwIBAgIEKudiYzANBgkqhkiG9w0BAQsFADAuMSwwKgYDVQQDEyNZdWJpY28gVTJGIFJvb3QgQ0EgU2VyaWFsIDQ1NzIwMDYzMTAgFw0xNDA4MDEwMDAwMDBaGA8yMDUwMDkwNDAwMDAwMFowbjELMAkGA1UEBhMCU0UxEjAQBgNVBAoMCVl1YmljbyBBQjEiMCAGA1UECwwZQXV0aGVudGljYXRvciBBdHRlc3RhdGlvbjEnMCUGA1UEAwweWXViaWNvIFUyRiBFRSBTZXJpYWwgNzE5ODA3MDc1MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEKgOGXmBD2Z4R_xCqJVRXhL8Jr45rHjsyFykhb1USGozZENOZ3cdovf5Ke8fj2rxi5tJGn_VnW4_6iQzKdIaeP6NsMGowIgYJKwYBBAGCxAoCBBUxLjMuNi4xLjQuMS40MTQ4Mi4xLjEwEwYLKwYBBAGC5RwCAQEEBAMCBDAwIQYLKwYBBAGC5RwBAQQEEgQQbUS6m_bsLkm5MAyP6SDLczAMBgNVHRMBAf8EAjAAMA0GCSqGSIb3DQEBCwUAA4IBAQByV9A83MPhFWmEkNb4DvlbUwcjc9nmRzJjKxHc3HeK7GvVkm0H4XucVDB4jeMvTke0WHb_jFUiApvpOHh5VyMx5ydwFoKKcRs5x0_WwSWL0eTZ5WbVcHkDR9pSNcA_D_5AsUKOBcbpF5nkdVRxaQHuuIuwV4k1iK2IqtMNcU8vL6w21U261xCcWwJ6sMq4zzVO8QCKCQhsoIaWrwz828GDmPzfAjFsJiLJXuYivdHACkeJ5KHMt0mjVLpfJ2BCML7_rgbmvwL7wBW80VHfNdcKmKjkLcpEiPzwcQQhiN_qHV90t-p4iyr5xRSpurlP5zic2hlRkLKxMH2_kRjhqSn4aGF1dGhEYXRhWMQ93EcQ6cCIsinbqJ1WMiC7Ofcimv9GWwplaxr7mor4oEEAAAAAAAAAAAAAAAAAAAAAAAAAAABAVHzbxaYaJu2P8m1Y2iHn2gRNHrgK0iYbn9E978L3Qi7Q-chFeicIHwYCRophz5lth2nCgEVKcgWirxlgidgbUaUBAgMmIAEhWCDIkcsOaVKDIQYwq3EDQ-pST2kRwNH_l1nCgW-WcFpNXiJYIBSbummp-KO3qZeqmvZ_U_uirCDL2RNj3E5y4_KzefIr', + clientDataJSON: + 'eyJjaGFsbGVuZ2UiOiJkRzkwWVd4c2VWVnVhWEYxWlZaaGJIVmxSWFpsY25sQmRIUmxjM1JoZEdsdmJnIiwiY2xpZW50RXh0ZW5zaW9ucyI6e30sImhhc2hBbGdvcml0aG0iOiJTSEEtMjU2Iiwib3JpZ2luIjoiaHR0cHM6Ly9kZXYuZG9udG5lZWRhLnB3IiwidHlwZSI6IndlYmF1dGhuLmNyZWF0ZSJ9', + transports: [], + }, + type: 'public-key', + clientExtensionResults: {}, +}; + +const attestationPacked: PasskeyRegistrationResponse = { + id: 'AYThY1csINY4JrbHyGmqTl1nL_F1zjAF3hSAIngz8kAcjugmAMNVvxZRwqpEH-bNHHAIv291OX5ko9eDf_5mu3UB2BvsScr2K-ppM4owOpGsqwg5tZglqqmxIm1Q', + rawId: + 'AYThY1csINY4JrbHyGmqTl1nL_F1zjAF3hSAIngz8kAcjugmAMNVvxZRwqpEH-bNHHAIv291OX5ko9eDf_5mu3UB2BvsScr2K-ppM4owOpGsqwg5tZglqqmxIm1Q', + response: { + attestationObject: + 'o2NmbXRmcGFja2VkZ2F0dFN0bXSiY2FsZyZjc2lnWEcwRQIhANvrPZMUFrl_rvlgR' + + 'qz6lCPlF6B4y885FYUCCrhrzAYXAiAb4dQKXbP3IimsTTadkwXQlrRVdxzlbmPXt847-Oh6r2hhdXRoRGF0YVjhP' + + 'dxHEOnAiLIp26idVjIguzn3Ipr_RlsKZWsa-5qK-KBFXsOO-a3OAAI1vMYKZIsLJfHwVQMAXQGE4WNXLCDWOCa2x' + + '8hpqk5dZy_xdc4wBd4UgCJ4M_JAHI7oJgDDVb8WUcKqRB_mzRxwCL9vdTl-ZKPXg3_-Zrt1Adgb7EnK9ivqaTOKM' + + 'DqRrKsIObWYJaqpsSJtUKUBAgMmIAEhWCBKMVVaivqCBpqqAxMjuCo5jMeUdh3jDOC0EF4fLBNNTyJYILc7rqDDe' + + 'X1pwCLrl3ZX7IThrtZNwKQVLQyfHiorqP-n', + clientDataJSON: + 'eyJjaGFsbGVuZ2UiOiJjelpRU1dKQ2JsQlFibkpIVGxOQ2VFNWtkRVJ5VkRkVmNsWlpT' + + 'a3M1U0UwIiwib3JpZ2luIjoiaHR0cHM6Ly9kZXYuZG9udG5lZWRhLnB3IiwidHlwZSI6IndlYmF1dGhuLmNyZWF0' + + 'ZSJ9', + transports: [], + }, + clientExtensionResults: {}, + type: 'public-key', +}; + +const attestationPackedX5C: PasskeyRegistrationResponse = { + id: '4rrvMciHCkdLQ2HghazIp1sMc8TmV8W8RgoX-x8tqV_1AmlqWACqUK8mBGLandr-htduQKPzgb2yWxOFV56Tlg', + rawId: + '4rrvMciHCkdLQ2HghazIp1sMc8TmV8W8RgoX-x8tqV_1AmlqWACqUK8mBGLandr-htduQKPzgb2yWxOFV56Tlg', + response: { + attestationObject: + 'o2NmbXRmcGFja2VkZ2F0dFN0bXSjY2FsZyZjc2lnWEcwRQIhAIMt_hGMtdgpIVIwMOeKK' + + 'w0IkUUFkXSY8arKh3Q0c5QQAiB9Sv9JavAEmppeH_XkZjB7TFM3jfxsgl97iIkvuJOUImN4NWOBWQLBMIICvTCCAaWgA' + + 'wIBAgIEKudiYzANBgkqhkiG9w0BAQsFADAuMSwwKgYDVQQDEyNZdWJpY28gVTJGIFJvb3QgQ0EgU2VyaWFsIDQ1NzIwM' + + 'DYzMTAgFw0xNDA4MDEwMDAwMDBaGA8yMDUwMDkwNDAwMDAwMFowbjELMAkGA1UEBhMCU0UxEjAQBgNVBAoMCVl1Ymljb' + + 'yBBQjEiMCAGA1UECwwZQXV0aGVudGljYXRvciBBdHRlc3RhdGlvbjEnMCUGA1UEAwweWXViaWNvIFUyRiBFRSBTZXJpY' + + 'WwgNzE5ODA3MDc1MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEKgOGXmBD2Z4R_xCqJVRXhL8Jr45rHjsyFykhb1USG' + + 'ozZENOZ3cdovf5Ke8fj2rxi5tJGn_VnW4_6iQzKdIaeP6NsMGowIgYJKwYBBAGCxAoCBBUxLjMuNi4xLjQuMS40MTQ4M' + + 'i4xLjEwEwYLKwYBBAGC5RwCAQEEBAMCBDAwIQYLKwYBBAGC5RwBAQQEEgQQbUS6m_bsLkm5MAyP6SDLczAMBgNVHRMBA' + + 'f8EAjAAMA0GCSqGSIb3DQEBCwUAA4IBAQByV9A83MPhFWmEkNb4DvlbUwcjc9nmRzJjKxHc3HeK7GvVkm0H4XucVDB4j' + + 'eMvTke0WHb_jFUiApvpOHh5VyMx5ydwFoKKcRs5x0_WwSWL0eTZ5WbVcHkDR9pSNcA_D_5AsUKOBcbpF5nkdVRxaQHuu' + + 'IuwV4k1iK2IqtMNcU8vL6w21U261xCcWwJ6sMq4zzVO8QCKCQhsoIaWrwz828GDmPzfAjFsJiLJXuYivdHACkeJ5KHMt' + + '0mjVLpfJ2BCML7_rgbmvwL7wBW80VHfNdcKmKjkLcpEiPzwcQQhiN_qHV90t-p4iyr5xRSpurlP5zic2hlRkLKxMH2_k' + + 'RjhqSn4aGF1dGhEYXRhWMQ93EcQ6cCIsinbqJ1WMiC7Ofcimv9GWwplaxr7mor4oEEAAAAcbUS6m_bsLkm5MAyP6SDLc' + + 'wBA4rrvMciHCkdLQ2HghazIp1sMc8TmV8W8RgoX-x8tqV_1AmlqWACqUK8mBGLandr-htduQKPzgb2yWxOFV56TlqUBA' + + 'gMmIAEhWCBsJbGAjckW-AA_XMk8OnB-VUvrs35ZpjtVJXRhnvXiGiJYIL2ncyg_KesCi44GH8UcZXYwjBkVdGMjNd6LF' + + 'myiD6xf', + clientDataJSON: + 'eyJ0eXBlIjoid2ViYXV0aG4uY3JlYXRlIiwiY2hhbGxlbmdlIjoiZEc5MFlXeHNlVlZ1YVhG' + + 'MVpWWmhiSFZsUlhabGNubFVhVzFsIiwib3JpZ2luIjoiaHR0cHM6Ly9kZXYuZG9udG5lZWRhLnB3In0=', + transports: [], + }, + type: 'public-key', + clientExtensionResults: {}, +}; + +const noneChallenge = decodeClientDataJSON( + attestationNone.response.clientDataJSON, +).challenge; + +const fidoU2fChallenge = decodeClientDataJSON( + attestationFIDOU2F.response.clientDataJSON, +).challenge; + +const packedChallenge = decodeClientDataJSON( + attestationPacked.response.clientDataJSON, +).challenge; + +const packedX5cChallenge = decodeClientDataJSON( + attestationPackedX5C.response.clientDataJSON, +).challenge; + +const TEST_RP_ID = 'example.com'; +const TEST_ORIGIN = 'https://example.com'; +const TEST_CHALLENGE = bytesToBase64URL(new Uint8Array(32).fill(0xab)); + +function makeClientDataJSON( + overrides?: Partial<{ + type: string; + challenge: string; + origin: string; + }>, +): string { + const json = JSON.stringify({ + type: overrides?.type ?? 'webauthn.create', + challenge: overrides?.challenge ?? TEST_CHALLENGE, + origin: overrides?.origin ?? TEST_ORIGIN, + }); + return bytesToBase64URL(new TextEncoder().encode(json)); +} + +function buildCosePublicKeyMap( + pubKeyBytes: Uint8Array, +): Map { + const map = new Map(); + map.set(COSEKEYS.Kty, COSEKTY.EC2); + map.set(COSEKEYS.Alg, COSEALG.ES256); + map.set(COSEKEYS.Crv, COSECRV.P256); + map.set(COSEKEYS.X, pubKeyBytes.slice(1, 33)); + map.set(COSEKEYS.Y, pubKeyBytes.slice(33, 65)); + return map; +} + +function generateES256KeyPair(): { + privateKey: Uint8Array; + cosePublicKeyCBOR: Uint8Array; +} { + const privateKey = p256.utils.randomPrivateKey(); + const publicKeyRaw = p256.getPublicKey(privateKey, false); + const coseMap = buildCosePublicKeyMap(publicKeyRaw); + const cosePublicKeyCBOR = encodeCBOR(coseMap); + return { privateKey, cosePublicKeyCBOR }; +} + +function buildAuthenticatorData(opts: { + rpIdHash: Uint8Array; + flags: number; + counter: number; + aaguid?: Uint8Array; + credentialID?: Uint8Array; + credentialPublicKey?: Uint8Array; +}): Uint8Array { + const parts: Uint8Array[] = []; + parts.push(opts.rpIdHash); + parts.push(new Uint8Array([opts.flags])); + + const counterBuf = new Uint8Array(4); + new DataView(counterBuf.buffer).setUint32(0, opts.counter, false); + parts.push(counterBuf); + + if (opts.aaguid && opts.credentialID && opts.credentialPublicKey) { + parts.push(opts.aaguid); + + const credIDLen = new Uint8Array(2); + new DataView(credIDLen.buffer).setUint16( + 0, + opts.credentialID.length, + false, + ); + parts.push(credIDLen); + parts.push(opts.credentialID); + parts.push(opts.credentialPublicKey); + } + + let totalLength = 0; + for (const part of parts) { + totalLength += part.length; + } + const result = new Uint8Array(totalLength); + let offset = 0; + for (const part of parts) { + result.set(part, offset); + offset += part.length; + } + return result; +} + +function buildAttestationObject( + authData: Uint8Array, + fmt: string = 'none', + attStmt: Map = new Map(), +): Uint8Array { + const map = new Map(); + map.set('fmt', fmt); + map.set('attStmt', attStmt); + map.set('authData', authData); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + return encodeCBOR(map as any); +} + +function buildRegistrationResponse( + authData: Uint8Array, + credentialId: string, + fmt: string = 'none', + attStmt: Map = new Map(), + clientDataJSONOverrides?: Partial<{ + type: string; + challenge: string; + origin: string; + }>, +): PasskeyRegistrationResponse { + const attestationObject = buildAttestationObject(authData, fmt, attStmt); + return { + id: credentialId, + rawId: credentialId, + type: 'public-key', + response: { + clientDataJSON: makeClientDataJSON(clientDataJSONOverrides), + attestationObject: bytesToBase64URL(attestationObject), + }, + clientExtensionResults: {}, + }; +} + +describe('verifyRegistrationResponse', () => { + it('verifies none attestation when expectedRPIDs is empty', async () => { + const verification = await verifyRegistrationResponse({ + response: attestationNone, + expectedChallenge: noneChallenge, + expectedOrigin: EXPECTED_ORIGIN, + expectedRPIDs: [], + }); + + expect(verification.verified).toBe(true); + }); + + it('verifies none attestation', async () => { + const verification = await verifyRegistrationResponse({ + response: attestationNone, + expectedChallenge: noneChallenge, + expectedOrigin: EXPECTED_ORIGIN, + expectedRPIDs: [EXPECTED_RP_ID], + }); + + expect(verification.verified).toBe(true); + if (!verification.verified) { + return; + } + + const { registrationInfo } = verification; + expect(registrationInfo.attestationFormat).toBe('none'); + expect(registrationInfo.counter).toBe(0); + expect(registrationInfo.publicKey).toStrictEqual( + base64URLToBytes( + 'pQECAyYgASFYID5PQTZQQg6haZFQWFzqfAOyQ_ENsMH8xxQ4GRiNPsqrIlggU8IVUOV8qpgk_Jh-OTaLuZL52KdX1fTht07X4DiQPow', + ), + ); + expect(registrationInfo.credentialId).toBe( + 'AdKXJEch1aV5Wo7bj7qLHskVY4OoNaj9qu8TPdJ7kSAgUeRxWNngXlcNIGt4gexZGKVGcqZpqqWordXb_he1izY', + ); + expect(registrationInfo.aaguid).toBe( + '00000000-0000-0000-0000-000000000000', + ); + // authData flags byte is 0x45 (UP | UV | AT); UV is set in this vector. + expect(registrationInfo.userVerified).toBe(true); + }); + + it('verifies packed self-attestation (SimpleWebAuthn conformance vector)', async () => { + const verification = await verifyRegistrationResponse({ + response: attestationPacked, + expectedChallenge: packedChallenge, + expectedOrigin: EXPECTED_ORIGIN, + expectedRPIDs: [EXPECTED_RP_ID], + }); + + expect(verification.verified).toBe(true); + if (!verification.verified) { + return; + } + + expect(verification.registrationInfo.attestationFormat).toBe('packed'); + expect(verification.registrationInfo.counter).toBe(1589874425); + expect(verification.registrationInfo.publicKey).toStrictEqual( + base64URLToBytes( + 'pQECAyYgASFYIEoxVVqK-oIGmqoDEyO4KjmMx5R2HeMM4LQQXh8sE01PIlggtzuuoMN5fWnAIuuXdlfshOGu1k3ApBUtDJ8eKiuo_6c', + ), + ); + expect(verification.registrationInfo.credentialId).toBe( + 'AYThY1csINY4JrbHyGmqTl1nL_F1zjAF3hSAIngz8kAcjugmAMNVvxZRwqpEH-bNHHAIv291OX5ko9eDf_5mu3UB2BvsScr2K-ppM4owOpGsqwg5tZglqqmxIm1Q', + ); + }); + + it('rejects when response challenge is not expected value', async () => { + await expect( + verifyRegistrationResponse({ + response: attestationNone, + expectedChallenge: 'shouldhavebeenthisvalue', + expectedOrigin: EXPECTED_ORIGIN, + expectedRPIDs: [EXPECTED_RP_ID], + }), + ).rejects.toThrow('Unexpected registration response challenge'); + }); + + it('rejects when response origin is not expected value', async () => { + await expect( + verifyRegistrationResponse({ + response: attestationNone, + expectedChallenge: noneChallenge, + expectedOrigin: 'https://different.address', + expectedRPIDs: [EXPECTED_RP_ID], + }), + ).rejects.toThrow('Unexpected registration response origin'); + }); + + it('rejects when RP ID is not expected value', async () => { + await expect( + verifyRegistrationResponse({ + response: attestationNone, + expectedChallenge: noneChallenge, + expectedOrigin: EXPECTED_ORIGIN, + expectedRPIDs: ['wrong-rp.com'], + }), + ).rejects.toThrow('Unexpected RP ID hash'); + }); + + it('rejects wrong clientDataJSON type', async () => { + const badTypeClientDataJSON = btoa( + JSON.stringify({ + ...decodeClientDataJSON(attestationNone.response.clientDataJSON), + type: 'webauthn.get', + }), + ) + .replace(/\+/gu, '-') + .replace(/\//gu, '_') + .replace(/[=]+$/u, ''); + + await expect( + verifyRegistrationResponse({ + response: { + ...attestationNone, + response: { + ...attestationNone.response, + clientDataJSON: badTypeClientDataJSON, + }, + }, + expectedChallenge: noneChallenge, + expectedOrigin: EXPECTED_ORIGIN, + expectedRPIDs: [EXPECTED_RP_ID], + }), + ).rejects.toThrow('Unexpected registration response type'); + }); + + it('rejects missing credential ID', async () => { + await expect( + verifyRegistrationResponse({ + response: { + ...attestationNone, + id: '', + rawId: '', + }, + expectedChallenge: noneChallenge, + expectedOrigin: EXPECTED_ORIGIN, + expectedRPIDs: [EXPECTED_RP_ID], + }), + ).rejects.toThrow('Missing credential ID'); + }); + + it('rejects fido-u2f attestation as unsupported format', async () => { + await expect( + verifyRegistrationResponse({ + response: attestationFIDOU2F, + expectedChallenge: fidoU2fChallenge, + expectedOrigin: EXPECTED_ORIGIN, + expectedRPIDs: [EXPECTED_RP_ID], + requireUserVerification: false, + }), + ).rejects.toThrow('Unsupported attestation format: fido-u2f'); + }); + + it('rejects packed attestation with x5c certificate chain', async () => { + await expect( + verifyRegistrationResponse({ + response: attestationPackedX5C, + expectedChallenge: packedX5cChallenge, + expectedOrigin: EXPECTED_ORIGIN, + expectedRPIDs: [EXPECTED_RP_ID], + requireUserVerification: false, + }), + ).rejects.toThrow( + 'Packed attestation with certificate chain (x5c) is not supported', + ); + }); +}); + +describe('verifyRegistrationResponse edge cases', () => { + it('rejects id !== rawId', async () => { + const response: PasskeyRegistrationResponse = { + id: 'id1', + rawId: 'id2', + type: 'public-key', + response: { + clientDataJSON: makeClientDataJSON(), + attestationObject: bytesToBase64URL(new Uint8Array([0])), + }, + clientExtensionResults: {}, + }; + + await expect( + verifyRegistrationResponse({ + response, + expectedChallenge: TEST_CHALLENGE, + expectedOrigin: TEST_ORIGIN, + expectedRPIDs: [TEST_RP_ID], + }), + ).rejects.toThrow('Credential ID was not base64url-encoded'); + }); + + it('rejects wrong credential type', async () => { + const response = { + id: 'abc', + rawId: 'abc', + type: 'not-public-key', + response: { + clientDataJSON: makeClientDataJSON(), + attestationObject: bytesToBase64URL(new Uint8Array([0])), + }, + clientExtensionResults: {}, + } as unknown as PasskeyRegistrationResponse; + + await expect( + verifyRegistrationResponse({ + response, + expectedChallenge: TEST_CHALLENGE, + expectedOrigin: TEST_ORIGIN, + expectedRPIDs: [TEST_RP_ID], + }), + ).rejects.toThrow('Unexpected credential type'); + }); + + it('rejects user verification not met when required', async () => { + const { cosePublicKeyCBOR } = generateES256KeyPair(); + const credentialID = new Uint8Array(16).fill(0x30); + const aaguid = new Uint8Array(16).fill(0); + const rpIdHash = sha256(new TextEncoder().encode(TEST_RP_ID)); + + const authData = buildAuthenticatorData({ + rpIdHash, + flags: 0x41, + counter: 0, + aaguid, + credentialID, + credentialPublicKey: cosePublicKeyCBOR, + }); + + const credentialIdB64 = bytesToBase64URL(credentialID); + const response = buildRegistrationResponse(authData, credentialIdB64); + + await expect( + verifyRegistrationResponse({ + response, + expectedChallenge: TEST_CHALLENGE, + expectedOrigin: TEST_ORIGIN, + expectedRPIDs: [TEST_RP_ID], + requireUserVerification: true, + }), + ).rejects.toThrow('User verification was required'); + }); + + it('rejects user not present', async () => { + const { cosePublicKeyCBOR } = generateES256KeyPair(); + const credentialID = new Uint8Array(16).fill(0x31); + const aaguid = new Uint8Array(16).fill(0); + const rpIdHash = sha256(new TextEncoder().encode(TEST_RP_ID)); + + const authData = buildAuthenticatorData({ + rpIdHash, + flags: 0x40, + counter: 0, + aaguid, + credentialID, + credentialPublicKey: cosePublicKeyCBOR, + }); + + const credentialIdB64 = bytesToBase64URL(credentialID); + const response = buildRegistrationResponse(authData, credentialIdB64); + + await expect( + verifyRegistrationResponse({ + response, + expectedChallenge: TEST_CHALLENGE, + expectedOrigin: TEST_ORIGIN, + expectedRPIDs: [TEST_RP_ID], + }), + ).rejects.toThrow('User presence was required'); + }); + + it('rejects credential id not matching authenticator data', async () => { + const { cosePublicKeyCBOR } = generateES256KeyPair(); + const credentialID = new Uint8Array(16).fill(0x30); + const aaguid = new Uint8Array(16).fill(0); + const rpIdHash = sha256(new TextEncoder().encode(TEST_RP_ID)); + + const authData = buildAuthenticatorData({ + rpIdHash, + flags: 0x41, + counter: 0, + aaguid, + credentialID, + credentialPublicKey: cosePublicKeyCBOR, + }); + + const wrongWrapperId = bytesToBase64URL(new Uint8Array(16).fill(0x42)); + const response = buildRegistrationResponse(authData, wrongWrapperId); + + await expect( + verifyRegistrationResponse({ + response, + expectedChallenge: TEST_CHALLENGE, + expectedOrigin: TEST_ORIGIN, + expectedRPIDs: [TEST_RP_ID], + }), + ).rejects.toThrow( + 'Credential id does not match the credential id in authenticator data', + ); + }); + + it('rejects unsupported public key algorithm', async () => { + const unsupportedMap = new Map(); + unsupportedMap.set(COSEKEYS.Kty, COSEKTY.EC2); + unsupportedMap.set(COSEKEYS.Alg, -999); + unsupportedMap.set(COSEKEYS.Crv, COSECRV.P256); + unsupportedMap.set(COSEKEYS.X, new Uint8Array(32).fill(0x01)); + unsupportedMap.set(COSEKEYS.Y, new Uint8Array(32).fill(0x02)); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const unsupportedKeyCBOR = encodeCBOR(unsupportedMap as any); + + const credentialID = new Uint8Array(16).fill(0x32); + const aaguid = new Uint8Array(16).fill(0); + const rpIdHash = sha256(new TextEncoder().encode(TEST_RP_ID)); + + const authData = buildAuthenticatorData({ + rpIdHash, + flags: 0x41, + counter: 0, + aaguid, + credentialID, + credentialPublicKey: unsupportedKeyCBOR, + }); + + const credentialIdB64 = bytesToBase64URL(credentialID); + const response = buildRegistrationResponse(authData, credentialIdB64); + + await expect( + verifyRegistrationResponse({ + response, + expectedChallenge: TEST_CHALLENGE, + expectedOrigin: TEST_ORIGIN, + expectedRPIDs: [TEST_RP_ID], + }), + ).rejects.toThrow('Unexpected public key alg'); + }); + + it('rejects packed attestation with missing alg', async () => { + const { cosePublicKeyCBOR } = generateES256KeyPair(); + const credentialID = new Uint8Array(16).fill(0x61); + const aaguid = new Uint8Array(16).fill(0); + const rpIdHash = sha256(new TextEncoder().encode(TEST_RP_ID)); + + const authData = buildAuthenticatorData({ + rpIdHash, + flags: 0x41, + counter: 0, + aaguid, + credentialID, + credentialPublicKey: cosePublicKeyCBOR, + }); + + const attStmt = new Map(); + attStmt.set('sig', new Uint8Array(64)); + + const credentialIdB64 = bytesToBase64URL(credentialID); + const response = buildRegistrationResponse( + authData, + credentialIdB64, + 'packed', + attStmt, + ); + + await expect( + verifyRegistrationResponse({ + response, + expectedChallenge: TEST_CHALLENGE, + expectedOrigin: TEST_ORIGIN, + expectedRPIDs: [TEST_RP_ID], + }), + ).rejects.toThrow('Packed attestation statement missing alg'); + }); + + it('rejects packed attestation with mismatched alg', async () => { + const { cosePublicKeyCBOR } = generateES256KeyPair(); + const credentialID = new Uint8Array(16).fill(0x62); + const aaguid = new Uint8Array(16).fill(0); + const rpIdHash = sha256(new TextEncoder().encode(TEST_RP_ID)); + + const authData = buildAuthenticatorData({ + rpIdHash, + flags: 0x41, + counter: 0, + aaguid, + credentialID, + credentialPublicKey: cosePublicKeyCBOR, + }); + + const attStmt = new Map(); + attStmt.set('alg', COSEALG.RS256); + attStmt.set('sig', new Uint8Array(64)); + + const credentialIdB64 = bytesToBase64URL(credentialID); + const response = buildRegistrationResponse( + authData, + credentialIdB64, + 'packed', + attStmt, + ); + + await expect( + verifyRegistrationResponse({ + response, + expectedChallenge: TEST_CHALLENGE, + expectedOrigin: TEST_ORIGIN, + expectedRPIDs: [TEST_RP_ID], + }), + ).rejects.toThrow('does not match credential alg'); + }); + + it('rejects packed attestation with missing signature', async () => { + const { cosePublicKeyCBOR } = generateES256KeyPair(); + const credentialID = new Uint8Array(16).fill(0x35); + const aaguid = new Uint8Array(16).fill(0); + const rpIdHash = sha256(new TextEncoder().encode(TEST_RP_ID)); + + const authData = buildAuthenticatorData({ + rpIdHash, + flags: 0x41, + counter: 0, + aaguid, + credentialID, + credentialPublicKey: cosePublicKeyCBOR, + }); + + const attStmt = new Map(); + attStmt.set('alg', COSEALG.ES256); + + const credentialIdB64 = bytesToBase64URL(credentialID); + const response = buildRegistrationResponse( + authData, + credentialIdB64, + 'packed', + attStmt, + ); + + await expect( + verifyRegistrationResponse({ + response, + expectedChallenge: TEST_CHALLENGE, + expectedOrigin: TEST_ORIGIN, + expectedRPIDs: [TEST_RP_ID], + }), + ).rejects.toThrow('Packed attestation missing signature'); + }); + + it('rejects none attestation with non-empty attStmt', async () => { + const { cosePublicKeyCBOR } = generateES256KeyPair(); + const credentialID = new Uint8Array(16).fill(0x37); + const aaguid = new Uint8Array(16).fill(0); + const rpIdHash = sha256(new TextEncoder().encode(TEST_RP_ID)); + + const authData = buildAuthenticatorData({ + rpIdHash, + flags: 0x41, + counter: 0, + aaguid, + credentialID, + credentialPublicKey: cosePublicKeyCBOR, + }); + + const attStmt = new Map(); + attStmt.set('unexpected', 'value'); + + const credentialIdB64 = bytesToBase64URL(credentialID); + const response = buildRegistrationResponse( + authData, + credentialIdB64, + 'none', + attStmt, + ); + + await expect( + verifyRegistrationResponse({ + response, + expectedChallenge: TEST_CHALLENGE, + expectedOrigin: TEST_ORIGIN, + expectedRPIDs: [TEST_RP_ID], + }), + ).rejects.toThrow('None attestation had unexpected attestation statement'); + }); + + it('accepts expectedOrigin as array', async () => { + const { cosePublicKeyCBOR } = generateES256KeyPair(); + const credentialID = new Uint8Array(16).fill(0x38); + const aaguid = new Uint8Array(16).fill(0); + const rpIdHash = sha256(new TextEncoder().encode(TEST_RP_ID)); + + const authData = buildAuthenticatorData({ + rpIdHash, + flags: 0x41, + counter: 0, + aaguid, + credentialID, + credentialPublicKey: cosePublicKeyCBOR, + }); + + const credentialIdB64 = bytesToBase64URL(credentialID); + const response = buildRegistrationResponse(authData, credentialIdB64); + + const result = await verifyRegistrationResponse({ + response, + expectedChallenge: TEST_CHALLENGE, + expectedOrigin: ['https://other.com', TEST_ORIGIN], + expectedRPIDs: [TEST_RP_ID], + }); + + expect(result.verified).toBe(true); + }); + + it('rejects tokenBinding that is not an object', async () => { + const clientDataJSON = bytesToBase64URL( + new TextEncoder().encode( + JSON.stringify({ + ...decodeClientDataJSON(attestationNone.response.clientDataJSON), + tokenBinding: 'invalid', + }), + ), + ); + + await expect( + verifyRegistrationResponse({ + response: { + ...attestationNone, + response: { + ...attestationNone.response, + clientDataJSON, + }, + }, + expectedChallenge: noneChallenge, + expectedOrigin: EXPECTED_ORIGIN, + expectedRPIDs: [EXPECTED_RP_ID], + }), + ).rejects.toThrow('ClientDataJSON tokenBinding was not an object'); + }); + + it('rejects tokenBinding with invalid status', async () => { + const clientDataJSON = bytesToBase64URL( + new TextEncoder().encode( + JSON.stringify({ + ...decodeClientDataJSON(attestationNone.response.clientDataJSON), + tokenBinding: { status: 'invalid-status' }, + }), + ), + ); + + await expect( + verifyRegistrationResponse({ + response: { + ...attestationNone, + response: { + ...attestationNone.response, + clientDataJSON, + }, + }, + expectedChallenge: noneChallenge, + expectedOrigin: EXPECTED_ORIGIN, + expectedRPIDs: [EXPECTED_RP_ID], + }), + ).rejects.toThrow('Unexpected tokenBinding.status value'); + }); + + it.each(['present', 'supported', 'not-supported'])( + 'accepts tokenBinding with %s status', + async (status) => { + const clientDataJSON = bytesToBase64URL( + new TextEncoder().encode( + JSON.stringify({ + ...decodeClientDataJSON(attestationNone.response.clientDataJSON), + tokenBinding: { status }, + }), + ), + ); + + const verification = await verifyRegistrationResponse({ + response: { + ...attestationNone, + response: { + ...attestationNone.response, + clientDataJSON, + }, + }, + expectedChallenge: noneChallenge, + expectedOrigin: EXPECTED_ORIGIN, + expectedRPIDs: [EXPECTED_RP_ID], + }); + + expect(verification.verified).toBe(true); + }, + ); + + it('rejects missing attested credential data', async () => { + const rpIdHash = sha256(new TextEncoder().encode(TEST_RP_ID)); + const authData = buildAuthenticatorData({ + rpIdHash, + flags: 0x01, + counter: 0, + }); + + const response = buildRegistrationResponse(authData, 'missing-attested'); + + await expect( + verifyRegistrationResponse({ + response, + expectedChallenge: TEST_CHALLENGE, + expectedOrigin: TEST_ORIGIN, + expectedRPIDs: [TEST_RP_ID], + }), + ).rejects.toThrow('No credential ID was provided by authenticator'); + }); + + it('returns verified false for packed attestation with invalid signature', async () => { + const { cosePublicKeyCBOR } = generateES256KeyPair(); + const credentialID = new Uint8Array(16).fill(0x91); + const aaguid = new Uint8Array(16).fill(0); + const rpIdHash = sha256(new TextEncoder().encode(TEST_RP_ID)); + const authData = buildAuthenticatorData({ + rpIdHash, + flags: 0x41, + counter: 0, + aaguid, + credentialID, + credentialPublicKey: cosePublicKeyCBOR, + }); + + const attStmt = new Map(); + attStmt.set('alg', COSEALG.ES256); + attStmt.set('sig', new Uint8Array(64).fill(0xff)); + + const response = buildRegistrationResponse( + authData, + bytesToBase64URL(credentialID), + 'packed', + attStmt, + ); + + const verification = await verifyRegistrationResponse({ + response, + expectedChallenge: TEST_CHALLENGE, + expectedOrigin: TEST_ORIGIN, + expectedRPIDs: [TEST_RP_ID], + }); + + expect(verification).toStrictEqual({ verified: false }); + }); + + const mockParsedAuthBase = { + rpIdHash: sha256(new TextEncoder().encode(EXPECTED_RP_ID)), + flags: { + up: true, + uv: false, + be: false, + bs: false, + at: true, + ed: false, + flagsByte: 0x41, + }, + counter: 0, + } as const; + + it('throws when parsed authenticator data has no credential ID', async () => { + const spy = jest + .spyOn(parseAuthenticatorDataModule, 'parseAuthenticatorData') + .mockReturnValueOnce({ + ...mockParsedAuthBase, + }); + + await expect( + verifyRegistrationResponse({ + response: attestationNone, + expectedChallenge: noneChallenge, + expectedOrigin: EXPECTED_ORIGIN, + expectedRPIDs: [EXPECTED_RP_ID], + }), + ).rejects.toThrow('No credential ID was provided by authenticator'); + + spy.mockRestore(); + }); + + it('throws when parsed authenticator data has no credential public key', async () => { + const spy = jest + .spyOn(parseAuthenticatorDataModule, 'parseAuthenticatorData') + .mockReturnValueOnce({ + ...mockParsedAuthBase, + credentialID: new Uint8Array([1]), + }); + + const credentialIdB64 = bytesToBase64URL(new Uint8Array([1])); + await expect( + verifyRegistrationResponse({ + response: { + ...attestationNone, + id: credentialIdB64, + rawId: credentialIdB64, + }, + expectedChallenge: noneChallenge, + expectedOrigin: EXPECTED_ORIGIN, + expectedRPIDs: [EXPECTED_RP_ID], + }), + ).rejects.toThrow('No public key was provided by authenticator'); + + spy.mockRestore(); + }); + + it('throws when parsed authenticator data has no AAGUID', async () => { + const spy = jest + .spyOn(parseAuthenticatorDataModule, 'parseAuthenticatorData') + .mockReturnValueOnce({ + ...mockParsedAuthBase, + credentialID: new Uint8Array([1]), + credentialPublicKey: new Uint8Array([0xa1]), + }); + + const credentialIdB64 = bytesToBase64URL(new Uint8Array([1])); + await expect( + verifyRegistrationResponse({ + response: { + ...attestationNone, + id: credentialIdB64, + rawId: credentialIdB64, + }, + expectedChallenge: noneChallenge, + expectedOrigin: EXPECTED_ORIGIN, + expectedRPIDs: [EXPECTED_RP_ID], + }), + ).rejects.toThrow('No AAGUID was present during registration'); + + spy.mockRestore(); + }); +}); + +describe('getAAGUIDFromRegistrationResponse', () => { + const TEST_AAGUID = new Uint8Array([ + 0x6d, 0x44, 0xba, 0x9b, 0xf6, 0xec, 0x2e, 0x49, 0xb9, 0x30, 0x0c, 0x8f, + 0xe9, 0x20, 0xcb, 0x73, + ]); + + it('returns the AAGUID as a dashed UUID string', () => { + const { cosePublicKeyCBOR } = generateES256KeyPair(); + const authData = buildAuthenticatorData({ + rpIdHash: sha256(new TextEncoder().encode(TEST_RP_ID)), + flags: 0x41, + counter: 0, + aaguid: TEST_AAGUID, + credentialID: new Uint8Array(16).fill(0x40), + credentialPublicKey: cosePublicKeyCBOR, + }); + + expect( + getAAGUIDFromRegistrationResponse( + buildRegistrationResponse(authData, 'Y3JlZC1pZA'), + ), + ).toBe('6d44ba9b-f6ec-2e49-b930-0c8fe920cb73'); + }); + + it('returns the all-zero AAGUID reported by privacy-preserving authenticators', () => { + expect(getAAGUIDFromRegistrationResponse(attestationNone)).toBe( + '00000000-0000-0000-0000-000000000000', + ); + }); + + it('returns undefined when there is no attested credential data', () => { + const authData = buildAuthenticatorData({ + rpIdHash: sha256(new TextEncoder().encode(TEST_RP_ID)), + flags: 0x01, + counter: 0, + }); + + expect( + getAAGUIDFromRegistrationResponse( + buildRegistrationResponse(authData, 'Y3JlZC1pZA'), + ), + ).toBeUndefined(); + }); + + it('throws when the attestation object cannot be decoded', () => { + const response = buildRegistrationResponse(new Uint8Array(0), 'Y3JlZC1pZA'); + response.response.attestationObject = bytesToBase64URL( + new Uint8Array([0xff, 0xff, 0xff]), + ); + + expect(() => getAAGUIDFromRegistrationResponse(response)).toThrow( + 'Unsupported or not well formed at 0', + ); + }); + + it('throws when the authenticator data is truncated', () => { + expect(() => + getAAGUIDFromRegistrationResponse( + buildRegistrationResponse(new Uint8Array(10), 'Y3JlZC1pZA'), + ), + ).toThrow('authenticatorData is 10 bytes, expected at least 37'); + }); +}); + +describe('verifyRegistrationResponse missing public key fields', () => { + it('rejects public key missing alg field', async () => { + const coseMapNoAlg = new Map(); + coseMapNoAlg.set(COSEKEYS.Kty, COSEKTY.EC2); + coseMapNoAlg.set(COSEKEYS.Crv, COSECRV.P256); + coseMapNoAlg.set(COSEKEYS.X, new Uint8Array(32).fill(0x01)); + coseMapNoAlg.set(COSEKEYS.Y, new Uint8Array(32).fill(0x02)); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const coseNoAlgCBOR = encodeCBOR(coseMapNoAlg as any); + + const credentialID = new Uint8Array(16).fill(0x40); + const aaguid = new Uint8Array(16).fill(0); + const rpIdHash = sha256(new TextEncoder().encode(TEST_RP_ID)); + + const authData = buildAuthenticatorData({ + rpIdHash, + flags: 0x41, + counter: 0, + aaguid, + credentialID, + credentialPublicKey: coseNoAlgCBOR, + }); + + const credentialIdB64 = bytesToBase64URL(credentialID); + const response = buildRegistrationResponse(authData, credentialIdB64); + + await expect( + verifyRegistrationResponse({ + response, + expectedChallenge: TEST_CHALLENGE, + expectedOrigin: TEST_ORIGIN, + expectedRPIDs: [TEST_RP_ID], + }), + ).rejects.toThrow('Credential public key was missing numeric alg'); + }); +}); diff --git a/packages/passkey-controller/src/webauthn/verify-registration-response.ts b/packages/passkey-controller/src/webauthn/verify-registration-response.ts new file mode 100644 index 00000000000..3753518af7d --- /dev/null +++ b/packages/passkey-controller/src/webauthn/verify-registration-response.ts @@ -0,0 +1,358 @@ +import { decodePartialCBOR } from '@levischuck/tiny-cbor'; +import { concatBytes } from '@metamask/utils'; +import { sha256 } from '@noble/hashes/sha2'; + +import type { AuthenticatorTransportFuture } from '../types.js'; +import { + base64URLToBytes, + bytesToBase64URL, + bytesToHex, +} from '../utils/encoding.js'; +import { COSEALG, COSEKEYS } from './constants.js'; +import { decodeAttestationObject } from './decode-attestation-object.js'; +import { decodeClientDataJSON } from './decode-client-data-json.js'; +import { matchExpectedRPID } from './match-expected-rp-id.js'; +import { parseAuthenticatorData } from './parse-authenticator-data.js'; +import type { PasskeyRegistrationResponse } from './types.js'; +import { verifySignature } from './verify-signature.js'; + +export type VerifiedRegistrationResponse = + | { verified: false; registrationInfo?: never } + | { + verified: true; + registrationInfo: { + credentialId: string; + publicKey: Uint8Array; + counter: number; + transports?: AuthenticatorTransportFuture[]; + aaguid: string; + attestationFormat: string; + userVerified: boolean; + }; + }; + +/** + * Verifies a WebAuthn registration (attestation) response per + * W3C WebAuthn Level 3 §7.1. + * + * Performs the following checks in order: + * 1. Credential ID presence and base64url consistency (`id === rawId`), and + * that `id` matches the credential id inside parsed authenticator data. + * 2. Credential type is `"public-key"`. + * 3. `clientDataJSON` -- type is `"webauthn.create"`, challenge and origin + * match the expected values. + * 4. Attestation object -- CBOR-decodes and parses `authData` to verify + * the RP ID hash, user-presence flag, optional user-verification flag, + * and the attested credential public key algorithm. + * 5. Attestation statement -- supports `"none"` (no signature) and + * `"packed"` self-attestation (signature verified against the + * credential's own public key). + * + * @param opts - Verification options. + * @param opts.response - The `PublicKeyCredential` result from + * `navigator.credentials.create()`, serialized as JSON. + * @param opts.expectedChallenge - The base64url challenge that was passed + * to the authenticator (must match `clientDataJSON.challenge`). + * @param opts.expectedOrigin - One or more acceptable values for + * `clientDataJSON.origin` (WebAuthn). Extension and HTTPS contexts differ by scheme. + * @param opts.expectedRPIDs - Relying Party ID strings. The authenticator's + * `rpIdHash` must equal `SHA-256(rpID)` for at least one entry. + * @param opts.requireUserVerification - When `true`, verification fails + * if the UV flag is not set. Defaults to `false`. + * @param opts.supportedAlgorithmIDs - COSE algorithm identifiers accepted + * for the credential public key. Defaults to EdDSA, ES256, and RS256. + * @returns On success, `{ verified: true, registrationInfo }` with the + * parsed credential ID, public key, counter, AAGUID, and transport + * hints. On failure, `{ verified: false }`. + */ +export async function verifyRegistrationResponse(opts: { + response: PasskeyRegistrationResponse; + expectedChallenge: string; + expectedOrigin: string | string[]; + expectedRPIDs: string[]; + requireUserVerification?: boolean; + supportedAlgorithmIDs?: number[]; +}): Promise { + const { + response, + expectedChallenge, + expectedOrigin, + expectedRPIDs, + requireUserVerification = false, + supportedAlgorithmIDs = [COSEALG.EdDSA, COSEALG.ES256, COSEALG.RS256], + } = opts; + + const { + id, + rawId, + type: credentialType, + response: attestationResponse, + } = response; + + // Ensure credential specified an ID + if (!id) { + throw new Error('Missing credential ID'); + } + + // Ensure ID is base64url-encoded + if (id !== rawId) { + throw new Error('Credential ID was not base64url-encoded'); + } + + // Make sure credential type is public-key + if (credentialType !== 'public-key') { + throw new Error( + `Unexpected credential type ${String(credentialType)}, expected "public-key"`, + ); + } + + const clientDataJSON = decodeClientDataJSON( + attestationResponse.clientDataJSON, + ); + const { type, challenge, origin, tokenBinding } = clientDataJSON; + + // Make sure we're handling an registration + if (type !== 'webauthn.create') { + throw new Error(`Unexpected registration response type: ${type}`); + } + + // Ensure the device provided the challenge we gave it + if (challenge !== expectedChallenge) { + throw new Error( + `Unexpected registration response challenge "${challenge}", expected "${expectedChallenge}"`, + ); + } + + // Check that the origin is our site + const expectedOrigins = Array.isArray(expectedOrigin) + ? expectedOrigin + : [expectedOrigin]; + if (!expectedOrigins.includes(origin)) { + throw new Error( + `Unexpected registration response origin "${origin}", expected one of: ${expectedOrigins.join(', ')}`, + ); + } + + if (tokenBinding) { + if (typeof tokenBinding !== 'object') { + throw new Error('ClientDataJSON tokenBinding was not an object'); + } + + if ( + !['present', 'supported', 'not-supported'].includes(tokenBinding.status) + ) { + throw new Error( + `Unexpected tokenBinding.status value of "${tokenBinding.status}"`, + ); + } + } + + const attestationObjectBytes = base64URLToBytes( + attestationResponse.attestationObject, + ); + const decodedAttObj = decodeAttestationObject(attestationObjectBytes); + const fmt = decodedAttObj.get('fmt'); + const authData = decodedAttObj.get('authData'); + const attStmt = decodedAttObj.get('attStmt'); + + const parsedAuthData = parseAuthenticatorData(authData); + const { + rpIdHash, + flags, + counter, + credentialID, + credentialPublicKey, + aaguid, + } = parsedAuthData; + + if (expectedRPIDs.length > 0) { + matchExpectedRPID(rpIdHash, expectedRPIDs); + } + + // Make sure someone was physically present + if (!flags.up) { + throw new Error('User presence was required, but user was not present'); + } + + // Enforce user verification if specified + if (requireUserVerification && !flags.uv) { + throw new Error( + 'User verification was required, but user could not be verified', + ); + } + + if (!credentialID) { + throw new Error('No credential ID was provided by authenticator'); + } + + const attestedCredentialId = bytesToBase64URL(credentialID); + if (id !== attestedCredentialId) { + throw new Error( + 'Credential id does not match the credential id in authenticator data', + ); + } + + if (!credentialPublicKey) { + throw new Error('No public key was provided by authenticator'); + } + if (!aaguid) { + throw new Error('No AAGUID was present during registration'); + } + + const decodedPublicKey = decodePartialCBOR( + new Uint8Array(credentialPublicKey), + 0, + )[0] as Map; + const alg = decodedPublicKey.get(COSEKEYS.Alg); + + if (typeof alg !== 'number') { + throw new Error('Credential public key was missing numeric alg'); + } + + // Make sure the key algorithm is one we specified within the registration options + if (!supportedAlgorithmIDs.includes(alg)) { + throw new Error( + `Unexpected public key alg "${alg}", expected one of "${supportedAlgorithmIDs.join(', ')}"`, + ); + } + + let verified = false; + if (fmt === 'none') { + if (attStmt.size > 0) { + throw new Error('None attestation had unexpected attestation statement'); + } + verified = true; + } else if (fmt === 'packed') { + verified = await verifyPackedAttestation( + attStmt, + authData, + attestationResponse.clientDataJSON, + decodedPublicKey, + ); + } else { + throw new Error(`Unsupported attestation format: ${fmt}`); + } + + if (!verified) { + return { verified: false }; + } + + return { + verified: true, + registrationInfo: { + credentialId: attestedCredentialId, + publicKey: credentialPublicKey, + counter, + transports: + attestationResponse.transports as AuthenticatorTransportFuture[], + aaguid: formatAAGUID(aaguid), + attestationFormat: fmt, + userVerified: flags.uv, + }, + }; +} + +/** + * Reads the authenticator AAGUID out of a registration response by decoding its + * attestation object and parsing the attested credential data within + * `authData`. + * + * The AAGUID identifies the authenticator model (e.g. iCloud Keychain, Google + * Password Manager, a hardware key), so it is useful for telemetry or for + * showing the user where their passkey lives. + * + * Unlike {@link verifyRegistrationResponse}, this reads the response as-is + * without verifying it, so treat the value as untrusted until enrollment + * completes; the verified AAGUID is persisted on + * `passkeyRecord.credential.aaguid`. Note also that many authenticators + * deliberately report an all-zero AAGUID. + * + * @param registrationResponse - Result of `navigator.credentials.create()`. + * @returns The AAGUID as a dashed UUID string, or `undefined` if the + * authenticator data carries no attested credential data. + * @throws If the attestation object or its authenticator data is malformed. + */ +export function getAAGUIDFromRegistrationResponse( + registrationResponse: PasskeyRegistrationResponse, +): string | undefined { + const attestationObject = decodeAttestationObject( + base64URLToBytes(registrationResponse.response.attestationObject), + ); + const { aaguid } = parseAuthenticatorData(attestationObject.get('authData')); + return aaguid ? formatAAGUID(aaguid) : undefined; +} + +/** + * Format the raw 16-byte AAGUID from attested credential data as a dashed + * UUID string (8-4-4-4-12). + * + * @param aaguid - Raw AAGUID bytes. + * @returns The AAGUID in canonical UUID form. + */ +function formatAAGUID(aaguid: Uint8Array): string { + const aaguidHex = bytesToHex(aaguid); + return [ + aaguidHex.slice(0, 8), + aaguidHex.slice(8, 12), + aaguidHex.slice(12, 16), + aaguidHex.slice(16, 20), + aaguidHex.slice(20), + ].join('-'); +} + +/** + * Verify packed self-attestation per WebAuthn §8.2: no x5c certificate + * chain, signature over `authData || SHA-256(clientDataJSON)` verified + * with the credential's own public key, and `alg` in the attestation + * statement must match the credential key's algorithm. + * + * @param attStmt - The attestation statement map from the attestation + * object. + * @param attStmt.get - Accessor to retrieve statement fields by key. + * @param attStmt.size - Number of entries in the statement. + * @param authData - Raw authenticator data bytes. + * @param clientDataJSONB64url - Base64url-encoded clientDataJSON. + * @param cosePublicKey - Decoded COSE public key map from authenticator + * data. + * @returns Whether the packed attestation signature is valid. + */ +async function verifyPackedAttestation( + attStmt: { get(key: string): unknown; size: number }, + authData: Uint8Array, + clientDataJSONB64url: string, + cosePublicKey: Map, +): Promise { + const attStmtAlg = attStmt.get('alg') as number | undefined; + const signature = attStmt.get('sig') as Uint8Array | undefined; + const x5c = attStmt.get('x5c') as Uint8Array[] | undefined; + + if (typeof attStmtAlg !== 'number') { + throw new Error('Packed attestation statement missing alg'); + } + + if (!signature) { + throw new Error('Packed attestation missing signature'); + } + + if (x5c && x5c.length > 0) { + throw new Error( + 'Packed attestation with certificate chain (x5c) is not supported; only self-attestation is accepted', + ); + } + + const credAlg = cosePublicKey.get(COSEKEYS.Alg) as number; + if (attStmtAlg !== credAlg) { + throw new Error( + `Packed attestation alg ${attStmtAlg} does not match credential alg ${credAlg}`, + ); + } + + const clientDataHash = sha256(base64URLToBytes(clientDataJSONB64url)); + const signatureBase = concatBytes([authData, clientDataHash]); + + return verifySignature({ + cosePublicKey, + signature, + data: signatureBase, + }); +} diff --git a/packages/passkey-controller/src/webauthn/verify-signature.test.ts b/packages/passkey-controller/src/webauthn/verify-signature.test.ts new file mode 100644 index 00000000000..67b68caa274 --- /dev/null +++ b/packages/passkey-controller/src/webauthn/verify-signature.test.ts @@ -0,0 +1,708 @@ +import { ed25519 } from '@noble/curves/ed25519'; +import { p384, p521 } from '@noble/curves/nist'; +import { sha384, sha512 } from '@noble/hashes/sha2'; +import { webcrypto } from 'node:crypto'; + +import { base64URLToBytes } from '../utils/encoding.js'; +import { COSEALG, COSECRV, COSEKEYS, COSEKTY } from './constants.js'; +import { verifySignature } from './verify-signature.js'; + +function decodeJwkBase64Url(value: string): Uint8Array { + return Uint8Array.from( + atob( + value.replace(/-/gu, '+').replace(/_/gu, '/') + + '='.repeat((4 - (value.length % 4)) % 4), + ), + (char) => char.charCodeAt(0), + ); +} + +describe('verifySignature', () => { + it('verifies P-256 EC2 signature from conformance vector', async () => { + const coseMap = new Map(); + coseMap.set(COSEKEYS.Kty, COSEKTY.EC2); + coseMap.set(COSEKEYS.Alg, COSEALG.ES256); + coseMap.set(COSEKEYS.Crv, COSECRV.P256); + coseMap.set( + COSEKEYS.X, + base64URLToBytes('_qRi-kwOVobsqJ_1GAHZYfC77QoIdsVFYkx2Mw20UM4'), + ); + coseMap.set( + COSEKEYS.Y, + base64URLToBytes('BXEathwyOK_uQRmlZ_m4wReHLujSXk_-e3-9co5B2MY'), + ); + + const data = base64URLToBytes( + 'Bt81jmu3ieajF4w1at8HmieVOTDymHd7xJguJCUsL-Q', + ); + const signature = base64URLToBytes( + 'MEQCH1h_F7TPTMVh_kwb_ssjD0_2U77bbXazz2ux-P6khLQCIQCutHs9eCBkCIMP3yA9mmNRKEfFd-REmhGY2GbHozaC7w', + ); + + const result = await verifySignature({ + cosePublicKey: coseMap, + signature, + data, + }); + + expect(result).toBe(true); + }); + + it('verifies P-384 EC2 signature', async () => { + const privateKey = p384.utils.randomSecretKey(); + const publicKeyRaw = p384.getPublicKey(privateKey, false); + + const coseMap = new Map(); + coseMap.set(COSEKEYS.Kty, COSEKTY.EC2); + coseMap.set(COSEKEYS.Alg, COSEALG.ES384); + coseMap.set(COSEKEYS.Crv, COSECRV.P384); + coseMap.set(COSEKEYS.X, publicKeyRaw.slice(1, 49)); + coseMap.set(COSEKEYS.Y, publicKeyRaw.slice(49, 97)); + + const data = new Uint8Array(32).fill(0xcc); + const hash = sha384(data); + const ecdsaSig = p384.sign(hash, privateKey); + + const result = await verifySignature({ + cosePublicKey: coseMap, + signature: new Uint8Array(ecdsaSig.toDERRawBytes()), + data, + }); + + expect(result).toBe(true); + }); + + it('verifies P-384 EC2 signature from conformance vector', async () => { + const coseMap = new Map(); + coseMap.set(COSEKEYS.Kty, COSEKTY.EC2); + coseMap.set(COSEKEYS.Alg, COSEALG.ES384); + coseMap.set(COSEKEYS.Crv, COSECRV.P384); + coseMap.set( + COSEKEYS.X, + base64URLToBytes( + 'pm-0exykk1x0O72S9sm6fl-iXxFrGikjQHi1CgONIiEz_yDJdCPxN453qg6HLkOx', + ), + ); + coseMap.set( + COSEKEYS.Y, + base64URLToBytes( + '2B7yW7sgza8Sf7ifznQlGJqmJxgupkAevUqqOJTWaWBZiQ7sAf-TfAaNBukiz12K', + ), + ); + + const data = base64URLToBytes( + 'D7mI8UwWXv4rpfSQUNqtUXAhZEPbRLugmWclPpJ9m7c', + ); + const signature = base64URLToBytes( + 'MGMCL3lZ2Rjxo5WcmTCdWyB6jTE9PVuduOR_AsJu956J9S_mFNbHP_-MbyWem4dfb5iqAjABJhTRltNl5Y0O4XC7YLNsYKq2WxYQ1HFOMGsr6oNkUPsX3UAr2zeeWL_Tp1VgHeM', + ); + + const result = await verifySignature({ + cosePublicKey: coseMap, + signature, + data, + }); + + expect(result).toBe(true); + }); + + it('verifies P-521 EC2 signature from conformance vector', async () => { + const coseMap = new Map(); + coseMap.set(COSEKEYS.Kty, COSEKTY.EC2); + coseMap.set(COSEKEYS.Alg, COSEALG.ES512); + coseMap.set(COSEKEYS.Crv, COSECRV.P521); + coseMap.set( + COSEKEYS.X, + base64URLToBytes( + 'AaLbnrCvCuQivbknRW50FjdqPQv4NRF9tHsN4QuVQ3sw8uSspd33o-NTBfjg5JzX9rnpbkKDigb6NugmrVjzNMNK', + ), + ); + coseMap.set( + COSEKEYS.Y, + base64URLToBytes( + 'AE64axa8L8PkLX5Td0GaX79cLOW9E2-8-ObhL9XT_ih-1XxbGQcA5VhL1gI0xIQq5zYAxgZYey6PmbbqgtcUPRVt', + ), + ); + + const data = base64URLToBytes( + '5p0h9RZTjLoBlnL2nY5pqOnhGy4q60NzbjDe2rVDR7o', + ); + const signature = base64URLToBytes( + 'MIGHAkFRpbGknlgpETORypMprGBXMkJMfuqgJupy3NcgCOaJJdj3Voz74kV2pjPqkLNpuO9FqVtXeEsUw-jYsBHcMqHZhwJCAQ88uFDJS5g81XVBcLMIgf6ro-F-5jgRAmHx3CRVNGdk81MYbFJhT3hd2w9RdhT8qBG0zzRBXYAcHrKo0qJwQZot', + ); + + const result = await verifySignature({ + cosePublicKey: coseMap, + signature, + data, + }); + + expect(result).toBe(true); + }); + + it('verifies Ed25519 OKP signature', async () => { + const privateKey = ed25519.utils.randomSecretKey(); + const publicKey = ed25519.getPublicKey(privateKey); + + const coseMap = new Map(); + coseMap.set(COSEKEYS.Kty, COSEKTY.OKP); + coseMap.set(COSEKEYS.Alg, COSEALG.EdDSA); + coseMap.set(COSEKEYS.Crv, COSECRV.ED25519); + coseMap.set(COSEKEYS.X, publicKey); + + const data = new Uint8Array(32).fill(0xdd); + const signature = ed25519.sign(data, privateKey); + + const result = await verifySignature({ + cosePublicKey: coseMap, + signature, + data, + }); + + expect(result).toBe(true); + }); + + it('verifies Ed25519 OKP signature from conformance vector', async () => { + const coseMap = new Map(); + coseMap.set(COSEKEYS.Kty, COSEKTY.OKP); + coseMap.set(COSEKEYS.Alg, COSEALG.EdDSA); + coseMap.set(COSEKEYS.Crv, COSECRV.ED25519); + coseMap.set( + COSEKEYS.X, + base64URLToBytes('bN-2dTH53XfUq55T1RkvXMpwHV0dRVnMBPxuOBm1-vI'), + ); + + const data = base64URLToBytes( + 'SZYN5YgOjGh0NBcPZHZgW4_krrmihjLHmVzzuoMdl2NBAAAAMpHf6teVnkR1rSabDUgr4IkAIBqlqljErWIWWTGYn6Lqjsb8p3djr7sVZW7WYoECyh5xpAEBAycgBiFYIGzftnUx-d131KueU9UZL1zKcB1dHUVZzAT8bjgZtfrytEHOGqAdESuKacg0dIwKWfEP8VP4or6CINxkD5qWQYw', + ); + const signature = base64URLToBytes( + 'HdoQloEiGSUHf9dJXbVzyWNbDh0K25tpNQQpj5hrkhCcdfz0pCBPtqChka_4kfIbhf6JyY1EGAuf9pQdwqJVBQ', + ); + + const result = await verifySignature({ + cosePublicKey: coseMap, + signature, + data, + }); + + expect(result).toBe(true); + }); + + it('throws for unsupported EC2 curve', async () => { + const coseMap = new Map(); + coseMap.set(COSEKEYS.Kty, COSEKTY.EC2); + coseMap.set(COSEKEYS.Alg, COSEALG.ES256); + coseMap.set(COSEKEYS.Crv, 99); + coseMap.set(COSEKEYS.X, new Uint8Array(32)); + coseMap.set(COSEKEYS.Y, new Uint8Array(32)); + + await expect( + verifySignature({ + cosePublicKey: coseMap, + signature: new Uint8Array(64), + data: new Uint8Array(32), + }), + ).rejects.toThrow('Unsupported EC2 curve'); + }); + + it('throws for missing EC2 coordinates', async () => { + const coseMap = new Map(); + coseMap.set(COSEKEYS.Kty, COSEKTY.EC2); + coseMap.set(COSEKEYS.Alg, COSEALG.ES256); + coseMap.set(COSEKEYS.Crv, COSECRV.P256); + + await expect( + verifySignature({ + cosePublicKey: coseMap, + signature: new Uint8Array(64), + data: new Uint8Array(32), + }), + ).rejects.toThrow('EC2 public key missing x or y coordinate'); + }); + + it('throws for missing EC2 alg', async () => { + const coseMap = new Map(); + coseMap.set(COSEKEYS.Kty, COSEKTY.EC2); + coseMap.set(COSEKEYS.Crv, COSECRV.P256); + coseMap.set(COSEKEYS.X, new Uint8Array(32)); + coseMap.set(COSEKEYS.Y, new Uint8Array(32)); + + await expect( + verifySignature({ + cosePublicKey: coseMap, + signature: new Uint8Array(64), + data: new Uint8Array(32), + }), + ).rejects.toThrow('EC2 public key missing alg'); + }); + + it('throws for missing OKP x coordinate', async () => { + const coseMap = new Map(); + coseMap.set(COSEKEYS.Kty, COSEKTY.OKP); + coseMap.set(COSEKEYS.Alg, COSEALG.EdDSA); + coseMap.set(COSEKEYS.Crv, COSECRV.ED25519); + + await expect( + verifySignature({ + cosePublicKey: coseMap, + signature: new Uint8Array(64), + data: new Uint8Array(32), + }), + ).rejects.toThrow('OKP public key missing x coordinate'); + }); + + it('throws for unsupported OKP algorithm', async () => { + const privateKey = ed25519.utils.randomSecretKey(); + const publicKey = ed25519.getPublicKey(privateKey); + + const coseMap = new Map(); + coseMap.set(COSEKEYS.Kty, COSEKTY.OKP); + coseMap.set(COSEKEYS.Alg, COSEALG.ES256); + coseMap.set(COSEKEYS.Crv, COSECRV.ED25519); + coseMap.set(COSEKEYS.X, publicKey); + + await expect( + verifySignature({ + cosePublicKey: coseMap, + signature: ed25519.sign(new Uint8Array(32), privateKey), + data: new Uint8Array(32), + }), + ).rejects.toThrow('Unexpected OKP algorithm'); + }); + + it('throws for unsupported OKP curve', async () => { + const privateKey = ed25519.utils.randomSecretKey(); + const publicKey = ed25519.getPublicKey(privateKey); + + const coseMap = new Map(); + coseMap.set(COSEKEYS.Kty, COSEKTY.OKP); + coseMap.set(COSEKEYS.Alg, COSEALG.EdDSA); + coseMap.set(COSEKEYS.Crv, COSECRV.P256); + coseMap.set(COSEKEYS.X, publicKey); + + await expect( + verifySignature({ + cosePublicKey: coseMap, + signature: ed25519.sign(new Uint8Array(32), privateKey), + data: new Uint8Array(32), + }), + ).rejects.toThrow('Unsupported OKP curve'); + }); + + it('throws for missing kty', async () => { + const coseMap = new Map(); + coseMap.set(COSEKEYS.Alg, COSEALG.ES256); + + await expect( + verifySignature({ + cosePublicKey: coseMap, + signature: new Uint8Array(64), + data: new Uint8Array(32), + }), + ).rejects.toThrow('COSE public key missing kty'); + }); + + it('throws for unsupported key type', async () => { + const coseMap = new Map(); + coseMap.set(COSEKEYS.Kty, 99); + coseMap.set(COSEKEYS.Alg, COSEALG.ES256); + + await expect( + verifySignature({ + cosePublicKey: coseMap, + signature: new Uint8Array(64), + data: new Uint8Array(32), + }), + ).rejects.toThrow('Unsupported COSE key type'); + }); + + it('verifies RSA signature via Web Crypto', async () => { + const keyPair = await webcrypto.subtle.generateKey( + { + name: 'RSASSA-PKCS1-v1_5', + modulusLength: 2048, + publicExponent: new Uint8Array([1, 0, 1]), + hash: { name: 'SHA-256' }, + }, + true, + ['sign', 'verify'], + ); + + const data = new Uint8Array(32).fill(0xee); + const signature = new Uint8Array( + await webcrypto.subtle.sign( + 'RSASSA-PKCS1-v1_5', + keyPair.privateKey, + data, + ), + ); + + const jwk = await webcrypto.subtle.exportKey('jwk', keyPair.publicKey); + + const nBytes = decodeJwkBase64Url(jwk.n as string); + const eBytes = decodeJwkBase64Url(jwk.e as string); + + const coseMap = new Map(); + coseMap.set(COSEKEYS.Kty, COSEKTY.RSA); + coseMap.set(COSEKEYS.Alg, COSEALG.RS256); + coseMap.set(-1, nBytes); + coseMap.set(-2, eBytes); + + const result = await verifySignature({ + cosePublicKey: coseMap, + signature, + data, + }); + + expect(result).toBe(true); + }); + + it('throws for unsupported RSA algorithm', async () => { + const coseMap = new Map(); + coseMap.set(COSEKEYS.Kty, COSEKTY.RSA); + coseMap.set(COSEKEYS.Alg, -999); + coseMap.set(-1, new Uint8Array(256)); + coseMap.set(-2, new Uint8Array([1, 0, 1])); + + await expect( + verifySignature({ + cosePublicKey: coseMap, + signature: new Uint8Array(256), + data: new Uint8Array(32), + }), + ).rejects.toThrow('Unsupported RSA algorithm'); + }); + + it('throws for missing RSA n or e', async () => { + const coseMap = new Map(); + coseMap.set(COSEKEYS.Kty, COSEKTY.RSA); + coseMap.set(COSEKEYS.Alg, COSEALG.RS256); + + await expect( + verifySignature({ + cosePublicKey: coseMap, + signature: new Uint8Array(256), + data: new Uint8Array(32), + }), + ).rejects.toThrow('RSA public key missing n or e'); + }); + + it('throws for missing RSA alg', async () => { + const coseMap = new Map(); + coseMap.set(COSEKEYS.Kty, COSEKTY.RSA); + coseMap.set(COSEKEYS.N, new Uint8Array(256).fill(1)); + coseMap.set(COSEKEYS.E, new Uint8Array([1, 0, 1])); + + await expect( + verifySignature({ + cosePublicKey: coseMap, + signature: new Uint8Array(256), + data: new Uint8Array(32), + }), + ).rejects.toThrow('RSA public key missing alg'); + }); + + it('verifies PS256 signature via Web Crypto', async () => { + const keyPair = await webcrypto.subtle.generateKey( + { + name: 'RSA-PSS', + modulusLength: 2048, + publicExponent: new Uint8Array([1, 0, 1]), + hash: { name: 'SHA-256' }, + }, + true, + ['sign', 'verify'], + ); + + const data = new Uint8Array(32).fill(0x9a); + const signature = new Uint8Array( + await webcrypto.subtle.sign( + { name: 'RSA-PSS', saltLength: 32 }, + keyPair.privateKey, + data, + ), + ); + + const jwk = await webcrypto.subtle.exportKey('jwk', keyPair.publicKey); + const nBytes = decodeJwkBase64Url(jwk.n as string); + const eBytes = decodeJwkBase64Url(jwk.e as string); + + const coseMap = new Map(); + coseMap.set(COSEKEYS.Kty, COSEKTY.RSA); + coseMap.set(COSEKEYS.Alg, COSEALG.PS256); + coseMap.set(COSEKEYS.N, nBytes); + coseMap.set(COSEKEYS.E, eBytes); + + const result = await verifySignature({ + cosePublicKey: coseMap, + signature, + data, + }); + + expect(result).toBe(true); + }); + + it('verifies RS1 signature via Web Crypto', async () => { + const keyPair = await webcrypto.subtle.generateKey( + { + name: 'RSASSA-PKCS1-v1_5', + modulusLength: 2048, + publicExponent: new Uint8Array([1, 0, 1]), + hash: { name: 'SHA-1' }, + }, + true, + ['sign', 'verify'], + ); + + const data = new Uint8Array(32).fill(0x44); + const signature = new Uint8Array( + await webcrypto.subtle.sign( + 'RSASSA-PKCS1-v1_5', + keyPair.privateKey, + data, + ), + ); + + const jwk = await webcrypto.subtle.exportKey('jwk', keyPair.publicKey); + const nBytes = decodeJwkBase64Url(jwk.n as string); + const eBytes = decodeJwkBase64Url(jwk.e as string); + + const coseMap = new Map(); + coseMap.set(COSEKEYS.Kty, COSEKTY.RSA); + coseMap.set(COSEKEYS.Alg, COSEALG.RS1); + coseMap.set(COSEKEYS.N, nBytes); + coseMap.set(COSEKEYS.E, eBytes); + + const result = await verifySignature({ + cosePublicKey: coseMap, + signature, + data, + }); + + expect(result).toBe(true); + }); + + it('verifies PS384 signature via Web Crypto', async () => { + const keyPair = await webcrypto.subtle.generateKey( + { + name: 'RSA-PSS', + modulusLength: 2048, + publicExponent: new Uint8Array([1, 0, 1]), + hash: { name: 'SHA-384' }, + }, + true, + ['sign', 'verify'], + ); + + const data = new Uint8Array(32).fill(0x4a); + const signature = new Uint8Array( + await webcrypto.subtle.sign( + { name: 'RSA-PSS', saltLength: 48 }, + keyPair.privateKey, + data, + ), + ); + + const jwk = await webcrypto.subtle.exportKey('jwk', keyPair.publicKey); + const nBytes = decodeJwkBase64Url(jwk.n as string); + const eBytes = decodeJwkBase64Url(jwk.e as string); + + const coseMap = new Map(); + coseMap.set(COSEKEYS.Kty, COSEKTY.RSA); + coseMap.set(COSEKEYS.Alg, COSEALG.PS384); + coseMap.set(COSEKEYS.N, nBytes); + coseMap.set(COSEKEYS.E, eBytes); + + const result = await verifySignature({ + cosePublicKey: coseMap, + signature, + data, + }); + + expect(result).toBe(true); + }); + + it('verifies PS512 signature via Web Crypto', async () => { + const keyPair = await webcrypto.subtle.generateKey( + { + name: 'RSA-PSS', + modulusLength: 2048, + publicExponent: new Uint8Array([1, 0, 1]), + hash: { name: 'SHA-512' }, + }, + true, + ['sign', 'verify'], + ); + + const data = new Uint8Array(32).fill(0x5a); + const signature = new Uint8Array( + await webcrypto.subtle.sign( + { name: 'RSA-PSS', saltLength: 64 }, + keyPair.privateKey, + data, + ), + ); + + const jwk = await webcrypto.subtle.exportKey('jwk', keyPair.publicKey); + const nBytes = decodeJwkBase64Url(jwk.n as string); + const eBytes = decodeJwkBase64Url(jwk.e as string); + + const coseMap = new Map(); + coseMap.set(COSEKEYS.Kty, COSEKTY.RSA); + coseMap.set(COSEKEYS.Alg, COSEALG.PS512); + coseMap.set(COSEKEYS.N, nBytes); + coseMap.set(COSEKEYS.E, eBytes); + + const result = await verifySignature({ + cosePublicKey: coseMap, + signature, + data, + }); + + expect(result).toBe(true); + }); + + it('verifies RS256 with subarray buffers', async () => { + const keyPair = await webcrypto.subtle.generateKey( + { + name: 'RSASSA-PKCS1-v1_5', + modulusLength: 2048, + publicExponent: new Uint8Array([1, 0, 1]), + hash: { name: 'SHA-256' }, + }, + true, + ['sign', 'verify'], + ); + + const data = new Uint8Array(32).fill(0x7c); + const signature = new Uint8Array( + await webcrypto.subtle.sign( + 'RSASSA-PKCS1-v1_5', + keyPair.privateKey, + data, + ), + ); + + const signatureContainer = new Uint8Array(signature.length + 8); + signatureContainer.set(signature, 4); + const signatureSubarray = signatureContainer.subarray( + 4, + 4 + signature.length, + ); + + const dataContainer = new Uint8Array(data.length + 10); + dataContainer.set(data, 5); + const dataSubarray = dataContainer.subarray(5, 5 + data.length); + + const jwk = await webcrypto.subtle.exportKey('jwk', keyPair.publicKey); + const nBytes = decodeJwkBase64Url(jwk.n as string); + const eBytes = decodeJwkBase64Url(jwk.e as string); + + const coseMap = new Map(); + coseMap.set(COSEKEYS.Kty, COSEKTY.RSA); + coseMap.set(COSEKEYS.Alg, COSEALG.RS256); + coseMap.set(COSEKEYS.N, nBytes); + coseMap.set(COSEKEYS.E, eBytes); + + const result = await verifySignature({ + cosePublicKey: coseMap, + signature: signatureSubarray, + data: dataSubarray, + }); + + expect(result).toBe(true); + }); +}); + +describe('verifySignature RSA hash variants', () => { + async function generateRSAKeyPairAndSign( + hashName: string, + alg: number, + ): Promise<{ + coseMap: Map; + signature: Uint8Array; + data: Uint8Array; + }> { + const keyPair = await webcrypto.subtle.generateKey( + { + name: 'RSASSA-PKCS1-v1_5', + modulusLength: 2048, + publicExponent: new Uint8Array([1, 0, 1]), + hash: { name: hashName }, + }, + true, + ['sign', 'verify'], + ); + + const data = new Uint8Array(32).fill(0xff); + const signature = new Uint8Array( + await webcrypto.subtle.sign( + 'RSASSA-PKCS1-v1_5', + keyPair.privateKey, + data, + ), + ); + + const jwk = await webcrypto.subtle.exportKey('jwk', keyPair.publicKey); + const nBytes = decodeJwkBase64Url(jwk.n as string); + const eBytes = decodeJwkBase64Url(jwk.e as string); + + const coseMap = new Map(); + coseMap.set(COSEKEYS.Kty, COSEKTY.RSA); + coseMap.set(COSEKEYS.Alg, alg); + coseMap.set(-1, nBytes); + coseMap.set(-2, eBytes); + + return { coseMap, signature, data }; + } + + it('verifies RS384 signature', async () => { + const { coseMap, signature, data } = await generateRSAKeyPairAndSign( + 'SHA-384', + COSEALG.RS384, + ); + const result = await verifySignature({ + cosePublicKey: coseMap, + signature, + data, + }); + expect(result).toBe(true); + }); + + it('verifies RS512 signature', async () => { + const { coseMap, signature, data } = await generateRSAKeyPairAndSign( + 'SHA-512', + COSEALG.RS512, + ); + const result = await verifySignature({ + cosePublicKey: coseMap, + signature, + data, + }); + expect(result).toBe(true); + }); + + it('verifies ES512/P-521 signature', async () => { + const privateKey = p521.utils.randomSecretKey(); + const publicKeyRaw = p521.getPublicKey(privateKey, false); + + const coseMap = new Map(); + coseMap.set(COSEKEYS.Kty, COSEKTY.EC2); + coseMap.set(COSEKEYS.Alg, COSEALG.ES512); + coseMap.set(COSEKEYS.Crv, COSECRV.P521); + coseMap.set(COSEKEYS.X, publicKeyRaw.slice(1, 67)); + coseMap.set(COSEKEYS.Y, publicKeyRaw.slice(67, 133)); + + const data = new Uint8Array(32).fill(0xab); + const hash = sha512(data); + const ecdsaSig = p521.sign(hash, privateKey); + + const result = await verifySignature({ + cosePublicKey: coseMap, + signature: new Uint8Array(ecdsaSig.toDERRawBytes()), + data, + }); + + expect(result).toBe(true); + }); +}); diff --git a/packages/passkey-controller/src/webauthn/verify-signature.ts b/packages/passkey-controller/src/webauthn/verify-signature.ts new file mode 100644 index 00000000000..558d51724f5 --- /dev/null +++ b/packages/passkey-controller/src/webauthn/verify-signature.ts @@ -0,0 +1,222 @@ +import { concatBytes } from '@metamask/utils'; +import { ed25519 } from '@noble/curves/ed25519'; +import { p256, p384, p521 } from '@noble/curves/nist'; +import { sha256, sha384, sha512 } from '@noble/hashes/sha2'; + +import { bytesToBase64URL } from '../utils/encoding.js'; +import { COSEALG, COSECRV, COSEKEYS, COSEKTY } from './constants.js'; + +type COSEPublicKey = Map; + +/** + * Get the key type from a COSE public key map. + * + * @param cosePublicKey - COSE public key map. + * @returns The COSEKTY value. + */ +function getKeyType(cosePublicKey: COSEPublicKey): number { + const kty = cosePublicKey.get(COSEKEYS.Kty); + if (typeof kty !== 'number') { + throw new Error('COSE public key missing kty'); + } + return kty; +} + +/** + * Verify an EC2 (P-256, P-384, P-521) signature using @noble/curves. + * + * ECDSA requires the data to be hashed with the curve-appropriate + * algorithm before verification: SHA-256 for P-256 and SHA-384 for P-384. + * + * @param cosePublicKey - COSE-encoded EC2 public key. + * @param signature - DER-encoded ECDSA signature. + * @param data - Data that was signed. + * @returns Whether the signature is valid. + */ +function verifyEC2( + cosePublicKey: COSEPublicKey, + signature: Uint8Array, + data: Uint8Array, +): boolean { + const alg = cosePublicKey.get(COSEKEYS.Alg); + const crv = cosePublicKey.get(COSEKEYS.Crv) as number; + const xCoord = cosePublicKey.get(COSEKEYS.X) as Uint8Array; + const yCoord = cosePublicKey.get(COSEKEYS.Y) as Uint8Array; + + if (typeof alg !== 'number') { + throw new Error('EC2 public key missing alg'); + } + + if (!xCoord || !yCoord) { + throw new Error('EC2 public key missing x or y coordinate'); + } + + const uncompressed = concatBytes([new Uint8Array([0x04]), xCoord, yCoord]); + + switch (crv) { + case COSECRV.P256: + return p256.verify(signature, sha256(data), uncompressed); + case COSECRV.P384: + return p384.verify(signature, sha384(data), uncompressed); + case COSECRV.P521: + return p521.verify(signature, sha512(data), uncompressed); + default: + throw new Error(`Unsupported EC2 curve: ${crv}`); + } +} + +/** + * Verify an OKP (Ed25519) signature using @noble/curves. + * + * @param cosePublicKey - COSE-encoded OKP public key. + * @param signature - Raw Ed25519 signature (64 bytes). + * @param data - Data that was signed. + * @returns Whether the signature is valid. + */ +function verifyOKP( + cosePublicKey: COSEPublicKey, + signature: Uint8Array, + data: Uint8Array, +): boolean { + const alg = cosePublicKey.get(COSEKEYS.Alg); + const crv = cosePublicKey.get(COSEKEYS.Crv); + const xCoord = cosePublicKey.get(COSEKEYS.X) as Uint8Array; + + if (alg !== COSEALG.EdDSA) { + throw new Error(`Unexpected OKP algorithm: ${String(alg)}`); + } + + if (crv !== COSECRV.ED25519) { + throw new Error(`Unsupported OKP curve: ${String(crv)}`); + } + + if (!xCoord) { + throw new Error('OKP public key missing x coordinate'); + } + + return ed25519.verify(signature, data, xCoord); +} + +/** + * Verify an RSA signature using Web Crypto API. + * + * @param cosePublicKey - COSE-encoded RSA public key. + * @param signature - RSA PKCS#1 v1.5 signature. + * @param data - Data that was signed. + * @returns Whether the signature is valid. + */ +async function verifyRSA( + cosePublicKey: COSEPublicKey, + signature: Uint8Array, + data: Uint8Array, +): Promise { + const alg = cosePublicKey.get(COSEKEYS.Alg); + const modulus = cosePublicKey.get(COSEKEYS.N) as Uint8Array; + const exponent = cosePublicKey.get(COSEKEYS.E) as Uint8Array; + + if (typeof alg !== 'number') { + throw new Error('RSA public key missing alg'); + } + + if (!modulus || !exponent) { + throw new Error('RSA public key missing n or e'); + } + + let keyAlgorithmName: 'RSASSA-PKCS1-v1_5' | 'RSA-PSS'; + let hashAlg: string; + let saltLength: number | undefined; + switch (alg) { + case COSEALG.RS1: + keyAlgorithmName = 'RSASSA-PKCS1-v1_5'; + hashAlg = 'SHA-1'; + break; + case COSEALG.RS256: + keyAlgorithmName = 'RSASSA-PKCS1-v1_5'; + hashAlg = 'SHA-256'; + break; + case COSEALG.RS384: + keyAlgorithmName = 'RSASSA-PKCS1-v1_5'; + hashAlg = 'SHA-384'; + break; + case COSEALG.RS512: + keyAlgorithmName = 'RSASSA-PKCS1-v1_5'; + hashAlg = 'SHA-512'; + break; + case COSEALG.PS256: + keyAlgorithmName = 'RSA-PSS'; + hashAlg = 'SHA-256'; + saltLength = 32; + break; + case COSEALG.PS384: + keyAlgorithmName = 'RSA-PSS'; + hashAlg = 'SHA-384'; + saltLength = 48; + break; + case COSEALG.PS512: + keyAlgorithmName = 'RSA-PSS'; + hashAlg = 'SHA-512'; + saltLength = 64; + break; + default: + throw new Error(`Unsupported RSA algorithm: ${alg}`); + } + + const key = await globalThis.crypto.subtle.importKey( + 'jwk', + { + kty: 'RSA', + n: bytesToBase64URL(modulus), + e: bytesToBase64URL(exponent), + }, + { name: keyAlgorithmName, hash: { name: hashAlg } }, + false, + ['verify'], + ); + + const verifyAlgorithm = + keyAlgorithmName === 'RSA-PSS' + ? { name: 'RSA-PSS', saltLength: saltLength as number } + : 'RSASSA-PKCS1-v1_5'; + + const signatureBytes = Uint8Array.from(signature); + const dataBytes = Uint8Array.from(data); + return globalThis.crypto.subtle.verify( + verifyAlgorithm, + key, + signatureBytes, + dataBytes, + ); +} + +/** + * Verify a WebAuthn signature using the appropriate algorithm based on + * the COSE key type. + * + * Uses @noble/curves for EC2 and OKP (synchronous, audited, handles DER + * natively). Falls back to Web Crypto API for RSA. + * + * @param opts - Options object. + * @param opts.cosePublicKey - COSE-encoded public key as a Map. + * @param opts.signature - The signature bytes. + * @param opts.data - The data that was signed. + * @returns Whether the signature is valid. + */ +export async function verifySignature(opts: { + cosePublicKey: COSEPublicKey; + signature: Uint8Array; + data: Uint8Array; +}): Promise { + const { cosePublicKey, signature, data } = opts; + const kty = getKeyType(cosePublicKey); + + switch (kty) { + case COSEKTY.EC2: + return verifyEC2(cosePublicKey, signature, data); + case COSEKTY.OKP: + return verifyOKP(cosePublicKey, signature, data); + case COSEKTY.RSA: + return verifyRSA(cosePublicKey, signature, data); + default: + throw new Error(`Unsupported COSE key type: ${kty}`); + } +} diff --git a/packages/passkey-controller/tests/mocks/passkey-controller-messenger.ts b/packages/passkey-controller/tests/mocks/passkey-controller-messenger.ts new file mode 100644 index 00000000000..786b42f64ca --- /dev/null +++ b/packages/passkey-controller/tests/mocks/passkey-controller-messenger.ts @@ -0,0 +1,118 @@ +import { Messenger, MOCK_ANY_NAMESPACE } from '@metamask/messenger'; +import type { + MessengerActions, + MessengerEvents, + MockAnyNamespace, +} from '@metamask/messenger'; + +import { controllerName } from '../../src/constants.js'; +import type { PasskeyControllerMessenger } from '../../src/types.js'; + +type AllPasskeyControllerActions = MessengerActions; + +type AllPasskeyControllerEvents = MessengerEvents; + +export type RootPasskeyControllerMessenger = Messenger< + MockAnyNamespace, + AllPasskeyControllerActions, + AllPasskeyControllerEvents +>; + +const PASSKEY_CONTROLLER_ALLOWED_KEYRING_ACTIONS = [ + 'KeyringController:verifyPassword', + 'KeyringController:exportEncryptionKey', + 'KeyringController:submitEncryptionKey', + 'KeyringController:changePassword', + 'KeyringController:exportSeedPhrase', + 'KeyringController:exportAccount', +] as const; + +export type PasskeyControllerKeyringActionMocks = { + verifyPassword?: jest.Mock; + exportEncryptionKey?: jest.Mock; + submitEncryptionKey?: jest.Mock; + changePassword?: jest.Mock; + exportSeedPhrase?: jest.Mock; + exportAccount?: jest.Mock; +}; + +/** + * Creates a restricted {@link PasskeyControllerMessenger} with mock KeyringController + * action handlers registered on a parent messenger. + * + * @param mocks - Optional jest mocks for KeyringController actions. + * @returns Root and restricted messengers for {@link PasskeyController} tests. + */ +export function createMockPasskeyControllerMessenger( + mocks: PasskeyControllerKeyringActionMocks = {}, +): { + rootMessenger: RootPasskeyControllerMessenger; + messenger: PasskeyControllerMessenger; + mocks: Required; +} { + const resolvedMocks: Required = { + verifyPassword: mocks.verifyPassword ?? jest.fn(), + exportEncryptionKey: + mocks.exportEncryptionKey ?? jest.fn().mockResolvedValue('vault-key'), + submitEncryptionKey: mocks.submitEncryptionKey ?? jest.fn(), + changePassword: mocks.changePassword ?? jest.fn(), + exportSeedPhrase: + mocks.exportSeedPhrase ?? jest.fn().mockResolvedValue(new Uint8Array()), + exportAccount: mocks.exportAccount ?? jest.fn().mockResolvedValue('0xabc'), + }; + + const rootMessenger = new Messenger< + MockAnyNamespace, + AllPasskeyControllerActions, + AllPasskeyControllerEvents + >({ + namespace: MOCK_ANY_NAMESPACE, + }); + + rootMessenger.registerActionHandler( + 'KeyringController:verifyPassword', + resolvedMocks.verifyPassword, + ); + rootMessenger.registerActionHandler( + 'KeyringController:exportEncryptionKey', + resolvedMocks.exportEncryptionKey, + ); + rootMessenger.registerActionHandler( + 'KeyringController:submitEncryptionKey', + resolvedMocks.submitEncryptionKey, + ); + rootMessenger.registerActionHandler( + 'KeyringController:changePassword', + resolvedMocks.changePassword, + ); + rootMessenger.registerActionHandler( + 'KeyringController:exportSeedPhrase', + resolvedMocks.exportSeedPhrase, + ); + rootMessenger.registerActionHandler( + 'KeyringController:exportAccount', + resolvedMocks.exportAccount, + ); + + const messenger = new Messenger< + typeof controllerName, + AllPasskeyControllerActions, + AllPasskeyControllerEvents, + RootPasskeyControllerMessenger + >({ + namespace: controllerName, + parent: rootMessenger, + }); + + rootMessenger.delegate({ + messenger, + events: [], + actions: [...PASSKEY_CONTROLLER_ALLOWED_KEYRING_ACTIONS], + }); + + return { + rootMessenger, + messenger, + mocks: resolvedMocks, + }; +} diff --git a/packages/passkey-controller/tsconfig.build.json b/packages/passkey-controller/tsconfig.build.json new file mode 100644 index 00000000000..fb01dfc7b16 --- /dev/null +++ b/packages/passkey-controller/tsconfig.build.json @@ -0,0 +1,20 @@ +{ + "extends": "../../tsconfig.packages.build.json", + "compilerOptions": { + "baseUrl": "./", + "outDir": "./dist", + "rootDir": "./src" + }, + "references": [ + { + "path": "../base-controller/tsconfig.build.json" + }, + { + "path": "../keyring-controller/tsconfig.build.json" + }, + { + "path": "../messenger/tsconfig.build.json" + } + ], + "include": ["../../types", "./src"] +} diff --git a/packages/passkey-controller/tsconfig.json b/packages/passkey-controller/tsconfig.json new file mode 100644 index 00000000000..83569574c51 --- /dev/null +++ b/packages/passkey-controller/tsconfig.json @@ -0,0 +1,18 @@ +{ + "extends": "../../tsconfig.packages.json", + "compilerOptions": { + "baseUrl": "./" + }, + "references": [ + { + "path": "../base-controller" + }, + { + "path": "../keyring-controller" + }, + { + "path": "../messenger" + } + ], + "include": ["../../types", "./src", "./tests"] +} diff --git a/packages/passkey-controller/typedoc.json b/packages/passkey-controller/typedoc.json new file mode 100644 index 00000000000..c9da015dbf8 --- /dev/null +++ b/packages/passkey-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/permission-controller/.eslintrc.js b/packages/permission-controller/.eslintrc.js new file mode 100644 index 00000000000..1e60b9c9bd4 --- /dev/null +++ b/packages/permission-controller/.eslintrc.js @@ -0,0 +1,85 @@ +module.exports = { + extends: ['../../.eslintrc.js'], + + overrides: [ + { + files: ['src/PermissionController.test.ts'], + rules: { + // This is taken directly from @metamask/eslint-config-typescript@12.1.0 + '@typescript-eslint/naming-convention': [ + 'error', + // We have to disable the default selector for our objectLiteralProperty + // filter to work. + // { + // selector: 'default', + // format: ['camelCase'], + // leadingUnderscore: 'allow', + // trailingUnderscore: 'forbid', + // }, + { + selector: 'enumMember', + format: ['PascalCase'], + }, + { + selector: 'interface', + format: ['PascalCase'], + custom: { + regex: '^I[A-Z]', + match: false, + }, + }, + { + selector: 'objectLiteralMethod', + format: ['camelCase', 'PascalCase', 'UPPER_CASE'], + }, + // This option is modified by the addition of a filter. + { + selector: 'objectLiteralProperty', + format: ['camelCase', 'PascalCase', 'UPPER_CASE'], + filter: { + // Match RPC method names like foo_bar, foo_barBaz, etc., and metamask.io + regex: '(^[a-z]+_[a-z]+[a-zA-Z0-9]*)|metamask\\.io$', + match: false, + }, + }, + { + selector: 'typeLike', + format: ['PascalCase'], + }, + { + selector: 'typeParameter', + format: ['PascalCase'], + custom: { + regex: '^.{3,}', + match: true, + }, + }, + { + selector: 'variable', + format: ['camelCase', 'UPPER_CASE', 'PascalCase'], + leadingUnderscore: 'allow', + }, + { + selector: 'parameter', + format: ['camelCase', 'PascalCase'], + leadingUnderscore: 'allow', + }, + { + selector: [ + 'classProperty', + 'objectLiteralProperty', + 'typeProperty', + 'classMethod', + 'objectLiteralMethod', + 'typeMethod', + 'accessor', + 'enumMember', + ], + format: null, + modifiers: ['requiresQuotes'], + }, + ], + }, + }, + ], +}; diff --git a/packages/permission-controller/ARCHITECTURE.md b/packages/permission-controller/ARCHITECTURE.md index 2fb0332c1ec..786a4c31cc0 100644 --- a/packages/permission-controller/ARCHITECTURE.md +++ b/packages/permission-controller/ARCHITECTURE.md @@ -3,7 +3,7 @@ The `PermissionController` is the heart of an object capability-inspired permission system. It is the successor of the original MetaMask permission system, [`rpc-cap`](https://github.com/MetaMask/rpc-cap). -## Conceptual Overview +## Conceptual overview The permission system itself belongs to a **host**, and it mediates the access to resources – called **targets** – of distinct **subjects**. A target can belong to the host itself, or another subject. @@ -14,7 +14,7 @@ Permissions are associated with a subject and target, and they are part of the s Permissions can have **caveats**, which are host-defined attenuations of the authority a permission grants over a particular target. -## Implementation Overview +## Implementation overview At any given moment, the `PermissionController` state tree describes the complete state of the permissions of all subjects known to the host (i.e., the MetaMask instance). The `PermissionController` also provides methods for adding, updating, and removing permissions, and enforcing the rules described by its state tree. @@ -30,15 +30,15 @@ Permission system concepts correspond to components of the MetaMask stack as fol | Caveats | Caveat objects | | Permission system | The `PermissionController` and its `json-rpc-engine` middleware | -### Permission / Target Types +### Permission / target Types In practice, targets can be different things, necessitating distinct implementations in order to enforce the logic of the permission system. This being the case, the `PermissionController` defines different **permission / target types**, intended for different kinds of permission targets. At present, there are two permission / target types. -#### JSON-RPC Methods +#### JSON-RPC methods -Restricting access to JSON-RPC methods was the motivating and only supported use case for the original permission system, and remains the predominant kind of permission to this day. +Restricting access to JSON-RPC methods was the motivating and only supported use case for the original permission system, and its successor also implements this feature. The `PermissionController` provides patterns for creating restricted JSON-RPC method implementations and caveats, and a `json-rpc-engine` middleware function factory. To permission a JSON-RPC server, every JSON-RPC method must be enumerated and designated as either "restricted" or "unrestricted", and a permission middleware function must be added to the `json-rpc-engine` middleware stack. Unrestricted methods can always be called by anyone. @@ -54,13 +54,11 @@ Once the permission middleware is injected into the middleware stack, every JSON #### Endowments -The name "endowment" comes from the endowments that you may provide to a [Secure EcmaScript (SES) `Compartment`](https://github.com/endojs/endo/tree/26d991afb01cf824827db0c958c50970e038112f/packages/ses#compartment) when it is constructed. +We inherit the name "endowment" from the endowments that you may provide to a [Secure EcmaScript (SES) `Compartment`](https://github.com/endojs/endo/tree/26d991afb01cf824827db0c958c50970e038112f/packages/ses#compartment) when it is constructed. SES endowments are simply names that appear in the compartment's global scope. In the context of the `PermissionController`, endowments are simply "things" that subjects should not be able to access by default. They _could_ be the names of endowments that are to be made available to a particular SES `Compartment`, but they could also be any JavaScript value, and it is the host's responsibility to make sense of them. -At present, endowment permissions may not have any caveats, but caveat support may be added in the future. - ### Caveats Caveats are arbitrary restrictions on restricted method requests. @@ -68,6 +66,160 @@ Every permission has a `caveats` field, which is either an array of caveats or ` Every caveat has a string `type`, and every type has an associated function that is used to apply the caveat to a restricted method request. When the `PermissionController` is constructed, the consumer specifies the available caveat types and their implementations. +#### Caveat structure + +The complete authority represented by a permissions is represented by that permission and its caveat. +Accurately and legibly representing this information to the user is one of the most important responsibilities of MetaMask itself. +Therefore, as with any data structure we will use to represent information to the user, the simpler a caveat value type is, the better. + +For the same reason, it is also critical for permission authors to carefully consider the _semantics_ of caveat values. +In particular, the existence of an authority **must** be represented by the **presence** of a value. + +For example, let's say there is a caveat `foo` that restricts the parameters that a method can be called with. +In theory, such a caveat could be implemented such that a value of `[1, 2]` means that the +method will only accept `1` and `2` as parameters, while an empty array `[]` means that +_all_ parameters are permitted. +**This is strictly forbidden.** +Instead, such a hypothetical caveat could use `['*']` to represent that all parameters are permitted. + +We, the maintainers of the permission controller, impose this requirement for two reasons: + +1. We find it more intuitive to reason about caveats structured in this manner. +2. It leaves the door open for establishing a caveat DSL, and subsequently standardized caveat value merger functions in support of [incremental permission requests](#requestpermissionsincremental). + +#### Caveat merging + + + +Consumers may supply a caveat value merger function when specifying a caveat. +This is required to support [incremental permission requests](#requestpermissionsincremental). +Caveat values must be merged in the fashion of a right-biased union. +This operation is _like_ a union in set theory, except the right-hand operand overwrites +the left-hand operand in case of collisions. + +Formally, let: + +- `A` be the value of the existing / left-hand caveat +- `B` be the value of the requested / right-hand caveat +- `C` be the value of the resulting caveat +- `⊕` be the right-biased union operator + +Then the following must be true: + +- `C = A ⊕ B` +- `C ⊇ B` +- `A` and `C` may have all, some, or no values in common. +- If `A = ∅`, then `C = B` + +In addition to merging the values, the caveat value merger implementation must supply +the difference between `C` and `A`, expressed in the relevant caveat value type. +This is necessary so that other parts of the application, especially the UI, can +understand how authority has changed. + +Caveat value mergers should assume that the left- and right-hand values are always defined. +In practice, when the permission controller attempts to merge two permissions, it's possible +that the left-hand side does not exist. +In this case, the value of the right-hand side will also be the value of the diff, `Δ`. +Therefore, caveat value mergers **must** express their diffs in the relevant caveat value type. + +If `Δ` the difference between `C` and `A`, then: + +- `Δ = C - A` + - `Δ ∩ A = ∅` + - `Δ ⊆ C` + - `A ⊕ Δ = C` +- `Δ ⊆ B` +- If `A = ∅`, then `Δ = C = B` + +To exemplify the above in JavaScript: + +```js +// A is empty. +A = undefined; +B = { foo: 'bar' }; +C = { foo: 'bar' }; +Delta = { foo: 'bar' }; + +// A and B are the same. +A = { foo: 'bar' }; +B = { foo: 'bar' }; +C = { foo: 'bar' }; +Delta = undefined; + +// A and B have no values in common. +A = { foo: 'bar' }; +B = { life: 42 }; +C = { foo: 'bar', life: 42 }; +Delta = { life: 42 }; + +// B overwrites A completely. +A = { foo: 'bar' }; +B = { foo: 'baz' }; +C = { foo: 'baz' }; +Delta = { foo: 'baz' }; + +// B partially overwrites A. +A = { foo: 'bar', life: 42 }; +B = { foo: 'baz' }; +C = { foo: 'baz', life: 42 }; +Delta = { foo: 'baz' }; +``` + +### Specifying permissions and caveats + +Permissions and caveats are specified by constructing _specification objects_, +which are passed to the `PermissionController` constructor. See the [construction examples](#construction) +for how to do this. + +#### Permission and caveat validators + +Permission and caveat specifications optionally include a `validator` function. +This function is called to validate the permission or caveat when they change. +If validation fails, the validator function should throw an appropriate JSON-RPC error. + +The validators are invoked in the following cases: + +- Permission validators + - When a permission is granted + - When a permission's caveat array is mutated +- Caveat validators + - When a caveat is constructed + - When a caveat's value is mutated + +Notice that permission validators are only invoked when a permission's caveat array is mutated, +not when an individual caveat is mutated. This means that permission validators **must not** +be relied upon to validate caveat values. + +This establishes a separation of concerns between permission validators and caveat validators. +In brief: + +- Caveat validators are inherently unaware of the permissions that they are caveats of. +- Permission validators **should** be unaware of the internal structure of their caveats. + - However, they **may** be used to verify the membership of its caveat array. + +### Requesting permissions + +The `PermissionController` provides two methods for requesting permissions: + +#### `requestPermissions()` + +This method accepts an object specifying the requested permissions and any caveats for a particular subject. +The method optionally allows existing permissions not named in the request to be preserved. +Any existing permissions named in the request will be overwritten with the value approved by the user. + +#### `requestPermissionsIncremental()` + +This method also accepts an object of requested permissions, but will preserve the subject's existing authority to the greatest extent possible. +In practice, this means that it will merge the requested permissions and caveats with the existing permissions and subjects. +This merger is performed by way of a right-biased union, where the requested permissions are the right-hand side. + +If a caveat of the same type is encountered on both the left- and right-hand sides, the +new caveat value is determined by calling that caveat type's merger function. +This function must also perform a right-biased union, see [caveat merging](#caveat-merging) for more details. +If no merger exists for a caveat that must be merged, the request will fail. + + + ## Examples In addition to the below examples, the [`PermissionController` unit tests](./PermissionController.test.ts) show how to set up the controller. @@ -101,6 +253,20 @@ const caveatSpecifications = { caveat.value.includes(resultValue), ); }, + validator: (caveat: { type: 'filterArrayResponse'; value: Json }) => { + // This function is called to validate the value of a caveat. + // If the value is invalid, the request will fail. By way of example, + // we could check that the value is an array of strings: + return ( + Array.isArray(caveat.value) && + caveat.value.every((v) => typeof v === 'string') + ); + }, + // This function is called if two caveats of this type have to be merged + // due to an incremental permissions request. The values must be merged + // in the fashion of a right-biased union. + merger: (leftValue, rightValue) => + Array.from(new Set([...leftValue, ...rightValue])), }, }; @@ -113,6 +279,18 @@ const permissionSpecifications = { // i.e. the restricted method name targetName: 'wallet_getSecretArray', allowedCaveats: ['filterArrayResponse'], + validator: (permission: PermissionConstraint) => { + // This function is called to validate the permission. + // If the permission is invalid, the request will fail. + // By way of example, we could check that the permission has at least + // one caveat of type 'filterArrayResponse'. + assert.ok( + permission.caveats?.some( + (caveat) => caveat.type === CaveatTypes.filterArrayResponse, + ), + 'getSecretArray permission validation failed', + ); + }, // Every restricted method must specify its implementation in its // specification. methodImplementation: ( @@ -140,13 +318,20 @@ const permissionSpecifications = { const permissionController = new PermissionController({ caveatSpecifications, - messenger: controllerMessenger, // assume this was given + messenger: permissionControllerMessenger, // assume this was given permissionSpecifications, unrestrictedMethods: ['wallet_unrestrictedMethod'], }); ``` -### Adding the Permission Middleware +### Adding the permission middleware + +The permission middleware is created via `createPermissionMiddlewareV2` for +`JsonRpcEngineV2`, or via the deprecated `createPermissionMiddleware` for the +legacy `JsonRpcEngine`. Both factories take a messenger with the +`PermissionController:executeRestrictedMethod` and +`PermissionController:hasUnrestrictedMethod` actions, typically obtained by +delegating them from a root messenger to a subject-scoped messenger. ```typescript // This should take place where a middleware stack is created for a particular @@ -155,14 +340,18 @@ const permissionController = new PermissionController({ // The subject could be a port, stream, socket, etc. const origin = getOrigin(subject); -const engine = new JsonRpcEngine(); -engine.push(/* your various middleware*/); -engine.push(permissionController.createPermissionMiddleware({ origin })); -// Your middleware stack is now permissioned -engine.push(/* your other various middleware*/); +// `messenger` is a messenger delegated the two actions listed above, e.g. +// via `rootMessenger.delegate({ actions: [...], messenger: subjectMessenger })`. +const engine = JsonRpcEngineV2.create({ + middleware: [ + /* your various middleware */ + createPermissionMiddlewareV2({ messenger, subject: { origin } }), + /* your other various middleware */ + ], +}); ``` -### Calling a Restricted Method Internally +### Calling a restricted method internally ```typescript // Sometimes, we need to call a restricted method internally, as a particular subject. @@ -175,7 +364,7 @@ permissionController.executeRestrictedMethod(origin, 'wallet_getSecret', { }); ``` -### Getting Endowments +### Getting endowments ```typescript // Getting endowments internally is the only option, since the host has to apply @@ -189,24 +378,72 @@ const endowments = await permissionController.getEndowments( applyEndowments(origin, endowments); ``` -### Requesting and Getting Permissions +### Requesting and getting permissions ```typescript -// From the perspective of subjects, requesting and getting permissions -// works the same as it does with `rpc-cap`. -const approvedPermissions = await ethereum.request({ +// This requests the `wallet_getSecretArray` permission. +const addedPermissions = await ethereum.request({ method: 'wallet_requestPermissions', params: [{ wallet_getSecretArray: {}, }] }) +// This gets the subject's existing permissions. const existingPermissions = await ethereum.request({ method: 'wallet_getPermissions', ) +console.log(existingPermissions) +// [ +// { +// "id": "DZ_a31y3E8FKQfBqLwIcN", +// "parentCapability": "wallet_getSecretArray", +// "invoker": "https://subject.io", +// "caveats": [/* ... */], +// "date": 1713279475396 +// } +// ] +``` + +### Requesting permissions incrementally + +```typescript +// Given an artifically truncated permission state of: +// { +// 'metamask.io': { +// wallet_getSecretArray: { +// caveats: [ +// { type: 'foo', value: ['a'] }, +// ], +// }, +// }, +// } + +// We request: +await permissionController.requestPermissionsIncremental({ + wallet_getSecretArray: { + caveats: [ + { type: 'foo', value: ['b'] }, + { type: 'bar', value: 42 }, + ], + }, +}); + +// Assuming that the caveat value merger implementation for 'foo' naively merges the +// values of the left- and right-hand sides, we end up with: +// { +// 'metamask.io': { +// wallet_getSecretArray: { +// caveats: [ +// { type: 'foo', value: ['a', 'b'] }, +// { type: 'bar', value: 42 }, +// ], +// }, +// }, +// } ``` -### Restricted Method Caveat Decorators +### Restricted method caveat decorators Here follows some more example caveat decorator implementations. diff --git a/packages/permission-controller/CHANGELOG.md b/packages/permission-controller/CHANGELOG.md index e6fc3286fd3..322c25fb657 100644 --- a/packages/permission-controller/CHANGELOG.md +++ b/packages/permission-controller/CHANGELOG.md @@ -1,4 +1,5 @@ # 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/), @@ -6,43 +7,427 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Changed + +- Bump `@metamask/utils` from `^11.9.0` to `^11.11.0` ([#9074](https://github.com/MetaMask/core/pull/9074)) +- Bump `@metamask/controller-utils` from `^12.0.0` to `^12.3.0` ([#8774](https://github.com/MetaMask/core/pull/8774), [#9058](https://github.com/MetaMask/core/pull/9058), [#9083](https://github.com/MetaMask/core/pull/9083), [#9218](https://github.com/MetaMask/core/pull/9218)) +- Bump `@metamask/approval-controller` from `^9.0.1` to `^9.0.2` ([#9058](https://github.com/MetaMask/core/pull/9058)) +- Bump `@metamask/messenger` from `^1.2.0` to `^2.0.0` ([#9392](https://github.com/MetaMask/core/pull/9392)) + +## [13.1.1] + +### Changed + +- Bump `@metamask/json-rpc-engine` from `^10.3.0` to `^10.5.0` ([#8746](https://github.com/MetaMask/core/pull/8746), [#8753](https://github.com/MetaMask/core/pull/8753)) +- Bump `@metamask/controller-utils` from `^11.20.0` to `^12.0.0` ([#8755](https://github.com/MetaMask/core/pull/8755)) + +## [13.1.0] + +### Added + +- Expose missing public `PermissionController` methods through its messenger ([#8675](https://github.com/MetaMask/core/pull/8675)) + - The following actions are now available: + - `PermissionController:acceptPermissionsRequest`, + - `PermissionController:rejectPermissionsRequest`, + - `PermissionController:revokePermission`, + - `PermissionController:updatePermissionsByCaveat`, + - `PermissionController:getPermission` + - Corresponding action types are available as well. + +## [13.0.0] + +### Added + +- Add `createPermissionMiddlewareV2`, a `JsonRpcEngineV2` variant of the standalone permission middleware factory ([#8532](https://github.com/MetaMask/core/pull/8532)) +- Add `messenger` option to permission specification builders, allowing restricted-method specs to receive a scoped messenger in place of `methodHooks` ([#8551](https://github.com/MetaMask/core/pull/8551)) + - Use the `actionNames` field on the specification builder and `createRestrictedMethodMessenger` to construct the scoped messenger. + +### Changed + +- **BREAKING:** Decouple the permission middleware from `PermissionController` and expose it as a standalone function ([#8532](https://github.com/MetaMask/core/pull/8532)) + - The standalone `createPermissionMiddleware` replaces the former `PermissionController.createPermissionMiddleware`; it is imported from `@metamask/permission-controller` and called with a messenger and subject metadata, and targets the legacy `JsonRpcEngine`. + - New integrations should prefer `createPermissionMiddlewareV2`, which targets `JsonRpcEngineV2`. + - `PermissionController.getRestrictedMethod` no longer serves a purpose, and is removed. Restricted methods should be invoked via the `:executeRestrictedMethod` action instead. +- Bump `@metamask/json-rpc-engine` from `^10.2.4` to `^10.3.0` ([#8661](https://github.com/MetaMask/core/pull/8661)) +- Bump `@metamask/controller-utils` from `^11.19.0` to `^11.20.0` ([#8344](https://github.com/MetaMask/core/pull/8344)) +- Bump `@metamask/messenger` from `^1.0.0` to `^1.2.0` ([#8364](https://github.com/MetaMask/core/pull/8364), [#8373](https://github.com/MetaMask/core/pull/8373), [#8632](https://github.com/MetaMask/core/pull/8632)) +- Bump `@metamask/base-controller` from `^9.0.1` to `^9.1.0` ([#8457](https://github.com/MetaMask/core/pull/8457)) + +### Deprecated + +- Deprecate `createPermissionMiddleware` in favor of `createPermissionMiddlewareV2`, which targets `JsonRpcEngineV2` ([#8532](https://github.com/MetaMask/core/pull/8532)) + +### Removed + +- **BREAKING:** Remove `factoryHooks`, `validatorHooks`, and related fields from permission specification builders ([#8551](https://github.com/MetaMask/core/pull/8551)) +- **BREAKING:** Remove permitted method handlers and types ([#8583](https://github.com/MetaMask/core/pull/8583)) + - The permitted method handlers were unused in practice. Replacement types for generic RPC method implementations are available in `@metamask/json-rpc-engine@10.3.0`. + +## [12.3.0] + +### Added + +- Expose missing public `PermissionController` methods through its messenger ([#8201](https://github.com/MetaMask/core/pull/8201)) + - The following actions are now available: + - `PermissionController:clearState` + - Corresponding action types (e.g. `PermissionControllerClearStateAction`) are + available as well. +- Expose missing public `SubjectMetadataController` methods through its messenger ([#8201](https://github.com/MetaMask/core/pull/8201)) + - The following actions are now available: + - `SubjectMetadataController:clearState` + - `SubjectMetadataController:trimMetadataState` + - Corresponding action types + (e.g. `SubjectMetadataControllerClearStateAction`) are available as well. + +### Changed + +- Bump `@metamask/approval-controller` from `^9.0.0` to `^9.0.1` ([#8317](https://github.com/MetaMask/core/pull/8317)) +- Bump `@metamask/base-controller` from `^9.0.0` to `^9.0.1` ([#8317](https://github.com/MetaMask/core/pull/8317)) +- Bump `@metamask/json-rpc-engine` from `^10.2.3` to `^10.2.4` ([#8317](https://github.com/MetaMask/core/pull/8317)) +- Bump `@metamask/messenger` from `^0.3.0` to `^1.0.0` ([#8317](https://github.com/MetaMask/core/pull/8317)) + +### Deprecated + +- Deprecate action types in favor of `PermissionController...Action` and `SubjectMetadataController...Action` types ([#8201](https://github.com/MetaMask/core/pull/8201)) + - For the `PermissionController`: + - `GetPermissionControllerState` is now `PermissionControllerGetStateAction`. + - `GetSubjects` is now `PermissionControllerGetSubjectsAction`. + - `GetPermissions` is now `PermissionControllerGetPermissionsAction`. + - `HasPermissions` is now `PermissionControllerHasPermissionsAction`. + - `HasPermission` is now `PermissionControllerHasPermissionAction`. + - `GrantPermissions` is now `PermissionControllerGrantPermissionsAction`. + - `GrantPermissionsIncremental` is now `PermissionControllerGrantPermissionsIncrementalAction`. + - `RequestPermissions` is now `PermissionControllerRequestPermissionsAction`. + - `RequestPermissionsIncremental` is now `PermissionControllerRequestPermissionsIncrementalAction`. + - `RevokePermissions` is now `PermissionControllerRevokePermissionsAction`. + - `RevokeAllPermissions` is now `PermissionControllerRevokeAllPermissionsAction`. + - `RevokePermissionForAllSubjects` is now `PermissionControllerRevokePermissionForAllSubjectsAction`. + - `UpdateCaveat` is now `PermissionControllerUpdateCaveatAction`. + - `GetCaveat` is now `PermissionControllerGetCaveatAction`. + - `ClearPermissions` is now `PermissionControllerClearPermissionsAction`. + - `GetEndowments` is now `PermissionControllerGetEndowmentsAction`. + - For the `SubjectMetadataController`: + - `GetSubjectMetadataControllerState` is now `SubjectMetadataControllerGetStateAction`. + - `GetSubjectMetadata` is now `SubjectMetadataControllerGetMetadataAction`. + - `AddSubjectMetadata` is now `SubjectMetadataControllerAddMetadataAction`. + - The old types are still exported but are now marked as deprecated and will + be removed in a future release. + +## [12.2.1] + +### Changed + +- Bump `@metamask/approval-controller` from `^8.0.0` to `^9.0.0` ([#8225](https://github.com/MetaMask/core/pull/8225)) +- Bump `@metamask/json-rpc-engine` from `^10.2.0` to `^10.2.3` ([#7642](https://github.com/MetaMask/core/pull/7642), [#7856](https://github.com/MetaMask/core/pull/7856), [#8078](https://github.com/MetaMask/core/pull/8078)) +- Bump `@metamask/controller-utils` from `^11.17.0` to `^11.19.0` ([#7583](https://github.com/MetaMask/core/pull/7583), [#7995](https://github.com/MetaMask/core/pull/7995)) + +## [12.2.0] + +### Added + +- Add `PermissionController:getCaveat` action ([#7303](https://github.com/MetaMask/core/pull/7303)) + +### 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)) + - The dependencies moved are: + - `@metamask/approval-controller` (^8.0.0) + - In clients, it is now possible for multiple versions of these packages to exist in the dependency tree. + - For example, this scenario would be valid: a client relies on `@metamask/controller-a` 1.0.0 and `@metamask/controller-b` 1.0.0, and `@metamask/controller-b` depends on `@metamask/controller-a` 1.1.0. + - Note, however, that the versions specified in the client's `package.json` always "win", and you are expected to keep them up to date so as not to break controller and service intercommunication. +- Bump `@metamask/controller-utils` from `^11.16.0` to `^11.17.0` ([#7534](https://github.com/MetaMask/core/pull/7534)) + +## [12.1.1] + +### Changed + +- Bump `@metamask/json-rpc-engine` from `^10.1.1` to `^10.2.0` ([#7202](https://github.com/MetaMask/core/pull/7202)) +- Bump `@metamask/controller-utils` from `^11.15.0` to `^11.16.0` ([#7202](https://github.com/MetaMask/core/pull/7202)) + +## [12.1.0] + +### Added + +- Add `name` property to permission errors ([#6987](https://github.com/MetaMask/core/pull/6987)) + +## [12.0.0] + +### Changed + +- **BREAKING:** Use new `Messenger` from `@metamask/messenger` ([#6537](https://github.com/MetaMask/core/pull/6537)) + - Previously, `PermissionController` and `SubjectMetadataController` accepted a `RestrictedMessenger` instance from `@metamask/base-controller`. +- **BREAKING:** Metadata property `anonymous` renamed to `includeInDebugSnapshot` ([#6537](https://github.com/MetaMask/core/pull/6537)) +- **BREAKING:** Bump `@metamask/approval-controller` from `^7.0.0` to `^8.0.0` ([#6962](https://github.com/MetaMask/core/pull/6962)) +- Bump `@metamask/base-controller` from `^8.4.2` to `^9.0.0` ([#6962](https://github.com/MetaMask/core/pull/6962)) + +## [11.1.1] + +### Changed + +- Bump `@metamask/base-controller` from `^8.4.1` to `^8.4.2` ([#6917](https://github.com/MetaMask/core/pull/6917)) + +## [11.1.0] + +### Added + +- Add two new controller state metadata properties: `includeInStateLogs` and `usedInUi` ([#6525](https://github.com/MetaMask/core/pull/6525)) + +### Changed + +- Bump `@metamask/utils` from `^11.1.0` to `^11.8.1` ([#5301](https://github.com/MetaMask/core/pull/5301), [#6054](https://github.com/MetaMask/core/pull/6054), [#6588](https://github.com/MetaMask/core/pull/6588), [#6708](https://github.com/MetaMask/core/pull/6708)) +- Bump `@metamask/base-controller` from `^8.0.0` to `^8.4.1` ([#5722](https://github.com/MetaMask/core/pull/5722), [#6284](https://github.com/MetaMask/core/pull/6284), [#6355](https://github.com/MetaMask/core/pull/6355), [#6465](https://github.com/MetaMask/core/pull/6465), [#6632](https://github.com/MetaMask/core/pull/6632), [#6807](https://github.com/MetaMask/core/pull/6807)) +- Bump `@metamask/controller-utils` from `^11.5.0` to `^11.14.1` ([#5439](https://github.com/MetaMask/core/pull/5439), [#5583](https://github.com/MetaMask/core/pull/5583), [#5765](https://github.com/MetaMask/core/pull/5765), [#5812](https://github.com/MetaMask/core/pull/5812), [#5935](https://github.com/MetaMask/core/pull/5935), [#6069](https://github.com/MetaMask/core/pull/6069), [#6303](https://github.com/MetaMask/core/pull/6303), [#6620](https://github.com/MetaMask/core/pull/6620), [#6629](https://github.com/MetaMask/core/pull/6629), [#6807](https://github.com/MetaMask/core/pull/6807)) +- Bump `@metamask/json-rpc-engine` from `^10.0.3` to `^10.1.1` ([#6678](https://github.com/MetaMask/core/pull/6678), [#6807](https://github.com/MetaMask/core/pull/6807)) + +## [11.0.6] + +### Changed + +- Bump `@metamask/base-controller` from `^7.1.1` to `^8.0.0` ([#5305](https://github.com/MetaMask/core/pull/5305)) +- Bump `@metamask/controller-utils` from `^11.4.5` to `^11.5.0` ([#5272](https://github.com/MetaMask/core/pull/5272)) +- Bump `@metamask/json-rpc-engine` from `^10.0.2` to `^10.0.3` ([#5272](https://github.com/MetaMask/core/pull/5272)) +- Bump `@metamask/utils` from `^11.0.1` to `^11.1.0` ([#5223](https://github.com/MetaMask/core/pull/5223)) + +## [11.0.5] + +### Changed + +- Remove redundant caveat validator calls ([#5062](https://github.com/MetaMask/core/pull/5062)) + - In some cases, caveats were being validated multiple times or without the + possibility of being changed. + - The intended purpose of permission and caveat validators has also been + documented. See `ARCHITECTURE.md`. +- Bump `nanoid` from `^3.1.31` to `^3.3.8` ([#5073](https://github.com/MetaMask/core/pull/5073)) +- Bump `@metamask/utils` from `^10.0.0` to `^11.0.1` ([#5080](https://github.com/MetaMask/core/pull/5080)) +- Bump `@metamask/rpc-errors` from `^7.0.0` to `^7.0.2` ([#5080](https://github.com/MetaMask/core/pull/5080)) +- Bump `@metamask/base-controller` from `^7.0.0` to `^7.1.1`, ([#5079](https://github.com/MetaMask/core/pull/5079), [#5135](https://github.com/MetaMask/core/pull/5135)) + +## [11.0.4] + +### Changed + +- Bump `@metamask/controller-utils` from `^11.4.1` to `^11.4.4`, [#4915](https://github.com/MetaMask/core/pull/4915), [#5012](https://github.com/MetaMask/core/pull/5012)) ([#4870](https://github.com/MetaMask/core/pull/4870)) + +### Fixed + +- Correct ESM-compatible build so that imports of the following packages that re-export other modules via `export *` are no longer corrupted: ([#5011](https://github.com/MetaMask/core/pull/5011)) + - `deep-freeze-strict` + +## [11.0.3] + +### Changed + +- Bump `@metamask/utils` from `^9.1.0` to `^10.0.0` ([#4831](https://github.com/MetaMask/core/pull/4831)) + +## [11.0.2] + +### Fixed + +- Produce and export ESM-compatible TypeScript type declaration files in addition to CommonJS-compatible declaration files ([#4648](https://github.com/MetaMask/core/pull/4648)) + - Previously, this package shipped with only one variant of type declaration + files, and these files were only CommonJS-compatible, and the `exports` + field in `package.json` linked to these files. This is an anti-pattern and + was rightfully flagged by the + ["Are the Types Wrong?"](https://arethetypeswrong.github.io/) tool as + ["masquerading as CJS"](https://github.com/arethetypeswrong/arethetypeswrong.github.io/blob/main/docs/problems/FalseCJS.md). + All of the ATTW checks now pass. +- Remove chunk files ([#4648](https://github.com/MetaMask/core/pull/4648)) + - Previously, the build tool we used to generate JavaScript files extracted + common code to "chunk" files. While this was intended to make this package + more tree-shakeable, it also made debugging more difficult for our + development teams. These chunk files are no longer present. + +## [11.0.1] + +### Changed + +- Bump `@metamask/base-controller` from `^6.0.3` to `^7.0.0` ([#4643](https://github.com/MetaMask/core/pull/4643)) +- Bump `@metamask/controller-utils` from `^11.0.2` to `^11.2.0` ([#4639](https://github.com/MetaMask/core/pull/4639), [#4651](https://github.com/MetaMask/core/pull/4651)) +- Bump `typescript` from `~5.0.4` to `~5.2.2` ([#4576](https://github.com/MetaMask/core/pull/4576), [#4584](https://github.com/MetaMask/core/pull/4584)) + +## [11.0.0] + +### Changed + +- **BREAKING:** Rename enum property names to match PascalCase instead of camelCase ([#4521](https://github.com/MetaMask/core/pull/4521)) + - The affected enums are: `CaveatMutatorOperations`, `MethodNames`. +- Bump TypeScript version to `~5.0.4` and set `moduleResolution` option to `Node16` ([#3645](https://github.com/MetaMask/core/pull/3645)) +- Bump `@metamask/base-controller` from `^6.0.1` to `^6.0.2` ([#4544](https://github.com/MetaMask/core/pull/4544)) +- Bump `@metamask/controller-utils` from `^11.0.1` to `^11.0.2` ([#4544](https://github.com/MetaMask/core/pull/4544)) +- Bump `@metamask/json-rpc-engine` from `^9.0.1` to `^9.0.2` ([#4544](https://github.com/MetaMask/core/pull/4544)) +- Bump `@metamask/utils` from `^9.0.0` to `^9.1.0` ([#4529](https://github.com/MetaMask/core/pull/4529)) + +## [10.0.1] + +### Changed + +- Bump `@metamask/rpc-errors` from `6.2.1` to `^6.3.1` ([#4516](https://github.com/MetaMask/core/pull/4516)) +- Bump `@metamask/utils` from `^8.3.0` to `^9.0.0` ([#4516](https://github.com/MetaMask/core/pull/4516)) +- Bump `@metamask/base-controller` to `^9.0.1` ([#4517](https://github.com/MetaMask/core/pull/4517)) +- Bump `@metamask/controller-utils` to `^11.0.1` ([#4517](https://github.com/MetaMask/core/pull/4517)) +- Bump `@metamask/json-rpc-engine` to `^9.0.1` ([#4517](https://github.com/MetaMask/core/pull/4517)) + +## [10.0.0] + +### Changed + +- **BREAKING:** Bump minimum Node version to 18.18 ([#3611](https://github.com/MetaMask/core/pull/3611)) +- **BREAKING:** Bump peer dependency `@metamask/approval-controller` to `^7.0.0` ([#4352](https://github.com/MetaMask/core/pull/4352)) +- Bump `@metamask/base-controller` to `^6.0.0` ([#4352](https://github.com/MetaMask/core/pull/4352)) +- Bump `@metamask/controller-utils` to `^11.0.0` ([#4352](https://github.com/MetaMask/core/pull/4352)) +- Bump `@metamask/json-rpc-engine` to `^9.0.0` ([#4352](https://github.com/MetaMask/core/pull/4352)) + +## [9.1.1] + +### Changed + +- Bump `@metamask/controller-utils` to `^10.0.0` ([#4342](https://github.com/MetaMask/core/pull/4342)) + +## [9.1.0] + +### Added + +- Add `requestPermissionsIncremental()` and caveat merger functions ([#4222](https://github.com/MetaMask/core/pull/4222)) +- Enable passing additional metadata during permission requests ([#4179](https://github.com/MetaMask/core/pull/4179)) +- Make permission request validation errors more informative ([#4172](https://github.com/MetaMask/core/pull/4172)) + +## [9.0.2] + +### Fixed + +- Fix `SideEffectMessenger` type not respecting generic parameter types ([#4059](https://github.com/MetaMask/core/pull/4059)) + +## [9.0.1] + +### Fixed + +- Fix `types` field in `package.json` ([#4047](https://github.com/MetaMask/core/pull/4047)) + +## [9.0.0] + +### Added + +- **BREAKING**: Add ESM build ([#3998](https://github.com/MetaMask/core/pull/3998)) + - It's no longer possible to import files from `./dist` directly. + +### Changed + +- **BREAKING:** Bump peer dependency on `@metamask/approval-controller` to `^6.0.0` ([#4039](https://github.com/MetaMask/core/pull/4039)) +- **BREAKING:** Bump `@metamask/base-controller` to `^5.0.0` ([#4039](https://github.com/MetaMask/core/pull/4039)) + - This version has a number of breaking changes. See the changelog for more. +- Bump `@metamask/controller-utils` to `^9.0.0` ([#4039](https://github.com/MetaMask/core/pull/4039)) +- Bump `@metamask/json-rpc-engine` to `^8.0.0` ([#4039](https://github.com/MetaMask/core/pull/4039)) + +### Fixed + +- **BREAKING:** Fix `SideEffectMessenger` so that it's defined with a `RestrictedControllerMessenger` that has access to `PermissionController` allowed actions ([#4031](https://github.com/MetaMask/core/pull/4031)) + - The messenger's `Action` generic parameter is widened to include the `PermissionController` actions allowlist. + - The messenger's `AllowedAction` generic parameter is narrowed from `string` to the `PermissionController` actions allowlist. + +## [8.0.1] + +### Fixed + +- Bump `@metamask/rpc-errors` to `^6.2.1` ([#3954](https://github.com/MetaMask/core/pull/3954), [#3970](https://github.com/MetaMask/core/pull/3970)) + +## [8.0.0] + +### Changed + +- **BREAKING:** Bump `@metamask/approval-controller` peer dependency to `^5.1.2` ([#3821](https://github.com/MetaMask/core/pull/3821)) +- Bump `@metamask/utils` to `^8.3.0` ([#3769](https://github.com/MetaMask/core/pull/3769)) +- Bump `@metamask/base-controller` to `^4.1.1` ([#3760](https://github.com/MetaMask/core/pull/3760), [#3821](https://github.com/MetaMask/core/pull/3821)) +- Bump `@metamask/controller-utils` to `^8.0.2` ([#3821](https://github.com/MetaMask/core/pull/3821)) +- Bump `@metamask/json-rpc-engine` to `^7.3.2` ([#3821](https://github.com/MetaMask/core/pull/3821)) + +## [7.1.0] + +### Added + +- Add `SubjectMetadataController:addSubjectMetadata` action ([#3733](https://github.com/MetaMask/core/pull/3733)) + +## [7.0.0] + +### Changed + +- **BREAKING:** Bump `@metamask/approval-controller` peer dependency from `^5.0.0` to `^5.1.1` ([#3680](https://github.com/MetaMask/core/pull/3680), [#3695](https://github.com/MetaMask/core/pull/3695)) +- Bump `@metamask/base-controller` to `^4.0.1` ([#3695](https://github.com/MetaMask/core/pull/3695)) +- Bump `@metamask/controller-utils` to `^8.0.1` ([#3695](https://github.com/MetaMask/core/pull/3695), [#3678](https://github.com/MetaMask/core/pull/3678), [#3667](https://github.com/MetaMask/core/pull/3667), [#3580](https://github.com/MetaMask/core/pull/3580)) +- Bump `@metamask/json-rpc-engine` to `^7.3.1` ([#3695](https://github.com/MetaMask/core/pull/3695)) + +### Fixed + +- Remove `@metamask/approval-controller` dependency ([#3607](https://github.com/MetaMask/core/pull/3607)) + +## [6.0.0] + +### Added + +- Add new handler to `permissionRpcMethods.handlers` for `wallet_revokePermissions` RPC method ([#1889](https://github.com/MetaMask/core/pull/1889)) + +### Changed + +- **BREAKING:** Bump `@metamask/base-controller` to ^4.0.0 ([#2063](https://github.com/MetaMask/core/pull/2063)) + - This is breaking because the type of the `messenger` has backward-incompatible changes. See the changelog for this package for more. +- **BREAKING:** Update `PermittedRpcMethodHooks` type so it must support signature for `wallet_revokePermission` hook ([#1889](https://github.com/MetaMask/core/pull/1889)) +- Bump `@metamask/approval-controller` to ^5.0.0 ([#2063](https://github.com/MetaMask/core/pull/2063)) +- Bump `@metamask/controller-utils` to ^6.0.0 ([#2063](https://github.com/MetaMask/core/pull/2063)) + ## [5.0.1] + ### Changed + +- Bump `@metamask/json-rpc-engine` from `^7.1.0` to `^7.2.0` ([#1895](https://github.com/MetaMask/core/pull/1895)) - Bump dependency on `@metamask/rpc-errors` to ^6.1.0 ([#1653](https://github.com/MetaMask/core/pull/1653)) - Bump dependency and peer dependency on `@metamask/approval-controller` to ^4.0.1 +- Bump `@metamask/utils` from `8.1.0` to `8.2.0` ([#1957](https://github.com/MetaMask/core/pull/1957)) +- Bump `@metamask/auto-changelog` from `^3.2.0` to `^3.4.3` ([#1870](https://github.com/MetaMask/core/pull/1870), [#1905](https://github.com/MetaMask/core/pull/1905), [#1997](https://github.com/MetaMask/core/pull/1997)) ## [5.0.0] + ### Changed -- **BREAKING:** Remove `undefined` from RestrictedMethodParameters type union and from type parameter for RestrictedMethodOptions ([#1749])(https://github.com/MetaMask/core/pull/1749)) -- **BREAKING:** Update from `json-rpc-engine@^6.1.0` to `@metamask/json-rpc-engine@^7.1.1` ([#1749])(https://github.com/MetaMask/core/pull/1749)) -- Update from `eth-rpc-errors@^4.0.2` to `@metamask/rpc-errors@^6.0.0` ([#1749])(https://github.com/MetaMask/core/pull/1749)) + +- **BREAKING:** Remove `undefined` from RestrictedMethodParameters type union and from type parameter for RestrictedMethodOptions ([#1749](https://github.com/MetaMask/core/pull/1749)) +- **BREAKING:** Update from `json-rpc-engine@^6.1.0` to `@metamask/json-rpc-engine@^7.1.1` ([#1749](https://github.com/MetaMask/core/pull/1749)) +- Update from `eth-rpc-errors@^4.0.2` to `@metamask/rpc-errors@^6.0.0` ([#1749](https://github.com/MetaMask/core/pull/1749)) - Bump dependency on `@metamask/utils` to ^8.1.0 ([#1639](https://github.com/MetaMask/core/pull/1639)) - Bump dependency and peer dependency on `@metamask/approval-controller` to ^4.0.0 - Bump dependency on `@metamask/base-controller` to ^3.2.3 - Bump dependency on `@metamask/controller-utils` to ^5.0.2 ## [4.1.2] + ### Changed + - Update TypeScript to v4.8.x ([#1718](https://github.com/MetaMask/core/pull/1718)) - Bump dependency on `@metamask/controller-utils` to ^5.0.0 ## [4.1.1] + ### Changed + - Bump dependency and peer dependency on `@metamask/approval-controller` to ^3.5.1 - Bump dependency on `@metamask/base-controller` to ^3.2.1 - Bump dependency on `@metamask/controller-utils` to ^4.3.2 ## [4.1.0] + ### Changed + - Update `@metamask/utils` to `^6.2.0` ([#1514](https://github.com/MetaMask/core/pull/1514)) ## [4.0.1] + ### Fixed + - Fix permissions RPC method types ([#1464](https://github.com/MetaMask/core/pull/1464)) - The RPC method handlers were mistakenly typed as an array rather than a tuple ## [4.0.0] + ### Changed + - **BREAKING:** Bump to Node 16 ([#1262](https://github.com/MetaMask/core/pull/1262)) - **BREAKING:** Update `@metamask/approval-controller` dependency and peer dependency - The export `permissionRpcMethods` has a slightly different type; the second generic type variable of the `getPermissions` handler is now `undefined` rather than `void` ([#1372](https://github.com/MetaMask/core/pull/1372)) @@ -51,6 +436,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Change type of constructor parameter `unrestrictedMethods` to be readonly ([#1395](https://github.com/MetaMask/core/pull/1395)) ### Removed + - **BREAKING**: Remove namespaced permissions ([#1337](https://github.com/MetaMask/core/pull/1337)) - Namespaced permissions are no longer supported. Consumers should replace namespaced permissions with equivalent caveat-based implementations. - **BREAKING**: Remove `targetKey` concept ([#1337](https://github.com/MetaMask/core/pull/1337)) @@ -58,48 +444,94 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - The `targetKey` property of permission specifications has been renamed to `targetName`. ## [3.2.0] + ### Added + - Allow restricting permissions by subject type ([#1233](https://github.com/MetaMask/core/pull/1233)) ### Changed + - Move `SubjectMetadataController` to permission-controller package ([#1234](https://github.com/MetaMask/core/pull/1234)) - Update minimum `eth-rpc-errors` version from `4.0.0` to `4.0.2` ([#1215](https://github.com/MetaMask/core/pull/1215)) ## [3.1.0] + ### Added + - Add side-effects to permissions ([#1069](https://github.com/MetaMask/core/pull/1069)) ## [3.0.0] + ### Removed + - **BREAKING:** Remove `isomorphic-fetch` ([#1106](https://github.com/MetaMask/controllers/pull/1106)) - Consumers must now import `isomorphic-fetch` or another polyfill themselves if they are running in an environment without `fetch` ## [2.0.0] + ### Added + - Add `updateCaveat` action ([#1071](https://github.com/MetaMask/core/pull/1071)) ### Changed + - **BREAKING:** Update `@metamask/network-controller` peer dependency to v3 ([#1041](https://github.com/MetaMask/controllers/pull/1041)) - Rename this repository to `core` ([#1031](https://github.com/MetaMask/controllers/pull/1031)) - Update `@metamask/controller-utils` package ([#1041](https://github.com/MetaMask/controllers/pull/1041)) ## [1.0.2] + ### Fixed + - This package will now warn if a required package is not present ([#1003](https://github.com/MetaMask/core/pull/1003)) ## [1.0.1] + ### Changed + - Relax dependencies on `@metamask/approval-controller`, `@metamask/base-controller` and `@metamask/controller-utils` (use `^` instead of `~`) ([#998](https://github.com/MetaMask/core/pull/998)) ## [1.0.0] + ### Added + - Initial release - As a result of converting our shared controllers repo into a monorepo ([#831](https://github.com/MetaMask/core/pull/831)), we've created this package from select parts of [`@metamask/controllers` v33.0.0](https://github.com/MetaMask/core/tree/v33.0.0), namely: - Everything in `src/permissions` All changes listed after this point were applied to this package following the monorepo conversion. -[Unreleased]: https://github.com/MetaMask/core/compare/@metamask/permission-controller@5.0.1...HEAD +[Unreleased]: https://github.com/MetaMask/core/compare/@metamask/permission-controller@13.1.1...HEAD +[13.1.1]: https://github.com/MetaMask/core/compare/@metamask/permission-controller@13.1.0...@metamask/permission-controller@13.1.1 +[13.1.0]: https://github.com/MetaMask/core/compare/@metamask/permission-controller@13.0.0...@metamask/permission-controller@13.1.0 +[13.0.0]: https://github.com/MetaMask/core/compare/@metamask/permission-controller@12.3.0...@metamask/permission-controller@13.0.0 +[12.3.0]: https://github.com/MetaMask/core/compare/@metamask/permission-controller@12.2.1...@metamask/permission-controller@12.3.0 +[12.2.1]: https://github.com/MetaMask/core/compare/@metamask/permission-controller@12.2.0...@metamask/permission-controller@12.2.1 +[12.2.0]: https://github.com/MetaMask/core/compare/@metamask/permission-controller@12.1.1...@metamask/permission-controller@12.2.0 +[12.1.1]: https://github.com/MetaMask/core/compare/@metamask/permission-controller@12.1.0...@metamask/permission-controller@12.1.1 +[12.1.0]: https://github.com/MetaMask/core/compare/@metamask/permission-controller@12.0.0...@metamask/permission-controller@12.1.0 +[12.0.0]: https://github.com/MetaMask/core/compare/@metamask/permission-controller@11.1.1...@metamask/permission-controller@12.0.0 +[11.1.1]: https://github.com/MetaMask/core/compare/@metamask/permission-controller@11.1.0...@metamask/permission-controller@11.1.1 +[11.1.0]: https://github.com/MetaMask/core/compare/@metamask/permission-controller@11.0.6...@metamask/permission-controller@11.1.0 +[11.0.6]: https://github.com/MetaMask/core/compare/@metamask/permission-controller@11.0.5...@metamask/permission-controller@11.0.6 +[11.0.5]: https://github.com/MetaMask/core/compare/@metamask/permission-controller@11.0.4...@metamask/permission-controller@11.0.5 +[11.0.4]: https://github.com/MetaMask/core/compare/@metamask/permission-controller@11.0.3...@metamask/permission-controller@11.0.4 +[11.0.3]: https://github.com/MetaMask/core/compare/@metamask/permission-controller@11.0.2...@metamask/permission-controller@11.0.3 +[11.0.2]: https://github.com/MetaMask/core/compare/@metamask/permission-controller@11.0.1...@metamask/permission-controller@11.0.2 +[11.0.1]: https://github.com/MetaMask/core/compare/@metamask/permission-controller@11.0.0...@metamask/permission-controller@11.0.1 +[11.0.0]: https://github.com/MetaMask/core/compare/@metamask/permission-controller@10.0.1...@metamask/permission-controller@11.0.0 +[10.0.1]: https://github.com/MetaMask/core/compare/@metamask/permission-controller@10.0.0...@metamask/permission-controller@10.0.1 +[10.0.0]: https://github.com/MetaMask/core/compare/@metamask/permission-controller@9.1.1...@metamask/permission-controller@10.0.0 +[9.1.1]: https://github.com/MetaMask/core/compare/@metamask/permission-controller@9.1.0...@metamask/permission-controller@9.1.1 +[9.1.0]: https://github.com/MetaMask/core/compare/@metamask/permission-controller@9.0.2...@metamask/permission-controller@9.1.0 +[9.0.2]: https://github.com/MetaMask/core/compare/@metamask/permission-controller@9.0.1...@metamask/permission-controller@9.0.2 +[9.0.1]: https://github.com/MetaMask/core/compare/@metamask/permission-controller@9.0.0...@metamask/permission-controller@9.0.1 +[9.0.0]: https://github.com/MetaMask/core/compare/@metamask/permission-controller@8.0.1...@metamask/permission-controller@9.0.0 +[8.0.1]: https://github.com/MetaMask/core/compare/@metamask/permission-controller@8.0.0...@metamask/permission-controller@8.0.1 +[8.0.0]: https://github.com/MetaMask/core/compare/@metamask/permission-controller@7.1.0...@metamask/permission-controller@8.0.0 +[7.1.0]: https://github.com/MetaMask/core/compare/@metamask/permission-controller@7.0.0...@metamask/permission-controller@7.1.0 +[7.0.0]: https://github.com/MetaMask/core/compare/@metamask/permission-controller@6.0.0...@metamask/permission-controller@7.0.0 +[6.0.0]: https://github.com/MetaMask/core/compare/@metamask/permission-controller@5.0.1...@metamask/permission-controller@6.0.0 [5.0.1]: https://github.com/MetaMask/core/compare/@metamask/permission-controller@5.0.0...@metamask/permission-controller@5.0.1 [5.0.0]: https://github.com/MetaMask/core/compare/@metamask/permission-controller@4.1.2...@metamask/permission-controller@5.0.0 [4.1.2]: https://github.com/MetaMask/core/compare/@metamask/permission-controller@4.1.1...@metamask/permission-controller@4.1.2 diff --git a/packages/permission-controller/LICENSE b/packages/permission-controller/LICENSE index ddfbecf9020..bbed2e24b91 100644 --- a/packages/permission-controller/LICENSE +++ b/packages/permission-controller/LICENSE @@ -18,3 +18,4 @@ 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/permission-controller/jest.config.js b/packages/permission-controller/jest.config.js index 0fb60da7b90..e69a27d2955 100644 --- a/packages/permission-controller/jest.config.js +++ b/packages/permission-controller/jest.config.js @@ -17,10 +17,10 @@ module.exports = merge(baseConfig, { // An object that configures minimum threshold enforcement for coverage results coverageThreshold: { global: { - branches: 98.8, + branches: 98.92, functions: 100, - lines: 99.78, - statements: 99.78, + lines: 100, + statements: 100, }, }, }); diff --git a/packages/permission-controller/package.json b/packages/permission-controller/package.json index b9b84887797..b384b3b07d0 100644 --- a/packages/permission-controller/package.json +++ b/packages/permission-controller/package.json @@ -1,63 +1,85 @@ { "name": "@metamask/permission-controller", - "version": "5.0.0", + "version": "13.1.1", "description": "Mediates access to JSON-RPC methods, used to interact with pieces of the MetaMask stack, via middleware for json-rpc-engine", "keywords": [ - "MetaMask", - "Ethereum" + "Ethereum", + "MetaMask" ], "homepage": "https://github.com/MetaMask/core/tree/main/packages/permission-controller#readme", "bugs": { "url": "https://github.com/MetaMask/core/issues" }, + "license": "MIT", "repository": { "type": "git", "url": "https://github.com/MetaMask/core.git" }, - "license": "MIT", - "main": "./dist/index.js", - "types": "./dist/index.d.ts", "files": [ "dist/" ], + "sideEffects": false, + "main": "./dist/index.cjs", + "types": "./dist/index.d.cts", + "exports": { + ".": { + "import": { + "types": "./dist/index.d.mts", + "default": "./dist/index.mjs" + }, + "require": { + "types": "./dist/index.d.cts", + "default": "./dist/index.cjs" + } + }, + "./package.json": "./package.json" + }, + "publishConfig": { + "access": "public", + "registry": "https://registry.npmjs.org/" + }, "scripts": { + "build": "ts-bridge --project tsconfig.build.json --verbose --clean --no-references", + "build:all": "ts-bridge --project tsconfig.build.json --verbose --clean", "build:docs": "typedoc", + "changelog:update": "../../scripts/update-changelog.sh @metamask/permission-controller", "changelog:validate": "../../scripts/validate-changelog.sh @metamask/permission-controller", - "publish:preview": "yarn npm publish --tag preview", - "test": "jest", - "test:clean": "jest --clearCache", - "test:watch": "jest --watch" + "lint:tsconfigs": "tsx ../../scripts/lint-tsconfigs/lint-tsconfigs.mts", + "lint:tsconfigs:fix": "tsx ../../scripts/lint-tsconfigs/lint-tsconfigs.mts --fix", + "messenger-action-types:check": "tsx ../../packages/messenger-cli/src/cli.ts --formatter oxfmt --esm --check", + "messenger-action-types:generate": "tsx ../../packages/messenger-cli/src/cli.ts --formatter oxfmt --esm --generate", + "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/approval-controller": "^4.0.1", - "@metamask/base-controller": "^3.2.3", - "@metamask/controller-utils": "^5.0.2", - "@metamask/json-rpc-engine": "^7.1.1", - "@metamask/rpc-errors": "^6.1.0", - "@metamask/utils": "^8.1.0", + "@metamask/approval-controller": "^9.0.2", + "@metamask/base-controller": "^9.1.0", + "@metamask/controller-utils": "^12.3.0", + "@metamask/json-rpc-engine": "^10.5.0", + "@metamask/messenger": "^2.0.0", + "@metamask/rpc-errors": "^7.0.2", + "@metamask/utils": "^11.11.0", "@types/deep-freeze-strict": "^1.1.0", "deep-freeze-strict": "^1.1.1", "immer": "^9.0.6", - "nanoid": "^3.1.31" + "nanoid": "^3.3.8" }, "devDependencies": { - "@metamask/auto-changelog": "^3.1.0", - "@types/jest": "^27.4.1", + "@metamask/auto-changelog": "^6.1.0", + "@ts-bridge/cli": "^0.6.4", + "@types/jest": "^30.0.0", "deepmerge": "^4.2.2", - "jest": "^27.5.1", - "ts-jest": "^27.1.4", - "typedoc": "^0.24.8", + "jest": "^30.4.2", + "ts-jest": "^29.4.11", + "tsx": "^4.20.5", + "typedoc": "^0.25.13", "typedoc-plugin-missing-exports": "^2.0.0", - "typescript": "~4.8.4" - }, - "peerDependencies": { - "@metamask/approval-controller": "^4.0.1" + "typescript": "~5.3.3" }, "engines": { - "node": ">=16.0.0" - }, - "publishConfig": { - "access": "public", - "registry": "https://registry.npmjs.org/" + "node": "^18.18 || >=20" } } diff --git a/packages/permission-controller/src/Caveat.test.ts b/packages/permission-controller/src/Caveat.test.ts index 5897f207212..0fb81e1dc99 100644 --- a/packages/permission-controller/src/Caveat.test.ts +++ b/packages/permission-controller/src/Caveat.test.ts @@ -1,17 +1,19 @@ -import type { PermissionConstraint } from '.'; -import { decorateWithCaveats, PermissionType } from '.'; -import * as errors from './errors'; +import * as errors from './errors.js'; +import type { Caveat, PermissionConstraint } from './index.js'; +import { decorateWithCaveats, PermissionType } from './index.js'; describe('decorateWithCaveats', () => { it('decorates a method with caveat', async () => { - const methodImplementation = () => [1, 2, 3]; + const methodImplementation = (): number[] => [1, 2, 3]; const caveatSpecifications = { reverse: { type: 'reverse', - decorator: (method: any, _caveat: any) => async () => { - return (await method()).reverse(); - }, + decorator: + (method: () => Promise, _caveat: Caveat) => + async (): Promise => { + return (await method()).reverse(); + }, }, }; @@ -39,20 +41,24 @@ describe('decorateWithCaveats', () => { }); it('decorates a method with multiple caveats', async () => { - const methodImplementation = () => [1, 2, 3]; + const methodImplementation = (): number[] => [1, 2, 3]; const caveatSpecifications = { reverse: { type: 'reverse', - decorator: (method: any, _caveat: any) => async () => { - return (await method()).reverse(); - }, + decorator: + (method: () => Promise, _caveat: Caveat) => + async (): Promise => { + return (await method()).reverse(); + }, }, slice: { type: 'slice', - decorator: (method: any, caveat: any) => async () => { - return (await method()).slice(0, caveat.value); - }, + decorator: + (method: () => Promise, caveat: Caveat) => + async (): Promise => { + return (await method()).slice(0, caveat.value); + }, }, }; @@ -83,7 +89,7 @@ describe('decorateWithCaveats', () => { }); it('returns the unmodified method implementation if there are no caveats', () => { - const methodImplementation = () => [1, 2, 3]; + const methodImplementation = (): number[] => [1, 2, 3]; const permission: PermissionConstraint = { id: 'foo', @@ -103,14 +109,16 @@ describe('decorateWithCaveats', () => { }); it('throws an error if the caveat type is unrecognized', () => { - const methodImplementation = () => [1, 2, 3]; + const methodImplementation = (): number[] => [1, 2, 3]; const caveatSpecifications = { reverse: { type: 'reverse', - decorator: (method: any, _caveat: any) => async () => { - return (await method()).reverse(); - }, + decorator: + (method: () => Promise, _caveat: Caveat) => + async (): Promise => { + return (await method()).reverse(); + }, }, }; @@ -136,7 +144,7 @@ describe('decorateWithCaveats', () => { }); it('throws an error if no decorator is present', async () => { - const methodImplementation = () => [1, 2, 3]; + const methodImplementation = (): number[] => [1, 2, 3]; const caveatSpecifications = { reverse: { diff --git a/packages/permission-controller/src/Caveat.ts b/packages/permission-controller/src/Caveat.ts index ad3a3115040..f79ded41b35 100644 --- a/packages/permission-controller/src/Caveat.ts +++ b/packages/permission-controller/src/Caveat.ts @@ -4,16 +4,16 @@ import { hasProperty } from '@metamask/utils'; import { CaveatSpecificationMismatchError, UnrecognizedCaveatTypeError, -} from './errors'; +} from './errors.js'; import type { AsyncRestrictedMethod, RestrictedMethod, PermissionConstraint, RestrictedMethodParameters, -} from './Permission'; -import { PermissionType } from './Permission'; +} from './Permission.js'; +import { PermissionType } from './Permission.js'; // eslint-disable-next-line @typescript-eslint/no-unused-vars -import type { PermissionController } from './PermissionController'; +import type { PermissionController } from './PermissionController.js'; export type CaveatConstraint = { /** @@ -24,7 +24,6 @@ export type CaveatConstraint = { */ readonly type: string; - // TODO:TS4.4 Make optional /** * Any additional data necessary to enforce the caveat. */ @@ -50,7 +49,6 @@ export type Caveat = { */ readonly type: Type; - // TODO:TS4.4 Make optional /** * Any additional data necessary to enforce the caveat. */ @@ -82,19 +80,21 @@ export type CaveatDecorator = ( * @template Decorator - The {@link CaveatDecorator} to extract a caveat value * type from. */ -type ExtractCaveatValueFromDecorator> = - Decorator extends ( - decorated: any, - caveat: infer ParentCaveat, - ) => AsyncRestrictedMethod - ? ParentCaveat extends CaveatConstraint - ? ParentCaveat['value'] - : never - : never; +type ExtractCaveatValueFromDecorator< + Decorator extends CaveatDecorator, +> = Decorator extends ( + decorated: AsyncRestrictedMethod, + caveat: infer ParentCaveat, +) => AsyncRestrictedMethod + ? ParentCaveat extends CaveatConstraint + ? ParentCaveat['value'] + : never + : never; /** * A function for validating caveats of a particular type. * + * @see `validator` in {@link CaveatSpecificationBase} for more details. * @template ParentCaveat - The caveat type associated with this validator. * @param caveat - The caveat object to validate. * @param origin - The origin associated with the parent permission. @@ -106,6 +106,29 @@ export type CaveatValidator = ( target?: string, ) => void; +/** + * A map of caveat type strings to {@link CaveatDiff} values. + */ +export type CaveatDiffMap = { + [CaveatType in ParentCaveat['type']]: ParentCaveat['value']; +}; + +/** + * A function that merges two caveat values of the same type. The values must be + * merged in the fashion of a right-biased union. + * + * @see `ARCHITECTURE.md` for more details. + * @template Value - The type of the values to merge. + * @param leftValue - The left-hand value. + * @param rightValue - The right-hand value. + * @returns `[newValue, diff]`, i.e. the merged value and the diff between the left value + * and the new value. The diff must be expressed in the same type as the value itself. + */ +export type CaveatValueMerger = ( + leftValue: Value, + rightValue: Value, +) => [Value, Value] | []; + export type CaveatSpecificationBase = { /** * The string type of the caveat. @@ -114,16 +137,30 @@ export type CaveatSpecificationBase = { /** * The validator function used to validate caveats of the associated type - * whenever they are instantiated. Caveat are instantiated whenever they are - * created or mutated. + * whenever they are constructed or mutated. * * The validator should throw an appropriate JSON-RPC error if validation fails. * * If no validator is specified, no validation of caveat values will be - * performed. Although caveats can also be validated by permission validators, - * validating caveat values separately is strongly recommended. + * performed. In instances where caveats are mutated but a permission's caveat + * array has not changed, any corresponding permission validator will not be + * called. For this reason, permission validators **must not** be relied upon + * to validate caveats. */ + // TODO: Replace `any` with type + // eslint-disable-next-line @typescript-eslint/no-explicit-any validator?: CaveatValidator; + + /** + * The merger function used to merge a pair of values of the associated caveat type + * during incremental permission requests. The values must be merged in the fashion + * of a right-biased union. + * + * @see `ARCHITECTURE.md` for more details. + */ + // TODO: Replace `any` with type + // eslint-disable-next-line @typescript-eslint/no-explicit-any + merger?: CaveatValueMerger; }; export type RestrictedMethodCaveatSpecificationConstraint = @@ -132,7 +169,7 @@ export type RestrictedMethodCaveatSpecificationConstraint = * The decorator function used to apply the caveat to restricted method * requests. */ - decorator: CaveatDecorator; + decorator: CaveatDecorator; }; export type EndowmentCaveatSpecificationConstraint = CaveatSpecificationBase; @@ -170,7 +207,10 @@ type CaveatSpecificationBuilderOptions< * tailored to their requirements. */ export type CaveatSpecificationBuilder< - Options extends CaveatSpecificationBuilderOptions, + Options extends CaveatSpecificationBuilderOptions< + Record, + Record + >, Specification extends CaveatSpecificationConstraint, > = (options: Options) => Specification; @@ -180,7 +220,10 @@ export type CaveatSpecificationBuilder< */ export type CaveatSpecificationBuilderExportConstraint = { specificationBuilder: CaveatSpecificationBuilder< - CaveatSpecificationBuilderOptions, + CaveatSpecificationBuilderOptions< + Record, + Record + >, CaveatSpecificationConstraint >; decoratorHookNames?: Record; @@ -206,16 +249,14 @@ export type CaveatSpecificationMap< */ export type ExtractCaveats< CaveatSpecification extends CaveatSpecificationConstraint, -> = CaveatSpecification extends any - ? CaveatSpecification extends RestrictedMethodCaveatSpecificationConstraint - ? Caveat< - CaveatSpecification['type'], - ExtractCaveatValueFromDecorator< - RestrictedMethodCaveatSpecificationConstraint['decorator'] - > +> = CaveatSpecification extends RestrictedMethodCaveatSpecificationConstraint + ? Caveat< + CaveatSpecification['type'], + ExtractCaveatValueFromDecorator< + RestrictedMethodCaveatSpecificationConstraint['decorator'] > - : Caveat - : never; + > + : Caveat; /** * Extracts the type of a specific {@link Caveat} from a union of caveat @@ -278,7 +319,7 @@ export function decorateWithCaveats< let decorated = async ( args: Parameters>[0], - ) => methodImplementation(args); + ): Promise => methodImplementation(args); for (const caveat of caveats) { const specification = diff --git a/packages/permission-controller/src/Permission.test.ts b/packages/permission-controller/src/Permission.test.ts index 95ace518c42..a18415681cb 100644 --- a/packages/permission-controller/src/Permission.test.ts +++ b/packages/permission-controller/src/Permission.test.ts @@ -1,6 +1,14 @@ -import type { CaveatConstraint, PermissionConstraint } from '.'; -import { constructPermission } from '.'; -import { findCaveat } from './Permission'; +import { Messenger } from '@metamask/messenger'; + +import { createRestrictedMethodMessenger } from './createRestrictedMethodMessenger.js'; +import type { + CaveatConstraint, + PermissionConstraint, + PermissionSpecificationBuilder, + RestrictedMethodSpecificationConstraint, +} from './index.js'; +import { constructPermission, PermissionType } from './index.js'; +import { findCaveat } from './Permission.js'; describe('constructPermission', () => { it('constructs a permission', () => { @@ -86,3 +94,73 @@ describe('findCaveat', () => { expect(findCaveat(permission, 'doesNotExist')).toBeUndefined(); }); }); + +describe('permission specification messenger option', () => { + type HostAction = { + type: 'Host:computeAnswer'; + handler: () => number; + }; + + const targetName = 'wallet_getAnswer'; + + type HostRootMessenger = Messenger<'Root', HostAction>; + + type SpecMessenger = ReturnType< + typeof createRestrictedMethodMessenger< + typeof targetName, + HostRootMessenger, + readonly ['Host:computeAnswer'] + > + >; + + const buildSpecificationBuilder = (): PermissionSpecificationBuilder< + PermissionType.RestrictedMethod, + { messenger: SpecMessenger }, + RestrictedMethodSpecificationConstraint + > => { + return ({ messenger }) => ({ + permissionType: PermissionType.RestrictedMethod, + targetName, + allowedCaveats: null, + methodImplementation: (): number => messenger.call('Host:computeAnswer'), + }); + }; + + const getRootMessenger = (): HostRootMessenger => { + const rootMessenger = new Messenger<'Root', HostAction>({ + namespace: 'Root', + }); + const hostMessenger = new Messenger< + 'Host', + HostAction, + never, + typeof rootMessenger + >({ namespace: 'Host', parent: rootMessenger }); + hostMessenger.registerActionHandler('Host:computeAnswer', () => 42); + return rootMessenger; + }; + + it('invokes the spec-declared action via the scoped messenger', () => { + const rootMessenger = getRootMessenger(); + const specificationBuilder = buildSpecificationBuilder(); + + const messenger = createRestrictedMethodMessenger({ + rootMessenger, + namespace: targetName, + actionNames: ['Host:computeAnswer'] as const, + }); + + const specification = specificationBuilder({ messenger }); + + expect(specification.targetName).toBe(targetName); + expect(specification.allowedCaveats).toBeNull(); + expect(specification.permissionType).toBe(PermissionType.RestrictedMethod); + expect( + specification.methodImplementation({ + method: targetName, + params: [], + context: { origin: 'example.com' }, + }), + ).toBe(42); + }); +}); diff --git a/packages/permission-controller/src/Permission.ts b/packages/permission-controller/src/Permission.ts index c9951630f0e..fcf4a171b87 100644 --- a/packages/permission-controller/src/Permission.ts +++ b/packages/permission-controller/src/Permission.ts @@ -1,20 +1,17 @@ -import type { - ActionConstraint, - EventConstraint, -} from '@metamask/base-controller'; import type { NonEmptyArray } from '@metamask/controller-utils'; +import type { ActionConstraint, EventConstraint } from '@metamask/messenger'; import type { Json } from '@metamask/utils'; import { nanoid } from 'nanoid'; // eslint-disable-next-line @typescript-eslint/no-unused-vars -import type { CaveatConstraint, Caveat } from './Caveat'; +import type { CaveatConstraint, Caveat } from './Caveat.js'; import type { // eslint-disable-next-line @typescript-eslint/no-unused-vars PermissionController, PermissionsRequest, SideEffectMessenger, -} from './PermissionController'; -import type { SubjectType } from './SubjectMetadataController'; +} from './PermissionController.js'; +import type { SubjectType } from './SubjectMetadataController.js'; /** * The origin of a subject. @@ -45,7 +42,6 @@ export type PermissionConstraint = { */ readonly '@context'?: NonEmptyArray; - // TODO:TS4.4 Make optional /** * The caveats of the permission. * @@ -92,7 +88,6 @@ export type ValidPermission< Name extends TargetName, AllowedCaveat extends CaveatConstraint, > = PermissionConstraint & { - // TODO:TS4.4 Make optional /** * The caveats of the permission. * @@ -118,9 +113,9 @@ export type ValidPermission< */ type ExtractArrayMembers = ArrayType extends [] ? never - : ArrayType extends any[] | readonly any[] - ? ArrayType[number] - : never; + : ArrayType extends unknown[] | readonly unknown[] + ? ArrayType[number] + : never; /** * A utility type for extracting the allowed caveat types for a particular @@ -207,7 +202,7 @@ export type RequestedPermissions = Record; */ type RestrictedMethodContext = Readonly<{ origin: OriginString; - [key: string]: any; + [key: string]: unknown; }>; export type RestrictedMethodParameters = Json[] | Record; @@ -261,7 +256,10 @@ export type RestrictedMethod< | AsyncRestrictedMethod; export type ValidRestrictedMethod< - MethodImplementation extends RestrictedMethod, + MethodImplementation extends RestrictedMethod< + RestrictedMethodParameters, + Json + >, > = MethodImplementation extends (args: infer Options) => Json | Promise ? Options extends RestrictedMethodOptions ? MethodImplementation @@ -316,7 +314,7 @@ export type SideEffectParams< Events extends EventConstraint, > = { requestData: PermissionsRequest; - messagingSystem: SideEffectMessenger; + messenger: SideEffectMessenger; }; /** @@ -398,12 +396,18 @@ type PermissionSpecificationBase = { * used, and the validator function (if specified) will be called on newly * constructed permissions. */ + // TODO: Replace `any` with type + // eslint-disable-next-line @typescript-eslint/no-explicit-any factory?: PermissionFactory>; /** * The validator function used to validate permissions of the associated type - * whenever they are mutated. The only way a permission can be legally mutated - * is when its caveats are modified by the permission controller. + * whenever they are granted or their caveat arrays are mutated. + * + * Permission validators are **not** invoked when a caveat is mutated, provided + * the caveat array has not changed. For this reason, permission validators + * **must not** be used to validate caveats. To validate caveats, use the + * corresponding caveat specification property. * * The validator should throw an appropriate JSON-RPC error if validation fails. */ @@ -415,7 +419,7 @@ type PermissionSpecificationBase = { * * If the side-effect action fails, the permission that triggered it is revoked. */ - sideEffect?: PermissionSideEffect; + sideEffect?: PermissionSideEffect; /** * The Permission may be available to only a subset of the subject types. If so, specify the subject types as an array. @@ -439,7 +443,9 @@ export type RestrictedMethodSpecificationConstraint = * The implementation of the restricted method that the permission * corresponds to. */ - methodImplementation: RestrictedMethod; + // TODO: Replace `any` with type + // eslint-disable-next-line @typescript-eslint/no-explicit-any + methodImplementation: RestrictedMethod; }; /** @@ -457,7 +463,7 @@ export type EndowmentSpecificationConstraint = * permission is invoked, after which the host can apply the endowments to * the requesting subject in the intended manner. */ - endowmentGetter: EndowmentGetter; + endowmentGetter: EndowmentGetter; }; /** @@ -478,15 +484,19 @@ export type PermissionSpecificationConstraint = * Options for {@link PermissionSpecificationBuilder} functions. */ type PermissionSpecificationBuilderOptions< - FactoryHooks extends Record, MethodHooks extends Record, - ValidatorHooks extends Record, + SpecMessenger = unknown, > = { targetName?: string; allowedCaveats?: Readonly> | null; - factoryHooks?: FactoryHooks; methodHooks?: MethodHooks; - validatorHooks?: ValidatorHooks; + /** + * A messenger scoped to this permission specification. The messenger is + * expected to have exactly the actions declared by the spec's `actionNames` + * delegated to it; {@link createRestrictedMethodMessenger} is the canonical + * way to construct it. + */ + messenger?: SpecMessenger; }; /** @@ -497,35 +507,22 @@ type PermissionSpecificationBuilderOptions< */ export type PermissionSpecificationBuilder< Type extends PermissionType, - Options extends PermissionSpecificationBuilderOptions, + Options extends PermissionSpecificationBuilderOptions< + Record + >, Specification extends PermissionSpecificationConstraint & { permissionType: Type; }, > = (options: Options) => Specification; -/** - * A restricted method permission export object, containing the - * {@link PermissionSpecificationBuilder} function and "hook name" objects. - */ -export type PermissionSpecificationBuilderExportConstraint = { - targetName: string; - specificationBuilder: PermissionSpecificationBuilder< - PermissionType, - PermissionSpecificationBuilderOptions, - PermissionSpecificationConstraint - >; - factoryHookNames?: Record; - methodHookNames?: Record; - validatorHookNames?: Record; -}; - type ValidRestrictedMethodSpecification< Specification extends RestrictedMethodSpecificationConstraint, -> = Specification['methodImplementation'] extends ValidRestrictedMethod< - Specification['methodImplementation'] -> - ? Specification - : never; +> = + Specification['methodImplementation'] extends ValidRestrictedMethod< + Specification['methodImplementation'] + > + ? Specification + : never; /** * Constraint for {@link PermissionSpecificationConstraint} objects that @@ -539,10 +536,10 @@ export type ValidPermissionSpecification< ? Specification['permissionType'] extends PermissionType.Endowment ? Specification : Specification['permissionType'] extends PermissionType.RestrictedMethod - ? ValidRestrictedMethodSpecification< - Extract - > - : never + ? ValidRestrictedMethodSpecification< + Extract + > + : never : never; /** diff --git a/packages/permission-controller/src/PermissionController-method-action-types.ts b/packages/permission-controller/src/PermissionController-method-action-types.ts new file mode 100644 index 00000000000..db60cd68d80 --- /dev/null +++ b/packages/permission-controller/src/PermissionController-method-action-types.ts @@ -0,0 +1,419 @@ +/** + * This file is auto generated. + * Do not edit manually. + */ + +import type { PermissionController } from './PermissionController.js'; + +/** + * Checks whether the given method was declared as unrestricted at + * construction time. Methods unknown to the controller return `false` and + * would be treated as restricted by callers such as the permission + * middleware. + * + * @param method - The name of the method to check. + * @returns Whether the method is unrestricted. + */ +export type PermissionControllerHasUnrestrictedMethodAction = { + type: `PermissionController:hasUnrestrictedMethod`; + handler: PermissionController['hasUnrestrictedMethod']; +}; + +/** + * Clears the state of the controller. + */ +export type PermissionControllerClearStateAction = { + type: `PermissionController:clearState`; + handler: PermissionController['clearState']; +}; + +/** + * Gets a list of all origins of subjects. + * + * @returns The origins (i.e. IDs) of all subjects. + */ +export type PermissionControllerGetSubjectNamesAction = { + type: `PermissionController:getSubjectNames`; + handler: PermissionController['getSubjectNames']; +}; + +/** + * Gets the permission for the specified target of the subject corresponding + * to the specified origin. + * + * @param origin - The origin of the subject. + * @param targetName - The method name as invoked by a third party (i.e., not + * a method key). + * @returns The permission if it exists, or undefined otherwise. + */ +export type PermissionControllerGetPermissionAction = { + type: `PermissionController:getPermission`; + handler: PermissionController['getPermission']; +}; + +/** + * Gets all permissions for the specified subject, if any. + * + * @param origin - The origin of the subject. + * @returns The permissions of the subject, if any. + */ +export type PermissionControllerGetPermissionsAction = { + type: `PermissionController:getPermissions`; + handler: PermissionController['getPermissions']; +}; + +/** + * Checks whether the subject with the specified origin has the specified + * permission. + * + * @param origin - The origin of the subject. + * @param target - The target name of the permission. + * @returns Whether the subject has the permission. + */ +export type PermissionControllerHasPermissionAction = { + type: `PermissionController:hasPermission`; + handler: PermissionController['hasPermission']; +}; + +/** + * Checks whether the subject with the specified origin has any permissions. + * Use this if you want to know if a subject "exists". + * + * @param origin - The origin of the subject to check. + * @returns Whether the subject has any permissions. + */ +export type PermissionControllerHasPermissionsAction = { + type: `PermissionController:hasPermissions`; + handler: PermissionController['hasPermissions']; +}; + +/** + * Revokes all permissions from the specified origin. + * + * Throws an error if the origin has no permissions. + * + * @param origin - The origin whose permissions to revoke. + */ +export type PermissionControllerRevokeAllPermissionsAction = { + type: `PermissionController:revokeAllPermissions`; + handler: PermissionController['revokeAllPermissions']; +}; + +/** + * Revokes the specified permission from the subject with the specified + * origin. + * + * Throws an error if the subject or the permission does not exist. + * + * @param origin - The origin of the subject whose permission to revoke. + * @param target - The target name of the permission to revoke. + */ +export type PermissionControllerRevokePermissionAction = { + type: `PermissionController:revokePermission`; + handler: PermissionController['revokePermission']; +}; + +/** + * Revokes the specified permissions from the specified subjects. + * + * Throws an error if any of the subjects or permissions do not exist. + * + * @param subjectsAndPermissions - An object mapping subject origins + * to arrays of permission target names to revoke. + */ +export type PermissionControllerRevokePermissionsAction = { + type: `PermissionController:revokePermissions`; + handler: PermissionController['revokePermissions']; +}; + +/** + * Revokes all permissions corresponding to the specified target for all subjects. + * Does nothing if no subjects or no such permission exists. + * + * @param target - The name of the target to revoke all permissions for. + */ +export type PermissionControllerRevokePermissionForAllSubjectsAction = { + type: `PermissionController:revokePermissionForAllSubjects`; + handler: PermissionController['revokePermissionForAllSubjects']; +}; + +/** + * Gets the caveat of the specified type, if any, for the permission of + * the subject corresponding to the given origin. + * + * Throws an error if the subject does not have a permission with the + * specified target name. + * + * @template TargetName - The permission target name. Should be inferred. + * @template CaveatType - The valid caveat types for the permission. Should + * be inferred. + * @param origin - The origin of the subject. + * @param target - The target name of the permission. + * @param caveatType - The type of the caveat to get. + * @returns The caveat, or `undefined` if no such caveat exists. + */ +export type PermissionControllerGetCaveatAction = { + type: `PermissionController:getCaveat`; + handler: PermissionController['getCaveat']; +}; + +/** + * Updates the value of the caveat of the specified type belonging to the + * permission corresponding to the given subject origin and permission + * target. + * + * For adding new caveats, use + * {@link PermissionController.addCaveat}. + * + * Throws an error if no such permission or caveat exists. + * + * @template TargetName - The permission target name. Should be inferred. + * @template CaveatType - The valid caveat types for the permission. Should + * be inferred. + * @param origin - The origin of the subject. + * @param target - The target name of the permission. + * @param caveatType - The type of the caveat to update. + * @param caveatValue - The new value of the caveat. + */ +export type PermissionControllerUpdateCaveatAction = { + type: `PermissionController:updateCaveat`; + handler: PermissionController['updateCaveat']; +}; + +/** + * Updates all caveats with the specified type for all subjects and + * permissions by applying the specified mutator function to them. + * + * ATTN: Permissions can be revoked entirely by the action of this method, + * read on for details. + * + * Caveat mutators are functions that receive a caveat value and return a + * tuple consisting of a {@link CaveatMutatorOperation} and, optionally, a new + * value to update the existing caveat with. + * + * For each caveat, depending on the mutator result, this method will: + * - Do nothing ({@link CaveatMutatorOperation.Noop}) + * - Update the value of the caveat ({@link CaveatMutatorOperation.UpdateValue}). The caveat specification validator, if any, will be called after updating the value. + * - Delete the caveat ({@link CaveatMutatorOperation.DeleteCaveat}). The permission specification validator, if any, will be called after deleting the caveat. + * - Revoke the parent permission ({@link CaveatMutatorOperation.RevokePermission}) + * + * This method throws if the validation of any caveat or permission fails. + * + * @param targetCaveatType - The type of the caveats to update. + * @param mutator - The mutator function which will be applied to all caveat + * values. + */ +export type PermissionControllerUpdatePermissionsByCaveatAction = { + type: `PermissionController:updatePermissionsByCaveat`; + handler: PermissionController['updatePermissionsByCaveat']; +}; + +/** + * Grants _approved_ permissions to the specified subject. Every permission and + * caveat is stringently validated—including by calling their specification + * validators—and an error is thrown if validation fails. + * + * ATTN: This method does **not** prompt the user for approval. User consent must + * first be obtained through some other means. + * + * @see {@link PermissionController.requestPermissions} For initiating a + * permissions request requiring user approval. + * @param options - Options bag. + * @param options.approvedPermissions - The requested permissions approved by + * the user. + * @param options.requestData - Permission request data. Passed to permission + * factory functions. + * @param options.preserveExistingPermissions - Whether to preserve the + * subject's existing permissions. + * @param options.subject - The subject to grant permissions to. + * @returns The subject's new permission state. It may or may not have changed. + */ +export type PermissionControllerGrantPermissionsAction = { + type: `PermissionController:grantPermissions`; + handler: PermissionController['grantPermissions']; +}; + +/** + * Incrementally grants _approved_ permissions to the specified subject. Every + * permission and caveat is stringently validated—including by calling their + * specification validators—and an error is thrown if validation fails. + * + * ATTN: This method does **not** prompt the user for approval. User consent must + * first be obtained through some other means. + * + * @see {@link PermissionController.requestPermissionsIncremental} For initiating + * an incremental permissions request requiring user approval. + * @param options - Options bag. + * @param options.approvedPermissions - The requested permissions approved by + * the user. + * @param options.requestData - Permission request data. Passed to permission + * factory functions. + * @param options.subject - The subject to grant permissions to. + * @returns The subject's new permission state. It may or may not have changed. + */ +export type PermissionControllerGrantPermissionsIncrementalAction = { + type: `PermissionController:grantPermissionsIncremental`; + handler: PermissionController['grantPermissionsIncremental']; +}; + +/** + * Initiates a permission request that requires user approval. + * + * Either this or {@link PermissionController.requestPermissionsIncremental} + * should always be used to grant additional permissions to a subject, + * unless user approval has been obtained through some other means. + * + * Permissions are validated at every step of the approval process, and this + * method will reject if validation fails. + * + * @see {@link ApprovalController} For the user approval logic. + * @see {@link PermissionController.acceptPermissionsRequest} For the method + * that _accepts_ the request and resolves the user approval promise. + * @see {@link PermissionController.rejectPermissionsRequest} For the method + * that _rejects_ the request and the user approval promise. + * @param subject - The grantee subject. + * @param requestedPermissions - The requested permissions. + * @param options - Additional options. + * @param options.id - The id of the permissions request. Defaults to a unique + * id. + * @param options.preserveExistingPermissions - Whether to preserve the + * subject's existing permissions. Defaults to `true`. + * @param options.metadata - Additional metadata about the permission request. + * @returns The granted permissions and request metadata. + */ +export type PermissionControllerRequestPermissionsAction = { + type: `PermissionController:requestPermissions`; + handler: PermissionController['requestPermissions']; +}; + +/** + * Initiates an incremental permission request that prompts for user approval. + * Incremental permission requests allow the caller to replace existing and/or + * add brand new permissions and caveats for the specified subject. + * + * Incremental permission request are merged with the subject's existing permissions + * through a right-biased union, where the incremental permission are the right-hand + * side of the merger. If both sides of the merger specify the same caveats for a + * given permission, the caveats are merged using their specification's caveat value + * merger property. + * + * Either this or {@link PermissionController.requestPermissions} should + * always be used to grant additional permissions to a subject, unless user + * approval has been obtained through some other means. + * + * Permissions are validated at every step of the approval process, and this + * method will reject if validation fails. + * + * @see {@link ApprovalController} For the user approval logic. + * @see {@link PermissionController.acceptPermissionsRequest} For the method + * that _accepts_ the request and resolves the user approval promise. + * @see {@link PermissionController.rejectPermissionsRequest} For the method + * that _rejects_ the request and the user approval promise. + * @param subject - The grantee subject. + * @param requestedPermissions - The requested permissions. + * @param options - Additional options. + * @param options.id - The id of the permissions request. Defaults to a unique + * id. + * @param options.metadata - Additional metadata about the permission request. + * @returns The granted permissions and request metadata. + */ +export type PermissionControllerRequestPermissionsIncrementalAction = { + type: `PermissionController:requestPermissionsIncremental`; + handler: PermissionController['requestPermissionsIncremental']; +}; + +/** + * Accepts a permissions request created by + * {@link PermissionController.requestPermissions}. + * + * @param request - The permissions request. + */ +export type PermissionControllerAcceptPermissionsRequestAction = { + type: `PermissionController:acceptPermissionsRequest`; + handler: PermissionController['acceptPermissionsRequest']; +}; + +/** + * Rejects a permissions request created by + * {@link PermissionController.requestPermissions}. + * + * @param id - The id of the request to be rejected. + */ +export type PermissionControllerRejectPermissionsRequestAction = { + type: `PermissionController:rejectPermissionsRequest`; + handler: PermissionController['rejectPermissionsRequest']; +}; + +/** + * Gets the subject's endowments per the specified endowment permission. + * Throws if the subject does not have the required permission or if the + * permission is not an endowment permission. + * + * @param origin - The origin of the subject whose endowments to retrieve. + * @param targetName - The name of the endowment permission. This must be a + * valid permission target name. + * @param requestData - Additional data associated with the request, if any. + * Forwarded to the endowment getter function for the permission. + * @returns The endowments, if any. + */ +export type PermissionControllerGetEndowmentsAction = { + type: `PermissionController:getEndowments`; + handler: PermissionController['getEndowments']; +}; + +/** + * Executes a restricted method as the subject with the given origin. + * The specified params, if any, will be passed to the method implementation. + * + * ATTN: Great caution should be exercised in the use of this method. + * Methods that cause side effects or affect application state should + * be avoided. + * + * This method will first attempt to retrieve the requested restricted method + * implementation, throwing if it does not exist. The method will then be + * invoked as though the subject with the specified origin had invoked it with + * the specified parameters. This means that any existing caveats will be + * applied to the restricted method, and this method will throw if the + * restricted method or its caveat decorators throw. + * + * In addition, this method will throw if the subject does not have a + * permission for the specified restricted method. + * + * @param origin - The origin of the subject to execute the method on behalf + * of. + * @param targetName - The name of the method to execute. This must be a valid + * permission target name. + * @param params - The parameters to pass to the method implementation. + * @returns The result of the executed method. + */ +export type PermissionControllerExecuteRestrictedMethodAction = { + type: `PermissionController:executeRestrictedMethod`; + handler: PermissionController['executeRestrictedMethod']; +}; + +/** + * Union of all PermissionController action types. + */ +export type PermissionControllerMethodActions = + | PermissionControllerHasUnrestrictedMethodAction + | PermissionControllerClearStateAction + | PermissionControllerGetSubjectNamesAction + | PermissionControllerGetPermissionAction + | PermissionControllerGetPermissionsAction + | PermissionControllerHasPermissionAction + | PermissionControllerHasPermissionsAction + | PermissionControllerRevokeAllPermissionsAction + | PermissionControllerRevokePermissionAction + | PermissionControllerRevokePermissionsAction + | PermissionControllerRevokePermissionForAllSubjectsAction + | PermissionControllerGetCaveatAction + | PermissionControllerUpdateCaveatAction + | PermissionControllerUpdatePermissionsByCaveatAction + | PermissionControllerGrantPermissionsAction + | PermissionControllerGrantPermissionsIncrementalAction + | PermissionControllerRequestPermissionsAction + | PermissionControllerRequestPermissionsIncrementalAction + | PermissionControllerAcceptPermissionsRequestAction + | PermissionControllerRejectPermissionsRequestAction + | PermissionControllerGetEndowmentsAction + | PermissionControllerExecuteRestrictedMethodAction; diff --git a/packages/permission-controller/src/PermissionController.test.ts b/packages/permission-controller/src/PermissionController.test.ts index 29a62805e65..92179f94f43 100644 --- a/packages/permission-controller/src/PermissionController.test.ts +++ b/packages/permission-controller/src/PermissionController.test.ts @@ -1,41 +1,51 @@ -import type { - AcceptRequest as AcceptApprovalRequest, - AddApprovalRequest, - HasApprovalRequest, - RejectRequest as RejectApprovalRequest, -} from '@metamask/approval-controller'; -import { ControllerMessenger } from '@metamask/base-controller'; +import { deriveStateFromMetadata } from '@metamask/base-controller'; import { isPlainObject } from '@metamask/controller-utils'; import { JsonRpcEngine } from '@metamask/json-rpc-engine'; -import type { Json, PendingJsonRpcResponse } from '@metamask/utils'; -import { hasProperty } from '@metamask/utils'; +import { JsonRpcEngineV2 } from '@metamask/json-rpc-engine/v2'; +import { Messenger, MOCK_ANY_NAMESPACE } from '@metamask/messenger'; +import type { + MessengerActions, + MessengerEvents, + MockAnyNamespace, +} from '@metamask/messenger'; +import type { Json, JsonRpcRequest } from '@metamask/utils'; +import { + assertIsJsonRpcFailure, + assertIsJsonRpcSuccess, + hasProperty, +} from '@metamask/utils'; import assert from 'assert'; -import type { +import * as errors from './errors.js'; +import { AsyncRestrictedMethod, Caveat, CaveatConstraint, + CaveatMutator, + CaveatSpecificationMap, ExtractSpecifications, PermissionConstraint, - PermissionControllerActions, - PermissionControllerEvents, PermissionControllerMessenger, + PermissionControllerOptions, + PermissionControllerState, + PermissionMiddlewareActions, PermissionOptions, + PermissionsRequest, RestrictedMethodOptions, RestrictedMethodParameters, ValidPermission, -} from '.'; +} from './index.js'; import { CaveatMutatorOperation, constructPermission, + createPermissionMiddleware, + createPermissionMiddlewareV2, MethodNames, PermissionController, PermissionType, -} from '.'; -import * as errors from './errors'; -import type { EndowmentGetterParams } from './Permission'; -import { SubjectType } from './SubjectMetadataController'; -import type { GetSubjectMetadata } from './SubjectMetadataController'; +} from './index.js'; +import type { EndowmentGetterParams } from './Permission.js'; +import { SubjectType } from './SubjectMetadataController.js'; // Caveat types and specifications @@ -61,10 +71,18 @@ type FilterObjectCaveat = Caveat< type NoopCaveat = Caveat; -const onPermittedMock = jest.fn(() => Promise.resolve('foo')); -const onFailureMock = jest.fn(() => Promise.resolve()); -const onPermitted = () => onPermittedMock(); -const onFailure = () => onFailureMock(); +// A caveat value merger for any caveat whose value is an array of JSON primitives. +const primitiveArrayMerger = ( + a: Element[], + b: Element[], +): [Element[], Element[]] | [] => { + const diff = b.filter((element) => !a.includes(element)); + + if (diff.length > 0) { + return [[...(a ?? []), ...diff], diff] as [Element[], Element[]]; + } + return [] as []; +}; /** * Gets caveat specifications for: @@ -76,7 +94,9 @@ const onFailure = () => onFailureMock(); * * @returns The caveat specifications. */ -function getDefaultCaveatSpecifications() { +// TODO: Replace `any` with proper type if possible. +// eslint-disable-next-line @typescript-eslint/no-explicit-any +function getDefaultCaveatSpecifications(): CaveatSpecificationMap { return { [CaveatTypes.filterArrayResponse]: { type: CaveatTypes.filterArrayResponse, @@ -105,6 +125,7 @@ function getDefaultCaveatSpecifications() { ); } }, + merger: primitiveArrayMerger, }, [CaveatTypes.reverseArrayResponse]: { type: CaveatTypes.reverseArrayResponse, @@ -142,6 +163,7 @@ function getDefaultCaveatSpecifications() { }); return result; }, + merger: primitiveArrayMerger, }, [CaveatTypes.noopCaveat]: { type: CaveatTypes.noopCaveat, @@ -161,6 +183,8 @@ function getDefaultCaveatSpecifications() { throw new Error('NoopCaveat value must be null'); } }, + merger: (a: null | undefined, _b: null) => + a === undefined ? ([null, null] as [null, null]) : ([] as []), }, [CaveatTypes.endowmentCaveat]: { type: CaveatTypes.endowmentCaveat, @@ -198,6 +222,7 @@ const PermissionKeys = { wallet_noopWithValidator: 'wallet_noopWithValidator', wallet_noopWithRequiredCaveat: 'wallet_noopWithRequiredCaveat', wallet_noopWithFactory: 'wallet_noopWithFactory', + wallet_noopWithManyCaveats: 'wallet_noopWithManyCaveats', snap_foo: 'snap_foo', endowmentAnySubject: 'endowmentAnySubject', endowmentSnapsOnly: 'endowmentSnapsOnly', @@ -230,211 +255,312 @@ const PermissionNames = { PermissionKeys.wallet_noopWithPermittedSideEffects, wallet_noopWithRequiredCaveat: PermissionKeys.wallet_noopWithRequiredCaveat, wallet_noopWithFactory: PermissionKeys.wallet_noopWithFactory, + wallet_noopWithManyCaveats: PermissionKeys.wallet_noopWithManyCaveats, snap_foo: PermissionKeys.snap_foo, endowmentAnySubject: PermissionKeys.endowmentAnySubject, endowmentSnapsOnly: PermissionKeys.endowmentSnapsOnly, } as const; +// Default side-effect implementations. +const onPermittedSideEffect = (): Promise => Promise.resolve('foo'); +const onFailureSideEffect = (): Promise => Promise.resolve(); + +/** + * Gets the mocks for the side effect handlers of the permissions that have side effects. + * Mocking these handlers is complicated by the fact that their use by the permission + * controller precludes using Jest mock functions directly. We must create the mocks + * separately, then wrap them inside a plain function in the actual permission + * specification. This otherwise circuitous nonsense still allows us to access the + * underlying mocks in tests. + * + * @returns The side effect mocks. + */ +function getSideEffectHandlerMocks(): Record< + | typeof PermissionKeys.wallet_noopWithPermittedAndFailureSideEffects + | typeof PermissionKeys.wallet_noopWithPermittedAndFailureSideEffects2 + | typeof PermissionKeys.wallet_noopWithPermittedSideEffects, + { + onPermitted: jest.Mock>; + onFailure?: jest.Mock>; + } +> { + return { + [PermissionKeys.wallet_noopWithPermittedAndFailureSideEffects]: { + onPermitted: jest.fn(onPermittedSideEffect), + onFailure: jest.fn(onFailureSideEffect), + }, + [PermissionKeys.wallet_noopWithPermittedAndFailureSideEffects2]: { + onPermitted: jest.fn(onPermittedSideEffect), + onFailure: jest.fn(onFailureSideEffect), + }, + [PermissionKeys.wallet_noopWithPermittedSideEffects]: { + onPermitted: jest.fn(onPermittedSideEffect), + }, + } as const; +} + /** * Gets permission specifications for our test permissions. * Used as a default in {@link getPermissionControllerOptions}. * * @returns The permission specifications. */ -function getDefaultPermissionSpecifications() { - return { - [PermissionKeys.wallet_getSecretArray]: { - permissionType: PermissionType.RestrictedMethod, - targetName: PermissionKeys.wallet_getSecretArray, - allowedCaveats: [ - CaveatTypes.filterArrayResponse, - CaveatTypes.reverseArrayResponse, - ], - methodImplementation: (_args: RestrictedMethodOptions) => { - return ['a', 'b', 'c']; - }, - }, - [PermissionKeys.wallet_getSecretObject]: { - permissionType: PermissionType.RestrictedMethod, - targetName: PermissionKeys.wallet_getSecretObject, - allowedCaveats: [ - CaveatTypes.filterObjectResponse, - CaveatTypes.noopCaveat, - ], - methodImplementation: ( - _args: RestrictedMethodOptions>, - ) => { - return { a: 'x', b: 'y', c: 'z' }; - }, - validator: (permission: PermissionConstraint) => { - // A dummy validator for a caveat type that should be impossible to add - assert.ok( - !permission.caveats?.some( - (caveat) => caveat.type === CaveatTypes.filterArrayResponse, - ), - 'getSecretObject permission validation failed', - ); +// The return type of this is quite complicated. +// eslint-disable-next-line @typescript-eslint/explicit-function-return-type +function getDefaultPermissionSpecificationsAndMocks() { + const sideEffectMocks = getSideEffectHandlerMocks(); + + return [ + { + [PermissionKeys.wallet_getSecretArray]: { + permissionType: PermissionType.RestrictedMethod, + targetName: PermissionKeys.wallet_getSecretArray, + allowedCaveats: [ + CaveatTypes.filterArrayResponse, + CaveatTypes.reverseArrayResponse, + ], + methodImplementation: ( + _args: RestrictedMethodOptions, + ): string[] => { + return ['a', 'b', 'c']; + }, }, - }, - [PermissionKeys.wallet_doubleNumber]: { - permissionType: PermissionType.RestrictedMethod, - targetName: PermissionKeys.wallet_doubleNumber, - allowedCaveats: null, - methodImplementation: ({ params }: RestrictedMethodOptions<[number]>) => { - if (!Array.isArray(params)) { - throw new Error( - `Invalid ${PermissionKeys.wallet_doubleNumber} request`, + [PermissionKeys.wallet_getSecretObject]: { + permissionType: PermissionType.RestrictedMethod, + targetName: PermissionKeys.wallet_getSecretObject, + allowedCaveats: [ + CaveatTypes.filterObjectResponse, + CaveatTypes.noopCaveat, + ], + methodImplementation: ( + _args: RestrictedMethodOptions>, + ): { a: string; b: string; c: string } => { + return { a: 'x', b: 'y', c: 'z' }; + }, + validator: (permission: PermissionConstraint): void => { + // A dummy validator for a caveat type that should be impossible to add + assert.ok( + !permission.caveats?.some( + (caveat) => caveat.type === CaveatTypes.filterArrayResponse, + ), + 'getSecretObject permission validation failed', ); - } - return params[0] * 2; + }, }, - }, - [PermissionKeys.wallet_noop]: { - permissionType: PermissionType.RestrictedMethod, - targetName: PermissionKeys.wallet_noop, - allowedCaveats: null, - methodImplementation: (_args: RestrictedMethodOptions) => { - return null; + [PermissionKeys.wallet_doubleNumber]: { + permissionType: PermissionType.RestrictedMethod, + targetName: PermissionKeys.wallet_doubleNumber, + allowedCaveats: null, + methodImplementation: ({ + params, + }: RestrictedMethodOptions<[number]>): number => { + if (!Array.isArray(params)) { + throw new Error( + `Invalid ${PermissionKeys.wallet_doubleNumber} request`, + ); + } + return params[0] * 2; + }, }, - }, - [PermissionKeys.wallet_noopWithPermittedAndFailureSideEffects]: { - permissionType: PermissionType.RestrictedMethod, - targetName: PermissionKeys.wallet_noopWithPermittedAndFailureSideEffects, - allowedCaveats: null, - methodImplementation: (_args: RestrictedMethodOptions) => { - return null; + [PermissionKeys.wallet_noop]: { + permissionType: PermissionType.RestrictedMethod, + targetName: PermissionKeys.wallet_noop, + allowedCaveats: null, + methodImplementation: (_args: RestrictedMethodOptions): null => { + return null; + }, }, - sideEffect: { - onPermitted, - onFailure, + [PermissionKeys.wallet_noopWithPermittedAndFailureSideEffects]: { + permissionType: PermissionType.RestrictedMethod, + targetName: + PermissionKeys.wallet_noopWithPermittedAndFailureSideEffects, + allowedCaveats: null, + methodImplementation: (_args: RestrictedMethodOptions): null => { + return null; + }, + sideEffect: { + onPermitted: (): Promise => + sideEffectMocks[ + PermissionKeys.wallet_noopWithPermittedAndFailureSideEffects + ].onPermitted(), + onFailure: async (): Promise => + sideEffectMocks[ + PermissionKeys.wallet_noopWithPermittedAndFailureSideEffects + ]?.onFailure?.(), + }, }, - }, - [PermissionKeys.wallet_noopWithPermittedAndFailureSideEffects2]: { - permissionType: PermissionType.RestrictedMethod, - targetName: PermissionKeys.wallet_noopWithPermittedAndFailureSideEffects2, - allowedCaveats: null, - methodImplementation: (_args: RestrictedMethodOptions) => { - return null; + [PermissionKeys.wallet_noopWithPermittedAndFailureSideEffects2]: { + permissionType: PermissionType.RestrictedMethod, + targetName: + PermissionKeys.wallet_noopWithPermittedAndFailureSideEffects2, + allowedCaveats: null, + methodImplementation: (_args: RestrictedMethodOptions): null => { + return null; + }, + sideEffect: { + onPermitted: (): Promise => + sideEffectMocks[ + PermissionKeys.wallet_noopWithPermittedAndFailureSideEffects2 + ].onPermitted(), + onFailure: async (): Promise => + sideEffectMocks[ + PermissionKeys.wallet_noopWithPermittedAndFailureSideEffects2 + ]?.onFailure?.(), + }, }, - sideEffect: { - onPermitted, - onFailure, + [PermissionKeys.wallet_noopWithPermittedSideEffects]: { + permissionType: PermissionType.RestrictedMethod, + targetName: PermissionKeys.wallet_noopWithPermittedSideEffects, + allowedCaveats: null, + methodImplementation: (_args: RestrictedMethodOptions): null => { + return null; + }, + sideEffect: { + onPermitted: (): Promise => + sideEffectMocks[ + PermissionKeys.wallet_noopWithPermittedSideEffects + ].onPermitted(), + }, }, - }, - [PermissionKeys.wallet_noopWithPermittedSideEffects]: { - permissionType: PermissionType.RestrictedMethod, - targetName: PermissionKeys.wallet_noopWithPermittedSideEffects, - allowedCaveats: null, - methodImplementation: (_args: RestrictedMethodOptions) => { - return null; + // This one exists to check some permission validator logic + [PermissionKeys.wallet_noopWithValidator]: { + permissionType: PermissionType.RestrictedMethod, + targetName: PermissionKeys.wallet_noopWithValidator, + methodImplementation: (_args: RestrictedMethodOptions): null => { + return null; + }, + allowedCaveats: [ + CaveatTypes.noopCaveat, + CaveatTypes.filterArrayResponse, + ], + validator: (permission: PermissionConstraint): void => { + if ( + permission.caveats?.some( + ({ type }) => type !== CaveatTypes.noopCaveat, + ) + ) { + throw new Error('noop permission validation failed'); + } + }, }, - sideEffect: { - onPermitted, + [PermissionKeys.wallet_noopWithRequiredCaveat]: { + permissionType: PermissionType.RestrictedMethod, + targetName: PermissionKeys.wallet_noopWithRequiredCaveat, + methodImplementation: (_args: RestrictedMethodOptions): null => { + return null; + }, + allowedCaveats: [CaveatTypes.noopCaveat], + factory: ( + options: PermissionOptions, + _requestData?: Record, + ): NoopWithRequiredCaveat => { + return constructPermission({ + ...options, + caveats: [ + { + type: CaveatTypes.noopCaveat, + value: null, + }, + ], + }); + }, + validator: (permission: PermissionConstraint): void => { + if ( + permission.caveats?.length !== 1 || + !permission.caveats?.some( + ({ type }) => type === CaveatTypes.noopCaveat, + ) + ) { + throw new Error( + 'noopWithRequiredCaveat permission validation failed', + ); + } + }, }, - }, - // This one exists to check some permission validator logic - [PermissionKeys.wallet_noopWithValidator]: { - permissionType: PermissionType.RestrictedMethod, - targetName: PermissionKeys.wallet_noopWithValidator, - methodImplementation: (_args: RestrictedMethodOptions) => { - return null; + // This one exists just to check that permission factories can use the + // requestData of approved permission requests + [PermissionKeys.wallet_noopWithFactory]: { + permissionType: PermissionType.RestrictedMethod, + targetName: PermissionKeys.wallet_noopWithFactory, + methodImplementation: (_args: RestrictedMethodOptions): null => { + return null; + }, + allowedCaveats: [CaveatTypes.filterArrayResponse], + factory: ( + options: PermissionOptions, + requestData?: Record, + ): NoopWithFactoryPermission => { + if (!requestData) { + throw new Error('requestData is required'); + } + + return constructPermission({ + ...options, + caveats: [ + { + type: CaveatTypes.filterArrayResponse, + value: requestData.caveatValue as string[], + }, + ], + }); + }, }, - allowedCaveats: [CaveatTypes.noopCaveat, CaveatTypes.filterArrayResponse], - validator: (permission: PermissionConstraint) => { - if ( - permission.caveats?.some( - ({ type }) => type !== CaveatTypes.noopCaveat, - ) - ) { - throw new Error('noop permission validation failed'); - } + // The implementation of this is fundamentally broken due to its allowed + // caveats, but that's okay because we never need to actually execute it. + // Originally created for the purpose of testing caveat merging. + [PermissionKeys.wallet_noopWithManyCaveats]: { + permissionType: PermissionType.RestrictedMethod, + targetName: PermissionKeys.wallet_noopWithManyCaveats, + methodImplementation: (_args: RestrictedMethodOptions): null => { + return null; + }, + allowedCaveats: [ + CaveatTypes.filterArrayResponse, + CaveatTypes.filterObjectResponse, + CaveatTypes.noopCaveat, + ], }, - }, - [PermissionKeys.wallet_noopWithRequiredCaveat]: { - permissionType: PermissionType.RestrictedMethod, - targetName: PermissionKeys.wallet_noopWithRequiredCaveat, - methodImplementation: (_args: RestrictedMethodOptions) => { - return null; + [PermissionKeys.snap_foo]: { + permissionType: PermissionType.RestrictedMethod, + targetName: PermissionKeys.snap_foo, + allowedCaveats: null, + methodImplementation: (_args: RestrictedMethodOptions): null => { + return null; + }, + subjectTypes: [SubjectType.Snap], }, - allowedCaveats: [CaveatTypes.noopCaveat], - factory: ( - options: PermissionOptions, - _requestData?: Record, - ) => { - return constructPermission({ - ...options, - caveats: [ - { - type: CaveatTypes.noopCaveat, - value: null, - }, - ], - }); + [PermissionKeys.endowmentAnySubject]: { + permissionType: PermissionType.Endowment, + targetName: PermissionKeys.endowmentAnySubject, + endowmentGetter: (_options: EndowmentGetterParams): string[] => [ + 'endowment1', + ], + allowedCaveats: null, }, - validator: (permission: PermissionConstraint) => { - if ( - permission.caveats?.length !== 1 || - !permission.caveats?.some( - ({ type }) => type === CaveatTypes.noopCaveat, - ) - ) { - throw new Error( - 'noopWithRequiredCaveat permission validation failed', - ); - } + [PermissionKeys.endowmentSnapsOnly]: { + permissionType: PermissionType.Endowment, + targetName: PermissionKeys.endowmentSnapsOnly, + endowmentGetter: (_options: EndowmentGetterParams): string[] => [ + 'endowment2', + ], + allowedCaveats: [CaveatTypes.endowmentCaveat], + subjectTypes: [SubjectType.Snap], }, }, - // This one exists just to check that permission factories can use the - // requestData of approved permission requests - [PermissionKeys.wallet_noopWithFactory]: { - permissionType: PermissionType.RestrictedMethod, - targetName: PermissionKeys.wallet_noopWithFactory, - methodImplementation: (_args: RestrictedMethodOptions) => { - return null; - }, - allowedCaveats: [CaveatTypes.filterArrayResponse], - factory: ( - options: PermissionOptions, - requestData?: Record, - ) => { - if (!requestData) { - throw new Error('requestData is required'); - } + sideEffectMocks, + ] as const; +} - return constructPermission({ - ...options, - caveats: [ - { - type: CaveatTypes.filterArrayResponse, - value: requestData.caveatValue as string[], - }, - ], - }); - }, - }, - [PermissionKeys.snap_foo]: { - permissionType: PermissionType.RestrictedMethod, - targetName: PermissionKeys.snap_foo, - allowedCaveats: null, - methodImplementation: (_args: RestrictedMethodOptions) => { - return null; - }, - subjectTypes: [SubjectType.Snap], - }, - [PermissionKeys.endowmentAnySubject]: { - permissionType: PermissionType.Endowment, - targetName: PermissionKeys.endowmentAnySubject, - endowmentGetter: (_options: EndowmentGetterParams) => ['endowment1'], - allowedCaveats: null, - }, - [PermissionKeys.endowmentSnapsOnly]: { - permissionType: PermissionType.Endowment, - targetName: PermissionKeys.endowmentSnapsOnly, - endowmentGetter: (_options: EndowmentGetterParams) => ['endowment2'], - allowedCaveats: [CaveatTypes.endowmentCaveat], - subjectTypes: [SubjectType.Snap], - }, - } as const; +/** + * Gets permission specifications for our test permissions. + * Used as a default in {@link getPermissionControllerOptions}. + * + * @returns The permission specifications. + */ +// The return type of this is quite complicated. +// eslint-disable-next-line @typescript-eslint/explicit-function-return-type +function getDefaultPermissionSpecifications() { + return getDefaultPermissionSpecificationsAndMocks()[0]; } type DefaultPermissionSpecifications = ExtractSpecifications< @@ -445,49 +571,97 @@ type DefaultPermissionSpecifications = ExtractSpecifications< const controllerName = 'PermissionController' as const; -type AllowedActions = - | HasApprovalRequest - | AddApprovalRequest - | AcceptApprovalRequest - | RejectApprovalRequest - | GetSubjectMetadata; +/** + * Params for `ApprovalController:addRequest` of type `wallet_requestPermissions`. + */ +type AddPermissionRequestParams = { + id: string; + origin: string; + requestData: PermissionsRequest; + type: MethodNames.RequestPermissions; +}; + +type AddPermissionRequestArgs = [string, AddPermissionRequestParams]; + +type AllPermissionControllerActions = + MessengerActions; + +type AllPermissionControllerEvents = + MessengerEvents; + +type RootMessenger = Messenger< + MockAnyNamespace, + AllPermissionControllerActions, + AllPermissionControllerEvents +>; /** - * Gets a unrestricted controller messenger. Used for tests. + * Creates and returns a root messenger for testing * - * @returns The unrestricted messenger. + * @returns A messenger instance */ -function getUnrestrictedMessenger() { - return new ControllerMessenger< - PermissionControllerActions | AllowedActions, - PermissionControllerEvents - >(); +function getRootMessenger(): RootMessenger { + return new Messenger({ + namespace: MOCK_ANY_NAMESPACE, + }); } /** - * Gets a restricted controller messenger. + * Gets a messenger for the permission controller. * Used as a default in {@link getPermissionControllerOptions}. * - * @param messenger - Optional parameter to pass in a messenger - * @returns The restricted messenger. + * @param rootMessenger - Optional parameter to pass in a root messenger + * @returns The messenger for the permission controller. */ function getPermissionControllerMessenger( - messenger = getUnrestrictedMessenger(), -) { - return messenger.getRestricted< + rootMessenger = getRootMessenger(), +): PermissionControllerMessenger { + const messenger = new Messenger< typeof controllerName, - PermissionControllerActions['type'] | AllowedActions['type'], - PermissionControllerEvents['type'] + AllPermissionControllerActions, + AllPermissionControllerEvents, + RootMessenger >({ - name: controllerName, - allowedActions: [ + namespace: controllerName, + parent: rootMessenger, + }); + rootMessenger.delegate({ + actions: [ 'ApprovalController:hasRequest', 'ApprovalController:addRequest', 'ApprovalController:acceptRequest', 'ApprovalController:rejectRequest', 'SubjectMetadataController:getSubjectMetadata', ], - }) as PermissionControllerMessenger; + messenger, + }); + return messenger; +} + +/** + * Gets a messenger scoped to the actions required by the permission + * middleware, delegated from the given root messenger. + * + * @param rootMessenger - The root messenger to delegate from. + * @returns A messenger suitable for passing to `createPermissionMiddleware`. + */ +function getPermissionMiddlewareMessenger( + rootMessenger: RootMessenger, +): Messenger<'PermissionMiddleware', PermissionMiddlewareActions> { + const messenger = new Messenger< + 'PermissionMiddleware', + PermissionMiddlewareActions, + never, + RootMessenger + >({ namespace: 'PermissionMiddleware', parent: rootMessenger }); + rootMessenger.delegate({ + actions: [ + 'PermissionController:executeRestrictedMethod', + 'PermissionController:hasUnrestrictedMethod', + ], + messenger, + }); + return messenger; } /** @@ -496,7 +670,7 @@ function getPermissionControllerMessenger( * * @returns The unrestricted methods array */ -function getDefaultUnrestrictedMethods() { +function getDefaultUnrestrictedMethods(): readonly string[] { return Object.freeze(['wallet_unrestrictedMethod']); } @@ -506,7 +680,7 @@ function getDefaultUnrestrictedMethods() { * * @returns The existing mock state */ -function getExistingPermissionState() { +function getExistingPermissionState(): PermissionControllerState { return { subjects: { 'metamask.io': { @@ -539,7 +713,12 @@ function getExistingPermissionState() { * @param opts - Permission controller options. * @returns The permission controller constructor options. */ -function getPermissionControllerOptions(opts?: Record) { +function getPermissionControllerOptions( + opts?: Record, +): PermissionControllerOptions< + DefaultPermissionSpecifications, + DefaultCaveatSpecifications +> { return { caveatSpecifications: getDefaultCaveatSpecifications(), messenger: getPermissionControllerMessenger(), @@ -559,7 +738,10 @@ function getPermissionControllerOptions(opts?: Record) { */ function getDefaultPermissionController( opts = getPermissionControllerOptions(), -) { +): PermissionController< + DefaultPermissionSpecifications, + DefaultCaveatSpecifications +> { return new PermissionController< (typeof opts.permissionSpecifications)[keyof typeof opts.permissionSpecifications], (typeof opts.caveatSpecifications)[keyof typeof opts.caveatSpecifications] @@ -574,7 +756,10 @@ function getDefaultPermissionController( * @returns The default permission controller for testing, with some initial * state. */ -function getDefaultPermissionControllerWithState() { +function getDefaultPermissionControllerWithState(): PermissionController< + DefaultPermissionSpecifications, + DefaultCaveatSpecifications +> { return new PermissionController< DefaultPermissionSpecifications, DefaultCaveatSpecifications @@ -599,7 +784,9 @@ function getPermissionMatcher({ parentCapability: string; caveats?: CaveatConstraint[] | null | typeof expect.objectContaining; invoker?: string; -}) { + // `expect.objectContaining` returns `any`. + // eslint-disable-next-line @typescript-eslint/no-explicit-any +}): any { return expect.objectContaining({ id: expect.any(String), parentCapability, @@ -610,9 +797,6 @@ function getPermissionMatcher({ } describe('PermissionController', () => { - beforeEach(() => { - jest.clearAllMocks(); - }); describe('constructor', () => { it('initializes a new PermissionController', () => { const controller = getDefaultPermissionController(); @@ -645,7 +829,9 @@ describe('PermissionController', () => { }, }), ), - ).toThrow(`Invalid permission type: "${invalidPermissionType}"`); + ).toThrow( + `Invalid permission type: "${String(invalidPermissionType)}"`, + ); }); }); @@ -695,9 +881,8 @@ describe('PermissionController', () => { it('throws if a permission specification lists unrecognized caveats', () => { const permissionSpecifications = getDefaultPermissionSpecifications(); - ( - permissionSpecifications as any - ).wallet_getSecretArray.allowedCaveats.push('foo'); + // @ts-expect-error Intentional destructive testing + permissionSpecifications.wallet_getSecretArray.allowedCaveats.push('foo'); expect( () => @@ -774,36 +959,6 @@ describe('PermissionController', () => { }); }); - describe('getRestrictedMethod', () => { - it('gets the implementation of a restricted method', async () => { - const controller = getDefaultPermissionController(); - const method = controller.getRestrictedMethod( - PermissionNames.wallet_getSecretArray, - ); - - expect( - await method({ - method: 'wallet_getSecretArray', - context: { origin: 'github.com' }, - }), - ).toStrictEqual(['a', 'b', 'c']); - }); - - it('throws an error if the requested permission target is not a restricted method', () => { - const controller = getDefaultPermissionController(); - expect(() => - controller.getRestrictedMethod(PermissionNames.endowmentAnySubject), - ).toThrow(errors.methodNotFound(PermissionNames.endowmentAnySubject)); - }); - - it('throws an error if the method does not exist', () => { - const controller = getDefaultPermissionController(); - expect(() => controller.getRestrictedMethod('foo')).toThrow( - errors.methodNotFound('foo'), - ); - }); - }); - describe('getSubjectNames', () => { it('gets all subject names', () => { const controller = getDefaultPermissionController(); @@ -1653,7 +1808,7 @@ describe('PermissionController', () => { origin, PermissionNames.wallet_getSecretObject, CaveatTypes.noopCaveat, - 'bar' as any, + 'bar', ), ).toThrow(new Error('NoopCaveat value must be null')); }); @@ -1851,7 +2006,6 @@ describe('PermissionController', () => { expect(() => controller.removeCaveat( origin, - // @ts-expect-error - Testing invalid permission name. PermissionNames.wallet_noopWithRequiredCaveat, CaveatTypes.noopCaveat, ), @@ -1863,9 +2017,9 @@ describe('PermissionController', () => { describe('updatePermissionsByCaveat', () => { enum MultiCaveatOrigins { - a = 'a.com', - b = 'b.io', - c = 'c.biz', + A = 'a.com', + B = 'b.io', + C = 'c.biz', } /** @@ -1873,11 +2027,14 @@ describe('PermissionController', () => { * * @returns The permission controller instance */ - const getMultiCaveatController = () => { + const getMultiCaveatController = (): PermissionController< + DefaultPermissionSpecifications, + DefaultCaveatSpecifications + > => { const controller = getDefaultPermissionController(); controller.grantPermissions({ - subject: { origin: MultiCaveatOrigins.a }, + subject: { origin: MultiCaveatOrigins.A }, approvedPermissions: { [PermissionNames.wallet_getSecretArray]: { caveats: [{ type: CaveatTypes.filterArrayResponse, value: ['a'] }], @@ -1886,7 +2043,7 @@ describe('PermissionController', () => { }); controller.grantPermissions({ - subject: { origin: MultiCaveatOrigins.b }, + subject: { origin: MultiCaveatOrigins.B }, approvedPermissions: { [PermissionNames.wallet_getSecretArray]: { caveats: [ @@ -1908,7 +2065,7 @@ describe('PermissionController', () => { }); controller.grantPermissions({ - subject: { origin: MultiCaveatOrigins.c }, + subject: { origin: MultiCaveatOrigins.C }, approvedPermissions: { [PermissionNames.wallet_getSecretObject]: { caveats: [{ type: CaveatTypes.filterObjectResponse, value: ['c'] }], @@ -1926,25 +2083,25 @@ describe('PermissionController', () => { overrides: Partial< Record> > = {}, - ) => { + ): PermissionControllerState => { return { subjects: { - [MultiCaveatOrigins.a]: { - origin: MultiCaveatOrigins.a, + [MultiCaveatOrigins.A]: { + origin: MultiCaveatOrigins.A, permissions: { [PermissionNames.wallet_getSecretArray]: getPermissionMatcher({ parentCapability: PermissionNames.wallet_getSecretArray, caveats: [ { type: CaveatTypes.filterArrayResponse, value: ['a'] }, ], - invoker: MultiCaveatOrigins.a, + invoker: MultiCaveatOrigins.A, }), - ...overrides[MultiCaveatOrigins.a], + ...overrides[MultiCaveatOrigins.A], }, }, - [MultiCaveatOrigins.b]: { - origin: MultiCaveatOrigins.b, + [MultiCaveatOrigins.B]: { + origin: MultiCaveatOrigins.B, permissions: { [PermissionNames.wallet_getSecretArray]: getPermissionMatcher({ parentCapability: PermissionNames.wallet_getSecretArray, @@ -1952,7 +2109,7 @@ describe('PermissionController', () => { { type: CaveatTypes.filterArrayResponse, value: ['b'] }, { type: CaveatTypes.reverseArrayResponse, value: null }, ], - invoker: MultiCaveatOrigins.b, + invoker: MultiCaveatOrigins.B, }), [PermissionNames.wallet_getSecretObject]: getPermissionMatcher({ parentCapability: PermissionNames.wallet_getSecretObject, @@ -1960,42 +2117,42 @@ describe('PermissionController', () => { { type: CaveatTypes.filterObjectResponse, value: ['b'] }, { type: CaveatTypes.noopCaveat, value: null }, ], - invoker: MultiCaveatOrigins.b, + invoker: MultiCaveatOrigins.B, }), [PermissionNames.wallet_noopWithRequiredCaveat]: getPermissionMatcher({ parentCapability: PermissionNames.wallet_noopWithRequiredCaveat, caveats: [{ type: CaveatTypes.noopCaveat, value: null }], - invoker: MultiCaveatOrigins.b, + invoker: MultiCaveatOrigins.B, }), [PermissionNames.wallet_doubleNumber]: getPermissionMatcher({ parentCapability: PermissionNames.wallet_doubleNumber, caveats: null, - invoker: MultiCaveatOrigins.b, + invoker: MultiCaveatOrigins.B, }), - ...overrides[MultiCaveatOrigins.b], + ...overrides[MultiCaveatOrigins.B], }, }, - [MultiCaveatOrigins.c]: { - origin: MultiCaveatOrigins.c, + [MultiCaveatOrigins.C]: { + origin: MultiCaveatOrigins.C, permissions: { [PermissionNames.wallet_getSecretObject]: getPermissionMatcher({ parentCapability: PermissionNames.wallet_getSecretObject, caveats: [ { type: CaveatTypes.filterObjectResponse, value: ['c'] }, ], - invoker: MultiCaveatOrigins.c, + invoker: MultiCaveatOrigins.C, }), [PermissionNames.wallet_noopWithRequiredCaveat]: getPermissionMatcher({ parentCapability: PermissionNames.wallet_noopWithRequiredCaveat, caveats: [{ type: CaveatTypes.noopCaveat, value: null }], - invoker: MultiCaveatOrigins.c, + invoker: MultiCaveatOrigins.C, }), - ...overrides[MultiCaveatOrigins.c], + ...overrides[MultiCaveatOrigins.C], }, }, }, @@ -2017,7 +2174,7 @@ describe('PermissionController', () => { CaveatTypes.filterArrayResponse, () => { return { - operation: CaveatMutatorOperation.updateValue, + operation: CaveatMutatorOperation.UpdateValue, value: ['a', 'b'], }; }, @@ -2033,7 +2190,7 @@ describe('PermissionController', () => { controller.updatePermissionsByCaveat( CaveatTypes.filterArrayResponse, () => { - return { operation: CaveatMutatorOperation.noop }; + return { operation: CaveatMutatorOperation.Noop }; }, ); expect(controller.state).toStrictEqual(getMultiCaveatStateMatcher()); @@ -2046,7 +2203,7 @@ describe('PermissionController', () => { CaveatTypes.filterArrayResponse, () => { return { - operation: CaveatMutatorOperation.updateValue, + operation: CaveatMutatorOperation.UpdateValue, value: ['a', 'b'], }; }, @@ -2054,23 +2211,23 @@ describe('PermissionController', () => { expect(controller.state).toStrictEqual( getMultiCaveatStateMatcher({ - [MultiCaveatOrigins.a]: { + [MultiCaveatOrigins.A]: { [PermissionNames.wallet_getSecretArray]: getPermissionMatcher({ parentCapability: PermissionNames.wallet_getSecretArray, caveats: [ { type: CaveatTypes.filterArrayResponse, value: ['a', 'b'] }, ], - invoker: MultiCaveatOrigins.a, + invoker: MultiCaveatOrigins.A, }), }, - [MultiCaveatOrigins.b]: { + [MultiCaveatOrigins.B]: { [PermissionNames.wallet_getSecretArray]: getPermissionMatcher({ parentCapability: PermissionNames.wallet_getSecretArray, caveats: [ { type: CaveatTypes.filterArrayResponse, value: ['a', 'b'] }, { type: CaveatTypes.reverseArrayResponse, value: null }, ], - invoker: MultiCaveatOrigins.b, + invoker: MultiCaveatOrigins.B, }), }, }), @@ -2081,12 +2238,12 @@ describe('PermissionController', () => { const controller = getMultiCaveatController(); let counter = 0; - const mutator: any = () => { + const mutator: CaveatMutator> = () => { counter += 1; return counter === 1 - ? { operation: CaveatMutatorOperation.noop } + ? { operation: CaveatMutatorOperation.Noop as const } : { - operation: CaveatMutatorOperation.updateValue, + operation: CaveatMutatorOperation.UpdateValue as const, value: ['a', 'b'], }; }; @@ -2098,14 +2255,14 @@ describe('PermissionController', () => { expect(controller.state).toStrictEqual( getMultiCaveatStateMatcher({ - [MultiCaveatOrigins.b]: { + [MultiCaveatOrigins.B]: { [PermissionNames.wallet_getSecretArray]: getPermissionMatcher({ parentCapability: PermissionNames.wallet_getSecretArray, caveats: [ { type: CaveatTypes.filterArrayResponse, value: ['a', 'b'] }, { type: CaveatTypes.reverseArrayResponse, value: null }, ], - invoker: MultiCaveatOrigins.b, + invoker: MultiCaveatOrigins.B, }), }, }), @@ -2118,26 +2275,26 @@ describe('PermissionController', () => { controller.updatePermissionsByCaveat( CaveatTypes.filterArrayResponse, () => { - return { operation: CaveatMutatorOperation.deleteCaveat }; + return { operation: CaveatMutatorOperation.DeleteCaveat }; }, ); expect(controller.state).toStrictEqual( getMultiCaveatStateMatcher({ - [MultiCaveatOrigins.a]: { + [MultiCaveatOrigins.A]: { [PermissionNames.wallet_getSecretArray]: getPermissionMatcher({ parentCapability: PermissionNames.wallet_getSecretArray, caveats: null, - invoker: MultiCaveatOrigins.a, + invoker: MultiCaveatOrigins.A, }), }, - [MultiCaveatOrigins.b]: { + [MultiCaveatOrigins.B]: { [PermissionNames.wallet_getSecretArray]: getPermissionMatcher({ parentCapability: PermissionNames.wallet_getSecretArray, caveats: [ { type: CaveatTypes.reverseArrayResponse, value: null }, ], - invoker: MultiCaveatOrigins.b, + invoker: MultiCaveatOrigins.B, }), }, }), @@ -2150,16 +2307,16 @@ describe('PermissionController', () => { controller.updatePermissionsByCaveat( CaveatTypes.filterObjectResponse, () => { - return { operation: CaveatMutatorOperation.revokePermission }; + return { operation: CaveatMutatorOperation.RevokePermission }; }, ); const matcher = getMultiCaveatStateMatcher(); - delete matcher.subjects[MultiCaveatOrigins.b].permissions[ + delete matcher.subjects[MultiCaveatOrigins.B].permissions[ PermissionNames.wallet_getSecretObject ]; - delete matcher.subjects[MultiCaveatOrigins.c].permissions[ + delete matcher.subjects[MultiCaveatOrigins.C].permissions[ PermissionNames.wallet_getSecretObject ]; @@ -2170,13 +2327,13 @@ describe('PermissionController', () => { const controller = getMultiCaveatController(); let counter = 0; - const mutator: any = () => { + const mutator: CaveatMutator = () => { counter += 1; return { operation: counter === 1 - ? CaveatMutatorOperation.revokePermission - : CaveatMutatorOperation.noop, + ? CaveatMutatorOperation.RevokePermission + : CaveatMutatorOperation.Noop, }; }; @@ -2186,7 +2343,7 @@ describe('PermissionController', () => { ); const matcher = getMultiCaveatStateMatcher(); - delete (matcher.subjects as any)[MultiCaveatOrigins.a]; + delete matcher.subjects[MultiCaveatOrigins.A]; expect(controller.state).toStrictEqual(matcher); }); @@ -2199,7 +2356,7 @@ describe('PermissionController', () => { CaveatTypes.filterArrayResponse, () => { return { - operation: CaveatMutatorOperation.updateValue, + operation: CaveatMutatorOperation.UpdateValue, value: 'foo', }; }, @@ -2212,7 +2369,7 @@ describe('PermissionController', () => { expect(() => controller.updatePermissionsByCaveat(CaveatTypes.noopCaveat, () => { - return { operation: CaveatMutatorOperation.deleteCaveat }; + return { operation: CaveatMutatorOperation.DeleteCaveat }; }), ).toThrow('noopWithRequiredCaveat permission validation failed'); }); @@ -2223,8 +2380,9 @@ describe('PermissionController', () => { expect(() => controller.updatePermissionsByCaveat( CaveatTypes.filterArrayResponse, + // @ts-expect-error Intentional destructive testing () => { - return { operation: 'foobar' } as any; + return { operation: 'foobar' }; }, ), ).toThrow(`Unrecognized mutation result: "foobar"`); @@ -2494,7 +2652,8 @@ describe('PermissionController', () => { expect(() => controller.grantPermissions({ - subject: { origin: 2 as any }, + // @ts-expect-error Intentional destructive testing + subject: { origin: 2 }, approvedPermissions: { wallet_getSecretArray: {}, }, @@ -2594,7 +2753,8 @@ describe('PermissionController', () => { subject: { origin }, approvedPermissions: { wallet_getSecretArray: { - caveats: [[]] as any, + // @ts-expect-error Intentional destructive testing + caveats: [[]], }, }, }), @@ -2611,7 +2771,8 @@ describe('PermissionController', () => { subject: { origin }, approvedPermissions: { wallet_getSecretArray: { - caveats: ['foo'] as any, + // @ts-expect-error Intentional destructive testing + caveats: ['foo'], }, }, }), @@ -2636,9 +2797,10 @@ describe('PermissionController', () => { caveats: [ { ...{ type: CaveatTypes.filterArrayResponse, value: ['foo'] }, + // @ts-expect-error Intentional destructive testing bar: 'bar', }, - ] as any, + ], }, }, }), @@ -2665,10 +2827,11 @@ describe('PermissionController', () => { wallet_getSecretArray: { caveats: [ { + // @ts-expect-error Intentional destructive testing type: 2, value: ['foo'], }, - ] as any, + ], }, }, }), @@ -2718,9 +2881,10 @@ describe('PermissionController', () => { caveats: [ { type: CaveatTypes.filterArrayResponse, + // @ts-expect-error Intentional destructive testing foo: 'bar', }, - ] as any, + ], }, }, }), @@ -2736,41 +2900,45 @@ describe('PermissionController', () => { ); }); - it('throws if a requested caveat has a value that is not valid JSON', () => { + it('throws if a requested caveat has a value with a self-referential cycle', () => { const controller = getDefaultPermissionController(); const origin = 'metamask.io'; - const circular: any = { foo: 'bar' }; + const circular: Record = { foo: 'bar' }; + // Create a cycle. This will cause our JSON validity check to error. circular.circular = circular; - [{ foo: () => undefined }, circular, { foo: BigInt(10) }].forEach( - (invalidValue) => { - expect(() => - controller.grantPermissions({ - subject: { origin }, - approvedPermissions: { - wallet_getSecretArray: { - caveats: [ - { - type: CaveatTypes.filterArrayResponse, - value: invalidValue, - }, - ], - }, - }, - }), - ).toThrow( - new errors.CaveatInvalidJsonError( - { - type: CaveatTypes.filterArrayResponse, - value: invalidValue, + [ + { foo: (): undefined => undefined }, + circular, + { foo: BigInt(10) }, + ].forEach((invalidValue) => { + expect(() => + controller.grantPermissions({ + subject: { origin }, + approvedPermissions: { + wallet_getSecretArray: { + caveats: [ + { + type: CaveatTypes.filterArrayResponse, + // @ts-expect-error Intentional destructive testing + value: invalidValue, + }, + ], }, - origin, - PermissionNames.wallet_getSecretArray, - ), - ); - }, - ); + }, + }), + ).toThrow( + new errors.CaveatInvalidJsonError( + { + type: CaveatTypes.filterArrayResponse, + value: invalidValue, + }, + origin, + PermissionNames.wallet_getSecretArray, + ), + ); + }); }); it('throws if caveat validation fails', () => { @@ -2867,594 +3035,803 @@ describe('PermissionController', () => { }); }); - describe('requestPermissions', () => { - it('requests a permission', async () => { - const options = getPermissionControllerOptions(); - const { messenger } = options; + // See requestPermissionsIncremental for further tests + describe('grantPermissionsIncremental', () => { + it('incrementally grants a permission', () => { + const controller = getDefaultPermissionControllerWithState(); const origin = 'metamask.io'; - const callActionSpy = jest - .spyOn(messenger, 'call') - .mockImplementationOnce(async (...args: any) => { - const [, { requestData }] = args; - return { - metadata: { ...requestData.metadata }, - permissions: { ...requestData.permissions }, - }; - }); - - const controller = getDefaultPermissionController(options); - expect( - await controller.requestPermissions( - { origin }, - { - [PermissionNames.wallet_getSecretArray]: {}, + expect(controller.state).toStrictEqual({ + subjects: { + [origin]: { + origin, + permissions: { + wallet_getSecretArray: getPermissionMatcher({ + parentCapability: 'wallet_getSecretArray', + }), + }, }, - ), - ).toMatchObject([ - { - [PermissionNames.wallet_getSecretArray]: getPermissionMatcher({ - parentCapability: PermissionNames.wallet_getSecretArray, - caveats: null, - invoker: origin, - }), }, - { id: expect.any(String), origin }, - ]); + }); - expect(callActionSpy).toHaveBeenCalledTimes(1); - expect(callActionSpy).toHaveBeenCalledWith( - 'ApprovalController:addRequest', - { - id: expect.any(String), - origin, - requestData: { - metadata: { id: expect.any(String), origin }, - permissions: { [PermissionNames.wallet_getSecretArray]: {} }, + controller.grantPermissionsIncremental({ + subject: { origin }, + approvedPermissions: { + wallet_getSecretObject: {}, + }, + }); + + expect(controller.state).toStrictEqual({ + subjects: { + [origin]: { + origin, + permissions: expect.objectContaining({ + wallet_getSecretArray: getPermissionMatcher({ + parentCapability: 'wallet_getSecretArray', + }), + wallet_getSecretObject: getPermissionMatcher({ + parentCapability: 'wallet_getSecretObject', + }), + }), }, - type: MethodNames.requestPermissions, }, - true, - ); + }); }); - it('requests a permission that requires permitted side-effects', async () => { - const options = getPermissionControllerOptions(); - const { messenger } = options; + it('incrementally grants a caveat to an existing permission', () => { + const controller = getDefaultPermissionController(); const origin = 'metamask.io'; + const caveat1 = { type: CaveatTypes.filterArrayResponse, value: ['foo'] }; + const caveat2 = { + type: CaveatTypes.filterObjectResponse, + value: ['bar'], + }; - const callActionSpy = jest - .spyOn(messenger, 'call') - .mockImplementationOnce(async (...args: any) => { - const [, { requestData }] = args; - return { - metadata: { ...requestData.metadata }, - permissions: { ...requestData.permissions }, - }; - }); - - const controller = getDefaultPermissionController(options); - expect( - await controller.requestPermissions( - { origin }, - { - [PermissionNames.wallet_noopWithPermittedSideEffects]: {}, + controller.grantPermissions({ + subject: { origin }, + approvedPermissions: { + wallet_noopWithManyCaveats: { + caveats: [{ ...caveat1 }], }, - ), - ).toMatchObject([ - { - [PermissionNames.wallet_noopWithPermittedSideEffects]: - getPermissionMatcher({ - parentCapability: - PermissionNames.wallet_noopWithPermittedSideEffects, - caveats: null, - invoker: origin, - }), }, - { - data: { - [PermissionNames.wallet_noopWithPermittedSideEffects]: 'foo', + }); + + controller.grantPermissionsIncremental({ + subject: { origin }, + approvedPermissions: { + wallet_noopWithManyCaveats: { + caveats: [{ ...caveat2 }], }, - id: expect.any(String), - origin, }, - ]); - expect(onPermittedMock).toHaveBeenCalledTimes(1); - expect(callActionSpy).toHaveBeenCalledTimes(1); - expect(callActionSpy).toHaveBeenCalledWith( - 'ApprovalController:addRequest', - { - id: expect.any(String), - origin, - requestData: { - metadata: { id: expect.any(String), origin }, - permissions: { - [PermissionNames.wallet_noopWithPermittedSideEffects]: {}, - }, + }); + + expect(controller.state).toStrictEqual({ + subjects: { + [origin]: { + origin, + permissions: expect.objectContaining({ + wallet_noopWithManyCaveats: getPermissionMatcher({ + parentCapability: 'wallet_noopWithManyCaveats', + caveats: [{ ...caveat1 }, { ...caveat2 }], + }), + }), }, - type: MethodNames.requestPermissions, }, - true, - ); + }); }); - it('requests a permission that requires permitted and failure side-effects', async () => { - const options = getPermissionControllerOptions(); - const { messenger } = options; + it('incrementally updates a caveat of an existing permission', () => { + const controller = getDefaultPermissionController(); const origin = 'metamask.io'; + const getCaveat = (...values: string[]): Caveat => ({ + type: CaveatTypes.filterArrayResponse, + value: values, + }); - const callActionSpy = jest - .spyOn(messenger, 'call') - .mockImplementationOnce(async (...args: any) => { - const [, { requestData }] = args; - return { - metadata: { ...requestData.metadata }, - permissions: { ...requestData.permissions }, - }; - }); - - const controller = getDefaultPermissionController(options); - expect( - await controller.requestPermissions( - { origin }, - { - [PermissionNames.wallet_noopWithPermittedAndFailureSideEffects]: {}, + controller.grantPermissions({ + subject: { origin }, + approvedPermissions: { + wallet_noopWithManyCaveats: { + caveats: [getCaveat('foo')], }, - ), - ).toMatchObject([ - { - [PermissionNames.wallet_noopWithPermittedAndFailureSideEffects]: - getPermissionMatcher({ - parentCapability: - PermissionNames.wallet_noopWithPermittedAndFailureSideEffects, - caveats: null, - invoker: origin, - }), }, - { - data: { - [PermissionNames.wallet_noopWithPermittedAndFailureSideEffects]: - 'foo', + }); + + controller.grantPermissionsIncremental({ + subject: { origin }, + approvedPermissions: { + wallet_noopWithManyCaveats: { + caveats: [getCaveat('foo', 'bar')], }, - id: expect.any(String), - origin, }, - ]); + }); - expect(callActionSpy).toHaveBeenCalledTimes(1); - expect(onPermittedMock).toHaveBeenCalledTimes(1); - expect(onFailureMock).not.toHaveBeenCalled(); - expect(callActionSpy).toHaveBeenCalledWith( - 'ApprovalController:addRequest', - { - id: expect.any(String), - origin, - requestData: { - metadata: { id: expect.any(String), origin }, - permissions: { - [PermissionNames.wallet_noopWithPermittedAndFailureSideEffects]: - {}, - }, + expect(controller.state).toStrictEqual({ + subjects: { + [origin]: { + origin, + permissions: expect.objectContaining({ + wallet_noopWithManyCaveats: getPermissionMatcher({ + parentCapability: 'wallet_noopWithManyCaveats', + caveats: [getCaveat('foo', 'bar')], + }), + }), }, - type: MethodNames.requestPermissions, }, - true, - ); + }); }); + }); - it('can handle multiple side effects', async () => { - const options = getPermissionControllerOptions(); - const { messenger } = options; - const origin = 'metamask.io'; + describe('requesting permissions', () => { + // `expect.objectContaining` returns `any`. + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const getAnyPermissionsDiffMatcher = (): any => + expect.objectContaining({ + currentPermissions: expect.any(Object), + permissionDiffMap: expect.any(Object), + }); - const callActionSpy = jest - .spyOn(messenger, 'call') - .mockImplementationOnce(async (...args: any) => { - const [, { requestData }] = args; - return { - metadata: { ...requestData.metadata }, - permissions: { ...requestData.permissions }, - }; - }); + describe.each([ + 'requestPermissions', + 'requestPermissionsIncremental', + ] as const)('%s', (requestFunctionName) => { + const getRequestDataDiffProperty = (): { + // `expect.objectContaining` returns `any`. + // eslint-disable-next-line @typescript-eslint/no-explicit-any + diff?: any; + } => + requestFunctionName === 'requestPermissionsIncremental' + ? { diff: getAnyPermissionsDiffMatcher() } + : {}; + + it('requests a permission', async () => { + const options = getPermissionControllerOptions(); + const { messenger } = options; + const origin = 'metamask.io'; + + const callActionSpy = jest + .spyOn(messenger, 'call') + .mockImplementationOnce(async (...args) => { + const [, { requestData }] = args as AddPermissionRequestArgs; + return { + metadata: { ...requestData.metadata }, + permissions: { ...requestData.permissions }, + }; + }); - const controller = getDefaultPermissionController(options); - expect( - await controller.requestPermissions( - { origin }, + const controller = getDefaultPermissionController(options); + expect( + await controller[requestFunctionName]( + { origin }, + { + [PermissionNames.wallet_getSecretArray]: {}, + }, + ), + ).toMatchObject([ { - [PermissionNames.wallet_noopWithPermittedAndFailureSideEffects]: {}, - [PermissionKeys.wallet_noopWithPermittedAndFailureSideEffects2]: {}, - }, - ), - ).toMatchObject([ - { - [PermissionNames.wallet_noopWithPermittedAndFailureSideEffects]: - getPermissionMatcher({ - parentCapability: - PermissionNames.wallet_noopWithPermittedAndFailureSideEffects, + [PermissionNames.wallet_getSecretArray]: getPermissionMatcher({ + parentCapability: PermissionNames.wallet_getSecretArray, caveats: null, invoker: origin, }), - }, - { - data: { - [PermissionNames.wallet_noopWithPermittedAndFailureSideEffects]: - 'foo', - [PermissionNames.wallet_noopWithPermittedAndFailureSideEffects2]: - 'foo', }, - id: expect.any(String), - origin, - }, - ]); + { id: expect.any(String), origin }, + ]); - expect(onPermittedMock).toHaveBeenCalledTimes(2); - expect(onFailureMock).not.toHaveBeenCalled(); - expect(callActionSpy).toHaveBeenCalledTimes(1); - expect(callActionSpy).toHaveBeenCalledWith( - 'ApprovalController:addRequest', - { - id: expect.any(String), - origin, - requestData: { - metadata: { id: expect.any(String), origin }, - permissions: { - [PermissionNames.wallet_noopWithPermittedAndFailureSideEffects]: - {}, - [PermissionNames.wallet_noopWithPermittedAndFailureSideEffects2]: - {}, + expect(callActionSpy).toHaveBeenCalledTimes(1); + expect(callActionSpy).toHaveBeenCalledWith( + 'ApprovalController:addRequest', + { + id: expect.any(String), + origin, + requestData: { + metadata: { id: expect.any(String), origin }, + permissions: { [PermissionNames.wallet_getSecretArray]: {} }, + ...getRequestDataDiffProperty(), }, + type: MethodNames.RequestPermissions, }, - type: MethodNames.requestPermissions, - }, - true, - ); - }); - - it('can handle permitted multiple side-effect failure', async () => { - const options = getPermissionControllerOptions(); - const { messenger } = options; - const origin = 'metamask.io'; + true, + ); + }); - onPermittedMock.mockImplementation(async () => - Promise.reject(new Error('error')), - ); + it('allows caller passing additional metadata', async () => { + const options = getPermissionControllerOptions(); + const { messenger } = options; + const origin = 'metamask.io'; - const callActionSpy = jest - .spyOn(messenger, 'call') - .mockImplementationOnce(async (...args: any) => { - const [, { requestData }] = args; - return { - metadata: { ...requestData.metadata }, - permissions: { ...requestData.permissions }, - }; - }); + const callActionSpy = jest + .spyOn(messenger, 'call') + .mockImplementationOnce(async (...args) => { + const [, { requestData }] = args as AddPermissionRequestArgs; + return { + metadata: { ...requestData.metadata }, + permissions: { ...requestData.permissions }, + }; + }); - const controller = getDefaultPermissionController(options); - await expect(async () => - controller.requestPermissions( - { origin }, + const controller = getDefaultPermissionController(options); + expect( + await controller[requestFunctionName]( + { origin }, + { + [PermissionNames.wallet_getSecretArray]: {}, + }, + { metadata: { foo: 'bar' } }, + ), + ).toMatchObject([ { - [PermissionNames.wallet_noopWithPermittedAndFailureSideEffects]: {}, - [PermissionKeys.wallet_noopWithPermittedAndFailureSideEffects2]: {}, + [PermissionNames.wallet_getSecretArray]: getPermissionMatcher({ + parentCapability: PermissionNames.wallet_getSecretArray, + caveats: null, + invoker: origin, + }), }, - ), - ).rejects.toThrow( - 'Multiple errors occurred during side-effects execution', - ); + { id: expect.any(String), origin }, + ]); - expect(onPermittedMock).toHaveBeenCalledTimes(2); - expect(onFailureMock).toHaveBeenCalledTimes(2); - expect(callActionSpy).toHaveBeenCalledTimes(1); - expect(callActionSpy).toHaveBeenCalledWith( - 'ApprovalController:addRequest', - { - id: expect.any(String), - origin, - requestData: { - metadata: { id: expect.any(String), origin }, - permissions: { - [PermissionNames.wallet_noopWithPermittedAndFailureSideEffects]: - {}, - [PermissionNames.wallet_noopWithPermittedAndFailureSideEffects2]: - {}, + expect(callActionSpy).toHaveBeenCalledTimes(1); + expect(callActionSpy).toHaveBeenCalledWith( + 'ApprovalController:addRequest', + { + id: expect.any(String), + origin, + requestData: { + metadata: { foo: 'bar', id: expect.any(String), origin }, + permissions: { [PermissionNames.wallet_getSecretArray]: {} }, + ...getRequestDataDiffProperty(), }, + type: MethodNames.RequestPermissions, }, - type: MethodNames.requestPermissions, - }, - true, - ); - }); + true, + ); + }); - it('can handle permitted side-effect rejection', async () => { - const options = getPermissionControllerOptions(); - const { messenger } = options; - const origin = 'metamask.io'; + it('requests a permission that requires permitted side-effects', async () => { + const [permissionSpecifications, sideEffectMocks] = + getDefaultPermissionSpecificationsAndMocks(); + const options = getPermissionControllerOptions({ + permissionSpecifications, + }); + const { messenger } = options; + const origin = 'metamask.io'; - onPermittedMock.mockImplementation(async () => - Promise.reject(new Error('error')), - ); + const callActionSpy = jest + .spyOn(messenger, 'call') + .mockImplementationOnce(async (...args) => { + const [, { requestData }] = args as AddPermissionRequestArgs; + return { + metadata: { ...requestData.metadata }, + permissions: { ...requestData.permissions }, + }; + }); - const callActionSpy = jest - .spyOn(messenger, 'call') - .mockImplementationOnce(async (...args: any) => { - const [, { requestData }] = args; - return { - metadata: { ...requestData.metadata }, - permissions: { ...requestData.permissions }, - }; - }); + const controller = getDefaultPermissionController(options); - const controller = getDefaultPermissionController(options); - await expect(async () => - controller.requestPermissions( - { origin }, + expect( + await controller[requestFunctionName]( + { origin }, + { + [PermissionNames.wallet_noopWithPermittedSideEffects]: {}, + }, + ), + ).toMatchObject([ { - [PermissionNames.wallet_noopWithPermittedSideEffects]: {}, + [PermissionNames.wallet_noopWithPermittedSideEffects]: + getPermissionMatcher({ + parentCapability: + PermissionNames.wallet_noopWithPermittedSideEffects, + caveats: null, + invoker: origin, + }), }, - ), - ).rejects.toThrow('error'); - - expect(onPermittedMock).toHaveBeenCalledTimes(1); - expect(callActionSpy).toHaveBeenCalledTimes(1); - expect(callActionSpy).toHaveBeenCalledWith( - 'ApprovalController:addRequest', - { - id: expect.any(String), - origin, - requestData: { - metadata: { id: expect.any(String), origin }, - permissions: { - [PermissionNames.wallet_noopWithPermittedSideEffects]: {}, + { + data: { + [PermissionNames.wallet_noopWithPermittedSideEffects]: 'foo', }, + id: expect.any(String), + origin, }, - type: MethodNames.requestPermissions, - }, - true, - ); - }); - - it('can handle failure side-effect rejection', async () => { - const options = getPermissionControllerOptions(); - const { messenger } = options; - const origin = 'metamask.io'; - - onPermittedMock.mockImplementation(async () => - Promise.reject(new Error('error')), - ); - - onFailureMock.mockImplementation(async () => - Promise.reject(new Error('error')), - ); + ]); - const callActionSpy = jest - .spyOn(messenger, 'call') - .mockImplementationOnce(async (...args: any) => { - const [, { requestData }] = args; - return { - metadata: { ...requestData.metadata }, - permissions: { ...requestData.permissions }, - }; - }); + expect( + sideEffectMocks[PermissionNames.wallet_noopWithPermittedSideEffects] + .onPermitted, + ).toHaveBeenCalledTimes(1); - const controller = getDefaultPermissionController(options); - await expect(async () => - controller.requestPermissions( - { origin }, + expect(callActionSpy).toHaveBeenCalledTimes(1); + expect(callActionSpy).toHaveBeenCalledWith( + 'ApprovalController:addRequest', { - [PermissionNames.wallet_noopWithPermittedAndFailureSideEffects]: {}, + id: expect.any(String), + origin, + requestData: { + metadata: { id: expect.any(String), origin }, + permissions: { + [PermissionNames.wallet_noopWithPermittedSideEffects]: {}, + }, + ...getRequestDataDiffProperty(), + }, + type: MethodNames.RequestPermissions, }, - ), - ).rejects.toThrow('Unexpected error in side-effects'); + true, + ); + }); - expect(onPermittedMock).toHaveBeenCalledTimes(1); - expect(callActionSpy).toHaveBeenCalledTimes(1); - expect(callActionSpy).toHaveBeenCalledWith( - 'ApprovalController:addRequest', - { - id: expect.any(String), - origin, - requestData: { - metadata: { id: expect.any(String), origin }, - permissions: { + it('requests a permission that requires permitted and failure side-effects', async () => { + const [permissionSpecifications, sideEffectMocks] = + getDefaultPermissionSpecificationsAndMocks(); + const options = getPermissionControllerOptions({ + permissionSpecifications, + }); + const { messenger } = options; + const origin = 'metamask.io'; + + const callActionSpy = jest + .spyOn(messenger, 'call') + .mockImplementationOnce(async (...args) => { + const [, { requestData }] = args as AddPermissionRequestArgs; + return { + metadata: { ...requestData.metadata }, + permissions: { ...requestData.permissions }, + }; + }); + + const controller = getDefaultPermissionController(options); + expect( + await controller[requestFunctionName]( + { origin }, + { [PermissionNames.wallet_noopWithPermittedAndFailureSideEffects]: {}, }, + ), + ).toMatchObject([ + { + [PermissionNames.wallet_noopWithPermittedAndFailureSideEffects]: + getPermissionMatcher({ + parentCapability: + PermissionNames.wallet_noopWithPermittedAndFailureSideEffects, + caveats: null, + invoker: origin, + }), + }, + { + data: { + [PermissionNames.wallet_noopWithPermittedAndFailureSideEffects]: + 'foo', + }, + id: expect.any(String), + origin, }, - type: MethodNames.requestPermissions, - }, - true, - ); - }); + ]); - it('requests a permission that requires requestData in its factory', async () => { - const options = getPermissionControllerOptions(); - const { messenger } = options; - const origin = 'metamask.io'; + expect( + sideEffectMocks[ + PermissionNames.wallet_noopWithPermittedAndFailureSideEffects + ].onPermitted, + ).toHaveBeenCalledTimes(1); - const callActionSpy = jest - .spyOn(messenger, 'call') - .mockImplementationOnce(async (...args: any) => { - const [, { requestData }] = args; - return { - metadata: { ...requestData.metadata }, - permissions: { ...requestData.permissions }, - caveatValue: ['foo'], // this will be added to the permission - }; - }); + expect( + sideEffectMocks[ + PermissionNames.wallet_noopWithPermittedAndFailureSideEffects + ].onFailure, + ).not.toHaveBeenCalled(); - const controller = getDefaultPermissionController(options); - expect( - await controller.requestPermissions( - { origin }, + expect(callActionSpy).toHaveBeenCalledTimes(1); + expect(callActionSpy).toHaveBeenCalledWith( + 'ApprovalController:addRequest', { - [PermissionNames.wallet_noopWithFactory]: {}, + id: expect.any(String), + origin, + requestData: { + metadata: { id: expect.any(String), origin }, + permissions: { + [PermissionNames.wallet_noopWithPermittedAndFailureSideEffects]: + {}, + }, + ...getRequestDataDiffProperty(), + }, + type: MethodNames.RequestPermissions, }, - ), - ).toMatchObject([ - { - [PermissionNames.wallet_noopWithFactory]: getPermissionMatcher({ - parentCapability: PermissionNames.wallet_noopWithFactory, - caveats: [ - { type: CaveatTypes.filterArrayResponse, value: ['foo'] }, - ], - invoker: origin, - }), - }, - { id: expect.any(String), origin }, - ]); + true, + ); + }); - expect(callActionSpy).toHaveBeenCalledTimes(1); - expect(callActionSpy).toHaveBeenCalledWith( - 'ApprovalController:addRequest', - { - id: expect.any(String), - origin, - requestData: { - metadata: { id: expect.any(String), origin }, - permissions: { - [PermissionNames.wallet_noopWithFactory]: {}, + it('can handle multiple side-effects', async () => { + const [permissionSpecifications, sideEffectMocks] = + getDefaultPermissionSpecificationsAndMocks(); + const options = getPermissionControllerOptions({ + permissionSpecifications, + }); + const { messenger } = options; + const origin = 'metamask.io'; + + const callActionSpy = jest + .spyOn(messenger, 'call') + .mockImplementationOnce(async (...args) => { + const [, { requestData }] = args as AddPermissionRequestArgs; + return { + metadata: { ...requestData.metadata }, + permissions: { ...requestData.permissions }, + }; + }); + + const controller = getDefaultPermissionController(options); + expect( + await controller[requestFunctionName]( + { origin }, + { + [PermissionNames.wallet_noopWithPermittedAndFailureSideEffects]: + {}, + [PermissionKeys.wallet_noopWithPermittedAndFailureSideEffects2]: + {}, }, + ), + ).toMatchObject([ + { + [PermissionNames.wallet_noopWithPermittedAndFailureSideEffects]: + getPermissionMatcher({ + parentCapability: + PermissionNames.wallet_noopWithPermittedAndFailureSideEffects, + caveats: null, + invoker: origin, + }), }, - type: MethodNames.requestPermissions, - }, - true, - ); - }); + { + data: { + [PermissionNames.wallet_noopWithPermittedAndFailureSideEffects]: + 'foo', + [PermissionNames.wallet_noopWithPermittedAndFailureSideEffects2]: + 'foo', + }, + id: expect.any(String), + origin, + }, + ]); - it('requests multiple permissions', async () => { - const options = getPermissionControllerOptions(); - const { messenger } = options; - const origin = 'metamask.io'; + expect( + sideEffectMocks[ + PermissionNames.wallet_noopWithPermittedAndFailureSideEffects + ].onPermitted, + ).toHaveBeenCalledTimes(1); + expect( + sideEffectMocks[ + PermissionNames.wallet_noopWithPermittedAndFailureSideEffects2 + ].onPermitted, + ).toHaveBeenCalledTimes(1); - const callActionSpy = jest - .spyOn(messenger, 'call') - .mockImplementationOnce(async (...args: any) => { - const [, { requestData }] = args; - return { - metadata: { ...requestData.metadata }, - permissions: { ...requestData.permissions }, - }; - }); + expect( + sideEffectMocks[ + PermissionNames.wallet_noopWithPermittedAndFailureSideEffects + ].onFailure, + ).not.toHaveBeenCalled(); + expect( + sideEffectMocks[ + PermissionNames.wallet_noopWithPermittedAndFailureSideEffects2 + ].onFailure, + ).not.toHaveBeenCalled(); - const controller = getDefaultPermissionController(options); - expect( - await controller.requestPermissions( - { origin }, + expect(callActionSpy).toHaveBeenCalledTimes(1); + expect(callActionSpy).toHaveBeenCalledWith( + 'ApprovalController:addRequest', { - [PermissionNames.wallet_getSecretArray]: {}, - [PermissionNames.wallet_getSecretObject]: { - caveats: [ - { type: CaveatTypes.filterObjectResponse, value: ['baz'] }, - ], + id: expect.any(String), + origin, + requestData: { + metadata: { id: expect.any(String), origin }, + permissions: { + [PermissionNames.wallet_noopWithPermittedAndFailureSideEffects]: + {}, + [PermissionNames.wallet_noopWithPermittedAndFailureSideEffects2]: + {}, + }, + ...getRequestDataDiffProperty(), }, + type: MethodNames.RequestPermissions, }, - ), - ).toMatchObject([ - { - [PermissionNames.wallet_getSecretArray]: getPermissionMatcher({ - parentCapability: PermissionNames.wallet_getSecretArray, - caveats: null, - invoker: origin, - }), - [PermissionNames.wallet_getSecretObject]: getPermissionMatcher({ - parentCapability: PermissionNames.wallet_getSecretObject, - caveats: [ - { type: CaveatTypes.filterObjectResponse, value: ['baz'] }, - ], - invoker: origin, - }), - }, - { id: expect.any(String), origin }, - ]); + true, + ); + }); - expect(callActionSpy).toHaveBeenCalledTimes(1); - expect(callActionSpy).toHaveBeenCalledWith( - 'ApprovalController:addRequest', - { - id: expect.any(String), - origin, - requestData: { - metadata: { id: expect.any(String), origin }, - permissions: { - [PermissionNames.wallet_getSecretArray]: {}, - [PermissionNames.wallet_getSecretObject]: { - caveats: [ - { type: CaveatTypes.filterObjectResponse, value: ['baz'] }, - ], + it('can handle multiple permitted side-effect failures', async () => { + const [permissionSpecifications, sideEffectMocks] = + getDefaultPermissionSpecificationsAndMocks(); + const options = getPermissionControllerOptions({ + permissionSpecifications, + }); + const { messenger } = options; + const origin = 'metamask.io'; + + sideEffectMocks[ + PermissionNames.wallet_noopWithPermittedAndFailureSideEffects + ].onPermitted.mockImplementation(() => + Promise.reject(new Error('error')), + ); + sideEffectMocks[ + PermissionNames.wallet_noopWithPermittedAndFailureSideEffects2 + ].onPermitted.mockImplementation(() => + Promise.reject(new Error('error')), + ); + + const callActionSpy = jest + .spyOn(messenger, 'call') + .mockImplementationOnce(async (...args) => { + const [, { requestData }] = args as AddPermissionRequestArgs; + return { + metadata: { ...requestData.metadata }, + permissions: { ...requestData.permissions }, + }; + }); + + const controller = getDefaultPermissionController(options); + await expect(async () => + controller[requestFunctionName]( + { origin }, + { + [PermissionNames.wallet_noopWithPermittedAndFailureSideEffects]: + {}, + [PermissionKeys.wallet_noopWithPermittedAndFailureSideEffects2]: + {}, + }, + ), + ).rejects.toThrow( + 'Multiple errors occurred during side-effects execution', + ); + + expect( + sideEffectMocks[ + PermissionNames.wallet_noopWithPermittedAndFailureSideEffects + ].onPermitted, + ).toHaveBeenCalledTimes(1); + expect( + sideEffectMocks[ + PermissionNames.wallet_noopWithPermittedAndFailureSideEffects2 + ].onPermitted, + ).toHaveBeenCalledTimes(1); + + expect( + sideEffectMocks[ + PermissionNames.wallet_noopWithPermittedAndFailureSideEffects + ].onFailure, + ).toHaveBeenCalledTimes(1); + expect( + sideEffectMocks[ + PermissionNames.wallet_noopWithPermittedAndFailureSideEffects2 + ].onFailure, + ).toHaveBeenCalledTimes(1); + + expect(callActionSpy).toHaveBeenCalledTimes(1); + expect(callActionSpy).toHaveBeenCalledWith( + 'ApprovalController:addRequest', + { + id: expect.any(String), + origin, + requestData: { + metadata: { id: expect.any(String), origin }, + permissions: { + [PermissionNames.wallet_noopWithPermittedAndFailureSideEffects]: + {}, + [PermissionNames.wallet_noopWithPermittedAndFailureSideEffects2]: + {}, }, + ...getRequestDataDiffProperty(), }, + type: MethodNames.RequestPermissions, }, - type: MethodNames.requestPermissions, - }, - true, - ); - }); + true, + ); + }); - it('requests multiple permissions (approved permissions are a strict superset)', async () => { - const options = getPermissionControllerOptions(); - const { messenger } = options; - const origin = 'metamask.io'; + it('can handle permitted side-effect rejection (no failure handler)', async () => { + const [permissionSpecifications, sideEffectMocks] = + getDefaultPermissionSpecificationsAndMocks(); + const options = getPermissionControllerOptions({ + permissionSpecifications, + }); + const { messenger } = options; + const origin = 'metamask.io'; - const callActionSpy = jest - .spyOn(messenger, 'call') - .mockImplementationOnce(async (...args: any) => { - const [, { requestData }] = args; - return { - metadata: { ...requestData.metadata }, - // endowmentAnySubject is added to the request - permissions: { - ...requestData.permissions, - [PermissionNames.endowmentAnySubject]: {}, + sideEffectMocks[ + PermissionNames.wallet_noopWithPermittedSideEffects + ].onPermitted.mockImplementation(() => + Promise.reject(new Error('error')), + ); + + const callActionSpy = jest + .spyOn(messenger, 'call') + .mockImplementationOnce(async (...args) => { + const [, { requestData }] = args as AddPermissionRequestArgs; + return { + metadata: { ...requestData.metadata }, + permissions: { ...requestData.permissions }, + }; + }); + + const controller = getDefaultPermissionController(options); + await expect(async () => + controller[requestFunctionName]( + { origin }, + { + [PermissionNames.wallet_noopWithPermittedSideEffects]: {}, }, - }; + ), + ).rejects.toThrow('error'); + + expect( + sideEffectMocks[PermissionNames.wallet_noopWithPermittedSideEffects] + .onPermitted, + ).toHaveBeenCalledTimes(1); + + expect(callActionSpy).toHaveBeenCalledTimes(1); + expect(callActionSpy).toHaveBeenCalledWith( + 'ApprovalController:addRequest', + { + id: expect.any(String), + origin, + requestData: { + metadata: { id: expect.any(String), origin }, + permissions: { + [PermissionNames.wallet_noopWithPermittedSideEffects]: {}, + }, + ...getRequestDataDiffProperty(), + }, + type: MethodNames.RequestPermissions, + }, + true, + ); + }); + + it('can handle failure side-effect rejection', async () => { + const [permissionSpecifications, sideEffectMocks] = + getDefaultPermissionSpecificationsAndMocks(); + const options = getPermissionControllerOptions({ + permissionSpecifications, }); + const { messenger } = options; + const origin = 'metamask.io'; - const controller = getDefaultPermissionController(options); - expect( - await controller.requestPermissions( - { origin }, + sideEffectMocks[ + PermissionNames.wallet_noopWithPermittedAndFailureSideEffects + ].onPermitted.mockImplementation(() => + Promise.reject(new Error('error')), + ); + + sideEffectMocks[ + PermissionNames.wallet_noopWithPermittedAndFailureSideEffects + ]?.onFailure?.mockImplementation(() => + Promise.reject(new Error('error')), + ); + + const callActionSpy = jest + .spyOn(messenger, 'call') + .mockImplementationOnce(async (...args) => { + const [, { requestData }] = args as AddPermissionRequestArgs; + return { + metadata: { ...requestData.metadata }, + permissions: { ...requestData.permissions }, + }; + }); + + const controller = getDefaultPermissionController(options); + await expect(async () => + controller[requestFunctionName]( + { origin }, + { + [PermissionNames.wallet_noopWithPermittedAndFailureSideEffects]: + {}, + }, + ), + ).rejects.toThrow('Unexpected error in side-effects'); + + expect( + sideEffectMocks[ + PermissionNames.wallet_noopWithPermittedAndFailureSideEffects + ].onPermitted, + ).toHaveBeenCalledTimes(1); + + expect( + sideEffectMocks[ + PermissionNames.wallet_noopWithPermittedAndFailureSideEffects + ].onFailure, + ).toHaveBeenCalledTimes(1); + + expect(callActionSpy).toHaveBeenCalledTimes(1); + expect(callActionSpy).toHaveBeenCalledWith( + 'ApprovalController:addRequest', { - [PermissionNames.wallet_getSecretArray]: {}, - [PermissionNames.wallet_getSecretObject]: { + id: expect.any(String), + origin, + requestData: { + metadata: { id: expect.any(String), origin }, + permissions: { + [PermissionNames.wallet_noopWithPermittedAndFailureSideEffects]: + {}, + }, + ...getRequestDataDiffProperty(), + }, + type: MethodNames.RequestPermissions, + }, + true, + ); + }); + + it('requests a permission that requires requestData in its factory', async () => { + const options = getPermissionControllerOptions(); + const { messenger } = options; + const origin = 'metamask.io'; + + const callActionSpy = jest + .spyOn(messenger, 'call') + .mockImplementationOnce(async (...args) => { + const [, { requestData }] = args as AddPermissionRequestArgs; + return { + metadata: { ...requestData.metadata }, + permissions: { ...requestData.permissions }, + caveatValue: ['foo'], // this will be added to the permission + }; + }); + + const controller = getDefaultPermissionController(options); + expect( + await controller[requestFunctionName]( + { origin }, + { + [PermissionNames.wallet_noopWithFactory]: {}, + }, + ), + ).toMatchObject([ + { + [PermissionNames.wallet_noopWithFactory]: getPermissionMatcher({ + parentCapability: PermissionNames.wallet_noopWithFactory, caveats: [ - { type: CaveatTypes.filterObjectResponse, value: ['baz'] }, + { type: CaveatTypes.filterArrayResponse, value: ['foo'] }, ], + invoker: origin, + }), + }, + { id: expect.any(String), origin }, + ]); + + expect(callActionSpy).toHaveBeenCalledTimes(1); + expect(callActionSpy).toHaveBeenCalledWith( + 'ApprovalController:addRequest', + { + id: expect.any(String), + origin, + requestData: { + metadata: { id: expect.any(String), origin }, + permissions: { + [PermissionNames.wallet_noopWithFactory]: {}, + }, + ...getRequestDataDiffProperty(), }, + type: MethodNames.RequestPermissions, }, - ), - ).toMatchObject([ - { - [PermissionNames.wallet_getSecretArray]: getPermissionMatcher({ - parentCapability: PermissionNames.wallet_getSecretArray, - caveats: null, - invoker: origin, - }), - [PermissionNames.wallet_getSecretObject]: getPermissionMatcher({ - parentCapability: PermissionNames.wallet_getSecretObject, - caveats: [ - { type: CaveatTypes.filterObjectResponse, value: ['baz'] }, - ], - invoker: origin, - }), - [PermissionNames.endowmentAnySubject]: getPermissionMatcher({ - parentCapability: PermissionNames.endowmentAnySubject, - caveats: null, - invoker: origin, - }), - }, - { id: expect.any(String), origin }, - ]); + true, + ); + }); - expect(callActionSpy).toHaveBeenCalledTimes(1); - expect(callActionSpy).toHaveBeenCalledWith( - 'ApprovalController:addRequest', - { - id: expect.any(String), - origin, - requestData: { - metadata: { id: expect.any(String), origin }, - permissions: { + it('requests multiple permissions', async () => { + const options = getPermissionControllerOptions(); + const { messenger } = options; + const origin = 'metamask.io'; + + const callActionSpy = jest + .spyOn(messenger, 'call') + .mockImplementationOnce(async (...args) => { + const [, { requestData }] = args as AddPermissionRequestArgs; + return { + metadata: { ...requestData.metadata }, + permissions: { ...requestData.permissions }, + }; + }); + + const controller = getDefaultPermissionController(options); + expect( + await controller[requestFunctionName]( + { origin }, + { [PermissionNames.wallet_getSecretArray]: {}, [PermissionNames.wallet_getSecretObject]: { caveats: [ @@ -3462,72 +3839,151 @@ describe('PermissionController', () => { ], }, }, + ), + ).toMatchObject([ + { + [PermissionNames.wallet_getSecretArray]: getPermissionMatcher({ + parentCapability: PermissionNames.wallet_getSecretArray, + caveats: null, + invoker: origin, + }), + [PermissionNames.wallet_getSecretObject]: getPermissionMatcher({ + parentCapability: PermissionNames.wallet_getSecretObject, + caveats: [ + { type: CaveatTypes.filterObjectResponse, value: ['baz'] }, + ], + invoker: origin, + }), }, - type: MethodNames.requestPermissions, - }, - true, - ); - }); + { id: expect.any(String), origin }, + ]); - it('requests multiple permissions (approved permissions are a strict subset)', async () => { - const options = getPermissionControllerOptions(); - const { messenger } = options; - const origin = 'metamask.io'; + expect(callActionSpy).toHaveBeenCalledTimes(1); + expect(callActionSpy).toHaveBeenCalledWith( + 'ApprovalController:addRequest', + { + id: expect.any(String), + origin, + requestData: { + metadata: { id: expect.any(String), origin }, + permissions: { + [PermissionNames.wallet_getSecretArray]: {}, + [PermissionNames.wallet_getSecretObject]: { + caveats: [ + { type: CaveatTypes.filterObjectResponse, value: ['baz'] }, + ], + }, + }, + ...getRequestDataDiffProperty(), + }, + type: MethodNames.RequestPermissions, + }, + true, + ); + }); - const callActionSpy = jest - .spyOn(messenger, 'call') - .mockImplementationOnce(async (...args: any) => { - const [, { requestData }] = args; - const approvedPermissions = { ...requestData.permissions }; - delete approvedPermissions[PermissionNames.wallet_getSecretArray]; + it('requests multiple permissions (approved permissions are a strict superset)', async () => { + const options = getPermissionControllerOptions(); + const { messenger } = options; + const origin = 'metamask.io'; - return { - metadata: { ...requestData.metadata }, - permissions: approvedPermissions, - }; - }); + const callActionSpy = jest + .spyOn(messenger, 'call') + .mockImplementationOnce(async (...args) => { + const [, { requestData }] = args as AddPermissionRequestArgs; + return { + metadata: { ...requestData.metadata }, + // endowmentAnySubject is added to the request + permissions: { + ...requestData.permissions, + [PermissionNames.endowmentAnySubject]: {}, + }, + }; + }); - const controller = getDefaultPermissionController(options); - expect( - await controller.requestPermissions( - { origin }, + const controller = getDefaultPermissionController(options); + expect( + await controller[requestFunctionName]( + { origin }, + { + [PermissionNames.wallet_getSecretArray]: {}, + [PermissionNames.wallet_getSecretObject]: { + caveats: [ + { type: CaveatTypes.filterObjectResponse, value: ['baz'] }, + ], + }, + }, + ), + ).toMatchObject([ { - [PermissionNames.wallet_getSecretArray]: {}, - [PermissionNames.wallet_getSecretObject]: { + [PermissionNames.wallet_getSecretArray]: getPermissionMatcher({ + parentCapability: PermissionNames.wallet_getSecretArray, + caveats: null, + invoker: origin, + }), + [PermissionNames.wallet_getSecretObject]: getPermissionMatcher({ + parentCapability: PermissionNames.wallet_getSecretObject, caveats: [ { type: CaveatTypes.filterObjectResponse, value: ['baz'] }, ], + invoker: origin, + }), + [PermissionNames.endowmentAnySubject]: getPermissionMatcher({ + parentCapability: PermissionNames.endowmentAnySubject, + caveats: null, + invoker: origin, + }), + }, + { id: expect.any(String), origin }, + ]); + + expect(callActionSpy).toHaveBeenCalledTimes(1); + expect(callActionSpy).toHaveBeenCalledWith( + 'ApprovalController:addRequest', + { + id: expect.any(String), + origin, + requestData: { + metadata: { id: expect.any(String), origin }, + permissions: { + [PermissionNames.wallet_getSecretArray]: {}, + [PermissionNames.wallet_getSecretObject]: { + caveats: [ + { type: CaveatTypes.filterObjectResponse, value: ['baz'] }, + ], + }, + }, + ...getRequestDataDiffProperty(), }, - [PermissionNames.endowmentAnySubject]: {}, + type: MethodNames.RequestPermissions, }, - ), - ).toMatchObject([ - { - [PermissionNames.wallet_getSecretObject]: getPermissionMatcher({ - parentCapability: PermissionNames.wallet_getSecretObject, - caveats: [ - { type: CaveatTypes.filterObjectResponse, value: ['baz'] }, - ], - invoker: origin, - }), - [PermissionNames.endowmentAnySubject]: getPermissionMatcher({ - parentCapability: PermissionNames.endowmentAnySubject, - caveats: null, - invoker: origin, - }), - }, - { id: expect.any(String), origin }, - ]); + true, + ); + }); - expect(callActionSpy).toHaveBeenCalledTimes(1); - expect(callActionSpy).toHaveBeenCalledWith( - 'ApprovalController:addRequest', - { - id: expect.any(String), - origin, - requestData: { - metadata: { id: expect.any(String), origin }, - permissions: { + it('requests multiple permissions (approved permissions are a strict subset)', async () => { + const options = getPermissionControllerOptions(); + const { messenger } = options; + const origin = 'metamask.io'; + + const callActionSpy = jest + .spyOn(messenger, 'call') + .mockImplementationOnce(async (...args) => { + const [, { requestData }] = args as AddPermissionRequestArgs; + const approvedPermissions = { ...requestData.permissions }; + delete approvedPermissions[PermissionNames.wallet_getSecretArray]; + + return { + metadata: { ...requestData.metadata }, + permissions: approvedPermissions, + }; + }); + + const controller = getDefaultPermissionController(options); + expect( + await controller[requestFunctionName]( + { origin }, + { [PermissionNames.wallet_getSecretArray]: {}, [PermissionNames.wallet_getSecretObject]: { caveats: [ @@ -3536,81 +3992,77 @@ describe('PermissionController', () => { }, [PermissionNames.endowmentAnySubject]: {}, }, - }, - type: MethodNames.requestPermissions, - }, - true, - ); - }); - - it('requests multiple permissions (an approved permission is modified)', async () => { - const options = getPermissionControllerOptions(); - const { messenger } = options; - const origin = 'metamask.io'; - - const callActionSpy = jest - .spyOn(messenger, 'call') - .mockImplementationOnce(async (...args: any) => { - const [, { requestData }] = args; - const approvedPermissions = { ...requestData.permissions }; - approvedPermissions[PermissionNames.wallet_getSecretObject] = { - caveats: [ - { type: CaveatTypes.filterObjectResponse, value: ['kaplar'] }, - ], - }; - - return { - metadata: { ...requestData.metadata }, - permissions: approvedPermissions, - }; - }); - - const controller = getDefaultPermissionController(options); - expect( - await controller.requestPermissions( - { origin }, + ), + ).toMatchObject([ { - [PermissionNames.wallet_getSecretArray]: {}, - [PermissionNames.wallet_getSecretObject]: { + [PermissionNames.wallet_getSecretObject]: getPermissionMatcher({ + parentCapability: PermissionNames.wallet_getSecretObject, caveats: [ { type: CaveatTypes.filterObjectResponse, value: ['baz'] }, ], + invoker: origin, + }), + [PermissionNames.endowmentAnySubject]: getPermissionMatcher({ + parentCapability: PermissionNames.endowmentAnySubject, + caveats: null, + invoker: origin, + }), + }, + { id: expect.any(String), origin }, + ]); + + expect(callActionSpy).toHaveBeenCalledTimes(1); + expect(callActionSpy).toHaveBeenCalledWith( + 'ApprovalController:addRequest', + { + id: expect.any(String), + origin, + requestData: { + metadata: { id: expect.any(String), origin }, + permissions: { + [PermissionNames.wallet_getSecretArray]: {}, + [PermissionNames.wallet_getSecretObject]: { + caveats: [ + { type: CaveatTypes.filterObjectResponse, value: ['baz'] }, + ], + }, + [PermissionNames.endowmentAnySubject]: {}, + }, + ...getRequestDataDiffProperty(), }, - [PermissionNames.endowmentAnySubject]: {}, + type: MethodNames.RequestPermissions, }, - ), - ).toMatchObject([ - { - [PermissionNames.wallet_getSecretArray]: getPermissionMatcher({ - parentCapability: PermissionNames.wallet_getSecretArray, - caveats: null, - invoker: origin, - }), - [PermissionNames.wallet_getSecretObject]: getPermissionMatcher({ - parentCapability: PermissionNames.wallet_getSecretObject, - caveats: [ - { type: CaveatTypes.filterObjectResponse, value: ['kaplar'] }, - ], - invoker: origin, - }), - [PermissionNames.endowmentAnySubject]: getPermissionMatcher({ - parentCapability: PermissionNames.endowmentAnySubject, - caveats: null, - invoker: origin, - }), - }, - { id: expect.any(String), origin }, - ]); + true, + ); + }); - expect(callActionSpy).toHaveBeenCalledTimes(1); - expect(callActionSpy).toHaveBeenCalledWith( - 'ApprovalController:addRequest', - { - id: expect.any(String), - origin, - requestData: { - metadata: { id: expect.any(String), origin }, - permissions: { + it('requests multiple permissions (an approved permission is modified)', async () => { + const options = getPermissionControllerOptions(); + const { messenger } = options; + const origin = 'metamask.io'; + + const callActionSpy = jest + .spyOn(messenger, 'call') + .mockImplementationOnce(async (...args) => { + const [, { requestData }] = args as AddPermissionRequestArgs; + const approvedPermissions = { ...requestData.permissions }; + approvedPermissions[PermissionNames.wallet_getSecretObject] = { + caveats: [ + { type: CaveatTypes.filterObjectResponse, value: ['kaplar'] }, + ], + }; + + return { + metadata: { ...requestData.metadata }, + permissions: approvedPermissions, + }; + }); + + const controller = getDefaultPermissionController(options); + expect( + await controller[requestFunctionName]( + { origin }, + { [PermissionNames.wallet_getSecretArray]: {}, [PermissionNames.wallet_getSecretObject]: { caveats: [ @@ -3619,348 +4071,565 @@ describe('PermissionController', () => { }, [PermissionNames.endowmentAnySubject]: {}, }, + ), + ).toMatchObject([ + { + [PermissionNames.wallet_getSecretArray]: getPermissionMatcher({ + parentCapability: PermissionNames.wallet_getSecretArray, + caveats: null, + invoker: origin, + }), + [PermissionNames.wallet_getSecretObject]: getPermissionMatcher({ + parentCapability: PermissionNames.wallet_getSecretObject, + caveats: [ + { type: CaveatTypes.filterObjectResponse, value: ['kaplar'] }, + ], + invoker: origin, + }), + [PermissionNames.endowmentAnySubject]: getPermissionMatcher({ + parentCapability: PermissionNames.endowmentAnySubject, + caveats: null, + invoker: origin, + }), }, - type: MethodNames.requestPermissions, - }, - true, - ); - }); + { id: expect.any(String), origin }, + ]); - it('throws if requested permissions object is not a plain object', async () => { - const options = getPermissionControllerOptions(); - const { messenger } = options; - const origin = 'metamask.io'; - const controller = getDefaultPermissionController(options); + expect(callActionSpy).toHaveBeenCalledTimes(1); + expect(callActionSpy).toHaveBeenCalledWith( + 'ApprovalController:addRequest', + { + id: expect.any(String), + origin, + requestData: { + metadata: { id: expect.any(String), origin }, + permissions: { + [PermissionNames.wallet_getSecretArray]: {}, + [PermissionNames.wallet_getSecretObject]: { + caveats: [ + { type: CaveatTypes.filterObjectResponse, value: ['baz'] }, + ], + }, + [PermissionNames.endowmentAnySubject]: {}, + }, + ...getRequestDataDiffProperty(), + }, + type: MethodNames.RequestPermissions, + }, + true, + ); + }); - const callActionSpy = jest.spyOn(messenger, 'call'); + it('throws if requested permissions object is not a plain object', async () => { + const options = getPermissionControllerOptions(); + const { messenger } = options; + const origin = 'metamask.io'; + const controller = getDefaultPermissionController(options); - for (const invalidInput of [ - // not plain objects - null, - 'foo', - [{ [PermissionNames.wallet_getSecretArray]: {} }], - ]) { + const callActionSpy = jest.spyOn(messenger, 'call'); + + for (const invalidInput of [ + // not plain objects + null, + 'foo', + [{ [PermissionNames.wallet_getSecretArray]: {} }], + ]) { + await expect( + async () => + await controller[requestFunctionName]( + { origin }, + // @ts-expect-error Intentional destructive testing + invalidInput, + ), + ).rejects.toThrow( + errors.invalidParams({ + message: `Requested permissions for origin "${origin}" is not a plain object.`, + data: { origin, requestedPermissions: invalidInput }, + }), + ); + } + + expect(callActionSpy).not.toHaveBeenCalled(); + }); + + it('throws if requested permissions object has no permissions', async () => { + const options = getPermissionControllerOptions(); + const { messenger } = options; + const origin = 'metamask.io'; + + const callActionSpy = jest.spyOn(messenger, 'call'); + + const controller = getDefaultPermissionController(options); await expect( async () => - await controller.requestPermissions( - { origin }, - invalidInput as any, - ), + // No permissions in object + await controller[requestFunctionName]({ origin }, {}), ).rejects.toThrow( errors.invalidParams({ - message: `Requested permissions for origin "${origin}" is not a plain object.`, - data: { origin, requestedPermissions: invalidInput }, + message: `Permissions request for origin "${origin}" contains no permissions.`, + data: { origin, requestedPermissions: {} }, }), ); - } - expect(callActionSpy).not.toHaveBeenCalled(); - }); + expect(callActionSpy).not.toHaveBeenCalled(); + }); - it('throws if requested permissions object has no permissions', async () => { - const options = getPermissionControllerOptions(); - const { messenger } = options; - const origin = 'metamask.io'; + it('throws if requested permissions contain a (key : value.parentCapability) mismatch', async () => { + const options = getPermissionControllerOptions(); + const { messenger } = options; + const origin = 'metamask.io'; - const callActionSpy = jest.spyOn(messenger, 'call'); + const callActionSpy = jest.spyOn(messenger, 'call'); - const controller = getDefaultPermissionController(options); - await expect( - async () => - // No permissions in object - await controller.requestPermissions({ origin }, {}), - ).rejects.toThrow( - errors.invalidParams({ - message: `Permissions request for origin "${origin}" contains no permissions.`, - data: { origin, requestedPermissions: {} }, - }), - ); + const controller = getDefaultPermissionController(options); + await expect( + async () => + await controller[requestFunctionName]( + { origin }, + { + [PermissionNames.wallet_getSecretArray]: { + parentCapability: PermissionNames.wallet_getSecretArray, + }, + // parentCapability value does not match key + [PermissionNames.wallet_getSecretObject]: { + parentCapability: PermissionNames.wallet_getSecretArray, + }, + }, + ), + ).rejects.toThrow( + errors.invalidParams({ + message: `Permissions request for origin "${origin}" contains invalid requested permission(s).`, + data: { + origin, + requestedPermissions: { + [PermissionNames.wallet_getSecretArray]: { + [PermissionNames.wallet_getSecretArray]: { + parentCapability: PermissionNames.wallet_getSecretArray, + }, + [PermissionNames.wallet_getSecretObject]: { + parentCapability: PermissionNames.wallet_getSecretArray, + }, + }, + }, + }, + }), + ); - expect(callActionSpy).not.toHaveBeenCalled(); - }); + expect(callActionSpy).not.toHaveBeenCalled(); + }); - it('throws if requested permissions contain a (key : value.parentCapability) mismatch', async () => { - const options = getPermissionControllerOptions(); - const { messenger } = options; - const origin = 'metamask.io'; + it('throws if requesting a permission for an unknown target', async () => { + const options = getPermissionControllerOptions(); + const { messenger } = options; + const origin = 'metamask.io'; - const callActionSpy = jest.spyOn(messenger, 'call'); + const callActionSpy = jest.spyOn(messenger, 'call'); - const controller = getDefaultPermissionController(options); - await expect( - async () => - await controller.requestPermissions( - { origin }, - { - [PermissionNames.wallet_getSecretArray]: { - parentCapability: PermissionNames.wallet_getSecretArray, - }, - // parentCapability value does not match key - [PermissionNames.wallet_getSecretObject]: { - parentCapability: PermissionNames.wallet_getSecretArray, + const controller = getDefaultPermissionController(options); + await expect( + async () => + await controller[requestFunctionName]( + { origin }, + { + [PermissionNames.wallet_getSecretArray]: {}, + wallet_getSecretKabob: {}, }, - }, - ), - ).rejects.toThrow( - errors.invalidParams({ - message: `Permissions request for origin "${origin}" contains invalid requested permission(s).`, - data: { + ), + ).rejects.toThrow( + errors.methodNotFound('wallet_getSecretKabob', { origin, requestedPermissions: { [PermissionNames.wallet_getSecretArray]: { - [PermissionNames.wallet_getSecretArray]: { - parentCapability: PermissionNames.wallet_getSecretArray, - }, - [PermissionNames.wallet_getSecretObject]: { - parentCapability: PermissionNames.wallet_getSecretArray, - }, + [PermissionNames.wallet_getSecretArray]: {}, + wallet_getSecretKabob: {}, }, }, - }, - }), - ); + }), + ); - expect(callActionSpy).not.toHaveBeenCalled(); - }); + expect(callActionSpy).not.toHaveBeenCalled(); + }); + + it('throws if permission subjectTypes does not include type of subject (restricted method)', async () => { + const options = getPermissionControllerOptions(); + const { messenger } = options; + const origin = 'metamask.io'; + + const callActionSpy = jest + .spyOn(messenger, 'call') + .mockImplementationOnce(() => { + return { + origin, + name: origin, + subjectType: SubjectType.Website, + iconUrl: null, + extensionId: null, + }; + }); + + const controller = getDefaultPermissionController(options); + await expect( + controller[requestFunctionName]( + { origin }, + { + [PermissionNames.snap_foo]: {}, + }, + ), + ).rejects.toThrow( + 'The method "snap_foo" does not exist / is not available.', + ); + + expect(callActionSpy).toHaveBeenCalledTimes(1); + expect(callActionSpy).toHaveBeenCalledWith( + 'SubjectMetadataController:getSubjectMetadata', + origin, + ); + }); - it('throws if requesting a permission for an unknown target', async () => { - const options = getPermissionControllerOptions(); - const { messenger } = options; - const origin = 'metamask.io'; + it('throws if permission subjectTypes does not include type of subject (endowment)', async () => { + const options = getPermissionControllerOptions(); + const { messenger } = options; + const origin = 'metamask.io'; - const callActionSpy = jest.spyOn(messenger, 'call'); + const callActionSpy = jest + .spyOn(messenger, 'call') + .mockImplementationOnce(() => { + return { + origin, + name: origin, + subjectType: SubjectType.Website, + iconUrl: null, + extensionId: null, + }; + }); - const controller = getDefaultPermissionController(options); - await expect( - async () => - await controller.requestPermissions( + const controller = getDefaultPermissionController(options); + await expect( + controller[requestFunctionName]( { origin }, { - [PermissionNames.wallet_getSecretArray]: {}, - wallet_getSecretKabob: {}, + [PermissionNames.endowmentSnapsOnly]: {}, }, ), - ).rejects.toThrow( - errors.methodNotFound('wallet_getSecretKabob', { + ).rejects.toThrow( + 'Subject "metamask.io" has no permission for "endowmentSnapsOnly".', + ); + + expect(callActionSpy).toHaveBeenCalledTimes(1); + expect(callActionSpy).toHaveBeenCalledWith( + 'SubjectMetadataController:getSubjectMetadata', origin, - requestedPermissions: { - [PermissionNames.wallet_getSecretArray]: { - [PermissionNames.wallet_getSecretArray]: {}, - wallet_getSecretKabob: {}, - }, - }, - }), - ); + ); + }); - expect(callActionSpy).not.toHaveBeenCalled(); - }); + it('does not throw if permission subjectTypes includes type of subject', async () => { + const options = getPermissionControllerOptions(); + const { messenger } = options; + const origin = '@metamask/test-snap-bip44'; + + const callActionSpy = jest + .spyOn(messenger, 'call') + .mockImplementation((...args) => { + const [action, { requestData }] = args as AddPermissionRequestArgs; + if (action === 'ApprovalController:addRequest') { + return Promise.resolve({ + metadata: { ...requestData.metadata }, + permissions: { ...requestData.permissions }, + }); + } else if ( + action === 'SubjectMetadataController:getSubjectMetadata' + ) { + return { + origin, + name: origin, + subjectType: SubjectType.Snap, + iconUrl: null, + extensionId: null, + }; + } + throw new Error(`Unexpected action: "${action}"`); + }); - it('throws if subjectTypes do not match', async () => { - const options = getPermissionControllerOptions(); - const { messenger } = options; - const origin = 'metamask.io'; + const controller = getDefaultPermissionController(options); - const callActionSpy = jest - .spyOn(messenger, 'call') - .mockImplementationOnce(() => { - return { + expect( + await controller[requestFunctionName]( + { origin }, + { + [PermissionNames.snap_foo]: {}, + }, + ), + ).toMatchObject([ + { + [PermissionNames.snap_foo]: getPermissionMatcher({ + parentCapability: PermissionNames.snap_foo, + caveats: null, + invoker: origin, + }), + }, + { + id: expect.any(String), origin, - name: origin, - subjectType: SubjectType.Website, - iconUrl: null, - extensionId: null, - }; - }); + }, + ]); - const controller = getDefaultPermissionController(options); - await expect( - controller.requestPermissions( - { origin }, + expect(callActionSpy).toHaveBeenCalledWith( + 'SubjectMetadataController:getSubjectMetadata', + origin, + ); + expect(callActionSpy).toHaveBeenCalledWith( + 'ApprovalController:addRequest', { - [PermissionNames.snap_foo]: {}, + id: expect.any(String), + origin, + requestData: { + metadata: { id: expect.any(String), origin }, + permissions: { [PermissionNames.snap_foo]: {} }, + ...getRequestDataDiffProperty(), + }, + type: MethodNames.RequestPermissions, }, - ), - ).rejects.toThrow( - 'The method "snap_foo" does not exist / is not available.', - ); - - expect(callActionSpy).toHaveBeenCalledTimes(1); - expect(callActionSpy).toHaveBeenCalledWith( - 'SubjectMetadataController:getSubjectMetadata', - origin, - ); - }); + true, + ); + }); - it('does not throw if subjectTypes match', async () => { - const options = getPermissionControllerOptions(); - const { messenger } = options; - const origin = '@metamask/test-snap-bip44'; + it('throws if the "caveats" property of a requested permission is invalid', async () => { + const options = getPermissionControllerOptions(); + const { messenger } = options; + const origin = 'metamask.io'; - const callActionSpy = jest - .spyOn(messenger, 'call') - .mockImplementationOnce(() => { - return { - origin, - name: origin, - subjectType: SubjectType.Snap, - iconUrl: null, - extensionId: null, - }; - }) - .mockImplementationOnce(async (...args: any) => { - const [, { requestData }] = args; - return { - metadata: { ...requestData.metadata }, - permissions: { ...requestData.permissions }, - }; - }) - .mockImplementation(() => { - return { - origin, - name: origin, - subjectType: SubjectType.Snap, - iconUrl: null, - extensionId: null, - }; - }); + const callActionSpy = jest.spyOn(messenger, 'call'); - const controller = getDefaultPermissionController(options); - expect( - await controller.requestPermissions( - { origin }, - { - [PermissionNames.snap_foo]: {}, - }, - ), - ).toMatchObject([ - { - [PermissionNames.snap_foo]: getPermissionMatcher({ - parentCapability: PermissionNames.snap_foo, - caveats: null, - invoker: origin, - }), - }, - { - id: expect.any(String), - origin, - }, - ]); + const controller = getDefaultPermissionController(options); + for (const invalidCaveatsValue of [ + [], // empty array + undefined, + 'foo', + 2, + Symbol('bar'), + ]) { + await expect( + async () => + await controller[requestFunctionName]( + { origin }, + { + [PermissionNames.wallet_getSecretArray]: { + // @ts-expect-error Intentional destructive testing + caveats: invalidCaveatsValue, + }, + }, + ), + ).rejects.toThrow( + new errors.InvalidCaveatsPropertyError( + origin, + PermissionNames.wallet_getSecretArray, + invalidCaveatsValue, + ), + ); - expect(callActionSpy).toHaveBeenCalledTimes(4); - expect(callActionSpy).toHaveBeenNthCalledWith( - 1, - 'SubjectMetadataController:getSubjectMetadata', - origin, - ); - expect(callActionSpy).toHaveBeenNthCalledWith( - 2, - 'ApprovalController:addRequest', - { - id: expect.any(String), - origin, - requestData: { - metadata: { id: expect.any(String), origin }, - permissions: { [PermissionNames.snap_foo]: {} }, - }, - type: MethodNames.requestPermissions, - }, - true, - ); - expect(callActionSpy).toHaveBeenNthCalledWith( - 3, - 'SubjectMetadataController:getSubjectMetadata', - origin, - ); - expect(callActionSpy).toHaveBeenNthCalledWith( - 4, - 'SubjectMetadataController:getSubjectMetadata', - origin, - ); - }); + expect(callActionSpy).not.toHaveBeenCalled(); + } + }); - it('throws if the "caveat" property of a requested permission is invalid', async () => { - const options = getPermissionControllerOptions(); - const { messenger } = options; - const origin = 'metamask.io'; + it('throws if a requested permission has duplicate caveats', async () => { + const options = getPermissionControllerOptions(); + const { messenger } = options; + const origin = 'metamask.io'; - const callActionSpy = jest.spyOn(messenger, 'call'); + const callActionSpy = jest.spyOn(messenger, 'call'); - const controller = getDefaultPermissionController(options); - for (const invalidCaveatsValue of [ - [], // empty array - undefined, - 'foo', - 2, - Symbol('bar'), - ]) { + const controller = getDefaultPermissionController(options); await expect( async () => - await controller.requestPermissions( + await controller[requestFunctionName]( { origin }, { [PermissionNames.wallet_getSecretArray]: { - caveats: invalidCaveatsValue as any, + caveats: [ + { type: CaveatTypes.filterArrayResponse, value: ['foo'] }, + { type: CaveatTypes.filterArrayResponse, value: ['foo'] }, + ], }, }, ), ).rejects.toThrow( - new errors.InvalidCaveatsPropertyError( + new errors.DuplicateCaveatError( + CaveatTypes.filterArrayResponse, origin, PermissionNames.wallet_getSecretArray, - invalidCaveatsValue, ), ); expect(callActionSpy).not.toHaveBeenCalled(); - } - }); + }); - it('throws if a requested permission has duplicate caveats', async () => { - const options = getPermissionControllerOptions(); - const { messenger } = options; - const origin = 'metamask.io'; + it('throws if the approved request object is invalid', async () => { + const options = getPermissionControllerOptions(); + const { messenger } = options; + const origin = 'metamask.io'; + const controller = getDefaultPermissionController(options); + const callActionSpy = jest.spyOn(messenger, 'call'); + + for (const invalidRequestObject of ['foo', null, { metadata: 'foo' }]) { + callActionSpy.mockClear(); + callActionSpy.mockImplementationOnce( + async () => invalidRequestObject, + ); - const callActionSpy = jest.spyOn(messenger, 'call'); + await expect( + async () => + await controller[requestFunctionName]( + { origin }, + { + [PermissionNames.wallet_getSecretArray]: {}, + }, + ), + ).rejects.toThrow( + errors.internalError( + `Approved permissions request for subject "${origin}" is invalid.`, + { data: { approvedRequest: invalidRequestObject } }, + ), + ); - const controller = getDefaultPermissionController(options); - await expect( - async () => - await controller.requestPermissions( - { origin }, + expect(callActionSpy).toHaveBeenCalledTimes(1); + expect(callActionSpy).toHaveBeenCalledWith( + 'ApprovalController:addRequest', { - [PermissionNames.wallet_getSecretArray]: { - caveats: [ - { type: CaveatTypes.filterArrayResponse, value: ['foo'] }, - { type: CaveatTypes.filterArrayResponse, value: ['foo'] }, - ], + id: expect.any(String), + origin, + requestData: { + metadata: { id: expect.any(String), origin }, + permissions: { [PermissionNames.wallet_getSecretArray]: {} }, + ...getRequestDataDiffProperty(), + }, + type: MethodNames.RequestPermissions, + }, + true, + ); + } + }); + + it('throws if the approved request ID changed', async () => { + const options = getPermissionControllerOptions(); + const { messenger } = options; + const origin = 'metamask.io'; + + const callActionSpy = jest + .spyOn(messenger, 'call') + .mockImplementationOnce(async (...args) => { + const [, { requestData }] = args as AddPermissionRequestArgs; + return { + // different id + metadata: { ...requestData.metadata, id: 'foo' }, + permissions: { + [PermissionNames.wallet_getSecretArray]: {}, + }, + }; + }); + + const controller = getDefaultPermissionController(options); + await expect( + async () => + await controller[requestFunctionName]( + { origin }, + { + [PermissionNames.wallet_getSecretArray]: {}, }, + ), + ).rejects.toThrow( + errors.internalError( + `Approved permissions request for subject "${origin}" mutated its id.`, + { originalId: expect.any(String), mutatedId: 'foo' }, + ), + ); + + expect(callActionSpy).toHaveBeenCalledTimes(1); + expect(callActionSpy).toHaveBeenCalledWith( + 'ApprovalController:addRequest', + { + id: expect.any(String), + origin, + requestData: { + metadata: { id: expect.any(String), origin }, + permissions: { [PermissionNames.wallet_getSecretArray]: {} }, + ...getRequestDataDiffProperty(), }, + type: MethodNames.RequestPermissions, + }, + true, + ); + }); + + it('throws if the approved request origin changed', async () => { + const options = getPermissionControllerOptions(); + const { messenger } = options; + const origin = 'metamask.io'; + + const callActionSpy = jest + .spyOn(messenger, 'call') + .mockImplementationOnce(async (...args) => { + const [, { requestData }] = args as AddPermissionRequestArgs; + return { + // different origin + metadata: { ...requestData.metadata, origin: 'foo.com' }, + permissions: { + [PermissionNames.wallet_getSecretArray]: {}, + }, + }; + }); + + const controller = getDefaultPermissionController(options); + await expect( + async () => + await controller[requestFunctionName]( + { origin }, + { + [PermissionNames.wallet_getSecretArray]: {}, + }, + ), + ).rejects.toThrow( + errors.internalError( + `Approved permissions request for subject "${origin}" mutated its origin.`, + { originalOrigin: origin, mutatedOrigin: 'foo' }, ), - ).rejects.toThrow( - new errors.DuplicateCaveatError( - CaveatTypes.filterArrayResponse, - origin, - PermissionNames.wallet_getSecretArray, - ), - ); + ); - expect(callActionSpy).not.toHaveBeenCalled(); - }); + expect(callActionSpy).toHaveBeenCalledTimes(1); + expect(callActionSpy).toHaveBeenCalledWith( + 'ApprovalController:addRequest', + { + id: expect.any(String), + origin, + requestData: { + metadata: { id: expect.any(String), origin }, + permissions: { [PermissionNames.wallet_getSecretArray]: {} }, + ...getRequestDataDiffProperty(), + }, + type: MethodNames.RequestPermissions, + }, + true, + ); + }); - it('throws if the approved request object is invalid', async () => { - const options = getPermissionControllerOptions(); - const { messenger } = options; - const origin = 'metamask.io'; - const controller = getDefaultPermissionController(options); - const callActionSpy = jest.spyOn(messenger, 'call'); + it('throws if no permissions were approved', async () => { + const options = getPermissionControllerOptions(); + const { messenger } = options; + const origin = 'metamask.io'; - for (const invalidRequestObject of ['foo', null, { metadata: 'foo' }]) { - callActionSpy.mockClear(); - callActionSpy.mockImplementationOnce(async () => invalidRequestObject); + const callActionSpy = jest + .spyOn(messenger, 'call') + .mockImplementationOnce(async (...args) => { + const [, { requestData }] = args as AddPermissionRequestArgs; + return { + metadata: { ...requestData.metadata }, + permissions: {}, // no permissions + }; + }); + const controller = getDefaultPermissionController(options); await expect( async () => - await controller.requestPermissions( + await controller[requestFunctionName]( { origin }, { [PermissionNames.wallet_getSecretArray]: {}, @@ -3968,8 +4637,10 @@ describe('PermissionController', () => { ), ).rejects.toThrow( errors.internalError( - `Approved permissions request for subject "${origin}" is invalid.`, - { data: { approvedRequest: invalidRequestObject } }, + `Invalid approved permissions request: Permissions request for origin "${origin}" contains no permissions.`, + { + [PermissionNames.wallet_getSecretArray]: {}, + }, ), ); @@ -3982,203 +4653,192 @@ describe('PermissionController', () => { requestData: { metadata: { id: expect.any(String), origin }, permissions: { [PermissionNames.wallet_getSecretArray]: {} }, + ...getRequestDataDiffProperty(), }, - type: MethodNames.requestPermissions, + type: MethodNames.RequestPermissions, }, true, ); - } - }); + }); - it('throws if the approved request ID changed', async () => { - const options = getPermissionControllerOptions(); - const { messenger } = options; - const origin = 'metamask.io'; + it('throws if approved permissions object is not a plain object', async () => { + const options = getPermissionControllerOptions(); + const { messenger } = options; + const origin = 'metamask.io'; + const id = 'arbitraryId'; + const controller = getDefaultPermissionController(options); - const callActionSpy = jest - .spyOn(messenger, 'call') - .mockImplementationOnce(async (...args: any) => { - const [, { requestData }] = args; - return { - // different id - metadata: { ...requestData.metadata, id: 'foo' }, - permissions: { - [PermissionNames.wallet_getSecretArray]: {}, - }, - }; - }); + const callActionSpy = jest.spyOn(messenger, 'call'); - const controller = getDefaultPermissionController(options); - await expect( - async () => - await controller.requestPermissions( - { origin }, - { - [PermissionNames.wallet_getSecretArray]: {}, - }, - ), - ).rejects.toThrow( - errors.internalError( - `Approved permissions request for subject "${origin}" mutated its id.`, - { originalId: expect.any(String), mutatedId: 'foo' }, - ), - ); + // The metadata is valid, but the permissions are invalid + const getInvalidRequestObject = ( + invalidPermissions: unknown, + ): PermissionsRequest => { + return { + metadata: { origin, id }, - expect(callActionSpy).toHaveBeenCalledTimes(1); - expect(callActionSpy).toHaveBeenCalledWith( - 'ApprovalController:addRequest', - { - id: expect.any(String), - origin, - requestData: { - metadata: { id: expect.any(String), origin }, - permissions: { [PermissionNames.wallet_getSecretArray]: {} }, - }, - type: MethodNames.requestPermissions, - }, - true, - ); - }); + // @ts-expect-error Intentional destructive testing. + permissions: invalidPermissions, + }; + }; - it('throws if the approved request origin changed', async () => { - const options = getPermissionControllerOptions(); - const { messenger } = options; - const origin = 'metamask.io'; + for (const invalidRequestObject of [ + null, + 'foo', + [{ [PermissionNames.wallet_getSecretArray]: {} }], + ].map((invalidPermissions) => + getInvalidRequestObject(invalidPermissions), + )) { + callActionSpy.mockClear(); + callActionSpy.mockImplementationOnce( + async () => invalidRequestObject, + ); - const callActionSpy = jest - .spyOn(messenger, 'call') - .mockImplementationOnce(async (...args: any) => { - const [, { requestData }] = args; - return { - // different origin - metadata: { ...requestData.metadata, origin: 'foo.com' }, - permissions: { - [PermissionNames.wallet_getSecretArray]: {}, - }, - }; - }); + await expect( + async () => + await controller[requestFunctionName]( + { origin }, + { + [PermissionNames.wallet_getSecretArray]: {}, + }, + { id, preserveExistingPermissions: true }, + ), + ).rejects.toThrow( + errors.internalError( + `Invalid approved permissions request: Requested permissions for origin "${origin}" is not a plain object.`, + { data: { approvedRequest: invalidRequestObject } }, + ), + ); - const controller = getDefaultPermissionController(options); - await expect( - async () => - await controller.requestPermissions( - { origin }, + expect(callActionSpy).toHaveBeenCalledTimes(1); + expect(callActionSpy).toHaveBeenCalledWith( + 'ApprovalController:addRequest', { - [PermissionNames.wallet_getSecretArray]: {}, + id: expect.any(String), + origin, + requestData: { + metadata: { id: expect.any(String), origin }, + permissions: { [PermissionNames.wallet_getSecretArray]: {} }, + ...getRequestDataDiffProperty(), + }, + type: MethodNames.RequestPermissions, }, - ), - ).rejects.toThrow( - errors.internalError( - `Approved permissions request for subject "${origin}" mutated its origin.`, - { originalOrigin: origin, mutatedOrigin: 'foo' }, - ), - ); - - expect(callActionSpy).toHaveBeenCalledTimes(1); - expect(callActionSpy).toHaveBeenCalledWith( - 'ApprovalController:addRequest', - { - id: expect.any(String), - origin, - requestData: { - metadata: { id: expect.any(String), origin }, - permissions: { [PermissionNames.wallet_getSecretArray]: {} }, - }, - type: MethodNames.requestPermissions, - }, - true, - ); - }); + true, + ); + } + }); - it('throws if no permissions were approved', async () => { - const options = getPermissionControllerOptions(); - const { messenger } = options; - const origin = 'metamask.io'; + it('throws if approved permissions contain a (key : value.parentCapability) mismatch', async () => { + const options = getPermissionControllerOptions(); + const { messenger } = options; + const origin = 'metamask.io'; + const controller = getDefaultPermissionController(options); - const callActionSpy = jest - .spyOn(messenger, 'call') - .mockImplementationOnce(async (...args: any) => { - const [, { requestData }] = args; - return { - metadata: { ...requestData.metadata }, - permissions: {}, // no permissions - }; - }); + const callActionSpy = jest + .spyOn(messenger, 'call') + .mockImplementationOnce(async (...args) => { + const [, { requestData }] = args as AddPermissionRequestArgs; + return { + metadata: { ...requestData.metadata }, + permissions: { + [PermissionNames.wallet_getSecretArray]: { + parentCapability: PermissionNames.wallet_getSecretArray, + }, + // parentCapability value does not match key + [PermissionNames.wallet_getSecretObject]: { + parentCapability: PermissionNames.wallet_getSecretArray, + }, + }, + }; + }); - const controller = getDefaultPermissionController(options); - await expect( - async () => - await controller.requestPermissions( - { origin }, - { - [PermissionNames.wallet_getSecretArray]: {}, + await expect( + async () => + await controller[requestFunctionName]( + { origin }, + { + [PermissionNames.wallet_getSecretArray]: { + parentCapability: PermissionNames.wallet_getSecretArray, + }, + }, + ), + ).rejects.toThrow( + errors.invalidParams({ + message: `Invalid approved permissions request: Permissions request for origin "${origin}" contains invalid requested permission(s).`, + data: { + origin, + requestedPermissions: { + [PermissionNames.wallet_getSecretArray]: { + [PermissionNames.wallet_getSecretArray]: { + parentCapability: PermissionNames.wallet_getSecretArray, + }, + [PermissionNames.wallet_getSecretObject]: { + parentCapability: PermissionNames.wallet_getSecretArray, + }, + }, + }, }, - ), - ).rejects.toThrow( - errors.internalError( - `Invalid approved permissions request: Permissions request for origin "${origin}" contains no permissions.`, - { - [PermissionNames.wallet_getSecretArray]: {}, - }, - ), - ); + }), + ); - expect(callActionSpy).toHaveBeenCalledTimes(1); - expect(callActionSpy).toHaveBeenCalledWith( - 'ApprovalController:addRequest', - { - id: expect.any(String), - origin, - requestData: { - metadata: { id: expect.any(String), origin }, - permissions: { [PermissionNames.wallet_getSecretArray]: {} }, + expect(callActionSpy).toHaveBeenCalledTimes(1); + expect(callActionSpy).toHaveBeenCalledWith( + 'ApprovalController:addRequest', + { + id: expect.any(String), + origin, + requestData: { + metadata: { id: expect.any(String), origin }, + permissions: { + [PermissionNames.wallet_getSecretArray]: { + parentCapability: PermissionNames.wallet_getSecretArray, + }, + }, + ...getRequestDataDiffProperty(), + }, + type: MethodNames.RequestPermissions, }, - type: MethodNames.requestPermissions, - }, - true, - ); - }); - - it('throws if approved permissions object is not a plain object', async () => { - const options = getPermissionControllerOptions(); - const { messenger } = options; - const origin = 'metamask.io'; - const id = 'arbitraryId'; - const controller = getDefaultPermissionController(options); - - const callActionSpy = jest.spyOn(messenger, 'call'); + true, + ); + }); - // The metadata is valid, but the permissions are invalid - const getInvalidRequestObject = (invalidPermissions: any) => { - return { - metadata: { origin, id }, - permissions: invalidPermissions, - }; - }; + it('correctly throws errors that do not inherit from JsonRpcError', async () => { + const options = getPermissionControllerOptions(); + const { messenger } = options; + const origin = 'metamask.io'; + const controller = getDefaultPermissionController(options); - for (const invalidRequestObject of [ - null, - 'foo', - [{ [PermissionNames.wallet_getSecretArray]: {} }], - ].map((invalidPermissions) => - getInvalidRequestObject(invalidPermissions), - )) { - callActionSpy.mockClear(); - callActionSpy.mockImplementationOnce(async () => invalidRequestObject); + const callActionSpy = jest + .spyOn(messenger, 'call') + .mockImplementationOnce(async (...args) => { + const [, { requestData }] = args as AddPermissionRequestArgs; + return { + metadata: { ...requestData.metadata }, + permissions: { + [PermissionNames.wallet_getSecretArray]: { + parentCapability: PermissionNames.wallet_getSecretArray, + }, + [PermissionNames.wallet_getSecretObject]: { + parentCapability: PermissionNames.wallet_getSecretObject, + caveats: 'foo', // invalid + }, + }, + }; + }); await expect( async () => - await controller.requestPermissions( + await controller[requestFunctionName]( { origin }, { - [PermissionNames.wallet_getSecretArray]: {}, + [PermissionNames.wallet_getSecretArray]: { + parentCapability: PermissionNames.wallet_getSecretArray, + }, }, - { id, preserveExistingPermissions: true }, ), ).rejects.toThrow( errors.internalError( - `Invalid approved permissions request: Requested permissions for origin "${origin}" is not a plain object.`, - { data: { approvedRequest: invalidRequestObject } }, + `Invalid approved permissions request: The "caveats" property of permission for "${PermissionNames.wallet_getSecretObject}" of subject "${origin}" is invalid. It must be a non-empty array if specified.`, ), ); @@ -4190,86 +4850,349 @@ describe('PermissionController', () => { origin, requestData: { metadata: { id: expect.any(String), origin }, - permissions: { [PermissionNames.wallet_getSecretArray]: {} }, + permissions: { + [PermissionNames.wallet_getSecretArray]: { + parentCapability: PermissionNames.wallet_getSecretArray, + }, + }, + ...getRequestDataDiffProperty(), }, - type: MethodNames.requestPermissions, + type: MethodNames.RequestPermissions, }, true, ); - } + }); }); - it('throws if approved permissions contain a (key : value.parentCapability) mismatch', async () => { - const options = getPermissionControllerOptions(); - const { messenger } = options; - const origin = 'metamask.io'; - const controller = getDefaultPermissionController(options); + // Permissions and their caveats are merged through a right-biased union. + // The existing permissions are the left-hand side and denoted as `A`. + // The requested permissions are the right-hand side and denoted as `B`. + describe('requestPermissionsIncremental: merging permissions', () => { + const caveatType1 = CaveatTypes.filterArrayResponse; + const caveatType2 = CaveatTypes.filterObjectResponse; + const caveatType3 = CaveatTypes.noopCaveat; - const callActionSpy = jest - .spyOn(messenger, 'call') - .mockImplementationOnce(async (...args: any) => { - const [, { requestData }] = args; - return { - metadata: { ...requestData.metadata }, - permissions: { - [PermissionNames.wallet_getSecretArray]: { - parentCapability: PermissionNames.wallet_getSecretArray, + const makeCaveat = (type: string, value: Json): Caveat => ({ + type, + value, + }); + const makeCaveat1 = (...value: string[]): Caveat => + makeCaveat(caveatType1, value); + const makeCaveat2 = (...value: string[]): Caveat => + makeCaveat(caveatType2, value); + const makeCaveat3 = (): Caveat => + makeCaveat(caveatType3, null); + + it.each([ + ['neither A nor B have caveats', null, null], + ['only A has caveats', [makeCaveat1('a', 'b'), makeCaveat3()], null], + [ + 'A and B have the same caveat', + [makeCaveat1('a', 'b')], + [makeCaveat1('a', 'b')], + ], + [ + 'A and B have the same caveats', + [makeCaveat1('a', 'b'), makeCaveat3()], + [makeCaveat1('a', 'b'), makeCaveat3()], + ], + ])( + 'no-ops if request results in no change: %s', + async (_case, leftCaveats, rightCaveats) => { + const options = getPermissionControllerOptions(); + const { messenger } = options; + const origin = 'metamask.io'; + const controller = getDefaultPermissionController(options); + + controller.grantPermissions({ + subject: { origin }, + approvedPermissions: { + [PermissionNames.wallet_noopWithManyCaveats]: { + // @ts-expect-error We know that the caveat type is correct. + caveats: leftCaveats, }, - // parentCapability value does not match key - [PermissionNames.wallet_getSecretObject]: { - parentCapability: PermissionNames.wallet_getSecretArray, + }, + }); + + const callActionSpy = jest + .spyOn(messenger, 'call') + .mockImplementationOnce(async (...args) => { + const [, { requestData }] = args as AddPermissionRequestArgs; + return { + metadata: { ...requestData.metadata }, + permissions: { ...requestData.permissions }, + }; + }); + + expect( + await controller.requestPermissionsIncremental( + { origin }, + { + [PermissionNames.wallet_noopWithManyCaveats]: { + // @ts-expect-error We know that the caveat type is correct. + caveats: rightCaveats, + }, + }, + ), + ).toStrictEqual([]); + + expect(callActionSpy).not.toHaveBeenCalled(); + }, + ); + + it.each([ + [ + 'only B has caveats', + null, + [makeCaveat1('a', 'b'), makeCaveat3()], + [makeCaveat1('a', 'b'), makeCaveat3()], + { [caveatType1]: ['a', 'b'], [caveatType3]: null }, + ], + [ + 'A and B have disjoint caveats', + [makeCaveat1('a', 'b')], + [makeCaveat2('y', 'z'), makeCaveat3()], + [makeCaveat1('a', 'b'), makeCaveat2('y', 'z'), makeCaveat3()], + { [caveatType2]: ['y', 'z'], [caveatType3]: null }, + ], + [ + 'A and B have one of the same caveat', + [makeCaveat1('a', 'b')], + [makeCaveat1('c')], + [makeCaveat1('a', 'b', 'c')], + { [caveatType1]: ['c'] }, + ], + [ + 'A and B have one of the same caveat, and others', + [makeCaveat1('a', 'b'), makeCaveat2('x')], + [makeCaveat1('c'), makeCaveat3()], + [makeCaveat1('a', 'b', 'c'), makeCaveat2('x'), makeCaveat3()], + { [caveatType1]: ['c'], [caveatType3]: null }, + ], + [ + 'A and B have two of the same caveat', + [makeCaveat1('a', 'b'), makeCaveat2('x')], + [makeCaveat2('y', 'z'), makeCaveat1('c')], + [makeCaveat1('a', 'b', 'c'), makeCaveat2('x', 'y', 'z')], + { [caveatType1]: ['c'], [caveatType2]: ['y', 'z'] }, + ], + [ + 'A and B have two of the same caveat, and A has one other', + [makeCaveat1('a', 'b'), makeCaveat2('x'), makeCaveat3()], + [makeCaveat2('y', 'z'), makeCaveat1('c')], + [ + makeCaveat1('a', 'b', 'c'), + makeCaveat2('x', 'y', 'z'), + makeCaveat3(), + ], + { [caveatType1]: ['c'], [caveatType2]: ['y', 'z'] }, + ], + [ + 'A and B have two of the same caveat, and B has one other', + [makeCaveat1('a', 'b'), makeCaveat2('x')], + [makeCaveat2('y', 'z'), makeCaveat1('c'), makeCaveat3()], + [ + makeCaveat1('a', 'b', 'c'), + makeCaveat2('x', 'y', 'z'), + makeCaveat3(), + ], + { + [caveatType1]: ['c'], + [caveatType2]: ['y', 'z'], + [caveatType3]: null, + }, + ], + ])( + 'requested permission merges with existing permission: %s', + async ( + _case, + leftCaveats, + rightCaveats, + expectedCaveats, + caveatsDiff, + ) => { + const getPermissionDiffMatcher = ( + previousCaveats: CaveatConstraint[] | null, + diff: Record, + // `expect.objectContaining` returns `any`. + // eslint-disable-next-line @typescript-eslint/no-explicit-any + ): any => + expect.objectContaining({ + currentPermissions: expect.objectContaining({ + [PermissionNames.wallet_noopWithManyCaveats]: + expect.objectContaining({ + caveats: previousCaveats, + }), + }), + permissionDiffMap: { + [PermissionNames.wallet_noopWithManyCaveats]: diff, + }, + }); + + const options = getPermissionControllerOptions(); + const { messenger } = options; + const origin = 'metamask.io'; + const controller = getDefaultPermissionController(options); + + controller.grantPermissions({ + subject: { origin }, + approvedPermissions: { + [PermissionNames.wallet_noopWithManyCaveats]: { + // @ts-expect-error The caveat type is in fact valid. + caveats: leftCaveats, + }, + }, + }); + + const callActionSpy = jest + .spyOn(messenger, 'call') + .mockImplementationOnce(async (...args) => { + const [, { requestData }] = args as AddPermissionRequestArgs; + return { + metadata: { ...requestData.metadata }, + permissions: { ...requestData.permissions }, + }; + }); + + expect( + await controller.requestPermissionsIncremental( + { origin }, + { + [PermissionNames.wallet_noopWithManyCaveats]: { + // @ts-expect-error The caveat type is in fact valid. + caveats: rightCaveats, + }, + }, + ), + ).toMatchObject([ + { + [PermissionNames.wallet_noopWithManyCaveats]: + getPermissionMatcher({ + parentCapability: PermissionNames.wallet_noopWithManyCaveats, + caveats: expectedCaveats, + }), + }, + { id: expect.any(String), origin }, + ]); + + expect(callActionSpy).toHaveBeenCalledTimes(1); + expect(callActionSpy).toHaveBeenCalledWith( + 'ApprovalController:addRequest', + { + id: expect.any(String), + origin, + requestData: { + metadata: { id: expect.any(String), origin }, + permissions: { + [PermissionNames.wallet_noopWithManyCaveats]: + getPermissionMatcher({ + parentCapability: + PermissionNames.wallet_noopWithManyCaveats, + caveats: expectedCaveats, + }), + }, + diff: getPermissionDiffMatcher(leftCaveats, caveatsDiff), }, + type: MethodNames.RequestPermissions, + }, + true, + ); + }, + ); + + it('throws if attempting to merge caveats without a merger function', async () => { + const options = getPermissionControllerOptions(); + const { messenger } = options; + const origin = 'metamask.io'; + + const controller = getDefaultPermissionController(options); + + controller.grantPermissions({ + subject: { origin }, + approvedPermissions: { + [PermissionNames.wallet_getSecretArray]: { + caveats: [makeCaveat(CaveatTypes.reverseArrayResponse, null)], }, - }; + }, }); - await expect( - async () => - await controller.requestPermissions( + const callActionSpy = jest + .spyOn(messenger, 'call') + .mockImplementationOnce(async (...args) => { + const [, { requestData }] = args as AddPermissionRequestArgs; + return { + metadata: { ...requestData.metadata }, + permissions: { ...requestData.permissions }, + }; + }); + + await expect( + controller.requestPermissionsIncremental( { origin }, { [PermissionNames.wallet_getSecretArray]: { - parentCapability: PermissionNames.wallet_getSecretArray, + caveats: [makeCaveat(CaveatTypes.reverseArrayResponse, null)], }, }, ), - ).rejects.toThrow( - errors.invalidParams({ - message: `Invalid approved permissions request: Permissions request for origin "${origin}" contains invalid requested permission(s).`, - data: { - origin, - requestedPermissions: { - [PermissionNames.wallet_getSecretArray]: { - [PermissionNames.wallet_getSecretArray]: { - parentCapability: PermissionNames.wallet_getSecretArray, - }, - [PermissionNames.wallet_getSecretObject]: { - parentCapability: PermissionNames.wallet_getSecretArray, - }, - }, + ).rejects.toThrow( + new errors.CaveatMergerDoesNotExistError( + CaveatTypes.reverseArrayResponse, + ), + ); + + expect(callActionSpy).not.toHaveBeenCalled(); + }); + + it('throws if merged caveats produce an invalid permission', async () => { + const caveatSpecifications = getDefaultCaveatSpecifications(); + caveatSpecifications[CaveatTypes.filterArrayResponse].merger = (): [ + string, + string, + ] => ['foo', 'foo']; + + const options = getPermissionControllerOptions({ + caveatSpecifications, + }); + const { messenger } = options; + const origin = 'metamask.io'; + + const controller = getDefaultPermissionController(options); + + controller.grantPermissions({ + subject: { origin }, + approvedPermissions: { + [PermissionNames.wallet_getSecretArray]: { + caveats: [makeCaveat(CaveatTypes.filterArrayResponse, ['a'])], }, }, - }), - ); + }); - expect(callActionSpy).toHaveBeenCalledTimes(1); - expect(callActionSpy).toHaveBeenCalledWith( - 'ApprovalController:addRequest', - { - id: expect.any(String), - origin, - requestData: { - metadata: { id: expect.any(String), origin }, - permissions: { + const callActionSpy = jest + .spyOn(messenger, 'call') + .mockImplementationOnce(async (...args) => { + const [, { requestData }] = args as AddPermissionRequestArgs; + return { + metadata: { ...requestData.metadata }, + permissions: { ...requestData.permissions }, + }; + }); + + await expect( + controller.requestPermissionsIncremental( + { origin }, + { [PermissionNames.wallet_getSecretArray]: { - parentCapability: PermissionNames.wallet_getSecretArray, + caveats: [makeCaveat(CaveatTypes.filterArrayResponse, ['b'])], }, }, - }, - type: MethodNames.requestPermissions, - }, - true, - ); + ), + ).rejects.toThrow( + `${CaveatTypes.filterArrayResponse} values must be arrays`, + ); + + expect(callActionSpy).not.toHaveBeenCalled(); + }); }); }); @@ -4282,8 +5205,8 @@ describe('PermissionController', () => { const callActionSpy = jest .spyOn(messenger, 'call') - .mockImplementationOnce((..._args: any) => true) - .mockImplementationOnce((..._args: any) => undefined); + .mockImplementationOnce(() => true) + .mockImplementationOnce(() => undefined); const controller = getDefaultPermissionController(options); @@ -4324,8 +5247,8 @@ describe('PermissionController', () => { const callActionSpy = jest .spyOn(messenger, 'call') - .mockImplementationOnce((..._args: any) => true) - .mockImplementationOnce((..._args: any) => undefined); + .mockImplementationOnce(() => true) + .mockImplementationOnce(() => undefined); const controller = getDefaultPermissionController(options); @@ -4361,7 +5284,7 @@ describe('PermissionController', () => { const callActionSpy = jest .spyOn(messenger, 'call') - .mockImplementationOnce((..._args: any) => false); + .mockImplementationOnce(() => false); const controller = getDefaultPermissionController(options); @@ -4393,11 +5316,11 @@ describe('PermissionController', () => { const callActionSpy = jest .spyOn(messenger, 'call') - .mockImplementationOnce((..._args: any) => true) - .mockImplementationOnce((..._args: any) => { + .mockImplementationOnce(() => true) + .mockImplementationOnce(() => { throw new Error('unexpected failure'); }) - .mockImplementationOnce((..._args: any) => undefined); + .mockImplementationOnce(() => undefined); const controller = getDefaultPermissionController(options); @@ -4449,8 +5372,8 @@ describe('PermissionController', () => { const callActionSpy = jest .spyOn(messenger, 'call') - .mockImplementationOnce(async (..._args: any) => true) - .mockImplementationOnce(async (..._args: any) => undefined); + .mockImplementationOnce(async () => true) + .mockImplementationOnce(async () => undefined); const controller = getDefaultPermissionController(options); @@ -4480,7 +5403,7 @@ describe('PermissionController', () => { const callActionSpy = jest .spyOn(messenger, 'call') - .mockImplementationOnce((..._args: any) => false); + .mockImplementationOnce(() => false); const controller = getDefaultPermissionController(options); @@ -4533,7 +5456,8 @@ describe('PermissionController', () => { await expect( controller.getEndowments( origin, - PermissionNames.wallet_getSecretArray as any, + // @ts-expect-error Intentional destructive testing + PermissionNames.wallet_getSecretArray, ), ).rejects.toThrow( new errors.EndowmentPermissionDoesNotExistError( @@ -4663,15 +5587,16 @@ describe('PermissionController', () => { const origin = 'metamask.io'; await expect( - controller.executeRestrictedMethod(origin, 'wallet_getMeTacos' as any), + // @ts-expect-error Intentional destructive testing + controller.executeRestrictedMethod(origin, 'wallet_getMeTacos'), ).rejects.toThrow(errors.methodNotFound('wallet_getMeTacos', { origin })); }); it('throws if the restricted method returns undefined', async () => { const permissionSpecifications = getDefaultPermissionSpecifications(); - ( - permissionSpecifications as any - ).wallet_doubleNumber.methodImplementation = () => undefined; + // @ts-expect-error Intentional destructive testing + permissionSpecifications.wallet_doubleNumber.methodImplementation = + (): undefined => undefined; const controller = new PermissionController< DefaultPermissionSpecifications, @@ -4697,15 +5622,17 @@ describe('PermissionController', () => { ), ).rejects.toThrow( new Error( - `Internal request for method "${PermissionNames.wallet_doubleNumber}" as origin "${origin}" returned no result.`, + `Request for method "${PermissionNames.wallet_doubleNumber}" as origin "${origin}" returned no result.`, ), ); }); }); describe('controller actions', () => { - it('action: PermissionController:clearPermissions', () => { - const messenger = getUnrestrictedMessenger(); + it('action: PermissionController:clearState', () => { + const messenger = getRootMessenger(); + jest.spyOn(messenger, 'call'); + const options = getPermissionControllerOptions({ messenger: getPermissionControllerMessenger(messenger), }); @@ -4713,7 +5640,6 @@ describe('PermissionController', () => { DefaultPermissionSpecifications, DefaultCaveatSpecifications >(options); - const clearStateSpy = jest.spyOn(controller, 'clearState'); controller.grantPermissions({ subject: { origin: 'foo' }, @@ -4724,13 +5650,19 @@ describe('PermissionController', () => { expect(hasProperty(controller.state.subjects, 'foo')).toBe(true); - messenger.call('PermissionController:clearPermissions'); - expect(clearStateSpy).toHaveBeenCalledTimes(1); + messenger.call('PermissionController:clearState'); + expect(messenger.call).toHaveBeenCalledTimes(1); + expect(messenger.call).toHaveBeenNthCalledWith( + 1, + 'PermissionController:clearState', + ); expect(controller.state).toStrictEqual({ subjects: {} }); }); it('action: PermissionController:getEndowments', async () => { - const messenger = getUnrestrictedMessenger(); + const messenger = getRootMessenger(); + jest.spyOn(messenger, 'call'); + const options = getPermissionControllerOptions({ messenger: getPermissionControllerMessenger(messenger), }); @@ -4738,7 +5670,6 @@ describe('PermissionController', () => { DefaultPermissionSpecifications, DefaultCaveatSpecifications >(options); - const getEndowmentsSpy = jest.spyOn(controller, 'getEndowments'); await expect( messenger.call( @@ -4779,23 +5710,24 @@ describe('PermissionController', () => { ), ).toStrictEqual(['endowment1']); - expect(getEndowmentsSpy).toHaveBeenCalledTimes(3); - expect(getEndowmentsSpy).toHaveBeenNthCalledWith( + expect(messenger.call).toHaveBeenCalledTimes(3); + expect(messenger.call).toHaveBeenNthCalledWith( 1, + 'PermissionController:getEndowments', 'foo', PermissionNames.endowmentAnySubject, - undefined, ); - expect(getEndowmentsSpy).toHaveBeenNthCalledWith( + expect(messenger.call).toHaveBeenNthCalledWith( 2, + 'PermissionController:getEndowments', 'foo', PermissionNames.endowmentAnySubject, - undefined, ); - expect(getEndowmentsSpy).toHaveBeenNthCalledWith( + expect(messenger.call).toHaveBeenNthCalledWith( 3, + 'PermissionController:getEndowments', 'foo', PermissionNames.endowmentAnySubject, { arbitrary: 'requestData' }, @@ -4803,7 +5735,9 @@ describe('PermissionController', () => { }); it('action: PermissionController:getSubjectNames', () => { - const messenger = getUnrestrictedMessenger(); + const messenger = getRootMessenger(); + jest.spyOn(messenger, 'call'); + const options = getPermissionControllerOptions({ messenger: getPermissionControllerMessenger(messenger), }); @@ -4811,7 +5745,6 @@ describe('PermissionController', () => { DefaultPermissionSpecifications, DefaultCaveatSpecifications >(options); - const getSubjectNamesSpy = jest.spyOn(controller, 'getSubjectNames'); expect( messenger.call('PermissionController:getSubjectNames'), @@ -4827,11 +5760,21 @@ describe('PermissionController', () => { expect( messenger.call('PermissionController:getSubjectNames'), ).toStrictEqual(['foo']); - expect(getSubjectNamesSpy).toHaveBeenCalledTimes(2); + expect(messenger.call).toHaveBeenCalledTimes(2); + expect(messenger.call).toHaveBeenNthCalledWith( + 1, + 'PermissionController:getSubjectNames', + ); + expect(messenger.call).toHaveBeenNthCalledWith( + 2, + 'PermissionController:getSubjectNames', + ); }); it('action: PermissionController:hasPermission', () => { - const messenger = getUnrestrictedMessenger(); + const messenger = getRootMessenger(); + jest.spyOn(messenger, 'call'); + const options = getPermissionControllerOptions({ messenger: getPermissionControllerMessenger(messenger), }); @@ -4839,7 +5782,6 @@ describe('PermissionController', () => { DefaultPermissionSpecifications, DefaultCaveatSpecifications >(options); - const hasPermissionSpy = jest.spyOn(controller, 'hasPermission'); expect( messenger.call( @@ -4872,28 +5814,33 @@ describe('PermissionController', () => { ), ).toBe(false); - expect(hasPermissionSpy).toHaveBeenCalledTimes(3); - expect(hasPermissionSpy).toHaveBeenNthCalledWith( + expect(messenger.call).toHaveBeenCalledTimes(3); + expect(messenger.call).toHaveBeenNthCalledWith( 1, + 'PermissionController:hasPermission', 'foo', PermissionNames.wallet_getSecretArray, ); - expect(hasPermissionSpy).toHaveBeenNthCalledWith( + expect(messenger.call).toHaveBeenNthCalledWith( 2, + 'PermissionController:hasPermission', 'foo', PermissionNames.wallet_getSecretArray, ); - expect(hasPermissionSpy).toHaveBeenNthCalledWith( + expect(messenger.call).toHaveBeenNthCalledWith( 3, + 'PermissionController:hasPermission', 'foo', PermissionNames.wallet_getSecretObject, ); }); it('action: PermissionController:hasPermissions', () => { - const messenger = getUnrestrictedMessenger(); + const messenger = getRootMessenger(); + jest.spyOn(messenger, 'call'); + const options = getPermissionControllerOptions({ messenger: getPermissionControllerMessenger(messenger), }); @@ -4901,7 +5848,6 @@ describe('PermissionController', () => { DefaultPermissionSpecifications, DefaultCaveatSpecifications >(options); - const hasPermissionsSpy = jest.spyOn(controller, 'hasPermissions'); expect(messenger.call('PermissionController:hasPermissions', 'foo')).toBe( false, @@ -4917,13 +5863,23 @@ describe('PermissionController', () => { expect(messenger.call('PermissionController:hasPermissions', 'foo')).toBe( true, ); - expect(hasPermissionsSpy).toHaveBeenCalledTimes(2); - expect(hasPermissionsSpy).toHaveBeenNthCalledWith(1, 'foo'); - expect(hasPermissionsSpy).toHaveBeenNthCalledWith(2, 'foo'); + expect(messenger.call).toHaveBeenCalledTimes(2); + expect(messenger.call).toHaveBeenNthCalledWith( + 1, + 'PermissionController:hasPermissions', + 'foo', + ); + expect(messenger.call).toHaveBeenNthCalledWith( + 2, + 'PermissionController:hasPermissions', + 'foo', + ); }); it('action: PermissionController:getPermissions', () => { - const messenger = getUnrestrictedMessenger(); + const messenger = getRootMessenger(); + jest.spyOn(messenger, 'call'); + const options = getPermissionControllerOptions({ messenger: getPermissionControllerMessenger(messenger), }); @@ -4931,7 +5887,6 @@ describe('PermissionController', () => { DefaultPermissionSpecifications, DefaultCaveatSpecifications >(options); - const getPermissionsSpy = jest.spyOn(controller, 'getPermissions'); expect( messenger.call('PermissionController:getPermissions', 'foo'), @@ -4951,13 +5906,23 @@ describe('PermissionController', () => { ), ).toStrictEqual(['wallet_getSecretArray']); - expect(getPermissionsSpy).toHaveBeenCalledTimes(3); - expect(getPermissionsSpy).toHaveBeenNthCalledWith(1, 'foo'); - expect(getPermissionsSpy).toHaveBeenNthCalledWith(2, 'foo'); + expect(messenger.call).toHaveBeenCalledTimes(2); + expect(messenger.call).toHaveBeenNthCalledWith( + 1, + 'PermissionController:getPermissions', + 'foo', + ); + expect(messenger.call).toHaveBeenNthCalledWith( + 2, + 'PermissionController:getPermissions', + 'foo', + ); }); it('action: PermissionController:revokeAllPermissions', () => { - const messenger = getUnrestrictedMessenger(); + const messenger = getRootMessenger(); + jest.spyOn(messenger, 'call'); + const options = getPermissionControllerOptions({ messenger: getPermissionControllerMessenger(messenger), }); @@ -4972,10 +5937,6 @@ describe('PermissionController', () => { wallet_getSecretArray: {}, }, }); - const revokeAllPermissionsSpy = jest.spyOn( - controller, - 'revokeAllPermissions', - ); expect(controller.hasPermission('foo', 'wallet_getSecretArray')).toBe( true, @@ -4986,12 +5947,18 @@ describe('PermissionController', () => { expect(controller.hasPermission('foo', 'wallet_getSecretArray')).toBe( false, ); - expect(revokeAllPermissionsSpy).toHaveBeenCalledTimes(1); - expect(revokeAllPermissionsSpy).toHaveBeenNthCalledWith(1, 'foo'); + expect(messenger.call).toHaveBeenCalledTimes(1); + expect(messenger.call).toHaveBeenNthCalledWith( + 1, + 'PermissionController:revokeAllPermissions', + 'foo', + ); }); it('action: PermissionController:revokePermissionForAllSubjects', () => { - const messenger = getUnrestrictedMessenger(); + const messenger = getRootMessenger(); + jest.spyOn(messenger, 'call'); + const options = getPermissionControllerOptions({ messenger: getPermissionControllerMessenger(messenger), }); @@ -5006,10 +5973,6 @@ describe('PermissionController', () => { wallet_getSecretArray: {}, }, }); - const revokePermissionForAllSubjectsSpy = jest.spyOn( - controller, - 'revokePermissionForAllSubjects', - ); expect(controller.hasPermission('foo', 'wallet_getSecretArray')).toBe( true, @@ -5023,15 +5986,16 @@ describe('PermissionController', () => { expect(controller.hasPermission('foo', 'wallet_getSecretArray')).toBe( false, ); - expect(revokePermissionForAllSubjectsSpy).toHaveBeenCalledTimes(1); - expect(revokePermissionForAllSubjectsSpy).toHaveBeenNthCalledWith( + expect(messenger.call).toHaveBeenCalledTimes(1); + expect(messenger.call).toHaveBeenNthCalledWith( 1, + 'PermissionController:revokePermissionForAllSubjects', 'wallet_getSecretArray', ); }); - it('action: PermissionsController:grantPermissions', async () => { - const messenger = getUnrestrictedMessenger(); + it('action: PermissionController:grantPermissions', async () => { + const messenger = getRootMessenger(); const options = getPermissionControllerOptions({ messenger: getPermissionControllerMessenger(messenger), }); @@ -5051,8 +6015,8 @@ describe('PermissionController', () => { ); }); - it('action: PermissionsController:requestPermissions', async () => { - const messenger = getUnrestrictedMessenger(); + it('action: PermissionController:grantPermissionsIncremental', async () => { + const messenger = getRootMessenger(); const options = getPermissionControllerOptions({ messenger: getPermissionControllerMessenger(messenger), }); @@ -5061,13 +6025,38 @@ describe('PermissionController', () => { DefaultCaveatSpecifications >(options); - // TODO(ritave): requestPermissions calls unregistered action ApprovalController:addRequest that - // can't be easily mocked, thus we mock the whole implementation - const requestPermissionsSpy = jest - .spyOn(controller, 'requestPermissions') - .mockImplementation(); + const result = messenger.call( + 'PermissionController:grantPermissionsIncremental', + { + subject: { origin: 'foo' }, + approvedPermissions: { wallet_getSecretArray: {} }, + }, + ); + + expect(result).toHaveProperty('wallet_getSecretArray'); + expect(controller.hasPermission('foo', 'wallet_getSecretArray')).toBe( + true, + ); + }); + + it('action: PermissionController:requestPermissions', async () => { + const messenger = getRootMessenger(); + const options = getPermissionControllerOptions({ + messenger: getPermissionControllerMessenger(messenger), + }); + + // eslint-disable-next-line no-new + new PermissionController< + DefaultPermissionSpecifications, + DefaultCaveatSpecifications + >(options); + + messenger.registerActionHandler( + 'ApprovalController:addRequest', + async ({ requestData }) => requestData, + ); - await messenger.call( + const [result] = await messenger.call( 'PermissionController:requestPermissions', { origin: 'foo' }, { @@ -5075,11 +6064,115 @@ describe('PermissionController', () => { }, ); - expect(requestPermissionsSpy).toHaveBeenCalledTimes(1); + expect(result).toHaveProperty('wallet_getSecretArray'); + }); + + it('action: PermissionController:requestPermissionsIncremental', async () => { + const messenger = getRootMessenger(); + jest.spyOn(messenger, 'call'); + + const options = getPermissionControllerOptions({ + messenger: getPermissionControllerMessenger(messenger), + }); + + // eslint-disable-next-line no-new + new PermissionController< + DefaultPermissionSpecifications, + DefaultCaveatSpecifications + >(options); + + messenger.registerActionHandler( + 'ApprovalController:addRequest', + async ({ requestData }) => requestData, + ); + + const [result] = await messenger.call( + 'PermissionController:requestPermissionsIncremental', + { origin: 'foo' }, + { + wallet_getSecretArray: {}, + }, + ); + + expect(result).toHaveProperty('wallet_getSecretArray'); + expect(messenger.call).toHaveBeenCalledWith( + 'PermissionController:requestPermissionsIncremental', + { origin: 'foo' }, + { wallet_getSecretArray: {} }, + ); }); it('action: PermissionController:updateCaveat', async () => { - const messenger = getUnrestrictedMessenger(); + const messenger = getRootMessenger(); + jest.spyOn(messenger, 'call'); + + const state = { + subjects: { + 'metamask.io': { + origin: 'metamask.io', + permissions: { + wallet_getSecretArray: { + id: 'escwEx9JrOxGZKZk3RkL4', + parentCapability: 'wallet_getSecretArray', + invoker: 'metamask.io', + caveats: [ + { type: CaveatTypes.filterArrayResponse, value: ['bar'] }, + ], + date: 1632618373085, + }, + }, + }, + }, + }; + const options = getPermissionControllerOptions({ + messenger: getPermissionControllerMessenger(messenger), + state, + }); + + const controller = new PermissionController< + DefaultPermissionSpecifications, + DefaultCaveatSpecifications + >(options); + + messenger.call( + 'PermissionController:updateCaveat', + 'metamask.io', + 'wallet_getSecretArray', + CaveatTypes.filterArrayResponse, + ['baz'], + ); + + expect(messenger.call).toHaveBeenCalledTimes(1); + expect(messenger.call).toHaveBeenNthCalledWith( + 1, + 'PermissionController:updateCaveat', + 'metamask.io', + 'wallet_getSecretArray', + CaveatTypes.filterArrayResponse, + ['baz'], + ); + expect(controller.state).toStrictEqual({ + subjects: { + 'metamask.io': { + origin: 'metamask.io', + permissions: { + wallet_getSecretArray: { + id: 'escwEx9JrOxGZKZk3RkL4', + parentCapability: 'wallet_getSecretArray', + invoker: 'metamask.io', + caveats: [ + { type: CaveatTypes.filterArrayResponse, value: ['baz'] }, + ], + date: 1632618373085, + }, + }, + }, + }, + }); + }); + + it('action: PermissionController:getCaveat', async () => { + const messenger = getRootMessenger(); const state = { subjects: { 'metamask.io': { @@ -5100,49 +6193,126 @@ describe('PermissionController', () => { }; const options = getPermissionControllerOptions({ messenger: getPermissionControllerMessenger(messenger), - state, + state, + }); + + // eslint-disable-next-line no-new + new PermissionController< + DefaultPermissionSpecifications, + DefaultCaveatSpecifications + >(options); + + const result = messenger.call( + 'PermissionController:getCaveat', + 'metamask.io', + 'wallet_getSecretArray', + CaveatTypes.filterArrayResponse, + ); + + expect(result).toBe( + state.subjects['metamask.io'].permissions.wallet_getSecretArray + .caveats[0], + ); + }); + + it('action: PermissionController:hasUnrestrictedMethod', () => { + const messenger = getRootMessenger(); + const options = getPermissionControllerOptions({ + messenger: getPermissionControllerMessenger(messenger), + }); + // eslint-disable-next-line no-new + new PermissionController< + DefaultPermissionSpecifications, + DefaultCaveatSpecifications + >(options); + + expect( + messenger.call( + 'PermissionController:hasUnrestrictedMethod', + 'wallet_unrestrictedMethod', + ), + ).toBe(true); + + expect( + messenger.call( + 'PermissionController:hasUnrestrictedMethod', + PermissionNames.wallet_getSecretArray, + ), + ).toBe(false); + + expect( + messenger.call( + 'PermissionController:hasUnrestrictedMethod', + 'wallet_unknownMethod', + ), + ).toBe(false); + }); + + it('action: PermissionController:executeRestrictedMethod', async () => { + const messenger = getRootMessenger(); + const options = getPermissionControllerOptions({ + messenger: getPermissionControllerMessenger(messenger), }); - const controller = new PermissionController< DefaultPermissionSpecifications, DefaultCaveatSpecifications >(options); + const origin = 'metamask.io'; - const updateCaveatSpy = jest.spyOn(controller, 'updateCaveat'); + controller.grantPermissions({ + subject: { origin }, + approvedPermissions: { + [PermissionNames.wallet_getSecretArray]: {}, + }, + }); - await messenger.call( - 'PermissionController:updateCaveat', - 'metamask.io', - 'wallet_getSecretArray', - CaveatTypes.filterArrayResponse, - ['baz'], + const result = await messenger.call( + 'PermissionController:executeRestrictedMethod', + origin, + PermissionNames.wallet_getSecretArray, ); - expect(updateCaveatSpy).toHaveBeenCalledTimes(1); - expect(controller.state).toStrictEqual({ - subjects: { - 'metamask.io': { - origin: 'metamask.io', - permissions: { - wallet_getSecretArray: { - id: 'escwEx9JrOxGZKZk3RkL4', - parentCapability: 'wallet_getSecretArray', - invoker: 'metamask.io', - caveats: [ - { type: CaveatTypes.filterArrayResponse, value: ['baz'] }, - ], - date: 1632618373085, - }, - }, - }, - }, - }); + expect(result).toStrictEqual(['a', 'b', 'c']); }); }); describe('permission middleware', () => { + /** + * Builds a permission controller and a messenger suitable for passing to + * `createPermissionMiddleware` that are wired to the same root messenger. + * + * @param opts - Permission controller options overrides. + * @returns The controller and the middleware messenger. + */ + const setup = ( + opts?: Record, + ): { + controller: PermissionController< + DefaultPermissionSpecifications, + DefaultCaveatSpecifications + >; + middlewareMessenger: Messenger< + 'PermissionMiddleware', + PermissionMiddlewareActions + >; + } => { + const rootMessenger = getRootMessenger(); + const controller = new PermissionController< + DefaultPermissionSpecifications, + DefaultCaveatSpecifications + >( + getPermissionControllerOptions({ + messenger: getPermissionControllerMessenger(rootMessenger), + ...opts, + }), + ); + const middlewareMessenger = + getPermissionMiddlewareMessenger(rootMessenger); + return { controller, middlewareMessenger }; + }; + it('executes a restricted method', async () => { - const controller = getDefaultPermissionController(); + const { controller, middlewareMessenger } = setup(); const origin = 'metamask.io'; controller.grantPermissions({ @@ -5153,19 +6323,25 @@ describe('PermissionController', () => { }); const engine = new JsonRpcEngine(); - engine.push(controller.createPermissionMiddleware({ origin })); + engine.push( + createPermissionMiddleware({ + messenger: middlewareMessenger, + origin, + }), + ); - const response: any = await engine.handle({ + const response = await engine.handle({ jsonrpc: '2.0', id: 1, method: PermissionNames.wallet_getSecretArray, }); + assertIsJsonRpcSuccess(response); expect(response.result).toStrictEqual(['a', 'b', 'c']); }); it('executes a restricted method with a caveat', async () => { - const controller = getDefaultPermissionController(); + const { controller, middlewareMessenger } = setup(); const origin = 'metamask.io'; controller.grantPermissions({ @@ -5178,19 +6354,25 @@ describe('PermissionController', () => { }); const engine = new JsonRpcEngine(); - engine.push(controller.createPermissionMiddleware({ origin })); + engine.push( + createPermissionMiddleware({ + messenger: middlewareMessenger, + origin, + }), + ); - const response: any = await engine.handle({ + const response = await engine.handle({ jsonrpc: '2.0', id: 1, method: PermissionNames.wallet_getSecretArray, }); + assertIsJsonRpcSuccess(response); expect(response.result).toStrictEqual(['b']); }); it('executes a restricted method with multiple caveats', async () => { - const controller = getDefaultPermissionController(); + const { controller, middlewareMessenger } = setup(); const origin = 'metamask.io'; controller.grantPermissions({ @@ -5206,66 +6388,62 @@ describe('PermissionController', () => { }); const engine = new JsonRpcEngine(); - engine.push(controller.createPermissionMiddleware({ origin })); + engine.push( + createPermissionMiddleware({ + messenger: middlewareMessenger, + origin, + }), + ); - const response: any = await engine.handle({ + const response = await engine.handle({ jsonrpc: '2.0', id: 1, method: PermissionNames.wallet_getSecretArray, }); + assertIsJsonRpcSuccess(response); expect(response.result).toStrictEqual(['c', 'a']); }); it('passes through unrestricted methods', async () => { - const controller = getDefaultPermissionController(); + const { middlewareMessenger } = setup(); const origin = 'metamask.io'; const engine = new JsonRpcEngine(); - engine.push(controller.createPermissionMiddleware({ origin })); engine.push( - ( - _req: any, - res: PendingJsonRpcResponse<'success'>, - _next: any, - end: () => any, - ) => { - res.result = 'success'; - end(); - }, + createPermissionMiddleware({ + messenger: middlewareMessenger, + origin, + }), ); + engine.push((_req, res, _next, end) => { + res.result = 'success'; + end(); + }); - const response: any = await engine.handle({ + const response = await engine.handle({ jsonrpc: '2.0', id: 1, method: 'wallet_unrestrictedMethod', }); + assertIsJsonRpcSuccess(response); expect(response.result).toBe('success'); }); - it('throws an error if the subject has an invalid "origin" property', async () => { - const controller = getDefaultPermissionController(); - - ['', null, undefined, 2].forEach((invalidOrigin) => { - expect(() => - controller.createPermissionMiddleware({ - origin: invalidOrigin as any, - }), - ).toThrow( - new Error('The subject "origin" must be a non-empty string.'), - ); - }); - }); - it('returns an error if the subject does not have the requisite permission', async () => { - const controller = getDefaultPermissionController(); + const { middlewareMessenger } = setup(); const origin = 'metamask.io'; const engine = new JsonRpcEngine(); - engine.push(controller.createPermissionMiddleware({ origin })); + engine.push( + createPermissionMiddleware({ + messenger: middlewareMessenger, + origin, + }), + ); - const request: any = { + const request: JsonRpcRequest<[]> = { jsonrpc: '2.0', id: 1, method: PermissionNames.wallet_getSecretArray, @@ -5281,18 +6459,26 @@ describe('PermissionController', () => { 'Unauthorized to perform action. Try requesting the required permission(s) first. For more information, see: https://docs.metamask.io/guide/rpc-api.html#permissions', }); - const { error }: any = await engine.handle(request); - expect(error).toMatchObject(expect.objectContaining(expectedError)); + const response = await engine.handle(request); + assertIsJsonRpcFailure(response); + expect(response.error).toMatchObject( + expect.objectContaining(expectedError), + ); }); it('returns an error if the method does not exist', async () => { - const controller = getDefaultPermissionController(); + const { middlewareMessenger } = setup(); const origin = 'metamask.io'; const engine = new JsonRpcEngine(); - engine.push(controller.createPermissionMiddleware({ origin })); + engine.push( + createPermissionMiddleware({ + messenger: middlewareMessenger, + origin, + }), + ); - const request: any = { + const request: JsonRpcRequest<[]> = { jsonrpc: '2.0', id: 1, method: 'wallet_foo', @@ -5300,29 +6486,27 @@ describe('PermissionController', () => { const expectedError = errors.methodNotFound('wallet_foo', { origin }); - const { error }: any = await engine.handle(request); + const response = await engine.handle(request); + assertIsJsonRpcFailure(response); + const { error } = response; expect(error.message).toStrictEqual(expectedError.message); - expect(error.data.cause).toBeNull(); - delete error.message; + // @ts-expect-error We do expect this property to exist. + expect(error.data?.cause).toBeNull(); + // @ts-expect-error Intentional destructive testing delete error.data.cause; expect(error).toMatchObject(expect.objectContaining(expectedError)); }); it('returns an error if the restricted method returns undefined', async () => { const permissionSpecifications = getDefaultPermissionSpecifications(); - ( - permissionSpecifications as any - ).wallet_doubleNumber.methodImplementation = () => undefined; + // @ts-expect-error Intentional destructive testing + permissionSpecifications.wallet_doubleNumber.methodImplementation = + (): undefined => undefined; - const controller = new PermissionController< - DefaultPermissionSpecifications, - DefaultCaveatSpecifications - >( - getPermissionControllerOptions({ - permissionSpecifications, - }), - ); + const { controller, middlewareMessenger } = setup({ + permissionSpecifications, + }); const origin = 'metamask.io'; controller.grantPermissions({ @@ -5333,25 +6517,301 @@ describe('PermissionController', () => { }); const engine = new JsonRpcEngine(); - engine.push(controller.createPermissionMiddleware({ origin })); + engine.push( + createPermissionMiddleware({ + messenger: middlewareMessenger, + origin, + }), + ); - const request: any = { + const request: JsonRpcRequest<[]> = { jsonrpc: '2.0', id: 1, method: PermissionNames.wallet_doubleNumber, }; - const expectedError = errors.internalError( - `Request for method "${PermissionNames.wallet_doubleNumber}" returned undefined result.`, - { request: { ...request } }, + const response = await engine.handle(request); + assertIsJsonRpcFailure(response); + const { error } = response; + + // The public `executeRestrictedMethod` throws a plain `Error` when the + // restricted method returns `undefined`; the JSON-RPC engine wraps it + // as an internal error response. + expect(error.message).toBe( + `Request for method "${PermissionNames.wallet_doubleNumber}" as origin "${origin}" returned no result.`, ); + expect(error.code).toBe(-32603); + }); - const { error }: any = await engine.handle(request); - expect(error.message).toStrictEqual(expectedError.message); - expect(error.data.cause).toBeNull(); - delete error.message; - delete error.data.cause; - expect(error).toMatchObject(expect.objectContaining(expectedError)); + describe('v2', () => { + it('executes a restricted method', async () => { + const { controller, middlewareMessenger } = setup(); + const origin = 'metamask.io'; + + controller.grantPermissions({ + subject: { origin }, + approvedPermissions: { + [PermissionNames.wallet_getSecretArray]: {}, + }, + }); + + const engine = JsonRpcEngineV2.create({ + middleware: [ + createPermissionMiddlewareV2({ + messenger: middlewareMessenger, + origin, + }), + ], + }); + + const result = await engine.handle({ + jsonrpc: '2.0', + id: 1, + method: PermissionNames.wallet_getSecretArray, + }); + + expect(result).toStrictEqual(['a', 'b', 'c']); + }); + + it('passes through unrestricted methods', async () => { + const { middlewareMessenger } = setup(); + const origin = 'metamask.io'; + + const engine = JsonRpcEngineV2.create({ + middleware: [ + createPermissionMiddlewareV2({ + messenger: middlewareMessenger, + origin, + }), + (): string => 'success', + ], + }); + + const result = await engine.handle({ + jsonrpc: '2.0', + id: 1, + method: 'wallet_unrestrictedMethod', + }); + + expect(result).toBe('success'); + }); + + it('throws if the subject does not have the requisite permission', async () => { + const { middlewareMessenger } = setup(); + const origin = 'metamask.io'; + + const engine = JsonRpcEngineV2.create({ + middleware: [ + createPermissionMiddlewareV2({ + messenger: middlewareMessenger, + origin, + }), + ], + }); + + await expect( + engine.handle({ + jsonrpc: '2.0', + id: 1, + method: PermissionNames.wallet_getSecretArray, + }), + ).rejects.toThrow( + 'Unauthorized to perform action. Try requesting the required permission(s) first.', + ); + }); + + it('executes a restricted method with a caveat', async () => { + const { controller, middlewareMessenger } = setup(); + const origin = 'metamask.io'; + + controller.grantPermissions({ + subject: { origin }, + approvedPermissions: { + [PermissionNames.wallet_getSecretArray]: { + caveats: [ + { type: CaveatTypes.filterArrayResponse, value: ['b'] }, + ], + }, + }, + }); + + const engine = JsonRpcEngineV2.create({ + middleware: [ + createPermissionMiddlewareV2({ + messenger: middlewareMessenger, + origin, + }), + ], + }); + + const result = await engine.handle({ + jsonrpc: '2.0', + id: 1, + method: PermissionNames.wallet_getSecretArray, + }); + + expect(result).toStrictEqual(['b']); + }); + + it('executes a restricted method with multiple caveats', async () => { + const { controller, middlewareMessenger } = setup(); + const origin = 'metamask.io'; + + controller.grantPermissions({ + subject: { origin }, + approvedPermissions: { + [PermissionNames.wallet_getSecretArray]: { + caveats: [ + { type: CaveatTypes.filterArrayResponse, value: ['a', 'c'] }, + { type: CaveatTypes.reverseArrayResponse, value: null }, + ], + }, + }, + }); + + const engine = JsonRpcEngineV2.create({ + middleware: [ + createPermissionMiddlewareV2({ + messenger: middlewareMessenger, + origin, + }), + ], + }); + + const result = await engine.handle({ + jsonrpc: '2.0', + id: 1, + method: PermissionNames.wallet_getSecretArray, + }); + + expect(result).toStrictEqual(['c', 'a']); + }); + + it('throws if the method does not exist', async () => { + const { middlewareMessenger } = setup(); + const origin = 'metamask.io'; + + const engine = JsonRpcEngineV2.create({ + middleware: [ + createPermissionMiddlewareV2({ + messenger: middlewareMessenger, + origin, + }), + ], + }); + + await expect( + engine.handle({ + jsonrpc: '2.0', + id: 1, + method: 'wallet_foo', + }), + ).rejects.toThrow(errors.methodNotFound('wallet_foo', { origin })); + }); + + it('throws if the restricted method returns undefined', async () => { + const permissionSpecifications = getDefaultPermissionSpecifications(); + // @ts-expect-error Intentional destructive testing + permissionSpecifications.wallet_doubleNumber.methodImplementation = + (): undefined => undefined; + + const { controller, middlewareMessenger } = setup({ + permissionSpecifications, + }); + const origin = 'metamask.io'; + + controller.grantPermissions({ + subject: { origin }, + approvedPermissions: { + [PermissionNames.wallet_doubleNumber]: {}, + }, + }); + + const engine = JsonRpcEngineV2.create({ + middleware: [ + createPermissionMiddlewareV2({ + messenger: middlewareMessenger, + origin, + }), + ], + }); + + await expect( + engine.handle({ + jsonrpc: '2.0', + id: 1, + method: PermissionNames.wallet_doubleNumber, + }), + ).rejects.toThrow( + `Request for method "${PermissionNames.wallet_doubleNumber}" as origin "${origin}" returned no result.`, + ); + }); + }); + }); + + describe('metadata', () => { + it('includes expected state in debug snapshots', () => { + const controller = getDefaultPermissionController(); + + expect( + deriveStateFromMetadata( + controller.state, + controller.metadata, + 'includeInDebugSnapshot', + ), + ).toMatchInlineSnapshot(` + { + "subjects": {}, + } + `); + }); + + it('includes expected state in state logs', () => { + const controller = getDefaultPermissionController(); + + expect( + deriveStateFromMetadata( + controller.state, + controller.metadata, + 'includeInStateLogs', + ), + ).toMatchInlineSnapshot(` + { + "subjects": {}, + } + `); + }); + + it('persists expected state', () => { + const controller = getDefaultPermissionController(); + + expect( + deriveStateFromMetadata( + controller.state, + controller.metadata, + 'persist', + ), + ).toMatchInlineSnapshot(` + { + "subjects": {}, + } + `); + }); + + it('exposes expected state to UI', () => { + const controller = getDefaultPermissionController(); + + expect( + deriveStateFromMetadata( + controller.state, + controller.metadata, + 'usedInUi', + ), + ).toMatchInlineSnapshot(` + { + "subjects": {}, + } + `); }); }); }); diff --git a/packages/permission-controller/src/PermissionController.ts b/packages/permission-controller/src/PermissionController.ts index 5bebdb970a8..7671eaa7ae6 100644 --- a/packages/permission-controller/src/PermissionController.ts +++ b/packages/permission-controller/src/PermissionController.ts @@ -1,47 +1,54 @@ -/* eslint-enable @typescript-eslint/no-unused-vars */ import type { - AcceptRequest as AcceptApprovalRequest, - AddApprovalRequest, - HasApprovalRequest, - RejectRequest as RejectApprovalRequest, + ApprovalControllerAcceptRequestAction, + ApprovalControllerAddRequestAction, + ApprovalControllerHasRequestAction, + ApprovalControllerRejectRequestAction, } from '@metamask/approval-controller'; import type { StateMetadata, - RestrictedControllerMessenger, - ActionConstraint, - EventConstraint, + ControllerGetStateAction, + ControllerStateChangeEvent, } from '@metamask/base-controller'; -import { BaseControllerV2 } from '@metamask/base-controller'; +import { BaseController } from '@metamask/base-controller'; import type { NonEmptyArray } from '@metamask/controller-utils'; import { isNonEmptyArray, isPlainObject, isValidJson, } from '@metamask/controller-utils'; +import type { + Messenger, + ActionConstraint, + EventConstraint, +} from '@metamask/messenger'; import { JsonRpcError } from '@metamask/rpc-errors'; import { hasProperty } from '@metamask/utils'; import type { Json, Mutable } from '@metamask/utils'; import deepFreeze from 'deep-freeze-strict'; -import { castDraft } from 'immer'; -import type { Draft, Patch } from 'immer'; +import { castDraft, produce as immerProduce } from 'immer'; +import type { Draft } from 'immer'; import { nanoid } from 'nanoid'; import type { CaveatConstraint, + CaveatDiffMap, CaveatSpecificationConstraint, CaveatSpecificationMap, + CaveatValueMerger, ExtractCaveat, ExtractCaveats, ExtractCaveatValue, -} from './Caveat'; +} from './Caveat.js'; import { decorateWithCaveats, isRestrictedMethodCaveatSpecification, -} from './Caveat'; +} from './Caveat.js'; import { CaveatAlreadyExistsError, CaveatDoesNotExistError, CaveatInvalidJsonError, + CaveatMergerDoesNotExistError, + CaveatMergeTypeMismatchError, CaveatMissingValueError, CaveatSpecificationMismatchError, DuplicateCaveatError, @@ -53,6 +60,7 @@ import { InvalidCaveatFieldsError, InvalidCaveatsPropertyError, InvalidCaveatTypeError, + InvalidMergedPermissionsError, invalidParams, InvalidSubjectIdentifierError, methodNotFound, @@ -62,7 +70,7 @@ import { UnrecognizedCaveatTypeError, UnrecognizedSubjectError, userRejectedRequest, -} from './errors'; +} from './errors.js'; import type { EndowmentSpecificationConstraint, ExtractAllowedCaveatTypes, @@ -78,16 +86,24 @@ import type { SideEffectHandler, ValidPermission, ValidPermissionSpecification, -} from './Permission'; +} from './Permission.js'; import { constructPermission, findCaveat, hasSpecificationType, PermissionType, -} from './Permission'; -import { getPermissionMiddlewareFactory } from './permission-middleware'; -import type { GetSubjectMetadata } from './SubjectMetadataController'; -import { MethodNames } from './utils'; +} from './Permission.js'; +import type { PermissionControllerMethodActions } from './PermissionController-method-action-types.js'; +import type { SubjectMetadataControllerGetSubjectMetadataAction } from './SubjectMetadataController-method-action-types.js'; +import { collectUniqueAndPairedCaveats, MethodNames } from './utils.js'; + +/** + * Flags for controlling the validation behavior of certain internal methods. + */ +type PermissionValidationFlags = { + invokePermissionValidator: boolean; + performCaveatValidation: boolean; +}; /** * Metadata associated with {@link PermissionController} subjects. @@ -101,25 +117,55 @@ export type PermissionSubjectMetadata = { */ export type PermissionsRequestMetadata = PermissionSubjectMetadata & { id: string; + [key: string]: Json; }; +/** + * A diff produced by an incremental permissions request. + */ +export type PermissionDiffMap< + TargetName extends string, + AllowedCaveats extends CaveatConstraint, +> = Record>; + /** * Used for prompting the user about a proposed new permission. - * Includes information about the grantee subject, requested permissions, and - * any additional information added by the consumer. + * Includes information about the grantee subject, requested permissions, the + * diff relative to the previously granted permissions (if relevant), and any + * additional information added by the consumer. * - * All properties except `permissions` are passed to any factories found for - * the requested permissions. + * All properties except `diff` and `permissions` are passed to any factories + * for the requested permissions. */ export type PermissionsRequest = { metadata: PermissionsRequestMetadata; permissions: RequestedPermissions; [key: string]: Json; +} & { + diff?: { + currentPermissions: SubjectPermissions; + permissionDiffMap: PermissionDiffMap; + }; +}; + +/** + * Metadata associated with an approved permission request. + */ +type ApprovedPermissionsMetadata = { + data?: Record; + id: string; + origin: OriginString; }; export type SideEffects = { - permittedHandlers: Record>; - failureHandlers: Record>; + permittedHandlers: Record< + string, + SideEffectHandler + >; + failureHandlers: Record< + string, + SideEffectHandler + >; }; /** @@ -127,6 +173,31 @@ export type SideEffects = { */ const controllerName = 'PermissionController'; +const MESSENGER_EXPOSED_METHODS = [ + 'clearState', + 'executeRestrictedMethod', + 'getEndowments', + 'getSubjectNames', + 'getPermissions', + 'hasPermission', + 'hasPermissions', + 'hasUnrestrictedMethod', + 'grantPermissions', + 'grantPermissionsIncremental', + 'requestPermissions', + 'requestPermissionsIncremental', + 'revokeAllPermissions', + 'revokePermissionForAllSubjects', + 'revokePermissions', + 'updateCaveat', + 'getCaveat', + 'acceptPermissionsRequest', + 'rejectPermissionsRequest', + 'revokePermission', + 'updatePermissionsByCaveat', + 'getPermission', +] as const; + /** * Permissions associated with a {@link PermissionController} subject. */ @@ -156,7 +227,6 @@ export type PermissionControllerSubjects< PermissionSubjectEntry >; -// TODO:TS4.4 Enable compiler flags to forbid unchecked member access /** * The state of a {@link PermissionController}. * @@ -175,10 +245,17 @@ export type PermissionControllerState = * @template Permission - The controller's permission type union. * @returns The state metadata */ -function getStateMetadata() { - return { subjects: { anonymous: true, persist: true } } as StateMetadata< - PermissionControllerState - >; +function getStateMetadata< + Permission extends PermissionConstraint, +>(): StateMetadata> { + return { + subjects: { + includeInStateLogs: true, + includeInDebugSnapshot: true, + persist: true, + usedInUi: true, + }, + } as StateMetadata>; } /** @@ -187,20 +264,31 @@ function getStateMetadata() { * @template Permission - The controller's permission type union. * @returns The default state of the controller */ -function getDefaultState() { +function getDefaultState< + Permission extends PermissionConstraint, +>(): PermissionControllerState { return { subjects: {} } as PermissionControllerState; } /** * Gets the state of the {@link PermissionController}. */ -export type GetPermissionControllerState = { - type: `${typeof controllerName}:getState`; - handler: () => PermissionControllerState; -}; +export type PermissionControllerGetStateAction = ControllerGetStateAction< + typeof controllerName, + PermissionControllerState +>; + +/** + * Gets the state of the {@link PermissionController}. + * + * @deprecated Use `PermissionControllerGetStateAction` instead. + */ +export type GetPermissionControllerState = PermissionControllerGetStateAction; /** * Gets the names of all subjects from the {@link PermissionController}. + * + * @deprecated Use `PermissionControllerGetSubjectNamesAction` instead. */ export type GetSubjects = { type: `${typeof controllerName}:getSubjectNames`; @@ -208,7 +296,9 @@ export type GetSubjects = { }; /** - * Gets the permissions for specified subject + * Gets the permissions for specified subject. + * + * @deprecated Use `PermissionControllerGetPermissionsAction` instead. */ export type GetPermissions = { type: `${typeof controllerName}:getPermissions`; @@ -217,6 +307,8 @@ export type GetPermissions = { /** * Checks whether the specified subject has any permissions. + * + * @deprecated Use `PermissionControllerHasPermissionAction` instead. */ export type HasPermissions = { type: `${typeof controllerName}:hasPermissions`; @@ -225,6 +317,8 @@ export type HasPermissions = { /** * Checks whether the specified subject has a specific permission. + * + * @deprecated Use `PermissionControllerHasPermissionAction` instead. */ export type HasPermission = { type: `${typeof controllerName}:hasPermission`; @@ -232,7 +326,9 @@ export type HasPermission = { }; /** - * Directly grants given permissions for a specificed origin without requesting user approval + * Directly grants given permissions for a specified origin without requesting user approval. + * + * @deprecated Use `PermissionControllerGrantPermissionsAction` instead. */ export type GrantPermissions = { type: `${typeof controllerName}:grantPermissions`; @@ -240,15 +336,40 @@ export type GrantPermissions = { }; /** - * Requests given permissions for a specified origin + * Directly grants given permissions for a specified origin without requesting user approval. + * + * @deprecated Use `PermissionControllerGrantPermissionsIncrementalAction` + * instead. + */ +export type GrantPermissionsIncremental = { + type: `${typeof controllerName}:grantPermissionsIncremental`; + handler: GenericPermissionController['grantPermissionsIncremental']; +}; + +/** + * Requests given permissions for a specified origin. + * + * @deprecated Use `PermissionControllerRequestPermissionsAction` instead. */ export type RequestPermissions = { type: `${typeof controllerName}:requestPermissions`; handler: GenericPermissionController['requestPermissions']; }; +/** + * Requests given permissions for a specified origin. + * + * @deprecated Use `PermissionControllerRequestPermissionsAction` instead. + */ +export type RequestPermissionsIncremental = { + type: `${typeof controllerName}:requestPermissionsIncremental`; + handler: GenericPermissionController['requestPermissionsIncremental']; +}; + /** * Removes the specified permissions for each origin. + * + * @deprecated Use `PermissionControllerRevokePermissionsAction` instead. */ export type RevokePermissions = { type: `${typeof controllerName}:revokePermissions`; @@ -256,7 +377,9 @@ export type RevokePermissions = { }; /** - * Removes all permissions for a given origin + * Removes all permissions for a given origin. + * + * @deprecated Use `PermissionControllerRevokeAllPermissionAction` instead. */ export type RevokeAllPermissions = { type: `${typeof controllerName}:revokeAllPermissions`; @@ -266,6 +389,9 @@ export type RevokeAllPermissions = { /** * Revokes all permissions corresponding to the specified target for all subjects. * Does nothing if no subjects or no such permission exists. + * + * @deprecated Use `PermissionControllerRevokePermissionForAllSubjectsAction` + * instead. */ export type RevokePermissionForAllSubjects = { type: `${typeof controllerName}:revokePermissionForAllSubjects`; @@ -274,14 +400,28 @@ export type RevokePermissionForAllSubjects = { /** * Updates a caveat value for a specified caveat type belonging to a specific target and origin. + * + * @deprecated Use `PermissionControllerUpdateCaveatAction` instead. */ export type UpdateCaveat = { type: `${typeof controllerName}:updateCaveat`; handler: GenericPermissionController['updateCaveat']; }; +/** + * Get a caveat value for a specified caveat type belonging to a specific target and origin. + * + * @deprecated Use `PermissionControllerGetCaveatAction` instead. + */ +export type GetCaveat = { + type: `${typeof controllerName}:getCaveat`; + handler: GenericPermissionController['getCaveat']; +}; + /** * Clears all permissions from the {@link PermissionController}. + * + * @deprecated Use `PermissionControllerClearStateAction` instead. */ export type ClearPermissions = { type: `${typeof controllerName}:clearPermissions`; @@ -290,6 +430,8 @@ export type ClearPermissions = { /** * Gets the endowments for the given subject and permission. + * + * @deprecated Use `PermissionControllerGetEndowmentsAction` instead. */ export type GetEndowments = { type: `${typeof controllerName}:getEndowments`; @@ -297,33 +439,22 @@ export type GetEndowments = { }; /** - * The {@link ControllerMessenger} actions of the {@link PermissionController}. + * The {@link Messenger} actions of the {@link PermissionController}. */ export type PermissionControllerActions = - | ClearPermissions - | GetEndowments - | GetPermissionControllerState - | GetSubjects - | GetPermissions - | HasPermission - | HasPermissions - | GrantPermissions - | RequestPermissions - | RevokeAllPermissions - | RevokePermissionForAllSubjects - | RevokePermissions - | UpdateCaveat; + | PermissionControllerGetStateAction + | PermissionControllerMethodActions; /** * The generic state change event of the {@link PermissionController}. */ -export type PermissionControllerStateChange = { - type: `${typeof controllerName}:stateChange`; - payload: [PermissionControllerState, Patch[]]; -}; +export type PermissionControllerStateChange = ControllerStateChangeEvent< + typeof controllerName, + PermissionControllerState +>; /** - * The {@link ControllerMessenger} events of the {@link PermissionController}. + * The {@link Messenger} events of the {@link PermissionController}. * * The permission controller only emits its generic state change events. * Consumers should use selector subscriptions to subscribe to relevant @@ -332,37 +463,29 @@ export type PermissionControllerStateChange = { export type PermissionControllerEvents = PermissionControllerStateChange; /** - * The external {@link ControllerMessenger} actions available to the + * The external {@link Messenger} actions available to the * {@link PermissionController}. */ type AllowedActions = - | AddApprovalRequest - | HasApprovalRequest - | AcceptApprovalRequest - | RejectApprovalRequest - | GetSubjectMetadata; + | ApprovalControllerAddRequestAction + | ApprovalControllerHasRequestAction + | ApprovalControllerAcceptRequestAction + | ApprovalControllerRejectRequestAction + | SubjectMetadataControllerGetSubjectMetadataAction; /** * The messenger of the {@link PermissionController}. */ -export type PermissionControllerMessenger = RestrictedControllerMessenger< +export type PermissionControllerMessenger = Messenger< typeof controllerName, PermissionControllerActions | AllowedActions, - PermissionControllerEvents, - AllowedActions['type'], - never + PermissionControllerEvents >; export type SideEffectMessenger< Actions extends ActionConstraint, Events extends EventConstraint, -> = RestrictedControllerMessenger< - typeof controllerName, - Actions, - Events, - string, - never ->; +> = Messenger; /** * A generic {@link PermissionController}. @@ -376,10 +499,10 @@ export type GenericPermissionController = PermissionController< * Describes the possible results of a {@link CaveatMutator} function. */ export enum CaveatMutatorOperation { - noop, - updateValue, - deleteCaveat, - revokePermission, + Noop = 0, + UpdateValue = 1, + DeleteCaveat = 2, + RevokePermission = 3, } /** @@ -398,16 +521,21 @@ export type CaveatMutator = ( type CaveatMutatorResult = | Readonly<{ - operation: CaveatMutatorOperation.updateValue; + operation: CaveatMutatorOperation.UpdateValue; value: CaveatConstraint['value']; }> | Readonly<{ operation: Exclude< CaveatMutatorOperation, - CaveatMutatorOperation.updateValue + CaveatMutatorOperation.UpdateValue >; }>; +type MergeCaveatResult = + CaveatType extends undefined + ? [CaveatConstraint, CaveatConstraint['value']] + : [CaveatConstraint, CaveatConstraint['value']] | []; + /** * Extracts the permission(s) specified by the given permission and caveat * specifications. @@ -421,12 +549,13 @@ type CaveatMutatorResult = export type ExtractPermission< ControllerPermissionSpecification extends PermissionSpecificationConstraint, ControllerCaveatSpecification extends CaveatSpecificationConstraint, -> = ControllerPermissionSpecification extends ValidPermissionSpecification - ? ValidPermission< - ControllerPermissionSpecification['targetName'], - ExtractCaveats - > - : never; +> = + ControllerPermissionSpecification extends ValidPermissionSpecification + ? ValidPermission< + ControllerPermissionSpecification['targetName'], + ExtractCaveats + > + : never; /** * Extracts the restricted method permission(s) specified by the given @@ -499,7 +628,7 @@ export type PermissionControllerOptions< * document for details. * * Assumes the existence of an {@link ApprovalController} reachable via the - * {@link ControllerMessenger}. + * {@link Messenger}. * * @template ControllerPermissionSpecification - A union of the types of all * permission specifications available to the controller. Any referenced caveats @@ -508,9 +637,11 @@ export type PermissionControllerOptions< * caveat specifications available to the controller. */ export class PermissionController< - ControllerPermissionSpecification extends PermissionSpecificationConstraint, - ControllerCaveatSpecification extends CaveatSpecificationConstraint, -> extends BaseControllerV2< + ControllerPermissionSpecification extends PermissionSpecificationConstraint = + PermissionSpecificationConstraint, + ControllerCaveatSpecification extends CaveatSpecificationConstraint = + CaveatSpecificationConstraint, +> extends BaseController< typeof controllerName, PermissionControllerState< ExtractPermission< @@ -520,15 +651,15 @@ export class PermissionController< >, PermissionControllerMessenger > { - private readonly _caveatSpecifications: Readonly< + readonly #caveatSpecifications: Readonly< CaveatSpecificationMap >; - private readonly _permissionSpecifications: Readonly< + readonly #permissionSpecifications: Readonly< PermissionSpecificationMap >; - private readonly _unrestrictedMethods: ReadonlySet; + readonly #unrestrictedMethods: ReadonlySet; /** * The names of all JSON-RPC methods that will be ignored by the controller. @@ -536,20 +667,21 @@ export class PermissionController< * @returns The names of all unrestricted JSON-RPC methods */ public get unrestrictedMethods(): ReadonlySet { - return this._unrestrictedMethods; + return this.#unrestrictedMethods; } /** - * Returns a `json-rpc-engine` middleware function factory, so that the rules - * described by the state of this controller can be applied to incoming - * JSON-RPC requests. + * Checks whether the given method was declared as unrestricted at + * construction time. Methods unknown to the controller return `false` and + * would be treated as restricted by callers such as the permission + * middleware. * - * The middleware **must** be added in the correct place in the middleware - * stack in order for it to work. See the README for an example. + * @param method - The name of the method to check. + * @returns Whether the method is unrestricted. */ - public createPermissionMiddleware: ReturnType< - typeof getPermissionMiddlewareFactory - >; + hasUnrestrictedMethod(method: string): boolean { + return this.#unrestrictedMethods.has(method); + } /** * Constructs the PermissionController. @@ -563,8 +695,8 @@ export class PermissionController< * {@link PermissionSpecificationMap} and the README for more details. * @param options.unrestrictedMethods - The callable names of all JSON-RPC * methods ignored by the new controller. - * @param options.messenger - The controller messenger. See - * {@link BaseControllerV2} for more information. + * @param options.messenger - The messenger. See + * {@link BaseController} for more information. * @param options.state - Existing state to hydrate the controller with at * initialization. */ @@ -603,26 +735,22 @@ export class PermissionController< }, }); - this._unrestrictedMethods = new Set(unrestrictedMethods); - this._caveatSpecifications = deepFreeze({ ...caveatSpecifications }); + this.#unrestrictedMethods = new Set(unrestrictedMethods); + this.#caveatSpecifications = deepFreeze({ ...caveatSpecifications }); - this.validatePermissionSpecifications( + this.#validatePermissionSpecifications( permissionSpecifications, - this._caveatSpecifications, + this.#caveatSpecifications, ); - this._permissionSpecifications = deepFreeze({ + this.#permissionSpecifications = deepFreeze({ ...permissionSpecifications, }); - this.registerMessageHandlers(); - this.createPermissionMiddleware = getPermissionMiddlewareFactory({ - executeRestrictedMethod: this._executeRestrictedMethod.bind(this), - getRestrictedMethod: this.getRestrictedMethod.bind(this), - isUnrestrictedMethod: this.unrestrictedMethods.has.bind( - this.unrestrictedMethods, - ), - }); + this.messenger.registerMethodActionHandlers( + this, + MESSENGER_EXPOSED_METHODS, + ); } /** @@ -631,7 +759,7 @@ export class PermissionController< * @param targetName - The name of the permission specification to get. * @returns The permission specification with the specified target name. */ - private getPermissionSpecification< + #getPermissionSpecification< TargetName extends ControllerPermissionSpecification['targetName'], >( targetName: TargetName, @@ -639,7 +767,7 @@ export class PermissionController< ControllerPermissionSpecification, TargetName > { - return this._permissionSpecifications[targetName]; + return this.#permissionSpecifications[targetName]; } /** @@ -648,10 +776,28 @@ export class PermissionController< * @param caveatType - The type of the caveat specification to get. * @returns The caveat specification with the specified type. */ - private getCaveatSpecification< + #getCaveatSpecification< CaveatType extends ControllerCaveatSpecification['type'], - >(caveatType: CaveatType) { - return this._caveatSpecifications[caveatType]; + >(caveatType: CaveatType): ControllerCaveatSpecification { + return this.#caveatSpecifications[caveatType]; + } + + /** + * Gets the merger function for the specified caveat. Throws if no + * merger exists. + * + * @param caveatType - The type of the caveat whose merger to get. + * @returns The caveat value merger function for the specified caveat type. + */ + #expectGetCaveatMerger< + CaveatType extends ControllerCaveatSpecification['type'], + >(caveatType: CaveatType): CaveatValueMerger { + const { merger } = this.#getCaveatSpecification(caveatType); + + if (merger === undefined) { + throw new CaveatMergerDoesNotExistError(caveatType); + } + return merger; } /** @@ -664,10 +810,10 @@ export class PermissionController< * @param caveatSpecifications - The caveat specifications passed to this * controller. */ - private validatePermissionSpecifications( + #validatePermissionSpecifications( permissionSpecifications: PermissionSpecificationMap, caveatSpecifications: CaveatSpecificationMap, - ) { + ): void { Object.entries( permissionSpecifications, ).forEach( @@ -719,87 +865,6 @@ export class PermissionController< ); } - /** - * Constructor helper for registering the controller's messaging system - * actions. - */ - private registerMessageHandlers(): void { - this.messagingSystem.registerActionHandler( - `${controllerName}:clearPermissions` as const, - () => this.clearState(), - ); - - this.messagingSystem.registerActionHandler( - `${controllerName}:getEndowments` as const, - (origin: string, targetName: string, requestData?: unknown) => - this.getEndowments(origin, targetName, requestData), - ); - - this.messagingSystem.registerActionHandler( - `${controllerName}:getSubjectNames` as const, - () => this.getSubjectNames(), - ); - - this.messagingSystem.registerActionHandler( - `${controllerName}:getPermissions` as const, - (origin: OriginString) => this.getPermissions(origin), - ); - - this.messagingSystem.registerActionHandler( - `${controllerName}:hasPermission` as const, - (origin: OriginString, targetName: string) => - this.hasPermission(origin, targetName), - ); - - this.messagingSystem.registerActionHandler( - `${controllerName}:hasPermissions` as const, - (origin: OriginString) => this.hasPermissions(origin), - ); - - this.messagingSystem.registerActionHandler( - `${controllerName}:grantPermissions` as const, - this.grantPermissions.bind(this), - ); - - this.messagingSystem.registerActionHandler( - `${controllerName}:requestPermissions` as const, - (subject: PermissionSubjectMetadata, permissions: RequestedPermissions) => - this.requestPermissions(subject, permissions), - ); - - this.messagingSystem.registerActionHandler( - `${controllerName}:revokeAllPermissions` as const, - (origin: OriginString) => this.revokeAllPermissions(origin), - ); - - this.messagingSystem.registerActionHandler( - `${controllerName}:revokePermissionForAllSubjects` as const, - ( - target: ExtractPermission< - ControllerPermissionSpecification, - ControllerCaveatSpecification - >['parentCapability'], - ) => this.revokePermissionForAllSubjects(target), - ); - - this.messagingSystem.registerActionHandler( - `${controllerName}:revokePermissions` as const, - this.revokePermissions.bind(this), - ); - - this.messagingSystem.registerActionHandler( - `${controllerName}:updateCaveat` as const, - (origin, target, caveatType, caveatValue) => { - this.updateCaveat( - origin, - target, - caveatType as ExtractAllowedCaveatTypes, - caveatValue, - ); - }, - ); - } - /** * Clears the state of the controller. */ @@ -825,32 +890,29 @@ export class PermissionController< * @template Type - The type of the permission specification to get. * @param permissionType - The type of the permission specification to get. * @param targetName - The name of the permission whose specification to get. - * @param requestingOrigin - The origin of the requesting subject, if any. - * Will be added to any thrown errors. + * @param requestingOrigin - The origin of the requesting subject. Will be + * added to any thrown errors. * @returns The specification object corresponding to the given type and * target name. */ - private getTypedPermissionSpecification( + #getTypedPermissionSpecification( permissionType: Type, targetName: string, - requestingOrigin?: string, + requestingOrigin: string, ): ControllerPermissionSpecification & { permissionType: Type } { const failureError = permissionType === PermissionType.RestrictedMethod - ? methodNotFound( - targetName, - requestingOrigin ? { origin: requestingOrigin } : undefined, - ) + ? methodNotFound(targetName, { origin: requestingOrigin }) : new EndowmentPermissionDoesNotExistError( targetName, requestingOrigin, ); - if (!this.targetExists(targetName)) { + if (!this.#targetExists(targetName)) { throw failureError; } - const specification = this.getPermissionSpecification(targetName); + const specification = this.#getPermissionSpecification(targetName); if (!hasSpecificationType(specification, permissionType)) { throw failureError; } @@ -863,18 +925,17 @@ export class PermissionController< * * A JSON-RPC error is thrown if the method does not exist. * - * @see {@link PermissionController.executeRestrictedMethod} and - * {@link PermissionController.createPermissionMiddleware} for internal usage. + * @see {@link PermissionController.executeRestrictedMethod} for internal usage. * @param method - The name of the restricted method. * @param origin - The origin associated with the request for the restricted - * method, if any. + * method. * @returns The restricted method implementation. */ - getRestrictedMethod( + #getRestrictedMethod( method: string, - origin?: string, + origin: string, ): RestrictedMethod { - return this.getTypedPermissionSpecification( + return this.#getTypedPermissionSpecification( PermissionType.RestrictedMethod, method, origin, @@ -961,7 +1022,7 @@ export class PermissionController< /** * Revokes all permissions from the specified origin. * - * Throws an error of the origin has no permissions. + * Throws an error if the origin has no permissions. * * @param origin - The origin whose permissions to revoke. */ @@ -1024,7 +1085,7 @@ export class PermissionController< throw new PermissionDoesNotExistError(origin, target); } - this.deletePermission(draftState.subjects, origin, target); + this.#deletePermission(draftState.subjects, origin, target); }); }); }); @@ -1051,7 +1112,7 @@ export class PermissionController< const { permissions } = subject; if (hasProperty(permissions as Record, target)) { - this.deletePermission(draftState.subjects, origin, target); + this.#deletePermission(draftState.subjects, origin, target); } }); }); @@ -1067,7 +1128,7 @@ export class PermissionController< * to delete. * @param target - The target name of the permission to delete. */ - private deletePermission( + #deletePermission( subjects: Draft>, origin: OriginString, target: ExtractPermission< @@ -1103,7 +1164,8 @@ export class PermissionController< ControllerPermissionSpecification, ControllerCaveatSpecification >['parentCapability'], - CaveatType extends ExtractAllowedCaveatTypes, + CaveatType extends + ExtractAllowedCaveatTypes, >(origin: OriginString, target: TargetName, caveatType: CaveatType): boolean { return Boolean(this.getCaveat(origin, target, caveatType)); } @@ -1128,7 +1190,8 @@ export class PermissionController< ControllerPermissionSpecification, ControllerCaveatSpecification >['parentCapability'], - CaveatType extends ExtractAllowedCaveatTypes, + CaveatType extends + ExtractAllowedCaveatTypes, >( origin: OriginString, target: TargetName, @@ -1168,7 +1231,8 @@ export class PermissionController< ControllerPermissionSpecification, ControllerCaveatSpecification >['parentCapability'], - CaveatType extends ExtractAllowedCaveatTypes, + CaveatType extends + ExtractAllowedCaveatTypes, >( origin: OriginString, target: TargetName, @@ -1179,7 +1243,7 @@ export class PermissionController< throw new CaveatAlreadyExistsError(origin, target, caveatType); } - this.setCaveat(origin, target, caveatType, caveatValue); + this.#setCaveat(origin, target, caveatType, caveatValue); } /** @@ -1205,7 +1269,8 @@ export class PermissionController< ControllerPermissionSpecification, ControllerCaveatSpecification >['parentCapability'], - CaveatType extends ExtractAllowedCaveatTypes, + CaveatType extends + ExtractAllowedCaveatTypes, CaveatValue extends ExtractCaveatValue< ControllerCaveatSpecification, CaveatType @@ -1220,7 +1285,7 @@ export class PermissionController< throw new CaveatDoesNotExistError(origin, target, caveatType); } - this.setCaveat(origin, target, caveatType, caveatValue); + this.#setCaveat(origin, target, caveatType, caveatValue); } /** @@ -1241,12 +1306,13 @@ export class PermissionController< * @param caveatType - The type of the caveat to set. * @param caveatValue - The value of the caveat to set. */ - private setCaveat< + #setCaveat< TargetName extends ExtractPermission< ControllerPermissionSpecification, ControllerCaveatSpecification >['parentCapability'], - CaveatType extends ExtractAllowedCaveatTypes, + CaveatType extends + ExtractAllowedCaveatTypes, >( origin: OriginString, target: TargetName, @@ -1274,8 +1340,9 @@ export class PermissionController< type: caveatType, value: caveatValue, }; - this.validateCaveat(caveat, origin, target); + this.#validateCaveat(caveat, origin, target); + let addedCaveat = false; if (permission.caveats) { const caveatIndex = permission.caveats.findIndex( (existingCaveat) => existingCaveat.type === caveat.type, @@ -1283,18 +1350,28 @@ export class PermissionController< if (caveatIndex === -1) { permission.caveats.push(caveat); + addedCaveat = true; } else { permission.caveats.splice(caveatIndex, 1, caveat); } } else { - // Typecast: At this point, we don't know if the specific permission - // is allowed to have caveats, but it should be impossible to call - // this method for a permission that may not have any caveats. - // If all else fails, the permission validator is also called. - permission.caveats = [caveat] as any; + // At this point, we don't know if the specific permission is allowed + // to have caveats, but it should be impossible to call this method + // for a permission that may not have any caveats. If all else fails, + // the permission validator is also called. + // @ts-expect-error See above comment + permission.caveats = [caveat]; + addedCaveat = true; } - this.validateModifiedPermission(permission, origin); + // Mutating a caveat does not warrant permission validation, but mutating + // the caveat array does. + if (addedCaveat) { + this.#validateModifiedPermission(permission, origin, { + invokePermissionValidator: true, + performCaveatValidation: false, // We just validated the caveat + }); + } }); } @@ -1310,10 +1387,10 @@ export class PermissionController< * value to update the existing caveat with. * * For each caveat, depending on the mutator result, this method will: - * - Do nothing ({@link CaveatMutatorOperation.noop}) - * - Update the value of the caveat ({@link CaveatMutatorOperation.updateValue}). The caveat specification validator, if any, will be called after updating the value. - * - Delete the caveat ({@link CaveatMutatorOperation.deleteCaveat}). The permission specification validator, if any, will be called after deleting the caveat. - * - Revoke the parent permission ({@link CaveatMutatorOperation.revokePermission}) + * - Do nothing ({@link CaveatMutatorOperation.Noop}) + * - Update the value of the caveat ({@link CaveatMutatorOperation.UpdateValue}). The caveat specification validator, if any, will be called after updating the value. + * - Delete the caveat ({@link CaveatMutatorOperation.DeleteCaveat}). The permission specification validator, if any, will be called after deleting the caveat. + * - Revoke the parent permission ({@link CaveatMutatorOperation.RevokePermission}) * * This method throws if the validation of any caveat or permission fails. * @@ -1346,11 +1423,12 @@ export class PermissionController< // The mutator may modify the caveat value in place, and must always // return a valid mutation result. const mutatorResult = mutator(targetCaveat.value); - switch (mutatorResult.operation) { - case CaveatMutatorOperation.noop: + const { operation } = mutatorResult; + switch (operation) { + case CaveatMutatorOperation.Noop: break; - case CaveatMutatorOperation.updateValue: + case CaveatMutatorOperation.UpdateValue: // Typecast: `Mutable` is used here to assign to a readonly // property. `targetConstraint` should already be mutable because // it's part of a draft, but for some reason it's not. We can't @@ -1359,19 +1437,19 @@ export class PermissionController< (targetCaveat as Mutable).value = mutatorResult.value; - this.validateCaveat( + this.#validateCaveat( targetCaveat, subject.origin, permission.parentCapability, ); break; - case CaveatMutatorOperation.deleteCaveat: - this.deleteCaveat(permission, targetCaveatType, subject.origin); + case CaveatMutatorOperation.DeleteCaveat: + this.#deleteCaveat(permission, targetCaveatType, subject.origin); break; - case CaveatMutatorOperation.revokePermission: - this.deletePermission( + case CaveatMutatorOperation.RevokePermission: + this.#deletePermission( draftState.subjects, subject.origin, permission.parentCapability, @@ -1379,14 +1457,10 @@ export class PermissionController< break; default: { - // This type check ensures that the switch statement is - // exhaustive. - const _exhaustiveCheck: never = mutatorResult; - throw new Error( - `Unrecognized mutation result: "${ - (_exhaustiveCheck as any).operation - }"`, - ); + // Overriding as `never` is the expected result of exhaustiveness checking, + // and is intended to represent unchecked exception cases. + // eslint-disable-next-line @typescript-eslint/restrict-template-expressions + throw new Error(`Unrecognized mutation result: "${operation}"`); } } }); @@ -1408,11 +1482,9 @@ export class PermissionController< * @param caveatType - The type of the caveat to remove. */ removeCaveat< - TargetName extends ExtractPermission< - ControllerPermissionSpecification, - ControllerCaveatSpecification - >['parentCapability'], - CaveatType extends ExtractAllowedCaveatTypes, + TargetName extends ControllerPermissionSpecification['targetName'], + CaveatType extends + ExtractAllowedCaveatTypes, >(origin: OriginString, target: TargetName, caveatType: CaveatType): void { this.update((draftState) => { const permission = draftState.subjects[origin]?.permissions[target]; @@ -1424,7 +1496,7 @@ export class PermissionController< throw new CaveatDoesNotExistError(origin, target, caveatType); } - this.deleteCaveat(permission, caveatType, origin); + this.#deleteCaveat(permission, caveatType, origin); }); } @@ -1440,7 +1512,7 @@ export class PermissionController< * @param caveatType - The type of the caveat to delete. * @param origin - The origin the permission subject. */ - private deleteCaveat< + #deleteCaveat< CaveatType extends ExtractCaveats['type'], >( permission: Draft, @@ -1474,35 +1546,41 @@ export class PermissionController< permission.caveats.splice(caveatIndex, 1); } - this.validateModifiedPermission(permission, origin); + this.#validateModifiedPermission(permission, origin, { + invokePermissionValidator: true, + performCaveatValidation: false, // No caveat object was mutated + }); } /** * Validates the specified modified permission. Should **always** be invoked - * on a permission after its caveats have been modified. + * on a permission when its caveat array has been mutated. * - * Just like {@link PermissionController.validatePermission}, except that the + * Just like {@link PermissionController.#validatePermission}, except that the * corresponding target name and specification are retrieved first, and an * error is thrown if the target name does not exist. * * @param permission - The modified permission to validate. * @param origin - The origin associated with the permission. + * @param validationFlags - Validation flags. See {@link PermissionController.#validatePermission}. */ - private validateModifiedPermission( + #validateModifiedPermission( permission: Draft, origin: OriginString, + validationFlags: PermissionValidationFlags, ): void { /* istanbul ignore if: this should be impossible */ - if (!this.targetExists(permission.parentCapability)) { + if (!this.#targetExists(permission.parentCapability)) { throw new Error( `Fatal: Existing permission target "${permission.parentCapability}" has no specification.`, ); } - this.validatePermission( - this.getPermissionSpecification(permission.parentCapability), + this.#validatePermission( + this.#getPermissionSpecification(permission.parentCapability), permission as PermissionConstraint, origin, + validationFlags, ); } @@ -1513,18 +1591,19 @@ export class PermissionController< * @param target - The requested permission target. * @returns Whether the permission target exists. */ - private targetExists( + #targetExists( target: string, ): target is ControllerPermissionSpecification['targetName'] { - return hasProperty(this._permissionSpecifications, target); + return hasProperty(this.#permissionSpecifications, target); } /** * Grants _approved_ permissions to the specified subject. Every permission and - * caveat is stringently validated – including by calling every specification - * validator – and an error is thrown if any validation fails. + * caveat is stringently validated—including by calling their specification + * validators—and an error is thrown if validation fails. * - * ATTN: This method does **not** prompt the user for approval. + * ATTN: This method does **not** prompt the user for approval. User consent must + * first be obtained through some other means. * * @see {@link PermissionController.requestPermissions} For initiating a * permissions request requiring user approval. @@ -1536,7 +1615,7 @@ export class PermissionController< * @param options.preserveExistingPermissions - Whether to preserve the * subject's existing permissions. * @param options.subject - The subject to grant permissions to. - * @returns The granted permissions. + * @returns The subject's new permission state. It may or may not have changed. */ grantPermissions({ approvedPermissions, @@ -1548,10 +1627,84 @@ export class PermissionController< subject: PermissionSubjectMetadata; preserveExistingPermissions?: boolean; requestData?: Record; - }): SubjectPermissions< - ExtractPermission< - ControllerPermissionSpecification, - ControllerCaveatSpecification + }): Partial< + SubjectPermissions< + ExtractPermission< + ControllerPermissionSpecification, + ControllerCaveatSpecification + > + > + > { + return this.#applyGrantedPermissions({ + approvedPermissions, + subject, + mergePermissions: false, + preserveExistingPermissions, + requestData, + }); + } + + /** + * Incrementally grants _approved_ permissions to the specified subject. Every + * permission and caveat is stringently validated—including by calling their + * specification validators—and an error is thrown if validation fails. + * + * ATTN: This method does **not** prompt the user for approval. User consent must + * first be obtained through some other means. + * + * @see {@link PermissionController.requestPermissionsIncremental} For initiating + * an incremental permissions request requiring user approval. + * @param options - Options bag. + * @param options.approvedPermissions - The requested permissions approved by + * the user. + * @param options.requestData - Permission request data. Passed to permission + * factory functions. + * @param options.subject - The subject to grant permissions to. + * @returns The subject's new permission state. It may or may not have changed. + */ + grantPermissionsIncremental({ + approvedPermissions, + requestData, + subject, + }: { + approvedPermissions: RequestedPermissions; + subject: PermissionSubjectMetadata; + requestData?: Record; + }): Partial< + SubjectPermissions< + ExtractPermission< + ControllerPermissionSpecification, + ControllerCaveatSpecification + > + > + > { + return this.#applyGrantedPermissions({ + approvedPermissions, + subject, + mergePermissions: true, + preserveExistingPermissions: true, + requestData, + }); + } + + #applyGrantedPermissions({ + approvedPermissions, + subject, + mergePermissions, + preserveExistingPermissions, + requestData, + }: { + approvedPermissions: RequestedPermissions; + subject: PermissionSubjectMetadata; + mergePermissions: boolean; + preserveExistingPermissions: boolean; + requestData?: Record; + }): Partial< + SubjectPermissions< + ExtractPermission< + ControllerPermissionSpecification, + ControllerCaveatSpecification + > > > { const { origin } = subject; @@ -1576,7 +1729,7 @@ export class PermissionController< for (const [requestedTarget, approvedPermission] of Object.entries( approvedPermissions, )) { - if (!this.targetExists(requestedTarget)) { + if (!this.#targetExists(requestedTarget)) { throw methodNotFound(requestedTarget); } @@ -1597,10 +1750,10 @@ export class PermissionController< ControllerPermissionSpecification, ControllerCaveatSpecification >['parentCapability']; - const specification = this.getPermissionSpecification(targetName); + const specification = this.#getPermissionSpecification(targetName); // The requested caveats are validated here. - const caveats = this.constructCaveats( + const caveats = this.#constructCaveats( origin, targetName, approvedPermission.caveats, @@ -1618,26 +1771,25 @@ export class PermissionController< >; if (specification.factory) { permission = specification.factory(permissionOptions, requestData); - - // Full caveat and permission validation is performed here since the - // factory function can arbitrarily modify the entire permission object, - // including its caveats. - this.validatePermission(specification, permission, origin); } else { permission = constructPermission(permissionOptions); + } - // We do not need to validate caveats in this case, because the plain - // permission constructor function does not modify the caveats, which - // were already validated by `constructCaveats` above. - this.validatePermission(specification, permission, origin, { - invokePermissionValidator: true, - performCaveatValidation: false, - }); + if (mergePermissions) { + permission = this.#mergePermission( + permissions[targetName], + permission, + )[0]; } + + this.#validatePermission(specification, permission, origin, { + invokePermissionValidator: true, + performCaveatValidation: true, + }); permissions[targetName] = permission; } - this.setValidatedPermissions(origin, permissions); + this.#setValidatedPermissions(origin, permissions); return permissions; } @@ -1659,17 +1811,17 @@ export class PermissionController< * @param validationOptions.invokePermissionValidator - Whether to invoke the * permission's consumer-specified validator function, if any. * @param validationOptions.performCaveatValidation - Whether to invoke - * {@link PermissionController.validateCaveat} on each of the permission's + * {@link PermissionController.#validateCaveat} on each of the permission's * caveats. */ - private validatePermission( + #validatePermission( specification: PermissionSpecificationConstraint, permission: PermissionConstraint, origin: OriginString, - { invokePermissionValidator, performCaveatValidation } = { - invokePermissionValidator: true, - performCaveatValidation: true, - }, + { + invokePermissionValidator, + performCaveatValidation, + }: PermissionValidationFlags, ): void { const { allowedCaveats, validator, targetName } = specification; @@ -1677,7 +1829,7 @@ export class PermissionController< specification.subjectTypes?.length && specification.subjectTypes.length > 0 ) { - const metadata = this.messagingSystem.call( + const metadata = this.messenger.call( 'SubjectMetadataController:getSubjectMetadata', origin, ); @@ -1703,7 +1855,7 @@ export class PermissionController< const seenCaveatTypes = new Set(); caveats?.forEach((caveat) => { if (performCaveatValidation) { - this.validateCaveat(caveat, origin, targetName); + this.#validateCaveat(caveat, origin, targetName); } if (!allowedCaveats?.includes(caveat.type)) { @@ -1732,7 +1884,7 @@ export class PermissionController< * @param origin - The origin of the grantee subject. * @param permissions - The new permissions for the grantee subject. */ - private setValidatedPermissions( + #setValidatedPermissions( origin: OriginString, permissions: Record< string, @@ -1762,7 +1914,7 @@ export class PermissionController< * @param requestedCaveats - The requested caveats to construct. * @returns The constructed caveats. */ - private constructCaveats( + #constructCaveats( origin: OriginString, target: ExtractPermission< ControllerPermissionSpecification, @@ -1771,7 +1923,7 @@ export class PermissionController< requestedCaveats?: unknown[] | null, ): NonEmptyArray> | undefined { const caveatArray = requestedCaveats?.map((requestedCaveat) => { - this.validateCaveat(requestedCaveat, origin, target); + this.#validateCaveat(requestedCaveat, origin, target); // Reassign so that we have a fresh object. const { type, value } = requestedCaveat as CaveatConstraint; @@ -1796,13 +1948,8 @@ export class PermissionController< * permission. * @param target - The target name associated with the parent permission. */ - private validateCaveat( - caveat: unknown, - origin: OriginString, - target: string, - ): void { + #validateCaveat(caveat: unknown, origin: OriginString, target: string): void { if (!isPlainObject(caveat)) { - // eslint-disable-next-line @typescript-eslint/no-throw-literal throw new InvalidCaveatError(caveat, origin, target); } @@ -1814,7 +1961,7 @@ export class PermissionController< throw new InvalidCaveatTypeError(caveat, origin, target); } - const specification = this.getCaveatSpecification(caveat.type); + const specification = this.#getCaveatSpecification(caveat.type); if (!specification) { throw new UnrecognizedCaveatTypeError(caveat.type, origin, target); } @@ -1832,9 +1979,11 @@ export class PermissionController< } /** - * Initiates a permission request that requires user approval. This should - * always be used to grant additional permissions to a subject, unless user - * approval has been obtained through some other means. + * Initiates a permission request that requires user approval. + * + * Either this or {@link PermissionController.requestPermissionsIncremental} + * should always be used to grant additional permissions to a subject, + * unless user approval has been obtained through some other means. * * Permissions are validated at every step of the approval process, and this * method will reject if validation fails. @@ -1851,6 +2000,7 @@ export class PermissionController< * id. * @param options.preserveExistingPermissions - Whether to preserve the * subject's existing permissions. Defaults to `true`. + * @param options.metadata - Additional metadata about the permission request. * @returns The granted permissions and request metadata. */ async requestPermissions( @@ -1859,68 +2009,153 @@ export class PermissionController< options: { id?: string; preserveExistingPermissions?: boolean; + metadata?: Record; } = {}, ): Promise< [ - SubjectPermissions< - ExtractPermission< - ControllerPermissionSpecification, - ControllerCaveatSpecification + Partial< + SubjectPermissions< + ExtractPermission< + ControllerPermissionSpecification, + ControllerCaveatSpecification + > > >, - { data?: Record; id: string; origin: OriginString }, + ApprovedPermissionsMetadata, ] > { const { origin } = subject; const { id = nanoid(), preserveExistingPermissions = true } = options; - this.validateRequestedPermissions(origin, requestedPermissions); + this.#validateRequestedPermissions(origin, requestedPermissions); const metadata = { + ...options.metadata, id, origin, }; - const permissionsRequest = { + const permissionsRequest: PermissionsRequest = { metadata, permissions: requestedPermissions, }; - const approvedRequest = await this.requestUserApproval(permissionsRequest); - const { permissions: approvedPermissions, ...requestData } = - approvedRequest; - - const sideEffects = this.getSideEffects(approvedPermissions); + const approvedRequest = await this.#requestUserApproval(permissionsRequest); + return await this.#handleApprovedPermissions({ + subject, + metadata, + preserveExistingPermissions, + approvedRequest, + }); + } - if (Object.values(sideEffects.permittedHandlers).length > 0) { - const sideEffectsData = await this.executeSideEffects( - sideEffects, - approvedRequest, - ); - const mappedData = Object.keys(sideEffects.permittedHandlers).reduce( - (acc, permission, i) => ({ [permission]: sideEffectsData[i], ...acc }), - {}, + /** + * Initiates an incremental permission request that prompts for user approval. + * Incremental permission requests allow the caller to replace existing and/or + * add brand new permissions and caveats for the specified subject. + * + * Incremental permission request are merged with the subject's existing permissions + * through a right-biased union, where the incremental permission are the right-hand + * side of the merger. If both sides of the merger specify the same caveats for a + * given permission, the caveats are merged using their specification's caveat value + * merger property. + * + * Either this or {@link PermissionController.requestPermissions} should + * always be used to grant additional permissions to a subject, unless user + * approval has been obtained through some other means. + * + * Permissions are validated at every step of the approval process, and this + * method will reject if validation fails. + * + * @see {@link ApprovalController} For the user approval logic. + * @see {@link PermissionController.acceptPermissionsRequest} For the method + * that _accepts_ the request and resolves the user approval promise. + * @see {@link PermissionController.rejectPermissionsRequest} For the method + * that _rejects_ the request and the user approval promise. + * @param subject - The grantee subject. + * @param requestedPermissions - The requested permissions. + * @param options - Additional options. + * @param options.id - The id of the permissions request. Defaults to a unique + * id. + * @param options.metadata - Additional metadata about the permission request. + * @returns The granted permissions and request metadata. + */ + async requestPermissionsIncremental( + subject: PermissionSubjectMetadata, + requestedPermissions: RequestedPermissions, + options: { + id?: string; + metadata?: Record; + } = {}, + ): Promise< + | [ + Partial< + SubjectPermissions< + ExtractPermission< + ControllerPermissionSpecification, + ControllerCaveatSpecification + > + > + >, + ApprovedPermissionsMetadata, + ] + | [] + > { + const { origin } = subject; + const { id = nanoid() } = options; + this.#validateRequestedPermissions(origin, requestedPermissions); + + const currentPermissions = this.getPermissions(origin) ?? {}; + const [newPermissions, permissionDiffMap] = + this.#mergeIncrementalPermissions( + currentPermissions, + requestedPermissions, ); - return [ - this.grantPermissions({ - subject, - approvedPermissions, - preserveExistingPermissions, - requestData, - }), - { data: mappedData, ...metadata }, - ]; + // The second undefined check is just for type narrowing purposes. These values + // will always be jointly defined or undefined. + if (newPermissions === undefined || permissionDiffMap === undefined) { + return []; } - return [ - this.grantPermissions({ - subject, - approvedPermissions, - preserveExistingPermissions, - requestData, - }), + try { + // It does not spark joy to run this validation again after the merger operation. + // But, optimizing this procedure is probably not worth it, especially considering + // that the worst-case scenario for validation degrades to the below function call. + this.#validateRequestedPermissions(origin, newPermissions); + } catch (error) { + if (error instanceof Error) { + throw new InvalidMergedPermissionsError( + origin, + error, + permissionDiffMap, + ); + } + /* istanbul ignore next: This should be impossible */ + throw internalError('Unrecognized error type', { error }); + } + + const metadata = { + ...options.metadata, + id, + origin, + }; + + const permissionsRequest: PermissionsRequest = { metadata, - ]; + permissions: newPermissions, + diff: { + currentPermissions, + permissionDiffMap, + }, + }; + + const approvedRequest = await this.#requestUserApproval(permissionsRequest); + return await this.#handleApprovedPermissions({ + subject, + metadata, + preserveExistingPermissions: false, + approvedRequest, + }); } /** @@ -1938,7 +2173,7 @@ export class PermissionController< * @param origin - The origin of the grantee subject. * @param requestedPermissions - The requested permissions. */ - private validateRequestedPermissions( + #validateRequestedPermissions( origin: OriginString, requestedPermissions: unknown, ): void { @@ -1959,7 +2194,7 @@ export class PermissionController< for (const targetName of Object.keys(requestedPermissions)) { const permission = requestedPermissions[targetName]; - if (!this.targetExists(targetName)) { + if (!this.#targetExists(targetName)) { throw methodNotFound(targetName, { origin, requestedPermissions }); } @@ -1976,8 +2211,8 @@ export class PermissionController< // Here we validate the permission without invoking its validator, if any. // The validator will be invoked after the permission has been approved. - this.validatePermission( - this.getPermissionSpecification(targetName), + this.#validatePermission( + this.#getPermissionSpecification(targetName), // Typecast: The permission is still a "PlainObject" here. permission as PermissionConstraint, origin, @@ -1986,42 +2221,269 @@ export class PermissionController< } } + /** + * Merges a set of incrementally requested permissions into the existing permissions of + * the requesting subject. The merge is a right-biased union, where the existing + * permissions are the left-hand side, and the incrementally requested permissions are + * the right-hand side. + * + * @param existingPermissions - The subject's existing permissions. + * @param incrementalRequestedPermissions - The requested permissions to merge. + * @returns The merged permissions and the resulting diff. + */ + #mergeIncrementalPermissions( + existingPermissions: Exclude< + ReturnType, + undefined + >, + incrementalRequestedPermissions: RequestedPermissions, + ): + | [ + SubjectPermissions< + ValidPermission> + >, + PermissionDiffMap, + ] + | [] { + const permissionDiffMap: PermissionDiffMap = {}; + + // Use immer's produce as a convenience for calculating the new permissions + // without mutating the existing permissions or committing the results to state. + const newPermissions = immerProduce( + existingPermissions, + (draftExistingPermissions) => { + const leftPermissions = + draftExistingPermissions as RequestedPermissions; + + Object.entries(incrementalRequestedPermissions).forEach( + ([targetName, rightPermission]) => { + const leftPermission: Partial | undefined = + leftPermissions[targetName]; + + const [newPermission, caveatsDiff] = this.#mergePermission( + leftPermission ?? {}, + rightPermission, + ); + + if ( + leftPermission === undefined || + Object.keys(caveatsDiff).length > 0 + ) { + leftPermissions[targetName] = newPermission; + permissionDiffMap[targetName] = caveatsDiff; + } + // Otherwise, leave the left permission as-is; its authority has + // not changed. + }, + ); + }, + ); + + if (Object.keys(permissionDiffMap).length === 0) { + return []; + } + return [newPermissions, permissionDiffMap]; + } + + /** + * Performs a right-biased union between two permissions. The task of merging caveats + * of the same type between the two permissions is delegated to the corresponding + * caveat type's merger implementation. + * + * Throws if the left-hand and right-hand permissions both have a caveat whose + * specification does not provide a caveat value merger function. + * + * @param leftPermission - The left-hand permission to merge. + * @param rightPermission - The right-hand permission to merge. + * @returns The merged permission. + */ + #mergePermission< + PermissionType extends Partial | PermissionConstraint, + >( + leftPermission: PermissionType | undefined, + rightPermission: PermissionType, + ): [PermissionType, CaveatDiffMap] { + const { caveatPairs, leftUniqueCaveats, rightUniqueCaveats } = + collectUniqueAndPairedCaveats(leftPermission, rightPermission); + + const [mergedCaveats, caveatDiffMap] = caveatPairs.reduce< + [CaveatConstraint[], CaveatDiffMap] + >( + ([caveats, diffMap], [leftCaveat, rightCaveat]) => { + const [newCaveat, diff] = this.#mergeCaveat(leftCaveat, rightCaveat); + + if (newCaveat !== undefined && diff !== undefined) { + caveats.push(newCaveat); + diffMap[newCaveat.type] = diff; + } else { + caveats.push(leftCaveat); + } + + return [caveats, diffMap]; + }, + [[], {}], + ); + + const mergedRightUniqueCaveats = rightUniqueCaveats.map((caveat) => { + const [newCaveat, diff] = this.#mergeCaveat(undefined, caveat); + + caveatDiffMap[newCaveat.type] = diff; + return newCaveat; + }); + + const allCaveats = [ + ...mergedCaveats, + ...leftUniqueCaveats, + ...mergedRightUniqueCaveats, + ]; + + const newPermission = { + ...leftPermission, + ...rightPermission, + ...(allCaveats.length > 0 + ? { caveats: allCaveats as NonEmptyArray } + : {}), + }; + + return [newPermission, caveatDiffMap]; + } + + /** + * Merges two caveats of the same type. The task of merging the values of the + * two caveats is delegated to the corresponding caveat type's merger implementation. + * + * @param leftCaveat - The left-hand caveat to merge. + * @param rightCaveat - The right-hand caveat to merge. + * @returns The merged caveat and the diff between the two caveats. + */ + #mergeCaveat< + RightCaveat extends CaveatConstraint, + LeftCaveat extends RightCaveat | undefined, + >( + leftCaveat: LeftCaveat, + rightCaveat: RightCaveat, + ): MergeCaveatResult { + /* istanbul ignore if: This should be impossible */ + if (leftCaveat !== undefined && leftCaveat.type !== rightCaveat.type) { + throw new CaveatMergeTypeMismatchError(leftCaveat.type, rightCaveat.type); + } + + const merger = this.#expectGetCaveatMerger(rightCaveat.type); + + if (leftCaveat === undefined) { + return [ + { + ...rightCaveat, + }, + rightCaveat.value, + ]; + } + + const [newValue, diff] = merger(leftCaveat.value, rightCaveat.value); + + return newValue !== undefined && diff !== undefined + ? [ + { + type: rightCaveat.type, + value: newValue, + }, + diff, + ] + : ([] as MergeCaveatResult); + } + /** * Adds a request to the {@link ApprovalController} using the - * {@link AddApprovalRequest} action. Also validates the resulting approved + * {@link ApprovalControllerAddRequestAction} action. Also validates the resulting approved * permissions request, and throws an error if validation fails. * * @param permissionsRequest - The permissions request object. * @returns The approved permissions request object. */ - private async requestUserApproval(permissionsRequest: PermissionsRequest) { + async #requestUserApproval( + permissionsRequest: PermissionsRequest, + ): Promise { const { origin, id } = permissionsRequest.metadata; - const approvedRequest = await this.messagingSystem.call( + const approvedRequest = await this.messenger.call( 'ApprovalController:addRequest', { id, origin, requestData: permissionsRequest, - type: MethodNames.requestPermissions, + type: MethodNames.RequestPermissions, }, true, ); - this.validateApprovedPermissions(approvedRequest, { id, origin }); + this.#validateApprovedPermissions(approvedRequest, { id, origin }); return approvedRequest as PermissionsRequest; } + /** + * Accepts a permissions request that has been approved by the user. This + * method should be called after the user has approved the request and the + * {@link ApprovalController} has resolved the user approval promise. + * + * @param options - Options bag. + * @param options.subject - The subject to grant permissions to. + * @param options.metadata - The metadata of the approved permissions request. + * @param options.preserveExistingPermissions - Whether to preserve the + * subject's existing permissions. + * @param options.approvedRequest - The approved permissions request to handle. + * @returns The granted permissions and request metadata. + */ + async #handleApprovedPermissions({ + subject, + metadata, + preserveExistingPermissions, + approvedRequest, + }: { + subject: PermissionSubjectMetadata; + metadata: PermissionsRequest['metadata']; + preserveExistingPermissions: boolean; + approvedRequest: PermissionsRequest; + }): Promise< + [ReturnType, ApprovedPermissionsMetadata] + > { + const { permissions: approvedPermissions, ...requestData } = + approvedRequest; + const approvedMetadata: ApprovedPermissionsMetadata = { ...metadata }; + + const sideEffects = this.#getSideEffects(approvedPermissions); + if (Object.values(sideEffects.permittedHandlers).length > 0) { + const sideEffectsData = await this.#executeSideEffects( + sideEffects, + approvedRequest, + ); + + approvedMetadata.data = Object.keys(sideEffects.permittedHandlers).reduce( + (acc, permission, i) => ({ [permission]: sideEffectsData[i], ...acc }), + {}, + ); + } + + return [ + this.grantPermissions({ + subject, + approvedPermissions, + preserveExistingPermissions, + requestData, + }), + approvedMetadata, + ]; + } + /** * Reunites all the side-effects (onPermitted and onFailure) of the requested permissions inside a record of arrays. * * @param permissions - The approved permissions. * @returns The {@link SideEffects} object containing the handlers arrays. */ - private getSideEffects(permissions: RequestedPermissions) { + #getSideEffects(permissions: RequestedPermissions): SideEffects { return Object.keys(permissions).reduce( (sideEffectList, targetName) => { - if (this.targetExists(targetName)) { - const specification = this.getPermissionSpecification(targetName); + if (this.#targetExists(targetName)) { + const specification = this.#getPermissionSpecification(targetName); if (specification.sideEffect) { sideEffectList.permittedHandlers[targetName] = @@ -2041,20 +2503,20 @@ export class PermissionController< /** * Executes the side-effects of the approved permissions while handling the errors if any. - * It will pass an instance of the {@link messagingSystem} and the request data associated with the permission request to the handlers through its params. + * It will pass an instance of the {@link messenger} and the request data associated with the permission request to the handlers through its params. * - * @param sideEffects - the side-effect record created by {@link getSideEffects} + * @param sideEffects - the side-effect record created by {@link #getSideEffects} * @param requestData - the permissions requestData. * @returns the value returned by all the `onPermitted` handlers in an array. */ - private async executeSideEffects( + async #executeSideEffects( sideEffects: SideEffects, requestData: PermissionsRequest, - ) { + ): Promise { const { permittedHandlers, failureHandlers } = sideEffects; const params = { requestData, - messagingSystem: this.messagingSystem, + messenger: this.messenger, }; const promiseResults = await Promise.allSettled( @@ -2104,7 +2566,7 @@ export class PermissionController< * request must have the required `metadata` and `permissions` properties, * the `id` and `origin` of the `metadata` must match the original request * metadata, and the requested permissions must be valid per - * {@link PermissionController.validateRequestedPermissions}. Any extra + * {@link PermissionController.#validateRequestedPermissions}. Any extra * metadata properties are ignored. * * An error is thrown if validation fails. @@ -2112,10 +2574,10 @@ export class PermissionController< * @param approvedRequest - The approved permissions request object. * @param originalMetadata - The original request metadata. */ - private validateApprovedPermissions( + #validateApprovedPermissions( approvedRequest: unknown, originalMetadata: PermissionsRequestMetadata, - ) { + ): void { const { id, origin } = originalMetadata; if ( @@ -2148,16 +2610,17 @@ export class PermissionController< } try { - this.validateRequestedPermissions(origin, permissions); + this.#validateRequestedPermissions(origin, permissions); } catch (error) { - if (error instanceof JsonRpcError) { + if (error instanceof Error) { // Re-throw as an internal error; we should never receive invalid approved // permissions. throw internalError( `Invalid approved permissions request: ${error.message}`, - error.data, + error instanceof JsonRpcError ? error.data : undefined, ); } + /* istanbul ignore next: This should be impossible */ throw internalError('Unrecognized error type', { error }); } } @@ -2171,12 +2634,12 @@ export class PermissionController< async acceptPermissionsRequest(request: PermissionsRequest): Promise { const { id } = request.metadata; - if (!this.hasApprovalRequest({ id })) { + if (!this.#hasApprovalRequest({ id })) { throw new PermissionsRequestNotFoundError(id); } if (Object.keys(request.permissions).length === 0) { - this._rejectPermissionsRequest( + this.#rejectPermissionsRequest( id, invalidParams({ message: 'Must request at least one permission.', @@ -2186,7 +2649,7 @@ export class PermissionController< } try { - this.messagingSystem.call( + await this.messenger.call( 'ApprovalController:acceptRequest', id, request, @@ -2194,7 +2657,7 @@ export class PermissionController< } catch (error) { // If accepting unexpectedly fails, reject the request and re-throw the // error - this._rejectPermissionsRequest(id, error); + this.#rejectPermissionsRequest(id, error); throw error; } } @@ -2206,11 +2669,11 @@ export class PermissionController< * @param id - The id of the request to be rejected. */ async rejectPermissionsRequest(id: string): Promise { - if (!this.hasApprovalRequest({ id })) { + if (!this.#hasApprovalRequest({ id })) { throw new PermissionsRequestNotFoundError(id); } - this._rejectPermissionsRequest(id, userRejectedRequest()); + this.#rejectPermissionsRequest(id, userRejectedRequest()); } /** @@ -2219,18 +2682,12 @@ export class PermissionController< * * @see {@link PermissionController.acceptPermissionsRequest} and * {@link PermissionController.rejectPermissionsRequest} for usage. - * @param options - The {@link HasApprovalRequest} options. + * @param options - The {@link ApprovalControllerHasRequestAction} options. * @param options.id - The id of the approval request to check for. * @returns Whether the specified request exists. */ - private hasApprovalRequest(options: { id: string }): boolean { - return this.messagingSystem.call( - 'ApprovalController:hasRequest', - // Typecast: For some reason, the type here expects all of the possible - // HasApprovalRequest options to be specified, when they're actually all - // optional. Passing just the id is definitely valid, so we just cast it. - options as any, - ); + #hasApprovalRequest(options: { id: string }): boolean { + return this.messenger.call('ApprovalController:hasRequest', options); } /** @@ -2244,12 +2701,8 @@ export class PermissionController< * @param error - The error associated with the rejection. * @returns Nothing */ - private _rejectPermissionsRequest(id: string, error: unknown): void { - return this.messagingSystem.call( - 'ApprovalController:rejectRequest', - id, - error, - ); + #rejectPermissionsRequest(id: string, error: unknown): void { + return this.messenger.call('ApprovalController:rejectRequest', id, error); } /** @@ -2276,7 +2729,7 @@ export class PermissionController< throw unauthorized({ data: { origin, targetName } }); } - return this.getTypedPermissionSpecification( + return this.#getTypedPermissionSpecification( PermissionType.Endowment, targetName, origin, @@ -2317,18 +2770,20 @@ export class PermissionController< params?: RestrictedMethodParameters, ): Promise { // Throws if the method does not exist - const methodImplementation = this.getRestrictedMethod(targetName, origin); + const methodImplementation = this.#getRestrictedMethod(targetName, origin); - const result = await this._executeRestrictedMethod( + const result = await this.#executeRestrictedMethod( methodImplementation, { origin }, targetName, params, ); + // This is impossible if the restricted method implementation is typed correctly, + // but we maintain it for backwards compatibility. if (result === undefined) { throw new Error( - `Internal request for method "${targetName}" as origin "${origin}" returned no result.`, + `Request for method "${targetName}" as origin "${origin}" returned no result.`, ); } @@ -2336,24 +2791,22 @@ export class PermissionController< } /** - * An internal method used in the controller's `json-rpc-engine` middleware - * and {@link PermissionController.executeRestrictedMethod}. Calls the - * specified restricted method implementation after decorating it with the - * caveats of its permission. Throws if the subject does not have the + * An internal method used in {@link PermissionController.executeRestrictedMethod}. + * Calls the specified restricted method implementation after decorating it + * with the caveats of its permission. Throws if the subject does not have the * requisite permission. * * ATTN: Parameter validation is the responsibility of the caller, or * the restricted method implementation in the case of `params`. * - * @see {@link PermissionController.executeRestrictedMethod} and - * {@link PermissionController.createPermissionMiddleware} for usage. + * @see {@link PermissionController.executeRestrictedMethod} for usage. * @param methodImplementation - The implementation of the method to call. * @param subject - Metadata about the subject that made the request. * @param method - The method name * @param params - Params needed for executing the restricted method * @returns The result of the restricted method implementation */ - private _executeRestrictedMethod( + #executeRestrictedMethod( methodImplementation: RestrictedMethod, subject: PermissionSubjectMetadata, method: ExtractPermission< @@ -2372,7 +2825,7 @@ export class PermissionController< return decorateWithCaveats( methodImplementation, permission, - this._caveatSpecifications, + this.#caveatSpecifications, )({ method, params, context: { origin } }); } } diff --git a/packages/permission-controller/src/SubjectMetadataController-method-action-types.ts b/packages/permission-controller/src/SubjectMetadataController-method-action-types.ts new file mode 100644 index 00000000000..1c0ceca3998 --- /dev/null +++ b/packages/permission-controller/src/SubjectMetadataController-method-action-types.ts @@ -0,0 +1,60 @@ +/** + * This file is auto generated. + * Do not edit manually. + */ + +import type { SubjectMetadataController } from './SubjectMetadataController.js'; + +/** + * Clears the state of this controller. Also resets the cache of subjects + * encountered since startup, so as to not prematurely reach the cache limit. + */ +export type SubjectMetadataControllerClearStateAction = { + type: `SubjectMetadataController:clearState`; + handler: SubjectMetadataController['clearState']; +}; + +/** + * Stores domain metadata for the given origin (subject). Deletes metadata for + * subjects without permissions in a FIFO manner once more than + * {@link SubjectMetadataController.subjectCacheLimit} distinct origins have + * been added since boot. + * + * In order to prevent a degraded user experience, + * metadata is never deleted for subjects with permissions, since metadata + * cannot yet be requested on demand. + * + * @param metadata - The subject metadata to store. + */ +export type SubjectMetadataControllerAddSubjectMetadataAction = { + type: `SubjectMetadataController:addSubjectMetadata`; + handler: SubjectMetadataController['addSubjectMetadata']; +}; + +/** + * Gets the subject metadata for the given origin, if any. + * + * @param origin - The origin for which to get the subject metadata. + * @returns The subject metadata, if any, or `undefined` otherwise. + */ +export type SubjectMetadataControllerGetSubjectMetadataAction = { + type: `SubjectMetadataController:getSubjectMetadata`; + handler: SubjectMetadataController['getSubjectMetadata']; +}; + +/** + * Deletes all subjects without permissions from the controller's state. + */ +export type SubjectMetadataControllerTrimMetadataStateAction = { + type: `SubjectMetadataController:trimMetadataState`; + handler: SubjectMetadataController['trimMetadataState']; +}; + +/** + * Union of all SubjectMetadataController action types. + */ +export type SubjectMetadataControllerMethodActions = + | SubjectMetadataControllerClearStateAction + | SubjectMetadataControllerAddSubjectMetadataAction + | SubjectMetadataControllerGetSubjectMetadataAction + | SubjectMetadataControllerTrimMetadataStateAction; diff --git a/packages/permission-controller/src/SubjectMetadataController.test.ts b/packages/permission-controller/src/SubjectMetadataController.test.ts index de67b769dc6..1d596599b58 100644 --- a/packages/permission-controller/src/SubjectMetadataController.test.ts +++ b/packages/permission-controller/src/SubjectMetadataController.test.ts @@ -1,50 +1,84 @@ -import { ControllerMessenger } from '@metamask/base-controller'; +import { deriveStateFromMetadata } from '@metamask/base-controller'; +import { Messenger, MOCK_ANY_NAMESPACE } from '@metamask/messenger'; +import type { + MessengerActions, + MessengerEvents, + MockAnyNamespace, +} from '@metamask/messenger'; import type { Json } from '@metamask/utils'; -import type { HasPermissions } from './PermissionController'; import type { - SubjectMetadataControllerActions, - SubjectMetadataControllerEvents, + SubjectMetadata, SubjectMetadataControllerMessenger, -} from './SubjectMetadataController'; +} from './SubjectMetadataController.js'; import { SubjectMetadataController, SubjectType, -} from './SubjectMetadataController'; +} from './SubjectMetadataController.js'; const controllerName = 'SubjectMetadataController'; +type AllSubjectMetadataControllerActions = + MessengerActions; + +type AllSubjectMetadataControllerEvents = + MessengerEvents; + +type RootMessenger = Messenger< + MockAnyNamespace, + AllSubjectMetadataControllerActions, + AllSubjectMetadataControllerEvents +>; + +/** + * Creates and returns a root messenger for testing + * + * @returns A messenger instance + */ +function getRootMessenger(): RootMessenger { + return new Messenger({ + namespace: MOCK_ANY_NAMESPACE, + }); +} + /** - * Utility function for creating a controller messenger. + * Utility function for creating a messenger. * * @returns A tuple containing the messenger and a spy for the "hasPermission" action handler */ -function getSubjectMetadataControllerMessenger() { - const controllerMessenger = new ControllerMessenger< - SubjectMetadataControllerActions | HasPermissions, - SubjectMetadataControllerEvents - >(); +function getSubjectMetadataControllerMessenger(): readonly [ + Messenger< + typeof controllerName, + AllSubjectMetadataControllerActions, + AllSubjectMetadataControllerEvents, + RootMessenger + >, + jest.Mock, +] { + const rootMessenger = getRootMessenger(); const hasPermissionsSpy = jest.fn(); - controllerMessenger.registerActionHandler( + rootMessenger.registerActionHandler( 'PermissionController:hasPermissions', hasPermissionsSpy, ); - return [ - controllerMessenger.getRestricted< - typeof controllerName, - SubjectMetadataControllerActions['type'] | HasPermissions['type'], - SubjectMetadataControllerEvents['type'] - >({ - name: controllerName, - allowedActions: [ - 'PermissionController:hasPermissions', - 'SubjectMetadataController:getState', - ], - }) as SubjectMetadataControllerMessenger, - hasPermissionsSpy, - ] as const; + const messenger = new Messenger< + typeof controllerName, + AllSubjectMetadataControllerActions, + AllSubjectMetadataControllerEvents, + RootMessenger + >({ + namespace: controllerName, + parent: rootMessenger, + }); + + rootMessenger.delegate({ + actions: ['PermissionController:hasPermissions'], + messenger, + }); + + return [messenger, hasPermissionsSpy] as const; } /** @@ -61,7 +95,7 @@ function getSubjectMetadata( name: string | null = null, subjectType: SubjectType | null = null, opts?: Record, -) { +): SubjectMetadata { return { origin, name, @@ -283,4 +317,144 @@ describe('SubjectMetadataController', () => { }); }); }); + + describe('controller actions', () => { + it(':getSubjectMetadata returns the subject metadata', () => { + const [messenger, hasPermissionsSpy] = + getSubjectMetadataControllerMessenger(); + const controller = new SubjectMetadataController({ + messenger, + subjectCacheLimit: 100, + }); + hasPermissionsSpy.mockImplementationOnce(() => true); + + controller.addSubjectMetadata( + getSubjectMetadata('foo.com', 'foo', SubjectType.Snap), + ); + + controller.addSubjectMetadata( + getSubjectMetadata('bar.io', 'bar', SubjectType.Website), + ); + + expect( + messenger.call( + 'SubjectMetadataController:getSubjectMetadata', + 'foo.com', + ), + ).toStrictEqual(getSubjectMetadata('foo.com', 'foo', SubjectType.Snap)); + + expect( + messenger.call( + 'SubjectMetadataController:getSubjectMetadata', + 'bar.io', + ), + ).toStrictEqual(getSubjectMetadata('bar.io', 'bar', SubjectType.Website)); + }); + + it(':addSubjectMetadata adds passed subject metadata', () => { + const [messenger, hasPermissionsSpy] = + getSubjectMetadataControllerMessenger(); + const controller = new SubjectMetadataController({ + messenger, + subjectCacheLimit: 100, + }); + hasPermissionsSpy.mockImplementationOnce(() => true); + + messenger.call( + 'SubjectMetadataController:addSubjectMetadata', + getSubjectMetadata('foo.com', 'foo', SubjectType.Snap), + ); + + messenger.call( + 'SubjectMetadataController:addSubjectMetadata', + getSubjectMetadata('bar.io', 'bar', SubjectType.Website), + ); + + expect(controller.getSubjectMetadata('foo.com')).toStrictEqual( + getSubjectMetadata('foo.com', 'foo', SubjectType.Snap), + ); + + expect(controller.getSubjectMetadata('bar.io')).toStrictEqual( + getSubjectMetadata('bar.io', 'bar', SubjectType.Website), + ); + }); + }); + + describe('metadata', () => { + it('includes expected state in debug snapshots', () => { + const [messenger] = getSubjectMetadataControllerMessenger(); + const controller = new SubjectMetadataController({ + messenger, + subjectCacheLimit: 100, + }); + + expect( + deriveStateFromMetadata( + controller.state, + controller.metadata, + 'includeInDebugSnapshot', + ), + ).toMatchInlineSnapshot(`{}`); + }); + + it('includes expected state in state logs', () => { + const [messenger] = getSubjectMetadataControllerMessenger(); + const controller = new SubjectMetadataController({ + messenger, + subjectCacheLimit: 100, + }); + + expect( + deriveStateFromMetadata( + controller.state, + controller.metadata, + 'includeInStateLogs', + ), + ).toMatchInlineSnapshot(` + { + "subjectMetadata": {}, + } + `); + }); + + it('persists expected state', () => { + const [messenger] = getSubjectMetadataControllerMessenger(); + const controller = new SubjectMetadataController({ + messenger, + subjectCacheLimit: 100, + }); + + expect( + deriveStateFromMetadata( + controller.state, + controller.metadata, + 'persist', + ), + ).toMatchInlineSnapshot(` + { + "subjectMetadata": {}, + } + `); + }); + + it('exposes expected state to UI', () => { + const [messenger] = getSubjectMetadataControllerMessenger(); + const controller = new SubjectMetadataController({ + messenger, + subjectCacheLimit: 100, + }); + + expect( + deriveStateFromMetadata( + controller.state, + controller.metadata, + 'usedInUi', + ), + ).toMatchInlineSnapshot(` + { + "subjectMetadata": {}, + } + `); + }); + }); }); diff --git a/packages/permission-controller/src/SubjectMetadataController.ts b/packages/permission-controller/src/SubjectMetadataController.ts index 5509b954c4f..dc781c6d9c2 100644 --- a/packages/permission-controller/src/SubjectMetadataController.ts +++ b/packages/permission-controller/src/SubjectMetadataController.ts @@ -1,16 +1,27 @@ -import type { RestrictedControllerMessenger } from '@metamask/base-controller'; -import { BaseControllerV2 } from '@metamask/base-controller'; +import type { + ControllerGetStateAction, + ControllerStateChangeEvent, +} from '@metamask/base-controller'; +import { BaseController } from '@metamask/base-controller'; +import type { Messenger } from '@metamask/messenger'; import type { Json } from '@metamask/utils'; -import type { Patch } from 'immer'; +import type { PermissionControllerHasPermissionsAction } from './PermissionController-method-action-types.js'; import type { GenericPermissionController, - HasPermissions, PermissionSubjectMetadata, -} from './PermissionController'; +} from './PermissionController.js'; +import type { SubjectMetadataControllerMethodActions } from './SubjectMetadataController-method-action-types.js'; const controllerName = 'SubjectMetadataController'; +const MESSENGER_EXPOSED_METHODS = [ + 'clearState', + 'addSubjectMetadata', + 'getSubjectMetadata', + 'trimMetadataState', +] as const; + type SubjectOrigin = string; /** @@ -27,7 +38,6 @@ export enum SubjectType { export type SubjectMetadata = PermissionSubjectMetadata & { [key: string]: Json; - // TODO:TS4.4 make optional name: string | null; subjectType: SubjectType | null; extensionId: string | null; @@ -46,42 +56,61 @@ export type SubjectMetadataControllerState = { }; const stateMetadata = { - subjectMetadata: { persist: true, anonymous: false }, + subjectMetadata: { + includeInStateLogs: true, + persist: true, + includeInDebugSnapshot: false, + usedInUi: true, + }, }; const defaultState: SubjectMetadataControllerState = { subjectMetadata: {}, }; -export type GetSubjectMetadataState = { - type: `${typeof controllerName}:getState`; - handler: () => SubjectMetadataControllerState; -}; +export type SubjectMetadataControllerGetStateAction = ControllerGetStateAction< + typeof controllerName, + SubjectMetadataControllerState +>; +/** + * @deprecated Use `SubjectMetadataControllerGetStateAction` instead. + */ +export type GetSubjectMetadataState = SubjectMetadataControllerGetStateAction; + +/** + * @deprecated Use `SubjectMetadataControllerGetSubjectMetadataAction` instead. + */ export type GetSubjectMetadata = { type: `${typeof controllerName}:getSubjectMetadata`; handler: (origin: SubjectOrigin) => SubjectMetadata | undefined; }; +/** + * @deprecated Use `SubjectMetadataControllerAddSubjectMetadataAction` instead. + */ +export type AddSubjectMetadata = { + type: `${typeof controllerName}:addSubjectMetadata`; + handler: (metadata: SubjectMetadataToAdd) => void; +}; + export type SubjectMetadataControllerActions = - | GetSubjectMetadataState - | GetSubjectMetadata; + | SubjectMetadataControllerGetStateAction + | SubjectMetadataControllerMethodActions; -export type SubjectMetadataStateChange = { - type: `${typeof controllerName}:stateChange`; - payload: [SubjectMetadataControllerState, Patch[]]; -}; +export type SubjectMetadataStateChange = ControllerStateChangeEvent< + typeof controllerName, + SubjectMetadataControllerState +>; export type SubjectMetadataControllerEvents = SubjectMetadataStateChange; -type AllowedActions = HasPermissions; +type AllowedActions = PermissionControllerHasPermissionsAction; -export type SubjectMetadataControllerMessenger = RestrictedControllerMessenger< +export type SubjectMetadataControllerMessenger = Messenger< typeof controllerName, SubjectMetadataControllerActions | AllowedActions, - SubjectMetadataControllerEvents, - AllowedActions['type'], - never + SubjectMetadataControllerEvents >; type SubjectMetadataControllerOptions = { @@ -94,16 +123,16 @@ type SubjectMetadataControllerOptions = { * A controller for storing metadata associated with permission subjects. More * or less, a cache. */ -export class SubjectMetadataController extends BaseControllerV2< +export class SubjectMetadataController extends BaseController< typeof controllerName, SubjectMetadataControllerState, SubjectMetadataControllerMessenger > { - private readonly subjectCacheLimit: number; + readonly #subjectCacheLimit: number; - private readonly subjectsWithoutPermissionsEncounteredSinceStartup: Set; + readonly #subjectsWithoutPermissionsEncounteredSinceStartup: Set; - private readonly subjectHasPermissions: GenericPermissionController['hasPermissions']; + readonly #subjectHasPermissions: GenericPermissionController['hasPermissions']; constructor({ messenger, @@ -116,7 +145,7 @@ export class SubjectMetadataController extends BaseControllerV2< ); } - const hasPermissions = (origin: string) => { + const hasPermissions = (origin: string): boolean => { return messenger.call('PermissionController:hasPermissions', origin); }; @@ -125,17 +154,17 @@ export class SubjectMetadataController extends BaseControllerV2< metadata: stateMetadata, messenger, state: { - ...SubjectMetadataController.getTrimmedState(state, hasPermissions), + ...SubjectMetadataController.#getTrimmedState(state, hasPermissions), }, }); - this.subjectHasPermissions = hasPermissions; - this.subjectCacheLimit = subjectCacheLimit; - this.subjectsWithoutPermissionsEncounteredSinceStartup = new Set(); + this.#subjectHasPermissions = hasPermissions; + this.#subjectCacheLimit = subjectCacheLimit; + this.#subjectsWithoutPermissionsEncounteredSinceStartup = new Set(); - this.messagingSystem.registerActionHandler( - `${this.name}:getSubjectMetadata`, - this.getSubjectMetadata.bind(this), + this.messenger.registerMethodActionHandlers( + this, + MESSENGER_EXPOSED_METHODS, ); } @@ -144,7 +173,7 @@ export class SubjectMetadataController extends BaseControllerV2< * encountered since startup, so as to not prematurely reach the cache limit. */ clearState(): void { - this.subjectsWithoutPermissionsEncounteredSinceStartup.clear(); + this.#subjectsWithoutPermissionsEncounteredSinceStartup.clear(); this.update((_draftState) => { return { ...defaultState }; }); @@ -166,38 +195,38 @@ export class SubjectMetadataController extends BaseControllerV2< const { origin } = metadata; const newMetadata: SubjectMetadata = { ...metadata, - extensionId: metadata.extensionId || null, - iconUrl: metadata.iconUrl || null, - name: metadata.name || null, - subjectType: metadata.subjectType || null, + extensionId: metadata.extensionId ?? null, + iconUrl: metadata.iconUrl ?? null, + name: metadata.name ?? null, + subjectType: metadata.subjectType ?? null, }; let originToForget: string | null = null; // We only delete the oldest encountered subject from the cache, again to // ensure that the user's experience isn't degraded by missing icons etc. if ( - this.subjectsWithoutPermissionsEncounteredSinceStartup.size >= - this.subjectCacheLimit + this.#subjectsWithoutPermissionsEncounteredSinceStartup.size >= + this.#subjectCacheLimit ) { const cachedOrigin = - this.subjectsWithoutPermissionsEncounteredSinceStartup + this.#subjectsWithoutPermissionsEncounteredSinceStartup .values() .next().value; - this.subjectsWithoutPermissionsEncounteredSinceStartup.delete( + this.#subjectsWithoutPermissionsEncounteredSinceStartup.delete( cachedOrigin, ); - if (!this.subjectHasPermissions(cachedOrigin)) { + if (!this.#subjectHasPermissions(cachedOrigin)) { originToForget = cachedOrigin; } } - this.subjectsWithoutPermissionsEncounteredSinceStartup.add(origin); + this.#subjectsWithoutPermissionsEncounteredSinceStartup.add(origin); this.update((draftState) => { - // Typecast: ts(2589) - draftState.subjectMetadata[origin] = newMetadata as any; + // @ts-expect-error TS2589: Type instantiation is excessively deep and possibly infinite + draftState.subjectMetadata[origin] = newMetadata; if (typeof originToForget === 'string') { delete draftState.subjectMetadata[originToForget]; } @@ -218,11 +247,10 @@ export class SubjectMetadataController extends BaseControllerV2< * Deletes all subjects without permissions from the controller's state. */ trimMetadataState(): void { - this.update((draftState) => { - return SubjectMetadataController.getTrimmedState( - // Typecast: ts(2589) - draftState as any, - this.subjectHasPermissions, + this.update(() => { + return SubjectMetadataController.#getTrimmedState( + this.state, + this.#subjectHasPermissions, ); }); } @@ -240,9 +268,9 @@ export class SubjectMetadataController extends BaseControllerV2< * subject metadata, the returned object will be equivalent to the default * state of this controller. */ - private static getTrimmedState( + static #getTrimmedState( state: Partial, - hasPermissions: SubjectMetadataController['subjectHasPermissions'], + hasPermissions: GenericPermissionController['hasPermissions'], ): SubjectMetadataControllerState { const { subjectMetadata = {} } = state; diff --git a/packages/permission-controller/src/createRestrictedMethodMessenger.test.ts b/packages/permission-controller/src/createRestrictedMethodMessenger.test.ts new file mode 100644 index 00000000000..62aeba55f14 --- /dev/null +++ b/packages/permission-controller/src/createRestrictedMethodMessenger.test.ts @@ -0,0 +1,119 @@ +import { Messenger } from '@metamask/messenger'; + +import { createRestrictedMethodMessenger } from './createRestrictedMethodMessenger.js'; + +type FooAction = { + type: 'Foo:ping'; + handler: () => string; +}; + +type BarAction = { + type: 'Bar:double'; + handler: (n: number) => number; +}; + +type RootActions = FooAction | BarAction; + +const getRootMessenger = (): Messenger<'Root', RootActions> => { + const messenger = new Messenger<'Root', RootActions>({ namespace: 'Root' }); + const fooMessenger = new Messenger<'Foo', FooAction, never, typeof messenger>( + { + namespace: 'Foo', + parent: messenger, + }, + ); + const barMessenger = new Messenger<'Bar', BarAction, never, typeof messenger>( + { + namespace: 'Bar', + parent: messenger, + }, + ); + fooMessenger.registerActionHandler('Foo:ping', () => 'pong'); + barMessenger.registerActionHandler('Bar:double', (value) => value * 2); + return messenger; +}; + +describe('createRestrictedMethodMessenger', () => { + it('returns undefined when actionNames is omitted', () => { + const rootMessenger = getRootMessenger(); + + expect( + createRestrictedMethodMessenger({ + rootMessenger, + namespace: 'wallet_example', + }), + ).toBeUndefined(); + }); + + it('returns undefined when actionNames is empty', () => { + const rootMessenger = getRootMessenger(); + + expect( + createRestrictedMethodMessenger({ + rootMessenger, + namespace: 'wallet_example', + // @ts-expect-error An empty array is rejected by the type system, but + // the runtime handles it as a no-op. + actionNames: [], + }), + ).toBeUndefined(); + }); + + it('exposes the requested action on the returned messenger', () => { + const rootMessenger = getRootMessenger(); + + const messenger = createRestrictedMethodMessenger({ + rootMessenger, + namespace: 'wallet_example', + actionNames: ['Foo:ping'] as const, + }); + + expect(messenger.call('Foo:ping')).toBe('pong'); + }); + + it('uses the provided namespace for the returned messenger', () => { + const rootMessenger = getRootMessenger(); + + const messenger = createRestrictedMethodMessenger({ + rootMessenger, + namespace: 'wallet_example', + actionNames: ['Foo:ping'] as const, + }); + + // Registering an action under a different namespace must fail with an + // error that names the messenger's configured namespace. + expect(() => + // @ts-expect-error Deliberately registering outside the child's action + // surface to probe its namespace. + messenger.registerActionHandler('Other:noop', () => undefined), + ).toThrow(/wallet_example/u); + }); + + it('rejects calls to actions that were not requested', () => { + const rootMessenger = getRootMessenger(); + + const messenger = createRestrictedMethodMessenger({ + rootMessenger, + namespace: 'wallet_example', + actionNames: ['Foo:ping'] as const, + }); + + expect(() => + // @ts-expect-error Intentionally calling an undelegated action. + messenger.call('Bar:double', 2), + ).toThrow(/Bar:double/u); + }); + + it('exposes every requested action when multiple are delegated', () => { + const rootMessenger = getRootMessenger(); + + const messenger = createRestrictedMethodMessenger({ + rootMessenger, + namespace: 'wallet_example', + actionNames: ['Foo:ping', 'Bar:double'] as const, + }); + + expect(messenger.call('Foo:ping')).toBe('pong'); + expect(messenger.call('Bar:double', 3)).toBe(6); + }); +}); diff --git a/packages/permission-controller/src/createRestrictedMethodMessenger.ts b/packages/permission-controller/src/createRestrictedMethodMessenger.ts new file mode 100644 index 00000000000..9a6c01e29db --- /dev/null +++ b/packages/permission-controller/src/createRestrictedMethodMessenger.ts @@ -0,0 +1,108 @@ +import type { + ActionConstraint, + EventConstraint, + MessengerActions, +} from '@metamask/messenger'; +import { Messenger } from '@metamask/messenger'; + +/** + * The subset of `RootMessenger`'s actions selected by `DelegatedActions`. + */ +type SelectedActions< + RootMessenger extends Messenger, + DelegatedActions extends readonly MessengerActions['type'][], +> = Extract< + MessengerActions, + { type: DelegatedActions[number] } +>; + +/** + * Create a child messenger scoped to a restricted-method permission + * specification, delegating only the spec's declared actions from the root + * messenger. This produces a minimally-scoped messenger whose action surface + * matches exactly what the spec has declared it needs. + * + * Returns `undefined` when `actionNames` is omitted — there is nothing to + * scope, and the builder can be invoked without a messenger. + * + * @param args - The arguments. + * @param args.rootMessenger - The root messenger to delegate actions from. + * @param args.namespace - The namespace for the scoped child messenger, + * typically the spec's `targetName`. + * @param args.actionNames - The action types the specification requires, + * typically the spec's declared `actionNames`. Must be a non-empty tuple of + * action types that exist on the root messenger. + * @returns A scoped child messenger with the requested actions delegated, or + * `undefined` if no actions were requested. + */ +export function createRestrictedMethodMessenger< + Namespace extends string, + RootMessenger extends Messenger, + DelegatedActions extends readonly [ + MessengerActions['type'], + ...MessengerActions['type'][], + ], +>(args: { + rootMessenger: RootMessenger; + namespace: Namespace; + actionNames: DelegatedActions; +}): Messenger< + Namespace, + SelectedActions, + never, + RootMessenger +>; + +export function createRestrictedMethodMessenger< + Namespace extends string, + RootMessenger extends Messenger, +>(args: { + rootMessenger: RootMessenger; + namespace: Namespace; + actionNames?: undefined; +}): undefined; + +export function createRestrictedMethodMessenger< + Namespace extends string, + RootMessenger extends Messenger, + DelegatedActions extends [ + MessengerActions['type'], + ...MessengerActions['type'][], + ], +>({ + rootMessenger, + namespace, + actionNames, +}: { + rootMessenger: RootMessenger; + namespace: Namespace; + actionNames?: DelegatedActions; +}): + | Messenger< + Namespace, + SelectedActions, + never, + RootMessenger + > + | undefined { + if (!actionNames?.length) { + return undefined; + } + + const restrictedMethodMessenger = new Messenger< + Namespace, + SelectedActions, + never, + RootMessenger + >({ + namespace, + parent: rootMessenger, + }); + + rootMessenger.delegate({ + actions: actionNames, + messenger: restrictedMethodMessenger, + }); + + return restrictedMethodMessenger; +} diff --git a/packages/permission-controller/src/errors.test.ts b/packages/permission-controller/src/errors.test.ts index 8958859418a..80263036e3b 100644 --- a/packages/permission-controller/src/errors.test.ts +++ b/packages/permission-controller/src/errors.test.ts @@ -1,6 +1,26 @@ -import { EndowmentPermissionDoesNotExistError } from './errors'; +import { + CaveatMergeTypeMismatchError, + EndowmentPermissionDoesNotExistError, +} from './errors.js'; describe('error', () => { + describe('CaveatMergeTypeMismatchError', () => { + it('has the expected shape', () => { + expect(new CaveatMergeTypeMismatchError('foo', 'bar').data).toStrictEqual( + { + leftCaveatType: 'foo', + rightCaveatType: 'bar', + }, + ); + }); + + it('has the expected name', () => { + expect(new CaveatMergeTypeMismatchError('foo', 'bar').name).toBe( + 'CaveatMergeTypeMismatchError', + ); + }); + }); + describe('EndowmentPermissionDoesNotExistError', () => { it('adds origin argument to data property', () => { expect( @@ -15,5 +35,11 @@ describe('error', () => { new EndowmentPermissionDoesNotExistError('bar').data, ).toBeUndefined(); }); + + it('has the expected name', () => { + expect(new EndowmentPermissionDoesNotExistError('bar').name).toBe( + 'EndowmentPermissionDoesNotExistError', + ); + }); }); }); diff --git a/packages/permission-controller/src/errors.ts b/packages/permission-controller/src/errors.ts index 43a80b7d87f..37bc6b7853f 100644 --- a/packages/permission-controller/src/errors.ts +++ b/packages/permission-controller/src/errors.ts @@ -1,4 +1,8 @@ -import type { DataWithOptionalCause } from '@metamask/rpc-errors'; +import { + DataWithOptionalCause, + EthereumProviderError, + OptionalDataWithOptionalCause, +} from '@metamask/rpc-errors'; import { errorCodes, providerErrors, @@ -6,7 +10,9 @@ import { JsonRpcError, } from '@metamask/rpc-errors'; -import type { PermissionType } from './Permission'; +import type { CaveatConstraint } from './Caveat.js'; +import type { PermissionType } from './Permission.js'; +import type { PermissionDiffMap } from './PermissionController.js'; type UnauthorizedArg = { data?: Record; @@ -19,7 +25,9 @@ type UnauthorizedArg = { * @param opts - Optional arguments that add extra context * @returns The built error */ -export function unauthorized(opts: UnauthorizedArg) { +export function unauthorized( + opts: UnauthorizedArg, +): EthereumProviderError { return providerErrors.unauthorized({ message: 'Unauthorized to perform action. Try requesting the required permission(s) first. For more information, see: https://docs.metamask.io/guide/rpc-api.html#permissions', @@ -34,7 +42,10 @@ export function unauthorized(opts: UnauthorizedArg) { * @param data - Optional data for context. * @returns The built error */ -export function methodNotFound(method: string, data?: DataWithOptionalCause) { +export function methodNotFound( + method: string, + data?: DataWithOptionalCause, +): JsonRpcError { const message = `The method "${method}" does not exist / is not available.`; const opts: Parameters[0] = { message }; @@ -55,7 +66,9 @@ type InvalidParamsArg = { * @param opts - Optional arguments that add extra context * @returns The built error */ -export function invalidParams(opts: InvalidParamsArg) { +export function invalidParams( + opts: InvalidParamsArg, +): JsonRpcError { return rpcErrors.invalidParams({ data: opts.data, message: opts.message, @@ -88,23 +101,58 @@ export function internalError>( return rpcErrors.internal({ message, data }); } -export class InvalidSubjectIdentifierError extends Error { +class CustomError extends Error { + constructor(message?: string) { + super(message); + this.name = this.constructor.name; + } +} + +export class InvalidSubjectIdentifierError extends CustomError { constructor(origin: unknown) { super( `Invalid subject identifier: "${ typeof origin === 'string' ? origin : typeof origin }"`, ); + this.name = this.constructor.name; } } -export class UnrecognizedSubjectError extends Error { +export class UnrecognizedSubjectError extends CustomError { constructor(origin: string) { super(`Unrecognized subject: "${origin}" has no permissions.`); + this.name = this.constructor.name; + } +} + +export class CaveatMergerDoesNotExistError extends CustomError { + constructor(caveatType: string) { + super(`Caveat value merger does not exist for type: "${caveatType}"`); } } -export class InvalidApprovedPermissionError extends Error { +export class InvalidMergedPermissionsError extends Error { + public cause: Error; + + public data: { + diff: PermissionDiffMap; + }; + + constructor( + origin: string, + cause: Error, + diff: PermissionDiffMap, + ) { + super( + `Invalid merged permissions for subject "${origin}":\n${cause.message}`, + ); + this.cause = cause; + this.data = { diff }; + } +} + +export class InvalidApprovedPermissionError extends CustomError { public data: { origin: string; target: string; @@ -122,24 +170,28 @@ export class InvalidApprovedPermissionError extends Error { this.data = { origin, target, approvedPermission }; } } -export class PermissionDoesNotExistError extends Error { +export class PermissionDoesNotExistError extends CustomError { constructor(origin: string, target: string) { super(`Subject "${origin}" has no permission for "${target}".`); } } -export class EndowmentPermissionDoesNotExistError extends Error { +export class EndowmentPermissionDoesNotExistError extends CustomError { public data?: { origin: string }; constructor(target: string, origin?: string) { - super(`Subject "${origin}" has no permission for "${target}".`); + super( + `${ + origin ? `Subject "${origin}"` : 'Unknown subject' + } has no permission for "${target}".`, + ); if (origin) { this.data = { origin }; } } } -export class UnrecognizedCaveatTypeError extends Error { +export class UnrecognizedCaveatTypeError extends CustomError { public data: { caveatType: string; origin?: string; @@ -163,7 +215,7 @@ export class UnrecognizedCaveatTypeError extends Error { } } -export class InvalidCaveatsPropertyError extends Error { +export class InvalidCaveatsPropertyError extends CustomError { public data: { origin: string; target: string; caveatsProperty: unknown }; constructor(origin: string, target: string, caveatsProperty: unknown) { @@ -174,7 +226,7 @@ export class InvalidCaveatsPropertyError extends Error { } } -export class CaveatDoesNotExistError extends Error { +export class CaveatDoesNotExistError extends CustomError { constructor(origin: string, target: string, caveatType: string) { super( `Permission for "${target}" of subject "${origin}" has no caveat of type "${caveatType}".`, @@ -182,7 +234,7 @@ export class CaveatDoesNotExistError extends Error { } } -export class CaveatAlreadyExistsError extends Error { +export class CaveatAlreadyExistsError extends CustomError { constructor(origin: string, target: string, caveatType: string) { super( `Permission for "${target}" of subject "${origin}" already has a caveat of type "${caveatType}".`, @@ -205,7 +257,7 @@ export class InvalidCaveatError extends JsonRpcError< } } -export class InvalidCaveatTypeError extends Error { +export class InvalidCaveatTypeError extends CustomError { public data: { caveat: Record; origin: string; @@ -218,7 +270,7 @@ export class InvalidCaveatTypeError extends Error { } } -export class CaveatMissingValueError extends Error { +export class CaveatMissingValueError extends CustomError { public data: { caveat: Record; origin: string; @@ -231,7 +283,7 @@ export class CaveatMissingValueError extends Error { } } -export class CaveatInvalidJsonError extends Error { +export class CaveatInvalidJsonError extends CustomError { public data: { caveat: Record; origin: string; @@ -244,7 +296,7 @@ export class CaveatInvalidJsonError extends Error { } } -export class InvalidCaveatFieldsError extends Error { +export class InvalidCaveatFieldsError extends CustomError { public data: { caveat: Record; origin: string; @@ -259,7 +311,7 @@ export class InvalidCaveatFieldsError extends Error { } } -export class ForbiddenCaveatError extends Error { +export class ForbiddenCaveatError extends CustomError { public data: { caveatType: string; origin: string; @@ -274,7 +326,7 @@ export class ForbiddenCaveatError extends Error { } } -export class DuplicateCaveatError extends Error { +export class DuplicateCaveatError extends CustomError { public data: { caveatType: string; origin: string; @@ -289,7 +341,21 @@ export class DuplicateCaveatError extends Error { } } -export class CaveatSpecificationMismatchError extends Error { +export class CaveatMergeTypeMismatchError extends CustomError { + public data: { + leftCaveatType: string; + rightCaveatType: string; + }; + + constructor(leftCaveatType: string, rightCaveatType: string) { + super( + `Cannot merge caveats of different types: "${leftCaveatType}" and "${rightCaveatType}".`, + ); + this.data = { leftCaveatType, rightCaveatType }; + } +} + +export class CaveatSpecificationMismatchError extends CustomError { public data: { caveatSpec: Record; permissionType: PermissionType; @@ -306,7 +372,7 @@ export class CaveatSpecificationMismatchError extends Error { } } -export class PermissionsRequestNotFoundError extends Error { +export class PermissionsRequestNotFoundError extends CustomError { constructor(id: string) { super(`Permissions request with id "${id}" not found.`); } diff --git a/packages/permission-controller/src/index.ts b/packages/permission-controller/src/index.ts index 0d3f11a4806..9781a532965 100644 --- a/packages/permission-controller/src/index.ts +++ b/packages/permission-controller/src/index.ts @@ -1,7 +1,43 @@ -export * from './Caveat'; -export * from './errors'; -export * from './Permission'; -export * from './PermissionController'; -export * from './utils'; -export * as permissionRpcMethods from './rpc-methods'; -export * from './SubjectMetadataController'; +export * from './Caveat.js'; +export { createRestrictedMethodMessenger } from './createRestrictedMethodMessenger.js'; +export * from './errors.js'; +export * from './Permission.js'; +export * from './PermissionController.js'; +export type { + PermissionControllerClearStateAction, + PermissionControllerExecuteRestrictedMethodAction, + PermissionControllerGetCaveatAction, + PermissionControllerGetEndowmentsAction, + PermissionControllerGetPermissionsAction, + PermissionControllerGetSubjectNamesAction, + PermissionControllerGrantPermissionsAction, + PermissionControllerGrantPermissionsIncrementalAction, + PermissionControllerHasPermissionAction, + PermissionControllerHasPermissionsAction, + PermissionControllerHasUnrestrictedMethodAction, + PermissionControllerRequestPermissionsAction, + PermissionControllerRequestPermissionsIncrementalAction, + PermissionControllerRevokeAllPermissionsAction, + PermissionControllerRevokePermissionForAllSubjectsAction, + PermissionControllerRevokePermissionsAction, + PermissionControllerUpdateCaveatAction, + PermissionControllerGetPermissionAction, + PermissionControllerRevokePermissionAction, + PermissionControllerUpdatePermissionsByCaveatAction, + PermissionControllerAcceptPermissionsRequestAction, + PermissionControllerRejectPermissionsRequestAction, +} from './PermissionController-method-action-types.js'; +export { + createPermissionMiddleware, + createPermissionMiddlewareV2, + type PermissionMiddlewareActions, +} from './permission-middleware.js'; +export type { ExtractSpecifications } from './utils.js'; +export { MethodNames } from './utils.js'; +export * from './SubjectMetadataController.js'; +export type { + SubjectMetadataControllerClearStateAction, + SubjectMetadataControllerAddSubjectMetadataAction, + SubjectMetadataControllerGetSubjectMetadataAction, + SubjectMetadataControllerTrimMetadataStateAction, +} from './SubjectMetadataController-method-action-types.js'; diff --git a/packages/permission-controller/src/permission-middleware.ts b/packages/permission-controller/src/permission-middleware.ts index c18b90c622b..9915f96ec6e 100644 --- a/packages/permission-controller/src/permission-middleware.ts +++ b/packages/permission-controller/src/permission-middleware.ts @@ -1,101 +1,109 @@ import { createAsyncMiddleware } from '@metamask/json-rpc-engine'; import type { - // eslint-disable-next-line @typescript-eslint/no-unused-vars - JsonRpcEngine, + AsyncJsonrpcMiddleware, JsonRpcMiddleware, - AsyncJsonRpcEngineNextCallback, } from '@metamask/json-rpc-engine'; -import type { - Json, - PendingJsonRpcResponse, - JsonRpcRequest, -} from '@metamask/utils'; +import type { JsonRpcMiddleware as JsonRpcMiddlewareV2 } from '@metamask/json-rpc-engine/v2'; +import type { Messenger } from '@metamask/messenger'; +import type { Json } from '@metamask/utils'; +import type { RestrictedMethodParameters } from './Permission.js'; import type { - GenericPermissionController, - PermissionSubjectMetadata, - RestrictedMethodParameters, -} from '.'; -import { internalError } from './errors'; -// eslint-disable-next-line @typescript-eslint/no-unused-vars -import type { PermissionController } from './PermissionController'; + PermissionControllerExecuteRestrictedMethodAction, + PermissionControllerHasUnrestrictedMethodAction, +} from './PermissionController-method-action-types.js'; + +/** + * The set of messenger actions required by the permission middleware. + */ +export type PermissionMiddlewareActions = + | PermissionControllerExecuteRestrictedMethodAction + | PermissionControllerHasUnrestrictedMethodAction; -type PermissionMiddlewareFactoryOptions = { - executeRestrictedMethod: GenericPermissionController['_executeRestrictedMethod']; - getRestrictedMethod: GenericPermissionController['getRestrictedMethod']; - isUnrestrictedMethod: (method: string) => boolean; +export type CreatePermissionMiddlewareOptions = { + messenger: Messenger; + origin: string; }; /** - * Creates a permission middleware function factory. Intended for internal use - * in the {@link PermissionController}. Like any {@link JsonRpcEngine} - * middleware, each middleware will only receive requests from a particular - * subject / origin. However, each middleware also requires access to some - * `PermissionController` internals, which is why this "factory factory" exists. + * Creates a JSON-RPC middleware that enforces permissions for a single subject. * - * The middlewares returned by the factory will pass through requests for - * unrestricted methods, and attempt to execute restricted methods. If a method - * is neither restricted nor unrestricted, a "method not found" error will be - * returned. - * If a method is restricted, the middleware will first attempt to retrieve the - * subject's permission for that method. If the permission is found, the method - * will be executed. Otherwise, an "unauthorized" error will be returned. + * The middleware passes through unrestricted methods, and otherwise dispatches + * restricted methods to the `PermissionController` via messenger actions. If + * the subject lacks the required permission, or if the method does not exist, + * the corresponding error is propagated to the JSON-RPC response. * + * @deprecated Use {@link createPermissionMiddlewareV2} with `JsonRpcEngineV2`. * @param options - Options bag. - * @param options.executeRestrictedMethod - {@link PermissionController._executeRestrictedMethod}. - * @param options.getRestrictedMethod - {@link PermissionController.getRestrictedMethod}. - * @param options.isUnrestrictedMethod - A function that checks whether a - * particular method is unrestricted. - * @returns A permission middleware factory function. + * @param options.messenger - A messenger with the + * `PermissionController:executeRestrictedMethod` and + * `PermissionController:hasUnrestrictedMethod` actions. + * @param options.origin - The origin of the subject for which to create the middleware. + * @returns A `json-rpc-engine` middleware. */ -export function getPermissionMiddlewareFactory({ - executeRestrictedMethod, - getRestrictedMethod, - isUnrestrictedMethod, -}: PermissionMiddlewareFactoryOptions) { - return function createPermissionMiddleware( - subject: PermissionSubjectMetadata, - ): JsonRpcMiddleware { - const { origin } = subject; - if (typeof origin !== 'string' || !origin) { - throw new Error('The subject "origin" must be a non-empty string.'); - } - - const permissionsMiddleware = async ( - req: JsonRpcRequest, - res: PendingJsonRpcResponse, - next: AsyncJsonRpcEngineNextCallback, - ): Promise => { - const { method, params } = req; +export function createPermissionMiddleware({ + messenger, + origin, +}: CreatePermissionMiddlewareOptions): JsonRpcMiddleware< + RestrictedMethodParameters, + Json +> { + const permissionsMiddleware: AsyncJsonrpcMiddleware< + RestrictedMethodParameters, + Json + > = async (request, response, next) => { + const { method, params } = request; - // Skip registered unrestricted methods. - if (isUnrestrictedMethod(method)) { - return next(); - } + if (messenger.call('PermissionController:hasUnrestrictedMethod', method)) { + return next(); + } - // This will throw if no restricted method implementation is found. - const methodImplementation = getRestrictedMethod(method, origin); + response.result = await messenger.call( + 'PermissionController:executeRestrictedMethod', + origin, + method, + params, + ); + return undefined; + }; - // This will throw if the permission does not exist. - const result = await executeRestrictedMethod( - methodImplementation, - subject, - method, - params, - ); + return createAsyncMiddleware( + permissionsMiddleware, + ); +} - if (result === undefined) { - res.error = internalError( - `Request for method "${req.method}" returned undefined result.`, - { request: req }, - ); - return undefined; - } +/** + * Creates a `JsonRpcEngineV2` middleware that enforces permissions for a + * single subject. + * + * The middleware passes through unrestricted methods, and otherwise dispatches + * restricted methods to the `PermissionController` via messenger actions. If + * the subject lacks the required permission, or if the method does not exist, + * the corresponding error is thrown. + * + * @param options - Options bag. + * @param options.messenger - A messenger with the + * `PermissionController:executeRestrictedMethod` and + * `PermissionController:hasUnrestrictedMethod` actions. + * @param options.origin - The origin of the subject for which to create the middleware. + * @returns A `JsonRpcEngineV2` middleware. + */ +export function createPermissionMiddlewareV2({ + messenger, + origin, +}: CreatePermissionMiddlewareOptions): JsonRpcMiddlewareV2 { + return async ({ request, next }) => { + const { method, params } = request; - res.result = result; - return undefined; - }; + if (messenger.call('PermissionController:hasUnrestrictedMethod', method)) { + return next(); + } - return createAsyncMiddleware(permissionsMiddleware); + return messenger.call( + 'PermissionController:executeRestrictedMethod', + origin, + method, + params, + ); }; } diff --git a/packages/permission-controller/src/rpc-methods/getPermissions.test.ts b/packages/permission-controller/src/rpc-methods/getPermissions.test.ts deleted file mode 100644 index 7ebbe4a04f1..00000000000 --- a/packages/permission-controller/src/rpc-methods/getPermissions.test.ts +++ /dev/null @@ -1,49 +0,0 @@ -import { JsonRpcEngine } from '@metamask/json-rpc-engine'; - -import { getPermissionsHandler } from './getPermissions'; - -describe('getPermissions RPC method', () => { - it('returns the values of the object returned by getPermissionsForOrigin', async () => { - const { implementation } = getPermissionsHandler; - const mockGetPermissionsForOrigin = jest.fn().mockImplementationOnce(() => { - return { a: 'a', b: 'b', c: 'c' }; - }); - - const engine = new JsonRpcEngine(); - engine.push((req, res, next, end) => - implementation(req as any, res as any, next, end, { - getPermissionsForOrigin: mockGetPermissionsForOrigin, - }), - ); - - const response: any = await engine.handle({ - jsonrpc: '2.0', - id: 1, - method: 'arbitraryName', - }); - expect(response.result).toStrictEqual(['a', 'b', 'c']); - expect(mockGetPermissionsForOrigin).toHaveBeenCalledTimes(1); - }); - - it('returns an empty array if getPermissionsForOrigin returns a falsy value', async () => { - const { implementation } = getPermissionsHandler; - const mockGetPermissionsForOrigin = jest - .fn() - .mockImplementationOnce(() => null); - - const engine = new JsonRpcEngine(); - engine.push((req, res, next, end) => - implementation(req as any, res as any, next, end, { - getPermissionsForOrigin: mockGetPermissionsForOrigin, - }), - ); - - const response: any = await engine.handle({ - jsonrpc: '2.0', - id: 1, - method: 'arbitraryName', - }); - expect(response.result).toStrictEqual([]); - expect(mockGetPermissionsForOrigin).toHaveBeenCalledTimes(1); - }); -}); diff --git a/packages/permission-controller/src/rpc-methods/getPermissions.ts b/packages/permission-controller/src/rpc-methods/getPermissions.ts deleted file mode 100644 index 807b20aedd1..00000000000 --- a/packages/permission-controller/src/rpc-methods/getPermissions.ts +++ /dev/null @@ -1,46 +0,0 @@ -import type { JsonRpcEngineEndCallback } from '@metamask/json-rpc-engine'; -import type { PendingJsonRpcResponse } from '@metamask/utils'; - -import type { PermissionConstraint } from '../Permission'; -import type { SubjectPermissions } from '../PermissionController'; -import type { PermittedHandlerExport } from '../utils'; -import { MethodNames } from '../utils'; - -export const getPermissionsHandler: PermittedHandlerExport< - GetPermissionsHooks, - [], - PermissionConstraint[] -> = { - methodNames: [MethodNames.getPermissions], - implementation: getPermissionsImplementation, - hookNames: { - getPermissionsForOrigin: true, - }, -}; - -export type GetPermissionsHooks = { - // This must be bound to the requesting origin. - getPermissionsForOrigin: () => SubjectPermissions; -}; - -/** - * Get Permissions implementation to be used in JsonRpcEngine middleware. - * - * @param _req - The JsonRpcEngine request - unused - * @param res - The JsonRpcEngine result object - * @param _next - JsonRpcEngine next() callback - unused - * @param end - JsonRpcEngine end() callback - * @param options - Method hooks passed to the method implementation - * @param options.getPermissionsForOrigin - The specific method hook needed for this method implementation - * @returns A promise that resolves to nothing - */ -async function getPermissionsImplementation( - _req: unknown, - res: PendingJsonRpcResponse, - _next: unknown, - end: JsonRpcEngineEndCallback, - { getPermissionsForOrigin }: GetPermissionsHooks, -): Promise { - res.result = Object.values(getPermissionsForOrigin() || {}); - return end(); -} diff --git a/packages/permission-controller/src/rpc-methods/index.ts b/packages/permission-controller/src/rpc-methods/index.ts deleted file mode 100644 index 22f5274926c..00000000000 --- a/packages/permission-controller/src/rpc-methods/index.ts +++ /dev/null @@ -1,12 +0,0 @@ -import type { GetPermissionsHooks } from './getPermissions'; -import { getPermissionsHandler } from './getPermissions'; -import type { RequestPermissionsHooks } from './requestPermissions'; -import { requestPermissionsHandler } from './requestPermissions'; - -export type PermittedRpcMethodHooks = RequestPermissionsHooks & - GetPermissionsHooks; - -export const handlers = [ - requestPermissionsHandler, - getPermissionsHandler, -] as const; diff --git a/packages/permission-controller/src/rpc-methods/requestPermissions.test.ts b/packages/permission-controller/src/rpc-methods/requestPermissions.test.ts deleted file mode 100644 index dfa18ef953e..00000000000 --- a/packages/permission-controller/src/rpc-methods/requestPermissions.test.ts +++ /dev/null @@ -1,150 +0,0 @@ -import { - JsonRpcEngine, - createAsyncMiddleware, -} from '@metamask/json-rpc-engine'; -import { rpcErrors, serializeError } from '@metamask/rpc-errors'; - -import { requestPermissionsHandler } from './requestPermissions'; - -describe('requestPermissions RPC method', () => { - it('returns the values of the object returned by requestPermissionsForOrigin', async () => { - const { implementation } = requestPermissionsHandler; - const mockRequestPermissionsForOrigin = jest - .fn() - .mockImplementationOnce(() => { - // Resolve this promise after a timeout to ensure that the function - // is awaited properly. - return new Promise((resolve) => { - setTimeout(() => { - resolve([{ a: 'a', b: 'b', c: 'c' }]); - }, 10); - }); - }); - - const engine = new JsonRpcEngine(); - engine.push((req, res, next, end) => - implementation(req as any, res as any, next, end, { - requestPermissionsForOrigin: mockRequestPermissionsForOrigin, - }), - ); - - const response: any = await engine.handle({ - jsonrpc: '2.0', - id: 1, - method: 'arbitraryName', - params: [{}], - }); - - expect(response.result).toStrictEqual(['a', 'b', 'c']); - expect(mockRequestPermissionsForOrigin).toHaveBeenCalledTimes(1); - expect(mockRequestPermissionsForOrigin).toHaveBeenCalledWith({}, '1'); - }); - - it('returns an error if requestPermissionsForOrigin rejects', async () => { - const { implementation } = requestPermissionsHandler; - const mockRequestPermissionsForOrigin = jest - .fn() - .mockImplementationOnce(async () => { - throw new Error('foo'); - }); - - const engine = new JsonRpcEngine(); - const end: any = () => undefined; // this won't be called - - // Pass the middleware function to createAsyncMiddleware so the error - // is catched. - engine.push( - createAsyncMiddleware( - (req, res, next) => - implementation(req as any, res as any, next, end, { - requestPermissionsForOrigin: mockRequestPermissionsForOrigin, - }) as any, - ), - ); - - const response: any = await engine.handle({ - jsonrpc: '2.0', - id: 1, - method: 'arbitraryName', - params: [{}], - }); - - expect(response.result).toBeUndefined(); - delete response.error.stack; - delete response.error.data.cause.stack; - const expectedError = new Error('foo'); - delete expectedError.stack; - expect(response.error).toStrictEqual( - serializeError(expectedError, { shouldIncludeStack: false }), - ); - expect(mockRequestPermissionsForOrigin).toHaveBeenCalledTimes(1); - expect(mockRequestPermissionsForOrigin).toHaveBeenCalledWith({}, '1'); - }); - - it('returns an error if the request has an invalid id', async () => { - const { implementation } = requestPermissionsHandler; - const mockRequestPermissionsForOrigin = jest.fn(); - - const engine = new JsonRpcEngine(); - engine.push((req, res, next, end) => - implementation(req as any, res as any, next, end, { - requestPermissionsForOrigin: mockRequestPermissionsForOrigin, - }), - ); - - for (const invalidId of ['', null, {}]) { - const req = { - jsonrpc: '2.0', - id: invalidId, - method: 'arbitraryName', - params: [], // doesn't matter - }; - - const expectedError = rpcErrors - .invalidRequest({ - message: 'Invalid request: Must specify a valid id.', - data: { request: { ...req } }, - }) - .serialize(); - delete expectedError.stack; - - const response: any = await engine.handle(req as any); - delete response.error.stack; - expect(response.error).toStrictEqual(expectedError); - expect(mockRequestPermissionsForOrigin).not.toHaveBeenCalled(); - } - }); - - it('returns an error if the request params are invalid', async () => { - const { implementation } = requestPermissionsHandler; - const mockRequestPermissionsForOrigin = jest.fn(); - - const engine = new JsonRpcEngine(); - engine.push((req, res, next, end) => - implementation(req as any, res as any, next, end, { - requestPermissionsForOrigin: mockRequestPermissionsForOrigin, - }), - ); - - for (const invalidParams of ['foo', ['bar']]) { - const req = { - jsonrpc: '2.0', - id: 1, - method: 'arbitraryName', - params: invalidParams, - }; - - const expectedError = rpcErrors - .invalidParams({ - data: { request: { ...req } }, - }) - .serialize(); - delete expectedError.stack; - - const response: any = await engine.handle(req as any); - delete response.error.stack; - expect(response.error).toStrictEqual(expectedError); - expect(mockRequestPermissionsForOrigin).not.toHaveBeenCalled(); - } - }); -}); diff --git a/packages/permission-controller/src/rpc-methods/requestPermissions.ts b/packages/permission-controller/src/rpc-methods/requestPermissions.ts deleted file mode 100644 index 1976eb2934d..00000000000 --- a/packages/permission-controller/src/rpc-methods/requestPermissions.ts +++ /dev/null @@ -1,79 +0,0 @@ -import { isPlainObject } from '@metamask/controller-utils'; -import type { JsonRpcEngineEndCallback } from '@metamask/json-rpc-engine'; -import { rpcErrors } from '@metamask/rpc-errors'; -import type { JsonRpcRequest, PendingJsonRpcResponse } from '@metamask/utils'; - -import { invalidParams } from '../errors'; -import type { PermissionConstraint, RequestedPermissions } from '../Permission'; -import type { PermittedHandlerExport } from '../utils'; -import { MethodNames } from '../utils'; - -export const requestPermissionsHandler: PermittedHandlerExport< - RequestPermissionsHooks, - [RequestedPermissions], - PermissionConstraint[] -> = { - methodNames: [MethodNames.requestPermissions], - implementation: requestPermissionsImplementation, - hookNames: { - requestPermissionsForOrigin: true, - }, -}; - -type RequestPermissions = ( - requestedPermissions: RequestedPermissions, - id: string, -) => Promise< - [Record, { id: string; origin: string }] ->; - -export type RequestPermissionsHooks = { - requestPermissionsForOrigin: RequestPermissions; -}; - -/** - * Request Permissions implementation to be used in JsonRpcEngine middleware. - * - * @param req - The JsonRpcEngine request - * @param res - The JsonRpcEngine result object - * @param _next - JsonRpcEngine next() callback - unused - * @param end - JsonRpcEngine end() callback - * @param options - Method hooks passed to the method implementation - * @param options.requestPermissionsForOrigin - The specific method hook needed for this method implementation - * @returns A promise that resolves to nothing - */ -async function requestPermissionsImplementation( - req: JsonRpcRequest<[RequestedPermissions]>, - res: PendingJsonRpcResponse, - _next: unknown, - end: JsonRpcEngineEndCallback, - { requestPermissionsForOrigin }: RequestPermissionsHooks, -): Promise { - const { id, params } = req; - - if ( - (typeof id !== 'number' && typeof id !== 'string') || - (typeof id === 'string' && !id) - ) { - return end( - rpcErrors.invalidRequest({ - message: 'Invalid request: Must specify a valid id.', - data: { request: req }, - }), - ); - } - - if (!Array.isArray(params) || !isPlainObject(params[0])) { - return end(invalidParams({ data: { request: req } })); - } - - const [requestedPermissions] = params; - const [grantedPermissions] = await requestPermissionsForOrigin( - requestedPermissions, - String(id), - ); - - // `wallet_requestPermission` is specified to return an array. - res.result = Object.values(grantedPermissions); - return end(); -} diff --git a/packages/permission-controller/src/utils.ts b/packages/permission-controller/src/utils.ts index a031cf4199a..7d229f97446 100644 --- a/packages/permission-controller/src/utils.ts +++ b/packages/permission-controller/src/utils.ts @@ -1,26 +1,18 @@ import type { - JsonRpcEngineEndCallback, - JsonRpcEngineNextCallback, -} from '@metamask/json-rpc-engine'; -import type { - Json, - JsonRpcParams, - JsonRpcRequest, - PendingJsonRpcResponse, -} from '@metamask/utils'; - -import type { + CaveatConstraint, CaveatSpecificationConstraint, CaveatSpecificationMap, -} from './Caveat'; +} from './Caveat.js'; import type { + PermissionConstraint, PermissionSpecificationConstraint, PermissionSpecificationMap, -} from './Permission'; +} from './Permission.js'; export enum MethodNames { - requestPermissions = 'wallet_requestPermissions', - getPermissions = 'wallet_getPermissions', + RequestPermissions = 'wallet_requestPermissions', + GetPermissions = 'wallet_getPermissions', + RevokePermissions = 'wallet_revokePermissions', } /** @@ -38,39 +30,46 @@ export type ExtractSpecifications< > = SpecificationsMap[keyof SpecificationsMap]; /** - * A middleware function for handling a permitted method. + * Given two permission objects, computes 3 sets: + * - The set of caveat pairs that are common to both permissions. + * - The set of caveats that are unique to the existing permission. + * - The set of caveats that are unique to the requested permission. + * + * Assumes that the caveat arrays of both permissions are valid. + * + * @param leftPermission - The left-hand permission. + * @param rightPermission - The right-hand permission. + * @returns The sets of caveat pairs and unique caveats. */ -export type HandlerMiddlewareFunction< - T, - U extends JsonRpcParams, - V extends Json, -> = ( - req: JsonRpcRequest, - res: PendingJsonRpcResponse, - next: JsonRpcEngineNextCallback, - end: JsonRpcEngineEndCallback, - hooks: T, -) => void | Promise; +export function collectUniqueAndPairedCaveats( + leftPermission: Partial | undefined, + rightPermission: Partial, +): { + caveatPairs: [CaveatConstraint, CaveatConstraint][]; + leftUniqueCaveats: CaveatConstraint[]; + rightUniqueCaveats: CaveatConstraint[]; +} { + const leftCaveats = leftPermission?.caveats?.slice() ?? []; + const rightCaveats = rightPermission.caveats?.slice() ?? []; + const leftUniqueCaveats: CaveatConstraint[] = []; + const caveatPairs: [CaveatConstraint, CaveatConstraint][] = []; -/** - * We use a mapped object type in order to create a type that requires the - * presence of the names of all hooks for the given handler. - * This can then be used to select only the necessary hooks whenever a method - * is called for purposes of POLA. - */ -export type HookNames = { - [Property in keyof T]: true; -}; + leftCaveats.forEach((leftCaveat) => { + const rightCaveatIndex = rightCaveats.findIndex( + (rightCaveat) => rightCaveat.type === leftCaveat.type, + ); -/** - * A handler for a permitted method. - */ -export type PermittedHandlerExport< - T, - U extends JsonRpcParams, - V extends Json, -> = { - implementation: HandlerMiddlewareFunction; - hookNames: HookNames; - methodNames: string[]; -}; + if (rightCaveatIndex === -1) { + leftUniqueCaveats.push(leftCaveat); + } else { + caveatPairs.push([leftCaveat, rightCaveats[rightCaveatIndex]]); + rightCaveats.splice(rightCaveatIndex, 1); + } + }); + + return { + caveatPairs, + leftUniqueCaveats, + rightUniqueCaveats: [...rightCaveats], + }; +} diff --git a/packages/permission-controller/tsconfig.build.json b/packages/permission-controller/tsconfig.build.json index 072ac3ed138..586d1ddeb4c 100644 --- a/packages/permission-controller/tsconfig.build.json +++ b/packages/permission-controller/tsconfig.build.json @@ -8,7 +8,9 @@ "references": [ { "path": "../approval-controller/tsconfig.build.json" }, { "path": "../base-controller/tsconfig.build.json" }, - { "path": "../controller-utils/tsconfig.build.json" } + { "path": "../controller-utils/tsconfig.build.json" }, + { "path": "../json-rpc-engine/tsconfig.build.json" }, + { "path": "../messenger/tsconfig.build.json" } ], "include": ["../../types", "./src"] } diff --git a/packages/permission-controller/tsconfig.json b/packages/permission-controller/tsconfig.json index 32e1ee560c3..1f4f13deae5 100644 --- a/packages/permission-controller/tsconfig.json +++ b/packages/permission-controller/tsconfig.json @@ -6,7 +6,9 @@ "references": [ { "path": "../approval-controller" }, { "path": "../base-controller" }, - { "path": "../controller-utils" } + { "path": "../controller-utils" }, + { "path": "../json-rpc-engine" }, + { "path": "../messenger" } ], "include": ["../../types", "./src"] } diff --git a/packages/permission-log-controller/CHANGELOG.md b/packages/permission-log-controller/CHANGELOG.md new file mode 100644 index 00000000000..8aa2121d980 --- /dev/null +++ b/packages/permission-log-controller/CHANGELOG.md @@ -0,0 +1,175 @@ +# 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] + +### Changed + +- Bump `@metamask/utils` from `^11.9.0` to `^11.11.0` ([#9074](https://github.com/MetaMask/core/pull/9074)) +- Bump `@metamask/json-rpc-engine` from `^10.2.4` to `^10.5.0` ([#8661](https://github.com/MetaMask/core/pull/8661), [#8746](https://github.com/MetaMask/core/pull/8746), [#8753](https://github.com/MetaMask/core/pull/8753)) +- Bump `@metamask/messenger` from `^1.0.0` to `^2.0.0` ([#8364](https://github.com/MetaMask/core/pull/8364), [#8373](https://github.com/MetaMask/core/pull/8373), [#8632](https://github.com/MetaMask/core/pull/8632), [#9392](https://github.com/MetaMask/core/pull/9392)) +- Bump `@metamask/base-controller` from `^9.0.1` to `^9.1.0` ([#8457](https://github.com/MetaMask/core/pull/8457)) + +## [5.1.0] + +### Added + +- Expose missing public `PermissionLogController` methods through its messenger ([#8201](https://github.com/MetaMask/core/pull/8201)) + - The following actions are now available: + - `PermissionLogController:createMiddleware` + - `PermissionLogController:updateAccountsHistory` + - Corresponding action types + (e.g. `PermissionLogControllerCreateMiddlewareAction`) are available as + well. + +### Changed + +- Bump `@metamask/base-controller` from `^9.0.0` to `^9.0.1` ([#8317](https://github.com/MetaMask/core/pull/8317)) +- Bump `@metamask/messenger` from `^0.3.0` to `^1.0.0` ([#8317](https://github.com/MetaMask/core/pull/8317)) +- Upgrade `@metamask/utils` from `^11.8.1` to `^11.9.0` ([#7511](https://github.com/MetaMask/core/pull/7511)) +- Bump `@metamask/json-rpc-engine` from `^10.1.1` to `^10.2.4` ([#7202](https://github.com/MetaMask/core/pull/7202), [#7642](https://github.com/MetaMask/core/pull/7642), [#7856](https://github.com/MetaMask/core/pull/7856), [#8078](https://github.com/MetaMask/core/pull/8078), [#8317](https://github.com/MetaMask/core/pull/8317)) + +## [5.0.0] + +### Added + +- Export new messenger action and event types: `PermissionLogControllerActions`, `PermissionLogControllerGetStateAction`, `PermissionLogControllerEvents`, and `PermissionLogControllerStateChangeEvent` ([#6536](https://github.com/MetaMask/core/pull/6536)) + +### Changed + +- **BREAKING:** Use new `Messenger` from `@metamask/messenger` ([#6536](https://github.com/MetaMask/core/pull/6536)) + - Previously, `PermissionLogController` accepted a `RestrictedMessenger` instance from `@metamask/base-controller`. +- **BREAKING:** Metadata property `anonymous` renamed to `includeInDebugSnapshot` ([#6536](https://github.com/MetaMask/core/pull/6536)) +- Bump `@metamask/base-controller` from `^8.4.2` to `^9.0.0` ([#6962](https://github.com/MetaMask/core/pull/6962)) + +## [4.1.1] + +### Changed + +- Bump `@metamask/base-controller` from `^8.4.1` to `^8.4.2` ([#6917](https://github.com/MetaMask/core/pull/6917)) + +## [4.1.0] + +### Added + +- Add two new controller state metadata properties: `includeInStateLogs` and `usedInUi` ([#6525](https://github.com/MetaMask/core/pull/6525)) + +### Changed + +- Bump `@metamask/utils` from `^11.4.2` to `^11.8.1` ([#6588](https://github.com/MetaMask/core/pull/6588), [#6708](https://github.com/MetaMask/core/pull/6708)) +- Bump `@metamask/base-controller` from `^8.0.1` to `^8.4.1` ([#6284](https://github.com/MetaMask/core/pull/6284), [#6355](https://github.com/MetaMask/core/pull/6355), [#6465](https://github.com/MetaMask/core/pull/6465), [#6632](https://github.com/MetaMask/core/pull/6632), [#6807](https://github.com/MetaMask/core/pull/6807)) +- Bump `@metamask/json-rpc-engine` from `^10.0.3` to `^10.1.1` ([#6678](https://github.com/MetaMask/core/pull/6678), [#6807](https://github.com/MetaMask/core/pull/6807)) + +## [4.0.0] + +### Changed + +- **BREAKING:** Stop persisting `permissionActivityLog` state ([#6156](https://github.com/MetaMask/core/pull/6156)) + - This will require a migration to delete existing persisted state. +- Bump `@metamask/utils` from `^11.1.0` to `^11.4.2` ([#5301](https://github.com/MetaMask/core/pull/5301), [#6054](https://github.com/MetaMask/core/pull/6054)) +- Bump `@metamask/base-controller` from ^8.0.0 to ^8.0.1 ([#5722](https://github.com/MetaMask/core/pull/5722)) + +## [3.0.3] + +### Changed + +- Bump `@metamask/base-controller` from `^7.0.0` to `^8.0.0` ([#5079](https://github.com/MetaMask/core/pull/5079)), ([#5135](https://github.com/MetaMask/core/pull/5135)), ([#5305](https://github.com/MetaMask/core/pull/5305)) +- Bump `@metamask/json-rpc-engine` from `^10.0.1` to `^10.0.3` ([#5082](https://github.com/MetaMask/core/pull/5082)), ([#5272](https://github.com/MetaMask/core/pull/5272)) +- Bump `@metamask/utils` from `^10.0.0` to `^11.1.0` ([#5080](https://github.com/MetaMask/core/pull/5080)), ([#5223](https://github.com/MetaMask/core/pull/5223)) +- Bump `nanoid` from `^3.1.31` to `^3.3.8` ([#5073](https://github.com/MetaMask/core/pull/5073)) + +## [3.0.2] + +### Changed + +- Bump `@metamask/utils` from `^9.1.0` to `^10.0.0` ([#4831](https://github.com/MetaMask/core/pull/4831)) +- Bump `@metamask/base-controller` from `^7.0.1` to `^7.0.2` ([#4862](https://github.com/MetaMask/core/pull/4862)) +- Bump `@metamask/json-rpc-engine` from `^9.0.3` to `^10.0.1` ([#4798](https://github.com/MetaMask/core/pull/4798), [#4862](https://github.com/MetaMask/core/pull/4862)) + +### Fixed + +- Correct ESM-compatible build so that imports of the following packages that re-export other modules via `export *` are no longer corrupted: ([#5011](https://github.com/MetaMask/core/pull/5011)) + - `deep-freeze-strict` + +## [3.0.1] + +### Changed + +- Bump `@metamask/utils` from `^8.3.0` to `^9.1.0` ([#4516](https://github.com/MetaMask/core/pull/4516), [#4529](https://github.com/MetaMask/core/pull/4529)) +- Bump `@metamask/rpc-errors` from `^6.2.1` to `^6.3.1` ([#4516](https://github.com/MetaMask/core/pull/4516)) +- Bump TypeScript from `~4.9.5` to `~5.2.2` and set `moduleResolution` option to `Node16` ([#3645](https://github.com/MetaMask/core/pull/3645), [#4576](https://github.com/MetaMask/core/pull/4576), [#4584](https://github.com/MetaMask/core/pull/4584)) + +### Fixed + +- Produce and export ESM-compatible TypeScript type declaration files in addition to CommonJS-compatible declaration files ([#4648](https://github.com/MetaMask/core/pull/4648)) + - Previously, this package shipped with only one variant of type declaration + files, and these files were only CommonJS-compatible, and the `exports` + field in `package.json` linked to these files. This is an anti-pattern and + was rightfully flagged by the + ["Are the Types Wrong?"](https://arethetypeswrong.github.io/) tool as + ["masquerading as CJS"](https://github.com/arethetypeswrong/arethetypeswrong.github.io/blob/main/docs/problems/FalseCJS.md). + All of the ATTW checks now pass. +- Remove chunk files ([#4648](https://github.com/MetaMask/core/pull/4648)). + - Previously, the build tool we used to generate JavaScript files extracted + common code to "chunk" files. While this was intended to make this package + more tree-shakeable, it also made debugging more difficult for our + development teams. These chunk files are no longer present. + +## [3.0.0] + +### Changed + +- **BREAKING:** Bump minimum Node version to 18.18 ([#3611](https://github.com/MetaMask/core/pull/3611)) +- Bump `@metamask/base-controller` to `^6.0.0` ([#4352](https://github.com/MetaMask/core/pull/4352)) +- Bump `@metamask/json-rpc-engine` to `^9.0.0` ([#4352](https://github.com/MetaMask/core/pull/4352)) + +## [2.0.2] + +### Changed + +- Bump `@metamask/base-controller` to `^5.0.2` ([#4234](https://github.com/MetaMask/core/pull/4234)) +- Bump `@metamask/json-rpc-engine` to `^8.0.2` ([#4232](https://github.com/MetaMask/core/pull/4232)) + +## [2.0.1] + +### Fixed + +- Fix `types` field in `package.json` ([#4047](https://github.com/MetaMask/core/pull/4047)) + +## [2.0.0] + +### Added + +- **BREAKING**: Add ESM build ([#3998](https://github.com/MetaMask/core/pull/3998)) + - It's no longer possible to import files from `./dist` directly. + +### Changed + +- **BREAKING:** Bump `@metamask/base-controller` to `^5.0.0` ([#4039](https://github.com/MetaMask/core/pull/4039)) + - This version has a number of breaking changes. See the changelog for more. +- Bump `@metamask/json-rpc-engine` to `^8.0.0` ([#4039](https://github.com/MetaMask/core/pull/4039)) + +## [1.0.0] + +### Added + +- Initial release + +[Unreleased]: https://github.com/MetaMask/core/compare/@metamask/permission-log-controller@5.1.0...HEAD +[5.1.0]: https://github.com/MetaMask/core/compare/@metamask/permission-log-controller@5.0.0...@metamask/permission-log-controller@5.1.0 +[5.0.0]: https://github.com/MetaMask/core/compare/@metamask/permission-log-controller@4.1.1...@metamask/permission-log-controller@5.0.0 +[4.1.1]: https://github.com/MetaMask/core/compare/@metamask/permission-log-controller@4.1.0...@metamask/permission-log-controller@4.1.1 +[4.1.0]: https://github.com/MetaMask/core/compare/@metamask/permission-log-controller@4.0.0...@metamask/permission-log-controller@4.1.0 +[4.0.0]: https://github.com/MetaMask/core/compare/@metamask/permission-log-controller@3.0.3...@metamask/permission-log-controller@4.0.0 +[3.0.3]: https://github.com/MetaMask/core/compare/@metamask/permission-log-controller@3.0.2...@metamask/permission-log-controller@3.0.3 +[3.0.2]: https://github.com/MetaMask/core/compare/@metamask/permission-log-controller@3.0.1...@metamask/permission-log-controller@3.0.2 +[3.0.1]: https://github.com/MetaMask/core/compare/@metamask/permission-log-controller@3.0.0...@metamask/permission-log-controller@3.0.1 +[3.0.0]: https://github.com/MetaMask/core/compare/@metamask/permission-log-controller@2.0.2...@metamask/permission-log-controller@3.0.0 +[2.0.2]: https://github.com/MetaMask/core/compare/@metamask/permission-log-controller@2.0.1...@metamask/permission-log-controller@2.0.2 +[2.0.1]: https://github.com/MetaMask/core/compare/@metamask/permission-log-controller@2.0.0...@metamask/permission-log-controller@2.0.1 +[2.0.0]: https://github.com/MetaMask/core/compare/@metamask/permission-log-controller@1.0.0...@metamask/permission-log-controller@2.0.0 +[1.0.0]: https://github.com/MetaMask/core/releases/tag/@metamask/permission-log-controller@1.0.0 diff --git a/packages/permission-log-controller/LICENSE b/packages/permission-log-controller/LICENSE new file mode 100644 index 00000000000..27d92bbcdc9 --- /dev/null +++ b/packages/permission-log-controller/LICENSE @@ -0,0 +1,18 @@ +Copyright ConsenSys Software Inc. 2022. All rights reserved. + +You acknowledge and agree that ConsenSys Software Inc. (“ConsenSys”) (or ConsenSys’s licensors) own all legal right, title and interest in and to the work, software, application, source code, documentation and any other documents in this repository (collectively, the “Program”), including any intellectual property rights which subsist in the Program (whether those rights happen to be registered or not, and wherever in the world those rights may exist), whether in source code or any other form. + +Subject to the limited license below, you may not (and you may not permit anyone else to) distribute, publish, copy, modify, merge, combine with another program, create derivative works of, reverse engineer, decompile or otherwise attempt to extract the source code of, the Program or any part thereof, except that you may contribute to this repository. + +You are granted a non-exclusive, non-transferable, non-sublicensable license to distribute, publish, copy, modify, merge, combine with another program or create derivative works of the Program (such resulting program, collectively, the “Resulting Program”) solely for Non-Commercial Use as long as you: + 1. give prominent notice (“Notice”) with each copy of the Resulting Program that the Program is used in the Resulting Program and that the Program is the copyright of ConsenSys; and + 2. subject the Resulting Program and any distribution, publication, copy, modification, merger therewith, combination with another program or derivative works thereof to the same Notice requirement and Non-Commercial Use restriction set forth herein. + +“Non-Commercial Use” means each use as described in clauses (1)-(3) below, as reasonably determined by ConsenSys in its sole discretion: + 1. personal use for research, personal study, private entertainment, hobby projects or amateur pursuits, in each case without any anticipated commercial application; + 2. use by any charitable organization, educational institution, public research organization, public safety or health organization, environmental protection organization or government institution; or + 3. the number of monthly active users of the Resulting Program across all versions thereof and platforms globally do not exceed 10,000 at any time. + +You will not use any trade mark, service mark, trade name, logo of ConsenSys or any other company or organization in a way that is likely or intended to cause confusion about the owner or authorized user of such marks, names or logos. + +If you have any questions, comments or interest in pursuing any other use cases, please reach out to us at communications@metamask.io. diff --git a/packages/permission-log-controller/README.md b/packages/permission-log-controller/README.md new file mode 100644 index 00000000000..18faa60f2b6 --- /dev/null +++ b/packages/permission-log-controller/README.md @@ -0,0 +1,15 @@ +# `@metamask/permission-log-controller` + +Controller with middleware for logging requests and responses to restricted and permissions-related methods. + +## Installation + +`yarn add @metamask/permission-log-controller` + +or + +`npm install @metamask/permission-log-controller` + +## Contributing + +This package is part of a monorepo. Instructions for contributing can be found in the [monorepo README](https://github.com/MetaMask/core#readme). diff --git a/packages/permission-log-controller/jest.config.js b/packages/permission-log-controller/jest.config.js new file mode 100644 index 00000000000..ca084133399 --- /dev/null +++ b/packages/permission-log-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/permission-log-controller/package.json b/packages/permission-log-controller/package.json new file mode 100644 index 00000000000..d214acb37cc --- /dev/null +++ b/packages/permission-log-controller/package.json @@ -0,0 +1,81 @@ +{ + "name": "@metamask/permission-log-controller", + "version": "5.1.0", + "description": "Controller with middleware for logging requests and responses to restricted and permissions-related methods", + "keywords": [ + "Ethereum", + "MetaMask" + ], + "homepage": "https://github.com/MetaMask/core/tree/main/packages/permission-log-controller#readme", + "bugs": { + "url": "https://github.com/MetaMask/core/issues" + }, + "license": "SEE LICENSE IN LICENSE", + "repository": { + "type": "git", + "url": "https://github.com/MetaMask/core.git" + }, + "files": [ + "dist/" + ], + "sideEffects": false, + "main": "./dist/index.cjs", + "types": "./dist/index.d.cts", + "exports": { + ".": { + "import": { + "types": "./dist/index.d.mts", + "default": "./dist/index.mjs" + }, + "require": { + "types": "./dist/index.d.cts", + "default": "./dist/index.cjs" + } + }, + "./package.json": "./package.json" + }, + "publishConfig": { + "access": "public", + "registry": "https://registry.npmjs.org/" + }, + "scripts": { + "build": "ts-bridge --project tsconfig.build.json --verbose --clean --no-references", + "build:all": "ts-bridge --project tsconfig.build.json --verbose --clean", + "build:docs": "typedoc", + "changelog:update": "../../scripts/update-changelog.sh @metamask/permission-log-controller", + "changelog:validate": "../../scripts/validate-changelog.sh @metamask/permission-log-controller", + "lint:tsconfigs": "tsx ../../scripts/lint-tsconfigs/lint-tsconfigs.mts", + "lint:tsconfigs:fix": "tsx ../../scripts/lint-tsconfigs/lint-tsconfigs.mts --fix", + "messenger-action-types:check": "tsx ../../packages/messenger-cli/src/cli.ts --formatter oxfmt --esm --check", + "messenger-action-types:generate": "tsx ../../packages/messenger-cli/src/cli.ts --formatter oxfmt --esm --generate", + "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.1.0", + "@metamask/json-rpc-engine": "^10.5.0", + "@metamask/messenger": "^2.0.0", + "@metamask/utils": "^11.11.0" + }, + "devDependencies": { + "@metamask/auto-changelog": "^6.1.0", + "@ts-bridge/cli": "^0.6.4", + "@types/deep-freeze-strict": "^1.1.0", + "@types/jest": "^30.0.0", + "deep-freeze-strict": "^1.1.1", + "deepmerge": "^4.2.2", + "jest": "^30.4.2", + "nanoid": "^3.3.8", + "ts-jest": "^29.4.11", + "tsx": "^4.20.5", + "typedoc": "^0.25.13", + "typedoc-plugin-missing-exports": "^2.0.0", + "typescript": "~5.3.3" + }, + "engines": { + "node": "^18.18 || >=20" + } +} diff --git a/packages/permission-log-controller/src/PermissionLogController-method-action-types.ts b/packages/permission-log-controller/src/PermissionLogController-method-action-types.ts new file mode 100644 index 00000000000..362445e3e70 --- /dev/null +++ b/packages/permission-log-controller/src/PermissionLogController-method-action-types.ts @@ -0,0 +1,42 @@ +/** + * This file is auto generated. + * Do not edit manually. + */ + +import type { PermissionLogController } from './PermissionLogController.js'; + +/** + * Updates the exposed account history for the given origin. + * Sets the 'last seen' time to Date.now() for the given accounts. + * Does **not** update the 'lastApproved' time for the permission itself. + * Returns if the accounts array is empty. + * + * @param origin - The origin that the accounts are exposed to. + * @param accounts - The accounts. + */ +export type PermissionLogControllerUpdateAccountsHistoryAction = { + type: `PermissionLogController:updateAccountsHistory`; + handler: PermissionLogController['updateAccountsHistory']; +}; + +/** + * Create a permissions log middleware. Records permissions activity and history: + * + * Activity: requests and responses for restricted and most wallet_ methods. + * + * History: for each origin, the last time a permission was granted, including + * which accounts were exposed, if any. + * + * @returns The permissions log middleware. + */ +export type PermissionLogControllerCreateMiddlewareAction = { + type: `PermissionLogController:createMiddleware`; + handler: PermissionLogController['createMiddleware']; +}; + +/** + * Union of all PermissionLogController action types. + */ +export type PermissionLogControllerMethodActions = + | PermissionLogControllerUpdateAccountsHistoryAction + | PermissionLogControllerCreateMiddlewareAction; diff --git a/packages/permission-log-controller/src/PermissionLogController.ts b/packages/permission-log-controller/src/PermissionLogController.ts new file mode 100644 index 00000000000..f62831e3bcd --- /dev/null +++ b/packages/permission-log-controller/src/PermissionLogController.ts @@ -0,0 +1,475 @@ +import { BaseController } from '@metamask/base-controller'; +import type { + ControllerGetStateAction, + ControllerStateChangeEvent, +} from '@metamask/base-controller'; +import type { JsonRpcMiddleware } from '@metamask/json-rpc-engine'; +import type { Messenger } from '@metamask/messenger'; +import { hasProperty } from '@metamask/utils'; +import type { + Json, + JsonRpcRequest, + JsonRpcParams, + PendingJsonRpcResponse, +} from '@metamask/utils'; + +import { + LOG_IGNORE_METHODS, + LOG_LIMIT, + LOG_METHOD_TYPES, + WALLET_PREFIX, + CAVEAT_TYPES, +} from './enums.js'; +import type { PermissionLogControllerMethodActions } from './PermissionLogController-method-action-types.js'; + +export type JsonRpcRequestWithOrigin< + Params extends JsonRpcParams = JsonRpcParams, +> = JsonRpcRequest & { + origin?: string; +}; + +export type Caveat = { + type: string; + value: string[]; +}; + +export type Permission = { + parentCapability: string; + caveats?: Caveat[]; +}; + +export type PermissionActivityLog = { + id: string | number | null; + method: string; + methodType: LOG_METHOD_TYPES; + origin?: string; + requestTime: number; + responseTime: number | null; + success: boolean | null; +}; + +export type PermissionLog = { + accounts?: Record; + lastApproved?: number; +}; +export type PermissionEntry = Record; + +export type PermissionHistory = Record; + +/** + * + * Permission log controller state + * + * @property permissionHistory - permission history + * @property permissionActivityLog - permission activity logs + */ +export type PermissionLogControllerState = { + permissionHistory: PermissionHistory; + permissionActivityLog: PermissionActivityLog[]; +}; + +export type PermissionLogControllerOptions = { + restrictedMethods: Set; + state?: Partial; + messenger: PermissionLogControllerMessenger; +}; + +export type PermissionLogControllerGetStateAction = ControllerGetStateAction< + typeof name, + PermissionLogControllerState +>; + +export type PermissionLogControllerActions = + | PermissionLogControllerGetStateAction + | PermissionLogControllerMethodActions; + +export type PermissionLogControllerStateChangeEvent = + ControllerStateChangeEvent; + +export type PermissionLogControllerEvents = + PermissionLogControllerStateChangeEvent; + +export type PermissionLogControllerMessenger = Messenger< + typeof name, + PermissionLogControllerActions, + PermissionLogControllerEvents +>; + +const defaultState: PermissionLogControllerState = { + permissionHistory: {}, + permissionActivityLog: [], +}; + +const name = 'PermissionLogController'; + +const MESSENGER_EXPOSED_METHODS = [ + 'updateAccountsHistory', + 'createMiddleware', +] as const; + +/** + * Controller with middleware for logging requests and responses to restricted + * and permissions-related methods. + */ +export class PermissionLogController extends BaseController< + typeof name, + PermissionLogControllerState, + PermissionLogControllerMessenger +> { + readonly #restrictedMethods: Set; + + constructor({ + messenger, + restrictedMethods, + state, + }: PermissionLogControllerOptions) { + super({ + messenger, + name, + metadata: { + permissionHistory: { + includeInStateLogs: true, + persist: true, + includeInDebugSnapshot: false, + usedInUi: true, + }, + permissionActivityLog: { + includeInStateLogs: true, + persist: false, + includeInDebugSnapshot: false, + usedInUi: false, + }, + }, + state: { ...defaultState, ...state }, + }); + this.#restrictedMethods = restrictedMethods; + this.messenger.registerMethodActionHandlers( + this, + MESSENGER_EXPOSED_METHODS, + ); + } + + /** + * Updates the exposed account history for the given origin. + * Sets the 'last seen' time to Date.now() for the given accounts. + * Does **not** update the 'lastApproved' time for the permission itself. + * Returns if the accounts array is empty. + * + * @param origin - The origin that the accounts are exposed to. + * @param accounts - The accounts. + */ + updateAccountsHistory(origin: string, accounts: string[]): void { + if (accounts.length === 0) { + return; + } + const newEntries = { + eth_accounts: { + accounts: this.#getAccountToTimeMap(accounts, Date.now()), + }, + }; + this.#commitNewHistory(origin, newEntries); + } + + /** + * Create a permissions log middleware. Records permissions activity and history: + * + * Activity: requests and responses for restricted and most wallet_ methods. + * + * History: for each origin, the last time a permission was granted, including + * which accounts were exposed, if any. + * + * @returns The permissions log middleware. + */ + createMiddleware(): JsonRpcMiddleware { + return (req: JsonRpcRequestWithOrigin, res, next) => { + const { origin, method } = req; + const isInternal = method.startsWith(WALLET_PREFIX); + const isEthRequestAccounts = method === 'eth_requestAccounts'; + + // Determine if the method should be logged + if ( + (!LOG_IGNORE_METHODS.includes(method) && + (isInternal || this.#restrictedMethods.has(method))) || + isEthRequestAccounts + ) { + const activityEntry = this.#logRequest(req, isInternal); + + const requestedMethods = this.#getRequestedMethods(req); + + // Call next with a return handler for capturing the response + next((callback) => { + const time = Date.now(); + this.#logResponse(activityEntry, res, time); + + if (requestedMethods && !res.error && res.result && origin) { + this.#logPermissionsHistory( + requestedMethods, + origin, + res.result, + time, + isEthRequestAccounts, + ); + } + callback(); + }); + return; + } + + next(); + }; + } + + /** + * Get a map from account addresses to the given time. + * + * @param accounts - An array of addresses. + * @param time - A time, e.g. Date.now(). + * @returns A string:number map of addresses to time. + */ + #getAccountToTimeMap( + accounts: string[], + time: number, + ): Record { + return accounts.reduce( + (acc, account) => ({ + ...acc, + [account]: time, + }), + {}, + ); + } + + /** + * Creates and commits an activity log entry, without response data. + * + * @param request - The request object. + * @param isInternal - Whether the request is internal. + * @returns new added activity entry + */ + #logRequest( + request: JsonRpcRequestWithOrigin, + isInternal: boolean, + ): PermissionActivityLog { + const activityEntry: PermissionActivityLog = { + id: request.id, + method: request.method, + methodType: isInternal + ? LOG_METHOD_TYPES.internal + : LOG_METHOD_TYPES.restricted, + origin: request.origin, + requestTime: Date.now(), + responseTime: null, + success: null, + }; + this.update((state) => { + const newLogs = [...state.permissionActivityLog, activityEntry]; + state.permissionActivityLog = + // remove oldest log if exceeding size limit + newLogs.length > LOG_LIMIT ? newLogs.slice(1) : newLogs; + }); + return activityEntry; + } + + /** + * Adds response data to an existing activity log entry. + * Entry assumed already committed (i.e., in the log). + * + * @param entry - The entry to add a response to. + * @param response - The response object. + * @param time - Output from Date.now() + */ + #logResponse( + entry: PermissionActivityLog, + response: PendingJsonRpcResponse, + time: number, + ): void { + if (!entry || !response) { + return; + } + + // The JSON-RPC 2.0 specification defines "success" by the presence of + // either the "result" or "error" property. The specification forbids + // both properties from being present simultaneously, and our JSON-RPC + // stack is spec-compliant at the time of writing. + this.update((state) => { + state.permissionActivityLog = state.permissionActivityLog.map((log) => { + // Update the log entry that matches the given entry id + if (log.id === entry.id) { + return { + ...log, + success: hasProperty(response, 'result'), + responseTime: time, + }; + } + return log; + }); + }); + } + + /** + * Create new permissions history log entries, if any, and commit them. + * + * @param requestedMethods - The method names corresponding to the requested permissions. + * @param origin - The origin of the permissions request. + * @param result - The permissions request response.result. + * @param time - The time of the request, i.e. Date.now(). + * @param isEthRequestAccounts - Whether the permissions request was 'eth_requestAccounts'. + */ + #logPermissionsHistory( + requestedMethods: string[], + origin: string, + result: Json, + time: number, + isEthRequestAccounts: boolean, + ): void { + let newEntries: PermissionEntry; + + if (isEthRequestAccounts) { + // Type assertion: We are assuming that the response data contains + // a set of accounts if the RPC method is "eth_requestAccounts". + const accounts = result as string[]; + newEntries = { + eth_accounts: { + accounts: this.#getAccountToTimeMap(accounts, time), + lastApproved: time, + }, + }; + } else { + // Records new "lastApproved" times for the granted permissions, if any. + // Special handling for eth_accounts, in order to record the time the + // accounts were last seen or approved by the origin. + // Type assertion: We are assuming that the response data contains + // a set of permissions if the RPC method is "eth_requestPermissions". + const permissions = result as Permission[]; + newEntries = permissions.reduce((acc: PermissionEntry, permission) => { + const method = permission.parentCapability; + + if (!requestedMethods.includes(method)) { + return acc; + } + + if (method === 'eth_accounts') { + const accounts = this.#getAccountsFromPermission(permission); + return { + ...acc, + [method]: { + lastApproved: time, + accounts: this.#getAccountToTimeMap(accounts, time), + }, + }; + } + + return { + ...acc, + [method]: { + lastApproved: time, + }, + }; + }, {}); + } + + if (Object.keys(newEntries).length > 0) { + this.#commitNewHistory(origin, newEntries); + } + } + + /** + * Commit new entries to the permissions history log. + * Merges the history for the given origin, overwriting existing entries + * with the same key (permission name). + * + * @param origin - The requesting origin. + * @param newEntries - The new entries to commit. + */ + #commitNewHistory(origin: string, newEntries: PermissionEntry): void { + const { permissionHistory } = this.state; + + // a simple merge updates most permissions + const oldOriginHistory = permissionHistory[origin] ?? {}; + const newOriginHistory = { + ...oldOriginHistory, + ...newEntries, + }; + + // eth_accounts requires special handling, because of information + // we store about the accounts + const existingEthAccountsEntry = oldOriginHistory.eth_accounts; + const newEthAccountsEntry = newEntries.eth_accounts; + + if (existingEthAccountsEntry && newEthAccountsEntry) { + // we may intend to update just the accounts, not the permission + // itself + const lastApproved = + newEthAccountsEntry.lastApproved ?? + existingEthAccountsEntry.lastApproved; + + // merge old and new eth_accounts history entries + newOriginHistory.eth_accounts = { + lastApproved, + accounts: { + ...existingEthAccountsEntry.accounts, + ...newEthAccountsEntry.accounts, + }, + }; + } + + this.update((state) => { + state.permissionHistory = { + ...permissionHistory, + [origin]: newOriginHistory, + }; + }); + } + + /** + * Get all requested methods from a permissions request. + * + * @param request - The request object. + * @returns The names of the requested permissions. + */ + #getRequestedMethods(request: JsonRpcRequestWithOrigin): string[] | null { + const { method, params } = request; + if (method === 'eth_requestAccounts') { + return ['eth_accounts']; + } else if ( + method === `${WALLET_PREFIX}requestPermissions` && + params && + Array.isArray(params) && + params[0] && + typeof params[0] === 'object' && + !Array.isArray(params[0]) + ) { + return Object.keys(params[0]); + } + return null; + } + + /** + * Get the permitted accounts from an eth_accounts permissions object. + * Returns an empty array if the permission is not eth_accounts. + * + * @param permission - The permissions object. + * @param permission.parentCapability - The permissions parentCapability. + * @param permission.caveats - The permissions caveats. + * @returns The permitted accounts. + */ + #getAccountsFromPermission(permission: Permission): string[] { + if (permission.parentCapability !== 'eth_accounts' || !permission.caveats) { + return []; + } + + const accounts = new Set(); + for (const caveat of permission.caveats) { + if ( + caveat.type === CAVEAT_TYPES.restrictReturnedAccounts && + Array.isArray(caveat.value) + ) { + for (const value of caveat.value) { + accounts.add(value); + } + } + } + + return [...accounts]; + } +} diff --git a/packages/permission-log-controller/src/enums.ts b/packages/permission-log-controller/src/enums.ts new file mode 100644 index 00000000000..280f9205424 --- /dev/null +++ b/packages/permission-log-controller/src/enums.ts @@ -0,0 +1,24 @@ +export const WALLET_PREFIX = 'wallet_'; + +export const CAVEAT_TYPES = Object.freeze({ + restrictReturnedAccounts: 'restrictReturnedAccounts' as const, +}); + +export const LOG_IGNORE_METHODS = [ + 'wallet_registerOnboarding', + 'wallet_watchAsset', +]; + +// This enum should be called `LogMethodTypes`, with PascalCase members, but +// to maintain backwards compatibility, the rule is disabled for now. +/* eslint-disable @typescript-eslint/naming-convention */ +export enum LOG_METHOD_TYPES { + restricted = 'restricted', + internal = 'internal', +} +/* eslint-enable @typescript-eslint/naming-convention */ + +/** + * The permission activity log size limit. + */ +export const LOG_LIMIT = 100; diff --git a/packages/permission-log-controller/src/index.ts b/packages/permission-log-controller/src/index.ts new file mode 100644 index 00000000000..ea689446ece --- /dev/null +++ b/packages/permission-log-controller/src/index.ts @@ -0,0 +1,21 @@ +export { PermissionLogController } from './PermissionLogController.js'; +export type { + PermissionLogControllerUpdateAccountsHistoryAction, + PermissionLogControllerCreateMiddlewareAction, +} from './PermissionLogController-method-action-types.js'; +export type { + JsonRpcRequestWithOrigin, + Caveat, + Permission, + PermissionActivityLog, + PermissionLog, + PermissionEntry, + PermissionHistory, + PermissionLogControllerActions, + PermissionLogControllerGetStateAction, + PermissionLogControllerStateChangeEvent, + PermissionLogControllerEvents, + PermissionLogControllerMessenger, + PermissionLogControllerState, + PermissionLogControllerOptions, +} from './PermissionLogController.js'; diff --git a/packages/permission-log-controller/tests/PermissionLogController.test.ts b/packages/permission-log-controller/tests/PermissionLogController.test.ts new file mode 100644 index 00000000000..26948d9673f --- /dev/null +++ b/packages/permission-log-controller/tests/PermissionLogController.test.ts @@ -0,0 +1,985 @@ +import { deriveStateFromMetadata } from '@metamask/base-controller'; +import type { + JsonRpcEngineReturnHandler, + JsonRpcEngineNextCallback, +} from '@metamask/json-rpc-engine'; +import { Messenger, MOCK_ANY_NAMESPACE } from '@metamask/messenger'; +import type { + MessengerActions, + MessengerEvents, + MockAnyNamespace, +} from '@metamask/messenger'; +import { PendingJsonRpcResponseStruct } from '@metamask/utils'; +import type { PendingJsonRpcResponse, JsonRpcRequest } from '@metamask/utils'; +import { nanoid } from 'nanoid'; + +import { LOG_LIMIT, LOG_METHOD_TYPES } from '../src/enums.js'; +import { PermissionLogController } from '../src/PermissionLogController.js'; +import type { + Permission, + PermissionLogControllerState, + PermissionLogControllerMessenger, +} from '../src/PermissionLogController.js'; +import { constants, getters, noop } from './helpers.js'; + +const { PERMS, RPC_REQUESTS } = getters; +const { ACCOUNTS, EXPECTED_HISTORIES, SUBJECTS, PERM_NAMES, REQUEST_IDS } = + constants; + +class CustomError extends Error { + code: number; + + constructor(message: string, code: number) { + super(message); + this.code = code; + } +} + +const name = 'PermissionLogController'; + +type AllPermissionLogControllerActions = + MessengerActions; + +type AllPermissionLogControllerEvents = + MessengerEvents; + +type RootMessenger = Messenger< + MockAnyNamespace, + AllPermissionLogControllerActions, + AllPermissionLogControllerEvents +>; + +/** + * Creates and returns a root messenger for testing + * + * @returns A messenger instance + */ +function getRootMessenger(): RootMessenger { + return new Messenger({ + namespace: MOCK_ANY_NAMESPACE, + }); +} + +type ControllerMessenger = Messenger< + typeof name, + AllPermissionLogControllerActions, + AllPermissionLogControllerEvents, + RootMessenger +>; + +/** + * Creates a controller messenger for testing. + * + * @param rootMessenger - The root messenger to use. + * @returns A controller messenger. + */ +function getControllerMessenger( + rootMessenger: RootMessenger, +): ControllerMessenger { + return new Messenger< + typeof name, + AllPermissionLogControllerActions, + AllPermissionLogControllerEvents, + RootMessenger + >({ + namespace: name, + parent: rootMessenger, + }); +} + +const initController = ({ + restrictedMethods, + state, +}: { + restrictedMethods: Set; + state?: Partial; +}): { controller: PermissionLogController; rootMessenger: RootMessenger } => { + const rootMessenger = getRootMessenger(); + const messenger = getControllerMessenger(rootMessenger); + const controller = new PermissionLogController({ + messenger, + restrictedMethods, + state, + }); + return { controller, rootMessenger }; +}; + +const mockNext = + (advanceTime: boolean): JsonRpcEngineNextCallback => + (handler) => { + if (advanceTime) { + jest.advanceTimersByTime(1); + } + handler?.(noop); + }; + +const initClock = (): void => { + jest.useFakeTimers(); + jest.setSystemTime(new Date(1)); +}; + +const tearDownClock = (): void => { + jest.useRealTimers(); +}; + +const getSavedMockNext = + ( + arr: (JsonRpcEngineReturnHandler | undefined)[], + advanceTime: boolean, + ): JsonRpcEngineNextCallback => + (handler) => { + if (advanceTime) { + jest.advanceTimersByTime(1); + } + arr.push(handler); + }; + +describe('PermissionLogController', () => { + describe('createMiddleware', () => { + describe('restricted method activity log', () => { + beforeEach(() => { + initClock(); + }); + + afterAll(() => { + tearDownClock(); + }); + + it('records activity for a successful restricted method request', () => { + const { controller, rootMessenger } = initController({ + restrictedMethods: new Set(['test_method']), + }); + const logMiddleware = rootMessenger.call( + 'PermissionLogController:createMiddleware', + ); + const req = RPC_REQUESTS.test_method(SUBJECTS.a.origin); + const res = { + ...PendingJsonRpcResponseStruct.TYPE, + result: ['bar'], + }; + + logMiddleware(req, res, mockNext(true), noop); + + expect(controller.state.permissionActivityLog).toStrictEqual([ + { + id: req.id, + method: req.method, + origin: req.origin, + methodType: LOG_METHOD_TYPES.restricted, + success: true, + requestTime: 1, + responseTime: 2, + }, + ]); + }); + + it('records activity for a failed restricted method request', () => { + const { controller, rootMessenger } = initController({ + restrictedMethods: new Set(['eth_accounts']), + }); + const logMiddleware = rootMessenger.call( + 'PermissionLogController:createMiddleware', + ); + const req = RPC_REQUESTS.eth_accounts(SUBJECTS.b.origin); + const res: PendingJsonRpcResponse = { + id: REQUEST_IDS.a, + jsonrpc: '2.0', + error: new CustomError('Unauthorized.', 1), + }; + + logMiddleware(req, res, mockNext(true), noop); + + expect(controller.state.permissionActivityLog).toStrictEqual([ + { + id: req.id, + method: req.method, + origin: req.origin, + methodType: LOG_METHOD_TYPES.restricted, + success: false, + requestTime: 1, + responseTime: 2, + }, + ]); + }); + + it('records activity for a restricted method request with successful eth_requestAccounts', () => { + const { controller, rootMessenger } = initController({ + restrictedMethods: new Set([]), + }); + const logMiddleware = rootMessenger.call( + 'PermissionLogController:createMiddleware', + ); + const req = RPC_REQUESTS.eth_requestAccounts(SUBJECTS.c.origin); + const res = { + ...PendingJsonRpcResponseStruct.TYPE, + result: ACCOUNTS.c.permitted, + }; + + logMiddleware(req, res, mockNext(true), noop); + + expect(controller.state.permissionActivityLog).toStrictEqual([ + { + id: req.id, + method: req.method, + origin: req.origin, + methodType: LOG_METHOD_TYPES.restricted, + success: true, + requestTime: 1, + responseTime: 2, + }, + ]); + }); + + it('handles a restricted method request without a response', () => { + const { controller, rootMessenger } = initController({ + restrictedMethods: new Set(['test_method']), + }); + const logMiddleware = rootMessenger.call( + 'PermissionLogController:createMiddleware', + ); + const req = RPC_REQUESTS.test_method(SUBJECTS.a.origin); + // @ts-expect-error We are intentionally passing bad input. + const res: PendingJsonRpcResponse = null; + + logMiddleware(req, res, mockNext(true), noop); + + expect(controller.state.permissionActivityLog).toStrictEqual([ + { + id: req.id, + method: req.method, + origin: req.origin, + methodType: LOG_METHOD_TYPES.restricted, + success: null, + requestTime: 1, + responseTime: null, + }, + ]); + }); + + it('ensures that "request" and "response" properties are not present in log entries', () => { + const { controller, rootMessenger } = initController({ + restrictedMethods: new Set([]), + }); + const logMiddleware = rootMessenger.call( + 'PermissionLogController:createMiddleware', + ); + const req = RPC_REQUESTS.test_method(SUBJECTS.a.origin); + const res = { + ...PendingJsonRpcResponseStruct.TYPE, + result: ['bar'], + }; + + logMiddleware(req, res, mockNext(false), noop); + + controller.state.permissionActivityLog.forEach((entry) => { + expect(entry).not.toHaveProperty('request'); + expect(entry).not.toHaveProperty('response'); + }); + }); + + it('handles responses added out of order', () => { + const { controller, rootMessenger } = initController({ + restrictedMethods: new Set(['test_method']), + }); + const logMiddleware = rootMessenger.call( + 'PermissionLogController:createMiddleware', + ); + const handlerArray: JsonRpcEngineReturnHandler[] = []; + const req = RPC_REQUESTS.test_method(SUBJECTS.a.origin); + + // get make requests + const id1 = nanoid(); + req.id = id1; + const res1 = { + ...PendingJsonRpcResponseStruct.TYPE, + result: [id1], + }; + + logMiddleware(req, res1, getSavedMockNext(handlerArray, true), noop); + + const id2 = nanoid(); + req.id = id2; + const res2 = { + ...PendingJsonRpcResponseStruct.TYPE, + result: [id2], + }; + logMiddleware(req, res2, getSavedMockNext(handlerArray, true), noop); + + const id3 = nanoid(); + req.id = id3; + const res3 = { + ...PendingJsonRpcResponseStruct.TYPE, + result: [id3], + }; + logMiddleware(req, res3, getSavedMockNext(handlerArray, true), noop); + + // all entries should be in correct order + expect(controller.state.permissionActivityLog).toMatchObject([ + { + id: id1, + responseTime: null, + }, + { + id: id2, + responseTime: null, + }, + { + id: id3, + responseTime: null, + }, + ]); + + for (const i of [1, 2, 0]) { + handlerArray[i](noop); + } + + expect(controller.state.permissionActivityLog).toStrictEqual([ + { + id: id1, + method: req.method, + origin: req.origin, + methodType: LOG_METHOD_TYPES.restricted, + success: true, + requestTime: 1, + responseTime: 4, + }, + { + id: id2, + method: req.method, + origin: req.origin, + methodType: LOG_METHOD_TYPES.restricted, + success: true, + requestTime: 2, + responseTime: 4, + }, + { + id: id3, + method: req.method, + origin: req.origin, + methodType: LOG_METHOD_TYPES.restricted, + success: true, + requestTime: 3, + responseTime: 4, + }, + ]); + }); + + it('handles a lack of response', () => { + const { controller, rootMessenger } = initController({ + restrictedMethods: new Set(['test_method']), + }); + const logMiddleware = rootMessenger.call( + 'PermissionLogController:createMiddleware', + ); + const req1 = { + ...RPC_REQUESTS.test_method(SUBJECTS.a.origin), + id: REQUEST_IDS.a, + }; + + // noop for next handler prevents recording of response + logMiddleware( + req1, + { + ...PendingJsonRpcResponseStruct.TYPE, + result: ['bar'], + }, + noop, + noop, + ); + + expect(controller.state.permissionActivityLog).toStrictEqual([ + { + id: req1.id, + method: req1.method, + origin: req1.origin, + methodType: LOG_METHOD_TYPES.restricted, + success: null, + requestTime: 1, + responseTime: null, + }, + ]); + + // next request should be handled as normal + const req2 = { + ...RPC_REQUESTS.test_method(SUBJECTS.b.origin), + id: REQUEST_IDS.b, + }; + + logMiddleware( + req2, + { + ...PendingJsonRpcResponseStruct.TYPE, + result: ACCOUNTS.b.permitted, + }, + mockNext(true), + noop, + ); + + expect(controller.state.permissionActivityLog).toStrictEqual([ + { + id: req1.id, + method: req1.method, + origin: req1.origin, + methodType: LOG_METHOD_TYPES.restricted, + success: null, + requestTime: 1, + responseTime: null, + }, + { + id: req2.id, + method: req2.method, + origin: req2.origin, + methodType: LOG_METHOD_TYPES.restricted, + success: true, + requestTime: 1, + responseTime: 2, + }, + ]); + }); + + it('ignores activity for expected methods', () => { + const { controller, rootMessenger } = initController({ + restrictedMethods: new Set([]), + }); + const logMiddleware = rootMessenger.call( + 'PermissionLogController:createMiddleware', + ); + expect(controller.state.permissionActivityLog).toHaveLength(0); + + const res = { + ...PendingJsonRpcResponseStruct.TYPE, + result: ['bar'], + }; + + const ignoredMethods = [ + RPC_REQUESTS.metamask_sendDomainMetadata(SUBJECTS.c.origin, 'foobar'), + RPC_REQUESTS.custom(SUBJECTS.b.origin, 'eth_getBlockNumber'), + RPC_REQUESTS.custom(SUBJECTS.b.origin, 'net_version'), + ]; + + ignoredMethods.forEach((req) => { + logMiddleware(req, res, mockNext(false), noop); + }); + + expect(controller.state.permissionActivityLog).toHaveLength(0); + }); + + it('fills up the log to its limit without exceeding', () => { + const { controller, rootMessenger } = initController({ + restrictedMethods: new Set(['test_method']), + }); + const logMiddleware = rootMessenger.call( + 'PermissionLogController:createMiddleware', + ); + const req = RPC_REQUESTS.test_method(SUBJECTS.a.origin); + const res = { ...PendingJsonRpcResponseStruct.TYPE, result: ['bar'] }; + + for (let i = 0; i < LOG_LIMIT; i++) { + logMiddleware({ ...req, id: nanoid() }, res, mockNext(false), noop); + } + + expect(controller.state.permissionActivityLog).toHaveLength(LOG_LIMIT); + }); + + it('removes the oldest log entry when a new one is added after reaching the limit', () => { + const { controller, rootMessenger } = initController({ + restrictedMethods: new Set(['test_method']), + }); + const logMiddleware = rootMessenger.call( + 'PermissionLogController:createMiddleware', + ); + const req = RPC_REQUESTS.test_method(SUBJECTS.a.origin); + const res = { ...PendingJsonRpcResponseStruct.TYPE, result: ['bar'] }; + + for (let i = 0; i < LOG_LIMIT; i++) { + logMiddleware({ ...req, id: nanoid() }, res, mockNext(false), noop); + } + + const firstLogIdAfterFilling = + controller.state.permissionActivityLog[0].id; + + const newLogId = nanoid(); + logMiddleware({ ...req, id: newLogId }, res, mockNext(false), noop); + + expect(controller.state.permissionActivityLog).toHaveLength(LOG_LIMIT); + expect(controller.state.permissionActivityLog[0].id).not.toBe( + firstLogIdAfterFilling, + ); + expect( + controller.state.permissionActivityLog.find( + (log) => log.id === newLogId, + ), + ).toBeDefined(); + }); + + it('ensures the log does not exceed the limit when adding multiple entries', () => { + const { controller, rootMessenger } = initController({ + restrictedMethods: new Set(['test_method']), + }); + const logMiddleware = rootMessenger.call( + 'PermissionLogController:createMiddleware', + ); + const req = RPC_REQUESTS.test_method(SUBJECTS.a.origin); + const res = { ...PendingJsonRpcResponseStruct.TYPE, result: ['bar'] }; + + for (let i = 0; i < LOG_LIMIT + 5; i++) { + logMiddleware({ ...req, id: nanoid() }, res, mockNext(false), noop); + } + + expect(controller.state.permissionActivityLog).toHaveLength(LOG_LIMIT); + }); + }); + + describe('permission history log', () => { + beforeEach(() => { + initClock(); + }); + + afterEach(() => { + tearDownClock(); + }); + + it('only updates history on responses', () => { + const { controller, rootMessenger } = initController({ + restrictedMethods: new Set([]), + }); + const logMiddleware = rootMessenger.call( + 'PermissionLogController:createMiddleware', + ); + const req = RPC_REQUESTS.requestPermission( + SUBJECTS.a.origin, + PERM_NAMES.test_method, + ); + const res = { + ...PendingJsonRpcResponseStruct.TYPE, + result: [PERMS.granted.test_method()], + }; + + // noop => no response + logMiddleware(req, res, noop, noop); + + expect(controller.state.permissionHistory).toStrictEqual({}); + + // response => records granted permissions + logMiddleware(req, res, mockNext(false), noop); + + const { permissionHistory } = controller.state; + expect(Object.keys(permissionHistory)).toHaveLength(1); + expect(permissionHistory[SUBJECTS.a.origin]).toBeDefined(); + }); + + it('ignores malformed permissions requests', () => { + const { controller, rootMessenger } = initController({ + restrictedMethods: new Set([]), + }); + const logMiddleware = rootMessenger.call( + 'PermissionLogController:createMiddleware', + ); + const req = RPC_REQUESTS.requestPermission( + SUBJECTS.a.origin, + PERM_NAMES.test_method, + ); + const res = { + ...PendingJsonRpcResponseStruct.TYPE, + result: [PERMS.granted.test_method()], + }; + + // no params => no response + logMiddleware( + { + ...req, + params: undefined, + }, + res, + mockNext(false), + noop, + ); + + expect(controller.state.permissionHistory).toStrictEqual({}); + }); + + it('records and updates account history as expected', async () => { + const { controller, rootMessenger } = initController({ + restrictedMethods: new Set([]), + }); + const logMiddleware = rootMessenger.call( + 'PermissionLogController:createMiddleware', + ); + const req = RPC_REQUESTS.requestPermission( + SUBJECTS.a.origin, + PERM_NAMES.eth_accounts, + ); + const res = { + ...PendingJsonRpcResponseStruct.TYPE, + result: [PERMS.granted.eth_accounts(ACCOUNTS.a.permitted)], + }; + + logMiddleware(req, res, mockNext(false), noop); + + expect(controller.state.permissionHistory).toStrictEqual( + EXPECTED_HISTORIES.case1[0], + ); + + // mock permission requested again, with another approved account + jest.advanceTimersByTime(1); + res.result = [PERMS.granted.eth_accounts([ACCOUNTS.a.permitted[0]])]; + + logMiddleware(req, res, mockNext(false), noop); + + expect(controller.state.permissionHistory).toStrictEqual( + EXPECTED_HISTORIES.case1[1], + ); + }); + + it('handles eth_accounts response without caveats', async () => { + const { controller, rootMessenger } = initController({ + restrictedMethods: new Set([]), + }); + const logMiddleware = rootMessenger.call( + 'PermissionLogController:createMiddleware', + ); + const req = RPC_REQUESTS.requestPermission( + SUBJECTS.a.origin, + PERM_NAMES.eth_accounts, + ); + const res: PendingJsonRpcResponse = { + ...PendingJsonRpcResponseStruct.TYPE, + result: [PERMS.granted.eth_accounts(ACCOUNTS.a.permitted)], + }; + delete res.result?.[0].caveats; + + logMiddleware(req, res, mockNext(false), noop); + + expect(controller.state.permissionHistory).toStrictEqual( + EXPECTED_HISTORIES.case2[0], + ); + }); + + it('handles extra caveats for eth_accounts', async () => { + const { controller, rootMessenger } = initController({ + restrictedMethods: new Set([]), + }); + const logMiddleware = rootMessenger.call( + 'PermissionLogController:createMiddleware', + ); + const req = RPC_REQUESTS.requestPermission( + SUBJECTS.a.origin, + PERM_NAMES.eth_accounts, + ); + const res = { + ...PendingJsonRpcResponseStruct.TYPE, + result: [PERMS.granted.eth_accounts(ACCOUNTS.a.permitted)], + }; + // @ts-expect-error We are intentionally passing bad input. + res.result[0].caveats.push({ foo: 'bar' }); + + logMiddleware(req, res, mockNext(false), noop); + + expect(controller.state.permissionHistory).toStrictEqual( + EXPECTED_HISTORIES.case1[0], + ); + }); + + // wallet_requestPermissions returns all permissions approved for the + // requesting origin, including old ones + it('handles unrequested permissions on the response', async () => { + const { controller, rootMessenger } = initController({ + restrictedMethods: new Set([]), + }); + const logMiddleware = rootMessenger.call( + 'PermissionLogController:createMiddleware', + ); + const req = RPC_REQUESTS.requestPermission( + SUBJECTS.a.origin, + PERM_NAMES.eth_accounts, + ); + const res = { + ...PendingJsonRpcResponseStruct.TYPE, + result: [ + PERMS.granted.eth_accounts(ACCOUNTS.a.permitted), + PERMS.granted.test_method(), + ], + }; + + logMiddleware(req, res, mockNext(false), noop); + + expect(controller.state.permissionHistory).toStrictEqual( + EXPECTED_HISTORIES.case1[0], + ); + }); + + it('does not update history if no new permissions are approved', async () => { + const { controller, rootMessenger } = initController({ + restrictedMethods: new Set([]), + }); + const logMiddleware = rootMessenger.call( + 'PermissionLogController:createMiddleware', + ); + let req = RPC_REQUESTS.requestPermission( + SUBJECTS.a.origin, + PERM_NAMES.test_method, + ); + let res = { + ...PendingJsonRpcResponseStruct.TYPE, + result: [PERMS.granted.test_method()], + }; + + logMiddleware(req, res, mockNext(false), noop); + + expect(controller.state.permissionHistory).toStrictEqual( + EXPECTED_HISTORIES.case4[0], + ); + + // new permission requested, but not approved + jest.advanceTimersByTime(1); + + req = RPC_REQUESTS.requestPermission( + SUBJECTS.a.origin, + PERM_NAMES.eth_accounts, + ); + res = { + ...PendingJsonRpcResponseStruct.TYPE, + result: [PERMS.granted.test_method()], + }; + + logMiddleware(req, res, mockNext(false), noop); + + // history should be unmodified + expect(controller.state.permissionHistory).toStrictEqual( + EXPECTED_HISTORIES.case4[0], + ); + }); + + it('records and updates history for multiple origins, regardless of response order', async () => { + const { controller, rootMessenger } = initController({ + restrictedMethods: new Set([]), + }); + const logMiddleware = rootMessenger.call( + 'PermissionLogController:createMiddleware', + ); + + const round1: { + req: JsonRpcRequest; + res: PendingJsonRpcResponse; + }[] = [ + { + req: RPC_REQUESTS.requestPermission( + SUBJECTS.a.origin, + PERM_NAMES.test_method, + ), + res: { + ...PendingJsonRpcResponseStruct.TYPE, + result: [PERMS.granted.test_method()], + }, + }, + { + req: RPC_REQUESTS.requestPermission( + SUBJECTS.b.origin, + PERM_NAMES.eth_accounts, + ), + res: { + ...PendingJsonRpcResponseStruct.TYPE, + result: [PERMS.granted.eth_accounts(ACCOUNTS.b.permitted)], + }, + }, + { + req: RPC_REQUESTS.requestPermissions(SUBJECTS.c.origin, { + [PERM_NAMES.test_method]: {}, + [PERM_NAMES.eth_accounts]: {}, + }), + res: { + ...PendingJsonRpcResponseStruct.TYPE, + result: [ + PERMS.granted.test_method(), + PERMS.granted.eth_accounts(ACCOUNTS.c.permitted), + ], + }, + }, + ]; + const handlers1: JsonRpcEngineReturnHandler[] = []; + + // make requests and process responses out of order + round1.forEach(({ req, res }) => { + logMiddleware(req, res, getSavedMockNext(handlers1, false), noop); + }); + + for (const i of [1, 2, 0]) { + handlers1[i](noop); + } + + expect(controller.state.permissionHistory).toStrictEqual( + EXPECTED_HISTORIES.case3[0], + ); + + // make next round of requests + jest.advanceTimersByTime(1); + + // nothing for second origin in this round + const round2: { + req: JsonRpcRequest; + res: PendingJsonRpcResponse; + }[] = [ + { + req: RPC_REQUESTS.requestPermission( + SUBJECTS.a.origin, + PERM_NAMES.test_method, + ), + res: { + ...PendingJsonRpcResponseStruct.TYPE, + result: [PERMS.granted.test_method()], + }, + }, + { + req: RPC_REQUESTS.requestPermissions(SUBJECTS.c.origin, { + [PERM_NAMES.eth_accounts]: {}, + }), + res: { + ...PendingJsonRpcResponseStruct.TYPE, + result: [PERMS.granted.eth_accounts(ACCOUNTS.b.permitted)], + }, + }, + ]; + + round2.forEach(({ req, res }) => { + logMiddleware(req, res, mockNext(false), noop); + }); + + expect(controller.state.permissionHistory).toStrictEqual( + EXPECTED_HISTORIES.case3[1], + ); + }); + }); + }); + + describe('updateAccountsHistory', () => { + beforeEach(() => { + initClock(); + }); + + afterEach(() => { + tearDownClock(); + }); + + it('does nothing if the list of accounts is empty', () => { + const { controller, rootMessenger } = initController({ + restrictedMethods: new Set([]), + }); + + rootMessenger.call( + 'PermissionLogController:updateAccountsHistory', + 'foo.com', + [], + ); + + expect(controller.state.permissionHistory).toStrictEqual({}); + }); + + it('updates the account history', () => { + const { controller, rootMessenger } = initController({ + restrictedMethods: new Set(['eth_accounts']), + state: { + permissionHistory: { + 'foo.com': { + [PERM_NAMES.eth_accounts]: { + accounts: { + '0x1': 1, + }, + lastApproved: 1, + }, + }, + }, + }, + }); + + jest.advanceTimersByTime(1); + rootMessenger.call( + 'PermissionLogController:updateAccountsHistory', + 'foo.com', + ['0x1', '0x2'], + ); + + expect(controller.state.permissionHistory).toStrictEqual({ + 'foo.com': { + [PERM_NAMES.eth_accounts]: { + accounts: { + '0x1': 2, + '0x2': 2, + }, + lastApproved: 1, + }, + }, + }); + }); + }); + + describe('metadata', () => { + it('includes expected state in debug snapshots', () => { + const { controller } = initController({ + restrictedMethods: new Set(['test_method']), + }); + + expect( + deriveStateFromMetadata( + controller.state, + controller.metadata, + 'includeInDebugSnapshot', + ), + ).toMatchInlineSnapshot(`{}`); + }); + + it('includes expected state in state logs', () => { + const { controller } = initController({ + restrictedMethods: new Set(['test_method']), + }); + + expect( + deriveStateFromMetadata( + controller.state, + controller.metadata, + 'includeInStateLogs', + ), + ).toMatchInlineSnapshot(` + { + "permissionActivityLog": [], + "permissionHistory": {}, + } + `); + }); + + it('persists expected state', () => { + const { controller } = initController({ + restrictedMethods: new Set(['test_method']), + }); + + expect( + deriveStateFromMetadata( + controller.state, + controller.metadata, + 'persist', + ), + ).toMatchInlineSnapshot(` + { + "permissionHistory": {}, + } + `); + }); + + it('exposes expected state to UI', () => { + const { controller } = initController({ + restrictedMethods: new Set(['test_method']), + }); + + expect( + deriveStateFromMetadata( + controller.state, + controller.metadata, + 'usedInUi', + ), + ).toMatchInlineSnapshot(` + { + "permissionHistory": {}, + } + `); + }); + }); +}); diff --git a/packages/permission-log-controller/tests/helpers.ts b/packages/permission-log-controller/tests/helpers.ts new file mode 100644 index 00000000000..b39f310124a --- /dev/null +++ b/packages/permission-log-controller/tests/helpers.ts @@ -0,0 +1,417 @@ +import { + Caveat, + JsonRpcRequestWithOrigin, + Permission, +} from '@metamask/permission-log-controller'; +import { JsonRpcRequestStruct } from '@metamask/utils'; +import type { Json } from '@metamask/utils'; +import deepFreeze from 'deep-freeze-strict'; + +import { CAVEAT_TYPES } from '../src/enums.js'; + +/** + * This file contains mocks for the PermissionLogController tests. + */ + +export const noop = (): undefined => undefined; + +const keyringAccounts = deepFreeze([ + '0x0dcd5d886577d5081b0c52e242ef29e70be3e7bc', + '0xc42edfcc21ed14dda456aa0756c153f7985d8813', + '0x7ae1cdd37bcbdb0e1f491974da8022bfdbf9c2bf', + '0xcc74c7a59194e5d9268476955650d1e285be703c', +]); + +const SUBJECTS = { + a: { origin: 'https://foo.xyz' }, + b: { origin: 'https://bar.abc' }, + c: { origin: 'https://baz.def' }, +}; + +const PERM_NAMES = Object.freeze({ + eth_accounts: 'eth_accounts', + test_method: 'test_method', + does_not_exist: 'does_not_exist', +}); + +const ACCOUNTS = { + a: { + permitted: keyringAccounts.slice(0, 3), + primary: keyringAccounts[0], + }, + b: { + permitted: [keyringAccounts[0]], + primary: keyringAccounts[0], + }, + c: { + permitted: [keyringAccounts[1]], + primary: keyringAccounts[1], + }, +}; + +/** + * Helpers for getting mock caveats. + */ +const CAVEATS = { + /** + * Gets a correctly formatted eth_accounts restrictReturnedAccounts caveat. + * + * @param accounts - The accounts for the caveat + * @returns An eth_accounts restrictReturnedAccounts caveats + */ + // This name is intentional. + // eslint-disable-next-line @typescript-eslint/naming-convention + eth_accounts: (accounts: string[]): Caveat[] => { + return [ + { + type: CAVEAT_TYPES.restrictReturnedAccounts, + value: accounts, + }, + ]; + }, +}; + +/** + * Each function here corresponds to what would be a type or interface consumed + * by permissions controller functions if we used TypeScript. + */ +const PERMS = { + /** + * Requested permissions objects, as passed to wallet_requestPermissions. + */ + requests: { + /** + * eth_accounts + * + * @returns A permissions request object with eth_accounts + */ + // This name is intentional. + // eslint-disable-next-line @typescript-eslint/naming-convention + eth_accounts: (): Json => { + return { eth_accounts: {} }; + }, + + /** + * test_method + * + * @returns A permissions request object with test_method + */ + // This name is intentional. + // eslint-disable-next-line @typescript-eslint/naming-convention + test_method: (): Json => { + return { test_method: {} }; + }, + + /** + * does_not_exist + * + * @returns A permissions request object with does_not_exist + */ + // This name is intentional. + // eslint-disable-next-line @typescript-eslint/naming-convention + does_not_exist: (): Json => { + return { does_not_exist: {} }; + }, + }, + + /** + * Partial members of res.result for successful: + * - wallet_requestPermissions + * - wallet_getPermissions + */ + granted: { + /** + * eth_accounts + * + * @param accounts - The accounts for the eth_accounts permission caveat + * @returns A granted permissions object with eth_accounts and its caveat + */ + // This name is intentional. + // eslint-disable-next-line @typescript-eslint/naming-convention + eth_accounts: (accounts: string[]): Permission => { + return { + parentCapability: PERM_NAMES.eth_accounts, + caveats: CAVEATS.eth_accounts(accounts), + }; + }, + + /** + * test_method + * + * @returns A granted permissions object with test_method + */ + // This name is intentional. + // eslint-disable-next-line @typescript-eslint/naming-convention + test_method: (): Permission => { + return { + parentCapability: PERM_NAMES.test_method, + }; + }, + }, +}; + +/** + * Objects with function values for getting correctly formatted permissions, + * caveats, errors, permissions requests etc. + */ +export const getters = deepFreeze({ + PERMS, + + /** + * Getters for mock RPC request objects. + */ + RPC_REQUESTS: { + /** + * Gets an arbitrary RPC request object. + * + * @param origin - The origin of the request + * @param method - The request method + * @param [params] - The request parameters + * @param [id] - The request id + * @returns An RPC request object + */ + custom: (origin: string, method: string, params?: Json[], id?: string) => { + const req = { + ...JsonRpcRequestStruct.TYPE, + origin, + method, + params: params ?? [], + id: id ?? null, + }; + return req; + }, + + /** + * Gets an eth_accounts RPC request object. + * + * @param origin - The origin of the request + * @returns An RPC request object + */ + // This name is intentional. + // eslint-disable-next-line @typescript-eslint/naming-convention + eth_accounts: (origin: string): JsonRpcRequestWithOrigin => { + return { + ...JsonRpcRequestStruct.TYPE, + origin, + method: 'eth_accounts', + params: [], + }; + }, + + /** + * Gets a test_method RPC request object. + * + * @param origin - The origin of the request + * @param param - The request param + * @returns An RPC request object + */ + // This name is intentional. + // eslint-disable-next-line @typescript-eslint/naming-convention + test_method: (origin: string, param = false): JsonRpcRequestWithOrigin => { + return { + ...JsonRpcRequestStruct.TYPE, + origin, + method: 'test_method', + params: [param], + }; + }, + + /** + * Gets an eth_requestAccounts RPC request object. + * + * @param origin - The origin of the request + * @returns An RPC request object + */ + // This name is intentional. + // eslint-disable-next-line @typescript-eslint/naming-convention + eth_requestAccounts: (origin: string): JsonRpcRequestWithOrigin => { + return { + ...JsonRpcRequestStruct.TYPE, + origin, + method: 'eth_requestAccounts', + params: [], + }; + }, + + /** + * Gets a wallet_requestPermissions RPC request object, + * for a single permission. + * + * @param origin - The origin of the request + * @param permissionName - The name of the permission to request + * @returns An RPC request object + */ + requestPermission: ( + origin: string, + permissionName: 'eth_accounts' | 'test_method' | 'does_not_exist', + ) => { + return { + ...JsonRpcRequestStruct.TYPE, + origin, + method: 'wallet_requestPermissions', + params: [PERMS.requests[permissionName]()], + }; + }, + + /** + * Gets a wallet_requestPermissions RPC request object, + * for multiple permissions. + * + * @param origin - The origin of the request + * @param permissions - A permission request object + * @returns An RPC request object + */ + requestPermissions: (origin: string, permissions = {}) => { + return { + ...JsonRpcRequestStruct.TYPE, + origin, + method: 'wallet_requestPermissions', + params: [permissions], + }; + }, + + /** + * Gets a metamask_sendDomainMetadata RPC request object. + * + * @param origin - The origin of the request + * @param name - The subjectMetadata name + * @param args - Any other data for the request's subjectMetadata + * @returns An RPC request object + */ + // This name is intentional. + // eslint-disable-next-line @typescript-eslint/naming-convention + metamask_sendDomainMetadata: ( + origin: string, + name: string, + ...args: Json[] + ): JsonRpcRequestWithOrigin => { + return { + ...JsonRpcRequestStruct.TYPE, + origin, + method: 'metamask_sendDomainMetadata', + params: { + ...args, + name, + }, + }; + }, + }, +}); + +/** + * Objects with immutable mock values. + */ +export const constants = deepFreeze({ + REQUEST_IDS: { + a: '1', + b: '2', + c: '3', + }, + + SUBJECTS: { ...SUBJECTS }, + + ACCOUNTS: { ...ACCOUNTS }, + + PERM_NAMES: { ...PERM_NAMES }, + + /** + * Mock permissions history objects. + */ + EXPECTED_HISTORIES: { + case1: [ + { + [SUBJECTS.a.origin]: { + [PERM_NAMES.eth_accounts]: { + lastApproved: 1, + accounts: { + [ACCOUNTS.a.permitted[0]]: 1, + [ACCOUNTS.a.permitted[1]]: 1, + [ACCOUNTS.a.permitted[2]]: 1, + }, + }, + }, + }, + { + [SUBJECTS.a.origin]: { + [PERM_NAMES.eth_accounts]: { + lastApproved: 2, + accounts: { + [ACCOUNTS.a.permitted[0]]: 2, + [ACCOUNTS.a.permitted[1]]: 1, + [ACCOUNTS.a.permitted[2]]: 1, + }, + }, + }, + }, + ], + + case2: [ + { + [SUBJECTS.a.origin]: { + [PERM_NAMES.eth_accounts]: { + lastApproved: 1, + accounts: {}, + }, + }, + }, + ], + + case3: [ + { + [SUBJECTS.a.origin]: { + [PERM_NAMES.test_method]: { lastApproved: 1 }, + }, + [SUBJECTS.b.origin]: { + [PERM_NAMES.eth_accounts]: { + lastApproved: 1, + accounts: { + [ACCOUNTS.b.permitted[0]]: 1, + }, + }, + }, + [SUBJECTS.c.origin]: { + [PERM_NAMES.test_method]: { lastApproved: 1 }, + [PERM_NAMES.eth_accounts]: { + lastApproved: 1, + accounts: { + [ACCOUNTS.c.permitted[0]]: 1, + }, + }, + }, + }, + { + [SUBJECTS.a.origin]: { + [PERM_NAMES.test_method]: { lastApproved: 2 }, + }, + [SUBJECTS.b.origin]: { + [PERM_NAMES.eth_accounts]: { + lastApproved: 1, + accounts: { + [ACCOUNTS.b.permitted[0]]: 1, + }, + }, + }, + [SUBJECTS.c.origin]: { + [PERM_NAMES.test_method]: { lastApproved: 1 }, + [PERM_NAMES.eth_accounts]: { + lastApproved: 2, + accounts: { + [ACCOUNTS.c.permitted[0]]: 1, + [ACCOUNTS.b.permitted[0]]: 2, + }, + }, + }, + }, + ], + + case4: [ + { + [SUBJECTS.a.origin]: { + [PERM_NAMES.test_method]: { + lastApproved: 1, + }, + }, + }, + ], + }, +}); diff --git a/packages/permission-log-controller/tests/index.test.ts b/packages/permission-log-controller/tests/index.test.ts new file mode 100644 index 00000000000..36132f46c74 --- /dev/null +++ b/packages/permission-log-controller/tests/index.test.ts @@ -0,0 +1,11 @@ +import * as allExports from '../src/index.js'; + +describe('Package exports', () => { + it('has expected exports', () => { + expect(Object.keys(allExports)).toMatchInlineSnapshot(` + [ + "PermissionLogController", + ] + `); + }); +}); diff --git a/packages/permission-log-controller/tsconfig.build.json b/packages/permission-log-controller/tsconfig.build.json new file mode 100644 index 00000000000..eb701f1cf26 --- /dev/null +++ b/packages/permission-log-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": "../json-rpc-engine/tsconfig.build.json" }, + { "path": "../messenger/tsconfig.build.json" } + ], + "include": ["../../types", "./src"] +} diff --git a/packages/permission-log-controller/tsconfig.json b/packages/permission-log-controller/tsconfig.json new file mode 100644 index 00000000000..79ee5fbbc9a --- /dev/null +++ b/packages/permission-log-controller/tsconfig.json @@ -0,0 +1,12 @@ +{ + "extends": "../../tsconfig.packages.json", + "compilerOptions": { + "baseUrl": "./" + }, + "references": [ + { "path": "../base-controller" }, + { "path": "../json-rpc-engine" }, + { "path": "../messenger" } + ], + "include": ["../../types", "./src", "./tests"] +} diff --git a/packages/permission-log-controller/typedoc.json b/packages/permission-log-controller/typedoc.json new file mode 100644 index 00000000000..c9da015dbf8 --- /dev/null +++ b/packages/permission-log-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/perps-controller/.sync-state.json b/packages/perps-controller/.sync-state.json new file mode 100644 index 00000000000..41982064198 --- /dev/null +++ b/packages/perps-controller/.sync-state.json @@ -0,0 +1,8 @@ +{ + "lastSyncedMobileCommit": "cc154d351581605282f5a70f8749565956d42b36", + "lastSyncedMobileBranch": "TAT-3187-perps-controller-removal", + "lastSyncedCoreCommit": "fbe58b4cca248101d12df709c0092cd87f15956f", + "lastSyncedCoreBranch": "feat/perps/controller-in-core", + "lastSyncedDate": "2026-05-21T08:44:09Z", + "sourceChecksum": "b05070da67baeb718f1e926ad167863c47efb5240b19e3ac99c31766070d0f91" +} diff --git a/packages/perps-controller/CHANGELOG.md b/packages/perps-controller/CHANGELOG.md new file mode 100644 index 00000000000..ab3a9887eef --- /dev/null +++ b/packages/perps-controller/CHANGELOG.md @@ -0,0 +1,852 @@ +# 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] + +## [14.0.0] + +### Added + +- **BREAKING:** Add `PerpsController.previewPositionModify` and `PerpsProvider.previewPositionModify` so clients can read an isolated-margin post-trade projection without placing an order ([#9968](https://github.com/MetaMask/core/pull/9968)) + - Mobile supplies the live position and proposed order; the HyperLiquid provider fetches the asset's margin table and applies selected leverage to the whole resulting position (matching `updateLeverage` before placement). + - The result is a discriminated union (`none` / `unsupported` / `full_close` / `open`) so a non-modifying preview cannot carry a flip kind and a full close cannot report remaining size. Margin and liquidation availability are independent: a missing live liquidation or missing multi-tier table withholds only liquidation. + - Isolated increases, leverage changes (up or down), reductions, flips, and full closes are projected for both longs and shorts. `price` is the expected fill or resting limit; the preview does not distinguish order types. Same-direction `reduceOnly` and increases/flips without a positive price return `{ status: 'none' }`. `resulting.leverage` is mark notional / remaining isolated margin. Liquidation uses the projected mark (not average entry) because isolated `marginUsed` is mark-based equity. A missing margin-table identity withholds liquidation rather than inventing a single-tier schedule. Aggregated providers route by `providerId` / `position.providerId`. Cross-margin returns `{ status: 'unsupported', reason: 'cross_margin' }`. MYX returns `{ status: 'unsupported', reason: 'provider' }`. + - Consumers that implement `PerpsProvider` must add `previewPositionModify`. Clients should use `resulting.direction` (not the order direction) when validating TP/SL against the projected liquidation. + +### Fixed + +- Prevent transient HyperLiquid WebSocket disconnects from failing the first TP/SL update by checking builder-fee approval over HTTP ([#9997](https://github.com/MetaMask/core/pull/9997)) +- Stop reporting `TPSL_UPDATE_FAILED` from `updatePositionTPSL` when HyperLiquid accepts a trigger with `waitingForTrigger`; accepted triggers without response order IDs are reconciled before mixed-failure cleanup ([#9995](https://github.com/MetaMask/core/pull/9995)) + +## [13.1.0] + +### Added + +- Include `direction` on pending trade configurations so a 30-second draft restores long/short with size ([#9992](https://github.com/MetaMask/core/pull/9992)) + +### Fixed + +- Preserve trigger prices and normalized trigger order types in HyperLiquid historical orders while retaining their lifecycle and execution semantics. ([#9982](https://github.com/MetaMask/core/pull/9982)) +- Classify `xyz:CBRS` and `xyz:SPCX` as stocks in the Hyperliquid fallback market map ([#9988](https://github.com/MetaMask/core/pull/9988)) + +## [13.0.0] + +### Added + +- Add the public Chase lifecycle API (`getChaseOrders` and + `suspendChaseOrders`), aggregated-provider routing, retained lifecycle + snapshots, directional max-distance stopping, and idempotent termination of + stale or already-gone child orders. Clients should map the new + `ORDER_CHASE_MAX_DISTANCE_INVALID` validation code when + exposing Chase configuration errors. Adds typed analytics interaction values + for background conversion and termination. An incomplete termination reports + `termination_pending` while its child remains cancellable. Consumers can use + the exported `CHASE_ORDER_STATUS` values instead of duplicating lifecycle + strings ([#9961](https://github.com/MetaMask/core/pull/9961)). +- **BREAKING:** Add persisted `selectedOrderType`, `orderBookPreferences`, and `visibleCandleCount` fields to `PerpsControllerState`, with controller methods and selectors for updating and reading each preference ([#9922](https://github.com/MetaMask/core/pull/9922)) + - `selectedOrderType` is shared across markets, order-book listed-by preferences default to USD totals, and visible candle count defaults to 30 with a supported range of 10–250. + - Consumers constructing a full `PerpsControllerState` must include the new fields; default state, getters, and selectors remain backward-compatible with older persisted state. +- Add per-market strategy capabilities, TWAP lifecycle records, recoverable Scale group IDs, and the Chase max-distance event ([#9948](https://github.com/MetaMask/core/pull/9948)) +- **BREAKING:** Add `ORDER_STRATEGY_ROUTE_UNAVAILABLE`, `PROVIDER_NOT_FOUND`, `PROVIDER_LIFECYCLE_STALE`, and `TPSL_PROTECTION_LOST` to `PerpsErrorCode` ([#9948](https://github.com/MetaMask/core/pull/9948)) + +### Changed + +- **BREAKING:** Support strategy orders on HyperLiquid HIP-3, route an optional `providerId` consistently, return zero MetaMask builder fee for TWAP through a provider-owned fee policy, and use 15-second unbounded Chase defaults ([#9961](https://github.com/MetaMask/core/pull/9961), [#9948](https://github.com/MetaMask/core/pull/9948)) +- Bound HyperLiquid combined-price debug payloads so large spot-market maps do not stall React Native DevTools and other CDP clients ([#9942](https://github.com/MetaMask/core/pull/9942)) +- Default `DEFAULT_PRO_LAYOUT_PREFERENCES.chartExpanded` to `true` so the chart is visible when a user first enters Pro mode; a persisted `chartExpanded` value still wins, so users who hid the chart keep it hidden ([#9920](https://github.com/MetaMask/core/pull/9920)) +- Restore pending trade configurations for 30 seconds instead of five minutes, include the `reduceOnly` setting, and clear the draft after a successful order while retaining leverage and the selected order type ([#9922](https://github.com/MetaMask/core/pull/9922)) +- Bump `@metamask/transaction-controller` from `^69.5.2` to `^69.6.1` ([#9960](https://github.com/MetaMask/core/pull/9960), [#9969](https://github.com/MetaMask/core/pull/9969)) +- Bump `@metamask/network-controller` from `^35.0.1` to `^36.0.0` ([#9969](https://github.com/MetaMask/core/pull/9969)) +- Bump `@metamask/remote-feature-flag-controller` from `^5.0.0` to `^6.0.0` ([#9945](https://github.com/MetaMask/core/pull/9945)) +- Bump `@metamask/authenticated-user-storage` from `^3.0.1` to `^3.0.2` ([#9972](https://github.com/MetaMask/core/pull/9972)) + +### Fixed + +- Keep selectively allowlisted HIP-3 markets aligned with their volume, open-interest, funding, and previous-price contexts ([#9971](https://github.com/MetaMask/core/pull/9971)) +- Display tiny nonzero funding rates as `<0.0001%` or `-<0.0001%` instead of `0.0000%` ([#9971](https://github.com/MetaMask/core/pull/9971)) +- Harden strategy and TP/SL lifecycles across partial failures, suspension, reconnects, provider changes, and teardown; validation and fee quotes now wait for initialization ([#9948](https://github.com/MetaMask/core/pull/9948)) +- Ignore missing optional MYX constructors when a consumer excludes the MYX module from its bundle ([#9942](https://github.com/MetaMask/core/pull/9942)) +- Consume rejected HyperLiquid candle unsubscriptions so cleanup cannot emit an unhandled promise rejection ([#9939](https://github.com/MetaMask/core/pull/9939)) + +## [12.2.0] + +### Added + +- Add `OrderParams.scaleSkew`, which weights a `scale` ladder's size across its rungs instead of spreading it evenly ([#9919](https://github.com/MetaMask/core/pull/9919)) + - Rung weights ramp linearly from 1 at `scaleMinPrice` to `scaleSkew` at `scaleMaxPrice`, in that direction for a buy and a sell alike. Above 1 puts more size at `scaleMaxPrice`, below 1 at `scaleMinPrice`; omitted or exactly 1 is the existing even split, unchanged. + - `splitScaleSizes` takes a matching optional `skew` and stays the single source of truth for the sizes, so a client previewing a ladder computes what placement submits. Sizes are allocated in whole size-grid units: each rung floors to its share and the leftover units go to the largest discarded fractions, ties by ascending index. The even split keeps putting its leftover on the first rung. + - A `scaleSkew` that is not a finite number above 0 is rejected by `validateOrderParams` with the existing `ORDER_SCALE_RANGE_INVALID` error code, and carrying it on any non-`scale` order type is rejected with the existing `ORDER_STRATEGY_PARAMS_NOT_SUPPORTED`. + - A skew that pushes a rung below the venue's per-order minimum or onto a zero size-grid slice is rejected before anything is signed, with the existing `ORDER_SCALE_NOTIONAL_TOO_SMALL` / `ORDER_SCALE_SIZE_TOO_SMALL`. +- Add `resolvePositionTriggerSummaryPrice` to `@metamask/perps-controller/utils`, which resolves the scalar TP/SL summary price a position reports for one direction from its trigger orders ([#9912](https://github.com/MetaMask/core/pull/9912)) + +### Fixed + +- Report the take profit (or stop loss) price on a `Position` when its only trigger for that direction is a partial, quantity-scoped one ([#9912](https://github.com/MetaMask/core/pull/9912)) + - `takeProfitPrice`/`stopLossPrice` were only ever scanned from position-bound triggers, so a position whose sole take profit closed it partially reported `takeProfitCount: 1` with no price, and clients rendering the scalar showed none. Applies to the REST `getPositions`, `getUserDataSnapshot`, and WebSocket position paths alike. + - Two or more triggers in a direction still report the scanned price, because no single price describes them and clients render the count instead. + +## [12.1.0] + +### Added + +- Add the optional `PerpsPerformance.onControllerConstructed` post-hydration timestamp hook ([#9906](https://github.com/MetaMask/core/pull/9906)) +- Add an explicit trace ID overload to `PerpsTracer.setMeasurement`, allowing clients to target preload measurements to their named trace ([#9906](https://github.com/MetaMask/core/pull/9906)) +- Add `PERPS_EVENT_PROPERTY.PREVIOUS_LEVERAGE` (`previous_leverage`) for Perp UI Interaction `leverage_changed` events so clients can import the Segment property key from `@metamask/perps-controller` instead of a local interim constant ([#9881](https://github.com/MetaMask/core/pull/9881)) + +### Changed + +- Target market and user preload measurements to their named traces, and omit wallet addresses from user-preload trace data ([#9906](https://github.com/MetaMask/core/pull/9906)) + +## [12.0.0] + +### Added + +- **BREAKING:** Add `ordersSideFilter`, `ordersSortField`, and `ordersSortDirection` to the flat `ProLayoutPreferences` object (defaults `'all'`, `'time'`, `'desc'`) so Pro Orders panel side-filter and sort preferences persist independently of Positions across markets and app restarts via the existing `getProLayoutPreferences()` / `setProLayoutPreferences(patch)` API; export `ProOrdersSideFilter`, `ProOrdersSortField`, and `ProOrdersSortDirection` ([#9862](https://github.com/MetaMask/core/pull/9862)) + - Consumers that construct a full `ProLayoutPreferences` object (instead of using `DEFAULT_PRO_LAYOUT_PREFERENCES`, the getter, or the patch setter) must include the new fields. Persisted state that predates them remains valid at runtime because the getter/selector merge over defaults. + - Orders side filter (`all` | `long` | `short`) is independent of `positionsSideFilter`. Orders sort fields are `orderValue` | `size` | `price` | `time`. +- Add `PERPS_EVENT_PROPERTY.PERPS_MODE` (`perps_mode`) for Lite/Pro interface mode analytics (`'lite' | 'pro'`), distinct from existing `PERPS_EVENT_PROPERTY.MODE` (`mode`) which is search intent (`discovery` / `intent` / `browse`) ([#9819](https://github.com/MetaMask/core/pull/9819)) +- **BREAKING:** Add `positionsSideFilter`, `positionsSortField`, and `positionsSortDirection` to the flat `ProLayoutPreferences` object (defaults `'all'`, `'positionValue'`, `'desc'`) so Pro Positions/Orders panel sort and side-filter preferences persist across markets and app restarts via the existing `getProLayoutPreferences()` / `setProLayoutPreferences(patch)` API; export `ProPositionsSideFilter`, `ProPositionsSortField`, and `ProPositionsSortDirection` ([#9838](https://github.com/MetaMask/core/pull/9838)) + - Consumers that construct a full `ProLayoutPreferences` object (instead of using `DEFAULT_PRO_LAYOUT_PREFERENCES`, the getter, or the patch setter) must include the new fields. Persisted state that predates them remains valid at runtime because the getter/selector merge over defaults. +- **BREAKING:** Add strategy placement order types to `OrderType`: `twap`, `scale`, and `chase`, placeable through `placeOrder` alongside the existing `market`, `limit`, and trigger types ([#9832](https://github.com/MetaMask/core/pull/9832)) + - `OrderType` is a wider union again, so — exactly as for the trigger types added in 11.0.0 — any consumer signature that narrows it back to a smaller set no longer accepts a value typed `OrderType`. Such signatures must widen to `OrderType` or narrow explicitly at the call site. + - A strategy placement expands one request into an execution schedule rather than a single resting order, so `OrderResult.orderId` carries a _handle_ — a venue TWAP id, or a client-generated group/session id — rather than an exchange order id. Its documentation says so; the individual exchange ids are in `childOrderIds`. + - `twap` slices the size over `OrderParams.twapDuration` whole minutes, optionally varying each suborder's size by up to ±20% with `OrderParams.twapRandomize`. On HyperLiquid it is submitted through the venue's own TWAP action, not the order book, and `HYPERLIQUID_TWAP_LIMITS` bounds the window to the pinned SDK's 5–1440-minute range. + - `scale` fans out `OrderParams.scaleNumOrders` limit orders on an inclusive price ladder between `OrderParams.scaleMinPrice` and `OrderParams.scaleMaxPrice`, submitted as a single batch. Sizes are split in whole units of the asset's size grid, so the rungs sum to exactly the submitted size. The batch is not atomic — the venue can rest some rungs and reject others — so `OrderResult.submittedSize` reports only the rungs that actually rested. + - The venue applies its minimum order value to what it receives, not to the strategy total: a `scale` ladder's notional must leave every submitted rung above the per-order minimum, and a `twap`'s total must clear the venue's own minimum TWAP size (`HYPERLIQUID_TWAP_LIMITS.MinNotionalUsd`). Both are rejected locally rather than by the exchange. The ladder check needs the asset's size grid, so it runs during placement — before anything is signed — rather than in `validateOrder`, which cannot see the grid and could only guess. + - A `chase` verifies its order is still live whenever its own price stops showing on the book, so an order that fills without the loop noticing ends the session and releases its concurrency slot instead of holding both until the window closes. + - A `chase` interrupted by `disconnect` resolves as a failure with `ORDER_CHASE_ABANDONED` rather than a success, because no strategy is running behind it. Interrupted anywhere before its submission it signs nothing at all. Interrupted while that submission is in flight — the one window it cannot check ahead of — it tries to take the order back before returning, through the client it signed with rather than one asked for after the teardown, so an account switch cannot strand it. That attempt is best-effort: the venue can refuse the cancel, and the transport underneath the client may already be closing. When it does not take, the order is reported in `OrderResult.childOrderIds`, where the ordinary single-order cancel can still reach it for as long as the provider signs as the account that placed it. + - `chase` prices against a book with _every_ chase this provider is running on that side netted out, not only its own order. Two chases each netting only themselves would read the other as the external touch and improve on it in turn, walking each other across an unchanged market. Resting inside the spread makes the chase its own best bid or ask, so reading the raw book would show its own quote as the touch and stop it re-pricing. + - At most `CHASE_ORDER_CONFIG.MaxActiveSessions` chases run at once, matching the venue's documented cap; a further placement is refused with `ORDER_CHASE_LIMIT_REACHED` before any signing setup or leverage change, and a placement reserves its slot for the round trips before its session registers so concurrent placements cannot overshoot. + - `chase` re-prices by cancelling and re-placing, and sizes each replacement from what the cancelled order left unfilled — read after the cancel has landed, when no further fill can reach it — so a child that partially filled is not re-placed at the original size. + - `chase` rests a post-only order one tick inside the spread — above the best bid for a buy, below the best ask for a sell, joining the touch when the spread is a single tick — and re-prices it as the touch moves, bounded by `OrderParams.chaseIntervalMs`, `OrderParams.chaseMaxDurationMs`, and `OrderParams.chaseMaxRepricings` (see the newly exported `CHASE_ORDER_CONFIG` for the defaults). No supported venue exposes a native chase action, so it is emulated client-side; the re-pricing loop is stopped by `cancelOrder` and by `disconnect`. + - The params model stays provider-agnostic: no protocol vocabulary appears in `OrderParams`, so a second provider can map the same fields onto its own execution primitives. +- **BREAKING:** Narrow `CalculateOrderPriceAndSizeParams.orderType` and `BuildOrdersArrayParams.orderType` to the new `OrdinaryOrderType` (`Exclude`) ([#9832](https://github.com/MetaMask/core/pull/9832)) + - These helpers resolve a single order the exchange can be handed directly. A strategy placement derives its own prices and sizes and never reaches them; passing one would price a `chase` as a limit order it carries no price for, and serialize a `twap` or `scale` as an ordinary market order. +- **BREAKING:** Narrow `ClosePositionParams.orderType` to `Exclude` ([#9832](https://github.com/MetaMask/core/pull/9832)) + - `closePosition` has no path that executes a strategy placement and `ClosePositionParams` carries none of the fields one needs, so the strategy types are refused at the type level rather than at runtime. A consumer passing a value typed `OrderType` into this field must narrow it at the call site. +- **BREAKING:** Add nineteen `PERPS_ERROR_CODES` entries covering strategy placement, editing and cancellation: `ORDER_STRATEGY_PARAMS_NOT_SUPPORTED`, `ORDER_STRATEGY_FIELD_UNSUPPORTED`, `ORDER_STRATEGY_MARKET_UNSUPPORTED`, `ORDER_STRATEGY_HANDLE_UNKNOWN`, `ORDER_STRATEGY_CANCEL_INCOMPLETE`, `ORDER_EDIT_STRATEGY_UNSUPPORTED`, `ORDER_TWAP_DURATION_REQUIRED`, `ORDER_TWAP_DURATION_INVALID`, `ORDER_TWAP_NOTIONAL_TOO_SMALL`, `ORDER_SCALE_RANGE_REQUIRED`, `ORDER_SCALE_RANGE_INVALID`, `ORDER_SCALE_COUNT_INVALID`, `ORDER_SCALE_SIZE_TOO_SMALL`, `ORDER_SCALE_NOTIONAL_TOO_SMALL`, `ORDER_CHASE_INTERVAL_INVALID`, `ORDER_CHASE_DURATION_INVALID`, `ORDER_CHASE_LIMIT_REACHED`, `ORDER_CHASE_ABANDONED`, and `ORDER_CHASE_TOUCH_UNAVAILABLE` ([#9832](https://github.com/MetaMask/core/pull/9832)) + - Like `EXCHANGE_ACCOUNT_NOT_FOUND` and the multi-sig codes in 11.0.0, this widens the exported `PerpsErrorCode` union, so consumers that key an exhaustive `Record` stop compiling until they add an entry for every new code. Both first-party clients do: Mobile's `app/components/UI/Perps/utils/translatePerpsError.ts` and Extension's `ui/components/app/perps/utils/translate-perps-error.ts`. + - These cover the rejections this package decides for itself: each is a typed code rather than an opaque exchange error, and none of them reaches the venue as a signed request. Most are decided before any request at all. The exceptions are the ladder's `ORDER_SCALE_SIZE_TOO_SMALL` and `ORDER_SCALE_NOTIONAL_TOO_SMALL`, which need the asset's size precision and so follow one read of its metadata, and `ORDER_CHASE_TOUCH_UNAVAILABLE`, which follows the order-book read — all still before anything is signed. A submission the venue itself rejects is not among them: as for an ordinary order, that surfaces through the provider's existing error mapping carrying the venue's own message. `ORDER_STRATEGY_CANCEL_INCOMPLETE` and `ORDER_CHASE_ABANDONED` describe what happened after a request and are not rejections at all. + - What the non-parameter codes mean: `ORDER_STRATEGY_MARKET_UNSUPPORTED` — a strategy was requested on a market the provider cannot run it on (on HyperLiquid, a HIP-3 sub-exchange). `ORDER_EDIT_STRATEGY_UNSUPPORTED` — `editOrder` cannot modify a strategy placement. `ORDER_STRATEGY_HANDLE_UNKNOWN` — `cancelOrder` was given a strategy handle this provider does not hold. `ORDER_STRATEGY_CANCEL_INCOMPLETE` — a cancel left part of the placement resting, and the handle stays valid for a retry. `ORDER_CHASE_TOUCH_UNAVAILABLE` — the order book had no price on the side a chase must rest at. `ORDER_CHASE_LIMIT_REACHED` — the venue's cap on simultaneous chases is already in use. `ORDER_CHASE_ABANDONED` — the provider was torn down while a chase was being placed. +- Add `CancelOrderParams.orderType`, which selects the cancellation path for a strategy handle: the venue's TWAP cancel action for `twap`, a batch cancel of every child for `scale`, and stopping the session plus cancelling its live order for `chase` ([#9832](https://github.com/MetaMask/core/pull/9832)) + - Omitting it — what every existing caller does — cancels a single resting order exactly as before. + - A cancel that leaves part of a strategy resting returns `ORDER_STRATEGY_CANCEL_INCOMPLETE` and keeps the handle valid, so the caller can retry with it. +- `editOrder` now rejects a strategy placement with `ORDER_EDIT_STRATEGY_UNSUPPORTED` instead of submitting it as an ordinary order modification ([#9832](https://github.com/MetaMask/core/pull/9832)) + - A strategy placement is not a single resting order, so there is nothing to rewrite: the edit would have gone through as a plain market/limit modification and quietly dropped the TWAP schedule, the ladder, or the chase loop. Cancel by the strategy handle and place again. +- A cancel refused because the order had already filled or been cancelled now completes rather than reporting `ORDER_STRATEGY_CANCEL_INCOMPLETE` ([#9832](https://github.com/MetaMask/core/pull/9832)) + - The venue answers a cancel it cannot match with a rejection, but nothing of that order is resting, which is what the caller asked for. Only a rejection that leaves the order on the book keeps the strategy handle open for a retry. +- Add `OrderResult.childOrderIds`, the exchange ids a strategy placement expanded into ([#9832](https://github.com/MetaMask/core/pull/9832)) + - For a `scale` ladder these stay valid — the rungs are placed once and never replaced — so a consumer that has lost the session-scoped handle can still cancel them through the existing batch cancel. + - For a `chase` this is only the order resting at placement time. The strategy cancels and re-places as the touch moves, and each replacement's id is held in the session rather than reported here, so the value goes stale on the first re-price; cancel a live chase by its handle. +- Add `OrderParams.twapDuration`, `OrderParams.twapRandomize`, `OrderParams.scaleMinPrice`, `OrderParams.scaleMaxPrice`, `OrderParams.scaleNumOrders`, `OrderParams.chaseIntervalMs`, `OrderParams.chaseMaxDurationMs`, and `OrderParams.chaseMaxRepricings` ([#9832](https://github.com/MetaMask/core/pull/9832)) + - Each field is required by the placement that owns it and rejected on every other one, so a stray field can never be silently dropped. A strategy placement also rejects `price`, `triggerPrice`, `timeInForce`, `clientOrderId`, and attached TP/SL, all of which the strategy decides for itself or cannot express — a TWAP action carries no client id, a scale ladder is many orders where a client id must be unique per order, and a chase replaces its order on every re-price. + - Invalid parameters are rejected with a typed `PERPS_ERROR_CODES` value, and nothing invalid is ever signed; see the new error codes entry below for the full list and for which few are decided after a read rather than before any request. +- Add `twap`, `scale` and `chase` to `PERPS_EVENT_VALUE.ORDER_TYPE`, which dashboards key on and which `TradingService` emits verbatim ([#9832](https://github.com/MetaMask/core/pull/9832)) +- Add the `StrategyOrderType` and `OrdinaryOrderType` types, plus `STRATEGY_ORDER_TYPES`, `isStrategyOrderType`, `SCALE_ORDER_COUNT`, `computeScalePriceLadder`, `splitScaleSizes`, `computeChaseQuotePrice`, `getPriceTick`, `CHASE_ORDER_CONFIG`, and `HYPERLIQUID_TWAP_LIMITS` ([#9832](https://github.com/MetaMask/core/pull/9832)) +- Add an optional schema-v2 Terminal market snapshot path with strict identity, freshness, completeness, unit, and payload validation before falling back to HyperLiquid. ([#9815](https://github.com/MetaMask/core/pull/9815)) +- Add `PerpsController.getUserDataSnapshot()` to fetch and cache positions, open orders, and account state as one account- and DEX-scoped result. ([#9815](https://github.com/MetaMask/core/pull/9815)) +- Add a subscription fee-waiver source to the MetaMask builder fee, wired through the optional `PerpsPlatformDependencies.subscription.getPerpsBenefits()` dependency, along with the `PerpsSubscriptionBenefits`, `PerpsSubscriptionUsage`, `PerpsSubscriptionFeeWaiverStatus`, `PerpsFeeSource`, and `PerpsFeeResolution` types and the `SUBSCRIPTION_BENEFITS_CACHE` constant ([#9857](https://github.com/MetaMask/core/pull/9857)) + - `RewardsIntegrationService.resolveFee()` returns the lowest fee across the default, rewards (VIP and season, already collapsed by `RewardsController`), and subscription sources, together with the winning source and the subscription gate outcome. The subscription source contributes `0` bips only when the eligibility gate — `status=active`, `perpsFeeWaiver` entitled, `usage=available`, not exhausted — passes on the cached benefits snapshot. + - `RewardsIntegrationService.resolveFee()` and `getSubscriptionFeeWaiverStatus()` are pure cache consumers and never start a subscription request on the order-signing path. `PerpsController.calculateFees()` owns preview hydration through `refreshSubscriptionBenefits()`. A snapshot older than `SUBSCRIPTION_BENEFITS_CACHE.MaxStaleMs` can no longer grant the waiver, and a failed or unreachable refresh falls back to the next-lowest source instead of erroring or over-granting. + - Refreshes are throttled on the last read _attempt_ rather than the last success, so a benefits outage retries at most once per `FreshMs` window instead of once per preview. + - `PerpsController.invalidateSubscriptionBenefits()` (also exposed as the `PerpsController:invalidateSubscriptionBenefits` messenger action) drops the cached snapshot. Call it on sign-out or a profile switch: the snapshot carries no profile identity, so without it the previous profile's benefits keep answering until the next successful refresh. A read already in flight when it is called is discarded rather than written back, so it cannot repopulate the cache for the previous identity. + - Clients that do not wire `subscription` are unaffected: the resolver keeps returning the rewards or default fee. +- Add `FeeCalculationResult.subscription`, surfacing the subscription waiver's `eligible`, `reason`, and `remainingNotionalUsd` on `PerpsController.calculateFees()` from the same cached benefits snapshot ([#9857](https://github.com/MetaMask/core/pull/9857)) + - The preview refreshes the benefits cache when needed, but does not adjust the quoted fee rates or mutate the notional cap. The field is omitted entirely when no `subscription` dependency is wired. + +### Changed + +- `RewardsIntegrationService.calculateUserFeeDiscount()` now returns the unified resolver's winning discount instead of the rewards discount alone, while preserving `undefined` when no source has resolved. TradingService passes the full `PerpsFeeResolution` to providers, isolates it across concurrent operations, and applies it to flip orders. HyperLiquid uses the configured subscription builder only after account-scoped approval through `PerpsController.approveSubscriptionBuilderFee()`; otherwise it uses the ordinary builder at the standard fee ([#9857](https://github.com/MetaMask/core/pull/9857)) +- `getTriggerExecution` now reports `'limit'` for `scale` and `chase`, which rest limit orders on the book without carrying an `OrderParams.price`, and `'market'` for `twap`, whose suborders cross it ([#9832](https://github.com/MetaMask/core/pull/9832)) + - This is what decides the fee tier and the max order value, so a scale ladder and a chase are no longer quoted at the taker rate or held to the tighter market-order cap. `calculateFees` additionally quotes `chase` at the maker rate regardless of `isMaker`, because a post-only order can only fill as a maker. + - `isLimitExecutionOrderType` is unchanged: it answers the narrower question of whether `OrderParams.price` carries a real limit price, which for a strategy placement it does not. +- `TriggerOrderType` is now spelled out as `'stop_market' | 'stop_limit' | 'take_profit_market' | 'take_profit_limit'` instead of being derived as `Exclude` ([#9832](https://github.com/MetaMask/core/pull/9832)) + - The resolved type is unchanged for existing consumers. Deriving it meant that any order type added to `OrderType` that was neither `market` nor `limit` was pulled into the trigger union automatically and started demanding a trigger price it had no concept of. +- Reuse provider DEX discovery for subscriptions, and start account preloading independently from market preloading to reduce cold-start blocking. ([#9815](https://github.com/MetaMask/core/pull/9815)) +- Require a selected EVM address and the current Hyperliquid network/HIP-3/DEX identity before returning cached account data; legacy or mismatched entries now fail closed and refresh. ([#9815](https://github.com/MetaMask/core/pull/9815)) + +### Fixed + +- Prevent `CLIENT_NOT_INITIALIZED` errors during cold-start and reconnection by awaiting in-flight initialization in trading action methods (`placeOrder`, `editOrder`, `cancelOrder`, `closePosition`, `deposit`, `withdraw`, etc.) ([#9032](https://github.com/MetaMask/core/pull/9032)) +- Fix compound error string (`CLIENT_NOT_INITIALIZED: `) breaking i18n translation lookup — now always throws the plain `CLIENT_NOT_INITIALIZED` code ([#9032](https://github.com/MetaMask/core/pull/9032)) +- Recreate all four SDK clients (including `ExchangeClient` and HTTP `InfoClient`) during WebSocket reconnection so `isInitialized()` returns `true` after reconnect ([#9032](https://github.com/MetaMask/core/pull/9032)) +- Bring the HyperLiquid SDK clients up before the provider's first asset-metadata read, so a trading action taken during cold start or after a disconnect waits for the clients instead of failing with `CLIENT_NOT_INITIALIZED` ([#9865](https://github.com/MetaMask/core/pull/9865)) + - `placeOrder` resolves asset info before it ensures trading readiness, so waiting for controller initialization alone was not enough: the metadata read still hit an uninitialized `InfoClient` and the order failed. Warm reads are unaffected — the cached path returns before the client check. +- Publish WebSocket-backed SDK clients only after reconnection succeeds, while keeping HTTP-backed metadata and trading clients available during retries ([#9868](https://github.com/MetaMask/core/pull/9868)) + +## [11.0.0] + +### Added + +- **BREAKING:** Add trigger placement order types to `OrderType`: `stop_market`, `stop_limit`, `take_profit_market`, and `take_profit_limit`, placeable through `placeOrder` alongside the existing `market` and `limit` types ([#9674](https://github.com/MetaMask/core/pull/9674)) + - `OrderType` is a wider union, so any consumer signature that narrows it back to `'market' | 'limit'` no longer accepts a value typed `OrderType` — including anything fed from `OrderFormState.type` or `selectPendingTradeConfiguration`. Mobile has six such signatures (`usePerpsOrderFees`, `usePerpsTPSLForm`, `PerpsLeverageBottomSheet`, `usePerpsClosePosition`, the `PerpsTPSL` route params in `types/navigation.ts`, and `determineMakerStatus` in `utils/orderUtils.ts`); they must widen to `OrderType` (or narrow explicitly at the call site) in the client update that adopts this release. + - `OrderParams.triggerPrice` sets the price at which the resting order activates. It is required for the four trigger types and rejected for `market`/`limit` orders, so a stray trigger price can never be silently dropped. + - `*_limit` types execute at `OrderParams.price` once triggered; `*_market` types execute as market orders, with a limit price derived from the trigger price and capped by `OrderParams.maxSlippageBps`, falling back to `ORDER_SLIPPAGE_CONFIG.DefaultTpslSlippageBps` when the caller does not set a tolerance. + - The params model stays provider-agnostic: no protocol vocabulary (HyperLiquid's `tpsl`, `triggerPx`, `isMarket`) appears in `OrderParams`, so a second provider can map the same fields. +- Add partial (quantity-scoped) TP/SL via `OrderParams.takeProfitSize` / `OrderParams.stopLossSize` and `UpdatePositionTPSLParams.takeProfitSize` / `UpdatePositionTPSLParams.stopLossSize`; omitting a size keeps the previous whole-order/whole-position behavior ([#9674](https://github.com/MetaMask/core/pull/9674)) + - On HyperLiquid, a size cannot be expressed under `positionTpsl` grouping, so `updatePositionTPSL` submits partial TP/SL as standalone reduce-only trigger orders with `na` grouping and explicit sizes. In both the partial and whole-position paths the pre-cancel sweep clears previously placed standalone reduce-only triggers for the symbol so repeated calls stay idempotent, but never a TP/SL child of another pending order. Note that this includes standalone triggers the caller placed independently through `placeOrder` (for example a manual reduce-only stop on the same market), which are cancelled as part of the replace. +- Add `Position.takeProfitOrders` and `Position.stopLossOrders` (`PositionTriggerOrder[]`), the complete view of the trigger orders attached to a position — including partial ones, which the scalar `takeProfitPrice`/`stopLossPrice` fields cannot represent. Each entry carries `orderId`, `direction`, `orderType`, `triggerPrice`, `size` (resolved to the position size when the protocol encodes "whole position"), `isPartial`, and `reduceOnly` ([#9674](https://github.com/MetaMask/core/pull/9674)) + - `direction` (`'stop' | 'take_profit'`) is always present and is what sorts a trigger into one array or the other. `orderType` is optional: HyperLiquid sometimes reports a bare `Trigger`, naming neither direction nor execution, and while the direction is recoverable from the trigger price against the entry, the execution mode is not — so it is left unstated rather than guessed. Such an order is still reported, in both the arrays and the counts derived from them. + - A trigger sitting exactly at the entry price is classified as a stop on both sides, matching the legacy price fallback the scalar `takeProfitPrice`/`stopLossPrice` fields use, so the arrays and the scalars cannot disagree about the same order. +- Add `Order.triggerOrderType`, the normalized placement type of an open trigger order, so open-orders state round-trips the placement type alongside the existing `triggerPrice`, `reduceOnly`, and size fields ([#9674](https://github.com/MetaMask/core/pull/9674)) +- Add `OrderParams.tpslLinkage` (`'none' | 'order' | 'position'`), a provider-agnostic way to say how an attached TP/SL is linked — to this order, to the resulting position, or absent — replacing the HyperLiquid-shaped `grouping` without removing it ([#9674](https://github.com/MetaMask/core/pull/9674)) + - `grouping` (`'na' | 'normalTpsl' | 'positionTpsl'`) is deprecated but still honoured for `'normalTpsl'`, so those callers keep working. `tpslLinkage` takes precedence; supplying both with different meanings is rejected with the new `ORDER_TPSL_LINKAGE_CONFLICT` error rather than silently resolved. `'positionTpsl'` is no longer accepted on `placeOrder` at all, and `'na'` is no longer accepted when the order carries an attached TP/SL — see the breaking entry under Changed. + - `adaptTpslLinkageToGrouping` maps the linkage onto HyperLiquid's grouping inside the adapter layer, keeping protocol wording out of `OrderParams`. +- Add the `TriggerOrderType`, `OrderExecution`, `TriggerDirection`, `TpslLinkage`, and `PositionTriggerOrder` types ([#9674](https://github.com/MetaMask/core/pull/9674)) +- Add order-type helpers `TRIGGER_ORDER_TYPES`, `isTriggerOrderType`, `isLimitExecutionOrderType`, `getTriggerExecution`, `getTriggerDirection`, `buildTriggerOrderType`, and `buildPositionTriggerOrderFromOrder`, plus the HyperLiquid mappers `adaptTriggerOrderTypeFromSDK` and `adaptPositionTriggerOrderFromSDK` ([#9674](https://github.com/MetaMask/core/pull/9674)) +- Add an executable proof of the advanced order-type contract under `tests/`: a case matrix shared by a Jest guard (`tests/src/e2e/advanced-orders.contract.test.ts`, simulated, runs in CI) and a script (`tests/e2e/advanced-orders.e2e.ts`) that replays it against HyperLiquid testnet. The matrix also probes the venue behaviours the controller's refusals rest on — that a `positionTpsl` batch containing a plain order is rejected, that a zero `triggerPx` or zero cap price is rejected, that a zero-size trigger is read as whole-position, and that `na`-grouped children outlive a cancelled parent — so each guard is justified by observed behaviour rather than assumption ([#9674](https://github.com/MetaMask/core/pull/9674)) +- Add order validation error codes `ORDER_TRIGGER_PRICE_REQUIRED`, `ORDER_TRIGGER_PRICE_POSITIVE`, `ORDER_TRIGGER_PRICE_NOT_SUPPORTED`, `ORDER_TRIGGER_TPSL_UNSUPPORTED`, `ORDER_TPSL_SIZE_INVALID`, `ORDER_EDIT_TRIGGER_UNSUPPORTED`, `ORDER_EDIT_ORDER_UNVERIFIABLE`, `ORDER_TPSL_LINKAGE_CONFLICT`, `ORDER_TPSL_POSITION_LINKAGE_UNSUPPORTED`, `ORDER_TPSL_LINKAGE_REQUIRED`, and `ORDER_TIME_IN_FORCE_NOT_SUPPORTED` ([#9674](https://github.com/MetaMask/core/pull/9674)) +- Add `floorToSizeDecimals(size, szDecimals)` (exported from `@metamask/perps-controller/utils/*`), which rounds an order size down onto an asset's size grid, snapping values that floating-point error leaves just below a grid point. The result is never greater than the input: a value genuinely below a grid point is truncated rather than snapped up ([#9719](https://github.com/MetaMask/core/pull/9719)) +- **BREAKING:** Add `EXCHANGE_ACCOUNT_NOT_FOUND` to `PERPS_ERROR_CODES`, returned by `HyperLiquidProvider.placeOrder` when the wallet has no HyperLiquid account yet (TAT-3343) ([#9709](https://github.com/MetaMask/core/pull/9709)) + - This widens the exported `PerpsErrorCode` union, so consumers that key an exhaustive `Record` stop compiling until they add an entry for the new code. Both first-party clients do: Mobile's `app/components/UI/Perps/utils/translatePerpsError.ts` and Extension's `ui/components/app/perps/utils/translate-perps-error.ts`. + - To migrate: add a translation entry for `EXCHANGE_ACCOUNT_NOT_FOUND`. It signals that the wallet has no HyperLiquid account yet, so the message should direct the user to fund the account before trading. +- **BREAKING:** Add `EXCHANGE_MULTI_SIG_REQUIRED` and `EXCHANGE_INVALID_NONCE` to `PERPS_ERROR_CODES` for HyperLiquid exchange rejections that previously surfaced as raw `"multi-sig required"` / `"invalid nonce"` strings (TAT-3633) ([#9750](https://github.com/MetaMask/core/pull/9750)) + - Like `EXCHANGE_ACCOUNT_NOT_FOUND` above, this widens the exported `PerpsErrorCode` union, so consumers that key an exhaustive `Record` stop compiling until they add entries for both new codes — including Mobile's `app/components/UI/Perps/utils/translatePerpsError.ts` and Extension's `ui/components/app/perps/utils/translate-perps-error.ts`. + - To migrate: add translation entries for both codes before bumping. `EXCHANGE_MULTI_SIG_REQUIRED` means the account requires a multi-sig wrapper for exchange writes; `EXCHANGE_INVALID_NONCE` means the action nonce was stale or reused and the request should be retried. +- Add `isHyperLiquidMultiSigRequiredError(error)` (exported from `@metamask/perps-controller/utils/*`), which classifies HyperLiquid's `Multi-sig required` rejection — matching the hyphenated spelling observed in the wild and the unhyphenated variant defensively (TAT-3214) ([#9769](https://github.com/MetaMask/core/pull/9769)) + +### Changed + +- Bump `@metamask/account-tree-controller` from `^7.5.5` to `^7.6.0` ([#9779](https://github.com/MetaMask/core/pull/9779)) +- Bump `@metamask/network-controller` from `^35.0.0` to `^35.0.1` ([#9758](https://github.com/MetaMask/core/pull/9758)) +- **BREAKING:** `placeOrder` now rejects `OrderParams.grouping: 'positionTpsl'` (and the equivalent `tpslLinkage: 'position'`) with `ORDER_TPSL_POSITION_LINKAGE_UNSUPPORTED`, where it was previously accepted and passed through to the exchange ([#9674](https://github.com/MetaMask/core/pull/9674)) + - The rejection is not new behaviour so much as an earlier, clearer one: HyperLiquid requires every order in a `positionTpsl` batch to be a trigger, and the parent being placed is an ordinary market or limit order, so the venue rejected the whole batch. This was confirmed against HyperLiquid testnet rather than assumed. A caller that previously sent this combination did not get position-bound TP/SL; it got a failed submission, further from the call site and without a typed error. + - To bind TP/SL to the position, call `updatePositionTPSL` once the parent has filled — a position must exist before anything can be bound to it. To attach TP/SL to the order itself, use `tpslLinkage: 'order'` (legacy `grouping: 'normalTpsl'`), which is unchanged. + - `grouping: 'na'` with an attached TP/SL is rejected on the same grounds with `ORDER_TPSL_LINKAGE_REQUIRED`: it submits the children bound to nothing, so an unfilled parent leaves orphan reduce-only triggers that fire against whatever position happens to exist. +- `validateOrderParams` accepts the new placement fields (`triggerPrice`, `takeProfitPrice`, `stopLossPrice`, `takeProfitSize`, `stopLossSize`) and enforces them: trigger types need a positive trigger price, `*_limit` types need a limit price, `market`/`limit` orders reject a trigger price, a trigger placement cannot carry attached TP/SL, and a partial TP/SL size must be positive, no larger than the order size, and paired with its price ([#9674](https://github.com/MetaMask/core/pull/9674)) +- `getMaxOrderValue`, `calculateOrderPriceAndSize`, `buildOrdersArray`, and `FeeCalculationParams` accept the full `OrderType` union; trigger types follow their execution mode (`*_limit` is treated as a limit order, `*_market` as a market order) for order-value limits and fee tiers ([#9674](https://github.com/MetaMask/core/pull/9674)) +- `validateOrder` falls back to the trigger price when validating the notional of a market-executing trigger order that has no current or limit price ([#9674](https://github.com/MetaMask/core/pull/9674)) +- `editOrder` rejects modifying a resting order into a trigger placement with `ORDER_EDIT_TRIGGER_UNSUPPORTED` instead of dropping the trigger, since HyperLiquid's `modify` rebuilds the order as a plain limit/market order ([#9674](https://github.com/MetaMask/core/pull/9674)) + - The resting side of the edit is verified too, and fails closed: when the WebSocket order cache cannot confirm the order's placement type, `editOrder` queries `frontendOpenOrders` and rejects with `ORDER_EDIT_TRIGGER_UNSUPPORTED` for a resting trigger, or `ORDER_EDIT_ORDER_UNVERIFIABLE` when the order is no longer listed. Previously an unverifiable order was edited anyway, which could rebuild a protective stop as a plain order and report success. Both refusals happen before the trading setup that may prompt for a signature and write builder-fee and referral approvals, so a refused edit costs the caller nothing. +- `validateOrderParams` rejects position linkage on an order placement (`tpslLinkage: 'position'` or `grouping: 'positionTpsl'`) with `ORDER_TPSL_POSITION_LINKAGE_UNSUPPORTED`, whether or not a TP/SL is attached; every order in a `positionTpsl` batch must be a trigger order, and no order placement produces one — with an attached TP/SL the batch carries the ordinary parent order, and without one it is that parent alone, so HyperLiquid rejected both. Use `updatePositionTPSL` on the position instead ([#9674](https://github.com/MetaMask/core/pull/9674)) +- `validateOrderParams` also rejects an attached TP/SL with no linkage (`tpslLinkage: 'none'` or `grouping: 'na'` alongside `takeProfitPrice`/`stopLossPrice`) with `ORDER_TPSL_LINKAGE_REQUIRED`; `na` grouping submits the TP/SL as standalone triggers bound to neither the parent order nor the position, so an unfilled parent left them behind as orphan reduce-only triggers ([#9674](https://github.com/MetaMask/core/pull/9674)) +- `adaptOrderToSDK` now throws where it previously never threw: `ORDER_TRIGGER_PRICE_REQUIRED` when a trigger placement has no trigger price, and `ORDER_TIME_IN_FORCE_NOT_SUPPORTED` when a market order or trigger placement carries a time in force ([#9674](https://github.com/MetaMask/core/pull/9674)) +- `PERPS_EVENT_VALUE.ORDER_TYPE` lists the four trigger placement types, which `TradingService` emits verbatim in the `order_type` analytics property ([#9674](https://github.com/MetaMask/core/pull/9674)) +- `Order.parentOrderId` is now populated for real TP/SL child orders on the WebSocket order stream (previously only ever set by clients for synthetic display rows), which is what lets position state tell a position's own triggers apart from another order's ([#9674](https://github.com/MetaMask/core/pull/9674)) +- Bump `@metamask/superstruct` from `^3.1.0` to `^3.4.1` ([#9754](https://github.com/MetaMask/core/pull/9754)) + +### Fixed + +- Hydrate trading readiness before coin validation in `HyperLiquidProvider.cancelOrder`, so a cold start with an empty prefetch asset map self-heals instead of returning `ORDER_UNKNOWN_COIN` for valid markets (TAT-3633) ([#9750](https://github.com/MetaMask/core/pull/9750)) +- Map HyperLiquid `"multi-sig required"` and `"invalid nonce"` exchange rejections to `EXCHANGE_MULTI_SIG_REQUIRED` and `EXCHANGE_INVALID_NONCE` across `placeOrder`, `cancelOrder`, and `cancelOrders`, attaching cached abstraction mode to account-mode error log context (TAT-3633) ([#9750](https://github.com/MetaMask/core/pull/9750)) + - `cancelOrder` now reads the per-status error HyperLiquid returns when it rejects a cancel without throwing, so `CancelOrderResult.error` carries the mapped code instead of the generic `'Order cancellation failed'` string. That generic string is still returned when the status entry carries no error text. + - `cancelOrders` maps both thrown batch failures and per-status rejections the same way, so `CancelOrdersResult.results[].error` carries a `PerpsErrorCode` rather than the raw exchange message for recognized rejections. +- **BREAKING:** `OrderParams.timeInForce` now controls the HyperLiquid time-in-force for plain limit orders instead of being ignored; `GTC`, `IOC`, and post-only `ALO` map to their corresponding SDK values ([#9674](https://github.com/MetaMask/core/pull/9674)) + - Order shapes that cannot carry a time in force — market orders and trigger placements, whose execution is decided when they fire — now reject it with `ORDER_TIME_IN_FORCE_NOT_SUPPORTED`, where previously the field was accepted and ignored for every order type. Callers passing `timeInForce` on anything other than a `limit` order must drop it. + - The rejection happens in `validateOrderParams`, before `placeOrder` changes leverage on-chain or moves margin to a HIP-3 DEX, so a rejected order leaves no side effects behind. +- Streamed `takeProfitCount` / `stopLossCount` can no longer disagree with the arrays they summarize. A symbol whose triggers produced no array entry fell back to the legacy count, so a position could report a count of 1 beside an empty array — a state no subscriber can render ([#9674](https://github.com/MetaMask/core/pull/9674)) +- `getPositions` now populates `takeProfitCount` / `stopLossCount` on the REST path, which previously always reported `0` there while the WebSocket path counted them. Both counts and the new trigger arrays use one definition on both transports: reduce-only triggers on the market that are not a child of another pending order, de-duplicated by order ID. The WebSocket path derives its counts from the same arrays, so a standalone or partial trigger is counted identically on both transports; orders whose placement type HyperLiquid does not name (its ambiguous `Trigger`) are absent from both, where the legacy WebSocket count included them. The legacy scalar `takeProfitPrice` / `stopLossPrice` fields keep their previous behaviour and may still reflect a pending order's TP/SL child, so they can disagree with the arrays and counts; this is documented on the `Position` fields ([#9674](https://github.com/MetaMask/core/pull/9674)) +- `updatePositionTPSL` now cancels standalone reduce-only triggers left by an earlier partial update when replacing whole-position TP/SL, where previously those leftovers survived the replace and could fire beside the new position-bound orders ([#9674](https://github.com/MetaMask/core/pull/9674)) +- `editOrder` no longer reports the edited order's ID back as `OrderResult.orderId`. HyperLiquid does not edit in place: it cancels the target and rests a replacement under a new order ID, which the SDK's modify response does not carry, so the returned ID named an order the venue had already cancelled ([#9674](https://github.com/MetaMask/core/pull/9674)) + - The replacement is now resolved from the open orders read after the modify, and is returned only when exactly one newly-rested order carries the submitted market, side and size. Novelty is judged against a snapshot taken before the modify, so an order that was already resting with the same attributes cannot be mistaken for the replacement. + - When the replacement cannot be identified unambiguously — a market edit that filled rather than rested, a read that has not yet caught up, or more than one candidate — the result stays successful and the optional `orderId` is omitted rather than reporting an ID that may be wrong. Callers reading `orderId` after an edit must handle it being absent. +- A partial TP/SL size that rounds away at the asset's precision (for example `0.0004` against `szDecimals: 3`) is now rejected with `ORDER_TPSL_SIZE_INVALID` in both `placeOrder` and `updatePositionTPSL`. Validation only saw the requested size, so such a size passed and was then submitted as `'0'` — which HyperLiquid reads as covering the whole position, silently turning a partial TP/SL into a full close ([#9674](https://github.com/MetaMask/core/pull/9674)) + - The check runs before either method takes a side effect, so a rejected update leaves the account as it found it: `updatePositionTPSL` rejects before its pre-cancel sweep, so the position keeps the triggers it already had, and `placeOrder` rejects before signing prompts, the leverage change, and any HIP-3 margin transfer. +- A price that rounds away at the asset's precision is rejected the same way, and at the same point, as a size that does. An asset quotes to `DECIMAL_PRECISION_CONFIG.MaxPriceDecimals - szDecimals` places, so a positive price below that tick was formatted to `'0'` and submitted as a zero `triggerPx`, which the SDK rejects — previously only after `placeOrder` had completed trading setup, the leverage change, and any HIP-3 margin handling. `OrderParams.triggerPrice` is rejected with `ORDER_TRIGGER_PRICE_POSITIVE`; an attached or position `takeProfitPrice` / `stopLossPrice` with `ORDER_PRICE_POSITIVE` ([#9674](https://github.com/MetaMask/core/pull/9674)) +- `updatePositionTPSL` now runs its trading setup — the step that can prompt a hardware wallet and write the referral and builder-fee approvals — only after every validation has passed, where previously it ran first. A rejected update (invalid partial size, a size missing its price, a size that rounds away) no longer leaves those writes behind ([#9674](https://github.com/MetaMask/core/pull/9674)) +- `adaptOrderToSDK` derives the slippage cap price for `stop_market` / `take_profit_market` orders from the trigger price when the caller supplies none, instead of emitting `p: '0'`, which the SDK rejects before the request is made ([#9674](https://github.com/MetaMask/core/pull/9674)) + - The cap follows the order's own `maxSlippageBps` (or the deprecated decimal `slippage`), falling back to `ORDER_SLIPPAGE_CONFIG.DefaultTpslSlippageBps` only when neither is set, so the same order priced through this helper and through `placeOrder` gets the same execution bound. +- Streamed positions now emit when a standalone or partial trigger order is placed or cancelled: the position change hash covers `takeProfitOrders` / `stopLossOrders`, where previously such a change altered neither the scalar TP/SL fields nor the counts and so was never delivered to position subscribers ([#9674](https://github.com/MetaMask/core/pull/9674)) +- `Position.takeProfitOrders` / `stopLossOrders` re-resolve a position-bound trigger's size against the current position instead of reporting the size it was resolved to when the order was first adapted. A position-bound TP/SL covers whatever the position is, so after the position was resized the entry reported a stale size — and, when the position had grown, wrongly reported `isPartial: true` for a trigger that still covered the whole position ([#9674](https://github.com/MetaMask/core/pull/9674)) + - That hash covers each trigger's placement type too, so a trigger modified in place from market to limit execution — keeping its order ID, trigger price, and size — is delivered rather than leaving subscribers on stale execution semantics. +- `Order.orderType` now reports how a trigger order executes rather than always reporting `limit`: HyperLiquid sets `limitPx` on trigger orders as a slippage cap, so a `Stop Market`/`Take Profit Market` order was previously read back as a limit order ([#9674](https://github.com/MetaMask/core/pull/9674)) +- Stop `closePosition` from submitting reduce-only orders that HyperLiquid rejects with "Reduce only order would increase position" ([#9719](https://github.com/MetaMask/core/pull/9719)) + - The position snapshot callers pass (to avoid a `getPositions()` REST call) is now re-validated against the freshest WebSocket position cache, so the order side and size follow the live position instead of a snapshot that a concurrent TP/SL fill, liquidation, or repeated close has already invalidated. No additional network request is made when the cache covers the symbol's DEX: a missing entry there means the position is already closed, so the close now fails fast with `No position found for ` instead of submitting a doomed order. When the cache does not cover that DEX — a HIP-3 DEX whose subscription has not published this session — the absence proves nothing, so a single `clearinghouseState` request for that DEX alone supplies live data, which keeps the outcome attributable to the symbol's own DEX: if the DEX answers without this symbol — including when it reports no positions at all — the position is genuinely closed and the close fails with `No position found for `; only if that request fails does the caller's snapshot stand, since a failed lookup proves nothing and must not block a position that is open and closable. `HyperLiquidSubscriptionService` exposes the new `getCachedPositionsForDex(dexName)` method this uses, which returns that DEX's own cached positions rather than the cross-DEX aggregate: the aggregate is only rebuilt once every expected DEX has published, so after a WebSocket reconnect it can sit frozen at pre-reconnect contents while the per-DEX slices keep updating. + - A caller-supplied close size is clamped to the live position size, and that clamp is binding: for a partial close the `usdAmount` clients also send can no longer recompute the size above it. A close size that is supplied but not a positive number (e.g. `'0'` or `'abc'`) now fails with `ORDER_SIZE_POSITIVE`; only an omitted or empty `size` means "close 100%". + - The batch `closePositions` path also rounds each reduce-only size down onto the asset's size grid, instead of letting `formatHyperLiquidSize`'s half-up rounding push it above the position. A position smaller than one size increment is skipped rather than submitted as a zero-size order, and the remaining positions still close. Each skipped position is reported in `results` with `success: false` and `error: ORDER_SIZE_POSITIVE` and counted in `failureCount`, so a caller cannot read "closed everything" from a batch that left one open. `results` keeps the order of the requested positions, so a consumer correlating results to positions by index is unaffected by a skip. + - A close that covers the whole position — no `size`, or a `size` that reaches (or was clamped to) the position size — now submits exactly the live position size. The `usdAmount` clients send for slippage protection is no longer forwarded for such a close, because `placeOrder` treats it as the source of truth and would recompute the size from it, discarding the clamp and rounding the size up. Genuine partial closes still use `usdAmount`. As a result, a close of the entire position requested via an explicit `size` is now treated as a full close for validation too, so it skips the USD/$10-minimum check as an omitted `size` already did. The `priceAtCalculation` staleness check is unaffected: `calculateFinalPositionSize` now runs it whenever a caller supplies that field, rather than only inside its `usdAmount` branch, so a full close that drifted past `maxSlippageBps` is still rejected with "Price moved too much" even though its size no longer comes from `usdAmount`. + - `placeOrder` no longer retries **any** reduce-only order that the exchange rejected for the $10 minimum order value with a 1.5% larger size; the minimum-value error is surfaced instead. This covers reduce-only limit and TP/SL orders submitted directly through `placeOrder`, not just closes. **It is a behaviour change for partial closes**, which previously recovered from that rejection by closing ~1.5% more than requested: a reduce-only order can no longer grow past the position (full close) or past the size the caller asked to close (partial close), so the retry could only be rejected again or resubmit an identical order. + - `calculateFinalPositionSize` throws `ORDER_SIZE_POSITIVE` when a `reduceOnly` call supplies a `size` that is not a positive number, in both its `usdAmount` and legacy-size branches, instead of capping the USD-derived size to that value or passing it through to be formatted as a zero or negative size. + - `calculateFinalPositionSize` accepts an optional `reduceOnly` flag. When set, the size is rounded down onto the asset's size grid and the "add one increment to meet the requested USD" adjustment is skipped, so a reduce-only size can never round up past the position. If rounding down leaves a size of `0` (the order is worth less than one size increment), it throws `ORDER_SIZE_POSITIVE` rather than submitting a zero-size order. **This is a behaviour change for a reduce-only close between half an increment and one full increment**: `formatHyperLiquidSize` uses `toFixed`, which rounds half-up, so such a close previously succeeded by closing one whole increment and now fails client-side instead. It is most visible on coarse-grid (low `szDecimals`) assets, and reachable from any partial close that omits `usdAmount` — Mobile limit partial closes do (`usePerpsClosePosition` sends `usdAmount: undefined` for limit orders). +- Map HyperLiquid's `"User or API Wallet 0x... does not exist."` order rejection to `PERPS_ERROR_CODES.EXCHANGE_ACCOUNT_NOT_FOUND` instead of returning the raw exchange message as `OrderResult.error`, so clients can render an actionable "fund your account" message (TAT-3343) ([#9709](https://github.com/MetaMask/core/pull/9709)) +- Stop reporting the `"User or API Wallet 0x... does not exist."` order rejection to the error logger; it is an expected pre-account state, matching the handling already applied to the other user-scoped HyperLiquid exchange writes (TAT-3343) ([#9709](https://github.com/MetaMask/core/pull/9709)) +- Size the max order amount off the price a resting limit order is submitted at, fixing `order 0: insufficient margin to place order` rejections on max-size limit orders resting above the market price ([#9694](https://github.com/MetaMask/core/pull/9694)) + - `getMaxAllowedAmount` derived the maximum from the market price, but HyperLiquid reserves initial margin for a resting order against the price that order is submitted at. A max-size limit order resting above the market price - typically a sell - therefore reserved more margin than the account had and the exchange rejected it. + - `getMaxAllowedAmount` now accepts optional `orderType` and `limitPrice` params. When a limit order rests above the market price the maximum is scaled by `limitPrice / marketPrice`; orders at or below the market price, and market orders, are unchanged. Both params are optional, so existing callers keep the previous behavior. +- Skip the unified-account migration for HyperLiquid multi-sig accounts, fixing the `ApiRequestError: Multi-sig required` error raised on every Perps entry for such an account (TAT-3214) ([#9769](https://github.com/MetaMask/core/pull/9769)) + - HyperLiquid rejects every single-signer exchange write for an account converted to multi-sig, so the silent `agentSetAbstraction` (and user-signed `userSetAbstraction`) migration could never succeed. `HyperLiquidProvider` now reads `userToMultiSigSigners` immediately before the migration write and, for a multi-sig account, skips it, emits the `Perp Account Setup` event with `status: not_applicable` / `error_message: multi_sig_account`, and records `{ attempted: true, enabled: false }` in the trading-readiness cache so the attempt is not repeated. The signer lookup only runs when a migration write would otherwise be made, so accounts already on `unifiedAccount` / `portfolioMargin` and deferred `dexAbstraction` accounts are unaffected. + - The same rejection is now classified in the migration's error handler, covering the case where the account is converted between the lookup and the write, so it is no longer reported to the error logger as a failed setup. + - Unified account mode stays off for these accounts; HIP-3 collateral continues to be handled by the existing programmatic transfer fallback. + +## [10.0.0] + +### Added + +- Add `AggregatedOrderBookConnection` service (with the `processAggregatedOrderBook` helper and the `OrderBookConnectionStatus`, `SubscribeAggregatedOrderBookParams`, and `AggregatedOrderBookConnectionOptions` types) for managing a dedicated, reference-counted aggregated order book subscription ([#9549](https://github.com/MetaMask/core/pull/9549)) +- Add `BOTTOM_NAV_BAR` to `PERPS_EVENT_VALUE.SOURCE` for bottom navigation bar analytics attribution ([#9551](https://github.com/MetaMask/core/pull/9551)) + +### Changed + +- Bump `@metamask/transaction-controller` from `^69.1.0` to `^69.3.0` ([#9589](https://github.com/MetaMask/core/pull/9589), [#9593](https://github.com/MetaMask/core/pull/9593), [#9693](https://github.com/MetaMask/core/pull/9693)) +- Gate HIP-3 markets to USDC collateral only, following HyperLiquid's USDH sunset (TAT-3304) ([#9530](https://github.com/MetaMask/core/pull/9530)) + - Market discovery (`getMarkets`) now filters a HIP-3 DEX out entirely when its collateral token positively resolves to something other than USDC, so such a market can never be surfaced to trade, even via an allowlist entry naming the DEX. + - `getMarketDataWithPrices` applies the same check before merging each HIP-3 DEX's results (both the initial fetch and the empty-universe retry), and before caching the snapshot used for stale fallbacks, so a non-USDC-collateral HIP-3 DEX can no longer appear in overview data (fresh or stale) while order placement rejects it. + - Placing an order on a non-USDC-collateral HIP-3 DEX now fails immediately with a new `UNSUPPORTED_COLLATERAL` error code instead of attempting the previous USDC→USDH auto-swap path. + - The collateral check fails closed: it only treats a DEX as USDC-collateral when the collateral token positively resolves to USDC against spot metadata, so missing or stale metadata never lets a non-USDC-collateral DEX through. + - Removed the now-unreachable USDH auto-swap machinery this replaces (spot USDH/USDC balance lookups, the USDC→USDH spot swap, and the auto-swap orchestration). +- Subscribe to HyperLiquid's `fastAssetCtxs` WebSocket feed for mark/mid price updates, replacing `assetCtxs` as the latency-sensitive price source now that HyperLiquid has slowed the public `assetCtxs` feed cadence ([#9530](https://github.com/MetaMask/core/pull/9530)) + - `assetCtxs` continues to populate funding, open interest, volume, and oracle price data, and no longer writes prices for any symbol `fastAssetCtxs` covers, so a slower `assetCtxs` batch tick can't overwrite a fresher `fastAssetCtxs` price; it remains the price source only for symbols outside `fastAssetCtxs`' coverage (e.g. HIP-3 DEX markets). + - `fastAssetCtxs` is a single global subscription (the HyperLiquid SDK exposes no per-DEX variant): the first message is a full snapshot keyed by coin, and later messages contain diffs for only the coins that changed. A coin is only marked as covered by `fastAssetCtxs` (deferring `assetCtxs`) once a usable price has actually been received for it; every coin with a usable price is cached regardless of whether it currently has a subscriber, so a later subscriber gets an immediate baseline, while notifications remain scoped to coins with an active subscriber. + - Established alongside the global `allMids` subscription, restored together on WebSocket reconnect, and torn down on `clearAll()`. Subscribe attempts use the same 3-attempt/500ms-backoff retry as `assetCtxs` for transient SDK errors. + +### Removed + +- **BREAKING:** Remove the `USDH_CONFIG` export, following HyperLiquid's USDH sunset (TAT-3304) ([#9530](https://github.com/MetaMask/core/pull/9530)) + - This constant configured the now-removed USDC→USDH auto-swap path; consumers importing it should remove the reference, as USDH-collateral HIP-3 DEXs are no longer supported (see the collateral gating change above). + +### Fixed + +- Scope `#notifyAllPriceSubscribers` to the symbols that actually changed, instead of always fanning out to every price subscriber ([#9530](https://github.com/MetaMask/core/pull/9530)) + - The `allMids` handler now tracks a per-symbol `changedSymbols` set (replacing the previous all-or-nothing `hasUpdates` boolean) and only notifies subscribers of symbols whose price changed. + - The `activeAssetCtx` handler now notifies only the subscribers of the symbol it just updated, instead of re-notifying every subscribed symbol on each tick. + - This eliminates redundant reference-equal `PriceUpdate` deliveries to list-view subscribers (e.g. market overview, watchlist) whenever an unrelated symbol's fast-stream price ticks. + +## [9.3.0] + +### Added + +- Add `proLayoutPreferences` state field (`orderBookExpanded`, `chartExpanded`, `orderBookPosition`, `orderFormPosition`) to `PerpsControllerState` for persisting Pro-mode layout across markets, along with the exported `ProLayoutPreferences` type and `DEFAULT_PRO_LAYOUT_PREFERENCES` constant, `getProLayoutPreferences()` / `setProLayoutPreferences(patch)` controller methods (exposed as messenger actions with exported `PerpsControllerGetProLayoutPreferencesAction` / `PerpsControllerSetProLayoutPreferencesAction` types), and a `selectProLayoutPreferences` selector; the getter and selector merge over defaults so callers always receive a fully-populated object ([#9550](https://github.com/MetaMask/core/pull/9550)) +- Add a `PerpsMode` enum (`Lite`/`Pro`) and a persisted `mode` state field (defaulting to `PerpsMode.Lite`) to `PerpsControllerState`, along with an exported `DEFAULT_PERPS_MODE` constant, a `setPerpsMode(mode)` controller method (exposed as a messenger action with an exported `PerpsControllerSetPerpsModeAction` type), and a `selectPerpsMode` selector that falls back to the default mode ([#9550](https://github.com/MetaMask/core/pull/9550)) + +### Changed + +- Bump `@metamask/account-tree-controller` from `^7.5.3` to `7.5.4` ([#9429](https://github.com/MetaMask/core/pull/9429)) +- Report the effective leverage (`positionUSD / marginUSD`, rounded to 1 decimal place) on `PERPS_POSITION_CLOSE_TRANSACTION` analytics instead of the configured `leverage.value`, and populate it for every close including TP/SL triggers ([#9471](https://github.com/MetaMask/core/pull/9471)) +- Emit an additional `partially_filled` `PERPS_TRADE_TRANSACTION` event with `order_size` (the final submitted size), `amount_filled`, and `remaining_amount` when an open trade fills for less than the size actually submitted to the exchange, mirroring the close path so partial fills are visible in analytics; classification uses the provider's post-normalization submitted size (returned as `OrderResult.submittedSize`) rather than the caller's pre-normalization `size`, so a complete fill of the normalized size is not misreported as partial; full fills are unchanged ([#9471](https://github.com/MetaMask/core/pull/9471)) +- Widen the `TradeAction` type to include `flip_long_to_short` and `flip_short_to_long` (already forwarded verbatim at runtime), so clients no longer need casts when deriving flip actions ([#9471](https://github.com/MetaMask/core/pull/9471)) +- Add `number_positions_closed` (the successful-close count) to the batch `PERPS_POSITION_CLOSE_TRANSACTION` summary event emitted by `closePositions`, which previously carried only status/completion_duration/bulk_action_id ([#9471](https://github.com/MetaMask/core/pull/9471)) + +### Fixed + +- Emit the failed Perp Risk Management analytics event when `updateMargin` receives a non-throwing `{ success: false }` provider result, which previously lost the terminal event (only the thrown-error path emitted it); the event fires exactly once per operation ([#9471](https://github.com/MetaMask/core/pull/9471)) +- Fix the CommonJS build inlining an absolute `file:` path in place of the `@nktkas/hyperliquid` specifier ([#9471](https://github.com/MetaMask/core/pull/9471)) + - `dist/services/HyperLiquidClientService.cjs` and `dist/utils/standaloneInfoClient.cjs` in `9.2.1` emitted `require("file:///home/runner/work/hyperliquid/hyperliquid/src/mod.ts")` instead of `require("@nktkas/hyperliquid")`, breaking any CommonJS/Jest/bundler consumer with "Cannot find module". + - Root cause: `@nktkas/hyperliquid@0.33.0`+ ships `.d.ts` files carrying `/// ` triple-slash directives (an artifact of its Deno/`dnt` build). `ts-bridge` uses that `amd-module` name as the CommonJS `require()` target, so the absolute path leaks into the emitted `.cjs`. A yarn patch (applied via monorepo `resolutions`) strips those directives so the build emits the bare `@nktkas/hyperliquid` specifier; the published dependency range stays `^0.33.1`. + +## [9.2.1] + +### Changed + +- Bump `@metamask/messenger` from `^1.2.0` to `^2.0.0` ([#9392](https://github.com/MetaMask/core/pull/9392)) + +### Fixed + +- Fix `adaptOrderFromSDK` dropping `takeProfitPrice`/`stopLossPrice` for child TP/SL orders whose `triggerPx` is an empty string (HyperLiquid's representation of "no trigger price" when the price is instead carried in `limitPx`) ([#9398](https://github.com/MetaMask/core/pull/9398)) + - `??` only falls back on `null`/`undefined`, so an empty-string `triggerPx` was never replaced by `limitPx`, leaving `takeProfitPrice`/`stopLossPrice` (and their order IDs) `undefined` on the resulting `Order`. Switched back to `||`, which correctly treats `''` as falsy. + +## [9.2.0] + +### Added + +- Add optional `description?: string` to `PerpsMarketData` and `TerminalAssetMetadata`, exposing the human-readable asset description sourced from the Terminal API when available ([#9334](https://github.com/MetaMask/core/pull/9334)) + - `TerminalMarketService` now reads the `description` field from Terminal API items (ignoring `null`/empty values) and includes it in per-symbol metadata. + - `MarketDataService.getMarketDataWithPrices` merges the description into `PerpsMarketData` when the Terminal API backend (`useTerminalApi`) is enabled; markets without a Terminal description keep the field `undefined`. + +## [9.1.0] + +### Added + +- Add Auto Close TP/SL RoE sign toggle analytics constants to `PERPS_EVENT_PROPERTY` and `PERPS_EVENT_VALUE` so mobile and extension can import them from `@metamask/perps-controller` instead of local mirrors ([#9322](https://github.com/MetaMask/core/pull/9322)) + - New `PERPS_EVENT_PROPERTY` key: `ROE_SIGN` (`roe_sign`) + - New `PERPS_EVENT_VALUE.INTERACTION_TYPE` entry: `TPSL_ROE_SIGN_TOGGLED` (`tpsl_roe_sign_toggled`) +- Add `listedAt` (epoch ms) to `PerpsMarketData` and `TerminalAssetMetadata`, sourced from the Terminal API and normalized from either a numeric epoch value or an ISO 8601 string. Clients can use this field to surface recently added markets (e.g. markets listed within the last 30 days). ([#9308](https://github.com/MetaMask/core/pull/9308)) +- Add recently viewed markets tracking to `PerpsController`: ([#9308](https://github.com/MetaMask/core/pull/9308)) + - New `recentlyViewedMarkets` persisted state (per-network: `testnet`/`mainnet`), containing `{ symbol, viewedAt }` entries ordered newest-first and capped at 10. + - New `recordMarketViewed(symbol)` method — call when the user opens a market. Deduplicates and prepends the entry; no remote sync. + - New `getRecentlyViewedMarkets()` method — returns up to 10 symbol strings for the current network, filtered to entries within the last 24 hours, ordered newest-first. Returns `[]` when none qualify. + - New `selectRecentlyViewedMarkets` selector that applies the same TTL/limit/ordering logic for Redux subscribers. + - New `PerpsControllerRecordMarketViewedAction` and `PerpsControllerGetRecentlyViewedMarketsAction` messenger action types. +- Consolidate the Perps analytics contract so clients import a single source of truth from `@metamask/perps-controller` ([#9311](https://github.com/MetaMask/core/pull/9311)) + - Add five new `PerpsAnalyticsEvent` members: `TransactionConsidered` (`Perp Transaction Considered`), `TradeQuoteReceived` (`Perp Trade Quote Received`), `SearchQuery` (`Perp Search Query`), `SearchResultTapped` (`Perp Search Result Tapped`), `SearchAbandoned` (`Perp Search Abandoned`) + - Add new `PERPS_EVENT_PROPERTY` keys: `entry_point`, `discovery_source`, `perp_discovery_source`, `utm_source`, `utm_medium`, `utm_campaign`, `utm_content`, `utm_term`, `watchlisted`, `hl_fee_rate`, `bulk_action_id`, `environment_type`, `order_context`, `order_size_percent`, `limit_price_input_type`, `limit_price_input_preset`, `order_has_tp`, `order_has_sl`, `quote_latency_ms`, `error_reason`, `saved_order`, `default_payment_token`, `default_size_amount`, `default_leverage`, `default_auto_close`, `order_execution_latency_ms`, `screen_context`, `from_token`, `from_chain`, `to_token`, `to_chain`, `search_query`, `results_count`, `result_rank`, `mode`, `current_token`, `sort_field`, `sort_direction`, `filter_category`, `time_on_screen_ms` + - Add new `PERPS_EVENT_VALUE` entries: `INTERACTION_TYPE.{SORT_APPLIED, FILTER_APPLIED, SEARCH_RESULT_TAPPED, SEARCH_CHIP_TAPPED, SEARCH_SIGNAL_TILE_TAPPED, PAYMENT_TOKEN_SELECTOR_DISMISSED}`, `ACTION.ABANDON_ORDER`, `BUTTON_CLICKED.{PLACE_ORDER, CLOSE, REDUCE_EXPOSURE}`, `SCREEN_TYPE.{SEARCH_RESULTS_SHOWN, SEARCH_NO_RESULTS}` + - Add `PerpsAttributionContext` type and `setAttributionContext` / `getAttributionContext` / `clearAttributionContext` / `mergeAttributionContext` on `PerpsController` (with matching messenger actions) for transient UTM attribution propagation + - Extend `TrackingData` with `entryPoint`, `discoverySource`, `perpDiscoverySource`, `hlFeeRate`; extend `TPSLTrackingData` with `entryPoint`, `discoverySource`, `perpDiscoverySource`; add optional `trackingData` to `CancelOrderParams` +- Add Perps Advanced Chart analytics constants to `PERPS_EVENT_PROPERTY` and `PERPS_EVENT_VALUE` so mobile can import chart instrumentation keys from `@metamask/perps-controller` instead of maintaining a local mirror ([#9221](https://github.com/MetaMask/core/pull/9221)) + - New `PERPS_EVENT_PROPERTY` keys: `CHART_LIBRARY`, `ASSET_TYPE` + - New `PERPS_EVENT_VALUE.CHART_LIBRARY` group: `lightweight`, `advanced` + - New `PERPS_EVENT_VALUE.ASSET_TYPE` group: `spot`, `perp` +- Add `fast?: boolean` to `SubscribeOrderBookParams`: when set to `true`, the order book subscription uses Hyperliquid's fast l2Book mode (5 levels @ ~0.5 s cadence) instead of the default (20 levels @ ~2 s) ([#9160](https://github.com/MetaMask/core/pull/9160)) + - No change to `#processOrderBookData` or cumulative-total math; callers opting into `fast: true` receive up to 5 levels per side instead of 20. + +### Changed + +- Consolidate the Perps transaction analytics pipeline in `TradingService` ([#9311](https://github.com/MetaMask/core/pull/9311)) + - Emit a `status: 'submitted'` event before the provider round-trip for trade (`placeOrder`), close (`closePosition`), cancel (`cancelOrder`) and risk-management (`updatePositionTPSL`) operations + - Populate `metamask_fee` on successful `flipPosition` trades from `trackingData` + - Add `leverage` to `Perp Position Close Transaction` event properties + - Add `hl_fee_rate` to trade and close events when present in `trackingData`; omit it entirely when unavailable + - Generate a `bulk_action_id` UUID for `closePositions` / `cancelOrders` and attach it to each per-item event and the batch summary event + - Propagate `entry_point`, `discovery_source`, `perp_discovery_source` from `trackingData` onto trade/close/cancel/risk events; the legacy `source` field on `TPSLTrackingData` is now deprecated +- On `subscribeToPrices` calls with `includeMarketData: true` (focused detail/ticket screens), the `price` field in each `PriceUpdate` is now driven by the per-symbol `activeAssetCtx` WebSocket stream (`midPx`, falling back to `markPx`) rather than the main-DEX `allMids` snapshot, which Hyperliquid throttles to a ~5 s push cadence ([#9160](https://github.com/MetaMask/core/pull/9160)) + - Price source selection is **per-subscriber**: focused (`includeMarketData: true`) callbacks receive the fast-stream price; list/overview (`includeMarketData: false`) callbacks always receive the raw `allMids` baseline, even when both subscriber types share the same symbol. + - The fast-stream price is preferred only while it is fresh (within a 10 s staleness window); `allMids` takes back over automatically once the `activeAssetCtx` stream goes quiet. + - A startup guard prevents any `'0'` price from being emitted: if `activeAssetCtx` fires before `allMids` with no `midPx`/`markPx`, no notification is sent until a usable price arrives from either source. + - No new WebSocket subscriptions are created; `activeAssetCtx` was already established for `includeMarketData: true` subscriptions. +- Bump `@nktkas/hyperliquid` from `^0.32.2` to `^0.33.1`: adds support for the `fast` field on `l2Book` subscriptions ([#9160](https://github.com/MetaMask/core/pull/9160)) + +## [9.0.0] + +### Added + +- **BREAKING:** Sync `watchlistMarkets` with `AuthenticatedUserStorageService` so the watchlist is persisted server-side per authenticated user account ([#9010](https://github.com/MetaMask/core/pull/9010)) +- `toggleWatchlistMarket` now performs an optimistic local-state update followed by an async AUS read-merge-write; on failure the local state is reverted. +- On `init()`, `state.watchlistMarkets` is hydrated from AUS (source of truth). If no remote watchlist exists yet for the active exchange, any existing local markets are migrated to AUS in a one-time push. +- When unauthenticated, or when the active provider is not mapped to an AUS exchange key (e.g. `'aggregated'`), the controller falls back to local-only state without surfacing errors to callers. +- `toggleWatchlistMarket` return type changed from `void` to `Promise` to allow callers to await the remote write. +- Add `resolveWatchlistExchangeKey(activeProvider)` helper that maps a `PerpsActiveProviderMode` to the corresponding `PerpsWatchlistMarkets` exchange key, returning `null` for unsupported modes ([#9010](https://github.com/MetaMask/core/pull/9010)) + +### Fixed + +- Fix `#syncWatchlistFromRemote` to use exchange-key presence instead of symbol count when deciding whether to hydrate from AUS, so an intentionally cleared remote watchlist is honored rather than overwritten by stale local favorites ([#9010](https://github.com/MetaMask/core/pull/9010)) + +## [8.3.0] + +### Added + +- Add Terminal API integration for market data, controlled via `useTerminalApi` parameter on `GetMarketsParams` / `GetMarketDataWithPricesParams` ([#9137](https://github.com/MetaMask/core/pull/9137)) + - `TerminalMarketService` fetches structured market metadata from the injected `terminalApiUrl` with a 5-minute cache TTL. + - When enabled, `getMarkets()` attempts the Terminal API first; on failure or empty response, falls back silently to HyperLiquid. Terminal results respect the same allowlist/blocklist filtering as the provider path. + - `getMarketDataWithPrices()` enriches provider data with Terminal API metadata (name, keywords, tags, categories). + - `PerpsPlatformDependencies` gains an optional `terminalApiUrl?: string` field and an optional `terminalMarketService?: PerpsTerminalMarketService` field; clients can inject a pre-built service instance or let the controller create one from the URL. + - `PerpsMarketData` gains optional `keywords`, `tags`, and `categories` fields. + - Market search (`getMarketMatchRank`, `rankMarketsByQuery`) now indexes the `keywords` field for richer search results. + - `HYPERLIQUID_ASSET_NAMES` and `HIP3_ASSET_MARKET_TYPES` remain intact as fallback for assets absent from the Terminal API. +- Surface per-market trading availability so clients can warn before placing an order that would be rejected ([#9205](https://github.com/MetaMask/core/pull/9205)) + - Add an `isTradable` boolean to `PriceUpdate` that defaults to `true`. It is `false` when a market's mid price has drifted past the protocol's oracle-deviation limit (HyperLiquid rejects orders more than 95% away from the reference price, which most often affects HIP-3 markets); a provider with no such rule, or that cannot yet assess tradability, reports `true`. + - Add an optional, protocol-agnostic `fallbackPriceDeviationLimit` to `PerpsControllerConfig` so clients can tune the deviation threshold; each provider applies its own default when omitted. + - Export the pure `isMarketTradable` helper and add `HYPERLIQUID_CONFIG.OraclePriceDeviationLimit` (`0.95`, the HyperLiquid default). + +### Changed + +- Bump `@metamask/controller-utils` from `^12.2.0` to `^12.3.0` ([#9218](https://github.com/MetaMask/core/pull/9218)) + +### Fixed + +- Add a 10-second fetch timeout to `TerminalMarketService` so a stalled Terminal API degrades to the provider promptly instead of blocking indefinitely ([#9224](https://github.com/MetaMask/core/pull/9224)) +- Only override the provider display name when Terminal supplies a non-null value, preventing symbol fallback from replacing good provider names ([#9224](https://github.com/MetaMask/core/pull/9224)) + +## [8.2.0] + +### Added + +- Add Perps Discovery analytics constants to `PERPS_EVENT_PROPERTY` and `PERPS_EVENT_VALUE` so mobile can import them from `@metamask/perps-controller` instead of maintaining a local mirror ([#9178](https://github.com/MetaMask/core/pull/9178)) + - New `PERPS_EVENT_PROPERTY` keys: `SOURCE_SECTION`, `RESULT_COUNT`, `SECTION_NAME`, `SECTION_INDEX`, `SECTIONS_DISPLAYED`, `WATCHLIST_COUNT`, `WATCHLIST_MARKETS` + - New `PERPS_EVENT_VALUE.SOURCE_SECTION` group: values for home sections (`positions`, `orders`, `watchlist`, `whats_happening`, `products`, `top_gainers`, `top_losers`, `crypto`, `commodity`, `stock`, `forex`), explore sections (`perps_movers`, `perps_crypto`, `perps_stocks_commodities`, `perps_markets`), and market-list sections (`all_markets`, `new`, `active_search`) + - New `PERPS_EVENT_VALUE.SECTION_NAME` group: `balance`, `positions`, `orders`, `watchlist`, `whats_happening`, `products`, `top_movers`, `explore_crypto`, `explore_commodities`, `explore_stocks`, `explore_forex`, `recent_activity` + - Extended `PERPS_EVENT_VALUE.INTERACTION_TYPE` with `MARKET_LIST_FILTER` + - Extended `PERPS_EVENT_VALUE.BUTTON_CLICKED` with `WATCHLIST`, `TOP_MOVERS`, `WHATS_HAPPENING` + - Extended `PERPS_EVENT_VALUE.BUTTON_LOCATION` with `ASSET_DETAILS` + +### Changed + +- Bump `@metamask/utils` from `^11.9.0` to `^11.11.0` ([#9074](https://github.com/MetaMask/core/pull/9074)) + +## [8.1.0] + +### Added + +- Add observational hard timeout for order submission: tag the `Perps Order Submission` trace and emit a breadcrumb when a provider round-trip exceeds `PlaceOrderTimeoutMs` (60s), without cancelling the in-flight order ([#8994](https://github.com/MetaMask/core/pull/8994)) +- Add `HYPERLIQUID_ASSET_NAMES` (a curated `symbol → human-readable name` map, e.g. `BTC → 'Bitcoin'`, `xyz:AAPL → 'Apple'`, `xyz:GOLD → 'Gold'`) and the `getHyperLiquidAssetName(symbol, names?)` helper, both exported from `@metamask/perps-controller/constants`, so clients can match and display markets by full name ([#9082](https://github.com/MetaMask/core/pull/9082)) + - HyperLiquid does not expose a per-asset human-readable name; this map is maintained client-side and keyed like `HIP3_ASSET_MARKET_TYPES` (bare `SYMBOL` for crypto, `dex:SYMBOL` for HIP-3). Unmapped assets fall back to their ticker. +- Add `rankMarketsByQuery(markets, query)` and `getMarketMatchRank(market, query)` helpers (and the `MarketMatchRank` enum) for relevance-ranked market search by ticker symbol or human-readable name (exact > prefix > substring, stable within a rank) ([#9082](https://github.com/MetaMask/core/pull/9082)) + - Complements the existing unranked `filterMarketsByQuery`; same match semantics (case-insensitive substring on `symbol` and `name`), but ordered by relevance. No fuzzy/phonetic matching. + +### Changed + +- Deliver HyperLiquid positions, orders, and account/spot balance via per-DEX `clearinghouseState` and `openOrders` subscriptions on all paths, removing the dependency on the deprecated `webData2` snapshot channel ([#9081](https://github.com/MetaMask/core/pull/9081)) + - The non-HIP-3 (main-DEX-only) user data path previously used `webData2`, which HyperLiquid is throttling to a 15s push interval and deprecating. It now uses the same sub-second per-DEX subscriptions as the HIP-3 path, with `webData3` retained only for open-interest caps (not latency-sensitive). +- Surface late order completions via trace `reason: 'late_success' | 'late_error'` ([#8994](https://github.com/MetaMask/core/pull/8994)) +- `PerpsMarketData.name` returned by `getMarketDataWithPrices()` is now the human-readable market name (resolved via `HYPERLIQUID_ASSET_NAMES`) instead of a copy of the ticker symbol; unmapped assets are unchanged (still equal the symbol) ([#9082](https://github.com/MetaMask/core/pull/9082)) + - `transformMarketData` gains an optional `assetNames` parameter (defaults to the bundled map) to override the name source. +- Bump `@metamask/controller-utils` from `^12.1.0` to `^12.2.0` ([#9058](https://github.com/MetaMask/core/pull/9058), [#9083](https://github.com/MetaMask/core/pull/9083)) + +### Removed + +- Remove unused `Perps Order Submission Toast` trace name from the `PerpsTraceName` union ([#8994](https://github.com/MetaMask/core/pull/8994)) + +### Fixed + +- Fix `late_error` never being emitted in the `placeOrder` catch path when a provider call succeeded past `PlaceOrderTimeoutMs` but a subsequent step threw; the trace `reason` now correctly reflects `'late_error'` whenever the submission threshold was exceeded, regardless of where the exception originated ([#8994](https://github.com/MetaMask/core/pull/8994)) + +## [8.0.0] + +### Added + +- Centralise market category classification so consumers share one model instead of re-deriving it per client ([#9009](https://github.com/MetaMask/core/pull/9009)) + - Export `getMarketTypeFilter` (resolves a market to its UI category filter with singular values aligned to `MarketCategory`) and `isHip3Market`. `getMarketTypeFilter` and `matchesCategory` treat a `marketSource` DEX id as a HIP-3 signal consistently, so partial (route-param) markets classify the same way in both. + - Export the pure `matchesCategory` and `applyMarketFilters` helpers (moved from `MarketDataService`). + +### Changed + +- **BREAKING:** Align `MarketTypeFilter` and `MARKET_CATEGORIES` values with `MarketCategory` singular values ([#9009](https://github.com/MetaMask/core/pull/9009)) + - Replace `stocks` with `stock`, `indices` with `index`, `etfs` with `etf`, and `commodities` with `commodity`. +- Reclassify `xyz:CBRS` (Cerebras) from `stock` to `pre-ipo` and add `xyz:IPOP` (Quantinuum) as `pre-ipo` in `HIP3_ASSET_MARKET_TYPES`, so all three Pre-IPO Perpetual markets on trade.xyz (CBRS, SPCX, IPOP) display under the Pre-IPO category ([#9038](https://github.com/MetaMask/core/pull/9038)) + +## [7.0.0] + +### Added + +- Add `MarketCategory` enum, `MARKET_CATEGORIES` ordered array (7 data-model category pills), and `getMarketCategories` messenger action ([#8892](https://github.com/MetaMask/core/pull/8892)) +- Expand `HIP3_ASSET_MARKET_TYPES` with new stock, ETF, pre-IPO, forex, and commodity markets ([#8892](https://github.com/MetaMask/core/pull/8892)) +- Add `categories`, `sortBy`, `direction`, `limit`, and `excludeSymbols` optional params to `GetMarketDataWithPricesParams` and `getMarketDataWithPrices()` for post-processing filtering, sorting, and pagination of market data ([#8892](https://github.com/MetaMask/core/pull/8892)) +- Export `SortField`, `SortDirection`, and `GetMarketDataWithPricesParams` types from the package root ([#8892](https://github.com/MetaMask/core/pull/8892)) + +### Changed + +- **BREAKING:** Replace `'equity'` with granular `MarketType` values: `'stock'`, `'pre-ipo'`, `'index'`, and `'etf'` ([#8892](https://github.com/MetaMask/core/pull/8892)) + - Update any code matching `marketType === 'equity'` to use the specific sub-type. + +## [6.3.0] + +### Added + +- Add slippage controls so users can configure per-order slippage tolerance for market trades ([#8871](https://github.com/MetaMask/core/pull/8871)) +- Track `vip_tier` and `vip_discount` properties on perps trading events for fee analytics ([#8871](https://github.com/MetaMask/core/pull/8871)) +- Surface an in-app banner during an ongoing HyperLiquid outage so users see degraded trading status ([#8871](https://github.com/MetaMask/core/pull/8871)) +- Expose subpath `exports` for `./constants`, `./constants/*`, `./types`, and `./utils/*` so consumers using legacy `node` module resolution can deep-import compiled entry points without losing tree-shaking ([#8883](https://github.com/MetaMask/core/pull/8883)) + +### Fixed + +- Prefer the currently selected EVM account when resolving the trading account so account switching is honored across providers ([#8871](https://github.com/MetaMask/core/pull/8871)) +- Suppress `User or API Wallet does not exist` Sentry noise from unfunded wallets that have not interacted with HyperLiquid ([#8871](https://github.com/MetaMask/core/pull/8871)) +- Approve the HyperLiquid builder fee when missing so order submission succeeds after fresh wallet setup ([#8871](https://github.com/MetaMask/core/pull/8871)) + +## [6.2.0] + +### Changed + +- Pass `isInternal: true` to all internal `addTransaction` calls to adopt the explicit `isInternal` flag introduced in `@metamask/transaction-controller` ([#8633](https://github.com/MetaMask/core/pull/8633)) +- Bump `@metamask/transaction-controller` from `^65.4.0` to `^66.0.0` ([#8848](https://github.com/MetaMask/core/pull/8848)) + +## [6.1.0] + +### Changed + +- Pass the perps builder base fee into rewards discount resolution and treat unhydrated rewards subscription state as retryable instead of a definitive no-discount result ([#8803](https://github.com/MetaMask/core/pull/8803)) +- Bump `@metamask/controller-utils` from `^12.0.0` to `^12.1.0` ([#8774](https://github.com/MetaMask/core/pull/8774)) +- Bump `@metamask/transaction-controller` from `^65.3.0` to `^65.4.0` ([#8796](https://github.com/MetaMask/core/pull/8796)) + +### Fixed + +- Defer signing-backed HyperLiquid unified-account setup for hardware wallets across migratable abstraction modes, including Ledger, Trezor, OneKey, Lattice, and QR keyrings, to avoid repeated signing prompts while browsing ([#8803](https://github.com/MetaMask/core/pull/8803)) +- Improve logging and retry classification for failed cancel/close/TP-SL operations and SDK-wrapped keyring-locked errors ([#8803](https://github.com/MetaMask/core/pull/8803)) + +## [6.0.1] + +### Changed + +- Bump `@metamask/controller-utils` from `^11.20.0` to `^12.0.0` ([#8755](https://github.com/MetaMask/core/pull/8755)) + +## [6.0.0] + +### Changed + +- **BREAKING:** Rename `AccountState.availableBalance` to `spendableBalance` and `AccountState.availableToTradeBalance` to `withdrawableBalance` for clearer semantics across abstraction modes ([#8678](https://github.com/MetaMask/core/pull/8678)) +- Mode-aware spot fold: `addSpotBalanceToAccountState` now folds free spot USDC into both `spendableBalance` and `withdrawableBalance` for Unified/Portfolio modes, while Standard/DEX-abstraction modes keep spot separate ([#8678](https://github.com/MetaMask/core/pull/8678)) +- Add throttled WS-driven `userAbstraction` refresh so HL-web mode flips propagate back without requiring a restart or account switch ([#8678](https://github.com/MetaMask/core/pull/8678)) +- Fix position direction display for flipped positions ([#8707](https://github.com/MetaMask/core/pull/8707)) + +## [5.0.0] + +### Added + +- **BREAKING:** `HyperLiquidClientService` now forces the `dexAbstraction → unifiedAccount` migration via a new internal flow, deferred until first `withdraw`, `placeOrder`, or other action entry point so users see unified collateral on their first trade/withdrawal ([#8658](https://github.com/MetaMask/core/pull/8658)) +- **BREAKING:** `addSpotBalanceToAccountState` and `HyperLiquidSubscriptionService` are now mode-aware: spot USDC is only folded into tradeable collateral for `unifiedAccount` / `portfolioMargin` modes, and `userAbstraction` is propagated through subscriptions ([#8658](https://github.com/MetaMask/core/pull/8658)) + +### Changed + +- Bump `@nktkas/hyperliquid` from `^0.30.2` to `^0.32.2` for `userAbstraction` / `userSetAbstraction` / `agentSetAbstraction` API surface ([#8658](https://github.com/MetaMask/core/pull/8658)) +- Replace `agentSetAbstraction` wire-code magic string with a typed constant ([#8658](https://github.com/MetaMask/core/pull/8658)) +- Bump `@metamask/keyring-controller` from `^25.3.0` to `^25.4.0` ([#8665](https://github.com/MetaMask/core/pull/8665)) +- Bump `@metamask/account-tree-controller` from `^7.1.0` to `^7.2.0` ([#8665](https://github.com/MetaMask/core/pull/8665)) +- Bump `@metamask/transaction-controller` from `^64.4.0` to `^65.0.0` ([#8613](https://github.com/MetaMask/core/pull/8613)) +- Bump `@metamask/messenger` from `^1.1.1` to `^1.2.0` ([#8632](https://github.com/MetaMask/core/pull/8632)) + +### Fixed + +- Keep users on `portfolioMargin` mode and recover the resolved abstraction mode after migration instead of evicting it ([#8658](https://github.com/MetaMask/core/pull/8658)) +- Retry abstraction mode after transient `userAbstraction` failures and reset the memoized readiness promise after silent migration failures ([#8658](https://github.com/MetaMask/core/pull/8658)) +- Close WebSocket-vs-REST race that could fold spot for Standard users and preserve abstraction REST results across active subscribers ([#8658](https://github.com/MetaMask/core/pull/8658)) +- Drop the pre-fetch generation guard so `userAbstraction` always resolves; treat cached balances as an unambiguous spot owner ([#8658](https://github.com/MetaMask/core/pull/8658)) +- Restore HyperLiquid withdrawal for Unified Account Mode users and support arb USDC withdraw balance in unified mode ([#8658](https://github.com/MetaMask/core/pull/8658)) +- Harden unified-account migration handling and close MM Pay `$0` + analytics gaps ([#8658](https://github.com/MetaMask/core/pull/8658)) + +## [4.0.0] + +### Added + +- Add `coalescePerpsRestRequest` utility for deduplicating concurrent REST requests with account-scoped cache keys ([#8560](https://github.com/MetaMask/core/pull/8560)) +- Add `accountUtils` helpers for resolving the active perps account id and pinning it to forwarded provider params ([#8560](https://github.com/MetaMask/core/pull/8560)) + +### Changed + +- Account-scope the REST cache and guard cache writes so mount load stays cacheable without cross-account bleed ([#8560](https://github.com/MetaMask/core/pull/8560)) +- Make `forceRefresh` provider-agnostic and align rate-limit handling with the extension ([#8560](https://github.com/MetaMask/core/pull/8560)) +- Regenerate `PerpsController` method action types; shrink rate-limit diff and drop verbose history logs ([#8560](https://github.com/MetaMask/core/pull/8560)) + +### Removed + +- **BREAKING:** Drop the dead `spotState` parameter from `adaptAccountStateFromSDK`. Spot balances are layered on by `addSpotBalanceToAccountState`, which enforces the USDC-only policy via `SPOT_COLLATERAL_COINS`; removing the dormant branch keeps one source of truth and prevents a future caller from silently getting ALL-coins behavior ([#8560](https://github.com/MetaMask/core/pull/8560)) + +### Fixed + +- HyperLiquid Unified-mode live balance: subscribe to `spotState` WS and compute tradeable/total balance from on-chain math ([#8560](https://github.com/MetaMask/core/pull/8560)) +- Complete spot-balance parity with the extension consumer ([#8560](https://github.com/MetaMask/core/pull/8560)) +- Preserve integer trailing zeros when `szDecimals=0` in `perpsFormatters` ([#8560](https://github.com/MetaMask/core/pull/8560)) +- Preserve candle pagination cancellation and skip coalesce for explicit-`endTime` candle paging to avoid stale pages ([#8560](https://github.com/MetaMask/core/pull/8560)) +- Defer account resolution on the non-paginated cache path to prevent race conditions ([#8560](https://github.com/MetaMask/core/pull/8560)) +- Force-refresh on activity mount and evict expired coalesce entries so stale promises cannot resolve to cache ([#8560](https://github.com/MetaMask/core/pull/8560)) +- Normalize `event.user` to lowercase when caching the spot-state WS address so `#ensureSpotState` hits the cache instead of triggering a redundant REST `spotClearinghouseState` refetch when HyperLiquid returns a checksummed address ([#8560](https://github.com/MetaMask/core/pull/8560)) + +## [3.2.0] + +### Added + +- Add `isAbortError` utility export from `utils` for distinguishing expected cancellation errors from real failures ([#8515](https://github.com/MetaMask/core/pull/8515)) + +### Changed + +- `TradingService.flipPosition()` no longer passes stale position `entryPrice` as `currentPrice` on reverse-position orders; providers now validate and price flips against live market data ([#8515](https://github.com/MetaMask/core/pull/8515)) + +### Removed + +- Remove unused `ESTIMATED_FEE_RATE` export from `constants/hyperLiquidConfig` (dead code after reverse-position fee precheck was removed) ([#8515](https://github.com/MetaMask/core/pull/8515)) + +### Fixed + +- Suppress noisy Sentry reports from expected historical-candle fetch cancellations (`AbortError`) during navigation, while preserving real error reporting in `HyperLiquidClientService` and `MarketDataService` ([#8515](https://github.com/MetaMask/core/pull/8515)) + +## [3.1.1] + +### Fixed + +- Preserve the `webpackIgnore` safeguard on the `MYXProvider` dynamic import in built dist files so extension consumers do not statically resolve the intentionally-unpublished MYX provider module ([#8473](https://github.com/MetaMask/core/pull/8473)) +- Use HTTP transport for HyperLiquid candle snapshots and refresh DEX discovery cache handling to avoid rapid market-switching 429s after syncing the latest mobile perps controller state ([#8473](https://github.com/MetaMask/core/pull/8473)) + +## [3.1.0] + +### Added + +- Add disk-backed cold-start cache for instant data display on launch ([#8460](https://github.com/MetaMask/core/pull/8460)) +- Add `skipTTL` option to `getCachedMarketDataForActiveProvider` and `getCachedUserDataForActiveProvider` ([#8460](https://github.com/MetaMask/core/pull/8460)) +- Add perps decimal formatters (`perpsFormatters`) for shared formatting utilities ([#8460](https://github.com/MetaMask/core/pull/8460)) +- Add `FUNDING_RATE_CONFIG` constants for funding rate display formatting ([#8460](https://github.com/MetaMask/core/pull/8460)) +- Add `buildProviderCacheKey` and `getProviderNetworkKey` helper exports ([#8460](https://github.com/MetaMask/core/pull/8460)) + +### Changed + +- Bump `@metamask/transaction-controller` from `^64.0.0` to `^64.1.0` ([#8432](https://github.com/MetaMask/core/pull/8432)) +- Bump `@metamask/base-controller` from `^9.0.1` to `^9.1.0` ([#8457](https://github.com/MetaMask/core/pull/8457)) + +### Fixed + +- Fix TP/SL orders disappearing after creating a market order by filtering on `isPositionTpsl` ([#8460](https://github.com/MetaMask/core/pull/8460)) +- Fix missing latest funding payments by using paginated fetch with auto-split ([#8460](https://github.com/MetaMask/core/pull/8460)) +- Fix WebSocket reconnection on foreground return when socket is still alive ([#8460](https://github.com/MetaMask/core/pull/8460)) + +## [3.0.0] + +### Added + +- Export `PerpsControllerGetStateAction` type ([#8352](https://github.com/MetaMask/core/pull/8352)) +- Expose missing public `PerpsController` methods through its messenger ([#8352](https://github.com/MetaMask/core/pull/8352)) + - The following actions are now available: + - `PerpsController:calculateLiquidationPrice` + - `PerpsController:calculateMaintenanceMargin` + - `PerpsController:clearDepositResult` + - `PerpsController:clearWithdrawResult` + - `PerpsController:completeWithdrawalFromHistory` + - `PerpsController:depositWithConfirmation` + - `PerpsController:depositWithOrder` + - `PerpsController:fetchHistoricalCandles` + - `PerpsController:flipPosition` + - `PerpsController:getActiveProvider` + - `PerpsController:getActiveProviderOrNull` + - `PerpsController:getAvailableDexs` + - `PerpsController:getBlockExplorerUrl` + - `PerpsController:getCachedMarketDataForActiveProvider` + - `PerpsController:getCachedUserDataForActiveProvider` + - `PerpsController:getCurrentNetwork` + - `PerpsController:getMarketDataWithPrices` + - `PerpsController:getMaxLeverage` + - `PerpsController:getWatchlistMarkets` + - `PerpsController:getWebSocketConnectionState` + - `PerpsController:getWithdrawalProgress` + - `PerpsController:getWithdrawalRoutes` + - `PerpsController:init` + - `PerpsController:isCurrentlyReinitializing` + - `PerpsController:isFirstTimeUserOnCurrentNetwork` + - `PerpsController:isWatchlistMarket` + - `PerpsController:reconnect` + - `PerpsController:setLiveDataConfig` + - `PerpsController:startMarketDataPreload` + - `PerpsController:stopMarketDataPreload` + - `PerpsController:subscribeToAccount` + - `PerpsController:subscribeToCandles` + - `PerpsController:subscribeToConnectionState` + - `PerpsController:subscribeToOICaps` + - `PerpsController:subscribeToOrderBook` + - `PerpsController:subscribeToOrderFills` + - `PerpsController:subscribeToOrders` + - `PerpsController:subscribeToPositions` + - `PerpsController:subscribeToPrices` + - `PerpsController:switchProvider` + - `PerpsController:toggleWatchlistMarket` + - `PerpsController:updateMargin` + - `PerpsController:updatePositionTPSL` + - `PerpsController:updateWithdrawalProgress` + - `PerpsController:updateWithdrawalStatus` + - `PerpsController:validateClosePosition` + - `PerpsController:validateOrder` + - `PerpsController:validateWithdrawal` + - Corresponding action types are available as well. +- Add `completeWithdrawalFromHistory` method for FIFO-based withdrawal completion matching ([#8333](https://github.com/MetaMask/core/pull/8333)) +- Add `lastCompletedWithdrawalTimestamp` and `lastCompletedWithdrawalTxHashes` state fields ([#8333](https://github.com/MetaMask/core/pull/8333)) + +### Changed + +- Refactor pending withdraw/deposit tracking to FIFO queue design ([#8333](https://github.com/MetaMask/core/pull/8333)) +- Centralize Arbitrum network check in deposit hooks to prevent missing network errors ([#8333](https://github.com/MetaMask/core/pull/8333)) +- Provider credentials, builder fee injection, and env var centralization ([#8333](https://github.com/MetaMask/core/pull/8333)) +- Reduce max order amount by 0.5% buffer to avoid insufficient margin rejections ([#8333](https://github.com/MetaMask/core/pull/8333)) +- Bump `@metamask/account-tree-controller` from `^6.0.0` to `^7.0.0` ([#8325](https://github.com/MetaMask/core/pull/8325)) +- Bump `@metamask/profile-sync-controller` from `^28.0.1` to `^28.0.2` ([#8325](https://github.com/MetaMask/core/pull/8325)) +- Bump `@metamask/controller-utils` from `^11.19.0` to `^11.20.0` ([#8344](https://github.com/MetaMask/core/pull/8344)) +- Bump `@metamask/messenger` from `^1.0.0` to `^1.1.1` ([#8364](https://github.com/MetaMask/core/pull/8364), [#8373](https://github.com/MetaMask/core/pull/8373)) +- Move `@myx-trade/sdk` from `dependencies` to `optionalDependencies` so consumers (extension, mobile) do not install it automatically ([#8398](https://github.com/MetaMask/core/pull/8398)) + - Combined with the MYX adapter export removal below, this prevents `@myx-trade/sdk` from entering the consumer's static webpack/metro import graph + - `MYXProvider` continues to load `@myx-trade/sdk` via dynamic `import()` when `MM_PERPS_MYX_PROVIDER_ENABLED=true` +- Add `/* webpackIgnore: true */` magic comment to the `MYXProvider` dynamic import so webpack (extension) skips static resolution of the intentionally-unshipped module ([#8398](https://github.com/MetaMask/core/pull/8398)) + +### Removed + +- **BREAKING:** Remove `adaptMarketFromMYX`, `adaptPriceFromMYX`, `adaptMarketDataFromMYX`, `filterMYXExclusiveMarkets`, `isOverlappingMarket`, `buildPoolSymbolMap`, `buildSymbolPoolsMap`, and `extractSymbolFromPoolId` from the public package exports to prevent `@myx-trade/sdk` from being included in the static webpack bundle ([#8398](https://github.com/MetaMask/core/pull/8398)) + - These functions are still used internally by `MYXProvider`, which is loaded via dynamic import + - Consumers that imported these utilities directly should instead import from `@metamask/perps-controller/src/utils/myxAdapter` or duplicate the logic locally + +### Fixed + +- Preserve `/* webpackIgnore: true */` magic comment in built dist files by using a variable for the MYXProvider dynamic import path, preventing ts-bridge from rewriting the AST node and stripping the comment ([#8424](https://github.com/MetaMask/core/pull/8424)) +- Fix incorrect fee estimate when flipping a position ([#8333](https://github.com/MetaMask/core/pull/8333)) +- Fix incorrect PnL and order size displayed after SL execution ([#8333](https://github.com/MetaMask/core/pull/8333)) +- Fix stop loss not showing up in recent activity ([#8333](https://github.com/MetaMask/core/pull/8333)) +- Fix incorrect market categories ([#8333](https://github.com/MetaMask/core/pull/8333)) +- Fix TP/SL decimal precision for PUMP ([#8333](https://github.com/MetaMask/core/pull/8333)) +- Fix missing decimal on price input when using preset on limit price ([#8333](https://github.com/MetaMask/core/pull/8333)) + +## [2.0.0] + +### Changed + +- Sync mobile perps code to core (mobile branch `feat/perps/core-resolver`) ([#8291](https://github.com/MetaMask/core/pull/8291)) +- Add `@metamask/geolocation-controller` dependency for eligibility geolocation checks ([#8291](https://github.com/MetaMask/core/pull/8291)) +- Exclude `MYXWalletService` from published package files ([#8291](https://github.com/MetaMask/core/pull/8291)) +- MYX provider improvements: enhanced error handling, wallet service integration ([#8291](https://github.com/MetaMask/core/pull/8291)) +- HyperLiquid provider improvements: subscription reliability, order book processing ([#8291](https://github.com/MetaMask/core/pull/8291)) +- Eligibility service refactored for geolocation-based region blocking ([#8291](https://github.com/MetaMask/core/pull/8291)) +- Bump `@metamask/base-controller` from `^9.0.0` to `^9.0.1` ([#8317](https://github.com/MetaMask/core/pull/8317)) +- Bump `@metamask/messenger` from `^0.3.0` to `^1.0.0` ([#8317](https://github.com/MetaMask/core/pull/8317)) + +## [1.3.0] + +### Changed + +- Exclude `@myx-trade/sdk` from build output by default, reducing bundled size by ~57% ([#8234](https://github.com/MetaMask/core/pull/8234)) +- MYX provider files are excluded from the package when publishing +- Static import of `MYXProvider` replaced with dynamic `import()` that depends upon `MM_PERPS_MYX_PROVIDER_ENABLED=true` to break the eager dependency chain + +## [1.2.0] + +### Added + +- Add `stopEligibilityMonitoring()` method to pause geo-blocking eligibility checks when basic functionality is disabled ([#8214](https://github.com/MetaMask/core/pull/8214)) + +## [1.1.0] + +### Added + +- feat: defer eligibility to allow for onboarding to proceed without le… ([#8197](https://github.com/MetaMask/core/pull/8197)) + +## [1.0.1] + +### Changed + +- Bump `@metamask/profile-sync-controller` from `^27.1.0` to `^28.0.0` ([#8162](https://github.com/MetaMask/core/pull/8162)) +- Bump `@metamask/account-tree-controller` from `^5.0.0` to `^5.0.1` ([#8162](https://github.com/MetaMask/core/pull/8162)) + +## [1.0.0] + +### Added + +- Initial release ([#7654](https://github.com/MetaMask/core/pull/7654), [#7941](https://github.com/MetaMask/core/pull/7941)) + - Add full `PerpsController` with multi-provider architecture, state management, and messenger integration + - Add `HyperLiquidProvider` with complete DEX integration: trading, market data, order book, WebSocket subscriptions, wallet operations, and HIP-3 builder-deployed perpetuals support + - Add `MYXProvider` with DEX integration: trading, market data, and account management + - Add `AggregatedPerpsProvider` for multi-provider aggregation and unified market/position views + - Add `ProviderRouter` for routing operations to the appropriate provider based on market configuration + - Add `SubscriptionMultiplexer` for real-time WebSocket data aggregation across providers + - Add `TradingService` for order placement, modification, cancellation, and position management + - Add `MarketDataService` for market listing, pricing, funding rates, and order book data + - Add `AccountService` for account state, balances, positions, and open orders + - Add `DepositService` for deposit flow handling + - Add `EligibilityService` for user eligibility verification + - Add `FeatureFlagConfigurationService` for runtime feature flag management + - Add `HyperLiquidClientService`, `HyperLiquidSubscriptionService`, and `HyperLiquidWalletService` for HyperLiquid-specific operations + - Add `MYXClientService` for MYX-specific API operations + - Add `DataLakeService` for data lake integration + - Add `RewardsIntegrationService` for rewards system integration + - Add `TradingReadinessCache` for caching trading readiness state + - Add `ServiceContext` for service dependency injection + - Add comprehensive type definitions for perps, HyperLiquid, MYX, configuration, tokens, and transactions + - Add utility functions for market data transformation, order calculations, account operations, validation, and adapters + - Add state selectors for accessing controller state + - Add error code definitions for structured error handling + - Add configuration constants for HyperLiquid, MYX, charts, order types, and performance metrics + - Add platform-agnostic design via `PerpsPlatformDependencies` injection interface + - Add generated method action types for messenger-exposed methods + +### Changed + +- Bump `@metamask/controller-utils` from `^11.18.0` to `^11.19.0` ([#7995](https://github.com/MetaMask/core/pull/7995)) + +[Unreleased]: https://github.com/MetaMask/core/compare/@metamask/perps-controller@14.0.0...HEAD +[14.0.0]: https://github.com/MetaMask/core/compare/@metamask/perps-controller@13.1.0...@metamask/perps-controller@14.0.0 +[13.1.0]: https://github.com/MetaMask/core/compare/@metamask/perps-controller@13.0.0...@metamask/perps-controller@13.1.0 +[13.0.0]: https://github.com/MetaMask/core/compare/@metamask/perps-controller@12.2.0...@metamask/perps-controller@13.0.0 +[12.2.0]: https://github.com/MetaMask/core/compare/@metamask/perps-controller@12.1.0...@metamask/perps-controller@12.2.0 +[12.1.0]: https://github.com/MetaMask/core/compare/@metamask/perps-controller@12.0.0...@metamask/perps-controller@12.1.0 +[12.0.0]: https://github.com/MetaMask/core/compare/@metamask/perps-controller@11.0.0...@metamask/perps-controller@12.0.0 +[11.0.0]: https://github.com/MetaMask/core/compare/@metamask/perps-controller@10.0.0...@metamask/perps-controller@11.0.0 +[10.0.0]: https://github.com/MetaMask/core/compare/@metamask/perps-controller@9.3.0...@metamask/perps-controller@10.0.0 +[9.3.0]: https://github.com/MetaMask/core/compare/@metamask/perps-controller@9.2.1...@metamask/perps-controller@9.3.0 +[9.2.1]: https://github.com/MetaMask/core/compare/@metamask/perps-controller@9.2.0...@metamask/perps-controller@9.2.1 +[9.2.0]: https://github.com/MetaMask/core/compare/@metamask/perps-controller@9.1.0...@metamask/perps-controller@9.2.0 +[9.1.0]: https://github.com/MetaMask/core/compare/@metamask/perps-controller@9.0.0...@metamask/perps-controller@9.1.0 +[9.0.0]: https://github.com/MetaMask/core/compare/@metamask/perps-controller@8.3.0...@metamask/perps-controller@9.0.0 +[8.3.0]: https://github.com/MetaMask/core/compare/@metamask/perps-controller@8.2.0...@metamask/perps-controller@8.3.0 +[8.2.0]: https://github.com/MetaMask/core/compare/@metamask/perps-controller@8.1.0...@metamask/perps-controller@8.2.0 +[8.1.0]: https://github.com/MetaMask/core/compare/@metamask/perps-controller@8.0.0...@metamask/perps-controller@8.1.0 +[8.0.0]: https://github.com/MetaMask/core/compare/@metamask/perps-controller@7.0.0...@metamask/perps-controller@8.0.0 +[7.0.0]: https://github.com/MetaMask/core/compare/@metamask/perps-controller@6.3.0...@metamask/perps-controller@7.0.0 +[6.3.0]: https://github.com/MetaMask/core/compare/@metamask/perps-controller@6.2.0...@metamask/perps-controller@6.3.0 +[6.2.0]: https://github.com/MetaMask/core/compare/@metamask/perps-controller@6.1.0...@metamask/perps-controller@6.2.0 +[6.1.0]: https://github.com/MetaMask/core/compare/@metamask/perps-controller@6.0.1...@metamask/perps-controller@6.1.0 +[6.0.1]: https://github.com/MetaMask/core/compare/@metamask/perps-controller@6.0.0...@metamask/perps-controller@6.0.1 +[6.0.0]: https://github.com/MetaMask/core/compare/@metamask/perps-controller@5.0.0...@metamask/perps-controller@6.0.0 +[5.0.0]: https://github.com/MetaMask/core/compare/@metamask/perps-controller@4.0.0...@metamask/perps-controller@5.0.0 +[4.0.0]: https://github.com/MetaMask/core/compare/@metamask/perps-controller@3.2.0...@metamask/perps-controller@4.0.0 +[3.2.0]: https://github.com/MetaMask/core/compare/@metamask/perps-controller@3.1.1...@metamask/perps-controller@3.2.0 +[3.1.1]: https://github.com/MetaMask/core/compare/@metamask/perps-controller@3.1.0...@metamask/perps-controller@3.1.1 +[3.1.0]: https://github.com/MetaMask/core/compare/@metamask/perps-controller@3.0.0...@metamask/perps-controller@3.1.0 +[3.0.0]: https://github.com/MetaMask/core/compare/@metamask/perps-controller@2.0.0...@metamask/perps-controller@3.0.0 +[2.0.0]: https://github.com/MetaMask/core/compare/@metamask/perps-controller@1.3.0...@metamask/perps-controller@2.0.0 +[1.3.0]: https://github.com/MetaMask/core/compare/@metamask/perps-controller@1.2.0...@metamask/perps-controller@1.3.0 +[1.2.0]: https://github.com/MetaMask/core/compare/@metamask/perps-controller@1.1.0...@metamask/perps-controller@1.2.0 +[1.1.0]: https://github.com/MetaMask/core/compare/@metamask/perps-controller@1.0.1...@metamask/perps-controller@1.1.0 +[1.0.1]: https://github.com/MetaMask/core/compare/@metamask/perps-controller@1.0.0...@metamask/perps-controller@1.0.1 +[1.0.0]: https://github.com/MetaMask/core/releases/tag/@metamask/perps-controller@1.0.0 diff --git a/packages/perps-controller/LICENSE b/packages/perps-controller/LICENSE new file mode 100644 index 00000000000..fe29e78e0fe --- /dev/null +++ b/packages/perps-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/perps-controller/README.md b/packages/perps-controller/README.md new file mode 100644 index 00000000000..5137f92564d --- /dev/null +++ b/packages/perps-controller/README.md @@ -0,0 +1,34 @@ +# `@metamask/perps-controller` + +Controller for perpetual trading functionality in MetaMask. + +## Installation + +`yarn add @metamask/perps-controller` + +or + +`npm install @metamask/perps-controller` + +## Usage + +```typescript +import { PerpsController } from '@metamask/perps-controller'; +``` + +Chase orders are client-managed post-only strategies. Use +`controller.getChaseOrders()` to read retained lifecycle snapshots and +`controller.suspendChaseOrders()` when the creating client leaves the +foreground; suspension stops repricing while leaving the latest child order +resting. Cancel a Chase through `cancelOrder` with its stable strategy handle +and `orderType: 'chase'`. When using an aggregated provider, also pass the +`providerId` returned with the Chase snapshot so cancellation routes to its +owning venue. `chaseMaxDistanceBps` caps adverse movement from the arrival +price and must be greater than 0 and less than 10,000. The stop follows the +live touch; the final resting child can sit just inside the boundary after +venue price-grid rounding, and `distanceChasedBps` reports that actual resting +distance rounded to the nearest whole basis point. + +## 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/perps-controller/jest.config.js b/packages/perps-controller/jest.config.js new file mode 100644 index 00000000000..b776cf1a044 --- /dev/null +++ b/packages/perps-controller/jest.config.js @@ -0,0 +1,33 @@ +/* + * 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: 72.76, + functions: 79.05, + lines: 83.46, + statements: 83.46, + }, + }, + }), + + // Coverage is collected from real source files. Barrel files are excluded + // because they only re-export the tested modules. + // Applied after merge to fully replace (not concat) the base array. + collectCoverageFrom: ['./src/**/*.ts', '!./src/**/index.ts'], +}; diff --git a/packages/perps-controller/package.json b/packages/perps-controller/package.json new file mode 100644 index 00000000000..49736726de6 --- /dev/null +++ b/packages/perps-controller/package.json @@ -0,0 +1,141 @@ +{ + "name": "@metamask/perps-controller", + "version": "14.0.0", + "description": "Controller for perpetual trading functionality in MetaMask", + "keywords": [ + "Ethereum", + "MetaMask" + ], + "homepage": "https://github.com/MetaMask/core/tree/main/packages/perps-controller#readme", + "bugs": { + "url": "https://github.com/MetaMask/core/issues" + }, + "license": "MIT", + "repository": { + "type": "git", + "url": "https://github.com/MetaMask/core.git" + }, + "files": [ + "dist/", + "!dist/providers/MYXProvider*", + "!dist/services/MYXClientService*", + "!dist/services/MYXWalletService*" + ], + "sideEffects": false, + "main": "./dist/index.cjs", + "types": "./dist/index.d.cts", + "exports": { + ".": { + "import": { + "types": "./dist/index.d.mts", + "default": "./dist/index.mjs" + }, + "require": { + "types": "./dist/index.d.cts", + "default": "./dist/index.cjs" + } + }, + "./constants": { + "import": { + "types": "./dist/constants/index.d.mts", + "default": "./dist/constants/index.mjs" + }, + "require": { + "types": "./dist/constants/index.d.cts", + "default": "./dist/constants/index.cjs" + } + }, + "./constants/*": { + "import": { + "types": "./dist/constants/*.d.mts", + "default": "./dist/constants/*.mjs" + }, + "require": { + "types": "./dist/constants/*.d.cts", + "default": "./dist/constants/*.cjs" + } + }, + "./types": { + "import": { + "types": "./dist/types/index.d.mts", + "default": "./dist/types/index.mjs" + }, + "require": { + "types": "./dist/types/index.d.cts", + "default": "./dist/types/index.cjs" + } + }, + "./utils/*": { + "import": { + "types": "./dist/utils/*.d.mts", + "default": "./dist/utils/*.mjs" + }, + "require": { + "types": "./dist/utils/*.d.cts", + "default": "./dist/utils/*.cjs" + } + }, + "./package.json": "./package.json" + }, + "publishConfig": { + "access": "public", + "registry": "https://registry.npmjs.org/" + }, + "scripts": { + "build": "ts-bridge --project tsconfig.build.json --verbose --clean --no-references", + "build:all": "ts-bridge --project tsconfig.build.json --verbose --clean", + "build:docs": "typedoc", + "changelog:update": "../../scripts/update-changelog.sh @metamask/perps-controller", + "changelog:validate": "../../scripts/validate-changelog.sh @metamask/perps-controller", + "lint:tsconfigs": "tsx ../../scripts/lint-tsconfigs/lint-tsconfigs.mts", + "lint:tsconfigs:fix": "tsx ../../scripts/lint-tsconfigs/lint-tsconfigs.mts --fix", + "messenger-action-types:check": "tsx ../../packages/messenger-cli/src/cli.ts --formatter oxfmt --esm --check", + "messenger-action-types:generate": "tsx ../../packages/messenger-cli/src/cli.ts --formatter oxfmt --esm --generate", + "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/abi-utils": "^2.0.3", + "@metamask/base-controller": "^9.1.0", + "@metamask/controller-utils": "^12.3.0", + "@metamask/messenger": "^2.0.0", + "@metamask/superstruct": "^3.4.1", + "@metamask/utils": "^11.11.0", + "@nktkas/hyperliquid": "^0.33.1", + "bignumber.js": "^9.1.2", + "reselect": "^5.1.1", + "uuid": "^8.3.2" + }, + "devDependencies": { + "@metamask/account-tree-controller": "^8.0.0", + "@metamask/authenticated-user-storage": "^3.0.2", + "@metamask/auto-changelog": "^6.1.0", + "@metamask/geolocation-controller": "^1.0.0", + "@metamask/keyring-controller": "^27.1.1", + "@metamask/keyring-internal-api": "^12.0.0", + "@metamask/network-controller": "^36.0.0", + "@metamask/profile-sync-controller": "^29.0.0", + "@metamask/remote-feature-flag-controller": "^6.1.0", + "@metamask/transaction-controller": "^69.6.1", + "@ts-bridge/cli": "^0.6.4", + "@types/jest": "^30.0.0", + "@types/uuid": "^8.3.0", + "deepmerge": "^4.2.2", + "jest": "^30.4.2", + "ts-jest": "^29.4.11", + "tsx": "^4.20.5", + "typedoc": "^0.25.13", + "typedoc-plugin-missing-exports": "^2.0.0", + "typescript": "~5.3.3", + "viem": "^2.55.8" + }, + "optionalDependencies": { + "@myx-trade/sdk": "^0.1.265" + }, + "engines": { + "node": "^18.18 || >=20" + } +} diff --git a/packages/perps-controller/src/PerpsController-method-action-types.ts b/packages/perps-controller/src/PerpsController-method-action-types.ts new file mode 100644 index 00000000000..011ea68ac18 --- /dev/null +++ b/packages/perps-controller/src/PerpsController-method-action-types.ts @@ -0,0 +1,1394 @@ +/** + * This file is auto generated. + * Do not edit manually. + */ + +import type { PerpsController } from './PerpsController.js'; + +/** + * Read cached market data for the currently active provider (or aggregated). + * Returns null when no valid cache exists or when cache has expired. + * + * @param options - Optional settings. + * @param options.skipTTL - When true, bypass the 5-minute TTL check. + * Used during initial render so disk-hydrated structural data (with + * placeholder prices) is returned regardless of age. + * @returns The cached market data array, or null if no valid cache. + */ +export type PerpsControllerGetCachedMarketDataForActiveProviderAction = { + type: `PerpsController:getCachedMarketDataForActiveProvider`; + handler: PerpsController['getCachedMarketDataForActiveProvider']; +}; + +/** + * Read cached user data for the currently active provider (or aggregated). + * Returns null when no valid cache exists, cache has expired, or address + * does not match the currently selected EVM account. + * + * @param options - Optional settings. + * @param options.skipTTL - When true, bypass the 60s staleness check. + * Used during initial render so disk-hydrated user data (positions/orders) + * is returned regardless of age, avoiding a skeleton flash. + * @returns The cached user data, or null if no valid cache. + */ +export type PerpsControllerGetCachedUserDataForActiveProviderAction = { + type: `PerpsController:getCachedUserDataForActiveProvider`; + handler: PerpsController['getCachedUserDataForActiveProvider']; +}; + +/** + * Fetch, validate, and atomically cache a complete user-data snapshot. + * This remains callable after mount so consumers can seed their live channel + * from one coherent positions/orders/account result. + * + * @returns The accepted user-data snapshot. + */ +export type PerpsControllerGetUserDataSnapshotAction = { + type: `PerpsController:getUserDataSnapshot`; + handler: PerpsController['getUserDataSnapshot']; +}; + +/** + * Initialize the PerpsController providers + * Must be called before using any other methods + * Prevents double initialization with promise caching + * + * @returns A promise that resolves when the operation completes. + */ +export type PerpsControllerInitAction = { + type: `PerpsController:init`; + handler: PerpsController['init']; +}; + +/** + * Get the currently active provider. + * In aggregated mode, returns AggregatedPerpsProvider which routes to underlying providers. + * In single provider mode, returns HyperLiquidProvider directly. + * + * @returns The active provider (aggregated wrapper or direct provider based on mode) + * @throws Error if provider is not initialized or reinitializing + */ +export type PerpsControllerGetActiveProviderAction = { + type: `PerpsController:getActiveProvider`; + handler: PerpsController['getActiveProvider']; +}; + +/** + * Get the currently active provider, returning null if not available + * Use this method when the caller can gracefully handle a missing provider + * (e.g., UI components during initialization or reconnection) + * + * @returns The active provider, or null if not initialized/reinitializing + */ +export type PerpsControllerGetActiveProviderOrNullAction = { + type: `PerpsController:getActiveProviderOrNull`; + handler: PerpsController['getActiveProviderOrNull']; +}; + +/** + * Get strategy capabilities through the active provider route used by order + * placement. The query waits for in-flight initialization and reports an + * explicit unavailable status when no provider route can answer reliably. + * + * @param params - Market and optional provider route. + * @returns Provider-owned order capabilities. + */ +export type PerpsControllerGetOrderCapabilitiesAction = { + type: `PerpsController:getOrderCapabilities`; + handler: PerpsController['getOrderCapabilities']; +}; + +/** + * Place a new order + * Thin delegation to TradingService + * + * @param params - The operation parameters. + * @returns The order result with order ID and status. + */ +export type PerpsControllerPlaceOrderAction = { + type: `PerpsController:placeOrder`; + handler: PerpsController['placeOrder']; +}; + +/** + * Edit an existing order + * Thin delegation to TradingService + * + * @param params - The operation parameters. + * @returns The updated order result with order ID and status. + */ +export type PerpsControllerEditOrderAction = { + type: `PerpsController:editOrder`; + handler: PerpsController['editOrder']; +}; + +/** + * Cancel an existing order + * + * @param params - The operation parameters. + * @returns The cancellation result with status. + */ +export type PerpsControllerCancelOrderAction = { + type: `PerpsController:cancelOrder`; + handler: PerpsController['cancelOrder']; +}; + +/** + * Read venue-backed TWAP lifecycle records through the active provider. + * Providers without native TWAP history return an empty list. + * + * @returns Current and terminal TWAP schedules with slice fills. + */ +export type PerpsControllerGetTwapOrdersAction = { + type: `PerpsController:getTwapOrders`; + handler: PerpsController['getTwapOrders']; +}; + +/** + * Read the active provider's retained Chase lifecycle snapshots. + * Providers without an emulated Chase implementation return an empty list. + * + * @returns Current Chase session snapshots. + */ +export type PerpsControllerGetChaseOrdersAction = { + type: `PerpsController:getChaseOrders`; + handler: PerpsController['getChaseOrders']; +}; + +/** + * Stop Chase repricing for app backgrounding without cancelling the current + * resting children. + * + * @returns Chase snapshots after suspension. + * @throws If an aggregated provider cannot suspend every active venue. Other + * providers may already be suspended; callers can retry to reconcile them. + */ +export type PerpsControllerSuspendChaseOrdersAction = { + type: `PerpsController:suspendChaseOrders`; + handler: PerpsController['suspendChaseOrders']; +}; + +/** + * Cancel multiple orders in parallel + * Batch version of cancelOrder() that cancels multiple orders simultaneously + * + * @param params - The operation parameters. + * @returns The batch cancellation results for each order. + */ +export type PerpsControllerCancelOrdersAction = { + type: `PerpsController:cancelOrders`; + handler: PerpsController['cancelOrders']; +}; + +/** + * Close a position (partial or full) + * Thin delegation to TradingService + * + * @param params - The operation parameters. + * @returns The order result from the close position request. + */ +export type PerpsControllerClosePositionAction = { + type: `PerpsController:closePosition`; + handler: PerpsController['closePosition']; +}; + +/** + * Close multiple positions in parallel + * Batch version of closePosition() that closes multiple positions simultaneously + * + * @param params - The operation parameters. + * @returns The batch close results for each position. + */ +export type PerpsControllerClosePositionsAction = { + type: `PerpsController:closePositions`; + handler: PerpsController['closePositions']; +}; + +/** + * Update TP/SL for an existing position + * + * @param params - The operation parameters. + * @returns The order result from the TP/SL update. + */ +export type PerpsControllerUpdatePositionTPSLAction = { + type: `PerpsController:updatePositionTPSL`; + handler: PerpsController['updatePositionTPSL']; +}; + +/** + * Update margin for an existing position (add or remove) + * + * @param params - The operation parameters. + * @returns The margin update result. + */ +export type PerpsControllerUpdateMarginAction = { + type: `PerpsController:updateMargin`; + handler: PerpsController['updateMargin']; +}; + +/** + * Flip position (reverse direction while keeping size and leverage) + * + * @param params - The operation parameters. + * @returns The order result from the position flip. + */ +export type PerpsControllerFlipPositionAction = { + type: `PerpsController:flipPosition`; + handler: PerpsController['flipPosition']; +}; + +/** + * Simplified deposit method that prepares transaction for confirmation screen + * No complex state tracking - just sets a loading flag + * + * @param params - Parameters for the deposit flow + * @param params.amount - Optional deposit amount + * @param params.placeOrder - If true, uses addTransaction instead of submit to avoid navigation + * @returns An object containing a promise that resolves to the transaction hash. + */ +export type PerpsControllerDepositWithConfirmationAction = { + type: `PerpsController:depositWithConfirmation`; + handler: PerpsController['depositWithConfirmation']; +}; + +/** + * Same as depositWithConfirmation - prepares transaction for confirmation screen. + * + * @returns A promise that resolves to the string result. + */ +export type PerpsControllerDepositWithOrderAction = { + type: `PerpsController:depositWithOrder`; + handler: PerpsController['depositWithOrder']; +}; + +/** + * Clear the last deposit result after it has been shown to the user + */ +export type PerpsControllerClearDepositResultAction = { + type: `PerpsController:clearDepositResult`; + handler: PerpsController['clearDepositResult']; +}; + +export type PerpsControllerClearWithdrawResultAction = { + type: `PerpsController:clearWithdrawResult`; + handler: PerpsController['clearWithdrawResult']; +}; + +/** + * Update withdrawal request status when it completes, or remove it on failure. + * This is called when a withdrawal is matched with a completed withdrawal from the API. + * When status is `failed`, the request is removed from the queue (not retained). + * + * @param withdrawalId - The withdrawal transaction ID. + * @param status - The current status. + * @param txHash - The transaction hash. + */ +export type PerpsControllerUpdateWithdrawalStatusAction = { + type: `PerpsController:updateWithdrawalStatus`; + handler: PerpsController['updateWithdrawalStatus']; +}; + +/** + * Complete a specific withdrawal detected via transaction history polling (FIFO queue). + * Called when a completed withdrawal appears in the transaction history matching a pending request. + * + * Uses FIFO matching: oldest pending withdrawal is matched with first completed withdrawal + * in history that happened after its submission time. + * + * @param withdrawalRequestId - The ID of the pending withdrawal request to mark as complete. + * @param completedWithdrawal - The completed withdrawal data from the history API. + * @param completedWithdrawal.txHash - The on-chain transaction hash. + * @param completedWithdrawal.amount - The withdrawal amount. + * @param completedWithdrawal.timestamp - The completion timestamp from the history API. + * @param completedWithdrawal.asset - The asset symbol (e.g. USDC). + */ +export type PerpsControllerCompleteWithdrawalFromHistoryAction = { + type: `PerpsController:completeWithdrawalFromHistory`; + handler: PerpsController['completeWithdrawalFromHistory']; +}; + +/** + * Update withdrawal progress (persistent across navigation) + * + * @param progress - The progress indicator. + * @param activeWithdrawalId - The active withdrawal ID. + */ +export type PerpsControllerUpdateWithdrawalProgressAction = { + type: `PerpsController:updateWithdrawalProgress`; + handler: PerpsController['updateWithdrawalProgress']; +}; + +/** + * Get current withdrawal progress + * + * @returns The withdrawal progress, last update timestamp, and active withdrawal ID. + */ +export type PerpsControllerGetWithdrawalProgressAction = { + type: `PerpsController:getWithdrawalProgress`; + handler: PerpsController['getWithdrawalProgress']; +}; + +/** + * Withdraw funds from trading account + * + * The withdrawal process varies by provider and may involve: + * - Direct on-chain transfers + * - Bridge operations + * - Multi-step validation processes + * + * Check the specific provider documentation for detailed withdrawal flows. + * + * @param params Withdrawal parameters + * @returns WithdrawResult with withdrawal ID and tracking info + */ +export type PerpsControllerWithdrawAction = { + type: `PerpsController:withdraw`; + handler: PerpsController['withdraw']; +}; + +/** + * Get current positions + * Thin delegation to MarketDataService + * + * For standalone mode, bypasses getActiveProvider() to allow position queries + * without full perps initialization (e.g., for showing positions on token details page) + * + * @param params - The operation parameters. + * @returns Array of open positions for the active provider. + */ +export type PerpsControllerGetPositionsAction = { + type: `PerpsController:getPositions`; + handler: PerpsController['getPositions']; +}; + +/** + * Get historical user fills (trade executions) + * Thin delegation to MarketDataService + * + * @param params - The operation parameters. + * @param options - Optional call modifiers. + * @param options.forceRefresh - Bypass the request-coalesce cache + * end-to-end (user-initiated refresh). + * @returns Array of historical trade executions (fills). + */ +export type PerpsControllerGetOrderFillsAction = { + type: `PerpsController:getOrderFills`; + handler: PerpsController['getOrderFills']; +}; + +/** + * Get historical user orders (order lifecycle) + * Thin delegation to MarketDataService + * + * @param params - The operation parameters. + * @param options - Optional call modifiers. + * @param options.forceRefresh - Bypass the request-coalesce cache + * end-to-end (user-initiated refresh). + * @returns Array of historical orders. + */ +export type PerpsControllerGetOrdersAction = { + type: `PerpsController:getOrders`; + handler: PerpsController['getOrders']; +}; + +/** + * Get currently open orders (real-time status) + * Thin delegation to MarketDataService + * + * For standalone mode, bypasses getActiveProvider() to allow open order queries + * without full perps initialization (e.g., for background preloading) + * + * @param params - The operation parameters. + * @returns A promise that resolves to the result. + */ +export type PerpsControllerGetOpenOrdersAction = { + type: `PerpsController:getOpenOrders`; + handler: PerpsController['getOpenOrders']; +}; + +/** + * Get historical user funding history (funding payments) + * Thin delegation to MarketDataService + * + * @param params - The operation parameters. + * @param options - Optional call modifiers. + * @param options.forceRefresh - Bypass the request-coalesce cache + * end-to-end (user-initiated refresh). + * @returns Array of historical funding payments. + */ +export type PerpsControllerGetFundingAction = { + type: `PerpsController:getFunding`; + handler: PerpsController['getFunding']; +}; + +/** + * Get account state (balances, etc.) + * Thin delegation to MarketDataService + * + * For standalone mode, bypasses getActiveProvider() to allow account state queries + * without full perps initialization (e.g., for checking if user has perps funds) + * + * @param params - The operation parameters. + * @returns A promise that resolves to the result. + */ +export type PerpsControllerGetAccountStateAction = { + type: `PerpsController:getAccountState`; + handler: PerpsController['getAccountState']; +}; + +/** + * Get historical portfolio data + * Thin delegation to MarketDataService + * + * @param params - The operation parameters. + * @returns The historical portfolio data points. + */ +export type PerpsControllerGetHistoricalPortfolioAction = { + type: `PerpsController:getHistoricalPortfolio`; + handler: PerpsController['getHistoricalPortfolio']; +}; + +/** + * Get available markets with optional filtering + * Thin delegation to MarketDataService + * + * For standalone mode, bypasses getActiveProvider() to allow market discovery + * without full perps initialization (e.g., for discovery banners on spot screens) + * + * @param params - The operation parameters. + * @returns Array of available markets matching the filter criteria. + */ +export type PerpsControllerGetMarketsAction = { + type: `PerpsController:getMarkets`; + handler: PerpsController['getMarkets']; +}; + +/** + * Get market data with prices (includes price, volume, 24h change). + * Optionally filter by category, sort, and limit the results. + * + * For standalone mode, bypasses getActiveProvider() to allow market data queries + * without full perps initialization (e.g., for background preloading on app start) + * + * @param params - The operation parameters. + * @param params.standalone - Whether to use standalone mode. + * @param params.categories - Filter to markets matching any of these categories. + * @param params.sortBy - Sort results by this field. + * @param params.direction - Sort direction (default: desc). + * @param params.limit - Maximum number of results to return. + * @returns A promise that resolves to the market data. + */ +export type PerpsControllerGetMarketDataWithPricesAction = { + type: `PerpsController:getMarketDataWithPrices`; + handler: PerpsController['getMarketDataWithPrices']; +}; + +/** + * Start background market data preloading. + * Fetches market data immediately and refreshes every 5 minutes. + * Watches for isTestnet and hip3ConfigVersion changes to re-preload. + */ +export type PerpsControllerStartMarketDataPreloadAction = { + type: `PerpsController:startMarketDataPreload`; + handler: PerpsController['startMarketDataPreload']; +}; + +/** + * Stop background market data preloading. + */ +export type PerpsControllerStopMarketDataPreloadAction = { + type: `PerpsController:stopMarketDataPreload`; + handler: PerpsController['stopMarketDataPreload']; +}; + +/** + * Get list of available HIP-3 builder-deployed DEXs + * + * @param params - Optional parameters for filtering + * @returns Array of DEX names + */ +export type PerpsControllerGetAvailableDexsAction = { + type: `PerpsController:getAvailableDexs`; + handler: PerpsController['getAvailableDexs']; +}; + +/** + * Fetch historical candle data + * Thin delegation to MarketDataService + * + * @param options - The configuration options. + * @param options.symbol - The trading pair symbol. + * @param options.interval - The candle interval period. + * @param options.limit - Maximum number of items to fetch. + * @param options.endTime - End timestamp in milliseconds. + * @returns The historical candle data for the requested symbol and interval. + */ +export type PerpsControllerFetchHistoricalCandlesAction = { + type: `PerpsController:fetchHistoricalCandles`; + handler: PerpsController['fetchHistoricalCandles']; +}; + +/** + * Calculate liquidation price for a position + * Uses provider-specific formulas based on protocol rules + * + * @param params - The operation parameters. + * @returns A promise that resolves to the string result. + */ +export type PerpsControllerCalculateLiquidationPriceAction = { + type: `PerpsController:calculateLiquidationPrice`; + handler: PerpsController['calculateLiquidationPrice']; +}; + +/** + * Project the isolated position that would remain after a proposed order. + * Margin and liquidation availability are independent: a missing liquidation + * does not hide a valid margin projection. Cross-margin returns unsupported. + * + * @param params - Live position plus the proposed order. + * @returns Discriminated preview of the resulting position. + */ +export type PerpsControllerPreviewPositionModifyAction = { + type: `PerpsController:previewPositionModify`; + handler: PerpsController['previewPositionModify']; +}; + +/** + * Calculate maintenance margin for a specific asset + * Returns a percentage (e.g., 0.0125 for 1.25%) + * + * @param params - The operation parameters. + * @returns A promise that resolves to the numeric result. + */ +export type PerpsControllerCalculateMaintenanceMarginAction = { + type: `PerpsController:calculateMaintenanceMargin`; + handler: PerpsController['calculateMaintenanceMargin']; +}; + +/** + * Get maximum leverage allowed for an asset + * + * @param asset - The asset identifier. + * @returns A promise that resolves to the numeric result. + */ +export type PerpsControllerGetMaxLeverageAction = { + type: `PerpsController:getMaxLeverage`; + handler: PerpsController['getMaxLeverage']; +}; + +/** + * Validate order parameters according to protocol-specific rules + * + * @param params - The operation parameters. + * @returns True if the condition is met. + */ +export type PerpsControllerValidateOrderAction = { + type: `PerpsController:validateOrder`; + handler: PerpsController['validateOrder']; +}; + +/** + * Validate close position parameters according to protocol-specific rules + * + * @param params - The operation parameters. + * @returns A promise that resolves to the result. + */ +export type PerpsControllerValidateClosePositionAction = { + type: `PerpsController:validateClosePosition`; + handler: PerpsController['validateClosePosition']; +}; + +/** + * Validate withdrawal parameters according to protocol-specific rules + * + * @param params - The operation parameters. + * @returns True if the condition is met. + */ +export type PerpsControllerValidateWithdrawalAction = { + type: `PerpsController:validateWithdrawal`; + handler: PerpsController['validateWithdrawal']; +}; + +/** + * Get supported withdrawal routes - returns complete asset and routing information + * + * @returns Array of supported asset routes for withdrawals. + */ +export type PerpsControllerGetWithdrawalRoutesAction = { + type: `PerpsController:getWithdrawalRoutes`; + handler: PerpsController['getWithdrawalRoutes']; +}; + +/** + * Set the transient UTM / discovery attribution context. + * Replaces any previously set context. Held in-memory only — not persisted. + * + * @param context - The attribution context (UTM fields) to store. + */ +export type PerpsControllerSetAttributionContextAction = { + type: `PerpsController:setAttributionContext`; + handler: PerpsController['setAttributionContext']; +}; + +/** + * Get a copy of the current attribution context. + * + * @returns A shallow copy of the stored attribution context. + */ +export type PerpsControllerGetAttributionContextAction = { + type: `PerpsController:getAttributionContext`; + handler: PerpsController['getAttributionContext']; +}; + +/** + * Clear the stored attribution context. + */ +export type PerpsControllerClearAttributionContextAction = { + type: `PerpsController:clearAttributionContext`; + handler: PerpsController['clearAttributionContext']; +}; + +/** + * Toggle between testnet and mainnet + * + * @returns The toggle result with success status and current network mode. + */ +export type PerpsControllerToggleTestnetAction = { + type: `PerpsController:toggleTestnet`; + handler: PerpsController['toggleTestnet']; +}; + +/** + * Switch to a different provider + * Uses a full reinit approach: disconnect() → update state → init() + * This ensures complete state reset including WebSocket connections and caches. + * + * @param providerId - The provider identifier. + * @returns The switch result with success status and active provider. + */ +export type PerpsControllerSwitchProviderAction = { + type: `PerpsController:switchProvider`; + handler: PerpsController['switchProvider']; +}; + +/** + * Get current network (mainnet/testnet) + * + * @returns Either 'mainnet' or 'testnet' based on the current configuration. + */ +export type PerpsControllerGetCurrentNetworkAction = { + type: `PerpsController:getCurrentNetwork`; + handler: PerpsController['getCurrentNetwork']; +}; + +/** + * Get the ordered list of all market categories for HIP-3 markets. + * Returns a stable, explicitly ordered array so the UI can render + * category filter tabs without deriving order from config insertion. + * + * @returns Ordered array of {@link MarketTypeFilter} values. Does not include the 'all' or 'new' sentinels — those are separate UI controls. + */ +export type PerpsControllerGetMarketCategoriesAction = { + type: `PerpsController:getMarketCategories`; + handler: PerpsController['getMarketCategories']; +}; + +/** + * Get the current WebSocket connection state from the active provider. + * Used by the UI to monitor connection health and show notifications. + * + * @returns The current WebSocket connection state, or DISCONNECTED if not supported + */ +export type PerpsControllerGetWebSocketConnectionStateAction = { + type: `PerpsController:getWebSocketConnectionState`; + handler: PerpsController['getWebSocketConnectionState']; +}; + +/** + * Subscribe to WebSocket connection state changes from the active provider. + * The listener will be called immediately with the current state and whenever the state changes. + * + * @param listener - Callback function that receives the new connection state and reconnection attempt + * @returns Unsubscribe function to remove the listener, or no-op if not supported + */ +export type PerpsControllerSubscribeToConnectionStateAction = { + type: `PerpsController:subscribeToConnectionState`; + handler: PerpsController['subscribeToConnectionState']; +}; + +/** + * Manually trigger a WebSocket reconnection attempt. + * Used by the UI retry button when connection is lost. + */ +export type PerpsControllerReconnectAction = { + type: `PerpsController:reconnect`; + handler: PerpsController['reconnect']; +}; + +/** + * Subscribe to live price updates + * + * @param params - The operation parameters. + * @returns A cleanup function to remove the subscription. + */ +export type PerpsControllerSubscribeToPricesAction = { + type: `PerpsController:subscribeToPrices`; + handler: PerpsController['subscribeToPrices']; +}; + +/** + * Subscribe to live position updates + * + * @param params - The operation parameters. + * @returns A cleanup function to remove the subscription. + */ +export type PerpsControllerSubscribeToPositionsAction = { + type: `PerpsController:subscribeToPositions`; + handler: PerpsController['subscribeToPositions']; +}; + +/** + * Subscribe to live order fill updates + * + * @param params - The operation parameters. + * @returns A cleanup function to remove the subscription. + */ +export type PerpsControllerSubscribeToOrderFillsAction = { + type: `PerpsController:subscribeToOrderFills`; + handler: PerpsController['subscribeToOrderFills']; +}; + +/** + * Subscribe to live order updates + * + * @param params - The operation parameters. + * @returns A cleanup function to remove the subscription. + */ +export type PerpsControllerSubscribeToOrdersAction = { + type: `PerpsController:subscribeToOrders`; + handler: PerpsController['subscribeToOrders']; +}; + +/** + * Subscribe to live account updates. + * Updates controller state (Redux) when new account data arrives so consumers + * like usePerpsBalanceTokenFilter (PayWithModal) see the latest balance. + * + * @param params - The operation parameters. + * @returns A cleanup function to remove the subscription. + */ +export type PerpsControllerSubscribeToAccountAction = { + type: `PerpsController:subscribeToAccount`; + handler: PerpsController['subscribeToAccount']; +}; + +/** + * Subscribe to full order book updates with multiple depth levels + * Creates a dedicated L2Book subscription for real-time order book data + * + * @param params - The operation parameters. + * @returns A cleanup function to remove the subscription. + */ +export type PerpsControllerSubscribeToOrderBookAction = { + type: `PerpsController:subscribeToOrderBook`; + handler: PerpsController['subscribeToOrderBook']; +}; + +/** + * Subscribe to live candle updates + * + * @param params - The operation parameters. + * @returns A cleanup function to remove the subscription. + */ +export type PerpsControllerSubscribeToCandlesAction = { + type: `PerpsController:subscribeToCandles`; + handler: PerpsController['subscribeToCandles']; +}; + +/** + * Subscribe to open interest cap updates + * Zero additional network overhead - data comes from existing webData3 subscription + * + * @param params - The operation parameters. + * @returns A cleanup function to remove the subscription. + */ +export type PerpsControllerSubscribeToOICapsAction = { + type: `PerpsController:subscribeToOICaps`; + handler: PerpsController['subscribeToOICaps']; +}; + +/** + * Configure live data throttling + * + * @param config - The configuration object. + */ +export type PerpsControllerSetLiveDataConfigAction = { + type: `PerpsController:setLiveDataConfig`; + handler: PerpsController['setLiveDataConfig']; +}; + +/** + * Calculate trading fees through the active provider route. + * Each provider owns its fee policy. An explicit provider route overrides + * the active/default provider used by placement. + * + * @param params - The operation parameters. + * @returns The fee calculation result for the trade. + */ +export type PerpsControllerCalculateFeesAction = { + type: `PerpsController:calculateFees`; + handler: PerpsController['calculateFees']; +}; + +/** + * Approve the dedicated subscription builder outside order submission. + * Until this succeeds, subscription waivers fall back to the ordinary + * builder at the standard fee. + * + * @returns Whether the subscription builder is approved. + */ +export type PerpsControllerApproveSubscriptionBuilderFeeAction = { + type: `PerpsController:approveSubscriptionBuilderFee`; + handler: PerpsController['approveSubscriptionBuilderFee']; +}; + +/** + * Drop the cached subscription benefits snapshot. + * + * Call this when the identity behind the benefits changes — sign-out, or a + * profile switch. The snapshot carries no profile identity of its own, so + * without this it keeps answering for the previous profile until the next + * successful refresh. The next fee resolution reports the waiver as + * unavailable, so it is withheld until preview or lifecycle hydration. + */ +export type PerpsControllerInvalidateSubscriptionBenefitsAction = { + type: `PerpsController:invalidateSubscriptionBenefits`; + handler: PerpsController['invalidateSubscriptionBenefits']; +}; + +/** + * Disconnect provider and cleanup subscriptions + * Call this when navigating away from Perps screens to prevent battery drain + */ +export type PerpsControllerDisconnectAction = { + type: `PerpsController:disconnect`; + handler: PerpsController['disconnect']; +}; + +/** + * Resume eligibility monitoring after onboarding completes. + * Clears the deferred flag and triggers an immediate eligibility check + * using the current remote feature flag state. + */ +export type PerpsControllerStartEligibilityMonitoringAction = { + type: `PerpsController:startEligibilityMonitoring`; + handler: PerpsController['startEligibilityMonitoring']; +}; + +/** + * Stops geo-blocking eligibility monitoring. + * Call this when the user disables basic functionality (e.g. useExternalServices becomes false). + * Prevents geolocation calls until startEligibilityMonitoring() is called again. + * Safe to call multiple times. + */ +export type PerpsControllerStopEligibilityMonitoringAction = { + type: `PerpsController:stopEligibilityMonitoring`; + handler: PerpsController['stopEligibilityMonitoring']; +}; + +export type PerpsControllerRefreshEligibilityAction = { + type: `PerpsController:refreshEligibility`; + handler: PerpsController['refreshEligibility']; +}; + +/** + * Get block explorer URL for an address or just the base URL + * + * @param address - Optional address to append to the base URL + * @returns Block explorer URL + */ +export type PerpsControllerGetBlockExplorerUrlAction = { + type: `PerpsController:getBlockExplorerUrl`; + handler: PerpsController['getBlockExplorerUrl']; +}; + +/** + * Check if user is first-time for the current network + * + * @returns True if the condition is met. + */ +export type PerpsControllerIsFirstTimeUserOnCurrentNetworkAction = { + type: `PerpsController:isFirstTimeUserOnCurrentNetwork`; + handler: PerpsController['isFirstTimeUserOnCurrentNetwork']; +}; + +/** + * Mark that the user has completed the tutorial/onboarding + * This prevents the tutorial from showing again + */ +export type PerpsControllerMarkTutorialCompletedAction = { + type: `PerpsController:markTutorialCompleted`; + handler: PerpsController['markTutorialCompleted']; +}; + +export type PerpsControllerMarkFirstOrderCompletedAction = { + type: `PerpsController:markFirstOrderCompleted`; + handler: PerpsController['markFirstOrderCompleted']; +}; + +/** + * Reset first-time user state for both networks + * This is useful for testing the tutorial flow + * Called by Reset Account feature in settings + */ +export type PerpsControllerResetFirstTimeUserStateAction = { + type: `PerpsController:resetFirstTimeUserState`; + handler: PerpsController['resetFirstTimeUserState']; +}; + +/** + * Clear pending/bridging withdrawal and deposit requests + * This is useful when users want to clear stuck pending indicators + * Called by Reset Account feature in settings + */ +export type PerpsControllerClearPendingTransactionRequestsAction = { + type: `PerpsController:clearPendingTransactionRequests`; + handler: PerpsController['clearPendingTransactionRequests']; +}; + +/** + * Get saved trade configuration for a market + * + * @param symbol - The trading pair symbol. + * @returns The resulting string value. + */ +export type PerpsControllerGetTradeConfigurationAction = { + type: `PerpsController:getTradeConfiguration`; + handler: PerpsController['getTradeConfiguration']; +}; + +/** + * Save trade configuration for a market + * + * @param symbol - Market symbol + * @param leverage - Leverage value + */ +export type PerpsControllerSaveTradeConfigurationAction = { + type: `PerpsController:saveTradeConfiguration`; + handler: PerpsController['saveTradeConfiguration']; +}; + +/** + * Save pending trade configuration for a market + * This is a temporary configuration that expires after 30 seconds. + * + * @param symbol - Market symbol + * @param config - Pending trade configuration (includes optional selected payment token from Pay row) + * @param config.amount - The amount value. + * @param config.leverage - The leverage multiplier. + * @param config.takeProfitPrice - The take profit price. + * @param config.stopLossPrice - The stop loss price. + * @param config.limitPrice - The limit price. + * @param config.orderType - The order type. + * @param config.reduceOnly - Whether the order may only reduce a position. + * @param config.direction - Long or short. + * @param config.selectedPaymentToken - The selected payment token. + */ +export type PerpsControllerSavePendingTradeConfigurationAction = { + type: `PerpsController:savePendingTradeConfiguration`; + handler: PerpsController['savePendingTradeConfiguration']; +}; + +/** + * Get pending trade configuration for a market + * Returns undefined if config doesn't exist or has expired. + * + * @param symbol - Market symbol + * @returns Pending trade configuration or undefined + */ +export type PerpsControllerGetPendingTradeConfigurationAction = { + type: `PerpsController:getPendingTradeConfiguration`; + handler: PerpsController['getPendingTradeConfiguration']; +}; + +/** + * Clear pending trade configuration for a market + * + * @param symbol - Market symbol + */ +export type PerpsControllerClearPendingTradeConfigurationAction = { + type: `PerpsController:clearPendingTradeConfiguration`; + handler: PerpsController['clearPendingTradeConfiguration']; +}; + +/** + * Get saved market filter preferences + * Handles backward compatibility with legacy string format + * + * @returns The saved sort option ID and direction. + */ +export type PerpsControllerGetMarketFilterPreferencesAction = { + type: `PerpsController:getMarketFilterPreferences`; + handler: PerpsController['getMarketFilterPreferences']; +}; + +/** + * Save market filter preferences + * + * @param optionId - Sort/filter option ID + * @param direction - Sort direction ('asc' or 'desc') + */ +export type PerpsControllerSaveMarketFilterPreferencesAction = { + type: `PerpsController:saveMarketFilterPreferences`; + handler: PerpsController['saveMarketFilterPreferences']; +}; + +/** + * Get the user's max slippage tolerance in basis points. + * + * @returns The configured max slippage bps, or undefined if never set (callers should default to 300 bps / 3%). + */ +export type PerpsControllerGetMaxSlippageAction = { + type: `PerpsController:getMaxSlippage`; + handler: PerpsController['getMaxSlippage']; +}; + +/** + * Set the user's max slippage tolerance in basis points. + * + * @param bps - Max slippage in basis points (e.g. 300 = 3%). Clamped to 10–1000, snapped to step of 10. + */ +export type PerpsControllerSetMaxSlippageAction = { + type: `PerpsController:setMaxSlippage`; + handler: PerpsController['setMaxSlippage']; +}; + +/** + * Get market-agnostic Pro order-book display preferences. + * + * @returns The current order-book display preferences. + */ +export type PerpsControllerGetOrderBookPreferencesAction = { + type: `PerpsController:getOrderBookPreferences`; + handler: PerpsController['getOrderBookPreferences']; +}; + +/** + * Update market-agnostic Pro order-book display preferences. + * + * @param patch - Partial order-book preferences to update. + */ +export type PerpsControllerSetOrderBookPreferencesAction = { + type: `PerpsController:setOrderBookPreferences`; + handler: PerpsController['setOrderBookPreferences']; +}; + +/** + * Get the selected order type shared by every market. + * + * @returns The selected order type. + */ +export type PerpsControllerGetSelectedOrderTypeAction = { + type: `PerpsController:getSelectedOrderType`; + handler: PerpsController['getSelectedOrderType']; +}; + +/** + * Set the selected order type shared by every market. + * + * @param orderType - The selected order type. + */ +export type PerpsControllerSetSelectedOrderTypeAction = { + type: `PerpsController:setSelectedOrderType`; + handler: PerpsController['setSelectedOrderType']; +}; + +/** + * Get the number of candles shown in Lite and Pro chart viewports. + * + * @returns The visible candle count. + */ +export type PerpsControllerGetVisibleCandleCountAction = { + type: `PerpsController:getVisibleCandleCount`; + handler: PerpsController['getVisibleCandleCount']; +}; + +/** + * Set the number of candles shown in Lite and Pro chart viewports. + * + * @param count - Requested visible candle count. + */ +export type PerpsControllerSetVisibleCandleCountAction = { + type: `PerpsController:setVisibleCandleCount`; + handler: PerpsController['setVisibleCandleCount']; +}; + +/** + * Get the user's pro-mode layout preferences (network-independent). + * + * @returns The current pro-mode layout preferences. + */ +export type PerpsControllerGetProLayoutPreferencesAction = { + type: `PerpsController:getProLayoutPreferences`; + handler: PerpsController['getProLayoutPreferences']; +}; + +/** + * Update the user's pro-mode layout preferences. + * + * Patch-style setter: only the provided fields are updated, the rest are + * preserved. This keeps the signature stable as new layout fields are added. + * + * @param patch - Partial set of pro-mode layout preferences to update. + */ +export type PerpsControllerSetProLayoutPreferencesAction = { + type: `PerpsController:setProLayoutPreferences`; + handler: PerpsController['setProLayoutPreferences']; +}; + +/** + * Set the Perps interface mode (lite/pro). + * + * @param mode - The mode to switch to. + */ +export type PerpsControllerSetPerpsModeAction = { + type: `PerpsController:setPerpsMode`; + handler: PerpsController['setPerpsMode']; +}; + +/** + * Set the selected payment token for the Perps order/deposit flow. + * Pass null or a token with description PERPS_CONSTANTS.PerpsBalanceTokenDescription to select Perps balance. + * Only required fields (address, chainId) are stored in state; description and symbol are optional. + * + * @param token - The token identifier. + */ +export type PerpsControllerSetSelectedPaymentTokenAction = { + type: `PerpsController:setSelectedPaymentToken`; + handler: PerpsController['setSelectedPaymentToken']; +}; + +/** + * Reset the selected payment token to Perps balance (null). + * Call when leaving the Perps order view so the next visit defaults to Perps balance. + */ +export type PerpsControllerResetSelectedPaymentTokenAction = { + type: `PerpsController:resetSelectedPaymentToken`; + handler: PerpsController['resetSelectedPaymentToken']; +}; + +/** + * Get saved order book grouping for a market + * + * @param symbol - Market symbol + * @returns The saved grouping value or undefined if not set + */ +export type PerpsControllerGetOrderBookGroupingAction = { + type: `PerpsController:getOrderBookGrouping`; + handler: PerpsController['getOrderBookGrouping']; +}; + +/** + * Save order book grouping for a market + * + * @param symbol - Market symbol + * @param grouping - Price grouping value + */ +export type PerpsControllerSaveOrderBookGroupingAction = { + type: `PerpsController:saveOrderBookGrouping`; + handler: PerpsController['saveOrderBookGrouping']; +}; + +/** + * Toggle watchlist status for a market. + * + * Updates local state immediately (optimistic UI) and then syncs the new + * watchlist to AuthenticatedUserStorageService. If the remote write fails, + * the local state is reverted so it stays consistent with AUS. + * + * When the user is unauthenticated, or the active provider is not yet + * supported by the AUS schema, the controller continues operating with + * local-persisted state only — no error is surfaced to the caller. + * + * Watchlist markets are stored per network (testnet/mainnet). + * + * @param symbol - The trading pair symbol. + */ +export type PerpsControllerToggleWatchlistMarketAction = { + type: `PerpsController:toggleWatchlistMarket`; + handler: PerpsController['toggleWatchlistMarket']; +}; + +/** + * Check if a market is in the watchlist on the current network + * + * @param symbol - The trading pair symbol. + * @returns True if the condition is met. + */ +export type PerpsControllerIsWatchlistMarketAction = { + type: `PerpsController:isWatchlistMarket`; + handler: PerpsController['isWatchlistMarket']; +}; + +/** + * Get all watchlist markets for the current network + * + * @returns The resulting string value. + */ +export type PerpsControllerGetWatchlistMarketsAction = { + type: `PerpsController:getWatchlistMarkets`; + handler: PerpsController['getWatchlistMarkets']; +}; + +/** + * Record that the user viewed a market. + * + * The symbol is prepended to the per-network recently-viewed list (newest-first). + * Any existing entry for the same symbol is removed first so there are no + * duplicates. The list is then capped at PERPS_CONSTANTS.RecentlyViewedMarketsLimit. + * + * @param symbol - The trading pair symbol (e.g. 'BTC', 'ETH', 'xyz:TSLA'). + */ +export type PerpsControllerRecordMarketViewedAction = { + type: `PerpsController:recordMarketViewed`; + handler: PerpsController['recordMarketViewed']; +}; + +/** + * Get recently viewed markets for the current network. + * + * Returns up to PERPS_CONSTANTS.RecentlyViewedMarketsLimit symbols, ordered + * newest-first, filtered to entries within the last + * PERPS_CONSTANTS.RecentlyViewedMarketsTtlMs (24 hours). Returns an empty + * array when no qualifying entries exist. + * + * @returns Ordered array of market symbols. + */ +export type PerpsControllerGetRecentlyViewedMarketsAction = { + type: `PerpsController:getRecentlyViewedMarkets`; + handler: PerpsController['getRecentlyViewedMarkets']; +}; + +/** + * Check if the controller is currently reinitializing + * + * @returns true if providers are being reinitialized + */ +export type PerpsControllerIsCurrentlyReinitializingAction = { + type: `PerpsController:isCurrentlyReinitializing`; + handler: PerpsController['isCurrentlyReinitializing']; +}; + +/** + * Union of all PerpsController action types. + */ +export type PerpsControllerMethodActions = + | PerpsControllerGetCachedMarketDataForActiveProviderAction + | PerpsControllerGetCachedUserDataForActiveProviderAction + | PerpsControllerGetUserDataSnapshotAction + | PerpsControllerInitAction + | PerpsControllerGetActiveProviderAction + | PerpsControllerGetActiveProviderOrNullAction + | PerpsControllerGetOrderCapabilitiesAction + | PerpsControllerPlaceOrderAction + | PerpsControllerEditOrderAction + | PerpsControllerCancelOrderAction + | PerpsControllerGetTwapOrdersAction + | PerpsControllerGetChaseOrdersAction + | PerpsControllerSuspendChaseOrdersAction + | PerpsControllerCancelOrdersAction + | PerpsControllerClosePositionAction + | PerpsControllerClosePositionsAction + | PerpsControllerUpdatePositionTPSLAction + | PerpsControllerUpdateMarginAction + | PerpsControllerFlipPositionAction + | PerpsControllerDepositWithConfirmationAction + | PerpsControllerDepositWithOrderAction + | PerpsControllerClearDepositResultAction + | PerpsControllerClearWithdrawResultAction + | PerpsControllerUpdateWithdrawalStatusAction + | PerpsControllerCompleteWithdrawalFromHistoryAction + | PerpsControllerUpdateWithdrawalProgressAction + | PerpsControllerGetWithdrawalProgressAction + | PerpsControllerWithdrawAction + | PerpsControllerGetPositionsAction + | PerpsControllerGetOrderFillsAction + | PerpsControllerGetOrdersAction + | PerpsControllerGetOpenOrdersAction + | PerpsControllerGetFundingAction + | PerpsControllerGetAccountStateAction + | PerpsControllerGetHistoricalPortfolioAction + | PerpsControllerGetMarketsAction + | PerpsControllerGetMarketDataWithPricesAction + | PerpsControllerStartMarketDataPreloadAction + | PerpsControllerStopMarketDataPreloadAction + | PerpsControllerGetAvailableDexsAction + | PerpsControllerFetchHistoricalCandlesAction + | PerpsControllerCalculateLiquidationPriceAction + | PerpsControllerPreviewPositionModifyAction + | PerpsControllerCalculateMaintenanceMarginAction + | PerpsControllerGetMaxLeverageAction + | PerpsControllerValidateOrderAction + | PerpsControllerValidateClosePositionAction + | PerpsControllerValidateWithdrawalAction + | PerpsControllerGetWithdrawalRoutesAction + | PerpsControllerSetAttributionContextAction + | PerpsControllerGetAttributionContextAction + | PerpsControllerClearAttributionContextAction + | PerpsControllerToggleTestnetAction + | PerpsControllerSwitchProviderAction + | PerpsControllerGetCurrentNetworkAction + | PerpsControllerGetMarketCategoriesAction + | PerpsControllerGetWebSocketConnectionStateAction + | PerpsControllerSubscribeToConnectionStateAction + | PerpsControllerReconnectAction + | PerpsControllerSubscribeToPricesAction + | PerpsControllerSubscribeToPositionsAction + | PerpsControllerSubscribeToOrderFillsAction + | PerpsControllerSubscribeToOrdersAction + | PerpsControllerSubscribeToAccountAction + | PerpsControllerSubscribeToOrderBookAction + | PerpsControllerSubscribeToCandlesAction + | PerpsControllerSubscribeToOICapsAction + | PerpsControllerSetLiveDataConfigAction + | PerpsControllerCalculateFeesAction + | PerpsControllerApproveSubscriptionBuilderFeeAction + | PerpsControllerInvalidateSubscriptionBenefitsAction + | PerpsControllerDisconnectAction + | PerpsControllerStartEligibilityMonitoringAction + | PerpsControllerStopEligibilityMonitoringAction + | PerpsControllerRefreshEligibilityAction + | PerpsControllerGetBlockExplorerUrlAction + | PerpsControllerIsFirstTimeUserOnCurrentNetworkAction + | PerpsControllerMarkTutorialCompletedAction + | PerpsControllerMarkFirstOrderCompletedAction + | PerpsControllerResetFirstTimeUserStateAction + | PerpsControllerClearPendingTransactionRequestsAction + | PerpsControllerGetTradeConfigurationAction + | PerpsControllerSaveTradeConfigurationAction + | PerpsControllerSavePendingTradeConfigurationAction + | PerpsControllerGetPendingTradeConfigurationAction + | PerpsControllerClearPendingTradeConfigurationAction + | PerpsControllerGetMarketFilterPreferencesAction + | PerpsControllerSaveMarketFilterPreferencesAction + | PerpsControllerGetMaxSlippageAction + | PerpsControllerSetMaxSlippageAction + | PerpsControllerGetOrderBookPreferencesAction + | PerpsControllerSetOrderBookPreferencesAction + | PerpsControllerGetSelectedOrderTypeAction + | PerpsControllerSetSelectedOrderTypeAction + | PerpsControllerGetVisibleCandleCountAction + | PerpsControllerSetVisibleCandleCountAction + | PerpsControllerGetProLayoutPreferencesAction + | PerpsControllerSetProLayoutPreferencesAction + | PerpsControllerSetPerpsModeAction + | PerpsControllerSetSelectedPaymentTokenAction + | PerpsControllerResetSelectedPaymentTokenAction + | PerpsControllerGetOrderBookGroupingAction + | PerpsControllerSaveOrderBookGroupingAction + | PerpsControllerToggleWatchlistMarketAction + | PerpsControllerIsWatchlistMarketAction + | PerpsControllerGetWatchlistMarketsAction + | PerpsControllerRecordMarketViewedAction + | PerpsControllerGetRecentlyViewedMarketsAction + | PerpsControllerIsCurrentlyReinitializingAction; diff --git a/packages/perps-controller/src/PerpsController.ts b/packages/perps-controller/src/PerpsController.ts new file mode 100644 index 00000000000..131da2bf6f0 --- /dev/null +++ b/packages/perps-controller/src/PerpsController.ts @@ -0,0 +1,6895 @@ +import type { + NotificationPreferences, + PerpsWatchlistMarkets, +} from '@metamask/authenticated-user-storage'; +import { + BaseController, + ControllerGetStateAction, + ControllerStateChangedEvent, + ControllerStateChangeEvent, + StateMetadata, +} from '@metamask/base-controller'; +import type { StateChangeListener } from '@metamask/base-controller'; +import { ORIGIN_METAMASK } from '@metamask/controller-utils'; +import type { Messenger } from '@metamask/messenger'; +import type { Json } from '@metamask/utils'; +import { v4 as uuidv4 } from 'uuid'; + +import { + CandlePeriod, + VISIBLE_CANDLE_COUNT_CONFIG, +} from './constants/chartConfig.js'; +import { + PERPS_EVENT_PROPERTY, + PERPS_EVENT_VALUE, +} from './constants/eventNames.js'; +import { + canonicalizeHyperLiquidDexes, + MAINNET_HIP3_CONFIG, + TESTNET_HIP3_CONFIG, + USDC_SYMBOL, +} from './constants/hyperLiquidConfig.js'; +import { PerpsMeasurementName } from './constants/performanceMetrics.js'; +import type { + SortOptionId, + OrderBookPreferences, + ProLayoutPreferences, + PerpsMode, +} from './constants/perpsConfig.js'; +import { + PERPS_CONSTANTS, + MARKET_SORTING_CONFIG, + PROVIDER_CONFIG, + buildProviderCacheKey, + MAX_SLIPPAGE_BOUNDS, + DEFAULT_PERPS_MODE, + DEFAULT_ORDER_BOOK_PREFERENCES, + DEFAULT_PRO_LAYOUT_PREFERENCES, + DEFAULT_SELECTED_ORDER_TYPE, +} from './constants/perpsConfig.js'; +import type { PerpsControllerMethodActions } from './PerpsController-method-action-types.js'; +import { PERPS_ERROR_CODES } from './perpsErrorCodes.js'; +import { AggregatedPerpsProvider } from './providers/AggregatedPerpsProvider.js'; +import { HyperLiquidProvider } from './providers/HyperLiquidProvider.js'; +import { AccountService } from './services/AccountService.js'; +import { DataLakeService } from './services/DataLakeService.js'; +import { DepositService } from './services/DepositService.js'; +import { EligibilityService } from './services/EligibilityService.js'; +import { FeatureFlagConfigurationService } from './services/FeatureFlagConfigurationService.js'; +import { MarketDataService } from './services/MarketDataService.js'; +import { RewardsIntegrationService } from './services/RewardsIntegrationService.js'; +import type { ServiceContext } from './services/ServiceContext.js'; +import { TerminalMarketService } from './services/TerminalMarketService.js'; +import { TradingService } from './services/TradingService.js'; +// PerpsStreamChannelKey removed: using string for channel keys (PerpsStreamManager.pauseChannel takes string) +import { + WebSocketConnectionState, + PerpsAnalyticsEvent, + PerpsTraceNames, + PerpsTraceOperations, + isVersionGatedFeatureFlag, + MARKET_CATEGORIES, + // Platform dependencies interface for core migration (bundles all platform-specific deps) +} from './types/index.js'; +import type { + AccountState, + AssetRoute, + CancelOrderParams, + CancelOrderResult, + CancelOrdersParams, + CancelOrdersResult, + ChaseOrder, + ChaseOrderMaxDistanceReached, + ClosePositionParams, + ClosePositionsParams, + ClosePositionsResult, + DepositWithConfirmationParams, + EditOrderParams, + FeeCalculationParams, + FeeCalculationResult, + FlipPositionParams, + Funding, + GetAccountStateParams, + GetAvailableDexsParams, + GetFundingParams, + GetMarketDataWithPricesParams, + GetMarketsParams, + GetOrderCapabilitiesParams, + GetOrderFillsParams, + GetOrdersParams, + GetPositionsParams, + PerpsProvider, + LiquidationPriceParams, + LiveDataConfig, + MaintenanceMarginParams, + PositionModifyPreviewParams, + PositionModifyPreviewResult, + MarginResult, + MarketInfo, + Order, + OrderCapabilitiesUnavailableReason, + OrderDirection, + OrderFill, + OrderParams, + OrderResult, + PerpsControllerConfig, + PerpsMarketData, + PerpsOrderCapabilities, + Position, + SubscribeAccountParams, + SubscribeCandlesParams, + SubscribeOICapsParams, + SubscribeOrderBookParams, + SubscribeOrderFillsParams, + SubscribeOrdersParams, + SubscribePositionsParams, + SubscribePricesParams, + SwitchProviderResult, + ToggleTestnetResult, + TwapOrder, + UpdateMarginParams, + UpdatePositionTPSLParams, + WithdrawParams, + WithdrawResult, + GetHistoricalPortfolioParams, + HistoricalPortfolioResult, + OrderType, + PerpsPlatformDependencies, + PerpsLogger, + PerpsActiveProviderMode, + PerpsAnalyticsProperties, + PerpsAttributionContext, + PerpsProviderType, + PerpsUserDataSnapshot, + PerpsSelectedPaymentToken, + PerpsRemoteFeatureFlagState, + PerpsTransactionParams, + PerpsAddTransactionOptions, + MarketTypeFilter, + MYXCredentials, +} from './types/index.js'; +import type { SortDirection } from './types/index.js'; +import type { + PerpsControllerAllowedActions, + PerpsControllerAllowedEvents, +} from './types/messenger.js'; +import type { CandleData } from './types/perps-types.js'; +import { + LastTransactionResult, + TransactionStatus, +} from './types/transactionTypes.js'; +import { getSelectedEvmAccountFromMessenger } from './utils/accountUtils.js'; +import { ensureError } from './utils/errorUtils.js'; +import { parseAssetName } from './utils/hyperLiquidAdapter.js'; +import { + clonePerpsMarketData, + compileMarketPattern, + shouldIncludeMarket, +} from './utils/marketUtils.js'; +import type { CompiledMarketPattern } from './utils/marketUtils.js'; +import { isStrategyOrderType } from './utils/orderTypes.js'; +import { + hydrateFromDiskSync, + persistMarketEntriesToDisk, + persistUserEntriesToDisk, +} from './utils/perpsDiskPersistence.js'; +import type { DiskCacheUserEntry } from './utils/perpsDiskPersistence.js'; +import { wait } from './utils/wait.js'; + +/** Derived type for logger options from PerpsLogger interface */ +type PerpsLoggerOptions = Parameters[1]; + +function cloneUserDataSnapshot( + snapshot: PerpsUserDataSnapshot, +): PerpsUserDataSnapshot { + return { + positions: snapshot.positions.map((position) => ({ + ...position, + leverage: { ...position.leverage }, + cumulativeFunding: { ...position.cumulativeFunding }, + ...(position.takeProfitOrders && { + takeProfitOrders: position.takeProfitOrders.map((order) => ({ + ...order, + })), + }), + ...(position.stopLossOrders && { + stopLossOrders: position.stopLossOrders.map((order) => ({ + ...order, + })), + }), + })), + orders: snapshot.orders.map((order) => ({ ...order })), + accountState: { + ...snapshot.accountState, + ...(snapshot.accountState.subAccountBreakdown && { + subAccountBreakdown: Object.fromEntries( + Object.entries(snapshot.accountState.subAccountBreakdown).map( + ([dex, balances]) => [dex, { ...balances }], + ), + ), + }), + }, + identity: { + ...snapshot.identity, + dexes: [...snapshot.identity.dexes], + }, + }; +} + +/** + * Returns the first non-empty string from the given values. + * Env vars default to '' (not null/undefined), so ?? wouldn't fall through. + * + * @param vals - String values to check in order. + * @returns The first non-empty string, or '' if all are empty/undefined. + */ +export function firstNonEmpty(...vals: (string | undefined)[]): string { + return ( + vals.find((val) => val !== null && val !== undefined && val !== '') ?? '' + ); +} + +/** + * Maps an active provider mode to the corresponding exchange key used in the + * AUS {@link PerpsWatchlistMarkets} schema. + * + * Returns `null` for modes that are not yet represented in the AUS schema + * (e.g. `'aggregated'`), which signals callers to skip remote sync and fall + * back to local state only. Add new entries here as additional DEX providers + * gain AUS watchlist support. + * + * @param activeProvider - The current active provider mode from controller state. + * @returns The matching `PerpsWatchlistMarkets` key, or `null` if unsupported. + */ +export function resolveWatchlistExchangeKey( + activeProvider: PerpsActiveProviderMode, +): keyof PerpsWatchlistMarkets | null { + const map: Partial< + Record + > = { + hyperliquid: 'hyperliquid', + myx: 'myx', + }; + return map[activeProvider] ?? null; +} + +/** + * Resolves MYX auth config from provider credentials, handling + * testnet/mainnet fallback logic. + * + * @param myx - MYX provider credentials. + * @param isTestnet - Whether the controller is in testnet mode. + * @returns Resolved appId, apiSecret, and brokerAddress. + */ +export function resolveMyxAuthConfig( + myx: MYXCredentials, + isTestnet: boolean, +): { appId: string; apiSecret: string; brokerAddress: string } { + return { + appId: isTestnet + ? (myx.appIdTestnet ?? '') + : firstNonEmpty(myx.appIdMainnet, myx.appIdTestnet), + apiSecret: isTestnet + ? (myx.apiSecretTestnet ?? '') + : firstNonEmpty(myx.apiSecretMainnet, myx.apiSecretTestnet), + brokerAddress: isTestnet + ? (myx.brokerAddressTestnet ?? '') + : firstNonEmpty(myx.brokerAddressMainnet, myx.brokerAddressTestnet), + }; +} + +// PaymentToken: minimal interface for deposit flow (replaces mobile-only AssetType) + +/** + * Minimal payment token stored in PerpsController state. + * Only required fields for identification, Perps balance detection, and analytics. + */ +export type SelectedPaymentTokenSnapshot = { + description?: string; + address: string; + chainId: string; + symbol?: string; +}; + +// Re-export error codes from separate file to avoid circular dependencies +export { PERPS_ERROR_CODES, type PerpsErrorCode } from './perpsErrorCodes.js'; + +/** + * Initialization state enum for state machine tracking + */ +export enum InitializationState { + Uninitialized = 'uninitialized', + Initializing = 'initializing', + Initialized = 'initialized', + Failed = 'failed', +} + +// Re-exported so consumers can keep importing these from the controller entry +// point; the canonical definitions live in the dependency-free constants module. +export { + PerpsMode, + DEFAULT_ORDER_BOOK_PREFERENCES, + DEFAULT_PERPS_MODE, + DEFAULT_PRO_LAYOUT_PREFERENCES, + DEFAULT_SELECTED_ORDER_TYPE, +} from './constants/perpsConfig.js'; +export type { + OrderBookListCurrency, + OrderBookListMetric, + OrderBookPreferences, + ProLayoutPreferences, + ProOrdersSideFilter, + ProOrdersSortDirection, + ProOrdersSortField, + ProPositionsSideFilter, + ProPositionsSortDirection, + ProPositionsSortField, +} from './constants/perpsConfig.js'; + +/** + * State shape for PerpsController + */ +export type PerpsControllerState = { + // Active provider + activeProvider: PerpsActiveProviderMode; + isTestnet: boolean; // Dev toggle for testnet + + // Initialization state machine + initializationState: InitializationState; + initializationError: string | null; + initializationAttempts: number; + + // Account data (persisted) - using HyperLiquid property names + accountState: AccountState | null; + + // Perps balances per provider for portfolio display (historical data) + perpsBalances: { + [provider: string]: { + totalBalance: string; // Current total account value (cash + positions) in USD + unrealizedPnl: string; // Current P&L from open positions in USD + accountValue1dAgo: string; // Account value 24h ago for daily change calculation in USD + lastUpdated: number; // Timestamp of last update + }; + }; + + // Simple deposit state (transient, for UI feedback) + depositInProgress: boolean; + // Internal transaction id for the deposit transaction + // We use this to fetch the bridge quotes and get the estimated time. + lastDepositTransactionId: string | null; + lastDepositResult: LastTransactionResult | null; + + // Simple withdrawal state (transient, for UI feedback) + // Note: withdrawInProgress is now derived from withdrawalRequests having pending/bridging entries + withdrawInProgress: boolean; + lastWithdrawResult: LastTransactionResult | null; + + // FIFO guard for withdrawal completion matching. + // Timestamp is persisted — survives app restarts so the hook skips + // already-processed history entries even after relaunch. + // TxHashes array is NOT persisted — it tracks completions within a + // single session to prevent re-matching (direct completions, + // same-millisecond API completions). Resets naturally on app restart; + // the timestamp guard provides cross-restart protection. + lastCompletedWithdrawalTimestamp: number | null; + lastCompletedWithdrawalTxHashes: string[]; + + // Withdrawal request tracking (persistent, for transaction history) + withdrawalRequests: { + id: string; + amount: string; + asset: string; + accountAddress: string; // Account that initiated this withdrawal + txHash?: string; + timestamp: number; + success: boolean; + status: TransactionStatus; + destination?: string; + source?: string; + transactionId?: string; + withdrawalId?: string; + depositId?: string; + }[]; + + // Withdrawal progress tracking (persistent across navigation) + withdrawalProgress: { + progress: number; // 0-100 + lastUpdated: number; // timestamp + activeWithdrawalId: string | null; // ID of the withdrawal being tracked + }; + + // Deposit request tracking (persistent, for transaction history) + depositRequests: { + id: string; + amount: string; + asset: string; + accountAddress: string; // Account that initiated this deposit + txHash?: string; + timestamp: number; + success: boolean; + status: TransactionStatus; + destination?: string; + source?: string; + transactionId?: string; + withdrawalId?: string; + depositId?: string; + }[]; + + // Eligibility (Geo-Blocking) + isEligible: boolean; + + // Tutorial/First time user tracking (per network) + isFirstTimeUser: { + testnet: boolean; + mainnet: boolean; + }; + + // Notification tracking + hasPlacedFirstOrder: { + testnet: boolean; + mainnet: boolean; + }; + + // Watchlist markets tracking (per network) + watchlistMarkets: { + testnet: string[]; // Array of watchlist market symbols for testnet + mainnet: string[]; // Array of watchlist market symbols for mainnet + }; + + // Recently viewed markets tracking (per network, persisted) + // Entries are ordered newest-first. TTL filtering and the 10-item cap + // are applied on read in getRecentlyViewedMarkets / selectRecentlyViewedMarkets. + recentlyViewedMarkets: { + testnet: { symbol: string; viewedAt: number }[]; + mainnet: { symbol: string; viewedAt: number }[]; + }; + + // Trade configurations per market (per network) + tradeConfigurations: { + testnet: { + [marketSymbol: string]: { + leverage?: number; // Last used leverage for this market + orderBookGrouping?: number; // Persisted price grouping for order book + // Pending trade configuration (temporary, expires after 30 seconds) + pendingConfig?: { + amount?: string; // Order size in USD + leverage?: number; // Leverage + takeProfitPrice?: string; // Take profit price + stopLossPrice?: string; // Stop loss price + limitPrice?: string; // Limit price (for limit orders) + orderType?: OrderType; // Market vs limit + reduceOnly?: boolean; // Whether the order may only reduce a position + direction?: OrderDirection; // Long vs short + timestamp: number; // When the config was saved (for expiration check) + }; + }; + }; + mainnet: { + [marketSymbol: string]: { + leverage?: number; + orderBookGrouping?: number; // Persisted price grouping for order book + // Pending trade configuration (temporary, expires after 30 seconds) + pendingConfig?: { + amount?: string; // Order size in USD + leverage?: number; // Leverage + takeProfitPrice?: string; // Take profit price + stopLossPrice?: string; // Stop loss price + limitPrice?: string; // Limit price (for limit orders) + orderType?: OrderType; // Market vs limit + reduceOnly?: boolean; // Whether the order may only reduce a position + direction?: OrderDirection; // Long vs short + timestamp: number; // When the config was saved (for expiration check) + }; + }; + }; + }; + + // Max slippage tolerance in basis points (e.g. 300 = 3%). Global user preference. + maxSlippageBps?: number; + + // Market filter preferences (network-independent) - includes both sorting and filtering options + marketFilterPreferences: { + optionId: SortOptionId; + direction: SortDirection; + }; + + // Pro-mode layout preferences (network-independent). Flat object that + // persists across markets (unlike the per-market tradeConfigurations). + proLayoutPreferences: ProLayoutPreferences; + + // Pro order-book display preferences (network-independent). + orderBookPreferences: OrderBookPreferences; + + // Last selected order type, shared across markets and networks. + selectedOrderType: OrderType; + + // Number of candles visible in Lite and Pro chart viewports. + visibleCandleCount: number; + + // Perps interface mode (lite/pro), network-independent global preference. + mode: PerpsMode; + + // Error handling + lastError: string | null; + lastUpdateTimestamp: number; + + // HIP-3 Configuration Version (incremented when HIP-3 remote flags change) + // Used to trigger reconnection and cache invalidation in ConnectionManager + hip3ConfigVersion: number; + + // Selected payment token for Perps order/deposit flow (null = Perps balance). Stored as Json (minimal shape: description, address, chainId). + selectedPaymentToken: Json | null; + + // Cached market data from background preloading (REST snapshots, not WebSocket) + // Keyed by "providerId:network" (e.g. 'hyperliquid:mainnet', 'myx:testnet') + cachedMarketDataByProvider: Record< + string, + { + data: PerpsMarketData[]; + timestamp: number; + sourceExpiresAt?: number; + hip3ConfigVersion?: number; + dexes?: string[]; + } + >; + + // Cached user data from background preloading (REST snapshots, not WebSocket) + // Keyed by "providerId:network". The entry carries the selected address and + // exact HyperLiquid configuration identity, both validated before reads. + cachedUserDataByProvider: Record< + string, + { + positions: Position[]; + orders: Order[]; + accountState: AccountState | null; + timestamp: number; + address: string; + hip3ConfigVersion?: number; + dexes?: string[]; + } + >; +}; + +/** + * Get default PerpsController state + * + * To change the active provider, modify the `activeProvider` value below: + * - 'hyperliquid': HyperLiquid provider (default, production) + * - 'aggregated': Multi-provider aggregation mode + * - 'myx': MYX provider (future implementation) + * + * @returns The default perps controller state. + */ +export const getDefaultPerpsControllerState = (): PerpsControllerState => ({ + activeProvider: 'hyperliquid', + isTestnet: false, // Default to mainnet + initializationState: InitializationState.Uninitialized, + initializationError: null, + initializationAttempts: 0, + accountState: null, + perpsBalances: {}, + depositInProgress: false, + lastDepositResult: null, + withdrawInProgress: false, + lastDepositTransactionId: null, + lastWithdrawResult: null, + lastCompletedWithdrawalTimestamp: null, + lastCompletedWithdrawalTxHashes: [], + withdrawalRequests: [], + withdrawalProgress: { + progress: 0, + lastUpdated: 0, + activeWithdrawalId: null, + }, + depositRequests: [], + lastError: null, + lastUpdateTimestamp: 0, + isEligible: false, + isFirstTimeUser: { + testnet: true, + mainnet: true, + }, + hasPlacedFirstOrder: { + testnet: false, + mainnet: false, + }, + watchlistMarkets: { + testnet: [], + mainnet: [], + }, + recentlyViewedMarkets: { + testnet: [], + mainnet: [], + }, + tradeConfigurations: { + testnet: {}, + mainnet: {}, + }, + marketFilterPreferences: { + optionId: MARKET_SORTING_CONFIG.DefaultSortOptionId, + direction: MARKET_SORTING_CONFIG.DefaultDirection, + }, + proLayoutPreferences: { ...DEFAULT_PRO_LAYOUT_PREFERENCES }, + orderBookPreferences: { ...DEFAULT_ORDER_BOOK_PREFERENCES }, + selectedOrderType: DEFAULT_SELECTED_ORDER_TYPE, + visibleCandleCount: VISIBLE_CANDLE_COUNT_CONFIG.Default, + mode: DEFAULT_PERPS_MODE, + hip3ConfigVersion: 0, + selectedPaymentToken: null, + cachedMarketDataByProvider: {}, + cachedUserDataByProvider: {}, +}); + +/** + * State metadata for the PerpsController + */ +const metadata: StateMetadata = { + accountState: { + includeInStateLogs: true, + persist: true, + includeInDebugSnapshot: false, + usedInUi: true, + }, + perpsBalances: { + includeInStateLogs: true, + persist: true, + includeInDebugSnapshot: false, + usedInUi: true, + }, + isTestnet: { + includeInStateLogs: true, + persist: true, + includeInDebugSnapshot: false, + usedInUi: true, + }, + activeProvider: { + includeInStateLogs: true, + persist: true, + includeInDebugSnapshot: false, + usedInUi: true, + }, + initializationState: { + includeInStateLogs: true, + persist: false, + includeInDebugSnapshot: false, + usedInUi: true, + }, + initializationError: { + includeInStateLogs: true, + persist: false, + includeInDebugSnapshot: false, + usedInUi: true, + }, + initializationAttempts: { + includeInStateLogs: true, + persist: false, + includeInDebugSnapshot: false, + usedInUi: false, + }, + depositInProgress: { + includeInStateLogs: true, + persist: false, + includeInDebugSnapshot: false, + usedInUi: true, + }, + lastDepositTransactionId: { + includeInStateLogs: true, + persist: false, + includeInDebugSnapshot: false, + usedInUi: true, + }, + lastDepositResult: { + includeInStateLogs: true, + persist: false, + includeInDebugSnapshot: false, + usedInUi: true, + }, + withdrawInProgress: { + includeInStateLogs: true, + persist: false, + includeInDebugSnapshot: false, + usedInUi: true, + }, + lastWithdrawResult: { + includeInStateLogs: true, + persist: false, + includeInDebugSnapshot: false, + usedInUi: true, + }, + lastCompletedWithdrawalTimestamp: { + includeInStateLogs: true, + persist: true, + includeInDebugSnapshot: false, + usedInUi: true, + }, + lastCompletedWithdrawalTxHashes: { + includeInStateLogs: false, + persist: false, + includeInDebugSnapshot: false, + usedInUi: true, + }, + withdrawalRequests: { + includeInStateLogs: true, + persist: true, + includeInDebugSnapshot: false, + usedInUi: true, + }, + withdrawalProgress: { + includeInStateLogs: true, + persist: true, + includeInDebugSnapshot: false, + usedInUi: true, + }, + depositRequests: { + includeInStateLogs: true, + persist: true, + includeInDebugSnapshot: false, + usedInUi: true, + }, + lastError: { + includeInStateLogs: false, + persist: false, + includeInDebugSnapshot: false, + usedInUi: false, + }, + lastUpdateTimestamp: { + includeInStateLogs: true, + persist: false, + includeInDebugSnapshot: false, + usedInUi: false, + }, + isEligible: { + includeInStateLogs: true, + persist: false, + includeInDebugSnapshot: false, + usedInUi: true, + }, + isFirstTimeUser: { + includeInStateLogs: true, + persist: true, + includeInDebugSnapshot: false, + usedInUi: true, + }, + hasPlacedFirstOrder: { + includeInStateLogs: true, + persist: true, + includeInDebugSnapshot: false, + usedInUi: true, + }, + watchlistMarkets: { + includeInStateLogs: true, + persist: true, + includeInDebugSnapshot: false, + usedInUi: true, + }, + recentlyViewedMarkets: { + includeInStateLogs: true, + persist: true, + includeInDebugSnapshot: false, + usedInUi: true, + }, + tradeConfigurations: { + includeInStateLogs: true, + persist: true, + includeInDebugSnapshot: false, + usedInUi: true, + }, + maxSlippageBps: { + includeInStateLogs: true, + persist: true, + includeInDebugSnapshot: false, + usedInUi: true, + }, + marketFilterPreferences: { + includeInStateLogs: true, + persist: true, + includeInDebugSnapshot: false, + usedInUi: true, + }, + proLayoutPreferences: { + includeInStateLogs: true, + persist: true, + includeInDebugSnapshot: false, + usedInUi: true, + }, + orderBookPreferences: { + includeInStateLogs: true, + persist: true, + includeInDebugSnapshot: false, + usedInUi: true, + }, + selectedOrderType: { + includeInStateLogs: true, + persist: true, + includeInDebugSnapshot: false, + usedInUi: true, + }, + visibleCandleCount: { + includeInStateLogs: true, + persist: true, + includeInDebugSnapshot: false, + usedInUi: true, + }, + mode: { + includeInStateLogs: true, + persist: true, + includeInDebugSnapshot: false, + usedInUi: true, + }, + hip3ConfigVersion: { + includeInStateLogs: true, + persist: true, + includeInDebugSnapshot: false, + usedInUi: false, + }, + selectedPaymentToken: { + includeInStateLogs: false, + persist: false, + includeInDebugSnapshot: false, + usedInUi: true, + }, + cachedMarketDataByProvider: { + includeInStateLogs: false, + persist: false, + includeInDebugSnapshot: false, + usedInUi: true, + }, + cachedUserDataByProvider: { + includeInStateLogs: false, + persist: false, + includeInDebugSnapshot: false, + usedInUi: true, + }, +}; + +export type PerpsControllerChaseOrderMaxDistanceReachedEvent = { + type: 'PerpsController:chaseOrderMaxDistanceReached'; + payload: [ChaseOrderMaxDistanceReached]; +}; + +/** PerpsController events. */ +export type PerpsControllerEvents = + | ControllerStateChangeEvent<'PerpsController', PerpsControllerState> + | ControllerStateChangedEvent<'PerpsController', PerpsControllerState> + | PerpsControllerChaseOrderMaxDistanceReachedEvent; + +/** + * The action which can be used to retrieve the state of the + * {@link PerpsController}. + */ +export type PerpsControllerGetStateAction = ControllerGetStateAction< + 'PerpsController', + PerpsControllerState +>; + +/** + * PerpsController actions + */ +export type PerpsControllerActions = + | PerpsControllerGetStateAction + | PerpsControllerMethodActions; + +/** + * PerpsController messenger constraints. + * Includes both PerpsController's own actions/events and + * allowed actions/events from external controllers. + */ +export type PerpsControllerMessenger = Messenger< + 'PerpsController', + PerpsControllerActions | PerpsControllerAllowedActions, + PerpsControllerEvents | PerpsControllerAllowedEvents +>; + +/** + * PerpsController options + */ +export type PerpsControllerOptions = { + messenger: PerpsControllerMessenger; + state?: Partial; + clientConfig?: PerpsControllerConfig; + /** + * Platform-specific dependencies (required) + * Provides logging, metrics, tracing, stream management, and rewards. + * Cross-controller communication uses the messenger pattern. + * Must be provided by the platform (mobile/extension) at instantiation time. + */ + infrastructure: PerpsPlatformDependencies; + /** + * When true, defers the initial eligibility (geolocation) check until + * `startEligibilityMonitoring()` is called. This prevents the eager + * geolocation fetch from firing during wallet onboarding (privacy compliance). + */ + deferEligibilityCheck?: boolean; +}; + +type BlockedRegionList = { + list: string[]; + source: 'remote' | 'fallback'; +}; + +type UserSnapshotContext = { + provider: PerpsProvider; + standaloneProvider: HyperLiquidProvider | null; + address: string; + isTestnet: boolean; + hip3ConfigVersion: number; + expectedDexes: string[]; + isCurrent: () => boolean; +}; + +const MESSENGER_EXPOSED_METHODS = [ + 'approveSubscriptionBuilderFee', + 'calculateFees', + 'calculateLiquidationPrice', + 'calculateMaintenanceMargin', + 'cancelOrder', + 'cancelOrders', + 'clearAttributionContext', + 'clearDepositResult', + 'clearPendingTradeConfiguration', + 'clearPendingTransactionRequests', + 'clearWithdrawResult', + 'closePosition', + 'closePositions', + 'completeWithdrawalFromHistory', + 'depositWithConfirmation', + 'depositWithOrder', + 'disconnect', + 'editOrder', + 'fetchHistoricalCandles', + 'flipPosition', + 'getAccountState', + 'getActiveProvider', + 'getActiveProviderOrNull', + 'getAttributionContext', + 'getAvailableDexs', + 'getBlockExplorerUrl', + 'getCachedMarketDataForActiveProvider', + 'getCachedUserDataForActiveProvider', + 'getChaseOrders', + 'getTwapOrders', + 'getUserDataSnapshot', + 'getCurrentNetwork', + 'getFunding', + 'getHistoricalPortfolio', + 'getMarketCategories', + 'getMarketDataWithPrices', + 'getMarketFilterPreferences', + 'getMarkets', + 'getMaxLeverage', + 'getOpenOrders', + 'getOrderBookGrouping', + 'getOrderBookPreferences', + 'getOrderCapabilities', + 'getOrderFills', + 'getOrders', + 'getPendingTradeConfiguration', + 'getPositions', + 'getSelectedOrderType', + 'getTradeConfiguration', + 'getRecentlyViewedMarkets', + 'getVisibleCandleCount', + 'getWatchlistMarkets', + 'getWebSocketConnectionState', + 'getWithdrawalProgress', + 'getWithdrawalRoutes', + 'init', + 'invalidateSubscriptionBenefits', + 'isCurrentlyReinitializing', + 'isFirstTimeUserOnCurrentNetwork', + 'isWatchlistMarket', + 'markFirstOrderCompleted', + 'markTutorialCompleted', + 'placeOrder', + 'previewPositionModify', + 'reconnect', + 'recordMarketViewed', + 'refreshEligibility', + 'resetFirstTimeUserState', + 'resetSelectedPaymentToken', + 'getMaxSlippage', + 'setMaxSlippage', + 'setOrderBookPreferences', + 'getProLayoutPreferences', + 'setProLayoutPreferences', + 'setPerpsMode', + 'setSelectedOrderType', + 'saveMarketFilterPreferences', + 'saveOrderBookGrouping', + 'savePendingTradeConfiguration', + 'saveTradeConfiguration', + 'setAttributionContext', + 'setLiveDataConfig', + 'setSelectedPaymentToken', + 'setVisibleCandleCount', + 'startEligibilityMonitoring', + 'startMarketDataPreload', + 'stopEligibilityMonitoring', + 'stopMarketDataPreload', + 'subscribeToAccount', + 'subscribeToCandles', + 'subscribeToConnectionState', + 'subscribeToOICaps', + 'subscribeToOrderBook', + 'subscribeToOrderFills', + 'subscribeToOrders', + 'subscribeToPositions', + 'subscribeToPrices', + 'suspendChaseOrders', + 'switchProvider', + 'toggleTestnet', + 'toggleWatchlistMarket', + 'updateMargin', + 'updatePositionTPSL', + 'updateWithdrawalProgress', + 'updateWithdrawalStatus', + 'validateClosePosition', + 'validateOrder', + 'validateWithdrawal', + 'withdraw', +] as const; + +/** + * PerpsController - Protocol-agnostic perpetuals trading controller + * + * Provides a unified interface for perpetual futures trading across multiple protocols. + * Features dual data flow architecture: + * - Trading actions use Redux for persistence and optimistic updates + * - Live data uses direct callbacks for maximum performance + */ +export class PerpsController extends BaseController< + 'PerpsController', + PerpsControllerState, + PerpsControllerMessenger +> { + protected providers: Map; + + protected isInitialized = false; + + #initializationPromise: Promise | null = null; + + #isReinitializing = false; + + #reinitializationOperationPromise: Promise | null = null; + + #disconnectOperationPromise: Promise | null = null; + + /** Tracks the async MYX dynamic import so performInitialization can await it. */ + #myxRegistrationPromise: Promise | null = null; + + protected blockedRegionList: BlockedRegionList = { + list: [], + source: 'fallback', + }; + + /** + * Version counter for blocked region list. + * Used to prevent race conditions where stale eligibility checks + * (started with fallback config) overwrite results from newer checks + * (started with remote config). + */ + #blockedRegionListVersion = 0; + + // Store HIP-3 configuration (mutable for runtime updates from remote flags) + #hip3Enabled: boolean; + + #hip3AllowlistMarkets: string[]; + + #hip3BlocklistMarkets: string[]; + + #hip3ConfigSource: 'remote' | 'fallback' = 'fallback'; + + // Optional client override for the max market-vs-oracle price deviation before a + // market is reported untradable (PriceUpdate.isTradable). Protocol-agnostic: passed + // through to each provider, which applies its own default when this is undefined. + readonly #priceDeviationLimit?: number; + + /** + * Transient UTM / discovery attribution context. + * Held in-memory only (never persisted in PerpsControllerState) and merged + * into analytics event properties via {@link mergeAttributionContext}. + */ + #attributionContext: PerpsAttributionContext = {}; + + /** + * Check if MYX provider is enabled via feature flag + * Uses same pattern as other feature flags in FeatureFlagConfigurationService + * + * @returns True if the condition is met. + */ + #isMYXProviderEnabled(): boolean { + const myx = this.#options.clientConfig?.providerCredentials?.myx; + + // Local env-var override (MM_PERPS_MYX_PROVIDER_ENABLED) always wins — + // matches the UI selector (resolvePerpsMyxProviderEnabled) so controller + // and UI agree on whether MYX is available. + if (myx?.enabled) { + return true; + } + + // Credentials present → MYX is enabled regardless of remote flag. + // Use || so empty-string env vars (default '') fall through. + const hasCredentials = Boolean( + // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing + myx?.appIdTestnet || myx?.appIdMainnet, + ); + + if (hasCredentials) { + return true; + } + + // No local override or credentials — check remote flag as fallback + try { + const remoteState = this.messenger.call( + 'RemoteFeatureFlagController:getState', + ); + const remoteFlag = + remoteState.remoteFeatureFlags?.perpsMyxProviderEnabled; + + if (isVersionGatedFeatureFlag(remoteFlag)) { + const validated = + this.#options.infrastructure.featureFlags.validateVersionGated( + remoteFlag, + ); + return validated ?? false; + } + + return false; + } catch { + return false; + } + } + + /** + * Active provider instance for routing operations. + * When activeProvider is 'hyperliquid' or 'myx': points to specific provider directly + * When activeProvider is 'aggregated': points to AggregatedPerpsProvider wrapper + */ + protected activeProviderInstance: PerpsProvider | null = null; + + /** + * Cached standalone provider for pre-initialization discovery queries. + * Avoids creating a new HyperLiquidProvider (and potentially leaking WebSocket + * connections) on every standalone call from the preload cycle. + */ + #standaloneProvider: HyperLiquidProvider | null = null; + + #handlersRegistered = false; + + #standaloneProviderIsTestnet: boolean | null = null; + + #standaloneProviderHip3Version: number | null = null; + + readonly #standaloneProviderOperations = new Map< + PerpsProvider, + Set> + >(); + + #eligibilityCheckDeferred: boolean; + + /** + * Serial promise queue for all AUS watchlist operations (hydration and + * individual toggles). Chaining every operation onto this field ensures + * that: + * + * - A toggle that fires immediately after init() always runs *after* the + * init hydration finishes (Bug 3). + * - Concurrent toggles are serialised so the last PUT reflects all changes + * rather than racing with each other (Bug 4). + * + * Errors from individual operations are swallowed inside the queue so that + * a failed operation does not stall subsequent ones. + */ + #ausQueue: Promise = Promise.resolve(); + + #userDiskWrite: Promise = Promise.resolve(); + + // Store options for dependency injection (allows core package to inject platform-specific services) + readonly #options: PerpsControllerOptions; + + // Service instances (instantiated with platform dependencies) + readonly #tradingService: TradingService; + + readonly #marketDataService: MarketDataService; + + readonly #accountService: AccountService; + + readonly #eligibilityService: EligibilityService; + + readonly #dataLakeService: DataLakeService; + + readonly #depositService: DepositService; + + readonly #featureFlagConfigurationService: FeatureFlagConfigurationService; + + readonly #rewardsIntegrationService: RewardsIntegrationService; + + readonly #publishChaseOrderMaxDistanceReached = ( + event: ChaseOrderMaxDistanceReached, + ): void => { + this.messenger.publish( + 'PerpsController:chaseOrderMaxDistanceReached', + event, + ); + }; + + constructor({ + messenger, + state = {}, + clientConfig = {}, + infrastructure, + deferEligibilityCheck = false, + }: PerpsControllerOptions) { + super({ + name: 'PerpsController', + metadata, + messenger, + state: { ...getDefaultPerpsControllerState(), ...state }, + }); + + this.#eligibilityCheckDeferred = deferEligibilityCheck; + + // Store options for dependency injection + this.#options = { + messenger, + state, + clientConfig, + infrastructure, + }; + + // Instantiate services with platform dependencies + // Services that need cross-controller access receive the messenger + this.#tradingService = new TradingService(infrastructure); + this.#marketDataService = new MarketDataService({ + ...infrastructure, + terminalMarketService: + infrastructure.terminalMarketService ?? + (infrastructure.terminalApi?.marketDataUrl || + infrastructure.terminalApiUrl || + infrastructure.terminalApi?.globalSnapshotUrl + ? new TerminalMarketService(infrastructure) + : undefined), + }); + this.#accountService = new AccountService(infrastructure, messenger); + this.#eligibilityService = new EligibilityService(infrastructure); + this.#dataLakeService = new DataLakeService(infrastructure, messenger); + this.#depositService = new DepositService(infrastructure, messenger); + this.#featureFlagConfigurationService = new FeatureFlagConfigurationService( + infrastructure, + ); + this.#rewardsIntegrationService = new RewardsIntegrationService( + infrastructure, + messenger, + ); + + // Set HIP-3 fallback configuration from client (will be updated if remote flags available) + this.#hip3Enabled = clientConfig.fallbackHip3Enabled ?? false; + this.#hip3AllowlistMarkets = [ + ...(clientConfig.fallbackHip3AllowlistMarkets ?? []), + ]; + this.#hip3BlocklistMarkets = [ + ...(clientConfig.fallbackHip3BlocklistMarkets ?? []), + ]; + this.#priceDeviationLimit = clientConfig.fallbackPriceDeviationLimit; + + // Immediately set the fallback region list since RemoteFeatureFlagController is empty by default and takes a moment to populate. + this.setBlockedRegionList( + clientConfig.fallbackBlockedRegions ?? [], + 'fallback', + ); + + /** + * Immediately read current state to catch any flags already loaded + * This is necessary to avoid race conditions where the RemoteFeatureFlagController fetches flags + * before the PerpsController initializes its RemoteFeatureFlagController subscription. + * + * We still subscribe in case the RemoteFeatureFlagController is not yet populated and updates later. + */ + try { + const currentRemoteFeatureFlagState = this.messenger.call( + 'RemoteFeatureFlagController:getState', + ); + + this.refreshEligibilityOnFeatureFlagChange(currentRemoteFeatureFlagState); + } catch (error) { + // If we can't read the remote feature flags at construction time, we'll rely on: + // 1. The fallback blocked regions already set above + // 2. The subscription to catch updates when RemoteFeatureFlagController is ready + this.#logError( + ensureError(error, 'PerpsController.constructor'), + this.#getErrorContext('constructor', { + operation: 'readRemoteFeatureFlags', + }), + ); + } + + // Subscribe for the full controller lifetime — intentionally not stored; + // geo-blocking and HIP-3 flag propagation must remain active across + // disconnect → reconnect cycles and must never be torn down. + this.messenger.subscribe( + 'RemoteFeatureFlagController:stateChange', + this.refreshEligibilityOnFeatureFlagChange.bind(this), + ); + + this.providers = new Map(); + + // Migrate old persisted data without accountAddress + this.#migrateRequestsIfNeeded(); + + // Eagerly hydrate in-memory caches from disk so hooks see data on first render. + // Must happen at construction time — before any React component mounts. + this.#hydrateCacheFromDiskSync(); + this.#options.infrastructure.performance.onControllerConstructed?.( + this.#options.infrastructure.performance.now(), + ); + } + + // ============================================================================ + // Infrastructure Access Methods + // These methods provide access to platform-specific infrastructure via dependency injection. + // Infrastructure is required and must be provided at instantiation time. + // ============================================================================ + + /** + * Log an error using injected infrastructure logger + * + * @param error - The error that occurred. + * @param options - The configuration options. + */ + #logError(error: Error, options?: PerpsLoggerOptions): void { + this.#options.infrastructure.logger.error(error, options); + } + + /** + * Log debug message using injected infrastructure debugLogger + * + * @param args - The function arguments. + */ + #debugLog( + ...args: (string | number | boolean | object | null | undefined)[] + ): void { + this.#options.infrastructure.debugLogger.log(...args); + } + + /** + * Resolve the provider ids that should participate in aggregated cache reads. + * + * Providers can still be registering when the first render happens, so we + * also look at cache keys to recover disk-hydrated provider snapshots before + * `init()` finishes populating `this.providers`. + * + * @param cacheKeys - Cache keys currently present in the relevant cache map. + * @returns Provider ids that should be included in aggregated reads. + */ + #getAggregatedCacheProviderIds(cacheKeys: string[]): string[] { + const providerIds = new Set(); + const currentNetwork = this.state.isTestnet ? 'testnet' : 'mainnet'; + + for (const [providerId] of this.providers) { + providerIds.add(providerId); + } + + for (const key of cacheKeys) { + const [providerId, network] = key.split(':'); + if ( + !providerId || + network !== currentNetwork || + providerId === 'aggregated' + ) { + continue; + } + + if ( + providerId === 'hyperliquid' || + (providerId === 'myx' && this.#isMYXProviderEnabled()) || + this.providers.has(providerId as PerpsProviderType) + ) { + providerIds.add(providerId); + } + } + + return Array.from(providerIds); + } + + /** + * Read cached market data for the currently active provider (or aggregated). + * Returns null when no valid cache exists or when cache has expired. + * + * @param options - Optional settings. + * @param options.skipTTL - When true, bypass the 5-minute TTL check. + * Used during initial render so disk-hydrated structural data (with + * placeholder prices) is returned regardless of age. + * @returns The cached market data array, or null if no valid cache. + */ + getCachedMarketDataForActiveProvider(options?: { + skipTTL?: boolean; + }): PerpsMarketData[] | null { + const { activeProvider } = this.state; + const cache = this.state.cachedMarketDataByProvider; + + if (activeProvider === 'aggregated') { + // Assemble from all registered provider entries + const assembled: PerpsMarketData[] = []; + for (const providerId of this.#getAggregatedCacheProviderIds( + Object.keys(cache), + )) { + const key = buildProviderCacheKey(providerId, this.state.isTestnet); + const entry = cache[key]; + if (!entry || entry.data.length === 0) { + continue; + } + if (!this.#isMarketCacheEntryCurrent(providerId, entry, options)) { + continue; + } + assembled.push(...clonePerpsMarketData(entry.data)); + } + if (assembled.length === 0) { + return null; + } + return assembled; + } + + // Single provider mode + const key = buildProviderCacheKey(activeProvider, this.state.isTestnet); + const entry = cache[key]; + if (!entry || entry.data.length === 0) { + return null; + } + if (!this.#isMarketCacheEntryCurrent(activeProvider, entry, options)) { + return null; + } + return clonePerpsMarketData(entry.data); + } + + #isMarketCacheEntryCurrent( + providerId: string, + entry: PerpsControllerState['cachedMarketDataByProvider'][string], + options?: { skipTTL?: boolean }, + ): boolean { + if (entry.sourceExpiresAt !== undefined) { + const expectedDexes = this.#getStaticSnapshotDexes(); + return ( + providerId === 'hyperliquid' && + Date.now() < entry.sourceExpiresAt && + entry.hip3ConfigVersion === this.state.hip3ConfigVersion && + expectedDexes !== undefined && + Array.isArray(entry.dexes) && + entry.dexes.length === expectedDexes.length && + entry.dexes.every((dex, index) => dex === expectedDexes[index]) + ); + } + return ( + options?.skipTTL === true || + Date.now() - entry.timestamp <= PerpsController.#preloadGuardMs * 10 + ); + } + + /** + * Read cached user data for the currently active provider (or aggregated). + * Returns null when no valid cache exists, cache has expired, or address + * does not match the currently selected EVM account. + * + * @param options - Optional settings. + * @param options.skipTTL - When true, bypass the 60s staleness check. + * Used during initial render so disk-hydrated user data (positions/orders) + * is returned regardless of age, avoiding a skeleton flash. + * @returns The cached user data, or null if no valid cache. + */ + getCachedUserDataForActiveProvider(options?: { skipTTL?: boolean }): { + positions: Position[]; + orders: Order[]; + accountState: AccountState | null; + } | null { + const { activeProvider } = this.state; + const cache = this.state.cachedUserDataByProvider; + const staleCutoff = PerpsController.#preloadGuardMs * 2; // 60s + + // Get current user address for validation + let currentAddress: string | null = null; + try { + const evmAccount = getSelectedEvmAccountFromMessenger(this.messenger); + currentAddress = evmAccount?.address ?? null; + } catch { + // Account identity is required before account-scoped data can be trusted. + } + + if (!currentAddress) { + return null; + } + const selectedAddress = currentAddress; + + const skipTTL = options?.skipTTL ?? false; + + const isValidEntry = ( + providerId: string, + entry: + | PerpsControllerState['cachedUserDataByProvider'][string] + | undefined, + ): entry is PerpsControllerState['cachedUserDataByProvider'][string] => { + if (!entry) { + return false; + } + if (!skipTTL && Date.now() - entry.timestamp >= staleCutoff) { + return false; + } + if ( + !this.#isUserCacheIdentityCurrent(providerId, entry, selectedAddress) + ) { + return false; + } + return true; + }; + + if (activeProvider === 'aggregated') { + // Assemble from all registered provider entries + const allPositions: Position[] = []; + const allOrders: Order[] = []; + let defaultAccountState: AccountState | null = null; + let hasValidEntry = false; + + for (const providerId of this.#getAggregatedCacheProviderIds( + Object.keys(cache), + )) { + const providerNetworkKey = buildProviderCacheKey( + providerId, + this.state.isTestnet, + ); + const entry = cache[providerNetworkKey]; + if (!isValidEntry(providerId, entry)) { + continue; + } + hasValidEntry = true; + allPositions.push(...entry.positions); + allOrders.push(...entry.orders); + // AccountState from default provider (hyperliquid) + if (providerId === 'hyperliquid') { + defaultAccountState = entry.accountState; + } + } + + if (!hasValidEntry) { + return null; + } + + return { + positions: allPositions, + orders: allOrders, + accountState: defaultAccountState, + }; + } + + // Single provider mode + const providerNetworkKey = buildProviderCacheKey( + activeProvider, + this.state.isTestnet, + ); + const entry = cache[providerNetworkKey]; + if (!entry || !isValidEntry(activeProvider, entry)) { + return null; + } + + return { + positions: entry.positions, + orders: entry.orders, + accountState: entry.accountState, + }; + } + + #isUserCacheIdentityCurrent( + providerId: string, + entry: PerpsControllerState['cachedUserDataByProvider'][string], + address: string, + ): boolean { + if (entry.address.toLowerCase() !== address.toLowerCase()) { + return false; + } + if (providerId !== 'hyperliquid') { + return true; + } + + const expectedDexes = this.#getStaticSnapshotDexes(); + return ( + entry.hip3ConfigVersion === this.state.hip3ConfigVersion && + expectedDexes !== undefined && + Array.isArray(entry.dexes) && + entry.dexes.length === expectedDexes.length && + entry.dexes.every((dex, index) => dex === expectedDexes[index]) + ); + } + + /** + * Fetch, validate, and atomically cache a complete user-data snapshot. + * This remains callable after mount so consumers can seed their live channel + * from one coherent positions/orders/account result. + * + * @returns The accepted user-data snapshot. + */ + async getUserDataSnapshot(): Promise { + const evmAccount = getSelectedEvmAccountFromMessenger(this.messenger); + if (!evmAccount?.address) { + throw new Error('Cannot fetch user data snapshot without an EVM account'); + } + if (this.state.activeProvider !== 'hyperliquid') { + throw new Error('User data snapshots require Hyperliquid provider mode'); + } + + const capturedActiveProvider = this.activeProviderInstance; + const standaloneProvider = capturedActiveProvider + ? null + : this.#getOrCreateStandaloneProvider(); + const provider = capturedActiveProvider ?? standaloneProvider; + if (!provider) { + throw new Error('Cannot create standalone Hyperliquid provider'); + } + const { address } = evmAccount; + const { isTestnet, hip3ConfigVersion } = this.state; + const lifecycleGeneration = this.#lifecycleGeneration; + const network = isTestnet ? 'testnet' : 'mainnet'; + const expectedDexes = this.#getStaticSnapshotDexes(); + if (!expectedDexes) { + throw new Error('User data snapshot DEX identity is not static'); + } + const isCurrent = (): boolean => { + let currentAddress: string | undefined; + try { + currentAddress = getSelectedEvmAccountFromMessenger( + this.messenger, + )?.address; + } catch { + return false; + } + + return ( + this.#lifecycleGeneration === lifecycleGeneration && + this.state.activeProvider === 'hyperliquid' && + (!capturedActiveProvider || + this.activeProviderInstance === capturedActiveProvider) && + this.state.isTestnet === isTestnet && + this.state.hip3ConfigVersion === hip3ConfigVersion && + currentAddress?.toLowerCase() === address.toLowerCase() + ); + }; + + const context: UserSnapshotContext = { + provider, + standaloneProvider, + address, + isTestnet, + hip3ConfigVersion, + expectedDexes, + isCurrent, + }; + const requestKey = [ + 'hyperliquid', + network, + address.toLowerCase(), + hip3ConfigVersion, + ...expectedDexes, + ].join('|'); + const existingRequest = this.#userSnapshotRequests.get(requestKey); + if (existingRequest?.provider === provider) { + return existingRequest.promise; + } + + const request = this.#fetchAndCacheUserDataSnapshot(context); + this.#userSnapshotRequests.set(requestKey, { provider, promise: request }); + try { + return await request; + } finally { + if (this.#userSnapshotRequests.get(requestKey)?.promise === request) { + this.#userSnapshotRequests.delete(requestKey); + } + } + } + + async #fetchAndCacheUserDataSnapshot( + context: UserSnapshotContext, + ): Promise { + const { + provider, + standaloneProvider, + address, + isTestnet, + hip3ConfigVersion, + expectedDexes, + isCurrent, + } = context; + if (!isCurrent()) { + throw new Error('User data snapshot context changed'); + } + if (!provider.getUserDataSnapshot) { + throw new Error('Provider has no atomic snapshot API'); + } + const identity = { + provider: 'hyperliquid' as const, + network: isTestnet ? ('testnet' as const) : ('mainnet' as const), + hip3ConfigVersion, + dexes: expectedDexes, + }; + const snapshotRequest = provider.getUserDataSnapshot({ + userAddress: address, + identity, + }); + const snapshot = standaloneProvider + ? await this.#trackStandaloneProviderOperation( + standaloneProvider, + snapshotRequest, + ) + : await snapshotRequest; + + if (!isCurrent()) { + throw new Error('User data snapshot context changed'); + } + + const snapshotIdentity = snapshot.identity; + const hasCompleteBundle = + Array.isArray(snapshot.positions) && + Array.isArray(snapshot.orders) && + snapshot.accountState !== null && + typeof snapshot.accountState === 'object'; + const hasExactIdentity = + snapshotIdentity.provider === identity.provider && + snapshotIdentity.network === identity.network && + snapshotIdentity.hip3ConfigVersion === identity.hip3ConfigVersion && + snapshotIdentity.address.toLowerCase() === address.toLowerCase() && + snapshotIdentity.dexes.length === expectedDexes.length && + snapshotIdentity.dexes.every( + (dex, index) => dex === expectedDexes[index], + ); + if (!hasCompleteBundle || !hasExactIdentity) { + throw new Error('User data snapshot is incomplete or mismatched'); + } + + if (!isCurrent()) { + throw new Error('User data snapshot context changed'); + } + + const cachedSnapshot = cloneUserDataSnapshot(snapshot); + const result = cloneUserDataSnapshot(snapshot); + const timestamp = Date.now(); + const providerNetworkKey = buildProviderCacheKey('hyperliquid', isTestnet); + this.update((state) => { + state.cachedUserDataByProvider[providerNetworkKey] = { + positions: cachedSnapshot.positions, + orders: cachedSnapshot.orders, + accountState: cachedSnapshot.accountState, + timestamp, + address, + hip3ConfigVersion, + dexes: expectedDexes, + }; + }); + this.#persistUserCacheToDisk(); + this.#debugLog('PerpsController: user cache snapshot written', { + writtenKey: providerNetworkKey, + availableKeys: Object.keys(this.state.cachedUserDataByProvider).sort(), + positionCount: cachedSnapshot.positions.length, + orderCount: cachedSnapshot.orders.length, + }); + + return result; + } + + /** + * Returns a cached standalone HyperLiquidProvider for pre-initialization + * discovery queries. Creates a new instance on first call or when the + * isTestnet / hip3ConfigVersion has changed since the last creation. + * + * @returns A HyperLiquidProvider suitable for standalone REST calls. + */ + #getOrCreateStandaloneProvider(): HyperLiquidProvider { + const currentIsTestnet = this.state.isTestnet; + const currentHip3Version = this.state.hip3ConfigVersion ?? 0; + + if ( + this.#standaloneProvider && + this.#standaloneProviderIsTestnet === currentIsTestnet && + this.#standaloneProviderHip3Version === currentHip3Version + ) { + return this.#standaloneProvider; + } + + // Stale or missing — retire the old provider after active operations finish. + if (this.#standaloneProvider) { + const old = this.#standaloneProvider; + this.#standaloneProvider = null; + this.#standaloneProviderIsTestnet = null; + this.#standaloneProviderHip3Version = null; + this.#retireStandaloneProvider(old).catch(() => { + /* best-effort */ + }); + } + + this.#standaloneProvider = new HyperLiquidProvider({ + isTestnet: currentIsTestnet, + hip3Enabled: this.#hip3Enabled, + allowlistMarkets: this.#hip3AllowlistMarkets, + blocklistMarkets: this.#hip3BlocklistMarkets, + priceDeviationLimit: this.#priceDeviationLimit, + platformDependencies: this.#options.infrastructure, + messenger: this.messenger, + builderAddressTestnet: + this.#options.clientConfig?.providerCredentials?.hyperliquid + ?.builderAddressTestnet, + builderAddressMainnet: + this.#options.clientConfig?.providerCredentials?.hyperliquid + ?.builderAddressMainnet, + subscriptionBuilderAddressTestnet: + this.#options.clientConfig?.providerCredentials?.hyperliquid + ?.subscriptionBuilderAddressTestnet, + subscriptionBuilderAddressMainnet: + this.#options.clientConfig?.providerCredentials?.hyperliquid + ?.subscriptionBuilderAddressMainnet, + onChaseOrderMaxDistanceReached: this.#publishChaseOrderMaxDistanceReached, + }); + this.#standaloneProviderIsTestnet = currentIsTestnet; + this.#standaloneProviderHip3Version = currentHip3Version; + + return this.#standaloneProvider; + } + + #trackStandaloneProviderOperation( + provider: PerpsProvider, + operation: Promise, + ): Promise { + const operations = + this.#standaloneProviderOperations.get(provider) ?? new Set(); + this.#standaloneProviderOperations.set(provider, operations); + + const trackedOperation = operation.finally(() => { + operations.delete(trackedOperation); + if (operations.size === 0) { + this.#standaloneProviderOperations.delete(provider); + } + }); + operations.add(trackedOperation); + + return trackedOperation; + } + + async #retireStandaloneProvider( + provider: HyperLiquidProvider, + ): Promise { + const operations = this.#standaloneProviderOperations.get(provider); + if (operations?.size) { + await Promise.allSettled([...operations]); + } + try { + await provider.disconnect(); + } catch { + /* best-effort */ + } finally { + this.#standaloneProviderOperations.delete(provider); + } + } + + /** + * Disconnect and discard the cached standalone provider (if any). + * Best-effort — errors are silently caught. + */ + async #cleanupStandaloneProvider(): Promise { + const provider = this.#standaloneProvider; + if (!provider) { + return; + } + this.#standaloneProvider = null; + this.#standaloneProviderIsTestnet = null; + this.#standaloneProviderHip3Version = null; + await this.#retireStandaloneProvider(provider); + } + + /** + * Test-observable accessor for whether a standalone provider is cached. + * + * @returns True if a standalone provider instance exists. + */ + protected hasStandaloneProvider(): boolean { + return this.#standaloneProvider !== null; + } + + /** + * Get metrics instance from platform dependencies + * + * @returns The platform metrics instance. + */ + #getMetrics(): PerpsPlatformDependencies['metrics'] { + return this.#options.infrastructure.metrics; + } + + // ============================================================================ + // Messenger-based Controller Access + // These methods use the messenger pattern for inter-controller communication + // ============================================================================ + + /** + * Find network client ID for a given chain via messenger + * + * @param chainId - The chain identifier. + * @returns The resulting string value. + */ + #findNetworkClientIdForChain(chainId: string): string | undefined { + return this.messenger.call( + 'NetworkController:findNetworkClientIdByChainId', + chainId as `0x${string}`, + ); + } + + /** + * Submit a transaction via messenger (shows confirmation screen) + * + * @param txParams - The transaction parameters. + * @param txParams.from - The sender address. + * @param txParams.to - The recipient address. + * @param txParams.value - The transaction value. + * @param txParams.data - The transaction data payload. + * @param txParams.gas - The gas limit. + * @param options - The configuration options. + * @param options.networkClientId - The network client identifier. + * @param options.origin - The transaction origin. + * @param options.type - The transaction type. + * @param options.skipInitialGasEstimate - Whether to skip initial gas estimation. + * @returns The transaction result containing a hash promise and transaction metadata. + */ + async #submitTransaction( + txParams: PerpsTransactionParams, + options: PerpsAddTransactionOptions, + ): Promise<{ + result: Promise; + transactionMeta: { id: string; hash?: string }; + }> { + // Cast needed: PerpsController uses loose string types for txParams/options + // while TransactionController uses strict branded types (TransactionParams, AddTransactionOptions) + return this.messenger.call( + 'TransactionController:addTransaction', + // eslint-disable-next-line @typescript-eslint/no-explicit-any + txParams as any, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + { ...(options as any), isInternal: true }, + ); + } + + /** + * Clean up old withdrawal/deposit requests that don't have accountAddress + * These are from before the accountAddress field was added and can't be displayed + * in the UI (which filters by account), so we discard them. + * Also drop persisted failed withdrawals — failures are surfaced via lastWithdrawResult only. + */ + #migrateRequestsIfNeeded(): void { + this.update((state) => { + // Remove withdrawal requests without accountAddress - they can't be attributed to any account + state.withdrawalRequests = state.withdrawalRequests.filter( + (req) => Boolean(req.accountAddress) && req.status !== 'failed', + ); + + // Remove deposit requests without accountAddress - they can't be attributed to any account + state.depositRequests = state.depositRequests.filter((req) => + Boolean(req.accountAddress), + ); + }); + } + + protected setBlockedRegionList( + list: string[], + source: 'remote' | 'fallback', + ): void { + this.#featureFlagConfigurationService.setBlockedRegions({ + list, + source, + context: this.#createServiceContext('setBlockedRegionList', { + getBlockedRegionList: () => this.blockedRegionList, + setBlockedRegionList: ( + newList: string[], + newSource: 'remote' | 'fallback', + ) => { + this.blockedRegionList = { list: newList, source: newSource }; + this.#blockedRegionListVersion += 1; + }, + refreshEligibility: () => this.refreshEligibility(), + }), + }); + } + + /** + * Respond to RemoteFeatureFlagController state changes + * Refreshes user eligibility based on geo-blocked regions defined in remote feature flag. + * Uses fallback configuration when remote feature flag is undefined. + * Note: Initial eligibility is set in the constructor if fallback regions are provided. + * + * @param remoteFeatureFlagControllerState - State from RemoteFeatureFlagController. + */ + protected refreshEligibilityOnFeatureFlagChange( + remoteFeatureFlagControllerState: PerpsRemoteFeatureFlagState, + ): void { + this.#featureFlagConfigurationService.refreshEligibility({ + remoteFeatureFlagControllerState, + context: this.#createServiceContext( + 'refreshEligibilityOnFeatureFlagChange', + { + getBlockedRegionList: () => this.blockedRegionList, + setBlockedRegionList: ( + list: string[], + source: 'remote' | 'fallback', + ) => { + this.blockedRegionList = { list, source }; + this.#blockedRegionListVersion += 1; + }, + refreshEligibility: () => this.refreshEligibility(), + getHip3Config: () => ({ + enabled: this.#hip3Enabled, + allowlistMarkets: this.#hip3AllowlistMarkets, + blocklistMarkets: this.#hip3BlocklistMarkets, + source: this.#hip3ConfigSource, + }), + setHip3Config: (config) => { + if (config.enabled !== undefined) { + this.#hip3Enabled = config.enabled; + } + if (config.allowlistMarkets !== undefined) { + this.#hip3AllowlistMarkets = [...config.allowlistMarkets]; + } + if (config.blocklistMarkets !== undefined) { + this.#hip3BlocklistMarkets = [...config.blocklistMarkets]; + } + if (config.source !== undefined) { + this.#hip3ConfigSource = config.source; + } + }, + incrementHip3ConfigVersion: () => { + const newVersion = (this.state.hip3ConfigVersion || 0) + 1; + this.update((state) => { + state.hip3ConfigVersion = newVersion; + }); + return newVersion; + }, + }, + ), + }); + } + + /** + * Execute an operation while temporarily pausing specified stream channels + * to prevent WebSocket updates from triggering UI re-renders during operations. + * + * WebSocket connections remain alive but updates are not emitted to subscribers. + * This prevents race conditions where UI re-renders fetch stale data during operations. + * + * @param operation - The async operation to execute + * @param channels - Array of stream channel names to pause + * @returns The result of the operation + * @example + * ```typescript + * // Cancel orders without stream interference + * await this.#withStreamPause( + * async () => this.provider.cancelOrders({ cancelAll: true }), + * ['orders'] + * ); + * + * // Close positions and pause multiple streams + * await this.#withStreamPause( + * async () => this.provider.closePositions(positions), + * ['positions', 'account', 'orders'] + * ); + * ``` + */ + async #withStreamPause( + operation: () => Promise, + channels: string[], + ): Promise { + const pausedChannels: string[] = []; + const { streamManager } = this.#options.infrastructure; + + // Pause emission on specified channels (WebSocket stays connected) + // Track which channels successfully paused to ensure proper cleanup + for (const channel of channels) { + try { + streamManager.pauseChannel(channel); + pausedChannels.push(channel); + } catch (error) { + // Log error to Sentry but continue pausing remaining channels + this.#logError( + ensureError(error, 'PerpsController.withStreamPause'), + this.#getErrorContext('withStreamPause', { + operation: 'pause', + channel: String(channel), + pausedChannels: pausedChannels.join(','), + }), + ); + } + } + + try { + // Execute operation without stream interference + return await operation(); + } finally { + // Resume only channels that were successfully paused + for (const channel of pausedChannels) { + try { + streamManager.resumeChannel(channel); + } catch (error) { + // Log error to Sentry but continue resuming remaining channels + this.#logError( + ensureError(error, 'PerpsController.withStreamPause'), + this.#getErrorContext('withStreamPause', { + operation: 'resume', + channel: String(channel), + pausedChannels: pausedChannels.join(','), + }), + ); + } + } + } + } + + /** + * Initialize the PerpsController providers + * Must be called before using any other methods + * Prevents double initialization with promise caching + * + * @returns A promise that resolves when the operation completes. + */ + async init(): Promise { + while (true) { + const pendingDisconnect = this.#disconnectOperationPromise; + if (pendingDisconnect) { + await pendingDisconnect; + continue; + } + + const pendingReinitialization = this.#reinitializationOperationPromise; + if (pendingReinitialization) { + await pendingReinitialization; + continue; + } + + return this.#initWithoutDisconnectWait(); + } + } + + /** + * Initialize without waiting for disconnect. Reinitialization operations use + * this after they have claimed the controller lifecycle. + * + * @returns A promise that resolves when initialization finishes. + */ + async #initWithoutDisconnectWait(): Promise { + if (!this.#handlersRegistered) { + this.messenger.registerMethodActionHandlers( + this, + MESSENGER_EXPOSED_METHODS, + ); + this.#handlersRegistered = true; + } + + if (this.isInitialized) { + return undefined; + } + + if (this.#initializationPromise) { + return this.#initializationPromise; + } + + this.#initializationPromise = this.#performInitialization(); + return this.#initializationPromise; + } + + /** + * Track a network or provider reinitialization so disconnect can serialize + * behind the whole operation, including work before and after init(). + * + * @returns A callback that releases the lifecycle operation. + */ + #beginReinitialization(): () => void { + this.#isReinitializing = true; + let resolveOperation = (): void => undefined; + const operation = new Promise((resolve) => { + resolveOperation = resolve; + }); + this.#reinitializationOperationPromise = operation; + + return (): void => { + this.#isReinitializing = false; + resolveOperation(); + if (this.#reinitializationOperationPromise === operation) { + this.#reinitializationOperationPromise = null; + } + }; + } + + /** + * Actual initialization implementation with retry logic + */ + async #performInitialization(): Promise { + const maxAttempts = 3; + const baseDelay = 1000; + + this.update((state) => { + state.initializationState = InitializationState.Initializing; + state.initializationError = null; + state.initializationAttempts = 0; + }); + + this.#debugLog('PerpsController: Initializing providers', { + currentNetwork: this.state.isTestnet ? 'testnet' : 'mainnet', + existingProviders: Array.from(this.providers.keys()), + timestamp: new Date().toISOString(), + }); + + let lastError: Error | null = null; + + for (let attempt = 1; attempt <= maxAttempts; attempt++) { + try { + this.update((state) => { + state.initializationAttempts = attempt; + }); + + // Disconnect existing providers to close WebSocket connections + const existingProviders = Array.from(this.providers.values()); + if (existingProviders.length > 0) { + this.#debugLog('PerpsController: Disconnecting existing providers', { + count: existingProviders.length, + timestamp: new Date().toISOString(), + }); + await Promise.all( + existingProviders.map((provider) => provider.disconnect()), + ); + } + this.providers.clear(); + await this.#cleanupStandaloneProvider(); + + this.#createProviders(); + + // Await MYX dynamic import (if started) so MYX is in the providers + // map before we assign the active provider. Runs concurrently with + // the WebSocket readiness delay for zero additional latency. + await Promise.all([ + wait(PERPS_CONSTANTS.ReconnectionCleanupDelayMs), + this.#myxRegistrationPromise, + ]); + this.#myxRegistrationPromise = null; + + this.#assignActiveProvider(); + + this.isInitialized = true; + this.update((state) => { + state.initializationState = InitializationState.Initialized; + state.initializationError = null; + }); + + this.#debugLog('PerpsController: Providers initialized successfully', { + providerCount: this.providers.size, + activeProvider: this.state.activeProvider, + timestamp: new Date().toISOString(), + attempts: attempt, + }); + + // Hydrate watchlist from AUS (non-blocking — transient failures are + // caught inside and must not prevent init from completing). + // Assigning to #ausQueue ensures subsequent toggleWatchlistMarket + // calls wait for hydration before running their own GET-merge-PUT. + this.#ausQueue = this.#syncWatchlistFromRemote().catch(() => { + // Errors are already logged inside #syncWatchlistFromRemote. + }); + + return; // Exit retry loop on success + } catch (error) { + lastError = ensureError(error, 'PerpsController.performInitialization'); + + this.#logError( + lastError, + this.#getErrorContext('performInitialization', { + attempt, + maxAttempts, + }), + ); + + // If not the last attempt, wait before retrying (exponential backoff) + if (attempt < maxAttempts) { + const delay = baseDelay * Math.pow(2, attempt - 1); // 1s, 2s, 4s + this.#debugLog( + `PerpsController: Retrying initialization in ${delay}ms`, + { + attempt, + maxAttempts, + error: lastError.message, + }, + ); + await wait(delay); + } + } + } + + this.isInitialized = false; + this.update((state) => { + state.initializationState = InitializationState.Failed; + state.initializationError = lastError?.message ?? 'Unknown error'; + }); + this.#initializationPromise = null; // Clear promise to allow retry + + this.#debugLog('PerpsController: Initialization failed', { + error: lastError?.message, + attempts: maxAttempts, + timestamp: new Date().toISOString(), + }); + } + + /** + * Instantiate provider instances based on current state and register them. + * Selects and assigns the active provider instance from the registry. + * Future providers can be added here with their own authentication patterns: + * - Some might use API keys: new BinanceProvider({ apiKey, apiSecret }) + * - Some might use different wallet patterns: new GMXProvider({ signer }) + * - Some might not need auth at all: new DydxProvider() + */ + #createProviders(): void { + const { activeProvider } = this.state; + + this.#debugLog( + 'PerpsController: Creating provider with HIP-3 configuration', + { + hip3Enabled: this.#hip3Enabled, + hip3AllowlistMarkets: this.#hip3AllowlistMarkets, + hip3BlocklistMarkets: this.#hip3BlocklistMarkets, + hip3ConfigSource: this.#hip3ConfigSource, + isTestnet: this.state.isTestnet, + activeProvider, + }, + ); + + // Always create HyperLiquid provider as the base provider + const hyperLiquidProvider = new HyperLiquidProvider({ + isTestnet: this.state.isTestnet, + hip3Enabled: this.#hip3Enabled, + allowlistMarkets: this.#hip3AllowlistMarkets, + blocklistMarkets: this.#hip3BlocklistMarkets, + priceDeviationLimit: this.#priceDeviationLimit, + platformDependencies: this.#options.infrastructure, + messenger: this.messenger, + builderAddressTestnet: + this.#options.clientConfig?.providerCredentials?.hyperliquid + ?.builderAddressTestnet, + builderAddressMainnet: + this.#options.clientConfig?.providerCredentials?.hyperliquid + ?.builderAddressMainnet, + subscriptionBuilderAddressTestnet: + this.#options.clientConfig?.providerCredentials?.hyperliquid + ?.subscriptionBuilderAddressTestnet, + subscriptionBuilderAddressMainnet: + this.#options.clientConfig?.providerCredentials?.hyperliquid + ?.subscriptionBuilderAddressMainnet, + onChaseOrderMaxDistanceReached: this.#publishChaseOrderMaxDistanceReached, + }); + this.providers.set('hyperliquid', hyperLiquidProvider); + + // Register MYX provider if enabled via feature flag. + // Dynamic import because the MYX package pulls in heavy dependencies we + // don't want bundled in extension. Until MYX fixes their package, extension + // doesn't ship it — the catch branch silently skips registration. + // Uses .then()/.catch() instead of await because #createProviders is not async; + // MYX registration completing asynchronously is fine since it's only used when + // explicitly enabled and selected. + const isMYXEnabled = this.#isMYXProviderEnabled(); + if (isMYXEnabled) { + // IMPORTANT: Must use import() — NOT require() — for core/extension tree-shaking. + // require() is synchronous and bundlers include it in the main bundle. + // import() enables true code splitting so MYX is excluded when not enabled. + // NOTE: Keep the path in a variable so ts-bridge does not rewrite the + // import argument and strip the webpackIgnore magic comment in core dist. + const myxModulePath = './providers/MYXProvider'; + this.#myxRegistrationPromise = import( + /* webpackIgnore: true */ myxModulePath + ) + .then(({ MYXProvider }) => { + this.registerMYXProvider(MYXProvider); + return undefined; + }) + .catch((error: unknown) => this.handleMYXImportError(error)); + } + } + + /** + * Registers the MYX provider after dynamic import resolves. + * + * Extracted from the import().then() callback so it can be tested directly + * (Jest cannot resolve dynamic imports without --experimental-vm-modules). + * + * @param MYXProvider - Constructor class for the MYX provider. + */ + protected registerMYXProvider(MYXProvider: unknown): void { + if (typeof MYXProvider !== 'function') { + return; + } + + const myxIsTestnet = + PROVIDER_CONFIG.MYX_TESTNET_ONLY || this.state.isTestnet; + const myx = this.#options.clientConfig?.providerCredentials?.myx ?? {}; + const myxAuthConfig = resolveMyxAuthConfig(myx, myxIsTestnet); + const MYXProviderConstructor = MYXProvider as new (opts: { + isTestnet: boolean; + platformDependencies: PerpsPlatformDependencies; + messenger: PerpsControllerMessenger; + myxAuthConfig: ReturnType; + }) => PerpsProvider; + const myxProvider = new MYXProviderConstructor({ + isTestnet: myxIsTestnet, + platformDependencies: this.#options.infrastructure, + messenger: this.messenger, + myxAuthConfig, + }); + this.providers.set('myx', myxProvider); + this.#debugLog('PerpsController: MYX provider registered', { + isTestnet: myxIsTestnet, + }); + } + + /** + * Handles errors from the MYX dynamic import. + * + * Module-not-found errors are expected (extension doesn't ship MYX) → debug log. + * Other errors indicate constructor/config problems → Sentry via logError. + * + * @param error - The caught error from the dynamic import or constructor. + */ + protected handleMYXImportError(error: unknown): void { + const isModuleError = + (error as Record)?.code === 'MODULE_NOT_FOUND'; + if (isModuleError) { + this.#debugLog( + 'PerpsController: MYX provider module not available, skipping registration', + ); + } else { + this.#logError( + error instanceof Error ? error : new Error(String(error)), + this.#getErrorContext('createProviders.myx'), + ); + } + } + + /** + * Assigns the active provider instance based on the current activeProvider state. + * Separated from #createProviders so it runs after async MYX registration settles. + */ + #assignActiveProvider(): void { + const { activeProvider } = this.state; + const hyperLiquidProvider = this.providers.get('hyperliquid'); + + if (!hyperLiquidProvider) { + throw new Error( + 'HyperLiquid provider not registered — cannot assign active provider', + ); + } + + if (activeProvider === 'aggregated') { + this.activeProviderInstance = new AggregatedPerpsProvider({ + providers: this.providers, + defaultProvider: 'hyperliquid', + infrastructure: this.#options.infrastructure, + }); + this.#debugLog( + 'PerpsController: Using aggregated provider (multi-provider)', + { registeredProviders: Array.from(this.providers.keys()) }, + ); + } else if (activeProvider === 'hyperliquid') { + this.activeProviderInstance = hyperLiquidProvider; + this.#debugLog( + `PerpsController: Using direct provider (${activeProvider})`, + ); + } else if (activeProvider === 'myx') { + const myxProvider = this.providers.get('myx'); + if (myxProvider) { + this.activeProviderInstance = myxProvider; + } else { + this.#debugLog( + 'PerpsController: MYX provider not available, falling back to hyperliquid', + ); + this.activeProviderInstance = hyperLiquidProvider; + this.update((state) => { + state.activeProvider = 'hyperliquid'; + }); + } + this.#debugLog( + `PerpsController: Using direct provider (${this.activeProviderInstance === hyperLiquidProvider ? 'hyperliquid' : activeProvider})`, + ); + } else { + throw new Error( + `Unsupported provider: ${String(activeProvider)}. Currently only 'hyperliquid', 'myx', and 'aggregated' are supported.`, + ); + } + } + + /** + * Generate standard error context for Logger.error calls with searchable tags and context. + * Enables Sentry dashboard filtering by feature, provider, and network. + * + * @param method - The method name where the error occurred + * @param extra - Optional additional context fields (becomes searchable context data) + * @returns PerpsLoggerOptions with tags (searchable) and context (searchable) + * @private + * @example + * this.#logError(error, this.#getErrorContext('placeOrder', { symbol: 'BTC', operation: 'validate' })); + * // Creates searchable tags: feature:perps, provider:hyperliquid, network:mainnet + * // Creates searchable context: perps_controller.method:placeOrder, perps_controller.symbol:BTC, perps_controller.operation:validate + */ + #getErrorContext( + method: string, + extra?: Record, + ): PerpsLoggerOptions { + return { + tags: { + feature: PERPS_CONSTANTS.FeatureName, + provider: this.state.activeProvider, + network: this.state.isTestnet ? 'testnet' : 'mainnet', + }, + context: { + name: 'PerpsController', + data: { + method, + ...extra, + }, + }, + }; + } + + /** + * Returns current controller state as PerpsControllerState. + * Used by createServiceContext to avoid deep type instantiation when building stateManager. + * + * @returns The current controller state cast to PerpsControllerState. + */ + #getControllerState(): PerpsControllerState { + return this.state as unknown as PerpsControllerState; + } + + /** + * Build a filter function that mirrors the provider's allowlist/blocklist + * logic so that Terminal API results are filtered identically. + * + * @returns Filter predicate accepting a market symbol. + */ + #buildMarketAllowedFilter(): (symbol: string) => boolean { + const hip3Enabled = this.#hip3Enabled; + const compiledAllowlist = this.#compilePatternsSafely( + this.#hip3AllowlistMarkets, + ); + const compiledBlocklist = this.#compilePatternsSafely( + this.#hip3BlocklistMarkets, + ); + return (symbol: string) => { + const { dex } = parseAssetName(symbol); + return shouldIncludeMarket( + symbol, + dex, + hip3Enabled, + compiledAllowlist, + compiledBlocklist, + ); + }; + } + + /** + * Compile market patterns safely, skipping any that fail validation. + * + * @param patterns - Raw pattern strings from config. + * @returns Compiled patterns (invalid entries silently skipped). + */ + #compilePatternsSafely(patterns: string[]): CompiledMarketPattern[] { + const compiled: CompiledMarketPattern[] = []; + for (const pattern of patterns) { + try { + compiled.push({ pattern, matcher: compileMarketPattern(pattern) }); + } catch { + // Invalid patterns silently skipped — logged at provider level. + } + } + return compiled; + } + + /** + * Create a ServiceContext for dependency injection into services + * Provides all orchestration dependencies (tracing, analytics, state management) + * + * @param method - Method name for error context + * @param additionalContext - Optional additional context (e.g., rewardsController, streamManager) + * @returns ServiceContext with all required dependencies + */ + #createServiceContext( + method: string, + additionalContext?: Partial, + ): ServiceContext { + return { + tracingContext: { + provider: this.state.activeProvider, + isTestnet: this.state.isTestnet, + }, + errorContext: { + controller: 'PerpsController', + method, + }, + stateManager: { + update: (updater: (state: PerpsControllerState) => void) => + this.update(updater), + getState: (): PerpsControllerState => this.#getControllerState(), + }, + ...additionalContext, + } as ServiceContext; + } + + /** + * Ensure TradingService has controller dependencies set. + * RewardsIntegrationService uses messenger internally for controller access. + */ + #ensureTradingServiceDeps(): void { + this.#tradingService.setControllerDependencies({ + rewardsIntegrationService: this.#rewardsIntegrationService, + }); + } + + /** + * Get the currently active provider. + * In aggregated mode, returns AggregatedPerpsProvider which routes to underlying providers. + * In single provider mode, returns HyperLiquidProvider directly. + * + * @returns The active provider (aggregated wrapper or direct provider based on mode) + * @throws Error if provider is not initialized or reinitializing + */ + getActiveProvider(): PerpsProvider { + // Check if we're in the middle of reinitializing + if (this.isCurrentlyReinitializing()) { + this.update((state) => { + state.lastError = PERPS_ERROR_CODES.CLIENT_REINITIALIZING; + state.lastUpdateTimestamp = Date.now(); + }); + throw new Error(PERPS_ERROR_CODES.CLIENT_REINITIALIZING); + } + + // Check if not initialized + if ( + this.state.initializationState !== InitializationState.Initialized || + !this.isInitialized + ) { + this.update((state) => { + state.lastError = PERPS_ERROR_CODES.CLIENT_NOT_INITIALIZED; + state.lastUpdateTimestamp = Date.now(); + }); + throw new Error(PERPS_ERROR_CODES.CLIENT_NOT_INITIALIZED); + } + + // Return the active provider instance (set during initialization based on providerMode) + if (!this.activeProviderInstance) { + this.update((state) => { + state.lastError = PERPS_ERROR_CODES.PROVIDER_NOT_AVAILABLE; + state.lastUpdateTimestamp = Date.now(); + }); + throw new Error(PERPS_ERROR_CODES.PROVIDER_NOT_AVAILABLE); + } + + return this.activeProviderInstance; + } + + /** + * Await in-flight initialization, then return the active provider. + * Use for async action methods (trading, deposits, withdrawals) that should + * tolerate an in-progress cold-start or reconnection instead of failing + * immediately with CLIENT_NOT_INITIALIZED. + * + * Synchronous callers that need fail-fast behaviour should keep using + * getActiveProvider() directly. + * + * @returns The active provider once initialization completes. + */ + async #getActiveProviderWhenReady(): Promise { + while (true) { + const pendingDisconnect = this.#disconnectOperationPromise; + if (pendingDisconnect) { + await pendingDisconnect; + continue; + } + + const pendingReinitialization = this.#reinitializationOperationPromise; + if (pendingReinitialization) { + await pendingReinitialization; + continue; + } + + const pendingInitialization = this.#initializationPromise; + if ( + this.state.initializationState === InitializationState.Initializing && + pendingInitialization + ) { + await pendingInitialization; + continue; + } + + return this.getActiveProvider(); + } + } + + /** + * Get the currently active provider, returning null if not available + * Use this method when the caller can gracefully handle a missing provider + * (e.g., UI components during initialization or reconnection) + * + * @returns The active provider, or null if not initialized/reinitializing + */ + getActiveProviderOrNull(): PerpsProvider | null { + // Return null during reinitialization + if (this.isCurrentlyReinitializing()) { + return null; + } + + // Return null if not initialized + if ( + this.state.initializationState !== InitializationState.Initialized || + !this.isInitialized + ) { + return null; + } + + // Return the active provider instance or null if not found + return this.activeProviderInstance ?? null; + } + + /** + * Get strategy capabilities through the active provider route used by order + * placement. The query waits for in-flight initialization and reports an + * explicit unavailable status when no provider route can answer reliably. + * + * @param params - Market and optional provider route. + * @returns Provider-owned order capabilities. + */ + async getOrderCapabilities( + params: GetOrderCapabilitiesParams, + ): Promise { + let activeProvider: PerpsProvider; + try { + activeProvider = await this.#getActiveProviderWhenReady(); + } catch (error) { + this.#debugLog('PerpsController: Order capabilities unavailable', { + error: ensureError(error, 'PerpsController.getOrderCapabilities') + .message, + }); + return this.#getUnavailableOrderCapabilities( + 'provider_unavailable', + params.providerId, + ); + } + + const resolvedProviderId = + params.providerId ?? this.#getDirectProviderId(activeProvider); + if (this.#hasConflictingProviderRoute(params.providerId, activeProvider)) { + return this.#getUnavailableOrderCapabilities( + 'provider_not_routable', + resolvedProviderId, + ); + } + if (!activeProvider.getOrderCapabilities) { + return this.#getUnavailableOrderCapabilities( + 'not_implemented', + resolvedProviderId, + ); + } + + try { + const capabilities = await activeProvider.getOrderCapabilities(params); + if ( + capabilities.status === 'unavailable' && + capabilities.providerId === undefined && + resolvedProviderId !== undefined + ) { + return { ...capabilities, providerId: resolvedProviderId }; + } + return capabilities; + } catch (error) { + const safeError = ensureError( + error, + 'PerpsController.getOrderCapabilities', + ); + this.#debugLog('PerpsController: Order capabilities unavailable', { + error: safeError.message, + }); + return this.#getUnavailableOrderCapabilities( + 'provider_unavailable', + resolvedProviderId, + ); + } + } + + /** + * Build an unavailable capability response and omit unknown provider IDs. + * + * @param reason - Why capability discovery is unavailable. + * @param providerId - Requested or resolved direct provider identity. + * @returns Unavailable capability response. + */ + #getUnavailableOrderCapabilities( + reason: OrderCapabilitiesUnavailableReason, + providerId: PerpsProviderType | undefined, + ): PerpsOrderCapabilities { + return providerId + ? { status: 'unavailable', providerId, reason } + : { status: 'unavailable', reason }; + } + + /** + * Return the identity of a resolved direct provider when it is known. + * Routing providers do not have one provider identity for every operation. + * + * @param provider - Resolved provider. + * @returns Direct provider identity, if known. + */ + #getDirectProviderId(provider: PerpsProvider): PerpsProviderType | undefined { + if (provider.routesOrdersByProviderId) { + return undefined; + } + return Array.from(this.providers.entries()).find( + ([, candidate]) => candidate === provider, + )?.[0]; + } + + /** + * Check whether a resolved direct provider conflicts with an explicit route. + * Aggregated mode owns its own per-operation routing. + * + * @param providerId - Explicit provider route, if any. + * @param provider - Provider resolved after any in-flight initialization. + * @returns Whether the route conflicts with the active provider mode. + */ + #hasConflictingProviderRoute( + providerId: PerpsProviderType | undefined, + provider: PerpsProvider, + ): boolean { + return ( + providerId !== undefined && + !provider.routesOrdersByProviderId && + providerId !== provider.protocolId + ); + } + + /** + * Resolve one routed order operation after any in-flight initialization. + * + * @param params - Routed operation context. + * @param params.orderType - Order type, when the operation carries one. + * @param params.providerId - Explicit provider route, when supplied. + * @returns The initialized provider that owns the operation. + */ + async #resolveRoutedOrderProvider(params: { + orderType: OrderType | undefined; + providerId: PerpsProviderType | undefined; + }): Promise { + const { orderType, providerId } = params; + const provider = await this.#getActiveProviderWhenReady(); + if (this.#hasConflictingProviderRoute(providerId, provider)) { + throw new Error( + orderType !== undefined && isStrategyOrderType(orderType) + ? PERPS_ERROR_CODES.ORDER_STRATEGY_ROUTE_UNAVAILABLE + : PERPS_ERROR_CODES.PROVIDER_NOT_FOUND, + ); + } + return provider; + } + + /** + * Place a new order + * Thin delegation to TradingService + * + * @param params - The operation parameters. + * @returns The order result with order ID and status. + */ + async placeOrder(params: OrderParams): Promise { + const provider = await this.#resolveRoutedOrderProvider({ + orderType: params.orderType, + providerId: params.providerId, + }); + this.#ensureTradingServiceDeps(); + + const result = await this.#tradingService.placeOrder({ + provider, + params, + context: this.#createServiceContext('placeOrder', { + saveTradeConfiguration: (symbol: string, leverage: number) => + this.saveTradeConfiguration(symbol, leverage), + }), + reportOrderToDataLake: (dataLakeParams) => + this.reportOrderToDataLake(dataLakeParams), + }); + + if (result.success) { + this.clearPendingTradeConfiguration(params.symbol); + } + + return result; + } + + /** + * Edit an existing order + * Thin delegation to TradingService + * + * @param params - The operation parameters. + * @returns The updated order result with order ID and status. + */ + async editOrder(params: EditOrderParams): Promise { + if (isStrategyOrderType(params.newOrder.orderType)) { + return { + success: false, + error: PERPS_ERROR_CODES.ORDER_EDIT_STRATEGY_UNSUPPORTED, + }; + } + + const provider = await this.#resolveRoutedOrderProvider({ + orderType: params.newOrder.orderType, + providerId: params.newOrder.providerId, + }); + this.#ensureTradingServiceDeps(); + + return this.#tradingService.editOrder({ + provider, + params, + context: this.#createServiceContext('editOrder'), + }); + } + + /** + * Cancel an existing order + * + * @param params - The operation parameters. + * @returns The cancellation result with status. + */ + async cancelOrder(params: CancelOrderParams): Promise { + const provider = await this.#resolveRoutedOrderProvider({ + orderType: params.orderType, + providerId: params.providerId, + }); + + return this.#tradingService.cancelOrder({ + provider, + params, + context: this.#createServiceContext('cancelOrder'), + }); + } + + /** + * Read venue-backed TWAP lifecycle records through the active provider. + * Providers without native TWAP history return an empty list. + * + * @returns Current and terminal TWAP schedules with slice fills. + */ + async getTwapOrders(): Promise { + const provider = await this.#getActiveProviderWhenReady(); + return provider.getTwapOrders ? await provider.getTwapOrders() : []; + } + + /** + * Read the active provider's retained Chase lifecycle snapshots. + * Providers without an emulated Chase implementation return an empty list. + * + * @returns Current Chase session snapshots. + */ + async getChaseOrders(): Promise { + const provider = await this.#getActiveProviderWhenReady(); + return provider.getChaseOrders ? await provider.getChaseOrders() : []; + } + + /** + * Stop Chase repricing for app backgrounding without cancelling the current + * resting children. + * + * @returns Chase snapshots after suspension. + * @throws If an aggregated provider cannot suspend every active venue. Other + * providers may already be suspended; callers can retry to reconcile them. + */ + async suspendChaseOrders(): Promise { + const provider = await this.#getActiveProviderWhenReady(); + return provider.suspendChaseOrders + ? await provider.suspendChaseOrders() + : []; + } + + /** + * Cancel multiple orders in parallel + * Batch version of cancelOrder() that cancels multiple orders simultaneously + * + * @param params - The operation parameters. + * @returns The batch cancellation results for each order. + */ + async cancelOrders(params: CancelOrdersParams): Promise { + const provider = await this.#getActiveProviderWhenReady(); + + return this.#tradingService.cancelOrders({ + provider, + params, + context: this.#createServiceContext('cancelOrders', { + getOpenOrders: () => this.getOpenOrders(), + }), + withStreamPause: ( + operation: () => Promise, + channels: string[], + ) => this.#withStreamPause(operation, channels), + }); + } + + /** + * Close a position (partial or full) + * Thin delegation to TradingService + * + * @param params - The operation parameters. + * @returns The order result from the close position request. + */ + async closePosition(params: ClosePositionParams): Promise { + const provider = await this.#resolveRoutedOrderProvider({ + orderType: params.orderType, + providerId: params.providerId, + }); + this.#ensureTradingServiceDeps(); + + return this.#tradingService.closePosition({ + provider, + params, + context: this.#createServiceContext('closePosition', { + getPositions: () => this.getPositions(), + }), + reportOrderToDataLake: (dataLakeParams) => + this.reportOrderToDataLake(dataLakeParams), + }); + } + + /** + * Close multiple positions in parallel + * Batch version of closePosition() that closes multiple positions simultaneously + * + * @param params - The operation parameters. + * @returns The batch close results for each position. + */ + async closePositions( + params: ClosePositionsParams, + ): Promise { + const provider = await this.#getActiveProviderWhenReady(); + this.#ensureTradingServiceDeps(); + + return this.#tradingService.closePositions({ + provider, + params, + context: this.#createServiceContext('closePositions', { + getPositions: () => this.getPositions(), + }), + }); + } + + /** + * Update TP/SL for an existing position + * + * @param params - The operation parameters. + * @returns The order result from the TP/SL update. + */ + async updatePositionTPSL( + params: UpdatePositionTPSLParams, + ): Promise { + const provider = await this.#resolveRoutedOrderProvider({ + orderType: undefined, + providerId: params.providerId, + }); + this.#ensureTradingServiceDeps(); + + return this.#tradingService.updatePositionTPSL({ + provider, + params, + context: this.#createServiceContext('updatePositionTPSL'), + }); + } + + /** + * Update margin for an existing position (add or remove) + * + * @param params - The operation parameters. + * @returns The margin update result. + */ + async updateMargin(params: UpdateMarginParams): Promise { + const provider = await this.#getActiveProviderWhenReady(); + this.#ensureTradingServiceDeps(); + + return this.#tradingService.updateMargin({ + provider, + symbol: params.symbol, + amount: params.amount, + context: this.#createServiceContext('updateMargin'), + }); + } + + /** + * Flip position (reverse direction while keeping size and leverage) + * + * @param params - The operation parameters. + * @returns The order result from the position flip. + */ + async flipPosition(params: FlipPositionParams): Promise { + const provider = await this.#getActiveProviderWhenReady(); + this.#ensureTradingServiceDeps(); + + return this.#tradingService.flipPosition({ + provider, + position: params.position, + trackingData: params.trackingData, + context: this.#createServiceContext('flipPosition'), + }); + } + + /** + * Simplified deposit method that prepares transaction for confirmation screen + * No complex state tracking - just sets a loading flag + * + * @param params - Parameters for the deposit flow + * @param params.amount - Optional deposit amount + * @param params.placeOrder - If true, uses addTransaction instead of submit to avoid navigation + * @returns An object containing a promise that resolves to the transaction hash. + */ + async depositWithConfirmation( + params: DepositWithConfirmationParams = {}, + ): Promise<{ result: Promise }> { + const { amount, placeOrder } = params; + + let currentDepositId: string | undefined; + + try { + const provider = await this.#getActiveProviderWhenReady(); + const { + transaction, + assetChainId, + currentDepositId: depositId, + } = await this.#depositService.prepareTransaction({ provider }); + currentDepositId = depositId; + + // Get current account address via messenger (outside of update() for proper typing) + const evmAccount = getSelectedEvmAccountFromMessenger(this.messenger); + const accountAddress = evmAccount?.address ?? 'unknown'; + + this.update((state) => { + state.lastDepositResult = null; + + // Add deposit request to tracking + const depositRequest = { + id: currentDepositId ?? uuidv4(), + timestamp: Date.now(), + amount: amount ?? '0', // Use provided amount or default to '0' + asset: USDC_SYMBOL, + accountAddress, // Track which account initiated deposit + success: false, // Will be updated when transaction completes + txHash: undefined, + status: 'pending' as TransactionStatus, + source: undefined, + transactionId: undefined, // Will be set to depositId when available + }; + + state.depositRequests.unshift(depositRequest); // Add to beginning of array + }); + + const networkClientId = this.#findNetworkClientIdForChain(assetChainId); + + if (!networkClientId) { + throw new Error( + `No network client found for chain ${assetChainId}. Please add the network first.`, + ); + } + + let result: Promise; + let transactionMeta: { id: string }; + let depositOrderResult: Promise | null = null; + + const defaultTransactionOptions = { + networkClientId, + origin: ORIGIN_METAMASK, + skipInitialGasEstimate: true, + }; + + this.#options.infrastructure.tracer.addBreadcrumb({ + category: 'perps', + message: 'Deposit action started', + level: 'info', + data: { + place_order_after_deposit: placeOrder === true, + }, + }); + + if (placeOrder) { + // Use addTransaction to create transaction without navigating to confirmation screen + const addResult = await this.#submitTransaction(transaction, { + ...defaultTransactionOptions, + type: 'perpsDepositAndOrder', + }); + transactionMeta = addResult.transactionMeta; + // Return transaction ID immediately (fire-and-forget for caller) + result = Promise.resolve(transactionMeta.id); + // Track deposit request lifecycle via the real transaction result + depositOrderResult = addResult.result; + } else { + // submit shows the confirmation screen and returns a promise + // The promise will resolve when transaction completes or reject if cancelled/failed + const submitResult = await this.#submitTransaction(transaction, { + ...defaultTransactionOptions, + type: 'perpsDeposit', + }); + result = submitResult.result; + transactionMeta = submitResult.transactionMeta; + } + + // Store the transaction ID and try to get amount from transaction + this.update((state) => { + state.lastDepositTransactionId = transactionMeta.id; + }); + + // Track the transaction lifecycle only when using submit (deposit-only flow) + if (!placeOrder) { + // At this point, the confirmation modal is shown to the user + // The result promise will resolve/reject based on user action and transaction outcome + + // Track the transaction lifecycle + // The result promise will resolve/reject based on user action and transaction outcome + // Note: We intentionally don't set depositInProgress immediately to avoid + // showing toasts before the user confirms the transaction + + // TODO: @abretonc7s Find a better way to trigger our custom toast notification then having to toggle the state + // How to replace the system notifications? + result + .then((actualTxHash) => { + // Transaction was successfully completed + // Set depositInProgress to true temporarily to show success + this.update((state) => { + state.depositInProgress = true; + state.lastDepositResult = { + success: true, + txHash: actualTxHash, + amount: amount ?? '0', + asset: USDC_SYMBOL, // Default asset for deposits + timestamp: Date.now(), + error: '', + }; + + // Update the deposit request by request ID to avoid race conditions + if (state.depositRequests.length > 0) { + const requestToUpdate = state.depositRequests.find( + (req) => req.id === currentDepositId, + ); + if (requestToUpdate) { + // For deposits, we have a txHash immediately, so mark as completed + // (the transaction hash means the deposit was successful) + requestToUpdate.status = 'completed' as TransactionStatus; + requestToUpdate.success = true; + requestToUpdate.txHash = actualTxHash; + } + } + }); + + // Clear depositInProgress after a short delay + setTimeout(() => { + this.update((state) => { + state.depositInProgress = false; + state.lastDepositTransactionId = null; + }); + }, 100); + + return undefined; + }) + .catch((error) => { + // Check if user denied/cancelled the transaction + const errorMessage = ensureError( + error, + 'PerpsController.initiateDeposit', + ).message; + const userCancelled = + errorMessage.includes('User denied') || + errorMessage.includes('User rejected') || + errorMessage.includes('User cancelled') || + errorMessage.includes('User canceled'); + + if (userCancelled) { + // User cancelled - clear any state, no toast + this.update((state) => { + state.depositInProgress = false; + state.lastDepositTransactionId = null; + // Don't set lastDepositResult - no toast needed + + // Mark deposit request as cancelled + const requestToUpdate = state.depositRequests.find( + (req) => req.id === currentDepositId, + ); + if (requestToUpdate) { + requestToUpdate.status = 'cancelled' as TransactionStatus; + requestToUpdate.success = false; + } + }); + } else { + // Transaction failed after confirmation - show error toast + this.update((state) => { + state.depositInProgress = false; + state.lastDepositTransactionId = null; + state.lastDepositResult = { + success: false, + error: errorMessage, + amount: amount ?? '0', + asset: USDC_SYMBOL, // Default asset for deposits + timestamp: Date.now(), + txHash: '', + }; + + // Update the deposit request by request ID to avoid race conditions + if (state.depositRequests.length > 0) { + const requestToUpdate = state.depositRequests.find( + (req) => req.id === currentDepositId, + ); + if (requestToUpdate) { + requestToUpdate.status = 'failed' as TransactionStatus; + requestToUpdate.success = false; + } + } + }); + } + }); + } else if (depositOrderResult) { + // Track deposit request lifecycle for deposit+order flow + depositOrderResult + .then((actualTxHash) => { + this.update((state) => { + const requestToUpdate = state.depositRequests.find( + (req) => req.id === currentDepositId, + ); + if (requestToUpdate) { + requestToUpdate.status = 'completed' as TransactionStatus; + requestToUpdate.success = true; + requestToUpdate.txHash = actualTxHash; + } + }); + return undefined; + }) + .catch((error) => { + const errorMessage = ensureError( + error, + 'PerpsController.depositWithOrder', + ).message; + const isCancellation = + errorMessage.includes('User denied') || + errorMessage.includes('User rejected') || + errorMessage.includes('User cancelled') || + errorMessage.includes('User canceled'); + this.update((state) => { + const requestToUpdate = state.depositRequests.find( + (req) => req.id === currentDepositId, + ); + if (requestToUpdate) { + requestToUpdate.status = ( + isCancellation ? 'cancelled' : 'failed' + ) as TransactionStatus; + requestToUpdate.success = false; + } + }); + }); + } + + return { + result, + }; + } catch (error) { + // Check if user denied/cancelled the transaction + const errorMessage = ensureError( + error, + 'PerpsController.initiateDeposit', + ).message; + const userCancelled = + errorMessage.includes('User denied') || + errorMessage.includes('User rejected') || + errorMessage.includes('User cancelled') || + errorMessage.includes('User canceled'); + + if (!userCancelled) { + // Only track actual errors, not user cancellations + this.update((state) => { + state.lastDepositTransactionId = null; + // Note: lastDepositResult is already set in the catch block above + + // Mark deposit request as failed if one was created + if (currentDepositId) { + const request = state.depositRequests.find( + (req) => req.id === currentDepositId, + ); + if (request) { + request.status = 'failed' as TransactionStatus; + request.success = false; + } + } + }); + } + throw error; + } + } + + /** + * Same as depositWithConfirmation - prepares transaction for confirmation screen. + * + * @returns A promise that resolves to the string result. + */ + async depositWithOrder(): Promise<{ result: Promise }> { + return this.depositWithConfirmation({ placeOrder: true }); + } + + /** + * Clear the last deposit result after it has been shown to the user + */ + clearDepositResult(): void { + this.update((state) => { + state.lastDepositResult = null; + }); + } + + clearWithdrawResult(): void { + this.update((state) => { + state.lastWithdrawResult = null; + }); + } + + /** + * Update withdrawal request status when it completes, or remove it on failure. + * This is called when a withdrawal is matched with a completed withdrawal from the API. + * When status is `failed`, the request is removed from the queue (not retained). + * + * @param withdrawalId - The withdrawal transaction ID. + * @param status - The current status. + * @param txHash - The transaction hash. + */ + updateWithdrawalStatus( + withdrawalId: string, + status: 'completed' | 'failed', + txHash?: string, + ): void { + let withdrawalAmount: string | undefined; + let shouldTrack = false; + let found = false; + + this.update((state) => { + const withdrawalIndex = state.withdrawalRequests.findIndex( + (request) => request.id === withdrawalId, + ); + + if (withdrawalIndex >= 0) { + found = true; + const request = state.withdrawalRequests[withdrawalIndex]; + withdrawalAmount = request.amount; + shouldTrack = + withdrawalAmount !== undefined && request.status !== status; + + if (status === 'failed') { + state.withdrawalRequests.splice(withdrawalIndex, 1); + state.withdrawInProgress = state.withdrawalRequests.some( + (req) => req.status === 'pending' || req.status === 'bridging', + ); + state.withdrawalProgress = { + progress: 0, + lastUpdated: Date.now(), + activeWithdrawalId: null, + }; + } else { + request.status = status; + request.success = status === 'completed'; + if (txHash) { + request.txHash = txHash; + } + + // Clear withdrawal progress when withdrawal completes + state.withdrawalProgress = { + progress: 0, + lastUpdated: Date.now(), + activeWithdrawalId: null, + }; + } + } + }); + + if (shouldTrack && withdrawalAmount !== undefined) { + this.#getMetrics().trackPerpsEvent( + PerpsAnalyticsEvent.WithdrawalTransaction, + { + [PERPS_EVENT_PROPERTY.STATUS]: + status === 'completed' + ? PERPS_EVENT_VALUE.STATUS.COMPLETED + : PERPS_EVENT_VALUE.STATUS.FAILED, + [PERPS_EVENT_PROPERTY.WITHDRAWAL_AMOUNT]: + Number.parseFloat(withdrawalAmount), + }, + ); + } + + if (found) { + this.#debugLog('PerpsController: Updated withdrawal status', { + withdrawalId, + status, + txHash, + }); + } + } + + /** + * Complete a specific withdrawal detected via transaction history polling (FIFO queue). + * Called when a completed withdrawal appears in the transaction history matching a pending request. + * + * Uses FIFO matching: oldest pending withdrawal is matched with first completed withdrawal + * in history that happened after its submission time. + * + * @param withdrawalRequestId - The ID of the pending withdrawal request to mark as complete. + * @param completedWithdrawal - The completed withdrawal data from the history API. + * @param completedWithdrawal.txHash - The on-chain transaction hash. + * @param completedWithdrawal.amount - The withdrawal amount. + * @param completedWithdrawal.timestamp - The completion timestamp from the history API. + * @param completedWithdrawal.asset - The asset symbol (e.g. USDC). + */ + completeWithdrawalFromHistory( + withdrawalRequestId: string, + completedWithdrawal: { + txHash: string; + amount: string; + timestamp: number; + asset?: string; + }, + ): void { + let didRemove = false; + this.update((state) => { + const requestIndex = state.withdrawalRequests.findIndex( + (req) => req.id === withdrawalRequestId, + ); + + if (requestIndex === -1) { + return; + } + + didRemove = true; + state.withdrawalRequests.splice(requestIndex, 1); + + // Update the FIFO guard. The timestamp is persisted for cross-restart + // protection. The txHashes array (not persisted) accumulates within a + // session to prevent re-matching direct completions and same-millisecond + // API completions. It resets naturally on app restart. + state.lastCompletedWithdrawalTimestamp = completedWithdrawal.timestamp; + state.lastCompletedWithdrawalTxHashes.push(completedWithdrawal.txHash); + + const hasPendingWithdrawals = state.withdrawalRequests.some( + (req) => req.status === 'pending' || req.status === 'bridging', + ); + + state.withdrawInProgress = hasPendingWithdrawals; + + if (!hasPendingWithdrawals) { + state.withdrawalProgress = { + progress: 0, + lastUpdated: Date.now(), + activeWithdrawalId: null, + }; + } + + state.lastUpdateTimestamp = Date.now(); + }); + + if (!didRemove) { + return; + } + + this.#debugLog( + 'PerpsController: Completed withdrawal from transaction history (FIFO)', + { + withdrawalRequestId, + txHash: completedWithdrawal.txHash, + amount: completedWithdrawal.amount, + }, + ); + + this.#getMetrics().trackPerpsEvent( + PerpsAnalyticsEvent.WithdrawalTransaction, + { + [PERPS_EVENT_PROPERTY.STATUS]: PERPS_EVENT_VALUE.STATUS.COMPLETED, + [PERPS_EVENT_PROPERTY.WITHDRAWAL_AMOUNT]: Number.parseFloat( + completedWithdrawal.amount, + ), + }, + ); + } + + /** + * Update withdrawal progress (persistent across navigation) + * + * @param progress - The progress indicator. + * @param activeWithdrawalId - The active withdrawal ID. + */ + updateWithdrawalProgress( + progress: number, + activeWithdrawalId: string | null = null, + ): void { + this.update((state) => { + state.withdrawalProgress = { + progress, + lastUpdated: Date.now(), + activeWithdrawalId, + }; + }); + } + + /** + * Get current withdrawal progress + * + * @returns The withdrawal progress, last update timestamp, and active withdrawal ID. + */ + getWithdrawalProgress(): { + progress: number; + lastUpdated: number; + activeWithdrawalId: string | null; + } { + return this.state.withdrawalProgress; + } + + /** + * Withdraw funds from trading account + * + * The withdrawal process varies by provider and may involve: + * - Direct on-chain transfers + * - Bridge operations + * - Multi-step validation processes + * + * Check the specific provider documentation for detailed withdrawal flows. + * + * @param params Withdrawal parameters + * @returns WithdrawResult with withdrawal ID and tracking info + */ + async withdraw(params: WithdrawParams): Promise { + const provider = await this.#getActiveProviderWhenReady(); + + return this.#accountService.withdraw({ + provider, + params, + context: this.#createServiceContext('withdraw'), + refreshAccountState: async () => { + await this.getAccountState({ source: 'post_withdrawal' }); + }, + }); + } + + /** + * Get current positions + * Thin delegation to MarketDataService + * + * For standalone mode, bypasses getActiveProvider() to allow position queries + * without full perps initialization (e.g., for showing positions on token details page) + * + * @param params - The operation parameters. + * @returns Array of open positions for the active provider. + */ + async getPositions(params?: GetPositionsParams): Promise { + // For standalone mode, access provider directly without initialization check + // This allows discovery use cases (checking if user has positions) without full perps setup + if (params?.standalone && params.userAddress) { + // Use activeProviderInstance if available (respects provider abstraction) + // Fallback to cached standalone provider for pre-initialization discovery + // TODO: When adding new providers (MYX), consider a provider factory pattern + const provider = + this.activeProviderInstance ?? this.#getOrCreateStandaloneProvider(); + const operation = provider.getPositions(params); + return provider === this.#standaloneProvider + ? this.#trackStandaloneProviderOperation(provider, operation) + : operation; + } + + const provider = this.getActiveProvider(); + return this.#marketDataService.getPositions({ + provider, + params, + context: this.#createServiceContext('getPositions'), + }); + } + + /** + * Get historical user fills (trade executions) + * Thin delegation to MarketDataService + * + * @param params - The operation parameters. + * @param options - Optional call modifiers. + * @param options.forceRefresh - Bypass the request-coalesce cache + * end-to-end (user-initiated refresh). + * @returns Array of historical trade executions (fills). + */ + async getOrderFills( + params?: GetOrderFillsParams, + options?: { forceRefresh?: boolean }, + ): Promise { + const provider = this.getActiveProvider(); + return this.#marketDataService.getOrderFills({ + provider, + params, + context: this.#createServiceContext('getOrderFills'), + forceRefresh: options?.forceRefresh, + }); + } + + /** + * Get historical user orders (order lifecycle) + * Thin delegation to MarketDataService + * + * @param params - The operation parameters. + * @param options - Optional call modifiers. + * @param options.forceRefresh - Bypass the request-coalesce cache + * end-to-end (user-initiated refresh). + * @returns Array of historical orders. + */ + async getOrders( + params?: GetOrdersParams, + options?: { forceRefresh?: boolean }, + ): Promise { + const provider = this.getActiveProvider(); + return this.#marketDataService.getOrders({ + provider, + params, + context: this.#createServiceContext('getOrders'), + forceRefresh: options?.forceRefresh, + }); + } + + /** + * Get currently open orders (real-time status) + * Thin delegation to MarketDataService + * + * For standalone mode, bypasses getActiveProvider() to allow open order queries + * without full perps initialization (e.g., for background preloading) + * + * @param params - The operation parameters. + * @returns A promise that resolves to the result. + */ + async getOpenOrders(params?: GetOrdersParams): Promise { + // For standalone mode, access provider directly without initialization check + if (params?.standalone && params.userAddress) { + const provider = + this.activeProviderInstance ?? this.#getOrCreateStandaloneProvider(); + const operation = provider.getOpenOrders(params); + return provider === this.#standaloneProvider + ? this.#trackStandaloneProviderOperation(provider, operation) + : operation; + } + + const provider = this.getActiveProvider(); + return this.#marketDataService.getOpenOrders({ + provider, + params, + context: this.#createServiceContext('getOpenOrders'), + }); + } + + /** + * Get historical user funding history (funding payments) + * Thin delegation to MarketDataService + * + * @param params - The operation parameters. + * @param options - Optional call modifiers. + * @param options.forceRefresh - Bypass the request-coalesce cache + * end-to-end (user-initiated refresh). + * @returns Array of historical funding payments. + */ + async getFunding( + params?: GetFundingParams, + options?: { forceRefresh?: boolean }, + ): Promise { + const provider = this.getActiveProvider(); + return this.#marketDataService.getFunding({ + provider, + params, + context: this.#createServiceContext('getFunding'), + forceRefresh: options?.forceRefresh, + }); + } + + /** + * Get account state (balances, etc.) + * Thin delegation to MarketDataService + * + * For standalone mode, bypasses getActiveProvider() to allow account state queries + * without full perps initialization (e.g., for checking if user has perps funds) + * + * @param params - The operation parameters. + * @returns A promise that resolves to the result. + */ + async getAccountState(params?: GetAccountStateParams): Promise { + // For standalone mode, access provider directly without initialization check + // This allows discovery use cases (checking if user has perps funds) without full perps setup + if (params?.standalone && params.userAddress) { + // Use activeProviderInstance if available (respects provider abstraction) + // Fallback to cached standalone provider for pre-initialization discovery + const provider = + this.activeProviderInstance ?? this.#getOrCreateStandaloneProvider(); + const operation = provider.getAccountState(params); + return provider === this.#standaloneProvider + ? this.#trackStandaloneProviderOperation(provider, operation) + : operation; + } + + const provider = this.getActiveProvider(); + return this.#marketDataService.getAccountState({ + provider, + params, + context: this.#createServiceContext('getAccountState'), + }); + } + + /** + * Get historical portfolio data + * Thin delegation to MarketDataService + * + * @param params - The operation parameters. + * @returns The historical portfolio data points. + */ + async getHistoricalPortfolio( + params?: GetHistoricalPortfolioParams, + ): Promise { + const provider = this.getActiveProvider(); + return this.#marketDataService.getHistoricalPortfolio({ + provider, + params, + context: this.#createServiceContext('getHistoricalPortfolio'), + }); + } + + /** + * Get available markets with optional filtering + * Thin delegation to MarketDataService + * + * For standalone mode, bypasses getActiveProvider() to allow market discovery + * without full perps initialization (e.g., for discovery banners on spot screens) + * + * @param params - The operation parameters. + * @returns Array of available markets matching the filter criteria. + */ + async getMarkets(params?: GetMarketsParams): Promise { + const isMarketAllowed = this.#buildMarketAllowedFilter(); + if (params?.standalone) { + const provider = + this.activeProviderInstance ?? this.#getOrCreateStandaloneProvider(); + const operation = this.#marketDataService.getMarkets({ + provider, + params, + context: this.#createServiceContext('getMarkets'), + isMarketAllowed, + }); + return provider === this.#standaloneProvider + ? this.#trackStandaloneProviderOperation(provider, operation) + : operation; + } + + const provider = this.getActiveProvider(); + return this.#marketDataService.getMarkets({ + provider, + params, + context: this.#createServiceContext('getMarkets'), + isMarketAllowed, + }); + } + + /** + * Get market data with prices (includes price, volume, 24h change). + * Optionally filter by category, sort, and limit the results. + * + * For standalone mode, bypasses getActiveProvider() to allow market data queries + * without full perps initialization (e.g., for background preloading on app start) + * + * @param params - The operation parameters. + * @param params.standalone - Whether to use standalone mode. + * @param params.categories - Filter to markets matching any of these categories. + * @param params.sortBy - Sort results by this field. + * @param params.direction - Sort direction (default: desc). + * @param params.limit - Maximum number of results to return. + * @returns A promise that resolves to the market data. + */ + async getMarketDataWithPrices( + params?: GetMarketDataWithPricesParams, + ): Promise { + const globalSnapshot = this.#buildGlobalSnapshotContext(); + const context = this.#createServiceContext('getMarketDataWithPrices', { + ...(globalSnapshot && { globalSnapshot }), + }); + if (params?.standalone) { + const provider = + this.activeProviderInstance ?? this.#getOrCreateStandaloneProvider(); + const operation = this.#marketDataService.getMarketDataWithPrices({ + provider, + params, + context, + }); + return provider === this.#standaloneProvider + ? this.#trackStandaloneProviderOperation(provider, operation) + : operation; + } + + const provider = this.getActiveProvider(); + return this.#marketDataService.getMarketDataWithPrices({ + provider, + params, + context, + }); + } + + /** + * Capture the exact static identity required to adopt an atomic snapshot. + * Dynamic DEX discovery and non-Hyperliquid provider modes deliberately opt + * out so they retain the provider path. + * + * @returns Snapshot identity plus a race guard, or undefined when unsafe. + */ + #buildGlobalSnapshotContext(): ServiceContext['globalSnapshot'] { + const snapshotConfigured = + Boolean(this.#options.infrastructure.terminalApi?.globalSnapshotUrl) || + typeof this.#options.infrastructure.terminalMarketService + ?.fetchGlobalSnapshot === 'function'; + if (!snapshotConfigured || this.state.activeProvider !== 'hyperliquid') { + return undefined; + } + + const enabledDexes = this.#getStaticSnapshotDexes(); + if (!enabledDexes) { + return undefined; + } + const { isTestnet, hip3ConfigVersion } = this.state; + return { + request: { + provider: 'hyperliquid', + network: isTestnet ? 'testnet' : 'mainnet', + enabledDexes, + }, + isCurrent: () => + this.state.activeProvider === 'hyperliquid' && + this.state.isTestnet === isTestnet && + this.state.hip3ConfigVersion === hip3ConfigVersion, + isMarketAllowed: this.#buildMarketAllowedFilter(), + }; + } + + #getStaticSnapshotDexes(): string[] | undefined { + if (!this.#hip3Enabled) { + return ['main']; + } + if (this.state.isTestnet) { + return TESTNET_HIP3_CONFIG.AutoDiscoverAll + ? undefined + : canonicalizeHyperLiquidDexes(TESTNET_HIP3_CONFIG.EnabledDexs); + } + if (MAINNET_HIP3_CONFIG.AutoDiscoverAll) { + return undefined; + } + + const dexes = new Set(); + for (const pattern of this.#hip3AllowlistMarkets) { + const colonIndex = pattern.indexOf(':'); + if (colonIndex <= 0) { + if (/^[a-z][a-z0-9]*$/iu.test(pattern)) { + dexes.add(pattern.toLowerCase()); + continue; + } + return undefined; + } + const dex = pattern.slice(0, colonIndex); + if (dex && /^[a-z0-9][a-z0-9-]*$/u.test(dex)) { + dexes.add(dex); + } else { + return undefined; + } + } + return canonicalizeHyperLiquidDexes(dexes); + } + + // ============================================================================ + // Market Data Preload (client-agnostic background caching) + // ============================================================================ + + /** State paths that the preload stateChange handler reads. */ + static readonly #preloadWatchedPaths = new Set([ + 'isTestnet', + 'hip3ConfigVersion', + ]); + + #preloadTimer: ReturnType | null = null; + + #preloadStartRequested = false; + + #isPreloading = false; + + #marketPreloadQueued = false; + + #isPreloadingUserData = false; + + #userPreloadQueued = false; + + #lifecycleGeneration = 0; + + readonly #userSnapshotRequests = new Map< + string, + { provider: PerpsProvider; promise: Promise } + >(); + + #preloadStateUnsubscribe: (() => void) | null = null; + + #accountChangeUnsubscribe: (() => void) | null = null; + + #previousIsTestnet: boolean | null = null; + + #previousHip3ConfigVersion: number | null = null; + + static readonly #preloadRefreshMs = 5 * 60 * 1000; // 5 min + + static readonly #preloadGuardMs = 30_000; // 30s debounce + + /** + * Synchronously hydrate in-memory caches from disk-persisted snapshots. + * Uses the sync MMKV API (~1ms) so data is available before any hook reads. + * Falls back to no-op when getItemSync is not available (e.g. E2E). + * All computed updates are applied in a single this.update() call to avoid + * triggering state subscribers once per provider entry. + */ + #hydrateCacheFromDiskSync(): void { + const { marketUpdates, userUpdates, stats } = hydrateFromDiskSync( + this.#options.infrastructure.diskCache, + this.state.cachedMarketDataByProvider, + this.state.cachedUserDataByProvider, + PerpsController.#preloadGuardMs, + ); + + const hasMarketUpdates = Object.keys(marketUpdates).length > 0; + const hasUserUpdates = Object.keys(userUpdates).length > 0; + if (hasMarketUpdates || hasUserUpdates) { + this.update((state) => { + if (hasMarketUpdates) { + Object.assign(state.cachedMarketDataByProvider, marketUpdates); + } + if (hasUserUpdates) { + Object.assign(state.cachedUserDataByProvider, userUpdates); + } + }); + } + + this.#debugLog('PerpsController: Disk cache hydrated (sync)', { + markets: stats.marketCount, + positions: stats.userPositions, + orders: stats.userOrders, + duration_ms: stats.durationMs, + }); + } + + /** Persist the latest selected-account snapshot for each provider/network. */ + #persistUserCacheToDisk(): void { + const entries: DiskCacheUserEntry[] = []; + + for (const [cacheKey, entry] of Object.entries( + this.state.cachedUserDataByProvider, + )) { + const [providerId, network] = cacheKey.split(':'); + if ( + !providerId || + (network !== 'mainnet' && network !== 'testnet') || + providerId === 'aggregated' + ) { + continue; + } + entries.push({ + providerNetworkKey: `${providerId}:${network}`, + address: entry.address, + positions: entry.positions, + orders: entry.orders, + accountState: entry.accountState, + timestamp: entry.timestamp, + ...(entry.hip3ConfigVersion !== undefined && { + hip3ConfigVersion: entry.hip3ConfigVersion, + }), + ...(entry.dexes !== undefined && { dexes: entry.dexes }), + }); + } + + this.#userDiskWrite = this.#userDiskWrite + .then(() => + persistUserEntriesToDisk( + this.#options.infrastructure.diskCache, + entries, + ), + ) + .catch(() => { + // Disk persistence is best-effort and must not block live data. + }); + } + + /** + * Start background market data preloading. + * Fetches market data immediately and refreshes every 5 minutes. + * Watches for isTestnet and hip3ConfigVersion changes to re-preload. + */ + startMarketDataPreload(): void { + this.#preloadStartRequested = true; + if (this.#disconnectOperationPromise) { + this.#debugLog( + 'PerpsController: Disconnect in progress, deferring market data preload', + ); + return; + } + if (this.#preloadTimer) { + this.#debugLog('PerpsController: Preload already started, skipping'); + return; + } + + this.#debugLog('PerpsController: Starting market data preload'); + + // Track current values for change detection + this.#previousIsTestnet = this.state.isTestnet; + this.#previousHip3ConfigVersion = this.state.hip3ConfigVersion; + + // Immediate preload + this.#performMarketDataPreload().catch(() => { + /* fire-and-forget */ + }); + this.#performUserDataPreload().catch(() => { + /* fire-and-forget */ + }); + + // Periodic refresh + this.#preloadTimer = setInterval(() => { + this.#performMarketDataPreload().catch(() => { + /* fire-and-forget */ + }); + this.#performUserDataPreload().catch(() => { + /* fire-and-forget */ + }); + }, PerpsController.#preloadRefreshMs); + + // Watch for isTestnet / hip3ConfigVersion changes + const handler: StateChangeListener = ( + _state, + patches, + ) => { + // Early-return when no watched field changed (skips ~46 unrelated updates) + const hasRelevantChange = patches.some( + (patch) => + typeof patch.path[0] === 'string' && + PerpsController.#preloadWatchedPaths.has(patch.path[0]), + ); + if (!hasRelevantChange) { + return; + } + + const currentIsTestnet = this.state.isTestnet; + const currentHip3Version = this.state.hip3ConfigVersion; + + const testnetChanged = currentIsTestnet !== this.#previousIsTestnet; + const hip3Changed = + currentHip3Version !== this.#previousHip3ConfigVersion; + + if (testnetChanged || hip3Changed) { + this.#debugLog( + 'PerpsController: Network/config changed, re-preloading', + { + testnetChanged, + hip3Changed, + isTestnet: currentIsTestnet, + hip3ConfigVersion: currentHip3Version, + }, + ); + + this.#previousIsTestnet = currentIsTestnet; + this.#previousHip3ConfigVersion = currentHip3Version; + + // No need to clear user data cache — per-provider keys include the + // network, so different networks don't collide. Re-preload will + // populate the new key. + + this.#performMarketDataPreload().catch(() => { + /* fire-and-forget */ + }); + this.#performUserDataPreload().catch(() => { + /* fire-and-forget */ + }); + } + }; + + this.messenger.subscribe('PerpsController:stateChanged', handler); + this.#preloadStateUnsubscribe = (): void => { + this.messenger.unsubscribe('PerpsController:stateChanged', handler); + }; + + // Watch for selected account changes and selected account group changes. + const accountChangeHandler = (): void => { + const evmAccount = getSelectedEvmAccountFromMessenger(this.messenger); + const currentAddress = evmAccount?.address ?? null; + this.#debugLog('PerpsController: account cache selection', { + address: currentAddress?.toLowerCase() ?? null, + availableKeys: Object.keys(this.state.cachedUserDataByProvider).sort(), + }); + + // The address guard makes the previous entry unreadable immediately; + // refresh replaces it under the existing provider/network key. + if (currentAddress) { + this.#performUserDataPreload().catch(() => { + /* fire-and-forget */ + }); + } + }; + this.messenger.subscribe( + 'AccountsController:selectedAccountChange', + accountChangeHandler, + ); + this.messenger.subscribe( + 'AccountTreeController:selectedAccountGroupChange', + accountChangeHandler, + ); + this.#accountChangeUnsubscribe = (): void => { + this.messenger.unsubscribe( + 'AccountsController:selectedAccountChange', + accountChangeHandler, + ); + this.messenger.unsubscribe( + 'AccountTreeController:selectedAccountGroupChange', + accountChangeHandler, + ); + }; + } + + /** + * Stop background market data preloading. + */ + stopMarketDataPreload(): void { + this.#debugLog('PerpsController: Stopping market data preload'); + this.#preloadStartRequested = false; + if (this.#preloadTimer) { + clearInterval(this.#preloadTimer); + this.#preloadTimer = null; + } + if (this.#preloadStateUnsubscribe) { + this.#preloadStateUnsubscribe(); + this.#preloadStateUnsubscribe = null; + } + if (this.#accountChangeUnsubscribe) { + this.#accountChangeUnsubscribe(); + this.#accountChangeUnsubscribe = null; + } + this.#previousIsTestnet = null; + this.#previousHip3ConfigVersion = null; + this.#marketPreloadQueued = false; + this.#userPreloadQueued = false; + this.#cleanupStandaloneProvider().catch(() => { + /* fire-and-forget to preserve sync signature */ + }); + } + + /** + * Perform a single market data preload (best-effort, no throw). + */ + async #performMarketDataPreload(): Promise { + if (this.#isPreloading) { + this.#marketPreloadQueued = true; + return; + } + + // Skip preloading during provider/network reinitialisation. + // The activeProviderInstance still points to the OLD network's provider + // until init() completes, so fetching now would store stale data under + // the NEW network's cache key. + if (this.#isReinitializing) { + return; + } + + // Determine actual provider and cache key for debounce + const actualProviderId = this.activeProviderInstance + ? this.state.activeProvider // includes 'aggregated' + : 'hyperliquid'; + const cacheKey = buildProviderCacheKey( + actualProviderId, + this.state.isTestnet, + ); + const preloadContext = { + activeProvider: this.state.activeProvider, + isTestnet: this.state.isTestnet, + hip3ConfigVersion: this.state.hip3ConfigVersion, + lifecycleGeneration: this.#lifecycleGeneration, + }; + const isCurrent = (): boolean => + this.#lifecycleGeneration === preloadContext.lifecycleGeneration && + this.state.activeProvider === preloadContext.activeProvider && + this.state.isTestnet === preloadContext.isTestnet && + this.state.hip3ConfigVersion === preloadContext.hip3ConfigVersion; + const staticSnapshotDexes = this.#getStaticSnapshotDexes(); + + const now = Date.now(); + const existingEntry = this.state.cachedMarketDataByProvider[cacheKey]; + if ( + existingEntry && + this.#isMarketCacheEntryCurrent(actualProviderId, existingEntry) && + now - existingEntry.timestamp < PerpsController.#preloadGuardMs + ) { + return; + } + + this.#isPreloading = true; + const traceId = uuidv4(); + const preloadStart = performance.now(); + let traceData: + | { success: boolean; marketCount?: number; error?: string } + | undefined; + + try { + this.#options.infrastructure.tracer.trace({ + name: PerpsTraceNames.MarketDataPreload, + id: traceId, + op: PerpsTraceOperations.Operation, + tags: { + provider: this.state.activeProvider, + isTestnet: this.state.isTestnet, + }, + }); + + this.#debugLog('PerpsController: Fetching market data in background'); + this.#debugLog('PerpsController: rest_preload_start'); + const data = await this.getMarketDataWithPrices({ standalone: true }); + this.#debugLog('PerpsController: rest_preload_end', { + duration_ms: Math.round(performance.now() - preloadStart), + markets: data.length, + }); + + if (!isCurrent()) { + traceData = { + success: false, + error: 'Global snapshot preload context changed', + }; + this.#debugLog( + 'PerpsController: Discarding stale global snapshot preload', + ); + return; + } + + // Store under per-provider key(s) + const ts = Date.now(); + const sourceExpiries = data.flatMap((market) => + market.dataSource === 'terminal-global-snapshot-mark' && + typeof market.sourceExpiresAt === 'number' + ? [market.sourceExpiresAt] + : [], + ); + const sourceExpiresAt = + data.length > 0 && sourceExpiries.length === data.length + ? Math.min(...sourceExpiries) + : undefined; + const snapshotCacheIdentity = + sourceExpiresAt !== undefined && staticSnapshotDexes + ? { + sourceExpiresAt, + hip3ConfigVersion: preloadContext.hip3ConfigVersion, + dexes: staticSnapshotDexes, + } + : {}; + const marketDiskEntries: { + providerNetworkKey: string; + data: PerpsMarketData[]; + timestamp: number; + }[] = []; + if (!isCurrent()) { + traceData = { + success: false, + error: 'Global snapshot preload context changed', + }; + return; + } + if ( + this.state.activeProvider === 'aggregated' && + this.activeProviderInstance + ) { + // Split returned data by providerId and store each slice + const fallbackProviderId = 'hyperliquid'; // default for items missing providerId + const byProvider = new Map(); + for (const item of data) { + const pid = item.providerId ?? fallbackProviderId; + const existing = byProvider.get(pid); + if (existing) { + existing.push(item); + } else { + byProvider.set(pid, [item]); + } + } + this.update((state) => { + for (const [pid, slice] of byProvider) { + const key = buildProviderCacheKey(pid, this.state.isTestnet); + marketDiskEntries.push({ + providerNetworkKey: key, + data: slice, + timestamp: ts, + }); + state.cachedMarketDataByProvider[key] = { + data: slice, + timestamp: ts, + }; + } + // Write aggregated sentinel so the staleness guard sees it + state.cachedMarketDataByProvider[cacheKey] = { + data: [], // sentinel — real data is in per-provider keys + timestamp: ts, + }; + }); + } else { + marketDiskEntries.push({ + providerNetworkKey: cacheKey, + data, + timestamp: ts, + }); + this.update((state) => { + state.cachedMarketDataByProvider[cacheKey] = { + data, + timestamp: ts, + ...snapshotCacheIdentity, + }; + }); + } + + persistMarketEntriesToDisk( + this.#options.infrastructure.diskCache, + marketDiskEntries, + ); + + this.#debugLog('PerpsController: Market data preloaded', { + marketCount: data.length, + }); + + traceData = { success: true, marketCount: data.length }; + + this.#options.infrastructure.tracer.setMeasurement( + PerpsMeasurementName.PerpsMarketDataPreload, + performance.now() - preloadStart, + 'millisecond', + traceId, + ); + } catch (error) { + traceData = { + success: false, + error: ensureError(error, 'PerpsController.performMarketDataPreload') + .message, + }; + this.#logError( + ensureError(error, 'PerpsController.performMarketDataPreload'), + this.#getErrorContext('performMarketDataPreload', { + message: 'Background preload failed', + }), + ); + } finally { + this.#options.infrastructure.tracer.endTrace({ + name: PerpsTraceNames.MarketDataPreload, + id: traceId, + data: traceData, + }); + this.#isPreloading = false; + if (this.#marketPreloadQueued && this.#preloadTimer) { + this.#marketPreloadQueued = false; + this.#performMarketDataPreload().catch(() => { + // Background preload is best-effort. + }); + } + } + } + + /** + * Perform a single user data preload (best-effort, no throw). + * Fetches positions, open orders, and account state via lightweight REST calls. + */ + async #performUserDataPreload(): Promise { + if (this.#isPreloadingUserData) { + this.#userPreloadQueued = true; + return; + } + + if (this.#isReinitializing) { + return; + } + + // Get current user address + const evmAccount = getSelectedEvmAccountFromMessenger(this.messenger); + if (!evmAccount?.address) { + return; + } + + const userAddress = evmAccount.address; + const { activeProvider, isTestnet, hip3ConfigVersion } = this.state; + const lifecycleGeneration = this.#lifecycleGeneration; + const { activeProviderInstance } = this; + const hyperliquidDexes = this.#getStaticSnapshotDexes(); + const isCurrent = (): boolean => { + let currentAddress: string | undefined; + try { + currentAddress = getSelectedEvmAccountFromMessenger( + this.messenger, + )?.address; + } catch { + return false; + } + return ( + this.#lifecycleGeneration === lifecycleGeneration && + this.state.activeProvider === activeProvider && + this.activeProviderInstance === activeProviderInstance && + this.state.isTestnet === isTestnet && + this.state.hip3ConfigVersion === hip3ConfigVersion && + currentAddress?.toLowerCase() === userAddress.toLowerCase() + ); + }; + + // Determine actual provider (same logic as market preload) + const actualProviderId = activeProviderInstance + ? activeProvider // includes 'aggregated' + : 'hyperliquid'; + const providerNetworkKey = buildProviderCacheKey( + actualProviderId, + isTestnet, + ); + + // Skip if cache is fresh and for same account + const now = Date.now(); + const existingEntry = + this.state.cachedUserDataByProvider[providerNetworkKey]; + const hasMatchingCache = + existingEntry !== undefined && + this.#isUserCacheIdentityCurrent( + actualProviderId, + existingEntry, + userAddress, + ); + const cacheAgeMs = existingEntry ? now - existingEntry.timestamp : null; + const websocketState = this.getWebSocketConnectionState(); + let selectedEntryKey: string | null = null; + if (this.state.cachedUserDataByProvider[providerNetworkKey]) { + selectedEntryKey = providerNetworkKey; + } + this.#debugLog('PerpsController: user cache preload decision', { + requestedKey: providerNetworkKey, + selectedEntryKey, + availableKeys: Object.keys(this.state.cachedUserDataByProvider).sort(), + hasMatchingCache, + cacheAgeMs, + websocketState, + }); + if ( + existingEntry && + hasMatchingCache && + now - existingEntry.timestamp < PerpsController.#preloadGuardMs + ) { + return; + } + + if ( + hasMatchingCache && + this.getWebSocketConnectionState() === WebSocketConnectionState.Connected + ) { + return; + } + + this.#isPreloadingUserData = true; + const traceId = uuidv4(); + const preloadStart = performance.now(); + let traceData: + | { + success: boolean; + positionCount?: number; + orderCount?: number; + error?: string; + } + | undefined; + const staleContextError = 'User data preload context changed'; + const discardStalePreload = (): boolean => { + if (isCurrent()) { + return false; + } + traceData = { success: false, error: staleContextError }; + this.#debugLog('PerpsController: Discarding stale user data preload'); + return true; + }; + + try { + this.#options.infrastructure.tracer.trace({ + name: PerpsTraceNames.UserDataPreload, + id: traceId, + op: PerpsTraceOperations.Operation, + tags: { + provider: activeProvider, + isTestnet, + }, + }); + + this.#debugLog('PerpsController: Fetching user data in background'); + + if (activeProvider === 'hyperliquid') { + const snapshot = await this.getUserDataSnapshot(); + if (discardStalePreload()) { + return; + } + this.#debugLog('PerpsController: User data preloaded', { + positionCount: snapshot.positions.length, + orderCount: snapshot.orders.length, + totalBalance: snapshot.accountState.totalBalance, + }); + traceData = { + success: true, + positionCount: snapshot.positions.length, + orderCount: snapshot.orders.length, + }; + this.#options.infrastructure.tracer.setMeasurement( + PerpsMeasurementName.PerpsUserDataPreload, + performance.now() - preloadStart, + 'millisecond', + traceId, + ); + return; + } + + const [positions, orders, accountState] = await Promise.all([ + this.getPositions({ standalone: true, userAddress }), + this.getOpenOrders({ standalone: true, userAddress }), + this.getAccountState({ standalone: true, userAddress }), + ]); + + if (discardStalePreload()) { + return; + } + + if (activeProvider === 'aggregated' && activeProviderInstance) { + // Split by providerId and write one cache entry per provider key + // (mirrors the market-data preload pattern at ~line 2976) + const ts = Date.now(); + type UserDataBucket = { + positions: typeof positions; + orders: typeof orders; + accountState: typeof accountState | null; + }; + const fallbackProviderId = 'hyperliquid'; // default for items missing providerId + const byProvider = new Map(); + + const ensureBucket = (pid: string): UserDataBucket => { + let bucket = byProvider.get(pid); + if (!bucket) { + bucket = { positions: [], orders: [], accountState: null }; + byProvider.set(pid, bucket); + } + return bucket; + }; + + for (const pos of positions) { + ensureBucket(pos.providerId ?? fallbackProviderId).positions.push( + pos, + ); + } + + for (const order of orders) { + ensureBucket(order.providerId ?? fallbackProviderId).orders.push( + order, + ); + } + + // AccountState — assign to its provider bucket + ensureBucket( + accountState.providerId ?? fallbackProviderId, + ).accountState = accountState; + + if (discardStalePreload()) { + return; + } + this.update((state) => { + for (const [pid, data] of byProvider) { + const key = buildProviderCacheKey(pid, isTestnet); + state.cachedUserDataByProvider[key] = { + ...data, + timestamp: ts, + address: userAddress, + ...(pid === 'hyperliquid' && + hyperliquidDexes && { + hip3ConfigVersion, + dexes: hyperliquidDexes, + }), + }; + } + // Write aggregated sentinel so the staleness guard sees it + state.cachedUserDataByProvider[providerNetworkKey] = { + positions: [], + orders: [], + accountState: null, + timestamp: ts, + address: userAddress, + }; + }); + + this.#persistUserCacheToDisk(); + } else { + // Single provider — store directly under its key + const ts = Date.now(); + if (discardStalePreload()) { + return; + } + this.update((state) => { + state.cachedUserDataByProvider[providerNetworkKey] = { + positions, + orders, + accountState, + timestamp: ts, + address: userAddress, + ...(actualProviderId === 'hyperliquid' && + hyperliquidDexes && { + hip3ConfigVersion, + dexes: hyperliquidDexes, + }), + }; + }); + + this.#persistUserCacheToDisk(); + } + + this.#debugLog('PerpsController: User data preloaded', { + positionCount: positions.length, + orderCount: orders.length, + totalBalance: accountState.totalBalance, + }); + + traceData = { + success: true, + positionCount: positions.length, + orderCount: orders.length, + }; + + this.#options.infrastructure.tracer.setMeasurement( + PerpsMeasurementName.PerpsUserDataPreload, + performance.now() - preloadStart, + 'millisecond', + traceId, + ); + } catch (error) { + if (discardStalePreload()) { + return; + } + traceData = { + success: false, + error: ensureError(error, 'PerpsController.performUserDataPreload') + .message, + }; + this.#logError( + ensureError(error, 'PerpsController.performUserDataPreload'), + this.#getErrorContext('performUserDataPreload', { + message: 'Background user data preload failed', + }), + ); + } finally { + this.#options.infrastructure.tracer.endTrace({ + name: PerpsTraceNames.UserDataPreload, + id: traceId, + data: traceData, + }); + this.#isPreloadingUserData = false; + if (this.#userPreloadQueued && this.#preloadTimer) { + this.#userPreloadQueued = false; + this.#performUserDataPreload().catch(() => { + // Background preload is best-effort. + }); + } + } + } + + /** + * Get list of available HIP-3 builder-deployed DEXs + * + * @param params - Optional parameters for filtering + * @returns Array of DEX names + */ + async getAvailableDexs(params?: GetAvailableDexsParams): Promise { + const provider = this.getActiveProvider(); + const context = this.#createServiceContext('getAvailableDexs'); + return this.#marketDataService.getAvailableDexs({ + provider, + params, + context, + }); + } + + /** + * Fetch historical candle data + * Thin delegation to MarketDataService + * + * @param options - The configuration options. + * @param options.symbol - The trading pair symbol. + * @param options.interval - The candle interval period. + * @param options.limit - Maximum number of items to fetch. + * @param options.endTime - End timestamp in milliseconds. + * @returns The historical candle data for the requested symbol and interval. + */ + async fetchHistoricalCandles(options: { + symbol: string; + interval: CandlePeriod; + limit?: number; + endTime?: number; + }): Promise { + const { symbol, interval, limit = 100, endTime } = options; + const provider = this.getActiveProvider(); + return this.#marketDataService.fetchHistoricalCandles({ + provider, + symbol, + interval, + limit, + endTime, + context: this.#createServiceContext('fetchHistoricalCandles'), + }); + } + + /** + * Calculate liquidation price for a position + * Uses provider-specific formulas based on protocol rules + * + * @param params - The operation parameters. + * @returns A promise that resolves to the string result. + */ + async calculateLiquidationPrice( + params: LiquidationPriceParams, + ): Promise { + const provider = this.getActiveProvider(); + const context = this.#createServiceContext('calculateLiquidationPrice'); + return this.#marketDataService.calculateLiquidationPrice({ + provider, + params, + context, + }); + } + + /** + * Project the isolated position that would remain after a proposed order. + * Margin and liquidation availability are independent: a missing liquidation + * does not hide a valid margin projection. Cross-margin returns unsupported. + * + * @param params - Live position plus the proposed order. + * @returns Discriminated preview of the resulting position. + */ + async previewPositionModify( + params: PositionModifyPreviewParams, + ): Promise { + const provider = this.getActiveProvider(); + const context = this.#createServiceContext('previewPositionModify'); + return this.#marketDataService.previewPositionModify({ + provider, + params, + context, + }); + } + + /** + * Calculate maintenance margin for a specific asset + * Returns a percentage (e.g., 0.0125 for 1.25%) + * + * @param params - The operation parameters. + * @returns A promise that resolves to the numeric result. + */ + async calculateMaintenanceMargin( + params: MaintenanceMarginParams, + ): Promise { + const provider = this.getActiveProvider(); + const context = this.#createServiceContext('calculateMaintenanceMargin'); + return this.#marketDataService.calculateMaintenanceMargin({ + provider, + params, + context, + }); + } + + /** + * Get maximum leverage allowed for an asset + * + * @param asset - The asset identifier. + * @returns A promise that resolves to the numeric result. + */ + async getMaxLeverage(asset: string): Promise { + const provider = this.getActiveProvider(); + const context = this.#createServiceContext('getMaxLeverage'); + return this.#marketDataService.getMaxLeverage({ provider, asset, context }); + } + + /** + * Validate order parameters according to protocol-specific rules + * + * @param params - The operation parameters. + * @returns True if the condition is met. + */ + async validateOrder( + params: OrderParams, + ): Promise<{ isValid: boolean; error?: string }> { + const provider = await this.#resolveRoutedOrderProvider({ + orderType: params.orderType, + providerId: params.providerId, + }); + const context = this.#createServiceContext('validateOrder'); + return this.#marketDataService.validateOrder({ provider, params, context }); + } + + /** + * Validate close position parameters according to protocol-specific rules + * + * @param params - The operation parameters. + * @returns A promise that resolves to the result. + */ + async validateClosePosition( + params: ClosePositionParams, + ): Promise<{ isValid: boolean; error?: string }> { + const provider = await this.#resolveRoutedOrderProvider({ + orderType: params.orderType, + providerId: params.providerId, + }); + const context = this.#createServiceContext('validateClosePosition'); + return this.#marketDataService.validateClosePosition({ + provider, + params, + context, + }); + } + + /** + * Validate withdrawal parameters according to protocol-specific rules + * + * @param params - The operation parameters. + * @returns True if the condition is met. + */ + async validateWithdrawal( + params: WithdrawParams, + ): Promise<{ isValid: boolean; error?: string }> { + const provider = this.getActiveProvider(); + return this.#accountService.validateWithdrawal({ provider, params }); + } + + /** + * Get supported withdrawal routes - returns complete asset and routing information + * + * @returns Array of supported asset routes for withdrawals. + */ + getWithdrawalRoutes(): AssetRoute[] { + try { + const provider = this.getActiveProvider(); + return this.#marketDataService.getWithdrawalRoutes({ provider }); + } catch (error) { + this.#logError( + ensureError(error, 'PerpsController.getWithdrawalRoutes'), + this.#getErrorContext('getWithdrawalRoutes'), + ); + // Return empty array if provider is not available + return []; + } + } + + /** + * Set the transient UTM / discovery attribution context. + * Replaces any previously set context. Held in-memory only — not persisted. + * + * @param context - The attribution context (UTM fields) to store. + */ + setAttributionContext(context: PerpsAttributionContext): void { + this.#attributionContext = { ...context }; + } + + /** + * Get a copy of the current attribution context. + * + * @returns A shallow copy of the stored attribution context. + */ + getAttributionContext(): PerpsAttributionContext { + return { ...this.#attributionContext }; + } + + /** + * Clear the stored attribution context. + */ + clearAttributionContext(): void { + this.#attributionContext = {}; + } + + /** + * Merge the stored UTM attribution context into a set of analytics event + * properties. Only defined UTM fields are added, mapped + * to their canonical PERPS_EVENT_PROPERTY keys. Provided properties take + * precedence and are never overwritten. + * + * @param properties - Base event properties to merge attribution into. + * @returns A new properties object including any defined UTM keys. + */ + mergeAttributionContext( + properties: PerpsAnalyticsProperties = {}, + ): PerpsAnalyticsProperties { + const utm: PerpsAnalyticsProperties = {}; + const context = this.#attributionContext; + if (context.utmSource !== undefined) { + utm[PERPS_EVENT_PROPERTY.UTM_SOURCE] = context.utmSource; + } + if (context.utmMedium !== undefined) { + utm[PERPS_EVENT_PROPERTY.UTM_MEDIUM] = context.utmMedium; + } + if (context.utmCampaign !== undefined) { + utm[PERPS_EVENT_PROPERTY.UTM_CAMPAIGN] = context.utmCampaign; + } + if (context.utmContent !== undefined) { + utm[PERPS_EVENT_PROPERTY.UTM_CONTENT] = context.utmContent; + } + if (context.utmTerm !== undefined) { + utm[PERPS_EVENT_PROPERTY.UTM_TERM] = context.utmTerm; + } + // Provided properties win over attribution context. + return { ...utm, ...properties }; + } + + /** + * Toggle between testnet and mainnet + * + * @returns The toggle result with success status and current network mode. + */ + async toggleTestnet(): Promise { + while (this.#disconnectOperationPromise) { + await this.#disconnectOperationPromise; + } + + // Prevent concurrent reinitializations + if (this.isCurrentlyReinitializing()) { + this.#debugLog( + 'PerpsController: Already reinitializing, skipping toggle', + { + timestamp: new Date().toISOString(), + }, + ); + return { + success: false, + isTestnet: this.state.isTestnet, + error: PERPS_ERROR_CODES.CLIENT_REINITIALIZING, + }; + } + + const completeReinitialization = this.#beginReinitialization(); + const previousIsTestnet = this.state.isTestnet; + + try { + const pendingInitialization = this.#initializationPromise; + if (pendingInitialization) { + await pendingInitialization; + } + + await this.#cleanupStandaloneProvider(); + + const previousNetwork = previousIsTestnet ? 'testnet' : 'mainnet'; + + this.update((state) => { + state.isTestnet = !state.isTestnet; + }); + + const newNetwork = this.state.isTestnet ? 'testnet' : 'mainnet'; + + this.#debugLog('PerpsController: Network toggle initiated', { + from: previousNetwork, + to: newNetwork, + timestamp: new Date().toISOString(), + }); + + // Reset initialization state and reinitialize provider with new testnet setting + this.isInitialized = false; + this.#initializationPromise = null; + await this.#initWithoutDisconnectWait(); + + // Check if initialization actually succeeded — performInitialization() + // does not throw on failure, it sets state to Failed and resolves. + if (this.state.initializationState === InitializationState.Failed) { + throw new Error( + this.state.initializationError ?? + 'Network toggle initialization failed', + ); + } + + this.#debugLog('PerpsController: Network toggle completed', { + newNetwork, + isTestnet: this.state.isTestnet, + timestamp: new Date().toISOString(), + }); + + return { success: true, isTestnet: this.state.isTestnet }; + } catch (error) { + // Rollback isTestnet to previous value + this.update((state) => { + state.isTestnet = previousIsTestnet; + }); + + return { + success: false, + isTestnet: this.state.isTestnet, + error: ensureError(error, 'PerpsController.toggleTestnet').message, + }; + } finally { + completeReinitialization(); + + // Re-trigger preload now that reinit is complete and the + // activeProviderInstance points to the correct network. + // The state-change listener may have already fired during reinit + // but was skipped due to the #isReinitializing guard. + if (this.#preloadTimer) { + this.#performMarketDataPreload().catch(() => { + /* fire-and-forget */ + }); + this.#performUserDataPreload().catch(() => { + /* fire-and-forget */ + }); + } + } + } + + /** + * Switch to a different provider + * Uses a full reinit approach: disconnect() → update state → init() + * This ensures complete state reset including WebSocket connections and caches. + * + * @param providerId - The provider identifier. + * @returns The switch result with success status and active provider. + */ + async switchProvider( + providerId: PerpsActiveProviderMode, + ): Promise { + while (this.#disconnectOperationPromise) { + await this.#disconnectOperationPromise; + } + + // Prevent concurrent switches + if (this.isCurrentlyReinitializing()) { + return { + success: false, + providerId: this.state.activeProvider, + error: PERPS_ERROR_CODES.CLIENT_REINITIALIZING, + }; + } + + const completeReinitialization = this.#beginReinitialization(); + let previousProvider = this.state.activeProvider; + + try { + const pendingInitialization = this.#initializationPromise; + if (pendingInitialization) { + await pendingInitialization; + } + + // A completed disconnect leaves no initialization promise to await. Rebuild + // the provider registry before same-provider detection or route validation + // so a successful switch always leaves a usable active provider. + const needsInitialization = + !this.isInitialized || + this.state.initializationState !== InitializationState.Initialized; + if (needsInitialization) { + await this.#initWithoutDisconnectWait(); + const initializationFailed = + !this.isInitialized || + this.state.initializationState !== InitializationState.Initialized; + if (initializationFailed) { + throw new Error( + this.state.initializationError ?? 'Provider initialization failed', + ); + } + } + + // Initialization may select a fallback provider. Read the effective + // provider only after it settles so no-op detection and rollback both + // use the state this switch is actually replacing. + previousProvider = this.state.activeProvider; + if (previousProvider === providerId) { + return { success: true, providerId }; + } + + // Validate provider only after a pending initialization has rebuilt the + // registry. Otherwise a switch queued behind disconnect can observe the + // intentionally empty teardown state. + const isValidProvider = + providerId === 'aggregated' || this.providers.has(providerId); + + if (!isValidProvider) { + return { + success: false, + providerId: this.state.activeProvider, + error: `Provider ${providerId} not available`, + }; + } + + await this.#cleanupStandaloneProvider(); + + this.#debugLog('PerpsController: Provider switch initiated', { + from: previousProvider, + to: providerId, + timestamp: new Date().toISOString(), + }); + + // Provider disconnect is handled by performInitialization() during + // reinitialization. + + // Update state with new provider (market data cache preserved per-provider) + this.update((state) => { + state.activeProvider = providerId; + state.accountState = null; + state.initializationState = InitializationState.Uninitialized; + }); + + // Reset initialization state and reinitialize + this.isInitialized = false; + this.#initializationPromise = null; + await this.#initWithoutDisconnectWait(); + + // Check if initialization actually succeeded — performInitialization() + // does not throw on failure, it sets state to Failed and resolves. + if (this.state.initializationState === InitializationState.Failed) { + throw new Error( + this.state.initializationError ?? 'Provider initialization failed', + ); + } + + this.#debugLog('PerpsController: Provider switch completed', { + providerId, + timestamp: new Date().toISOString(), + }); + + return { success: true, providerId }; + } catch (error) { + // Rollback state to previous provider + this.update((state) => { + state.activeProvider = previousProvider; + }); + + this.#logError( + ensureError(error, 'PerpsController.switchProvider'), + this.#getErrorContext('switchProvider', { providerId }), + ); + + // Attempt to reinitialize the previous provider via init(), + // which handles all provider modes including 'aggregated'. + try { + this.isInitialized = false; + this.#initializationPromise = null; + await this.#initWithoutDisconnectWait(); + + this.#debugLog( + 'PerpsController: Rollback to previous provider succeeded', + { + previousProvider, + timestamp: new Date().toISOString(), + }, + ); + } catch (reinitError) { + // Reinit also failed — mark as failed + this.update((state) => { + state.initializationState = InitializationState.Failed; + }); + this.#logError( + ensureError(reinitError, 'PerpsController.switchProvider.rollback'), + this.#getErrorContext('switchProvider.rollback', { + previousProvider, + }), + ); + } + + return { + success: false, + providerId: previousProvider, + error: + error instanceof Error + ? error.message + : PERPS_ERROR_CODES.UNKNOWN_ERROR, + }; + } finally { + completeReinitialization(); + + // Re-trigger preload now that reinit is complete. + if (this.#preloadTimer) { + this.#performMarketDataPreload().catch(() => { + /* fire-and-forget */ + }); + this.#performUserDataPreload().catch(() => { + /* fire-and-forget */ + }); + } + } + } + + /** + * Get current network (mainnet/testnet) + * + * @returns Either 'mainnet' or 'testnet' based on the current configuration. + */ + getCurrentNetwork(): 'mainnet' | 'testnet' { + return this.state.isTestnet ? 'testnet' : 'mainnet'; + } + + /** + * Get the ordered list of all market categories for HIP-3 markets. + * Returns a stable, explicitly ordered array so the UI can render + * category filter tabs without deriving order from config insertion. + * + * @returns Ordered array of {@link MarketTypeFilter} values. Does not include the 'all' or 'new' sentinels — those are separate UI controls. + */ + getMarketCategories(): MarketTypeFilter[] { + return MARKET_CATEGORIES; + } + + /** + * Get the current WebSocket connection state from the active provider. + * Used by the UI to monitor connection health and show notifications. + * + * @returns The current WebSocket connection state, or DISCONNECTED if not supported + */ + getWebSocketConnectionState(): WebSocketConnectionState { + try { + const provider = this.getActiveProvider(); + if (provider.getWebSocketConnectionState) { + return provider.getWebSocketConnectionState(); + } + // Fallback for providers that don't support this method + return WebSocketConnectionState.Disconnected; + } catch { + // If no provider is active, return disconnected + return WebSocketConnectionState.Disconnected; + } + } + + /** + * Subscribe to WebSocket connection state changes from the active provider. + * The listener will be called immediately with the current state and whenever the state changes. + * + * @param listener - Callback function that receives the new connection state and reconnection attempt + * @returns Unsubscribe function to remove the listener, or no-op if not supported + */ + subscribeToConnectionState( + listener: ( + state: WebSocketConnectionState, + reconnectionAttempt: number, + ) => void, + ): () => void { + try { + const provider = this.getActiveProvider(); + if (provider.subscribeToConnectionState) { + return provider.subscribeToConnectionState(listener); + } + // Fallback: immediately call with current state and return no-op unsubscribe + listener(this.getWebSocketConnectionState(), 0); + return () => { + // No-op + }; + } catch { + // If no provider is active, call with disconnected and return no-op + listener(WebSocketConnectionState.Disconnected, 0); + return () => { + // No-op + }; + } + } + + /** + * Manually trigger a WebSocket reconnection attempt. + * Used by the UI retry button when connection is lost. + */ + async reconnect(): Promise { + this.#debugLog('[PerpsController] reconnect() called'); + try { + const provider = this.getActiveProvider(); + if (provider.reconnect) { + this.#debugLog('[PerpsController] Delegating to provider.reconnect()'); + await provider.reconnect(); + this.#debugLog('[PerpsController] provider.reconnect() completed'); + } else { + this.#debugLog( + '[PerpsController] Provider does not support reconnect()', + ); + } + } catch (error) { + this.#logError( + ensureError(error, 'PerpsController.reconnect'), + this.#getErrorContext('reconnect', { + operation: 'websocket_reconnect', + }), + ); + } + } + + // Live data delegation (NO Redux) - delegates to active provider + + /** + * Subscribe to live price updates + * + * @param params - The operation parameters. + * @returns A cleanup function to remove the subscription. + */ + subscribeToPrices(params: SubscribePricesParams): () => void { + const provider = this.getActiveProviderOrNull(); + if (!provider) { + return () => { + // No-op: Provider not initialized + }; + } + try { + return provider.subscribeToPrices(params); + } catch (error) { + this.#logError( + ensureError(error, 'PerpsController.subscribeToPrices'), + this.#getErrorContext('subscribeToPrices', { + symbols: params.symbols?.join(','), + }), + ); + return () => { + // No-op + }; + } + } + + /** + * Subscribe to live position updates + * + * @param params - The operation parameters. + * @returns A cleanup function to remove the subscription. + */ + subscribeToPositions(params: SubscribePositionsParams): () => void { + const provider = this.getActiveProviderOrNull(); + if (!provider) { + return () => { + // No-op: Provider not initialized + }; + } + try { + return provider.subscribeToPositions(params); + } catch (error) { + this.#logError( + ensureError(error, 'PerpsController.subscribeToPositions'), + this.#getErrorContext('subscribeToPositions', { + accountId: params.accountId, + }), + ); + return () => { + // No-op + }; + } + } + + /** + * Subscribe to live order fill updates + * + * @param params - The operation parameters. + * @returns A cleanup function to remove the subscription. + */ + subscribeToOrderFills(params: SubscribeOrderFillsParams): () => void { + const provider = this.getActiveProviderOrNull(); + if (!provider) { + return () => { + // No-op: Provider not initialized + }; + } + try { + return provider.subscribeToOrderFills(params); + } catch (error) { + this.#logError( + ensureError(error, 'PerpsController.subscribeToOrderFills'), + this.#getErrorContext('subscribeToOrderFills', { + accountId: params.accountId, + }), + ); + return () => { + // No-op + }; + } + } + + /** + * Subscribe to live order updates + * + * @param params - The operation parameters. + * @returns A cleanup function to remove the subscription. + */ + subscribeToOrders(params: SubscribeOrdersParams): () => void { + const provider = this.getActiveProviderOrNull(); + if (!provider) { + return () => { + // No-op: Provider not initialized + }; + } + try { + return provider.subscribeToOrders(params); + } catch (error) { + this.#logError( + ensureError(error, 'PerpsController.subscribeToOrders'), + this.#getErrorContext('subscribeToOrders', { + accountId: params.accountId, + }), + ); + return () => { + // No-op + }; + } + } + + /** + * Subscribe to live account updates. + * Updates controller state (Redux) when new account data arrives so consumers + * like usePerpsBalanceTokenFilter (PayWithModal) see the latest balance. + * + * @param params - The operation parameters. + * @returns A cleanup function to remove the subscription. + */ + subscribeToAccount(params: SubscribeAccountParams): () => void { + const provider = this.getActiveProviderOrNull(); + if (!provider) { + return () => { + // No-op: Provider not initialized + }; + } + try { + const originalCallback = params.callback; + return provider.subscribeToAccount({ + ...params, + callback: (account: AccountState | null) => { + if (account) { + this.update((state) => { + state.accountState = account; + state.lastUpdateTimestamp = Date.now(); + state.lastError = null; + }); + } + originalCallback(account); + }, + }); + } catch (error) { + this.#logError( + ensureError(error, 'PerpsController.subscribeToAccount'), + this.#getErrorContext('subscribeToAccount', { + accountId: params.accountId, + }), + ); + return () => { + // No-op + }; + } + } + + /** + * Subscribe to full order book updates with multiple depth levels + * Creates a dedicated L2Book subscription for real-time order book data + * + * @param params - The operation parameters. + * @returns A cleanup function to remove the subscription. + */ + subscribeToOrderBook(params: SubscribeOrderBookParams): () => void { + const provider = this.getActiveProviderOrNull(); + if (!provider) { + return () => { + // No-op: Provider not initialized + }; + } + try { + return provider.subscribeToOrderBook(params); + } catch (error) { + this.#logError( + ensureError(error, 'PerpsController.subscribeToOrderBook'), + this.#getErrorContext('subscribeToOrderBook', { + symbol: params.symbol, + levels: params.levels, + }), + ); + return () => { + // No-op + }; + } + } + + /** + * Subscribe to live candle updates + * + * @param params - The operation parameters. + * @returns A cleanup function to remove the subscription. + */ + subscribeToCandles(params: SubscribeCandlesParams): () => void { + const provider = this.getActiveProviderOrNull(); + if (!provider) { + return () => { + // No-op: Provider not initialized + }; + } + try { + return provider.subscribeToCandles(params); + } catch (error) { + this.#logError( + ensureError(error, 'PerpsController.subscribeToCandles'), + this.#getErrorContext('subscribeToCandles', { + symbol: params.symbol, + interval: params.interval, + duration: params.duration, + }), + ); + return () => { + // No-op + }; + } + } + + /** + * Subscribe to open interest cap updates + * Zero additional network overhead - data comes from existing webData3 subscription + * + * @param params - The operation parameters. + * @returns A cleanup function to remove the subscription. + */ + subscribeToOICaps(params: SubscribeOICapsParams): () => void { + const provider = this.getActiveProviderOrNull(); + if (!provider) { + return () => { + // No-op: Provider not initialized + }; + } + try { + return provider.subscribeToOICaps(params); + } catch (error) { + this.#logError( + ensureError(error, 'PerpsController.subscribeToOICaps'), + this.#getErrorContext('subscribeToOICaps', { + accountId: params.accountId, + }), + ); + return () => { + // No-op + }; + } + } + + /** + * Configure live data throttling + * + * @param config - The configuration object. + */ + setLiveDataConfig(config: Partial): void { + try { + const provider = this.getActiveProvider(); + provider.setLiveDataConfig(config); + } catch (error) { + this.#logError( + ensureError(error, 'PerpsController.setLiveDataConfig'), + this.#getErrorContext('setLiveDataConfig'), + ); + } + } + + /** + * Calculate trading fees through the active provider route. + * Each provider owns its fee policy. An explicit provider route overrides + * the active/default provider used by placement. + * + * @param params - The operation parameters. + * @returns The fee calculation result for the trade. + */ + async calculateFees( + params: FeeCalculationParams, + ): Promise { + const provider = await this.#resolveRoutedOrderProvider({ + orderType: params.orderType, + providerId: params.providerId, + }); + // Preview owns subscription hydration. The submit resolver remains a pure + // cache read and can therefore never start a benefits request while an + // order is being signed. + await this.#rewardsIntegrationService.refreshSubscriptionBenefits(); + const waiverStatus = + this.#rewardsIntegrationService.getSubscriptionFeeWaiverStatus(); + const context = this.#createServiceContext('calculateFees', { + subscriptionFeeWaiver: + waiverStatus.reason === 'no-source' ? undefined : waiverStatus, + }); + return this.#marketDataService.calculateFees({ provider, params, context }); + } + + /** + * Approve the dedicated subscription builder outside order submission. + * Until this succeeds, subscription waivers fall back to the ordinary + * builder at the standard fee. + * + * @returns Whether the subscription builder is approved. + */ + async approveSubscriptionBuilderFee(): Promise { + const provider = this.getActiveProvider(); + return provider.approveSubscriptionBuilderFee + ? provider.approveSubscriptionBuilderFee() + : false; + } + + /** + * Drop the cached subscription benefits snapshot. + * + * Call this when the identity behind the benefits changes — sign-out, or a + * profile switch. The snapshot carries no profile identity of its own, so + * without this it keeps answering for the previous profile until the next + * successful refresh. The next fee resolution reports the waiver as + * unavailable, so it is withheld until preview or lifecycle hydration. + */ + invalidateSubscriptionBenefits(): void { + this.#rewardsIntegrationService.invalidateSubscriptionBenefits(); + } + + /** + * Disconnect provider and cleanup subscriptions + * Call this when navigating away from Perps screens to prevent battery drain + */ + async disconnect(): Promise { + while (this.#disconnectOperationPromise) { + // Each explicit disconnect claims a teardown after the operation already + // in flight. This lets a later disconnect close providers created by an + // init call that was queued behind the same earlier teardown. + this.#preloadStartRequested = false; + await this.#disconnectOperationPromise; + } + + // A disconnect stops the current preload session. A later start call made + // while teardown is in flight sets this back to true and is resumed below. + this.#preloadStartRequested = false; + + let resolveOperation = (): void => undefined; + const operation = new Promise((resolve) => { + resolveOperation = resolve; + }); + this.#disconnectOperationPromise = operation; + + try { + await this.#performDisconnect(); + } finally { + resolveOperation(); + if (this.#disconnectOperationPromise === operation) { + this.#disconnectOperationPromise = null; + } + if (this.#preloadStartRequested) { + this.startMarketDataPreload(); + } + } + } + + /** + * Disconnect after this call has claimed the controller lifecycle. + * + * @returns A promise that resolves when teardown finishes. + */ + async #performDisconnect(): Promise { + this.#lifecycleGeneration += 1; + this.#debugLog( + 'PerpsController: Disconnecting provider to cleanup subscriptions', + { + timestamp: new Date().toISOString(), + }, + ); + + // Stop preload interval and messenger subscriptions first, + // so no background work fires while we tear down providers. + if (this.#preloadTimer) { + clearInterval(this.#preloadTimer); + this.#preloadTimer = null; + } + if (this.#preloadStateUnsubscribe) { + this.#preloadStateUnsubscribe(); + this.#preloadStateUnsubscribe = null; + } + if (this.#accountChangeUnsubscribe) { + this.#accountChangeUnsubscribe(); + this.#accountChangeUnsubscribe = null; + } + this.#previousIsTestnet = null; + this.#previousHip3ConfigVersion = null; + + const pendingReinitialization = this.#reinitializationOperationPromise; + if (pendingReinitialization) { + await pendingReinitialization; + } + + // Initialization owns provider creation. Let it finish before teardown so + // it cannot repopulate providers after this method clears the references. + const pendingInitialization = this.#initializationPromise; + if (pendingInitialization) { + try { + await pendingInitialization; + } catch (error) { + this.#logError( + ensureError(error, 'PerpsController.disconnect.initialization'), + this.#getErrorContext('disconnect', { + operation: 'awaitInitialization', + }), + ); + } + } + + const provider = this.activeProviderInstance; + if (provider) { + try { + await provider.disconnect(); + } catch (error) { + this.#logError( + ensureError(error, 'PerpsController.disconnect'), + this.#getErrorContext('disconnect'), + ); + } + } + + // Clear stale reference so standalone reads don't route through old provider + this.activeProviderInstance = null; + + // Cleanup cached standalone provider (if any) — awaited to prevent races + await this.#cleanupStandaloneProvider(); + + // Note: Feature-flag subscription is NOT cleaned up here. + // It is a controller-lifetime concern (set once in the constructor), + // not a session-lifetime concern. Unsubscribing here would break + // geo-blocking / HIP-3 flag propagation after disconnect → reconnect. + + // Reset initialization state to ensure proper reconnection + this.isInitialized = false; + this.#initializationPromise = null; + } + + /** + * Eligibility (Geo-Blocking) + */ + + /** + * Fetch geo location + * + * Returned in Country or Country-Region format + * Example: FR, DE, US-MI, CA-ON + */ + /** + * Refresh eligibility status + */ + /** + * Resume eligibility monitoring after onboarding completes. + * Clears the deferred flag and triggers an immediate eligibility check + * using the current remote feature flag state. + */ + startEligibilityMonitoring(): void { + this.#eligibilityCheckDeferred = false; + try { + const currentState = this.messenger.call( + 'RemoteFeatureFlagController:getState', + ); + this.refreshEligibilityOnFeatureFlagChange(currentState); + } catch (error) { + this.#logError( + ensureError(error, 'PerpsController.startEligibilityMonitoring'), + this.#getErrorContext('startEligibilityMonitoring', { + operation: 'readRemoteFeatureFlags', + }), + ); + } + } + + /** + * Stops geo-blocking eligibility monitoring. + * Call this when the user disables basic functionality (e.g. useExternalServices becomes false). + * Prevents geolocation calls until startEligibilityMonitoring() is called again. + * Safe to call multiple times. + */ + stopEligibilityMonitoring(): void { + this.#eligibilityCheckDeferred = true; + } + + async refreshEligibility(): Promise { + if (this.#eligibilityCheckDeferred) { + return; + } + + // Capture the current version before starting the async operation. + // This prevents race conditions where stale eligibility checks + // (started with fallback config) overwrite results from newer checks + // (started with remote config after it was fetched). + const versionAtStart = this.#blockedRegionListVersion; + + try { + const geoLocation = await this.messenger.call( + 'GeolocationController:getGeolocation', + ); + + const isEligible = await this.#eligibilityService.checkEligibility({ + blockedRegions: this.blockedRegionList.list, + geoLocation, + }); + + // Only update state if the blocked region list hasn't changed while we were awaiting. + // This prevents stale fallback-based eligibility checks from overwriting + // results from remote-based checks. + if (this.#blockedRegionListVersion !== versionAtStart) { + return; + } + + this.update((state) => { + state.isEligible = isEligible; + }); + } catch (error) { + this.#logError( + ensureError(error, 'PerpsController.refreshEligibility'), + this.#getErrorContext('refreshEligibility'), + ); + + // Only update on error if version is still current + if (this.#blockedRegionListVersion === versionAtStart) { + // Default to eligible on error + this.update((state) => { + state.isEligible = true; + }); + } + } + } + + /** + * Get block explorer URL for an address or just the base URL + * + * @param address - Optional address to append to the base URL + * @returns Block explorer URL + */ + getBlockExplorerUrl(address?: string): string { + const provider = this.getActiveProvider(); + return this.#marketDataService.getBlockExplorerUrl({ provider, address }); + } + + /** + * Check if user is first-time for the current network + * + * @returns True if the condition is met. + */ + isFirstTimeUserOnCurrentNetwork(): boolean { + const currentNetwork = this.state.isTestnet ? 'testnet' : 'mainnet'; + return this.state.isFirstTimeUser[currentNetwork]; + } + + /** + * Mark that the user has completed the tutorial/onboarding + * This prevents the tutorial from showing again + */ + markTutorialCompleted(): void { + const currentNetwork = this.state.isTestnet ? 'testnet' : 'mainnet'; + + this.#debugLog('PerpsController: Marking tutorial as completed', { + timestamp: new Date().toISOString(), + network: currentNetwork, + }); + + this.update((state) => { + state.isFirstTimeUser[currentNetwork] = false; + }); + } + + /* + * Mark that user has placed their first successful order + * This prevents the notification tooltip from showing again + */ + markFirstOrderCompleted(): void { + const currentNetwork = this.state.isTestnet ? 'testnet' : 'mainnet'; + + this.#debugLog('PerpsController: Marking first order completed', { + timestamp: new Date().toISOString(), + network: currentNetwork, + }); + + this.update((state) => { + state.hasPlacedFirstOrder[currentNetwork] = true; + }); + } + + /** + * Reset first-time user state for both networks + * This is useful for testing the tutorial flow + * Called by Reset Account feature in settings + */ + resetFirstTimeUserState(): void { + this.#debugLog('PerpsController: Resetting first-time user state', { + timestamp: new Date().toISOString(), + previousState: this.state.isFirstTimeUser, + }); + + this.update((state) => { + state.isFirstTimeUser = { + testnet: true, + mainnet: true, + }; + state.hasPlacedFirstOrder = { + testnet: false, + mainnet: false, + }; + }); + } + + /** + * Clear pending/bridging withdrawal and deposit requests + * This is useful when users want to clear stuck pending indicators + * Called by Reset Account feature in settings + */ + clearPendingTransactionRequests(): void { + this.#debugLog('PerpsController: Clearing pending transaction requests', { + timestamp: new Date().toISOString(), + }); + + this.update((state) => { + // Filter out pending/bridging withdrawals, keep completed for history + state.withdrawalRequests = state.withdrawalRequests.filter( + (req) => req.status !== 'pending' && req.status !== 'bridging', + ); + + // Filter out pending deposits, keep completed/failed for history + state.depositRequests = state.depositRequests.filter( + (req) => req.status !== 'pending' && req.status !== 'bridging', + ); + + // Reset withdrawal progress + state.withdrawalProgress = { + progress: 0, + lastUpdated: Date.now(), + activeWithdrawalId: null, + }; + }); + } + + /** + * Get saved trade configuration for a market + * + * @param symbol - The trading pair symbol. + * @returns The resulting string value. + */ + getTradeConfiguration(symbol: string): { leverage?: number } | undefined { + const network = this.state.isTestnet ? 'testnet' : 'mainnet'; + const config = this.state.tradeConfigurations[network]?.[symbol]; + + if (!config?.leverage) { + return undefined; + } + + this.#debugLog('PerpsController: Retrieved trade config', { + symbol, + network, + leverage: config.leverage, + }); + + return { leverage: config.leverage }; + } + + /** + * Save trade configuration for a market + * + * @param symbol - Market symbol + * @param leverage - Leverage value + */ + saveTradeConfiguration(symbol: string, leverage: number): void { + const network = this.state.isTestnet ? 'testnet' : 'mainnet'; + + this.#debugLog('PerpsController: Saving trade configuration', { + symbol, + network, + leverage, + timestamp: new Date().toISOString(), + }); + + this.update((state) => { + if (!state.tradeConfigurations[network]) { + state.tradeConfigurations[network] = {}; + } + + const existingConfig = state.tradeConfigurations[network][symbol] || {}; + state.tradeConfigurations[network][symbol] = { + ...existingConfig, + leverage, + }; + }); + } + + /** + * Save pending trade configuration for a market + * This is a temporary configuration that expires after 30 seconds. + * + * @param symbol - Market symbol + * @param config - Pending trade configuration (includes optional selected payment token from Pay row) + * @param config.amount - The amount value. + * @param config.leverage - The leverage multiplier. + * @param config.takeProfitPrice - The take profit price. + * @param config.stopLossPrice - The stop loss price. + * @param config.limitPrice - The limit price. + * @param config.orderType - The order type. + * @param config.reduceOnly - Whether the order may only reduce a position. + * @param config.direction - Long or short. + * @param config.selectedPaymentToken - The selected payment token. + */ + savePendingTradeConfiguration( + symbol: string, + config: { + amount?: string; + leverage?: number; + takeProfitPrice?: string; + stopLossPrice?: string; + limitPrice?: string; + orderType?: OrderType; + reduceOnly?: boolean; + direction?: OrderDirection; + /** When user used pay-with-token in PerpsPayRow: minimal token shape to restore selection */ + selectedPaymentToken?: PerpsSelectedPaymentToken | null; + }, + ): void { + const network = this.state.isTestnet ? 'testnet' : 'mainnet'; + + this.#debugLog('PerpsController: Saving pending trade configuration', { + symbol, + network, + config, + timestamp: new Date().toISOString(), + }); + + this.update((state) => { + if (!state.tradeConfigurations[network]) { + state.tradeConfigurations[network] = {}; + } + + const existingConfig = state.tradeConfigurations[network][symbol] || {}; + state.tradeConfigurations[network][symbol] = { + ...existingConfig, + pendingConfig: { + ...config, + timestamp: Date.now(), + }, + }; + if (config.orderType) { + state.selectedOrderType = config.orderType; + } + }); + } + + /** + * Get pending trade configuration for a market + * Returns undefined if config doesn't exist or has expired. + * + * @param symbol - Market symbol + * @returns Pending trade configuration or undefined + */ + getPendingTradeConfiguration(symbol: string): + | { + amount?: string; + leverage?: number; + takeProfitPrice?: string; + stopLossPrice?: string; + limitPrice?: string; + orderType?: OrderType; + reduceOnly?: boolean; + direction?: OrderDirection; + selectedPaymentToken?: PerpsSelectedPaymentToken | null; + } + | undefined { + const network = this.state.isTestnet ? 'testnet' : 'mainnet'; + const config = + this.state.tradeConfigurations[network]?.[symbol]?.pendingConfig; + + if (!config) { + return undefined; + } + + const now = Date.now(); + const age = now - config.timestamp; + + if (age > PERPS_CONSTANTS.PendingTradeConfigurationTtlMs) { + this.#debugLog('PerpsController: Pending trade config expired', { + symbol, + network, + age, + timestamp: config.timestamp, + }); + // Clear expired config + this.update((state) => { + if (state.tradeConfigurations[network]?.[symbol]?.pendingConfig) { + delete state.tradeConfigurations[network][symbol].pendingConfig; + } + }); + return undefined; + } + + this.#debugLog('PerpsController: Retrieved pending trade config', { + symbol, + network, + config, + age, + }); + + // Return config without timestamp + const { timestamp, ...configWithoutTimestamp } = config; + return configWithoutTimestamp; + } + + /** + * Clear pending trade configuration for a market + * + * @param symbol - Market symbol + */ + clearPendingTradeConfiguration(symbol: string): void { + const network = this.state.isTestnet ? 'testnet' : 'mainnet'; + + this.#debugLog('PerpsController: Clearing pending trade configuration', { + symbol, + network, + timestamp: new Date().toISOString(), + }); + + this.update((state) => { + if (state.tradeConfigurations[network]?.[symbol]?.pendingConfig) { + delete state.tradeConfigurations[network][symbol].pendingConfig; + } + }); + } + + /** + * Get saved market filter preferences + * Handles backward compatibility with legacy string format + * + * @returns The saved sort option ID and direction. + */ + getMarketFilterPreferences(): { + optionId: SortOptionId; + direction: SortDirection; + } { + const pref = this.state.marketFilterPreferences; + + // Handle legacy string format (backward compatibility) + if (typeof pref === 'string') { + // Map legacy compound IDs to new format + // Old format: 'priceChange-desc' or 'priceChange-asc' + // New format: { optionId: 'priceChange', direction: 'desc'/'asc' } + if (pref === 'priceChange-desc') { + return { + optionId: 'priceChange', + direction: 'desc', + }; + } + if (pref === 'priceChange-asc') { + return { + optionId: 'priceChange', + direction: 'asc', + }; + } + + // Handle other simple legacy strings (e.g., 'volume', 'openInterest', etc.) + return { + optionId: pref as SortOptionId, + direction: MARKET_SORTING_CONFIG.DefaultDirection, + }; + } + + // Return new object format or default + return ( + pref ?? { + optionId: MARKET_SORTING_CONFIG.DefaultSortOptionId, + direction: MARKET_SORTING_CONFIG.DefaultDirection, + } + ); + } + + /** + * Save market filter preferences + * + * @param optionId - Sort/filter option ID + * @param direction - Sort direction ('asc' or 'desc') + */ + saveMarketFilterPreferences( + optionId: SortOptionId, + direction: SortDirection, + ): void { + this.#debugLog('PerpsController: Saving market filter preferences', { + optionId, + direction, + timestamp: new Date().toISOString(), + }); + + this.update((state) => { + state.marketFilterPreferences = { optionId, direction }; + }); + } + + /** + * Get the user's max slippage tolerance in basis points. + * + * @returns The configured max slippage bps, or undefined if never set (callers should default to 300 bps / 3%). + */ + getMaxSlippage(): number | undefined { + return this.state.maxSlippageBps; + } + + /** + * Set the user's max slippage tolerance in basis points. + * + * @param bps - Max slippage in basis points (e.g. 300 = 3%). Clamped to 10–1000, snapped to step of 10. + */ + setMaxSlippage(bps: number): void { + // Reject non-finite input (NaN/Infinity) so it cannot reach the order + // path, where it would poison `getMaxSlippage` and produce a NaN limit + // price. `Math.max(..., NaN)` returns NaN and `??` does not catch it. + if (!Number.isFinite(bps)) { + return; + } + const clamped = Math.min( + MAX_SLIPPAGE_BOUNDS.MaxBps, + Math.max(MAX_SLIPPAGE_BOUNDS.MinBps, bps), + ); + const snapped = + Math.round(clamped / MAX_SLIPPAGE_BOUNDS.StepBps) * + MAX_SLIPPAGE_BOUNDS.StepBps; + this.update((state) => { + state.maxSlippageBps = snapped; + }); + } + + /** + * Get market-agnostic Pro order-book display preferences. + * + * @returns The current order-book display preferences. + */ + getOrderBookPreferences(): OrderBookPreferences { + return { + ...DEFAULT_ORDER_BOOK_PREFERENCES, + ...this.state.orderBookPreferences, + }; + } + + /** + * Update market-agnostic Pro order-book display preferences. + * + * @param patch - Partial order-book preferences to update. + */ + setOrderBookPreferences(patch: Partial): void { + this.update((state) => { + state.orderBookPreferences = { + ...DEFAULT_ORDER_BOOK_PREFERENCES, + ...state.orderBookPreferences, + ...patch, + }; + }); + } + + /** + * Get the selected order type shared by every market. + * + * @returns The selected order type. + */ + getSelectedOrderType(): OrderType { + return this.state.selectedOrderType ?? DEFAULT_SELECTED_ORDER_TYPE; + } + + /** + * Set the selected order type shared by every market. + * + * @param orderType - The selected order type. + */ + setSelectedOrderType(orderType: OrderType): void { + this.update((state) => { + state.selectedOrderType = orderType; + }); + } + + /** + * Get the number of candles shown in Lite and Pro chart viewports. + * + * @returns The visible candle count. + */ + getVisibleCandleCount(): number { + const count = this.state.visibleCandleCount; + return Number.isFinite(count) ? count : VISIBLE_CANDLE_COUNT_CONFIG.Default; + } + + /** + * Set the number of candles shown in Lite and Pro chart viewports. + * + * @param count - Requested visible candle count. + */ + setVisibleCandleCount(count: number): void { + if (!Number.isFinite(count)) { + return; + } + + const normalized = Math.min( + VISIBLE_CANDLE_COUNT_CONFIG.Max, + Math.max(VISIBLE_CANDLE_COUNT_CONFIG.Min, Math.round(count)), + ); + this.update((state) => { + state.visibleCandleCount = normalized; + }); + } + + /** + * Get the user's pro-mode layout preferences (network-independent). + * + * @returns The current pro-mode layout preferences. + */ + getProLayoutPreferences(): ProLayoutPreferences { + // Merge over defaults so callers always receive a fully-populated object, + // even if the persisted state predates one of the fields. + return { + ...DEFAULT_PRO_LAYOUT_PREFERENCES, + ...this.state.proLayoutPreferences, + }; + } + + /** + * Update the user's pro-mode layout preferences. + * + * Patch-style setter: only the provided fields are updated, the rest are + * preserved. This keeps the signature stable as new layout fields are added. + * + * @param patch - Partial set of pro-mode layout preferences to update. + */ + setProLayoutPreferences(patch: Partial): void { + this.update((state) => { + state.proLayoutPreferences = { + ...state.proLayoutPreferences, + ...patch, + }; + }); + } + + /** + * Set the Perps interface mode (lite/pro). + * + * @param mode - The mode to switch to. + */ + setPerpsMode(mode: PerpsMode): void { + this.update((state) => { + state.mode = mode; + }); + } + + /** + * Set the selected payment token for the Perps order/deposit flow. + * Pass null or a token with description PERPS_CONSTANTS.PerpsBalanceTokenDescription to select Perps balance. + * Only required fields (address, chainId) are stored in state; description and symbol are optional. + * + * @param token - The token identifier. + */ + setSelectedPaymentToken(token: PerpsSelectedPaymentToken | null): void { + let normalized: PerpsSelectedPaymentToken | null = null; + if ( + token !== null && + token.description !== PERPS_CONSTANTS.PerpsBalanceTokenDescription + ) { + normalized = token; + } + + const current = this.state.selectedPaymentToken as + | SelectedPaymentTokenSnapshot + | null + | undefined; + const initialPaymentMethod = + current === null || + current === undefined || + current?.description === PERPS_CONSTANTS.PerpsBalanceTokenDescription + ? 'perps_balance' + : (current?.symbol ?? 'unknown'); + const newPaymentMethod = + token === null || + token.description === PERPS_CONSTANTS.PerpsBalanceTokenDescription + ? 'perps_balance' + : (token.symbol ?? 'unknown'); + + if (initialPaymentMethod !== newPaymentMethod) { + this.#getMetrics().trackPerpsEvent(PerpsAnalyticsEvent.UiInteraction, { + [PERPS_EVENT_PROPERTY.INTERACTION_TYPE]: + PERPS_EVENT_VALUE.INTERACTION_TYPE.PAYMENT_METHOD_CHANGED, + [PERPS_EVENT_PROPERTY.INITIAL_PAYMENT_METHOD]: initialPaymentMethod, + [PERPS_EVENT_PROPERTY.NEW_PAYMENT_METHOD]: newPaymentMethod, + }); + } + + let snapshot: Json | null = null; + if (normalized !== null) { + snapshot = { + ...(normalized.description !== undefined && { + description: normalized.description, + }), + address: normalized.address, + chainId: normalized.chainId, + symbol: normalized.symbol, + } as unknown as Json; + } + + this.update((state) => { + state.selectedPaymentToken = snapshot; + }); + } + + /** + * Reset the selected payment token to Perps balance (null). + * Call when leaving the Perps order view so the next visit defaults to Perps balance. + */ + resetSelectedPaymentToken(): void { + this.update((state) => { + state.selectedPaymentToken = null; + }); + } + + /** + * Get saved order book grouping for a market + * + * @param symbol - Market symbol + * @returns The saved grouping value or undefined if not set + */ + getOrderBookGrouping(symbol: string): number | undefined { + const network = this.state.isTestnet ? 'testnet' : 'mainnet'; + const grouping = + this.state.tradeConfigurations[network]?.[symbol]?.orderBookGrouping; + + if (grouping !== undefined) { + this.#debugLog('PerpsController: Retrieved order book grouping', { + symbol, + network, + grouping, + }); + } + + return grouping; + } + + /** + * Save order book grouping for a market + * + * @param symbol - Market symbol + * @param grouping - Price grouping value + */ + saveOrderBookGrouping(symbol: string, grouping: number): void { + const network = this.state.isTestnet ? 'testnet' : 'mainnet'; + + this.#debugLog('PerpsController: Saving order book grouping', { + symbol, + network, + grouping, + timestamp: new Date().toISOString(), + }); + + this.update((state) => { + if (!state.tradeConfigurations[network]) { + state.tradeConfigurations[network] = {}; + } + + const existingConfig = state.tradeConfigurations[network][symbol] || {}; + state.tradeConfigurations[network][symbol] = { + ...existingConfig, + orderBookGrouping: grouping, + }; + }); + } + + /** + * Toggle watchlist status for a market. + * + * Updates local state immediately (optimistic UI) and then syncs the new + * watchlist to AuthenticatedUserStorageService. If the remote write fails, + * the local state is reverted so it stays consistent with AUS. + * + * When the user is unauthenticated, or the active provider is not yet + * supported by the AUS schema, the controller continues operating with + * local-persisted state only — no error is surfaced to the caller. + * + * Watchlist markets are stored per network (testnet/mainnet). + * + * @param symbol - The trading pair symbol. + */ + async toggleWatchlistMarket(symbol: string): Promise { + const currentNetwork = this.state.isTestnet ? 'testnet' : 'mainnet'; + const currentWatchlist = this.state.watchlistMarkets[currentNetwork]; + const isWatchlisted = currentWatchlist.includes(symbol); + + this.#debugLog('PerpsController: Toggling watchlist market', { + timestamp: new Date().toISOString(), + network: currentNetwork, + symbol, + action: isWatchlisted ? 'remove' : 'add', + }); + + // Step 1: Optimistic local state update — UI reflects change immediately. + this.update((state) => { + if (isWatchlisted) { + state.watchlistMarkets[currentNetwork] = currentWatchlist.filter( + (marketSymbol) => marketSymbol !== symbol, + ); + } else { + state.watchlistMarkets[currentNetwork] = [...currentWatchlist, symbol]; + } + }); + + this.#getMetrics().trackPerpsEvent(PerpsAnalyticsEvent.UiInteraction, { + [PERPS_EVENT_PROPERTY.INTERACTION_TYPE]: + PERPS_EVENT_VALUE.INTERACTION_TYPE.FAVORITE_TOGGLED, + [PERPS_EVENT_PROPERTY.ASSET]: symbol, + [PERPS_EVENT_PROPERTY.ACTION_TYPE]: isWatchlisted + ? PERPS_EVENT_VALUE.ACTION_TYPE.UNFAVORITE_MARKET + : PERPS_EVENT_VALUE.ACTION_TYPE.FAVORITE_MARKET, + [PERPS_EVENT_PROPERTY.FAVORITES_COUNT]: + this.state.watchlistMarkets[currentNetwork].length, + }); + + // Step 2: Persist to AUS; revert local state if the write fails. + // Enqueue behind #ausQueue so that: + // - concurrent toggles serialize their GET-merge-PUT sequences, and + // - any in-flight init hydration completes before we issue a write. + try { + await new Promise((resolve, reject) => { + this.#ausQueue = this.#ausQueue + .then(() => this.#persistWatchlistToRemote(currentNetwork)) + .then(resolve, reject) + // Swallow the error on the queue chain so later operations can run. + .catch(() => undefined); + }); + } catch (error) { + this.#logError( + ensureError(error, 'PerpsController.toggleWatchlistMarket'), + this.#getErrorContext('toggleWatchlistMarket', { + symbol, + network: currentNetwork, + action: isWatchlisted ? 'remove' : 'add', + }), + ); + // Revert the optimistic update. + this.update((state) => { + state.watchlistMarkets[currentNetwork] = currentWatchlist; + }); + } + } + + /** + * Check if a market is in the watchlist on the current network + * + * @param symbol - The trading pair symbol. + * @returns True if the condition is met. + */ + isWatchlistMarket(symbol: string): boolean { + const currentNetwork = this.state.isTestnet ? 'testnet' : 'mainnet'; + return this.state.watchlistMarkets[currentNetwork].includes(symbol); + } + + /** + * Get all watchlist markets for the current network + * + * @returns The resulting string value. + */ + getWatchlistMarkets(): string[] { + const currentNetwork = this.state.isTestnet ? 'testnet' : 'mainnet'; + return this.state.watchlistMarkets[currentNetwork]; + } + + /** + * Record that the user viewed a market. + * + * The symbol is prepended to the per-network recently-viewed list (newest-first). + * Any existing entry for the same symbol is removed first so there are no + * duplicates. The list is then capped at PERPS_CONSTANTS.RecentlyViewedMarketsLimit. + * + * @param symbol - The trading pair symbol (e.g. 'BTC', 'ETH', 'xyz:TSLA'). + */ + recordMarketViewed(symbol: string): void { + const currentNetwork = this.state.isTestnet ? 'testnet' : 'mainnet'; + const now = Date.now(); + + this.update((state) => { + const current = state.recentlyViewedMarkets[currentNetwork].filter( + (entry) => entry.symbol !== symbol, + ); + state.recentlyViewedMarkets[currentNetwork] = [ + { symbol, viewedAt: now }, + ...current, + ].slice(0, PERPS_CONSTANTS.RecentlyViewedMarketsLimit); + }); + } + + /** + * Get recently viewed markets for the current network. + * + * Returns up to PERPS_CONSTANTS.RecentlyViewedMarketsLimit symbols, ordered + * newest-first, filtered to entries within the last + * PERPS_CONSTANTS.RecentlyViewedMarketsTtlMs (24 hours). Returns an empty + * array when no qualifying entries exist. + * + * @returns Ordered array of market symbols. + */ + getRecentlyViewedMarkets(): string[] { + const currentNetwork = this.state.isTestnet ? 'testnet' : 'mainnet'; + const cutoff = Date.now() - PERPS_CONSTANTS.RecentlyViewedMarketsTtlMs; + + return this.state.recentlyViewedMarkets[currentNetwork] + .filter((entry) => entry.viewedAt > cutoff) + .map((entry) => entry.symbol) + .slice(0, PERPS_CONSTANTS.RecentlyViewedMarketsLimit); + } + + /** + * Writes the current local watchlist to AuthenticatedUserStorageService + * using a read-merge-write strategy to avoid overwriting other preferences. + * + * Skips silently when: + * - The active provider has no AUS exchange key (e.g. `'aggregated'`). + * - The remote preferences blob does not yet exist (returns `null` / 404). + * In that case, `NotificationServicesController.createOnChainTriggers` is + * the canonical owner that creates the initial blob. + * + * Throws on remote write failure so the caller can decide whether to revert. + * + * @param network - Which network's list to sync ('testnet' | 'mainnet'). + */ + async #persistWatchlistToRemote( + network: 'testnet' | 'mainnet', + ): Promise { + const exchangeKey = resolveWatchlistExchangeKey(this.state.activeProvider); + if (!exchangeKey) { + this.#debugLog( + 'PerpsController: Skipping AUS watchlist sync — provider not mapped', + { activeProvider: this.state.activeProvider }, + ); + return; + } + + const prefs = await this.messenger.call( + 'AuthenticatedUserStorageService:getNotificationPreferences', + ); + + if (!prefs) { + this.#debugLog( + 'PerpsController: Skipping AUS watchlist write — preferences blob not yet initialised', + { exchangeKey, network }, + ); + return; + } + + const existingWatchlist: PerpsWatchlistMarkets = prefs.perps + .watchlistMarkets ?? { + hyperliquid: { testnet: [], mainnet: [] }, + myx: { testnet: [], mainnet: [] }, + }; + + const nextWatchlistMarkets: PerpsWatchlistMarkets = { + ...existingWatchlist, + [exchangeKey]: { + ...existingWatchlist[exchangeKey], + [network]: this.state.watchlistMarkets[network], + }, + }; + + const nextPrefs: NotificationPreferences = { + ...prefs, + perps: { + ...prefs.perps, + watchlistMarkets: nextWatchlistMarkets, + }, + }; + + await this.messenger.call( + 'AuthenticatedUserStorageService:putNotificationPreferences', + nextPrefs, + ); + + this.#debugLog('PerpsController: Watchlist synced to AUS', { + exchangeKey, + network, + count: this.state.watchlistMarkets[network].length, + }); + } + + /** + * Hydrates `state.watchlistMarkets` from AuthenticatedUserStorageService on + * controller initialisation. + * + * AUS is the source of truth; local state is used as an offline cache. + * This method also handles the one-time migration from local-only state to + * AUS for users who had a watchlist before AUS sync was introduced. + * + * All remote errors are swallowed so a transient network failure does not + * block the rest of `init()`. + */ + async #syncWatchlistFromRemote(): Promise { + const exchangeKey = resolveWatchlistExchangeKey(this.state.activeProvider); + if (!exchangeKey) { + this.#debugLog( + 'PerpsController: Skipping AUS watchlist hydration — provider not mapped', + { activeProvider: this.state.activeProvider }, + ); + return; + } + + try { + const prefs = await this.messenger.call( + 'AuthenticatedUserStorageService:getNotificationPreferences', + ); + + if (!prefs) { + this.#debugLog( + 'PerpsController: No AUS preferences blob — using local watchlist', + ); + return; + } + + const remoteExchangeWatchlist = + prefs.perps.watchlistMarkets?.[exchangeKey]; + + // AUS is the source of truth: an absent exchange key means this device + // has not been migrated yet — push any local favorites up once. + // A present key (even with empty arrays) must be honored as-is, + // including an intentional remote clear. + if (remoteExchangeWatchlist === undefined) { + // Blob exists but has no watchlist for this exchange yet. + // If local state has any markets, push them up as a one-time migration. + const { testnet, mainnet } = this.state.watchlistMarkets; + const hasLocalMarkets = testnet.length > 0 || mainnet.length > 0; + + if (hasLocalMarkets) { + this.#debugLog('PerpsController: Migrating local watchlist to AUS', { + exchangeKey, + testnetCount: testnet.length, + mainnetCount: mainnet.length, + }); + // Push testnet and mainnet together via a single read-merge-write. + // Start from existing remote watchlistMarkets (or empty fallback) so + // that other exchanges already stored in AUS are not overwritten. + const existingWatchlist: PerpsWatchlistMarkets = prefs.perps + .watchlistMarkets ?? { + hyperliquid: { testnet: [], mainnet: [] }, + myx: { testnet: [], mainnet: [] }, + }; + const nextWatchlistMarkets: PerpsWatchlistMarkets = { + ...existingWatchlist, + [exchangeKey]: { testnet, mainnet }, + }; + const nextPrefs: NotificationPreferences = { + ...prefs, + perps: { + ...prefs.perps, + watchlistMarkets: nextWatchlistMarkets, + }, + }; + await this.messenger.call( + 'AuthenticatedUserStorageService:putNotificationPreferences', + nextPrefs, + ); + this.#debugLog('PerpsController: Local watchlist migrated to AUS', { + exchangeKey, + }); + } + } else { + // AUS has an entry for this exchange — hydrate local state from it. + this.update((state) => { + state.watchlistMarkets.testnet = remoteExchangeWatchlist.testnet; + state.watchlistMarkets.mainnet = remoteExchangeWatchlist.mainnet; + }); + this.#debugLog('PerpsController: Watchlist hydrated from AUS', { + exchangeKey, + testnetCount: remoteExchangeWatchlist.testnet.length, + mainnetCount: remoteExchangeWatchlist.mainnet.length, + }); + } + } catch (error) { + this.#logError( + ensureError(error, 'PerpsController.syncWatchlistFromRemote'), + this.#getErrorContext('syncWatchlistFromRemote'), + ); + } + } + + /** + * Report order events to data lake API with retry (non-blocking) + * Thin delegation to DataLakeService + * + * @param params - The operation parameters. + * @param params.action - The order action. + * @param params.symbol - The trading pair symbol. + * @param params.slPrice - The stop loss price. + * @param params.tpPrice - The take profit price. + * @param params.retryCount - Internal retry counter. + * @param params._traceId - Internal trace ID. + * @returns Whether the report was sent successfully, with an optional error message. + */ + protected async reportOrderToDataLake(params: { + action: 'open' | 'close'; + symbol: string; + slPrice?: number; + tpPrice?: number; + retryCount?: number; + _traceId?: string; + }): Promise<{ success: boolean; error?: string }> { + return this.#dataLakeService.reportOrder({ + action: params.action, + symbol: params.symbol, + slPrice: params.slPrice, + tpPrice: params.tpPrice, + isTestnet: this.state.isTestnet, + context: this.#createServiceContext('reportOrderToDataLake', {}), + retryCount: params.retryCount, + _traceId: params._traceId, + }); + } + + /** + * Check if the controller is currently reinitializing + * + * @returns true if providers are being reinitialized + */ + public isCurrentlyReinitializing(): boolean { + return this.#isReinitializing; + } +} diff --git a/packages/perps-controller/src/aggregation/SubscriptionMultiplexer.ts b/packages/perps-controller/src/aggregation/SubscriptionMultiplexer.ts new file mode 100644 index 00000000000..75785579ef8 --- /dev/null +++ b/packages/perps-controller/src/aggregation/SubscriptionMultiplexer.ts @@ -0,0 +1,611 @@ +/** + * SubscriptionMultiplexer - Manages WebSocket subscriptions across multiple providers + * + * Responsibilities: + * - Manage subscriptions to multiple providers simultaneously + * - Tag all updates with providerId so UI can differentiate sources + * - Support aggregation modes: 'merge' (all prices) or 'best_price' (best price per symbol) + * - Cache latest updates per provider per symbol for aggregation + */ + +import { PERPS_CONSTANTS } from '../constants/perpsConfig.js'; +import type { + PerpsProviderType, + PerpsProvider, + PerpsLogger, + PriceUpdate, + Position, + OrderFill, + Order, + AccountState, + SubscribePricesParams, + SubscribePositionsParams, + SubscribeOrderFillsParams, + SubscribeOrdersParams, + SubscribeAccountParams, +} from '../types/index.js'; +import { ensureError } from '../utils/errorUtils.js'; + +/** + * Options for constructing SubscriptionMultiplexer + */ +export type SubscriptionMultiplexerOptions = { + /** Optional logger for error reporting (e.g., Sentry) */ + logger?: PerpsLogger; +}; + +/** + * Aggregation mode for price subscriptions + */ +export type PriceAggregationMode = 'merge' | 'best_price'; + +/** + * Parameters for multiplexed price subscriptions + */ +export type MultiplexedPricesParams = { + /** Symbols to subscribe to */ + symbols: string[]; + /** Provider instances to subscribe through */ + providers: [PerpsProviderType, PerpsProvider][]; + /** Callback to receive aggregated price updates */ + callback: (prices: PriceUpdate[]) => void; + /** Aggregation mode: 'merge' returns all prices, 'best_price' returns best per symbol */ + aggregationMode?: PriceAggregationMode; + /** Optional throttle in milliseconds */ + throttleMs?: number; + /** Include order book data */ + includeOrderBook?: boolean; + /** Include market data (funding, OI, volume) */ + includeMarketData?: boolean; +}; + +/** + * Parameters for multiplexed position subscriptions + */ +export type MultiplexedPositionsParams = { + /** Provider instances to subscribe through */ + providers: [PerpsProviderType, PerpsProvider][]; + /** Callback to receive aggregated position updates */ + callback: (positions: Position[]) => void; +}; + +/** + * Parameters for multiplexed order fill subscriptions + */ +export type MultiplexedOrderFillsParams = { + /** Provider instances to subscribe through */ + providers: [PerpsProviderType, PerpsProvider][]; + /** Callback to receive aggregated order fill updates */ + callback: (fills: OrderFill[], isSnapshot?: boolean) => void; +}; + +/** + * Parameters for multiplexed order subscriptions + */ +export type MultiplexedOrdersParams = { + /** Provider instances to subscribe through */ + providers: [PerpsProviderType, PerpsProvider][]; + /** Callback to receive aggregated order updates */ + callback: (orders: Order[]) => void; +}; + +/** + * Parameters for multiplexed account subscriptions + */ +export type MultiplexedAccountParams = { + /** Provider instances to subscribe through */ + providers: [PerpsProviderType, PerpsProvider][]; + /** Callback to receive account updates (one per provider) */ + callback: (accounts: AccountState[]) => void; +}; + +/** + * SubscriptionMultiplexer manages real-time data subscriptions across + * multiple perps providers. + * + * Key features: + * - Subscribes to all providers simultaneously + * - Tags all updates with source providerId + * - Caches latest values for aggregation + * - Supports different aggregation modes for prices + * + * @example + * ```typescript + * const mux = new SubscriptionMultiplexer(); + * + * const unsubscribe = mux.subscribeToPrices({ + * symbols: ['BTC', 'ETH'], + * providers: [ + * ['hyperliquid', hlProvider], + * ['myx', myxProvider], + * ], + * callback: (prices) => { + * // prices have providerId injected + * prices.forEach(p => console.log(`${p.providerId}: ${p.symbol} = ${p.price}`)); + * }, + * aggregationMode: 'merge', + * }); + * + * // Later: clean up + * unsubscribe(); + * ``` + */ +export class SubscriptionMultiplexer { + /** + * Optional logger for error reporting + */ + readonly #logger?: PerpsLogger; + + /** + * Cache of latest prices per symbol per provider + * Map> + */ + readonly #priceCache: Map> = + new Map(); + + /** + * Cache of latest positions per provider + * Map + */ + readonly #positionCache: Map = new Map(); + + /** + * Cache of latest orders per provider + * Map + */ + readonly #orderCache: Map = new Map(); + + /** + * Cache of latest account state per provider + * Map + */ + readonly #accountCache: Map = new Map(); + + /** + * Create a new SubscriptionMultiplexer. + * + * @param options - Optional configuration including logger for error reporting + */ + constructor(options?: SubscriptionMultiplexerOptions) { + this.#logger = options?.logger; + } + + /** + * Subscribe to price updates from multiple providers. + * + * @param params - Subscription parameters + * @returns Unsubscribe function + */ + subscribeToPrices(params: MultiplexedPricesParams): () => void { + const { + symbols, + providers, + callback, + aggregationMode = 'merge', + throttleMs, + includeOrderBook, + includeMarketData, + } = params; + + const unsubscribers: (() => void)[] = []; + + // Subscribe to each provider with defensive error handling + for (const [providerId, provider] of providers) { + try { + const subscribeParams: SubscribePricesParams = { + symbols, + callback: (updates) => { + // Tag and cache each update + updates.forEach((update) => { + const taggedUpdate: PriceUpdate = { ...update, providerId }; + + // Initialize symbol cache if needed + if (!this.#priceCache.has(update.symbol)) { + this.#priceCache.set(update.symbol, new Map()); + } + const symbolCache = this.#priceCache.get(update.symbol); + if (symbolCache) { + symbolCache.set(providerId, taggedUpdate); + } + }); + + // Aggregate and emit based on mode + const aggregated = this.#aggregatePrices(symbols, aggregationMode); + callback(aggregated); + }, + throttleMs, + includeOrderBook, + includeMarketData, + }; + + const unsub = provider.subscribeToPrices(subscribeParams); + unsubscribers.push(unsub); + } catch (error) { + // Log to Sentry before cleanup + this.#logger?.error( + ensureError(error, 'SubscriptionMultiplexer.subscribeToPrices'), + { + tags: { + feature: PERPS_CONSTANTS.FeatureName, + provider: providerId, + method: 'subscribeToPrices', + }, + context: { + name: 'SubscriptionMultiplexer', + data: { subscribedCount: unsubscribers.length }, + }, + }, + ); + + // Clean up any subscriptions created before the failure + unsubscribers.forEach((unsub) => unsub()); + throw error; + } + } + + // Return combined unsubscribe function + return () => { + unsubscribers.forEach((unsub) => unsub()); + // Optionally clear cache for these symbols + symbols.forEach((symbol) => { + this.#priceCache.delete(symbol); + }); + }; + } + + /** + * Subscribe to position updates from multiple providers. + * + * @param params - Subscription parameters + * @returns Unsubscribe function + */ + subscribeToPositions(params: MultiplexedPositionsParams): () => void { + const { providers, callback } = params; + const unsubscribers: (() => void)[] = []; + + for (const [providerId, provider] of providers) { + try { + const subscribeParams: SubscribePositionsParams = { + callback: (positions) => { + // Tag positions with providerId and cache + const taggedPositions = positions.map((pos) => ({ + ...pos, + providerId, + })); + this.#positionCache.set(providerId, taggedPositions); + + // Emit aggregated positions from all providers + const allPositions = this.#aggregatePositions(); + callback(allPositions); + }, + }; + + const unsub = provider.subscribeToPositions(subscribeParams); + unsubscribers.push(unsub); + } catch (error) { + this.#logger?.error( + ensureError(error, 'SubscriptionMultiplexer.subscribeToPositions'), + { + tags: { + feature: PERPS_CONSTANTS.FeatureName, + provider: providerId, + method: 'subscribeToPositions', + }, + context: { + name: 'SubscriptionMultiplexer', + data: { subscribedCount: unsubscribers.length }, + }, + }, + ); + unsubscribers.forEach((unsub) => unsub()); + throw error; + } + } + + return () => { + unsubscribers.forEach((unsub) => unsub()); + // Clear position cache for these providers + providers.forEach(([providerId]) => { + this.#positionCache.delete(providerId); + }); + }; + } + + /** + * Subscribe to order fill updates from multiple providers. + * + * @param params - Subscription parameters + * @returns Unsubscribe function + */ + subscribeToOrderFills(params: MultiplexedOrderFillsParams): () => void { + const { providers, callback } = params; + const unsubscribers: (() => void)[] = []; + + for (const [providerId, provider] of providers) { + try { + const subscribeParams: SubscribeOrderFillsParams = { + callback: (fills, isSnapshot) => { + // Tag fills with providerId + const taggedFills = fills.map((fill) => ({ + ...fill, + providerId, + })); + + // For fills, we don't aggregate - emit immediately with tags + callback(taggedFills, isSnapshot); + }, + }; + + const unsub = provider.subscribeToOrderFills(subscribeParams); + unsubscribers.push(unsub); + } catch (error) { + this.#logger?.error( + ensureError(error, 'SubscriptionMultiplexer.subscribeToOrderFills'), + { + tags: { + feature: PERPS_CONSTANTS.FeatureName, + provider: providerId, + method: 'subscribeToOrderFills', + }, + context: { + name: 'SubscriptionMultiplexer', + data: { subscribedCount: unsubscribers.length }, + }, + }, + ); + unsubscribers.forEach((unsub) => unsub()); + throw error; + } + } + + return () => { + unsubscribers.forEach((unsub) => unsub()); + }; + } + + /** + * Subscribe to order updates from multiple providers. + * + * @param params - Subscription parameters + * @returns Unsubscribe function + */ + subscribeToOrders(params: MultiplexedOrdersParams): () => void { + const { providers, callback } = params; + const unsubscribers: (() => void)[] = []; + + for (const [providerId, provider] of providers) { + try { + const subscribeParams: SubscribeOrdersParams = { + callback: (orders) => { + // Tag orders with providerId and cache + const taggedOrders = orders.map((order) => ({ + ...order, + providerId, + })); + this.#orderCache.set(providerId, taggedOrders); + + // Emit aggregated orders from all providers + const allOrders = this.#aggregateOrders(); + callback(allOrders); + }, + }; + + const unsub = provider.subscribeToOrders(subscribeParams); + unsubscribers.push(unsub); + } catch (error) { + this.#logger?.error( + ensureError(error, 'SubscriptionMultiplexer.subscribeToOrders'), + { + tags: { + feature: PERPS_CONSTANTS.FeatureName, + provider: providerId, + method: 'subscribeToOrders', + }, + context: { + name: 'SubscriptionMultiplexer', + data: { subscribedCount: unsubscribers.length }, + }, + }, + ); + unsubscribers.forEach((unsub) => unsub()); + throw error; + } + } + + return () => { + unsubscribers.forEach((unsub) => unsub()); + providers.forEach(([providerId]) => { + this.#orderCache.delete(providerId); + }); + }; + } + + /** + * Subscribe to account updates from multiple providers. + * + * @param params - Subscription parameters + * @returns Unsubscribe function + */ + subscribeToAccount(params: MultiplexedAccountParams): () => void { + const { providers, callback } = params; + const unsubscribers: (() => void)[] = []; + + for (const [providerId, provider] of providers) { + try { + const subscribeParams: SubscribeAccountParams = { + callback: (account) => { + if (account === null) { + this.#accountCache.delete(providerId); + } else { + // Tag account with providerId and cache + const taggedAccount: AccountState = { + ...account, + providerId, + }; + this.#accountCache.set(providerId, taggedAccount); + } + + // Emit all cached account states + const allAccounts = Array.from(this.#accountCache.values()); + callback(allAccounts); + }, + }; + + const unsub = provider.subscribeToAccount(subscribeParams); + unsubscribers.push(unsub); + } catch (error) { + this.#logger?.error( + ensureError(error, 'SubscriptionMultiplexer.subscribeToAccount'), + { + tags: { + feature: PERPS_CONSTANTS.FeatureName, + provider: providerId, + method: 'subscribeToAccount', + }, + context: { + name: 'SubscriptionMultiplexer', + data: { subscribedCount: unsubscribers.length }, + }, + }, + ); + unsubscribers.forEach((unsub) => unsub()); + throw error; + } + } + + return () => { + unsubscribers.forEach((unsub) => unsub()); + providers.forEach(([providerId]) => { + this.#accountCache.delete(providerId); + }); + }; + } + + /** + * Aggregate cached prices based on mode. + * + * @param symbols - Symbols to include in result + * @param mode - Aggregation mode + * @returns Aggregated price updates + */ + #aggregatePrices( + symbols: string[], + mode: PriceAggregationMode, + ): PriceUpdate[] { + const result: PriceUpdate[] = []; + + symbols.forEach((symbol) => { + const providerPrices = this.#priceCache.get(symbol); + if (!providerPrices || providerPrices.size === 0) { + return; + } + + if (mode === 'merge') { + // Return all prices (one per provider) + providerPrices.forEach((price) => { + result.push(price); + }); + } else { + // 'best_price': Return the best price across providers + const best = this.#findBestPrice(providerPrices); + if (best) { + result.push(best); + } + } + }); + + return result; + } + + /** + * Find the best price from multiple provider prices. + * "Best" is defined as the price with the smallest spread. + * + * @param providerPrices - Map of provider prices for a symbol + * @returns Best price update or undefined + */ + #findBestPrice( + providerPrices: Map, + ): PriceUpdate | undefined { + let bestPrice: PriceUpdate | undefined; + let smallestSpread = Infinity; + + providerPrices.forEach((price) => { + if (price.spread === undefined) { + // No spread info - just use the first one + bestPrice ??= price; + } else { + // If spread is available, use it to determine best + const spreadValue = parseFloat(price.spread); + if (!isNaN(spreadValue) && spreadValue < smallestSpread) { + smallestSpread = spreadValue; + bestPrice = price; + } + } + }); + + return bestPrice; + } + + /** + * Aggregate positions from all providers. + * + * @returns All cached positions + */ + #aggregatePositions(): Position[] { + const allPositions: Position[] = []; + this.#positionCache.forEach((positions) => { + allPositions.push(...positions); + }); + return allPositions; + } + + /** + * Aggregate orders from all providers. + * + * @returns All cached orders + */ + #aggregateOrders(): Order[] { + const allOrders: Order[] = []; + this.#orderCache.forEach((orders) => { + allOrders.push(...orders); + }); + return allOrders; + } + + /** + * Clear all cached data. + */ + clearCache(): void { + this.#priceCache.clear(); + this.#positionCache.clear(); + this.#orderCache.clear(); + this.#accountCache.clear(); + } + + /** + * Get cached price for a symbol from a specific provider. + * + * @param symbol - Market symbol + * @param providerId - Provider ID + * @returns Cached price update or undefined + */ + getCachedPrice( + symbol: string, + providerId: PerpsProviderType, + ): PriceUpdate | undefined { + return this.#priceCache.get(symbol)?.get(providerId); + } + + /** + * Get all cached prices for a symbol. + * + * @param symbol - Market symbol + * @returns Map of provider ID to price update + */ + getAllCachedPricesForSymbol( + symbol: string, + ): Map | undefined { + return this.#priceCache.get(symbol); + } +} diff --git a/packages/perps-controller/src/aggregation/index.ts b/packages/perps-controller/src/aggregation/index.ts new file mode 100644 index 00000000000..bc4b3e19c38 --- /dev/null +++ b/packages/perps-controller/src/aggregation/index.ts @@ -0,0 +1,12 @@ +/** + * Provider aggregation module exports + */ +export { SubscriptionMultiplexer } from './SubscriptionMultiplexer.js'; +export type { + PriceAggregationMode, + MultiplexedPricesParams, + MultiplexedPositionsParams, + MultiplexedOrderFillsParams, + MultiplexedOrdersParams, + MultiplexedAccountParams, +} from './SubscriptionMultiplexer.js'; diff --git a/packages/perps-controller/src/constants/chartConfig.ts b/packages/perps-controller/src/constants/chartConfig.ts new file mode 100644 index 00000000000..a4ddb61b186 --- /dev/null +++ b/packages/perps-controller/src/constants/chartConfig.ts @@ -0,0 +1,253 @@ +/** + * Portable chart configuration constants for PerpsController + * NO UI dependencies (@metamask/design-tokens, Colors, Theme) + * + * UI-specific exports (PERPS_CHART_CONFIG, CHART_INTERVALS, TIME_DURATIONS, + * getCandlestickColors) remain in the outer constants/chartConfig.ts + */ + +/** + * Enum for available candle periods + * Provides type safety and prevents typos when referencing candle periods + */ +export enum CandlePeriod { + OneMinute = '1m', + ThreeMinutes = '3m', + FiveMinutes = '5m', + FifteenMinutes = '15m', + ThirtyMinutes = '30m', + OneHour = '1h', + TwoHours = '2h', + FourHours = '4h', + EightHours = '8h', + TwelveHours = '12h', + OneDay = '1d', + ThreeDays = '3d', + OneWeek = '1w', + OneMonth = '1M', +} + +/** + * Enum for available time durations + * Provides type safety and prevents typos when referencing durations + */ +export enum TimeDuration { + OneHour = '1hr', + OneDay = '1d', + OneWeek = '1w', + OneMonth = '1m', + YearToDate = 'ytd', + Max = 'max', +} + +/** + * Enum for chart intervals (legacy support) + * Note: Some intervals overlap with CandlePeriod but serve different purposes + */ +export enum ChartInterval { + OneMinute = '1m', + FiveMinutes = '5m', + FifteenMinutes = '15m', + ThirtyMinutes = '30m', + OneHour = '1h', + TwoHours = '2h', + FourHours = '4h', + EightHours = '8h', +} + +/** + * Maximum number of candles to load in memory + * Extracted from PERPS_CHART_CONFIG.CANDLE_COUNT.TOTAL for portability + */ +export const MAX_CANDLE_COUNT = 500; + +/** + * Bounds and default for the user-controlled number of visible candles. + * + * This preference is shared by Lite and Pro charts. The maximum is lower than + * MAX_CANDLE_COUNT because the latter controls fetched history, not viewport + * density. + */ +export const VISIBLE_CANDLE_COUNT_CONFIG = { + Min: 10, + Default: 30, + Max: 250, +} as const; + +/** + * Available candle periods mapped to each time duration + * This ensures users only see sensible candle periods for each duration + * and keeps the chart readable on mobile screens (target: ~20-100 candles) + */ +export const DURATION_CANDLE_PERIODS = { + [TimeDuration.OneHour]: { + periods: [ + { label: '1min', value: CandlePeriod.OneMinute }, // 60 candles + { label: '3min', value: CandlePeriod.ThreeMinutes }, // 20 candles + { label: '5min', value: CandlePeriod.FiveMinutes }, // 12 candles + { label: '15min', value: CandlePeriod.FifteenMinutes }, // 4 candles + ], + default: CandlePeriod.OneMinute, // 1-minute candles for development/testing + }, + [TimeDuration.OneDay]: { + periods: [ + { label: '15min', value: CandlePeriod.FifteenMinutes }, // 96 candles + { label: '1h', value: CandlePeriod.OneHour }, // 24 candles + { label: '2h', value: CandlePeriod.TwoHours }, // 12 candles + { label: '4h', value: CandlePeriod.FourHours }, // 6 candles + ], + default: CandlePeriod.OneHour, // Good balance for daily view + }, + [TimeDuration.OneWeek]: { + periods: [ + { label: '1h', value: CandlePeriod.OneHour }, // 168 candles (bit high, but acceptable) + { label: '2h', value: CandlePeriod.TwoHours }, // 84 candles + { label: '4h', value: CandlePeriod.FourHours }, // 42 candles + { label: '8h', value: CandlePeriod.EightHours }, // 21 candles + { label: '1D', value: CandlePeriod.OneDay }, // 7 candles + ], + default: CandlePeriod.FourHours, // Good detail for weekly view + }, + [TimeDuration.OneMonth]: { + periods: [ + { label: '8h', value: CandlePeriod.EightHours }, // 90 candles (30 days * 3 per day) + { label: '12h', value: CandlePeriod.TwelveHours }, // 60 candles (30 days * 2 per day) + { label: '1D', value: CandlePeriod.OneDay }, // 30 candles + { label: '1W', value: CandlePeriod.OneWeek }, // ~4 candles + ], + default: CandlePeriod.OneDay, // Daily candles for monthly view + }, + [TimeDuration.YearToDate]: { + periods: [ + { label: '1D', value: CandlePeriod.OneDay }, // ~365 candles (will be capped) + { label: '1W', value: CandlePeriod.OneWeek }, // ~52 candles + ], + default: CandlePeriod.OneWeek, // Weekly candles for yearly view + }, + [TimeDuration.Max]: { + periods: [ + { label: '1W', value: CandlePeriod.OneWeek }, // ~104 candles (2 years) + ], + default: CandlePeriod.OneWeek, // Only weekly makes sense for max view + }, +} as const; + +export const CANDLE_PERIODS = [ + { label: '1m', value: CandlePeriod.OneMinute }, + { label: '3m', value: CandlePeriod.ThreeMinutes }, + { label: '5m', value: CandlePeriod.FiveMinutes }, + { label: '15m', value: CandlePeriod.FifteenMinutes }, + { label: '30m', value: CandlePeriod.ThirtyMinutes }, + { label: '1h', value: CandlePeriod.OneHour }, + { label: '2h', value: CandlePeriod.TwoHours }, + { label: '4h', value: CandlePeriod.FourHours }, + { label: '8h', value: CandlePeriod.EightHours }, + { label: '12h', value: CandlePeriod.TwelveHours }, + { label: '1d', value: CandlePeriod.OneDay }, + { label: '3d', value: CandlePeriod.ThreeDays }, + { label: '7d', value: CandlePeriod.OneWeek }, +] as const; + +export const DEFAULT_CANDLE_PERIOD = CandlePeriod.FifteenMinutes; + +/** + * Get available candle periods for a specific duration + * + * @param duration - The time duration to retrieve candle periods for. + * @returns The list of candle period options available for the given duration. + */ +export const getCandlePeriodsForDuration = ( + duration: TimeDuration | string, +): readonly { label: string; value: CandlePeriod }[] => { + const periods = + DURATION_CANDLE_PERIODS[duration as TimeDuration]?.periods || []; + + return periods; +}; + +/** + * Get the default candle period for a specific duration + * + * @param duration - The time duration to retrieve the default candle period for. + * @returns The default candle period for the given duration. + */ +export const getDefaultCandlePeriodForDuration = ( + duration: TimeDuration | string, +): CandlePeriod => + DURATION_CANDLE_PERIODS[duration as TimeDuration]?.default || + CandlePeriod.OneHour; + +/** + * Calculate the number of candles to fetch based on duration and candle period + * + * @param duration - The time duration for the chart display. + * @param candlePeriod - The candle period interval. + * @returns The number of candles to fetch, capped at MAX_CANDLE_COUNT. + */ +export const calculateCandleCount = ( + duration: TimeDuration | string, + candlePeriod: CandlePeriod | string, +): number => { + // Convert candle period to minutes + const periodInMinutes = ((): number => { + switch (candlePeriod) { + case CandlePeriod.OneMinute: + return 1; + case CandlePeriod.ThreeMinutes: + return 3; + case CandlePeriod.FiveMinutes: + return 5; + case CandlePeriod.FifteenMinutes: + return 15; + case CandlePeriod.ThirtyMinutes: + return 30; + case CandlePeriod.OneHour: + return 60; + case CandlePeriod.TwoHours: + return 120; + case CandlePeriod.FourHours: + return 240; + case CandlePeriod.EightHours: + return 480; + case CandlePeriod.TwelveHours: + return 720; + case CandlePeriod.OneDay: + return 1440; // 24 * 60 + case CandlePeriod.ThreeDays: + return 4320; // 3 * 24 * 60 + case CandlePeriod.OneWeek: + return 10080; // 7 * 24 * 60 + case CandlePeriod.OneMonth: + return 43200; // 30 * 24 * 60 (approximate) + default: + return 60; // Default to 1h + } + })(); + + // Convert duration to total minutes needed + const durationInMinutes = ((): number => { + switch (duration) { + case TimeDuration.OneHour: + return 60; // 1 hour + case TimeDuration.OneDay: + return 60 * 24; // 1 day + case TimeDuration.OneWeek: + return 60 * 24 * 7; // 1 week + case TimeDuration.OneMonth: + return 60 * 24 * 30; // 1 month (30 days) + case TimeDuration.YearToDate: + return 60 * 24 * 365; // Year to date (365 days max) + case TimeDuration.Max: + return 60 * 24 * 365 * 2; // Max (2 years) + default: + return 60 * 24; // Default to 1 day + } + })(); + + // Calculate number of candles needed + const candleCount = Math.ceil(durationInMinutes / periodInMinutes); + + // Cap at MAX_CANDLE_COUNT candles max for memory management + // Allow minimum of 10 candles for basic functionality + return Math.min(Math.max(candleCount, 10), MAX_CANDLE_COUNT); +}; diff --git a/packages/perps-controller/src/constants/eventNames.ts b/packages/perps-controller/src/constants/eventNames.ts new file mode 100644 index 00000000000..529788c04eb --- /dev/null +++ b/packages/perps-controller/src/constants/eventNames.ts @@ -0,0 +1,684 @@ +/** + * Perps event property keys and values - matching dashboard requirements exactly + * Event names are defined in MetaMetrics.events.ts as the single source of truth + */ + +/** + * Event property keys - ensures consistent property naming + */ +export const PERPS_EVENT_PROPERTY = { + // Common properties + TIMESTAMP: 'timestamp', + ASSET: 'asset', + DIRECTION: 'direction', + SOURCE: 'source', + TAB_NAME: 'tab_name', + LOCATION: 'location', + + // Trade properties + LEVERAGE: 'leverage', + LEVERAGE_USED: 'leverage_used', + // Perp UI Interaction `leverage_changed`: prior leverage before the user change + PREVIOUS_LEVERAGE: 'previous_leverage', + ORDER_SIZE: 'order_size', + MARGIN_USED: 'margin_used', + ORDER_TYPE: 'order_type', // lowercase per dashboard + ORDER_TIMESTAMP: 'order_timestamp', + LIMIT_PRICE: 'limit_price', + FEES: 'fees', + FEE: 'fee', + METAMASK_FEE: 'metamask_fee', + METAMASK_FEE_RATE: 'metamask_fee_rate', + DISCOUNT_PERCENTAGE: 'discount_percentage', + ESTIMATED_REWARDS: 'estimated_rewards', + ASSET_PRICE: 'asset_price', + COMPLETION_DURATION: 'completion_duration', + + // Position properties + OPEN_POSITION: 'open_position', + OPEN_ORDER: 'open_order', + OPEN_POSITION_SIZE: 'open_position_size', + UNREALIZED_PNL_DOLLAR: 'unrealized_dollar_pnl', + UNREALIZED_PNL_PERCENT: 'unrealized_percent_pnl', + CLOSE_VALUE: 'close_value', + CLOSE_PERCENTAGE: 'close_percentage', + CLOSE_TYPE: 'close_type', + PERCENTAGE_CLOSED: 'percentage_closed', + PNL_DOLLAR: 'dollar_pnl', + PNL_PERCENT: 'percent_pnl', + RECEIVED_AMOUNT: 'received_amount', + + // Order type variations + CURRENT_ORDER_TYPE: 'current_order_type', + SELECTED_ORDER_TYPE: 'selected_order_type', + + // Funding properties + SOURCE_CHAIN: 'source_chain', + SOURCE_ASSET: 'source_asset', + SOURCE_AMOUNT: 'source_amount', + DESTINATION_AMOUNT: 'destination_amount', + NETWORK_FEE: 'network_fee', + WITHDRAWAL_AMOUNT: 'withdrawal_amount', + + // Chart properties + INTERACTION_TYPE: 'interaction_type', + TIME_SERIE_SELECTED: 'time_serie_selected', + CANDLE_PERIOD: 'candle_period', + CHART_LIBRARY: 'chart_library', + ASSET_TYPE: 'asset_type', + + // Risk management properties + STOP_LOSS_PRICE: 'stop_loss_price', + STOP_LOSS_PERCENT: 'stop_loss_percent', + TAKE_PROFIT_PRICE: 'take_profit_price', + TAKE_PROFIT_PERCENT: 'take_profit_percent', + POSITION_SIZE: 'position_size', + POSITION_AGE: 'position_age', + LIQUIDATION_DISTANCE_OLD: 'liquidation_distance_old', + LIQUIDATION_DISTANCE_NEW: 'liquidation_distance_new', + + // Notification properties + NOTIFICATION_TYPE: 'notification_type', + + // Other properties + INPUT_METHOD: 'input_method', // camelCase per requirements + ACTION_TYPE: 'action_type', + SETTING_TYPE: 'setting_type', + FAILURE_REASON: 'failure_reason', + WARNING_TYPE: 'warning_type', + WARNING_MESSAGE: 'warning_message', + ERROR_TYPE: 'error_type', + ERROR_MESSAGE: 'error_message', + AB_TESTS: 'ab_tests', + COMPLETION_DURATION_TUTORIAL: 'completion_duration_tutorial', + STEPS_VIEWED: 'steps_viewed', + VIEW_OCCURRENCES: 'view_occurrences', + AMOUNT_FILLED: 'amount_filled', + REMAINING_AMOUNT: 'remaining_amount', + NUMBER_POSITIONS_CLOSED: 'number_positions_closed', + + // Tutorial carousel navigation properties + PREVIOUS_SCREEN: 'previous_screen', + CURRENT_SCREEN: 'current_screen', + SCREEN_POSITION: 'screen_position', + TOTAL_SCREENS: 'total_screens', + NAVIGATION_METHOD: 'navigation_method', + STATUS: 'status', + SCREEN_TYPE: 'screen_type', + SCREEN_NAME: 'screen_name', + ACTION: 'action', + RETRY_ATTEMPTS: 'retry_attempts', + SHOW_BACK_BUTTON: 'show_back_button', + ATTEMPT_NUMBER: 'attempt_number', + + // PnL Hero Card properties + IMAGE_SELECTED: 'image_selected', + TAB_NUMBER: 'tab_number', + + // VIP rewards properties + VIP_TIER: 'vip_tier', + VIP_DISCOUNT: 'vip_discount', + + // A/B testing properties (flat per test for multiple concurrent tests) + // Only include AB test properties when test is enabled (event not sent when disabled) + // Button color test + AB_TEST_BUTTON_COLOR: 'ab_test_button_color', + // Future tests: add as AB_TEST_{TEST_NAME} (no _ENABLED property needed) + + // Entry point tracking properties + BUTTON_CLICKED: 'button_clicked', + BUTTON_LOCATION: 'button_location', + + // Balance properties + HAS_PERP_BALANCE: 'has_perp_balance', + + // Service interruption banner + OUTAGE_BANNER_SHOWN: 'outage_banner_shown', + + // Geo-blocking properties (TAT-2337: track geo-blocked withdrawals for monitoring) + IS_GEO_BLOCKED: 'is_geo_blocked', + + // TP/SL differentiation properties + HAS_TAKE_PROFIT: 'has_take_profit', + HAS_STOP_LOSS: 'has_stop_loss', + TAKE_PROFIT_PERCENTAGE: 'take_profit_percentage', + STOP_LOSS_PERCENTAGE: 'stop_loss_percentage', + // Auto Close TP/SL RoE sign toggle (`'+'` | `'-'`) + ROE_SIGN: 'roe_sign', + // Watchlist/Favorites properties + FAVORITES_COUNT: 'favorites_count', + + // Scroll tracking properties + SECTION_VIEWED: 'section_viewed', + + // Discovery analytics properties + SOURCE_SECTION: 'source_section', + RESULT_COUNT: 'result_count', + SECTION_NAME: 'section_name', + SECTION_INDEX: 'section_index', + SECTIONS_DISPLAYED: 'sections_displayed', + WATCHLIST_COUNT: 'watchlist_count', + WATCHLIST_MARKETS: 'watchlist_markets', + + // Order value (USD $ value of the order) + ORDER_VALUE: 'order_value', + + // Market category filter (for market list screen) + MARKET_CATEGORY: 'market_category', + + // Pay with any token (PERPS_TRADE_TRANSACTION) + TRADE_WITH_TOKEN: 'trade_with_token', + MM_PAY_TOKEN_SELECTED: 'mm_pay_token_selected', + MM_PAY_NETWORK_SELECTED: 'mm_pay_network_selected', + + // Pay-with UI (PERPS_UI_INTERACTION) + INITIAL_PAYMENT_METHOD: 'initial_payment_method', + NEW_PAYMENT_METHOD: 'new_payment_method', + + // Slippage properties + MAX_SLIPPAGE_PCT: 'max_slippage_pct', + MAX_SLIPPAGE_SOURCE: 'max_slippage_source', + ESTIMATED_SLIPPAGE_PCT: 'estimated_slippage_pct', + + // Account setup / abstraction mode (PERPS_ACCOUNT_SETUP) + ABSTRACTION_MODE: 'abstraction_mode', + PREVIOUS_ABSTRACTION_MODE: 'previous_abstraction_mode', + + // Entry point / discovery attribution + ENTRY_POINT: 'entry_point', + DISCOVERY_SOURCE: 'discovery_source', + PERP_DISCOVERY_SOURCE: 'perp_discovery_source', + + // UTM attribution context + UTM_SOURCE: 'utm_source', + UTM_MEDIUM: 'utm_medium', + UTM_CAMPAIGN: 'utm_campaign', + UTM_CONTENT: 'utm_content', + UTM_TERM: 'utm_term', + + // Watchlist membership at event time + WATCHLISTED: 'watchlisted', + + // HyperLiquid protocol fee rate on trade + close + HL_FEE_RATE: 'hl_fee_rate', + + // Bulk action correlation id for batch close/cancel + BULK_ACTION_ID: 'bulk_action_id', + + // Client environment (Extension supplies value) + ENVIRONMENT_TYPE: 'environment_type', + + // Order funnel / consideration + quote properties + ORDER_CONTEXT: 'order_context', + ORDER_SIZE_PERCENT: 'order_size_percent', + LIMIT_PRICE_INPUT_TYPE: 'limit_price_input_type', + LIMIT_PRICE_INPUT_PRESET: 'limit_price_input_preset', + ORDER_HAS_TP: 'order_has_tp', + ORDER_HAS_SL: 'order_has_sl', + QUOTE_LATENCY_MS: 'quote_latency_ms', + ERROR_REASON: 'error_reason', + SAVED_ORDER: 'saved_order', + DEFAULT_PAYMENT_TOKEN: 'default_payment_token', + DEFAULT_SIZE_AMOUNT: 'default_size_amount', + DEFAULT_LEVERAGE: 'default_leverage', + DEFAULT_AUTO_CLOSE: 'default_auto_close', + ORDER_EXECUTION_LATENCY_MS: 'order_execution_latency_ms', + SCREEN_CONTEXT: 'screen_context', + FROM_TOKEN: 'from_token', + FROM_CHAIN: 'from_chain', + TO_TOKEN: 'to_token', + TO_CHAIN: 'to_chain', + + // Search / discovery query properties + SEARCH_QUERY: 'search_query', + RESULTS_COUNT: 'results_count', + RESULT_RANK: 'result_rank', + // Search intent (`discovery` / `intent` / `browse`) — not Lite/Pro UI mode + MODE: 'mode', + CURRENT_TOKEN: 'current_token', + + // Lite/Pro interface mode (`'lite' | 'pro'`) + PERPS_MODE: 'perps_mode', + + // Sort / filter properties + SORT_FIELD: 'sort_field', + SORT_DIRECTION: 'sort_direction', + FILTER_CATEGORY: 'filter_category', + + // Time-on-screen for abandon tracking + TIME_ON_SCREEN_MS: 'time_on_screen_ms', +} as const; + +/** + * Property value constants + */ +export const PERPS_EVENT_VALUE = { + DIRECTION: { + LONG: 'long', + SHORT: 'short', + }, + ORDER_TYPE: { + MARKET: 'market', + LIMIT: 'limit', + // Trigger placements are emitted verbatim by TradingService, so the enum has + // to list them for dashboards keyed on `order_type`. + STOP_MARKET: 'stop_market', + STOP_LIMIT: 'stop_limit', + TAKE_PROFIT_MARKET: 'take_profit_market', + TAKE_PROFIT_LIMIT: 'take_profit_limit', + // Strategy placements, likewise emitted verbatim. + TWAP: 'twap', + SCALE: 'scale', + CHASE: 'chase', + }, + ORDER_TYPE_CAPITALIZED: { + MARKET: 'market', + LIMIT: 'limit', + }, + CHART_LIBRARY: { + LIGHTWEIGHT: 'lightweight', + ADVANCED: 'advanced', + }, + ASSET_TYPE: { + SPOT: 'spot', + PERP: 'perp', + }, + INPUT_METHOD: { + SLIDER: 'slider', + KEYBOARD: 'keyboard', + PRESET: 'preset', + MANUAL: 'manual', + PERCENTAGE_BUTTON: 'percentage_button', + }, + SOURCE: { + BANNER: 'banner', + NOTIFICATION: 'notification', + MAIN_ACTION_BUTTON: 'main_action_button', + POSITION_TAB: 'position_tab', + PERP_MARKETS: 'perp_markets', + DEEPLINK: 'deeplink', + TUTORIAL: 'tutorial', + TRADE_SCREEN: 'trade_screen', + HOMESCREEN_TAB: 'homescreen_tab', + PERP_ASSET_SCREEN: 'perp_asset_screen', + PERP_MARKET: 'perp_market', + PERP_MARKET_SEARCH: 'perp_market_search', + POSITION_SCREEN: 'position_screen', + BOTTOM_NAV_BAR: 'bottom_nav_bar', + TP_SL_VIEW: 'tp_sl_view', + PERPS_HOME: 'perps_home', + PERPS_TUTORIAL: 'perps_tutorial', + PERPS_HOME_EMPTY_STATE: 'perps_home_empty_state', + PERPS_ASSET_SCREEN_NO_FUNDS: 'perps_asset_screen_no_funds', + TRADE_MENU_ACTION: 'trade_menu_action', + WALLET_HOME: 'wallet_home', + HOME_SECTION: 'home_section', + TOOLTIP: 'tooltip', + MAGNIFYING_GLASS: 'magnifying_glass', + CRYPTO_BUTTON: 'crypto_button', + STOCKS_BUTTON: 'stocks_button', + CLOSE_TOAST: 'close_toast', + // Perps home section sources (for navigation tracking) + PERPS_HOME_POSITION: 'perps_home_position', + PERPS_HOME_ORDERS: 'perps_home_orders', + PERPS_HOME_WATCHLIST: 'perps_home_watchlist', + PERPS_HOME_EXPLORE_CRYPTO: 'perps_home_explore_crypto', + PERPS_HOME_EXPLORE_STOCKS: 'perps_home_explore_stocks', + PERPS_HOME_ACTIVITY: 'perps_home_activity', + // Explore/Trending page source + EXPLORE: 'explore', + // Market list tab sources + PERPS_MARKET_LIST_ALL: 'perps_market_list_all', + PERPS_MARKET_LIST_CRYPTO: 'perps_market_list_crypto', + PERPS_MARKET_LIST_STOCKS: 'perps_market_list_stocks', + // Other navigation sources + PERPS_TAB: 'perps_tab', + WALLET_MAIN_ACTION_MENU: 'wallet_main_action_menu', + PUSH_NOTIFICATION: 'push_notification', + ORDER_BOOK: 'order_book', + FULL_SCREEN_CHART: 'full_screen_chart', + STOP_LOSS_PROMPT_BANNER: 'stop_loss_prompt_banner', + // Position management sources + OPEN_POSITION: 'open_position', + POSITION_CLOSE_TOAST: 'position_close_toast', + TRADE_DETAILS: 'trade_details', + // Geo-block trigger sources (for tracking what action was blocked) + DEPOSIT_BUTTON: 'deposit_button', + WITHDRAW_BUTTON: 'withdraw_button', + TRADE_ACTION: 'trade_action', + ADD_FUNDS_ACTION: 'add_funds_action', + CANCEL_ORDER: 'cancel_order', + ASSET_DETAIL_SCREEN: 'asset_detail_screen', + MARKET_INSIGHTS: 'market_insights', + // TAT-2449: Geo-block sources for close/modify actions + CLOSE_POSITION_ACTION: 'close_position_action', + MODIFY_POSITION_ACTION: 'modify_position_action', + // Geo-block sources for order book actions + ORDER_BOOK_LONG_BUTTON: 'order_book_long_button', + ORDER_BOOK_SHORT_BUTTON: 'order_book_short_button', + ORDER_BOOK_CLOSE_BUTTON: 'order_book_close_button', + ORDER_BOOK_MODIFY_BUTTON: 'order_book_modify_button', + // Geo-block sources for position management actions + AUTO_CLOSE_ACTION: 'auto_close_action', + ADJUST_MARGIN_ACTION: 'adjust_margin_action', + STOP_LOSS_PROMPT_ADD_MARGIN: 'stop_loss_prompt_add_margin', + STOP_LOSS_PROMPT_SET_SL: 'stop_loss_prompt_set_sl', + // Geo-block sources for bulk actions + CLOSE_ALL_POSITIONS_BUTTON: 'close_all_positions_button', + CANCEL_ALL_ORDERS_BUTTON: 'cancel_all_orders_button', + }, + WARNING_TYPE: { + MINIMUM_DEPOSIT: 'minimum_deposit', + MINIMUM_ORDER_SIZE: 'minimum_order_size', + INSUFFICIENT_BALANCE: 'insufficient_balance', + }, + ERROR_TYPE: { + NETWORK: 'network', + APP_CRASH: 'app_crash', + BACKEND: 'backend', + VALIDATION: 'validation', + WARNING: 'warning', + }, + // Standardized error message keys for PERP_ERROR events + // These should be used instead of localized strings for consistent analytics + ERROR_MESSAGE_KEY: { + ORDER_CHANGED_FROM_LIMIT_TO_MARKET: 'order_changed_from_limit_to_market', + OPEN_CROSS_MARGIN_POSITION_DETECTED: 'open_cross_margin_position_detected', + INSUFFICIENT_BALANCE: 'insufficient_balance', + ORDER_FAILED: 'order_failed', + GEO_RESTRICTION: 'geo_restriction', + MINIMUM_ORDER_SIZE: 'minimum_order_size', + MAXIMUM_LEVERAGE_EXCEEDED: 'maximum_leverage_exceeded', + PRICE_DEVIATION_TOO_HIGH: 'price_deviation_too_high', + MARKET_AT_CAPACITY: 'market_at_capacity', + NETWORK_ERROR: 'network_error', + CONNECTION_FAILED: 'connection_failed', + POSITION_UPDATE_FAILED: 'position_update_failed', + TP_SL_UPDATE_FAILED: 'tp_sl_update_failed', + MARGIN_UPDATE_FAILED: 'margin_update_failed', + UNKNOWN: 'unknown', + }, + SOURCE_SECTION: { + // Home sections + POSITIONS: 'positions', + ORDERS: 'orders', + WATCHLIST: 'watchlist', + WHATS_HAPPENING: 'whats_happening', + PRODUCTS: 'products', + TOP_GAINERS: 'top_gainers', + TOP_LOSERS: 'top_losers', + CRYPTO: 'crypto', + COMMODITY: 'commodity', + STOCK: 'stock', + FOREX: 'forex', + // Explore sections + PERPS_MOVERS: 'perps_movers', + PERPS_CRYPTO: 'perps_crypto', + PERPS_STOCKS_COMMODITIES: 'perps_stocks_commodities', + PERPS_MARKETS: 'perps_markets', + // Market list sections + ALL_MARKETS: 'all_markets', + NEW: 'new', + ACTIVE_SEARCH: 'active_search', + }, + SECTION_NAME: { + BALANCE: 'balance', + POSITIONS: 'positions', + ORDERS: 'orders', + WATCHLIST: 'watchlist', + WHATS_HAPPENING: 'whats_happening', + PRODUCTS: 'products', + TOP_MOVERS: 'top_movers', + EXPLORE_CRYPTO: 'explore_crypto', + EXPLORE_COMMODITIES: 'explore_commodities', + EXPLORE_STOCKS: 'explore_stocks', + EXPLORE_FOREX: 'explore_forex', + RECENT_ACTIVITY: 'recent_activity', + }, + INTERACTION_TYPE: { + TAP: 'tap', + ZOOM: 'zoom', + SLIDE: 'slide', + SEARCH_CLICKED: 'search_clicked', + ORDER_TYPE_VIEWED: 'order_type_viewed', + ORDER_TYPE_SELECTED: 'order_type_selected', + /** @deprecated Use LEVERAGE_CHANGED instead for clarity */ + SETTING_CHANGED: 'setting_changed', + /** + * Perp UI Interaction `leverage_changed`. Properties include `leverage` + * and `previous_leverage`. + */ + LEVERAGE_CHANGED: 'leverage_changed', + TUTORIAL_STARTED: 'tutorial_started', + TUTORIAL_COMPLETED: 'tutorial_completed', + TUTORIAL_NAVIGATION: 'tutorial_navigation', + CANDLE_PERIOD_VIEWED: 'candle_period_viewed', + CANDLE_PERIOD_CHANGED: 'candle_period_changed', + FAVORITE_TOGGLED: 'favorite_toggled', + BUTTON_CLICKED: 'button_clicked', + // Position management interactions + CONTACT_SUPPORT: 'contact_support', + STOP_LOSS_ONE_CLICK_PROMPT: 'stop_loss_one_click_prompt', + ADD_MARGIN: 'add_margin', + REMOVE_MARGIN: 'remove_margin', + INCREASE_EXPOSURE: 'increase_exposure', + REDUCE_EXPOSURE: 'reduce_exposure', + FLIP_POSITION: 'flip_position', + // Hero card interactions + DISPLAY_HERO_CARD: 'display_hero_card', + SHARE_PNL_HERO_CARD: 'share_pnl_hero_card', + // Chart interactions + FULL_SCREEN_CHART: 'full_screen_chart', + // Pay-with interactions + PAYMENT_TOKEN_SELECTOR: 'payment_token_selector', + PAYMENT_METHOD_CHANGED: 'payment_method_changed', + // Deposit + order (pay-with token) cancel + CANCEL_TRADE_WITH_TOKEN: 'cancel_trade_with_token', + // Slippage interactions + SLIPPAGE_CONFIG_OPENED: 'slippage_config_opened', + SLIPPAGE_CONFIG_CHANGED: 'slippage_config_changed', + SLIPPAGE_LIMIT_BLOCKED_ORDER: 'slippage_limit_blocked_order', + // Auto Close TP/SL RoE sign toggle + TPSL_ROE_SIGN_TOGGLED: 'tpsl_roe_sign_toggled', + // Discovery analytics + MARKET_LIST_FILTER: 'market_list_filter', + // Sort / filter interactions + SORT_APPLIED: 'sort_applied', + FILTER_APPLIED: 'filter_applied', + // Chase interactions + CHASE_BACKGROUNDED_CONVERTED: 'chase_backgrounded_converted', + CHASE_TERMINATED: 'chase_terminated', + // Search interactions + SEARCH_RESULT_TAPPED: 'search_result_tapped', + SEARCH_CHIP_TAPPED: 'search_chip_tapped', + SEARCH_SIGNAL_TILE_TAPPED: 'search_signal_tile_tapped', + // Pay-with token selector dismissed + PAYMENT_TOKEN_SELECTOR_DISMISSED: 'payment_token_selector_dismissed', + }, + MAX_SLIPPAGE_SOURCE: { + DEFAULT: 'default', + USER_CONFIGURED: 'user_configured', + }, + ACTION_TYPE: { + START_TRADING: 'start_trading', + SKIP: 'skip', + STOP_LOSS_SET: 'stop_loss_set', + TAKE_PROFIT_SET: 'take_profit_set', + ADL_LEARN_MORE: 'adl_learn_more', + LEARN_MORE: 'learn_more', + FAVORITE_MARKET: 'favorite_market', + UNFAVORITE_MARKET: 'unfavorite_market', + }, + NOTIFICATION_TYPE: { + POSITION_LIQUIDATED: 'position_liquidated', + TP_EXECUTED: 'tp_executed', + SL_EXECUTED: 'sl_executed', + LIMIT_ORDER_EXECUTED: 'limit_order_executed', + // Analytics schema value for the app-background conversion notification. + CHASE_BACKGROUNDED: 'chase_backgrounded', + }, + CLOSE_TYPE: { + FULL: 'full', + PARTIAL: 'partial', + }, + NAVIGATION_METHOD: { + SWIPE: 'swipe', + CONTINUE_BUTTON: 'continue_button', + PROGRESS_DOT: 'progress_dot', + }, + STATUS: { + VIEWED: 'viewed', + STARTED: 'started', + COMPLETED: 'completed', + INITIATED: 'initiated', + SUBMITTED: 'submitted', + EXECUTED: 'executed', + PARTIALLY_FILLED: 'partially_filled', + FAILED: 'failed', + SUCCESS: 'success', + ALREADY_ENABLED: 'already_enabled', + MIGRATION_REQUIRED: 'migration_required', + // Emitted when a migration attempt is skipped because it is not applicable + // (e.g. the user has no Hyperliquid account yet — nothing to migrate). + // Distinguishes expected no-ops from real failures in dashboards. + NOT_APPLICABLE: 'not_applicable', + }, + SCREEN_TYPE: { + MARKETS: 'markets', + MARKET_LIST: 'market_list', + ASSET_DETAILS: 'asset_details', + TRADING: 'trading', + /** @deprecated Use PERPS_HOME or WALLET_HOME_PERPS_TAB instead */ + HOMESCREEN: 'homescreen', + PERPS_HOME: 'perps_home', + WALLET_HOME_PERPS_TAB: 'wallet_home_perps_tab', + POSITION_CLOSE: 'position_close', + LEVERAGE: 'leverage', + TUTORIAL: 'tutorial', + WITHDRAWAL: 'withdrawal', + TP_SL: 'tp_sl', + CREATE_TPSL: 'create_tpsl', + EDIT_TPSL: 'edit_tpsl', + DEPOSIT_INPUT: 'deposit_input', + DEPOSIT_REVIEW: 'deposit_review', + CLOSE_ALL_POSITIONS: 'close_all_positions', + CANCEL_ALL_ORDERS: 'cancel_all_orders', + PNL_HERO_CARD: 'pnl_hero_card', + ORDER_BOOK: 'order_book', + ERROR: 'error', + // Market list tab screen types + MARKET_LIST_ALL: 'market_list_all', + MARKET_LIST_CRYPTO: 'market_list_crypto', + MARKET_LIST_STOCKS: 'market_list_stocks', + // Additional screens + FULL_SCREEN_CHART: 'full_screen_chart', + ACTIVITY: 'activity', + INCREASE_EXPOSURE: 'increase_exposure', + ADD_MARGIN: 'add_margin', + REMOVE_MARGIN: 'remove_margin', + GEO_BLOCK_NOTIF: 'geo_block_notif', + COMPLIANCE_BLOCK_NOTIF: 'compliance_block_notif', + // Deposit + order (pay-with token) cancel toast + CANCEL_TRADE_WITH_TOKEN_TOAST: 'cancel_trade_with_token_toast', + // Search result screen states + SEARCH_RESULTS_SHOWN: 'search_results_shown', + SEARCH_NO_RESULTS: 'search_no_results', + }, + SETTING_TYPE: { + LEVERAGE: 'leverage', + SLIPPAGE: 'slippage', + }, + SCREEN_NAME: { + CONNECTION_ERROR: 'connection_error', + PERPS_HERO_CARD: 'perps_hero_card', + PERPS_ACTIVITY_HISTORY: 'perps_activity_history', + PERPS_HOME: 'perps_home', + PERPS_MARKET_DETAILS: 'perps_market_details', + PERPS_ORDER: 'perps_order', + }, + ACTION: { + CONNECTION_RETRY: 'connection_retry', + CONNECTION_GO_BACK: 'connection_go_back', + SHARE: 'share', + // Risk management actions + ADD_MARGIN: 'add_margin', + REMOVE_MARGIN: 'remove_margin', + EDIT_TP_SL: 'edit_tp_sl', + CREATE_TP_SL: 'create_tp_sl', + // TP/SL specific actions for risk management events + TP: 'tp', + SL: 'sl', + TPSL: 'tpsl', + // Trade transaction actions - differentiates new position from adding to existing + CREATE_POSITION: 'create_position', + INCREASE_EXPOSURE: 'increase_exposure', + // Flip position actions with direction specificity + FLIP_LONG_TO_SHORT: 'flip_long_to_short', + FLIP_SHORT_TO_LONG: 'flip_short_to_long', + // Order funnel abandonment + ABANDON_ORDER: 'abandon_order', + }, + // Risk management sources + RISK_MANAGEMENT_SOURCE: { + TRADE_SCREEN: 'trade_screen', + POSITION_SCREEN: 'position_screen', + STOP_LOSS_PROMPT_BANNER: 'stop_loss_prompt_banner', + }, + PERPS_HISTORY_TABS: { + TRADES: 'trades', + ORDERS: 'orders', + FUNDING: 'funding', + DEPOSITS: 'deposits', + }, + /** Value for mm_pay_token_selected when user pays with Perps balance (not a token) */ + MM_PAY_TOKEN: { + PERPS_BALANCE: 'Perps Balance', + }, + // A/B testing values + AB_TEST: { + // Test IDs + BUTTON_COLOR_TEST: 'button_color_test', + // Button color test variants + CONTROL: 'control', + MONOCHROME: 'monochrome', + }, + BUTTON_CLICKED: { + DEPOSIT: 'deposit', + WITHDRAW: 'withdraw', + PERPS_HOME: 'perps_home', + TUTORIAL: 'tutorial', + TOOLTIP: 'tooltip', + MARKET_LIST: 'market_list', + OPEN_POSITION: 'open_position', + MAGNIFYING_GLASS: 'magnifying_glass', + CRYPTO: 'crypto', + STOCKS: 'stocks', + COMMODITIES: 'commodities', + FOREX: 'forex', + NEW: 'new', + GIVE_FEEDBACK: 'give_feedback', + WATCHLIST: 'watchlist', + TOP_MOVERS: 'top_movers', + WHATS_HAPPENING: 'whats_happening', + // Order + position management CTAs + PLACE_ORDER: 'place_order', + CLOSE: 'close', + REDUCE_EXPOSURE: 'reduce_exposure', + }, + BUTTON_LOCATION: { + PERPS_HOME: 'perps_home', + PERPS_TUTORIAL: 'perps_tutorial', + PERPS_HOME_EMPTY_STATE: 'perps_home_empty_state', + PERPS_ASSET_SCREEN: 'perps_asset_screen', + PERPS_TAB: 'perps_tab', + TRADE_MENU_ACTION: 'trade_menu_action', + WALLET_HOME: 'wallet_home', + MARKET_LIST: 'market_list', + SCREEN: 'screen', + TOOLTIP: 'tooltip', + PERP_MARKET_DETAILS: 'perp_market_details', + ORDER_BOOK: 'order_book', + FULL_SCREEN_CHART: 'full_screen_chart', + ASSET_DETAILS: 'asset_details', + }, +} as const; diff --git a/packages/perps-controller/src/constants/hyperLiquidConfig.ts b/packages/perps-controller/src/constants/hyperLiquidConfig.ts new file mode 100644 index 00000000000..16f55ee8f1c --- /dev/null +++ b/packages/perps-controller/src/constants/hyperLiquidConfig.ts @@ -0,0 +1,687 @@ +import type { CaipAssetId, CaipChainId, Hex } from '@metamask/utils'; + +import { MarketCategory } from '../types/index.js'; +import type { + DirectProviderOrderCapabilities, + MarketType, +} from '../types/index.js'; +import type { + HyperLiquidNetwork, + HyperLiquidEndpoints, + HyperLiquidAssetConfigs, + BridgeContractConfig, + HyperLiquidBridgeContracts, + HyperLiquidTransportConfig, + TradingDefaultsConfig, + FeeRatesConfig, +} from '../types/perps-types.js'; +import { STRATEGY_ORDER_TYPES } from '../utils/orderTypes.js'; +import { PROVIDER_CONFIG } from './perpsConfig.js'; + +// Network constants +export const ARBITRUM_MAINNET_CHAIN_ID_HEX = '0xa4b1' as const; +export const ARBITRUM_MAINNET_CHAIN_ID = '42161'; +export const ARBITRUM_TESTNET_CHAIN_ID = '421614'; +export const ARBITRUM_MAINNET_CAIP_CHAIN_ID = `eip155:${ARBITRUM_MAINNET_CHAIN_ID}`; +export const ARBITRUM_TESTNET_CAIP_CHAIN_ID = `eip155:${ARBITRUM_TESTNET_CHAIN_ID}`; + +// Hyperliquid chain constants +export const HYPERLIQUID_MAINNET_CHAIN_ID = '0x3e7'; // 999 in decimal +export const HYPERLIQUID_TESTNET_CHAIN_ID = '0x3e6'; // 998 in decimal (assumed) +export const HYPERLIQUID_MAINNET_CAIP_CHAIN_ID = 'eip155:999' as CaipChainId; +export const HYPERLIQUID_TESTNET_CAIP_CHAIN_ID = 'eip155:998' as CaipChainId; +export const HYPERLIQUID_NETWORK_NAME = 'Hyperliquid'; + +/** + * Return the canonical snapshot identity: main first, then unique DEX ids. + * + * @param dexes - DEX identifiers to canonicalize. + * @returns The canonical DEX identifiers. + */ +export function canonicalizeHyperLiquidDexes( + dexes: Iterable, +): string[] { + const additionalDexes = new Set(dexes); + additionalDexes.delete('main'); + return ['main', ...Array.from(additionalDexes).sort()]; +} + +// Token constants +export const USDC_SYMBOL = 'USDC'; +export const USDC_NAME = 'USD Coin'; +export const USDC_DECIMALS = 6; +export const TOKEN_DECIMALS = 18; + +// Network constants +export const ARBITRUM_SEPOLIA_CHAIN_ID = '0x66eee'; // 421614 in decimal + +// USDC token addresses +export const USDC_ETHEREUM_MAINNET_ADDRESS = + '0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48'; +export const USDC_ARBITRUM_MAINNET_ADDRESS = + '0xaf88d065e77c8cC2239327C5EDb3A432268e5831'; +export const USDC_ARBITRUM_TESTNET_ADDRESS = + '0x75faf114eafb1BDbe2F0316DF893fd58CE46AA4d'; + +// USDC token icon URL using MetaMask's official Token Icons API +// Format: https://static.cx.metamask.io/api/v1/tokenIcons/{chainId}/{contractAddress}.png +// This URL follows the same pattern used throughout MetaMask (bridges, swaps, etc.) +export const USDC_TOKEN_ICON_URL = `https://static.cx.metamask.io/api/v1/tokenIcons/1/${USDC_ETHEREUM_MAINNET_ADDRESS}.png`; + +// WebSocket endpoints +export const HYPERLIQUID_ENDPOINTS: HyperLiquidEndpoints = { + mainnet: 'wss://api.hyperliquid.xyz/ws', + testnet: 'wss://api.hyperliquid-testnet.xyz/ws', +}; + +// Asset icons base URL (HyperLiquid CDN - fallback source) +export const HYPERLIQUID_ASSET_ICONS_BASE_URL = + 'https://app.hyperliquid.xyz/coins/'; + +// MetaMask-hosted Perps asset icons (primary source) +// Assets uploaded to: https://github.com/MetaMask/contract-metadata/tree/master/icons/eip155:999 +// HIP-3 assets use format: hip3:dex_SYMBOL.svg (e.g., hip3:xyz_AAPL.svg) +// Regular assets use format: SYMBOL.svg (e.g., BTC.svg) +export const METAMASK_PERPS_ICONS_BASE_URL = + 'https://raw.githubusercontent.com/MetaMask/contract-metadata/master/icons/eip155:999/'; + +// Asset configurations for multichain abstraction +export const HYPERLIQUID_ASSET_CONFIGS: HyperLiquidAssetConfigs = { + usdc: { + mainnet: `${ARBITRUM_MAINNET_CAIP_CHAIN_ID}/erc20:0xaf88d065e77c8cC2239327C5EDb3A432268e5831/default`, + testnet: `${ARBITRUM_TESTNET_CAIP_CHAIN_ID}/erc20:0x75faf114eafb1BDbe2F0316DF893fd58CE46AA4d/default`, + }, +}; + +// HyperLiquid bridge contract addresses for direct USDC deposits +// These are the official bridge contracts where USDC must be sent to credit user's HyperLiquid account +export const HYPERLIQUID_BRIDGE_CONTRACTS: HyperLiquidBridgeContracts = { + mainnet: { + chainId: ARBITRUM_MAINNET_CAIP_CHAIN_ID, + contractAddress: '0x2df1c51e09aecf9cacb7bc98cb1742757f163df7', + }, + testnet: { + chainId: ARBITRUM_TESTNET_CAIP_CHAIN_ID, + contractAddress: '0x08cfc1B6b2dCF36A1480b99353A354AA8AC56f89', + }, +}; + +// SDK transport configuration +export const HYPERLIQUID_TRANSPORT_CONFIG: HyperLiquidTransportConfig = { + timeout: 10_000, + keepAlive: { interval: 30_000 }, + reconnect: { + maxRetries: 5, + connectionTimeout: 10_000, + }, +}; + +// Trading configuration constants +export const TRADING_DEFAULTS: TradingDefaultsConfig = { + leverage: 3, // 3x default leverage + marginPercent: 10, // 10% fixed margin default + takeProfitPercent: 0.3, // 30% take profit + stopLossPercent: 0.1, // 10% stop loss + amount: { + mainnet: 10, // $10 minimum order size + testnet: 10, // $10 minimum order size + }, +}; + +// Fee configuration +// Note: These are base rates (Tier 0, no discounts) +// Actual fees will be calculated based on user's volume tier and staking +export const FEE_RATES: FeeRatesConfig = { + taker: 0.00045, // 0.045% - Market orders and aggressive limit orders + maker: 0.00015, // 0.015% - Limit orders that add liquidity +}; + +/** + * HIP-3 dynamic fee calculation configuration + * + * HIP-3 (builder-deployed) perpetual markets have variable fees based on: + * 1. deployerFeeScale - Per-DEX fee multiplier (fetched from perpDexs API) + * 2. growthMode - Per-asset 90% fee reduction (fetched from meta API) + * + * Fee Formula (from HyperLiquid docs): + * - scaleIfHip3 = deployerFeeScale < 1 ? deployerFeeScale + 1 : deployerFeeScale * 2 + * - growthModeScale = growthMode ? 0.1 : 1 + * - finalRate = baseRate * scaleIfHip3 * growthModeScale + * + * Example: For xyz:TSLA with deployerFeeScale=1.0 and growthMode="enabled": + * - scaleIfHip3 = 1.0 * 2 = 2.0 + * - growthModeScale = 0.1 (90% reduction) + * - Final multiplier = 2.0 * 0.1 = 0.2 (effectively 80% off standard 2x HIP-3 fees) + * + * @see https://hyperliquid.gitbook.io/hyperliquid-docs/trading/fees#fee-formula-for-developers + * @see parseAssetName() in HyperLiquidProvider for HIP-3 asset detection + */ +export const HIP3_FEE_CONFIG = { + /** + * Growth Mode multiplier - 90% fee reduction for assets in growth phase + * This is a protocol constant from HyperLiquid's fee formula + */ + GrowthModeScale: 0.1, + + /** + * Default deployerFeeScale when API is unavailable + * Most HIP-3 DEXs use 1.0, which results in 2x base fees + */ + DefaultDeployerFeeScale: 1.0, + + /** + * Cache TTL for perpDexs data (5 minutes) + * Fee scales rarely change, so longer cache is acceptable + */ + PerpDexsCacheTtlMs: 5 * 60 * 1000, + + /** + * @deprecated Use dynamic calculation via calculateHip3FeeMultiplier() + * Kept for backwards compatibility during migration + */ + FeeMultiplier: 2, +} as const; + +const BUILDER_FEE_MAX_FEE_DECIMAL = 0.001; + +// Builder fee configuration +export const BUILDER_FEE_CONFIG = { + // Test builder wallet + TestnetBuilder: '0x724e57771ba749650875bd8adb2e29a85d0cacfa' as Hex, + // Production builder wallet + MainnetBuilder: '0xe95a5e31904e005066614247d309e00d8ad753aa' as Hex, + // Fee in decimal (10 bp = 0.1%) + MaxFeeDecimal: BUILDER_FEE_MAX_FEE_DECIMAL, + MaxFeeTenthsBps: BUILDER_FEE_MAX_FEE_DECIMAL * 100000, + MaxFeeRate: `${(BUILDER_FEE_MAX_FEE_DECIMAL * 100) + .toFixed(4) + .replace(/\.?0+$/u, '')}%`, +}; + +/** + * Strategies that HyperLiquid can execute for its routed perp markets. + * Providers own this declaration so clients never infer support from a + * provider name. + */ +export const HYPERLIQUID_ORDER_CAPABILITIES = Object.freeze({ + status: 'ready', + providerId: PROVIDER_CONFIG.DefaultProvider, + supportedStrategies: Object.freeze([...STRATEGY_ORDER_TYPES]), +}) satisfies DirectProviderOrderCapabilities; + +// Referral code configuration +export const REFERRAL_CONFIG = { + // Production referral code + MainnetCode: 'MMCSI', + // Development/testnet referral code + TestnetCode: 'MMCSITEST', +}; + +// Deposit constants +export const DEPOSIT_CONFIG = { + EstimatedGasLimit: 100000, // Estimated gas limit for bridge deposit + DefaultSlippage: 1, // 1% default slippage for bridge quotes + BridgeQuoteTimeout: 1000, // 1 second timeout for bridge quotes + RefreshRate: 30000, // 30 seconds quote refresh rate + EstimatedTime: { + DirectDeposit: '3-5 seconds', // Direct USDC deposit on Arbitrum + SameChainSwap: '30-60 seconds', // Swap on same chain before deposit + }, +}; + +// Withdrawal constants (HyperLiquid-specific) +export const HYPERLIQUID_WITHDRAWAL_MINUTES = 5; // HyperLiquid withdrawal processing time in minutes + +// Type helpers +export type SupportedAsset = keyof typeof HYPERLIQUID_ASSET_CONFIGS; + +// Configuration helpers +export function getWebSocketEndpoint(isTestnet: boolean): string { + return isTestnet + ? HYPERLIQUID_ENDPOINTS.testnet + : HYPERLIQUID_ENDPOINTS.mainnet; +} + +export function getChainId(isTestnet: boolean): string { + return isTestnet ? ARBITRUM_TESTNET_CHAIN_ID : ARBITRUM_MAINNET_CHAIN_ID; +} + +export function getCaipChainId(isTestnet: boolean): CaipChainId { + const network: HyperLiquidNetwork = isTestnet ? 'testnet' : 'mainnet'; + return HYPERLIQUID_BRIDGE_CONTRACTS[network].chainId; +} + +export function getBridgeInfo(isTestnet: boolean): BridgeContractConfig { + const network: HyperLiquidNetwork = isTestnet ? 'testnet' : 'mainnet'; + return HYPERLIQUID_BRIDGE_CONTRACTS[network]; +} + +export function getSupportedAssets(isTestnet?: boolean): CaipAssetId[] { + const network = isTestnet ? 'testnet' : 'mainnet'; + return Object.values(HYPERLIQUID_ASSET_CONFIGS).map( + (config) => config[network], + ); +} + +// CAIP asset namespace constants +export const CAIP_ASSET_NAMESPACES = { + Erc20: 'erc20', +} as const; + +/** + * HyperLiquid protocol-specific configuration + * Contains constants specific to HyperLiquid's perps exchange + */ +export const HYPERLIQUID_CONFIG = { + // Exchange name used in predicted funding data + // HyperLiquid uses 'HlPerp' as their perps exchange identifier + ExchangeName: 'HlPerp', + // Maximum allowed deviation of the market (mid) price from the oracle (reference) + // price before HyperLiquid rejects orders. HyperLiquid enforces "Order price cannot + // be more than 95% away from the reference price", which makes markets — most often + // HIP-3 builder-deployed ones — temporarily untradable when the mid price drifts past + // this limit. Expressed as a decimal fraction (0.95 = 95%). + // Protocol rule, not a UI warning threshold (see VALIDATION_THRESHOLDS.PriceDeviation). + OraclePriceDeviationLimit: 0.95, +} as const; + +/** + * HIP-3 multi-DEX asset ID calculation constants + * Per HIP-3-IMPLEMENTATION.md: + * - Main DEX: assetId = index (0, 1, 2, ...) + * - HIP-3 DEX: assetId = BASE_ASSET_ID + (perpDexIndex × DEX_MULTIPLIER) + index + * + * This formula enables proper order routing across multiple DEXs: + * - Main DEX (perpDexIndex=0): Uses index directly (BTC=0, ETH=1, SOL=2, etc.) + * - xyz DEX (perpDexIndex=1): 100000 + (1 × 10000) + index = 110000-110999 + * - abc DEX (perpDexIndex=2): 100000 + (2 × 10000) + index = 120000-120999 + * + * Supports up to 10 HIP-3 DEXs with 10000 assets each. + */ +export const HIP3_ASSET_ID_CONFIG = { + // Base offset for HIP-3 asset IDs (100000) + // Ensures HIP-3 asset IDs don't conflict with main DEX indices + BaseAssetId: 100000, + + // Multiplier for DEX index in asset ID calculation (10000) + // Allocates 10000 asset ID slots per DEX (0-9999) + DexMultiplier: 10000, +} as const; + +/** + * Basis points conversion constant + * 1 basis point (bp) = 0.01% = 0.0001 as decimal + * Used for fee discount calculations (e.g., 6500 bps = 65%) + */ +export const BASIS_POINTS_DIVISOR = 10000; + +/** + * Offset added to spot market pair index to derive the spot asset ID + * used in HyperLiquid order routing. + * Per HyperLiquid protocol: spotAssetId = SPOT_ASSET_ID_OFFSET + pairIndex + */ +export const SPOT_ASSET_ID_OFFSET = 10000; + +/** + * HIP-3 asset market type classifications (PRODUCTION DEFAULT) + * + * This is the production default configuration, can be overridden via feature flag + * (remoteFeatureFlags.perpsAssetMarketTypes) for dynamic control. + * + * Maps asset symbols (e.g., "xyz:TSLA") to their market type for badge display. + * + * Market type determines the badge shown in the UI: + * - 'stock': Individual stocks (TSLA, NVDA, AAPL, etc.) + * - 'pre-ipo': Pre-IPO assets not yet publicly listed + * - 'index': Market indices (SP500, JP225, VIX, etc.) + * - 'etf': Exchange-traded funds (EWY, EWJ, USAR, etc.) + * - 'commodity': Commodities (GOLD, SILVER, CL, etc.) + * - 'forex': Forex pairs (EUR, JPY, DXY) + * - 'crypto': Explicitly categorized crypto assets + * - undefined: No badge for unmapped assets + * + * Format: 'dex:SYMBOL' → MarketType + * This allows flexible per-asset classification. + * Assets not listed here will have no market type (undefined). + */ +export const HIP3_ASSET_MARKET_TYPES: Record = { + // xyz DEX - Stocks (US) + 'xyz:TSLA': MarketCategory.Stock, + 'xyz:NVDA': MarketCategory.Stock, + 'xyz:INTC': MarketCategory.Stock, + 'xyz:MU': MarketCategory.Stock, + 'xyz:CRCL': MarketCategory.Stock, + 'xyz:HOOD': MarketCategory.Stock, + 'xyz:SNDK': MarketCategory.Stock, + 'xyz:GOOGL': MarketCategory.Stock, + 'xyz:COIN': MarketCategory.Stock, + 'xyz:ORCL': MarketCategory.Stock, + 'xyz:AMZN': MarketCategory.Stock, + 'xyz:PLTR': MarketCategory.Stock, + 'xyz:AAPL': MarketCategory.Stock, + 'xyz:META': MarketCategory.Stock, + 'xyz:AMD': MarketCategory.Stock, + 'xyz:MSFT': MarketCategory.Stock, + 'xyz:BABA': MarketCategory.Stock, + 'xyz:RIVN': MarketCategory.Stock, + 'xyz:NFLX': MarketCategory.Stock, + 'xyz:COST': MarketCategory.Stock, + 'xyz:LLY': MarketCategory.Stock, + 'xyz:TSM': MarketCategory.Stock, + 'xyz:MSTR': MarketCategory.Stock, + 'xyz:CRWV': MarketCategory.Stock, + 'xyz:GME': MarketCategory.Stock, + 'xyz:HIMS': MarketCategory.Stock, + 'xyz:USAR': MarketCategory.Stock, + 'xyz:DKNG': MarketCategory.Stock, + 'xyz:BIRD': MarketCategory.Stock, + 'xyz:RKLB': MarketCategory.Stock, + 'xyz:MRVL': MarketCategory.Stock, + 'xyz:ZM': MarketCategory.Stock, + 'xyz:EBAY': MarketCategory.Stock, + 'xyz:PURRDAT': MarketCategory.Stock, + 'xyz:ARM': MarketCategory.Stock, + 'xyz:BX': MarketCategory.Stock, + 'xyz:LITE': MarketCategory.Stock, + 'xyz:CBRS': MarketCategory.Stock, + 'xyz:SPCX': MarketCategory.Stock, + + // xyz DEX - Stocks (Korea) + 'xyz:SKHX': MarketCategory.Stock, + 'xyz:SMSN': MarketCategory.Stock, + 'xyz:HYUNDAI': MarketCategory.Stock, + + // xyz DEX - Stocks (Japan) + 'xyz:SOFTBANK': MarketCategory.Stock, + 'xyz:KIOXIA': MarketCategory.Stock, + + // xyz DEX - Pre-IPO + 'xyz:IPOP': MarketCategory.PreIpo, + + // xyz DEX - Indices + 'xyz:SP500': MarketCategory.Index, + 'xyz:XYZ100': MarketCategory.Index, + 'xyz:JP225': MarketCategory.Index, + 'xyz:KR200': MarketCategory.Index, + 'xyz:VIX': MarketCategory.Index, + + // xyz DEX - ETFs + 'xyz:EWY': MarketCategory.Etf, + 'xyz:EWJ': MarketCategory.Etf, + 'xyz:EWT': MarketCategory.Etf, + 'xyz:EWZ': MarketCategory.Etf, + 'xyz:URNM': MarketCategory.Etf, + 'xyz:DRAM': MarketCategory.Etf, + 'xyz:XLE': MarketCategory.Etf, + + // xyz DEX - Commodities + 'xyz:GOLD': MarketCategory.Commodity, + 'xyz:SILVER': MarketCategory.Commodity, + 'xyz:CL': MarketCategory.Commodity, + 'xyz:WTIOIL': MarketCategory.Commodity, + 'xyz:COPPER': MarketCategory.Commodity, + 'xyz:ALUMINIUM': MarketCategory.Commodity, + 'xyz:URANIUM': MarketCategory.Commodity, + 'xyz:NATGAS': MarketCategory.Commodity, + 'xyz:PLATINUM': MarketCategory.Commodity, + 'xyz:PALLADIUM': MarketCategory.Commodity, + 'xyz:BRENTOIL': MarketCategory.Commodity, + + // xyz DEX - Forex + 'xyz:EUR': MarketCategory.Forex, + 'xyz:JPY': MarketCategory.Forex, + 'xyz:GBP': MarketCategory.Forex, + 'xyz:DXY': MarketCategory.Forex, +}; + +/** + * Human-readable market names keyed by HyperLiquid asset symbol. + * + * HyperLiquid does NOT expose a human-readable name per market: the `meta` + * universe only returns the ticker (`BTC`, `xyz:TSLA`), and `perpDexs` only + * exposes a `fullName` for the DEX/venue, not the individual asset. This map is + * therefore maintained client-side so that clients (mobile, extension) can: + * - match markets by full name in search ("Bitcoin", "Apple", "Gold"), and + * - display the full name alongside / instead of the ticker. + * + * Keys follow the same convention as {@link HIP3_ASSET_MARKET_TYPES}: bare + * `SYMBOL` for main-DEX crypto and `dex:SYMBOL` for HIP-3 markets. Use + * {@link getHyperLiquidAssetName} to resolve a name with a safe fallback to the + * ticker for unmapped assets. + * + * This list is intentionally curated (not exhaustive): unmapped assets simply + * fall back to their ticker, which matches prior behavior. Add entries as needed. + */ +export const HYPERLIQUID_ASSET_NAMES: Record = { + // Main DEX - Crypto majors + BTC: 'Bitcoin', + ETH: 'Ethereum', + SOL: 'Solana', + XRP: 'XRP', + BNB: 'BNB', + DOGE: 'Dogecoin', + ADA: 'Cardano', + AVAX: 'Avalanche', + LINK: 'Chainlink', + LTC: 'Litecoin', + DOT: 'Polkadot', + BCH: 'Bitcoin Cash', + TRX: 'TRON', + MATIC: 'Polygon', + ARB: 'Arbitrum', + OP: 'Optimism', + SUI: 'Sui', + APT: 'Aptos', + ATOM: 'Cosmos', + NEAR: 'NEAR Protocol', + INJ: 'Injective', + TIA: 'Celestia', + SEI: 'Sei', + UNI: 'Uniswap', + AAVE: 'Aave', + MKR: 'Maker', + CRV: 'Curve DAO', + LDO: 'Lido DAO', + PEPE: 'Pepe', + WIF: 'dogwifhat', + BONK: 'Bonk', + SHIB: 'Shiba Inu', + ETC: 'Ethereum Classic', + FIL: 'Filecoin', + HBAR: 'Hedera', + ICP: 'Internet Computer', + STX: 'Stacks', + RUNE: 'THORChain', + TON: 'Toncoin', + KAS: 'Kaspa', + FET: 'Fetch.ai', + ENA: 'Ethena', + JUP: 'Jupiter', + PYTH: 'Pyth Network', + JTO: 'Jito', + STRK: 'Starknet', + BLUR: 'Blur', + GMX: 'GMX', + DYDX: 'dYdX', + HYPE: 'Hyperliquid', + + // xyz DEX - Stocks (US) + 'xyz:TSLA': 'Tesla', + 'xyz:NVDA': 'NVIDIA', + 'xyz:INTC': 'Intel', + 'xyz:MU': 'Micron Technology', + 'xyz:CRCL': 'Circle', + 'xyz:HOOD': 'Robinhood', + 'xyz:SNDK': 'SanDisk', + 'xyz:GOOGL': 'Alphabet (Google)', + 'xyz:COIN': 'Coinbase', + 'xyz:ORCL': 'Oracle', + 'xyz:AMZN': 'Amazon', + 'xyz:PLTR': 'Palantir', + 'xyz:AAPL': 'Apple', + 'xyz:META': 'Meta Platforms', + 'xyz:AMD': 'AMD', + 'xyz:MSFT': 'Microsoft', + 'xyz:BABA': 'Alibaba', + 'xyz:RIVN': 'Rivian', + 'xyz:NFLX': 'Netflix', + 'xyz:COST': 'Costco', + 'xyz:LLY': 'Eli Lilly', + 'xyz:TSM': 'Taiwan Semiconductor', + 'xyz:MSTR': 'Strategy (MicroStrategy)', + 'xyz:CRWV': 'CoreWeave', + 'xyz:GME': 'GameStop', + 'xyz:HIMS': 'Hims & Hers', + 'xyz:USAR': 'USA Rare Earth', + 'xyz:DKNG': 'DraftKings', + 'xyz:RKLB': 'Rocket Lab', + 'xyz:MRVL': 'Marvell', + 'xyz:ZM': 'Zoom', + 'xyz:EBAY': 'eBay', + 'xyz:ARM': 'Arm Holdings', + 'xyz:BX': 'Blackstone', + 'xyz:LITE': 'Lumentum', + + // xyz DEX - Stocks (Korea) + 'xyz:SKHX': 'SK Hynix', + 'xyz:SMSN': 'Samsung Electronics', + 'xyz:HYUNDAI': 'Hyundai Motor', + + // xyz DEX - Stocks (Japan) + 'xyz:SOFTBANK': 'SoftBank Group', + 'xyz:KIOXIA': 'Kioxia', + + // xyz DEX - Pre-IPO + 'xyz:SPCX': 'SpaceX', + 'xyz:CBRS': 'Cerebras', + 'xyz:IPOP': 'Quantinuum', + + // xyz DEX - Indices + 'xyz:SP500': 'S&P 500', + 'xyz:JP225': 'Nikkei 225', + 'xyz:KR200': 'KOSPI 200', + 'xyz:VIX': 'CBOE Volatility Index', + + // xyz DEX - ETFs + 'xyz:EWY': 'iShares MSCI South Korea ETF', + 'xyz:EWJ': 'iShares MSCI Japan ETF', + 'xyz:EWT': 'iShares MSCI Taiwan ETF', + 'xyz:EWZ': 'iShares MSCI Brazil ETF', + 'xyz:URNM': 'Sprott Uranium Miners ETF', + 'xyz:XLE': 'Energy Select Sector SPDR Fund', + + // xyz DEX - Commodities + 'xyz:GOLD': 'Gold', + 'xyz:SILVER': 'Silver', + 'xyz:CL': 'Crude Oil', + 'xyz:WTIOIL': 'WTI Crude Oil', + 'xyz:COPPER': 'Copper', + 'xyz:ALUMINIUM': 'Aluminium', + 'xyz:URANIUM': 'Uranium', + 'xyz:NATGAS': 'Natural Gas', + 'xyz:PLATINUM': 'Platinum', + 'xyz:PALLADIUM': 'Palladium', + 'xyz:BRENTOIL': 'Brent Crude Oil', + + // xyz DEX - Forex + 'xyz:EUR': 'Euro', + 'xyz:JPY': 'Japanese Yen', + 'xyz:GBP': 'British Pound', + 'xyz:DXY': 'US Dollar Index', +}; + +/** + * Resolve the human-readable name for a HyperLiquid market. + * + * Falls back to the ticker symbol when the asset is not present in + * {@link HYPERLIQUID_ASSET_NAMES}, so callers always receive a displayable + * string and unmapped assets keep their prior behavior. + * + * @param symbol - HyperLiquid asset symbol (bare `SYMBOL` for main-DEX crypto, + * `dex:SYMBOL` for HIP-3 markets). + * @param names - Name map to look up against (defaults to the bundled + * {@link HYPERLIQUID_ASSET_NAMES}); injectable for testing/overrides. + * @returns The human-readable name, or the symbol itself when unmapped. + */ +export function getHyperLiquidAssetName( + symbol: string, + names: Record = HYPERLIQUID_ASSET_NAMES, +): string { + return names[symbol] ?? symbol; +} + +/** + * Testnet-specific HIP-3 DEX configuration + * + * On testnet, there are many HIP-3 DEXs (test deployments from various builders). + * Subscribing to all of them causes connection/subscription overload and instability. + * This configuration limits which DEXs are discovered and subscribed to on testnet. + */ +export const TESTNET_HIP3_CONFIG = { + /** + * Allowed DEX names for testnet + * Empty array = main DEX only (no HIP-3 DEXs) + * Add specific DEX names to test with particular HIP-3 DEXs: ['testdex1', 'testdex2'] + */ + EnabledDexs: ['xyz'] as string[], + + /** + * Set to true to enable full HIP-3 discovery on testnet (not recommended) + * When false, only DEXs in ENABLED_DEXS are used + */ + AutoDiscoverAll: false, +} as const; + +/** + * Mainnet-specific HIP-3 DEX configuration + * + * On mainnet, DEX filtering is dynamically determined from the allowlist markets + * feature flag. This avoids hardcoding DEX names and ensures consistency with + * the market filtering logic. + * + * When AutoDiscoverAll is false and no allowlist is provided, only the main DEX is used. + * When an allowlist is provided, DEXs are extracted from the allowlist patterns. + */ +export const MAINNET_HIP3_CONFIG = { + /** + * Set to true to enable full HIP-3 discovery on mainnet + * When false, DEXs are filtered based on the allowlist markets feature flag + * (recommended for production to reduce subscription overhead) + */ + AutoDiscoverAll: false, +} as const; + +/** + * HIP-3 margin management configuration + * Controls margin buffers and auto-rebalance behavior for HIP-3 DEXes with isolated margin + * + * Background: HyperLiquid validates spendableBalance >= totalRequiredMargin BEFORE reallocating + * existing locked margin. This requires temporary over-funding when increasing positions, + * followed by automatic cleanup to minimize locked capital. + */ +export const HIP3_MARGIN_CONFIG = { + /** + * Margin buffer multiplier for fees and slippage (0.3% = multiply by 1.003) + * Covers HyperLiquid's max taker fee (0.035%) with comfortable margin + */ + BufferMultiplier: 1.003, + + /** + * Desired buffer to keep on HIP-3 DEX after auto-rebalance (USDC amount) + * Small buffer allows quick follow-up orders without transfers + */ + RebalanceDesiredBuffer: 0.1, + + /** + * Minimum excess threshold to trigger auto-rebalance (USDC amount) + * Prevents unnecessary transfers for tiny amounts + */ + RebalanceMinThreshold: 0.1, +} as const; + +// Progress bar constants +export const INITIAL_AMOUNT_UI_PROGRESS = 10; +export const WITHDRAWAL_PROGRESS_STAGES = [ + 25, 35, 45, 55, 65, 75, 85, 90, 95, 98, +]; +export const PROGRESS_BAR_COMPLETION_DELAY_MS = 500; diff --git a/packages/perps-controller/src/constants/index.ts b/packages/perps-controller/src/constants/index.ts new file mode 100644 index 00000000000..cd510d8579c --- /dev/null +++ b/packages/perps-controller/src/constants/index.ts @@ -0,0 +1,11 @@ +/** + * Barrel re-export for all portable constants in controllers/ + */ +export * from './chartConfig.js'; +export * from './eventNames.js'; +export * from './hyperLiquidConfig.js'; +export * from './orderTypes.js'; +export * from './perpsConfig.js'; +export * from './transactionsHistoryConfig.js'; +export * from './performanceMetrics.js'; +export * from './myxConfig.js'; diff --git a/packages/perps-controller/src/constants/myxConfig.ts b/packages/perps-controller/src/constants/myxConfig.ts new file mode 100644 index 00000000000..3eaf86976b1 --- /dev/null +++ b/packages/perps-controller/src/constants/myxConfig.ts @@ -0,0 +1,284 @@ +/** + * MYX Protocol Configuration Constants + * + * Configuration for market display, price fetching, and trading. + * Based on MYX SDK patterns. + */ + +import type { CaipChainId } from '@metamask/utils'; +import { BigNumber } from 'bignumber.js'; + +import type { + MYXNetwork, + MYXEndpoints, + MYXAssetConfigs, +} from '../types/myx-types.js'; + +// ============================================================================ +// Network Constants +// ============================================================================ + +/** + * MYX Chain IDs + * Mainnet: BNB Chain (56) + * Testnet: Linea Sepolia (59141) — primary testnet chain with most active pools. + * The testnet API also has one pool on Arbitrum Sepolia (421614) but it has no + * ticker data, so Linea Sepolia is the effective testnet chain. + */ +export const MYX_MAINNET_CHAIN_ID = '56' as const; +export const MYX_TESTNET_CHAIN_ID = '59141' as const; +export const MYX_MAINNET_CAIP_CHAIN_ID = + `eip155:${MYX_MAINNET_CHAIN_ID}` as CaipChainId; +export const MYX_TESTNET_CAIP_CHAIN_ID = + `eip155:${MYX_TESTNET_CHAIN_ID}` as CaipChainId; + +/** + * Get numeric chain ID for MYX network + * + * @param network - The MYX network environment (mainnet or testnet). + * @returns The numeric chain ID for the specified network. + */ +export function getMYXChainId(network: MYXNetwork): number { + return network === 'testnet' + ? parseInt(MYX_TESTNET_CHAIN_ID, 10) + : parseInt(MYX_MAINNET_CHAIN_ID, 10); +} + +// ============================================================================ +// API Endpoints +// ============================================================================ + +/** + * MYX REST and WebSocket endpoints + */ +export const MYX_ENDPOINTS: MYXEndpoints = { + mainnet: { + http: 'https://api.myx.finance', + ws: 'wss://oapi.myx.finance/ws', + }, + testnet: { + http: 'https://api-test.myx.cash', + ws: 'wss://oapi-test.myx.cash/ws', + }, +}; + +/** + * Get HTTP endpoint for network + * + * @param network - The MYX network environment (mainnet or testnet). + * @returns The HTTP API endpoint URL for the specified network. + */ +export function getMYXHttpEndpoint(network: MYXNetwork): string { + return MYX_ENDPOINTS[network].http; +} + +// ============================================================================ +// Decimal Constants +// ============================================================================ + +/** + * MYX API returns prices as normal floating-point strings (e.g. "64854.76"). + * No decimal scaling is needed for prices from the REST/WS API. + * + * Note: The SDK's internal contract layer uses 30 decimals, but the API + * endpoints (tickers, candles, order history) return human-readable values. + */ +export const MYX_PRICE_DECIMALS = 0; + +/** + * MYX uses 18 decimals for position sizes + */ +export const MYX_SIZE_DECIMALS = 18; + +/** + * MYX uses 18 decimals for collateral amounts (USDT on BNB) + */ +export const MYX_COLLATERAL_DECIMALS = 18; + +// ============================================================================ +// Token Addresses +// ============================================================================ + +/** + * Collateral token address — testnet (USDC on Linea Sepolia) + * From SDK: LINEA_SEPOLIA.USDC + */ +export const MYX_COLLATERAL_TOKEN_TESTNET = + '0xD984fd34f91F92DA0586e1bE82E262fF27DC431b' as const; + +/** + * Collateral token address — mainnet (BUSD on BNB, per pool quoteToken) + * Note: individual pools may use different quote tokens + */ +export const MYX_COLLATERAL_TOKEN_MAINNET = + '0x8bfc51e1928e91e47c6734983ac018b2fc0adf4e' as const; + +/** @deprecated Use MYX_COLLATERAL_TOKEN_TESTNET */ +export const USDT_BNB_TESTNET = MYX_COLLATERAL_TOKEN_TESTNET; +/** @deprecated Use MYX_COLLATERAL_TOKEN_MAINNET */ +export const USDT_BNB_MAINNET = MYX_COLLATERAL_TOKEN_MAINNET; + +/** + * Collateral token configuration by network + */ +export const MYX_ASSET_CONFIGS: MYXAssetConfigs = { + USDT: { + mainnet: { + chainId: MYX_MAINNET_CAIP_CHAIN_ID, + tokenAddress: MYX_COLLATERAL_TOKEN_MAINNET, + }, + testnet: { + chainId: MYX_TESTNET_CAIP_CHAIN_ID, + tokenAddress: MYX_COLLATERAL_TOKEN_TESTNET, + }, + }, +}; + +// ============================================================================ +// Decimal Conversion Helpers +// ============================================================================ + +/** + * Convert MYX API price string to standard number. + * + * MYX API returns normal floating-point price strings (e.g. "64854.76"), + * NOT 30-decimal scaled integers. This is a simple parseFloat. + * + * @param myxPrice - Price string from MYX API (e.g. "64854.760266796727") + * @returns Standard decimal number + */ +export function fromMYXPrice(myxPrice: string): number { + if (!myxPrice || myxPrice === '0') { + return 0; + } + + const parsed = parseFloat(myxPrice); + return isNaN(parsed) ? 0 : parsed; +} + +/** + * Convert standard number to MYX API price string. + * + * MYX API uses normal floating-point strings, so this is a simple toString. + * + * @param price - Standard decimal number + * @returns Price string for MYX API + */ +export function toMYXPrice(price: number | string): string { + const parsed = typeof price === 'string' ? parseFloat(price) : price; + return isNaN(parsed) ? '0' : parsed.toString(); +} + +/** + * Convert MYX SDK size (18 decimals) to standard number + * + * @param myxSize - Size string in 18-decimal format from SDK + * @returns Standard decimal number + */ +export function fromMYXSize(myxSize: string): number { + if (!myxSize || myxSize === '0') { + return 0; + } + + try { + const bn = new BigNumber(myxSize); + if (bn.isNaN()) { + return 0; + } + const divisor = new BigNumber(10).pow(MYX_SIZE_DECIMALS); + return bn.dividedBy(divisor).toNumber(); + } catch { + return 0; + } +} + +/** + * Convert standard number to MYX SDK size format (18 decimals) + * + * @param size - Standard decimal number + * @returns Size string in 18-decimal format for SDK + */ +export function toMYXSize(size: number | string): string { + try { + const bn = new BigNumber(size); + if (bn.isNaN()) { + return '0'; + } + const multiplier = new BigNumber(10).pow(MYX_SIZE_DECIMALS); + return bn.multipliedBy(multiplier).toFixed(0); + } catch { + return '0'; + } +} + +/** + * Convert MYX SDK collateral (18 decimals) to standard number + * + * @param myxCollateral - Collateral string in 18-decimal format from SDK + * @returns Standard decimal number + */ +export function fromMYXCollateral(myxCollateral: string): number { + if (!myxCollateral || myxCollateral === '0') { + return 0; + } + + try { + const bn = new BigNumber(myxCollateral); + if (bn.isNaN()) { + return 0; + } + const divisor = new BigNumber(10).pow(MYX_COLLATERAL_DECIMALS); + return bn.dividedBy(divisor).toNumber(); + } catch { + return 0; + } +} + +// ============================================================================ +// REST API Configuration +// ============================================================================ + +/** + * Price polling interval in milliseconds + * Using 5 seconds as a fallback for unreliable WebSocket + */ +export const MYX_PRICE_POLLING_INTERVAL_MS = 5000; + +/** + * HTTP request timeout in milliseconds + */ +export const MYX_HTTP_TIMEOUT_MS = 10000; + +/** + * Maximum retries for failed API requests + */ +export const MYX_MAX_RETRIES = 3; + +/** + * Default slippage in basis points for MYX orders (1% — matches SDK default) + */ +export const MYX_DEFAULT_SLIPPAGE_BPS = 100; + +/** + * Maximum leverage supported by MYX (most markets) + */ +export const MYX_MAX_LEVERAGE = 100; + +/** + * Minimum order size in USD + */ +export const MYX_MINIMUM_ORDER_SIZE_USD = 10; + +/** + * MYX fee rates (placeholder — will be replaced with per-market rates) + */ +export const MYX_FEE_RATE = 0.0005; // 0.05% total fee rate +export const MYX_PROTOCOL_FEE_RATE = 0.0005; // Protocol taker fee + +/** + * USDT execution fee token address per network (used for order execution fees) + */ +export const MYX_EXECUTION_FEE_TOKEN: Record = { + testnet: MYX_COLLATERAL_TOKEN_TESTNET, + mainnet: MYX_COLLATERAL_TOKEN_MAINNET, +}; diff --git a/packages/perps-controller/src/constants/orderTypes.ts b/packages/perps-controller/src/constants/orderTypes.ts new file mode 100644 index 00000000000..47b1b126a6a --- /dev/null +++ b/packages/perps-controller/src/constants/orderTypes.ts @@ -0,0 +1,29 @@ +/** + * Detailed order types from HyperLiquid API + */ +export const DETAILED_ORDER_TYPES = { + LIMIT: 'Limit', + MARKET: 'Market', + STOP_LIMIT: 'Stop Limit', + STOP_MARKET: 'Stop Market', + TAKE_PROFIT_LIMIT: 'Take Profit Limit', + TAKE_PROFIT_MARKET: 'Take Profit Market', +} as const; + +/** + * Check if an order type is a TP/SL order + * + * @param detailedOrderType - The detailed order type string to check. + * @returns True if the order type is a take-profit or stop-loss variant. + */ +export const isTPSLOrder = (detailedOrderType?: string): boolean => { + if (!detailedOrderType) { + return false; + } + return ( + detailedOrderType === DETAILED_ORDER_TYPES.STOP_LIMIT || + detailedOrderType === DETAILED_ORDER_TYPES.STOP_MARKET || + detailedOrderType === DETAILED_ORDER_TYPES.TAKE_PROFIT_LIMIT || + detailedOrderType === DETAILED_ORDER_TYPES.TAKE_PROFIT_MARKET + ); +}; diff --git a/packages/perps-controller/src/constants/performanceMetrics.ts b/packages/perps-controller/src/constants/performanceMetrics.ts new file mode 100644 index 00000000000..412fe9c568f --- /dev/null +++ b/packages/perps-controller/src/constants/performanceMetrics.ts @@ -0,0 +1,64 @@ +/** + * Performance measurement names for Sentry monitoring + * These constants ensure consistency across the Perps feature + * Used for direct setMeasurement() calls in controllers and services + * + * Naming Convention: perps.{category}.{metric_name} + * - Uses dot notation for hierarchical grouping in Sentry + * - Categories: websocket, connection, api, operation, screen, ui + * - Enables easy filtering (e.g., perps.websocket.*) and dashboard aggregation + */ +export enum PerpsMeasurementName { + // ===== ACTIVE SENTRY METRICS ===== + + // WebSocket Performance Metrics (milliseconds) + // Tracks WebSocket connection lifecycle and data flow + PerpsWebsocketConnectionEstablishment = 'perps.websocket.connection_establishment', + PerpsWebsocketConnectionWithPreload = 'perps.websocket.connection_with_preload', + PerpsWebsocketFirstPositionData = 'perps.websocket.first_position_data', + PerpsWebsocketAccountSwitchReconnection = 'perps.websocket.account_switch_reconnection', + PerpsConnectionHealthCheck = 'perps.websocket.health_check', + PerpsReconnectionHealthCheck = 'perps.websocket.reconnection_health_check', + + // Connection Lifecycle Metrics (milliseconds) + // Tracks connection initialization and reconnection sub-stages + PerpsProviderInit = 'perps.connection.provider_init', + PerpsAccountStateFetch = 'perps.connection.account_state_fetch', + PerpsSubscriptionsPreload = 'perps.connection.subscriptions_preload', + PerpsReconnectionCleanup = 'perps.connection.cleanup', + PerpsControllerReinit = 'perps.connection.controller_reinit', + PerpsNewAccountFetch = 'perps.connection.new_account_fetch', + PerpsReconnectionPreload = 'perps.connection.reconnection_preload', + + // API Call Metrics (milliseconds) + // Tracks external API performance + PerpsDataLakeApiCall = 'perps.api.data_lake_call', + PerpsRewardsFeeDiscountApiCall = 'perps.api.rewards_fee_discount', + PerpsRewardsPointsEstimationApiCall = 'perps.api.rewards_points_estimation', + PerpsRewardsOrderExecutionFeeDiscountApiCall = 'perps.api.rewards_order_execution_fee_discount', + + // Data Operation Metrics (milliseconds) + // Tracks data fetch operations + PerpsGetPositionsOperation = 'perps.operation.get_positions', + PerpsGetOpenOrdersOperation = 'perps.operation.get_open_orders', + PerpsMarketDataPreload = 'perps.operation.market_data_preload', + PerpsUserDataPreload = 'perps.operation.user_data_preload', + + // Screen Load Metrics (milliseconds) + // Tracks full screen render performance + PerpsWithdrawalScreenLoaded = 'perps.screen.withdrawal_loaded', + PerpsMarketsScreenLoaded = 'perps.screen.markets_loaded', + PerpsAssetScreenLoaded = 'perps.screen.asset_loaded', + PerpsTradeScreenLoaded = 'perps.screen.trade_loaded', + PerpsCloseScreenLoaded = 'perps.screen.close_loaded', + PerpsTransactionHistoryScreenLoaded = 'perps.screen.transaction_history_loaded', + PerpsTabLoaded = 'perps.screen.tab_loaded', + + // UI Component Metrics (milliseconds) + // Tracks individual UI component render performance + PerpsLeverageBottomSheetLoaded = 'perps.ui.leverage_bottom_sheet_loaded', + PerpsOrderSubmissionToastLoaded = 'perps.ui.order_submission_toast_loaded', + PerpsOrderConfirmationToastLoaded = 'perps.ui.order_confirmation_toast_loaded', + PerpsCloseOrderSubmissionToastLoaded = 'perps.ui.close_order_submission_toast_loaded', + PerpsCloseOrderConfirmationToastLoaded = 'perps.ui.close_order_confirmation_toast_loaded', +} diff --git a/packages/perps-controller/src/constants/perpsConfig.ts b/packages/perps-controller/src/constants/perpsConfig.ts new file mode 100644 index 00000000000..68e9bad93d1 --- /dev/null +++ b/packages/perps-controller/src/constants/perpsConfig.ts @@ -0,0 +1,712 @@ +/** + * Perps feature constants - Controller layer (portable) + * + * This file contains only controller-portable configuration: + * - Constants used by controller logic, providers, and services + * - Calculation thresholds, API configs, and protocol constants + * + * UI-only constants (layout, display, navigation) live in: + * app/components/UI/Perps/constants/perpsConfig.ts + */ +export const ZERO_ADDRESS = '0x0000000000000000000000000000000000000000'; +export const ZERO_BALANCE = '0x0'; + +export const PERPS_CONSTANTS = { + FeatureFlagKey: 'perpsEnabled', + FeatureName: 'perps', // Constant for Sentry error filtering - enables "feature:perps" dashboard queries + /** Token description used to identify the synthetic "Perps balance" option in pay-with token lists */ + PerpsBalanceTokenDescription: 'perps-balance', + /** Symbol displayed for the synthetic "Perps balance" token in pay-with token lists */ + PerpsBalanceTokenSymbol: 'USD', + WebsocketTimeout: 5000, // 5 seconds + WebsocketCleanupDelay: 1000, // 1 second + BackgroundDisconnectDelay: 20_000, // 20 seconds delay before disconnecting when app is backgrounded or when user exits perps UX + ConnectionTimeoutMs: 10_000, // 10 seconds timeout for connection and position loading states + DefaultMonitoringTimeoutMs: 10_000, // 10 seconds default timeout for data monitoring operations + + // Connection timing constants + ConnectionGracePeriodMs: 20_000, // 20 seconds grace period before actual disconnection (same as BackgroundDisconnectDelay for semantic clarity) + ConnectionAttemptTimeoutMs: 30_000, // 30 seconds timeout for connection attempts to prevent indefinite hanging + WebsocketPingTimeoutMs: 5_000, // 5 seconds timeout for WebSocket health check ping + ConnectRetryDelayMs: 200, // Delay before retrying connect() when connection isn't ready yet + ForegroundPingRetryDelayMs: 500, // Delay before retrying ping in resumeFromForeground — JS thread may be sluggish right after foregrounding + ReconnectionCleanupDelayMs: 500, // Platform-agnostic delay to ensure WebSocket is ready + ReconnectionDelayAndroidMs: 300, // Android-specific reconnection delay for better reliability on slower devices + ReconnectionDelayIosMs: 100, // iOS-specific reconnection delay for optimal performance + ReconnectionRetryDelayMs: 5_000, // 5 seconds delay between reconnection attempts + NetworkRestoreMaxRetries: 8, // Max retry attempts when reconnecting after WiFi/network restore + NetworkRestoreRetryBaseMs: 1_500, // Base delay (ms) between network restore retries (multiplied by attempt number) + + // Connection manager timing constants + BalanceUpdateThrottleMs: 15000, // Update at most every 15 seconds to reduce state updates in PerpsConnectionManager + InitialDataDelayMs: 100, // Delay to allow initial data to load after connection establishment + + // Order submission timing + PlaceOrderTimeoutMs: 60_000, // Hard timeout for provider round-trip in TradingService.placeOrder + + // Deposit toast timing + DepositTakingLongerToastDelayMs: 30_000, // Delay before showing "Deposit taking longer than usual" toast + + DefaultAssetPreviewLimit: 5, + DefaultMaxLeverage: 3 as number, // Default fallback max leverage when market data is unavailable - conservative default + FallbackPriceDisplay: '$---', // Display when price data is unavailable + FallbackPercentageDisplay: '--%', // Display when change data is unavailable + FallbackDataDisplay: '--', // Display when non-price data is unavailable + ZeroAmountDisplay: '$0', // Display for zero dollar amounts (e.g., no volume) + ZeroAmountDetailedDisplay: '$0.00', // Display for zero dollar amounts with decimals + + RecentActivityLimit: 3, + + // Historical data fetching constants + FillsLookbackMs: 90 * 24 * 60 * 60 * 1000, // 3 months in milliseconds - limits REST API fills fetch + + // Recently viewed markets + RecentlyViewedMarketsTtlMs: 24 * 60 * 60 * 1000, // 24 hours TTL for recently viewed market entries + RecentlyViewedMarketsLimit: 10, // Maximum number of recently viewed markets to track + + // Temporary order form draft + PendingTradeConfigurationTtlMs: 30_000, // Restore a draft only during a brief navigation away from the form +} as const; + +/** + * Withdrawal-specific constants (protocol-agnostic) + * Note: Protocol-specific values like estimated time should be defined in each protocol's config + */ +export const WITHDRAWAL_CONSTANTS = { + DefaultMinAmount: '1.01', // Default minimum withdrawal amount in USDC + DefaultFeeAmount: 1, // Default withdrawal fee in USDC + DefaultFeeToken: 'USDC', // Default fee token +} as const; + +/** + * Validation thresholds for UI warnings and checks + * These values control when warnings are shown to users + */ +export const VALIDATION_THRESHOLDS = { + // Leverage threshold for warning users about high leverage + HighLeverageWarning: 20, // Show warning when leverage > 20x + + // Limit price difference threshold (as decimal, 0.1 = 10%) + LimitPriceDifferenceWarning: 0.1, // Warn if limit price differs by >10% from current price + + // Price deviation threshold (as decimal, 0.1 = 10%) + PriceDeviation: 0.1, // Warn if perps price deviates by >10% from spot price +} as const; + +/** + * Order slippage configuration + * Controls default slippage tolerance for different order types + * Conservative defaults based on HyperLiquid platform interface + * See: docs/perps/hyperliquid/ORDER-MATCHING-ERRORS.md + */ +export const ORDER_SLIPPAGE_CONFIG = { + // Market order slippage (basis points) + // 300 basis points = 3% = 0.03 decimal + // Conservative default for measured rollout, prevents most IOC failures + DefaultMarketSlippageBps: 300, + + // TP/SL order slippage (basis points) + // 1000 basis points = 10% = 0.10 decimal + // Aligns with HyperLiquid platform default for triggered orders + DefaultTpslSlippageBps: 1000, + + // Limit order slippage (basis points) + // 100 basis points = 1% = 0.01 decimal + // Kept conservative as limit orders rest on book (not IOC/immediate execution) + DefaultLimitSlippageBps: 100, +} as const; + +/** + * Defaults and bounds for the emulated `chase` placement. + * + * No supported venue exposes a chase as an API action — HyperLiquid documents it + * as running client-side — so the strategy is run here: a post-only order rests + * one tick inside the spread and is cancelled and re-placed as the touch moves. + * The poll floor limits request frequency; callers may also set explicit + * duration or repricing caps. Protocol-agnostic — a provider that gains a + * native chase ignores these entirely. + */ +export const CHASE_ORDER_CONFIG = { + /** Maximum submissions used to survive a touch moving before an ALO rests. */ + InitialPlacementAttempts: 3, + /** How often the touch is re-read when the caller does not say. */ + DefaultIntervalMs: 15000, + /** Floor on the poll interval, whatever the caller asks for. */ + MinIntervalMs: 1000, + /** + * How many chases may run at once. + * + * HyperLiquid documents a cap of five simultaneously active chase orders. It + * is a venue rule rather than controller policy, but it is spelled here + * alongside the rest of the chase configuration because an emulated chase is + * the only thing that can enforce it. + */ + MaxActiveSessions: 5, +} as const; + +/** Public lifecycle states reported for an emulated Chase order. */ +export const CHASE_ORDER_STATUS = { + Active: 'active', + TerminationPending: 'termination_pending', + Backgrounded: 'backgrounded', + MaxDistanceReached: 'max_distance_reached', + DurationReached: 'duration_reached', + RepricingLimitReached: 'repricing_limit_reached', + Filled: 'filled', + Canceled: 'canceled', + Failed: 'failed', +} as const; + +/** + * Bounds and step for the user-configurable max slippage preference (basis points). + * Shared by the controller (`setMaxSlippage`) and UI (`slippageConfig.ts`). + */ +export const MAX_SLIPPAGE_BOUNDS = { + MinBps: 10, + MaxBps: 1000, + StepBps: 10, +} as const; + +/** + * Max order amount buffer to reduce "Insufficient margin" rejections from the exchange. + * When the user selects 100% (slider or Max), we cap the order at (1 - this) of the + * theoretical max so that fees, rounding, and exchange-side margin checks are covered. + * Value as decimal (e.g. 0.005 = 0.5%). + */ +export const MAX_ORDER_MARGIN_BUFFER = 0.005; // 0.5% + +/** + * Performance optimization constants + * These values control debouncing and throttling for better performance + */ +export const PERFORMANCE_CONFIG = { + // Price updates debounce delay (milliseconds) + // Batches rapid WebSocket price updates to reduce re-renders + PriceUpdateDebounceMs: 1000, + + // Order validation debounce delay (milliseconds) + // Prevents excessive validation calls during rapid form input changes + ValidationDebounceMs: 300, + + // Freshness window for provider market metadata used by strategy capability reads + OrderCapabilitiesMetaFreshnessMs: 30_000, + + // Liquidation price debounce delay (milliseconds) + // Prevents excessive liquidation price calls during rapid form input changes + LiquidationPriceDebounceMs: 500, + + // Candle subscription debounce delay (milliseconds) + // Prevents WS subscription churn during rapid market switching (#28141) + CandleConnectDebounceMs: 500, + + // Order-form slippage estimate throttle (milliseconds) + // Updates the estimated-slippage value derived from the live L2 order book + // no more than once per window. Aggressive enough to keep the row reactive + // while the user edits the amount, conservative enough to avoid re-render + // pressure on every book tick. + SlippageEstimateThrottleMs: 250, + + // Order-book levels sampled when estimating slippage + // Number of price levels (per side) walked by `calculateEstimatedSlippageBps` + // to fill the requested USD notional. Matches the L2 sample size used by the + // order-book panel and is enough depth for the typical order sizes we + // surface in the order form. + SlippageEstimateBookLevels: 10, + + // Candle WS teardown delay (milliseconds) + // When the last subscriber for a cacheKey unsubscribes, wait this long before + // tearing down the WS. A subsequent subscribe inside the window cancels the + // teardown so rapid back-and-forth switches do not churn the connection. + CandleTeardownDelayMs: 150, + + // Perps REST coalesce TTL (milliseconds) + // + // Window in which identical GET-style REST calls (getOrderFills, getOrders, + // getFunding, historicalOrders) share a single in-flight promise / cached + // result. `forceRefresh` still bypasses the cache end-to-end (hooks → + // controller → MarketDataService → provider → HyperLiquidClientService), so + // pull-to-refresh always hits the network. + // + // Why 60 s: HyperLiquid's documented rate limit is 1200 weight / IP / + // rolling 60 s window. Sizing TTL = window length caps each endpoint-per- + // account at ≤1 REST hit per window under any UI activity pattern — rapid + // market switching, re-mounts (usePerpsMarketFills, usePerpsTransactionHistory), + // and multi-tab scans all share a single request. Live fills/orders/prices + // still flow via WS subscriptions, so REST is seed/backfill only — cache + // staleness inside the 60 s window is never user-visible. + PerpsRestCoalesceTtlMs: 60_000, + + // Candle snapshot REST coalesce TTL (milliseconds). + // Longer than PerpsRestCoalesceTtlMs because WS stream keeps live candles + // fresh — the REST snapshot only seeds the chart on initial subscribe. A + // 30 s window lets rapid market switching (pass 1 → pass 2 of a stress + // loop) share the same snapshot per (symbol, interval), cutting + // candleSnapshot REST weight roughly in half. + PerpsCandleCoalesceTtlMs: 30_000, + + // Navigation params delay (milliseconds) + // Required for React Navigation to complete state transitions before setting params + // This ensures navigation context is available when programmatically selecting tabs + NavigationParamsDelayMs: 200, + + // Tab control reset delay (milliseconds) + // Delay to reset programmatic tab control after tab switching to prevent render loops + TabControlResetDelayMs: 500, + + // Market data cache duration (milliseconds) + // How long to cache market list data before fetching fresh data + MarketDataCacheDurationMs: 5 * 60 * 1000, // 5 minutes + + // Asset metadata cache duration (milliseconds) + // How long to cache asset icon validation results + AssetMetadataCacheDurationMs: 60 * 60 * 1000, // 1 hour + + // Max leverage cache duration (milliseconds) + // How long to cache max leverage values per asset (leverage rarely changes) + MaxLeverageCacheDurationMs: 60 * 60 * 1000, // 1 hour + + // Rewards cache durations (milliseconds) + // How long to cache fee discount data from rewards API + FeeDiscountCacheDurationMs: 5 * 60 * 1000, // 5 minutes + // How long to cache points calculation parameters from rewards API + PointsCalculationCacheDurationMs: 5 * 60 * 1000, // 5 minutes + + /** + * Performance logging markers for filtering logs during development and debugging + * These markers help isolate performance-related logs from general application logs + * Usage: Use in DevLogger calls to easily filter specific performance areas + * Impact: Development only (uses DevLogger) - zero production performance cost + * + * Examples: + * - Filter Sentry performance logs: `adb logcat | grep PERPSMARK_SENTRY` + * - Filter MetaMetrics events: `adb logcat | grep PERPSMARK_METRICS` + * - Filter WebSocket performance: `adb logcat | grep PERPSMARK_WS` + * - Filter all Perps performance: `adb logcat | grep PERPSMARK_` + */ + LoggingMarkers: { + // Sentry performance measurement logs (screen loads, bottom sheets, API timing) + SentryPerformance: 'PERPSMARK_SENTRY', + + // MetaMetrics event tracking logs (user interactions, business analytics) + MetametricsEvents: 'PERPSMARK_METRICS', + + // WebSocket performance logs (connection timing, data flow, reconnections) + WebsocketPerformance: 'PERPSMARK_SENTRY_WS', + } as const, +} as const; + +export const TP_SL_CONFIG = { + UsePositionBoundTpsl: true, +} as const; + +/** + * Bounds applied to a HyperLiquid TWAP placement. + * + * The pinned HyperLiquid SDK (0.33.1) validates the TWAP duration as a safe + * integer in `[5, 1440]` before signing, although the venue currently documents + * a maximum of seven days (`10080` minutes). The controller exposes the SDK's + * narrower cap until that dependency supports the venue limit, avoiding an + * opaque SDK error. `MinNotionalUsd` is the venue's documented minimum *total* + * order size for a TWAP, which it enforces instead of the per-order minimum — + * its suborders are its own business. + * + * Carries the venue prefix, like `HYPERLIQUID_ORDER_LIMITS`, because these are + * venue/SDK constraints rather than controller policy. + * + * From: https://hyperliquid.gitbook.io/hyperliquid-docs/trading/order-types + */ +export const HYPERLIQUID_TWAP_LIMITS = { + MinDurationMinutes: 5, + MaxDurationMinutes: 1440, + MinNotionalUsd: 100, +} as const; + +/** + * HyperLiquid order limits based on leverage + * From: https://hyperliquid.gitbook.io/hyperliquid-docs/trading/contract-specifications + */ +export const HYPERLIQUID_ORDER_LIMITS = { + // Market orders + MarketOrderLimits: { + // $15,000,000 for max leverage >= 25 + HighLeverage: 15_000_000, + // $5,000,000 for max leverage in [20, 25) + MediumHighLeverage: 5_000_000, + // $2,000,000 for max leverage in [10, 20) + MediumLeverage: 2_000_000, + // $500,000 for max leverage < 10 + LowLeverage: 500_000, + }, + // Limit orders are 10x market order limits + LimitOrderMultiplier: 10, +} as const; + +/** + * Close position configuration + * Controls behavior and constants specific to position closing + */ +export const CLOSE_POSITION_CONFIG = { + // Decimal places for USD amount input display + UsdDecimalPlaces: 2, + + // Default close percentage when opening the close position view + DefaultClosePercentage: 100, + + // Precision for position size calculations to prevent rounding errors + AmountCalculationPrecision: 6, + + // Throttle delay for real-time price updates during position closing + PriceThrottleMs: 3000, + + // Fallback decimal places for tokens without metadata + FallbackTokenDecimals: 18, +} as const; + +/** + * Margin adjustment configuration + * Controls behavior for adding/removing margin from positions + */ +export const MARGIN_ADJUSTMENT_CONFIG = { + // Risk thresholds for margin removal warnings + // Threshold values represent ratio of (price distance to liquidation) / (liquidation price) + // Values < 1.0 mean price is dangerously close to liquidation + LiquidationRiskThreshold: 1.2, // 20% buffer before liquidation - triggers danger state + LiquidationWarningThreshold: 1.5, // 50% buffer before liquidation - triggers warning state + + // Minimum margin adjustment amount (USD) + // Prevents dust adjustments and ensures meaningful position changes + MinAdjustmentAmount: 1, + + // Precision for margin calculations + // Ensures accurate decimal handling in margin/leverage calculations + CalculationPrecision: 6, + + // Safety buffer for margin removal to account for HyperLiquid's transfer margin requirement + // HyperLiquid enforces: transfer_margin_required = max(initial_margin_required, 0.1 * total_position_value) + // See: https://hyperliquid.gitbook.io/hyperliquid-docs/trading/margin-and-pnl + MarginRemovalSafetyBuffer: 0.1, + + // Fallback max leverage when market data is unavailable + // Conservative value to prevent over-removal of margin + // Most HyperLiquid assets support at least 50x leverage + FallbackMaxLeverage: 50, +} as const; + +/** + * Data Lake API configuration + * Endpoints for reporting perps trading activity for notifications + */ +export const DATA_LAKE_API_CONFIG = { + // Order reporting endpoint - only used for mainnet perps trading + OrdersEndpoint: 'https://perps.api.cx.metamask.io/api/v1/orders', +} as const; + +/** + * Subscription benefits cache (stale-while-revalidate). + * + * The unified fee resolver never awaits the benefits read, so these bounds are + * what decide whether the cached snapshot may grant the perps fee waiver: + * - within `FreshMs` the snapshot is served as-is, + * - past `FreshMs` it is still served while a background refresh runs, + * - past `MaxStaleMs` it is no longer trusted to grant the waiver, and the + * resolver falls back to the next-lowest fee source. + */ +export const SUBSCRIPTION_BENEFITS_CACHE = { + FreshMs: 60_000, // 1 minute – no refresh triggered + MaxStaleMs: 10 * 60 * 1000, // 10 minutes – ceiling for granting the waiver +} as const; + +/** + * Terminal API configuration. + * The full endpoint URL is injected at runtime via + * `PerpsPlatformDependencies.terminalApi.marketDataUrl` from each client build + * (dev/uat/prd); only cache settings live here. + */ +export const TERMINAL_API_CONFIG = { + CacheTtlMs: 5 * 60 * 1000, // 5 minutes + FetchTimeoutMs: 10_000, // 10 seconds – degrade to provider on slow Terminal +} as const; + +/** + * Decimal precision configuration + * Controls maximum decimal places for price and input validation + */ +export const DECIMAL_PRECISION_CONFIG = { + // Maximum decimal places for price input (matches Hyperliquid limit) + // Used in TP/SL forms, limit price inputs, and price validation + MaxPriceDecimals: 6, + // Maximum significant figures allowed by HyperLiquid API + // Orders with more than 5 significant figures will be rejected + MaxSignificantFigures: 5, + // Defensive fallback for size decimals when market data fails to load + // Real szDecimals should always come from market data API (varies by asset) + // Using 6 as safe maximum to prevent crashes (covers most assets) + // NOTE: This is NOT semantically correct - just a defensive measure + FallbackSizeDecimals: 6, +} as const; + +/** + * Market sorting configuration + * Controls sorting behavior and presets for the trending markets view + */ +export const MARKET_SORTING_CONFIG = { + // Default sort settings + DefaultSortOptionId: 'volume' as const, + DefaultDirection: 'desc' as const, + + // Available sort fields (only includes fields supported by PerpsMarketData) + SortFields: { + Volume: 'volume', + PriceChange: 'priceChange', + OpenInterest: 'openInterest', + FundingRate: 'fundingRate', + } as const, + + // Sort button presets for filter chips (simplified buttons without direction) + SortButtonPresets: [ + { field: 'volume', labelKey: 'perps.sort.volume' }, + { field: 'priceChange', labelKey: 'perps.sort.price_change' }, + { field: 'fundingRate', labelKey: 'perps.sort.funding_rate' }, + ] as const, + + // Sort options for the bottom sheet + // All options support direction toggle (high-to-low / low-to-high) + SortOptions: [ + { + id: 'volume', + labelKey: 'perps.sort.volume', + field: 'volume', + direction: 'desc', + }, + { + id: 'priceChange', + labelKey: 'perps.sort.price_change', + field: 'priceChange', + direction: 'desc', + }, + { + id: 'openInterest', + labelKey: 'perps.sort.open_interest', + field: 'openInterest', + direction: 'desc', + }, + { + id: 'fundingRate', + labelKey: 'perps.sort.funding_rate', + field: 'fundingRate', + direction: 'desc', + }, + ] as const, +} as const; + +/** + * Type for valid sort option IDs + * Derived from SORT_OPTIONS to ensure type safety + * Valid values: 'volume' | 'priceChange' | 'openInterest' | 'fundingRate' + */ +export type SortOptionId = + (typeof MARKET_SORTING_CONFIG.SortOptions)[number]['id']; + +/** + * Perps interface mode. + * + * `Lite` is the simplified default experience; `Pro` exposes the advanced + * trading layout (chart, order book, inline order form). + */ +export enum PerpsMode { + Lite = 'lite', + Pro = 'pro', +} + +/** + * Side filter for the Pro Positions list (long/short/all). + * + * Independent of `ordersSideFilter`. Shared across markets via + * `proLayoutPreferences.positionsSideFilter`. + */ +export type ProPositionsSideFilter = 'all' | 'long' | 'short'; + +/** + * Sort fields available on the Pro Positions list. + */ +export type ProPositionsSortField = + | 'positionValue' + | 'unrealizedPnl' + | 'fundingRate'; + +/** + * Sort direction for the Pro Positions list. + */ +export type ProPositionsSortDirection = 'asc' | 'desc'; + +/** + * Side filter for the Pro Orders list (long/short/all). + * + * Independent of `positionsSideFilter`. Shared across markets via + * `proLayoutPreferences.ordersSideFilter`. + */ +export type ProOrdersSideFilter = 'all' | 'long' | 'short'; + +/** + * Sort fields available on the Pro Orders list. + */ +export type ProOrdersSortField = 'orderValue' | 'size' | 'price' | 'time'; + +/** + * Sort direction for the Pro Orders list. + */ +export type ProOrdersSortDirection = 'asc' | 'desc'; + +/** + * Currency used by the Pro order-book size/total column. + */ +export type OrderBookListCurrency = 'base' | 'usd'; + +/** + * Value shown by the Pro order-book size/total column. + */ +export type OrderBookListMetric = 'size' | 'total'; + +/** + * Market-agnostic Pro order-book display preferences. + */ +export type OrderBookPreferences = { + currency: OrderBookListCurrency; + metric: OrderBookListMetric; +}; + +/** + * Default Pro order-book display preferences. + */ +export const DEFAULT_ORDER_BOOK_PREFERENCES: OrderBookPreferences = { + currency: 'usd', + metric: 'total', +}; + +/** + * Pro-mode layout preferences (network-independent). + * + * Flat object that persists across markets (unlike the per-market + * `tradeConfigurations`). `chartExpanded` and the `*Position` fields are + * reserved for future container-position UI. Positions and Orders each have + * their own side filter and sort so they survive market navigation and app + * restarts independently. + */ +export type ProLayoutPreferences = { + orderBookExpanded: boolean; + chartExpanded: boolean; + orderBookPosition: 'left' | 'right'; + orderFormPosition: 'left' | 'right'; + positionsSideFilter: ProPositionsSideFilter; + positionsSortField: ProPositionsSortField; + positionsSortDirection: ProPositionsSortDirection; + ordersSideFilter: ProOrdersSideFilter; + ordersSortField: ProOrdersSortField; + ordersSortDirection: ProOrdersSortDirection; +}; + +/** + * Default pro-mode layout preferences. + * + * Shared by `getDefaultPerpsControllerState()`, the controller getter, and the + * selector so callers always receive a fully-populated object even when the + * persisted state predates this field. + */ +export const DEFAULT_PRO_LAYOUT_PREFERENCES: ProLayoutPreferences = { + orderBookExpanded: false, + chartExpanded: true, + orderBookPosition: 'left', + orderFormPosition: 'right', + positionsSideFilter: 'all', + positionsSortField: 'positionValue', + positionsSortDirection: 'desc', + ordersSideFilter: 'all', + ordersSortField: 'time', + ordersSortDirection: 'desc', +}; + +/** + * Default Perps interface mode. + */ +export const DEFAULT_PERPS_MODE: PerpsMode = PerpsMode.Lite; + +/** + * Default market-agnostic order type. + */ +export const DEFAULT_SELECTED_ORDER_TYPE = 'market' as const; + +/** + * Funding rate display configuration + * Controls how funding rates are formatted and displayed + */ +export const FUNDING_RATE_CONFIG = { + // Number of decimal places to display for funding rates + Decimals: 4, + // Default display value when funding rate is zero or unavailable + ZeroDisplay: '0.0000%', + // Multiplier to convert decimal funding rate to percentage + PercentageMultiplier: 100, +} as const; + +/** + * Provider configuration for multi-provider support + */ +export const PROVIDER_CONFIG = { + /** Default perpetual DEX provider when no explicit selection exists */ + DefaultProvider: 'hyperliquid' as const, + /** Force MYX to testnet only (mainnet credentials not yet available) */ + MYX_TESTNET_ONLY: false, +} as const; + +// Disk-backed cold-start cache keys and throttle interval. +// The user-data key ends in _V2 because the AccountState balance contract +// changed (TAT-3047) and has no in-payload version field. Bumping the key +// forces a one-time empty cache on upgrade — consumers fall through to +// skeleton/fallback until the first WS tick, avoiding stale legacy-shape +// reads that would surface as $0 balances. +export const PERPS_DISK_CACHE_MARKETS = 'PERPS_DISK_CACHE_MARKETS'; +export const PERPS_DISK_CACHE_USER_DATA = 'PERPS_DISK_CACHE_USER_DATA_V2'; +export const PERPS_DISK_CACHE_THROTTLE_MS = 30_000; + +/** + * Minimum interval between WebSocket-triggered HL `userAbstraction` + * refreshes. Balances picking up HL-web mode flips (Unified ↔ Standard) + * promptly against burning REST quota on every spot tick. Covers the + * observed user pattern of flipping mode once per session at most. + */ +export const ABSTRACTION_MODE_REFRESH_THROTTLE_MS = 60_000; + +/** + * Build the standard provider:network cache key from controller state. + * + * @param state - Controller state containing provider and network info. + * @param state.activeProvider - Active perps provider name. + * @param state.isTestnet - Whether testnet mode is active. + * @returns Cache key in the format "provider:mainnet" or "provider:testnet". + */ +export function getProviderNetworkKey(state: { + activeProvider?: string; + isTestnet?: boolean; +}): string { + return `${state.activeProvider ?? PROVIDER_CONFIG.DefaultProvider}:${state.isTestnet ? 'testnet' : 'mainnet'}`; +} + +/** + * Build a provider:network cache key for a specific provider id. + * Accounts for MYX_TESTNET_ONLY: MYX is always on testnet regardless of the + * global network flag. + * + * @param providerId - The provider identifier (e.g. "hyperliquid", "myx"). + * @param isTestnet - Global testnet flag from controller state. + * @returns Cache key in the format "provider:mainnet" or "provider:testnet". + */ +export function buildProviderCacheKey( + providerId: string, + isTestnet: boolean, +): string { + const effectiveTestnet = + providerId === 'myx' + ? PROVIDER_CONFIG.MYX_TESTNET_ONLY || isTestnet + : isTestnet; + return `${providerId}:${effectiveTestnet ? 'testnet' : 'mainnet'}`; +} diff --git a/packages/perps-controller/src/constants/transactionsHistoryConfig.ts b/packages/perps-controller/src/constants/transactionsHistoryConfig.ts new file mode 100644 index 00000000000..c646156a320 --- /dev/null +++ b/packages/perps-controller/src/constants/transactionsHistoryConfig.ts @@ -0,0 +1,34 @@ +/** + * Perps feature constants + */ +export const PERPS_TRANSACTIONS_HISTORY_CONSTANTS = { + FLASH_LIST_DRAW_DISTANCE: 200, + FLASH_LIST_SCROLL_EVENT_THROTTLE: 16, + LIST_ITEM_SELECTOR_OPACITY: 0.7, + /** + * Maximum number of days to look back for funding history. + * Only the most recent 30-day window is fetched on initial load; + * older windows are fetched on-demand as the user scrolls. + * Empty windows (gaps in activity) are skipped automatically. + */ + DEFAULT_FUNDING_HISTORY_DAYS: 365, + /** + * Number of days per pagination window when fetching funding history. + * Each window is fetched via fetchWindowWithAutoSplit, which recursively + * halves any window that hits FUNDING_HISTORY_API_LIMIT, guaranteeing + * complete results regardless of position count or trading activity. + */ + FUNDING_HISTORY_PAGE_WINDOW_DAYS: 30, + /** + * HyperLiquid API returns at most this many records per userFunding call. + * When a single window exceeds this, only the oldest records are returned — + * the pagination strategy avoids this by using small enough windows. + */ + FUNDING_HISTORY_API_LIMIT: 500, + /** + * Minimum window size (ms) for the auto-split recursion in getFunding. + * HyperLiquid's funding interval is 8 h, so a 1-hour window holds at most + * a fraction of one event per position — well under the 500-record cap. + */ + MIN_SPLIT_WINDOW_MS: 60 * 60 * 1000, +} as const; diff --git a/packages/perps-controller/src/index.ts b/packages/perps-controller/src/index.ts new file mode 100644 index 00000000000..942182cebcf --- /dev/null +++ b/packages/perps-controller/src/index.ts @@ -0,0 +1,720 @@ +/** + * PerpsController - Protocol-agnostic perpetuals trading controller + * + * This module provides a unified interface for perpetual futures trading + * across multiple protocols with high-performance real-time data handling. + * + * Key Features: + * - Protocol abstraction (HyperLiquid first, extensible to GMX, dYdX, etc.) + * - Dual data flow: Redux for persistence, direct callbacks for live data + * - MetaMask native integration with BaseController pattern + * - Mobile-optimized with throttling and performance considerations + * + * Usage: + * ```typescript + * import { usePerpsController } from './controllers.js'; + * + * const { placeOrder, getPositions } = usePerpsController(); + * // Live prices hooks removed with Live Market Prices component + * + * // Place a market order + * await placeOrder({ + * coin: 'ETH', + * is_buy: true, + * sz: '0.1', + * order_type: 'market' + * }); + * ``` + */ + +// Core controller and types +export { + PerpsController, + getDefaultPerpsControllerState, + InitializationState, + PerpsMode, + DEFAULT_ORDER_BOOK_PREFERENCES, + DEFAULT_PERPS_MODE, + DEFAULT_PRO_LAYOUT_PREFERENCES, + DEFAULT_SELECTED_ORDER_TYPE, +} from './PerpsController.js'; +export type { + OrderBookListCurrency, + OrderBookListMetric, + OrderBookPreferences, + PerpsControllerState, + PerpsControllerOptions, + PerpsControllerMessenger, + PerpsControllerGetStateAction, + PerpsControllerActions, + PerpsControllerChaseOrderMaxDistanceReachedEvent, + PerpsControllerEvents, + ProLayoutPreferences, + ProOrdersSideFilter, + ProOrdersSortDirection, + ProOrdersSortField, + ProPositionsSideFilter, + ProPositionsSortDirection, + ProPositionsSortField, +} from './PerpsController.js'; +export type { + PerpsControllerApproveSubscriptionBuilderFeeAction, + PerpsControllerCalculateFeesAction, + PerpsControllerCalculateLiquidationPriceAction, + PerpsControllerCalculateMaintenanceMarginAction, + PerpsControllerPreviewPositionModifyAction, + PerpsControllerCancelOrderAction, + PerpsControllerCancelOrdersAction, + PerpsControllerClearDepositResultAction, + PerpsControllerClearPendingTradeConfigurationAction, + PerpsControllerClearPendingTransactionRequestsAction, + PerpsControllerClearWithdrawResultAction, + PerpsControllerClosePositionAction, + PerpsControllerClosePositionsAction, + PerpsControllerClearAttributionContextAction, + PerpsControllerCompleteWithdrawalFromHistoryAction, + PerpsControllerDepositWithConfirmationAction, + PerpsControllerDepositWithOrderAction, + PerpsControllerDisconnectAction, + PerpsControllerEditOrderAction, + PerpsControllerFetchHistoricalCandlesAction, + PerpsControllerFlipPositionAction, + PerpsControllerGetAccountStateAction, + PerpsControllerGetActiveProviderAction, + PerpsControllerGetActiveProviderOrNullAction, + PerpsControllerGetAttributionContextAction, + PerpsControllerGetAvailableDexsAction, + PerpsControllerGetBlockExplorerUrlAction, + PerpsControllerGetCachedMarketDataForActiveProviderAction, + PerpsControllerGetCachedUserDataForActiveProviderAction, + PerpsControllerGetUserDataSnapshotAction, + PerpsControllerGetCurrentNetworkAction, + PerpsControllerGetFundingAction, + PerpsControllerGetChaseOrdersAction, + PerpsControllerGetTwapOrdersAction, + PerpsControllerGetHistoricalPortfolioAction, + PerpsControllerGetMarketDataWithPricesAction, + PerpsControllerGetMarketFilterPreferencesAction, + PerpsControllerGetMarketCategoriesAction, + PerpsControllerGetMarketsAction, + PerpsControllerGetMaxLeverageAction, + PerpsControllerGetOpenOrdersAction, + PerpsControllerGetOrderBookGroupingAction, + PerpsControllerGetOrderBookPreferencesAction, + PerpsControllerGetOrderCapabilitiesAction, + PerpsControllerGetOrderFillsAction, + PerpsControllerGetOrdersAction, + PerpsControllerGetPendingTradeConfigurationAction, + PerpsControllerGetPositionsAction, + PerpsControllerGetSelectedOrderTypeAction, + PerpsControllerGetTradeConfigurationAction, + PerpsControllerGetRecentlyViewedMarketsAction, + PerpsControllerGetWatchlistMarketsAction, + PerpsControllerGetVisibleCandleCountAction, + PerpsControllerGetWebSocketConnectionStateAction, + PerpsControllerGetWithdrawalProgressAction, + PerpsControllerGetWithdrawalRoutesAction, + PerpsControllerInitAction, + PerpsControllerInvalidateSubscriptionBenefitsAction, + PerpsControllerIsCurrentlyReinitializingAction, + PerpsControllerIsFirstTimeUserOnCurrentNetworkAction, + PerpsControllerIsWatchlistMarketAction, + PerpsControllerMarkFirstOrderCompletedAction, + PerpsControllerMarkTutorialCompletedAction, + PerpsControllerPlaceOrderAction, + PerpsControllerReconnectAction, + PerpsControllerRecordMarketViewedAction, + PerpsControllerRefreshEligibilityAction, + PerpsControllerResetFirstTimeUserStateAction, + PerpsControllerResetSelectedPaymentTokenAction, + PerpsControllerSaveMarketFilterPreferencesAction, + PerpsControllerGetProLayoutPreferencesAction, + PerpsControllerSetProLayoutPreferencesAction, + PerpsControllerSetPerpsModeAction, + PerpsControllerSetOrderBookPreferencesAction, + PerpsControllerSetSelectedOrderTypeAction, + PerpsControllerSaveOrderBookGroupingAction, + PerpsControllerSavePendingTradeConfigurationAction, + PerpsControllerSaveTradeConfigurationAction, + PerpsControllerSetAttributionContextAction, + PerpsControllerSetLiveDataConfigAction, + PerpsControllerSetSelectedPaymentTokenAction, + PerpsControllerSetVisibleCandleCountAction, + PerpsControllerStartEligibilityMonitoringAction, + PerpsControllerStartMarketDataPreloadAction, + PerpsControllerSuspendChaseOrdersAction, + PerpsControllerStopEligibilityMonitoringAction, + PerpsControllerStopMarketDataPreloadAction, + PerpsControllerSubscribeToAccountAction, + PerpsControllerSubscribeToCandlesAction, + PerpsControllerSubscribeToConnectionStateAction, + PerpsControllerSubscribeToOICapsAction, + PerpsControllerSubscribeToOrderBookAction, + PerpsControllerSubscribeToOrderFillsAction, + PerpsControllerSubscribeToOrdersAction, + PerpsControllerSubscribeToPositionsAction, + PerpsControllerSubscribeToPricesAction, + PerpsControllerSwitchProviderAction, + PerpsControllerToggleTestnetAction, + PerpsControllerToggleWatchlistMarketAction, + PerpsControllerUpdateMarginAction, + PerpsControllerUpdatePositionTPSLAction, + PerpsControllerUpdateWithdrawalProgressAction, + PerpsControllerUpdateWithdrawalStatusAction, + PerpsControllerValidateClosePositionAction, + PerpsControllerValidateOrderAction, + PerpsControllerValidateWithdrawalAction, + PerpsControllerWithdrawAction, +} from './PerpsController-method-action-types.js'; + +// Provider interfaces and implementations +export { HyperLiquidProvider } from './providers/HyperLiquidProvider.js'; +export { ChaseOrderSuspensionError } from './providers/AggregatedPerpsProvider.js'; + +// Type definitions (explicit named exports) +export { + WebSocketConnectionState, + PerpsAnalyticsEvent, + MARKET_CATEGORIES, + MarketCategory, +} from './types/index.js'; +export type { + RawLedgerUpdate, + UserHistoryItem, + GetUserHistoryParams, + TradeConfiguration, + OrderType, + TriggerOrderType, + StrategyOrderType, + OrdinaryOrderType, + OrderExecution, + TriggerDirection, + TpslLinkage, + PositionTriggerOrder, + MarketType, + MarketTypeFilter, + InputMethod, + TradeAction, + TrackingData, + TPSLTrackingData, + OrderParams, + OrderResult, + ChaseOrder, + ChaseOrderMaxDistanceReached, + ChaseOrderStatus, + TwapOrder, + TwapOrderFill, + TwapOrderStatus, + Position, + AccountState, + ClosePositionParams, + ClosePositionsParams, + ClosePositionsResult, + UpdateMarginParams, + MarginResult, + FlipPositionParams, + InitializeResult, + ReadyToTradeResult, + DisconnectResult, + MarketInfo, + PerpsMarketData, + ToggleTestnetResult, + AssetRoute, + SwitchProviderResult, + CancelOrderParams, + CancelOrderResult, + BatchCancelOrdersParams, + CancelOrdersParams, + CancelOrdersResult, + EditOrderParams, + DepositParams, + DepositWithConfirmationParams, + DepositResult, + DepositStatus, + DepositFlowType, + DepositStepInfo, + WithdrawParams, + WithdrawResult, + TransferBetweenDexsParams, + TransferBetweenDexsResult, + GetHistoricalPortfolioParams, + HistoricalPortfolioResult, + LiveDataConfig, + PerpsControllerConfig, + PriceUpdate, + OrderFill, + CheckEligibilityParams, + GetPositionsParams, + GetAccountStateParams, + GetUserDataSnapshotParams, + PerpsUserDataSnapshot, + GetOrderFillsParams, + GetOrFetchFillsParams, + GetOrdersParams, + GetFundingParams, + GetSupportedPathsParams, + GetAvailableDexsParams, + GetMarketsParams, + GetMarketDataWithPricesParams, + SortField, + SortDirection, + SubscribePricesParams, + SubscribePositionsParams, + SubscribeOrderFillsParams, + SubscribeOrdersParams, + SubscribeAccountParams, + SubscribeOICapsParams, + SubscribeCandlesParams, + OrderBookLevel, + OrderBookData, + SubscribeOrderBookParams, + LiquidationPriceParams, + MaintenanceMarginParams, + PositionModifyPreviewParams, + PositionModifyPreviewResult, + PositionModifyPreviewSource, + PositionModifyPreviewKind, + PositionPreviewValue, + PositionModifyPreviewCurrent, + PositionModifyPreviewOpen, + PositionModifyPreviewFullClose, + PositionModifyPreviewUnsupported, + PositionModifyPreviewNone, + FeeCalculationParams, + FeeCalculationResult, + GetOrderCapabilitiesParams, + OrderCapabilitiesUnavailableReason, + DirectProviderOrderCapabilitiesUnavailableReason, + RoutedOrderCapabilitiesUnavailableReason, + DirectProviderOrderCapabilities, + PerpsOrderCapabilities, + PerpsSubscriptionBenefits, + PerpsSubscriptionUsage, + PerpsSubscriptionFeeWaiverStatus, + PerpsFeeSource, + PerpsFeeResolution, + UpdatePositionTPSLParams, + Order, + Funding, + PerpsProvider, + PerpsProviderType, + PerpsActiveProviderMode, + AggregationMode, + RoutingStrategy, + AggregatedProviderConfig, + ProviderError, + AggregatedAccountState, + PerpsLogger, + PerpsTraceName, + PerpsTraceValue, + PerpsAnalyticsProperties, + PerpsAttributionContext, + PerpsMetrics, + PerpsDebugLogger, + PerpsStreamManager, + PerpsPerformance, + PerpsTracer, + PerpsTypedMessageParams, + PerpsTransactionParams, + PerpsAddTransactionOptions, + PerpsInternalAccount, + PerpsRemoteFeatureFlagState, + PerpsPlatformDependencies, + PerpsTerminalMarketService, + PerpsGlobalSnapshotRequest, + PerpsGlobalSnapshotResult, + TerminalAssetMetadata, + PerpsCacheType, + InvalidateCacheParams, + PerpsCacheInvalidator, + MarketDataFormatters, + PaymentToken, + PerpsSelectedPaymentToken, + VersionGatedFeatureFlag, +} from './types/index.js'; +export { + PerpsTraceNames, + PerpsTraceOperations, + isVersionGatedFeatureFlag, +} from './types/index.js'; + +// Types from sub-modules (re-exported via types/index.ts) +export type { + TestResultStatus, + TestResult, + SDKTestType, + HyperliquidAsset, + CandleStick, + CandleData, + OrderFormState, + OrderDirection, + ReconnectOptions, + ExtendedAssetMeta, + ExtendedPerpDex, +} from './types/index.js'; +export type { + BaseTransactionResult, + LastTransactionResult, + TransactionStatus, + TransactionRecord, +} from './types/index.js'; +export { isTransactionRecord, isLastTransactionResult } from './types/index.js'; +export type { + AssetPosition, + SpotBalance, + PerpsUniverse, + PerpsAssetCtx, + PredictedFunding, + FrontendOrder, + SDKOrderParams, + ClearinghouseStateResponse, + SpotClearinghouseStateResponse, + MetaResponse, + FrontendOpenOrdersResponse, + AllMidsResponse, + MetaAndAssetCtxsResponse, + PredictedFundingsResponse, + SpotMetaResponse, +} from './types/index.js'; +export type { + HyperLiquidEndpoints, + AssetNetworkConfig, + HyperLiquidAssetConfigs, + BridgeContractConfig, + HyperLiquidBridgeContracts, + TransportReconnectConfig, + TransportKeepAliveConfig, + HyperLiquidTransportConfig, + TradingAmountConfig, + TradingDefaultsConfig, + FeeRatesConfig, + HyperLiquidNetwork, +} from './types/index.js'; +export type { PerpsToken } from './types/index.js'; + +// Constants (explicit named exports) +export { + CandlePeriod, + TimeDuration, + ChartInterval, + MAX_CANDLE_COUNT, + VISIBLE_CANDLE_COUNT_CONFIG, + DURATION_CANDLE_PERIODS, + CANDLE_PERIODS, + DEFAULT_CANDLE_PERIOD, + getCandlePeriodsForDuration, + getDefaultCandlePeriodForDuration, + calculateCandleCount, +} from './constants/index.js'; +export { PERPS_EVENT_PROPERTY, PERPS_EVENT_VALUE } from './constants/index.js'; +export { DETAILED_ORDER_TYPES, isTPSLOrder } from './constants/index.js'; +export { PERPS_TRANSACTIONS_HISTORY_CONSTANTS } from './constants/index.js'; +export { + ARBITRUM_MAINNET_CHAIN_ID_HEX, + ARBITRUM_MAINNET_CHAIN_ID, + ARBITRUM_TESTNET_CHAIN_ID, + ARBITRUM_MAINNET_CAIP_CHAIN_ID, + ARBITRUM_TESTNET_CAIP_CHAIN_ID, + HYPERLIQUID_MAINNET_CHAIN_ID, + HYPERLIQUID_TESTNET_CHAIN_ID, + HYPERLIQUID_MAINNET_CAIP_CHAIN_ID, + HYPERLIQUID_TESTNET_CAIP_CHAIN_ID, + HYPERLIQUID_NETWORK_NAME, + USDC_SYMBOL, + USDC_NAME, + USDC_DECIMALS, + TOKEN_DECIMALS, + ZERO_ADDRESS, + ZERO_BALANCE, + ARBITRUM_SEPOLIA_CHAIN_ID, + USDC_ETHEREUM_MAINNET_ADDRESS, + USDC_ARBITRUM_MAINNET_ADDRESS, + USDC_ARBITRUM_TESTNET_ADDRESS, + USDC_TOKEN_ICON_URL, + HYPERLIQUID_ENDPOINTS, + HYPERLIQUID_ASSET_ICONS_BASE_URL, + METAMASK_PERPS_ICONS_BASE_URL, + HYPERLIQUID_ASSET_CONFIGS, + HYPERLIQUID_BRIDGE_CONTRACTS, + HYPERLIQUID_TRANSPORT_CONFIG, + TRADING_DEFAULTS, + FEE_RATES, + HIP3_FEE_CONFIG, + BUILDER_FEE_CONFIG, + REFERRAL_CONFIG, + DEPOSIT_CONFIG, + HYPERLIQUID_WITHDRAWAL_MINUTES, + getWebSocketEndpoint, + getChainId, + getCaipChainId, + getBridgeInfo, + getSupportedAssets, + CAIP_ASSET_NAMESPACES, + HYPERLIQUID_CONFIG, + HIP3_ASSET_ID_CONFIG, + BASIS_POINTS_DIVISOR, + SPOT_ASSET_ID_OFFSET, + HIP3_ASSET_MARKET_TYPES, + TESTNET_HIP3_CONFIG, + MAINNET_HIP3_CONFIG, + HIP3_MARGIN_CONFIG, + INITIAL_AMOUNT_UI_PROGRESS, + WITHDRAWAL_PROGRESS_STAGES, + PROGRESS_BAR_COMPLETION_DELAY_MS, +} from './constants/index.js'; +export type { SupportedAsset } from './constants/index.js'; +export { PerpsMeasurementName } from './constants/index.js'; +export { + MYX_MAINNET_CHAIN_ID, + MYX_TESTNET_CHAIN_ID, + MYX_MAINNET_CAIP_CHAIN_ID, + MYX_TESTNET_CAIP_CHAIN_ID, + getMYXChainId, + MYX_ENDPOINTS, + getMYXHttpEndpoint, + MYX_PRICE_DECIMALS, + MYX_SIZE_DECIMALS, + MYX_COLLATERAL_DECIMALS, + USDT_BNB_TESTNET, + USDT_BNB_MAINNET, + MYX_ASSET_CONFIGS, + fromMYXPrice, + toMYXPrice, + fromMYXSize, + toMYXSize, + fromMYXCollateral, + MYX_PRICE_POLLING_INTERVAL_MS, + MYX_HTTP_TIMEOUT_MS, + MYX_MAX_RETRIES, + MYX_MAX_LEVERAGE, + MYX_FEE_RATE, + MYX_PROTOCOL_FEE_RATE, + MYX_DEFAULT_SLIPPAGE_BPS, + MYX_MINIMUM_ORDER_SIZE_USD, + MYX_EXECUTION_FEE_TOKEN, +} from './constants/index.js'; +export { + PERPS_CONSTANTS, + WITHDRAWAL_CONSTANTS, + VALIDATION_THRESHOLDS, + ORDER_SLIPPAGE_CONFIG, + CHASE_ORDER_CONFIG, + CHASE_ORDER_STATUS, + MAX_SLIPPAGE_BOUNDS, + PERFORMANCE_CONFIG, + TP_SL_CONFIG, + HYPERLIQUID_ORDER_LIMITS, + HYPERLIQUID_TWAP_LIMITS, + CLOSE_POSITION_CONFIG, + MARGIN_ADJUSTMENT_CONFIG, + DATA_LAKE_API_CONFIG, + DECIMAL_PRECISION_CONFIG, + MARKET_SORTING_CONFIG, + PROVIDER_CONFIG, + FUNDING_RATE_CONFIG, +} from './constants/index.js'; +export type { SortOptionId } from './constants/index.js'; + +// Utilities (explicit named exports) +export { + findEvmAccount, + getEvmAccountFromAccountGroup, + getSelectedEvmAccount, + calculateWeightedReturnOnEquity, + aggregateAccountStates, +} from './utils/index.js'; +export type { ReturnOnEquityInput } from './utils/index.js'; +export { ensureError, isAbortError } from './utils/index.js'; +export type { + OrderBookCacheEntry, + ProcessL2BookDataParams, + ProcessBboDataParams, +} from './utils/index.js'; +export { processL2BookData, processBboData } from './utils/index.js'; +export type { ValidationDebugLogger } from './utils/index.js'; +export { + createErrorResult, + validateWithdrawalParams, + validateDepositParams, + validateAssetSupport, + validateBalance, + applyPathFilters, + getSupportedPaths, + getMaxOrderValue, + validateOrderParams, + validateCoinExists, +} from './utils/index.js'; +export { + TRIGGER_ORDER_TYPES, + STRATEGY_ORDER_TYPES, + SCALE_ORDER_COUNT, + isTriggerOrderType, + isStrategyOrderType, + isLimitExecutionOrderType, + getTriggerExecution, + getTriggerDirection, + buildTriggerOrderType, + buildPositionTriggerOrderFromOrder, + computeScalePriceLadder, + computeChaseQuotePrice, + getPriceTick, + splitScaleSizes, +} from './utils/index.js'; +export { + adaptTriggerOrderTypeFromSDK, + adaptPositionTriggerOrderFromSDK, + adaptTpslLinkageToGrouping, +} from './utils/index.js'; +export { + generatePerpsId, + generateDepositId, + generateWithdrawalId, + generateOrderId, + generateTransactionId, +} from './utils/index.js'; +export { + calculateOpenInterestUSD, + isMarketTradable, + transformMarketData, + formatChange, +} from './utils/index.js'; +export type { HyperLiquidMarketData } from './utils/index.js'; +export { + getPerpsConnectionAttemptContext, + withPerpsConnectionAttemptContext, +} from './utils/perpsConnectionAttemptContext.js'; +export type { PerpsConnectionAttemptContext } from './utils/perpsConnectionAttemptContext.js'; +export { + MAX_MARKET_PATTERN_LENGTH, + escapeRegex, + validateMarketPattern, + compileMarketPattern, + matchesMarketPattern, + shouldIncludeMarket, + getPerpsDisplaySymbol, + getPerpsDexFromSymbol, + calculateFundingCountdown, + calculate24hHighLow, + filterMarketsByQuery, + matchesCategory, + getMarketTypeFilter, + applyMarketFilters, + isHip3Market, + rankMarketsByQuery, + getMarketMatchRank, +} from './utils/index.js'; +export { MarketMatchRank } from './utils/index.js'; +export type { + MarketPatternMatcher, + CompiledMarketPattern, +} from './utils/index.js'; +export type { + OrderCalculationsDebugLogger, + CalculateFinalPositionSizeParams, + CalculateFinalPositionSizeResult, + CalculateOrderPriceAndSizeParams, + CalculateOrderPriceAndSizeResult, + BuildOrdersArrayParams, + BuildOrdersArrayResult, +} from './utils/index.js'; +export { + calculatePositionSize, + calculateMarginRequired, + getMaxAllowedAmount, + calculateFinalPositionSize, + calculateOrderPriceAndSize, + buildOrdersArray, +} from './utils/index.js'; +export { + formatAccountToCaipAccountId, + isCaipAccountId, + handleRewardsError, +} from './utils/index.js'; +export { + countSignificantFigures, + hasExceededSignificantFigures, + roundToSignificantFigures, +} from './utils/index.js'; +export type { SortMarketsParams } from './utils/index.js'; +export { parseVolume, sortMarkets } from './utils/index.js'; +export type { StandaloneInfoClientOptions } from './utils/index.js'; +export { + createStandaloneInfoClient, + queryStandaloneClearinghouseStates, + queryStandaloneOpenOrders, +} from './utils/index.js'; +export { stripQuotes, parseCommaSeparatedString } from './utils/index.js'; +export { generateERC20TransferData } from './utils/index.js'; +export { wait } from './utils/index.js'; +export { + adaptOrderToSDK, + adaptPositionFromSDK, + adaptOrderFromSDK, + adaptMarketFromSDK, + adaptAccountStateFromSDK, + buildAssetMapping, + formatHyperLiquidPrice, + formatHyperLiquidSize, + calculateHip3AssetId, + parseAssetName, + adaptHyperLiquidLedgerUpdateToUserHistoryItem, +} from './utils/index.js'; +export { + previewHyperLiquidIsolatedPositionModify, + resolveHyperLiquidMarginTiers, + buildMaintenanceSchedule, + estimateIsolatedLiquidationPrice, + estimateIsolatedLiquidationPriceAtTier, +} from './utils/index.js'; +export type { HyperLiquidMarginTier } from './utils/index.js'; +export { getEnvironment } from './utils/index.js'; +export type { FiatRangeConfig } from './utils/index.js'; +export { + PRICE_THRESHOLD, + formatWithSignificantDigits, + PRICE_RANGES_MINIMAL_VIEW, + PRICE_RANGES_UNIVERSAL, + formatPerpsFiat, + formatPositionSize, + formatPnl, + formatPercentage, + formatFundingRate, +} from './utils/index.js'; + +// Error codes (explicit named exports) +export { PERPS_ERROR_CODES } from './perpsErrorCodes.js'; +export type { PerpsErrorCode } from './perpsErrorCodes.js'; + +// Selectors (explicit named exports) +export { + selectIsFirstTimeUser, + selectHasPlacedFirstOrder, + selectWatchlistMarkets, + selectIsWatchlistMarket, + selectRecentlyViewedMarkets, + selectTradeConfiguration, + selectPendingTradeConfiguration, + selectMarketFilterPreferences, + selectOrderBookGrouping, + selectOrderBookPreferences, + selectProLayoutPreferences, + selectSelectedOrderType, + selectVisibleCandleCount, + selectPerpsMode, +} from './selectors.js'; + +// Services (only externally consumed items) +export { TradingReadinessCache } from './services/TradingReadinessCache.js'; +export type { ServiceContext } from './services/ServiceContext.js'; +export { + AggregatedOrderBookConnection, + processAggregatedOrderBook, +} from './services/AggregatedOrderBookConnection.js'; +export type { + OrderBookConnectionStatus, + SubscribeAggregatedOrderBookParams, + AggregatedOrderBookConnectionOptions, +} from './services/AggregatedOrderBookConnection.js'; + +// Removed with Live Market Prices component: +// - usePerpsPrices diff --git a/packages/perps-controller/src/perpsErrorCodes.ts b/packages/perps-controller/src/perpsErrorCodes.ts new file mode 100644 index 00000000000..a1361124d23 --- /dev/null +++ b/packages/perps-controller/src/perpsErrorCodes.ts @@ -0,0 +1,127 @@ +/** + * Error codes for PerpsController + * These codes are returned to the UI layer for translation + * Extracted to separate file to avoid circular dependencies with translatePerpsError + */ +export const PERPS_ERROR_CODES = { + CLIENT_NOT_INITIALIZED: 'CLIENT_NOT_INITIALIZED', + CLIENT_REINITIALIZING: 'CLIENT_REINITIALIZING', + PROVIDER_NOT_AVAILABLE: 'PROVIDER_NOT_AVAILABLE', + PROVIDER_NOT_FOUND: 'PROVIDER_NOT_FOUND', + PROVIDER_LIFECYCLE_STALE: 'PROVIDER_LIFECYCLE_STALE', + TOKEN_NOT_SUPPORTED: 'TOKEN_NOT_SUPPORTED', + BRIDGE_CONTRACT_NOT_FOUND: 'BRIDGE_CONTRACT_NOT_FOUND', + WITHDRAW_FAILED: 'WITHDRAW_FAILED', + POSITIONS_FAILED: 'POSITIONS_FAILED', + ACCOUNT_STATE_FAILED: 'ACCOUNT_STATE_FAILED', + MARKETS_FAILED: 'MARKETS_FAILED', + UNKNOWN_ERROR: 'UNKNOWN_ERROR', + // Provider-agnostic order errors + ORDER_LEVERAGE_REDUCTION_FAILED: 'ORDER_LEVERAGE_REDUCTION_FAILED', + // HyperLiquid-specific order errors + IOC_CANCEL: 'IOC_CANCEL', // Order could not immediately match (insufficient liquidity) + // Connection errors + CONNECTION_TIMEOUT: 'CONNECTION_TIMEOUT', + // Validation errors - withdraw + WITHDRAW_ASSET_ID_REQUIRED: 'WITHDRAW_ASSET_ID_REQUIRED', + WITHDRAW_AMOUNT_REQUIRED: 'WITHDRAW_AMOUNT_REQUIRED', + WITHDRAW_AMOUNT_POSITIVE: 'WITHDRAW_AMOUNT_POSITIVE', + WITHDRAW_INVALID_DESTINATION: 'WITHDRAW_INVALID_DESTINATION', + WITHDRAW_ASSET_NOT_SUPPORTED: 'WITHDRAW_ASSET_NOT_SUPPORTED', + WITHDRAW_INSUFFICIENT_BALANCE: 'WITHDRAW_INSUFFICIENT_BALANCE', + // Validation errors - deposit + DEPOSIT_ASSET_ID_REQUIRED: 'DEPOSIT_ASSET_ID_REQUIRED', + DEPOSIT_AMOUNT_REQUIRED: 'DEPOSIT_AMOUNT_REQUIRED', + DEPOSIT_AMOUNT_POSITIVE: 'DEPOSIT_AMOUNT_POSITIVE', + DEPOSIT_MINIMUM_AMOUNT: 'DEPOSIT_MINIMUM_AMOUNT', + // Validation errors - order + ORDER_COIN_REQUIRED: 'ORDER_COIN_REQUIRED', + ORDER_LIMIT_PRICE_REQUIRED: 'ORDER_LIMIT_PRICE_REQUIRED', + ORDER_PRICE_POSITIVE: 'ORDER_PRICE_POSITIVE', + ORDER_UNKNOWN_COIN: 'ORDER_UNKNOWN_COIN', + ORDER_SIZE_POSITIVE: 'ORDER_SIZE_POSITIVE', + ORDER_PRICE_REQUIRED: 'ORDER_PRICE_REQUIRED', + ORDER_SIZE_MIN: 'ORDER_SIZE_MIN', + ORDER_LEVERAGE_INVALID: 'ORDER_LEVERAGE_INVALID', + ORDER_LEVERAGE_BELOW_POSITION: 'ORDER_LEVERAGE_BELOW_POSITION', + ORDER_MAX_VALUE_EXCEEDED: 'ORDER_MAX_VALUE_EXCEEDED', + // Validation errors - trigger placement (stop / take profit) and partial TP/SL + ORDER_TRIGGER_PRICE_REQUIRED: 'ORDER_TRIGGER_PRICE_REQUIRED', // stop_*/take_profit_* placed without a trigger price + ORDER_TRIGGER_PRICE_POSITIVE: 'ORDER_TRIGGER_PRICE_POSITIVE', + ORDER_TRIGGER_PRICE_NOT_SUPPORTED: 'ORDER_TRIGGER_PRICE_NOT_SUPPORTED', // Trigger price supplied for a market/limit order + ORDER_TRIGGER_TPSL_UNSUPPORTED: 'ORDER_TRIGGER_TPSL_UNSUPPORTED', // Attached TP/SL on a trigger placement + ORDER_TPSL_SIZE_INVALID: 'ORDER_TPSL_SIZE_INVALID', // Partial TP/SL size non-positive, larger than the order, or missing its price + ORDER_EDIT_TRIGGER_UNSUPPORTED: 'ORDER_EDIT_TRIGGER_UNSUPPORTED', // editOrder cannot turn a resting order into a trigger placement + ORDER_TPSL_LINKAGE_CONFLICT: 'ORDER_TPSL_LINKAGE_CONFLICT', // tpslLinkage and the deprecated grouping disagree + ORDER_TPSL_POSITION_LINKAGE_UNSUPPORTED: + 'ORDER_TPSL_POSITION_LINKAGE_UNSUPPORTED', // Position-linked TP/SL requested on an order placement; use updatePositionTPSL + ORDER_TPSL_LINKAGE_REQUIRED: 'ORDER_TPSL_LINKAGE_REQUIRED', // Attached TP/SL requested with no linkage to parent or position + ORDER_EDIT_ORDER_UNVERIFIABLE: 'ORDER_EDIT_ORDER_UNVERIFIABLE', // editOrder cannot confirm the resting order's placement type + ORDER_TIME_IN_FORCE_NOT_SUPPORTED: 'ORDER_TIME_IN_FORCE_NOT_SUPPORTED', // Time in force supplied for an order shape that cannot carry one + // Validation errors - strategy placement (twap / scale / chase) + ORDER_STRATEGY_PARAMS_NOT_SUPPORTED: 'ORDER_STRATEGY_PARAMS_NOT_SUPPORTED', // Strategy field supplied on an order type that does not own it + ORDER_STRATEGY_FIELD_UNSUPPORTED: 'ORDER_STRATEGY_FIELD_UNSUPPORTED', // price / triggerPrice / timeInForce / attached TP/SL supplied on a strategy placement + ORDER_STRATEGY_MARKET_UNSUPPORTED: 'ORDER_STRATEGY_MARKET_UNSUPPORTED', // The provider cannot run a strategy placement on this market + ORDER_STRATEGY_ROUTE_UNAVAILABLE: 'ORDER_STRATEGY_ROUTE_UNAVAILABLE', // The requested strategy provider route does not match the active direct provider + ORDER_STRATEGY_HANDLE_UNKNOWN: 'ORDER_STRATEGY_HANDLE_UNKNOWN', // Strategy cancel handle is invalid or this provider session does not hold it + ORDER_EDIT_STRATEGY_UNSUPPORTED: 'ORDER_EDIT_STRATEGY_UNSUPPORTED', // editOrder cannot modify a strategy placement; cancel by its handle and place again + ORDER_STRATEGY_CANCEL_INCOMPLETE: 'ORDER_STRATEGY_CANCEL_INCOMPLETE', // Part of a strategy placement is still resting after a cancel; the handle stays valid for a retry + ORDER_TWAP_DURATION_REQUIRED: 'ORDER_TWAP_DURATION_REQUIRED', // TWAP placed without twapDuration + ORDER_TWAP_DURATION_INVALID: 'ORDER_TWAP_DURATION_INVALID', // twapDuration not a whole number of minutes within the venue's bounds + ORDER_SCALE_RANGE_REQUIRED: 'ORDER_SCALE_RANGE_REQUIRED', // Scale placed without both ladder bounds + ORDER_SCALE_RANGE_INVALID: 'ORDER_SCALE_RANGE_INVALID', // Scale ladder bounds or skew are invalid + ORDER_SCALE_COUNT_INVALID: 'ORDER_SCALE_COUNT_INVALID', // scaleNumOrders missing, non-integer, or outside the supported ladder size + ORDER_SCALE_SIZE_TOO_SMALL: 'ORDER_SCALE_SIZE_TOO_SMALL', // Total size cannot give every ladder rung a non-zero slice + ORDER_SCALE_NOTIONAL_TOO_SMALL: 'ORDER_SCALE_NOTIONAL_TOO_SMALL', // Ladder notional split across the rungs leaves each below the venue's per-order minimum + ORDER_TWAP_NOTIONAL_TOO_SMALL: 'ORDER_TWAP_NOTIONAL_TOO_SMALL', // TWAP total below the venue's documented minimum TWAP order size + ORDER_CHASE_INTERVAL_INVALID: 'ORDER_CHASE_INTERVAL_INVALID', // chaseIntervalMs below the minimum poll interval + ORDER_CHASE_DURATION_INVALID: 'ORDER_CHASE_DURATION_INVALID', // chaseMaxDurationMs shorter than one poll interval, or chaseMaxRepricings non-positive + ORDER_CHASE_MAX_DISTANCE_INVALID: 'ORDER_CHASE_MAX_DISTANCE_INVALID', // chaseMaxDistanceBps must be finite, greater than 0, and less than 10,000 when supplied + ORDER_CHASE_ABANDONED: 'ORDER_CHASE_ABANDONED', // The provider was torn down while the chase was being placed; its order rests but no strategy runs + ORDER_CHASE_LIMIT_REACHED: 'ORDER_CHASE_LIMIT_REACHED', // The venue's cap on simultaneously active chase orders is already in use + ORDER_CHASE_TOUCH_UNAVAILABLE: 'ORDER_CHASE_TOUCH_UNAVAILABLE', // The order book returned no price on the side the chase must rest at + // HyperLiquid client/service errors + EXCHANGE_CLIENT_NOT_AVAILABLE: 'EXCHANGE_CLIENT_NOT_AVAILABLE', + INFO_CLIENT_NOT_AVAILABLE: 'INFO_CLIENT_NOT_AVAILABLE', + SUBSCRIPTION_CLIENT_NOT_AVAILABLE: 'SUBSCRIPTION_CLIENT_NOT_AVAILABLE', + // Wallet/account errors + NO_ACCOUNT_SELECTED: 'NO_ACCOUNT_SELECTED', + KEYRING_LOCKED: 'KEYRING_LOCKED', + INVALID_ADDRESS_FORMAT: 'INVALID_ADDRESS_FORMAT', + // Wallet has no account on the exchange yet (HyperLiquid creates accounts + // server-side on the first USDC credit). Actionable: the user must fund the + // account before any order can be placed. + EXCHANGE_ACCOUNT_NOT_FOUND: 'EXCHANGE_ACCOUNT_NOT_FOUND', + // HyperLiquid exchange rejects agent-signed writes for multi-sig accounts + // without a multi-sig wrapper, or when the action nonce is stale/reused. + EXCHANGE_MULTI_SIG_REQUIRED: 'EXCHANGE_MULTI_SIG_REQUIRED', + EXCHANGE_INVALID_NONCE: 'EXCHANGE_INVALID_NONCE', + // Transfer/swap errors + TRANSFER_FAILED: 'TRANSFER_FAILED', + SWAP_FAILED: 'SWAP_FAILED', + SPOT_PAIR_NOT_FOUND: 'SPOT_PAIR_NOT_FOUND', + PRICE_UNAVAILABLE: 'PRICE_UNAVAILABLE', + // Market/collateral errors + UNSUPPORTED_COLLATERAL: 'UNSUPPORTED_COLLATERAL', // DEX collateral token is not USDC (TAT-3304) + // Batch operation errors + BATCH_CANCEL_FAILED: 'BATCH_CANCEL_FAILED', + BATCH_CLOSE_FAILED: 'BATCH_CLOSE_FAILED', + // Position/margin errors + INSUFFICIENT_MARGIN: 'INSUFFICIENT_MARGIN', + INSUFFICIENT_BALANCE: 'INSUFFICIENT_BALANCE', + REDUCE_ONLY_VIOLATION: 'REDUCE_ONLY_VIOLATION', + POSITION_WOULD_FLIP: 'POSITION_WOULD_FLIP', + MARGIN_ADJUSTMENT_FAILED: 'MARGIN_ADJUSTMENT_FAILED', + TPSL_UPDATE_FAILED: 'TPSL_UPDATE_FAILED', + TPSL_PROTECTION_LOST: 'TPSL_PROTECTION_LOST', + // Order execution errors + ORDER_REJECTED: 'ORDER_REJECTED', + SLIPPAGE_EXCEEDED: 'SLIPPAGE_EXCEEDED', + RATE_LIMIT_EXCEEDED: 'RATE_LIMIT_EXCEEDED', + // Network/service errors + SERVICE_UNAVAILABLE: 'SERVICE_UNAVAILABLE', + NETWORK_ERROR: 'NETWORK_ERROR', +} as const; + +export type PerpsErrorCode = + (typeof PERPS_ERROR_CODES)[keyof typeof PERPS_ERROR_CODES]; diff --git a/packages/perps-controller/src/providers/AggregatedPerpsProvider.ts b/packages/perps-controller/src/providers/AggregatedPerpsProvider.ts new file mode 100644 index 00000000000..cbe06c13fcc --- /dev/null +++ b/packages/perps-controller/src/providers/AggregatedPerpsProvider.ts @@ -0,0 +1,1014 @@ +/** + * AggregatedPerpsProvider - Multi-provider aggregation wrapper + * + * Implements PerpsProvider interface to enable seamless multi-provider support. + * Aggregates read operations from all providers, routes write operations to specific + * providers based on params.providerId or default provider. + * + * Phase 1 Implementation: + * - Read operations: Aggregate from all providers using Promise.allSettled() + * - Write operations: Route to params.providerId ?? defaultProvider + * - Subscriptions: Multiplex via SubscriptionMultiplexer + * - Lifecycle: Delegate to default provider + * + * All returned data includes providerId field for UI differentiation. + */ + +import type { CaipAccountId } from '@metamask/utils'; + +import { SubscriptionMultiplexer } from '../aggregation/SubscriptionMultiplexer.js'; +import { PERPS_ERROR_CODES } from '../perpsErrorCodes.js'; +import { ProviderRouter } from '../routing/ProviderRouter.js'; +import { WebSocketConnectionState } from '../types/index.js'; +import type { + AccountState, + AggregatedProviderConfig, + AggregationMode, + AssetRoute, + BatchCancelOrdersParams, + CancelOrderParams, + CancelOrderResult, + CancelOrdersResult, + ChaseOrder, + ClosePositionParams, + ClosePositionsParams, + ClosePositionsResult, + DepositParams, + DisconnectResult, + EditOrderParams, + FeeCalculationParams, + FeeCalculationResult, + Funding, + GetAccountStateParams, + GetAvailableDexsParams, + GetFundingParams, + GetHistoricalPortfolioParams, + GetMarketsParams, + GetOrderCapabilitiesParams, + GetOrderFillsParams, + GetOrdersParams, + GetOrFetchFillsParams, + GetPositionsParams, + GetSupportedPathsParams, + HistoricalPortfolioResult, + InitializeResult, + PerpsPlatformDependencies, + PerpsProvider, + LiquidationPriceParams, + LiveDataConfig, + MaintenanceMarginParams, + PositionModifyPreviewParams, + PositionModifyPreviewResult, + MarginResult, + MarketInfo, + Order, + OrderFill, + OrderParams, + OrderResult, + PerpsMarketData, + PerpsOrderCapabilities, + PerpsProviderType, + Position, + ReadyToTradeResult, + SubscribeAccountParams, + SubscribeCandlesParams, + SubscribeOICapsParams, + SubscribeOrderBookParams, + SubscribeOrderFillsParams, + SubscribeOrdersParams, + SubscribePositionsParams, + SubscribePricesParams, + ToggleTestnetResult, + TwapOrder, + UpdateMarginParams, + UpdatePositionTPSLParams, + UserHistoryItem, + WithdrawParams, + WithdrawResult, + RawLedgerUpdate, + PerpsReadOptions, + PerpsFeeResolution, +} from '../types/index.js'; + +/** Error returned when only some providers suspend their Chase orders. */ +export class ChaseOrderSuspensionError extends Error { + readonly suspendedOrders: ChaseOrder[]; + + readonly failures: { providerId: PerpsProviderType; reason: unknown }[]; + + constructor(options: { + suspendedOrders: ChaseOrder[]; + failures: { providerId: PerpsProviderType; reason: unknown }[]; + }) { + const failedProviderIds = options.failures + .map(({ providerId }) => providerId) + .join(', '); + super(`Failed to suspend Chase orders for: ${failedProviderIds}`); + this.name = 'ChaseOrderSuspensionError'; + this.suspendedOrders = options.suspendedOrders; + this.failures = options.failures; + } +} + +/** + * AggregatedPerpsProvider implements PerpsProvider by coordinating + * multiple backend providers. + * + * Design principles: + * 1. Read operations aggregate from all providers (parallel) + * 2. Write operations route to specific provider (explicit > default) + * 3. Lifecycle operations delegate to default provider + * 4. All returned data includes providerId for UI differentiation + * + * @example + * ```typescript + * const aggregated = new AggregatedPerpsProvider({ + * providers: new Map([ + * ['hyperliquid', hlProvider], + * ['myx', myxProvider], + * ]), + * defaultProvider: 'hyperliquid', + * infrastructure: deps, + * }); + * + * // Read: returns positions from all providers + * const positions = await aggregated.getPositions(); + * + * // Write: routes to specific or default provider + * await aggregated.placeOrder({ symbol: 'BTC', providerId: 'myx', ... }); + * ``` + */ +export class AggregatedPerpsProvider implements PerpsProvider { + readonly protocolId = 'aggregated'; + + readonly routesOrdersByProviderId = true; + + readonly #providers: Map; + + readonly #defaultProvider: PerpsProviderType; + + readonly #aggregationMode: AggregationMode; + + readonly #deps: PerpsPlatformDependencies; + + readonly #router: ProviderRouter; + + readonly #subscriptionMux: SubscriptionMultiplexer; + + constructor(config: AggregatedProviderConfig) { + this.#providers = config.providers; + this.#defaultProvider = config.defaultProvider; + this.#aggregationMode = config.aggregationMode ?? 'all'; + this.#deps = config.infrastructure; + + // Initialize router with default provider + this.#router = new ProviderRouter({ + defaultProvider: this.#defaultProvider, + }); + + // Initialize subscription multiplexer with logger for error reporting + this.#subscriptionMux = new SubscriptionMultiplexer({ + logger: this.#deps.logger, + }); + + this.#deps.debugLogger.log('[AggregatedPerpsProvider] Initialized', { + providers: Array.from(this.#providers.keys()), + defaultProvider: this.#defaultProvider, + aggregationMode: this.#aggregationMode, + }); + } + + // ============================================================================ + // Helper Methods + // ============================================================================ + + /** + * Get list of active providers as tuples for iteration. + * Returns array of [providerId, provider] pairs. + * + * @returns The result of the operation. + */ + #getActiveProviders(): [PerpsProviderType, PerpsProvider][] { + return Array.from(this.#providers.entries()); + } + + /** + * Get the default provider instance. + * Throws if default provider is not available. + * + * @returns The result of the operation. + */ + #getDefaultProvider(): PerpsProvider { + const provider = this.#providers.get(this.#defaultProvider); + if (!provider) { + throw new Error( + `[AggregatedPerpsProvider] Default provider '${this.#defaultProvider}' not available`, + ); + } + return provider; + } + + /** + * Get the explicit provider, or the default when no route was supplied. + * + * @param providerId - The provider id value. + * @returns The selected provider id and instance. + * @throws If an explicit provider is not registered. + */ + #getProviderOrDefault( + providerId?: PerpsProviderType, + ): [PerpsProviderType, PerpsProvider] { + if (providerId === undefined) { + return [this.#defaultProvider, this.#getDefaultProvider()]; + } + + const provider = this.#providers.get(providerId); + if (!provider) { + throw new Error(PERPS_ERROR_CODES.PROVIDER_NOT_FOUND); + } + return [providerId, provider]; + } + + /** + * Extract successful results from Promise.allSettled. + * Logs errors for failed promises. + * + * @param results - Results from Promise.allSettled + * @param context - Context string for logging + * @returns Array of successful values + */ + #extractSuccessfulResults( + results: PromiseSettledResult[], + context: string, + ): TResult[] { + const successful: TResult[] = []; + results.forEach((result, index) => { + if (result.status === 'fulfilled') { + successful.push(result.value); + } else { + this.#deps.debugLogger.log( + `[AggregatedPerpsProvider] ${context} failed for provider ${index}`, + { error: result.reason }, + ); + } + }); + return successful; + } + + // ============================================================================ + // Asset Routes (Synchronous - delegate to default provider) + // ============================================================================ + + getDepositRoutes(params?: GetSupportedPathsParams): AssetRoute[] { + return this.#getDefaultProvider().getDepositRoutes(params); + } + + getWithdrawalRoutes(params?: GetSupportedPathsParams): AssetRoute[] { + return this.#getDefaultProvider().getWithdrawalRoutes(params); + } + + /** + * Resolve capabilities with the same explicit-provider/default-provider + * selection used by order placement. Unknown routes return a typed + * unavailable result instead of throwing a placement error. + * + * @param params - Market and optional provider route. + * @returns Capabilities from the selected provider. + */ + async getOrderCapabilities( + params: GetOrderCapabilitiesParams, + ): Promise { + const providerId = params.providerId ?? this.#defaultProvider; + const provider = this.#providers.get(providerId); + if (!provider) { + return { + status: 'unavailable', + providerId, + reason: 'provider_not_found', + }; + } + if (!provider.getOrderCapabilities) { + return { status: 'unavailable', providerId, reason: 'not_implemented' }; + } + try { + const capabilities = await provider.getOrderCapabilities({ + ...params, + providerId, + }); + if ( + capabilities.providerId !== undefined && + capabilities.providerId !== providerId + ) { + return { + status: 'unavailable', + providerId, + reason: 'provider_not_routable', + }; + } + return { ...capabilities, providerId }; + } catch (error) { + this.#deps.debugLogger.log( + '[AggregatedPerpsProvider] Order capabilities unavailable', + { + providerId, + error: error instanceof Error ? error.message : String(error), + }, + ); + return { + status: 'unavailable', + providerId, + reason: 'provider_unavailable', + }; + } + } + + // ============================================================================ + // Read Operations (Aggregate from all providers) + // ============================================================================ + + async getPositions(params?: GetPositionsParams): Promise { + const results = await Promise.allSettled( + this.#getActiveProviders().map(async ([id, provider]) => { + const positions = await provider.getPositions(params); + return positions.map((pos) => ({ ...pos, providerId: id })); + }), + ); + + return this.#extractSuccessfulResults(results, 'getPositions').flat(); + } + + async getAccountState(params?: GetAccountStateParams): Promise { + // Return account state from default provider with providerId injected + const provider = this.#getDefaultProvider(); + const state = await provider.getAccountState(params); + return { ...state, providerId: this.#defaultProvider }; + } + + async getMarkets(params?: GetMarketsParams): Promise { + const results = await Promise.allSettled( + this.#getActiveProviders().map(async ([id, provider]) => { + const markets = await provider.getMarkets(params); + return markets.map((market) => ({ ...market, providerId: id })); + }), + ); + + const allMarkets = this.#extractSuccessfulResults( + results, + 'getMarkets', + ).flat(); + + // Deduplicate markets by name (keep first occurrence) + const seen = new Set(); + return allMarkets.filter((market) => { + // Use providerId:name as unique key to allow same market from different providers + const key = `${market.providerId}:${market.name}`; + if (seen.has(key)) { + return false; + } + seen.add(key); + return true; + }); + } + + async getMarketDataWithPrices(): Promise { + const results = await Promise.allSettled( + this.#getActiveProviders().map(async ([id, provider]) => { + const data = await provider.getMarketDataWithPrices(); + return data.map((item) => ({ ...item, providerId: id })); + }), + ); + + return this.#extractSuccessfulResults( + results, + 'getMarketDataWithPrices', + ).flat(); + } + + async getOrderFills( + params?: GetOrderFillsParams, + options?: PerpsReadOptions, + ): Promise { + const results = await Promise.allSettled( + this.#getActiveProviders().map(async ([id, provider]) => { + const fills = await provider.getOrderFills(params, options); + return fills.map((fill) => ({ ...fill, providerId: id })); + }), + ); + + return this.#extractSuccessfulResults(results, 'getOrderFills').flat(); + } + + async getOrFetchFills(params?: GetOrFetchFillsParams): Promise { + const results = await Promise.allSettled( + this.#getActiveProviders().map(async ([id, provider]) => { + const fills = await provider.getOrFetchFills(params); + return fills.map((fill) => ({ ...fill, providerId: id })); + }), + ); + + return this.#extractSuccessfulResults(results, 'getOrFetchFills').flat(); + } + + async getOrders( + params?: GetOrdersParams, + options?: PerpsReadOptions, + ): Promise { + const results = await Promise.allSettled( + this.#getActiveProviders().map(async ([id, provider]) => { + const orders = await provider.getOrders(params, options); + return orders.map((order) => ({ ...order, providerId: id })); + }), + ); + + return this.#extractSuccessfulResults(results, 'getOrders').flat(); + } + + async getOpenOrders(params?: GetOrdersParams): Promise { + const results = await Promise.allSettled( + this.#getActiveProviders().map(async ([id, provider]) => { + const orders = await provider.getOpenOrders(params); + return orders.map((order) => ({ ...order, providerId: id })); + }), + ); + + return this.#extractSuccessfulResults(results, 'getOpenOrders').flat(); + } + + async getFunding( + params?: GetFundingParams, + options?: PerpsReadOptions, + ): Promise { + const results = await Promise.allSettled( + this.#getActiveProviders().map(async ([_providerId, provider]) => { + const funding = await provider.getFunding(params, options); + // Funding type doesn't have providerId - we could add it if needed + return funding; + }), + ); + + return this.#extractSuccessfulResults(results, 'getFunding').flat(); + } + + async getHistoricalPortfolio( + params?: GetHistoricalPortfolioParams, + ): Promise { + // Delegate to default provider + return this.#getDefaultProvider().getHistoricalPortfolio(params); + } + + /** + * Get user non-funding ledger updates from default provider. + * + * @param params - Optional parameters + * @param params.accountId - Account ID to filter by + * @param params.startTime - Start time filter + * @param params.endTime - End time filter + * @returns Raw ledger updates + */ + async getUserNonFundingLedgerUpdates(params?: { + accountId?: string; + startTime?: number; + endTime?: number; + }): Promise { + // Delegate to default provider (protocol-specific) + return this.#getDefaultProvider().getUserNonFundingLedgerUpdates(params); + } + + /** + * Resolve the currently selected CAIP account identifier. Accounts are + * shared across sub-providers (same InternalAccountController), so the + * default provider's view is authoritative. + * + * @returns Resolved CAIP account id from the default sub-provider. + */ + async getCurrentAccountId(): Promise { + return this.#getDefaultProvider().getCurrentAccountId(); + } + + /** + * Get user history from all providers. + * + * @param params - Optional parameters + * @param params.accountId - Account ID to filter by + * @param params.startTime - Start time filter + * @param params.endTime - End time filter + * @returns Aggregated user history with providerId + */ + async getUserHistory(params?: { + accountId?: CaipAccountId; + startTime?: number; + endTime?: number; + }): Promise { + const results = await Promise.allSettled( + this.#getActiveProviders().map(async ([id, provider]) => { + const history = await provider.getUserHistory(params); + return history.map((item) => ({ ...item, providerId: id })); + }), + ); + + return this.#extractSuccessfulResults(results, 'getUserHistory').flat(); + } + + // ============================================================================ + // Write Operations (Route to specific provider) + // ============================================================================ + + async placeOrder(params: OrderParams): Promise { + const [providerId, provider] = this.#getProviderOrDefault( + params.providerId, + ); + + this.#deps.debugLogger.log('[AggregatedPerpsProvider] placeOrder routing', { + requestedProvider: params.providerId, + actualProvider: providerId, + symbol: params.symbol, + }); + + const result = await provider.placeOrder(params); + return { ...result, providerId }; + } + + /** + * Read TWAP lifecycle records from every active provider. + * + * @returns TWAP records with their provider route attached. + */ + async getTwapOrders(): Promise { + const results = await Promise.allSettled( + this.#getActiveProviders().map(async ([providerId, provider]) => + provider.getTwapOrders + ? (await provider.getTwapOrders()).map((order) => ({ + ...order, + providerId, + })) + : [], + ), + ); + + return this.#extractSuccessfulResults(results, 'getTwapOrders').flat(); + } + + /** + * Read all available snapshots, retaining successful providers on a partial failure. + * + * @returns Chase snapshots from every provider that responded successfully. + */ + async getChaseOrders(): Promise { + const results = await Promise.allSettled( + this.#getActiveProviders().map(async ([providerId, provider]) => + provider.getChaseOrders + ? (await provider.getChaseOrders()).map((order) => ({ + ...order, + providerId, + })) + : [], + ), + ); + + return this.#extractSuccessfulResults(results, 'getChaseOrders').flat(); + } + + /** + * Attempt to suspend every provider and reject after all attempts settle if + * any provider could not suspend safely. Successful providers remain + * suspended, so callers may retry to reconcile a partial failure. + * + * @returns Chase snapshots after every provider suspends successfully. + * @throws ChaseOrderSuspensionError if any active provider fails. The error + * includes successful snapshots and each failed provider ID. + */ + async suspendChaseOrders(): Promise { + const providers = this.#getActiveProviders(); + const results = await Promise.allSettled( + providers.map(async ([providerId, provider]) => + provider.suspendChaseOrders + ? (await provider.suspendChaseOrders()).map((order) => ({ + ...order, + providerId, + })) + : [], + ), + ); + + const snapshots: ChaseOrder[] = []; + const failures: { + providerId: PerpsProviderType; + reason: unknown; + }[] = []; + results.forEach((result, index) => { + if (result.status === 'fulfilled') { + snapshots.push(...result.value); + } else { + failures.push({ + providerId: providers[index][0], + reason: result.reason, + }); + } + }); + if (failures.length > 0) { + throw new ChaseOrderSuspensionError({ + suspendedOrders: snapshots, + failures, + }); + } + return snapshots; + } + + async editOrder(params: EditOrderParams): Promise { + // EditOrderParams contains OrderParams in newOrder which may have providerId + const [providerId, provider] = this.#getProviderOrDefault( + params.newOrder.providerId, + ); + const result = await provider.editOrder(params); + return { ...result, providerId }; + } + + async cancelOrder(params: CancelOrderParams): Promise { + const [providerId, provider] = this.#getProviderOrDefault( + params.providerId, + ); + const result = await provider.cancelOrder(params); + return { ...result, providerId }; + } + + async cancelOrders( + params: BatchCancelOrdersParams, + ): Promise { + // Batch cancel delegates to default provider + const provider = this.#getDefaultProvider(); + if (!provider.cancelOrders) { + return { + success: false, + successCount: 0, + failureCount: params.length, + results: params.map((param) => ({ + orderId: param.orderId, + symbol: param.symbol, + success: false, + error: 'Batch cancel not supported', + })), + }; + } + return provider.cancelOrders(params); + } + + async closePosition(params: ClosePositionParams): Promise { + const [providerId, provider] = this.#getProviderOrDefault( + params.providerId, + ); + const result = await provider.closePosition(params); + return { ...result, providerId }; + } + + async closePositions( + params: ClosePositionsParams, + ): Promise { + // Batch close delegates to default provider + const provider = this.#getDefaultProvider(); + if (!provider.closePositions) { + return { + success: false, + successCount: 0, + failureCount: 0, + results: [], + }; + } + return provider.closePositions(params); + } + + async updatePositionTPSL( + params: UpdatePositionTPSLParams, + ): Promise { + const [providerId, provider] = this.#getProviderOrDefault( + params.providerId, + ); + const result = await provider.updatePositionTPSL(params); + return { ...result, providerId }; + } + + async updateMargin(params: UpdateMarginParams): Promise { + const [, provider] = this.#getProviderOrDefault(params.providerId); + return provider.updateMargin(params); + } + + async withdraw(params: WithdrawParams): Promise { + const [, provider] = this.#getProviderOrDefault(params.providerId); + return provider.withdraw(params); + } + + // ============================================================================ + // Validation (Route to specific provider) + // ============================================================================ + + async validateDeposit( + params: DepositParams, + ): Promise<{ isValid: boolean; error?: string }> { + return this.#getDefaultProvider().validateDeposit(params); + } + + async validateOrder( + params: OrderParams, + ): Promise<{ isValid: boolean; error?: string }> { + const [, provider] = this.#getProviderOrDefault(params.providerId); + return provider.validateOrder(params); + } + + async validateClosePosition( + params: ClosePositionParams, + ): Promise<{ isValid: boolean; error?: string }> { + const [, provider] = this.#getProviderOrDefault(params.providerId); + return provider.validateClosePosition(params); + } + + async validateWithdrawal( + params: WithdrawParams, + ): Promise<{ isValid: boolean; error?: string }> { + const [, provider] = this.#getProviderOrDefault(params.providerId); + return provider.validateWithdrawal(params); + } + + // ============================================================================ + // Protocol Calculations (Delegate to default or route) + // ============================================================================ + + async calculateLiquidationPrice( + params: LiquidationPriceParams, + ): Promise { + return this.#getDefaultProvider().calculateLiquidationPrice(params); + } + + async calculateMaintenanceMargin( + params: MaintenanceMarginParams, + ): Promise { + return this.#getDefaultProvider().calculateMaintenanceMargin(params); + } + + async getMaxLeverage(asset: string): Promise { + return this.#getDefaultProvider().getMaxLeverage(asset); + } + + async calculateFees( + params: FeeCalculationParams, + ): Promise { + const [, provider] = this.#getProviderOrDefault(params.providerId); + return provider.calculateFees(params); + } + + async previewPositionModify( + params: PositionModifyPreviewParams, + ): Promise { + const [, provider] = this.#getProviderOrDefault( + params.providerId ?? params.position.providerId, + ); + return provider.previewPositionModify(params); + } + + // ============================================================================ + // Subscriptions (Multiplex via SubscriptionMultiplexer) + // ============================================================================ + + subscribeToPrices(params: SubscribePricesParams): () => void { + return this.#subscriptionMux.subscribeToPrices({ + ...params, + providers: this.#getActiveProviders(), + aggregationMode: 'merge', + }); + } + + subscribeToPositions(params: SubscribePositionsParams): () => void { + return this.#subscriptionMux.subscribeToPositions({ + ...params, + providers: this.#getActiveProviders(), + }); + } + + subscribeToOrderFills(params: SubscribeOrderFillsParams): () => void { + return this.#subscriptionMux.subscribeToOrderFills({ + ...params, + providers: this.#getActiveProviders(), + }); + } + + subscribeToOrders(params: SubscribeOrdersParams): () => void { + return this.#subscriptionMux.subscribeToOrders({ + ...params, + providers: this.#getActiveProviders(), + }); + } + + subscribeToAccount(params: SubscribeAccountParams): () => void { + // For account subscriptions, we emit as array for multi-provider + // but the callback expects single AccountState + // Delegate to default provider for now + return this.#getDefaultProvider().subscribeToAccount(params); + } + + subscribeToOICaps(params: SubscribeOICapsParams): () => void { + // Delegate to default provider + return this.#getDefaultProvider().subscribeToOICaps(params); + } + + subscribeToCandles(params: SubscribeCandlesParams): () => void { + // Delegate to default provider + return this.#getDefaultProvider().subscribeToCandles(params); + } + + subscribeToOrderBook(params: SubscribeOrderBookParams): () => void { + // Delegate to default provider + return this.#getDefaultProvider().subscribeToOrderBook(params); + } + + // ============================================================================ + // Configuration + // ============================================================================ + + setLiveDataConfig(config: Partial): void { + // Apply config to all providers + this.#providers.forEach((provider) => { + provider.setLiveDataConfig(config); + }); + } + + setUserFeeDiscount(discountBips: number | undefined): void { + // Apply to all providers that support it + this.#providers.forEach((provider) => { + if (provider.setUserFeeDiscount) { + provider.setUserFeeDiscount(discountBips); + } + }); + } + + setUserFeeResolution(resolution: PerpsFeeResolution | undefined): void { + this.#providers.forEach((provider) => { + if (provider.setUserFeeResolution) { + provider.setUserFeeResolution(resolution); + } else if (provider.setUserFeeDiscount) { + provider.setUserFeeDiscount(resolution?.discountBips); + } + }); + } + + async approveSubscriptionBuilderFee(): Promise { + const provider = + this.#providers.get('hyperliquid') ?? this.#getDefaultProvider(); + return provider.approveSubscriptionBuilderFee + ? provider.approveSubscriptionBuilderFee() + : false; + } + + // ============================================================================ + // Lifecycle (Delegate to default provider) + // ============================================================================ + + async toggleTestnet(): Promise { + return this.#getDefaultProvider().toggleTestnet(); + } + + async initialize(): Promise { + // Initialize default provider + const result = await this.#getDefaultProvider().initialize(); + + // Optionally initialize other providers in background + // For Phase 1, we only initialize default provider + return result; + } + + async isReadyToTrade(): Promise { + return this.#getDefaultProvider().isReadyToTrade(); + } + + async disconnect(): Promise { + // Disconnect all providers + const results = await Promise.allSettled( + this.#getActiveProviders().map(([, provider]) => provider.disconnect()), + ); + + // Clear subscription cache + this.#subscriptionMux.clearCache(); + + // Return success if at least one succeeded + const successCount = results.filter( + (res) => res.status === 'fulfilled' && res.value.success, + ).length; + + return { + success: successCount > 0, + }; + } + + async ping(timeoutMs?: number): Promise { + return this.#getDefaultProvider().ping(timeoutMs); + } + + getWebSocketConnectionState(): WebSocketConnectionState { + const provider = this.#getDefaultProvider(); + if (provider.getWebSocketConnectionState) { + return provider.getWebSocketConnectionState(); + } + return WebSocketConnectionState.Disconnected; + } + + subscribeToConnectionState( + listener: ( + state: WebSocketConnectionState, + reconnectionAttempt: number, + ) => void, + ): () => void { + const provider = this.#getDefaultProvider(); + if (provider.subscribeToConnectionState) { + return provider.subscribeToConnectionState(listener); + } + listener(WebSocketConnectionState.Disconnected, 0); + return () => { + /* noop */ + }; + } + + async reconnect(): Promise { + const provider = this.#getDefaultProvider(); + if (provider.reconnect) { + await provider.reconnect(); + } + } + + // ============================================================================ + // Block Explorer + // ============================================================================ + + getBlockExplorerUrl(address?: string): string { + return this.#getDefaultProvider().getBlockExplorerUrl(address); + } + + // ============================================================================ + // HIP-3 (Optional) + // ============================================================================ + + async getAvailableDexs(params?: GetAvailableDexsParams): Promise { + const provider = this.#getDefaultProvider(); + if (!provider.getAvailableDexs) { + return []; + } + return provider.getAvailableDexs(params); + } + + // ============================================================================ + // Provider Management + // ============================================================================ + + /** + * Add a new provider to the aggregated provider. + * + * @param providerId - Unique identifier for the provider + * @param provider - Provider instance + */ + addProvider(providerId: PerpsProviderType, provider: PerpsProvider): void { + this.#providers.set(providerId, provider); + this.#deps.debugLogger.log('[AggregatedPerpsProvider] Provider added', { + providerId, + }); + } + + /** + * Remove a provider from the aggregated provider. + * + * @param providerId - Provider to remove + * @returns true if removed, false if not found + */ + removeProvider(providerId: PerpsProviderType): boolean { + const removed = this.#providers.delete(providerId); + if (removed) { + this.#deps.debugLogger.log('[AggregatedPerpsProvider] Provider removed', { + providerId, + }); + } + return removed; + } + + /** + * Get list of all registered provider IDs. + * + * @returns The result of the operation. + */ + getProviderIds(): PerpsProviderType[] { + return Array.from(this.#providers.keys()); + } + + /** + * Check if a provider is registered. + * + * @param providerId - The provider id value. + * @returns True if the condition is met. + */ + hasProvider(providerId: PerpsProviderType): boolean { + return this.#providers.has(providerId); + } + + /** + * Get the router instance for external configuration. + * + * @returns The result of the operation. + */ + getRouter(): ProviderRouter { + return this.#router; + } +} diff --git a/packages/perps-controller/src/providers/HyperLiquidProvider.ts b/packages/perps-controller/src/providers/HyperLiquidProvider.ts new file mode 100644 index 00000000000..82eb8d0cdac --- /dev/null +++ b/packages/perps-controller/src/providers/HyperLiquidProvider.ts @@ -0,0 +1,14473 @@ +import { CaipAccountId, hasProperty, isHexString } from '@metamask/utils'; +import type { Hex } from '@metamask/utils'; +import type { + ExchangeClient, + InfoClient, + UserAbstractionResponse, +} from '@nktkas/hyperliquid'; +import { BigNumber } from 'bignumber.js'; +import { v4 as uuidv4 } from 'uuid'; + +import type { CandlePeriod } from '../constants/chartConfig.js'; +import { + PERPS_EVENT_PROPERTY, + PERPS_EVENT_VALUE, +} from '../constants/eventNames.js'; +import { + BASIS_POINTS_DIVISOR, + BUILDER_FEE_CONFIG, + canonicalizeHyperLiquidDexes, + FEE_RATES, + HYPERLIQUID_ORDER_CAPABILITIES, + getBridgeInfo, + getChainId, + HIP3_ASSET_MARKET_TYPES, + HIP3_FEE_CONFIG, + HIP3_MARGIN_CONFIG, + HYPERLIQUID_ASSET_NAMES, + HYPERLIQUID_CONFIG, + HYPERLIQUID_WITHDRAWAL_MINUTES, + REFERRAL_CONFIG, + TRADING_DEFAULTS, + USDC_DECIMALS, + USDC_SYMBOL, +} from '../constants/hyperLiquidConfig.js'; +import { DETAILED_ORDER_TYPES } from '../constants/orderTypes.js'; +import { + CHASE_ORDER_CONFIG, + CHASE_ORDER_STATUS, + HYPERLIQUID_TWAP_LIMITS, + ORDER_SLIPPAGE_CONFIG, + PERFORMANCE_CONFIG, + PERPS_CONSTANTS, + PROVIDER_CONFIG, + TP_SL_CONFIG, + WITHDRAWAL_CONSTANTS, +} from '../constants/perpsConfig.js'; +import { PERPS_TRANSACTIONS_HISTORY_CONSTANTS } from '../constants/transactionsHistoryConfig.js'; +import { PERPS_ERROR_CODES } from '../perpsErrorCodes.js'; +import type { PerpsErrorCode } from '../perpsErrorCodes.js'; +import { DexDiscoveryCacheManager } from '../services/DexDiscoveryCacheManager.js'; +import { + HyperLiquidClientService, + WebSocketConnectionState, +} from '../services/HyperLiquidClientService.js'; +import { HyperLiquidSubscriptionService } from '../services/HyperLiquidSubscriptionService.js'; +import { HyperLiquidWalletService } from '../services/HyperLiquidWalletService.js'; +import { + TradingReadinessCache, + PerpsSigningCache, +} from '../services/TradingReadinessCache.js'; +import type { + FrontendOrder, + OrderType as HyperLiquidOrderType, + SDKOrderParams, + MetaResponse, + PerpsAssetCtx, + SpotMetaResponse, +} from '../types/hyperliquid-types.js'; +import { + HL_ABSTRACTION_WIRE, + HL_UNIFIED_ACCOUNT_MODE, + hyperLiquidModeFoldsSpot, +} from '../types/hyperliquid-types.js'; +import { PerpsAnalyticsEvent } from '../types/index.js'; +import type { + AccountState, + AssetRoute, + BatchCancelOrdersParams, + CancelOrderParams, + CancelOrderResult, + CancelOrdersResult, + ChaseOrder, + ChaseOrderMaxDistanceReached, + ChaseOrderStatus, + CandleData, + ClosePositionParams, + ClosePositionsParams, + ClosePositionsResult, + DepositParams, + DisconnectResult, + EditOrderParams, + FeeCalculationParams, + FeeCalculationResult, + Funding, + GetAccountStateParams, + GetAvailableDexsParams, + GetFundingParams, + GetHistoricalPortfolioParams, + GetMarketsParams, + GetOrderCapabilitiesParams, + GetOrderFillsParams, + GetOrdersParams, + GetOrFetchFillsParams, + GetPositionsParams, + GetSupportedPathsParams, + GetUserDataSnapshotParams, + HistoricalPortfolioResult, + InitializeResult, + PerpsPlatformDependencies, + PerpsProvider, + LiquidationPriceParams, + LiveDataConfig, + MaintenanceMarginParams, + PositionModifyPreviewParams, + PositionModifyPreviewResult, + MarginResult, + MarketInfo, + Order, + OrderFill, + OrderParams, + OrderResult, + PerpsMarketData, + DirectProviderOrderCapabilities, + Position, + PositionTriggerOrder, + ReadyToTradeResult, + SubscribeAccountParams, + SubscribeCandlesParams, + SubscribeOICapsParams, + SubscribeOrderBookParams, + SubscribeOrderFillsParams, + SubscribeOrdersParams, + SubscribePositionsParams, + SubscribePricesParams, + ToggleTestnetResult, + TransferBetweenDexsParams, + TransferBetweenDexsResult, + TwapOrder, + TwapOrderFill, + TwapOrderStatus, + UpdateMarginParams, + UpdatePositionTPSLParams, + UserHistoryItem, + WithdrawParams, + WithdrawResult, + RawLedgerUpdate, + PerpsReadOptions, + PerpsUserDataSnapshot, + PerpsFeeResolution, +} from '../types/index.js'; +import type { PerpsControllerMessengerBase } from '../types/messenger.js'; +import type { OrderType, StrategyOrderType } from '../types/perps-types.js'; +import type { + ExtendedAssetMeta, + ExtendedPerpDex, +} from '../types/perps-types.js'; +import { + addSpotBalanceToAccountState, + aggregateAccountStates, +} from '../utils/accountUtils.js'; +import { isValidCapabilitySymbol } from '../utils/capabilitySymbols.js'; +import { + ensureError, + isHyperLiquidMultiSigRequiredError, + isHyperLiquidUserNotFoundError, + isKeyringLockedError, +} from '../utils/errorUtils.js'; +import { shouldDeferUnifiedAccountSetup } from '../utils/hyperLiquidAbstraction.js'; +import { + adaptAccountStateFromSDK, + adaptHyperLiquidLedgerUpdateToUserHistoryItem, + adaptMarketFromSDK, + adaptOrderFromSDK, + adaptPositionFromSDK, + adaptPositionTriggerOrderFromSDK, + adaptTpslLinkageToGrouping, + buildAssetMapping, + formatHyperLiquidPrice, + formatHyperLiquidSize, + HYPERLIQUID_SCALE_CLOID_MARKER, + parseAssetName, +} from '../utils/hyperLiquidAdapter.js'; +import { + previewHyperLiquidIsolatedPositionModify, + resolveHyperLiquidMarginTiers, +} from '../utils/hyperLiquidPositionPreview.js'; +import { + createErrorResult, + getMaxOrderValue, + getSupportedPaths, + validateAssetSupport, + validateBalance, + validateCoinExists, + validateDepositParams, + validateOrderParams, + validateWithdrawalParams, +} from '../utils/hyperLiquidValidation.js'; +import type { StrategyOrderValidationParams } from '../utils/hyperLiquidValidation.js'; +import { generatePerpsId } from '../utils/idUtils.js'; +import { transformMarketData } from '../utils/marketDataTransform.js'; +import { + compileMarketPattern, + shouldIncludeMarket, +} from '../utils/marketUtils.js'; +import type { CompiledMarketPattern } from '../utils/marketUtils.js'; +import { + buildOrdersArray, + calculateFinalPositionSize, + calculateOrderPriceAndSize, + computeChaseQuotePrice, + computeScalePriceLadder, + floorToSizeDecimals, + formatPartialTpslSize, + getPriceTick, + splitScaleSizes, + validateOrderPrecision, +} from '../utils/orderCalculations.js'; +import { + getTriggerDirection, + getTriggerExecution, + isLimitExecutionOrderType, + isStrategyOrderType, + isTriggerOrderType, + resolvePositionTriggerSummaryPrice, + toSDKTimeInForce, +} from '../utils/orderTypes.js'; +import { + createStandaloneInfoClient, + queryStandaloneClearinghouseStates, + queryStandaloneOpenOrders, +} from '../utils/standaloneInfoClient.js'; +import { parseBoundedNonNegativeDecimal } from '../utils/stringParseUtils.js'; +// getStreamManagerInstance removed: use this.#deps.streamManager instead + +const HISTORICAL_ORDER_TYPE_BY_DETAILED_TYPE = { + [DETAILED_ORDER_TYPES.LIMIT]: 'limit', + [DETAILED_ORDER_TYPES.MARKET]: 'market', + [DETAILED_ORDER_TYPES.STOP_LIMIT]: 'limit', + [DETAILED_ORDER_TYPES.STOP_MARKET]: 'market', + [DETAILED_ORDER_TYPES.TAKE_PROFIT_LIMIT]: 'limit', + [DETAILED_ORDER_TYPES.TAKE_PROFIT_MARKET]: 'market', +} as const satisfies Record; + +/** + * Type guard to check if a status is an object (not a string literal like "waitingForFill") + * The SDK returns status as a union of object types and string literals. + * + * @param status - The current status. + * @returns The result of the operation. + */ +const isStatusObject = (status: unknown): status is Record => + typeof status === 'object' && status !== null; + +/** + * Exchange messages that mean a cancel was refused because the order is not on + * the book any more. + * + * HyperLiquid answers a cancel it cannot match with "Order was never placed, + * already canceled, or filled." That is a rejection of the request but a + * confirmation of what the caller wanted — nothing of that order is resting. + * Every other rejection (multi-sig, a stale nonce, a rate limit) leaves the + * order exactly where it was, which is a materially different outcome. + */ +const ALREADY_GONE_CANCEL_MARKERS = [ + 'never placed', + 'already canceled', + 'already cancelled', + 'order not found', + 'twap not found', +]; + +const RETRYABLE_CHASE_PLACEMENT_MARKERS = [ + 'post only order would have immediately matched', + 'price too far from oracle', +]; + +const CHASE_ORDER_STATUS_RETRY_COUNT = 2; +const CHASE_ORDER_STATUS_RETRY_DELAY_MS = 100; +const CHASE_ORDER_STATUS_UNAVAILABLE = 'status_unavailable'; + +class ChaseOrderStatusUnavailableError extends Error { + constructor() { + super('Chase order status unavailable'); + this.name = 'ChaseOrderStatusUnavailableError'; + } +} + +const isRetryableChasePlacementError = (error: unknown): boolean => { + const message = ensureError( + error, + 'HyperLiquidProvider.startChaseSession', + ).message.toLowerCase(); + return RETRYABLE_CHASE_PLACEMENT_MARKERS.some((marker) => + message.includes(marker), + ); +}; + +/** + * What happened to one order a cancel was asked to remove. + * + * `Cancelled` and `Gone` both mean nothing of it is resting, which is all a + * caller asked for; only `Refused` leaves an order behind. + */ +const enum CancelChildOutcome { + Cancelled = 'cancelled', + Gone = 'gone', + Refused = 'refused', +} + +const enum HyperLiquidTwapLifecycleStatus { + Activated = 'activated', + Failed = 'error', + Finished = 'finished', + Stopped = 'stopped', + Terminated = 'terminated', + WaitingForTrigger = 'waitingForTrigger', +} + +const enum PerpsTwapLifecycleStatus { + Active = 'active', + Canceled = 'canceled', + Completed = 'completed', + CompletedUnderfilled = 'completed_underfilled', + Failed = 'failed', +} + +type SdkHyperLiquidTwapHistoryEntry = Awaited< + ReturnType +>[number]; + +type HyperLiquidTwapNonFailureLifecycleStatus = + | `${HyperLiquidTwapLifecycleStatus.Activated}` + | `${HyperLiquidTwapLifecycleStatus.Finished}` + | `${HyperLiquidTwapLifecycleStatus.Stopped}` + | `${HyperLiquidTwapLifecycleStatus.Terminated}` + | `${HyperLiquidTwapLifecycleStatus.WaitingForTrigger}`; + +type HyperLiquidTwapFailureLifecycleStatus = + `${HyperLiquidTwapLifecycleStatus.Failed}`; + +type HyperLiquidTwapHistoryEntry = Omit< + SdkHyperLiquidTwapHistoryEntry, + 'status' +> & { + status: + | { + status: HyperLiquidTwapNonFailureLifecycleStatus; + } + | { + status: HyperLiquidTwapFailureLifecycleStatus; + description: string; + }; +}; + +type HyperLiquidTwapSliceFillEntry = Awaited< + ReturnType +>[number]; + +type ExchangeCancelRequest = { a: number; o: number }; + +type CancelOrderBatchOutcome = { + remainingOrderIds: number[]; + cancelledOrderIds: number[]; + responseComplete: boolean; +}; + +type OrderPlacementOutcome = { + orderId: string; + state: 'resting' | 'filled'; +}; + +type TpslOrderPlacementOutcome = { + orderId?: string; + state: 'resting' | 'filled' | 'waitingForTrigger' | 'rejected' | 'unknown'; +}; + +type RestorableTpslOrder = { + orderId: number; + order: SDKOrderParams; + chargesMetamaskBuilderFee: boolean; +}; + +type TpslProtectionRestorationOutcome = { + restoredOrderIds: string[]; + success: boolean; +}; + +type BuilderFeeSetupContext = { + network: 'testnet' | 'mainnet'; + userAddress: string; + builderAddress: string; +}; + +/** + * Classify one entry of a cancel response. + * + * @param status - A single status from the exchange's cancel response. + * @returns Whether the order was cancelled, was already gone, or still rests. + */ +const classifyCancelStatus = (status: unknown): CancelChildOutcome => { + if (status === 'success') { + return CancelChildOutcome.Cancelled; + } + + const message = ( + isStatusObject(status) && typeof status.error === 'string' + ? status.error + : '' + ).toLowerCase(); + + return ALREADY_GONE_CANCEL_MARKERS.some((marker) => message.includes(marker)) + ? CancelChildOutcome.Gone + : CancelChildOutcome.Refused; +}; + +/** + * Read the best price on one side of the book, discounting the chase's own + * order. + * + * A chase rests one tick inside the spread, so its own order *is* the best + * price on its side. Reading the raw book would therefore see the chase's own + * quote every tick, conclude the touch has not moved, and never re-price — + * masking exactly the adverse moves the strategy exists to follow. Levels this + * provider occupies are netted down by what it holds there, and are skipped + * entirely when nothing else is left on them. + * + * "Own" means *every* chase this provider is running on that side, not just the + * one asking. Two chases on the same side each netting only themselves would + * read the other as the external touch and improve on it, then improve on the + * improvement — walking each other toward the opposite side of an unchanged + * market. + * + * The sizes must be what the orders still have resting, not what they were + * placed for: an order netted at its original size subtracts more than it + * occupies, which can hide external liquidity sharing the level. + * + * @param levels - One side of the book, best-first. + * @param ownByPrice - This provider's own resting size at each price. + * @returns The best price other participants are showing, or null. + */ +const readBestExternalPrice = ( + levels: { px: string; sz: string }[] | undefined, + ownByPrice: Map, +): number | null => { + for (const level of levels ?? []) { + const price = parseFloat(level.px); + if (!Number.isFinite(price) || price <= 0) { + continue; + } + + const size = parseFloat(level.sz) - (ownByPrice.get(level.px) ?? 0); + if (size > 0) { + return price; + } + } + + return null; +}; + +/** + * Narrow order params down to their strategy fields. + * + * Both validation entry points forward exactly this group, so a field added to + * `StrategyOrderValidationParams` reaches both of them or neither. + * + * @param params - Order parameters. + * @returns The strategy fields, as validation takes them. + */ +const pickStrategyParams = ( + params: OrderParams, +): StrategyOrderValidationParams => ({ + twapDuration: params.twapDuration, + twapRandomize: params.twapRandomize, + scaleMinPrice: params.scaleMinPrice, + scaleMaxPrice: params.scaleMaxPrice, + scaleNumOrders: params.scaleNumOrders, + scaleSkew: params.scaleSkew, + chaseIntervalMs: params.chaseIntervalMs, + chaseMaxDurationMs: params.chaseMaxDurationMs, + chaseMaxRepricings: params.chaseMaxRepricings, + chaseMaxDistanceBps: params.chaseMaxDistanceBps, +}); + +/** + * Check a strategy placement's notional against the minimum the venue will + * actually apply to it. + * + * The per-order minimum is charged against each order the venue receives, and a + * strategy placement does not send one order: + * + * A `twap` is a single instruction the venue slices itself, so it is bounded by + * the venue's own documented minimum for a TWAP rather than by the per-order + * minimum that `validateOrder` has already applied. + * + * `scale` is deliberately absent. Its rungs are the orders the venue charges + * the minimum against, and their sizes come from flooring onto the asset's size + * grid with the remainder landing on the first rung — none of which is knowable + * without `szDecimals`, which this check does not have. Every approximation + * available here is unsound in one direction or the other, and the one that was + * here rejected ladders whose submitted rungs all cleared the minimum. + * `#buildScaleLadder` applies the exact per-rung check against the real grid + * sizes, and it runs before anything is signed, so nothing is lost by leaving + * the question to it. + * + * `chase` is absent too: it rests one order at a time, so the per-order minimum + * the caller has already been checked against is the right bound. + * + * @param params - Notional parameters. + * @param params.orderType - The order placement type. + * @param params.orderValueUSD - Total notional of the placement, in USD. + * @returns Validation result with isValid flag and optional error message. + */ +const validateStrategyNotional = (params: { + orderType?: OrderType; + orderValueUSD: number; +}): { isValid: boolean; error?: string } => { + const { orderType, orderValueUSD } = params; + + if (orderType === 'twap') { + return orderValueUSD < HYPERLIQUID_TWAP_LIMITS.MinNotionalUsd + ? { + isValid: false, + error: PERPS_ERROR_CODES.ORDER_TWAP_NOTIONAL_TOO_SMALL, + } + : { isValid: true }; + } + + return { isValid: true }; +}; + +// Helper method parameter interfaces (module-level for class-dependent methods only) +type GetAssetInfoParams = { + symbol: string; + dexName: string | null; +}; + +type DexFetchFailureStep = 'metaAndAssetCtxs' | 'allMids'; + +type DexFetchResult = { + dex: string | null; + meta: MetaResponse | null; + assetCtxs: PerpsAssetCtx[]; + allMids: Record; + success: boolean; + failedStep?: DexFetchFailureStep; + errorMessage?: string; +}; + +type DexQueryResult = { + dex: string | null; + data: TResult; +}; + +type DexQueryResponse = { + results: DexQueryResult[]; + failedDexs: { dex: string | null; error: Error }[]; +}; + +type CachedMarketDataSnapshot = { + data: PerpsMarketData[]; + timestamp: number; + contributingDexs: string[]; + failedDexs: string[]; +}; + +type GetAssetInfoResult = { + assetInfo: { + name: string; + szDecimals: number; + maxLeverage: number; + }; + currentPrice: number; + meta: MetaResponse; +}; + +type PrepareAssetForTradingParams = { + symbol: string; + assetId: number; + leverage?: number; +}; + +type Hip3TransferInfo = { + amount: number; + sourceDex: string; +}; + +type Hip3TransferContext = { + dexName: string; + transferInfo: Hip3TransferInfo; +}; + +type TwapOrderScope = { + network: 'testnet' | 'mainnet'; + userAddress: string; + orderId: string; +}; + +type TwapAccountScope = Pick; + +type TrackedTwapOrder = { + symbol: string; + hip3Transfer?: Hip3TransferContext; + rebalancePromise?: Promise; +}; + +type ChaseTerminalStatus = + | typeof CHASE_ORDER_STATUS.Filled + | typeof CHASE_ORDER_STATUS.Canceled + | typeof CHASE_ORDER_STATUS.Failed; + +type ChaseOrderRemainder = { + remainingSize: string | null; + terminalStatus: ChaseTerminalStatus | null; +}; + +type ChaseTerminalResolution = { + remainingSize: string | null; + status: ChaseTerminalStatus; +}; + +type ChaseRestingOrder = { + orderId: string; + price: string; + size: string; +}; + +type OrderCapabilitiesMarketCacheEntry = Readonly<{ + markets: MarketInfo[]; + freshAt: number; +}>; + +type AdaptTwapOrderParams = { + historyEntry: HyperLiquidTwapHistoryEntry; + sliceFills: HyperLiquidTwapSliceFillEntry[]; + now: number; +}; + +type ChaseOrderMaxDistanceReachedHandler = ( + event: ChaseOrderMaxDistanceReached, +) => void; + +type HyperLiquidProviderOptions = { + isTestnet?: boolean; + hip3Enabled?: boolean; + allowlistMarkets?: string[]; + blocklistMarkets?: string[]; + priceDeviationLimit?: number; + useUnifiedAccount?: boolean; + platformDependencies: PerpsPlatformDependencies; + messenger: PerpsControllerMessengerBase; + initialAssetMapping?: [string, number][]; + builderAddressTestnet?: string; + builderAddressMainnet?: string; + subscriptionBuilderAddressTestnet?: string; + subscriptionBuilderAddressMainnet?: string; + onChaseOrderMaxDistanceReached?: ChaseOrderMaxDistanceReachedHandler; +}; + +type HandleHip3PreOrderParams = { + dexName: string; + symbol: string; + orderPrice: number; + positionSize: number; + leverage: number; + isBuy: boolean; + maxLeverage: number; +}; + +type HandleHip3PreOrderResult = { + transferInfo: Hip3TransferInfo | null; +}; + +type SubmitOrderWithRollbackParams = { + orders: SDKOrderParams[]; + grouping: 'na' | 'normalTpsl' | 'positionTpsl'; + isHip3Order: boolean; + dexName: string | null; + transferInfo: Hip3TransferInfo | null; + symbol: string; + assetId: number; + chargesMetamaskBuilderFee: boolean; + builderFeeSetupContext?: BuilderFeeSetupContext; +}; + +type BuilderOrderContext = { b: string; f: number }; + +type HandleOrderErrorParams = { + error: unknown; + symbol: string; + orderType: OrderType; + isBuy: boolean; +}; + +type GetOrFetchPriceParams = { + symbol: string; + dexName: string | null; +}; + +/** + * What every strategy placement needs before it can talk to the exchange. + * + * The single-order path derives the same values inline; strategy placements + * share them through `#prepareStrategyPlacement` so the three of them cannot + * drift apart on validation, readiness, or leverage. + */ +type StrategyPlacementContext = { + assetId: number; + szDecimals: number; + formattedSize: string; + builder?: BuilderOrderContext; + dexName: string | null; + network: 'testnet' | 'mainnet'; + transferInfo: Hip3TransferInfo | null; + userAddress: string; + /** The validated ladder a scale placement submits; absent for other types. */ + ladder?: ScaleLadder; +}; + +/** + * A scale placement's rungs, exactly as they will be submitted. + * + * Built and checked before anything is signed, so the sizes and prices the + * venue sees are the ones the minimums were applied to. + */ +type ScaleLadder = { + prices: string[]; + sizes: string[]; +}; + +type ScaleOrderIdentity = { + groupId: string; + clientOrderIds: Hex[]; +}; + +/** + * A scale ladder's children, held so the group can be cancelled as one. + * + * The group can be rebuilt from open orders because each rung carries the + * same recoverable group identity in its venue client-order ID. + */ +type ScaleOrderGroup = { + symbol: string; + orderIds: string[]; +}; + +/** + * Create one recoverable identity for a Scale ladder and one venue CLOID per + * rung. HyperLiquid requires every CLOID to be unique, so the last byte holds + * the rung index while the preceding 15 bytes identify the shared group. + * + * @param count - Number of ladder rungs. + * @returns The public group handle and venue client order IDs. + */ +const createScaleOrderIdentity = (count: number): ScaleOrderIdentity => { + const groupKey = `${HYPERLIQUID_SCALE_CLOID_MARKER}${uuidv4() + .replace(/-/gu, '') + .slice(0, 22)}`; + const clientOrderIds = Array.from({ length: count }, (_, index) => { + const clientOrderId: Hex = `0x${groupKey}${index + .toString(16) + .padStart(2, '0')}`; + if (!isHexString(clientOrderId)) { + throw new Error('Failed to create Scale client order ID'); + } + return clientOrderId; + }); + return { groupId: `scale:${groupKey}`, clientOrderIds }; +}; + +/** + * A running chase: the order currently resting, and the loop re-pricing it. + */ +type ChaseSession = { + symbol: string; + assetId: number; + isBuy: boolean; + szDecimals: number; + reduceOnly: boolean; + /** Exchange ID of the order resting right now, or null once it is gone. */ + orderId: string | null; + /** Previous child ID while a replacement is between cancel and rest. */ + replacingOrderId: string | null; + /** + * Size the order resting right now was placed for. + * + * Shrinks as the chase re-prices: a child that partially filled before it was + * cancelled leaves less to chase, and re-placing the original size would + * execute more than the caller asked for. + */ + size: string; + originalSize: string; + /** + * Settles when an in-flight replacement has finished resting (or failed). + * Null while no placement is in flight. + * + * `orderId` is null for the whole of that round trip, which is + * indistinguishable from "nothing rests" unless a canceller can tell the two + * apart — so a cancel joins this before deciding what to cancel. + */ + pendingReplacement: Promise | null; + /** Price the resting order sits at. */ + restingPrice: string; + arrivalPrice: string; + startedAt: number; + maxDistanceBps?: number; + intervalMs: number; + /** Last live remainder refresh used by client-facing snapshots. */ + lastSnapshotSizeRefreshAt: number; + /** + * Builder fee this chase was quoted at, in tenths of a basis point. + * + * Captured when the session starts because the rewards discount it reflects + * is set around the caller's `placeOrder` and cleared as soon as that returns. + * A chase returns immediately, so every replacement runs after the clear and + * would otherwise be re-quoted at the undiscounted maximum. + */ + builder?: BuilderOrderContext; + /** Manual HIP-3 collateral retained while this session has venue exposure. */ + hip3Transfer?: Hip3TransferContext; + /** Coalesces concurrent terminal cleanup reads for this session. */ + hip3RebalancePromise?: Promise; + /** Remove a canceled session once deferred collateral cleanup succeeds. */ + removeAfterRebalance?: boolean; + /** Absolute deadline, as a `Date.now()` stamp. */ + deadline: number; + maxRepricings: number; + repricings: number; + /** Cleared when the session stops; null while no tick is pending. */ + timer: ReturnType | null; + /** False once the chase has stopped re-pricing, whatever the reason. */ + active: boolean; + status: ChaseOrderStatus; +}; + +/** + * Collect the order IDs of every TP/SL child carried by a parent order. + * + * HyperLiquid lists `normalTpsl` children both nested under their parent and as + * top-level entries in `frontendOpenOrders`. Those children protect the pending + * parent order rather than the position, so callers use this set to exclude them. + * + * @param orders - Raw frontend open orders for the account. + * @returns The set of child order IDs. + */ +function collectChildOrderIds(orders: FrontendOrder[]): Set { + const childOrderIds = new Set(); + + orders.forEach((order) => { + order.children?.forEach((child) => { + childOrderIds.add(child.oid); + }); + }); + + return childOrderIds; +} + +/** + * Group orders by market, so a per-position pass does not rescan every order. + * + * @param orders - Raw frontend open orders across all DEXs. + * @returns Orders keyed by market symbol. + */ +function groupOrdersBySymbol( + orders: FrontendOrder[], +): Map { + const bySymbol = new Map(); + + orders.forEach((order) => { + const existing = bySymbol.get(order.coin); + if (existing) { + existing.push(order); + } else { + bySymbol.set(order.coin, [order]); + } + }); + + return bySymbol; +} + +/** + * Build the trigger-order view of a position: position-bound TP/SL plus + * standalone (partial) reduce-only triggers on the same market, de-duplicated by + * order ID and excluding children of pending parent orders. + * + * @param params - Collection parameters. + * @param params.orders - Raw frontend open orders across all DEXs. + * @param params.position - Position the triggers are attached to. + * @param params.childOrderIds - Order IDs that belong to a pending parent order. + * @returns The take profit and stop loss trigger orders for the position. + */ +function collectPositionTriggerOrders(params: { + orders: FrontendOrder[]; + position: Position; + childOrderIds: Set; +}): { + takeProfitOrders: PositionTriggerOrder[]; + stopLossOrders: PositionTriggerOrder[]; + takeProfitPrice?: string; + stopLossPrice?: string; +} { + const { orders, position, childOrderIds } = params; + + const byOrderId = new Map(); + let takeProfitPrice: string | undefined; + let stopLossPrice: string | undefined; + + orders.forEach((rawOrder) => { + if ( + rawOrder.isTrigger && + rawOrder.reduceOnly && + rawOrder.isPositionTpsl === Boolean(TP_SL_CONFIG.UsePositionBoundTpsl) + ) { + if (rawOrder.orderType.includes('Take Profit')) { + takeProfitPrice = rawOrder.triggerPx; + } else if (rawOrder.orderType.includes('Stop')) { + stopLossPrice = rawOrder.triggerPx; + } + } + + rawOrder.children?.forEach((childOrder) => { + if ( + !childOrder.isTrigger || + !childOrder.reduceOnly || + childOrder.isPositionTpsl !== Boolean(TP_SL_CONFIG.UsePositionBoundTpsl) + ) { + return; + } + if (childOrder.orderType.includes('Take Profit')) { + takeProfitPrice = childOrder.triggerPx; + } else if (childOrder.orderType.includes('Stop')) { + stopLossPrice = childOrder.triggerPx; + } + }); + + if ( + rawOrder.coin !== position.symbol || + !rawOrder.isTrigger || + !rawOrder.reduceOnly || + childOrderIds.has(rawOrder.oid) + ) { + return; + } + + const triggerOrder = adaptPositionTriggerOrderFromSDK({ + rawOrder, + positionSize: position.size, + entryPrice: position.entryPrice, + }); + + if (triggerOrder && !byOrderId.has(triggerOrder.orderId)) { + byOrderId.set(triggerOrder.orderId, triggerOrder); + } + }); + + const triggerOrders = Array.from(byOrderId.values()); + const takeProfitOrders = triggerOrders.filter( + (order) => order.direction === 'take_profit', + ); + const stopLossOrders = triggerOrders.filter( + (order) => order.direction !== 'take_profit', + ); + + const takeProfitSummaryPrice = resolvePositionTriggerSummaryPrice({ + triggerOrders: takeProfitOrders, + scannedPrice: takeProfitPrice, + }); + const stopLossSummaryPrice = resolvePositionTriggerSummaryPrice({ + triggerOrders: stopLossOrders, + scannedPrice: stopLossPrice, + }); + + return { + takeProfitOrders, + stopLossOrders, + ...(takeProfitSummaryPrice && { takeProfitPrice: takeProfitSummaryPrice }), + ...(stopLossSummaryPrice && { stopLossPrice: stopLossSummaryPrice }), + }; +} + +type HyperLiquidOrderFeeContext = + | Readonly + | Readonly; + +type HyperLiquidOrderFeePolicy = Readonly<{ + chargesMetamaskBuilderFee: boolean; +}>; + +const MAX_API_FEE_RATE = 1; +const MAX_API_FEE_DISCOUNT = 1; + +/** + * Fee applicability by canonical order type. + * + * The configuration type makes this map exhaustive, so a new order type cannot + * inherit a builder-fee policy accidentally. HyperLiquid's dedicated TWAP + * action has no builder field. Every other standalone placement uses an order + * action that can carry one builder context. This policy is deliberately not a + * constructor or order input. A future backend policy must be resolved inside + * the provider from its trusted source rather than accepted from a client. + */ +const HYPERLIQUID_ORDER_FEE_CONFIG = { + market: { chargesMetamaskBuilderFee: true }, + limit: { chargesMetamaskBuilderFee: true }, + stop_market: { chargesMetamaskBuilderFee: true }, + stop_limit: { chargesMetamaskBuilderFee: true }, + take_profit_market: { chargesMetamaskBuilderFee: true }, + take_profit_limit: { chargesMetamaskBuilderFee: true }, + twap: { chargesMetamaskBuilderFee: false }, + scale: { chargesMetamaskBuilderFee: true }, + chase: { chargesMetamaskBuilderFee: true }, +} satisfies Record; + +const MILLISECONDS_PER_MINUTE = 60_000; +const MILLISECONDS_PER_SECOND = 1_000; + +/** + * Normalize the TWAP history timestamp documented in seconds while tolerating + * an already-millisecond value from a future SDK response. + * + * @param timestamp - Venue timestamp in seconds or milliseconds. + * @returns Timestamp in milliseconds. + */ +const normalizeTwapHistoryTimestamp = (timestamp: number): number => + timestamp < 1_000_000_000_000 + ? timestamp * MILLISECONDS_PER_SECOND + : timestamp; + +const getTwapOrderScopeKey = (params: TwapOrderScope): string => + `${params.network}:${params.userAddress.toLowerCase()}:${params.orderId}`; + +const resolveTwapOrderStatus = ( + historyEntry: HyperLiquidTwapHistoryEntry, + executedSize: BigNumber, + totalSize: BigNumber, +): TwapOrderStatus => { + switch (historyEntry.status.status) { + case HyperLiquidTwapLifecycleStatus.Activated: + case HyperLiquidTwapLifecycleStatus.WaitingForTrigger: + return PerpsTwapLifecycleStatus.Active; + case HyperLiquidTwapLifecycleStatus.Finished: + return executedSize.isLessThan(totalSize) + ? PerpsTwapLifecycleStatus.CompletedUnderfilled + : PerpsTwapLifecycleStatus.Completed; + case HyperLiquidTwapLifecycleStatus.Terminated: + case HyperLiquidTwapLifecycleStatus.Stopped: + return PerpsTwapLifecycleStatus.Canceled; + case HyperLiquidTwapLifecycleStatus.Failed: + return PerpsTwapLifecycleStatus.Failed; + default: + // A new venue status is not proof that a schedule stopped. Keep it + // non-terminal so collateral cannot be reclaimed from a live TWAP. + return PerpsTwapLifecycleStatus.Active; + } +}; + +const adaptTwapOrderFill = ( + entry: HyperLiquidTwapSliceFillEntry, +): TwapOrderFill => ({ + fillId: entry.fill.tid.toString(), + orderId: entry.fill.oid.toString(), + side: entry.fill.side === 'B' ? 'buy' : 'sell', + price: entry.fill.px, + size: entry.fill.sz, + fee: entry.fill.fee, + feeToken: entry.fill.feeToken, + ...(entry.fill.builderFee === undefined + ? {} + : { builderFee: entry.fill.builderFee }), + timestamp: entry.fill.time, + transactionHash: entry.fill.hash, +}); + +/** + * HyperLiquid provider implementation + * + * Implements the PerpsProvider interface for HyperLiquid protocol. + * Uses the @nktkas/hyperliquid SDK for all operations. + * Delegates to service classes for client management, wallet integration, and subscriptions. + * + * HIP-3 Balance Management: + * Attempts to use HyperLiquid's native DEX abstraction for automatic collateral transfers. + * If not supported, falls back to programmatic balance management using SDK's sendAsset. + */ +export class HyperLiquidProvider implements PerpsProvider { + readonly protocolId = PROVIDER_CONFIG.DefaultProvider; + + // Platform dependencies for logging and debugging + readonly #deps: PerpsPlatformDependencies; + + // Service instances + readonly #clientService: HyperLiquidClientService; + + readonly #walletService: HyperLiquidWalletService; + + readonly #subscriptionService: HyperLiquidSubscriptionService; + + // Asset mapping + readonly #symbolToAssetId = new Map(); + + // Cache for user fee rates to avoid excessive API calls + readonly #userFeeCache = new Map< + string, + { + perpsTakerRate: number; + perpsMakerRate: number; + spotTakerRate: number; + spotMakerRate: number; + timestamp: number; + ttl: number; + } + >(); + + // Cache for max leverage values to avoid excessive API calls + readonly #maxLeverageCache = new Map< + string, + { value: number; timestamp: number } + >(); + + // Cache for raw meta responses (shared across methods to avoid redundant API calls) + // Filtering is applied on-demand (cheap array operations) - no need for separate processed cache + readonly #cachedMetaByDex = new Map(); + + // Fresh filtered markets used only for capability discovery. Each routed + // DEX owns its freshness window and coalesced refresh. + readonly #orderCapabilitiesMarketsByDex = new Map< + string | null, + OrderCapabilitiesMarketCacheEntry + >(); + + readonly #orderCapabilitiesRefreshByDex = new Map< + string | null, + Promise + >(); + + // Sticky for this instance. PerpsController discards disconnected providers + // during initialization; direct callers must call initialize() before reuse. + #isDisconnected = false; + + #disconnectOperationsInFlight = 0; + + #disconnectOperationPromise: Promise | null = null; + + #lifecycleGeneration = 0; + + // Last known-good market list for stale fallback when every enabled DEX fails in one fetch window. + #cachedMarketDataWithPrices: CachedMarketDataSnapshot | null = null; + + // Session cache for spot metadata (contains token info for HIP-3 collateral checks) + // Pre-fetched in ensureReadyForTrading() to avoid API failures during order placement + #cachedSpotMeta: SpotMetaResponse | null = null; + + // Unified DEX discovery cache — single source of truth for all perpDexs() derivatives. + // Replaces three separate caches to eliminate desync bugs by construction. + // All writes go through #dexDiscoveryCache.update(); readers use .state. + readonly #dexDiscoveryCache: DexDiscoveryCacheManager; + + // Session cache for referral state (cleared on disconnect/reconnect) + // Key: `network:userAddress`, Value: true if referral is set + readonly #referralCheckCache = new Map(); + + // Session cache for builder fee approval state (cleared on disconnect/reconnect) + // Key: `network:userAddress`, Value: true if builder fee is approved + readonly #builderFeeCheckCache = new Map(); + + // Pending promise trackers for deduplicating concurrent calls + // Prevents multiple signature requests when methods called simultaneously + #ensureReadyPromise: Promise | null = null; + + readonly #pendingBuilderFeeApprovals = new Map>(); + + #subscriptionBuilderApprovalEpoch = 0; + + /** Builder approvals keyed by network, account, and builder address. */ + readonly #approvedBuilderAddresses = new Set(); + + // Pre-compiled patterns for fast filtering + readonly #compiledAllowlistPatterns: CompiledMarketPattern[] = []; + + readonly #compiledBlocklistPatterns: CompiledMarketPattern[] = []; + + // Fee discount context for MetaMask reward discounts (in basis points) + #userFeeDiscountBips?: number; + + #userFeeResolution?: PerpsFeeResolution; + + // Feature flag configuration for HIP-3 market filtering + readonly #hip3Enabled: boolean; + + readonly #allowlistMarkets: string[]; + + readonly #blocklistMarkets: string[]; + + // Legacy rollback switch. MetaMask production supports unified account and + // portfolio margin. The false branch and its manual HIP-3 transfers remain + // temporarily for rollback compatibility and will be removed separately. + #useUnifiedAccount: boolean; + + // True once DEX discovery has succeeded with real data (not a fallback). + // When false, #ensureReadyPromise is reset after each init so the next + // caller retries DEX discovery instead of reusing a degraded mapping. + #dexDiscoveryComplete = false; + + // True when the most recent #ensureUnifiedAccountEnabled run ended in a + // transient state that warrants retry (silent agent-key failure, REST + // userAbstraction lookup failure, or keyring locked). #ensureReady resets + // its memoized promise when this is set so the next entry retries the + // migration instead of returning the cached resolved promise. + #unifiedAccountSetupNeedsRetry = false; + + // Pending promise to deduplicate concurrent getValidatedDexs() calls + #pendingValidatedDexsPromise: Promise<(string | null)[]> | null = null; + + // Cache for USDC token ID from spot metadata + #cachedUsdcTokenId?: string; + + // Error mappings from HyperLiquid API errors to standardized PERPS_ERROR_CODES + readonly #errorMappings = { + 'isolated position does not have sufficient margin available to decrease leverage': + PERPS_ERROR_CODES.ORDER_LEVERAGE_REDUCTION_FAILED, + 'could not immediately match': PERPS_ERROR_CODES.IOC_CANCEL, + 'multi-sig required': PERPS_ERROR_CODES.EXCHANGE_MULTI_SIG_REQUIRED, + 'invalid nonce': PERPS_ERROR_CODES.EXCHANGE_INVALID_NONCE, + }; + + // Scale ladders placed by this provider instance, keyed by group handle, so + // one cancel can reach every rung. Cleared on disconnect; the rung IDs are + // also returned to the caller as `OrderResult.childOrderIds`. + readonly #scaleOrderGroups = new Map(); + + // Prevent a stale open-order cache from recreating a group that this + // provider lifecycle already canceled successfully. + readonly #cancelledScaleOrderGroups = new Set(); + + // Native TWAP IDs and any manual HIP-3 collateral transfer must outlive one + // provider instance: controller disconnect/reinitialization replaces the + // provider while the venue schedule keeps running. Each controller messenger + // owns one registry, so separate controller lifecycles never share handles. + static readonly #trackedTwapOrdersByMessenger = new WeakMap< + PerpsControllerMessengerBase, + Map + >(); + + readonly #trackedTwapOrders: Map; + + // Chase sessions running on this provider instance, keyed by session handle. + // Each owns a pending timer, so disconnect has to stop them. + readonly #chaseSessions = new Map(); + + // In-flight termination is keyed by stable strategy handle so retries share + // one venue mutation. Completed handles remain idempotent for this provider + // lifecycle and are cleared on disconnect. + readonly #chaseTerminations = new Map>(); + + // Provider-lifetime tombstones make every later retry idempotent. Disconnect + // clears them with the rest of the account-scoped Chase registry. + readonly #terminatedChaseHandles = new Set(); + + // Chase placements that have reserved a slot against the venue's concurrency + // cap but have not registered their session yet. Two round trips separate the + // two, and a reservation is what keeps concurrent placements from both + // passing the check. + #chasePlacementsInFlight = 0; + + // Allows lifecycle teardown to wait until every placement has either + // registered a session or retracted its newly-rested child. + readonly #chasePlacementWaiters = new Set>(); + + // Serialize background Chase mutations. Several sessions share one signer and + // transport, so simultaneous cancel/replace ticks would create avoidable + // signed traffic and can starve a foreground placement. + #chaseTickQueue: Promise = Promise.resolve(); + + // Every strategy captures this before its round trips. A changed generation + // prevents a late response from registering handles after disconnect. + #strategyGeneration = 0; + + // Reference-counted because suspension and disconnect may overlap. One + // lifecycle owner finishing must not reopen placement while another still + // drains or tears down the provider. + #chasePlacementBlockers = 0; + + // Track whether clients have been initialized (lazy initialization) + #clientsInitialized = false; + + // Promise-based lock to prevent race conditions in concurrent initialization + #initializationPromise: Promise | null = null; + + readonly #messenger: PerpsControllerMessengerBase; + + readonly #builderAddressTestnet?: string; + + readonly #builderAddressMainnet?: string; + + readonly #subscriptionBuilderAddressTestnet?: string; + + readonly #subscriptionBuilderAddressMainnet?: string; + + readonly #priceDeviationLimit: number; + + readonly #onChaseOrderMaxDistanceReached?: ChaseOrderMaxDistanceReachedHandler; + + constructor(options: HyperLiquidProviderOptions) { + this.#deps = options.platformDependencies; + this.#messenger = options.messenger; + const trackedTwapOrders = + HyperLiquidProvider.#trackedTwapOrdersByMessenger.get(this.#messenger) ?? + new Map(); + HyperLiquidProvider.#trackedTwapOrdersByMessenger.set( + this.#messenger, + trackedTwapOrders, + ); + this.#trackedTwapOrders = trackedTwapOrders; + this.#builderAddressTestnet = options.builderAddressTestnet; + this.#builderAddressMainnet = options.builderAddressMainnet; + this.#subscriptionBuilderAddressTestnet = + options.subscriptionBuilderAddressTestnet; + this.#subscriptionBuilderAddressMainnet = + options.subscriptionBuilderAddressMainnet; + this.#onChaseOrderMaxDistanceReached = + options.onChaseOrderMaxDistanceReached; + this.#priceDeviationLimit = + options.priceDeviationLimit ?? + HYPERLIQUID_CONFIG.OraclePriceDeviationLimit; + const isTestnet = options.isTestnet ?? false; + + // Dev-friendly defaults: Enable all markets by default for easier testing (discovery mode) + this.#hip3Enabled = options.hip3Enabled ?? false; + this.#allowlistMarkets = options.allowlistMarkets ?? []; + this.#blocklistMarkets = options.blocklistMarkets ?? []; + + // Attempt unified account mode, fallback to programmatic transfer if unsupported + this.#useUnifiedAccount = options.useUnifiedAccount ?? true; + + // Initialize services with injected platform dependencies + this.#clientService = new HyperLiquidClientService(this.#deps, { + isTestnet, + }); + this.#dexDiscoveryCache = new DexDiscoveryCacheManager({ + isTestnetMode: (): boolean => this.#clientService.isTestnetMode(), + debugLogger: this.#deps.debugLogger, + getAllowlistMarkets: (): string[] => this.#allowlistMarkets, + }); + this.#walletService = new HyperLiquidWalletService( + this.#deps, + this.#messenger, + { + isTestnet, + }, + ); + this.#subscriptionService = new HyperLiquidSubscriptionService( + this.#clientService, + this.#walletService, + this.#deps, + this.#hip3Enabled, + [], // enabledDexs - will be populated after DEX discovery in buildAssetMapping + this.#allowlistMarkets, + this.#blocklistMarkets, + this.#priceDeviationLimit, + async () => { + await this.#ensureClientsInitialized(); + const validatedDexs = await this.#getValidatedDexs(); + return validatedDexs.filter((dex): dex is string => dex !== null); + }, + ); + + // NOTE: Clients are NOT initialized here - they'll be initialized lazily + // when first needed. This avoids accessing Engine.context before it's ready. + + // Pre-compile filter patterns for performance (invalid patterns are skipped) + this.#compiledAllowlistPatterns = this.#compilePatternsSafely( + this.#allowlistMarkets, + 'allowlist', + ); + this.#compiledBlocklistPatterns = this.#compilePatternsSafely( + this.#blocklistMarkets, + 'blocklist', + ); + + // Populate initial asset mapping if provided (used for DI in tests) + if (options.initialAssetMapping) { + for (const [symbol, assetId] of options.initialAssetMapping) { + this.#symbolToAssetId.set(symbol, assetId); + } + } + + // Debug: Confirm batch methods exist and show HIP-3 config + this.#deps.debugLogger.log('[HyperLiquidProvider] Constructor complete', { + hasBatchCancel: typeof this.cancelOrders === 'function', + hasBatchClose: typeof this.closePositions === 'function', + protocolId: this.protocolId, + hip3Enabled: this.#hip3Enabled, + allowlistMarkets: this.#allowlistMarkets, + blocklistMarkets: this.#blocklistMarkets, + isTestnet, + }); + } + + /** + * Resolve the provider-owned fee policy for one placement context. + * + * The full context is accepted so future policies can vary by market route + * or other order inputs without changing the provider contract. Runtime + * callers can still bypass TypeScript, so an unknown order type falls back to + * the revenue-preserving standard policy and is made observable. + * + * @param params - Fee quote or placement context. + * @returns HyperLiquid's fee policy for this order. + */ + #resolveOrderFeePolicy( + params: HyperLiquidOrderFeeContext, + ): HyperLiquidOrderFeePolicy { + const fallbackPolicy = Object.prototype.hasOwnProperty.call( + HYPERLIQUID_ORDER_FEE_CONFIG, + params.orderType, + ) + ? HYPERLIQUID_ORDER_FEE_CONFIG[params.orderType] + : undefined; + if (fallbackPolicy) { + return fallbackPolicy; + } + + this.#deps.debugLogger.log( + 'HyperLiquid: Unknown order type used the safe builder-fee policy', + { orderType: params.orderType }, + ); + return HYPERLIQUID_ORDER_FEE_CONFIG.market; + } + + /** + * Return provider-owned strategy support for a routed market. + * HyperLiquid metadata has no per-market strategy flags. This provider + * advertises its implemented strategies uniformly after confirming the + * routed market exists. + * + * @param params - Required market route context. + * @returns Supported strategy order types. + */ + async getOrderCapabilities( + params: GetOrderCapabilitiesParams, + ): Promise { + if (!isValidCapabilitySymbol(params.symbol, { allowProviderRoute: true })) { + return { + status: 'unavailable', + providerId: this.protocolId, + reason: 'invalid_symbol', + }; + } + const { dex } = parseAssetName(params.symbol); + + if (this.#isDisconnected) { + return { + status: 'unavailable', + providerId: this.protocolId, + reason: 'provider_unavailable', + }; + } + + const lifecycleGeneration = this.#lifecycleGeneration; + + try { + const marketExists = ( + await this.#getFreshOrderCapabilityMarkets(dex) + ).some((market) => market.name === params.symbol); + this.#assertProviderLifecycleCurrent( + lifecycleGeneration, + 'Order capability read', + ); + return marketExists + ? HYPERLIQUID_ORDER_CAPABILITIES + : { + status: 'unavailable', + providerId: this.protocolId, + reason: 'market_not_found', + }; + } catch (error) { + this.#deps.debugLogger.log( + 'HyperLiquid: Order capabilities unavailable', + { + symbol: params.symbol, + error: ensureError(error, 'HyperLiquidProvider.getOrderCapabilities') + .message, + }, + ); + return { + status: 'unavailable', + providerId: this.protocolId, + reason: 'provider_unavailable', + }; + } + } + + /** + * Reject provider work that crossed an asynchronous disconnect boundary. + * + * @param generation - Provider lifecycle generation captured before work. + * @param operation - Operation name included in the cancellation error. + */ + #assertProviderLifecycleCurrent(generation: number, operation: string): void { + if (this.#isDisconnected || generation !== this.#lifecycleGeneration) { + this.#deps.debugLogger.log( + 'HyperLiquid: Provider operation became stale during disconnect', + { operation }, + ); + throw new Error(PERPS_ERROR_CODES.PROVIDER_LIFECYCLE_STALE); + } + } + + /** + * Reject cache writes from an old lifecycle or while teardown is active. + * General reads may lazily rebuild caches after disconnect completes, but a + * request that crossed the disconnect boundary must not repopulate them. + * + * @param generation - Provider lifecycle generation captured before work. + * @param operation - Operation name included in the cancellation log. + */ + #assertCacheWriteLifecycleCurrent( + generation: number, + operation: string, + ): void { + if (!this.#isCacheWriteLifecycleCurrent(generation, operation)) { + throw new Error(PERPS_ERROR_CODES.PROVIDER_LIFECYCLE_STALE); + } + } + + /** + * Check whether fetched data may still populate provider caches. + * + * @param generation - Provider lifecycle generation captured before work. + * @param operation - Operation name included in the cancellation log. + * @returns Whether a cache write is safe. + */ + #isCacheWriteLifecycleCurrent( + generation: number, + operation: string, + ): boolean { + if ( + this.#disconnectOperationsInFlight > 0 || + generation !== this.#lifecycleGeneration + ) { + this.#deps.debugLogger.log( + 'HyperLiquid: Cache write became stale during disconnect', + { operation }, + ); + return false; + } + return true; + } + + /** + * Return filtered market metadata fresh enough for a capability query. + * Each DEX shares one refresh across concurrent callers, and a failed + * refresh remains immediately retryable. + * + * @param dex - Routed DEX, or null for the main DEX. + * @returns Fresh filtered markets for that route. + */ + async #getFreshOrderCapabilityMarkets( + dex: string | null, + ): Promise { + const lifecycleGeneration = this.#lifecycleGeneration; + const cachedMarkets = this.#orderCapabilitiesMarketsByDex.get(dex); + if ( + cachedMarkets && + Date.now() - cachedMarkets.freshAt < + PERFORMANCE_CONFIG.OrderCapabilitiesMetaFreshnessMs + ) { + return cachedMarkets.markets; + } + + let refreshPromise = this.#orderCapabilitiesRefreshByDex.get(dex); + if (!refreshPromise) { + refreshPromise = (async (): Promise => { + const markets = await this.#fetchMarketsForDex({ + dex, + skipCache: true, + }); + this.#assertProviderLifecycleCurrent( + lifecycleGeneration, + 'Order capability metadata refresh', + ); + + this.#orderCapabilitiesMarketsByDex.set(dex, { + markets, + freshAt: Date.now(), + }); + return markets; + })(); + this.#orderCapabilitiesRefreshByDex.set(dex, refreshPromise); + } + + try { + const markets = await refreshPromise; + this.#assertProviderLifecycleCurrent( + lifecycleGeneration, + 'Order capability read', + ); + return markets; + } finally { + if (this.#orderCapabilitiesRefreshByDex.get(dex) === refreshPromise) { + this.#orderCapabilitiesRefreshByDex.delete(dex); + } + } + } + + /** + * Compile market patterns safely, skipping any that fail validation. + * Prevents a single bad pattern from crashing the entire constructor. + * + * @param patterns - The array of patterns to validate. + * @param listName - The name of the list for logging context. + * @returns The result of the operation. + */ + #compilePatternsSafely( + patterns: string[], + listName: string, + ): CompiledMarketPattern[] { + const compiled: CompiledMarketPattern[] = []; + for (const pattern of patterns) { + try { + compiled.push({ pattern, matcher: compileMarketPattern(pattern) }); + } catch (error) { + this.#deps.logger.error( + ensureError(error, `HyperLiquidProvider.compilePatternsSafely`), + this.#getErrorContext('compilePatternsSafely', { listName, pattern }), + ); + } + } + return compiled; + } + + /** + * Initialize HyperLiquid SDK clients (lazy initialization) + * + * This is called on first API operation to ensure Engine.context is ready. + * Creating the wallet adapter requires accessing Engine.context.AccountTreeController, + * which may not be available during early app initialization. + * + * IMPORTANT: This method awaits the WebSocket transport.ready() to ensure + * the connection is fully established before marking initialization complete. + */ + async #ensureClientsInitialized(): Promise { + if (this.#disconnectOperationsInFlight > 0) { + throw new Error(PERPS_ERROR_CODES.PROVIDER_LIFECYCLE_STALE); + } + + if (this.#clientsInitialized) { + return; // Already initialized + } + + // Reuse existing initialization promise if one is in progress + // This prevents race conditions when multiple methods call concurrently + if (this.#initializationPromise) { + await this.#initializationPromise; + if (this.#disconnectOperationsInFlight > 0) { + throw new Error(PERPS_ERROR_CODES.PROVIDER_LIFECYCLE_STALE); + } + return; + } + + // Create and cache the initialization promise + this.#initializationPromise = (async (): Promise => { + // Double-check after acquiring the "lock" + if (this.#clientsInitialized) { + return; + } + if (this.#disconnectOperationsInFlight > 0) { + throw new Error(PERPS_ERROR_CODES.PROVIDER_LIFECYCLE_STALE); + } + + const wallet = this.#walletService.createWalletAdapter(); + await this.#clientService.initialize(wallet); + if (this.#disconnectOperationsInFlight > 0) { + throw new Error(PERPS_ERROR_CODES.PROVIDER_LIFECYCLE_STALE); + } + + // Set termination callback for logging when WebSocket terminates + // Note: Do NOT restore subscriptions here - termination means connection failed permanently + this.#clientService.setOnTerminateCallback((error: Error) => { + this.#deps.debugLogger.log( + '[HyperLiquidProvider] WebSocket terminated', + { + error: error.message, + }, + ); + }); + + // Set reconnection callback to restore subscriptions after successful reconnection + // This is called in handleConnectionDrop() after the WebSocket reconnects successfully + this.#clientService.setOnReconnectCallback(async () => { + try { + this.#deps.debugLogger.log( + '[HyperLiquidProvider] WebSocket reconnected, restoring subscriptions', + ); + await this.#subscriptionService.restoreSubscriptions(); + this.#deps.streamManager.clearAllChannels(); + } catch (restoreError) { + this.#deps.debugLogger.log( + '[HyperLiquidProvider] Failed to restore subscriptions', + restoreError, + ); + } + }); + + // Only set flag AFTER successful initialization + this.#clientsInitialized = true; + + this.#deps.debugLogger.log( + '[HyperLiquidProvider] Clients initialized lazily', + ); + })(); + + try { + await this.#initializationPromise; + } finally { + // Clear promise after completion (success or failure) + // so future calls can retry if needed + this.#initializationPromise = null; + } + } + + /** + * Decide whether the wallet has a Hyperliquid account. + * + * Hyperliquid accounts are created server-side on first USDC deposit. + * Before that, every user-scoped exchange write rejects with + * "User or API Wallet 0x... does not exist." — formerly the top source + * of `feature:perps` Sentry events (Sentry issues METAMASK-MOBILE-4XB5 + * iOS / 4Q4M Android: ~530k events / ~100k users in 14d on 7.75.1). + * + * Probes `infoClient.userNonFundingLedgerUpdates` and caches a positive + * result in `PerpsSigningCache.walletRegistered`. Negative results are NOT + * cached — the wallet may deposit between checks; the next entry must + * re-probe. The probe is cheap (~100ms), non-throwing, and returns the + * full deposit/withdraw history. A non-empty array means the wallet has + * interacted with Hyperliquid at least once — necessary and sufficient + * for `agentSetAbstraction` / `userSetAbstraction` / `setReferrer` to + * succeed. + * + * If the probe itself throws (transient network), returns `true` and does + * not cache — fail open so one bad probe never traps a real Hyperliquid + * user in the deferred state. + * + * @param userAddress - The wallet address to check. + * @param network - The network environment (mainnet | testnet). + * @returns True if the wallet has been observed on Hyperliquid OR if + * the probe was inconclusive (fail open). False only when the probe + * succeeded AND returned an empty ledger. + * @private + */ + async #isWalletOnHyperliquid( + userAddress: string, + network: 'mainnet' | 'testnet', + ): Promise { + const cached = PerpsSigningCache.getWalletRegistered(network, userAddress); + if (cached?.registered) { + return true; + } + + try { + const infoClient = this.#clientService.getInfoClient(); + const ledger = await infoClient.userNonFundingLedgerUpdates({ + user: userAddress, + startTime: 0, + }); + const registered = Array.isArray(ledger) && ledger.length > 0; + if (registered) { + PerpsSigningCache.setWalletRegistered(network, userAddress, true); + } + return registered; + } catch (error) { + // Fail open. A transient probe failure must never prevent a + // legitimate Hyperliquid user from completing migration / referral + // setup. The next entry will re-probe. + this.#deps.debugLogger.log( + '[isWalletOnHyperliquid] Probe failed, assuming registered', + { + network, + user: userAddress, + error: ensureError(error, 'HyperLiquidProvider.isWalletOnHyperliquid') + .message, + }, + ); + return true; + } + } + + /** + * Decide whether the Hyperliquid account is a multi-sig account. + * + * Hyperliquid rejects every single-signer exchange write for a converted + * multi-sig account with `ApiRequestError: Multi-sig required`, so the + * unified-account migration must not be attempted for those accounts. + * + * If the probe throws (transient network), returns `false` — fail open so + * one bad probe never blocks migration for a normal single-signer account. + * The `isHyperLiquidMultiSigRequiredError` fallback in the write's catch + * block remains the safety net. + * + * @param userAddress - The wallet address to check. + * @returns True only when Hyperliquid reports a multi-sig signer set. + * @private + */ + async #isHyperliquidMultiSigAccount(userAddress: string): Promise { + try { + const infoClient = this.#clientService.getInfoClient(); + const signers = await infoClient.userToMultiSigSigners({ + user: userAddress, + }); + return signers !== null && signers !== undefined; + } catch (error) { + this.#deps.debugLogger.log( + '[isHyperliquidMultiSigAccount] Probe failed, assuming single-signer', + { + user: userAddress, + error: ensureError( + error, + 'HyperLiquidProvider.isHyperliquidMultiSigAccount', + ).message, + }, + ); + return false; + } + } + + /** + * Attempt to enable HyperLiquid Unified Account mode for HIP-3 orders + * + * If successful, HyperLiquid automatically manages collateral transfers for HIP-3 orders. + * If not supported, disables the flag to trigger programmatic transfer fallback. + * + * IMPORTANT: Uses global singleton cache to prevent repeated signing requests + * across provider reconnections (critical for hardware wallets). + * + * @param options - Optional configuration. + * @param options.allowUserSigning - When true, runs the EIP-712 user-signed migration for `dexAbstraction` accounts. Defaults to false so init does not surface a signing prompt; action-time entry points (trading, withdraw) pass true. + * @private + */ + async #ensureUnifiedAccountEnabled(options?: { + allowUserSigning?: boolean; + }): Promise { + // dexAbstraction → unifiedAccount requires an EIP-712 prompt (HL blocks + // the agent path for that transition). Init calls with allowUserSigning=false so + // viewing the Perps section never surfaces a signing dialog. Trading and + // withdraw entry points pass allowUserSigning=true to drive the migration when + // the user actually intends to act. + const allowUserSigning = options?.allowUserSigning ?? false; + + // Optimistic reset — set true below only at the failure points that + // warrant retry (silent agent failure, REST lookup failure, keyring + // locked). Final-state outcomes (success, prompted-failure cached, + // already-on-compatible, defer, unknown mode, feature off) leave it + // false so #ensureReady can keep the memoized promise. + this.#unifiedAccountSetupNeedsRetry = false; + + if (!this.#useUnifiedAccount) { + return; // Feature disabled + } + + const userAddress = await this.#walletService.getUserAddressWithDefault(); + const network = this.#clientService.isTestnetMode() ? 'testnet' : 'mainnet'; + + // Check global cache first to avoid repeated signing requests + // This is CRITICAL for hardware wallets to prevent repeated signing prompts + // while browsing. + const cachedStatus = TradingReadinessCache.get(network, userAddress); + if (cachedStatus?.attempted) { + this.#deps.debugLogger.log( + 'HyperLiquidProvider: Unified Account setup already attempted (from global cache)', + { + user: userAddress, + network, + enabled: cachedStatus.enabled, + note: 'Skipping to prevent repeated signing requests', + }, + ); + return; + } + + // Check if another provider instance is currently attempting this operation + // This prevents concurrent signing attempts across providers during reconnection + const inFlightPromise = PerpsSigningCache.isInFlight( + 'unifiedAccount', + network, + userAddress, + ); + if (inFlightPromise) { + this.#deps.debugLogger.log( + 'HyperLiquidProvider: Unified Account setup in-flight, waiting...', + { network, userAddress }, + ); + await inFlightPromise; + // The other instance may have finished without writing the cache (e.g. + // an init-time call deferred a dexAbstraction migration). If the cache + // is still empty and we are an action-time caller (allowUserSigning=true), + // we must run our own attempt — otherwise the trade/withdraw would + // proceed in the deprecated mode. + const postWaitCache = TradingReadinessCache.get(network, userAddress); + if (postWaitCache?.attempted) { + return; + } + // Fall through to acquire our own lock and retry. + } + + // Set in-flight lock to prevent concurrent attempts + const completeInFlight = PerpsSigningCache.setInFlight( + 'unifiedAccount', + network, + userAddress, + ); + + let currentMode: UserAbstractionResponse | undefined; + + try { + // Re-check cache after acquiring lock (another provider might have finished) + const recheckCache = TradingReadinessCache.get(network, userAddress); + if (recheckCache?.attempted) { + this.#deps.debugLogger.log( + 'HyperLiquidProvider: Unified Account setup completed by another provider', + { network, userAddress }, + ); + completeInFlight(); + return; + } + + // Skip the migration entirely for wallets that have no Hyperliquid + // account yet. HL creates accounts server-side on first USDC deposit; + // before that, both `agentSetAbstraction` and `userSetAbstraction` + // reject with "User or API Wallet 0x... does not exist." — formerly + // the top source of `feature:perps` Sentry events on 7.75.1. + // The probe is cheap, non-throwing, and cached. + const isRegistered = await this.#isWalletOnHyperliquid( + userAddress, + network, + ); + if (!isRegistered) { + this.#deps.debugLogger.log( + '[ensureUnifiedAccountEnabled] Wallet not yet on Hyperliquid, deferring migration', + { user: userAddress, network }, + ); + this.#deps.metrics.trackPerpsEvent(PerpsAnalyticsEvent.AccountSetup, { + [PERPS_EVENT_PROPERTY.STATUS]: + PERPS_EVENT_VALUE.STATUS.NOT_APPLICABLE, + [PERPS_EVENT_PROPERTY.ERROR_MESSAGE]: 'no_hl_account', + }); + // Signal #ensureReady to drop its memoized promise so the next entry + // re-probes. Without this, the resolved promise would be reused and + // the wallet would stay permanently deferred until reconnect. + this.#unifiedAccountSetupNeedsRetry = true; + completeInFlight(); + return; + } + + const infoClient = this.#clientService.getInfoClient(); + + // Check current abstraction mode on-chain + currentMode = await infoClient.userAbstraction({ + user: userAddress, + }); + + if ( + currentMode === 'unifiedAccount' || + currentMode === 'portfolioMargin' + ) { + // portfolioMargin is a superset of unifiedAccount — it already supports + // auto-collateral management for HIP-3 orders and is more capital-efficient. + // Downgrading portfolio margin users to unifiedAccount would be harmful, + // so we treat both modes as already-enabled and skip migration. + this.#deps.debugLogger.log( + 'HyperLiquidProvider: Account already in a compatible mode, skipping migration', + { user: userAddress, network, mode: currentMode }, + ); + this.#deps.metrics.trackPerpsEvent(PerpsAnalyticsEvent.AccountSetup, { + [PERPS_EVENT_PROPERTY.ABSTRACTION_MODE]: currentMode, + [PERPS_EVENT_PROPERTY.STATUS]: + PERPS_EVENT_VALUE.STATUS.ALREADY_ENABLED, + }); + TradingReadinessCache.set(network, userAddress, { + attempted: true, + enabled: true, + }); + // Record the resolved mode in the subscription service so the next + // aggregation folds spot correctly without waiting for #refreshSpotState. + this.#subscriptionService.setUserAbstractionMode( + userAddress, + currentMode, + ); + completeInFlight(); + return; + } + + // Defer signing-backed transitions until the user attempts an action. + // Cache is intentionally left untouched so the next entry re-evaluates; + // the read-only userAbstraction call is cheap and gated by the in-flight + // lock, preventing concurrent prompts. + if (shouldDeferUnifiedAccountSetup(currentMode, allowUserSigning)) { + this.#deps.debugLogger.log( + 'HyperLiquidProvider: Deferring unified account migration to action time', + { user: userAddress, network, mode: currentMode }, + ); + completeInFlight(); + return; + } + + // Bail on unknown modes BEFORE firing analytics or attempting dispatch. + // Keeps `migration_required` actionable (only fires for modes we can + // actually migrate) and avoids re-emitting on every reconnection. + if ( + currentMode !== 'dexAbstraction' && + currentMode !== 'default' && + currentMode !== 'disabled' + ) { + this.#deps.debugLogger.log( + 'HyperLiquidProvider: Unknown abstraction mode, skipping Unified Account migration', + { user: userAddress, network, mode: currentMode }, + ); + completeInFlight(); + return; + } + + // Hyperliquid rejects every single-signer exchange write for a converted + // multi-sig account with "ApiRequestError: Multi-sig required", which + // surfaced on the Perps tab on every entry (TAT-3214). Probe right + // before the write so accounts that never reach one (already compatible, + // deferred, unknown mode) do not pay the extra round trip. + const isMultiSig = await this.#isHyperliquidMultiSigAccount(userAddress); + if (isMultiSig) { + this.#deps.debugLogger.log( + '[ensureUnifiedAccountEnabled] Multi-sig account, skipping unified account migration', + { user: userAddress, network, mode: currentMode }, + ); + this.#deps.metrics.trackPerpsEvent(PerpsAnalyticsEvent.AccountSetup, { + [PERPS_EVENT_PROPERTY.PREVIOUS_ABSTRACTION_MODE]: currentMode, + [PERPS_EVENT_PROPERTY.STATUS]: + PERPS_EVENT_VALUE.STATUS.NOT_APPLICABLE, + [PERPS_EVENT_PROPERTY.ERROR_MESSAGE]: 'multi_sig_account', + }); + // Final state: the write can never succeed for this account, so cache + // it as attempted with unified mode off. Perps keeps working through + // the programmatic collateral-transfer fallback. + TradingReadinessCache.set(network, userAddress, { + attempted: true, + enabled: false, + }); + completeInFlight(); + return; + } + + // Track which mode users are currently on before we attempt migration. + // This tells us the distribution of legacy modes across our user base. + this.#deps.metrics.trackPerpsEvent(PerpsAnalyticsEvent.AccountSetup, { + [PERPS_EVENT_PROPERTY.ABSTRACTION_MODE]: currentMode, + [PERPS_EVENT_PROPERTY.STATUS]: + PERPS_EVENT_VALUE.STATUS.MIGRATION_REQUIRED, + }); + + // Enable Unified Account mode. + // - default / disabled: agent wallet can do this silently (no prompt) + // - dexAbstraction: HL blocks the agent transition — requires the user's main + // wallet to sign an EIP-712 action via userSetAbstraction (one-time prompt) + this.#deps.debugLogger.log( + 'HyperLiquidProvider: Enabling Unified Account mode', + { + user: userAddress, + network, + previousMode: currentMode, + note: 'HyperLiquid will auto-manage collateral for HIP-3 orders', + }, + ); + + const exchangeClient = this.#clientService.getExchangeClient(); + if (currentMode === 'dexAbstraction') { + // Requires EIP-712 signature from the user's main wallet (one-time migration). + // HL blocks the dexAbstraction → unifiedAccount transition via the agent wallet, + // so userSetAbstraction (user-signed) is the only path for legacy users. + await exchangeClient.userSetAbstraction({ + user: userAddress, + abstraction: HL_UNIFIED_ACCOUNT_MODE, + }); + } else { + // default / disabled — silent agent transition, no user prompt + await exchangeClient.agentSetAbstraction({ + abstraction: HL_ABSTRACTION_WIRE.unifiedAccount, + }); + } + + this.#deps.debugLogger.log( + '✅ HyperLiquidProvider: Unified Account enabled successfully', + ); + this.#deps.metrics.trackPerpsEvent(PerpsAnalyticsEvent.AccountSetup, { + [PERPS_EVENT_PROPERTY.PREVIOUS_ABSTRACTION_MODE]: currentMode, + [PERPS_EVENT_PROPERTY.ABSTRACTION_MODE]: HL_UNIFIED_ACCOUNT_MODE, + [PERPS_EVENT_PROPERTY.STATUS]: PERPS_EVENT_VALUE.STATUS.SUCCESS, + }); + TradingReadinessCache.set(network, userAddress, { + attempted: true, + enabled: true, + }); + // Record the post-migration mode in the subscription service so it + // immediately re-aggregates with fold=true and surfaces the unified + // balance rather than waiting for the next #refreshSpotState. + this.#subscriptionService.setUserAbstractionMode( + userAddress, + HL_UNIFIED_ACCOUNT_MODE, + ); + completeInFlight(); + } catch (error) { + // HyperLiquid wraps wallet signing failures and preserves KEYRING_LOCKED + // in `cause`, so classify the full chain and leave retry caches empty. + if (isKeyringLockedError(error)) { + this.#deps.debugLogger.log( + '[ensureUnifiedAccountEnabled] Keyring locked, will retry later', + ); + this.#unifiedAccountSetupNeedsRetry = true; + completeInFlight(); + return; + } + + // Safety net: a Hyperliquid "user does not exist" rejection slipped + // past the proactive probe (race with deposit confirmation, transient + // probe failure that failed open, ...). Treat as benign — do NOT + // forward to Sentry. The walletRegistered cache stores positive + // observations only, so no demotion is needed; the next entry will + // re-probe. + if (isHyperLiquidUserNotFoundError(error)) { + this.#deps.debugLogger.log( + '[ensureUnifiedAccountEnabled] Wallet not on Hyperliquid (race/stale-cache), deferring migration', + { user: userAddress, network, currentMode }, + ); + this.#deps.metrics.trackPerpsEvent(PerpsAnalyticsEvent.AccountSetup, { + ...(currentMode && { + [PERPS_EVENT_PROPERTY.PREVIOUS_ABSTRACTION_MODE]: currentMode, + [PERPS_EVENT_PROPERTY.ABSTRACTION_MODE]: HL_UNIFIED_ACCOUNT_MODE, + }), + [PERPS_EVENT_PROPERTY.STATUS]: + PERPS_EVENT_VALUE.STATUS.NOT_APPLICABLE, + [PERPS_EVENT_PROPERTY.ERROR_MESSAGE]: 'no_hl_account', + }); + this.#unifiedAccountSetupNeedsRetry = true; + completeInFlight(); + return; + } + + // Safety net for the multi-sig probe: the account can be converted + // between the lookup and the write, and the probe fails open on + // transient info-API errors. Either way the rejection is a permanent + // account-shape condition, not a failure worth reporting or retrying. + if (isHyperLiquidMultiSigRequiredError(error)) { + this.#deps.debugLogger.log( + '[ensureUnifiedAccountEnabled] Multi-sig account (race/probe fallback), skipping unified account migration', + { user: userAddress, network, mode: currentMode }, + ); + this.#deps.metrics.trackPerpsEvent(PerpsAnalyticsEvent.AccountSetup, { + ...(currentMode && { + [PERPS_EVENT_PROPERTY.PREVIOUS_ABSTRACTION_MODE]: currentMode, + }), + [PERPS_EVENT_PROPERTY.STATUS]: + PERPS_EVENT_VALUE.STATUS.NOT_APPLICABLE, + [PERPS_EVENT_PROPERTY.ERROR_MESSAGE]: 'multi_sig_account', + }); + TradingReadinessCache.set(network, userAddress, { + attempted: true, + enabled: false, + }); + completeInFlight(); + return; + } + + // Cache failure ONLY for the user-prompted path + // (`dexAbstraction → unifiedAccount` via `userSetAbstraction`). The + // rationale for caching is "don't re-prompt a user who already saw the + // signature dialog and rejected it" — that doesn't apply to: + // - Read-only userAbstraction lookup failures (no prompt; transient). + // - Silent agent-key paths (`default`/`disabled` → `agentSetAbstraction` + // does not show a UI prompt; failures are typically transient HL + // outages and pinning them would leave users stuck in the + // deprecated mode for the rest of the session). + // Action-time retries pick up the unmigrated state and try again. + if (currentMode === 'dexAbstraction') { + TradingReadinessCache.set(network, userAddress, { + attempted: true, + enabled: false, + }); + } else { + // Silent agent-key failure (default/disabled) or read-only + // userAbstraction lookup failure — neither is a final state, so + // signal #ensureReady to drop its memoized promise and retry on + // the next entry instead of pinning the user in the deprecated + // mode for the provider's lifetime. + this.#unifiedAccountSetupNeedsRetry = true; + } + + const errorMessage = ensureError( + error, + 'HyperLiquidProvider.ensureUnifiedAccountEnabled', + ).message; + + this.#deps.debugLogger.log( + 'HyperLiquidProvider: Unified Account setup failed', + { + user: userAddress, + network, + error: errorMessage, + // Cache writes only happen on the user-prompted dexAbstraction + // path (see P2-B logic above). Reflect that here so retry + // behaviour is debuggable from the log alone. + cached: currentMode === 'dexAbstraction', + }, + ); + + this.#deps.metrics.trackPerpsEvent(PerpsAnalyticsEvent.AccountSetup, { + ...(currentMode && { + [PERPS_EVENT_PROPERTY.PREVIOUS_ABSTRACTION_MODE]: currentMode, + [PERPS_EVENT_PROPERTY.ABSTRACTION_MODE]: HL_UNIFIED_ACCOUNT_MODE, + }), + [PERPS_EVENT_PROPERTY.STATUS]: PERPS_EVENT_VALUE.STATUS.FAILED, + [PERPS_EVENT_PROPERTY.ERROR_MESSAGE]: errorMessage, + }); + + completeInFlight(); + + this.#deps.logger.error( + ensureError(error, 'HyperLiquidProvider.ensureUnifiedAccountEnabled'), + this.#getErrorContext('ensureUnifiedAccountEnabled', { + note: 'Could not enable Unified Account (user rejected, or network error)', + }), + ); + } + } + + /** + * Ensure clients are initialized and asset mapping is loaded + * Asset mapping is built once on first call and reused for the provider's lifetime + * since HIP-3 configuration is immutable after construction + */ + async #ensureReady(): Promise { + // If already initializing or completed, wait for/return that promise + // This prevents duplicate initialization flows when multiple methods called concurrently + if (this.#ensureReadyPromise) { + this.#deps.debugLogger.log( + '[ensureReady] Reusing existing initialization promise', + ); + await this.#ensureReadyPromise; + return; + } + + this.#deps.debugLogger.log('[ensureReady] Starting new initialization'); + + // Create and track initialization promise + this.#ensureReadyPromise = (async (): Promise => { + // Lazy initialization: ensure clients are created (safe after Engine.context is ready) + // This awaits WebSocket transport.ready() to ensure connection is established + await this.#ensureClientsInitialized(); + + // Verify clients are properly initialized + this.#clientService.ensureInitialized(); + + // Build asset mapping on first call, or retry if DEX discovery previously failed + if (this.#symbolToAssetId.size === 0 || !this.#dexDiscoveryComplete) { + this.#deps.debugLogger.log( + 'HyperLiquidProvider: Building asset mapping', + { + hip3Enabled: this.#hip3Enabled, + allowlistMarkets: this.#allowlistMarkets, + blocklistMarkets: this.#blocklistMarkets, + }, + ); + await this.#buildAssetMapping(); + } + + // Attempt Unified Account migration as early as possible so users aren't + // blocked when they try to trade. Software wallets can complete the + // signing-backed migration during initial setup so the first trade sees + // the unified balance. Hardware wallets remain deferred to action time to + // avoid repeated signing prompts while browsing. + await this.#ensureUnifiedAccountEnabled({ + allowUserSigning: !this.#walletService.isSelectedHardwareWallet(), + }); + })(); + + // Await initialization - keep the promise so subsequent calls resolve immediately + // The promise is only reset in disconnect() for clean reconnection, + // or when DEX discovery was degraded so the next caller retries. + try { + await this.#ensureReadyPromise; + } catch (error) { + this.#ensureReadyPromise = null; + throw error; + } + if (!this.#dexDiscoveryComplete) { + // DEX discovery failed transiently — reset so next call retries. + // Trading still works (main DEX mapping is populated), but HIP-3 markets + // will be re-discovered on the next #ensureReady() call. + this.#ensureReadyPromise = null; + } else if (this.#unifiedAccountSetupNeedsRetry) { + // Silent migration / lookup / keyring-locked failure left the cache + // empty. Without resetting the memoized promise, subsequent + // #ensureReady calls would skip retry and the user would be stuck + // in the deprecated mode for the provider's lifetime. + this.#ensureReadyPromise = null; + } + this.#deps.debugLogger.log('[ensureReady] Initialization complete'); + } + + /** + * Ensure provider is ready for TRADING operations (signing required) + * + * This method performs shared setup that may require user signatures: + * - DEX abstraction enablement (for HIP-3 auto-transfers) + * - Referral code setup (attribution) + * - Builder fee approval when the whole action carries a builder context + * + * These operations are DEFERRED from ensureReady() to avoid hardware wallet prompt spam + * when users are just viewing the Perps section (critical for hardware wallets). + * + * General trading readiness is independent of builder approval. Native TWAP + * and cancellations call this with `requiresBuilderFee: false`. + */ + #tradingSetupPromise: Promise | null = null; + + #tradingSetupComplete = false; + + readonly #builderFeeSetupPromises = new Map>(); + + /** + * Approve the standard builder fee once for the current account and network. + * TWAP never calls this because its native action has no builder field. + * Without an approval failure code, approval remains non-blocking and the + * returned context describes attribution only; it does not prove approval. + * + * @param approvalFailureCode - Operation-specific error to throw when + * approval is unavailable or fails. + * @returns The account, network, and configured builder for the action. + */ + async #ensureBuilderFeeSetup( + approvalFailureCode?: PerpsErrorCode, + ): Promise { + const isTestnet = this.#clientService.isTestnetMode(); + const network = isTestnet ? 'testnet' : 'mainnet'; + const userAddress = await this.#walletService.getUserAddressWithDefault(); + const cacheKey = this.#getCacheKey(network, userAddress); + const builderAddress = this.#getBuilderAddress(isTestnet); + const context: BuilderFeeSetupContext = { + network, + userAddress, + builderAddress, + }; + const setupKey = this.#getApprovedBuilderKey( + network, + userAddress, + builderAddress, + ); + if (this.#builderFeeCheckCache.has(cacheKey)) { + return context; + } + + let pendingApproval = this.#builderFeeSetupPromises.get(setupKey); + if (!pendingApproval) { + pendingApproval = this.#ensureBuilderFeeApproval(context); + this.#builderFeeSetupPromises.set(setupKey, pendingApproval); + } + + try { + await pendingApproval; + } catch (error) { + this.#deps.debugLogger.log( + '[ensureBuilderFeeSetup] Builder fee approval failed', + error, + ); + if (approvalFailureCode) { + throw new Error(approvalFailureCode); + } + } finally { + if (this.#builderFeeSetupPromises.get(setupKey) === pendingApproval) { + this.#builderFeeSetupPromises.delete(setupKey); + } + } + + if (approvalFailureCode && !this.#builderFeeCheckCache.has(cacheKey)) { + throw new Error(approvalFailureCode); + } + + return context; + } + + #ensureReadyForTrading(options: { + requiresBuilderFee: true; + builderFeeApprovalFailureCode?: PerpsErrorCode; + }): Promise; + + #ensureReadyForTrading(options: { + requiresBuilderFee: false; + builderFeeApprovalFailureCode?: PerpsErrorCode; + }): Promise; + + #ensureReadyForTrading(options: { + requiresBuilderFee: boolean; + builderFeeApprovalFailureCode?: PerpsErrorCode; + }): Promise; + + async #ensureReadyForTrading(options: { + requiresBuilderFee: boolean; + builderFeeApprovalFailureCode?: PerpsErrorCode; + }): Promise { + // First ensure basic initialization is complete + await this.#ensureReady(); + + // dexAbstraction users were deferred during init to avoid an EIP-712 prompt + // on Perps section open. Drive the migration here, gated by its own cache so + // already-migrated or already-rejected users are not re-prompted. + await this.#ensureUnifiedAccountEnabled({ allowUserSigning: true }); + + if (!this.#tradingSetupComplete && !this.#tradingSetupPromise) { + const lifecycleGeneration = this.#lifecycleGeneration; + this.#deps.debugLogger.log( + '[ensureReadyForTrading] Starting shared trading setup', + ); + + this.#tradingSetupPromise = (async (): Promise => { + // Pre-fetch spotMeta for HIP-3 operations (non-blocking if it fails) + // This ensures token info (e.g. USDC token index) is available during order placement + if (this.#hip3Enabled) { + try { + await this.#getCachedSpotMeta(); + } catch (error) { + this.#deps.debugLogger.log( + '[ensureReadyForTrading] spotMeta pre-fetch failed, will retry when needed', + error, + ); + // Don't throw - spotMeta will be fetched on-demand if needed + } + } + + // Set up referral code independently from builder-fee applicability. + await this.#ensureReferralSet(); + + this.#assertProviderLifecycleCurrent( + lifecycleGeneration, + 'Trading setup completion', + ); + + // Only mark complete if keyring was unlocked (signing could actually happen) + if (this.#walletService.isKeyringUnlocked()) { + this.#tradingSetupComplete = true; + } + })(); + } + + const pendingSetup = this.#tradingSetupPromise; + if (pendingSetup) { + try { + await pendingSetup; + } finally { + if (this.#tradingSetupPromise === pendingSetup) { + this.#tradingSetupPromise = null; + } + } + } + + const builderFeeSetupContext = options.requiresBuilderFee + ? await this.#ensureBuilderFeeSetup(options.builderFeeApprovalFailureCode) + : undefined; + + this.#deps.debugLogger.log( + '[ensureReadyForTrading] Trading setup complete', + ); + + return builderFeeSetupContext; + } + + /** + * Get current price for a symbol using WebSocket cache first, REST API fallback + * Centralizes the price fetching pattern used across multiple methods + * + * @param params - Parameters for fetching price + * @param params.symbol - The symbol to get price for + * @param params.dexName - Optional DEX name for REST API fallback + * @returns The current price as a number + * @throws Error if no price is available + */ + async #getOrFetchPrice(params: GetOrFetchPriceParams): Promise { + const { symbol, dexName } = params; + + // OPTIMIZATION: Use WebSocket price cache first (0 weight), fall back to REST (2 weight) + const cachedPrice = this.#subscriptionService.getCachedPrice(symbol); + + if (cachedPrice) { + const price = parseFloat(cachedPrice); + // Validate cached price: must be positive and finite + // Covers zero, negative, NaN, and Infinity in one check + if (price <= 0 || !isFinite(price)) { + this.#deps.debugLogger.log( + 'WebSocket cached price invalid for getOrFetchPrice, falling back to REST', + { symbol, cachedPrice, parsedPrice: price }, + ); + // Fall through to REST API fallback + } else { + this.#deps.debugLogger.log('Using WebSocket cached price', { + symbol, + price, + }); + return price; + } + } + + // Fallback to REST API if cache miss + this.#deps.debugLogger.log( + 'Price cache miss for getOrFetchPrice, falling back to REST allMids', + { symbol }, + ); + const infoClient = this.#clientService.getInfoClient({ useHttp: true }); + const mids = await infoClient.allMids( + dexName ? { dex: dexName } : undefined, + ); + const price = parseFloat(mids[symbol] || '0'); + + // Validate REST price: must be positive and finite + if (price <= 0 || !isFinite(price)) { + throw new Error(`Invalid price for ${symbol}: ${price}`); + } + + return price; + } + + /** + * Get fills using WebSocket cache first, falling back to REST API + * OPTIMIZATION: Uses cached fills when available (0 API weight), only calls REST on cache miss + * + * Cache limitation: WebSocket cache is limited to ~100 most recent fills. + * For historical data (e.g., position-opening fills from months ago), use getOrderFills directly. + * + * @param params - Optional filter parameters (startTime, symbol) + * @returns Array of order fills + */ + public async getOrFetchFills( + params?: GetOrFetchFillsParams, + ): Promise { + // Check WebSocket cache first (0 API weight) + const cachedFills = this.#subscriptionService.getFillsCacheIfInitialized(); + + if (cachedFills !== null) { + this.#deps.debugLogger.log('Using WebSocket cached fills', { + count: cachedFills.length, + params, + }); + return this.#filterFills(cachedFills, params); + } + + // Fallback to REST API when cache not initialized + this.#deps.debugLogger.log( + 'Fills cache miss for getOrFetchFills, falling back to REST', + { params }, + ); + const restFills = await this.getOrderFills(params); + // Apply symbol filter to REST results for consistent API behavior + // Note: getOrderFills doesn't support symbol filtering natively + return this.#filterFills(restFills, params); + } + + /** + * Filter fills array by optional startTime and symbol parameters + * + * @param fills - Array of fills to filter + * @param params - Optional filter parameters + * @param params.startTime - Start timestamp in milliseconds. + * @param params.symbol - The trading pair symbol. + * @returns Filtered fills array + */ + #filterFills( + fills: OrderFill[], + params?: { startTime?: number; symbol?: string }, + ): OrderFill[] { + if (!params) { + return fills; + } + + return fills.filter((fill) => { + if (params.startTime && fill.timestamp < params.startTime) { + return false; + } + if (params.symbol && fill.symbol !== params.symbol) { + return false; + } + return true; + }); + } + + /** + * Get all available DEXs without allowlist filtering + * Used when skipFilters=true in getMarkets() + * + * @returns Array of all DEX names (null for main DEX, strings for HIP-3 DEXs) + */ + async #getAllAvailableDexs(): Promise<(string | null)[]> { + const lifecycleGeneration = this.#lifecycleGeneration; + // Use unified state if available + if (this.#dexDiscoveryCache.state) { + const availableHip3Dexs: string[] = []; + this.#dexDiscoveryCache.state.raw.forEach((dex) => { + if (dex !== null) { + availableHip3Dexs.push(dex.name); + } + }); + return [null, ...availableHip3Dexs]; + } + + // Fetch fresh from API and update unified state + const infoClient = this.#clientService.getInfoClient(); + try { + const allDexs = await infoClient.perpDexs(); + if (!allDexs || !Array.isArray(allDexs)) { + return [null]; // Fallback to main DEX only + } + + this.#assertCacheWriteLifecycleCurrent( + lifecycleGeneration, + 'DEX discovery cache write', + ); + const state = this.#dexDiscoveryCache.update(allDexs); + const availableHip3Dexs: string[] = []; + state.raw.forEach((dex) => { + if (dex !== null) { + availableHip3Dexs.push(dex.name); + } + }); + return [null, ...availableHip3Dexs]; + } catch (error) { + this.#deps.logger.error( + ensureError(error, 'HyperLiquidProvider.getAllAvailableDexs'), + this.#getErrorContext('getAllAvailableDexs'), + ); + return [null]; // Fallback to main DEX only + } + } + + /** + * Get validated list of DEXs to use based on feature flags and allowlist + * Implements Step 3b from HIP-3-IMPLEMENTATION.md (lines 108-134) + * + * Logic Flow: + * 1. If hip3Enabled === false → Return [null] (main DEX only) + * 2. Fetch available DEXs via SDK: infoClient.perpDexs() + * 3. If enabledDexs is empty [] → Return [null, ...allDiscoveredDexs] (auto-discover) + * 4. Else filter enabledDexs against available DEXs → Return [null, ...validatedDexs] (allowlist) + * + * Invalid DEX names are silently filtered with debugLogger warning. + * + * @returns Array of DEX names to use (null = main DEX, strings = HIP-3 DEXs) + */ + async #getValidatedDexs(): Promise<(string | null)[]> { + // Kill switch: HIP-3 disabled, return main DEX only + // Must check before cache — #getAllAvailableDexs() can populate + // state.validated without the hip3Enabled gate + if (!this.#hip3Enabled) { + return [null]; + } + + // Return cached result if available + if (this.#dexDiscoveryCache.state?.validated) { + return this.#dexDiscoveryCache.state.validated; + } + + // If a fetch is already in progress, reuse the pending promise + // This prevents duplicate perpDexs() API calls from concurrent callers + if (this.#pendingValidatedDexsPromise !== null) { + this.#deps.debugLogger.log( + '[getValidatedDexs] Reusing pending promise for perpDexs fetch', + ); + return this.#pendingValidatedDexsPromise; + } + + // Create and cache the pending promise for deduplication + const pendingPromise = this.#fetchValidatedDexsInternal(); + this.#pendingValidatedDexsPromise = pendingPromise; + + try { + const result = await pendingPromise; + return result; + } finally { + // A stale request must not clear a newer lifecycle's pending request. + if (this.#pendingValidatedDexsPromise === pendingPromise) { + this.#pendingValidatedDexsPromise = null; + } + } + } + + /** + * Internal method that performs the actual perpDexs fetch and caching + * Separated from getValidatedDexs to enable promise deduplication + * + * @returns A promise that resolves to the result. + */ + async #fetchValidatedDexsInternal(): Promise<(string | null)[]> { + const lifecycleGeneration = this.#lifecycleGeneration; + // Kill switch: HIP-3 disabled, return main DEX only + if (!this.#hip3Enabled) { + this.#deps.debugLogger.log( + 'HyperLiquidProvider: HIP-3 disabled via hip3Enabled flag', + ); + const state = this.#dexDiscoveryCache.update([null]); + return state.validated; + } + + // Fetch all available DEXs from HyperLiquid + const infoClient = this.#clientService.getInfoClient(); + let allDexs; + try { + allDexs = await infoClient.perpDexs(); + } catch (error) { + // debugLogger not logger.error: this is a handled transient failure — the app + // recovers to main DEX via return [null]. Sending to Sentry as error() is noise. + this.#deps.debugLogger.log( + '[fetchValidatedDexsInternal] perpDexs() call failed, falling back to main DEX', + { + error: String(error), + ...this.#getErrorContext('getValidatedDexs.perpDexs'), + }, + ); + // Do not cache — transient error, allow retry on next call + return [null]; + } + + // Validate API response + if (!allDexs || !Array.isArray(allDexs)) { + this.#deps.debugLogger.log( + 'HyperLiquidProvider: Failed to fetch DEX list (invalid response), falling back to main DEX only', + { allDexs }, + ); + // Do not cache — may be transient, allow retry on next call + return [null]; + } + + // Atomically update unified state (raw + validated + timestamp) + this.#assertCacheWriteLifecycleCurrent( + lifecycleGeneration, + 'DEX discovery cache write', + ); + const state = this.#dexDiscoveryCache.update(allDexs); + + this.#deps.debugLogger.log( + 'HyperLiquidProvider: Available DEXs (market filtering applied at data layer)', + { + count: state.validated.filter((dex) => dex !== null).length, + dexNames: state.validated.filter((dex) => dex !== null), + }, + ); + + return state.validated; + } + + /** + * Get cached meta response for a DEX, fetching from API if not cached + * This helper consolidates cache logic to avoid redundant API calls across the provider + * + * @param params - The operation parameters. + * @param params.dexName - DEX name (null for main DEX). + * @param params.skipCache - If true, bypass cache and fetch fresh data. + * @returns MetaResponse with universe data. + * @throws Error if API returns invalid data + */ + async #getCachedMeta(params: { + dexName: string | null; + skipCache?: boolean; + }): Promise { + const lifecycleGeneration = this.#lifecycleGeneration; + const { dexName, skipCache } = params; + const dexDisplayName = dexName ?? 'main'; + + // Skip cache if requested (forces fresh fetch) + if (!skipCache) { + const cached = this.#cachedMetaByDex.get(dexName); + if (cached) { + this.#deps.debugLogger.log( + '[getCachedMeta] Using cached meta response', + { + dex: dexDisplayName, + universeSize: cached.universe.length, + }, + ); + return cached; + } + } + + // Cache miss or skipCache=true - fetch from API. + const meta = await this.#fetchMeta(dexName); + const cached = await this.#cacheDexMetadataFromRead({ + dex: dexName, + meta, + lifecycleGeneration, + }); + if (cached) { + this.#deps.debugLogger.log( + '[getCachedMeta] Fetched and cached meta response', + { + dex: dexDisplayName, + universeSize: meta.universe.length, + skipCache, + }, + ); + } + + return meta; + } + + /** + * Fetch and validate metadata without mutating provider caches. + * + * @param dexName - DEX name, or null for the main DEX. + * @returns Validated metadata for the requested DEX. + */ + async #fetchMeta(dexName: string | null): Promise { + const dexDisplayName = dexName ?? 'main'; + + // Bring the SDK clients up first. This is the first client touch on the + // write path — placeOrder resolves asset info before it ensures trading + // readiness — so without it a cold start or a post-disconnect action fails + // with CLIENT_NOT_INITIALIZED instead of waiting for the clients it needs. + // Idempotent, and a warm cache hit returns above without reaching here. + await this.#ensureClientsInitialized(); + // Metadata is request/response data, so keep this path available while a + // failed WebSocket reconnect is retrying. + const infoClient = this.#clientService.getInfoClient({ useHttp: true }); + // Pass dex only for HIP-3 DEXs; omit for main DEX (empty string). + // Testnet API returns null when dex="" is explicitly sent. + const meta = await infoClient.meta(dexName ? { dex: dexName } : undefined); + + // Defensive validation before caching + if (!meta?.universe || !Array.isArray(meta.universe)) { + throw new Error( + `[HyperLiquidProvider] Invalid meta response for DEX ${dexDisplayName}: universe is ${meta?.universe ? 'not an array' : 'missing'}`, + ); + } + + return meta; + } + + /** + * Write provider metadata caches only within the lifecycle that fetched it. + * + * @param params - Metadata and lifecycle context. + * @param params.dex - DEX name, or null for the main DEX. + * @param params.meta - Validated DEX metadata. + * @param params.assetCtxs - Asset contexts shared with subscriptions, if any. + * @param params.lifecycleGeneration - Lifecycle that produced the metadata. + */ + #cacheDexMetadata(params: { + dex: string | null; + meta: MetaResponse; + assetCtxs?: PerpsAssetCtx[]; + lifecycleGeneration: number; + }): void { + const { dex, meta, assetCtxs, lifecycleGeneration } = params; + this.#assertCacheWriteLifecycleCurrent( + lifecycleGeneration, + 'DEX metadata cache write', + ); + this.#cachedMetaByDex.set(dex, meta); + if (assetCtxs) { + const subscriptionDexKey = dex ?? ''; + this.#subscriptionService.setDexMetaCache(subscriptionDexKey, meta); + this.#subscriptionService.setDexAssetCtxsCache( + subscriptionDexKey, + assetCtxs, + ); + } + } + + /** + * Cache metadata fetched for a read without discarding a valid response. + * + * @param params - Metadata and lifecycle context. + * @param params.dex - DEX name, or null for the main DEX. + * @param params.meta - Validated DEX metadata. + * @param params.assetCtxs - Asset contexts shared with subscriptions, if any. + * @param params.lifecycleGeneration - Lifecycle that produced the metadata. + * @returns Whether the fetched metadata was cached. + */ + async #cacheDexMetadataFromRead(params: { + dex: string | null; + meta: MetaResponse; + assetCtxs?: PerpsAssetCtx[]; + lifecycleGeneration: number; + }): Promise { + const { dex, meta, assetCtxs, lifecycleGeneration } = params; + if ( + !this.#isCacheWriteLifecycleCurrent( + lifecycleGeneration, + 'DEX metadata read cache write', + ) + ) { + return false; + } + + try { + this.#cacheDexMetadata({ + dex, + meta, + assetCtxs, + lifecycleGeneration, + }); + await this.#backfillAssetMapForDex(dex, meta, lifecycleGeneration); + return true; + } catch (error) { + if ( + ensureError(error, 'HyperLiquidProvider.cacheDexMetadataFromRead') + .message === PERPS_ERROR_CODES.PROVIDER_LIFECYCLE_STALE + ) { + return false; + } + throw error; + } + } + + /** + * Backfill the asset ID map for a single DEX from a fresh meta response. + * Used to repair partial asset mapping when an individual DEX becomes available later. + * + * @param dex - DEX name (null for main DEX). + * @param meta - Meta response containing the DEX universe. + * @param lifecycleGeneration - Lifecycle that produced the metadata. + * @returns True if the mapping was rebuilt for the DEX. + */ + async #backfillAssetMapForDex( + dex: string | null, + meta: MetaResponse, + lifecycleGeneration: number, + ): Promise { + this.#assertCacheWriteLifecycleCurrent( + lifecycleGeneration, + 'Asset map backfill', + ); + if (!meta?.universe || !Array.isArray(meta.universe)) { + return false; + } + + if (!this.#dexDiscoveryCache.state) { + try { + await this.#getValidatedDexs(); + } catch (error) { + this.#deps.debugLogger.log( + '[backfillAssetMapForDex] Unable to refresh validated DEXs before rebuilding asset map', + { + dex: dex ?? 'main', + error: ensureError( + error, + 'HyperLiquidProvider.backfillAssetMapForDex', + ).message, + }, + ); + } + } + + this.#assertCacheWriteLifecycleCurrent( + lifecycleGeneration, + 'Asset map backfill', + ); + + const allPerpDexs = this.#dexDiscoveryCache.state?.raw ?? [null]; + const perpDexIndex = allPerpDexs.findIndex((entry) => { + if (dex === null) { + return entry === null; + } + return entry !== null && entry.name === dex; + }); + + if (perpDexIndex === -1) { + this.#deps.debugLogger.log( + '[backfillAssetMapForDex] Could not find perpDexIndex for DEX', + { dex: dex ?? 'main' }, + ); + return false; + } + + const { symbolToAssetId } = buildAssetMapping({ + metaUniverse: meta.universe, + dex, + perpDexIndex, + }); + + symbolToAssetId.forEach((assetId, coin) => { + this.#symbolToAssetId.set(coin, assetId); + }); + + this.#deps.debugLogger.log( + '[backfillAssetMapForDex] Rebuilt asset mapping for DEX', + { + dex: dex ?? 'main', + dexAssetCount: symbolToAssetId.size, + totalAssetCount: this.#symbolToAssetId.size, + }, + ); + + return symbolToAssetId.size > 0; + } + + /** + * Resolve an asset ID, repairing the DEX-specific map when meta is available but the map is stale. + * + * @param params - Resolution parameters. + * @param params.symbol - Asset symbol to resolve. + * @param params.dexName - DEX name (null for main DEX). + * @param params.meta - Optional pre-fetched meta for the DEX. + * @returns The asset ID. + */ + async #getAssetIdWithRepair(params: { + symbol: string; + dexName: string | null; + meta?: MetaResponse; + }): Promise { + const lifecycleGeneration = this.#lifecycleGeneration; + const { symbol, dexName } = params; + const existingAssetId = this.#symbolToAssetId.get(symbol); + + if (existingAssetId !== undefined) { + return existingAssetId; + } + + const meta = + params.meta ?? (await this.#getCachedMeta({ dexName: dexName ?? null })); + const assetExistsInMeta = meta.universe.some( + (asset) => asset.name === symbol, + ); + + if (assetExistsInMeta) { + await this.#backfillAssetMapForDex(dexName, meta, lifecycleGeneration); + const repairedAssetId = this.#symbolToAssetId.get(symbol); + if (repairedAssetId !== undefined) { + return repairedAssetId; + } + } + + this.#deps.debugLogger.log('Asset ID lookup failed', { + requestedCoin: symbol, + dexName: dexName ?? 'main', + mapSize: this.#symbolToAssetId.size, + mapContainsAsset: this.#symbolToAssetId.has(symbol), + assetExistsInMeta, + allKeys: Array.from(this.#symbolToAssetId.keys()).slice(0, 20), + }); + + throw new Error(`Asset ID not found for ${symbol}`); + } + + /** + * Fetch spot metadata with session-based caching + * Contains token info (e.g. USDC token index) needed for HIP-3 collateral checks + * Pre-fetched in ensureReadyForTrading() to ensure availability during order placement + * + * @returns SpotMetaResponse with tokens and universe data + */ + async #getCachedSpotMeta(): Promise { + if (this.#cachedSpotMeta) { + this.#deps.debugLogger.log('[getCachedSpotMeta] Using cached spotMeta', { + tokensCount: this.#cachedSpotMeta.tokens.length, + universeCount: this.#cachedSpotMeta.universe.length, + }); + return this.#cachedSpotMeta; + } + + const lifecycleGeneration = this.#lifecycleGeneration; + const infoClient = this.#clientService.getInfoClient(); + const spotMeta = await infoClient.spotMeta(); + + if ( + this.#isCacheWriteLifecycleCurrent( + lifecycleGeneration, + 'Spot metadata cache write', + ) + ) { + this.#cachedSpotMeta = spotMeta; + this.#deps.debugLogger.log( + '[getCachedSpotMeta] Fetched and cached spotMeta', + { + tokensCount: spotMeta.tokens.length, + universeCount: spotMeta.universe.length, + }, + ); + } + + return spotMeta; + } + + /** + * Fetch perpDexs data with TTL-based caching + * Returns deployerFeeScale info needed for dynamic fee calculation + * + * @returns Array of ExtendedPerpDex objects (null entries represent main DEX) + */ + async #getCachedPerpDexs(): Promise { + const lifecycleGeneration = this.#lifecycleGeneration; + const now = Date.now(); + + // Return cached data if still valid (uses unified state timestamp for TTL) + if ( + this.#dexDiscoveryCache.state && + now - this.#dexDiscoveryCache.state.timestamp < + HIP3_FEE_CONFIG.PerpDexsCacheTtlMs + ) { + const raw = this.#dexDiscoveryCache.state.raw as ExtendedPerpDex[]; + this.#deps.debugLogger.log( + '[getCachedPerpDexs] Using cached perpDexs data', + { + age: `${Math.round((now - this.#dexDiscoveryCache.state.timestamp) / 1000)}s`, + count: raw.length, + }, + ); + return raw; + } + + // Fetch fresh data from API + // Note: SDK types are incomplete, but API returns deployerFeeScale + await this.#ensureClientsInitialized(); + const infoClient = this.#clientService.getInfoClient(); + const perpDexs = + (await infoClient.perpDexs()) as unknown as ExtendedPerpDex[]; + + if ( + this.#isCacheWriteLifecycleCurrent( + lifecycleGeneration, + 'Perp DEX cache write', + ) + ) { + // Atomically update unified state (raw + validated + timestamp) + this.#dexDiscoveryCache.update(perpDexs); + + this.#deps.debugLogger.log( + '[getCachedPerpDexs] Fetched and cached perpDexs data', + { + count: perpDexs.length, + dexes: perpDexs + .filter((dex) => dex !== null) + .map((dex) => ({ + name: dex.name, + deployerFeeScale: dex.deployerFeeScale, + })), + }, + ); + } + + return perpDexs; + } + + /** + * Calculate HIP-3 fee multiplier using HyperLiquid's official formula + * Fetches deployerFeeScale from perpDexs API and growthMode from meta API + * + * Formula from HyperLiquid docs: + * - scaleIfHip3 = deployerFeeScale < 1 ? deployerFeeScale + 1 : deployerFeeScale * 2 + * - growthModeScale = growthMode ? 0.1 : 1 + * - finalMultiplier = scaleIfHip3 * growthModeScale + * + * @param params - The operation parameters. + * @param params.dexName - The DEX identifier (empty string for main DEX). + * @param params.assetSymbol - The asset symbol. + * @see https://hyperliquid.gitbook.io/hyperliquid-docs/trading/fees#fee-formula-for-developers + * @returns The result of the operation. + */ + async #calculateHip3FeeMultiplier(params: { + dexName: string; + assetSymbol: string; + }): Promise { + const { dexName, assetSymbol } = params; + + try { + // Get deployerFeeScale from perpDexs + const perpDexs = await this.#getCachedPerpDexs(); + const dexInfo = perpDexs.find((dex) => dex?.name === dexName); + const parsedScale = parseFloat(dexInfo?.deployerFeeScale ?? ''); + const deployerFeeScale = Number.isNaN(parsedScale) + ? HIP3_FEE_CONFIG.DefaultDeployerFeeScale + : parsedScale; + + // Get growthMode from meta for this specific asset + const meta = await this.#getCachedMeta({ dexName }); + const fullAssetName = `${dexName}:${assetSymbol}`; + const assetMeta = meta.universe.find( + (univ) => (univ as ExtendedAssetMeta).name === fullAssetName, + ) as ExtendedAssetMeta | undefined; + const isGrowthMode = assetMeta?.growthMode === 'enabled'; + + // Apply official formula + const scaleIfHip3 = + deployerFeeScale < 1 ? deployerFeeScale + 1 : deployerFeeScale * 2; + const growthModeScale = isGrowthMode + ? HIP3_FEE_CONFIG.GrowthModeScale + : 1; + + const finalMultiplier = scaleIfHip3 * growthModeScale; + + this.#deps.debugLogger.log('HIP-3 Dynamic Fee Calculation', { + dexName, + assetSymbol, + fullAssetName, + deployerFeeScale, + isGrowthMode, + scaleIfHip3, + growthModeScale, + finalMultiplier, + }); + + return finalMultiplier; + } catch (error) { + this.#deps.debugLogger.log( + 'HIP-3 Fee Calculation Failed, using fallback', + { + dexName, + assetSymbol, + error: ensureError( + error, + 'HyperLiquidProvider.calculateHip3FeeMultiplier', + ).message, + }, + ); + // Safe fallback: standard HIP-3 2x multiplier (no Growth Mode discount) + return HIP3_FEE_CONFIG.DefaultDeployerFeeScale * 2; + } + } + + /** + * Generate session cache key for user-specific caches + * Format: "network:userAddress" (address normalized to lowercase) + * + * @param network - 'mainnet' or 'testnet' + * @param userAddress - User's Ethereum address + * @returns Cache key for session-based caches + */ + #getCacheKey(network: string, userAddress: string): string { + return `${network}:${userAddress.toLowerCase()}`; + } + + #getApprovedBuilderKey( + network: string, + userAddress: string, + builderAddress: string, + ): string { + return `${this.#getCacheKey(network, userAddress)}:${builderAddress.toLowerCase()}`; + } + + /** + * Fetch markets for a specific DEX with optional filtering + * Uses session-based caching via getCachedMeta() - no TTL, cleared on disconnect + * + * @param params - The operation parameters. + * @param params.dex - DEX name (null for main DEX). + * @param params.skipFilters - If true, skip HIP-3 filtering (return all markets). + * @param params.skipCache - If true, bypass cache and fetch fresh data. + * @returns Array of MarketInfo objects. + */ + async #fetchMarketsForDex(params: { + dex: string | null; + skipFilters?: boolean; + skipCache?: boolean; + }): Promise { + const { dex, skipFilters = false, skipCache = false } = params; + + // Get raw meta response (uses session cache unless skipCache=true) + const meta = await this.#getCachedMeta({ dexName: dex, skipCache }); + + if (!meta.universe || !Array.isArray(meta.universe)) { + this.#deps.debugLogger.log( + `HyperLiquidProvider: Invalid universe data for DEX ${dex ?? 'main'}`, + ); + return []; + } + + // TAT-3304: Following the USDH sunset, only USDC-collateral HIP-3 DEXs + // are supported for trading. Gate non-USDC-collateral DEXs out of market + // discovery entirely (regardless of skipFilters) so their markets can + // never be surfaced to trade, even via an allowlist entry naming the DEX. + if (dex !== null && !(await this.#isUsdcCollateralDex(dex))) { + this.#deps.debugLogger.log( + 'HyperLiquidProvider: Filtering out non-USDC-collateral HIP-3 DEX from market discovery', + { dex }, + ); + return []; + } + + // Transform to MarketInfo format + const markets = meta.universe.map((asset) => adaptMarketFromSDK(asset)); + + // Apply HIP-3 filtering on-demand (cheap array operation) + // Skip filtering for main DEX (null) or if explicitly requested + const filteredMarkets = + skipFilters || dex === null + ? markets + : markets.filter((market) => + shouldIncludeMarket( + market.name, + dex, + this.#hip3Enabled, + this.#compiledAllowlistPatterns, + this.#compiledBlocklistPatterns, + ), + ); + + this.#deps.debugLogger.log('HyperLiquidProvider: Fetched markets for DEX', { + dex: dex ?? 'main', + marketCount: filteredMarkets.length, + skipFilters, + skipCache, + }); + + return filteredMarkets; + } + + /** + * Get USDC token ID from spot metadata + * Returns format: "USDC:{hex_token_id}" + * Caches result to avoid repeated API calls + * + * @returns A promise that resolves to the string result. + */ + async #getUsdcTokenId(): Promise { + if (this.#cachedUsdcTokenId) { + return this.#cachedUsdcTokenId; + } + + const spotMeta = await this.#getCachedSpotMeta(); + + const usdcToken = spotMeta.tokens.find((tok) => tok.name === 'USDC'); + if (!usdcToken) { + throw new Error('USDC token not found in spot metadata'); + } + + this.#cachedUsdcTokenId = `USDC:${usdcToken.tokenId}`; + this.#deps.debugLogger.log('HyperLiquidProvider: USDC token ID cached', { + tokenId: this.#cachedUsdcTokenId, + }); + + return this.#cachedUsdcTokenId; + } + + /** + * Check if a HIP-3 DEX uses USDC as its collateral token + * + * TAT-3304: Following the USDH sunset, only USDC-collateral DEXs are + * supported for trading. This gate filters non-USDC-collateral DEXs + * (e.g. the now-sunset USDH) out of market discovery and blocks order + * placement, replacing the previous USDH-specific auto-swap path. + * + * Fails closed: only returns true when the collateral token index + * positively resolves to USDC against spot metadata. If the token can't + * be resolved (e.g. missing/stale spot metadata), the DEX is treated as + * non-USDC and gated out, since we can't otherwise verify the USDC-only + * requirement. This only affects HIP-3 DEXs — main-DEX trading (dex === + * null) never calls this gate. + * + * @param dexName - The DEX identifier (empty string for main DEX). + * @returns A promise that resolves to the boolean result. + */ + async #isUsdcCollateralDex(dexName: string): Promise { + const meta = await this.#getCachedMeta({ dexName }); + const spotMeta = await this.#getCachedSpotMeta(); + + const collateralToken = spotMeta.tokens.find( + (tok: { index: number }) => tok.index === meta.collateralToken, + ); + + const isUsdc = collateralToken?.name === USDC_SYMBOL; + + this.#deps.debugLogger.log( + 'HyperLiquidProvider: Checked DEX collateral type', + { + dexName, + collateralTokenIndex: meta.collateralToken, + collateralTokenName: collateralToken?.name, + isUsdc, + }, + ); + + return isUsdc; + } + + /** + * Build asset ID mapping from market metadata + * Fetches metadata for feature-flag-enabled DEXs and builds a unified mapping + * with DEX-prefixed keys for HIP-3 assets (e.g., "xyz:XYZ100" → assetId) + * + * Per HIP-3-IMPLEMENTATION.md: + * - Main DEX: assetId = index (0, 1, 2, ...) + * - HIP-3 DEX: assetId = BASE_ASSET_ID + (perpDexIndex × DEX_MULTIPLIER) + index + * + * This enables proper order routing - when placeOrder({ symbol: "xyz:XYZ100" }) is called, + * the asset ID lookup succeeds and the order routes to the correct DEX. + */ + async #buildAssetMapping(): Promise { + const lifecycleGeneration = this.#lifecycleGeneration; + // Get feature-flag-validated DEXs to map (respects hip3Enabled and enabledDexs) + let dexsToMap: (string | null)[]; + try { + dexsToMap = await this.#getValidatedDexs(); + } catch (dexError) { + // If getValidatedDexs fails, fall back to main DEX only to keep the provider + // functional. Without this, a transient perpDexs() failure would permanently + // brick #ensureReady via the cached rejected promise. + // Do not update #dexDiscoveryCache here — leave state null + // so #getValidatedDexs retries on the next call (same as #fetchValidatedDexsInternal). + this.#deps.debugLogger.log( + '[buildAssetMapping] getValidatedDexs failed, falling back to main DEX', + { error: String(dexError) }, + ); + dexsToMap = [null]; + } + + // Local fallback only — never write [null] into #dexDiscoveryCache here. + // That state is owned exclusively by #dexDiscoveryCache.update(); writing a + // fallback here would prevent subsequent callers from retrying perpDexs(). + const allPerpDexs = this.#dexDiscoveryCache.state?.raw ?? [null]; + + this.#deps.debugLogger.log( + 'HyperLiquidProvider: Starting asset mapping rebuild', + { + dexs: dexsToMap, + previousMapSize: this.#symbolToAssetId.size, + hip3Enabled: this.#hip3Enabled, + allowlistMarkets: this.#allowlistMarkets, + blocklistMarkets: this.#blocklistMarkets, + timestamp: new Date().toISOString(), + }, + ); + + // Update subscription service with current feature flags + // Extract HIP-3 DEX names (filter out null which represents main DEX) + const enabledDexs = dexsToMap.filter((dex): dex is string => dex !== null); + + await this.#subscriptionService.updateFeatureFlags( + this.#hip3Enabled, + enabledDexs, + this.#allowlistMarkets, + this.#blocklistMarkets, + ); + this.#assertCacheWriteLifecycleCurrent( + lifecycleGeneration, + 'Asset mapping rebuild', + ); + + // Fetch metadata for each DEX in parallel using metaAndAssetCtxs + // Optimization: Check cache first - getMarketDataWithPrices may have already fetched + // If not cached, fetch via metaAndAssetCtxs and populate cache for other methods + const infoClient = this.#clientService.getInfoClient(); + const allMetas = await Promise.allSettled( + dexsToMap.map((dex) => { + // Check if already cached (e.g., by getMarketDataWithPrices running in parallel) + const cachedMeta = this.#cachedMetaByDex.get(dex); + if (cachedMeta) { + this.#deps.debugLogger.log( + `[buildAssetMapping] Using cached meta for ${dex ?? 'main'}`, + { universeSize: cachedMeta.universe.length }, + ); + return Promise.resolve({ + dex, + meta: cachedMeta, + success: true as const, + }); + } + + // Not cached, fetch and populate cache + const dexParam = dex ?? undefined; + return infoClient + .metaAndAssetCtxs(dexParam ? { dex: dexParam } : undefined) + .then((result) => { + const meta = result?.[0] || null; + const assetCtxs = result?.[1] || []; + // Cache meta for later use by getCachedMeta + if (meta?.universe) { + this.#cacheDexMetadata({ + dex, + meta, + assetCtxs, + lifecycleGeneration, + }); + } + return { dex, meta, success: true as const }; + }) + .catch((error) => { + this.#deps.debugLogger.log( + `HyperLiquidProvider: Failed to fetch metaAndAssetCtxs for DEX ${ + dex ?? 'main' + }`, + { error }, + ); + return { dex, meta: null, success: false as const }; + }); + }), + ); + + this.#assertCacheWriteLifecycleCurrent( + lifecycleGeneration, + 'Asset mapping rebuild', + ); + // Build mapping with DEX prefixes for HIP-3 DEXs using the utility function + this.#symbolToAssetId.clear(); + let dexDiscoveryComplete = this.#dexDiscoveryCache.state !== null; + + allMetas.forEach((result) => { + if ( + result.status === 'fulfilled' && + result.value.success && + result.value.meta + ) { + const { dex, meta } = result.value; + + // Validate that meta.universe exists and is an array + if (!meta.universe || !Array.isArray(meta.universe)) { + this.#deps.debugLogger.log( + `HyperLiquidProvider: Skipping DEX ${ + dex ?? 'main' + } - invalid or missing universe data`, + { + hasUniverse: Boolean(meta.universe), + isArray: Array.isArray(meta.universe), + }, + ); + dexDiscoveryComplete = false; + return; + } + + // Find perpDexIndex for this DEX in the perpDexs array + // Main DEX (dex=null) is at index 0 + // HIP-3 DEXs are at indices 1, 2, 3, etc. + const perpDexIndex = allPerpDexs.findIndex((entry) => { + if (dex === null) { + return entry === null; // Main DEX + } + return entry !== null && entry.name === dex; + }); + + if (perpDexIndex === -1) { + this.#deps.debugLogger.log( + `HyperLiquidProvider: Could not find perpDexIndex for DEX ${ + dex ?? 'main' + }`, + ); + dexDiscoveryComplete = false; + return; + } + + // Use the utility function to build mapping for this DEX + const { symbolToAssetId } = buildAssetMapping({ + metaUniverse: meta.universe, + dex, + perpDexIndex, + }); + + // Merge into provider's map + symbolToAssetId.forEach((assetId, coin) => { + this.#symbolToAssetId.set(coin, assetId); + }); + } else { + dexDiscoveryComplete = false; + } + }); + + this.#dexDiscoveryComplete = dexDiscoveryComplete; + + const allKeys = Array.from(this.#symbolToAssetId.keys()); + const mainDexKeys = allKeys.filter((key) => !key.includes(':')).slice(0, 5); + const hip3Keys = allKeys.filter((key) => key.includes(':')).slice(0, 10); + + this.#deps.debugLogger.log('HyperLiquidProvider: Asset mapping built', { + totalAssets: this.#symbolToAssetId.size, + dexCount: dexsToMap.length, + mainDexSample: mainDexKeys, + hip3Sample: hip3Keys, + }); + } + + /** + * Set user fee discount context for next operations + * Used by PerpsController to apply MetaMask reward discounts + * + * @param discountBips - The discount in basis points (e.g., 550 = 5.5%) + */ + setUserFeeDiscount(discountBips: number | undefined): void { + this.#userFeeResolution = undefined; + this.#userFeeDiscountBips = discountBips; + + this.#deps.debugLogger.log('HyperLiquid: Fee discount context updated', { + discountBips, + discountPercentage: discountBips ? discountBips / 100 : undefined, + isActive: discountBips !== undefined, + }); + } + + /** + * Set the resolved fee and its attribution source for the next operation. + * + * @param resolution - Unified fee resolution, or undefined to clear it. + */ + setUserFeeResolution(resolution: PerpsFeeResolution | undefined): void { + this.#userFeeResolution = resolution; + this.#userFeeDiscountBips = resolution?.discountBips; + + this.#deps.debugLogger.log('HyperLiquid: Fee resolution context updated', { + source: resolution?.source, + discountBips: resolution?.discountBips, + isActive: resolution !== undefined, + }); + } + + /** + * Query user data across all enabled DEXs in parallel + * + * DRY helper for multi-DEX user data queries. Handles feature flag logic + * and DEX iteration in one place. Uses cached getValidatedDexs() to avoid + * redundant perpDexs() API calls. + * + * @param baseParams - Base parameters (e.g., { user: '0x...' }) + * @param queryFn - API method to call per DEX + * @returns Array of results per DEX with DEX identifier + * @example + * ```typescript + * const results = await this.#queryUserDataAcrossDexs( + * { user: userAddress }, + * (p) => infoClient.clearinghouseState(p) + * ); + * ``` + */ + async #queryUserDataAcrossDexs< + TParams extends Record, + TResult, + >( + baseParams: TParams, + queryFn: (params: TParams & { dex?: string }) => Promise, + ): Promise> { + const enabledDexs = await this.#getValidatedDexs(); + + const settledResults = await Promise.allSettled( + enabledDexs.map(async (dex) => { + const params = dex + ? ({ ...baseParams, dex } as TParams & { dex: string }) + : (baseParams as TParams & { dex?: string }); + return queryFn(params); + }), + ); + + const results: DexQueryResult[] = []; + const failedDexs: DexQueryResponse['failedDexs'] = []; + + settledResults.forEach((result, index) => { + const dex = enabledDexs[index]; + if (result.status === 'fulfilled') { + results.push({ dex, data: result.value }); + return; + } + + failedDexs.push({ + dex, + error: ensureError( + result.reason, + 'HyperLiquidProvider.queryUserDataAcrossDexs', + ), + }); + }); + + return { results, failedDexs }; + } + + /** + * Map HyperLiquid API errors to standardized PERPS_ERROR_CODES + * + * @param error - The error that occurred. + * @returns The result of the operation. + */ + #mapError(error: unknown): Error { + const { message } = ensureError(error, 'HyperLiquidProvider.mapError'); + + // "User or API Wallet 0x... does not exist." carries the user's address, so + // it cannot be matched by the static substring table below. It means the + // wallet has no Hyperliquid account yet — surface an actionable code the + // client can translate ("fund your account") instead of leaking the raw + // exchange string to the UI and to failed-trade analytics. + if (isHyperLiquidUserNotFoundError(error)) { + return new Error(PERPS_ERROR_CODES.EXCHANGE_ACCOUNT_NOT_FOUND); + } + + for (const [pattern, code] of Object.entries(this.#errorMappings)) { + if (message.toLowerCase().includes(pattern.toLowerCase())) { + return new Error(code); + } + } + + // Return original error to preserve stack trace for unmapped errors + return ensureError(error, 'HyperLiquidProvider.mapError'); + } + + /** + * Get error context for logging with searchable tags and context. + * Enables Sentry dashboard filtering by feature, provider, and network. + * + * @param method - The method name where the error occurred + * @param extra - Optional additional context fields (becomes searchable context data) + * @returns LoggerErrorOptions with tags (searchable) and context (searchable) + * @private + * @example + * this.#deps.logger.error(error, this.#getErrorContext('placeOrder', { symbol: 'BTC', orderType: 'limit' })); + * // Creates searchable tags: feature:perps, provider:hyperliquid, network:mainnet + * // Creates searchable context: perps_provider.method:placeOrder, perps_provider.symbol:BTC, perps_provider.orderType:limit + */ + #getErrorContext( + method: string, + extra?: Record, + ): { + tags?: Record; + context?: { name: string; data: Record }; + extras?: Record; + } { + return { + tags: { + feature: PERPS_CONSTANTS.FeatureName, + provider: this.protocolId, + network: this.#clientService.isTestnetMode() ? 'testnet' : 'mainnet', + }, + context: { + name: 'HyperLiquidProvider', + data: { + method, + ...extra, + }, + }, + }; + } + + #isMappedAccountModeExchangeError(error: Error): boolean { + return ( + error.message === PERPS_ERROR_CODES.EXCHANGE_MULTI_SIG_REQUIRED || + error.message === PERPS_ERROR_CODES.EXCHANGE_INVALID_NONCE + ); + } + + async #getTradingErrorContext( + method: string, + error: Error, + extra?: Record, + ): Promise<{ + tags?: Record; + context?: { name: string; data: Record }; + extras?: Record; + }> { + const contextExtra = { ...extra }; + if (this.#isMappedAccountModeExchangeError(error)) { + try { + const userAddress = + await this.#walletService.getUserAddressWithDefault(); + const abstractionMode = + this.#subscriptionService.getCachedAbstractionMode(userAddress); + if (abstractionMode) { + contextExtra[PERPS_EVENT_PROPERTY.ABSTRACTION_MODE] = abstractionMode; + } + } catch { + // Best-effort context enrichment only. + } + } + return this.#getErrorContext(method, contextExtra); + } + + /** + * Get supported deposit routes with complete asset and routing information + * + * @param params - The operation parameters. + * @returns The result of the operation. + */ + getDepositRoutes(params?: GetSupportedPathsParams): AssetRoute[] { + const isTestnet = params?.isTestnet ?? this.#clientService.isTestnetMode(); + const supportedAssets = getSupportedPaths({ ...params, isTestnet }); + const bridgeInfo = getBridgeInfo(isTestnet); + + return supportedAssets.map((assetId) => ({ + assetId, + chainId: bridgeInfo.chainId, + contractAddress: bridgeInfo.contractAddress, + constraints: { + minAmount: WITHDRAWAL_CONSTANTS.DefaultMinAmount, + estimatedMinutes: HYPERLIQUID_WITHDRAWAL_MINUTES, + fees: { + fixed: WITHDRAWAL_CONSTANTS.DefaultFeeAmount, + token: WITHDRAWAL_CONSTANTS.DefaultFeeToken, + }, + }, + })); + } + + /** + * Get supported withdrawal routes with complete asset and routing information + * + * @param params - The operation parameters. + * @returns The result of the operation. + */ + getWithdrawalRoutes(params?: GetSupportedPathsParams): AssetRoute[] { + // For HyperLiquid, withdrawal routes are the same as deposit routes + return this.getDepositRoutes(params); + } + + /** + * Check current builder fee approval for the user + * + * @param builder - Builder address to query. + * @param userAddress - Account whose approval should be queried. + * @returns Current max fee rate or null if not approved + */ + async #checkBuilderFeeApproval( + builder: string, + userAddress: string, + ): Promise { + const infoClient = this.#clientService.getInfoClient({ useHttp: true }); + + return infoClient.maxBuilderFee({ + user: userAddress, + builder, + }); + } + + /** + * Ensure builder fee is approved for MetaMask + * Called once during initialization (ensureReady) to set up builder fee for the session + * Uses session cache to avoid redundant API calls until disconnect/reconnect + * + * Cache semantics: Uses GLOBAL cache to persist across provider reconnections + * This prevents repeated signing requests for hardware wallets. + * + * Note: This is network-specific - testnet and mainnet have separate builder fee states + * + * @param params - Builder fee setup context. + * @param params.network - HyperLiquid network for the approval. + * @param params.userAddress - Account that owns the approval. + * @param params.builderAddress - Builder address being approved. + */ + async #ensureBuilderFeeApproval( + params: BuilderFeeSetupContext, + ): Promise { + const lifecycleGeneration = this.#lifecycleGeneration; + const { network, userAddress, builderAddress } = params; + this.#assertProviderLifecycleCurrent( + lifecycleGeneration, + 'Builder fee approval', + ); + const cacheKey = this.#getCacheKey(network, userAddress); + + // Check GLOBAL cache first to avoid repeated signing requests across reconnections + // This is CRITICAL for hardware wallets to prevent repeated signing prompts + // while browsing. + const globalCached = PerpsSigningCache.getBuilderFee(network, userAddress); + if (globalCached?.attempted && globalCached?.success) { + this.#deps.debugLogger.log( + '[ensureBuilderFeeApproval] Using global cache (prevents hardware wallet prompt spam)', + { network, success: globalCached.success }, + ); + this.#builderFeeCheckCache.set(cacheKey, true); + this.#approvedBuilderAddresses.add( + this.#getApprovedBuilderKey(network, userAddress, builderAddress), + ); + return; + } + + // Check if another provider instance is currently attempting this operation + const inFlightPromise = PerpsSigningCache.isInFlight( + 'builderFee', + network, + userAddress, + ); + if (inFlightPromise) { + this.#deps.debugLogger.log( + '[ensureBuilderFeeApproval] Global in-flight, waiting...', + { network }, + ); + await inFlightPromise; + const completedApproval = PerpsSigningCache.getBuilderFee( + network, + userAddress, + ); + if (completedApproval?.attempted && completedApproval.success) { + this.#builderFeeCheckCache.set(cacheKey, true); + this.#approvedBuilderAddresses.add( + this.#getApprovedBuilderKey(network, userAddress, builderAddress), + ); + } + return; + } + + // Set global in-flight lock + const completeInFlight = PerpsSigningCache.setInFlight( + 'builderFee', + network, + userAddress, + ); + + try { + // Re-check cache after acquiring lock + const recheckCache = PerpsSigningCache.getBuilderFee( + network, + userAddress, + ); + if (recheckCache?.attempted && recheckCache?.success) { + this.#deps.debugLogger.log( + '[ensureBuilderFeeApproval] Completed by another provider', + { network }, + ); + this.#builderFeeCheckCache.set(cacheKey, true); + this.#approvedBuilderAddresses.add( + this.#getApprovedBuilderKey(network, userAddress, builderAddress), + ); + completeInFlight(); + return; + } + + const { isApproved, requiredDecimal } = await this.#checkBuilderFeeStatus( + builderAddress, + userAddress, + ); + this.#assertProviderLifecycleCurrent( + lifecycleGeneration, + 'Builder fee approval', + ); + + if (isApproved) { + // User already has approval on-chain + PerpsSigningCache.setBuilderFee(network, userAddress, { + attempted: true, + success: true, + }); + this.#builderFeeCheckCache.set(cacheKey, true); + this.#approvedBuilderAddresses.add( + this.#getApprovedBuilderKey(network, userAddress, builderAddress), + ); + + this.#deps.debugLogger.log( + '[ensureBuilderFeeApproval] Already approved on-chain', + { network }, + ); + } else { + this.#deps.debugLogger.log( + '[ensureBuilderFeeApproval] Approval required (will show signing request)', + { builder: builderAddress, requiredDecimal }, + ); + + const exchangeClient = this.#clientService.getExchangeClient(); + const maxFeeRate = BUILDER_FEE_CONFIG.MaxFeeRate; + + await exchangeClient.approveBuilderFee({ + builder: builderAddress, + maxFeeRate, + }); + + // Verify approval was successful before caching + const afterApprovalDecimal = await this.#checkBuilderFeeApproval( + builderAddress, + userAddress, + ); + + if ( + afterApprovalDecimal === null || + afterApprovalDecimal < requiredDecimal + ) { + throw new Error( + '[HyperLiquidProvider] Builder fee approval verification failed', + ); + } + + // The approval is already confirmed on-chain. Preserve that fact for + // the next provider lifecycle even if this instance became stale while + // the approval request was in flight. + PerpsSigningCache.setBuilderFee(network, userAddress, { + attempted: true, + success: true, + }); + this.#assertProviderLifecycleCurrent( + lifecycleGeneration, + 'Builder fee approval', + ); + + // Cache success in the current instance after the lifecycle check. + this.#builderFeeCheckCache.set(cacheKey, true); + this.#approvedBuilderAddresses.add( + this.#getApprovedBuilderKey(network, userAddress, builderAddress), + ); + + this.#deps.debugLogger.log( + '[ensureBuilderFeeApproval] Approval successful', + { + builder: builderAddress, + maxFeeRate, + }, + ); + } + completeInFlight(); + } catch (error) { + if ( + ensureError(error, 'HyperLiquidProvider.ensureBuilderFeeApproval') + .message === PERPS_ERROR_CODES.PROVIDER_LIFECYCLE_STALE + ) { + completeInFlight(); + return; + } + + // HyperLiquid wraps wallet signing failures and preserves KEYRING_LOCKED + // in `cause`, so classify the full chain and leave retry caches empty. + if (isKeyringLockedError(error)) { + this.#deps.debugLogger.log( + '[ensureBuilderFeeApproval] Keyring locked, will retry later', + ); + completeInFlight(); + return; + } + + // Record failure — will be retried on next trading operation + PerpsSigningCache.setBuilderFee(network, userAddress, { + attempted: true, + success: false, + }); + + this.#deps.debugLogger.log( + '[ensureBuilderFeeApproval] Failed, will retry on next trading operation', + { + network, + error: ensureError( + error, + 'HyperLiquidProvider.ensureBuilderFeeApproval', + ).message, + }, + ); + + completeInFlight(); + throw error; + } + } + + /** + * Approve the dedicated subscription builder outside order submission. + * Failure is non-blocking: order construction will use the ordinary builder + * at the standard fee until a later approval succeeds. + * + * @returns Whether the builder is approved for the current account. + */ + async approveSubscriptionBuilderFee(): Promise { + const approvalEpoch = this.#subscriptionBuilderApprovalEpoch; + await this.#ensureClientsInitialized(); + if (approvalEpoch !== this.#subscriptionBuilderApprovalEpoch) { + return false; + } + const isTestnet = this.#clientService.isTestnetMode(); + const network = isTestnet ? 'testnet' : 'mainnet'; + const builderAddress = this.#getSubscriptionBuilderAddress(isTestnet); + if (!builderAddress) { + return false; + } + const userAddress = await this.#walletService.getUserAddressWithDefault(); + const key = this.#getApprovedBuilderKey( + network, + userAddress, + builderAddress, + ); + if (this.#approvedBuilderAddresses.has(key)) { + return true; + } + + const pending = this.#pendingBuilderFeeApprovals.get(key); + if (pending) { + try { + await pending; + return this.#approvedBuilderAddresses.has(key); + } catch (error) { + this.#deps.debugLogger.log( + 'HyperLiquidProvider: Subscription builder approval unavailable', + error, + ); + return false; + } + } + + const approval = (async (): Promise => { + const currentApproval = await this.#checkBuilderFeeApproval( + builderAddress, + userAddress, + ); + if (approvalEpoch !== this.#subscriptionBuilderApprovalEpoch) { + return; + } + if ( + currentApproval !== null && + currentApproval >= BUILDER_FEE_CONFIG.MaxFeeDecimal + ) { + this.#approvedBuilderAddresses.add(key); + return; + } + + const exchangeClient = this.#clientService.getExchangeClient(); + await exchangeClient.approveBuilderFee({ + builder: builderAddress, + maxFeeRate: BUILDER_FEE_CONFIG.MaxFeeRate, + }); + if (approvalEpoch !== this.#subscriptionBuilderApprovalEpoch) { + return; + } + const afterApproval = await this.#checkBuilderFeeApproval( + builderAddress, + userAddress, + ); + if (approvalEpoch !== this.#subscriptionBuilderApprovalEpoch) { + return; + } + if ( + afterApproval === null || + afterApproval < BUILDER_FEE_CONFIG.MaxFeeDecimal + ) { + throw new Error( + '[HyperLiquidProvider] Subscription builder approval verification failed', + ); + } + this.#approvedBuilderAddresses.add(key); + })(); + this.#pendingBuilderFeeApprovals.set(key, approval); + + try { + await approval; + return this.#approvedBuilderAddresses.has(key); + } catch (error) { + this.#deps.debugLogger.log( + 'HyperLiquidProvider: Subscription builder approval unavailable', + error, + ); + return false; + } finally { + if (this.#pendingBuilderFeeApprovals.get(key) === approval) { + this.#pendingBuilderFeeApprovals.delete(key); + } + } + } + + /** + * Check if builder fee is approved for the current user + * + * @param builderAddress - Builder address to query. + * @param userAddress - Account whose approval should be queried. + * @returns Object with approval status and current rate + */ + async #checkBuilderFeeStatus( + builderAddress: string, + userAddress: string, + ): Promise<{ + isApproved: boolean; + currentRate: number | null; + requiredDecimal: number; + }> { + const currentApproval = await this.#checkBuilderFeeApproval( + builderAddress, + userAddress, + ); + const requiredDecimal = BUILDER_FEE_CONFIG.MaxFeeDecimal; + + return { + isApproved: + currentApproval !== null && currentApproval >= requiredDecimal, + currentRate: currentApproval, + requiredDecimal, + }; + } + + /** + * Get available balance for a specific DEX + * + * @param params - Balance query parameters + * @param params.dex - DEX name (null = main, 'xyz' = HIP-3) + * @returns Available balance in USDC + * @private + */ + async #getBalanceForDex(params: { dex: string | null }): Promise { + const { dex } = params; + const userAddress = await this.#walletService.getUserAddressWithDefault(); + const infoClient = this.#clientService.getInfoClient(); + + const queryParams = dex + ? { user: userAddress, dex } + : { user: userAddress }; + + const accountState = await infoClient.clearinghouseState(queryParams); + const adapted = adaptAccountStateFromSDK(accountState); + return parseFloat(adapted.withdrawableBalance); + } + + /** + * Find source DEX with sufficient balance for transfer + * Strategy: Prefer main DEX → other HIP-3 DEXs + * + * @param params - Source search parameters + * @param params.targetDex - Target DEX name + * @param params.requiredAmount - Required balance shortfall + * @returns Source DEX info or null if insufficient funds + * @private + */ + async #findSourceDexWithBalance(params: { + targetDex: string; + requiredAmount: number; + }): Promise<{ sourceDex: string; available: number } | null> { + const { targetDex, requiredAmount } = params; + + // Try main DEX first + try { + const mainBalance = await this.#getBalanceForDex({ dex: null }); + if (mainBalance >= requiredAmount) { + return { sourceDex: '', available: mainBalance }; + } + } catch (error) { + this.#deps.debugLogger.log('Could not fetch main DEX balance', { error }); + } + + // Try other HIP-3 DEXs + // Get all available DEXs from cache (includes all HIP-3 DEXs since we no longer filter) + const availableDexs = + this.#dexDiscoveryCache.state?.validated?.filter( + (dex): dex is string => dex !== null, + ) ?? []; + for (const dex of availableDexs) { + if (dex === targetDex) { + continue; + } + + try { + const balance = await this.#getBalanceForDex({ dex }); + if (balance >= requiredAmount) { + return { sourceDex: dex, available: balance }; + } + } catch (error) { + this.#deps.debugLogger.log(`Could not fetch balance for DEX ${dex}`, { + error, + }); + } + } + + return null; + } + + /** + * Auto-transfer funds for HIP-3 orders when insufficient balance + * Only called for HIP-3 markets (not main DEX) + * + * @param params - Transfer parameters + * @param params.targetDex - HIP-3 DEX name (e.g., 'xyz') + * @param params.requiredMargin - Required margin with buffer + * @returns Transfer info for rollback, or null if no transfer needed + * @private + */ + async #autoTransferForHip3Order(params: { + targetDex: string; + requiredMargin: number; + }): Promise<{ amount: number; sourceDex: string } | null> { + const { targetDex, requiredMargin } = params; + + // Check target DEX balance + const targetBalance = await this.#getBalanceForDex({ dex: targetDex }); + + this.#deps.debugLogger.log('HyperLiquidProvider: HIP-3 balance check', { + targetDex, + targetBalance: targetBalance.toFixed(2), + requiredMargin: requiredMargin.toFixed(2), + shortfall: Math.max(0, requiredMargin - targetBalance).toFixed(2), + }); + + // Sufficient balance - no transfer needed + if (targetBalance >= requiredMargin) { + return null; + } + + // Calculate shortfall and find source + const shortfall = requiredMargin - targetBalance; + const source = await this.#findSourceDexWithBalance({ + targetDex, + requiredAmount: shortfall, + }); + + if (!source) { + throw new Error( + `Insufficient balance for HIP-3 order. Required: ${requiredMargin.toFixed( + 2, + )} USDC on ${targetDex} DEX, Available: ${targetBalance.toFixed( + 2, + )} USDC. Please transfer funds to ${targetDex} DEX.`, + ); + } + + // Execute transfer + const transferAmount = Math.min(shortfall, source.available).toFixed( + USDC_DECIMALS, + ); + + this.#deps.debugLogger.log( + 'HyperLiquidProvider: Executing HIP-3 auto-transfer', + { + from: source.sourceDex || 'main', + to: targetDex, + amount: transferAmount, + }, + ); + + const result = await this.transferBetweenDexs({ + sourceDex: source.sourceDex, + destinationDex: targetDex, + amount: transferAmount, + }); + + if (!result.success) { + throw new Error( + `Auto-transfer failed: ${result.error ?? 'Unknown error'}`, + ); + } + + this.#deps.debugLogger.log( + '✅ HyperLiquidProvider: HIP-3 auto-transfer complete', + { + amount: transferAmount, + from: source.sourceDex || 'main', + to: targetDex, + }, + ); + + return { + amount: parseFloat(transferAmount), + sourceDex: source.sourceDex, + }; + } + + /** + * Auto-transfer freed margin back to main DEX after closing a HIP-3 position + * + * This method transfers the margin released from closing a position back to + * the main DEX to prevent balance fragmentation across HIP-3 DEXs. + * + * Design: Non-blocking operation - failures are logged but don't affect the + * position close operation. Extensible for future configuration options. + * + * @param params - Transfer configuration + * @param params.sourceDex - HIP-3 DEX name to transfer from + * @param params.freedMargin - Amount of margin released from position close + * @param params.transferAll - (Future) Transfer all available balance instead + * @param params.skipTransfer - (Future) Skip auto-transfer if disabled + * @returns Transfer info if successful, null if skipped/failed + * @private + */ + async #autoTransferBackAfterClose(params: { + sourceDex: string; + freedMargin: number; + transferAll?: boolean; + skipTransfer?: boolean; + }): Promise<{ amount: number; destinationDex: string } | null> { + const { + sourceDex, + freedMargin, + transferAll = false, + skipTransfer = false, + } = params; + + // Future: Check user preference to skip auto-transfer + if (skipTransfer) { + this.#deps.debugLogger.log( + 'Auto-transfer back skipped (disabled by config)', + ); + return null; + } + + try { + this.#deps.debugLogger.log('Attempting auto-transfer back to main DEX', { + sourceDex, + freedMargin: freedMargin.toFixed(2), + transferAll, + }); + + // Get current balance on HIP-3 DEX + const sourceBalance = await this.#getBalanceForDex({ dex: sourceDex }); + + if (sourceBalance <= 0) { + this.#deps.debugLogger.log('No balance to transfer back', { + sourceBalance, + }); + return null; + } + + // Determine transfer amount + const transferAmount = transferAll + ? sourceBalance + : Math.min(freedMargin, sourceBalance); + + if (transferAmount <= 0) { + this.#deps.debugLogger.log('Transfer amount too small', { + transferAmount, + }); + return null; + } + + this.#deps.debugLogger.log('Transferring back to main DEX', { + amount: transferAmount.toFixed(USDC_DECIMALS), + from: sourceDex, + to: 'main', + }); + + // Execute transfer back to main DEX (empty string '' represents main DEX) + const result = await this.transferBetweenDexs({ + sourceDex, + destinationDex: '', + amount: transferAmount.toFixed(USDC_DECIMALS), + }); + + if (!result.success) { + this.#deps.debugLogger.log('❌ Auto-transfer back failed', { + error: result.error, + }); + return null; + } + + this.#deps.debugLogger.log('✅ Auto-transfer back successful', { + amount: transferAmount.toFixed(USDC_DECIMALS), + from: sourceDex, + to: 'main', + }); + + return { + amount: transferAmount, + destinationDex: '', + }; + } catch (error) { + // Non-blocking: Log error but don't throw + this.#deps.debugLogger.log('❌ Auto-transfer back exception', { + error, + sourceDex, + freedMargin, + }); + return null; + } + } + + /** + * Calculate required margin for HIP-3 order based on existing position + * Handles three scenarios: + * 1. Increasing existing position - requires TOTAL margin (temporary over-funding) + * 2. Reducing/flipping position - requires margin for new order only + * 3. New position - requires margin for new order only + * + * @param params - The operation parameters. + * @param params.symbol - The trading pair symbol. + * @param params.dexName - The DEX identifier (empty string for main DEX). + * @param params.positionSize - The position size value. + * @param params.orderPrice - The order price value. + * @param params.leverage - The leverage multiplier. + * @param params.isBuy - Whether this is a buy order. + * @private + * @returns The result of the operation. + */ + async #calculateHip3RequiredMargin(params: { + symbol: string; + dexName: string; + positionSize: number; + orderPrice: number; + leverage: number; + isBuy: boolean; + }): Promise { + const { symbol, dexName, positionSize, orderPrice, leverage, isBuy } = + params; + + // Get existing position to check if we're increasing + const positions = await this.getPositions(); + const existingPosition = positions.find((pos) => pos.symbol === symbol); + + let requiredMarginWithBuffer: number; + + // HyperLiquid validates isolated margin by checking if available balance >= TOTAL position margin + // When increasing a position, we need to ensure enough funds are available for the TOTAL combined size + if (existingPosition) { + const existingIsLong = parseFloat(existingPosition.size) > 0; + const orderIsLong = isBuy; + + if (existingIsLong === orderIsLong) { + // Increasing position - HyperLiquid validates spendableBalance >= totalRequiredMargin + // BEFORE reallocating existing locked margin. Must transfer TOTAL margin temporarily. + const existingSize = Math.abs(parseFloat(existingPosition.size)); + const existingMargin = parseFloat(existingPosition.marginUsed); + const totalSize = existingSize + positionSize; + const totalNotionalValue = totalSize * orderPrice; + const totalRequiredMargin = totalNotionalValue / leverage; + + // Accept temporary over-funding - excess will be reclaimed after order succeeds + requiredMarginWithBuffer = + totalRequiredMargin * HIP3_MARGIN_CONFIG.BufferMultiplier; + + this.#deps.debugLogger.log( + 'HyperLiquidProvider: HIP-3 margin calculation (TOTAL margin - temporary over-funding)', + { + symbol, + dex: dexName, + existingSize: existingSize.toFixed(4), + existingMargin: existingMargin.toFixed(2), + newSize: positionSize.toFixed(4), + totalSize: totalSize.toFixed(4), + totalNotionalValue: totalNotionalValue.toFixed(2), + leverage, + totalRequiredMargin: totalRequiredMargin.toFixed(2), + requiredMarginWithBuffer: requiredMarginWithBuffer.toFixed(2), + note: 'Transferring TOTAL margin (HyperLiquid validates before reallocation). Will auto-rebalance excess after success.', + }, + ); + } else { + // Reducing or flipping position - just need margin for new order + const notionalValue = positionSize * orderPrice; + const requiredMargin = notionalValue / leverage; + requiredMarginWithBuffer = + requiredMargin * HIP3_MARGIN_CONFIG.BufferMultiplier; + + this.#deps.debugLogger.log( + 'HyperLiquidProvider: HIP-3 margin calculation (reducing position)', + { + symbol, + dex: dexName, + notionalValue: notionalValue.toFixed(2), + leverage, + requiredMargin: requiredMargin.toFixed(2), + requiredMarginWithBuffer: requiredMarginWithBuffer.toFixed(2), + }, + ); + } + } else { + // No existing position - just need margin for this order + const notionalValue = positionSize * orderPrice; + const requiredMargin = notionalValue / leverage; + requiredMarginWithBuffer = + requiredMargin * HIP3_MARGIN_CONFIG.BufferMultiplier; + + this.#deps.debugLogger.log( + 'HyperLiquidProvider: HIP-3 margin calculation (new position)', + { + symbol, + dex: dexName, + notionalValue: notionalValue.toFixed(2), + leverage, + requiredMargin: requiredMargin.toFixed(2), + requiredMarginWithBuffer: requiredMarginWithBuffer.toFixed(2), + }, + ); + } + + return requiredMarginWithBuffer; + } + + /** + * Handle post-order balance check and auto-rebalance for HIP-3 orders + * After a successful order, checks available balance and transfers excess back to main DEX + * Does not throw errors - logs them for monitoring + * + * @param params - The operation parameters. + * @param params.dexName - The DEX identifier (empty string for main DEX). + * @param params.transferInfo - The transfer information. + * @param params.transferInfo.amount - The amount value. + * @param params.transferInfo.sourceDex - The source DEX for the transfer. + * @returns Whether the balance check and any required transfer succeeded. + * @private + */ + async #handleHip3PostOrderRebalance( + params: Hip3TransferContext, + ): Promise { + const { dexName, transferInfo } = params; + + try { + const postOrderBalance = await this.#getBalanceForDex({ dex: dexName }); + const transferredAmount = transferInfo.amount; + const leftoverAmount = postOrderBalance; + const leftoverPercentage = + transferredAmount > 0 ? (leftoverAmount / transferredAmount) * 100 : 0; + + this.#deps.debugLogger.log( + '✅ HyperLiquidProvider: Order succeeded - post-order balance', + { + dex: dexName, + transferredAmount: transferredAmount.toFixed(2), + availableAfterOrder: leftoverAmount.toFixed(2), + leftoverPercentage: `${leftoverPercentage.toFixed(2)}%`, + }, + ); + + // Auto-rebalance: Reclaim excess funds back to main DEX + const desiredBuffer = HIP3_MARGIN_CONFIG.RebalanceDesiredBuffer; + const excessAmount = postOrderBalance - desiredBuffer; + const minimumTransferThreshold = HIP3_MARGIN_CONFIG.RebalanceMinThreshold; + + if (excessAmount > minimumTransferThreshold) { + try { + this.#deps.debugLogger.log( + '🔄 HyperLiquidProvider: Auto-rebalancing excess margin back to main DEX', + { + dex: dexName, + spendableBalance: postOrderBalance.toFixed(2), + desiredBuffer: desiredBuffer.toFixed(2), + excessAmount: excessAmount.toFixed(2), + destinationDex: transferInfo.sourceDex, + }, + ); + + const transferResult = await this.transferBetweenDexs({ + sourceDex: dexName, + destinationDex: transferInfo.sourceDex, + amount: excessAmount.toFixed(USDC_DECIMALS), + }); + if (!transferResult.success) { + throw new Error( + transferResult.error ?? PERPS_ERROR_CODES.TRANSFER_FAILED, + ); + } + + this.#deps.debugLogger.log( + '✅ HyperLiquidProvider: Auto-rebalance completed', + { + transferredBack: excessAmount.toFixed(2), + from: dexName, + to: transferInfo.sourceDex, + }, + ); + return true; + } catch (rebalanceError) { + // Don't fail the order if rebalance fails (order already succeeded) + this.#deps.logger.error( + ensureError( + rebalanceError, + 'HyperLiquidProvider.placeOrder:autoRebalance', + ), + this.#getErrorContext('placeOrder:autoRebalance', { + dex: dexName, + excessAmount: excessAmount.toFixed(2), + note: 'Auto-rebalance failed - funds remain on HIP-3 DEX', + }), + ); + return false; + } + } else { + this.#deps.debugLogger.log( + 'ℹ️ HyperLiquidProvider: No auto-rebalance needed', + { + excessAmount: excessAmount.toFixed(2), + threshold: minimumTransferThreshold.toFixed(2), + note: 'Excess below minimum transfer threshold', + }, + ); + return true; + } + } catch (balanceCheckError) { + // Don't fail the order if balance check fails - log for monitoring + this.#deps.logger.error( + ensureError( + balanceCheckError, + 'HyperLiquidProvider.placeOrder:postOrderBalanceCheck', + ), + this.#getErrorContext('placeOrder:postOrderBalanceCheck', { + dex: dexName, + note: 'Failed to verify post-order balance for auto-rebalance', + }), + ); + return false; + } + } + + /** + * Handle rollback of HIP-3 transfer when order fails + * Attempts to return funds to source DEX + * Does not throw errors - logs them for monitoring + * + * @param params - The operation parameters. + * @param params.dexName - The DEX identifier (empty string for main DEX). + * @param params.transferInfo - The transfer information. + * @param params.transferInfo.amount - The amount value. + * @param params.transferInfo.sourceDex - The source DEX for the transfer. + * @private + */ + async #handleHip3OrderRollback(params: Hip3TransferContext): Promise { + const { dexName, transferInfo } = params; + + try { + this.#deps.debugLogger.log( + 'HyperLiquidProvider: Rolling back failed order transfer', + { + from: dexName, + to: transferInfo.sourceDex || 'main', + amount: transferInfo.amount.toFixed(USDC_DECIMALS), + reason: 'order_failed', + }, + ); + + const rollbackResult = await this.transferBetweenDexs({ + sourceDex: dexName, // From HIP-3 DEX + destinationDex: transferInfo.sourceDex, // Back to source + amount: transferInfo.amount.toFixed(USDC_DECIMALS), + }); + + if (rollbackResult.success) { + this.#deps.debugLogger.log( + '✅ HyperLiquidProvider: Rollback successful', + { + amount: transferInfo.amount.toFixed(USDC_DECIMALS), + returnedTo: transferInfo.sourceDex || 'main', + }, + ); + } else { + this.#deps.logger.error( + new Error(rollbackResult.error ?? 'Rollback transfer failed'), + this.#getErrorContext('placeOrder:rollback', { + dex: dexName, + amount: transferInfo.amount.toFixed(USDC_DECIMALS), + note: 'Rollback failed - funds remain on HIP-3 DEX', + }), + ); + } + } catch (rollbackError) { + // Log but don't throw - original order error is more important + this.#deps.logger.error( + ensureError(rollbackError, 'HyperLiquidProvider.placeOrder:rollback'), + this.#getErrorContext('placeOrder:rollback:exception', { + dex: dexName, + amount: transferInfo.amount.toFixed(USDC_DECIMALS), + note: 'Rollback threw exception - funds remain on HIP-3 DEX', + }), + ); + } + } + + // ============================================================================ + // Helper Methods for placeOrder Refactoring + // ============================================================================ + + /** + * Validates order parameters before placement using provider-level validation + * + * @param params - The operation parameters. + * @throws Error if validation fails + */ + async #validateOrderBeforePlacement(params: OrderParams): Promise { + this.#deps.debugLogger.log( + 'Provider: Validating order before placement:', + params, + ); + + const validation = await this.validateOrder(params); + if (!validation.isValid) { + throw new Error( + validation.error ?? 'Order validation failed at provider level', + ); + } + } + + /** + * Gets asset info and current price from the correct DEX + * + * @param params - The operation parameters. + * @returns A promise that resolves to the result. + */ + async #getAssetInfo(params: GetAssetInfoParams): Promise { + const { symbol, dexName } = params; + + const meta = await this.#getCachedMeta({ dexName }); + + const assetInfo = meta.universe.find((asset) => asset.name === symbol); + if (!assetInfo) { + throw new Error( + `Asset ${symbol} not found in ${dexName ?? 'main'} DEX universe`, + ); + } + + const currentPrice = await this.#getOrFetchPrice({ + symbol, + dexName: dexName ?? null, + }); + + return { assetInfo, currentPrice, meta }; + } + + /** + * Prepares asset for trading by updating leverage if specified + * + * @param params - The operation parameters. + */ + async #prepareAssetForTrading( + params: PrepareAssetForTradingParams, + ): Promise { + const { symbol, assetId, leverage } = params; + + if (!leverage) { + return; + } + + this.#deps.debugLogger.log('Updating leverage before order:', { + symbol, + assetId, + requestedLeverage: leverage, + leverageType: 'isolated', + }); + + const exchangeClient = this.#clientService.getExchangeClient(); + const leverageResult = await exchangeClient.updateLeverage({ + asset: assetId, + isCross: false, + leverage, + }); + + if (leverageResult.status !== 'ok') { + throw new Error( + `Failed to update leverage: ${JSON.stringify(leverageResult)}`, + ); + } + + this.#deps.debugLogger.log('Leverage updated successfully:', { + symbol, + leverage, + }); + } + + /** + * Handles HIP-3 pre-order balance management + * + * @param params - The operation parameters. + * @returns A promise that resolves to the result. + */ + async #handleHip3PreOrder( + params: HandleHip3PreOrderParams, + ): Promise { + const { dexName, symbol, orderPrice, positionSize, leverage, isBuy } = + params; + + // TAT-3304: Only USDC-collateral HIP-3 DEXs are supported for trading. + // Following the USDH sunset, reject orders on any non-USDC-collateral + // DEX here instead of attempting the (now-removed) USDH auto-swap path. + // Market discovery (#fetchMarketsForDex) already filters such DEXs out, + // so this is a defense-in-depth check against stale caches or a + // misconfigured allowlist entry. + const isUsdcDex = await this.#isUsdcCollateralDex(dexName); + if (!isUsdcDex) { + this.#deps.debugLogger.log( + 'HyperLiquidProvider: Rejecting order for non-USDC-collateral DEX', + { + dexName, + symbol, + }, + ); + + throw new Error(PERPS_ERROR_CODES.UNSUPPORTED_COLLATERAL); + } + + if (this.#useUnifiedAccount) { + this.#deps.debugLogger.log('Using Unified Account (no manual transfer)', { + symbol, + dex: dexName, + }); + return { transferInfo: null }; + } + + this.#deps.debugLogger.log('Using manual auto-transfer', { + symbol, + dex: dexName, + }); + + const requiredMarginWithBuffer = await this.#calculateHip3RequiredMargin({ + symbol, + dexName, + positionSize, + orderPrice, + leverage, + isBuy, + }); + + try { + const transferInfo = await this.#autoTransferForHip3Order({ + targetDex: dexName, + requiredMargin: requiredMarginWithBuffer, + }); + return { transferInfo }; + } catch (transferError) { + const errorMsg = (transferError as Error)?.message || ''; + + if (errorMsg.includes('Cannot transfer with DEX abstraction enabled')) { + this.#deps.debugLogger.log( + 'Detected DEX abstraction is enabled, switching mode', + ); + this.#useUnifiedAccount = true; + return { transferInfo: null }; + } + + throw transferError; + } + } + + /** + * Submits order with atomic rollback for HIP-3 failures + * + * @param params - The operation parameters. + * @returns A promise that resolves to the result. + */ + async #submitOrderWithRollback( + params: SubmitOrderWithRollbackParams, + ): Promise { + const { orders, grouping, isHip3Order, dexName, transferInfo, symbol } = + params; + + const exchangeClient = this.#clientService.getExchangeClient(); + + const builder = params.chargesMetamaskBuilderFee + ? await this.#getBuilderOrderContext( + params.builderFeeSetupContext ?? + (await this.#ensureBuilderFeeSetup()), + ) + : undefined; + + this.#deps.debugLogger.log('Submitting order via asset ID routing', { + symbol, + assetId: orders[0].a, + orderCount: orders.length, + mainOrder: orders[0], + dexName: dexName ?? 'main', + isHip3: Boolean(dexName), + }); + + try { + const result = await exchangeClient.order({ + orders, + grouping, + ...(builder && { builder }), + }); + + if (result.status !== 'ok') { + throw new Error(`Order failed: ${JSON.stringify(result)}`); + } + + const status = result.response?.data?.statuses?.[0]; + // Note: `in` narrows the HyperLiquid SDK discriminated union to the + // branch that has the property; `hasProperty` types the property as + // `unknown`, losing downstream access to `.oid`, `.totalSz`, `.avgPx`. + /* eslint-disable no-restricted-syntax */ + const restingOrder = + isStatusObject(status) && 'resting' in status ? status.resting : null; + const filledOrder = + isStatusObject(status) && 'filled' in status ? status.filled : null; + /* eslint-enable no-restricted-syntax */ + + // Success - auto-rebalance excess funds + if (isHip3Order && transferInfo && dexName) { + await this.#handleHip3PostOrderRebalance({ dexName, transferInfo }); + } + + return { + success: true, + orderId: restingOrder?.oid?.toString() ?? filledOrder?.oid?.toString(), + filledSize: filledOrder?.totalSz, + // The main order's `s` is the final normalized size sent to the exchange + // (post precision rounding, USD recalculation, and $10-minimum retry — + // the retry recurses through placeOrder so this reflects the last + // submission). TradingService uses it to classify partial fills. The SDK + // types `s` as `string | number`, so normalize to the string OrderResult + // shape while preserving `undefined` when no order was built. + submittedSize: + orders[0]?.s === undefined ? undefined : String(orders[0].s), + averagePrice: filledOrder?.avgPx, + }; + } catch (orderError) { + // Failure - rollback transfer + if (transferInfo && dexName) { + await this.#handleHip3OrderRollback({ dexName, transferInfo }); + } + throw orderError; + } + } + + /** + * Handles order errors with proper error mapping + * + * @param params - The operation parameters. + * @returns The result of the operation. + */ + async #handleOrderError( + params: HandleOrderErrorParams, + ): Promise { + const { error, symbol, orderType, isBuy } = params; + const mappedError = this.#mapError(error); + + // A wallet with no Hyperliquid account is an expected pre-account state, + // not an app defect — same policy already applied to every other + // user-scoped exchange write in this provider. Keep it out of Sentry; the + // failure is still reported to the caller (and to trade analytics) via the + // mapped EXCHANGE_ACCOUNT_NOT_FOUND code below. + if (isHyperLiquidUserNotFoundError(error)) { + this.#deps.debugLogger.log( + '[handleOrderError] Wallet has no Hyperliquid account, order cannot be placed', + { symbol, orderType, isBuy }, + ); + } else { + this.#deps.logger.error( + mappedError, + await this.#getTradingErrorContext('placeOrder', mappedError, { + symbol, + orderType, + isBuy, + }), + ); + } + + return createErrorResult(mappedError, { success: false }); + } + + /** + * Place an order using direct wallet signing + * + * Refactored to use helper methods for better maintainability and reduced complexity. + * Each helper method is focused on a single responsibility. + * + * @param params - Order parameters + * @param retryCount - Internal retry counter to prevent infinite loops (default: 0) + * @returns A promise that resolves to the result. + */ + async placeOrder(params: OrderParams, retryCount = 0): Promise { + // Hoisted so the retry path in the catch block can use the fetched price + // even when the caller (e.g. flipPosition) omits currentPrice from params. + let effectivePrice: number | undefined; + try { + this.#deps.debugLogger.log('Placing order via HyperLiquid SDK:', params); + + // Basic sync validation (backward compatibility) + const validation = validateOrderParams({ + coin: params.symbol, + size: params.size, + price: params.price, + orderType: params.orderType, + triggerPrice: params.triggerPrice, + takeProfitPrice: params.takeProfitPrice, + stopLossPrice: params.stopLossPrice, + takeProfitSize: params.takeProfitSize, + stopLossSize: params.stopLossSize, + tpslLinkage: params.tpslLinkage, + grouping: params.grouping, + timeInForce: params.timeInForce, + clientOrderId: params.clientOrderId, + ...pickStrategyParams(params), + }); + if (!validation.isValid) { + throw new Error(validation.error); + } + + // Strategy placements expand into an execution schedule rather than a + // single order, so they leave the shared path here — before the order + // array, the HIP-3 transfer, and the atomic single-order submit, none of + // which describe what a TWAP, a ladder, or a chase does. + if (isStrategyOrderType(params.orderType)) { + return await this.#placeStrategyOrder(params, params.orderType); + } + + // Extract DEX name for API calls (main DEX = null) + const { dex: dexName } = parseAssetName(params.symbol); + + // 1. Get asset info and current price before validation so price-less + // callers (e.g. flipPosition) can validate against the live fetched price. + const { assetInfo, currentPrice, meta } = await this.#getAssetInfo({ + symbol: params.symbol, + dexName, + }); + + // A price or partial size that rounds away at the asset precision is + // caught here, as soon as szDecimals is known and before anything is + // committed: the signing prompts in #ensureReadyForTrading, the leverage + // change in #prepareAssetForTrading, and the HIP-3 margin transfer all + // come later. + const precision = validateOrderPrecision({ + triggerPrice: params.triggerPrice, + takeProfitPrice: params.takeProfitPrice, + stopLossPrice: params.stopLossPrice, + takeProfitSize: params.takeProfitSize, + stopLossSize: params.stopLossSize, + szDecimals: assetInfo.szDecimals, + }); + if (!precision.isValid) { + throw new Error(precision.error); + } + + // Allow override with UI-provided price (optimization to avoid API call). + effectivePrice = + params.currentPrice && params.currentPrice > 0 + ? params.currentPrice + : currentPrice; + + if (params.currentPrice && params.currentPrice > 0) { + this.#deps.debugLogger.log('Using provided current price:', { + coin: params.symbol, + providedPrice: effectivePrice, + source: 'UI price feed', + }); + } + + // Validate order at provider level (enforces USD validation rules). + // Pass effectivePrice so price-less market orders (e.g. flipPosition) + // validate against the live fetched price instead of failing with + // ORDER_PRICE_REQUIRED. + await this.#validateOrderBeforePlacement({ + ...params, + currentPrice: effectivePrice, + }); + + // Ensure provider is ready for trading (includes signing operations). + // Kept after validation so invalid orders never trigger signature prompts + // (builder-fee approval, DEX abstraction enablement, etc.). + const { chargesMetamaskBuilderFee } = this.#resolveOrderFeePolicy(params); + const builderFeeSetupContext = await this.#ensureReadyForTrading({ + requiresBuilderFee: chargesMetamaskBuilderFee, + }); + + // Debug: Log asset map state before order placement + const allMapKeys = Array.from(this.#symbolToAssetId.keys()); + const hip3Keys = allMapKeys.filter((key) => key.includes(':')); + const assetExists = this.#symbolToAssetId.has(params.symbol); + this.#deps.debugLogger.log('Asset map state at order time', { + requestedCoin: params.symbol, + assetExistsInMap: assetExists, + totalAssetsInMap: this.#symbolToAssetId.size, + hip3AssetsCount: hip3Keys.length, + hip3AssetsSample: hip3Keys.slice(0, 10), + hip3Enabled: this.#hip3Enabled, + allowlistMarkets: this.#allowlistMarkets, + blocklistMarkets: this.#blocklistMarkets, + }); + + // Normalize the deprecated decimal `slippage` to bps once so both the + // price-staleness check and the limit-price calc see the same value. + const normalizedMaxSlippageBps = + params.maxSlippageBps ?? + (typeof params.slippage === 'number' + ? Math.round(params.slippage * BASIS_POINTS_DIVISOR) + : undefined); + + const { finalPositionSize } = calculateFinalPositionSize({ + usdAmount: params.usdAmount, + size: params.size, + currentPrice: effectivePrice, + priceAtCalculation: params.priceAtCalculation, + maxSlippageBps: normalizedMaxSlippageBps, + szDecimals: assetInfo.szDecimals, + leverage: params.leverage, + reduceOnly: params.reduceOnly, + }); + + const { orderPrice, formattedSize, formattedPrice } = + calculateOrderPriceAndSize({ + orderType: params.orderType, + isBuy: params.isBuy, + finalPositionSize, + currentPrice: effectivePrice, + limitPrice: params.price, + triggerPrice: params.triggerPrice, + maxSlippageBps: normalizedMaxSlippageBps, + szDecimals: assetInfo.szDecimals, + }); + + // 4. Get asset ID and validate it exists + const assetId = await this.#getAssetIdWithRepair({ + symbol: params.symbol, + dexName, + meta, + }); + + this.#deps.debugLogger.log('Resolved DEX-specific asset ID', { + coin: params.symbol, + dex: dexName ?? 'main', + assetId, + }); + + // 5. Update leverage if specified + await this.#prepareAssetForTrading({ + symbol: params.symbol, + assetId, + leverage: params.leverage, + }); + + // 6. Handle HIP-3 balance management (if applicable) + const isHip3Order = dexName !== null; + let transferInfo: Hip3TransferInfo | null = null; + + if (isHip3Order && dexName) { + const effectiveLeverage = params.leverage ?? assetInfo.maxLeverage ?? 1; + const hip3Result = await this.#handleHip3PreOrder({ + dexName, + symbol: params.symbol, + orderPrice, + positionSize: parseFloat(formattedSize), + leverage: effectiveLeverage, + isBuy: params.isBuy, + maxLeverage: assetInfo.maxLeverage, + }); + transferInfo = hip3Result.transferInfo; + } + + // 7. Build orders array (main + TP/SL if specified) + const { orders, grouping } = buildOrdersArray({ + assetId, + isBuy: params.isBuy, + formattedPrice, + formattedSize, + reduceOnly: params.reduceOnly ?? false, + orderType: params.orderType, + timeInForce: params.timeInForce, + clientOrderId: params.clientOrderId, + triggerPrice: params.triggerPrice, + takeProfitPrice: params.takeProfitPrice, + stopLossPrice: params.stopLossPrice, + takeProfitSize: params.takeProfitSize, + stopLossSize: params.stopLossSize, + szDecimals: assetInfo.szDecimals, + // The provider-agnostic linkage wins; `grouping` is the deprecated + // HyperLiquid-shaped spelling kept for existing callers. + grouping: params.tpslLinkage + ? adaptTpslLinkageToGrouping(params.tpslLinkage) + : params.grouping, + }); + + // 8. Submit order with atomic rollback + return await this.#submitOrderWithRollback({ + orders, + grouping, + isHip3Order, + dexName, + transferInfo, + symbol: params.symbol, + assetId, + chargesMetamaskBuilderFee, + builderFeeSetupContext, + }); + } catch (error) { + // Retry mechanism for $10 minimum order errors + // This handles the case where UI price feed slightly differs from HyperLiquid's orderbook price + const errorMessage = ensureError( + error, + 'HyperLiquidProvider.placeOrder', + ).message; + const isMinimumOrderError = + errorMessage.includes('Order must have minimum value of $10') || + errorMessage.includes('Order 0: Order must have minimum value'); + + // Reduce-only orders are excluded. The retry works by growing the order + // 1.5%, which a close cannot do: a full close already submits the whole + // position, and a partial close is capped at the size the caller asked to + // close, so the retry would either be rejected as "Reduce only order would + // increase position" or resubmit an identical order. Surfacing the + // minimum-value error names the real problem instead. + // + // Strategy placements are excluded for the same reason in a different + // shape: the minimum applies to each TWAP slice and each ladder rung, not + // to the total, so growing the total by 1.5% does not clear it. + if ( + isMinimumOrderError && + retryCount === 0 && + !params.reduceOnly && + !isStrategyOrderType(params.orderType) + ) { + let adjustedUsdAmount: string; + let originalValue: string | undefined; + + if (params.usdAmount) { + // USD-based order: adjust the USD amount directly + originalValue = params.usdAmount; + adjustedUsdAmount = (parseFloat(params.usdAmount) * 1.015).toFixed(2); + } else if (effectivePrice) { + // Size-based order: calculate USD from size and adjust. + // Use the hoisted effectivePrice (fetched live price) so callers that + // omit currentPrice (e.g. flipPosition) can still recover from the + // $10-minimum edge case. + const sizeValue = parseFloat(params.size); + const estimatedUsd = sizeValue * effectivePrice; + originalValue = `${estimatedUsd.toFixed(2)} (calculated from size ${params.size})`; + adjustedUsdAmount = (estimatedUsd * 1.015).toFixed(2); + } else { + // No price information available - cannot retry + return await this.#handleOrderError({ + error, + symbol: params.symbol, + orderType: params.orderType, + isBuy: params.isBuy, + }); + } + + this.#deps.debugLogger.log( + 'Retrying order with adjusted size due to minimum value error', + { + originalValue, + adjustedUsdAmount, + retryCount, + }, + ); + + return this.placeOrder( + { + ...params, + usdAmount: adjustedUsdAmount, + }, + 1, // Retry count = 1, prevents further retries + ); + } + + return await this.#handleOrderError({ + error, + symbol: params.symbol, + orderType: params.orderType, + isBuy: params.isBuy, + }); + } + } + + /** + * Dispatch a strategy placement to the handler that owns it. + * + * @param params - Order parameters, already validated. + * @param orderType - The strategy type, narrowed by the caller. + * @returns A promise that resolves to the result. + */ + async #placeStrategyOrder( + params: OrderParams, + orderType: StrategyOrderType, + ): Promise { + // Captured before the shared preamble, not after it: that preamble awaits + // asset info, validation, trading readiness and a leverage update, and a + // disconnect during any of them has to be seen before or after submission. + const generation = this.#strategyGeneration; + + if (orderType !== 'chase') { + const context = await this.#prepareStrategyPlacement(params); + return await this.#submitPreparedStrategy(params, context, async () => { + if (generation !== this.#strategyGeneration) { + throw new Error(PERPS_ERROR_CODES.PROVIDER_LIFECYCLE_STALE); + } + return orderType === 'twap' + ? await this.#placeTwapOrder(params, context, generation) + : await this.#placeScaleOrder(params, context, generation); + }); + } + + if (this.#chasePlacementBlockers > 0) { + throw new Error(PERPS_ERROR_CODES.ORDER_CHASE_ABANDONED); + } + // The chase slot is claimed before the preamble, not after it: the preamble + // completes the signing setup and can change the asset's leverage, and a + // request that is going to be refused for exceeding the venue's cap must + // not cost either. Reserved in the same synchronous step it is checked, so + // concurrent placements cannot both see room during the round trips that + // follow. + const activeChases = [...this.#chaseSessions.values()].filter( + (session) => session.active, + ).length; + if ( + activeChases + this.#chasePlacementsInFlight >= + CHASE_ORDER_CONFIG.MaxActiveSessions + ) { + throw new Error(PERPS_ERROR_CODES.ORDER_CHASE_LIMIT_REACHED); + } + this.#chasePlacementsInFlight += 1; + this.#pauseChaseTicksForPlacement(); + + let settlePlacement: (() => void) | undefined; + const placementWaiter = new Promise((resolve) => { + settlePlacement = resolve; + }); + this.#chasePlacementWaiters.add(placementWaiter); + + try { + await this.#chaseTickQueue; + const context = await this.#prepareStrategyPlacement(params); + return await this.#submitPreparedStrategy( + params, + context, + async () => await this.#startChaseSession(params, context, generation), + ); + } finally { + settlePlacement?.(); + this.#chasePlacementWaiters.delete(placementWaiter); + this.#chasePlacementsInFlight -= 1; + if (this.#chasePlacementsInFlight === 0) { + this.#resumeChaseTicksAfterPlacement(); + } + } + } + + /** + * Submit a prepared strategy and settle any manual HIP-3 collateral move. + * + * Scale and Chase leave regular orders or fills on the venue, so the normal + * HIP-3 post-order balance check can return any unreserved excess. Native + * TWAP executes future slices, so its transferred collateral stays on the + * routed DEX until cancellation confirms the schedule has stopped. + * + * @param params - Strategy order parameters. + * @param context - Prepared placement and HIP-3 transfer context. + * @param submit - Venue submission. + * @returns The strategy placement result. + */ + async #submitPreparedStrategy( + params: OrderParams, + context: StrategyPlacementContext, + submit: () => Promise, + ): Promise { + const { dexName, network, transferInfo, userAddress } = context; + let result: OrderResult; + + try { + result = await submit(); + } catch (error) { + if (dexName && transferInfo) { + await this.#handleHip3OrderRollback({ dexName, transferInfo }); + } + throw error; + } + + const hasVenueExposure = + result.success === true || + result.orderId !== undefined || + (result.childOrderIds?.length ?? 0) > 0; + if (dexName && transferInfo && !hasVenueExposure) { + await this.#handleHip3OrderRollback({ dexName, transferInfo }); + return result; + } + + if (params.orderType === 'twap' && result.orderId) { + this.#trackedTwapOrders.set( + getTwapOrderScopeKey({ + network, + userAddress, + orderId: result.orderId, + }), + { + symbol: params.symbol, + ...(dexName && transferInfo + ? { hip3Transfer: { dexName, transferInfo } } + : {}), + }, + ); + return result; + } + + if ( + params.orderType === 'chase' && + result.success && + result.orderId && + dexName && + transferInfo + ) { + const session = this.#chaseSessions.get(result.orderId); + if (session) { + session.hip3Transfer = { dexName, transferInfo }; + return result; + } + } + + if (params.orderType === 'chase' && hasVenueExposure) { + // A stale placement can fail after its child rests and the best-effort + // retraction is refused. With no session left to own that child, keep + // manual HIP-3 collateral on its DEX until the caller cancels the + // reported exchange ID. + return result; + } + + if (dexName && transferInfo) { + await this.#handleHip3PostOrderRebalance({ dexName, transferInfo }); + } + + return result; + } + + /** + * Run the shared preamble every strategy placement needs. + * + * Mirrors the single-order path's own preamble — validate against a live + * price, complete the signing setup, resolve the asset ID, apply leverage — + * so a strategy order is held to the same rules as an ordinary one. + * + * @param params - Order parameters. + * @returns The asset and sizing context the strategy handlers submit with. + */ + async #prepareStrategyPlacement( + params: OrderParams, + ): Promise { + const { dex: dexName } = parseAssetName(params.symbol); + + const { assetInfo, currentPrice, meta } = await this.#getAssetInfo({ + symbol: params.symbol, + dexName, + }); + + const effectivePrice = + params.currentPrice && params.currentPrice > 0 + ? params.currentPrice + : currentPrice; + + await this.#validateOrderBeforePlacement({ + ...params, + currentPrice: effectivePrice, + }); + + const normalizedMaxSlippageBps = + params.maxSlippageBps ?? + (typeof params.slippage === 'number' + ? Math.round(params.slippage * BASIS_POINTS_DIVISOR) + : undefined); + + const { finalPositionSize } = calculateFinalPositionSize({ + usdAmount: params.usdAmount, + size: params.size, + currentPrice: effectivePrice, + priceAtCalculation: params.priceAtCalculation, + maxSlippageBps: normalizedMaxSlippageBps, + szDecimals: assetInfo.szDecimals, + leverage: params.leverage, + reduceOnly: params.reduceOnly, + }); + + const formattedSize = formatHyperLiquidSize({ + size: finalPositionSize, + szDecimals: assetInfo.szDecimals, + }); + + // Everything below is checked here, before anything is signed: it needs + // `szDecimals`, which only arrives with the asset info above, and these are + // caller mistakes that must not cost a leverage change or a signing prompt + // first. + const ladder = + params.orderType === 'scale' + ? this.#buildScaleLadder({ + params, + szDecimals: assetInfo.szDecimals, + finalPositionSize, + }) + : undefined; + + // `validateOrder` applied the venue minimum to the notional the caller + // asked for; this applies it to the size actually being submitted. The two + // differ whenever sizing floors onto the grid rather than rounding up to + // meet the requested USD — which is exactly what a reduce-only order does, + // since the venue rejects a close larger than the position. A boundary + // amount can therefore clear the check the caller sees and still arrive + // under the minimum the venue charges against what it receives. + if (params.orderType === 'twap' || params.orderType === 'chase') { + const submittedNotional = parseFloat(formattedSize) * effectivePrice; + const minimumNotional = + params.orderType === 'twap' + ? HYPERLIQUID_TWAP_LIMITS.MinNotionalUsd + : this.#getMinimumOrderSize(); + + if (submittedNotional < minimumNotional) { + throw new Error( + params.orderType === 'twap' + ? PERPS_ERROR_CODES.ORDER_TWAP_NOTIONAL_TOO_SMALL + : PERPS_ERROR_CODES.ORDER_SIZE_MIN, + ); + } + } + + // Kept after validation so an invalid strategy order never triggers the + // signature prompts in trading setup — same ordering as `placeOrder`. + const { chargesMetamaskBuilderFee } = this.#resolveOrderFeePolicy(params); + const builderFeeSetupContext = chargesMetamaskBuilderFee + ? await this.#ensureReadyForTrading({ requiresBuilderFee: true }) + : await this.#ensureReadyForTrading({ requiresBuilderFee: false }); + + const assetId = await this.#getAssetIdWithRepair({ + symbol: params.symbol, + dexName, + meta, + }); + + await this.#prepareAssetForTrading({ + symbol: params.symbol, + assetId, + leverage: params.leverage, + }); + + const builder = builderFeeSetupContext + ? await this.#getBuilderOrderContext(builderFeeSetupContext) + : undefined; + const network = this.#clientService.isTestnetMode() ? 'testnet' : 'mainnet'; + const userAddress = + builderFeeSetupContext?.userAddress ?? + (await this.#walletService.getUserAddressWithDefault()); + + let transferInfo: Hip3TransferInfo | null = null; + if (dexName) { + const orderPrice = + ladder === undefined + ? effectivePrice + : Math.max(...ladder.prices.map(Number.parseFloat)); + const hip3Result = await this.#handleHip3PreOrder({ + dexName, + symbol: params.symbol, + orderPrice, + positionSize: parseFloat(formattedSize), + leverage: params.leverage ?? assetInfo.maxLeverage ?? 1, + isBuy: params.isBuy, + maxLeverage: assetInfo.maxLeverage, + }); + transferInfo = hip3Result.transferInfo; + } + + return { + assetId, + szDecimals: assetInfo.szDecimals, + formattedSize, + ladder, + builder, + dexName, + network, + transferInfo, + userAddress, + }; + } + + /** + * The venue's minimum value for a single order, in USD. + * + * @returns The minimum for the network this provider is pointed at. + */ + #getMinimumOrderSize(): number { + return this.#clientService.isTestnetMode() + ? TRADING_DEFAULTS.amount.testnet + : TRADING_DEFAULTS.amount.mainnet; + } + + /** + * Build a scale placement's rungs and check them as the venue will see them. + * + * Three things can only be decided once the asset's precision is known, and + * all of them are decided here rather than after the order is on its way: + * + * - **Prices collapse.** Two distinct rung prices can round onto the same + * venue-formatted price, which would submit several orders at one price + * instead of a ladder spanning the requested range. + * - **Slices are not the average.** `splitScaleSizes` floors onto the size + * grid, and a `scaleSkew` weights the rungs along the ladder on top of that, + * so no rung carries the average slice the pre-network check in + * `validateOrder` approximates with. + * - **The cheapest rung decides.** Each rung is an independent order, so the + * venue applies its per-order minimum to the smallest slice at the lowest + * price, not to the ladder's total. + * + * @param options - Ladder inputs. + * @param options.params - Order parameters. + * @param options.szDecimals - The asset's size and price precision. + * @param options.finalPositionSize - Total size to spread across the rungs. + * @returns The rungs, exactly as they will be submitted. + */ + #buildScaleLadder(options: { + params: OrderParams; + szDecimals: number; + finalPositionSize: number; + }): ScaleLadder { + const { params, szDecimals, finalPositionSize } = options; + const { scaleMinPrice, scaleMaxPrice, scaleNumOrders } = params; + if (scaleMinPrice === undefined || scaleMaxPrice === undefined) { + throw new Error(PERPS_ERROR_CODES.ORDER_SCALE_RANGE_REQUIRED); + } + if (scaleNumOrders === undefined) { + throw new Error(PERPS_ERROR_CODES.ORDER_SCALE_COUNT_INVALID); + } + + const prices = computeScalePriceLadder({ + minPrice: parseFloat(scaleMinPrice), + maxPrice: parseFloat(scaleMaxPrice), + count: scaleNumOrders, + }).map((price) => formatHyperLiquidPrice({ price, szDecimals })); + + if (new Set(prices).size !== prices.length) { + throw new Error(PERPS_ERROR_CODES.ORDER_SCALE_RANGE_INVALID); + } + + // Throws ORDER_SCALE_SIZE_TOO_SMALL when a rung would round to nothing, + // which a skew weighted far enough from even can do on its own. + const sizes = splitScaleSizes({ + totalSize: finalPositionSize, + count: scaleNumOrders, + szDecimals, + skew: params.scaleSkew, + }); + + const minimumOrderSize = this.#getMinimumOrderSize(); + const cheapestRungNotional = Math.min( + ...sizes.map( + (size, index) => parseFloat(size) * parseFloat(prices[index]), + ), + ); + if (cheapestRungNotional < minimumOrderSize) { + throw new Error(PERPS_ERROR_CODES.ORDER_SCALE_NOTIONAL_TOO_SMALL); + } + + return { prices, sizes }; + } + + /** + * Submit a TWAP through HyperLiquid's dedicated TWAP action. + * + * A TWAP is not an order on the book, so it does not go through the `order` + * action, carries no builder fee, and comes back identified by a `twapId` + * rather than an `oid`. That ID is the handle `cancelOrder` needs. + * + * @param params - Order parameters. + * @param context - Prepared asset and sizing context. + * @param generation - Teardown generation captured before preparation. + * @returns A promise that resolves to the result. + */ + async #placeTwapOrder( + params: OrderParams, + context: StrategyPlacementContext, + generation: number, + ): Promise { + const { assetId, formattedSize } = context; + const durationMinutes = params.twapDuration; + if (durationMinutes === undefined) { + throw new Error(PERPS_ERROR_CODES.ORDER_TWAP_DURATION_REQUIRED); + } + if ( + !Number.isSafeInteger(durationMinutes) || + durationMinutes < HYPERLIQUID_TWAP_LIMITS.MinDurationMinutes || + durationMinutes > HYPERLIQUID_TWAP_LIMITS.MaxDurationMinutes + ) { + throw new Error(PERPS_ERROR_CODES.ORDER_TWAP_DURATION_INVALID); + } + const exchangeClient = this.#clientService.getExchangeClient(); + + this.#deps.debugLogger.log('Submitting TWAP order', { + symbol: params.symbol, + assetId, + size: formattedSize, + durationMinutes, + randomize: params.twapRandomize ?? false, + }); + + const result = await exchangeClient.twapOrder({ + twap: { + a: assetId, + b: params.isBuy, + s: formattedSize, + r: params.reduceOnly ?? false, + m: durationMinutes, + t: params.twapRandomize ?? false, + }, + }); + + if (result.status !== 'ok') { + throw new Error(`TWAP order failed: ${JSON.stringify(result)}`); + } + + const status: unknown = result.response?.data?.status; + if (!isStatusObject(status)) { + throw new Error('TWAP order rejected'); + } + + const rawError = + typeof status.error === 'string' ? status.error : 'TWAP order rejected'; + const { running } = status; + if ( + !isStatusObject(running) || + typeof running.twapId !== 'number' || + !Number.isSafeInteger(running.twapId) || + running.twapId < 0 + ) { + throw new Error(rawError); + } + + const orderId = running.twapId.toString(); + if (generation !== this.#strategyGeneration) { + let remainsLive = true; + try { + const cancelResult = await exchangeClient.twapCancel({ + a: assetId, + t: running.twapId, + }); + remainsLive = + classifyCancelStatus(cancelResult.response?.data?.status) === + CancelChildOutcome.Refused; + } catch (error) { + this.#deps.debugLogger.log( + 'Stale TWAP placement could not be retracted', + { + error: ensureError( + error, + 'HyperLiquidProvider.placeTwapOrder.retract', + ).message, + orderId, + }, + ); + } + return createErrorResult( + new Error(PERPS_ERROR_CODES.PROVIDER_LIFECYCLE_STALE), + { + success: false, + submittedSize: formattedSize, + ...(remainsLive && { orderId }), + }, + ); + } + + return { + success: true, + orderId, + submittedSize: formattedSize, + }; + } + + /** + * Fan a scale placement out into one batch of resting limit orders. + * + * The whole ladder goes in a single `order` action, which is one round trip + * and one signature rather than one per rung. It is **not** atomic: an `na` + * grouping evaluates each entry independently. Every rung must either rest + * or fill; otherwise all known resting rungs are retracted before failure. + * + * @param params - Order parameters. + * @param context - Prepared asset and sizing context. + * @param generation - Teardown generation captured before preparation. + * @returns A promise that resolves to the result. + */ + async #placeScaleOrder( + params: OrderParams, + context: StrategyPlacementContext, + generation: number, + ): Promise { + const { assetId, formattedSize, ladder, builder } = context; + // Built and validated in `#prepareStrategyPlacement`, before anything was + // signed, so what is submitted here is exactly what the minimums were + // applied to. + if (!ladder) { + throw new Error(PERPS_ERROR_CODES.ORDER_SCALE_RANGE_REQUIRED); + } + const { prices, sizes } = ladder; + const count = prices.length; + const { groupId, clientOrderIds } = createScaleOrderIdentity(count); + + const orders: SDKOrderParams[] = prices.map((price, index) => ({ + a: assetId, + b: params.isBuy, + p: price, + s: sizes[index], + r: params.reduceOnly ?? false, + t: { limit: { tif: 'Gtc' as const } }, + c: clientOrderIds[index], + })); + + this.#deps.debugLogger.log('Submitting scale ladder', { + symbol: params.symbol, + assetId, + count, + prices: orders.map((order) => order.p), + sizes, + }); + + const exchangeClient = this.#clientService.getExchangeClient(); + const result = await exchangeClient.order({ + orders, + grouping: 'na', + ...(builder && { builder }), + }); + + const statuses = result.response?.data?.statuses ?? []; + const outcomes = statuses + .slice(0, count) + .map((status) => this.#readOrderPlacementOutcome(status)); + const acceptedCount = outcomes.filter( + (outcome) => outcome !== undefined, + ).length; + const restingChildOrderIds = outcomes.flatMap((outcome) => + outcome?.state === 'resting' ? [outcome.orderId] : [], + ); + const filledChildOrderIds = outcomes.flatMap((outcome) => + outcome?.state === 'filled' ? [outcome.orderId] : [], + ); + + if (generation !== this.#strategyGeneration) { + const remainingOrderIds = await this.#cancelOrderRequests( + exchangeClient, + restingChildOrderIds.map((orderId) => ({ + a: assetId, + o: Number(orderId), + })), + ); + const recoverableOrderIds = [ + ...filledChildOrderIds, + ...remainingOrderIds.map(String), + ]; + return createErrorResult( + new Error(PERPS_ERROR_CODES.PROVIDER_LIFECYCLE_STALE), + { + success: false, + submittedSize: formattedSize, + ...(recoverableOrderIds.length > 0 && { + childOrderIds: recoverableOrderIds, + }), + }, + ); + } + + if ( + result.status !== 'ok' || + statuses.length !== count || + acceptedCount !== count + ) { + this.#deps.debugLogger.log('Scale ladder was not fully accepted', { + accepted: acceptedCount, + filled: filledChildOrderIds.length, + resting: restingChildOrderIds.length, + requested: count, + statuses, + }); + const remainingOrderIds = await this.#cancelOrderRequests( + exchangeClient, + restingChildOrderIds.map((orderId) => ({ + a: assetId, + o: Number(orderId), + })), + ); + const recoverableOrderIds = [ + ...filledChildOrderIds, + ...remainingOrderIds.map(String), + ]; + if (remainingOrderIds.length > 0) { + const remainingRestingOrderIds = remainingOrderIds.map(String); + this.#scaleOrderGroups.set(groupId, { + symbol: params.symbol, + orderIds: remainingRestingOrderIds, + }); + return createErrorResult( + new Error(PERPS_ERROR_CODES.ORDER_STRATEGY_CANCEL_INCOMPLETE), + { + success: false, + orderId: groupId, + childOrderIds: recoverableOrderIds, + submittedSize: formattedSize, + }, + ); + } + + if (recoverableOrderIds.length > 0) { + return createErrorResult(new Error(PERPS_ERROR_CODES.ORDER_REJECTED), { + success: false, + childOrderIds: recoverableOrderIds, + submittedSize: formattedSize, + }); + } + + throw new Error(PERPS_ERROR_CODES.ORDER_REJECTED); + } + + this.#scaleOrderGroups.set(groupId, { + symbol: params.symbol, + orderIds: restingChildOrderIds, + }); + return { + success: true, + orderId: groupId, + childOrderIds: restingChildOrderIds, + submittedSize: formattedSize, + }; + } + + /** + * Place a chase's first order and register the session that follows it. + * + * Split from the routing above so the concurrency reservation there wraps + * every await in one `try`/`finally`. + * + * @param params - Order parameters. + * @param context - Prepared asset and sizing context. + * @param generation - Teardown generation captured before any await. + * @returns A promise that resolves to the result. + */ + async #startChaseSession( + params: OrderParams, + context: StrategyPlacementContext, + generation: number, + ): Promise { + const { assetId, szDecimals, formattedSize, builder } = context; + + // The preamble is several round trips long. A disconnect during it has + // already torn down everything this session would run on, so the chase + // stops here rather than reading the book and putting a fresh order on it + // for a provider that no longer exists. + if (generation !== this.#strategyGeneration) { + throw new Error(PERPS_ERROR_CODES.ORDER_CHASE_ABANDONED); + } + + let quotePrice = await this.#getChaseQuotePrice({ + symbol: params.symbol, + isBuy: params.isBuy, + szDecimals, + }); + if ( + quotePrice === CHASE_ORDER_STATUS_UNAVAILABLE || + typeof quotePrice !== 'string' + ) { + // A first placement has no child whose terminal state could be read. + throw new Error(PERPS_ERROR_CODES.ORDER_CHASE_TOUCH_UNAVAILABLE); + } + + // The book read is a round trip of its own, so the check above does not + // cover it. Re-checked here, immediately before the only statement that + // signs anything: every await on this path is now followed by a refusal + // before the next one, leaving one window that cannot be closed from here + // — a disconnect arriving while the submission itself is in flight, which + // the check after it handles. + if (generation !== this.#strategyGeneration) { + throw new Error(PERPS_ERROR_CODES.ORDER_CHASE_ABANDONED); + } + + // Held onto rather than looked up again after the submission returns. + // `disconnect` drops the service's client reference synchronously, so a + // retraction that asked for a client after the fact would be refused one + // and leave the order resting under the account that placed it. This + // instance signed the order and can still cancel it as that account. + const placingClient = this.#clientService.getExchangeClient(); + + let orderId: string | undefined; + for ( + let attempt = 1; + attempt <= CHASE_ORDER_CONFIG.InitialPlacementAttempts; + attempt += 1 + ) { + try { + orderId = await this.#restChaseOrder({ + assetId, + isBuy: params.isBuy, + price: quotePrice, + size: formattedSize, + reduceOnly: params.reduceOnly ?? false, + builder, + exchangeClient: placingClient, + }); + break; + } catch (error) { + if ( + attempt === CHASE_ORDER_CONFIG.InitialPlacementAttempts || + !isRetryableChasePlacementError(error) + ) { + throw error; + } + if (generation !== this.#strategyGeneration) { + throw new Error(PERPS_ERROR_CODES.ORDER_CHASE_ABANDONED); + } + const refreshedQuote = await this.#getChaseQuotePrice({ + symbol: params.symbol, + isBuy: params.isBuy, + szDecimals, + }); + if ( + refreshedQuote === CHASE_ORDER_STATUS_UNAVAILABLE || + typeof refreshedQuote !== 'string' + ) { + throw new Error(PERPS_ERROR_CODES.ORDER_CHASE_TOUCH_UNAVAILABLE); + } + if (generation !== this.#strategyGeneration) { + throw new Error(PERPS_ERROR_CODES.ORDER_CHASE_ABANDONED); + } + quotePrice = refreshedQuote; + } + } + if (!orderId) { + throw new Error(PERPS_ERROR_CODES.ORDER_CHASE_TOUCH_UNAVAILABLE); + } + + const intervalMs = + params.chaseIntervalMs ?? CHASE_ORDER_CONFIG.DefaultIntervalMs; + const sessionId = generatePerpsId('chase'); + const session: ChaseSession = { + symbol: params.symbol, + assetId, + isBuy: params.isBuy, + size: formattedSize, + originalSize: formattedSize, + szDecimals, + reduceOnly: params.reduceOnly ?? false, + orderId, + replacingOrderId: null, + pendingReplacement: null, + restingPrice: quotePrice, + arrivalPrice: quotePrice, + startedAt: Date.now(), + maxDistanceBps: params.chaseMaxDistanceBps, + intervalMs, + lastSnapshotSizeRefreshAt: 0, + builder, + deadline: + params.chaseMaxDurationMs === undefined + ? Number.POSITIVE_INFINITY + : Date.now() + params.chaseMaxDurationMs, + maxRepricings: params.chaseMaxRepricings ?? Number.POSITIVE_INFINITY, + repricings: 0, + timer: null, + active: true, + status: CHASE_ORDER_STATUS.Active, + }; + // A disconnect landed while the submission was in flight — the one window + // the checks above cannot close. The order rested, but no strategy is + // running behind it and no handle names it, so this placement is reported + // as a failure. That makes the resting order an orphan: the caller is told + // the chase failed, nothing refreshes the caches a successful placement + // would have invalidated, and the exchange id is only usable for as long as + // the provider stays pointed at the account that placed it — a disconnect + // is usually followed by an account or network switch, after which handing + // the id back is no remedy at all. So it is cancelled here, through the + // client that signed it — the account switch cannot reach that instance — + // and the failure is then true of the venue as well as of this provider. + // Best-effort still: the transport underneath that client may already be + // closing, so a cancel that does not land falls back to reporting the id. + if (generation !== this.#strategyGeneration) { + this.#deps.debugLogger.log('Chase placement outlived its provider', { + orderId, + restingPrice: quotePrice, + }); + + const outcome = await this.#retractOrphanedChaseOrder( + session, + placingClient, + ); + return createErrorResult( + new Error(PERPS_ERROR_CODES.ORDER_CHASE_ABANDONED), + { + success: false, + // Reported only when the retraction did not take. Naming an order + // that is no longer on the book would send the caller to cancel + // something already gone; naming one that still rests is the only + // route left to it. + ...(outcome === CancelChildOutcome.Refused + ? { childOrderIds: [orderId] } + : {}), + submittedSize: formattedSize, + }, + ); + } + + this.#chaseSessions.set(sessionId, session); + this.#scheduleChaseTick(sessionId); + + this.#deps.debugLogger.log('Chase session started', { + sessionId, + symbol: params.symbol, + orderId, + restingPrice: quotePrice, + intervalMs, + }); + + return { + success: true, + orderId: sessionId, + childOrderIds: [orderId], + submittedSize: formattedSize, + }; + } + + /** + * Read the price a chase must rest at: the best bid for a buy, the best ask + * for a sell. + * + * The touch, not the mid: a post-only order priced at the mid would either + * cross or be rejected, and a chase that never rests at the front of the + * queue is not chasing anything. + * + * @param params - The lookup parameters. + * @param params.symbol - Market to read. + * @param params.isBuy - Which side the chase rests on. + * @param params.szDecimals - Asset size precision, for price formatting. + * @param params.own - The chase's own resting order, excluded from the book. + * @param params.own.orderId - Exchange ID of that order. + * @param params.own.price - Price that order rests at. + * @param params.own.size - Size it was last placed for. + * @returns The formatted price, a terminal child result, or the status + * unavailable sentinel when the child may still be live. + */ + async #getChaseQuotePrice(params: { + symbol: string; + isBuy: boolean; + szDecimals: number; + /** The chase's own resting order, excluded from the book it reads. */ + own?: { orderId: string; price: string; size: string }; + }): Promise< + string | ChaseTerminalResolution | typeof CHASE_ORDER_STATUS_UNAVAILABLE + > { + const { symbol, isBuy, szDecimals, own } = params; + + const book = await this.#clientService.getInfoClient().l2Book({ + coin: symbol, + }); + + // `levels` is [bids, asks], each best-first. + const ownSide = book?.levels?.[isBuy ? 0 : 1]; + const netting = await this.#resolveOwnRestingSizes({ + symbol, + isBuy, + callerOrderId: own?.orderId, + ownSide, + }); + if ( + netting === CHASE_ORDER_STATUS_UNAVAILABLE || + !(netting instanceof Map) + ) { + return netting; + } + + const bestBid = readBestExternalPrice(book?.levels?.[0], netting); + const bestAsk = readBestExternalPrice(book?.levels?.[1], netting); + + if (bestBid === null || bestAsk === null) { + throw new Error(PERPS_ERROR_CODES.ORDER_CHASE_TOUCH_UNAVAILABLE); + } + + return computeChaseQuotePrice({ bestBid, bestAsk, isBuy, szDecimals }); + } + + /** + * Rest one post-only order for a chase and return its exchange ID. + * + * @param params - The placement parameters. + * @param params.assetId - Resolved asset ID. + * @param params.isBuy - Order side. + * @param params.price - Formatted limit price. + * @param params.size - Formatted size. + * @param params.reduceOnly - Whether the order may only reduce a position. + * @param params.builder - Builder context captured when the session started. + * @param params.exchangeClient - Client to submit through. Passed in rather + * than looked up here so a first placement can keep the instance it signed + * with, which is the only one that can take the order back once `disconnect` + * has dropped the service's reference. + * @returns The resting order's exchange ID. + */ + async #restChaseOrder(params: { + assetId: number; + isBuy: boolean; + price: string; + size: string; + reduceOnly: boolean; + builder?: BuilderOrderContext; + exchangeClient: ExchangeClient; + }): Promise { + const result = await params.exchangeClient.order({ + orders: [ + { + a: params.assetId, + b: params.isBuy, + p: params.price, + s: params.size, + r: params.reduceOnly, + // Post-only: a chase adds liquidity at the touch. Crossing would end + // the chase on its first tick at a worse price than resting does. + t: { limit: { tif: 'Alo' as const } }, + }, + ], + grouping: 'na', + ...(params.builder && { builder: params.builder }), + }); + + if (result.status !== 'ok') { + throw new Error(`Chase order failed: ${JSON.stringify(result)}`); + } + + const orderId = this.#readOrderIdFromStatus( + result.response?.data?.statuses?.[0], + ); + if (!orderId) { + throw new Error( + `Chase order rejected: ${JSON.stringify(result.response?.data)}`, + ); + } + + return orderId; + } + + /** + * Schedule the next tick of a chase session. + * + * @param sessionId - Session to advance. + */ + #scheduleChaseTick(sessionId: string): void { + const session = this.#chaseSessions.get(sessionId); + if (!session?.active || session.timer !== null) { + return; + } + + const timer = setTimeout(() => { + session.timer = null; + this.#chaseTickQueue = this.#chaseTickQueue + .then(() => this.#runChaseTick(sessionId)) + .catch((error: unknown) => { + // Resolve the shared queue after every failure. Otherwise one + // rejected tick prevents all later ticks and teardown from running. + this.#deps.debugLogger.log('Chase tick failed', { + sessionId, + error: ensureError(error, 'HyperLiquidProvider.chaseTick').message, + }); + this.#recoverFailedChaseTick(sessionId); + }); + }, session.intervalMs); + + // Node keeps the process alive for a pending timer; a background chase is + // not a reason to hold an exiting process open. React Native's timers have + // no `unref`, hence the guard. + timer.unref?.(); + session.timer = timer; + } + + /** Pause scheduled background work while a foreground Chase is submitted. */ + #pauseChaseTicksForPlacement(): void { + for (const session of this.#chaseSessions.values()) { + if (session.timer) { + clearTimeout(session.timer); + session.timer = null; + } + } + } + + /** Restart the polling cadence after all foreground placements settle. */ + #resumeChaseTicksAfterPlacement(): void { + for (const [sessionId, session] of this.#chaseSessions) { + if (session.active && !session.timer) { + this.#scheduleChaseTick(sessionId); + } + } + } + + /** + * Decide what a session does after one of its ticks threw. + * + * Two outcomes, told apart by whether the session still owns a resting order: + * + * - It does — the failure was a book read or a refused cancel, and the order + * is untouched. The chase keeps going; the deadline check at the top of the + * next tick ends it if the window has since closed. Letting a transient + * error end the chase would strand a live order under a session that still + * claimed to be re-pricing it. + * - It does not — the tick cancelled the order and then failed to rest its + * replacement, so nothing is on the book. There is nothing left to chase and + * nothing left to cancel, so the session ends here. Leaving it `active` would + * report a live chase with no timer, and every later cancel would try to + * cancel the dead order and keep the handle forever. + * + * @param sessionId - Session whose tick failed. + */ + #recoverFailedChaseTick(sessionId: string): void { + const session = this.#chaseSessions.get(sessionId); + if (!session?.active) { + return; + } + + if (session.orderId === null) { + session.active = false; + session.status = CHASE_ORDER_STATUS.Failed; + this.#deps.debugLogger.log('Chase ended with nothing resting', { + sessionId, + }); + this.#rebalanceChaseSession(session).catch((error: unknown) => { + this.#deps.debugLogger.log('Chase collateral cleanup failed', { + sessionId, + error: ensureError( + error, + 'HyperLiquidProvider.recoverFailedChaseTick', + ).message, + }); + }); + return; + } + + this.#scheduleChaseTick(sessionId); + } + + /** + * Advance one chase session: re-price if the touch has moved, stop if the + * window has closed. + * + * @param sessionId - Session to advance. + */ + async #runChaseTick(sessionId: string): Promise { + const session = this.#chaseSessions.get(sessionId); + if (!session?.active) { + return; + } + + // Give a new Chase placement priority over background cancel/replace + // traffic. With several active sessions, their signed mutations can + // otherwise overlap the placement preamble and trip the SDK transport's + // circuit breaker before the new order reaches the venue. Deferring one + // polling interval leaves every existing child resting as a valid ALO and + // preserves the venue's five-session limit without increasing request + // churn. + if (this.#chasePlacementsInFlight > 0) { + this.#scheduleChaseTick(sessionId); + return; + } + + if (Date.now() >= session.deadline) { + // The window closed. The last order stays resting as an ordinary limit + // order and the session stays registered, so cancelling by its handle + // still reaches that order. + session.active = false; + session.status = CHASE_ORDER_STATUS.DurationReached; + this.#deps.debugLogger.log('Chase session window closed', { sessionId }); + await this.#rebalanceChaseSession(session); + return; + } + + const rawQuotePrice = await this.#getChaseQuotePrice({ + symbol: session.symbol, + isBuy: session.isBuy, + szDecimals: session.szDecimals, + own: session.orderId + ? { + orderId: session.orderId, + price: session.restingPrice, + size: session.size, + } + : undefined, + }); + + if (typeof rawQuotePrice !== 'string') { + // The order left the book without the loop seeing it. Preserve the venue + // status so an external cancellation is not reported as a fill. + session.orderId = null; + session.active = false; + session.status = rawQuotePrice.status; + session.size = rawQuotePrice.remainingSize ?? '0'; + this.#deps.debugLogger.log('Chase order ended between ticks', { + sessionId, + status: session.status, + }); + await this.#rebalanceChaseSession(session); + return; + } + if (rawQuotePrice === CHASE_ORDER_STATUS_UNAVAILABLE) { + // The child may still be live. Leave it untouched and retry on the next + // normal interval instead of permanently stranding the Chase after one + // short status outage. + this.#deps.debugLogger.log('Chase order status unavailable', { + sessionId, + orderId: session.orderId, + }); + this.#scheduleChaseTick(sessionId); + return; + } + + const arrivalPrice = Number.parseFloat(session.arrivalPrice); + const rawQuote = Number.parseFloat(rawQuotePrice); + const adverseDistanceFromArrival = session.isBuy + ? (rawQuote - arrivalPrice) / arrivalPrice + : (arrivalPrice - rawQuote) / arrivalPrice; + const boundaryDirection = session.isBuy ? 1 : -1; + const maxDistanceRatio = + session.maxDistanceBps === undefined + ? undefined + : session.maxDistanceBps / BASIS_POINTS_DIVISOR; + const boundaryPrice = + maxDistanceRatio === undefined + ? undefined + : arrivalPrice * (1 + boundaryDirection * maxDistanceRatio); + let reachedMaxDistance = + boundaryPrice !== undefined && + maxDistanceRatio !== undefined && + adverseDistanceFromArrival >= maxDistanceRatio; + let quotePrice = rawQuotePrice; + if (reachedMaxDistance && boundaryPrice !== undefined) { + quotePrice = formatHyperLiquidPrice({ + price: boundaryPrice, + szDecimals: session.szDecimals, + }); + const formattedBoundary = Number.parseFloat(quotePrice); + const overshootsBoundary = session.isBuy + ? formattedBoundary > boundaryPrice + : formattedBoundary < boundaryPrice; + if (overshootsBoundary) { + const tick = getPriceTick({ + price: boundaryPrice, + szDecimals: session.szDecimals, + }); + quotePrice = formatHyperLiquidPrice({ + price: session.isBuy + ? formattedBoundary - tick + : formattedBoundary + tick, + szDecimals: session.szDecimals, + }); + } + } + + // The book read is a round trip, and a cancel arriving during it stops the + // session. Without this re-check the tick would cancel and re-place the very + // order the caller just asked to be rid of. + if (!session.active) { + return; + } + + if (quotePrice !== session.restingPrice && session.orderId) { + const outcome = await this.#cancelChaseChild(session); + + // The cancel is a second round trip, and this is the window where a + // caller's `cancelOrder` does the most damage: it would cancel the order + // this tick has already cancelled, report the cancel incomplete, and then + // this tick would rest a replacement the caller has no idea exists. + // Stopping here leaves nothing on the book. + if (!session.active) { + // A refused cancel leaves the child resting. Preserve its ID so the + // caller that stopped this session can report an incomplete cancel and + // retry it instead of orphaning the order. + if (outcome !== CancelChildOutcome.Refused) { + session.orderId = null; + } + return; + } + + if (outcome === CancelChildOutcome.Refused) { + // The exchange kept the order, so it is still resting at the old price. + // Placing the replacement anyway would double the position. Leave it + // alone and try again on the next tick, which is why this falls through + // to the reschedule below rather than ending the session. + this.#deps.debugLogger.log('Chase reprice cancel refused', { + sessionId, + orderId: session.orderId, + }); + reachedMaxDistance = false; + } else if (outcome === CancelChildOutcome.Gone) { + // The order left the book between ticks: it filled, or something else + // cancelled it. There is nothing left to chase. + const goneOrderId = session.orderId; + session.orderId = null; + session.active = false; + let remainder: ChaseOrderRemainder; + try { + remainder = await this.#readOrderRemainder( + goneOrderId, + CHASE_ORDER_STATUS_RETRY_COUNT, + ); + } catch (error) { + session.status = CHASE_ORDER_STATUS.Failed; + this.#deps.debugLogger.log('Chase order status unavailable', { + sessionId, + orderId: goneOrderId, + error: ensureError( + error, + 'HyperLiquidProvider.chaseGoneOrderStatus', + ).message, + }); + await this.#rebalanceChaseSession(session); + return; + } + session.status = remainder.terminalStatus ?? CHASE_ORDER_STATUS.Failed; + if (remainder.remainingSize !== null) { + session.size = remainder.remainingSize; + } + this.#deps.debugLogger.log('Chase order no longer resting', { + sessionId, + status: session.status, + remainingSize: session.size, + }); + await this.#rebalanceChaseSession(session); + return; + } else { + // The cancel is confirmed, so the session owns nothing from here on. + // Recorded before the read rather than after it: a read that rejects + // would otherwise leave the session naming an order that is already off + // the book, and recovery would reschedule a chase with nothing resting. + const cancelledOrderId = session.orderId; + session.replacingOrderId = cancelledOrderId; + session.orderId = null; + + // No further fills can reach a cancelled order, so what it did not fill + // is now fixed. Sampling before the cancel would have left a window in + // which a fill lands and is then re-placed. + let remainder: ChaseOrderRemainder; + try { + remainder = await this.#readOrderRemainder( + cancelledOrderId, + CHASE_ORDER_STATUS_RETRY_COUNT, + ); + } catch (error) { + session.replacingOrderId = null; + throw error; + } + + // Another round trip, another window for a cancel to land. By now the + // old order is already off the book, so a cancel that arrived during it + // found nothing to cancel and reported success — resting a replacement + // after that would put an order on the book the caller believes is gone + // and has no handle for. + if (!session.active || !this.#chaseSessions.has(sessionId)) { + session.replacingOrderId = null; + this.#deps.debugLogger.log('Chase cancelled mid-reprice', { + sessionId, + }); + return; + } + + if ( + remainder.remainingSize === null || + (remainder.terminalStatus !== null && + remainder.terminalStatus !== CHASE_ORDER_STATUS.Canceled) + ) { + // Nothing is left to chase. Preserve whether the venue actually + // confirmed a fill rather than labelling every zero remainder filled. + session.replacingOrderId = null; + session.active = false; + session.size = remainder.remainingSize ?? '0'; + session.status = + remainder.terminalStatus ?? CHASE_ORDER_STATUS.Failed; + this.#deps.debugLogger.log('Chase order has no remainder', { + sessionId, + status: session.status, + }); + await this.#rebalanceChaseSession(session); + return; + } + + const remaining = remainder.remainingSize; + + // Published *before* the placement starts, not from the promise it + // returns: the window opens the moment `#restChaseOrder` is entered, so + // deriving the marker from its return value would leave the synchronous + // prefix of that call unguarded. A cancel arriving anywhere inside the + // round trip now waits for the replacement to land instead of reading + // the null `orderId` as "nothing rests", reporting success, and dropping + // the handle while this call is still putting an order on the book. + let settleReplacement: (() => void) | undefined; + session.pendingReplacement = new Promise((resolve) => { + settleReplacement = resolve; + }); + + try { + session.orderId = await this.#restChaseOrder({ + assetId: session.assetId, + isBuy: session.isBuy, + price: quotePrice, + size: remaining, + reduceOnly: session.reduceOnly, + builder: session.builder, + // A running session is on a live provider, so the current client is + // the right one; only the first placement has a teardown to survive. + exchangeClient: this.#clientService.getExchangeClient(), + }); + session.size = remaining; + } finally { + // Cleared before the waiters wake, so they see the settled session. + session.replacingOrderId = null; + session.pendingReplacement = null; + settleReplacement?.(); + } + session.restingPrice = quotePrice; + session.repricings += 1; + + this.#deps.debugLogger.log('Chase re-priced', { + sessionId, + orderId: session.orderId, + restingPrice: quotePrice, + size: session.size, + repricings: session.repricings, + }); + + // A cancel that arrived during the round trip is now waiting on this + // session; it cancels the order just recorded above. A `disconnect` + // deregisters the session instead, and deliberately leaves resting + // orders alone — logged with its ID so it stays traceable. + if (!this.#chaseSessions.has(sessionId)) { + this.#deps.debugLogger.log('Chase replacement outlived its session', { + sessionId, + orderId: session.orderId, + }); + return; + } + if (!session.active) { + return; + } + } + } + + if (reachedMaxDistance && session.maxDistanceBps !== undefined) { + session.active = false; + session.status = CHASE_ORDER_STATUS.MaxDistanceReached; + this.#deps.debugLogger.log('Chase max distance reached', { + sessionId, + restingPrice: session.restingPrice, + maxDistanceBps: session.maxDistanceBps, + }); + this.#onChaseOrderMaxDistanceReached?.({ + handle: sessionId, + symbol: session.symbol, + side: session.isBuy ? 'buy' : 'sell', + restingOrderId: session.orderId, + restingPrice: session.restingPrice, + maxDistanceBps: session.maxDistanceBps, + timestamp: Date.now(), + providerId: PROVIDER_CONFIG.DefaultProvider, + }); + await this.#rebalanceChaseSession(session); + return; + } + + if (session.repricings >= session.maxRepricings) { + session.active = false; + session.status = CHASE_ORDER_STATUS.RepricingLimitReached; + this.#deps.debugLogger.log('Chase repricing cap reached', { sessionId }); + await this.#rebalanceChaseSession(session); + return; + } + + this.#scheduleChaseTick(sessionId); + } + + /** + * Resolve how much of the chase's own order is really sitting on its level. + * + * The session tracks what its order was last *placed* for, which overstates + * the order once it partially fills. Over-subtracting only matters when it + * would net the level away entirely — while the level still shows more size + * than the order could possibly hold, the level has external liquidity either + * way and the stale figure is good enough. + * + * So the live size is fetched only in the ambiguous case, which keeps the + * common tick to a single book read rather than doubling the request rate + * against the venue for every chase on every interval. + * + * @param params - Netting parameters. + * @param params.symbol - Market being quoted. + * @param params.isBuy - Side being quoted. + * @param params.callerOrderId - The asking session's order, if it has one. + * @param params.ownSide - The side of the book those orders rest on. + * @returns Our resting size at each price, the asking child's terminal + * result, or the unavailable sentinel. + */ + async #resolveOwnRestingSizes(params: { + symbol: string; + isBuy: boolean; + callerOrderId?: string; + ownSide?: { px: string; sz: string }[]; + }): Promise< + | Map + | ChaseTerminalResolution + | typeof CHASE_ORDER_STATUS_UNAVAILABLE + > { + const { symbol, isBuy, callerOrderId, ownSide } = params; + + // Every chase this provider is running on this side, the caller included. + const ourOrders: ChaseRestingOrder[] = []; + for (const session of this.#chaseSessions.values()) { + if ( + session.orderId !== null && + session.symbol === symbol && + session.isBuy === isBuy + ) { + ourOrders.push({ + orderId: session.orderId, + price: session.restingPrice, + size: session.size, + }); + } + } + + if (ourOrders.length === 0) { + return new Map(); + } + + const recorded = new Map(); + for (const order of ourOrders) { + recorded.set( + order.price, + (recorded.get(order.price) ?? 0) + parseFloat(order.size), + ); + } + + // A level holding more than we possibly could has external size regardless, + // so the recorded figures cannot change the answer there and are left as + // they are. Elsewhere the recorded sizes may overstate what is really + // resting — an order partially fills without the loop seeing it — and only + // then is a lookup worth a round trip. + const ambiguous = ourOrders.filter((order) => { + const level = ownSide?.find((entry) => entry.px === order.price); + return !level || parseFloat(level.sz) <= (recorded.get(order.price) ?? 0); + }); + + let callerTerminalResult: ChaseTerminalResolution | null = null; + let callerStatusUnavailable = false; + const resolutions = await Promise.allSettled( + ambiguous.map((order) => + this.#readOrderRemainder(order.orderId, CHASE_ORDER_STATUS_RETRY_COUNT), + ), + ); + resolutions.forEach((resolution, index) => { + const order = ambiguous[index]; + if (resolution.status === 'fulfilled') { + const { remainingSize: live, terminalStatus } = resolution.value; + const delta = + (live === null ? 0 : parseFloat(live)) - parseFloat(order.size); + recorded.set( + order.price, + Math.max(0, (recorded.get(order.price) ?? 0) + delta), + ); + if (terminalStatus !== null && order.orderId === callerOrderId) { + callerTerminalResult = { + remainingSize: live, + status: terminalStatus, + }; + } + } else { + const { reason: error } = resolution; + if ( + error instanceof ChaseOrderStatusUnavailableError && + order.orderId === callerOrderId + ) { + callerStatusUnavailable = true; + } else { + // A failed lookup must not stop the chase. Keeping the recorded size + // can only over-subtract, which quotes a level deeper rather than + // leaving the order stranded at a stale price. + this.#deps.debugLogger.log('Chase own-size lookup failed', { + orderId: order.orderId, + error: ensureError(error, 'HyperLiquidProvider.chaseOwnSize') + .message, + }); + } + } + }); + + if (callerStatusUnavailable) { + return CHASE_ORDER_STATUS_UNAVAILABLE; + } + return callerTerminalResult ?? recorded; + } + + /** + * Read what a chase's just-cancelled order left unfilled. + * + * A chase re-prices by cancelling and re-placing, and the order it cancels + * may have partially filled first; re-placing the session's original size + * would execute more than the caller asked for. + * + * Read *after* the cancel on purpose. The order's status endpoint answers for + * an order that is no longer on the book, and once the cancel has landed no + * further fills can reach it — so the unfilled size it reports is final. + * Sampling the open-order book beforehand would instead leave a one-round-trip + * window in which a fill lands and is then re-placed on top. + * + * @param orderId - Exchange ID of the order that was just cancelled. + * @param unknownStatusRetries - Remaining retries for a transient unknown ID. + * @returns The unfilled size and exact terminal status, when present. + */ + async #readOrderRemainder( + orderId: string, + unknownStatusRetries = 0, + ): Promise { + const userAddress = await this.#walletService.getUserAddressWithDefault(); + const status = await this.#clientService.getInfoClient().orderStatus({ + user: userAddress, + oid: parseInt(orderId, 10), + }); + + if (status.status !== 'order') { + // unknownOid can be transient while the order-status index catches up. + // Treating it as a fill would drop the only handle to a live order. + if (unknownStatusRetries > 0) { + await new Promise((resolve) => + setTimeout(resolve, CHASE_ORDER_STATUS_RETRY_DELAY_MS), + ); + return await this.#readOrderRemainder( + orderId, + unknownStatusRetries - 1, + ); + } + throw new ChaseOrderStatusUnavailableError(); + } + + const remaining = String(status.order.order.sz); + const remainingSize = parseFloat(remaining) > 0 ? remaining : null; + switch (status.order.status) { + case 'open': + case 'triggered': + return { remainingSize, terminalStatus: null }; + case 'filled': + return { + remainingSize: null, + terminalStatus: CHASE_ORDER_STATUS.Filled, + }; + case 'canceled': + return remainingSize === null + ? { + remainingSize: null, + terminalStatus: CHASE_ORDER_STATUS.Filled, + } + : { + remainingSize, + terminalStatus: CHASE_ORDER_STATUS.Canceled, + }; + default: + return { + remainingSize, + terminalStatus: CHASE_ORDER_STATUS.Failed, + }; + } + } + + /** + * Cancel the order a chase session currently has resting. + * + * @param session - The session whose child to cancel. + * @param placingClient - Client to cancel through. Omitted by every cancel on + * a live provider, which wants the current one; an abandoned placement passes + * the client it signed with, the only one still able to reach its order. + * @returns Whether the order was cancelled, was already gone, or still rests. + */ + async #cancelChaseChild( + session: ChaseSession, + placingClient?: ExchangeClient, + ): Promise { + if (!session.orderId) { + return CancelChildOutcome.Gone; + } + + // Looked up only once there is something to cancel, so a session with + // nothing resting still answers on a provider whose client is already gone. + const exchangeClient = + placingClient ?? this.#clientService.getExchangeClient(); + let result; + try { + result = await exchangeClient.cancel({ + cancels: [{ a: session.assetId, o: parseInt(session.orderId, 10) }], + }); + } catch (error) { + const message = ensureError( + error, + 'HyperLiquidProvider.cancelChaseChild', + ).message.toLowerCase(); + if ( + ALREADY_GONE_CANCEL_MARKERS.some((marker) => message.includes(marker)) + ) { + return CancelChildOutcome.Gone; + } + throw error; + } + + return classifyCancelStatus(result.response?.data?.statuses?.[0]); + } + + /** + * Take back the order of a chase that was abandoned before it registered. + * + * Best-effort by construction: the provider is already being torn down, so a + * cancel that fails outright is reported rather than retried or thrown. The + * caller reports the placement as a failure either way — the outcome only + * decides whether an exchange id is worth handing back with it. + * + * @param session - The unregistered session holding the resting order. + * @param placingClient - The client the order was signed with. The service's + * own reference is already cleared by the time this runs, so asking it for a + * client here would fail before a cancel was ever sent. + * @returns Whether the order was cancelled, was already gone, or still rests. + */ + async #retractOrphanedChaseOrder( + session: ChaseSession, + placingClient: ExchangeClient, + ): Promise { + try { + const outcome = await this.#cancelChaseChild(session, placingClient); + this.#deps.debugLogger.log('Retracted abandoned chase order', { + orderId: session.orderId, + outcome, + }); + return outcome; + } catch (error) { + this.#deps.debugLogger.log('Could not retract abandoned chase order', { + orderId: session.orderId, + error: ensureError(error, 'HyperLiquidProvider.startChaseSession') + .message, + }); + return CancelChildOutcome.Refused; + } + } + + /** + * Stop a chase session's re-pricing loop. + * + * @param sessionId - Session to stop. + */ + #stopChaseSession(sessionId: string): void { + const session = this.#chaseSessions.get(sessionId); + if (!session) { + return; + } + + const wasActive = session.active; + session.active = false; + if (wasActive) { + session.status = CHASE_ORDER_STATUS.TerminationPending; + } + if (session.timer) { + clearTimeout(session.timer); + session.timer = null; + } + } + + /** + * Rebalance one Chase session at most once at a time. Failed cleanup keeps + * the transfer context so a later lifecycle read or cancel can retry it. + * + * @param session - Chase session whose manual HIP-3 transfer is complete. + * @returns Whether no collateral cleanup remains for the session. + */ + async #rebalanceChaseSession(session: ChaseSession): Promise { + const { hip3Transfer } = session; + if (!hip3Transfer) { + return true; + } + if ( + session.orderId !== null || + session.replacingOrderId !== null || + session.pendingReplacement !== null + ) { + return false; + } + + let rebalancePromise = session.hip3RebalancePromise; + if (!rebalancePromise) { + rebalancePromise = this.#handleHip3PostOrderRebalance(hip3Transfer); + session.hip3RebalancePromise = rebalancePromise; + } + + try { + const success = await rebalancePromise; + if (success && session.hip3Transfer === hip3Transfer) { + delete session.hip3Transfer; + } + return success; + } finally { + if (session.hip3RebalancePromise === rebalancePromise) { + delete session.hip3RebalancePromise; + } + } + } + + /** + * Adapt one venue TWAP history record and its slice fills to the shared + * controller contract. + * + * @param params - Venue history, fills, and read time. + * @returns A TWAP lifecycle record, or null when the venue supplied no ID. + */ + #adaptTwapOrder(params: AdaptTwapOrderParams): TwapOrder | null { + const { historyEntry, now } = params; + const { state, twapId } = historyEntry; + if (twapId === undefined || !Number.isSafeInteger(twapId) || twapId < 0) { + return null; + } + + const totalSize = new BigNumber(state.sz); + const executedSize = new BigNumber(state.executedSz); + const executedNotional = new BigNumber(state.executedNtl); + if ( + !totalSize.isFinite() || + !executedSize.isFinite() || + !executedNotional.isFinite() || + !Number.isFinite(state.timestamp) || + !Number.isFinite(historyEntry.time) || + !Number.isFinite(state.minutes) + ) { + return null; + } + let boundedExecutedSize = executedSize; + if (executedSize.isLessThan(0)) { + boundedExecutedSize = new BigNumber(0); + } else if (executedSize.isGreaterThan(totalSize)) { + boundedExecutedSize = totalSize; + } + const rawRemainingSize = totalSize.minus(boundedExecutedSize); + const remainingSize = rawRemainingSize.isGreaterThan(0) + ? rawRemainingSize + : new BigNumber(0); + const status = resolveTwapOrderStatus( + historyEntry, + boundedExecutedSize, + totalSize, + ); + const fills = params.sliceFills + .map(adaptTwapOrderFill) + .sort((left, right) => left.timestamp - right.timestamp); + const startedAt = state.timestamp; + const historyUpdatedAt = normalizeTwapHistoryTimestamp(historyEntry.time); + const lastUpdated = Math.max( + startedAt, + historyUpdatedAt, + ...fills.map((fill) => fill.timestamp), + ); + const durationMilliseconds = state.minutes * MILLISECONDS_PER_MINUTE; + const elapsedTimeMilliseconds = Math.min( + durationMilliseconds, + Math.max( + 0, + (status === PerpsTwapLifecycleStatus.Active ? now : lastUpdated) - + startedAt, + ), + ); + const fillProgressBps = totalSize.isGreaterThan(0) + ? boundedExecutedSize + .dividedBy(totalSize) + .multipliedBy(BASIS_POINTS_DIVISOR) + .integerValue(BigNumber.ROUND_FLOOR) + .toNumber() + : 0; + const timeProgressBps = + durationMilliseconds > 0 + ? Math.floor( + (elapsedTimeMilliseconds / durationMilliseconds) * + BASIS_POINTS_DIVISOR, + ) + : 0; + + return { + orderId: twapId.toString(), + symbol: state.coin, + side: state.side === 'B' ? 'buy' : 'sell', + size: state.sz, + executedSize: boundedExecutedSize.toString(), + remainingSize: remainingSize.toFixed(), + executedNotional: state.executedNtl, + ...(executedSize.isGreaterThan(0) && { + averagePrice: executedNotional.dividedBy(executedSize).toFixed(), + }), + fillProgressBps, + timeProgressBps, + elapsedTimeMilliseconds, + durationMinutes: state.minutes, + randomize: state.randomize, + reduceOnly: state.reduceOnly, + status, + startedAt, + lastUpdated, + ...(historyEntry.status.status === + HyperLiquidTwapLifecycleStatus.Failed && { + error: historyEntry.status.description, + }), + fills, + }; + } + + /** + * Rebalance one tracked TWAP at most once at a time. A failed transfer keeps + * the tracking entry so the next lifecycle read or cancel can retry it. + * + * @param trackingKey - Account-scoped TWAP tracking key. + * @returns Whether no cleanup remains for the entry. + */ + async #rebalanceTrackedTwapOrder(trackingKey: string): Promise { + const trackedOrder = this.#trackedTwapOrders.get(trackingKey); + if (!trackedOrder) { + return true; + } + if (!trackedOrder.hip3Transfer) { + if (this.#trackedTwapOrders.get(trackingKey) === trackedOrder) { + this.#trackedTwapOrders.delete(trackingKey); + } + return true; + } + + let { rebalancePromise } = trackedOrder; + if (!rebalancePromise) { + rebalancePromise = this.#handleHip3PostOrderRebalance( + trackedOrder.hip3Transfer, + ); + trackedOrder.rebalancePromise = rebalancePromise; + } + + try { + const success = await rebalancePromise; + if ( + success && + this.#trackedTwapOrders.get(trackingKey) === trackedOrder + ) { + this.#trackedTwapOrders.delete(trackingKey); + } + return success; + } finally { + if ( + this.#trackedTwapOrders.get(trackingKey) === trackedOrder && + trackedOrder.rebalancePromise === rebalancePromise + ) { + delete trackedOrder.rebalancePromise; + } + } + } + + /** + * Return manually moved HIP-3 collateral after a TWAP reaches a terminal + * venue state. The scoped registry survives reconnects so an active schedule + * cannot lose its eventual cleanup path. + * + * @param orders - Current and historical TWAP lifecycle records. + * @param scope - Current account and network identity. + */ + async #rebalanceTerminalHip3Twaps( + orders: TwapOrder[], + scope: TwapAccountScope, + ): Promise { + for (const order of orders) { + if (order.status === PerpsTwapLifecycleStatus.Active) { + continue; + } + + const trackingKey = getTwapOrderScopeKey({ + ...scope, + orderId: order.orderId, + }); + const trackedOrder = this.#trackedTwapOrders.get(trackingKey); + if (trackedOrder?.symbol !== order.symbol) { + continue; + } + + await this.#rebalanceTrackedTwapOrder(trackingKey); + } + } + + /** + * Read native TWAP schedules and their slice fills from the venue. + * + * @returns Current and terminal TWAP lifecycle records, newest first. + */ + async getTwapOrders(): Promise { + await this.#ensureClientsInitialized(); + this.#clientService.ensureInitialized(); + + const lifecycleGeneration = this.#lifecycleGeneration; + const infoClient = this.#clientService.getInfoClient(); + const userAddress = await this.#walletService.getUserAddressWithDefault(); + const [history, sliceFills] = await Promise.all([ + infoClient.twapHistory({ user: userAddress }), + infoClient.userTwapSliceFills({ user: userAddress }), + ]); + this.#assertProviderLifecycleCurrent( + lifecycleGeneration, + 'TWAP lifecycle read', + ); + + const fillsByTwapId = new Map(); + for (const sliceFill of sliceFills) { + const existing = fillsByTwapId.get(sliceFill.twapId) ?? []; + existing.push(sliceFill); + fillsByTwapId.set(sliceFill.twapId, existing); + } + + const now = Date.now(); + const ordersById = new Map(); + for (const historyEntry of history) { + const order = this.#adaptTwapOrder({ + historyEntry, + sliceFills: + historyEntry.twapId === undefined + ? [] + : (fillsByTwapId.get(historyEntry.twapId) ?? []), + now, + }); + if (!order) { + continue; + } + + const existing = ordersById.get(order.orderId); + if (!existing || order.lastUpdated >= existing.lastUpdated) { + ordersById.set(order.orderId, order); + } + } + + const orders = [...ordersById.values()].sort( + (left, right) => right.startedAt - left.startedAt, + ); + const network = this.#clientService.isTestnetMode() ? 'testnet' : 'mainnet'; + await this.#rebalanceTerminalHip3Twaps(orders, { + network, + userAddress, + }); + return orders; + } + + /** + * Return stable client-facing snapshots for every Chase session retained by + * this provider instance. A read may refresh the current child order's + * remaining size from the venue, throttled to the Chase interval. + * + * @returns Current Chase session snapshots. + */ + async getChaseOrders(): Promise { + return await Promise.all( + [...this.#chaseSessions.entries()].map(async ([handle, session]) => { + const now = Date.now(); + let snapshotRemainingSize = session.size; + if ( + session.orderId !== null && + session.pendingReplacement === null && + now - session.lastSnapshotSizeRefreshAt >= session.intervalMs + ) { + session.lastSnapshotSizeRefreshAt = now; + const snapshotOrderId = session.orderId; + try { + const liveRemainder = await this.#readOrderRemainder( + snapshotOrderId, + CHASE_ORDER_STATUS_RETRY_COUNT, + ); + if ( + session.orderId === snapshotOrderId && + session.pendingReplacement === null + ) { + if (liveRemainder.terminalStatus === null) { + if (liveRemainder.remainingSize !== null) { + session.size = liveRemainder.remainingSize; + snapshotRemainingSize = liveRemainder.remainingSize; + } + } else { + session.orderId = null; + session.size = liveRemainder.remainingSize ?? '0'; + session.active = false; + if (session.timer) { + clearTimeout(session.timer); + session.timer = null; + } + session.status = liveRemainder.terminalStatus; + snapshotRemainingSize = session.size; + } + } + } catch (error) { + this.#deps.debugLogger.log('Chase snapshot size refresh failed', { + orderId: snapshotOrderId, + error: ensureError(error, 'HyperLiquidProvider.getChaseOrders') + .message, + }); + } + } + + if (!session.active && session.hip3Transfer) { + const rebalanced = await this.#rebalanceChaseSession(session); + if (rebalanced && session.removeAfterRebalance) { + this.#chaseSessions.delete(handle); + } + } + + const arrival = Number.parseFloat(session.arrivalPrice); + const resting = Number.parseFloat(session.restingPrice); + const adverseDistance = session.isBuy + ? resting - arrival + : arrival - resting; + const measuredDistance = + Number.isFinite(arrival) && + arrival > 0 && + Number.isFinite(adverseDistance) + ? Math.round( + (Math.max(0, adverseDistance) / arrival) * BASIS_POINTS_DIVISOR, + ) + : 0; + return { + handle, + symbol: session.symbol, + side: session.isBuy ? 'buy' : 'sell', + originalSize: session.originalSize, + remainingSize: snapshotRemainingSize, + arrivalPrice: session.arrivalPrice, + restingPrice: session.restingPrice, + restingOrderId: session.orderId, + distanceChasedBps: measuredDistance, + ...(session.maxDistanceBps === undefined + ? {} + : { maxDistanceBps: session.maxDistanceBps }), + repricings: session.repricings, + startedAt: session.startedAt, + status: session.status, + }; + }), + ); + } + + /** + * Stop all active Chase loops while deliberately leaving their current + * post-only children resting. Used when a client moves to the background. + * + * @returns Snapshots after every in-flight replacement has settled. + */ + async suspendChaseOrders(): Promise { + this.#chasePlacementBlockers += 1; + try { + await Promise.all([...this.#chasePlacementWaiters]); + // Clear timers without deactivating sessions, then let any tick already + // admitted to the shared queue finish its cancel/replace. Stopping first + // can strand a session between children: the old order is cancelled, the + // tick observes `active === false`, and suspension reports a resting + // Chase that no longer owns an exchange order. + this.#pauseChaseTicksForPlacement(); + await this.#chaseTickQueue; + + for (const [sessionId, session] of this.#chaseSessions.entries()) { + if (!session.active) { + continue; + } + this.#stopChaseSession(sessionId); + session.status = CHASE_ORDER_STATUS.Backgrounded; + } + return await this.getChaseOrders(); + } finally { + this.#chasePlacementBlockers -= 1; + } + } + + /** + * Cancel a strategy placement by its handle. + * + * @param params - Cancellation parameters. + * @returns A promise that resolves to the result. + */ + async #cancelStrategyOrder( + params: CancelOrderParams, + ): Promise { + try { + this.#deps.debugLogger.log('Canceling strategy order:', params); + + if (params.orderType === 'twap') { + return await this.#cancelTwapOrder(params); + } + if (params.orderType === 'scale') { + return await this.#cancelScaleOrder(params); + } + return await this.#cancelChaseOrder(params); + } catch (error) { + const mappedError = this.#mapError(error); + this.#deps.logger.error( + mappedError, + await this.#getTradingErrorContext('cancelOrder', mappedError, { + orderId: params.orderId, + coin: params.symbol, + orderType: params.orderType, + }), + ); + return createErrorResult(mappedError, { + success: false, + orderId: params.orderId, + }); + } + } + + /** + * Cancel a running TWAP through the venue's TWAP cancel action. + * + * A TWAP never rested on the book, so the ordinary `cancel` action has no + * order ID to match and would reject it. + * + * @param params - Cancellation parameters, with `orderId` carrying the TWAP ID. + * @returns A promise that resolves to the result. + */ + async #cancelTwapOrder( + params: CancelOrderParams, + ): Promise { + await this.#ensureReady(); + + const coinValidation = validateCoinExists( + params.symbol, + this.#symbolToAssetId, + ); + if (!coinValidation.isValid) { + throw new Error(coinValidation.error); + } + + const twapId = Number(params.orderId); + if (!/^\d+$/u.test(params.orderId) || !Number.isSafeInteger(twapId)) { + throw new Error(PERPS_ERROR_CODES.ORDER_STRATEGY_HANDLE_UNKNOWN); + } + + await this.#ensureReadyForTrading({ requiresBuilderFee: false }); + + const network = this.#clientService.isTestnetMode() ? 'testnet' : 'mainnet'; + const userAddress = await this.#walletService.getUserAddressWithDefault(); + const trackingKey = getTwapOrderScopeKey({ + network, + userAddress, + orderId: params.orderId, + }); + const trackedOrder = this.#trackedTwapOrders.get(trackingKey); + let ownershipAuthenticated = trackedOrder !== undefined; + if (trackedOrder) { + if (trackedOrder.symbol !== params.symbol) { + throw new Error(PERPS_ERROR_CODES.ORDER_STRATEGY_HANDLE_UNKNOWN); + } + } else { + let venueOrders: TwapOrder[] | null = null; + try { + venueOrders = await this.getTwapOrders(); + } catch (error) { + // History is an ownership check, not the cancellation transport. A + // transient read failure must not make a live TWAP impossible to stop. + this.#deps.debugLogger.log( + 'TWAP history unavailable before cancellation', + { + orderId: params.orderId, + error: ensureError( + error, + 'HyperLiquidProvider.cancelTwapOrderHistory', + ).message, + }, + ); + } + const venueOrder = venueOrders?.find( + (order) => order.orderId === params.orderId, + ); + if (venueOrders !== null && venueOrder?.symbol !== params.symbol) { + throw new Error(PERPS_ERROR_CODES.ORDER_STRATEGY_HANDLE_UNKNOWN); + } + ownershipAuthenticated = venueOrder !== undefined; + } + + const assetId = await this.#getAssetIdWithRepair({ + symbol: params.symbol, + dexName: parseAssetName(params.symbol).dex, + }); + + const result = await this.#clientService.getExchangeClient().twapCancel({ + a: assetId, + t: twapId, + }); + + const status = result.response?.data?.status; + const cancelOutcome = classifyCancelStatus(status); + if (cancelOutcome === CancelChildOutcome.Gone && !ownershipAuthenticated) { + throw new Error(PERPS_ERROR_CODES.ORDER_STRATEGY_HANDLE_UNKNOWN); + } + if (cancelOutcome !== CancelChildOutcome.Refused) { + await this.#rebalanceTrackedTwapOrder(trackingKey); + return { success: true, orderId: params.orderId }; + } + + const rawError = + isStatusObject(status) && typeof status.error === 'string' + ? status.error + : 'TWAP cancellation failed'; + return createErrorResult(this.#mapError(new Error(rawError)), { + success: false, + orderId: params.orderId, + }); + } + + /** + * Cancel every child of a scale ladder in one batch. + * + * @param params - Cancellation parameters, with `orderId` carrying the group handle. + * @returns A promise that resolves to the result. + */ + async #cancelScaleOrder( + params: CancelOrderParams, + ): Promise { + const group = this.#scaleOrderGroups.get(params.orderId); + if (!group) { + throw new Error(PERPS_ERROR_CODES.ORDER_STRATEGY_HANDLE_UNKNOWN); + } + + await this.#ensureReadyForTrading({ requiresBuilderFee: false }); + + const assetId = await this.#getAssetIdWithRepair({ + symbol: group.symbol, + dexName: parseAssetName(group.symbol).dex, + }); + const exchangeClient = this.#clientService.getExchangeClient(); + const cancelRequests = group.orderIds.map((orderId) => ({ + a: assetId, + o: Number(orderId), + })); + const remaining = ( + await this.#cancelOrderRequests(exchangeClient, cancelRequests) + ).map(String); + + /* + * A rung that filled or was cancelled individually comes back as a + * rejection, but nothing of it is resting. The shared cancellation helper + * distinguishes that result from a refusal while retaining every child + * after a malformed or non-ok batch response. + */ + if (remaining.length === 0) { + this.#cancelledScaleOrderGroups.add(params.orderId); + this.#scaleOrderGroups.delete(params.orderId); + return { success: true, orderId: params.orderId }; + } + + // A rung the exchange refused to cancel may still be resting. Keeping the + // handle registered against what is left means the caller can retry the + // same cancel rather than being told the ladder is gone when it is not. + this.#scaleOrderGroups.set(params.orderId, { + symbol: group.symbol, + orderIds: remaining, + }); + this.#deps.debugLogger.log('Scale group cancel left children resting', { + groupId: params.orderId, + remaining: remaining.length, + total: group.orderIds.length, + }); + + return createErrorResult( + new Error(PERPS_ERROR_CODES.ORDER_STRATEGY_CANCEL_INCOMPLETE), + { success: false, orderId: params.orderId }, + ); + } + + /** + * Stop a chase session and cancel whatever it still has resting. + * + * @param params - Cancellation parameters, with `orderId` carrying the session handle. + * @returns A promise that resolves to the result. + */ + async #cancelChaseOrder( + params: CancelOrderParams, + ): Promise { + if (this.#terminatedChaseHandles.has(params.orderId)) { + return { success: true, orderId: params.orderId }; + } + const existingTermination = this.#chaseTerminations.get(params.orderId); + if (existingTermination) { + return await existingTermination; + } + + const termination = this.#performChaseTermination(params); + this.#chaseTerminations.set(params.orderId, termination); + try { + const result = await termination; + if (result.success) { + this.#terminatedChaseHandles.add(params.orderId); + } + return result; + } finally { + this.#chaseTerminations.delete(params.orderId); + } + } + + /** + * Own one Chase termination after concurrent callers have been deduplicated. + * + * @param params - Cancellation parameters with a stable Chase handle. + * @returns The single underlying termination result. + */ + async #performChaseTermination( + params: CancelOrderParams, + ): Promise { + const session = this.#chaseSessions.get(params.orderId); + if (!session) { + throw new Error(PERPS_ERROR_CODES.ORDER_STRATEGY_HANDLE_UNKNOWN); + } + + // Stopped first: a tick that fired between here and the cancel below would + // otherwise re-place the very order being cancelled. + this.#stopChaseSession(params.orderId); + + // A replacement already in flight cannot be called off, so the cancel waits + // for it and then cancels whatever it rested. Deciding without waiting would + // read the session's null `orderId` as "nothing rests" and report a clean + // cancellation over an order that was about to appear on the book. + if (session.pendingReplacement) { + await session.pendingReplacement; + } + + // Nothing rests: the order filled, something else cancelled it, or a + // replacement failed to go up. Either way the session ends cleanly. + if (session.orderId === null) { + session.removeAfterRebalance = true; + if (await this.#rebalanceChaseSession(session)) { + this.#chaseSessions.delete(params.orderId); + } + return { success: true, orderId: params.orderId }; + } + + await this.#ensureReadyForTrading({ requiresBuilderFee: false }); + // Only a refusal leaves an order behind. A child that had already filled or + // been cancelled is reported as a rejection too, but nothing of it is + // resting — treating that as a failure would pin the handle open forever on + // a chase that is entirely finished. + const cancelOutcome = await this.#cancelChaseChild(session); + + if (cancelOutcome === CancelChildOutcome.Refused) { + // The order is still resting. The session stays registered — stopped, but + // still cancellable — so the caller can retry with the same handle. + this.#deps.debugLogger.log('Chase cancel left its order resting', { + sessionId: params.orderId, + orderId: session.orderId, + }); + await this.#rebalanceChaseSession(session); + return createErrorResult( + new Error(PERPS_ERROR_CODES.ORDER_STRATEGY_CANCEL_INCOMPLETE), + { success: false, orderId: params.orderId }, + ); + } + + session.orderId = null; + session.status = CHASE_ORDER_STATUS.Canceled; + session.removeAfterRebalance = true; + if (await this.#rebalanceChaseSession(session)) { + this.#chaseSessions.delete(params.orderId); + } + return { success: true, orderId: params.orderId }; + } + + /** + * Cancel a batch and retain every order that may still be live. + * + * A malformed, truncated, or rejected response cannot prove which requests + * reached the venue, so every requested ID remains recoverable for a retry. + * + * @param exchangeClient - Client that owns the orders. + * @param requests - Venue cancel requests. + * @returns Order IDs that may still be resting. + */ + async #cancelOrderRequests( + exchangeClient: ExchangeClient, + requests: ExchangeCancelRequest[], + ): Promise { + return (await this.#cancelOrderRequestBatch(exchangeClient, requests)) + .remainingOrderIds; + } + + /** + * Cancel a batch while distinguishing confirmed cancellations from orders + * that were already gone. Replacement rollback may only restore the former. + * + * @param exchangeClient - Client that owns the orders. + * @param requests - Venue cancel requests. + * @returns IDs that may still rest and IDs this call confirmed it cancelled. + */ + async #cancelOrderRequestBatch( + exchangeClient: ExchangeClient, + requests: ExchangeCancelRequest[], + ): Promise { + if (requests.length === 0) { + return { + remainingOrderIds: [], + cancelledOrderIds: [], + responseComplete: true, + }; + } + + try { + const result = await exchangeClient.cancel({ cancels: requests }); + const statuses = result.response?.data?.statuses ?? []; + if (result.status !== 'ok' || statuses.length !== requests.length) { + return { + remainingOrderIds: requests.map((request) => request.o), + cancelledOrderIds: [], + responseComplete: false, + }; + } + + const remainingOrderIds: number[] = []; + const cancelledOrderIds: number[] = []; + requests.forEach((request, index) => { + const outcome = classifyCancelStatus(statuses[index]); + if (outcome === CancelChildOutcome.Refused) { + remainingOrderIds.push(request.o); + } else if (outcome === CancelChildOutcome.Cancelled) { + cancelledOrderIds.push(request.o); + } + }); + return { remainingOrderIds, cancelledOrderIds, responseComplete: true }; + } catch (error) { + this.#deps.debugLogger.log('Order cancellation batch failed', { + error: ensureError(error, 'HyperLiquidProvider.cancelOrderRequests') + .message, + orderIds: requests.map((request) => request.o), + }); + return { + remainingOrderIds: requests.map((request) => request.o), + cancelledOrderIds: [], + responseComplete: false, + }; + } + } + + /** + * Read an order ID out of one `order` action status entry. + * + * @param status - A single status from the exchange response. + * @returns The order ID, or undefined when the entry rested nothing. + */ + #readOrderPlacementOutcome( + status: unknown, + ): OrderPlacementOutcome | undefined { + if (!isStatusObject(status)) { + return undefined; + } + + for (const state of ['resting', 'filled'] as const) { + if (!hasProperty(status, state)) { + continue; + } + const order = status[state]; + if ( + isStatusObject(order) && + typeof order.oid === 'number' && + Number.isSafeInteger(order.oid) && + order.oid >= 0 + ) { + return { orderId: order.oid.toString(), state }; + } + } + return undefined; + } + + /** + * Classify one TP/SL placement response. + * + * HyperLiquid acknowledges a resting trigger with the bare + * `waitingForTrigger` string, which is successful but carries no order ID. + * Other placement paths still require a resting or filled ID. + * + * @param status - A single status from the exchange response. + * @returns The TP/SL-specific placement outcome. + */ + #readTpslOrderPlacementOutcome(status: unknown): TpslOrderPlacementOutcome { + const placement = this.#readOrderPlacementOutcome(status); + if (placement) { + return placement; + } + if (status === 'waitingForTrigger') { + return { state: 'waitingForTrigger' }; + } + if (isStatusObject(status) && hasProperty(status, 'error')) { + return { state: 'rejected' }; + } + return { state: 'unknown' }; + } + + /** + * Recover IDs omitted from waiting trigger acknowledgements. + * + * HyperLiquid does not preserve submission order in `frontendOpenOrders`, so + * each unresolved leg is matched by its submitted trigger attributes and + * accepted only when exactly one new order qualifies. + * + * @param params - Reconciliation parameters. + * @param params.outcomes - Classified placement responses. + * @param params.orders - Submitted TP/SL orders. + * @param params.previousOrderIds - Order IDs observed before submission. + * @param params.dexName - DEX queried for the placement. + * @param params.symbol - Market the triggers protect. + * @returns Outcomes enriched with unambiguous exchange order IDs. + */ + async #reconcileTpslOrderPlacementOutcomes(params: { + outcomes: TpslOrderPlacementOutcome[]; + orders: SDKOrderParams[]; + previousOrderIds: ReadonlySet; + dexName: string | null; + symbol: string; + }): Promise { + if ( + !params.outcomes.some( + (outcome) => + outcome.state === 'waitingForTrigger' && + outcome.orderId === undefined, + ) + ) { + return params.outcomes; + } + + try { + const appearedOrders = ( + await this.#fetchOpenOrders({ dexName: params.dexName }) + ).filter( + (order) => + order.coin === params.symbol && + order.reduceOnly && + order.isTrigger && + !params.previousOrderIds.has(order.oid.toString()), + ); + const claimedOrderIds = new Set(); + + return params.outcomes.map((outcome, index) => { + if ( + outcome.orderId !== undefined || + outcome.state !== 'waitingForTrigger' + ) { + return outcome; + } + + const submittedOrder = params.orders[index]; + const trigger = hasProperty(submittedOrder.t, 'trigger') + ? submittedOrder.t.trigger + : undefined; + if ( + !isStatusObject(trigger) || + !hasProperty(trigger, 'triggerPx') || + (typeof trigger.triggerPx !== 'string' && + typeof trigger.triggerPx !== 'number') || + !hasProperty(trigger, 'tpsl') || + (trigger.tpsl !== 'tp' && trigger.tpsl !== 'sl') + ) { + return outcome; + } + const submittedSize = parseFloat(String(submittedOrder.s)); + const submittedTriggerPrice = parseFloat(String(trigger.triggerPx)); + const expectedOrderType = + trigger.tpsl === 'tp' ? 'Take Profit' : 'Stop'; + const candidates = appearedOrders.filter( + (order) => + !claimedOrderIds.has(order.oid) && + (order.side === 'B') === submittedOrder.b && + parseFloat(order.sz) === submittedSize && + parseFloat(order.triggerPx) === submittedTriggerPrice && + order.orderType.includes(expectedOrderType), + ); + if (candidates.length !== 1) { + return outcome; + } + + claimedOrderIds.add(candidates[0].oid); + return { ...outcome, orderId: candidates[0].oid.toString() }; + }); + } catch (error) { + this.#deps.debugLogger.log( + 'Could not reconcile TP/SL placement order IDs', + { + error: ensureError( + error, + 'HyperLiquidProvider.reconcileTpslOrderPlacementOutcomes', + ).message, + symbol: params.symbol, + }, + ); + return params.outcomes; + } + } + + /** + * Read a valid order ID from a resting or filled placement status. + * + * @param status - A single status from the exchange response. + * @returns The order ID, or undefined when the status has no valid ID. + */ + #readOrderIdFromStatus(status: unknown): string | undefined { + return this.#readOrderPlacementOutcome(status)?.orderId; + } + + /** + * The builder fee this account pays, after any rewards discount. + * + * @returns The fee in tenths of a basis point. + */ + #getDiscountedBuilderFee(): number { + if (this.#userFeeDiscountBips === undefined) { + return BUILDER_FEE_CONFIG.MaxFeeTenthsBps; + } + return Math.floor( + BUILDER_FEE_CONFIG.MaxFeeTenthsBps * + (1 - this.#userFeeDiscountBips / BASIS_POINTS_DIVISOR), + ); + } + + /** + * Resolve the builder payload for the current operation. + * + * Subscription waivers use their dedicated builder only after approval is + * cached for this provider/account session. Until then, the ordinary builder + * and standard fee keep the trade attributable and non-blocking. + * + * @param setupContext - Account, network, and builder approved for the order. + * @returns HyperLiquid builder address and fee payload. + */ + async #getBuilderOrderContext( + setupContext: BuilderFeeSetupContext, + ): Promise<{ b: string; f: number }> { + const { + network, + userAddress, + builderAddress: defaultBuilder, + } = setupContext; + const isTestnet = network === 'testnet'; + + if (this.#userFeeResolution?.source === 'subscription') { + const subscriptionBuilder = + this.#getSubscriptionBuilderAddress(isTestnet); + if ( + subscriptionBuilder && + this.#approvedBuilderAddresses.has( + this.#getApprovedBuilderKey( + network, + userAddress, + subscriptionBuilder, + ), + ) + ) { + return { b: subscriptionBuilder, f: 0 }; + } + + return { + b: defaultBuilder, + f: BUILDER_FEE_CONFIG.MaxFeeTenthsBps, + }; + } + + return { b: defaultBuilder, f: this.#getDiscountedBuilderFee() }; + } + + /** + * Read the account's currently resting orders. + * + * @param params - The lookup parameters. + * @param params.dexName - DEX to query, or null for the main DEX. + * @returns The raw open orders. + */ + async #fetchOpenOrders(params: { + dexName: string | null; + }): Promise { + const userAddress = await this.#walletService.getUserAddressWithDefault(); + return await this.#clientService.getInfoClient().frontendOpenOrders({ + user: userAddress, + dex: params.dexName ?? undefined, + }); + } + + /** + * Resolve the order id that a `modify` rested the replacement under. + * + * HyperLiquid does not edit an order in place: it cancels the target and + * rests a replacement under a NEW oid, which the SDK's modify response does + * not carry. The submitted oid therefore names an order that no longer + * exists, so the only honest source of identity is a post-modify read. + * + * An id is returned only when exactly one newly-rested order carries the + * attributes just submitted. Everything else leaves it absent: a market edit + * that filled rather than rested, a read that has not caught up yet, or two + * equally plausible candidates. Novelty is judged against the pre-edit + * snapshot rather than attributes alone, because an order that was already + * resting can share a market, side and size with the replacement. + * + * @param params - The resolution parameters. + * @param params.previousOrders - Orders resting immediately before the edit. + * @param params.dexName - DEX to query, or null for the main DEX. + * @param params.symbol - Market the edit was submitted against. + * @param params.isBuy - Direction submitted. + * @param params.size - Formatted size submitted. + * @returns The replacement order id, or undefined when it cannot be resolved unambiguously. + */ + async #resolveReplacementOrderId(params: { + previousOrders: FrontendOrder[]; + dexName: string | null; + symbol: string; + isBuy: boolean; + size: string; + }): Promise { + try { + const previousOrderIds = new Set( + params.previousOrders.map((order) => order.oid.toString()), + ); + const ordersAfterEdit = await this.#fetchOpenOrders({ + dexName: params.dexName, + }); + const submittedSize = parseFloat(params.size); + const candidates = ordersAfterEdit.filter( + (order) => + !previousOrderIds.has(order.oid.toString()) && + order.coin === params.symbol && + (order.side === 'B') === params.isBuy && + parseFloat(order.sz) === submittedSize, + ); + + return candidates.length === 1 ? candidates[0].oid.toString() : undefined; + } catch (error) { + // The modify was accepted; only the identity lookup failed. Reporting a + // failed edit here would misstate an order that really was changed. + this.#deps.debugLogger.log( + 'Could not resolve the replacement order id after modify:', + error, + ); + return undefined; + } + } + + /** + * Edit an existing order (pending/unfilled order) + * + * Note: This modifies price/size of a pending order. It CANNOT add TP/SL to an existing order. + * For adding TP/SL to an existing position, use updatePositionTPSL instead. + * + * @param params - The operation parameters. + * @param params.orderId - The order ID to modify + * @param params.newOrder - New order parameters (price, size, etc.) + * @returns A promise that resolves to the result. + */ + async editOrder(params: EditOrderParams): Promise { + try { + this.#deps.debugLogger.log('Editing order:', params); + + // Validate size is positive (validateOrderParams no longer validates size) + const size = parseFloat(params.newOrder.size || '0'); + if (size <= 0) { + return { + success: false, + error: PERPS_ERROR_CODES.ORDER_SIZE_POSITIVE, + }; + } + + // `modify` rebuilds an order as a plain limit/market order, so a trigger + // on either side of the edit would be silently dropped. Reject a resting + // trigger order as well as an edit *into* one; cancel and re-place instead. + if (isTriggerOrderType(params.newOrder.orderType)) { + return { + success: false, + error: PERPS_ERROR_CODES.ORDER_EDIT_TRIGGER_UNSUPPORTED, + }; + } + + // A strategy placement is not a single resting order, so there is nothing + // for `modify` to rewrite: it would submit the edit as an ordinary + // FrontendMarket modification and quietly drop the TWAP schedule, the + // ladder, or the chase loop the caller asked for. Cancel by the strategy + // handle and place again. + if (isStrategyOrderType(params.newOrder.orderType)) { + return { + success: false, + error: PERPS_ERROR_CODES.ORDER_EDIT_STRATEGY_UNSUPPORTED, + }; + } + + // The WebSocket order cache is the cheap source for the resting order's + // placement type, but it may be cold or stale. + const cachedRestingOrder = this.#subscriptionService + .getOrdersCacheIfInitialized() + ?.find((order) => order.orderId === params.orderId.toString()); + if (cachedRestingOrder?.isTrigger === true) { + return { + success: false, + error: PERPS_ERROR_CODES.ORDER_EDIT_TRIGGER_UNSUPPORTED, + }; + } + + // Validate new order parameters + const validation = validateOrderParams({ + coin: params.newOrder.symbol, + size: params.newOrder.size, + price: params.newOrder.price, + orderType: params.newOrder.orderType, + triggerPrice: params.newOrder.triggerPrice, + takeProfitPrice: params.newOrder.takeProfitPrice, + stopLossPrice: params.newOrder.stopLossPrice, + takeProfitSize: params.newOrder.takeProfitSize, + stopLossSize: params.newOrder.stopLossSize, + tpslLinkage: params.newOrder.tpslLinkage, + grouping: params.newOrder.grouping, + timeInForce: params.newOrder.timeInForce, + }); + if (!validation.isValid) { + throw new Error(validation.error); + } + + // Extract DEX name for API calls (main DEX = null) + const { dex: dexName } = parseAssetName(params.newOrder.symbol); + + // Initialization only — clients and the asset mapping. The signing half + // of readiness is deferred until after the checks below, so a refused + // edit never prompts for a signature or writes an approval. + await this.#ensureReady(); + + // What is resting before the edit serves two purposes, and they carry + // different weight. Verifying the target is REQUIRED when the cache could + // not do it — an unverified edit can rebuild a protective stop as a plain + // order — so that read must fail closed. Providing a baseline for the + // optional orderId resolution is not: when the cache already confirmed the + // order, a failed read must not sink a modify that would otherwise + // succeed, exactly as the post-modify lookup does not. + let ordersBeforeEdit: FrontendOrder[] | undefined; + + if (cachedRestingOrder === undefined) { + ordersBeforeEdit = await this.#fetchOpenOrders({ dexName }); + const restingOrder = ordersBeforeEdit.find( + (order) => order.oid.toString() === params.orderId.toString(), + ); + + if (!restingOrder) { + return { + success: false, + error: PERPS_ERROR_CODES.ORDER_EDIT_ORDER_UNVERIFIABLE, + }; + } + + if (restingOrder.isTrigger) { + return { + success: false, + error: PERPS_ERROR_CODES.ORDER_EDIT_TRIGGER_UNSUPPORTED, + }; + } + } else { + try { + ordersBeforeEdit = await this.#fetchOpenOrders({ dexName }); + } catch (error) { + // Only the optional identity baseline is lost. Without it novelty + // cannot be judged, so the id is omitted below rather than guessed. + this.#deps.debugLogger.log( + 'Could not read the pre-edit orders baseline:', + error, + ); + } + } + + // Get asset info and prices (uses cache to avoid redundant API calls) + const meta = await this.#getCachedMeta({ dexName }); + + // asset.name format: "BTC" for main DEX, "xyz:XYZ100" for HIP-3 + const assetInfo = meta.universe.find( + (asset) => asset.name === params.newOrder.symbol, + ); + if (!assetInfo) { + throw new Error( + `Asset ${params.newOrder.symbol} not found in ${ + dexName ?? 'main' + } DEX universe`, + ); + } + + const currentPrice = await this.#getOrFetchPrice({ + symbol: params.newOrder.symbol, + dexName: dexName ?? null, + }); + + // Calculate order parameters using the same helper as placeOrder so the + // slippage rules stay in one place (bps → decimal, market-only, default). + // Accept the deprecated decimal `slippage` field too, normalizing to bps. + const normalizedMaxSlippageBps = + params.newOrder.maxSlippageBps ?? + (typeof params.newOrder.slippage === 'number' + ? Math.round(params.newOrder.slippage * BASIS_POINTS_DIVISOR) + : undefined); + const { formattedSize, formattedPrice } = calculateOrderPriceAndSize({ + orderType: params.newOrder.orderType, + isBuy: params.newOrder.isBuy, + finalPositionSize: parseFloat(params.newOrder.size), + currentPrice, + limitPrice: params.newOrder.price, + maxSlippageBps: normalizedMaxSlippageBps, + szDecimals: assetInfo.szDecimals, + }); + const assetId = await this.#getAssetIdWithRepair({ + symbol: params.newOrder.symbol, + dexName, + meta, + }); + + // Build new order parameters + const newOrder: SDKOrderParams = { + a: assetId, + b: params.newOrder.isBuy, + p: formattedPrice, + s: formattedSize, + r: params.newOrder.reduceOnly ?? false, + // Same TIF logic as placeOrder - see documentation above for details. + // A limit order honours the caller's time in force; validation above has + // already rejected one on any other order shape. + t: + params.newOrder.orderType === 'limit' + ? { limit: { tif: toSDKTimeInForce(params.newOrder.timeInForce) } } + : { limit: { tif: 'FrontendMarket' } }, // True market order + c: params.newOrder.clientOrderId + ? (params.newOrder.clientOrderId as Hex) + : undefined, + }; + + // Every refusal is behind us, so shared trading readiness can run now. + // HyperLiquid's modify action has no builder field and must not request a + // builder-fee approval. + await this.#ensureReadyForTrading({ requiresBuilderFee: false }); + + // Submit modification via SDK + const exchangeClient = this.#clientService.getExchangeClient(); + const result = await exchangeClient.modify({ + oid: + typeof params.orderId === 'string' + ? (params.orderId as Hex) + : params.orderId, + order: newOrder, + }); + + if (result.status !== 'ok') { + throw new Error(`Order modification failed: ${JSON.stringify(result)}`); + } + + // `params.orderId` is the order that was just REPLACED, so returning it + // as OrderResult.orderId (documented as the exchange order ID) names an + // order the venue has already cancelled. Report the replacement when it + // can be resolved unambiguously, and otherwise omit the optional id + // rather than fabricate identity. + const replacementOrderId = + ordersBeforeEdit === undefined + ? undefined + : await this.#resolveReplacementOrderId({ + previousOrders: ordersBeforeEdit, + dexName, + symbol: params.newOrder.symbol, + isBuy: params.newOrder.isBuy, + size: formattedSize, + }); + + return { + success: true, + ...(replacementOrderId === undefined + ? {} + : { orderId: replacementOrderId }), + }; + } catch (error) { + this.#deps.logger.error( + ensureError(error, 'HyperLiquidProvider.editOrder'), + this.#getErrorContext('editOrder', { + orderId: params.orderId, + coin: params.newOrder.symbol, + orderType: params.newOrder.orderType, + }), + ); + return createErrorResult(error, { success: false }); + } + } + + /** + * Cancel an order + * + * @param params - The operation parameters. + * @returns A promise that resolves to the result. + */ + async cancelOrder(params: CancelOrderParams): Promise { + // A strategy handle is not an exchange order ID, so it cannot go through + // the single-order cancel below. Callers that omit `orderType` — every + // existing one — keep the behaviour they have today. + if (params.orderType && isStrategyOrderType(params.orderType)) { + return await this.#cancelStrategyOrder(params); + } + + // Resolve ownership before the first await. A reprice can change the child + // ID while an ordinary cancel is in flight; cancelling through the stable + // session handle stops the loop, joins any replacement, and removes the + // current child instead of leaving a replacement chasing behind the user. + const owningChase = [...this.#chaseSessions.entries()].find( + ([, session]) => + session.orderId === params.orderId || + session.replacingOrderId === params.orderId, + ); + try { + if (owningChase) { + this.#stopChaseSession(owningChase[0]); + await this.#chaseTickQueue; + const result = await this.#cancelChaseOrder({ + ...params, + orderId: owningChase[0], + orderType: 'chase', + }); + return { ...result, orderId: params.orderId }; + } + + this.#deps.debugLogger.log('Canceling order:', params); + + // Hydrate the asset map before coin validation so a cold start (e.g. + // service-worker restart with an empty prefetch map) can self-heal + // without signature prompts on invalid cancels. Trading setup (builder + // referral and unified-account setup runs only after validation passes, + // matching placeOrder / editOrder. Cancel has no builder field. + await this.#ensureReady(); + + const coinValidation = validateCoinExists( + params.symbol, + this.#symbolToAssetId, + ); + if (!coinValidation.isValid) { + throw new Error(coinValidation.error); + } + + await this.#ensureReadyForTrading({ requiresBuilderFee: false }); + + const exchangeClient = this.#clientService.getExchangeClient(); + const asset = await this.#getAssetIdWithRepair({ + symbol: params.symbol, + dexName: parseAssetName(params.symbol).dex, + }); + + const result = await exchangeClient.cancel({ + cancels: [ + { + a: asset, + o: parseInt(params.orderId, 10), + }, + ], + }); + + const status: unknown = result.response?.data?.statuses?.[0]; + if (status === 'success') { + return { + success: true, + orderId: params.orderId, + }; + } + + // HyperLiquid usually rejects a cancel without throwing: the status entry + // carries the raw exchange string (e.g. "multi-sig required"). Map it the + // same way as a thrown rejection so callers get a standardized code + // instead of a generic message. + const rawError = + isStatusObject(status) && typeof status.error === 'string' + ? status.error + : 'Order cancellation failed'; + + return createErrorResult(this.#mapError(new Error(rawError)), { + success: false, + orderId: params.orderId, + }); + } catch (error) { + const mappedError = this.#mapError(error); + this.#deps.logger.error( + mappedError, + await this.#getTradingErrorContext('cancelOrder', mappedError, { + orderId: params.orderId, + coin: params.symbol, + }), + ); + return createErrorResult(mappedError, { + success: false, + orderId: params.orderId, + }); + } + } + + /** + * Cancel multiple orders in a single batch API call + * Optimized implementation that uses HyperLiquid's batch cancel endpoint + * + * @param params - The operation parameters. + * @returns A promise that resolves to the result. + */ + async cancelOrders( + params: BatchCancelOrdersParams, + ): Promise { + this.#deps.debugLogger.log('Batch canceling orders:', { + count: params.length, + }); + + if (params.length === 0) { + return { + success: false, + successCount: 0, + failureCount: 0, + results: [], + }; + } + + const results: CancelOrdersResult['results'] = params.map((order) => ({ + orderId: order.orderId, + symbol: order.symbol, + success: false, + error: PERPS_ERROR_CODES.BATCH_CANCEL_FAILED, + })); + + // Resolve Chase ownership before the first await. A queued reprice may + // replace the child ID, but the stable session handle still reaches it. + const chaseSessions = [...this.#chaseSessions.entries()]; + const chaseRoutes = params.flatMap((order, index) => { + const owner = chaseSessions.find( + ([, session]) => + session.orderId === order.orderId || + session.replacingOrderId === order.orderId, + ); + return owner ? [{ handle: owner[0], index, order }] : []; + }); + const chaseIndexes = new Set(chaseRoutes.map(({ index }) => index)); + + try { + for (const handle of new Set(chaseRoutes.map((route) => route.handle))) { + this.#stopChaseSession(handle); + } + if (chaseRoutes.length > 0) { + await this.#chaseTickQueue; + } + for (const { handle, index, order } of chaseRoutes) { + const result = await this.#cancelStrategyOrder({ + orderId: handle, + symbol: order.symbol, + orderType: 'chase', + }); + results[index] = { + orderId: order.orderId, + symbol: order.symbol, + success: result.success, + ...(result.error === undefined ? {} : { error: result.error }), + }; + } + + const ordinaryOrders = params.flatMap((order, index) => + chaseIndexes.has(index) ? [] : [{ index, order }], + ); + if (ordinaryOrders.length > 0) { + // Cancellation carries no builder context, so it must not prompt for a + // fee approval that the action cannot use. + await this.#ensureReadyForTrading({ requiresBuilderFee: false }); + const exchangeClient = this.#clientService.getExchangeClient(); + const cancelRequests = await Promise.all( + ordinaryOrders.map(async ({ order }) => { + const asset = await this.#getAssetIdWithRepair({ + symbol: order.symbol, + dexName: parseAssetName(order.symbol).dex, + }); + return { + a: asset, + o: parseInt(order.orderId, 10), + }; + }), + ); + const result = await exchangeClient.cancel({ + cancels: cancelRequests, + }); + const statuses = result.response?.data?.statuses ?? []; + + if ( + result.status === 'ok' && + statuses.length === ordinaryOrders.length + ) { + ordinaryOrders.forEach(({ index, order }, statusIndex) => { + const status: unknown = statuses[statusIndex]; + const success = status === 'success'; + const statusError = + isStatusObject(status) && typeof status.error === 'string' + ? status.error + : undefined; + results[index] = { + orderId: order.orderId, + symbol: order.symbol, + success, + ...(success + ? {} + : { + error: + statusError === undefined + ? PERPS_ERROR_CODES.BATCH_CANCEL_FAILED + : this.#mapError(new Error(statusError)).message, + }), + }; + }); + } + } + } catch (error) { + const mappedError = this.#mapError(error); + this.#deps.logger.error( + mappedError, + await this.#getTradingErrorContext('cancelOrders', mappedError, { + orderCount: params.length, + }), + ); + for (const result of results) { + if ( + !result.success && + result.error === PERPS_ERROR_CODES.BATCH_CANCEL_FAILED + ) { + result.error = + error instanceof Error + ? mappedError.message + : PERPS_ERROR_CODES.BATCH_CANCEL_FAILED; + } + } + } + + const successCount = results.filter(({ success }) => success).length; + return { + success: successCount > 0, + successCount, + failureCount: results.length - successCount, + results, + }; + } + + async closePositions( + params: ClosePositionsParams, + ): Promise { + // Declare outside try block so it's accessible in catch block + let positionsToClose: Position[] = []; + + try { + // Batch preparation needs trading readiness but not builder approval yet. + // The provider-owned market policy is resolved from the positions below. + await this.#ensureReadyForTrading({ requiresBuilderFee: false }); + + // Get all current positions from cache (avoids 429 rate limiting) + const positions = await this.getPositions(); + + // Filter positions based on params + positionsToClose = + params.closeAll === true || + !params.symbols || + params.symbols.length === 0 + ? positions + : positions.filter((pos) => params.symbols?.includes(pos.symbol)); + + this.#deps.debugLogger.log('Batch closing positions:', { + count: positionsToClose.length, + closeAll: params.closeAll, + coins: params.symbols, + }); + + if (positionsToClose.length === 0) { + return { + success: false, + successCount: 0, + failureCount: 0, + results: [], + }; + } + + // Get exchange client for order submission + const exchangeClient = this.#clientService.getExchangeClient(); + + // Pre-fetch meta for all unique DEXs to avoid N API calls in loop + const uniqueDexs = [ + ...new Set( + positionsToClose.map( + (pos) => parseAssetName(pos.symbol).dex ?? 'main', + ), + ), + ]; + await Promise.all( + uniqueDexs.map((dex) => + this.#getCachedMeta({ dexName: dex === 'main' ? null : dex }), + ), + ); + + // Freed-margin transfer for each submitted order, or null when that order + // needs none. One entry per order rather than one per HIP-3 position: a + // compacted list read with the response-status index credits the wrong + // order in a mixed main-DEX/HIP-3 batch. + const orderedHip3Transfers: ({ + sourceDex: string; + freedMargin: number; + } | null)[] = []; + + // Build orders array, plus the positions each order closes so response + // statuses stay index-aligned when a position is skipped below + const orders: SDKOrderParams[] = []; + const feePolicyContexts: OrderParams[] = []; + const orderedPositions: Position[] = []; + // Positions no order could be built for. Reported as failures so a caller + // cannot read "closed everything" from a result that left one open. + const skippedResults: ClosePositionsResult['results'] = []; + + for (const position of positionsToClose) { + // Extract DEX name for HIP-3 positions + const { dex: dexName } = parseAssetName(position.symbol); + const isHip3Position = position.symbol.includes(':'); + + // Get asset info for formatting (uses cache populated above) + const meta = await this.#getCachedMeta({ dexName }); + + const assetInfo = meta.universe.find( + (asset) => asset.name === position.symbol, + ); + if (!assetInfo) { + throw new Error( + `Asset ${position.symbol} not found in ${ + dexName ?? 'main' + } DEX universe`, + ); + } + + // Get asset ID + const assetId = await this.#getAssetIdWithRepair({ + symbol: position.symbol, + dexName, + meta, + }); + + // Calculate position details (always full close) + const positionSize = parseFloat(position.size); + const isBuy = positionSize < 0; // Close opposite side + const closeSize = Math.abs(positionSize); + const totalMarginUsed = parseFloat(position.marginUsed); + + // formatHyperLiquidSize() below rounds half-up, so floor onto the size + // grid first: a reduce-only order rounded above the position is rejected + // with "Reduce only order would increase position". + const flooredCloseSize = floorToSizeDecimals( + closeSize, + assetInfo.szDecimals, + ); + + // A dust position worth less than one size increment floors to 0, which + // would submit a zero-size order. Skip it rather than sending an order + // the exchange must reject; the remaining positions still close, and the + // skip is reported as a failure below. + if (flooredCloseSize <= 0) { + this.#deps.debugLogger.log( + 'Skipping position smaller than one size increment', + { coin: position.symbol, size: position.size }, + ); + skippedResults.push({ + symbol: position.symbol, + success: false, + error: PERPS_ERROR_CODES.ORDER_SIZE_POSITIVE, + }); + continue; + } + + // Track this order's HIP-3 transfer, if it needs one (a full position + // close frees all of its margin). Pushed below alongside the order so the + // two stay index-aligned. + const hip3Transfer = + isHip3Position && dexName && !this.#useUnifiedAccount + ? { sourceDex: dexName, freedMargin: totalMarginUsed } + : null; + + const currentPrice = await this.#getOrFetchPrice({ + symbol: position.symbol, + dexName: dexName ?? null, + }); + + // Calculate order price with slippage + const slippage = ORDER_SLIPPAGE_CONFIG.DefaultMarketSlippageBps / 10000; + const orderPrice = isBuy + ? currentPrice * (1 + slippage) + : currentPrice * (1 - slippage); + + const formattedSize = formatHyperLiquidSize({ + size: flooredCloseSize, + szDecimals: assetInfo.szDecimals, + }); + + const formattedPrice = formatHyperLiquidPrice({ + price: orderPrice, + szDecimals: assetInfo.szDecimals, + }); + + // Build reduce-only order + orders.push({ + a: assetId, + b: isBuy, + p: formattedPrice, + s: formattedSize, + r: true, // reduceOnly + t: { limit: { tif: 'Ioc' } }, // Immediate or cancel for market-like execution + }); + feePolicyContexts.push({ + symbol: position.symbol, + isBuy, + size: formattedSize, + orderType: 'market', + reduceOnly: true, + }); + orderedPositions.push(position); + orderedHip3Transfers.push(hip3Transfer); + } + + // Every position was smaller than one size increment. Return their + // failures rather than an empty result, which would be indistinguishable + // from "no positions matched". + if (orders.length === 0) { + return { + success: false, + successCount: 0, + failureCount: skippedResults.length, + results: skippedResults, + }; + } + + // HyperLiquid accepts one builder context for the whole close batch. + // Resolve every position context and charge the batch when any included + // policy charges, preserving deterministic behavior for future policies + // that vary by market or route. + const chargesMetamaskBuilderFee = feePolicyContexts.some( + (context) => + this.#resolveOrderFeePolicy(context).chargesMetamaskBuilderFee, + ); + const builder = chargesMetamaskBuilderFee + ? await this.#getBuilderOrderContext( + await this.#ensureReadyForTrading({ requiresBuilderFee: true }), + ) + : undefined; + + // Single batch API call + const result = await exchangeClient.order({ + orders, + grouping: 'na', + ...(builder && { builder }), + }); + + // Parse response statuses (one per order) + const { statuses } = result.response.data; + const successCount = statuses.filter( + (stat) => + isStatusObject(stat) && + (hasProperty(stat, 'filled') || hasProperty(stat, 'resting')), + ).length; + const failureCount = + statuses.length - successCount + skippedResults.length; + + // Handle HIP-3 margin transfers for successful closes + if (!this.#useUnifiedAccount) { + for (let i = 0; i < statuses.length; i++) { + const status = statuses[i]; + const isSuccess = + isStatusObject(status) && + (hasProperty(status, 'filled') || hasProperty(status, 'resting')); + + const transfer = orderedHip3Transfers[i]; + + if (isSuccess && transfer) { + const { sourceDex, freedMargin } = transfer; + this.#deps.debugLogger.log( + 'Position closed successfully, initiating manual auto-transfer back', + { symbol: orderedPositions[i].symbol, freedMargin }, + ); + + // Non-blocking: Transfer freed margin back to main DEX + await this.#autoTransferBackAfterClose({ + sourceDex, + freedMargin, + }); + } + } + } + + // Index submitted and skipped outcomes by symbol so `results` can keep the + // order of the requested positions: consumers may correlate them by index. + const submittedResults = new Map( + statuses.map((status, index) => [ + orderedPositions[index].symbol, + { + symbol: orderedPositions[index].symbol, + success: + isStatusObject(status) && + (hasProperty(status, 'filled') || hasProperty(status, 'resting')), + error: + isStatusObject(status) && hasProperty(status, 'error') + ? String(status.error) + : undefined, + }, + ]), + ); + const skippedBySymbol = new Map( + skippedResults.map((skipped) => [skipped.symbol, skipped]), + ); + + return { + success: successCount > 0, + successCount, + failureCount, + results: positionsToClose.flatMap((position) => { + const outcome = + submittedResults.get(position.symbol) ?? + skippedBySymbol.get(position.symbol); + return outcome ? [outcome] : []; + }), + }; + } catch (error) { + this.#deps.logger.error( + ensureError(error, 'HyperLiquidProvider.closePositions'), + this.#getErrorContext('closePositions', { + positionCount: positionsToClose.length, + }), + ); + // Return all positions as failed + return { + success: false, + successCount: 0, + failureCount: positionsToClose.length, + results: positionsToClose.map((position) => ({ + symbol: position.symbol, + success: false, + error: + error instanceof Error + ? error.message + : PERPS_ERROR_CODES.BATCH_CLOSE_FAILED, + })), + }; + } + } + + /** + * Update TP/SL for an existing position + * + * This creates new TP/SL orders for the position using 'positionTpsl' grouping. + * These are separate orders that will close the position when triggered. + * + * Key differences from editOrder: + * - editOrder: Modifies pending orders (before fill) + * - updatePositionTPSL: Creates TP/SL orders for filled positions + * + * HyperLiquid supports two TP/SL types: + * 1. 'normalTpsl' - Tied to a parent order (set when placing the order) + * 2. 'positionTpsl' - Tied to a position (can be set/modified after fill) + * + * Partial TP/SL: when `takeProfitSize` or `stopLossSize` is supplied, the + * orders cannot use 'positionTpsl' (which always covers the whole position and + * requires size 0). They are submitted as standalone reduce-only trigger orders + * with 'na' grouping and explicit sizes instead. + * + * Note that the pre-cancel sweep clears every standalone reduce-only trigger + * on the symbol — whether this update is partial or whole-position — not only + * the ones this method placed. A trigger the caller placed independently + * through `placeOrder` (for example a manual reduce-only stop) is therefore + * cancelled too. Only TP/SL children of another pending order are protected. + * + * @param params - The operation parameters. + * @param params.symbol - Asset symbol of the position + * @param params.takeProfitPrice - TP price (undefined to remove) + * @param params.stopLossPrice - SL price (undefined to remove) + * @param params.takeProfitSize - Partial TP size (undefined for the whole position) + * @param params.stopLossSize - Partial SL size (undefined for the whole position) + * @returns A promise that resolves to the result. + */ + async updatePositionTPSL( + params: UpdatePositionTPSLParams, + ): Promise { + try { + this.#deps.debugLogger.log('Updating position TP/SL:', params); + + const { + symbol, + takeProfitPrice, + stopLossPrice, + takeProfitSize, + stopLossSize, + position: livePosition, + } = params; + + const isPartialTpsl = + takeProfitSize !== undefined || stopLossSize !== undefined; + + // Basic initialization only. The trading setup that can prompt a hardware + // wallet and write the referral / builder-fee approvals is deferred until + // every validation below has passed, so a rejected update leaves nothing + // behind. + await this.#ensureReady(); + + // Use live position (from WebSocket) if available, otherwise fetch via REST + // Preferring WebSocket data avoids rate limiting issues with the REST API + let fetchedPosition: Position | undefined; + + if (livePosition) { + this.#deps.debugLogger.log('Using live position from WebSocket', { + symbol: livePosition.symbol, + size: livePosition.size, + }); + } else { + // Fallback: fetch positions via REST API (legacy behavior) + this.#deps.debugLogger.log( + 'No live position passed, falling back to REST API fetch', + ); + let positions: Position[]; + try { + positions = await this.getPositions({ skipCache: true }); + } catch (error) { + this.#deps.logger.error( + ensureError(error, 'HyperLiquidProvider.updatePositionTPSL'), + this.#getErrorContext('updatePositionTPSL > getPositions', { + symbol, + }), + ); + throw error; + } + fetchedPosition = positions.find((pos) => pos.symbol === symbol); + } + + const position = livePosition ?? fetchedPosition; + if (!position) { + throw new Error(`No position found for ${symbol}`); + } + + const positionSize = Math.abs(parseFloat(position.size)); + const isLong = parseFloat(position.size) > 0; + + // Partial TP/SL sizes must be positive, paired with their price, and no + // larger than the position they close. + const tpslSizeValidation = validateOrderParams({ + coin: symbol, + size: positionSize.toString(), + takeProfitPrice, + stopLossPrice, + takeProfitSize, + stopLossSize, + }); + if (!tpslSizeValidation.isValid) { + return { + success: false, + error: tpslSizeValidation.error, + }; + } + + // Get clients for API calls (#ensureReady already called at method start). + // Holding the exchange client reference is not itself a write; it is only + // used below, after the trading setup has run. + const infoClient = this.#clientService.getInfoClient(); + const exchangeClient = this.#clientService.getExchangeClient(); + const userAddress = await this.#walletService.getUserAddressWithDefault(); + + // Extract DEX name for API calls (main DEX = null) + const { dex: dexName } = parseAssetName(symbol); + + // Asset info is resolved before the pre-cancel sweep so a partial size + // that rounds away at the asset precision is rejected while the + // position's existing triggers are still in place. Rejecting it after the + // sweep would leave the position unprotected with nothing put back. + const meta = await this.#getCachedMeta({ dexName }); + + // Check if meta is an error response (string) or doesn't have universe property + if ( + !meta || + typeof meta === 'string' || + !meta.universe || + !Array.isArray(meta.universe) + ) { + this.#deps.debugLogger.log( + 'Failed to fetch metadata for asset mapping', + { + meta, + dex: dexName ?? 'main', + }, + ); + throw new Error( + `Failed to fetch market metadata for DEX ${dexName ?? 'main'}`, + ); + } + + // asset.name format: "BTC" for main DEX, "xyz:XYZ100" for HIP-3 + const assetInfo = meta.universe.find((asset) => asset.name === symbol); + if (!assetInfo) { + throw new Error( + `Asset ${symbol} not found in ${dexName ?? 'main'} DEX universe`, + ); + } + + const precision = validateOrderPrecision({ + takeProfitPrice, + stopLossPrice, + takeProfitSize, + stopLossSize, + szDecimals: assetInfo.szDecimals, + }); + if (!precision.isValid) { + return { + success: false, + error: precision.error, + }; + } + + const assetId = await this.#getAssetIdWithRepair({ + symbol, + dexName, + }); + + const fullSize = + TP_SL_CONFIG.UsePositionBoundTpsl && !isPartialTpsl + ? '0' + : formatHyperLiquidSize({ + size: positionSize, + szDecimals: assetInfo.szDecimals, + }); + + // Partial TP/SL orders carry their own size; the rest cover the position. + // A partial size that rounds away at the asset precision is rejected + // rather than sent as '0', which the exchange reads as whole-position. + const resolveTpslSize = (tpslSize?: string): string => + tpslSize === undefined + ? fullSize + : formatPartialTpslSize({ + size: parseFloat(tpslSize), + szDecimals: assetInfo.szDecimals, + }); + + // HyperLiquid accepts one builder context for the whole TP/SL action. + // Position TP/SL has no parent policy to inherit, so resolve every + // included trigger and charge the batch when any child policy charges. + const feePolicyContexts = [ + ...(takeProfitPrice + ? [ + { + symbol, + isBuy: !isLong, + size: resolveTpslSize(takeProfitSize), + orderType: 'take_profit_limit', + price: takeProfitPrice, + triggerPrice: takeProfitPrice, + reduceOnly: true, + } satisfies OrderParams, + ] + : []), + ...(stopLossPrice + ? [ + { + symbol, + isBuy: !isLong, + size: resolveTpslSize(stopLossSize), + orderType: 'stop_market', + triggerPrice: stopLossPrice, + reduceOnly: true, + } satisfies OrderParams, + ] + : []), + ]; + const replacementChargesMetamaskBuilderFee = feePolicyContexts.some( + (feePolicyContext) => + this.#resolveOrderFeePolicy(feePolicyContext) + .chargesMetamaskBuilderFee, + ); + + // Cancel existing TP/SL orders for this position + // OPTIMIZATION: Use WebSocket cache first (0 weight), fall back to single-DEX REST (20 weight) + // Previously: queryUserDataAcrossDexs queried ALL DEXs (20 weight × N DEXs = 40+ weight) + let cancelRequests: ExchangeCancelRequest[] = []; + const orderIdsBeforePlacement = new Set(); + const restorablePositionTpslOrders: RestorableTpslOrder[] = []; + const restorableStandaloneTpslOrders: RestorableTpslOrder[] = []; + + // Every replacement must cancel first so old and new reduce-only triggers + // cannot execute together. Retain the exact trigger definitions so a + // definitively failed replacement can restore confirmed cancellations. + const captureRestorableTpslOrders = (tpslOrders: Order[]): void => { + if (!takeProfitPrice && !stopLossPrice) { + return; + } + + tpslOrders.forEach((order) => { + const orderId = Number(order.orderId); + const orderType = order.triggerOrderType; + const triggerPrice = order.triggerPrice ?? order.price; + if (!Number.isSafeInteger(orderId) || !orderType || !triggerPrice) { + throw new Error(PERPS_ERROR_CODES.TPSL_UPDATE_FAILED); + } + + const size = order.isPositionTpsl ? '0' : order.remainingSize; + const isBuy = order.side === 'buy'; + const feePolicyContext = { + symbol, + isBuy, + size, + orderType, + triggerPrice, + reduceOnly: true, + ...(isLimitExecutionOrderType(orderType) && { + price: order.price, + }), + } satisfies OrderParams; + const restorableOrder = { + orderId, + order: { + a: assetId, + b: isBuy, + p: order.price, + s: size, + r: true, + t: { + trigger: { + isMarket: !isLimitExecutionOrderType(orderType), + triggerPx: triggerPrice, + tpsl: getTriggerDirection(orderType) === 'stop' ? 'sl' : 'tp', + }, + }, + }, + chargesMetamaskBuilderFee: + this.#resolveOrderFeePolicy(feePolicyContext) + .chargesMetamaskBuilderFee, + } satisfies RestorableTpslOrder; + + if (order.isPositionTpsl) { + restorablePositionTpslOrders.push(restorableOrder); + } else { + restorableStandaloneTpslOrders.push(restorableOrder); + } + }); + }; + + // Use atomic getter to prevent race condition between check and get + const cachedOrders = + this.#subscriptionService.getOrdersCacheIfInitialized(); + + // Replacing TP/SL has to consider standalone ('na' grouping) triggers — + // left by a partial update or placed independently — which are not + // position-bound. Telling those apart from a pending order's normalTpsl + // child requires the parent/child relationship, which only the REST + // payload carries. The cache path is therefore only safe when the cache + // shows no such trigger on this market: a partial update always places + // standalone triggers, and a whole-position update must still clear any + // standalone leftovers instead of letting them fire beside the new + // position-bound orders. + const cacheShowsStandaloneTriggers = Boolean( + cachedOrders?.some( + (order) => + order.symbol === symbol && + order.reduceOnly === true && + order.isTrigger === true && + order.isPositionTpsl !== + Boolean(TP_SL_CONFIG.UsePositionBoundTpsl) && + order.detailedOrderType && + (order.detailedOrderType.includes('Take Profit') || + order.detailedOrderType.includes('Stop')), + ), + ); + + if ( + cachedOrders === null || + isPartialTpsl || + cacheShowsStandaloneTriggers + ) { + // Fallback: Query only the specific DEX (20 weight instead of 40+) + this.#deps.debugLogger.log( + cachedOrders === null + ? 'WebSocket cache not initialized, falling back to single-DEX REST query' + : 'TP/SL update needs parent/child order context: using single-DEX REST query', + { dex: dexName ?? 'main', isPartialTpsl }, + ); + + const openOrders = await infoClient.frontendOpenOrders({ + user: userAddress, + dex: dexName ?? undefined, + }); + openOrders.forEach((order) => + orderIdsBeforePlacement.add(order.oid.toString()), + ); + + // Orders that belong to a pending parent order (normalTpsl children) are + // also listed at the top level, so collect their IDs to exclude them: + // they protect that pending order, not this position. + const childOrderIds = collectChildOrderIds(openOrders); + + // Filter using raw SDK response properties + const tpslOrders = openOrders.filter( + (order) => + order.coin === symbol && + order.reduceOnly && + // Position-bound TP/SL always qualifies, and so do standalone + // triggers on this market (they belong to the position too, whether + // this update is partial or whole) — but never another order's + // TP/SL children. + (order.isPositionTpsl === + Boolean(TP_SL_CONFIG.UsePositionBoundTpsl) || + !childOrderIds.has(order.oid)) && + order.isTrigger && + (order.orderType.includes('Take Profit') || + order.orderType.includes('Stop')), + ); + + captureRestorableTpslOrders( + tpslOrders.map((order) => adaptOrderFromSDK(order, position)), + ); + cancelRequests = tpslOrders.map((order) => ({ + a: assetId, + o: order.oid, + })); + } else { + // WebSocket cache available - use it (no API call, 0 weight) + this.#deps.debugLogger.log( + 'Using WebSocket cache for TP/SL orders lookup', + { cachedOrdersCount: cachedOrders.length }, + ); + cachedOrders.forEach((order) => + orderIdsBeforePlacement.add(order.orderId), + ); + + // Filter using normalized Order type properties, matching the REST fallback criteria: + // - symbol matches + // - isTrigger === true + // - reduceOnly === true + // - isPositionTpsl matches the configured mode (only cancel position-bound TP/SL, + // not normalTpsl children that belong to pending limit orders) + // - detailedOrderType contains 'Take Profit' or 'Stop' + const tpslOrders = cachedOrders.filter( + (order) => + order.symbol === symbol && + order.reduceOnly === true && + order.isTrigger === true && + order.isPositionTpsl === + Boolean(TP_SL_CONFIG.UsePositionBoundTpsl) && + order.detailedOrderType && + (order.detailedOrderType.includes('Take Profit') || + order.detailedOrderType.includes('Stop')), + ); + captureRestorableTpslOrders(tpslOrders); + cancelRequests = tpslOrders.map((order) => ({ + a: assetId, + o: parseInt(order.orderId, 10), + })); + } + + // Build orders array for TP/SL + const orders: SDKOrderParams[] = []; + + // Take Profit order + if (takeProfitPrice) { + const tpOrder: SDKOrderParams = { + a: assetId, + b: !isLong, // Opposite side to close position + p: formatHyperLiquidPrice({ + price: parseFloat(takeProfitPrice), + szDecimals: assetInfo.szDecimals, + }), + s: resolveTpslSize(takeProfitSize), + r: true, // Always reduce-only for position TP + t: { + trigger: { + isMarket: false, // Limit order when triggered + triggerPx: formatHyperLiquidPrice({ + price: parseFloat(takeProfitPrice), + szDecimals: assetInfo.szDecimals, + }), + tpsl: 'tp', + }, + }, + }; + orders.push(tpOrder); + } + + // Stop Loss order + if (stopLossPrice) { + const slOrder: SDKOrderParams = { + a: assetId, + b: !isLong, // Opposite side to close position + p: formatHyperLiquidPrice({ + price: parseFloat(stopLossPrice), + szDecimals: assetInfo.szDecimals, + }), + s: resolveTpslSize(stopLossSize), + r: true, // Always reduce-only for position SL + t: { + trigger: { + isMarket: true, // Market order when triggered for faster execution + triggerPx: formatHyperLiquidPrice({ + price: parseFloat(stopLossPrice), + szDecimals: assetInfo.szDecimals, + }), + tpsl: 'sl', + }, + }, + }; + orders.push(slOrder); + } + + const rollbackRequiresBuilderFee = [ + ...restorablePositionTpslOrders, + ...restorableStandaloneTpslOrders, + ].some((order) => order.chargesMetamaskBuilderFee); + const requiresBuilderFee = + replacementChargesMetamaskBuilderFee || rollbackRequiresBuilderFee; + + // Approval and builder-context resolution both finish before the + // pre-cancel. A failure here therefore leaves the old protection intact. + const builderFeeSetupContext = requiresBuilderFee + ? await this.#ensureReadyForTrading({ + requiresBuilderFee: true, + builderFeeApprovalFailureCode: PERPS_ERROR_CODES.TPSL_UPDATE_FAILED, + }) + : await this.#ensureReadyForTrading({ requiresBuilderFee: false }); + const builderOrderContext = builderFeeSetupContext + ? await this.#getBuilderOrderContext(builderFeeSetupContext) + : undefined; + + const restoreCancelledProtection = async ( + cancelledOrderIds: Set, + ): Promise => { + const restoredOrderIds: string[] = []; + let success = true; + const protectionGroups: { + grouping: 'positionTpsl' | 'na'; + entries: RestorableTpslOrder[]; + }[] = [ + { + grouping: 'positionTpsl', + entries: restorablePositionTpslOrders, + }, + { + grouping: 'na', + entries: restorableStandaloneTpslOrders, + }, + ]; + for (const protection of protectionGroups) { + const entries = protection.entries.filter((entry) => + cancelledOrderIds.has(entry.orderId), + ); + if (entries.length === 0) { + continue; + } + + try { + const result = await exchangeClient.order({ + orders: entries.map((entry) => entry.order), + grouping: protection.grouping, + ...(entries.some((entry) => entry.chargesMetamaskBuilderFee) && + builderOrderContext && { builder: builderOrderContext }), + }); + const statuses = result.response?.data?.statuses ?? []; + const rawOutcomes = statuses + .slice(0, entries.length) + .map((status) => this.#readTpslOrderPlacementOutcome(status)); + const outcomes = await this.#reconcileTpslOrderPlacementOutcomes({ + outcomes: rawOutcomes, + orders: entries.map((entry) => entry.order), + previousOrderIds: new Set([ + ...orderIdsBeforePlacement, + ...Array.from(cancelledOrderIds, String), + ]), + dexName, + symbol, + }); + restoredOrderIds.push( + ...outcomes.flatMap((outcome) => + outcome.orderId ? [outcome.orderId] : [], + ), + ); + if ( + result.status !== 'ok' || + statuses.length !== entries.length || + outcomes.some( + (outcome) => + outcome.state === 'rejected' || outcome.state === 'unknown', + ) + ) { + success = false; + this.#deps.logger.error( + new Error(PERPS_ERROR_CODES.TPSL_UPDATE_FAILED), + this.#getErrorContext( + 'updatePositionTPSL > restoreCancelledProtection', + { symbol, grouping: protection.grouping, statuses }, + ), + ); + } + } catch (error) { + success = false; + this.#deps.logger.error( + ensureError( + error, + 'HyperLiquidProvider.updatePositionTPSL.restoreCancelledProtection', + ), + this.#getErrorContext( + 'updatePositionTPSL > restoreCancelledProtection', + { symbol, grouping: protection.grouping }, + ), + ); + } + } + return { restoredOrderIds, success }; + }; + + const createProtectionLostResult = ( + survivingOrderIds: string[], + ): OrderResult => + createErrorResult(new Error(PERPS_ERROR_CODES.TPSL_PROTECTION_LOST), { + success: false, + childOrderIds: [...new Set(survivingOrderIds)], + }); + + // Clearing has no replacement batch to preserve. A partial cancellation + // is reported so the caller can retry the same clear operation. + if (orders.length === 0) { + const remainingOrderIds = await this.#cancelOrderRequests( + exchangeClient, + cancelRequests, + ); + if (remainingOrderIds.length > 0) { + throw new Error(PERPS_ERROR_CODES.TPSL_UPDATE_FAILED); + } + this.#deps.debugLogger.log( + 'No new TP/SL orders to place - existing ones cancelled', + ); + return { + success: true, + // No orderId since we only cancelled orders, didn't place new ones + }; + } + + // Cancel before placing for both position-bound and standalone partial + // triggers. A place-first partial update leaves both trigger sets live + // during the cancellation round trip and can reduce more than requested. + const confirmedCancelledOldOrderIds = new Set(); + const oldCancellation = await this.#cancelOrderRequestBatch( + exchangeClient, + cancelRequests, + ); + if (!oldCancellation.responseComplete) { + const requestedOrderIds = new Set( + cancelRequests.map((request) => request.o), + ); + let possiblyLiveOrderIds = Array.from(requestedOrderIds, String); + try { + const liveOrders = await this.#fetchOpenOrders({ dexName }); + possiblyLiveOrderIds = liveOrders + .filter((order) => requestedOrderIds.has(order.oid)) + .map((order) => String(order.oid)); + } catch (error) { + this.#deps.debugLogger.log( + 'Could not reconcile TP/SL protection after an incomplete cancel', + { + symbol, + error: ensureError( + error, + 'HyperLiquidProvider.updatePositionTPSL.reconcileProtection', + ).message, + }, + ); + } + return createProtectionLostResult(possiblyLiveOrderIds); + } + oldCancellation.cancelledOrderIds.forEach((orderId) => + confirmedCancelledOldOrderIds.add(orderId), + ); + if (oldCancellation.remainingOrderIds.length > 0) { + const restoration = await restoreCancelledProtection( + confirmedCancelledOldOrderIds, + ); + if (!restoration.success) { + return createProtectionLostResult([ + ...oldCancellation.remainingOrderIds.map(String), + ...restoration.restoredOrderIds, + ]); + } + const updateError = new Error(PERPS_ERROR_CODES.TPSL_UPDATE_FAILED); + this.#deps.logger.error( + updateError, + this.#getErrorContext('updatePositionTPSL', { + symbol: params.symbol, + hasTakeProfit: params.takeProfitPrice !== undefined, + hasStopLoss: params.stopLossPrice !== undefined, + }), + ); + return createErrorResult(updateError, { + success: false, + childOrderIds: [ + ...new Set([ + ...oldCancellation.remainingOrderIds.map(String), + ...restoration.restoredOrderIds, + ]), + ], + }); + } + + let result: Awaited>; + try { + result = await exchangeClient.order({ + orders, + grouping: isPartialTpsl ? 'na' : 'positionTpsl', + ...(replacementChargesMetamaskBuilderFee && + builderOrderContext && { builder: builderOrderContext }), + }); + } catch (error) { + const restoration = await restoreCancelledProtection( + confirmedCancelledOldOrderIds, + ); + if (!restoration.success) { + return createProtectionLostResult(restoration.restoredOrderIds); + } + throw error; + } + + const placementStatuses = result.response?.data?.statuses ?? []; + const initialPlacementOutcomes = placementStatuses + .slice(0, orders.length) + .map((status) => this.#readTpslOrderPlacementOutcome(status)); + const placementAccepted = + result.status === 'ok' && + placementStatuses.length === orders.length && + initialPlacementOutcomes.every( + (outcome) => + outcome.state === 'resting' || + outcome.state === 'filled' || + outcome.state === 'waitingForTrigger', + ); + + if (placementAccepted) { + return { + success: true, + orderId: 'TP/SL orders placed', + }; + } + + const placementOutcomes = await this.#reconcileTpslOrderPlacementOutcomes( + { + outcomes: initialPlacementOutcomes, + orders, + previousOrderIds: new Set([ + ...orderIdsBeforePlacement, + ...Array.from(confirmedCancelledOldOrderIds, String), + ]), + dexName, + symbol, + }, + ); + const restingReplacementOrderIds = placementOutcomes.flatMap((outcome) => + (outcome.state === 'resting' || + outcome.state === 'waitingForTrigger') && + outcome.orderId + ? [outcome.orderId] + : [], + ); + const filledReplacementOrderIds = placementOutcomes.flatMap((outcome) => + outcome.state === 'filled' && outcome.orderId ? [outcome.orderId] : [], + ); + const hasUnresolvedWaitingTrigger = placementOutcomes.some( + (outcome) => + outcome.state === 'waitingForTrigger' && + outcome.orderId === undefined, + ); + const remainingReplacementIds = await this.#cancelOrderRequests( + exchangeClient, + restingReplacementOrderIds.map((orderId) => ({ + a: assetId, + o: Number(orderId), + })), + ); + const recoverableOrderIds = [ + ...filledReplacementOrderIds, + ...remainingReplacementIds.map(String), + ]; + if (hasUnresolvedWaitingTrigger) { + return createProtectionLostResult(recoverableOrderIds); + } + if (recoverableOrderIds.length === 0) { + const restoration = await restoreCancelledProtection( + confirmedCancelledOldOrderIds, + ); + if (!restoration.success) { + return createProtectionLostResult(restoration.restoredOrderIds); + } + } + // A filled replacement may have changed or closed the position, while + // an uncancelled replacement may still protect it. Restoring the old + // whole-position triggers in either case risks stale or duplicate + // protection, so return those IDs for caller reconciliation instead. + if (recoverableOrderIds.length > 0) { + if (!isPartialTpsl) { + return createProtectionLostResult(recoverableOrderIds); + } + return createErrorResult( + new Error(PERPS_ERROR_CODES.TPSL_UPDATE_FAILED), + { + success: false, + childOrderIds: recoverableOrderIds, + }, + ); + } + throw new Error(PERPS_ERROR_CODES.TPSL_UPDATE_FAILED); + } catch (error) { + this.#deps.logger.error( + ensureError(error, 'HyperLiquidProvider.updatePositionTPSL'), + this.#getErrorContext('updatePositionTPSL', { + symbol: params.symbol, + hasTakeProfit: params.takeProfitPrice !== undefined, + hasStopLoss: params.stopLossPrice !== undefined, + }), + ); + return createErrorResult(error, { success: false }); + } + } + + /** + * Close a position + * + * For HIP-3 positions, this method automatically transfers freed margin + * back to the main DEX after successfully closing the position. + * + * @param params - The operation parameters. + * @returns A promise that resolves to the result. + */ + async closePosition(params: ClosePositionParams): Promise { + try { + this.#deps.debugLogger.log('Closing position:', params); + + // The delegated placeOrder call resolves the builder-fee policy for the + // concrete close order after validation. + await this.#ensureReadyForTrading({ requiresBuilderFee: false }); + + // Use provided position (from WebSocket) or fetch from cache + // This avoids unnecessary API calls and prevents 429 rate limiting + let { position } = params; + + // Re-validate the caller-supplied snapshot against the freshest WebSocket + // position cache. Clients pass a throttled snapshot (~1s old on mobile), + // so a concurrent TP/SL fill, a liquidation, or a double-tapped close + // leaves the snapshot's side/size larger than (or opposite to) the real + // position and HyperLiquid rejects the reduce-only order with "Reduce + // only order would increase position". Reading the cache never issues a + // REST request, so this does not reintroduce 429 rate limiting. + if (position && this.#subscriptionService.isPositionsCacheInitialized()) { + // Read the symbol's own DEX slice, not the aggregate. The aggregate is + // only rebuilt once every expected DEX has published, so after a + // WebSocket reconnect — which resets the initialized-DEX set without + // clearing these caches — it can sit frozen at pre-reconnect contents + // while the per-DEX slices keep updating. Deciding "this DEX is covered" + // from the per-DEX map and then reading the position from the aggregate + // mixed a fresh answer with stale data: a close could reuse a stale size, + // or throw for a position that is open. + const dexPositions = this.#subscriptionService.getCachedPositionsForDex( + parseAssetName(params.symbol).dex ?? '', + ); + const livePosition = dexPositions?.find( + (pos) => pos.symbol === params.symbol, + ); + + if (livePosition) { + if (livePosition.size !== position.size) { + this.#deps.debugLogger.log( + 'Stale close position snapshot: using live WebSocket position', + { + coin: params.symbol, + snapshotSize: position.size, + liveSize: livePosition.size, + }, + ); + } + + position = livePosition; + } else if (dexPositions) { + // That DEX has published and does not hold this symbol, so the position + // is already closed (e.g. a double-tapped close). This is the same read + // the lookup above used, so the two can never disagree. Fail here rather + // than falling back to REST: the cache is the freshest source, so a REST + // lookup can only burn a request that risks 429s and, if it lags, hand + // back a position that no longer exists. + throw new Error(`No position found for ${params.symbol}`); + } else { + // The cache holds nothing for this symbol's DEX — a HIP-3 DEX whose + // subscription has not published this session — so the symbol's + // absence proves nothing. Spend one REST request to get live data + // rather than trusting a snapshot the exchange may have moved past. + this.#deps.debugLogger.log( + 'Position cache does not cover this DEX: fetching live positions', + { coin: params.symbol }, + ); + + // Query the symbol's own DEX so the outcome carries provenance. + // getPositions() fans out across every enabled DEX, flattens the subset + // that answered and turns any failure into [], so it cannot distinguish + // "this DEX answered and holds nothing" from "this DEX failed or was + // never queried" — and those two need opposite decisions. + const { answered, positions } = await this.#queryDexPositions( + parseAssetName(params.symbol).dex, + ); + const livePositionFromApi = positions.find( + (pos) => pos.symbol === params.symbol, + ); + + if (livePositionFromApi) { + position = livePositionFromApi; + } else if (answered) { + // The DEX answered without this symbol — even with no positions at + // all — so it is genuinely closed. + throw new Error(`No position found for ${params.symbol}`); + } + // Otherwise the query failed, so the absence proves nothing: keep the + // caller's snapshot rather than block a position that may be closable. + } + } + + if (!position) { + const positions = await this.getPositions(); + position = positions.find((pos) => pos.symbol === params.symbol); + } + + if (!position) { + throw new Error(`No position found for ${params.symbol}`); + } + + const positionSize = parseFloat(position.size); + const isBuy = positionSize < 0; + const absPositionSize = Math.abs(positionSize); + // Only an omitted (or empty) size means "close 100%". A supplied size must + // be a positive number: silently promoting '0' or 'abc' to a full close + // would liquidate the whole position on a caller-side formatting slip. + // A supplied size is clamped to the live position size, because + // HyperLiquid rejects reduce-only orders that exceed the position and the + // caller computed its size from a snapshot that may already be too large. + const hasRequestedSize = params.size !== undefined && params.size !== ''; + let closeSizeNumber = absPositionSize; + + if (hasRequestedSize) { + const requestedSize = parseFloat(params.size as string); + + if (!Number.isFinite(requestedSize) || requestedSize <= 0) { + throw new Error(PERPS_ERROR_CODES.ORDER_SIZE_POSITIVE); + } + + closeSizeNumber = Math.min(requestedSize, absPositionSize); + } + + const closeSize = closeSizeNumber.toString(); + + // Capture position details BEFORE closing for freed margin calculation + const totalMarginUsed = parseFloat(position.marginUsed); + const totalPositionSize = absPositionSize; + const closeSizeNum = closeSizeNumber; + const isHip3Position = position.symbol.includes(':'); + const hip3Dex = isHip3Position ? position.symbol.split(':')[0] : null; + + // Calculate freed margin proportionally + const freedMarginRatio = closeSizeNum / totalPositionSize; + const freedMargin = totalMarginUsed * freedMarginRatio; + + // Get current price for USD/minimum validation if not provided. A full + // close skips *that* validation because it submits the exact live size — + // but not the price-staleness guard: calculateFinalPositionSize checks + // priceAtCalculation against the live price for every close that supplies + // it, using the price placeOrder fetches when none is passed here. + let { currentPrice } = params; + if (!currentPrice && params.size && !params.usdAmount) { + // Partial close without USD or price: use limit price as fallback for validation + // For limit orders, the limit price is a reasonable proxy for validation purposes + if (params.price && params.orderType === 'limit') { + currentPrice = parseFloat(params.price); + this.#deps.debugLogger.log( + 'Using limit price for close position validation (limit order)', + { + coin: params.symbol, + currentPrice, + }, + ); + } + // Note: For market orders without usdAmount/currentPrice, validation will fail + // with "price_required" error, which is correct behavior (prevents invalid orders) + } + + this.#deps.debugLogger.log('Position close details', { + coin: position.symbol, + isHip3Position, + hip3Dex, + totalMarginUsed, + closedSize: closeSize, + freedMargin: freedMargin.toFixed(2), + }); + + // True when the order closes 100% of the position: either no size was + // provided, or the requested size covers (or was clamped to) the whole + // position. + const isFullClose = closeSizeNum >= absPositionSize; + + // Execute position close with consistent slippage handling + const result = await this.placeOrder({ + symbol: params.symbol, + isBuy, + size: closeSize, + orderType: params.orderType ?? 'market', + price: params.price, + reduceOnly: true, + isFullClose, + // Pass through price and slippage parameters for consistent validation + currentPrice, + // A close of the whole position must submit exactly the live position + // size. Forwarding usdAmount would make placeOrder recompute the size as + // usdAmount / currentPrice — discarding the clamp above, since usdAmount + // is the source of truth there — and submit more than the position + // holds, which is rejected with "Reduce only order would increase + // position". Genuine partial closes keep usdAmount so their size stays + // USD-accurate. + usdAmount: isFullClose ? undefined : params.usdAmount, + priceAtCalculation: params.priceAtCalculation, + maxSlippageBps: params.maxSlippageBps, + }); + + // Return freed margin using native abstraction or programmatic transfer + if ( + result.success && + isHip3Position && + hip3Dex && + !this.#useUnifiedAccount + ) { + this.#deps.debugLogger.log( + 'Position closed successfully, initiating manual auto-transfer back', + ); + + // Non-blocking: Transfer freed margin back to main DEX + await this.#autoTransferBackAfterClose({ + sourceDex: hip3Dex, + freedMargin, + }); + } else if ( + result.success && + isHip3Position && + hip3Dex && + this.#useUnifiedAccount + ) { + this.#deps.debugLogger.log( + 'Position closed - Unified Account will auto-return freed margin', + { + coin: params.symbol, + dex: hip3Dex, + note: 'HyperLiquid handles return automatically', + }, + ); + } + + return result; + } catch (error) { + this.#deps.logger.error( + ensureError(error, 'HyperLiquidProvider.closePosition'), + this.#getErrorContext('closePosition', { + coin: params.symbol, + orderType: params.orderType, + }), + ); + return createErrorResult(error, { success: false }); + } + } + + /** + * Update margin for an existing position (add or remove) + * + * @param params - Margin adjustment parameters + * @param params.symbol - Asset symbol (e.g., 'BTC', 'ETH') + * @param params.amount - Amount to adjust as string (positive = add, negative = remove) + * @param params.providerId - Optional provider identifier (ignored, always uses HyperLiquid) + * @returns Promise resolving to margin adjustment result + * + * Note: HyperLiquid uses micro-units (multiply by 1e6) for the ntli parameter. + * The SDK's updateIsolatedMargin requires: + * - asset: Asset ID (number) + * - isBuy: Position direction (true for long, false for short) + * - ntli: Amount in micro-units (amount * 1e6) + */ + async updateMargin(params: UpdateMarginParams): Promise { + try { + this.#deps.debugLogger.log('Updating position margin:', params); + + const { symbol, amount } = params; + + // Ensure provider is ready + await this.#ensureReady(); + + // Get current position to determine direction (from cache to avoid 429 rate limiting) + const positions = await this.getPositions(); + const position = positions.find((pos) => pos.symbol === symbol); + + if (!position) { + throw new Error(`No position found for ${symbol}`); + } + + // Determine position direction + const isBuy = parseFloat(position.size) > 0; // true for long, false for short + + // Get asset ID for the symbol + const assetId = await this.#getAssetIdWithRepair({ + symbol, + dexName: parseAssetName(symbol).dex, + }); + + // Convert amount to micro-units (HyperLiquid SDK requirement) + const amountFloat = parseFloat(amount); + const ntli = Math.floor(amountFloat * 1e6); + + this.#deps.debugLogger.log('Margin adjustment details', { + symbol, + assetId, + isBuy, + amount: amountFloat, + ntli, + }); + + // Guard: confirm spendableBalance can cover margin addition. + // spendableBalance is already mode-aware (includes free spot in Unified, + // excludes it in Standard), so no extra spot fetch needed. + if (amountFloat > 0) { + const accountState = await this.getAccountState(); + const spendable = parseFloat(accountState.spendableBalance); + + if (spendable < amountFloat) { + throw new Error( + `Insufficient balance for margin addition: need ${amountFloat}, available ${spendable.toFixed(2)}`, + ); + } + } + + // Call SDK to update isolated margin + const exchangeClient = this.#clientService.getExchangeClient(); + const result = await exchangeClient.updateIsolatedMargin({ + asset: assetId, + isBuy, + ntli, + }); + + this.#deps.debugLogger.log('Margin update result:', result); + + if (result.status !== 'ok') { + throw new Error(`Margin adjustment failed: ${JSON.stringify(result)}`); + } + + return { + success: true, + }; + } catch (error) { + const safeError = ensureError(error, 'HyperLiquidProvider.updateMargin'); + this.#deps.logger.error( + safeError, + this.#getErrorContext('updateMargin', { + symbol: params.symbol, + amount: params.amount, + }), + ); + return { + success: false, + error: safeError.message, + }; + } + } + + /** + * Get validated DEXs for standalone mode using a standalone InfoClient. + * Similar to getValidatedDexs() but doesn't require full initialization. + * Reuses cachedValidatedDexs to avoid redundant perpDexs() calls. + * + * @returns A promise that resolves to the result. + */ + async #getStandaloneValidatedDexs(): Promise<(string | null)[]> { + // Return cached result if available (unified state) + if (this.#dexDiscoveryCache.state?.validated) { + return this.#dexDiscoveryCache.state.validated; + } + + // Kill switch: HIP-3 disabled, return main DEX only + if (!this.#hip3Enabled) { + const state = this.#dexDiscoveryCache.update([null]); + return state.validated; + } + + // Fetch available DEXs via standalone client + const standaloneInfoClient = createStandaloneInfoClient({ + isTestnet: this.#clientService.isTestnetMode(), + }); + let allDexs; + try { + allDexs = await standaloneInfoClient.perpDexs(); + } catch { + this.#deps.debugLogger.log( + 'HyperLiquidProvider: standalone perpDexs() failed, falling back to main DEX only', + ); + // Do not cache — transient error, allow retry on next call + return [null]; + } + + // Validate response + if (!allDexs || !Array.isArray(allDexs)) { + // Do not cache — may be transient, allow retry on next call + return [null]; + } + + // Atomically update unified state (raw + validated + timestamp). + // buildAssetMapping uses state.raw for perpDexIndex computation. + const state = this.#dexDiscoveryCache.update(allDexs); + return state.validated; + } + + /** + * Fetch a complete standalone user-data bundle. + * + * Each DEX clearinghouse response is shared by position and account-state + * mapping. Any required request failure rejects the entire bundle. + * + * @param params - User and captured controller identity. + * @returns The complete user-data snapshot. + */ + async getUserDataSnapshot( + params: GetUserDataSnapshotParams, + ): Promise { + const { identity, userAddress } = params; + const network = this.#clientService.isTestnetMode() ? 'testnet' : 'mainnet'; + const snapshotStartedAt = this.#deps.performance.now(); + const measure = async ( + stage: string, + request: () => Promise, + dex?: string | null, + ): Promise => { + const startedAt = this.#deps.performance.now(); + const dexDetail = dex === undefined ? {} : { dex: dex ?? 'main' }; + try { + const result = await request(); + this.#deps.debugLogger.log('[PerpsUserSnapshot]', { + stage, + durationMs: Math.round(this.#deps.performance.now() - startedAt), + success: true, + ...dexDetail, + }); + return result; + } catch (error) { + this.#deps.debugLogger.log('[PerpsUserSnapshot]', { + stage, + durationMs: Math.round(this.#deps.performance.now() - startedAt), + success: false, + ...dexDetail, + }); + throw error; + } + }; + + if (identity.provider !== 'hyperliquid' || identity.network !== network) { + throw new Error('User data snapshot identity does not match provider'); + } + + const requestedDexes = identity.dexes; + const canonicalDexes = canonicalizeHyperLiquidDexes(requestedDexes); + const hasValidDexIdentity = + requestedDexes.length > 0 && + new Set(requestedDexes).size === requestedDexes.length && + requestedDexes.every( + (dex) => dex === 'main' || /^[a-z0-9][a-z0-9-]*$/u.test(dex), + ) && + requestedDexes.length === canonicalDexes.length && + requestedDexes.every((dex, index) => dex === canonicalDexes[index]); + if (!hasValidDexIdentity) { + throw new Error('User data snapshot DEX identity is invalid'); + } + const dexs = requestedDexes.map((dex) => (dex === 'main' ? null : dex)); + const standaloneInfoClient = createStandaloneInfoClient({ + isTestnet: network === 'testnet', + }); + const buildUserParams = ( + dex: string | null, + ): { user: string; dex?: string } => ({ + user: userAddress, + ...(dex ? { dex } : {}), + }); + + const [clearinghouseStates, openOrdersByDex, spotState, abstractionMode] = + await Promise.all([ + Promise.all( + dexs.map((dex) => + measure( + 'clearinghouse_state', + () => + standaloneInfoClient.clearinghouseState(buildUserParams(dex)), + dex, + ), + ), + ), + Promise.all( + dexs.map((dex) => + measure( + 'frontend_open_orders', + () => + standaloneInfoClient.frontendOpenOrders(buildUserParams(dex)), + dex, + ), + ), + ), + measure('spot_clearinghouse_state', () => + standaloneInfoClient.spotClearinghouseState({ user: userAddress }), + ), + measure('user_abstraction', () => + standaloneInfoClient.userAbstraction({ user: userAddress }), + ), + ]); + + const rawOrders = openOrdersByDex.flat(); + const childOrderIds = collectChildOrderIds(rawOrders); + const ordersBySymbol = groupOrdersBySymbol(rawOrders); + const positions = clearinghouseStates.flatMap((state) => + state.assetPositions + .filter(({ position }) => position.szi !== '0') + .map((assetPosition) => { + const position = adaptPositionFromSDK(assetPosition); + const { + takeProfitOrders, + stopLossOrders, + takeProfitPrice, + stopLossPrice, + } = collectPositionTriggerOrders({ + orders: ordersBySymbol.get(position.symbol) ?? [], + position, + childOrderIds, + }); + return { + ...position, + takeProfitCount: takeProfitOrders.length, + stopLossCount: stopLossOrders.length, + takeProfitOrders, + stopLossOrders, + ...(takeProfitPrice && { takeProfitPrice }), + ...(stopLossPrice && { stopLossPrice }), + }; + }), + ); + const positionsBySymbol = new Map( + positions.map((position) => [position.symbol, position]), + ); + const orders = rawOrders.map((order) => + adaptOrderFromSDK(order, positionsBySymbol.get(order.coin)), + ); + const dexAccountStates = clearinghouseStates.map((state) => + adaptAccountStateFromSDK(state), + ); + const accountState = addSpotBalanceToAccountState( + aggregateAccountStates(dexAccountStates), + spotState, + { foldIntoCollateral: hyperLiquidModeFoldsSpot(abstractionMode) }, + ); + + accountState.subAccountBreakdown = Object.fromEntries( + dexAccountStates.map((dexAccountState, index) => { + return [ + dexs[index] ?? '', + { + spendableBalance: dexAccountState.spendableBalance, + withdrawableBalance: dexAccountState.withdrawableBalance, + totalBalance: dexAccountState.totalBalance, + }, + ]; + }), + ); + + const snapshot = { + positions, + orders, + accountState, + identity: { + ...identity, + address: userAddress, + }, + }; + this.#deps.debugLogger.log('[PerpsUserSnapshot]', { + stage: 'complete', + durationMs: Math.round(this.#deps.performance.now() - snapshotStartedAt), + success: true, + dexCount: dexs.length, + }); + return snapshot; + } + + /** + * Query one DEX's positions directly, preserving whether that DEX answered. + * + * `getPositions()` fans out across every enabled DEX, flattens the subset that + * answered and converts any thrown error into an empty array, so its result + * cannot distinguish "this DEX answered and holds no positions" from "this + * DEX's request failed or it was never queried". `closePosition` needs that + * distinction: the first means the position is closed and the close must fail + * before submitting, the second means the absence proves nothing and the + * caller's snapshot should stand. + * + * TP/SL enrichment is skipped, as in standalone mode: the close path only reads + * size, side and margin. + * + * @param dexName - DEX identifier, or null for the main DEX. + * @returns Whether the DEX answered, and the positions it reported. + */ + async #queryDexPositions( + dexName: string | null, + ): Promise<{ answered: boolean; positions: Position[] }> { + try { + await this.#ensureClientsInitialized(); + this.#clientService.ensureInitialized(); + + const infoClient = this.#clientService.getInfoClient(); + const userAddress = await this.#walletService.getUserAddressWithDefault(); + const state = await infoClient.clearinghouseState( + dexName ? { user: userAddress, dex: dexName } : { user: userAddress }, + ); + const positions = (state.assetPositions ?? []) + .filter((assetPos) => assetPos.position.szi !== '0') + .map((assetPos) => adaptPositionFromSDK(assetPos)); + + this.#deps.debugLogger.log('Target DEX position query answered', { + dex: dexName ?? 'main', + count: positions.length, + }); + + return { answered: true, positions }; + } catch (error) { + this.#deps.debugLogger.log( + 'Target DEX position query failed; its silence proves nothing', + { + dex: dexName ?? 'main', + error: ensureError(error, 'HyperLiquidProvider.queryDexPositions') + .message, + }, + ); + + return { answered: false, positions: [] }; + } + } + + /** + * Get current positions with TP/SL prices + * + * Note on TP/SL orders: + * - normalTpsl: TP/SL tied to parent order, only placed after parent fills + * - positionTpsl: TP/SL tied to position, placed immediately + * + * This means TP/SL prices may not appear immediately after placing an order + * with TP/SL. They will only show up once the parent order is filled and + * the child TP/SL orders are actually placed on the order book. + * + * @param params - The operation parameters. + * @returns A promise that resolves to the result. + */ + async getPositions(params?: GetPositionsParams): Promise { + try { + // Path 0: Standalone mode for lightweight position queries + // Creates a standalone InfoClient without requiring full initialization + // No wallet, WebSocket, or account setup needed - just HTTP API call + // Use for discovery use cases like showing positions on token details page + if (params?.standalone && params.userAddress) { + const { userAddress } = params; + this.#deps.debugLogger.log( + 'HyperLiquidProvider: Getting positions in standalone mode', + { userAddress }, + ); + + const standaloneInfoClient = createStandaloneInfoClient({ + isTestnet: this.#clientService.isTestnetMode(), + }); + const dexs = await this.#getStandaloneValidatedDexs(); + const results = await queryStandaloneClearinghouseStates( + standaloneInfoClient, + userAddress, + dexs, + ); + + // Combine and filter positions from all DEXs + // Skip TP/SL lookup (would require additional API call) + const positions = results.flatMap((state) => + state.assetPositions + .filter((assetPos) => assetPos.position.szi !== '0') + .map((assetPos) => adaptPositionFromSDK(assetPos)), + ); + + this.#deps.debugLogger.log( + 'HyperLiquidProvider: standalone positions fetched', + { count: positions.length }, + ); + + return positions; + } + + // Try WebSocket cache first (unless explicitly bypassed) + if ( + !params?.skipCache && + this.#subscriptionService.isPositionsCacheInitialized() + ) { + const cachedPositions = + this.#subscriptionService.getCachedPositions() ?? []; + this.#deps.debugLogger.log('Using cached positions from WebSocket', { + count: cachedPositions.length, + }); + return cachedPositions; + } + + // Fallback to API call + this.#deps.debugLogger.log( + 'Fetching positions via API', + params?.skipCache ? '(skipCache requested)' : '(cache not initialized)', + ); + + // Read-only operation: only need client initialization + await this.#ensureClientsInitialized(); + this.#clientService.ensureInitialized(); + + const infoClient = this.#clientService.getInfoClient(); + const userAddress = await this.#walletService.getUserAddressWithDefault( + params?.accountId, + ); + + // Query positions and orders across all enabled DEXs in parallel + const [stateResponse, orderResponse] = await Promise.all([ + this.#queryUserDataAcrossDexs({ user: userAddress }, (userParam) => + infoClient.clearinghouseState(userParam), + ), + this.#queryUserDataAcrossDexs({ user: userAddress }, (userParam) => + infoClient.frontendOpenOrders(userParam), + ), + ]); + const { results: stateResults, failedDexs: failedStateDexs } = + stateResponse; + const { results: orderResults, failedDexs: failedOrderDexs } = + orderResponse; + + if (failedStateDexs.length > 0 || failedOrderDexs.length > 0) { + this.#deps.debugLogger.log( + 'Partial multi-DEX position fetch completed with failures', + { + failedStateDexs: failedStateDexs.map( + ({ dex, error }) => `${dex ?? 'main'}:${error.message}`, + ), + failedOrderDexs: failedOrderDexs.map( + ({ dex, error }) => `${dex ?? 'main'}:${error.message}`, + ), + }, + ); + } + + // Combine all orders from all DEXs for TP/SL lookup + const allOrders = orderResults.flatMap((result) => result.data); + + // TP/SL children of pending parent orders are listed at the top level too; + // they belong to that order, not to a position. + const allOrdersChildIds = collectChildOrderIds(allOrders); + + // Grouped once here rather than rescanned per position, mirroring the + // positionsBySymbol map on the WebSocket path. + const ordersBySymbol = groupOrdersBySymbol(allOrders); + + this.#deps.debugLogger.log('Frontend open orders (all DEXs):', { + count: allOrders.length, + orders: allOrders.map((ord) => ({ + coin: ord.coin, + oid: ord.oid, + orderType: ord.orderType, + reduceOnly: ord.reduceOnly, + isTrigger: ord.isTrigger, + triggerPx: ord.triggerPx, + isPositionTpsl: ord.isPositionTpsl, + side: ord.side, + sz: ord.sz, + })), + }); + + // Combine and process positions from all DEXs + const allPositions = stateResults.flatMap((result) => + result.data.assetPositions + .filter((assetPos) => assetPos.position.szi !== '0') + .map((assetPos) => { + const position = adaptPositionFromSDK(assetPos); + + // Find TP/SL orders for this position + // First check direct trigger orders (raw SDK uses 'coin', adapted position uses 'symbol') + // Only match position-bound TP/SL orders when UsePositionBoundTpsl is enabled, + // to avoid picking up normalTpsl children from pending limit orders + const positionOrders = allOrders.filter( + (order) => + order.coin === position.symbol && + order.isTrigger && + order.reduceOnly && + order.isPositionTpsl === + Boolean(TP_SL_CONFIG.UsePositionBoundTpsl), + ); + + // Also check for parent orders that might have TP/SL children + const parentOrdersWithChildren = allOrders.filter( + (order) => + order.coin === position.symbol && + order.children && + order.children.length > 0, + ); + + // Look for TP and SL trigger orders + let takeProfitPrice: string | undefined; + let stopLossPrice: string | undefined; + + // Trigger orders attached to this position: position-bound TP/SL plus + // standalone ('na' grouping) partial TP/SL. A pending order's + // normalTpsl children are excluded — they are also listed at the top + // level, but they protect that order, not this position (same rule as + // the positionOrders filter above). + const { takeProfitOrders, stopLossOrders } = + collectPositionTriggerOrders({ + orders: ordersBySymbol.get(position.symbol) ?? [], + position, + childOrderIds: allOrdersChildIds, + }); + + // Check direct trigger orders + positionOrders.forEach((order) => { + // Frontend orders have explicit orderType field + if ( + order.orderType === 'Take Profit Market' || + order.orderType === 'Take Profit Limit' + ) { + takeProfitPrice = order.triggerPx; + this.#deps.debugLogger.log( + `Found TP order for ${position.symbol}:`, + { + triggerPrice: order.triggerPx, + orderId: order.oid, + orderType: order.orderType, + isPositionTpsl: order.isPositionTpsl, + }, + ); + } else if ( + order.orderType === 'Stop Market' || + order.orderType === 'Stop Limit' + ) { + stopLossPrice = order.triggerPx; + this.#deps.debugLogger.log( + `Found SL order for ${position.symbol}:`, + { + triggerPrice: order.triggerPx, + orderId: order.oid, + orderType: order.orderType, + isPositionTpsl: order.isPositionTpsl, + }, + ); + } + }); + + // Check child orders (for normalTpsl grouping) + parentOrdersWithChildren.forEach((parentOrder) => { + this.#deps.debugLogger.log( + `Parent order with children for ${position.symbol}:`, + { + parentOid: parentOrder.oid, + childrenCount: parentOrder.children.length, + }, + ); + + parentOrder.children.forEach((childOrder) => { + if (childOrder.isTrigger && childOrder.reduceOnly) { + if ( + childOrder.orderType === 'Take Profit Market' || + childOrder.orderType === 'Take Profit Limit' + ) { + takeProfitPrice = childOrder.triggerPx; + this.#deps.debugLogger.log( + `Found TP child order for ${position.symbol}:`, + { + triggerPrice: childOrder.triggerPx, + orderId: childOrder.oid, + orderType: childOrder.orderType, + }, + ); + } else if ( + childOrder.orderType === 'Stop Market' || + childOrder.orderType === 'Stop Limit' + ) { + stopLossPrice = childOrder.triggerPx; + this.#deps.debugLogger.log( + `Found SL child order for ${position.symbol}:`, + { + triggerPrice: childOrder.triggerPx, + orderId: childOrder.oid, + orderType: childOrder.orderType, + }, + ); + } + } + }); + }); + + return { + ...position, + takeProfitPrice: resolvePositionTriggerSummaryPrice({ + triggerOrders: takeProfitOrders, + scannedPrice: takeProfitPrice, + }), + stopLossPrice: resolvePositionTriggerSummaryPrice({ + triggerOrders: stopLossOrders, + scannedPrice: stopLossPrice, + }), + takeProfitCount: takeProfitOrders.length, + stopLossCount: stopLossOrders.length, + takeProfitOrders, + stopLossOrders, + }; + }), + ); + + return allPositions; + } catch (error) { + this.#deps.debugLogger.log('Error getting positions:', error); + return []; + } + } + + /** + * Get historical user fills (trade executions) + * + * @param params - The operation parameters. + * @param options - Optional cache-control modifiers for this read. + * @returns A promise that resolves to the result. + */ + async getOrderFills( + params?: GetOrderFillsParams, + options?: PerpsReadOptions, + ): Promise { + try { + this.#deps.debugLogger.log( + 'Getting user fills via HyperLiquid SDK:', + params, + ); + + // Read-only operation: only need client initialization + await this.#ensureClientsInitialized(); + this.#clientService.ensureInitialized(); + + const infoClient = this.#clientService.getInfoClient(); + const userAddress = await this.#walletService.getUserAddressWithDefault( + params?.accountId, + ); + + // Use userFillsByTime when startTime is provided for time-filtered queries, + // otherwise use userFills for backward compatibility + const rawFills = params?.startTime + ? await infoClient.userFillsByTime({ + user: userAddress, + startTime: params.startTime, + endTime: params.endTime, + aggregateByTime: params?.aggregateByTime ?? false, + }) + : await infoClient.userFills({ + user: userAddress, + aggregateByTime: params?.aggregateByTime ?? false, + }); + + this.#deps.debugLogger.log('User fills received:', { + count: rawFills?.length ?? 0, + }); + + // Start fetching historical orders in parallel with fill transformation. + // The fills API does not return order type, so we cross-reference + // with historical orders to enable TP/SL pill rendering in activity. + // Routed through the client-service coalesce so the enrichment sidecar + // rides the same cache as an explicit getOrders call, preventing a + // second REST fire under rapid market switching. + const historicalOrdersPromise = this.#clientService + .fetchHistoricalOrders(userAddress, { + forceRefresh: options?.forceRefresh, + }) + .catch((enrichError: unknown) => { + this.#deps.debugLogger.log( + 'Warning: failed to enrich fills with order types:', + enrichError, + ); + return null; + }); + + // Transform HyperLiquid fills to abstract OrderFill type + const fills = (rawFills || []).reduce((acc: OrderFill[], fill) => { + // Perps only, no Spots + if (!['Buy', 'Sell'].includes(fill.dir)) { + acc.push({ + orderId: fill.oid?.toString() || '', + symbol: fill.coin, + side: fill.side === 'A' ? 'sell' : 'buy', + startPosition: fill.startPosition, + size: fill.sz, + price: fill.px, + fee: fill.fee, + feeToken: fill.feeToken, + timestamp: fill.time, + pnl: fill.closedPnl, + direction: fill.dir, + success: true, + liquidation: fill.liquidation + ? { + liquidatedUser: fill.liquidation.liquidatedUser, + markPx: fill.liquidation.markPx, + method: fill.liquidation.method, + } + : undefined, + }); + } + + return acc; + }, []); + + // Enrich fills with detailedOrderType from historical orders + // Wrapped in its own try/catch so a malformed order never discards fetched fills + try { + const rawOrders = await historicalOrdersPromise; + if (rawOrders) { + const orderTypeByOid = new Map(); + for (const rawOrder of rawOrders) { + const oid = rawOrder.order?.oid?.toString(); + if (oid && rawOrder.order?.orderType && !orderTypeByOid.has(oid)) { + orderTypeByOid.set(oid, rawOrder.order.orderType); + } + } + for (const fill of fills) { + const orderType = orderTypeByOid.get(fill.orderId); + if (orderType) { + fill.detailedOrderType = orderType; + } + } + } + } catch (enrichError) { + this.#deps.debugLogger.log( + 'Error enriching fills with order types:', + enrichError, + ); + } + + return fills; + } catch (error) { + this.#deps.debugLogger.log('Error getting user fills:', error); + return []; + } + } + + /** + * Get historical orders (order lifecycle) + * + * @param params - The operation parameters. + * @param options - Optional cache-control modifiers for this read. + * @returns A promise that resolves to the result. + */ + async getOrders( + params?: GetOrdersParams, + options?: PerpsReadOptions, + ): Promise { + try { + this.#deps.debugLogger.log( + 'Getting user orders via HyperLiquid SDK:', + params, + ); + + // Read-only operation: only need client initialization + await this.#ensureClientsInitialized(); + this.#clientService.ensureInitialized(); + + const userAddress = await this.#walletService.getUserAddressWithDefault( + params?.accountId, + ); + + const rawOrders = await this.#clientService.fetchHistoricalOrders( + userAddress, + { forceRefresh: options?.forceRefresh }, + ); + + this.#deps.debugLogger.log('User orders received:', { + count: rawOrders?.length ?? 0, + }); + + // Transform HyperLiquid orders to abstract Order type + const orders: Order[] = (rawOrders || []).map((rawOrder) => { + const { order, status, statusTimestamp } = rawOrder; + + // Normalize status + let normalizedStatus: Order['status']; + switch (status) { + case 'open': + normalizedStatus = 'open'; + break; + case 'filled': + normalizedStatus = 'filled'; + break; + case 'canceled': + case 'marginCanceled': + case 'vaultWithdrawalCanceled': + case 'openInterestCapCanceled': + case 'selfTradeCanceled': + case 'reduceOnlyCanceled': + case 'siblingFilledCanceled': + case 'delistedCanceled': + case 'liquidatedCanceled': + case 'scheduledCancel': + case 'reduceOnlyRejected': + normalizedStatus = 'canceled'; + break; + case 'rejected': + // case 'minTradeNtlRejected': + normalizedStatus = 'rejected'; + break; + case 'triggered': + normalizedStatus = 'triggered'; + break; + default: + normalizedStatus = 'queued'; + } + + const adaptedOrder = adaptOrderFromSDK(order, undefined); + // limitPx is also populated as a slippage cap for market orders, so the + // exchange's detailed type is the reliable execution-mode source. + const historicalOrderType = hasProperty( + HISTORICAL_ORDER_TYPE_BY_DETAILED_TYPE, + order.orderType, + ) + ? HISTORICAL_ORDER_TYPE_BY_DETAILED_TYPE[order.orderType] + : 'market'; + + return { + ...adaptedOrder, + orderType: historicalOrderType, + remainingSize: parseFloat(order.sz).toString(), + status: normalizedStatus, + timestamp: statusTimestamp, + lastUpdated: statusTimestamp, + }; + }); + + return orders; + } catch (error) { + this.#deps.debugLogger.log('Error getting user orders:', error); + return []; + } + } + + /** + * Rebuild Scale cancellation handles from the group identity persisted on + * open venue orders. + * + * @param orders - Current normalized open orders. + */ + #restoreScaleOrderGroups(orders: Order[]): void { + const recovered = new Map(); + for (const order of orders) { + if (!order.strategyGroupId) { + continue; + } + const group = recovered.get(order.strategyGroupId) ?? { + symbol: order.symbol, + orderIds: [], + }; + group.orderIds.push(order.orderId); + recovered.set(order.strategyGroupId, group); + } + + for (const [groupId, group] of recovered) { + if (this.#cancelledScaleOrderGroups.has(groupId)) { + continue; + } + const existingGroup = this.#scaleOrderGroups.get(groupId); + if (!existingGroup) { + this.#scaleOrderGroups.set(groupId, group); + continue; + } + if (existingGroup.symbol === group.symbol) { + existingGroup.orderIds = [ + ...new Set([...existingGroup.orderIds, ...group.orderIds]), + ]; + } else { + this.#deps.debugLogger.log( + 'Scale group symbol mismatch during recovery', + { + groupId, + existingSymbol: existingGroup.symbol, + recoveredSymbol: group.symbol, + }, + ); + } + } + } + + /** + * Get currently open orders (real-time status) + * Uses frontendOpenOrders API to get only currently active orders + * Aggregates orders from all enabled DEXs (main + HIP-3) + * + * @param params - The operation parameters. + * @returns A promise that resolves to the result. + */ + async getOpenOrders(params?: GetOrdersParams): Promise { + try { + // Path 0: Standalone mode for lightweight open order queries + // Creates a standalone InfoClient without requiring full initialization + // No wallet, WebSocket, or account setup needed - just HTTP API call + if (params?.standalone && params.userAddress) { + const { userAddress } = params; + this.#deps.debugLogger.log( + 'HyperLiquidProvider: Getting open orders in standalone mode', + { userAddress }, + ); + + const standaloneInfoClient = createStandaloneInfoClient({ + isTestnet: this.#clientService.isTestnetMode(), + }); + const dexs = await this.#getStandaloneValidatedDexs(); + const orderResults = await queryStandaloneOpenOrders( + standaloneInfoClient, + userAddress, + dexs, + ); + + // Combine all orders from all DEXs and adapt (without position context in standalone mode) + const orders = orderResults.flatMap((dexOrders) => + dexOrders.map((order) => adaptOrderFromSDK(order, undefined)), + ); + + this.#deps.debugLogger.log( + 'HyperLiquidProvider: standalone open orders fetched', + { count: orders.length }, + ); + + return orders; + } + + // Try WebSocket cache first (unless explicitly bypassed) + // Use atomic getter to prevent race condition between check and get + if (!params?.skipCache) { + const cachedOrders = + this.#subscriptionService.getOrdersCacheIfInitialized(); + if (cachedOrders !== null) { + this.#deps.debugLogger.log( + 'Using cached open orders from WebSocket', + { + count: cachedOrders.length, + }, + ); + this.#restoreScaleOrderGroups(cachedOrders); + return cachedOrders; + } + } + + // Fallback to API call + this.#deps.debugLogger.log( + 'Fetching open orders via API', + params?.skipCache ? '(skipCache requested)' : '(cache not initialized)', + ); + + // Read-only operation: only need client initialization + await this.#ensureClientsInitialized(); + this.#clientService.ensureInitialized(); + + const infoClient = this.#clientService.getInfoClient(); + const userAddress = await this.#walletService.getUserAddressWithDefault( + params?.accountId, + ); + + // Query orders across all enabled DEXs in parallel + const { results: orderResults, failedDexs } = + await this.#queryUserDataAcrossDexs( + { user: userAddress }, + (userParam) => infoClient.frontendOpenOrders(userParam), + ); + + if (failedDexs.length > 0) { + this.#deps.debugLogger.log( + 'Partial multi-DEX open order fetch completed with failures', + { + failedDexs: failedDexs.map( + ({ dex, error }) => `${dex ?? 'main'}:${error.message}`, + ), + }, + ); + } + + // Combine all orders from all DEXs + const rawOrders = orderResults.flatMap((result) => result.data); + + // Get positions for order context (already multi-DEX aware) + const positions = await this.getPositions(); + + this.#deps.debugLogger.log('Currently open orders received (all DEXs):', { + count: rawOrders.length, + }); + + // Transform HyperLiquid open orders to abstract Order type using adapter + // Raw SDK orders use 'coin', adapted positions use 'symbol' + const orders: Order[] = (rawOrders || []).map((order) => { + const position = positions.find((pos) => pos.symbol === order.coin); + return adaptOrderFromSDK(order, position); + }); + + this.#restoreScaleOrderGroups(orders); + return orders; + } catch (error) { + this.#deps.debugLogger.log('Error getting currently open orders:', error); + return []; + } + } + + /** + * Get user funding history + * + * @param params - The operation parameters. + * @param _options - Cache-control modifiers (unused — funding has no + * provider-internal cache; coalescing happens at MarketDataService). + * @returns A promise that resolves to the result. + */ + async getFunding( + params?: GetFundingParams, + _options?: PerpsReadOptions, + ): Promise { + try { + this.#deps.debugLogger.log( + 'Getting user funding via HyperLiquid SDK:', + params, + ); + + // Read-only operation: only need client initialization + await this.#ensureClientsInitialized(); + this.#clientService.ensureInitialized(); + + const infoClient = this.#clientService.getInfoClient(); + const userAddress = await this.#walletService.getUserAddressWithDefault( + params?.accountId, + ); + + // On-demand loading: the default window is one 30-day page so the + // initial fetch costs exactly 1 API call (~24 weight vs 312 previously). + // When loadMoreFunding in usePerpsTransactionHistory passes explicit + // startTime/endTime for an older 30-day page the while-loop below still + // produces exactly 1 chunk. The 365-day max lookback is enforced by the + // caller. + // + // Each chunk is fetched via fetchWindowWithAutoSplit: if a call returns + // FUNDING_HISTORY_API_LIMIT records the window has hit the API cap and + // the oldest records would be silently dropped. The function splits the + // window in half and recurses until every sub-window is under the cap, + // guaranteeing complete results regardless of position count or activity. + const finalEndTime = params?.endTime ?? Date.now(); + const pageWindowMs = + PERPS_TRANSACTIONS_HISTORY_CONSTANTS.FUNDING_HISTORY_PAGE_WINDOW_DAYS * + 24 * + 60 * + 60 * + 1000; + const finalStartTime = params?.startTime ?? finalEndTime - pageWindowMs; // Default: most recent 30-day window only + + const minSplitWindowMs = + PERPS_TRANSACTIONS_HISTORY_CONSTANTS.MIN_SPLIT_WINDOW_MS; + const apiLimit = + PERPS_TRANSACTIONS_HISTORY_CONSTANTS.FUNDING_HISTORY_API_LIMIT; + + // Fetches a single window. If the result hits the API cap the window is + // split in half and both halves are fetched in parallel, recursively, + // until every sub-window is under the cap. + const fetchWindowWithAutoSplit = async ( + windowStart: number, + windowEnd: number, + ): Promise>> => { + const result = await infoClient.userFunding({ + user: userAddress, + startTime: windowStart, + endTime: windowEnd, + }); + const records = result ?? []; + if ( + records.length >= apiLimit && + windowEnd - windowStart > minSplitWindowMs + ) { + const mid = windowStart + Math.floor((windowEnd - windowStart) / 2); + const [left, right] = await Promise.all([ + fetchWindowWithAutoSplit(windowStart, mid), + fetchWindowWithAutoSplit(mid, windowEnd), + ]); + return [...(left ?? []), ...(right ?? [])]; + } + return records; + }; + + const chunks: { start: number; end: number }[] = []; + let chunkEnd = finalEndTime; + while (chunkEnd > finalStartTime) { + const chunkStart = Math.max(finalStartTime, chunkEnd - pageWindowMs); + chunks.push({ start: chunkStart, end: chunkEnd }); + chunkEnd = chunkStart; + } + + const pages = await Promise.all( + chunks.map((chunk) => fetchWindowWithAutoSplit(chunk.start, chunk.end)), + ); + + // Deduplicate at chunk boundaries — adjacent windows share their boundary + // timestamp (chunkEnd of N === chunkStart of N+1) and the API is + // inclusive on both sides, so a record can appear in both adjacent calls. + // Funding records share a zero hash, so we key on time + coin instead. + const seen = new Set(); + const allRaw = pages.flat().filter((record) => { + const key = `${record.time}-${record.delta.coin}`; + if (seen.has(key)) { + return false; + } + seen.add(key); + return true; + }); + allRaw.sort((a, b) => a.time - b.time); + + this.#deps.debugLogger.log('User funding received:', { + count: allRaw.length, + chunks: chunks.length, + }); + + // Transform HyperLiquid funding to abstract Funding type + const funding: Funding[] = allRaw.map(({ delta, hash, time }) => ({ + symbol: delta.coin, + amountUsd: delta.usdc, + rate: delta.fundingRate, + timestamp: time, + transactionHash: hash, + })); + + return funding; + } catch (error) { + this.#deps.debugLogger.log('Error getting user funding:', error); + return []; + } + } + + /** + * Get user non-funding ledger updates (deposits, transfers, withdrawals) + * + * @param params - The operation parameters. + * @param params.accountId - The CAIP account ID. + * @param params.startTime - Start timestamp in milliseconds. + * @param params.endTime - End timestamp in milliseconds. + * @returns The result of the operation. + */ + async getUserNonFundingLedgerUpdates(params?: { + accountId?: string; + startTime?: number; + endTime?: number; + }): Promise { + try { + // Read-only operation: only need client initialization + await this.#ensureClientsInitialized(); + this.#clientService.ensureInitialized(); + + const infoClient = this.#clientService.getInfoClient(); + const userAddress = await this.#walletService.getUserAddressWithDefault( + params?.accountId as CaipAccountId | undefined, + ); + + const rawLedgerUpdates = await infoClient.userNonFundingLedgerUpdates({ + user: userAddress, + startTime: params?.startTime ?? 0, + endTime: params?.endTime, + }); + + return rawLedgerUpdates ?? []; + } catch (error) { + this.#deps.logger.error( + ensureError( + error, + 'HyperLiquidProvider.getUserNonFundingLedgerUpdates', + ), + this.#getErrorContext('getUserNonFundingLedgerUpdates', params), + ); + return []; + } + } + + /** + * Resolve the provider's currently active CAIP account identifier. + * Used by the MarketDataService REST coalesce layer so cached payloads + * are keyed by the actual resolved address rather than a shared + * "default" sentinel — prevents one account's data from being served + * after an account switch within the coalesce TTL window. + * + * @returns CAIP account id for the currently selected HyperLiquid account. + */ + async getCurrentAccountId(): Promise { + return this.#walletService.getCurrentAccountId(); + } + + /** + * Get user history (deposits, withdrawals, transfers) + * + * @param params - The operation parameters. + * @param params.accountId - The CAIP account ID. + * @param params.startTime - Start timestamp in milliseconds. + * @param params.endTime - End timestamp in milliseconds. + * @returns The result of the operation. + */ + async getUserHistory(params?: { + accountId?: CaipAccountId; + startTime?: number; + endTime?: number; + }): Promise { + try { + // Read-only operation: only need client initialization + await this.#ensureClientsInitialized(); + this.#clientService.ensureInitialized(); + + const infoClient = this.#clientService.getInfoClient(); + const userAddress = await this.#walletService.getUserAddressWithDefault( + params?.accountId, + ); + + const rawLedgerUpdates = await infoClient.userNonFundingLedgerUpdates({ + user: userAddress, + startTime: params?.startTime ?? 0, + endTime: params?.endTime, + }); + + // Transform the raw ledger updates to UserHistoryItem format + return adaptHyperLiquidLedgerUpdateToUserHistoryItem(rawLedgerUpdates); + } catch (error) { + this.#deps.logger.error( + ensureError(error, 'HyperLiquidProvider.getUserHistory'), + this.#getErrorContext('getUserHistory'), + ); + return []; + } + } + + async getHistoricalPortfolio( + params?: GetHistoricalPortfolioParams, + ): Promise { + try { + this.#deps.debugLogger.log( + 'Getting historical portfolio via HyperLiquid SDK:', + params, + ); + + // Read-only operation: only need client initialization + await this.#ensureClientsInitialized(); + this.#clientService.ensureInitialized(); + + const infoClient = this.#clientService.getInfoClient(); + const userAddress = await this.#walletService.getUserAddressWithDefault( + params?.accountId, + ); + + // Get portfolio data + const portfolioData = await infoClient.portfolio({ + user: userAddress, + }); + + // Calculate target time (default to 24 hours ago) + const targetTime = Date.now() - 24 * 60 * 60 * 1000; + + // Get UTC 00:00 of the target day + const targetDate = new Date(targetTime); + const targetTimestamp = targetDate.getTime(); + + // Get the account value history from the last week's data + const weeklyPeriod = portfolioData?.[1]; + const weekData = weeklyPeriod?.[1]; + const accountValueHistory = weekData?.accountValueHistory || []; + + // Find entries that are before the target timestamp, then get the closest one + const entriesBeforeTarget = accountValueHistory.filter( + ([timestamp]) => timestamp < targetTimestamp, + ); + + let closestEntry = null; + let smallestDiff = Infinity; + for (const entry of entriesBeforeTarget) { + const [timestamp] = entry; + const diff = targetTimestamp - timestamp; + if (diff < smallestDiff) { + smallestDiff = diff; + closestEntry = entry; + } + } + + const result: HistoricalPortfolioResult = closestEntry + ? { + accountValue1dAgo: closestEntry[1] || '0', + timestamp: closestEntry[0] || 0, + } + : { + accountValue1dAgo: + accountValueHistory?.[accountValueHistory.length - 1]?.[1] || '0', + timestamp: 0, + }; + + this.#deps.debugLogger.log('Historical portfolio result:', result); + return result; + } catch (error) { + this.#deps.debugLogger.log('Error getting historical portfolio:', error); + return { + accountValue1dAgo: '0', + timestamp: 0, + }; + } + } + + /** + * Get account state + * Aggregates balances across all enabled DEXs (main + HIP-3) + * + * @param params - The operation parameters. + * @returns A promise that resolves to the result. + */ + async getAccountState(params?: GetAccountStateParams): Promise { + try { + // Path 0: Standalone mode for lightweight account state queries + // Creates a standalone InfoClient without requiring full initialization + // No wallet, WebSocket, or account setup needed - just HTTP API call + // Use for discovery use cases like checking if user has perps funds + if (params?.standalone && params.userAddress) { + const { userAddress } = params; + this.#deps.debugLogger.log( + 'HyperLiquidProvider: Getting account state in standalone mode', + { userAddress }, + ); + + const standaloneInfoClient = createStandaloneInfoClient({ + isTestnet: this.#clientService.isTestnetMode(), + }); + const dexs = await this.#getStandaloneValidatedDexs(); + const [ + standaloneSpotStateResult, + standalonePerpsResults, + standaloneAbstractionResult, + ] = await Promise.all([ + standaloneInfoClient + .spotClearinghouseState({ user: userAddress }) + .catch((error: unknown) => { + this.#deps.debugLogger.log( + 'Standalone spot state fetch failed — falling back to perps-only totals', + { + error: ensureError( + error, + 'HyperLiquidProvider.getAccountState.standalone.spot', + ).message, + }, + ); + return null; + }), + queryStandaloneClearinghouseStates( + standaloneInfoClient, + userAddress, + dexs, + ), + standaloneInfoClient + .userAbstraction({ user: userAddress }) + .catch((error: unknown) => { + this.#deps.debugLogger.log( + 'Standalone userAbstraction fetch failed; spot fold disabled until the mode resolves', + { + error: ensureError( + error, + 'HyperLiquidProvider.getAccountState.standalone.abstraction', + ).message, + }, + ); + return null; + }), + ]); + + // Aggregate account states across all DEXs, then apply spot-backed + // adjustments so streamed/standalone/full paths report the same totals. + const dexAccountStates = standalonePerpsResults.map((perpsState) => + adaptAccountStateFromSDK(perpsState), + ); + const aggregatedAccountState = addSpotBalanceToAccountState( + aggregateAccountStates(dexAccountStates), + standaloneSpotStateResult, + { + foldIntoCollateral: hyperLiquidModeFoldsSpot( + standaloneAbstractionResult, + ), + }, + ); + + this.#deps.debugLogger.log( + 'HyperLiquidProvider: standalone account state fetched', + { totalBalance: aggregatedAccountState.totalBalance }, + ); + + return aggregatedAccountState; + } + + this.#deps.debugLogger.log('Getting account state via HyperLiquid SDK'); + + // Read-only operation: only need client initialization + await this.#ensureClientsInitialized(); + this.#clientService.ensureInitialized(); + + const infoClient = this.#clientService.getInfoClient(); + const userAddress = await this.#walletService.getUserAddressWithDefault( + params?.accountId, + ); + + this.#deps.debugLogger.log( + 'User address for account state:', + userAddress, + ); + this.#deps.debugLogger.log( + 'Network mode:', + this.#clientService.isTestnetMode() ? 'TESTNET' : 'MAINNET', + ); + + // Get Spot balance, Perps states across DEXs, and the HL abstraction + // mode (Unified / Standard / Portfolio / DEX-abstraction). Mode decides + // whether spot USDC is perps collateral — see addSpotBalanceToAccountState. + // One transient DEX failure should not blank the entire account state. + const [spotStateResult, perpsStateResult, abstractionResult] = + await Promise.allSettled([ + infoClient.spotClearinghouseState({ user: userAddress }), + this.#queryUserDataAcrossDexs({ user: userAddress }, (userParam) => + infoClient.clearinghouseState(userParam), + ), + infoClient.userAbstraction({ user: userAddress }), + ]); + const spotState = + spotStateResult.status === 'fulfilled' ? spotStateResult.value : null; + const abstractionMode = + abstractionResult.status === 'fulfilled' + ? abstractionResult.value + : null; + if (abstractionResult.status === 'rejected') { + this.#deps.debugLogger.log( + 'User abstraction fetch failed; spot fold disabled until the mode resolves', + { + error: ensureError( + abstractionResult.reason, + 'HyperLiquidProvider.getAccountState.abstraction', + ).message, + }, + ); + } + const perpsResponse = + perpsStateResult.status === 'fulfilled' + ? perpsStateResult.value + : { + results: [], + failedDexs: [ + { + dex: null, + error: ensureError( + perpsStateResult.reason, + 'HyperLiquidProvider.getAccountState.perps', + ), + }, + ], + }; + const perpsStateResults = perpsResponse.results; + const failedPerpsDexs = perpsResponse.failedDexs; + + if (spotStateResult.status === 'rejected') { + this.#deps.debugLogger.log( + 'Spot state fetch failed during account state aggregation', + { + error: ensureError( + spotStateResult.reason, + 'HyperLiquidProvider.getAccountState.spot', + ).message, + }, + ); + } + + if (failedPerpsDexs.length > 0) { + this.#deps.debugLogger.log( + 'Perps account state completed with partial DEX failures', + { + failedDexs: failedPerpsDexs.map( + ({ dex, error }) => `${dex ?? 'main'}:${error.message}`, + ), + }, + ); + } + + if (perpsStateResults.length === 0) { + const failedDexNames = failedPerpsDexs.map(({ dex }) => dex ?? 'main'); + const spotErrorMessage = + spotStateResult.status === 'rejected' + ? ensureError( + spotStateResult.reason, + 'HyperLiquidProvider.getAccountState.spot', + ).message + : undefined; + throw new Error( + `Failed to fetch account state (failedDexs=[${failedDexNames.join(',')}], spotError=${spotErrorMessage ?? 'none'})`, + ); + } + + this.#deps.debugLogger.log('Spot state:', spotState); + this.#deps.debugLogger.log('Perps states (all DEXs):', { + dexCount: perpsStateResults.length, + }); + + // Aggregate account states from all DEXs + // Each DEX has independent positions and margin, we sum them + const dexAccountStates = perpsStateResults.map((result) => { + const dexAccountState = adaptAccountStateFromSDK(result.data); + this.#deps.debugLogger.log( + `DEX ${result.dex ?? 'main'} account state:`, + { + totalBalance: dexAccountState.totalBalance, + spendableBalance: dexAccountState.spendableBalance, + withdrawableBalance: dexAccountState.withdrawableBalance, + marginUsed: dexAccountState.marginUsed, + unrealizedPnl: dexAccountState.unrealizedPnl, + }, + ); + return dexAccountState; + }); + const aggregatedAccountState = addSpotBalanceToAccountState( + aggregateAccountStates(dexAccountStates), + spotState, + { foldIntoCollateral: hyperLiquidModeFoldsSpot(abstractionMode) }, + ); + + // Build per-sub-account breakdown (HIP-3 DEXs map to sub-accounts) + const subAccountBreakdown: Record< + string, + { + spendableBalance: string; + withdrawableBalance: string; + totalBalance: string; + } + > = {}; + perpsStateResults.forEach((result) => { + const { dex, data: perpsState } = result; + const dexAccountState = adaptAccountStateFromSDK(perpsState); + const subAccountKey = dex ?? ''; // Empty string for main DEX + + subAccountBreakdown[subAccountKey] = { + spendableBalance: dexAccountState.spendableBalance, + withdrawableBalance: dexAccountState.withdrawableBalance, + totalBalance: dexAccountState.totalBalance, + }; + }); + + // Add sub-account breakdown to result + aggregatedAccountState.subAccountBreakdown = subAccountBreakdown; + + this.#deps.debugLogger.log( + 'Aggregated account state:', + aggregatedAccountState, + ); + + return aggregatedAccountState; + } catch (error) { + this.#deps.logger.error( + ensureError(error, 'HyperLiquidProvider.getAccountState'), + this.#getErrorContext('getAccountState', { + accountId: params?.accountId, + }), + ); + // Re-throw the error so the controller can handle it properly + // This allows the UI to show proper error messages instead of zeros + throw ensureError(error, 'HyperLiquidProvider.getAccountState'); + } + } + + /** + * Get available markets with multi-DEX aggregation support (HIP-3) + * Handles three query patterns: + * 1. Symbol filtering: Groups symbols by DEX, fetches in parallel + * 2. Multi-DEX aggregation: Fetches from all enabled DEXs when no specific DEX requested + * 3. Single DEX query: Fetches from main or specific DEX + * + * @param params - Optional parameters for filtering + * @returns A promise that resolves to the result. + */ + async getMarkets(params?: GetMarketsParams): Promise { + try { + // Path 0: Standalone mode for lightweight discovery queries + // Creates a standalone InfoClient without requiring full initialization + // No wallet, WebSocket, or account setup needed - just HTTP API call + // Use for discovery use cases like checking if a perps market exists + if (params?.standalone) { + this.#deps.debugLogger.log( + 'HyperLiquidProvider: Getting markets in standalone mode', + { symbolCount: params?.symbols?.length }, + ); + + // Create standalone client - bypasses all initialization (wallet, WebSocket, etc.) + const standaloneInfoClient = createStandaloneInfoClient({ + isTestnet: this.#clientService.isTestnetMode(), + }); + + // Simple path: fetch main DEX markets only (no HIP-3 multi-DEX) + const meta = await standaloneInfoClient.meta(); + + if (!meta?.universe || !Array.isArray(meta.universe)) { + throw new Error( + 'Invalid universe data received from HyperLiquid API', + ); + } + + // Transform to MarketInfo format + const markets = meta.universe.map((asset) => adaptMarketFromSDK(asset)); + + // Filter by symbols if provided + if (params?.symbols?.length) { + return markets.filter((market) => + params.symbols?.some( + (symbol) => market.name.toUpperCase() === symbol.toUpperCase(), + ), + ); + } + + return markets; + } + + // Ensure full initialization including asset mapping + // This is deduplicated - concurrent calls wait for the same promise + await this.#ensureReady(); + + // Path 1: Symbol filtering - group by DEX and fetch in parallel + if (params?.symbols && params.symbols.length > 0) { + this.#deps.debugLogger.log( + 'HyperLiquidProvider: Getting markets with symbol filter', + { + symbolCount: params.symbols.length, + }, + ); + + // Group symbols by DEX + const symbolsByDex = new Map(); + params.symbols.forEach((symbol) => { + const { dex } = parseAssetName(symbol); + const existing = symbolsByDex.get(dex); + if (existing) { + existing.push(symbol); + } else { + symbolsByDex.set(dex, [symbol]); + } + }); + + // Query each unique DEX in parallel (with caching) + const marketArrays = await Promise.all( + Array.from(symbolsByDex.keys()).map(async (dex) => + this.#fetchMarketsForDex({ + dex, + skipFilters: params?.skipFilters, + }), + ), + ); + + // Combine and filter by requested symbols + const allMarkets = marketArrays.flat(); + return allMarkets.filter((market) => + params.symbols?.some( + (symbol) => market.name.toLowerCase() === symbol.toLowerCase(), + ), + ); + } + + // Path 2: Multi-DEX aggregation - fetch from all enabled DEXs + if (!params?.dex && this.#hip3Enabled) { + // Determine which DEXs to query based on skipFilters flag + const dexsToQuery = params?.skipFilters + ? await this.#getAllAvailableDexs() + : await this.#getValidatedDexs(); + + if (dexsToQuery.length > 1) { + // More than just main DEX + this.#deps.debugLogger.log( + 'HyperLiquidProvider: Fetching markets from DEXs', + { + dexCount: dexsToQuery.length, + skipFilters: params?.skipFilters ?? false, + }, + ); + + const marketArrays = await Promise.all( + dexsToQuery.map(async (dex) => { + try { + return await this.#fetchMarketsForDex({ + dex, + skipFilters: params?.skipFilters, + }); + } catch (error) { + this.#deps.logger.error( + ensureError(error, 'HyperLiquidProvider.getMarkets'), + this.#getErrorContext('getMarkets.multiDex', { + dex: dex ?? 'main', + }), + ); + return []; // Continue with other DEXs on error + } + }), + ); + + return marketArrays.flat(); + } + } + + // Path 3: Single DEX query (main DEX or specific DEX) - with caching + this.#deps.debugLogger.log( + 'HyperLiquidProvider: Getting markets for single DEX', + { + dex: params?.dex ?? 'main', + }, + ); + + return await this.#fetchMarketsForDex({ + dex: params?.dex ?? null, + skipFilters: params?.skipFilters, + }); + } catch (error) { + this.#deps.logger.error( + ensureError(error, 'HyperLiquidProvider.getMarkets'), + this.#getErrorContext('getMarkets', { + dex: params?.dex, + symbolCount: params?.symbols?.length, + }), + ); + return []; + } + } + + /** + * Get list of available HIP-3 DEXs that have markets + * Useful for debugging and manual DEX selection + * + * @returns Array of DEX names (excluding main DEX) + */ + async getAvailableHip3Dexs(): Promise { + try { + // Read-only operation: only need client initialization + await this.#ensureClientsInitialized(); + this.#clientService.ensureInitialized(); + + if (!this.#hip3Enabled) { + this.#deps.debugLogger.log('HIP-3 disabled, no DEXs available'); + return []; + } + + const infoClient = this.#clientService.getInfoClient(); + + // Get all DEXs from API + const allDexs = await infoClient.perpDexs(); + + if (!allDexs || !Array.isArray(allDexs)) { + this.#deps.debugLogger.log('perpDexs() returned invalid data'); + return []; + } + + // Extract HIP-3 DEX names (filter out null which is main DEX) + const hip3DexNames: string[] = []; + allDexs.forEach((dex) => { + if (dex !== null && hasProperty(dex, 'name')) { + hip3DexNames.push(dex.name); + } + }); + + this.#deps.debugLogger.log( + `Found ${hip3DexNames.length} HIP-3 DEXs from perpDexs() API`, + ); + + // Filter to only DEXs that have markets + const dexsWithMarkets: string[] = []; + await Promise.all( + hip3DexNames.map(async (dexName) => { + try { + const meta = await this.#getCachedMeta({ dexName }); + if ( + meta.universe && + Array.isArray(meta.universe) && + meta.universe.length > 0 + ) { + dexsWithMarkets.push(dexName); + this.#deps.debugLogger.log( + ` ✅ ${dexName}: ${meta.universe.length} markets`, + ); + } else { + this.#deps.debugLogger.log(` ⚠️ ${dexName}: no markets`); + } + } catch (error) { + this.#deps.debugLogger.log( + ` ❌ ${dexName}: error querying`, + error, + ); + } + }), + ); + + this.#deps.debugLogger.log( + `${dexsWithMarkets.length} DEXs have markets:`, + dexsWithMarkets, + ); + return dexsWithMarkets.sort((a, b) => a.localeCompare(b)); + } catch (error) { + this.#deps.logger.error( + ensureError(error, 'HyperLiquidProvider.getAvailableHip3Dexs'), + this.#getErrorContext('getAvailableHip3Dexs'), + ); + return []; + } + } + + /** + * Get allMids for a DEX — uses WS snapshot as primary source, REST as fallback. + * + * @param infoClient - The HyperLiquid info client (used only on cold start). + * @param dexParam - Optional DEX parameter (empty string for main DEX). + * @returns allMids record. + */ + async #getAllMids( + infoClient: ReturnType< + typeof HyperLiquidClientService.prototype.getInfoClient + >, + dexParam?: string, + ): Promise> { + const wsSnapshot = + this.#subscriptionService.getLastAllMidsSnapshot?.(dexParam); + if (wsSnapshot) { + return wsSnapshot; + } + + this.#deps.debugLogger.log( + '[getMarketDataWithPrices] No WS allMids snapshot, falling back to REST', + { dexParam: dexParam ?? 'main' }, + ); + const mids = await infoClient.allMids( + dexParam ? { dex: dexParam } : undefined, + ); + return mids ?? {}; + } + + async #fetchSingleDexFresh( + infoClient: ReturnType< + typeof HyperLiquidClientService.prototype.getInfoClient + >, + dex: string | null, + lifecycleGeneration: number, + ): Promise { + const dexParam = dex ?? ''; + let metaAndCtxs: [MetaResponse | null, PerpsAssetCtx[]] | null = null; + try { + metaAndCtxs = (await infoClient.metaAndAssetCtxs( + dexParam ? { dex: dexParam } : undefined, + )) as [MetaResponse | null, PerpsAssetCtx[]] | null; + } catch (error) { + return { + dex, + meta: null, + assetCtxs: [], + allMids: {}, + success: false, + failedStep: 'metaAndAssetCtxs', + errorMessage: ensureError( + error, + 'HyperLiquidProvider.getMarketDataWithPrices.metaAndAssetCtxs', + ).message, + }; + } + + const meta = metaAndCtxs?.[0] ?? null; + const assetCtxs = metaAndCtxs?.[1] ?? []; + + let dexAllMids: Record = {}; + let allMidsErrorMessage: string | undefined; + try { + dexAllMids = await this.#getAllMids(infoClient, dexParam || undefined); + } catch (error) { + allMidsErrorMessage = ensureError( + error, + 'HyperLiquidProvider.getMarketDataWithPrices.allMids', + ).message; + } + + if (meta?.universe) { + await this.#cacheDexMetadataFromRead({ + dex, + meta, + assetCtxs, + lifecycleGeneration, + }); + } + + return { + dex, + meta, + assetCtxs, + allMids: dexAllMids, + success: Boolean(meta?.universe), + failedStep: allMidsErrorMessage ? 'allMids' : undefined, + errorMessage: allMidsErrorMessage, + }; + } + + /** + * Filter out successful HIP-3 DexFetchResults whose collateral token is + * not USDC, so getMarketDataWithPrices enforces the same USDC-only policy + * as market discovery (#fetchMarketsForDex) and order placement + * (#handleHip3PreOrder) — otherwise a non-USDC HIP-3 market could appear + * in overview data (and the cached stale snapshot derived from it) while + * order placement rejects it (TAT-3304). + * + * Main-DEX results (dex === null) and already-failed results pass through + * unchanged. #isUsdcCollateralDex fails closed, and on an unexpected + * error checking it (e.g. a spotMeta fetch failure) this also fails + * closed by dropping the DEX's result, rather than failing the whole + * method and losing main-DEX overview data. + * + * @param results - The DEX fetch results to filter. + * @returns A promise that resolves to the filtered results. + */ + async #excludeNonUsdcCollateralResults( + results: DexFetchResult[], + ): Promise { + return Promise.all( + results.map(async (result) => { + if (result.dex === null || !result.success) { + return result; + } + + let isUsdcDex: boolean; + try { + isUsdcDex = await this.#isUsdcCollateralDex(result.dex); + } catch (error) { + this.#deps.debugLogger.log( + 'HyperLiquidProvider: Failed to check collateral type for HIP-3 DEX; excluding from market data', + { + dex: result.dex, + error: ensureError( + error, + 'HyperLiquidProvider.excludeNonUsdcCollateralResults', + ).message, + }, + ); + isUsdcDex = false; + } + + if (isUsdcDex) { + return result; + } + + this.#deps.debugLogger.log( + 'HyperLiquidProvider: Excluding non-USDC-collateral HIP-3 DEX from market data', + { dex: result.dex }, + ); + + return { + ...result, + meta: null, + assetCtxs: [], + allMids: {}, + success: false, + }; + }), + ); + } + + #mergeDexResultsInto( + results: DexFetchResult[], + combinedUniverse: MetaResponse['universe'], + combinedAssetCtxs: PerpsAssetCtx[], + combinedAllMids: Record, + ): void { + results.forEach((result) => { + if (result.success && result.meta?.universe) { + const marketsFromDex = result.meta.universe; + const filteredMarketsWithContexts = marketsFromDex + .map((market, index) => ({ + market, + assetCtx: result.assetCtxs[index], + })) + .filter( + ({ market }) => + result.dex === null || + shouldIncludeMarket( + market.name, + result.dex, + this.#hip3Enabled, + this.#compiledAllowlistPatterns, + this.#compiledBlocklistPatterns, + ), + ); + + combinedUniverse.push( + ...filteredMarketsWithContexts.map(({ market }) => market), + ); + combinedAssetCtxs.push( + ...filteredMarketsWithContexts.map(({ assetCtx }) => assetCtx), + ); + Object.assign(combinedAllMids, result.allMids); + } + }); + } + + #cacheFreshMarketDataSnapshot( + marketData: PerpsMarketData[], + results: DexFetchResult[], + ): PerpsMarketData[] { + const freshMarketData = marketData.map((market) => ({ + ...market, + isStale: false, + })); + this.#cachedMarketDataWithPrices = { + data: freshMarketData.map((market) => ({ ...market })), + timestamp: Date.now(), + contributingDexs: results + .filter((result) => result.success && result.meta?.universe) + .map((result) => result.dex ?? 'main'), + failedDexs: results + .filter((result) => !result.success) + .map((result) => result.dex ?? 'main'), + }; + return freshMarketData; + } + + #getStaleMarketDataSnapshot(): PerpsMarketData[] | null { + if (!this.#cachedMarketDataWithPrices) { + return null; + } + + return this.#cachedMarketDataWithPrices.data.map((market) => ({ + ...market, + isStale: true, + })); + } + + /** + * Get market data with prices, volumes, and 24h changes + * Aggregates data from all enabled DEXs (main + HIP-3) when equity is enabled + * + * Note: This is called once during initialization and cached by PerpsStreamManager. + * Real-time price updates come from WebSocket subscriptions, not this method. + * + * @returns A promise that resolves to the combined market data from all enabled DEXs. + */ + async getMarketDataWithPrices(): Promise { + const lifecycleGeneration = this.#lifecycleGeneration; + this.#deps.debugLogger.log( + 'Getting market data with prices via HyperLiquid SDK', + ); + + // Ensure asset mapping is built first (populates meta cache) + // This guarantees buildAssetMapping has run before we check cache, + // eliminating duplicate metaAndAssetCtxs API calls from race conditions + await this.#ensureReady(); + + // Use HTTP transport for market data fetches — these are one-shot request/response calls + // that don't benefit from WebSocket. When the WebSocket is in CONNECTING state (after app + // backgrounding or network transitions), the SDK buffers messages causing timeouts. + const infoClient = this.#clientService.getInfoClient({ useHttp: true }); + + // Get enabled DEXs respecting feature flags (uses cached perpDexs) + const enabledDexs = await this.#getValidatedDexs(); + + // Fetch meta, assetCtxs, and allMids for each enabled DEX in parallel. + // Check the meta cache first to avoid redundant API calls when buildAssetMapping + // has already populated it; on cache miss, delegate to #fetchSingleDexFresh. + const dexDataResults = await Promise.all( + enabledDexs.map(async (dex) => { + const dexParam = dex ?? ''; + const cachedMeta = this.#cachedMetaByDex.get(dex); + if (!cachedMeta) { + this.#deps.debugLogger.log( + `[getMarketDataWithPrices] Cache miss for ${dex ?? 'main'}, fetching`, + ); + return this.#fetchSingleDexFresh( + infoClient, + dex, + lifecycleGeneration, + ); + } + + this.#deps.debugLogger.log( + `[getMarketDataWithPrices] Using cached meta for ${dex ?? 'main'}`, + { universeSize: cachedMeta.universe.length }, + ); + + let metaForDex = cachedMeta; + let assetCtxs = + this.#subscriptionService.getDexAssetCtxsCache(dexParam) ?? []; + let failedStep: DexFetchFailureStep | undefined; + let errorMessage: string | undefined; + + if (assetCtxs.length !== metaForDex.universe.length) { + try { + const freshResult = await infoClient.metaAndAssetCtxs( + dexParam ? { dex: dexParam } : undefined, + ); + const freshMeta = freshResult?.[0] ?? null; + const freshAssetCtxs = freshResult?.[1] ?? []; + + if (freshAssetCtxs.length !== freshMeta?.universe?.length) { + return { + dex, + meta: null, + assetCtxs: [], + allMids: {}, + success: false, + failedStep: 'metaAndAssetCtxs' as const, + errorMessage: + 'metaAndAssetCtxs returned mismatched universe/assetCtxs lengths', + }; + } + + metaForDex = freshMeta; + assetCtxs = freshAssetCtxs; + await this.#cacheDexMetadataFromRead({ + dex, + meta: freshMeta, + assetCtxs, + lifecycleGeneration, + }); + } catch (error) { + return { + dex, + meta: null, + assetCtxs: [], + allMids: {}, + success: false, + failedStep: 'metaAndAssetCtxs' as const, + errorMessage: ensureError( + error, + 'HyperLiquidProvider.getMarketDataWithPrices.metaAndAssetCtxs', + ).message, + }; + } + } + + let dexAllMids: Record = {}; + try { + dexAllMids = await this.#getAllMids( + infoClient, + dexParam || undefined, + ); + } catch (error) { + failedStep = 'allMids'; + errorMessage = ensureError( + error, + 'HyperLiquidProvider.getMarketDataWithPrices.allMids', + ).message; + } + + return { + dex, + meta: metaForDex, + assetCtxs, + allMids: dexAllMids, + success: true, + failedStep, + errorMessage, + }; + }), + ); + + // TAT-3304: Exclude non-USDC-collateral HIP-3 DEXs before merging, so + // getMarketDataWithPrices (and the stale snapshot #cacheFreshMarketDataSnapshot + // derives from it) enforces the same USDC-only policy as market discovery + // and order placement. + const usdcFilteredDexDataResults = + await this.#excludeNonUsdcCollateralResults(dexDataResults); + + // Combine universe, assetCtxs, and allMids from all DEXs + const combinedUniverse: MetaResponse['universe'] = []; + const combinedAssetCtxs: PerpsAssetCtx[] = []; + const combinedAllMids: Record = {}; + let latestDexResults = usdcFilteredDexDataResults; + + this.#mergeDexResultsInto( + usdcFilteredDexDataResults, + combinedUniverse, + combinedAssetCtxs, + combinedAllMids, + ); + + if (combinedUniverse.length === 0) { + combinedUniverse.length = 0; + combinedAssetCtxs.length = 0; + for (const key of Object.keys(combinedAllMids)) { + delete combinedAllMids[key]; + } + + const retryDelayMs = 2000; + this.#deps.debugLogger.log( + `[getMarketDataWithPrices] All DEXs returned empty, retrying in ${retryDelayMs}ms`, + ); + await new Promise((resolve) => setTimeout(resolve, retryDelayMs)); + + const retryResults = await Promise.all( + enabledDexs.map((dex) => + this.#fetchSingleDexFresh(infoClient, dex, lifecycleGeneration), + ), + ); + const usdcFilteredRetryResults = + await this.#excludeNonUsdcCollateralResults(retryResults); + + this.#mergeDexResultsInto( + usdcFilteredRetryResults, + combinedUniverse, + combinedAssetCtxs, + combinedAllMids, + ); + + latestDexResults = usdcFilteredRetryResults; + + if (combinedUniverse.length === 0) { + const failedDexs = retryResults + .filter((result) => !result.success) + .map((result) => result.dex ?? 'main'); + const succeededDexs = retryResults + .filter((result) => result.success) + .map((result) => result.dex ?? 'main'); + const failedDetails = retryResults + .filter((result) => result.errorMessage) + .map( + (result) => + `${result.dex ?? 'main'}:${result.failedStep ?? 'unknown'}:${ + result.errorMessage + }`, + ); + const staleMarketData = this.#getStaleMarketDataSnapshot(); + + if (staleMarketData) { + this.#deps.debugLogger.log( + '[getMarketDataWithPrices] Returning stale cached market data after retry failure', + { + failedDexs, + failedDetails, + cachedAt: + this.#cachedMarketDataWithPrices?.timestamp ?? Date.now(), + }, + ); + return staleMarketData; + } + + const wsState = this.#clientService.getConnectionState(); + throw new Error( + `Failed to fetch market data - no markets available (enabledDexs=${enabledDexs.length}, failed=[${failedDexs.join(',')}], succeeded=[${succeededDexs.join(',')}], wsState=${wsState}, details=[${failedDetails.join(';')}])`, + ); + } + + this.#deps.debugLogger.log('[getMarketDataWithPrices] Retry succeeded', { + marketCount: combinedUniverse.length, + }); + } + + const partialFailures = latestDexResults.filter( + (result) => result.errorMessage, + ); + if (partialFailures.length > 0) { + this.#deps.debugLogger.log( + 'Market data fetch completed with partial per-DEX failures', + { + failures: partialFailures.map( + (result) => + `${result.dex ?? 'main'}:${result.failedStep ?? 'unknown'}:${ + result.errorMessage + }`, + ), + }, + ); + } + + this.#deps.debugLogger.log( + 'HyperLiquidProvider: Aggregated market data from all DEXs', + { + dexCount: enabledDexs.length, + totalMarkets: combinedUniverse.length, + mainDexMarkets: latestDexResults[0]?.meta?.universe?.length ?? 0, + hip3Markets: + combinedUniverse.length - + (latestDexResults[0]?.meta?.universe?.length ?? 0), + }, + ); + + // Keep this diagnostic bounded. Logging every price stalls React Native + // DevTools and other CDP clients when thousands of spot keys are present. + const allMidEntries = Object.entries(combinedAllMids); + const hip3Entries = allMidEntries.filter(([key]) => key.includes(':')); + this.#deps.debugLogger.log('Combined allMids price data:', { + totalKeys: allMidEntries.length, + hip3Keys: hip3Entries.length, + keySample: allMidEntries.slice(0, 5).map(([key]) => key), + hip3KeySample: hip3Entries.slice(0, 5).map(([key]) => key), + samplePrices: Object.fromEntries(allMidEntries.slice(0, 5)), + }); + + // Transform to UI-friendly format using standalone utility + const transformedMarketData = transformMarketData( + { + universe: combinedUniverse, + assetCtxs: combinedAssetCtxs, + allMids: combinedAllMids, + }, + this.#deps.marketDataFormatters, + HIP3_ASSET_MARKET_TYPES, + HYPERLIQUID_ASSET_NAMES, + ); + if ( + !this.#isCacheWriteLifecycleCurrent( + lifecycleGeneration, + 'Market data cache write', + ) + ) { + return transformedMarketData; + } + return this.#cacheFreshMarketDataSnapshot( + transformedMarketData, + latestDexResults, + ); + } + + /** + * Validate deposit parameters according to HyperLiquid-specific rules + * This method enforces protocol-specific requirements like minimum amounts + * + * @param params - The operation parameters. + * @returns A promise that resolves to the result. + */ + async validateDeposit( + params: DepositParams, + ): Promise<{ isValid: boolean; error?: string }> { + return validateDepositParams({ + amount: params.amount, + assetId: params.assetId, + isTestnet: this.#clientService.isTestnetMode(), + }); + } + + /** + * Validate order parameters according to HyperLiquid-specific rules + * This includes minimum order sizes, leverage limits, and other protocol requirements + * + * @param params - The operation parameters. + * @returns A promise that resolves to the result. + */ + async validateOrder( + params: OrderParams, + ): Promise<{ isValid: boolean; error?: string }> { + try { + // Basic parameter validation + const basicValidation = validateOrderParams({ + coin: params.symbol, + size: params.size, + price: params.price, + orderType: params.orderType, + triggerPrice: params.triggerPrice, + takeProfitPrice: params.takeProfitPrice, + stopLossPrice: params.stopLossPrice, + takeProfitSize: params.takeProfitSize, + stopLossSize: params.stopLossSize, + tpslLinkage: params.tpslLinkage, + grouping: params.grouping, + timeInForce: params.timeInForce, + clientOrderId: params.clientOrderId, + ...pickStrategyParams(params), + }); + if (!basicValidation.isValid) { + return basicValidation; + } + + // Check minimum order size using consistent defaults (matching useMinimumOrderAmount hook) + // Note: For full validation with market-specific limits, use async methods + const minimumOrderSize = this.#clientService.isTestnetMode() + ? TRADING_DEFAULTS.amount.testnet + : TRADING_DEFAULTS.amount.mainnet; + + // Skip USD validation and minimum check for full closes (100% position close) + if (params.reduceOnly && params.isFullClose) { + this.#deps.debugLogger.log( + 'Full close detected: skipping USD validation and $10 minimum', + ); + } else { + // Calculate order value in USD for minimum validation + let orderValueUSD: number; + + if (params.usdAmount) { + // Preferred: Use provided USD amount (source of truth, no rounding loss) + orderValueUSD = parseFloat(params.usdAmount); + + this.#deps.debugLogger.log( + 'Validating USD amount (source of truth):', + { + usdAmount: orderValueUSD, + minimumRequired: minimumOrderSize, + }, + ); + } else { + // Fallback: Calculate from size × price + const size = parseFloat(params.size || '0'); + let priceForValidation = params.currentPrice; + + // For limit-executing orders without currentPrice, use limit price as + // fallback (plain limit, stop_limit, take_profit_limit) + if ( + !priceForValidation && + params.price && + isLimitExecutionOrderType(params.orderType) + ) { + priceForValidation = parseFloat(params.price); + this.#deps.debugLogger.log( + 'Using limit price for order validation (limit order):', + { + size, + limitPrice: priceForValidation, + }, + ); + } + + // Market-executing trigger orders (stop_market, take_profit_market) + // have no limit price; the trigger price is the best notional estimate. + if ( + !priceForValidation && + params.triggerPrice && + isTriggerOrderType(params.orderType) + ) { + priceForValidation = parseFloat(params.triggerPrice); + this.#deps.debugLogger.log( + 'Using trigger price for order validation (trigger order):', + { + size, + triggerPrice: priceForValidation, + orderType: params.orderType, + }, + ); + } + + if (!priceForValidation) { + return { + isValid: false, + error: PERPS_ERROR_CODES.ORDER_PRICE_REQUIRED, + }; + } + + orderValueUSD = size * priceForValidation; + + this.#deps.debugLogger.log('Validating calculated USD from size:', { + size, + price: priceForValidation, + calculatedUsd: orderValueUSD, + minimumRequired: minimumOrderSize, + }); + } + + // Validate minimum order size + if (orderValueUSD < minimumOrderSize) { + return { + isValid: false, + error: PERPS_ERROR_CODES.ORDER_SIZE_MIN, + }; + } + + // A strategy placement's total clearing the per-order minimum does not + // mean the orders it expands into will. + const strategyMinimum = validateStrategyNotional({ + orderType: params.orderType, + orderValueUSD, + }); + if (!strategyMinimum.isValid) { + return strategyMinimum; + } + } + + // Asset-specific leverage validation + if (params.leverage && params.symbol) { + try { + const maxLeverage = await this.getMaxLeverage(params.symbol); + if (params.leverage < 1 || params.leverage > maxLeverage) { + return { + isValid: false, + error: PERPS_ERROR_CODES.ORDER_LEVERAGE_INVALID, + }; + } + } catch (error) { + // Log the error before falling back + this.#deps.debugLogger.log( + 'Failed to get max leverage for symbol', + error, + ); + // If we can't get max leverage, use the default as fallback + const defaultMaxLeverage = PERPS_CONSTANTS.DefaultMaxLeverage; + if (params.leverage < 1 || params.leverage > defaultMaxLeverage) { + return { + isValid: false, + error: PERPS_ERROR_CODES.ORDER_LEVERAGE_INVALID, + }; + } + } + } + + // Check if order leverage meets existing position requirement (HyperLiquid protocol constraint) + if ( + params.leverage && + params.existingPositionLeverage && + params.leverage < params.existingPositionLeverage + ) { + return { + isValid: false, + error: PERPS_ERROR_CODES.ORDER_LEVERAGE_BELOW_POSITION, + }; + } + + // Validate order value against max limits + if (params.currentPrice && params.leverage) { + try { + const maxLeverage = await this.getMaxLeverage(params.symbol); + + const maxOrderValue = getMaxOrderValue(maxLeverage, params.orderType); + const orderValue = parseFloat(params.size) * params.currentPrice; + + if (orderValue > maxOrderValue) { + return { + isValid: false, + error: PERPS_ERROR_CODES.ORDER_MAX_VALUE_EXCEEDED, + }; + } + } catch (error) { + this.#deps.debugLogger.log( + 'Failed to validate max order value', + error, + ); + // Continue without max order validation if we can't get leverage + } + } + + return { isValid: true }; + } catch (error) { + return { + isValid: false, + error: + error instanceof Error + ? error.message + : PERPS_ERROR_CODES.UNKNOWN_ERROR, + }; + } + } + + /** + * Validate close position parameters according to HyperLiquid-specific rules + * Note: Full validation including remaining position size requires position data + * which should be passed from the UI layer + * + * @param params - The operation parameters. + * @returns A promise that resolves to the result. + */ + async validateClosePosition( + params: ClosePositionParams, + ): Promise<{ isValid: boolean; error?: string }> { + try { + // Basic validation + if (!params.symbol) { + return { + isValid: false, + error: PERPS_ERROR_CODES.ORDER_COIN_REQUIRED, + }; + } + + // If closing with limit order, must have price + if (params.orderType === 'limit' && !params.price) { + return { + isValid: false, + error: PERPS_ERROR_CODES.ORDER_LIMIT_PRICE_REQUIRED, + }; + } + + // Determine minimum order size (needed for precedence logic) + const minimumOrderSize = this.#clientService.isTestnetMode() + ? TRADING_DEFAULTS.amount.testnet + : TRADING_DEFAULTS.amount.mainnet; + + // Validate close size & minimum only if size provided (partial close) + if (params.size) { + const closeSize = parseFloat(params.size); + const price = params.currentPrice + ? parseFloat(params.currentPrice.toString()) + : undefined; + const orderValueUSD = + price && !isNaN(closeSize) ? closeSize * price : undefined; + + // Precedence rule: if size <= 0 treat as minimum_amount failure (more actionable) + if (isNaN(closeSize) || closeSize <= 0) { + return { + isValid: false, + error: PERPS_ERROR_CODES.ORDER_SIZE_MIN, + }; + } + + // Enforce minimum order value for partial closes when price known + if (orderValueUSD !== undefined && orderValueUSD < minimumOrderSize) { + return { + isValid: false, + error: PERPS_ERROR_CODES.ORDER_SIZE_MIN, + }; + } + + // Note: Remaining position validation stays in UI layer. + } + // Full closes (size undefined) bypass minimum check by design + // Note: For full closes (when size is undefined), there is no minimum + // This allows users to close positions worth less than $10 completely + + return { isValid: true }; + } catch (error) { + return { + isValid: false, + error: + error instanceof Error + ? error.message + : PERPS_ERROR_CODES.UNKNOWN_ERROR, + }; + } + } + + /** + * Validate withdrawal parameters - placeholder for future implementation + * + * @param _params - The unused operation parameters. + * @returns A promise that resolves to the result. + */ + async validateWithdrawal( + _params: WithdrawParams, + ): Promise<{ isValid: boolean; error?: string }> { + // Placeholder - to be implemented when needed + return { isValid: true }; + } + + /** + * Withdraw funds from HyperLiquid trading account + * + * This initiates a withdrawal request via HyperLiquid's API (withdraw3 endpoint). + * + * HyperLiquid Bridge Process: + * - Funds are immediately deducted from L1 balance on HyperLiquid + * - Validators sign the withdrawal (2/3 of staking power required) + * - Bridge contract on destination chain processes the withdrawal + * - After dispute period, USDC is sent to destination address + * - Total time: ~5 minutes + * - Fee: 1 USDC (covers Arbitrum gas costs) + * - No ETH required from user + * + * Note: Withdrawals won't appear as incoming transactions until the + * finalization phase completes (~5 minutes after initiation) + * + * @param params Withdrawal parameters + * @returns Result with txHash (HyperLiquid internal) and withdrawal ID + */ + async withdraw(params: WithdrawParams): Promise { + try { + this.#deps.debugLogger.log('HyperLiquidProvider: STARTING WITHDRAWAL', { + params, + timestamp: new Date().toISOString(), + assetId: params.assetId, + amount: params.amount, + destination: params.destination, + isTestnet: this.#clientService.isTestnetMode(), + }); + + // Step 1: Validate withdrawal parameters + this.#deps.debugLogger.log('HyperLiquidProvider: VALIDATING PARAMETERS'); + const validation = validateWithdrawalParams(params); + if (!validation.isValid) { + this.#deps.debugLogger.log( + '❌ HyperLiquidProvider: PARAMETER VALIDATION FAILED', + { + error: validation.error, + params, + validationResult: validation, + }, + ); + throw new Error(validation.error); + } + this.#deps.debugLogger.log('HyperLiquidProvider: PARAMETERS VALIDATED'); + + // Step 2: Get supported withdrawal routes and validate asset + this.#deps.debugLogger.log('HyperLiquidProvider: CHECKING ASSET SUPPORT'); + const supportedRoutes = this.getWithdrawalRoutes(); + this.#deps.debugLogger.log( + 'HyperLiquidProvider: SUPPORTED WITHDRAWAL ROUTES', + { + routeCount: supportedRoutes.length, + routes: supportedRoutes.map((route) => ({ + assetId: route.assetId, + chainId: route.chainId, + contractAddress: route.contractAddress, + })), + }, + ); + + // This check is already done in validateWithdrawalParams, but TypeScript needs explicit check + if (!params.assetId) { + this.#deps.debugLogger.log('HyperLiquidProvider: MISSING ASSET ID', { + error: PERPS_ERROR_CODES.WITHDRAW_ASSET_ID_REQUIRED, + params, + }); + throw new Error(PERPS_ERROR_CODES.WITHDRAW_ASSET_ID_REQUIRED); + } + + const assetValidation = validateAssetSupport( + params.assetId, + supportedRoutes, + ); + if (!assetValidation.isValid) { + this.#deps.debugLogger.log( + '❌ HyperLiquidProvider: ASSET NOT SUPPORTED', + { + error: assetValidation.error, + assetId: params.assetId, + supportedAssets: supportedRoutes.map((route) => route.assetId), + }, + ); + throw new Error(assetValidation.error); + } + this.#deps.debugLogger.log('HyperLiquidProvider: ASSET SUPPORTED', { + assetId: params.assetId, + }); + + // Step 3: Determine destination address + this.#deps.debugLogger.log( + 'HyperLiquidProvider: DETERMINING DESTINATION ADDRESS', + ); + let destination: Hex; + if (params.destination) { + destination = params.destination; + this.#deps.debugLogger.log( + 'HyperLiquidProvider: USING PROVIDED DESTINATION', + { + destination, + }, + ); + } else { + destination = await this.#walletService.getUserAddressWithDefault(); + this.#deps.debugLogger.log( + 'HyperLiquidProvider: USING USER WALLET ADDRESS', + { + destination, + }, + ); + } + + // Step 4: Ensure client is ready + this.#deps.debugLogger.log('HyperLiquidProvider: ENSURING CLIENT READY'); + await this.#ensureReady(); + await this.#ensureUnifiedAccountEnabled({ allowUserSigning: true }); + const exchangeClient = this.#clientService.getExchangeClient(); + this.#deps.debugLogger.log('HyperLiquidProvider: CLIENT READY'); + + // Step 5: Validate amount against account balance + this.#deps.debugLogger.log( + 'HyperLiquidProvider: CHECKING ACCOUNT BALANCE', + ); + const accountState = await this.getAccountState(); + const withdrawableBalance = parseFloat(accountState.withdrawableBalance); + this.#deps.debugLogger.log('HyperLiquidProvider: ACCOUNT BALANCE', { + withdrawableBalance, + spendableBalance: accountState.spendableBalance, + totalBalance: accountState.totalBalance, + marginUsed: accountState.marginUsed, + unrealizedPnl: accountState.unrealizedPnl, + }); + + // This check is already done in validateWithdrawalParams, but TypeScript needs explicit check + if (!params.amount) { + this.#deps.debugLogger.log('HyperLiquidProvider: MISSING AMOUNT', { + error: PERPS_ERROR_CODES.WITHDRAW_AMOUNT_REQUIRED, + params, + }); + throw new Error(PERPS_ERROR_CODES.WITHDRAW_AMOUNT_REQUIRED); + } + + const withdrawAmount = parseFloat(params.amount); + this.#deps.debugLogger.log('HyperLiquidProvider: WITHDRAWAL AMOUNT', { + requestedAmount: withdrawAmount, + withdrawableBalance, + sufficientBalance: withdrawAmount <= withdrawableBalance, + }); + + // Validate against withdrawableBalance — the mode-aware cap. + // No spot sweep: withdrawableBalance already reflects what withdraw3 + // can pull. In Unified mode HL handles cross-wallet internally; in + // Standard mode spot is not withdrawable via perps. + const balanceValidation = validateBalance( + withdrawAmount, + withdrawableBalance, + ); + if (!balanceValidation.isValid) { + this.#deps.debugLogger.log( + 'HyperLiquidProvider: INSUFFICIENT BALANCE', + { + error: balanceValidation.error, + requestedAmount: withdrawAmount, + withdrawableBalance, + difference: withdrawAmount - withdrawableBalance, + }, + ); + throw new Error(balanceValidation.error); + } + this.#deps.debugLogger.log('✅ HyperLiquidProvider: BALANCE SUFFICIENT'); + + // Step 6: Execute withdrawal via HyperLiquid SDK (API call) + this.#deps.debugLogger.log('HyperLiquidProvider: CALLING WITHDRAW3 API', { + destination, + amount: params.amount, + endpoint: 'withdraw3', + timestamp: new Date().toISOString(), + }); + + const result = await exchangeClient.withdraw3({ + destination, + amount: params.amount, + }); + + this.#deps.debugLogger.log( + 'HyperLiquidProvider: WITHDRAW3 API RESPONSE', + { + status: result.status, + response: result, + timestamp: new Date().toISOString(), + }, + ); + + if (result.status === 'ok') { + this.#deps.debugLogger.log( + 'HyperLiquidProvider: WITHDRAWAL SUBMITTED SUCCESSFULLY', + { + destination, + amount: params.amount, + assetId: params.assetId, + status: result.status, + }, + ); + + const now = Date.now(); + const withdrawalId = `hl_${uuidv4()}`; + + return { + success: true, + withdrawalId, + estimatedArrivalTime: now + 5 * 60 * 1000, // HyperLiquid typically takes ~5 minutes + // Don't set txHash if we don't have a real transaction hash + // HyperLiquid's withdraw3 API doesn't return a transaction hash immediately + }; + } + + const errorMessage = `Withdrawal failed: ${String(result.status)}`; + this.#deps.debugLogger.log('HyperLiquidProvider: WITHDRAWAL FAILED', { + error: errorMessage, + status: result.status, + response: result, + params, + }); + return { + success: false, + error: errorMessage, + }; + } catch (error) { + const safeError = ensureError( + error, + 'HyperLiquidProvider.initiateWithdrawal', + ); + this.#deps.debugLogger.log('HyperLiquidProvider: WITHDRAWAL EXCEPTION', { + error: safeError.message, + errorType: safeError.name, + stack: safeError.stack, + params, + timestamp: new Date().toISOString(), + }); + this.#deps.logger.error( + safeError, + this.#getErrorContext('withdraw', { + assetId: params.assetId, + amount: params.amount, + destination: params.destination, + }), + ); + return createErrorResult(error, { success: false }); + } + } + + /** + * Transfer USDC collateral between DEXs (main ↔ HIP-3) + * + * Verified working on mainnet via Phantom wallet testing (10/15/2025). + * See docs/perps/HIP-3-IMPLEMENTATION.md for complete transaction flow. + * + * @param params - Transfer parameters + * @param params.sourceDex - Source DEX name ('' = main, 'xyz' = HIP-3) + * @param params.destinationDex - Destination DEX name ('' = main, 'xyz' = HIP-3) + * @param params.amount - USDC amount to transfer + * @returns Transfer result with success status and transaction hash + * @example + * // Transfer 10 USDC from main DEX to xyz HIP-3 DEX + * await transferBetweenDexs({ + * sourceDex: '', + * destinationDex: 'xyz', + * amount: '10' + * }); + */ + async transferBetweenDexs( + params: TransferBetweenDexsParams, + ): Promise { + try { + this.#deps.debugLogger.log('HyperLiquidProvider: STARTING DEX TRANSFER', { + params, + timestamp: new Date().toISOString(), + }); + + // Validate parameters + if (!params.amount || parseFloat(params.amount) <= 0) { + throw new Error('Transfer amount must be greater than 0'); + } + + if (params.sourceDex === params.destinationDex) { + throw new Error('Source and destination DEX must be different'); + } + + // Get user address + const userAddress = await this.#walletService.getUserAddressWithDefault(); + this.#deps.debugLogger.log('HyperLiquidProvider: USER ADDRESS', { + userAddress, + }); + + // Ensure client ready + await this.#ensureReady(); + const exchangeClient = this.#clientService.getExchangeClient(); + + // Execute transfer using SDK sendAsset() + // Note: SDK docs say "testnet-only" but it works on mainnet (verified via Phantom) + this.#deps.debugLogger.log( + 'HyperLiquidProvider: CALLING SEND_ASSET API', + { + sourceDex: params.sourceDex || '(main)', + destinationDex: params.destinationDex || '(main)', + amount: params.amount, + }, + ); + + const result = await exchangeClient.sendAsset({ + destination: userAddress, + sourceDex: params.sourceDex, + destinationDex: params.destinationDex, + token: await this.#getUsdcTokenId(), // Query correct USDC token ID dynamically + amount: params.amount, + }); + + this.#deps.debugLogger.log('HyperLiquidProvider: SEND_ASSET RESPONSE', { + status: result.status, + timestamp: new Date().toISOString(), + }); + + if (result.status === 'ok') { + this.#deps.debugLogger.log( + '✅ HyperLiquidProvider: TRANSFER SUCCESSFUL', + ); + return { + success: true, + // Note: sendAsset doesn't return txHash in response + // User can verify transfer in explorer by timestamp + }; + } + + throw new Error(PERPS_ERROR_CODES.TRANSFER_FAILED); + } catch (error) { + const safeError = ensureError( + error, + 'HyperLiquidProvider.transferToSpot', + ); + this.#deps.debugLogger.log('❌ HyperLiquidProvider: TRANSFER FAILED', { + error: safeError.message, + params, + }); + this.#deps.logger.error( + safeError, + this.#getErrorContext('transferBetweenDexs', { ...params }), + ); + return { + success: false, + error: safeError.message, + }; + } + } + + /** + * Subscribe to live price updates + * + * @param params - The operation parameters. + * @returns A cleanup function to remove the subscription. + */ + subscribeToPrices(params: SubscribePricesParams): () => void { + // Handle async subscription service by immediately returning cleanup function + // The subscription service will load correct funding rates before any callbacks + let unsubscribe: (() => void) | undefined; + let cancelled = false; + + this.#subscriptionService + .subscribeToPrices(params) + .then((unsub) => { + // If cleanup was called before subscription completed, immediately unsubscribe + if (cancelled) { + unsub(); + } else { + unsubscribe = unsub; + } + return undefined; + }) + .catch((error) => { + this.#deps.logger.error( + ensureError(error, 'HyperLiquidProvider.subscribeToPrices'), + this.#getErrorContext('subscribeToPrices', { + symbols: params.symbols, + }), + ); + return undefined; + }); + + return () => { + cancelled = true; + if (unsubscribe) { + unsubscribe(); + } + }; + } + + /** + * Subscribe to live position updates + * + * @param params - The operation parameters. + * @returns A cleanup function to remove the subscription. + */ + subscribeToPositions(params: SubscribePositionsParams): () => void { + return this.#subscriptionService.subscribeToPositions(params); + } + + /** + * Subscribe to live order fill updates + * + * @param params - The operation parameters. + * @returns A cleanup function to remove the subscription. + */ + subscribeToOrderFills(params: SubscribeOrderFillsParams): () => void { + return this.#subscriptionService.subscribeToOrderFills(params); + } + + /** + * Subscribe to live order updates + * + * @param params - The operation parameters. + * @returns A cleanup function to remove the subscription. + */ + subscribeToOrders(params: SubscribeOrdersParams): () => void { + return this.#subscriptionService.subscribeToOrders(params); + } + + /** + * Subscribe to live account updates + * + * @param params - The operation parameters. + * @returns A cleanup function to remove the subscription. + */ + subscribeToAccount(params: SubscribeAccountParams): () => void { + return this.#subscriptionService.subscribeToAccount(params); + } + + /** + * Subscribe to open interest cap updates + * Zero additional overhead - data extracted from existing webData3 subscription + * + * @param params - The operation parameters. + * @returns A cleanup function to remove the subscription. + */ + subscribeToOICaps(params: SubscribeOICapsParams): () => void { + return this.#subscriptionService.subscribeToOICaps(params); + } + + /** + * Subscribe to full order book updates with multiple depth levels + * Creates a dedicated L2Book subscription for real-time order book data + * + * @param params - The operation parameters. + * @returns A cleanup function to remove the subscription. + */ + subscribeToOrderBook(params: SubscribeOrderBookParams): () => void { + return this.#subscriptionService.subscribeToOrderBook(params); + } + + /** + * Subscribe to live candle updates + * + * @param params - The operation parameters. + * @returns A cleanup function to remove the subscription. + */ + subscribeToCandles(params: SubscribeCandlesParams): () => void { + return this.#clientService.subscribeToCandles(params); + } + + /** + * Configure live data settings + * + * @param config - The configuration object. + */ + setLiveDataConfig(config: Partial): void { + this.#deps.debugLogger.log('Live data config updated:', config); + } + + /** + * Toggle testnet mode + * + * @returns A promise that resolves to the result. + */ + async toggleTestnet(): Promise { + try { + const newIsTestnet = !this.#clientService.isTestnetMode(); + const disconnectResult = await this.disconnect(); + if (!disconnectResult.success) { + // The network did not change. Keep the provider eligible for lazy + // recovery instead of leaving this aborted toggle permanently sticky. + this.#isDisconnected = false; + throw new Error( + disconnectResult.error ?? + 'Failed to disconnect before network toggle', + ); + } + + // Update all services + this.#clientService.setTestnetMode(newIsTestnet); + // Invalidate reads that started after disconnect completed but before + // the clients switched networks. + this.#lifecycleGeneration += 1; + this.#walletService.setTestnetMode(newIsTestnet); + + const initializeResult = await this.initialize(); + if (!initializeResult.success) { + // The network switch is already committed. Leave the provider eligible + // for lazy initialization so the next operation can retry the failed + // client setup instead of remaining permanently disconnected. + this.#isDisconnected = false; + throw new Error( + initializeResult.error ?? 'Failed to initialize after network toggle', + ); + } + + return { + success: true, + isTestnet: newIsTestnet, + }; + } catch (error) { + return createErrorResult(error, { + success: false, + isTestnet: this.#clientService.isTestnetMode(), + }); + } + } + + /** + * Initialize provider (ensures clients are ready) + * + * @returns A promise that resolves to the result. + */ + async initialize(): Promise { + const lifecycleGeneration = this.#lifecycleGeneration; + try { + if (this.#disconnectOperationsInFlight > 0) { + throw new Error(PERPS_ERROR_CODES.PROVIDER_LIFECYCLE_STALE); + } + // Ensure clients are initialized (lazy initialization) + await this.#ensureClientsInitialized(); + if ( + this.#disconnectOperationsInFlight > 0 || + lifecycleGeneration !== this.#lifecycleGeneration + ) { + throw new Error(PERPS_ERROR_CODES.PROVIDER_LIFECYCLE_STALE); + } + this.#isDisconnected = false; + return { + success: true, + chainId: getChainId(this.#clientService.isTestnetMode()), + }; + } catch (error) { + return createErrorResult(error, { success: false }); + } + } + + /** + * Check if ready to trade + * + * @returns A promise that resolves to the result. + */ + async isReadyToTrade(): Promise { + try { + const exchangeClient = this.#clientService.getExchangeClient(); + const infoClient = this.#clientService.getInfoClient(); + const walletConnected = Boolean(exchangeClient) && Boolean(infoClient); + + let accountConnected = false; + try { + await this.#walletService.getCurrentAccountId(); + accountConnected = true; + } catch (error) { + this.#deps.debugLogger.log('Account not connected:', error); + accountConnected = false; + } + + const ready = walletConnected && accountConnected; + + return { + ready, + walletConnected, + networkSupported: true, + }; + } catch (error) { + return { + ready: false, + walletConnected: false, + networkSupported: false, + error: + error instanceof Error + ? error.message + : PERPS_ERROR_CODES.UNKNOWN_ERROR, + }; + } + } + + /** + * Project the isolated position that would remain after a proposed order. + * + * Fetches the asset's margin table from cached meta so liquidation uses the + * maintenance tier at the resulting liquidation notional. Cross-margin + * positions return unsupported without a table lookup. + * + * @param params - Live position plus the proposed order. + * @returns Discriminated preview; margin and liquidation are independently available. + */ + async previewPositionModify( + params: PositionModifyPreviewParams, + ): Promise { + if (params.position.leverage.type === 'cross') { + return { status: 'unsupported', reason: 'cross_margin' }; + } + + const { dex: dexName } = parseAssetName(params.position.symbol); + let marginTiers = null; + + try { + const meta = await this.#getCachedMeta({ dexName }); + const assetInfo = meta.universe.find( + (universeItem) => universeItem.name === params.position.symbol, + ); + marginTiers = resolveHyperLiquidMarginTiers({ + marginTableId: assetInfo?.marginTableId, + maxLeverage: assetInfo?.maxLeverage ?? params.position.maxLeverage, + marginTables: meta.marginTables, + }); + } catch (error) { + this.#deps.debugLogger.log( + 'HyperLiquidProvider: margin table unavailable for position preview', + { + symbol: params.position.symbol, + error, + }, + ); + } + + return previewHyperLiquidIsolatedPositionModify({ + ...params, + marginTiers, + }); + } + + /** + * Calculate liquidation price using HyperLiquid's formula + * Formula: liq_price = price - side * margin_available / position_size / (1 - maintenanceMarginRatio * side) + * where maintenanceMarginRatio = 1 / MAINTENANCE_LEVERAGE = 1 / (2 * max_leverage) + * + * @param params - The operation parameters. + * @returns A promise that resolves to the string result. + */ + async calculateLiquidationPrice( + params: LiquidationPriceParams, + ): Promise { + const { entryPrice, leverage, direction, asset } = params; + + // Validate inputs + if ( + !isFinite(entryPrice) || + !isFinite(leverage) || + entryPrice <= 0 || + leverage <= 0 + ) { + return '0.00'; + } + + // Get asset's max leverage to calculate maintenance margin + let maxLeverage = PERPS_CONSTANTS.DefaultMaxLeverage; // Default fallback + if (asset) { + try { + maxLeverage = await this.getMaxLeverage(asset); + } catch (error) { + this.#deps.debugLogger.log( + 'Failed to get max leverage for asset, using default', + { + asset, + error, + }, + ); + // Use default if we can't fetch the asset's max leverage + } + } + + // Calculate maintenance leverage and margin according to HyperLiquid docs + const maintenanceLeverage = 2 * maxLeverage; + const maintenanceMarginRatio = 1 / maintenanceLeverage; + const side = direction === 'long' ? 1 : -1; + + // For isolated margin, we use the standard formula + // margin_available = initial_margin - maintenance_margin_required + const initialMargin = 1 / leverage; + const maintenanceMargin = 1 / maintenanceLeverage; + + // Check if position can be opened + if (initialMargin < maintenanceMargin) { + // Position cannot be opened - leverage exceeds maximum allowed (2 * maxLeverage) + throw new Error( + `Invalid leverage: ${leverage}x exceeds maximum allowed leverage of ${maintenanceLeverage}x`, + ); + } + + try { + // HyperLiquid liquidation formula + // For isolated margin: margin_available = isolated_margin - maintenance_margin_required + const marginAvailable = initialMargin - maintenanceMargin; + + // Simplified calculation when position size is 1 unit + // liq_price = price - side * margin_available * price / (1 - maintenanceMarginRatio * side) + const denominator = 1 - maintenanceMarginRatio * side; + if (Math.abs(denominator) < 0.0001) { + // Avoid division by very small numbers + return String(entryPrice); + } + + const liquidationPrice = + entryPrice - (side * marginAvailable * entryPrice) / denominator; + + // Ensure liquidation price is non-negative + return String(Math.max(0, liquidationPrice)); + } catch (error) { + this.#deps.logger.error( + ensureError(error, 'HyperLiquidProvider.calculateLiquidationPrice'), + this.#getErrorContext('calculateLiquidationPrice', { + asset: params.asset, + entryPrice: params.entryPrice, + leverage: params.leverage, + direction: params.direction, + }), + ); + return '0.00'; + } + } + + /** + * Calculate maintenance margin for a specific asset + * According to HyperLiquid docs: maintenance_margin = 1 / (2 * max_leverage) + * + * @param params - The operation parameters. + * @returns A promise that resolves to the numeric result. + */ + async calculateMaintenanceMargin( + params: MaintenanceMarginParams, + ): Promise { + const { asset } = params; + + // Get asset's max leverage + const maxLeverage = await this.getMaxLeverage(asset); + + // Maintenance margin = 1 / (2 * max_leverage) + // This varies from 1.25% (for 40x) to 16.7% (for 3x) depending on the asset + return 1 / (2 * maxLeverage); + } + + /** + * Get maximum leverage allowed for an asset + * + * @param asset - The asset identifier. + * @returns A promise that resolves to the numeric result. + */ + async getMaxLeverage(asset: string): Promise { + const lifecycleGeneration = this.#lifecycleGeneration; + try { + // Check cache first + const cached = this.#maxLeverageCache.get(asset); + const now = Date.now(); + + if ( + cached && + now - cached.timestamp < PERFORMANCE_CONFIG.MaxLeverageCacheDurationMs + ) { + return cached.value; + } + + // Read-only operation: only need client initialization, not full ensureReady() + // (no DEX abstraction, referral, or builder fee needed for metadata) + await this.#ensureClientsInitialized(); + this.#clientService.ensureInitialized(); + + // Extract DEX name for API calls (main DEX = null) + const { dex: dexName } = parseAssetName(asset); + + // Get asset info (uses cache to avoid redundant API calls) + const meta = await this.#getCachedMeta({ dexName }); + + // Check if meta and universe exist and is valid + // This should never happen since getCachedMeta validates, but defensive check + if (!meta?.universe || !Array.isArray(meta.universe)) { + this.#deps.logger.error( + new Error( + '[HyperLiquidProvider] Invalid meta response in getMaxLeverage', + ), + this.#getErrorContext('getMaxLeverage', { + asset, + dexName: dexName ?? 'main', + note: 'Meta or universe not available, using default max leverage', + }), + ); + return PERPS_CONSTANTS.DefaultMaxLeverage; + } + + // asset.name format: "BTC" for main DEX, "xyz:XYZ100" for HIP-3 + const assetInfo = meta.universe.find((univ) => univ.name === asset); + if (!assetInfo) { + this.#deps.debugLogger.log( + `Asset ${asset} not found in universe, using default max leverage`, + ); + return PERPS_CONSTANTS.DefaultMaxLeverage; + } + + if ( + this.#isCacheWriteLifecycleCurrent( + lifecycleGeneration, + 'Maximum leverage cache write', + ) + ) { + this.#maxLeverageCache.set(asset, { + value: assetInfo.maxLeverage, + timestamp: now, + }); + } + + return assetInfo.maxLeverage; + } catch (error) { + this.#deps.logger.error( + ensureError(error, 'HyperLiquidProvider.getMaxLeverage'), + this.#getErrorContext('getMaxLeverage', { + asset, + }), + ); + return PERPS_CONSTANTS.DefaultMaxLeverage; + } + } + + /** + * Calculate fees based on HyperLiquid's fee structure + * Returns fee rate as decimal (e.g., 0.00045 for 0.045%) + * + * Uses the SDK's userFees API to get actual discounted rates when available, + * falling back to base rates if the API is unavailable or user not connected. + * + * @param params - The operation parameters. + * @returns A promise that resolves to the result. + */ + async calculateFees( + params: FeeCalculationParams, + ): Promise { + const lifecycleGeneration = this.#lifecycleGeneration; + const { orderType, isMaker = false, amount, symbol } = params; + const numericAmount = + amount === undefined ? undefined : Number.parseFloat(amount); + const parsedAmount = + numericAmount === undefined || + (Number.isFinite(numericAmount) && numericAmount >= 0) + ? numericAmount + : 0; + + // Every placement is charged as its execution kind: a stop_market fills as a + // market order (taker), a stop_limit as a limit order, a scale ladder as the + // resting limit orders it fans out into, and a TWAP as the marketable + // suborders it crosses the book with. + const isMarketExecution = getTriggerExecution(orderType) === 'market'; + + // A chase is post-only by construction, so it can only ever fill as a maker. + // Quoting it at the taker rate would overstate the fee whatever the caller + // passes for `isMaker`. + const chargesMakerRate = + orderType === 'chase' || (!isMarketExecution && isMaker); + + // Start with base rates from config + let feeRate = chargesMakerRate ? FEE_RATES.maker : FEE_RATES.taker; + + // Parse symbol to detect HIP-3 DEX (e.g., "xyz:TSLA" → dex="xyz", parsedSymbol="TSLA") + const { dex, symbol: parsedSymbol } = parseAssetName(symbol); + const isHip3Asset = dex !== null; + + // Calculate HIP-3 fee multiplier dynamically (handles Growth Mode) + let hip3Multiplier = 1; + if (isHip3Asset && dex && parsedSymbol) { + hip3Multiplier = await this.#calculateHip3FeeMultiplier({ + dexName: dex, + assetSymbol: parsedSymbol, + }); + const originalRate = feeRate; + feeRate *= hip3Multiplier; + + this.#deps.debugLogger.log('HIP-3 Dynamic Fee Multiplier Applied', { + symbol, + dex, + parsedSymbol, + originalBaseRate: originalRate, + hip3BaseRate: feeRate, + hip3Multiplier, + }); + } + + this.#deps.debugLogger.log('HyperLiquid Fee Calculation Started', { + orderType, + isMaker, + amount, + symbol, + isHip3Asset, + hip3Multiplier, + baseFeeRate: feeRate, + baseTakerRate: FEE_RATES.taker, + baseMakerRate: FEE_RATES.maker, + }); + + // Try to get user-specific rates if wallet is connected + try { + const userAddress = await this.#walletService.getUserAddressWithDefault(); + this.#assertProviderLifecycleCurrent( + lifecycleGeneration, + 'User fee cache read', + ); + + this.#deps.debugLogger.log('User Address Retrieved', { + userAddress, + network: this.#clientService.isTestnetMode() ? 'testnet' : 'mainnet', + }); + + // Check cache first + if (this.#isFeeCacheValid(userAddress)) { + const cached = this.#userFeeCache.get(userAddress); + if (cached) { + // Same maker/taker decision as the base rates above, including the + // post-only chase case — re-deriving it here would quote a chase at + // the taker rate whenever the caller passed isMaker: false. + let userFeeRate = chargesMakerRate + ? cached.perpsMakerRate + : cached.perpsTakerRate; + + // Apply HIP-3 dynamic multiplier to user-specific rates (includes Growth Mode) + if (isHip3Asset && hip3Multiplier > 0) { + userFeeRate *= hip3Multiplier; + } + + feeRate = userFeeRate; + + this.#deps.debugLogger.log('📦 Using Cached Fee Rates', { + cacheHit: true, + perpsTakerRate: cached.perpsTakerRate, + perpsMakerRate: cached.perpsMakerRate, + spotTakerRate: cached.spotTakerRate, + spotMakerRate: cached.spotMakerRate, + selectedRate: feeRate, + isHip3Asset, + hip3Multiplier, + cacheExpiry: new Date(cached.timestamp + cached.ttl).toISOString(), + cacheAge: `${Math.round((Date.now() - cached.timestamp) / 1000)}s`, + }); + } + } else { + this.#deps.debugLogger.log( + 'Fetching Fresh Fee Rates from HyperLiquid API', + { + cacheHit: false, + userAddress, + }, + ); + + // Fetch fresh rates from SDK + // Read-only operation: only need client initialization + this.#assertProviderLifecycleCurrent( + lifecycleGeneration, + 'User fee fetch', + ); + await this.#ensureClientsInitialized(); + this.#assertProviderLifecycleCurrent( + lifecycleGeneration, + 'User fee fetch', + ); + this.#clientService.ensureInitialized(); + const infoClient = this.#clientService.getInfoClient(); + const userFees = await infoClient.userFees({ + user: userAddress, + }); + + this.#deps.debugLogger.log('HyperLiquid userFees API Response', { + userCrossRate: userFees.userCrossRate, + userAddRate: userFees.userAddRate, + activeReferralDiscount: userFees.activeReferralDiscount, + activeStakingDiscount: userFees.activeStakingDiscount, + }); + + // Parse base user rates (these don't include discounts as expected) + const baseUserTakerRate = parseBoundedNonNegativeDecimal( + userFees.userCrossRate, + MAX_API_FEE_RATE, + ); + const baseUserMakerRate = parseBoundedNonNegativeDecimal( + userFees.userAddRate, + MAX_API_FEE_RATE, + ); + const baseUserSpotTakerRate = parseBoundedNonNegativeDecimal( + userFees.userSpotCrossRate, + MAX_API_FEE_RATE, + ); + const baseUserSpotMakerRate = parseBoundedNonNegativeDecimal( + userFees.userSpotAddRate, + MAX_API_FEE_RATE, + ); + + // Apply discounts manually since HyperLiquid API doesn't apply them + const referralDiscount = parseBoundedNonNegativeDecimal( + userFees.activeReferralDiscount || '0', + MAX_API_FEE_DISCOUNT, + ); + const stakingDiscount = parseBoundedNonNegativeDecimal( + userFees.activeStakingDiscount?.discount || '0', + MAX_API_FEE_DISCOUNT, + ); + + if ( + baseUserTakerRate === null || + baseUserMakerRate === null || + baseUserSpotTakerRate === null || + baseUserSpotMakerRate === null || + referralDiscount === null || + stakingDiscount === null + ) { + throw new Error('Invalid fee rates received from API'); + } + + // Calculate total discount (referral + staking, but not compounding) + const totalDiscount = Math.min(referralDiscount + stakingDiscount, 0.4); // Cap at 40% + + // Apply discount to rates + const perpsTakerRate = baseUserTakerRate * (1 - totalDiscount); + const perpsMakerRate = baseUserMakerRate * (1 - totalDiscount); + const spotTakerRate = baseUserSpotTakerRate * (1 - totalDiscount); + const spotMakerRate = baseUserSpotMakerRate * (1 - totalDiscount); + + this.#deps.debugLogger.log('Fee Discount Calculation', { + discounts: { + referral: `${(referralDiscount * 100).toFixed(1)}%`, + staking: `${(stakingDiscount * 100).toFixed(1)}%`, + total: `${(totalDiscount * 100).toFixed(1)}%`, + }, + rates: { + before: { + taker: `${(baseUserTakerRate * 100).toFixed(4)}%`, + maker: `${(baseUserMakerRate * 100).toFixed(4)}%`, + }, + after: { + taker: `${(perpsTakerRate * 100).toFixed(4)}%`, + maker: `${(perpsMakerRate * 100).toFixed(4)}%`, + }, + }, + }); + + // Validate all rates are valid numbers before caching + if ( + !Number.isFinite(perpsTakerRate) || + !Number.isFinite(perpsMakerRate) || + !Number.isFinite(spotTakerRate) || + !Number.isFinite(spotMakerRate) || + perpsTakerRate < 0 || + perpsMakerRate < 0 || + spotTakerRate < 0 || + spotMakerRate < 0 + ) { + this.#deps.debugLogger.log('Fee Rate Validation Failed', { + validation: { + perpsTakerValid: + Number.isFinite(perpsTakerRate) && perpsTakerRate >= 0, + perpsMakerValid: + Number.isFinite(perpsMakerRate) && perpsMakerRate >= 0, + spotTakerValid: + Number.isFinite(spotTakerRate) && spotTakerRate >= 0, + spotMakerValid: + Number.isFinite(spotMakerRate) && spotMakerRate >= 0, + }, + rawValues: { + perpsTakerRate, + perpsMakerRate, + spotTakerRate, + spotMakerRate, + }, + }); + throw new Error('Invalid fee rates received from API'); + } + + const rates = { + perpsTakerRate, + perpsMakerRate, + spotTakerRate, + spotMakerRate, + timestamp: Date.now(), + ttl: 5 * 60 * 1000, // 5 minutes + }; + + this.#assertProviderLifecycleCurrent( + lifecycleGeneration, + 'User fee cache write', + ); + this.#userFeeCache.set(userAddress, rates); + // Same maker/taker decision as the base rates above, chase included. + let userFeeRate = chargesMakerRate + ? rates.perpsMakerRate + : rates.perpsTakerRate; + + // Apply HIP-3 dynamic multiplier to API-fetched rates (includes Growth Mode) + if (isHip3Asset && hip3Multiplier > 0) { + userFeeRate *= hip3Multiplier; + } + + feeRate = userFeeRate; + + this.#deps.debugLogger.log('Fee Rates Validated and Cached', { + selectedRate: feeRate, + selectedRatePercentage: `${(feeRate * 100).toFixed(4)}%`, + discountApplied: perpsTakerRate < FEE_RATES.taker, + isHip3Asset, + hip3Multiplier, + cacheExpiry: new Date(rates.timestamp + rates.ttl).toISOString(), + }); + } + } catch (error) { + // Silently fall back to base rates + const safeError = ensureError( + error, + 'HyperLiquidProvider.getFeeSchedule', + ); + if (safeError.message === PERPS_ERROR_CODES.PROVIDER_LIFECYCLE_STALE) { + throw safeError; + } + this.#deps.debugLogger.log( + 'Fee API Call Failed - Falling Back to Base Rates', + { + error: safeError.message, + errorType: safeError.name, + fallbackTakerRate: FEE_RATES.taker, + fallbackMakerRate: FEE_RATES.maker, + userAddress: 'unknown', + }, + ); + } + + this.#assertProviderLifecycleCurrent( + lifecycleGeneration, + 'Fee calculation', + ); + + // Protocol base fee (HyperLiquid's fee) + const protocolFeeRate = feeRate; + const protocolFeeAmount = + parsedAmount === undefined ? undefined : parsedAmount * protocolFeeRate; + + // The provider policy, not the client, owns whether this placement can + // carry a builder fee. The dedicated TWAP action has no builder field. + const { chargesMetamaskBuilderFee } = this.#resolveOrderFeePolicy(params); + const baseMetamaskFeeRate = chargesMetamaskBuilderFee + ? BUILDER_FEE_CONFIG.MaxFeeDecimal + : 0; + const metamaskFeeDiscount = + this.#userFeeDiscountBips === undefined + ? 0 + : this.#userFeeDiscountBips / BASIS_POINTS_DIVISOR; + const metamaskFeeRate = baseMetamaskFeeRate * (1 - metamaskFeeDiscount); + + if (chargesMetamaskBuilderFee && this.#userFeeDiscountBips !== undefined) { + this.#deps.debugLogger.log('HyperLiquid: Applied MetaMask fee discount', { + originalRate: baseMetamaskFeeRate, + discountBips: this.#userFeeDiscountBips, + discountPercentage: this.#userFeeDiscountBips / 100, + adjustedRate: metamaskFeeRate, + discountAmount: baseMetamaskFeeRate * metamaskFeeDiscount, + }); + } + + const metamaskFeeAmount = + parsedAmount === undefined ? undefined : parsedAmount * metamaskFeeRate; + + // Total fees + const totalFeeRate = protocolFeeRate + metamaskFeeRate; + const totalFeeAmount = + parsedAmount === undefined ? undefined : parsedAmount * totalFeeRate; + if ( + !Number.isFinite(protocolFeeRate) || + protocolFeeRate < 0 || + !Number.isFinite(metamaskFeeRate) || + metamaskFeeRate < 0 || + !Number.isFinite(totalFeeRate) || + totalFeeRate < 0 || + (protocolFeeAmount !== undefined && + !Number.isFinite(protocolFeeAmount)) || + (metamaskFeeAmount !== undefined && + !Number.isFinite(metamaskFeeAmount)) || + (totalFeeAmount !== undefined && !Number.isFinite(totalFeeAmount)) + ) { + throw new Error('Invalid fee calculation result'); + } + + const result = { + // Total fees + feeRate: totalFeeRate, + feeAmount: totalFeeAmount, + + // Protocol fees + protocolFeeRate, + protocolFeeAmount, + + // MetaMask fees + metamaskFeeRate, + metamaskFeeAmount, + }; + + this.#deps.debugLogger.log('Final Fee Calculation Result', { + orderType, + amount, + fees: { + protocolRate: `${(protocolFeeRate * 100).toFixed(4)}%`, + metamaskRate: `${(metamaskFeeRate * 100).toFixed(4)}%`, + totalRate: `${(totalFeeRate * 100).toFixed(4)}%`, + totalAmount: totalFeeAmount, + }, + usingFallbackRates: + protocolFeeRate === FEE_RATES.taker || + protocolFeeRate === FEE_RATES.maker, + }); + + return result; + } + + /** + * Check if the fee cache is valid for a user + * + * @param userAddress - The user's wallet address. + * @private + * @returns True if the condition is met. + */ + #isFeeCacheValid(userAddress: string): boolean { + const cached = this.#userFeeCache.get(userAddress); + if (!cached) { + return false; + } + return Date.now() - cached.timestamp < cached.ttl; + } + + /** + * Clear fee cache for a specific user or all users + * + * @param userAddress - Optional address to clear cache for + */ + public clearFeeCache(userAddress?: string): void { + if (userAddress) { + this.#userFeeCache.delete(userAddress); + this.#deps.debugLogger.log('Cleared fee cache for user', { userAddress }); + } else { + this.#userFeeCache.clear(); + this.#deps.debugLogger.log('Cleared all fee cache'); + } + } + + /** + * Escape hatch for agentic validation flows and test harnesses that drive + * HL mutations directly. NOT part of the PerpsProvider interface. + * Production code paths must go through the provider's own methods. + * + * @returns A promise resolving to the underlying HyperLiquid SDK + * ExchangeClient. Promise shape matches the existing agentic flows + * (hl-provision-fixture) that chain `.then` on the result. + */ + public async getExchangeClient(): Promise { + return this.#clientService.getExchangeClient(); + } + + /** + * Disconnect provider + * + * @returns A promise that resolves to the result. + */ + async disconnect(): Promise { + if (this.#disconnectOperationPromise) { + return this.#disconnectOperationPromise; + } + + const operation = this.#performDisconnect(); + this.#disconnectOperationPromise = operation; + try { + return await operation; + } finally { + if (this.#disconnectOperationPromise === operation) { + this.#disconnectOperationPromise = null; + } + } + } + + /** + * Perform one provider teardown shared by every concurrent caller. + * + * @returns A promise that resolves to the result. + */ + async #performDisconnect(): Promise { + this.#chasePlacementBlockers += 1; + this.#strategyGeneration += 1; + this.#disconnectOperationsInFlight += 1; + this.#isDisconnected = true; + this.#lifecycleGeneration += 1; + try { + this.#deps.debugLogger.log('HyperLiquid: Disconnecting provider', { + isTestnet: this.#clientService.isTestnetMode(), + timestamp: new Date().toISOString(), + }); + + // Clear subscriptions through subscription service + this.#subscriptionService.clearAll(); + + // Stop every chase loop: each holds a pending timer, and re-pricing after + // a disconnect would sign orders against a client that is being torn down. + // Orders already resting are deliberately left alone — disconnecting is + // not a request to cancel the user's positions or orders. + // Invalidate placements first, then drain every mutation before clearing + // the registry and tearing down the clients they use. + for (const [sessionId, session] of this.#chaseSessions.entries()) { + if (session.active) { + this.#stopChaseSession(sessionId); + } + } + await Promise.all([...this.#chasePlacementWaiters]); + await this.#chaseTickQueue; + await Promise.allSettled([...this.#chaseTerminations.values()]); + await Promise.all( + [...this.#chaseSessions.values()].map( + async (session) => await this.#rebalanceChaseSession(session), + ), + ); + this.#chaseSessions.clear(); + this.#chaseTerminations.clear(); + this.#terminatedChaseHandles.clear(); + this.#scaleOrderGroups.clear(); + this.#cancelledScaleOrderGroups.clear(); + + // Clear session caches. Capability and fee reads use the lifecycle + // generation above to discard any old response that resolves later. + this.clearFeeCache(); + this.#referralCheckCache.clear(); + this.#builderFeeCheckCache.clear(); + this.#subscriptionBuilderApprovalEpoch += 1; + this.#approvedBuilderAddresses.clear(); + this.#userFeeResolution = undefined; + this.#userFeeDiscountBips = undefined; + // UnifiedAccountCache stays global to avoid repeated signing requests. + this.#cachedMetaByDex.clear(); + this.#pendingValidatedDexsPromise = null; + this.#orderCapabilitiesMarketsByDex.clear(); + this.#orderCapabilitiesRefreshByDex.clear(); + this.#cachedSpotMeta = null; + this.#dexDiscoveryCache.reset(); + this.#dexDiscoveryComplete = false; + + // Await pending initialization before clearing to prevent the IIFE from + // setting clientsInitialized = true after disconnect completes + const pendingInit = this.#initializationPromise; + const pendingReady = this.#ensureReadyPromise; + const pendingTradingSetup = this.#tradingSetupPromise; + const pendingBuilderFeeSetups = [ + ...this.#builderFeeSetupPromises.values(), + ]; + + // Clear references first to prevent new callers from reusing + this.#initializationPromise = null; + this.#ensureReadyPromise = null; + this.#tradingSetupPromise = null; + this.#tradingSetupComplete = false; + this.#builderFeeSetupPromises.clear(); + this.#pendingBuilderFeeApprovals.clear(); + + // Wait for pending operations to complete (ignore errors) + // This prevents IIFEs from setting state after disconnect completes + if (pendingInit) { + try { + await pendingInit; + } catch { + // Ignore - we're disconnecting anyway + } + } + if (pendingReady) { + try { + await pendingReady; + } catch { + // Ignore - we're disconnecting anyway + } + } + + if (pendingTradingSetup) { + try { + await pendingTradingSetup; + } catch { + // Ignore - we're disconnecting anyway + } + } + + for (const pendingBuilderFeeSetup of pendingBuilderFeeSetups) { + try { + await pendingBuilderFeeSetup; + } catch { + // Ignore - we're disconnecting anyway + } + } + + // Reset client initialization flag so wallet adapter will be recreated with new account + // This fixes account synchronization issue where old account's address persists in wallet adapter + this.#clientsInitialized = false; + + // Disconnect client service + await this.#clientService.disconnect(); + + this.#deps.debugLogger.log('HyperLiquid: Provider fully disconnected', { + timestamp: new Date().toISOString(), + }); + + return { success: true }; + } catch (error) { + return createErrorResult(error, { success: false }); + } finally { + this.#chasePlacementBlockers -= 1; + this.#disconnectOperationsInFlight -= 1; + } + } + + /** + * Lightweight WebSocket health check using SDK's built-in ready() method + * Checks if WebSocket connection is open without making expensive API calls + * + * @param timeoutMs - Optional timeout in milliseconds (defaults to WEBSOCKET_PING_TIMEOUT_MS) + * @throws {Error} If WebSocket connection times out or fails + */ + async ping(timeoutMs?: number): Promise { + // Read-only operation: only need client initialization + await this.#ensureClientsInitialized(); + this.#clientService.ensureInitialized(); + + const subscriptionClient = this.#clientService.getSubscriptionClient(); + if (!subscriptionClient) { + throw new Error('Subscription client not initialized'); + } + + const timeout = timeoutMs ?? PERPS_CONSTANTS.WebsocketPingTimeoutMs; + + this.#deps.debugLogger.log( + `HyperLiquid: WebSocket health check ping starting (timeout: ${timeout}ms)`, + ); + + const controller = new AbortController(); + let didTimeout = false; + + const timeoutId = setTimeout(() => { + didTimeout = true; + controller.abort(); + }, timeout); + + try { + // Use SDK's built-in ready() method which checks socket.readyState === OPEN + // This is much more efficient than creating a subscription just for health check + await subscriptionClient.config_.transport.ready(controller.signal); + + this.#deps.debugLogger.log( + 'HyperLiquid: WebSocket health check ping succeeded', + ); + } catch (error) { + // Check if we timed out first + if (didTimeout) { + this.#deps.debugLogger.log( + `HyperLiquid: WebSocket health check ping timed out after ${timeout}ms`, + ); + throw new Error(PERPS_ERROR_CODES.CONNECTION_TIMEOUT); + } + + // Otherwise throw the actual error + this.#deps.debugLogger.log( + 'HyperLiquid: WebSocket health check ping failed', + error, + ); + throw ensureError(error, 'HyperLiquidProvider.ping'); + } finally { + clearTimeout(timeoutId); + } + } + + /** + * Get the current WebSocket connection state from the client service. + * Used by the UI to monitor connection health and show notifications. + * + * @returns The current WebSocket connection state + */ + getWebSocketConnectionState(): WebSocketConnectionState { + return this.#clientService.getConnectionState(); + } + + /** + * Subscribe to WebSocket connection state changes. + * The listener will be called immediately with the current state and whenever the state changes. + * + * @param listener - Callback function that receives the new connection state and reconnection attempt + * @returns Unsubscribe function to remove the listener + */ + subscribeToConnectionState( + listener: ( + state: WebSocketConnectionState, + reconnectionAttempt: number, + ) => void, + ): () => void { + return this.#clientService.subscribeToConnectionState(listener); + } + + /** + * Manually trigger a WebSocket reconnection attempt. + * Used by the UI retry button when connection is lost. + * + * @returns A promise that resolves when the operation completes. + */ + async reconnect(): Promise { + return this.#clientService.reconnect(); + } + + /** + * Get list of available HIP-3 builder-deployed DEXs + * + * @param _params - Optional parameters (reserved for future filters/pagination) + * @returns Array of DEX names (empty string '' represents main DEX) + */ + async getAvailableDexs(_params?: GetAvailableDexsParams): Promise { + try { + // Read-only operation: only need client initialization + await this.#ensureClientsInitialized(); + this.#clientService.ensureInitialized(); + + const infoClient = this.#clientService.getInfoClient(); + const dexs = await infoClient.perpDexs(); + + // Map DEX objects to names: null -> '' (main DEX), object -> object.name + return dexs.map((dex) => (dex === null ? '' : dex.name)); + } catch (error) { + this.#deps.logger.error( + ensureError(error, 'HyperLiquidProvider.getAvailableDexs'), + { + context: { + name: 'HyperLiquidProvider.getAvailableDexs', + data: { action: 'fetch_available_dexs' }, + }, + }, + ); + throw error; + } + } + + async fetchHistoricalCandles(options: { + symbol: string; + interval: CandlePeriod; + limit?: number; + endTime?: number; + }): Promise { + this.#clientService.ensureInitialized(); + const result = await this.#clientService.fetchHistoricalCandles(options); + return ( + result ?? { + symbol: options.symbol, + interval: options.interval, + candles: [], + } + ); + } + + /** + * Get block explorer URL for an address or just the base URL + * + * @param address - Optional address to append to the base URL + * @returns Block explorer URL + */ + getBlockExplorerUrl(address?: string): string { + const network = this.#clientService.isTestnetMode() ? 'testnet' : 'mainnet'; + const baseUrl = + network === 'testnet' + ? 'https://app.hyperliquid-testnet.xyz' + : 'https://app.hyperliquid.xyz'; + + if (address) { + return `${baseUrl}/explorer/address/${address}`; + } + + return `${baseUrl}/explorer`; + } + + #getBuilderAddress(isTestnet: boolean): string { + // || intentional: env vars default to '' which must fall through to the hardcoded default + if (isTestnet) { + // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing + return this.#builderAddressTestnet || BUILDER_FEE_CONFIG.TestnetBuilder; + } + // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing + return this.#builderAddressMainnet || BUILDER_FEE_CONFIG.MainnetBuilder; + } + + #getSubscriptionBuilderAddress(isTestnet: boolean): string | undefined { + return isTestnet + ? this.#subscriptionBuilderAddressTestnet + : this.#subscriptionBuilderAddressMainnet; + } + + #getReferralCode(isTestnet: boolean): string { + return isTestnet + ? REFERRAL_CONFIG.TestnetCode + : REFERRAL_CONFIG.MainnetCode; + } + + /** + * Ensure user has a MetaMask referral code set + * Called once during initialization (ensureReady) to set up referral for the session + * Uses GLOBAL cache to persist across provider reconnections + * This prevents repeated signing requests for hardware wallets. + * + * Note: This is network-specific - testnet and mainnet have separate referral states + * Note: Non-blocking - failures are logged to Sentry but don't prevent trading + */ + async #ensureReferralSet(): Promise { + const isTestnet = this.#clientService.isTestnetMode(); + const network = isTestnet ? 'testnet' : 'mainnet'; + const expectedReferralCode = this.#getReferralCode(isTestnet); + const referrerAddress = this.#getBuilderAddress(isTestnet); + + let userAddress: string; + try { + userAddress = await this.#walletService.getUserAddressWithDefault(); + } catch { + return; // Can't proceed without address + } + + if (userAddress.toLowerCase() === referrerAddress.toLowerCase()) { + this.#deps.debugLogger.log( + '[ensureReferralSet] User is builder, skipping', + { network }, + ); + return; + } + + // Skip the referral write for unfunded wallets — same proactive gate + // as `#ensureUnifiedAccountEnabled`. `exchangeClient.setReferrer` + // rejects with "User or API Wallet 0x... does not exist." for wallets + // that have not yet deposited. + const isRegistered = await this.#isWalletOnHyperliquid( + userAddress, + network, + ); + if (!isRegistered) { + this.#deps.debugLogger.log( + '[ensureReferralSet] Wallet not yet on Hyperliquid, deferring referral setup', + { network }, + ); + return; + } + + // Check GLOBAL cache first + const globalCached = PerpsSigningCache.getReferral(network, userAddress); + if (globalCached?.attempted) { + this.#deps.debugLogger.log( + '[ensureReferralSet] Using global cache (prevents hardware wallet prompt spam)', + { network, success: globalCached.success }, + ); + return; + } + + // Check if another provider is currently attempting this + const inFlightPromise = PerpsSigningCache.isInFlight( + 'referral', + network, + userAddress, + ); + if (inFlightPromise) { + this.#deps.debugLogger.log( + '[ensureReferralSet] Global in-flight, waiting...', + { network }, + ); + await inFlightPromise; + return; + } + + // Set global in-flight lock + const completeInFlight = PerpsSigningCache.setInFlight( + 'referral', + network, + userAddress, + ); + + try { + // Re-check cache after acquiring lock + const recheckCache = PerpsSigningCache.getReferral(network, userAddress); + if (recheckCache?.attempted) { + this.#deps.debugLogger.log( + '[ensureReferralSet] Completed by another provider', + { network }, + ); + completeInFlight(); + return; + } + + const isReady = await this.#isReferralCodeReady(); + if (!isReady) { + this.#deps.debugLogger.log( + '[ensureReferralSet] Builder referral not ready, skipping', + { network }, + ); + completeInFlight(); + return; // Don't cache - retry when ready + } + + // Check if user already has a referral on-chain + const hasReferral = await this.#checkReferralSet(); + + if (hasReferral) { + // Already has referral on-chain + PerpsSigningCache.setReferral(network, userAddress, { + attempted: true, + success: true, + }); + this.#deps.debugLogger.log( + '[ensureReferralSet] Already has referral on-chain', + { network }, + ); + } else { + this.#deps.debugLogger.log( + '[ensureReferralSet] Setting referral (will show signing request)', + { network, referralCode: expectedReferralCode }, + ); + const result = await this.#setReferralCode(); + if (result) { + this.#deps.debugLogger.log( + '[ensureReferralSet] Referral set successfully', + { network }, + ); + PerpsSigningCache.setReferral(network, userAddress, { + attempted: true, + success: true, + }); + } else { + PerpsSigningCache.setReferral(network, userAddress, { + attempted: true, + success: false, + }); + this.#deps.debugLogger.log( + '[ensureReferralSet] Failed, cached to prevent retries', + { network }, + ); + } + } + completeInFlight(); + } catch (error) { + // HyperLiquid wraps wallet signing failures and preserves KEYRING_LOCKED + // in `cause`, so classify the full chain and leave retry caches empty. + if (isKeyringLockedError(error)) { + this.#deps.debugLogger.log( + '[ensureReferralSet] Keyring locked, will retry later', + ); + completeInFlight(); + return; + } + + // Safety net: wallet looked registered but the SDK still rejects with + // "User or API Wallet 0x... does not exist." Do not forward to Sentry. + // The walletRegistered cache stores positive observations only, so no + // demotion is needed; the next entry will re-probe. + if (isHyperLiquidUserNotFoundError(error)) { + this.#deps.debugLogger.log( + '[ensureReferralSet] Wallet not on Hyperliquid (race/stale-cache), deferring referral', + { network, user: userAddress }, + ); + completeInFlight(); + return; + } + + // Cache failure to prevent retries + PerpsSigningCache.setReferral(network, userAddress, { + attempted: true, + success: false, + }); + this.#deps.debugLogger.log( + '[ensureReferralSet] Error, cached to prevent retries', + { + network, + error: ensureError(error, 'HyperLiquidProvider.ensureReferralSet') + .message, + }, + ); + completeInFlight(); + + // Non-blocking: Log to Sentry but don't throw + this.#deps.logger.error( + ensureError(error, 'HyperLiquidProvider.ensureReferralSet'), + this.#getErrorContext('ensureReferralSet', { + note: 'Referral setup failed (non-blocking), cached to prevent retries', + }), + ); + } + } + + /** + * Check if the referral code is ready to be used + * + * @returns Promise resolving to true if referral code is ready + */ + async #isReferralCodeReady(): Promise { + try { + const infoClient = this.#clientService.getInfoClient(); + const isTestnet = this.#clientService.isTestnetMode(); + const code = this.#getReferralCode(isTestnet); + const referrerAddr = this.#getBuilderAddress(isTestnet); + + const referral = await infoClient.referral({ user: referrerAddr }); + + const stage = referral.referrerState?.stage; + + if (stage === 'ready') { + const onFile = referral.referrerState?.data?.code || ''; + if (onFile.toUpperCase() !== code.toUpperCase()) { + throw new Error( + `Ready for referrals but there is a config code mismatch ${onFile} vs ${code}`, + ); + } + return true; + } + + // Not ready yet - log as debugLogger since this is expected during setup phase + this.#deps.debugLogger.log( + '[isReferralCodeReady] Referral code not ready', + { + stage, + code, + referrerAddr, + }, + ); + return false; + } catch (error) { + this.#deps.logger.error( + ensureError(error, 'HyperLiquidProvider.isReferralCodeReady'), + this.#getErrorContext('isReferralCodeReady', { + code: this.#getReferralCode(this.#clientService.isTestnetMode()), + referrerAddress: this.#getBuilderAddress( + this.#clientService.isTestnetMode(), + ), + }), + ); + return false; + } + } + + /** + * Check if user has a referral code set with HyperLiquid + * + * @returns Promise resolving to true if referral is set, false otherwise + */ + async #checkReferralSet(): Promise { + try { + const infoClient = this.#clientService.getInfoClient(); + const userAddress = await this.#walletService.getUserAddressWithDefault(); + + // Call HyperLiquid API to check if user has a referral set + const referralData = await infoClient.referral({ + user: userAddress, + }); + + this.#deps.debugLogger.log('Referral check result:', { + userAddress, + referralData, + }); + + return Boolean(referralData?.referredBy?.code); + } catch (error) { + // Benign for unfunded wallets — downgrade to debug log, do not Sentry. + if (isHyperLiquidUserNotFoundError(error)) { + this.#deps.debugLogger.log( + '[checkReferralSet] Wallet not on Hyperliquid yet, treating as no referral', + { + error: ensureError(error, 'HyperLiquidProvider.checkReferralSet') + .message, + }, + ); + return false; + } + this.#deps.logger.error( + ensureError(error, 'HyperLiquidProvider.checkReferralSet'), + this.#getErrorContext('checkReferralSet', { + note: 'Error checking referral status, will retry', + }), + ); + // do not throw here, return false as we can try to set it again + return false; + } + } + + /** + * Set MetaMask as the user's referrer on HyperLiquid + * + * @returns A promise that resolves to the boolean result. + */ + async #setReferralCode(): Promise { + try { + const exchangeClient = this.#clientService.getExchangeClient(); + const referralCode = this.#getReferralCode( + this.#clientService.isTestnetMode(), + ); + + this.#deps.debugLogger.log('[setReferralCode] Setting referral code', { + code: referralCode, + network: this.#clientService.isTestnetMode() ? 'testnet' : 'mainnet', + }); + + // set the referral code + const result = await exchangeClient.setReferrer({ + code: referralCode, + }); + + this.#deps.debugLogger.log( + '[setReferralCode] Referral code set result', + result, + ); + + return result?.status === 'ok'; + } catch (error) { + // Benign for unfunded wallets — downgrade and rethrow so the outer + // `#ensureReferralSet` catch self-heals the walletRegistered gate + // without forwarding to Sentry. + if (isHyperLiquidUserNotFoundError(error)) { + this.#deps.debugLogger.log( + '[setReferralCode] Wallet not on Hyperliquid yet, skipping referral write', + { + error: ensureError(error, 'HyperLiquidProvider.setReferralCode') + .message, + }, + ); + throw error; + } + this.#deps.logger.error( + ensureError(error, 'HyperLiquidProvider.setReferralCode'), + this.#getErrorContext('setReferralCode', { + code: this.#getReferralCode(this.#clientService.isTestnetMode()), + }), + ); + // Rethrow to be caught by retry logic in ensureReferralSet + throw error; + } + } +} diff --git a/packages/perps-controller/src/providers/MYXProvider.ts b/packages/perps-controller/src/providers/MYXProvider.ts new file mode 100644 index 00000000000..9314fd03161 --- /dev/null +++ b/packages/perps-controller/src/providers/MYXProvider.ts @@ -0,0 +1,1216 @@ +/** + * MYXProvider + * + * Provider implementation for MYX protocol. + * Implements the PerpsProvider interface with read-only and authenticated read operations. + * Trading write operations will be added in Phase 2. + * + * Key differences from HyperLiquid: + * - Uses USDT collateral on BNB chain (vs USDC on Arbitrum) + * - Multi-Pool Model: multiple pools can exist per symbol + * - Uses REST polling for prices (WebSocket deferred to Phase 4) + */ + +import type { CaipAccountId } from '@metamask/utils'; +import type { KlineResolution } from '@myx-trade/sdk'; + +import { calculateCandleCount } from '../constants/chartConfig.js'; +import { + MYX_MAX_LEVERAGE, + MYX_FEE_RATE, + MYX_PROTOCOL_FEE_RATE, +} from '../constants/myxConfig.js'; +import { PERPS_CONSTANTS } from '../constants/perpsConfig.js'; +import type { PerpsControllerMessenger } from '../PerpsController.js'; +import { MYXClientService } from '../services/MYXClientService.js'; +import { MYXWalletService } from '../services/MYXWalletService.js'; +import { WebSocketConnectionState } from '../types/index.js'; +import type { + AccountState, + AssetRoute, + BatchCancelOrdersParams, + CancelOrderParams, + CancelOrderResult, + CancelOrdersResult, + ClosePositionParams, + ClosePositionsParams, + ClosePositionsResult, + DepositParams, + DisconnectResult, + EditOrderParams, + FeeCalculationParams, + FeeCalculationResult, + Funding, + GetAccountStateParams, + GetFundingParams, + GetHistoricalPortfolioParams, + GetMarketsParams, + GetOrderFillsParams, + GetOrdersParams, + GetOrFetchFillsParams, + GetPositionsParams, + GetSupportedPathsParams, + HistoricalPortfolioResult, + InitializeResult, + LiquidationPriceParams, + LiveDataConfig, + MaintenanceMarginParams, + PositionModifyPreviewParams, + PositionModifyPreviewResult, + MarginResult, + MarketInfo, + Order, + OrderFill, + OrderParams, + OrderResult, + PerpsPlatformDependencies, + PerpsMarketData, + PerpsProvider, + Position, + PriceUpdate, + ReadyToTradeResult, + SubscribeAccountParams, + SubscribeCandlesParams, + SubscribeOICapsParams, + SubscribeOrderBookParams, + SubscribeOrderFillsParams, + SubscribeOrdersParams, + SubscribePositionsParams, + SubscribePricesParams, + ToggleTestnetResult, + UpdateMarginParams, + UpdatePositionTPSLParams, + UserHistoryItem, + WithdrawParams, + WithdrawResult, + RawLedgerUpdate, + PerpsReadOptions, +} from '../types/index.js'; +import { MYXOrderStatusEnum } from '../types/myx-types.js'; +import type { + MYXAuthConfig, + MYXKlineDataResponse, + MYXPoolSymbol, + MYXTicker, +} from '../types/myx-types.js'; +import type { CandleData } from '../types/perps-types.js'; +import { ensureError } from '../utils/errorUtils.js'; +import { + adaptMarketFromMYX, + adaptMarketDataFromMYX, + adaptPriceFromMYX, + adaptPositionFromMYX, + adaptOrderFromMYX, + adaptOrderFillFromMYX, + adaptAccountStateFromMYX, + adaptCandleFromMYX, + adaptCandleFromMYXWebSocket, + adaptFundingFromMYX, + adaptUserHistoryFromMYX, + filterMYXExclusiveMarkets, + buildPoolSymbolMap, + toMYXKlineResolution, +} from '../utils/myxAdapter.js'; + +// ============================================================================ +// Constants +// ============================================================================ + +const MYX_NOT_SUPPORTED_ERROR = 'MYX trading not yet supported'; +const MYX_BLOCK_EXPLORER_URL = 'https://bscscan.com'; +const MYX_TESTNET_EXPLORER_URL = 'https://sepolia.arbiscan.io'; + +// ============================================================================ +// MYXProvider +// ============================================================================ + +/** + * MYX provider implementation + * + * Authenticated read operations for positions, orders, account state. + * Trading write operations return errors until Phase 2. + */ +export class MYXProvider implements PerpsProvider { + readonly protocolId = 'myx'; + + // Platform dependencies + readonly #deps: PerpsPlatformDependencies; + + // Client service + readonly #clientService: MYXClientService; + + // Wallet service (requires messenger for signing) + #walletService: MYXWalletService | null = null; + + // Messenger for wallet operations + readonly #messenger: PerpsControllerMessenger | null; + + // Configuration + readonly #isTestnet: boolean; + + // Cache for pools (freshness delegated to MYXClientService) + #poolsCache: MYXPoolSymbol[] = []; + + #poolSymbolMap: Map = new Map(); + + // Ticker cache for price data + readonly #tickersCache: Map = new Map(); + + // Auth dedup promise + #authPromise: Promise | null = null; + + constructor(options: { + isTestnet?: boolean; + platformDependencies: PerpsPlatformDependencies; + messenger?: PerpsControllerMessenger; + myxAuthConfig?: MYXAuthConfig; + }) { + this.#deps = options.platformDependencies; + this.#isTestnet = options.isTestnet ?? true; + this.#messenger = options.messenger ?? null; + + // Initialize client service with auth config + this.#clientService = new MYXClientService(this.#deps, { + isTestnet: this.#isTestnet, + authConfig: options.myxAuthConfig, + }); + + this.#deps.debugLogger.log('[MYXProvider] Constructor complete', { + protocolId: this.protocolId, + isTestnet: this.#isTestnet, + hasMessenger: Boolean(this.#messenger), + }); + } + + // ============================================================================ + // Error Context Helper + // ============================================================================ + + #getErrorContext( + method: string, + extra?: Record, + ): { + tags?: Record; + context?: { name: string; data: Record }; + } { + return { + tags: { + feature: PERPS_CONSTANTS.FeatureName, + provider: 'MYXProvider', + network: this.#isTestnet ? 'testnet' : 'mainnet', + }, + context: { + name: `MYXProvider.${method}`, + data: { + isTestnet: this.#isTestnet, + ...extra, + }, + }, + }; + } + + // ============================================================================ + // Initialization & Lifecycle + // ============================================================================ + + async initialize(): Promise { + try { + this.#deps.debugLogger.log('[MYXProvider] Initializing...'); + + // Fetch initial markets + const pools = await this.#clientService.getMarkets(); + + // Filter to MYX-exclusive markets + this.#poolsCache = filterMYXExclusiveMarkets(pools); + this.#poolSymbolMap = buildPoolSymbolMap(this.#poolsCache); + + this.#deps.debugLogger.log('[MYXProvider] Initialized successfully', { + totalPools: pools.length, + exclusivePools: this.#poolsCache.length, + }); + + return { success: true }; + } catch (caughtError) { + const wrappedError = ensureError(caughtError, 'MYXProvider.initialize'); + this.#deps.logger.error( + wrappedError, + this.#getErrorContext('initialize'), + ); + return { success: false, error: wrappedError.message }; + } + } + + async disconnect(): Promise { + try { + this.#deps.debugLogger.log('[MYXProvider] Disconnecting...'); + + this.#clientService.disconnect(); + this.#poolsCache = []; + this.#poolSymbolMap.clear(); + this.#tickersCache.clear(); + + return { success: true }; + } catch (caughtError) { + const wrappedError = ensureError(caughtError, 'MYXProvider.disconnect'); + this.#deps.logger.error( + wrappedError, + this.#getErrorContext('disconnect'), + ); + return { success: false, error: wrappedError.message }; + } + } + + async ping(timeoutMs?: number): Promise { + await this.#clientService.ping(timeoutMs); + } + + async toggleTestnet(): Promise { + // Stage 1: Testnet only + return { + success: false, + isTestnet: this.#isTestnet, + error: 'MYX mainnet not yet available', + }; + } + + async isReadyToTrade(): Promise { + if (!this.#messenger) { + return { + ready: false, + error: 'MYX provider requires messenger for wallet operations', + walletConnected: false, + networkSupported: true, + }; + } + + try { + await this.#ensureAuthenticated(); + return { + ready: true, + walletConnected: true, + networkSupported: true, + authenticatedAddress: + this.#clientService.getAuthenticatedAddress() ?? undefined, + }; + } catch (caughtError) { + const wrappedError = ensureError( + caughtError, + 'MYXProvider.isReadyToTrade', + ); + return { + ready: false, + error: wrappedError.message, + walletConnected: false, + networkSupported: true, + }; + } + } + + /** + * Ensure the MYX client is authenticated. + * Lazy auth: creates signer + walletClient on first call, then calls clientService.authenticate(). + * Uses promise dedup to prevent concurrent auth attempts. + */ + async #ensureAuthenticated(): Promise { + // Always resolve the current address first so we can check per-address auth + const currentAddress = this.#getCurrentAddress(); + + if (this.#clientService.isAuthenticatedForAddress(currentAddress)) { + return; + } + + if (this.#authPromise) { + await this.#authPromise; + // Re-check: the in-flight auth may have been for a different address + if (this.#clientService.isAuthenticatedForAddress(currentAddress)) { + return; + } + // Otherwise fall through to start a new auth for the current address + } + + this.#authPromise = this.#doEnsureAuthenticated(); + try { + await this.#authPromise; + } finally { + this.#authPromise = null; + } + } + + /** + * Get the current user address, creating the wallet service if needed. + * + * @returns The current user wallet address. + */ + #getCurrentAddress(): string { + if (!this.#messenger) { + throw new Error( + 'MYX provider requires messenger for authenticated operations', + ); + } + + if (!this.#walletService) { + this.#walletService = new MYXWalletService(this.#deps, this.#messenger, { + isTestnet: this.#isTestnet, + }); + } + + return this.#walletService.getUserAddress(); + } + + async #doEnsureAuthenticated(): Promise { + if (!this.#messenger) { + throw new Error( + 'MYX provider requires messenger for authenticated operations', + ); + } + + // Create wallet service if not yet created + if (!this.#walletService) { + this.#walletService = new MYXWalletService(this.#deps, this.#messenger, { + isTestnet: this.#isTestnet, + }); + } + + const signer = this.#walletService.createEthersSigner(); + const walletClient = this.#walletService.createWalletClient(); + const address = this.#walletService.getUserAddress(); + + await this.#clientService.authenticate(signer, walletClient, address); + } + + /** + * Get the wallet service, throwing if not initialized. + * Call #ensureAuthenticated() before calling this. + * + * @returns The initialized MYXWalletService instance. + */ + #getWalletService(): MYXWalletService { + if (!this.#walletService) { + throw new Error('MYX wallet service not initialized'); + } + return this.#walletService; + } + + // ============================================================================ + // Market Data Operations (Stage 1 - Fully Implemented) + // ============================================================================ + + async getMarkets(_params?: GetMarketsParams): Promise { + try { + // Delegate cache freshness to MYXClientService + const pools = await this.#clientService.getMarkets(); + this.#poolsCache = filterMYXExclusiveMarkets(pools); + this.#poolSymbolMap = buildPoolSymbolMap(this.#poolsCache); + + return this.#poolsCache.map((pool) => adaptMarketFromMYX(pool)); + } catch (caughtError) { + const wrappedError = ensureError(caughtError, 'MYXProvider.getMarkets'); + this.#deps.logger.error( + wrappedError, + this.#getErrorContext('getMarkets'), + ); + return []; + } + } + + async getMarketDataWithPrices(): Promise { + try { + // Ensure we have markets + if (this.#poolsCache.length === 0) { + await this.getMarkets(); + } + + // Fetch tickers for all pools + const poolIds = this.#poolsCache.map((pool) => pool.poolId); + const tickers = await this.#clientService.getTickers(poolIds); + + // Build ticker map + const tickerMap = new Map(); + for (const ticker of tickers) { + tickerMap.set(ticker.poolId, ticker); + this.#tickersCache.set(ticker.poolId, ticker); + } + + // Transform to PerpsMarketData, only include pools with ticker data + return this.#poolsCache + .filter((pool) => tickerMap.has(pool.poolId)) + .map((pool) => + adaptMarketDataFromMYX( + pool, + tickerMap.get(pool.poolId), + this.#deps.marketDataFormatters, + ), + ); + } catch (caughtError) { + const wrappedError = ensureError( + caughtError, + 'MYXProvider.getMarketDataWithPrices', + ); + this.#deps.debugLogger.log( + '[MYXProvider] getMarketDataWithPrices failed', + { + error: String(wrappedError), + ...this.#getErrorContext('getMarketDataWithPrices'), + }, + ); + return []; + } + } + + // ============================================================================ + // Price Subscriptions (Stage 1 - REST Polling) + // ============================================================================ + + subscribeToPrices(params: SubscribePricesParams): () => void { + const { symbols, callback, includeOrderBook } = params; + + this.#deps.debugLogger.log('[MYXProvider] Setting up price subscription', { + symbols: symbols.length, + includeOrderBook, + }); + + // Map symbols to pool IDs + const poolIds: string[] = []; + for (const pool of this.#poolsCache) { + const symbol = pool.baseSymbol || pool.poolId; + if (symbols.includes(symbol)) { + poolIds.push(pool.poolId); + } + } + + if (poolIds.length === 0) { + this.#deps.debugLogger.log( + '[MYXProvider] subscribeToPrices: No pool IDs found. Ensure initialize() has been called.', + { symbols }, + ); + setTimeout(() => params.callback([]), 0); + return () => { + /* noop */ + }; + } + + // Start price polling + this.#clientService.startPricePolling(poolIds, (tickers) => { + // Convert tickers to PriceUpdate format + const updates: PriceUpdate[] = tickers.map((ticker) => { + const symbol = this.#poolSymbolMap.get(ticker.poolId) ?? ticker.poolId; + const { price, change24h } = this.#getAdaptedPrice(ticker); + + return { + symbol, + price, + timestamp: Date.now(), + percentChange24h: change24h.toFixed(2), + // MYX has no oracle-deviation tradability rule yet, so always report tradable. + isTradable: true, + providerId: 'myx', + }; + }); + + callback(updates); + }); + + // Return unsubscribe function + return () => { + this.#deps.debugLogger.log('[MYXProvider] Unsubscribing from prices'); + this.#clientService.stopPricePolling(); + }; + } + + #getAdaptedPrice(ticker: MYXTicker): { + price: string; + change24h: number; + } { + return adaptPriceFromMYX(ticker); + } + + // ============================================================================ + // Asset Routes (Stage 1 - Stubbed) + // ============================================================================ + + getDepositRoutes(_params?: GetSupportedPathsParams): AssetRoute[] { + // Stage 1: No deposit support + return []; + } + + getWithdrawalRoutes(_params?: GetSupportedPathsParams): AssetRoute[] { + // Stage 1: No withdrawal support + return []; + } + + // ============================================================================ + // Trading Operations (Stage 1 - All Stubbed) + // ============================================================================ + + async placeOrder(_params: OrderParams): Promise { + return { + success: false, + error: MYX_NOT_SUPPORTED_ERROR, + }; + } + + async editOrder(_params: EditOrderParams): Promise { + return { + success: false, + error: MYX_NOT_SUPPORTED_ERROR, + }; + } + + async cancelOrder(_params: CancelOrderParams): Promise { + return { + success: false, + error: MYX_NOT_SUPPORTED_ERROR, + }; + } + + async cancelOrders( + _params: BatchCancelOrdersParams, + ): Promise { + return { + success: false, + successCount: 0, + failureCount: 0, + results: [], + }; + } + + async closePosition(_params: ClosePositionParams): Promise { + return { + success: false, + error: MYX_NOT_SUPPORTED_ERROR, + }; + } + + async closePositions( + _params: ClosePositionsParams, + ): Promise { + return { + success: false, + successCount: 0, + failureCount: 0, + results: [], + }; + } + + async updatePositionTPSL( + _params: UpdatePositionTPSLParams, + ): Promise { + return { + success: false, + error: MYX_NOT_SUPPORTED_ERROR, + }; + } + + async updateMargin(_params: UpdateMarginParams): Promise { + return { + success: false, + error: MYX_NOT_SUPPORTED_ERROR, + }; + } + + async withdraw(_params: WithdrawParams): Promise { + return { + success: false, + error: MYX_NOT_SUPPORTED_ERROR, + }; + } + + // ============================================================================ + // Account Operations (Authenticated Reads) + // ============================================================================ + + async getPositions(_params?: GetPositionsParams): Promise { + try { + await this.#ensureAuthenticated(); + const address = this.#getWalletService().getUserAddress(); + const result = await this.#clientService.listPositions(address); + + if (!result.data || !Array.isArray(result.data)) { + return []; + } + + // Filter out zero-size positions + return result.data + .filter((pos) => pos.size && pos.size !== '0') + .map((pos) => adaptPositionFromMYX(pos, this.#poolSymbolMap)); + } catch (caughtError) { + const wrappedError = ensureError(caughtError, 'MYXProvider.getPositions'); + this.#deps.debugLogger.log('[MYXProvider] getPositions failed', { + error: String(wrappedError), + ...this.#getErrorContext('getPositions'), + }); + return []; + } + } + + async getAccountState( + _params?: GetAccountStateParams, + ): Promise { + try { + await this.#ensureAuthenticated(); + const address = this.#getWalletService().getUserAddress(); + const chainId = this.#clientService.getChainId(); + + // Fetch wallet balance + let walletBalance: string | undefined; + try { + const balanceResult = + await this.#clientService.getWalletQuoteTokenBalance( + chainId, + address, + ); + walletBalance = String(balanceResult.data ?? '0'); + } catch { + // Non-fatal: wallet balance is supplementary + walletBalance = '0'; + } + + // Try to get account info from first pool + let accountInfo: Record | undefined; + if (this.#poolsCache.length > 0) { + try { + const infoResult = await this.#clientService.getAccountInfo( + chainId, + address, + this.#poolsCache[0].poolId, + ); + accountInfo = infoResult.data; + } catch { + // Non-fatal: we'll return what we have + } + } + + return adaptAccountStateFromMYX(accountInfo, walletBalance); + } catch (caughtError) { + const wrappedError = ensureError( + caughtError, + 'MYXProvider.getAccountState', + ); + this.#deps.debugLogger.log('[MYXProvider] getAccountState failed', { + error: String(wrappedError), + ...this.#getErrorContext('getAccountState'), + }); + return { + spendableBalance: '0', + withdrawableBalance: '0', + totalBalance: '0', + marginUsed: '0', + unrealizedPnl: '0', + returnOnEquity: '0', + }; + } + } + + async getOrders( + _params?: GetOrdersParams, + _options?: PerpsReadOptions, + ): Promise { + try { + await this.#ensureAuthenticated(); + const address = this.#getWalletService().getUserAddress(); + const result = await this.#clientService.getOrderHistory( + { limit: 50 }, + address, + ); + + if (!result.data || !Array.isArray(result.data)) { + return []; + } + + return result.data.map((order) => + adaptOrderFromMYX(order, this.#poolSymbolMap), + ); + } catch (caughtError) { + const wrappedError = ensureError(caughtError, 'MYXProvider.getOrders'); + this.#deps.debugLogger.log('[MYXProvider] getOrders failed', { + error: String(wrappedError), + ...this.#getErrorContext('getOrders'), + }); + return []; + } + } + + async getOpenOrders(_params?: GetOrdersParams): Promise { + try { + const allOrders = await this.getOrders(); + return allOrders.filter((order) => order.status === 'open'); + } catch (caughtError) { + const wrappedError = ensureError( + caughtError, + 'MYXProvider.getOpenOrders', + ); + this.#deps.debugLogger.log('[MYXProvider] getOpenOrders failed', { + error: String(wrappedError), + ...this.#getErrorContext('getOpenOrders'), + }); + return []; + } + } + + async getOrderFills( + _params?: GetOrderFillsParams, + _options?: PerpsReadOptions, + ): Promise { + try { + await this.#ensureAuthenticated(); + const address = this.#getWalletService().getUserAddress(); + const result = await this.#clientService.getOrderHistory( + { limit: 50 }, + address, + ); + + if (!result.data || !Array.isArray(result.data)) { + return []; + } + + // Only return filled orders + return result.data + .filter((order) => order.orderStatus === MYXOrderStatusEnum.Successful) + .map((order) => adaptOrderFillFromMYX(order, this.#poolSymbolMap)); + } catch (caughtError) { + const wrappedError = ensureError( + caughtError, + 'MYXProvider.getOrderFills', + ); + this.#deps.debugLogger.log('[MYXProvider] getOrderFills failed', { + error: String(wrappedError), + ...this.#getErrorContext('getOrderFills'), + }); + return []; + } + } + + async getOrFetchFills(_params?: GetOrFetchFillsParams): Promise { + // No WS cache for MYX yet - always fetch via REST + return this.getOrderFills(_params); + } + + async getFunding( + _params?: GetFundingParams, + _options?: PerpsReadOptions, + ): Promise { + try { + await this.#ensureAuthenticated(); + const address = this.#getWalletService().getUserAddress(); + const result = await this.#clientService.getTradeFlow( + { limit: 50 }, + address, + ); + + if (!result.data || !Array.isArray(result.data)) { + return []; + } + + return adaptFundingFromMYX(result.data, this.#poolSymbolMap); + } catch (caughtError) { + const wrappedError = ensureError(caughtError, 'MYXProvider.getFunding'); + this.#deps.debugLogger.log('[MYXProvider] getFunding failed', { + error: String(wrappedError), + ...this.#getErrorContext('getFunding'), + }); + return []; + } + } + + async getHistoricalPortfolio( + _params?: GetHistoricalPortfolioParams, + ): Promise { + return { + accountValue1dAgo: '0', + timestamp: Date.now(), + }; + } + + async getUserNonFundingLedgerUpdates(_params?: { + accountId?: string; + startTime?: number; + endTime?: number; + }): Promise { + return []; + } + + /** + * Resolve the provider's currently active CAIP account identifier. + * Used by the MarketDataService REST coalesce layer so cached payloads + * are keyed by the actual resolved address rather than a shared + * "default" sentinel. + * + * @returns CAIP account id for the currently selected MYX account. + */ + async getCurrentAccountId(): Promise { + return this.#getWalletService().getCurrentAccountId(); + } + + async getUserHistory(_params?: { + accountId?: CaipAccountId; + startTime?: number; + endTime?: number; + }): Promise { + try { + await this.#ensureAuthenticated(); + const address = this.#getWalletService().getUserAddress(); + const result = await this.#clientService.getTradeFlow( + { limit: 50 }, + address, + ); + + if (!result.data || !Array.isArray(result.data)) { + return []; + } + + return adaptUserHistoryFromMYX(result.data); + } catch (caughtError) { + const wrappedError = ensureError( + caughtError, + 'MYXProvider.getUserHistory', + ); + this.#deps.debugLogger.log('[MYXProvider] getUserHistory failed', { + error: String(wrappedError), + ...this.#getErrorContext('getUserHistory'), + }); + return []; + } + } + + // ============================================================================ + // Validation Operations (Stage 1 - All Invalid) + // ============================================================================ + + async validateDeposit( + _params: DepositParams, + ): Promise<{ isValid: boolean; error?: string }> { + return { isValid: false, error: MYX_NOT_SUPPORTED_ERROR }; + } + + async validateOrder( + _params: OrderParams, + ): Promise<{ isValid: boolean; error?: string }> { + return { isValid: false, error: MYX_NOT_SUPPORTED_ERROR }; + } + + async validateClosePosition( + _params: ClosePositionParams, + ): Promise<{ isValid: boolean; error?: string }> { + return { isValid: false, error: MYX_NOT_SUPPORTED_ERROR }; + } + + async validateWithdrawal( + _params: WithdrawParams, + ): Promise<{ isValid: boolean; error?: string }> { + return { isValid: false, error: MYX_NOT_SUPPORTED_ERROR }; + } + + // ============================================================================ + // Protocol Calculations (Stage 1 - Default Values) + // ============================================================================ + + async calculateLiquidationPrice( + _params: LiquidationPriceParams, + ): Promise { + return '0'; + } + + async calculateMaintenanceMargin( + _params: MaintenanceMarginParams, + ): Promise { + return 0; + } + + async getMaxLeverage(_asset: string): Promise { + return MYX_MAX_LEVERAGE; + } + + async calculateFees( + _params: FeeCalculationParams, + ): Promise { + return { + feeRate: MYX_FEE_RATE, + protocolFeeRate: MYX_PROTOCOL_FEE_RATE, + }; + } + + async previewPositionModify( + _params: PositionModifyPreviewParams, + ): Promise { + return { status: 'unsupported', reason: 'provider' }; + } + + // ============================================================================ + // Subscriptions (Stage 1 - No-op) + // ============================================================================ + + subscribeToPositions(params: SubscribePositionsParams): () => void { + // Stage 1: No position tracking - immediately call back with empty array + // to signal loading is complete (no data to show) + setTimeout(() => params.callback([]), 0); + return () => { + /* noop */ + }; + } + + subscribeToOrderFills(params: SubscribeOrderFillsParams): () => void { + // Stage 1: No fill tracking - immediately call back with empty array + setTimeout(() => params.callback([]), 0); + return () => { + /* noop */ + }; + } + + subscribeToOrders(params: SubscribeOrdersParams): () => void { + // Stage 1: No order tracking - immediately call back with empty array + setTimeout(() => params.callback([]), 0); + return () => { + /* noop */ + }; + } + + subscribeToAccount(params: SubscribeAccountParams): () => void { + // Stage 1: Empty account state - immediately call back + setTimeout( + () => + params.callback({ + spendableBalance: '0', + withdrawableBalance: '0', + totalBalance: '0', + marginUsed: '0', + unrealizedPnl: '0', + returnOnEquity: '0', + }), + 0, + ); + return () => { + /* noop */ + }; + } + + subscribeToOICaps(params: SubscribeOICapsParams): () => void { + // Stage 1: No OI caps - immediately call back with empty array + // (matches HyperLiquid pattern which calls callback with cached data) + setTimeout(() => params.callback([]), 0); + return () => { + /* noop */ + }; + } + + subscribeToCandles(params: SubscribeCandlesParams): () => void { + const { symbol, interval, duration, callback, onError } = params; + let cancelled = false; + let wsCallback: ((data: MYXKlineDataResponse) => void) | null = null; + let globalId: number | null = null; + let currentCandleData: CandleData | null = null; + + // Map CandlePeriod to MYX KlineResolution + const myxInterval = toMYXKlineResolution(interval); + + // Resolve symbol → poolId (same pattern as subscribeToPrices) + const pool = this.#poolsCache.find( + (item) => (item.baseSymbol || item.poolId) === symbol, + ); + + if (!pool) { + this.#deps.debugLogger.log( + '[MYXProvider] subscribeToCandles: No pool found for symbol', + { symbol }, + ); + setTimeout(() => callback({ symbol, interval, candles: [] }), 0); + return () => { + cancelled = true; + }; + } + + // Calculate limit from duration + const limit = duration ? calculateCandleCount(duration, interval) : 100; + + this.#deps.debugLogger.log('[MYXProvider] subscribeToCandles', { + symbol, + interval, + myxInterval, + limit, + poolId: pool.poolId, + }); + + const initAndSubscribe = async (): Promise => { + // Phase 1: REST fetch historical candles + const klineData = await this.#clientService.getKlineData({ + poolId: pool.poolId, + interval: myxInterval as KlineResolution, + limit, + }); + + if (cancelled) { + return; + } + + currentCandleData = { + symbol, + interval, + candles: klineData.map(adaptCandleFromMYX), + }; + + this.#deps.debugLogger.log('[MYXProvider] Historical candles received', { + symbol, + count: currentCandleData.candles.length, + }); + + callback(currentCandleData); + + // Phase 2: WS live updates (independent — failure does NOT erase REST data) + try { + globalId = await this.#clientService.getGlobalId(pool.poolId); + + if (cancelled) { + return; + } + + wsCallback = (data: MYXKlineDataResponse): void => { + if (cancelled || !currentCandleData) { + return; + } + + const newCandle = adaptCandleFromMYXWebSocket(data.data); + const { candles } = currentCandleData; + const lastCandle = candles[candles.length - 1]; + + if (lastCandle?.time === newCandle.time) { + // Same timestamp: update existing candle (live tick) + currentCandleData = { + ...currentCandleData, + candles: [...candles.slice(0, -1), newCandle], + }; + } else { + // New timestamp: append new candle + currentCandleData = { + ...currentCandleData, + candles: [...candles, newCandle], + }; + } + + callback(currentCandleData); + }; + + this.#clientService.subscribeToKline( + globalId, + myxInterval as KlineResolution, + wsCallback, + ); + + this.#deps.debugLogger.log( + '[MYXProvider] WS kline subscription active', + { symbol, globalId }, + ); + } catch (wsError) { + this.#deps.debugLogger.log( + '[MYXProvider] WS kline failed, REST data preserved', + { symbol, error: String(wsError) }, + ); + } + }; + + initAndSubscribe().catch((error: unknown) => { + if (cancelled) { + return; + } + const wrappedError = ensureError(error, 'MYXProvider.subscribeToCandles'); + this.#deps.debugLogger.log('[MYXProvider] subscribeToCandles failed', { + error: String(wrappedError), + ...this.#getErrorContext('subscribeToCandles', { symbol, interval }), + }); + if (onError) { + onError(wrappedError); + } + // Emit empty candles so the UI isn't stuck loading. + // Use setTimeout to avoid promise/no-callback-in-promise lint rule. + setTimeout(() => callback({ symbol, interval, candles: [] }), 0); + }); + + return () => { + cancelled = true; + if (wsCallback && globalId !== null) { + this.#clientService.unsubscribeFromKline( + globalId, + myxInterval as KlineResolution, + wsCallback, + ); + } + }; + } + + subscribeToOrderBook(params: SubscribeOrderBookParams): () => void { + // Stage 1: No order book - immediately call back with empty data + setTimeout( + () => + params.callback({ + bids: [], + asks: [], + spread: '0', + spreadPercentage: '0', + midPrice: '0', + lastUpdated: Date.now(), + maxTotal: '0', + }), + 0, + ); + return () => { + /* noop */ + }; + } + + setLiveDataConfig(_config: Partial): void { + // Stage 1: No-op + } + + // ============================================================================ + // Connection State (Stage 1 - REST Only) + // ============================================================================ + + getWebSocketConnectionState(): WebSocketConnectionState { + // Stage 1: No WebSocket, report as connected (REST is always available) + return WebSocketConnectionState.Connected; + } + + subscribeToConnectionState( + _listener: ( + state: WebSocketConnectionState, + reconnectionAttempt: number, + ) => void, + ): () => void { + // Stage 1: No WebSocket, no connection state changes + return () => { + /* noop */ + }; + } + + async reconnect(): Promise { + // Stage 1: No WebSocket to reconnect + this.#deps.debugLogger.log('[MYXProvider] reconnect() is no-op in Stage 1'); + } + + // ============================================================================ + // Block Explorer + // ============================================================================ + + getBlockExplorerUrl(address?: string): string { + const baseUrl = this.#isTestnet + ? MYX_TESTNET_EXPLORER_URL + : MYX_BLOCK_EXPLORER_URL; + + return address ? `${baseUrl}/address/${address}` : baseUrl; + } + + // ============================================================================ + // Fee Discount (Stage 1 - No-op) + // ============================================================================ + + setUserFeeDiscount(_discountBips: number | undefined): void { + // Stage 1: No fee discount support + } + + // ============================================================================ + // HIP-3 Operations (N/A for MYX) + // ============================================================================ + + async getAvailableDexs(): Promise { + // MYX doesn't have HIP-3 equivalent + return []; + } +} diff --git a/packages/perps-controller/src/routing/ProviderRouter.ts b/packages/perps-controller/src/routing/ProviderRouter.ts new file mode 100644 index 00000000000..96e3d2e9c58 --- /dev/null +++ b/packages/perps-controller/src/routing/ProviderRouter.ts @@ -0,0 +1,173 @@ +/** + * ProviderRouter - Simple routing logic for multi-provider order routing + * + * Phase 1 implementation: Uses simple routing strategy where: + * - Explicit providerId always wins + * - Falls back to default provider otherwise + * + * Advanced routing strategies (best_price, user_preference per market, lowest_fee) + * are deferred to Phase 3. + */ + +import type { PerpsProviderType, RoutingStrategy } from '../types/index.js'; + +/** + * Parameters for selecting a provider for an operation + */ +export type RouterSelectParams = { + /** Asset identifier (e.g., 'BTC', 'ETH', 'xyz:TSLA') */ + symbol?: string; + /** Explicit provider override - if provided, always used */ + providerId?: PerpsProviderType; +}; + +/** + * ProviderRouter handles routing decisions for write operations + * in multi-provider scenarios. + * + * Phase 1 routing logic is simple: + * 1. If explicit providerId is passed, use it + * 2. Otherwise, use the default provider + * + * @example + * ```typescript + * const router = new ProviderRouter({ defaultProvider: 'hyperliquid' }); + * + * // With explicit provider + * router.selectProvider({ providerId: 'myx' }); // Returns 'myx' + * + * // Without explicit provider + * router.selectProvider({ symbol: 'BTC' }); // Returns 'hyperliquid' (default) + * ``` + */ +export class ProviderRouter { + /** Default provider to use when no explicit providerId is specified */ + #defaultProvider: PerpsProviderType; + + /** Current routing strategy (Phase 1: only 'default_provider' supported) */ + readonly #strategy: RoutingStrategy = 'default_provider'; + + /** Map of provider ID to the markets it supports */ + readonly #providerMarkets: Map> = new Map(); + + constructor(options: { + /** Default provider for operations without explicit providerId */ + defaultProvider: PerpsProviderType; + /** Routing strategy (Phase 1: only 'default_provider' supported) */ + strategy?: RoutingStrategy; + }) { + this.#defaultProvider = options.defaultProvider; + if (options.strategy) { + this.#strategy = options.strategy; + } + } + + /** + * Select the provider to use for an operation. + * + * Phase 1 logic: + * - Explicit providerId > defaultProvider + * + * @param params - Selection parameters + * @returns The provider ID to use + */ + selectProvider(params: RouterSelectParams): PerpsProviderType { + // Phase 1: explicit providerId always wins + if (params.providerId) { + return params.providerId; + } + + // Fall back to default provider + return this.#defaultProvider; + } + + /** + * Get all providers that support a specific market. + * + * @param symbol - Market symbol (e.g., 'BTC', 'ETH') + * @returns Array of provider IDs that support this market + */ + getProvidersForMarket(symbol: string): PerpsProviderType[] { + const providers: PerpsProviderType[] = []; + this.#providerMarkets.forEach((markets, providerId) => { + if (markets.has(symbol)) { + providers.push(providerId); + } + }); + return providers; + } + + /** + * Update the markets supported by a provider. + * Called during provider initialization or market refresh. + * + * @param providerId - Provider to update + * @param markets - Array of market symbols the provider supports + */ + updateProviderMarkets( + providerId: PerpsProviderType, + markets: string[], + ): void { + this.#providerMarkets.set(providerId, new Set(markets)); + } + + /** + * Clear markets for a provider (e.g., on disconnect). + * + * @param providerId - Provider to clear + */ + clearProviderMarkets(providerId: PerpsProviderType): void { + this.#providerMarkets.delete(providerId); + } + + /** + * Set the default provider for routing. + * + * @param providerId - New default provider + */ + setDefaultProvider(providerId: PerpsProviderType): void { + this.#defaultProvider = providerId; + } + + /** + * Get the current default provider. + * + * @returns Current default provider ID + */ + getDefaultProvider(): PerpsProviderType { + return this.#defaultProvider; + } + + /** + * Get the current routing strategy. + * + * @returns Current routing strategy + */ + getStrategy(): RoutingStrategy { + return this.#strategy; + } + + /** + * Check if a provider supports a specific market. + * + * @param providerId - Provider to check + * @param symbol - Market symbol + * @returns true if provider supports the market + */ + providerSupportsMarket( + providerId: PerpsProviderType, + symbol: string, + ): boolean { + const markets = this.#providerMarkets.get(providerId); + return markets?.has(symbol) ?? false; + } + + /** + * Get all registered provider IDs. + * + * @returns Array of all provider IDs with registered markets + */ + getRegisteredProviders(): PerpsProviderType[] { + return Array.from(this.#providerMarkets.keys()); + } +} diff --git a/packages/perps-controller/src/routing/index.ts b/packages/perps-controller/src/routing/index.ts new file mode 100644 index 00000000000..12b69c6d5ea --- /dev/null +++ b/packages/perps-controller/src/routing/index.ts @@ -0,0 +1,5 @@ +/** + * Provider routing module exports + */ +export { ProviderRouter } from './ProviderRouter.js'; +export type { RouterSelectParams } from './ProviderRouter.js'; diff --git a/packages/perps-controller/src/selectors.ts b/packages/perps-controller/src/selectors.ts new file mode 100644 index 00000000000..3677988fb1d --- /dev/null +++ b/packages/perps-controller/src/selectors.ts @@ -0,0 +1,365 @@ +import { createSelector } from 'reselect'; + +import { VISIBLE_CANDLE_COUNT_CONFIG } from './constants/chartConfig.js'; +import { + MARKET_SORTING_CONFIG, + PERPS_CONSTANTS, + SortOptionId, + DEFAULT_ORDER_BOOK_PREFERENCES, + DEFAULT_PRO_LAYOUT_PREFERENCES, + DEFAULT_PERPS_MODE, + DEFAULT_SELECTED_ORDER_TYPE, +} from './constants/perpsConfig.js'; +import type { + OrderBookPreferences, + PerpsMode, + ProLayoutPreferences, +} from './constants/perpsConfig.js'; +import type { PerpsControllerState } from './PerpsController.js'; +import type { + OrderDirection, + OrderType, + PerpsSelectedPaymentToken, + SortDirection, +} from './types/index.js'; + +/** + * Select whether the user is a first-time perps user + * + * @param state - PerpsController state + * @returns true if user is first-time, false otherwise + */ +export const selectIsFirstTimeUser = ( + state: PerpsControllerState | undefined, +): boolean => { + if (state?.isTestnet) { + return state?.isFirstTimeUser?.testnet ?? true; + } + return state?.isFirstTimeUser?.mainnet ?? true; +}; + +/** + * Select whether user has ever placed their first successful order + * + * @param state - PerpsController state + * @returns boolean indicating if first order was placed + */ +export const selectHasPlacedFirstOrder = ( + state: PerpsControllerState, +): boolean => { + if (state?.isTestnet) { + return state?.hasPlacedFirstOrder?.testnet ?? false; + } + return state?.hasPlacedFirstOrder?.mainnet ?? false; +}; + +/** + * Select watchlist markets for the current network + * + * @param state - PerpsController state + * @returns Array of watchlist market symbols for current network + */ +export const selectWatchlistMarkets = ( + state: PerpsControllerState, +): string[] => { + if (state?.isTestnet) { + return state?.watchlistMarkets?.testnet ?? []; + } + return state?.watchlistMarkets?.mainnet ?? []; +}; + +/** + * Check if a specific market is in the watchlist on the current network + * + * @param state - PerpsController state + * @param symbol - Market symbol to check (e.g., 'BTC', 'ETH') + * @returns boolean indicating if market is in watchlist + */ +export const selectIsWatchlistMarket = ( + state: PerpsControllerState, + symbol: string, +): boolean => { + const watchlist = selectWatchlistMarkets(state); + return watchlist.includes(symbol); +}; + +/** + * Select recently viewed markets for the current network. + * + * Returns up to PERPS_CONSTANTS.RecentlyViewedMarketsLimit symbols, ordered + * newest-first, filtered to entries within PERPS_CONSTANTS.RecentlyViewedMarketsTtlMs + * (24 hours). Returns an empty array when no qualifying entries exist. + * + * @param state - PerpsController state + * @returns Ordered array of recently viewed market symbols + */ +export const selectRecentlyViewedMarkets = ( + state: PerpsControllerState, +): string[] => { + const network = state?.isTestnet ? 'testnet' : 'mainnet'; + const entries = state?.recentlyViewedMarkets?.[network] ?? []; + const cutoff = Date.now() - PERPS_CONSTANTS.RecentlyViewedMarketsTtlMs; + + return entries + .filter((entry) => entry.viewedAt > cutoff) + .map((entry) => entry.symbol) + .slice(0, PERPS_CONSTANTS.RecentlyViewedMarketsLimit); +}; + +/** + * Select trade configuration for a specific market on the current network. + * Uses memoization to return stable object references and prevent unnecessary re-renders. + * + * Usage: selectTradeConfiguration(state, coin) + * + * @param state - The perps controller state. + * @param coin - The market coin symbol. + * @returns The trade configuration for the specified market, or undefined. + */ + +export const selectTradeConfiguration = createSelector( + [ + (state: PerpsControllerState): boolean | undefined => state?.isTestnet, + ( + state: PerpsControllerState, + _coin: string, + ): PerpsControllerState['tradeConfigurations'] | undefined => + state?.tradeConfigurations, + (_state: PerpsControllerState, coin: string): string => coin, + ], + (isTestnet, configs, coin): { leverage?: number } | undefined => { + const network = isTestnet ? 'testnet' : 'mainnet'; + const config = configs?.[network]?.[coin]; + + if (!config?.leverage) { + return undefined; + } + + return { leverage: config.leverage }; + }, +); + +/** + * Pending trade configuration as returned to consumers (timestamp stripped). + */ +type PendingTradeConfiguration = { + amount?: string; + leverage?: number; + takeProfitPrice?: string; + stopLossPrice?: string; + limitPrice?: string; + orderType?: OrderType; + reduceOnly?: boolean; + direction?: OrderDirection; + selectedPaymentToken?: PerpsSelectedPaymentToken | null; +}; + +/** + * Memoized extractor for the raw pending trade configuration of a market. + * + * Keyed on `isTestnet`, `tradeConfigurations`, and `coin` so it yields a stable + * object reference (both the stripped `config` and its `timestamp`) while those + * inputs are unchanged. The TTL is deliberately NOT evaluated here: because the + * result is memoized, evaluating expiry inside this selector would freeze the + * `Date.now()` check between input changes. Expiry is applied per-call by + * `selectPendingTradeConfiguration` instead. + * + * @param state - The perps controller state. + * @param coin - The market coin symbol. + * @returns The stripped config and its save timestamp, or undefined. + */ + +const selectRawPendingTradeConfiguration = createSelector( + [ + (state: PerpsControllerState): boolean | undefined => state?.isTestnet, + ( + state: PerpsControllerState, + _coin: string, + ): PerpsControllerState['tradeConfigurations'] | undefined => + state?.tradeConfigurations, + (_state: PerpsControllerState, coin: string): string => coin, + ], + ( + isTestnet, + configs, + coin, + ): { timestamp: number; config: PendingTradeConfiguration } | undefined => { + const network = isTestnet ? 'testnet' : 'mainnet'; + const config = configs?.[network]?.[coin]?.pendingConfig; + + if (!config) { + return undefined; + } + + const { timestamp, ...configWithoutTimestamp } = config; + return { timestamp, config: configWithoutTimestamp }; + }, +); + +/** + * Select pending trade configuration for a specific market on the current network. + * Returns undefined if config doesn't exist or has expired. + * + * The underlying data extraction is memoized for stable object references, but + * the TTL is checked on every call (using `Date.now()`) so expiry stays accurate + * even when the memoized inputs have not changed. This mirrors + * `PerpsController.getPendingTradeConfiguration`, which also evaluates time on + * every call. + * + * Usage: selectPendingTradeConfiguration(state, coin) + * + * @param state - The perps controller state. + * @param coin - The market coin symbol. + * @returns The pending trade configuration, or undefined if expired or not found. + */ +export const selectPendingTradeConfiguration = ( + state: PerpsControllerState, + coin: string, +): PendingTradeConfiguration | undefined => { + const raw = selectRawPendingTradeConfiguration(state, coin); + + if (!raw) { + return undefined; + } + + const age = Date.now() - raw.timestamp; + + if (age > PERPS_CONSTANTS.PendingTradeConfigurationTtlMs) { + // Config expired, return undefined + return undefined; + } + + return raw.config; +}; + +/** + * Select market filter preferences (network-independent) + * + * @param state - PerpsController state + * @returns Sort/filter preferences object with optionId and direction + */ +export const selectMarketFilterPreferences = ( + state: PerpsControllerState, +): { optionId: SortOptionId; direction: SortDirection } => { + const pref = state?.marketFilterPreferences; + + // Handle legacy string format (backward compatibility) + if (typeof pref === 'string') { + // Map legacy compound IDs to new format + // Old format: 'priceChange-desc' or 'priceChange-asc' + // New format: { optionId: 'priceChange', direction: 'desc'/'asc' } + if (pref === 'priceChange-desc') { + return { + optionId: 'priceChange', + direction: 'desc', + }; + } + if (pref === 'priceChange-asc') { + return { + optionId: 'priceChange', + direction: 'asc', + }; + } + + // Handle other simple legacy strings (e.g., 'volume', 'openInterest', etc.) + return { + optionId: pref as SortOptionId, + direction: MARKET_SORTING_CONFIG.DefaultDirection, + }; + } + + // Return new object format or default + return ( + pref ?? { + optionId: MARKET_SORTING_CONFIG.DefaultSortOptionId, + direction: MARKET_SORTING_CONFIG.DefaultDirection, + } + ); +}; + +/** + * Select pro-mode layout preferences (network-independent). + * + * Merges over defaults so callers always receive a fully-populated object, + * even when the state slice (or a nested field) is missing. + * + * @param state - PerpsController state + * @returns The pro-mode layout preferences object + */ +export const selectProLayoutPreferences = ( + state: PerpsControllerState, +): ProLayoutPreferences => ({ + ...DEFAULT_PRO_LAYOUT_PREFERENCES, + ...state?.proLayoutPreferences, +}); + +/** + * Select market-agnostic Pro order-book display preferences. + * + * @param state - PerpsController state + * @returns The order-book display preferences + */ +export const selectOrderBookPreferences = ( + state: PerpsControllerState, +): OrderBookPreferences => ({ + ...DEFAULT_ORDER_BOOK_PREFERENCES, + ...state?.orderBookPreferences, +}); + +/** + * Select the market-agnostic order type. + * + * @param state - PerpsController state + * @returns The selected order type + */ +export const selectSelectedOrderType = ( + state: PerpsControllerState, +): OrderType => state?.selectedOrderType ?? DEFAULT_SELECTED_ORDER_TYPE; + +/** + * Select the visible candle count shared by Lite and Pro. + * + * @param state - PerpsController state + * @returns The visible candle count + */ +export const selectVisibleCandleCount = (state: PerpsControllerState): number => + Number.isFinite(state?.visibleCandleCount) + ? state.visibleCandleCount + : VISIBLE_CANDLE_COUNT_CONFIG.Default; + +/** + * Select the current Perps interface mode (lite/pro). + * + * Falls back to the default mode when the state slice is missing. + * + * @param state - PerpsController state + * @returns The current Perps mode + */ +export const selectPerpsMode = (state: PerpsControllerState): PerpsMode => + state?.mode ?? DEFAULT_PERPS_MODE; + +/** + * Select order book grouping for a specific market on the current network. + * + * Usage: selectOrderBookGrouping(state, coin) + * + * @param state - The perps controller state. + * @param coin - The market coin symbol. + * @returns The order book grouping value, or undefined. + */ + +export const selectOrderBookGrouping = createSelector( + [ + (state: PerpsControllerState): boolean | undefined => state?.isTestnet, + ( + state: PerpsControllerState, + _coin: string, + ): PerpsControllerState['tradeConfigurations'] | undefined => + state?.tradeConfigurations, + (_state: PerpsControllerState, coin: string): string => coin, + ], + (isTestnet, configs, coin): number | undefined => { + const network = isTestnet ? 'testnet' : 'mainnet'; + return configs?.[network]?.[coin]?.orderBookGrouping; + }, +); diff --git a/packages/perps-controller/src/services/AccountService.ts b/packages/perps-controller/src/services/AccountService.ts new file mode 100644 index 00000000000..906a7780de9 --- /dev/null +++ b/packages/perps-controller/src/services/AccountService.ts @@ -0,0 +1,417 @@ +import { v4 as uuidv4 } from 'uuid'; + +import { + PERPS_EVENT_PROPERTY, + PERPS_EVENT_VALUE, +} from '../constants/eventNames.js'; +import { USDC_SYMBOL } from '../constants/hyperLiquidConfig.js'; +import { + PERPS_CONSTANTS, + WITHDRAWAL_CONSTANTS, +} from '../constants/perpsConfig.js'; +import { PERPS_ERROR_CODES } from '../perpsErrorCodes.js'; +import { + PerpsAnalyticsEvent, + PerpsTraceNames, + PerpsTraceOperations, +} from '../types/index.js'; +import type { + PerpsProvider, + WithdrawParams, + WithdrawResult, + PerpsPlatformDependencies, +} from '../types/index.js'; +import type { PerpsControllerMessengerBase } from '../types/messenger.js'; +import type { TransactionStatus } from '../types/transactionTypes.js'; +import { getSelectedEvmAccountFromMessenger } from '../utils/accountUtils.js'; +import { ensureError } from '../utils/errorUtils.js'; +import type { ServiceContext } from './ServiceContext.js'; + +/** + * AccountService + * + * Handles account operations (deposits, withdrawals). + * Stateless service that delegates to provider. + * Controller handles state updates and analytics. + * + * Instance-based service with constructor injection of platform dependencies + * and messenger for inter-controller communication. + */ +export class AccountService { + readonly #deps: PerpsPlatformDependencies; + + readonly #messenger: PerpsControllerMessengerBase; + + /** + * Create a new AccountService instance + * + * @param deps - Platform dependencies for logging, metrics, etc. + * @param messenger - Controller messenger for cross-controller communication. + */ + constructor( + deps: PerpsPlatformDependencies, + messenger: PerpsControllerMessengerBase, + ) { + this.#deps = deps; + this.#messenger = messenger; + } + + /** + * Withdraw funds with full orchestration + * Handles tracing, state management, analytics, and account refresh + * + * @param options - The withdrawal configuration. + * @param options.provider - The perps provider to execute the withdrawal. + * @param options.params - The withdrawal parameters (amount, destination, etc.). + * @param options.context - The service context for tracing and dependencies. + * @param options.refreshAccountState - Callback to refresh account state after withdrawal. + * @returns The withdrawal result containing success status and transaction details. + */ + async withdraw(options: { + provider: PerpsProvider; + params: WithdrawParams; + context: ServiceContext; + refreshAccountState: () => Promise; + }): Promise { + const { provider, params, context, refreshAccountState } = options; + + const traceId = uuidv4(); + const startTime = this.#deps.performance.now(); + let traceData: + | { + success: boolean; + error?: string; + txHash?: string; + withdrawalId?: string; + } + | undefined; + + // Generate withdrawal request ID for tracking + const currentWithdrawalId = `withdraw-${Date.now()}-${Math.random() + .toString(36) + .substring(2, 11)}`; + + try { + this.#deps.tracer.trace({ + name: PerpsTraceNames.Withdraw, + id: traceId, + op: PerpsTraceOperations.Operation, + tags: { + assetId: params.assetId ?? '', + provider: context.tracingContext.provider, + isTestnet: String(context.tracingContext.isTestnet), + }, + }); + + this.#deps.debugLogger.log('AccountService: STARTING WITHDRAWAL', { + params, + timestamp: new Date().toISOString(), + assetId: params.assetId, + amount: params.amount, + destination: params.destination, + activeProvider: context.tracingContext.provider, + isTestnet: context.tracingContext.isTestnet, + }); + + // Set withdrawal in progress + if (context.stateManager) { + context.stateManager.update((state) => { + state.withdrawInProgress = true; + + // Calculate net amount after fees + const grossAmount = parseFloat(params.amount); + const feeAmount = WITHDRAWAL_CONSTANTS.DefaultFeeAmount; + const netAmount = Math.max(0, grossAmount - feeAmount); + + // Get current account address via messenger + const evmAccount = getSelectedEvmAccountFromMessenger( + this.#messenger, + ); + const accountAddress = evmAccount?.address ?? 'unknown'; + + this.#deps.debugLogger.log( + 'AccountService: Creating withdrawal request', + { + accountAddress, + hasEvmAccount: Boolean(evmAccount), + evmAccountAddress: evmAccount?.address, + amount: netAmount.toString(), + }, + ); + + // Add withdrawal request to tracking + const withdrawalRequest = { + id: currentWithdrawalId, + timestamp: Date.now(), + amount: netAmount.toString(), // Use net amount (after fees) + asset: USDC_SYMBOL, + accountAddress, // Track which account initiated withdrawal + success: false, // Will be updated when transaction completes + txHash: undefined, + status: 'pending' as TransactionStatus, + destination: params.destination, + transactionId: undefined, // Will be set to withdrawalId when available + }; + + state.withdrawalRequests.unshift(withdrawalRequest); + }); + } + + this.#deps.debugLogger.log('AccountService: DELEGATING TO PROVIDER', { + provider: context.tracingContext.provider, + providerReady: Boolean(provider), + }); + + // Execute withdrawal + const result = await provider.withdraw(params); + + this.#deps.debugLogger.log('AccountService: WITHDRAWAL RESULT', { + success: result.success, + error: result.error, + txHash: result.txHash, + timestamp: new Date().toISOString(), + }); + + // Update state based on result + if (result.success) { + if (context.stateManager) { + context.stateManager.update((state) => { + state.lastError = null; + state.lastUpdateTimestamp = Date.now(); + + const withdrawalRequestIndex = state.withdrawalRequests.findIndex( + (req) => req.id === currentWithdrawalId, + ); + + if (result.txHash) { + // Direct completion: remove from queue and record the txHash + // so the polling hook won't re-match this completion. + // We do NOT update lastCompletedWithdrawalTimestamp here + // because Date.now() is local device time while the FIFO guard + // compares against API server timestamps — mixing domains can + // poison the guard. The txHash exclusion alone prevents + // re-matching since the item is also spliced from the queue. + if (withdrawalRequestIndex !== -1) { + state.withdrawalRequests.splice(withdrawalRequestIndex, 1); + } + + state.lastCompletedWithdrawalTxHashes.push(result.txHash); + + const hasOtherPending = state.withdrawalRequests.some( + (req) => req.status === 'pending' || req.status === 'bridging', + ); + state.withdrawInProgress = hasOtherPending; + } else if (withdrawalRequestIndex !== -1) { + const requestToUpdate = + state.withdrawalRequests[withdrawalRequestIndex]; + // Withdrawal is bridging (no txHash yet) + requestToUpdate.status = 'bridging' as TransactionStatus; + requestToUpdate.success = true; + if (result.withdrawalId) { + requestToUpdate.withdrawalId = result.withdrawalId; + } + } + + // Set lastWithdrawResult when submission is successful (even if bridging) + // This triggers the "confirmed" toast telling user funds arrive in ~5 mins + state.lastWithdrawResult = { + success: true, + txHash: result.txHash ?? '', + amount: params.amount, + asset: USDC_SYMBOL, + timestamp: Date.now(), + error: '', + }; + }); + } + + this.#deps.debugLogger.log('AccountService: WITHDRAWAL SUCCESSFUL', { + txHash: result.txHash, + amount: params.amount, + assetId: params.assetId, + withdrawalId: result.withdrawalId, + }); + + // Track withdrawal transaction executed + const completionDuration = this.#deps.performance.now() - startTime; + this.#deps.metrics.trackPerpsEvent( + PerpsAnalyticsEvent.WithdrawalTransaction, + { + [PERPS_EVENT_PROPERTY.STATUS]: PERPS_EVENT_VALUE.STATUS.EXECUTED, + [PERPS_EVENT_PROPERTY.WITHDRAWAL_AMOUNT]: parseFloat(params.amount), + [PERPS_EVENT_PROPERTY.COMPLETION_DURATION]: completionDuration, + }, + ); + + // Trigger account state refresh after withdrawal + refreshAccountState().catch((refreshError) => { + this.#deps.logger.error( + ensureError(refreshError, 'AccountService.withdraw'), + { + tags: { feature: PERPS_CONSTANTS.FeatureName }, + context: { + name: 'AccountService.withdraw', + data: { operation: 'refreshAccountState' }, + }, + }, + ); + }); + + // Invalidate standalone caches so external hooks (e.g., usePerpsPositionForAsset) refresh + this.#deps.cacheInvalidator.invalidate({ cacheType: 'accountState' }); + + traceData = { + success: true, + txHash: result.txHash ?? '', + withdrawalId: result.withdrawalId ?? '', + }; + + return result; + } + + // Handle failure + if (context.stateManager) { + context.stateManager.update((state) => { + state.lastError = result.error ?? PERPS_ERROR_CODES.WITHDRAW_FAILED; + state.lastUpdateTimestamp = Date.now(); + state.lastWithdrawResult = { + success: false, + error: result.error ?? PERPS_ERROR_CODES.WITHDRAW_FAILED, + amount: params.amount, + asset: USDC_SYMBOL, + timestamp: Date.now(), + txHash: '', + }; + + const withdrawalRequestIndex = state.withdrawalRequests.findIndex( + (req) => req.id === currentWithdrawalId, + ); + if (withdrawalRequestIndex !== -1) { + state.withdrawalRequests.splice(withdrawalRequestIndex, 1); + } + state.withdrawInProgress = state.withdrawalRequests.some( + (req) => req.status === 'pending' || req.status === 'bridging', + ); + }); + } + + this.#deps.debugLogger.log('AccountService: WITHDRAWAL FAILED', { + error: result.error, + params, + }); + + // Track withdrawal transaction failed + const completionDuration = this.#deps.performance.now() - startTime; + this.#deps.metrics.trackPerpsEvent( + PerpsAnalyticsEvent.WithdrawalTransaction, + { + [PERPS_EVENT_PROPERTY.STATUS]: PERPS_EVENT_VALUE.STATUS.FAILED, + [PERPS_EVENT_PROPERTY.WITHDRAWAL_AMOUNT]: parseFloat(params.amount), + [PERPS_EVENT_PROPERTY.COMPLETION_DURATION]: completionDuration, + [PERPS_EVENT_PROPERTY.ERROR_MESSAGE]: result.error ?? 'Unknown error', + }, + ); + + traceData = { + success: false, + error: result.error ?? 'Unknown error', + }; + + return result; + } catch (error) { + const errorMessage = + error instanceof Error + ? error.message + : PERPS_ERROR_CODES.WITHDRAW_FAILED; + + this.#deps.logger.error(ensureError(error, 'AccountService.withdraw'), { + tags: { feature: PERPS_CONSTANTS.FeatureName }, + context: { + name: 'AccountService.withdraw', + data: { assetId: params.assetId, amount: params.amount }, + }, + }); + + if (context.stateManager) { + context.stateManager.update((state) => { + state.lastError = errorMessage; + state.lastUpdateTimestamp = Date.now(); + state.lastWithdrawResult = { + success: false, + error: errorMessage, + amount: '0', + asset: USDC_SYMBOL, + timestamp: Date.now(), + txHash: '', + }; + + const withdrawalRequestIndex = state.withdrawalRequests.findIndex( + (req) => req.id === currentWithdrawalId, + ); + if (withdrawalRequestIndex !== -1) { + state.withdrawalRequests.splice(withdrawalRequestIndex, 1); + } + state.withdrawInProgress = state.withdrawalRequests.some( + (req) => req.status === 'pending' || req.status === 'bridging', + ); + }); + } + + // Track withdrawal transaction failed (catch block) + const completionDuration = this.#deps.performance.now() - startTime; + this.#deps.metrics.trackPerpsEvent( + PerpsAnalyticsEvent.WithdrawalTransaction, + { + [PERPS_EVENT_PROPERTY.STATUS]: PERPS_EVENT_VALUE.STATUS.FAILED, + [PERPS_EVENT_PROPERTY.WITHDRAWAL_AMOUNT]: params.amount, + [PERPS_EVENT_PROPERTY.COMPLETION_DURATION]: completionDuration, + [PERPS_EVENT_PROPERTY.ERROR_MESSAGE]: errorMessage, + }, + ); + + traceData = { + success: false, + error: errorMessage, + }; + + return { success: false, error: errorMessage }; + } finally { + this.#deps.tracer.endTrace({ + name: PerpsTraceNames.Withdraw, + id: traceId, + data: traceData, + }); + } + } + + /** + * Validate withdrawal parameters + * + * @param options - The validation configuration. + * @param options.provider - The perps provider to validate against. + * @param options.params - The withdrawal parameters to validate. + * @returns An object indicating whether the withdrawal is valid, with an optional error message. + */ + async validateWithdrawal(options: { + provider: PerpsProvider; + params: WithdrawParams; + }): Promise<{ isValid: boolean; error?: string }> { + const { provider, params } = options; + + try { + return await provider.validateWithdrawal(params); + } catch (error) { + this.#deps.logger.error( + ensureError(error, 'AccountService.validateWithdrawal'), + { + tags: { feature: PERPS_CONSTANTS.FeatureName }, + context: { + name: 'AccountService.validateWithdrawal', + data: { params }, + }, + }, + ); + throw error; + } + } +} diff --git a/packages/perps-controller/src/services/AggregatedOrderBookConnection.ts b/packages/perps-controller/src/services/AggregatedOrderBookConnection.ts new file mode 100644 index 00000000000..c4bce709ff8 --- /dev/null +++ b/packages/perps-controller/src/services/AggregatedOrderBookConnection.ts @@ -0,0 +1,491 @@ +import { SubscriptionClient, WebSocketTransport } from '@nktkas/hyperliquid'; +import type { ISubscription } from '@nktkas/hyperliquid'; + +import { HYPERLIQUID_TRANSPORT_CONFIG } from '../constants/hyperLiquidConfig.js'; +import type { OrderBookData } from '../types/index.js'; + +/** + * A single L2 book price level as delivered by Hyperliquid's `l2Book` + * subscription. Declared locally to avoid coupling to the SDK's exported type + * names (and to keep this the only file that references the SDK's shapes). + */ +type HyperliquidL2BookLevel = { + /** Price. */ + px: string; + /** Total size resting at this price. */ + sz: string; + /** Number of individual orders. */ + n: number; +}; + +/** `l2Book` snapshot event (index 0 = bids, index 1 = asks). */ +type HyperliquidL2BookEvent = { + coin: string; + time: number; + levels: [bids: HyperliquidL2BookLevel[], asks: HyperliquidL2BookLevel[]]; + spread?: string; +}; + +/** + * Health of the dedicated order-book socket, surfaced to the UI so the panel + * can show a reconnect affordance. + * + * - `connecting`: socket opening or reconnecting after a transient drop. + * - `connected`: subscription is live. + * - `error`: dropped and automatic reconnection was exhausted; needs a manual reconnect. + */ +export type OrderBookConnectionStatus = 'connecting' | 'connected' | 'error'; + +export type SubscribeAggregatedOrderBookParams = { + /** Market symbol (e.g. 'BTC'). */ + symbol: string; + /** Number of levels per side to keep. */ + levels?: number; + /** + * Server-side aggregation significant figures. Required: omitting it would + * request the raw, full-precision book instead of an aggregated one, which + * contradicts this service's contract. + */ + nSigFigs: 2 | 3 | 4 | 5; + /** Mantissa refinement when `nSigFigs` is 5. */ + mantissa?: 2 | 5; + /** Invoked with each processed snapshot. */ + callback: (data: OrderBookData) => void; + /** Invoked when the underlying socket's health changes. */ + onStatusChange?: (status: OrderBookConnectionStatus) => void; +}; + +export type AggregatedOrderBookConnectionOptions = { + /** Resolves the current network at subscribe time. */ + isTestnet: () => boolean; +}; + +// Fast mode streams 5 levels per side (slow mode streams 20). We run fast mode +// for lower-latency ladder updates, so the book never carries more than this. +const DEFAULT_LEVELS = 5; + +/** + * Transforms a raw Hyperliquid `l2Book` snapshot into the `OrderBookData` shape + * the UI consumes. Mirrors the subscription service's internal + * `processOrderBookData` so this dedicated connection is a drop-in replacement + * for `subscribeToOrderBook` on the aggregated channel. + * + * @param data - Raw `l2Book` event. + * @param levels - Number of levels per side to keep. + * @returns Processed order-book snapshot. + */ +export function processAggregatedOrderBook( + data: HyperliquidL2BookEvent, + levels: number, +): OrderBookData { + const bidsRaw = data?.levels?.[0] ?? []; + const asksRaw = data?.levels?.[1] ?? []; + + let bidCumulativeSize = 0; + let bidCumulativeNotional = 0; + const bids = bidsRaw.slice(0, levels).map((level) => { + const price = Number.parseFloat(level.px); + const size = Number.parseFloat(level.sz); + const notional = price * size; + bidCumulativeSize += size; + bidCumulativeNotional += notional; + return { + price: level.px, + size: level.sz, + total: bidCumulativeSize.toString(), + notional: notional.toFixed(2), + totalNotional: bidCumulativeNotional.toFixed(2), + }; + }); + + let askCumulativeSize = 0; + let askCumulativeNotional = 0; + const asks = asksRaw.slice(0, levels).map((level) => { + const price = Number.parseFloat(level.px); + const size = Number.parseFloat(level.sz); + const notional = price * size; + askCumulativeSize += size; + askCumulativeNotional += notional; + return { + price: level.px, + size: level.sz, + total: askCumulativeSize.toString(), + notional: notional.toFixed(2), + totalNotional: askCumulativeNotional.toFixed(2), + }; + }); + + const bestBid = bids[0]; + const bestAsk = asks[0]; + const bidPrice = bestBid ? Number.parseFloat(bestBid.price) : 0; + const askPrice = bestAsk ? Number.parseFloat(bestAsk.price) : 0; + const spread = askPrice > 0 && bidPrice > 0 ? askPrice - bidPrice : 0; + const midPrice = askPrice > 0 && bidPrice > 0 ? (askPrice + bidPrice) / 2 : 0; + const spreadPercentage = + midPrice > 0 ? ((spread / midPrice) * 100).toFixed(4) : '0'; + const maxTotal = Math.max(bidCumulativeSize, askCumulativeSize).toString(); + + return { + bids, + asks, + spread: spread.toFixed(5), + spreadPercentage, + midPrice: midPrice.toFixed(5), + lastUpdated: Date.now(), + maxTotal, + }; +} + +/** + * Owns a dedicated Hyperliquid WebSocket connection used solely for the + * order-book panel's server-aggregated `l2Book` subscription. + * + * The main connection (managed by the subscription service) multiplexes every + * subscription onto a single socket. The Hyperliquid SDK dispatches `l2Book` + * events by `coin` only, so running the raw (full-precision) and the aggregated + * (`nSigFigs`) subscriptions for the same coin on that shared socket + * cross-contaminates them — the coarse ladder and the precise spread/slippage + * clobber each other. Giving the aggregated subscription its own socket removes + * the collision entirely: this socket only ever carries a single `l2Book` + * stream, and the main socket is never touched by the panel's grouping. + * + * The socket is created lazily on the first subscription and torn down once the + * last subscription is removed, so it exists only while an order-book panel is + * open. Because network is a global setting, the transport is recreated if + * `isTestnet` changes between (re)subscriptions. + */ +export class AggregatedOrderBookConnection { + readonly #isTestnet: () => boolean; + + #transport: WebSocketTransport | null = null; + + #transportIsTestnet = false; + + #activeCount = 0; + + // Tracks the single `l2Book` payload the dedicated socket carries per asset, + // keyed by symbol. The SDK dispatches `l2Book` events by `coin` only, so two + // subscriptions for the same asset with different params (e.g. `nSigFigs`) + // would cross-contaminate on this shared socket — exactly the collision this + // connection exists to avoid. `count` refcounts the (identical) subscriptions + // sharing a payload so the entry is dropped once the last one unsubscribes. + readonly #payloads = new Map(); + + // Force-terminate callback for every currently-active subscription. When a + // transport rebuild (`#closeTransport`) shuts the socket down out from under + // live subscriptions, these tear each one down — notifying the caller and + // releasing its SDK subscription / socket listeners — instead of orphaning + // them (stale handle, no more updates, and no further status because + // reporting is suppressed once `transport !== this.#transport`). + readonly #activeSubscriptions = new Set<() => void>(); + + // Set when the socket's auto-reconnection is exhausted (its + // `terminationSignal` aborts). A terminated socket cannot recover, so the next + // subscribe must build a fresh transport instead of reusing the dead one. + #terminated = false; + + constructor({ isTestnet }: AggregatedOrderBookConnectionOptions) { + this.#isTestnet = isTestnet; + } + + /** + * Opens an aggregated `l2Book` subscription on the dedicated socket. + * + * Mirrors the subscription service's synchronous-unsubscribe contract: the + * returned function can be called before the async subscribe resolves and + * will cancel the pending subscription. + * + * Only one `l2Book` payload per asset may be active at a time. Subscribing to + * an asset that already has a live subscription with different params (e.g. a + * different `nSigFigs` or `mantissa`) throws, because the shared socket + * dispatches by `coin` and the conflicting streams would clobber each other. + * + * @param params - Subscription parameters. + * @returns An unsubscribe function. + * @throws If the asset already has an active subscription with different params. + */ + subscribe(params: SubscribeAggregatedOrderBookParams): () => void { + const levels = params.levels ?? DEFAULT_LEVELS; + // The `l2Book` subscription params the socket carries. `levels` is + // client-side only (it slices each snapshot), so it is deliberately excluded + // from the params and their signature. + const l2BookParams = { + coin: params.symbol, + nSigFigs: params.nSigFigs, + mantissa: params.mantissa ?? null, + fast: true as const, + }; + const signature = JSON.stringify(l2BookParams); + + const transport = this.#ensureTransport(this.#isTestnet()); + + // Reject a conflicting payload for an asset already on this socket. A + // recreated transport (first use, network change, or terminate) starts with + // an empty payload map, so this can only trip on the reuse path — the shared + // socket that would actually suffer the collision. + const existingPayload = this.#payloads.get(params.symbol); + if (existingPayload && existingPayload.signature !== signature) { + throw new Error( + `AggregatedOrderBookConnection: "${params.symbol}" is already subscribed with different params; only one l2Book payload per asset is allowed on the dedicated socket.`, + ); + } + + const { socket } = transport; + + let cancelled = false; + let subscription: ISubscription | null = null; + this.#activeCount += 1; + if (existingPayload) { + existingPayload.count += 1; + } else { + this.#payloads.set(params.symbol, { signature, count: 1 }); + } + + // Set once this subscription's socket terminates (reconnection exhausted). + // The `error` state is terminal until teardown/resubscribe, so once set we + // suppress any late `connected`/`connecting` — e.g. from a subscribe promise + // that resolves *after* the socket died — which would otherwise flip the UI + // back to a healthy state on a dead socket and hide the manual-reconnect + // affordance. + let terminated = false; + const reportStatus = (status: OrderBookConnectionStatus): void => { + // Suppress reports from a subscription that no longer drives the UI: it + // was unsubscribed (`cancelled`), its transport was replaced by a network + // flip or recreate (`transport !== this.#transport`, so its socket is + // dead), or its socket permanently terminated and `error` is now sticky + // until teardown (`terminated`). + if ( + cancelled || + transport !== this.#transport || + (terminated && status !== 'error') + ) { + return; + } + params.onStatusChange?.(status); + }; + + // Reflect the socket's live health. Every drop dispatches a `close` event; + // the reconnecting socket only exposes permanent termination through its + // `terminationSignal` (an `AbortSignal`), which it aborts *before* the final + // close. So an aborted signal on close — unless it was our own `close()` + // (`TERMINATED_BY_USER`) — means automatic reconnection is exhausted: the + // unrecoverable state the UI surfaces with a manual reconnect button. A + // still-live signal means a transient drop the socket will auto-reconnect. + const handleOpen = (): void => reportStatus('connected'); + const handleClose = (): void => { + // A torn-down subscription must not mutate shared connection state. Its + // listeners are normally detached before the socket closes, but guard + // anyway so a late `close` (e.g. from `transport.close()` racing listener + // removal) can't wrongly flip `#terminated`. + if (cancelled) { + return; + } + const { terminationSignal } = socket; + const terminatedByUser = + (terminationSignal.reason as { code?: string } | undefined)?.code === + 'TERMINATED_BY_USER'; + if (terminationSignal.aborted && !terminatedByUser) { + this.#terminated = true; + terminated = true; + reportStatus('error'); + return; + } + reportStatus('connecting'); + }; + socket.addEventListener('open', handleOpen); + socket.addEventListener('close', handleClose); + + const removeSocketListeners = (): void => { + socket.removeEventListener('open', handleOpen); + socket.removeEventListener('close', handleClose); + }; + + // Ends this subscription when its transport is torn down beneath it (network + // flip, post-termination resubscribe, or `close()`). Unlike `teardown` it + // leaves the shared refcount/payload state alone — `#closeTransport` clears + // those wholesale — but still notifies the caller and releases this + // subscription's resources so nothing leaks on the dead socket. + const forceTerminate = (): void => { + if (cancelled) { + return; + } + // Notify directly rather than via `reportStatus`: `#closeTransport` + // detaches `#transport` before invoking these callbacks (so a reentrant + // subscribe from this handler builds a fresh transport instead of binding + // to the dying one), which would otherwise trip `reportStatus`'s + // stale-transport guard. A subscription whose socket already terminated + // has reported `error`; a still-live one (abandoned by a network flip or + // `close()`) needs the terminal signal so the caller stops trusting a + // now-dead book. + if (!terminated) { + params.onStatusChange?.('error'); + } + cancelled = true; + removeSocketListeners(); + this.#activeSubscriptions.delete(forceTerminate); + if (subscription) { + subscription.unsubscribe().catch(() => undefined); + subscription = null; + } + }; + this.#activeSubscriptions.add(forceTerminate); + + // Releases this subscription's refcount and tears down the socket once no + // subscriptions remain. Idempotent via `cancelled`, so it's safe whether it + // runs from the returned unsubscribe or from a failed subscribe. + const teardown = (): void => { + if (cancelled) { + return; + } + cancelled = true; + removeSocketListeners(); + this.#activeSubscriptions.delete(forceTerminate); + if (subscription) { + subscription.unsubscribe().catch(() => undefined); + subscription = null; + } + // Only touch the refcount/current socket if this subscription still + // belongs to the active transport. If the transport was recreated (network + // change or terminate), this subscription's socket is already dead and + // `#activeCount` now tracks only the new transport's subscriptions — so an + // older unsubscribe must not decrement it and tear down the live socket. + if (transport === this.#transport) { + this.#activeCount = Math.max(0, this.#activeCount - 1); + const entry = this.#payloads.get(params.symbol); + if (entry) { + entry.count -= 1; + if (entry.count <= 0) { + this.#payloads.delete(params.symbol); + } + } + if (this.#activeCount === 0) { + this.#closeTransport(); + } + } + }; + + // Surfaces a subscription failure the same way regardless of when it + // happens: report `error` (before teardown flips `cancelled`, which gates + // status updates) then release the refcount so the dead subscription doesn't + // keep the dedicated socket open. Used for both the initial subscribe + // rejection (`.catch`) and post-confirmation failures the SDK reports only + // through `onError` — e.g. the server rejecting the re-subscription after a + // reconnect, which removes the listener and stops all further events (a + // frozen order book that would otherwise still read as `connected`). + // Idempotent via `teardown`'s `cancelled` guard. + const handleSubscriptionError = (): void => { + reportStatus('error'); + teardown(); + }; + + reportStatus('connecting'); + + // Subscribe through the typed `l2Book` client so the params are validated + // before they reach the wire (`fast: true` requests fast mode — 5 levels at + // ~0.5s). The listener receives the decoded snapshot directly. + new SubscriptionClient({ transport }) + .l2Book( + l2BookParams, + (data: HyperliquidL2BookEvent) => { + if (cancelled || data?.coin !== params.symbol || !data?.levels) { + return; + } + params.callback(processAggregatedOrderBook(data, levels)); + }, + // `onError` fires at most once for an *already confirmed* subscription + // that later fails (rejected re-subscription after reconnect, permanent + // termination, or a drop while re-subscription is disabled). The SDK + // removes the listener and emits nothing further, so treat it exactly + // like an initial failure. + { onError: handleSubscriptionError }, + ) + .then(async (sub) => { + // Stale if this subscription was unsubscribed (`cancelled`) or its + // transport was replaced (network flip / recreate) before the subscribe + // settled — either way the captured socket is dead, so clean up the SDK + // subscription instead of storing it or announcing `connected`. + if (cancelled || transport !== this.#transport) { + try { + await sub.unsubscribe(); + } catch { + // Ignore cleanup errors on an already-cancelled/stale subscription. + } + return undefined; + } + subscription = sub; + reportStatus('connected'); + return undefined; + }) + .catch(handleSubscriptionError); + + return teardown; + } + + /** Closes the dedicated socket and drops all subscriptions. */ + close(): void { + this.#closeTransport(); + } + + #ensureTransport(isTestnet: boolean): WebSocketTransport { + if ( + this.#transport && + this.#transportIsTestnet === isTestnet && + !this.#terminated + ) { + return this.#transport; + } + // First use, the network changed, or the previous socket was terminated — + // (re)create the dedicated transport. Reuse the package's transport config + // so this socket shares the finite five-attempt reconnection policy; without + // it the SDK defaults `maxRetries` to Infinity and a sustained outage would + // never exhaust reconnection to reach the `error`/manual-reconnect state. + this.#closeTransport(); + // `#closeTransport` notifies subscribers, which may synchronously re-enter + // `subscribe` and build a matching transport. Reuse it instead of orphaning + // it (which would leak the reentrant subscription on an unreferenced socket). + if ( + this.#transport && + this.#transportIsTestnet === isTestnet && + !this.#terminated + ) { + return this.#transport; + } + const transport = new WebSocketTransport({ + isTestnet, + ...HYPERLIQUID_TRANSPORT_CONFIG, + reconnect: HYPERLIQUID_TRANSPORT_CONFIG.reconnect, + }); + this.#transport = transport; + this.#transportIsTestnet = isTestnet; + return transport; + } + + #closeTransport(): void { + const transport = this.#transport; + // Snapshot the subscriptions to force-terminate, then detach ALL shared + // state (the set, `#transport`, refcounts) *before* invoking any callback. + // Those callbacks notify subscribers via `onStatusChange`, which can + // synchronously re-enter `subscribe`; detaching first guarantees a reentrant + // subscribe builds a fresh transport (rather than reusing this dying one) + // and registers itself in a clean set (rather than being swept up by, or + // lingering past, this teardown). Subscriptions torn down normally have + // already removed themselves, so their entry is a no-op here. + const subscriptions = [...this.#activeSubscriptions]; + this.#activeSubscriptions.clear(); + this.#transport = null; + this.#activeCount = 0; + this.#payloads.clear(); + this.#terminated = false; + // Force-terminate (which detaches each subscription's socket listeners) + // BEFORE closing the transport. `close()` on an already-exhausted socket + // dispatches a final `close`; if a stale `handleClose` were still attached + // it would re-set `#terminated` right after we cleared it, making the next + // `#ensureTransport` tear down the healthy replacement socket. + for (const forceTerminate of subscriptions) { + forceTerminate(); + } + if (transport) { + transport.close(); + } + } +} diff --git a/packages/perps-controller/src/services/DataLakeService.ts b/packages/perps-controller/src/services/DataLakeService.ts new file mode 100644 index 00000000000..d0e00e9ef79 --- /dev/null +++ b/packages/perps-controller/src/services/DataLakeService.ts @@ -0,0 +1,283 @@ +import { v4 as uuidv4 } from 'uuid'; + +import { PerpsMeasurementName } from '../constants/performanceMetrics.js'; +import { + DATA_LAKE_API_CONFIG, + PERPS_CONSTANTS, +} from '../constants/perpsConfig.js'; +import { PerpsTraceNames, PerpsTraceOperations } from '../types/index.js'; +import type { PerpsPlatformDependencies } from '../types/index.js'; +import type { PerpsControllerMessengerBase } from '../types/messenger.js'; +import { getSelectedEvmAccountFromMessenger } from '../utils/accountUtils.js'; +import { ensureError } from '../utils/errorUtils.js'; +import type { ServiceContext } from './ServiceContext.js'; + +/** + * DataLakeService + * + * Handles reporting order events to external Data Lake API. + * Implements exponential backoff retry logic and performance tracing. + * Stateless service that operates purely on external API calls. + * + * Instance-based service with constructor injection of platform dependencies. + */ +export class DataLakeService { + readonly #deps: PerpsPlatformDependencies; + + readonly #messenger: PerpsControllerMessengerBase; + + /** + * Create a new DataLakeService instance + * + * @param deps - Platform dependencies for logging, metrics, etc. + * @param messenger - Controller messenger for cross-controller communication. + */ + constructor( + deps: PerpsPlatformDependencies, + messenger: PerpsControllerMessengerBase, + ) { + this.#deps = deps; + this.#messenger = messenger; + } + + /** + * Get bearer token via DI authentication controller + * + * @returns The bearer token string for API authentication. + */ + async #getBearerToken(): Promise { + return this.#messenger.call('AuthenticationController:getBearerToken'); + } + + /** + * Report order events to data lake API with retry (non-blocking) + * Implements exponential backoff retry logic (max 3 retries) + * + * @param options - Configuration object + * @param options.action - Order action ('open' or 'close') + * @param options.symbol - Market symbol + * @param options.slPrice - Optional stop loss price. + * @param options.tpPrice - Optional take profit price. + * @param options.isTestnet - Whether this is a testnet operation (skips API call) + * @param options.context - ServiceContext for dependencies (messenger, tracing) + * @param options.retryCount - Internal retry counter (managed by service) + * @param options._traceId - Internal trace ID (managed by service) + * @returns Result object with success flag and optional error message + */ + async reportOrder(options: { + action: 'open' | 'close'; + symbol: string; + slPrice?: number; + tpPrice?: number; + isTestnet: boolean; + context: ServiceContext; + retryCount?: number; + _traceId?: string; + }): Promise<{ success: boolean; error?: string }> { + const { + action, + symbol, + slPrice, + tpPrice, + isTestnet, + context, + retryCount = 0, + _traceId, + } = options; + + // Skip data lake reporting for testnet as the API doesn't handle testnet data + if (isTestnet) { + this.#deps.debugLogger.log('DataLake API: Skipping for testnet', { + action, + symbol, + network: 'testnet', + }); + return { success: true, error: 'Skipped for testnet' }; + } + + const MAX_RETRIES = 3; + const RETRY_DELAY_MS = 1000; + + // Generate trace ID once on first call + const traceId = _traceId ?? uuidv4(); + + // Start trace only on first attempt + if (retryCount === 0) { + this.#deps.tracer.trace({ + name: PerpsTraceNames.DataLakeReport, + op: PerpsTraceOperations.Operation, + id: traceId, + tags: { + action, + symbol, + provider: context.tracingContext.provider, + isTestnet: String(context.tracingContext.isTestnet), + }, + }); + } + + // Log the attempt + this.#deps.debugLogger.log('DataLake API: Starting order report', { + action, + symbol, + attempt: retryCount + 1, + maxAttempts: MAX_RETRIES + 1, + hasStopLoss: Boolean(slPrice), + hasTakeProfit: Boolean(tpPrice), + timestamp: new Date().toISOString(), + }); + + const apiCallStartTime = this.#deps.performance.now(); + + try { + const token = await this.#getBearerToken(); + const evmAccount = getSelectedEvmAccountFromMessenger(this.#messenger); + + if (!evmAccount || !token) { + this.#deps.debugLogger.log('DataLake API: Missing requirements', { + hasAccount: Boolean(evmAccount), + hasToken: Boolean(token), + action, + symbol, + }); + return { success: false, error: 'No account or token available' }; + } + + const response = await fetch(DATA_LAKE_API_CONFIG.OrdersEndpoint, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${token}`, + }, + body: JSON.stringify({ + user_id: evmAccount.address, + symbol, + sl_price: slPrice, + tp_price: tpPrice, + }), + }); + + if (!response.ok) { + throw new Error(`DataLake API error: ${response.status}`); + } + + // Consume response body (might be empty for 201, but good to check) + const responseBody = await response.text(); + + const apiCallDuration = this.#deps.performance.now() - apiCallStartTime; + + // Record measurement + this.#deps.tracer.setMeasurement( + PerpsMeasurementName.PerpsDataLakeApiCall, + apiCallDuration, + 'millisecond', + ); + + // Success logging + this.#deps.debugLogger.log('DataLake API: Order reported successfully', { + action, + symbol, + status: response.status, + attempt: retryCount + 1, + responseBody: responseBody || 'empty', + duration: `${apiCallDuration.toFixed(0)}ms`, + }); + + // End trace on success + this.#deps.tracer.endTrace({ + name: PerpsTraceNames.DataLakeReport, + id: traceId, + data: { + success: true, + retries: retryCount, + }, + }); + + return { success: true }; + } catch (error) { + const errorMessage = + error instanceof Error ? error.message : 'Unknown error'; + + this.#deps.logger.error( + ensureError(error, 'DataLakeService.reportOrder'), + { + tags: { feature: PERPS_CONSTANTS.FeatureName }, + context: { + name: 'DataLakeService.reportOrder', + data: { + action, + symbol, + retryCount, + willRetry: retryCount < MAX_RETRIES, + }, + }, + }, + ); + + // Retry logic + if (retryCount < MAX_RETRIES) { + const retryDelay = RETRY_DELAY_MS * Math.pow(2, retryCount); + this.#deps.debugLogger.log('DataLake API: Scheduling retry', { + retryIn: `${retryDelay}ms`, + nextAttempt: retryCount + 2, + action, + symbol, + }); + + setTimeout(() => { + this.reportOrder({ + action, + symbol, + slPrice, + tpPrice, + isTestnet, + context, + retryCount: retryCount + 1, + _traceId: traceId, + }).catch((_retryError) => { + this.#deps.logger.error( + ensureError(_retryError, 'DataLakeService.reportOrder'), + { + tags: { feature: PERPS_CONSTANTS.FeatureName }, + context: { + name: 'DataLakeService.reportOrder', + data: { + operation: 'retry', + retryCount: retryCount + 1, + action, + symbol, + }, + }, + }, + ); + }); + }, retryDelay); + + return { success: false, error: errorMessage }; + } + + this.#deps.tracer.endTrace({ + name: PerpsTraceNames.DataLakeReport, + id: traceId, + data: { + success: false, + error: errorMessage, + totalRetries: retryCount, + }, + }); + + this.#deps.logger.error( + ensureError(error, 'DataLakeService.reportOrder'), + { + tags: { feature: PERPS_CONSTANTS.FeatureName }, + context: { + name: 'DataLakeService.reportOrder', + data: { operation: 'finalFailure', action, symbol, retryCount }, + }, + }, + ); + + return { success: false, error: errorMessage }; + } + } +} diff --git a/packages/perps-controller/src/services/DepositService.ts b/packages/perps-controller/src/services/DepositService.ts new file mode 100644 index 00000000000..76afdea10f0 --- /dev/null +++ b/packages/perps-controller/src/services/DepositService.ts @@ -0,0 +1,115 @@ +import { toHex } from '@metamask/controller-utils'; +import { parseCaipAssetId } from '@metamask/utils'; +import type { Hex } from '@metamask/utils'; + +import { DEPOSIT_CONFIG } from '../constants/hyperLiquidConfig.js'; +import type { + PerpsProvider, + PerpsPlatformDependencies, + PerpsTransactionParams, +} from '../types/index.js'; +import type { PerpsControllerMessengerBase } from '../types/messenger.js'; +import { getSelectedEvmAccountFromMessenger } from '../utils/accountUtils.js'; +import { generateDepositId } from '../utils/idUtils.js'; +import { generateERC20TransferData } from '../utils/transferData.js'; + +// Temporary to avoid estimation failures due to insufficient balance +const DEPOSIT_GAS_LIMIT = toHex(DEPOSIT_CONFIG.EstimatedGasLimit); + +/** + * DepositService + * + * Handles deposit transaction preparation and validation. + * Stateless service that prepares transaction data for TransactionController. + * Controller handles TransactionController integration and promise lifecycle. + * + * Instance-based service with constructor injection of platform dependencies + * and messenger for inter-controller communication. + */ +export class DepositService { + readonly #deps: PerpsPlatformDependencies; + + readonly #messenger: PerpsControllerMessengerBase; + + /** + * Create a new DepositService instance + * + * @param deps - Platform dependencies for logging, metrics, etc. + * @param messenger - Controller messenger for cross-controller communication. + */ + constructor( + deps: PerpsPlatformDependencies, + messenger: PerpsControllerMessengerBase, + ) { + this.#deps = deps; + this.#messenger = messenger; + } + + /** + * Prepare deposit transaction for confirmation + * Extracts transaction construction logic from controller + * + * @param options - Configuration object + * @param options.provider - Active provider instance + * @returns Transaction data ready for TransactionController.addTransaction + */ + async prepareTransaction(options: { provider: PerpsProvider }): Promise<{ + transaction: PerpsTransactionParams; + assetChainId: Hex; + currentDepositId: string; + }> { + const { provider } = options; + + this.#deps.debugLogger.log('DepositService: Preparing deposit transaction'); + + // Generate deposit request ID for tracking + const currentDepositId = generateDepositId(); + + // Get deposit routes from provider + const depositRoutes = provider.getDepositRoutes({ isTestnet: false }); + const route = depositRoutes[0]; + const bridgeContractAddress = route.contractAddress; + + // Generate transfer data for ERC-20 token transfer (portable, no mobile imports) + const transferData = generateERC20TransferData( + bridgeContractAddress, + '0x0', + ); + + // Get EVM account from selected account, falling back to the selected account group. + const evmAccount = getSelectedEvmAccountFromMessenger(this.#messenger); + if (!evmAccount) { + throw new Error( + 'No EVM-compatible account found in selected account group', + ); + } + const accountAddress = evmAccount.address as Hex; + + // Parse CAIP asset ID to extract chain ID and token address + const parsedAsset = parseCaipAssetId(route.assetId); + const assetChainId = toHex(parsedAsset.chainId.split(':')[1]); + const tokenAddress = parsedAsset.assetReference as Hex; + + // Build transaction parameters for TransactionController + const transaction: PerpsTransactionParams = { + from: accountAddress, + to: tokenAddress, + value: '0x0', + data: transferData, + gas: DEPOSIT_GAS_LIMIT, + }; + + this.#deps.debugLogger.log('DepositService: Deposit transaction prepared', { + depositId: currentDepositId, + assetChainId, + from: accountAddress, + to: tokenAddress, + }); + + return { + transaction, + assetChainId, + currentDepositId, + }; + } +} diff --git a/packages/perps-controller/src/services/DexDiscoveryCacheManager.ts b/packages/perps-controller/src/services/DexDiscoveryCacheManager.ts new file mode 100644 index 00000000000..3ed2a912489 --- /dev/null +++ b/packages/perps-controller/src/services/DexDiscoveryCacheManager.ts @@ -0,0 +1,184 @@ +import { + MAINNET_HIP3_CONFIG, + TESTNET_HIP3_CONFIG, +} from '../constants/hyperLiquidConfig.js'; +import type { + DexDiscoveryState, + ExtendedPerpDex, +} from '../types/perps-types.js'; + +type DexDiscoveryDeps = { + isTestnetMode: () => boolean; + debugLogger: { log: (...args: unknown[]) => void }; + getAllowlistMarkets: () => string[]; +}; + +/** + * Manages the unified DEX discovery cache — single source of truth for all perpDexs() derivatives. + * + * Extracted from HyperLiquidProvider to isolate cache logic. + * All writes go through update(); readers use .state. + */ +export class DexDiscoveryCacheManager { + /** + * Unified DEX discovery state. + * null = not yet fetched; object = raw + validated + timestamp. + */ + state: DexDiscoveryState | null = null; + + readonly #deps: DexDiscoveryDeps; + + constructor(deps: DexDiscoveryDeps) { + this.#deps = deps; + } + + /** + * Single atomic writer for DEX discovery state. + * All code paths that fetch perpDexs() MUST call this — no direct field writes. + * + * @param allDexs - Raw perpDexs() API response array. + * @returns The newly created unified discovery state. + */ + update(allDexs: (ExtendedPerpDex | null)[]): DexDiscoveryState { + const validated = this.computeValidatedDexs(allDexs); + const newState: DexDiscoveryState = { + raw: allDexs, + validated, + timestamp: Date.now(), + }; + this.state = newState; + return newState; + } + + /** + * Reset state to null (used on disconnect/reconnect). + */ + reset(): void { + this.state = null; + } + + /** + * Pure filtering of perpDexs() response into validated DEX names. + * Encapsulates testnet/mainnet feature-flag logic. + * + * @param allDexs - Raw perpDexs() API response array. + * @returns Filtered DEX name list (null = main DEX, strings = HIP-3 DEXs). + */ + computeValidatedDexs(allDexs: (ExtendedPerpDex | null)[]): (string | null)[] { + const availableHip3Dexs: string[] = []; + allDexs.forEach((dex) => { + if (dex !== null) { + availableHip3Dexs.push(dex.name); + } + }); + + if (this.#deps.isTestnetMode()) { + const { EnabledDexs, AutoDiscoverAll } = TESTNET_HIP3_CONFIG; + + if (!AutoDiscoverAll) { + if (EnabledDexs.length === 0) { + this.#deps.debugLogger.log( + 'HyperLiquidProvider: Testnet - using main DEX only (HIP-3 DEXs filtered)', + { + availableHip3Dexs: availableHip3Dexs.length, + reason: 'TESTNET_HIP3_CONFIG.EnabledDexs is empty', + }, + ); + return [null]; + } + + const filteredDexs = availableHip3Dexs.filter((dex) => + EnabledDexs.includes(dex), + ); + this.#deps.debugLogger.log( + 'HyperLiquidProvider: Testnet - filtered to allowed DEXs', + { + allowedDexs: EnabledDexs, + filteredDexs, + availableHip3Dexs: availableHip3Dexs.length, + }, + ); + return [null, ...filteredDexs]; + } + + this.#deps.debugLogger.log( + 'HyperLiquidProvider: Testnet - AUTO_DISCOVER_ALL enabled, using all DEXs', + { totalDexCount: availableHip3Dexs.length + 1 }, + ); + } else { + const { AutoDiscoverAll } = MAINNET_HIP3_CONFIG; + + if (!AutoDiscoverAll) { + const allowedDexsFromAllowlist = this.extractDexsFromAllowlist(); + + if (allowedDexsFromAllowlist.length === 0) { + this.#deps.debugLogger.log( + 'HyperLiquidProvider: Mainnet - using main DEX only (no HIP-3 DEXs in allowlist)', + { + availableHip3Dexs: availableHip3Dexs.length, + allowlistMarkets: this.#deps.getAllowlistMarkets(), + }, + ); + return [null]; + } + + const filteredDexs = availableHip3Dexs.filter((dex) => + allowedDexsFromAllowlist.includes(dex), + ); + this.#deps.debugLogger.log( + 'HyperLiquidProvider: Mainnet - filtered to allowlist DEXs', + { + allowedDexsFromAllowlist, + filteredDexs, + availableHip3Dexs: availableHip3Dexs.length, + }, + ); + return [null, ...filteredDexs]; + } + + this.#deps.debugLogger.log( + 'HyperLiquidProvider: Mainnet - AUTO_DISCOVER_ALL enabled, using all DEXs', + { totalDexCount: availableHip3Dexs.length + 1 }, + ); + } + + this.#deps.debugLogger.log( + 'HyperLiquidProvider: All DEXs enabled (market filtering at data layer)', + { + mainDex: true, + hip3Dexs: availableHip3Dexs, + totalDexCount: availableHip3Dexs.length + 1, + }, + ); + return [null, ...availableHip3Dexs]; + } + + /** + * Extract unique DEX names from allowlist market patterns. + * Patterns can be: "xyz:*" (wildcard), "xyz:TSLA" (exact), or "xyz" (DEX shorthand). + * + * @returns Array of unique DEX names from the allowlist. + */ + extractDexsFromAllowlist(): string[] { + const allowlistMarkets = this.#deps.getAllowlistMarkets(); + if (allowlistMarkets.length === 0) { + return []; + } + + const dexNames = new Set(); + + for (const pattern of allowlistMarkets) { + const colonIndex = pattern.indexOf(':'); + if (colonIndex > 0) { + const dex = pattern.substring(0, colonIndex); + dexNames.add(dex); + } else if (pattern.length > 0 && !pattern.includes('*')) { + if (/^[a-z][a-z0-9]*$/iu.test(pattern)) { + dexNames.add(pattern.toLowerCase()); + } + } + } + + return Array.from(dexNames); + } +} diff --git a/packages/perps-controller/src/services/EligibilityService.ts b/packages/perps-controller/src/services/EligibilityService.ts new file mode 100644 index 00000000000..1b3dabdd979 --- /dev/null +++ b/packages/perps-controller/src/services/EligibilityService.ts @@ -0,0 +1,79 @@ +import { PERPS_CONSTANTS } from '../constants/perpsConfig.js'; +import type { + PerpsPlatformDependencies, + CheckEligibilityParams, +} from '../types/index.js'; +import { ensureError } from '../utils/errorUtils.js'; + +/** + * EligibilityService + * + * Handles eligibility checking based on geolocation and blocked regions. + * Geolocation is sourced externally from the GeolocationController. + * + * Instance-based service with constructor injection of platform dependencies. + */ +export class EligibilityService { + readonly #deps: PerpsPlatformDependencies; + + /** + * Create a new EligibilityService instance + * + * @param deps - Platform dependencies for logging, metrics, etc. + */ + constructor(deps: PerpsPlatformDependencies) { + this.#deps = deps; + } + + /** + * Check if user is eligible based on geo-blocked regions. + * + * @param options - The eligibility check parameters. + * @param options.blockedRegions - List of blocked region codes (e.g., ['US', 'CN']). + * @param options.geoLocation - The user's geolocation string from GeolocationController. + * @returns True if eligible (not in blocked region), false otherwise. + */ + async checkEligibility(options: CheckEligibilityParams): Promise { + const { blockedRegions, geoLocation } = options; + try { + this.#deps.debugLogger.log('EligibilityService: Checking eligibility', { + blockedRegionsCount: blockedRegions.length, + geoLocation, + }); + + if (geoLocation !== 'UNKNOWN') { + const isEligible = blockedRegions.every( + (geoBlockedRegion) => + !geoLocation + .toUpperCase() + .startsWith(geoBlockedRegion.toUpperCase()), + ); + + this.#deps.debugLogger.log( + 'EligibilityService: Eligibility check completed', + { + geoLocation, + isEligible, + blockedRegions, + }, + ); + + return isEligible; + } + + return true; + } catch (error) { + this.#deps.logger.error( + ensureError(error, 'EligibilityService.checkEligibility'), + { + tags: { feature: PERPS_CONSTANTS.FeatureName }, + context: { + name: 'EligibilityService.checkEligibility', + data: {}, + }, + }, + ); + return true; + } + } +} diff --git a/packages/perps-controller/src/services/FeatureFlagConfigurationService.ts b/packages/perps-controller/src/services/FeatureFlagConfigurationService.ts new file mode 100644 index 00000000000..38adebae9ec --- /dev/null +++ b/packages/perps-controller/src/services/FeatureFlagConfigurationService.ts @@ -0,0 +1,406 @@ +import { hasProperty } from '@metamask/utils'; + +import { PERPS_CONSTANTS } from '../constants/perpsConfig.js'; +import { isVersionGatedFeatureFlag } from '../types/index.js'; +import type { + PerpsPlatformDependencies, + PerpsRemoteFeatureFlagState, +} from '../types/index.js'; +import { ensureError } from '../utils/errorUtils.js'; +import { validateMarketPattern } from '../utils/marketUtils.js'; +import { + parseCommaSeparatedString, + stripQuotes, +} from '../utils/stringParseUtils.js'; +import type { ServiceContext } from './ServiceContext.js'; + +/** + * FeatureFlagConfigurationService + * + * Handles HIP-3 configuration and geo-blocking configuration from remote feature flags. + * Implements "sticky remote" pattern: once remote config is loaded, never downgrade to fallback. + * Orchestrates validation, change detection, and version management for feature flag updates. + * + * Responsibilities: + * - Remote feature flag validation and parsing + * - HIP-3 configuration management (equity, allowlist, blocklist) + * - Geo-blocking configuration from remote flags + * - Change detection and version management + * - "Sticky remote" pattern enforcement (never downgrade) + * + * Instance-based service with constructor injection of platform dependencies. + */ +export class FeatureFlagConfigurationService { + readonly #deps: PerpsPlatformDependencies; + + /** + * Create a new FeatureFlagConfigurationService instance + * + * @param deps - Platform dependencies for logging, metrics, etc. + */ + constructor(deps: PerpsPlatformDependencies) { + this.#deps = deps; + } + + /** + * Validate and parse market list from remote feature flags + * Handles both string (comma-separated) and array formats from LaunchDarkly + * + * @param remoteValue - The raw value from remote feature flags (string or array). + * @param fieldName - The name of the field being validated (for logging). + * @param currentValue - The current local market list as fallback reference. + * @returns The validated market list, or undefined if validation fails. + */ + #validateMarketList( + remoteValue: unknown, + fieldName: string, + currentValue: string[], + ): string[] | undefined { + this.#deps.debugLogger.log( + `PerpsController: HIP-3 ${fieldName} validation`, + { + remoteValue, + type: typeof remoteValue, + isArray: Array.isArray(remoteValue), + }, + ); + + // LaunchDarkly returns comma-separated strings for list values + // Values may have literal quotes (e.g., '"xyz"') due to JSON encoding quirks + if (typeof remoteValue === 'string') { + const parsed = this.#filterValidPatterns( + parseCommaSeparatedString(remoteValue).map(stripQuotes), + fieldName, + ); + + if (parsed.length > 0) { + this.#deps.debugLogger.log( + `PerpsController: HIP-3 ${fieldName} validated from string`, + { validatedMarkets: parsed }, + ); + return parsed; + } + + this.#deps.debugLogger.log( + `PerpsController: HIP-3 ${fieldName} string was empty after parsing`, + { fallbackValue: currentValue }, + ); + return undefined; + } + + // Fallback: Validate array of non-empty strings + if ( + Array.isArray(remoteValue) && + remoteValue.every((item) => typeof item === 'string' && item.length > 0) + ) { + const validatedMarkets = this.#filterValidPatterns( + (remoteValue as string[]) + .map((market) => stripQuotes(market.trim())) + .filter((market) => market.length > 0), + fieldName, + ); + + if (validatedMarkets.length > 0) { + this.#deps.debugLogger.log( + `PerpsController: HIP-3 ${fieldName} validated from array`, + { validatedMarkets }, + ); + return validatedMarkets; + } + + this.#deps.debugLogger.log( + `PerpsController: HIP-3 ${fieldName} array was empty after filtering`, + { fallbackValue: currentValue }, + ); + return undefined; + } + + this.#deps.debugLogger.log( + `PerpsController: HIP-3 ${fieldName} validation FAILED - falling back to local config`, + { + reason: Array.isArray(remoteValue) + ? 'Array contains non-string or empty values' + : 'Invalid type (expected string or array)', + fallbackValue: currentValue, + }, + ); + return undefined; + } + + /** + * Filter out patterns that fail market pattern validation. + * Invalid patterns are logged and dropped instead of propagated downstream. + * + * @param patterns - The array of market patterns to validate. + * @param fieldName - The name of the field being validated (for logging). + * @returns The filtered array containing only valid market patterns. + */ + #filterValidPatterns(patterns: string[], fieldName: string): string[] { + return patterns.filter((pattern) => { + try { + validateMarketPattern(pattern); + return true; + } catch (error) { + this.#deps.logger.error( + ensureError( + error, + `FeatureFlagConfigurationService.filterValidPatterns`, + ), + { + tags: { feature: PERPS_CONSTANTS.FeatureName }, + context: { + name: 'FeatureFlagConfigurationService.filterValidPatterns', + data: { fieldName, pattern }, + }, + }, + ); + return false; + } + }); + } + + /** + * Check if arrays have different values (order-independent comparison) + * + * @param a - The first string array to compare. + * @param b - The second string array to compare. + * @returns True if the arrays contain different values. + */ + #arraysHaveDifferentValues(a: string[], b: string[]): boolean { + return ( + JSON.stringify( + [...a].sort((itemA, itemB) => itemA.localeCompare(itemB)), + ) !== + JSON.stringify([...b].sort((itemA, itemB) => itemA.localeCompare(itemB))) + ); + } + + /** + * Refresh HIP-3 configuration when remote feature flags change. + * This method extracts HIP-3 settings from remote flags, validates them, + * and updates internal state if they differ from current values. + * When config changes, increments hip3ConfigVersion to trigger ConnectionManager reconnection. + * + * Follows the "sticky remote" pattern: once remote config is loaded, never downgrade to fallback. + * + * @param options - Configuration object + * @param options.remoteFeatureFlagControllerState - Remote feature flag state + * @param options.context - ServiceContext providing state access callbacks + */ + refreshHip3Config(options: { + remoteFeatureFlagControllerState: PerpsRemoteFeatureFlagState; + context: ServiceContext; + }): void { + const { remoteFeatureFlagControllerState, context } = options; + + if ( + !context.getHip3Config || + !context.setHip3Config || + !context.incrementHip3ConfigVersion + ) { + throw new Error( + 'Required HIP-3 callbacks not available in ServiceContext', + ); + } + + const remoteFlags = remoteFeatureFlagControllerState.remoteFeatureFlags; + const currentConfig = context.getHip3Config(); + + // Extract and validate remote HIP-3 equity enabled flag + const equityFlag = remoteFlags?.perpsHip3Enabled; + // Use type guard to validate before calling - validatedVersionGatedFeatureFlag also + // handles invalid flags internally, but proper typing requires the guard + const validatedEquity = isVersionGatedFeatureFlag(equityFlag) + ? this.#deps.featureFlags.validateVersionGated(equityFlag) + : undefined; + + this.#deps.debugLogger.log( + 'PerpsController: HIP-3 equity flag validation', + { + equityFlag, + validatedEquity, + willUse: validatedEquity === undefined ? 'fallback' : 'remote', + }, + ); + + // Extract and validate remote HIP-3 market lists + const validatedAllowlistMarkets = hasProperty( + remoteFlags, + 'perpsHip3AllowlistMarkets', + ) + ? this.#validateMarketList( + remoteFlags.perpsHip3AllowlistMarkets, + 'allowlistMarkets', + currentConfig.allowlistMarkets, + ) + : undefined; + + const validatedBlocklistMarkets = hasProperty( + remoteFlags, + 'perpsHip3BlocklistMarkets', + ) + ? this.#validateMarketList( + remoteFlags.perpsHip3BlocklistMarkets, + 'blocklistMarkets', + currentConfig.blocklistMarkets, + ) + : undefined; + + // Detect changes (only if we have valid remote values) + const equityChanged = + validatedEquity !== undefined && + validatedEquity !== currentConfig.enabled; + const allowlistMarketsChanged = + validatedAllowlistMarkets !== undefined && + this.#arraysHaveDifferentValues( + validatedAllowlistMarkets, + currentConfig.allowlistMarkets, + ); + const blocklistMarketsChanged = + validatedBlocklistMarkets !== undefined && + this.#arraysHaveDifferentValues( + validatedBlocklistMarkets, + currentConfig.blocklistMarkets, + ); + + if (equityChanged || allowlistMarketsChanged || blocklistMarketsChanged) { + this.#deps.debugLogger.log( + 'PerpsController: HIP-3 config changed via remote feature flags', + { + equityChanged, + allowlistMarketsChanged, + blocklistMarketsChanged, + oldEquity: currentConfig.enabled, + newEquity: validatedEquity, + oldAllowlistMarkets: currentConfig.allowlistMarkets, + newAllowlistMarkets: validatedAllowlistMarkets, + oldBlocklistMarkets: currentConfig.blocklistMarkets, + newBlocklistMarkets: validatedBlocklistMarkets, + source: 'remote', + }, + ); + + // Update internal state (sticky remote - never downgrade) + context.setHip3Config({ + enabled: validatedEquity, + allowlistMarkets: validatedAllowlistMarkets + ? [...validatedAllowlistMarkets] + : undefined, + blocklistMarkets: validatedBlocklistMarkets + ? [...validatedBlocklistMarkets] + : undefined, + source: 'remote', + }); + + // Increment version to trigger ConnectionManager reconnection and cache clearing + const newVersion = context.incrementHip3ConfigVersion(); + + this.#deps.debugLogger.log( + 'PerpsController: Incremented hip3ConfigVersion to trigger reconnection', + { + newVersion, + newHip3Enabled: validatedEquity ?? currentConfig.enabled, + newHip3AllowlistMarkets: + validatedAllowlistMarkets ?? currentConfig.allowlistMarkets, + newHip3BlocklistMarkets: + validatedBlocklistMarkets ?? currentConfig.blocklistMarkets, + }, + ); + + // Note: ConnectionManager will handle: + // 1. Detecting hip3ConfigVersion change via Redux monitoring + // 2. Clearing all StreamManager caches + // 3. Calling reconnectWithNewContext() -> initializeProviders() + // 4. Provider reinitialization will read the new HIP-3 config below + } + } + + /** + * Respond to RemoteFeatureFlagController state changes + * Refreshes user eligibility based on geo-blocked regions defined in remote feature flag. + * Uses fallback configuration when remote feature flag is undefined. + * Note: Initial eligibility is set in the constructor if fallback regions are provided. + * + * @param options - Configuration object + * @param options.remoteFeatureFlagControllerState - Remote feature flag state + * @param options.context - ServiceContext providing callbacks + */ + refreshEligibility(options: { + remoteFeatureFlagControllerState: PerpsRemoteFeatureFlagState; + context: ServiceContext; + }): void { + const { remoteFeatureFlagControllerState, context } = options; + + const perpsGeoBlockedRegionsFeatureFlag = + // NOTE: Do not use perpsPerpTradingGeoBlockedCountries as it is deprecated. + remoteFeatureFlagControllerState.remoteFeatureFlags + ?.perpsPerpTradingGeoBlockedCountriesV2; + + const remoteBlockedRegions = ( + perpsGeoBlockedRegionsFeatureFlag as { blockedRegions?: string[] } + )?.blockedRegions; + + if (Array.isArray(remoteBlockedRegions)) { + this.setBlockedRegions({ + list: remoteBlockedRegions, + source: 'remote', + context, + }); + } + + // Also check for HIP-3 config changes + this.refreshHip3Config({ remoteFeatureFlagControllerState, context }); + } + + /** + * Set blocked region list with "never downgrade" pattern enforcement + * Updates the blocked region list and triggers eligibility refresh. + * Implements "sticky remote": once remote regions are set, never downgrade to fallback. + * + * @param options - Configuration object + * @param options.list - Array of blocked region codes + * @param options.source - Source of the list ('remote' or 'fallback') + * @param options.context - ServiceContext providing callbacks + */ + setBlockedRegions(options: { + list: string[]; + source: 'remote' | 'fallback'; + context: ServiceContext; + }): void { + const { list, source, context } = options; + + if ( + !context.getBlockedRegionList || + !context.setBlockedRegionList || + !context.refreshEligibility + ) { + throw new Error( + 'Required blocked region callbacks not available in ServiceContext', + ); + } + + const currentList = context.getBlockedRegionList(); + + // Never downgrade from remote to fallback + if (source === 'fallback' && currentList.source === 'remote') { + return; + } + + if (Array.isArray(list)) { + context.setBlockedRegionList(list, source); + } + + context.refreshEligibility().catch((error) => { + this.#deps.logger.error( + ensureError(error, 'FeatureFlagConfigurationService.setBlockedRegions'), + { + tags: { feature: PERPS_CONSTANTS.FeatureName }, + context: { + name: 'FeatureFlagConfigurationService.setBlockedRegions', + data: { source }, + }, + }, + ); + }); + } +} diff --git a/packages/perps-controller/src/services/HyperLiquidClientService.ts b/packages/perps-controller/src/services/HyperLiquidClientService.ts new file mode 100644 index 00000000000..421b6ccc53c --- /dev/null +++ b/packages/perps-controller/src/services/HyperLiquidClientService.ts @@ -0,0 +1,1369 @@ +import { Hex } from '@metamask/utils'; +import { + ExchangeClient, + HttpTransport, + InfoClient, + SubscriptionClient, + WebSocketTransport, +} from '@nktkas/hyperliquid'; +import type { HistoricalOrdersResponse } from '@nktkas/hyperliquid'; + +import { + CandlePeriod, + calculateCandleCount, +} from '../constants/chartConfig.js'; +import { HYPERLIQUID_TRANSPORT_CONFIG } from '../constants/hyperLiquidConfig.js'; +import { + PERFORMANCE_CONFIG, + PERPS_CONSTANTS, +} from '../constants/perpsConfig.js'; +import { PERPS_ERROR_CODES } from '../perpsErrorCodes.js'; +import type { HyperLiquidNetwork } from '../types/config.js'; +import { WebSocketConnectionState } from '../types/index.js'; +import type { + SubscribeCandlesParams, + PerpsPlatformDependencies, +} from '../types/index.js'; +import type { CandleData } from '../types/perps-types.js'; +import { coalescePerpsRestRequest } from '../utils/coalescePerpsRestRequest.js'; +import { ensureError, isAbortError } from '../utils/errorUtils.js'; +import { getPerpsConnectionAttemptContext } from '../utils/perpsConnectionAttemptContext.js'; + +/** + * Maximum number of reconnection attempts before giving up. + */ +const maxReconnectionAttempts = 10; + +/** + * Valid time intervals for historical candle data + * Uses CandlePeriod enum for type safety + */ +export type ValidCandleInterval = CandlePeriod; + +/** + * Wallet interface for HyperLiquid SDK operations. + * Extracted for reuse across initialize(), toggleTestnet(), and ensureSubscriptionClient() methods. + */ +export type HyperLiquidWalletParams = { + signTypedData: (params: { + domain: { + name: string; + version: string; + chainId: number; + verifyingContract: Hex; + }; + types: { + [key: string]: { name: string; type: string }[]; + }; + primaryType: string; + message: Record; + }) => Promise; + getChainId?: () => Promise; +}; + +// WebSocketConnectionState is now imported from controllers/types +// Re-export for backward compatibility with existing consumers +export { WebSocketConnectionState } from '../types/index.js'; + +/** + * Service for managing HyperLiquid SDK clients + * Handles initialization, transport creation, and client lifecycle + */ +export class HyperLiquidClientService { + #exchangeClient?: ExchangeClient; + + #infoClient?: InfoClient; // WebSocket transport (default) + + #infoClientHttp?: InfoClient; // HTTP transport (fallback) + + #subscriptionClient?: SubscriptionClient<{ + transport: WebSocketTransport; + }>; + + #wsTransport?: WebSocketTransport; + + #httpTransport?: HttpTransport; + + #walletParams?: HyperLiquidWalletParams; + + #isTestnet: boolean; + + #connectionState: WebSocketConnectionState = + WebSocketConnectionState.Disconnected; + + #disconnectionPromise: Promise | null = null; + + // Callback for SDK terminate event (fired when all reconnection attempts exhausted) + #onTerminateCallback: ((error: Error) => void) | null = null; + + #onReconnectCallback?: () => Promise; + + // Reconnection attempt counter + #reconnectionAttempt = 0; + + // Connection state change listeners for event-based notifications + readonly #connectionStateListeners: Set< + (state: WebSocketConnectionState, reconnectionAttempt: number) => void + > = new Set(); + + // Timeout reference for reconnection retry, tracked to enable cancellation on disconnect + #reconnectionRetryTimeout: ReturnType | null = null; + + // Platform dependencies for logging + readonly #deps: PerpsPlatformDependencies; + + constructor( + deps: PerpsPlatformDependencies, + options: { isTestnet?: boolean } = {}, + ) { + this.#deps = deps; + this.#isTestnet = options.isTestnet ?? false; + } + + /** + * Initialize all HyperLiquid SDK clients + * + * IMPORTANT: This method awaits transport.ready() to ensure the WebSocket is + * in OPEN state before marking initialization complete. This prevents race + * conditions where subscriptions are attempted before the WebSocket handshake + * completes (which would cause "subscribe error: undefined" errors). + * + * @param wallet - The wallet parameters for signing typed data. + */ + public async initialize(wallet: HyperLiquidWalletParams): Promise { + const network = this.#isTestnet ? 'testnet' : 'mainnet'; + const attemptContext = getPerpsConnectionAttemptContext(); + + try { + this.#updateConnectionState(WebSocketConnectionState.Connecting); + this.#walletParams = wallet; + this.#createTransports(); + + // Ensure transports are created + if (!this.#httpTransport || !this.#wsTransport) { + throw new Error('Failed to create transports'); + } + + this.#createAllClients(wallet); + + // Wait for WebSocket to actually be ready before setting CONNECTED + // This ensures we have a real connection, not just client objects + await this.#wsTransport.ready(); + + this.#updateConnectionState(WebSocketConnectionState.Connected); + + this.#deps.debugLogger.log('HyperLiquid SDK clients initialized', { + testnet: this.#isTestnet, + timestamp: new Date().toISOString(), + connectionState: this.#connectionState, + note: 'Using WebSocket for InfoClient (default), HTTP fallback available', + }); + } catch (error) { + // Cleanup on failure to prevent leaks and ensure isInitialized() returns false + // Clear clients first, then transports + this.#subscriptionClient = undefined; + this.#infoClient = undefined; + this.#infoClientHttp = undefined; + this.#exchangeClient = undefined; + + // Close WebSocket transport to release resources and event listeners + if (this.#wsTransport) { + try { + this.#wsTransport.close(); + } catch { + // Ignore cleanup errors + } + this.#wsTransport = undefined; + } + this.#httpTransport = undefined; + + const errorInstance = ensureError( + error, + 'HyperLiquidClientService.initialize', + ); + this.#updateConnectionState(WebSocketConnectionState.Disconnected); + + if (attemptContext?.suppressError) { + this.#deps.debugLogger.log( + 'HyperLiquid initialize failed during suppressed startup attempt', + { + error: errorInstance.message, + network, + source: attemptContext.source, + }, + ); + } else { + this.#deps.logger.error(errorInstance, { + tags: { + feature: PERPS_CONSTANTS.FeatureName, + service: 'HyperLiquidClientService', + network, + }, + context: { + name: 'sdk_initialization', + data: { + operation: 'initialize', + isTestnet: this.#isTestnet, + source: attemptContext?.source ?? 'unspecified', + }, + }, + }); + } + + throw error; + } + } + + /** + * Create HTTP and WebSocket transports + * - HTTP for InfoClient and ExchangeClient (request/response operations) + * - WebSocket for SubscriptionClient (real-time pub/sub) + * + * Both transports use SDK's built-in endpoint resolution via isTestnet flag + * + * @returns The created WebSocket transport instance. + */ + #createTransports(): WebSocketTransport { + // Prevent duplicate transport creation and listener accumulation + // This guards against re-entry if initialize() is called multiple times + // (e.g., after a failed initialization attempt that didn't properly clean up) + if (this.#wsTransport && this.#httpTransport) { + this.#deps.debugLogger.log( + 'HyperLiquid: Transports already exist, skipping creation', + ); + return this.#wsTransport; + } + + this.#deps.debugLogger.log('HyperLiquid: Creating transports', { + isTestnet: this.#isTestnet, + timestamp: new Date().toISOString(), + note: 'SDK will auto-select endpoints based on isTestnet flag', + }); + + // HTTP transport for request/response operations (InfoClient, ExchangeClient) + // SDK automatically selects: mainnet (https://api.hyperliquid.xyz) or testnet (https://api.hyperliquid-testnet.xyz) + this.#httpTransport = new HttpTransport({ + isTestnet: this.#isTestnet, + timeout: HYPERLIQUID_TRANSPORT_CONFIG.timeout, + }); + + // WebSocket transport for real-time subscriptions (SubscriptionClient) + // SDK automatically selects: mainnet (wss://api.hyperliquid.xyz/ws) or testnet (wss://api.hyperliquid-testnet.xyz/ws) + this.#wsTransport = new WebSocketTransport({ + isTestnet: this.#isTestnet, + ...HYPERLIQUID_TRANSPORT_CONFIG, + reconnect: HYPERLIQUID_TRANSPORT_CONFIG.reconnect, + }); + + // Listen for WebSocket termination (fired when SDK exhausts all reconnection attempts) + this.#wsTransport.socket.addEventListener('terminate', (event: Event) => { + const customEvent = event as CustomEvent; + this.#deps.debugLogger.log('HyperLiquid: WebSocket terminated', { + reason: customEvent.detail?.code, + timestamp: new Date().toISOString(), + }); + + this.#updateConnectionState(WebSocketConnectionState.Disconnected); + + if (this.#onTerminateCallback) { + const error = + customEvent.detail instanceof Error + ? customEvent.detail + : new Error( + `WebSocket terminated: ${customEvent.detail?.code ?? 'unknown'}`, + ); + this.#onTerminateCallback(error); + } + }); + + return this.#wsTransport; + } + + /** + * Create all SDK clients using the current transports. + * Shared by initialize() and #handleConnectionDrop() to avoid drift. + * + * @param wallet - Optional wallet params. Uses stored #walletParams when omitted (reconnection path). + */ + #createAllClients(wallet?: HyperLiquidWalletParams): void { + if (!this.#wsTransport || !this.#httpTransport) { + throw new Error('Transports must be created before clients'); + } + + this.#infoClient = new InfoClient({ transport: this.#wsTransport }); + this.#subscriptionClient = new SubscriptionClient({ + transport: this.#wsTransport, + }); + this.#createHttpClients(wallet); + } + + /** + * Create the HTTP-backed SDK clients. + * + * @param wallet - Optional wallet params. Uses stored #walletParams when omitted. + */ + #createHttpClients(wallet?: HyperLiquidWalletParams): void { + const effectiveWallet = wallet ?? this.#walletParams; + + if (!this.#httpTransport) { + throw new Error('HTTP transport must be created before clients'); + } + + this.#infoClientHttp = new InfoClient({ transport: this.#httpTransport }); + + if (effectiveWallet) { + this.#exchangeClient = new ExchangeClient({ + wallet: effectiveWallet as any, // eslint-disable-line @typescript-eslint/no-explicit-any -- Type widening for SDK compatibility + transport: this.#httpTransport, + }); + } else { + this.#exchangeClient = undefined; + } + } + + /** + * Toggle testnet mode and reinitialize clients + * + * @param wallet - The wallet parameters for signing typed data. + * @returns The new network name after toggling. + */ + public async toggleTestnet( + wallet: HyperLiquidWalletParams, + ): Promise { + this.#isTestnet = !this.#isTestnet; + await this.initialize(wallet); + return this.#isTestnet ? 'testnet' : 'mainnet'; + } + + /** + * Check if clients are properly initialized + * + * @returns True if all SDK clients are initialized. + */ + public isInitialized(): boolean { + return Boolean( + this.#exchangeClient && + this.#infoClient && + this.#infoClientHttp && + this.#subscriptionClient, + ); + } + + /** + * Ensure clients are initialized, throw if not + */ + public ensureInitialized(): void { + if (!this.isInitialized()) { + throw new Error(PERPS_ERROR_CODES.CLIENT_NOT_INITIALIZED); + } + } + + /** + * Recreate subscription client if needed (for reconnection scenarios) + * + * @param wallet - The wallet parameters for signing typed data. + */ + public async ensureSubscriptionClient( + wallet: HyperLiquidWalletParams, + ): Promise { + if (!this.#subscriptionClient) { + // A reconnect publishes its WebSocket clients only after transport.ready(). + // Do not start a competing initialize() while that attempt or its retry + // backoff is active; callers will observe an unavailable subscription + // client until the reconnect completes and restores tracked subscriptions. + if (this.#isReconnecting || this.#reconnectionRetryTimeout) { + return; + } + + this.#deps.debugLogger.log( + 'HyperLiquid: Recreating subscription client after disconnect', + ); + + if ( + this.#walletParams && + this.#connectionState === WebSocketConnectionState.Disconnected + ) { + await this.reconnect(); + } else { + await this.initialize(wallet); + } + } + } + + /** + * Get the exchange client + * + * @returns The initialized ExchangeClient instance. + */ + public getExchangeClient(): ExchangeClient { + if (!this.#exchangeClient) { + this.ensureInitialized(); + throw new Error(PERPS_ERROR_CODES.EXCHANGE_CLIENT_NOT_AVAILABLE); + } + return this.#exchangeClient; + } + + /** + * Get the info client + * + * @param options - The options for selecting the transport. + * @param options.useHttp - Force HTTP transport instead of WebSocket (default: false). + * @returns InfoClient instance with the selected transport. + */ + public getInfoClient(options?: { useHttp?: boolean }): InfoClient { + if (options?.useHttp) { + if (!this.#infoClientHttp) { + this.ensureInitialized(); + throw new Error(PERPS_ERROR_CODES.INFO_CLIENT_NOT_AVAILABLE); + } + return this.#infoClientHttp; + } + + this.ensureInitialized(); + if (!this.#infoClient) { + throw new Error(PERPS_ERROR_CODES.INFO_CLIENT_NOT_AVAILABLE); + } + return this.#infoClient; + } + + /** + * Get the subscription client + * + * @returns The SubscriptionClient instance, or undefined if not initialized. + */ + public getSubscriptionClient(): + | SubscriptionClient<{ transport: WebSocketTransport }> + | undefined { + if (!this.#subscriptionClient) { + this.#deps.debugLogger.log('SubscriptionClient not initialized'); + return undefined; + } + return this.#subscriptionClient; + } + + /** + * Ensures the WebSocket transport is in OPEN state and ready for subscriptions. + * This MUST be called before any subscription operations to prevent race conditions. + * + * The SDK's `transport.ready()` method: + * - Returns immediately if WebSocket is already in OPEN state + * - Waits for the "open" event if WebSocket is in CONNECTING state + * - Supports AbortSignal for timeout/cancellation + * + * @param options - The options for transport readiness check. + * @param options.timeoutMs - Maximum time to wait for transport ready (default 5000ms). + * @throws Error if transport not ready within timeout or subscription client unavailable. + */ + public async ensureTransportReady( + options: { timeoutMs?: number } = {}, + ): Promise { + const { timeoutMs = 5000 } = options; + const subscriptionClient = this.getSubscriptionClient(); + if (!subscriptionClient) { + throw new Error('Subscription client not initialized'); + } + + const controller = new AbortController(); + const timeoutId = setTimeout( + () => + controller.abort( + new Error(`WebSocket transport ready timeout after ${timeoutMs}ms`), + ), + timeoutMs, + ); + + try { + await subscriptionClient.config_.transport.ready(controller.signal); + } catch (error) { + if (controller.signal.aborted) { + throw new Error( + `WebSocket transport ready timeout after ${timeoutMs}ms`, + ); + } + throw ensureError(error, 'HyperLiquidClientService.ensureTransportReady'); + } finally { + clearTimeout(timeoutId); + } + } + + /** + * Get current network state + * + * @returns The current HyperLiquid network (mainnet or testnet). + */ + public getNetwork(): HyperLiquidNetwork { + return this.#isTestnet ? 'testnet' : 'mainnet'; + } + + /** + * Check if running on testnet + * + * @returns True if the service is in testnet mode. + */ + public isTestnetMode(): boolean { + return this.#isTestnet; + } + + /** + * Update testnet mode + * + * @param isTestnet - Whether to enable testnet mode. + */ + public setTestnetMode(isTestnet: boolean): void { + this.#isTestnet = isTestnet; + } + + /** + * Fetch historical candle data using the HyperLiquid SDK + * + * @param options - The candle fetch configuration. + * @param options.symbol - The asset symbol (e.g., "BTC", "ETH"). + * @param options.interval - The candle interval (e.g., "1m", "5m", "15m", "1h", "1d"). + * @param options.limit - Number of candles to fetch (default: 100). + * @param options.endTime - End timestamp in milliseconds (default: now). + * @param options.signal - Optional AbortSignal to cancel the fetch. + * @returns The historical candle data, or null if no data is available. + */ + public async fetchHistoricalCandles(options: { + symbol: string; + interval: ValidCandleInterval; + limit?: number; + endTime?: number; + signal?: AbortSignal; + }): Promise { + const { symbol, interval, limit = 100, endTime, signal } = options; + + if (signal?.aborted) { + const abortError = new Error('Aborted'); + abortError.name = 'AbortError'; + throw abortError; + } + + // Explicit endTime is a paging call — the caller owns that exact window + // and expects a fresh page. Coalescing per-millisecond endTimes produces + // keys that never dedupe and never evict (TTL-miss sweep only fires on + // re-access with the same key), so route paging straight to the SDK and + // only coalesce the live-snapshot path where all callers share 'now'. + if (endTime !== undefined) { + return this.#runCandleSnapshotFetch({ + symbol, + interval, + limit, + endTime, + signal, + }); + } + + // Live snapshot: coalesce across rapid market switches (pass 1 → pass 2 + // of the 10-market stress loop) so callers share one snapshot per + // (symbol, interval). + // Signal is intentionally dropped inside the coalesced fetch — the HL + // SDK charges weight for any request already sent, and dropping a + // per-caller abort lets the next caller reuse the in-flight/cached + // result instead of re-firing the REST. The WS stream keeps live + // candles fresh, so reusing the first-caller snapshot for up to the + // TTL is acceptable. + const cacheKey = [ + 'candleSnapshot', + this.#isTestnet ? 'testnet' : 'mainnet', + symbol, + interval, + limit, + ].join('|'); + + return coalescePerpsRestRequest( + cacheKey, + () => this.#runCandleSnapshotFetch({ symbol, interval, limit }), + { ttlMs: PERFORMANCE_CONFIG.PerpsCandleCoalesceTtlMs }, + ); + } + + async #runCandleSnapshotFetch(options: { + symbol: string; + interval: ValidCandleInterval; + limit: number; + endTime?: number; + signal?: AbortSignal; + }): Promise { + const { symbol, interval, limit, endTime, signal } = options; + try { + const now = endTime ?? Date.now(); + const intervalMs = this.#getIntervalMilliseconds(interval); + const startTime = now - limit * intervalMs; + + // Use HTTP transport for historical candle snapshots (request/response). + // This avoids the WebSocket abort race condition that causes 429s + // during rapid market switching on extension (#TAT-2954). + const infoClient = this.getInfoClient({ useHttp: true }); + const request = { + coin: symbol, // Map to HyperLiquid SDK's 'coin' parameter + interval, + startTime, + endTime: now, + }; + const data = signal + ? await infoClient.candleSnapshot(request, signal) + : await infoClient.candleSnapshot(request); + + if (Array.isArray(data) && data.length > 0) { + const candles = data.map((candle) => ({ + time: candle.t, // open time + open: candle.o.toString(), + high: candle.h.toString(), + low: candle.l.toString(), + close: candle.c.toString(), + volume: candle.v.toString(), + })); + + return { + symbol, + interval, + candles, + }; + } + + return { + symbol, + interval, + candles: [], + }; + } catch (error) { + const errorInstance = ensureError( + error, + 'HyperLiquidClientService.fetchHistoricalCandles', + ); + + if (isAbortError(error)) { + throw error; + } + + // Log to Sentry: prevents initial chart data load + this.#deps.logger.error(errorInstance, { + tags: { + feature: PERPS_CONSTANTS.FeatureName, + service: 'HyperLiquidClientService', + network: this.#isTestnet ? 'testnet' : 'mainnet', + }, + context: { + name: 'historical_candles_api', + data: { + operation: 'fetchHistoricalCandles', + symbol, + interval, + limit, + hasEndTime: endTime !== undefined, + }, + }, + }); + + throw error; + } + } + + /** + * Fetch the user's historical orders via the HyperLiquid SDK, coalesced + * across concurrent callers and cached for {@link PERFORMANCE_CONFIG.PerpsRestCoalesceTtlMs}. + * + * Both getOrders (service layer) and the getUserFills enrichment sidecar + * (fills→order-type resolution for TP/SL pills in activity) hit the same + * `historicalOrders` info-post. Routing both through this wrapper means the + * enrichment path rides the same cache as an explicit activity-page fetch, + * so rapid market switching never fires redundant HL traffic. + * + * Pass `forceRefresh: true` to bypass the coalesce cache end-to-end + * (hooks → controller → MarketDataService → provider → this method), which + * is required for pull-to-refresh to fetch fresh data from the network. + * + * @param userAddress - The user's 0x address to query. + * @param options - Optional cache-control options. + * @param options.forceRefresh - When true, bypasses the coalesce cache. + * @returns Array of historical orders, empty array on SDK null. + */ + public async fetchHistoricalOrders( + userAddress: Hex, + options?: { forceRefresh?: boolean }, + ): Promise { + this.ensureInitialized(); + + const cacheKey = [ + 'historicalOrders', + this.#isTestnet ? 'testnet' : 'mainnet', + userAddress.toLowerCase(), + ].join('|'); + + return coalescePerpsRestRequest( + cacheKey, + () => this.#runHistoricalOrdersFetch(userAddress), + { forceRefresh: options?.forceRefresh }, + ); + } + + async #runHistoricalOrdersFetch( + userAddress: Hex, + ): Promise { + const infoClient = this.getInfoClient(); + const result = await infoClient.historicalOrders({ user: userAddress }); + return result ?? []; + } + + /** + * Subscribe to candle updates via WebSocket + * + * @param root0 - The subscription parameters. + * @param root0.symbol - The asset symbol (e.g., "BTC", "ETH"). + * @param root0.interval - The candle interval (e.g., "1m", "5m", "15m"). + * @param root0.duration - Optional time duration for calculating initial fetch size. + * @param root0.callback - Function called with updated candle data. + * @param root0.onError - Optional function called if subscription initialization fails. + * @returns Cleanup function to unsubscribe. + */ + public subscribeToCandles({ + symbol, + interval, + duration, + callback, + onError, + }: SubscribeCandlesParams): () => void { + this.ensureInitialized(); + + const subscriptionClient = this.getSubscriptionClient(); + if (!subscriptionClient) { + throw new Error(PERPS_ERROR_CODES.SUBSCRIPTION_CLIENT_NOT_AVAILABLE); + } + + let currentCandleData: CandleData | null = null; + let wsUnsubscribe: (() => void) | null = null; + let isUnsubscribed = false; + let hasUnsubscribeStarted = false; + // Store the subscription promise to enable cleanup even when pending + // This fixes a race condition where component unmounts before subscription resolves + type CandleSubscription = Awaited< + ReturnType + >; + let subscriptionPromise: Promise | null = null; + + const unsubscribeFromCandles = (subscription: CandleSubscription): void => { + if (hasUnsubscribeStarted) { + return; + } + hasUnsubscribeStarted = true; + + const logUnsubscribeError = (error: unknown): void => { + const errorInstance = ensureError( + error, + 'HyperLiquidClientService.subscribeToCandles', + ); + + if (errorInstance.message.startsWith('Already unsubscribed')) { + return; + } + + this.#deps.logger.error(errorInstance, { + tags: { + feature: PERPS_CONSTANTS.FeatureName, + service: 'HyperLiquidClientService', + network: this.#isTestnet ? 'testnet' : 'mainnet', + }, + context: { + name: 'websocket_unsubscription', + data: { + operation: 'subscribeToCandles', + symbol, + interval, + phase: 'ws_unsubscription', + }, + }, + }); + }; + + try { + Promise.resolve(subscription.unsubscribe()).catch(logUnsubscribeError); + } catch (error) { + logUnsubscribeError(error); + } + }; + + // AbortController to cancel in-flight REST calls (candleSnapshot) on cleanup. + // Prevents rate limit exhaustion when rapidly switching markets (#28141). + const abortController = new AbortController(); + + // Calculate initial fetch size dynamically based on duration and interval + // Match main branch behavior: up to 500 candles initially + const initialLimit = duration + ? Math.min(calculateCandleCount(duration, interval), 500) + : 100; // Default to 100 if no duration provided + + // 1. Fetch initial historical data, then subscribe to WebSocket updates + // Using an async IIFE to avoid nested promises and callback-in-promise issues + const initAndSubscribe = async (): Promise => { + try { + const initialData = await this.fetchHistoricalCandles({ + symbol, + interval, + limit: initialLimit, + signal: abortController.signal, + }); + + // Don't proceed if already unsubscribed + if (isUnsubscribed) { + return; + } + + currentCandleData = initialData; + if (currentCandleData) { + callback(currentCandleData); + } + + // 2. Subscribe to WebSocket for new candles + // HyperLiquid SDK uses 'coin' terminology + // Store the promise so cleanup can wait for it if needed + subscriptionPromise = subscriptionClient.candle( + { coin: symbol, interval }, // Map to HyperLiquid SDK's 'coin' parameter + (candleEvent) => { + // Don't process events if already unsubscribed + if (isUnsubscribed) { + return; + } + + // Transform SDK CandleEvent to our Candle format + const newCandle = { + time: candleEvent.t, + open: candleEvent.o.toString(), + high: candleEvent.h.toString(), + low: candleEvent.l.toString(), + close: candleEvent.c.toString(), + volume: candleEvent.v.toString(), + }; + + if (currentCandleData) { + // Check if this is an update to the last candle or a new candle + const { candles } = currentCandleData; + const lastCandle = candles[candles.length - 1]; + + if (lastCandle?.time === newCandle.time) { + // Update existing candle (live candle update) + // Create new array with updated last element to trigger React re-render + currentCandleData = { + ...currentCandleData, + candles: [...candles.slice(0, -1), newCandle], + }; + } else { + // New candle (completed candle) + // Create new array with added element to trigger React re-render + currentCandleData = { + ...currentCandleData, + candles: [...candles, newCandle], + }; + } + } else { + currentCandleData = { + symbol, + interval, + candles: [newCandle], + }; + } + + callback(currentCandleData); + }, + ); + + // Store cleanup function when subscription resolves + try { + const sub = await subscriptionPromise; + wsUnsubscribe = (): void => unsubscribeFromCandles(sub); + // If already unsubscribed while waiting, clean up immediately + if (isUnsubscribed) { + wsUnsubscribe(); + wsUnsubscribe = null; + } + } catch (error) { + const errorInstance = ensureError( + error, + 'HyperLiquidClientService.subscribeToCandles', + ); + + // Log to Sentry: WebSocket subscription failure prevents live updates + this.#deps.logger.error(errorInstance, { + tags: { + feature: PERPS_CONSTANTS.FeatureName, + service: 'HyperLiquidClientService', + network: this.#isTestnet ? 'testnet' : 'mainnet', + }, + context: { + name: 'websocket_subscription', + data: { + operation: 'subscribeToCandles', + symbol, + interval, + phase: 'ws_subscription', + }, + }, + }); + + // Notify caller of error + onError?.(errorInstance); + } + } catch (error) { + // Skip logging and notification for intentional abort (user navigated away) + if (abortController.signal.aborted) { + return; + } + + const errorInstance = ensureError( + error, + 'HyperLiquidClientService.subscribeToCandles', + ); + + // Log to Sentry: initial fetch failure blocks chart completely + this.#deps.logger.error(errorInstance, { + tags: { + feature: PERPS_CONSTANTS.FeatureName, + service: 'HyperLiquidClientService', + network: this.#isTestnet ? 'testnet' : 'mainnet', + }, + context: { + name: 'initial_candles_fetch', + data: { + operation: 'subscribeToCandles', + symbol, + interval, + phase: 'initial_fetch', + initialLimit, + }, + }, + }); + + // Notify caller of error + onError?.(errorInstance); + } + }; + + // Fire-and-forget the async initialization + initAndSubscribe().catch(() => { + // Error already handled inside initAndSubscribe + }); + + // Return cleanup function + return () => { + isUnsubscribed = true; + // Cancel any in-flight REST calls (candleSnapshot) to conserve rate limit budget (#28141) + abortController.abort(); + if (wsUnsubscribe) { + // Subscription already resolved - unsubscribe directly + wsUnsubscribe(); + wsUnsubscribe = null; + } else if (subscriptionPromise) { + // Subscription promise still pending - wait for it and clean up + // This prevents WebSocket subscription leaks when component unmounts + // before the subscription promise resolves + subscriptionPromise + .then((sub) => unsubscribeFromCandles(sub)) + .catch(() => { + // Ignore errors during cleanup - subscription may have failed + }); + subscriptionPromise = null; + } + }; + } + + /** + * Convert interval string to milliseconds + * + * @param interval - The candle period interval to convert. + * @returns The interval duration in milliseconds. + */ + #getIntervalMilliseconds(interval: CandlePeriod): number { + const intervalMap: Record = { + [CandlePeriod.OneMinute]: 1 * 60 * 1000, + [CandlePeriod.ThreeMinutes]: 3 * 60 * 1000, + [CandlePeriod.FiveMinutes]: 5 * 60 * 1000, + [CandlePeriod.FifteenMinutes]: 15 * 60 * 1000, + [CandlePeriod.ThirtyMinutes]: 30 * 60 * 1000, + [CandlePeriod.OneHour]: 60 * 60 * 1000, + [CandlePeriod.TwoHours]: 2 * 60 * 60 * 1000, + [CandlePeriod.FourHours]: 4 * 60 * 60 * 1000, + [CandlePeriod.EightHours]: 8 * 60 * 60 * 1000, + [CandlePeriod.TwelveHours]: 12 * 60 * 60 * 1000, + [CandlePeriod.OneDay]: 24 * 60 * 60 * 1000, + [CandlePeriod.ThreeDays]: 3 * 24 * 60 * 60 * 1000, + [CandlePeriod.OneWeek]: 7 * 24 * 60 * 60 * 1000, + [CandlePeriod.OneMonth]: 30 * 24 * 60 * 60 * 1000, // Approximate + }; + + return intervalMap[interval]; + } + + /** + * Disconnect and cleanup all clients + * + * @returns A promise that resolves when disconnection is complete. + */ + public async disconnect(): Promise { + // Await existing promise if already disconnecting + if (this.#disconnectionPromise) { + await this.#disconnectionPromise; + return; + } + + // If already disconnected, return immediately + if (this.#connectionState === WebSocketConnectionState.Disconnected) { + return; + } + + // Create and store the disconnection promise + this.#disconnectionPromise = this.#performDisconnection(); + + try { + await this.#disconnectionPromise; + } finally { + this.#disconnectionPromise = null; + } + } + + async #performDisconnection(): Promise { + try { + this.#updateConnectionState(WebSocketConnectionState.Disconnecting); + + this.#deps.debugLogger.log('HyperLiquid: Disconnecting SDK clients', { + isTestnet: this.#isTestnet, + timestamp: new Date().toISOString(), + connectionState: this.#connectionState, + }); + + // Clear callbacks + this.#onReconnectCallback = undefined; + this.#onTerminateCallback = null; + + // Cancel any pending reconnection retry timeout + if (this.#reconnectionRetryTimeout) { + clearTimeout(this.#reconnectionRetryTimeout); + this.#reconnectionRetryTimeout = null; + } + + // Clear connection state listeners to prevent stale callbacks + this.#connectionStateListeners.clear(); + + // Reset reconnection flag to allow future manual retries + // This prevents a race condition where disconnecting during an active + // reconnection attempt could leave the flag stuck, blocking subsequent retries + this.#isReconnecting = false; + + // Close WebSocket transport only (HTTP is stateless) + if (this.#wsTransport) { + try { + this.#wsTransport.close(); + this.#deps.debugLogger.log( + 'HyperLiquid: Closed WebSocket transport', + { + timestamp: new Date().toISOString(), + }, + ); + } catch (error) { + this.#deps.logger.error( + ensureError(error, 'HyperLiquidClientService.performDisconnection'), + { + tags: { feature: PERPS_CONSTANTS.FeatureName }, + context: { + name: 'HyperLiquidClientService.performDisconnection', + data: { action: 'close_transport' }, + }, + }, + ); + } + } + + // Clear client references + this.#subscriptionClient = undefined; + this.#exchangeClient = undefined; + this.#infoClient = undefined; + this.#infoClientHttp = undefined; + this.#wsTransport = undefined; + this.#httpTransport = undefined; + + this.#updateConnectionState(WebSocketConnectionState.Disconnected); + + this.#deps.debugLogger.log( + 'HyperLiquid: SDK clients fully disconnected', + { + timestamp: new Date().toISOString(), + connectionState: this.#connectionState, + }, + ); + } catch (error) { + this.#updateConnectionState(WebSocketConnectionState.Disconnected); + this.#deps.logger.error( + ensureError(error, 'HyperLiquidClientService.performDisconnection'), + { + tags: { feature: PERPS_CONSTANTS.FeatureName }, + context: { + name: 'HyperLiquidClientService.performDisconnection', + data: { action: 'outer_catch' }, + }, + }, + ); + throw error; + } + } + + /** + * Get current WebSocket connection state + * + * @returns The current WebSocket connection state. + */ + public getConnectionState(): WebSocketConnectionState { + return this.#connectionState; + } + + /** + * Check if WebSocket is fully disconnected + * + * @returns True if the WebSocket is in disconnected state. + */ + public isDisconnected(): boolean { + return this.#connectionState === WebSocketConnectionState.Disconnected; + } + + /** + * Set callback to be invoked when reconnection is needed + * This allows the service to notify external components (like PerpsConnectionManager) + * when a connection drop is detected + * + * @param callback - The async callback to invoke when reconnection is needed. + */ + public setOnReconnectCallback(callback: () => Promise): void { + this.#onReconnectCallback = callback; + } + + /** + * Set callback for WebSocket termination events + * Called when the SDK exhausts all reconnection attempts + * + * @param callback - The callback to invoke on termination, or null to clear. + */ + public setOnTerminateCallback( + callback: ((error: Error) => void) | null, + ): void { + this.#onTerminateCallback = callback; + } + + /** + * Subscribe to connection state changes. + * The listener will be called immediately with the current state and whenever the state changes. + * + * @param listener - Callback function that receives the new connection state and reconnection attempt + * @returns Unsubscribe function to remove the listener + */ + public subscribeToConnectionState( + listener: ( + state: WebSocketConnectionState, + reconnectionAttempt: number, + ) => void, + ): () => void { + this.#connectionStateListeners.add(listener); + + // Immediately notify with current state + // Wrap in try-catch to match notifyConnectionStateListeners behavior + // This ensures the unsubscribe function is always returned even if listener throws + try { + listener(this.#connectionState, this.#reconnectionAttempt); + } catch { + // Ignore errors in listeners to prevent breaking subscription mechanism + // If listener throws, it will be removed when unsubscribe is called + } + + // Return unsubscribe function + return () => { + this.#connectionStateListeners.delete(listener); + }; + } + + /** + * Update connection state and notify all listeners + * Always notifies if state changes OR if we're in CONNECTING state (to update attempt count) + * + * @param newState - The new WebSocket connection state. + */ + #updateConnectionState(newState: WebSocketConnectionState): void { + const previousState = this.#connectionState; + const stateChanged = previousState !== newState; + const isReconnectionAttempt = + newState === WebSocketConnectionState.Connecting && + this.#reconnectionAttempt > 0; + + this.#connectionState = newState; + + // Reset reconnection attempt counter when successfully connected + if (newState === WebSocketConnectionState.Connected) { + this.#reconnectionAttempt = 0; + } + + // Notify if state changed OR if this is a reconnection attempt (to update attempt count) + if (stateChanged || isReconnectionAttempt) { + this.#notifyConnectionStateListeners(); + } + } + + /** + * Notify all connection state listeners of the current state + */ + #notifyConnectionStateListeners(): void { + this.#connectionStateListeners.forEach((listener) => { + try { + listener(this.#connectionState, this.#reconnectionAttempt); + } catch { + // Ignore errors in listeners to prevent breaking other listeners + } + }); + } + + // Flag to prevent concurrent reconnection attempts + #isReconnecting = false; + + /** + * Manually trigger a reconnection attempt. + * This is exposed for UI retry buttons when user wants to force reconnection. + * Resets the reconnection attempt counter to allow retrying after max attempts. + */ + public async reconnect(): Promise { + this.#deps.debugLogger.log( + '[HyperLiquidClientService] reconnect() called', + { + previousAttempt: this.#reconnectionAttempt, + currentState: this.#connectionState, + }, + ); + // Reset attempt counter when user manually triggers retry + this.#reconnectionAttempt = 0; + await this.#handleConnectionDrop(); + this.#deps.debugLogger.log( + '[HyperLiquidClientService] reconnect() completed', + { + newState: this.#connectionState, + }, + ); + } + + /** + * Handle detected connection drop + * Recreates WebSocket transport and notifies callback to restore subscriptions + * Will give up after maxReconnectionAttempts and mark status as disconnected + */ + async #handleConnectionDrop(): Promise { + // Prevent multiple simultaneous reconnection attempts + if (this.#isReconnecting) { + return; + } + + this.#isReconnecting = true; + + // Increment reconnection attempt counter + this.#reconnectionAttempt += 1; + + // Check if we've exceeded max retry attempts + if (this.#reconnectionAttempt > maxReconnectionAttempts) { + this.#isReconnecting = false; + this.#updateConnectionState(WebSocketConnectionState.Disconnected); + return; + } + + try { + this.#updateConnectionState(WebSocketConnectionState.Connecting); + + // Close existing WebSocket transport and clear references + // so createTransports() will create fresh ones + if (this.#wsTransport) { + try { + this.#wsTransport.close(); + } catch { + // Ignore errors during close - transport may already be dead + } + } + this.#wsTransport = undefined; + this.#httpTransport = undefined; + + // WebSocket clients are unavailable throughout the reconnect. HTTP + // clients remain usable while the new socket is staged and verified. + this.#subscriptionClient = undefined; + this.#infoClient = undefined; + + // Recreate transports (both WS and HTTP) + const newWsTransport = this.#createTransports(); + + const newInfoClient = new InfoClient({ transport: newWsTransport }); + const newSubscriptionClient = new SubscriptionClient({ + transport: newWsTransport, + }); + this.#createHttpClients(); + + await newWsTransport.ready(); + + // Publish WebSocket clients only after the transport is usable. This + // keeps isInitialized() false and blocks WS-backed access during a + // failed or in-flight reconnect without disabling HTTP-backed trading. + this.#infoClient = newInfoClient; + this.#subscriptionClient = newSubscriptionClient; + + this.#deps.debugLogger.log( + 'HyperLiquid: Transport ready, restoring subscriptions', + { timestamp: new Date().toISOString() }, + ); + + // NOW safe to restore subscriptions + if (this.#onReconnectCallback) { + await this.#onReconnectCallback(); + } + + // Cancel any pending retry timeout from previous failed attempts + if (this.#reconnectionRetryTimeout) { + clearTimeout(this.#reconnectionRetryTimeout); + this.#reconnectionRetryTimeout = null; + } + + this.#updateConnectionState(WebSocketConnectionState.Connected); + this.#isReconnecting = false; + } catch { + // The staged WebSocket clients were never published. Keep the HTTP + // clients alive so exchange writes and explicit HTTP info reads remain + // available while the WebSocket retry loop continues. + this.#subscriptionClient = undefined; + this.#infoClient = undefined; + + if (this.#wsTransport) { + try { + this.#wsTransport.close(); + } catch { + // Ignore cleanup errors - transport may already be dead + } + } + this.#wsTransport = undefined; + + // Reset flag before scheduling retry so the next attempt can proceed + this.#isReconnecting = false; + + // Check if we've exceeded max retry attempts + if (this.#reconnectionAttempt >= maxReconnectionAttempts) { + this.#updateConnectionState(WebSocketConnectionState.Disconnected); + return; + } + + // Reconnection failed - schedule a retry after a delay + // Store timeout reference so it can be cancelled on intentional disconnect + this.#reconnectionRetryTimeout = setTimeout(() => { + this.#reconnectionRetryTimeout = null; // Clear reference after execution + // Only retry if we haven't been intentionally disconnected + // and no manual reconnect() is already in progress + // Note: State may be CONNECTING or DISCONNECTED (if terminate event fired during reconnect) + if ( + (this.#connectionState === WebSocketConnectionState.Connecting || + this.#connectionState === WebSocketConnectionState.Disconnected) && + !this.#disconnectionPromise && + !this.#isReconnecting + ) { + this.#handleConnectionDrop().catch(() => { + // Error already handled inside #handleConnectionDrop + }); + } + }, PERPS_CONSTANTS.ReconnectionRetryDelayMs); + } + } +} diff --git a/packages/perps-controller/src/services/HyperLiquidSubscriptionService.ts b/packages/perps-controller/src/services/HyperLiquidSubscriptionService.ts new file mode 100644 index 00000000000..d3916924b34 --- /dev/null +++ b/packages/perps-controller/src/services/HyperLiquidSubscriptionService.ts @@ -0,0 +1,4816 @@ +import type { CaipAccountId } from '@metamask/utils'; +import { hasProperty } from '@metamask/utils'; +import type { + ISubscription, + AllMidsWsEvent, + WebData3WsEvent, + UserFillsWsEvent, + ActiveAssetCtxWsEvent, + ActiveSpotAssetCtxWsEvent, + BboWsEvent, + L2BookResponse, + AssetCtxsWsEvent, + FastAssetCtxsWsEvent, + FrontendOpenOrdersResponse, + ClearinghouseStateWsEvent, + OpenOrdersWsEvent, + SpotStateWsEvent, +} from '@nktkas/hyperliquid'; + +import { HYPERLIQUID_CONFIG } from '../constants/hyperLiquidConfig.js'; +import { + TP_SL_CONFIG, + PERPS_CONSTANTS, + ABSTRACTION_MODE_REFRESH_THROTTLE_MS, +} from '../constants/perpsConfig.js'; +import type { + SpotClearinghouseStateResponse, + HyperLiquidAbstractionMode, +} from '../types/hyperliquid-types.js'; +import { hyperLiquidModeFoldsSpot } from '../types/hyperliquid-types.js'; +import { WebSocketConnectionState } from '../types/index.js'; +import type { + PriceUpdate, + Position, + OrderFill, + Order, + AccountState, + SubscribePricesParams, + SubscribePositionsParams, + SubscribeOrderFillsParams, + SubscribeOrdersParams, + SubscribeAccountParams, + SubscribeOICapsParams, + SubscribeOrderBookParams, + OrderBookData, + OrderBookLevel, + PerpsPlatformDependencies, + PerpsLogger, + PositionTriggerOrder, +} from '../types/index.js'; +import { + addSpotBalanceToAccountState, + calculateWeightedReturnOnEquity, +} from '../utils/accountUtils.js'; +import type { AddSpotBalanceOptions } from '../utils/accountUtils.js'; +import { ensureError } from '../utils/errorUtils.js'; +import { + adaptPositionFromSDK, + adaptOrderFromSDK, + adaptAccountStateFromSDK, + parseAssetName, +} from '../utils/hyperLiquidAdapter.js'; +import { processBboData } from '../utils/hyperLiquidOrderBookProcessor.js'; +import { + calculateOpenInterestUSD, + isMarketTradable, +} from '../utils/marketDataTransform.js'; +import { + buildPositionTriggerOrderFromOrder, + hashTriggerOrders, + resolvePositionTriggerSummaryPrice, +} from '../utils/orderTypes.js'; +import type { HyperLiquidClientService } from './HyperLiquidClientService.js'; +import type { HyperLiquidWalletService } from './HyperLiquidWalletService.js'; + +/** + * Per-symbol view of the trigger orders attached to a position, keyed by symbol. + */ +type PositionTriggerOrderMap = Map< + string, + { + takeProfitOrders: PositionTriggerOrder[]; + stopLossOrders: PositionTriggerOrder[]; + } +>; + +/** + * Service for managing HyperLiquid WebSocket subscriptions + * Implements singleton subscription architecture with reference counting + */ +export class HyperLiquidSubscriptionService { + // Service dependencies + readonly #clientService: HyperLiquidClientService; + + readonly #walletService: HyperLiquidWalletService; + + // HIP-3 feature flag support + #hip3Enabled: boolean; + + #enabledDexs: string[]; // DEX identification (maps webData3 indices to DEX names) + + #allowlistMarkets: string[]; // Market filtering (allowlist) + + #blocklistMarkets: string[]; // Market filtering (blocklist) + + // Max market-vs-oracle price deviation before a market is reported untradable + readonly #priceDeviationLimit: number; + + readonly #discoverEnabledDexs?: () => Promise; + + #discoveredDexNames: string[] = []; // DEX order for mapping webData3 perpDexStates indices + + // DEX discovery synchronization - allows subscriptions to wait for HIP-3 DEX discovery + #dexDiscoveryPromise: Promise | null = null; + + #dexDiscoveryResolver: (() => void) | null = null; + + // Track DEXs for synchronized position notifications + // Ensures all DEXs send initial data before notifying subscribers + #expectedDexs: Set = new Set(); + + #initializedDexs: Set = new Set(); + + // Subscriber collections + readonly #priceSubscribers = new Map< + string, + Set<(prices: PriceUpdate[]) => void> + >(); + + readonly #positionSubscribers = new Set<(positions: Position[]) => void>(); + + // Order fill subscribers keyed by accountId (normalized: undefined -> 'default') + readonly #orderFillSubscribers = new Map< + string, + Set<(fills: OrderFill[], isSnapshot?: boolean) => void> + >(); + + readonly #orderSubscribers = new Set<(orders: Order[]) => void>(); + + readonly #accountSubscribers = new Set<(account: AccountState) => void>(); + + // Track which subscribers want market data + readonly #marketDataSubscribers = new Map< + string, + Set<(prices: PriceUpdate[]) => void> + >(); + + // Track which subscribers want top-of-book (best bid/ask) data + readonly #orderBookSubscribers = new Map< + string, + Set<(prices: PriceUpdate[]) => void> + >(); + + // Global singleton subscriptions + #globalAllMidsSubscription?: ISubscription; + + #globalAllMidsPromise?: Promise; // Track in-progress subscription + + // fastAssetCtxs (TAT-3387): single global feed (no per-DEX param) that owns + // the latency-sensitive mark/mid price path at HyperLiquid's fast (~5s) + // cadence, now that the public assetCtxs feed has been slowed down. + #globalFastAssetCtxsSubscription?: ISubscription; + + #globalFastAssetCtxsPromise?: Promise; // Track in-progress subscription + + // Coins with a usable price (midPx/markPx) from any fastAssetCtxs event + // (snapshot or diff). Once a coin appears here, the per-DEX assetCtxs + // handler stops writing its price into #cachedPriceData, since + // fastAssetCtxs is the fresher/authoritative source for that coin going + // forward. A coin is only added once fastAssetCtxs has actually supplied a + // usable price for it (not merely appeared in a message with a null/absent + // price), so ownership is never claimed without a fast price backing it — + // otherwise assetCtxs, the coin's only remaining price source, would be + // suppressed with nothing to fall back on. Cleared on clearAll() and when + // the fastAssetCtxs subscription is re-established after a reconnect, so + // assetCtxs can serve prices again until a fresh snapshot arrives. + readonly #fastAssetCtxsCoins = new Set(); + + readonly #globalActiveAssetSubscriptions = new Map(); + + // Track in-progress activeAssetCtx subscription promises to prevent leaks + // when cleanup fires before the async subscription resolves (#28141) + readonly #pendingActiveAssetPromises = new Map< + string, + Promise + >(); + + readonly #globalBboSubscriptions = new Map(); + + // Track in-progress BBO subscription promises to prevent leaks (#28141) + readonly #pendingBboPromises = new Map>(); + + // Order fill subscriptions keyed by accountId (normalized: undefined -> 'default') + readonly #orderFillSubscriptions = new Map(); + + readonly #spotStateSubscriptions = new Map(); + + readonly #spotStateSubscriptionPromises = new Map>(); + + // Bumped on cleanup so in-flight #ensureSpotStateSubscription + // continuations discard their subscription instead of rehydrating + // #spotStateSubscriptions after clearAll/cleanupSharedWebData3. + #spotStateSubscriptionGeneration = 0; + + readonly #symbolSubscriberCounts = new Map(); + + readonly #dexSubscriberCounts = new Map(); // Track subscribers per DEX for assetCtxs + + // Multi-DEX webData3 subscription for all user data (positions, orders, account, OI caps) + readonly #webData3Subscriptions = new Map(); // Key: dex name ('' for main) + + #webData3SubscriptionPromise?: Promise; + + #positionSubscriberCount = 0; + + #orderSubscriberCount = 0; + + #accountSubscriberCount = 0; + + #oiCapSubscriberCount = 0; + + // Multi-DEX data caches + readonly #dexPositionsCache = new Map(); // Per-DEX positions + + readonly #dexOrdersCache = new Map(); // Per-DEX orders + + readonly #dexAccountCache = new Map(); // Per-DEX account state + + #cachedSpotState: SpotClearinghouseStateResponse | null = null; + + // HL abstraction mode (Unified / Standard / Portfolio / DEX-abstraction). + // Gates spot→perps folding in addSpotBalanceToAccountState. Keyed by user + // address so an in-flight refresh or late response for one wallet cannot + // overwrite another wallet's fold semantics after an account switch. + readonly #abstractionModeByUser = new Map< + string, + HyperLiquidAbstractionMode + >(); + + // Timestamp of the last successful WS-driven userAbstraction refresh per + // user. This throttle intentionally does not count the initial bootstrap + // fetch so the first spot tick after app launch can still detect an HL-web + // mode flip immediately. + readonly #abstractionModeLastWsRefreshAtByUser = new Map(); + + // In-flight promises for WS-triggered refreshes, keyed by user so concurrent + // ticks for the same wallet share one fetch while account switches can start + // their own refresh immediately. + readonly #abstractionModeInflightByUser = new Map>(); + + #cachedSpotStateUserAddress: string | null = null; + + #spotStatePromise?: Promise; + + #spotStatePromiseUserAddress?: string; + + // Monotonic token bumped on cleanUp/clearAll and on each new fetch. + // Any in-flight #refreshSpotState that resolves with a stale token + // discards its result, preventing cross-account cache contamination + // when accounts are switched mid-fetch. + #spotStateGeneration = 0; + + #cachedPositions: Position[] | null = null; // Aggregated positions + + #cachedOrders: Order[] | null = null; // Aggregated orders + + #cachedAccount: AccountState | null = null; // Aggregated account + + #ordersCacheInitialized = false; // Track if orders cache has received WebSocket data + + #positionsCacheInitialized = false; // Track if positions cache has received WebSocket data + + // OI Cap tracking (from webData3.perpDexStates[].perpsAtOpenInterestCap) + readonly #oiCapSubscribers = new Set<(caps: string[]) => void>(); + + #cachedOICaps: string[] = []; + + #cachedOICapsHash = ''; + + #oiCapsCacheInitialized = false; + + // Global price data cache + #cachedPriceData: Map | null = null; + + // Raw allMids WS snapshots keyed by DEX ('' for main DEX) + readonly #allMidsSnapshots = new Map>(); + + // Fills cache for cache-first pattern (similar to price caching) + #cachedFills: OrderFill[] | null = null; + + #fillsCacheInitialized = false; + + // HIP-3: assetCtxs subscriptions for multi-DEX market data + readonly #assetCtxsSubscriptions = new Map(); // Key: dex name ('' for main) + + readonly #dexAssetCtxsCache = new Map(); // Per-DEX asset contexts + + readonly #assetCtxsSubscriptionPromises = new Map>(); // Track in-progress subscriptions + + readonly #dexAllMidsSubscriptions = new Map(); + + readonly #dexAllMidsSubscriptionPromises = new Map>(); + + readonly #clearinghouseStateSubscriptions = new Map(); // Key: dex name ('' for main) + + readonly #openOrdersSubscriptions = new Map(); // Key: dex name ('' for main) + + // Pending subscription promises to prevent race conditions + // When multiple calls to ensure*Subscription happen concurrently, this ensures + // only one subscription is created per DEX (others wait for the pending promise) + readonly #pendingClearinghouseSubscriptions = new Map< + string, + Promise + >(); + + readonly #pendingOpenOrdersSubscriptions = new Map>(); + + // Meta cache per DEX - populated by metaAndAssetCtxs, used by createAssetCtxsSubscription + // This avoids redundant meta() API calls since metaAndAssetCtxs already returns meta data + readonly #dexMetaCache = new Map< + string, + { + universe: { + name: string; + szDecimals: number; + maxLeverage: number; + }[]; + } + >(); + + // Order book data cache + readonly #orderBookCache = new Map< + string, + { + bestBid?: string; + bestAsk?: string; + spread?: string; + lastUpdated: number; + } + >(); + + // Market data caching for multi-channel consolidation + readonly #marketDataCache = new Map< + string, + { + prevDayPx?: number; + funding?: number; + openInterest?: number; + volume24h?: number; + oraclePrice?: number; + lastUpdated: number; + // Fast-stream price from activeAssetCtx (midPx preferred, markPx fallback). + // Populated only for symbols with includeMarketData: true subscriptions. + // #notifyAllPriceSubscribers projects this onto the allMids baseline for + // focused (includeMarketData: true) subscribers only; list subscribers + // always receive the raw allMids price. + activeAssetCtxPrice?: number; + // Timestamp of the last activeAssetCtx price update. + // Used by #notifyAllPriceSubscribers and #projectPriceUpdate for staleness checks. + priceLastUpdated?: number; + } + >(); + + // Stale threshold for the fast-stream price preference. If the last + // activeAssetCtx price update is older than this, the allMids baseline is + // used for focused subscribers. + static readonly #activeAssetCtxPriceTtlMs = 10_000; + + // Flag to suppress error logging during intentional disconnect + // Set in clearAll() and never reset (service instance is discarded after disconnect) + #isClearing = false; + + readonly #restoreRetryTimeouts = new Map< + string, + ReturnType + >(); + + // Platform dependencies for logging + readonly #deps: PerpsPlatformDependencies; + + constructor( + clientService: HyperLiquidClientService, + walletService: HyperLiquidWalletService, + platformDependencies: PerpsPlatformDependencies, + hip3Enabled?: boolean, + enabledDexs?: string[], + allowlistMarkets?: string[], + blocklistMarkets?: string[], + priceDeviationLimit?: number, + discoverEnabledDexs?: () => Promise, + ) { + this.#clientService = clientService; + this.#walletService = walletService; + this.#deps = platformDependencies; + this.#hip3Enabled = hip3Enabled ?? false; + this.#enabledDexs = enabledDexs ?? []; + this.#discoveredDexNames = enabledDexs ?? []; + this.#allowlistMarkets = allowlistMarkets ?? []; + this.#blocklistMarkets = blocklistMarkets ?? []; + this.#priceDeviationLimit = + priceDeviationLimit ?? HYPERLIQUID_CONFIG.OraclePriceDeviationLimit; + this.#discoverEnabledDexs = discoverEnabledDexs; + } + + /** + * Conditionally log an error to Sentry, suppressing during intentional disconnect. + * When `clearAll()` is called, pending subscription promises reject with + * `WebSocketRequestError` — these are expected and must not pollute Sentry. + * + * @param error - The error to log + * @param context - Sentry context from #getErrorContext() + */ + #logErrorUnlessClearing( + error: Error, + context: Parameters[1], + ): void { + if (this.#isClearing) { + return; + } + if (this.#isTransientSdkError(error)) { + // Expected SDK lifecycle: reconnect churn, intentional terminations, or + // request-side aborts. Forwarding these to Sentry pollutes the error + // budget with handled events the SDK already recovers from. Keep them + // visible locally via debugLogger for diagnosis. + this.#deps.debugLogger.log( + `[Perps transient SDK error] ${(context?.context?.data?.method as string) ?? 'unknown'}: ${error.message}`, + ); + return; + } + this.#deps.logger.error(error, context); + } + + /** + * Detects transient SDK errors that are part of normal WebSocket / HTTP + * lifecycle and should not surface to Sentry. The Hyperliquid SDK + * (`@nktkas/hyperliquid`) and its `@nktkas/rews` v2 transport surface several + * error classes that are caught and recovered automatically by the SDK or + * by our own teardown paths. + * + * Returns true for `WebSocketRequestError` (rews queue rejection on close), + * `ReconnectingWebSocketError` (rews v2 lifecycle: RECONNECTION_LIMIT, + * TERMINATED_BY_USER, UNKNOWN_ERROR — v1 silently hung), `TimeoutError` + * with "Signal timed out" message (AbortSignal.timeout shim firing inside + * the SDK transport as designed), and reconnect-churn fallbacks (unknown + * or undefined errors while in Connecting / Disconnected states). + * + * Used both to drop these from Sentry (`#logErrorUnlessClearing`) and to + * decide whether to retry on the assetCtxs subscription path. + * + * @param error - The error thrown by the SDK or rews transport. + * @returns True if the error is part of normal SDK lifecycle and should + * be downgraded from Sentry capture to debug logging. + */ + #isTransientSdkError(error: unknown): boolean { + const ensuredError = ensureError( + error, + 'HyperLiquidSubscriptionService.isTransientSdkError', + ); + const connectionState = this.#clientService.getConnectionState?.(); + const messageParts = [ + ensuredError.message, + error instanceof Error ? error.name : '', + typeof error === 'string' ? error : '', + String(error), + ] + .join(' ') + .toLowerCase(); + const isReconnectChurn = + connectionState === WebSocketConnectionState.Connecting || + connectionState === WebSocketConnectionState.Disconnected; + + return ( + messageParts.includes('websocketrequesterror') || + messageParts.includes('unknown error while making a websocket request') || + messageParts.includes('reconnectingwebsocketerror') || + (messageParts.includes('timeouterror') && + messageParts.includes('signal timed out')) || + (isReconnectChurn && + (messageParts.includes('unknown error (no details provided)') || + messageParts.includes('undefined'))) + ); + } + + #scheduleRestoreRetry(dex: string, kind: 'assetCtxs' | 'allMids'): void { + const retryKey = `${kind}:${dex}`; + if (this.#isClearing || this.#restoreRetryTimeouts.has(retryKey)) { + return; + } + + const timeoutId = setTimeout(() => { + this.#restoreRetryTimeouts.delete(retryKey); + const retryPromise = + kind === 'assetCtxs' + ? this.#ensureAssetCtxsSubscription(dex, { + incrementRefCount: false, + }) + : this.#ensureDexAllMidsSubscription(dex); + + retryPromise.catch((error) => { + this.#logErrorUnlessClearing( + ensureError( + error, + 'HyperLiquidSubscriptionService.restoreSubscriptions.retry', + ), + this.#getErrorContext('restoreSubscriptions.retry', { + dex, + kind, + }), + ); + }); + }, 1000); + + this.#restoreRetryTimeouts.set(retryKey, timeoutId); + } + + /** + * Get error context for logging with searchable tags and context. + * Enables Sentry dashboard filtering by feature, provider, and network. + * + * @param method - The method name where the error occurred + * @param extra - Optional additional context fields (merged into searchable context.data) + * @returns Error options with tags (searchable) and context (searchable) + * @private + */ + #getErrorContext( + method: string, + extra?: Record, + ): { + tags?: Record; + context?: { name: string; data: Record }; + extras?: Record; + } { + return { + tags: { + feature: PERPS_CONSTANTS.FeatureName, + provider: 'hyperliquid', + network: this.#clientService.isTestnetMode() ? 'testnet' : 'mainnet', + }, + context: { + name: 'HyperLiquidSubscriptionService', + data: { + method, + ...extra, + }, + }, + }; + } + + /** + * Check if a DEX is enabled in our configuration + * Used to filter webData3 callback data to only process DEXs we care about + * + * @param dex - DEX name (null for main DEX, string for HIP-3) + * @returns true if this DEX should be processed + */ + #isDexEnabled(dex: string | null): boolean { + if (dex === null) { + return true; // Main DEX always enabled + } + if (!this.#hip3Enabled) { + return false; // HIP-3 disabled entirely + } + return this.#enabledDexs.includes(dex); + } + + /** + * Populate DEX meta cache with pre-fetched meta data + * Called by Provider after buildAssetMapping to share cached meta, + * avoiding redundant metaAndAssetCtxs/meta API calls during subscription setup + * + * @param dex - DEX key ('' for main DEX, 'xyz'/'flx'/etc for HIP-3) + * @param meta - Meta response containing universe data + * @param meta.universe - The array of asset universe entries from the meta response. + */ + public setDexMetaCache( + dex: string, + meta: { + universe: { + name: string; + szDecimals: number; + maxLeverage: number; + }[]; + }, + ): void { + this.#dexMetaCache.set(dex, meta); + this.#deps.debugLogger.log( + '[SubscriptionService] DEX meta cache populated', + { + dex: dex || 'main', + universeSize: meta.universe.length, + }, + ); + } + + /** + * Cache asset contexts for a specific DEX from API response + * This allows buildAssetMapping() to populate cache for getMarketDataWithPrices() to use + * + * @param dex - DEX name ('' for main perps) + * @param assetCtxs - Asset contexts from metaAndAssetCtxs response + */ + public setDexAssetCtxsCache( + dex: string, + assetCtxs: AssetCtxsWsEvent['ctxs'], + ): void { + this.#dexAssetCtxsCache.set(dex, assetCtxs); + this.#deps.debugLogger.log( + '[SubscriptionService] DEX assetCtxs cache populated', + { + dex: dex || 'main', + ctxsCount: assetCtxs.length, + }, + ); + } + + /** + * Get cached assetCtxs for a DEX + * Returns the cached asset contexts from WebSocket subscription if available + * + * @param dex - DEX key ('' for main DEX, 'xyz'/'flx'/etc for HIP-3) + * @returns Array of asset contexts or undefined if not cached + */ + public getDexAssetCtxsCache( + dex: string, + ): AssetCtxsWsEvent['ctxs'] | undefined { + return this.#dexAssetCtxsCache.get(dex); + } + + /** + * Wait for DEX discovery to complete (with timeout) + * Used when HIP-3 is enabled but enabledDexs hasn't been populated yet. + * This allows subscriptions to wait for DEX discovery before creating per-DEX subscriptions. + * + * @param timeoutMs - The maximum time in milliseconds to wait for DEX discovery. + */ + async #waitForDexDiscovery(timeoutMs: number = 5000): Promise { + // Already have DEXs, no need to wait + if (this.#enabledDexs.length > 0) { + return; + } + + // Create promise if not exists + if (!this.#dexDiscoveryPromise) { + this.#dexDiscoveryPromise = new Promise((resolve) => { + this.#dexDiscoveryResolver = resolve; + }); + } + + const discovery = this.#discoverEnabledDexs + ? this.#discoverEnabledDexs() + .then((enabledDexs) => { + this.#enabledDexs = enabledDexs; + this.#discoveredDexNames = enabledDexs; + return undefined; + }) + .catch(() => this.#dexDiscoveryPromise ?? Promise.resolve()) + : this.#dexDiscoveryPromise; + + // Wait with timeout + let timeoutId: NodeJS.Timeout | undefined; + const timeoutPromise = new Promise((_resolve, reject) => { + timeoutId = setTimeout( + () => reject(new Error('DEX discovery timeout')), + timeoutMs, + ); + }); + + try { + await Promise.race([discovery, timeoutPromise]); + } catch { + this.#deps.debugLogger.log( + 'DEX discovery wait timed out, proceeding with main DEX only', + ); + } finally { + if (timeoutId) { + clearTimeout(timeoutId); + } + } + } + + /** + * Update feature flags for HIP-3 support + * Called when provider configuration changes at runtime + * Note: Market filtering is NOT applied in subscription service - only in Provider + * + * @param hip3Enabled - Whether HIP-3 multi-DEX support is enabled. + * @param enabledDexs - The array of enabled DEX identifiers. + * @param allowlistMarkets - The array of allowed market patterns. + * @param blocklistMarkets - The array of blocked market patterns. + */ + public async updateFeatureFlags( + hip3Enabled: boolean, + enabledDexs: string[], + allowlistMarkets: string[], + blocklistMarkets: string[], + ): Promise { + const previousEnabledDexs = [...this.#enabledDexs]; + const previousAllowlistMarkets = [...this.#allowlistMarkets]; + const previousBlocklistMarkets = [...this.#blocklistMarkets]; + const previousHip3Enabled = this.#hip3Enabled; + + this.#hip3Enabled = hip3Enabled; + this.#enabledDexs = enabledDexs; + this.#allowlistMarkets = allowlistMarkets; + this.#blocklistMarkets = blocklistMarkets; + this.#discoveredDexNames = enabledDexs; // Store DEX order for webData3 index mapping + + // Resolve any pending DEX discovery wait now that DEXs are available + if (this.#dexDiscoveryResolver && enabledDexs.length > 0) { + this.#dexDiscoveryResolver(); + this.#dexDiscoveryPromise = null; + this.#dexDiscoveryResolver = null; + } + + this.#deps.debugLogger.log('Feature flags updated:', { + previousHip3Enabled, + hip3Enabled, + previousEnabledDexs, + enabledDexs, + previousAllowlistMarkets, + allowlistMarkets, + previousBlocklistMarkets, + blocklistMarkets, + }); + + // If equity was just enabled or new DEXs were added + const newDexs = enabledDexs.filter( + (dex) => !previousEnabledDexs.includes(dex), + ); + if ( + (!previousHip3Enabled && hip3Enabled && enabledDexs.length > 0) || + newDexs.length > 0 + ) { + this.#deps.debugLogger.log( + 'Establishing subscriptions for new DEXs:', + newDexs, + ); + + // Establish assetCtxs subscriptions for new DEXs (for market data) + const hasMarketDataSubscribers = this.#marketDataSubscribers.size > 0; + if (hasMarketDataSubscribers) { + await Promise.all( + newDexs.map(async (dex) => { + try { + await this.#ensureAssetCtxsSubscription(dex); + } catch (error) { + this.#logErrorUnlessClearing( + ensureError( + error, + 'HyperLiquidSubscriptionService.updateFeatureFlags', + ), + this.#getErrorContext( + 'updateFeatureFlags.ensureAssetCtxsSubscription', + { + dex, + }, + ), + ); + } + }), + ); + } + + // Establish clearinghouseState/openOrders subscriptions for new DEXs + // (needed for positions, orders, and account data when using individual subscriptions) + const hasUserDataSubscribers = + this.#positionSubscriberCount > 0 || + this.#orderSubscriberCount > 0 || + this.#accountSubscriberCount > 0; + + if (hasUserDataSubscribers && this.#hip3Enabled) { + try { + const userAddress = + await this.#walletService.getUserAddressWithDefault(); + + await Promise.all( + newDexs.map(async (dex) => { + try { + await this.#ensureClearinghouseStateSubscription( + userAddress, + dex, + ); + await this.#ensureOpenOrdersSubscription(userAddress, dex); + this.#deps.debugLogger.log( + `Established user data subscriptions for new DEX: ${dex}`, + ); + } catch (error) { + this.#logErrorUnlessClearing( + ensureError( + error, + 'HyperLiquidSubscriptionService.updateFeatureFlags', + ), + this.#getErrorContext( + 'updateFeatureFlags.ensureUserDataSubscription', + { dex }, + ), + ); + } + }), + ); + } catch (error) { + this.#logErrorUnlessClearing( + ensureError( + error, + 'HyperLiquidSubscriptionService.updateFeatureFlags', + ), + this.#getErrorContext('updateFeatureFlags.getUserAddress'), + ); + } + } + } + } + + /** + * Fast hash function for change detection + * Uses string concatenation of key fields instead of JSON.stringify() + * Performance: ~100x faster than JSON.stringify() for typical objects + * Tracks structural changes (coin, size, entryPrice, leverage, TP/SL prices/counts) + * and value changes (unrealizedPnl, returnOnEquity, liquidationPrice, marginUsed) for live updates + * + * @param positions - The array of positions to hash. + * @returns A hash string representing the current position state. + */ + #hashPositions(positions: Position[]): string { + if (!positions || positions.length === 0) { + return '0'; + } + return positions + .map( + (pos) => + `${pos.symbol}:${pos.size}:${pos.entryPrice}:${pos.leverage.value}:${ + pos.takeProfitPrice ?? '' + }:${pos.stopLossPrice ?? ''}:${pos.takeProfitCount}:${pos.stopLossCount}:${ + pos.unrealizedPnl + }:${pos.returnOnEquity}:${pos.liquidationPrice ?? ''}:${pos.marginUsed || ''}:${ + // Trigger arrays are part of the emitted shape, so a standalone or + // partial trigger appearing/disappearing has to change the hash — + // otherwise subscribers never receive the updated arrays. + hashTriggerOrders(pos.takeProfitOrders) + }:${hashTriggerOrders(pos.stopLossOrders)}`, + ) + .join('|'); + } + + #hashOrders(orders: Order[]): string { + if (!orders || orders.length === 0) { + return '0'; + } + return orders + .map( + (ord) => + `${ord.symbol}:${ord.side}:${ord.size}:${ord.price}:${ord.orderType}`, + ) + .join('|'); + } + + #hashAccountState(account: AccountState): string { + return `${account.spendableBalance}:${account.withdrawableBalance}:${account.totalBalance}:${account.marginUsed}:${account.unrealizedPnl}`; + } + + // Cache hashes to avoid recomputation + #cachedPositionsHash = ''; + + #cachedOrdersHash = ''; + + #cachedAccountHash = ''; + + /** + * Extract TP/SL from orders and optionally convert raw SDK orders to Order format. + * DRY helper used by the clearinghouseState and openOrders callbacks. + * + * @param orders - Raw SDK orders from WebSocket event + * @param positions - Current positions for TP/SL matching + * @param cachedProcessedOrders - Optional pre-processed orders (skips conversion if provided) + * @returns Maps for TP/SL prices and counts, plus processed Order array + */ + #extractTPSLFromOrders( + orders: FrontendOpenOrdersResponse, + positions: Position[], + cachedProcessedOrders?: Order[], + ): { + tpslMap: Map; + tpslCountMap: Map< + string, + { takeProfitCount?: number; stopLossCount?: number } + >; + triggerOrderMap: PositionTriggerOrderMap; + processedOrders: Order[]; + } { + const tpslMap = new Map< + string, + { takeProfitPrice?: string; stopLossPrice?: string } + >(); + + const tpslCountMap = new Map< + string, + { takeProfitCount?: number; stopLossCount?: number } + >(); + + // Complete per-symbol trigger order view, including quantity-scoped (partial) + // TP/SL orders that the scalar tpslMap prices cannot represent. + const triggerOrderMap: PositionTriggerOrderMap = new Map(); + + const addTriggerOrder = ( + symbol: string, + triggerOrder: PositionTriggerOrder | undefined, + ): void => { + if (!triggerOrder) { + return; + } + + const existing = triggerOrderMap.get(symbol) ?? { + takeProfitOrders: [], + stopLossOrders: [], + }; + + if (triggerOrder.direction === 'take_profit') { + existing.takeProfitOrders.push(triggerOrder); + } else { + existing.stopLossOrders.push(triggerOrder); + } + + triggerOrderMap.set(symbol, existing); + }; + + // If cached processed orders provided, extract TP/SL from them directly + if (cachedProcessedOrders) { + // Hoisted out of the per-order loop: this runs on every order-update tick. + const positionsBySymbol = new Map( + positions.map((position) => [position.symbol, position]), + ); + + cachedProcessedOrders.forEach((order) => { + // Use triggerPrice for TP/SL (trigger condition price), falling back to price + // This ensures consistency with raw SDK order processing which uses triggerPx + const tpslPrice = order.triggerPrice ?? order.price; + + // Collected before the position-bound filter below: partial TP/SL orders + // are standalone (not position-bound) and still belong to this view. + // A trigger that is another order's child does not — same rule as the + // REST path in HyperLiquidProvider.getPositions. + if (order.isTrigger && order.reduceOnly && !order.parentOrderId) { + addTriggerOrder( + order.symbol, + buildPositionTriggerOrderFromOrder({ + order, + positionSize: positionsBySymbol.get(order.symbol)?.size ?? '0', + entryPrice: positionsBySymbol.get(order.symbol)?.entryPrice, + }), + ); + } + + if (order.isTrigger && tpslPrice) { + // When UsePositionBoundTpsl is enabled, only position-bound TP/SL orders + // should be shown on positions — skip normalTpsl children of limit orders + if ( + TP_SL_CONFIG.UsePositionBoundTpsl && + order.isPositionTpsl !== true + ) { + return; + } + + const isTakeProfit = order.detailedOrderType?.includes('Take Profit'); + const isStop = order.detailedOrderType?.includes('Stop'); + + const matchingPosition = positions.find( + (pos) => pos.symbol === order.symbol, + ); + + // Determine TP vs SL classification for count and price updates + // Use order type first, fallback to price-based detection for ambiguous 'Trigger' types + let classifiedAsTakeProfit = isTakeProfit; + let classifiedAsStop = isStop; + + if (!isTakeProfit && !isStop && matchingPosition) { + // Fallback: determine based on trigger price vs entry price + // This handles orders with ambiguous type 'Trigger' + const triggerPrice = parseFloat(tpslPrice); + const entryPrice = parseFloat(matchingPosition.entryPrice || '0'); + const isLong = parseFloat(matchingPosition.size) > 0; + + if (isLong) { + if (triggerPrice > entryPrice) { + classifiedAsTakeProfit = true; + } else { + classifiedAsStop = true; + } + } else if (triggerPrice < entryPrice) { + classifiedAsTakeProfit = true; + } else { + classifiedAsStop = true; + } + } + + const currentTakeProfitCount = + tpslCountMap.get(order.symbol)?.takeProfitCount ?? 0; + const currentStopLossCount = + tpslCountMap.get(order.symbol)?.stopLossCount ?? 0; + + tpslCountMap.set(order.symbol, { + takeProfitCount: classifiedAsTakeProfit + ? currentTakeProfitCount + 1 + : currentTakeProfitCount, + stopLossCount: classifiedAsStop + ? currentStopLossCount + 1 + : currentStopLossCount, + }); + + if (matchingPosition) { + const existing = tpslMap.get(order.symbol) ?? {}; + if (classifiedAsTakeProfit) { + existing.takeProfitPrice = tpslPrice; + } else if (classifiedAsStop) { + existing.stopLossPrice = tpslPrice; + } + tpslMap.set(order.symbol, existing); + } + } + }); + + return { + tpslMap, + tpslCountMap, + triggerOrderMap, + processedOrders: cachedProcessedOrders, + }; + } + + // Process raw SDK orders + const processedOrders: Order[] = []; + + // TP/SL children of a pending parent order are listed both nested under the + // parent and as top-level entries. Map each child back to its parent so the + // converted order carries the link and the position trigger view can exclude + // them: they protect that order, not a position. + const parentIdByChildId = new Map(); + orders.forEach((order) => { + order.children?.forEach((child) => { + parentIdByChildId.set(child.oid, order.oid); + }); + }); + + orders.forEach((order) => { + let position: Position | undefined; + let positionForCoin: Position | undefined; + + const matchPositionToTpsl = (pos: Position): boolean => { + if (TP_SL_CONFIG.UsePositionBoundTpsl) { + return ( + pos.symbol === order.coin && + order.reduceOnly && + order.isPositionTpsl + ); + } + + return ( + pos.symbol === order.coin && + Math.abs(parseFloat(order.sz)) >= Math.abs(parseFloat(pos.size)) + ); + }; + + const matchPositionToCoin = (pos: Position): boolean => + pos.symbol === order.coin; + + // Process trigger orders for TP/SL extraction + if (order.triggerPx) { + const isTakeProfit = order.orderType?.includes('Take Profit'); + const isStop = order.orderType?.includes('Stop'); + + const { coin } = order; + position = positions.find(matchPositionToTpsl); + positionForCoin = positions.find(matchPositionToCoin); + + // Determine TP vs SL classification for count and price updates + // Use order type first, fallback to price-based detection for ambiguous 'Trigger' types + // This matches the cached order processing logic for consistency + let classifiedAsTakeProfit = isTakeProfit; + let classifiedAsStop = isStop; + + if (!isTakeProfit && !isStop && position) { + // Fallback: determine based on trigger price vs entry price + // This handles orders with ambiguous type 'Trigger' + const triggerPrice = parseFloat(order.triggerPx); + const entryPrice = parseFloat(position.entryPrice || '0'); + const isLong = parseFloat(position.size) > 0; + + if (isLong) { + if (triggerPrice > entryPrice) { + classifiedAsTakeProfit = true; + } else { + classifiedAsStop = true; + } + } else if (triggerPrice < entryPrice) { + classifiedAsTakeProfit = true; + } else { + classifiedAsStop = true; + } + } + + const currentTakeProfitCount = + tpslCountMap.get(coin)?.takeProfitCount ?? 0; + const currentStopLossCount = tpslCountMap.get(coin)?.stopLossCount ?? 0; + + tpslCountMap.set(coin, { + takeProfitCount: classifiedAsTakeProfit + ? currentTakeProfitCount + 1 + : currentTakeProfitCount, + stopLossCount: classifiedAsStop + ? currentStopLossCount + 1 + : currentStopLossCount, + }); + + if (position) { + const existing = tpslMap.get(coin) ?? {}; + + // Use classified values for price assignment (consistent with count logic) + if (classifiedAsTakeProfit) { + existing.takeProfitPrice = order.triggerPx; + } else if (classifiedAsStop) { + existing.stopLossPrice = order.triggerPx; + } + + tpslMap.set(coin, existing); + } + } + + // Convert ALL open orders to Order format + const convertedOrder = adaptOrderFromSDK( + order, + position ?? positionForCoin, + ); + const parentOrderId = parentIdByChildId.get(order.oid); + if (parentOrderId !== undefined) { + convertedOrder.parentOrderId = parentOrderId.toString(); + } + + processedOrders.push(convertedOrder); + + if ( + convertedOrder.isTrigger && + convertedOrder.reduceOnly && + !convertedOrder.parentOrderId + ) { + addTriggerOrder( + convertedOrder.symbol, + buildPositionTriggerOrderFromOrder({ + order: convertedOrder, + positionSize: (position ?? positionForCoin)?.size ?? '0', + entryPrice: (position ?? positionForCoin)?.entryPrice, + }), + ); + } + }); + + return { tpslMap, tpslCountMap, triggerOrderMap, processedOrders }; + } + + /** + * Merge TP/SL data into positions + * DRY helper used by the clearinghouseState and openOrders callbacks + * + * @param positions - Base positions without TP/SL + * @param tpslMap - Map of coin -> TP/SL prices + * @param tpslCountMap - Map of coin -> TP/SL counts + * @param triggerOrderMap - Map of coin -> attached trigger orders (including partial TP/SL) + * @returns Positions enhanced with TP/SL data + */ + #mergeTPSLIntoPositions( + positions: Position[], + tpslMap: Map, + tpslCountMap: Map< + string, + { takeProfitCount?: number; stopLossCount?: number } + >, + triggerOrderMap?: PositionTriggerOrderMap, + ): Position[] { + return positions.map((position) => { + const tpsl = tpslMap.get(position.symbol) ?? {}; + const tpslCount = tpslCountMap.get(position.symbol) ?? {}; + const triggerOrders = triggerOrderMap?.get(position.symbol); + const takeProfitOrders = triggerOrders?.takeProfitOrders ?? []; + const stopLossOrders = triggerOrders?.stopLossOrders ?? []; + + return { + ...position, + // The scanned prices only ever come from position-bound triggers, so a + // lone quantity-scoped trigger has to be read off the array instead. + takeProfitPrice: resolvePositionTriggerSummaryPrice({ + triggerOrders: takeProfitOrders, + scannedPrice: tpsl.takeProfitPrice, + }), + stopLossPrice: resolvePositionTriggerSummaryPrice({ + triggerOrders: stopLossOrders, + scannedPrice: tpsl.stopLossPrice, + }), + // Counts come from the same arrays as the REST path, so both transports + // report one definition. Orders whose placement type the exchange did + // not name (HyperLiquid's ambiguous 'Trigger') are absent from both, + // where the legacy count included them. + // Keyed on the map, not on this symbol's entry: a symbol with no + // entry has no triggers, and falling back to the legacy count there + // would report a count beside an empty array. + takeProfitCount: triggerOrderMap + ? takeProfitOrders.length + : (tpslCount.takeProfitCount ?? 0), + stopLossCount: triggerOrderMap + ? stopLossOrders.length + : (tpslCount.stopLossCount ?? 0), + takeProfitOrders, + stopLossOrders, + }; + }); + } + + /** + * Aggregate account states from all cached DEXs + * Sums balances and creates per-DEX breakdown for multi-DEX portfolio view + * + * @returns Aggregated account state with dexBreakdown field + * @private + */ + #aggregateAccountStates(): AccountState { + const subAccountBreakdown: Record< + string, + { + spendableBalance: string; + withdrawableBalance: string; + totalBalance: string; + } + > = {}; + let totalSpendableBalance = 0; + let totalWithdrawableBalance = 0; + let totalBalance = 0; + let totalMarginUsed = 0; + let totalUnrealizedPnl = 0; + + // Collect account states for weighted ROE calculation + const accountStatesForROE: { + unrealizedPnl: string; + returnOnEquity: string; + }[] = []; + + // Aggregate all cached account states + Array.from(this.#dexAccountCache.entries()).forEach( + ([currentDex, state]) => { + const dexKey = currentDex === '' ? 'main' : currentDex; + subAccountBreakdown[dexKey] = { + spendableBalance: state.spendableBalance, + withdrawableBalance: state.withdrawableBalance, + totalBalance: state.totalBalance, + }; + totalSpendableBalance += parseFloat(state.spendableBalance); + totalWithdrawableBalance += parseFloat(state.withdrawableBalance); + totalBalance += parseFloat(state.totalBalance); + totalMarginUsed += parseFloat(state.marginUsed); + totalUnrealizedPnl += parseFloat(state.unrealizedPnl); + + // Collect data for weighted ROE calculation + accountStatesForROE.push({ + unrealizedPnl: state.unrealizedPnl, + returnOnEquity: state.returnOnEquity, + }); + }, + ); + + // Use first DEX's account state as base and override aggregated values + const firstDexAccount = + this.#dexAccountCache.values().next().value ?? ({} as AccountState); + + // Calculate weighted returnOnEquity across all DEXs + const returnOnEquity = calculateWeightedReturnOnEquity(accountStatesForROE); + + return addSpotBalanceToAccountState( + { + ...firstDexAccount, + spendableBalance: totalSpendableBalance.toString(), + withdrawableBalance: totalWithdrawableBalance.toString(), + totalBalance: totalBalance.toString(), + marginUsed: totalMarginUsed.toString(), + unrealizedPnl: totalUnrealizedPnl.toString(), + subAccountBreakdown, + returnOnEquity, + }, + this.#cachedSpotState, + this.#getSpotBalanceOptions(), + ); + } + + /** + * Return the cached HL abstraction mode for the given user address. + * + * Returns `null` when the address is unknown or this user has not been + * fetched yet — callers pass this through `hyperLiquidModeFoldsSpot`, + * which fail-closes (no fold) when the mode is unresolved. + * + * @param userAddress - Current user address; null/empty returns null. + * @returns Cached abstraction mode when the user matches; otherwise null. + */ + #getAbstractionModeForUser( + userAddress?: string | null, + ): HyperLiquidAbstractionMode | null { + if (!userAddress) { + return null; + } + return this.#abstractionModeByUser.get(userAddress.toLowerCase()) ?? null; + } + + #getSpotBalanceOptions(): AddSpotBalanceOptions { + return { + foldIntoCollateral: hyperLiquidModeFoldsSpot( + this.#getAbstractionModeForUser(this.#cachedSpotStateUserAddress), + ), + }; + } + + /** + * Return the cached HL abstraction mode for the given user address. + * + * @param userAddress - The EVM address to look up. + * @returns Cached abstraction mode, or null when unresolved. + */ + public getCachedAbstractionMode( + userAddress: string, + ): HyperLiquidAbstractionMode | null { + return this.#getAbstractionModeForUser(userAddress); + } + + /** + * Record a user's resolved abstraction mode and immediately re-aggregate. + * Call after the provider has confirmed the on-chain mode (already-enabled + * or just-migrated) so the WS-driven aggregator picks up the correct fold + * decision on the next tick. + * + * @param userAddress - The EVM address whose mode is being recorded. + * @param mode - The current abstraction mode for this user. + */ + public setUserAbstractionMode( + userAddress: string, + mode: HyperLiquidAbstractionMode, + ): void { + const lower = userAddress.toLowerCase(); + this.#abstractionModeByUser.set(lower, mode); + + if (this.#dexAccountCache.size > 0) { + this.#aggregateAndNotifySubscribers(); + } + } + + /** + * Fetch userAbstraction and update the cache, throttled so the long-lived + * spotState WebSocket can trigger a background refresh on every tick + * without burning REST quota. Handles HL-web mode flips propagating back + * to mobile without requiring a restart or account switch. + * + * Concurrent callers share the same in-flight promise so an in-flight + * fetch (especially a slow-failing one) doesn't ratchet the throttle + * forward on every WS tick and leave mode stale during a network hang. + * + * @param userAddress - Current user address to refresh the cache for. + * @returns Promise that resolves once the refresh completes (or immediately when throttled). + */ + async #refreshAbstractionModeThrottled(userAddress: string): Promise { + const normalizedUser = userAddress.toLowerCase(); + const existing = this.#abstractionModeInflightByUser.get(normalizedUser); + if (existing) { + await existing; + return undefined; + } + const now = Date.now(); + const lastWsRefreshAt = + this.#abstractionModeLastWsRefreshAtByUser.get(normalizedUser) ?? 0; + if (now - lastWsRefreshAt < ABSTRACTION_MODE_REFRESH_THROTTLE_MS) { + return undefined; + } + const inflight: Promise = (async (): Promise => { + try { + const infoClient = this.#clientService.getInfoClient(); + const mode = await infoClient.userAbstraction({ user: userAddress }); + const previousMode = + this.#abstractionModeByUser.get(normalizedUser) ?? null; + this.#abstractionModeByUser.set(normalizedUser, mode); + // Set timestamp only on success; a hanging/failed fetch must not + // ratchet the throttle window forward (which would silence every + // subsequent spot WS tick for the full throttle duration). + this.#abstractionModeLastWsRefreshAtByUser.set( + normalizedUser, + Date.now(), + ); + + // If the fold semantics actually changed for this user, trigger a + // re-aggregation so balance-dependent UI (withdraw cap, order-entry + // validation) picks up the new mode immediately — otherwise a + // Unified→Standard flip can stay folded with old semantics until the + // next spot/account event happens to arrive. + const foldChanged = + hyperLiquidModeFoldsSpot(previousMode) !== + hyperLiquidModeFoldsSpot(mode); + if (foldChanged && this.#dexAccountCache.size > 0) { + this.#aggregateAndNotifySubscribers(); + } + } catch (error) { + // Non-fatal — preserve the last known mode for this user. Leave + // timestamp at its previous value so a genuine retry on the next + // WS tick is allowed (no forward ratchet on slow failures). Route + // through the shared Sentry helper so repeated failures become + // visible on the perps ops dashboard (consistent with other async + // boundary errors in this file, e.g. #refreshSpotState). + this.#logErrorUnlessClearing( + ensureError( + error, + 'HyperLiquidSubscriptionService.refreshAbstractionModeThrottled', + ), + this.#getErrorContext('refreshAbstractionModeThrottled', { + user: normalizedUser, + }), + ); + } + })(); + this.#abstractionModeInflightByUser.set(normalizedUser, inflight); + try { + await inflight; + } finally { + if ( + this.#abstractionModeInflightByUser.get(normalizedUser) === inflight + ) { + this.#abstractionModeInflightByUser.delete(normalizedUser); + } + } + return undefined; + } + + async #ensureSpotState(accountId?: CaipAccountId): Promise { + const userAddress = + await this.#walletService.getUserAddressWithDefault(accountId); + const lowerUserAddress = userAddress.toLowerCase(); + + // Fast-path only when we have spot for this user AND a resolved + // abstraction mode. Without the mode, `#getSpotBalanceOptions` would + // fall back to fail-closed (no fold), under-reporting Unified / + // Portfolio Margin balances — force a refresh instead. + if ( + this.#cachedSpotState && + this.#cachedSpotStateUserAddress === lowerUserAddress && + this.#abstractionModeByUser.has(lowerUserAddress) + ) { + return; + } + + // Share an in-flight fetch only if it targets the same user. + // A pending fetch for a different user is stale after an account switch — + // start a fresh fetch; the stale one will self-discard via generation check. + if ( + this.#spotStatePromise && + this.#spotStatePromiseUserAddress === userAddress + ) { + await this.#spotStatePromise; + return; + } + + this.#spotStateGeneration += 1; + const generation = this.#spotStateGeneration; + const promise = this.#refreshSpotState(userAddress, generation); + this.#spotStatePromise = promise; + this.#spotStatePromiseUserAddress = userAddress; + + try { + await promise; + } finally { + // Only clear tracker if we're still the latest in-flight fetch. + // A newer fetch may have already replaced us. + if (this.#spotStatePromise === promise) { + this.#spotStatePromise = undefined; + this.#spotStatePromiseUserAddress = undefined; + } + } + } + + async #refreshSpotState( + userAddress: string, + generation: number, + ): Promise { + try { + // Cold-start safety: getInfoClient() throws until the SDK has been + // initialized via ensureSubscriptionClient. On a fresh service + // instance subscribeToAccount can race ahead of the webData3 path, + // so initialize here first — subsequent calls are no-ops. + await this.#clientService.ensureSubscriptionClient( + this.#walletService.createWalletAdapter(), + ); + + // Don't bail here even if generation has bumped (e.g. WS spot snapshot + // arrived while we awaited the subscription client). We still need to + // resolve `userAbstraction` for this user — the mode is user-keyed, + // independent of the spot generation, and the post-fetch path below + // correctly handles the generation-changed case (seal + re-aggregate + // instead of overwriting WS spot). + const infoClient = this.#clientService.getInfoClient({ useHttp: true }); + const lowerUserAddress = userAddress.toLowerCase(); + // Fetch spot state + abstraction mode in parallel — mode decides + // whether the spot fold applies in addSpotBalanceToAccountState. + // Register the userAbstraction call in `#abstractionModeInflightByUser` + // so a concurrent WS-driven `#refreshAbstractionModeThrottled` awaits + // this fetch instead of duplicating the REST round-trip. + const abstractionFetch = infoClient.userAbstraction({ + user: userAddress, + }); + const trackedAbstraction = abstractionFetch.then( + () => undefined, + () => undefined, + ); + this.#abstractionModeInflightByUser.set( + lowerUserAddress, + trackedAbstraction, + ); + const [spotResult, abstractionResult] = await Promise.allSettled([ + infoClient.spotClearinghouseState({ user: userAddress }), + abstractionFetch, + ]); + if ( + this.#abstractionModeInflightByUser.get(lowerUserAddress) === + trackedAbstraction + ) { + this.#abstractionModeInflightByUser.delete(lowerUserAddress); + } + + // Record the abstraction mode regardless of generation. The mode is + // user-keyed (independent of the spot snapshot generation) so a WS + // push that bumped generation while we awaited cannot make this + // result wrong for this user. Discarding it would strand + // Unified / Portfolio Margin users at fail-closed until another + // subscribe runs — exactly the race the WS-vs-REST guard creates. + if (abstractionResult.status === 'fulfilled') { + this.#abstractionModeByUser.set( + lowerUserAddress, + abstractionResult.value, + ); + } else { + this.#deps.debugLogger.log( + 'User abstraction fetch failed during spot refresh; spot fold disabled until the mode resolves', + { + error: ensureError( + abstractionResult.reason, + 'HyperLiquidSubscriptionService.refreshSpotState.abstraction', + ).message, + }, + ); + } + + if (generation !== this.#spotStateGeneration) { + // A WS push superseded our spot snapshot. The earlier WS-driven + // aggregation ran with a null mode (fail-closed), so subscribers + // may currently be under-reported. If we just resolved the mode + // for the user whose spot is cached (strict match — null cache + // owner could mean cleanUp ran for a different user), re-aggregate + // now so the active subscribers immediately see the correct fold. + if ( + abstractionResult.status === 'fulfilled' && + this.#cachedSpotState && + this.#cachedSpotStateUserAddress === lowerUserAddress + ) { + if (this.#dexAccountCache.size > 0) { + this.#aggregateAndNotifySubscribers(); + } + } + return; + } + + if (spotResult.status === 'rejected') { + throw spotResult.reason; + } + + this.#cachedSpotState = spotResult.value; + // Always record the spot owner so subsequent #ensureSpotState calls + // and recovery branches can identify whose data is cached. Fast-path + // eligibility is gated separately by #abstractionModeByUser.has(...); + // a transient abstraction failure leaves the user out of the map and + // the next #ensureSpotState retries both fetches. + this.#cachedSpotStateUserAddress = lowerUserAddress; + + if (this.#dexAccountCache.size > 0) { + this.#aggregateAndNotifySubscribers(); + } + } catch (error) { + if (generation !== this.#spotStateGeneration) { + return; + } + this.#logErrorUnlessClearing( + ensureError(error, 'HyperLiquidSubscriptionService.refreshSpotState'), + this.#getErrorContext('refreshSpotState'), + ); + } + } + + async #ensureSpotStateSubscription(accountId?: CaipAccountId): Promise { + const userAddress = + await this.#walletService.getUserAddressWithDefault(accountId); + + if (this.#spotStateSubscriptions.has(userAddress)) { + return; + } + + const inFlight = this.#spotStateSubscriptionPromises.get(userAddress); + if (inFlight) { + await inFlight; + return; + } + + const startGeneration = this.#spotStateSubscriptionGeneration; + + const promise = (async (): Promise => { + await this.#clientService.ensureSubscriptionClient( + this.#walletService.createWalletAdapter(), + ); + const subscriptionClient = this.#clientService.getSubscriptionClient(); + if (!subscriptionClient) { + throw new Error('SubscriptionClient not available'); + } + + const subscription = await subscriptionClient.spotState( + { user: userAddress }, + (event: SpotStateWsEvent) => { + try { + if (event.user.toLowerCase() !== userAddress.toLowerCase()) { + return; + } + // Invalidate any in-flight REST refreshSpotState so it drops + // its result instead of overwriting this fresher WS snapshot. + this.#spotStateGeneration += 1; + this.#cachedSpotState = event.spotState; + // Always record the spot owner so subsequent generation guards + // and recovery branches can identify whose data is cached. + // Fast-path eligibility is gated separately in #ensureSpotState + // by checking #abstractionModeByUser.has(...). + this.#cachedSpotStateUserAddress = userAddress.toLowerCase(); + + // Kick a throttled userAbstraction refresh so HL-web mode + // flips (Unified → Standard or vice versa) propagate back to + // mobile while the app stays open. Fire-and-forget: the + // refresh updates the per-user abstraction-mode cache and the next + // fold picks up the new value. + this.#refreshAbstractionModeThrottled( + event.user.toLowerCase(), + ).catch(() => { + // Errors are logged inside the throttled refresh helper. + }); + + if (this.#dexAccountCache.size > 0) { + this.#aggregateAndNotifySubscribers(); + } + } catch (error) { + this.#logErrorUnlessClearing( + ensureError( + error, + 'HyperLiquidSubscriptionService.ensureSpotStateSubscription', + ), + this.#getErrorContext('spotState callback error', { + user: userAddress, + }), + ); + } + }, + ); + + // Discard if cleanup ran while we were awaiting the subscription + // handshake; rehydrating #spotStateSubscriptions here would leave + // a stale entry that short-circuits future resubscribe attempts. + if (startGeneration !== this.#spotStateSubscriptionGeneration) { + await subscription.unsubscribe().catch(() => undefined); + return; + } + + this.#spotStateSubscriptions.set(userAddress, subscription); + })(); + + this.#spotStateSubscriptionPromises.set(userAddress, promise); + try { + await promise; + } finally { + this.#spotStateSubscriptionPromises.delete(userAddress); + } + } + + /** + * Subscribe to live price updates with singleton subscription architecture + * Uses allMids for fast price updates and predictedFundings for accurate funding rates + * + * @param params - The subscription parameters including symbols and callbacks. + * @returns A cleanup function to unsubscribe from price updates. + */ + public async subscribeToPrices( + params: SubscribePricesParams, + ): Promise<() => void> { + const { + symbols, + callback, + includeOrderBook = false, + includeMarketData = false, + } = params; + const unsubscribers: (() => void)[] = []; + + symbols.forEach((symbol) => { + unsubscribers.push( + this.#createSubscription(this.#priceSubscribers, callback, symbol), + ); + // Track market data subscribers separately + if (includeMarketData) { + unsubscribers.push( + this.#createSubscription( + this.#marketDataSubscribers, + callback, + symbol, + ), + ); + } + // Track order book subscribers separately + if (includeOrderBook) { + unsubscribers.push( + this.#createSubscription( + this.#orderBookSubscribers, + callback, + symbol, + ), + ); + } + }); + + await this.#clientService.ensureSubscriptionClient( + this.#walletService.createWalletAdapter(), + ); + + const subscriptionClient = this.#clientService.getSubscriptionClient(); + if (!subscriptionClient) { + this.#deps.debugLogger.log( + 'SubscriptionClient not available for price subscription', + ); + return () => unsubscribers.forEach((fn) => fn()); + } + + // Ensure global subscriptions are established + this.#ensureGlobalAllMidsSubscription(); + this.#ensureGlobalFastAssetCtxsSubscription(); + + // Extract unique DEXs from requested symbols + const dexsNeeded = new Set(); + symbols.forEach((symbol) => { + const { dex } = parseAssetName(symbol); + dexsNeeded.add(dex); + }); + + // Always ensure assetCtxs subscriptions (1 per DEX, lightweight). + // Provides prevDayPx for percentChange24h even without includeMarketData + // (e.g., prewarm after reconnection). Uses incrementRefCount: false when + // not explicitly requested so lifecycle is managed by component subscriptions. + dexsNeeded.forEach((dex) => { + const dexName = dex ?? ''; + this.#ensureAssetCtxsSubscription(dexName, { + incrementRefCount: includeMarketData, + }).catch((error) => { + this.#logErrorUnlessClearing( + ensureError( + error, + 'HyperLiquidSubscriptionService.subscribeToPrices', + ), + this.#getErrorContext( + 'subscribeToPrices.ensureAssetCtxsSubscription', + { dex: dexName }, + ), + ); + }); + }); + + // dexAllMids and activeAssetCtx only when market data explicitly requested + if (includeMarketData) { + dexsNeeded.forEach((dex) => { + const dexName = dex ?? ''; + this.#ensureDexAllMidsSubscription(dexName).catch((error) => { + this.#logErrorUnlessClearing( + ensureError( + error, + 'HyperLiquidSubscriptionService.subscribeToPrices', + ), + this.#getErrorContext( + 'subscribeToPrices.ensureDexAllMidsSubscription', + { dex: dexName }, + ), + ); + }); + }); + } + + // Note: Funding rates are now cached via assetCtxs WebSocket subscription + // (ensureAssetCtxsSubscription above), eliminating the need for a separate + // metaAndAssetCtxs API call here. The WebSocket callback in createAssetCtxsSubscription + // populates marketDataCache with funding rates as they arrive. + + symbols.forEach((symbol) => { + // Subscribe to activeAssetCtx only when market data is requested + if (includeMarketData) { + this.#ensureActiveAssetSubscription(symbol); + } + if (includeOrderBook) { + this.#ensureBboSubscription(symbol); + } + }); + + // Send cached data immediately if available, projecting the fast-stream + // price for focused subscribers and falling back to the allMids baseline + // for list subscribers. + symbols.forEach((symbol) => { + const cachedPrice = this.#cachedPriceData?.get(symbol); + if (cachedPrice) { + const projected = includeMarketData + ? this.#projectPriceUpdate(symbol, cachedPrice) + : cachedPrice; + callback([projected]); + } else if (includeMarketData) { + // No allMids baseline yet; if a fresh fast-stream price is cached, + // send it immediately so focused screens are not blank on first render. + const fastPrice = this.#getFreshActiveAssetCtxPrice(symbol); + if (fastPrice !== undefined) { + callback([this.#createPriceUpdate(symbol, fastPrice)]); + } + } + }); + + // Return cleanup function + return () => { + unsubscribers.forEach((fn) => fn()); + // Cleanup subscriptions with reference counting + symbols.forEach((symbol) => { + if (includeMarketData) { + this.#cleanupActiveAssetSubscription(symbol); + } + if (includeOrderBook) { + this.#cleanupBboSubscription(symbol); + } + }); + + // Cleanup DEX-level assetCtxs subscriptions + if (includeMarketData) { + dexsNeeded.forEach((dex) => { + const dexName = dex ?? ''; + this.#cleanupAssetCtxsSubscription(dexName); + }); + } + }; + } + + /** + * Ensure shared webData3 subscription is active (singleton pattern with multi-DEX support) + * webData3 provides data for all DEXs (main + HIP-3) in a single subscription + * + * @param accountId - Optional CAIP account ID to subscribe for. + */ + async #ensureSharedWebData3Subscription( + accountId?: CaipAccountId, + ): Promise { + // Establish webData3 subscription (if not exists) + if (!this.#webData3Subscriptions.has('')) { + if (this.#webData3SubscriptionPromise) { + await this.#webData3SubscriptionPromise; + } else { + this.#webData3SubscriptionPromise = + this.#createUserDataSubscription(accountId); + + try { + await this.#webData3SubscriptionPromise; + } catch (error) { + this.#webData3SubscriptionPromise = undefined; + throw error; + } + } + } + // Note: webData3 includes all DEX data, so no separate HIP-3 subscriptions needed + } + + /** + * Create WebSocket subscription for user data (positions, orders, account). + * + * Positions, orders, and account/spot balance are always delivered via + * per-DEX `clearinghouseState` + `openOrders` subscriptions (sub-second + * updates). webData3 is used only for OI caps extraction (not + * latency-sensitive). The deprecated webData2 snapshot channel is no longer + * used (TAT-3332). + * + * - HIP-3 disabled: subscribe to the main DEX only (`dexsToSubscribe = ['']`). + * - HIP-3 enabled: subscribe to the main DEX plus each enabled HIP-3 DEX. + * + * webData3 provides perpDexStates[] array containing OI caps for all DEXs: + * - Index 0: Main DEX (dexName = '') + * - Index 1+: HIP-3 DEXs in order of enabledDexs array + * + * @param accountId - Optional CAIP account ID to subscribe for. + * @returns A promise that resolves when the operation completes. + */ + async #createUserDataSubscription(accountId?: CaipAccountId): Promise { + await this.#clientService.ensureSubscriptionClient( + this.#walletService.createWalletAdapter(), + ); + const subscriptionClient = this.#clientService.getSubscriptionClient(); + + if (!subscriptionClient) { + throw new Error('Subscription client not initialized'); + } + + const userAddress = + await this.#walletService.getUserAddressWithDefault(accountId); + + const dexName = ''; // Use empty string as key for single subscription + + // Skip if subscription already exists + if (this.#webData3Subscriptions.has(dexName)) { + return undefined; + } + + // Wait for DEX discovery if HIP-3 is enabled but DEXs haven't been discovered yet + // This ensures HIP-3 subscriptions are created together with main DEX + if (this.#hip3Enabled && this.#enabledDexs.length === 0) { + this.#deps.debugLogger.log( + 'Waiting for DEX discovery before creating subscriptions...', + ); + await this.#waitForDexDiscovery(); + this.#deps.debugLogger.log( + 'DEX discovery complete, proceeding with subscriptions', + { + enabledDexs: this.#enabledDexs, + }, + ); + } + + return new Promise((resolve, reject) => { + // Use per-DEX clearinghouseState + openOrders subscriptions for + // positions/orders/account on every path. webData3 is used only for OI + // caps extraction. The deprecated webData2 channel is no longer used. + + // Determine which DEXs to subscribe to: + // - HIP-3 enabled: main DEX + each enabled HIP-3 DEX. + // - HIP-3 disabled: main DEX only. + const dexsToSubscribe = this.#hip3Enabled + ? [ + '', + ...this.#enabledDexs.filter((dexId) => this.#isDexEnabled(dexId)), + ] + : ['']; + + // Track expected DEXs for synchronized notifications + // Clear previous tracking and set new expected DEXs + this.#expectedDexs = new Set(dexsToSubscribe); + this.#initializedDexs = new Set(); + + // Set up individual subscriptions for each DEX + const subscriptionPromises: Promise[] = []; + + for (const currentDexName of dexsToSubscribe) { + // Set up clearinghouseState subscription for positions + account + subscriptionPromises.push( + this.#ensureClearinghouseStateSubscription( + userAddress, + currentDexName, + ), + ); + + // Set up openOrders subscription for orders + subscriptionPromises.push( + this.#ensureOpenOrdersSubscription(userAddress, currentDexName), + ); + } + + // Also set up webData3 for OI caps only + const webData3Promise = subscriptionClient + .webData3({ user: userAddress }, (data: WebData3WsEvent) => { + try { + // webData3 is ONLY used for OI caps extraction + // Positions, orders, and account data come from individual subscriptions + const allOICaps: string[] = []; + data.perpDexStates.forEach((dexState, index) => { + // Map webData3 index to DEX name + // Index 0 = main DEX (null), Index 1+ = HIP-3 DEXs from discoveredDexNames + const dexIdentifier = + index === 0 ? null : this.#discoveredDexNames[index - 1]; + + // Skip unknown DEXs (not in discoveredDexNames) to prevent main DEX cache corruption + if (index > 0 && dexIdentifier === undefined) { + return; // Unknown DEX - skip to prevent misidentifying as main DEX + } + + // Only process DEXs we care about (skip others silently) + if (!this.#isDexEnabled(dexIdentifier ?? null)) { + return; // Skip this DEX - not enabled in our configuration + } + + const currentDexName = dexIdentifier ?? ''; + + const oiCaps = dexState.perpsAtOpenInterestCap ?? []; + + // Add DEX prefix for HIP-3 symbols (e.g., "xyz:TSLA") + if (currentDexName) { + allOICaps.push( + ...oiCaps.map((symbol) => `${currentDexName}:${symbol}`), + ); + } else { + // Main DEX - no prefix needed + allOICaps.push(...oiCaps); + } + }); + + // Update OI caps cache and notify if changed + const oiCapsHash = [...allOICaps] + .sort((a: string, b: string) => a.localeCompare(b)) + .join(','); + if (oiCapsHash !== this.#cachedOICapsHash) { + this.#cachedOICaps = allOICaps; + this.#cachedOICapsHash = oiCapsHash; + this.#oiCapsCacheInitialized = true; + + // Notify all subscribers + this.#oiCapSubscribers.forEach((callback) => callback(allOICaps)); + } + } catch (error) { + this.#logErrorUnlessClearing( + ensureError( + error, + 'HyperLiquidSubscriptionService.createUserDataSubscription', + ), + this.#getErrorContext('webData3 callback error', { + user: userAddress, + hasPerpDexStates: data?.perpDexStates !== undefined, + perpDexStatesLength: data?.perpDexStates?.length ?? 0, + }), + ); + } + }) + .then((sub) => { + this.#webData3Subscriptions.set(dexName, sub); + this.#deps.debugLogger.log( + `webData3 subscription established for OI caps (main + HIP-3)`, + ); + return undefined; + }) + .catch((error) => { + this.#logErrorUnlessClearing( + ensureError( + error, + 'HyperLiquidSubscriptionService.createUserDataSubscription', + ), + this.#getErrorContext('createUserDataSubscription (webData3)', { + dex: dexName, + }), + ); + throw error; + }); + + subscriptionPromises.push(webData3Promise); + + // Wait for all subscriptions to be established + Promise.all(subscriptionPromises) + .then(() => { + this.#deps.debugLogger.log( + `User data subscriptions established for ${dexsToSubscribe.length} DEX(s)`, + ); + resolve(); + return undefined; + }) + .catch((error) => { + this.#logErrorUnlessClearing( + ensureError( + error, + 'HyperLiquidSubscriptionService.createUserDataSubscription', + ), + this.#getErrorContext('createUserDataSubscription', { + dexs: dexsToSubscribe, + }), + ); + reject( + ensureError( + error, + 'HyperLiquidSubscriptionService.createUserDataSubscription', + ), + ); + }); + }); + } + + /** + * Ensure clearinghouseState subscription exists for a DEX + * Uses pending promise tracking to prevent race conditions where multiple + * concurrent calls could create duplicate subscriptions + * + * @param userAddress - The user's wallet address. + * @param dexName - The DEX identifier (empty string for main DEX). + * @returns A promise that resolves when the subscription is established. + */ + async #ensureClearinghouseStateSubscription( + userAddress: string, + dexName: string, + ): Promise { + // Already subscribed + if (this.#clearinghouseStateSubscriptions.has(dexName)) { + return; + } + + // Another call is already in progress - wait for it instead of creating duplicate + const pending = this.#pendingClearinghouseSubscriptions.get(dexName); + if (pending) { + this.#deps.debugLogger.log( + `[ensureClearinghouseStateSubscription] Waiting for pending subscription for DEX: ${dexName || 'main'}`, + ); + await pending; + return; + } + + // Create subscription promise and track it + const subscriptionPromise = this.#createClearinghouseSubscription( + userAddress, + dexName, + ); + this.#pendingClearinghouseSubscriptions.set(dexName, subscriptionPromise); + + try { + await subscriptionPromise; + } finally { + this.#pendingClearinghouseSubscriptions.delete(dexName); + } + } + + /** + * Create the actual clearinghouseState subscription + * Separated from ensureClearinghouseStateSubscription to enable promise deduplication + * + * @param userAddress - The user's wallet address. + * @param dexName - The DEX identifier (empty string for main DEX). + */ + async #createClearinghouseSubscription( + userAddress: string, + dexName: string, + ): Promise { + await this.#clientService.ensureSubscriptionClient( + this.#walletService.createWalletAdapter(), + ); + const subscriptionClient = this.#clientService.getSubscriptionClient(); + if (!subscriptionClient) { + throw new Error('Subscription client not available'); + } + + try { + const subscription = await subscriptionClient.clearinghouseState( + { + user: userAddress, + dex: dexName || undefined, // Empty string -> undefined for main DEX + }, + (data: ClearinghouseStateWsEvent) => { + const cacheKey = data.dex || ''; + + // Update caches and notify subscribers if we have positions/account subscribers + if ( + this.#positionSubscriberCount > 0 || + this.#accountSubscriberCount > 0 + ) { + // Process positions from clearinghouse state + const positions = data.clearinghouseState.assetPositions + .filter((assetPos) => assetPos.position.szi !== '0') + .map((assetPos) => adaptPositionFromSDK(assetPos)); + + // Get cached orders to preserve TP/SL data (prevents flickering) + // Orders are cached by openOrders subscription + const cachedOrders = this.#dexOrdersCache.get(cacheKey) ?? []; + + // Re-extract TP/SL from cached orders for the new positions + // This ensures TP/SL data persists across clearinghouseState updates + // Default the trigger arrays so "no triggers" and "not streamed yet" + // look the same to consumers as they do on the REST path. + let positionsWithTPSL: Position[] = positions.map((position) => ({ + ...position, + takeProfitOrders: position.takeProfitOrders ?? [], + stopLossOrders: position.stopLossOrders ?? [], + })); + if (cachedOrders.length > 0) { + const { tpslMap, tpslCountMap, triggerOrderMap } = + this.#extractTPSLFromOrders([], positions, cachedOrders); + + positionsWithTPSL = this.#mergeTPSLIntoPositions( + positions, + tpslMap, + tpslCountMap, + triggerOrderMap, + ); + } + + // Update account state + const accountState: AccountState = adaptAccountStateFromSDK( + data.clearinghouseState, + ); + + // Update caches + this.#dexPositionsCache.set(cacheKey, positionsWithTPSL); + this.#dexAccountCache.set(cacheKey, accountState); + + // Mark this DEX as initialized (has sent first data) + this.#initializedDexs.add(cacheKey); + + // Trigger aggregation and notify subscribers + this.#aggregateAndNotifySubscribers(); + } + }, + ); + + this.#clearinghouseStateSubscriptions.set(dexName, subscription); + this.#deps.debugLogger.log( + `clearinghouseState subscription established for DEX: ${dexName || 'main'}`, + ); + } catch (error) { + // Remove this DEX from expected set so it doesn't block notifications for other DEXs + this.#expectedDexs.delete(dexName); + + this.#logErrorUnlessClearing( + ensureError( + error, + 'HyperLiquidSubscriptionService.createClearinghouseSubscription', + ), + this.#getErrorContext('ensureClearinghouseStateSubscription', { + dex: dexName, + }), + ); + throw error; + } + } + + /** + * Ensure openOrders subscription exists for a DEX + * Uses pending promise tracking to prevent race conditions where multiple + * concurrent calls could create duplicate subscriptions + * + * @param userAddress - The user's wallet address. + * @param dexName - The DEX identifier (empty string for main DEX). + * @returns A promise that resolves when the subscription is established. + */ + async #ensureOpenOrdersSubscription( + userAddress: string, + dexName: string, + ): Promise { + // Already subscribed + if (this.#openOrdersSubscriptions.has(dexName)) { + return; + } + + // Another call is already in progress - wait for it instead of creating duplicate + const pending = this.#pendingOpenOrdersSubscriptions.get(dexName); + if (pending) { + this.#deps.debugLogger.log( + `[ensureOpenOrdersSubscription] Waiting for pending subscription for DEX: ${dexName || 'main'}`, + ); + await pending; + return; + } + + // Create subscription promise and track it + const subscriptionPromise = this.#createOpenOrdersSubscription( + userAddress, + dexName, + ); + this.#pendingOpenOrdersSubscriptions.set(dexName, subscriptionPromise); + + try { + await subscriptionPromise; + } finally { + this.#pendingOpenOrdersSubscriptions.delete(dexName); + } + } + + /** + * Create the actual openOrders subscription + * Separated from ensureOpenOrdersSubscription to enable promise deduplication + * + * @param userAddress - The user's wallet address. + * @param dexName - The DEX identifier (empty string for main DEX). + */ + async #createOpenOrdersSubscription( + userAddress: string, + dexName: string, + ): Promise { + await this.#clientService.ensureSubscriptionClient( + this.#walletService.createWalletAdapter(), + ); + const subscriptionClient = this.#clientService.getSubscriptionClient(); + if (!subscriptionClient) { + throw new Error('Subscription client not available'); + } + + try { + const subscription = await subscriptionClient.openOrders( + { + user: userAddress, + dex: dexName || undefined, // Empty string -> undefined for main DEX + }, + (data: OpenOrdersWsEvent) => { + const cacheKey = data.dex || ''; + + // Update caches and notify subscribers if we have order subscribers + if ( + this.#orderSubscriberCount > 0 || + this.#positionSubscriberCount > 0 + ) { + // Get cached positions for TP/SL processing + const cachedPositions = this.#dexPositionsCache.get(cacheKey) ?? []; + + // Extract TP/SL and process orders + const { + tpslMap, + tpslCountMap, + triggerOrderMap, + processedOrders: orders, + } = this.#extractTPSLFromOrders(data.orders, cachedPositions); + + // Update orders cache with processed orders + this.#dexOrdersCache.set(cacheKey, orders); + + // Update positions with TP/SL if we have positions + if (cachedPositions.length > 0) { + const positionsWithTPSL = this.#mergeTPSLIntoPositions( + cachedPositions, + tpslMap, + tpslCountMap, + triggerOrderMap, + ); + this.#dexPositionsCache.set(cacheKey, positionsWithTPSL); + } + + // Mark this DEX as initialized (has sent first data) + this.#initializedDexs.add(cacheKey); + + // Trigger aggregation and notify subscribers + this.#aggregateAndNotifySubscribers(); + } + }, + ); + + this.#openOrdersSubscriptions.set(dexName, subscription); + this.#deps.debugLogger.log( + `openOrders subscription established for DEX: ${dexName || 'main'}`, + ); + } catch (error) { + // Remove this DEX from expected set so it doesn't block notifications for other DEXs + this.#expectedDexs.delete(dexName); + + this.#logErrorUnlessClearing( + ensureError( + error, + 'HyperLiquidSubscriptionService.createOpenOrdersSubscription', + ), + this.#getErrorContext('ensureOpenOrdersSubscription', { + dex: dexName, + }), + ); + throw error; + } + } + + /** + * Aggregate data from all DEX caches and notify subscribers if data changed + * Used by both webData3 callback and fallback subscription callbacks + */ + #aggregateAndNotifySubscribers(): void { + // Wait for all expected DEXs to send initial data before notifying + // This ensures positions from all DEXs appear simultaneously + if (this.#expectedDexs.size > 0) { + const allDexsInitialized = Array.from(this.#expectedDexs).every((dex) => + this.#initializedDexs.has(dex), + ); + if (!allDexsInitialized) { + this.#deps.debugLogger.log( + 'Waiting for all DEXs to send initial data', + { + expected: Array.from(this.#expectedDexs), + initialized: Array.from(this.#initializedDexs), + }, + ); + return; // Don't notify yet - waiting for more DEXs + } + } + + // Aggregate data from all DEX caches + // Order: Main DEX (crypto perps) first, then HIP-3 DEXs + const mainDexPositions = this.#dexPositionsCache.get('') ?? []; + const hip3DexPositions = Array.from(this.#dexPositionsCache.entries()) + .filter(([key]) => key !== '') + .flatMap(([, positions]) => positions); + const aggregatedPositions = [...mainDexPositions, ...hip3DexPositions]; + + const mainDexOrders = this.#dexOrdersCache.get('') ?? []; + const hip3DexOrders = Array.from(this.#dexOrdersCache.entries()) + .filter(([key]) => key !== '') + .flatMap(([, orders]) => orders); + const aggregatedOrders = [...mainDexOrders, ...hip3DexOrders]; + + const aggregatedAccount = this.#aggregateAccountStates(); + + // Check if aggregated data changed using fast hash comparison + const positionsHash = this.#hashPositions(aggregatedPositions); + const ordersHash = this.#hashOrders(aggregatedOrders); + const accountHash = this.#hashAccountState(aggregatedAccount); + + const positionsChanged = positionsHash !== this.#cachedPositionsHash; + const ordersChanged = ordersHash !== this.#cachedOrdersHash; + const accountChanged = accountHash !== this.#cachedAccountHash; + + // Only notify subscribers if aggregated data changed + if (positionsChanged) { + this.#cachedPositions = aggregatedPositions; + this.#cachedPositionsHash = positionsHash; + this.#positionsCacheInitialized = true; // Mark cache as initialized + this.#positionSubscribers.forEach((callback) => { + callback(aggregatedPositions); + }); + } + + if (ordersChanged) { + this.#cachedOrders = aggregatedOrders; + this.#cachedOrdersHash = ordersHash; + this.#ordersCacheInitialized = true; // Mark cache as initialized + this.#orderSubscribers.forEach((callback) => { + callback(aggregatedOrders); + }); + } + + if (accountChanged) { + this.#cachedAccount = aggregatedAccount; + this.#cachedAccountHash = accountHash; + this.#accountSubscribers.forEach((callback) => { + callback(aggregatedAccount); + }); + } + } + + /** + * Clean up webData3 subscription when no longer needed + */ + #cleanupSharedWebData3ISubscription(): void { + const totalSubscribers = + this.#positionSubscriberCount + + this.#orderSubscriberCount + + this.#accountSubscriberCount + + this.#oiCapSubscriberCount; + + if (totalSubscribers <= 0) { + // Cleanup webData3 subscription (covers all DEXs) + if (this.#webData3Subscriptions.size > 0) { + this.#webData3Subscriptions.forEach((subscription, dexName) => { + subscription.unsubscribe().catch((error: Error) => { + this.#logErrorUnlessClearing( + ensureError( + error, + 'HyperLiquidSubscriptionService.cleanupSharedWebData3ISubscription', + ), + this.#getErrorContext( + 'cleanupSharedWebData3ISubscription.webData3', + { + dex: dexName, + }, + ), + ); + }); + }); + this.#webData3Subscriptions.clear(); + this.#webData3SubscriptionPromise = undefined; + } + + // Cleanup spotState subscriptions (per-user). Bump generation + + // drop in-flight promises so a racing #ensureSpotStateSubscription + // continuation discards its subscription rather than rehydrating + // #spotStateSubscriptions after this clear. + this.#spotStateSubscriptionGeneration += 1; + this.#spotStateSubscriptionPromises.clear(); + if (this.#spotStateSubscriptions.size > 0) { + this.#spotStateSubscriptions.forEach((subscription, user) => { + subscription.unsubscribe().catch((error: Error) => { + this.#logErrorUnlessClearing( + ensureError( + error, + 'HyperLiquidSubscriptionService.cleanupSharedWebData3ISubscription', + ), + this.#getErrorContext( + 'cleanupSharedWebData3ISubscription.spotState', + { user }, + ), + ); + }); + }); + this.#spotStateSubscriptions.clear(); + } + + // Cleanup individual subscriptions (clearinghouseState + openOrders) + if (this.#clearinghouseStateSubscriptions.size > 0) { + this.#clearinghouseStateSubscriptions.forEach( + (subscription, dexName) => { + subscription.unsubscribe().catch((error: Error) => { + this.#logErrorUnlessClearing( + ensureError( + error, + 'HyperLiquidSubscriptionService.cleanupSharedWebData3ISubscription', + ), + this.#getErrorContext( + 'cleanupSharedWebData3ISubscription.clearinghouseState', + { + dex: dexName, + }, + ), + ); + }); + }, + ); + this.#clearinghouseStateSubscriptions.clear(); + } + + if (this.#openOrdersSubscriptions.size > 0) { + this.#openOrdersSubscriptions.forEach((subscription, dexName) => { + subscription.unsubscribe().catch((error: Error) => { + this.#logErrorUnlessClearing( + ensureError( + error, + 'HyperLiquidSubscriptionService.cleanupSharedWebData3ISubscription', + ), + this.#getErrorContext( + 'cleanupSharedWebData3ISubscription.openOrders', + { + dex: dexName, + }, + ), + ); + }); + }); + this.#openOrdersSubscriptions.clear(); + } + + // Clear pending subscription promises (race condition prevention) + this.#pendingClearinghouseSubscriptions.clear(); + this.#pendingOpenOrdersSubscriptions.clear(); + + // Clear subscriber counts + this.#positionSubscriberCount = 0; + this.#orderSubscriberCount = 0; + this.#accountSubscriberCount = 0; + this.#oiCapSubscriberCount = 0; + + // Clear per-DEX caches + this.#dexPositionsCache.clear(); + this.#dexOrdersCache.clear(); + this.#dexAccountCache.clear(); + + // Clear DEX tracking for synchronized notifications + this.#expectedDexs.clear(); + this.#initializedDexs.clear(); + + // Clear aggregated caches + this.#cachedPositions = null; + this.#cachedOrders = null; + this.#cachedAccount = null; + this.#cachedSpotState = null; + this.#cachedSpotStateUserAddress = null; + this.#abstractionModeByUser.clear(); + this.#abstractionModeLastWsRefreshAtByUser.clear(); + // Drop in-flight refresh handles so stale hanging userAbstraction + // requests from the prior connection can't be awaited by future calls. + this.#abstractionModeInflightByUser.clear(); + // Bump generation so any in-flight spot fetch from a prior user discards + // its result instead of re-populating the cache post-cleanup. + this.#spotStateGeneration += 1; + this.#spotStatePromise = undefined; + this.#spotStatePromiseUserAddress = undefined; + this.#ordersCacheInitialized = false; // Reset cache initialization flag + this.#positionsCacheInitialized = false; // Reset cache initialization flag + + // Clear hash caches + this.#cachedPositionsHash = ''; + this.#cachedOrdersHash = ''; + this.#cachedAccountHash = ''; + + this.#deps.debugLogger.log( + 'All multi-DEX subscriptions cleaned up (webData3 + individual subscriptions)', + ); + } + } + + /** + * Subscribe to live position updates with TP/SL data + * + * @param params - The subscription parameters including callback and account ID. + * @returns A cleanup function to unsubscribe from position updates. + */ + public subscribeToPositions(params: SubscribePositionsParams): () => void { + const { callback, accountId } = params; + const unsubscribe = this.#createSubscription( + this.#positionSubscribers, + callback, + ); + + // Increment position subscriber count + this.#positionSubscriberCount += 1; + + // Immediately provide cached data if available + if (this.#cachedPositions) { + callback(this.#cachedPositions); + } + + // Ensure shared subscription is active + this.#ensureSharedWebData3Subscription(accountId).catch((error) => { + this.#logErrorUnlessClearing( + ensureError( + error, + 'HyperLiquidSubscriptionService.subscribeToPositions', + ), + this.#getErrorContext('subscribeToPositions'), + ); + }); + + return () => { + unsubscribe(); + this.#positionSubscriberCount -= 1; + this.#cleanupSharedWebData3ISubscription(); + }; + } + + /** + * Subscribe to open interest cap updates + * OI caps are extracted from the shared webData3 subscription (zero additional overhead) + * + * @param params - The subscription parameters including callback and account ID. + * @returns A cleanup function to unsubscribe from OI cap updates. + */ + public subscribeToOICaps(params: SubscribeOICapsParams): () => void { + const { callback, accountId } = params; + + // Create subscription + const unsubscribe = this.#createSubscription( + this.#oiCapSubscribers, + callback, + ); + + // Increment OI cap subscriber count + this.#oiCapSubscriberCount += 1; + + // Immediately provide cached data if available + if (this.#cachedOICaps) { + callback(this.#cachedOICaps); + } + + // Ensure webData3 subscription is active (OI caps come from webData3) + this.#ensureSharedWebData3Subscription(accountId).catch((error) => { + this.#logErrorUnlessClearing( + ensureError(error, 'HyperLiquidSubscriptionService.subscribeToOICaps'), + this.#getErrorContext('subscribeToOICaps'), + ); + }); + + return () => { + unsubscribe(); + this.#oiCapSubscriberCount -= 1; + this.#cleanupSharedWebData3ISubscription(); + }; + } + + /** + * Check if OI caps cache has been initialized + * Useful for preventing UI flashing before first data arrives + * + * @returns True if the condition is met. + */ + public isOICapsCacheInitialized(): boolean { + return this.#oiCapsCacheInitialized; + } + + /** + * Subscribe to live order fill updates + * Shares subscriptions per accountId to avoid duplicate WebSocket connections + * + * @param params - The subscription parameters including callback and account ID. + * @returns A cleanup function to unsubscribe from order fill updates. + */ + public subscribeToOrderFills(params: SubscribeOrderFillsParams): () => void { + const { callback, accountId } = params; + // Normalize accountId: undefined -> 'default' for Map key + const normalizedAccountId = accountId ?? 'default'; + const unsubscribe = this.#createSubscription( + this.#orderFillSubscribers, + callback, + normalizedAccountId, + ); + + // Ensure subscription is established for this accountId + this.#ensureOrderFillISubscription(accountId).catch((error) => { + this.#logErrorUnlessClearing( + ensureError( + error, + 'HyperLiquidSubscriptionService.subscribeToOrderFills', + ), + this.#getErrorContext('subscribeToOrderFills'), + ); + }); + + return () => { + unsubscribe(); + + // If no more subscribers for this accountId, clean up subscription + const subscribers = this.#orderFillSubscribers.get(normalizedAccountId); + if (!subscribers || subscribers.size === 0) { + const subscription = + this.#orderFillSubscriptions.get(normalizedAccountId); + if (subscription) { + subscription.unsubscribe().catch((error: Error) => { + this.#logErrorUnlessClearing( + ensureError( + error, + 'HyperLiquidSubscriptionService.subscribeToOrderFills', + ), + this.#getErrorContext('subscribeToOrderFills.unsubscribe'), + ); + }); + this.#orderFillSubscriptions.delete(normalizedAccountId); + } + } + }; + } + + /** + * Ensure order fill subscription is active for the given accountId + * Shares subscription across all callbacks for the same accountId + * + * @param accountId - Optional CAIP account ID to subscribe for. + * @returns A promise that resolves when the subscription is established. + */ + async #ensureOrderFillISubscription( + accountId?: CaipAccountId, + ): Promise { + // Normalize accountId: undefined -> 'default' for Map key + const normalizedAccountId = accountId ?? 'default'; + + // If subscription already exists, no need to create another + if (this.#orderFillSubscriptions.has(normalizedAccountId)) { + return; + } + + const subscriptionClient = this.#clientService.getSubscriptionClient(); + if (!subscriptionClient) { + await this.#clientService.ensureSubscriptionClient( + this.#walletService.createWalletAdapter(), + ); + const client = this.#clientService.getSubscriptionClient(); + if (!client) { + throw new Error('SubscriptionClient not available'); + } + await this.#ensureOrderFillISubscription(accountId); + return; + } + + const userAddress = + await this.#walletService.getUserAddressWithDefault(accountId); + + // userFills returns a Promise, need to await it + const subscription = await subscriptionClient.userFills( + { user: userAddress }, + (data: UserFillsWsEvent) => { + // Build a Map for O(1) lookup instead of O(n) find per fill + const orderMap = new Map(); + if (this.#cachedOrders) { + for (const order of this.#cachedOrders) { + if (order.detailedOrderType) { + orderMap.set(order.orderId, order.detailedOrderType); + } + } + } + const orderFills: OrderFill[] = data.fills.map((fill) => { + const oid = fill.oid.toString(); + return { + orderId: oid, + symbol: fill.coin, + side: fill.side, + size: fill.sz, + price: fill.px, + fee: fill.fee, + timestamp: fill.time, + pnl: fill.closedPnl, + direction: fill.dir, + feeToken: fill.feeToken, + startPosition: fill.startPosition, + liquidation: fill.liquidation + ? { + liquidatedUser: fill.liquidation.liquidatedUser, + markPx: fill.liquidation.markPx, + method: fill.liquidation.method, + } + : undefined, + detailedOrderType: orderMap.get(oid), + }; + }); + + // Cache fills for cache-first pattern (similar to price caching) + // This allows getOrFetchFills() to return cached data without REST API calls + if (data.isSnapshot) { + // Snapshot: replace cache with initial historical data, sorted newest first + this.#cachedFills = [...orderFills] + .sort((a, b) => b.timestamp - a.timestamp) + .slice(0, 100); + this.#fillsCacheInitialized = true; + } else { + // Streaming: prepend new fills to existing (newest first) + this.#cachedFills = [ + ...orderFills, + ...(this.#cachedFills ?? []), + ].slice(0, 100); + } + + // Distribute to all callbacks for this accountId + const subscribers = this.#orderFillSubscribers.get(normalizedAccountId); + if (subscribers) { + subscribers.forEach((callback) => { + callback(orderFills, data.isSnapshot); + }); + } + }, + ); + + this.#orderFillSubscriptions.set(normalizedAccountId, subscription); + } + + /** + * Subscribe to live order updates + * Uses the shared per-DEX subscriptions to avoid duplicate connections + * + * @param params - The subscription parameters including callback and account ID. + * @returns A cleanup function to unsubscribe from order updates. + */ + public subscribeToOrders(params: SubscribeOrdersParams): () => void { + const { callback, accountId } = params; + const unsubscribe = this.#createSubscription( + this.#orderSubscribers, + callback, + ); + + // Increment order subscriber count + this.#orderSubscriberCount += 1; + + // Immediately provide cached data if available + if (this.#cachedOrders) { + callback(this.#cachedOrders); + } + + // Ensure shared subscription is active + this.#ensureSharedWebData3Subscription(accountId).catch((error) => { + this.#logErrorUnlessClearing( + ensureError(error, 'HyperLiquidSubscriptionService.subscribeToOrders'), + this.#getErrorContext('subscribeToOrders'), + ); + }); + + return () => { + unsubscribe(); + this.#orderSubscriberCount -= 1; + this.#cleanupSharedWebData3ISubscription(); + }; + } + + /** + * Subscribe to live account updates + * Uses the shared per-DEX subscriptions to avoid duplicate connections + * + * @param params - The subscription parameters including callback and account ID. + * @returns A cleanup function to unsubscribe from account updates. + */ + public subscribeToAccount(params: SubscribeAccountParams): () => void { + const { callback, accountId } = params; + const unsubscribe = this.#createSubscription( + this.#accountSubscribers, + callback, + ); + + // Increment account subscriber count + this.#accountSubscriberCount += 1; + + // Immediately provide cached data if available. May be spot-less if the + // spot fetch has not resolved yet (or permanently failed) — subscribers + // prefer stale-but-present data over silent starvation; the next + // aggregation after #ensureSpotState / next WebSocket update pushes the + // spot-inclusive value. + if (this.#cachedAccount) { + callback(this.#cachedAccount); + } + + this.#ensureSpotState(accountId).catch((error) => { + this.#logErrorUnlessClearing( + ensureError(error, 'HyperLiquidSubscriptionService.subscribeToAccount'), + this.#getErrorContext('subscribeToAccount.ensureSpotState'), + ); + }); + + this.#ensureSpotStateSubscription(accountId).catch((error) => { + this.#logErrorUnlessClearing( + ensureError(error, 'HyperLiquidSubscriptionService.subscribeToAccount'), + this.#getErrorContext('subscribeToAccount.ensureSpotStateSubscription'), + ); + }); + + // Ensure shared subscription is active (reuses existing connection) + this.#ensureSharedWebData3Subscription(accountId).catch((error) => { + this.#logErrorUnlessClearing( + ensureError(error, 'HyperLiquidSubscriptionService.subscribeToAccount'), + this.#getErrorContext('subscribeToAccount'), + ); + }); + + return () => { + unsubscribe(); + this.#accountSubscriberCount -= 1; + this.#cleanupSharedWebData3ISubscription(); + }; + } + + /** + * Check if orders cache has been initialized from WebSocket + * + * @returns true if WebSocket has sent at least one update, false otherwise + */ + public isOrdersCacheInitialized(): boolean { + return this.#ordersCacheInitialized; + } + + /** + * Check if positions cache has been initialized from WebSocket + * + * @returns true if WebSocket has sent at least one update, false otherwise + */ + public isPositionsCacheInitialized(): boolean { + return this.#positionsCacheInitialized; + } + + /** + * Get the cached positions for one DEX, or null when that DEX has published + * none this session. + * + * A DEX only enters this map once its `clearinghouseState` subscription has + * published, so `null` means the absence of a symbol proves nothing about + * whether a position exists there. + * + * Prefer this over `getCachedPositions()` when a decision depends on whether a + * specific symbol is absent. The aggregate is only rebuilt once *every* + * expected DEX has published (`#aggregateAndNotifySubscribers`), so after a + * reconnect — which resets `#initializedDexs` without clearing these caches — + * the aggregate can sit frozen at its pre-reconnect contents while this map + * keeps receiving per-DEX updates. Deciding "covered" from this map and then + * reading the symbol from the aggregate would mix a fresh answer with stale + * data. + * + * @param dexName - DEX identifier, or '' for the main DEX. + * @returns That DEX's cached positions, or null if it has not published. + */ + public getCachedPositionsForDex(dexName: string): Position[] | null { + return this.#dexPositionsCache.get(dexName) ?? null; + } + + /** + * Get cached positions from WebSocket subscription + * + * @returns Cached positions array, or null if not initialized + */ + public getCachedPositions(): Position[] | null { + return this.#cachedPositions; + } + + /** + * Get cached orders from WebSocket subscription + * + * @returns Cached orders array, or null if not initialized + */ + public getCachedOrders(): Order[] | null { + return this.#cachedOrders; + } + + /** + * Atomically get cached orders if initialized + * Prevents race condition between checking initialization and getting data + * + * @returns Cached orders array if initialized, null otherwise + */ + public getOrdersCacheIfInitialized(): Order[] | null { + if (!this.#ordersCacheInitialized) { + return null; + } + return this.#cachedOrders ? [...this.#cachedOrders] : []; + } + + /** + * Get cached price for a symbol from WebSocket allMids subscription + * OPTIMIZATION: Use this instead of REST infoClient.allMids() to avoid rate limiting + * + * @param symbol - Asset symbol (e.g., 'BTC', 'ETH', 'xyz:TSLA') + * @returns Price string, or undefined if not cached + */ + public getCachedPrice(symbol: string): string | undefined { + return this.#cachedPriceData?.get(symbol)?.price; + } + + public getLastAllMidsSnapshot(dex?: string): Record | null { + const dexKey = dex ?? ''; + const snapshot = this.#allMidsSnapshots.get(dexKey); + if (!snapshot) { + return null; + } + return { ...snapshot }; + } + + /** + * Get cached fills from WebSocket userFills subscription + * OPTIMIZATION: Use this instead of REST userFills() to avoid rate limiting + * + * @returns Copy of cached fills array, or null if not cached + */ + public getCachedFills(): OrderFill[] | null { + return this.#cachedFills ? [...this.#cachedFills] : null; + } + + /** + * Get cached fills only if the cache has been initialized from WebSocket + * OPTIMIZATION: Distinguishes between "not initialized" (null) and "initialized but empty" ([]) + * - Returns null if cache hasn't received WebSocket snapshot yet (caller should use REST) + * - Returns empty array [] if cache is initialized but user has no fills (caller can skip REST) + * - Returns fills array if cache has data + * + * @returns Fills array or empty array if initialized, null if not yet initialized + */ + public getFillsCacheIfInitialized(): OrderFill[] | null { + if (!this.#fillsCacheInitialized) { + return null; + } + return this.#cachedFills ? [...this.#cachedFills] : []; + } + + /** + * Create subscription with common error handling + * + * @param subscribers - The subscriber set or map to register the callback in. + * @param callback - The callback function to invoke on updates. + * @param key - Optional key for Map-based subscriber collections. + * @returns A cleanup function to remove the subscription. + */ + #createSubscription( + subscribers: Set | Map>, + callback: TCallback, + key?: string, + ): () => void { + if (subscribers instanceof Map && key) { + if (!subscribers.has(key)) { + subscribers.set(key, new Set()); + } + subscribers.get(key)?.add(callback); + } else if (subscribers instanceof Set) { + subscribers.add(callback); + } + + return () => { + if (subscribers instanceof Map && key) { + const set = subscribers.get(key); + set?.delete(callback); + if (set?.size === 0) { + subscribers.delete(key); + } + } else if (subscribers instanceof Set) { + subscribers.delete(callback); + } + }; + } + + /** + * Helper function to create consolidated price updates with 24h change calculation + * + * @param symbol - The trading pair symbol. + * @param price - The current price string. + * @returns A consolidated price update object with change data. + */ + #createPriceUpdate(symbol: string, price: string): PriceUpdate { + const marketData = this.#marketDataCache.get(symbol); + const orderBookData = this.#orderBookCache.get(symbol); + const currentPrice = parseFloat(price); + + let percentChange24h: string | undefined; + if (marketData?.prevDayPx !== undefined) { + const change = + ((currentPrice - marketData.prevDayPx) / marketData.prevDayPx) * 100; + percentChange24h = change.toFixed(2); + } + + // Check if any subscriber for this symbol wants market data + const hasMarketDataSubscribers = + this.#marketDataSubscribers.has(symbol) && + (this.#marketDataSubscribers.get(symbol)?.size ?? 0) > 0; + + const priceUpdate = { + symbol, + price, + timestamp: Date.now(), + percentChange24h, + // Add mark price from activeAssetCtx + markPrice: marketData?.oraclePrice + ? marketData.oraclePrice.toString() + : undefined, + // Add order book data if available + bestBid: orderBookData?.bestBid, + bestAsk: orderBookData?.bestAsk, + spread: orderBookData?.spread, + // Always include funding when available (don't default to 0, preserve undefined) + funding: marketData?.funding, + // Add market data only if requested by at least one subscriber + openInterest: hasMarketDataSubscribers + ? marketData?.openInterest + : undefined, + volume24h: hasMarketDataSubscribers ? marketData?.volume24h : undefined, + // Flag markets that are currently untradable because the mid price has drifted + // too far from the oracle price (HyperLiquid rejects such orders). Lets clients + // warn the user before they attempt an order that would fail. Defaults to tradable + // when the oracle price isn't yet cached. + isTradable: isMarketTradable({ + midPrice: currentPrice, + oraclePrice: marketData?.oraclePrice, + deviationLimit: this.#priceDeviationLimit, + }), + }; + + return priceUpdate; + } + + /** + * Returns the fresh `activeAssetCtx` price string for a symbol, or + * `undefined` when no price is cached or the cached price is older than + * `#activeAssetCtxPriceTtlMs` (10 s). + * + * Single source of truth for the staleness check used by + * `#projectPriceUpdate`, `#notifyAllPriceSubscribers`, and the immediate + * emit in `subscribeToPrices`. + * + * @param symbol - The asset symbol to look up (e.g. `'BTC'`). + * @returns The price as a string when fresh, or `undefined` when absent/stale. + */ + #getFreshActiveAssetCtxPrice(symbol: string): string | undefined { + const marketData = this.#marketDataCache.get(symbol); + if ( + marketData?.activeAssetCtxPrice === undefined || + marketData.priceLastUpdated === undefined + ) { + return undefined; + } + if ( + Date.now() - marketData.priceLastUpdated > + HyperLiquidSubscriptionService.#activeAssetCtxPriceTtlMs + ) { + return undefined; + } + return marketData.activeAssetCtxPrice.toString(); + } + + /** + * Project a base PriceUpdate (allMids baseline) onto the per-symbol fast-stream + * price for focused (`includeMarketData: true`) subscribers. + * + * Returns `base` unchanged when no fresh `activeAssetCtxPrice` is available + * (absent or older than the 10 s TTL). Otherwise returns a shallow clone of + * `base` with `price` and `timestamp` overridden by the fast-stream value. + * All other fields (funding, openInterest, isTradable, etc.) are inherited + * from the allMids baseline so cumulative metrics stay consistent. + * + * @param symbol - The asset symbol whose fast-stream price to look up. + * @param base - The allMids baseline `PriceUpdate` to project onto. + * @returns A `PriceUpdate` with the fast-stream price when fresh, or `base` unchanged. + */ + #projectPriceUpdate(symbol: string, base: PriceUpdate): PriceUpdate { + const fastPrice = this.#getFreshActiveAssetCtxPrice(symbol); + if (fastPrice === undefined) { + return base; + } + return { + ...base, + price: fastPrice, + timestamp: Date.now(), + }; + } + + /** + * Ensure global allMids subscription is active (singleton pattern) + * + * NOTE ON PUSH CADENCE: Hyperliquid throttles the main-DEX allMids stream to + * push every ~5 seconds. This cadence is acceptable for list/overview screens + * that show many symbols simultaneously, but would make a focused single-symbol + * view (trade detail, order ticket) feel noticeably stale. + * + * Mitigation: when a subscription is created with `includeMarketData: true`, + * #ensureActiveAssetSubscription establishes a per-symbol activeAssetCtx + * WebSocket that ticks at a faster cadence. #notifyAllPriceSubscribers + * projects the fast-stream price (with a 10 s staleness gate via + * #activeAssetCtxPriceTtlMs) for focused (includeMarketData: true) callbacks + * only; list/overview callbacks always receive the raw allMids baseline so + * the two subscriber types are guaranteed separate price sources. + */ + #ensureGlobalAllMidsSubscription(): void { + // Check both the subscription AND the promise to prevent race conditions + if (this.#globalAllMidsSubscription ?? this.#globalAllMidsPromise) { + return; + } + + const subscriptionClient = this.#clientService.getSubscriptionClient(); + if (!subscriptionClient) { + return; + } + + // Track WebSocket metrics + const wsMetrics = { + messagesReceived: 0, + lastMessageTime: Date.now(), + reconnectCount: 0, + startTime: Date.now(), + }; + + // Store the promise immediately to prevent duplicate calls + this.#globalAllMidsPromise = subscriptionClient + .allMids((data: AllMidsWsEvent) => { + wsMetrics.messagesReceived += 1; + wsMetrics.lastMessageTime = Date.now(); + + // Initialize cache if needed + this.#cachedPriceData ??= new Map(); + + // Store raw snapshot for the main DEX so market fetches can reuse it without REST. + this.#allMidsSnapshots.set('', data.mids as Record); + + const subscribedSymbols = new Set(); + + // Collect all symbols that have subscribers + for (const [ + symbol, + subscriberSet, + ] of this.#priceSubscribers.entries()) { + if (subscriberSet.size > 0) { + subscribedSymbols.add(symbol); + } + } + + // Track which subscribed symbols actually changed price, so + // notification can be scoped to just those symbols + const changedSymbols = new Set(); + + // Only process symbols that are actually subscribed to + for (const symbol in data.mids) { + // Skip if nobody is subscribed to this symbol + if (!subscribedSymbols.has(symbol)) { + continue; + } + + const price = data.mids[symbol].toString(); + const cachedPrice = this.#cachedPriceData.get(symbol); + + // Skip if price hasn't changed + if (cachedPrice?.price === price) { + continue; + } + + // Price changed or new symbol - update cache + const priceUpdate = this.#createPriceUpdate(symbol, price); + this.#cachedPriceData.set(symbol, priceUpdate); + changedSymbols.add(symbol); + } + + // Only notify subscribers of symbols whose price actually changed + // This prevents unnecessary React re-renders when prices haven't changed + if (changedSymbols.size > 0) { + this.#notifyAllPriceSubscribers(changedSymbols); + } + }) + .then((sub) => { + this.#globalAllMidsSubscription = sub; + this.#deps.debugLogger.log( + 'HyperLiquid: Global allMids subscription established', + ); + + // Notify existing subscribers with any cached data now that subscription is established + if (this.#cachedPriceData && this.#cachedPriceData.size > 0) { + this.#notifyAllPriceSubscribers(); + } + return undefined; + }) + .catch((error) => { + // Clear the promise on error so it can be retried + this.#globalAllMidsPromise = undefined; + + this.#logErrorUnlessClearing( + ensureError( + error, + 'HyperLiquidSubscriptionService.ensureGlobalAllMidsSubscription', + ), + this.#getErrorContext('ensureGlobalAllMidsSubscription'), + ); + }); + } + + /** + * Ensure global fastAssetCtxs subscription is active (singleton pattern) + * + * TAT-3387: Hyperliquid slowed the public assetCtxs feed cadence and + * introduced fastAssetCtxs to preserve a fast (~5 s) cadence specifically + * for mark/mid price diffs. This subscription owns the #cachedPriceData + * price path going forward; assetCtxs continues to populate + * #marketDataCache (funding/OI/volume/oracle price) unchanged, and remains + * the price source for any symbol fastAssetCtxs does not cover. + * + * The SDK exposes fastAssetCtxs as a single global feed with no `dex` + * parameter (unlike assetCtxs, which is per-DEX). The first message after + * subscribing is a full snapshot keyed by coin; later messages contain + * diffs for only the coins that changed. Every coin with a usable price is + * cached in #cachedPriceData regardless of whether it currently has a + * subscriber, so a later subscriber gets an immediate baseline instead of + * waiting for the next snapshot/diff that happens to include the coin. + * Notification via #notifyAllPriceSubscribers is still scoped to coins + * with an active subscriber, matching the allMids handler's filtering. + */ + #ensureGlobalFastAssetCtxsSubscription(): void { + // Check both the subscription AND the promise to prevent race conditions + if ( + this.#globalFastAssetCtxsSubscription ?? + this.#globalFastAssetCtxsPromise + ) { + return; + } + + const subscriptionClient = this.#clientService.getSubscriptionClient(); + if (!subscriptionClient) { + return; + } + + const handleFastAssetCtxsUpdate = (data: FastAssetCtxsWsEvent): void => { + this.#cachedPriceData ??= new Map(); + + // Track which subscribed symbols actually changed price, so + // notification can be scoped to just those symbols + const changedSymbols = new Set(); + + for (const coin in data) { + if (!hasProperty(data, coin)) { + continue; + } + + const ctx = data[coin]; + const priceRaw = ctx.midPx ?? ctx.markPx; + if (priceRaw === undefined || priceRaw === null) { + // No usable price for this coin in this message — don't claim + // ownership. Otherwise a coin with no usable price here would be + // marked as fastAssetCtxs-owned while never having a fast price + // cached, suppressing assetCtxs (its only remaining price source) + // for that coin indefinitely. + continue; + } + + // Mark this coin as covered by fastAssetCtxs now that a usable + // price backs that ownership (regardless of whether there's + // currently a subscriber), so the slower per-DEX assetCtxs handler + // knows to defer to this feed for the coin's price. + this.#fastAssetCtxsCoins.add(coin); + + const price = priceRaw.toString(); + const cachedPrice = this.#cachedPriceData.get(coin); + + // Skip if price hasn't changed + if (cachedPrice?.price === price) { + continue; + } + + const priceUpdate = this.#createPriceUpdate(coin, price); + // Cache every valid price, even for coins nobody is subscribed to + // yet (snapshot messages include every asset on the exchange), so + // a later subscriber gets an immediate baseline via the + // subscribe-time cached-price replay instead of an assetCtxs feed + // that's been suppressed with no fastAssetCtxs price to fall back + // on. + this.#cachedPriceData.set(coin, priceUpdate); + + // Scope notification to coins with an active subscriber; snapshot + // messages cover the full exchange and most coins have none. + if (this.#priceSubscribers.get(coin)?.size) { + changedSymbols.add(coin); + } + } + + // Only notify subscribers of symbols whose price actually changed + if (changedSymbols.size > 0) { + this.#notifyAllPriceSubscribers(changedSymbols); + } + }; + + const subscribeWithRetry = async (): Promise => { + const maxAttempts = 3; + + for (let attempt = 1; attempt <= maxAttempts; attempt++) { + try { + return await subscriptionClient.fastAssetCtxs( + handleFastAssetCtxsUpdate, + ); + } catch (error) { + const ensuredError = ensureError( + error, + 'HyperLiquidSubscriptionService.ensureGlobalFastAssetCtxsSubscription', + ); + const isLastAttempt = attempt === maxAttempts; + if (isLastAttempt || !this.#isTransientSdkError(ensuredError)) { + throw ensuredError; + } + + const retryDelayMs = attempt * 500; + this.#deps.debugLogger.log( + 'Transient fastAssetCtxs subscription failure during reconnect, retrying', + { + attempt, + retryDelayMs, + error: ensuredError.message, + }, + ); + await new Promise((_resolve) => setTimeout(_resolve, retryDelayMs)); + } + } + + throw new Error('Failed to establish fastAssetCtxs subscription'); + }; + + // Store the promise immediately to prevent duplicate calls + this.#globalFastAssetCtxsPromise = subscribeWithRetry() + .then((sub) => { + this.#globalFastAssetCtxsSubscription = sub; + this.#deps.debugLogger.log( + 'HyperLiquid: Global fastAssetCtxs subscription established', + ); + return undefined; + }) + .catch((error) => { + // Clear the promise on error so it can be retried + this.#globalFastAssetCtxsPromise = undefined; + + this.#logErrorUnlessClearing( + ensureError( + error, + 'HyperLiquidSubscriptionService.ensureGlobalFastAssetCtxsSubscription', + ), + this.#getErrorContext('ensureGlobalFastAssetCtxsSubscription'), + ); + }); + } + + /** + * Ensure activeAssetCtx subscription for specific symbol (with reference counting) + * + * @param symbol - The trading pair symbol to subscribe to. + */ + #ensureActiveAssetSubscription(symbol: string): void { + // Increment subscriber count + const currentCount = this.#symbolSubscriberCounts.get(symbol) ?? 0; + this.#symbolSubscriberCounts.set(symbol, currentCount + 1); + + // If subscription already exists or is being created, just return + if ( + this.#globalActiveAssetSubscriptions.has(symbol) || + this.#pendingActiveAssetPromises.has(symbol) + ) { + return; + } + + const subscriptionClient = this.#clientService.getSubscriptionClient(); + if (!subscriptionClient) { + return; + } + + // Track metrics for this subscription + const subscriptionMetrics = { + messagesReceived: 0, + startTime: Date.now(), + }; + + const promise = subscriptionClient + .activeAssetCtx( + { coin: symbol }, + (data: ActiveAssetCtxWsEvent | ActiveSpotAssetCtxWsEvent) => { + subscriptionMetrics.messagesReceived += 1; + + if (data.coin === symbol && data.ctx) { + // Type guard using SDK types: check if this is perps (has funding) or spot (no funding) + const isPerpsContext = ( + event: ActiveAssetCtxWsEvent | ActiveSpotAssetCtxWsEvent, + ): event is ActiveAssetCtxWsEvent => + hasProperty(event.ctx, 'funding') && + hasProperty(event.ctx, 'openInterest') && + hasProperty(event.ctx, 'oraclePx'); + + const { ctx } = data; + + // Cache market data for consolidation with price updates + const ctxPrice = ctx.midPx ?? ctx.markPx; + const now = Date.now(); + const openInterestUSD = + isPerpsContext(data) && ctxPrice + ? calculateOpenInterestUSD(data.ctx.openInterest, ctxPrice) + : NaN; + const marketData = { + prevDayPx: ctx.prevDayPx + ? parseFloat(ctx.prevDayPx.toString()) + : undefined, + // Cache funding rate from activeAssetCtx for real-time updates + // SDK defines funding as string (not nullable) in ActiveAssetCtxEvent + funding: isPerpsContext(data) + ? parseFloat(data.ctx.funding.toString()) + : undefined, + openInterest: isNaN(openInterestUSD) + ? undefined + : openInterestUSD, + volume24h: ctx.dayNtlVlm + ? parseFloat(ctx.dayNtlVlm.toString()) + : undefined, + oraclePrice: isPerpsContext(data) + ? parseFloat(data.ctx.oraclePx.toString()) + : undefined, + lastUpdated: now, + // Store fast-stream price for per-subscriber projection in + // #notifyAllPriceSubscribers. Used only for focused subscribers. + activeAssetCtxPrice: ctxPrice + ? parseFloat(ctxPrice.toString()) + : undefined, + priceLastUpdated: ctxPrice ? now : undefined, + }; + + this.#marketDataCache.set(symbol, marketData); + + // Rebuild the allMids baseline so derived fields (isTradable, + // funding, openInterest, volume24h, markPrice, percentChange24h) + // pick up the new activeAssetCtx data. Only rebuild when a baseline + // already exists to preserve the startup zero-price guard: we never + // want to synthesize a baseline from a '0' / absent allMids price. + const priceCache = this.#cachedPriceData; + const existingBaseline = priceCache?.get(symbol); + if (priceCache && existingBaseline) { + priceCache.set( + symbol, + this.#createPriceUpdate(symbol, existingBaseline.price), + ); + } + + // Notify subscribers of this symbol only. #notifyAllPriceSubscribers + // projects the fast-stream price (now stored in #marketDataCache) for + // focused (includeMarketData: true) subscribers, while list subscribers + // continue to receive only the allMids baseline from #cachedPriceData. + // Scoping to this symbol avoids redundant reference-equal allMids + // updates to list subscribers watching other symbols, since their + // allMids baseline hasn't changed on this tick. + this.#notifyAllPriceSubscribers(new Set([symbol])); + } + }, + ) + .then((sub) => { + // Only clear pending ref if this is still the current promise. + // A rapid away-and-back can replace the pending promise; blindly + // deleting would remove the *newer* reference (#28141). + if (this.#pendingActiveAssetPromises.get(symbol) === promise) { + this.#pendingActiveAssetPromises.delete(symbol); + } + // Stale subscription: cleanup was called while pending, a newer + // subscription already won the race, OR a different pending promise + // exists (rapid away-and-back before this one resolved). (#28141) + if ( + (this.#symbolSubscriberCounts.get(symbol) ?? 0) <= 0 || + this.#globalActiveAssetSubscriptions.has(symbol) || + this.#pendingActiveAssetPromises.has(symbol) + ) { + return sub.unsubscribe(); + } + this.#globalActiveAssetSubscriptions.set(symbol, sub); + this.#deps.debugLogger.log( + `HyperLiquid: Market data subscription established for ${symbol}`, + ); + return undefined; + }) + .catch((error) => { + if (this.#pendingActiveAssetPromises.get(symbol) === promise) { + this.#pendingActiveAssetPromises.delete(symbol); + } + this.#logErrorUnlessClearing( + ensureError( + error, + 'HyperLiquidSubscriptionService.ensureActiveAssetSubscription', + ), + this.#getErrorContext('ensureActiveAssetSubscription', { symbol }), + ); + }); + + this.#pendingActiveAssetPromises.set(symbol, promise); + } + + /** + * Cleanup activeAssetCtx subscription when no longer needed + * + * @param symbol - The trading pair symbol to clean up. + */ + #cleanupActiveAssetSubscription(symbol: string): void { + const currentCount = this.#symbolSubscriberCounts.get(symbol) ?? 0; + if (currentCount <= 1) { + // Last subscriber, cleanup subscription + this.#symbolSubscriberCounts.delete(symbol); + + const subscription = this.#globalActiveAssetSubscriptions.get(symbol); + if (subscription && typeof subscription.unsubscribe === 'function') { + const unsubscribeResult = Promise.resolve(subscription.unsubscribe()); + + unsubscribeResult.catch(() => { + // Ignore errors during cleanup + }); + this.#globalActiveAssetSubscriptions.delete(symbol); + } else if (subscription) { + // Subscription exists but unsubscribe is not a function or doesn't return a Promise + // Just clean up the reference + this.#globalActiveAssetSubscriptions.delete(symbol); + } + + // If subscription is still pending (async), the .then() handler in + // #ensureActiveAssetSubscription will check symbolSubscriberCounts + // and unsubscribe immediately when it resolves (#28141) + // Clean up the pending promise reference + this.#pendingActiveAssetPromises.delete(symbol); + } else { + // Still has subscribers, just decrement count + this.#symbolSubscriberCounts.set(symbol, currentCount - 1); + } + } + + /** + * Ensure assetCtxs subscription for specific DEX (HIP-3 support) + * Uses WebSocket instead of REST polling for market data + * Implements reference counting to track active subscribers per DEX + * + * @param dex - The DEX identifier (empty string for main DEX). + * @param options - Subscription behavior overrides. + * @param options.incrementRefCount - Skip incrementing when restoring existing subscribers after reconnect. + */ + async #ensureAssetCtxsSubscription( + dex: string, + options: { incrementRefCount?: boolean } = {}, + ): Promise { + const dexKey = dex || ''; + const { incrementRefCount = true } = options; + + if (incrementRefCount) { + const currentCount = this.#dexSubscriberCounts.get(dexKey) ?? 0; + this.#dexSubscriberCounts.set(dexKey, currentCount + 1); + } + + // Return if subscription already exists + if (this.#assetCtxsSubscriptions.has(dexKey)) { + return; + } + + let promise = this.#assetCtxsSubscriptionPromises.get(dexKey); + if (!promise) { + promise = this.#createAssetCtxsSubscription(dex); + this.#assetCtxsSubscriptionPromises.set(dexKey, promise); + } + + try { + await promise; + } catch (error) { + if (this.#assetCtxsSubscriptionPromises.get(dexKey) === promise) { + this.#assetCtxsSubscriptionPromises.delete(dexKey); + } + if (incrementRefCount) { + const currentCount = this.#dexSubscriberCounts.get(dexKey) ?? 0; + if (currentCount <= 1) { + this.#dexSubscriberCounts.delete(dexKey); + } else { + this.#dexSubscriberCounts.set(dexKey, currentCount - 1); + } + } + throw error; + } + } + + /** + * Ensure a per-DEX allMids WS subscription exists (singleton per DEX). + * Mirrors the assetCtxs subscription dedup pattern and is used only for HIP-3 DEXs. + * + * @param dex - The HIP-3 DEX name. + */ + async #ensureDexAllMidsSubscription(dex: string): Promise { + if (!dex) { + return; + } + + if (this.#dexAllMidsSubscriptions.has(dex)) { + return; + } + + if (this.#dexAllMidsSubscriptionPromises.has(dex)) { + await this.#dexAllMidsSubscriptionPromises.get(dex); + return; + } + + const promise = this.#createDexAllMidsSubscription(dex); + this.#dexAllMidsSubscriptionPromises.set(dex, promise); + + try { + await promise; + } catch (error) { + this.#dexAllMidsSubscriptionPromises.delete(dex); + throw error; + } + } + + /** + * Create allMids WS subscription for a specific HIP-3 DEX. + * + * @param dex - The HIP-3 DEX name. + */ + async #createDexAllMidsSubscription(dex: string): Promise { + await this.#clientService.ensureSubscriptionClient( + this.#walletService.createWalletAdapter(), + ); + const subscriptionClient = this.#clientService.getSubscriptionClient(); + + if (!subscriptionClient) { + throw new Error('Subscription client not initialized'); + } + + return new Promise((resolve, reject) => { + subscriptionClient + .allMids({ dex }, (data: AllMidsWsEvent) => { + this.#allMidsSnapshots.set(dex, data.mids as Record); + }) + .then((sub) => { + // If a newer subscription already won the race, discard this one (#28141) + if (this.#dexAllMidsSubscriptions.has(dex)) { + resolve(); + return sub.unsubscribe(); + } + this.#dexAllMidsSubscriptions.set(dex, sub); + this.#deps.debugLogger.log( + `allMids subscription established for DEX: ${dex}`, + ); + resolve(); + return undefined; + }) + .catch((error) => { + this.#logErrorUnlessClearing( + ensureError( + error, + 'HyperLiquidSubscriptionService.createDexAllMidsSubscription', + ), + this.#getErrorContext('createDexAllMidsSubscription', { dex }), + ); + reject( + ensureError( + error, + 'HyperLiquidSubscriptionService.createDexAllMidsSubscription', + ), + ); + }); + }); + } + + /** + * Create assetCtxs subscription for specific DEX + * Provides real-time market data for all assets on the DEX + * + * Performance: Uses cached meta from dexMetaCache (populated by metaAndAssetCtxs) + * to avoid redundant meta() API calls during subscription setup + * + * @param dex - The DEX identifier (empty string for main DEX). + */ + async #createAssetCtxsSubscription(dex: string): Promise { + await this.#clientService.ensureSubscriptionClient( + this.#walletService.createWalletAdapter(), + ); + const subscriptionClient = this.#clientService.getSubscriptionClient(); + + if (!subscriptionClient) { + throw new Error('Subscription client not initialized'); + } + + const dexKey = dex || ''; + const dexIdentifier = dex ?? 'main DEX'; + + // Check cache first - populated by metaAndAssetCtxs in ensureAssetCtxsSubscription + let perpsMeta = this.#dexMetaCache.get(dexKey); + + if (!perpsMeta) { + // Fallback: fetch meta if not in cache (shouldn't happen in normal flow) + this.#deps.debugLogger.log( + `Meta cache miss for ${dexIdentifier}, fetching from API`, + ); + const infoClient = this.#clientService.getInfoClient(); + const fetchedMeta = await infoClient.meta({ dex: dex || undefined }); + if (fetchedMeta?.universe) { + perpsMeta = fetchedMeta; + this.#dexMetaCache.set(dexKey, fetchedMeta); + } + } + + if (!perpsMeta?.universe) { + const errorMessage = `No universe data available for ${dexIdentifier}`; + throw new Error(errorMessage); + } + + // Capture narrowed perpsMeta in a const for use inside closures + const validatedMeta = perpsMeta; + + this.#deps.debugLogger.log( + `Using ${this.#dexMetaCache.has(dexKey) ? 'cached' : 'fetched'} meta for ${dexIdentifier}`, + { + dex, + universeCount: validatedMeta.universe.length, + firstAssetSample: validatedMeta.universe[0]?.name, + }, + ); + + return new Promise((resolve, reject) => { + const subscriptionParams = dex ? { dex } : {}; + const handleAssetCtxsUpdate = (data: AssetCtxsWsEvent): void => { + // Cache asset contexts for this DEX + this.#dexAssetCtxsCache.set(dexKey, data.ctxs); + + // Use cached meta to map ctxs array indices to symbols (no REST API call!) + validatedMeta.universe.forEach((asset, index) => { + const ctx = data.ctxs[index]; + if (ctx && hasProperty(ctx, 'funding')) { + // This is a perps context + const ctxPrice = ctx.midPx ?? ctx.markPx; + const openInterestUSD = calculateOpenInterestUSD( + ctx.openInterest, + ctxPrice, + ); + // Preserve the fast-stream price fields set by the per-symbol + // activeAssetCtx handler. assetCtxs is a per-DEX batch that does not + // carry the fast-stream price concept, so rebuilding the entry from + // scratch would clobber activeAssetCtxPrice/priceLastUpdated and make + // #getFreshActiveAssetCtxPrice return stale, dropping focused + // subscribers back to the slower allMids baseline. priceLastUpdated + // is carried forward (not reset) so the staleness gate keeps + // reflecting the last activeAssetCtx tick. + const existingMarketData = this.#marketDataCache.get(asset.name); + const marketData = { + prevDayPx: ctx.prevDayPx + ? parseFloat(ctx.prevDayPx.toString()) + : undefined, + funding: parseFloat(ctx.funding.toString()), + openInterest: isNaN(openInterestUSD) + ? undefined + : openInterestUSD, + volume24h: ctx.dayNtlVlm + ? parseFloat(ctx.dayNtlVlm.toString()) + : undefined, + oraclePrice: parseFloat(ctx.oraclePx.toString()), + lastUpdated: Date.now(), + activeAssetCtxPrice: existingMarketData?.activeAssetCtxPrice, + priceLastUpdated: existingMarketData?.priceLastUpdated, + }; + + this.#marketDataCache.set(asset.name, marketData); + + // HIP-3: Extract price from assetCtx and update cached prices. + // For HIP-3 DEXs, meta() returns asset.name already containing the + // DEX prefix (e.g., "xyz:XYZ100"), so use it directly. + const symbol = asset.name; + const price = ctx.midPx?.toString() ?? ctx.markPx?.toString(); + if (this.#fastAssetCtxsCoins.has(symbol)) { + // fastAssetCtxs (TAT-3387) owns the price string for this coin + // with fresher, ~5s-cadence data, so don't overwrite it with + // this batch's price. Still rebuild the baseline (keeping the + // existing price) so derived fields just refreshed above in + // #marketDataCache (funding, openInterest, volume24h, + // oraclePrice, percentChange24h/isTradable via markPrice) reach + // list subscribers instead of going stale until the next + // fastAssetCtxs/allMids price change. Only rebuild an existing + // baseline to preserve the startup zero-price guard: we never + // want to synthesize a baseline from a '0' / absent allMids + // price. + const existingBaseline = this.#cachedPriceData?.get(symbol); + if (this.#cachedPriceData && existingBaseline) { + this.#cachedPriceData.set( + symbol, + this.#createPriceUpdate(symbol, existingBaseline.price), + ); + } + } else if (price) { + const priceUpdate = this.#createPriceUpdate(symbol, price); + this.#cachedPriceData ??= new Map(); + this.#cachedPriceData.set(symbol, priceUpdate); + } + } + }); + + // Notify price subscribers with updated market data + this.#notifyAllPriceSubscribers(); + }; + + const subscribeWithRetry = async (): Promise => { + const maxAttempts = 3; + + for (let attempt = 1; attempt <= maxAttempts; attempt++) { + try { + return await subscriptionClient.assetCtxs( + subscriptionParams, + handleAssetCtxsUpdate, + ); + } catch (error) { + const ensuredError = ensureError( + error, + 'HyperLiquidSubscriptionService.createAssetCtxsSubscription', + ); + const isLastAttempt = attempt === maxAttempts; + if (isLastAttempt || !this.#isTransientSdkError(ensuredError)) { + throw ensuredError; + } + + const retryDelayMs = attempt * 500; + this.#deps.debugLogger.log( + 'Transient assetCtxs subscription failure during reconnect, retrying', + { + dex: dexKey || 'main', + attempt, + retryDelayMs, + error: ensuredError.message, + }, + ); + await new Promise((_resolve) => setTimeout(_resolve, retryDelayMs)); + } + } + + throw new Error( + `Failed to establish assetCtxs subscription for ${dexIdentifier}`, + ); + }; + + subscribeWithRetry() + .then((sub) => { + // If a newer subscription already won the race, discard this one (#28141) + if (this.#assetCtxsSubscriptions.has(dexKey)) { + resolve(); + return sub.unsubscribe(); + } + this.#assetCtxsSubscriptions.set(dexKey, sub); + this.#deps.debugLogger.log( + `assetCtxs subscription established for ${ + dex ? `DEX: ${dex}` : 'main DEX' + }`, + ); + resolve(); + return undefined; + }) + .catch((error) => { + this.#logErrorUnlessClearing( + ensureError( + error, + 'HyperLiquidSubscriptionService.createAssetCtxsSubscription', + ), + this.#getErrorContext('createAssetCtxsSubscription', { dex }), + ); + reject( + ensureError( + error, + 'HyperLiquidSubscriptionService.createAssetCtxsSubscription', + ), + ); + }); + }); + } + + /** + * Cleanup assetCtxs subscription for specific DEX with reference counting + * Only unsubscribes when the last subscriber for this DEX is removed + * + * @param dex - The DEX identifier (empty string for main DEX). + */ + #cleanupAssetCtxsSubscription(dex: string): void { + const dexKey = dex || ''; + + // Decrement subscriber count for this DEX + const currentCount = this.#dexSubscriberCounts.get(dexKey) ?? 0; + + if (currentCount <= 1) { + // Last subscriber - cleanup the subscription + const subscription = this.#assetCtxsSubscriptions.get(dexKey); + const allMidsSubscription = dex + ? this.#dexAllMidsSubscriptions.get(dex) + : undefined; + + if (subscription) { + subscription.unsubscribe().catch((error: Error) => { + this.#logErrorUnlessClearing( + ensureError( + error, + 'HyperLiquidSubscriptionService.cleanupAssetCtxsSubscription', + ), + this.#getErrorContext('cleanupAssetCtxsSubscription', { dex }), + ); + }); + } + + if (allMidsSubscription) { + allMidsSubscription.unsubscribe().catch((error: Error) => { + this.#logErrorUnlessClearing( + ensureError( + error, + 'HyperLiquidSubscriptionService.cleanupDexAllMidsSubscription', + ), + this.#getErrorContext('cleanupDexAllMidsSubscription', { dex }), + ); + }); + } + + this.#assetCtxsSubscriptions.delete(dexKey); + this.#dexAssetCtxsCache.delete(dexKey); + this.#assetCtxsSubscriptionPromises.delete(dexKey); + this.#dexSubscriberCounts.delete(dexKey); + + if (dex) { + this.#dexAllMidsSubscriptions.delete(dex); + this.#dexAllMidsSubscriptionPromises.delete(dex); + this.#allMidsSnapshots.delete(dex); + } + + this.#deps.debugLogger.log( + `Cleaned up assetCtxs subscription for ${ + dex ? `DEX: ${dex}` : 'main DEX' + }`, + ); + } else { + // Still has subscribers - just decrement count + this.#dexSubscriberCounts.set(dexKey, currentCount - 1); + } + } + + /** + * Ensure BBO subscription for specific symbol (singleton) + * + * BBO provides best bid/ask without being affected by L2Book aggregation parameters, + * keeping spread consistent across order book grouping selections (matches Hyperliquid UI). + * + * @param symbol - The trading pair symbol to subscribe to BBO for. + */ + #ensureBboSubscription(symbol: string): void { + // Skip if subscription already exists or is being created + if ( + this.#globalBboSubscriptions.has(symbol) || + this.#pendingBboPromises.has(symbol) + ) { + return; + } + + const subscriptionClient = this.#clientService.getSubscriptionClient(); + if (!subscriptionClient) { + return; + } + + const promise = subscriptionClient + .bbo({ coin: symbol }, (data: BboWsEvent) => { + processBboData({ + symbol, + data, + orderBookCache: this.#orderBookCache, + cachedPriceData: this.#cachedPriceData, + createPriceUpdate: this.#createPriceUpdate.bind(this), + notifySubscribers: this.#notifyAllPriceSubscribers.bind(this), + }); + }) + .then((sub) => { + // Only clear pending ref if this is still the current promise (#28141). + if (this.#pendingBboPromises.get(symbol) === promise) { + this.#pendingBboPromises.delete(symbol); + } + // Stale subscription: cleanup was called while pending, a newer + // subscription already won the race, OR a different pending promise + // exists (rapid away-and-back before this one resolved). (#28141) + if ( + (this.#orderBookSubscribers.get(symbol)?.size ?? 0) <= 0 || + this.#globalBboSubscriptions.has(symbol) || + this.#pendingBboPromises.has(symbol) + ) { + return sub.unsubscribe(); + } + this.#globalBboSubscriptions.set(symbol, sub); + this.#deps.debugLogger.log( + `HyperLiquid: BBO subscription established for ${symbol}`, + ); + return undefined; + }) + .catch((error) => { + if (this.#pendingBboPromises.get(symbol) === promise) { + this.#pendingBboPromises.delete(symbol); + } + this.#logErrorUnlessClearing( + ensureError( + error, + 'HyperLiquidSubscriptionService.ensureBboSubscription', + ), + this.#getErrorContext('ensureBboSubscription', { symbol }), + ); + }); + + this.#pendingBboPromises.set(symbol, promise); + } + + /** + * Cleanup BBO subscription when no longer needed + * + * @param symbol - The trading pair symbol. + */ + #cleanupBboSubscription(symbol: string): void { + // If anyone still wants order book (top-of-book) data for this symbol, keep the subscription alive. + if ((this.#orderBookSubscribers.get(symbol)?.size ?? 0) > 0) { + return; + } + + const subscription = this.#globalBboSubscriptions.get(symbol); + if (subscription && typeof subscription.unsubscribe === 'function') { + const unsubscribeResult = Promise.resolve(subscription.unsubscribe()); + unsubscribeResult.catch(() => { + // Ignore errors during cleanup + }); + + this.#globalBboSubscriptions.delete(symbol); + this.#orderBookCache.delete(symbol); + } else if (subscription) { + // Subscription exists but unsubscribe is not a function or doesn't return a Promise + // Just clean up the reference + this.#globalBboSubscriptions.delete(symbol); + this.#orderBookCache.delete(symbol); + } + + // If subscription is still pending (async), the .then() handler in + // #ensureBboSubscription will check orderBookSubscribers and + // unsubscribe immediately when it resolves (#28141) + this.#pendingBboPromises.delete(symbol); + } + + /** + * Subscribe to full order book updates with multiple depth levels + * Creates a dedicated L2Book subscription for the requested symbol + * and processes data into OrderBookData format for UI consumption + * + * @param params - Subscription parameters + * @returns Cleanup function to unsubscribe + */ + public subscribeToOrderBook(params: SubscribeOrderBookParams): () => void { + const { + symbol, + levels = 10, + nSigFigs = 5, + mantissa, + fast, + callback, + onError, + } = params; + + this.#clientService + .ensureSubscriptionClient(this.#walletService.createWalletAdapter()) + .catch(() => { + // Handled by getSubscriptionClient check below + }); + + const subscriptionClient = this.#clientService.getSubscriptionClient(); + if (!subscriptionClient) { + const error = new Error('Subscription client not available'); + onError?.(error); + this.#deps.debugLogger.log( + 'subscribeToOrderBook: Subscription client not available', + ); + return () => { + // No-op cleanup + }; + } + + let subscription: ISubscription | undefined; + let cancelled = false; + + subscriptionClient + .l2Book( + { coin: symbol, nSigFigs, mantissa, fast }, + (data: L2BookResponse) => { + if (cancelled || data?.coin !== symbol || !data?.levels) { + return; + } + + const orderBookData = this.#processOrderBookData(data, levels); + callback(orderBookData); + }, + ) + .then(async (sub) => { + if (cancelled) { + try { + await sub.unsubscribe(); + } catch (unsubError: unknown) { + this.#logErrorUnlessClearing( + ensureError( + unsubError, + 'HyperLiquidSubscriptionService.subscribeToOrderBook', + ), + this.#getErrorContext('subscribeToOrderBook.cleanup', { symbol }), + ); + } + return undefined; + } + subscription = sub; + this.#deps.debugLogger.log( + `HyperLiquid: Order book subscription established for ${symbol}`, + ); + return undefined; + }) + .catch((error) => { + this.#logErrorUnlessClearing( + ensureError( + error, + 'HyperLiquidSubscriptionService.subscribeToOrderBook', + ), + this.#getErrorContext('subscribeToOrderBook', { symbol }), + ); + onError?.( + ensureError( + error, + 'HyperLiquidSubscriptionService.subscribeToOrderBook', + ), + ); + }); + + return () => { + cancelled = true; + if (subscription) { + subscription.unsubscribe().catch((error: Error) => { + this.#logErrorUnlessClearing( + ensureError( + error, + 'HyperLiquidSubscriptionService.subscribeToOrderBook', + ), + this.#getErrorContext('subscribeToOrderBook.unsubscribe', { + symbol, + }), + ); + }); + } + }; + } + + /** + * Process raw L2Book data into OrderBookData format + * Calculates cumulative totals, notional values, and spread metrics + * + * @param data - Raw L2Book response from WebSocket + * @param levels - Number of levels to return per side + * @returns Processed OrderBookData + */ + #processOrderBookData(data: L2BookResponse, levels: number): OrderBookData { + const bidsRaw = data?.levels?.[0] ?? []; + const asksRaw = data?.levels?.[1] ?? []; + + // Process bids (buy orders) - highest price first + let bidCumulativeSize = 0; + let bidCumulativeNotional = 0; + const bids: OrderBookLevel[] = bidsRaw.slice(0, levels).map((level) => { + const price = parseFloat(level.px); + const size = parseFloat(level.sz); + const notional = price * size; + bidCumulativeSize += size; + bidCumulativeNotional += notional; + + return { + price: level.px, + size: level.sz, + total: bidCumulativeSize.toString(), + notional: notional.toFixed(2), + totalNotional: bidCumulativeNotional.toFixed(2), + }; + }); + + // Process asks (sell orders) - lowest price first + let askCumulativeSize = 0; + let askCumulativeNotional = 0; + const asks: OrderBookLevel[] = asksRaw.slice(0, levels).map((level) => { + const price = parseFloat(level.px); + const size = parseFloat(level.sz); + const notional = price * size; + askCumulativeSize += size; + askCumulativeNotional += notional; + + return { + price: level.px, + size: level.sz, + total: askCumulativeSize.toString(), + notional: notional.toFixed(2), + totalNotional: askCumulativeNotional.toFixed(2), + }; + }); + + // Calculate spread and mid price + const bestBid = bids[0]; + const bestAsk = asks[0]; + const bidPrice = bestBid ? parseFloat(bestBid.price) : 0; + const askPrice = bestAsk ? parseFloat(bestAsk.price) : 0; + const spread = askPrice > 0 && bidPrice > 0 ? askPrice - bidPrice : 0; + const midPrice = + askPrice > 0 && bidPrice > 0 ? (askPrice + bidPrice) / 2 : 0; + const spreadPercentage = + midPrice > 0 ? ((spread / midPrice) * 100).toFixed(4) : '0'; + + // Calculate max total for depth chart scaling + const maxTotal = Math.max(bidCumulativeSize, askCumulativeSize).toString(); + + return { + bids, + asks, + spread: spread.toFixed(5), + spreadPercentage, + midPrice: midPrice.toFixed(5), + lastUpdated: Date.now(), + maxTotal, + }; + } + + /** + * Notify all price subscribers with per-subscriber price projection. + * + * Price source selection per (symbol, callback): + * - **Focused** (`includeMarketData: true`) callbacks are identified by their + * presence in `#marketDataSubscribers[symbol]`. When a fresh + * `activeAssetCtxPrice` is cached (within the 10 s TTL), those callbacks + * receive a clone of the allMids baseline with `price` and `timestamp` + * overridden by the fast-stream value. If no fresh fast price exists they + * fall back to the allMids baseline. + * - **List** (`includeMarketData: false`) callbacks always receive the raw + * allMids baseline. They are skipped entirely until at least one allMids + * tick has been cached for the symbol. + * - When no allMids baseline exists yet but a fresh `activeAssetCtxPrice` is + * available, focused callbacks still receive an update so detail screens + * stay responsive on first render. + * + * @param changedSymbols - When provided, only subscribers for symbols in + * this set are notified. This avoids redundant reference-equal updates to + * list subscribers whose symbols were untouched by the triggering event + * (e.g. a per-symbol `activeAssetCtx` tick for a different symbol). When + * omitted, all symbols with subscribers are notified (fan-out-all), which + * is the correct behavior for callers whose event isn't scoped to specific + * symbols (e.g. subscription-established replays, per-DEX `assetCtxs`). + */ + #notifyAllPriceSubscribers(changedSymbols?: Set): void { + const subscriberUpdates = new Map< + (prices: PriceUpdate[]) => void, + PriceUpdate[] + >(); + + this.#priceSubscribers.forEach((subscriberSet, symbol) => { + if (changedSymbols && !changedSymbols.has(symbol)) { + return; + } + + const allMidsBase = this.#cachedPriceData?.get(symbol); + const fastPrice = this.#getFreshActiveAssetCtxPrice(symbol); + const now = Date.now(); + + subscriberSet.forEach((callback) => { + const isFocused = + this.#marketDataSubscribers.get(symbol)?.has(callback) ?? false; + + let priceUpdate: PriceUpdate | undefined; + + if (isFocused && fastPrice !== undefined) { + // Use allMids baseline as the structural base when available; + // fall back to a freshly computed PriceUpdate if allMids hasn't + // arrived yet so focused screens stay responsive on first render. + const base = + allMidsBase ?? this.#createPriceUpdate(symbol, fastPrice); + priceUpdate = { ...base, price: fastPrice, timestamp: now }; + } else if (allMidsBase !== undefined) { + priceUpdate = allMidsBase; + } + + if (priceUpdate !== undefined) { + const updates = subscriberUpdates.get(callback) ?? []; + updates.push(priceUpdate); + subscriberUpdates.set(callback, updates); + } + }); + }); + + // Send batched updates to each subscriber + subscriberUpdates.forEach((updates, callback) => { + if (updates.length > 0) { + callback(updates); + } + }); + } + + /** + * Restore all active subscriptions after WebSocket reconnection + * Re-establishes WebSocket subscriptions for all active subscribers + * + * IMPORTANT: This method verifies transport readiness before attempting + * any subscriptions to prevent "subscribe error: undefined" errors. + */ + public async restoreSubscriptions(): Promise { + // CRITICAL: Verify transport is ready before attempting any subscriptions + // This prevents race conditions where subscriptions are attempted while + // the WebSocket is still in CONNECTING state + try { + await this.#clientService.ensureTransportReady({ timeoutMs: 5000 }); + } catch (error) { + this.#deps.debugLogger.log( + 'Transport not ready during subscription restore, will retry on next reconnect', + { error: error instanceof Error ? error.message : String(error) }, + ); + return; + } + + // Re-establish global allMids subscription if there are price subscribers + if (this.#priceSubscribers.size > 0) { + // Clear existing subscription reference (it's dead after reconnection) + this.#globalAllMidsSubscription = undefined; + this.#globalAllMidsPromise = undefined; + + // Re-establish the subscription + this.#ensureGlobalAllMidsSubscription(); + + // Re-establish the fastAssetCtxs subscription alongside allMids (TAT-3387). + // Clear fastAssetCtxsCoins so assetCtxs can serve prices in the gap + // until the fresh post-reconnect snapshot re-establishes coverage. + this.#globalFastAssetCtxsSubscription = undefined; + this.#globalFastAssetCtxsPromise = undefined; + this.#fastAssetCtxsCoins.clear(); + this.#ensureGlobalFastAssetCtxsSubscription(); + } + + // Re-establish order fill subscriptions if there are fill subscribers + if (this.#orderFillSubscribers.size > 0) { + // Clear existing subscription references (they're dead after reconnection) + this.#orderFillSubscriptions.clear(); + + // Re-establish subscriptions for all accountIds with subscribers + // Note: normalizedAccountId is 'default' for undefined, need to convert back + const normalizedAccountIds = Array.from( + this.#orderFillSubscribers.keys(), + ); + await Promise.all( + normalizedAccountIds.map((normalizedAccountId) => { + // Convert normalized key back to original accountId (undefined if 'default') + const accountId = + normalizedAccountId === 'default' + ? undefined + : (normalizedAccountId as CaipAccountId); + return this.#ensureOrderFillISubscription(accountId).catch(() => { + // Ignore errors during order fill subscription restoration + }); + }), + ); + } + + // Re-establish user data subscriptions if there are user data subscribers + if ( + this.#positionSubscribers.size > 0 || + this.#orderSubscribers.size > 0 || + this.#accountSubscribers.size > 0 || + this.#oiCapSubscribers.size > 0 + ) { + // Clear existing subscription references (they're dead after reconnection) + this.#webData3Subscriptions.clear(); + this.#webData3SubscriptionPromise = undefined; + + // Clear individual subscriptions (clearinghouseState + openOrders) + this.#clearinghouseStateSubscriptions.clear(); + this.#openOrdersSubscriptions.clear(); + + // Re-establish the subscription (will use current account) + // This sets up per-DEX clearinghouseState + openOrders subscriptions plus webData3 (OI caps only) + await this.#ensureSharedWebData3Subscription(); + } + + // Re-establish activeAsset subscriptions if there are market data subscribers + if (this.#marketDataSubscribers.size > 0) { + // Clear existing subscriptions (they're dead after reconnection) + this.#globalActiveAssetSubscriptions.clear(); + this.#pendingActiveAssetPromises.clear(); + // Clear reference counts to prevent double-counting after reconnection + this.#symbolSubscriberCounts.clear(); + + // Re-establish subscriptions for all symbols with market data subscribers + const symbolsNeedingMarketData = Array.from( + this.#marketDataSubscribers.keys(), + ); + symbolsNeedingMarketData.forEach((symbol) => { + this.#ensureActiveAssetSubscription(symbol); + }); + } + + // Re-establish BBO subscriptions if there are order book subscribers + if (this.#orderBookSubscribers.size > 0) { + // Clear existing subscriptions (they're dead after reconnection) + this.#globalBboSubscriptions.clear(); + this.#pendingBboPromises.clear(); + + // Re-establish subscriptions for all symbols with order book subscribers + const symbolsNeedingOrderBook = Array.from( + this.#orderBookSubscribers.keys(), + ); + symbolsNeedingOrderBook.forEach((symbol) => { + this.#ensureBboSubscription(symbol); + }); + } + + // Re-establish assetCtxs subscriptions if there are market data subscribers + if (this.#marketDataSubscribers.size > 0) { + // Clear existing subscriptions (they're dead after reconnection) + this.#assetCtxsSubscriptions.clear(); + this.#assetCtxsSubscriptionPromises.clear(); + this.#dexAllMidsSubscriptions.clear(); + this.#dexAllMidsSubscriptionPromises.clear(); + + // Re-establish subscriptions for all DEXs with market data subscribers + const dexsNeeded = new Set(); + this.#marketDataSubscribers.forEach((_subscribers, symbol) => { + const { dex } = parseAssetName(symbol); + if (dex) { + dexsNeeded.add(dex); + } + }); + + // Add main DEX if any main DEX symbols have subscribers + const hasMainDexSubscribers = Array.from( + this.#marketDataSubscribers.keys(), + ).some((symbol) => { + const { dex } = parseAssetName(symbol); + return !dex; + }); + if (hasMainDexSubscribers) { + dexsNeeded.add(''); + } + + const marketDataRestoreOperations = Array.from(dexsNeeded).flatMap( + (dex) => { + const operations: { + dex: string; + kind: 'assetCtxs' | 'allMids'; + promise: Promise; + }[] = [ + { + dex, + kind: 'assetCtxs', + promise: this.#ensureAssetCtxsSubscription(dex, { + incrementRefCount: false, + }), + }, + ]; + + if (dex) { + operations.push({ + dex, + kind: 'allMids', + promise: this.#ensureDexAllMidsSubscription(dex), + }); + } + + return operations; + }, + ); + + const marketDataRestoreResults = await Promise.allSettled( + marketDataRestoreOperations.map(({ promise }) => promise), + ); + const marketDataRestoreFailures = marketDataRestoreResults.flatMap( + (result, index) => { + if (result.status === 'fulfilled') { + return []; + } + + const operation = marketDataRestoreOperations[index]; + return [ + { + ...operation, + error: ensureError( + result.reason, + 'HyperLiquidSubscriptionService.restoreSubscriptions', + ), + }, + ]; + }, + ); + + if (marketDataRestoreFailures.length > 0) { + marketDataRestoreFailures.forEach(({ dex, kind }) => { + this.#scheduleRestoreRetry(dex, kind); + }); + + this.#logErrorUnlessClearing( + new Error( + `Failed to restore ${marketDataRestoreFailures.length} market data subscriptions`, + ), + this.#getErrorContext('restoreSubscriptions.marketData', { + failures: marketDataRestoreFailures.map( + ({ dex, kind, error }) => + `${kind}:${dex || 'main'}:${error.message}`, + ), + }), + ); + } + } + } + + /** + * Clear all subscriptions and cached data (multi-DEX support) + */ + public clearAll(): void { + // Suppress error logging for pending unsubscribe requests during intentional disconnect. + // The WebSocket will be closed after this, causing pending unsubscribe promises to reject + // with WebSocketRequestError - these are expected and should not be logged to Sentry. + this.#isClearing = true; + + // Clear all local subscriber collections + this.#priceSubscribers.clear(); + this.#positionSubscribers.clear(); + this.#orderFillSubscribers.clear(); + this.#orderSubscribers.clear(); + this.#accountSubscribers.clear(); + this.#marketDataSubscribers.clear(); + this.#orderBookSubscribers.clear(); + + // Clear order fill subscriptions + this.#orderFillSubscriptions.forEach((subscription) => { + subscription.unsubscribe().catch(() => { + // Ignore errors during cleanup + }); + }); + this.#orderFillSubscriptions.clear(); + + // Clear spotState subscriptions. Bump generation + drop in-flight + // promises so any racing #ensureSpotStateSubscription continuation + // unsubscribes its fresh sub instead of rehydrating the cleared map. + this.#spotStateSubscriptionGeneration += 1; + this.#spotStateSubscriptionPromises.clear(); + this.#spotStateSubscriptions.forEach((subscription) => { + subscription.unsubscribe().catch(() => { + // Ignore errors during cleanup + }); + }); + this.#spotStateSubscriptions.clear(); + + // Clear cached data + this.#cachedPriceData = null; + this.#allMidsSnapshots.clear(); + this.#cachedPositions = null; + this.#cachedOrders = null; + this.#cachedAccount = null; + this.#cachedFills = null; + this.#ordersCacheInitialized = false; // Reset cache initialization flag + this.#positionsCacheInitialized = false; // Reset cache initialization flag + this.#fillsCacheInitialized = false; // Reset fills cache initialization flag + this.#marketDataCache.clear(); + this.#orderBookCache.clear(); + this.#symbolSubscriberCounts.clear(); + this.#dexSubscriberCounts.clear(); + + // Clear hash caches + this.#cachedPositionsHash = ''; + this.#cachedOrdersHash = ''; + this.#cachedAccountHash = ''; + + // Clear multi-DEX caches + this.#deps.debugLogger.log( + 'HyperLiquidSubscriptionService: Clearing per-DEX caches', + { + dexPositionsCacheSize: this.#dexPositionsCache.size, + dexOrdersCacheSize: this.#dexOrdersCache.size, + dexAccountCacheSize: this.#dexAccountCache.size, + dexAssetCtxsCacheSize: this.#dexAssetCtxsCache.size, + dexPositionsCacheKeys: Array.from(this.#dexPositionsCache.keys()), + dexAssetCtxsCacheKeys: Array.from(this.#dexAssetCtxsCache.keys()), + }, + ); + + this.#dexPositionsCache.clear(); + this.#dexOrdersCache.clear(); + this.#dexAccountCache.clear(); + this.#cachedSpotState = null; + this.#cachedSpotStateUserAddress = null; + this.#abstractionModeByUser.clear(); + this.#abstractionModeLastWsRefreshAtByUser.clear(); + this.#abstractionModeInflightByUser.clear(); + this.#spotStateGeneration += 1; + this.#spotStatePromise = undefined; + this.#spotStatePromiseUserAddress = undefined; + this.#dexAssetCtxsCache.clear(); + + // Unsubscribe all active subscriptions before clearing references. + // Without this, orphaned subscriptions try to send unsubscribe frames + // on the closing WebSocket, causing SOCKET_NOT_CONNECTED errors. + if (this.#globalAllMidsSubscription) { + this.#globalAllMidsSubscription.unsubscribe().catch((error: Error) => { + this.#logErrorUnlessClearing( + ensureError(error, 'HyperLiquidSubscriptionService.clearAll'), + this.#getErrorContext('clearAll.globalAllMids'), + ); + }); + } + this.#globalAllMidsSubscription = undefined; + this.#globalAllMidsPromise = undefined; + + if (this.#globalFastAssetCtxsSubscription) { + this.#globalFastAssetCtxsSubscription + .unsubscribe() + .catch((error: Error) => { + this.#logErrorUnlessClearing( + ensureError(error, 'HyperLiquidSubscriptionService.clearAll'), + this.#getErrorContext('clearAll.globalFastAssetCtxs'), + ); + }); + } + this.#globalFastAssetCtxsSubscription = undefined; + this.#globalFastAssetCtxsPromise = undefined; + this.#fastAssetCtxsCoins.clear(); + + this.#globalActiveAssetSubscriptions.forEach((sub, symbol) => { + sub.unsubscribe().catch((error: Error) => { + this.#logErrorUnlessClearing( + ensureError(error, 'HyperLiquidSubscriptionService.clearAll'), + this.#getErrorContext('clearAll.activeAsset', { symbol }), + ); + }); + }); + this.#globalActiveAssetSubscriptions.clear(); + this.#pendingActiveAssetPromises.clear(); + + this.#globalBboSubscriptions.forEach((sub, symbol) => { + sub.unsubscribe().catch((error: Error) => { + this.#logErrorUnlessClearing( + ensureError(error, 'HyperLiquidSubscriptionService.clearAll'), + this.#getErrorContext('clearAll.bbo', { symbol }), + ); + }); + }); + this.#globalBboSubscriptions.clear(); + this.#pendingBboPromises.clear(); + + this.#webData3Subscriptions.forEach((sub, dexName) => { + sub.unsubscribe().catch((error: Error) => { + this.#logErrorUnlessClearing( + ensureError(error, 'HyperLiquidSubscriptionService.clearAll'), + this.#getErrorContext('clearAll.webData3', { dex: dexName }), + ); + }); + }); + this.#webData3Subscriptions.clear(); + this.#webData3SubscriptionPromise = undefined; + + // HIP-3: Clear assetCtxs subscriptions + this.#assetCtxsSubscriptions.forEach((sub, dexName) => { + sub.unsubscribe().catch((error: Error) => { + this.#logErrorUnlessClearing( + ensureError(error, 'HyperLiquidSubscriptionService.clearAll'), + this.#getErrorContext('clearAll.assetCtxs', { dex: dexName }), + ); + }); + }); + this.#assetCtxsSubscriptions.clear(); + this.#assetCtxsSubscriptionPromises.clear(); + + // HIP-3: Clear per-DEX allMids subscriptions + this.#dexAllMidsSubscriptions.forEach((sub, dexName) => { + sub.unsubscribe().catch((error: Error) => { + this.#logErrorUnlessClearing( + ensureError(error, 'HyperLiquidSubscriptionService.clearAll'), + this.#getErrorContext('clearAll.dexAllMids', { dex: dexName }), + ); + }); + }); + this.#dexAllMidsSubscriptions.clear(); + this.#dexAllMidsSubscriptionPromises.clear(); + + this.#restoreRetryTimeouts.forEach((timeoutId) => { + clearTimeout(timeoutId); + }); + this.#restoreRetryTimeouts.clear(); + + // Cleanup individual subscriptions (clearinghouseState + openOrders) + if (this.#clearinghouseStateSubscriptions.size > 0) { + this.#clearinghouseStateSubscriptions.forEach((subscription, dexName) => { + subscription.unsubscribe().catch((error: Error) => { + this.#logErrorUnlessClearing( + ensureError(error, 'HyperLiquidSubscriptionService.clearAll'), + this.#getErrorContext('clearAll.clearinghouseState', { + dex: dexName, + }), + ); + }); + }); + this.#clearinghouseStateSubscriptions.clear(); + } + + if (this.#openOrdersSubscriptions.size > 0) { + this.#openOrdersSubscriptions.forEach((subscription, dexName) => { + subscription.unsubscribe().catch((error: Error) => { + this.#logErrorUnlessClearing( + ensureError(error, 'HyperLiquidSubscriptionService.clearAll'), + this.#getErrorContext('clearAll.openOrders', { + dex: dexName, + }), + ); + }); + }); + this.#openOrdersSubscriptions.clear(); + } + + this.#deps.debugLogger.log( + 'HyperLiquid: Subscription service cleared (multi-DEX with individual subscriptions)', + { + timestamp: new Date().toISOString(), + }, + ); + } +} diff --git a/packages/perps-controller/src/services/HyperLiquidWalletService.ts b/packages/perps-controller/src/services/HyperLiquidWalletService.ts new file mode 100644 index 00000000000..ab0a7dad883 --- /dev/null +++ b/packages/perps-controller/src/services/HyperLiquidWalletService.ts @@ -0,0 +1,257 @@ +import { + hasProperty, + isValidHexAddress, + parseCaipAccountId, +} from '@metamask/utils'; +import type { CaipAccountId, Hex } from '@metamask/utils'; + +import { getChainId } from '../constants/hyperLiquidConfig.js'; +import { PERPS_ERROR_CODES } from '../perpsErrorCodes.js'; +import type { + PerpsPlatformDependencies, + PerpsTypedMessageParams, +} from '../types/index.js'; +import type { PerpsControllerMessengerBase } from '../types/messenger.js'; +import { + getSelectedEvmAccountDetailsFromMessenger, + getSelectedEvmAccountFromMessenger, +} from '../utils/accountUtils.js'; + +// Mirrors KeyringTypes from @metamask/keyring-controller. Inlined to keep this +// service portable between mobile and the core monorepo. +const HARDWARE_KEYRING_TYPES = new Set([ + 'Ledger Hardware', + 'Trezor Hardware', + 'OneKey Hardware', + 'Lattice Hardware', + 'QR Hardware Wallet Device', +]); + +/** + * Service for MetaMask wallet integration with HyperLiquid SDK + * Provides wallet adapter that implements AbstractWindowEthereum interface + */ +export class HyperLiquidWalletService { + #isTestnet: boolean; + + // Platform dependencies for observability + readonly #deps: PerpsPlatformDependencies; + + readonly #messenger: PerpsControllerMessengerBase; + + constructor( + deps: PerpsPlatformDependencies, + messenger: PerpsControllerMessengerBase, + options: { isTestnet?: boolean } = {}, + ) { + this.#deps = deps; + this.#messenger = messenger; + this.#isTestnet = options.isTestnet ?? false; + } + + /** + * Check if the keyring is currently unlocked + * + * @returns True if the keyring is unlocked and available for signing. + */ + public isKeyringUnlocked(): boolean { + return this.#messenger.call('KeyringController:getState').isUnlocked; + } + + /** + * Check whether the selected EVM account is backed by hardware. + * + * @returns True for MetaMask hardware keyrings; false for software accounts. + */ + public isSelectedHardwareWallet(): boolean { + const selectedEvmAccount = getSelectedEvmAccountDetailsFromMessenger( + this.#messenger, + ); + if (!selectedEvmAccount || !hasProperty(selectedEvmAccount, 'metadata')) { + return false; + } + + const metadata = selectedEvmAccount.metadata as + | { keyring?: { type?: string } } + | undefined; + const keyringType = metadata?.keyring?.type; + + return Boolean(keyringType && HARDWARE_KEYRING_TYPES.has(keyringType)); + } + + /** + * Sign typed data via DI keyring controller + * + * @param msgParams - The typed message parameters including data and sender address. + * @returns The signature string. + */ + async #signTypedMessage(msgParams: PerpsTypedMessageParams): Promise { + if (!this.isKeyringUnlocked()) { + throw new Error(PERPS_ERROR_CODES.KEYRING_LOCKED); + } + // Cast needed: PerpsTypedMessageParams uses loose `data: unknown` type + // while KeyringController uses strict TypedMessageParams / SignTypedDataVersion + return this.#messenger.call( + 'KeyringController:signTypedMessage', + // eslint-disable-next-line @typescript-eslint/no-explicit-any + msgParams as any, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + 'V4' as any, + ); + } + + /** + * Create wallet adapter that implements AbstractViemJsonRpcAccount interface + * Required by @nktkas/hyperliquid SDK for signing transactions + * + * @returns The wallet adapter with address, signTypedData, and getChainId methods. + */ + public createWalletAdapter(): { + address: Hex; + signTypedData: (params: { + domain: { + name: string; + version: string; + chainId: number; + verifyingContract: Hex; + }; + types: { + [key: string]: { name: string; type: string }[]; + }; + primaryType: string; + message: Record; + }) => Promise; + getChainId?: () => Promise; + } { + // Get current EVM account via DI messenger + const evmAccount = getSelectedEvmAccountFromMessenger(this.#messenger); + + if (!evmAccount?.address) { + throw new Error(PERPS_ERROR_CODES.NO_ACCOUNT_SELECTED); + } + + const address = evmAccount.address as Hex; + + return { + address, + signTypedData: async (params: { + domain: { + name: string; + version: string; + chainId: number; + verifyingContract: Hex; + }; + types: { + [key: string]: { name: string; type: string }[]; + }; + primaryType: string; + message: Record; + }): Promise => { + // Get FRESH account on every sign to handle account switches + // This prevents race conditions where wallet adapter was created with old account + const currentEvmAccount = getSelectedEvmAccountFromMessenger( + this.#messenger, + ); + + if (!currentEvmAccount?.address) { + throw new Error(PERPS_ERROR_CODES.NO_ACCOUNT_SELECTED); + } + + const currentAddress = currentEvmAccount.address as Hex; + + // Construct EIP-712 typed data + const typedData = { + domain: params.domain, + types: params.types, + primaryType: params.primaryType, + message: params.message, + }; + + this.#deps.debugLogger.log( + 'HyperLiquidWalletService: Signing typed data', + { + address: currentAddress, + primaryType: params.primaryType, + domain: params.domain, + }, + ); + + // Use messenger to sign typed data + const signature = await this.#signTypedMessage({ + from: currentAddress, + data: typedData, + }); + + return signature as Hex; + }, + getChainId: async (): Promise => + parseInt(getChainId(this.#isTestnet), 10), + }; + } + + /** + * Get current account ID using messenger + * + * @returns The CAIP account ID for the current EVM account. + */ + public async getCurrentAccountId(): Promise { + const evmAccount = getSelectedEvmAccountFromMessenger(this.#messenger); + + if (!evmAccount?.address) { + throw new Error(PERPS_ERROR_CODES.NO_ACCOUNT_SELECTED); + } + + const chainId = getChainId(this.#isTestnet); + const caipAccountId: CaipAccountId = `eip155:${chainId}:${evmAccount.address}`; + + return caipAccountId; + } + + /** + * Get validated user address as Hex from account ID + * + * @param accountId - The CAIP account ID to extract the address from. + * @returns The validated hex address. + */ + public getUserAddress(accountId: CaipAccountId): Hex { + const parsed = parseCaipAccountId(accountId); + const address = parsed.address as Hex; + + if (!isValidHexAddress(address)) { + throw new Error(PERPS_ERROR_CODES.INVALID_ADDRESS_FORMAT); + } + + return address; + } + + /** + * Get user address with default fallback to current account + * + * @param accountId - Optional CAIP account ID; defaults to current account if omitted. + * @returns The validated hex address. + */ + public async getUserAddressWithDefault( + accountId?: CaipAccountId, + ): Promise { + const id = accountId ?? (await this.getCurrentAccountId()); + return this.getUserAddress(id); + } + + /** + * Update testnet mode + * + * @param isTestnet - Whether to enable testnet mode. + */ + public setTestnetMode(isTestnet: boolean): void { + this.#isTestnet = isTestnet; + } + + /** + * Check if running on testnet + * + * @returns True if the service is in testnet mode. + */ + public isTestnetMode(): boolean { + return this.#isTestnet; + } +} diff --git a/packages/perps-controller/src/services/MYXClientService.ts b/packages/perps-controller/src/services/MYXClientService.ts new file mode 100644 index 00000000000..80cd8076cc2 --- /dev/null +++ b/packages/perps-controller/src/services/MYXClientService.ts @@ -0,0 +1,1070 @@ +/** + * MYXClientService + * + * Service for managing MYX SDK client interactions. + * Handles market listing, ticker fetching, price polling, authentication, + * and authenticated reads (positions, orders, account info). + * + * Uses MyxClient SDK for API calls. + */ + +import type { + KlineDataItemType, + KlineResolution, + KlineDataResponse, +} from '@myx-trade/sdk'; +import { MyxClient } from '@myx-trade/sdk'; + +import { + MYX_PRICE_POLLING_INTERVAL_MS, + getMYXChainId, + getMYXHttpEndpoint, +} from '../constants/myxConfig.js'; +import { PERPS_CONSTANTS, ZERO_ADDRESS } from '../constants/perpsConfig.js'; +import type { PerpsPlatformDependencies } from '../types/index.js'; +import type { + MYXAuthConfig, + MYXPoolSymbol, + MYXTicker, + MYXPositionType, + MYXHistoryOrderItem, + MYXPositionHistoryItem, + MYXTradeFlowItem, + MYXGetHistoryOrdersParams, +} from '../types/myx-types.js'; +import { ensureError } from '../utils/errorUtils.js'; + +// ============================================================================ +// Types +// ============================================================================ + +/** + * MYX Client Configuration + */ +export type MYXClientConfig = { + isTestnet: boolean; + authConfig?: MYXAuthConfig; +}; + +/** + * Price polling callback type + */ +export type PricePollingCallback = (tickers: MYXTicker[]) => void; + +// ============================================================================ +// MYXClientService +// ============================================================================ + +/** + * Service for managing MYX SDK client interactions. + * Handles markets, prices, authentication, and authenticated reads. + */ +export class MYXClientService { + // SDK Client + readonly #myxClient: MyxClient; + + // Configuration + readonly #isTestnet: boolean; + + readonly #chainId: number; + + readonly #network: 'testnet' | 'mainnet'; + + // Auth config (passed at construction, not from runtime env vars) + readonly #authConfig: MYXAuthConfig; + + // Auth state — null means unauthenticated, non-null is the lowercased address + #authenticatedAddress: string | null = null; + + #authenticating: Promise | null = null; + + // Price polling (sequential using setTimeout to prevent request pileup) + #pricePollingTimeout?: ReturnType; + + #pollingSymbols: string[] = []; + + #pollingCallback?: PricePollingCallback; + + // Caches + #marketsCache: MYXPoolSymbol[] = []; + + #marketsCacheTimestamp = 0; + + readonly #marketsCacheTtlMs = 5 * 60 * 1000; // 5 minutes + + // globalId cache: poolId → globalId (for WS subscriptions) + readonly #globalIdCache: Map = new Map(); + + // Platform dependencies + readonly #deps: PerpsPlatformDependencies; + + constructor(deps: PerpsPlatformDependencies, config: MYXClientConfig) { + this.#deps = deps; + + this.#isTestnet = config.isTestnet; + this.#network = this.#isTestnet ? 'testnet' : 'mainnet'; + this.#chainId = getMYXChainId(this.#network); + + // Store auth config (from init file's babel-transformed process.env.X) + this.#authConfig = config.authConfig ?? { + appId: '', + apiSecret: '', + brokerAddress: '', + }; + + const brokerAddress = this.#authConfig.brokerAddress || ZERO_ADDRESS; + + if (brokerAddress === ZERO_ADDRESS) { + this.#deps.debugLogger.log( + '[MYXClientService] brokerAddress not configured, using zero address', + ); + } + + // Initialize MyxClient with broker address + this.#myxClient = new MyxClient({ + chainId: this.#chainId, + brokerAddress, + isTestnet: this.#isTestnet, + }); + + // Connect WS at construction time (always-on, like HyperLiquid). + // Individual subscriptions (kline, tickers) subscribe/unsubscribe on + // this already-open socket. + this.#myxClient.subscription.connect(); + + this.#deps.debugLogger.log('[MYXClientService] Initialized with SDK', { + isTestnet: this.#isTestnet, + chainId: this.#chainId, + wsConnected: true, + brokerAddress: + brokerAddress === ZERO_ADDRESS ? 'zero (not configured)' : 'configured', + }); + } + + // ============================================================================ + // Error Context Helper + // ============================================================================ + + #getErrorContext( + method: string, + extra?: Record, + ): { + tags?: Record; + context?: { name: string; data: Record }; + } { + return { + tags: { + feature: PERPS_CONSTANTS.FeatureName, + service: 'MYXClientService', + network: this.#isTestnet ? 'testnet' : 'mainnet', + }, + context: { + name: `MYXClientService.${method}`, + data: { + chainId: this.#chainId, + ...extra, + }, + }, + }; + } + + // ============================================================================ + // Market Operations + // ============================================================================ + + /** + * Get all available markets/pools + * Uses SDK markets.getPoolSymbolAll() + * + * @returns The array of available MYX pool symbols. + */ + async getMarkets(): Promise { + // Return cache if valid + const now = Date.now(); + if ( + this.#marketsCache.length > 0 && + now - this.#marketsCacheTimestamp < this.#marketsCacheTtlMs + ) { + return this.#marketsCache; + } + + try { + this.#deps.debugLogger.log('[MYXClientService] Fetching markets via SDK'); + + const pools = await this.#myxClient.markets.getPoolSymbolAll(); + + // Update cache + this.#marketsCache = pools || []; + this.#marketsCacheTimestamp = Date.now(); + + this.#deps.debugLogger.log('[MYXClientService] Markets fetched', { + count: this.#marketsCache.length, + }); + + return this.#marketsCache; + } catch (caughtError) { + const wrappedError = ensureError( + caughtError, + 'MYXClientService.getMarkets', + ); + this.#deps.logger.error( + wrappedError, + this.#getErrorContext('getMarkets'), + ); + + // Return stale cache if available + if (this.#marketsCache.length > 0) { + this.#deps.debugLogger.log( + '[MYXClientService] Returning stale cache after error', + ); + return this.#marketsCache; + } + + throw wrappedError; + } + } + + /** + * Get tickers for specific symbols/pools + * Uses SDK markets.getTickerList() + * + * @param poolIds - The array of pool identifiers to fetch tickers for. + * @returns The array of ticker data for the specified pools. + */ + async getTickers(poolIds: string[]): Promise { + if (poolIds.length === 0) { + return []; + } + + try { + this.#deps.debugLogger.log( + '[MYXClientService] Fetching tickers via SDK', + { + poolIds: poolIds.length, + }, + ); + + // Group poolIds by their actual chainId from the markets cache. + // getPoolSymbolAll() can return pools across multiple chains (e.g. testnet + // spans chainId 59141 and 421614). The ticker API only returns results for + // pools that match the passed chainId, so we must call it once per chain. + const chainIdMap = new Map(); + for (const poolId of poolIds) { + const pool = this.#marketsCache.find((mp) => mp.poolId === poolId); + const chainId = pool?.chainId ?? this.#chainId; + const existing = chainIdMap.get(chainId) ?? []; + existing.push(poolId); + chainIdMap.set(chainId, existing); + } + + const results = await Promise.all( + Array.from(chainIdMap.entries()).map(([chainId, ids]) => + this.#myxClient.markets.getTickerList({ chainId, poolIds: ids }), + ), + ); + + return results.flat().filter(Boolean); + } catch (caughtError) { + const wrappedError = ensureError( + caughtError, + 'MYXClientService.getTickers', + ); + this.#deps.logger.error( + wrappedError, + this.#getErrorContext('getTickers', { poolIds }), + ); + throw wrappedError; + } + } + + /** + * Get all tickers (for all available markets) + * + * @returns The array of ticker data for all available markets. + */ + async getAllTickers(): Promise { + try { + this.#deps.debugLogger.log( + '[MYXClientService] Fetching all tickers via SDK', + ); + + // Get all pools first, then fetch tickers for them + const pools = await this.getMarkets(); + const poolIds = pools.map((pool) => pool.poolId); + + if (poolIds.length === 0) { + return []; + } + + return this.getTickers(poolIds); + } catch (caughtError) { + const wrappedError = ensureError( + caughtError, + 'MYXClientService.getAllTickers', + ); + this.#deps.logger.error( + wrappedError, + this.#getErrorContext('getAllTickers'), + ); + throw wrappedError; + } + } + + // ============================================================================ + // Price Polling + // ============================================================================ + + /** + * Start polling for price updates. + * Uses sequential setTimeout to prevent request pileup — the next poll + * is only scheduled after the current one completes (or fails). + * + * @param poolIds - The array of pool identifiers to poll prices for. + * @param callback - The callback invoked with updated ticker data on each poll. + */ + startPricePolling(poolIds: string[], callback: PricePollingCallback): void { + // Stop existing polling + this.stopPricePolling(); + + this.#pollingSymbols = poolIds; + this.#pollingCallback = callback; + + // Fetch immediately, then schedule subsequent polls + this.#pollPrices().catch(() => { + // Error handling is done inside #pollPrices + }); + + this.#deps.debugLogger.log('[MYXClientService] Started price polling', { + symbols: poolIds.length, + intervalMs: MYX_PRICE_POLLING_INTERVAL_MS, + }); + } + + /** + * Stop price polling + */ + stopPricePolling(): void { + if (this.#pricePollingTimeout) { + clearTimeout(this.#pricePollingTimeout); + this.#pricePollingTimeout = undefined; + } + this.#pollingSymbols = []; + this.#pollingCallback = undefined; + + this.#deps.debugLogger.log('[MYXClientService] Stopped price polling'); + } + + /** + * Execute a single price poll, then schedule the next one. + * Sequential pattern ensures no request pileup if polls take longer than the interval. + */ + async #pollPrices(): Promise { + if (!this.#pollingCallback || this.#pollingSymbols.length === 0) { + return; + } + + try { + const tickers = await this.getTickers(this.#pollingSymbols); + // Re-check: polling may have been stopped during the await + // (TS narrows after early return but can't track mutations across await) + const callback = this.#pollingCallback; + if (callback) { + callback(tickers); + } + } catch (caughtError) { + const wrappedError = ensureError( + caughtError, + 'MYXClientService.pollPrices', + ); + this.#deps.debugLogger.log('[MYXClientService] Price poll failed', { + error: wrappedError.message, + }); + // Don't propagate error - polling continues + } finally { + this.#scheduleNextPoll(); + } + } + + /** + * Schedule the next poll after the configured interval + */ + #scheduleNextPoll(): void { + // Only schedule if polling is still active + if (!this.#pollingCallback || this.#pollingSymbols.length === 0) { + return; + } + + this.#pricePollingTimeout = setTimeout(() => { + this.#pollPrices().catch(() => { + // Error handling is done inside #pollPrices + }); + }, MYX_PRICE_POLLING_INTERVAL_MS); + } + + // ============================================================================ + // Authentication + // ============================================================================ + + /** + * Authenticate the MYX client with signer and access token. + * Uses promise dedup to prevent concurrent auth attempts. + * + * @param signer - ethers v6 Signer-like object + * @param walletClient - viem WalletClient-like object + * @param address - User wallet address for token generation + */ + async authenticate( + signer: unknown, + walletClient: unknown, + address: string, + ): Promise { + if (this.#authenticatedAddress === address.toLowerCase()) { + return; + } + + // Dedup concurrent auth calls + if (this.#authenticating) { + await this.#authenticating; + return; + } + + this.#authenticating = this.#doAuthenticate(signer, walletClient, address); + try { + await this.#authenticating; + } finally { + this.#authenticating = null; + } + } + + async #doAuthenticate( + signer: unknown, + walletClient: unknown, + address: string, + ): Promise { + try { + this.#deps.debugLogger.log('[MYXClientService] Authenticating...', { + address: `${address.slice(0, 6)}...${address.slice(-4)}`, + }); + + // Create getAccessToken callback for the SDK. + // The SDK calls this when it needs a fresh token. + // Must return {accessToken, expireAt} or undefined. + const getAccessToken = async (): Promise< + { accessToken: string; expireAt: number } | undefined + > => { + try { + const token = await this.#generateAccessToken(address); + if (!token) { + return undefined; + } + // Workaround: MYX WS requires 'sdk.' prefix on access tokens. + // The SDK's subscription.auth() omits it — prepend here. + // Guard against double-prefix if SDK fixes this in the future. + const prefixed = token.accessToken.startsWith('sdk.') + ? token.accessToken + : `sdk.${token.accessToken}`; + return { accessToken: prefixed, expireAt: token.expireAt }; + } catch (tokenError) { + this.#deps.debugLogger.log( + '[MYXClientService] Token generation failed', + { error: String(tokenError) }, + ); + return undefined; + } + }; + + // Call SDK auth with signer, walletClient, and getAccessToken + this.#myxClient.auth({ + // eslint-disable-next-line @typescript-eslint/no-explicit-any + signer: signer as any, + getAccessToken, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + walletClient: walletClient as any, + }); + + this.#authenticatedAddress = address.toLowerCase(); + + this.#deps.debugLogger.log( + '[MYXClientService] Authentication successful', + ); + } catch (caughtError) { + const wrappedError = ensureError( + caughtError, + 'MYXClientService.authenticate', + ); + this.#deps.logger.error( + wrappedError, + this.#getErrorContext('authenticate'), + ); + throw wrappedError; + } + } + + // ============================================================================ + // Token Generation (moved from myxConfig.ts) + // ============================================================================ + + /** + * Compute SHA-256 hex digest using the Web Crypto API (available in React Native). + * + * @param input - The string to hash. + * @returns Hex-encoded SHA-256 digest. + */ + async #sha256Hex(input: string): Promise { + const encoder = new TextEncoder(); + const data = encoder.encode(input); + const hashBuffer = await globalThis.crypto.subtle.digest( + 'SHA-256', + data.buffer as ArrayBuffer, + ); + const hashArray = Array.from(new Uint8Array(hashBuffer)); + return hashArray.map((b) => b.toString(16).padStart(2, '0')).join(''); + } + + /** + * Generate MYX access token via Token API. + * Token = SHA256({appId}&{timestamp}&{expireTime}&{address}&{secret}) + * + * @param address - User wallet address. + * @returns Access token response with token string and expiry. + */ + async #generateAccessToken( + address: string, + ): Promise<{ accessToken: string; expireAt: number }> { + const { appId, apiSecret } = this.#authConfig; + + if (!appId || !apiSecret) { + throw new Error( + `MYX credentials not configured for ${this.#network}. Ensure MM_PERPS_MYX_APP_ID and MM_PERPS_MYX_API_SECRET are set in .js.env`, + ); + } + + const timestamp = Math.floor(Date.now() / 1000); + const expireTime = timestamp + 86400; // 24 hours + const signString = `${appId}&${timestamp}&${expireTime}&${address}&${apiSecret}`; + const signature = await this.#sha256Hex(signString); + + // GET request with query params (per SDK integration guide) + const params = new URLSearchParams({ + appId, + timestamp: String(timestamp), + expireTime: String(expireTime), + allowAccount: address, + signature, + }); + + const tokenApiUrl = `${getMYXHttpEndpoint(this.#network)}/openapi/gateway/auth/api_key/create_token`; + + const response = await fetch(`${tokenApiUrl}?${params.toString()}`); + + if (!response.ok) { + throw new Error(`MYX token API request failed: ${response.status}`); + } + + const result = (await response.json()) as { + code: number; + data?: { accessToken: string; expireAt: number }; + message?: string; + }; + + if ( + (result.code !== 9200 && result.code !== 0) || + !result.data?.accessToken + ) { + throw new Error( + `MYX token API error: code=${result.code} message=${result.message ?? 'unknown'}`, + ); + } + + return { + accessToken: result.data.accessToken, + expireAt: result.data.expireAt, + }; + } + + /** + * Check if the client is authenticated. + * + * @returns True if the client has been authenticated. + */ + isAuthenticated(): boolean { + return this.#authenticatedAddress !== null; + } + + /** + * Check if the client is authenticated for a specific address. + * + * @param address - The wallet address to check. + * @returns True if the client is authenticated for the given address. + */ + isAuthenticatedForAddress(address: string): boolean { + return this.#authenticatedAddress === address.toLowerCase(); + } + + /** + * Get the currently authenticated address, or null if not authenticated. + * + * @returns The authenticated address or null. + */ + getAuthenticatedAddress(): string | null { + return this.#authenticatedAddress; + } + + // ============================================================================ + // Authenticated Read Operations + // ============================================================================ + + /** + * List positions for the given address. + * + * @param address - User wallet address. + * @returns SDK response with position data. + */ + async listPositions( + address: string, + ): Promise<{ code: number; data?: MYXPositionType[] }> { + try { + this.#deps.debugLogger.log('[MYXClientService] Listing positions'); + const result = await this.#myxClient.position.listPositions(address); + return result as { code: number; data?: MYXPositionType[] }; + } catch (caughtError) { + const wrappedError = ensureError( + caughtError, + 'MYXClientService.listPositions', + ); + this.#deps.logger.error( + wrappedError, + this.#getErrorContext('listPositions'), + ); + throw wrappedError; + } + } + + /** + * Get open orders for the given address. + * + * @param address - User wallet address. + * @returns SDK response with order data. + */ + async getOrders( + address: string, + ): Promise<{ code: number; data?: MYXPositionType[] }> { + try { + this.#deps.debugLogger.log('[MYXClientService] Getting orders'); + const result = await this.#myxClient.order.getOrders(address); + return result as { code: number; data?: MYXPositionType[] }; + } catch (caughtError) { + const wrappedError = ensureError( + caughtError, + 'MYXClientService.getOrders', + ); + this.#deps.logger.error(wrappedError, this.#getErrorContext('getOrders')); + throw wrappedError; + } + } + + /** + * Get order history. + * + * @param params - History query parameters (limit, chainId, poolId). + * @param address - User wallet address. + * @returns SDK response with historical order data. + */ + async getOrderHistory( + params: MYXGetHistoryOrdersParams, + address: string, + ): Promise<{ code: number; data: MYXHistoryOrderItem[] }> { + try { + this.#deps.debugLogger.log('[MYXClientService] Getting order history'); + const result = await this.#myxClient.order.getOrderHistory( + params, + address, + ); + return result as { code: number; data: MYXHistoryOrderItem[] }; + } catch (caughtError) { + const wrappedError = ensureError( + caughtError, + 'MYXClientService.getOrderHistory', + ); + this.#deps.logger.error( + wrappedError, + this.#getErrorContext('getOrderHistory'), + ); + throw wrappedError; + } + } + + /** + * Get position history. + * + * @param params - History query parameters (limit, chainId, poolId). + * @param address - User wallet address. + * @returns SDK response with historical position data. + */ + async getPositionHistory( + params: MYXGetHistoryOrdersParams, + address: string, + ): Promise<{ code: number; data: MYXPositionHistoryItem[] }> { + try { + this.#deps.debugLogger.log('[MYXClientService] Getting position history'); + const result = await this.#myxClient.position.getPositionHistory( + params, + address, + ); + return result as { code: number; data: MYXPositionHistoryItem[] }; + } catch (caughtError) { + const wrappedError = ensureError( + caughtError, + 'MYXClientService.getPositionHistory', + ); + this.#deps.logger.error( + wrappedError, + this.#getErrorContext('getPositionHistory'), + ); + throw wrappedError; + } + } + + /** + * Get account info for a specific pool. + * + * @param chainId - Chain ID for the query. + * @param address - User wallet address. + * @param poolId - Pool identifier. + * @returns SDK response with account info data. + */ + async getAccountInfo( + chainId: number, + address: string, + poolId: string, + ): Promise<{ code: number; data?: Record }> { + try { + this.#deps.debugLogger.log('[MYXClientService] Getting account info', { + poolId, + }); + const result = await this.#myxClient.account.getAccountInfo( + chainId, + address, + poolId, + ); + return result as { code: number; data?: Record }; + } catch (caughtError) { + const wrappedError = ensureError( + caughtError, + 'MYXClientService.getAccountInfo', + ); + this.#deps.logger.error( + wrappedError, + this.#getErrorContext('getAccountInfo'), + ); + throw wrappedError; + } + } + + /** + * Get wallet USDT balance. + * + * @param chainId - Chain ID for the query. + * @param address - User wallet address. + * @returns SDK response with balance data. + */ + async getWalletQuoteTokenBalance( + chainId: number, + address: string, + ): Promise<{ code: number; data: string }> { + try { + this.#deps.debugLogger.log('[MYXClientService] Getting wallet balance'); + const result = await this.#myxClient.account.getWalletQuoteTokenBalance( + chainId, + address, + ); + return result as { code: number; data: string }; + } catch (caughtError) { + const wrappedError = ensureError( + caughtError, + 'MYXClientService.getWalletQuoteTokenBalance', + ); + this.#deps.logger.error( + wrappedError, + this.#getErrorContext('getWalletQuoteTokenBalance'), + ); + throw wrappedError; + } + } + + /** + * Get trade flow (deposits, withdrawals, funding, etc.). + * + * @param params - History query parameters (limit, chainId, poolId). + * @param address - User wallet address. + * @returns SDK response with trade flow data. + */ + async getTradeFlow( + params: MYXGetHistoryOrdersParams, + address: string, + ): Promise<{ code: number; data: MYXTradeFlowItem[] }> { + try { + this.#deps.debugLogger.log('[MYXClientService] Getting trade flow'); + const result = await this.#myxClient.account.getTradeFlow( + params, + address, + ); + return result as { code: number; data: MYXTradeFlowItem[] }; + } catch (caughtError) { + const wrappedError = ensureError( + caughtError, + 'MYXClientService.getTradeFlow', + ); + this.#deps.logger.error( + wrappedError, + this.#getErrorContext('getTradeFlow'), + ); + throw wrappedError; + } + } + + /** + * Get chain ID. + * + * @returns The numeric chain ID. + */ + getChainId(): number { + return this.#chainId; + } + + /** + * Get network. + * + * @returns The network identifier string. + */ + getNetwork(): 'testnet' | 'mainnet' { + return this.#network; + } + + // ============================================================================ + // Kline (Candle) Data + // ============================================================================ + + /** + * Get kline (candle) data for a pool. + * + * @param params - Kline query parameters. + * @param params.poolId - Pool identifier. + * @param params.interval - Kline resolution ('1m', '5m', '15m', '30m', '1h', '4h', '1d', '1w', '1M'). + * @param params.limit - Number of candles to fetch. + * @param params.endTime - Optional end time (unix seconds). + * @returns Array of kline data items. + */ + async getKlineData(params: { + poolId: string; + interval: KlineResolution; + limit: number; + endTime?: number; + }): Promise { + try { + this.#deps.debugLogger.log('[MYXClientService] Fetching kline data', { + poolId: params.poolId, + interval: params.interval, + limit: params.limit, + }); + + const result = await this.#myxClient.markets.getKlineList({ + poolId: params.poolId, + chainId: this.#chainId, + interval: params.interval, + limit: params.limit, + endTime: params.endTime ?? Math.floor(Date.now() / 1000), + }); + + return result || []; + } catch (caughtError) { + const wrappedError = ensureError( + caughtError, + 'MYXClientService.getKlineData', + ); + this.#deps.logger.error( + wrappedError, + this.#getErrorContext('getKlineData', { + poolId: params.poolId, + interval: params.interval, + }), + ); + throw wrappedError; + } + } + + // ============================================================================ + // Market Detail / Global ID + // ============================================================================ + + /** + * Get the globalId for a pool. Required for WebSocket subscriptions. + * Fetches via getMarketDetail and caches the result. + * + * @param poolId - Pool identifier. + * @returns The numeric globalId for WebSocket subscriptions. + */ + async getGlobalId(poolId: string): Promise { + const cached = this.#globalIdCache.get(poolId); + if (cached !== undefined) { + return cached; + } + + try { + this.#deps.debugLogger.log( + '[MYXClientService] Fetching globalId via getMarketDetail', + { poolId }, + ); + + const detail = await this.#myxClient.markets.getMarketDetail({ + chainId: this.#chainId, + poolId, + }); + + const { globalId } = detail; + this.#globalIdCache.set(poolId, globalId); + + this.#deps.debugLogger.log('[MYXClientService] globalId cached', { + poolId, + globalId, + }); + + return globalId; + } catch (caughtError) { + const wrappedError = ensureError( + caughtError, + 'MYXClientService.getGlobalId', + ); + this.#deps.logger.error( + wrappedError, + this.#getErrorContext('getGlobalId', { poolId }), + ); + throw wrappedError; + } + } + + // ============================================================================ + // Kline WebSocket Subscriptions + // ============================================================================ + + /** + * Subscribe to live kline (candle) updates via WebSocket. + * WS is already connected (always-on from construction). + * + * @param globalId - Market globalId (from getGlobalId). + * @param resolution - Kline resolution. + * @param callback - Called on each WS kline update. + */ + subscribeToKline( + globalId: number, + resolution: KlineResolution, + callback: (data: KlineDataResponse) => void, + ): void { + this.#deps.debugLogger.log('[MYXClientService] Subscribing to kline WS', { + globalId, + resolution, + }); + this.#myxClient.subscription.subscribeKline(globalId, resolution, callback); + } + + /** + * Unsubscribe from live kline updates. + * + * @param globalId - Market globalId. + * @param resolution - Kline resolution. + * @param callback - The same callback reference passed to subscribeToKline. + */ + unsubscribeFromKline( + globalId: number, + resolution: KlineResolution, + callback: (data: KlineDataResponse) => void, + ): void { + this.#deps.debugLogger.log( + '[MYXClientService] Unsubscribing from kline WS', + { globalId, resolution }, + ); + try { + this.#myxClient.subscription.unsubscribeKline( + globalId, + resolution, + callback, + ); + } catch (error) { + // SOCKET_NOT_CONNECTED is expected during cleanup — socket already torn down + this.#deps.debugLogger.log( + '[MYXClientService] Kline unsubscribe failed (expected during disconnect)', + { globalId, resolution, error: String(error) }, + ); + } + } + + // ============================================================================ + // Health Check + // ============================================================================ + + /** + * Health check — attempts a lightweight REST call (getTickerList with empty poolIds) + * to verify the MYX API is reachable. + * + * @param timeoutMs - The timeout in milliseconds for the ping request. + */ + async ping(timeoutMs = 5000): Promise { + this.#deps.debugLogger.log( + '[MYXClientService] Ping - checking REST health', + ); + + let timeoutId: ReturnType | undefined; + const timeoutPromise = new Promise((_resolve, reject) => { + timeoutId = setTimeout( + () => reject(new Error('MYX ping timeout')), + timeoutMs, + ); + }); + + try { + await Promise.race([ + this.#myxClient.markets.getTickerList({ + chainId: this.#chainId, + poolIds: [], + }), + timeoutPromise, + ]); + } catch (caughtError) { + const wrappedError = ensureError(caughtError, 'MYXClientService.ping'); + this.#deps.debugLogger.log('[MYXClientService] Ping failed', { + error: wrappedError.message, + }); + throw wrappedError; + } finally { + clearTimeout(timeoutId); + } + } + + // ============================================================================ + // Lifecycle + // ============================================================================ + + /** + * Disconnect and cleanup + */ + disconnect(): void { + this.stopPricePolling(); + this.#myxClient.subscription.disconnect(); + this.#marketsCache = []; + this.#marketsCacheTimestamp = 0; + this.#globalIdCache.clear(); + this.#authenticatedAddress = null; + this.#authenticating = null; + + this.#deps.debugLogger.log('[MYXClientService] Disconnected'); + } + + /** + * Get current network mode + * + * @returns True if the service is in testnet mode. + */ + getIsTestnet(): boolean { + return this.#isTestnet; + } +} diff --git a/packages/perps-controller/src/services/MYXWalletService.ts b/packages/perps-controller/src/services/MYXWalletService.ts new file mode 100644 index 00000000000..6e85e5d502a --- /dev/null +++ b/packages/perps-controller/src/services/MYXWalletService.ts @@ -0,0 +1,242 @@ +/** + * MYXWalletService + * + * Provides ethers v6 Signer and viem WalletClient adapters for the MYX SDK. + * Routes signing operations through MetaMask's KeyringController via messenger. + * + * The MYX SDK requires: + * - ethers.Signer (v6) for on-chain transaction signing + * - viem WalletClient for wallet interactions + * - getAccessToken callback for API auth + * + * This service creates lightweight adapter objects that satisfy these interfaces + * while delegating actual signing to the MetaMask keyring. + */ + +import { SignTypedDataVersion } from '@metamask/keyring-controller'; +import type { TypedMessageParams } from '@metamask/keyring-controller'; +import { parseCaipAccountId, isValidHexAddress } from '@metamask/utils'; +import type { CaipAccountId, Hex } from '@metamask/utils'; + +import { + getMYXChainId, + MYX_TESTNET_CHAIN_ID, + MYX_MAINNET_CHAIN_ID, +} from '../constants/myxConfig.js'; +import type { PerpsControllerMessenger } from '../PerpsController.js'; +import { PERPS_ERROR_CODES } from '../perpsErrorCodes.js'; +import type { PerpsPlatformDependencies } from '../types/index.js'; +import { getSelectedEvmAccountFromMessenger } from '../utils/accountUtils.js'; + +export class MYXWalletService { + #isTestnet: boolean; + + readonly #deps: PerpsPlatformDependencies; + + readonly #messenger: PerpsControllerMessenger; + + constructor( + deps: PerpsPlatformDependencies, + messenger: PerpsControllerMessenger, + options: { isTestnet?: boolean } = {}, + ) { + this.#deps = deps; + this.#messenger = messenger; + this.#isTestnet = options.isTestnet ?? false; + } + + /** + * Check if the keyring is currently unlocked. + * + * @returns True if the keyring is unlocked and available for signing. + */ + public isKeyringUnlocked(): boolean { + return this.#messenger.call('KeyringController:getState').isUnlocked; + } + + async #signTypedMessage(msgParams: TypedMessageParams): Promise { + if (!this.isKeyringUnlocked()) { + throw new Error(PERPS_ERROR_CODES.KEYRING_LOCKED); + } + return this.#messenger.call( + 'KeyringController:signTypedMessage', + msgParams, + SignTypedDataVersion.V4, + ); + } + + /** + * Create an ethers v6 Signer-like object for the MYX SDK. + * The MYX SDK uses ethers v6 internally (bundled in its own node_modules). + * We return a plain object that satisfies the SDK's usage pattern: + * - getAddress(): returns the user's address + * - signTypedData(): delegates to MetaMask keyring + * + * @returns Signer-like adapter object for the MYX SDK. + */ + public createEthersSigner(): { + getAddress: () => Promise; + signTypedData: ( + domain: Record, + types: Record, + value: Record, + ) => Promise; + provider: null; + } { + const evmAccount = getSelectedEvmAccountFromMessenger(this.#messenger); + if (!evmAccount?.address) { + throw new Error(PERPS_ERROR_CODES.NO_ACCOUNT_SELECTED); + } + + return { + getAddress: async (): Promise => { + const currentAccount = getSelectedEvmAccountFromMessenger( + this.#messenger, + ); + if (!currentAccount?.address) { + throw new Error(PERPS_ERROR_CODES.NO_ACCOUNT_SELECTED); + } + return currentAccount.address; + }, + signTypedData: async ( + domain: Record, + types: Record, + value: Record, + ): Promise => { + const currentAccount = getSelectedEvmAccountFromMessenger( + this.#messenger, + ); + if (!currentAccount?.address) { + throw new Error(PERPS_ERROR_CODES.NO_ACCOUNT_SELECTED); + } + + // Determine primaryType from types (exclude EIP712Domain) + const typeKeys = Object.keys(types).filter((k) => k !== 'EIP712Domain'); + const primaryType = typeKeys[0] ?? 'EIP712Domain'; + + this.#deps.debugLogger.log('MYXWalletService: Signing typed data', { + address: currentAccount.address, + primaryType, + }); + + const signature = await this.#signTypedMessage({ + from: currentAccount.address as Hex, + data: { + domain, + types, + primaryType, + message: value, + }, + }); + + return signature; + }, + provider: null, + }; + } + + /** + * Create a viem WalletClient-like object for the MYX SDK. + * The SDK's auth() requires a walletClient parameter. + * We provide a minimal object that satisfies the SDK's usage. + * + * @returns WalletClient-like adapter object for the MYX SDK. + */ + public createWalletClient(): { + account: { address: string }; + chain: { id: number }; + signTypedData: (args: { + domain: Record; + types: Record; + primaryType: string; + message: Record; + }) => Promise; + } { + const evmAccount = getSelectedEvmAccountFromMessenger(this.#messenger); + if (!evmAccount?.address) { + throw new Error(PERPS_ERROR_CODES.NO_ACCOUNT_SELECTED); + } + const chainId = getMYXChainId(this.#isTestnet ? 'testnet' : 'mainnet'); + + return { + account: { address: evmAccount.address }, + chain: { id: chainId }, + signTypedData: async (args): Promise => { + const currentAccount = getSelectedEvmAccountFromMessenger( + this.#messenger, + ); + if (!currentAccount?.address) { + throw new Error(PERPS_ERROR_CODES.NO_ACCOUNT_SELECTED); + } + + this.#deps.debugLogger.log( + 'MYXWalletService: WalletClient signTypedData', + { + address: currentAccount.address, + primaryType: args.primaryType, + }, + ); + + const signature = await this.#signTypedMessage({ + from: currentAccount.address as Hex, + data: { + domain: args.domain, + types: args.types, + primaryType: args.primaryType, + message: args.message, + }, + }); + + return signature; + }, + }; + } + + public getUserAddress(): Hex { + const evmAccount = getSelectedEvmAccountFromMessenger(this.#messenger); + if (!evmAccount?.address) { + throw new Error(PERPS_ERROR_CODES.NO_ACCOUNT_SELECTED); + } + const address = evmAccount.address as Hex; + if (!isValidHexAddress(address)) { + throw new Error(PERPS_ERROR_CODES.INVALID_ADDRESS_FORMAT); + } + return address; + } + + public async getCurrentAccountId(): Promise { + const evmAccount = getSelectedEvmAccountFromMessenger(this.#messenger); + if (!evmAccount?.address) { + throw new Error(PERPS_ERROR_CODES.NO_ACCOUNT_SELECTED); + } + const chainId = this.#isTestnet + ? MYX_TESTNET_CHAIN_ID + : MYX_MAINNET_CHAIN_ID; + const caipAccountId: CaipAccountId = `eip155:${chainId}:${evmAccount.address}`; + return caipAccountId; + } + + public getUserAddressFromAccountId(accountId: CaipAccountId): Hex { + const parsed = parseCaipAccountId(accountId); + const address = parsed.address as Hex; + if (!isValidHexAddress(address)) { + throw new Error(PERPS_ERROR_CODES.INVALID_ADDRESS_FORMAT); + } + return address; + } + + public async getUserAddressWithDefault( + accountId?: CaipAccountId, + ): Promise { + const id = accountId ?? (await this.getCurrentAccountId()); + return this.getUserAddressFromAccountId(id); + } + + public setTestnetMode(isTestnet: boolean): void { + this.#isTestnet = isTestnet; + } + + public isTestnetMode(): boolean { + return this.#isTestnet; + } +} diff --git a/packages/perps-controller/src/services/MarketDataService.ts b/packages/perps-controller/src/services/MarketDataService.ts new file mode 100644 index 00000000000..eec95293e81 --- /dev/null +++ b/packages/perps-controller/src/services/MarketDataService.ts @@ -0,0 +1,1482 @@ +import { v4 as uuidv4 } from 'uuid'; + +import type { CandlePeriod } from '../constants/chartConfig.js'; +import { PerpsMeasurementName } from '../constants/performanceMetrics.js'; +import { PERPS_CONSTANTS } from '../constants/perpsConfig.js'; +import { PERPS_ERROR_CODES } from '../perpsErrorCodes.js'; +import { PerpsTraceNames, PerpsTraceOperations } from '../types/index.js'; +import type { + PerpsProvider, + Position, + GetPositionsParams, + AccountState, + GetAccountStateParams, + HistoricalPortfolioResult, + GetHistoricalPortfolioParams, + OrderFill, + GetOrderFillsParams, + Funding, + GetFundingParams, + Order, + GetOrdersParams, + MarketInfo, + GetMarketDataWithPricesParams, + GetMarketsParams, + GetAvailableDexsParams, + LiquidationPriceParams, + MaintenanceMarginParams, + PositionModifyPreviewParams, + PositionModifyPreviewResult, + FeeCalculationParams, + FeeCalculationResult, + OrderParams, + ClosePositionParams, + AssetRoute, + PerpsPlatformDependencies, + PerpsMarketData, + TerminalAssetMetadata, +} from '../types/index.js'; +import type { CandleData } from '../types/perps-types.js'; +import { coalescePerpsRestRequest } from '../utils/coalescePerpsRestRequest.js'; +import { ensureError, isAbortError } from '../utils/errorUtils.js'; +import { applyMarketFilters } from '../utils/marketUtils.js'; +import type { ServiceContext } from './ServiceContext.js'; + +/** + * MarketDataService + * + * Handles all read-only data-fetching operations for the Perps controller. + * This service is stateless and delegates to the provider. + * The controller is responsible for tracing and state management. + * + * Instance-based service with constructor injection of platform dependencies. + */ +export class MarketDataService { + readonly #deps: PerpsPlatformDependencies; + + /** + * Create a new MarketDataService instance + * + * @param deps - Platform dependencies for logging, metrics, etc. + */ + constructor(deps: PerpsPlatformDependencies) { + this.#deps = deps; + } + + /** + * Get current positions + * Handles full orchestration: tracing, error logging, state management, and provider delegation + * + * @param options - The configuration options. + * @param options.provider - The perps provider instance. + * @param options.params - The operation parameters. + * @param options.context - The service context for dependencies. + * @returns The result of the operation. + */ + async getPositions(options: { + provider: PerpsProvider; + params?: GetPositionsParams; + context: ServiceContext; + }): Promise { + const { provider, params, context } = options; + const traceId = uuidv4(); + let traceData: { success: boolean; error?: string } | undefined; + + try { + this.#deps.tracer.trace({ + name: PerpsTraceNames.GetPositions, + id: traceId, + op: PerpsTraceOperations.Operation, + tags: { + provider: context.tracingContext.provider, + isTestnet: String(context.tracingContext.isTestnet), + }, + }); + + const positions = await provider.getPositions(params); + + // Update state on success (if stateManager is provided) + if (context.stateManager) { + context.stateManager.update((state) => { + state.lastUpdateTimestamp = Date.now(); + state.lastError = null; + }); + } + + traceData = { success: true }; + return positions; + } catch (error) { + const errorMessage = + error instanceof Error + ? error.message + : PERPS_ERROR_CODES.POSITIONS_FAILED; + + // Update error state (if stateManager is provided) + if (context.stateManager) { + context.stateManager.update((state) => { + state.lastError = errorMessage; + state.lastUpdateTimestamp = Date.now(); + }); + } + + traceData = { + success: false, + error: errorMessage, + }; + + throw error; + } finally { + this.#deps.tracer.endTrace({ + name: PerpsTraceNames.GetPositions, + id: traceId, + data: traceData, + }); + } + } + + /** + * Get order fills for a specific user or order + * Handles full orchestration: tracing, error logging, and provider delegation + * + * @param options - The configuration options. + * @param options.provider - The perps provider instance. + * @param options.params - The operation parameters. + * @param options.context - The service context for dependencies. + * @param options.forceRefresh - Bypass the request-coalesce cache end-to-end + * (user-initiated refresh). + * @returns The result of the operation. + */ + async getOrderFills(options: { + provider: PerpsProvider; + params?: GetOrderFillsParams; + context: ServiceContext; + /** + * Bypass the request-coalesce cache. Use for user-initiated refresh + * (pull-to-refresh, polling tick) so the fetch runs fresh. + */ + forceRefresh?: boolean; + }): Promise { + const { provider, params, context, forceRefresh } = options; + const traceId = uuidv4(); + let traceData: { success: boolean; error?: string } | undefined; + + try { + this.#deps.tracer.trace({ + name: PerpsTraceNames.OrderFillsFetch, + id: traceId, + op: PerpsTraceOperations.Operation, + tags: { + provider: context.tracingContext.provider, + isTestnet: String(context.tracingContext.isTestnet), + }, + }); + + // Pagination / explicit end-window callers bypass the shared cache so + // their specific page never collides with the default "recent fills" + // bucket. Day-granular startTime bucket prevents a ~90d caller from + // sharing payloads with an all-history caller. + const isPaginated = + params?.limit !== undefined || params?.endTime !== undefined; + + if (isPaginated) { + const result = await provider.getOrderFills(params, { forceRefresh }); + traceData = { success: true }; + return result; + } + + // Non-paginated: resolve the caller's account so the cache key is + // account-scoped. Without this, callers that omit params.accountId + // (the common hook path) would collide on a shared "default" bucket — + // after an account switch, account B could receive account A's + // still-fresh payload until the TTL expired. Pin the resolved id onto + // the forwarded params so the provider cannot re-resolve to a different + // account between our resolve() and its fetch (TOCTOU guard). + const resolvedAccountId = + params?.accountId ?? (await provider.getCurrentAccountId()); + const pinnedParams: GetOrderFillsParams = { + ...params, + accountId: resolvedAccountId, + }; + const result = await coalescePerpsRestRequest( + [ + context.tracingContext.provider, + context.tracingContext.isTestnet ? 'testnet' : 'mainnet', + 'getOrderFills', + resolvedAccountId, + params?.aggregateByTime === true ? 'agg' : 'raw', + params?.startTime === undefined + ? 'unbounded' + : `s${Math.floor(params.startTime / 86_400_000)}`, + ].join('|'), + () => provider.getOrderFills(pinnedParams, { forceRefresh }), + { forceRefresh }, + ); + + traceData = { success: true }; + return result; + } catch (error) { + this.#deps.logger.error( + ensureError(error, 'MarketDataService.getOrderFills'), + { + tags: { + feature: PERPS_CONSTANTS.FeatureName, + provider: context.tracingContext.provider, + network: context.tracingContext.isTestnet ? 'testnet' : 'mainnet', + }, + context: { + name: context.errorContext.controller, + data: { + method: context.errorContext.method, + params, + }, + }, + }, + ); + + traceData = { + success: false, + error: error instanceof Error ? error.message : 'Unknown error', + }; + throw error; + } finally { + this.#deps.tracer.endTrace({ + name: PerpsTraceNames.OrderFillsFetch, + id: traceId, + data: traceData, + }); + } + } + + /** + * Get historical user orders (order lifecycle) + * Handles full orchestration: tracing, error logging, and provider delegation + * + * @param options - The configuration options. + * @param options.provider - The perps provider instance. + * @param options.params - The operation parameters. + * @param options.context - The service context for dependencies. + * @param options.forceRefresh - Bypass the request-coalesce cache end-to-end + * (user-initiated refresh). + * @returns The result of the operation. + */ + async getOrders(options: { + provider: PerpsProvider; + params?: GetOrdersParams; + context: ServiceContext; + /** + * Bypass the request-coalesce cache. Use for user-initiated refresh. + */ + forceRefresh?: boolean; + }): Promise { + const { provider, params, context, forceRefresh } = options; + const traceId = uuidv4(); + let traceData: { success: boolean; error?: string } | undefined; + + try { + this.#deps.tracer.trace({ + name: PerpsTraceNames.OrdersFetch, + id: traceId, + op: PerpsTraceOperations.Operation, + tags: { + provider: context.tracingContext.provider, + isTestnet: String(context.tracingContext.isTestnet), + }, + }); + + const isPaginated = + params?.limit !== undefined || + params?.offset !== undefined || + params?.endTime !== undefined; + + if (isPaginated) { + const result = await provider.getOrders(params, { forceRefresh }); + traceData = { success: true }; + return result; + } + + // Non-paginated: resolve the caller's account so the cache key is + // account-scoped (see getOrderFills for rationale). Pin the resolved + // id onto the forwarded params so the provider cannot re-resolve to a + // different account between our resolve() and its fetch. + const resolvedAccountId = + params?.accountId ?? (await provider.getCurrentAccountId()); + const pinnedParams: GetOrdersParams = { + ...params, + accountId: resolvedAccountId, + }; + const result = await coalescePerpsRestRequest( + [ + context.tracingContext.provider, + context.tracingContext.isTestnet ? 'testnet' : 'mainnet', + 'getOrders', + resolvedAccountId, + ].join('|'), + () => provider.getOrders(pinnedParams, { forceRefresh }), + { forceRefresh }, + ); + + traceData = { success: true }; + return result; + } catch (error) { + this.#deps.logger.error( + ensureError(error, 'MarketDataService.getOrders'), + { + tags: { + feature: PERPS_CONSTANTS.FeatureName, + provider: context.tracingContext.provider, + network: context.tracingContext.isTestnet ? 'testnet' : 'mainnet', + }, + context: { + name: context.errorContext.controller, + data: { + method: context.errorContext.method, + params, + }, + }, + }, + ); + + traceData = { + success: false, + error: error instanceof Error ? error.message : 'Unknown error', + }; + throw error; + } finally { + this.#deps.tracer.endTrace({ + name: PerpsTraceNames.OrdersFetch, + id: traceId, + data: traceData, + }); + } + } + + /** + * Get current open orders + * Handles full orchestration: tracing, error logging, performance measurement, and provider delegation + * + * @param options - The configuration options. + * @param options.provider - The perps provider instance. + * @param options.params - The operation parameters. + * @param options.context - The service context for dependencies. + * @returns The result of the operation. + */ + async getOpenOrders(options: { + provider: PerpsProvider; + params?: GetOrdersParams; + context: ServiceContext; + }): Promise { + const { provider, params, context } = options; + const traceId = uuidv4(); + const startTime = this.#deps.performance.now(); + let traceData: { success: boolean; error?: string } | undefined; + + try { + this.#deps.tracer.trace({ + name: PerpsTraceNames.OrdersFetch, + id: traceId, + op: PerpsTraceOperations.Operation, + tags: { + provider: context.tracingContext.provider, + isTestnet: String(context.tracingContext.isTestnet), + }, + }); + + const result = await provider.getOpenOrders(params); + + const completionDuration = this.#deps.performance.now() - startTime; + this.#deps.tracer.setMeasurement( + PerpsMeasurementName.PerpsGetOpenOrdersOperation, + completionDuration, + 'millisecond', + ); + + traceData = { success: true }; + return result; + } catch (error) { + this.#deps.logger.error( + ensureError(error, 'MarketDataService.getOpenOrders'), + { + tags: { + feature: PERPS_CONSTANTS.FeatureName, + provider: context.tracingContext.provider, + network: context.tracingContext.isTestnet ? 'testnet' : 'mainnet', + }, + context: { + name: context.errorContext.controller, + data: { + method: context.errorContext.method, + params, + }, + }, + }, + ); + + traceData = { + success: false, + error: error instanceof Error ? error.message : 'Unknown error', + }; + throw error; + } finally { + this.#deps.tracer.endTrace({ + name: PerpsTraceNames.OrdersFetch, + id: traceId, + data: traceData, + }); + } + } + + /** + * Get funding rates + * Handles full orchestration: tracing, error logging, and provider delegation + * + * @param options - The configuration options. + * @param options.provider - The perps provider instance. + * @param options.params - The operation parameters. + * @param options.context - The service context for dependencies. + * @param options.forceRefresh - Bypass the request-coalesce cache end-to-end + * (user-initiated refresh). + * @returns The result of the operation. + */ + async getFunding(options: { + provider: PerpsProvider; + params?: GetFundingParams; + context: ServiceContext; + /** + * Bypass the request-coalesce cache. Use for user-initiated refresh. + */ + forceRefresh?: boolean; + }): Promise { + const { provider, params, context, forceRefresh } = options; + const traceId = uuidv4(); + let traceData: { success: boolean; error?: string } | undefined; + + try { + this.#deps.tracer.trace({ + name: PerpsTraceNames.FundingFetch, + id: traceId, + op: PerpsTraceOperations.Operation, + tags: { + provider: context.tracingContext.provider, + isTestnet: String(context.tracingContext.isTestnet), + }, + }); + + const isPaginated = + params?.limit !== undefined || + params?.offset !== undefined || + params?.startTime !== undefined || + params?.endTime !== undefined; + + if (isPaginated) { + const result = await provider.getFunding(params, { forceRefresh }); + traceData = { success: true }; + return result; + } + + // Non-paginated: resolve the caller's account so the cache key is + // account-scoped (see getOrderFills for rationale). Pin the resolved + // id onto the forwarded params so the provider cannot re-resolve to a + // different account between our resolve() and its fetch. + const resolvedAccountId = + params?.accountId ?? (await provider.getCurrentAccountId()); + const pinnedParams: GetFundingParams = { + ...params, + accountId: resolvedAccountId, + }; + const result = await coalescePerpsRestRequest( + [ + context.tracingContext.provider, + context.tracingContext.isTestnet ? 'testnet' : 'mainnet', + 'getFunding', + resolvedAccountId, + ].join('|'), + () => provider.getFunding(pinnedParams, { forceRefresh }), + { forceRefresh }, + ); + + traceData = { success: true }; + return result; + } catch (error) { + this.#deps.logger.error( + ensureError(error, 'MarketDataService.getFunding'), + { + tags: { + feature: PERPS_CONSTANTS.FeatureName, + provider: context.tracingContext.provider, + network: context.tracingContext.isTestnet ? 'testnet' : 'mainnet', + }, + context: { + name: context.errorContext.controller, + data: { + method: context.errorContext.method, + params, + }, + }, + }, + ); + + traceData = { + success: false, + error: error instanceof Error ? error.message : 'Unknown error', + }; + throw error; + } finally { + this.#deps.tracer.endTrace({ + name: PerpsTraceNames.FundingFetch, + id: traceId, + data: traceData, + }); + } + } + + /** + * Get account state + * Handles full orchestration: tracing, error logging, state management, and provider delegation + * + * @param options - The configuration options. + * @param options.provider - The perps provider instance. + * @param options.params - The operation parameters. + * @param options.context - The service context for dependencies. + * @returns The result of the operation. + */ + async getAccountState(options: { + provider: PerpsProvider; + params?: GetAccountStateParams; + context: ServiceContext; + }): Promise { + const { provider, params, context } = options; + const traceId = uuidv4(); + let traceData: { success: boolean; error?: string } | undefined; + + try { + this.#deps.tracer.trace({ + name: PerpsTraceNames.GetAccountState, + id: traceId, + op: PerpsTraceOperations.Operation, + tags: { + provider: context.tracingContext.provider, + isTestnet: String(context.tracingContext.isTestnet), + source: params?.source ?? 'unknown', + }, + }); + + const accountState = await provider.getAccountState(params); + + // Safety check for accountState + if (!accountState) { + const error = new Error( + 'Failed to get account state: received null/undefined response', + ); + + this.#deps.logger.error( + ensureError(error, 'MarketDataService.getAccountState'), + { + tags: { + feature: PERPS_CONSTANTS.FeatureName, + provider: context.tracingContext.provider, + network: context.tracingContext.isTestnet ? 'testnet' : 'mainnet', + }, + context: { + name: context.errorContext.controller, + data: { + method: context.errorContext.method, + operation: 'nullAccountStateCheck', + }, + }, + }, + ); + + throw error; + } + + // Update state on success (if stateManager is provided) + if (context.stateManager) { + context.stateManager.update((state) => { + state.accountState = accountState; + state.lastUpdateTimestamp = Date.now(); + state.lastError = null; + }); + } + + traceData = { success: true }; + return accountState; + } catch (error) { + const errorMessage = + error instanceof Error ? error.message : 'Account state fetch failed'; + + // Update error state (if stateManager is provided) + if (context.stateManager) { + context.stateManager.update((state) => { + state.lastError = errorMessage; + state.lastUpdateTimestamp = Date.now(); + }); + } + + traceData = { + success: false, + error: errorMessage, + }; + + throw error; + } finally { + this.#deps.tracer.endTrace({ + name: PerpsTraceNames.GetAccountState, + id: traceId, + data: traceData, + }); + } + } + + /** + * Get historical portfolio data + * Handles full orchestration: tracing, error logging, state management, and provider delegation + * + * @param options - The configuration options. + * @param options.provider - The perps provider instance. + * @param options.params - The operation parameters. + * @param options.context - The service context for dependencies. + * @returns The result of the operation. + */ + async getHistoricalPortfolio(options: { + provider: PerpsProvider; + params?: GetHistoricalPortfolioParams; + context: ServiceContext; + }): Promise { + const { provider, params, context } = options; + const traceId = uuidv4(); + let traceData: { success: boolean; error?: string } | undefined; + + try { + this.#deps.tracer.trace({ + name: PerpsTraceNames.GetHistoricalPortfolio, + id: traceId, + op: PerpsTraceOperations.Operation, + tags: { + provider: context.tracingContext.provider, + isTestnet: String(context.tracingContext.isTestnet), + }, + }); + + if (!provider.getHistoricalPortfolio) { + throw new Error('Historical portfolio not supported by provider'); + } + + const result = await provider.getHistoricalPortfolio(params); + + traceData = { success: true }; + return result; + } catch (error) { + const errorMessage = + error instanceof Error + ? error.message + : 'Failed to get historical portfolio'; + + this.#deps.logger.error( + ensureError(error, 'MarketDataService.getHistoricalPortfolio'), + { + tags: { + feature: PERPS_CONSTANTS.FeatureName, + provider: context.tracingContext.provider, + network: context.tracingContext.isTestnet ? 'testnet' : 'mainnet', + }, + context: { + name: context.errorContext.controller, + data: { + method: context.errorContext.method, + params, + }, + }, + }, + ); + + // Update error state (if stateManager is provided) + if (context.stateManager) { + context.stateManager.update((state) => { + state.lastError = errorMessage; + state.lastUpdateTimestamp = Date.now(); + }); + } + + traceData = { + success: false, + error: errorMessage, + }; + + throw error; + } finally { + this.#deps.tracer.endTrace({ + name: PerpsTraceNames.GetHistoricalPortfolio, + id: traceId, + data: traceData, + }); + } + } + + /** + * Get available markets + * Handles full orchestration: tracing, error logging, state management, and provider delegation. + * When `useTerminalApi` is true, attempts the Terminal API first; on failure or empty + * response, falls back silently to the HyperLiquid provider path. + * + * @param options - The configuration options. + * @param options.provider - The perps provider instance. + * @param options.params - The operation parameters. + * @param options.context - The service context for dependencies. + * @param options.isMarketAllowed - Optional filter callback applied to + * Terminal API results so that allowlist/blocklist rules from the provider + * layer are enforced even when the provider is bypassed. Skipped when + * `params.skipFilters` is true. + * @returns The result of the operation. + */ + async getMarkets(options: { + provider: PerpsProvider; + params?: GetMarketsParams; + context: ServiceContext; + isMarketAllowed?: (symbol: string) => boolean; + }): Promise { + const { provider, params, context, isMarketAllowed } = options; + const useTerminalApi = params?.useTerminalApi; + const traceId = uuidv4(); + let traceData: { success: boolean; error?: string } | undefined; + + try { + this.#deps.tracer.trace({ + name: PerpsTraceNames.GetMarkets, + id: traceId, + op: PerpsTraceOperations.Operation, + tags: { + provider: context.tracingContext.provider, + isTestnet: String(context.tracingContext.isTestnet), + ...(params?.symbols && { + symbolCount: String(params.symbols.length), + }), + ...(params?.dex !== undefined && { dex: params.dex }), + ...(useTerminalApi !== undefined && { + useTerminalApi: String(useTerminalApi), + }), + }, + }); + + // Terminal API path: attempt first when flag is enabled + if (useTerminalApi && this.#deps.terminalMarketService) { + try { + const { markets: terminalMarkets } = + await this.#deps.terminalMarketService.fetchMarkets(); + if (terminalMarkets.length > 0) { + let filtered = terminalMarkets; + + // Apply allowlist/blocklist filtering (same as provider path) + if (!params?.skipFilters && isMarketAllowed) { + filtered = filtered.filter((market) => + isMarketAllowed(market.name), + ); + } + + // Filter by specific DEX when requested + if (params?.dex !== undefined) { + const dexPrefix = params.dex ? `${params.dex}:` : ''; + filtered = filtered.filter((market) => + dexPrefix + ? market.name.startsWith(dexPrefix) + : !market.name.includes(':'), + ); + } + + // Filter by symbols when requested + if (params?.symbols?.length) { + filtered = filtered.filter((market) => + (params.symbols as string[]).some( + (sym) => market.name.toLowerCase() === sym.toLowerCase(), + ), + ); + } + + // Fall back to provider when a constrained query (symbols or dex) + // yields no matches — Terminal partial coverage should not hide + // valid provider-backed markets. + const isConstrainedQuery = + (params?.symbols?.length ?? 0) > 0 || params?.dex !== undefined; + if (filtered.length === 0 && isConstrainedQuery) { + // Let execution continue to the provider path below. + } else { + if (context.stateManager) { + context.stateManager.update((state) => { + state.lastError = null; + state.lastUpdateTimestamp = Date.now(); + }); + } + traceData = { success: true }; + return filtered; + } + } + } catch (terminalError) { + this.#deps.terminalMarketService.logError( + terminalError, + 'getMarkets', + ); + } + } + + const markets = await provider.getMarkets(params); + + if (context.stateManager) { + context.stateManager.update((state) => { + state.lastError = null; + state.lastUpdateTimestamp = Date.now(); + }); + } + + traceData = { success: true }; + return markets; + } catch (error) { + const errorMessage = + error instanceof Error + ? error.message + : PERPS_ERROR_CODES.MARKETS_FAILED; + + this.#deps.logger.error( + ensureError(error, 'MarketDataService.getMarkets'), + { + tags: { + feature: PERPS_CONSTANTS.FeatureName, + provider: context.tracingContext.provider, + network: context.tracingContext.isTestnet ? 'testnet' : 'mainnet', + }, + context: { + name: context.errorContext.controller, + data: { + method: context.errorContext.method, + params, + }, + }, + }, + ); + + if (context.stateManager) { + context.stateManager.update((state) => { + state.lastError = errorMessage; + state.lastUpdateTimestamp = Date.now(); + }); + } + + traceData = { + success: false, + error: errorMessage, + }; + + throw error; + } finally { + this.#deps.tracer.endTrace({ + name: PerpsTraceNames.GetMarkets, + id: traceId, + data: traceData, + }); + } + } + + /** + * Get market data with prices (includes price, volume, 24h change). + * Applies optional category filtering, sorting, and limit after fetching. + * An explicitly configured global snapshot is the preferred complete source. + * `useTerminalApi` controls only legacy metadata enrichment of provider data. + * + * @param options - The configuration options. + * @param options.provider - The perps provider instance. + * @param options.params - Optional filter/sort/limit params. + * @param options.context - The service context for dependencies. + * @returns The result of the operation. + */ + async getMarketDataWithPrices(options: { + provider: PerpsProvider; + params?: GetMarketDataWithPricesParams; + context: ServiceContext; + }): Promise { + const { provider, params, context } = options; + const { globalSnapshot } = context; + const useTerminalApi = params?.useTerminalApi; + const traceId = uuidv4(); + let traceData: { success: boolean; error?: string } | undefined; + + try { + this.#deps.tracer.trace({ + name: PerpsTraceNames.GetMarketDataWithPrices, + id: traceId, + op: PerpsTraceOperations.Operation, + tags: { + provider: context.tracingContext.provider, + isTestnet: String(context.tracingContext.isTestnet), + ...(params?.categories && { + categoryCount: String(params.categories.length), + }), + ...(useTerminalApi !== undefined && { + useTerminalApi: String(useTerminalApi), + }), + }, + }); + + // Prefer a separately configured atomic snapshot only for an exact, + // still-current provider/network/DEX identity. A rejected snapshot has + // one lexical fallback to the provider below and is not followed by a + // second legacy Terminal request. + let snapshotAttempted = false; + if ( + globalSnapshot && + this.#deps.terminalMarketService?.fetchGlobalSnapshot + ) { + snapshotAttempted = true; + if (!globalSnapshot.isCurrent()) { + throw new Error('Terminal global snapshot context changed'); + } + try { + const snapshot = + await this.#deps.terminalMarketService.fetchGlobalSnapshot( + globalSnapshot.request, + ); + if (!globalSnapshot.isCurrent()) { + throw new Error('Terminal global snapshot context changed'); + } + if (Date.now() >= snapshot.expiresAt) { + throw new Error('Terminal global snapshot expired'); + } + if (snapshot.markets.length > 0) { + traceData = { success: true }; + const allowedMarkets = snapshot.markets.filter((market) => + globalSnapshot.isMarketAllowed(market.symbol), + ); + return applyMarketFilters(allowedMarkets, params); + } + } catch (snapshotError) { + if (!globalSnapshot.isCurrent()) { + throw new Error('Terminal global snapshot context changed'); + } + this.#deps.terminalMarketService.logError( + snapshotError, + 'getMarketDataWithPrices.globalSnapshot', + ); + } + } + + // Fetch Terminal API metadata before provider data when enabled. + // Terminal metadata enriches the provider result (name, keywords, tags, + // categories) but never replaces live pricing / funding data. + let terminalMetadata: Map | undefined; + if ( + !snapshotAttempted && + useTerminalApi && + this.#deps.terminalMarketService + ) { + try { + const result = await this.#deps.terminalMarketService.fetchMarkets(); + if (result.metadata.size > 0) { + terminalMetadata = result.metadata; + } + } catch (terminalError) { + this.#deps.terminalMarketService.logError( + terminalError, + 'getMarketDataWithPrices', + ); + } + } + + const markets = await provider.getMarketDataWithPrices(); + if (snapshotAttempted && globalSnapshot && !globalSnapshot.isCurrent()) { + throw new Error('Terminal global snapshot context changed'); + } + + // Enrich with terminal metadata when available + const enriched = terminalMetadata + ? this.#enrichWithTerminalMetadata(markets, terminalMetadata) + : markets; + + const filtered = applyMarketFilters(enriched, params); + + traceData = { success: true }; + return filtered; + } catch (error) { + const errorMessage = + error instanceof Error + ? error.message + : PERPS_ERROR_CODES.MARKETS_FAILED; + + this.#deps.logger.error( + ensureError(error, 'MarketDataService.getMarketDataWithPrices'), + { + tags: { + feature: PERPS_CONSTANTS.FeatureName, + provider: context.tracingContext.provider, + network: context.tracingContext.isTestnet ? 'testnet' : 'mainnet', + }, + context: { + name: context.errorContext.controller, + data: { + method: context.errorContext.method, + params, + }, + }, + }, + ); + + traceData = { + success: false, + error: errorMessage, + }; + + throw error; + } finally { + this.#deps.tracer.endTrace({ + name: PerpsTraceNames.GetMarketDataWithPrices, + id: traceId, + data: traceData, + }); + } + } + + /** + * Get available DEXs (HIP-3 support required) + * + * @param options - The configuration options. + * @param options.provider - The perps provider instance. + * @param options.params - The operation parameters. + * @param options.context - The service context for dependencies. + * @returns The result of the operation. + */ + async getAvailableDexs(options: { + provider: PerpsProvider; + params?: GetAvailableDexsParams; + context: ServiceContext; + }): Promise { + const { provider, params } = options; + + try { + if (!provider.getAvailableDexs) { + throw new Error('Provider does not support HIP-3 DEXs'); + } + + return await provider.getAvailableDexs(params); + } catch (error) { + this.#deps.logger.error( + ensureError(error, 'MarketDataService.getAvailableDexs'), + { + context: { + name: 'MarketDataService.getAvailableDexs', + data: { params }, + }, + }, + ); + throw error; + } + } + + /** + * Fetch historical candle data for charting + * Handles full orchestration: tracing, error logging, state management, and provider delegation + * + * @param options - The configuration options. + * @param options.provider - The perps provider instance. + * @param options.symbol - The trading pair symbol. + * @param options.interval - The candle interval period. + * @param options.limit - Maximum number of items to fetch. + * @param options.endTime - End timestamp in milliseconds. + * @param options.context - The service context for dependencies. + * @returns The result of the operation. + */ + async fetchHistoricalCandles(options: { + provider: PerpsProvider; + symbol: string; + interval: CandlePeriod; + limit?: number; + endTime?: number; + context: ServiceContext; + }): Promise { + const { + provider, + symbol, + interval, + limit = 100, + endTime, + context, + } = options; + const traceId = uuidv4(); + let traceData: { success: boolean; error?: string } | undefined; + + try { + this.#deps.tracer.trace({ + name: PerpsTraceNames.FetchHistoricalCandles, + id: traceId, + op: PerpsTraceOperations.Operation, + tags: { + provider: context.tracingContext.provider, + isTestnet: String(context.tracingContext.isTestnet), + symbol, + interval, + }, + }); + + if (!provider.fetchHistoricalCandles) { + throw new Error('Historical candles not supported by provider'); + } + + const result = await provider.fetchHistoricalCandles({ + symbol, + interval, + limit, + endTime, + }); + + traceData = { success: true }; + return result; + } catch (error) { + const errorMessage = + error instanceof Error + ? error.message + : 'Failed to fetch historical candles'; + + // Expected cancellation — skip Sentry and state updates + if (!isAbortError(error)) { + this.#deps.logger.error( + ensureError(error, 'MarketDataService.fetchHistoricalCandles'), + { + tags: { + feature: PERPS_CONSTANTS.FeatureName, + provider: context.tracingContext.provider, + network: context.tracingContext.isTestnet ? 'testnet' : 'mainnet', + }, + context: { + name: context.errorContext.controller, + data: { + method: context.errorContext.method, + symbol, + interval, + limit, + endTime, + }, + }, + }, + ); + + // Update error state (if stateManager is provided) + if (context.stateManager) { + context.stateManager.update((state) => { + state.lastError = errorMessage; + state.lastUpdateTimestamp = Date.now(); + }); + } + } + + traceData = { + success: false, + error: errorMessage, + }; + + throw error; + } finally { + this.#deps.tracer.endTrace({ + name: PerpsTraceNames.FetchHistoricalCandles, + id: traceId, + data: traceData, + }); + } + } + + /** + * Calculate liquidation price for a position + * + * @param options - The configuration options. + * @param options.provider - The perps provider instance. + * @param options.params - The operation parameters. + * @param options.context - The service context for dependencies. + * @returns The result of the operation. + */ + async calculateLiquidationPrice(options: { + provider: PerpsProvider; + params: LiquidationPriceParams; + context: ServiceContext; + }): Promise { + const { provider, params } = options; + + try { + return await provider.calculateLiquidationPrice(params); + } catch (error) { + this.#deps.logger.error( + ensureError(error, 'MarketDataService.calculateLiquidationPrice'), + { + context: { + name: 'MarketDataService.calculateLiquidationPrice', + data: { params }, + }, + }, + ); + throw error; + } + } + + /** + * Project the position that would remain after a proposed order. + * + * @param options - The configuration options. + * @param options.provider - The perps provider instance. + * @param options.params - Live position plus the proposed order. + * @param options.context - The service context for dependencies. + * @returns Discriminated preview of the resulting position. + */ + async previewPositionModify(options: { + provider: PerpsProvider; + params: PositionModifyPreviewParams; + context: ServiceContext; + }): Promise { + const { provider, params } = options; + + try { + return await provider.previewPositionModify(params); + } catch (error) { + this.#deps.logger.error( + ensureError(error, 'MarketDataService.previewPositionModify'), + { + context: { + name: 'MarketDataService.previewPositionModify', + data: { params }, + }, + }, + ); + throw error; + } + } + + /** + * Calculate maintenance margin for a position + * + * @param options - The configuration options. + * @param options.provider - The perps provider instance. + * @param options.params - The operation parameters. + * @param options.context - The service context for dependencies. + * @returns The result of the operation. + */ + async calculateMaintenanceMargin(options: { + provider: PerpsProvider; + params: MaintenanceMarginParams; + context: ServiceContext; + }): Promise { + const { provider, params } = options; + + try { + return await provider.calculateMaintenanceMargin(params); + } catch (error) { + this.#deps.logger.error( + ensureError(error, 'MarketDataService.calculateMaintenanceMargin'), + { + context: { + name: 'MarketDataService.calculateMaintenanceMargin', + data: { params }, + }, + }, + ); + throw error; + } + } + + /** + * Get maximum leverage for an asset + * + * @param options - The configuration options. + * @param options.provider - The perps provider instance. + * @param options.asset - The asset identifier. + * @param options.context - The service context for dependencies. + * @returns The result of the operation. + */ + async getMaxLeverage(options: { + provider: PerpsProvider; + asset: string; + context: ServiceContext; + }): Promise { + const { provider, asset } = options; + + try { + return await provider.getMaxLeverage(asset); + } catch (error) { + this.#deps.logger.error( + ensureError(error, 'MarketDataService.getMaxLeverage'), + { + context: { + name: 'MarketDataService.getMaxLeverage', + data: { asset }, + }, + }, + ); + throw error; + } + } + + /** + * Calculate fees for an order + * + * @param options - The configuration options. + * @param options.provider - The perps provider instance. + * @param options.params - The operation parameters. + * @param options.context - The service context for dependencies. + * @returns The result of the operation. + */ + async calculateFees(options: { + provider: PerpsProvider; + params: FeeCalculationParams; + context: ServiceContext; + }): Promise { + const { provider, params, context } = options; + + try { + const fees = await provider.calculateFees(params); + + // Read-only preview of the same cached benefits snapshot the fee resolver + // reads. The quoted rates are left untouched: surfacing eligibility and + // the remaining notional must not mutate the cap or the cache. + return context.subscriptionFeeWaiver + ? { ...fees, subscription: context.subscriptionFeeWaiver } + : fees; + } catch (error) { + this.#deps.logger.error( + ensureError(error, 'MarketDataService.calculateFees'), + { + context: { + name: 'MarketDataService.calculateFees', + data: { params }, + }, + }, + ); + throw error; + } + } + + /** + * Validate an order before placement + * + * @param options - The configuration options. + * @param options.provider - The perps provider instance. + * @param options.params - The operation parameters. + * @param options.context - The service context for dependencies. + * @returns The result of the operation. + */ + async validateOrder(options: { + provider: PerpsProvider; + params: OrderParams; + context: ServiceContext; + }): Promise<{ isValid: boolean; error?: string }> { + const { provider, params } = options; + + try { + return await provider.validateOrder(params); + } catch (error) { + this.#deps.logger.error( + ensureError(error, 'MarketDataService.validateOrder'), + { + context: { + name: 'MarketDataService.validateOrder', + data: { params }, + }, + }, + ); + throw error; + } + } + + /** + * Validate a position close request + * + * @param options - The configuration options. + * @param options.provider - The perps provider instance. + * @param options.params - The operation parameters. + * @param options.context - The service context for dependencies. + * @returns The result of the operation. + */ + async validateClosePosition(options: { + provider: PerpsProvider; + params: ClosePositionParams; + context: ServiceContext; + }): Promise<{ isValid: boolean; error?: string }> { + const { provider, params } = options; + + try { + return await provider.validateClosePosition(params); + } catch (error) { + this.#deps.logger.error( + ensureError(error, 'MarketDataService.validateClosePosition'), + { + context: { + name: 'MarketDataService.validateClosePosition', + data: { params }, + }, + }, + ); + throw error; + } + } + + /** + * Get supported withdrawal routes (synchronous) + * Note: This method doesn't log errors to avoid needing context for a synchronous getter + * + * @param options - The configuration options. + * @param options.provider - The perps provider instance. + * @returns The result of the operation. + */ + getWithdrawalRoutes(options: { provider: PerpsProvider }): AssetRoute[] { + const { provider } = options; + + try { + return provider.getWithdrawalRoutes(); + } catch { + // Silent fail - withdrawal routes are not critical + return []; + } + } + + /** + * Get block explorer URL (synchronous) + * + * @param options - The configuration options. + * @param options.provider - The perps provider instance. + * @param options.address - The wallet address. + * @returns The result of the operation. + */ + getBlockExplorerUrl(options: { + provider: PerpsProvider; + address?: string; + }): string { + const { provider, address } = options; + return provider.getBlockExplorerUrl(address); + } + + /** + * Merge Terminal API metadata into provider-sourced PerpsMarketData. + * For each market, if the terminal metadata map contains an entry for its + * symbol, override name/description/marketType and attach + * keywords/tags/categories. Unmatched markets keep their provider-sourced + * values. + * + * @param markets - Markets from the provider. + * @param metadata - Per-symbol metadata from the Terminal API. + * @returns Enriched market data array. + */ + #enrichWithTerminalMetadata( + markets: PerpsMarketData[], + metadata: Map, + ): PerpsMarketData[] { + return markets.map((market) => { + const meta = metadata.get(market.symbol); + if (!meta) { + return market; + } + + return { + ...market, + ...(meta.name !== undefined && { name: meta.name }), + ...(meta.description !== undefined && { + description: meta.description, + }), + ...(meta.marketType !== undefined && { marketType: meta.marketType }), + ...(meta.keywords !== undefined && { keywords: meta.keywords }), + ...(meta.tags !== undefined && { tags: meta.tags }), + ...(meta.categories !== undefined && { categories: meta.categories }), + ...(meta.listedAt !== undefined && { listedAt: meta.listedAt }), + }; + }); + } +} diff --git a/packages/perps-controller/src/services/RewardsIntegrationService.ts b/packages/perps-controller/src/services/RewardsIntegrationService.ts new file mode 100644 index 00000000000..228e70bbc8c --- /dev/null +++ b/packages/perps-controller/src/services/RewardsIntegrationService.ts @@ -0,0 +1,503 @@ +import { + BASIS_POINTS_DIVISOR, + BUILDER_FEE_CONFIG, +} from '../constants/hyperLiquidConfig.js'; +import { + PERPS_CONSTANTS, + SUBSCRIPTION_BENEFITS_CACHE, +} from '../constants/perpsConfig.js'; +import type { + PerpsFeeResolution, + PerpsFeeSource, + PerpsPlatformDependencies, + PerpsSubscriptionBenefits, + PerpsSubscriptionFeeWaiverStatus, +} from '../types/index.js'; +import type { PerpsControllerMessengerBase } from '../types/messenger.js'; +import { getSelectedEvmAccountFromMessenger } from '../utils/accountUtils.js'; +import { ensureError } from '../utils/errorUtils.js'; +import { formatAccountToCaipAccountId } from '../utils/rewardsUtils.js'; + +/** + * Default MetaMask builder fee, in basis points. + * This is the fee every user pays when no cheaper source applies. + */ +const DEFAULT_FEE_BIPS = + BUILDER_FEE_CONFIG.MaxFeeDecimal * BASIS_POINTS_DIVISOR; + +/** + * Cached subscription benefits plus the time they were read. + */ +type BenefitsSnapshot = { + benefits: PerpsSubscriptionBenefits | null; + fetchedAt: number; +}; + +/** + * RewardsIntegrationService + * + * Owns the unified perps fee resolver: it considers every fee source and + * returns the lowest fee, expressed as the discount bips providers consume. + * + * Sources, all in fee basis points (lowest wins): + * - `default` — {@link BUILDER_FEE_CONFIG}, the fee with no reductions. + * - `rewards` — VIP and season, collapsed into one discount by + * `RewardsController` (`rewards.getPerpsDiscountForAccount`), so this service + * does not re-derive the VIP/season split. + * - `subscription` — `0` bips, but only when the eligibility gate passes on a + * cached read of the profile's benefits. + * + * On a tie the cheaper-to-explain source wins, in the order + * `subscription` > `rewards` > `default`. + * + * The benefits cache is stale-while-revalidate: fee resolution is a pure read + * of the cached snapshot, while preview and lifecycle callers refresh it + * explicitly. Nothing is reserved or committed client-side, so backend + * exhaustion needs no release logic — the next refresh simply stops passing + * the gate. + * + * Instance-based service with constructor injection of platform dependencies. + */ +export class RewardsIntegrationService { + readonly #deps: PerpsPlatformDependencies; + + readonly #messenger: PerpsControllerMessengerBase; + + /** Last successful benefits read, or undefined before the first one. */ + #benefitsSnapshot: BenefitsSnapshot | undefined; + + /** + * When the last benefits read finished, successful or not. + * + * Separate from `#benefitsSnapshot.fetchedAt`, which only advances on + * success: a failing read must still throttle the next preview refresh, + * otherwise an outage turns every fee preview into a new request. + */ + #lastAttemptAt: number | undefined; + + /** In-flight refresh, deduped so only one runs at a time. */ + #benefitsRefresh: Promise | undefined; + + /** + * Identity generation for the cached benefits. + * + * Bumped by {@link invalidateSubscriptionBenefits}; a read that resolves + * against a superseded epoch is discarded rather than written back, so a + * refresh issued for the previous profile cannot repopulate the cache after + * a sign-out or profile switch. + */ + #benefitsEpoch = 0; + + /** + * Create a new RewardsIntegrationService instance + * + * @param deps - Platform dependencies for logging, metrics, etc. + * @param messenger - Controller messenger for cross-controller communication. + */ + constructor( + deps: PerpsPlatformDependencies, + messenger: PerpsControllerMessengerBase, + ) { + this.#deps = deps; + this.#messenger = messenger; + } + + /** + * Get chain ID for a network client via DI network controller + * + * @param networkClientId - The network client identifier to look up. + * @returns The chain ID string, or undefined if the network client is not found. + */ + #getChainIdForNetwork(networkClientId: string): string | undefined { + try { + const networkClient = this.#messenger.call( + 'NetworkController:getNetworkClientById', + networkClientId, + ); + return networkClient.configuration.chainId; + } catch { + // Network client may not exist + return undefined; + } + } + + /** + * Calculate user fee discount from the unified fee resolver. + * Returns discount in basis points (e.g., 6500 = 65% discount) + * + * @returns The fee discount in basis points, or undefined if no source resolved. + */ + async calculateUserFeeDiscount(): Promise { + const resolution = await this.resolveFee(); + return resolution.discountBips; + } + + /** + * Resolve the MetaMask builder fee across every source and return the lowest. + * + * Never throws and never starts a subscription benefits read: a failing or + * unresolved cached source simply drops out of the comparison, so the worst + * case is the default fee rather than an error or an over-granted waiver. + * + * @returns The winning fee, its source, and the subscription gate outcome. + */ + async resolveFee(): Promise { + const rewardsDiscountBips = await this.#calculateRewardsDiscount(); + // Pure cache read: subscription benefits must never start a network request + // while an order is being prepared for signing. + const subscription = this.getSubscriptionFeeWaiverStatus(); + + let feeBips = DEFAULT_FEE_BIPS; + let source: PerpsFeeSource = 'default'; + + if (rewardsDiscountBips !== undefined) { + const rewardsFeeBips = + DEFAULT_FEE_BIPS * (1 - rewardsDiscountBips / BASIS_POINTS_DIVISOR); + // `<=` so an equal rewards fee still reports the rewards source, keeping + // a resolved 0% discount distinguishable from an unresolved one. + if (rewardsFeeBips <= feeBips) { + feeBips = rewardsFeeBips; + source = 'rewards'; + } + } + + // Nothing can undercut a waived fee, so the gate passing always wins. + if (subscription.eligible) { + feeBips = 0; + source = 'subscription'; + } + + const discountBips = + source === 'default' + ? undefined + : Math.round((1 - feeBips / DEFAULT_FEE_BIPS) * BASIS_POINTS_DIVISOR); + + this.#deps.debugLogger.log('RewardsIntegrationService: Fee resolved', { + source, + feeBips, + discountBips, + defaultFeeBips: DEFAULT_FEE_BIPS, + rewardsDiscountBips, + subscriptionEligible: subscription.eligible, + subscriptionReason: subscription.reason, + }); + + return { feeBips, discountBips, source, subscription }; + } + + /** + * Read the subscription fee-waiver gate from the cached benefits snapshot. + * + * Synchronous and side-effect free. The returned value always comes from + * what is already cached; preview and lifecycle callers own hydration. + * + * @returns Whether the waiver applies, why, and the remaining notional. + */ + getSubscriptionFeeWaiverStatus(): PerpsSubscriptionFeeWaiverStatus { + if (!this.#deps.subscription) { + return { eligible: false, reason: 'no-source' }; + } + + const now = Date.now(); + const snapshot = this.#benefitsSnapshot; + const age = snapshot ? now - snapshot.fetchedAt : Infinity; + if (!snapshot) { + return { eligible: false, reason: 'not-hydrated' }; + } + + if (age > SUBSCRIPTION_BENEFITS_CACHE.MaxStaleMs) { + // Past the ceiling we cannot tell whether the cap is still available, so + // fall back to the next-lowest source rather than over-granting. + return { eligible: false, reason: 'stale' }; + } + + return evaluateFeeWaiverGate(snapshot.benefits); + } + + /** + * Refresh the cached subscription benefits snapshot. + * + * Deduped: concurrent callers share the in-flight request. Rejections are + * logged and swallowed, leaving the previous snapshot in place. Preview and + * lifecycle callers invoke this outside order submission. + * + * @returns A promise that settles when the refresh completes. + */ + async refreshSubscriptionBenefits(): Promise { + const source = this.#deps.subscription; + if (!source) { + return; + } + + if (this.#benefitsRefresh) { + await this.#benefitsRefresh; + return; + } + + const now = Date.now(); + const snapshotAge = this.#benefitsSnapshot + ? now - this.#benefitsSnapshot.fetchedAt + : Infinity; + const sinceAttempt = + this.#lastAttemptAt === undefined ? Infinity : now - this.#lastAttemptAt; + if ( + snapshotAge < SUBSCRIPTION_BENEFITS_CACHE.FreshMs || + sinceAttempt < SUBSCRIPTION_BENEFITS_CACHE.FreshMs + ) { + return; + } + + const refresh = this.#readSubscriptionBenefits(source); + this.#benefitsRefresh = refresh; + // `finally` always defers, so this never clears the handle we just set. + refresh + .finally(() => { + if (this.#benefitsRefresh === refresh) { + this.#benefitsRefresh = undefined; + } + }) + .catch(() => undefined); + + await refresh; + } + + /** + * Drop the cached benefits snapshot. + * + * Call this when the identity behind the benefits changes — sign-out, or a + * profile switch — since the snapshot carries no profile identity of its own + * and would otherwise keep answering for the previous profile until the next + * successful refresh. The next status read reports `not-hydrated`, so the + * waiver is withheld until a preview or lifecycle caller hydrates it. + */ + invalidateSubscriptionBenefits(): void { + this.#benefitsSnapshot = undefined; + this.#lastAttemptAt = undefined; + // Fence any in-flight read: it was issued for the previous identity, so its + // result must not repopulate the cache after this point. + this.#benefitsEpoch += 1; + // Drop the dedupe handle too. The fenced read can only be discarded, so + // leaving it in place would make the next refresh await it instead of + // fetching for the new identity. Its `finally` guard compares against the + // current handle, so it will not clear whatever replaces it here. + this.#benefitsRefresh = undefined; + + this.#deps.debugLogger.log( + 'RewardsIntegrationService: Subscription benefits cache invalidated', + ); + } + + /** + * Perform one benefits read and store it, keeping the previous snapshot on + * error. Never rejects, so callers cannot produce an unhandled rejection. + * + * @param source - The injected subscription benefits source. + */ + async #readSubscriptionBenefits( + source: NonNullable, + ): Promise { + const epoch = this.#benefitsEpoch; + + try { + const benefits = await source.getPerpsBenefits(); + + if (epoch !== this.#benefitsEpoch) { + // Invalidated while this read was in flight: it belongs to a previous + // identity, so discarding it is the only safe outcome. + this.#deps.debugLogger.log( + 'RewardsIntegrationService: Discarding benefits read from a previous identity', + ); + return; + } + + this.#benefitsSnapshot = { benefits, fetchedAt: Date.now() }; + + this.#deps.debugLogger.log( + 'RewardsIntegrationService: Subscription benefits refreshed', + { + status: benefits?.status, + entitled: benefits?.perpsFeeWaiver?.entitled, + usage: benefits?.perpsFeeWaiver?.usage, + exhausted: benefits?.perpsFeeWaiver?.exhausted, + }, + ); + } catch (error) { + // Keep the previous snapshot: an unreachable benefits endpoint must not + // erase a valid cache, and it must never grant the waiver either. + this.#deps.logger.error( + ensureError( + error, + 'RewardsIntegrationService.refreshSubscriptionBenefits', + ), + { + tags: { feature: PERPS_CONSTANTS.FeatureName }, + context: { + name: 'RewardsIntegrationService.refreshSubscriptionBenefits', + data: {}, + }, + }, + ); + } finally { + // Recorded on failure too — this is what throttles the retry loop. Not + // recorded for a fenced read: that attempt belongs to a previous + // identity, and letting it throttle would delay the new identity's first + // fetch by a whole freshness window. + if (epoch === this.#benefitsEpoch) { + this.#lastAttemptAt = Date.now(); + } + } + } + + /** + * Resolve the rewards (VIP + season) discount for the selected account. + * + * @returns The discount in basis points, or undefined when unavailable. + */ + async #calculateRewardsDiscount(): Promise { + try { + const evmAccount = getSelectedEvmAccountFromMessenger(this.#messenger); + + if (!evmAccount) { + this.#deps.debugLogger.log( + 'RewardsIntegrationService: No EVM account found for fee discount', + ); + return undefined; + } + + // Get the chain ID via DI network controller + const networkState = this.#messenger.call('NetworkController:getState'); + const { selectedNetworkClientId } = networkState; + const chainId = this.#getChainIdForNetwork(selectedNetworkClientId); + + if (!chainId) { + this.#deps.logger.error( + new Error('Chain ID not found for fee discount calculation'), + { + tags: { feature: PERPS_CONSTANTS.FeatureName }, + context: { + name: 'RewardsIntegrationService.calculateUserFeeDiscount', + data: { + selectedNetworkClientId, + }, + }, + }, + ); + return undefined; + } + + // Use pure utility function for CAIP formatting (pass logger for error reporting) + const caipAccountId = formatAccountToCaipAccountId( + evmAccount.address, + chainId, + this.#deps.logger, + ); + + if (!caipAccountId) { + this.#deps.logger.error( + new Error('Failed to format CAIP account ID for fee discount'), + { + tags: { feature: PERPS_CONSTANTS.FeatureName }, + context: { + name: 'RewardsIntegrationService.calculateUserFeeDiscount', + data: { + address: evmAccount.address, + chainId, + selectedNetworkClientId, + }, + }, + }, + ); + return undefined; + } + + // Use rewards via DI (no RewardsController in Core yet). + // The rewards controller needs the perps MetaMask builder base fee in + // bips to convert an absolute VIP fee into a discount fraction. + const discountBips = await this.#deps.rewards.getPerpsDiscountForAccount( + caipAccountId, + DEFAULT_FEE_BIPS, + ); + + // null = subscription state not hydrated yet; surface as undefined so + // callers don't treat it as a definitive "no discount" answer. + if (discountBips === null) { + this.#deps.debugLogger.log( + 'RewardsIntegrationService: Fee discount unavailable (subscription state not hydrated)', + { address: evmAccount.address, caipAccountId }, + ); + return undefined; + } + + this.#deps.debugLogger.log( + 'RewardsIntegrationService: Fee discount calculated', + { + address: evmAccount.address, + caipAccountId, + discountBips, + discountPercentage: discountBips / 100, + }, + ); + + return discountBips; + } catch (error) { + this.#deps.logger.error( + ensureError( + error, + 'RewardsIntegrationService.calculateUserFeeDiscount', + ), + { + tags: { feature: PERPS_CONSTANTS.FeatureName }, + context: { + name: 'RewardsIntegrationService.calculateUserFeeDiscount', + data: {}, + }, + }, + ); + return undefined; + } + } +} + +/** + * Evaluate the perps fee-waiver eligibility gate against a benefits snapshot. + * + * The gate is `status=active` AND `perpsFeeWaiver` entitled AND + * `usage=available`. A backend `exhausted` flag (or an `exhausted` usage) fails + * the gate on its own; anything short of an affirmative `available` is treated + * as not entitled, because the waiver is only granted on positive evidence. + * A `null` payload means there is no subscription at all, which is reported + * separately from a subscription that exists but is not active. + * + * @param benefits - The cached benefits payload, or null when there is none. + * @returns The gate outcome plus the remaining notional when reported. + */ +function evaluateFeeWaiverGate( + benefits: PerpsSubscriptionBenefits | null, +): PerpsSubscriptionFeeWaiverStatus { + const waiver = benefits?.perpsFeeWaiver; + const { remainingNotionalUsd } = waiver ?? {}; + + // `null` is the DI contract's "nothing to report" (signed out, no profile), + // which is distinct from a subscription that exists but is not active. + if (benefits === null) { + return { eligible: false, reason: 'no-subscription' }; + } + + if (benefits.status !== 'active') { + return { eligible: false, reason: 'inactive', remainingNotionalUsd }; + } + + if (waiver?.entitled !== true) { + return { eligible: false, reason: 'not-entitled', remainingNotionalUsd }; + } + + if (waiver.exhausted === true || waiver.usage === 'exhausted') { + return { eligible: false, reason: 'exhausted', remainingNotionalUsd }; + } + + if (waiver.usage !== 'available') { + return { eligible: false, reason: 'not-entitled', remainingNotionalUsd }; + } + + return { eligible: true, reason: 'eligible', remainingNotionalUsd }; +} diff --git a/packages/perps-controller/src/services/ServiceContext.ts b/packages/perps-controller/src/services/ServiceContext.ts new file mode 100644 index 00000000000..8a19411deaf --- /dev/null +++ b/packages/perps-controller/src/services/ServiceContext.ts @@ -0,0 +1,117 @@ +import type { PerpsControllerState } from '../PerpsController.js'; +import type { + Order, + PerpsGlobalSnapshotRequest, + PerpsSubscriptionFeeWaiverStatus, + Position, +} from '../types/index.js'; + +/** + * ServiceContext + * + * Lightweight per-call context for Perps services. + * Contains ONLY data that varies per operation: + * - Tracing context (provider, network) + * - Error context (controller, method) + * - State management callbacks + * - Query/action callbacks specific to the operation + * + * Platform dependencies (logging, metrics, tracing) are injected into service + * instances via constructor, not passed per-call. + * + * Controller-level singletons (RewardsController, NetworkController, messenger) + * are also injected into services that need them, not passed per-call. + * + * This enables: + * - Clean method signatures (no verbose dependency passing) + * - Fat services with complete orchestration + * - Thin controller with pure delegation + * - Easy testing through mock contexts and constructor injection + */ +export type ServiceContext = { + /** + * Tracing context for performance monitoring + * Used in trace() calls to tag operations + */ + tracingContext: { + provider: string; + isTestnet: boolean; + }; + + /** + * Error logging context + * Provides consistent error logging across services + */ + errorContext: { + controller: string; + method: string; + extra?: Record; + }; + + /** + * State management functions (optional) + * Only provided for operations that need to mutate controller state + * Example: Trading operations that update lastTransaction + */ + stateManager?: { + update: (updater: (state: PerpsControllerState) => void) => void; + getState: () => PerpsControllerState; + }; + + /** + * Query functions for dependent data + * Required by: Operations that need to fetch related data + */ + getOpenOrders?: () => Promise; + getPositions?: () => Promise; + + /** + * Exact per-call identity and guards for adopting a global market snapshot. + * Omitted when the active provider or DEX configuration is not static. + */ + globalSnapshot?: { + request: PerpsGlobalSnapshotRequest; + isCurrent: () => boolean; + isMarketAllowed: (symbol: string) => boolean; + }; + + /** + * Cached subscription fee-waiver status for read-only fee previews. + * Read by the controller from `RewardsIntegrationService` — the same cached + * benefits snapshot the fee resolver uses — and omitted entirely when no + * subscription source is wired. + */ + subscriptionFeeWaiver?: PerpsSubscriptionFeeWaiverStatus; + + /** + * Callback functions for controller-specific operations + */ + saveTradeConfiguration?: (symbol: string, leverage: number) => void; + + /** + * Feature flag configuration callbacks + * Required by: FeatureFlagConfigurationService + */ + getBlockedRegionList?: () => { + list: string[]; + source: 'remote' | 'fallback'; + }; + setBlockedRegionList?: ( + list: string[], + source: 'remote' | 'fallback', + ) => void; + getHip3Config?: () => { + enabled: boolean; + allowlistMarkets: string[]; + blocklistMarkets: string[]; + source: 'remote' | 'fallback'; + }; + setHip3Config?: (config: { + enabled?: boolean; + allowlistMarkets?: string[]; + blocklistMarkets?: string[]; + source: 'remote' | 'fallback'; + }) => void; + incrementHip3ConfigVersion?: () => number; + refreshEligibility?: () => Promise; +}; diff --git a/packages/perps-controller/src/services/TerminalMarketService.ts b/packages/perps-controller/src/services/TerminalMarketService.ts new file mode 100644 index 00000000000..62c9ce85a55 --- /dev/null +++ b/packages/perps-controller/src/services/TerminalMarketService.ts @@ -0,0 +1,832 @@ +import type { Infer } from '@metamask/superstruct'; +import { + array, + boolean, + is, + nullable, + number, + object, + optional, + string, + tuple, + type, + union, +} from '@metamask/superstruct'; +import { bytesToHex, sha256, stringToBytes } from '@metamask/utils'; + +import { canonicalizeHyperLiquidDexes } from '../constants/hyperLiquidConfig.js'; +import { + PERPS_CONSTANTS, + TERMINAL_API_CONFIG, +} from '../constants/perpsConfig.js'; +import type { + MarketInfo, + PerpsGlobalSnapshotRequest, + PerpsGlobalSnapshotResult, + PerpsMarketData, + PerpsPlatformDependencies, + TerminalAssetMetadata, +} from '../types/index.js'; +import { MarketCategory } from '../types/index.js'; +import { ensureError } from '../utils/errorUtils.js'; +import { formatChange } from '../utils/marketDataTransform.js'; +import { clonePerpsMarketData } from '../utils/marketUtils.js'; + +const VALID_MARKET_TYPES = new Set(Object.values(MarketCategory)); +const GLOBAL_SNAPSHOT_SCHEMA_VERSION = 2; +const GLOBAL_SNAPSHOT_CONSUMER_MAX_AGE_MS = 30_000; +const GLOBAL_SNAPSHOT_MAX_PAYLOAD_BYTES = 1_048_576; +const GLOBAL_SNAPSHOT_PERCENT_TOLERANCE = 0.01; +const GLOBAL_SNAPSHOT_MAX_FUTURE_CLOCK_SKEW_MS = 5_000; +const MINIMUM_EPOCH_MILLISECONDS = Date.UTC(2000, 0, 1); +const DECIMAL_PATTERN = /^-?(?:0|[1-9]\d*)(?:\.\d+)?$/u; +const NON_NEGATIVE_DECIMAL_PATTERN = /^(?:0|[1-9]\d*)(?:\.\d+)?$/u; +const DEX_PATTERN = /^(?:main|[a-z0-9][a-z0-9-]*)$/u; + +const GlobalSnapshotMarketStruct = object({ + symbol: string(), + provider: string(), + dex: string(), + name: nullable(string()), + description: nullable(string()), + iconUrl: nullable(string()), + szDecimals: number(), + maxLeverage: number(), + markPrice: string(), + price: string(), + midPrice: nullable(string()), + oraclePrice: string(), + change24h: string(), + changePercent24h: number(), + funding: string(), + volume24h: string(), + openInterest: string(), + category: nullable(string()), + keywords: nullable(array(string())), + tags: nullable(array(string())), + listedAt: nullable(number()), + trend: array(tuple([number(), string()])), +}); + +const GlobalSnapshotStruct = object({ + schemaVersion: number(), + provider: string(), + network: string(), + enabledDexes: array(string()), + fingerprint: string(), + generatedAt: number(), + receivedAt: number(), + maxAgeMs: number(), + complete: boolean(), + perDexErrors: array( + object({ + dex: string(), + error: string(), + }), + ), + markets: array(GlobalSnapshotMarketStruct), +}); + +type GlobalSnapshotMarket = Infer; +type GlobalSnapshot = Infer; + +/** + * Runtime validation schema for a single market item returned by + * `GET {terminalApi.marketDataUrl}`. + * + * Uses `type()` (loose object matching) so that extra fields the API sends + * (e.g. `price`, `iconUrl`, `trend`) are silently accepted. + * Each item is individually validated; items that fail validation are + * filtered out and logged rather than rejecting the entire response. + */ +const TerminalPerpetualItemStruct = type({ + symbol: string(), + name: optional(nullable(string())), + description: optional(nullable(string())), + szDecimals: optional(number()), + maxLeverage: optional(number()), + marginTableId: optional(number()), + onlyIsolated: optional(boolean()), + isDelisted: optional(boolean()), + minimumOrderSize: optional(number()), + keywords: optional(nullable(array(string()))), + tags: optional(nullable(array(string()))), + categories: optional(nullable(array(string()))), + marketType: optional(nullable(string())), + listedAt: optional(nullable(union([number(), string()]))), +}); + +type TerminalPerpetualItem = Infer; + +type CacheEntry = { + markets: MarketInfo[]; + metadata: Map; + timestamp: number; +}; + +/** + * TerminalMarketService + * + * Fetches structured market metadata from the MetaMask Terminal API. + * Caches responses for {@link TERMINAL_API_CONFIG.CacheTtlMs} to avoid + * redundant network calls across polling cycles. + * + * Instance-based service with constructor injection of platform dependencies. + */ +export class TerminalMarketService { + readonly #deps: PerpsPlatformDependencies; + + #cache: CacheEntry | null = null; + + readonly #globalSnapshotCache = new Map(); + + readonly #globalSnapshotInFlight = new Map< + string, + Promise + >(); + + #globalSnapshotGeneration = 0; + + constructor(deps: PerpsPlatformDependencies) { + this.#deps = deps; + } + + /** + * Fetch markets from the Terminal API. + * Returns cached data when available and within TTL. + * + * @returns Object with mapped MarketInfo array and per-symbol metadata. + */ + async fetchMarkets(): Promise<{ + markets: MarketInfo[]; + metadata: Map; + }> { + if ( + this.#cache && + Date.now() - this.#cache.timestamp < TERMINAL_API_CONFIG.CacheTtlMs + ) { + return { + markets: this.#cache.markets, + metadata: this.#cache.metadata, + }; + } + + const marketDataUrl = + this.#deps.terminalApi?.marketDataUrl ?? this.#deps.terminalApiUrl; + if (!marketDataUrl) { + throw new Error('Terminal API market-data URL not configured'); + } + + const url = marketDataUrl; + const controller = new AbortController(); + const timeoutId = setTimeout( + () => controller.abort(new Error('Terminal API fetch timed out')), + TERMINAL_API_CONFIG.FetchTimeoutMs, + ); + + let response: Response; + try { + response = await fetch(url, { + method: 'GET', + headers: { 'Content-Type': 'application/json' }, + signal: controller.signal, + }); + } finally { + clearTimeout(timeoutId); + } + + if (!response.ok) { + throw new Error( + `Terminal API returned ${String(response.status)}: ${response.statusText}`, + ); + } + + const body: unknown = await response.json(); + + if (!Array.isArray(body)) { + throw new Error(`Terminal API returned non-array body: ${typeof body}`); + } + + const items = this.#validateItems(body); + const markets = this.#mapToMarketInfo(items); + const metadata = this.#extractMetadata(items); + + this.#cache = { markets, metadata, timestamp: Date.now() }; + return { markets, metadata }; + } + + /** + * Fetch, authenticate by exact identity, and map a schema-v2 atomic market + * snapshot. Accepted entries remain inside the source freshness window; + * rejected responses are never cached. + * + * @param request - Exact provider/network/DEX identity expected by the client. + * @returns UI-ready market data and its source-bounded expiry. + */ + async fetchGlobalSnapshot( + request: PerpsGlobalSnapshotRequest, + ): Promise { + const identity = this.#validateRequestedIdentity(request); + if (!this.#deps.terminalApi?.globalSnapshotUrl) { + throw new Error('Terminal global snapshot URL not configured'); + } + + const url = this.#buildGlobalSnapshotUrl( + this.#deps.terminalApi.globalSnapshotUrl, + identity, + ); + const cacheKey = [ + url, + String(GLOBAL_SNAPSHOT_SCHEMA_VERSION), + identity.provider, + identity.network, + identity.enabledDexes.join(','), + ].join('|'); + const now = Date.now(); + const cached = this.#globalSnapshotCache.get(cacheKey); + if (cached && now < cached.expiresAt) { + return this.#cloneGlobalSnapshotResult(cached); + } + if (cached) { + this.#globalSnapshotCache.delete(cacheKey); + } + + const existing = this.#globalSnapshotInFlight.get(cacheKey); + if (existing) { + return this.#cloneGlobalSnapshotResult(await existing); + } + + const generation = this.#globalSnapshotGeneration; + const pending = this.#fetchAndValidateGlobalSnapshot(identity, url).then( + (result) => { + if (this.#globalSnapshotGeneration !== generation) { + return result; + } + this.#globalSnapshotCache.set(cacheKey, result); + return result; + }, + ); + this.#globalSnapshotInFlight.set(cacheKey, pending); + try { + return this.#cloneGlobalSnapshotResult(await pending); + } finally { + if (this.#globalSnapshotInFlight.get(cacheKey) === pending) { + this.#globalSnapshotInFlight.delete(cacheKey); + } + } + } + + async #fetchAndValidateGlobalSnapshot( + identity: PerpsGlobalSnapshotRequest, + url: string, + ): Promise { + const controller = new AbortController(); + const timeoutId = setTimeout( + () => controller.abort(new Error('Terminal global snapshot timed out')), + TERMINAL_API_CONFIG.FetchTimeoutMs, + ); + + try { + const response = await fetch(url, { + method: 'GET', + headers: { 'Content-Type': 'application/json' }, + signal: controller.signal, + }); + + if (!response.ok) { + throw new Error( + `Terminal global snapshot returned ${String(response.status)}: ${response.statusText}`, + ); + } + + const declaredLength = response.headers?.get('content-length'); + if ( + declaredLength !== null && + declaredLength !== undefined && + /^\d+$/u.test(declaredLength) && + Number(declaredLength) > GLOBAL_SNAPSHOT_MAX_PAYLOAD_BYTES + ) { + throw new Error('Terminal global snapshot payload exceeds 1 MiB'); + } + // React Native fetch does not consistently expose a streaming reader. + // Reject declared oversize bodies before allocation, then enforce the same + // byte cap after text() for servers that omit Content-Length. + const text = await response.text(); + if (stringToBytes(text).byteLength > GLOBAL_SNAPSHOT_MAX_PAYLOAD_BYTES) { + throw new Error('Terminal global snapshot payload exceeds 1 MiB'); + } + let body: unknown; + try { + body = JSON.parse(text) as unknown; + } catch { + throw new Error('Terminal global snapshot returned invalid JSON'); + } + if (!is(body, GlobalSnapshotStruct)) { + throw new Error('Terminal global snapshot failed schema validation'); + } + return this.#validateAndMapGlobalSnapshot(body, identity, Date.now()); + } finally { + clearTimeout(timeoutId); + } + } + + async #validateAndMapGlobalSnapshot( + snapshot: GlobalSnapshot, + identity: PerpsGlobalSnapshotRequest, + now: number, + ): Promise { + if (snapshot.schemaVersion !== GLOBAL_SNAPSHOT_SCHEMA_VERSION) { + throw new Error('Terminal global snapshot schema version mismatch'); + } + if ( + snapshot.provider !== identity.provider || + snapshot.network !== identity.network + ) { + throw new Error('Terminal global snapshot identity mismatch'); + } + + const responseDexes = this.#normalizeDexes(snapshot.enabledDexes); + if ( + responseDexes.length !== identity.enabledDexes.length || + responseDexes.some((dex, index) => dex !== identity.enabledDexes[index]) + ) { + throw new Error('Terminal global snapshot DEX mismatch'); + } + const expectedFingerprint = await this.#createFingerprint(identity); + if (snapshot.fingerprint !== expectedFingerprint) { + throw new Error('Terminal global snapshot fingerprint mismatch'); + } + if (!snapshot.complete || snapshot.perDexErrors.length > 0) { + throw new Error('Terminal global snapshot is incomplete'); + } + if ( + !this.#isNonNegativeSafeInteger(snapshot.generatedAt) || + !this.#isNonNegativeSafeInteger(snapshot.receivedAt) || + !this.#isPositiveSafeInteger(snapshot.maxAgeMs) || + snapshot.receivedAt > snapshot.generatedAt || + snapshot.generatedAt > now + GLOBAL_SNAPSHOT_MAX_FUTURE_CLOCK_SKEW_MS || + snapshot.receivedAt > now + GLOBAL_SNAPSHOT_MAX_FUTURE_CLOCK_SKEW_MS + ) { + throw new Error('Terminal global snapshot has invalid timestamps'); + } + + const trustedMaxAgeMs = Math.min( + snapshot.maxAgeMs, + GLOBAL_SNAPSHOT_CONSUMER_MAX_AGE_MS, + ); + const expiresAt = snapshot.receivedAt + trustedMaxAgeMs; + if (now >= expiresAt) { + throw new Error('Terminal global snapshot is stale'); + } + if (snapshot.markets.length === 0) { + throw new Error('Terminal global snapshot has no markets'); + } + + const marketKeys = new Set(); + const representedDexes = new Set(); + const markets = snapshot.markets + .map((market, index) => { + this.#validateSnapshotMarket( + market, + identity, + index, + snapshot.generatedAt, + ); + const key = `${market.dex}:${market.symbol}`; + if (marketKeys.has(key)) { + throw new Error(`Terminal global snapshot duplicates market ${key}`); + } + marketKeys.add(key); + representedDexes.add(market.dex); + return market; + }) + .map((market) => this.#mapSnapshotMarket(market, expiresAt)); + if (identity.enabledDexes.some((dex) => !representedDexes.has(dex))) { + throw new Error('Terminal global snapshot is missing a requested DEX'); + } + if (markets.length === 0) { + throw new Error('Terminal global snapshot has no tradable markets'); + } + + return { markets, expiresAt }; + } + + #validateRequestedIdentity( + request: PerpsGlobalSnapshotRequest, + ): PerpsGlobalSnapshotRequest { + if (request.provider !== 'hyperliquid') { + throw new Error('Terminal global snapshot provider is unsupported'); + } + if (request.network !== 'mainnet' && request.network !== 'testnet') { + throw new Error('Terminal global snapshot network is unsupported'); + } + return { + provider: request.provider, + network: request.network, + enabledDexes: this.#normalizeDexes(request.enabledDexes), + }; + } + + #buildGlobalSnapshotUrl( + baseUrl: string, + identity: PerpsGlobalSnapshotRequest, + ): string { + const query = new URLSearchParams({ + provider: identity.provider, + network: identity.network, + dexes: identity.enabledDexes.join(','), + }); + return `${baseUrl}${baseUrl.includes('?') ? '&' : '?'}${query.toString()}`; + } + + #normalizeDexes(dexes: string[]): string[] { + if (!Array.isArray(dexes) || dexes.length === 0) { + throw new Error('Terminal global snapshot requires at least one DEX'); + } + const normalized = dexes.map((dex) => { + if (typeof dex !== 'string' || !DEX_PATTERN.test(dex)) { + throw new Error('Terminal global snapshot contains an invalid DEX'); + } + return dex; + }); + if (new Set(normalized).size !== normalized.length) { + throw new Error('Terminal global snapshot contains duplicate DEXes'); + } + if (!normalized.includes('main')) { + throw new Error('Terminal global snapshot requires the main DEX'); + } + return canonicalizeHyperLiquidDexes(normalized); + } + + async #createFingerprint( + identity: PerpsGlobalSnapshotRequest, + ): Promise { + const canonicalIdentity = JSON.stringify({ + provider: identity.provider, + network: identity.network, + enabledDexes: identity.enabledDexes, + }); + const digest = await sha256(stringToBytes(canonicalIdentity)); + return `sha256:${bytesToHex(digest).slice(2)}`; + } + + #validateSnapshotMarket( + market: GlobalSnapshotMarket, + identity: PerpsGlobalSnapshotRequest, + index: number, + generatedAt: number, + ): void { + const invalid = (field: string): Error => + new Error( + `Terminal global snapshot market ${String(index)} has invalid ${field}`, + ); + if (!identity.enabledDexes.includes(market.dex)) { + throw invalid('dex'); + } + const expectedProvider = market.dex === 'main' ? 'hyperliquid' : market.dex; + if (market.provider !== expectedProvider) { + throw invalid('provider'); + } + const expectedPrefix = market.dex === 'main' ? '' : `${market.dex}:`; + if ( + market.symbol.length === 0 || + (expectedPrefix + ? !market.symbol.startsWith(expectedPrefix) + : market.symbol.includes(':')) + ) { + throw invalid('symbol'); + } + if ( + !this.#isNonNegativeSafeInteger(market.szDecimals) || + !this.#isPositiveSafeInteger(market.maxLeverage) || + (market.listedAt !== null && + (!this.#isNonNegativeSafeInteger(market.listedAt) || + market.listedAt < MINIMUM_EPOCH_MILLISECONDS || + market.listedAt > generatedAt)) + ) { + throw invalid('integer field'); + } + + const decimalFields: [string, string, boolean][] = [ + ['markPrice', market.markPrice, true], + ['price', market.price, true], + ['oraclePrice', market.oraclePrice, true], + ['change24h', market.change24h, false], + ['volume24h', market.volume24h, true], + ['openInterest', market.openInterest, true], + ['funding', market.funding, false], + ]; + if (market.midPrice !== null) { + decimalFields.push(['midPrice', market.midPrice, true]); + } + for (const [field, value, nonNegative] of decimalFields) { + const pattern = nonNegative + ? NON_NEGATIVE_DECIMAL_PATTERN + : DECIMAL_PATTERN; + if (!pattern.test(value) || !Number.isFinite(Number(value))) { + throw invalid(field); + } + } + if (market.price !== market.markPrice) { + throw invalid('deprecated price alias'); + } + if ( + Number(market.oraclePrice) <= 0 || + (market.midPrice !== null && Number(market.midPrice) <= 0) + ) { + throw invalid('reference price'); + } + const price = Number(market.markPrice); + const change24h = Number(market.change24h); + const previousPrice = price - change24h; + if (price <= 0 || previousPrice <= 0 || !Number.isFinite(previousPrice)) { + throw invalid('mark/change coherence'); + } + const derivedPercent = (change24h / previousPrice) * 100; + if ( + !Number.isFinite(derivedPercent) || + !Number.isFinite(market.changePercent24h) || + Math.abs(market.changePercent24h - derivedPercent) > + GLOBAL_SNAPSHOT_PERCENT_TOLERANCE + ) { + throw invalid('changePercent24h coherence'); + } + for (const [field, values] of [ + ['keywords', market.keywords], + ['tags', market.tags], + ] as const) { + if ( + values !== null && + (values.some((value) => value.length === 0) || + new Set(values).size !== values.length) + ) { + throw invalid(field); + } + } + for (const [field, value] of [ + ['name', market.name], + ['description', market.description], + ['iconUrl', market.iconUrl], + ['category', market.category], + ] as const) { + if (value !== null && value.length === 0) { + throw invalid(field); + } + } + let previousTrendTimestamp = -1; + for (const [timestamp, trendPrice] of market.trend) { + if ( + !this.#isNonNegativeSafeInteger(timestamp) || + timestamp < MINIMUM_EPOCH_MILLISECONDS || + timestamp > generatedAt || + timestamp <= previousTrendTimestamp || + !NON_NEGATIVE_DECIMAL_PATTERN.test(trendPrice) || + !Number.isFinite(Number(trendPrice)) || + Number(trendPrice) <= 0 + ) { + throw invalid('trend'); + } + previousTrendTimestamp = timestamp; + } + } + + #mapSnapshotMarket( + market: GlobalSnapshotMarket, + sourceExpiresAt: number, + ): PerpsMarketData { + const formatters = this.#deps.marketDataFormatters; + // Keep both provider price semantics explicit in the wire contract. Core + // maps markPrice to its UI price while retaining validation of midPrice. + const price = Number(market.markPrice); + const change24h = Number(market.change24h); + const volume = Number(market.volume24h); + const openInterest = Number(market.openInterest); + const isHip3 = market.dex !== 'main'; + const marketType = this.#marketTypeFor(market.dex, market.category); + + return { + symbol: market.symbol, + name: market.name ?? market.symbol, + ...(market.description !== null && { + description: market.description, + }), + maxLeverage: `${String(market.maxLeverage)}x`, + price: formatters.formatPerpsFiat(price, { + ranges: formatters.priceRangesUniversal, + }), + change24h: formatChange(change24h, formatters), + change24hPercent: formatters.formatPercentage(market.changePercent24h), + volume: formatters.formatVolume(volume), + openInterest: formatters.formatVolume(openInterest), + fundingRate: Number(market.funding), + marketSource: isHip3 ? market.dex : undefined, + marketType, + isHip3, + isNewMarket: isHip3 && marketType === undefined, + ...(market.keywords && { keywords: market.keywords }), + ...(market.tags && { tags: market.tags }), + ...(market.category && { categories: [market.category] }), + ...(market.listedAt !== null && { listedAt: market.listedAt }), + trend: market.trend, + dataSource: 'terminal-global-snapshot-mark', + sourceExpiresAt, + }; + } + + #marketTypeFor( + dex: string, + category: string | null, + ): TerminalAssetMetadata['marketType'] | undefined { + if (dex === 'main') { + return MarketCategory.CryptoCurrency; + } + if (category === 'stocks') { + return MarketCategory.Stock; + } + if (category === 'pre_ipo') { + return MarketCategory.PreIpo; + } + if (category && VALID_MARKET_TYPES.has(category)) { + return category as TerminalAssetMetadata['marketType']; + } + return undefined; + } + + #cloneGlobalSnapshotResult( + result: PerpsGlobalSnapshotResult, + ): PerpsGlobalSnapshotResult { + return { + expiresAt: result.expiresAt, + markets: clonePerpsMarketData(result.markets), + }; + } + + #isNonNegativeSafeInteger(value: unknown): value is number { + return ( + typeof value === 'number' && Number.isSafeInteger(value) && value >= 0 + ); + } + + #isPositiveSafeInteger(value: unknown): value is number { + return this.#isNonNegativeSafeInteger(value) && value > 0; + } + + /** + * Invalidate the internal cache so the next fetch hits the network. + */ + clearCache(): void { + this.#cache = null; + this.#globalSnapshotGeneration += 1; + this.#globalSnapshotCache.clear(); + this.#globalSnapshotInFlight.clear(); + } + + /** + * Validate raw API response items against the expected schema. + * Items that fail validation are filtered out and logged rather than + * rejecting the entire response. + * + * @param raw - The raw array from the API response body. + * @returns Array of validated items. + */ + #validateItems(raw: unknown[]): TerminalPerpetualItem[] { + const valid: TerminalPerpetualItem[] = []; + for (const item of raw) { + if (is(item, TerminalPerpetualItemStruct)) { + valid.push(item); + } else { + this.#deps.logger.error( + ensureError( + new Error('Terminal API item failed schema validation'), + 'TerminalMarketService.validateItems', + ), + { + tags: { + feature: PERPS_CONSTANTS.FeatureName, + source: 'terminal-api', + }, + context: { + name: 'TerminalMarketService.validateItems', + data: { + symbol: + typeof item === 'object' && + item !== null && + Object.prototype.hasOwnProperty.call(item, 'symbol') + ? (item as Record).symbol + : undefined, + }, + }, + }, + ); + } + } + return valid; + } + + /** + * Map Terminal API items to the protocol-agnostic MarketInfo shape. + * + * @param items - Raw items from the API response. + * @returns Array of MarketInfo objects. + */ + #mapToMarketInfo(items: TerminalPerpetualItem[]): MarketInfo[] { + return items + .filter( + (item) => typeof item.symbol === 'string' && item.symbol.length > 0, + ) + .map((item) => ({ + name: item.symbol, + szDecimals: item.szDecimals ?? 0, + maxLeverage: item.maxLeverage ?? 1, + marginTableId: item.marginTableId ?? 0, + ...(item.onlyIsolated === true && { onlyIsolated: true as const }), + ...(item.isDelisted === true && { isDelisted: true as const }), + ...(item.minimumOrderSize !== undefined && { + minimumOrderSize: item.minimumOrderSize, + }), + })); + } + + /** + * Extract per-symbol metadata for downstream merge into PerpsMarketData. + * + * @param items - Raw items from the API response. + * @returns Map keyed by symbol with enrichment metadata. + */ + #extractMetadata( + items: TerminalPerpetualItem[], + ): Map { + const map = new Map(); + + for (const item of items) { + if (typeof item.symbol !== 'string' || item.symbol.length === 0) { + continue; + } + + const entry: TerminalAssetMetadata = {}; + + if (typeof item.name === 'string' && item.name.length > 0) { + entry.name = item.name; + } + + if (typeof item.description === 'string' && item.description.length > 0) { + entry.description = item.description; + } + + if (Array.isArray(item.keywords) && item.keywords.length > 0) { + entry.keywords = item.keywords; + } + if (Array.isArray(item.tags) && item.tags.length > 0) { + entry.tags = item.tags; + } + if (Array.isArray(item.categories) && item.categories.length > 0) { + entry.categories = item.categories; + } + if ( + typeof item.marketType === 'string' && + VALID_MARKET_TYPES.has(item.marketType) + ) { + entry.marketType = + item.marketType as TerminalAssetMetadata['marketType']; + } + + if (item.listedAt !== null && item.listedAt !== undefined) { + const listedAtMs = + typeof item.listedAt === 'number' + ? item.listedAt + : Date.parse(item.listedAt); + if (isFinite(listedAtMs)) { + entry.listedAt = listedAtMs; + } + } + + map.set(item.symbol, entry); + } + + return map; + } + + /** + * Log a Terminal API error to Sentry without surfacing it to the user. + * + * @param error - The caught error. + * @param method - The calling method name for context. + */ + logError(error: unknown, method: string): void { + this.#deps.logger.error( + ensureError(error, `TerminalMarketService.${method}`), + { + tags: { feature: PERPS_CONSTANTS.FeatureName, source: 'terminal-api' }, + context: { + name: `TerminalMarketService.${method}`, + data: { + url: method.includes('globalSnapshot') + ? this.#deps.terminalApi?.globalSnapshotUrl + : (this.#deps.terminalApi?.marketDataUrl ?? + this.#deps.terminalApiUrl), + }, + }, + }, + ); + } +} diff --git a/packages/perps-controller/src/services/TradingReadinessCache.ts b/packages/perps-controller/src/services/TradingReadinessCache.ts new file mode 100644 index 00000000000..e797eb61b24 --- /dev/null +++ b/packages/perps-controller/src/services/TradingReadinessCache.ts @@ -0,0 +1,432 @@ +/** + * Global singleton cache for Perps signing operations + * + * This cache persists across provider reconnections to prevent repeated + * signing requests for hardware wallets. Critical for preventing repeated + * hardware wallet signing prompts. + * + * Cache is intentionally kept separate from provider instances because providers + * are recreated on account/network changes, which would reset instance-level caches. + * + * Tracks three signing operations: + * 1. Unified Account enablement (one-time, replaces deprecated DEX abstraction) + * 2. Builder Fee approval (required for trading) + * 3. Referral code setup (one-time per account) + * + * Cache Structure: + * - Key: `network:userAddress` (e.g., "mainnet:0x123...") + * - Value: { unifiedAccount, builderFee, referral, timestamp } + * + * Lifecycle: + * - Cache persists throughout app session + * - Individual entries can be cleared per user/network + * - Full cache can be cleared on app restart or explicit user action + */ + +type SigningOperationState = { + attempted: boolean; // Whether we've attempted this operation + success: boolean; // Whether it succeeded (only valid if attempted=true) + reason?: 'no_hl_account' | 'user_rejected' | 'transient'; // optional discriminator +}; + +// Tracks whether the wallet has ever been observed on Hyperliquid. +// Hyperliquid accounts only come into existence on first USDC deposit. +// Before then, user-scoped exchange writes (agentSetAbstraction, +// userSetAbstraction, setReferrer, ...) reject with +// "User or API Wallet 0x... does not exist." +// Used to skip those writes proactively rather than catching the rejection. +type WalletRegistrationState = { + known: boolean; // Whether we have signal at all + registered: boolean; // True once observed on Hyperliquid (monotonic) +}; + +type PerpsSigningCacheEntry = { + unifiedAccount: SigningOperationState; + builderFee: SigningOperationState; + referral: SigningOperationState; + walletRegistered: WalletRegistrationState; + timestamp: number; // When this entry was last updated +}; + +// Legacy interface for backward compatibility +type TradingReadinessCacheEntry = { + attempted: boolean; + enabled: boolean; + reason?: 'no_hl_account' | 'user_rejected' | 'transient'; + timestamp: number; +}; + +class PerpsSigningCacheManager { + static #instance: PerpsSigningCacheManager; + + readonly #cache: Map = new Map(); + + // Global in-flight locks to prevent concurrent signing attempts across providers + // Key: operationType:network:userAddress, Value: Promise that resolves when operation completes + readonly #inFlightOperations: Map> = new Map(); + + // Singleton: use getInstance() instead of new + protected constructor() { + // Protected constructor for singleton + } + + public static getInstance(): PerpsSigningCacheManager { + PerpsSigningCacheManager.#instance ??= new PerpsSigningCacheManager(); + return PerpsSigningCacheManager.#instance; + } + + // ===== In-Flight Lock Methods ===== + + /** + * Check if an operation is currently in-flight for this user/network + * + * @param operationType - The type of operation being performed. + * @param network - The network environment. + * @param userAddress - The user's wallet address. + * @returns The resulting string value. + */ + public isInFlight( + operationType: 'unifiedAccount' | 'builderFee' | 'referral', + network: 'mainnet' | 'testnet', + userAddress: string, + ): Promise | undefined { + const key = `${operationType}:${network}:${userAddress.toLowerCase()}`; + return this.#inFlightOperations.get(key); + } + + /** + * Set an operation as in-flight + * Returns a function to call when operation completes + * + * @param operationType - The type of operation being performed. + * @param network - The network environment. + * @param userAddress - The user's wallet address. + * @returns The resulting string value. + */ + public setInFlight( + operationType: 'unifiedAccount' | 'builderFee' | 'referral', + network: 'mainnet' | 'testnet', + userAddress: string, + ): () => void { + const key = `${operationType}:${network}:${userAddress.toLowerCase()}`; + let resolvePromise: () => void; + const promise = new Promise((resolve) => { + resolvePromise = resolve; + }); + this.#inFlightOperations.set(key, promise); + return () => { + this.#inFlightOperations.delete(key); + resolvePromise(); + }; + } + + #getCacheKey(network: 'mainnet' | 'testnet', userAddress: string): string { + return `${network}:${userAddress.toLowerCase()}`; + } + + #getOrCreateEntry( + network: 'mainnet' | 'testnet', + userAddress: string, + ): PerpsSigningCacheEntry { + const key = this.#getCacheKey(network, userAddress); + let entry = this.#cache.get(key); + if (!entry) { + entry = { + unifiedAccount: { attempted: false, success: false }, + builderFee: { attempted: false, success: false }, + referral: { attempted: false, success: false }, + walletRegistered: { known: false, registered: false }, + timestamp: Date.now(), + }; + this.#cache.set(key, entry); + } + return entry; + } + + // ===== Unified Account Methods ===== + + /** + * Get unified account cache entry (legacy compatibility) + * + * @param network - The network environment. + * @param userAddress - The user's wallet address. + * @returns The resulting string value. + */ + public get( + network: 'mainnet' | 'testnet', + userAddress: string, + ): TradingReadinessCacheEntry | undefined { + const key = this.#getCacheKey(network, userAddress); + const entry = this.#cache.get(key); + if (!entry) { + return undefined; + } + return { + attempted: entry.unifiedAccount.attempted, + enabled: entry.unifiedAccount.success, + reason: entry.unifiedAccount.reason, + timestamp: entry.timestamp, + }; + } + + /** + * Set unified account cache entry (legacy compatibility) + * + * @param network - The network environment. + * @param userAddress - The user's wallet address. + * @param data - The transaction data payload. + * @param data.attempted - Whether the operation was attempted. + * @param data.enabled - Whether the feature is enabled. + * @param data.reason - Optional discriminator explaining a non-success outcome. + */ + public set( + network: 'mainnet' | 'testnet', + userAddress: string, + data: { + attempted: boolean; + enabled: boolean; + reason?: 'no_hl_account' | 'user_rejected' | 'transient'; + }, + ): void { + const entry = this.#getOrCreateEntry(network, userAddress); + entry.unifiedAccount = { + attempted: data.attempted, + success: data.enabled, + reason: data.reason, + }; + entry.timestamp = Date.now(); + } + + // ===== Builder Fee Methods ===== + + /** + * Check if builder fee approval was attempted + * + * @param network - The network environment. + * @param userAddress - The user's wallet address. + * @returns The resulting string value. + */ + public getBuilderFee( + network: 'mainnet' | 'testnet', + userAddress: string, + ): SigningOperationState | undefined { + const key = this.#getCacheKey(network, userAddress); + const entry = this.#cache.get(key); + return entry?.builderFee; + } + + /** + * Set builder fee approval state + * + * @param network - The network environment. + * @param userAddress - The user's wallet address. + * @param state - The current state. + */ + public setBuilderFee( + network: 'mainnet' | 'testnet', + userAddress: string, + state: SigningOperationState, + ): void { + const entry = this.#getOrCreateEntry(network, userAddress); + entry.builderFee = state; + entry.timestamp = Date.now(); + } + + // ===== Referral Methods ===== + + /** + * Check if referral setup was attempted + * + * @param network - The network environment. + * @param userAddress - The user's wallet address. + * @returns The resulting string value. + */ + public getReferral( + network: 'mainnet' | 'testnet', + userAddress: string, + ): SigningOperationState | undefined { + const key = this.#getCacheKey(network, userAddress); + const entry = this.#cache.get(key); + return entry?.referral; + } + + /** + * Set referral setup state + * + * @param network - The network environment. + * @param userAddress - The user's wallet address. + * @param state - The current state. + */ + public setReferral( + network: 'mainnet' | 'testnet', + userAddress: string, + state: SigningOperationState, + ): void { + const entry = this.#getOrCreateEntry(network, userAddress); + entry.referral = state; + entry.timestamp = Date.now(); + } + + // ===== General Methods ===== + + /** + * Clear only unified account state for a specific network and user address + * This preserves builder fee and referral states + * + * @param network - The network environment. + * @param userAddress - The user's wallet address. + */ + public clearUnifiedAccount( + network: 'mainnet' | 'testnet', + userAddress: string, + ): void { + const key = this.#getCacheKey(network, userAddress); + const entry = this.#cache.get(key); + if (entry) { + entry.unifiedAccount = { attempted: false, success: false }; + entry.timestamp = Date.now(); + } + } + + // ===== Wallet Registration Methods ===== + + /** + * Read the wallet's Hyperliquid registration signal. + * + * @param network - The network environment. + * @param userAddress - The user's wallet address. + * @returns The current signal, or undefined if no entry exists. + */ + public getWalletRegistered( + network: 'mainnet' | 'testnet', + userAddress: string, + ): WalletRegistrationState | undefined { + const key = this.#getCacheKey(network, userAddress); + return this.#cache.get(key)?.walletRegistered; + } + + /** + * Record whether the wallet has been observed on Hyperliquid. Once + * `registered=true` is set, it stays true for the session — the goal + * is to skip doomed exchange writes for unfunded wallets, not to + * gate them after they fund. + * + * @param network - The network environment. + * @param userAddress - The user's wallet address. + * @param registered - True once any evidence (clearinghouseState balance, + * userFills, successful deposit, ...) confirms the wallet exists on HL. + */ + public setWalletRegistered( + network: 'mainnet' | 'testnet', + userAddress: string, + registered: boolean, + ): void { + const entry = this.#getOrCreateEntry(network, userAddress); + if (entry.walletRegistered.registered && !registered) { + // Monotonic: once registered, never demote. + return; + } + entry.walletRegistered = { known: true, registered }; + entry.timestamp = Date.now(); + } + + /** + * Clear only builder fee state for a specific network and user address + * This preserves unified account and referral states + * + * @param network - The network environment. + * @param userAddress - The user's wallet address. + */ + public clearBuilderFee( + network: 'mainnet' | 'testnet', + userAddress: string, + ): void { + const key = this.#getCacheKey(network, userAddress); + const entry = this.#cache.get(key); + if (entry) { + entry.builderFee = { attempted: false, success: false }; + entry.timestamp = Date.now(); + } + } + + /** + * Clear only referral state for a specific network and user address + * This preserves unified account and builder fee states + * + * @param network - The network environment. + * @param userAddress - The user's wallet address. + */ + public clearReferral( + network: 'mainnet' | 'testnet', + userAddress: string, + ): void { + const key = this.#getCacheKey(network, userAddress); + const entry = this.#cache.get(key); + if (entry) { + entry.referral = { attempted: false, success: false }; + entry.timestamp = Date.now(); + } + } + + /** + * Clear entire cache entry for a specific network and user address + * WARNING: This clears ALL signing operation states (unifiedAccount, builderFee, referral) + * + * @param network - The network environment. + * @param userAddress - The user's wallet address. + */ + public clear(network: 'mainnet' | 'testnet', userAddress: string): void { + const key = this.#getCacheKey(network, userAddress); + this.#cache.delete(key); + } + + /** + * Clear all cache entries + * WARNING: This clears ALL signing operation states for ALL users + */ + public clearAll(): void { + this.#cache.clear(); + } + + /** + * Get all cache entries (for debugging) + * + * @returns The result of the operation. + */ + public getAll(): Map { + return new Map(this.#cache); + } + + /** + * Get cache size (for debugging) + * + * @returns The resulting numeric value. + */ + public size(): number { + return this.#cache.size; + } + + /** + * Get full cache state for debugging + * + * @returns The resulting string value. + */ + public debugState(): string { + const entries: string[] = []; + this.#cache.forEach((entry, key) => { + entries.push( + `${key}: unified=${entry.unifiedAccount.attempted}/${entry.unifiedAccount.success}, ` + + `builder=${entry.builderFee.attempted}/${entry.builderFee.success}, ` + + `referral=${entry.referral.attempted}/${entry.referral.success}, ` + + `walletRegistered=${entry.walletRegistered.known}/${entry.walletRegistered.registered}`, + ); + }); + return entries.join('\n') || '(empty)'; + } +} + +// Export singleton instance with backward-compatible name +export const TradingReadinessCache = PerpsSigningCacheManager.getInstance(); + +// Export with new name for clarity +export const PerpsSigningCache = PerpsSigningCacheManager.getInstance(); diff --git a/packages/perps-controller/src/services/TradingService.ts b/packages/perps-controller/src/services/TradingService.ts new file mode 100644 index 00000000000..f0bee6b9087 --- /dev/null +++ b/packages/perps-controller/src/services/TradingService.ts @@ -0,0 +1,2518 @@ +import { BigNumber } from 'bignumber.js'; +import { v4 as uuidv4 } from 'uuid'; + +import { + PERPS_EVENT_PROPERTY, + PERPS_EVENT_VALUE, +} from '../constants/eventNames.js'; +import { isTPSLOrder } from '../constants/orderTypes.js'; +import { PerpsMeasurementName } from '../constants/performanceMetrics.js'; +import { PERPS_CONSTANTS } from '../constants/perpsConfig.js'; +import { + PerpsAnalyticsEvent, + PerpsTraceNames, + PerpsTraceOperations, +} from '../types/index.js'; +import type { + PerpsProvider, + OrderParams, + OrderResult, + EditOrderParams, + CancelOrderParams, + CancelOrderResult, + CancelOrdersParams, + CancelOrdersResult, + ClosePositionParams, + ClosePositionsParams, + ClosePositionsResult, + Position, + TrackingData, + UpdatePositionTPSLParams, + PerpsAnalyticsProperties, + PerpsPlatformDependencies, + PerpsFeeResolution, +} from '../types/index.js'; +import { ensureError } from '../utils/errorUtils.js'; +import { isLimitExecutionOrderType } from '../utils/orderTypes.js'; +import type { RewardsIntegrationService } from './RewardsIntegrationService.js'; +import type { ServiceContext } from './ServiceContext.js'; + +/** + * Controller-level dependencies for TradingService. + * These are singletons that don't change per-call, injected once via setControllerDependencies(). + */ +export type TradingServiceControllerDeps = { + rewardsIntegrationService: RewardsIntegrationService; +}; + +/** + * Subset of tracking data carrying discovery attribution + hl_fee_rate that is + * shared across trade/close/cancel/risk events. Both + * {@link TrackingData} and {@link TPSLTrackingData} satisfy this shape. + */ +type AttributionTrackingData = Pick< + TrackingData, + 'entryPoint' | 'discoverySource' | 'perpDiscoverySource' | 'hlFeeRate' +>; + +/** + * TradingService + * + * Handles trading operations with fee discount management. + * Controller is responsible for analytics, state management, and tracing. + * + * Instance-based service with constructor injection of platform dependencies. + * Controller-level dependencies (RewardsController, NetworkController, etc.) + * are injected via setControllerDependencies() after construction. + */ +export class TradingService { + /** + * Platform dependencies for logging, metrics, etc. + */ + readonly #deps: PerpsPlatformDependencies; + + /** + * Controller-level dependencies for fee discount calculation. + * Set via setControllerDependencies() after construction. + */ + #controllerDeps: TradingServiceControllerDeps | null = null; + + /** Serializes provider fee context so concurrent orders cannot share it. */ + #feeContextTail: Promise = Promise.resolve(); + + /** + * Create a new TradingService instance + * + * @param deps - Platform dependencies for logging, metrics, etc. + */ + constructor(deps: PerpsPlatformDependencies) { + this.#deps = deps; + } + + /** + * Set controller-level dependencies for fee discount calculation. + * Called by PerpsController after construction to inject singleton dependencies. + * + * @param controllerDeps - Controller-level dependencies (RewardsController, etc.) + */ + setControllerDependencies( + controllerDeps: TradingServiceControllerDeps, + ): void { + this.#controllerDeps = controllerDeps; + } + + /** + * Error context helper for consistent logging + * + * @param method - The method name. + * @param additionalContext - The additional context value. + * @returns The resulting string value. + */ + #getErrorContext( + method: string, + additionalContext?: Record, + ): Record { + return { + controller: 'TradingService', + method, + ...additionalContext, + }; + } + + /** + * Build discovery/attribution properties shared across trade/close/cancel/risk + * events. Each property is only included when present so + * that, in particular, hl_fee_rate is omitted entirely when unavailable. + * + * @param trackingData - Optional tracking data carried on the operation params. + * @returns The attribution properties to merge into an analytics event. + */ + #buildAttributionProperties( + trackingData?: AttributionTrackingData, + ): PerpsAnalyticsProperties { + const properties: PerpsAnalyticsProperties = {}; + if (trackingData?.entryPoint !== undefined) { + properties[PERPS_EVENT_PROPERTY.ENTRY_POINT] = trackingData.entryPoint; + } + if (trackingData?.discoverySource !== undefined) { + properties[PERPS_EVENT_PROPERTY.DISCOVERY_SOURCE] = + trackingData.discoverySource; + } + if (trackingData?.perpDiscoverySource !== undefined) { + properties[PERPS_EVENT_PROPERTY.PERP_DISCOVERY_SOURCE] = + trackingData.perpDiscoverySource; + } + if (trackingData?.hlFeeRate !== undefined) { + properties[PERPS_EVENT_PROPERTY.HL_FEE_RATE] = trackingData.hlFeeRate; + } + return properties; + } + + /** + * Emit a transaction event with status=submitted before the provider round-trip. + * Fired for trade, close, cancel and risk-management operations. + * + * @param event - The analytics event name to emit. + * @param properties - Additional event properties (asset, attribution, etc.). + */ + #trackSubmitted( + event: PerpsAnalyticsEvent, + properties: PerpsAnalyticsProperties, + ): void { + this.#deps.metrics.trackPerpsEvent(event, { + [PERPS_EVENT_PROPERTY.STATUS]: PERPS_EVENT_VALUE.STATUS.SUBMITTED, + ...properties, + }); + } + + /** + * Track order result analytics event (success or failure) + * + * @param options - The configuration options. + * @param options.result - The transaction result to check. + * @param options.error - The error that occurred. + * @param options.params - The operation parameters. + * @param options.context - The service context for dependencies. + * @param options.duration - Optional time duration. + */ + #trackOrderResult(options: { + result: OrderResult | null; + error?: Error; + params: OrderParams; + context: ServiceContext; + duration: number; + }): void { + const { result, error, params, duration } = options; + + const status = + result?.success === true + ? PERPS_EVENT_VALUE.STATUS.EXECUTED + : PERPS_EVENT_VALUE.STATUS.FAILED; + + // Build base properties + const properties: PerpsAnalyticsProperties = { + [PERPS_EVENT_PROPERTY.STATUS]: status, + [PERPS_EVENT_PROPERTY.ASSET]: params.symbol, + [PERPS_EVENT_PROPERTY.DIRECTION]: params.isBuy + ? PERPS_EVENT_VALUE.DIRECTION.LONG + : PERPS_EVENT_VALUE.DIRECTION.SHORT, + [PERPS_EVENT_PROPERTY.ORDER_TYPE]: params.orderType, + [PERPS_EVENT_PROPERTY.LEVERAGE]: parseFloat(String(params.leverage ?? 1)), + [PERPS_EVENT_PROPERTY.ORDER_SIZE]: parseFloat( + result?.filledSize ?? params.size, + ), + [PERPS_EVENT_PROPERTY.COMPLETION_DURATION]: duration, + }; + + // Add optional properties + if (params.trackingData?.marginUsed !== undefined) { + properties[PERPS_EVENT_PROPERTY.MARGIN_USED] = + params.trackingData.marginUsed; + } + if (params.trackingData?.totalFee !== undefined) { + properties[PERPS_EVENT_PROPERTY.FEES] = params.trackingData.totalFee; + } + if (result?.averagePrice ?? params.trackingData?.marketPrice) { + properties[PERPS_EVENT_PROPERTY.ASSET_PRICE] = result?.averagePrice + ? parseFloat(result.averagePrice) + : params.trackingData?.marketPrice; + } + // Trigger limit placements carry a real limit price too, so the companion + // property must not go missing when order_type is stop_limit/take_profit_limit. + if (isLimitExecutionOrderType(params.orderType) && params.price) { + properties[PERPS_EVENT_PROPERTY.LIMIT_PRICE] = parseFloat(params.price); + } + if (params.trackingData?.source) { + properties[PERPS_EVENT_PROPERTY.SOURCE] = params.trackingData.source; + } + if (params.trackingData?.chartLibrary) { + properties[PERPS_EVENT_PROPERTY.CHART_LIBRARY] = + params.trackingData.chartLibrary; + } + if (params.trackingData?.tradeAction) { + properties[PERPS_EVENT_PROPERTY.ACTION] = params.trackingData.tradeAction; + } + // Pay with any token: trade_with_token (boolean); when true, include mm_pay_token_selected and mm_pay_network_selected; when false (Perps balance), include mm_pay_token_selected: "Perps Balance" + properties[PERPS_EVENT_PROPERTY.TRADE_WITH_TOKEN] = + params.trackingData?.tradeWithToken === true; + if (params.trackingData?.tradeWithToken === true) { + if (params.trackingData.mmPayTokenSelected !== undefined) { + properties[PERPS_EVENT_PROPERTY.MM_PAY_TOKEN_SELECTED] = + params.trackingData.mmPayTokenSelected; + } + if (params.trackingData.mmPayNetworkSelected !== undefined) { + properties[PERPS_EVENT_PROPERTY.MM_PAY_NETWORK_SELECTED] = + params.trackingData.mmPayNetworkSelected; + } + } else if (params.trackingData !== undefined) { + properties[PERPS_EVENT_PROPERTY.MM_PAY_TOKEN_SELECTED] = + PERPS_EVENT_VALUE.MM_PAY_TOKEN.PERPS_BALANCE; + } + + // Calculate order value in USD (size * price) + const orderSize = parseFloat(result?.filledSize ?? params.size); + const assetPrice = result?.averagePrice + ? parseFloat(result.averagePrice) + : params.trackingData?.marketPrice; + if (assetPrice && orderSize) { + properties[PERPS_EVENT_PROPERTY.ORDER_VALUE] = orderSize * assetPrice; + } + + // Add success-specific properties + if (status === PERPS_EVENT_VALUE.STATUS.EXECUTED) { + if (params.trackingData?.metamaskFee !== undefined) { + properties[PERPS_EVENT_PROPERTY.METAMASK_FEE] = + params.trackingData.metamaskFee; + } + if (params.trackingData?.metamaskFeeRate !== undefined) { + properties[PERPS_EVENT_PROPERTY.METAMASK_FEE_RATE] = + params.trackingData.metamaskFeeRate; + } + if (params.trackingData?.feeDiscountPercentage !== undefined) { + properties[PERPS_EVENT_PROPERTY.DISCOUNT_PERCENTAGE] = + params.trackingData.feeDiscountPercentage; + } + if (params.trackingData?.estimatedPoints !== undefined) { + properties[PERPS_EVENT_PROPERTY.ESTIMATED_REWARDS] = + params.trackingData.estimatedPoints; + } + if (params.takeProfitPrice) { + properties[PERPS_EVENT_PROPERTY.TAKE_PROFIT_PRICE] = parseFloat( + params.takeProfitPrice, + ); + } + if (params.stopLossPrice) { + properties[PERPS_EVENT_PROPERTY.STOP_LOSS_PRICE] = parseFloat( + params.stopLossPrice, + ); + } + } else { + // Add failure-specific properties + properties[PERPS_EVENT_PROPERTY.ERROR_MESSAGE] = + error?.message ?? result?.error ?? 'Unknown error'; + } + + if (params.trackingData?.vipTier !== undefined) { + properties[PERPS_EVENT_PROPERTY.VIP_TIER] = params.trackingData.vipTier; + } + if (params.trackingData?.vipDiscount !== undefined) { + properties[PERPS_EVENT_PROPERTY.VIP_DISCOUNT] = + params.trackingData.vipDiscount; + } + + if ( + params.trackingData?.abTests && + Object.keys(params.trackingData.abTests).length > 0 + ) { + properties[PERPS_EVENT_PROPERTY.AB_TESTS] = params.trackingData.abTests; + } + + // Propagate discovery attribution + hl_fee_rate + Object.assign( + properties, + this.#buildAttributionProperties(params.trackingData), + ); + + // Emit an additional partially filled trade event when the fill is partial, + // mirroring the close path so the fill's partiality is visible in analytics + // rather than hidden behind a status=executed event. Classification is based + // on the provider's final submitted size (post precision rounding, USD + // recalculation, and $10-minimum retry), not the caller's pre-normalization + // params.size — the provider transforms the size before submission and a + // complete fill of the normalized size must not look partial. When the + // provider did not report a submitted size we do not classify (rather than + // guess from params.size). The partial event mirrors the close schema: + // order_size = submitted size, amount_filled = filled, remaining = the rest. + // Compare and subtract the decimal size strings with arbitrary-precision + // math (BigNumber): routing them through parseFloat can introduce + // binary-float artifacts that collapse distinct values (misclassifying the + // fill) or leave e-17 dust in remaining_amount. Only convert to Number for + // the emitted analytics values, after the exact decimal subtraction. + const submittedSize = + result?.submittedSize === undefined + ? undefined + : new BigNumber(result.submittedSize); + const filledSize = + result?.filledSize === undefined + ? undefined + : new BigNumber(result.filledSize); + if ( + result?.success === true && + submittedSize !== undefined && + filledSize !== undefined && + submittedSize.isFinite() && + filledSize.isFinite() && + filledSize.gt(0) && + filledSize.lt(submittedSize) + ) { + this.#deps.metrics.trackPerpsEvent(PerpsAnalyticsEvent.TradeTransaction, { + ...properties, + [PERPS_EVENT_PROPERTY.STATUS]: + PERPS_EVENT_VALUE.STATUS.PARTIALLY_FILLED, + [PERPS_EVENT_PROPERTY.ORDER_SIZE]: submittedSize.toNumber(), + [PERPS_EVENT_PROPERTY.AMOUNT_FILLED]: filledSize.toNumber(), + [PERPS_EVENT_PROPERTY.REMAINING_AMOUNT]: submittedSize + .minus(filledSize) + .toNumber(), + }); + } + + this.#deps.metrics.trackPerpsEvent( + PerpsAnalyticsEvent.TradeTransaction, + properties, + ); + } + + /** + * Handle successful order placement (state updates, analytics, data lake reporting) + * + * @param options - The configuration options. + * @param options.params - The operation parameters. + * @param options.context - The service context for dependencies. + * @param options.reportOrderToDataLake - The report order to data lake value. + */ + async #handleOrderSuccess(options: { + params: OrderParams; + context: ServiceContext; + reportOrderToDataLake: (params: { + action: 'open' | 'close'; + symbol: string; + slPrice?: number; + tpPrice?: number; + }) => Promise<{ success: boolean; error?: string }>; + }): Promise { + const { params, context, reportOrderToDataLake } = options; + + // Update state on success + if (context.stateManager) { + context.stateManager.update((state) => { + state.lastUpdateTimestamp = Date.now(); + }); + } + + // Save executed trade configuration for this market + if (params.leverage && context.saveTradeConfiguration) { + context.saveTradeConfiguration(params.symbol, params.leverage); + } + + // Report to data lake (fire-and-forget with retry) + reportOrderToDataLake({ + action: 'open', + symbol: params.symbol, + slPrice: params.stopLossPrice + ? parseFloat(params.stopLossPrice) + : undefined, + tpPrice: params.takeProfitPrice + ? parseFloat(params.takeProfitPrice) + : undefined, + }).catch((error) => { + this.#deps.logger.error( + ensureError(error, 'TradingService.handleOrderSuccess'), + { + tags: { + feature: PERPS_CONSTANTS.FeatureName, + provider: context.tracingContext.provider, + network: context.tracingContext.isTestnet ? 'testnet' : 'mainnet', + }, + context: { + name: context.errorContext.controller, + data: { + method: context.errorContext.method, + operation: 'reportOrderToDataLake', + symbol: params.symbol, + }, + }, + }, + ); + }); + } + + /** + * Execute a trading operation with fee discount context + * Ensures fee discount is always cleared after operation (success or failure) + * + * @param options - The configuration options. + * @param options.provider - The perps provider instance. + * @param options.feeResolution - The resolved fee and attribution source. + * @param options.operation - The operation value. + * @returns The result of the operation. + */ + async #withFeeDiscount(options: { + provider: PerpsProvider; + feeResolution?: PerpsFeeResolution; + operation: () => Promise; + }): Promise { + const { provider, feeResolution, operation } = options; + const previous = this.#feeContextTail; + let release: () => void = () => undefined; + this.#feeContextTail = new Promise((resolve) => { + release = resolve; + }); + await previous; + + try { + if (provider.setUserFeeResolution) { + provider.setUserFeeResolution(feeResolution); + } else if (provider.setUserFeeDiscount) { + provider.setUserFeeDiscount(feeResolution?.discountBips); + } + if (feeResolution) { + this.#deps.debugLogger.log( + 'TradingService: Fee resolution set in provider', + { + feeDiscountBips: feeResolution.discountBips, + feeSource: feeResolution.source, + }, + ); + } + + // Execute the operation + return await operation(); + } finally { + // Always clear discount context, even on exception + if (provider.setUserFeeResolution) { + provider.setUserFeeResolution(undefined); + } else if (provider.setUserFeeDiscount) { + provider.setUserFeeDiscount(undefined); + } + this.#deps.debugLogger.log( + 'TradingService: Fee resolution cleared from provider', + ); + release(); + } + } + + /** + * Place a new order with full orchestration + * Handles tracing, fee discounts, state management, analytics, and data lake reporting + * + * @param options - The configuration options. + * @param options.provider - The perps provider instance. + * @param options.params - The operation parameters. + * @param options.context - The service context for dependencies. + * @param options.reportOrderToDataLake - The report order to data lake value. + * @returns The result of the operation. + */ + async placeOrder(options: { + provider: PerpsProvider; + params: OrderParams; + context: ServiceContext; + reportOrderToDataLake: (params: { + action: 'open' | 'close'; + symbol: string; + slPrice?: number; + tpPrice?: number; + }) => Promise<{ success: boolean; error?: string }>; + }): Promise { + const { provider, params, context, reportOrderToDataLake } = options; + const traceId = uuidv4(); + const startTime = this.#deps.performance.now(); + let traceData: + | { + success: boolean; + error?: string; + orderId?: string; + reason?: 'error' | 'late_success' | 'late_error'; + } + | undefined; + let orderSubmissionThresholdTimeoutId: + | ReturnType + | undefined; + let didExceedOrderSubmissionThreshold = false; + + const paymentToken = + params.trackingData?.tradeWithToken === true + ? (params.trackingData.mmPayTokenSelected ?? 'unknown_token') + : 'perps_balance'; + + try { + this.#deps.tracer.addBreadcrumb({ + category: 'perps', + message: 'Order execution started', + level: 'info', + data: { + payment_token: paymentToken, + market: params.symbol, + orderType: params.orderType, + }, + }); + + // Start trace for the entire operation + this.#deps.tracer.trace({ + name: PerpsTraceNames.PlaceOrder, + id: traceId, + op: PerpsTraceOperations.OrderSubmission, + tags: { + provider: context.tracingContext.provider, + orderType: params.orderType, + market: params.symbol, + leverage: String(params.leverage ?? 1), + isTestnet: String(context.tracingContext.isTestnet), + payment_token: paymentToken, + }, + data: { + isBuy: params.isBuy, + orderPrice: params.price ?? '', + payment_token: paymentToken, + }, + }); + + // Calculate fee discount at execution time (fresh, secure) + const feeResolution = await this.#calculateFeeDiscountWithMeasurement(); + + this.#deps.debugLogger.log('TradingService: Fee resolution calculated', { + feeDiscountBips: feeResolution?.discountBips, + feeSource: feeResolution?.source, + hasDiscount: feeResolution?.discountBips !== undefined, + }); + + this.#deps.debugLogger.log( + 'TradingService: Submitting order to provider', + { + symbol: params.symbol, + orderType: params.orderType, + isBuy: params.isBuy, + size: params.size, + leverage: params.leverage, + hasTP: Boolean(params.takeProfitPrice), + hasSL: Boolean(params.stopLossPrice), + }, + ); + + // Emit submitted event before the provider round-trip + this.#trackSubmitted(PerpsAnalyticsEvent.TradeTransaction, { + [PERPS_EVENT_PROPERTY.ASSET]: params.symbol, + [PERPS_EVENT_PROPERTY.DIRECTION]: params.isBuy + ? PERPS_EVENT_VALUE.DIRECTION.LONG + : PERPS_EVENT_VALUE.DIRECTION.SHORT, + [PERPS_EVENT_PROPERTY.ORDER_TYPE]: params.orderType, + [PERPS_EVENT_PROPERTY.LEVERAGE]: parseFloat( + String(params.leverage ?? 1), + ), + ...this.#buildAttributionProperties(params.trackingData), + }); + + // Observational threshold: when the provider round-trip exceeds + // PlaceOrderTimeoutMs we tag the trace and emit a breadcrumb, but we + // intentionally do NOT cancel the in-flight order. Cancelling client-side + // (e.g. Promise.race rejection) does not stop the provider request, so a + // race-based timeout would let the UI mark an order as failed while + // HyperLiquid could still accept it. Instead, we always await + // provider.placeOrder(params) to terminal completion and surface + // late completions via trace `reason: 'late_success' | 'late_error'`. + orderSubmissionThresholdTimeoutId = setTimeout(() => { + didExceedOrderSubmissionThreshold = true; + this.#deps.tracer.addBreadcrumb({ + category: 'perps', + message: 'Order submission exceeded threshold (still pending)', + level: 'warning', + data: { + thresholdMs: PERPS_CONSTANTS.PlaceOrderTimeoutMs, + payment_token: paymentToken, + market: params.symbol, + orderType: params.orderType, + }, + }); + this.#deps.debugLogger.log( + 'TradingService: Order submission exceeded threshold (still pending)', + { + thresholdMs: PERPS_CONSTANTS.PlaceOrderTimeoutMs, + symbol: params.symbol, + orderType: params.orderType, + }, + ); + }, PERPS_CONSTANTS.PlaceOrderTimeoutMs); + const result = await this.#withFeeDiscount({ + provider, + feeResolution, + operation: () => provider.placeOrder(params), + }); + if (orderSubmissionThresholdTimeoutId !== undefined) { + clearTimeout(orderSubmissionThresholdTimeoutId); + orderSubmissionThresholdTimeoutId = undefined; + } + + this.#deps.debugLogger.log('TradingService: Provider response received', { + success: result.success, + orderId: result.orderId, + error: result.error, + didExceedOrderSubmissionThreshold, + }); + + // Update state and handle success/failure + const completionDuration = this.#deps.performance.now() - startTime; + + if (result.success) { + // Handle success: state updates, data lake reporting + await this.#handleOrderSuccess({ + params, + context, + reportOrderToDataLake, + }); + traceData = { + success: true, + orderId: result.orderId ?? '', + ...(didExceedOrderSubmissionThreshold + ? { reason: 'late_success' as const } + : {}), + }; + + // Invalidate standalone caches so external hooks (e.g., usePerpsPositionForAsset) refresh + this.#deps.cacheInvalidator.invalidate({ cacheType: 'positions' }); + this.#deps.cacheInvalidator.invalidate({ cacheType: 'accountState' }); + } else { + traceData = { + success: false, + reason: didExceedOrderSubmissionThreshold ? 'late_error' : 'error', + error: result.error ?? 'Unknown error', + }; + } + + // Track analytics (success or failure) + this.#trackOrderResult({ + result, + params, + context, + duration: completionDuration, + }); + + return result; + } catch (error) { + const completionDuration = this.#deps.performance.now() - startTime; + + // Track analytics for exception + this.#trackOrderResult({ + result: null, + error: error instanceof Error ? error : undefined, + params, + context, + duration: completionDuration, + }); + + // withFeeDiscount handles fee discount cleanup automatically + + this.#deps.logger.error(ensureError(error, 'TradingService.placeOrder'), { + tags: { + feature: PERPS_CONSTANTS.FeatureName, + provider: context.tracingContext.provider, + network: context.tracingContext.isTestnet ? 'testnet' : 'mainnet', + }, + context: { + name: context.errorContext.controller, + data: { + method: context.errorContext.method, + symbol: params.symbol, + orderType: params.orderType, + }, + }, + }); + + traceData = { + success: false, + reason: didExceedOrderSubmissionThreshold ? 'late_error' : 'error', + error: error instanceof Error ? error.message : 'Unknown error', + }; + throw error; + } finally { + if (orderSubmissionThresholdTimeoutId !== undefined) { + clearTimeout(orderSubmissionThresholdTimeoutId); + } + // Always end trace on exit (success or failure) + this.#deps.tracer.endTrace({ + name: PerpsTraceNames.PlaceOrder, + id: traceId, + data: traceData, + }); + } + } + + /** + * Load position data with performance measurement + * + * @param options - The configuration options. + * @param options.symbol - The trading pair symbol. + * @param options.context - The service context for dependencies. + * @returns The result of the operation. + */ + async #loadPositionData(options: { + symbol: string; + context: ServiceContext; + }): Promise { + const { symbol, context } = options; + + const positionLoadStart = this.#deps.performance.now(); + try { + const positions = context.getPositions + ? await context.getPositions() + : []; + const position = positions.find((pos) => pos.symbol === symbol); + + this.#deps.tracer.setMeasurement( + PerpsMeasurementName.PerpsGetPositionsOperation, + this.#deps.performance.now() - positionLoadStart, + 'millisecond', + ); + + return position; + } catch (error) { + this.#deps.debugLogger.log( + 'TradingService: Could not get position data for tracking', + error instanceof Error ? error.message : String(error), + ); + return undefined; + } + } + + /** + * Calculate close position metrics + * + * @param position - The position value. + * @param params - The operation parameters. + * @param result - The transaction result to check. + * @returns The result of the operation. + */ + #calculateCloseMetrics( + position: Position, + params: ClosePositionParams, + result: OrderResult, + ): { + direction: string; + closePercentage: number; + closeType: string; + orderType: string; + filledSize: number; + requestedSize: number; + isPartiallyFilled: boolean; + } { + const direction = + parseFloat(position.size) > 0 + ? PERPS_EVENT_VALUE.DIRECTION.LONG + : PERPS_EVENT_VALUE.DIRECTION.SHORT; + + const filledSize = result.filledSize ? parseFloat(result.filledSize) : 0; + const requestedSize = params.size + ? parseFloat(params.size) + : Math.abs(parseFloat(position.size)); + const isPartiallyFilled = filledSize > 0 && filledSize < requestedSize; + + const orderType = params.orderType ?? PERPS_EVENT_VALUE.ORDER_TYPE.MARKET; + const closePercentage = params.size + ? (parseFloat(params.size) / Math.abs(parseFloat(position.size))) * 100 + : 100; + const closeType = + closePercentage === 100 + ? PERPS_EVENT_VALUE.CLOSE_TYPE.FULL + : PERPS_EVENT_VALUE.CLOSE_TYPE.PARTIAL; + + return { + direction, + closePercentage, + closeType, + orderType, + filledSize, + requestedSize, + isPartiallyFilled, + }; + } + + /** + * Build event properties for position close analytics + * + * @param position - The position value. + * @param params - The operation parameters. + * @param metrics - The metrics value. + * @param metrics.direction - The sort direction. + * @param metrics.closePercentage - The close percentage value. + * @param metrics.closeType - The close type value. + * @param metrics.orderType - The order type value. + * @param metrics.requestedSize - The requested size value. + * @param result - The transaction result to check. + * @param status - The status value. + * @param error - The error that occurred. + * @returns The result of the operation. + */ + #buildCloseEventProperties( + position: Position, + params: ClosePositionParams, + metrics: { + direction: string; + closePercentage: number; + closeType: string; + orderType: string; + requestedSize: number; + }, + result: OrderResult | null, + status: string, + error?: string, + ): Record { + // Effective leverage = positionUSD / marginUSD, rounded to 1 decimal place. + // Computed from the live position rather than the configured leverage so it's + // populated for every close, including TP/SL triggers. + const positionUSD = Math.abs(parseFloat(position.positionValue)); + const marginUSD = parseFloat(position.marginUsed); + const effectiveLeverage = + Number.isFinite(positionUSD) && + Number.isFinite(marginUSD) && + marginUSD > 0 + ? Math.round((positionUSD / marginUSD) * 10) / 10 + : undefined; + + const baseProperties = { + [PERPS_EVENT_PROPERTY.STATUS]: status, + [PERPS_EVENT_PROPERTY.ASSET]: position.symbol, + [PERPS_EVENT_PROPERTY.DIRECTION]: metrics.direction, + [PERPS_EVENT_PROPERTY.ORDER_TYPE]: metrics.orderType, + [PERPS_EVENT_PROPERTY.ORDER_SIZE]: metrics.requestedSize, + [PERPS_EVENT_PROPERTY.OPEN_POSITION_SIZE]: Math.abs( + parseFloat(position.size), + ), + [PERPS_EVENT_PROPERTY.PERCENTAGE_CLOSED]: metrics.closePercentage, + ...(position.unrealizedPnl && { + [PERPS_EVENT_PROPERTY.PNL_DOLLAR]: parseFloat(position.unrealizedPnl), + }), + ...(position.returnOnEquity && { + [PERPS_EVENT_PROPERTY.PNL_PERCENT]: + parseFloat(position.returnOnEquity) * 100, + }), + ...(params.trackingData?.totalFee !== undefined && { + [PERPS_EVENT_PROPERTY.FEE]: params.trackingData.totalFee, + }), + ...(params.trackingData?.metamaskFee !== undefined && { + [PERPS_EVENT_PROPERTY.METAMASK_FEE]: params.trackingData.metamaskFee, + }), + ...(params.trackingData?.metamaskFeeRate !== undefined && { + [PERPS_EVENT_PROPERTY.METAMASK_FEE_RATE]: + params.trackingData.metamaskFeeRate, + }), + ...(params.trackingData?.feeDiscountPercentage !== undefined && { + [PERPS_EVENT_PROPERTY.DISCOUNT_PERCENTAGE]: + params.trackingData.feeDiscountPercentage, + }), + ...(params.trackingData?.estimatedPoints !== undefined && { + [PERPS_EVENT_PROPERTY.ESTIMATED_REWARDS]: + params.trackingData.estimatedPoints, + }), + ...((params.trackingData?.marketPrice ?? result?.averagePrice) && { + [PERPS_EVENT_PROPERTY.ASSET_PRICE]: result?.averagePrice + ? parseFloat(result.averagePrice) + : params.trackingData?.marketPrice, + }), + ...(params.orderType && + isLimitExecutionOrderType(params.orderType) && + params.price && { + [PERPS_EVENT_PROPERTY.LIMIT_PRICE]: parseFloat(params.price), + }), + ...(params.trackingData?.receivedAmount !== undefined && { + [PERPS_EVENT_PROPERTY.RECEIVED_AMOUNT]: + params.trackingData.receivedAmount, + }), + ...(params.trackingData?.source && { + [PERPS_EVENT_PROPERTY.SOURCE]: params.trackingData.source, + }), + ...(params.trackingData?.vipTier !== undefined && { + [PERPS_EVENT_PROPERTY.VIP_TIER]: params.trackingData.vipTier, + }), + ...(params.trackingData?.vipDiscount !== undefined && { + [PERPS_EVENT_PROPERTY.VIP_DISCOUNT]: params.trackingData.vipDiscount, + }), + // Effective leverage on close events + ...(effectiveLeverage !== undefined && { + [PERPS_EVENT_PROPERTY.LEVERAGE]: effectiveLeverage, + }), + // Discovery attribution + hl_fee_rate + ...this.#buildAttributionProperties(params.trackingData), + }; + + // Calculate and add order value in USD (size * price) + const closeAssetPrice = result?.averagePrice + ? parseFloat(result.averagePrice) + : params.trackingData?.marketPrice; + const orderValue = + closeAssetPrice && metrics.requestedSize + ? metrics.requestedSize * closeAssetPrice + : undefined; + + // Add success-specific properties + if (status === PERPS_EVENT_VALUE.STATUS.EXECUTED) { + return { + ...baseProperties, + [PERPS_EVENT_PROPERTY.CLOSE_TYPE]: metrics.closeType, + ...(orderValue !== undefined && { + [PERPS_EVENT_PROPERTY.ORDER_VALUE]: orderValue, + }), + }; + } + + // Add error for failures + return { + ...baseProperties, + ...(error && { [PERPS_EVENT_PROPERTY.ERROR_MESSAGE]: error }), + ...(orderValue !== undefined && { + [PERPS_EVENT_PROPERTY.ORDER_VALUE]: orderValue, + }), + }; + } + + /** + * Track position close result analytics (consolidates all tracking logic) + * + * @param options - The configuration options. + * @param options.position - The position value. + * @param options.result - The transaction result to check. + * @param options.error - The error that occurred. + * @param options.params - The operation parameters. + * @param options.context - The service context for dependencies. + * @param options.duration - Optional time duration. + * @param options.bulkActionId - Optional batch correlation id. + */ + #trackPositionCloseResult(options: { + position: Position | undefined; + result: OrderResult | null; + error?: Error; + params: ClosePositionParams; + context: ServiceContext; + duration: number; + bulkActionId?: string; + }): void { + const { position, result, error, params, duration, bulkActionId } = options; + + // Bulk action correlation id for batch close events + const bulkActionProps: PerpsAnalyticsProperties = bulkActionId + ? { [PERPS_EVENT_PROPERTY.BULK_ACTION_ID]: bulkActionId } + : {}; + + if (!position) { + // No local position record, yet closePosition already emitted a + // submitted event and the close may still complete at the provider. + // Emit a terminal (executed/failed) event so every submitted close has a + // matching outcome, even without position-derived metrics. + const status = + result?.success === true + ? PERPS_EVENT_VALUE.STATUS.EXECUTED + : PERPS_EVENT_VALUE.STATUS.FAILED; + const errorMessage = error?.message ?? result?.error; + + this.#deps.metrics.trackPerpsEvent( + PerpsAnalyticsEvent.PositionCloseTransaction, + { + [PERPS_EVENT_PROPERTY.STATUS]: status, + [PERPS_EVENT_PROPERTY.ASSET]: params.symbol, + [PERPS_EVENT_PROPERTY.ORDER_TYPE]: + params.orderType ?? PERPS_EVENT_VALUE.ORDER_TYPE.MARKET, + [PERPS_EVENT_PROPERTY.COMPLETION_DURATION]: duration, + ...(errorMessage && { + [PERPS_EVENT_PROPERTY.ERROR_MESSAGE]: errorMessage, + }), + ...this.#buildAttributionProperties(params.trackingData), + ...bulkActionProps, + }, + ); + return; + } + + const metrics = result + ? this.#calculateCloseMetrics(position, params, result) + : { + direction: + parseFloat(position.size) > 0 + ? PERPS_EVENT_VALUE.DIRECTION.LONG + : PERPS_EVENT_VALUE.DIRECTION.SHORT, + closePercentage: params.size + ? (parseFloat(params.size) / Math.abs(parseFloat(position.size))) * + 100 + : 100, + closeType: PERPS_EVENT_VALUE.CLOSE_TYPE.FULL, + orderType: params.orderType ?? PERPS_EVENT_VALUE.ORDER_TYPE.MARKET, + requestedSize: params.size + ? parseFloat(params.size) + : Math.abs(parseFloat(position.size)), + filledSize: 0, + isPartiallyFilled: false, + }; + + // Track partially filled event if applicable + if (result?.success && metrics.isPartiallyFilled) { + const partialProperties = this.#buildCloseEventProperties( + position, + params, + metrics, + result, + PERPS_EVENT_VALUE.STATUS.PARTIALLY_FILLED, + ); + + this.#deps.metrics.trackPerpsEvent( + PerpsAnalyticsEvent.PositionCloseTransaction, + { + ...partialProperties, + [PERPS_EVENT_PROPERTY.AMOUNT_FILLED]: metrics.filledSize, + [PERPS_EVENT_PROPERTY.REMAINING_AMOUNT]: + metrics.requestedSize - metrics.filledSize, + [PERPS_EVENT_PROPERTY.COMPLETION_DURATION]: duration, + ...bulkActionProps, + }, + ); + } + + // Determine status + const status = + result?.success === true + ? PERPS_EVENT_VALUE.STATUS.EXECUTED + : PERPS_EVENT_VALUE.STATUS.FAILED; + + const errorMessage = error?.message ?? result?.error; + + // Track main close event + const eventProperties = this.#buildCloseEventProperties( + position, + params, + metrics, + result, + status, + errorMessage, + ); + + this.#deps.metrics.trackPerpsEvent( + PerpsAnalyticsEvent.PositionCloseTransaction, + { + ...eventProperties, + [PERPS_EVENT_PROPERTY.COMPLETION_DURATION]: duration, + ...bulkActionProps, + }, + ); + } + + /** + * Handle data lake reporting (fire-and-forget) + * + * @param reportOrderToDataLake - The report order to data lake value. + * @param symbol - The trading pair symbol. + * @param context - The service context for dependencies. + */ + #handleDataLakeReporting( + reportOrderToDataLake: (params: { + action: 'open' | 'close'; + symbol: string; + }) => Promise<{ success: boolean; error?: string }>, + symbol: string, + context: ServiceContext, + ): void { + reportOrderToDataLake({ + action: 'close', + symbol, + }).catch((error) => { + this.#deps.logger.error( + ensureError(error, 'TradingService.handleDataLakeReporting'), + { + tags: { + feature: PERPS_CONSTANTS.FeatureName, + provider: context.tracingContext.provider, + network: context.tracingContext.isTestnet ? 'testnet' : 'mainnet', + }, + context: { + name: context.errorContext.controller, + data: { + method: context.errorContext.method, + operation: 'reportOrderToDataLake', + symbol, + }, + }, + }, + ); + }); + } + + /** + * Calculate fee discount with performance measurement + * Uses controller dependencies injected via setControllerDependencies() + * Helper method for placeOrder orchestration + * + * @returns The result of the operation. + */ + async #calculateFeeDiscountWithMeasurement(): Promise< + PerpsFeeResolution | undefined + > { + // Check if controller dependencies are available + if (!this.#controllerDeps) { + this.#deps.debugLogger.log( + 'TradingService: Controller dependencies not set, skipping fee discount', + ); + return undefined; + } + + const { rewardsIntegrationService } = this.#controllerDeps; + + const orderExecutionFeeDiscountStartTime = this.#deps.performance.now(); + + // Calculate fee discount using messenger pattern (service handles controller access internally) + const resolution = await rewardsIntegrationService.resolveFee(); + + const orderExecutionFeeDiscountDuration = + this.#deps.performance.now() - orderExecutionFeeDiscountStartTime; + + // Record measurement + this.#deps.tracer.setMeasurement( + PerpsMeasurementName.PerpsRewardsOrderExecutionFeeDiscountApiCall, + orderExecutionFeeDiscountDuration, + 'millisecond', + ); + + this.#deps.debugLogger.log( + 'TradingService: Fee discount API call completed', + { + discountBips: resolution.discountBips, + source: resolution.source, + duration: `${orderExecutionFeeDiscountDuration.toFixed(0)}ms`, + }, + ); + + return resolution; + } + + /** + * Edit an existing order with full orchestration + * Handles tracing, fee discounts, state management, and analytics + * + * @param options - The configuration options. + * @param options.provider - The perps provider instance. + * @param options.params - The operation parameters. + * @param options.context - The service context for dependencies. + * @returns The result of the operation. + */ + async editOrder(options: { + provider: PerpsProvider; + params: EditOrderParams; + context: ServiceContext; + }): Promise { + const { provider, params, context } = options; + const traceId = uuidv4(); + const startTime = this.#deps.performance.now(); + let traceData: + | { success: boolean; error?: string; orderId?: string } + | undefined; + + try { + this.#deps.tracer.trace({ + name: PerpsTraceNames.EditOrder, + id: traceId, + op: PerpsTraceOperations.OrderSubmission, + tags: { + provider: context.tracingContext.provider, + orderType: params.newOrder.orderType, + market: params.newOrder.symbol, + leverage: String(params.newOrder.leverage ?? 1), + isTestnet: String(context.tracingContext.isTestnet), + }, + data: { + isBuy: params.newOrder.isBuy, + orderPrice: params.newOrder.price ?? '', + }, + }); + + // Calculate fee discount only if required dependencies are available + const feeResolution = await this.#calculateFeeDiscountWithMeasurement(); + + // Execute order edit with fee discount management + const result = await this.#withFeeDiscount({ + provider, + feeResolution, + operation: () => provider.editOrder(params), + }); + + const completionDuration = this.#deps.performance.now() - startTime; + + if (result.success) { + // Update state on success + if (context.stateManager) { + context.stateManager.update((state) => { + state.lastUpdateTimestamp = Date.now(); + }); + } + + // Track order edit executed + const editExecutedProps: PerpsAnalyticsProperties = { + [PERPS_EVENT_PROPERTY.STATUS]: PERPS_EVENT_VALUE.STATUS.EXECUTED, + [PERPS_EVENT_PROPERTY.ASSET]: params.newOrder.symbol, + [PERPS_EVENT_PROPERTY.DIRECTION]: params.newOrder.isBuy + ? PERPS_EVENT_VALUE.DIRECTION.LONG + : PERPS_EVENT_VALUE.DIRECTION.SHORT, + [PERPS_EVENT_PROPERTY.ORDER_TYPE]: params.newOrder.orderType, + [PERPS_EVENT_PROPERTY.LEVERAGE]: params.newOrder.leverage ?? 1, + [PERPS_EVENT_PROPERTY.ORDER_SIZE]: params.newOrder.size, + [PERPS_EVENT_PROPERTY.COMPLETION_DURATION]: completionDuration, + }; + if (params.newOrder.price) { + editExecutedProps[PERPS_EVENT_PROPERTY.LIMIT_PRICE] = parseFloat( + params.newOrder.price, + ); + } + this.#deps.metrics.trackPerpsEvent( + PerpsAnalyticsEvent.TradeTransaction, + editExecutedProps, + ); + + traceData = { success: true, orderId: result.orderId ?? '' }; + } else { + // Track order edit failed + this.#deps.metrics.trackPerpsEvent( + PerpsAnalyticsEvent.TradeTransaction, + { + [PERPS_EVENT_PROPERTY.STATUS]: PERPS_EVENT_VALUE.STATUS.FAILED, + [PERPS_EVENT_PROPERTY.ASSET]: params.newOrder.symbol, + [PERPS_EVENT_PROPERTY.DIRECTION]: params.newOrder.isBuy + ? PERPS_EVENT_VALUE.DIRECTION.LONG + : PERPS_EVENT_VALUE.DIRECTION.SHORT, + [PERPS_EVENT_PROPERTY.ORDER_TYPE]: params.newOrder.orderType, + [PERPS_EVENT_PROPERTY.LEVERAGE]: params.newOrder.leverage ?? 1, + [PERPS_EVENT_PROPERTY.ORDER_SIZE]: params.newOrder.size, + [PERPS_EVENT_PROPERTY.COMPLETION_DURATION]: completionDuration, + [PERPS_EVENT_PROPERTY.ERROR_MESSAGE]: + result.error ?? 'Unknown error', + }, + ); + + traceData = { success: false, error: result.error ?? 'Unknown error' }; + } + + return result; + } catch (error) { + const completionDuration = this.#deps.performance.now() - startTime; + + // Track order edit exception + this.#deps.metrics.trackPerpsEvent(PerpsAnalyticsEvent.TradeTransaction, { + [PERPS_EVENT_PROPERTY.STATUS]: PERPS_EVENT_VALUE.STATUS.FAILED, + [PERPS_EVENT_PROPERTY.ASSET]: params.newOrder.symbol, + [PERPS_EVENT_PROPERTY.DIRECTION]: params.newOrder.isBuy + ? PERPS_EVENT_VALUE.DIRECTION.LONG + : PERPS_EVENT_VALUE.DIRECTION.SHORT, + [PERPS_EVENT_PROPERTY.ORDER_TYPE]: params.newOrder.orderType, + [PERPS_EVENT_PROPERTY.LEVERAGE]: params.newOrder.leverage ?? 1, + [PERPS_EVENT_PROPERTY.ORDER_SIZE]: params.newOrder.size, + [PERPS_EVENT_PROPERTY.COMPLETION_DURATION]: completionDuration, + [PERPS_EVENT_PROPERTY.ERROR_MESSAGE]: + error instanceof Error ? error.message : 'Unknown error', + }); + + this.#deps.logger.error(ensureError(error, 'TradingService.editOrder'), { + tags: { + feature: PERPS_CONSTANTS.FeatureName, + provider: context.tracingContext.provider, + network: context.tracingContext.isTestnet ? 'testnet' : 'mainnet', + }, + context: { + name: context.errorContext.controller, + data: { + method: context.errorContext.method, + orderId: params.orderId, + }, + }, + }); + + traceData = { + success: false, + error: error instanceof Error ? error.message : 'Unknown error', + }; + throw error; + } finally { + this.#deps.tracer.endTrace({ + name: PerpsTraceNames.EditOrder, + id: traceId, + data: traceData, + }); + } + } + + /** + * Cancel a single order with full orchestration + * Handles tracing, state management, and analytics + * + * @param options - The configuration options. + * @param options.provider - The perps provider instance. + * @param options.params - The operation parameters. + * @param options.context - The service context for dependencies. + * @param options.bulkActionId - Optional batch correlation id. + * @returns The result of the operation. + */ + async cancelOrder(options: { + provider: PerpsProvider; + params: CancelOrderParams; + context: ServiceContext; + bulkActionId?: string; + }): Promise { + const { provider, params, context, bulkActionId } = options; + const traceId = uuidv4(); + const startTime = this.#deps.performance.now(); + let traceData: + | { success: boolean; error?: string; orderId?: string } + | undefined; + + // Shared attribution + bulk correlation props + const cancelExtraProps: PerpsAnalyticsProperties = { + ...this.#buildAttributionProperties(params.trackingData), + ...(bulkActionId && { + [PERPS_EVENT_PROPERTY.BULK_ACTION_ID]: bulkActionId, + }), + }; + + try { + // Start trace for the entire operation + this.#deps.tracer.trace({ + name: PerpsTraceNames.CancelOrder, + id: traceId, + op: PerpsTraceOperations.OrderSubmission, + tags: { + provider: context.tracingContext.provider, + market: params.symbol, + isTestnet: String(context.tracingContext.isTestnet), + }, + data: { + orderId: params.orderId, + }, + }); + + // Emit submitted event before the provider round-trip + this.#trackSubmitted(PerpsAnalyticsEvent.OrderCancelTransaction, { + [PERPS_EVENT_PROPERTY.ASSET]: params.symbol, + ...cancelExtraProps, + }); + + // Execute order cancellation + const result = await provider.cancelOrder(params); + const completionDuration = this.#deps.performance.now() - startTime; + + if (result.success) { + // Update state on success + if (context.stateManager) { + context.stateManager.update((state) => { + state.lastUpdateTimestamp = Date.now(); + }); + } + + // Track order cancel executed + this.#deps.metrics.trackPerpsEvent( + PerpsAnalyticsEvent.OrderCancelTransaction, + { + [PERPS_EVENT_PROPERTY.STATUS]: PERPS_EVENT_VALUE.STATUS.EXECUTED, + [PERPS_EVENT_PROPERTY.ASSET]: params.symbol, + [PERPS_EVENT_PROPERTY.COMPLETION_DURATION]: completionDuration, + ...cancelExtraProps, + }, + ); + + traceData = { success: true, orderId: params.orderId }; + } else { + // Track order cancel failed + this.#deps.metrics.trackPerpsEvent( + PerpsAnalyticsEvent.OrderCancelTransaction, + { + [PERPS_EVENT_PROPERTY.STATUS]: PERPS_EVENT_VALUE.STATUS.FAILED, + [PERPS_EVENT_PROPERTY.ASSET]: params.symbol, + [PERPS_EVENT_PROPERTY.COMPLETION_DURATION]: completionDuration, + [PERPS_EVENT_PROPERTY.ERROR_MESSAGE]: + result.error ?? 'Unknown error', + ...cancelExtraProps, + }, + ); + + this.#deps.logger.error( + ensureError(result.error, 'TradingService.cancelOrder'), + this.#getErrorContext('cancelOrder', { + symbol: params.symbol, + orderId: params.orderId, + providerError: result.error ?? 'Unknown error', + }), + ); + + traceData = { success: false, error: result.error ?? 'Unknown error' }; + } + + return result; + } catch (error) { + const completionDuration = this.#deps.performance.now() - startTime; + + // Track order cancel exception + this.#deps.metrics.trackPerpsEvent( + PerpsAnalyticsEvent.OrderCancelTransaction, + { + [PERPS_EVENT_PROPERTY.STATUS]: PERPS_EVENT_VALUE.STATUS.FAILED, + [PERPS_EVENT_PROPERTY.ASSET]: params.symbol, + [PERPS_EVENT_PROPERTY.COMPLETION_DURATION]: completionDuration, + [PERPS_EVENT_PROPERTY.ERROR_MESSAGE]: + error instanceof Error ? error.message : 'Unknown error', + ...cancelExtraProps, + }, + ); + + this.#deps.logger.error( + ensureError(error, 'TradingService.cancelOrder'), + this.#getErrorContext('cancelOrder', { symbol: params.symbol }), + ); + + traceData = { + success: false, + error: error instanceof Error ? error.message : 'Unknown error', + }; + throw error; + } finally { + this.#deps.tracer.endTrace({ + name: PerpsTraceNames.CancelOrder, + id: traceId, + data: traceData, + }); + } + } + + /** + * Cancel multiple orders with full orchestration + * Handles tracing, stream pausing, filtering, batch operations, and analytics + * + * @param options - The configuration options. + * @param options.provider - The perps provider instance. + * @param options.params - The operation parameters. + * @param options.context - The service context for dependencies. + * @param options.withStreamPause - The with stream pause value. + * @returns The result of the operation. + */ + async cancelOrders(options: { + provider: PerpsProvider; + params: CancelOrdersParams; + context: ServiceContext; + withStreamPause: ( + operation: () => Promise, + channels: string[], + ) => Promise; + }): Promise { + const { provider, params, context, withStreamPause } = options; + const traceId = uuidv4(); + // Correlation id linking every per-item event to the batch summary + const bulkActionId = uuidv4(); + const startTime = this.#deps.performance.now(); + let operationResult: CancelOrdersResult | null = null; + let operationError: Error | null = null; + + try { + // Start trace for batch operation + this.#deps.tracer.trace({ + name: PerpsTraceNames.CancelOrder, + id: traceId, + op: PerpsTraceOperations.OrderSubmission, + tags: { + provider: context.tracingContext.provider, + isBatch: 'true', + isTestnet: String(context.tracingContext.isTestnet), + }, + data: { + cancelAll: params.cancelAll ? 'true' : 'false', + symbolCount: params.symbols?.length ?? 0, + orderIdCount: params.orderIds?.length ?? 0, + }, + }); + + // Pause orders stream to prevent WebSocket updates during cancellation + operationResult = await withStreamPause(async () => { + // Get all open orders + if (!context.getOpenOrders) { + throw new Error('getOpenOrders callback not provided in context'); + } + const orders = await context.getOpenOrders(); + + // Filter orders based on params + let ordersToCancel = orders; + if ( + params.cancelAll === true || + (!params.symbols && !params.orderIds) + ) { + // Cancel all orders (excluding TP/SL orders for positions) + ordersToCancel = orders.filter( + (order) => !isTPSLOrder(order.detailedOrderType), + ); + } else if (params.orderIds && params.orderIds.length > 0) { + // Cancel specific order IDs + ordersToCancel = orders.filter((order) => + params.orderIds?.includes(order.orderId), + ); + } else if (params.symbols && params.symbols.length > 0) { + // Cancel orders for specific symbols + ordersToCancel = orders.filter((order) => + params.symbols?.includes(order.symbol), + ); + } + + if (ordersToCancel.length === 0) { + return { + success: false, + successCount: 0, + failureCount: 0, + results: [], + }; + } + + // Use batch cancel if provider supports it + if (provider.cancelOrders) { + return await provider.cancelOrders( + ordersToCancel.map((order) => ({ + symbol: order.symbol, + orderId: order.orderId, + })), + ); + } + + // Fallback: Cancel orders in parallel (for providers without batch support) + const results = await Promise.allSettled( + ordersToCancel.map((order) => + this.cancelOrder({ + provider, + params: { symbol: order.symbol, orderId: order.orderId }, + context, + bulkActionId, + }), + ), + ); + + // Aggregate results + const successCount = results.filter( + (res) => res.status === 'fulfilled' && res.value.success, + ).length; + const failureCount = results.length - successCount; + + return { + success: successCount > 0, + successCount, + failureCount, + results: results.map((result, index) => { + let error: string | undefined; + if (result.status === 'rejected') { + error = + result.reason instanceof Error + ? result.reason.message + : 'Unknown error'; + } else if (result.status === 'fulfilled' && !result.value.success) { + error = result.value.error; + } + + return { + orderId: ordersToCancel[index].orderId, + symbol: ordersToCancel[index].symbol, + success: Boolean( + result.status === 'fulfilled' && result.value.success, + ), + error, + }; + }), + }; + }, ['orders']); // Disconnect orders stream during operation + + if ( + provider.cancelOrders && + operationResult && + operationResult.failureCount > 0 + ) { + const failureSummary = operationResult.results + .filter((result) => !result.success) + .map( + (result) => + `${result.symbol}/${result.orderId}: ${result.error ?? 'Unknown error'}`, + ) + .join('; '); + + this.#deps.logger.error( + new Error( + `cancelOrders batch failure: ${operationResult.failureCount}/${operationResult.results.length} failed - ${failureSummary}`, + ), + this.#getErrorContext('cancelOrders', { + successCount: operationResult.successCount, + failureCount: operationResult.failureCount, + cancelAll: params.cancelAll, + }), + ); + } + + return operationResult; + } catch (error) { + operationError = + error instanceof Error ? error : new Error(String(error)); + this.#deps.logger.error( + ensureError(error, 'TradingService.cancelOrders'), + this.#getErrorContext('cancelOrders'), + ); + throw error; + } finally { + const completionDuration = this.#deps.performance.now() - startTime; + + // Track batch cancel event (success or failure) + const batchCancelProps: PerpsAnalyticsProperties = { + [PERPS_EVENT_PROPERTY.STATUS]: + operationResult?.success && operationResult.successCount > 0 + ? PERPS_EVENT_VALUE.STATUS.EXECUTED + : PERPS_EVENT_VALUE.STATUS.FAILED, + [PERPS_EVENT_PROPERTY.COMPLETION_DURATION]: completionDuration, + [PERPS_EVENT_PROPERTY.BULK_ACTION_ID]: bulkActionId, + }; + if (operationError) { + batchCancelProps[PERPS_EVENT_PROPERTY.ERROR_MESSAGE] = + operationError.message; + } + this.#deps.metrics.trackPerpsEvent( + PerpsAnalyticsEvent.OrderCancelTransaction, + batchCancelProps, + ); + + this.#deps.tracer.endTrace({ + name: PerpsTraceNames.CancelOrder, + id: traceId, + }); + } + } + + /** + * Close a single position with full orchestration + * Handles tracing, fee discounts, state management, analytics, and data lake reporting + * + * @param options - The configuration options. + * @param options.provider - The perps provider instance. + * @param options.params - The operation parameters. + * @param options.context - The service context for dependencies. + * @param options.reportOrderToDataLake - The report order to data lake value. + * @param options.bulkActionId - Optional batch correlation id. + * @returns The result of the operation. + */ + async closePosition(options: { + provider: PerpsProvider; + params: ClosePositionParams; + context: ServiceContext; + reportOrderToDataLake: (params: { + action: 'open' | 'close'; + symbol: string; + }) => Promise<{ success: boolean; error?: string }>; + bulkActionId?: string; + }): Promise { + const { provider, params, context, reportOrderToDataLake, bulkActionId } = + options; + const traceId = uuidv4(); + const startTime = this.#deps.performance.now(); + let position: Position | undefined; + let result: OrderResult | undefined; + let traceData: + | { success: boolean; error?: string; filledSize?: string } + | undefined; + + try { + this.#deps.tracer.trace({ + name: PerpsTraceNames.ClosePosition, + id: traceId, + op: PerpsTraceOperations.PositionManagement, + tags: { + provider: context.tracingContext.provider, + symbol: params.symbol, + closeSize: params.size ?? 'full', + isTestnet: String(context.tracingContext.isTestnet), + }, + }); + + // Load position data with measurement + position = await this.#loadPositionData({ + symbol: params.symbol, + context, + }); + + // Emit submitted event before the provider round-trip + this.#trackSubmitted(PerpsAnalyticsEvent.PositionCloseTransaction, { + [PERPS_EVENT_PROPERTY.ASSET]: params.symbol, + [PERPS_EVENT_PROPERTY.ORDER_TYPE]: + params.orderType ?? PERPS_EVENT_VALUE.ORDER_TYPE.MARKET, + ...this.#buildAttributionProperties(params.trackingData), + ...(bulkActionId && { + [PERPS_EVENT_PROPERTY.BULK_ACTION_ID]: bulkActionId, + }), + }); + + // Calculate fee discount with measurement + const feeResolution = await this.#calculateFeeDiscountWithMeasurement(); + + // Execute position close with fee discount management + result = await this.#withFeeDiscount({ + provider, + feeResolution, + operation: () => provider.closePosition(params), + }); + + const completionDuration = this.#deps.performance.now() - startTime; + + if (result.success) { + // Update state on success + if (context.stateManager) { + context.stateManager.update((state) => { + state.lastUpdateTimestamp = Date.now(); + }); + } + + // Report to data lake (fire-and-forget) + this.#handleDataLakeReporting( + reportOrderToDataLake, + params.symbol, + context, + ); + + traceData = { success: true, filledSize: result.filledSize ?? '' }; + + // Invalidate standalone caches so external hooks (e.g., usePerpsPositionForAsset) refresh + this.#deps.cacheInvalidator.invalidate({ cacheType: 'positions' }); + this.#deps.cacheInvalidator.invalidate({ cacheType: 'accountState' }); + } else { + traceData = { success: false, error: result.error ?? 'Unknown error' }; + + this.#deps.logger.error( + ensureError(result.error, 'TradingService.closePosition'), + this.#getErrorContext('closePosition', { + symbol: params.symbol, + providerError: result.error ?? 'Unknown error', + }), + ); + } + + // Track analytics (success or failure, includes partial fills) + this.#trackPositionCloseResult({ + position, + result, + params, + context, + duration: completionDuration, + bulkActionId, + }); + + return result; + } catch (error) { + const completionDuration = this.#deps.performance.now() - startTime; + + traceData = { + success: false, + error: error instanceof Error ? error.message : 'Unknown error', + }; + + // Track analytics for exception + this.#trackPositionCloseResult({ + position, + result: null, + error: error instanceof Error ? error : undefined, + params, + context, + duration: completionDuration, + bulkActionId, + }); + + this.#deps.logger.error( + ensureError(error, 'TradingService.closePosition'), + { + tags: { + feature: PERPS_CONSTANTS.FeatureName, + provider: context.tracingContext.provider, + network: context.tracingContext.isTestnet ? 'testnet' : 'mainnet', + }, + context: { + name: context.errorContext.controller, + data: { + method: context.errorContext.method, + symbol: params.symbol, + }, + }, + }, + ); + + throw error; + } finally { + // Always end trace on exit (success or failure) + this.#deps.tracer.endTrace({ + name: PerpsTraceNames.ClosePosition, + id: traceId, + data: traceData, + }); + } + } + + /** + * Close multiple positions with full orchestration + * Handles tracing, fee discounts, batch operations, and analytics + * + * @param options - The configuration options. + * @param options.provider - The perps provider instance. + * @param options.params - The operation parameters. + * @param options.context - The service context for dependencies. + * @returns The result of the operation. + */ + async closePositions(options: { + provider: PerpsProvider; + params: ClosePositionsParams; + context: ServiceContext; + }): Promise { + const { provider, params, context } = options; + const traceId = uuidv4(); + // Correlation id linking every per-item event to the batch summary + const bulkActionId = uuidv4(); + const startTime = this.#deps.performance.now(); + let operationResult: ClosePositionsResult | null = null; + let operationError: Error | null = null; + + try { + // Start trace for batch operation + this.#deps.tracer.trace({ + name: PerpsTraceNames.ClosePosition, + id: traceId, + op: PerpsTraceOperations.PositionManagement, + tags: { + provider: context.tracingContext.provider, + isBatch: 'true', + isTestnet: String(context.tracingContext.isTestnet), + }, + data: { + closeAll: params.closeAll ? 'true' : 'false', + symbolCount: params.symbols?.length ?? 0, + }, + }); + + this.#deps.debugLogger.log('[closePositions] Batch method check', { + providerType: provider.protocolId, + providerKeys: Object.keys(provider).filter((key) => + key.includes('close'), + ), + }); + + // Use batch close if provider supports it (provider handles filtering) + if (provider.closePositions) { + const feeResolution = await this.#calculateFeeDiscountWithMeasurement(); + + operationResult = await this.#withFeeDiscount({ + provider, + feeResolution, + operation: async () => { + if (!provider.closePositions) { + throw new Error('closePositions method not available'); + } + return provider.closePositions(params); + }, + }); + } else { + // Fallback: Get positions, filter, and close in parallel + if (!context.getPositions) { + throw new Error('getPositions callback not provided in context'); + } + const positions = await context.getPositions(); + + const positionsToClose = + params.closeAll === true || + !params.symbols || + params.symbols.length === 0 + ? positions + : positions.filter((pos) => params.symbols?.includes(pos.symbol)); + + if (positionsToClose.length === 0) { + operationResult = { + success: false, + successCount: 0, + failureCount: 0, + results: [], + }; + return operationResult; + } + + const results = await Promise.allSettled( + positionsToClose.map((position) => + this.closePosition({ + provider, + params: { symbol: position.symbol }, + context, + reportOrderToDataLake: () => Promise.resolve({ success: true }), // No-op for batch fallback + bulkActionId, + }), + ), + ); + + // Aggregate results + const successCount = results.filter( + (res) => res.status === 'fulfilled' && res.value.success, + ).length; + const failureCount = results.length - successCount; + + operationResult = { + success: successCount > 0, + successCount, + failureCount, + results: results.map((result, index) => { + let error: string | undefined; + if (result.status === 'rejected') { + error = + result.reason instanceof Error + ? result.reason.message + : 'Unknown error'; + } else if (result.status === 'fulfilled' && !result.value.success) { + error = result.value.error; + } + + return { + symbol: positionsToClose[index].symbol, + success: Boolean( + result.status === 'fulfilled' && result.value.success, + ), + error, + }; + }), + }; + } + + if ( + provider.closePositions && + operationResult && + operationResult.failureCount > 0 + ) { + const failureSummary = operationResult.results + .filter((result) => !result.success) + .map( + (result) => `${result.symbol}: ${result.error ?? 'Unknown error'}`, + ) + .join('; '); + + this.#deps.logger.error( + new Error( + `closePositions batch failure: ${operationResult.failureCount}/${operationResult.results.length} failed - ${failureSummary}`, + ), + this.#getErrorContext('closePositions', { + successCount: operationResult.successCount, + failureCount: operationResult.failureCount, + symbols: params.symbols?.length ?? 0, + closeAll: params.closeAll, + }), + ); + } + + return operationResult; + } catch (error) { + operationError = + error instanceof Error ? error : new Error(String(error)); + this.#deps.logger.error( + ensureError(error, 'TradingService.closePositions'), + this.#getErrorContext('closePositions', { + symbols: params.symbols?.length ?? 0, + closeAll: params.closeAll, + }), + ); + throw error; + } finally { + const completionDuration = this.#deps.performance.now() - startTime; + + // Track batch close event (success or failure) + const batchCloseProps: PerpsAnalyticsProperties = { + [PERPS_EVENT_PROPERTY.STATUS]: + operationResult?.success && operationResult.successCount > 0 + ? PERPS_EVENT_VALUE.STATUS.EXECUTED + : PERPS_EVENT_VALUE.STATUS.FAILED, + [PERPS_EVENT_PROPERTY.COMPLETION_DURATION]: completionDuration, + [PERPS_EVENT_PROPERTY.BULK_ACTION_ID]: bulkActionId, + [PERPS_EVENT_PROPERTY.NUMBER_POSITIONS_CLOSED]: + operationResult?.successCount ?? 0, + }; + if (operationError) { + batchCloseProps[PERPS_EVENT_PROPERTY.ERROR_MESSAGE] = + operationError.message; + } + this.#deps.metrics.trackPerpsEvent( + PerpsAnalyticsEvent.PositionCloseTransaction, + batchCloseProps, + ); + + // Invalidate standalone caches on successful batch close + if (operationResult?.success && operationResult.successCount > 0) { + this.#deps.cacheInvalidator.invalidate({ cacheType: 'positions' }); + this.#deps.cacheInvalidator.invalidate({ cacheType: 'accountState' }); + } + + this.#deps.tracer.endTrace({ + name: PerpsTraceNames.ClosePosition, + id: traceId, + }); + } + } + + /** + * Update TP/SL for an existing position with full orchestration + * Handles tracing, fee discounts, state management, and analytics + * + * @param options - The configuration options. + * @param options.provider - The perps provider instance. + * @param options.params - The operation parameters. + * @param options.context - The service context for dependencies. + * @returns The result of the operation. + */ + async updatePositionTPSL(options: { + provider: PerpsProvider; + params: UpdatePositionTPSLParams; + context: ServiceContext; + }): Promise { + const { provider, params, context } = options; + const traceId = uuidv4(); + const startTime = this.#deps.performance.now(); + let traceData: { success: boolean; error?: string } | undefined; + let result: OrderResult | undefined; + let errorMessage: string | undefined; + + // Extract tracking data with defaults + const direction = params.trackingData?.direction; + const positionSize = params.trackingData?.positionSize; + const source = + params.trackingData?.source ?? PERPS_EVENT_VALUE.SOURCE.TP_SL_VIEW; + const takeProfitPercentage = params.trackingData?.takeProfitPercentage; + const stopLossPercentage = params.trackingData?.stopLossPercentage; + const isEditingExistingPosition = + params.trackingData?.isEditingExistingPosition ?? false; + + try { + this.#deps.tracer.trace({ + name: PerpsTraceNames.UpdateTpsl, + id: traceId, + op: PerpsTraceOperations.PositionManagement, + tags: { + provider: context.tracingContext.provider, + market: params.symbol, + isTestnet: String(context.tracingContext.isTestnet), + }, + data: { + takeProfitPrice: params.takeProfitPrice ?? '', + stopLossPrice: params.stopLossPrice ?? '', + }, + }); + + // Emit submitted event before the provider round-trip + this.#trackSubmitted(PerpsAnalyticsEvent.RiskManagement, { + [PERPS_EVENT_PROPERTY.ASSET]: params.symbol, + [PERPS_EVENT_PROPERTY.SOURCE]: source, + ...this.#buildAttributionProperties(params.trackingData), + }); + + // Get fee discount from rewards + const feeResolution = await this.#calculateFeeDiscountWithMeasurement(); + + // Execute with fee discount management + result = await this.#withFeeDiscount({ + provider, + feeResolution, + operation: () => provider.updatePositionTPSL(params), + }); + + if (result.success) { + // Update state on success + if (context.stateManager) { + context.stateManager.update((state) => { + state.lastUpdateTimestamp = Date.now(); + }); + } + traceData = { success: true }; + } else { + errorMessage = result.error ?? 'Unknown error'; + traceData = { success: false, error: errorMessage }; + } + + return result; + } catch (error) { + errorMessage = error instanceof Error ? error.message : 'Unknown error'; + traceData = { success: false, error: errorMessage }; + + this.#deps.logger.error( + ensureError(error, 'TradingService.updatePositionTPSL'), + this.#getErrorContext('updatePositionTPSL', { + symbol: params.symbol, + hasTakeProfit: Boolean(params.takeProfitPrice), + hasStopLoss: Boolean(params.stopLossPrice), + }), + ); + + throw error; + } finally { + const completionDuration = this.#deps.performance.now() - startTime; + + // Determine screen type based on whether editing existing position + const screenType = isEditingExistingPosition + ? PERPS_EVENT_VALUE.SCREEN_TYPE.EDIT_TPSL + : PERPS_EVENT_VALUE.SCREEN_TYPE.CREATE_TPSL; + + // Determine if TP/SL are set + const hasTakeProfit = Boolean(params.takeProfitPrice); + const hasStopLoss = Boolean(params.stopLossPrice); + + // Determine TP/SL action type + let tpslAction: string | undefined; + if (hasTakeProfit && hasStopLoss) { + tpslAction = PERPS_EVENT_VALUE.ACTION.TPSL; + } else if (hasTakeProfit) { + tpslAction = PERPS_EVENT_VALUE.ACTION.TP; + } else if (hasStopLoss) { + tpslAction = PERPS_EVENT_VALUE.ACTION.SL; + } + + // Build comprehensive event properties + const eventProperties = { + [PERPS_EVENT_PROPERTY.STATUS]: result?.success + ? PERPS_EVENT_VALUE.STATUS.EXECUTED + : PERPS_EVENT_VALUE.STATUS.FAILED, + [PERPS_EVENT_PROPERTY.ASSET]: params.symbol, + [PERPS_EVENT_PROPERTY.COMPLETION_DURATION]: completionDuration, + [PERPS_EVENT_PROPERTY.SOURCE]: source, + [PERPS_EVENT_PROPERTY.SCREEN_TYPE]: screenType, + [PERPS_EVENT_PROPERTY.HAS_TAKE_PROFIT]: hasTakeProfit, + [PERPS_EVENT_PROPERTY.HAS_STOP_LOSS]: hasStopLoss, + ...(tpslAction && { + [PERPS_EVENT_PROPERTY.ACTION]: tpslAction, + }), + ...(direction && { + [PERPS_EVENT_PROPERTY.DIRECTION]: + direction === 'long' + ? PERPS_EVENT_VALUE.DIRECTION.LONG + : PERPS_EVENT_VALUE.DIRECTION.SHORT, + }), + ...(positionSize !== undefined && { + [PERPS_EVENT_PROPERTY.POSITION_SIZE]: positionSize, + }), + ...(params.takeProfitPrice && { + [PERPS_EVENT_PROPERTY.TAKE_PROFIT_PRICE]: parseFloat( + params.takeProfitPrice, + ), + }), + ...(params.stopLossPrice && { + [PERPS_EVENT_PROPERTY.STOP_LOSS_PRICE]: parseFloat( + params.stopLossPrice, + ), + }), + ...(takeProfitPercentage !== undefined && { + [PERPS_EVENT_PROPERTY.TAKE_PROFIT_PERCENTAGE]: takeProfitPercentage, + }), + ...(stopLossPercentage !== undefined && { + [PERPS_EVENT_PROPERTY.STOP_LOSS_PERCENTAGE]: stopLossPercentage, + }), + ...(errorMessage && { + [PERPS_EVENT_PROPERTY.ERROR_MESSAGE]: errorMessage, + }), + // Discovery attribution + ...this.#buildAttributionProperties(params.trackingData), + }; + + // Track event once with all properties + this.#deps.metrics.trackPerpsEvent( + PerpsAnalyticsEvent.RiskManagement, + eventProperties, + ); + + this.#deps.tracer.endTrace({ + name: PerpsTraceNames.UpdateTpsl, + id: traceId, + data: traceData, + }); + } + } + + /** + * Update margin for an existing position (add or remove) + * + * @param options - The configuration options. + * @param options.provider - The perps provider instance. + * @param options.symbol - The trading pair symbol. + * @param options.amount - The amount value. + * @param options.context - The service context for dependencies. + * @returns The result of the operation. + */ + async updateMargin(options: { + provider: PerpsProvider; + symbol: string; + amount: string; + context: ServiceContext; + }): Promise<{ success: boolean; error?: string }> { + const { provider, symbol, amount, context } = options; + const traceId = uuidv4(); + const startTime = this.#deps.performance.now(); + + try { + this.#deps.tracer.trace({ + name: PerpsTraceNames.UpdateMargin, + id: traceId, + op: PerpsTraceOperations.PositionManagement, + tags: { + provider: context.tracingContext.provider, + symbol, + isAdd: String(parseFloat(amount) > 0), + isTestnet: String(context.tracingContext.isTestnet), + }, + }); + + // Call provider method + const result = await provider.updateMargin?.({ symbol, amount }); + + if (!result) { + throw new Error('Provider does not support margin adjustment'); + } + + const completionDuration = this.#deps.performance.now() - startTime; + + if (result.success) { + // Update state on success + if (context.stateManager) { + context.stateManager.update((state) => { + state.lastUpdateTimestamp = Date.now(); + }); + } + + // Track success analytics + this.#deps.metrics.trackPerpsEvent(PerpsAnalyticsEvent.RiskManagement, { + [PERPS_EVENT_PROPERTY.STATUS]: PERPS_EVENT_VALUE.STATUS.EXECUTED, + [PERPS_EVENT_PROPERTY.ASSET]: symbol, + [PERPS_EVENT_PROPERTY.ACTION]: + parseFloat(amount) > 0 ? 'add_margin' : 'remove_margin', + [PERPS_EVENT_PROPERTY.MARGIN_USED]: Math.abs(parseFloat(amount)), + [PERPS_EVENT_PROPERTY.COMPLETION_DURATION]: completionDuration, + }); + + // Invalidate standalone caches so external hooks refresh + this.#deps.cacheInvalidator.invalidate({ cacheType: 'positions' }); + this.#deps.cacheInvalidator.invalidate({ cacheType: 'accountState' }); + } else { + // Track failure analytics for a non-throwing provider failure so the + // terminal Risk Management event is emitted exactly once here (the + // thrown path below handles exceptions). + this.#deps.metrics.trackPerpsEvent(PerpsAnalyticsEvent.RiskManagement, { + [PERPS_EVENT_PROPERTY.STATUS]: PERPS_EVENT_VALUE.STATUS.FAILED, + [PERPS_EVENT_PROPERTY.ASSET]: symbol, + [PERPS_EVENT_PROPERTY.ACTION]: + parseFloat(amount) > 0 ? 'add_margin' : 'remove_margin', + [PERPS_EVENT_PROPERTY.MARGIN_USED]: Math.abs(parseFloat(amount)), + [PERPS_EVENT_PROPERTY.COMPLETION_DURATION]: completionDuration, + [PERPS_EVENT_PROPERTY.ERROR_MESSAGE]: result.error ?? 'Unknown error', + }); + } + + this.#deps.tracer.endTrace({ + name: PerpsTraceNames.UpdateMargin, + id: traceId, + data: { success: result.success, error: result.error ?? '' }, + }); + + return result; + } catch (error) { + const completionDuration = this.#deps.performance.now() - startTime; + const errorMessage = + error instanceof Error ? error.message : 'Unknown error'; + + this.#deps.logger.error( + ensureError(error, 'TradingService.updateMargin'), + this.#getErrorContext('updateMargin', { symbol, amount }), + ); + + // Track failure analytics + this.#deps.metrics.trackPerpsEvent(PerpsAnalyticsEvent.RiskManagement, { + [PERPS_EVENT_PROPERTY.STATUS]: PERPS_EVENT_VALUE.STATUS.FAILED, + [PERPS_EVENT_PROPERTY.ASSET]: symbol, + [PERPS_EVENT_PROPERTY.ACTION]: + parseFloat(amount) > 0 ? 'add_margin' : 'remove_margin', + [PERPS_EVENT_PROPERTY.MARGIN_USED]: Math.abs(parseFloat(amount)), + [PERPS_EVENT_PROPERTY.COMPLETION_DURATION]: completionDuration, + [PERPS_EVENT_PROPERTY.ERROR_MESSAGE]: errorMessage, + }); + + this.#deps.tracer.endTrace({ + name: PerpsTraceNames.UpdateMargin, + id: traceId, + data: { success: false, error: errorMessage }, + }); + + throw error; + } + } + + /** + * Flip position (reverse direction while keeping size and leverage) + * + * @param options - The configuration options. + * @param options.provider - The perps provider instance. + * @param options.position - The position data. + * @param options.trackingData - Optional tracking data for analytics events. + * @param options.context - The service context for dependencies. + * @returns The result of the operation. + */ + async flipPosition(options: { + provider: PerpsProvider; + position: Position; + trackingData?: TrackingData; + context: ServiceContext; + }): Promise { + const { provider, position, trackingData, context } = options; + const traceId = uuidv4(); + const startTime = this.#deps.performance.now(); + + try { + this.#deps.tracer.trace({ + name: PerpsTraceNames.FlipPosition, + id: traceId, + op: PerpsTraceOperations.PositionManagement, + tags: { + provider: context.tracingContext.provider, + symbol: position.symbol, + isTestnet: String(context.tracingContext.isTestnet), + }, + }); + + // Calculate flip parameters + const positionSize = Math.abs(parseFloat(position.size)); + const isCurrentlyLong = parseFloat(position.size) > 0; + const oppositeDirection = !isCurrentlyLong; + + const flipSize = positionSize * 2; + + // Direction-specific flip action, shared by the submitted and terminal events + const flipAction = isCurrentlyLong + ? PERPS_EVENT_VALUE.ACTION.FLIP_LONG_TO_SHORT + : PERPS_EVENT_VALUE.ACTION.FLIP_SHORT_TO_LONG; + + // Create order params for flip + // Use 2x position size: 1x to close current position + 1x to open opposite position. + // Do not pass the position entry price as currentPrice: the provider must fetch + // live market data for validation and IOC pricing. + const orderParams: OrderParams = { + symbol: position.symbol, + isBuy: oppositeDirection, + size: flipSize.toString(), + orderType: 'market', + leverage: position.leverage?.value, + }; + + // Emit submitted event before the provider round-trip, keeping flip + // trades aligned with the consolidated placeOrder pipeline. + this.#trackSubmitted(PerpsAnalyticsEvent.TradeTransaction, { + [PERPS_EVENT_PROPERTY.ASSET]: position.symbol, + [PERPS_EVENT_PROPERTY.DIRECTION]: oppositeDirection + ? PERPS_EVENT_VALUE.DIRECTION.LONG + : PERPS_EVENT_VALUE.DIRECTION.SHORT, + [PERPS_EVENT_PROPERTY.ORDER_TYPE]: 'market', + [PERPS_EVENT_PROPERTY.LEVERAGE]: position.leverage?.value || 1, + [PERPS_EVENT_PROPERTY.ORDER_SIZE]: positionSize, + [PERPS_EVENT_PROPERTY.ACTION]: flipAction, + ...this.#buildAttributionProperties(trackingData), + }); + + const feeResolution = await this.#calculateFeeDiscountWithMeasurement(); + // Place flip order (HyperLiquid handles margin transfer automatically) + const result = await this.#withFeeDiscount({ + provider, + feeResolution, + operation: () => provider.placeOrder(orderParams), + }); + + const completionDuration = this.#deps.performance.now() - startTime; + + const executedPrice = parseFloat( + result.averagePrice ?? position.entryPrice, + ); + + if (result.success) { + // Update state on success + if (context.stateManager) { + context.stateManager.update((state) => { + state.lastUpdateTimestamp = Date.now(); + }); + } + + // Track success analytics with direction-specific flip action + this.#deps.metrics.trackPerpsEvent( + PerpsAnalyticsEvent.TradeTransaction, + { + [PERPS_EVENT_PROPERTY.STATUS]: PERPS_EVENT_VALUE.STATUS.EXECUTED, + [PERPS_EVENT_PROPERTY.ASSET]: position.symbol, + [PERPS_EVENT_PROPERTY.DIRECTION]: oppositeDirection + ? PERPS_EVENT_VALUE.DIRECTION.LONG + : PERPS_EVENT_VALUE.DIRECTION.SHORT, + [PERPS_EVENT_PROPERTY.ORDER_TYPE]: 'market', + [PERPS_EVENT_PROPERTY.LEVERAGE]: position.leverage?.value || 1, + [PERPS_EVENT_PROPERTY.ORDER_SIZE]: positionSize, + [PERPS_EVENT_PROPERTY.COMPLETION_DURATION]: completionDuration, + [PERPS_EVENT_PROPERTY.ACTION]: flipAction, + [PERPS_EVENT_PROPERTY.ORDER_VALUE]: positionSize * executedPrice, + // MetaMask fee on flip trades + ...(trackingData?.metamaskFee !== undefined && { + [PERPS_EVENT_PROPERTY.METAMASK_FEE]: trackingData.metamaskFee, + }), + ...(trackingData?.vipTier !== undefined && { + [PERPS_EVENT_PROPERTY.VIP_TIER]: trackingData.vipTier, + }), + ...(trackingData?.vipDiscount !== undefined && { + [PERPS_EVENT_PROPERTY.VIP_DISCOUNT]: trackingData.vipDiscount, + }), + ...this.#buildAttributionProperties(trackingData), + }, + ); + + // Invalidate standalone caches so external hooks refresh + this.#deps.cacheInvalidator.invalidate({ cacheType: 'positions' }); + this.#deps.cacheInvalidator.invalidate({ cacheType: 'accountState' }); + } else { + // Provider rejected the flip without throwing: emit a terminal failed + // event so every submitted flip is paired with executed or failed. + this.#deps.metrics.trackPerpsEvent( + PerpsAnalyticsEvent.TradeTransaction, + { + [PERPS_EVENT_PROPERTY.STATUS]: PERPS_EVENT_VALUE.STATUS.FAILED, + [PERPS_EVENT_PROPERTY.ASSET]: position.symbol, + [PERPS_EVENT_PROPERTY.ACTION]: flipAction, + [PERPS_EVENT_PROPERTY.COMPLETION_DURATION]: completionDuration, + [PERPS_EVENT_PROPERTY.ERROR_MESSAGE]: + result.error ?? 'Unknown error', + ...(trackingData?.vipTier !== undefined && { + [PERPS_EVENT_PROPERTY.VIP_TIER]: trackingData.vipTier, + }), + ...(trackingData?.vipDiscount !== undefined && { + [PERPS_EVENT_PROPERTY.VIP_DISCOUNT]: trackingData.vipDiscount, + }), + ...this.#buildAttributionProperties(trackingData), + }, + ); + } + + this.#deps.tracer.endTrace({ + name: PerpsTraceNames.FlipPosition, + id: traceId, + data: { success: result.success ?? false, error: result.error ?? '' }, + }); + + return result; + } catch (error) { + const completionDuration = this.#deps.performance.now() - startTime; + const errorMessage = + error instanceof Error ? error.message : 'Unknown error'; + + this.#deps.logger.error( + ensureError(error, 'TradingService.flipPosition'), + this.#getErrorContext('flipPosition', { symbol: position.symbol }), + ); + + // Track failure analytics with direction-specific flip action + const wasLong = parseFloat(position.size) > 0; + const failFlipAction = wasLong + ? PERPS_EVENT_VALUE.ACTION.FLIP_LONG_TO_SHORT + : PERPS_EVENT_VALUE.ACTION.FLIP_SHORT_TO_LONG; + + this.#deps.metrics.trackPerpsEvent(PerpsAnalyticsEvent.TradeTransaction, { + [PERPS_EVENT_PROPERTY.STATUS]: PERPS_EVENT_VALUE.STATUS.FAILED, + [PERPS_EVENT_PROPERTY.ASSET]: position.symbol, + [PERPS_EVENT_PROPERTY.ACTION]: failFlipAction, + [PERPS_EVENT_PROPERTY.COMPLETION_DURATION]: completionDuration, + [PERPS_EVENT_PROPERTY.ERROR_MESSAGE]: errorMessage, + ...(trackingData?.vipTier !== undefined && { + [PERPS_EVENT_PROPERTY.VIP_TIER]: trackingData.vipTier, + }), + ...(trackingData?.vipDiscount !== undefined && { + [PERPS_EVENT_PROPERTY.VIP_DISCOUNT]: trackingData.vipDiscount, + }), + ...this.#buildAttributionProperties(trackingData), + }); + + this.#deps.tracer.endTrace({ + name: PerpsTraceNames.FlipPosition, + id: traceId, + data: { success: false, error: errorMessage }, + }); + + throw error; + } + } +} diff --git a/packages/perps-controller/src/types/config.ts b/packages/perps-controller/src/types/config.ts new file mode 100644 index 00000000000..533e4a77ab3 --- /dev/null +++ b/packages/perps-controller/src/types/config.ts @@ -0,0 +1,67 @@ +import type { CaipAssetId, CaipChainId, Hex } from '@metamask/utils'; + +// WebSocket endpoints interface +export type HyperLiquidEndpoints = { + mainnet: string; + testnet: string; +}; + +// Asset configuration interface +export type AssetNetworkConfig = { + mainnet: CaipAssetId; + testnet: CaipAssetId; +}; + +export type HyperLiquidAssetConfigs = { + usdc: AssetNetworkConfig; +}; + +// Bridge contract configuration interface +export type BridgeContractConfig = { + chainId: CaipChainId; + contractAddress: Hex; +}; + +export type HyperLiquidBridgeContracts = { + mainnet: BridgeContractConfig; + testnet: BridgeContractConfig; +}; + +// SDK transport configuration interface +export type TransportReconnectConfig = { + maxRetries: number; + connectionTimeout: number; +}; + +export type TransportKeepAliveConfig = { + interval: number; +}; + +export type HyperLiquidTransportConfig = { + timeout: number; + keepAlive: TransportKeepAliveConfig; + reconnect: TransportReconnectConfig; +}; + +// Trading configuration interface +export type TradingAmountConfig = { + mainnet: number; + testnet: number; +}; + +export type TradingDefaultsConfig = { + leverage: number; + marginPercent: number; + takeProfitPercent: number; + stopLossPercent: number; + amount: TradingAmountConfig; +}; + +// Fee configuration interface +export type FeeRatesConfig = { + taker: number; + maker: number; +}; + +// Network type helper +export type HyperLiquidNetwork = 'mainnet' | 'testnet'; diff --git a/packages/perps-controller/src/types/hyperliquid-types.ts b/packages/perps-controller/src/types/hyperliquid-types.ts new file mode 100644 index 00000000000..18bb3c3c5ea --- /dev/null +++ b/packages/perps-controller/src/types/hyperliquid-types.ts @@ -0,0 +1,107 @@ +/** + * HyperLiquid SDK Type Aliases + * + * The @nktkas/hyperliquid SDK only exports Response types (e.g., ClearinghouseStateResponse). + * We extract commonly-used nested types here to avoid repetitive type extraction syntax. + * + * Pattern: Import Response types, extract nested types using TypeScript index access. + * This is the SDK's intentional design - not bad practice! + */ +import type { + ClearinghouseStateResponse, + SpotClearinghouseStateResponse, + MetaResponse, + FrontendOpenOrdersResponse, + MetaAndAssetCtxsResponse, + AllMidsResponse, + PredictedFundingsResponse, + OrderParameters, + SpotMetaResponse, + UserAbstractionResponse, +} from '@nktkas/hyperliquid'; + +/** + * HL account abstraction mode returned by the `userAbstraction` info endpoint. + * Re-exported here to keep HL-specific types centralised. + * + * `unifiedAccount` / `portfolioMargin`: spot is unified with perps; + * `withdraw3` draws from the unified ledger, spot folds into perps collateral. + * + * `disabled` (Standard) / `dexAbstraction` (deprecated) / `default` (unset): + * spot and perps are separate ledgers; spot is NOT auto-collateral until the + * user is migrated to unified mode. + */ +export type HyperLiquidAbstractionMode = UserAbstractionResponse; + +/** + * Wire codes accepted by `agentSetAbstraction({ abstraction })`. The SDK + * types these as a `"i" | "u" | "p"` literal union with no exported constant. + * + * Only `unifiedAccount` is referenced by the current migration flow; the + * other entries document the full SDK wire format so a future caller + * (e.g. emergency rollback to `disabled`, or opting into `portfolioMargin`) + * does not have to re-discover the codes. + */ +export const HL_ABSTRACTION_WIRE = { + disabled: 'i', + unifiedAccount: 'u', + portfolioMargin: 'p', +} as const; + +/** + * Long-form abstraction-mode value targeted by the migration. Used as the + * `abstraction` parameter for `userSetAbstraction` and as the success / target + * value reported by Account Setup analytics. + */ +export const HL_UNIFIED_ACCOUNT_MODE = 'unifiedAccount' as const; + +/** + * True when the given HL abstraction mode treats spot USDC as perps collateral. + * Used by the provider + subscription service to gate `addSpotBalanceToAccountState`'s + * `foldIntoCollateral` option. + * + * Fail-CLOSED on missing mode: until userAbstraction has been resolved we do + * NOT fold spot, because over-reporting withdrawable funds for Standard / + * dexAbstraction users (which `withdraw3` cannot actually draw) is worse than + * briefly under-reporting for Unified users during the initial subscription + * window or a transient REST outage. + * + * @param mode - Abstraction mode from `userAbstraction` endpoint; null/undefined means unknown. + * @returns `true` when spot folds into spendable/withdrawable (Unified / Portfolio); `false` for Standard / DEX abstraction / unknown. + */ +export function hyperLiquidModeFoldsSpot( + mode?: HyperLiquidAbstractionMode | null, +): boolean { + if (mode === null || mode === undefined) { + return false; + } + return mode === 'unifiedAccount' || mode === 'portfolioMargin'; +} + +// Clearinghouse (Account) Types +export type AssetPosition = + ClearinghouseStateResponse['assetPositions'][number]; +export type SpotBalance = SpotClearinghouseStateResponse['balances'][number]; + +// Market/Asset Types +export type PerpsUniverse = MetaResponse['universe'][number]; +export type PerpsAssetCtx = MetaAndAssetCtxsResponse[1][number]; +export type PredictedFunding = PredictedFundingsResponse[number]; + +// Order Types +export type FrontendOrder = FrontendOpenOrdersResponse[number]; +export type SDKOrderParams = OrderParameters['orders'][number]; +export type OrderType = FrontendOrder['orderType']; + +// Re-export Response types for convenience +export type { + ClearinghouseStateResponse, + SpotClearinghouseStateResponse, + MetaResponse, + FrontendOpenOrdersResponse, + AllMidsResponse, + MetaAndAssetCtxsResponse, + PredictedFundingsResponse, + SpotMetaResponse, + UserAbstractionResponse, +}; diff --git a/packages/perps-controller/src/types/index.ts b/packages/perps-controller/src/types/index.ts new file mode 100644 index 00000000000..82e6e037d71 --- /dev/null +++ b/packages/perps-controller/src/types/index.ts @@ -0,0 +1,2634 @@ +import { hasProperty } from '@metamask/utils'; +import type { + CaipAccountId, + CaipChainId, + CaipAssetId, + Hex, +} from '@metamask/utils'; + +import type { CandlePeriod, TimeDuration } from '../constants/chartConfig.js'; +import type { CHASE_ORDER_STATUS } from '../constants/perpsConfig.js'; +import type { + CandleData, + OrderType, + StrategyOrderType, + TpslLinkage, + TriggerDirection, + TriggerOrderType, +} from './perps-types.js'; + +/** + * Connection states for WebSocket management. + * Defined inline to avoid importing from Mobile-only services. + * Must stay in sync with HyperLiquidClientService.WebSocketConnectionState. + */ +export enum WebSocketConnectionState { + Disconnected = 'disconnected', + Connecting = 'connecting', + Connected = 'connected', + Disconnecting = 'disconnecting', +} + +/** Provider-agnostic raw ledger update. Fields match the common shape across providers. */ +export type RawLedgerUpdate = { + hash: string; + time: number; + delta: { + type: string; + usdc?: string; + coin?: string; + }; +}; + +// User history item for deposits and withdrawals +export type UserHistoryItem = { + id: string; + timestamp: number; + type: 'deposit' | 'withdrawal'; + amount: string; + asset: string; + txHash: string; + status: 'completed' | 'failed' | 'pending'; + details: { + source: string; + bridgeContract?: string; + recipient?: string; + blockNumber?: string; + chainId?: string; + synthetic?: boolean; + }; +}; + +// Parameters for getting user history +export type GetUserHistoryParams = { + startTime?: number; + endTime?: number; + accountId?: CaipAccountId; +}; + +// Trade configuration saved per market per network +export type TradeConfiguration = { + leverage?: number; // Last used leverage for this market + // Pending trade configuration (temporary, expires after 30 seconds) + pendingConfig?: { + amount?: string; // Order size in USD + leverage?: number; // Leverage + takeProfitPrice?: string; // Take profit price + stopLossPrice?: string; // Stop loss price + limitPrice?: string; // Limit price (for limit orders) + orderType?: OrderType; // Market vs limit + reduceOnly?: boolean; // Whether the order may only reduce a position + timestamp: number; // When the config was saved (for expiration check) + }; +}; + +// Market asset type classification (reusable across components) +export enum MarketCategory { + CryptoCurrency = 'crypto', + Stock = 'stock', + PreIpo = 'pre-ipo', + Index = 'index', + Etf = 'etf', + Commodity = 'commodity', + Forex = 'forex', +} + +export type MarketType = `${MarketCategory}`; + +/** + * Metadata extracted from Terminal API for a single asset. + * Used downstream to enrich PerpsMarketData with name, keywords, tags, etc. + */ +export type TerminalAssetMetadata = { + name?: string; + description?: string; + keywords?: string[]; + tags?: string[]; + categories?: string[]; + marketType?: MarketType; + /** + * Epoch ms when this market was listed on the Terminal backend. + * Normalized from the raw API value (number or ISO string). + */ + listedAt?: number; +}; + +// Market type filter for UI category badges +export type MarketTypeFilter = + | 'all' + | 'crypto' + | 'stock' + | 'pre-ipo' + | 'index' + | 'etf' + | 'commodity' + | 'forex' + | 'new'; + +/** + * Ordered list of the 7 data-model market categories for UI pills. + * Does not include the 'all' or 'new' sentinel values — those are applied + * via dedicated UI controls, not the category pills. + * Kept in sync with {@link MarketTypeFilter} via `satisfies`. + */ +export const MARKET_CATEGORIES = [ + 'crypto', + 'stock', + 'pre-ipo', + 'index', + 'etf', + 'commodity', + 'forex', +] as const satisfies MarketTypeFilter[]; + +// Input method for amount entry tracking +export type InputMethod = + | 'default' + | 'slider' + | 'keypad' + | 'percentage' + | 'max'; + +// Trade action type - differentiates first trade on a market, adding to an +// existing position, and flipping a position's direction +export type TradeAction = + | 'create_position' + | 'increase_exposure' + | 'flip_long_to_short' + | 'flip_short_to_long'; + +// Unified tracking data interface for analytics events (never persisted in state) +// Note: Numeric values are already parsed by hooks (usePerpsOrderFees, etc.) from API responses +export type TrackingData = { + // Common to all operations + totalFee: number; // Total fee for the operation (parsed by hooks) + marketPrice: number; // Market price at operation time (parsed by hooks) + metamaskFee?: number; // MetaMask fee amount (parsed by hooks) + metamaskFeeRate?: number; // MetaMask fee rate (parsed by hooks) + feeDiscountPercentage?: number; // Fee discount percentage (parsed by hooks) + estimatedPoints?: number; // Estimated reward points (parsed by hooks) + + // Order-specific (used for trade operations) + marginUsed?: number; // Margin required for this order (calculated by hooks) + inputMethod?: InputMethod; // How user set the amount + tradeAction?: TradeAction; // 'create_position' for first trade, 'increase_exposure' for adding to existing + + // Close-specific (used for position close operations) + receivedAmount?: number; // Amount user receives after close (calculated by hooks) + realizedPnl?: number; // Realized P&L from close (calculated by hooks) + + // Entry source for analytics (e.g., 'trending' for Trending page discovery) + source?: string; + // Chart library active when the trade was initiated (e.g., lightweight, advanced) + chartLibrary?: string; + + // Entry point / discovery attribution. Propagated onto trade/close/ + // cancel/risk events as entry_point, discovery_source, perp_discovery_source. + entryPoint?: string; + discoverySource?: string; + perpDiscoverySource?: string; + + // HyperLiquid protocol fee rate. Emitted as hl_fee_rate on trade + + // close events when present; omitted entirely when unavailable. + hlFeeRate?: number; + + // Pay with any token: true when user paid with a custom token (not Perps balance) + tradeWithToken?: boolean; + mmPayTokenSelected?: string; // Token symbol when tradeWithToken is true + mmPayNetworkSelected?: string; // chainId when tradeWithToken is true + + // VIP tier and discount for rewards tracking + vipTier?: number; // User's VIP tier level + vipDiscount?: number; // VIP discount percentage applied + + // A/B test context to attribute trade events to specific experiments + abTests?: Record; +}; + +// TP/SL-specific tracking data for analytics events +export type TPSLTrackingData = { + direction: 'long' | 'short'; // Position direction + /** + * @deprecated Source of the TP/SL update (e.g., 'tp_sl_view', 'position_card'). + * Prefer `entryPoint` / `discoverySource` / `perpDiscoverySource` for risk-event + * attribution; `source` is retained for backward compatibility. + */ + source: string; + positionSize: number; // Unsigned position size for metrics + takeProfitPercentage?: number; // Take profit percentage from entry + stopLossPercentage?: number; // Stop loss percentage from entry + isEditingExistingPosition?: boolean; // true = editing existing position, false = creating for new order + entryPrice?: number; // Entry price for percentage calculations + + // Entry point / discovery attribution, propagated onto risk events. + entryPoint?: string; + discoverySource?: string; + perpDiscoverySource?: string; +}; + +// MetaMask Perps API order parameters for PerpsController +export type OrderParams = { + symbol: string; // Asset identifier (e.g., 'ETH', 'BTC', 'xyz:TSLA') + isBuy: boolean; // true = BUY order, false = SELL order + size: string; // Order size as string (derived for validation, provider recalculates from usdAmount) + orderType: OrderType; // Order type + price?: string; // Limit price (required for limit orders) + reduceOnly?: boolean; // Reduce-only flag + isFullClose?: boolean; // Indicates closing 100% of position (skips $10 minimum validation) + timeInForce?: 'GTC' | 'IOC' | 'ALO'; // Time in force for plain limit orders + + // USD as source of truth (hybrid approach) + usdAmount?: string; // USD amount (primary source of truth, provider calculates size from this) + priceAtCalculation?: number; // Price snapshot when size was calculated (for slippage validation) + maxSlippageBps?: number; // Slippage tolerance in basis points (e.g., 100 = 1%, default if not provided) + /** + * @deprecated Use `maxSlippageBps` instead. Retained for one release so that + * existing publisher consumers (extension, core) that still pass slippage as + * a decimal (e.g. 0.03 for 3%) continue to work; the provider normalizes the + * value to basis points when `maxSlippageBps` is absent. + */ + slippage?: number; + + // Trigger placement (stop_market, stop_limit, take_profit_market, take_profit_limit). + // Required for those order types and rejected for market/limit orders. + triggerPrice?: string; // Price at which the resting order activates + + // Strategy placement (twap, scale, chase). Each group below is required for + // its own order type and rejected on every other one, so a stray field can + // never be silently dropped. Strategy orders carry no `price`, `triggerPrice`, + // `timeInForce` or attached TP/SL — the strategy owns its own execution. + twapDuration?: number; // TWAP window in whole minutes; each provider enforces its own venue's bounds + twapRandomize?: boolean; // Randomize each TWAP suborder's size by up to ±20% (default false) + scaleMinPrice?: string; // Lowest limit price in the scale ladder + scaleMaxPrice?: string; // Highest limit price in the scale ladder; must exceed scaleMinPrice + scaleNumOrders?: number; // How many limit orders to spread across the ladder (2..20) + /** + * How the ladder's size is weighted across its rungs. Rung weights ramp + * linearly from 1 at `scaleMinPrice` to this value at `scaleMaxPrice`, in that + * direction for both sides — a short does not flip it. Above 1 puts more size + * at `scaleMaxPrice`, below 1 at `scaleMinPrice`. Omitted or exactly 1 spreads + * the size evenly. Any finite value above 0 is accepted as given; see + * `splitScaleSizes` for how the sizes are allocated. + */ + scaleSkew?: number; + chaseIntervalMs?: number; // How often the chase re-reads the touch (default 15000, min 1000) + chaseMaxDurationMs?: number; // Optional hard stop for the chase window (unbounded by default) + chaseMaxRepricings?: number; // Optional cap on cancel/replace cycles (unbounded by default) + chaseMaxDistanceBps?: number; // Optional directional distance from arrival price where chasing stops + + // Advanced order features + takeProfitPrice?: string; // Take profit price + stopLossPrice?: string; // Stop loss price + // Partial TP/SL: size of the attached TP/SL order. Omit for a TP/SL covering + // the full order size. Must be positive and no greater than `size`. + takeProfitSize?: string; // Quantity covered by the attached take profit + stopLossSize?: string; // Quantity covered by the attached stop loss + clientOrderId?: string; // Optional client-provided order ID + /** + * How an attached TP/SL is linked: to this order (`order`), to the resulting + * position (`position`), or absent (`none`). Defaults to `none` without TP/SL + * and `order` with TP/SL. Takes precedence over the deprecated `grouping`. + */ + tpslLinkage?: TpslLinkage; + /** + * @deprecated Use `tpslLinkage`. This field carries HyperLiquid's own grouping + * vocabulary; it is honoured for existing callers but a provider-agnostic + * placement should not depend on protocol wording. + */ + grouping?: 'na' | 'normalTpsl' | 'positionTpsl'; // Override grouping (defaults: 'na' without TP/SL, 'normalTpsl' with TP/SL) + currentPrice?: number; // Current market price (avoids extra API call if provided) + leverage?: number; // Leverage to apply for the order (e.g., 10 for 10x leverage) + existingPositionLeverage?: number; // Existing position leverage for validation (protocol constraint) + + // Optional tracking data for MetaMetrics events + trackingData?: TrackingData; + + // Multi-provider routing (optional: defaults to active/default provider). + providerId?: PerpsProviderType; +}; + +export type OrderResult = { + success?: boolean; + /** + * What names the placement afterwards. + * + * For an ordinary placement this is the exchange's order ID. For a *strategy* + * placement it is a handle instead — a venue TWAP id, or a client-generated + * scale-group or chase-session id — which is what `CancelOrderParams` takes + * together with the matching `orderType` and `providerId`. Scale and chase + * handles are held in the provider session that created them. Scale handles + * are also encoded in each rung's venue client-order ID, so an open-order + * read can recover the group after reconnect. Chase handles cannot be + * recovered because every replacement receives a new exchange order ID. + * The individual exchange IDs a strategy expanded into are in + * `childOrderIds`. + */ + orderId?: string; + error?: string; + filledSize?: string; // Amount filled + // Final normalized size actually submitted to the exchange (post precision + // rounding, USD recalculation, and any $10-minimum retry). Present only when + // the provider reached submission; used to classify partial fills against the + // real submitted size rather than the caller's pre-normalization params.size. + submittedSize?: string; + averagePrice?: string; // Average execution price + // Exchange IDs tied to a multi-order or recovery result. On a successful + // strategy placement, `orderId` carries the strategy handle and these IDs + // identify its individual children. + // + // For a `scale` ladder they stay valid: the rungs are placed once and are not + // replaced, so they remain cancellable even after the session-scoped handle is + // gone. For a `chase` this is only the order resting at placement time — the + // strategy cancels and re-places as the touch moves, and each replacement has + // a new ID that is held in the session rather than reported here, so the value + // goes stale on the first re-price. Cancel a live chase by its handle. + // + // Failure results can mix filled IDs with orders that may still rest, so a + // caller must not blindly cancel every ID. When TP/SL protection cannot be + // fully restored, these identify the old orders that survived, may still be + // live when reconciliation failed, or were recreated; an empty array means + // none are known or potentially live. + childOrderIds?: string[]; + providerId?: PerpsProviderType; // Multi-provider: which provider executed this order (injected by aggregator) +}; + +export type ChaseOrderStatus = + (typeof CHASE_ORDER_STATUS)[keyof typeof CHASE_ORDER_STATUS]; + +export type TwapOrderStatus = + | 'active' + | 'completed' + | 'completed_underfilled' + | 'canceled' + | 'failed'; + +export type TwapOrderFill = { + fillId: string; + orderId: string; + side: 'buy' | 'sell'; + price: string; + size: string; + fee: string; + feeToken: string; + builderFee?: string; + timestamp: number; + transactionHash: string; +}; + +/** Current and terminal state of one venue-native TWAP schedule. */ +export type TwapOrder = { + orderId: string; + symbol: string; + side: 'buy' | 'sell'; + size: string; + executedSize: string; + remainingSize: string; + executedNotional: string; + averagePrice?: string; + fillProgressBps: number; + timeProgressBps: number; + elapsedTimeMilliseconds: number; + durationMinutes: number; + randomize: boolean; + reduceOnly: boolean; + status: TwapOrderStatus; + startedAt: number; + lastUpdated: number; + error?: string; + fills: TwapOrderFill[]; + providerId?: PerpsProviderType; +}; + +/** + * Client-visible state of one emulated Chase placement. + * + * The handle remains stable while the exchange child ID and resting price can + * change on every reprice. Terminal sessions are retained until provider + * teardown so clients can observe why chasing stopped and then reconcile the + * surviving child through the ordinary orders stream. + */ +export type ChaseOrder = { + handle: string; + symbol: string; + side: 'buy' | 'sell'; + originalSize: string; + remainingSize: string; + arrivalPrice: string; + restingPrice: string; + restingOrderId: string | null; + /** Adverse distance of the resting child from arrival, rounded to whole bps. */ + distanceChasedBps: number; + /** + * Optional adverse-touch stop, strictly between 0 and 10,000 bps. + * The resting child may sit just inside this boundary after price-grid rounding. + */ + maxDistanceBps?: number; + repricings: number; + startedAt: number; + status: ChaseOrderStatus; + providerId?: PerpsProviderType; +}; + +/** Lifecycle signal emitted when a Chase reaches its configured distance. */ +export type ChaseOrderMaxDistanceReached = { + handle: string; + symbol: string; + side: 'buy' | 'sell'; + restingOrderId: string | null; + restingPrice: string; + maxDistanceBps: number; + timestamp: number; + providerId: PerpsProviderType; +}; + +export type Position = { + symbol: string; // Asset identifier (e.g., 'ETH', 'BTC', 'xyz:TSLA') + size: string; // Signed position size (+ = LONG, - = SHORT) + entryPrice: string; // Average entry price + positionValue: string; // Total position value in USD + unrealizedPnl: string; // Unrealized profit/loss + marginUsed: string; // Margin currently used for this position + leverage: { + type: 'isolated' | 'cross'; // Margin type + value: number; // Leverage multiplier + rawUsd?: string; // USD amount (for isolated margin) + }; + liquidationPrice: string | null; // Liquidation price (null if no risk) + maxLeverage: number; // Maximum allowed leverage for this asset + returnOnEquity: string; // ROE percentage + cumulativeFunding: { + // Funding payments history + allTime: string; // Total funding since account opening + sinceOpen: string; // Funding since position opened + sinceChange: string; // Funding since last size change + }; + /** + * Take profit price (if set). + * + * Summary field, resolved for the common case a client renders: when + * `takeProfitOrders` holds exactly one order this is that order's trigger + * price, whether or not it covers the whole position. With two or more orders + * no single price describes them, so this falls back to the position-bound + * trigger — clients render `takeProfitCount` there instead. + * + * It may also reflect a TP/SL child of a *pending* order on this market, which + * `takeProfitOrders` and `takeProfitCount` deliberately exclude because such a + * child protects that order rather than the position. A position can therefore + * report a price here with an empty array and a count of `0`. Prefer + * `takeProfitOrders` for anything that must be exact. + */ + takeProfitPrice?: string; + /** + * Stop loss price (if set). Same caveat as `takeProfitPrice`. + */ + stopLossPrice?: string; + takeProfitCount: number; // Take profit count, how many tps can affect the position + stopLossCount: number; // Stop loss count, how many sls can affect the position + // Full view of the trigger orders attached to this position, including + // quantity-scoped (partial) ones. The scalar `takeProfitPrice`/`stopLossPrice` + // fields above only carry one price each and cannot represent partial TP/SL. + takeProfitOrders?: PositionTriggerOrder[]; + stopLossOrders?: PositionTriggerOrder[]; + providerId?: PerpsProviderType; // Multi-provider: which provider holds this position (injected by aggregator) +}; + +/** + * A trigger order attached to a position, as surfaced in position state. + * Provider-agnostic: protocols map their own trigger representation onto this. + */ +export type PositionTriggerOrder = { + orderId: string; // Exchange order ID (cancelable) + direction: TriggerDirection; // Whether the trigger takes profit or stops loss. Always known: recovered from the trigger price against the entry when the exchange does not name the placement type + orderType?: TriggerOrderType; // Normalized placement type. Absent when the exchange reported an unnamed trigger, whose execution mode cannot be recovered + triggerPrice: string; // Price at which the order activates + size: string; // Quantity this trigger closes (resolved to position size when the protocol encodes "whole position") + isPartial: boolean; // true when `size` is smaller than the position size + reduceOnly: boolean; // Whether the trigger can only reduce the position +}; + +// Using 'type' instead of 'interface' for BaseController Json compatibility +export type AccountState = { + /** + * Total USD equity on this venue — collateral + unrealized PnL. Live MTM. + * HL: crossMarginSummary.accountValue + spot(USDC) − spot.hold + * MYX: walletBalance + marginUsed + unrealizedPnl + */ + totalBalance: string; + /** + * Max USD that can immediately collateralize a new position on this venue, + * with no internal transfer required. + * HL Unified: withdrawable + freeSpotUSDC + * HL Standard: withdrawable + * MYX: walletBalance + */ + spendableBalance: string; + /** + * Max USD that can leave this venue to the user's external wallet. + * UI reads this value without branching on provider; the provider + * contract guarantees HL's own abstraction (Unified) or the direct + * perps-clearinghouse (Standard) is what actually settles the + * withdraw — no client-side spot→perps sweep is performed. + * HL Unified: withdrawable + freeSpotUSDC (USDC only; `freeSpotUSDC = spot.total - spot.hold`, and HL withdraw3 draws from the unified ledger server-side) + * HL Standard: withdrawable (perps-clearinghouse only; spot is a separate ledger) + * MYX: walletBalance + */ + withdrawableBalance: string; + marginUsed: string; + unrealizedPnl: string; + returnOnEquity: string; + /** + * Per-sub-account balance breakdown (protocol-specific, optional) + * Maps sub-account identifier to its balance details. + * + * Protocol examples: + * - HyperLiquid HIP-3: '' or 'main' (main DEX), 'xyz' (HIP-3 builder DEX) + * - dYdX: Sub-account numbers (e.g., '0', '1', '2') + * - Other protocols: Vault IDs, pool IDs, margin account IDs, etc. + * + * Key: Sub-account identifier (protocol-specific string) + * Value: Balance details for that sub-account + */ + subAccountBreakdown?: Record< + string, + { + spendableBalance: string; + withdrawableBalance: string; + totalBalance: string; + } + >; + providerId?: PerpsProviderType; // Multi-provider: which provider this account state is from (injected by aggregator) +}; + +export type ClosePositionParams = { + symbol: string; // Asset identifier to close (e.g., 'ETH', 'BTC', 'xyz:TSLA') + size?: string; // Size to close (omit for full close) + /** + * Close order type (default: market). Only `market` and `limit` are meaningful + * here: `ClosePositionParams` carries no trigger price, so a trigger-based + * close is not expressible and would be rejected during placement. + * + * Strategy placements are excluded at the type level rather than left to a + * runtime rejection, because this type cannot carry any of the fields they + * require and `closePosition` has no path that executes them. Derived with + * `Exclude` on purpose: it shrinks as `StrategyOrderType` grows, so a strategy + * added later is refused here automatically. + */ + orderType?: Exclude; + price?: string; // Limit price (required for limit close) + currentPrice?: number; // Current market price for validation + + // USD as source of truth (hybrid approach - same as OrderParams) + usdAmount?: string; // USD amount (primary source of truth, provider calculates size from this) + priceAtCalculation?: number; // Price snapshot when size was calculated (for slippage validation) + maxSlippageBps?: number; // Slippage tolerance in basis points (e.g., 100 = 1%, default if not provided) + + // Optional tracking data for MetaMetrics events + trackingData?: TrackingData; + + // Multi-provider routing (optional: defaults to active/default provider) + providerId?: PerpsProviderType; // Optional: override active provider for routing + + /** + * Optional live position data from WebSocket. + * + * Pass a WebSocket-sourced snapshot only. The provider treats its own + * WebSocket position cache as fresher than this value and overrides the + * snapshot's size and side with it, so a REST-sourced (potentially older) + * position gives no benefit here. + * + * Providing it avoids a position fetch in the common case, but does not + * guarantee one is skipped: when the WebSocket cache does not cover the + * symbol's DEX (for example a HIP-3 DEX whose subscription has not published + * this session), the provider issues a single `clearinghouseState` request for + * that DEX alone, because the cache's silence proves nothing about the symbol. + * If that request succeeds, its answer is authoritative — the close fails with + * `No position found for ` when the DEX reports the symbol gone, even + * if it reports no positions at all. This snapshot is used only when that + * request fails, since a failed lookup proves nothing either. + * + * If not provided, the position is read from the WebSocket cache, falling back + * to a REST fetch when the cache is not initialized. + */ + position?: Position; +}; + +export type ClosePositionsParams = { + symbols?: string[]; // Optional: specific symbols to close (omit or empty array to close all) + closeAll?: boolean; // Explicitly close all positions +}; + +export type ClosePositionsResult = { + success: boolean; // Overall success (true if at least one position closed) + successCount: number; // Number of positions closed successfully + failureCount: number; // Number of positions that failed to close + results: { + symbol: string; + success: boolean; + error?: string; + }[]; +}; + +export type UpdateMarginParams = { + symbol: string; // Asset identifier (e.g., 'BTC', 'ETH', 'xyz:TSLA') + amount: string; // Amount to adjust as string (positive = add, negative = remove) + providerId?: PerpsProviderType; // Multi-provider: optional provider override for routing +}; + +export type MarginResult = { + success: boolean; + error?: string; +}; + +export type FlipPositionParams = { + symbol: string; // Asset identifier to flip (e.g., 'BTC', 'ETH', 'xyz:TSLA') + position: Position; // Current position to flip + + // Optional tracking data for MetaMetrics events + trackingData?: TrackingData; +}; + +export type InitializeResult = { + success: boolean; + error?: string; + chainId?: string; +}; + +export type ReadyToTradeResult = { + ready: boolean; + error?: string; + walletConnected?: boolean; + networkSupported?: boolean; + authenticatedAddress?: string; +}; + +export type DisconnectResult = { + success: boolean; + error?: string; +}; + +export type MarketInfo = { + name: string; // HyperLiquid: universe name (asset symbol) + szDecimals: number; // HyperLiquid: size decimals + maxLeverage: number; // HyperLiquid: max leverage + marginTableId: number; // HyperLiquid: margin requirements table ID + onlyIsolated?: true; // HyperLiquid: isolated margin only (optional, only when true) + isDelisted?: true; // HyperLiquid: delisted status (optional, only when true) + minimumOrderSize?: number; // Minimum order size in USD (protocol-specific) + providerId?: PerpsProviderType; // Multi-provider: which provider this market comes from (injected by aggregator) +}; + +/** + * Market data with prices for UI display + * Protocol-agnostic interface for market information with formatted values + */ +export type PerpsMarketData = { + /** + * Token symbol (e.g., 'BTC', 'ETH') + */ + symbol: string; + /** + * Full token name (e.g., 'Bitcoin', 'Ethereum') + */ + name: string; + /** + * Human-readable asset description from Terminal API metadata, when available + * (e.g., 'The leading smart contract platform. Home to DeFi, NFTs...'). + * Only populated when using the Terminal API backend and the asset has one. + */ + description?: string; + /** + * Maximum leverage available as formatted string (e.g., '40x', '25x') + */ + maxLeverage: string; + /** + * Current price as formatted string (e.g., '$50,000.00') + */ + price: string; + /** + * 24h price change as formatted string (e.g., '+$1,250.00', '-$850.50') + */ + change24h: string; + /** + * 24h price change percentage as formatted string (e.g., '+2.5%', '-1.8%') + */ + change24hPercent: string; + /** + * Trading volume as formatted string (e.g., '$1.2B', '$850M') + */ + volume: string; + /** + * Open interest as formatted string (e.g., '$24.5M', '$1.2B') + */ + openInterest?: string; + /** + * Next funding time in milliseconds since epoch (optional, market-specific) + */ + nextFundingTime?: number; + /** + * Funding interval in hours (optional, market-specific) + */ + fundingIntervalHours?: number; + /** + * Current funding rate as decimal (optional, from predictedFundings API) + */ + fundingRate?: number; + /** + * Market source DEX identifier (HIP-3 support) + * - null or undefined: Main validator DEX + * - "xyz", "abc", etc: HIP-3 builder-deployed DEX + */ + marketSource?: string | null; + /** + * Market asset type classification (optional) + * - crypto: Cryptocurrency (default for most markets) + * - stock: Individual stocks (HIP-3) + * - pre-ipo: Pre-IPO assets (HIP-3) + * - index: Market indices (HIP-3) + * - etf: Exchange-traded funds (HIP-3) + * - commodity: Commodity markets (HIP-3) + * - forex: Foreign exchange pairs (HIP-3) + */ + marketType?: MarketType; + /** + * Whether this is a HIP-3 market (has DEX prefix like xyz:, flx:) + * Used to distinguish between crypto (isHip3=false) and non-crypto markets + */ + isHip3?: boolean; + /** + * Whether this is a new/uncategorized market (HIP-3 markets not yet in explicit mapping) + * Used for the "New" filter tab + */ + isNewMarket?: boolean; + /** + * Multi-provider: which provider this market data comes from (injected by aggregator) + */ + providerId?: PerpsProviderType; + /** + * Indicates this market snapshot came from the last known good cache after live fetch failure. + */ + isStale?: boolean; + /** Identifies an atomic Terminal summary whose price/change use mark semantics. */ + dataSource?: 'terminal-global-snapshot-mark'; + /** Source-bounded expiry for an atomic Terminal summary. */ + sourceExpiresAt?: number; + /** + * Searchable keywords from Terminal API metadata (e.g., ['defi', 'layer-1']) + */ + keywords?: string[]; + /** + * Taxonomy tags from Terminal API metadata (e.g., ['top-100', 'gaming']) + */ + tags?: string[]; + /** + * Market categories from Terminal API metadata (e.g., ['crypto', 'meme']) + */ + categories?: string[]; + /** Timestamped hourly price points supplied by the atomic Terminal snapshot. */ + trend?: [timestampMs: number, price: string][]; + /** + * Epoch ms when this market was listed on the Terminal backend. + * Sourced from the Terminal API `listedAt` field. + * Clients can use this to surface recently added markets (e.g. markets listed within the last 30 days). + */ + listedAt?: number; +}; + +export type ToggleTestnetResult = { + success: boolean; + isTestnet: boolean; + error?: string; +}; + +export type AssetRoute = { + assetId: CaipAssetId; // CAIP asset ID (e.g., "eip155:42161/erc20:0xaf88.../default") + chainId: CaipChainId; // CAIP-2 chain ID where the bridge contract is located + contractAddress: Hex; // Bridge contract address for deposits/withdrawals + constraints?: { + minAmount?: string; // Minimum deposit/withdrawal amount + maxAmount?: string; // Maximum deposit/withdrawal amount + estimatedTime?: string; // Estimated processing time (formatted string - deprecated, use estimatedMinutes) + estimatedMinutes?: number; // Estimated processing time in minutes (raw value for UI formatting) + fees?: { + fixed?: number; // Fixed fee amount (e.g., 1 for 1 token) + percentage?: number; // Percentage fee (e.g., 0.05 for 0.05%) + token?: string; // Fee token symbol (e.g., 'USDC', 'ETH') + }; + }; +}; + +export type SwitchProviderResult = { + success: boolean; + providerId: PerpsActiveProviderMode; + error?: string; +}; + +export type CancelOrderParams = { + orderId: string; // Order ID to cancel, or the strategy handle when orderType is a strategy type + symbol: string; // Asset identifier (e.g., 'BTC', 'ETH', 'xyz:TSLA') + /** + * Placement type of what is being cancelled. Only the strategy types + * (`twap`, `scale`, `chase`) change anything: each is cancelled through its + * own path — the venue's TWAP cancel endpoint, a batch cancel of the ladder's + * children, or stopping the chase session and cancelling its live order. + * Omit it (or pass an ordinary order type) to cancel a single resting order, + * which is what every existing caller does. + */ + orderType?: OrderType; + // Optional provider override. Omission uses active/default routing. + providerId?: PerpsProviderType; + // Optional tracking data for MetaMetrics events (e.g. discovery attribution) + trackingData?: TrackingData; +}; + +export type CancelOrderResult = { + success: boolean; + /** + * What was cancelled, named the same way it was placed: an exchange order ID + * for an ordinary cancel, and the strategy handle — TWAP id, scale group, or + * chase session — when `CancelOrderParams.orderType` named one. Scale and + * chase handles can only be cancelled during the provider session that + * created them; TWAP IDs are venue-owned. + */ + orderId?: string; + error?: string; + providerId?: PerpsProviderType; // Multi-provider: source provider identifier +}; + +export type BatchCancelOrdersParams = { + orderId: string; + symbol: string; +}[]; + +export type CancelOrdersParams = { + symbols?: string[]; // Optional: specific symbols (omit to cancel all orders) + orderIds?: string[]; // Optional: specific order IDs (omit to cancel all orders for specified coins) + cancelAll?: boolean; // Explicitly cancel all orders +}; + +export type CancelOrdersResult = { + success: boolean; // Overall success (true if at least one order cancelled) + successCount: number; // Number of orders cancelled successfully + failureCount: number; // Number of orders that failed to cancel + results: { + orderId: string; + symbol: string; + success: boolean; + error?: string; + }[]; +}; + +export type EditOrderParams = { + orderId: string | number; // Order ID or client order ID to modify + newOrder: OrderParams; // New order parameters +}; + +export type DepositParams = { + amount: string; // Amount to deposit + assetId: CaipAssetId; // Asset to deposit (required for validation) + fromChainId?: CaipChainId; // Source chain (defaults to current network) + toChainId?: CaipChainId; // Destination chain (defaults to HyperLiquid Arbitrum) + recipient?: Hex; // Recipient address (defaults to selected account) +}; + +/** Params for depositWithConfirmation: prepares transaction for confirmation screen */ +export type DepositWithConfirmationParams = { + /** Optional deposit amount (display/tracking; actual amount comes from prepared transaction) */ + amount?: string; + /** If true, uses addTransaction instead of submit to avoid navigation (e.g. deposit + place order flow) */ + placeOrder?: boolean; +}; + +export type DepositResult = { + success: boolean; + txHash?: string; + error?: string; +}; + +// Enhanced deposit flow state types for multi-step deposits +export type DepositStatus = + | 'idle' // No deposit in progress + | 'preparing' // Analyzing route & preparing transactions + | 'swapping' // Converting token (e.g., ETH → USDC) + | 'bridging' // Cross-chain transfer + | 'depositing' // Final deposit to HyperLiquid + | 'success' // Deposit completed successfully + | 'error'; // Deposit failed at any step + +export type DepositFlowType = + | 'direct' // Same chain, same token (USDC on Arbitrum) + | 'swap' // Same chain, different token (ETH → USDC) + | 'bridge' // Different chain, same token (USDC on Ethereum → Arbitrum) + | 'swap_bridge'; // Different chain, different token (ETH on Ethereum → USDC on Arbitrum) + +export type DepositStepInfo = { + totalSteps: number; // Total number of steps in this flow + currentStep: number; // Current step (0-based index) + stepNames: string[]; // Human-readable step names + stepTxHashes?: string[]; // Transaction hashes for each completed step +}; + +export type WithdrawParams = { + amount: string; // Amount to withdraw + destination?: Hex; // Destination address (optional, defaults to current account) + assetId?: CaipAssetId; // Asset to withdraw (defaults to USDC) + providerId?: PerpsProviderType; // Multi-provider: optional provider override for routing +}; + +export type WithdrawResult = { + success: boolean; + txHash?: string; + error?: string; + withdrawalId?: string; // Unique ID for tracking + estimatedArrivalTime?: number; // Provider-specific arrival time +}; + +export type TransferBetweenDexsParams = { + sourceDex: string; // Source DEX name ('' = main DEX, 'xyz' = HIP-3 DEX) + destinationDex: string; // Destination DEX name ('' = main DEX, 'xyz' = HIP-3 DEX) + amount: string; // USDC amount to transfer +}; + +export type TransferBetweenDexsResult = { + success: boolean; + txHash?: string; + error?: string; +}; + +export type GetHistoricalPortfolioParams = { + accountId?: CaipAccountId; // Optional: defaults to selected account +}; + +export type HistoricalPortfolioResult = { + accountValue1dAgo: string; + timestamp: number; +}; + +export type LiveDataConfig = { + priceThrottleMs?: number; // ms between price updates (default: 2000) + positionThrottleMs?: number; // ms between position updates (default: 5000) + maxUpdatesPerSecond?: number; // hard limit to prevent UI blocking +}; + +export type PerpsControllerConfig = { + /** + * Fallback blocked regions to use when RemoteFeatureFlagController fails to fetch. + * The fallback is set by default if defined and replaced with remote block list once available. + */ + fallbackBlockedRegions?: string[]; + /** + * Fallback HIP-3 equity perps master switch to use when RemoteFeatureFlagController fails to fetch. + * Controls whether HIP-3 (builder-deployed) DEXs are enabled. + * The fallback is set by default if defined and replaced with remote feature flag once available. + */ + fallbackHip3Enabled?: boolean; + /** + * Fallback HIP-3 market allowlist to use when RemoteFeatureFlagController fails to fetch. + * Empty array = enable all markets (discovery mode), non-empty = allowlist specific markets. + * Supports wildcards: "xyz:*" (all xyz markets), "xyz" (shorthand for "xyz:*"), "BTC" (main DEX market). + * Only applies when HIP-3 is enabled. + * The fallback is set by default if defined and replaced with remote feature flag once available. + */ + fallbackHip3AllowlistMarkets?: string[]; + /** + * Fallback HIP-3 market blocklist to use when RemoteFeatureFlagController fails to fetch. + * Empty array = no blocking, non-empty = block specific markets. + * Supports wildcards: "xyz:*" (block all xyz markets), "xyz" (shorthand for "xyz:*"), "BTC" (block main DEX market). + * Always applied regardless of HIP-3 enabled state. + * The fallback is set by default if defined and replaced with remote feature flag once available. + */ + fallbackHip3BlocklistMarkets?: string[]; + + /** + * Override for the maximum allowed deviation of a market's price from its oracle + * (reference) price before it is reported as untradable (`PriceUpdate.isTradable`), + * as a decimal fraction (e.g. `0.95` = 95%). Protocol-agnostic: each provider applies + * its own default when omitted (HyperLiquid uses + * `HYPERLIQUID_CONFIG.OraclePriceDeviationLimit`, `0.95`). Lets a client tune the + * threshold without a package release. + */ + fallbackPriceDeviationLimit?: number; + + /** + * Per-provider credentials and configuration. + * Nested by provider name so each provider's settings are self-contained + * and new protocols can be added without polluting the top-level config. + * Passed from the init file where `process.env.X` is babel-transformed at build time. + */ + providerCredentials?: PerpsProviderCredentials; +}; + +export type HyperLiquidCredentials = { + /** Builder fee wallet address for testnet. Empty/omitted = uses BUILDER_FEE_CONFIG default. */ + builderAddressTestnet?: string; + /** Builder fee wallet address for mainnet. Empty/omitted = uses BUILDER_FEE_CONFIG default. */ + builderAddressMainnet?: string; + /** Dedicated subscription waiver builder for testnet. */ + subscriptionBuilderAddressTestnet?: string; + /** Dedicated subscription waiver builder for mainnet. */ + subscriptionBuilderAddressMainnet?: string; +}; + +export type MYXCredentials = { + /** Whether MYX provider is enabled via local env var. */ + enabled?: boolean; + appIdTestnet?: string; + apiSecretTestnet?: string; + brokerAddressTestnet?: string; + appIdMainnet?: string; + apiSecretMainnet?: string; + brokerAddressMainnet?: string; +}; + +export type PerpsProviderCredentials = { + hyperliquid?: HyperLiquidCredentials; + myx?: MYXCredentials; +}; + +export type PriceUpdate = { + symbol: string; // Asset identifier (e.g., 'BTC', 'ETH', 'xyz:TSLA') + price: string; // Current mid price (average of best bid and ask) + timestamp: number; // Update timestamp + percentChange24h?: string; // 24h price change percentage + // Order book data (only available when includeOrderBook is true) + bestBid?: string; // Best bid price (highest price buyers are willing to pay) + bestAsk?: string; // Best ask price (lowest price sellers are willing to accept) + spread?: string; // Ask - Bid spread + markPrice?: string; // Mark price from oracle (used for liquidations) + // Market data (only available when includeMarketData is true) + funding?: number; // Current funding rate + openInterest?: number; // Open interest in USD + volume24h?: number; // 24h trading volume in USD + /** + * Whether the market is currently tradable. Defaults to `true`. + * + * Some markets — most often HIP-3 builder-deployed ones — become temporarily + * untradable when their market price drifts too far from the oracle price, in which + * case the protocol rejects orders (HyperLiquid: "Order price cannot be more than 95% + * away from the reference price"). Clients use this to proactively show a "trading + * unavailable" warning instead of letting the order fail on submission. + * + * Computed per provider/protocol from that protocol's own rules. It is `false` only + * when a provider determines the market is currently untradable; a provider that has no + * such rule, or cannot assess tradability yet (e.g. before the oracle price is cached), + * reports `true`. The value is always a concrete boolean — never `undefined`. + */ + isTradable: boolean; + providerId?: PerpsProviderType; // Multi-provider: price source (injected by aggregator) +}; + +export type OrderFill = { + orderId: string; // Order ID that was filled + symbol: string; // Asset symbol + side: string; // Normalized order side ('buy' or 'sell') + size: string; // Fill size + price: string; // Fill price + pnl: string; // PNL + direction: string; // Direction of the fill + fee: string; // Fee paid + feeToken: string; // Fee token symbol + timestamp: number; // Fill timestamp + startPosition?: string; // Start position + success?: boolean; // Whether the order was filled successfully + liquidation?: { + liquidatedUser: string; // Address of the liquidated user. liquidatedUser isn't always the current user. It can also mean the fill filled another user's liquidation. + markPx: string; // Mark price at liquidation + method: string; // Liquidation method (e.g., 'market') + }; + orderType?: 'take_profit' | 'stop_loss' | 'liquidation' | 'regular'; + detailedOrderType?: string; // Original order type from exchange + providerId?: PerpsProviderType; // Multi-provider: which provider this fill occurred on (injected by aggregator) +}; + +// Parameter interfaces - all fully optional for better UX +export type CheckEligibilityParams = { + blockedRegions: string[]; // List of blocked region codes (e.g., ['US', 'CN']) + geoLocation: string; // User's geolocation from GeolocationController +}; + +export type GetPositionsParams = { + accountId?: CaipAccountId; // Optional: defaults to selected account + includeHistory?: boolean; // Optional: include historical positions + skipCache?: boolean; // Optional: bypass WebSocket cache and force API call (default: false) + standalone?: boolean; // Optional: lightweight mode - skip full initialization, use standalone HTTP client (no wallet/WebSocket needed) + userAddress?: string; // Optional: required when standalone is true - user address to query positions for +}; + +export type GetAccountStateParams = { + accountId?: CaipAccountId; // Optional: defaults to selected account + source?: string; // Optional: source of the call for tracing (e.g., 'health_check', 'initial_connection') + standalone?: boolean; // Optional: lightweight mode - skip full initialization, use standalone HTTP client (no wallet/WebSocket needed) + userAddress?: string; // Optional: required when standalone is true - user address to query account state for +}; + +export type GetUserDataSnapshotParams = { + userAddress: string; + identity: { + provider: 'hyperliquid'; + network: 'mainnet' | 'testnet'; + hip3ConfigVersion: number; + dexes: string[]; + }; +}; + +export type PerpsUserDataSnapshot = { + positions: Position[]; + orders: Order[]; + accountState: AccountState; + identity: GetUserDataSnapshotParams['identity'] & { address: string }; +}; + +export type GetOrderFillsParams = { + accountId?: CaipAccountId; // Optional: defaults to selected account + user?: Hex; // Optional: user address (defaults to selected account) + startTime?: number; // Optional: start timestamp (Unix milliseconds) + endTime?: number; // Optional: end timestamp (Unix milliseconds) + limit?: number; // Optional: max number of results for pagination + aggregateByTime?: boolean; // Optional: aggregate by time +}; + +/** + * Parameters for getOrFetchFills - optimized cache-first fill retrieval. + * Subset of GetOrderFillsParams for cache filtering. + */ +export type GetOrFetchFillsParams = { + startTime?: number; // Optional: start timestamp (Unix milliseconds) + symbol?: string; // Optional: filter by symbol +}; + +export type GetOrdersParams = { + accountId?: CaipAccountId; // Optional: defaults to selected account + startTime?: number; // Optional: start timestamp (Unix milliseconds) + endTime?: number; // Optional: end timestamp (Unix milliseconds) + limit?: number; // Optional: max number of results for pagination + offset?: number; // Optional: offset for pagination + skipCache?: boolean; // Optional: bypass WebSocket cache and force API call (default: false) + standalone?: boolean; // Optional: lightweight mode - skip full initialization, use standalone HTTP client (no wallet/WebSocket needed) + userAddress?: string; // Optional: required when standalone is true - user address to query orders for +}; + +/** + * Options for cache-aware provider read calls (getOrders, getOrderFills, etc.). + * Provider-agnostic: providers without inner caches can ignore; providers that + * cache at the service layer (e.g. HyperLiquid) must honor forceRefresh. + */ +export type PerpsReadOptions = { + /** Bypass any provider-internal cache. Used for user-initiated refresh (pull-to-refresh). */ + forceRefresh?: boolean; +}; + +export type GetFundingParams = { + accountId?: CaipAccountId; // Optional: defaults to selected account + startTime?: number; // Optional: start timestamp (Unix milliseconds) + endTime?: number; // Optional: end timestamp (Unix milliseconds) + limit?: number; // Optional: max number of results for pagination + offset?: number; // Optional: offset for pagination +}; + +export type GetSupportedPathsParams = { + isTestnet?: boolean; // Optional: override current testnet state + assetId?: CaipAssetId; // Optional: filter by specific asset + symbol?: string; // Optional: filter by asset symbol (e.g., 'USDC') + chainId?: CaipChainId; // Optional: filter by chain (CAIP-2 format) +}; + +/** Placeholder for future filter/pagination params (e.g., validated, chain). Empty today so the API signature is stable. */ +export type GetAvailableDexsParams = Record; + +/** Field to sort markets by. */ +export type SortField = + | 'volume' + | 'priceChange' + | 'fundingRate' + | 'openInterest'; + +/** Direction for market sorting. */ +export type SortDirection = 'asc' | 'desc'; + +export type GetMarketsParams = { + symbols?: string[]; // Optional symbol filter (e.g., ['BTC', 'xyz:XYZ100']) + dex?: string; // HyperLiquid HIP-3: DEX name (empty string '' or undefined for main DEX). Other protocols: ignored. + skipFilters?: boolean; // Skip market filtering (both allowlist and blocklist, default: false). When true, returns all markets without filtering. + standalone?: boolean; // Lightweight mode: skip full initialization, only fetch market metadata (no wallet/WebSocket needed). Only main DEX markets returned. Use for discovery use cases like checking if a perps market exists. + useTerminalApi?: boolean; // When true, enrich provider data from the legacy Terminal market endpoint. +}; + +/** + * Parameters for {@link PerpsController.getMarketDataWithPrices}. + * Extends the base market-fetch params with optional category filtering, + * sorting, and pagination that are applied as post-processing. + */ +export type GetMarketDataWithPricesParams = { + standalone?: boolean; // Lightweight mode: see GetMarketsParams.standalone + categories?: MarketTypeFilter[]; // Filter to markets matching any of these categories; omit for all markets + excludeSymbols?: string[]; // Symbols to exclude from results (e.g. the currently viewed market) + sortBy?: SortField; // Sort results by this field + direction?: SortDirection; // Sort direction (default: desc) + limit?: number; // Maximum number of results to return + useTerminalApi?: boolean; // When true, enrich provider data from the legacy Terminal market endpoint. +}; + +export type SubscribePricesParams = { + symbols: string[]; + callback: (prices: PriceUpdate[]) => void; + throttleMs?: number; // Future: per-subscription throttling + includeOrderBook?: boolean; // Optional: include bid/ask data from L2 book + includeMarketData?: boolean; // Optional: include funding, open interest, volume data +}; + +export type SubscribePositionsParams = { + callback: (positions: Position[]) => void; + accountId?: CaipAccountId; // Optional: defaults to selected account + includeHistory?: boolean; // Future: include historical data +}; + +export type SubscribeOrderFillsParams = { + callback: (fills: OrderFill[], isSnapshot?: boolean) => void; + accountId?: CaipAccountId; // Optional: defaults to selected account + since?: number; // Future: only fills after timestamp +}; + +export type SubscribeOrdersParams = { + callback: (orders: Order[]) => void; + accountId?: CaipAccountId; // Optional: defaults to selected account + includeHistory?: boolean; // Optional: include filled/canceled orders +}; + +export type SubscribeAccountParams = { + callback: (account: AccountState | null) => void; + accountId?: CaipAccountId; // Optional: defaults to selected account +}; + +export type SubscribeOICapsParams = { + callback: (caps: string[]) => void; + accountId?: CaipAccountId; // Optional: defaults to selected account +}; + +export type SubscribeCandlesParams = { + symbol: string; + interval: CandlePeriod; + duration?: TimeDuration; + callback: (data: CandleData) => void; + onError?: (error: Error) => void; +}; + +/** + * Single price level in the order book + */ +export type OrderBookLevel = { + /** Price at this level */ + price: string; + /** Size at this level (in base asset) */ + size: string; + /** Cumulative size up to and including this level */ + total: string; + /** Notional value in USD */ + notional: string; + /** Cumulative notional up to and including this level */ + totalNotional: string; +}; + +/** + * Full order book data with multiple price levels + */ +export type OrderBookData = { + /** Bid levels (buy orders) - highest price first */ + bids: OrderBookLevel[]; + /** Ask levels (sell orders) - lowest price first */ + asks: OrderBookLevel[]; + /** Spread between best bid and best ask */ + spread: string; + /** Spread as a percentage of mid price */ + spreadPercentage: string; + /** Mid price (average of best bid and best ask) */ + midPrice: string; + /** Timestamp of last update */ + lastUpdated: number; + /** Maximum total size across all levels (for scaling depth bars) */ + maxTotal: string; +}; + +export type SubscribeOrderBookParams = { + /** Symbol to subscribe to (e.g., 'BTC', 'ETH') */ + symbol: string; + /** Number of levels to return per side (default: 10) */ + levels?: number; + /** Price aggregation significant figures (2-5, default: 5). Higher = finer granularity */ + nSigFigs?: 2 | 3 | 4 | 5; + /** Mantissa for aggregation when nSigFigs is 5 (2 or 5). Controls finest price increments */ + mantissa?: 2 | 5; + /** + * Enable fast order book updates (5 levels @ ~0.5 s cadence). + * When omitted, Hyperliquid uses the default cadence (20 levels @ ~2 s). + * Note: with `fast: true` the widget receives at most 5 levels per side + * regardless of the `levels` setting. + */ + fast?: boolean; + /** Callback function receiving order book updates */ + callback: (orderBook: OrderBookData) => void; + /** Callback for errors */ + onError?: (error: Error) => void; +}; + +export type LiquidationPriceParams = { + entryPrice: number; + leverage: number; + direction: 'long' | 'short'; + positionSize?: number; // Optional: for more accurate calculations + marginType?: 'isolated' | 'cross'; // Optional: defaults to isolated + asset?: string; // Optional: for asset-specific maintenance margins +}; + +/** + * Live position fields required to project a modify. Clients may pass a full + * {@link Position}; extra fields are ignored. + */ +export type PositionModifyPreviewSource = Pick< + Position, + | 'symbol' + | 'size' + | 'marginUsed' + | 'liquidationPrice' + | 'entryPrice' + | 'leverage' + | 'positionValue' + | 'maxLeverage' + | 'providerId' +>; + +/** + * Proposed order plus the live position it would modify. + * + * Isolated-margin previews apply `leverage` to the *whole* resulting + * position, matching `updateLeverage` before placement. Cross-margin + * positions are not projected. + */ +export type PositionModifyPreviewParams = { + position: PositionModifyPreviewSource; + /** Proposed order direction. */ + direction: 'long' | 'short'; + /** Proposed order size in token units. */ + size: string; + /** + * Expected fill price for a marketable order, or the resting limit price. + * Increases and flips require a positive price; a reduce does not. + * Scale, TWAP, and chase orders should pass the expected fill size and price; + * this preview models a single fill. + */ + price: string; + /** + * Isolated leverage the provider will set on the asset before placing. + * Applied to the entire resulting position, not only the added size. + */ + leverage: number; + reduceOnly?: boolean; + /** + * Estimated trading fees in USD. Deducted from isolated margin on + * increases and flips. Omit or pass 0 when unknown. + */ + feeAmountUsd?: number; + /** + * Explicit venue route. Aggregated providers use this, then + * `position.providerId`, then the default provider. + */ + providerId?: PerpsProviderType; +}; + +/** + * Independently available numeric projection. Margin can be known when + * liquidation cannot (missing maintenance-tier data, or no liquidation risk). + */ +export type PositionPreviewValue = + | { available: true; value: number } + | { available: false }; + +export type PositionModifyPreviewKind = 'increase' | 'decrease' | 'flip'; + +export type PositionModifyPreviewCurrent = { + margin: PositionPreviewValue; + liquidationPrice: PositionPreviewValue; +}; + +export type PositionModifyPreviewOpen = { + status: 'open'; + kind: PositionModifyPreviewKind; + current: PositionModifyPreviewCurrent; + resulting: { + direction: 'long' | 'short'; + /** Resulting token size; always > 0 for `open`. */ + size: number; + entryPrice: number; + /** + * Isolated leverage from mark notional / remaining margin, matching + * HyperLiquid's displayed leverage rather than entry notional / margin. + */ + leverage: number; + margin: PositionPreviewValue; + liquidationPrice: PositionPreviewValue; + }; +}; + +export type PositionModifyPreviewFullClose = { + status: 'full_close'; + current: PositionModifyPreviewCurrent; + /** Direction of the position being closed. */ + resultingDirection: 'long' | 'short'; +}; + +export type PositionModifyPreviewUnsupported = { + status: 'unsupported'; + reason: 'cross_margin' | 'provider'; +}; + +export type PositionModifyPreviewNone = { + status: 'none'; +}; + +/** + * Read-only post-trade position projection. + * + * Discriminated on `status` so a non-modifying result cannot carry a + * flip/full-close kind, and a full close cannot report a remaining size. + */ +export type PositionModifyPreviewResult = + | PositionModifyPreviewNone + | PositionModifyPreviewUnsupported + | PositionModifyPreviewFullClose + | PositionModifyPreviewOpen; + +export type MaintenanceMarginParams = { + asset: string; + positionSize?: number; // Optional: for tiered margin systems +}; + +/** + * Context used to resolve the provider that owns order capabilities. + * `symbol` allows providers to narrow capabilities per market, while + * `providerId` follows the same explicit-over-default routing as placement. + */ +export type GetOrderCapabilitiesParams = { + /** Provider-specific market identifier, including any routing prefix. */ + symbol: string; + providerId?: PerpsProviderType; +}; + +/** Provider-owned strategy capabilities for the selected market route. */ +export type DirectProviderOrderCapabilitiesUnavailableReason = + | 'provider_unavailable' + | 'invalid_symbol' + | 'market_not_found' + | 'strategy_market_unsupported'; + +export type RoutedOrderCapabilitiesUnavailableReason = + | 'provider_not_found' + | 'provider_not_routable' + | 'not_implemented'; + +export type OrderCapabilitiesUnavailableReason = + | DirectProviderOrderCapabilitiesUnavailableReason + | RoutedOrderCapabilitiesUnavailableReason; + +type ReadyPerpsOrderCapabilities = Readonly<{ + status: 'ready'; + providerId: PerpsProviderType; + supportedStrategies: readonly StrategyOrderType[]; +}>; + +export type DirectProviderOrderCapabilities = + | ReadyPerpsOrderCapabilities + | Readonly<{ + status: 'unavailable'; + providerId?: PerpsProviderType; + reason: DirectProviderOrderCapabilitiesUnavailableReason; + }>; + +export type PerpsOrderCapabilities = + | ReadyPerpsOrderCapabilities + | Readonly<{ + status: 'unavailable'; + providerId?: PerpsProviderType; + reason: OrderCapabilitiesUnavailableReason; + }>; + +export type FeeCalculationParams = { + // Trigger placements are charged as their execution kind (a stop_limit pays + // limit-order fees when it fills, a stop_market pays taker fees). + orderType: OrderType; + isMaker?: boolean; + amount?: string; + symbol: string; // Required: Asset identifier for HIP-3 fee calculation (e.g., 'BTC', 'xyz:TSLA') + // Optional provider override. Omission uses active/default routing. + providerId?: PerpsProviderType; +}; + +export type FeeCalculationResult = { + // Total fees (protocol + MetaMask) + feeRate?: number; // Total fee rate as decimal (e.g., 0.00145 for 0.145%), undefined when unavailable + feeAmount?: number; // Total fee amount in USD (when amount is provided) + + // Protocol-specific base fees + protocolFeeRate?: number; // Protocol fee rate (e.g., 0.00045 for HyperLiquid taker), undefined when unavailable + protocolFeeAmount?: number; // Protocol fee amount in USD + + // MetaMask builder/revenue fee + metamaskFeeRate?: number; // MetaMask fee rate (e.g., 0.001 for 0.1%), undefined when unavailable + metamaskFeeAmount?: number; // MetaMask fee amount in USD + + // Optional detailed breakdown for transparency + breakdown?: { + baseFeeRate: number; + volumeTier?: string; + volumeDiscount?: number; + stakingDiscount?: number; + }; + + /** + * Read-only subscription fee-waiver preview, sourced from the same cached + * benefits snapshot the fee resolver uses. Present only when the controller + * has a subscription source wired; the quoted rates above are not adjusted + * from it, so surfacing this never mutates the cap or the cache. + */ + subscription?: PerpsSubscriptionFeeWaiverStatus; +}; + +/** + * Usage state of the perps fee waiver on a subscription benefits snapshot. + */ +export type PerpsSubscriptionUsage = 'available' | 'exhausted'; + +/** + * Subscription benefits as returned by `GET /v1/profiles/{profileId}/benefits`. + * + * The perps controller never performs this request itself — the client owns the + * Profile JWT and injects the read through + * {@link PerpsPlatformDependencies.subscription}. Only the fields the perps fee + * waiver depends on are modelled here. + */ +export type PerpsSubscriptionBenefits = { + /** Subscription status; only `active` can pass the eligibility gate. */ + status: string; + + /** Perps fee waiver entitlement and its remaining allowance. */ + perpsFeeWaiver?: { + /** Whether the plan entitles this profile to the perps fee waiver. */ + entitled: boolean; + + /** Backend usage state; only `available` can pass the eligibility gate. */ + usage?: PerpsSubscriptionUsage; + + /** + * Set by the backend once the notional cap is crossed. Honored on the next + * cache refresh — there is no client-held reservation to release. + */ + exhausted?: boolean; + + /** Notional (USD) still covered by the waiver, for fee previews. */ + remainingNotionalUsd?: number; + }; +}; + +/** + * Why the subscription fee waiver did or did not apply, plus the remaining + * allowance for fee previews. Derived purely from the cached benefits snapshot. + */ +export type PerpsSubscriptionFeeWaiverStatus = { + /** True only when every condition of the eligibility gate passed. */ + eligible: boolean; + + /** + * Gate outcome: + * - `eligible` — every condition passed + * - `no-source` — no subscription dependency is wired + * - `not-hydrated` — nothing cached yet; a refresh was kicked off + * - `stale` — the cached snapshot is past the hard-stale ceiling + * - `no-subscription` — the read succeeded but reported no subscription at + * all (signed out, or no profile) + * - `inactive` — subscription status is not `active` + * - `not-entitled` — the plan does not include the perps fee waiver + * - `exhausted` — the backend reported the notional cap as spent + */ + reason: + | 'eligible' + | 'no-source' + | 'not-hydrated' + | 'stale' + | 'no-subscription' + | 'inactive' + | 'not-entitled' + | 'exhausted'; + + /** Notional (USD) still covered by the waiver, when the backend reports it. */ + remainingNotionalUsd?: number; +}; + +/** + * Fee source that won the unified resolver. + * + * `rewards` covers both VIP and season discounts: `RewardsController` already + * returns the better of the two as a single discount, so the perps controller + * treats them as one source rather than re-deriving the split. + */ +export type PerpsFeeSource = 'default' | 'rewards' | 'subscription'; + +/** + * Outcome of the unified fee resolver. + */ +export type PerpsFeeResolution = { + /** Winning MetaMask builder fee, in basis points (lowest across sources). */ + feeBips: number; + + /** + * Winning fee expressed as a discount off the default builder fee, in basis + * points — the unit providers consume. `undefined` when no source resolved + * (e.g. rewards state has not hydrated and no subscription waiver applies), + * so callers do not treat it as a definitive "no discount" answer. + */ + discountBips: number | undefined; + + /** Source that produced the winning fee. */ + source: PerpsFeeSource; + + /** Subscription gate outcome, always populated for observability. */ + subscription: PerpsSubscriptionFeeWaiverStatus; +}; + +export type UpdatePositionTPSLParams = { + symbol: string; // Asset identifier (e.g., 'BTC', 'ETH', 'xyz:TSLA') + takeProfitPrice?: string; // Optional: undefined to remove + stopLossPrice?: string; // Optional: undefined to remove + // Partial TP/SL: quantity covered by the TP/SL order. Omit to cover the whole + // position. When either size is provided the TP/SL orders are placed as + // standalone reduce-only triggers, since a position-bound TP/SL cannot carry a + // quantity. + takeProfitSize?: string; + stopLossSize?: string; + // Optional tracking data for MetaMetrics events + trackingData?: TPSLTrackingData; + providerId?: PerpsProviderType; // Multi-provider: optional provider override for routing + /** + * Optional live position data from WebSocket. + * If provided, skips the REST API position fetch (avoids rate limiting issues). + * If not provided, falls back to fetching positions via REST API. + */ + position?: Position; +}; + +export type Order = { + orderId: string; // Order ID + symbol: string; // Asset symbol (e.g., 'ETH', 'BTC') + side: 'buy' | 'sell'; // Normalized order side + orderType: OrderType; // Order type (market/limit) + size: string; // Order size + originalSize: string; // Original order size + price: string; // Order price (for limit orders) + filledSize: string; // Amount filled + remainingSize: string; // Amount remaining + status: 'open' | 'filled' | 'canceled' | 'rejected' | 'triggered' | 'queued'; // Normalized status + timestamp: number; // Order timestamp + lastUpdated?: number; // Last status update timestamp (optional - not provided by all APIs) + // TODO: Consider creating separate type for OpenOrders (UI Orders) potentially if optional properties muddy up the original Order type + takeProfitPrice?: string; // Take profit price (if set) + stopLossPrice?: string; // Stop loss price (if set) + stopLossOrderId?: string; // Stop loss order ID + takeProfitOrderId?: string; // Take profit order ID + detailedOrderType?: string; // Full order type from exchange (e.g., 'Take Profit Limit', 'Stop Market') + isTrigger?: boolean; // Whether this is a trigger order (TP/SL) + // Normalized trigger placement type, set for trigger orders only. `orderType` + // above stays coarse (how the order executes) so existing consumers keep + // their meaning; this field carries the full placement type. + triggerOrderType?: TriggerOrderType; + reduceOnly?: boolean; // Whether this is a reduce-only order + isPositionTpsl?: boolean; // Whether this TP/SL is associated with the full position + parentOrderId?: string; // Parent order ID for display-only synthetic TP/SL rows + isSynthetic?: boolean; // Whether this order is synthetic (display-only, cancelable only when linked to a real child order ID) + triggerPrice?: string; // Trigger condition price for trigger orders (e.g., TP/SL trigger level) + strategyGroupId?: string; // Recoverable strategy handle shared by related venue orders + providerId?: PerpsProviderType; // Multi-provider: which provider this order is on (injected by aggregator) +}; + +export type Funding = { + symbol: string; // Asset symbol (e.g., 'ETH', 'BTC') + amountUsd: string; // Funding amount in USD (positive = received, negative = paid) + rate?: string; // Funding rate applied (undefined when not available from provider) + timestamp: number; // Funding payment timestamp + transactionHash?: string; // Optional transaction hash +}; + +export type PerpsProvider = { + readonly protocolId: string; + + /** Whether this provider routes individual requests by `providerId`. */ + readonly routesOrdersByProviderId?: boolean; + + /** + * Return strategy capabilities for the provider/market route. Providers may + * omit this hook; the controller then reports capabilities as unavailable. + */ + getOrderCapabilities?( + params: GetOrderCapabilitiesParams, + ): Promise; + + // Unified asset and route information + getDepositRoutes(params?: GetSupportedPathsParams): AssetRoute[]; // Assets and their deposit routes + getWithdrawalRoutes(params?: GetSupportedPathsParams): AssetRoute[]; // Assets and their withdrawal routes + + // Trading operations → Redux (persisted, optimistic updates) + placeOrder(params: OrderParams): Promise; + editOrder(params: EditOrderParams): Promise; + cancelOrder(params: CancelOrderParams): Promise; + cancelOrders?(params: BatchCancelOrdersParams): Promise; // Optional: batch cancel for protocols that support it + getTwapOrders?(): Promise; + getChaseOrders?(): Promise; + suspendChaseOrders?(): Promise; + closePosition(params: ClosePositionParams): Promise; + closePositions?(params: ClosePositionsParams): Promise; // Optional: batch close for protocols that support it + updatePositionTPSL(params: UpdatePositionTPSLParams): Promise; + updateMargin(params: UpdateMarginParams): Promise; + getPositions(params?: GetPositionsParams): Promise; + getAccountState(params?: GetAccountStateParams): Promise; + getUserDataSnapshot?( + params: GetUserDataSnapshotParams, + ): Promise; + getMarkets(params?: GetMarketsParams): Promise; + getMarketDataWithPrices(): Promise; + withdraw(params: WithdrawParams): Promise; // API operation - stays in provider + // Note: deposit() is handled by PerpsController routing (blockchain operation) + validateDeposit( + params: DepositParams, + ): Promise<{ isValid: boolean; error?: string }>; // Protocol-specific deposit validation + validateOrder( + params: OrderParams, + ): Promise<{ isValid: boolean; error?: string }>; // Protocol-specific order validation + validateClosePosition( + params: ClosePositionParams, + ): Promise<{ isValid: boolean; error?: string }>; // Protocol-specific position close validation + validateWithdrawal( + params: WithdrawParams, + ): Promise<{ isValid: boolean; error?: string }>; // Protocol-specific withdrawal validation + + // Historical data operations + /** + * Historical trade fills - actual executed trades with exact prices and fees. + * Purpose: Track what actually happened when orders were executed. + * Example: Market long 1 ETH @ $50,000 → OrderFill with exact execution price and fees + */ + getOrderFills( + params?: GetOrderFillsParams, + options?: PerpsReadOptions, + ): Promise; + + /** + * Get fills using WebSocket cache first, falling back to REST API. + * OPTIMIZATION: Uses cached fills when available (0 API weight), only calls REST on cache miss. + * Purpose: Prevent 429 errors during rapid market switching by reusing cached fills. + * + * @param params - Optional filter parameters (startTime, symbol) + */ + getOrFetchFills(params?: GetOrFetchFillsParams): Promise; + + /** + * Get historical portfolio data + * Purpose: Retrieve account value from previous periods for PnL tracking + * Example: Get account value from yesterday to calculate 24h percentage change + * + * @param params - Optional parameters for historical portfolio retrieval + */ + getHistoricalPortfolio( + params?: GetHistoricalPortfolioParams, + ): Promise; + + /** + * Historical order lifecycle - order placement, modifications, and status changes. + * Purpose: Track the complete journey of orders from request to completion. + * Example: Limit buy 1 ETH @ $48,000 → Order with status 'open' → 'filled' when executed + */ + getOrders( + params?: GetOrdersParams, + options?: PerpsReadOptions, + ): Promise; + + /** + * Currently active open orders (real-time status). + * Purpose: Show orders that are currently open/pending execution (not historical states). + * Different from getOrders() which returns complete historical order lifecycle. + * Example: Shows only orders that are actually open right now in the exchange. + */ + getOpenOrders(params?: GetOrdersParams): Promise; + + /** + * Historical funding payments - periodic costs/rewards for holding positions. + * Purpose: Track ongoing expenses and income from position maintenance. + * Example: Holding long ETH position → Funding payment of -$5.00 (you pay the funding) + */ + getFunding( + params?: GetFundingParams, + options?: PerpsReadOptions, + ): Promise; + + /** + * Get user non-funding ledger updates (deposits, transfers, withdrawals) + */ + getUserNonFundingLedgerUpdates(params?: { + accountId?: string; + startTime?: number; + endTime?: number; + }): Promise; + + /** + * Resolve the provider's currently active account identifier. + * Used by the REST coalesce layer so cached payloads are account-scoped + * even when callers omit params.accountId (the common hook path) — prevents + * one account's data from being served after an account switch within + * the coalesce TTL window. + */ + getCurrentAccountId(): Promise; + + /** + * Get user history (deposits, withdrawals, transfers) + */ + getUserHistory(params?: { + accountId?: CaipAccountId; + startTime?: number; + endTime?: number; + }): Promise; + + // Protocol-specific calculations + calculateLiquidationPrice(params: LiquidationPriceParams): Promise; + calculateMaintenanceMargin(params: MaintenanceMarginParams): Promise; + getMaxLeverage(asset: string): Promise; + calculateFees(params: FeeCalculationParams): Promise; + /** + * Read-only projection of the position that would remain after the proposed + * order. Isolated-margin venues apply selected leverage to the whole + * resulting position and use the maintenance tier at liquidation notional. + * Cross-margin returns `{ status: 'unsupported', reason: 'cross_margin' }`. + */ + previewPositionModify( + params: PositionModifyPreviewParams, + ): Promise; + + // Live data subscriptions → Direct UI (NO Redux, maximum speed) + subscribeToPrices(params: SubscribePricesParams): () => void; + subscribeToPositions(params: SubscribePositionsParams): () => void; + subscribeToOrderFills(params: SubscribeOrderFillsParams): () => void; + subscribeToOrders(params: SubscribeOrdersParams): () => void; + subscribeToAccount(params: SubscribeAccountParams): () => void; + subscribeToOICaps(params: SubscribeOICapsParams): () => void; + subscribeToCandles(params: SubscribeCandlesParams): () => void; + subscribeToOrderBook(params: SubscribeOrderBookParams): () => void; + + // Live data configuration + setLiveDataConfig(config: Partial): void; + + // Connection management + toggleTestnet(): Promise; + initialize(): Promise; + isReadyToTrade(): Promise; + disconnect(): Promise; + ping(timeoutMs?: number): Promise; // Lightweight WebSocket health check with configurable timeout + getWebSocketConnectionState?(): WebSocketConnectionState; // Optional: get current WebSocket connection state + subscribeToConnectionState?( + listener: ( + state: WebSocketConnectionState, + reconnectionAttempt: number, + ) => void, + ): () => void; // Optional: subscribe to WebSocket connection state changes + reconnect?(): Promise; // Optional: manually trigger WebSocket reconnection + + // Block explorer + getBlockExplorerUrl(address?: string): string; + + // Fee discount context (optional - for MetaMask reward discounts) + setUserFeeDiscount?(discountBips: number | undefined): void; + // Full fee resolution context, including attribution source. + setUserFeeResolution?(resolution: PerpsFeeResolution | undefined): void; + /** Approve the dedicated subscription builder outside order submission. */ + approveSubscriptionBuilderFee?(): Promise; + + // HIP-3 (Builder-deployed DEXs) operations - optional for backward compatibility + /** + * Get list of available HIP-3 builder-deployed DEXs + * + * @param params - Optional parameters (reserved for future filters/pagination) + * @returns Array of DEX names (empty string '' represents main DEX) + */ + getAvailableDexs?(params?: GetAvailableDexsParams): Promise; + + /** + * Fetch historical OHLCV candle data for a symbol. + * Optional: only providers that support historical candles need to implement this. + */ + fetchHistoricalCandles?(options: { + symbol: string; + interval: CandlePeriod; + limit?: number; + endTime?: number; + }): Promise; +}; + +// ============================================================================ +// Multi-Provider Aggregation Types (Phase 1) +// ============================================================================ + +/** + * Provider identifier type for multi-provider support. + * Add new providers here as they are implemented. + */ +export type PerpsProviderType = 'hyperliquid' | 'myx'; + +/** + * Active provider mode for PerpsController state. + * - Direct providers: 'hyperliquid', 'myx' + * - 'aggregated': Multi-provider aggregation mode + */ +export type PerpsActiveProviderMode = PerpsProviderType | 'aggregated'; + +/** + * Aggregation mode for read operations. + * - 'all': Aggregate data from all registered providers + * - 'active': Only aggregate from providers with active connections + * - 'specific': Aggregate from a specific subset of providers + */ +export type AggregationMode = 'all' | 'active' | 'specific'; + +/** + * Routing strategy for write operations. + * Phase 1 only supports 'default_provider' - advanced strategies deferred to Phase 3. + */ +export type RoutingStrategy = 'default_provider'; + +/** + * Configuration for AggregatedPerpsProvider + */ +export type AggregatedProviderConfig = { + /** Map of provider ID to provider instance */ + providers: Map; + /** Default provider for write operations when providerId not specified */ + defaultProvider: PerpsProviderType; + /** Aggregation mode for read operations (default: 'all') */ + aggregationMode?: AggregationMode; + /** Platform dependencies for logging, metrics, etc. */ + infrastructure: PerpsPlatformDependencies; +}; + +/** + * Provider-specific error with context for multi-provider error handling + */ +export type ProviderError = { + /** Which provider the error originated from */ + providerId: PerpsProviderType; + /** Human-readable error message */ + message: string; + /** Original error object if available */ + originalError?: Error; + /** Whether the operation can be retried */ + isRetryable?: boolean; +}; + +/** + * Aggregated account state combining data from multiple providers + */ +export type AggregatedAccountState = { + /** Combined totals across all providers */ + total: AccountState; + /** Per-provider breakdown */ + byProvider: Map; +}; + +// ============================================================================ +// Injectable Dependency Interfaces +// These interfaces enable dependency injection for platform-specific services, +// allowing PerpsController to be moved to core without mobile-specific imports. +// ============================================================================ + +/** + * Injectable logger interface for error reporting. + * Allows core package to be platform-agnostic (mobile: Sentry, extension: different impl) + */ +export type PerpsLogger = { + error( + error: Error, + options?: { + tags?: Record; + context?: { name: string; data: Record }; + extras?: Record; + }, + ): void; +}; + +/** + * Analytics events specific to Perps feature. + * These are the actual event names sent to analytics backend. + * Values must match the corresponding MetaMetricsEvents values in mobile for compatibility. + * + * When migrating to core monorepo, this enum travels with PerpsController. + */ +export enum PerpsAnalyticsEvent { + WithdrawalTransaction = 'Perp Withdrawal Transaction', + TradeTransaction = 'Perp Trade Transaction', + PositionCloseTransaction = 'Perp Position Close Transaction', + OrderCancelTransaction = 'Perp Order Cancel Transaction', + ScreenViewed = 'Perp Screen Viewed', + UiInteraction = 'Perp UI Interaction', + RiskManagement = 'Perp Risk Management', + PerpsError = 'Perp Error', + AccountSetup = 'Perp Account Setup', + // New funnel + search events. + // Names must match MetaMetrics/Mixpanel exactly; no other event names may be added. + TransactionConsidered = 'Perp Transaction Considered', + TradeQuoteReceived = 'Perp Trade Quote Received', + SearchQuery = 'Perp Search Query', + SearchResultTapped = 'Perp Search Result Tapped', + SearchAbandoned = 'Perp Search Abandoned', +} + +/** + * UTM / discovery attribution context for Perps analytics. + * + * Held transiently in-memory by PerpsController (never persisted in state) and + * merged into analytics event properties so client-originated UTM attribution + * can be propagated onto core-emitted transaction events. + */ +export type PerpsAttributionContext = { + utmSource?: string; + utmMedium?: string; + utmCampaign?: string; + utmContent?: string; + utmTerm?: string; +}; + +/** + * Perps-specific trace names. These must match TraceName enum values in mobile. + * When in core monorepo, this defines the valid trace names for Perps operations. + */ +export type PerpsTraceName = + | 'Perps Open Position' + | 'Perps Close Position' + | 'Perps Deposit' + | 'Perps Withdraw' + | 'Perps Place Order' + | 'Perps Edit Order' + | 'Perps Cancel Order' + | 'Perps Update TP/SL' + | 'Perps Update Margin' + | 'Perps Flip Position' + | 'Perps Market Data Update' + | 'Perps Order View' + | 'Perps Tab View' + | 'Perps Market List View' + | 'Perps Position Details View' + | 'Perps Adjust Margin View' + | 'Perps Order Details View' + | 'Perps Order Book View' + | 'Perps Flip Position Sheet' + | 'Perps Transactions View' + | 'Perps Order Fills Fetch' + | 'Perps Orders Fetch' + | 'Perps Funding Fetch' + | 'Perps Get Positions' + | 'Perps Get Account State' + | 'Perps Get Historical Portfolio' + | 'Perps Get Markets' + | 'Perps Get Market Data With Prices' + | 'Perps Fetch Historical Candles' + | 'Perps WebSocket Connected' + | 'Perps WebSocket Disconnected' + | 'Perps WebSocket First Positions' + | 'Perps WebSocket First Orders' + | 'Perps WebSocket First Account' + | 'Perps Data Lake Report' + | 'Perps Rewards API Call' + | 'Perps Close Position View' + | 'Perps Withdraw View' + | 'Perps Connection Establishment' + | 'Perps Account Switch Reconnection' + | 'Perps Market Data Preload' + | 'Perps User Data Preload'; + +/** + * Perps trace name constants. Values match TraceName enum in mobile. + * When in core, these ARE the source of truth - mobile will re-export from core. + */ +export const PerpsTraceNames = { + // Trading operations + PlaceOrder: 'Perps Place Order', + EditOrder: 'Perps Edit Order', + CancelOrder: 'Perps Cancel Order', + ClosePosition: 'Perps Close Position', + UpdateTpsl: 'Perps Update TP/SL', + UpdateMargin: 'Perps Update Margin', + FlipPosition: 'Perps Flip Position', + + // Account operations + Withdraw: 'Perps Withdraw', + Deposit: 'Perps Deposit', + + // Market data + GetPositions: 'Perps Get Positions', + GetAccountState: 'Perps Get Account State', + GetMarkets: 'Perps Get Markets', + GetMarketDataWithPrices: 'Perps Get Market Data With Prices', + OrderFillsFetch: 'Perps Order Fills Fetch', + OrdersFetch: 'Perps Orders Fetch', + FundingFetch: 'Perps Funding Fetch', + GetHistoricalPortfolio: 'Perps Get Historical Portfolio', + FetchHistoricalCandles: 'Perps Fetch Historical Candles', + + // Data lake + DataLakeReport: 'Perps Data Lake Report', + + // WebSocket + WebsocketConnected: 'Perps WebSocket Connected', + WebsocketDisconnected: 'Perps WebSocket Disconnected', + WebsocketFirstPositions: 'Perps WebSocket First Positions', + WebsocketFirstOrders: 'Perps WebSocket First Orders', + WebsocketFirstAccount: 'Perps WebSocket First Account', + + // Other + RewardsApiCall: 'Perps Rewards API Call', + ConnectionEstablishment: 'Perps Connection Establishment', + AccountSwitchReconnection: 'Perps Account Switch Reconnection', + MarketDataPreload: 'Perps Market Data Preload', + UserDataPreload: 'Perps User Data Preload', +} as const satisfies Record; + +/** + * Perps trace operation constants. Values match TraceOperation enum in mobile. + * These categorize traces by type of operation for Sentry/observability filtering. + */ +export const PerpsTraceOperations = { + Operation: 'perps.operation', + OrderSubmission: 'perps.order_submission', + PositionManagement: 'perps.position_management', + MarketData: 'perps.market_data', +} as const; + +/** + * Values allowed in trace data/tags. Matches Sentry's TraceValue type. + */ +export type PerpsTraceValue = string | number | boolean; + +/** + * Properties allowed in analytics events. More constrained than unknown. + * Named PerpsAnalyticsProperties to avoid conflict with PERPS_EVENT_PROPERTY + * constant object from eventNames.ts (which contains property key names). + */ +export type PerpsAnalyticsProperties = Record< + string, + string | number | boolean | Record | null | undefined +>; + +/** + * Injectable metrics interface for analytics. + * Allows core package to work with different analytics backends. + */ +export type PerpsMetrics = { + isEnabled(): boolean; + + /** + * Track a Perps-specific analytics event with properties. + * This abstracts away the MetricsEventBuilder pattern used in mobile. + * + * @param event - The Perps analytics event type (enum with actual event name values) + * @param properties - Type-safe key-value properties to attach to the event + */ + trackPerpsEvent( + event: PerpsAnalyticsEvent, + properties: PerpsAnalyticsProperties, + ): void; +}; + +/** + * Injectable debug logger for development logging. + * Only logs in development mode. + * Accepts `unknown` to allow logging error objects from catch blocks. + */ +export type PerpsDebugLogger = { + log(...args: unknown[]): void; +}; + +/** + * Injectable stream manager interface for pause/resume during critical operations. + * + * WHY THIS IS NEEDED: + * PerpsStreamManager is a React-based mobile-specific singleton that: + * - Uses React Context for subscription management + * - Uses react-native-performance for tracing + * - Directly accesses Engine.context (mobile singleton pattern) + * - Manages WebSocket connections with throttling/caching + * + * PerpsController only needs pause/resume during critical operations (withStreamPause method) + * to prevent stale UI updates during batch operations. The minimal interface allows: + * - Mobile: Wrap existing singleton (streamManager[channel].pause()) + * - Extension: Implement with whatever streaming solution they use + */ +/** + * Injectable stream manager interface for pause/resume during critical operations. + * + * WHY THIS IS NEEDED: + * PerpsStreamManager is a React-based mobile-specific singleton that: + * - Uses React Context for subscription management + * - Uses react-native-performance for tracing + * - Directly accesses Engine.context (mobile singleton pattern) + * - Manages WebSocket connections with throttling/caching + * + * PerpsController only needs pause/resume during critical operations (withStreamPause method) + * to prevent stale UI updates during batch operations. The minimal interface allows: + * - Mobile: Wrap existing singleton (streamManager[channel].pause()) + * - Extension: Implement with whatever streaming solution they use + */ +export type PerpsStreamManager = { + pauseChannel(channel: string): void; + resumeChannel(channel: string): void; + clearAllChannels(): void; +}; + +/** + * Injectable performance monitor interface. + * Wraps react-native-performance or browser Performance API. + */ +export type PerpsPerformance = { + now(): number; + /** + * Optional platform hook invoked once after constructor disk hydration. + * Receives `performance.now()` — not a Sentry write. + */ + onControllerConstructed?: (monotonicMs: number) => void; +}; + +type PerpsSetMeasurement = (( + name: string, + value: number, + unit: string, +) => void) & + ((name: string, value: number, unit: string, id: string) => void); + +/** + * Injectable tracer interface for Sentry/observability tracing. + * Services use this to create spans and measure operation durations. + * + * Note: trace() returns void because services use name/id pairs to identify traces. + * The actual span management is handled internally by the platform adapter. + */ +export type PerpsTracer = { + trace(params: { + name: PerpsTraceName; + id: string; + op: string; + tags?: Record; + data?: Record; + }): void; + + endTrace(params: { + name: PerpsTraceName; + id: string; + data?: Record; + }): void; + + setMeasurement: PerpsSetMeasurement; + + addBreadcrumb(breadcrumb: { + category: string; + message: string; + level: 'fatal' | 'error' | 'warning' | 'log' | 'info' | 'debug'; + data?: Record; + }): void; +}; + +// ============================================================================ +// Minimal local types for cross-controller DI (no external controller imports) +// ============================================================================ + +/** + * Minimal typed-message params passed to keyring for EIP-712 signing. + * Structurally matches KeyringController's TypedMessageParams. + */ +export type PerpsTypedMessageParams = { + from: string; + data: unknown; +}; + +/** + * Minimal transaction params passed to TransactionController.addTransaction. + * Only the fields PerpsController actually sets. + */ +export type PerpsTransactionParams = { + from: string; + to?: string; + value?: string; + data?: string; + gas?: string; +}; + +/** + * Options passed to TransactionController.addTransaction. + */ +export type PerpsAddTransactionOptions = { + networkClientId: string; + origin?: string; + type?: string; + skipInitialGasEstimate?: boolean; +}; + +/** + * Minimal account shape read from AccountTreeController. + * Only the fields PerpsController and its services actually use. + */ +export type PerpsInternalAccount = { + address: string; + type: string; + id: string; +}; + +/** + * Minimal remote feature flag state shape. + * Only the remoteFeatureFlags record is needed by PerpsController. + */ +export type PerpsRemoteFeatureFlagState = { + remoteFeatureFlags: Record; +}; + +/** + * Injectable interface for the Terminal-market service. + * + * `MarketDataService` programs against this contract so the concrete + * `TerminalMarketService` class (which lives in `services/`) never leaks + * into the types barrel — callers can supply any implementation that + * satisfies the shape (production, stub, mock, etc.). + */ +export type PerpsTerminalMarketService = { + fetchMarkets(): Promise<{ + markets: MarketInfo[]; + metadata: Map; + }>; + clearCache(): void; + logError(error: unknown, method: string): void; + fetchGlobalSnapshot?( + request: PerpsGlobalSnapshotRequest, + ): Promise; +}; + +/** Exact identity a client expects from an atomic global Perps snapshot. */ +export type PerpsGlobalSnapshotRequest = { + provider: 'hyperliquid'; + network: 'mainnet' | 'testnet'; + enabledDexes: string[]; +}; + +/** Validated snapshot data and its source-bounded expiry. */ +export type PerpsGlobalSnapshotResult = { + markets: PerpsMarketData[]; + expiresAt: number; +}; + +/** + * Platform dependencies for PerpsController and services. + * + * Architecture: + * - Observability: logger, debugLogger, metrics, performance, tracer + * - Platform: streamManager (mobile/extension specific) + * - Cache: cache invalidation for standalone queries + * - Rewards: delegated rewards interaction (DI — no RewardsController in Core yet) + * + * Cross-controller communication uses the messenger pattern (messenger.call). + * Only rewards remains as DI because RewardsController is not yet in Core. + */ +export type PerpsPlatformDependencies = { + // === Observability (stateless utilities) === + logger: PerpsLogger; + debugLogger: PerpsDebugLogger; + metrics: PerpsMetrics; + performance: PerpsPerformance; + tracer: PerpsTracer; + + // === Platform Services (mobile/extension specific) === + streamManager: PerpsStreamManager; + + // === Feature Flags (platform-specific version gating) === + featureFlags: { + /** + * Validate a version-gated feature flag against current app version. + * Returns true if flag is enabled AND app meets minimum version, + * false if flag is disabled, or undefined if flag is misconfigured/overridden. + * + * Platform-specific because it uses react-native-device-info (mobile) + * or browser APIs (extension) to get the app version. + */ + validateVersionGated(flag: VersionGatedFeatureFlag): boolean | undefined; + }; + + // === Market Data Formatting (platform-specific number formatting) === + marketDataFormatters: MarketDataFormatters; + + // === Cache Invalidation (for standalone query caches) === + cacheInvalidator: PerpsCacheInvalidator; + + // === Disk Cache (cold-start persistence) === + diskCache: { + getItem(key: string): Promise; + getItemSync?(key: string): string | null; + setItem(key: string, value: string): Promise; + removeItem(key: string): Promise; + }; + + // === Terminal API (market metadata source) === + terminalApi?: { + /** Full endpoint URL for the legacy perpetuals market-data endpoint. */ + marketDataUrl?: string; + + /** Full endpoint URL for the schema-v2 atomic global Perps snapshot. */ + globalSnapshotUrl?: string; + }; + + /** @deprecated Use `terminalApi.marketDataUrl`. */ + terminalApiUrl?: string; + + /** + * Optional Terminal-market service instance for fetching structured market + * metadata from the MetaMask Terminal API. + * + * When provided, `MarketDataService` uses this service to attempt the + * Terminal API path before falling back to the provider. + * Clients that do not use the Terminal API can omit this field. + */ + terminalMarketService?: PerpsTerminalMarketService; + + // === Rewards (DI — no RewardsController in Core yet) === + rewards: { + /** + * Get fee discount for an account from the RewardsController. + * Returns discount in basis points (e.g., 6500 = 65% discount), or null + * when subscription state hasn't hydrated yet — callers should skip + * caching null results and retry on the next fee calculation. + * + * Pass the perps MetaMask builder base fee in bips so the rewards + * controller can convert an absolute VIP fee into a discount fraction. + */ + getPerpsDiscountForAccount( + caipAccountId: `${string}:${string}:${string}`, + baseFeeBips: number, + ): Promise; + }; + + // === Subscription (DI — benefits endpoint is owned by the Subscription team) === + /** + * Optional subscription source for the unified fee resolver. + * + * The client owns the Profile JWT, so it performs + * `GET /v1/profiles/{profileId}/benefits` and hands the perps controller the + * parsed body. The controller caches the result stale-while-revalidate and + * never awaits this call on the order-signing path. + * + * Omit it entirely on clients that do not ship the subscription waiver; the + * resolver then falls back to the rewards and default sources. + */ + subscription?: { + /** + * Read the current profile's subscription benefits. + * Resolve `null` when there is no subscription to report (signed out, no + * profile). Rejections are tolerated: the resolver keeps the previous + * snapshot and never grants the waiver from a failed read. + */ + getPerpsBenefits(): Promise; + }; +}; + +/** + * Cache types that can be invalidated. + * Used by standalone query caches (e.g., usePerpsPositionForAsset). + */ +export type PerpsCacheType = 'positions' | 'accountState' | 'markets'; + +/** + * Parameters for invalidating a specific cache type. + */ +export type InvalidateCacheParams = { + /** The type of cache to invalidate */ + cacheType: PerpsCacheType; +}; + +/** + * Cache invalidation interface for standalone query caches. + * Allows services to signal when data has changed without depending on + * mobile-specific implementations. + */ +export type PerpsCacheInvalidator = { + /** + * Invalidate a specific cache type. + * Notifies all subscribers that cached data is stale. + */ + invalidate(params: InvalidateCacheParams): void; + + /** + * Invalidate all cache types. + */ + invalidateAll(): void; +}; + +// ============================================================================ +// Market Data Formatting +// ============================================================================ + +/** + * Injectable formatters for market data transformation. + * Decouples marketDataTransform from mobile-specific intl/formatUtils imports. + * + * Range configs are opaque (unknown[]) because the concrete type + * (FiatRangeConfig on mobile) is platform-specific. The formatter + * implementation casts internally. + */ +export type MarketDataFormatters = { + /** Format a number as a USD volume string (e.g., '$1.2B', '$850M') */ + formatVolume(value: number): string; + /** Format a number as a USD fiat string with adaptive precision */ + formatPerpsFiat(value: number, options?: { ranges?: unknown[] }): string; + /** Format a number as a percentage string (e.g., '2.50%', '-1.80%') */ + formatPercentage(percent: number): string; + /** Universal price ranges for formatting (opaque to portable code) */ + priceRangesUniversal: unknown[]; +}; + +// ============================================================================ +// Payment Token (portable replacement for mobile-only AssetType) +// ============================================================================ + +/** + * Minimal payment token for deposit flow. + * Only the fields actually used by PerpsController are included. + * Replaces the mobile-only AssetType (which extends TokenI and uses ImageSourcePropType). + */ +export type PaymentToken = { + description?: string; + address: string; + chainId?: string; + symbol?: string; +}; + +/** + * Selected pay-with token shape used in PerpsController state, pending trade config, + * selectors, and UI hooks. Use this type everywhere this shape is needed. + */ +export type PerpsSelectedPaymentToken = { + description?: string; + address: string; + chainId: string; + symbol?: string; +}; + +// ============================================================================ +// Version-gated Feature Flag (portable) +// ============================================================================ + +/** + * Structure for a version-gated feature flag from LaunchDarkly. + * Portable: no platform-specific imports. + */ +export type VersionGatedFeatureFlag = { + enabled: boolean; + minimumVersion: string; +}; + +/** + * Type guard for VersionGatedFeatureFlag. + * Pure logic, no platform dependencies. + * + * @param value - The value to check. + * @returns True if the value is a VersionGatedFeatureFlag. + */ +export function isVersionGatedFeatureFlag( + value: unknown, +): value is VersionGatedFeatureFlag { + return ( + typeof value === 'object' && + value !== null && + hasProperty(value, 'enabled') && + hasProperty(value, 'minimumVersion') && + typeof (value as { enabled: unknown }).enabled === 'boolean' && + typeof (value as { minimumVersion: unknown }).minimumVersion === 'string' + ); +} + +// ============================================================================ +// Sub-module type re-exports +// These types live in separate files within types/ and need to be accessible +// from the root barrel via `export * from './types.js'`. +// ============================================================================ +export type * from './perps-types.js'; +export * from './transactionTypes.js'; +// hyperliquid-types: selective export to avoid OrderType clash with main types +export type { + AssetPosition, + SpotBalance, + PerpsUniverse, + PerpsAssetCtx, + PredictedFunding, + FrontendOrder, + SDKOrderParams, + ClearinghouseStateResponse, + SpotClearinghouseStateResponse, + MetaResponse, + FrontendOpenOrdersResponse, + AllMidsResponse, + MetaAndAssetCtxsResponse, + PredictedFundingsResponse, + SpotMetaResponse, +} from './hyperliquid-types.js'; diff --git a/packages/perps-controller/src/types/messenger.ts b/packages/perps-controller/src/types/messenger.ts new file mode 100644 index 00000000000..96d3a1b6f63 --- /dev/null +++ b/packages/perps-controller/src/types/messenger.ts @@ -0,0 +1,71 @@ +import type { + AccountTreeControllerGetAccountsFromSelectedAccountGroupAction, + AccountTreeControllerSelectedAccountGroupChangeEvent, +} from '@metamask/account-tree-controller'; +import type { + AccountsControllerGetSelectedAccountAction, + AccountsControllerSelectedAccountChangeEvent, +} from '@metamask/accounts-controller'; +import type { + AuthenticatedUserStorageServiceGetNotificationPreferencesAction, + AuthenticatedUserStorageServicePutNotificationPreferencesAction, +} from '@metamask/authenticated-user-storage'; +import type { GeolocationControllerGetGeolocationAction } from '@metamask/geolocation-controller'; +import type { + KeyringControllerGetStateAction, + KeyringControllerSignTypedMessageAction, +} from '@metamask/keyring-controller'; +import type { Messenger } from '@metamask/messenger'; +import type { + NetworkControllerGetStateAction, + NetworkControllerGetNetworkClientByIdAction, + NetworkControllerFindNetworkClientIdByChainIdAction, +} from '@metamask/network-controller'; +import type { AuthenticationController } from '@metamask/profile-sync-controller'; +import type { + RemoteFeatureFlagControllerGetStateAction, + RemoteFeatureFlagControllerStateChangeEvent, +} from '@metamask/remote-feature-flag-controller'; +import type { TransactionControllerAddTransactionAction } from '@metamask/transaction-controller'; + +/** + * Actions from other controllers that PerpsController is allowed to call. + */ +export type PerpsControllerAllowedActions = + | GeolocationControllerGetGeolocationAction + | NetworkControllerGetStateAction + | NetworkControllerGetNetworkClientByIdAction + | NetworkControllerFindNetworkClientIdByChainIdAction + | KeyringControllerGetStateAction + | KeyringControllerSignTypedMessageAction + | TransactionControllerAddTransactionAction + | RemoteFeatureFlagControllerGetStateAction + | AccountsControllerGetSelectedAccountAction + | AccountTreeControllerGetAccountsFromSelectedAccountGroupAction + | AuthenticationController.AuthenticationControllerGetBearerTokenAction + | AuthenticatedUserStorageServiceGetNotificationPreferencesAction + | AuthenticatedUserStorageServicePutNotificationPreferencesAction; + +/** + * Events from other controllers that PerpsController is allowed to subscribe to. + */ +export type PerpsControllerAllowedEvents = + | RemoteFeatureFlagControllerStateChangeEvent + | AccountsControllerSelectedAccountChangeEvent + | AccountTreeControllerSelectedAccountGroupChangeEvent; + +/** + * The messenger type used by PerpsController and its services. + * Defined here (rather than in PerpsController.ts) to avoid circular imports + * between the controller and service files. + * + * The first two type parameters (Actions, Events) are filled in by + * PerpsController.ts when it unions in its own actions/events. + * Services use this base type directly since they only need the allowed + * external actions/events. + */ +export type PerpsControllerMessengerBase = Messenger< + 'PerpsController', + PerpsControllerAllowedActions, + PerpsControllerAllowedEvents +>; diff --git a/packages/perps-controller/src/types/myx-types.ts b/packages/perps-controller/src/types/myx-types.ts new file mode 100644 index 00000000000..e313f0bc794 --- /dev/null +++ b/packages/perps-controller/src/types/myx-types.ts @@ -0,0 +1,157 @@ +/** + * MYX Protocol Type Definitions + * + * SDK types re-exported with MYX prefix for consistency. + * Includes types for market display, positions, orders, and trading. + */ + +import type { CaipChainId } from '@metamask/utils'; + +// ============================================================================ +// Re-export SDK Types with MYX prefix for consistency +// ============================================================================ + +export type { PoolSymbolAllResponse as MYXPoolSymbol } from '@myx-trade/sdk'; +export type { TickerDataItem as MYXTicker } from '@myx-trade/sdk'; + +// Position & order types from SDK +export type { PositionType as MYXPositionType } from '@myx-trade/sdk'; +export type { HistoryOrderItem as MYXHistoryOrderItem } from '@myx-trade/sdk'; +export type { PositionHistoryItem as MYXPositionHistoryItem } from '@myx-trade/sdk'; +export type { TradeFlowItem as MYXTradeFlowItem } from '@myx-trade/sdk'; +export type { KlineDataItemType as MYXKlineData } from '@myx-trade/sdk'; +// KlineData is declared but not exported by @myx-trade/sdk — define locally. +// Property names match the SDK's wire format (single-char keys). +export type MYXKlineWsData = { + // eslint-disable-next-line @typescript-eslint/naming-convention + E: number; // Timestamp + // eslint-disable-next-line @typescript-eslint/naming-convention + T: string; // Turnover + c: string; // Close price + h: string; // High price + l: string; // Low price + o: string; // Open price + t: number; // Timestamp + v: string; // Volume +}; +export type { KlineDataResponse as MYXKlineDataResponse } from '@myx-trade/sdk'; + +// SDK enums (re-exported as types since they're const objects in the SDK) +export { + Direction as MYXDirection, + OrderType as MYXOrderType, + OperationType as MYXOperationType, + TriggerType as MYXTriggerType, + OrderStatus as MYXOrderStatus, + TimeInForce as MYXTimeInForce, +} from '@myx-trade/sdk'; + +// History enums +export { + DirectionEnum as MYXDirectionEnum, + OrderTypeEnum as MYXOrderTypeEnum, + OperationEnum as MYXOperationEnum, + OrderStatusEnum as MYXOrderStatusEnum, + ExecTypeEnum as MYXExecTypeEnum, + TradeFlowTypeEnum as MYXTradeFlowTypeEnum, +} from '@myx-trade/sdk'; + +// Trading params types +export type { PlaceOrderParams as MYXPlaceOrderParams } from '@myx-trade/sdk'; +export type { PositionTpSlOrderParams as MYXPositionTpSlOrderParams } from '@myx-trade/sdk'; +export type { GetHistoryOrdersParams as MYXGetHistoryOrdersParams } from '@myx-trade/sdk'; + +// ============================================================================ +// Network Configuration Types +// ============================================================================ + +/** + * MYX Network type - mainnet or testnet + */ +export type MYXNetwork = 'mainnet' | 'testnet'; + +/** + * MYX Endpoint configuration for a single network + */ +export type MYXEndpointConfig = { + http: string; + ws: string; +}; + +/** + * MYX Endpoints for all networks + */ +export type MYXEndpoints = { + mainnet: MYXEndpointConfig; + testnet: MYXEndpointConfig; +}; + +/** + * MYX Asset network configuration (token addresses per network) + */ +export type MYXAssetNetworkConfig = { + chainId: CaipChainId; + tokenAddress: string; +}; + +/** + * MYX Asset configurations by network + */ +export type MYXAssetConfigs = { + // USDT is the token symbol used as API key - not a variable name + // eslint-disable-next-line @typescript-eslint/naming-convention + USDT: { + mainnet: MYXAssetNetworkConfig; + testnet: MYXAssetNetworkConfig; + }; +}; + +// ============================================================================ +// Market Overlap Configuration +// ============================================================================ + +/** + * Markets that overlap with HyperLiquid + * These are excluded from MYX display in v1.0 to avoid confusion + * In Stage 7, we'll implement market collision handling + */ +export const MYX_HL_OVERLAPPING_MARKETS = [ + 'BTC', + 'ETH', + 'BNB', + 'PUMP', + 'WLFI', +] as const; + +export type MYXOverlappingMarket = (typeof MYX_HL_OVERLAPPING_MARKETS)[number]; + +// ============================================================================ +// Auth Configuration (passed from init file via babel-transformed env vars) +// ============================================================================ + +/** + * MYX auth credentials passed at construction time. + * Eliminates runtime `process.env` lookups — values come from the init file + * where `process.env.X` is babel-transformed at build time. + */ +export type MYXAuthConfig = { + appId: string; + apiSecret: string; + brokerAddress: string; +}; + +// ============================================================================ +// Client Service Types +// ============================================================================ + +/** + * Price callback for REST polling + */ +export type MYXPriceCallback = ( + tickers: { symbol: string; price: string; change24h: number }[], +) => void; + +/** + * Error callback for client operations + */ +export type MYXErrorCallback = (error: Error) => void; diff --git a/packages/perps-controller/src/types/perps-types.ts b/packages/perps-controller/src/types/perps-types.ts new file mode 100644 index 00000000000..45e4fdac5de --- /dev/null +++ b/packages/perps-controller/src/types/perps-types.ts @@ -0,0 +1,237 @@ +/** + * Test result states for SDK validation + */ +import { CandlePeriod } from '../constants/chartConfig.js'; + +/** + * Order type enumeration (placement type). + * + * - `market` / `limit`: immediate placement. + * - `stop_*` / `take_profit_*`: trigger placement — the order rests off-book until + * `OrderParams.triggerPrice` is reached, then executes as a market or limit order + * according to the suffix. + * - `twap` / `scale` / `chase`: strategy placement — one request expands into an + * execution schedule rather than a single resting order. See `StrategyOrderType`. + * + * Provider-agnostic by design: no protocol vocabulary (HyperLiquid's `tpsl`, + * `triggerPx`, `isMarket`) appears in the params model. + */ +export type OrderType = + | 'market' + | 'limit' + | 'stop_market' + | 'stop_limit' + | 'take_profit_market' + | 'take_profit_limit' + | 'twap' + | 'scale' + | 'chase'; + +/** + * The subset of `OrderType` values that require a trigger price. + * + * Spelled out rather than derived with `Exclude`: every order type added to + * `OrderType` that is neither `market` nor `limit` would otherwise be pulled in + * here automatically and start demanding a trigger price it has no concept of. + */ +export type TriggerOrderType = + | 'stop_market' + | 'stop_limit' + | 'take_profit_market' + | 'take_profit_limit'; + +/** + * The subset of `OrderType` values that describe an execution *strategy* rather + * than a single order. + * + * - `twap`: slice the size over `OrderParams.twapDuration` minutes. Placed and + * cancelled through the venue's own TWAP endpoints, not the order book. + * - `scale`: fan out `OrderParams.scaleNumOrders` limit orders between + * `OrderParams.scaleMinPrice` and `OrderParams.scaleMaxPrice`, evenly sized + * unless `OrderParams.scaleSkew` weights them along the ladder. + * - `chase`: rest a post-only order at the near touch and re-price it as the + * touch moves, until it fills or the chase window closes. + * + * A strategy placement returns a *handle* in `OrderResult.orderId` — a venue + * TWAP id, or a client-generated group/session id — which is what + * `CancelOrderParams` takes together with the matching `orderType`. + */ +export type StrategyOrderType = 'twap' | 'scale' | 'chase'; + +/** + * Every `OrderType` that resolves to a single order the exchange can be handed + * directly — market, limit, and the four trigger placements. + * + * Derived with `Exclude` on purpose, the safe direction: it *shrinks* as + * `StrategyOrderType` grows, so a strategy added later is kept out of the + * single-order helpers automatically rather than silently falling through them. + */ +export type OrdinaryOrderType = Exclude; + +/** + * Whether a triggered order executes as a market or a limit order. + */ +export type OrderExecution = 'market' | 'limit'; + +/** + * How an attached TP/SL relates to what it protects. + * + * - `none`: the order carries no attached TP/SL. + * - `order`: the TP/SL belongs to this order and is cancelled with it. + * - `position`: the TP/SL belongs to the resulting position and covers all of it. + * + * Provider-agnostic replacement for the HyperLiquid-shaped `OrderParams.grouping`. + */ +export type TpslLinkage = 'none' | 'order' | 'position'; + +/** + * Which side of the mark price a trigger order fires on. + * `stop` protects against adverse moves, `take_profit` realizes gains. + */ +export type TriggerDirection = 'stop' | 'take_profit'; + +export type TestResultStatus = + | 'idle' + | 'loading' + | 'success' + | 'warning' + | 'error'; + +/** + * Test result data structure + */ +export type TestResult = { + status: TestResultStatus; + message: string; + data?: Record; +}; + +/** + * SDK test types + */ +export type SDKTestType = 'connection' | 'asset-listing' | 'websocket'; + +/** + * Hyperliquid asset interface (basic structure) + */ +export type HyperliquidAsset = { + name: string; + [key: string]: unknown; +}; + +/** + * Represents a single candlestick data point + */ +export type CandleStick = { + time: number; + open: string; + high: string; + low: string; + close: string; + volume: string; +}; + +/** + * Represents historical candlestick data for a specific symbol and interval + */ +export type CandleData = { + /** Asset identifier (e.g., 'BTC', 'ETH'). Protocol-agnostic terminology for multi-provider support. */ + symbol: string; + interval: CandlePeriod; + candles: CandleStick[]; +}; + +// Configuration types +export type { + HyperLiquidEndpoints, + AssetNetworkConfig, + HyperLiquidAssetConfigs, + BridgeContractConfig, + HyperLiquidBridgeContracts, + TransportReconnectConfig, + TransportKeepAliveConfig, + HyperLiquidTransportConfig, + TradingAmountConfig, + TradingDefaultsConfig, + FeeRatesConfig, + HyperLiquidNetwork, +} from './config.js'; + +// Token types +export type { PerpsToken } from './token.js'; + +/** + * Order form state for the Perps order view + */ +export type OrderFormState = { + asset: string; + direction: 'long' | 'short'; + amount: string; + leverage: number; + balancePercent: number; + takeProfitPrice?: string; + stopLossPrice?: string; + limitPrice?: string; + type: OrderType; +}; + +export type OrderDirection = 'long' | 'short'; + +/** + * Options for reconnecting the Perps connection + */ +export type ReconnectOptions = { + /** + * If true, forces immediate disconnect and cancels all pending operations. + * Use for user-initiated retry actions. + * If false (default), waits for pending operations to complete. + * Use for automatic reconnections like account switches. + */ + force?: boolean; +}; + +/** + * Extended asset metadata including Growth Mode fields not in SDK types. + * The HyperLiquid API returns these fields but the SDK doesn't type them. + * + * @see https://hyperliquid.gitbook.io/hyperliquid-docs/trading/fees#fee-formula-for-developers + */ +export type ExtendedAssetMeta = { + name: string; + szDecimals: number; + maxLeverage: number; + /** Per-asset Growth Mode status - "enabled" means 90% fee reduction */ + growthMode?: 'enabled' | null; + /** ISO timestamp of last Growth Mode change */ + lastGrowthModeChangeTime?: string; +}; + +/** + * Extended perp DEX info including fee scale fields not in SDK types. + * The HyperLiquid API returns these fields but the SDK doesn't type them. + * + * @see https://hyperliquid.gitbook.io/hyperliquid-docs/trading/fees#fee-formula-for-developers + */ +export type ExtendedPerpDex = { + name: string; + fullName?: string; + deployer?: string; + /** DEX-level fee scale (e.g., "1.0" for xyz DEX) - determines HIP-3 multiplier */ + deployerFeeScale?: string; + /** ISO timestamp of last fee scale change */ + lastDeployerFeeScaleChangeTime?: string; +}; + +/** + * Unified DEX discovery state — single source of truth for all perpDexs() derivatives. + * Replaces three separate caches (#cachedAllPerpDexs, #cachedValidatedDexs, #perpDexsCache) + * to eliminate desync bugs by construction. + */ +export type DexDiscoveryState = { + /** perpDexs() API response (raw objects with deployerFeeScale etc.) */ + raw: (ExtendedPerpDex | null)[]; + /** Feature-flag-filtered DEX names (null = main DEX, strings = HIP-3 DEXs) */ + validated: (string | null)[]; + /** When raw was fetched — used for fee-scale TTL checks */ + timestamp: number; +}; diff --git a/packages/perps-controller/src/types/token.ts b/packages/perps-controller/src/types/token.ts new file mode 100644 index 00000000000..1dde5a92128 --- /dev/null +++ b/packages/perps-controller/src/types/token.ts @@ -0,0 +1,22 @@ +import type { Hex, CaipChainId } from '@metamask/utils'; + +/** + * Token interface for Perps trading. + * Independent from BridgeToken to avoid Mobile-only dependencies. + * Shape matches BridgeToken for backward compatibility. + */ +export type PerpsToken = { + address: string; + name?: string; + symbol: string; + image?: string; + decimals: number; + chainId: Hex | CaipChainId; + balance?: string; + balanceFiat?: string; + tokenFiatAmount?: number; + currencyExchangeRate?: number; + noFee?: { isSource: boolean; isDestination: boolean }; + aggregators?: string[]; + metadata?: Record; +}; diff --git a/packages/perps-controller/src/types/transactionTypes.ts b/packages/perps-controller/src/types/transactionTypes.ts new file mode 100644 index 00000000000..de7fe0bcd1e --- /dev/null +++ b/packages/perps-controller/src/types/transactionTypes.ts @@ -0,0 +1,79 @@ +/** + * Shared transaction types for Perps deposits and withdrawals + * Provides a unified structure while maintaining separate use cases + */ +import { hasProperty } from '@metamask/utils'; + +/** + * Base type with core properties shared between all transaction results + * All properties are JSON serializable for controller state compatibility + */ +export type BaseTransactionResult = { + amount: string; + asset: string; + txHash?: string; + timestamp: number; + success: boolean; // explicit to avoid regressions +}; + +/** + * For transient UI feedback (toasts, progress indicators) + * Used for immediate success/failure notifications + * JSON serializable for controller state + */ +export type LastTransactionResult = { + amount: string; + asset: string; + txHash: string; + timestamp: number; + success: boolean; + error: string; + [key: string]: string | number | boolean; +}; + +/** + * For persistent transaction history tracking + * Used for transaction history display and detailed status tracking + * JSON serializable for controller state + */ +export type TransactionStatus = 'pending' | 'bridging' | 'completed' | 'failed'; + +export type TransactionRecord = { + id: string; + amount: string; + asset: string; + txHash?: string; + timestamp: number; + success: boolean; + status: TransactionStatus; + destination?: string; // mainly for withdrawals + source?: string; // mainly for deposits + transactionId?: string; // generic - could be withdrawalId or depositId + // Legacy fields for backward compatibility + withdrawalId?: string; // for withdrawals + depositId?: string; // for deposits +}; + +/** + * Type guard to check if a transaction result is a TransactionRecord + * + * @param result - The transaction result to check. + * @returns True if the result is a TransactionRecord with id and status fields. + */ +export function isTransactionRecord( + result: LastTransactionResult | TransactionRecord, +): result is TransactionRecord { + return hasProperty(result, 'id') && hasProperty(result, 'status'); +} + +/** + * Type guard to check if a transaction result is a LastTransactionResult + * + * @param result - The transaction result to check. + * @returns True if the result is a LastTransactionResult without id or status fields. + */ +export function isLastTransactionResult( + result: LastTransactionResult | TransactionRecord, +): result is LastTransactionResult { + return !hasProperty(result, 'id') || !hasProperty(result, 'status'); +} diff --git a/packages/perps-controller/src/utils/accountUtils.ts b/packages/perps-controller/src/utils/accountUtils.ts new file mode 100644 index 00000000000..9ca57314939 --- /dev/null +++ b/packages/perps-controller/src/utils/accountUtils.ts @@ -0,0 +1,372 @@ +/** + * Account utilities for Perps components + * Handles account selection and EVM account filtering + */ +import type { InternalAccount } from '@metamask/keyring-internal-api'; + +import { PERPS_CONSTANTS } from '../constants/perpsConfig.js'; +import type { SpotClearinghouseStateResponse } from '../types/hyperliquid-types.js'; +import type { AccountState, PerpsInternalAccount } from '../types/index.js'; + +const EVM_ACCOUNT_TYPES = new Set(['eip155:eoa', 'eip155:erc4337']); + +function isEvmAccountType(type: string): boolean { + return EVM_ACCOUNT_TYPES.has(type); +} + +export function findEvmAccount( + accounts: (InternalAccount | PerpsInternalAccount)[], +): InternalAccount | PerpsInternalAccount | null { + const evmAccount = accounts.find( + (account) => + account && isEvmAccountType(account.type as InternalAccount['type']), + ); + return evmAccount ?? null; +} + +export function getEvmAccountFromAccountGroup( + accounts: (InternalAccount | PerpsInternalAccount)[], +): { address: string } | undefined { + const evmAccount = findEvmAccount(accounts); + return evmAccount ? { address: evmAccount.address } : undefined; +} + +export function getSelectedEvmAccount( + accounts: (InternalAccount | PerpsInternalAccount)[], +): { address: string } | undefined { + return getEvmAccountFromAccountGroup(accounts); +} + +type SelectedEvmAccountMessenger = { + call( + actionType: + | 'AccountsController:getSelectedAccount' + | 'AccountTreeController:getAccountsFromSelectedAccountGroup', + ): unknown; +}; + +function isAccountLike( + value: unknown, +): value is InternalAccount | PerpsInternalAccount { + const account = value as { address?: unknown; type?: unknown } | null; + + return ( + typeof value === 'object' && + value !== null && + typeof account?.address === 'string' && + typeof account.type === 'string' + ); +} + +export function getSelectedEvmAccountDetailsFromMessenger( + messenger: SelectedEvmAccountMessenger, +): InternalAccount | PerpsInternalAccount | undefined { + try { + const selectedAccount = messenger.call( + 'AccountsController:getSelectedAccount', + ); + if (isAccountLike(selectedAccount)) { + const evmAccount = findEvmAccount([selectedAccount]); + if (evmAccount) { + return evmAccount; + } + } + } catch { + // Fall back to the selected account group if the direct lookup is unavailable. + } + + try { + const selectedAccountGroup = messenger.call( + 'AccountTreeController:getAccountsFromSelectedAccountGroup', + ); + return Array.isArray(selectedAccountGroup) + ? (findEvmAccount(selectedAccountGroup.filter(isAccountLike)) ?? + undefined) + : undefined; + } catch { + return undefined; + } +} + +export function getSelectedEvmAccountFromMessenger( + messenger: SelectedEvmAccountMessenger, +): { address: string } | undefined { + const evmAccount = getSelectedEvmAccountDetailsFromMessenger(messenger); + + return evmAccount ? { address: evmAccount.address } : undefined; +} + +export type ReturnOnEquityInput = { + unrealizedPnl: string | number; + returnOnEquity: string | number; +}; + +export function calculateWeightedReturnOnEquity( + accounts: ReturnOnEquityInput[], +): string { + if (accounts.length === 0) { + return '0'; + } + + let totalWeightedROE = 0; + let totalMarginUsed = 0; + + for (const account of accounts) { + const unrealizedPnl = + typeof account.unrealizedPnl === 'string' + ? Number.parseFloat(account.unrealizedPnl) + : account.unrealizedPnl; + const returnOnEquity = + typeof account.returnOnEquity === 'string' + ? Number.parseFloat(account.returnOnEquity) + : account.returnOnEquity; + + if (Number.isNaN(unrealizedPnl) || Number.isNaN(returnOnEquity)) { + continue; + } + + if (returnOnEquity === 0) { + continue; + } + + const marginUsed = (unrealizedPnl / returnOnEquity) * 100; + + if (Number.isNaN(marginUsed) || marginUsed <= 0) { + continue; + } + + const roeDecimal = returnOnEquity / 100; + + totalWeightedROE += roeDecimal * marginUsed; + totalMarginUsed += marginUsed; + } + + if (totalMarginUsed <= 0) { + return '0'; + } + + const weightedROE = (totalWeightedROE / totalMarginUsed) * 100; + return weightedROE.toString(); +} + +// The release-branch balance bridge is USDC-only. Non-USDC spot assets must +// not inflate the balances shown or validated by withdraw/payment flows. +const SPOT_COLLATERAL_COINS = new Set(['USDC']); + +export function getSpotBalance( + spotState?: SpotClearinghouseStateResponse | null, +): number { + if (!spotState?.balances || !Array.isArray(spotState.balances)) { + return 0; + } + + return spotState.balances.reduce( + (sum: number, balance: { coin?: string; total?: string }) => { + if (!balance.coin || !SPOT_COLLATERAL_COINS.has(balance.coin)) { + return sum; + } + const value = parseFloat(balance.total ?? '0'); + return Number.isFinite(value) ? sum + value : sum; + }, + 0, + ); +} + +export function getSpotHold( + spotState?: SpotClearinghouseStateResponse | null, +): number { + if (!spotState?.balances || !Array.isArray(spotState.balances)) { + return 0; + } + + return spotState.balances.reduce( + (sum: number, balance: { coin?: string; hold?: string }) => { + if (!balance.coin || !SPOT_COLLATERAL_COINS.has(balance.coin)) { + return sum; + } + const value = parseFloat(balance.hold ?? '0'); + return Number.isFinite(value) ? sum + value : sum; + }, + 0, + ); +} + +/** + * Options controlling how `addSpotBalanceToAccountState` folds spot balance + * into the three-field AccountState contract. + */ +export type AddSpotBalanceOptions = { + /** + * When `true`, free spot USDC contributes to both `spendableBalance` and + * `withdrawableBalance` in addition to `totalBalance` — appropriate for + * venues where spot is automatically used as perps collateral (e.g. + * HyperLiquid Unified/Portfolio mode, where `withdraw3` draws from the + * unified ledger). + * + * When `false`, free spot contributes to `totalBalance` only; spendable + * and withdrawable stay perps-only — appropriate for venues where spot + * is a separate ledger the backend cannot auto-draw from (e.g. HL + * Standard mode). The caller is responsible for translating + * provider-specific state into this flag. + * + * Defaults to `true` for backward compatibility with call sites that + * haven't yet been wired with provider-specific context. + */ + foldIntoCollateral?: boolean; +}; + +/** + * Add spot USDC to the AccountState contract. Caller decides whether the + * spot balance counts as perps collateral via `options.foldIntoCollateral` + * — the util stays provider-agnostic. + * + * @param accountState - Base AccountState produced by a provider adapter. + * @param spotState - Raw spot clearinghouse response (HL-shaped); null or missing means no spot balance and the state is returned unchanged. + * @param options - See {@link AddSpotBalanceOptions}. + * @returns AccountState with spot folded into `totalBalance` always, and into spendable/withdrawable when `foldIntoCollateral` is true. + */ +export function addSpotBalanceToAccountState( + accountState: AccountState, + spotState?: SpotClearinghouseStateResponse | null, + options?: AddSpotBalanceOptions, +): AccountState { + // Fail-closed default: align with `hyperLiquidModeFoldsSpot(null) → false`. + // A caller that omits `options` should NOT silently fold spot — that would + // over-report withdrawable funds for Standard / dexAbstraction users. + const foldIntoCollateral = options?.foldIntoCollateral ?? false; + + const spotBalance = getSpotBalance(spotState); + const spotHold = getSpotHold(spotState); + const freeSpot = Math.max(0, spotBalance - spotHold); + + const currentTotal = parseFloat(accountState.totalBalance); + const currentSpendable = parseFloat(accountState.spendableBalance); + const currentWithdrawable = parseFloat(accountState.withdrawableBalance); + + // Preserve sentinel totals (e.g. PERPS_CONSTANTS.FallbackDataDisplay '--') + // rather than coercing them to NaN. + if (!Number.isFinite(currentTotal)) { + return accountState; + } + + if (spotBalance === 0) { + // No spot wealth means no hold either in the HL payload shape, so the + // later totalBalance adjustment would also be a no-op. + return accountState; + } + + // Folding is gated strictly on the resolved abstraction mode (see callers' + // `foldIntoCollateral` argument). Standard / DEX-abstraction users keep + // perps and spot independent, so spot must NOT surface as a perps- + // withdrawable balance for them — withdraw3 only draws from the perps + // ledger in those modes. Unified / portfolio-margin users get the fold; + // live callers fail-CLOSED via `hyperLiquidModeFoldsSpot` when mode is + // unresolved. + const nextSpendable = resolveFoldedBalance( + currentSpendable, + accountState.spendableBalance, + freeSpot, + foldIntoCollateral, + ); + const nextWithdrawable = resolveFoldedBalance( + currentWithdrawable, + accountState.withdrawableBalance, + freeSpot, + foldIntoCollateral, + ); + + // Total always reflects combined wealth: subtract spotHold to avoid + // double-counting on Unified/PM accounts where marginSummary.accountValue + // already includes the margin that HL surfaces via spot.hold. Standard + // mode has spotHold = 0 by construction, so the subtraction is a no-op. + const nextTotal = currentTotal + spotBalance - spotHold; + + return { + ...accountState, + totalBalance: nextTotal.toString(), + spendableBalance: nextSpendable, + withdrawableBalance: nextWithdrawable, + }; +} + +function resolveFoldedBalance( + currentNumeric: number, + currentRaw: string, + freeSpot: number, + foldIntoCollateral: boolean, +): string { + if (!foldIntoCollateral) { + return currentRaw; + } + if (Number.isFinite(currentNumeric)) { + return (currentNumeric + freeSpot).toString(); + } + // Non-finite currentNumeric means the adapter passed a sentinel like + // `PERPS_CONSTANTS.FallbackDataDisplay` ("--") during loading. Preserve + // the sentinel rather than synthesising a numeric fold; the caller's + // UI treats "--" as "loading" and would otherwise show a misleading + // spot-only figure while the per-DEX perps fetch is still in flight. + return currentRaw; +} + +/** + * Aggregate multiple per-DEX AccountState objects into one by summing numeric fields. + * ROE is recalculated as (totalUnrealizedPnl / totalMarginUsed) * 100. + * + * @param states - The array of per-DEX account states to aggregate. + * @returns The combined account state with summed balances and recalculated ROE. + */ +export function aggregateAccountStates(states: AccountState[]): AccountState { + const fallback: AccountState = { + spendableBalance: PERPS_CONSTANTS.FallbackDataDisplay, + withdrawableBalance: PERPS_CONSTANTS.FallbackDataDisplay, + totalBalance: PERPS_CONSTANTS.FallbackDataDisplay, + marginUsed: PERPS_CONSTANTS.FallbackDataDisplay, + unrealizedPnl: PERPS_CONSTANTS.FallbackDataDisplay, + returnOnEquity: PERPS_CONSTANTS.FallbackDataDisplay, + }; + + if (states.length === 0) { + return fallback; + } + + const aggregated = states.reduce((acc, state, index) => { + if (index === 0) { + return { ...state }; + } + + return { + spendableBalance: ( + parseFloat(acc.spendableBalance) + parseFloat(state.spendableBalance) + ).toString(), + withdrawableBalance: ( + parseFloat(acc.withdrawableBalance) + + parseFloat(state.withdrawableBalance) + ).toString(), + totalBalance: ( + parseFloat(acc.totalBalance) + parseFloat(state.totalBalance) + ).toString(), + marginUsed: ( + parseFloat(acc.marginUsed) + parseFloat(state.marginUsed) + ).toString(), + unrealizedPnl: ( + parseFloat(acc.unrealizedPnl) + parseFloat(state.unrealizedPnl) + ).toString(), + returnOnEquity: '0', + }; + }, fallback); + + // Recalculate ROE across all DEXs + const totalMarginUsed = parseFloat(aggregated.marginUsed); + const totalUnrealizedPnl = parseFloat(aggregated.unrealizedPnl); + if (totalMarginUsed > 0) { + aggregated.returnOnEquity = ( + (totalUnrealizedPnl / totalMarginUsed) * + 100 + ).toString(); + } else { + aggregated.returnOnEquity = '0'; + } + + return aggregated; +} diff --git a/packages/perps-controller/src/utils/capabilitySymbols.ts b/packages/perps-controller/src/utils/capabilitySymbols.ts new file mode 100644 index 00000000000..ce952e822bd --- /dev/null +++ b/packages/perps-controller/src/utils/capabilitySymbols.ts @@ -0,0 +1,24 @@ +/** + * Check whether a capability symbol has the provider-independent shape the + * caller allows. + * + * @param symbol - Market symbol supplied by a consumer. + * @param options - Provider route syntax supported by the caller. + * @param options.allowProviderRoute - Whether one non-empty `dex:market` + * route prefix is allowed. + * @returns Whether the symbol is non-empty, contains no whitespace, and uses + * the allowed route shape. + */ +export function isValidCapabilitySymbol( + symbol: string, + options: { allowProviderRoute: boolean }, +): boolean { + if (symbol.length === 0 || /\s/u.test(symbol)) { + return false; + } + + const routeParts = symbol.split(':'); + return options.allowProviderRoute + ? routeParts.length <= 2 && routeParts.every((part) => part.length > 0) + : routeParts.length === 1; +} diff --git a/packages/perps-controller/src/utils/coalescePerpsRestRequest.ts b/packages/perps-controller/src/utils/coalescePerpsRestRequest.ts new file mode 100644 index 00000000000..31d80c999d0 --- /dev/null +++ b/packages/perps-controller/src/utils/coalescePerpsRestRequest.ts @@ -0,0 +1,106 @@ +import { PERFORMANCE_CONFIG } from '../constants/perpsConfig.js'; + +// Rapid perps market switching (candle bridge + activity tab burst) drives +// duplicate REST calls against api.hyperliquid.xyz and occasionally trips 429. +// This helper collapses concurrent identical calls into one in-flight promise +// and serves a short TTL cache to absorb the burst. Explicit refresh bypasses +// the cache so user-initiated refetch still re-runs. +// +// Lives at the service layer (MarketDataService) rather than per-hook so every +// current and future caller — hooks, controller, aggregated provider — is +// deduped automatically. Centralized at MarketDataService because it is +// already a single choke point. + +type CacheEntry = { + value: TValue; + expiresAt: number; +}; + +const inflight = new Map>(); +const cache = new Map>(); + +export type CoalesceOptions = { + /** + * Cache TTL in milliseconds. Defaults to + * {@link PERFORMANCE_CONFIG.PerpsRestCoalesceTtlMs}. + */ + ttlMs?: number; + /** + * Bypass the cache and any in-flight promise. The fresh result will be + * written back to the cache under the same key. + */ + forceRefresh?: boolean; +}; + +/** + * Coalesce an idempotent perps REST call. + * + * - If a fresh cached value exists for `key`, it is returned immediately. + * - If an in-flight promise exists for `key`, it is shared with the caller. + * - Otherwise, `fetcher` runs once; the result populates the cache for `ttlMs`. + * + * `forceRefresh` skips both cache and in-flight dedup. + * + * @param key - Stable cache key identifying this logical REST call. + * @param fetcher - Thunk that performs the REST call when no cache/inflight hit. + * @param options - Optional overrides for TTL and forceRefresh behavior. + * @returns The fetched (or cached) value. + */ +export function coalescePerpsRestRequest( + key: string, + fetcher: () => Promise, + options: CoalesceOptions = {}, +): Promise { + const ttlMs = options.ttlMs ?? PERFORMANCE_CONFIG.PerpsRestCoalesceTtlMs; + const forceRefresh = options.forceRefresh ?? false; + + if (forceRefresh) { + cache.delete(key); + } else { + const now = Date.now(); + const cached = cache.get(key) as CacheEntry | undefined; + if (cached) { + if (cached.expiresAt > now) { + return Promise.resolve(cached.value); + } + // Evict expired entry so callers with per-call-unique keys (e.g. + // CandleStreamChannel historical paging with per-page endTime) do + // not accumulate dead blobs for the life of the process. + cache.delete(key); + } + const existing = inflight.get(key) as Promise | undefined; + if (existing !== undefined) { + return existing; + } + } + + const run = fetcher().then( + (value) => { + // Only the currently-tracked in-flight promise writes to cache. A stale + // in-flight (e.g. one that was racing a later forceRefresh=true caller) + // must not clobber the fresh value once it finally resolves. + if (inflight.get(key) === run) { + cache.set(key, { value, expiresAt: Date.now() + ttlMs }); + inflight.delete(key); + } + return value; + }, + (error) => { + if (inflight.get(key) === run) { + inflight.delete(key); + } + throw error; + }, + ); + + inflight.set(key, run); + return run; +} + +/** + * Test-only: wipe all cached entries and in-flight promises. + */ +export function resetPerpsRestCacheForTests(): void { + cache.clear(); + inflight.clear(); +} diff --git a/packages/perps-controller/src/utils/errorUtils.ts b/packages/perps-controller/src/utils/errorUtils.ts new file mode 100644 index 00000000000..e01ebbd95a1 --- /dev/null +++ b/packages/perps-controller/src/utils/errorUtils.ts @@ -0,0 +1,113 @@ +/** + * Utility functions for error handling across Perps controller code. + * Includes generic error helpers and Perps error classification helpers. + */ +import { hasProperty } from '@metamask/utils'; + +import { PERPS_ERROR_CODES } from '../perpsErrorCodes.js'; + +/** + * Detects expected cancellation/abort errors that should not be reported to Sentry. + * These occur during normal navigation or view teardown when in-flight fetch requests + * are cancelled via AbortController. + * + * @param error - The error to check. + * @returns True if the error is an expected abort/cancellation. + */ +export function isAbortError(error: unknown): boolean { + if (error instanceof Error) { + return ( + error.name === 'AbortError' || + error.message.includes('signal is aborted') || + error.message.includes('The operation was aborted') + ); + } + return false; +} + +/** + * Detects keyring-locked errors, including SDK-wrapped errors that preserve the + * original error in `cause`. + * + * @param error - The error to check. + * @returns True if any error in the cause chain is KEYRING_LOCKED. + */ +export function isKeyringLockedError(error: unknown): boolean { + let current: unknown = error; + const seen = new Set(); + + while (current instanceof Error && !seen.has(current)) { + seen.add(current); + + if (current.message === PERPS_ERROR_CODES.KEYRING_LOCKED) { + return true; + } + + current = (current as { cause?: unknown }).cause; + } + + return false; +} + +/** + * Ensures we have a proper Error object for logging. + * Converts unknown/string errors to proper Error instances. + * Handles undefined/null specially for better Sentry context. + * + * @param error - The caught error (could be Error, string, or unknown) + * @param context - Optional context string to help identify the source of the error + * @returns A proper Error instance + */ +export function ensureError(error: unknown, context?: string): Error { + if (error instanceof Error) { + return error; + } + // Handle undefined/null specifically for better error context + // e.g. Hyperliquid SDK may reject with undefined when AbortSignal.reason is not set + if (error === undefined || error === null) { + const baseMessage = 'Unknown error (no details provided)'; + return new Error(context ? `${baseMessage} [${context}]` : baseMessage); + } + if (typeof error === 'string') { + return new Error(error); + } + return new Error( + typeof error === 'object' && error !== null && hasProperty(error, 'message') + ? String((error as { message: unknown }).message) + : 'Unknown error', + ); +} + +/** + * Hyperliquid rejects user-scoped exchange writes (`agentSetAbstraction`, + * `userSetAbstraction`, `setReferrer`, ...) with this exact message when the + * wallet has never funded a Hyperliquid account. It is a benign pre-account + * state, not an error we should forward to Sentry. + * + * @param error - The caught error. + * @returns True if the error matches the Hyperliquid "user not on chain yet" rejection. + */ +export function isHyperLiquidUserNotFoundError(error: unknown): boolean { + const lower = ensureError(error).message.toLowerCase(); + return ( + lower.includes('user or api wallet') && lower.includes('does not exist') + ); +} + +/** + * Hyperliquid rejects every single-signer exchange write for an account that + * has been converted to multi-sig (`ApiRequestError: Multi-sig required`). + * MetaMask signs Perps actions with a single agent/user wallet, so this is a + * permanent account-shape condition rather than a failure we should retry or + * forward to Sentry. Only the hyphenated spelling has been observed from the + * venue; the unhyphenated variant is matched defensively. + * + * @param error - The caught error. + * @returns True if the error indicates multi-sig signing is required. + */ +export function isHyperLiquidMultiSigRequiredError(error: unknown): boolean { + const lower = ensureError(error).message.toLowerCase(); + return ( + lower.includes('multi-sig required') || lower.includes('multisig required') + ); +} diff --git a/packages/perps-controller/src/utils/hyperLiquidAbstraction.ts b/packages/perps-controller/src/utils/hyperLiquidAbstraction.ts new file mode 100644 index 00000000000..606befac4d4 --- /dev/null +++ b/packages/perps-controller/src/utils/hyperLiquidAbstraction.ts @@ -0,0 +1,26 @@ +import type { HyperLiquidAbstractionMode } from '../types/hyperliquid-types.js'; + +const MIGRATABLE_ABSTRACTION_MODES = new Set([ + 'dexAbstraction', + 'default', + 'disabled', +]); + +/** + * Determine whether unified-account setup should be deferred until a user + * explicitly starts a trading or withdrawal action. + * + * @param currentMode - The user's current HyperLiquid abstraction mode. + * @param allowUserSigning - Whether the caller is allowed to trigger wallet signing. + * @returns True when migration would require a signing-backed mutation that should be deferred. + */ +export function shouldDeferUnifiedAccountSetup( + currentMode: HyperLiquidAbstractionMode | undefined, + allowUserSigning: boolean, +): boolean { + return ( + !allowUserSigning && + currentMode !== undefined && + MIGRATABLE_ABSTRACTION_MODES.has(currentMode) + ); +} diff --git a/packages/perps-controller/src/utils/hyperLiquidAdapter.ts b/packages/perps-controller/src/utils/hyperLiquidAdapter.ts new file mode 100644 index 00000000000..eb803b6b370 --- /dev/null +++ b/packages/perps-controller/src/utils/hyperLiquidAdapter.ts @@ -0,0 +1,696 @@ +import { hasProperty, Hex, isHexString } from '@metamask/utils'; + +import { + BASIS_POINTS_DIVISOR, + HIP3_ASSET_ID_CONFIG, +} from '../constants/hyperLiquidConfig.js'; +import { + DECIMAL_PRECISION_CONFIG, + ORDER_SLIPPAGE_CONFIG, +} from '../constants/perpsConfig.js'; +import { PERPS_ERROR_CODES } from '../perpsErrorCodes.js'; +import type { + AssetPosition, + FrontendOrder, + ClearinghouseStateResponse, + MetaResponse, + SDKOrderParams, +} from '../types/hyperliquid-types.js'; +import type { + AccountState, + MarketInfo, + Order, + OrderParams as PerpsOrderParams, + Position, + PositionTriggerOrder, + RawLedgerUpdate, + UserHistoryItem, +} from '../types/index.js'; +import type { TpslLinkage, TriggerOrderType } from '../types/perps-types.js'; +import { + buildTriggerOrderType, + classifyTriggerDirection, + getTriggerDirection, + getTriggerExecution, + isLimitExecutionOrderType, + isTriggerOrderType, + toSDKTimeInForce, +} from './orderTypes.js'; +import { + countSignificantFigures, + roundToSignificantFigures, +} from './significantFigures.js'; + +type FrontendOrderWithParentTpsl = FrontendOrder & { + takeProfitPrice?: unknown; + stopLossPrice?: unknown; + takeProfitOrderId?: unknown; + stopLossOrderId?: unknown; +}; + +export const HYPERLIQUID_SCALE_CLOID_MARKER = '4d4d5343'; + +/** + * Recover the Scale group shared by MetaMask-generated rung CLOIDs. + * + * @param clientOrderId - HyperLiquid client order ID. + * @returns The Scale handle, or undefined for an unrelated order. + */ +const readScaleGroupId = (clientOrderId: string | null): string | undefined => { + const normalized = clientOrderId?.toLowerCase(); + if ( + normalized?.length !== 34 || + !normalized.startsWith(`0x${HYPERLIQUID_SCALE_CLOID_MARKER}`) + ) { + return undefined; + } + return `scale:${normalized.slice(2, -2)}`; +}; + +const readOptionalString = (value: unknown): string | undefined => + typeof value === 'string' && value.length > 0 ? value : undefined; + +const readOptionalOrderId = (value: unknown): string | undefined => { + if (typeof value === 'string' && value.length > 0) { + return value; + } + + if (typeof value === 'number' && Number.isFinite(value)) { + return value.toString(); + } + + return undefined; +}; + +const getParentTpslMetadata = ( + rawOrder: FrontendOrderWithParentTpsl, +): { + takeProfitPrice?: string; + stopLossPrice?: string; + takeProfitOrderId?: string; + stopLossOrderId?: string; +} => ({ + takeProfitPrice: readOptionalString(rawOrder.takeProfitPrice), + stopLossPrice: readOptionalString(rawOrder.stopLossPrice), + takeProfitOrderId: readOptionalOrderId(rawOrder.takeProfitOrderId), + stopLossOrderId: readOptionalOrderId(rawOrder.stopLossOrderId), +}); + +/** + * HyperLiquid SDK Adapter Utilities + * + * These functions transform between MetaMask Perps API types and HyperLiquid SDK types. + * The SDK uses cryptic property names for efficiency, but our API uses descriptive names + * to provide a consistent interface across different perps protocols. + */ + +export function adaptOrderToSDK( + order: PerpsOrderParams, + symbolToAssetId: Map, +): SDKOrderParams { + const assetId = symbolToAssetId.get(order.symbol); + if (assetId === undefined) { + const availableDexs = new Set(); + symbolToAssetId.forEach((_, symbol) => { + if (symbol.includes(':')) { + const dex = symbol.split(':')[0]; + availableDexs.add(dex); + } + }); + + const dexHint = + availableDexs.size > 0 + ? ` Available HIP-3 DEXs: ${Array.from(availableDexs).join(', ')}` + : ' No HIP-3 DEXs currently available.'; + + throw new Error( + `Asset ${order.symbol} not found in asset mapping.${dexHint} Check console logs for "HyperLiquidProvider: Asset mapping built" to see available assets.`, + ); + } + + return { + a: assetId, + b: order.isBuy, + p: order.price ?? resolveTriggerCapPrice(order) ?? '0', + s: order.size, + r: order.reduceOnly ?? false, + t: adaptOrderTypeToSDK(order), + c: + order.clientOrderId && isHexString(order.clientOrderId) + ? (order.clientOrderId as Hex) + : undefined, + }; +} + +/** + * Derive the slippage cap a market-on-trigger order submits as its price. + * + * A `stop_market` / `take_profit_market` order legitimately carries no limit + * price, but the SDK still requires a positive `p` — it is the cap the order + * fills against once the trigger fires, not a resting price. Sending `'0'` + * fails SDK validation before the request is ever made. + * + * The cap follows the order's own tolerance, matching `calculateOrderPriceAndSize` + * on the `placeOrder` path, so the same order priced through either route gets + * the same execution bound. + * + * @param order - Order params carrying the placement type, trigger price, and + * slippage tolerance. + * @returns The formatted cap price, or undefined when the order needs no cap. + */ +function resolveTriggerCapPrice(order: PerpsOrderParams): string | undefined { + if ( + !isTriggerOrderType(order.orderType) || + isLimitExecutionOrderType(order.orderType) || + !order.triggerPrice + ) { + return undefined; + } + + const triggerPrice = parseFloat(order.triggerPrice); + if (!Number.isFinite(triggerPrice)) { + return undefined; + } + + // Accept the deprecated decimal `slippage` too, normalizing it to bps the way + // `placeOrder` does, so neither spelling silently falls back to the default. + const effectiveBps = + order.maxSlippageBps ?? + (typeof order.slippage === 'number' + ? Math.round(order.slippage * BASIS_POINTS_DIVISOR) + : undefined) ?? + ORDER_SLIPPAGE_CONFIG.DefaultTpslSlippageBps; + + // Buying pays up to the cap, selling accepts down to it. + const slippage = effectiveBps / BASIS_POINTS_DIVISOR; + const capPrice = order.isBuy + ? triggerPrice * (1 + slippage) + : triggerPrice * (1 - slippage); + + return capPrice.toString(); +} + +/** + * Map a placement type onto the SDK's order-type field. + * + * @param order - Order params carrying the placement type and trigger price + * @returns The SDK order-type field + */ +function adaptOrderTypeToSDK(order: PerpsOrderParams): SDKOrderParams['t'] { + if (isTriggerOrderType(order.orderType)) { + if (order.timeInForce !== undefined) { + throw new Error(PERPS_ERROR_CODES.ORDER_TIME_IN_FORCE_NOT_SUPPORTED); + } + if (!order.triggerPrice) { + throw new Error(PERPS_ERROR_CODES.ORDER_TRIGGER_PRICE_REQUIRED); + } + + return { + trigger: { + isMarket: !isLimitExecutionOrderType(order.orderType), + triggerPx: order.triggerPrice, + tpsl: getTriggerDirection(order.orderType) === 'stop' ? 'sl' : 'tp', + }, + }; + } + + if (order.orderType === 'limit') { + return { limit: { tif: toSDKTimeInForce(order.timeInForce) } }; + } + if (order.timeInForce !== undefined) { + throw new Error(PERPS_ERROR_CODES.ORDER_TIME_IN_FORCE_NOT_SUPPORTED); + } + return { limit: { tif: 'FrontendMarket' } }; +} + +/** + * Map the provider-agnostic TP/SL linkage onto HyperLiquid's grouping vocabulary. + * + * @param linkage - How the attached TP/SL is linked. + * @returns The HyperLiquid grouping value. + */ +export function adaptTpslLinkageToGrouping( + linkage: TpslLinkage, +): 'na' | 'normalTpsl' | 'positionTpsl' { + switch (linkage) { + case 'position': + return 'positionTpsl'; + case 'order': + return 'normalTpsl'; + default: + return 'na'; + } +} + +export function adaptPositionFromSDK(assetPosition: AssetPosition): Position { + const pos = assetPosition.position; + return { + symbol: pos.coin, + size: pos.szi, + entryPrice: pos.entryPx, + positionValue: pos.positionValue, + unrealizedPnl: pos.unrealizedPnl, + marginUsed: pos.marginUsed, + leverage: { + type: pos.leverage.type, + value: pos.leverage.value, + rawUsd: + pos.leverage.type === 'isolated' ? pos.leverage.rawUsd : undefined, + }, + liquidationPrice: pos.liquidationPx, + maxLeverage: pos.maxLeverage, + returnOnEquity: pos.returnOnEquity, + cumulativeFunding: pos.cumFunding, + takeProfitCount: 0, + stopLossCount: 0, + }; +} + +export function adaptOrderFromSDK( + rawOrder: FrontendOrder, + position?: Position, +): Order { + // TODO: Remove this widened boundary type when FrontendOrder includes + // takeProfitPrice/stopLossPrice and takeProfitOrderId/stopLossOrderId. + const parentTpslMetadata = getParentTpslMetadata( + rawOrder as FrontendOrderWithParentTpsl, + ); + + // Extract basic fields with appropriate conversions + const orderId = rawOrder.oid?.toString() || ''; + const symbol = rawOrder.coin; + const side: 'buy' | 'sell' = rawOrder.side === 'B' ? 'buy' : 'sell'; + const detailedOrderType = rawOrder.orderType; + const { isTrigger } = rawOrder; + const { reduceOnly } = rawOrder; + + const triggerOrderType = adaptTriggerOrderTypeFromSDK(detailedOrderType); + + let orderType: 'limit' | 'market' = 'market'; + if (triggerOrderType) { + // Trigger orders always carry a limitPx (the slippage cap for + // market-on-trigger execution), so the placement type is the only reliable + // source for how the order actually executes. + orderType = getTriggerExecution(triggerOrderType); + } else if ( + detailedOrderType?.toLowerCase().includes('limit') || + rawOrder.limitPx + ) { + orderType = 'limit'; + } + + const price = rawOrder.limitPx || rawOrder.triggerPx || '0'; + + let size = rawOrder.sz; + let originalSize = rawOrder.origSz || size; + + let currentSize = parseFloat(size); + let origSize = parseFloat(originalSize); + + if (rawOrder.isPositionTpsl && origSize === 0 && position) { + const absPositionSize = Math.abs(parseFloat(position.size)); + currentSize = absPositionSize; + origSize = absPositionSize; + size = absPositionSize.toString(); + originalSize = absPositionSize.toString(); + } + + const filledSize = origSize - currentSize; + + let takeProfitPrice: string | undefined; + let stopLossPrice: string | undefined; + let takeProfitOrderId: string | undefined; + let stopLossOrderId: string | undefined; + + // TODO: We assume that there can only be 1 TP and 1 SL as children but there can be several TPSLs as children + if (rawOrder.children && rawOrder.children.length > 0) { + rawOrder.children.forEach((child) => { + if (child.isTrigger && child.orderType) { + if (child.orderType.includes('Take Profit')) { + // HyperLiquid represents "no trigger price" as an empty string, not + // null/undefined, so `||` (not `??`) is required to fall back to + // limitPx when triggerPx is ''. + takeProfitPrice = child.triggerPx || child.limitPx; + takeProfitOrderId = child.oid.toString(); + } else if (child.orderType.includes('Stop')) { + stopLossPrice = child.triggerPx || child.limitPx; + stopLossOrderId = child.oid.toString(); + } + } + }); + } + + // Fallback: preserve parent-level TP/SL metadata when children are absent. + takeProfitPrice ??= parentTpslMetadata.takeProfitPrice; + stopLossPrice ??= parentTpslMetadata.stopLossPrice; + takeProfitOrderId ??= parentTpslMetadata.takeProfitOrderId; + stopLossOrderId ??= parentTpslMetadata.stopLossOrderId; + + // Build the order object + const order: Order = { + orderId, + symbol, + side, + orderType, + size, + originalSize, + price, + filledSize: filledSize.toString(), + remainingSize: size, + status: 'open' as const, + timestamp: rawOrder.timestamp, + detailedOrderType, + isTrigger, + reduceOnly, + }; + + const strategyGroupId = readScaleGroupId(rawOrder.cloid); + if (strategyGroupId) { + order.strategyGroupId = strategyGroupId; + } + + if (typeof rawOrder.isPositionTpsl === 'boolean') { + order.isPositionTpsl = rawOrder.isPositionTpsl; + } + + if (takeProfitPrice) { + order.takeProfitPrice = takeProfitPrice; + order.takeProfitOrderId = takeProfitOrderId; + } + if (stopLossPrice) { + order.stopLossPrice = stopLossPrice; + order.stopLossOrderId = stopLossOrderId; + } + if (rawOrder.triggerPx) { + order.triggerPrice = rawOrder.triggerPx; + } + + if (triggerOrderType) { + order.triggerOrderType = triggerOrderType; + } + + return order; +} + +/** + * Map HyperLiquid's human-readable order type string onto the provider-agnostic + * trigger placement type. + * + * @param detailedOrderType - HyperLiquid `orderType` string (e.g. `'Stop Limit'`) + * @returns The normalized trigger placement type, or undefined for non-trigger orders + */ +export function adaptTriggerOrderTypeFromSDK( + detailedOrderType: string | undefined, +): TriggerOrderType | undefined { + if (!detailedOrderType) { + return undefined; + } + + const isTakeProfit = detailedOrderType.includes('Take Profit'); + const isStop = detailedOrderType.includes('Stop'); + + if (!isTakeProfit && !isStop) { + return undefined; + } + + return buildTriggerOrderType({ + direction: isTakeProfit ? 'take_profit' : 'stop', + execution: detailedOrderType.includes('Limit') ? 'limit' : 'market', + }); +} + +/** + * Build the position-state view of a trigger order attached to a position. + * + * HyperLiquid encodes "the whole position" as size `0` for position-bound TP/SL, + * which is resolved here against the position size so consumers always see a + * concrete quantity and can tell partial triggers apart. + * + * @param params - Mapping parameters + * @param params.rawOrder - Raw HyperLiquid frontend order + * @param params.positionSize - Signed or unsigned position size + * @param params.entryPrice - Entry price, used to classify a trigger the exchange left unnamed + * @returns The normalized trigger order, or undefined when the order is not a trigger + */ +export function adaptPositionTriggerOrderFromSDK(params: { + rawOrder: Pick< + FrontendOrder, + 'oid' | 'orderType' | 'triggerPx' | 'limitPx' | 'sz' | 'reduceOnly' + >; + positionSize: string; + entryPrice?: string; +}): PositionTriggerOrder | undefined { + const { rawOrder, positionSize, entryPrice } = params; + + const orderType = adaptTriggerOrderTypeFromSDK(rawOrder.orderType); + + // HyperLiquid uses '' for "no trigger price", so `||` (not `??`) is required. + const triggerPrice = rawOrder.triggerPx || rawOrder.limitPx || '0'; + + // Same rule as the WebSocket path: an unnamed trigger keeps its recoverable + // direction and leaves its execution mode unstated, so both transports + // report the same set of orders. + const direction = orderType + ? getTriggerDirection(orderType) + : classifyTriggerDirection({ triggerPrice, entryPrice, positionSize }); + + if (!direction) { + return undefined; + } + const absolutePositionSize = Math.abs(parseFloat(positionSize || '0')); + const rawSize = Math.abs(parseFloat(rawOrder.sz || '0')); + + // Position-bound TP/SL carries size 0, meaning the whole position. + const size = rawSize > 0 ? rawSize : absolutePositionSize; + + return { + orderId: rawOrder.oid.toString(), + direction, + orderType, + triggerPrice, + size: size.toString(), + isPartial: + rawSize > 0 && absolutePositionSize > 0 && rawSize < absolutePositionSize, + reduceOnly: Boolean(rawOrder.reduceOnly), + }; +} + +export function adaptMarketFromSDK( + sdkMarket: MetaResponse['universe'][number], +): MarketInfo { + return { + name: sdkMarket.name, + szDecimals: sdkMarket.szDecimals, + maxLeverage: sdkMarket.maxLeverage, + marginTableId: sdkMarket.marginTableId, + onlyIsolated: sdkMarket.onlyIsolated, + isDelisted: sdkMarket.isDelisted, + }; +} + +// Perps-only account adapter. Spot balances are layered on afterwards by +// addSpotBalanceToAccountState, which enforces the USDC-only policy via +// SPOT_COLLATERAL_COINS. Keeping spot logic out of here preserves a single +// source of truth for spot balance math. +export function adaptAccountStateFromSDK( + perpsState: ClearinghouseStateResponse, +): AccountState { + const { totalUnrealizedPnl, weightedReturnOnEquity } = + perpsState.assetPositions.reduce( + (acc, assetPos: AssetPosition) => { + const unrealizedPnl = parseFloat( + assetPos.position.unrealizedPnl || '0', + ); + const marginUsed = parseFloat(assetPos.position.marginUsed || '0'); + const returnOnEquity = parseFloat( + assetPos.position.returnOnEquity || '0', + ); + acc.totalUnrealizedPnl += unrealizedPnl; + acc.weightedReturnOnEquity += returnOnEquity * marginUsed; + return acc; + }, + { + totalUnrealizedPnl: 0, + weightedReturnOnEquity: 0, + }, + ); + const totalMarginUsed = parseFloat( + perpsState.marginSummary.totalMarginUsed || '0', + ); + const totalReturnOnEquityPercentage = + totalMarginUsed > 0 + ? ((weightedReturnOnEquity / totalMarginUsed) * 100).toString() + : '0'; + + const perpsBalance = parseFloat(perpsState.marginSummary.accountValue); + + const withdrawable = perpsState.withdrawable || '0'; + const accountState: AccountState = { + spendableBalance: withdrawable, + withdrawableBalance: withdrawable, + totalBalance: perpsBalance.toString() || '0', + marginUsed: perpsState.marginSummary.totalMarginUsed || '0', + unrealizedPnl: totalUnrealizedPnl.toString() || '0', + returnOnEquity: totalReturnOnEquityPercentage || '0', + }; + + return accountState; +} + +export function buildAssetMapping(params: { + metaUniverse: MetaResponse['universe']; + dex?: string | null; + perpDexIndex: number; +}): { + symbolToAssetId: Map; + assetIdToSymbol: Map; +} { + const { metaUniverse, perpDexIndex } = params; + const symbolToAssetId = new Map(); + const assetIdToSymbol = new Map(); + + metaUniverse.forEach((asset, index) => { + const assetId = calculateHip3AssetId(perpDexIndex, index); + symbolToAssetId.set(asset.name, assetId); + assetIdToSymbol.set(assetId, asset.name); + }); + + return { symbolToAssetId, assetIdToSymbol }; +} + +export function formatHyperLiquidPrice(params: { + price: string | number; + szDecimals: number; +}): string { + const { price, szDecimals } = params; + const priceNum = typeof price === 'string' ? parseFloat(price) : price; + + if (Number.isInteger(priceNum)) { + return priceNum.toString(); + } + + const maxDecimalPlaces = + DECIMAL_PRECISION_CONFIG.MaxPriceDecimals - szDecimals; + + let formattedPrice = priceNum.toFixed(maxDecimalPlaces); + formattedPrice = parseFloat(formattedPrice).toString(); + + const significantDigits = countSignificantFigures(formattedPrice); + + if (significantDigits > DECIMAL_PRECISION_CONFIG.MaxSignificantFigures) { + formattedPrice = roundToSignificantFigures(formattedPrice); + } + + return formattedPrice; +} + +export function formatHyperLiquidSize(params: { + size: string | number; + szDecimals: number; +}): string { + const { size, szDecimals } = params; + const number = typeof size === 'string' ? parseFloat(size) : size; + + if (isNaN(number)) { + return '0'; + } + + const formatted = number.toFixed(szDecimals); + + if (!formatted.includes('.')) { + return formatted; + } + + return formatted.replace(/\.?0+$/u, ''); +} + +export function calculatePositionSize(params: { + usdValue: number; + leverage: number; + assetPrice: number; +}): number { + const { usdValue, leverage, assetPrice } = params; + return (usdValue * leverage) / assetPrice; +} + +export function calculateHip3AssetId( + perpDexIndex: number, + indexInMeta: number, +): number { + if (perpDexIndex === 0) { + return indexInMeta; + } + return ( + HIP3_ASSET_ID_CONFIG.BaseAssetId + + perpDexIndex * HIP3_ASSET_ID_CONFIG.DexMultiplier + + indexInMeta + ); +} + +export function parseAssetName(assetName: string): { + dex: string | null; + symbol: string; +} { + const colonIndex = assetName.indexOf(':'); + if (colonIndex === -1) { + return { dex: null, symbol: assetName }; + } + return { + dex: assetName.substring(0, colonIndex), + symbol: assetName.substring(colonIndex + 1), + }; +} + +export function adaptHyperLiquidLedgerUpdateToUserHistoryItem( + rawLedgerUpdates: RawLedgerUpdate[], +): UserHistoryItem[] { + return (rawLedgerUpdates || []) + .filter((update) => { + if (update.delta.type === 'deposit') { + return true; + } + if (update.delta.type === 'withdraw') { + return true; + } + if (update.delta.type === 'internalTransfer') { + const usdc = Number.parseFloat(update.delta.usdc ?? '0'); + if (Number.isNaN(usdc)) { + return false; + } + return usdc > 0; + } + return false; + }) + .map((update) => { + let amount = '0'; + let asset = 'USDC'; + + if (hasProperty(update.delta, 'usdc') && update.delta.usdc) { + amount = Math.abs(parseFloat(update.delta.usdc)).toString(); + } + if ( + hasProperty(update.delta, 'coin') && + typeof update.delta.coin === 'string' + ) { + asset = update.delta.coin; + } + + return { + id: `history-${update.hash}`, + timestamp: update.time, + amount, + asset, + txHash: update.hash, + status: 'completed' as const, + type: update.delta.type === 'withdraw' ? 'withdrawal' : 'deposit', + details: { + source: '', + bridgeContract: undefined, + recipient: undefined, + blockNumber: undefined, + chainId: undefined, + synthetic: undefined, + }, + }; + }); +} diff --git a/packages/perps-controller/src/utils/hyperLiquidOrderBookProcessor.ts b/packages/perps-controller/src/utils/hyperLiquidOrderBookProcessor.ts new file mode 100644 index 00000000000..f8dec472894 --- /dev/null +++ b/packages/perps-controller/src/utils/hyperLiquidOrderBookProcessor.ts @@ -0,0 +1,150 @@ +import type { BboWsEvent, L2BookResponse } from '@nktkas/hyperliquid'; + +import type { PriceUpdate } from '../types/index.js'; + +/** + * HyperLiquid Order Book Processor + * + * Utility functions for processing Level 2 order book data from HyperLiquid WebSocket. + * Extracts best bid/ask prices, calculates spreads, and updates caches. + */ + +/** + * Order book cache entry structure + */ +export type OrderBookCacheEntry = { + bestBid?: string; + bestAsk?: string; + spread?: string; + lastUpdated: number; +}; + +/** + * Parameters for processing L2 book data + */ +export type ProcessL2BookDataParams = { + symbol: string; + data: L2BookResponse; + orderBookCache: Map; + cachedPriceData: Map | null; + createPriceUpdate: (symbol: string, price: string) => PriceUpdate; + notifySubscribers: () => void; +}; + +export type ProcessBboDataParams = { + symbol: string; + data: BboWsEvent; + orderBookCache: Map; + cachedPriceData: Map | null; + createPriceUpdate: (symbol: string, price: string) => PriceUpdate; + notifySubscribers: () => void; +}; + +/** + * Process Level 2 order book data and update caches + * + * Extracts best bid/ask prices from order book levels, calculates spread, + * and updates the order book cache and price data cache. + * + * @param params - Processing parameters + */ +export function processL2BookData(params: ProcessL2BookDataParams): void { + const { + symbol, + data, + orderBookCache, + cachedPriceData, + createPriceUpdate, + notifySubscribers, + } = params; + + if (data?.coin !== symbol || !data?.levels) { + return; + } + + // Extract best bid and ask from order book + const bestBid = data.levels[0]?.[0]; // First bid level + const bestAsk = data.levels[1]?.[0]; // First ask level + + if (!bestBid && !bestAsk) { + return; + } + + const bidPrice = bestBid ? parseFloat(bestBid.px) : 0; + const askPrice = bestAsk ? parseFloat(bestAsk.px) : 0; + const spread = + bidPrice > 0 && askPrice > 0 ? (askPrice - bidPrice).toFixed(5) : undefined; + + // Update order book cache + orderBookCache.set(symbol, { + bestBid: bestBid?.px, + bestAsk: bestAsk?.px, + spread, + lastUpdated: Date.now(), + }); + + // Update cached price data with new order book data + const currentCachedPrice = cachedPriceData?.get(symbol); + if (!currentCachedPrice) { + return; + } + + const updatedPrice = createPriceUpdate(symbol, currentCachedPrice.price); + + // Ensure cache exists before setting + if (cachedPriceData) { + cachedPriceData.set(symbol, updatedPrice); + notifySubscribers(); + } +} + +/** + * Process BBO (best bid/offer) data and update caches + * + * BBO is lightweight and independent from L2Book aggregation parameters, + * making it ideal for spread / top-of-book display. + * + * @param params - The BBO processing parameters including symbol, data, and caches. + */ +export function processBboData(params: ProcessBboDataParams): void { + const { + symbol, + data, + orderBookCache, + cachedPriceData, + createPriceUpdate, + notifySubscribers, + } = params; + + if (data?.coin !== symbol || !Array.isArray(data?.bbo)) { + return; + } + + const [bestBid, bestAsk] = data.bbo; + if (!bestBid && !bestAsk) { + return; + } + + const bidPrice = bestBid ? parseFloat(bestBid.px) : 0; + const askPrice = bestAsk ? parseFloat(bestAsk.px) : 0; + const spread = + bidPrice > 0 && askPrice > 0 ? (askPrice - bidPrice).toFixed(5) : undefined; + + orderBookCache.set(symbol, { + bestBid: bestBid?.px, + bestAsk: bestAsk?.px, + spread, + lastUpdated: Date.now(), + }); + + const currentCachedPrice = cachedPriceData?.get(symbol); + if (!currentCachedPrice) { + return; + } + + const updatedPrice = createPriceUpdate(symbol, currentCachedPrice.price); + if (cachedPriceData) { + cachedPriceData.set(symbol, updatedPrice); + notifySubscribers(); + } +} diff --git a/packages/perps-controller/src/utils/hyperLiquidPositionPreview.ts b/packages/perps-controller/src/utils/hyperLiquidPositionPreview.ts new file mode 100644 index 00000000000..7dcf2d452a1 --- /dev/null +++ b/packages/perps-controller/src/utils/hyperLiquidPositionPreview.ts @@ -0,0 +1,495 @@ +import type { + PositionModifyPreviewCurrent, + PositionModifyPreviewParams, + PositionModifyPreviewResult, + PositionPreviewValue, +} from '../types/index.js'; + +/** Token-size comparison tolerance (floating-point / szDecimals noise). */ +const SIZE_EPSILON = 1e-10; + +/** + * HyperLiquid documents margin-table IDs below 50 as a single tier whose max + * leverage equals the table id. Multi-tier tables need the `meta.marginTables` + * entry; without it liquidation is withheld. + * + * @see https://hyperliquid.gitbook.io/hyperliquid-docs/trading/margin-and-pnl + */ +const SINGLE_TIER_MARGIN_TABLE_ID_MAX = 50; + +export type HyperLiquidMarginTier = { + /** Inclusive notional lower bound in USD. */ + lowerBound: number; + maxLeverage: number; +}; + +export type PreviewHyperLiquidIsolatedPositionModifyParams = + PositionModifyPreviewParams & { + /** + * Maintenance tiers for the asset, lowest notional first. `null` or empty + * withholds liquidation while still returning margin when it can be known. + */ + marginTiers?: HyperLiquidMarginTier[] | null; + }; + +type MaintenanceScheduleTier = { + lowerBound: number; + upperBound: number; + maxLeverage: number; + maintenanceMarginRate: number; + maintenanceDeduction: number; +}; + +const unavailable = (): PositionPreviewValue => ({ available: false }); + +const available = (value: number): PositionPreviewValue => ({ + available: true, + value, +}); + +const parseFiniteNumber = (value: string | null | undefined): number => + Number.parseFloat(value ?? ''); + +const isPositiveFinite = (value: number): boolean => + Number.isFinite(value) && value > 0; + +const isNonNegativeFinite = (value: number): boolean => + Number.isFinite(value) && value >= 0; + +const currentFromPosition = (params: { + currentMargin: number; + currentLiquidationPrice: number; +}): PositionModifyPreviewCurrent => ({ + margin: isNonNegativeFinite(params.currentMargin) + ? available(params.currentMargin) + : unavailable(), + liquidationPrice: isPositiveFinite(params.currentLiquidationPrice) + ? available(params.currentLiquidationPrice) + : unavailable(), +}); + +/** + * Resolves the HyperLiquid margin table into preview tiers. + * + * Table IDs below 50 are single-tier. IDs at or above 50 require the matching + * `marginTables` row; missing data returns `null` so liquidation is withheld. + * An unknown table id (asset missing from `meta.universe`) also returns `null` + * instead of inventing a single tier from max leverage. + * + * @param params - Margin table id, asset max leverage, and optional tables. + * @param params.marginTableId - HyperLiquid margin table id from `meta.universe`. + * @param params.maxLeverage - Asset max leverage used for single-tier tables. + * @param params.marginTables - `meta.marginTables` rows; required for table ids ≥ 50. + * @returns Tiers for liquidation, or `null` when the table identity is unknown. + */ +export function resolveHyperLiquidMarginTiers(params: { + marginTableId?: number; + maxLeverage?: number; + marginTables?: + | [number, { marginTiers: { lowerBound: string; maxLeverage: number }[] }][] + | null; +}): HyperLiquidMarginTier[] | null { + const { marginTableId, maxLeverage, marginTables } = params; + + if (typeof marginTableId !== 'number' || !Number.isFinite(marginTableId)) { + return null; + } + + if (marginTableId >= SINGLE_TIER_MARGIN_TABLE_ID_MAX) { + const table = marginTables?.find(([id]) => id === marginTableId)?.[1]; + const tiers = table?.marginTiers + ?.map((tier) => ({ + lowerBound: Number.parseFloat(tier.lowerBound), + maxLeverage: tier.maxLeverage, + })) + .filter( + (tier) => + Number.isFinite(tier.lowerBound) && + tier.lowerBound >= 0 && + isPositiveFinite(tier.maxLeverage), + ); + return tiers && tiers.length > 0 ? tiers : null; + } + + if (marginTableId <= 0) { + return null; + } + + const tierMaxLeverage = + typeof maxLeverage === 'number' && isPositiveFinite(maxLeverage) + ? maxLeverage + : marginTableId; + if (!isPositiveFinite(tierMaxLeverage)) { + return null; + } + + return [{ lowerBound: 0, maxLeverage: tierMaxLeverage }]; +} + +/** + * Builds the continuous maintenance-margin schedule from HyperLiquid tiers. + * + * `maintenance_margin = notional * mmr - deduction`, with + * `mmr = 1 / (2 * tierMaxLeverage)` and deduction chosen so the function is + * continuous across tier boundaries. + * + * @param tiers - Notional lower bounds and per-tier max leverage. + * @returns Sorted schedule used to pick the tier at liquidation notional. + */ +export function buildMaintenanceSchedule( + tiers: HyperLiquidMarginTier[], +): MaintenanceScheduleTier[] { + const sorted = [...tiers] + .filter( + (tier) => + Number.isFinite(tier.lowerBound) && + tier.lowerBound >= 0 && + isPositiveFinite(tier.maxLeverage), + ) + .sort((left, right) => left.lowerBound - right.lowerBound); + + const schedule: MaintenanceScheduleTier[] = []; + let deduction = 0; + let previousMmr = 0; + + for (let index = 0; index < sorted.length; index++) { + const tier = sorted[index]; + const maintenanceMarginRate = 1 / (2 * tier.maxLeverage); + if (index > 0) { + deduction += tier.lowerBound * (maintenanceMarginRate - previousMmr); + } + schedule.push({ + lowerBound: tier.lowerBound, + upperBound: + index + 1 < sorted.length ? sorted[index + 1].lowerBound : Infinity, + maxLeverage: tier.maxLeverage, + maintenanceMarginRate, + maintenanceDeduction: deduction, + }); + previousMmr = maintenanceMarginRate; + } + + return schedule; +} + +/** + * Isolated liquidation from mark, margin, size, and a maintenance tier. + * + * HyperLiquid liquidations use mark price, not average entry. Isolated + * `marginUsed` is mark-based equity (includes unrealized PnL), so the + * closed form must use the same reference: + * + * Long: `(mark - margin/size - deduction/size) / (1 - mmr)` + * Short: `(mark + margin/size + deduction/size) / (1 + mmr)` + * + * @param params - Position geometry plus the tier's mmr and deduction. + * @param params.isLong - Whether the remaining position is long. + * @param params.markPrice - Projected mark after the proposed fill. + * @param params.margin - Isolated margin after the proposed fill. + * @param params.positionSize - Absolute remaining size in token units. + * @param params.maintenanceMarginRate - `1 / (2 * tierMaxLeverage)` for the tier. + * @param params.maintenanceDeduction - Continuity deduction at this tier. + * @returns Liquidation price, or `null` when the inputs cannot produce one. + */ +export function estimateIsolatedLiquidationPrice(params: { + isLong: boolean; + markPrice: number; + margin: number; + positionSize: number; + maintenanceMarginRate: number; + maintenanceDeduction?: number; +}): number | null { + const { + isLong, + markPrice, + margin, + positionSize, + maintenanceMarginRate, + maintenanceDeduction = 0, + } = params; + + if ( + !isPositiveFinite(markPrice) || + !isPositiveFinite(margin) || + !isPositiveFinite(positionSize) || + !Number.isFinite(maintenanceMarginRate) || + maintenanceMarginRate < 0 || + !Number.isFinite(maintenanceDeduction) + ) { + return null; + } + + const direction = isLong ? -1 : 1; + const side = isLong ? 1 : -1; + const adjustmentFactor = 1 - maintenanceMarginRate * side; + if (Math.abs(adjustmentFactor) < 0.0001) { + return null; + } + + const liquidationPrice = + (markPrice + + direction * (margin / positionSize) + + direction * (maintenanceDeduction / positionSize)) / + adjustmentFactor; + + if (!isPositiveFinite(liquidationPrice)) { + return null; + } + + return liquidationPrice; +} + +/** + * Picks the maintenance tier whose notional range contains the liquidation + * notional (`size * liqPrice`), including that tier's deduction. + * + * @param params - Resulting geometry and the asset's maintenance schedule. + * @param params.isLong - Whether the remaining position is long. + * @param params.markPrice - Projected mark after the proposed fill. + * @param params.margin - Isolated margin after the proposed fill. + * @param params.positionSize - Absolute remaining size in token units. + * @param params.marginTiers - Maintenance tiers, lowest notional first. + * @returns Liquidation price when a consistent tier exists. + */ +export function estimateIsolatedLiquidationPriceAtTier(params: { + isLong: boolean; + markPrice: number; + margin: number; + positionSize: number; + marginTiers: HyperLiquidMarginTier[] | null | undefined; +}): number | null { + const schedule = buildMaintenanceSchedule(params.marginTiers ?? []); + if (schedule.length === 0) { + return null; + } + + for (const tier of schedule) { + const liquidationPrice = estimateIsolatedLiquidationPrice({ + isLong: params.isLong, + markPrice: params.markPrice, + margin: params.margin, + positionSize: params.positionSize, + maintenanceMarginRate: tier.maintenanceMarginRate, + maintenanceDeduction: tier.maintenanceDeduction, + }); + if (liquidationPrice === null) { + continue; + } + const notionalAtLiquidation = params.positionSize * liquidationPrice; + if ( + notionalAtLiquidation >= tier.lowerBound && + notionalAtLiquidation < tier.upperBound + ) { + return liquidationPrice; + } + } + + return null; +} + +const resultingLeverage = (params: { + notional: number; + margin: number; + fallback: number; +}): number => { + if (params.margin > 0 && params.notional > 0) { + return params.notional / params.margin; + } + return params.fallback; +}; + +/** + * Projects the isolated position that would remain after a proposed order. + * + * Models HyperLiquid's isolated `updateLeverage` (the selected leverage is + * applied to the whole asset before the fill) and maintenance tiers at the + * resulting liquidation notional. Liquidation uses the projected mark, not + * average entry, because isolated `marginUsed` is mark-based equity. + * Cross-margin positions return `{ status: 'unsupported', reason: 'cross_margin' }`. + * + * `price` is the fill or resting-limit price the caller expects. A marketable + * order should pass its execution price; a limit should pass the limit. The + * preview does not distinguish order types itself, does not model whether a + * resting limit would fill, and treats scale/TWAP/chase as one aggregated fill. + * Decrease margin is the remaining isolated collateral after leverage + * reallocation; close fees and realized PnL settle to the account, not the + * leftover margin. + * + * @param params - Live isolated position, proposed order, and optional tiers. + * @returns Discriminated preview; margin and liquidation are independently available. + */ +export function previewHyperLiquidIsolatedPositionModify( + params: PreviewHyperLiquidIsolatedPositionModifyParams, +): PositionModifyPreviewResult { + const { position, direction, reduceOnly = false, marginTiers } = params; + + if (position.leverage.type === 'cross') { + return { status: 'unsupported', reason: 'cross_margin' }; + } + + const currentSize = Math.abs(parseFiniteNumber(position.size)); + const signedSize = parseFiniteNumber(position.size); + const currentMargin = parseFiniteNumber(position.marginUsed); + const currentEntry = parseFiniteNumber(position.entryPrice); + const currentLiquidationPrice = parseFiniteNumber(position.liquidationPrice); + const currentLeverage = position.leverage.value; + const selectedLeverage = params.leverage; + const orderSize = parseFiniteNumber(params.size); + const orderPrice = parseFiniteNumber(params.price); + const feeAmountUsd = + typeof params.feeAmountUsd === 'number' && params.feeAmountUsd > 0 + ? params.feeAmountUsd + : 0; + + if ( + !isPositiveFinite(currentSize) || + !Number.isFinite(signedSize) || + signedSize === 0 || + !isNonNegativeFinite(currentMargin) || + !isPositiveFinite(currentEntry) || + !isPositiveFinite(selectedLeverage) || + !isPositiveFinite(currentLeverage) + ) { + return { status: 'none' }; + } + + const openDirection: 'long' | 'short' = signedSize > 0 ? 'long' : 'short'; + const currentSnapshot = currentFromPosition({ + currentMargin, + currentLiquidationPrice, + }); + + if (!isPositiveFinite(orderSize)) { + return { status: 'none' }; + } + + const positionValue = parseFiniteNumber(position.positionValue); + const currentNotional = isPositiveFinite(positionValue) + ? positionValue + : currentSize * currentEntry; + + const leverageChanged = + Math.abs(selectedLeverage - currentLeverage) > SIZE_EPSILON; + const existingMarginAfterLeverage = leverageChanged + ? currentNotional / selectedLeverage + : currentMargin; + + const withResultingLiquidation = (preview: { + kind: 'increase' | 'decrease' | 'flip'; + resultingDirection: 'long' | 'short'; + resultingSize: number; + resultingEntryPrice: number; + resultingMarkPrice: number; + resultingNotional: number; + newMargin: number; + }): PositionModifyPreviewResult => { + const liquidationPrice = estimateIsolatedLiquidationPriceAtTier({ + isLong: preview.resultingDirection === 'long', + markPrice: preview.resultingMarkPrice, + margin: preview.newMargin, + positionSize: preview.resultingSize, + marginTiers, + }); + + return { + status: 'open', + kind: preview.kind, + current: currentSnapshot, + resulting: { + direction: preview.resultingDirection, + size: preview.resultingSize, + entryPrice: preview.resultingEntryPrice, + leverage: resultingLeverage({ + notional: preview.resultingNotional, + margin: preview.newMargin, + fallback: selectedLeverage, + }), + margin: available(preview.newMargin), + liquidationPrice: + liquidationPrice === null + ? unavailable() + : available(liquidationPrice), + }, + }; + }; + + const isSameDirection = openDirection === direction; + const fillPrice = isPositiveFinite(orderPrice) ? orderPrice : null; + + // Reduce-only in the position's own direction cannot add size and does not + // close it, so there is no resulting position to project. + if (isSameDirection && reduceOnly) { + return { status: 'none' }; + } + + if (isSameDirection) { + if (fillPrice === null) { + return { status: 'none' }; + } + const orderMargin = (orderSize * fillPrice) / selectedLeverage; + const resultingSize = currentSize + orderSize; + const resultingEntryPrice = + (currentSize * currentEntry + orderSize * fillPrice) / resultingSize; + const newMargin = Math.max( + 0, + existingMarginAfterLeverage + orderMargin - feeAmountUsd, + ); + + return withResultingLiquidation({ + kind: 'increase', + resultingDirection: openDirection, + resultingSize, + resultingEntryPrice, + resultingMarkPrice: fillPrice, + resultingNotional: resultingSize * fillPrice, + newMargin, + }); + } + + if (orderSize + SIZE_EPSILON < currentSize) { + const remainingRatio = (currentSize - orderSize) / currentSize; + const resultingSize = currentSize - orderSize; + const newMargin = Math.max(0, existingMarginAfterLeverage * remainingRatio); + const currentMarkPrice = currentNotional / currentSize; + const resultingMarkPrice = fillPrice ?? currentMarkPrice; + + return withResultingLiquidation({ + kind: 'decrease', + resultingDirection: openDirection, + resultingSize, + resultingEntryPrice: currentEntry, + resultingMarkPrice, + resultingNotional: resultingSize * resultingMarkPrice, + newMargin, + }); + } + + const leftover = orderSize - currentSize; + if (leftover > SIZE_EPSILON && !reduceOnly) { + if (fillPrice === null) { + return { status: 'none' }; + } + const orderMargin = (orderSize * fillPrice) / selectedLeverage; + const leftoverRatio = leftover / orderSize; + const leftoverMargin = Math.max( + 0, + (orderMargin - feeAmountUsd) * leftoverRatio, + ); + + return withResultingLiquidation({ + kind: 'flip', + resultingDirection: direction, + resultingSize: leftover, + resultingEntryPrice: fillPrice, + resultingMarkPrice: fillPrice, + resultingNotional: leftover * fillPrice, + newMargin: leftoverMargin, + }); + } + + return { + status: 'full_close', + current: currentSnapshot, + resultingDirection: openDirection, + }; +} diff --git a/packages/perps-controller/src/utils/hyperLiquidValidation.ts b/packages/perps-controller/src/utils/hyperLiquidValidation.ts new file mode 100644 index 00000000000..e1e43eff859 --- /dev/null +++ b/packages/perps-controller/src/utils/hyperLiquidValidation.ts @@ -0,0 +1,1061 @@ +import { isValidHexAddress } from '@metamask/utils'; +import type { CaipAssetId, Hex } from '@metamask/utils'; + +import { + HYPERLIQUID_ASSET_CONFIGS, + BASIS_POINTS_DIVISOR, + getSupportedAssets, + TRADING_DEFAULTS, +} from '../constants/hyperLiquidConfig.js'; +import { + CHASE_ORDER_CONFIG, + HYPERLIQUID_ORDER_LIMITS, + HYPERLIQUID_TWAP_LIMITS, +} from '../constants/perpsConfig.js'; +import { PERPS_ERROR_CODES } from '../perpsErrorCodes.js'; +import type { + GetSupportedPathsParams, + PerpsDebugLogger, +} from '../types/index.js'; +import type { OrderType, TpslLinkage } from '../types/perps-types.js'; +import { + getTriggerExecution, + isLimitExecutionOrderType, + isStrategyOrderType, + isTriggerOrderType, + SCALE_ORDER_COUNT, +} from './orderTypes.js'; + +/** + * Optional debug logger for validation functions. + * When provided, enables detailed logging for debugging. + * When omitted, validation runs silently. + */ +export type ValidationDebugLogger = PerpsDebugLogger | undefined; + +/** + * Validation utilities for HyperLiquid operations + */ + +/** + * Create standardized error response. + * + * @param error - The error that occurred + * @param defaultResponse - The default response object to use as template + * @returns The error response with success=false and error message + */ +export function createErrorResult< + TValue extends { success: boolean; error?: string }, +>(error: unknown, defaultResponse: TValue): TValue { + return { + ...defaultResponse, + success: false, + error: + error instanceof Error ? error.message : PERPS_ERROR_CODES.UNKNOWN_ERROR, + }; +} + +/** + * Validate withdrawal parameters. + * + * @param params - Withdrawal parameters to validate + * @param params.assetId - The CAIP asset ID to withdraw + * @param params.amount - Amount to withdraw as string + * @param params.destination - Optional destination hex address + * @param debugLogger - Optional debug logger for detailed logging + * @returns Validation result with isValid flag and optional error message + */ +export function validateWithdrawalParams( + params: { + assetId?: CaipAssetId; + amount?: string; + destination?: Hex; + }, + debugLogger?: ValidationDebugLogger, +): { isValid: boolean; error?: string } { + debugLogger?.log('validateWithdrawalParams: Starting validation', { + params, + hasAssetId: Boolean(params.assetId), + hasAmount: Boolean(params.amount), + hasDestination: Boolean(params.destination), + }); + + // Validate required parameters + if (!params.assetId) { + debugLogger?.log('validateWithdrawalParams: Missing assetId', { + error: PERPS_ERROR_CODES.WITHDRAW_ASSET_ID_REQUIRED, + params, + }); + return { + isValid: false, + error: PERPS_ERROR_CODES.WITHDRAW_ASSET_ID_REQUIRED, + }; + } + + // Validate amount + if (!params.amount) { + debugLogger?.log('validateWithdrawalParams: Missing amount', { + error: PERPS_ERROR_CODES.WITHDRAW_AMOUNT_REQUIRED, + params, + }); + return { + isValid: false, + error: PERPS_ERROR_CODES.WITHDRAW_AMOUNT_REQUIRED, + }; + } + + const amount = parseFloat(params.amount); + if (isNaN(amount) || amount <= 0) { + debugLogger?.log('validateWithdrawalParams: Invalid amount', { + error: PERPS_ERROR_CODES.WITHDRAW_AMOUNT_POSITIVE, + amount: params.amount, + parsedAmount: amount, + isNaN: isNaN(amount), + }); + return { + isValid: false, + error: PERPS_ERROR_CODES.WITHDRAW_AMOUNT_POSITIVE, + }; + } + + // Validate destination address if provided + if (params.destination && !isValidHexAddress(params.destination)) { + debugLogger?.log('validateWithdrawalParams: Invalid destination address', { + error: PERPS_ERROR_CODES.WITHDRAW_INVALID_DESTINATION, + destination: params.destination, + isValidHex: isValidHexAddress(params.destination), + }); + return { + isValid: false, + error: PERPS_ERROR_CODES.WITHDRAW_INVALID_DESTINATION, + }; + } + + debugLogger?.log('validateWithdrawalParams: All validations passed', { + assetId: params.assetId, + amount: params.amount, + destination: params.destination ?? 'will use user wallet', + }); + + return { isValid: true }; +} + +/** + * Validate deposit parameters. + * + * @param params - Deposit parameters to validate + * @param params.assetId - The CAIP asset ID to deposit + * @param params.amount - Amount to deposit as string + * @param params.isTestnet - Whether this is a testnet deposit + * @param debugLogger - Optional debug logger for detailed logging + * @returns Validation result with isValid flag and optional error message + */ +export function validateDepositParams( + params: { + assetId?: CaipAssetId; + amount?: string; + isTestnet?: boolean; + }, + debugLogger?: ValidationDebugLogger, +): { isValid: boolean; error?: string } { + debugLogger?.log('validateDepositParams: Starting validation', { + params, + hasAssetId: Boolean(params.assetId), + hasAmount: Boolean(params.amount), + isTestnet: params.isTestnet, + }); + + // Validate required parameters + if (!params.assetId) { + debugLogger?.log('validateDepositParams: Missing assetId', { + error: PERPS_ERROR_CODES.DEPOSIT_ASSET_ID_REQUIRED, + params, + }); + return { + isValid: false, + error: PERPS_ERROR_CODES.DEPOSIT_ASSET_ID_REQUIRED, + }; + } + + // Validate amount + if (!params.amount) { + debugLogger?.log('validateDepositParams: Missing amount', { + error: PERPS_ERROR_CODES.DEPOSIT_AMOUNT_REQUIRED, + params, + }); + return { + isValid: false, + error: PERPS_ERROR_CODES.DEPOSIT_AMOUNT_REQUIRED, + }; + } + + const amount = parseFloat(params.amount); + if (isNaN(amount) || amount <= 0) { + debugLogger?.log('validateDepositParams: Invalid amount', { + error: PERPS_ERROR_CODES.DEPOSIT_AMOUNT_POSITIVE, + amount: params.amount, + parsedAmount: amount, + isNaN: isNaN(amount), + }); + return { + isValid: false, + error: PERPS_ERROR_CODES.DEPOSIT_AMOUNT_POSITIVE, + }; + } + + // Check minimum deposit amount + const minimumAmount = params.isTestnet + ? TRADING_DEFAULTS.amount.testnet + : TRADING_DEFAULTS.amount.mainnet; + + debugLogger?.log('validateDepositParams: Checking minimum amount', { + amount, + minimumAmount, + isTestnet: params.isTestnet, + network: params.isTestnet ? 'testnet' : 'mainnet', + }); + + if (amount < minimumAmount) { + debugLogger?.log('validateDepositParams: Below minimum deposit', { + error: PERPS_ERROR_CODES.DEPOSIT_MINIMUM_AMOUNT, + amount, + minimumAmount, + difference: minimumAmount - amount, + }); + return { + isValid: false, + error: PERPS_ERROR_CODES.DEPOSIT_MINIMUM_AMOUNT, + }; + } + + debugLogger?.log('validateDepositParams: All validations passed', { + assetId: params.assetId, + amount: params.amount, + parsedAmount: amount, + minimumAmount, + isTestnet: params.isTestnet, + }); + + return { isValid: true }; +} + +/** + * Validate asset support for withdrawals using AssetRoute arrays. + * + * @param assetId - The CAIP asset ID to validate + * @param supportedRoutes - Array of supported asset routes + * @param debugLogger - Optional debug logger for detailed logging + * @returns Validation result with isValid flag and optional error message + */ +export function validateAssetSupport( + assetId: CaipAssetId, + supportedRoutes: { assetId: CaipAssetId }[], + debugLogger?: ValidationDebugLogger, +): { isValid: boolean; error?: string } { + debugLogger?.log('validateAssetSupport: Checking asset support', { + assetId, + supportedRoutesCount: supportedRoutes.length, + }); + + const supportedAssetIds = supportedRoutes.map((route) => route.assetId); + + // Check if asset is supported + const isSupported = supportedAssetIds.includes(assetId); + + if (!isSupported) { + // Also check case-insensitive match for contract addresses + const isSupportedCaseInsensitive = supportedAssetIds.some( + (supportedId) => supportedId.toLowerCase() === assetId.toLowerCase(), + ); + + if (!isSupportedCaseInsensitive) { + debugLogger?.log('validateAssetSupport: Asset not supported', { + error: PERPS_ERROR_CODES.WITHDRAW_ASSET_NOT_SUPPORTED, + assetId, + supportedAssetIds, + checkedCaseInsensitive: true, + }); + + return { + isValid: false, + error: PERPS_ERROR_CODES.WITHDRAW_ASSET_NOT_SUPPORTED, + }; + } + + debugLogger?.log( + '⚠️ validateAssetSupport: Asset supported with case mismatch', + { + providedAssetId: assetId, + matchedAssetId: supportedAssetIds.find( + (id) => id.toLowerCase() === assetId.toLowerCase(), + ), + }, + ); + } + + debugLogger?.log('validateAssetSupport: Asset is supported', { + assetId, + }); + + return { isValid: true }; +} + +/** + * Validate balance against withdrawal amount. + * + * @param withdrawAmount - The amount to withdraw + * @param withdrawableBalance - Max USD that can leave the venue right now + * @param debugLogger - Optional debug logger for detailed logging + * @returns Validation result with isValid flag and optional error message + */ +export function validateBalance( + withdrawAmount: number, + withdrawableBalance: number, + debugLogger?: ValidationDebugLogger, +): { isValid: boolean; error?: string } { + debugLogger?.log('validateBalance: Checking balance sufficiency', { + withdrawAmount, + withdrawableBalance, + difference: withdrawableBalance - withdrawAmount, + }); + + if (withdrawAmount > withdrawableBalance) { + const shortfall = withdrawAmount - withdrawableBalance; + + debugLogger?.log('validateBalance: Insufficient balance', { + error: PERPS_ERROR_CODES.WITHDRAW_INSUFFICIENT_BALANCE, + withdrawAmount, + withdrawableBalance, + shortfall, + percentageOfAvailable: `${((withdrawAmount / withdrawableBalance) * 100).toFixed(2)}%`, + }); + + return { + isValid: false, + error: PERPS_ERROR_CODES.WITHDRAW_INSUFFICIENT_BALANCE, + }; + } + + const remainingBalance = withdrawableBalance - withdrawAmount; + debugLogger?.log('validateBalance: Balance is sufficient', { + withdrawAmount, + withdrawableBalance, + remainingBalance, + percentageUsed: `${((withdrawAmount / withdrawableBalance) * 100).toFixed(2)}%`, + }); + + return { isValid: true }; +} + +/** + * Apply filters to asset paths with comprehensive logging. + * + * @param assets - Array of CAIP asset IDs to filter + * @param params - Filter parameters including chainId, symbol, and assetId + * @param debugLogger - Optional debug logger for detailed logging + * @returns Filtered array of CAIP asset IDs + */ +export function applyPathFilters( + assets: CaipAssetId[], + params?: GetSupportedPathsParams, + debugLogger?: ValidationDebugLogger, +): CaipAssetId[] { + if (!params) { + debugLogger?.log( + 'HyperLiquid: applyPathFilters - no params, returning all assets', + { assets }, + ); + return assets; + } + + let filtered = assets; + + debugLogger?.log('HyperLiquid: applyPathFilters - starting filter', { + initialAssets: assets, + filterParams: params, + }); + + if (params.chainId) { + const before = filtered; + filtered = filtered.filter((asset) => + asset.startsWith(params.chainId as string), + ); + debugLogger?.log('HyperLiquid: applyPathFilters - chainId filter', { + chainId: params.chainId, + before, + after: filtered, + }); + } + + // Note: `in` is the idiomatic TypeScript way to narrow a string to + // `keyof typeof` for indexed access; `hasProperty` types the indexed + // result as `unknown` and loses the `{ testnet, mainnet }` shape. + /* eslint-disable-next-line no-restricted-syntax */ + if (params.symbol && params.symbol in HYPERLIQUID_ASSET_CONFIGS) { + const config = + HYPERLIQUID_ASSET_CONFIGS[ + params.symbol as keyof typeof HYPERLIQUID_ASSET_CONFIGS + ]; + const isTestnet = params.isTestnet ?? false; + const selectedAsset = isTestnet ? config.testnet : config.mainnet; + const before = filtered; + filtered = [selectedAsset]; + debugLogger?.log('HyperLiquid: applyPathFilters - symbol filter', { + symbol: params.symbol, + isTestnet, + config, + selectedAsset, + before, + after: filtered, + }); + } + + if (params.assetId) { + const before = filtered; + // Use case-insensitive comparison for asset ID matching to handle address case differences + filtered = filtered.filter( + (asset) => asset.toLowerCase() === params.assetId?.toLowerCase(), + ); + debugLogger?.log('HyperLiquid: applyPathFilters - assetId filter', { + assetId: params.assetId, + before, + after: filtered, + exactMatch: before.includes(params.assetId), + caseInsensitiveMatch: before.some( + (asset) => asset.toLowerCase() === params.assetId?.toLowerCase(), + ), + }); + } + + debugLogger?.log('HyperLiquid: applyPathFilters - final result', { + initialAssets: assets, + finalFiltered: filtered, + filterParams: params, + }); + + return filtered; +} + +/** + * Get supported deposit/withdrawal paths with filtering. + * + * @param params - Filter parameters including isTestnet, chainId, symbol + * @param debugLogger - Optional debug logger for detailed logging + * @returns Array of supported CAIP asset IDs + */ +export function getSupportedPaths( + params?: GetSupportedPathsParams, + debugLogger?: ValidationDebugLogger, +): CaipAssetId[] { + const isTestnet = params?.isTestnet ?? false; + const assets = getSupportedAssets(isTestnet); + const filteredAssets = applyPathFilters(assets, params, debugLogger); + + debugLogger?.log('HyperLiquid: getSupportedPaths', { + isTestnet, + requestedParams: params, + allAssets: assets, + filteredAssets, + returnType: 'CaipAssetId[]', + example: filteredAssets[0], + }); + + return filteredAssets; +} + +/** + * Get maximum order value based on leverage and order type. + * Based on HyperLiquid contract specifications. + * + * @param maxLeverage - The maximum leverage for the market + * @param orderType - The order type; every type follows the limit/market + * multiplier of its execution mode, so `stop_limit`, `scale` and `chase` are all + * treated as limit orders and `twap` as a market order + * @returns Maximum order value in USD + */ +export function getMaxOrderValue( + maxLeverage: number, + orderType: OrderType, +): number { + let marketLimit: number; + + if (maxLeverage >= 25) { + marketLimit = HYPERLIQUID_ORDER_LIMITS.MarketOrderLimits.HighLeverage; + } else if (maxLeverage >= 20) { + marketLimit = HYPERLIQUID_ORDER_LIMITS.MarketOrderLimits.MediumHighLeverage; + } else if (maxLeverage >= 10) { + marketLimit = HYPERLIQUID_ORDER_LIMITS.MarketOrderLimits.MediumLeverage; + } else { + marketLimit = HYPERLIQUID_ORDER_LIMITS.MarketOrderLimits.LowLeverage; + } + + // The higher cap follows how the order executes, not whose price it uses: a + // scale ladder and a chase rest limit orders on the book, so holding them to + // the tighter market-order cap would bound them by how they were requested + // rather than by what they do. + return getTriggerExecution(orderType) === 'limit' + ? marketLimit * HYPERLIQUID_ORDER_LIMITS.LimitOrderMultiplier + : marketLimit; +} + +/** + * The `grouping` value each provider-agnostic linkage corresponds to, used to + * detect a caller supplying both spellings with different meanings. + */ +const TPSL_LINKAGE_GROUPING: Record< + TpslLinkage, + 'na' | 'normalTpsl' | 'positionTpsl' +> = { + none: 'na', + order: 'normalTpsl', + position: 'positionTpsl', +}; + +/** + * The strategy-placement fields of `OrderParams`, as validation sees them. + * + * Named so the provider can forward exactly this group to `validateOrderParams` + * without the field list drifting between the two call sites. + */ +export type StrategyOrderValidationParams = { + twapDuration?: number; + twapRandomize?: boolean; + scaleMinPrice?: string; + scaleMaxPrice?: string; + scaleNumOrders?: number; + scaleSkew?: number; + chaseIntervalMs?: number; + chaseMaxDurationMs?: number; + chaseMaxRepricings?: number; + chaseMaxDistanceBps?: number; +}; + +/** + * Which strategy owns each strategy-only field. + * + * Anything a placement does not own is rejected rather than ignored: a `twap` + * carrying `scaleNumOrders` is a caller mistake, and dropping it silently would + * execute something other than what was asked for. + */ +const STRATEGY_FIELD_OWNER: Record< + keyof StrategyOrderValidationParams, + 'twap' | 'scale' | 'chase' +> = { + twapDuration: 'twap', + twapRandomize: 'twap', + scaleMinPrice: 'scale', + scaleMaxPrice: 'scale', + scaleNumOrders: 'scale', + scaleSkew: 'scale', + chaseIntervalMs: 'chase', + chaseMaxDurationMs: 'chase', + chaseMaxRepricings: 'chase', + chaseMaxDistanceBps: 'chase', +}; + +/** + * Validate the TWAP-specific parameters of a `twap` placement. + * + * @param params - Strategy fields supplied by the caller. + * @returns Validation result with isValid flag and optional error message. + */ +function validateTwapParams(params: StrategyOrderValidationParams): { + isValid: boolean; + error?: string; +} { + if (params.twapDuration === undefined) { + return { + isValid: false, + error: PERPS_ERROR_CODES.ORDER_TWAP_DURATION_REQUIRED, + }; + } + + // The venue validates `twap.m` as a safe integer within these bounds before + // the request is signed, so checking it here turns an opaque SDK failure into + // a typed rejection that costs no round trip. + if ( + !Number.isInteger(params.twapDuration) || + params.twapDuration < HYPERLIQUID_TWAP_LIMITS.MinDurationMinutes || + params.twapDuration > HYPERLIQUID_TWAP_LIMITS.MaxDurationMinutes + ) { + return { + isValid: false, + error: PERPS_ERROR_CODES.ORDER_TWAP_DURATION_INVALID, + }; + } + + return { isValid: true }; +} + +/** + * Validate the ladder parameters of a `scale` placement. + * + * @param params - Strategy fields supplied by the caller. + * @returns Validation result with isValid flag and optional error message. + */ +function validateScaleParams(params: StrategyOrderValidationParams): { + isValid: boolean; + error?: string; +} { + if ( + params.scaleMinPrice === undefined || + params.scaleMaxPrice === undefined + ) { + return { + isValid: false, + error: PERPS_ERROR_CODES.ORDER_SCALE_RANGE_REQUIRED, + }; + } + + const minPrice = parseFloat(params.scaleMinPrice); + const maxPrice = parseFloat(params.scaleMaxPrice); + if ( + !Number.isFinite(minPrice) || + !Number.isFinite(maxPrice) || + minPrice <= 0 || + maxPrice <= minPrice + ) { + return { + isValid: false, + error: PERPS_ERROR_CODES.ORDER_SCALE_RANGE_INVALID, + }; + } + + if ( + params.scaleNumOrders === undefined || + !Number.isInteger(params.scaleNumOrders) || + params.scaleNumOrders < SCALE_ORDER_COUNT.min || + params.scaleNumOrders > SCALE_ORDER_COUNT.max + ) { + return { + isValid: false, + error: PERPS_ERROR_CODES.ORDER_SCALE_COUNT_INVALID, + }; + } + + // Omitted is an even ladder, so only a supplied skew is checked. The value is + // taken exactly as the caller wrote it: clients coerce their input to two + // decimals, and rounding it again here would place a ladder weighted + // differently from the one the form previewed. + if ( + params.scaleSkew !== undefined && + (!Number.isFinite(params.scaleSkew) || params.scaleSkew <= 0) + ) { + return { + isValid: false, + error: PERPS_ERROR_CODES.ORDER_SCALE_RANGE_INVALID, + }; + } + + return { isValid: true }; +} + +/** + * Validate the polling parameters of a `chase` placement. + * + * @param params - Strategy fields supplied by the caller. + * @returns Validation result with isValid flag and optional error message. + */ +function validateChaseParams(params: StrategyOrderValidationParams): { + isValid: boolean; + error?: string; +} { + const interval = + params.chaseIntervalMs ?? CHASE_ORDER_CONFIG.DefaultIntervalMs; + if ( + !Number.isFinite(interval) || + interval < CHASE_ORDER_CONFIG.MinIntervalMs + ) { + return { + isValid: false, + error: PERPS_ERROR_CODES.ORDER_CHASE_INTERVAL_INVALID, + }; + } + + const maxDuration = params.chaseMaxDurationMs; + // A window shorter than one poll would place the order and immediately stop + // chasing it — a plain post-only limit order wearing a chase's name. + if ( + maxDuration !== undefined && + (!Number.isFinite(maxDuration) || maxDuration < interval) + ) { + return { + isValid: false, + error: PERPS_ERROR_CODES.ORDER_CHASE_DURATION_INVALID, + }; + } + + const maxRepricings = params.chaseMaxRepricings; + if ( + maxRepricings !== undefined && + (!Number.isInteger(maxRepricings) || maxRepricings < 1) + ) { + return { + isValid: false, + error: PERPS_ERROR_CODES.ORDER_CHASE_DURATION_INVALID, + }; + } + + if ( + params.chaseMaxDistanceBps !== undefined && + (!Number.isFinite(params.chaseMaxDistanceBps) || + params.chaseMaxDistanceBps <= 0 || + params.chaseMaxDistanceBps >= BASIS_POINTS_DIVISOR) + ) { + return { + isValid: false, + error: PERPS_ERROR_CODES.ORDER_CHASE_MAX_DISTANCE_INVALID, + }; + } + + return { isValid: true }; +} + +/** + * Validate strategy placement parameters, and the absence of them. + * + * Runs before every other order rule so a strategy placement is rejected with a + * strategy-shaped reason rather than with the trigger/limit rule its shape + * happens to trip first. + * + * @param params - Order parameters to validate. + * @param params.orderType - The order placement type. + * @returns Validation result with isValid flag and optional error message. + */ +function validateStrategyOrderParams( + params: StrategyOrderValidationParams & { + orderType?: OrderType; + price?: string; + triggerPrice?: string; + timeInForce?: 'GTC' | 'IOC' | 'ALO'; + clientOrderId?: string; + takeProfitPrice?: string; + stopLossPrice?: string; + takeProfitSize?: string; + stopLossSize?: string; + }, +): { isValid: boolean; error?: string } { + const { orderType } = params; + const strategy = + orderType !== undefined && isStrategyOrderType(orderType) + ? orderType + : undefined; + + // Every strategy-only field must belong to the placement carrying it. This + // also covers the non-strategy case, where no field has an owner to match. + const foreignField = ( + Object.keys(STRATEGY_FIELD_OWNER) as (keyof StrategyOrderValidationParams)[] + ).find( + (field) => + params[field] !== undefined && STRATEGY_FIELD_OWNER[field] !== strategy, + ); + if (foreignField) { + return { + isValid: false, + error: PERPS_ERROR_CODES.ORDER_STRATEGY_PARAMS_NOT_SUPPORTED, + }; + } + + if (!strategy) { + return { isValid: true }; + } + + // A strategy owns its own execution: it derives its own prices, rests its own + // orders, and decides its own time in force. Accepting these fields would + // mean quietly ignoring them. + // + // `clientOrderId` is refused for the same reason and cannot be honoured even + // in principle: the venue's TWAP action carries no client id, a scale ladder + // is many orders and a client id must be unique per order, and a chase + // replaces its order on every re-price. One id cannot name any of them. + if ( + params.price !== undefined || + params.triggerPrice !== undefined || + params.timeInForce !== undefined || + params.clientOrderId !== undefined || + params.takeProfitPrice !== undefined || + params.stopLossPrice !== undefined || + params.takeProfitSize !== undefined || + params.stopLossSize !== undefined + ) { + return { + isValid: false, + error: PERPS_ERROR_CODES.ORDER_STRATEGY_FIELD_UNSUPPORTED, + }; + } + + if (strategy === 'twap') { + return validateTwapParams(params); + } + if (strategy === 'scale') { + return validateScaleParams(params); + } + return validateChaseParams(params); +} + +/** + * Validate order parameters. + * Basic validation - checks required fields are present. + * Amount validation (size/USD) is handled by validateOrder. + * + * @param params - Order parameters to validate + * @param params.coin - The trading pair coin symbol + * @param params.size - The order size as string + * @param params.price - The order price as string + * @param params.orderType - The order placement type + * @param params.triggerPrice - Trigger price; required for trigger placement types and + * rejected for market/limit orders so a stray value can never be silently dropped + * @param params.takeProfitPrice - Attached take profit price + * @param params.stopLossPrice - Attached stop loss price + * @param params.takeProfitSize - Partial take profit size + * @param params.stopLossSize - Partial stop loss size + * @param params.tpslLinkage - How an attached TP/SL is linked + * @param params.grouping - Deprecated protocol-shaped spelling of `tpslLinkage` + * @param params.timeInForce - Time in force; only a plain limit order can carry one + * @param params.clientOrderId - Client-provided order ID; a strategy placement cannot carry one + * @param params.twapDuration - TWAP window in whole minutes + * @param params.twapRandomize - Whether to vary each TWAP suborder's size by up to ±20% + * @param params.scaleMinPrice - Lowest price in a scale ladder + * @param params.scaleMaxPrice - Highest price in a scale ladder + * @param params.scaleNumOrders - How many orders a scale ladder fans out into + * @param params.scaleSkew - How a scale ladder's size is weighted across its rungs + * @param params.chaseIntervalMs - How often a chase re-reads the touch + * @param params.chaseMaxDurationMs - How long a chase keeps re-pricing + * @param params.chaseMaxRepricings - Cap on a chase's cancel/replace cycles + * @returns Validation result with isValid flag and optional error message + */ +export function validateOrderParams( + params: StrategyOrderValidationParams & { + coin?: string; + size?: string; + price?: string; + orderType?: OrderType; + triggerPrice?: string; + takeProfitPrice?: string; + stopLossPrice?: string; + takeProfitSize?: string; + stopLossSize?: string; + tpslLinkage?: TpslLinkage; + grouping?: 'na' | 'normalTpsl' | 'positionTpsl'; + timeInForce?: 'GTC' | 'IOC' | 'ALO'; + clientOrderId?: string; + }, +): { isValid: boolean; error?: string } { + if (!params.coin) { + return { + isValid: false, + error: PERPS_ERROR_CODES.ORDER_COIN_REQUIRED, + }; + } + + // Note: Size validation removed - validateOrder handles amount validation using USD as source of truth + + // Strategy placements are decided first: their shape trips the limit-price and + // trigger rules below, and the strategy-shaped reason is the useful one. + const strategyValidation = validateStrategyOrderParams(params); + if (!strategyValidation.isValid) { + return strategyValidation; + } + + const { orderType } = params; + const isTrigger = orderType !== undefined && isTriggerOrderType(orderType); + + // Require price for orders that execute as limit orders (limit, stop_limit, + // take_profit_limit) + if ( + orderType !== undefined && + isLimitExecutionOrderType(orderType) && + !params.price + ) { + return { + isValid: false, + error: PERPS_ERROR_CODES.ORDER_LIMIT_PRICE_REQUIRED, + }; + } + + if (params.price && parseFloat(params.price) <= 0) { + return { + isValid: false, + error: PERPS_ERROR_CODES.ORDER_PRICE_POSITIVE, + }; + } + + if (isTrigger) { + if (!params.triggerPrice) { + return { + isValid: false, + error: PERPS_ERROR_CODES.ORDER_TRIGGER_PRICE_REQUIRED, + }; + } + + const triggerPrice = parseFloat(params.triggerPrice); + if (isNaN(triggerPrice) || triggerPrice <= 0) { + return { + isValid: false, + error: PERPS_ERROR_CODES.ORDER_TRIGGER_PRICE_POSITIVE, + }; + } + + // A trigger placement combined with attached TP/SL children is rejected + // rather than silently reshaped: the exchange semantics of a triggered + // parent owning triggered children are not part of this contract. + // Each field is checked explicitly: a falsy-but-present price (e.g. '') is + // still an attached TP/SL request and must be rejected, not skipped. + if ( + params.takeProfitPrice !== undefined || + params.stopLossPrice !== undefined + ) { + return { + isValid: false, + error: PERPS_ERROR_CODES.ORDER_TRIGGER_TPSL_UNSUPPORTED, + }; + } + // Consistent with the attached-TP/SL check above: a falsy-but-present value + // (e.g. '') is still a request to place a trigger, not an absent field. + } else if (params.triggerPrice !== undefined) { + return { + isValid: false, + error: PERPS_ERROR_CODES.ORDER_TRIGGER_PRICE_NOT_SUPPORTED, + }; + } + + // `tpslLinkage` supersedes `grouping`, but two spellings that disagree are a + // caller mistake — resolving one silently would hide it. + if (params.tpslLinkage !== undefined && params.grouping !== undefined) { + const expectedGrouping = TPSL_LINKAGE_GROUPING[params.tpslLinkage]; + if (expectedGrouping !== params.grouping) { + return { + isValid: false, + error: PERPS_ERROR_CODES.ORDER_TPSL_LINKAGE_CONFLICT, + }; + } + } + + const hasAttachedTpsl = + params.takeProfitPrice !== undefined || params.stopLossPrice !== undefined; + + // Every order in a `positionTpsl` batch has to be a trigger order, and no + // shape of order placement produces one. With an attached TP/SL the batch + // carries the ordinary parent order the TP/SL protects; without one it is + // that parent order alone. HyperLiquid rejects both, so the linkage is + // refused outright — it belongs to `updatePositionTPSL`, applied to the + // position once the parent has filled. + const requestsPositionLinkage = + params.tpslLinkage === 'position' || params.grouping === 'positionTpsl'; + if (requestsPositionLinkage) { + return { + isValid: false, + error: PERPS_ERROR_CODES.ORDER_TPSL_POSITION_LINKAGE_UNSUPPORTED, + }; + } + + // `na` grouping submits the attached TP/SL as standalone triggers, bound to + // neither the parent order nor the resulting position. An unfilled parent + // then leaves them behind as orphan reduce-only triggers that fire against + // whatever position happens to exist. An attached TP/SL needs a linkage that + // links it, so the combination is a caller mistake rather than a mode. + const requestsNoLinkage = + params.tpslLinkage === 'none' || params.grouping === 'na'; + if (requestsNoLinkage && hasAttachedTpsl) { + return { + isValid: false, + error: PERPS_ERROR_CODES.ORDER_TPSL_LINKAGE_REQUIRED, + }; + } + + // Only a plain limit order rests on the book long enough for a time in force to + // mean anything: a market order fills immediately and a trigger order's + // execution is decided when it fires. Rejected here, at step 1 of placement, so + // it cannot fire after leverage changes or a HIP-3 margin transfer. + if (params.timeInForce !== undefined && orderType !== 'limit') { + return { + isValid: false, + error: PERPS_ERROR_CODES.ORDER_TIME_IN_FORCE_NOT_SUPPORTED, + }; + } + + const partialTpslValidation = validatePartialTpslSizes(params); + if (!partialTpslValidation.isValid) { + return partialTpslValidation; + } + + return { isValid: true }; +} + +/** + * Validate quantity-scoped (partial) TP/SL sizes against the parent order. + * + * @param params - Order parameters carrying the TP/SL prices and sizes + * @param params.size - Parent order size + * @param params.takeProfitPrice - Attached take profit price + * @param params.stopLossPrice - Attached stop loss price + * @param params.takeProfitSize - Partial take profit size + * @param params.stopLossSize - Partial stop loss size + * @returns Validation result with isValid flag and optional error message + */ +function validatePartialTpslSizes(params: { + size?: string; + takeProfitPrice?: string; + stopLossPrice?: string; + takeProfitSize?: string; + stopLossSize?: string; +}): { isValid: boolean; error?: string } { + const orderSize = params.size ? Math.abs(parseFloat(params.size)) : undefined; + + const entries: { size?: string; price?: string }[] = [ + { size: params.takeProfitSize, price: params.takeProfitPrice }, + { size: params.stopLossSize, price: params.stopLossPrice }, + ]; + + for (const entry of entries) { + if (entry.size === undefined) { + continue; + } + + // A size without its price would silently place nothing. + if (!entry.price) { + return { + isValid: false, + error: PERPS_ERROR_CODES.ORDER_TPSL_SIZE_INVALID, + }; + } + + const size = parseFloat(entry.size); + if (isNaN(size) || size <= 0) { + return { + isValid: false, + error: PERPS_ERROR_CODES.ORDER_TPSL_SIZE_INVALID, + }; + } + + if (orderSize !== undefined && !isNaN(orderSize) && size > orderSize) { + return { + isValid: false, + error: PERPS_ERROR_CODES.ORDER_TPSL_SIZE_INVALID, + }; + } + } + + return { isValid: true }; +} + +/** + * Validate coin exists in asset mapping. + * + * @param coin - The coin symbol to validate + * @param coinToAssetId - Map of coin symbols to asset IDs + * @returns Validation result with isValid flag and optional error message + */ +export function validateCoinExists( + coin: string, + coinToAssetId: Map, +): { isValid: boolean; error?: string } { + if (!coinToAssetId.has(coin)) { + return { + isValid: false, + error: PERPS_ERROR_CODES.ORDER_UNKNOWN_COIN, + }; + } + + return { isValid: true }; +} diff --git a/packages/perps-controller/src/utils/idUtils.ts b/packages/perps-controller/src/utils/idUtils.ts new file mode 100644 index 00000000000..b5b3f5d2433 --- /dev/null +++ b/packages/perps-controller/src/utils/idUtils.ts @@ -0,0 +1,12 @@ +import { v4 as uuidv4 } from 'uuid'; + +export const generatePerpsId = (prefix?: string): string => { + const id = uuidv4(); + return prefix ? `${prefix}-${id}` : id; +}; + +export const generateDepositId = (): string => generatePerpsId('deposit'); +export const generateWithdrawalId = (): string => generatePerpsId('withdrawal'); +export const generateOrderId = (): string => generatePerpsId('order'); +export const generateTransactionId = (): string => + generatePerpsId('transaction'); diff --git a/packages/perps-controller/src/utils/index.ts b/packages/perps-controller/src/utils/index.ts new file mode 100644 index 00000000000..c1104121ab8 --- /dev/null +++ b/packages/perps-controller/src/utils/index.ts @@ -0,0 +1,49 @@ +/** + * Barrel re-export for all portable utilities in controllers/utils/ + * + * Note: hyperLiquidAdapter and orderCalculations both export calculatePositionSize. + * We use selective exports to avoid the name collision. + */ +export * from './accountUtils.js'; +export * from './errorUtils.js'; +// hyperLiquidAdapter: selective export to avoid calculatePositionSize clash with orderCalculations +export { + adaptOrderToSDK, + adaptPositionFromSDK, + adaptOrderFromSDK, + adaptPositionTriggerOrderFromSDK, + adaptTpslLinkageToGrouping, + adaptTriggerOrderTypeFromSDK, + adaptMarketFromSDK, + adaptAccountStateFromSDK, + buildAssetMapping, + formatHyperLiquidPrice, + formatHyperLiquidSize, + calculateHip3AssetId, + parseAssetName, + adaptHyperLiquidLedgerUpdateToUserHistoryItem, +} from './hyperLiquidAdapter.js'; +export * from './hyperLiquidOrderBookProcessor.js'; +export * from './hyperLiquidPositionPreview.js'; +export * from './hyperLiquidValidation.js'; +export * from './idUtils.js'; +export * from './marketDataTransform.js'; +export * from './marketSearch.js'; +export * from './marketUtils.js'; +export * from './orderCalculations.js'; +export * from './orderTypes.js'; +export * from './perpsDiskPersistence.js'; +export * from './rewardsUtils.js'; +export * from './significantFigures.js'; +export * from './sortMarkets.js'; +export * from './standaloneInfoClient.js'; +export * from './stringParseUtils.js'; +export * from './transferData.js'; +export * from './wait.js'; + +// Inline from former utils.ts (getEnvironment was previously at perps/utils.ts root) +export const getEnvironment = (): 'DEV' | 'PROD' => { + const env = globalThis.process?.env?.NODE_ENV ?? 'production'; + return env === 'production' ? 'PROD' : 'DEV'; +}; +export * from './perpsFormatters.js'; diff --git a/packages/perps-controller/src/utils/marketDataTransform.ts b/packages/perps-controller/src/utils/marketDataTransform.ts new file mode 100644 index 00000000000..47246da23f8 --- /dev/null +++ b/packages/perps-controller/src/utils/marketDataTransform.ts @@ -0,0 +1,374 @@ +/** + * Market data transformation utilities. + * + * Portable: no mobile-specific imports. + * Formatters are injected via MarketDataFormatters interface. + */ +import { hasProperty } from '@metamask/utils'; + +import { + HYPERLIQUID_CONFIG, + getHyperLiquidAssetName, +} from '../constants/hyperLiquidConfig.js'; +import { PERPS_CONSTANTS } from '../constants/perpsConfig.js'; +import type { + AllMidsResponse, + PerpsUniverse, + PerpsAssetCtx, + PredictedFunding, +} from '../types/hyperliquid-types.js'; +import type { + PerpsMarketData, + MarketType, + MarketDataFormatters, +} from '../types/index.js'; +import { parseAssetName } from './hyperLiquidAdapter.js'; + +/** + * Calculate open interest in USD + * Open interest from HyperLiquid is in contracts/units, not USD + * To get USD value, multiply by current price + * + * @param openInterest - Raw open interest value in contracts/units + * @param currentPrice - Current price of the asset + * @returns Open interest in USD, or NaN if invalid + */ +export function calculateOpenInterestUSD( + openInterest: string | number | undefined, + currentPrice: string | number | undefined, +): number { + if (openInterest === undefined || currentPrice === undefined) { + return NaN; + } + + const openInterestNum = + typeof openInterest === 'string' ? parseFloat(openInterest) : openInterest; + const priceNum = + typeof currentPrice === 'string' ? parseFloat(currentPrice) : currentPrice; + + if (isNaN(openInterestNum) || isNaN(priceNum)) { + return NaN; + } + + return openInterestNum * priceNum; +} + +/** + * Determine whether a market is currently tradable based on how far its market + * (mid) price has drifted from the oracle (reference) price. + * + * HyperLiquid rejects orders when the order price is more than 95% away from the + * reference price ("Order price cannot be more than 95% away from the reference + * price"). This most often affects HIP-3 builder-deployed markets, which can become + * temporarily untradable when their mid price diverges far from the oracle price. + * Clients use this signal to proactively warn the user (e.g. a "trading unavailable" + * banner) instead of letting the order fail on submission. + * + * Note: the deviation limit is a HyperLiquid protocol rule. Other providers may have + * different rules and should compute tradability accordingly. + * + * @param params - The parameters for the tradability check. + * @param params.midPrice - Current market/mid price. + * @param params.oraclePrice - Current oracle/reference price. + * @param params.deviationLimit - Max allowed deviation as a decimal fraction + * (defaults to HyperLiquid's 0.95). A market is untradable when + * `abs(midPrice - oraclePrice) / oraclePrice > deviationLimit`. + * @returns `true` when the market is tradable (or when prices are unavailable, so the + * absence of data never blocks trading); `false` when the deviation exceeds the limit. + */ +export function isMarketTradable(params: { + midPrice: number | undefined; + oraclePrice: number | undefined; + deviationLimit?: number; +}): boolean { + const { + midPrice, + oraclePrice, + deviationLimit = HYPERLIQUID_CONFIG.OraclePriceDeviationLimit, + } = params; + + // Without usable prices we cannot assess deviation — default to tradable so missing + // data never blocks the user. A non-positive price means "no data" (e.g. the transient + // zero price emitted before the first real tick), not an untradable market. + if ( + midPrice === undefined || + oraclePrice === undefined || + isNaN(midPrice) || + isNaN(oraclePrice) || + midPrice <= 0 || + oraclePrice <= 0 + ) { + return true; + } + + const deviation = Math.abs(midPrice - oraclePrice) / oraclePrice; + return deviation <= deviationLimit; +} + +/** + * HyperLiquid-specific market data structure + */ +export type HyperLiquidMarketData = { + universe: PerpsUniverse[]; + assetCtxs: PerpsAssetCtx[]; + allMids: AllMidsResponse; + predictedFundings?: PredictedFunding[]; +}; + +/** + * Parameters for calculating 24h percentage change + */ +type CalculateChange24hPercentParams = { + hasCurrentPrice: boolean; + currentPrice: number; + prevDayPrice: number; +}; + +/** + * Calculate 24h percentage change + * Shows -100% when current price is missing but previous price exists + * + * @param params - The parameters for calculating the 24h change. + * @returns The 24h percentage change value. + */ +function calculateChange24hPercent( + params: CalculateChange24hPercentParams, +): number { + const { hasCurrentPrice, currentPrice, prevDayPrice } = params; + + if (!hasCurrentPrice) { + return prevDayPrice > 0 ? -100 : 0; + } + + if (prevDayPrice <= 0) { + return 0; + } + + return ((currentPrice - prevDayPrice) / prevDayPrice) * 100; +} + +/** + * Funding data extracted from predicted fundings + */ +type FundingData = { + nextFundingTime?: number; + fundingIntervalHours?: number; + predictedFundingRate?: number; +}; + +/** + * Parameters for extracting funding data + */ +type ExtractFundingDataParams = { + predictedFundings?: PredictedFunding[]; + symbol: string; + exchangeName?: string; +}; + +/** + * Extract funding data for a symbol from predicted fundings. + * Looks for specified exchange first, falls back to first available. + * + * @param params - Parameters for extracting funding data + * @param params.predictedFundings - Array of predicted funding data + * @param params.symbol - Asset symbol to extract funding for + * @param params.exchangeName - Exchange to prioritize (defaults to HyperLiquid's 'HlPerp') + * @returns Funding data including next funding time, interval, and predicted rate + */ +function extractFundingData(params: ExtractFundingDataParams): FundingData { + const { + predictedFundings, + symbol, + exchangeName = HYPERLIQUID_CONFIG.ExchangeName, + } = params; + + const result: FundingData = {}; + + if (!predictedFundings) { + return result; + } + + const fundingData = predictedFundings.find( + ([assetSymbol]) => assetSymbol === symbol, + ); + + if ( + !fundingData?.[1] || + !Array.isArray(fundingData[1]) || + fundingData[1].length === 0 + ) { + return result; + } + + // Look for specified exchange (e.g., 'HlPerp' for HyperLiquid) + const targetExchange = fundingData[1].find( + (exchange: unknown) => + Array.isArray(exchange) && exchange[0] === exchangeName, + ); + + if (targetExchange?.[1]) { + result.nextFundingTime = targetExchange[1].nextFundingTime; + result.fundingIntervalHours = targetExchange[1].fundingIntervalHours; + result.predictedFundingRate = parseFloat(targetExchange[1].fundingRate); + return result; + } + + // Fallback to first exchange if target not found + const firstExchange = fundingData[1][0]; + if (Array.isArray(firstExchange) && firstExchange[1]) { + result.nextFundingTime = firstExchange[1].nextFundingTime; + result.fundingIntervalHours = firstExchange[1].fundingIntervalHours; + } + + return result; +} + +/** + * Transform raw HyperLiquid market data to UI-friendly format + * + * @param hyperLiquidData - Raw data from HyperLiquid API + * @param formatters - Injectable formatters for platform-agnostic formatting + * @param assetMarketTypes - Optional mapping of asset symbols to market types + * @param assetNames - Optional mapping of asset symbols to human-readable names. + * Defaults to the bundled HYPERLIQUID_ASSET_NAMES; unmapped assets fall back to + * their ticker symbol. + * @returns Transformed market data ready for UI consumption + */ +export function transformMarketData( + hyperLiquidData: HyperLiquidMarketData, + formatters: MarketDataFormatters, + assetMarketTypes?: Record, + assetNames?: Record, +): PerpsMarketData[] { + const { universe, assetCtxs, allMids, predictedFundings } = hyperLiquidData; + + return universe.map((asset, index) => { + const symbol = asset.name; + const currentPrice = parseFloat(allMids[symbol]); + + // Find matching asset context for additional data + // The assetCtxs array is aligned with universe array by index + const assetCtx = assetCtxs[index]; + + // Calculate 24h change + const prevDayPrice = assetCtx ? parseFloat(assetCtx.prevDayPx) : 0; + + // Handle missing current price data + const hasCurrentPrice = !isNaN(currentPrice); + const effectiveCurrentPrice = hasCurrentPrice ? currentPrice : 0; + + // For dollar change: show $0.00 when current price is missing + const change24h = hasCurrentPrice + ? effectiveCurrentPrice - prevDayPrice + : 0; + + // For percentage: show -100% when current price is missing but previous price exists + const change24hPercent = calculateChange24hPercent({ + hasCurrentPrice, + currentPrice: effectiveCurrentPrice, + prevDayPrice, + }); + + // Format volume (dayNtlVlm is daily notional volume) + // If assetCtx is missing or dayNtlVlm is not available, use NaN to indicate missing data + const volume = assetCtx?.dayNtlVlm ? parseFloat(assetCtx.dayNtlVlm) : NaN; + + // Calculate open interest in USD + const openInterest = calculateOpenInterestUSD( + assetCtx?.openInterest, + currentPrice, + ); + + // Get current funding rate from assetCtx - this is the actual current funding rate + let fundingRate: number | undefined; + + if (assetCtx && hasProperty(assetCtx, 'funding')) { + fundingRate = parseFloat(assetCtx.funding); + } + + // Extract funding timing and predicted rate + const fundingData = extractFundingData({ + predictedFundings, + symbol, + }); + + // Use current funding rate from assetCtx, not predicted + // The predicted rate is for the next funding period + if (!fundingRate && fundingData.predictedFundingRate !== undefined) { + fundingRate = fundingData.predictedFundingRate; + } + + // Extract DEX and base symbol for display + // e.g., "flx:TSLA" → { dex: "flx", symbol: "TSLA" } + const { dex } = parseAssetName(symbol); + const marketSource = dex ?? undefined; + + // HIP-3 markets have a DEX prefix (e.g., xyz:TSLA, flx:GOLD) + // Crypto markets (HIP-2) don't have a prefix (e.g., BTC, ETH) + const isHip3 = Boolean(dex); + + // Determine market type from explicit static mapping + const marketType: MarketType | undefined = assetMarketTypes?.[symbol]; + + // Mark as "new" if it's a HIP-3 market but not explicitly categorized + const isNewMarket = isHip3 && !marketType; + + return { + symbol, + name: getHyperLiquidAssetName(symbol, assetNames), + maxLeverage: `${asset.maxLeverage}x`, + price: isNaN(currentPrice) + ? PERPS_CONSTANTS.FallbackPriceDisplay + : formatters.formatPerpsFiat(currentPrice, { + ranges: formatters.priceRangesUniversal, + }), + change24h: isNaN(change24h) + ? PERPS_CONSTANTS.ZeroAmountDetailedDisplay + : formatChange(change24h, formatters), + change24hPercent: isNaN(change24hPercent) + ? '0.00%' + : formatters.formatPercentage(change24hPercent), + volume: isNaN(volume) + ? PERPS_CONSTANTS.FallbackPriceDisplay + : formatters.formatVolume(volume), + openInterest: isNaN(openInterest) + ? PERPS_CONSTANTS.FallbackPriceDisplay + : formatters.formatVolume(openInterest), + nextFundingTime: fundingData.nextFundingTime, + fundingIntervalHours: fundingData.fundingIntervalHours, + fundingRate, + marketSource, + marketType, + isHip3, + isNewMarket, + }; + }); +} + +/** + * Format 24h change with sign. + * Uses more decimal places for smaller amounts to show meaningful precision. + * + * @param change - The price change value to format + * @param formatters - Injectable formatters + * @returns Formatted change string with sign and dollar symbol + */ +export function formatChange( + change: number, + formatters: MarketDataFormatters, +): string { + if (isNaN(change) || !isFinite(change)) { + return '$0.00'; + } + if (change === 0) { + return '$0.00'; + } + + const formatted = formatters.formatPerpsFiat(Math.abs(change), { + ranges: formatters.priceRangesUniversal, + }); + + // Remove $ sign and add it back with proper sign placement + const valueWithoutDollar = formatted.replace('$', ''); + return change > 0 ? `+$${valueWithoutDollar}` : `-$${valueWithoutDollar}`; +} diff --git a/packages/perps-controller/src/utils/marketSearch.ts b/packages/perps-controller/src/utils/marketSearch.ts new file mode 100644 index 00000000000..6f57c9a4a0f --- /dev/null +++ b/packages/perps-controller/src/utils/marketSearch.ts @@ -0,0 +1,141 @@ +/** + * Market search ranking (TAT-2413). + * + * Provisional, standalone helper layered on the same match semantics as + * `filterMarketsByQuery` (case-insensitive substring on a market's ticker symbol + * and human-readable name). It adds the one thing `filterMarketsByQuery` does + * not: relevance ranking — exact matches first, then prefix, then substring; + * ties keep their input order (stable). No fuzzy/phonetic matching (out of scope + * for v1). + * + * Kept in its own file so it can be promoted or relocated later without touching + * the shared `marketUtils`. A market matches here (rank !== null) iff + * `filterMarketsByQuery` would include it, so the two stay behaviorally aligned. + * + * Portable: no platform-specific imports. + */ +import type { PerpsMarketData } from '../types/index.js'; + +/** + * Relevance tier for a market/query match. Lower values sort first. + */ +export enum MarketMatchRank { + Exact = 0, + Prefix = 1, + Substring = 2, +} + +/** + * Rank a single field value against a normalized query. + * + * @param value - Field value (e.g. symbol or name); may be undefined. + * @param query - Already trimmed, lower-cased, non-empty query. + * @returns The match tier, or null when the field does not match. + */ +function fieldRank( + value: string | undefined, + query: string, +): MarketMatchRank | null { + if (!value) { + return null; + } + const normalized = value.toLowerCase(); + if (normalized === query) { + return MarketMatchRank.Exact; + } + if (normalized.startsWith(query)) { + return MarketMatchRank.Prefix; + } + if (normalized.includes(query)) { + return MarketMatchRank.Substring; + } + return null; +} + +/** + * Rank an array of keyword strings against a normalized query. + * Returns the best (lowest) rank found across all keywords, or null. + * + * @param keywords - Array of keyword strings; may be undefined. + * @param query - Already trimmed, lower-cased, non-empty query. + * @returns The best match tier across all keywords, or null when none match. + */ +function keywordsRank( + keywords: string[] | undefined, + query: string, +): MarketMatchRank | null { + if (!keywords || keywords.length === 0) { + return null; + } + let best: MarketMatchRank | null = null; + for (const keyword of keywords) { + const rank = fieldRank(keyword, query); + if (rank === MarketMatchRank.Exact) { + return rank; + } + if (rank !== null && (best === null || rank < best)) { + best = rank; + } + } + return best; +} + +/** + * Compute the best (lowest) relevance rank for a market against a search query, + * considering its ticker symbol, human-readable name, and optional keywords + * from Terminal API metadata. + * + * @param market - Market to score (uses `symbol`, `name`, and optional `keywords`). + * @param searchQuery - User search text (trimmed/cased internally). + * @returns The match rank, or null when the market does not match (or the query + * is empty/whitespace). + */ +export function getMarketMatchRank( + market: Pick, + searchQuery: string, +): MarketMatchRank | null { + if (!searchQuery?.trim()) { + return null; + } + const query = searchQuery.toLowerCase().trim(); + const ranks = [ + fieldRank(market.symbol, query), + fieldRank(market.name, query), + keywordsRank(market.keywords, query), + ].filter((rank): rank is MarketMatchRank => rank !== null); + + return ranks.length > 0 ? Math.min(...ranks) : null; +} + +/** + * Filter and rank markets by a search query, matching the human-readable name or + * ticker symbol. Exact matches sort first, then prefix, then substring; markets + * sharing a rank keep their input order (stable). An empty/whitespace query + * returns the markets unchanged (no filtering), matching `filterMarketsByQuery`. + * + * @param markets - Markets to search. + * @param searchQuery - User search text. + * @returns Matching markets ordered by relevance. + */ +export function rankMarketsByQuery( + markets: PerpsMarketData[], + searchQuery: string, +): PerpsMarketData[] { + if (!searchQuery?.trim()) { + return markets; + } + const query = searchQuery.toLowerCase().trim(); + + const matches: { market: PerpsMarketData; rank: MarketMatchRank }[] = []; + markets.forEach((market) => { + const rank = getMarketMatchRank(market, query); + if (rank !== null) { + matches.push({ market, rank }); + } + }); + + // Stable sort by rank only; Array.prototype.sort is stable in modern engines, + // so equal-rank markets retain their original relative order. + matches.sort((a, b) => a.rank - b.rank); + return matches.map((match) => match.market); +} diff --git a/packages/perps-controller/src/utils/marketUtils.ts b/packages/perps-controller/src/utils/marketUtils.ts new file mode 100644 index 00000000000..5dfd7f7b140 --- /dev/null +++ b/packages/perps-controller/src/utils/marketUtils.ts @@ -0,0 +1,373 @@ +import type { + GetMarketDataWithPricesParams, + MarketTypeFilter, + PerpsMarketData, +} from '../types/index.js'; +import type { CandleData, CandleStick } from '../types/perps-types.js'; +import { sortMarkets } from './sortMarkets.js'; + +export function clonePerpsMarketData( + markets: PerpsMarketData[], +): PerpsMarketData[] { + return markets.map((market) => ({ + ...market, + ...(market.keywords && { keywords: [...market.keywords] }), + ...(market.tags && { tags: [...market.tags] }), + ...(market.categories && { categories: [...market.categories] }), + ...(market.trend && { + trend: market.trend.map(([timestamp, price]): [number, string] => [ + timestamp, + price, + ]), + }), + })); +} + +// ============================================================================ +// Market category classification (pure functions) +// No service dependencies — pure data transformations that can be tested and +// reused independently. `matchesCategory` and `getMarketTypeFilter` share the +// same category model. +// ============================================================================ + +/** + * Whether a market is a HIP-3 (non-main-DEX) market. A `marketSource` DEX id + * marks a HIP-3 market even when the `isHip3` flag is unset (e.g. partial route + * params), so both signals are checked. Used as the single HIP-3 signal so the + * classifiers stay consistent. + * + * @param market - The market data to test. + * @returns True if the market is HIP-3. + */ +export const isHip3Market = ( + market: Pick, +): boolean => Boolean(market.isHip3) || Boolean(market.marketSource); + +/** + * Returns true when a market matches the given UI filter category. + * + * @param market - The market data to test. + * @param category - The filter category to test against. + * @returns Whether the market matches the category. + */ +export function matchesCategory( + market: PerpsMarketData, + category: MarketTypeFilter, +): boolean { + switch (category) { + case 'all': + return true; + case 'new': + // Explicitly flagged, or an uncategorized HIP-3 market (kept in sync with + // getMarketTypeFilter's 'new' bucket). + return ( + market.isNewMarket === true || + (isHip3Market(market) && market.marketType === undefined) + ); + case 'crypto': + // Main-DEX markets, plus HIP-3 assets explicitly typed as CryptoCurrency. + return !isHip3Market(market) || market.marketType === 'crypto'; + default: + // Every other filter is a 1:1 data-model category match. + return market.marketType !== undefined && market.marketType === category; + } +} + +/** + * Resolve the user-facing category bucket for a market — one of `crypto`, + * `stock`, `pre-ipo`, `index`, `etf`, `commodity`, `forex`, or `new`. Data-model + * categories map 1:1. A market with no data-model category is `crypto` when it + * is main-DEX, or `new` when it is an uncategorized HIP-3 market (`isHip3`, or a + * `marketSource` DEX id when `isHip3` is unset, e.g. minimal route params). + * Never returns the `all` sentinel. + * + * Centralised as the single source of truth so consumers (e.g. category + * shortcuts, related markets) share one classification instead of re-deriving + * it per client and drifting as new categories are added. + * + * @param market - The market data to classify. + * @returns The market type filter bucket. + */ +export function getMarketTypeFilter(market: PerpsMarketData): MarketTypeFilter { + const { marketType } = market; + if (marketType) { + return marketType; + } + // No data-model category: an uncategorized HIP-3 market is the 'new' bucket; + // otherwise it's a main-DEX crypto market. + return isHip3Market(market) ? 'new' : 'crypto'; +} + +/** + * Applies optional category filtering, sorting, and limit to a list of markets. + * + * @param markets - Source market array. + * @param params - Optional filter/sort/limit params. + * @returns Filtered, sorted, and/or sliced market array. + */ +export function applyMarketFilters( + markets: PerpsMarketData[], + params?: GetMarketDataWithPricesParams, +): PerpsMarketData[] { + let result = markets; + + if (params?.categories?.length) { + const { categories } = params; + result = result.filter((market) => + // A market is included if it matches ANY of the requested categories. + categories.some((category) => matchesCategory(market, category)), + ); + } + + if (params?.excludeSymbols?.length) { + const excluded = new Set(params.excludeSymbols); + result = result.filter((market) => !excluded.has(market.symbol)); + } + + if (params?.sortBy) { + result = sortMarkets({ + markets: result, + sortBy: params.sortBy, + direction: params.direction, + }); + } + + if (params?.limit !== undefined) { + result = result.slice(0, params.limit); + } + + return result; +} + +/** + * Maximum length for market filter patterns (prevents DoS attacks) + */ +export const MAX_MARKET_PATTERN_LENGTH = 100; + +export type MarketPatternMatcher = RegExp | string; + +export type CompiledMarketPattern = { + pattern: string; + matcher: MarketPatternMatcher; +}; + +export const escapeRegex = (str: string): string => + str.replace(/[.*+?^${}()|[\]\\]/gu, '\\$&'); + +export const validateMarketPattern = (pattern: string): boolean => { + if (!pattern || pattern.trim().length === 0) { + throw new Error('Market pattern cannot be empty'); + } + + const normalizedPattern = pattern.trim(); + + if (normalizedPattern.length > MAX_MARKET_PATTERN_LENGTH) { + throw new Error( + `Market pattern exceeds maximum length (${MAX_MARKET_PATTERN_LENGTH} chars): ${normalizedPattern}`, + ); + } + + const dangerousChars = /[\\()[\]{}^$+?.|]/u; + if (dangerousChars.test(normalizedPattern)) { + throw new Error( + `Market pattern contains invalid regex characters: ${normalizedPattern}`, + ); + } + + const validPattern = /^[a-zA-Z0-9:_\-*]+$/u; + if (!validPattern.test(normalizedPattern)) { + throw new Error( + `Market pattern contains invalid characters: ${normalizedPattern}`, + ); + } + + return true; +}; + +export const compileMarketPattern = (pattern: string): MarketPatternMatcher => { + const normalizedPattern = pattern.trim(); + validateMarketPattern(normalizedPattern); + + if (normalizedPattern.endsWith(':*')) { + const prefix = normalizedPattern.slice(0, -2); + return new RegExp(`^${escapeRegex(prefix)}:`, 'u'); + } + + if (!normalizedPattern.includes(':')) { + return new RegExp(`^${escapeRegex(normalizedPattern)}:`, 'u'); + } + + return normalizedPattern; +}; + +export const matchesMarketPattern = ( + symbol: string, + matcher: MarketPatternMatcher, +): boolean => { + if (typeof matcher === 'string') { + return symbol === matcher; + } + + return matcher.test(symbol); +}; + +export const shouldIncludeMarket = ( + symbol: string, + dex: string | null, + hip3Enabled: boolean, + compiledEnabledPatterns: CompiledMarketPattern[], + compiledBlockedPatterns: CompiledMarketPattern[], +): boolean => { + if (dex === null) { + return true; + } + + if (!hip3Enabled) { + return false; + } + + if (compiledEnabledPatterns.length > 0) { + const whitelisted = compiledEnabledPatterns.some(({ matcher }) => + matchesMarketPattern(symbol, matcher), + ); + if (!whitelisted) { + return false; + } + } + + if (compiledBlockedPatterns.length === 0) { + return true; + } + + const blacklisted = compiledBlockedPatterns.some(({ matcher }) => + matchesMarketPattern(symbol, matcher), + ); + + return !blacklisted; +}; + +export const getPerpsDisplaySymbol = (symbol: string): string => { + if (!symbol || typeof symbol !== 'string') { + return symbol; + } + + const colonIndex = symbol.indexOf(':'); + if (colonIndex > 0 && colonIndex < symbol.length - 1) { + return symbol.substring(colonIndex + 1); + } + + return symbol; +}; + +export const getPerpsDexFromSymbol = (symbol: string): string | null => { + if (!symbol || typeof symbol !== 'string') { + return null; + } + + const colonIndex = symbol.indexOf(':'); + if (colonIndex > 0 && colonIndex < symbol.length - 1) { + return symbol.substring(0, colonIndex); + } + + return null; +}; + +type FundingCountdownParams = { + nextFundingTime?: number; + fundingIntervalHours?: number; +}; + +export const calculateFundingCountdown = ( + params?: FundingCountdownParams, +): string => { + const now = new Date(); + const nowMs = now.getTime(); + + if (params?.nextFundingTime && params.nextFundingTime > nowMs) { + const msUntilFunding = params.nextFundingTime - nowMs; + const hoursUntilFunding = msUntilFunding / (1000 * 60 * 60); + + if (hoursUntilFunding <= 1.1) { + const totalSeconds = Math.floor(msUntilFunding / 1000); + + const hours = Math.floor(totalSeconds / 3600); + const minutes = Math.floor((totalSeconds % 3600) / 60); + const seconds = totalSeconds % 60; + + const formattedHours = String(hours).padStart(2, '0'); + const formattedMinutes = String(minutes).padStart(2, '0'); + const formattedSeconds = String(seconds).padStart(2, '0'); + + return `${formattedHours}:${formattedMinutes}:${formattedSeconds}`; + } + } + + const utcMinutes = now.getUTCMinutes(); + const utcSeconds = now.getUTCSeconds(); + + const minutesUntilNextHour = 59 - utcMinutes; + const secondsUntilNextHour = 60 - utcSeconds; + + const finalSeconds = secondsUntilNextHour === 60 ? 0 : secondsUntilNextHour; + const finalMinutes = + secondsUntilNextHour === 60 + ? minutesUntilNextHour + 1 + : minutesUntilNextHour; + + const finalHours = finalMinutes === 60 ? 1 : 0; + const adjustedMinutes = finalMinutes === 60 ? 0 : finalMinutes; + + const formattedHours = String(finalHours).padStart(2, '0'); + const formattedMinutes = String(adjustedMinutes).padStart(2, '0'); + const formattedSeconds = String(finalSeconds).padStart(2, '0'); + + return `${formattedHours}:${formattedMinutes}:${formattedSeconds}`; +}; + +export const calculate24hHighLow = ( + candleData: CandleData | null, +): { high: number; low: number } => { + if (!candleData?.candles || candleData.candles.length === 0) { + return { high: 0, low: 0 }; + } + + const now = Date.now(); + const twentyFourHoursAgo = now - 24 * 60 * 60 * 1000; + + let last24hCandles = candleData.candles.filter( + (candle: CandleStick) => candle.time >= twentyFourHoursAgo, + ); + + if (last24hCandles.length === 0) { + last24hCandles = [...candleData.candles]; + } + + const highs = last24hCandles.map((candle: CandleStick) => + parseFloat(candle.high), + ); + const lows = last24hCandles.map((candle: CandleStick) => + parseFloat(candle.low), + ); + + return { + high: Math.max(...highs), + low: Math.min(...lows), + }; +}; + +export const filterMarketsByQuery = ( + markets: PerpsMarketData[], + searchQuery: string, +): PerpsMarketData[] => { + if (!searchQuery?.trim()) { + return markets; + } + + const lowerQuery = searchQuery.toLowerCase().trim(); + + return markets.filter( + (market) => + market.symbol?.toLowerCase().includes(lowerQuery) || + market.name?.toLowerCase().includes(lowerQuery), + ); +}; diff --git a/packages/perps-controller/src/utils/myxAdapter.ts b/packages/perps-controller/src/utils/myxAdapter.ts new file mode 100644 index 00000000000..9c6a50965e2 --- /dev/null +++ b/packages/perps-controller/src/utils/myxAdapter.ts @@ -0,0 +1,690 @@ +/** + * MYX SDK Adapter Utilities + * + * Adapters for transforming between MetaMask Perps API types and MYX SDK types. + * Includes adapters for market display, positions, orders, account state, and fills. + * + * Portable: no mobile-specific imports. + * Formatters are injected via MarketDataFormatters interface (same pattern as marketDataTransform.ts). + * + * Key differences from HyperLiquid: + * - API prices are normal floats (SDK contract layer uses 30 decimals internally) + * - Sizes use 18 decimals (vs HyperLiquid's szDecimals per asset) + * - Multiple pools can exist per symbol (MPM model) + * - USDT collateral (vs USDC) + */ + +import { + fromMYXPrice, + fromMYXSize, + fromMYXCollateral, + MYX_MAX_LEVERAGE, + MYX_MINIMUM_ORDER_SIZE_USD, +} from '../constants/myxConfig.js'; +import type { + AccountState, + CandleStick, + Funding, + MarketInfo, + Order, + OrderFill, + PerpsMarketData, + Position, + MarketDataFormatters, + UserHistoryItem, +} from '../types/index.js'; +import { + MYX_HL_OVERLAPPING_MARKETS, + MYXDirection, + MYXDirectionEnum, + MYXOperationEnum, + MYXOrderStatusEnum, + MYXOrderTypeEnum, + MYXExecTypeEnum, + MYXTradeFlowTypeEnum, +} from '../types/myx-types.js'; +import type { + MYXPoolSymbol, + MYXTicker, + MYXPositionType, + MYXHistoryOrderItem, + MYXTradeFlowItem, + MYXKlineData, + MYXKlineWsData, +} from '../types/myx-types.js'; + +/** + * Format a price change value with sign prefix. + * Uses injected formatters (same pattern as marketDataTransform.ts formatChange). + * + * @param change - The price change value to format. + * @param formatters - Injectable formatters for platform-agnostic formatting. + * @returns The formatted change string with sign and dollar symbol. + */ +function formatChange( + change: number, + formatters: MarketDataFormatters, +): string { + if (isNaN(change) || !isFinite(change)) { + return '$0.00'; + } + if (change === 0) { + return '$0.00'; + } + + const formatted = formatters.formatPerpsFiat(Math.abs(change), { + ranges: formatters.priceRangesUniversal, + }); + + const valueWithoutDollar = formatted.replace('$', ''); + return change > 0 ? `+$${valueWithoutDollar}` : `-$${valueWithoutDollar}`; +} + +// ============================================================================ +// Market Transformation +// ============================================================================ + +/** + * Transform MYX Pool/Market info to MetaMask Perps API MarketInfo format + * + * @param pool - Pool symbol data from MYX SDK (PoolSymbolAllResponse) + * @returns MetaMask Perps API market info object + */ +export function adaptMarketFromMYX(pool: MYXPoolSymbol): MarketInfo { + // Extract base symbol from pool data + const symbol = pool.baseSymbol || extractSymbolFromPoolId(pool.poolId); + + // MYX uses fixed 18 decimals for sizes + const szDecimals = 18; + + return { + name: symbol, + szDecimals, + maxLeverage: MYX_MAX_LEVERAGE, + marginTableId: 0, // MYX doesn't use margin tables like HyperLiquid + minimumOrderSize: MYX_MINIMUM_ORDER_SIZE_USD, + providerId: 'myx', + }; +} + +/** + * Convert MYX ticker data to price and change values + * + * @param ticker - Ticker data from MYX SDK + * @returns Object with price string and 24h change percentage + */ +export function adaptPriceFromMYX(ticker: MYXTicker): { + price: string; + change24h: number; +} { + // MYX API returns normal float strings (e.g. "64854.76") + const priceNum = fromMYXPrice(ticker.price); + + // Change is provided as a percentage string (e.g., "2.5" means 2.5%) + const change24h = ticker.change ? parseFloat(ticker.change) : 0; + + return { + price: priceNum.toString(), + change24h, + }; +} + +/** + * Transform MYX pool and ticker to PerpsMarketData for UI display + * + * @param pool - Pool symbol data from MYX SDK + * @param ticker - Optional ticker data for price info + * @param formatters - Injectable formatters for platform-agnostic formatting + * @returns Formatted market data for UI display + */ +export function adaptMarketDataFromMYX( + pool: MYXPoolSymbol, + ticker: MYXTicker | undefined, + formatters: MarketDataFormatters, +): PerpsMarketData { + const symbol = pool.baseSymbol || extractSymbolFromPoolId(pool.poolId); + + // Get price data from ticker if available + let price = '0'; + let change24h = 0; + let volume = '0'; + + if (ticker) { + const priceData = adaptPriceFromMYX(ticker); + price = priceData.price; + change24h = priceData.change24h; + // Volume is already in USD (not 30-decimal format) + volume = ticker.volume || '0'; + } + + // Format using injected formatters (consistent with HyperLiquid via marketDataTransform.ts) + const priceNum = parseFloat(price); + const formattedPrice = formatters.formatPerpsFiat(priceNum); + const priceChange = priceNum * (change24h / 100); + const formattedChange = formatChange(priceChange, formatters); + const formattedChangePercent = formatters.formatPercentage(change24h); + const formattedVolume = formatters.formatVolume(parseFloat(volume)); + + return { + symbol, + name: getTokenName(symbol), + maxLeverage: `${MYX_MAX_LEVERAGE}x`, + price: formattedPrice, + change24h: formattedChange, + change24hPercent: formattedChangePercent, + volume: formattedVolume, + providerId: 'myx', + }; +} + +// ============================================================================ +// Market Filtering +// ============================================================================ + +/** + * Filter MYX markets to only include MYX-exclusive markets + * Removes markets that overlap with HyperLiquid + * + * @param pools - Array of MYX pool symbols + * @returns Filtered array with only MYX-exclusive markets + */ +export function filterMYXExclusiveMarkets( + pools: MYXPoolSymbol[], +): MYXPoolSymbol[] { + return pools.filter((pool) => { + const symbol = pool.baseSymbol || extractSymbolFromPoolId(pool.poolId); + // Exclude markets that overlap with HyperLiquid + return !MYX_HL_OVERLAPPING_MARKETS.includes( + symbol as (typeof MYX_HL_OVERLAPPING_MARKETS)[number], + ); + }); +} + +/** + * Check if a symbol overlaps with HyperLiquid markets + * + * @param symbol - Market symbol to check + * @returns true if the symbol is available on both MYX and HyperLiquid + */ +export function isOverlappingMarket(symbol: string): boolean { + return MYX_HL_OVERLAPPING_MARKETS.includes( + symbol as (typeof MYX_HL_OVERLAPPING_MARKETS)[number], + ); +} + +// ============================================================================ +// Pool ID Utilities +// ============================================================================ + +/** + * Build a map of poolId to symbol for quick lookup + * + * @param pools - Array of MYX pool symbols + * @returns Map of poolId to symbol + */ +export function buildPoolSymbolMap( + pools: MYXPoolSymbol[], +): Map { + const map = new Map(); + for (const pool of pools) { + const symbol = pool.baseSymbol || extractSymbolFromPoolId(pool.poolId); + map.set(pool.poolId, symbol); + } + return map; +} + +/** + * Build a map of symbol to poolIds (for multi-pool support) + * + * @param pools - Array of MYX pool symbols + * @returns Map of symbol to array of poolIds + */ +export function buildSymbolPoolsMap( + pools: MYXPoolSymbol[], +): Map { + const map = new Map(); + for (const pool of pools) { + const symbol = pool.baseSymbol || extractSymbolFromPoolId(pool.poolId); + const existing = map.get(symbol) ?? []; + existing.push(pool.poolId); + map.set(symbol, existing); + } + return map; +} + +/** + * Extract symbol from pool ID + * Pool IDs typically contain the symbol as a suffix or can be parsed. + * When baseSymbol is unavailable, returns a truncated address for UI display. + * + * @param poolId - MYX pool ID string + * @returns Extracted symbol or truncated poolId as fallback + */ +export function extractSymbolFromPoolId(poolId: string): string { + // Pool IDs in MYX are hex addresses ("0x...") + // The actual symbol comes from the pool's baseSymbol field + // Truncate hex addresses so they're UI-friendly + if (poolId.startsWith('0x') && poolId.length > 10) { + return `${poolId.slice(0, 6)}...${poolId.slice(-4)}`; + } + return poolId; +} + +/** + * Get full token name from symbol + * Returns the symbol as name if not found (MYX-specific tokens) + * + * @param symbol - The market symbol to look up. + * @returns The human-readable token name, or the symbol itself if not found. + */ +function getTokenName(symbol: string): string { + const tokenNames: Record = { + BTC: 'Bitcoin', + ETH: 'Ethereum', + BNB: 'BNB', + MYX: 'MYX Protocol', + RHEA: 'Rhea Finance', + PARTI: 'Particle Network', + SKYAI: 'SkyAI', + PUMP: 'PumpFun', + WLFI: 'World Liberty Financial', + }; + + return tokenNames[symbol] || symbol; +} + +// ============================================================================ +// Position Adapter +// ============================================================================ + +/** + * Adapt MYX SDK PositionType to MetaMask Position + * + * @param pos - MYX position from SDK + * @param poolSymbolMap - Map of poolId to symbol + * @returns MetaMask Position object + */ +export function adaptPositionFromMYX( + pos: MYXPositionType, + poolSymbolMap: Map, +): Position { + const symbol = poolSymbolMap.get(pos.poolId) ?? pos.poolId; + const sizeNum = fromMYXSize(pos.size); + const entryPriceNum = fromMYXPrice(pos.entryPrice); + const collateralNum = fromMYXCollateral(pos.collateralAmount); + + // Direction: 0 = LONG (positive size), 1 = SHORT (negative size) + const isLong = pos.direction === MYXDirection.LONG; + const signedSize = isLong ? sizeNum : -sizeNum; + + // Position value = size * entry price + const positionValue = Math.abs(sizeNum * entryPriceNum); + + // Leverage = position value / collateral (approximate) + const leverage = collateralNum > 0 ? positionValue / collateralNum : 1; + + return { + symbol, + size: signedSize.toString(), + entryPrice: entryPriceNum.toString(), + positionValue: positionValue.toString(), + unrealizedPnl: '0', // Requires mark price - will be enriched by WS or separate call + marginUsed: collateralNum.toString(), + leverage: { + type: 'isolated', + value: Math.round(leverage), + rawUsd: collateralNum.toString(), + }, + liquidationPrice: null, // Requires separate calculation + maxLeverage: MYX_MAX_LEVERAGE, + returnOnEquity: '0', + cumulativeFunding: { + allTime: '0', + sinceOpen: '0', + sinceChange: '0', + }, + takeProfitPrice: undefined, + stopLossPrice: undefined, + takeProfitCount: 0, + stopLossCount: 0, + providerId: 'myx', + }; +} + +// ============================================================================ +// Order Adapter +// ============================================================================ + +/** + * Adapt MYX SDK open order (PositionType-shaped from getOrders) to MetaMask Order. + * Note: getOrders returns PositionType[] per the SDK types. + * For richer order data, use getOrderHistory. + * + * @param historyOrder - MYX history order item + * @param poolSymbolMap - Map of poolId to symbol + * @returns MetaMask Order object + */ +export function adaptOrderFromMYX( + historyOrder: MYXHistoryOrderItem, + poolSymbolMap: Map, +): Order { + const symbol = + historyOrder.baseSymbol ?? + poolSymbolMap.get(historyOrder.poolId) ?? + historyOrder.poolId; + + const priceNum = fromMYXPrice(historyOrder.price); + const sizeNum = fromMYXSize(historyOrder.size); + const filledSizeNum = fromMYXSize(historyOrder.filledSize); + const remainingSize = Math.max(0, sizeNum - filledSizeNum); + + // Map direction + const side: 'buy' | 'sell' = + historyOrder.direction === MYXDirectionEnum.Long ? 'buy' : 'sell'; + + // Map order type + let orderType: 'market' | 'limit' = 'market'; + if (historyOrder.orderType === MYXOrderTypeEnum.Limit) { + orderType = 'limit'; + } + + // Map status + let status: Order['status'] = 'open'; + switch (historyOrder.orderStatus) { + case MYXOrderStatusEnum.Successful: + status = 'filled'; + break; + case MYXOrderStatusEnum.Cancelled: + status = 'canceled'; + break; + case MYXOrderStatusEnum.Expired: + status = 'canceled'; + break; + default: + status = 'open'; + } + + // Detect trigger orders + const isTrigger = + historyOrder.execType === MYXExecTypeEnum.TP || + historyOrder.execType === MYXExecTypeEnum.SL; + let detailedOrderType: string | undefined; + if (historyOrder.execType === MYXExecTypeEnum.TP) { + detailedOrderType = 'Take Profit'; + } else if (historyOrder.execType === MYXExecTypeEnum.SL) { + detailedOrderType = 'Stop Loss'; + } else if (historyOrder.execType === MYXExecTypeEnum.Liquidation) { + detailedOrderType = 'Liquidation'; + } + + return { + orderId: String(historyOrder.orderId), + symbol, + side, + orderType, + size: sizeNum.toString(), + originalSize: sizeNum.toString(), + price: priceNum.toString(), + filledSize: filledSizeNum.toString(), + remainingSize: remainingSize.toString(), + status, + timestamp: historyOrder.txTime, + isTrigger, + detailedOrderType, + reduceOnly: + historyOrder.operation === MYXOperationEnum.Decrease ? true : undefined, + providerId: 'myx', + }; +} + +// ============================================================================ +// Account State Adapter +// ============================================================================ + +/** + * Adapt MYX account info response to MetaMask AccountState. + * + * @param accountInfo - Raw account info from MYX SDK + * @param walletBalance - Wallet USDT balance (from getWalletQuoteTokenBalance) + * @returns MetaMask AccountState + */ +export function adaptAccountStateFromMYX( + accountInfo: Record | undefined, + walletBalance?: string, +): AccountState { + // accountInfo structure varies; extract what we can + // TODO: Verify SDK semantics — if totalCollateral already includes unrealizedPnl, + // the totalBalance formula below double-counts. Needs SDK documentation check. + const rawCollateral = accountInfo?.totalCollateral ?? '0'; + const rawPnl = accountInfo?.unrealizedPnl ?? '0'; + const marginUsed = accountInfo ? fromMYXCollateral(String(rawCollateral)) : 0; + const unrealizedPnl = accountInfo ? fromMYXCollateral(String(rawPnl)) : 0; + const balance = walletBalance ? fromMYXCollateral(walletBalance) : 0; + + const totalBalance = balance + marginUsed + unrealizedPnl; + + return { + spendableBalance: balance.toString(), + withdrawableBalance: balance.toString(), + totalBalance: totalBalance.toString(), + marginUsed: marginUsed.toString(), + unrealizedPnl: unrealizedPnl.toString(), + returnOnEquity: '0', + }; +} + +// ============================================================================ +// Order Fill Adapter +// ============================================================================ + +/** + * Adapt MYX history order item (filled) to MetaMask OrderFill + * + * @param order - MYX history order item + * @param poolSymbolMap - Map of poolId to symbol + * @returns MetaMask OrderFill + */ +export function adaptOrderFillFromMYX( + order: MYXHistoryOrderItem, + poolSymbolMap: Map, +): OrderFill { + const symbol = + order.baseSymbol ?? poolSymbolMap.get(order.poolId) ?? order.poolId; + const sizeNum = fromMYXSize(order.filledSize || order.size); + const priceNum = fromMYXPrice(order.lastPrice || order.price); + const side = order.direction === MYXDirectionEnum.Long ? 'buy' : 'sell'; + const feeNum = fromMYXCollateral(order.tradingFee || '0'); + const pnlNum = fromMYXCollateral(order.realizedPnl || '0'); + + let orderType: OrderFill['orderType'] = 'regular'; + if (order.execType === MYXExecTypeEnum.TP) { + orderType = 'take_profit'; + } else if (order.execType === MYXExecTypeEnum.SL) { + orderType = 'stop_loss'; + } else if (order.execType === MYXExecTypeEnum.Liquidation) { + orderType = 'liquidation'; + } + + return { + orderId: String(order.orderId), + symbol, + side, + size: sizeNum.toString(), + price: priceNum.toString(), + pnl: pnlNum.toString(), + direction: side, + fee: feeNum.toString(), + feeToken: 'USDT', + timestamp: order.txTime, + success: order.orderStatus === MYXOrderStatusEnum.Successful, + orderType, + providerId: 'myx', + }; +} + +// ============================================================================ +// Funding Adapter +// ============================================================================ + +/** + * Adapt MYX trade flow items (funding type) to MetaMask Funding + * + * @param flows - MYX trade flow items filtered to funding type + * @param poolSymbolMap - Map of poolId to symbol + * @returns Array of MetaMask Funding objects + */ +export function adaptFundingFromMYX( + flows: MYXTradeFlowItem[], + poolSymbolMap: Map, +): Funding[] { + return flows + .filter( + (flow) => + flow.fundingFee && flow.fundingFee !== '0' && flow.fundingFee !== '', + ) + .map((flow) => { + const symbol = poolSymbolMap.get(flow.poolId) ?? flow.poolId; + const amountUsd = fromMYXCollateral(flow.fundingFee); + return { + symbol, + amountUsd: amountUsd.toString(), + rate: undefined, // Funding rate not available in MYX trade flow data + timestamp: flow.txTime, + transactionHash: flow.txHash, + }; + }); +} + +// ============================================================================ +// User History Adapter +// ============================================================================ + +/** + * Adapt MYX trade flow items to MetaMask UserHistoryItem + * + * @param flows - MYX trade flow items + * @returns Array of UserHistoryItem + */ +export function adaptUserHistoryFromMYX( + flows: MYXTradeFlowItem[], +): UserHistoryItem[] { + return flows + .filter( + (flow) => + flow.type === MYXTradeFlowTypeEnum.MarginAccountDeposit || + flow.type === MYXTradeFlowTypeEnum.TransferToWallet, + ) + .map((flow) => { + const isDeposit = flow.type === MYXTradeFlowTypeEnum.MarginAccountDeposit; + const amount = fromMYXCollateral(flow.collateralAmount || '0'); + return { + id: String(flow.orderId), + timestamp: flow.txTime, + type: isDeposit ? 'deposit' : 'withdrawal', + amount: Math.abs(amount).toString(), + asset: 'USDT', + txHash: flow.txHash, + status: 'completed' as const, + details: { + source: 'myx', + }, + }; + }); +} + +// ============================================================================ +// Candle (Kline) Adapter +// ============================================================================ + +/** + * Adapt MYX KlineDataItemType to MetaMask CandleStick. + * KlineDataItemType fields (time, open, close, high, low) are already + * human-readable strings — no 30-decimal conversion needed. + * + * @param item - MYX kline data item from SDK + * @returns MetaMask CandleStick object + */ +export function adaptCandleFromMYX(item: MYXKlineData): CandleStick { + return { + time: item.time, + open: item.open, + high: item.high, + low: item.low, + close: item.close, + volume: '0', // KlineDataItemType has no volume field + }; +} + +/** + * Adapt MYX WebSocket KlineData to MetaMask CandleStick. + * WS KlineData uses single-letter fields: {t, o, h, l, c, v}. + * + * @param data - MYX WebSocket kline data + * @returns MetaMask CandleStick object + */ +export function adaptCandleFromMYXWebSocket(data: MYXKlineWsData): CandleStick { + return { + time: data.t, + open: data.o, + high: data.h, + low: data.l, + close: data.c, + volume: data.v, + }; +} + +/** + * Map CandlePeriod values to MYX KlineResolution. + * MYX SDK supports: '1m', '5m', '15m', '30m', '1h', '4h', '1d', '1w', '1M'. + * Unsupported CandlePeriod values are mapped to the nearest supported resolution. + */ +const CANDLE_PERIOD_TO_MYX_RESOLUTION: Record = { + '1m': '1m', + '3m': '5m', // No 3m → use 5m + '5m': '5m', + '15m': '15m', + '30m': '30m', + '1h': '1h', + '2h': '4h', // No 2h → use 4h + '4h': '4h', + '8h': '4h', // No 8h → use 4h + '12h': '1d', // No 12h → use 1d + '1d': '1d', + '3d': '1w', // No 3d → use 1w + '1w': '1w', + '1M': '1M', +}; + +/** + * Convert a CandlePeriod string to MYX KlineResolution. + * + * @param period - CandlePeriod value (e.g., '1m', '3m', '1h') + * @returns MYX KlineResolution string + */ +export function toMYXKlineResolution(period: string): string { + return CANDLE_PERIOD_TO_MYX_RESOLUTION[period] ?? '1h'; +} + +// ============================================================================ +// Response Validation +// ============================================================================ + +/** + * Assert MYX API response is successful. + * MYX uses code 9200 or 0 for success. + * + * @param response - MYX API response with code field + * @param response.code - Response code (9200 or 0 = success) + * @param response.message - Optional error message + * @param context - Context string for error messages + */ +export function assertMYXSuccess( + response: { code: number; message?: string | null }, + context: string, +): void { + if (response.code !== 9200 && response.code !== 0) { + throw new Error( + `MYX ${context} failed: code=${response.code} message=${response.message ?? 'unknown'}`, + ); + } +} diff --git a/packages/perps-controller/src/utils/orderCalculations.ts b/packages/perps-controller/src/utils/orderCalculations.ts new file mode 100644 index 00000000000..11cbb9f97d6 --- /dev/null +++ b/packages/perps-controller/src/utils/orderCalculations.ts @@ -0,0 +1,1188 @@ +import type { Hex } from '@metamask/utils'; + +import { BASIS_POINTS_DIVISOR } from '../constants/hyperLiquidConfig.js'; +import { + DECIMAL_PRECISION_CONFIG, + MAX_ORDER_MARGIN_BUFFER, + ORDER_SLIPPAGE_CONFIG, +} from '../constants/perpsConfig.js'; +import { PERPS_ERROR_CODES } from '../perpsErrorCodes.js'; +import type { SDKOrderParams } from '../types/hyperliquid-types.js'; +import type { PerpsDebugLogger } from '../types/index.js'; +import type { OrdinaryOrderType, OrderType } from '../types/perps-types.js'; +import { + formatHyperLiquidPrice, + formatHyperLiquidSize, +} from './hyperLiquidAdapter.js'; +import { + getTriggerDirection, + isLimitExecutionOrderType, + isTriggerOrderType, + SCALE_ORDER_COUNT, + toSDKTimeInForce, +} from './orderTypes.js'; + +/** + * Optional debug logger for order calculation functions. + * When provided, enables detailed logging for debugging. + */ +export type OrderCalculationsDebugLogger = PerpsDebugLogger | undefined; + +/** + * Tolerance used when deciding whether a scaled size is already on the size + * grid, guarding against floating-point representation error. + */ +const FLOAT_TOLERANCE = 1e-6; + +type PositionSizeParams = { + amount: string; + price: number; + szDecimals: number; +}; + +type MarginRequiredParams = { + amount: string; + leverage: number; +}; + +type MaxAllowedAmountParams = { + spendableBalance: number; + assetPrice: number; + assetSzDecimals: number; + leverage: number; + // Placement type. Only a resting order is margin-checked against its own + // submitted price; a marketable order is charged at the fill price. Defaults + // to 'market'. + orderType?: 'market' | 'limit'; + // Price a limit order will rest at. Needed to size a limit order that rests + // above the market price. + limitPrice?: number; +}; + +// Advanced order calculation interfaces +export type CalculateFinalPositionSizeParams = { + usdAmount?: string; + size?: string; + currentPrice: number; + priceAtCalculation?: number; + maxSlippageBps?: number; + szDecimals: number; + leverage?: number; + // Reduce-only orders (position closes) may never round up: HyperLiquid + // rejects a reduce-only order whose size exceeds the live position with + // "Reduce only order would increase position". + reduceOnly?: boolean; + debugLogger?: OrderCalculationsDebugLogger; +}; + +export type CalculateFinalPositionSizeResult = { + finalPositionSize: number; +}; + +export type CalculateOrderPriceAndSizeParams = { + // Strategy placements are excluded: each derives its own prices and sizes and + // never reaches this helper. Left in, `chase` would fall through as a + // limit-priced order it carries no price for, and `twap`/`scale` would be + // serialized as ordinary FrontendMarket orders. + orderType: OrdinaryOrderType; + isBuy: boolean; + finalPositionSize: number; + currentPrice: number; + limitPrice?: string; + // Trigger price for stop_*/take_profit_* placements. Required for those types: + // `*_limit` executes at `limitPrice`, `*_market` derives a slippage-capped + // limit price from this trigger price. + triggerPrice?: string; + // Max slippage in basis points (e.g. 300 = 3%). Applied to market orders and to + // market-executing trigger orders (where it caps the limit price derived from + // the trigger price); limit orders use limitPrice directly. Falls back to + // ORDER_SLIPPAGE_CONFIG.DefaultMarketSlippageBps for market orders and + // .DefaultTpslSlippageBps for market-executing triggers. + maxSlippageBps?: number; + szDecimals: number; +}; + +export type CalculateOrderPriceAndSizeResult = { + orderPrice: number; + formattedSize: string; + formattedPrice: string; +}; + +export type BuildOrdersArrayParams = { + assetId: number; + isBuy: boolean; + formattedPrice: string; + formattedSize: string; + reduceOnly: boolean; + // Strategy placements never reach here; see `CalculateOrderPriceAndSizeParams`. + orderType: OrdinaryOrderType; + timeInForce?: 'GTC' | 'IOC' | 'ALO'; + clientOrderId?: string; + // Trigger price for stop_*/take_profit_* placements (required for those types) + triggerPrice?: string; + takeProfitPrice?: string; + stopLossPrice?: string; + // Partial TP/SL sizes; default to the full order size when omitted + takeProfitSize?: string; + stopLossSize?: string; + szDecimals: number; + grouping?: 'na' | 'normalTpsl' | 'positionTpsl'; +}; + +export type BuildOrdersArrayResult = { + orders: SDKOrderParams[]; + grouping: 'na' | 'normalTpsl' | 'positionTpsl'; +}; + +/** + * Calculate position size based on USD amount and asset price + * + * @param params - Amount in USD, current asset price, and required decimal precision + * @returns Position size formatted to the asset's decimal precision + */ +export function calculatePositionSize(params: PositionSizeParams): string { + const { amount, price, szDecimals } = params; + + // Validate required parameters + if (szDecimals === undefined || szDecimals === null) { + throw new Error('szDecimals is required for position size calculation'); + } + if (szDecimals < 0) { + throw new Error(`szDecimals must be >= 0, got: ${szDecimals}`); + } + + const amountNum = parseFloat(amount || '0'); + + if (isNaN(amountNum) || isNaN(price) || amountNum === 0 || price === 0) { + return (0).toFixed(szDecimals); + } + + const positionSize = amountNum / price; + const multiplier = Math.pow(10, szDecimals); + let rounded = Math.round(positionSize * multiplier) / multiplier; + + // Ensure rounded size meets requested USD (fix validation gap) + const actualUsd = rounded * price; + if (actualUsd < amountNum) { + rounded += 1 / multiplier; + } + + return rounded.toFixed(szDecimals); +} + +/** + * Calculate margin required for a position + * + * @param params - Position amount and leverage + * @returns Margin required formatted to 2 decimal places + */ +export function calculateMarginRequired(params: MarginRequiredParams): string { + const { amount, leverage } = params; + const amountNum = parseFloat(amount || '0'); + + if ( + isNaN(amountNum) || + isNaN(leverage) || + amountNum === 0 || + leverage === 0 + ) { + return '0.00'; + } + + return (amountNum / leverage).toFixed(2); +} + +export function getMaxAllowedAmount(params: MaxAllowedAmountParams): number { + const { + spendableBalance, + assetPrice, + assetSzDecimals, + leverage, + orderType = 'market', + limitPrice, + } = params; + if (spendableBalance === 0 || !assetPrice || assetSzDecimals === undefined) { + return 0; + } + + // HyperLiquid reserves initial margin for a RESTING order against the price + // the order is submitted at, not the market price its size was derived from. + // A limit order resting above the market price - typically a sell - therefore + // needs more margin than a market-priced notional budgets for, and the + // exchange refuses it with "insufficient margin to place order". Price the max + // off that submitted price instead. A marketable order is charged at the fill + // price, so it needs no adjustment. + const executionPriceRatio = + orderType === 'limit' && limitPrice && limitPrice > assetPrice + ? limitPrice / assetPrice + : 1; + + // The theoretical maximum is spendableBalance * leverage, expressed in the + // market-price notional the caller works with. + const theoreticalMax = (spendableBalance * leverage) / executionPriceRatio; + + // But we need to account for position size rounding + // Find the largest whole dollar amount that fits within this limit + let maxAmount = Math.floor(theoreticalMax); + + // Verify this amount doesn't exceed available balance after rounding + const testPositionSize = calculatePositionSize({ + amount: maxAmount.toString(), + price: assetPrice, + szDecimals: assetSzDecimals, + }); + + const actualNotionalValue = + parseFloat(testPositionSize) * assetPrice * executionPriceRatio; + const requiredMargin = actualNotionalValue / leverage; + + // If rounding caused us to exceed available balance, step down by one position increment + if (requiredMargin > spendableBalance) { + const minPositionSizeIncrement = 1 / Math.pow(10, assetSzDecimals); + const positionSizeIncrementUsd = Math.ceil( + minPositionSizeIncrement * assetPrice, + ); + maxAmount -= positionSizeIncrementUsd; + } + + // Apply margin buffer to reduce "Insufficient margin" rejections from the exchange + // (fees, rounding, and exchange-side checks can make 100% theoretical max fail) + const bufferedMax = maxAmount * (1 - MAX_ORDER_MARGIN_BUFFER); + + return Math.max(0, Math.floor(bufferedMax)); +} + +/** + * Round a size down onto the asset's size grid. + * + * Used for reduce-only orders, where rounding up would push the size past the + * live position size. Values already on the grid are snapped rather than + * truncated, because floating-point math can leave them just below a grid + * point (0.0123 * 10000 === 122.99999999999999) and truncating would drop a + * whole increment. + * + * The result is never greater than `size`, for negative sizes as well as + * positive: the snap only ever recovers a grid point the input already + * represents, so a value genuinely below a grid point is stepped down even when + * the tolerance would have reached the point above it. + * + * A size whose scaled form reaches `2^53` is returned unchanged: doubles cannot + * represent consecutive integers there, so the grid is finer than the spacing + * between representable values and there is nothing to round down to. + * + * @param size - Size to round down. + * @param szDecimals - The asset's size decimal precision. + * @returns The size rounded down onto the size grid, never exceeding `size`. + */ +export function floorToSizeDecimals(size: number, szDecimals: number): number { + const multiplier = Math.pow(10, szDecimals); + const scaled = size * multiplier; + + // Past 2^53 a double cannot represent consecutive integers, so `units -= 1` + // below would be a no-op and the step-down loop would never terminate. The + // size grid is finer than the spacing between representable values at that + // magnitude, so there is no increment to shave: return the input unchanged. + if (!Number.isFinite(scaled) || Math.abs(scaled) >= Number.MAX_SAFE_INTEGER) { + return size; + } + + const nearest = Math.round(scaled); + // The tolerance scales with the magnitude, because double-precision error + // does too: a fixed epsilon would stop absorbing representation error for + // sizes that scale past ~1e10 and would then shave off a whole increment. + const tolerance = Math.max( + FLOAT_TOLERANCE, + Math.abs(scaled) * Number.EPSILON * 8, + ); + let units = + Math.abs(scaled - nearest) < tolerance ? nearest : Math.floor(scaled); + + // Step down until the result no longer exceeds the input. One pass is not + // enough: a tolerance wide enough to absorb representation error at large + // magnitudes also reaches the next grid point, and for an input less than half + // an ulp below a grid point `size * multiplier` evaluates to exactly that grid + // integer, so flooring the scaled value returns the same too-large result. + // The comparison alone is the whole termination condition: for a non-negative + // size the loop stops at or before zero, and for a negative size it stops once + // the value is no longer above the input. Guarding on `units` instead would + // skip a negative size below the tolerance, which snaps to `-0` — and + // `-0 !== 0` is false. The 2^53 bail-out above keeps this bounded. + while (units / multiplier > size) { + units -= 1; + } + + return units / multiplier; +} + +/** + * The smallest price increment the venue will represent at a given price. + * + * HyperLiquid bounds a perp price two ways at once — a decimal-place cap that + * depends on the asset's size precision, and a significant-figure cap — so the + * tick widens as the price grows. Whichever bound is coarser at this price is + * the tick. + * + * @param params - Tick parameters. + * @param params.price - Price to measure the increment at. + * @param params.szDecimals - The asset's size decimal precision. + * @returns The tick size at that price. + */ +export function getPriceTick(params: { + price: number; + szDecimals: number; +}): number { + const { price, szDecimals } = params; + + const byDecimals = Math.pow( + 10, + -(DECIMAL_PRECISION_CONFIG.MaxPriceDecimals - szDecimals), + ); + const bySignificantFigures = Math.pow( + 10, + Math.floor(Math.log10(Math.abs(price))) - + (DECIMAL_PRECISION_CONFIG.MaxSignificantFigures - 1), + ); + + return Math.max(byDecimals, bySignificantFigures); +} + +/** + * Price a chase order against the book it is chasing. + * + * The venue's own definition: a chase rests one tick *inside* the spread — + * above the best bid for a buy, below the best ask for a sell — except when the + * spread is already a single tick, where there is no room to improve and the + * order joins the touch instead. + * + * Rests inside rather than at the touch because a chase is post-only: sitting + * one tick ahead of the rest of the queue is the whole point, and joining the + * touch would leave it behind every order already resting there. + * + * @param params - Quote parameters. + * @param params.bestBid - Best bid, excluding the chase's own resting order. + * @param params.bestAsk - Best ask, excluding the chase's own resting order. + * @param params.isBuy - Which side the chase rests on. + * @param params.szDecimals - The asset's size decimal precision. + * @returns The formatted price the chase should rest at. + */ +export function computeChaseQuotePrice(params: { + bestBid: number; + bestAsk: number; + isBuy: boolean; + szDecimals: number; +}): string { + const { bestBid, bestAsk, isBuy, szDecimals } = params; + + const reference = isBuy ? bestBid : bestAsk; + const tick = getPriceTick({ price: reference, szDecimals }); + const improved = isBuy ? bestBid + tick : bestAsk - tick; + + // A single-tick spread leaves nowhere to improve to: the improved price would + // cross, which a post-only order cannot do. Join the touch instead. + const crosses = isBuy ? improved >= bestAsk : improved <= bestBid; + + return formatHyperLiquidPrice({ + price: crosses ? reference : improved, + szDecimals, + }); +} + +/** + * Compute the price ladder a scale placement fans out over. + * + * The ladder is inclusive of both ends — the first rung sits exactly on + * `minPrice`, the last exactly on `maxPrice` — so the range the caller asked + * for is the range that actually reaches the exchange. + * + * @param params - Ladder parameters. + * @param params.minPrice - Lowest price in the ladder. + * @param params.maxPrice - Highest price in the ladder; must exceed `minPrice`. + * @param params.count - Number of rungs. + * @returns The rung prices, ascending. + */ +export function computeScalePriceLadder(params: { + minPrice: number; + maxPrice: number; + count: number; +}): number[] { + const { minPrice, maxPrice, count } = params; + + if ( + !Number.isInteger(count) || + count < SCALE_ORDER_COUNT.min || + count > SCALE_ORDER_COUNT.max + ) { + throw new Error(PERPS_ERROR_CODES.ORDER_SCALE_COUNT_INVALID); + } + if ( + !Number.isFinite(minPrice) || + !Number.isFinite(maxPrice) || + minPrice <= 0 || + maxPrice <= minPrice + ) { + throw new Error(PERPS_ERROR_CODES.ORDER_SCALE_RANGE_INVALID); + } + + const step = (maxPrice - minPrice) / (count - 1); + // The top rung is assigned rather than accumulated: `minPrice + step * n` + // drifts, and a ladder that stops short of the caller's maxPrice would quietly + // narrow the range they asked for. + return Array.from({ length: count }, (_unused, index) => + index === count - 1 ? maxPrice : minPrice + step * index, + ); +} + +/** + * Split a scale placement's total size across its ladder rungs. + * + * The split is done in whole units of the asset's size grid rather than in + * decimal sizes: dividing and re-flooring in floating point loses a sub-unit of + * dust on every rung, and a ladder that submits less than the size that was + * validated is not the order the caller placed. Either allocation below sums to + * exactly `totalSize` in grid units. + * + * This is the one place the ladder's sizes are decided. Clients previewing a + * scale placement must call it rather than reproduce the ramp, or the preview + * and the placement will disagree at the rounding. + * + * **Even (no `skew`, or `skew` exactly 1).** Every rung gets `floor(total / + * count)` units and whatever does not divide evenly goes onto the first rung — + * 11 units across 3 rungs is `5, 3, 3`, not three equal slices. + * + * **Skewed.** Rung weights ramp linearly from 1 at index 0 to `skew` at the last + * index, in ladder order — which is ascending price, `scaleMinPrice` to + * `scaleMaxPrice`, for a buy and a sell alike. Each rung takes + * `floor(weight / sumOfWeights * totalUnits)` units, and the units left over go + * to the rungs with the largest discarded fraction, ties broken by ascending + * index. The leftover is deliberately *not* dumped on the first rung the way the + * even split does it: on a `skew` above 1 that would push size back to the end + * of the ladder the caller weighted away from. + * + * The total is expected to sit on the grid already — `calculateFinalPositionSize` + * floors it there — so rounding onto the grid here only absorbs representation + * error. A total too small to give every rung a whole unit is rejected: placing + * fewer orders than asked for would silently change the strategy. A `skew` far + * enough from 1 can starve a rung the same way, and is rejected the same way. + * + * @param params - Split parameters. + * @param params.totalSize - Total size to distribute. + * @param params.count - Number of rungs. + * @param params.szDecimals - The asset's size decimal precision. + * @param params.skew - Optional size weighting across the ladder; any finite + * value above 0, used exactly as given. + * @returns One size string per rung, in ladder order. + */ +export function splitScaleSizes(params: { + totalSize: number; + count: number; + szDecimals: number; + skew?: number; +}): string[] { + const { totalSize, count, szDecimals, skew } = params; + + // Checked here as well as in `computeScalePriceLadder`: this is exported on + // its own, and a count of zero would otherwise return an empty split while a + // fractional one would return slices that do not sum to the total. + if ( + !Number.isInteger(count) || + count < SCALE_ORDER_COUNT.min || + count > SCALE_ORDER_COUNT.max + ) { + throw new Error(PERPS_ERROR_CODES.ORDER_SCALE_COUNT_INVALID); + } + + // Checked here rather than left to the arithmetic: a non-finite or + // non-positive skew produces weights that are NaN or run negative, and either + // one would come back as a ladder of zero-size rungs instead of a rejection. + if (skew !== undefined && (!Number.isFinite(skew) || skew <= 0)) { + throw new Error(PERPS_ERROR_CODES.ORDER_SCALE_RANGE_INVALID); + } + + const multiplier = Math.pow(10, szDecimals); + const totalUnits = Math.round(totalSize * multiplier); + + if (!Number.isSafeInteger(totalUnits) || totalUnits < count) { + throw new Error(PERPS_ERROR_CODES.ORDER_SCALE_SIZE_TOO_SMALL); + } + + const unitsPerRung = + skew === undefined || skew === 1 + ? splitUnitsEvenly({ totalUnits, count }) + : splitUnitsBySkew({ totalUnits, count, skew }); + + // A rung the ramp starved of every unit would be submitted as a zero-size + // order. That is the same failure the total-size check above rejects, and it + // is reported the same way, so a caller reads one reason for "this ladder + // cannot be cut this finely" rather than two. + if (unitsPerRung.some((units) => units === 0)) { + throw new Error(PERPS_ERROR_CODES.ORDER_SCALE_SIZE_TOO_SMALL); + } + + return unitsPerRung.map((units) => + formatHyperLiquidSize({ size: units / multiplier, szDecimals }), + ); +} + +/** + * Spread the ladder's units evenly, leftover on the first rung. + * + * @param params - Split parameters. + * @param params.totalUnits - Total size, in whole size-grid units. + * @param params.count - Number of rungs. + * @returns Units per rung, in ladder order. + */ +function splitUnitsEvenly(params: { + totalUnits: number; + count: number; +}): number[] { + const { totalUnits, count } = params; + + const sliceUnits = Math.floor(totalUnits / count); + const remainderUnits = totalUnits - sliceUnits * count; + + return Array.from({ length: count }, (_unused, index) => + index === 0 ? sliceUnits + remainderUnits : sliceUnits, + ); +} + +/** + * Spread the ladder's units along a linear weight ramp. + * + * Flooring every rung leaves up to `count - 1` units unallocated, and they go to + * the rungs that lost the most to the floor — the standard largest-remainder + * allocation. Ties go to the lower index, which keeps the result a function of + * the inputs alone rather than of sort stability. + * + * @param params - Split parameters. + * @param params.totalUnits - Total size, in whole size-grid units. + * @param params.count - Number of rungs. + * @param params.skew - Weight of the last rung relative to the first. + * @returns Units per rung, in ladder order. + */ +function splitUnitsBySkew(params: { + totalUnits: number; + count: number; + skew: number; +}): number[] { + const { totalUnits, count, skew } = params; + + const weights = Array.from( + { length: count }, + (_unused, index) => 1 + ((skew - 1) * index) / (count - 1), + ); + const weightSum = weights.reduce((sum, weight) => sum + weight, 0); + + const ideal = weights.map((weight) => (weight / weightSum) * totalUnits); + const unitsPerRung = ideal.map((units) => Math.floor(units)); + const leftoverUnits = + totalUnits - unitsPerRung.reduce((sum, units) => sum + units, 0); + + Array.from({ length: count }, (_unused, index) => index) + .sort((left, right) => { + const fractionDelta = + ideal[right] - unitsPerRung[right] - (ideal[left] - unitsPerRung[left]); + return fractionDelta === 0 ? left - right : fractionDelta; + }) + .slice(0, leftoverUnits) + .forEach((index) => { + unitsPerRung[index] += 1; + }); + + return unitsPerRung; +} + +/** + * Calculates final position size using USD as source of truth with price validation + * + * This function implements the hybrid approach where USD is the source of truth, + * but includes price staleness validation and proper rounding to prevent precision loss. + * + * @param params - USD amount, size, prices, and configuration + * @returns Final position size as a number + */ +export function calculateFinalPositionSize( + params: CalculateFinalPositionSizeParams, +): CalculateFinalPositionSizeResult { + const { + usdAmount, + size, + currentPrice, + priceAtCalculation, + maxSlippageBps, + szDecimals, + leverage, + reduceOnly, + debugLogger, + } = params; + + let finalPositionSize: number; + + // Validate price staleness whenever the caller supplied a calculation-time + // price. This runs before the sizing branches on purpose: a full close submits + // the exact live position size rather than a USD-derived one, and it must still + // be rejected when the price has moved past the caller's tolerance. + if (priceAtCalculation) { + const priceDeltaBps = Math.abs( + ((currentPrice - priceAtCalculation) / priceAtCalculation) * 10000, + ); + const maxSlippageBpsValue = + maxSlippageBps ?? ORDER_SLIPPAGE_CONFIG.DefaultMarketSlippageBps; + + if (priceDeltaBps > maxSlippageBpsValue) { + throw new Error( + `Price moved too much: ${priceDeltaBps.toFixed(0)} bps (max: ${maxSlippageBpsValue} bps). ` + + `Expected: ${priceAtCalculation.toFixed(2)}, Current: ${currentPrice.toFixed(2)}`, + ); + } + + debugLogger?.log('Price validation passed:', { + priceAtCalculation, + currentPrice, + deltaBps: priceDeltaBps.toFixed(2), + maxSlippageBps: maxSlippageBpsValue, + }); + } + + if (usdAmount && parseFloat(usdAmount) > 0) { + // USD amount provided - use it as source of truth + const usdValue = parseFloat(usdAmount); + + // Recalculate position size with fresh price + finalPositionSize = usdValue / currentPrice; + + // A reduce-only order may never exceed the size the caller asked to close: + // that size is already clamped to the live position, while the USD amount was + // computed against an older price and can imply a larger size after an + // adverse move. Capping here keeps USD accuracy in the common case and makes + // the caller's clamp binding. + if (reduceOnly && size) { + const requestedSize = parseFloat(size); + + // A supplied size must be positive, or the cap below would submit a + // zero/negative order. Reject it rather than silently falling back to the + // USD-derived size, matching how closePosition treats the same input. + if (!Number.isFinite(requestedSize) || requestedSize <= 0) { + throw new Error(PERPS_ERROR_CODES.ORDER_SIZE_POSITIVE); + } + + finalPositionSize = Math.min(finalPositionSize, requestedSize); + } + + // 3. Apply size decimals rounding (reduce-only never rounds up) + const multiplier = Math.pow(10, szDecimals); + const sizeBeforeRounding = finalPositionSize; + finalPositionSize = reduceOnly + ? floorToSizeDecimals(finalPositionSize, szDecimals) + : Math.round(finalPositionSize * multiplier) / multiplier; + + // Rounding down can zero out a reduce-only order whose USD value is worth + // less than one size increment. Fail with a clear error instead of + // submitting a size of "0" the exchange will reject. + if (reduceOnly && finalPositionSize <= 0 && sizeBeforeRounding > 0) { + throw new Error(PERPS_ERROR_CODES.ORDER_SIZE_POSITIVE); + } + + // 4. Ensure rounded size meets requested USD (fix validation gap). + // Skipped for reduce-only orders: adding an increment there would submit + // more than the position holds and HyperLiquid rejects the order. + let actualNotionalValue = finalPositionSize * currentPrice; + if (!reduceOnly && actualNotionalValue < usdValue) { + // Add 1 minimum increment to meet requested USD + finalPositionSize += 1 / multiplier; + actualNotionalValue = finalPositionSize * currentPrice; + + debugLogger?.log('Position size adjusted to meet USD minimum:', { + requestedUsd: usdValue, + beforeAdjustment: finalPositionSize - 1 / multiplier, + afterAdjustment: finalPositionSize, + actualUsd: actualNotionalValue, + }); + } + + const requiredMargin = actualNotionalValue / (leverage ?? 1); + + // Log if rounding caused significant difference + const usdDifference = Math.abs(actualNotionalValue - usdValue); + if (usdDifference > 0.01) { + debugLogger?.log( + 'Position size rounding caused USD difference (acceptable):', + { + requestedUsd: usdValue, + actualUsd: actualNotionalValue, + difference: usdDifference, + positionSize: finalPositionSize, + }, + ); + } + + debugLogger?.log('Recalculated position size with fresh price:', { + usdAmount: usdValue, + priceAtCalculation, + currentPrice, + originalSize: size, + recalculatedSize: finalPositionSize, + requiredMargin, + minIncrement: 1 / multiplier, + }); + } else { + // Legacy: Use provided size (backward compatibility) + finalPositionSize = parseFloat(size ?? '0'); + + // Reduce-only sizes are formatted with toFixed() further down, which rounds + // up; truncate onto the size grid first so a close can never exceed the + // position it is closing. + if (reduceOnly) { + // A supplied size must be positive, or formatHyperLiquidSize would render + // a zero or negative order size. The USD branch above rejects the same + // input. + if (size && !(finalPositionSize > 0)) { + throw new Error(PERPS_ERROR_CODES.ORDER_SIZE_POSITIVE); + } + + const sizeBeforeFlooring = finalPositionSize; + finalPositionSize = floorToSizeDecimals(finalPositionSize, szDecimals); + + // A positive size that floors to zero is worth less than one increment + if (finalPositionSize <= 0 && sizeBeforeFlooring > 0) { + throw new Error(PERPS_ERROR_CODES.ORDER_SIZE_POSITIVE); + } + } + + debugLogger?.log( + 'Using legacy size calculation (no USD amount provided):', + { + providedSize: size, + finalSize: finalPositionSize, + }, + ); + } + + return { finalPositionSize }; +} + +/** + * Calculates order price and formatted size based on order type + * + * @param params - Order parameters including type, direction, size, and prices + * @returns Formatted order price, size, and price string + */ +export function calculateOrderPriceAndSize( + params: CalculateOrderPriceAndSizeParams, +): CalculateOrderPriceAndSizeResult { + const { + orderType, + isBuy, + finalPositionSize, + currentPrice, + limitPrice, + triggerPrice, + maxSlippageBps, + szDecimals, + } = params; + + let orderPrice: number; + let formattedSize: string; + + if (isTriggerOrderType(orderType)) { + // Trigger placements price off the trigger, not the live market: the order + // rests off-book until the trigger fires. + if (!triggerPrice) { + throw new Error(PERPS_ERROR_CODES.ORDER_TRIGGER_PRICE_REQUIRED); + } + + const triggerPriceNum = parseFloat(triggerPrice); + if (isNaN(triggerPriceNum) || triggerPriceNum <= 0) { + throw new Error(PERPS_ERROR_CODES.ORDER_TRIGGER_PRICE_POSITIVE); + } + + if (isLimitExecutionOrderType(orderType)) { + if (!limitPrice) { + throw new Error(PERPS_ERROR_CODES.ORDER_LIMIT_PRICE_REQUIRED); + } + orderPrice = parseFloat(limitPrice); + } else { + // Market execution on trigger: HyperLiquid still needs a limit price, used + // as a slippage cap. The caller's tolerance wins when supplied; otherwise + // the 10% convention of the existing TP/SL children applies. + const effectiveBps = + maxSlippageBps ?? ORDER_SLIPPAGE_CONFIG.DefaultTpslSlippageBps; + const slippageValue = effectiveBps / BASIS_POINTS_DIVISOR; + orderPrice = isBuy + ? triggerPriceNum * (1 + slippageValue) + : triggerPriceNum * (1 - slippageValue); + } + + formattedSize = formatHyperLiquidSize({ + size: finalPositionSize, + szDecimals, + }); + } else if (orderType === 'market') { + // Market orders: apply slippage buffer to the live price so HyperLiquid + // receives a worst-case acceptable limit price. Falls back to the + // documented default if the caller does not provide one. + const effectiveBps = + maxSlippageBps ?? ORDER_SLIPPAGE_CONFIG.DefaultMarketSlippageBps; + const slippageValue = effectiveBps / BASIS_POINTS_DIVISOR; + orderPrice = isBuy + ? currentPrice * (1 + slippageValue) + : currentPrice * (1 - slippageValue); + formattedSize = formatHyperLiquidSize({ + size: finalPositionSize, + szDecimals, + }); + } else { + // Limit orders: use provided price (no slippage applied) + if (!limitPrice) { + throw new Error(PERPS_ERROR_CODES.ORDER_LIMIT_PRICE_REQUIRED); + } + orderPrice = parseFloat(limitPrice); + formattedSize = formatHyperLiquidSize({ + size: finalPositionSize, + szDecimals, + }); + } + + const formattedPrice = formatHyperLiquidPrice({ + price: orderPrice, + szDecimals, + }); + + return { orderPrice, formattedSize, formattedPrice }; +} + +/** + * Build the SDK order-type field for the main order. + * + * Trigger placements map to the SDK's trigger shape; everything else keeps the + * existing Gtc/FrontendMarket limit shape. + * + * @param params - Order type parameters + * @param params.orderType - Placement type + * @param params.timeInForce - Time in force; only limit orders may carry one + * @param params.triggerPrice - Trigger price (required for trigger placements) + * @param params.szDecimals - Asset size decimals, for price formatting + * @returns The SDK `t` field for the main order + */ +function buildMainOrderTypeField(params: { + orderType: OrderType; + timeInForce?: 'GTC' | 'IOC' | 'ALO'; + triggerPrice?: string; + szDecimals: number; +}): SDKOrderParams['t'] { + const { orderType, timeInForce, triggerPrice, szDecimals } = params; + + if (!isTriggerOrderType(orderType)) { + if (orderType === 'limit') { + return { limit: { tif: toSDKTimeInForce(timeInForce) } }; + } + if (timeInForce !== undefined) { + throw new Error(PERPS_ERROR_CODES.ORDER_TIME_IN_FORCE_NOT_SUPPORTED); + } + return { limit: { tif: 'FrontendMarket' } }; + } + + if (timeInForce !== undefined) { + throw new Error(PERPS_ERROR_CODES.ORDER_TIME_IN_FORCE_NOT_SUPPORTED); + } + + if (!triggerPrice) { + throw new Error(PERPS_ERROR_CODES.ORDER_TRIGGER_PRICE_REQUIRED); + } + + return { + trigger: { + isMarket: !isLimitExecutionOrderType(orderType), + triggerPx: formatTriggerPrice({ + price: triggerPrice, + szDecimals, + error: PERPS_ERROR_CODES.ORDER_TRIGGER_PRICE_POSITIVE, + }), + tpsl: getTriggerDirection(orderType) === 'stop' ? 'sl' : 'tp', + }, + }; +} + +/** + * Format a price that becomes a `triggerPx`, rejecting one that disappears at + * the asset's precision. + * + * A positive price below the asset's tick (`0.0004` where the asset quotes to + * three places) formats to `'0'`, which the exchange rejects. Callers validate + * this up front via `validateOrderPrecision`; this is the guard on the build + * path itself, so no caller can assemble an order that cannot be accepted. + * + * @param params - Price parameters + * @param params.price - The requested price + * @param params.szDecimals - Asset size decimals + * @param params.error - Typed error to throw when the price rounds away + * @returns The exchange-formatted price, guaranteed positive. + */ +function formatTriggerPrice(params: { + price: string; + szDecimals: number; + error: string; +}): string { + const { price, szDecimals, error } = params; + const formatted = formatHyperLiquidPrice({ price, szDecimals }); + + if (parseFloat(formatted) <= 0) { + throw new Error(error); + } + + return formatted; +} + +/** + * Resolve the size of an attached TP/SL order. + * + * @param params - Size parameters + * @param params.tpslSize - Requested partial size, if any + * @param params.formattedSize - Full order size, used when no partial size is given + * @param params.szDecimals - Asset size decimals + * @returns The exchange-formatted TP/SL order size + */ +function formatTpslSize(params: { + tpslSize?: string; + formattedSize: string; + szDecimals: number; +}): string { + const { tpslSize, formattedSize, szDecimals } = params; + + if (tpslSize === undefined) { + return formattedSize; + } + + // Validation compares the requested size against `params.size`, but a + // usdAmount-based order is finally sized from a fresher price, so the parent + // can end up smaller than the child that validated cleanly. Clamp so the + // attached TP/SL never exceeds the order it protects. + const requested = parseFloat(tpslSize); + const parentSize = parseFloat(formattedSize); + const size = + Number.isFinite(parentSize) && Number.isFinite(requested) + ? Math.min(requested, parentSize) + : requested; + + return formatPartialTpslSize({ size, szDecimals }); +} + +/** + * Check that an order's prices and partial sizes survive the asset's precision. + * + * Validation elsewhere sees the values the caller supplied; this sees what the + * exchange will actually receive. A positive value below the asset's tick + * formats to `'0'`, which either changes the order's meaning (a zero-sized + * trigger covers the whole position) or is rejected outright (a zero + * `triggerPx`). + * + * Callers run this before taking any side effect — cancelling the position's + * existing triggers, changing leverage, moving HIP-3 margin — so a value that + * would only fail once the orders are built cannot leave a position stripped of + * its protection, or an account with leverage moved, for an order that was + * never going to be accepted. + * + * @param params - Price and size parameters + * @param params.triggerPrice - Trigger price for a trigger placement, if any + * @param params.takeProfitPrice - Attached take profit price, if any + * @param params.stopLossPrice - Attached stop loss price, if any + * @param params.takeProfitSize - Requested partial take profit size, if any + * @param params.stopLossSize - Requested partial stop loss size, if any + * @param params.szDecimals - Asset size decimals + * @returns Validation result with isValid flag and optional error message + */ +export function validateOrderPrecision(params: { + triggerPrice?: string; + takeProfitPrice?: string; + stopLossPrice?: string; + takeProfitSize?: string; + stopLossSize?: string; + szDecimals: number; +}): { isValid: boolean; error?: string } { + const { + triggerPrice, + takeProfitPrice, + stopLossPrice, + takeProfitSize, + stopLossSize, + szDecimals, + } = params; + + for (const size of [takeProfitSize, stopLossSize]) { + if (size === undefined) { + continue; + } + + if (parseFloat(formatHyperLiquidSize({ size, szDecimals })) <= 0) { + return { + isValid: false, + error: PERPS_ERROR_CODES.ORDER_TPSL_SIZE_INVALID, + }; + } + } + + // Prices carry their own precision: an asset quotes to + // `MaxPriceDecimals - szDecimals` places, so a positive price under that tick + // formats to '0'. Every one of these becomes a `triggerPx` the exchange + // rejects outright. + const priceChecks: [string | undefined, string][] = [ + [triggerPrice, PERPS_ERROR_CODES.ORDER_TRIGGER_PRICE_POSITIVE], + [takeProfitPrice, PERPS_ERROR_CODES.ORDER_PRICE_POSITIVE], + [stopLossPrice, PERPS_ERROR_CODES.ORDER_PRICE_POSITIVE], + ]; + + for (const [price, error] of priceChecks) { + if (price === undefined) { + continue; + } + + if (parseFloat(formatHyperLiquidPrice({ price, szDecimals })) <= 0) { + return { isValid: false, error }; + } + } + + return { isValid: true }; +} + +/** + * Format a partial TP/SL size, rejecting one that disappears at the asset + * precision. + * + * Validation only sees the requested size, so a positive value below the + * asset's precision (0.0004 against `szDecimals: 3`) passes and then formats to + * `'0'`. HyperLiquid reads a zero-sized trigger as covering the whole position, + * which would silently turn a partial TP/SL into a full close. + * + * @param params - Size parameters + * @param params.size - The requested partial size + * @param params.szDecimals - Asset size decimals + * @returns The exchange-formatted size, guaranteed positive. + */ +export function formatPartialTpslSize(params: { + size: string | number; + szDecimals: number; +}): string { + const formatted = formatHyperLiquidSize(params); + + if (parseFloat(formatted) <= 0) { + throw new Error(PERPS_ERROR_CODES.ORDER_TPSL_SIZE_INVALID); + } + + return formatted; +} + +/** + * Builds orders array including main order and optional TP/SL orders + * + * @param params - Order construction parameters + * @returns Array of SDK order params and grouping type + */ +export function buildOrdersArray( + params: BuildOrdersArrayParams, +): BuildOrdersArrayResult { + const { + assetId, + isBuy, + formattedPrice, + formattedSize, + reduceOnly, + orderType, + timeInForce, + clientOrderId, + triggerPrice, + takeProfitPrice, + stopLossPrice, + takeProfitSize, + stopLossSize, + szDecimals, + grouping, + } = params; + + const orders: SDKOrderParams[] = []; + + // 1. Main order + const mainOrder: SDKOrderParams = { + a: assetId, + b: isBuy, + p: formattedPrice, + s: formattedSize, + r: reduceOnly || false, + t: buildMainOrderTypeField({ + orderType, + timeInForce, + triggerPrice, + szDecimals, + }), + c: clientOrderId ? (clientOrderId as Hex) : undefined, + }; + orders.push(mainOrder); + + // 2. Take Profit order + if (takeProfitPrice) { + const tpOrder: SDKOrderParams = { + a: assetId, + b: !isBuy, + p: formatHyperLiquidPrice({ + price: parseFloat(takeProfitPrice), + szDecimals, + }), + s: formatTpslSize({ + tpslSize: takeProfitSize, + formattedSize, + szDecimals, + }), + r: true, + t: { + trigger: { + isMarket: false, + triggerPx: formatTriggerPrice({ + price: takeProfitPrice, + szDecimals, + error: PERPS_ERROR_CODES.ORDER_PRICE_POSITIVE, + }), + tpsl: 'tp', + }, + }, + }; + orders.push(tpOrder); + } + + // 3. Stop Loss order + if (stopLossPrice) { + // Apply 10% slippage to SL limit price (executes as market order when triggered) + // HyperLiquid recommended: 10% for TP/SL orders + const stopLossPriceNum = parseFloat(stopLossPrice); + const slippageValue = ORDER_SLIPPAGE_CONFIG.DefaultTpslSlippageBps / 10000; + const limitPriceWithSlippage = isBuy + ? stopLossPriceNum * (1 - slippageValue) // Selling to close long: willing to accept LESS (slippage protection) + : stopLossPriceNum * (1 + slippageValue); // Buying to close short: willing to pay MORE (slippage protection) + + const slOrder: SDKOrderParams = { + a: assetId, + b: !isBuy, + p: formatHyperLiquidPrice({ + price: limitPriceWithSlippage, + szDecimals, + }), + s: formatTpslSize({ tpslSize: stopLossSize, formattedSize, szDecimals }), + r: true, + t: { + trigger: { + isMarket: true, + triggerPx: formatTriggerPrice({ + price: stopLossPrice, + szDecimals, + error: PERPS_ERROR_CODES.ORDER_PRICE_POSITIVE, + }), + tpsl: 'sl', + }, + }, + }; + orders.push(slOrder); + } + + // Determine grouping + const finalGrouping: 'na' | 'normalTpsl' | 'positionTpsl' = + grouping ?? ((takeProfitPrice ?? stopLossPrice) ? 'normalTpsl' : 'na'); + + return { orders, grouping: finalGrouping }; +} diff --git a/packages/perps-controller/src/utils/orderTypes.ts b/packages/perps-controller/src/utils/orderTypes.ts new file mode 100644 index 00000000000..15434042e69 --- /dev/null +++ b/packages/perps-controller/src/utils/orderTypes.ts @@ -0,0 +1,354 @@ +import type { Order, PositionTriggerOrder } from '../types/index.js'; +import type { + OrderExecution, + OrderType, + StrategyOrderType, + TriggerDirection, + TriggerOrderType, +} from '../types/perps-types.js'; + +/** + * All trigger placement types, in a stable order suitable for iteration + * (validation tables, e2e matrices). + */ +export const TRIGGER_ORDER_TYPES = [ + 'stop_market', + 'stop_limit', + 'take_profit_market', + 'take_profit_limit', +] as const satisfies readonly TriggerOrderType[]; + +/** + * All strategy placement types, in a stable order suitable for iteration + * (validation tables, e2e matrices). + */ +export const STRATEGY_ORDER_TYPES = [ + 'twap', + 'scale', + 'chase', +] as const satisfies readonly StrategyOrderType[]; + +/** + * Bounds on how many limit orders a scale placement may fan out into. + * + * A ladder needs at least two rungs to span a range at all; the upper bound + * keeps a single placement from consuming a venue's per-account open-order + * budget. Protocol-agnostic: a provider whose venue is stricter narrows this + * further in its own validation. + */ +export const SCALE_ORDER_COUNT = { min: 2, max: 20 } as const; + +/** + * Order types whose price field (`OrderParams.price`) is a real limit price the + * exchange must honour, as opposed to a slippage cap derived from the market. + */ +const LIMIT_EXECUTION_ORDER_TYPES = [ + 'limit', + 'stop_limit', + 'take_profit_limit', +] as const satisfies readonly OrderType[]; + +/** + * Order types that rest limit orders on the book, whatever decides their price. + * + * A superset of `LIMIT_EXECUTION_ORDER_TYPES`: a scale ladder and a chase both + * rest limit orders, but they derive their own prices rather than taking one + * from `OrderParams.price`, so they are limit *execution* without being + * limit-*priced*. A TWAP is absent because its suborders cross the book. + * + * The distinction matters wherever execution is what is being charged or + * bounded — fee tier, max order value — as opposed to where the caller's price + * field is being read. + */ +const LIMIT_RESTING_ORDER_TYPES = [ + ...LIMIT_EXECUTION_ORDER_TYPES, + 'scale', + 'chase', +] as const satisfies readonly OrderType[]; + +/** + * Check whether an order type is a trigger placement (stop / take profit). + * + * @param orderType - Order type to check. + * @returns True when the type requires `OrderParams.triggerPrice`. + */ +export function isTriggerOrderType( + orderType: OrderType, +): orderType is TriggerOrderType { + return (TRIGGER_ORDER_TYPES as readonly OrderType[]).includes(orderType); +} + +/** + * Check whether an order type is a strategy placement (TWAP / scale / chase). + * + * @param orderType - Order type to check. + * @returns True when the placement expands into an execution schedule rather + * than a single order. + */ +export function isStrategyOrderType( + orderType: OrderType, +): orderType is StrategyOrderType { + return (STRATEGY_ORDER_TYPES as readonly OrderType[]).includes(orderType); +} + +/** + * Check whether an order type executes as a limit order. + * + * Covers plain limit orders and the `*_limit` trigger types, both of which + * require `OrderParams.price`. + * + * @param orderType - Order type to check. + * @returns True when the order executes as a limit order. + */ +export function isLimitExecutionOrderType(orderType: OrderType): boolean { + return (LIMIT_EXECUTION_ORDER_TYPES as readonly OrderType[]).includes( + orderType, + ); +} + +/** + * Get how an order executes, ignoring whether it is trigger-gated. + * + * This is also the coarse execution type that consumers predating trigger orders + * understand (fee tiers, max order value, analytics). It answers "does this rest + * on the book or cross it", which is not the same question as + * `isLimitExecutionOrderType` — a scale ladder and a chase rest limit orders + * without carrying an `OrderParams.price`. + * + * @param orderType - Order type to inspect. + * @returns `'limit'` for limit, `*_limit`, `scale` and `chase`; `'market'` + * otherwise, including `twap`, whose suborders cross the book. + */ +export function getTriggerExecution(orderType: OrderType): OrderExecution { + return (LIMIT_RESTING_ORDER_TYPES as readonly OrderType[]).includes(orderType) + ? 'limit' + : 'market'; +} + +/** + * Get the direction a trigger order fires in. + * + * @param orderType - Trigger order type. + * @returns `'stop'` for `stop_*`, `'take_profit'` for `take_profit_*`. + */ +export function getTriggerDirection( + orderType: TriggerOrderType, +): TriggerDirection { + return orderType === 'stop_market' || orderType === 'stop_limit' + ? 'stop' + : 'take_profit'; +} + +/** + * Recover which way a trigger fires from its price relative to the entry. + * + * Used when the exchange reports a trigger without naming its placement type: + * a long takes profit above its entry and stops out below, a short the other + * way round. Shared by both transports so they classify identically. + * + * @param params - Classification parameters + * @param params.triggerPrice - Price at which the order activates + * @param params.entryPrice - Entry price of the position it is attached to + * @param params.positionSize - Signed position size; its sign gives the side + * @returns The direction, or undefined when there is nothing to compare against + */ +export function classifyTriggerDirection(params: { + triggerPrice?: string; + entryPrice?: string; + positionSize: string; +}): TriggerDirection | undefined { + const { triggerPrice, entryPrice, positionSize } = params; + + const trigger = parseFloat(triggerPrice ?? ''); + const entry = parseFloat(entryPrice ?? ''); + const signedSize = parseFloat(positionSize || '0'); + + if (!Number.isFinite(trigger) || !Number.isFinite(entry) || entry <= 0) { + return undefined; + } + + // A long takes profit above its entry and stops out below; a short is the + // mirror image. A trigger sitting exactly at entry is neither, so both sides + // fall to 'stop' — matching the legacy price fallback the scalar + // takeProfitPrice/stopLossPrice fields still use. Splitting that tie the + // other way would file the order under takeProfitOrders while the scalar + // still reported it as a stop. + const isLong = signedSize > 0; + + if (isLong) { + return trigger > entry ? 'take_profit' : 'stop'; + } + return trigger < entry ? 'take_profit' : 'stop'; +} + +/** + * Project a normalized open order onto the position-state view of a trigger order. + * + * Returns undefined when the order is not a trigger, or when its direction can + * be established neither from a named placement type nor from its price. + * + * @param params - Mapping parameters + * @param params.order - Normalized open order + * @param params.positionSize - Size of the position the trigger is attached to + * @param params.entryPrice - Entry price, used to classify an unnamed trigger + * @returns The position trigger order, or undefined + */ +export function buildPositionTriggerOrderFromOrder(params: { + order: Order; + positionSize: string; + entryPrice?: string; +}): PositionTriggerOrder | undefined { + const { order, positionSize, entryPrice } = params; + + if (!order.isTrigger) { + return undefined; + } + + // HyperLiquid sometimes reports a bare 'Trigger', naming neither direction + // nor execution. The direction is still recoverable from the trigger price + // against the entry, and it is what decides which array the order belongs + // to — so an unnamed trigger is kept rather than dropped, with its execution + // mode left unstated. Without a position to compare against there is nothing + // to recover, and it is dropped. + const direction = + order.triggerOrderType === undefined + ? classifyTriggerDirection({ + triggerPrice: order.triggerPrice ?? order.price, + entryPrice, + positionSize, + }) + : getTriggerDirection(order.triggerOrderType); + + if (!direction) { + return undefined; + } + + const absolutePositionSize = Math.abs(parseFloat(positionSize || '0')); + const rawSize = Math.abs(parseFloat(order.size || '0')); + + // A position-bound TP/SL covers whatever the position currently is. The + // exchange encodes that as size 0, but `adaptOrderFromSDK` has already + // resolved it against the position as it stood when the order was adapted, + // so the size carried here goes stale as soon as the position is resized. + // The flag is the durable statement of what the trigger covers; the number + // is not. Reading the number instead would report the old size, and would + // call the order partial whenever the position had since grown. + const isPositionBound = order.isPositionTpsl === true; + const size = + isPositionBound || rawSize === 0 ? absolutePositionSize : rawSize; + + return { + orderId: order.orderId, + direction, + orderType: order.triggerOrderType, + triggerPrice: order.triggerPrice ?? order.price, + size: size.toString(), + isPartial: + !isPositionBound && + rawSize > 0 && + absolutePositionSize > 0 && + rawSize < absolutePositionSize, + reduceOnly: Boolean(order.reduceOnly), + }; +} + +/** + * Resolve the scalar TP/SL summary price a position reports for one direction. + * + * The scalar fields are only ever scanned from position-bound triggers, so a + * position whose only take profit (or stop loss) is quantity-scoped reported a + * count of 1 with no price — and a client that renders the scalar showed + * nothing. When the direction has exactly one trigger order, that order is the + * price, whether or not it is position-bound. + * + * Two or more triggers keep the scanned value: no single price describes them, + * and clients render the count instead. Zero triggers keep it too, because it + * still carries the TP/SL of a *pending* order on the market, which the arrays + * deliberately exclude. + * + * @param params - Resolution parameters + * @param params.triggerOrders - Trigger orders attached to the position for one direction + * @param params.scannedPrice - Price scanned from position-bound triggers, if any + * @returns The price to report, or undefined when there is none + */ +export function resolvePositionTriggerSummaryPrice(params: { + triggerOrders: PositionTriggerOrder[]; + scannedPrice?: string; +}): string | undefined { + const { triggerOrders, scannedPrice } = params; + + if (triggerOrders.length === 1) { + return triggerOrders[0].triggerPrice; + } + + return scannedPrice; +} + +/** + * Build a trigger order type from its two independent dimensions. + * + * @param params - Trigger dimensions. + * @param params.direction - Whether the trigger is a stop or a take profit. + * @param params.execution - How the order executes once triggered. + * @returns The matching trigger order type. + */ +export function buildTriggerOrderType(params: { + direction: TriggerDirection; + execution: OrderExecution; +}): TriggerOrderType { + const { direction, execution } = params; + + if (direction === 'stop') { + return execution === 'limit' ? 'stop_limit' : 'stop_market'; + } + + return execution === 'limit' ? 'take_profit_limit' : 'take_profit_market'; +} + +/** + * Map the controller's time in force onto the exchange's spelling. + * + * Shared by the two order-building paths so they cannot drift apart. + * + * @param timeInForce - Requested time in force; defaults to GTC. + * @returns The SDK time-in-force value. + */ +export function toSDKTimeInForce( + timeInForce?: 'GTC' | 'IOC' | 'ALO', +): 'Gtc' | 'Ioc' | 'Alo' { + switch (timeInForce) { + case 'IOC': + return 'Ioc'; + case 'ALO': + return 'Alo'; + default: + return 'Gtc'; + } +} + +/** + * Hash the identity of a position's trigger orders for change detection. + * + * Streamed positions only re-emit when their hash changes, so this has to move + * when a trigger is added, removed, repriced, resized, or retyped — otherwise + * subscribers never receive the updated arrays. + * + * The placement type is part of the identity because a trigger can be modified + * in place: switching a stop from market to limit execution keeps its order ID, + * trigger price, and size, so nothing else here would move even though the + * execution semantics subscribers rely on have changed. + * + * @param orders - Trigger orders attached to a position, if any. + * @returns A stable string; `'0'` for both empty and absent. + */ +export function hashTriggerOrders(orders?: PositionTriggerOrder[]): string { + if (!orders || orders.length === 0) { + return '0'; + } + return orders + .map( + (order) => + `${order.orderId}:${order.direction}:${order.orderType ?? '?'}@${order.triggerPrice}x${order.size}${order.isPartial ? 'p' : ''}`, + ) + .join(','); +} diff --git a/packages/perps-controller/src/utils/perpsConnectionAttemptContext.ts b/packages/perps-controller/src/utils/perpsConnectionAttemptContext.ts new file mode 100644 index 00000000000..6e96042223a --- /dev/null +++ b/packages/perps-controller/src/utils/perpsConnectionAttemptContext.ts @@ -0,0 +1,24 @@ +export type PerpsConnectionAttemptContext = { + source: string; + suppressError: boolean; +}; + +let currentAttemptContext: PerpsConnectionAttemptContext | null = null; + +export function getPerpsConnectionAttemptContext(): PerpsConnectionAttemptContext | null { + return currentAttemptContext; +} + +export async function withPerpsConnectionAttemptContext( + context: PerpsConnectionAttemptContext, + callback: () => Promise, +): Promise { + const previousContext = currentAttemptContext; + currentAttemptContext = context; + + try { + return await callback(); + } finally { + currentAttemptContext = previousContext; + } +} diff --git a/packages/perps-controller/src/utils/perpsDiskPersistence.ts b/packages/perps-controller/src/utils/perpsDiskPersistence.ts new file mode 100644 index 00000000000..35b10f3de2b --- /dev/null +++ b/packages/perps-controller/src/utils/perpsDiskPersistence.ts @@ -0,0 +1,378 @@ +import { + buildProviderCacheKey, + PERPS_CONSTANTS, + PERPS_DISK_CACHE_MARKETS, + PERPS_DISK_CACHE_USER_DATA, + PROVIDER_CONFIG, +} from '../constants/perpsConfig.js'; +import type { + AccountState, + Order, + PerpsMarketData, + Position, +} from '../types/index.js'; + +/** + * Multiplier applied to staleGuardMs (preloadGuardMs, currently 30s) to compute + * the staleness cap for disk-hydrated timestamps. A factor of 10 ensures hydrated + * data is always TTL-expired so the stream manager overwrites it with live data, + * while still being recent enough for a useful first paint. + */ +const DISK_HYDRATION_STALENESS_FACTOR = 10; + +/** Minimal disk cache interface required by persistence utilities. */ +export type PerpsDiskCache = { + getItem(key: string): Promise; + getItemSync?(key: string): string | null; + setItem(key: string, value: string): Promise; +}; + +/** Shape of a single market entry persisted to disk cache. */ +export type DiskCacheMarketEntry = { + providerNetworkKey: string; + data: PerpsMarketData[]; + timestamp: number; +}; + +/** Shape of a single user-data entry persisted to disk cache. */ +export type DiskCacheUserEntry = { + providerNetworkKey: string; + address: string; + positions: Position[]; + orders: Order[]; + accountState: AccountState | null; + timestamp: number; + hip3ConfigVersion?: number; + dexes?: string[]; +}; + +/** Disk payload shape — either a single entry or a multi-provider wrapper. */ +export type DiskCacheMarketPayload = + | DiskCacheMarketEntry + | { entries: DiskCacheMarketEntry[] }; + +export type DiskCacheUserPayload = + | DiskCacheUserEntry + | { entries: DiskCacheUserEntry[] }; + +/** + * Build the disk-cache payload for market data. + * In aggregated mode, groups markets by provider into separate entries. + * + * @param markets - Current market data snapshot. + * @param activeProvider - The active provider id (may be "aggregated"). + * @param isTestnet - Global testnet flag. + * @param now - Timestamp to stamp entries with. + * @returns Payload ready for JSON serialization. + */ +export function buildMarketDataPayload( + markets: PerpsMarketData[], + activeProvider: string, + isTestnet: boolean, + now: number, +): DiskCacheMarketPayload { + if (activeProvider === 'aggregated') { + const entriesByKey = new Map(); + for (const market of markets) { + const providerId = market.providerId ?? PROVIDER_CONFIG.DefaultProvider; + const key = buildProviderCacheKey(providerId, isTestnet); + const existing = entriesByKey.get(key); + if (existing) { + existing.data.push(market); + } else { + entriesByKey.set(key, { + providerNetworkKey: key, + data: [market], + timestamp: now, + }); + } + } + const entries = Array.from(entriesByKey.values()); + return entries.length === 1 ? entries[0] : { entries }; + } + return { + providerNetworkKey: buildProviderCacheKey( + activeProvider ?? PROVIDER_CONFIG.DefaultProvider, + isTestnet, + ), + data: markets, + timestamp: now, + }; +} + +/** + * Build the disk-cache payload for user data (positions, orders, account). + * In aggregated mode, groups entries by provider. + * + * @param positions - Current positions snapshot. + * @param orders - Current orders snapshot. + * @param accountState - Current account state snapshot. + * @param address - EVM account address. + * @param activeProvider - The active provider id (may be "aggregated"). + * @param isTestnet - Global testnet flag. + * @param now - Timestamp to stamp entries with. + * @returns Payload ready for JSON serialization. + */ +export function buildUserDataPayload( + positions: Position[], + orders: Order[], + accountState: AccountState | null, + address: string, + activeProvider: string, + isTestnet: boolean, + now: number, +): DiskCacheUserPayload { + if (activeProvider === 'aggregated') { + const entriesByKey = new Map(); + const ensureEntry = (providerId: string): DiskCacheUserEntry => { + const key = buildProviderCacheKey(providerId, isTestnet); + let entry = entriesByKey.get(key); + if (!entry) { + entry = { + providerNetworkKey: key, + address, + positions: [], + orders: [], + accountState: null, + timestamp: now, + }; + entriesByKey.set(key, entry); + } + return entry; + }; + + for (const position of positions) { + ensureEntry( + position.providerId ?? PROVIDER_CONFIG.DefaultProvider, + ).positions.push(position); + } + for (const order of orders) { + ensureEntry( + order.providerId ?? PROVIDER_CONFIG.DefaultProvider, + ).orders.push(order); + } + if (accountState) { + ensureEntry( + accountState.providerId ?? PROVIDER_CONFIG.DefaultProvider, + ).accountState = accountState; + } + + const entries = Array.from(entriesByKey.values()).filter( + (entry) => + entry.positions.length > 0 || + entry.orders.length > 0 || + entry.accountState !== null, + ); + return entries.length === 1 ? entries[0] : { entries }; + } + return { + providerNetworkKey: buildProviderCacheKey( + activeProvider ?? PROVIDER_CONFIG.DefaultProvider, + isTestnet, + ), + address, + positions, + orders, + accountState, + timestamp: now, + }; +} + +/** + * Write market entries to disk (best-effort, non-blocking). + * + * @param diskCache - Disk cache instance from controller infrastructure. + * @param entries - Pre-assembled market cache entries to persist. + */ +export function persistMarketEntriesToDisk( + diskCache: PerpsDiskCache, + entries: DiskCacheMarketEntry[], +): void { + if (entries.length === 0) { + return; + } + const payload = entries.length === 1 ? entries[0] : { entries }; + diskCache + .setItem(PERPS_DISK_CACHE_MARKETS, JSON.stringify(payload)) + .catch(() => { + // Disk persistence is best-effort and must never block preload. + }); +} + +/** + * Write user data entries to disk (best-effort, non-blocking). + * + * @param diskCache - Disk cache instance from controller infrastructure. + * @param entries - Pre-assembled user cache entries to persist. + */ +export async function persistUserEntriesToDisk( + diskCache: PerpsDiskCache, + entries: DiskCacheUserEntry[], +): Promise { + if (entries.length === 0) { + return; + } + const payload = entries.length === 1 ? entries[0] : { entries }; + await diskCache.setItem(PERPS_DISK_CACHE_USER_DATA, JSON.stringify(payload)); +} + +/** Computed updates returned by hydrateFromDiskSync. */ +export type HydrateFromDiskResult = { + marketUpdates: Record; + userUpdates: Record< + string, + { + positions: Position[]; + orders: Order[]; + accountState: AccountState | null; + timestamp: number; + address: string; + hip3ConfigVersion?: number; + dexes?: string[]; + } + >; + stats: { + marketCount: number; + userPositions: number; + userOrders: number; + durationMs: number; + }; +}; + +/** + * Read disk-persisted cache snapshots and compute the state updates to apply. + * Returns plain objects rather than mutating state directly, so the caller + * can apply all changes in a single batched this.update() call. + * + * All returned timestamps are capped at DISK_HYDRATION_STALENESS_FACTOR * staleGuardMs + * in the past so the stream manager always overwrites disk data with fresh live data. + * + * @param diskCache - Disk cache instance from controller infrastructure. + * @param currentMarketCache - Current cachedMarketDataByProvider state. + * @param currentUserCache - Current cachedUserDataByProvider state. + * @param staleGuardMs - preloadGuardMs constant from the controller. + * @returns Updates to apply plus stats for debug logging. + */ +export function hydrateFromDiskSync( + diskCache: PerpsDiskCache, + currentMarketCache: Record, + currentUserCache: Record, + staleGuardMs: number, +): HydrateFromDiskResult { + const hydrateT0 = Date.now(); + const marketUpdates: HydrateFromDiskResult['marketUpdates'] = {}; + const userUpdates: HydrateFromDiskResult['userUpdates'] = {}; + let marketCount = 0; + let userPositions = 0; + let userOrders = 0; + + if (!diskCache.getItemSync) { + return { + marketUpdates, + userUpdates, + stats: { marketCount, userPositions, userOrders, durationMs: 0 }, + }; + } + + const staleHydratedTimestamp = + Date.now() - staleGuardMs * DISK_HYDRATION_STALENESS_FACTOR - 1; + + try { + const marketsRaw = diskCache.getItemSync(PERPS_DISK_CACHE_MARKETS); + const userRaw = diskCache.getItemSync(PERPS_DISK_CACHE_USER_DATA); + + if (marketsRaw) { + try { + const parsed = JSON.parse(marketsRaw) as + | DiskCacheMarketEntry + | { entries: DiskCacheMarketEntry[] }; + const entries = Array.isArray((parsed as { entries?: unknown }).entries) + ? (parsed as { entries: DiskCacheMarketEntry[] }).entries + : [parsed as DiskCacheMarketEntry]; + + for (const entry of entries) { + if (entry.providerNetworkKey && Array.isArray(entry.data)) { + const existing = currentMarketCache[entry.providerNetworkKey]; + if (!existing || existing.timestamp < entry.timestamp) { + const strippedData = entry.data.map((market) => { + const structuralMarket = { ...market }; + if ( + structuralMarket.dataSource === + 'terminal-global-snapshot-mark' + ) { + delete structuralMarket.trend; + } + delete structuralMarket.dataSource; + delete structuralMarket.sourceExpiresAt; + return { + ...structuralMarket, + price: PERPS_CONSTANTS.FallbackPriceDisplay, + change24h: PERPS_CONSTANTS.FallbackDataDisplay, + change24hPercent: PERPS_CONSTANTS.FallbackPercentageDisplay, + }; + }); + marketUpdates[entry.providerNetworkKey] = { + data: strippedData, + // Disk-hydrated market snapshots are only for structural + // first paint. Keep them TTL-stale so the stream manager + // still fetches fresh prices on connect. + timestamp: Math.min(entry.timestamp, staleHydratedTimestamp), + }; + marketCount += strippedData.length; + } + } + } + } catch { + // Corrupt JSON — silently ignore + } + } + + if (userRaw) { + try { + const parsed = JSON.parse(userRaw) as + | DiskCacheUserEntry + | { entries: DiskCacheUserEntry[] }; + const entries = Array.isArray((parsed as { entries?: unknown }).entries) + ? (parsed as { entries: DiskCacheUserEntry[] }).entries + : [parsed as DiskCacheUserEntry]; + + for (const entry of entries) { + if (entry.providerNetworkKey && entry.address) { + // Skip address check here — accounts may not be loaded yet at + // constructor time. getCachedUserDataForActiveProvider validates + // the address at read time, so stale-account data is never served. + const existing = currentUserCache[entry.providerNetworkKey]; + if (!existing || existing.timestamp < entry.timestamp) { + userUpdates[entry.providerNetworkKey] = { + positions: entry.positions, + orders: entry.orders, + accountState: entry.accountState, + timestamp: Math.min(entry.timestamp, staleHydratedTimestamp), + address: entry.address, + hip3ConfigVersion: entry.hip3ConfigVersion, + dexes: entry.dexes, + }; + userPositions += entry.positions.length; + userOrders += entry.orders.length; + } + } + } + } catch { + // Corrupt JSON — silently ignore + } + } + } catch { + // Disk read failure — non-critical + } + + return { + marketUpdates, + userUpdates, + stats: { + marketCount, + userPositions, + userOrders, + durationMs: Date.now() - hydrateT0, + }, + }; +} diff --git a/packages/perps-controller/src/utils/perpsFormatters.ts b/packages/perps-controller/src/utils/perpsFormatters.ts new file mode 100644 index 00000000000..dbd9e261abf --- /dev/null +++ b/packages/perps-controller/src/utils/perpsFormatters.ts @@ -0,0 +1,638 @@ +/** + * Portable perps decimal formatters. + * + * These are the canonical implementations, exported from the controller so + * extension and any future consumer can import them directly. + * No mobile-specific imports — safe to sync to Core. + * + * Intl.NumberFormat instances are cached in a module-level Map keyed by + * serialized options, avoiding repeated construction costs. + */ +import { + DECIMAL_PRECISION_CONFIG, + FUNDING_RATE_CONFIG, + PERPS_CONSTANTS, +} from '../constants/perpsConfig.js'; + +// Module-level Intl.NumberFormat cache (keyed by serialized options). +const _fmtCache = new Map(); + +function _formatCurrency( + value: number, + currency: string, + opts: { minimumFractionDigits: number; maximumFractionDigits: number }, +): string { + const key = `${currency}:${opts.minimumFractionDigits}:${opts.maximumFractionDigits}`; + let formatter = _fmtCache.get(key); + if (!formatter) { + formatter = new Intl.NumberFormat('en-US', { + style: 'currency', + currency, + currencyDisplay: 'narrowSymbol', + minimumFractionDigits: opts.minimumFractionDigits, + maximumFractionDigits: opts.maximumFractionDigits, + }); + _fmtCache.set(key, formatter); + } + return formatter.format(value); +} + +/** + * Internal equivalent of the mobile formatWithThreshold utility. + * Formats a currency value, returning "<$X.XX" for values below threshold. + * + * @param amount - The numeric amount to format. + * @param threshold - The threshold below which the "<" prefix is shown. + * @param options - Intl formatting options. + * @param options.currency - ISO 4217 currency code. + * @param options.minimumFractionDigits - Minimum decimal digits. + * @param options.maximumFractionDigits - Maximum decimal digits. + * @returns Formatted currency string. + */ +function _formatWithThreshold( + amount: number, + threshold: number, + options: { + currency: string; + minimumFractionDigits: number; + maximumFractionDigits: number; + }, +): string { + const formatOpts = { + minimumFractionDigits: options.minimumFractionDigits, + maximumFractionDigits: options.maximumFractionDigits, + currencyDisplay: 'narrowSymbol' as const, + }; + if (amount === 0) { + return _formatCurrency(0, options.currency, formatOpts); + } + return Math.abs(amount) < threshold + ? `<${_formatCurrency(threshold, options.currency, formatOpts)}` + : _formatCurrency(amount, options.currency, formatOpts); +} + +/** + * Price threshold constants for PRICE_RANGES_UNIVERSAL + * These define the boundaries between different formatting ranges + */ +export const PRICE_THRESHOLD = { + /** Very high values boundary (> $100k) */ + VERY_HIGH: 100_000, + /** High values boundary (> $10k) */ + HIGH: 10_000, + /** Large values boundary (> $1k) */ + LARGE: 1_000, + /** Medium values boundary (> $100) */ + MEDIUM: 100, + /** Medium-low values boundary (> $10) */ + MEDIUM_LOW: 10, + /** Low values boundary (>= $0.01) */ + LOW: 0.01, + /** + * Very small values threshold (< $0.01) + * This is the minimum value for formatWithThreshold and should align with + * the 6 decimal maximum (0.000001 is the smallest representable value) + */ + VERY_SMALL: 0.000001, +} as const; + +/** + * Configuration for a specific number range formatting + */ +export type FiatRangeConfig = { + /** + * The condition to match for this range (e.g., < 0.0001, < 1, >= 1000) + * Function should return true if this config should be applied + */ + condition: (value: number) => boolean; + /** Minimum decimal places for this range */ + minimumDecimals: number; + /** Maximum decimal places for this range */ + maximumDecimals: number; + /** Optional threshold for formatWithThreshold (defaults to the range boundary) */ + threshold?: number; + /** Optional significant digits for this range (overrides decimal places when set) */ + significantDigits?: number; + /** Optional custom formatting logic for this range */ + customFormat?: (value: number, locale: string, currency: string) => string; + /** Optional flag to strip trailing zeros for this range (overrides global stripTrailingZeros option) */ + stripTrailingZeros?: boolean; + /** + * Optional flag for fiat-style stripping (only strips .00, preserves meaningful decimals like .10, .40) + * When true, "$1,250.00" → "$1,250" but "$1,250.10" stays "$1,250.10" + * When false (default), strips all trailing zeros: "$1,250.10" → "$1,250.1" + */ + fiatStyleStripping?: boolean; +}; + +/** + * Formats a number to a specific number of significant digits + * Strips trailing zeros unless minDecimals requires them + * + * @param value - The numeric value to format + * @param significantDigits - Number of significant digits to maintain + * @param minDecimals - Minimum decimal places to show (may add zeros) + * @param maxDecimals - Maximum decimal places allowed + * @returns Formatted number with appropriate precision, trailing zeros removed + */ +export function formatWithSignificantDigits( + value: number, + significantDigits: number, + minDecimals?: number, + maxDecimals?: number, +): { value: number; decimals: number } { + // Handle special cases + if (value === 0) { + // Return zero with no trailing decimals by default (matches stripTrailingZeros behavior) + // Can be overridden by explicit minDecimals if needed + return { value: 0, decimals: minDecimals ?? 0 }; + } + + const absValue = Math.abs(value); + + // For numbers >= 1, calculate decimals based on magnitude to achieve target significant figures + // This ensures consistent precision across different price ranges: + // Examples with 4 significant figures: + // $123,456.78 → $123,456.78 (≥$1000: 2 decimals minimum, 8 sig figs) + // $456.12 → $456.12 (≥$10: 2 decimals minimum, 5 sig figs) + // $56.123 → $56.123 (≥$10: 2 decimals minimum, 5 sig figs) + // $5.123 → $5.123 ($1-$10: 3 decimals = 4 sig figs) + // $2.801 → $2.801 ($1-$10: 3 decimals = 4 sig figs) + // $1.234 → $1.234 ($1-$10: 3 decimals = 4 sig figs) + if (absValue >= 1) { + let targetDecimals: number; + + // Calculate decimals needed based on integer digits to achieve target significant figures + // For $38.388 with 5 sig figs: 2 integer digits, need 3 decimals (3,8,3,8,8) + // For $123.45 with 5 sig figs: 3 integer digits, need 2 decimals (1,2,3,4,5) + const integerDigits = Math.floor(Math.log10(absValue)) + 1; + const decimalsNeeded = significantDigits - integerDigits; + targetDecimals = Math.max(decimalsNeeded, 0); // Can't have negative decimals + + // Apply explicit minimum decimals constraint if provided (for special cases) + if (minDecimals !== undefined && targetDecimals < minDecimals) { + targetDecimals = minDecimals; + } + + // Apply maximum decimals constraint if specified + const finalDecimals = + maxDecimals === undefined + ? targetDecimals + : Math.min(targetDecimals, maxDecimals); + + // Round to prevent floating-point artifacts (e.g., 2.820000000000003 → 2.82) + const roundedValue = Number(value.toFixed(finalDecimals)); + + return { + value: roundedValue, + decimals: finalDecimals, + }; + } + + // For numbers < 1, use toPrecision to limit to significantDigits + // Examples: 0.1234, 0.01234 should show exactly 4 sig figs + const precisionStr = absValue.toPrecision(significantDigits); + const precisionNum = parseFloat(precisionStr); + + // Convert to string to count actual decimals after trailing zeros are removed + const valueStr = precisionNum.toString(); + const [, decPart = ''] = valueStr.split('.'); + let actualDecimals = decPart.length; + + // Apply min/max decimal constraints + if (minDecimals !== undefined && actualDecimals < minDecimals) { + actualDecimals = minDecimals; // Will add zeros if needed + } + if (maxDecimals !== undefined && actualDecimals > maxDecimals) { + actualDecimals = maxDecimals; + } + + // Return the value with sign restored and decimal count + return { + value: value < 0 ? -precisionNum : precisionNum, + decimals: actualDecimals, + }; +} + +/** + * Minimal view fiat range configuration + * Uses fiat-style stripping for clean currency display + * Strips only .00 to avoid partial decimals like $1,250.1 + */ +export const PRICE_RANGES_MINIMAL_VIEW: FiatRangeConfig[] = [ + { + // Large values (>= $1000): Strip .00 only ($5,000 not $5,000.00, but $5,000.10 stays) + condition: (val: number) => Math.abs(val) >= PRICE_THRESHOLD.LARGE, + minimumDecimals: 2, + maximumDecimals: 2, + threshold: PRICE_THRESHOLD.LARGE, + stripTrailingZeros: true, + fiatStyleStripping: true, + }, + { + // Small values (< $1000): Also use fiat-style stripping ($100 not $100.00, but $13.40 stays) + condition: () => true, + minimumDecimals: 2, + maximumDecimals: 2, + threshold: PRICE_THRESHOLD.LOW, + stripTrailingZeros: true, + fiatStyleStripping: true, + }, +]; + +/** + * Universal price range configuration following comprehensive rules from rules-decimals.md + * + * Rules: + * - Max 6 decimals across all ranges (Hyperliquid limit) + * - Strip trailing zeros by default + * - Use |v| (absolute value) for conditions + * + * Significant digits by range: + * - > $100,000: 6 sig digs + * - $100,000 > x > $0.01: 5 sig digs + * - < $0.01: 4 sig digs + * + * Decimal limits by price range: + * - |v| > 10,000: min 0, max 0 decimals; 5 sig digs (6 if >100k) + * - |v| > 1,000: min 0, max 1 decimal; 5 sig digs + * - |v| > 100: min 0, max 2 decimals; 5 sig digs + * - |v| > 10: min 0, max 4 decimals; 5 sig digs + * - |v| ≥ 0.01: 5 sig digs, min 2, max 6 decimals + * - |v| < 0.01: 4 sig digs, min 2, max 6 decimals + * + * Examples: + * - $123,456.78 → $123,457 (>$10k: 0 decimals, 6 sig figs) + * - $12,345.67 → $12,346 (>$10k: 0 decimals, 5 sig figs) + * - $1,234.56 → $1,234.6 ($1k-$10k: 1 decimal, 5 sig figs) + * - $123.456 → $123.46 ($100-$1k: 2 decimals, 5 sig figs) + * - $12.34567 → $12.346 ($10-$100: 4 decimals, 5 sig figs) + * - $1.3445555 → $1.3446 (≥$0.01: 5 sig figs) + * - $0.333333 → $0.33333 (≥$0.01: 5 sig figs) + * - $0.004236 → $0.004236 (<$0.01: 4 sig figs, max 6 decimals) + * - $0.0000006 → $0.000001 (<$0.01: 4 sig figs, rounds with max 6 decimals) + */ +export const PRICE_RANGES_UNIVERSAL: FiatRangeConfig[] = [ + { + // Very high values (> $100,000): No decimals, 6 significant figures + // Ex: $123,456.78 → $123,457 + condition: (val) => Math.abs(val) > PRICE_THRESHOLD.VERY_HIGH, + minimumDecimals: 0, + maximumDecimals: 0, + significantDigits: 6, + threshold: PRICE_THRESHOLD.VERY_HIGH, + }, + { + // High values ($10,000-$100,000]: No decimals, 5 significant figures + // Ex: $12,345.67 → $12,346 + condition: (val) => Math.abs(val) > PRICE_THRESHOLD.HIGH, + minimumDecimals: 0, + maximumDecimals: 0, + significantDigits: 5, + threshold: PRICE_THRESHOLD.HIGH, + }, + { + // Large values ($1,000-$10,000]: Max 1 decimal, 5 significant figures + // Ex: $1,234.56 → $1,234.6 + condition: (val) => Math.abs(val) > PRICE_THRESHOLD.LARGE, + minimumDecimals: 0, + maximumDecimals: 1, + significantDigits: 5, + threshold: PRICE_THRESHOLD.LARGE, + }, + { + // Medium values ($100-$1,000]: Max 2 decimals, 5 significant figures + // Ex: $123.456 → $123.46 + condition: (val) => Math.abs(val) > PRICE_THRESHOLD.MEDIUM, + minimumDecimals: 0, + maximumDecimals: 2, + significantDigits: 5, + threshold: PRICE_THRESHOLD.MEDIUM, + }, + { + // Medium-low values ($10-$100]: Max 4 decimals, 5 significant figures + // Ex: $12.34567 → $12.346 + condition: (val) => Math.abs(val) > PRICE_THRESHOLD.MEDIUM_LOW, + minimumDecimals: 0, + maximumDecimals: 4, + significantDigits: 5, + threshold: PRICE_THRESHOLD.MEDIUM_LOW, + }, + { + // Low values ($0.01-$10]: 5 significant figures, min 2 max MAX_PRICE_DECIMALS decimals + // Ex: $1.3445555 → $1.3446 | $0.333333 → $0.33333 + condition: (val) => Math.abs(val) >= PRICE_THRESHOLD.LOW, + significantDigits: 5, + minimumDecimals: 2, + maximumDecimals: DECIMAL_PRECISION_CONFIG.MaxPriceDecimals, + threshold: PRICE_THRESHOLD.LOW, + }, + { + // Very small values (< $0.01): 4 significant figures, min 2 max MAX_PRICE_DECIMALS decimals + // Ex: $0.004236 → $0.004236 | $0.0000006 → $0.000001 + condition: () => true, + significantDigits: 4, + minimumDecimals: 2, + maximumDecimals: DECIMAL_PRECISION_CONFIG.MaxPriceDecimals, + threshold: PRICE_THRESHOLD.VERY_SMALL, + }, +]; + +/** + * Formats a balance value as USD currency with appropriate decimal places + * + * @param balance - Raw numeric balance value (e.g., 1234.56, not token minimal denomination) + * @param options - Optional formatting options + * @param options.minimumDecimals - Global minimum decimal places (overrides range configs) + * @param options.maximumDecimals - Global maximum decimal places (overrides range configs) + * @param options.significantDigits - Global significant digits (overrides decimal settings when set) + * @param options.ranges - Custom range configurations (defaults to PRICE_RANGES_MINIMAL_VIEW) + * @param options.currency - Currency code (default: 'USD') + * @param options.locale - Locale for formatting (default: 'en-US') + * @param options.stripTrailingZeros - Strip trailing zeros from output (default: false via PRICE_RANGES_MINIMAL_VIEW). When true, overrides minimumDecimals constraint. + * @returns Formatted currency string with variable decimals based on configured ranges + * @example + * // Using defaults (preserves trailing zeros for fiat) + * formatPerpsFiat(1234.56) => "$1,234.56" + * formatPerpsFiat(1250.00) => "$1,250.00" // Trailing zeros preserved + * formatPerpsFiat(50000) => "$50,000.00" // Trailing zeros preserved + * + * // Stripping trailing zeros when needed (e.g., for crypto) + * formatPerpsFiat(1250, { stripTrailingZeros: true }) => "$1,250" + * + * // With custom ranges + * formatPerpsFiat(0.00001, { + * ranges: [ + * { condition: (v) => v < 0.001, minimumDecimals: 6, maximumDecimals: 8 }, + * { condition: () => true, minimumDecimals: 2, maximumDecimals: 2 } + * ] + * }) => "$0.00001" // Trailing zero stripped + * + * // With significant digits + * formatPerpsFiat(1234.56789, { significantDigits: 5 }) => "$1,234.6" + * formatPerpsFiat(0.0001234, { significantDigits: 3 }) => "$0.000123" + */ +export const formatPerpsFiat = ( + balance: string | number, + options?: { + minimumDecimals?: number; + maximumDecimals?: number; + significantDigits?: number; + ranges?: FiatRangeConfig[]; + currency?: string; + locale?: string; + stripTrailingZeros?: boolean; + }, +): string => { + const value = typeof balance === 'string' ? parseFloat(balance) : balance; + const currency = options?.currency ?? 'USD'; + + let formatted: string; + + if (isNaN(value)) { + // Return placeholder for invalid values to avoid confusion with actual $0 values + return PERPS_CONSTANTS.FallbackPriceDisplay; + } + + // Use custom ranges or defaults + const ranges = options?.ranges ?? PRICE_RANGES_MINIMAL_VIEW; + + // Find the first matching range configuration + const rangeConfig = ranges.find((range) => range.condition(value)); + + if (rangeConfig) { + // Check for significant digits (global or range-specific) + const sigDigits = + options?.significantDigits ?? rangeConfig.significantDigits; + + // If significant digits are specified, use them + if (sigDigits) { + // Get min/max decimals (global overrides range, range overrides default) + const minDecimals = + options?.minimumDecimals ?? rangeConfig.minimumDecimals; + const maxDecimals = + options?.maximumDecimals ?? rangeConfig.maximumDecimals; + + // Calculate appropriate formatting based on significant digits + const { value: formattedValue, decimals } = formatWithSignificantDigits( + value, + sigDigits, + minDecimals, + maxDecimals, + ); + + // Format with the calculated decimal places + formatted = _formatWithThreshold( + formattedValue, + rangeConfig.threshold ?? 0.01, + { + currency, + minimumFractionDigits: decimals, + maximumFractionDigits: decimals, + }, + ); + } else { + // Standard decimal-based formatting (existing logic) + const minDecimals = + options?.minimumDecimals ?? rangeConfig.minimumDecimals; + const maxDecimals = + options?.maximumDecimals ?? rangeConfig.maximumDecimals; + + // Use custom formatting if provided + if (rangeConfig.customFormat) { + formatted = rangeConfig.customFormat( + value, + options?.locale ?? 'en-US', + currency, + ); + } else { + // Use standard formatting with threshold + formatted = _formatWithThreshold(value, rangeConfig.threshold ?? 0.01, { + currency, + minimumFractionDigits: minDecimals, + maximumFractionDigits: maxDecimals, + }); + } + } + } else { + // Fallback if no range matches (shouldn't happen with proper default config) + const fallbackMin = options?.minimumDecimals ?? 2; + const fallbackMax = options?.maximumDecimals ?? 2; + formatted = _formatWithThreshold(value, 0.01, { + currency, + minimumFractionDigits: fallbackMin, + maximumFractionDigits: fallbackMax, + }); + } + + // Post-process: strip trailing zeros unless explicitly disabled + // Priority: explicit options.stripTrailingZeros false > rangeConfig > options default > true + // If options.stripTrailingZeros is explicitly false, skip stripping entirely + if (options?.stripTrailingZeros === false) { + return formatted; + } + + // Otherwise check range config or default to true + const shouldStrip = + rangeConfig?.stripTrailingZeros ?? options?.stripTrailingZeros ?? true; + + if (shouldStrip) { + // Check if fiat-style stripping is enabled (only strips .00) + const useFiatStyle = rangeConfig?.fiatStyleStripping ?? false; + + if (useFiatStyle) { + // Fiat-style: Only strip .00 (no meaningful decimals), preserve 2-decimal format + // Examples: $1,250.00 → $1,250 | $1,000.10 → $1,000.10 | $13.40 → $13.40 + return formatted.replace(/\.00$/u, ''); + } + // Standard: Strip all trailing zeros after decimal point + // Examples: $1,250.00 → $1,250 | $100.0 → $100 | $10.5 → $10.5 | $1.234 → $1.234 + return formatted.replace(/(\.\d*?)0+$/u, '$1').replace(/\.$/u, ''); + } + + return formatted; +}; + +/** + * Formats position size with variable decimal precision based on magnitude or asset-specific decimals + * Removes trailing zeros to match task requirements + * + * @param size - Raw position size value + * @param szDecimals - Optional asset-specific decimal precision from Hyperliquid metadata (e.g., BTC=5, ETH=4, DOGE=1) + * @returns Format varies by size or uses asset-specific decimals, with trailing zeros removed: + * If szDecimals provided: Uses exact decimals (e.g., 0.00009 BTC with szDecimals=5 => "0.00009") + * Otherwise falls back to magnitude-based logic: + * - Size < 0.01: Up to 6 decimals (e.g., "0.00009" not "0.000090") + * - Size < 1: Up to 4 decimals (e.g., "0.0024" not "0.002400") + * - Size >= 1: Up to 2 decimals (e.g., "44" not "44.00") + * @example formatPositionSize(0.00009, 5) => "0.00009" (uses szDecimals) + * @example formatPositionSize(44.00, 1) => "44" (uses szDecimals, trailing zeros removed) + * @example formatPositionSize(0.0024) => "0.0024" (no szDecimals, uses magnitude logic) + * @example formatPositionSize(44.00) => "44" (no szDecimals, uses magnitude logic) + */ +export const formatPositionSize = ( + size: string | number, + szDecimals?: number, +): string => { + const value = typeof size === 'string' ? parseFloat(size) : size; + + if (isNaN(value) || value === 0) { + return '0'; + } + + // Use asset-specific decimals if provided (Hyperliquid metadata) + if (szDecimals !== undefined) { + const fixed = value.toFixed(szDecimals); + // Only strip trailing zeros when a decimal point is present; toFixed(0) + // returns an integer string and the regex would otherwise eat valid zeros + // on whole-unit assets (szDecimals=0), e.g. "100" -> "1". + return fixed.includes('.') ? fixed.replace(/\.?0+$/u, '') : fixed; + } + + // Fallback: magnitude-based decimal logic for backwards compatibility + const abs = Math.abs(value); + let formatted: string; + + if (abs < 0.01) { + // For very small numbers, use more decimal places + formatted = value.toFixed(6); + } else if (abs < 1) { + // For small numbers, use 4 decimal places + formatted = value.toFixed(4); + } else { + // For normal numbers, use 2 decimal places + formatted = value.toFixed(2); + } + + // Remove trailing zeros and unnecessary decimal point + return formatted.replace(/\.?0+$/u, ''); +}; + +/** + * Formats a PnL (Profit and Loss) value with sign prefix + * + * @param pnl - Raw numeric PnL value (positive for profit, negative for loss) + * @returns Format: "+$X,XXX.XX" or "-$X,XXX.XX" (always shows sign, 2 decimals) + * @example formatPnl(1234.56) => "+$1,234.56" + * @example formatPnl(-500) => "-$500.00" + * @example formatPnl(0) => "+$0.00" + */ +export const formatPnl = (pnl: string | number): string => { + const value = typeof pnl === 'string' ? parseFloat(pnl) : pnl; + + if (isNaN(value)) { + return PERPS_CONSTANTS.ZeroAmountDetailedDisplay; + } + + const formatted = _formatCurrency(Math.abs(value), 'USD', { + minimumFractionDigits: 2, + maximumFractionDigits: 2, + }); + + return value >= 0 ? `+${formatted}` : `-${formatted}`; +}; + +/** + * Formats a percentage value with sign prefix + * + * @param value - Raw percentage value (e.g., 5.25 for 5.25%, not 0.0525) + * @param decimals - Number of decimal places to show (default: 2) + * @returns Format: "+X.XX%" or "-X.XX%" (always shows sign, 2 decimals) + * @example formatPercentage(5.25) => "+5.25%" + * @example formatPercentage(-2.75) => "-2.75%" + * @example formatPercentage(0) => "+0.00%" + */ +export const formatPercentage = ( + value: string | number, + decimals: number = 2, +): string => { + const parsed = typeof value === 'string' ? parseFloat(value) : value; + + if (isNaN(parsed)) { + return '0.00%'; + } + + return `${parsed >= 0 ? '+' : ''}${parsed.toFixed(decimals)}%`; +}; + +/** + * Formats funding rate for display + * + * @param value - Raw funding rate value (decimal, not percentage) + * @param options - Optional formatting options + * @param options.showZero - Whether to return zero display value for zero/undefined (default: true) + * @returns Formatted funding rate as percentage string + * @example formatFundingRate(0.0005) => "0.0500%" + * @example formatFundingRate(-0.0001) => "-0.0100%" + * @example formatFundingRate(0.000000059) => "<0.0001%" + * @example formatFundingRate(-0.000000059) => "-<0.0001%" + * @example formatFundingRate(undefined) => "0.0000%" + */ +export const formatFundingRate = ( + value?: number | null, + options?: { showZero?: boolean }, +): string => { + const showZero = options?.showZero ?? true; + + if (value === undefined || value === null) { + return showZero ? FUNDING_RATE_CONFIG.ZeroDisplay : ''; + } + + const percentage = value * FUNDING_RATE_CONFIG.PercentageMultiplier; + const formatted = percentage.toFixed(FUNDING_RATE_CONFIG.Decimals); + const minimumDisplayPercentage = 10 ** -FUNDING_RATE_CONFIG.Decimals; + + if (value !== 0 && parseFloat(formatted) === 0) { + const threshold = minimumDisplayPercentage.toFixed( + FUNDING_RATE_CONFIG.Decimals, + ); + return value > 0 ? `<${threshold}%` : `-<${threshold}%`; + } + + // Check if the result is effectively zero + if (showZero && parseFloat(formatted) === 0) { + return FUNDING_RATE_CONFIG.ZeroDisplay; + } + + return `${formatted}%`; +}; diff --git a/packages/perps-controller/src/utils/rewardsUtils.ts b/packages/perps-controller/src/utils/rewardsUtils.ts new file mode 100644 index 00000000000..ebe80e86853 --- /dev/null +++ b/packages/perps-controller/src/utils/rewardsUtils.ts @@ -0,0 +1,115 @@ +/** + * Shared rewards utilities for Perps components + * Handles CAIP account formatting and rewards integration + * + * Portable: no mobile-specific imports. + * Logger is injected as optional parameter for platform-agnostic error reporting. + */ +import { toChecksumHexAddress } from '@metamask/controller-utils'; +import { + toCaipAccountId, + CaipAccountId, + parseCaipChainId, +} from '@metamask/utils'; + +import type { PerpsLogger } from '../types/index.js'; +import { ensureError } from './errorUtils.js'; + +/** + * Converts a numeric or hex chain ID to a CAIP-2 chain ID string. + * e.g. '0x1' → 'eip155:1', '42161' → 'eip155:42161' + * + * @param chainId - Numeric string or hex string chain ID. + * @returns CAIP-2 formatted chain ID. + */ +function formatChainIdToCaip(chainId: string): string { + const decimal = chainId.startsWith('0x') + ? parseInt(chainId, 16) + : parseInt(chainId, 10); + if (isNaN(decimal)) { + throw new Error(`Invalid chain ID: ${chainId}`); + } + return `eip155:${decimal}`; +} + +/** + * Formats an address to CAIP-10 account ID format + * + * @param address - The wallet address to format + * @param chainId - The chain ID (e.g., '1' for mainnet, '42161' for Arbitrum) + * @param logger - Optional logger for error reporting + * @returns CAIP-10 formatted account ID or null if formatting fails + * @example + * ```typescript + * const caipId = formatAccountToCaipAccountId('0x123...', '42161'); + * // Returns: 'eip155:42161:0x123...' + * ``` + */ +export const formatAccountToCaipAccountId = ( + address: string, + chainId: string, + logger?: PerpsLogger, +): CaipAccountId | null => { + try { + const caipChainId = formatChainIdToCaip(chainId) as `${string}:${string}`; + const { namespace, reference } = parseCaipChainId(caipChainId); + + // Normalize EVM addresses to checksummed format for consistent CAIP IDs + let normalizedAddress = address; + if (namespace === 'eip155') { + normalizedAddress = toChecksumHexAddress(address); + } + + return toCaipAccountId(namespace, reference, normalizedAddress); + } catch (error) { + logger?.error( + ensureError(error, 'rewardsUtils.formatAccountToCaipAccountId'), + { + context: { + name: 'rewardsUtils.formatAccountToCaipAccountId', + data: { address, chainId }, + }, + }, + ); + return null; + } +}; + +/** + * Type guard to check if a value is a valid CAIP account ID + * + * @param value - Value to check + * @returns True if value is a valid CAIP account ID + */ +export const isCaipAccountId = (value: unknown): value is CaipAccountId => { + if (typeof value !== 'string') { + return false; + } + + // CAIP-10 format: namespace:reference:account_address + const parts = value.split(':'); + return parts.length >= 3 && parts[0] === 'eip155'; +}; + +/** + * Helper to handle rewards-related errors consistently + * + * @param error - The error that occurred + * @param logger - Optional logger for error reporting + * @param context - Optional context information + * @returns A user-friendly error message + */ +export const handleRewardsError = ( + error: unknown, + logger?: PerpsLogger, + context?: Record, +): string => { + logger?.error(ensureError(error, 'rewardsUtils.handleRewardsError'), { + context: { + name: 'rewardsUtils.handleRewardsError', + data: { additionalContext: context }, + }, + }); + + return 'Rewards operation failed'; +}; diff --git a/packages/perps-controller/src/utils/significantFigures.ts b/packages/perps-controller/src/utils/significantFigures.ts new file mode 100644 index 00000000000..4fe05c1f8f2 --- /dev/null +++ b/packages/perps-controller/src/utils/significantFigures.ts @@ -0,0 +1,105 @@ +import { DECIMAL_PRECISION_CONFIG } from '../constants/perpsConfig.js'; + +/** + * Count significant figures in a price string. + * Pure math function extracted from formatUtils for portability. + * + * @param priceString - The price string to count significant figures for. + * @returns The number of significant figures in the price string. + */ +export const countSignificantFigures = (priceString: string): number => { + if (!priceString) { + return 0; + } + + const cleaned = priceString.replace(/[$,]/gu, '').trim(); + const number = parseFloat(cleaned); + if (isNaN(number) || number === 0) { + return 0; + } + + const normalized = number.toString(); + const [integerPart, decimalPart = ''] = normalized.split('.'); + const trimmedInteger = integerPart.replace(/^-?0*/u, '') || ''; + + const effectiveIntegerLength = decimalPart + ? trimmedInteger.length + : trimmedInteger.replace(/0+$/u, '').length || + (trimmedInteger.length > 0 ? 1 : 0); + + return effectiveIntegerLength + decimalPart.length; +}; + +/** + * Check if a price string exceeds the maximum significant figures. + * + * @param priceString - The price string to check. + * @param maxSigFigs - The maximum allowed significant figures. + * @returns True if the price string exceeds the maximum significant figures. + */ +export const hasExceededSignificantFigures = ( + priceString: string, + maxSigFigs: number = DECIMAL_PRECISION_CONFIG.MaxSignificantFigures, +): boolean => { + if (!priceString || priceString.trim() === '') { + return false; + } + + const cleaned = priceString.replace(/[$,]/gu, '').trim(); + const number = parseFloat(cleaned); + if (isNaN(number)) { + return false; + } + + const normalized = number.toString(); + if (!normalized.includes('.')) { + return false; + } + + return countSignificantFigures(priceString) > maxSigFigs; +}; + +/** + * Round a price string to the maximum significant figures. + * + * @param priceString - The price string to round. + * @param maxSigFigs - The maximum allowed significant figures. + * @returns The price string rounded to the specified significant figures. + */ +export const roundToSignificantFigures = ( + priceString: string, + maxSigFigs: number = DECIMAL_PRECISION_CONFIG.MaxSignificantFigures, +): string => { + if (!priceString || priceString.trim() === '') { + return priceString; + } + + const cleaned = priceString.replace(/[$,]/gu, '').trim(); + const number = Number.parseFloat(cleaned); + if (Number.isNaN(number) || number === 0) { + return priceString; + } + + const normalized = number.toString(); + const [integerPart, decimalPart = ''] = normalized.split('.'); + + const trimmedInteger = integerPart.replace(/^-?0*/u, '') || ''; + const integerSigFigs = trimmedInteger.length; + + if (!decimalPart) { + return normalized; + } + + const allowedDecimalDigits = maxSigFigs - integerSigFigs; + + if (allowedDecimalDigits <= 0) { + return Math.round(number).toString(); + } + + if (decimalPart.length <= allowedDecimalDigits) { + return normalized; + } + + const rounded = number.toFixed(allowedDecimalDigits); + return Number.parseFloat(rounded).toString(); +}; diff --git a/packages/perps-controller/src/utils/sortMarkets.ts b/packages/perps-controller/src/utils/sortMarkets.ts new file mode 100644 index 00000000000..1c7622527f6 --- /dev/null +++ b/packages/perps-controller/src/utils/sortMarkets.ts @@ -0,0 +1,127 @@ +import { + MARKET_SORTING_CONFIG, + PERPS_CONSTANTS, +} from '../constants/perpsConfig.js'; +import type { + PerpsMarketData, + SortDirection, + SortField, +} from '../types/index.js'; + +export type SortMarketsParams = { + markets: PerpsMarketData[]; + sortBy: SortField; + direction?: SortDirection; +}; + +const VOLUME_SUFFIX_REGEX = /\$?([\d.,]+)([KMBT])?/u; + +const multipliers: Record = { + K: 1e3, + M: 1e6, + B: 1e9, + T: 1e12, +} as const; + +const removeCommas = (str: string): string => str.replace(/,/gu, ''); + +/** + * Parse a formatted volume string (e.g., "$1.5M", "$2.3B") to a numeric value. + * Extracted from hooks/usePerpsMarkets.ts for portability. + * + * @param volumeStr - The formatted volume string to parse. + * @returns The numeric volume value, or -1 if unparseable. + */ +export const parseVolume = (volumeStr: string | undefined): number => { + if (!volumeStr) { + return -1; + } + + if (volumeStr === PERPS_CONSTANTS.FallbackPriceDisplay) { + return -1; + } + if (volumeStr === '$<1') { + return 0.5; + } + + const suffixMatch = VOLUME_SUFFIX_REGEX.exec(volumeStr); + if (suffixMatch) { + const [, numberPart, suffix] = suffixMatch; + const baseValue = Number.parseFloat(removeCommas(numberPart)); + + if (Number.isNaN(baseValue)) { + return -1; + } + + return suffix ? baseValue * multipliers[suffix] : baseValue; + } + + // Fallback: try to parse as plain number + const cleaned = volumeStr.replace(/[$,]/gu, ''); + const parsed = Number.parseFloat(cleaned); + return Number.isNaN(parsed) ? -1 : parsed; +}; + +/** + * Sorts markets based on the specified criteria. + * + * @param options0 - The sorting configuration. + * @param options0.markets - The array of market data to sort. + * @param options0.sortBy - The field to sort by (volume, priceChange, fundingRate, or openInterest). + * @param options0.direction - The sort direction (asc or desc). + * @returns A new sorted array of market data. + */ +export const sortMarkets = ({ + markets, + sortBy, + direction = MARKET_SORTING_CONFIG.DefaultDirection, +}: SortMarketsParams): PerpsMarketData[] => { + const sortedMarkets = [...markets]; + + sortedMarkets.sort((a, b) => { + let compareValue = 0; + + switch (sortBy) { + case MARKET_SORTING_CONFIG.SortFields.Volume: { + const volumeA = parseVolume(a.volume); + const volumeB = parseVolume(b.volume); + compareValue = volumeA - volumeB; + break; + } + + case MARKET_SORTING_CONFIG.SortFields.PriceChange: { + const changeA = parseFloat( + a.change24hPercent?.replace(/[%+]/gu, '') || '0', + ); + const changeB = parseFloat( + b.change24hPercent?.replace(/[%+]/gu, '') || '0', + ); + compareValue = changeA - changeB; + break; + } + + case MARKET_SORTING_CONFIG.SortFields.FundingRate: { + const fundingA = a.fundingRate ?? 0; + const fundingB = b.fundingRate ?? 0; + compareValue = fundingA - fundingB; + break; + } + + case MARKET_SORTING_CONFIG.SortFields.OpenInterest: { + const openInterestA = parseVolume(a.openInterest); + const openInterestB = parseVolume(b.openInterest); + compareValue = openInterestA - openInterestB; + break; + } + + default: + break; + } + + return direction === MARKET_SORTING_CONFIG.DefaultDirection + ? compareValue * -1 + : compareValue; + }); + + return sortedMarkets; +}; diff --git a/packages/perps-controller/src/utils/standaloneInfoClient.ts b/packages/perps-controller/src/utils/standaloneInfoClient.ts new file mode 100644 index 00000000000..66ac7541518 --- /dev/null +++ b/packages/perps-controller/src/utils/standaloneInfoClient.ts @@ -0,0 +1,102 @@ +import { HttpTransport, InfoClient } from '@nktkas/hyperliquid'; + +import { PERPS_CONSTANTS } from '../constants/perpsConfig.js'; +import type { + ClearinghouseStateResponse, + FrontendOpenOrdersResponse, +} from '../types/hyperliquid-types.js'; + +export type StandaloneInfoClientOptions = { + /** Whether to use testnet API endpoint */ + isTestnet: boolean; + /** Request timeout in ms (default: CONNECTION_TIMEOUT_MS) */ + timeout?: number; +}; + +/** + * Creates a standalone InfoClient for lightweight read-only queries. + * Does not require full perps initialization (no wallet, WebSocket, etc.) + * + * @param options - The configuration options for the standalone client. + * @returns A new InfoClient instance configured for read-only queries. + */ +export const createStandaloneInfoClient = ( + options: StandaloneInfoClientOptions, +): InfoClient => { + const { isTestnet, timeout = PERPS_CONSTANTS.ConnectionTimeoutMs } = options; + + const httpTransport = new HttpTransport({ + isTestnet, + timeout, + }); + + return new InfoClient({ transport: httpTransport }); +}; + +/** + * Query clearinghouseState across multiple DEXs in parallel. + * Used by standalone mode to aggregate positions/account state across HIP-3 DEXs. + * + * @param infoClient - The HyperLiquid InfoClient instance to use for queries. + * @param userAddress - The user's wallet address to query state for. + * @param dexs - The array of DEX identifiers to query (null for main DEX). + * @returns A promise that resolves to an array of clearinghouse state responses. + */ +export const queryStandaloneClearinghouseStates = async ( + infoClient: InfoClient, + userAddress: string, + dexs: (string | null)[], +): Promise => { + const results = await Promise.allSettled( + dexs.map(async (dex) => { + const queryParams: { user: string; dex?: string } = { + user: userAddress, + }; + if (dex) { + queryParams.dex = dex; + } + return infoClient.clearinghouseState(queryParams); + }), + ); + + return results + .filter( + (result): result is PromiseFulfilledResult => + result.status === 'fulfilled', + ) + .map((result) => result.value); +}; + +/** + * Query frontendOpenOrders across multiple DEXs in parallel. + * Used by standalone mode to fetch open orders across HIP-3 DEXs. + * + * @param infoClient - The HyperLiquid InfoClient instance to use for queries. + * @param userAddress - The user's wallet address to query orders for. + * @param dexs - The array of DEX identifiers to query (null for main DEX). + * @returns A promise that resolves to an array of frontend open orders responses. + */ +export const queryStandaloneOpenOrders = async ( + infoClient: InfoClient, + userAddress: string, + dexs: (string | null)[], +): Promise => { + const results = await Promise.allSettled( + dexs.map(async (dex) => { + const queryParams: { user: string; dex?: string } = { + user: userAddress, + }; + if (dex) { + queryParams.dex = dex; + } + return infoClient.frontendOpenOrders(queryParams); + }), + ); + + return results + .filter( + (result): result is PromiseFulfilledResult => + result.status === 'fulfilled', + ) + .map((result) => result.value); +}; diff --git a/packages/perps-controller/src/utils/stringParseUtils.ts b/packages/perps-controller/src/utils/stringParseUtils.ts new file mode 100644 index 00000000000..e17bb674f32 --- /dev/null +++ b/packages/perps-controller/src/utils/stringParseUtils.ts @@ -0,0 +1,36 @@ +export const stripQuotes = (str: string): string => { + let result = str; + while ( + (result.startsWith('"') && result.endsWith('"')) || + (result.startsWith("'") && result.endsWith("'")) + ) { + result = result.slice(1, -1); + } + return result; +}; + +export const parseCommaSeparatedString = (value: string): string[] => + value + .split(',') + .map((item) => item.trim()) + .filter((item) => item.length > 0); + +const NON_NEGATIVE_DECIMAL_PATTERN = /^\d+(?:\.\d+)?$/u; + +/** + * Parse a plain non-negative decimal within an inclusive upper bound. + * + * @param value - Candidate decimal string. + * @param upperBound - Largest accepted value. + * @returns The parsed value, or null when the input is invalid or too large. + */ +export function parseBoundedNonNegativeDecimal( + value: unknown, + upperBound = Number.MAX_VALUE, +): number | null { + if (typeof value !== 'string' || !NON_NEGATIVE_DECIMAL_PATTERN.test(value)) { + return null; + } + const parsed = Number(value); + return Number.isFinite(parsed) && parsed <= upperBound ? parsed : null; +} diff --git a/packages/perps-controller/src/utils/transferData.ts b/packages/perps-controller/src/utils/transferData.ts new file mode 100644 index 00000000000..0a0df56a39c --- /dev/null +++ b/packages/perps-controller/src/utils/transferData.ts @@ -0,0 +1,37 @@ +/** + * Portable ERC-20 transfer data generation. + * Only the 'transfer(address,uint256)' case is needed by PerpsController. + * + * Uses @metamask/abi-utils (core package with proper TypeScript types) + * and @metamask/utils for hex conversion. + */ +import { encode } from '@metamask/abi-utils'; +import { bytesToHex } from '@metamask/utils'; + +/** ERC-20 transfer function selector: transfer(address,uint256) */ +const TRANSFER_FUNCTION_SIGNATURE = '0xa9059cbb'; + +/** + * Generate ERC-20 transfer calldata. + * + * @param toAddress - Recipient address (0x-prefixed hex string) + * @param amount - Transfer amount (0x-prefixed hex string) + * @returns Hex-encoded calldata for ERC-20 transfer + */ +export function generateERC20TransferData( + toAddress: string, + amount: string, +): string { + if (!toAddress || !amount) { + throw new Error( + "[transferData] 'toAddress' and 'amount' must be defined for ERC-20 transfer", + ); + } + + const encoded = encode(['address', 'uint256'], [toAddress, amount]); + // bytesToHex returns '0x...' prefixed string; strip the '0x' prefix + // since we prepend the function selector ourselves + const encodedHex = bytesToHex(encoded).slice(2); + + return TRANSFER_FUNCTION_SIGNATURE + encodedHex; +} diff --git a/packages/perps-controller/src/utils/wait.ts b/packages/perps-controller/src/utils/wait.ts new file mode 100644 index 00000000000..0c23d62a585 --- /dev/null +++ b/packages/perps-controller/src/utils/wait.ts @@ -0,0 +1,2 @@ +export const wait = (ms: number): Promise => + new Promise((resolve) => setTimeout(resolve, ms)); diff --git a/packages/perps-controller/tests/defer-eligibility.test.ts b/packages/perps-controller/tests/defer-eligibility.test.ts new file mode 100644 index 00000000000..9e987b305bc --- /dev/null +++ b/packages/perps-controller/tests/defer-eligibility.test.ts @@ -0,0 +1,330 @@ +import { Messenger, MOCK_ANY_NAMESPACE } from '@metamask/messenger'; +import type { + MockAnyNamespace, + MessengerActions, + MessengerEvents, +} from '@metamask/messenger'; + +import { PerpsController } from '../src/PerpsController.js'; +import type { PerpsControllerMessenger } from '../src/PerpsController.js'; +import type { PerpsPlatformDependencies } from '../src/types/index.js'; + +jest.mock('@nktkas/hyperliquid', () => ({})); +jest.mock('@myx-trade/sdk', () => ({})); + +type RootMessenger = Messenger< + MockAnyNamespace, + MessengerActions, + MessengerEvents +>; + +const noopLogger = { + error: jest.fn(), + warn: jest.fn(), +}; + +const noopDebugLogger = { + log: jest.fn(), + warn: jest.fn(), + error: jest.fn(), +}; + +function buildMockInfrastructure(): PerpsPlatformDependencies { + return { + logger: noopLogger as unknown as PerpsPlatformDependencies['logger'], + debugLogger: + noopDebugLogger as unknown as PerpsPlatformDependencies['debugLogger'], + metrics: { + trackEvent: jest.fn(), + } as unknown as PerpsPlatformDependencies['metrics'], + performance: { + startTrace: jest.fn(), + endTrace: jest.fn(), + } as unknown as PerpsPlatformDependencies['performance'], + tracer: { + trace: jest.fn(), + } as unknown as PerpsPlatformDependencies['tracer'], + streamManager: { + subscribe: jest.fn(), + unsubscribe: jest.fn(), + pauseChannel: jest.fn(), + resumeChannel: jest.fn(), + } as unknown as PerpsPlatformDependencies['streamManager'], + featureFlags: { validateVersionGated: jest.fn() }, + marketDataFormatters: { + formatPrice: jest.fn(), + formatSize: jest.fn(), + } as unknown as PerpsPlatformDependencies['marketDataFormatters'], + cacheInvalidator: { + invalidate: jest.fn(), + } as unknown as PerpsPlatformDependencies['cacheInvalidator'], + diskCache: { + getItem: jest.fn().mockResolvedValue(null), + getItemSync: jest.fn().mockReturnValue(null), + setItem: jest.fn().mockResolvedValue(undefined), + removeItem: jest.fn().mockResolvedValue(undefined), + }, + rewards: { getPerpsDiscountForAccount: jest.fn().mockResolvedValue(0) }, + }; +} + +const MOCK_REMOTE_FEATURE_FLAG_STATE = { + remoteFeatureFlags: {}, + cacheTimestamp: 0, +}; + +function getRootMessenger(): RootMessenger { + return new Messenger({ namespace: MOCK_ANY_NAMESPACE }); +} + +function getControllerMessenger( + rootMessenger: RootMessenger, +): PerpsControllerMessenger { + const messenger: PerpsControllerMessenger = new Messenger({ + namespace: 'PerpsController', + parent: rootMessenger, + }); + + rootMessenger.delegate({ + actions: [ + 'RemoteFeatureFlagController:getState', + 'NetworkController:getState', + 'NetworkController:getNetworkClientById', + 'NetworkController:findNetworkClientIdByChainId', + 'KeyringController:getState', + 'KeyringController:signTypedMessage', + 'TransactionController:addTransaction', + 'AccountTreeController:getAccountsFromSelectedAccountGroup', + 'AuthenticationController:getBearerToken', + ], + events: [ + 'RemoteFeatureFlagController:stateChange', + 'AccountTreeController:selectedAccountGroupChange', + ], + messenger, + }); + + return messenger; +} + +type BuildControllerOptions = { + deferEligibilityCheck?: boolean; +}; + +function buildController({ + deferEligibilityCheck, +}: BuildControllerOptions = {}): { + controller: PerpsController; + rootMessenger: RootMessenger; + controllerMessenger: PerpsControllerMessenger; +} { + const rootMessenger = getRootMessenger(); + + rootMessenger.registerActionHandler( + 'RemoteFeatureFlagController:getState', + () => MOCK_REMOTE_FEATURE_FLAG_STATE, + ); + + const controllerMessenger = getControllerMessenger(rootMessenger); + + const controller = new PerpsController({ + messenger: controllerMessenger, + infrastructure: buildMockInfrastructure(), + deferEligibilityCheck, + }); + + return { controller, rootMessenger, controllerMessenger }; +} + +describe('PerpsController - deferEligibilityCheck', () => { + describe('when deferEligibilityCheck is true', () => { + it('does not trigger a geolocation fetch during construction', async () => { + const fetchSpy = jest.spyOn(globalThis, 'fetch').mockResolvedValue({ + text: () => Promise.resolve('US'), + } as globalThis.Response); + + buildController({ deferEligibilityCheck: true }); + + // Allow any pending microtasks to flush + await new Promise((resolve) => process.nextTick(resolve)); + + expect(fetchSpy).not.toHaveBeenCalled(); + fetchSpy.mockRestore(); + }); + + it('does not trigger a geolocation fetch from subscription events', async () => { + const fetchSpy = jest.spyOn(globalThis, 'fetch').mockResolvedValue({ + text: () => Promise.resolve('US'), + } as globalThis.Response); + + const { rootMessenger } = buildController({ + deferEligibilityCheck: true, + }); + + rootMessenger.publish( + 'RemoteFeatureFlagController:stateChange', + { ...MOCK_REMOTE_FEATURE_FLAG_STATE }, + [], + ); + + await new Promise((resolve) => process.nextTick(resolve)); + + expect(fetchSpy).not.toHaveBeenCalled(); + fetchSpy.mockRestore(); + }); + + it('keeps isEligible at default (false) during deferral', () => { + const { controller } = buildController({ + deferEligibilityCheck: true, + }); + + expect(controller.state.isEligible).toBe(false); + }); + }); + + describe('startEligibilityMonitoring', () => { + it('is callable after deferred construction', () => { + const { controller } = buildController({ + deferEligibilityCheck: true, + }); + + expect(() => controller.startEligibilityMonitoring()).not.toThrow(); + }); + + it('reads current RemoteFeatureFlagController state', () => { + const rootMessenger = getRootMessenger(); + const getStateMock = jest + .fn() + .mockReturnValue(MOCK_REMOTE_FEATURE_FLAG_STATE); + + rootMessenger.registerActionHandler( + 'RemoteFeatureFlagController:getState', + getStateMock, + ); + + const controllerMessenger = getControllerMessenger(rootMessenger); + const controller = new PerpsController({ + messenger: controllerMessenger, + infrastructure: buildMockInfrastructure(), + deferEligibilityCheck: true, + }); + + getStateMock.mockClear(); + + controller.startEligibilityMonitoring(); + + expect(getStateMock).toHaveBeenCalledTimes(1); + }); + + it('unblocks future subscription-driven eligibility checks', () => { + const refreshSpy = jest.spyOn( + PerpsController.prototype as unknown as { + refreshEligibilityOnFeatureFlagChange: (...args: unknown[]) => void; + }, + 'refreshEligibilityOnFeatureFlagChange', + ); + + const { controller, rootMessenger } = buildController({ + deferEligibilityCheck: true, + }); + + const callCountAfterConstruction = refreshSpy.mock.calls.length; + + controller.startEligibilityMonitoring(); + + const callCountAfterStart = refreshSpy.mock.calls.length; + expect(callCountAfterStart).toBe(callCountAfterConstruction + 1); + + rootMessenger.publish( + 'RemoteFeatureFlagController:stateChange', + { ...MOCK_REMOTE_FEATURE_FLAG_STATE }, + [], + ); + + expect(refreshSpy.mock.calls).toHaveLength(callCountAfterStart + 1); + refreshSpy.mockRestore(); + }); + }); + + describe('stopEligibilityMonitoring', () => { + it('prevents geolocation calls after stop', async () => { + const fetchSpy = jest.spyOn(globalThis, 'fetch').mockResolvedValue({ + text: () => Promise.resolve('US'), + } as globalThis.Response); + + const { controller, rootMessenger } = buildController({ + deferEligibilityCheck: true, + }); + + controller.startEligibilityMonitoring(); + controller.stopEligibilityMonitoring(); + + rootMessenger.publish( + 'RemoteFeatureFlagController:stateChange', + { ...MOCK_REMOTE_FEATURE_FLAG_STATE }, + [], + ); + + await new Promise((resolve) => process.nextTick(resolve)); + + expect(fetchSpy).not.toHaveBeenCalled(); + fetchSpy.mockRestore(); + }); + + it('is idempotent', () => { + const { controller } = buildController({ + deferEligibilityCheck: true, + }); + + controller.startEligibilityMonitoring(); + expect(() => { + controller.stopEligibilityMonitoring(); + controller.stopEligibilityMonitoring(); + controller.stopEligibilityMonitoring(); + }).not.toThrow(); + }); + + it('resumes monitoring when startEligibilityMonitoring is called again', () => { + const rootMessenger = getRootMessenger(); + const getStateMock = jest + .fn() + .mockReturnValue(MOCK_REMOTE_FEATURE_FLAG_STATE); + + rootMessenger.registerActionHandler( + 'RemoteFeatureFlagController:getState', + getStateMock, + ); + + const controllerMessenger = getControllerMessenger(rootMessenger); + const controller = new PerpsController({ + messenger: controllerMessenger, + infrastructure: buildMockInfrastructure(), + deferEligibilityCheck: true, + }); + + getStateMock.mockClear(); + + controller.startEligibilityMonitoring(); + controller.stopEligibilityMonitoring(); + controller.startEligibilityMonitoring(); + + expect(getStateMock).toHaveBeenCalledTimes(2); + }); + }); + + describe('when deferEligibilityCheck is false (default)', () => { + it('triggers eligibility processing during construction', () => { + const refreshSpy = jest.spyOn( + PerpsController.prototype as unknown as { + refreshEligibilityOnFeatureFlagChange: (...args: unknown[]) => void; + }, + 'refreshEligibilityOnFeatureFlagChange', + ); + + buildController({ deferEligibilityCheck: false }); + + expect(refreshSpy).toHaveBeenCalled(); + refreshSpy.mockRestore(); + }); + }); +}); diff --git a/packages/perps-controller/tests/e2e/advanced-orders.e2e.ts b/packages/perps-controller/tests/e2e/advanced-orders.e2e.ts new file mode 100644 index 00000000000..eab3c5c8b3e --- /dev/null +++ b/packages/perps-controller/tests/e2e/advanced-orders.e2e.ts @@ -0,0 +1,648 @@ +import { hasProperty } from '@metamask/utils'; +/** + * Advanced order types — end-to-end contract proof (TAT-3511). + * + * For every advanced order type this script proves the full round trip: + * + * place -> visible in open-orders state with the correct trigger data + * -> cancel -> absent from open-orders state + * + * The case matrix, exchange doubles, and assertions live in + * `e2e/lib/advancedOrders.ts` and are shared with the Jest contract guard + * (`tests/src/e2e/advanced-orders.contract.test.ts`), so this script and CI can + * never drift apart. Only the transport differs per mode: + * + * - `simulated` (default): an in-process HyperLiquid double that stores the + * submitted payloads and renders them back in HyperLiquid's + * `frontendOpenOrders` shape. Deterministic, needs no credentials, and runs + * from a clean checkout. + * - `testnet`: the real HyperLiquid testnet through `@nktkas/hyperliquid`. + * Requires `PERPS_E2E_PRIVATE_KEY` and `PERPS_E2E_ADDRESS`. + * + * Usage: + * npx tsx e2e/advanced-orders.e2e.ts [--mode=simulated|testnet] [--out=DIR] [--symbol=BTC] + * + * Exit code is non-zero if any case fails. + */ +import fs from 'fs/promises'; +import path from 'path'; +import { mnemonicToAccount, privateKeyToAccount } from 'viem/accounts'; + +import type { FrontendOrder } from '../../src/types/hyperliquid-types.js'; +import { + formatHyperLiquidPrice, + formatHyperLiquidSize, +} from '../../src/utils/hyperLiquidAdapter.js'; +import { isTriggerOrderType } from '../../src/utils/orderTypes.js'; +import type { + CaseContext, + CaseEvidence, + ExchangeRunner, + Mode, + PremiseEvidence, +} from '../helpers/advancedOrders.js'; +import { + buildCases, + buildPremiseCases, + caseContext, + createSimulatedRunner, + runCase, + runPremiseCase, + runTypedErrorCase, +} from '../helpers/advancedOrders.js'; + +/** + * Locate the wallet fixture the recipe harness already uses. + * + * The harness resolves it as `/temp/recipe/runtime/wallet-fixture.json` + * (see walletFixturePath in the harness). This script runs from inside the + * checkout, so walk up from the working directory to find the same file rather + * than asking the caller to configure a second copy of the same secret. + * + * @returns The fixture path, or null when no checkout above the cwd has one. + */ +async function findWalletFixture(): Promise { + const { access } = await import('node:fs/promises'); + const nodePath = await import('node:path'); + + let dir = process.cwd(); + for (;;) { + const candidate = nodePath.join( + dir, + 'temp', + 'recipe', + 'runtime', + 'wallet-fixture.json', + ); + try { + await access(candidate); + return candidate; + } catch { + // Not in this directory; keep walking up. + } + const parent = nodePath.dirname(dir); + if (parent === dir) { + return null; + } + dir = parent; + } +} + +/** + * Derive the testnet signer from the recipe harness's wallet fixture. + * + * Mirrors the harness's own derivation exactly — account selected by `name`, + * mnemonics at BIP-44 address index 0 — so a premise probe signs as the very + * same account the recipes trade with. One funded account, one place to rotate + * it, and evidence from both paths that refers to the same address. + * + * @param accountName - Fixture account `name` to use. + * @returns The viem account, or null when no fixture is reachable. + */ +async function signerFromWalletFixture( + accountName: string, +): Promise<{ account: unknown; address: `0x${string}` } | null> { + const fixturePath = await findWalletFixture(); + if (!fixturePath) { + return null; + } + + const { readFile } = await import('node:fs/promises'); + const fixture = JSON.parse(await readFile(fixturePath, 'utf8')) as { + accounts?: { type?: string; value?: string; name?: string }[]; + }; + + const entry = fixture.accounts?.find((item) => item?.name === accountName); + if (!entry?.value) { + const names = (fixture.accounts ?? []) + .map((item) => item?.name) + .filter(Boolean) + .join(', '); + throw new Error( + `wallet-fixture.json at ${fixturePath} has no account named "${accountName}". Available: ${names}.`, + ); + } + + if (entry.type === 'mnemonic') { + const account = mnemonicToAccount(entry.value.trim(), { addressIndex: 0 }); + return { account, address: account.address }; + } + if (entry.type === 'privateKey') { + const raw = entry.value.trim(); + const account = privateKeyToAccount( + (raw.startsWith('0x') ? raw : `0x${raw}`) as `0x${string}`, + ); + return { account, address: account.address }; + } + throw new Error( + `wallet-fixture.json account "${accountName}" must be type mnemonic or privateKey, got "${entry.type}".`, + ); +} + +export async function createTestnetRunner(options: { + assetId: number; + accountName?: string; +}): Promise { + // Precedence: an explicit key in the environment, otherwise the wallet + // fixture the recipes already sign with. Sharing that one source is what lets + // a premise probe and a recipe run prove things about the same account. + // eslint-disable-next-line n/no-process-env + const envKey = process.env.PERPS_E2E_PRIVATE_KEY; + // eslint-disable-next-line n/no-process-env + const envAddress = process.env.PERPS_E2E_ADDRESS as `0x${string}` | undefined; + + let wallet: unknown = envKey; + let address = envAddress; + + if (envKey && !envAddress) { + throw new Error( + 'PERPS_E2E_ADDRESS is required alongside PERPS_E2E_PRIVATE_KEY (address of that key)', + ); + } + + if (!envKey) { + const accountName = + options.accountName ?? + // eslint-disable-next-line n/no-process-env + process.env.PERPS_E2E_ACCOUNT ?? + 'dev1'; + const fixtureSigner = await signerFromWalletFixture(accountName); + if (!fixtureSigner) { + throw new Error( + 'No testnet signer: set PERPS_E2E_PRIVATE_KEY and PERPS_E2E_ADDRESS, or run from a checkout with temp/recipe/runtime/wallet-fixture.json (the fixture the recipes use).', + ); + } + wallet = fixtureSigner.account; + address = fixtureSigner.address; + } + + if (!address) { + throw new Error( + 'No testnet address resolved: set PERPS_E2E_ADDRESS, or use a wallet fixture account.', + ); + } + const userAddress: `0x${string}` = address; + + const hyperliquid = await import('@nktkas/hyperliquid'); + const transport = new hyperliquid.HttpTransport({ isTestnet: true }); + // The SDK accepts either a raw private key or a viem account as its wallet; + // the declared union is wider than what is needed here. + const exchangeClient = new hyperliquid.ExchangeClient({ + transport, + wallet, + } as unknown as ConstructorParameters[0]); + const infoClient = new hyperliquid.InfoClient({ transport }); + + return { + submit: async ({ orders, grouping, symbol }): Promise => { + // A resting trigger comes back as a bare "waitingForTrigger" with no + // order id, so the ids it did not give us are recovered by diffing open + // orders either side of the submission. + const before = new Set( + (await infoClient.frontendOpenOrders({ user: userAddress })) + .filter((order: FrontendOrder) => order.coin === symbol) + .map((order: FrontendOrder) => String(order.oid)), + ); + + const result = await exchangeClient.order({ orders, grouping }); + if (result.status !== 'ok') { + throw new Error(`Order submission failed: ${JSON.stringify(result)}`); + } + + const acknowledged = result.response.data.statuses.map( + (status: unknown): string | null => { + if (status && typeof status === 'object') { + if (hasProperty(status, 'resting')) { + return String( + (status as { resting: { oid: number } }).resting.oid, + ); + } + if (hasProperty(status, 'filled')) { + return String((status as { filled: { oid: number } }).filled.oid); + } + } + if (status === 'waitingForTrigger') { + return null; + } + throw new Error(`Unexpected order status: ${JSON.stringify(status)}`); + }, + ); + + if (!acknowledged.includes(null)) { + return acknowledged as string[]; + } + + const appeared = ( + await infoClient.frontendOpenOrders({ user: userAddress }) + ) + .filter((order: FrontendOrder) => order.coin === symbol) + .filter((order: FrontendOrder) => !before.has(String(order.oid))); + + // The venue lists new orders in its own order, not the order they were + // submitted in, so match each unacknowledged slot to the resting order + // carrying its trigger price rather than pairing them off by position. + const claimed = new Set(); + return acknowledged.map((oid, index) => { + if (oid !== null) { + return oid; + } + const submittedTrigger = hasProperty(orders[index].t, 'trigger') + ? (orders[index].t as { trigger: { triggerPx: string } }).trigger + .triggerPx + : undefined; + const match = appeared.find( + (order: FrontendOrder) => + !claimed.has(String(order.oid)) && + (submittedTrigger === undefined || + parseFloat(String(order.triggerPx)) === + parseFloat(submittedTrigger)), + ); + if (!match) { + return ''; + } + claimed.add(String(match.oid)); + return String(match.oid); + }); + }, + openOrders: async (symbol): Promise => { + const orders = await infoClient.frontendOpenOrders({ user: userAddress }); + return orders.filter((order: FrontendOrder) => order.coin === symbol); + }, + cancel: async ({ orderIds }): Promise => { + // Cancel one at a time and tolerate an order that is already gone. A + // market order in the matrix fills on submission, so it is no longer + // cancellable — that is the expected outcome, not a proof failure. But + // "nothing left resting" is not the whole contract: a filled parent + // leaves a POSITION, which `flatten` below is what actually clears. + for (const orderId of orderIds) { + if (!orderId) { + continue; + } + try { + const result = await exchangeClient.cancel({ + cancels: [{ a: options.assetId, o: Number(orderId) }], + }); + if (result.status !== 'ok') { + throw new Error(`Cancel failed: ${JSON.stringify(result)}`); + } + } catch (error) { + const message = + error instanceof Error ? error.message : String(error); + if (!message.includes('never placed, already canceled, or filled')) { + throw error; + } + } + } + }, + + flatten: async (symbol): Promise => { + // A market parent fills instead of resting, so cancelling leaves real + // exposure behind. The venue premises run later against this same + // account and at least one reasons about the position, so the case must + // hand the account back flat rather than merely order-free. + const state = await infoClient.clearinghouseState({ user: userAddress }); + const held = state.assetPositions.find( + (entry: { position: { coin: string; szi: string } }) => + entry.position.coin === symbol, + ); + const signedSize = parseFloat(held?.position.szi ?? '0'); + if (!signedSize) { + return; + } + + const meta = await infoClient.meta(); + const asset = meta.universe.find( + (entry: { name: string }) => entry.name === symbol, + ); + const szDecimals = asset?.szDecimals ?? 0; + const mids = await infoClient.allMids(); + const mid = parseFloat(mids[symbol]); + + // Close by crossing the book: buy back a short, sell off a long. The + // limit is deliberately aggressive so an IOC fills rather than rests. + const isBuy = signedSize < 0; + const result = await exchangeClient.order({ + orders: [ + { + a: options.assetId, + b: isBuy, + p: formatHyperLiquidPrice({ + price: mid * (isBuy ? 1.05 : 0.95), + szDecimals, + }), + s: formatHyperLiquidSize({ + size: Math.abs(signedSize), + szDecimals, + }), + r: true, + t: { limit: { tif: 'Ioc' } }, + }, + ], + grouping: 'na', + }); + if (result.status !== 'ok') { + throw new Error( + `Failed to flatten ${symbol}: ${JSON.stringify(result)}`, + ); + } + }, + }; +} + +// The simulated exchange has no market, so its context describes the double. +// These are the double's reference values, not an assumption about any asset. +const SIMULATED_MID = 50_000; +const SIMULATED_SZ_DECIMALS = 3; +// The double has a single market, so its index is arbitrary. +const SIMULATED_ASSET_ID = 0; + +/** + * Read the venue's live mid and size precision for a market. + * + * Everything the matrix submits is derived from these, so the same cases hold + * whatever the asset is worth on the day they run. + * + * @param symbol - Market symbol. + * @returns The market's index, mid and size precision. + */ +async function readVenueContext(symbol: string): Promise<{ + symbol: string; + mid: number; + szDecimals: number; + assetId: number; +}> { + const hyperliquid = await import('@nktkas/hyperliquid'); + const infoClient = new hyperliquid.InfoClient({ + transport: new hyperliquid.HttpTransport({ isTestnet: true }), + }); + + const [mids, meta] = await Promise.all([ + infoClient.allMids(), + infoClient.meta(), + ]); + + const mid = Number(mids[symbol]); + if (!Number.isFinite(mid) || mid <= 0) { + throw new Error(`No live mid for ${symbol} on HyperLiquid testnet.`); + } + + const assetId = meta.universe.findIndex((item) => item.name === symbol); + if (assetId < 0) { + throw new Error(`${symbol} is not in the HyperLiquid testnet universe.`); + } + + return { + symbol, + mid, + szDecimals: meta.universe[assetId].szDecimals, + assetId, + }; +} + +/** + * Parses `--key=value` CLI arguments. + * + * @param argv - Raw process arguments. + * @returns The parsed options. + */ +function parseArgs(argv: string[]): { + mode: Mode; + out: string; + symbol: string; + requirePremises: boolean; + overrides: Partial; +} { + const read = (key: string): string | undefined => + argv.find((arg) => arg.startsWith(`--${key}=`))?.slice(`--${key}=`.length); + + const requestedMode = read('mode'); + if ( + requestedMode && + requestedMode !== 'simulated' && + requestedMode !== 'testnet' + ) { + throw new Error(`Unknown --mode: ${requestedMode}`); + } + + return { + mode: (requestedMode as Mode | undefined) ?? 'simulated', + // eslint-disable-next-line n/no-process-env + out: read('out') ?? process.env.PERPS_E2E_OUT ?? 'e2e/artifacts', + // eslint-disable-next-line n/no-process-env + symbol: read('symbol') ?? process.env.PERPS_E2E_SYMBOL ?? 'BTC', + requirePremises: argv.includes('--require-premises'), + // Every knob the matrix derives from is overridable, so a market that needs + // a bigger probe or a different resting distance needs no code change. + overrides: Object.fromEntries( + ( + [ + 'notional', + 'stopOffsetPct', + 'takeProfitOffsetPct', + 'limitSlipPct', + 'partialFraction', + ] as const + ) + .map((key) => [ + key, + read(key.replace(/[A-Z]/gu, (char) => `-${char.toLowerCase()}`)), + ]) + .filter(([, value]) => value !== undefined) + .map(([key, value]) => [key, Number(value)]), + ), + }; +} + +/** + * Renders the human-readable summary table. + * + * @param params - Summary inputs. + * @param params.mode - Run mode. + * @param params.symbol - Market symbol. + * @param params.results - Case evidence. + * @param params.errorCase - Typed-error case evidence. + * @param params.premises - Venue-premise evidence. + * @returns The markdown summary. + */ +function renderSummary(params: { + mode: Mode; + symbol: string; + results: CaseEvidence[]; + errorCase: ReturnType; + premises: PremiseEvidence[]; +}): string { + const { mode, symbol, results, errorCase } = params; + + const rows = results.map((result) => { + const trigger = + result.readBack?.triggerOrderType ?? result.readBack?.orderType ?? '—'; + return `| ${result.case} | ${mode} | yes | ${trigger} @ ${ + result.readBack?.triggerPrice ?? '—' + } | ${result.cancelled ? 'cancelled' : 'STILL OPEN'} | ${ + result.pass ? 'PASS' : 'FAIL' + } |`; + }); + + return [ + `# Advanced order types — e2e evidence (${mode})`, + '', + `Market: \`${symbol}\``, + '', + '| type | mode | placed | visible in open orders (trigger data) | cancelled/triggered | result |', + '| --- | --- | --- | --- | --- | --- |', + ...rows, + `| ${errorCase.case} | ${mode} | n/a | typed error \`${errorCase.actualError}\` | n/a | ${ + errorCase.pass ? 'PASS' : 'FAIL' + } |`, + '', + '## Per-check detail', + '', + ...results.flatMap((result) => [ + `### ${result.case}`, + '', + result.description, + '', + ...result.checks.map( + (check) => + `- ${check.pass ? 'PASS' : 'FAIL'} — ${check.name}: expected \`${JSON.stringify( + check.expected, + )}\`, got \`${JSON.stringify(check.actual)}\``, + ), + '', + ]), + ].join('\n'); +} + +/** + * Runs the whole matrix and writes the evidence artifacts. + */ +async function main(): Promise { + const { mode, out, symbol, requirePremises, overrides } = parseArgs( + process.argv.slice(2), + ); + const outDir = path.resolve(process.cwd(), out); + await fs.mkdir(outDir, { recursive: true }); + + // On testnet the matrix is described against the venue's own index, mid and + // size precision, read live. Simulated has no market, so the context + // describes the in-process double instead. Either way nothing below names a + // price, a size, or an asset index. + const ctx = + mode === 'testnet' + ? caseContext({ ...(await readVenueContext(symbol)), ...overrides }) + : caseContext({ + symbol, + mid: SIMULATED_MID, + szDecimals: SIMULATED_SZ_DECIMALS, + assetId: SIMULATED_ASSET_ID, + ...overrides, + }); + + const runner = + mode === 'testnet' + ? await createTestnetRunner({ assetId: ctx.assetId }) + : createSimulatedRunner(); + + process.stdout.write( + `context: ${symbol} assetId=${ctx.assetId} mid=${ctx.mid} szDecimals=${ctx.szDecimals} notional=${ctx.notional}\n`, + ); + + const cases = buildCases(ctx); + const results: CaseEvidence[] = []; + + for (const testCase of cases) { + // Sanity check on the case matrix itself: every trigger placement must + // carry a trigger price, otherwise the case proves nothing. + if ( + isTriggerOrderType(testCase.params.orderType) && + !testCase.params.triggerPrice + ) { + throw new Error(`Case ${testCase.name} is missing a trigger price`); + } + + const result = await runCase({ testCase, runner, mode, ctx }); + results.push(result); + await fs.writeFile( + path.join(outDir, `${result.case}.json`), + `${JSON.stringify(result, null, 2)}\n`, + ); + process.stdout.write( + `${result.pass ? 'PASS' : 'FAIL'} ${result.case}: ${result.description}\n`, + ); + } + + const errorCase = runTypedErrorCase(ctx); + await fs.writeFile( + path.join(outDir, `${errorCase.case}.json`), + `${JSON.stringify(errorCase, null, 2)}\n`, + ); + process.stdout.write( + `${errorCase.pass ? 'PASS' : 'FAIL'} ${errorCase.case}: ${errorCase.actualError}\n`, + ); + + // The premises the controller's guards rest on. Only testnet can settle + // them; simulated records them as skipped so a run without credentials never + // reads as having proven them. + const premises: PremiseEvidence[] = []; + for (const premiseCase of buildPremiseCases(ctx)) { + const result = await runPremiseCase({ premiseCase, runner, mode, symbol }); + premises.push(result); + await fs.writeFile( + path.join(outDir, `premise-${result.case}.json`), + `${JSON.stringify(result, null, 2)}\n`, + ); + let label = 'FAIL'; + if (result.outcome === 'skipped') { + label = 'SKIP'; + } else if (result.pass) { + label = 'PASS'; + } + process.stdout.write( + `${label} premise ${result.case}: ${result.premise}\n`, + ); + } + + const premisesSettled = premises.filter( + (premise) => premise.outcome !== 'skipped', + ); + // An acceptance run cannot claim a guard is justified while the premise it + // rests on is unsettled, so --require-premises turns a skip into a failure. + // Without it a credential-less simulated run still reports on everything else. + const premisesComplete = + !requirePremises || premisesSettled.length === premises.length; + const allPass = + results.every((result) => result.pass) && + errorCase.pass && + premisesComplete && + premisesSettled.every((premise) => premise.pass); + + if (!premisesComplete) { + process.stdout.write( + `\n--require-premises was set but ${premises.length - premisesSettled.length} premise(s) were skipped; only --mode=testnet can settle them.\n`, + ); + } + + await fs.writeFile( + path.join(outDir, 'summary.json'), + `${JSON.stringify({ mode, symbol, allPass, results, errorCase, premises }, null, 2)}\n`, + ); + await fs.writeFile( + path.join(outDir, 'summary.md'), + renderSummary({ mode, symbol, results, errorCase, premises }), + ); + + process.stdout.write( + `\n${allPass ? 'All cases passed' : 'Some cases FAILED'} — evidence written to ${outDir}\n`, + ); + + if (!allPass) { + process.exitCode = 1; + } +} + +main().catch((error: unknown) => { + process.stderr.write( + `${error instanceof Error ? error.stack : String(error)}\n`, + ); + process.exitCode = 1; +}); diff --git a/packages/perps-controller/tests/helpers/advancedOrders.ts b/packages/perps-controller/tests/helpers/advancedOrders.ts new file mode 100644 index 00000000000..5891fd17328 --- /dev/null +++ b/packages/perps-controller/tests/helpers/advancedOrders.ts @@ -0,0 +1,1031 @@ +/** + * Advanced order types — shared e2e contract matrix (TAT-3511). + * + * The case matrix, the exchange doubles, and the round-trip assertions live here + * so that exactly the same contract is exercised by: + * + * - `e2e/advanced-orders.e2e.ts` — the scripted run that writes evidence + * artifacts (simulated, or against HyperLiquid testnet). + * - `tests/src/e2e/advanced-orders.contract.test.ts` — the Jest guard that runs + * in CI, so the proven contract cannot silently regress. + */ +import { hasProperty } from '@metamask/utils'; + +import { PERPS_ERROR_CODES } from '../../src/perpsErrorCodes.js'; +import type { + FrontendOrder, + SDKOrderParams, +} from '../../src/types/hyperliquid-types.js'; +import type { Order, OrderParams } from '../../src/types/index.js'; +import type { TriggerOrderType } from '../../src/types/perps-types.js'; +import { + adaptOrderFromSDK, + adaptPositionTriggerOrderFromSDK, + formatHyperLiquidPrice, + formatHyperLiquidSize, +} from '../../src/utils/hyperLiquidAdapter.js'; +import { + buildOrdersArray, + calculateOrderPriceAndSize, +} from '../../src/utils/orderCalculations.js'; + +export type Mode = 'simulated' | 'testnet'; + +export type CaseExpectation = { + triggerOrderType?: TriggerOrderType; + triggerPrice?: string; + execution: 'market' | 'limit'; + reduceOnly: boolean; + /** Expected size of the resting order that carries the trigger */ + size: string; + /** Expected `isPartial` on the position-state view, when applicable */ + isPartial?: boolean; +}; + +export type E2ECase = { + name: string; + description: string; + params: OrderParams; + /** Which submitted order carries the behaviour under test (0 = main order) */ + orderIndex: number; + expected: CaseExpectation; +}; + +export type CaseEvidence = { + case: string; + description: string; + mode: Mode; + params: OrderParams; + submitted: { grouping: string; orders: SDKOrderParams[] }; + placedOrderIds: string[]; + readBack: Order | null; + positionTriggerView: ReturnType< + typeof adaptPositionTriggerOrderFromSDK + > | null; + cancelled: boolean; + openOrdersAfterCancel: string[]; + checks: { name: string; expected: unknown; actual: unknown; pass: boolean }[]; + pass: boolean; +}; + +/** + * Everything the matrix needs in order to describe an order without naming a + * number that only holds for one market at one moment. + * + * `mid` and `szDecimals` come from the venue on a testnet run; on the simulated + * run they describe the in-process double rather than any real market. Every + * price below is an offset from `mid` and every size a share of the order, so + * the same matrix holds whatever the asset is worth today. + */ +export type CaseContext = { + symbol: string; + /** + * The venue's index for this market. Read from its meta rather than assumed: + * index 0 is a different asset on testnet than on mainnet, and submitting + * against the wrong one silently trades the wrong market. + */ + assetId: number; + /** Live mid on testnet; the double's reference price when simulated. */ + mid: number; + /** Size precision, read from the venue's meta rather than assumed. */ + szDecimals: number; + /** USD per probe order. Raised when a partial slice would not be expressible. */ + notional: number; + /** Where a stop rests, as a percentage from mid. Negative sits below. */ + stopOffsetPct: number; + /** Where a take profit rests, as a percentage from mid. Positive sits above. */ + takeProfitOffsetPct: number; + /** How far a trigger's limit price sits from its own trigger, as a percentage. */ + limitSlipPct: number; + /** Share of the order a partial TP/SL covers. */ + partialFraction: number; +}; + +/** + * Build a case context, filling in the parts a caller does not pin. + * + * @param overrides - At minimum the market, its mid, and its size precision. + * @returns A fully resolved context. + */ +export function caseContext( + overrides: Pick & + Partial, +): CaseContext { + return { + notional: 11, + stopOffsetPct: -10, + takeProfitOffsetPct: 20, + limitSlipPct: 1, + partialFraction: 0.4, + ...overrides, + }; +} + +/** + * Resolve the order size for a context. + * + * A probe is sized from a USD notional so it stays small on any asset, but a + * notional that is small enough on a cheap asset can produce a size that + * disappears at an expensive one's precision — and a partial slice of it + * disappears sooner still. The size is therefore floored at the smallest whole + * order whose partial slice is expressible, rather than assuming any of it. + * + * @param ctx - Case context. + * @returns The order size, formatted to the asset's precision. + */ +function orderSizeFor(ctx: CaseContext): string { + const tick = 1 / 10 ** ctx.szDecimals; + const smallestSlice = Math.min(ctx.partialFraction, 1 - ctx.partialFraction); + const minWholeOrder = tick / Math.max(smallestSlice, Number.EPSILON); + // An order resting away from mid is worth less than the same size at mid, so + // sizing from mid alone puts the cheapest resting order under the venue's + // minimum value. Size against the furthest-below price the matrix uses. + const worstRestingPrice = + ctx.mid * (1 + Math.min(ctx.stopOffsetPct - ctx.limitSlipPct, 0) / 100); + const fromNotional = + ctx.notional / Math.max(worstRestingPrice, Number.EPSILON); + return formatHyperLiquidSize({ + size: Math.max(fromNotional, minWholeOrder), + szDecimals: ctx.szDecimals, + }); +} + +/** + * Builds the case matrix: one entry per advanced order type in scope. + * + * Every price is an offset from the context's mid and every size a share of the + * order, so the matrix describes the same contract whatever the asset is worth. + * Offsets are chosen so each order RESTS: a sell stop below mid, a sell take + * profit above it. A trigger whose condition is already met fires on submission + * and leaves nothing to read back. + * + * @param ctx - Case context. + * @returns The ordered case list. + */ +export function buildCases(ctx: CaseContext): E2ECase[] { + const size = orderSizeFor(ctx); + const priceAt = (pct: number): string => + formatHyperLiquidPrice({ + price: ctx.mid * (1 + pct / 100), + szDecimals: ctx.szDecimals, + }); + const shareOf = (fraction: number): string => + formatHyperLiquidSize({ + size: parseFloat(size) * fraction, + szDecimals: ctx.szDecimals, + }); + + const stopTrigger = priceAt(ctx.stopOffsetPct); + // The limit sits past the trigger, so a fired order still has room to fill. + const stopLimit = priceAt(ctx.stopOffsetPct - ctx.limitSlipPct); + const takeProfitTrigger = priceAt(ctx.takeProfitOffsetPct); + const takeProfitLimit = priceAt(ctx.takeProfitOffsetPct + ctx.limitSlipPct); + + const base = { symbol: ctx.symbol, size, currentPrice: ctx.mid }; + + return [ + { + name: 'stop_market', + description: 'Stop market: rests until the trigger, then takes liquidity', + params: { + ...base, + isBuy: false, + orderType: 'stop_market', + triggerPrice: stopTrigger, + }, + orderIndex: 0, + expected: { + triggerOrderType: 'stop_market', + triggerPrice: stopTrigger, + execution: 'market', + reduceOnly: false, + size, + }, + }, + { + name: 'stop_limit', + description: + 'Stop limit: rests until the trigger, then posts a limit order', + params: { + ...base, + isBuy: false, + orderType: 'stop_limit', + triggerPrice: stopTrigger, + price: stopLimit, + }, + orderIndex: 0, + expected: { + triggerOrderType: 'stop_limit', + triggerPrice: stopTrigger, + execution: 'limit', + reduceOnly: false, + size, + }, + }, + { + name: 'take_profit_market', + description: + 'Take profit market: fires above the mark and takes liquidity', + params: { + ...base, + isBuy: false, + orderType: 'take_profit_market', + triggerPrice: takeProfitTrigger, + }, + orderIndex: 0, + expected: { + triggerOrderType: 'take_profit_market', + triggerPrice: takeProfitTrigger, + execution: 'market', + reduceOnly: false, + size, + }, + }, + { + name: 'take_profit_limit', + description: + 'Take profit limit: fires above the mark and posts a limit order', + params: { + ...base, + isBuy: false, + orderType: 'take_profit_limit', + triggerPrice: takeProfitTrigger, + price: takeProfitLimit, + }, + orderIndex: 0, + expected: { + triggerOrderType: 'take_profit_limit', + triggerPrice: takeProfitTrigger, + execution: 'limit', + reduceOnly: false, + size, + }, + }, + { + name: 'reduce_only', + description: + 'Reduce-only as a first-class placement flag on a trigger order', + params: { + ...base, + isBuy: false, + orderType: 'stop_market', + triggerPrice: stopTrigger, + reduceOnly: true, + }, + orderIndex: 0, + expected: { + triggerOrderType: 'stop_market', + triggerPrice: stopTrigger, + execution: 'market', + reduceOnly: true, + size, + isPartial: false, + }, + }, + { + name: 'partial_take_profit', + description: + 'Partial TP/SL: quantity-scoped take profit attached to the order', + params: { + ...base, + isBuy: true, + orderType: 'market', + takeProfitPrice: takeProfitTrigger, + takeProfitSize: shareOf(ctx.partialFraction), + stopLossPrice: stopTrigger, + stopLossSize: shareOf(1 - ctx.partialFraction), + }, + // Index 1 is the attached take profit child + orderIndex: 1, + expected: { + triggerOrderType: 'take_profit_limit', + triggerPrice: takeProfitTrigger, + execution: 'limit', + reduceOnly: true, + size: shareOf(ctx.partialFraction), + isPartial: true, + }, + }, + ]; +} + +/** + * Maps a submitted SDK order onto the HyperLiquid `frontendOpenOrders` shape, + * mirroring how the exchange echoes a resting order back. + * + * @param params - Rendering parameters. + * @param params.order - Submitted SDK order. + * @param params.oid - Order ID assigned by the exchange. + * @param params.symbol - Market symbol. + * @returns The rendered frontend order. + */ +function renderRestingOrder(params: { + order: SDKOrderParams; + oid: number; + symbol: string; +}): FrontendOrder { + const { order, oid, symbol } = params; + // `hasProperty` narrows the key, not the value: the SDK's order-type union + // leaves it `unknown`, so shape the two variants explicitly. + const orderTypeField = order.t as { + trigger?: { tpsl: 'tp' | 'sl'; isMarket: boolean; triggerPx: string }; + limit?: { tif: string }; + }; + const trigger = hasProperty(order.t, 'trigger') + ? orderTypeField.trigger + : undefined; + + let orderType = + hasProperty(order.t, 'limit') && orderTypeField.limit?.tif === 'Gtc' + ? 'Limit' + : 'Market'; + if (trigger) { + const direction = trigger.tpsl === 'tp' ? 'Take Profit' : 'Stop'; + orderType = `${direction} ${trigger.isMarket ? 'Market' : 'Limit'}`; + } + + return { + coin: symbol, + side: order.b ? 'B' : 'A', + limitPx: order.p, + sz: order.s, + origSz: order.s, + oid, + timestamp: 1_700_000_000_000, + triggerCondition: trigger + ? `Price ${trigger.tpsl === 'tp' ? 'above' : 'below'} ${trigger.triggerPx}` + : 'N/A', + isTrigger: Boolean(trigger), + triggerPx: trigger?.triggerPx ?? '', + children: [], + isPositionTpsl: false, + reduceOnly: order.r, + orderType, + } as unknown as FrontendOrder; +} + +/** + * Minimal transport contract shared by the simulated and testnet runners. + */ +export type ExchangeRunner = { + submit(params: { + orders: SDKOrderParams[]; + grouping: 'na' | 'normalTpsl' | 'positionTpsl'; + symbol: string; + }): Promise; + openOrders(symbol: string): Promise; + cancel(params: { orderIds: string[]; symbol: string }): Promise; + /** + * Close any position the case left behind. + * + * Cancelling resting orders is not enough to leave the account as the case + * found it: a case whose parent is a market order fills, so it has no resting + * id to cancel and opens real exposure instead. The venue premises run later + * in the same session and at least one of them reasons about the position, so + * leftover exposure would let a premise pass or fail for the wrong reason. + * + * Optional because the simulated double has no positions to close. + */ + flatten?(symbol: string): Promise; +}; + +/** + * Creates the in-process HyperLiquid double. + * + * @returns A runner backed by an in-memory order book of resting orders. + */ +export function createSimulatedRunner(): ExchangeRunner { + let nextOid = 1000; + const resting = new Map(); + + return { + submit: async ({ orders, symbol }): Promise => { + const ids: string[] = []; + for (const order of orders) { + nextOid += 1; + const rendered = renderRestingOrder({ order, oid: nextOid, symbol }); + resting.set(String(nextOid), rendered); + ids.push(String(nextOid)); + } + return ids; + }, + openOrders: async (symbol): Promise => + Array.from(resting.values()).filter((order) => order.coin === symbol), + cancel: async ({ orderIds }): Promise => { + for (const orderId of orderIds) { + resting.delete(orderId); + } + }, + }; +} + +/** + * Builds the exchange payload for a case using the production mapping. + * + * @param testCase - Case under test. + * @param ctx - Case context, supplying the market index and precision. + * @returns The submitted orders and grouping. + */ +export function buildSubmission( + testCase: E2ECase, + ctx: CaseContext, +): { + orders: SDKOrderParams[]; + grouping: 'na' | 'normalTpsl' | 'positionTpsl'; +} { + const { params } = testCase; + + const { formattedSize, formattedPrice } = calculateOrderPriceAndSize({ + orderType: params.orderType, + isBuy: params.isBuy, + finalPositionSize: parseFloat(params.size), + currentPrice: params.currentPrice ?? ctx.mid, + limitPrice: params.price, + triggerPrice: params.triggerPrice, + szDecimals: ctx.szDecimals, + }); + + return buildOrdersArray({ + assetId: ctx.assetId, + isBuy: params.isBuy, + formattedPrice, + formattedSize, + reduceOnly: params.reduceOnly ?? false, + orderType: params.orderType, + triggerPrice: params.triggerPrice, + takeProfitPrice: params.takeProfitPrice, + stopLossPrice: params.stopLossPrice, + takeProfitSize: params.takeProfitSize, + stopLossSize: params.stopLossSize, + szDecimals: ctx.szDecimals, + grouping: params.grouping, + }); +} + +/** + * Compare two exchange-formatted numbers by value. + * + * A venue may echo a price back in a different but equivalent spelling — + * `57650.0` for the `57650` that was submitted — so comparing the strings + * fails on values that are in fact identical. The simulated double echoes our + * own string back, so only a real venue shows this. + * + * @param actual - Value read back from the venue. + * @param expected - Value that was submitted. + * @returns True when both parse to the same number. + */ +function sameNumber(actual?: string, expected?: string): boolean { + if (actual === undefined || expected === undefined) { + return actual === expected; + } + const left = parseFloat(actual); + const right = parseFloat(expected); + return Number.isFinite(left) && Number.isFinite(right) + ? left === right + : actual === expected; +} + +/** + * Runs a single case end to end and collects its evidence. + * + * @param params - Run parameters. + * @param params.testCase - Case under test. + * @param params.runner - Exchange runner. + * @param params.mode - Run mode, recorded in the evidence. + * @param params.ctx - Case context, carrying the market and its precision. + * @returns The case evidence. + */ +export async function runCase(params: { + testCase: E2ECase; + runner: ExchangeRunner; + mode: Mode; + ctx: CaseContext; +}): Promise { + const { testCase, runner, mode, ctx } = params; + const { symbol } = ctx; + const { expected } = testCase; + + const submitted = buildSubmission(testCase, ctx); + const placedOrderIds = await runner.submit({ + orders: submitted.orders, + grouping: submitted.grouping, + symbol, + }); + + const targetOrderId = placedOrderIds[testCase.orderIndex]; + const openOrders = await runner.openOrders(symbol); + const rawOrder = openOrders.find( + (order) => String(order.oid) === targetOrderId, + ); + const readBack = rawOrder ? adaptOrderFromSDK(rawOrder) : null; + const positionTriggerView = rawOrder + ? (adaptPositionTriggerOrderFromSDK({ + rawOrder, + positionSize: testCase.params.size, + }) ?? null) + : null; + + const checks: CaseEvidence['checks'] = [ + { + name: 'order is visible in open orders', + expected: true, + actual: Boolean(readBack), + pass: Boolean(readBack), + }, + { + name: 'placement type round-trips', + expected: expected.triggerOrderType, + actual: readBack?.triggerOrderType, + pass: readBack?.triggerOrderType === expected.triggerOrderType, + }, + { + name: 'trigger price round-trips', + expected: expected.triggerPrice, + actual: readBack?.triggerPrice, + pass: sameNumber(readBack?.triggerPrice, expected.triggerPrice), + }, + { + name: 'execution mode round-trips', + expected: expected.execution, + actual: readBack?.orderType, + pass: readBack?.orderType === expected.execution, + }, + { + name: 'reduce-only flag round-trips', + expected: expected.reduceOnly, + actual: readBack?.reduceOnly, + pass: Boolean(readBack?.reduceOnly) === expected.reduceOnly, + }, + { + name: 'quantity round-trips', + expected: expected.size, + actual: readBack?.size, + pass: sameNumber(readBack?.size, expected.size), + }, + ]; + + if (expected.isPartial !== undefined) { + checks.push({ + name: 'partial quantity is represented on the position view', + expected: expected.isPartial, + actual: positionTriggerView?.isPartial, + pass: positionTriggerView?.isPartial === expected.isPartial, + }); + } + + await runner.cancel({ orderIds: placedOrderIds, symbol }); + // Leave the account flat, not merely order-free — see ExchangeRunner.flatten. + await runner.flatten?.(symbol); + const remaining = await runner.openOrders(symbol); + const openOrdersAfterCancel = remaining.map((order) => String(order.oid)); + const cancelled = placedOrderIds.every( + (orderId) => !openOrdersAfterCancel.includes(orderId), + ); + + checks.push({ + name: 'order is gone after cancel', + expected: [], + actual: placedOrderIds.filter((orderId) => + openOrdersAfterCancel.includes(orderId), + ), + pass: cancelled, + }); + + return { + case: testCase.name, + description: testCase.description, + mode, + params: testCase.params, + submitted, + placedOrderIds, + readBack, + positionTriggerView, + cancelled, + openOrdersAfterCancel, + checks, + pass: checks.every((check) => check.pass), + }; +} + +/** + * Proves that an invalid trigger placement fails with a typed error rather than + * silently placing something else. + * + * @param ctx - Case context, so the invalid order is described like any other. + * @returns Evidence for the error path. + */ +export function runTypedErrorCase(ctx: CaseContext): { + case: string; + expectedError: string; + actualError: string; + pass: boolean; +} { + let actualError = 'no error thrown'; + const size = orderSizeFor(ctx); + try { + buildSubmission( + { + name: 'missing_trigger_price', + description: 'trigger placement without a trigger price', + params: { + symbol: ctx.symbol, + isBuy: false, + size, + orderType: 'stop_market', + currentPrice: ctx.mid, + }, + orderIndex: 0, + expected: { execution: 'market', reduceOnly: false, size }, + }, + ctx, + ); + } catch (error) { + actualError = error instanceof Error ? error.message : String(error); + } + + return { + case: 'typed_error_missing_trigger_price', + expectedError: PERPS_ERROR_CODES.ORDER_TRIGGER_PRICE_REQUIRED, + actualError, + pass: actualError === PERPS_ERROR_CODES.ORDER_TRIGGER_PRICE_REQUIRED, + }; +} + +// --- Exchange premises --------------------------------------------------- +// +// The cases above prove what the controller DOES. These prove why it must: +// each one is a claim about how HyperLiquid behaves that a guard in the +// controller depends on. Those claims were asserted during review and never +// executed, so every guard below rests on an assumption until this matrix runs +// against testnet. +// +// They deliberately hand-build the SDK payload instead of going through +// `buildOrdersArray` / `validateOrderParams`. That is the whole point: the +// controller now refuses these shapes, so the only way to ask the exchange what +// it would have done is to bypass our own builders and submit directly. +// +// The simulated runner renders our own payload back as resting orders and can +// never reject anything, so a premise cannot be proven there. It is recorded as +// skipped rather than passing vacuously. + +export type PremiseOutcome = 'rejected' | 'accepted' | 'skipped'; + +export type PremiseCase = { + name: string; + /** The claim about the venue, in words. */ + premise: string; + /** The controller guard that rests on it. */ + justifies: string; + /** Hand-built payload, deliberately bypassing the production builders. */ + build: (symbol: string) => { + orders: SDKOrderParams[]; + grouping: 'na' | 'normalTpsl' | 'positionTpsl'; + }; + /** What the venue is claimed to do with that payload. */ + expect: Exclude; + /** + * Optional follow-up for a premise that is about what happens AFTER the + * payload is accepted — a zero-size trigger covering the whole position, or + * orphaned children outliving a cancelled parent. + */ + verify?: (params: { + runner: ExchangeRunner; + symbol: string; + placedOrderIds: string[]; + }) => Promise<{ pass: boolean; detail: unknown }>; +}; + +export type PremiseEvidence = { + case: string; + premise: string; + justifies: string; + mode: Mode; + expected: PremiseOutcome; + outcome: PremiseOutcome; + submitted: { + grouping: string; + orders: SDKOrderParams[]; + } | null; + exchangeError: string | null; + verification: { pass: boolean; detail: unknown } | null; + pass: boolean; + note: string | null; +}; + +/** + * A resting trigger order in the SDK's submitted shape. + * + * @param params - Order fields. + * @param params.assetId - The venue's index for the market. + * @param params.price - Limit/cap price submitted as `p`. + * @param params.size - Size submitted as `s`. + * @param params.triggerPx - Trigger price. + * @param params.isMarket - Whether it executes as market once triggered. + * @param params.tpsl - Trigger direction. + * @returns The SDK order params. + */ +function triggerOrder(params: { + assetId: number; + price: string; + size: string; + triggerPx: string; + isMarket: boolean; + tpsl: 'tp' | 'sl'; +}): SDKOrderParams { + return { + a: params.assetId, + b: false, + p: params.price, + s: params.size, + r: true, + t: { + trigger: { + isMarket: params.isMarket, + triggerPx: params.triggerPx, + tpsl: params.tpsl, + }, + }, + }; +} + +/** + * An ordinary resting limit order in the SDK's submitted shape. + * + * @param params - Order fields. + * @param params.assetId - The venue's index for the market. + * @param params.price - Resting price. + * @param params.size - Order size. + * @returns The SDK order params. + */ +function plainLimitOrder(params: { + assetId: number; + price: string; + size: string; +}): SDKOrderParams { + return { + a: params.assetId, + b: true, + p: params.price, + s: params.size, + r: false, + t: { limit: { tif: 'Gtc' } }, + }; +} + +/** + * Builds the premise matrix: one entry per claim a controller guard rests on. + * + * @param ctx - Case context; prices and sizes are derived from it. + * @returns The ordered premise list. + */ +export function buildPremiseCases(ctx: CaseContext): PremiseCase[] { + const size = orderSizeFor(ctx); + const priceAt = (pct: number): string => + formatHyperLiquidPrice({ + price: ctx.mid * (1 + pct / 100), + szDecimals: ctx.szDecimals, + }); + // A premise probe must also rest rather than fire on submission, so it uses + // the same resting offsets as the placement matrix. + const restingStop = priceAt(ctx.stopOffsetPct); + const restingTakeProfit = priceAt(ctx.takeProfitOffsetPct); + + return [ + { + name: 'position_tpsl_batch_with_plain_parent', + premise: + 'HyperLiquid rejects a positionTpsl batch that contains a non-trigger order.', + justifies: + 'validateOrderParams refuses tpslLinkage=position on an order placement (ORDER_TPSL_POSITION_LINKAGE_UNSUPPORTED).', + expect: 'rejected', + build: () => ({ + grouping: 'positionTpsl', + orders: [ + plainLimitOrder({ assetId: ctx.assetId, price: restingStop, size }), + triggerOrder({ + assetId: ctx.assetId, + price: restingTakeProfit, + size, + triggerPx: restingTakeProfit, + isMarket: false, + tpsl: 'tp', + }), + ], + }), + }, + { + name: 'zero_trigger_price', + premise: 'HyperLiquid rejects a trigger order whose triggerPx is 0.', + justifies: + 'validateOrderPrecision refuses a trigger price that rounds away at the asset precision (ORDER_TRIGGER_PRICE_POSITIVE).', + expect: 'rejected', + build: () => ({ + grouping: 'na', + orders: [ + triggerOrder({ + assetId: ctx.assetId, + price: restingStop, + size, + triggerPx: '0', + isMarket: false, + tpsl: 'sl', + }), + ], + }), + }, + { + name: 'zero_cap_price_on_market_trigger', + premise: + 'HyperLiquid rejects a market-on-trigger order submitted with p = 0.', + justifies: + 'adaptOrderToSDK derives a slippage cap from the trigger price instead of emitting p: "0".', + expect: 'rejected', + build: () => ({ + grouping: 'na', + orders: [ + triggerOrder({ + assetId: ctx.assetId, + price: '0', + size, + triggerPx: restingStop, + isMarket: true, + tpsl: 'sl', + }), + ], + }), + }, + { + name: 'zero_size_trigger_is_whole_position', + premise: + 'HyperLiquid accepts a zero-size trigger and reads it as covering the whole position.', + justifies: + 'A partial TP/SL size that formats to "0" is refused, because submitting it would silently widen a partial close to the whole position (ORDER_TPSL_SIZE_INVALID).', + expect: 'accepted', + build: () => ({ + grouping: 'na', + orders: [ + triggerOrder({ + assetId: ctx.assetId, + price: restingTakeProfit, + size: '0', + triggerPx: restingTakeProfit, + isMarket: false, + tpsl: 'tp', + }), + ], + }), + verify: async ({ + runner, + symbol, + placedOrderIds, + }): Promise<{ pass: boolean; detail: unknown }> => { + const resting = await runner.openOrders(symbol); + const placed = resting.find((order) => + placedOrderIds.includes(String(order.oid)), + ); + // The venue reporting it as position-bound is the observable form of + // "covers the whole position"; a partial would carry its own size. + return { + pass: placed?.isPositionTpsl === true || placed?.sz === '0', + detail: placed + ? { + oid: placed.oid, + sz: placed.sz, + isPositionTpsl: placed.isPositionTpsl, + } + : { found: false }, + }; + }, + }, + { + name: 'na_grouping_orphans_children', + premise: + 'Children submitted under na grouping outlive their parent: cancelling the parent leaves them resting.', + justifies: + 'validateOrderParams refuses an attached TP/SL with no linkage (ORDER_TPSL_LINKAGE_REQUIRED), which would otherwise leave orphan reduce-only triggers behind.', + expect: 'accepted', + build: () => ({ + grouping: 'na', + orders: [ + plainLimitOrder({ assetId: ctx.assetId, price: restingStop, size }), + triggerOrder({ + assetId: ctx.assetId, + price: restingTakeProfit, + size, + triggerPx: restingTakeProfit, + isMarket: false, + tpsl: 'tp', + }), + ], + }), + verify: async ({ + runner, + symbol, + placedOrderIds, + }): Promise<{ pass: boolean; detail: unknown }> => { + const [parentId, ...childIds] = placedOrderIds; + await runner.cancel({ orderIds: [parentId], symbol }); + const resting = await runner.openOrders(symbol); + const survivors = childIds.filter((id) => + resting.some((order) => String(order.oid) === id), + ); + // Orphaned children are the failure this guard exists to prevent, so + // survival is what confirms the premise. + return { + pass: survivors.length === childIds.length, + detail: { parentId, childIds, survivors }, + }; + }, + }, + ]; +} + +/** + * Runs a single premise against the venue and collects its evidence. + * + * @param params - Run parameters. + * @param params.premiseCase - Premise under test. + * @param params.runner - Exchange runner. + * @param params.mode - Run mode; simulated cannot prove a premise. + * @param params.symbol - Market symbol. + * @returns The premise evidence. + */ +export async function runPremiseCase(params: { + premiseCase: PremiseCase; + runner: ExchangeRunner; + mode: Mode; + symbol: string; +}): Promise { + const { premiseCase, runner, mode, symbol } = params; + + const base = { + case: premiseCase.name, + premise: premiseCase.premise, + justifies: premiseCase.justifies, + mode, + expected: premiseCase.expect as PremiseOutcome, + }; + + if (mode !== 'testnet') { + return { + ...base, + outcome: 'skipped', + submitted: null, + exchangeError: null, + verification: null, + pass: false, + note: 'Skipped: the simulated exchange renders submitted payloads back and never rejects, so it cannot establish what the venue does.', + }; + } + + const submitted = premiseCase.build(symbol); + let placedOrderIds: string[] = []; + let exchangeError: string | null = null; + + try { + placedOrderIds = await runner.submit({ ...submitted, symbol }); + } catch (error) { + exchangeError = error instanceof Error ? error.message : String(error); + } + + const outcome: PremiseOutcome = + exchangeError === null ? 'accepted' : 'rejected'; + if (outcome !== premiseCase.expect) { + return { + ...base, + outcome, + submitted, + exchangeError, + verification: null, + pass: false, + note: + outcome === 'accepted' + ? 'The venue accepted a payload the guard assumes it rejects. The guard it justifies needs revisiting.' + : 'The venue rejected a payload the guard assumes it accepts. The guard it justifies needs revisiting.', + }; + } + + let verification: { pass: boolean; detail: unknown } | null = null; + if (premiseCase.verify && outcome === 'accepted') { + verification = await premiseCase.verify({ runner, symbol, placedOrderIds }); + } + + // Leave nothing resting: an accepted premise placed real orders. + if (placedOrderIds.length > 0) { + try { + await runner.cancel({ orderIds: placedOrderIds, symbol }); + } catch { + // Already cancelled by a verify step, or never rested. Not a proof failure. + } + } + + return { + ...base, + outcome, + submitted, + exchangeError, + verification, + pass: verification === null ? true : verification.pass, + note: null, + }; +} diff --git a/packages/perps-controller/tests/helpers/providerMocks.ts b/packages/perps-controller/tests/helpers/providerMocks.ts new file mode 100644 index 00000000000..397fb67212b --- /dev/null +++ b/packages/perps-controller/tests/helpers/providerMocks.ts @@ -0,0 +1,115 @@ +/* eslint-disable */ +/** + * Shared provider mocks for Perps tests + * Provides reusable mock implementations for HyperLiquidProvider and related interfaces + */ +import { type HyperLiquidProvider } from '@metamask/perps-controller'; + +export const createMockHyperLiquidProvider = + (): jest.Mocked => + ({ + protocolId: 'hyperliquid', + initialize: jest.fn(), + isReadyToTrade: jest.fn(), + toggleTestnet: jest.fn(), + getPositions: jest.fn(), + getAccountState: jest.fn(), + getHistoricalPortfolio: jest.fn().mockResolvedValue({ + totalBalance24hAgo: '10000', + totalBalance7dAgo: '9500', + totalBalance30dAgo: '9000', + }), + getMarkets: jest.fn(), + getOrderCapabilities: jest.fn().mockResolvedValue({ + status: 'ready', + providerId: 'hyperliquid', + supportedStrategies: ['twap', 'scale', 'chase'], + }), + placeOrder: jest.fn(), + editOrder: jest.fn(), + cancelOrder: jest.fn(), + cancelOrders: jest.fn(), + getTwapOrders: jest.fn().mockResolvedValue([]), + getChaseOrders: jest.fn().mockResolvedValue([]), + suspendChaseOrders: jest.fn().mockResolvedValue([]), + closePosition: jest.fn(), + closePositions: jest.fn(), + withdraw: jest.fn(), + getDepositRoutes: jest.fn(), + getWithdrawalRoutes: jest.fn(), + validateDeposit: jest.fn().mockResolvedValue({ isValid: true }), + validateOrder: jest.fn().mockResolvedValue({ isValid: true }), + validateClosePosition: jest.fn().mockResolvedValue({ isValid: true }), + validateWithdrawal: jest.fn().mockResolvedValue({ isValid: true }), + subscribeToPrices: jest.fn(), + subscribeToPositions: jest.fn(), + subscribeToOrderFills: jest.fn(), + setLiveDataConfig: jest.fn(), + disconnect: jest.fn(), + updatePositionTPSL: jest.fn(), + calculateLiquidationPrice: jest.fn(), + calculateMaintenanceMargin: jest.fn(), + getMaxLeverage: jest.fn(), + calculateFees: jest.fn(), + previewPositionModify: jest.fn(), + getMarketDataWithPrices: jest.fn(), + getBlockExplorerUrl: jest.fn(), + getOrderFills: jest.fn(), + getOrders: jest.fn(), + getFunding: jest.fn(), + getCurrentAccountId: jest + .fn() + .mockResolvedValue( + 'eip155:1:0x0000000000000000000000000000000000000001', + ), + getIsFirstTimeUser: jest.fn(), + getOpenOrders: jest.fn(), + subscribeToOrders: jest.fn(), + subscribeToAccount: jest.fn(), + setUserFeeDiscount: jest.fn(), + // WebSocket connection state methods + getWebSocketConnectionState: jest.fn(), + subscribeToConnectionState: jest.fn().mockReturnValue(() => undefined), + reconnect: jest.fn().mockResolvedValue(undefined), + }) as unknown as jest.Mocked; + +export const createMockOrder = (overrides = {}) => ({ + orderId: 'order-1', + symbol: 'BTC', + side: 'buy' as const, + orderType: 'limit' as const, + size: '0.1', + originalSize: '0.1', + price: '50000', + filledSize: '0', + remainingSize: '0.1', + status: 'open' as const, + timestamp: Date.now(), + ...overrides, +}); + +export const createMockPosition = (overrides = {}) => ({ + symbol: 'BTC', + size: '0.5', + entryPrice: '50000', + positionValue: '25000', + unrealizedPnl: '100', + marginUsed: '1000', + leverage: { type: 'cross' as const, value: 25 }, + liquidationPrice: '48000', + maxLeverage: 50, + returnOnEquity: '10', + cumulativeFunding: { + allTime: '0', + sinceOpen: '0', + sinceChange: '0', + }, + roi: '10', + takeProfitPrice: undefined, + stopLossPrice: undefined, + takeProfitCount: 0, + stopLossCount: 0, + marketPrice: '50200', + timestamp: Date.now(), + ...overrides, +}); diff --git a/packages/perps-controller/tests/helpers/serviceMocks.ts b/packages/perps-controller/tests/helpers/serviceMocks.ts new file mode 100644 index 00000000000..ee0b6d3af3a --- /dev/null +++ b/packages/perps-controller/tests/helpers/serviceMocks.ts @@ -0,0 +1,270 @@ +/* eslint-disable */ +/** + * Shared service mocks for Perps service tests + * Provides reusable mock implementations for ServiceContext and related types + */ + +import { + type ServiceContext, + type PerpsControllerState, + type InitializationState, + type PerpsControllerMessenger, + type PerpsPlatformDependencies, +} from '@metamask/perps-controller'; + +export type Deferred = { + promise: Promise; + resolve: (value: T | PromiseLike) => void; + reject: (reason?: unknown) => void; +}; + +/** + * Create a promise whose settlement is controlled by the test. + * + * @returns The promise and its resolve and reject callbacks. + */ +export const createDeferred = (): Deferred => { + let resolve!: Deferred['resolve']; + let reject!: Deferred['reject']; + const promise = new Promise((promiseResolve, promiseReject) => { + resolve = promiseResolve; + reject = promiseReject; + }); + return { promise, resolve, reject }; +}; + +/** + * Create a mock EVM account (KeyringAccount) + */ +export const createMockEvmAccount = () => ({ + id: '00000000-0000-0000-0000-000000000000', + address: '0x1234567890abcdef1234567890abcdef12345678' as `0x${string}`, + type: 'eip155:eoa' as const, + options: {}, + scopes: ['eip155:1'], + methods: ['eth_signTransaction', 'eth_sign'], + metadata: { + name: 'Test Account', + importTime: Date.now(), + keyring: { type: 'HD Key Tree' }, + }, +}); + +/** + * Create a mock PerpsPlatformDependencies instance. + * Returns a type-safe mock with jest.Mock functions for all methods. + * Uses `as unknown as jest.Mocked` pattern + * to ensure compatibility with both the interface contract and Jest mock APIs. + * + * Architecture: + * - Observability: logger, debugLogger, metrics, performance, tracer (stateless utilities) + * - Platform: streamManager (mobile/extension specific capabilities) + * - Controllers: consolidated access to all external controllers + */ +export const createMockInfrastructure = + (): jest.Mocked => + ({ + // === Observability (stateless utilities) === + logger: { + error: jest.fn(), + }, + debugLogger: { + log: jest.fn(), + }, + metrics: { + trackEvent: jest.fn(), + isEnabled: jest.fn(() => true), + trackPerpsEvent: jest.fn(), + }, + performance: { + now: jest.fn(() => Date.now()), + }, + tracer: { + trace: jest.fn(() => undefined), + endTrace: jest.fn(), + setMeasurement: jest.fn(), + addBreadcrumb: jest.fn(), + }, + + // === Platform Services === + streamManager: { + pauseChannel: jest.fn(), + resumeChannel: jest.fn(), + clearAllChannels: jest.fn(), + }, + + // === Feature Flags (platform-specific version gating) === + featureFlags: { + validateVersionGated: jest.fn().mockReturnValue(undefined), + }, + + // === Market Data Formatting === + marketDataFormatters: { + formatVolume: jest.fn((v: number) => `$${v.toFixed(0)}`), + formatPerpsFiat: jest.fn((v: number) => `$${v.toFixed(2)}`), + formatPercentage: jest.fn((p: number) => `${p.toFixed(2)}%`), + priceRangesUniversal: [], + }, + + // === Cache Invalidation === + cacheInvalidator: { + invalidate: jest.fn(), + invalidateAll: jest.fn(), + }, + + // === Terminal API === + terminalApiUrl: 'https://terminal.test-api.cx.metamask.io/v1/perpetuals', + + // === Rewards (DI — no RewardsController in Core yet) === + rewards: { + getPerpsDiscountForAccount: jest.fn().mockResolvedValue(0), + }, + + // === Disk Cache (cold-start persistence) === + diskCache: { + getItem: jest.fn().mockResolvedValue(null), + getItemSync: jest.fn().mockReturnValue(null), + setItem: jest.fn().mockResolvedValue(undefined), + removeItem: jest.fn().mockResolvedValue(undefined), + }, + }) as unknown as jest.Mocked; + +/** + * Create a mock PerpsControllerState + */ +export const createMockPerpsControllerState = ( + overrides: Partial = {}, +): PerpsControllerState => ({ + activeProvider: 'hyperliquid', + isTestnet: false, + initializationState: 'initialized' as InitializationState, + initializationError: null, + initializationAttempts: 0, + accountState: null, + perpsBalances: {}, + depositInProgress: false, + lastDepositTransactionId: null, + lastDepositResult: null, + withdrawInProgress: false, + lastWithdrawResult: null, + lastCompletedWithdrawalTimestamp: null, + lastCompletedWithdrawalTxHashes: [], + withdrawalRequests: [], + withdrawalProgress: { + progress: 0, + lastUpdated: 0, + activeWithdrawalId: null, + }, + depositRequests: [], + isEligible: true, + isFirstTimeUser: { + testnet: true, + mainnet: true, + }, + hasPlacedFirstOrder: { + testnet: false, + mainnet: false, + }, + watchlistMarkets: { + testnet: [], + mainnet: [], + }, + tradeConfigurations: { + testnet: {}, + mainnet: {}, + }, + marketFilterPreferences: { + optionId: 'volume', + direction: 'desc', + }, + lastError: null, + lastUpdateTimestamp: Date.now(), + hip3ConfigVersion: 0, + selectedPaymentToken: null, + cachedMarketDataByProvider: {}, + cachedUserDataByProvider: {}, + ...overrides, +}); + +/** + * Create a mock ServiceContext with optional overrides + * Note: infrastructure is no longer part of ServiceContext - it's now injected + * into service instances via constructor. + */ +export const createMockServiceContext = ( + overrides: Partial = {}, +): ServiceContext => ({ + tracingContext: { + provider: 'hyperliquid', + isTestnet: false, + }, + errorContext: { + controller: 'TestService', + method: 'testMethod', + }, + stateManager: { + update: jest.fn(), + getState: jest.fn(() => createMockPerpsControllerState()), + }, + ...overrides, +}); + +/** + * Create a mock PerpsControllerMessenger for testing inter-controller communication. + * The messenger.call() method should be configured in each test to return appropriate values. + * + * Common messenger actions used: + * - 'AccountTreeController:getAccountsFromSelectedAccountGroup' - returns array of accounts + * - 'KeyringController:signTypedMessage' - returns signature string + * - 'NetworkController:getState' - returns { selectedNetworkClientId: string } + * - 'NetworkController:getNetworkClientById' - returns { configuration: { chainId: string } } + * - 'AuthenticationController:getBearerToken' - returns bearer token string + * + * @param overrides - Optional partial messenger to override default behavior + */ +export const createMockMessenger = ( + overrides?: Partial, +): jest.Mocked => { + const mockEvmAccount = createMockEvmAccount(); + const base = { + call: jest.fn().mockImplementation((action: string) => { + // Default implementations for common actions + if ( + action === 'AccountTreeController:getAccountsFromSelectedAccountGroup' + ) { + return [mockEvmAccount]; + } + if (action === 'KeyringController:getState') { + return { isUnlocked: true }; + } + if (action === 'KeyringController:signTypedMessage') { + return Promise.resolve('0xSignatureResult'); + } + if (action === 'NetworkController:getState') { + return { selectedNetworkClientId: 'mainnet' }; + } + if (action === 'NetworkController:getNetworkClientById') { + return { configuration: { chainId: '0x1' } }; + } + if (action === 'AuthenticationController:getBearerToken') { + return Promise.resolve('mock-bearer-token'); + } + return undefined; + }), + publish: jest.fn(), + subscribe: jest.fn(), + unsubscribe: jest.fn(), + registerActionHandler: jest.fn(), + registerMethodActionHandlers: jest.fn(), + unregisterActionHandler: jest.fn(), + // Additional methods used by PerpsController + registerEventHandler: jest.fn(), + registerInitialEventPayload: jest.fn(), + unregisterEventHandler: jest.fn(), + clearEventSubscriptions: jest.fn(), + }; + return { + ...base, + ...overrides, + } as unknown as jest.Mocked; +}; diff --git a/packages/perps-controller/tests/placeholder.test.ts b/packages/perps-controller/tests/placeholder.test.ts new file mode 100644 index 00000000000..c497d3246ca --- /dev/null +++ b/packages/perps-controller/tests/placeholder.test.ts @@ -0,0 +1,18 @@ +// This is a placeholder test file. The real unit tests for PerpsController +// live in Mobile (source of truth). This file exists solely to satisfy +// Core's CI requirement that every package has at least one test. +// +// It lives in tests/ (not src/) so the Mobile sync script (which uses +// rsync --delete on src/) does not remove it. +// +// Remove this file when tests are migrated from Mobile to Core. + +// Satisfies import-x/unambiguous (file must be an ES module). +import type { PerpsControllerState } from '../src/index.js'; + +describe('PerpsController', () => { + it('exports PerpsControllerState type', () => { + const stub: PerpsControllerState | undefined = undefined; + expect(stub).toBeUndefined(); + }); +}); diff --git a/packages/perps-controller/tests/src/PerpsController.configuration.test.ts b/packages/perps-controller/tests/src/PerpsController.configuration.test.ts new file mode 100644 index 00000000000..595f5bfe8ff --- /dev/null +++ b/packages/perps-controller/tests/src/PerpsController.configuration.test.ts @@ -0,0 +1,1865 @@ +/* eslint-disable */ +/** + * PerpsController Tests + * Clean, focused test suite for PerpsController + */ + +/* eslint-disable @typescript-eslint/no-explicit-any */ + +import { + GasFeeEstimateLevel, + GasFeeEstimateType, +} from '@metamask/transaction-controller'; + +import { + createMockHyperLiquidProvider, + createMockPosition, +} from '../helpers/providerMocks.js'; +import { + createMockInfrastructure, + createMockMessenger, +} from '../helpers/serviceMocks.js'; + +jest.mock('@nktkas/hyperliquid', () => ({})); +jest.mock('@myx-trade/sdk', () => ({ + MyxClient: jest.fn(), + OrderStatusEnum: { Successful: 9 }, +})); + +import { + PERPS_EVENT_PROPERTY, + PERPS_EVENT_VALUE, +} from '../../src/constants/eventNames.js'; +import { + PERPS_CONSTANTS, + PERPS_DISK_CACHE_MARKETS, + PERPS_DISK_CACHE_USER_DATA, +} from '../../src/constants/perpsConfig.js'; +import { + PerpsController, + getDefaultPerpsControllerState, + InitializationState, + PerpsMode, + firstNonEmpty, + resolveMyxAuthConfig, +} from '../../src/PerpsController.js'; +import type { PerpsControllerState } from '../../src/PerpsController.js'; +import { PERPS_ERROR_CODES } from '../../src/perpsErrorCodes.js'; +import { HyperLiquidProvider } from '../../src/providers/HyperLiquidProvider.js'; +import type { + AccountState, + GetAvailableDexsParams, + PerpsProvider, + PerpsPlatformDependencies, + PerpsProviderType, + SubscribeAccountParams, +} from '../../src/types/index.js'; +import { PerpsAnalyticsEvent } from '../../src/types/index.js'; + +jest.mock('../../src/providers/HyperLiquidProvider'); +jest.mock('../../src/providers/MYXProvider'); + +// Mock transaction controller utility +const mockAddTransaction = jest.fn(); +jest.mock( + '../../../util/transaction-controller', + () => ({ + addTransaction: (...args: unknown[]) => mockAddTransaction(...args), + }), + { virtual: true }, +); + +// Mock wait utility to speed up retry tests +jest.mock('../../src/utils/wait', () => ({ + wait: jest.fn().mockResolvedValue(undefined), +})); + +// Mock stream manager +const mockStreamManager = { + positions: { pause: jest.fn(), resume: jest.fn() }, + account: { pause: jest.fn(), resume: jest.fn() }, + orders: { pause: jest.fn(), resume: jest.fn() }, + prices: { pause: jest.fn(), resume: jest.fn() }, + orderFills: { pause: jest.fn(), resume: jest.fn() }, +}; + +jest.mock( + '../../../components/UI/Perps/providers/PerpsStreamManager', + () => ({ + getStreamManagerInstance: jest.fn(() => mockStreamManager), + }), + { virtual: true }, +); + +jest.mock('@metamask/utils', () => ({ + ...jest.requireActual('@metamask/utils'), + formatAccountToCaipAccountId: jest + .fn() + .mockReturnValue('eip155:1:0x1234567890123456789012345678901234567890'), +})); + +// Mock EligibilityService as a class with instance methods +const mockEligibilityServiceInstance = { + checkEligibility: jest.fn().mockResolvedValue(true), +}; +jest.mock('../../src/services/EligibilityService', () => ({ + EligibilityService: jest + .fn() + .mockImplementation(() => mockEligibilityServiceInstance), +})); + +// Mock DepositService as a class with instance methods +const mockDepositServiceInstance = { + prepareTransaction: jest.fn(), +}; +jest.mock('../../src/services/DepositService', () => ({ + DepositService: jest + .fn() + .mockImplementation(() => mockDepositServiceInstance), +})); + +// Mock MarketDataService as a class with instance methods +const mockMarketDataServiceInstance = { + getPositions: jest.fn(), + getAccountState: jest.fn(), + getMarkets: jest.fn(), + getMarketDataWithPrices: jest + .fn() + .mockImplementation( + ({ + provider, + }: { + provider: { getMarketDataWithPrices: () => Promise }; + }) => provider.getMarketDataWithPrices(), + ), + getWithdrawalRoutes: jest.fn().mockReturnValue([]), + validateClosePosition: jest.fn().mockResolvedValue({ isValid: true }), + validateOrder: jest.fn(), + calculateMaintenanceMargin: jest.fn().mockResolvedValue(0), + calculateLiquidationPrice: jest.fn(), + getMaxLeverage: jest.fn(), + calculateFees: jest.fn().mockResolvedValue({ totalFee: 0 }), + previewPositionModify: jest.fn(), + getAvailableDexs: jest.fn().mockResolvedValue([]), + getBlockExplorerUrl: jest.fn(), + getOrderFills: jest.fn(), + getOrders: jest.fn(), + getFunding: jest.fn(), +}; +jest.mock('../../src/services/MarketDataService', () => ({ + MarketDataService: jest + .fn() + .mockImplementation(() => mockMarketDataServiceInstance), +})); + +// Mock TradingService as a class with instance methods +const mockTradingServiceInstance = { + placeOrder: jest.fn(), + editOrder: jest.fn(), + cancelOrder: jest.fn(), + cancelOrders: jest.fn(), + closePosition: jest.fn(), + closePositions: jest.fn(), + updatePositionTPSL: jest.fn(), + updateMargin: jest.fn(), + flipPosition: jest.fn(), + setControllerDependencies: jest.fn(), +}; +jest.mock('../../src/services/TradingService', () => ({ + TradingService: jest + .fn() + .mockImplementation(() => mockTradingServiceInstance), +})); + +// Mock AccountService as a class with instance methods +const mockAccountServiceInstance = { + withdraw: jest.fn(), + validateWithdrawal: jest.fn(), +}; +jest.mock('../../src/services/AccountService', () => ({ + AccountService: jest + .fn() + .mockImplementation(() => mockAccountServiceInstance), +})); + +// Mock DataLakeService as a class with instance methods +const mockDataLakeServiceInstance = { + reportOrder: jest.fn(), +}; +jest.mock('../../src/services/DataLakeService', () => ({ + DataLakeService: jest + .fn() + .mockImplementation(() => mockDataLakeServiceInstance), +})); + +// Mock FeatureFlagConfigurationService as a class with instance methods +const mockFeatureFlagConfigurationServiceInstance = { + refreshEligibility: jest.fn((options: any) => { + // Simulate the service's behavior: extract blocked regions from remote flags + const remoteFlags = + options.remoteFeatureFlagControllerState.remoteFeatureFlags; + const perpsGeoBlockedRegionsFeatureFlag = + remoteFlags?.perpsPerpTradingGeoBlockedCountriesV2; + const remoteBlockedRegions = + perpsGeoBlockedRegionsFeatureFlag?.blockedRegions; + + if ( + Array.isArray(remoteBlockedRegions) && + options.context.setBlockedRegionList + ) { + const currentList = options.context.getBlockedRegionList?.(); + // Never downgrade from remote to fallback + if (!currentList || currentList.source !== 'remote') { + options.context.setBlockedRegionList(remoteBlockedRegions, 'remote'); + } + } + + // Call refreshEligibility callback if available + if (options.context.refreshEligibility) { + options.context.refreshEligibility().catch(() => { + // Ignore errors in mock + }); + } + + // Also call refreshHip3Config if available + if (remoteFlags) { + mockFeatureFlagConfigurationServiceInstance.refreshHip3Config(options); + } + }), + refreshHip3Config: jest.fn(), + setBlockedRegions: jest.fn((options: any) => { + // Simulate setBlockedRegions behavior + const { list, source, context } = options; + if (context.setBlockedRegionList && context.getBlockedRegionList) { + const currentList = context.getBlockedRegionList(); + // Never downgrade from remote to fallback + if (source === 'fallback' && currentList.source === 'remote') { + return; + } + if (Array.isArray(list)) { + context.setBlockedRegionList(list, source); + } + } + + // Call refreshEligibility callback if available + if (context.refreshEligibility) { + context.refreshEligibility().catch(() => { + // Ignore errors in mock + }); + } + }), +}; +jest.mock('../../src/services/FeatureFlagConfigurationService', () => ({ + FeatureFlagConfigurationService: jest + .fn() + .mockImplementation(() => mockFeatureFlagConfigurationServiceInstance), +})); + +/** + * Testable version of PerpsController that exposes protected methods for testing. + * This follows the pattern used in RewardsController.test.ts + */ +class TestablePerpsController extends PerpsController { + /** + * Test-only method to update state directly. + * Exposed for scenarios where state needs to be manipulated + * outside the normal public API (e.g., testing error conditions). + * @param callback + */ + public testUpdate(callback: (state: PerpsControllerState) => void) { + this.update(callback); + } + + /** + * Test-only method to mark controller as initialized. + * Common test scenario that requires internal state changes. + */ + public testMarkInitialized() { + this.isInitialized = true; + this.update((state) => { + state.initializationState = InitializationState.Initialized; + }); + } + + /** + * Test-only method to set the providers map with complete providers. + * Used in most tests to inject mock providers. + * Also sets activeProviderInstance to the first provider (default provider). + * @param providers + */ + public testSetProviders(providers: Map) { + this.providers = providers; + // Set activeProviderInstance to the first provider (typically 'hyperliquid') + const firstProvider = providers.values().next().value; + if (firstProvider) { + this.activeProviderInstance = firstProvider; + } + } + + /** + * Test-only method to set the providers map with partial providers. + * Used explicitly in tests that verify error handling with incomplete providers. + * Type cast is intentional and necessary for testing graceful degradation. + * @param providers + */ + public testSetPartialProviders( + providers: Map>, + ) { + this.providers = providers as Map; + } + + /** + * Test-only method to get the providers map. + * Used to verify provider state in tests. + */ + public testGetProviders(): Map { + return this.providers; + } + + /** + * Test-only method to set initialization state. + * Allows tests to simulate both initialized and uninitialized states. + * @param value + */ + public testSetInitialized(value: boolean) { + this.isInitialized = value; + } + + /** + * Test-only method to get initialization state. + * Used to verify initialization status in tests. + */ + public testGetInitialized(): boolean { + return this.isInitialized; + } + + /** + * Test-only method to get blocked region list. + * Used to verify geo-blocking configuration in tests. + */ + public testGetBlockedRegionList(): { source: string; list: string[] } { + return this.blockedRegionList; + } + + /** + * Test-only method to set blocked region list. + * Used to test priority logic (remote vs fallback). + * @param list + * @param source + */ + public testSetBlockedRegionList( + list: string[], + source: 'remote' | 'fallback', + ) { + this.setBlockedRegionList(list, source); + } + + /** + * Test accessor for protected method refreshEligibilityOnFeatureFlagChange. + * Wrapper is necessary because protected methods can't be called from test code. + * @param remoteFlags + */ + public testRefreshEligibilityOnFeatureFlagChange(remoteFlags: any) { + this.refreshEligibilityOnFeatureFlagChange(remoteFlags); + } + + /** + * Test accessor for protected method reportOrderToDataLake. + * Wrapper is necessary because protected methods can't be called from test code. + * @param data + */ + public testReportOrderToDataLake(data: any): Promise { + return this.reportOrderToDataLake(data); + } + + public testHasStandaloneProvider(): boolean { + return this.hasStandaloneProvider(); + } + + public testRegisterMYXProvider( + MYXProvider: new (opts: Record) => PerpsProvider, + ) { + this.registerMYXProvider(MYXProvider as never); + } + + public testHandleMYXImportError(error: unknown) { + this.handleMYXImportError(error); + } +} + +describe('PerpsController', () => { + let controller: TestablePerpsController; + let mockProvider: jest.Mocked; + let mockInfrastructure: jest.Mocked; + + // Helper to mark controller as initialized for tests + const markControllerAsInitialized = () => { + controller.testMarkInitialized(); + }; + + beforeEach(() => { + jest.clearAllMocks(); + + ( + jest.requireMock('../../src/services/EligibilityService') + .EligibilityService as jest.Mock + ).mockImplementation(() => mockEligibilityServiceInstance); + ( + jest.requireMock('../../src/services/DepositService') + .DepositService as jest.Mock + ).mockImplementation(() => mockDepositServiceInstance); + ( + jest.requireMock('../../src/services/MarketDataService') + .MarketDataService as jest.Mock + ).mockImplementation(() => mockMarketDataServiceInstance); + ( + jest.requireMock('../../src/services/TradingService') + .TradingService as jest.Mock + ).mockImplementation(() => mockTradingServiceInstance); + ( + jest.requireMock('../../src/services/AccountService') + .AccountService as jest.Mock + ).mockImplementation(() => mockAccountServiceInstance); + ( + jest.requireMock('../../src/services/DataLakeService') + .DataLakeService as jest.Mock + ).mockImplementation(() => mockDataLakeServiceInstance); + ( + jest.requireMock('../../src/services/FeatureFlagConfigurationService') + .FeatureFlagConfigurationService as jest.Mock + ).mockImplementation(() => mockFeatureFlagConfigurationServiceInstance); + + mockEligibilityServiceInstance.checkEligibility.mockResolvedValue(true); + mockMarketDataServiceInstance.getPositions.mockResolvedValue([]); + mockMarketDataServiceInstance.getAccountState.mockResolvedValue({ + spendableBalance: '10000', + withdrawableBalance: '10000', + totalBalance: '10000', + marginUsed: '0', + unrealizedPnl: '0', + returnOnEquity: '0', + }); + mockMarketDataServiceInstance.getMarkets.mockResolvedValue([]); + mockMarketDataServiceInstance.getMarketDataWithPrices.mockImplementation( + ({ + provider, + }: { + provider: { getMarketDataWithPrices: () => Promise }; + }) => provider.getMarketDataWithPrices(), + ); + mockMarketDataServiceInstance.getWithdrawalRoutes.mockReturnValue([]); + mockMarketDataServiceInstance.validateClosePosition.mockResolvedValue({ + isValid: true, + }); + mockMarketDataServiceInstance.calculateMaintenanceMargin.mockResolvedValue( + 0, + ); + mockMarketDataServiceInstance.calculateFees.mockResolvedValue({ + totalFee: 0, + }); + mockMarketDataServiceInstance.getAvailableDexs.mockResolvedValue([]); + + mockFeatureFlagConfigurationServiceInstance.refreshEligibility.mockImplementation( + (options: any) => { + const remoteFlags = + options.remoteFeatureFlagControllerState.remoteFeatureFlags; + const perpsGeoBlockedRegionsFeatureFlag = + remoteFlags?.perpsPerpTradingGeoBlockedCountriesV2; + const remoteBlockedRegions = + perpsGeoBlockedRegionsFeatureFlag?.blockedRegions; + + if ( + Array.isArray(remoteBlockedRegions) && + options.context.setBlockedRegionList + ) { + const currentList = options.context.getBlockedRegionList?.(); + if (!currentList || currentList.source !== 'remote') { + options.context.setBlockedRegionList( + remoteBlockedRegions, + 'remote', + ); + } + } + + if (options.context.refreshEligibility) { + options.context.refreshEligibility().catch(() => { + // Ignore errors in mock + }); + } + + if (remoteFlags) { + mockFeatureFlagConfigurationServiceInstance.refreshHip3Config( + options, + ); + } + }, + ); + mockFeatureFlagConfigurationServiceInstance.setBlockedRegions.mockImplementation( + (options: any) => { + const { list, source, context } = options; + if (context.setBlockedRegionList && context.getBlockedRegionList) { + const currentList = context.getBlockedRegionList(); + if (source === 'fallback' && currentList.source === 'remote') { + return; + } + if (Array.isArray(list)) { + context.setBlockedRegionList(list, source); + } + } + + if (context.refreshEligibility) { + context.refreshEligibility().catch(() => { + // Ignore errors in mock + }); + } + }, + ); + mockFeatureFlagConfigurationServiceInstance.refreshHip3Config.mockImplementation( + () => undefined, + ); + + // Create a fresh mock provider for each test + mockProvider = createMockHyperLiquidProvider(); + + // Add default mock return values for all provider methods + mockProvider.getPositions.mockResolvedValue([]); + mockProvider.getAccountState.mockResolvedValue({ + spendableBalance: '10000', + withdrawableBalance: '10000', + totalBalance: '10000', + marginUsed: '0', + unrealizedPnl: '0', + returnOnEquity: '0', + }); + mockProvider.getMarkets.mockResolvedValue([]); + mockProvider.getOpenOrders.mockResolvedValue([]); + mockProvider.getFunding.mockResolvedValue([]); + mockProvider.getOrderFills.mockResolvedValue([]); + mockProvider.getOrders.mockResolvedValue([]); + mockProvider.calculateLiquidationPrice.mockResolvedValue('0'); + mockProvider.getMaxLeverage.mockResolvedValue(50); + mockProvider.calculateMaintenanceMargin.mockResolvedValue(0); + mockProvider.calculateFees.mockResolvedValue({ feeAmount: 0 }); + mockProvider.getBlockExplorerUrl.mockReturnValue( + 'https://explorer.example.com', + ); + mockProvider.getWithdrawalRoutes.mockReturnValue([]); + + ( + HyperLiquidProvider as jest.MockedClass + ).mockImplementation(() => mockProvider); + + const mockCall = jest.fn().mockImplementation((action: string) => { + if (action === 'RemoteFeatureFlagController:getState') { + return { + remoteFeatureFlags: { + perpsPerpTradingGeoBlockedCountriesV2: { + blockedRegions: [], + }, + }, + }; + } + if ( + action === 'AccountTreeController:getAccountsFromSelectedAccountGroup' + ) { + return [ + { + address: '0x1234567890123456789012345678901234567890', + type: 'eip155:eoa', + id: 'account-1', + options: {}, + scopes: ['eip155:1'], + methods: [], + metadata: { + name: 'Test', + importTime: 0, + keyring: { type: 'HD Key Tree' }, + }, + }, + ]; + } + return undefined; + }); + + mockInfrastructure = createMockInfrastructure(); + controller = new TestablePerpsController({ + messenger: createMockMessenger({ call: mockCall }), + state: getDefaultPerpsControllerState(), + infrastructure: mockInfrastructure, + }); + }); + + afterEach(() => { + // Clear only provider mocks, not Engine.context mocks + // This prevents breaking Engine.context.RewardsController/NetworkController references + if (mockProvider) { + Object.values(mockProvider).forEach((value) => { + if ( + typeof value === 'function' && + value !== null && + 'mockClear' in value + ) { + (value as jest.Mock).mockClear(); + } + }); + } + (mockInfrastructure.metrics.trackPerpsEvent as jest.Mock).mockClear(); + (mockInfrastructure.logger.error as jest.Mock).mockClear(); + (mockInfrastructure.debugLogger.log as jest.Mock).mockClear(); + }); + describe('toggleTestnet', () => { + it('returns error when already reinitializing', async () => { + await controller.init(); + jest.spyOn(controller, 'isCurrentlyReinitializing').mockReturnValue(true); + + const result = await controller.toggleTestnet(); + + expect(result.success).toBe(false); + expect(result.error).toBe(PERPS_ERROR_CODES.CLIENT_REINITIALIZING); + expect(result.isTestnet).toBe(false); + }); + + it('toggles to testnet network', async () => { + await controller.init(); + const initialTestnetState = controller.state.isTestnet; + + const result = await controller.toggleTestnet(); + + expect(result.success).toBe(true); + expect(result.isTestnet).toBe(!initialTestnetState); + expect(controller.state.isTestnet).toBe(!initialTestnetState); + }); + + it('returns failure and rolls back isTestnet when init sets InitializationState.Failed', async () => { + await controller.init(); + const initialTestnetState = controller.state.isTestnet; + + mockProvider.disconnect.mockRejectedValue( + new Error('Network toggle init failed'), + ); + + const result = await controller.toggleTestnet(); + + expect(result.success).toBe(false); + expect(result.error).toBe('Network toggle init failed'); + // isTestnet should be rolled back to its original value + expect(result.isTestnet).toBe(initialTestnetState); + expect(controller.state.isTestnet).toBe(initialTestnetState); + }); + + it('clears isReinitializing flag after init failure', async () => { + await controller.init(); + + mockProvider.disconnect.mockRejectedValue(new Error('Init failed')); + + await controller.toggleTestnet(); + + expect(controller.isCurrentlyReinitializing()).toBe(false); + }); + }); + + describe('market filter preferences', () => { + it('saves and retrieves filter preference', () => { + controller.saveMarketFilterPreferences('openInterest', 'desc'); + + const result = controller.getMarketFilterPreferences(); + + expect(result).toEqual({ + optionId: 'openInterest', + direction: 'desc', + }); + }); + + it('saves and retrieves price change with ascending direction', () => { + controller.saveMarketFilterPreferences('priceChange', 'asc'); + + const result = controller.getMarketFilterPreferences(); + + expect(result).toEqual({ + optionId: 'priceChange', + direction: 'asc', + }); + }); + }); + + describe('pro layout preferences', () => { + it('defaults to collapsed order book, expanded chart, reserved positions, and positions/orders sort/filter defaults', () => { + expect(controller.getProLayoutPreferences()).toEqual({ + orderBookExpanded: false, + chartExpanded: true, + orderBookPosition: 'left', + orderFormPosition: 'right', + positionsSideFilter: 'all', + positionsSortField: 'positionValue', + positionsSortDirection: 'desc', + ordersSideFilter: 'all', + ordersSortField: 'time', + ordersSortDirection: 'desc', + }); + }); + + it('updates a single field without clobbering the others', () => { + controller.setProLayoutPreferences({ orderBookExpanded: true }); + + expect(controller.getProLayoutPreferences()).toEqual({ + orderBookExpanded: true, + chartExpanded: true, + orderBookPosition: 'left', + orderFormPosition: 'right', + positionsSideFilter: 'all', + positionsSortField: 'positionValue', + positionsSortDirection: 'desc', + ordersSideFilter: 'all', + ordersSortField: 'time', + ordersSortDirection: 'desc', + }); + }); + + it('merges successive partial patches', () => { + controller.setProLayoutPreferences({ orderBookExpanded: true }); + controller.setProLayoutPreferences({ orderBookPosition: 'right' }); + controller.setProLayoutPreferences({ orderFormPosition: 'left' }); + controller.setProLayoutPreferences({ positionsSideFilter: 'long' }); + controller.setProLayoutPreferences({ + positionsSortField: 'unrealizedPnl', + positionsSortDirection: 'asc', + }); + controller.setProLayoutPreferences({ + ordersSideFilter: 'short', + ordersSortField: 'orderValue', + ordersSortDirection: 'asc', + }); + + expect(controller.getProLayoutPreferences()).toEqual({ + orderBookExpanded: true, + chartExpanded: true, + orderBookPosition: 'right', + orderFormPosition: 'left', + positionsSideFilter: 'long', + positionsSortField: 'unrealizedPnl', + positionsSortDirection: 'asc', + ordersSideFilter: 'short', + ordersSortField: 'orderValue', + ordersSortDirection: 'asc', + }); + }); + + it('updates sort field without clobbering sort direction', () => { + controller.setProLayoutPreferences({ + positionsSortField: 'fundingRate', + positionsSortDirection: 'asc', + }); + controller.setProLayoutPreferences({ + positionsSortField: 'unrealizedPnl', + }); + + expect(controller.getProLayoutPreferences()).toEqual({ + orderBookExpanded: false, + chartExpanded: true, + orderBookPosition: 'left', + orderFormPosition: 'right', + positionsSideFilter: 'all', + positionsSortField: 'unrealizedPnl', + positionsSortDirection: 'asc', + ordersSideFilter: 'all', + ordersSortField: 'time', + ordersSortDirection: 'desc', + }); + }); + + it('updates orders sort field without clobbering orders sort direction or positions sort', () => { + controller.setProLayoutPreferences({ + ordersSortField: 'size', + ordersSortDirection: 'asc', + }); + controller.setProLayoutPreferences({ + ordersSortField: 'price', + }); + + expect(controller.getProLayoutPreferences()).toEqual({ + orderBookExpanded: false, + chartExpanded: true, + orderBookPosition: 'left', + orderFormPosition: 'right', + positionsSideFilter: 'all', + positionsSortField: 'positionValue', + positionsSortDirection: 'desc', + ordersSideFilter: 'all', + ordersSortField: 'price', + ordersSortDirection: 'asc', + }); + }); + + it('updates orders side filter without clobbering positions side filter', () => { + controller.setProLayoutPreferences({ positionsSideFilter: 'long' }); + controller.setProLayoutPreferences({ ordersSideFilter: 'short' }); + + expect(controller.getProLayoutPreferences()).toEqual({ + orderBookExpanded: false, + chartExpanded: true, + orderBookPosition: 'left', + orderFormPosition: 'right', + positionsSideFilter: 'long', + positionsSortField: 'positionValue', + positionsSortDirection: 'desc', + ordersSideFilter: 'short', + ordersSortField: 'time', + ordersSortDirection: 'desc', + }); + }); + + it('persists the update to controller state', () => { + controller.setProLayoutPreferences({ chartExpanded: false }); + + expect(controller.state.proLayoutPreferences.chartExpanded).toBe(false); + }); + + it('fills in defaults for fields missing from persisted state', () => { + controller.testUpdate((state) => { + // Simulate persisted state that predates some fields. + state.proLayoutPreferences = { + orderBookExpanded: true, + } as PerpsControllerState['proLayoutPreferences']; + }); + + expect(controller.getProLayoutPreferences()).toEqual({ + orderBookExpanded: true, + chartExpanded: true, + orderBookPosition: 'left', + orderFormPosition: 'right', + positionsSideFilter: 'all', + positionsSortField: 'positionValue', + positionsSortDirection: 'desc', + ordersSideFilter: 'all', + ordersSortField: 'time', + ordersSortDirection: 'desc', + }); + }); + }); + + describe('order book preferences', () => { + it('defaults to USD totals', () => { + expect(controller.getOrderBookPreferences()).toEqual({ + currency: 'usd', + metric: 'total', + }); + }); + + it('updates a single preference without clobbering the other', () => { + controller.setOrderBookPreferences({ currency: 'base' }); + + expect(controller.getOrderBookPreferences()).toEqual({ + currency: 'base', + metric: 'total', + }); + }); + + it('fills in defaults for fields missing from persisted state', () => { + controller.testUpdate((state) => { + state.orderBookPreferences = { + metric: 'size', + } as PerpsControllerState['orderBookPreferences']; + }); + + expect(controller.getOrderBookPreferences()).toEqual({ + currency: 'usd', + metric: 'size', + }); + }); + }); + + describe('selected order type', () => { + it('defaults to market and persists independently of market', () => { + expect(controller.getSelectedOrderType()).toBe('market'); + + controller.setSelectedOrderType('limit'); + + expect(controller.getSelectedOrderType()).toBe('limit'); + controller.saveTradeConfiguration('ETH', 5); + expect(controller.getSelectedOrderType()).toBe('limit'); + controller.testUpdate((state) => { + state.isTestnet = true; + }); + expect(controller.getSelectedOrderType()).toBe('limit'); + }); + }); + + describe('visible candle count', () => { + it('defaults to 30 and persists a valid count', () => { + expect(controller.getVisibleCandleCount()).toBe(30); + + controller.setVisibleCandleCount(45); + + expect(controller.getVisibleCandleCount()).toBe(45); + }); + + it('clamps to the supported range and rounds to a whole candle', () => { + controller.setVisibleCandleCount(9); + expect(controller.getVisibleCandleCount()).toBe(10); + + controller.setVisibleCandleCount(251); + expect(controller.getVisibleCandleCount()).toBe(250); + + controller.setVisibleCandleCount(42.6); + expect(controller.getVisibleCandleCount()).toBe(43); + }); + + it('ignores non-finite values', () => { + controller.setVisibleCandleCount(45); + controller.setVisibleCandleCount(Number.NaN); + + expect(controller.getVisibleCandleCount()).toBe(45); + }); + }); + + describe('perps mode', () => { + it('defaults to lite mode', () => { + expect(controller.state.mode).toBe(PerpsMode.Lite); + }); + + it('sets the mode to pro', () => { + controller.setPerpsMode(PerpsMode.Pro); + + expect(controller.state.mode).toBe(PerpsMode.Pro); + }); + + it('sets the mode back to lite', () => { + controller.setPerpsMode(PerpsMode.Pro); + controller.setPerpsMode(PerpsMode.Lite); + + expect(controller.state.mode).toBe(PerpsMode.Lite); + }); + }); + + describe('watchlist management', () => { + it('adds and removes market from watchlist', async () => { + await controller.init(); + + controller.toggleWatchlistMarket('BTC'); + + expect(controller.isWatchlistMarket('BTC')).toBe(true); + expect(controller.getWatchlistMarkets()).toContain('BTC'); + + controller.toggleWatchlistMarket('BTC'); + + expect(controller.isWatchlistMarket('BTC')).toBe(false); + }); + }); + + describe('resetFirstTimeUserState', () => { + it('resets tutorial and order state for both networks', () => { + controller.markTutorialCompleted(); + controller.markFirstOrderCompleted(); + + controller.resetFirstTimeUserState(); + + expect(controller.state.isFirstTimeUser.testnet).toBe(true); + expect(controller.state.isFirstTimeUser.mainnet).toBe(true); + expect(controller.state.hasPlacedFirstOrder.testnet).toBe(false); + expect(controller.state.hasPlacedFirstOrder.mainnet).toBe(false); + }); + }); + + describe('clearPendingTransactionRequests', () => { + it('removes pending and bridging withdrawal requests', () => { + // Arrange: Add withdrawal requests with different statuses + controller.testUpdate((state) => { + state.withdrawalRequests = [ + { + id: 'withdrawal-1', + amount: '100', + asset: 'USDC', + accountAddress: '0x123', + timestamp: Date.now(), + success: false, + status: 'pending', + }, + { + id: 'withdrawal-2', + amount: '200', + asset: 'USDC', + accountAddress: '0x123', + timestamp: Date.now(), + success: false, + status: 'bridging', + }, + { + id: 'withdrawal-3', + amount: '300', + asset: 'USDC', + accountAddress: '0x123', + timestamp: Date.now(), + success: true, + status: 'completed', + txHash: '0xabc', + }, + { + id: 'withdrawal-4', + amount: '50', + asset: 'USDC', + accountAddress: '0x123', + timestamp: Date.now(), + success: false, + status: 'failed', + }, + ]; + }); + + controller.clearPendingTransactionRequests(); + + expect(controller.state.withdrawalRequests).toHaveLength(2); + expect(controller.state.withdrawalRequests.map((w) => w.id)).toEqual([ + 'withdrawal-3', + 'withdrawal-4', + ]); + }); + + it('removes pending and bridging deposit requests', () => { + // Arrange: Add deposit requests with different statuses + controller.testUpdate((state) => { + state.depositRequests = [ + { + id: 'deposit-1', + amount: '100', + asset: 'USDC', + accountAddress: '0x123', + timestamp: Date.now(), + success: false, + status: 'pending', + }, + { + id: 'deposit-2', + amount: '200', + asset: 'USDC', + accountAddress: '0x123', + timestamp: Date.now(), + success: false, + status: 'bridging', + }, + { + id: 'deposit-3', + amount: '300', + asset: 'USDC', + accountAddress: '0x123', + timestamp: Date.now(), + success: true, + status: 'completed', + txHash: '0xdef', + }, + ]; + }); + + controller.clearPendingTransactionRequests(); + + expect(controller.state.depositRequests).toHaveLength(1); + expect(controller.state.depositRequests[0].id).toBe('deposit-3'); + }); + + it('resets withdrawal progress', () => { + // Arrange: Set some withdrawal progress + controller.testUpdate((state) => { + state.withdrawalProgress = { + progress: 50, + lastUpdated: Date.now() - 10000, + activeWithdrawalId: 'withdrawal-1', + }; + }); + + controller.clearPendingTransactionRequests(); + + expect(controller.state.withdrawalProgress.progress).toBe(0); + expect(controller.state.withdrawalProgress.activeWithdrawalId).toBeNull(); + }); + + it('handles empty arrays gracefully', () => { + // Arrange: Ensure arrays are empty + controller.testUpdate((state) => { + state.withdrawalRequests = []; + state.depositRequests = []; + }); + + controller.clearPendingTransactionRequests(); + + expect(controller.state.withdrawalRequests).toHaveLength(0); + expect(controller.state.depositRequests).toHaveLength(0); + }); + }); + + describe('trade configuration', () => { + it('returns undefined for unsaved configuration', () => { + const result = controller.getTradeConfiguration('ETH'); + + expect(result).toBeUndefined(); + }); + + it('retrieves saved configuration', () => { + controller.saveTradeConfiguration('BTC', 10); + + const result = controller.getTradeConfiguration('BTC'); + + expect(result?.leverage).toBe(10); + }); + }); + + describe('pending trade configuration', () => { + beforeEach(() => { + jest.useFakeTimers(); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + it('saves pending trade configuration', () => { + const config = { + amount: '100', + leverage: 5, + takeProfitPrice: '50000', + stopLossPrice: '40000', + limitPrice: '45000', + orderType: 'limit' as const, + reduceOnly: true, + direction: 'short' as const, + }; + + controller.savePendingTradeConfiguration('BTC', config); + + const result = controller.getPendingTradeConfiguration('BTC'); + expect(result).toEqual(config); + expect(controller.getSelectedOrderType()).toBe('limit'); + }); + + it('restores a short direction from a pending trade configuration', () => { + controller.savePendingTradeConfiguration('BTC', { + amount: '100', + direction: 'short', + }); + + const result = controller.getPendingTradeConfiguration('BTC'); + + expect(result).toEqual({ + amount: '100', + direction: 'short', + }); + }); + + it('returns undefined for non-existent pending configuration', () => { + const result = controller.getPendingTradeConfiguration('ETH'); + + expect(result).toBeUndefined(); + }); + + it('returns undefined for expired pending configuration (more than 30 seconds)', () => { + const config = { + amount: '100', + leverage: 5, + }; + + controller.savePendingTradeConfiguration('BTC', config); + + jest.advanceTimersByTime(30_001); + + const result = controller.getPendingTradeConfiguration('BTC'); + + expect(result).toBeUndefined(); + }); + + it('returns configuration for valid pending configuration (less than 30 seconds)', () => { + const config = { + amount: '100', + leverage: 5, + takeProfitPrice: '50000', + orderType: 'market' as const, + }; + + controller.savePendingTradeConfiguration('BTC', config); + + jest.advanceTimersByTime(29_999); + + const result = controller.getPendingTradeConfiguration('BTC'); + + expect(result).toEqual(config); + }); + + it('clears expired pending configuration automatically', () => { + const config = { + amount: '100', + leverage: 5, + }; + + controller.savePendingTradeConfiguration('BTC', config); + + jest.advanceTimersByTime(30_001); + + // First call should clear expired config + controller.getPendingTradeConfiguration('BTC'); + + // Second call should return undefined + const result = controller.getPendingTradeConfiguration('BTC'); + expect(result).toBeUndefined(); + + // Verify state was cleaned up + const network = controller.state.isTestnet ? 'testnet' : 'mainnet'; + expect( + controller.state.tradeConfigurations[network]?.BTC?.pendingConfig, + ).toBeUndefined(); + }); + + it('clears pending trade configuration explicitly', () => { + const config = { + amount: '100', + leverage: 5, + }; + + controller.savePendingTradeConfiguration('BTC', config); + expect(controller.getPendingTradeConfiguration('BTC')).toEqual(config); + + controller.clearPendingTradeConfiguration('BTC'); + + const result = controller.getPendingTradeConfiguration('BTC'); + expect(result).toBeUndefined(); + }); + + it('clears only the draft while retaining leverage and selected order type', () => { + controller.saveTradeConfiguration('BTC', 10); + controller.setSelectedOrderType('limit'); + controller.savePendingTradeConfiguration('BTC', { + amount: '100', + leverage: 5, + orderType: 'limit', + reduceOnly: true, + }); + + controller.clearPendingTradeConfiguration('BTC'); + + expect(controller.getPendingTradeConfiguration('BTC')).toBeUndefined(); + expect(controller.getTradeConfiguration('BTC')).toEqual({ + leverage: 10, + }); + expect(controller.getSelectedOrderType()).toBe('limit'); + }); + + it('clears the draft after a successful order', async () => { + controller.testSetProviders(new Map([['hyperliquid', mockProvider]])); + markControllerAsInitialized(); + mockTradingServiceInstance.placeOrder.mockResolvedValue({ + success: true, + orderId: 'order-1', + }); + controller.savePendingTradeConfiguration('BTC', { + amount: '100', + reduceOnly: true, + }); + + await controller.placeOrder({ + symbol: 'BTC', + isBuy: true, + size: '0.1', + orderType: 'market', + }); + + expect(controller.getPendingTradeConfiguration('BTC')).toBeUndefined(); + }); + + it('retains the draft after an unsuccessful order', async () => { + controller.testSetProviders(new Map([['hyperliquid', mockProvider]])); + markControllerAsInitialized(); + mockTradingServiceInstance.placeOrder.mockResolvedValue({ + success: false, + error: 'Order failed', + }); + const config = { + amount: '100', + reduceOnly: true, + }; + controller.savePendingTradeConfiguration('BTC', config); + + await controller.placeOrder({ + symbol: 'BTC', + isBuy: true, + size: '0.1', + orderType: 'market', + }); + + expect(controller.getPendingTradeConfiguration('BTC')).toEqual(config); + }); + + it('saves pending config per network (testnet vs mainnet)', () => { + const configMainnet = { + amount: '100', + leverage: 5, + }; + const configTestnet = { + amount: '200', + leverage: 10, + }; + + // Save on mainnet (default is mainnet) + controller.savePendingTradeConfiguration('BTC', configMainnet); + expect(controller.getPendingTradeConfiguration('BTC')).toEqual( + configMainnet, + ); + + // Switch to testnet using update method + controller.testUpdate((state) => { + state.isTestnet = true; + }); + controller.savePendingTradeConfiguration('BTC', configTestnet); + expect(controller.getPendingTradeConfiguration('BTC')).toEqual( + configTestnet, + ); + + // Switch back to mainnet + controller.testUpdate((state) => { + state.isTestnet = false; + }); + expect(controller.getPendingTradeConfiguration('BTC')).toEqual( + configMainnet, + ); + }); + + it('preserves existing leverage when saving pending config', () => { + // First save leverage + controller.saveTradeConfiguration('BTC', 10); + + // Then save pending config + const pendingConfig = { + amount: '100', + leverage: 5, + }; + controller.savePendingTradeConfiguration('BTC', pendingConfig); + + // Leverage should still be saved + const savedConfig = controller.getTradeConfiguration('BTC'); + expect(savedConfig?.leverage).toBe(10); + + // Pending config should also be available + const pending = controller.getPendingTradeConfiguration('BTC'); + expect(pending).toEqual(pendingConfig); + }); + }); + + describe('WebSocket connection state', () => { + // Import actual enum to ensure type compatibility + const { WebSocketConnectionState } = jest.requireActual('../../src/types'); + + it('getWebSocketConnectionState returns state from active provider', () => { + // Arrange + controller.testSetProviders(new Map([['hyperliquid', mockProvider]])); + markControllerAsInitialized(); + mockProvider.getWebSocketConnectionState.mockReturnValue( + WebSocketConnectionState.Connected, + ); + + // Act + const result = controller.getWebSocketConnectionState(); + + // Assert + expect(result).toBe(WebSocketConnectionState.Connected); + expect(mockProvider.getWebSocketConnectionState).toHaveBeenCalled(); + }); + + it('getWebSocketConnectionState returns DISCONNECTED when provider does not support method', () => { + // Arrange + controller.testSetProviders(new Map([['hyperliquid', mockProvider]])); + markControllerAsInitialized(); + // Remove the method to simulate provider without support + mockProvider.getWebSocketConnectionState = undefined as never; + + // Act + const result = controller.getWebSocketConnectionState(); + + // Assert + expect(result).toBe(WebSocketConnectionState.Disconnected); + }); + + it('getWebSocketConnectionState returns DISCONNECTED when no provider is active', () => { + // Arrange - don't set up any provider + + // Act + const result = controller.getWebSocketConnectionState(); + + // Assert + expect(result).toBe(WebSocketConnectionState.Disconnected); + }); + + it('subscribeToConnectionState delegates to active provider', () => { + // Arrange + controller.testSetProviders(new Map([['hyperliquid', mockProvider]])); + markControllerAsInitialized(); + const mockUnsubscribe = jest.fn(); + mockProvider.subscribeToConnectionState.mockReturnValue(mockUnsubscribe); + const listener = jest.fn(); + + // Act + const unsubscribe = controller.subscribeToConnectionState(listener); + + // Assert + expect(mockProvider.subscribeToConnectionState).toHaveBeenCalledWith( + listener, + ); + expect(unsubscribe).toBe(mockUnsubscribe); + }); + + it('subscribeToConnectionState calls listener immediately when provider does not support method', () => { + // Arrange + controller.testSetProviders(new Map([['hyperliquid', mockProvider]])); + markControllerAsInitialized(); + // Keep getWebSocketConnectionState but remove subscribeToConnectionState + mockProvider.getWebSocketConnectionState.mockReturnValue( + WebSocketConnectionState.Disconnected, + ); + mockProvider.subscribeToConnectionState = undefined as never; + const listener = jest.fn(); + + // Act + const unsubscribe = controller.subscribeToConnectionState(listener); + + // Assert - listener is called with result of getWebSocketConnectionState() + expect(listener).toHaveBeenCalledWith( + WebSocketConnectionState.Disconnected, + 0, + ); + expect(typeof unsubscribe).toBe('function'); + }); + + it('subscribeToConnectionState returns no-op when no provider is active', () => { + // Arrange - don't set up any provider + const listener = jest.fn(); + + // Act + const unsubscribe = controller.subscribeToConnectionState(listener); + + // Assert + expect(listener).toHaveBeenCalledWith( + WebSocketConnectionState.Disconnected, + 0, + ); + expect(typeof unsubscribe).toBe('function'); + // Verify unsubscribe doesn't throw + expect(() => unsubscribe()).not.toThrow(); + }); + + it('reconnect delegates to active provider', async () => { + // Arrange + controller.testSetProviders(new Map([['hyperliquid', mockProvider]])); + markControllerAsInitialized(); + mockProvider.reconnect.mockResolvedValue(undefined); + + // Act + await controller.reconnect(); + + // Assert + expect(mockProvider.reconnect).toHaveBeenCalled(); + }); + + it('reconnect does nothing when provider does not support method', async () => { + // Arrange + controller.testSetProviders(new Map([['hyperliquid', mockProvider]])); + markControllerAsInitialized(); + // Remove the method to simulate provider without support + mockProvider.reconnect = undefined as never; + + // Act & Assert - should not throw + await expect(controller.reconnect()).resolves.toBeUndefined(); + }); + + it('reconnect does nothing when no provider is active', async () => { + // Arrange - don't set up any provider + + // Act & Assert - should not throw + await expect(controller.reconnect()).resolves.toBeUndefined(); + }); + }); + + describe('order book grouping', () => { + it('saves order book grouping for mainnet', () => { + controller.testUpdate((state) => { + state.isTestnet = false; + }); + + controller.saveOrderBookGrouping('BTC', 10); + + const result = controller.getOrderBookGrouping('BTC'); + expect(result).toBe(10); + }); + + it('saves order book grouping for testnet', () => { + controller.testUpdate((state) => { + state.isTestnet = true; + }); + + controller.saveOrderBookGrouping('ETH', 0.01); + + const result = controller.getOrderBookGrouping('ETH'); + expect(result).toBe(0.01); + }); + + it('returns undefined when no grouping is saved', () => { + const result = controller.getOrderBookGrouping('SOL'); + expect(result).toBeUndefined(); + }); + + it('preserves existing config when saving grouping', () => { + controller.testUpdate((state) => { + state.isTestnet = false; + }); + + // First save leverage + controller.saveTradeConfiguration('BTC', 5); + + // Then save grouping + controller.saveOrderBookGrouping('BTC', 100); + + // Both should be preserved + const savedConfig = controller.getTradeConfiguration('BTC'); + expect(savedConfig?.leverage).toBe(5); + + const savedGrouping = controller.getOrderBookGrouping('BTC'); + expect(savedGrouping).toBe(100); + }); + }); + + describe('standalone mode', () => { + const mockUserAddress = '0xabcdef1234567890abcdef1234567890abcdef12'; + const MockedHyperLiquidProvider = HyperLiquidProvider as jest.MockedClass< + typeof HyperLiquidProvider + >; + + beforeEach(() => { + // Reset mocks before each test + MockedHyperLiquidProvider.mockClear(); + }); + + describe('getPositions with standalone mode', () => { + it('uses existing provider for standalone queries when available', async () => { + // Arrange - set up mock provider with properly typed positions + const mockPositions = [ + createMockPosition({ symbol: 'BTC', size: '0.5' }), + ]; + const existingMockProvider = createMockHyperLiquidProvider(); + existingMockProvider.getPositions.mockResolvedValue(mockPositions); + controller.testSetProviders( + new Map([['hyperliquid', existingMockProvider]]), + ); + controller.testMarkInitialized(); + controller.testUpdate((state) => { + state.activeProvider = 'hyperliquid'; + }); + + // Act + const positions = await controller.getPositions({ + standalone: true, + userAddress: mockUserAddress, + }); + + // Assert - should use existing provider + expect(existingMockProvider.getPositions).toHaveBeenCalledWith({ + standalone: true, + userAddress: mockUserAddress, + }); + expect(positions).toEqual(mockPositions); + // Should NOT create a new HyperLiquidProvider instance + expect(MockedHyperLiquidProvider).not.toHaveBeenCalled(); + }); + + it('creates temporary provider for standalone queries when no activeProviderInstance', async () => { + // Arrange - no activeProviderInstance set (pre-initialization) + const mockPositions = [ + createMockPosition({ symbol: 'ETH', size: '2.0' }), + ]; + const tempMockProvider = createMockHyperLiquidProvider(); + tempMockProvider.getPositions.mockResolvedValue(mockPositions); + MockedHyperLiquidProvider.mockImplementation(() => tempMockProvider); + + controller.testUpdate((state) => { + state.activeProvider = 'aggregated'; + state.isTestnet = false; + }); + + // Act + const positions = await controller.getPositions({ + standalone: true, + userAddress: mockUserAddress, + }); + + // Assert - should create a temporary provider for pre-init discovery + expect(MockedHyperLiquidProvider).toHaveBeenCalledWith( + expect.objectContaining({ + isTestnet: false, + }), + ); + expect(positions).toEqual(mockPositions); + }); + + it('bypasses getActiveProvider check for standalone queries', async () => { + // Arrange - controller not initialized (no provider available via normal path) + const mockPositions = [ + createMockPosition({ symbol: 'BTC', size: '1.0' }), + ]; + const tempMockProvider = createMockHyperLiquidProvider(); + tempMockProvider.getPositions.mockResolvedValue(mockPositions); + MockedHyperLiquidProvider.mockImplementation(() => tempMockProvider); + + controller.testUpdate((state) => { + state.initializationState = InitializationState.Initializing; + state.activeProvider = 'aggregated'; + }); + + // Act - should NOT throw despite controller not being initialized + const positions = await controller.getPositions({ + standalone: true, + userAddress: mockUserAddress, + }); + + // Assert + expect(positions).toEqual(mockPositions); + }); + }); + + describe('getAccountState with standalone mode', () => { + // Complete AccountState mock with all required fields + const createMockAccountState = (overrides = {}) => ({ + totalBalance: '50000', + spendableBalance: '45000', + withdrawableBalance: '45000', + marginUsed: '5000', + unrealizedPnl: '1000', + returnOnEquity: '20', + ...overrides, + }); + + it('uses existing provider for standalone queries when available', async () => { + // Arrange + const mockAccountState = createMockAccountState(); + const existingMockProvider = createMockHyperLiquidProvider(); + existingMockProvider.getAccountState.mockResolvedValue( + mockAccountState, + ); + controller.testSetProviders( + new Map([['hyperliquid', existingMockProvider]]), + ); + controller.testMarkInitialized(); + controller.testUpdate((state) => { + state.activeProvider = 'hyperliquid'; + }); + + // Act + const accountState = await controller.getAccountState({ + standalone: true, + userAddress: mockUserAddress, + }); + + // Assert - should use existing provider + expect(existingMockProvider.getAccountState).toHaveBeenCalledWith({ + standalone: true, + userAddress: mockUserAddress, + }); + expect(accountState).toEqual(mockAccountState); + expect(MockedHyperLiquidProvider).not.toHaveBeenCalled(); + }); + + it('creates temporary provider for standalone queries when no activeProviderInstance', async () => { + // Arrange - no activeProviderInstance set (pre-initialization) + const mockAccountState = createMockAccountState({ + totalBalance: '25000', + spendableBalance: '20000', + withdrawableBalance: '20000', + }); + const tempMockProvider = createMockHyperLiquidProvider(); + tempMockProvider.getAccountState.mockResolvedValue(mockAccountState); + MockedHyperLiquidProvider.mockImplementation(() => tempMockProvider); + + controller.testUpdate((state) => { + state.activeProvider = 'aggregated'; + state.isTestnet = true; + }); + + // Act + const accountState = await controller.getAccountState({ + standalone: true, + userAddress: mockUserAddress, + }); + + // Assert - should create a temporary provider for pre-init discovery + expect(MockedHyperLiquidProvider).toHaveBeenCalledWith( + expect.objectContaining({ + isTestnet: true, + }), + ); + expect(accountState).toEqual(mockAccountState); + }); + + it('bypasses getActiveProvider check for standalone queries', async () => { + // Arrange - controller not initialized (no provider available via normal path) + const mockAccountState = createMockAccountState({ + totalBalance: '10000', + }); + const tempMockProvider = createMockHyperLiquidProvider(); + tempMockProvider.getAccountState.mockResolvedValue(mockAccountState); + MockedHyperLiquidProvider.mockImplementation(() => tempMockProvider); + + controller.testUpdate((state) => { + state.initializationState = InitializationState.Initializing; + state.activeProvider = 'aggregated'; + }); + + // Act - should NOT throw despite controller not being initialized + const accountState = await controller.getAccountState({ + standalone: true, + userAddress: mockUserAddress, + }); + + // Assert + expect(accountState).toEqual(mockAccountState); + }); + }); + + describe('standalone provider caching', () => { + it('reuses the same standalone provider across multiple calls', async () => { + const tempMockProvider = createMockHyperLiquidProvider(); + tempMockProvider.getPositions.mockResolvedValue([]); + tempMockProvider.getOpenOrders.mockResolvedValue([]); + MockedHyperLiquidProvider.mockImplementation(() => tempMockProvider); + + // Two standalone calls — should only create one provider + await controller.getPositions({ + standalone: true, + userAddress: mockUserAddress, + }); + await controller.getOpenOrders({ + standalone: true, + userAddress: mockUserAddress, + }); + + expect(MockedHyperLiquidProvider).toHaveBeenCalledTimes(1); + expect(controller.testHasStandaloneProvider()).toBe(true); + }); + + it('cleans up standalone provider on init()', async () => { + const tempMockProvider = createMockHyperLiquidProvider(); + tempMockProvider.getPositions.mockResolvedValue([]); + MockedHyperLiquidProvider.mockImplementation(() => tempMockProvider); + + // Create a cached standalone provider + await controller.getPositions({ + standalone: true, + userAddress: mockUserAddress, + }); + expect(controller.testHasStandaloneProvider()).toBe(true); + + // init() should clean it up + await controller.init(); + + expect(controller.testHasStandaloneProvider()).toBe(false); + expect(tempMockProvider.disconnect).toHaveBeenCalled(); + }); + + it('invalidates cached provider when isTestnet changes', async () => { + const firstProvider = createMockHyperLiquidProvider(); + firstProvider.getPositions.mockResolvedValue([]); + const secondProvider = createMockHyperLiquidProvider(); + secondProvider.getPositions.mockResolvedValue([]); + MockedHyperLiquidProvider.mockImplementationOnce( + () => firstProvider, + ).mockImplementationOnce(() => secondProvider); + + // First standalone call on mainnet + await controller.getPositions({ + standalone: true, + userAddress: mockUserAddress, + }); + expect(MockedHyperLiquidProvider).toHaveBeenCalledTimes(1); + + // Toggle testnet flag (simulates config change) + controller.testUpdate((state) => { + state.isTestnet = true; + }); + + // Second standalone call — should create a new provider + await controller.getPositions({ + standalone: true, + userAddress: mockUserAddress, + }); + expect(MockedHyperLiquidProvider).toHaveBeenCalledTimes(2); + // Old provider should have been disconnected + expect(firstProvider.disconnect).toHaveBeenCalled(); + }); + + it('cleans up standalone provider on disconnect()', async () => { + const tempMockProvider = createMockHyperLiquidProvider(); + tempMockProvider.getMarketDataWithPrices.mockResolvedValue([]); + MockedHyperLiquidProvider.mockImplementation(() => tempMockProvider); + + await controller.getMarketDataWithPrices({ standalone: true }); + expect(controller.testHasStandaloneProvider()).toBe(true); + + await controller.disconnect(); + + expect(controller.testHasStandaloneProvider()).toBe(false); + expect(tempMockProvider.disconnect).toHaveBeenCalled(); + }); + + it('cleans up standalone provider on stopMarketDataPreload()', async () => { + const tempMockProvider = createMockHyperLiquidProvider(); + tempMockProvider.getMarkets.mockResolvedValue([]); + MockedHyperLiquidProvider.mockImplementation(() => tempMockProvider); + + await controller.getMarkets({ standalone: true }); + expect(controller.testHasStandaloneProvider()).toBe(true); + + controller.stopMarketDataPreload(); + + // Fire-and-forget — give microtask a tick to resolve + await new Promise((resolve) => setTimeout(resolve, 0)); + + expect(controller.testHasStandaloneProvider()).toBe(false); + expect(tempMockProvider.disconnect).toHaveBeenCalled(); + }); + }); + }); + + describe('setSelectedPaymentToken', () => { + it('sets selectedPaymentToken to null when passed null', () => { + controller.testUpdate((state) => { + state.selectedPaymentToken = { + description: 'USDC', + address: '0xa0b8', + chainId: '0x1', + } as PerpsControllerState['selectedPaymentToken']; + }); + + controller.setSelectedPaymentToken(null); + + expect(controller.state.selectedPaymentToken).toBeNull(); + }); + + it('sets selectedPaymentToken to null when token has PerpsBalanceTokenDescription', () => { + controller.setSelectedPaymentToken({ + description: 'perps-balance', + address: '0x0', + chainId: '0x1', + } as Parameters[0]); + + expect(controller.state.selectedPaymentToken).toBeNull(); + }); + + it('stores description, address and chainId when passed a normal token', () => { + const token = { + description: 'USDC', + address: '0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48' as const, + chainId: '0x1' as const, + }; + + controller.setSelectedPaymentToken( + token as Parameters[0], + ); + + expect(controller.state.selectedPaymentToken).toMatchObject({ + description: 'USDC', + address: token.address, + chainId: token.chainId, + }); + }); + }); +}); diff --git a/packages/perps-controller/tests/src/PerpsController.lifecycle.test.ts b/packages/perps-controller/tests/src/PerpsController.lifecycle.test.ts new file mode 100644 index 00000000000..b77fc7239fa --- /dev/null +++ b/packages/perps-controller/tests/src/PerpsController.lifecycle.test.ts @@ -0,0 +1,2063 @@ +/* eslint-disable */ +/** + * PerpsController Tests + * Clean, focused test suite for PerpsController + */ + +/* eslint-disable @typescript-eslint/no-explicit-any */ + +import { createMockHyperLiquidProvider } from '../helpers/providerMocks.js'; +import { + createDeferred, + createMockInfrastructure, + createMockMessenger, +} from '../helpers/serviceMocks.js'; + +jest.mock('@nktkas/hyperliquid', () => ({})); +jest.mock('@myx-trade/sdk', () => ({ + MyxClient: jest.fn(), + OrderStatusEnum: { Successful: 9 }, +})); + +import { PERPS_DISK_CACHE_MARKETS } from '../../src/constants/perpsConfig.js'; +import { + PerpsController, + getDefaultPerpsControllerState, + InitializationState, +} from '../../src/PerpsController.js'; +import type { PerpsControllerState } from '../../src/PerpsController.js'; +import { PERPS_ERROR_CODES } from '../../src/perpsErrorCodes.js'; +import { HyperLiquidProvider } from '../../src/providers/HyperLiquidProvider.js'; +import type { + PerpsProvider, + PerpsPlatformDependencies, + PerpsProviderType, +} from '../../src/types/index.js'; + +jest.mock('../../src/providers/HyperLiquidProvider'); +jest.mock('../../src/providers/MYXProvider'); + +// Mock transaction controller utility +const mockAddTransaction = jest.fn(); +jest.mock( + '../../../util/transaction-controller', + () => ({ + addTransaction: (...args: unknown[]) => mockAddTransaction(...args), + }), + { virtual: true }, +); + +// Mock wait utility to speed up retry tests +jest.mock('../../src/utils/wait', () => ({ + wait: jest.fn().mockResolvedValue(undefined), +})); +import { wait as mockWait } from '../../src/utils/wait'; + +// Mock stream manager +const mockStreamManager = { + positions: { pause: jest.fn(), resume: jest.fn() }, + account: { pause: jest.fn(), resume: jest.fn() }, + orders: { pause: jest.fn(), resume: jest.fn() }, + prices: { pause: jest.fn(), resume: jest.fn() }, + orderFills: { pause: jest.fn(), resume: jest.fn() }, +}; + +jest.mock( + '../../../components/UI/Perps/providers/PerpsStreamManager', + () => ({ + getStreamManagerInstance: jest.fn(() => mockStreamManager), + }), + { virtual: true }, +); + +jest.mock('@metamask/utils', () => ({ + ...jest.requireActual('@metamask/utils'), + formatAccountToCaipAccountId: jest + .fn() + .mockReturnValue('eip155:1:0x1234567890123456789012345678901234567890'), +})); + +// Mock EligibilityService as a class with instance methods +const mockEligibilityServiceInstance = { + checkEligibility: jest.fn().mockResolvedValue(true), +}; +jest.mock('../../src/services/EligibilityService', () => ({ + EligibilityService: jest + .fn() + .mockImplementation(() => mockEligibilityServiceInstance), +})); + +// Mock DepositService as a class with instance methods +const mockDepositServiceInstance = { + prepareTransaction: jest.fn(), +}; +jest.mock('../../src/services/DepositService', () => ({ + DepositService: jest + .fn() + .mockImplementation(() => mockDepositServiceInstance), +})); + +// Mock MarketDataService as a class with instance methods +const mockMarketDataServiceInstance = { + getMarketDataWithPrices: jest.fn(), + getPositions: jest.fn(), + getAccountState: jest.fn(), + getMarkets: jest.fn(), + getWithdrawalRoutes: jest.fn().mockReturnValue([]), + validateClosePosition: jest.fn().mockResolvedValue({ isValid: true }), + validateOrder: jest.fn(), + calculateMaintenanceMargin: jest.fn().mockResolvedValue(0), + calculateLiquidationPrice: jest.fn(), + getMaxLeverage: jest.fn(), + calculateFees: jest.fn().mockResolvedValue({ totalFee: 0 }), + previewPositionModify: jest.fn(), + getAvailableDexs: jest.fn().mockResolvedValue([]), + getBlockExplorerUrl: jest.fn(), + getOrderFills: jest.fn(), + getOrders: jest.fn(), + getFunding: jest.fn(), +}; +jest.mock('../../src/services/MarketDataService', () => ({ + MarketDataService: jest + .fn() + .mockImplementation(() => mockMarketDataServiceInstance), +})); + +// Mock TradingService as a class with instance methods +const mockTradingServiceInstance = { + placeOrder: jest.fn(), + editOrder: jest.fn(), + cancelOrder: jest.fn(), + cancelOrders: jest.fn(), + closePosition: jest.fn(), + closePositions: jest.fn(), + updatePositionTPSL: jest.fn(), + updateMargin: jest.fn(), + flipPosition: jest.fn(), + setControllerDependencies: jest.fn(), +}; +jest.mock('../../src/services/TradingService', () => ({ + TradingService: jest + .fn() + .mockImplementation(() => mockTradingServiceInstance), +})); + +// Mock AccountService as a class with instance methods +const mockAccountServiceInstance = { + withdraw: jest.fn(), + validateWithdrawal: jest.fn(), +}; +jest.mock('../../src/services/AccountService', () => ({ + AccountService: jest + .fn() + .mockImplementation(() => mockAccountServiceInstance), +})); + +// Mock DataLakeService as a class with instance methods +const mockDataLakeServiceInstance = { + reportOrder: jest.fn(), +}; +jest.mock('../../src/services/DataLakeService', () => ({ + DataLakeService: jest + .fn() + .mockImplementation(() => mockDataLakeServiceInstance), +})); + +// Mock FeatureFlagConfigurationService as a class with instance methods +const mockFeatureFlagConfigurationServiceInstance = { + refreshEligibility: jest.fn((options: any) => { + // Simulate the service's behavior: extract blocked regions from remote flags + const remoteFlags = + options.remoteFeatureFlagControllerState.remoteFeatureFlags; + const perpsGeoBlockedRegionsFeatureFlag = + remoteFlags?.perpsPerpTradingGeoBlockedCountriesV2; + const remoteBlockedRegions = + perpsGeoBlockedRegionsFeatureFlag?.blockedRegions; + + if ( + Array.isArray(remoteBlockedRegions) && + options.context.setBlockedRegionList + ) { + const currentList = options.context.getBlockedRegionList?.(); + // Never downgrade from remote to fallback + if (!currentList || currentList.source !== 'remote') { + options.context.setBlockedRegionList(remoteBlockedRegions, 'remote'); + } + } + + // Call refreshEligibility callback if available + if (options.context.refreshEligibility) { + options.context.refreshEligibility().catch(() => { + // Ignore errors in mock + }); + } + + // Also call refreshHip3Config if available + if (remoteFlags) { + mockFeatureFlagConfigurationServiceInstance.refreshHip3Config(options); + } + }), + refreshHip3Config: jest.fn(), + setBlockedRegions: jest.fn((options: any) => { + // Simulate setBlockedRegions behavior + const { list, source, context } = options; + if (context.setBlockedRegionList && context.getBlockedRegionList) { + const currentList = context.getBlockedRegionList(); + // Never downgrade from remote to fallback + if (source === 'fallback' && currentList.source === 'remote') { + return; + } + if (Array.isArray(list)) { + context.setBlockedRegionList(list, source); + } + } + + // Call refreshEligibility callback if available + if (context.refreshEligibility) { + context.refreshEligibility().catch(() => { + // Ignore errors in mock + }); + } + }), +}; +jest.mock('../../src/services/FeatureFlagConfigurationService', () => ({ + FeatureFlagConfigurationService: jest + .fn() + .mockImplementation(() => mockFeatureFlagConfigurationServiceInstance), +})); + +/** + * Testable version of PerpsController that exposes protected methods for testing. + * This follows the pattern used in RewardsController.test.ts + */ +class TestablePerpsController extends PerpsController { + /** + * Test-only method to update state directly. + * Exposed for scenarios where state needs to be manipulated + * outside the normal public API (e.g., testing error conditions). + * @param callback + */ + public testUpdate(callback: (state: PerpsControllerState) => void) { + this.update(callback); + } + + /** + * Test-only method to mark controller as initialized. + * Common test scenario that requires internal state changes. + */ + public testMarkInitialized() { + this.isInitialized = true; + this.update((state) => { + state.initializationState = InitializationState.Initialized; + }); + } + + /** + * Test-only method to set the providers map with complete providers. + * Used in most tests to inject mock providers. + * Also sets activeProviderInstance to the first provider (default provider). + * @param providers + */ + public testSetProviders(providers: Map) { + this.providers = providers; + // Set activeProviderInstance to the first provider (typically 'hyperliquid') + const firstProvider = providers.values().next().value; + if (firstProvider) { + this.activeProviderInstance = firstProvider; + } + } + + /** + * Test-only method to set the providers map with partial providers. + * Used explicitly in tests that verify error handling with incomplete providers. + * Type cast is intentional and necessary for testing graceful degradation. + * @param providers + */ + public testSetPartialProviders( + providers: Map>, + ) { + this.providers = providers as Map; + } + + /** + * Test-only method to get the providers map. + * Used to verify provider state in tests. + */ + public testGetProviders(): Map { + return this.providers; + } + + /** + * Test-only method to set initialization state. + * Allows tests to simulate both initialized and uninitialized states. + * @param value + */ + public testSetInitialized(value: boolean) { + this.isInitialized = value; + } + + /** + * Test-only method to get initialization state. + * Used to verify initialization status in tests. + */ + public testGetInitialized(): boolean { + return this.isInitialized; + } + + /** + * Test-only method to get blocked region list. + * Used to verify geo-blocking configuration in tests. + */ + public testGetBlockedRegionList(): { source: string; list: string[] } { + return this.blockedRegionList; + } + + /** + * Test-only method to set blocked region list. + * Used to test priority logic (remote vs fallback). + * @param list + * @param source + */ + public testSetBlockedRegionList( + list: string[], + source: 'remote' | 'fallback', + ) { + this.setBlockedRegionList(list, source); + } + + /** + * Test accessor for protected method refreshEligibilityOnFeatureFlagChange. + * Wrapper is necessary because protected methods can't be called from test code. + * @param remoteFlags + */ + public testRefreshEligibilityOnFeatureFlagChange(remoteFlags: any) { + this.refreshEligibilityOnFeatureFlagChange(remoteFlags); + } + + /** + * Test accessor for protected method reportOrderToDataLake. + * Wrapper is necessary because protected methods can't be called from test code. + * @param data + */ + public testReportOrderToDataLake(data: any): Promise { + return this.reportOrderToDataLake(data); + } + + public testHasStandaloneProvider(): boolean { + return this.hasStandaloneProvider(); + } + + public testRegisterMYXProvider( + MYXProvider: new (opts: Record) => PerpsProvider, + ) { + this.registerMYXProvider(MYXProvider as never); + } + + public testHandleMYXImportError(error: unknown) { + this.handleMYXImportError(error); + } +} + +describe('PerpsController', () => { + let controller: TestablePerpsController; + let mockProvider: jest.Mocked; + let mockInfrastructure: jest.Mocked; + let mockMessenger: ReturnType; + + // Helper to mark controller as initialized for tests + const markControllerAsInitialized = () => { + controller.testMarkInitialized(); + }; + + beforeEach(() => { + jest.clearAllMocks(); + + ( + jest.requireMock('../../src/services/EligibilityService') + .EligibilityService as jest.Mock + ).mockImplementation(() => mockEligibilityServiceInstance); + ( + jest.requireMock('../../src/services/DepositService') + .DepositService as jest.Mock + ).mockImplementation(() => mockDepositServiceInstance); + ( + jest.requireMock('../../src/services/MarketDataService') + .MarketDataService as jest.Mock + ).mockImplementation(() => mockMarketDataServiceInstance); + ( + jest.requireMock('../../src/services/TradingService') + .TradingService as jest.Mock + ).mockImplementation(() => mockTradingServiceInstance); + ( + jest.requireMock('../../src/services/AccountService') + .AccountService as jest.Mock + ).mockImplementation(() => mockAccountServiceInstance); + ( + jest.requireMock('../../src/services/DataLakeService') + .DataLakeService as jest.Mock + ).mockImplementation(() => mockDataLakeServiceInstance); + ( + jest.requireMock('../../src/services/FeatureFlagConfigurationService') + .FeatureFlagConfigurationService as jest.Mock + ).mockImplementation(() => mockFeatureFlagConfigurationServiceInstance); + + mockEligibilityServiceInstance.checkEligibility.mockResolvedValue(true); + mockMarketDataServiceInstance.getMarketDataWithPrices.mockResolvedValue([]); + mockMarketDataServiceInstance.getPositions.mockResolvedValue([]); + mockMarketDataServiceInstance.getAccountState.mockResolvedValue({ + spendableBalance: '10000', + withdrawableBalance: '10000', + totalBalance: '10000', + marginUsed: '0', + unrealizedPnl: '0', + returnOnEquity: '0', + }); + mockMarketDataServiceInstance.getMarkets.mockResolvedValue([]); + mockMarketDataServiceInstance.getWithdrawalRoutes.mockReturnValue([]); + mockMarketDataServiceInstance.validateClosePosition.mockResolvedValue({ + isValid: true, + }); + mockMarketDataServiceInstance.calculateMaintenanceMargin.mockResolvedValue( + 0, + ); + mockMarketDataServiceInstance.calculateFees.mockResolvedValue({ + totalFee: 0, + }); + mockMarketDataServiceInstance.getAvailableDexs.mockResolvedValue([]); + + mockFeatureFlagConfigurationServiceInstance.refreshEligibility.mockImplementation( + (options: any) => { + const remoteFlags = + options.remoteFeatureFlagControllerState.remoteFeatureFlags; + const perpsGeoBlockedRegionsFeatureFlag = + remoteFlags?.perpsPerpTradingGeoBlockedCountriesV2; + const remoteBlockedRegions = + perpsGeoBlockedRegionsFeatureFlag?.blockedRegions; + + if ( + Array.isArray(remoteBlockedRegions) && + options.context.setBlockedRegionList + ) { + const currentList = options.context.getBlockedRegionList?.(); + if (!currentList || currentList.source !== 'remote') { + options.context.setBlockedRegionList( + remoteBlockedRegions, + 'remote', + ); + } + } + + if (options.context.refreshEligibility) { + options.context.refreshEligibility().catch(() => { + // Ignore errors in mock + }); + } + + if (remoteFlags) { + mockFeatureFlagConfigurationServiceInstance.refreshHip3Config( + options, + ); + } + }, + ); + mockFeatureFlagConfigurationServiceInstance.setBlockedRegions.mockImplementation( + (options: any) => { + const { list, source, context } = options; + if (context.setBlockedRegionList && context.getBlockedRegionList) { + const currentList = context.getBlockedRegionList(); + if (source === 'fallback' && currentList.source === 'remote') { + return; + } + if (Array.isArray(list)) { + context.setBlockedRegionList(list, source); + } + } + + if (context.refreshEligibility) { + context.refreshEligibility().catch(() => { + // Ignore errors in mock + }); + } + }, + ); + mockFeatureFlagConfigurationServiceInstance.refreshHip3Config.mockImplementation( + () => undefined, + ); + + // Create a fresh mock provider for each test + mockProvider = createMockHyperLiquidProvider(); + + // Add default mock return values for all provider methods + mockProvider.getPositions.mockResolvedValue([]); + mockProvider.getAccountState.mockResolvedValue({ + spendableBalance: '10000', + withdrawableBalance: '10000', + totalBalance: '10000', + marginUsed: '0', + unrealizedPnl: '0', + returnOnEquity: '0', + }); + mockProvider.getMarkets.mockResolvedValue([]); + mockProvider.getOpenOrders.mockResolvedValue([]); + mockProvider.getFunding.mockResolvedValue([]); + mockProvider.getOrderFills.mockResolvedValue([]); + mockProvider.getOrders.mockResolvedValue([]); + mockProvider.calculateLiquidationPrice.mockResolvedValue('0'); + mockProvider.getMaxLeverage.mockResolvedValue(50); + mockProvider.calculateMaintenanceMargin.mockResolvedValue(0); + mockProvider.calculateFees.mockResolvedValue({ feeAmount: 0 }); + mockProvider.getBlockExplorerUrl.mockReturnValue( + 'https://explorer.example.com', + ); + mockProvider.getWithdrawalRoutes.mockReturnValue([]); + + ( + HyperLiquidProvider as jest.MockedClass + ).mockImplementation(() => mockProvider); + + const mockCall = jest.fn().mockImplementation((action: string) => { + if (action === 'RemoteFeatureFlagController:getState') { + return { + remoteFeatureFlags: { + perpsPerpTradingGeoBlockedCountriesV2: { + blockedRegions: [], + }, + }, + }; + } + if ( + action === 'AccountTreeController:getAccountsFromSelectedAccountGroup' + ) { + return [ + { + address: '0x1234567890123456789012345678901234567890', + type: 'eip155:eoa', + id: 'account-1', + options: {}, + scopes: ['eip155:1'], + methods: [], + metadata: { + name: 'Test', + importTime: 0, + keyring: { type: 'HD Key Tree' }, + }, + }, + ]; + } + return undefined; + }); + + mockInfrastructure = createMockInfrastructure(); + mockMessenger = createMockMessenger({ call: mockCall }); + controller = new TestablePerpsController({ + messenger: mockMessenger, + state: getDefaultPerpsControllerState(), + infrastructure: mockInfrastructure, + }); + }); + + afterEach(() => { + // Clear only provider mocks, not Engine.context mocks + // This prevents breaking Engine.context.RewardsController/NetworkController references + if (mockProvider) { + Object.values(mockProvider).forEach((value) => { + if ( + typeof value === 'function' && + value !== null && + 'mockClear' in value + ) { + (value as jest.Mock).mockClear(); + } + }); + } + (mockInfrastructure.metrics.trackPerpsEvent as jest.Mock).mockClear(); + (mockInfrastructure.logger.error as jest.Mock).mockClear(); + (mockInfrastructure.debugLogger.log as jest.Mock).mockClear(); + }); + describe('constructor', () => { + it('initializes with default state', () => { + // Constructor no longer auto-starts initialization (moved to Engine.ts) + expect(controller.state.activeProvider).toBe('hyperliquid'); + expect(controller.state.accountState).toBeNull(); + expect(controller.state.initializationState).toBe('uninitialized'); // Waits for explicit initialization + expect(controller.state.initializationError).toBeNull(); + expect(controller.state.initializationAttempts).toBe(0); // Not started yet + // isEligible is initially false, but refreshEligibility is called during construction + // which updates it to true (defaulting to eligible when geo-location is unknown) + expect(controller.state.isEligible).toBe(true); + expect(controller.state.isTestnet).toBe(false); // Default to mainnet + }); + + it('reads current RemoteFeatureFlagController state during construction', () => { + // Given: A messenger that returns remote feature flags state + const testMockCall = jest.fn().mockImplementation((action: string) => { + if (action === 'RemoteFeatureFlagController:getState') { + return { + remoteFeatureFlags: { + perpsPerpTradingGeoBlockedCountriesV2: { + blockedRegions: ['US', 'CA'], + }, + }, + }; + } + return undefined; + }); + const testMessenger = createMockMessenger({ call: testMockCall }); + + // When: Controller is constructed + const testController = new TestablePerpsController({ + messenger: testMessenger, + state: getDefaultPerpsControllerState(), + infrastructure: createMockInfrastructure(), + }); + + // Then: Should have called to get RemoteFeatureFlagController state via messenger + expect(testController).toBeDefined(); + expect(testMockCall).toHaveBeenCalledWith( + 'RemoteFeatureFlagController:getState', + ); + }); + + it('applies remote blocked regions when available during construction', () => { + // Given: Messenger that returns remote feature flags with blocked regions + const testMockCall = jest.fn().mockImplementation((action: string) => { + if (action === 'RemoteFeatureFlagController:getState') { + return { + remoteFeatureFlags: { + perpsPerpTradingGeoBlockedCountriesV2: { + blockedRegions: ['US-NY', 'CA-ON'], + }, + }, + }; + } + return undefined; + }); + + // When: Controller is constructed + const testController = new TestablePerpsController({ + messenger: createMockMessenger({ call: testMockCall }), + state: getDefaultPerpsControllerState(), + infrastructure: createMockInfrastructure(), + clientConfig: { + fallbackBlockedRegions: ['FALLBACK-REGION'], + }, + }); + + // Then: Should have used remote regions (not fallback) + // Verify by checking the internal blockedRegionList + const blockedRegionList = testController.testGetBlockedRegionList(); + expect(blockedRegionList.source).toBe('remote'); + expect(blockedRegionList.list).toEqual(['US-NY', 'CA-ON']); + }); + + it('uses fallback regions when remote flags are not available', () => { + // Given: Remote feature flags without blocked regions + const mockCall = jest.fn().mockImplementation((action: string) => { + if (action === 'RemoteFeatureFlagController:getState') { + return { + remoteFeatureFlags: {}, + }; + } + return undefined; + }); + + // When: Controller is constructed with fallback regions + const testController = new TestablePerpsController({ + messenger: createMockMessenger({ call: mockCall }), + state: getDefaultPerpsControllerState(), + infrastructure: createMockInfrastructure(), + clientConfig: { + fallbackBlockedRegions: ['FALLBACK-US', 'FALLBACK-CA'], + }, + }); + + // Then: Should have used fallback regions + const blockedRegionList = testController.testGetBlockedRegionList(); + expect(blockedRegionList.source).toBe('fallback'); + expect(blockedRegionList.list).toEqual(['FALLBACK-US', 'FALLBACK-CA']); + }); + + it('never downgrade from remote to fallback regions', () => { + // Given: Messenger that returns remote feature flags with blocked regions + const testMockCall = jest.fn().mockImplementation((action: string) => { + if (action === 'RemoteFeatureFlagController:getState') { + return { + remoteFeatureFlags: { + perpsPerpTradingGeoBlockedCountriesV2: { + blockedRegions: ['REMOTE-US'], + }, + }, + }; + } + return undefined; + }); + + // When: Controller is constructed with both remote and fallback + const testController = new TestablePerpsController({ + messenger: createMockMessenger({ call: testMockCall }), + state: getDefaultPerpsControllerState(), + infrastructure: createMockInfrastructure(), + clientConfig: { + fallbackBlockedRegions: ['FALLBACK-US'], + }, + }); + + // Then: Should use remote (set after fallback) + let blockedRegionList = testController.testGetBlockedRegionList(); + expect(blockedRegionList.source).toBe('remote'); + expect(blockedRegionList.list).toEqual(['REMOTE-US']); + + // When: Attempt to set fallback again (simulating what setBlockedRegionList does) + testController.testSetBlockedRegionList(['NEW-FALLBACK'], 'fallback'); + + // Then: Should still use remote (no downgrade) + blockedRegionList = testController.testGetBlockedRegionList(); + expect(blockedRegionList.source).toBe('remote'); + expect(blockedRegionList.list).toEqual(['REMOTE-US']); + }); + + it('continues initialization when RemoteFeatureFlagController state call throws error', () => { + const testInfrastructure = createMockInfrastructure(); + const testMockCall = jest.fn().mockImplementation((action: string) => { + if (action === 'RemoteFeatureFlagController:getState') { + throw new Error('RemoteFeatureFlagController not ready'); + } + return undefined; + }); + + const testController = new TestablePerpsController({ + messenger: createMockMessenger({ call: testMockCall }), + state: getDefaultPerpsControllerState(), + infrastructure: testInfrastructure, + clientConfig: { + fallbackBlockedRegions: ['FALLBACK-US', 'FALLBACK-CA'], + }, + }); + + expect(testController).toBeDefined(); + const blockedRegionList = testController.testGetBlockedRegionList(); + expect(blockedRegionList.source).toBe('fallback'); + expect(blockedRegionList.list).toEqual(['FALLBACK-US', 'FALLBACK-CA']); + expect(testInfrastructure.logger.error).toHaveBeenCalledWith( + expect.any(Error), + expect.objectContaining({ + tags: expect.objectContaining({ + feature: 'perps', + }), + context: expect.objectContaining({ + name: 'PerpsController', + data: expect.objectContaining({ + method: 'constructor', + operation: 'readRemoteFeatureFlags', + }), + }), + }), + ); + }); + }); + + describe('deferEligibilityCheck', () => { + it('skips refreshEligibility when eligibility check is deferred', async () => { + // Arrange + const testMockCall = jest.fn().mockImplementation((action: string) => { + if (action === 'RemoteFeatureFlagController:getState') { + return { remoteFeatureFlags: {} }; + } + if (action === 'GeolocationController:getGeolocation') { + return 'US'; + } + return undefined; + }); + + const deferredController = new TestablePerpsController({ + messenger: createMockMessenger({ call: testMockCall }), + state: getDefaultPerpsControllerState(), + infrastructure: createMockInfrastructure(), + deferEligibilityCheck: true, + }); + + // Act + await deferredController.refreshEligibility(); + + // Assert — geolocation was never called because refreshEligibility returned early + expect(testMockCall).not.toHaveBeenCalledWith( + 'GeolocationController:getGeolocation', + ); + }); + + it('resumes eligibility checks after startEligibilityMonitoring is called', () => { + // Arrange + const testMockCall = jest.fn().mockImplementation((action: string) => { + if (action === 'RemoteFeatureFlagController:getState') { + return { + remoteFeatureFlags: { + perpsPerpTradingGeoBlockedCountriesV2: { + blockedRegions: ['US'], + }, + }, + }; + } + return undefined; + }); + + const deferredController = new TestablePerpsController({ + messenger: createMockMessenger({ call: testMockCall }), + state: getDefaultPerpsControllerState(), + infrastructure: createMockInfrastructure(), + deferEligibilityCheck: true, + }); + + // Reset mocks after construction to isolate startEligibilityMonitoring behavior + testMockCall.mockClear(); + mockFeatureFlagConfigurationServiceInstance.refreshEligibility.mockClear(); + + // Re-wire the mock so it still returns flags when called again + testMockCall.mockImplementation((action: string) => { + if (action === 'RemoteFeatureFlagController:getState') { + return { + remoteFeatureFlags: { + perpsPerpTradingGeoBlockedCountriesV2: { + blockedRegions: ['US'], + }, + }, + }; + } + return undefined; + }); + + // Act + deferredController.startEligibilityMonitoring(); + + // Assert — startEligibilityMonitoring itself reads remote flags and triggers eligibility + expect(testMockCall).toHaveBeenCalledWith( + 'RemoteFeatureFlagController:getState', + ); + expect( + mockFeatureFlagConfigurationServiceInstance.refreshEligibility, + ).toHaveBeenCalled(); + }); + + it('logs error when RemoteFeatureFlagController throws during startEligibilityMonitoring', () => { + // Arrange + const testInfrastructure = createMockInfrastructure(); + const testMockCall = jest.fn().mockImplementation((action: string) => { + if (action === 'RemoteFeatureFlagController:getState') { + throw new Error('Controller not ready'); + } + return undefined; + }); + + const deferredController = new TestablePerpsController({ + messenger: createMockMessenger({ call: testMockCall }), + state: getDefaultPerpsControllerState(), + infrastructure: testInfrastructure, + deferEligibilityCheck: true, + }); + + // Reset mock to isolate startEligibilityMonitoring errors from constructor errors + (testInfrastructure.logger.error as jest.Mock).mockClear(); + + // Act — should not throw + expect(() => + deferredController.startEligibilityMonitoring(), + ).not.toThrow(); + + // Assert — error was logged + expect(testInfrastructure.logger.error).toHaveBeenCalledWith( + expect.any(Error), + expect.objectContaining({ + context: expect.objectContaining({ + data: expect.objectContaining({ + method: 'startEligibilityMonitoring', + operation: 'readRemoteFeatureFlags', + }), + }), + }), + ); + }); + + it('stopEligibilityMonitoring defers subsequent refreshEligibility calls', async () => { + // Arrange — controller without deferral + const testMockCall = jest.fn().mockImplementation((action: string) => { + if (action === 'RemoteFeatureFlagController:getState') { + return { remoteFeatureFlags: {} }; + } + if (action === 'GeolocationController:getGeolocation') { + return 'US'; + } + return undefined; + }); + + const testController = new TestablePerpsController({ + messenger: createMockMessenger({ call: testMockCall }), + state: getDefaultPerpsControllerState(), + infrastructure: createMockInfrastructure(), + }); + testMockCall.mockClear(); + + // Act + testController.stopEligibilityMonitoring(); + await testController.refreshEligibility(); + + // Assert — geolocation was never called + expect(testMockCall).not.toHaveBeenCalledWith( + 'GeolocationController:getGeolocation', + ); + }); + }); + + describe('HIP-3 Configuration Integration', () => { + it('delegates HIP-3 config updates to FeatureFlagConfigurationService', () => { + const remoteFlags = { + remoteFeatureFlags: { + perpsHip3AllowlistMarkets: 'BTC-USD,ETH-USD', + perpsHip3BlocklistMarkets: 'SCAM-USD', + }, + }; + + controller.testRefreshEligibilityOnFeatureFlagChange(remoteFlags); + + expect( + mockFeatureFlagConfigurationServiceInstance.refreshEligibility, + ).toHaveBeenCalledWith({ + remoteFeatureFlagControllerState: remoteFlags, + context: expect.objectContaining({ + getHip3Config: expect.any(Function), + setHip3Config: expect.any(Function), + incrementHip3ConfigVersion: expect.any(Function), + }), + }); + }); + + it('does not crash on malformed remote flags', () => { + const malformedFlags = { + remoteFeatureFlags: { + perpsHip3AllowlistMarkets: 123, + }, + }; + + expect(() => + controller.testRefreshEligibilityOnFeatureFlagChange(malformedFlags), + ).not.toThrow(); + }); + }); + + describe('getActiveProvider', () => { + it('throws error when not initialized', () => { + controller.testSetInitialized(false); + + expect(() => controller.getActiveProvider()).toThrow( + 'CLIENT_NOT_INITIALIZED', + ); + }); + + it('returns provider when initialized', () => { + markControllerAsInitialized(); + controller.testSetProviders(new Map([['hyperliquid', mockProvider]])); + + const provider = controller.getActiveProvider(); + expect(provider).toBe(mockProvider); + }); + + it('throws plain CLIENT_NOT_INITIALIZED on Failed state (not compound string)', () => { + controller.testSetInitialized(false); + controller.testUpdate((state) => { + state.initializationState = InitializationState.Failed; + state.initializationError = 'WebSocket transport failed'; + }); + + expect(() => controller.getActiveProvider()).toThrow( + PERPS_ERROR_CODES.CLIENT_NOT_INITIALIZED, + ); + + try { + controller.getActiveProvider(); + } catch (e: any) { + expect(e.message).toBe(PERPS_ERROR_CODES.CLIENT_NOT_INITIALIZED); + expect(e.message).not.toContain(':'); + } + }); + + it('does not log to Sentry when state is Failed', () => { + controller.testSetInitialized(false); + controller.testUpdate((state) => { + state.initializationState = InitializationState.Failed; + state.initializationError = 'WebSocket transport failed'; + }); + + expect(() => controller.getActiveProvider()).toThrow( + PERPS_ERROR_CODES.CLIENT_NOT_INITIALIZED, + ); + expect(mockInfrastructure.logger.error).not.toHaveBeenCalled(); + }); + + it('does not log to Sentry when initializationError is null on Failed state', () => { + controller.testSetInitialized(false); + controller.testUpdate((state) => { + state.initializationState = InitializationState.Failed; + state.initializationError = null; + }); + + expect(() => controller.getActiveProvider()).toThrow( + PERPS_ERROR_CODES.CLIENT_NOT_INITIALIZED, + ); + expect(mockInfrastructure.logger.error).not.toHaveBeenCalled(); + }); + }); + + describe('getActiveProviderOrNull', () => { + it('returns null during reinitialization', () => { + markControllerAsInitialized(); + controller.testSetProviders(new Map([['hyperliquid', mockProvider]])); + jest.spyOn(controller, 'isCurrentlyReinitializing').mockReturnValue(true); + + const result = controller.getActiveProviderOrNull(); + + expect(result).toBeNull(); + }); + + it('returns null when not initialized', () => { + controller.testSetInitialized(false); + + const result = controller.getActiveProviderOrNull(); + + expect(result).toBeNull(); + }); + + it('returns provider when initialized and not reinitializing', () => { + markControllerAsInitialized(); + controller.testSetProviders(new Map([['hyperliquid', mockProvider]])); + + const result = controller.getActiveProviderOrNull(); + + expect(result).toBe(mockProvider); + }); + }); + + describe('action calls during initialization', () => { + it('discards an in-flight market preload after disconnect', async () => { + await controller.init(); + const marketData = createDeferred< + { + symbol: string; + name: string; + price: string; + maxLeverage: string; + change24h: string; + change24hPercent: string; + volume: string; + }[] + >(); + mockMarketDataServiceInstance.getMarketDataWithPrices.mockReturnValueOnce( + marketData.promise, + ); + + controller.startMarketDataPreload(); + await Promise.resolve(); + expect( + mockMarketDataServiceInstance.getMarketDataWithPrices, + ).toHaveBeenCalledTimes(1); + + await controller.disconnect(); + marketData.resolve([ + { + symbol: 'BTC', + name: 'Bitcoin', + price: '50000', + maxLeverage: '50x', + change24h: '+100', + change24hPercent: '+0.2%', + volume: '$1B', + }, + ]); + await marketData.promise; + await Promise.resolve(); + await Promise.resolve(); + + expect( + controller.state.cachedMarketDataByProvider['hyperliquid:mainnet'], + ).toBeUndefined(); + expect(mockInfrastructure.diskCache.setItem).not.toHaveBeenCalledWith( + PERPS_DISK_CACHE_MARKETS, + expect.any(String), + ); + }); + + it('defers market preloading until an in-flight disconnect finishes', async () => { + await controller.init(); + const disconnectStarted = createDeferred(); + const pendingDisconnect = createDeferred(); + mockProvider.disconnect.mockImplementationOnce(async () => { + disconnectStarted.resolve(); + await pendingDisconnect.promise; + return { success: true }; + }); + + const disconnectPromise = controller.disconnect(); + await disconnectStarted.promise; + controller.startMarketDataPreload(); + + expect(mockInfrastructure.debugLogger.log).toHaveBeenCalledWith( + 'PerpsController: Disconnect in progress, deferring market data preload', + ); + expect( + mockMarketDataServiceInstance.getMarketDataWithPrices, + ).not.toHaveBeenCalled(); + + pendingDisconnect.resolve(); + await disconnectPromise; + await Promise.resolve(); + + expect( + mockMarketDataServiceInstance.getMarketDataWithPrices, + ).toHaveBeenCalledTimes(1); + controller.stopMarketDataPreload(); + }); + + it('lets an explicit stop cancel a deferred preload start', async () => { + await controller.init(); + const disconnectStarted = createDeferred(); + const pendingDisconnect = createDeferred(); + mockProvider.disconnect.mockImplementationOnce(async () => { + disconnectStarted.resolve(); + await pendingDisconnect.promise; + return { success: true }; + }); + + const disconnectPromise = controller.disconnect(); + await disconnectStarted.promise; + controller.startMarketDataPreload(); + controller.stopMarketDataPreload(); + pendingDisconnect.resolve(); + await disconnectPromise; + await Promise.resolve(); + + expect( + mockMarketDataServiceInstance.getMarketDataWithPrices, + ).not.toHaveBeenCalled(); + }); + + it('lets a later disconnect cancel a deferred preload start', async () => { + await controller.init(); + const disconnectStarted = createDeferred(); + const pendingDisconnect = createDeferred(); + mockProvider.disconnect.mockImplementationOnce(async () => { + disconnectStarted.resolve(); + await pendingDisconnect.promise; + return { success: true }; + }); + + const firstDisconnect = controller.disconnect(); + await disconnectStarted.promise; + controller.startMarketDataPreload(); + const secondDisconnect = controller.disconnect(); + pendingDisconnect.resolve(); + await Promise.all([firstDisconnect, secondDisconnect]); + await Promise.resolve(); + + expect( + mockMarketDataServiceInstance.getMarketDataWithPrices, + ).not.toHaveBeenCalled(); + }); + + it('initializes only after an in-flight disconnect finishes', async () => { + await controller.init(); + const disconnectStarted = createDeferred(); + const pendingDisconnect = createDeferred(); + mockProvider.disconnect.mockImplementationOnce(() => { + disconnectStarted.resolve(); + return pendingDisconnect.promise; + }); + + const disconnectPromise = controller.disconnect(); + await disconnectStarted.promise; + let initSettled = false; + const initPromise = controller.init().then(() => { + initSettled = true; + }); + await Promise.resolve(); + await Promise.resolve(); + + expect(initSettled).toBe(false); + pendingDisconnect.resolve(); + await disconnectPromise; + await initPromise; + + expect(controller.testGetInitialized()).toBe(true); + expect(mockProvider.disconnect).toHaveBeenCalledTimes(2); + }); + + it('lets a later disconnect win over init queued behind a disconnect', async () => { + await controller.init(); + const disconnectStarted = createDeferred(); + const pendingDisconnect = createDeferred(); + const replacementProvider = createMockHyperLiquidProvider(); + mockProvider.disconnect.mockImplementationOnce(() => { + disconnectStarted.resolve(); + return pendingDisconnect.promise; + }); + jest + .mocked(HyperLiquidProvider) + .mockImplementationOnce(() => replacementProvider); + + const firstDisconnect = controller.disconnect(); + await disconnectStarted.promise; + const initPromise = controller.init(); + const secondDisconnect = controller.disconnect(); + pendingDisconnect.resolve(); + await Promise.all([firstDisconnect, initPromise, secondDisconnect]); + + expect(replacementProvider.disconnect).toHaveBeenCalledTimes(1); + expect(controller.testGetInitialized()).toBe(false); + expect(controller.getActiveProviderOrNull()).toBeNull(); + }); + + it.each([ + { + label: 'network toggle', + start: (current: TestablePerpsController) => current.toggleTestnet(), + expected: { success: true, isTestnet: true }, + }, + { + label: 'provider switch', + start: (current: TestablePerpsController) => + current.switchProvider('aggregated'), + expected: { success: true, providerId: 'aggregated' }, + }, + ])( + 'keeps a $label queued behind a second disconnect', + async ({ start, expected }) => { + await controller.init(); + const firstDisconnectStarted = createDeferred(); + const releaseFirstDisconnect = createDeferred(); + mockProvider.disconnect.mockImplementationOnce(async () => { + firstDisconnectStarted.resolve(); + await releaseFirstDisconnect.promise; + return { success: true }; + }); + + const initializedProvider = createMockHyperLiquidProvider(); + const initializedProviderCreated = createDeferred(); + const releaseInitialization = createDeferred(); + const secondDisconnectStarted = createDeferred(); + const releaseSecondDisconnect = createDeferred(); + initializedProvider.disconnect.mockImplementationOnce(async () => { + secondDisconnectStarted.resolve(); + await releaseSecondDisconnect.promise; + return { success: true }; + }); + jest + .mocked(mockWait) + .mockImplementationOnce(() => releaseInitialization.promise); + jest + .mocked(HyperLiquidProvider) + .mockImplementationOnce(() => { + initializedProviderCreated.resolve(); + return initializedProvider; + }) + .mockImplementation(() => createMockHyperLiquidProvider()); + + const firstDisconnect = controller.disconnect(); + await firstDisconnectStarted.promise; + const queuedInitialization = controller.init(); + const secondDisconnect = controller.disconnect(); + let operationSettled = false; + const operation = start(controller).then((result) => { + operationSettled = true; + return result; + }); + + releaseFirstDisconnect.resolve(); + await initializedProviderCreated.promise; + releaseInitialization.resolve(); + await secondDisconnectStarted.promise; + + expect(controller.isCurrentlyReinitializing()).toBe(false); + expect(operationSettled).toBe(false); + + releaseSecondDisconnect.resolve(); + await Promise.all([ + firstDisconnect, + queuedInitialization, + secondDisconnect, + ]); + await expect(operation).resolves.toStrictEqual(expected); + expect(controller.testGetInitialized()).toBe(true); + expect(controller.getActiveProviderOrNull()).not.toBeNull(); + }, + ); + + it('does not route an action through a provider being disconnected', async () => { + await controller.init(); + const disconnectStarted = createDeferred(); + const pendingDisconnect = createDeferred(); + mockProvider.disconnect.mockImplementationOnce(async () => { + disconnectStarted.resolve(); + await pendingDisconnect.promise; + return { success: true }; + }); + + const disconnectPromise = controller.disconnect(); + await disconnectStarted.promise; + const orderPromise = controller.placeOrder({ + symbol: 'BTC', + isBuy: true, + size: '0.1', + orderType: 'market', + }); + await Promise.resolve(); + + expect(mockTradingServiceInstance.placeOrder).not.toHaveBeenCalled(); + pendingDisconnect.resolve(); + await disconnectPromise; + await expect(orderPromise).rejects.toThrow( + PERPS_ERROR_CODES.CLIENT_NOT_INITIALIZED, + ); + expect(mockTradingServiceInstance.placeOrder).not.toHaveBeenCalled(); + }); + + it('keeps init queued when disconnect starts during reinitialization', async () => { + await controller.init(); + const replacementProvider = createMockHyperLiquidProvider(); + const disconnectStarted = createDeferred(); + const pendingDisconnect = createDeferred(); + replacementProvider.disconnect.mockImplementationOnce(async () => { + disconnectStarted.resolve(); + await pendingDisconnect.promise; + return { success: true }; + }); + jest + .mocked(HyperLiquidProvider) + .mockImplementationOnce(() => replacementProvider); + const pendingReinitialization = createDeferred(); + jest + .mocked(mockWait) + .mockImplementationOnce(() => pendingReinitialization.promise); + + const togglePromise = controller.toggleTestnet(); + await Promise.resolve(); + await Promise.resolve(); + let initSettled = false; + const initPromise = controller.init().then(() => { + initSettled = true; + }); + const disconnectPromise = controller.disconnect(); + + pendingReinitialization.resolve(); + await disconnectStarted.promise; + + expect(initSettled).toBe(false); + pendingDisconnect.resolve(); + await disconnectPromise; + await togglePromise; + await initPromise; + + expect(controller.testGetInitialized()).toBe(true); + }); + + it('keeps init queued behind a network toggle released by disconnect', async () => { + await controller.init(); + const disconnectStarted = createDeferred(); + const pendingDisconnect = createDeferred(); + mockProvider.disconnect.mockImplementationOnce(() => { + disconnectStarted.resolve(); + return pendingDisconnect.promise; + }); + const disconnectPromise = controller.disconnect(); + await disconnectStarted.promise; + + const replacementProvider = createMockHyperLiquidProvider(); + const reinitializationStarted = createDeferred(); + jest.mocked(HyperLiquidProvider).mockImplementationOnce(() => { + reinitializationStarted.resolve(); + return replacementProvider; + }); + const pendingReinitialization = createDeferred(); + jest + .mocked(mockWait) + .mockImplementationOnce(() => pendingReinitialization.promise); + const togglePromise = controller.toggleTestnet(); + let initSettled = false; + const initPromise = controller.init().then(() => { + initSettled = true; + }); + + pendingDisconnect.resolve(); + await disconnectPromise; + await reinitializationStarted.promise; + + expect(initSettled).toBe(false); + expect(HyperLiquidProvider).toHaveBeenCalledTimes(2); + pendingReinitialization.resolve(); + await expect(togglePromise).resolves.toStrictEqual({ + success: true, + isTestnet: true, + }); + await initPromise; + + expect(HyperLiquidProvider).toHaveBeenCalledTimes(2); + expect(controller.testGetInitialized()).toBe(true); + }); + + it('keeps a provider switch queued behind init released by disconnect', async () => { + await controller.init(); + const disconnectStarted = createDeferred(); + const pendingDisconnect = createDeferred(); + mockProvider.disconnect.mockImplementationOnce(() => { + disconnectStarted.resolve(); + return pendingDisconnect.promise; + }); + const disconnectPromise = controller.disconnect(); + await disconnectStarted.promise; + + const initializedProvider = createMockHyperLiquidProvider(); + const switchedProvider = createMockHyperLiquidProvider(); + const initializationStarted = createDeferred(); + jest + .mocked(HyperLiquidProvider) + .mockImplementationOnce(() => { + initializationStarted.resolve(); + return initializedProvider; + }) + .mockImplementationOnce(() => switchedProvider); + const pendingInitialization = createDeferred(); + jest + .mocked(mockWait) + .mockImplementationOnce(() => pendingInitialization.promise); + const initPromise = controller.init(); + let switchSettled = false; + const switchPromise = controller + .switchProvider('aggregated') + .then((result) => { + switchSettled = true; + return result; + }); + + pendingDisconnect.resolve(); + await disconnectPromise; + await initializationStarted.promise; + + expect(switchSettled).toBe(false); + expect(HyperLiquidProvider).toHaveBeenCalledTimes(2); + pendingInitialization.resolve(); + await initPromise; + await expect(switchPromise).resolves.toStrictEqual({ + success: true, + providerId: 'aggregated', + }); + + expect(initializedProvider.disconnect).toHaveBeenCalledTimes(1); + expect(HyperLiquidProvider).toHaveBeenCalledTimes(3); + expect(controller.testGetInitialized()).toBe(true); + }); + + it('waits for pending initialization before resolving a same-provider switch', async () => { + const pendingInitialization = createDeferred(); + jest.mocked(HyperLiquidProvider).mockImplementationOnce(() => { + throw new Error('Transient initialization failure'); + }); + jest + .mocked(mockWait) + .mockImplementationOnce(() => pendingInitialization.promise); + + const initPromise = controller.init(); + await Promise.resolve(); + await Promise.resolve(); + expect(controller.state.initializationState).toBe( + InitializationState.Initializing, + ); + + let switchSettled = false; + const switchPromise = controller + .switchProvider('hyperliquid') + .then((result) => { + switchSettled = true; + return result; + }); + await Promise.resolve(); + await Promise.resolve(); + + expect(switchSettled).toBe(false); + pendingInitialization.resolve(); + await initPromise; + await expect(switchPromise).resolves.toStrictEqual({ + success: true, + providerId: 'hyperliquid', + }); + }); + + it('reinitializes before resolving a same-provider switch after disconnect', async () => { + await controller.init(); + await controller.disconnect(); + const replacementProvider = createMockHyperLiquidProvider(); + jest + .mocked(HyperLiquidProvider) + .mockImplementationOnce(() => replacementProvider); + + await expect( + controller.switchProvider('hyperliquid'), + ).resolves.toStrictEqual({ + success: true, + providerId: 'hyperliquid', + }); + + expect(controller.testGetInitialized()).toBe(true); + expect(controller.getActiveProviderOrNull()).toBe(replacementProvider); + }); + + it('rejects a same-provider switch when reinitialization fails after disconnect', async () => { + await controller.init(); + await controller.disconnect(); + jest.mocked(HyperLiquidProvider).mockImplementation(() => { + throw new Error('Provider reinitialization failed'); + }); + + await expect( + controller.switchProvider('hyperliquid'), + ).resolves.toStrictEqual({ + success: false, + providerId: 'hyperliquid', + error: 'Provider reinitialization failed', + }); + + expect(controller.testGetInitialized()).toBe(false); + expect(controller.getActiveProviderOrNull()).toBeNull(); + }); + + it.each([ + { + label: 'network toggle', + start: (current: TestablePerpsController) => current.toggleTestnet(), + }, + { + label: 'provider switch', + start: (current: TestablePerpsController) => + current.switchProvider('aggregated'), + }, + ])( + 'serializes disconnect behind a $label started during initialization', + async ({ start }) => { + const pendingInitialization = createDeferred(); + jest.mocked(HyperLiquidProvider).mockImplementationOnce(() => { + throw new Error('Transient initialization failure'); + }); + jest + .mocked(mockWait) + .mockImplementationOnce(() => pendingInitialization.promise); + + const initPromise = controller.init(); + await Promise.resolve(); + await Promise.resolve(); + expect(controller.state.initializationState).toBe( + InitializationState.Initializing, + ); + + const reinitialization = start(controller); + expect(controller.isCurrentlyReinitializing()).toBe(true); + let disconnectSettled = false; + const disconnectPromise = controller.disconnect().then(() => { + disconnectSettled = true; + }); + await Promise.resolve(); + await Promise.resolve(); + + expect(disconnectSettled).toBe(false); + pendingInitialization.resolve(); + await initPromise; + await reinitialization; + await disconnectPromise; + + expect(mockProvider.disconnect).toHaveBeenCalled(); + expect(controller.testGetInitialized()).toBe(false); + expect(controller.getActiveProviderOrNull()).toBeNull(); + }, + ); + + it('disconnects the provider created by an in-flight network toggle', async () => { + await controller.init(); + const replacementProvider = createMockHyperLiquidProvider(); + jest + .mocked(HyperLiquidProvider) + .mockImplementationOnce(() => replacementProvider); + const pendingReinitialization = createDeferred(); + jest + .mocked(mockWait) + .mockImplementationOnce(() => pendingReinitialization.promise); + + const togglePromise = controller.toggleTestnet(); + await Promise.resolve(); + await Promise.resolve(); + let disconnectSettled = false; + const disconnectPromise = controller.disconnect().then(() => { + disconnectSettled = true; + }); + await Promise.resolve(); + await Promise.resolve(); + + expect(disconnectSettled).toBe(false); + expect(replacementProvider.disconnect).not.toHaveBeenCalled(); + pendingReinitialization.resolve(); + await expect(togglePromise).resolves.toStrictEqual({ + success: true, + isTestnet: true, + }); + await disconnectPromise; + + expect(replacementProvider.disconnect).toHaveBeenCalledTimes(1); + expect(controller.testGetInitialized()).toBe(false); + }); + + it('disconnects the provider created by an in-flight provider switch', async () => { + await controller.init(); + const replacementProvider = createMockHyperLiquidProvider(); + jest + .mocked(HyperLiquidProvider) + .mockImplementationOnce(() => replacementProvider); + const pendingReinitialization = createDeferred(); + jest + .mocked(mockWait) + .mockImplementationOnce(() => pendingReinitialization.promise); + + const switchPromise = controller.switchProvider('aggregated'); + await Promise.resolve(); + await Promise.resolve(); + let disconnectSettled = false; + const disconnectPromise = controller.disconnect().then(() => { + disconnectSettled = true; + }); + await Promise.resolve(); + await Promise.resolve(); + + expect(disconnectSettled).toBe(false); + expect(replacementProvider.disconnect).not.toHaveBeenCalled(); + pendingReinitialization.resolve(); + await expect(switchPromise).resolves.toStrictEqual({ + success: true, + providerId: 'aggregated', + }); + await disconnectPromise; + + expect(replacementProvider.disconnect).toHaveBeenCalledTimes(1); + expect(controller.testGetInitialized()).toBe(false); + }); + + it('waits for initialization before disconnecting the created provider', async () => { + let resolveBlock!: () => void; + const blockingPromise = new Promise((resolve) => { + resolveBlock = resolve; + }); + let attempt = 0; + ( + HyperLiquidProvider as jest.MockedClass + ).mockImplementation(() => { + attempt++; + if (attempt === 1) { + throw new Error('Transient failure'); + } + return mockProvider; + }); + (mockWait as jest.Mock).mockImplementationOnce(() => blockingPromise); + + const initPromise = controller.init(); + await Promise.resolve(); + await Promise.resolve(); + expect(controller.state.initializationState).toBe( + InitializationState.Initializing, + ); + + let disconnectSettled = false; + const disconnectPromise = controller.disconnect().then(() => { + disconnectSettled = true; + }); + await Promise.resolve(); + await Promise.resolve(); + + expect(disconnectSettled).toBe(false); + resolveBlock(); + await initPromise; + await disconnectPromise; + + expect(mockProvider.disconnect).toHaveBeenCalledTimes(1); + expect(controller.testGetInitialized()).toBe(false); + expect(controller.getActiveProviderOrNull()).toBeNull(); + }); + + it('tears down an active provider when initialization rejects', async () => { + controller.testSetProviders(new Map([['hyperliquid', mockProvider]])); + mockInfrastructure.debugLogger.log.mockImplementationOnce(() => { + throw new Error('Initialization logging failed'); + }); + + await expect(controller.init()).rejects.toThrow( + 'Initialization logging failed', + ); + await expect(controller.disconnect()).resolves.toBeUndefined(); + + expect(mockProvider.disconnect).toHaveBeenCalledTimes(1); + expect(controller.testGetInitialized()).toBe(false); + expect(controller.getActiveProviderOrNull()).toBeNull(); + }); + + it('waits for init to complete before resolving when state is Initializing', async () => { + let resolveBlock!: () => void; + const blockingPromise = new Promise((resolve) => { + resolveBlock = resolve; + }); + + let attempt = 0; + ( + HyperLiquidProvider as jest.MockedClass + ).mockImplementation(() => { + attempt++; + if (attempt === 1) { + throw new Error('Transient failure'); + } + return mockProvider; + }); + + // Block the first retry delay so init stays in Initializing + (mockWait as jest.Mock).mockImplementationOnce(() => blockingPromise); + + const mockOrderResult = { + success: true, + orderId: '123', + status: 'filled', + }; + jest + .spyOn(mockTradingServiceInstance, 'placeOrder') + .mockResolvedValue(mockOrderResult); + + // Start init (will fail first attempt → block on retry wait) + const initPromise = controller.init(); + + // Yield so init reaches the blocking wait + await Promise.resolve(); + await Promise.resolve(); + expect(controller.state.initializationState).toBe( + InitializationState.Initializing, + ); + + // Call placeOrder while init is still in-flight — should not throw + const orderPromise = controller.placeOrder({ + symbol: 'BTC', + isBuy: true, + size: '0.1', + orderType: 'market', + } as any); + + // Unblock init retry so initialization completes + resolveBlock(); + + await initPromise; + const result = await orderPromise; + + expect(result).toEqual(expect.objectContaining({ orderId: '123' })); + }); + + it('waits for init before validating an order', async () => { + const pendingRetry = createDeferred(); + let attempt = 0; + jest.mocked(HyperLiquidProvider).mockImplementation(() => { + attempt += 1; + if (attempt === 1) { + throw new Error('Transient failure'); + } + return mockProvider; + }); + jest.mocked(mockWait).mockImplementationOnce(() => pendingRetry.promise); + mockMarketDataServiceInstance.validateOrder.mockResolvedValue({ + isValid: true, + }); + + const initPromise = controller.init(); + await Promise.resolve(); + await Promise.resolve(); + const validationPromise = controller.validateOrder({ + symbol: 'BTC', + isBuy: true, + size: '0.1', + orderType: 'market', + }); + + expect( + mockMarketDataServiceInstance.validateOrder, + ).not.toHaveBeenCalled(); + pendingRetry.resolve(); + await initPromise; + await expect(validationPromise).resolves.toStrictEqual({ isValid: true }); + expect(mockMarketDataServiceInstance.validateOrder).toHaveBeenCalledWith( + expect.objectContaining({ provider: mockProvider }), + ); + }); + + it('waits for network reinitialization before selecting the capability provider', async () => { + await controller.init(); + const replacementProvider = createMockHyperLiquidProvider(); + replacementProvider.getOrderCapabilities = jest.fn().mockResolvedValue({ + status: 'ready', + providerId: 'hyperliquid', + supportedStrategies: ['twap', 'scale', 'chase'], + }); + jest + .mocked(HyperLiquidProvider) + .mockImplementationOnce(() => replacementProvider); + + const togglePromise = controller.toggleTestnet(); + const capabilitiesPromise = controller.getOrderCapabilities({ + symbol: 'BTC', + }); + expect(replacementProvider.getOrderCapabilities).not.toHaveBeenCalled(); + + await expect(togglePromise).resolves.toStrictEqual({ + success: true, + isTestnet: true, + }); + await expect(capabilitiesPromise).resolves.toStrictEqual({ + status: 'ready', + providerId: 'hyperliquid', + supportedStrategies: ['twap', 'scale', 'chase'], + }); + expect(replacementProvider.getOrderCapabilities).toHaveBeenCalledWith({ + symbol: 'BTC', + }); + }); + + it('waits for init before selecting the capability provider', async () => { + let resolveBlock!: () => void; + const blockingPromise = new Promise((resolve) => { + resolveBlock = resolve; + }); + let attempt = 0; + ( + HyperLiquidProvider as jest.MockedClass + ).mockImplementation(() => { + attempt++; + if (attempt === 1) { + throw new Error('Transient failure'); + } + return mockProvider; + }); + (mockWait as jest.Mock).mockImplementationOnce(() => blockingPromise); + mockProvider.getOrderCapabilities = jest.fn().mockResolvedValue({ + status: 'ready', + providerId: 'hyperliquid', + supportedStrategies: ['twap', 'scale', 'chase'], + }); + + const initPromise = controller.init(); + await Promise.resolve(); + await Promise.resolve(); + expect(controller.state.initializationState).toBe( + InitializationState.Initializing, + ); + + const capabilities = controller.getOrderCapabilities({ symbol: 'BTC' }); + expect(mockProvider.getOrderCapabilities).not.toHaveBeenCalled(); + resolveBlock(); + + await initPromise; + await expect(capabilities).resolves.toStrictEqual({ + status: 'ready', + providerId: 'hyperliquid', + supportedStrategies: ['twap', 'scale', 'chase'], + }); + expect(mockProvider.getOrderCapabilities).toHaveBeenCalledWith({ + symbol: 'BTC', + }); + }); + + it('waits for init before calculating a strategy fee quote', async () => { + let resolveBlock!: () => void; + const blockingPromise = new Promise((resolve) => { + resolveBlock = resolve; + }); + + let attempt = 0; + ( + HyperLiquidProvider as jest.MockedClass + ).mockImplementation(() => { + attempt++; + if (attempt === 1) { + throw new Error('Transient failure'); + } + return mockProvider; + }); + (mockWait as jest.Mock).mockImplementationOnce(() => blockingPromise); + + const initPromise = controller.init(); + await Promise.resolve(); + await Promise.resolve(); + expect(controller.state.initializationState).toBe( + InitializationState.Initializing, + ); + + const feePromise = controller.calculateFees({ + orderType: 'twap', + symbol: 'BTC', + providerId: 'hyperliquid', + }); + expect( + mockMarketDataServiceInstance.calculateFees, + ).not.toHaveBeenCalled(); + + resolveBlock(); + + await initPromise; + await expect(feePromise).resolves.toEqual({ totalFee: 0 }); + expect(mockMarketDataServiceInstance.calculateFees).toHaveBeenCalledWith( + expect.objectContaining({ + provider: mockProvider, + params: { + orderType: 'twap', + symbol: 'BTC', + providerId: 'hyperliquid', + }, + }), + ); + }); + + it('waits for init before calculating an ordinary fee quote', async () => { + let resolveBlock!: () => void; + const blockingPromise = new Promise((resolve) => { + resolveBlock = resolve; + }); + + let attempt = 0; + ( + HyperLiquidProvider as jest.MockedClass + ).mockImplementation(() => { + attempt++; + if (attempt === 1) { + throw new Error('Transient failure'); + } + return mockProvider; + }); + (mockWait as jest.Mock).mockImplementationOnce(() => blockingPromise); + + const initPromise = controller.init(); + await Promise.resolve(); + await Promise.resolve(); + expect(controller.state.initializationState).toBe( + InitializationState.Initializing, + ); + + const feePromise = controller.calculateFees({ + orderType: 'market', + symbol: 'BTC', + }); + expect( + mockMarketDataServiceInstance.calculateFees, + ).not.toHaveBeenCalled(); + + resolveBlock(); + + await initPromise; + await expect(feePromise).resolves.toEqual({ totalFee: 0 }); + expect(mockMarketDataServiceInstance.calculateFees).toHaveBeenCalledWith( + expect.objectContaining({ + provider: mockProvider, + params: { + orderType: 'market', + symbol: 'BTC', + }, + }), + ); + }); + + it('throws CLIENT_NOT_INITIALIZED immediately when state is Failed', async () => { + controller.testSetInitialized(false); + controller.testUpdate((state) => { + state.initializationState = InitializationState.Failed; + state.initializationError = 'Network error'; + }); + + await expect( + controller.placeOrder({ + symbol: 'BTC', + isBuy: true, + size: '0.1', + orderType: 'market', + } as any), + ).rejects.toThrow(PERPS_ERROR_CODES.CLIENT_NOT_INITIALIZED); + }); + }); + + describe('init', () => { + it('initializes providers successfully', async () => { + await controller.init(); + + expect(controller.testGetInitialized()).toBe(true); + expect(controller.testGetProviders().has('hyperliquid')).toBe(true); + expect(mockMessenger.registerMethodActionHandlers).toHaveBeenCalledWith( + controller, + expect.arrayContaining(['getOrderCapabilities']), + ); + }); + + it('handles initialization when already initialized', async () => { + // First initialization + await controller.init(); + expect(controller.testGetInitialized()).toBe(true); + + // Second initialization should not throw + await controller.init(); + expect(controller.testGetInitialized()).toBe(true); + }); + + it('allows retry after all initialization attempts fail', async () => { + // Set up mock to throw errors BEFORE creating controller + const networkError = new Error('Network error'); + ( + HyperLiquidProvider as jest.MockedClass + ).mockImplementation(() => { + throw networkError; + }); + + const mockCall = jest.fn().mockImplementation((action: string) => { + if (action === 'RemoteFeatureFlagController:getState') { + return { + remoteFeatureFlags: { + perpsPerpTradingGeoBlockedCountriesV2: { + blockedRegions: [], + }, + }, + }; + } + return undefined; + }); + + const testController = new TestablePerpsController({ + messenger: createMockMessenger({ call: mockCall }), + state: getDefaultPerpsControllerState(), + infrastructure: createMockInfrastructure(), + }); + + // Explicitly start initialization (no longer auto-starts in constructor) + testController.init().catch(() => { + // Expected to fail - error is stored in state + }); + + // Wait for initialization to complete (retries happen instantly due to mocked wait()) + // Small delay allows async promise chain to resolve + await new Promise((resolve) => setTimeout(resolve, 50)); + + // Verify failure state + expect(testController.state.initializationState).toBe('failed'); + expect(testController.state.initializationError).toBe('Network error'); + expect(testController.testGetInitialized()).toBe(false); + + // Network recovers - provider succeeds on next attempt + ( + HyperLiquidProvider as jest.MockedClass + ).mockImplementation(() => mockProvider); + + // User retries initialization (e.g., via network switch) + await testController.init(); + + // Verify initialization succeeds (not cached failure) + expect(testController.state.initializationState).toBe('initialized'); + expect(testController.state.initializationError).toBeNull(); + expect(testController.testGetInitialized()).toBe(true); + }); // Fast execution with mocked wait() + }); +}); diff --git a/packages/perps-controller/tests/src/PerpsController.market-filtering.test.ts b/packages/perps-controller/tests/src/PerpsController.market-filtering.test.ts new file mode 100644 index 00000000000..36f605bc2d2 --- /dev/null +++ b/packages/perps-controller/tests/src/PerpsController.market-filtering.test.ts @@ -0,0 +1,470 @@ +/** + * Tests for PerpsController market filtering, sorting, and pagination: + * - getMarketCategories() + * - getMarketDataWithPrices({ categories, sortBy, direction, limit }) + */ + +/* eslint-disable */ + +import { + PerpsController, + getDefaultPerpsControllerState, + InitializationState, +} from '../../src/PerpsController.js'; +import { HyperLiquidProvider } from '../../src/providers/HyperLiquidProvider.js'; +import type { + PerpsProvider, + PerpsProviderType, + PerpsPlatformDependencies, + PerpsMarketData, +} from '../../src/types/index.js'; +import { MARKET_CATEGORIES, MarketCategory } from '../../src/types/index.js'; +import { createMockHyperLiquidProvider } from '../helpers/providerMocks.js'; +import { + createMockInfrastructure, + createMockMessenger, +} from '../helpers/serviceMocks.js'; + +jest.mock('@nktkas/hyperliquid', () => ({})); +jest.mock('@myx-trade/sdk', () => ({ + MyxClient: jest.fn(), + OrderStatusEnum: { Successful: 9 }, +})); + +jest.mock('../../src/providers/HyperLiquidProvider'); +jest.mock('../../src/providers/MYXProvider'); + +jest.mock('../../src/utils/wait', () => ({ + wait: jest.fn().mockResolvedValue(undefined), +})); + +jest.mock('../../src/services/EligibilityService', () => ({ + EligibilityService: jest.fn().mockImplementation(() => ({ + checkAndUpdateEligibility: jest + .fn() + .mockResolvedValue({ isEligible: true }), + isCurrentlyEligible: jest.fn().mockReturnValue(true), + subscribeToEligibilityChanges: jest.fn().mockReturnValue(() => undefined), + })), +})); + +/** Expose protected methods for testing. */ +class TestablePerpsController extends PerpsController { + public testMarkInitialized() { + this.isInitialized = true; + this.update((state) => { + state.initializationState = InitializationState.Initialized; + }); + } + + public testSetProviders(providers: Map) { + this.providers = providers; + const firstProvider = providers.values().next().value; + if (firstProvider) { + this.activeProviderInstance = firstProvider; + } + } +} + +/** Build a minimal PerpsMarketData object. */ +function buildMarket( + overrides: Partial = {}, +): PerpsMarketData { + return { + symbol: 'TEST', + name: 'Test Market', + maxLeverage: '10x', + price: '$100.00', + change24h: '$0.00', + change24hPercent: '0%', + volume: '$1M', + ...overrides, + }; +} + +describe('PerpsController — market categories & filtering', () => { + let controller: TestablePerpsController; + let mockProvider: jest.Mocked; + let mockInfrastructure: jest.Mocked; + + beforeEach(() => { + jest.clearAllMocks(); + + mockProvider = createMockHyperLiquidProvider(); + mockInfrastructure = createMockInfrastructure(); + + const mockCall = jest.fn().mockReturnValue(undefined); + + controller = new TestablePerpsController({ + messenger: createMockMessenger({ call: mockCall }), + state: getDefaultPerpsControllerState(), + infrastructure: mockInfrastructure, + }); + + controller.testSetProviders( + new Map([['hyperliquid', mockProvider as unknown as PerpsProvider]]), + ); + controller.testMarkInitialized(); + }); + + // ============================================================================ + // getMarketCategories + // ============================================================================ + + describe('getMarketCategories', () => { + it('returns the MARKET_CATEGORIES constant', () => { + expect(controller.getMarketCategories()).toStrictEqual(MARKET_CATEGORIES); + }); + + it('does not include the all or new sentinel values', () => { + const categories = controller.getMarketCategories(); + expect(categories).not.toContain('all'); + expect(categories).not.toContain('new'); + }); + + it('includes all 7 data categories', () => { + const categories = controller.getMarketCategories(); + expect(categories).toContain('crypto'); + expect(categories).toContain('stock'); + expect(categories).toContain('pre-ipo'); + expect(categories).toContain('index'); + expect(categories).toContain('etf'); + expect(categories).toContain('commodity'); + expect(categories).toContain('forex'); + }); + }); + + // ============================================================================ + // getMarketDataWithPrices — category filtering + // ============================================================================ + + describe('getMarketDataWithPrices — category filtering', () => { + it('returns unfiltered results when no params are provided', async () => { + const markets = [ + buildMarket({ symbol: 'BTC', isHip3: false }), + buildMarket({ + symbol: 'xyz:TSLA', + isHip3: true, + marketType: MarketCategory.Stock, + }), + ]; + mockProvider.getMarketDataWithPrices.mockResolvedValue(markets); + + const result = await controller.getMarketDataWithPrices(); + + expect(result).toHaveLength(2); + }); + + it('filters to only crypto markets when categories is ["crypto"]', async () => { + const markets = [ + buildMarket({ symbol: 'BTC', isHip3: false }), + buildMarket({ symbol: 'ETH', isHip3: false }), + buildMarket({ + symbol: 'xyz:CRYPTO1', + isHip3: true, + marketType: MarketCategory.CryptoCurrency, + }), + buildMarket({ + symbol: 'xyz:TSLA', + isHip3: true, + marketType: MarketCategory.Stock, + }), + ]; + mockProvider.getMarketDataWithPrices.mockResolvedValue(markets); + + const result = await controller.getMarketDataWithPrices({ + categories: ['crypto'], + }); + + // Non-HIP3 markets + HIP-3 assets explicitly typed CryptoCurrency; excludes Stock + expect(result).toHaveLength(3); + const symbols = result.map((m) => m.symbol); + expect(symbols).toContain('BTC'); + expect(symbols).toContain('ETH'); + expect(symbols).toContain('xyz:CRYPTO1'); + expect(symbols).not.toContain('xyz:TSLA'); + }); + + it('filters to only stock markets when categories is ["stock"]', async () => { + const markets = [ + buildMarket({ symbol: 'BTC', isHip3: false }), + buildMarket({ + symbol: 'xyz:TSLA', + isHip3: true, + marketType: MarketCategory.Stock, + }), + buildMarket({ + symbol: 'xyz:NVDA', + isHip3: true, + marketType: MarketCategory.Stock, + }), + buildMarket({ + symbol: 'xyz:EUR', + isHip3: true, + marketType: MarketCategory.Forex, + }), + ]; + mockProvider.getMarketDataWithPrices.mockResolvedValue(markets); + + const result = await controller.getMarketDataWithPrices({ + categories: ['stock'], + }); + + expect(result).toHaveLength(2); + expect(result.every((m) => m.marketType === MarketCategory.Stock)).toBe( + true, + ); + }); + + it('returns the union of matched markets when multiple categories are given', async () => { + const markets = [ + buildMarket({ symbol: 'BTC', isHip3: false }), + buildMarket({ + symbol: 'xyz:TSLA', + isHip3: true, + marketType: MarketCategory.Stock, + }), + buildMarket({ + symbol: 'xyz:SPY', + isHip3: true, + marketType: MarketCategory.Etf, + }), + buildMarket({ + symbol: 'xyz:EUR', + isHip3: true, + marketType: MarketCategory.Forex, + }), + ]; + mockProvider.getMarketDataWithPrices.mockResolvedValue(markets); + + const result = await controller.getMarketDataWithPrices({ + categories: ['stock', 'etf'], + }); + + expect(result).toHaveLength(2); + const symbols = result.map((m) => m.symbol); + expect(symbols).toContain('xyz:TSLA'); + expect(symbols).toContain('xyz:SPY'); + }); + + it('returns all markets when categories contains "all"', async () => { + const markets = [ + buildMarket({ symbol: 'BTC', isHip3: false }), + buildMarket({ + symbol: 'xyz:TSLA', + isHip3: true, + marketType: MarketCategory.Stock, + }), + ]; + mockProvider.getMarketDataWithPrices.mockResolvedValue(markets); + + const result = await controller.getMarketDataWithPrices({ + categories: ['all'], + }); + + expect(result).toHaveLength(2); + }); + + it('filters to isNewMarket markets when categories is ["new"]', async () => { + const markets = [ + buildMarket({ symbol: 'BTC', isNewMarket: false }), + buildMarket({ + symbol: 'xyz:NEWTOKEN', + isHip3: true, + isNewMarket: true, + }), + ]; + mockProvider.getMarketDataWithPrices.mockResolvedValue(markets); + + const result = await controller.getMarketDataWithPrices({ + categories: ['new'], + }); + + expect(result).toHaveLength(1); + expect(result[0].symbol).toBe('xyz:NEWTOKEN'); + }); + + it('returns empty array when no markets match the given categories', async () => { + const markets = [buildMarket({ symbol: 'BTC', isHip3: false })]; + mockProvider.getMarketDataWithPrices.mockResolvedValue(markets); + + const result = await controller.getMarketDataWithPrices({ + categories: ['forex'], + }); + + expect(result).toHaveLength(0); + }); + }); + + // ============================================================================ + // getMarketDataWithPrices — excludeSymbols + // ============================================================================ + + describe('getMarketDataWithPrices — excludeSymbols', () => { + it('excludes a single symbol from results', async () => { + const markets = [ + buildMarket({ symbol: 'BTC' }), + buildMarket({ symbol: 'ETH' }), + buildMarket({ symbol: 'SOL' }), + ]; + mockProvider.getMarketDataWithPrices.mockResolvedValue(markets); + + const result = await controller.getMarketDataWithPrices({ + excludeSymbols: ['ETH'], + }); + + expect(result.map((m) => m.symbol)).toStrictEqual(['BTC', 'SOL']); + }); + + it('excludes multiple symbols from results', async () => { + const markets = [ + buildMarket({ symbol: 'BTC' }), + buildMarket({ symbol: 'ETH' }), + buildMarket({ symbol: 'SOL' }), + buildMarket({ symbol: 'AVAX' }), + ]; + mockProvider.getMarketDataWithPrices.mockResolvedValue(markets); + + const result = await controller.getMarketDataWithPrices({ + excludeSymbols: ['ETH', 'SOL'], + }); + + expect(result.map((m) => m.symbol)).toStrictEqual(['BTC', 'AVAX']); + }); + + it('applies excludeSymbols after category filter and before limit', async () => { + const markets = [ + buildMarket({ symbol: 'BTC', isHip3: false }), + buildMarket({ symbol: 'ETH', isHip3: false }), + buildMarket({ symbol: 'SOL', isHip3: false }), + buildMarket({ + symbol: 'xyz:TSLA', + isHip3: true, + marketType: MarketCategory.Stock, + }), + ]; + mockProvider.getMarketDataWithPrices.mockResolvedValue(markets); + + const result = await controller.getMarketDataWithPrices({ + categories: ['crypto'], + excludeSymbols: ['ETH'], + limit: 10, + }); + + // Stock excluded by category, ETH excluded by excludeSymbols + expect(result.map((m) => m.symbol)).toStrictEqual(['BTC', 'SOL']); + }); + }); + + // ============================================================================ + // getMarketDataWithPrices — sorting + // ============================================================================ + + describe('getMarketDataWithPrices — sorting', () => { + it('sorts by openInterest descending when sortBy is "openInterest"', async () => { + const markets = [ + buildMarket({ symbol: 'LOW', openInterest: '$1M' }), + buildMarket({ symbol: 'HIGH', openInterest: '$5M' }), + buildMarket({ symbol: 'MID', openInterest: '$3M' }), + ]; + mockProvider.getMarketDataWithPrices.mockResolvedValue(markets); + + const result = await controller.getMarketDataWithPrices({ + sortBy: 'openInterest', + direction: 'desc', + }); + + expect(result[0].symbol).toBe('HIGH'); + expect(result[1].symbol).toBe('MID'); + expect(result[2].symbol).toBe('LOW'); + }); + + it('sorts by openInterest ascending when direction is "asc"', async () => { + const markets = [ + buildMarket({ symbol: 'HIGH', openInterest: '$5M' }), + buildMarket({ symbol: 'LOW', openInterest: '$1M' }), + ]; + mockProvider.getMarketDataWithPrices.mockResolvedValue(markets); + + const result = await controller.getMarketDataWithPrices({ + sortBy: 'openInterest', + direction: 'asc', + }); + + expect(result[0].symbol).toBe('LOW'); + expect(result[1].symbol).toBe('HIGH'); + }); + }); + + // ============================================================================ + // getMarketDataWithPrices — limit + // ============================================================================ + + describe('getMarketDataWithPrices — limit', () => { + it('returns at most `limit` markets', async () => { + const markets = Array.from({ length: 10 }, (_, i) => + buildMarket({ symbol: `MARKET${i}` }), + ); + mockProvider.getMarketDataWithPrices.mockResolvedValue(markets); + + const result = await controller.getMarketDataWithPrices({ limit: 3 }); + + expect(result).toHaveLength(3); + }); + + it('returns all markets when limit exceeds total count', async () => { + const markets = [ + buildMarket({ symbol: 'A' }), + buildMarket({ symbol: 'B' }), + ]; + mockProvider.getMarketDataWithPrices.mockResolvedValue(markets); + + const result = await controller.getMarketDataWithPrices({ limit: 100 }); + + expect(result).toHaveLength(2); + }); + }); + + // ============================================================================ + // getMarketDataWithPrices — params compose together + // ============================================================================ + + describe('getMarketDataWithPrices — composed params', () => { + it('applies categories filter, then sort, then limit in order', async () => { + const markets = [ + buildMarket({ + symbol: 'xyz:TSLA', + isHip3: true, + marketType: MarketCategory.Stock, + openInterest: '$5M', + }), + buildMarket({ + symbol: 'xyz:NVDA', + isHip3: true, + marketType: MarketCategory.Stock, + openInterest: '$3M', + }), + buildMarket({ + symbol: 'xyz:AAPL', + isHip3: true, + marketType: MarketCategory.Stock, + openInterest: '$8M', + }), + buildMarket({ symbol: 'BTC', isHip3: false, openInterest: '$100M' }), + ]; + mockProvider.getMarketDataWithPrices.mockResolvedValue(markets); + + const result = await controller.getMarketDataWithPrices({ + categories: ['stock'], + sortBy: 'openInterest', + direction: 'desc', + limit: 2, + }); + + // Only stocks, sorted by OI desc, top 2 + expect(result).toHaveLength(2); + expect(result[0].symbol).toBe('xyz:AAPL'); // $8M + expect(result[1].symbol).toBe('xyz:TSLA'); // $5M + }); + }); +}); diff --git a/packages/perps-controller/tests/src/PerpsController.operations.test.ts b/packages/perps-controller/tests/src/PerpsController.operations.test.ts new file mode 100644 index 00000000000..86f4e21853e --- /dev/null +++ b/packages/perps-controller/tests/src/PerpsController.operations.test.ts @@ -0,0 +1,2781 @@ +/* eslint-disable */ +/** + * PerpsController Tests + * Clean, focused test suite for PerpsController + */ + +/* eslint-disable @typescript-eslint/no-explicit-any */ + +import { createMockHyperLiquidProvider } from '../helpers/providerMocks.js'; +import { + createMockInfrastructure, + createMockMessenger, +} from '../helpers/serviceMocks.js'; + +jest.mock('@nktkas/hyperliquid', () => ({})); +jest.mock('@myx-trade/sdk', () => ({ + MyxClient: jest.fn(), + OrderStatusEnum: { Successful: 9 }, +})); + +import { + PERPS_EVENT_PROPERTY, + PERPS_EVENT_VALUE, +} from '../../src/constants/eventNames.js'; +import { + PerpsController, + getDefaultPerpsControllerState, + InitializationState, +} from '../../src/PerpsController.js'; +import type { PerpsControllerState } from '../../src/PerpsController.js'; +import { PERPS_ERROR_CODES } from '../../src/perpsErrorCodes.js'; +import { AggregatedPerpsProvider } from '../../src/providers/AggregatedPerpsProvider.js'; +import { HyperLiquidProvider } from '../../src/providers/HyperLiquidProvider.js'; +import { RewardsIntegrationService } from '../../src/services/RewardsIntegrationService.js'; +import type { + GetAvailableDexsParams, + PerpsProvider, + PerpsPlatformDependencies, + PerpsProviderType, +} from '../../src/types/index.js'; +import { PerpsAnalyticsEvent } from '../../src/types/index.js'; +import { STRATEGY_ORDER_TYPES } from '../../src/utils/orderTypes.js'; + +jest.mock('../../src/providers/HyperLiquidProvider'); +jest.mock('../../src/providers/MYXProvider'); + +// Mock transaction controller utility +const mockAddTransaction = jest.fn(); +jest.mock( + '../../../util/transaction-controller', + () => ({ + addTransaction: (...args: unknown[]) => mockAddTransaction(...args), + }), + { virtual: true }, +); + +// Mock wait utility to speed up retry tests +jest.mock('../../src/utils/wait', () => ({ + wait: jest.fn().mockResolvedValue(undefined), +})); + +// Mock stream manager +const mockStreamManager = { + positions: { pause: jest.fn(), resume: jest.fn() }, + account: { pause: jest.fn(), resume: jest.fn() }, + orders: { pause: jest.fn(), resume: jest.fn() }, + prices: { pause: jest.fn(), resume: jest.fn() }, + orderFills: { pause: jest.fn(), resume: jest.fn() }, +}; + +jest.mock( + '../../../components/UI/Perps/providers/PerpsStreamManager', + () => ({ + getStreamManagerInstance: jest.fn(() => mockStreamManager), + }), + { virtual: true }, +); + +jest.mock('@metamask/utils', () => ({ + ...jest.requireActual('@metamask/utils'), + formatAccountToCaipAccountId: jest + .fn() + .mockReturnValue('eip155:1:0x1234567890123456789012345678901234567890'), +})); + +// Mock EligibilityService as a class with instance methods +const mockEligibilityServiceInstance = { + checkEligibility: jest.fn().mockResolvedValue(true), +}; +jest.mock('../../src/services/EligibilityService', () => ({ + EligibilityService: jest + .fn() + .mockImplementation(() => mockEligibilityServiceInstance), +})); + +// Mock DepositService as a class with instance methods +const mockDepositServiceInstance = { + prepareTransaction: jest.fn(), +}; +jest.mock('../../src/services/DepositService', () => ({ + DepositService: jest + .fn() + .mockImplementation(() => mockDepositServiceInstance), +})); + +// Mock MarketDataService as a class with instance methods +const mockMarketDataServiceInstance = { + getPositions: jest.fn(), + getAccountState: jest.fn(), + getMarkets: jest.fn(), + getWithdrawalRoutes: jest.fn().mockReturnValue([]), + validateClosePosition: jest.fn().mockResolvedValue({ isValid: true }), + validateOrder: jest.fn(), + calculateMaintenanceMargin: jest.fn().mockResolvedValue(0), + calculateLiquidationPrice: jest.fn(), + getMaxLeverage: jest.fn(), + calculateFees: jest.fn().mockResolvedValue({ totalFee: 0 }), + previewPositionModify: jest.fn(), + getAvailableDexs: jest.fn().mockResolvedValue([]), + getBlockExplorerUrl: jest.fn(), + getOrderFills: jest.fn(), + getOrders: jest.fn(), + getFunding: jest.fn(), +}; +jest.mock('../../src/services/MarketDataService', () => ({ + MarketDataService: jest + .fn() + .mockImplementation(() => mockMarketDataServiceInstance), +})); + +// Mock TradingService as a class with instance methods +const mockTradingServiceInstance = { + placeOrder: jest.fn(), + editOrder: jest.fn(), + cancelOrder: jest.fn(), + cancelOrders: jest.fn(), + closePosition: jest.fn(), + closePositions: jest.fn(), + updatePositionTPSL: jest.fn(), + updateMargin: jest.fn(), + flipPosition: jest.fn(), + setControllerDependencies: jest.fn(), +}; +jest.mock('../../src/services/TradingService', () => ({ + TradingService: jest + .fn() + .mockImplementation(() => mockTradingServiceInstance), +})); + +// Mock AccountService as a class with instance methods +const mockAccountServiceInstance = { + withdraw: jest.fn(), + validateWithdrawal: jest.fn(), +}; +jest.mock('../../src/services/AccountService', () => ({ + AccountService: jest + .fn() + .mockImplementation(() => mockAccountServiceInstance), +})); + +// Mock DataLakeService as a class with instance methods +const mockDataLakeServiceInstance = { + reportOrder: jest.fn(), +}; +jest.mock('../../src/services/DataLakeService', () => ({ + DataLakeService: jest + .fn() + .mockImplementation(() => mockDataLakeServiceInstance), +})); + +// Mock FeatureFlagConfigurationService as a class with instance methods +const mockFeatureFlagConfigurationServiceInstance = { + refreshEligibility: jest.fn((options: any) => { + // Simulate the service's behavior: extract blocked regions from remote flags + const remoteFlags = + options.remoteFeatureFlagControllerState.remoteFeatureFlags; + const perpsGeoBlockedRegionsFeatureFlag = + remoteFlags?.perpsPerpTradingGeoBlockedCountriesV2; + const remoteBlockedRegions = + perpsGeoBlockedRegionsFeatureFlag?.blockedRegions; + + if ( + Array.isArray(remoteBlockedRegions) && + options.context.setBlockedRegionList + ) { + const currentList = options.context.getBlockedRegionList?.(); + // Never downgrade from remote to fallback + if (!currentList || currentList.source !== 'remote') { + options.context.setBlockedRegionList(remoteBlockedRegions, 'remote'); + } + } + + // Call refreshEligibility callback if available + if (options.context.refreshEligibility) { + options.context.refreshEligibility().catch(() => { + // Ignore errors in mock + }); + } + + // Also call refreshHip3Config if available + if (remoteFlags) { + mockFeatureFlagConfigurationServiceInstance.refreshHip3Config(options); + } + }), + refreshHip3Config: jest.fn(), + setBlockedRegions: jest.fn((options: any) => { + // Simulate setBlockedRegions behavior + const { list, source, context } = options; + if (context.setBlockedRegionList && context.getBlockedRegionList) { + const currentList = context.getBlockedRegionList(); + // Never downgrade from remote to fallback + if (source === 'fallback' && currentList.source === 'remote') { + return; + } + if (Array.isArray(list)) { + context.setBlockedRegionList(list, source); + } + } + + // Call refreshEligibility callback if available + if (context.refreshEligibility) { + context.refreshEligibility().catch(() => { + // Ignore errors in mock + }); + } + }), +}; +jest.mock('../../src/services/FeatureFlagConfigurationService', () => ({ + FeatureFlagConfigurationService: jest + .fn() + .mockImplementation(() => mockFeatureFlagConfigurationServiceInstance), +})); + +/** + * Testable version of PerpsController that exposes protected methods for testing. + * This follows the pattern used in RewardsController.test.ts + */ +class TestablePerpsController extends PerpsController { + /** + * Test-only method to update state directly. + * Exposed for scenarios where state needs to be manipulated + * outside the normal public API (e.g., testing error conditions). + * @param callback + */ + public testUpdate(callback: (state: PerpsControllerState) => void) { + this.update(callback); + } + + /** + * Test-only method to mark controller as initialized. + * Common test scenario that requires internal state changes. + */ + public testMarkInitialized() { + this.isInitialized = true; + this.update((state) => { + state.initializationState = InitializationState.Initialized; + }); + } + + /** + * Test-only method to set the providers map with complete providers. + * Used in most tests to inject mock providers. + * Also sets activeProviderInstance to the first provider (default provider). + * @param providers + */ + public testSetProviders(providers: Map) { + this.providers = providers; + // Set activeProviderInstance to the first provider (typically 'hyperliquid') + const firstProvider = providers.values().next().value; + if (firstProvider) { + this.activeProviderInstance = firstProvider; + } + } + + /** + * Set a routed provider independently from the direct-provider registry. + * + * @param provider - Active direct or routed provider. + */ + public testSetActiveProvider(provider: PerpsProvider) { + this.activeProviderInstance = provider; + } + + /** + * Test-only method to set the providers map with partial providers. + * Used explicitly in tests that verify error handling with incomplete providers. + * Type cast is intentional and necessary for testing graceful degradation. + * @param providers + */ + public testSetPartialProviders( + providers: Map>, + ) { + this.providers = providers as Map; + } + + /** + * Test-only method to get the providers map. + * Used to verify provider state in tests. + */ + public testGetProviders(): Map { + return this.providers; + } + + /** + * Test-only method to set initialization state. + * Allows tests to simulate both initialized and uninitialized states. + * @param value + */ + public testSetInitialized(value: boolean) { + this.isInitialized = value; + } + + /** + * Test-only method to get initialization state. + * Used to verify initialization status in tests. + */ + public testGetInitialized(): boolean { + return this.isInitialized; + } + + /** + * Test-only method to get blocked region list. + * Used to verify geo-blocking configuration in tests. + */ + public testGetBlockedRegionList(): { source: string; list: string[] } { + return this.blockedRegionList; + } + + /** + * Test-only method to set blocked region list. + * Used to test priority logic (remote vs fallback). + * @param list + * @param source + */ + public testSetBlockedRegionList( + list: string[], + source: 'remote' | 'fallback', + ) { + this.setBlockedRegionList(list, source); + } + + /** + * Test accessor for protected method refreshEligibilityOnFeatureFlagChange. + * Wrapper is necessary because protected methods can't be called from test code. + * @param remoteFlags + */ + public testRefreshEligibilityOnFeatureFlagChange(remoteFlags: any) { + this.refreshEligibilityOnFeatureFlagChange(remoteFlags); + } + + /** + * Test accessor for protected method reportOrderToDataLake. + * Wrapper is necessary because protected methods can't be called from test code. + * @param data + */ + public testReportOrderToDataLake(data: any): Promise { + return this.reportOrderToDataLake(data); + } + + public testHasStandaloneProvider(): boolean { + return this.hasStandaloneProvider(); + } + + public testRegisterMYXProvider( + MYXProvider: new (opts: Record) => PerpsProvider, + ) { + this.registerMYXProvider(MYXProvider as never); + } + + public testHandleMYXImportError(error: unknown) { + this.handleMYXImportError(error); + } +} + +describe('PerpsController', () => { + let controller: TestablePerpsController; + let mockProvider: jest.Mocked; + let mockInfrastructure: jest.Mocked; + + // Helper to mark controller as initialized for tests + const markControllerAsInitialized = () => { + controller.testMarkInitialized(); + }; + + beforeEach(() => { + jest.clearAllMocks(); + + ( + jest.requireMock('../../src/services/EligibilityService') + .EligibilityService as jest.Mock + ).mockImplementation(() => mockEligibilityServiceInstance); + ( + jest.requireMock('../../src/services/DepositService') + .DepositService as jest.Mock + ).mockImplementation(() => mockDepositServiceInstance); + ( + jest.requireMock('../../src/services/MarketDataService') + .MarketDataService as jest.Mock + ).mockImplementation(() => mockMarketDataServiceInstance); + ( + jest.requireMock('../../src/services/TradingService') + .TradingService as jest.Mock + ).mockImplementation(() => mockTradingServiceInstance); + ( + jest.requireMock('../../src/services/AccountService') + .AccountService as jest.Mock + ).mockImplementation(() => mockAccountServiceInstance); + ( + jest.requireMock('../../src/services/DataLakeService') + .DataLakeService as jest.Mock + ).mockImplementation(() => mockDataLakeServiceInstance); + ( + jest.requireMock('../../src/services/FeatureFlagConfigurationService') + .FeatureFlagConfigurationService as jest.Mock + ).mockImplementation(() => mockFeatureFlagConfigurationServiceInstance); + + mockEligibilityServiceInstance.checkEligibility.mockResolvedValue(true); + mockMarketDataServiceInstance.getPositions.mockResolvedValue([]); + mockMarketDataServiceInstance.getAccountState.mockResolvedValue({ + spendableBalance: '10000', + withdrawableBalance: '10000', + totalBalance: '10000', + marginUsed: '0', + unrealizedPnl: '0', + returnOnEquity: '0', + }); + mockMarketDataServiceInstance.getMarkets.mockResolvedValue([]); + mockMarketDataServiceInstance.getWithdrawalRoutes.mockReturnValue([]); + mockMarketDataServiceInstance.validateClosePosition.mockResolvedValue({ + isValid: true, + }); + mockMarketDataServiceInstance.calculateMaintenanceMargin.mockResolvedValue( + 0, + ); + mockMarketDataServiceInstance.calculateFees.mockResolvedValue({ + totalFee: 0, + }); + mockMarketDataServiceInstance.getAvailableDexs.mockResolvedValue([]); + + mockFeatureFlagConfigurationServiceInstance.refreshEligibility.mockImplementation( + (options: any) => { + const remoteFlags = + options.remoteFeatureFlagControllerState.remoteFeatureFlags; + const perpsGeoBlockedRegionsFeatureFlag = + remoteFlags?.perpsPerpTradingGeoBlockedCountriesV2; + const remoteBlockedRegions = + perpsGeoBlockedRegionsFeatureFlag?.blockedRegions; + + if ( + Array.isArray(remoteBlockedRegions) && + options.context.setBlockedRegionList + ) { + const currentList = options.context.getBlockedRegionList?.(); + if (!currentList || currentList.source !== 'remote') { + options.context.setBlockedRegionList( + remoteBlockedRegions, + 'remote', + ); + } + } + + if (options.context.refreshEligibility) { + options.context.refreshEligibility().catch(() => { + // Ignore errors in mock + }); + } + + if (remoteFlags) { + mockFeatureFlagConfigurationServiceInstance.refreshHip3Config( + options, + ); + } + }, + ); + mockFeatureFlagConfigurationServiceInstance.setBlockedRegions.mockImplementation( + (options: any) => { + const { list, source, context } = options; + if (context.setBlockedRegionList && context.getBlockedRegionList) { + const currentList = context.getBlockedRegionList(); + if (source === 'fallback' && currentList.source === 'remote') { + return; + } + if (Array.isArray(list)) { + context.setBlockedRegionList(list, source); + } + } + + if (context.refreshEligibility) { + context.refreshEligibility().catch(() => { + // Ignore errors in mock + }); + } + }, + ); + mockFeatureFlagConfigurationServiceInstance.refreshHip3Config.mockImplementation( + () => undefined, + ); + + // Create a fresh mock provider for each test + mockProvider = createMockHyperLiquidProvider(); + + // Add default mock return values for all provider methods + mockProvider.getPositions.mockResolvedValue([]); + mockProvider.getAccountState.mockResolvedValue({ + spendableBalance: '10000', + withdrawableBalance: '10000', + totalBalance: '10000', + marginUsed: '0', + unrealizedPnl: '0', + returnOnEquity: '0', + }); + mockProvider.getMarkets.mockResolvedValue([]); + mockProvider.getOpenOrders.mockResolvedValue([]); + mockProvider.getFunding.mockResolvedValue([]); + mockProvider.getOrderFills.mockResolvedValue([]); + mockProvider.getOrders.mockResolvedValue([]); + mockProvider.calculateLiquidationPrice.mockResolvedValue('0'); + mockProvider.getMaxLeverage.mockResolvedValue(50); + mockProvider.calculateMaintenanceMargin.mockResolvedValue(0); + mockProvider.calculateFees.mockResolvedValue({ feeAmount: 0 }); + mockProvider.getBlockExplorerUrl.mockReturnValue( + 'https://explorer.example.com', + ); + mockProvider.getWithdrawalRoutes.mockReturnValue([]); + + ( + HyperLiquidProvider as jest.MockedClass + ).mockImplementation(() => mockProvider); + + const mockCall = jest.fn().mockImplementation((action: string) => { + if (action === 'RemoteFeatureFlagController:getState') { + return { + remoteFeatureFlags: { + perpsPerpTradingGeoBlockedCountriesV2: { + blockedRegions: [], + }, + }, + }; + } + if ( + action === 'AccountTreeController:getAccountsFromSelectedAccountGroup' + ) { + return [ + { + address: '0x1234567890123456789012345678901234567890', + type: 'eip155:eoa', + id: 'account-1', + options: {}, + scopes: ['eip155:1'], + methods: [], + metadata: { + name: 'Test', + importTime: 0, + keyring: { type: 'HD Key Tree' }, + }, + }, + ]; + } + return undefined; + }); + + mockInfrastructure = createMockInfrastructure(); + controller = new TestablePerpsController({ + messenger: createMockMessenger({ call: mockCall }), + state: getDefaultPerpsControllerState(), + infrastructure: mockInfrastructure, + }); + }); + + afterEach(() => { + // Clear only provider mocks, not Engine.context mocks + // This prevents breaking Engine.context.RewardsController/NetworkController references + if (mockProvider) { + Object.values(mockProvider).forEach((value) => { + if ( + typeof value === 'function' && + value !== null && + 'mockClear' in value + ) { + (value as jest.Mock).mockClear(); + } + }); + } + (mockInfrastructure.metrics.trackPerpsEvent as jest.Mock).mockClear(); + (mockInfrastructure.logger.error as jest.Mock).mockClear(); + (mockInfrastructure.debugLogger.log as jest.Mock).mockClear(); + }); + describe('validation methods', () => { + it('validates close position', async () => { + const closeParams = { + symbol: 'BTC', + orderType: 'market' as const, + size: '0.5', + }; + + const mockValidationResult = { + isValid: true, + errors: [], + }; + + markControllerAsInitialized(); + controller.testSetProviders(new Map([['hyperliquid', mockProvider]])); + jest + .spyOn(mockMarketDataServiceInstance, 'validateClosePosition') + .mockResolvedValue(mockValidationResult); + + const result = await controller.validateClosePosition(closeParams); + + expect(result).toEqual(mockValidationResult); + expect( + mockMarketDataServiceInstance.validateClosePosition, + ).toHaveBeenCalledWith({ + provider: mockProvider, + params: closeParams, + context: expect.any(Object), + }); + }); + + it('validates withdrawal', async () => { + const withdrawParams = { + amount: '100', + destination: + '0x1234567890123456789012345678901234567890' as `0x${string}`, + }; + + const mockValidationResult = { + isValid: true, + errors: [], + }; + + markControllerAsInitialized(); + controller.testSetProviders(new Map([['hyperliquid', mockProvider]])); + jest + .spyOn(mockAccountServiceInstance, 'validateWithdrawal') + .mockResolvedValue(mockValidationResult); + + const result = await controller.validateWithdrawal(withdrawParams); + + expect(result).toEqual(mockValidationResult); + expect( + mockAccountServiceInstance.validateWithdrawal, + ).toHaveBeenCalledWith({ + provider: mockProvider, + params: withdrawParams, + }); + }); + }); + + describe('position management', () => { + it('updates position TP/SL', async () => { + const updateParams = { + symbol: 'BTC', + takeProfitPrice: '55000', + stopLossPrice: '45000', + }; + + const mockUpdateResult = { + success: true, + positionId: 'pos-123', + }; + + markControllerAsInitialized(); + controller.testSetProviders(new Map([['hyperliquid', mockProvider]])); + jest + .spyOn(mockTradingServiceInstance, 'updatePositionTPSL') + .mockResolvedValue(mockUpdateResult); + + const result = await controller.updatePositionTPSL(updateParams); + + expect(result).toEqual(mockUpdateResult); + expect( + mockTradingServiceInstance.updatePositionTPSL, + ).toHaveBeenCalledWith( + expect.objectContaining({ + provider: mockProvider, + params: updateParams, + context: expect.any(Object), + }), + ); + }); + + it('calculates maintenance margin', async () => { + const marginParams = { + symbol: 'BTC', + size: '1.0', + entryPrice: '50000', + asset: 'BTC', + }; + + const mockMargin = 2500; + + markControllerAsInitialized(); + controller.testSetProviders(new Map([['hyperliquid', mockProvider]])); + jest + .spyOn(mockMarketDataServiceInstance, 'calculateMaintenanceMargin') + .mockResolvedValue(mockMargin); + + const result = await controller.calculateMaintenanceMargin(marginParams); + + expect(result).toBe(mockMargin); + expect( + mockMarketDataServiceInstance.calculateMaintenanceMargin, + ).toHaveBeenCalledWith({ + provider: mockProvider, + params: marginParams, + context: expect.any(Object), + }); + }); + + it('updates margin successfully', async () => { + const updateMarginParams = { + symbol: 'BTC', + amount: '100', + }; + + const mockUpdateResult = { + success: true, + }; + + markControllerAsInitialized(); + controller.testSetProviders(new Map([['hyperliquid', mockProvider]])); + jest + .spyOn(mockTradingServiceInstance, 'updateMargin') + .mockResolvedValue(mockUpdateResult); + + const result = await controller.updateMargin(updateMarginParams); + + expect(result).toEqual(mockUpdateResult); + expect(mockTradingServiceInstance.updateMargin).toHaveBeenCalledWith( + expect.objectContaining({ + provider: mockProvider, + symbol: updateMarginParams.symbol, + amount: '100', + context: expect.any(Object), + }), + ); + }); + + it('handles updateMargin error', async () => { + const updateMarginParams = { + symbol: 'BTC', + amount: '100', + }; + + const errorMessage = 'Insufficient balance'; + + markControllerAsInitialized(); + controller.testSetProviders(new Map([['hyperliquid', mockProvider]])); + jest + .spyOn(mockTradingServiceInstance, 'updateMargin') + .mockRejectedValue(new Error(errorMessage)); + + await expect(controller.updateMargin(updateMarginParams)).rejects.toThrow( + errorMessage, + ); + expect(mockTradingServiceInstance.updateMargin).toHaveBeenCalled(); + }); + + it('flips position successfully', async () => { + const mockPosition = { + symbol: 'BTC', + size: '0.5', + entryPrice: '50000', + positionValue: '25000', + unrealizedPnl: '1000', + returnOnEquity: '0.04', + leverage: { type: 'cross' as const, value: 10 }, + liquidationPrice: '45000', + marginUsed: '2500', + maxLeverage: 100, + cumulativeFunding: { allTime: '0', sinceOpen: '0', sinceChange: '0' }, + takeProfitCount: 0, + stopLossCount: 0, + }; + + const flipPositionParams = { + symbol: 'BTC', + position: mockPosition, + }; + + const mockFlipResult = { + success: true, + orderId: 'flip-123', + filledSize: '1.0', + averagePrice: '50000', + }; + + markControllerAsInitialized(); + controller.testSetProviders(new Map([['hyperliquid', mockProvider]])); + jest + .spyOn(mockTradingServiceInstance, 'flipPosition') + .mockResolvedValue(mockFlipResult); + + const result = await controller.flipPosition(flipPositionParams); + + expect(result).toEqual(mockFlipResult); + expect(mockTradingServiceInstance.flipPosition).toHaveBeenCalledWith( + expect.objectContaining({ + provider: mockProvider, + position: mockPosition, + context: expect.any(Object), + }), + ); + }); + + it('handles flipPosition error', async () => { + const mockPosition = { + symbol: 'BTC', + size: '0.5', + entryPrice: '50000', + positionValue: '25000', + unrealizedPnl: '1000', + returnOnEquity: '0.04', + leverage: { type: 'cross' as const, value: 10 }, + liquidationPrice: '45000', + marginUsed: '2500', + maxLeverage: 100, + cumulativeFunding: { allTime: '0', sinceOpen: '0', sinceChange: '0' }, + takeProfitCount: 0, + stopLossCount: 0, + }; + + const flipPositionParams = { + symbol: 'BTC', + position: mockPosition, + }; + + const errorMessage = 'Insufficient balance for flip fees'; + + markControllerAsInitialized(); + controller.testSetProviders(new Map([['hyperliquid', mockProvider]])); + jest + .spyOn(mockTradingServiceInstance, 'flipPosition') + .mockRejectedValue(new Error(errorMessage)); + + await expect(controller.flipPosition(flipPositionParams)).rejects.toThrow( + errorMessage, + ); + expect(mockTradingServiceInstance.flipPosition).toHaveBeenCalled(); + }); + }); + + describe('order capabilities', () => { + const setAggregatedProvider = (): AggregatedPerpsProvider => { + const myxProvider: PerpsProvider = { + ...createMockHyperLiquidProvider(), + protocolId: 'myx', + }; + const aggregatedProvider = new AggregatedPerpsProvider({ + providers: new Map([ + ['hyperliquid', mockProvider], + ['myx', myxProvider], + ]), + defaultProvider: 'hyperliquid', + infrastructure: mockInfrastructure, + }); + markControllerAsInitialized(); + controller.testUpdate((state) => { + state.activeProvider = 'aggregated'; + }); + controller.testSetProviders( + new Map([ + ['hyperliquid', mockProvider], + ['myx', myxProvider], + ]), + ); + controller.testSetActiveProvider(aggregatedProvider); + return aggregatedProvider; + }; + + it('returns capabilities from the active routed provider', async () => { + mockProvider.getOrderCapabilities = jest.fn().mockResolvedValue({ + status: 'ready', + providerId: 'hyperliquid', + supportedStrategies: ['twap', 'scale', 'chase'], + }); + markControllerAsInitialized(); + controller.testSetProviders(new Map([['hyperliquid', mockProvider]])); + + await expect( + controller.getOrderCapabilities({ symbol: 'BTC' }), + ).resolves.toStrictEqual({ + status: 'ready', + providerId: 'hyperliquid', + supportedStrategies: ['twap', 'scale', 'chase'], + }); + expect(mockProvider.getOrderCapabilities).toHaveBeenCalledWith({ + symbol: 'BTC', + }); + }); + + it('reports unavailable while no provider can answer', async () => { + expect(controller.state.initializationState).toBe( + InitializationState.Uninitialized, + ); + + await expect( + controller.getOrderCapabilities({ symbol: 'BTC' }), + ).resolves.toStrictEqual({ + status: 'unavailable', + reason: 'provider_unavailable', + }); + }); + + it('reports unavailable when the provider omits the optional hook', async () => { + mockProvider.getOrderCapabilities = undefined; + markControllerAsInitialized(); + controller.testSetProviders(new Map([['hyperliquid', mockProvider]])); + + await expect( + controller.getOrderCapabilities({ symbol: 'BTC' }), + ).resolves.toStrictEqual({ + status: 'unavailable', + providerId: 'hyperliquid', + reason: 'not_implemented', + }); + }); + + it('preserves a direct unavailable reason when the provider omits its identity', async () => { + mockProvider.getOrderCapabilities = jest.fn().mockResolvedValue({ + status: 'unavailable', + reason: 'market_not_found', + }); + markControllerAsInitialized(); + controller.testSetProviders(new Map([['hyperliquid', mockProvider]])); + + await expect( + controller.getOrderCapabilities({ symbol: 'UNKNOWN' }), + ).resolves.toStrictEqual({ + status: 'unavailable', + providerId: 'hyperliquid', + reason: 'market_not_found', + }); + }); + + it('preserves the resolved MYX provider when its hook is unavailable', async () => { + const myxProvider: PerpsProvider = { + ...mockProvider, + protocolId: 'myx', + getOrderCapabilities: undefined, + }; + markControllerAsInitialized(); + controller.testUpdate((state) => { + state.activeProvider = 'myx'; + }); + controller.testSetProviders(new Map([['myx', myxProvider]])); + + await expect( + controller.getOrderCapabilities({ symbol: 'RHEA' }), + ).resolves.toStrictEqual({ + status: 'unavailable', + providerId: 'myx', + reason: 'not_implemented', + }); + }); + + it('preserves the resolved provider when capability discovery fails', async () => { + mockProvider.getOrderCapabilities = jest + .fn() + .mockRejectedValue(new Error('metadata unavailable')); + markControllerAsInitialized(); + controller.testSetProviders(new Map([['hyperliquid', mockProvider]])); + + await expect( + controller.getOrderCapabilities({ symbol: 'BTC' }), + ).resolves.toStrictEqual({ + status: 'unavailable', + providerId: 'hyperliquid', + reason: 'provider_unavailable', + }); + }); + + it('preserves the requested provider when provider resolution fails', async () => { + await expect( + controller.getOrderCapabilities({ + symbol: 'RHEA', + providerId: 'myx', + }), + ).resolves.toStrictEqual({ + status: 'unavailable', + providerId: 'myx', + reason: 'provider_unavailable', + }); + }); + + it('routes an explicit provider through the active aggregator', async () => { + const getMyxOrderCapabilities = jest.fn().mockResolvedValue({ + status: 'ready', + providerId: 'myx', + supportedStrategies: [], + }); + const myxProvider: PerpsProvider = { + ...createMockHyperLiquidProvider(), + protocolId: 'myx', + getOrderCapabilities: getMyxOrderCapabilities, + }; + const aggregatedProvider = new AggregatedPerpsProvider({ + providers: new Map([ + ['hyperliquid', mockProvider], + ['myx', myxProvider], + ]), + defaultProvider: 'hyperliquid', + infrastructure: mockInfrastructure, + }); + markControllerAsInitialized(); + controller.testUpdate((state) => { + state.activeProvider = 'aggregated'; + }); + controller.testSetProviders( + new Map([ + ['hyperliquid', mockProvider], + ['myx', myxProvider], + ]), + ); + controller.testSetActiveProvider(aggregatedProvider); + + await expect( + controller.getOrderCapabilities({ symbol: 'RHEA', providerId: 'myx' }), + ).resolves.toStrictEqual({ + status: 'ready', + providerId: 'myx', + supportedStrategies: [], + }); + expect(getMyxOrderCapabilities).toHaveBeenCalledWith({ + symbol: 'RHEA', + providerId: 'myx', + }); + }); + + it('rejects an explicit route that conflicts with the resolved provider', async () => { + markControllerAsInitialized(); + controller.testSetProviders(new Map([['hyperliquid', mockProvider]])); + controller.testUpdate((state) => { + state.activeProvider = 'myx'; + }); + + await expect( + controller.getOrderCapabilities({ symbol: 'RHEA', providerId: 'myx' }), + ).resolves.toStrictEqual({ + status: 'unavailable', + providerId: 'myx', + reason: 'provider_not_routable', + }); + expect(mockProvider.getOrderCapabilities).not.toHaveBeenCalled(); + }); + + it('does not infer routing support from a provider protocol ID', async () => { + const directProvider = { + ...mockProvider, + protocolId: 'aggregated', + }; + markControllerAsInitialized(); + controller.testSetProviders(new Map([['hyperliquid', directProvider]])); + + await expect( + controller.getOrderCapabilities({ symbol: 'RHEA', providerId: 'myx' }), + ).resolves.toStrictEqual({ + status: 'unavailable', + providerId: 'myx', + reason: 'provider_not_routable', + }); + expect(directProvider.getOrderCapabilities).not.toHaveBeenCalled(); + }); + + it.each(STRATEGY_ORDER_TYPES)( + 'rejects %s placement that conflicts with the resolved provider', + async (orderType) => { + markControllerAsInitialized(); + controller.testSetProviders(new Map([['hyperliquid', mockProvider]])); + + await expect( + controller.placeOrder({ + symbol: 'RHEA', + providerId: 'myx', + orderType, + isBuy: true, + size: '1', + }), + ).rejects.toThrow(PERPS_ERROR_CODES.ORDER_STRATEGY_ROUTE_UNAVAILABLE); + expect(mockTradingServiceInstance.placeOrder).not.toHaveBeenCalled(); + }, + ); + + it('keeps an accepted placement providerId in the service request', async () => { + const aggregatedProvider = setAggregatedProvider(); + const params = { + symbol: 'RHEA', + providerId: 'myx', + orderType: 'twap', + isBuy: true, + size: '1', + twapDuration: 30, + } as const; + mockTradingServiceInstance.placeOrder.mockResolvedValue({ + success: true, + orderId: 'twap-123', + }); + + await controller.placeOrder(params); + + expect(mockTradingServiceInstance.placeOrder).toHaveBeenCalledWith( + expect.objectContaining({ provider: aggregatedProvider, params }), + ); + }); + + it.each(STRATEGY_ORDER_TYPES)( + 'routes %s placement without providerId to the active provider', + async (orderType) => { + markControllerAsInitialized(); + controller.testSetProviders(new Map([['hyperliquid', mockProvider]])); + const params = { + symbol: 'ETH', + orderType, + isBuy: true, + size: '1', + }; + mockTradingServiceInstance.placeOrder.mockResolvedValue({ + success: true, + }); + + await controller.placeOrder(params); + + expect(mockTradingServiceInstance.placeOrder).toHaveBeenCalledWith( + expect.objectContaining({ provider: mockProvider, params }), + ); + }, + ); + + it('rejects a conflicting direct-provider route for an ordinary order', async () => { + markControllerAsInitialized(); + controller.testSetProviders(new Map([['hyperliquid', mockProvider]])); + + const params = { + symbol: 'RHEA', + providerId: 'myx', + orderType: 'market', + isBuy: true, + size: '1', + } satisfies OrderParams; + + await expect(controller.placeOrder(params)).rejects.toThrow( + PERPS_ERROR_CODES.PROVIDER_NOT_FOUND, + ); + + expect(mockTradingServiceInstance.placeOrder).not.toHaveBeenCalled(); + }); + + it.each(STRATEGY_ORDER_TYPES)( + 'rejects %s cancellation that conflicts with the resolved provider', + async (orderType) => { + markControllerAsInitialized(); + controller.testSetProviders(new Map([['hyperliquid', mockProvider]])); + + await expect( + controller.cancelOrder({ + orderId: 'strategy-123', + symbol: 'RHEA', + providerId: 'myx', + orderType, + }), + ).rejects.toThrow(PERPS_ERROR_CODES.ORDER_STRATEGY_ROUTE_UNAVAILABLE); + expect(mockTradingServiceInstance.cancelOrder).not.toHaveBeenCalled(); + }, + ); + + it('keeps an accepted cancellation providerId in the service request', async () => { + const aggregatedProvider = setAggregatedProvider(); + const params = { + orderId: 'twap-123', + symbol: 'RHEA', + providerId: 'myx', + orderType: 'twap', + } as const; + mockTradingServiceInstance.cancelOrder.mockResolvedValue({ + success: true, + orderId: 'twap-123', + }); + + await controller.cancelOrder(params); + + expect(mockTradingServiceInstance.cancelOrder).toHaveBeenCalledWith( + expect.objectContaining({ provider: aggregatedProvider, params }), + ); + }); + + it.each(STRATEGY_ORDER_TYPES)( + 'routes %s cancellation without providerId to the active provider', + async (orderType) => { + markControllerAsInitialized(); + controller.testSetProviders(new Map([['hyperliquid', mockProvider]])); + const params = { + orderId: 'strategy-123', + symbol: 'ETH', + orderType, + }; + mockTradingServiceInstance.cancelOrder.mockResolvedValue({ + success: true, + }); + + await controller.cancelOrder(params); + + expect(mockTradingServiceInstance.cancelOrder).toHaveBeenCalledWith( + expect.objectContaining({ provider: mockProvider, params }), + ); + }, + ); + + it.each(STRATEGY_ORDER_TYPES)( + 'rejects %s validation that conflicts with the resolved provider', + async (orderType) => { + markControllerAsInitialized(); + controller.testSetProviders(new Map([['hyperliquid', mockProvider]])); + + await expect( + controller.validateOrder({ + symbol: 'RHEA', + providerId: 'myx', + orderType, + isBuy: true, + size: '1', + }), + ).rejects.toThrow(PERPS_ERROR_CODES.ORDER_STRATEGY_ROUTE_UNAVAILABLE); + expect( + mockMarketDataServiceInstance.validateOrder, + ).not.toHaveBeenCalled(); + }, + ); + + it('keeps an accepted validation providerId in the service request', async () => { + const aggregatedProvider = setAggregatedProvider(); + const params = { + symbol: 'RHEA', + providerId: 'myx', + orderType: 'twap', + isBuy: true, + size: '1', + twapDuration: 30, + } as const; + mockMarketDataServiceInstance.validateOrder.mockResolvedValue({ + isValid: true, + }); + + await controller.validateOrder(params); + + expect(mockMarketDataServiceInstance.validateOrder).toHaveBeenCalledWith( + expect.objectContaining({ provider: aggregatedProvider, params }), + ); + }); + + it.each(STRATEGY_ORDER_TYPES)( + 'routes %s validation without providerId to the active provider', + async (orderType) => { + markControllerAsInitialized(); + controller.testSetProviders(new Map([['hyperliquid', mockProvider]])); + const params = { + symbol: 'ETH', + orderType, + isBuy: true, + size: '1', + }; + mockMarketDataServiceInstance.validateOrder.mockResolvedValue({ + isValid: true, + }); + + await controller.validateOrder(params); + + expect( + mockMarketDataServiceInstance.validateOrder, + ).toHaveBeenCalledWith( + expect.objectContaining({ provider: mockProvider, params }), + ); + }, + ); + + it('rejects a conflicting direct-provider route during ordinary validation', async () => { + markControllerAsInitialized(); + controller.testSetProviders(new Map([['hyperliquid', mockProvider]])); + + await expect( + controller.validateOrder({ + symbol: 'RHEA', + providerId: 'myx', + orderType: 'limit', + isBuy: true, + size: '1', + }), + ).rejects.toThrow(PERPS_ERROR_CODES.PROVIDER_NOT_FOUND); + expect( + mockMarketDataServiceInstance.validateOrder, + ).not.toHaveBeenCalled(); + }); + + it('rejects a conflicting direct-provider route for an ordinary cancel', async () => { + markControllerAsInitialized(); + controller.testSetProviders(new Map([['hyperliquid', mockProvider]])); + + const params = { + orderId: 'order-123', + symbol: 'RHEA', + providerId: 'myx', + orderType: 'limit', + } satisfies CancelOrderParams; + + await expect(controller.cancelOrder(params)).rejects.toThrow( + PERPS_ERROR_CODES.PROVIDER_NOT_FOUND, + ); + + expect(mockTradingServiceInstance.cancelOrder).not.toHaveBeenCalled(); + }); + + it('rejects a conflicting direct-provider route for an edit', async () => { + markControllerAsInitialized(); + controller.testSetProviders(new Map([['hyperliquid', mockProvider]])); + + await expect( + controller.editOrder({ + orderId: 'order-123', + newOrder: { + symbol: 'RHEA', + providerId: 'myx', + orderType: 'limit', + isBuy: true, + size: '1', + price: '10', + }, + }), + ).rejects.toThrow(PERPS_ERROR_CODES.PROVIDER_NOT_FOUND); + expect(mockTradingServiceInstance.editOrder).not.toHaveBeenCalled(); + }); + + it.each(STRATEGY_ORDER_TYPES)( + 'returns the unsupported result for a %s edit without requiring a route', + async (orderType) => { + await expect( + controller.editOrder({ + orderId: 'strategy-123', + newOrder: { + symbol: 'ETH', + orderType, + isBuy: true, + size: '1', + }, + }), + ).resolves.toStrictEqual({ + success: false, + error: PERPS_ERROR_CODES.ORDER_EDIT_STRATEGY_UNSUPPORTED, + }); + expect(mockTradingServiceInstance.editOrder).not.toHaveBeenCalled(); + }, + ); + + it('rejects a conflicting direct-provider route for a position close', async () => { + markControllerAsInitialized(); + controller.testSetProviders(new Map([['hyperliquid', mockProvider]])); + + await expect( + controller.closePosition({ symbol: 'RHEA', providerId: 'myx' }), + ).rejects.toThrow(PERPS_ERROR_CODES.PROVIDER_NOT_FOUND); + expect(mockTradingServiceInstance.closePosition).not.toHaveBeenCalled(); + }); + + it('rejects a conflicting direct-provider route for a TP/SL update', async () => { + markControllerAsInitialized(); + controller.testSetProviders(new Map([['hyperliquid', mockProvider]])); + + await expect( + controller.updatePositionTPSL({ + symbol: 'RHEA', + providerId: 'myx', + takeProfitPrice: '10', + }), + ).rejects.toThrow(PERPS_ERROR_CODES.PROVIDER_NOT_FOUND); + expect( + mockTradingServiceInstance.updatePositionTPSL, + ).not.toHaveBeenCalled(); + }); + + it('rejects a conflicting direct-provider route for close validation', async () => { + markControllerAsInitialized(); + controller.testSetProviders(new Map([['hyperliquid', mockProvider]])); + + await expect( + controller.validateClosePosition({ + symbol: 'RHEA', + providerId: 'myx', + }), + ).rejects.toThrow(PERPS_ERROR_CODES.PROVIDER_NOT_FOUND); + expect( + mockMarketDataServiceInstance.validateClosePosition, + ).not.toHaveBeenCalled(); + }); + }); + + describe('fee calculations', () => { + it('approves the subscription builder outside order submission', async () => { + mockProvider.approveSubscriptionBuilderFee = jest + .fn() + .mockResolvedValue(true); + markControllerAsInitialized(); + controller.testSetProviders(new Map([['hyperliquid', mockProvider]])); + + await expect(controller.approveSubscriptionBuilderFee()).resolves.toBe( + true, + ); + expect(mockProvider.approveSubscriptionBuilderFee).toHaveBeenCalledTimes( + 1, + ); + }); + + it('calculates fees', async () => { + const feeParams = { + orderType: 'market' as const, + isMaker: false, + amount: '100000', + symbol: 'BTC', + }; + + const mockFees = { + makerFee: '0.0001', + takerFee: '0.0005', + totalFee: '0.05', + feeToken: 'USDC', + feeAmount: 0.05, + feeRate: 0.0005, + protocolFeeRate: 0.0003, + metamaskFeeRate: 0.0002, + }; + + markControllerAsInitialized(); + controller.testSetProviders(new Map([['hyperliquid', mockProvider]])); + jest + .spyOn(mockMarketDataServiceInstance, 'calculateFees') + .mockResolvedValue(mockFees); + + const result = await controller.calculateFees(feeParams); + + expect(result).toEqual(mockFees); + expect(mockMarketDataServiceInstance.calculateFees).toHaveBeenCalledWith({ + provider: mockProvider, + params: feeParams, + context: expect.any(Object), + }); + }); + + it('rejects a conflicting direct-provider route for an ordinary fee quote', async () => { + markControllerAsInitialized(); + controller.testSetProviders(new Map([['hyperliquid', mockProvider]])); + + const params = { + orderType: 'market', + symbol: 'RHEA', + providerId: 'myx', + } satisfies FeeCalculationParams; + + await expect(controller.calculateFees(params)).rejects.toThrow( + PERPS_ERROR_CODES.PROVIDER_NOT_FOUND, + ); + + expect( + mockMarketDataServiceInstance.calculateFees, + ).not.toHaveBeenCalled(); + }); + + it.each(STRATEGY_ORDER_TYPES)( + 'rejects a %s fee route that conflicts with the resolved provider', + async (orderType) => { + markControllerAsInitialized(); + controller.testSetProviders(new Map([['hyperliquid', mockProvider]])); + + await expect( + controller.calculateFees({ + orderType, + symbol: 'RHEA', + providerId: 'myx', + }), + ).rejects.toThrow(PERPS_ERROR_CODES.ORDER_STRATEGY_ROUTE_UNAVAILABLE); + expect( + mockMarketDataServiceInstance.calculateFees, + ).not.toHaveBeenCalled(); + }, + ); + + it.each(STRATEGY_ORDER_TYPES)( + 'routes a %s fee quote without providerId to the active provider', + async (orderType) => { + markControllerAsInitialized(); + controller.testSetProviders(new Map([['hyperliquid', mockProvider]])); + const fees = { feeRate: 0.001 }; + const params = { + orderType, + symbol: 'ETH', + }; + mockMarketDataServiceInstance.calculateFees.mockResolvedValue(fees); + + await expect(controller.calculateFees(params)).resolves.toEqual(fees); + + expect( + mockMarketDataServiceInstance.calculateFees, + ).toHaveBeenCalledWith( + expect.objectContaining({ provider: mockProvider, params }), + ); + }, + ); + + it('passes the cached subscription waiver status to the fee preview', async () => { + const feeParams = { + orderType: 'market' as const, + isMaker: false, + amount: '100000', + symbol: 'BTC', + }; + const waiverStatus = { + eligible: true, + reason: 'eligible' as const, + remainingNotionalUsd: 2500, + }; + const getStatus = jest + .spyOn( + RewardsIntegrationService.prototype, + 'getSubscriptionFeeWaiverStatus', + ) + .mockReturnValue(waiverStatus); + const refresh = jest + .spyOn( + RewardsIntegrationService.prototype, + 'refreshSubscriptionBenefits', + ) + .mockResolvedValue(undefined); + + markControllerAsInitialized(); + controller.testSetProviders(new Map([['hyperliquid', mockProvider]])); + + await controller.calculateFees(feeParams); + + expect(refresh).toHaveBeenCalledTimes(1); + expect(refresh.mock.invocationCallOrder[0]).toBeLessThan( + getStatus.mock.invocationCallOrder[0], + ); + expect(mockMarketDataServiceInstance.calculateFees).toHaveBeenCalledWith( + expect.objectContaining({ + context: expect.objectContaining({ + subscriptionFeeWaiver: waiverStatus, + }), + }), + ); + + getStatus.mockRestore(); + refresh.mockRestore(); + }); + + it('exposes subscription benefits invalidation to clients', async () => { + // The service is private to the controller, so a client detecting a + // sign-out or profile switch can only reach it through this method. + const invalidate = jest + .spyOn( + RewardsIntegrationService.prototype, + 'invalidateSubscriptionBenefits', + ) + .mockImplementation(() => undefined); + + controller.invalidateSubscriptionBenefits(); + + expect(invalidate).toHaveBeenCalledTimes(1); + + invalidate.mockRestore(); + }); + + it('omits the subscription waiver from the fee preview when no source is wired', async () => { + const feeParams = { + orderType: 'market' as const, + isMaker: false, + amount: '100000', + symbol: 'BTC', + }; + // The mocked infrastructure wires no `subscription` dependency, so the + // real service reports `no-source` and the context field must be absent + // rather than carrying a meaningless "not eligible". + const getStatus = jest.spyOn( + RewardsIntegrationService.prototype, + 'getSubscriptionFeeWaiverStatus', + ); + + markControllerAsInitialized(); + controller.testSetProviders(new Map([['hyperliquid', mockProvider]])); + + await controller.calculateFees(feeParams); + + expect(getStatus).toHaveReturnedWith({ + eligible: false, + reason: 'no-source', + }); + const { context } = ( + mockMarketDataServiceInstance.calculateFees as jest.Mock + ).mock.calls.at(-1)[0]; + expect(context.subscriptionFeeWaiver).toBeUndefined(); + + getStatus.mockRestore(); + }); + }); + + describe('reportOrderToDataLake', () => { + beforeEach(() => { + markControllerAsInitialized(); + controller.testSetProviders(new Map([['hyperliquid', mockProvider]])); + }); + + it('delegates to DataLakeService.reportOrder', async () => { + const mockReportResult = { + success: true, + error: undefined, + }; + + jest + .spyOn(mockDataLakeServiceInstance, 'reportOrder') + .mockResolvedValue(mockReportResult); + + const orderParams = { + action: 'open' as const, + symbol: 'BTC', + slPrice: 45000, + tpPrice: 55000, + }; + + const result = await controller.testReportOrderToDataLake(orderParams); + + expect(result).toEqual(mockReportResult); + expect(mockDataLakeServiceInstance.reportOrder).toHaveBeenCalledWith({ + action: orderParams.action, + symbol: orderParams.symbol, + slPrice: orderParams.slPrice, + tpPrice: orderParams.tpPrice, + isTestnet: controller.state.isTestnet, + context: expect.objectContaining({ + tracingContext: expect.any(Object), + errorContext: expect.objectContaining({ + method: 'reportOrderToDataLake', + }), + stateManager: expect.any(Object), + }), + retryCount: undefined, + _traceId: undefined, + }); + }); + }); + + describe('getAvailableDexs', () => { + beforeEach(() => { + markControllerAsInitialized(); + controller.testSetProviders(new Map([['hyperliquid', mockProvider]])); + }); + + it('returns available HIP-3 DEXs from provider', async () => { + const mockDexs = ['dex1', 'dex2', 'dex3']; + jest + .spyOn(mockMarketDataServiceInstance, 'getAvailableDexs') + .mockResolvedValue(mockDexs); + + const result = await controller.getAvailableDexs(); + + expect(result).toEqual(mockDexs); + expect( + mockMarketDataServiceInstance.getAvailableDexs, + ).toHaveBeenCalledWith({ + provider: mockProvider, + params: undefined, + context: expect.any(Object), + }); + }); + + it('passes filter parameters to provider', async () => { + const mockDexs = ['dex1']; + const filterParams = {} as GetAvailableDexsParams; + jest + .spyOn(mockMarketDataServiceInstance, 'getAvailableDexs') + .mockResolvedValue(mockDexs); + + const result = await controller.getAvailableDexs(filterParams); + + expect(result).toEqual(mockDexs); + expect( + mockMarketDataServiceInstance.getAvailableDexs, + ).toHaveBeenCalledWith({ + provider: mockProvider, + params: filterParams, + context: expect.any(Object), + }); + }); + + it('throws error when provider does not support HIP-3', async () => { + jest + .spyOn(mockMarketDataServiceInstance, 'getAvailableDexs') + .mockRejectedValue(new Error('Provider does not support HIP-3 DEXs')); + + await expect(controller.getAvailableDexs()).rejects.toThrow( + 'Provider does not support HIP-3 DEXs', + ); + }); + }); + + describe('depositWithConfirmation', () => { + const mockTransaction = { + from: '0x1234567890123456789012345678901234567890', + to: '0xabcdefabcdefabcdefabcdefabcdefabcdefabcd', + value: '0x0', + data: '0x', + gas: '0x186a0', + }; + + const mockDepositId = 'deposit-123'; + const mockAssetChainId = '0x1'; + const mockNetworkClientId = 'mainnet'; + const mockTransactionMeta = { id: 'tx-meta-123' }; + const mockTxHash = '0xhash123'; + + let depositInfrastructure: jest.Mocked; + let depositController: TestablePerpsController; + let depositMockCall: jest.Mock; + + beforeEach(async () => { + // Mock DepositService + jest + .spyOn(mockDepositServiceInstance, 'prepareTransaction') + .mockResolvedValue({ + transaction: mockTransaction, + assetChainId: mockAssetChainId, + currentDepositId: mockDepositId, + }); + + // Create infrastructure mock (controllers no longer on infra) + depositInfrastructure = createMockInfrastructure(); + + // Create messenger mock that handles network + transaction + account controller calls + depositMockCall = jest + .fn() + .mockImplementation((action: string, ..._args: unknown[]) => { + if (action === 'RemoteFeatureFlagController:getState') { + return { + remoteFeatureFlags: { + perpsPerpTradingGeoBlockedCountriesV2: { blockedRegions: [] }, + }, + }; + } + if ( + action === + 'AccountTreeController:getAccountsFromSelectedAccountGroup' + ) { + return [ + { + address: '0x1234567890123456789012345678901234567890', + type: 'eip155:eoa', + }, + ]; + } + if (action === 'NetworkController:findNetworkClientIdByChainId') { + return mockNetworkClientId; + } + if (action === 'TransactionController:addTransaction') { + return Promise.resolve({ + result: Promise.resolve(mockTxHash), + transactionMeta: mockTransactionMeta, + }); + } + return undefined; + }); + + // Create a controller with the custom infrastructure for this test suite + depositController = new TestablePerpsController({ + messenger: createMockMessenger({ call: depositMockCall }), + state: getDefaultPerpsControllerState(), + infrastructure: depositInfrastructure, + }); + + // Drain the async eligibility chain started in the constructor + // (RemoteFeatureFlagController:getState → refreshEligibility → + // GeolocationController:getGeolocation) so its messenger calls do not + // leak into per-test assertions on depositMockCall. Microtask ordering + // differs between Node versions, so without this drain the chain can + // bleed into the recorded call list on CI (Node 18) while passing + // locally (Node 22). + await new Promise((resolve) => setImmediate(resolve)); + depositMockCall.mockClear(); + }); + + afterEach(() => { + jest.clearAllMocks(); + mockAddTransaction.mockClear(); + }); + + it('returns promise result', async () => { + depositController.testMarkInitialized(); + depositController.testSetProviders( + new Map([['hyperliquid', mockProvider]]), + ); + + const result = await depositController.depositWithConfirmation({ + amount: '100', + }); + + expect(result).toEqual({ + result: expect.any(Promise), + }); + }); + + it('delegates to DepositService.prepareTransaction', async () => { + depositController.testMarkInitialized(); + depositController.testSetProviders( + new Map([['hyperliquid', mockProvider]]), + ); + + await depositController.depositWithConfirmation({ amount: '100' }); + + expect( + mockDepositServiceInstance.prepareTransaction, + ).toHaveBeenCalledWith({ + provider: mockProvider, + }); + }); + + it('calls NetworkController:findNetworkClientIdByChainId with correct chainId', async () => { + depositController.testMarkInitialized(); + depositController.testSetProviders( + new Map([['hyperliquid', mockProvider]]), + ); + + await depositController.depositWithConfirmation({ amount: '100' }); + + expect(depositMockCall).toHaveBeenCalledWith( + 'NetworkController:findNetworkClientIdByChainId', + mockAssetChainId, + ); + }); + + it('calls TransactionController:addTransaction with prepared transaction', async () => { + depositController.testMarkInitialized(); + depositController.testSetProviders( + new Map([['hyperliquid', mockProvider]]), + ); + + await depositController.depositWithConfirmation({ amount: '100' }); + + expect(depositMockCall).toHaveBeenCalledWith( + 'TransactionController:addTransaction', + mockTransaction, + { + networkClientId: mockNetworkClientId, + origin: 'metamask', + type: 'perpsDeposit', + skipInitialGasEstimate: true, + isInternal: true, + }, + ); + }); + + it('throws error when controller not initialized', async () => { + depositController.testSetInitialized(false); + + await expect( + depositController.depositWithConfirmation({ amount: '100' }), + ).rejects.toThrow('CLIENT_NOT_INITIALIZED'); + }); + + it('throws error when no active provider', async () => { + depositController.testMarkInitialized(); + depositController.testSetProviders(new Map()); + + await expect( + depositController.depositWithConfirmation({ amount: '100' }), + ).rejects.toThrow(); + }); + + it('propagates DepositService errors', async () => { + depositController.testMarkInitialized(); + depositController.testSetProviders( + new Map([['hyperliquid', mockProvider]]), + ); + const mockError = new Error('Deposit service failed'); + jest + .spyOn(mockDepositServiceInstance, 'prepareTransaction') + .mockRejectedValue(mockError); + + await expect( + depositController.depositWithConfirmation({ amount: '100' }), + ).rejects.toThrow('Deposit service failed'); + }); + + it('propagates NetworkController:findNetworkClientIdByChainId errors', async () => { + depositController.testMarkInitialized(); + depositController.testSetProviders( + new Map([['hyperliquid', mockProvider]]), + ); + const mockError = new Error('Network client not found'); + depositMockCall.mockImplementation( + (action: string, ..._args: unknown[]) => { + if ( + action === + 'AccountTreeController:getAccountsFromSelectedAccountGroup' + ) { + return [ + { + address: '0x1234567890123456789012345678901234567890', + type: 'eip155:eoa', + }, + ]; + } + if (action === 'NetworkController:findNetworkClientIdByChainId') { + throw mockError; + } + if (action === 'RemoteFeatureFlagController:getState') { + return { remoteFeatureFlags: {} }; + } + return undefined; + }, + ); + + await expect( + depositController.depositWithConfirmation({ amount: '100' }), + ).rejects.toThrow('Network client not found'); + }); + + it('marks deposit request as failed when networkClientId is not found', async () => { + depositController.testMarkInitialized(); + depositController.testSetProviders( + new Map([['hyperliquid', mockProvider]]), + ); + depositMockCall.mockImplementation( + (action: string, ..._args: unknown[]) => { + if ( + action === + 'AccountTreeController:getAccountsFromSelectedAccountGroup' + ) { + return [ + { + address: '0x1234567890123456789012345678901234567890', + type: 'eip155:eoa', + }, + ]; + } + if (action === 'NetworkController:findNetworkClientIdByChainId') { + return undefined; + } + if (action === 'RemoteFeatureFlagController:getState') { + return { remoteFeatureFlags: {} }; + } + return undefined; + }, + ); + + await expect( + depositController.depositWithConfirmation({ amount: '100' }), + ).rejects.toThrow('No network client found for chain'); + + // Verify the deposit request was marked as failed, not left as pending + const depositRequest = depositController.state.depositRequests.find( + (req) => req.id === mockDepositId, + ); + expect(depositRequest).toBeDefined(); + expect(depositRequest?.status).toBe('failed'); + expect(depositRequest?.success).toBe(false); + }); + + it('propagates TransactionController:addTransaction errors', async () => { + depositController.testMarkInitialized(); + depositController.testSetProviders( + new Map([['hyperliquid', mockProvider]]), + ); + const mockError = new Error('Transaction failed'); + depositMockCall.mockImplementation( + (action: string, ..._args: unknown[]) => { + if ( + action === + 'AccountTreeController:getAccountsFromSelectedAccountGroup' + ) { + return [ + { + address: '0x1234567890123456789012345678901234567890', + type: 'eip155:eoa', + }, + ]; + } + if (action === 'TransactionController:addTransaction') { + return Promise.reject(mockError); + } + if (action === 'NetworkController:findNetworkClientIdByChainId') { + return mockNetworkClientId; + } + if (action === 'RemoteFeatureFlagController:getState') { + return { remoteFeatureFlags: {} }; + } + return undefined; + }, + ); + + await expect( + depositController.depositWithConfirmation({ amount: '100' }), + ).rejects.toThrow('Transaction failed'); + }); + + it('clears transaction ID when error occurs and not user cancellation', async () => { + depositController.testMarkInitialized(); + depositController.testSetProviders( + new Map([['hyperliquid', mockProvider]]), + ); + depositController.testUpdate((state) => { + state.lastDepositTransactionId = 'old-tx-id'; + }); + const mockError = new Error('Network error'); + depositMockCall.mockImplementation( + (action: string, ..._args: unknown[]) => { + if ( + action === + 'AccountTreeController:getAccountsFromSelectedAccountGroup' + ) { + return [ + { + address: '0x1234567890123456789012345678901234567890', + type: 'eip155:eoa', + }, + ]; + } + if (action === 'TransactionController:addTransaction') { + return Promise.reject(mockError); + } + if (action === 'NetworkController:findNetworkClientIdByChainId') { + return mockNetworkClientId; + } + if (action === 'RemoteFeatureFlagController:getState') { + return { remoteFeatureFlags: {} }; + } + return undefined; + }, + ); + + await expect( + depositController.depositWithConfirmation({ amount: '100' }), + ).rejects.toThrow('Network error'); + + expect(depositController.state.lastDepositTransactionId).toBeNull(); + }); + + it('preserves state when user cancels transaction', async () => { + depositController.testMarkInitialized(); + depositController.testSetProviders( + new Map([['hyperliquid', mockProvider]]), + ); + depositController.testUpdate((state) => { + state.lastDepositTransactionId = 'old-tx-id'; + }); + const mockError = new Error('User denied transaction signature'); + depositMockCall.mockImplementation( + (action: string, ..._args: unknown[]) => { + if ( + action === + 'AccountTreeController:getAccountsFromSelectedAccountGroup' + ) { + return [ + { + address: '0x1234567890123456789012345678901234567890', + type: 'eip155:eoa', + }, + ]; + } + if (action === 'TransactionController:addTransaction') { + return Promise.reject(mockError); + } + if (action === 'NetworkController:findNetworkClientIdByChainId') { + return mockNetworkClientId; + } + if (action === 'RemoteFeatureFlagController:getState') { + return { remoteFeatureFlags: {} }; + } + return undefined; + }, + ); + + await expect( + depositController.depositWithConfirmation({ amount: '100' }), + ).rejects.toThrow('User denied'); + + // When user cancels, transaction ID is not cleared + expect(depositController.state.lastDepositTransactionId).toBe( + 'old-tx-id', + ); + }); + + it('clears stale deposit results before transaction', async () => { + depositController.testMarkInitialized(); + depositController.testSetProviders( + new Map([['hyperliquid', mockProvider]]), + ); + depositController.testUpdate((state) => { + state.lastDepositResult = { + success: true, + txHash: '0xold', + amount: '50', + asset: 'USDC', + timestamp: Date.now() - 1000, + error: '', + }; + }); + + const { result } = await depositController.depositWithConfirmation({ + amount: '100', + }); + + await result; + + // After promise resolves, lastDepositResult is set with new result + expect(depositController.state.lastDepositResult).toBeTruthy(); + expect(depositController.state.lastDepositResult?.success).toBe(true); + }); + + it('updates state with transaction details', async () => { + depositController.testMarkInitialized(); + depositController.testSetProviders( + new Map([['hyperliquid', mockProvider]]), + ); + + await depositController.depositWithConfirmation({ amount: '100' }); + + expect(depositController.state.lastDepositTransactionId).toBe( + 'tx-meta-123', + ); + }); + + it('stores depositId from service immediately', async () => { + depositController.testMarkInitialized(); + depositController.testSetProviders( + new Map([['hyperliquid', mockProvider]]), + ); + + await depositController.depositWithConfirmation({ amount: '100' }); + + expect(depositController.state.depositRequests[0].id).toBe(mockDepositId); + }); + + it('delegates to DepositService with provider', async () => { + depositController.testMarkInitialized(); + depositController.testSetProviders( + new Map([['hyperliquid', mockProvider]]), + ); + + await depositController.depositWithConfirmation({ amount: '100' }); + + expect( + mockDepositServiceInstance.prepareTransaction, + ).toHaveBeenCalledWith({ + provider: mockProvider, + }); + }); + + it('adds deposit request to tracking initially as pending', async () => { + depositController.testMarkInitialized(); + depositController.testSetProviders( + new Map([['hyperliquid', mockProvider]]), + ); + + await depositController.depositWithConfirmation({ amount: '100' }); + + expect(depositController.state.depositRequests).toHaveLength(1); + expect(depositController.state.depositRequests[0].id).toBe(mockDepositId); + expect(depositController.state.depositRequests[0].amount).toBe('100'); + expect(depositController.state.depositRequests[0].asset).toBe('USDC'); + }); + + it('uses default amount when not provided', async () => { + depositController.testMarkInitialized(); + depositController.testSetProviders( + new Map([['hyperliquid', mockProvider]]), + ); + + await depositController.depositWithConfirmation(); + + expect(depositController.state.depositRequests[0].amount).toBe('0'); + }); + + it('updates deposit request to completed when transaction succeeds', async () => { + depositController.testMarkInitialized(); + depositController.testSetProviders( + new Map([['hyperliquid', mockProvider]]), + ); + + const { result } = await depositController.depositWithConfirmation({ + amount: '100', + }); + + await result; + + // After promise resolves, deposit request is marked as completed + expect(depositController.state.depositRequests[0].status).toBe( + 'completed', + ); + expect(depositController.state.depositRequests[0].success).toBe(true); + expect(depositController.state.depositRequests[0].txHash).toBe( + mockTxHash, + ); + }); + + it('handles concurrent deposit operations without data corruption', async () => { + depositController.testMarkInitialized(); + depositController.testSetProviders( + new Map([['hyperliquid', mockProvider]]), + ); + + const deposit1 = depositController.depositWithConfirmation({ + amount: '100', + }); + const deposit2 = depositController.depositWithConfirmation({ + amount: '200', + }); + + await Promise.all([deposit1, deposit2]); + + expect(depositController.state.depositRequests).toHaveLength(2); + const amounts = depositController.state.depositRequests.map( + (req) => req.amount, + ); + expect(amounts).toContain('100'); + expect(amounts).toContain('200'); + }); + + it('uses addTransaction when placeOrder is true', async () => { + depositController.testMarkInitialized(); + depositController.testSetProviders( + new Map([['hyperliquid', mockProvider]]), + ); + + await depositController.depositWithConfirmation({ + amount: '100', + placeOrder: true, + }); + + // placeOrder uses messenger-based addTransaction with perpsDepositAndOrder type + expect(depositMockCall).toHaveBeenCalledWith( + 'TransactionController:addTransaction', + mockTransaction, + { + networkClientId: mockNetworkClientId, + origin: 'metamask', + type: 'perpsDepositAndOrder', + skipInitialGasEstimate: true, + isInternal: true, + }, + ); + // Should NOT also call with perpsDeposit type + expect(depositMockCall).not.toHaveBeenCalledWith( + 'TransactionController:addTransaction', + expect.anything(), + expect.objectContaining({ type: 'perpsDeposit' }), + ); + expect(depositController.state.lastDepositTransactionId).toBe( + 'tx-meta-123', + ); + }); + + it('returns resolved promise with transaction ID when placeOrder is true', async () => { + depositController.testMarkInitialized(); + depositController.testSetProviders( + new Map([['hyperliquid', mockProvider]]), + ); + + const { result } = await depositController.depositWithConfirmation({ + amount: '100', + placeOrder: true, + }); + + // This would hang indefinitely with the old never-resolving promise + const txId = await result; + expect(typeof txId).toBe('string'); + expect(txId).toBe('tx-meta-123'); + }); + + it('clears depositInProgress after successful transaction', async () => { + jest.useFakeTimers(); + depositController.testMarkInitialized(); + depositController.testSetProviders( + new Map([['hyperliquid', mockProvider]]), + ); + + const { result } = await depositController.depositWithConfirmation({ + amount: '100', + }); + + // Transaction succeeds + await result; + + // Initially depositInProgress should be true + expect(depositController.state.depositInProgress).toBe(true); + + // Fast-forward the setTimeout + jest.advanceTimersByTime(100); + + // After timeout, depositInProgress should be cleared + expect(depositController.state.depositInProgress).toBe(false); + expect(depositController.state.lastDepositTransactionId).toBeNull(); + + jest.useRealTimers(); + }); + + it('handles non-user-cancelled transaction errors after confirmation', async () => { + jest.useFakeTimers(); + depositController.testMarkInitialized(); + depositController.testSetProviders( + new Map([['hyperliquid', mockProvider]]), + ); + + // Mock messenger to succeed initially, but result promise rejects + const mockError = new Error('Network error occurred'); + depositMockCall.mockImplementation( + (action: string, ..._args: unknown[]) => { + if ( + action === + 'AccountTreeController:getAccountsFromSelectedAccountGroup' + ) { + return [ + { + address: '0x1234567890123456789012345678901234567890', + type: 'eip155:eoa', + }, + ]; + } + if (action === 'TransactionController:addTransaction') { + return Promise.resolve({ + result: Promise.reject(mockError), + transactionMeta: mockTransactionMeta, + }); + } + if (action === 'NetworkController:findNetworkClientIdByChainId') { + return mockNetworkClientId; + } + if (action === 'RemoteFeatureFlagController:getState') { + return { remoteFeatureFlags: {} }; + } + return undefined; + }, + ); + + const { result } = await depositController.depositWithConfirmation({ + amount: '100', + }); + + // Wait for the result promise to reject + await expect(result).rejects.toThrow('Network error occurred'); + + // Should set error state + expect(depositController.state.depositInProgress).toBe(false); + expect(depositController.state.lastDepositTransactionId).toBeNull(); + expect(depositController.state.lastDepositResult).toEqual({ + success: false, + error: 'Network error occurred', + amount: '100', + asset: 'USDC', + timestamp: expect.any(Number), + txHash: '', + }); + + // Should update deposit request status + expect(depositController.state.depositRequests[0].status).toBe('failed'); + expect(depositController.state.depositRequests[0].success).toBe(false); + + jest.useRealTimers(); + }); + + it('handles user cancelled transaction with different error messages', async () => { + depositController.testMarkInitialized(); + depositController.testSetProviders( + new Map([['hyperliquid', mockProvider]]), + ); + + const cancellationMessages = [ + 'User rejected transaction signature', + 'User cancelled transaction', + 'User canceled transaction', + ]; + + for (const message of cancellationMessages) { + // Reset deposit controller state for each iteration + depositController.testUpdate((state) => { + state.depositRequests = []; + state.lastDepositResult = null; + state.depositInProgress = false; + }); + jest.clearAllMocks(); + const mockError = new Error(message); + // Mock messenger to succeed initially, but result promise rejects with user cancellation + depositMockCall.mockImplementation( + (action: string, ..._args: unknown[]) => { + if ( + action === + 'AccountTreeController:getAccountsFromSelectedAccountGroup' + ) { + return [ + { + address: '0x1234567890123456789012345678901234567890', + type: 'eip155:eoa', + }, + ]; + } + if (action === 'TransactionController:addTransaction') { + return Promise.resolve({ + result: Promise.reject(mockError), + transactionMeta: mockTransactionMeta, + }); + } + if (action === 'NetworkController:findNetworkClientIdByChainId') { + return mockNetworkClientId; + } + if (action === 'RemoteFeatureFlagController:getState') { + return { remoteFeatureFlags: {} }; + } + return undefined; + }, + ); + + const { result } = await depositController.depositWithConfirmation({ + amount: '100', + }); + + await expect(result).rejects.toThrow(message); + + // Should clear state but not set error result + expect(depositController.state.depositInProgress).toBe(false); + expect(depositController.state.lastDepositTransactionId).toBeNull(); + expect(depositController.state.lastDepositResult).toBeNull(); + } + }); + }); + + describe('updateWithdrawalStatus', () => { + const mockWithdrawalId = 'withdrawal-123'; + const mockTxHash = '0xhash456'; + + beforeEach(() => { + markControllerAsInitialized(); + controller.testUpdate((state) => { + state.withdrawalRequests = [ + { + id: mockWithdrawalId, + timestamp: Date.now(), + amount: '50', + asset: 'USDC', + accountAddress: '0x1234567890123456789012345678901234567890', + success: false, + status: 'pending', + source: 'hyperliquid', + }, + ]; + }); + }); + + it('updates withdrawal status to completed with txHash', () => { + controller.updateWithdrawalStatus( + mockWithdrawalId, + 'completed', + mockTxHash, + ); + + const withdrawal = controller.state.withdrawalRequests[0]; + expect(withdrawal.status).toBe('completed'); + expect(withdrawal.txHash).toBe(mockTxHash); + expect(withdrawal.success).toBe(true); + }); + + it('removes withdrawal request when status is failed', () => { + controller.updateWithdrawalStatus(mockWithdrawalId, 'failed'); + + expect( + controller.state.withdrawalRequests.some( + (w) => w.id === mockWithdrawalId, + ), + ).toBe(false); + }); + + it('clears withdrawal progress when status completed', () => { + controller.testUpdate((state) => { + state.withdrawalProgress = { + progress: 50, + lastUpdated: Date.now() - 1000, + activeWithdrawalId: mockWithdrawalId, + }; + }); + + controller.updateWithdrawalStatus( + mockWithdrawalId, + 'completed', + mockTxHash, + ); + + expect(controller.state.withdrawalProgress.progress).toBe(0); + expect(controller.state.withdrawalProgress.activeWithdrawalId).toBeNull(); + }); + + it('clears withdrawal progress when status failed', () => { + controller.testUpdate((state) => { + state.withdrawalProgress = { + progress: 75, + lastUpdated: Date.now() - 1000, + activeWithdrawalId: mockWithdrawalId, + }; + }); + + controller.updateWithdrawalStatus(mockWithdrawalId, 'failed'); + + expect(controller.state.withdrawalProgress.progress).toBe(0); + expect(controller.state.withdrawalProgress.activeWithdrawalId).toBeNull(); + expect( + controller.state.withdrawalRequests.some( + (w) => w.id === mockWithdrawalId, + ), + ).toBe(false); + }); + + it('finds withdrawal by ID', () => { + controller.testUpdate((state) => { + state.withdrawalRequests.push({ + id: 'withdrawal-456', + timestamp: Date.now(), + amount: '75', + asset: 'USDC', + accountAddress: '0x1234567890123456789012345678901234567890', + success: false, + status: 'pending', + source: 'hyperliquid', + }); + }); + + controller.updateWithdrawalStatus( + 'withdrawal-456', + 'completed', + mockTxHash, + ); + + expect(controller.state.withdrawalRequests[1].status).toBe('completed'); + expect(controller.state.withdrawalRequests[0].status).toBe('pending'); + }); + + it('does nothing when withdrawal ID not found', () => { + const initialRequests = [...controller.state.withdrawalRequests]; + + controller.updateWithdrawalStatus( + 'non-existent-id', + 'completed', + mockTxHash, + ); + + expect(controller.state.withdrawalRequests).toEqual(initialRequests); + }); + + it('updates state correctly for multiple withdrawals', () => { + controller.testUpdate((state) => { + state.withdrawalRequests.push({ + id: 'withdrawal-789', + timestamp: Date.now(), + amount: '100', + asset: 'USDC', + accountAddress: '0x1234567890123456789012345678901234567890', + success: false, + status: 'pending', + source: 'hyperliquid', + }); + }); + + controller.updateWithdrawalStatus( + mockWithdrawalId, + 'completed', + mockTxHash, + ); + + expect(controller.state.withdrawalRequests[0].status).toBe('completed'); + expect(controller.state.withdrawalRequests[1].status).toBe('pending'); + }); + + it('handles undefined txHash gracefully', () => { + controller.updateWithdrawalStatus(mockWithdrawalId, 'completed'); + + const withdrawal = controller.state.withdrawalRequests[0]; + expect(withdrawal.status).toBe('completed'); + expect(withdrawal.txHash).toBeUndefined(); + expect(withdrawal.success).toBe(true); + }); + }); + + describe('completeWithdrawalFromHistory', () => { + const pendingId = 'withdrawal-fifo-1'; + const txHash = '0xfifoabc'; + const completedPayload = { + txHash, + amount: '25', + timestamp: 1_700_000_000_000, + asset: 'USDC', + }; + + beforeEach(() => { + markControllerAsInitialized(); + controller.testUpdate((state) => { + state.withdrawalRequests = [ + { + id: pendingId, + timestamp: Date.now(), + amount: '25', + asset: 'USDC', + accountAddress: '0x1234567890123456789012345678901234567890', + success: false, + status: 'pending', + source: 'hyperliquid', + }, + ]; + state.withdrawInProgress = true; + state.lastCompletedWithdrawalTimestamp = 1_699_000_000_000; + state.lastCompletedWithdrawalTxHashes = ['0xexisting']; + state.lastUpdateTimestamp = 99_999; + }); + }); + + it('does not mutate FIFO guards or emit analytics when withdrawal id is unknown', () => { + const snapshot = { + withdrawalRequests: [...controller.state.withdrawalRequests], + lastCompletedWithdrawalTimestamp: + controller.state.lastCompletedWithdrawalTimestamp, + lastCompletedWithdrawalTxHashes: [ + ...controller.state.lastCompletedWithdrawalTxHashes, + ], + withdrawInProgress: controller.state.withdrawInProgress, + lastUpdateTimestamp: controller.state.lastUpdateTimestamp, + }; + + controller.completeWithdrawalFromHistory('unknown-withdrawal-id', { + ...completedPayload, + txHash: '0xstale', + }); + + expect(controller.state.withdrawalRequests).toEqual( + snapshot.withdrawalRequests, + ); + expect(controller.state.lastCompletedWithdrawalTimestamp).toBe( + snapshot.lastCompletedWithdrawalTimestamp, + ); + expect(controller.state.lastCompletedWithdrawalTxHashes).toEqual( + snapshot.lastCompletedWithdrawalTxHashes, + ); + expect(controller.state.withdrawInProgress).toBe( + snapshot.withdrawInProgress, + ); + expect(controller.state.lastUpdateTimestamp).toBe( + snapshot.lastUpdateTimestamp, + ); + expect(mockInfrastructure.metrics.trackPerpsEvent).not.toHaveBeenCalled(); + }); + + it('removes the request, updates FIFO guards, and tracks completion when id matches', () => { + controller.completeWithdrawalFromHistory(pendingId, completedPayload); + + expect(controller.state.withdrawalRequests).toHaveLength(0); + expect(controller.state.lastCompletedWithdrawalTimestamp).toBe( + completedPayload.timestamp, + ); + expect(controller.state.lastCompletedWithdrawalTxHashes).toEqual([ + '0xexisting', + txHash, + ]); + expect(controller.state.withdrawInProgress).toBe(false); + expect(mockInfrastructure.metrics.trackPerpsEvent).toHaveBeenCalledWith( + PerpsAnalyticsEvent.WithdrawalTransaction, + expect.objectContaining({ + [PERPS_EVENT_PROPERTY.STATUS]: PERPS_EVENT_VALUE.STATUS.COMPLETED, + [PERPS_EVENT_PROPERTY.WITHDRAWAL_AMOUNT]: 25, + }), + ); + }); + }); + + describe('markFirstOrderCompleted', () => { + beforeEach(() => { + markControllerAsInitialized(); + }); + + it('marks first order completed for mainnet', () => { + controller.testUpdate((state) => { + state.isTestnet = false; + }); + + controller.markFirstOrderCompleted(); + + expect(controller.state.hasPlacedFirstOrder.mainnet).toBe(true); + }); + + it('marks first order completed for testnet', () => { + controller.testUpdate((state) => { + state.isTestnet = true; + }); + + controller.markFirstOrderCompleted(); + + expect(controller.state.hasPlacedFirstOrder.testnet).toBe(true); + }); + + it('only updates status for current network', () => { + controller.testUpdate((state) => { + state.isTestnet = false; + state.hasPlacedFirstOrder = { + mainnet: false, + testnet: false, + }; + }); + + controller.markFirstOrderCompleted(); + + expect(controller.state.hasPlacedFirstOrder.mainnet).toBe(true); + expect(controller.state.hasPlacedFirstOrder.testnet).toBe(false); + }); + + it('does not crash when called multiple times', () => { + controller.testUpdate((state) => { + state.isTestnet = false; + }); + + controller.markFirstOrderCompleted(); + expect(controller.state.hasPlacedFirstOrder.mainnet).toBe(true); + + controller.markFirstOrderCompleted(); + expect(controller.state.hasPlacedFirstOrder.mainnet).toBe(true); + }); + + it('logs completion without throwing', () => { + controller.testUpdate((state) => { + state.isTestnet = false; + }); + + expect(() => controller.markFirstOrderCompleted()).not.toThrow(); + }); + }); + + describe('getWithdrawalRoutes error handling', () => { + beforeEach(() => { + markControllerAsInitialized(); + controller.testSetProviders(new Map([['hyperliquid', mockProvider]])); + }); + + it('logs error in getWithdrawalRoutes when provider throws', () => { + const mockError = new Error('Provider error'); + jest + .spyOn(mockMarketDataServiceInstance, 'getWithdrawalRoutes') + .mockImplementation(() => { + throw mockError; + }); + + const result = controller.getWithdrawalRoutes(); + + expect(result).toEqual([]); + expect(mockInfrastructure.logger.error).toHaveBeenCalledWith( + mockError, + expect.objectContaining({ + context: expect.objectContaining({ + name: 'PerpsController', + data: expect.objectContaining({ + method: 'getWithdrawalRoutes', + }), + }), + }), + ); + }); + + it('returns empty array from getWithdrawalRoutes on error', () => { + jest + .spyOn(mockMarketDataServiceInstance, 'getWithdrawalRoutes') + .mockImplementation(() => { + throw new Error('Service failure'); + }); + + const result = controller.getWithdrawalRoutes(); + + expect(result).toEqual([]); + }); + + it('handles edge case with null provider gracefully', () => { + controller.testSetProviders(new Map()); + + expect(() => controller.getWithdrawalRoutes()).not.toThrow(); + expect(controller.getWithdrawalRoutes()).toEqual([]); + }); + }); +}); diff --git a/packages/perps-controller/tests/src/PerpsController.providers-cache.test.ts b/packages/perps-controller/tests/src/PerpsController.providers-cache.test.ts new file mode 100644 index 00000000000..cc02cd6c6cb --- /dev/null +++ b/packages/perps-controller/tests/src/PerpsController.providers-cache.test.ts @@ -0,0 +1,3903 @@ +/* eslint-disable */ +/** + * PerpsController Tests + * Clean, focused test suite for PerpsController + */ + +/* eslint-disable @typescript-eslint/no-explicit-any */ + +import { + createMockHyperLiquidProvider, + createMockPosition, +} from '../helpers/providerMocks.js'; +import { + createMockInfrastructure, + createMockMessenger, +} from '../helpers/serviceMocks.js'; + +jest.mock('@nktkas/hyperliquid', () => ({})); +jest.mock('@myx-trade/sdk', () => ({ + MyxClient: jest.fn(), + OrderStatusEnum: { Successful: 9 }, +})); + +import { + PERPS_EVENT_PROPERTY, + PERPS_EVENT_VALUE, +} from '../../src/constants/eventNames.js'; +import { + PERPS_CONSTANTS, + PERPS_DISK_CACHE_MARKETS, + PERPS_DISK_CACHE_USER_DATA, +} from '../../src/constants/perpsConfig.js'; +import { + PerpsController, + getDefaultPerpsControllerState, + InitializationState, + firstNonEmpty, + resolveMyxAuthConfig, +} from '../../src/PerpsController.js'; +import type { PerpsControllerState } from '../../src/PerpsController.js'; +import { PERPS_ERROR_CODES } from '../../src/perpsErrorCodes.js'; +import { HyperLiquidProvider } from '../../src/providers/HyperLiquidProvider.js'; +import type { ServiceContext } from '../../src/services/ServiceContext.js'; +import type { + AccountState, + GetAvailableDexsParams, + PerpsProvider, + PerpsPlatformDependencies, + PerpsMarketData, + PerpsProviderType, + PerpsUserDataSnapshot, + Position, + SubscribeAccountParams, +} from '../../src/types/index.js'; +import { PerpsAnalyticsEvent } from '../../src/types/index.js'; + +jest.mock('../../src/providers/HyperLiquidProvider'); +jest.mock('../../src/providers/MYXProvider'); + +// Mock transaction controller utility +const mockAddTransaction = jest.fn(); +jest.mock( + '../../../util/transaction-controller', + () => ({ + addTransaction: (...args: unknown[]) => mockAddTransaction(...args), + }), + { virtual: true }, +); + +// Mock wait utility to speed up retry tests +jest.mock('../../src/utils/wait', () => ({ + wait: jest.fn().mockResolvedValue(undefined), +})); + +// Mock stream manager +const mockStreamManager = { + positions: { pause: jest.fn(), resume: jest.fn() }, + account: { pause: jest.fn(), resume: jest.fn() }, + orders: { pause: jest.fn(), resume: jest.fn() }, + prices: { pause: jest.fn(), resume: jest.fn() }, + orderFills: { pause: jest.fn(), resume: jest.fn() }, +}; + +jest.mock( + '../../../components/UI/Perps/providers/PerpsStreamManager', + () => ({ + getStreamManagerInstance: jest.fn(() => mockStreamManager), + }), + { virtual: true }, +); + +jest.mock('@metamask/utils', () => ({ + ...jest.requireActual('@metamask/utils'), + formatAccountToCaipAccountId: jest + .fn() + .mockReturnValue('eip155:1:0x1234567890123456789012345678901234567890'), +})); + +// Mock EligibilityService as a class with instance methods +const mockEligibilityServiceInstance = { + checkEligibility: jest.fn().mockResolvedValue(true), +}; +jest.mock('../../src/services/EligibilityService', () => ({ + EligibilityService: jest + .fn() + .mockImplementation(() => mockEligibilityServiceInstance), +})); + +// Mock DepositService as a class with instance methods +const mockDepositServiceInstance = { + prepareTransaction: jest.fn(), +}; +jest.mock('../../src/services/DepositService', () => ({ + DepositService: jest + .fn() + .mockImplementation(() => mockDepositServiceInstance), +})); + +// Mock MarketDataService as a class with instance methods +const mockMarketDataServiceInstance = { + getPositions: jest.fn(), + getAccountState: jest.fn(), + getMarkets: jest.fn(), + getMarketDataWithPrices: jest + .fn() + .mockImplementation( + ({ + provider, + }: { + provider: { getMarketDataWithPrices: () => Promise }; + }) => provider.getMarketDataWithPrices(), + ), + getWithdrawalRoutes: jest.fn().mockReturnValue([]), + validateClosePosition: jest.fn().mockResolvedValue({ isValid: true }), + validateOrder: jest.fn(), + calculateMaintenanceMargin: jest.fn().mockResolvedValue(0), + calculateLiquidationPrice: jest.fn(), + getMaxLeverage: jest.fn(), + calculateFees: jest.fn().mockResolvedValue({ totalFee: 0 }), + previewPositionModify: jest.fn(), + getAvailableDexs: jest.fn().mockResolvedValue([]), + getBlockExplorerUrl: jest.fn(), + getOrderFills: jest.fn(), + getOrders: jest.fn(), + getFunding: jest.fn(), +}; +jest.mock('../../src/services/MarketDataService', () => ({ + MarketDataService: jest + .fn() + .mockImplementation(() => mockMarketDataServiceInstance), +})); + +// Mock TradingService as a class with instance methods +const mockTradingServiceInstance = { + placeOrder: jest.fn(), + editOrder: jest.fn(), + cancelOrder: jest.fn(), + cancelOrders: jest.fn(), + closePosition: jest.fn(), + closePositions: jest.fn(), + updatePositionTPSL: jest.fn(), + updateMargin: jest.fn(), + flipPosition: jest.fn(), + setControllerDependencies: jest.fn(), +}; +jest.mock('../../src/services/TradingService', () => ({ + TradingService: jest + .fn() + .mockImplementation(() => mockTradingServiceInstance), +})); + +// Mock AccountService as a class with instance methods +const mockAccountServiceInstance = { + withdraw: jest.fn(), + validateWithdrawal: jest.fn(), +}; +jest.mock('../../src/services/AccountService', () => ({ + AccountService: jest + .fn() + .mockImplementation(() => mockAccountServiceInstance), +})); + +// Mock DataLakeService as a class with instance methods +const mockDataLakeServiceInstance = { + reportOrder: jest.fn(), +}; +jest.mock('../../src/services/DataLakeService', () => ({ + DataLakeService: jest + .fn() + .mockImplementation(() => mockDataLakeServiceInstance), +})); + +// Mock FeatureFlagConfigurationService as a class with instance methods +const mockFeatureFlagConfigurationServiceInstance = { + refreshEligibility: jest.fn((options: any) => { + // Simulate the service's behavior: extract blocked regions from remote flags + const remoteFlags = + options.remoteFeatureFlagControllerState.remoteFeatureFlags; + const perpsGeoBlockedRegionsFeatureFlag = + remoteFlags?.perpsPerpTradingGeoBlockedCountriesV2; + const remoteBlockedRegions = + perpsGeoBlockedRegionsFeatureFlag?.blockedRegions; + + if ( + Array.isArray(remoteBlockedRegions) && + options.context.setBlockedRegionList + ) { + const currentList = options.context.getBlockedRegionList?.(); + // Never downgrade from remote to fallback + if (!currentList || currentList.source !== 'remote') { + options.context.setBlockedRegionList(remoteBlockedRegions, 'remote'); + } + } + + // Call refreshEligibility callback if available + if (options.context.refreshEligibility) { + options.context.refreshEligibility().catch(() => { + // Ignore errors in mock + }); + } + + // Also call refreshHip3Config if available + if (remoteFlags) { + mockFeatureFlagConfigurationServiceInstance.refreshHip3Config(options); + } + }), + refreshHip3Config: jest.fn(), + setBlockedRegions: jest.fn((options: any) => { + // Simulate setBlockedRegions behavior + const { list, source, context } = options; + if (context.setBlockedRegionList && context.getBlockedRegionList) { + const currentList = context.getBlockedRegionList(); + // Never downgrade from remote to fallback + if (source === 'fallback' && currentList.source === 'remote') { + return; + } + if (Array.isArray(list)) { + context.setBlockedRegionList(list, source); + } + } + + // Call refreshEligibility callback if available + if (context.refreshEligibility) { + context.refreshEligibility().catch(() => { + // Ignore errors in mock + }); + } + }), +}; +jest.mock('../../src/services/FeatureFlagConfigurationService', () => ({ + FeatureFlagConfigurationService: jest + .fn() + .mockImplementation(() => mockFeatureFlagConfigurationServiceInstance), +})); + +/** + * Testable version of PerpsController that exposes protected methods for testing. + * This follows the pattern used in RewardsController.test.ts + */ +class TestablePerpsController extends PerpsController { + /** + * Test-only method to update state directly. + * Exposed for scenarios where state needs to be manipulated + * outside the normal public API (e.g., testing error conditions). + * @param callback + */ + public testUpdate(callback: (state: PerpsControllerState) => void) { + this.update(callback); + } + + /** + * Test-only method to mark controller as initialized. + * Common test scenario that requires internal state changes. + */ + public testMarkInitialized() { + this.isInitialized = true; + this.update((state) => { + state.initializationState = InitializationState.Initialized; + }); + } + + /** + * Test-only method to set the providers map with complete providers. + * Used in most tests to inject mock providers. + * Also sets activeProviderInstance to the first provider (default provider). + * @param providers + */ + public testSetProviders(providers: Map) { + this.providers = providers; + // Set activeProviderInstance to the first provider (typically 'hyperliquid') + const firstProvider = providers.values().next().value; + if (firstProvider) { + this.activeProviderInstance = firstProvider; + } + } + + /** + * Test-only method to set the providers map with partial providers. + * Used explicitly in tests that verify error handling with incomplete providers. + * Type cast is intentional and necessary for testing graceful degradation. + * @param providers + */ + public testSetPartialProviders( + providers: Map>, + ) { + this.providers = providers as Map; + } + + /** + * Test-only method to get the providers map. + * Used to verify provider state in tests. + */ + public testGetProviders(): Map { + return this.providers; + } + + /** + * Test-only method to set initialization state. + * Allows tests to simulate both initialized and uninitialized states. + * @param value + */ + public testSetInitialized(value: boolean) { + this.isInitialized = value; + } + + /** + * Test-only method to get initialization state. + * Used to verify initialization status in tests. + */ + public testGetInitialized(): boolean { + return this.isInitialized; + } + + /** + * Test-only method to get blocked region list. + * Used to verify geo-blocking configuration in tests. + */ + public testGetBlockedRegionList(): { source: string; list: string[] } { + return this.blockedRegionList; + } + + /** + * Test-only method to set blocked region list. + * Used to test priority logic (remote vs fallback). + * @param list + * @param source + */ + public testSetBlockedRegionList( + list: string[], + source: 'remote' | 'fallback', + ) { + this.setBlockedRegionList(list, source); + } + + /** + * Test accessor for protected method refreshEligibilityOnFeatureFlagChange. + * Wrapper is necessary because protected methods can't be called from test code. + * @param remoteFlags + */ + public testRefreshEligibilityOnFeatureFlagChange(remoteFlags: any) { + this.refreshEligibilityOnFeatureFlagChange(remoteFlags); + } + + /** + * Test accessor for protected method reportOrderToDataLake. + * Wrapper is necessary because protected methods can't be called from test code. + * @param data + */ + public testReportOrderToDataLake(data: any): Promise { + return this.reportOrderToDataLake(data); + } + + public testHasStandaloneProvider(): boolean { + return this.hasStandaloneProvider(); + } + + public testRegisterMYXProvider(MYXProvider: unknown) { + this.registerMYXProvider(MYXProvider); + } + + public testHandleMYXImportError(error: unknown) { + this.handleMYXImportError(error); + } +} + +describe('PerpsController', () => { + let controller: TestablePerpsController; + let mockProvider: jest.Mocked; + let mockInfrastructure: jest.Mocked; + let mockMessenger: ReturnType; + + // Helper to mark controller as initialized for tests + const markControllerAsInitialized = () => { + controller.testMarkInitialized(); + }; + + beforeEach(() => { + jest.clearAllMocks(); + + ( + jest.requireMock('../../src/services/EligibilityService') + .EligibilityService as jest.Mock + ).mockImplementation(() => mockEligibilityServiceInstance); + ( + jest.requireMock('../../src/services/DepositService') + .DepositService as jest.Mock + ).mockImplementation(() => mockDepositServiceInstance); + ( + jest.requireMock('../../src/services/MarketDataService') + .MarketDataService as jest.Mock + ).mockImplementation(() => mockMarketDataServiceInstance); + ( + jest.requireMock('../../src/services/TradingService') + .TradingService as jest.Mock + ).mockImplementation(() => mockTradingServiceInstance); + ( + jest.requireMock('../../src/services/AccountService') + .AccountService as jest.Mock + ).mockImplementation(() => mockAccountServiceInstance); + ( + jest.requireMock('../../src/services/DataLakeService') + .DataLakeService as jest.Mock + ).mockImplementation(() => mockDataLakeServiceInstance); + ( + jest.requireMock('../../src/services/FeatureFlagConfigurationService') + .FeatureFlagConfigurationService as jest.Mock + ).mockImplementation(() => mockFeatureFlagConfigurationServiceInstance); + + mockEligibilityServiceInstance.checkEligibility.mockResolvedValue(true); + mockMarketDataServiceInstance.getPositions.mockResolvedValue([]); + mockMarketDataServiceInstance.getAccountState.mockResolvedValue({ + spendableBalance: '10000', + withdrawableBalance: '10000', + totalBalance: '10000', + marginUsed: '0', + unrealizedPnl: '0', + returnOnEquity: '0', + }); + mockMarketDataServiceInstance.getMarkets.mockResolvedValue([]); + mockMarketDataServiceInstance.getMarketDataWithPrices.mockImplementation( + ({ + provider, + }: { + provider: { getMarketDataWithPrices: () => Promise }; + }) => provider.getMarketDataWithPrices(), + ); + mockMarketDataServiceInstance.getWithdrawalRoutes.mockReturnValue([]); + mockMarketDataServiceInstance.validateClosePosition.mockResolvedValue({ + isValid: true, + }); + mockMarketDataServiceInstance.calculateMaintenanceMargin.mockResolvedValue( + 0, + ); + mockMarketDataServiceInstance.calculateFees.mockResolvedValue({ + totalFee: 0, + }); + mockMarketDataServiceInstance.getAvailableDexs.mockResolvedValue([]); + + mockFeatureFlagConfigurationServiceInstance.refreshEligibility.mockImplementation( + (options: any) => { + const remoteFlags = + options.remoteFeatureFlagControllerState.remoteFeatureFlags; + const perpsGeoBlockedRegionsFeatureFlag = + remoteFlags?.perpsPerpTradingGeoBlockedCountriesV2; + const remoteBlockedRegions = + perpsGeoBlockedRegionsFeatureFlag?.blockedRegions; + + if ( + Array.isArray(remoteBlockedRegions) && + options.context.setBlockedRegionList + ) { + const currentList = options.context.getBlockedRegionList?.(); + if (!currentList || currentList.source !== 'remote') { + options.context.setBlockedRegionList( + remoteBlockedRegions, + 'remote', + ); + } + } + + if (options.context.refreshEligibility) { + options.context.refreshEligibility().catch(() => { + // Ignore errors in mock + }); + } + + if (remoteFlags) { + mockFeatureFlagConfigurationServiceInstance.refreshHip3Config( + options, + ); + } + }, + ); + mockFeatureFlagConfigurationServiceInstance.setBlockedRegions.mockImplementation( + (options: any) => { + const { list, source, context } = options; + if (context.setBlockedRegionList && context.getBlockedRegionList) { + const currentList = context.getBlockedRegionList(); + if (source === 'fallback' && currentList.source === 'remote') { + return; + } + if (Array.isArray(list)) { + context.setBlockedRegionList(list, source); + } + } + + if (context.refreshEligibility) { + context.refreshEligibility().catch(() => { + // Ignore errors in mock + }); + } + }, + ); + mockFeatureFlagConfigurationServiceInstance.refreshHip3Config.mockImplementation( + () => undefined, + ); + + // Create a fresh mock provider for each test + mockProvider = createMockHyperLiquidProvider(); + + // Add default mock return values for all provider methods + mockProvider.getPositions.mockResolvedValue([]); + mockProvider.getAccountState.mockResolvedValue({ + spendableBalance: '10000', + withdrawableBalance: '10000', + totalBalance: '10000', + marginUsed: '0', + unrealizedPnl: '0', + returnOnEquity: '0', + }); + mockProvider.getMarkets.mockResolvedValue([]); + mockProvider.getOpenOrders.mockResolvedValue([]); + mockProvider.getFunding.mockResolvedValue([]); + mockProvider.getOrderFills.mockResolvedValue([]); + mockProvider.getOrders.mockResolvedValue([]); + mockProvider.calculateLiquidationPrice.mockResolvedValue('0'); + mockProvider.getMaxLeverage.mockResolvedValue(50); + mockProvider.calculateMaintenanceMargin.mockResolvedValue(0); + mockProvider.calculateFees.mockResolvedValue({ feeAmount: 0 }); + mockProvider.getBlockExplorerUrl.mockReturnValue( + 'https://explorer.example.com', + ); + mockProvider.getWithdrawalRoutes.mockReturnValue([]); + + ( + HyperLiquidProvider as jest.MockedClass + ).mockImplementation(() => mockProvider); + + const mockCall = jest.fn().mockImplementation((action: string) => { + if (action === 'RemoteFeatureFlagController:getState') { + return { + remoteFeatureFlags: { + perpsPerpTradingGeoBlockedCountriesV2: { + blockedRegions: [], + }, + }, + }; + } + if ( + action === 'AccountTreeController:getAccountsFromSelectedAccountGroup' + ) { + return [ + { + address: '0x1234567890123456789012345678901234567890', + type: 'eip155:eoa', + id: 'account-1', + options: {}, + scopes: ['eip155:1'], + methods: [], + metadata: { + name: 'Test', + importTime: 0, + keyring: { type: 'HD Key Tree' }, + }, + }, + ]; + } + return undefined; + }); + + mockInfrastructure = createMockInfrastructure(); + mockMessenger = createMockMessenger({ call: mockCall }); + controller = new TestablePerpsController({ + messenger: mockMessenger, + state: getDefaultPerpsControllerState(), + infrastructure: mockInfrastructure, + }); + }); + + afterEach(() => { + // Clear only provider mocks, not Engine.context mocks + // This prevents breaking Engine.context.RewardsController/NetworkController references + if (mockProvider) { + Object.values(mockProvider).forEach((value) => { + if ( + typeof value === 'object' && + value !== null && + 'mockClear' in value + ) { + (value as jest.Mock).mockClear(); + } + }); + } + (mockInfrastructure.metrics.trackPerpsEvent as jest.Mock).mockClear(); + (mockInfrastructure.logger.error as jest.Mock).mockClear(); + (mockInfrastructure.debugLogger.log as jest.Mock).mockClear(); + }); + describe('resetSelectedPaymentToken', () => { + it('sets selectedPaymentToken to null', () => { + controller.testUpdate((state) => { + state.selectedPaymentToken = { + description: 'USDC', + address: '0xa0b8', + chainId: '0x1', + } as PerpsControllerState['selectedPaymentToken']; + }); + + controller.resetSelectedPaymentToken(); + + expect(controller.state.selectedPaymentToken).toBeNull(); + }); + }); + + describe('switchProvider', () => { + it('initializes before a same-provider switch on a cold controller', async () => { + const result = await controller.switchProvider('hyperliquid'); + + expect(result.success).toBe(true); + expect(result.providerId).toBe('hyperliquid'); + expect(controller.testGetInitialized()).toBe(true); + expect(controller.getActiveProviderOrNull()).not.toBeNull(); + }); + + it('returns success without re-init when switching to same provider', async () => { + await controller.init(); + + const result = await controller.switchProvider('hyperliquid'); + + expect(result.success).toBe(true); + expect(result.providerId).toBe('hyperliquid'); + }); + + it('returns error when already reinitializing', async () => { + await controller.init(); + + // Register myx in providers map so it passes the isValidProvider check + const mockMYXProvider = { + ...createMockHyperLiquidProvider(), + protocolId: 'myx', + }; + const providers = controller.testGetProviders(); + providers.set('myx', mockMYXProvider as any); + controller.testSetProviders(providers); + + jest.spyOn(controller, 'isCurrentlyReinitializing').mockReturnValue(true); + + const result = await controller.switchProvider('myx'); + + expect(result.success).toBe(false); + expect(result.error).toBe(PERPS_ERROR_CODES.CLIENT_REINITIALIZING); + }); + + it('returns error for invalid provider not in providers map', async () => { + await controller.init(); + + const result = await controller.switchProvider('myx'); + + expect(result.success).toBe(false); + expect(result.error).toContain('Provider myx not available'); + }); + + it('allows aggregated even without explicit map entry', async () => { + await controller.init(); + + // 'aggregated' is always valid according to the validation logic + const result = await controller.switchProvider('aggregated'); + + // The key assertion is that it didn't return "not available" error + // aggregated proceeds to the init path and succeeds + expect(result.success).toBe(true); + }); + + it('switches to myx provider successfully', async () => { + // Create controller with MYX-enabled mocks + const myxInfrastructure = createMockInfrastructure(); + ( + myxInfrastructure.featureFlags.validateVersionGated as jest.Mock + ).mockReturnValue(true); + // Enable MYX feature flag via messenger + const myxMockCall = jest.fn().mockImplementation((action: string) => { + if (action === 'RemoteFeatureFlagController:getState') { + return { + remoteFeatureFlags: { + perpsPerpTradingGeoBlockedCountriesV2: { blockedRegions: [] }, + perpsMyxProviderEnabled: { + enabled: true, + minimumVersion: '0.0.0', + }, + }, + }; + } + return undefined; + }); + + const myxController = new TestablePerpsController({ + messenger: createMockMessenger({ call: myxMockCall }), + state: getDefaultPerpsControllerState(), + infrastructure: myxInfrastructure, + }); + + await myxController.init(); + + // Register a mock MYX provider + const mockMYXProvider = { + ...createMockHyperLiquidProvider(), + protocolId: 'myx', + }; + const providers = myxController.testGetProviders(); + providers.set('myx', mockMYXProvider as any); + myxController.testSetProviders(providers); + + // Mock init on the reinit call inside switchProvider. + // Dynamic import() rejects in Jest (no --experimental-vm-modules), + // so MYX can't register via #createProviders. Mock init to + // simulate successful reinitialization while preserving our + // manually-injected MYX provider in the map. + jest.spyOn(myxController, 'init').mockImplementationOnce(async () => { + myxController.testUpdate((state) => { + state.initializationState = InitializationState.Initialized; + }); + }); + + const result = await myxController.switchProvider('myx'); + + expect(result.success).toBe(true); + expect(result.providerId).toBe('myx'); + expect(myxController.state.activeProvider).toBe('myx'); + }); + + it('rolls back to previous provider on init failure', async () => { + await controller.init(); + + // Register a mock MYX provider + const mockMYXProvider = { + ...createMockHyperLiquidProvider(), + protocolId: 'myx', + }; + const providers = controller.testGetProviders(); + providers.set('myx', mockMYXProvider as any); + controller.testSetProviders(providers); + + // Reinitialization now runs through the private serialized lifecycle, + // so fail provider reconstruction rather than spying on public init(). + jest.mocked(HyperLiquidProvider).mockImplementation(() => { + throw new Error('MYX init failed'); + }); + + const result = await controller.switchProvider('myx'); + + expect(result.success).toBe(false); + // Should roll back to previous provider + expect(controller.state.activeProvider).toBe('hyperliquid'); + + // Restore init for further tests + jest.restoreAllMocks(); + }); + + it('clears isReinitializing flag after success', async () => { + await controller.init(); + + const mockMYXProvider = { + ...createMockHyperLiquidProvider(), + protocolId: 'myx', + }; + const providers = controller.testGetProviders(); + providers.set('myx', mockMYXProvider as any); + controller.testSetProviders(providers); + + await controller.switchProvider('myx'); + + expect(controller.isCurrentlyReinitializing()).toBe(false); + }); + + it('clears isReinitializing flag after failure', async () => { + await controller.init(); + + const mockMYXProvider = { + ...createMockHyperLiquidProvider(), + protocolId: 'myx', + }; + const providers = controller.testGetProviders(); + providers.set('myx', mockMYXProvider as any); + controller.testSetProviders(providers); + + jest.spyOn(controller, 'init').mockImplementationOnce(async () => { + controller.testUpdate((state) => { + state.initializationState = InitializationState.Failed; + state.initializationError = 'fail'; + }); + }); + + await controller.switchProvider('myx'); + + expect(controller.isCurrentlyReinitializing()).toBe(false); + + jest.restoreAllMocks(); + }); + }); + + describe('init - MYX fallback', () => { + it('falls back to hyperliquid when activeProvider is myx but MYX feature flag is disabled', async () => { + // Set state to myx before init + controller.testUpdate((state) => { + state.activeProvider = 'myx'; + }); + + // isMYXProviderEnabled() returns false by default (no perpsMyxProviderEnabled in remote flags) + await controller.init(); + + // The init path should detect MYX is not available and fall back + expect(controller.state.activeProvider).toBe('hyperliquid'); + }); + + it('registerMYXProvider creates and registers the MYX provider', () => { + // Arrange + const mockMYXInstance = createMockHyperLiquidProvider(); + const MockMYXConstructor = jest.fn(() => mockMYXInstance); + + // Act + controller.testRegisterMYXProvider( + MockMYXConstructor as unknown as new ( + opts: Record, + ) => PerpsProvider, + ); + + // Assert + const providers = controller.testGetProviders(); + expect(providers.get('myx')).toBe(mockMYXInstance); + expect(MockMYXConstructor).toHaveBeenCalledWith( + expect.objectContaining({ isTestnet: false }), + ); + }); + + it('registerMYXProvider ignores a missing optional constructor', () => { + controller.testRegisterMYXProvider(undefined); + + expect(controller.testGetProviders().has('myx')).toBe(false); + }); + + it('handleMYXImportError logs debug for MODULE_NOT_FOUND errors', () => { + // Arrange — Node sets code: 'MODULE_NOT_FOUND' on missing modules + const moduleError = Object.assign( + new Error('Cannot find module ./providers/MYXProvider'), + { code: 'MODULE_NOT_FOUND' }, + ); + + // Act + controller.testHandleMYXImportError(moduleError); + + // Assert + expect(mockInfrastructure.debugLogger.log).toHaveBeenCalledWith( + 'PerpsController: MYX provider module not available, skipping registration', + ); + }); + + it('handleMYXImportError routes runtime errors to logError', () => { + // Act — error without MODULE_NOT_FOUND code goes to Sentry + controller.testHandleMYXImportError(new Error('Invalid auth config')); + + // Assert + expect(mockInfrastructure.logger.error).toHaveBeenCalledWith( + expect.objectContaining({ message: 'Invalid auth config' }), + expect.objectContaining({ + context: expect.objectContaining({ + data: expect.objectContaining({ + method: 'createProviders.myx', + }), + }), + }), + ); + }); + }); + + describe('getOpenOrders with standalone mode', () => { + const mockUserAddress = '0xabcdef1234567890abcdef1234567890abcdef12'; + const MockedHyperLiquidProvider = HyperLiquidProvider as jest.MockedClass< + typeof HyperLiquidProvider + >; + + beforeEach(() => { + MockedHyperLiquidProvider.mockClear(); + }); + + it('uses existing provider for standalone queries when available', async () => { + const mockOrders = [ + { + orderId: 'o1', + symbol: 'BTC', + side: 'buy' as const, + orderType: 'limit' as const, + size: '0.1', + originalSize: '0.1', + filledSize: '0', + remainingSize: '0.1', + price: '50000', + status: 'open' as const, + timestamp: Date.now(), + }, + ]; + const existingMockProvider = createMockHyperLiquidProvider(); + existingMockProvider.getOpenOrders.mockResolvedValue(mockOrders); + controller.testSetProviders( + new Map([['hyperliquid', existingMockProvider]]), + ); + controller.testMarkInitialized(); + controller.testUpdate((state) => { + state.activeProvider = 'hyperliquid'; + }); + + const result = await controller.getOpenOrders({ + standalone: true, + userAddress: mockUserAddress, + }); + + expect(existingMockProvider.getOpenOrders).toHaveBeenCalledWith({ + standalone: true, + userAddress: mockUserAddress, + }); + expect(result).toEqual(mockOrders); + expect(MockedHyperLiquidProvider).not.toHaveBeenCalled(); + }); + + it('creates temporary provider for standalone queries when no activeProviderInstance', async () => { + const mockOrders = [ + { + orderId: 'o2', + symbol: 'ETH', + side: 'sell' as const, + orderType: 'market' as const, + size: '1', + originalSize: '1', + filledSize: '0', + remainingSize: '1', + price: '3000', + status: 'open' as const, + timestamp: Date.now(), + }, + ]; + const tempMockProvider = createMockHyperLiquidProvider(); + tempMockProvider.getOpenOrders.mockResolvedValue(mockOrders); + MockedHyperLiquidProvider.mockImplementation(() => tempMockProvider); + + controller.testUpdate((state) => { + state.activeProvider = 'aggregated'; + state.isTestnet = true; + }); + + const result = await controller.getOpenOrders({ + standalone: true, + userAddress: mockUserAddress, + }); + + expect(MockedHyperLiquidProvider).toHaveBeenCalledWith( + expect.objectContaining({ isTestnet: true }), + ); + expect(result).toEqual(mockOrders); + }); + + it('bypasses getActiveProvider check for standalone queries', async () => { + const tempMockProvider = createMockHyperLiquidProvider(); + tempMockProvider.getOpenOrders.mockResolvedValue([]); + MockedHyperLiquidProvider.mockImplementation(() => tempMockProvider); + + controller.testUpdate((state) => { + state.initializationState = InitializationState.Initializing; + state.activeProvider = 'aggregated'; + }); + + const result = await controller.getOpenOrders({ + standalone: true, + userAddress: mockUserAddress, + }); + + expect(result).toEqual([]); + }); + }); + + describe('getMarketDataWithPrices with standalone mode', () => { + const MockedHyperLiquidProvider = HyperLiquidProvider as jest.MockedClass< + typeof HyperLiquidProvider + >; + + beforeEach(() => { + MockedHyperLiquidProvider.mockClear(); + }); + + it('passes only an exact static Hyperliquid snapshot identity and guards config races', async () => { + mockInfrastructure.terminalApi = { + ...mockInfrastructure.terminalApi, + globalSnapshotUrl: 'https://terminal.test/v2/perpetuals', + }; + controller = new TestablePerpsController({ + messenger: createMockMessenger(), + state: getDefaultPerpsControllerState(), + clientConfig: { + fallbackHip3Enabled: true, + fallbackHip3AllowlistMarkets: ['xyz:*'], + fallbackHip3BlocklistMarkets: ['xyz:TSLA'], + }, + infrastructure: mockInfrastructure, + }); + controller.testSetProviders(new Map([['hyperliquid', mockProvider]])); + controller.testMarkInitialized(); + mockProvider.getMarketDataWithPrices.mockResolvedValue([]); + + await controller.getMarketDataWithPrices({ + standalone: true, + useTerminalApi: true, + }); + + const call = mockMarketDataServiceInstance.getMarketDataWithPrices.mock + .calls[0]?.[0] as { + context: ServiceContext; + }; + expect(call.context.globalSnapshot?.request).toStrictEqual({ + provider: 'hyperliquid', + network: 'mainnet', + enabledDexes: ['main', 'xyz'], + }); + expect(call.context.globalSnapshot?.isMarketAllowed('xyz:GOLD')).toBe( + true, + ); + expect(call.context.globalSnapshot?.isMarketAllowed('xyz:TSLA')).toBe( + false, + ); + expect(call.context.globalSnapshot?.isCurrent()).toBe(true); + + controller.testUpdate((state) => { + state.hip3ConfigVersion += 1; + }); + expect(call.context.globalSnapshot?.isCurrent()).toBe(false); + + const liveUpdate = { + symbol: 'BTC', + price: '50002', + timestamp: Date.now(), + isTradable: true, + }; + mockProvider.subscribeToPrices.mockImplementation(({ callback }) => { + callback([liveUpdate]); + return jest.fn(); + }); + const priceCallback = jest.fn(); + controller.subscribeToPrices({ + symbols: ['BTC'], + callback: priceCallback, + }); + expect(mockProvider.subscribeToPrices).toHaveBeenCalledTimes(1); + expect(priceCallback).toHaveBeenCalledWith([liveUpdate]); + }); + + it('treats a bare allowlist entry as a DEX shorthand', async () => { + mockInfrastructure.terminalApi = { + ...mockInfrastructure.terminalApi, + globalSnapshotUrl: 'https://terminal.test/v2/perpetuals', + }; + controller = new TestablePerpsController({ + messenger: createMockMessenger(), + state: getDefaultPerpsControllerState(), + clientConfig: { + fallbackHip3Enabled: true, + fallbackHip3AllowlistMarkets: ['xyz'], + }, + infrastructure: mockInfrastructure, + }); + controller.testSetProviders(new Map([['hyperliquid', mockProvider]])); + controller.testMarkInitialized(); + mockProvider.getMarketDataWithPrices.mockResolvedValue([]); + + await controller.getMarketDataWithPrices({ + standalone: true, + useTerminalApi: true, + }); + + expect( + mockMarketDataServiceInstance.getMarketDataWithPrices.mock.calls[0]?.[0] + .context.globalSnapshot?.request.enabledDexes, + ).toStrictEqual(['main', 'xyz']); + }); + + it('keeps main first in an exact static snapshot identity', async () => { + mockInfrastructure.terminalApi = { + globalSnapshotUrl: 'https://terminal.test/v2/perpetuals', + }; + controller = new TestablePerpsController({ + messenger: createMockMessenger(), + state: getDefaultPerpsControllerState(), + clientConfig: { + fallbackHip3Enabled: true, + fallbackHip3AllowlistMarkets: ['flx:*', 'xyz:*'], + }, + infrastructure: mockInfrastructure, + }); + controller.testSetProviders(new Map([['hyperliquid', mockProvider]])); + controller.testMarkInitialized(); + + await controller.getMarketDataWithPrices({ standalone: true }); + + expect( + mockMarketDataServiceInstance.getMarketDataWithPrices.mock.calls[0]?.[0] + .context.globalSnapshot?.request.enabledDexes, + ).toEqual(['main', 'flx', 'xyz']); + }); + + it('does not enable snapshots for a legacy-only injected Terminal service', async () => { + mockInfrastructure.terminalApi = undefined; + mockInfrastructure.terminalMarketService = { + fetchMarkets: jest.fn(), + clearCache: jest.fn(), + logError: jest.fn(), + }; + controller = new TestablePerpsController({ + messenger: createMockMessenger(), + state: getDefaultPerpsControllerState(), + infrastructure: mockInfrastructure, + }); + controller.testSetProviders(new Map([['hyperliquid', mockProvider]])); + controller.testMarkInitialized(); + + await controller.getMarketDataWithPrices({ standalone: true }); + + expect( + mockMarketDataServiceInstance.getMarketDataWithPrices.mock.calls[0]?.[0] + .context.globalSnapshot, + ).toBeUndefined(); + }); + + it('uses existing provider for standalone queries when available', async () => { + const mockMarketData = [ + { + symbol: 'BTC', + name: 'BTC', + price: '50000', + maxLeverage: '50x', + change24h: '+100', + change24hPercent: '+0.2%', + volume: '$1B', + }, + ]; + const existingMockProvider = createMockHyperLiquidProvider(); + existingMockProvider.getMarketDataWithPrices.mockResolvedValue( + mockMarketData, + ); + controller.testSetProviders( + new Map([['hyperliquid', existingMockProvider]]), + ); + controller.testMarkInitialized(); + controller.testUpdate((state) => { + state.activeProvider = 'hyperliquid'; + }); + + const result = await controller.getMarketDataWithPrices({ + standalone: true, + }); + + expect(existingMockProvider.getMarketDataWithPrices).toHaveBeenCalled(); + expect(result).toEqual(mockMarketData); + expect(MockedHyperLiquidProvider).not.toHaveBeenCalled(); + }); + + it('creates temporary provider for standalone queries when no activeProviderInstance', async () => { + const mockMarketData = [ + { + symbol: 'ETH', + name: 'ETH', + price: '3000', + maxLeverage: '50x', + change24h: '+50', + change24hPercent: '+1.7%', + volume: '$500M', + }, + ]; + const tempMockProvider = createMockHyperLiquidProvider(); + tempMockProvider.getMarketDataWithPrices.mockResolvedValue( + mockMarketData, + ); + MockedHyperLiquidProvider.mockImplementation(() => tempMockProvider); + + controller.testUpdate((state) => { + state.activeProvider = 'aggregated'; + state.isTestnet = false; + }); + + const result = await controller.getMarketDataWithPrices({ + standalone: true, + }); + + expect(MockedHyperLiquidProvider).toHaveBeenCalledWith( + expect.objectContaining({ isTestnet: false }), + ); + expect(result).toEqual(mockMarketData); + }); + + it('does not disconnect a standalone provider while a market request is in flight', async () => { + let resolveMarketData!: (marketData: unknown[]) => void; + const marketDataPromise = new Promise((resolve) => { + resolveMarketData = resolve; + }); + const tempMockProvider = createMockHyperLiquidProvider(); + tempMockProvider.getMarketDataWithPrices.mockReturnValue( + marketDataPromise as ReturnType< + typeof tempMockProvider.getMarketDataWithPrices + >, + ); + MockedHyperLiquidProvider.mockImplementation(() => tempMockProvider); + + const marketRequest = controller.getMarketDataWithPrices({ + standalone: true, + }); + const disconnectRequest = controller.disconnect(); + + expect(tempMockProvider.disconnect).not.toHaveBeenCalled(); + + resolveMarketData([]); + + await expect(marketRequest).resolves.toEqual([]); + await disconnectRequest; + expect(tempMockProvider.disconnect).toHaveBeenCalledTimes(1); + }); + + it('uses getActiveProvider for non-standalone queries', async () => { + markControllerAsInitialized(); + controller.testSetProviders(new Map([['hyperliquid', mockProvider]])); + mockProvider.getMarketDataWithPrices.mockResolvedValue([]); + + const result = await controller.getMarketDataWithPrices(); + + expect(mockProvider.getMarketDataWithPrices).toHaveBeenCalled(); + expect(result).toEqual([]); + }); + }); + + describe('startMarketDataPreload and stopMarketDataPreload', () => { + beforeEach(() => { + jest.useFakeTimers(); + }); + + afterEach(() => { + controller.stopMarketDataPreload(); + jest.useRealTimers(); + }); + + it('is idempotent - calling start twice does not create duplicate timers', () => { + markControllerAsInitialized(); + controller.testSetProviders(new Map([['hyperliquid', mockProvider]])); + mockProvider.getMarketDataWithPrices.mockResolvedValue([]); + + controller.startMarketDataPreload(); + controller.startMarketDataPreload(); + + // Advance timers past the preload interval (5 min) to verify no double calls + jest.advanceTimersByTime(5 * 60 * 1000 + 100); + + // performMarketDataPreload calls getMarketDataWithPrices({ standalone: true }) + // The first immediate call happens, then only 1 interval call (not 2) + // With isPreloading guard, second immediate call is skipped + expect(mockInfrastructure.debugLogger.log).toHaveBeenCalledWith( + 'PerpsController: Preload already started, skipping', + ); + }); + + it('calls performMarketDataPreload immediately on start', async () => { + markControllerAsInitialized(); + controller.testSetProviders(new Map([['hyperliquid', mockProvider]])); + mockProvider.getMarketDataWithPrices.mockResolvedValue([ + { + symbol: 'BTC', + name: 'BTC', + price: '50000', + maxLeverage: '50x', + change24h: '+100', + change24hPercent: '+0.2%', + volume: '$1B', + }, + ]); + + controller.startMarketDataPreload(); + + // Wait for the async performMarketDataPreload to complete + await jest.advanceTimersByTimeAsync(100); + + expect(mockInfrastructure.debugLogger.log).toHaveBeenCalledWith( + 'PerpsController: Fetching market data in background', + ); + }); + + it('stopMarketDataPreload clears interval', () => { + markControllerAsInitialized(); + controller.testSetProviders(new Map([['hyperliquid', mockProvider]])); + mockProvider.getMarketDataWithPrices.mockResolvedValue([]); + + controller.startMarketDataPreload(); + controller.stopMarketDataPreload(); + + // After stop, advancing timers should not trigger more calls + const callCountBefore = + mockProvider.getMarketDataWithPrices.mock.calls.length; + jest.advanceTimersByTime(10 * 60 * 1000); + const callCountAfter = + mockProvider.getMarketDataWithPrices.mock.calls.length; + + // No new calls should have been made after stop + expect(callCountAfter).toBe(callCountBefore); + }); + + it('stopMarketDataPreload drops a queued trailing refresh', async () => { + let resolvePreload!: (value: PerpsMarketData[]) => void; + mockProvider.getMarketDataWithPrices.mockReturnValue( + new Promise((resolve) => { + resolvePreload = resolve; + }), + ); + markControllerAsInitialized(); + controller.testSetProviders(new Map([['hyperliquid', mockProvider]])); + + controller.startMarketDataPreload(); + await jest.advanceTimersByTimeAsync(5 * 60 * 1000); + controller.stopMarketDataPreload(); + resolvePreload([]); + await jest.advanceTimersByTimeAsync(0); + + expect(mockProvider.getMarketDataWithPrices).toHaveBeenCalledTimes(1); + }); + + it('stopMarketDataPreload is safe to call when not started', () => { + expect(() => controller.stopMarketDataPreload()).not.toThrow(); + }); + + it('hydrates market data from disk at construction time', () => { + const diskMarkets = { + providerNetworkKey: 'hyperliquid:mainnet', + data: [ + { + symbol: 'BTC', + name: 'Bitcoin', + price: '50000', + change24h: '+100', + change24hPercent: '+0.2%', + maxLeverage: '50x', + volume: '$1B', + }, + ], + timestamp: Date.now(), + }; + const infra = createMockInfrastructure(); + (infra.diskCache.getItemSync as jest.Mock).mockImplementation( + (key: string) => { + if (key === PERPS_DISK_CACHE_MARKETS) { + return JSON.stringify(diskMarkets); + } + return null; + }, + ); + + const ctrl = new TestablePerpsController({ + messenger: createMockMessenger(), + state: getDefaultPerpsControllerState(), + infrastructure: infra, + }); + + const cached = + ctrl.state.cachedMarketDataByProvider['hyperliquid:mainnet']; + expect(cached).not.toBeNull(); + expect(cached?.data).toHaveLength(1); + expect(cached?.data[0].symbol).toBe('BTC'); + // Prices are stripped to placeholder values + expect(cached?.data[0].price).toBe(PERPS_CONSTANTS.FallbackPriceDisplay); + expect(cached?.data[0].change24h).toBe( + PERPS_CONSTANTS.FallbackDataDisplay, + ); + expect(cached?.data[0].change24hPercent).toBe( + PERPS_CONSTANTS.FallbackPercentageDisplay, + ); + expect(ctrl.getCachedMarketDataForActiveProvider()).toBeNull(); + expect( + ctrl.getCachedMarketDataForActiveProvider({ skipTTL: true }), + ).toHaveLength(1); + }); + + it('publishes one construction timestamp after disk hydration and does not write Sentry at construct', () => { + const infra = createMockInfrastructure(); + (infra.performance.now as jest.Mock).mockReturnValue(321); + const onControllerConstructed = jest.fn(); + infra.performance.onControllerConstructed = onControllerConstructed; + + new TestablePerpsController({ + messenger: createMockMessenger(), + state: getDefaultPerpsControllerState(), + infrastructure: infra, + }); + + expect(onControllerConstructed).toHaveBeenCalledTimes(1); + expect(onControllerConstructed).toHaveBeenCalledWith(321); + expect( + (infra.diskCache.getItemSync as jest.Mock).mock.invocationCallOrder[0], + ).toBeLessThan(onControllerConstructed.mock.invocationCallOrder[0]); + expect(infra.tracer.setMeasurement).not.toHaveBeenCalled(); + }); + + it('does not hydrate expired Terminal trend provenance from disk', () => { + const infra = createMockInfrastructure(); + (infra.diskCache.getItemSync as jest.Mock).mockImplementation( + (key: string) => + key === PERPS_DISK_CACHE_MARKETS + ? JSON.stringify({ + providerNetworkKey: 'hyperliquid:mainnet', + data: [ + { + symbol: 'BTC', + name: 'Bitcoin', + price: '50000', + change24h: '+100', + change24hPercent: '+0.2%', + maxLeverage: '50x', + volume: '$1B', + dataSource: 'terminal-global-snapshot-mark', + sourceExpiresAt: Date.now() - 1, + trend: [[Date.now() - 3_600_000, '49000']], + }, + ], + timestamp: Date.now(), + }) + : null, + ); + + const ctrl = new TestablePerpsController({ + messenger: createMockMessenger(), + state: getDefaultPerpsControllerState(), + infrastructure: infra, + }); + + expect( + ctrl.state.cachedMarketDataByProvider['hyperliquid:mainnet']?.data[0] + .trend, + ).toBeUndefined(); + }); + + it('hydrates multi-provider market data from disk before providers register', () => { + const timestamp = Date.now(); + const diskMarkets = { + entries: [ + { + providerNetworkKey: 'hyperliquid:mainnet', + data: [ + { + symbol: 'BTC', + name: 'Bitcoin', + price: '50000', + change24h: '+100', + change24hPercent: '+0.2%', + maxLeverage: '50x', + volume: '$1B', + }, + ], + timestamp, + }, + { + providerNetworkKey: 'myx:mainnet', + data: [ + { + symbol: 'ETH', + name: 'Ethereum', + price: '3000', + change24h: '+50', + change24hPercent: '+1.2%', + maxLeverage: '25x', + volume: '$500M', + }, + ], + timestamp, + }, + ], + }; + const infra = createMockInfrastructure(); + (infra.diskCache.getItemSync as jest.Mock).mockImplementation( + (key: string) => { + if (key === PERPS_DISK_CACHE_MARKETS) { + return JSON.stringify(diskMarkets); + } + return null; + }, + ); + + const ctrl = new TestablePerpsController({ + messenger: createMockMessenger(), + state: { + ...getDefaultPerpsControllerState(), + activeProvider: 'aggregated', + }, + clientConfig: { + providerCredentials: { + myx: { + enabled: true, + }, + }, + } as never, + infrastructure: infra, + }); + + const aggregated = ctrl.getCachedMarketDataForActiveProvider({ + skipTTL: true, + }); + expect(aggregated).toHaveLength(2); + expect(aggregated?.map((market) => market.symbol)).toEqual([ + 'BTC', + 'ETH', + ]); + }); + + it('hydrates aggregated user data from disk before providers register', () => { + const timestamp = Date.now(); + const diskUserData = { + entries: [ + { + providerNetworkKey: 'hyperliquid:mainnet', + address: '0x1234567890abcdef1234567890abcdef12345678', + positions: [createMockPosition({ symbol: 'BTC', size: '1.0' })], + orders: [], + accountState: { + totalBalance: '5000', + spendableBalance: '4000', + withdrawableBalance: '4000', + marginUsed: '1000', + unrealizedPnl: '0', + returnOnEquity: '0', + providerId: 'hyperliquid', + }, + timestamp, + hip3ConfigVersion: 0, + dexes: ['main'], + }, + { + providerNetworkKey: 'myx:mainnet', + address: '0x1234567890abcdef1234567890abcdef12345678', + positions: [createMockPosition({ symbol: 'MYX', size: '2.0' })], + orders: [], + accountState: null, + timestamp, + }, + ], + }; + const infra = createMockInfrastructure(); + (infra.diskCache.getItemSync as jest.Mock).mockImplementation( + (key: string) => { + if (key === PERPS_DISK_CACHE_USER_DATA) { + return JSON.stringify(diskUserData); + } + return null; + }, + ); + + const ctrl = new TestablePerpsController({ + messenger: createMockMessenger(), + state: { + ...getDefaultPerpsControllerState(), + activeProvider: 'aggregated', + }, + clientConfig: { + providerCredentials: { + myx: { + enabled: true, + }, + }, + } as never, + infrastructure: infra, + }); + + const aggregated = ctrl.getCachedUserDataForActiveProvider({ + skipTTL: true, + }); + expect(aggregated?.positions).toHaveLength(2); + expect(aggregated?.accountState?.providerId).toBe('hyperliquid'); + }); + + it('hydrates user data from disk at construction time', () => { + const diskUserData = { + providerNetworkKey: 'hyperliquid:mainnet', + address: '0x1234567890123456789012345678901234567890', + positions: [{ symbol: 'ETH', size: '2.0', entryPrice: '3000' }], + orders: [], + accountState: { + totalBalance: '5000', + spendableBalance: '4000', + withdrawableBalance: '4000', + marginUsed: '1000', + unrealizedPnl: '0', + returnOnEquity: '0', + }, + timestamp: Date.now(), + }; + const infra = createMockInfrastructure(); + (infra.diskCache.getItemSync as jest.Mock).mockImplementation( + (key: string) => { + if (key === PERPS_DISK_CACHE_USER_DATA) { + return JSON.stringify(diskUserData); + } + return null; + }, + ); + + const ctrl = new TestablePerpsController({ + messenger: createMockMessenger(), + state: getDefaultPerpsControllerState(), + infrastructure: infra, + }); + + const cached = ctrl.state.cachedUserDataByProvider['hyperliquid:mainnet']; + expect(cached).not.toBeNull(); + expect(cached?.positions).toHaveLength(1); + expect(cached?.positions[0].symbol).toBe('ETH'); + expect(cached?.address).toBe( + '0x1234567890123456789012345678901234567890', + ); + }); + + it('rejects a disk user snapshot with a mismatched HIP-3 identity', () => { + const diskUserData = { + providerNetworkKey: 'hyperliquid:mainnet', + address: '0x1234567890abcdef1234567890abcdef12345678', + positions: [createMockPosition()], + orders: [], + accountState: null, + timestamp: Date.now(), + hip3ConfigVersion: 9, + dexes: ['main'], + }; + const infra = createMockInfrastructure(); + (infra.diskCache.getItemSync as jest.Mock).mockImplementation( + (key: string) => + key === PERPS_DISK_CACHE_USER_DATA + ? JSON.stringify(diskUserData) + : null, + ); + const ctrl = new TestablePerpsController({ + messenger: createMockMessenger(), + state: getDefaultPerpsControllerState(), + infrastructure: infra, + }); + + const result = ctrl.getCachedUserDataForActiveProvider({ skipTTL: true }); + + expect(result).toBeNull(); + }); + + it('rejects malformed disk DEX identity without throwing', () => { + const diskUserData = { + providerNetworkKey: 'hyperliquid:mainnet', + address: '0x1234567890abcdef1234567890abcdef12345678', + positions: [createMockPosition()], + orders: [], + accountState: null, + timestamp: Date.now(), + hip3ConfigVersion: 0, + dexes: { length: 1 }, + }; + const infra = createMockInfrastructure(); + (infra.diskCache.getItemSync as jest.Mock).mockImplementation( + (key: string) => + key === PERPS_DISK_CACHE_USER_DATA + ? JSON.stringify(diskUserData) + : null, + ); + const ctrl = new TestablePerpsController({ + messenger: createMockMessenger(), + state: getDefaultPerpsControllerState(), + infrastructure: infra, + }); + + const readCache = () => + ctrl.getCachedUserDataForActiveProvider({ skipTTL: true }); + + expect(readCache).not.toThrow(); + expect(readCache()).toBeNull(); + }); + + it('accepts a disk user snapshot with the current exact HIP-3 identity', () => { + const diskUserData = { + providerNetworkKey: 'hyperliquid:mainnet', + address: '0x1234567890abcdef1234567890abcdef12345678', + positions: [createMockPosition()], + orders: [], + accountState: null, + timestamp: Date.now(), + hip3ConfigVersion: 0, + dexes: ['main'], + }; + const infra = createMockInfrastructure(); + (infra.diskCache.getItemSync as jest.Mock).mockImplementation( + (key: string) => + key === PERPS_DISK_CACHE_USER_DATA + ? JSON.stringify(diskUserData) + : null, + ); + const ctrl = new TestablePerpsController({ + messenger: createMockMessenger(), + state: getDefaultPerpsControllerState(), + infrastructure: infra, + }); + + const result = ctrl.getCachedUserDataForActiveProvider({ skipTTL: true }); + + expect(result?.positions).toHaveLength(1); + }); + + it('hydrates user data from disk even when address differs (filtered at read time)', () => { + const diskUserData = { + providerNetworkKey: 'hyperliquid:mainnet', + address: '0xDEADBEEF00000000000000000000000000000000', + positions: [{ symbol: 'ETH', size: '2.0' }], + orders: [], + accountState: null, + timestamp: Date.now(), + }; + const infra = createMockInfrastructure(); + (infra.diskCache.getItemSync as jest.Mock).mockImplementation( + (key: string) => { + if (key === PERPS_DISK_CACHE_USER_DATA) { + return JSON.stringify(diskUserData); + } + return null; + }, + ); + + const ctrl = new TestablePerpsController({ + messenger: createMockMessenger(), + state: getDefaultPerpsControllerState(), + infrastructure: infra, + }); + + // Sync hydration populates cache unconditionally — address + // validation happens in getCachedUserDataForActiveProvider at read time + const cached = ctrl.state.cachedUserDataByProvider['hyperliquid:mainnet']; + expect(cached).not.toBeNull(); + expect(cached?.address).toBe( + '0xDEADBEEF00000000000000000000000000000000', + ); + + // But getCachedUserDataForActiveProvider filters it out (address mismatch) + const read = ctrl.getCachedUserDataForActiveProvider({ + skipTTL: true, + }); + expect(read).toBeNull(); + }); + + it('does not overwrite fresher in-memory state from older disk data', () => { + const freshTimestamp = Date.now(); + const diskMarkets = { + providerNetworkKey: 'hyperliquid:mainnet', + data: [{ symbol: 'BTC', name: 'Bitcoin', price: '50000' }], + timestamp: freshTimestamp - 60_000, // older than initial state + }; + const infra = createMockInfrastructure(); + (infra.diskCache.getItemSync as jest.Mock).mockImplementation( + (key: string) => { + if (key === PERPS_DISK_CACHE_MARKETS) { + return JSON.stringify(diskMarkets); + } + return null; + }, + ); + + // Construct with fresher in-memory data already present + const ctrl = new TestablePerpsController({ + messenger: createMockMessenger(), + state: { + ...getDefaultPerpsControllerState(), + cachedMarketDataByProvider: { + 'hyperliquid:mainnet': { + data: [{ symbol: 'ETH', name: 'Ethereum', price: '3000' } as any], + timestamp: freshTimestamp, + }, + }, + }, + infrastructure: infra, + }); + + const cached = + ctrl.state.cachedMarketDataByProvider['hyperliquid:mainnet']; + expect(cached?.data[0].symbol).toBe('ETH'); + expect(cached?.timestamp).toBe(freshTimestamp); + }); + + it('handles corrupt disk JSON gracefully at construction', () => { + const infra = createMockInfrastructure(); + (infra.diskCache.getItemSync as jest.Mock).mockReturnValue( + 'not valid json{{{', + ); + + const ctrl = new TestablePerpsController({ + messenger: createMockMessenger(), + state: getDefaultPerpsControllerState(), + infrastructure: infra, + }); + + expect( + ctrl.state.cachedMarketDataByProvider['hyperliquid:mainnet'], + ).toBeUndefined(); + }); + + it('falls back gracefully when getItemSync is undefined', () => { + const infra = createMockInfrastructure(); + (infra.diskCache as any).getItemSync = undefined; + + const ctrl = new TestablePerpsController({ + messenger: createMockMessenger(), + state: getDefaultPerpsControllerState(), + infrastructure: infra, + }); + + expect( + ctrl.state.cachedMarketDataByProvider['hyperliquid:mainnet'], + ).toBeUndefined(); + }); + }); + + describe('performMarketDataPreload', () => { + beforeEach(() => { + jest.useFakeTimers(); + }); + + afterEach(() => { + controller.stopMarketDataPreload(); + jest.useRealTimers(); + }); + + it('writes returned global snapshot data into the provider preload cache', async () => { + const mockData = [ + { + symbol: 'BTC', + name: 'BTC', + price: '50000', + maxLeverage: '50x', + change24h: '+100', + change24hPercent: '+0.2%', + volume: '$1B', + dataSource: 'terminal-global-snapshot-mark' as const, + sourceExpiresAt: Date.now() + 20_000, + }, + ]; + markControllerAsInitialized(); + controller.testSetProviders(new Map([['hyperliquid', mockProvider]])); + mockProvider.getMarketDataWithPrices.mockResolvedValue(mockData); + + controller.startMarketDataPreload(); + await jest.advanceTimersByTimeAsync(100); + + const entry = + controller.state.cachedMarketDataByProvider['hyperliquid:mainnet']; + expect(entry?.data).toEqual(mockData); + expect(entry?.data[0]?.dataSource).toBe('terminal-global-snapshot-mark'); + expect(entry?.sourceExpiresAt).toBe(mockData[0].sourceExpiresAt); + expect(entry?.hip3ConfigVersion).toBe(0); + expect(entry?.dexes).toEqual(['main']); + expect(entry?.timestamp).toBeGreaterThan(0); + }); + + it('refreshes a source-expired snapshot inside the normal preload guard', async () => { + markControllerAsInitialized(); + controller.testSetProviders(new Map([['hyperliquid', mockProvider]])); + controller.testUpdate((state) => { + state.cachedMarketDataByProvider['hyperliquid:mainnet'] = { + data: [{ symbol: 'BTC', name: 'BTC', price: '$1' }], + timestamp: Date.now(), + sourceExpiresAt: Date.now() - 1, + hip3ConfigVersion: 0, + dexes: ['main'], + }; + }); + mockMarketDataServiceInstance.getMarketDataWithPrices.mockResolvedValue( + [], + ); + + controller.startMarketDataPreload(); + await jest.advanceTimersByTimeAsync(100); + + expect( + mockMarketDataServiceInstance.getMarketDataWithPrices, + ).toHaveBeenCalledTimes(1); + }); + + it('does not seed memory or disk when snapshot context changes during preload', async () => { + let resolveSnapshot: + | (( + value: Awaited< + ReturnType + >, + ) => void) + | undefined; + mockMarketDataServiceInstance.getMarketDataWithPrices.mockImplementation( + () => + new Promise((resolve) => { + resolveSnapshot = resolve; + }), + ); + markControllerAsInitialized(); + controller.testSetProviders(new Map([['hyperliquid', mockProvider]])); + + controller.startMarketDataPreload(); + await Promise.resolve(); + controller.testUpdate((state) => { + state.isTestnet = true; + state.hip3ConfigVersion += 1; + }); + resolveSnapshot?.([ + { + symbol: 'BTC', + name: 'Bitcoin', + price: '$50000.00', + maxLeverage: '50x', + change24h: '+$125.00', + change24hPercent: '0.25%', + volume: '$1000000', + dataSource: 'terminal-global-snapshot-mark', + }, + ]); + await jest.advanceTimersByTimeAsync(100); + + expect( + controller.state.cachedMarketDataByProvider['hyperliquid:mainnet'], + ).toBeUndefined(); + expect( + controller.state.cachedMarketDataByProvider['hyperliquid:testnet'], + ).toBeUndefined(); + expect(mockInfrastructure.diskCache.setItem).not.toHaveBeenCalled(); + }); + + it('does not seed a provider fallback when context changes during preload', async () => { + let resolveProvider: + | (( + value: Awaited< + ReturnType + >, + ) => void) + | undefined; + mockMarketDataServiceInstance.getMarketDataWithPrices.mockImplementation( + () => + new Promise((resolve) => { + resolveProvider = resolve; + }), + ); + markControllerAsInitialized(); + controller.testSetProviders(new Map([['hyperliquid', mockProvider]])); + + controller.startMarketDataPreload(); + await Promise.resolve(); + controller.testUpdate((state) => { + state.hip3ConfigVersion += 1; + }); + resolveProvider?.([ + { + symbol: 'BTC', + name: 'Bitcoin', + price: '$50000.00', + maxLeverage: '50x', + change24h: '+$125.00', + change24hPercent: '0.25%', + volume: '$1000000', + }, + ]); + await jest.advanceTimersByTimeAsync(100); + + expect( + controller.state.cachedMarketDataByProvider['hyperliquid:mainnet'], + ).toBeUndefined(); + expect(mockInfrastructure.diskCache.setItem).not.toHaveBeenCalled(); + }); + + it('runs the latest network preload after an older request completes', async () => { + let resolveMainnet!: (value: PerpsMarketData[]) => void; + mockMarketDataServiceInstance.getMarketDataWithPrices + .mockImplementationOnce( + () => + new Promise((resolve) => { + resolveMainnet = resolve; + }), + ) + .mockResolvedValueOnce([ + { + symbol: 'BTC', + name: 'Bitcoin', + price: '$50000', + }, + ]); + markControllerAsInitialized(); + controller.testSetProviders(new Map([['hyperliquid', mockProvider]])); + controller.startMarketDataPreload(); + await Promise.resolve(); + + controller.testUpdate((state) => { + state.isTestnet = true; + }); + const stateChangedHandler = mockMessenger.subscribe.mock.calls.find( + ([event]) => event === 'PerpsController:stateChanged', + )?.[1]; + stateChangedHandler?.(controller.state, [ + { op: 'replace', path: ['isTestnet'], value: true }, + ]); + resolveMainnet([]); + await jest.advanceTimersByTimeAsync(100); + + expect( + mockMarketDataServiceInstance.getMarketDataWithPrices, + ).toHaveBeenCalledTimes(2); + expect( + controller.state.cachedMarketDataByProvider['hyperliquid:testnet'] + ?.data[0].symbol, + ).toBe('BTC'); + }); + + it('persists preloaded market data to disk', async () => { + const mockData = [ + { + symbol: 'BTC', + name: 'BTC', + price: '50000', + maxLeverage: '50x', + change24h: '+100', + change24hPercent: '+0.2%', + volume: '$1B', + }, + ]; + markControllerAsInitialized(); + controller.testSetProviders(new Map([['hyperliquid', mockProvider]])); + mockProvider.getMarketDataWithPrices.mockResolvedValue(mockData); + + controller.startMarketDataPreload(); + await jest.advanceTimersByTimeAsync(100); + + expect(mockInfrastructure.diskCache.setItem).toHaveBeenCalledWith( + PERPS_DISK_CACHE_MARKETS, + expect.any(String), + ); + + const persistedPayload = JSON.parse( + (mockInfrastructure.diskCache.setItem as jest.Mock).mock.calls.find( + ([key]) => key === PERPS_DISK_CACHE_MARKETS, + )?.[1] as string, + ); + + expect(persistedPayload.providerNetworkKey).toBe('hyperliquid:mainnet'); + expect(persistedPayload.data).toEqual(mockData); + expect(persistedPayload.timestamp).toBeGreaterThan(0); + }); + + it('respects 30s debounce guard', async () => { + const mockData = [ + { + symbol: 'BTC', + name: 'BTC', + price: '50000', + maxLeverage: '50x', + change24h: '+100', + change24hPercent: '+0.2%', + volume: '$1B', + }, + ]; + markControllerAsInitialized(); + controller.testSetProviders(new Map([['hyperliquid', mockProvider]])); + mockProvider.getMarketDataWithPrices.mockResolvedValue(mockData); + + // First preload + controller.startMarketDataPreload(); + await jest.advanceTimersByTimeAsync(100); + + const callCount = mockProvider.getMarketDataWithPrices.mock.calls.length; + + // Advance by less than 30s and trigger interval + controller.stopMarketDataPreload(); + // Set timestamp to recent to trigger debounce guard + controller.testUpdate((state) => { + state.cachedMarketDataByProvider['hyperliquid:mainnet'] = { + data: mockData, + timestamp: Date.now(), + }; + }); + controller.startMarketDataPreload(); + await jest.advanceTimersByTimeAsync(100); + + // Should not have called again due to debounce + // The second immediate call is debounced + expect(mockProvider.getMarketDataWithPrices.mock.calls.length).toBe( + callCount, + ); + }); + + it('handles errors without throwing', async () => { + markControllerAsInitialized(); + controller.testSetProviders(new Map([['hyperliquid', mockProvider]])); + mockProvider.getMarketDataWithPrices.mockRejectedValue( + new Error('API failed'), + ); + + controller.startMarketDataPreload(); + await jest.advanceTimersByTimeAsync(100); + + // Should log error but not throw + expect(mockInfrastructure.logger.error).toHaveBeenCalledWith( + expect.any(Error), + expect.objectContaining({ + context: expect.objectContaining({ + data: expect.objectContaining({ + method: 'performMarketDataPreload', + }), + }), + }), + ); + }); + + it('traces performance via tracer', async () => { + const mockData = [ + { + symbol: 'BTC', + name: 'BTC', + price: '50000', + maxLeverage: '50x', + change24h: '+100', + change24hPercent: '+0.2%', + volume: '$1B', + }, + ]; + markControllerAsInitialized(); + controller.testSetProviders(new Map([['hyperliquid', mockProvider]])); + mockProvider.getMarketDataWithPrices.mockResolvedValue(mockData); + + controller.startMarketDataPreload(); + await jest.advanceTimersByTimeAsync(100); + + expect(mockInfrastructure.tracer.trace).toHaveBeenCalled(); + expect(mockInfrastructure.tracer.endTrace).toHaveBeenCalled(); + expect(mockInfrastructure.tracer.setMeasurement).toHaveBeenCalled(); + const traceId = mockInfrastructure.tracer.trace.mock.calls[0][0].id; + expect(mockInfrastructure.tracer.setMeasurement).toHaveBeenCalledWith( + expect.any(String), + expect.any(Number), + 'millisecond', + traceId, + ); + }); + }); + + describe('performUserDataPreload', () => { + // Import actual enum for type compatibility + const { WebSocketConnectionState: WSState } = + jest.requireActual('../../src/types'); + const mockEvmAccount = { + address: '0x1234567890123456789012345678901234567890', + type: 'eip155:eoa', + id: 'account-1', + options: {}, + scopes: ['eip155:1'], + methods: [], + metadata: { + name: 'Test', + importTime: 0, + keyring: { type: 'HD Key Tree' }, + }, + }; + + let preloadController: TestablePerpsController; + let preloadMockProvider: jest.Mocked; + let preloadInfrastructure: jest.Mocked; + let preloadMessenger: ReturnType; + + const createUserSnapshot = (): PerpsUserDataSnapshot => ({ + positions: [createMockPosition()], + orders: [], + accountState: { + totalBalance: '10000', + spendableBalance: '10000', + withdrawableBalance: '10000', + marginUsed: '0', + unrealizedPnl: '0', + returnOnEquity: '0', + }, + identity: { + provider: 'hyperliquid', + network: 'mainnet', + address: mockEvmAccount.address, + hip3ConfigVersion: 0, + dexes: ['main'], + }, + }); + + const createDeferredSnapshot = () => { + let resolve!: (value: PerpsUserDataSnapshot) => void; + const promise = new Promise((promiseResolve) => { + resolve = promiseResolve; + }); + + return { promise, resolve }; + }; + + beforeEach(() => { + jest.useFakeTimers(); + // Create controller with messenger that handles account queries + const mockCall = jest.fn().mockImplementation((action: string) => { + if (action === 'RemoteFeatureFlagController:getState') { + return { + remoteFeatureFlags: { + perpsPerpTradingGeoBlockedCountriesV2: { blockedRegions: [] }, + }, + }; + } + if ( + action === 'AccountTreeController:getAccountsFromSelectedAccountGroup' + ) { + return [mockEvmAccount]; + } + return undefined; + }); + preloadInfrastructure = createMockInfrastructure(); + preloadMockProvider = createMockHyperLiquidProvider(); + preloadMockProvider.getPositions.mockResolvedValue([]); + preloadMockProvider.getAccountState.mockResolvedValue({ + spendableBalance: '10000', + withdrawableBalance: '10000', + totalBalance: '10000', + marginUsed: '0', + unrealizedPnl: '0', + returnOnEquity: '0', + }); + preloadMockProvider.getMarkets.mockResolvedValue([]); + preloadMockProvider.getOpenOrders.mockResolvedValue([]); + preloadMockProvider.getUserDataSnapshot = jest + .fn() + .mockImplementation(async ({ userAddress, identity }) => { + const [positions, orders, accountState] = await Promise.all([ + preloadMockProvider.getPositions({ + standalone: true, + userAddress, + }), + preloadMockProvider.getOpenOrders({ + standalone: true, + userAddress, + }), + preloadMockProvider.getAccountState({ + standalone: true, + userAddress, + }), + ]); + + return { + positions, + orders, + accountState, + identity: { + ...identity, + address: userAddress, + dexes: ['main'], + }, + }; + }); + ( + HyperLiquidProvider as jest.MockedClass + ).mockImplementation(() => preloadMockProvider); + preloadMessenger = createMockMessenger({ call: mockCall }); + preloadController = new TestablePerpsController({ + messenger: preloadMessenger, + state: getDefaultPerpsControllerState(), + infrastructure: preloadInfrastructure, + }); + }); + + afterEach(() => { + mockEvmAccount.address = '0x1234567890123456789012345678901234567890'; + preloadController.stopMarketDataPreload(); + jest.useRealTimers(); + }); + + it('returns and atomically persists a provider user snapshot', async () => { + const snapshot = createUserSnapshot(); + preloadMockProvider.getUserDataSnapshot = jest + .fn() + .mockResolvedValue(snapshot); + preloadController.testMarkInitialized(); + preloadController.testSetProviders( + new Map([['hyperliquid', preloadMockProvider]]), + ); + + const result = await preloadController.getUserDataSnapshot(); + + expect(result).toEqual(snapshot); + expect( + preloadController.state.cachedUserDataByProvider['hyperliquid:mainnet'], + ).toEqual( + expect.objectContaining({ + positions: snapshot.positions, + orders: snapshot.orders, + accountState: snapshot.accountState, + address: mockEvmAccount.address, + }), + ); + expect(preloadInfrastructure.diskCache.setItem).toHaveBeenCalledTimes(1); + }); + + it('keeps the returned user snapshot mutable without mutating the cache', async () => { + const snapshot = createUserSnapshot(); + snapshot.accountState.subAccountBreakdown = { + main: { + spendableBalance: '10', + withdrawableBalance: '10', + totalBalance: '10', + }, + }; + preloadMockProvider.getUserDataSnapshot = jest + .fn() + .mockResolvedValue(snapshot); + preloadController.testMarkInitialized(); + preloadController.testSetProviders( + new Map([['hyperliquid', preloadMockProvider]]), + ); + + const result = await preloadController.getUserDataSnapshot(); + result.positions[0].leverage.value = 99; + const breakdown = result.accountState.subAccountBreakdown; + if (!breakdown) { + throw new Error('Expected sub-account breakdown'); + } + breakdown.main.totalBalance = '99'; + + const cached = + preloadController.state.cachedUserDataByProvider['hyperliquid:mainnet']; + expect(cached.positions[0].leverage.value).not.toBe(99); + expect(cached.accountState?.subAccountBreakdown?.main.totalBalance).toBe( + '10', + ); + }); + + it('fetches through the standalone provider before an active instance exists', async () => { + const snapshot = createUserSnapshot(); + preloadMockProvider.getUserDataSnapshot = jest + .fn() + .mockResolvedValue(snapshot); + + const result = await preloadController.getUserDataSnapshot(); + + expect(result).toEqual(snapshot); + expect(preloadMockProvider.getUserDataSnapshot).toHaveBeenCalledWith({ + userAddress: mockEvmAccount.address, + identity: { + provider: 'hyperliquid', + network: 'mainnet', + hip3ConfigVersion: 0, + dexes: ['main'], + }, + }); + }); + + it('rejects a snapshot whose DEX identity differs from captured configuration', async () => { + const snapshot = createUserSnapshot(); + snapshot.identity.dexes = ['main', 'xyz']; + preloadMockProvider.getUserDataSnapshot = jest + .fn() + .mockResolvedValue(snapshot); + preloadController.testMarkInitialized(); + preloadController.testSetProviders( + new Map([['hyperliquid', preloadMockProvider]]), + ); + + await expect(preloadController.getUserDataSnapshot()).rejects.toThrow( + 'mismatched', + ); + + expect(preloadController.state.cachedUserDataByProvider).toEqual({}); + expect(preloadInfrastructure.diskCache.setItem).not.toHaveBeenCalled(); + }); + + it('coalesces concurrent requests with the same captured identity', async () => { + const deferred = createDeferredSnapshot(); + preloadMockProvider.getUserDataSnapshot = jest + .fn() + .mockReturnValue(deferred.promise); + preloadController.testMarkInitialized(); + preloadController.testSetProviders( + new Map([['hyperliquid', preloadMockProvider]]), + ); + + const firstRequest = preloadController.getUserDataSnapshot(); + const secondRequest = preloadController.getUserDataSnapshot(); + deferred.resolve(createUserSnapshot()); + + await expect(Promise.all([firstRequest, secondRequest])).resolves.toEqual( + [createUserSnapshot(), createUserSnapshot()], + ); + expect(preloadMockProvider.getUserDataSnapshot).toHaveBeenCalledTimes(1); + expect(preloadInfrastructure.diskCache.setItem).toHaveBeenCalledTimes(1); + }); + + it('preloads a bare DEX allowlist through the atomic snapshot path', async () => { + preloadController = new TestablePerpsController({ + messenger: preloadMessenger, + state: getDefaultPerpsControllerState(), + clientConfig: { + fallbackHip3Enabled: true, + fallbackHip3AllowlistMarkets: ['xyz'], + }, + infrastructure: preloadInfrastructure, + }); + preloadMockProvider.getUserDataSnapshot = jest + .fn() + .mockImplementation(async ({ userAddress, identity }) => ({ + ...createUserSnapshot(), + identity: { + ...identity, + address: userAddress, + dexes: ['main', 'xyz'], + }, + })); + preloadMockProvider.getMarketDataWithPrices.mockResolvedValue([]); + preloadMockProvider.getWebSocketConnectionState.mockReturnValue( + WSState.Disconnected, + ); + preloadController.testMarkInitialized(); + preloadController.testSetProviders( + new Map([['hyperliquid', preloadMockProvider]]), + ); + + preloadController.startMarketDataPreload(); + await jest.advanceTimersByTimeAsync(100); + + expect(preloadMockProvider.getUserDataSnapshot).toHaveBeenCalledWith( + expect.objectContaining({ + identity: expect.objectContaining({ dexes: ['main', 'xyz'] }), + }), + ); + expect( + preloadController.state.cachedUserDataByProvider['hyperliquid:mainnet'] + ?.dexes, + ).toStrictEqual(['main', 'xyz']); + }); + + it('does not coalesce requests across provider instances', async () => { + const deferred = createDeferredSnapshot(); + preloadMockProvider.getUserDataSnapshot = jest + .fn() + .mockReturnValue(deferred.promise); + preloadController.testMarkInitialized(); + preloadController.testSetProviders( + new Map([['hyperliquid', preloadMockProvider]]), + ); + + const firstRequest = preloadController.getUserDataSnapshot(); + const replacementProvider = createMockHyperLiquidProvider(); + replacementProvider.getUserDataSnapshot = jest + .fn() + .mockResolvedValue(createUserSnapshot()); + preloadController.testSetProviders( + new Map([['hyperliquid', replacementProvider]]), + ); + + await expect(preloadController.getUserDataSnapshot()).resolves.toEqual( + createUserSnapshot(), + ); + deferred.resolve(createUserSnapshot()); + await expect(firstRequest).rejects.toThrow('context changed'); + + expect(preloadMockProvider.getUserDataSnapshot).toHaveBeenCalledTimes(1); + expect(replacementProvider.getUserDataSnapshot).toHaveBeenCalledTimes(1); + }); + + it('serializes disk writes so an older account cannot overwrite a newer one', async () => { + let resolveFirstWrite!: () => void; + const firstWrite = new Promise((resolve) => { + resolveFirstWrite = resolve; + }); + preloadInfrastructure.diskCache.setItem + .mockReturnValueOnce(firstWrite) + .mockResolvedValue(undefined); + preloadMockProvider.getUserDataSnapshot = jest + .fn() + .mockImplementation(async () => createUserSnapshot()); + preloadController.testMarkInitialized(); + preloadController.testSetProviders( + new Map([['hyperliquid', preloadMockProvider]]), + ); + + await preloadController.getUserDataSnapshot(); + mockEvmAccount.address = '0x9999999999999999999999999999999999999999'; + await preloadController.getUserDataSnapshot(); + expect(preloadInfrastructure.diskCache.setItem).toHaveBeenCalledTimes(1); + + resolveFirstWrite(); + await jest.advanceTimersByTimeAsync(0); + + expect(preloadInfrastructure.diskCache.setItem).toHaveBeenCalledTimes(2); + const lastPayload = JSON.parse( + preloadInfrastructure.diskCache.setItem.mock.calls[1][1] as string, + ) as { address: string }; + expect(lastPayload.address).toBe(mockEvmAccount.address); + }); + + it('refreshes user data while WebSocket is connected independently of market preload', async () => { + const snapshot = createUserSnapshot(); + preloadMockProvider.getUserDataSnapshot = jest + .fn() + .mockResolvedValue(snapshot); + preloadMockProvider.getMarketDataWithPrices.mockReturnValue( + new Promise(() => undefined), + ); + preloadMockProvider.getWebSocketConnectionState.mockReturnValue( + WSState.Connected, + ); + preloadController.testMarkInitialized(); + preloadController.testSetProviders( + new Map([['hyperliquid', preloadMockProvider]]), + ); + + preloadController.startMarketDataPreload(); + await jest.advanceTimersByTimeAsync(100); + + expect(preloadMockProvider.getUserDataSnapshot).toHaveBeenCalledTimes(1); + expect( + preloadController.state.cachedUserDataByProvider['hyperliquid:mainnet'] + ?.positions, + ).toEqual(snapshot.positions); + }); + + it('queues the selected-account refresh when an older preload is in flight', async () => { + const firstSnapshot = createUserSnapshot(); + const firstRequest = createDeferredSnapshot(); + const secondAddress = '0x9999999999999999999999999999999999999999'; + preloadMockProvider.getUserDataSnapshot = jest + .fn() + .mockReturnValueOnce(firstRequest.promise) + .mockImplementationOnce(async () => createUserSnapshot()); + preloadMockProvider.getMarketDataWithPrices.mockResolvedValue([]); + preloadMockProvider.getWebSocketConnectionState.mockReturnValue( + WSState.Disconnected, + ); + preloadController.testMarkInitialized(); + preloadController.testSetProviders( + new Map([['hyperliquid', preloadMockProvider]]), + ); + + preloadController.startMarketDataPreload(); + await Promise.resolve(); + const accountChangeHandler = preloadMessenger.subscribe.mock.calls.find( + ([event]) => event === 'AccountsController:selectedAccountChange', + )?.[1] as (() => void) | undefined; + mockEvmAccount.address = secondAddress; + accountChangeHandler?.(); + firstRequest.resolve(firstSnapshot); + await jest.advanceTimersByTimeAsync(100); + + expect(preloadMockProvider.getUserDataSnapshot).toHaveBeenCalledTimes(2); + expect( + preloadController.state.cachedUserDataByProvider['hyperliquid:mainnet'], + ).toEqual(expect.objectContaining({ address: secondAddress })); + }); + + it('does not poll user REST data when WebSocket and a matching cache are available', async () => { + preloadMockProvider.getUserDataSnapshot = jest.fn(); + preloadMockProvider.getMarketDataWithPrices.mockResolvedValue([]); + preloadMockProvider.getWebSocketConnectionState.mockReturnValue( + WSState.Connected, + ); + preloadController.testMarkInitialized(); + preloadController.testSetProviders( + new Map([['hyperliquid', preloadMockProvider]]), + ); + preloadController.testUpdate((state) => { + state.cachedUserDataByProvider['hyperliquid:mainnet'] = { + positions: [], + orders: [], + accountState: createUserSnapshot().accountState, + timestamp: 1, + address: mockEvmAccount.address, + hip3ConfigVersion: 0, + dexes: ['main'], + }; + }); + + preloadController.startMarketDataPreload(); + await jest.advanceTimersByTimeAsync(100); + + expect(preloadMockProvider.getUserDataSnapshot).not.toHaveBeenCalled(); + }); + + it('refreshes once after HIP-3 identity changes while WebSocket is connected', async () => { + preloadMockProvider.getUserDataSnapshot = jest + .fn() + .mockImplementation(async ({ userAddress, identity }) => ({ + ...createUserSnapshot(), + identity: { + ...identity, + address: userAddress, + dexes: ['main'], + }, + })); + preloadMockProvider.getMarketDataWithPrices.mockResolvedValue([]); + preloadMockProvider.getWebSocketConnectionState.mockReturnValue( + WSState.Connected, + ); + preloadController.testMarkInitialized(); + preloadController.testSetProviders( + new Map([['hyperliquid', preloadMockProvider]]), + ); + preloadController.testUpdate((state) => { + state.cachedUserDataByProvider['hyperliquid:mainnet'] = { + positions: [], + orders: [], + accountState: createUserSnapshot().accountState, + timestamp: Date.now(), + address: mockEvmAccount.address, + hip3ConfigVersion: 0, + dexes: ['main'], + }; + }); + preloadController.startMarketDataPreload(); + await jest.advanceTimersByTimeAsync(100); + preloadMockProvider.getUserDataSnapshot.mockClear(); + + preloadController.testUpdate((state) => { + state.hip3ConfigVersion = 1; + }); + await jest.advanceTimersByTimeAsync(100); + await jest.advanceTimersByTimeAsync(300_000); + + expect(preloadMockProvider.getUserDataSnapshot).toHaveBeenCalledTimes(1); + expect( + preloadController.state.cachedUserDataByProvider['hyperliquid:mainnet'], + ).toEqual( + expect.objectContaining({ + hip3ConfigVersion: 1, + dexes: ['main'], + }), + ); + }); + + it('starts user preload after network reinitialization completes', async () => { + preloadMockProvider.getMarketDataWithPrices.mockResolvedValue([]); + preloadMockProvider.getWebSocketConnectionState.mockReturnValue( + WSState.Disconnected, + ); + preloadController.testMarkInitialized(); + preloadController.testSetProviders( + new Map([['hyperliquid', preloadMockProvider]]), + ); + preloadController.startMarketDataPreload(); + await jest.advanceTimersByTimeAsync(100); + preloadMockProvider.getUserDataSnapshot.mockClear(); + jest.spyOn(preloadController, 'init').mockImplementationOnce(async () => { + preloadController.testUpdate((state) => { + state.initializationState = InitializationState.Initialized; + }); + }); + + await preloadController.toggleTestnet(); + await jest.advanceTimersByTimeAsync(100); + + expect(preloadMockProvider.getUserDataSnapshot).toHaveBeenCalledTimes(1); + expect(preloadMockProvider.getUserDataSnapshot).toHaveBeenCalledWith( + expect.objectContaining({ + identity: expect.objectContaining({ network: 'testnet' }), + }), + ); + }); + + it('does not put userAddress on user-preload trace data and targets the named trace id', async () => { + preloadMockProvider.getMarketDataWithPrices.mockResolvedValue([]); + preloadMockProvider.getWebSocketConnectionState.mockReturnValue( + WSState.Disconnected, + ); + preloadController.testMarkInitialized(); + preloadController.testSetProviders( + new Map([['hyperliquid', preloadMockProvider]]), + ); + + preloadController.startMarketDataPreload(); + await jest.advanceTimersByTimeAsync(100); + + const userPreloadTrace = preloadInfrastructure.tracer.trace.mock.calls + .map((call) => call[0]) + .find((params) => params.name === 'Perps User Data Preload'); + expect(userPreloadTrace).toBeDefined(); + expect(userPreloadTrace?.data).toBeUndefined(); + expect(JSON.stringify(userPreloadTrace)).not.toContain( + mockEvmAccount.address, + ); + expect(preloadInfrastructure.tracer.setMeasurement).toHaveBeenCalledWith( + expect.any(String), + expect.any(Number), + 'millisecond', + userPreloadTrace?.id, + ); + }); + + it.each([ + [ + 'provider', + () => + preloadController.testUpdate((state) => { + state.activeProvider = 'myx'; + }), + ], + [ + 'network', + () => + preloadController.testUpdate((state) => { + state.isTestnet = true; + }), + ], + [ + 'HIP-3 configuration', + () => + preloadController.testUpdate((state) => { + state.hip3ConfigVersion += 1; + }), + ], + [ + 'selected address', + () => { + mockEvmAccount.address = '0x9999999999999999999999999999999999999999'; + }, + ], + ])('discards a user snapshot after a %s change', async (_label, mutate) => { + const deferred = createDeferredSnapshot(); + preloadMockProvider.getUserDataSnapshot = jest + .fn() + .mockReturnValue(deferred.promise); + preloadController.testMarkInitialized(); + preloadController.testSetProviders( + new Map([['hyperliquid', preloadMockProvider]]), + ); + + const request = preloadController.getUserDataSnapshot(); + await Promise.resolve(); + mutate(); + deferred.resolve(createUserSnapshot()); + + await expect(request).rejects.toThrow('context changed'); + expect(preloadController.state.cachedUserDataByProvider).toEqual({}); + expect(preloadInfrastructure.diskCache.setItem).not.toHaveBeenCalled(); + }); + + it('discards an in-flight user snapshot after disconnect', async () => { + const deferred = createDeferredSnapshot(); + preloadMockProvider.getUserDataSnapshot = jest + .fn() + .mockReturnValue(deferred.promise); + preloadController.testMarkInitialized(); + preloadController.testSetProviders( + new Map([['hyperliquid', preloadMockProvider]]), + ); + + const request = preloadController.getUserDataSnapshot(); + await Promise.resolve(); + await preloadController.disconnect(); + deferred.resolve(createUserSnapshot()); + + await expect(request).rejects.toThrow('context changed'); + expect(preloadController.state.cachedUserDataByProvider).toEqual({}); + expect(preloadInfrastructure.diskCache.setItem).not.toHaveBeenCalled(); + }); + + it('does not report an expected background preload invalidation as an error', async () => { + const deferred = createDeferredSnapshot(); + preloadMockProvider.getUserDataSnapshot = jest + .fn() + .mockReturnValue(deferred.promise); + preloadMockProvider.getMarketDataWithPrices.mockResolvedValue([]); + preloadMockProvider.getWebSocketConnectionState.mockReturnValue( + WSState.Disconnected, + ); + preloadController.testMarkInitialized(); + preloadController.testSetProviders( + new Map([['hyperliquid', preloadMockProvider]]), + ); + + preloadController.startMarketDataPreload(); + await jest.advanceTimersByTimeAsync(100); + expect(preloadMockProvider.getUserDataSnapshot).toHaveBeenCalledTimes(1); + + await preloadController.disconnect(); + deferred.resolve(createUserSnapshot()); + await jest.advanceTimersByTimeAsync(0); + + expect(preloadController.state.cachedUserDataByProvider).toEqual({}); + expect(preloadInfrastructure.logger.error).not.toHaveBeenCalled(); + }); + + it('preserves last-known-good data when a snapshot request fails', async () => { + const lastKnownGood = { + positions: [createMockPosition({ symbol: 'ETH' })], + orders: [], + accountState: null, + timestamp: 1, + address: mockEvmAccount.address, + }; + preloadMockProvider.getUserDataSnapshot = jest + .fn() + .mockRejectedValue(new Error('partial snapshot')); + preloadController.testMarkInitialized(); + preloadController.testSetProviders( + new Map([['hyperliquid', preloadMockProvider]]), + ); + preloadController.testUpdate((state) => { + state.cachedUserDataByProvider['hyperliquid:mainnet'] = lastKnownGood; + }); + + await expect(preloadController.getUserDataSnapshot()).rejects.toThrow( + 'partial snapshot', + ); + + expect( + preloadController.state.cachedUserDataByProvider['hyperliquid:mainnet'], + ).toEqual(lastKnownGood); + expect(preloadInfrastructure.diskCache.setItem).not.toHaveBeenCalled(); + }); + + it('fails closed when the provider has no atomic snapshot API', async () => { + preloadMockProvider.getUserDataSnapshot = undefined; + preloadController.testMarkInitialized(); + preloadController.testSetProviders( + new Map([['hyperliquid', preloadMockProvider]]), + ); + + await expect(preloadController.getUserDataSnapshot()).rejects.toThrow( + 'atomic snapshot API', + ); + + expect(preloadMockProvider.getPositions).not.toHaveBeenCalled(); + expect(preloadMockProvider.getOpenOrders).not.toHaveBeenCalled(); + expect(preloadMockProvider.getAccountState).not.toHaveBeenCalled(); + }); + + it('fetches positions, orders, and account state', async () => { + const mockPositions = [createMockPosition()]; + const mockOrders = [ + { + orderId: 'o1', + symbol: 'BTC', + side: 'buy' as const, + orderType: 'limit' as const, + size: '0.1', + originalSize: '0.1', + filledSize: '0', + remainingSize: '0.1', + price: '50000', + status: 'open' as const, + timestamp: Date.now(), + }, + ]; + const mockAccountState: AccountState = { + totalBalance: '50000', + spendableBalance: '45000', + withdrawableBalance: '45000', + marginUsed: '5000', + unrealizedPnl: '1000', + returnOnEquity: '20', + }; + + preloadController.testMarkInitialized(); + preloadController.testSetProviders( + new Map([['hyperliquid', preloadMockProvider]]), + ); + preloadMockProvider.getPositions.mockResolvedValue(mockPositions); + preloadMockProvider.getOpenOrders.mockResolvedValue(mockOrders); + preloadMockProvider.getAccountState.mockResolvedValue(mockAccountState); + preloadMockProvider.getMarketDataWithPrices.mockResolvedValue([ + { + symbol: 'BTC', + name: 'BTC', + price: '50000', + maxLeverage: '50x', + change24h: '+100', + change24hPercent: '+0.2%', + volume: '$1B', + }, + ]); + preloadMockProvider.getWebSocketConnectionState.mockReturnValue( + WSState.Disconnected, + ); + + preloadController.startMarketDataPreload(); + await jest.advanceTimersByTimeAsync(500); + + const userCache = preloadController.state.cachedUserDataByProvider; + const cacheKey = Object.keys(userCache)[0]; + expect(cacheKey).toBeDefined(); + const entry = userCache[cacheKey]; + expect(entry.positions).toEqual(mockPositions); + expect(entry.orders).toEqual(mockOrders); + expect(entry.accountState).toEqual(mockAccountState); + expect(entry.timestamp).toBeGreaterThan(0); + }); + + it('keeps aggregated mode on the legacy provider path with trusted identity', async () => { + preloadController.testMarkInitialized(); + preloadController.testSetProviders( + new Map([['hyperliquid', preloadMockProvider]]), + ); + preloadController.testUpdate((state) => { + state.activeProvider = 'aggregated'; + }); + preloadMockProvider.getMarketDataWithPrices.mockResolvedValue([]); + preloadMockProvider.getWebSocketConnectionState.mockReturnValue( + WSState.Disconnected, + ); + + preloadController.startMarketDataPreload(); + await jest.advanceTimersByTimeAsync(500); + + expect(preloadMockProvider.getUserDataSnapshot).not.toHaveBeenCalled(); + expect(preloadMockProvider.getPositions).toHaveBeenCalledTimes(1); + expect( + preloadController.state.cachedUserDataByProvider['hyperliquid:mainnet'], + ).toEqual( + expect.objectContaining({ + hip3ConfigVersion: 0, + dexes: ['main'], + }), + ); + expect( + preloadController.getCachedUserDataForActiveProvider({ + skipTTL: true, + }), + ).not.toBeNull(); + }); + + it('stamps Hyperliquid identity for aggregated preload before initialization', async () => { + preloadController.testUpdate((state) => { + state.activeProvider = 'aggregated'; + }); + preloadMockProvider.getMarketDataWithPrices.mockResolvedValue([]); + preloadMockProvider.getWebSocketConnectionState.mockReturnValue( + WSState.Disconnected, + ); + + preloadController.startMarketDataPreload(); + await jest.advanceTimersByTimeAsync(500); + + expect( + preloadController.state.cachedUserDataByProvider['hyperliquid:mainnet'], + ).toEqual( + expect.objectContaining({ + hip3ConfigVersion: 0, + dexes: ['main'], + }), + ); + }); + + it('discards an aggregated preload after HIP-3 context changes', async () => { + let resolveOldPositions!: (value: Position[]) => void; + preloadMockProvider.getPositions + .mockReturnValueOnce( + new Promise((resolve) => { + resolveOldPositions = resolve; + }), + ) + .mockResolvedValueOnce([ + createMockPosition({ + symbol: 'NEW', + providerId: 'hyperliquid', + }), + ]); + preloadMockProvider.getMarketDataWithPrices.mockResolvedValue([]); + preloadMockProvider.getWebSocketConnectionState.mockReturnValue( + WSState.Disconnected, + ); + preloadController.testMarkInitialized(); + preloadController.testSetProviders( + new Map([['hyperliquid', preloadMockProvider]]), + ); + preloadController.testUpdate((state) => { + state.activeProvider = 'aggregated'; + }); + + preloadController.startMarketDataPreload(); + await Promise.resolve(); + preloadController.testUpdate((state) => { + state.hip3ConfigVersion = 1; + }); + const stateChangeHandler = preloadMessenger.subscribe.mock.calls.find( + ([event]) => event === 'PerpsController:stateChanged', + )?.[1] as + | (( + state: PerpsControllerState, + patches: { path: (string | number)[] }[], + ) => void) + | undefined; + stateChangeHandler?.(preloadController.state, [ + { path: ['hip3ConfigVersion'] }, + ]); + resolveOldPositions([ + createMockPosition({ + symbol: 'OLD', + providerId: 'hyperliquid', + }), + ]); + await jest.advanceTimersByTimeAsync(500); + + expect(preloadMockProvider.getPositions).toHaveBeenCalledTimes(2); + expect( + preloadController.state.cachedUserDataByProvider['hyperliquid:mainnet'], + ).toEqual( + expect.objectContaining({ + positions: [expect.objectContaining({ symbol: 'NEW' })], + hip3ConfigVersion: 1, + dexes: ['main'], + }), + ); + }); + + it('persists preloaded user data to disk', async () => { + const mockPositions = [createMockPosition()]; + const mockOrders = [ + { + orderId: 'o1', + symbol: 'BTC', + side: 'buy' as const, + orderType: 'limit' as const, + size: '0.1', + originalSize: '0.1', + filledSize: '0', + remainingSize: '0.1', + price: '50000', + status: 'open' as const, + timestamp: Date.now(), + }, + ]; + const mockAccountState: AccountState = { + totalBalance: '50000', + spendableBalance: '45000', + withdrawableBalance: '45000', + marginUsed: '5000', + unrealizedPnl: '1000', + returnOnEquity: '20', + }; + + preloadController.testMarkInitialized(); + preloadController.testSetProviders( + new Map([['hyperliquid', preloadMockProvider]]), + ); + preloadMockProvider.getPositions.mockResolvedValue(mockPositions); + preloadMockProvider.getOpenOrders.mockResolvedValue(mockOrders); + preloadMockProvider.getAccountState.mockResolvedValue(mockAccountState); + preloadMockProvider.getMarketDataWithPrices.mockResolvedValue([ + { + symbol: 'BTC', + name: 'BTC', + price: '50000', + maxLeverage: '50x', + change24h: '+100', + change24hPercent: '+0.2%', + volume: '$1B', + }, + ]); + preloadMockProvider.getWebSocketConnectionState.mockReturnValue( + WSState.Disconnected, + ); + + preloadController.startMarketDataPreload(); + await jest.advanceTimersByTimeAsync(500); + + expect(preloadInfrastructure.diskCache.setItem).toHaveBeenCalledWith( + PERPS_DISK_CACHE_USER_DATA, + expect.any(String), + ); + + const persistedPayload = JSON.parse( + (preloadInfrastructure.diskCache.setItem as jest.Mock).mock.calls.find( + ([key]) => key === PERPS_DISK_CACHE_USER_DATA, + )?.[1] as string, + ); + + expect(persistedPayload.providerNetworkKey).toBe('hyperliquid:mainnet'); + expect(persistedPayload.address).toBe(mockEvmAccount.address); + expect(persistedPayload.positions).toEqual(mockPositions); + expect(persistedPayload.orders).toEqual(mockOrders); + expect(persistedPayload.accountState).toEqual(mockAccountState); + expect(persistedPayload.timestamp).toBeGreaterThan(0); + }); + + it('replaces the provider cache when the selected account changes', async () => { + const firstAddress = mockEvmAccount.address; + const secondAddress = '0x9999999999999999999999999999999999999999'; + preloadController.testMarkInitialized(); + preloadController.testSetProviders( + new Map([['hyperliquid', preloadMockProvider]]), + ); + preloadMockProvider.getMarketDataWithPrices.mockResolvedValue([]); + preloadMockProvider.getWebSocketConnectionState.mockReturnValue( + WSState.Disconnected, + ); + preloadMockProvider.getPositions.mockImplementation( + async ({ userAddress }) => [ + createMockPosition({ + symbol: + userAddress.toLowerCase() === firstAddress.toLowerCase() + ? 'BTC' + : 'ETH', + }), + ], + ); + + preloadController.startMarketDataPreload(); + await jest.advanceTimersByTimeAsync(500); + + const accountChangeHandler = preloadMessenger.subscribe.mock.calls.find( + ([event]) => event === 'AccountsController:selectedAccountChange', + )?.[1] as (() => void) | undefined; + expect(accountChangeHandler).toBeDefined(); + + mockEvmAccount.address = secondAddress; + accountChangeHandler?.(); + await jest.advanceTimersByTimeAsync(500); + expect( + preloadController.state.cachedUserDataByProvider['hyperliquid:mainnet'], + ).toEqual( + expect.objectContaining({ + address: secondAddress, + hip3ConfigVersion: 0, + dexes: ['main'], + }), + ); + expect( + preloadController.getCachedUserDataForActiveProvider({ + skipTTL: true, + }), + ).toEqual( + expect.objectContaining({ + positions: [expect.objectContaining({ symbol: 'ETH' })], + }), + ); + + mockEvmAccount.address = firstAddress; + accountChangeHandler?.(); + + expect( + preloadController.getCachedUserDataForActiveProvider({ + skipTTL: true, + }), + ).toBeNull(); + await jest.advanceTimersByTimeAsync(500); + expect( + preloadController.getCachedUserDataForActiveProvider({ + skipTTL: true, + })?.positions[0].symbol, + ).toBe('BTC'); + expect(preloadMockProvider.getUserDataSnapshot).toHaveBeenCalledTimes(3); + expect(preloadInfrastructure.diskCache.removeItem).not.toHaveBeenCalled(); + + const userWrites = ( + preloadInfrastructure.diskCache.setItem as jest.Mock + ).mock.calls.filter(([key]) => key === PERPS_DISK_CACHE_USER_DATA); + const retainedPayload = JSON.parse( + userWrites[userWrites.length - 1][1] as string, + ) as { address: string }; + expect(retainedPayload.address.toLowerCase()).toBe( + firstAddress.toLowerCase(), + ); + + const hydratedInfrastructure = createMockInfrastructure(); + hydratedInfrastructure.diskCache.getItemSync.mockImplementation((key) => + key === PERPS_DISK_CACHE_USER_DATA + ? JSON.stringify(retainedPayload) + : null, + ); + const hydratedController = new TestablePerpsController({ + messenger: preloadMessenger, + state: getDefaultPerpsControllerState(), + infrastructure: hydratedInfrastructure, + }); + + expect( + hydratedController.getCachedUserDataForActiveProvider({ + skipTTL: true, + })?.positions[0].symbol, + ).toBe('BTC'); + mockEvmAccount.address = secondAddress; + expect( + hydratedController.getCachedUserDataForActiveProvider({ + skipTTL: true, + }), + ).toBeNull(); + }); + + it('handles errors without throwing', async () => { + preloadController.testMarkInitialized(); + preloadController.testSetProviders( + new Map([['hyperliquid', preloadMockProvider]]), + ); + preloadMockProvider.getMarketDataWithPrices.mockResolvedValue([]); + preloadMockProvider.getPositions.mockRejectedValue( + new Error('positions error'), + ); + preloadMockProvider.getWebSocketConnectionState.mockReturnValue( + WSState.Disconnected, + ); + + preloadController.startMarketDataPreload(); + await jest.advanceTimersByTimeAsync(500); + + // Should not crash + expect( + Object.keys(preloadController.state.cachedUserDataByProvider), + ).toHaveLength(0); + }); + + it('skips when cache is fresh for same account', async () => { + preloadController.testMarkInitialized(); + preloadController.testSetProviders( + new Map([['hyperliquid', preloadMockProvider]]), + ); + preloadMockProvider.getMarketDataWithPrices.mockResolvedValue([]); + preloadMockProvider.getWebSocketConnectionState.mockReturnValue( + WSState.Disconnected, + ); + preloadMockProvider.getPositions.mockResolvedValue([]); + preloadMockProvider.getOpenOrders.mockResolvedValue([]); + preloadMockProvider.getAccountState.mockResolvedValue({ + spendableBalance: '100', + withdrawableBalance: '100', + totalBalance: '100', + marginUsed: '0', + unrealizedPnl: '0', + returnOnEquity: '0', + }); + + // First preload — populates the cache + preloadController.startMarketDataPreload(); + await jest.advanceTimersByTimeAsync(500); + + const freshCache = preloadController.state.cachedUserDataByProvider; + const freshKey = Object.keys(freshCache)[0]; + expect(freshKey).toBeDefined(); + expect(freshCache[freshKey].address).toBe(mockEvmAccount.address); + expect(freshCache[freshKey].timestamp).toBeGreaterThan(0); + + // Reset call counts + preloadMockProvider.getPositions.mockClear(); + preloadMockProvider.getOpenOrders.mockClear(); + preloadMockProvider.getAccountState.mockClear(); + + // Trigger another preload cycle — should skip (cache is fresh, same account) + await jest.advanceTimersByTimeAsync(60_000); + + expect(preloadMockProvider.getPositions).not.toHaveBeenCalled(); + }); + }); + + describe('subscribe method hardening', () => { + it('subscribeToPrices returns no-op when provider is null', () => { + controller.testSetInitialized(false); + + const unsub = controller.subscribeToPrices({ + symbols: ['BTC'], + callback: jest.fn(), + }); + + expect(typeof unsub).toBe('function'); + // Should not throw + unsub(); + expect(mockProvider.subscribeToPrices).not.toHaveBeenCalled(); + }); + + it('subscribeToOrders returns no-op when provider is null', () => { + controller.testSetInitialized(false); + + const unsub = controller.subscribeToOrders({ callback: jest.fn() }); + + expect(typeof unsub).toBe('function'); + unsub(); + expect(mockProvider.subscribeToOrders).not.toHaveBeenCalled(); + }); + + it('subscribeToPositions returns no-op when provider is null', () => { + controller.testSetInitialized(false); + + const unsub = controller.subscribeToPositions({ + callback: jest.fn(), + }); + + expect(typeof unsub).toBe('function'); + unsub(); + expect(mockProvider.subscribeToPositions).not.toHaveBeenCalled(); + }); + + it('subscribeToOrderFills returns no-op when provider is null', () => { + controller.testSetInitialized(false); + + const unsub = controller.subscribeToOrderFills({ + callback: jest.fn(), + }); + + expect(typeof unsub).toBe('function'); + unsub(); + expect(mockProvider.subscribeToOrderFills).not.toHaveBeenCalled(); + }); + + it('subscribeToOrderBook returns no-op when provider is null', () => { + controller.testSetInitialized(false); + + const unsub = controller.subscribeToOrderBook({ + symbol: 'BTC', + callback: jest.fn(), + }); + + expect(typeof unsub).toBe('function'); + unsub(); + }); + + it('subscribeToCandles returns no-op when provider is null', () => { + controller.testSetInitialized(false); + + const unsub = controller.subscribeToCandles({ + symbol: 'BTC', + interval: '1h' as never, + callback: jest.fn(), + }); + + expect(typeof unsub).toBe('function'); + unsub(); + }); + + it('subscribeToOICaps returns no-op when provider is null', () => { + controller.testSetInitialized(false); + + const unsub = controller.subscribeToOICaps({ + callback: jest.fn(), + }); + + expect(typeof unsub).toBe('function'); + unsub(); + }); + }); + + describe('getCachedMarketDataForActiveProvider', () => { + it('rejects an expired Terminal snapshot even when TTL is skipped', () => { + controller.testUpdate((state) => { + state.cachedMarketDataByProvider['hyperliquid:mainnet'] = { + data: [ + { + symbol: 'BTC', + name: 'BTC', + price: '$50000', + dataSource: 'terminal-global-snapshot-mark', + sourceExpiresAt: Date.now() - 1, + }, + ], + timestamp: Date.now(), + sourceExpiresAt: Date.now() - 1, + hip3ConfigVersion: 0, + dexes: ['main'], + }; + }); + + expect( + controller.getCachedMarketDataForActiveProvider({ skipTTL: true }), + ).toBeNull(); + }); + + it('returns defensive copies of current Terminal snapshot data', () => { + const expiresAt = Date.now() + 20_000; + controller.testUpdate((state) => { + state.cachedMarketDataByProvider['hyperliquid:mainnet'] = { + data: [ + { + symbol: 'BTC', + name: 'BTC', + price: '$50000', + trend: [[Date.now() - 3_600_000, '49000']], + dataSource: 'terminal-global-snapshot-mark', + sourceExpiresAt: expiresAt, + }, + ], + timestamp: Date.now(), + sourceExpiresAt: expiresAt, + hip3ConfigVersion: 0, + dexes: ['main'], + }; + }); + + const first = controller.getCachedMarketDataForActiveProvider(); + first?.[0].trend?.push([Date.now(), '1']); + first?.splice(0); + const second = controller.getCachedMarketDataForActiveProvider(); + + expect(second).toHaveLength(1); + expect(second?.[0].trend).toHaveLength(1); + }); + + it('returns null when no cache exists', () => { + markControllerAsInitialized(); + controller.testSetProviders(new Map([['hyperliquid', mockProvider]])); + controller.testUpdate((state) => { + state.activeProvider = 'hyperliquid'; + }); + + const result = controller.getCachedMarketDataForActiveProvider(); + + expect(result).toBeNull(); + }); + + it('returns cached data for single provider', () => { + markControllerAsInitialized(); + controller.testSetProviders(new Map([['hyperliquid', mockProvider]])); + controller.testUpdate((state) => { + state.activeProvider = 'hyperliquid'; + state.cachedMarketDataByProvider['hyperliquid:mainnet'] = { + data: [{ symbol: 'BTC', name: 'BTC', price: '50000' } as any], + timestamp: Date.now(), + }; + }); + + const result = controller.getCachedMarketDataForActiveProvider(); + + expect(result).toHaveLength(1); + expect(result?.[0].symbol).toBe('BTC'); + }); + + it('returns null when single provider cache is expired', () => { + markControllerAsInitialized(); + controller.testSetProviders(new Map([['hyperliquid', mockProvider]])); + controller.testUpdate((state) => { + state.activeProvider = 'hyperliquid'; + state.cachedMarketDataByProvider['hyperliquid:mainnet'] = { + data: [{ symbol: 'BTC', name: 'BTC', price: '50000' } as any], + timestamp: Date.now() - 999_999_999, // very old + }; + }); + + const result = controller.getCachedMarketDataForActiveProvider(); + + expect(result).toBeNull(); + }); + + it('returns expired data when skipTTL is true', () => { + markControllerAsInitialized(); + controller.testSetProviders(new Map([['hyperliquid', mockProvider]])); + controller.testUpdate((state) => { + state.activeProvider = 'hyperliquid'; + state.cachedMarketDataByProvider['hyperliquid:mainnet'] = { + data: [{ symbol: 'BTC', name: 'BTC', price: '50000' } as any], + timestamp: Date.now() - 999_999_999, // very old + }; + }); + + const result = controller.getCachedMarketDataForActiveProvider({ + skipTTL: true, + }); + + expect(result).toHaveLength(1); + expect(result?.[0].symbol).toBe('BTC'); + }); + + it('assembles data from multiple providers in aggregated mode', () => { + const mockMYXProvider = createMockHyperLiquidProvider(); + markControllerAsInitialized(); + controller.testSetProviders( + new Map([ + ['hyperliquid', mockProvider], + ['myx', mockMYXProvider], + ] as any), + ); + controller.testUpdate((state) => { + state.activeProvider = 'aggregated'; + state.cachedMarketDataByProvider['hyperliquid:mainnet'] = { + data: [ + { + symbol: 'BTC', + name: 'BTC', + price: '50000', + providerId: 'hyperliquid', + } as any, + ], + timestamp: Date.now(), + }; + state.cachedMarketDataByProvider['myx:mainnet'] = { + data: [ + { + symbol: 'MYX', + name: 'MYX', + price: '1', + providerId: 'myx', + } as any, + ], + timestamp: Date.now(), + }; + }); + + const result = controller.getCachedMarketDataForActiveProvider(); + + expect(result).toHaveLength(2); + const symbols = (result ?? []).map((m: any) => m.symbol); + expect(symbols).toEqual(expect.arrayContaining(['BTC', 'MYX'])); + }); + + it('returns null in aggregated mode when all provider caches are empty', () => { + const mockMYXProvider = createMockHyperLiquidProvider(); + markControllerAsInitialized(); + controller.testSetProviders( + new Map([ + ['hyperliquid', mockProvider], + ['myx', mockMYXProvider], + ] as any), + ); + controller.testUpdate((state) => { + state.activeProvider = 'aggregated'; + state.cachedMarketDataByProvider['hyperliquid:mainnet'] = { + data: [], + timestamp: Date.now(), + }; + }); + + const result = controller.getCachedMarketDataForActiveProvider(); + + expect(result).toBeNull(); + }); + + it('keeps current provider data when another aggregated entry is stale', () => { + const mockMYXProvider = createMockHyperLiquidProvider(); + markControllerAsInitialized(); + controller.testSetProviders( + new Map([ + ['hyperliquid', mockProvider], + ['myx', mockMYXProvider], + ] as any), + ); + controller.testUpdate((state) => { + state.activeProvider = 'aggregated'; + state.cachedMarketDataByProvider['hyperliquid:mainnet'] = { + data: [{ symbol: 'BTC', name: 'BTC', price: '50000' } as any], + timestamp: Date.now() - 999_999_999, // very old + }; + state.cachedMarketDataByProvider['myx:mainnet'] = { + data: [{ symbol: 'MYX', name: 'MYX', price: '1' } as any], + timestamp: Date.now(), // fresh + }; + }); + + const result = controller.getCachedMarketDataForActiveProvider(); + + expect(result).toEqual([expect.objectContaining({ symbol: 'MYX' })]); + }); + }); + + describe('getCachedUserDataForActiveProvider', () => { + const mockAddress = '0x1234567890123456789012345678901234567890'; + + it('returns null when no cache exists', () => { + markControllerAsInitialized(); + controller.testSetProviders(new Map([['hyperliquid', mockProvider]])); + controller.testUpdate((state) => { + state.activeProvider = 'hyperliquid'; + }); + + const result = controller.getCachedUserDataForActiveProvider(); + + expect(result).toBeNull(); + }); + + it('returns null when the selected account cannot be resolved', () => { + const ctrl = new TestablePerpsController({ + messenger: createMockMessenger({ + call: jest.fn().mockImplementation((action: string) => { + if (action === 'RemoteFeatureFlagController:getState') { + return { remoteFeatureFlags: {} }; + } + return undefined; + }), + }), + state: getDefaultPerpsControllerState(), + infrastructure: createMockInfrastructure(), + }); + ctrl.testUpdate((state) => { + state.cachedUserDataByProvider['hyperliquid:mainnet'] = { + positions: [createMockPosition()], + orders: [], + accountState: null, + timestamp: Date.now(), + address: mockAddress, + hip3ConfigVersion: 0, + dexes: ['main'], + }; + }); + + const result = ctrl.getCachedUserDataForActiveProvider({ skipTTL: true }); + + expect(result).toBeNull(); + }); + + it('returns cached user data for single provider', () => { + const mockPosition = createMockPosition({ symbol: 'BTC', size: '1.0' }); + markControllerAsInitialized(); + controller.testSetProviders(new Map([['hyperliquid', mockProvider]])); + controller.testUpdate((state) => { + state.activeProvider = 'hyperliquid'; + state.cachedUserDataByProvider['hyperliquid:mainnet'] = { + positions: [mockPosition], + orders: [], + accountState: { + totalBalance: '50000', + spendableBalance: '45000', + withdrawableBalance: '45000', + marginUsed: '5000', + unrealizedPnl: '1000', + returnOnEquity: '20', + }, + timestamp: Date.now(), + address: mockAddress, + hip3ConfigVersion: 0, + dexes: ['main'], + }; + }); + + const result = controller.getCachedUserDataForActiveProvider(); + + expect(result).not.toBeNull(); + expect(result?.positions).toHaveLength(1); + expect(result?.positions[0].symbol).toBe('BTC'); + expect(result?.accountState?.totalBalance).toBe('50000'); + }); + + it('assembles user data from multiple providers in aggregated mode', () => { + const hlPosition = createMockPosition({ symbol: 'BTC', size: '1.0' }); + const myxPosition = createMockPosition({ symbol: 'MYX', size: '5.0' }); + const mockMYXProvider = createMockHyperLiquidProvider(); + markControllerAsInitialized(); + controller.testSetProviders( + new Map([ + ['hyperliquid', mockProvider], + ['myx', mockMYXProvider], + ] as any), + ); + controller.testUpdate((state) => { + state.activeProvider = 'aggregated'; + state.cachedUserDataByProvider['hyperliquid:mainnet'] = { + positions: [hlPosition], + orders: [], + accountState: { + totalBalance: '50000', + spendableBalance: '45000', + withdrawableBalance: '45000', + marginUsed: '5000', + unrealizedPnl: '1000', + returnOnEquity: '20', + }, + timestamp: Date.now(), + address: mockAddress, + hip3ConfigVersion: 0, + dexes: ['main'], + }; + state.cachedUserDataByProvider['myx:mainnet'] = { + positions: [myxPosition], + orders: [], + accountState: null, + timestamp: Date.now(), + address: mockAddress, + }; + }); + + const result = controller.getCachedUserDataForActiveProvider(); + + expect(result).not.toBeNull(); + expect(result?.positions).toHaveLength(2); + expect(result?.accountState?.totalBalance).toBe('50000'); + }); + + it('returns null in aggregated mode when no valid entries exist', () => { + const mockMYXProvider = createMockHyperLiquidProvider(); + markControllerAsInitialized(); + controller.testSetProviders( + new Map([ + ['hyperliquid', mockProvider], + ['myx', mockMYXProvider], + ] as any), + ); + controller.testUpdate((state) => { + state.activeProvider = 'aggregated'; + }); + + const result = controller.getCachedUserDataForActiveProvider(); + + expect(result).toBeNull(); + }); + + it('returns stale data when skipTTL is true', () => { + const mockPosition = createMockPosition({ symbol: 'BTC', size: '1.0' }); + markControllerAsInitialized(); + controller.testSetProviders(new Map([['hyperliquid', mockProvider]])); + controller.testUpdate((state) => { + state.activeProvider = 'hyperliquid'; + state.cachedUserDataByProvider['hyperliquid:mainnet'] = { + positions: [mockPosition], + orders: [], + accountState: null, + timestamp: Date.now() - 999_999_999, // very old + address: mockAddress, + hip3ConfigVersion: 0, + dexes: ['main'], + }; + }); + + const withoutSkip = controller.getCachedUserDataForActiveProvider(); + const withSkip = controller.getCachedUserDataForActiveProvider({ + skipTTL: true, + }); + + expect(withoutSkip).toBeNull(); + expect(withSkip).not.toBeNull(); + expect(withSkip?.positions).toHaveLength(1); + }); + }); + + describe('performMarketDataPreload aggregated mode', () => { + beforeEach(() => { + jest.useFakeTimers(); + }); + + afterEach(() => { + controller.stopMarketDataPreload(); + jest.useRealTimers(); + }); + + it('splits market data by providerId into per-provider cache entries', async () => { + const base = { + maxLeverage: '50x', + change24h: '+1', + change24hPercent: '+0.1%', + volume: '$1M', + }; + const mockData = [ + { + ...base, + symbol: 'BTC', + name: 'BTC', + price: '50000', + providerId: 'hyperliquid' as const, + }, + { + ...base, + symbol: 'ETH', + name: 'ETH', + price: '3000', + providerId: 'hyperliquid' as const, + }, + { + ...base, + symbol: 'MYX', + name: 'MYX', + price: '1', + providerId: 'myx' as const, + }, + ]; + markControllerAsInitialized(); + controller.testSetProviders(new Map([['hyperliquid', mockProvider]])); + controller.testUpdate((state) => { + state.activeProvider = 'aggregated'; + }); + mockProvider.getMarketDataWithPrices.mockResolvedValue(mockData); + + controller.startMarketDataPreload(); + await jest.advanceTimersByTimeAsync(100); + + // Per-provider entries should be written + const hlEntry = + controller.state.cachedMarketDataByProvider['hyperliquid:mainnet']; + expect(hlEntry?.data).toHaveLength(2); + expect(hlEntry?.data[0].symbol).toBe('BTC'); + + const myxEntry = + controller.state.cachedMarketDataByProvider['myx:mainnet']; + expect(myxEntry?.data).toHaveLength(1); + expect(myxEntry?.data[0].symbol).toBe('MYX'); + + // Aggregated sentinel should be empty + const sentinel = + controller.state.cachedMarketDataByProvider['aggregated:mainnet']; + expect(sentinel?.data).toHaveLength(0); + expect(sentinel?.timestamp).toBeGreaterThan(0); + }); + + it('assigns items without providerId to hyperliquid fallback', async () => { + const mockData = [ + { + symbol: 'BTC', + name: 'BTC', + price: '50000', + maxLeverage: '50x', + change24h: '+1', + change24hPercent: '+0.1%', + volume: '$1M', + }, // no providerId + ]; + markControllerAsInitialized(); + controller.testSetProviders(new Map([['hyperliquid', mockProvider]])); + controller.testUpdate((state) => { + state.activeProvider = 'aggregated'; + }); + mockProvider.getMarketDataWithPrices.mockResolvedValue(mockData); + + controller.startMarketDataPreload(); + await jest.advanceTimersByTimeAsync(100); + + const hlEntry = + controller.state.cachedMarketDataByProvider['hyperliquid:mainnet']; + expect(hlEntry?.data).toHaveLength(1); + expect(hlEntry?.data[0].symbol).toBe('BTC'); + }); + }); + + describe('firstNonEmpty', () => { + it('returns the first non-empty string', () => { + expect(firstNonEmpty('', undefined, 'hello', 'world')).toBe('hello'); + }); + + it('returns empty string when all values are empty or undefined', () => { + expect(firstNonEmpty('', undefined, '')).toBe(''); + }); + + it('returns the first value if it is non-empty', () => { + expect(firstNonEmpty('first', 'second')).toBe('first'); + }); + + it('skips empty strings and returns the fallback', () => { + expect(firstNonEmpty('', 'fallback')).toBe('fallback'); + }); + }); + + describe('resolveMyxAuthConfig', () => { + it('uses testnet credentials on testnet', () => { + // Arrange + const myx = { + appIdTestnet: 'test-app', + apiSecretTestnet: 'test-secret', + brokerAddressTestnet: '0xTestBroker', + appIdMainnet: 'main-app', + apiSecretMainnet: 'main-secret', + brokerAddressMainnet: '0xMainBroker', + }; + + // Act + const result = resolveMyxAuthConfig(myx, true); + + // Assert + expect(result.appId).toBe('test-app'); + expect(result.apiSecret).toBe('test-secret'); + expect(result.brokerAddress).toBe('0xTestBroker'); + }); + + it('uses mainnet credentials on mainnet', () => { + // Arrange + const myx = { + appIdTestnet: 'test-app', + apiSecretTestnet: 'test-secret', + brokerAddressTestnet: '0xTestBroker', + appIdMainnet: 'main-app', + apiSecretMainnet: 'main-secret', + brokerAddressMainnet: '0xMainBroker', + }; + + // Act + const result = resolveMyxAuthConfig(myx, false); + + // Assert + expect(result.appId).toBe('main-app'); + expect(result.apiSecret).toBe('main-secret'); + expect(result.brokerAddress).toBe('0xMainBroker'); + }); + + it('falls back to testnet credentials when mainnet are empty', () => { + // Arrange + const myx = { + appIdTestnet: 'test-app', + apiSecretTestnet: 'test-secret', + brokerAddressTestnet: '0xTestBroker', + appIdMainnet: '', + apiSecretMainnet: '', + brokerAddressMainnet: '', + }; + + // Act + const result = resolveMyxAuthConfig(myx, false); + + // Assert + expect(result.appId).toBe('test-app'); + expect(result.apiSecret).toBe('test-secret'); + expect(result.brokerAddress).toBe('0xTestBroker'); + }); + + it('returns empty strings when no credentials are set', () => { + // Act + const result = resolveMyxAuthConfig({}, true); + + // Assert + expect(result.appId).toBe(''); + expect(result.apiSecret).toBe(''); + expect(result.brokerAddress).toBe(''); + }); + }); +}); diff --git a/packages/perps-controller/tests/src/PerpsController.state.test.ts b/packages/perps-controller/tests/src/PerpsController.state.test.ts new file mode 100644 index 00000000000..85bae8ca31f --- /dev/null +++ b/packages/perps-controller/tests/src/PerpsController.state.test.ts @@ -0,0 +1,1611 @@ +/* eslint-disable */ +/** + * PerpsController Tests + * Clean, focused test suite for PerpsController + */ + +import { createMockHyperLiquidProvider } from '../helpers/providerMocks.js'; +import { + createMockInfrastructure, + createMockMessenger, +} from '../helpers/serviceMocks.js'; + +jest.mock('@nktkas/hyperliquid', () => ({})); +jest.mock('@myx-trade/sdk', () => ({ + MyxClient: jest.fn(), + OrderStatusEnum: { Successful: 9 }, +})); + +import { + PERPS_EVENT_PROPERTY, + PERPS_EVENT_VALUE, +} from '../../src/constants/eventNames.js'; +import { + PERPS_CONSTANTS, + PERPS_DISK_CACHE_MARKETS, + PERPS_DISK_CACHE_USER_DATA, +} from '../../src/constants/perpsConfig.js'; +import { + PerpsController, + getDefaultPerpsControllerState, + InitializationState, + firstNonEmpty, + resolveMyxAuthConfig, +} from '../../src/PerpsController.js'; +import { PERPS_ERROR_CODES } from '../../src/perpsErrorCodes.js'; +import { HyperLiquidProvider } from '../../src/providers/HyperLiquidProvider.js'; +import { PerpsAnalyticsEvent } from '../../src/types/index.js'; + +jest.mock('../../src/providers/HyperLiquidProvider'); +jest.mock('../../src/providers/MYXProvider'); + +// Mock transaction controller utility +const mockAddTransaction = jest.fn(); +jest.mock( + '../../../util/transaction-controller', + () => ({ + addTransaction: (...args) => mockAddTransaction(...args), + }), + { virtual: true }, +); + +// Mock wait utility to speed up retry tests +jest.mock('../../src/utils/wait', () => ({ + wait: jest.fn().mockResolvedValue(undefined), +})); + +// Mock stream manager +const mockStreamManager = { + positions: { pause: jest.fn(), resume: jest.fn() }, + account: { pause: jest.fn(), resume: jest.fn() }, + orders: { pause: jest.fn(), resume: jest.fn() }, + prices: { pause: jest.fn(), resume: jest.fn() }, + orderFills: { pause: jest.fn(), resume: jest.fn() }, +}; + +jest.mock( + '../../../components/UI/Perps/providers/PerpsStreamManager', + () => ({ + getStreamManagerInstance: jest.fn(() => mockStreamManager), + }), + { virtual: true }, +); + +jest.mock('@metamask/utils', () => ({ + ...jest.requireActual('@metamask/utils'), + formatAccountToCaipAccountId: jest + .fn() + .mockReturnValue('eip155:1:0x1234567890123456789012345678901234567890'), +})); + +// Mock EligibilityService as a class with instance methods +const mockEligibilityServiceInstance = { + checkEligibility: jest.fn().mockResolvedValue(true), +}; +jest.mock('../../src/services/EligibilityService', () => ({ + EligibilityService: jest + .fn() + .mockImplementation(() => mockEligibilityServiceInstance), +})); + +// Mock DepositService as a class with instance methods +const mockDepositServiceInstance = { + prepareTransaction: jest.fn(), +}; +jest.mock('../../src/services/DepositService', () => ({ + DepositService: jest + .fn() + .mockImplementation(() => mockDepositServiceInstance), +})); + +// Mock MarketDataService as a class with instance methods +const mockMarketDataServiceInstance = { + getPositions: jest.fn(), + getAccountState: jest.fn(), + getMarkets: jest.fn(), + getWithdrawalRoutes: jest.fn().mockReturnValue([]), + validateClosePosition: jest.fn().mockResolvedValue({ isValid: true }), + validateOrder: jest.fn(), + calculateMaintenanceMargin: jest.fn().mockResolvedValue(0), + calculateLiquidationPrice: jest.fn(), + getMaxLeverage: jest.fn(), + calculateFees: jest.fn().mockResolvedValue({ totalFee: 0 }), + previewPositionModify: jest.fn(), + getAvailableDexs: jest.fn().mockResolvedValue([]), + getBlockExplorerUrl: jest.fn(), + getOrderFills: jest.fn(), + getOrders: jest.fn(), + getFunding: jest.fn(), +}; +jest.mock('../../src/services/MarketDataService', () => ({ + MarketDataService: jest + .fn() + .mockImplementation(() => mockMarketDataServiceInstance), +})); + +// Mock TradingService as a class with instance methods +const mockTradingServiceInstance = { + placeOrder: jest.fn(), + editOrder: jest.fn(), + cancelOrder: jest.fn(), + cancelOrders: jest.fn(), + closePosition: jest.fn(), + closePositions: jest.fn(), + updatePositionTPSL: jest.fn(), + updateMargin: jest.fn(), + flipPosition: jest.fn(), + setControllerDependencies: jest.fn(), +}; +jest.mock('../../src/services/TradingService', () => ({ + TradingService: jest + .fn() + .mockImplementation(() => mockTradingServiceInstance), +})); + +// Mock AccountService as a class with instance methods +const mockAccountServiceInstance = { + withdraw: jest.fn(), + validateWithdrawal: jest.fn(), +}; +jest.mock('../../src/services/AccountService', () => ({ + AccountService: jest + .fn() + .mockImplementation(() => mockAccountServiceInstance), +})); + +// Mock DataLakeService as a class with instance methods +const mockDataLakeServiceInstance = { + reportOrder: jest.fn(), +}; +jest.mock('../../src/services/DataLakeService', () => ({ + DataLakeService: jest + .fn() + .mockImplementation(() => mockDataLakeServiceInstance), +})); + +// Mock FeatureFlagConfigurationService as a class with instance methods +const mockFeatureFlagConfigurationServiceInstance = { + refreshEligibility: jest.fn((options) => { + // Simulate the service's behavior: extract blocked regions from remote flags + const remoteFlags = + options.remoteFeatureFlagControllerState.remoteFeatureFlags; + const perpsGeoBlockedRegionsFeatureFlag = + remoteFlags?.perpsPerpTradingGeoBlockedCountriesV2; + const remoteBlockedRegions = + perpsGeoBlockedRegionsFeatureFlag?.blockedRegions; + + if ( + Array.isArray(remoteBlockedRegions) && + options.context.setBlockedRegionList + ) { + const currentList = options.context.getBlockedRegionList?.(); + // Never downgrade from remote to fallback + if (!currentList || currentList.source !== 'remote') { + options.context.setBlockedRegionList(remoteBlockedRegions, 'remote'); + } + } + + // Call refreshEligibility callback if available + if (options.context.refreshEligibility) { + options.context.refreshEligibility().catch(() => { + // Ignore errors in mock + }); + } + + // Also call refreshHip3Config if available + if (remoteFlags) { + mockFeatureFlagConfigurationServiceInstance.refreshHip3Config(options); + } + }), + refreshHip3Config: jest.fn(), + setBlockedRegions: jest.fn((options) => { + // Simulate setBlockedRegions behavior + const { list, source, context } = options; + if (context.setBlockedRegionList && context.getBlockedRegionList) { + const currentList = context.getBlockedRegionList(); + // Never downgrade from remote to fallback + if (source === 'fallback' && currentList.source === 'remote') { + return; + } + if (Array.isArray(list)) { + context.setBlockedRegionList(list, source); + } + } + + // Call refreshEligibility callback if available + if (context.refreshEligibility) { + context.refreshEligibility().catch(() => { + // Ignore errors in mock + }); + } + }), +}; +jest.mock('../../src/services/FeatureFlagConfigurationService', () => ({ + FeatureFlagConfigurationService: jest + .fn() + .mockImplementation(() => mockFeatureFlagConfigurationServiceInstance), +})); + +/** + * Testable version of PerpsController that exposes protected methods for testing. + * This follows the pattern used in RewardsController.test.ts + */ +class TestablePerpsController extends PerpsController { + testUpdate(callback) { + this.update(callback); + } + + testMarkInitialized() { + this.isInitialized = true; + this.update((state) => { + state.initializationState = InitializationState.Initialized; + }); + } + + testSetProviders(providers) { + this.providers = providers; + const firstProvider = providers.values().next().value; + if (firstProvider) { + this.activeProviderInstance = firstProvider; + } + } + + testSetPartialProviders(providers) { + this.providers = providers; + } + + testGetProviders() { + return this.providers; + } + + testSetInitialized(value) { + this.isInitialized = value; + } + + testGetInitialized() { + return this.isInitialized; + } + + testGetBlockedRegionList() { + return this.blockedRegionList; + } + + testSetBlockedRegionList(list, source) { + this.setBlockedRegionList(list, source); + } + + testRefreshEligibilityOnFeatureFlagChange(remoteFlags) { + this.refreshEligibilityOnFeatureFlagChange(remoteFlags); + } + + testReportOrderToDataLake(data) { + return this.reportOrderToDataLake(data); + } + + testHasStandaloneProvider() { + return this.hasStandaloneProvider(); + } + + testRegisterMYXProvider(MYXProvider) { + this.registerMYXProvider(MYXProvider); + } + + testHandleMYXImportError(error) { + this.handleMYXImportError(error); + } +} + +describe('PerpsController', () => { + let controller; + let mockProvider; + let mockInfrastructure; + + // Helper to mark controller as initialized for tests + const markControllerAsInitialized = () => { + controller.testMarkInitialized(); + }; + + beforeEach(() => { + jest.clearAllMocks(); + + jest + .requireMock('../../src/services/EligibilityService') + .EligibilityService.mockImplementation( + () => mockEligibilityServiceInstance, + ); + jest + .requireMock('../../src/services/DepositService') + .DepositService.mockImplementation(() => mockDepositServiceInstance); + jest + .requireMock('../../src/services/MarketDataService') + .MarketDataService.mockImplementation( + () => mockMarketDataServiceInstance, + ); + jest + .requireMock('../../src/services/TradingService') + .TradingService.mockImplementation(() => mockTradingServiceInstance); + jest + .requireMock('../../src/services/AccountService') + .AccountService.mockImplementation(() => mockAccountServiceInstance); + jest + .requireMock('../../src/services/DataLakeService') + .DataLakeService.mockImplementation(() => mockDataLakeServiceInstance); + jest + .requireMock('../../src/services/FeatureFlagConfigurationService') + .FeatureFlagConfigurationService.mockImplementation( + () => mockFeatureFlagConfigurationServiceInstance, + ); + + mockEligibilityServiceInstance.checkEligibility.mockResolvedValue(true); + mockMarketDataServiceInstance.getPositions.mockResolvedValue([]); + mockMarketDataServiceInstance.getAccountState.mockResolvedValue({ + spendableBalance: '10000', + withdrawableBalance: '10000', + totalBalance: '10000', + marginUsed: '0', + unrealizedPnl: '0', + returnOnEquity: '0', + }); + mockMarketDataServiceInstance.getMarkets.mockResolvedValue([]); + mockMarketDataServiceInstance.getWithdrawalRoutes.mockReturnValue([]); + mockMarketDataServiceInstance.validateClosePosition.mockResolvedValue({ + isValid: true, + }); + mockMarketDataServiceInstance.calculateMaintenanceMargin.mockResolvedValue( + 0, + ); + mockMarketDataServiceInstance.calculateFees.mockResolvedValue({ + totalFee: 0, + }); + mockMarketDataServiceInstance.getAvailableDexs.mockResolvedValue([]); + + mockFeatureFlagConfigurationServiceInstance.refreshEligibility.mockImplementation( + (options) => { + const remoteFlags = + options.remoteFeatureFlagControllerState.remoteFeatureFlags; + const perpsGeoBlockedRegionsFeatureFlag = + remoteFlags?.perpsPerpTradingGeoBlockedCountriesV2; + const remoteBlockedRegions = + perpsGeoBlockedRegionsFeatureFlag?.blockedRegions; + + if ( + Array.isArray(remoteBlockedRegions) && + options.context.setBlockedRegionList + ) { + const currentList = options.context.getBlockedRegionList?.(); + if (!currentList || currentList.source !== 'remote') { + options.context.setBlockedRegionList( + remoteBlockedRegions, + 'remote', + ); + } + } + + if (options.context.refreshEligibility) { + options.context.refreshEligibility().catch(() => { + // Ignore errors in mock + }); + } + + if (remoteFlags) { + mockFeatureFlagConfigurationServiceInstance.refreshHip3Config( + options, + ); + } + }, + ); + mockFeatureFlagConfigurationServiceInstance.setBlockedRegions.mockImplementation( + (options) => { + const { list, source, context } = options; + if (context.setBlockedRegionList && context.getBlockedRegionList) { + const currentList = context.getBlockedRegionList(); + if (source === 'fallback' && currentList.source === 'remote') { + return; + } + if (Array.isArray(list)) { + context.setBlockedRegionList(list, source); + } + } + + if (context.refreshEligibility) { + context.refreshEligibility().catch(() => { + // Ignore errors in mock + }); + } + }, + ); + mockFeatureFlagConfigurationServiceInstance.refreshHip3Config.mockImplementation( + () => undefined, + ); + + // Create a fresh mock provider for each test + mockProvider = createMockHyperLiquidProvider(); + + // Add default mock return values for all provider methods + mockProvider.getPositions.mockResolvedValue([]); + mockProvider.getAccountState.mockResolvedValue({ + spendableBalance: '10000', + withdrawableBalance: '10000', + totalBalance: '10000', + marginUsed: '0', + unrealizedPnl: '0', + returnOnEquity: '0', + }); + mockProvider.getMarkets.mockResolvedValue([]); + mockProvider.getOpenOrders.mockResolvedValue([]); + mockProvider.getFunding.mockResolvedValue([]); + mockProvider.getOrderFills.mockResolvedValue([]); + mockProvider.getOrders.mockResolvedValue([]); + mockProvider.calculateLiquidationPrice.mockResolvedValue('0'); + mockProvider.getMaxLeverage.mockResolvedValue(50); + mockProvider.calculateMaintenanceMargin.mockResolvedValue(0); + mockProvider.calculateFees.mockResolvedValue({ feeAmount: 0 }); + mockProvider.getBlockExplorerUrl.mockReturnValue( + 'https://explorer.example.com', + ); + mockProvider.getWithdrawalRoutes.mockReturnValue([]); + + HyperLiquidProvider.mockImplementation(() => mockProvider); + + const mockCall = jest.fn().mockImplementation((action) => { + if (action === 'RemoteFeatureFlagController:getState') { + return { + remoteFeatureFlags: { + perpsPerpTradingGeoBlockedCountriesV2: { + blockedRegions: [], + }, + }, + }; + } + if ( + action === 'AccountTreeController:getAccountsFromSelectedAccountGroup' + ) { + return [ + { + address: '0x1234567890123456789012345678901234567890', + type: 'eip155:eoa', + id: 'account-1', + options: {}, + scopes: ['eip155:1'], + methods: [], + metadata: { + name: 'Test', + importTime: 0, + keyring: { type: 'HD Key Tree' }, + }, + }, + ]; + } + return undefined; + }); + + mockInfrastructure = createMockInfrastructure(); + controller = new TestablePerpsController({ + messenger: createMockMessenger({ call: mockCall }), + state: getDefaultPerpsControllerState(), + infrastructure: mockInfrastructure, + }); + }); + + afterEach(() => { + // Clear only provider mocks, not Engine.context mocks + // This prevents breaking Engine.context.RewardsController/NetworkController references + if (mockProvider) { + Object.values(mockProvider).forEach((value) => { + if ( + typeof value === 'object' && + value !== null && + 'mockClear' in value + ) { + value.mockClear(); + } + }); + } + mockInfrastructure.metrics.trackPerpsEvent.mockClear(); + mockInfrastructure.logger.error.mockClear(); + mockInfrastructure.debugLogger.log.mockClear(); + }); + describe('attribution context', () => { + it('returns an empty context by default', () => { + expect(controller.getAttributionContext()).toStrictEqual({}); + }); + + it('stores and returns the UTM attribution context', () => { + controller.setAttributionContext({ + utmSource: 'newsletter', + utmMedium: 'email', + utmCampaign: 'launch', + }); + + expect(controller.getAttributionContext()).toStrictEqual({ + utmSource: 'newsletter', + utmMedium: 'email', + utmCampaign: 'launch', + }); + }); + + it('clears the stored attribution context', () => { + controller.setAttributionContext({ utmSource: 'newsletter' }); + controller.clearAttributionContext(); + + expect(controller.getAttributionContext()).toStrictEqual({}); + }); + + it('merges defined UTM keys into event properties using canonical keys', () => { + controller.setAttributionContext({ + utmSource: 'newsletter', + utmMedium: 'email', + utmCampaign: 'launch', + utmContent: 'cta', + utmTerm: 'perps', + }); + + expect( + controller.mergeAttributionContext({ asset: 'BTC' }), + ).toStrictEqual({ + [PERPS_EVENT_PROPERTY.UTM_SOURCE]: 'newsletter', + [PERPS_EVENT_PROPERTY.UTM_MEDIUM]: 'email', + [PERPS_EVENT_PROPERTY.UTM_CAMPAIGN]: 'launch', + [PERPS_EVENT_PROPERTY.UTM_CONTENT]: 'cta', + [PERPS_EVENT_PROPERTY.UTM_TERM]: 'perps', + asset: 'BTC', + }); + }); + + it('lets provided properties win over attribution context and omits undefined UTM keys', () => { + controller.setAttributionContext({ utmSource: 'newsletter' }); + + expect( + controller.mergeAttributionContext({ + [PERPS_EVENT_PROPERTY.UTM_SOURCE]: 'override', + }), + ).toStrictEqual({ [PERPS_EVENT_PROPERTY.UTM_SOURCE]: 'override' }); + }); + + it('returns only base properties when no context is set', () => { + expect(controller.mergeAttributionContext()).toStrictEqual({}); + }); + }); + + describe('state management', () => { + it('returns positions without updating state', async () => { + const mockPositions = [ + { + symbol: 'ETH', + size: '2.5', + entryPrice: '2000', + positionValue: '5000', + unrealizedPnl: '500', + marginUsed: '2500', + leverage: { type: 'cross', value: 2 }, + liquidationPrice: '1500', + maxLeverage: 100, + returnOnEquity: '10.0', + cumulativeFunding: { + allTime: '10', + sinceOpen: '5', + sinceChange: '2', + }, + takeProfitCount: 0, + stopLossCount: 0, + }, + ]; + + markControllerAsInitialized(); + controller.testSetProviders(new Map([['hyperliquid', mockProvider]])); + jest + .spyOn(mockMarketDataServiceInstance, 'getPositions') + .mockResolvedValue(mockPositions); + + const result = await controller.getPositions(); + + expect(result).toEqual(mockPositions); + expect(mockMarketDataServiceInstance.getPositions).toHaveBeenCalled(); + }); + + it('handles errors without updating state', async () => { + const errorMessage = 'Failed to fetch positions'; + + markControllerAsInitialized(); + controller.testSetProviders(new Map([['hyperliquid', mockProvider]])); + jest + .spyOn(mockMarketDataServiceInstance, 'getPositions') + .mockRejectedValue(new Error(errorMessage)); + + await expect(controller.getPositions()).rejects.toThrow(errorMessage); + expect(mockMarketDataServiceInstance.getPositions).toHaveBeenCalled(); + }); + }); + + describe('connection management', () => { + it('handles disconnection', async () => { + markControllerAsInitialized(); + controller.testSetProviders(new Map([['hyperliquid', mockProvider]])); + mockProvider.disconnect.mockResolvedValue({ success: true }); + + await controller.disconnect(); + + expect(mockProvider.disconnect).toHaveBeenCalled(); + }); + + it('cleans up preload subscriptions on disconnect', async () => { + jest.useFakeTimers(); + markControllerAsInitialized(); + controller.testSetProviders(new Map([['hyperliquid', mockProvider]])); + mockProvider.disconnect.mockResolvedValue({ success: true }); + mockProvider.getMarketDataWithPrices.mockResolvedValue([]); + + // Arrange: start preloading to set up timer + subscriptions + controller.startMarketDataPreload(); + await jest.advanceTimersByTimeAsync(100); + + // Act: disconnect should tear down all preload state + await controller.disconnect(); + + // Assert: provider disconnected and no interval fires after disconnect + expect(mockProvider.disconnect).toHaveBeenCalled(); + const callsBefore = + mockProvider.getMarketDataWithPrices.mock.calls.length; + jest.advanceTimersByTime(10 * 60 * 1000); + expect(mockProvider.getMarketDataWithPrices.mock.calls.length).toBe( + callsBefore, + ); + + jest.useRealTimers(); + }); + }); + + describe('utility methods', () => { + it('gets funding information', async () => { + const mockFunding = [ + { + symbol: 'BTC', + fundingRate: '0.0001', + timestamp: 1640995200000, + amountUsd: '100', + rate: '0.0001', + }, + ]; + + markControllerAsInitialized(); + controller.testSetProviders(new Map([['hyperliquid', mockProvider]])); + jest + .spyOn(mockMarketDataServiceInstance, 'getFunding') + .mockResolvedValue(mockFunding); + + const result = await controller.getFunding(); + + expect(result).toEqual(mockFunding); + expect(mockMarketDataServiceInstance.getFunding).toHaveBeenCalledWith({ + provider: mockProvider, + params: undefined, + context: expect.any(Object), + }); + }); + + it('gets order fills with parameters', async () => { + const params = { limit: 10, user: '0x123' as `0x${string}` }; + const mockOrderFills = [ + { + orderId: 'order-123', + symbol: 'BTC', + side: 'buy', + size: '0.1', + price: '50000', + pnl: '100', + direction: 'long', + fee: '5', + feeToken: 'USDC', + timestamp: 1640995200000, + }, + ]; + + markControllerAsInitialized(); + controller.testSetProviders(new Map([['hyperliquid', mockProvider]])); + jest + .spyOn(mockMarketDataServiceInstance, 'getOrderFills') + .mockResolvedValue(mockOrderFills); + + const result = await controller.getOrderFills(params); + + expect(result).toEqual(mockOrderFills); + expect(mockMarketDataServiceInstance.getOrderFills).toHaveBeenCalledWith({ + provider: mockProvider, + params, + context: expect.any(Object), + }); + }); + }); + + describe('order management', () => { + it('edits order successfully', async () => { + const editParams = { + orderId: 'order-123', + newOrder: { + symbol: 'BTC', + isBuy: true, + orderType: 'limit', + price: '51000', + size: '0.2', + }, + }; + + const mockEditResult = { + success: true, + orderId: 'order-123', + updatedOrder: editParams.newOrder, + }; + + markControllerAsInitialized(); + controller.testSetProviders(new Map([['hyperliquid', mockProvider]])); + jest + .spyOn(mockTradingServiceInstance, 'editOrder') + .mockResolvedValue(mockEditResult); + + const result = await controller.editOrder(editParams); + + expect(result).toEqual(mockEditResult); + expect(mockTradingServiceInstance.editOrder).toHaveBeenCalledWith( + expect.objectContaining({ + provider: mockProvider, + params: editParams, + context: expect.any(Object), + }), + ); + }); + + it('handles edit order error', async () => { + const editParams = { + orderId: 'order-123', + newOrder: { + symbol: 'BTC', + isBuy: true, + orderType: 'limit', + price: '51000', + size: '0.2', + }, + }; + + const errorMessage = 'Order edit failed'; + + markControllerAsInitialized(); + controller.testSetProviders(new Map([['hyperliquid', mockProvider]])); + jest + .spyOn(mockTradingServiceInstance, 'editOrder') + .mockRejectedValue(new Error(errorMessage)); + + await expect(controller.editOrder(editParams)).rejects.toThrow( + errorMessage, + ); + expect(mockTradingServiceInstance.editOrder).toHaveBeenCalled(); + }); + }); + + describe('subscription management', () => { + it('subscribes to order fills', () => { + const mockUnsubscribe = jest.fn(); + const params = { + callback: jest.fn(), + }; + + markControllerAsInitialized(); + controller.testSetProviders(new Map([['hyperliquid', mockProvider]])); + mockProvider.subscribeToOrderFills.mockReturnValue(mockUnsubscribe); + + const unsubscribe = controller.subscribeToOrderFills(params); + + expect(unsubscribe).toBe(mockUnsubscribe); + expect(mockProvider.subscribeToOrderFills).toHaveBeenCalledWith(params); + }); + + it('sets live data configuration', () => { + const config = { + priceThrottleMs: 1000, + positionThrottleMs: 2000, + }; + + markControllerAsInitialized(); + controller.testSetProviders(new Map([['hyperliquid', mockProvider]])); + mockProvider.setLiveDataConfig.mockReturnValue(undefined); + + controller.setLiveDataConfig(config); + + expect(mockProvider.setLiveDataConfig).toHaveBeenCalledWith(config); + }); + + it('handles subscription cleanup', () => { + const mockUnsubscribe = jest.fn(); + const params = { + symbols: ['BTC', 'ETH'], + callback: jest.fn(), + }; + + markControllerAsInitialized(); + controller.testSetProviders(new Map([['hyperliquid', mockProvider]])); + mockProvider.subscribeToPrices.mockReturnValue(mockUnsubscribe); + + const unsubscribe = controller.subscribeToPrices(params); + + // Test that unsubscribe function works + unsubscribe(); + expect(mockUnsubscribe).toHaveBeenCalled(); + }); + }); + + describe('deposit operations', () => { + it('clears deposit result', () => { + // Test that clearDepositResult method exists and can be called + expect(() => controller.clearDepositResult()).not.toThrow(); + + // Verify the method was called (it's a void method) + expect(typeof controller.clearDepositResult).toBe('function'); + }); + }); + + describe('withdrawal operations', () => { + it('clears withdraw result', () => { + // Test that clearWithdrawResult method exists and can be called + expect(() => controller.clearWithdrawResult()).not.toThrow(); + + // Verify the method was called (it's a void method) + expect(typeof controller.clearWithdrawResult).toBe('function'); + }); + }); + + describe('network management', () => { + it('gets current network', () => { + const network = controller.getCurrentNetwork(); + + expect(['mainnet', 'testnet']).toContain(network); + expect(typeof network).toBe('string'); + }); + + it('gets withdrawal routes', () => { + const mockRoutes = [ + { + assetId: + 'eip155:42161/erc20:0xaf88d065e77c8cc2239327c5edb3a432268e5831' as `${string}:${string}/${string}:${string}/${string}`, + chainId: 'eip155:42161' as `${string}:${string}`, + contractAddress: + '0x1234567890123456789012345678901234567890' as `0x${string}`, + constraints: { + minAmount: '10', + maxAmount: '1000000', + }, + }, + ]; + + markControllerAsInitialized(); + controller.testSetProviders(new Map([['hyperliquid', mockProvider]])); + jest + .spyOn(mockMarketDataServiceInstance, 'getWithdrawalRoutes') + .mockReturnValue(mockRoutes); + + const result = controller.getWithdrawalRoutes(); + + expect(result).toEqual(mockRoutes); + expect( + mockMarketDataServiceInstance.getWithdrawalRoutes, + ).toHaveBeenCalledWith({ + provider: mockProvider, + }); + }); + }); + + describe('user management', () => { + it('checks if first time user on current network', () => { + const isFirstTime = controller.isFirstTimeUserOnCurrentNetwork(); + + expect(typeof isFirstTime).toBe('boolean'); + }); + + it('marks tutorial as completed', () => { + // Test that markTutorialCompleted method exists and can be called + expect(() => controller.markTutorialCompleted()).not.toThrow(); + + // Verify the method was called (it's a void method) + expect(typeof controller.markTutorialCompleted).toBe('function'); + }); + }); + + describe('watchlist markets', () => { + it('returns empty array by default', () => { + const watchlist = controller.getWatchlistMarkets(); + expect(watchlist).toEqual([]); + }); + + it('toggles watchlist market (add)', async () => { + await controller.toggleWatchlistMarket('BTC'); + + const watchlist = controller.getWatchlistMarkets(); + expect(watchlist).toContain('BTC'); + expect(controller.isWatchlistMarket('BTC')).toBe(true); + }); + + it('toggles watchlist market (remove)', async () => { + await controller.toggleWatchlistMarket('BTC'); + await controller.toggleWatchlistMarket('BTC'); + + const watchlist = controller.getWatchlistMarkets(); + expect(watchlist).not.toContain('BTC'); + expect(controller.isWatchlistMarket('BTC')).toBe(false); + }); + + it('handles multiple watchlist markets', async () => { + await controller.toggleWatchlistMarket('BTC'); + await controller.toggleWatchlistMarket('ETH'); + await controller.toggleWatchlistMarket('SOL'); + + const watchlist = controller.getWatchlistMarkets(); + expect(watchlist).toHaveLength(3); + expect(watchlist).toContain('BTC'); + expect(watchlist).toContain('ETH'); + expect(watchlist).toContain('SOL'); + }); + + it('persist watchlist per network', async () => { + // Add to watchlist on mainnet (default is testnet in dev, so set to false) + controller.testUpdate((state) => { + state.isTestnet = false; + }); + await controller.toggleWatchlistMarket('BTC'); + + const mainnetWatchlist = controller.getWatchlistMarkets(); + expect(mainnetWatchlist).toContain('BTC'); + + // Switch to testnet + controller.testUpdate((state) => { + state.isTestnet = true; + }); + const testnetWatchlist = controller.getWatchlistMarkets(); + expect(testnetWatchlist).toEqual([]); + + // Add to watchlist on testnet + await controller.toggleWatchlistMarket('ETH'); + expect(controller.getWatchlistMarkets()).toContain('ETH'); + expect(controller.isWatchlistMarket('ETH')).toBe(true); + + // Switch back to mainnet + controller.testUpdate((state) => { + state.isTestnet = false; + }); + expect(controller.getWatchlistMarkets()).toContain('BTC'); + expect(controller.getWatchlistMarkets()).not.toContain('ETH'); + }); + }); + + describe('recently viewed markets', () => { + beforeEach(() => { + jest.useFakeTimers(); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + it('returns empty array by default', () => { + expect(controller.getRecentlyViewedMarkets()).toStrictEqual([]); + }); + + it('records a viewed market and returns it', () => { + controller.recordMarketViewed('BTC'); + + expect(controller.getRecentlyViewedMarkets()).toStrictEqual(['BTC']); + }); + + it('prepends new entries (newest first)', () => { + controller.recordMarketViewed('BTC'); + jest.advanceTimersByTime(1000); + controller.recordMarketViewed('ETH'); + + expect(controller.getRecentlyViewedMarkets()).toStrictEqual([ + 'ETH', + 'BTC', + ]); + }); + + it('deduplicates: moves existing symbol to front', () => { + controller.recordMarketViewed('BTC'); + jest.advanceTimersByTime(1000); + controller.recordMarketViewed('ETH'); + jest.advanceTimersByTime(1000); + controller.recordMarketViewed('BTC'); + + const result = controller.getRecentlyViewedMarkets(); + expect(result[0]).toBe('BTC'); + expect(result.filter((s) => s === 'BTC')).toHaveLength(1); + }); + + it('caps at 10 entries', () => { + for (let i = 0; i < 15; i++) { + controller.recordMarketViewed(`COIN${i}`); + jest.advanceTimersByTime(100); + } + + expect(controller.getRecentlyViewedMarkets()).toHaveLength(10); + }); + + it('filters out entries older than 24 hours', () => { + controller.recordMarketViewed('BTC'); + // Advance past the 24h TTL + jest.advanceTimersByTime(25 * 60 * 60 * 1000); + controller.recordMarketViewed('ETH'); + + const result = controller.getRecentlyViewedMarkets(); + expect(result).toContain('ETH'); + expect(result).not.toContain('BTC'); + }); + + it('returns empty array when all entries are expired', () => { + controller.recordMarketViewed('BTC'); + jest.advanceTimersByTime(25 * 60 * 60 * 1000); + + expect(controller.getRecentlyViewedMarkets()).toStrictEqual([]); + }); + + it('tracks per network — mainnet and testnet are independent', () => { + controller.testUpdate((state) => { + state.isTestnet = false; + }); + controller.recordMarketViewed('BTC'); + + controller.testUpdate((state) => { + state.isTestnet = true; + }); + expect(controller.getRecentlyViewedMarkets()).toStrictEqual([]); + + controller.recordMarketViewed('SOL'); + expect(controller.getRecentlyViewedMarkets()).toContain('SOL'); + + controller.testUpdate((state) => { + state.isTestnet = false; + }); + expect(controller.getRecentlyViewedMarkets()).toContain('BTC'); + expect(controller.getRecentlyViewedMarkets()).not.toContain('SOL'); + }); + }); + + describe('AUS watchlist sync', () => { + /** + * Minimal valid NotificationPreferences blob used across these tests. + * `watchlistMarkets` is intentionally absent so individual tests can + * control whether the field is present or not. + */ + const MOCK_PREFS_BASE = { + walletActivity: { + inAppNotificationsEnabled: true, + pushNotificationsEnabled: true, + accounts: [], + }, + marketing: { + inAppNotificationsEnabled: false, + pushNotificationsEnabled: false, + }, + perps: { + inAppNotificationsEnabled: true, + pushNotificationsEnabled: true, + }, + socialAI: { + inAppNotificationsEnabled: false, + pushNotificationsEnabled: false, + mutedTraderProfileIds: [], + }, + agenticCli: { + inAppNotificationsEnabled: true, + pushNotificationsEnabled: true, + }, + priceAlerts: { + inAppNotificationsEnabled: true, + pushNotificationsEnabled: true, + }, + }; + + let ausController; + let mockAusCall; + let mockAusInfrastructure; + + beforeEach(() => { + mockAusCall = jest.fn().mockImplementation((action) => { + if (action === 'RemoteFeatureFlagController:getState') { + return { + remoteFeatureFlags: { + perpsPerpTradingGeoBlockedCountriesV2: { blockedRegions: [] }, + }, + }; + } + // By default, behave as if no blob exists (unauthenticated / 404). + if ( + action === + 'AuthenticatedUserStorageService:getNotificationPreferences' + ) { + return Promise.resolve(null); + } + if ( + action === + 'AuthenticatedUserStorageService:putNotificationPreferences' + ) { + return Promise.resolve(undefined); + } + return undefined; + }); + + mockAusInfrastructure = createMockInfrastructure(); + ausController = new TestablePerpsController({ + messenger: createMockMessenger({ call: mockAusCall }), + state: getDefaultPerpsControllerState(), + infrastructure: mockAusInfrastructure, + }); + }); + + it('local state updates immediately (optimistic) when AUS returns null blob', async () => { + // AUS returns null → no remote write, but local state should still change. + await ausController.toggleWatchlistMarket('BTC'); + + expect(ausController.getWatchlistMarkets()).toContain('BTC'); + expect(mockAusCall).toHaveBeenCalledWith( + 'AuthenticatedUserStorageService:getNotificationPreferences', + ); + expect(mockAusCall).not.toHaveBeenCalledWith( + 'AuthenticatedUserStorageService:putNotificationPreferences', + expect.anything(), + ); + }); + + it('writes merged watchlist to AUS when a preferences blob exists', async () => { + const existingPrefs = { + ...MOCK_PREFS_BASE, + perps: { + ...MOCK_PREFS_BASE.perps, + watchlistMarkets: { + hyperliquid: { testnet: [], mainnet: [] }, + myx: { testnet: [], mainnet: [] }, + }, + }, + }; + + mockAusCall.mockImplementation((action) => { + if (action === 'RemoteFeatureFlagController:getState') { + return { remoteFeatureFlags: {} }; + } + if ( + action === + 'AuthenticatedUserStorageService:getNotificationPreferences' + ) { + return Promise.resolve(existingPrefs); + } + if ( + action === + 'AuthenticatedUserStorageService:putNotificationPreferences' + ) { + return Promise.resolve(undefined); + } + return undefined; + }); + + // Default state is testnet; toggle on testnet. + ausController.testUpdate((state) => { + state.isTestnet = true; + state.activeProvider = 'hyperliquid'; + }); + + await ausController.toggleWatchlistMarket('BTC'); + + expect(ausController.getWatchlistMarkets()).toContain('BTC'); + + // Verify put was called with merged prefs. + expect(mockAusCall).toHaveBeenCalledWith( + 'AuthenticatedUserStorageService:putNotificationPreferences', + expect.objectContaining({ + perps: expect.objectContaining({ + watchlistMarkets: expect.objectContaining({ + hyperliquid: expect.objectContaining({ + testnet: expect.arrayContaining(['BTC']), + }), + }), + }), + }), + ); + }); + + it('reverts local state when AUS PUT fails', async () => { + const existingPrefs = { + ...MOCK_PREFS_BASE, + perps: { + ...MOCK_PREFS_BASE.perps, + watchlistMarkets: { + hyperliquid: { testnet: [], mainnet: [] }, + myx: { testnet: [], mainnet: [] }, + }, + }, + }; + + mockAusCall.mockImplementation((action) => { + if (action === 'RemoteFeatureFlagController:getState') { + return { remoteFeatureFlags: {} }; + } + if ( + action === + 'AuthenticatedUserStorageService:getNotificationPreferences' + ) { + return Promise.resolve(existingPrefs); + } + if ( + action === + 'AuthenticatedUserStorageService:putNotificationPreferences' + ) { + return Promise.reject(new Error('AUS server error')); + } + return undefined; + }); + + ausController.testUpdate((state) => { + state.isTestnet = false; + state.activeProvider = 'hyperliquid'; + }); + + // After toggle, local state should optimistically contain BTC. + // After PUT fails, it should be reverted. + await ausController.toggleWatchlistMarket('BTC'); + + expect(ausController.getWatchlistMarkets()).not.toContain('BTC'); + expect(mockAusInfrastructure.logger.error).toHaveBeenCalled(); + }); + + it('skips AUS sync when activeProvider is aggregated', async () => { + ausController.testUpdate((state) => { + state.activeProvider = 'aggregated'; + }); + + await ausController.toggleWatchlistMarket('BTC'); + + // Local state changes. + expect(ausController.getWatchlistMarkets()).toContain('BTC'); + // AUS is never contacted. + expect(mockAusCall).not.toHaveBeenCalledWith( + 'AuthenticatedUserStorageService:getNotificationPreferences', + ); + expect(mockAusCall).not.toHaveBeenCalledWith( + 'AuthenticatedUserStorageService:putNotificationPreferences', + expect.anything(), + ); + }); + + it('does not throw when AUS GET throws (unauthenticated)', async () => { + mockAusCall.mockImplementation((action) => { + if (action === 'RemoteFeatureFlagController:getState') { + return { remoteFeatureFlags: {} }; + } + if ( + action === + 'AuthenticatedUserStorageService:getNotificationPreferences' + ) { + return Promise.reject(new Error('Unauthenticated')); + } + return undefined; + }); + + ausController.testUpdate((state) => { + state.isTestnet = false; + }); + + // Should not throw — failure is handled internally. + await expect( + ausController.toggleWatchlistMarket('BTC'), + ).resolves.toBeUndefined(); + + // Local state is reverted since the AUS path failed. + expect(ausController.getWatchlistMarkets()).not.toContain('BTC'); + }); + + it('tracks analytics event when toggling watchlist market', async () => { + ausController.testUpdate((state) => { + state.isTestnet = false; + state.activeProvider = 'hyperliquid'; + }); + + await ausController.toggleWatchlistMarket('ETH'); + + expect( + mockAusInfrastructure.metrics.trackPerpsEvent, + ).toHaveBeenCalledWith( + PerpsAnalyticsEvent.UiInteraction, + expect.objectContaining({ + interaction_type: 'favorite_toggled', + asset: 'ETH', + }), + ); + }); + + describe('init hydration from AUS', () => { + it('hydrates local watchlist from AUS on successful init', async () => { + const remotePrefs = { + ...MOCK_PREFS_BASE, + perps: { + ...MOCK_PREFS_BASE.perps, + watchlistMarkets: { + hyperliquid: { + testnet: ['BTC', 'ETH'], + mainnet: ['SOL'], + }, + myx: { testnet: [], mainnet: [] }, + }, + }, + }; + + mockAusCall.mockImplementation((action) => { + if (action === 'RemoteFeatureFlagController:getState') { + return { + remoteFeatureFlags: { + perpsPerpTradingGeoBlockedCountriesV2: { blockedRegions: [] }, + }, + }; + } + if ( + action === + 'AuthenticatedUserStorageService:getNotificationPreferences' + ) { + return Promise.resolve(remotePrefs); + } + return undefined; + }); + + ausController.testUpdate((state) => { + state.activeProvider = 'hyperliquid'; + }); + + await ausController.init(); + + // Allow the non-blocking #syncWatchlistFromRemote promise to settle. + await new Promise((resolve) => setTimeout(resolve, 0)); + + expect(ausController.state.watchlistMarkets.testnet).toEqual([ + 'BTC', + 'ETH', + ]); + expect(ausController.state.watchlistMarkets.mainnet).toEqual(['SOL']); + }); + + it('hydrates (clears) local watchlist when remote entry exists with empty arrays', async () => { + // Remote blob has the hyperliquid key present but both arrays are empty — + // this represents an intentional clear by another device. The controller + // must honor the remote state rather than treating it as "not migrated". + const remotePrefsEmptyWatchlist = { + ...MOCK_PREFS_BASE, + perps: { + ...MOCK_PREFS_BASE.perps, + watchlistMarkets: { + hyperliquid: { testnet: [], mainnet: [] }, + myx: { testnet: [], mainnet: [] }, + }, + }, + }; + + mockAusCall.mockImplementation((action) => { + if (action === 'RemoteFeatureFlagController:getState') { + return { + remoteFeatureFlags: { + perpsPerpTradingGeoBlockedCountriesV2: { blockedRegions: [] }, + }, + }; + } + if ( + action === + 'AuthenticatedUserStorageService:getNotificationPreferences' + ) { + return Promise.resolve(remotePrefsEmptyWatchlist); + } + return undefined; + }); + + // Local state has stale favorites from before the remote clear. + const staleState = getDefaultPerpsControllerState(); + staleState.activeProvider = 'hyperliquid'; + staleState.watchlistMarkets.testnet = ['BTC', 'ETH']; + staleState.watchlistMarkets.mainnet = ['SOL']; + + const clearController = new TestablePerpsController({ + messenger: createMockMessenger({ call: mockAusCall }), + state: staleState, + infrastructure: mockAusInfrastructure, + }); + + await clearController.init(); + await new Promise((resolve) => setTimeout(resolve, 0)); + + // Local state must be cleared to match the remote empty arrays. + expect(clearController.state.watchlistMarkets.testnet).toEqual([]); + expect(clearController.state.watchlistMarkets.mainnet).toEqual([]); + + // No migration PUT should be issued — the remote key is present. + expect(mockAusCall).not.toHaveBeenCalledWith( + 'AuthenticatedUserStorageService:putNotificationPreferences', + expect.anything(), + ); + }); + + it('performs one-time migration when blob exists but has no watchlist for the active provider', async () => { + const remotePrefsWithoutWatchlist = { ...MOCK_PREFS_BASE }; + + mockAusCall.mockImplementation((action) => { + if (action === 'RemoteFeatureFlagController:getState') { + return { + remoteFeatureFlags: { + perpsPerpTradingGeoBlockedCountriesV2: { blockedRegions: [] }, + }, + }; + } + if ( + action === + 'AuthenticatedUserStorageService:getNotificationPreferences' + ) { + return Promise.resolve(remotePrefsWithoutWatchlist); + } + if ( + action === + 'AuthenticatedUserStorageService:putNotificationPreferences' + ) { + return Promise.resolve(undefined); + } + return undefined; + }); + + // Local state has some markets saved before AUS was introduced. + const initialState = getDefaultPerpsControllerState(); + initialState.watchlistMarkets.testnet = ['BTC']; + initialState.watchlistMarkets.mainnet = ['ETH', 'SOL']; + initialState.activeProvider = 'hyperliquid'; + + const migrationController = new TestablePerpsController({ + messenger: createMockMessenger({ call: mockAusCall }), + state: initialState, + infrastructure: mockAusInfrastructure, + }); + + await migrationController.init(); + await new Promise((resolve) => setTimeout(resolve, 0)); + + // Verify local markets were pushed to AUS. + expect(mockAusCall).toHaveBeenCalledWith( + 'AuthenticatedUserStorageService:putNotificationPreferences', + expect.objectContaining({ + perps: expect.objectContaining({ + watchlistMarkets: expect.objectContaining({ + hyperliquid: expect.objectContaining({ + testnet: ['BTC'], + mainnet: ['ETH', 'SOL'], + }), + }), + }), + }), + ); + }); + + it('skips hydration when AUS blob is null', async () => { + // AUS returns null — local state is untouched. + const localState = getDefaultPerpsControllerState(); + localState.watchlistMarkets.mainnet = ['BTC']; + localState.activeProvider = 'hyperliquid'; + + const nullBlobController = new TestablePerpsController({ + messenger: createMockMessenger({ call: mockAusCall }), + state: localState, + infrastructure: mockAusInfrastructure, + }); + + await nullBlobController.init(); + await new Promise((resolve) => setTimeout(resolve, 0)); + + // Local state unchanged. + expect(nullBlobController.state.watchlistMarkets.mainnet).toEqual([ + 'BTC', + ]); + expect(mockAusCall).not.toHaveBeenCalledWith( + 'AuthenticatedUserStorageService:putNotificationPreferences', + expect.anything(), + ); + }); + + it('does not throw when AUS GET throws during init', async () => { + mockAusCall.mockImplementation((action) => { + if (action === 'RemoteFeatureFlagController:getState') { + return { + remoteFeatureFlags: { + perpsPerpTradingGeoBlockedCountriesV2: { blockedRegions: [] }, + }, + }; + } + if ( + action === + 'AuthenticatedUserStorageService:getNotificationPreferences' + ) { + return Promise.reject(new Error('Network error')); + } + return undefined; + }); + + // init() should still succeed; the watchlist sync error is handled internally. + await expect(ausController.init()).resolves.toBeUndefined(); + + await new Promise((resolve) => setTimeout(resolve, 0)); + + expect(mockAusInfrastructure.logger.error).toHaveBeenCalled(); + }); + }); + }); + + describe('additional subscriptions', () => { + it('subscribes to orders', () => { + const mockUnsubscribe = jest.fn(); + const params = { + callback: jest.fn(), + }; + + markControllerAsInitialized(); + controller.testSetProviders(new Map([['hyperliquid', mockProvider]])); + mockProvider.subscribeToOrders.mockReturnValue(mockUnsubscribe); + + const unsubscribe = controller.subscribeToOrders(params); + + expect(unsubscribe).toBe(mockUnsubscribe); + expect(mockProvider.subscribeToOrders).toHaveBeenCalledWith(params); + }); + + it('subscribes to account updates', () => { + const mockUnsubscribe = jest.fn(); + const params = { + callback: jest.fn(), + }; + + markControllerAsInitialized(); + controller.testSetProviders(new Map([['hyperliquid', mockProvider]])); + mockProvider.subscribeToAccount.mockReturnValue(mockUnsubscribe); + + const unsubscribe = controller.subscribeToAccount(params); + + expect(unsubscribe).toBe(mockUnsubscribe); + // Controller wraps callback to update state, so expect a function rather than exact params + expect(mockProvider.subscribeToAccount).toHaveBeenCalledWith( + expect.objectContaining({ callback: expect.any(Function) }), + ); + }); + + it('updates accountState when subscribeToAccount callback receives non-null account', () => { + const originalCallback = jest.fn(); + let wrappedCallback = () => { + /* assigned by mock */ + }; + mockProvider.subscribeToAccount.mockImplementation((p) => { + wrappedCallback = p.callback; + return jest.fn(); + }); + + markControllerAsInitialized(); + controller.testSetProviders(new Map([['hyperliquid', mockProvider]])); + + controller.subscribeToAccount({ callback: originalCallback }); + + const accountState = { + spendableBalance: '5000', + withdrawableBalance: '5000', + totalBalance: '5000', + marginUsed: '0', + unrealizedPnl: '0', + returnOnEquity: '0', + }; + wrappedCallback(accountState); + + expect(controller.state.accountState).toMatchObject(accountState); + expect(originalCallback).toHaveBeenCalledWith(accountState); + }); + + it('returns no-op unsub and does not throw when subscribeToAccount called before init', () => { + const params = { callback: jest.fn() }; + + const unsubscribe = controller.subscribeToAccount(params); + + expect(typeof unsubscribe).toBe('function'); + expect(() => unsubscribe()).not.toThrow(); + expect(mockProvider.subscribeToAccount).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/packages/perps-controller/tests/src/PerpsController.subscriptions.test.ts b/packages/perps-controller/tests/src/PerpsController.subscriptions.test.ts new file mode 100644 index 00000000000..db820d4f057 --- /dev/null +++ b/packages/perps-controller/tests/src/PerpsController.subscriptions.test.ts @@ -0,0 +1,801 @@ +/* eslint-disable */ +/** + * PerpsController Tests + * Clean, focused test suite for PerpsController + */ + +/* eslint-disable @typescript-eslint/no-explicit-any */ + +import { + createMockHyperLiquidProvider, + createMockPosition, +} from '../helpers/providerMocks.js'; +import { + createMockInfrastructure, + createMockMessenger, +} from '../helpers/serviceMocks.js'; + +jest.mock('@nktkas/hyperliquid', () => ({})); +jest.mock('@myx-trade/sdk', () => ({ + MyxClient: jest.fn(), + OrderStatusEnum: { Successful: 9 }, +})); + +import { + PerpsController, + getDefaultPerpsControllerState, + InitializationState, +} from '../../src/PerpsController.js'; +import type { PerpsControllerState } from '../../src/PerpsController.js'; +import { HyperLiquidProvider } from '../../src/providers/HyperLiquidProvider.js'; +import type { + PerpsProvider, + PerpsPlatformDependencies, + PerpsProviderType, + SubscribeAccountParams, +} from '../../src/types/index.js'; + +jest.mock('../../src/providers/HyperLiquidProvider'); +jest.mock('../../src/providers/MYXProvider'); + +// Mock transaction controller utility +const mockAddTransaction = jest.fn(); +jest.mock( + '../../../util/transaction-controller', + () => ({ + addTransaction: (...args: unknown[]) => mockAddTransaction(...args), + }), + { virtual: true }, +); + +// Mock wait utility to speed up retry tests +jest.mock('../../src/utils/wait', () => ({ + wait: jest.fn().mockResolvedValue(undefined), +})); + +// Mock stream manager +const mockStreamManager = { + positions: { pause: jest.fn(), resume: jest.fn() }, + account: { pause: jest.fn(), resume: jest.fn() }, + orders: { pause: jest.fn(), resume: jest.fn() }, + prices: { pause: jest.fn(), resume: jest.fn() }, + orderFills: { pause: jest.fn(), resume: jest.fn() }, +}; + +jest.mock( + '../../../components/UI/Perps/providers/PerpsStreamManager', + () => ({ + getStreamManagerInstance: jest.fn(() => mockStreamManager), + }), + { virtual: true }, +); + +jest.mock('@metamask/utils', () => ({ + ...jest.requireActual('@metamask/utils'), + formatAccountToCaipAccountId: jest + .fn() + .mockReturnValue('eip155:1:0x1234567890123456789012345678901234567890'), +})); + +// Mock EligibilityService as a class with instance methods +const mockEligibilityServiceInstance = { + checkEligibility: jest.fn().mockResolvedValue(true), +}; +jest.mock('../../src/services/EligibilityService', () => ({ + EligibilityService: jest + .fn() + .mockImplementation(() => mockEligibilityServiceInstance), +})); + +// Mock DepositService as a class with instance methods +const mockDepositServiceInstance = { + prepareTransaction: jest.fn(), +}; +jest.mock('../../src/services/DepositService', () => ({ + DepositService: jest + .fn() + .mockImplementation(() => mockDepositServiceInstance), +})); + +// Mock MarketDataService as a class with instance methods +const mockMarketDataServiceInstance = { + getPositions: jest.fn(), + getAccountState: jest.fn(), + getMarkets: jest.fn(), + getWithdrawalRoutes: jest.fn().mockReturnValue([]), + validateClosePosition: jest.fn().mockResolvedValue({ isValid: true }), + validateOrder: jest.fn(), + calculateMaintenanceMargin: jest.fn().mockResolvedValue(0), + calculateLiquidationPrice: jest.fn(), + getMaxLeverage: jest.fn(), + calculateFees: jest.fn().mockResolvedValue({ totalFee: 0 }), + previewPositionModify: jest.fn(), + getAvailableDexs: jest.fn().mockResolvedValue([]), + getBlockExplorerUrl: jest.fn(), + getOrderFills: jest.fn(), + getOrders: jest.fn(), + getFunding: jest.fn(), +}; +jest.mock('../../src/services/MarketDataService', () => ({ + MarketDataService: jest + .fn() + .mockImplementation(() => mockMarketDataServiceInstance), +})); + +// Mock TradingService as a class with instance methods +const mockTradingServiceInstance = { + placeOrder: jest.fn(), + editOrder: jest.fn(), + cancelOrder: jest.fn(), + cancelOrders: jest.fn(), + closePosition: jest.fn(), + closePositions: jest.fn(), + updatePositionTPSL: jest.fn(), + updateMargin: jest.fn(), + flipPosition: jest.fn(), + setControllerDependencies: jest.fn(), +}; +jest.mock('../../src/services/TradingService', () => ({ + TradingService: jest + .fn() + .mockImplementation(() => mockTradingServiceInstance), +})); + +// Mock AccountService as a class with instance methods +const mockAccountServiceInstance = { + withdraw: jest.fn(), + validateWithdrawal: jest.fn(), +}; +jest.mock('../../src/services/AccountService', () => ({ + AccountService: jest + .fn() + .mockImplementation(() => mockAccountServiceInstance), +})); + +// Mock DataLakeService as a class with instance methods +const mockDataLakeServiceInstance = { + reportOrder: jest.fn(), +}; +jest.mock('../../src/services/DataLakeService', () => ({ + DataLakeService: jest + .fn() + .mockImplementation(() => mockDataLakeServiceInstance), +})); + +// Mock FeatureFlagConfigurationService as a class with instance methods +const mockFeatureFlagConfigurationServiceInstance = { + refreshEligibility: jest.fn((options: any) => { + // Simulate the service's behavior: extract blocked regions from remote flags + const remoteFlags = + options.remoteFeatureFlagControllerState.remoteFeatureFlags; + const perpsGeoBlockedRegionsFeatureFlag = + remoteFlags?.perpsPerpTradingGeoBlockedCountriesV2; + const remoteBlockedRegions = + perpsGeoBlockedRegionsFeatureFlag?.blockedRegions; + + if ( + Array.isArray(remoteBlockedRegions) && + options.context.setBlockedRegionList + ) { + const currentList = options.context.getBlockedRegionList?.(); + // Never downgrade from remote to fallback + if (!currentList || currentList.source !== 'remote') { + options.context.setBlockedRegionList(remoteBlockedRegions, 'remote'); + } + } + + // Call refreshEligibility callback if available + if (options.context.refreshEligibility) { + options.context.refreshEligibility().catch(() => { + // Ignore errors in mock + }); + } + + // Also call refreshHip3Config if available + if (remoteFlags) { + mockFeatureFlagConfigurationServiceInstance.refreshHip3Config(options); + } + }), + refreshHip3Config: jest.fn(), + setBlockedRegions: jest.fn((options: any) => { + // Simulate setBlockedRegions behavior + const { list, source, context } = options; + if (context.setBlockedRegionList && context.getBlockedRegionList) { + const currentList = context.getBlockedRegionList(); + // Never downgrade from remote to fallback + if (source === 'fallback' && currentList.source === 'remote') { + return; + } + if (Array.isArray(list)) { + context.setBlockedRegionList(list, source); + } + } + + // Call refreshEligibility callback if available + if (context.refreshEligibility) { + context.refreshEligibility().catch(() => { + // Ignore errors in mock + }); + } + }), +}; +jest.mock('../../src/services/FeatureFlagConfigurationService', () => ({ + FeatureFlagConfigurationService: jest + .fn() + .mockImplementation(() => mockFeatureFlagConfigurationServiceInstance), +})); + +/** + * Testable version of PerpsController that exposes protected methods for testing. + * This follows the pattern used in RewardsController.test.ts + */ +class TestablePerpsController extends PerpsController { + /** + * Test-only method to update state directly. + * Exposed for scenarios where state needs to be manipulated + * outside the normal public API (e.g., testing error conditions). + * @param callback + */ + public testUpdate(callback: (state: PerpsControllerState) => void) { + this.update(callback); + } + + /** + * Test-only method to mark controller as initialized. + * Common test scenario that requires internal state changes. + */ + public testMarkInitialized() { + this.isInitialized = true; + this.update((state) => { + state.initializationState = InitializationState.Initialized; + }); + } + + /** + * Test-only method to set the providers map with complete providers. + * Used in most tests to inject mock providers. + * Also sets activeProviderInstance to the first provider (default provider). + * @param providers + */ + public testSetProviders(providers: Map) { + this.providers = providers; + // Set activeProviderInstance to the first provider (typically 'hyperliquid') + const firstProvider = providers.values().next().value; + if (firstProvider) { + this.activeProviderInstance = firstProvider; + } + } + + /** + * Test-only method to set the providers map with partial providers. + * Used explicitly in tests that verify error handling with incomplete providers. + * Type cast is intentional and necessary for testing graceful degradation. + * @param providers + */ + public testSetPartialProviders( + providers: Map>, + ) { + this.providers = providers as Map; + } + + /** + * Test-only method to get the providers map. + * Used to verify provider state in tests. + */ + public testGetProviders(): Map { + return this.providers; + } + + /** + * Test-only method to set initialization state. + * Allows tests to simulate both initialized and uninitialized states. + * @param value + */ + public testSetInitialized(value: boolean) { + this.isInitialized = value; + } + + /** + * Test-only method to get initialization state. + * Used to verify initialization status in tests. + */ + public testGetInitialized(): boolean { + return this.isInitialized; + } + + /** + * Test-only method to get blocked region list. + * Used to verify geo-blocking configuration in tests. + */ + public testGetBlockedRegionList(): { source: string; list: string[] } { + return this.blockedRegionList; + } + + /** + * Test-only method to set blocked region list. + * Used to test priority logic (remote vs fallback). + * @param list + * @param source + */ + public testSetBlockedRegionList( + list: string[], + source: 'remote' | 'fallback', + ) { + this.setBlockedRegionList(list, source); + } + + /** + * Test accessor for protected method refreshEligibilityOnFeatureFlagChange. + * Wrapper is necessary because protected methods can't be called from test code. + * @param remoteFlags + */ + public testRefreshEligibilityOnFeatureFlagChange(remoteFlags: any) { + this.refreshEligibilityOnFeatureFlagChange(remoteFlags); + } + + /** + * Test accessor for protected method reportOrderToDataLake. + * Wrapper is necessary because protected methods can't be called from test code. + * @param data + */ + public testReportOrderToDataLake(data: any): Promise { + return this.reportOrderToDataLake(data); + } + + public testHasStandaloneProvider(): boolean { + return this.hasStandaloneProvider(); + } + + public testRegisterMYXProvider( + MYXProvider: new (opts: Record) => PerpsProvider, + ) { + this.registerMYXProvider(MYXProvider as never); + } + + public testHandleMYXImportError(error: unknown) { + this.handleMYXImportError(error); + } +} + +describe('PerpsController', () => { + let controller: TestablePerpsController; + let mockProvider: jest.Mocked; + let mockInfrastructure: jest.Mocked; + + // Helper to mark controller as initialized for tests + const markControllerAsInitialized = () => { + controller.testMarkInitialized(); + }; + + beforeEach(() => { + jest.clearAllMocks(); + + ( + jest.requireMock('../../src/services/EligibilityService') + .EligibilityService as jest.Mock + ).mockImplementation(() => mockEligibilityServiceInstance); + ( + jest.requireMock('../../src/services/DepositService') + .DepositService as jest.Mock + ).mockImplementation(() => mockDepositServiceInstance); + ( + jest.requireMock('../../src/services/MarketDataService') + .MarketDataService as jest.Mock + ).mockImplementation(() => mockMarketDataServiceInstance); + ( + jest.requireMock('../../src/services/TradingService') + .TradingService as jest.Mock + ).mockImplementation(() => mockTradingServiceInstance); + ( + jest.requireMock('../../src/services/AccountService') + .AccountService as jest.Mock + ).mockImplementation(() => mockAccountServiceInstance); + ( + jest.requireMock('../../src/services/DataLakeService') + .DataLakeService as jest.Mock + ).mockImplementation(() => mockDataLakeServiceInstance); + ( + jest.requireMock('../../src/services/FeatureFlagConfigurationService') + .FeatureFlagConfigurationService as jest.Mock + ).mockImplementation(() => mockFeatureFlagConfigurationServiceInstance); + + mockEligibilityServiceInstance.checkEligibility.mockResolvedValue(true); + mockMarketDataServiceInstance.getPositions.mockResolvedValue([]); + mockMarketDataServiceInstance.getAccountState.mockResolvedValue({ + spendableBalance: '10000', + withdrawableBalance: '10000', + totalBalance: '10000', + marginUsed: '0', + unrealizedPnl: '0', + returnOnEquity: '0', + }); + mockMarketDataServiceInstance.getMarkets.mockResolvedValue([]); + mockMarketDataServiceInstance.getWithdrawalRoutes.mockReturnValue([]); + mockMarketDataServiceInstance.validateClosePosition.mockResolvedValue({ + isValid: true, + }); + mockMarketDataServiceInstance.calculateMaintenanceMargin.mockResolvedValue( + 0, + ); + mockMarketDataServiceInstance.calculateFees.mockResolvedValue({ + totalFee: 0, + }); + mockMarketDataServiceInstance.getAvailableDexs.mockResolvedValue([]); + + mockFeatureFlagConfigurationServiceInstance.refreshEligibility.mockImplementation( + (options: any) => { + const remoteFlags = + options.remoteFeatureFlagControllerState.remoteFeatureFlags; + const perpsGeoBlockedRegionsFeatureFlag = + remoteFlags?.perpsPerpTradingGeoBlockedCountriesV2; + const remoteBlockedRegions = + perpsGeoBlockedRegionsFeatureFlag?.blockedRegions; + + if ( + Array.isArray(remoteBlockedRegions) && + options.context.setBlockedRegionList + ) { + const currentList = options.context.getBlockedRegionList?.(); + if (!currentList || currentList.source !== 'remote') { + options.context.setBlockedRegionList( + remoteBlockedRegions, + 'remote', + ); + } + } + + if (options.context.refreshEligibility) { + options.context.refreshEligibility().catch(() => { + // Ignore errors in mock + }); + } + + if (remoteFlags) { + mockFeatureFlagConfigurationServiceInstance.refreshHip3Config( + options, + ); + } + }, + ); + mockFeatureFlagConfigurationServiceInstance.setBlockedRegions.mockImplementation( + (options: any) => { + const { list, source, context } = options; + if (context.setBlockedRegionList && context.getBlockedRegionList) { + const currentList = context.getBlockedRegionList(); + if (source === 'fallback' && currentList.source === 'remote') { + return; + } + if (Array.isArray(list)) { + context.setBlockedRegionList(list, source); + } + } + + if (context.refreshEligibility) { + context.refreshEligibility().catch(() => { + // Ignore errors in mock + }); + } + }, + ); + mockFeatureFlagConfigurationServiceInstance.refreshHip3Config.mockImplementation( + () => undefined, + ); + + // Create a fresh mock provider for each test + mockProvider = createMockHyperLiquidProvider(); + + // Add default mock return values for all provider methods + mockProvider.getPositions.mockResolvedValue([]); + mockProvider.getAccountState.mockResolvedValue({ + spendableBalance: '10000', + withdrawableBalance: '10000', + totalBalance: '10000', + marginUsed: '0', + unrealizedPnl: '0', + returnOnEquity: '0', + }); + mockProvider.getMarkets.mockResolvedValue([]); + mockProvider.getOpenOrders.mockResolvedValue([]); + mockProvider.getFunding.mockResolvedValue([]); + mockProvider.getOrderFills.mockResolvedValue([]); + mockProvider.getOrders.mockResolvedValue([]); + mockProvider.calculateLiquidationPrice.mockResolvedValue('0'); + mockProvider.getMaxLeverage.mockResolvedValue(50); + mockProvider.calculateMaintenanceMargin.mockResolvedValue(0); + mockProvider.calculateFees.mockResolvedValue({ feeAmount: 0 }); + mockProvider.getBlockExplorerUrl.mockReturnValue( + 'https://explorer.example.com', + ); + mockProvider.getWithdrawalRoutes.mockReturnValue([]); + + ( + HyperLiquidProvider as jest.MockedClass + ).mockImplementation(() => mockProvider); + + const mockCall = jest.fn().mockImplementation((action: string) => { + if (action === 'RemoteFeatureFlagController:getState') { + return { + remoteFeatureFlags: { + perpsPerpTradingGeoBlockedCountriesV2: { + blockedRegions: [], + }, + }, + }; + } + if ( + action === 'AccountTreeController:getAccountsFromSelectedAccountGroup' + ) { + return [ + { + address: '0x1234567890123456789012345678901234567890', + type: 'eip155:eoa', + id: 'account-1', + options: {}, + scopes: ['eip155:1'], + methods: [], + metadata: { + name: 'Test', + importTime: 0, + keyring: { type: 'HD Key Tree' }, + }, + }, + ]; + } + return undefined; + }); + + mockInfrastructure = createMockInfrastructure(); + controller = new TestablePerpsController({ + messenger: createMockMessenger({ call: mockCall }), + state: getDefaultPerpsControllerState(), + infrastructure: mockInfrastructure, + }); + }); + + afterEach(() => { + // Clear only provider mocks, not Engine.context mocks + // This prevents breaking Engine.context.RewardsController/NetworkController references + if (mockProvider) { + Object.values(mockProvider).forEach((value) => { + if ( + typeof value === 'object' && + value !== null && + 'mockClear' in value + ) { + (value as jest.Mock).mockClear(); + } + }); + } + (mockInfrastructure.metrics.trackPerpsEvent as jest.Mock).mockClear(); + (mockInfrastructure.logger.error as jest.Mock).mockClear(); + (mockInfrastructure.debugLogger.log as jest.Mock).mockClear(); + }); + describe('subscribeToPositions', () => { + it('subscribes to position updates', () => { + const mockUnsubscribe = jest.fn(); + const params = { + callback: jest.fn(), + }; + + markControllerAsInitialized(); + controller.testSetProviders(new Map([['hyperliquid', mockProvider]])); + mockProvider.subscribeToPositions.mockReturnValue(mockUnsubscribe); + + const unsubscribe = controller.subscribeToPositions(params); + + expect(unsubscribe).toBe(mockUnsubscribe); + expect(mockProvider.subscribeToPositions).toHaveBeenCalledWith(params); + }); + }); + + describe('withdraw', () => { + it('withdraws successfully', async () => { + const withdrawParams = { + amount: '100', + destination: + '0x1234567890123456789012345678901234567890' as `0x${string}`, + assetId: + 'eip155:42161/erc20:0xaf88d065e77c8cc2239327c5edb3a432268e5831' as `${string}:${string}/${string}:${string}/${string}`, + }; + + const mockWithdrawResult = { + success: true, + txHash: '0xabcdef1234567890', + withdrawalId: 'withdrawal-123', + }; + + markControllerAsInitialized(); + controller.testSetProviders(new Map([['hyperliquid', mockProvider]])); + jest + .spyOn(mockAccountServiceInstance, 'withdraw') + .mockResolvedValue(mockWithdrawResult); + + const result = await controller.withdraw(withdrawParams); + + expect(result).toEqual(mockWithdrawResult); + expect(mockAccountServiceInstance.withdraw).toHaveBeenCalledWith({ + provider: mockProvider, + params: withdrawParams, + context: expect.objectContaining({ + tracingContext: expect.any(Object), + errorContext: expect.objectContaining({ method: 'withdraw' }), + stateManager: expect.any(Object), + }), + refreshAccountState: expect.any(Function), + }); + }); + }); + + describe('calculateLiquidationPrice', () => { + it('calculates liquidation price successfully', async () => { + const liquidationParams = { + entryPrice: 50000, + leverage: 10, + direction: 'long' as const, + positionSize: 1, + marginType: 'isolated' as const, + asset: 'BTC', + }; + + const mockLiquidationPrice = '45000'; + + markControllerAsInitialized(); + controller.testSetProviders(new Map([['hyperliquid', mockProvider]])); + jest + .spyOn(mockMarketDataServiceInstance, 'calculateLiquidationPrice') + .mockResolvedValue(mockLiquidationPrice); + + const result = + await controller.calculateLiquidationPrice(liquidationParams); + + expect(result).toBe(mockLiquidationPrice); + expect( + mockMarketDataServiceInstance.calculateLiquidationPrice, + ).toHaveBeenCalledWith({ + provider: mockProvider, + params: liquidationParams, + context: expect.any(Object), + }); + }); + }); + + describe('previewPositionModify', () => { + it('delegates to MarketDataService', async () => { + const params = { + position: createMockPosition({ + leverage: { type: 'isolated' as const, value: 5 }, + }), + direction: 'long' as const, + size: '0.1', + price: '50000', + leverage: 10, + }; + + markControllerAsInitialized(); + controller.testSetProviders(new Map([['hyperliquid', mockProvider]])); + jest + .spyOn(mockMarketDataServiceInstance, 'previewPositionModify') + .mockResolvedValue({ status: 'none' }); + + const result = await controller.previewPositionModify(params); + + expect(result).toEqual({ status: 'none' }); + expect( + mockMarketDataServiceInstance.previewPositionModify, + ).toHaveBeenCalledWith({ + provider: mockProvider, + params, + context: expect.any(Object), + }); + }); + }); + + describe('getMaxLeverage', () => { + it('gets max leverage successfully', async () => { + const asset = 'BTC'; + const mockMaxLeverage = 50; + + markControllerAsInitialized(); + controller.testSetProviders(new Map([['hyperliquid', mockProvider]])); + jest + .spyOn(mockMarketDataServiceInstance, 'getMaxLeverage') + .mockResolvedValue(mockMaxLeverage); + + const result = await controller.getMaxLeverage(asset); + + expect(result).toBe(mockMaxLeverage); + expect(mockMarketDataServiceInstance.getMaxLeverage).toHaveBeenCalledWith( + { + provider: mockProvider, + asset, + context: expect.any(Object), + }, + ); + }); + }); + + describe('getWithdrawalRoutes', () => { + it('gets withdrawal routes successfully', () => { + const mockRoutes = [ + { + assetId: + 'eip155:42161/erc20:0xaf88d065e77c8cc2239327c5edb3a432268e5831' as `${string}:${string}/${string}:${string}/${string}`, + chainId: 'eip155:42161' as `${string}:${string}`, + contractAddress: + '0x1234567890123456789012345678901234567890' as `0x${string}`, + constraints: { + minAmount: '10', + maxAmount: '1000000', + }, + }, + ]; + + markControllerAsInitialized(); + controller.testSetProviders(new Map([['hyperliquid', mockProvider]])); + jest + .spyOn(mockMarketDataServiceInstance, 'getWithdrawalRoutes') + .mockReturnValue(mockRoutes); + + const result = controller.getWithdrawalRoutes(); + + expect(result).toEqual(mockRoutes); + expect( + mockMarketDataServiceInstance.getWithdrawalRoutes, + ).toHaveBeenCalledWith({ + provider: mockProvider, + }); + }); + }); + + describe('getBlockExplorerUrl', () => { + it('gets block explorer URL successfully', () => { + const address = '0x1234567890123456789012345678901234567890'; + const mockUrl = + 'https://app.hyperliquid.xyz/explorer/address/0x1234567890123456789012345678901234567890'; + + markControllerAsInitialized(); + controller.testSetProviders(new Map([['hyperliquid', mockProvider]])); + jest + .spyOn(mockMarketDataServiceInstance, 'getBlockExplorerUrl') + .mockReturnValue(mockUrl); + + const result = controller.getBlockExplorerUrl(address); + + expect(result).toBe(mockUrl); + expect( + mockMarketDataServiceInstance.getBlockExplorerUrl, + ).toHaveBeenCalledWith({ + provider: mockProvider, + address, + }); + }); + }); + + describe('error handling', () => { + it('handles provider errors gracefully', async () => { + const errorMessage = 'Provider connection failed'; + + markControllerAsInitialized(); + controller.testSetProviders(new Map([['hyperliquid', mockProvider]])); + jest + .spyOn(mockMarketDataServiceInstance, 'getPositions') + .mockRejectedValue(new Error(errorMessage)); + + await expect(controller.getPositions()).rejects.toThrow(errorMessage); + expect(mockMarketDataServiceInstance.getPositions).toHaveBeenCalled(); + }); + + it('handles network errors', async () => { + const errorMessage = 'Network timeout'; + + markControllerAsInitialized(); + controller.testSetProviders(new Map([['hyperliquid', mockProvider]])); + jest + .spyOn(mockMarketDataServiceInstance, 'getAccountState') + .mockRejectedValue(new Error(errorMessage)); + + await expect(controller.getAccountState()).rejects.toThrow(errorMessage); + expect(mockMarketDataServiceInstance.getAccountState).toHaveBeenCalled(); + }); + }); +}); diff --git a/packages/perps-controller/tests/src/PerpsController.trading.test.ts b/packages/perps-controller/tests/src/PerpsController.trading.test.ts new file mode 100644 index 00000000000..c6e39fc4497 --- /dev/null +++ b/packages/perps-controller/tests/src/PerpsController.trading.test.ts @@ -0,0 +1,1099 @@ +/* eslint-disable */ +/** + * PerpsController Tests + * Clean, focused test suite for PerpsController + */ + +/* eslint-disable @typescript-eslint/no-explicit-any */ + +import { createMockHyperLiquidProvider } from '../helpers/providerMocks.js'; +import { + createMockInfrastructure, + createMockMessenger, +} from '../helpers/serviceMocks.js'; + +jest.mock('@nktkas/hyperliquid', () => ({})); +jest.mock('@myx-trade/sdk', () => ({ + MyxClient: jest.fn(), + OrderStatusEnum: { Successful: 9 }, +})); + +import { + PerpsController, + getDefaultPerpsControllerState, + InitializationState, +} from '../../src/PerpsController.js'; +import type { PerpsControllerState } from '../../src/PerpsController.js'; +import { HyperLiquidProvider } from '../../src/providers/HyperLiquidProvider.js'; +import type { + PerpsProvider, + PerpsPlatformDependencies, + PerpsProviderType, + TwapOrder, +} from '../../src/types/index.js'; + +jest.mock('../../src/providers/HyperLiquidProvider'); +jest.mock('../../src/providers/MYXProvider'); + +// Mock transaction controller utility +const mockAddTransaction = jest.fn(); +jest.mock( + '../../../util/transaction-controller', + () => ({ + addTransaction: (...args: unknown[]) => mockAddTransaction(...args), + }), + { virtual: true }, +); + +// Mock wait utility to speed up retry tests +jest.mock('../../src/utils/wait', () => ({ + wait: jest.fn().mockResolvedValue(undefined), +})); + +// Mock stream manager +const mockStreamManager = { + positions: { pause: jest.fn(), resume: jest.fn() }, + account: { pause: jest.fn(), resume: jest.fn() }, + orders: { pause: jest.fn(), resume: jest.fn() }, + prices: { pause: jest.fn(), resume: jest.fn() }, + orderFills: { pause: jest.fn(), resume: jest.fn() }, +}; + +jest.mock( + '../../../components/UI/Perps/providers/PerpsStreamManager', + () => ({ + getStreamManagerInstance: jest.fn(() => mockStreamManager), + }), + { virtual: true }, +); + +jest.mock('@metamask/utils', () => ({ + ...jest.requireActual('@metamask/utils'), + formatAccountToCaipAccountId: jest + .fn() + .mockReturnValue('eip155:1:0x1234567890123456789012345678901234567890'), +})); + +// Mock EligibilityService as a class with instance methods +const mockEligibilityServiceInstance = { + checkEligibility: jest.fn().mockResolvedValue(true), +}; +jest.mock('../../src/services/EligibilityService', () => ({ + EligibilityService: jest + .fn() + .mockImplementation(() => mockEligibilityServiceInstance), +})); + +// Mock DepositService as a class with instance methods +const mockDepositServiceInstance = { + prepareTransaction: jest.fn(), +}; +jest.mock('../../src/services/DepositService', () => ({ + DepositService: jest + .fn() + .mockImplementation(() => mockDepositServiceInstance), +})); + +// Mock MarketDataService as a class with instance methods +const mockMarketDataServiceInstance = { + getPositions: jest.fn(), + getAccountState: jest.fn(), + getMarkets: jest.fn(), + getWithdrawalRoutes: jest.fn().mockReturnValue([]), + validateClosePosition: jest.fn().mockResolvedValue({ isValid: true }), + validateOrder: jest.fn(), + calculateMaintenanceMargin: jest.fn().mockResolvedValue(0), + calculateLiquidationPrice: jest.fn(), + getMaxLeverage: jest.fn(), + calculateFees: jest.fn().mockResolvedValue({ totalFee: 0 }), + previewPositionModify: jest.fn(), + getAvailableDexs: jest.fn().mockResolvedValue([]), + getBlockExplorerUrl: jest.fn(), + getOrderFills: jest.fn(), + getOrders: jest.fn(), + getFunding: jest.fn(), +}; +jest.mock('../../src/services/MarketDataService', () => ({ + MarketDataService: jest + .fn() + .mockImplementation(() => mockMarketDataServiceInstance), +})); + +// Mock TradingService as a class with instance methods +const mockTradingServiceInstance = { + placeOrder: jest.fn(), + editOrder: jest.fn(), + cancelOrder: jest.fn(), + cancelOrders: jest.fn(), + closePosition: jest.fn(), + closePositions: jest.fn(), + updatePositionTPSL: jest.fn(), + updateMargin: jest.fn(), + flipPosition: jest.fn(), + setControllerDependencies: jest.fn(), +}; +jest.mock('../../src/services/TradingService', () => ({ + TradingService: jest + .fn() + .mockImplementation(() => mockTradingServiceInstance), +})); + +// Mock AccountService as a class with instance methods +const mockAccountServiceInstance = { + withdraw: jest.fn(), + validateWithdrawal: jest.fn(), +}; +jest.mock('../../src/services/AccountService', () => ({ + AccountService: jest + .fn() + .mockImplementation(() => mockAccountServiceInstance), +})); + +// Mock DataLakeService as a class with instance methods +const mockDataLakeServiceInstance = { + reportOrder: jest.fn(), +}; +jest.mock('../../src/services/DataLakeService', () => ({ + DataLakeService: jest + .fn() + .mockImplementation(() => mockDataLakeServiceInstance), +})); + +// Mock FeatureFlagConfigurationService as a class with instance methods +const mockFeatureFlagConfigurationServiceInstance = { + refreshEligibility: jest.fn((options: any) => { + // Simulate the service's behavior: extract blocked regions from remote flags + const remoteFlags = + options.remoteFeatureFlagControllerState.remoteFeatureFlags; + const perpsGeoBlockedRegionsFeatureFlag = + remoteFlags?.perpsPerpTradingGeoBlockedCountriesV2; + const remoteBlockedRegions = + perpsGeoBlockedRegionsFeatureFlag?.blockedRegions; + + if ( + Array.isArray(remoteBlockedRegions) && + options.context.setBlockedRegionList + ) { + const currentList = options.context.getBlockedRegionList?.(); + // Never downgrade from remote to fallback + if (!currentList || currentList.source !== 'remote') { + options.context.setBlockedRegionList(remoteBlockedRegions, 'remote'); + } + } + + // Call refreshEligibility callback if available + if (options.context.refreshEligibility) { + options.context.refreshEligibility().catch(() => { + // Ignore errors in mock + }); + } + + // Also call refreshHip3Config if available + if (remoteFlags) { + mockFeatureFlagConfigurationServiceInstance.refreshHip3Config(options); + } + }), + refreshHip3Config: jest.fn(), + setBlockedRegions: jest.fn((options: any) => { + // Simulate setBlockedRegions behavior + const { list, source, context } = options; + if (context.setBlockedRegionList && context.getBlockedRegionList) { + const currentList = context.getBlockedRegionList(); + // Never downgrade from remote to fallback + if (source === 'fallback' && currentList.source === 'remote') { + return; + } + if (Array.isArray(list)) { + context.setBlockedRegionList(list, source); + } + } + + // Call refreshEligibility callback if available + if (context.refreshEligibility) { + context.refreshEligibility().catch(() => { + // Ignore errors in mock + }); + } + }), +}; +jest.mock('../../src/services/FeatureFlagConfigurationService', () => ({ + FeatureFlagConfigurationService: jest + .fn() + .mockImplementation(() => mockFeatureFlagConfigurationServiceInstance), +})); + +/** + * Testable version of PerpsController that exposes protected methods for testing. + * This follows the pattern used in RewardsController.test.ts + */ +class TestablePerpsController extends PerpsController { + /** + * Test-only method to update state directly. + * Exposed for scenarios where state needs to be manipulated + * outside the normal public API (e.g., testing error conditions). + * @param callback + */ + public testUpdate(callback: (state: PerpsControllerState) => void) { + this.update(callback); + } + + /** + * Test-only method to mark controller as initialized. + * Common test scenario that requires internal state changes. + */ + public testMarkInitialized() { + this.isInitialized = true; + this.update((state) => { + state.initializationState = InitializationState.Initialized; + }); + } + + /** + * Test-only method to set the providers map with complete providers. + * Used in most tests to inject mock providers. + * Also sets activeProviderInstance to the first provider (default provider). + * @param providers + */ + public testSetProviders(providers: Map) { + this.providers = providers; + // Set activeProviderInstance to the first provider (typically 'hyperliquid') + const firstProvider = providers.values().next().value; + if (firstProvider) { + this.activeProviderInstance = firstProvider; + } + } + + /** + * Test-only method to set the providers map with partial providers. + * Used explicitly in tests that verify error handling with incomplete providers. + * Type cast is intentional and necessary for testing graceful degradation. + * @param providers + */ + public testSetPartialProviders( + providers: Map>, + ) { + this.providers = providers as Map; + } + + /** + * Test-only method to get the providers map. + * Used to verify provider state in tests. + */ + public testGetProviders(): Map { + return this.providers; + } + + /** + * Test-only method to set initialization state. + * Allows tests to simulate both initialized and uninitialized states. + * @param value + */ + public testSetInitialized(value: boolean) { + this.isInitialized = value; + } + + /** + * Test-only method to get initialization state. + * Used to verify initialization status in tests. + */ + public testGetInitialized(): boolean { + return this.isInitialized; + } + + /** + * Test-only method to get blocked region list. + * Used to verify geo-blocking configuration in tests. + */ + public testGetBlockedRegionList(): { source: string; list: string[] } { + return this.blockedRegionList; + } + + /** + * Test-only method to set blocked region list. + * Used to test priority logic (remote vs fallback). + * @param list + * @param source + */ + public testSetBlockedRegionList( + list: string[], + source: 'remote' | 'fallback', + ) { + this.setBlockedRegionList(list, source); + } + + /** + * Test accessor for protected method refreshEligibilityOnFeatureFlagChange. + * Wrapper is necessary because protected methods can't be called from test code. + * @param remoteFlags + */ + public testRefreshEligibilityOnFeatureFlagChange(remoteFlags: any) { + this.refreshEligibilityOnFeatureFlagChange(remoteFlags); + } + + /** + * Test accessor for protected method reportOrderToDataLake. + * Wrapper is necessary because protected methods can't be called from test code. + * @param data + */ + public testReportOrderToDataLake(data: any): Promise { + return this.reportOrderToDataLake(data); + } + + public testHasStandaloneProvider(): boolean { + return this.hasStandaloneProvider(); + } + + public testRegisterMYXProvider( + MYXProvider: new (opts: Record) => PerpsProvider, + ) { + this.registerMYXProvider(MYXProvider as never); + } + + public testHandleMYXImportError(error: unknown) { + this.handleMYXImportError(error); + } +} + +describe('PerpsController', () => { + let controller: TestablePerpsController; + let mockProvider: jest.Mocked; + let mockInfrastructure: jest.Mocked; + + // Helper to mark controller as initialized for tests + const markControllerAsInitialized = () => { + controller.testMarkInitialized(); + }; + + beforeEach(() => { + jest.clearAllMocks(); + + ( + jest.requireMock('../../src/services/EligibilityService') + .EligibilityService as jest.Mock + ).mockImplementation(() => mockEligibilityServiceInstance); + ( + jest.requireMock('../../src/services/DepositService') + .DepositService as jest.Mock + ).mockImplementation(() => mockDepositServiceInstance); + ( + jest.requireMock('../../src/services/MarketDataService') + .MarketDataService as jest.Mock + ).mockImplementation(() => mockMarketDataServiceInstance); + ( + jest.requireMock('../../src/services/TradingService') + .TradingService as jest.Mock + ).mockImplementation(() => mockTradingServiceInstance); + ( + jest.requireMock('../../src/services/AccountService') + .AccountService as jest.Mock + ).mockImplementation(() => mockAccountServiceInstance); + ( + jest.requireMock('../../src/services/DataLakeService') + .DataLakeService as jest.Mock + ).mockImplementation(() => mockDataLakeServiceInstance); + ( + jest.requireMock('../../src/services/FeatureFlagConfigurationService') + .FeatureFlagConfigurationService as jest.Mock + ).mockImplementation(() => mockFeatureFlagConfigurationServiceInstance); + + mockEligibilityServiceInstance.checkEligibility.mockResolvedValue(true); + mockMarketDataServiceInstance.getPositions.mockResolvedValue([]); + mockMarketDataServiceInstance.getAccountState.mockResolvedValue({ + spendableBalance: '10000', + withdrawableBalance: '10000', + totalBalance: '10000', + marginUsed: '0', + unrealizedPnl: '0', + returnOnEquity: '0', + }); + mockMarketDataServiceInstance.getMarkets.mockResolvedValue([]); + mockMarketDataServiceInstance.getWithdrawalRoutes.mockReturnValue([]); + mockMarketDataServiceInstance.validateClosePosition.mockResolvedValue({ + isValid: true, + }); + mockMarketDataServiceInstance.calculateMaintenanceMargin.mockResolvedValue( + 0, + ); + mockMarketDataServiceInstance.calculateFees.mockResolvedValue({ + totalFee: 0, + }); + mockMarketDataServiceInstance.getAvailableDexs.mockResolvedValue([]); + + mockFeatureFlagConfigurationServiceInstance.refreshEligibility.mockImplementation( + (options: any) => { + const remoteFlags = + options.remoteFeatureFlagControllerState.remoteFeatureFlags; + const perpsGeoBlockedRegionsFeatureFlag = + remoteFlags?.perpsPerpTradingGeoBlockedCountriesV2; + const remoteBlockedRegions = + perpsGeoBlockedRegionsFeatureFlag?.blockedRegions; + + if ( + Array.isArray(remoteBlockedRegions) && + options.context.setBlockedRegionList + ) { + const currentList = options.context.getBlockedRegionList?.(); + if (!currentList || currentList.source !== 'remote') { + options.context.setBlockedRegionList( + remoteBlockedRegions, + 'remote', + ); + } + } + + if (options.context.refreshEligibility) { + options.context.refreshEligibility().catch(() => { + // Ignore errors in mock + }); + } + + if (remoteFlags) { + mockFeatureFlagConfigurationServiceInstance.refreshHip3Config( + options, + ); + } + }, + ); + mockFeatureFlagConfigurationServiceInstance.setBlockedRegions.mockImplementation( + (options: any) => { + const { list, source, context } = options; + if (context.setBlockedRegionList && context.getBlockedRegionList) { + const currentList = context.getBlockedRegionList(); + if (source === 'fallback' && currentList.source === 'remote') { + return; + } + if (Array.isArray(list)) { + context.setBlockedRegionList(list, source); + } + } + + if (context.refreshEligibility) { + context.refreshEligibility().catch(() => { + // Ignore errors in mock + }); + } + }, + ); + mockFeatureFlagConfigurationServiceInstance.refreshHip3Config.mockImplementation( + () => undefined, + ); + + // Create a fresh mock provider for each test + mockProvider = createMockHyperLiquidProvider(); + + // Add default mock return values for all provider methods + mockProvider.getPositions.mockResolvedValue([]); + mockProvider.getAccountState.mockResolvedValue({ + spendableBalance: '10000', + withdrawableBalance: '10000', + totalBalance: '10000', + marginUsed: '0', + unrealizedPnl: '0', + returnOnEquity: '0', + }); + mockProvider.getMarkets.mockResolvedValue([]); + mockProvider.getOpenOrders.mockResolvedValue([]); + mockProvider.getFunding.mockResolvedValue([]); + mockProvider.getTwapOrders.mockResolvedValue([]); + mockProvider.getOrderFills.mockResolvedValue([]); + mockProvider.getOrders.mockResolvedValue([]); + mockProvider.calculateLiquidationPrice.mockResolvedValue('0'); + mockProvider.getMaxLeverage.mockResolvedValue(50); + mockProvider.calculateMaintenanceMargin.mockResolvedValue(0); + mockProvider.calculateFees.mockResolvedValue({ feeAmount: 0 }); + mockProvider.getBlockExplorerUrl.mockReturnValue( + 'https://explorer.example.com', + ); + mockProvider.getWithdrawalRoutes.mockReturnValue([]); + + ( + HyperLiquidProvider as jest.MockedClass + ).mockImplementation(() => mockProvider); + + const mockCall = jest.fn().mockImplementation((action: string) => { + if (action === 'RemoteFeatureFlagController:getState') { + return { + remoteFeatureFlags: { + perpsPerpTradingGeoBlockedCountriesV2: { + blockedRegions: [], + }, + }, + }; + } + if ( + action === 'AccountTreeController:getAccountsFromSelectedAccountGroup' + ) { + return [ + { + address: '0x1234567890123456789012345678901234567890', + type: 'eip155:eoa', + id: 'account-1', + options: {}, + scopes: ['eip155:1'], + methods: [], + metadata: { + name: 'Test', + importTime: 0, + keyring: { type: 'HD Key Tree' }, + }, + }, + ]; + } + return undefined; + }); + + mockInfrastructure = createMockInfrastructure(); + controller = new TestablePerpsController({ + messenger: createMockMessenger({ call: mockCall }), + state: getDefaultPerpsControllerState(), + infrastructure: mockInfrastructure, + }); + }); + + afterEach(() => { + // Clear only provider mocks, not Engine.context mocks + // This prevents breaking Engine.context.RewardsController/NetworkController references + if (mockProvider) { + Object.values(mockProvider).forEach((value) => { + if ( + typeof value === 'object' && + value !== null && + 'mockClear' in value + ) { + (value as jest.Mock).mockClear(); + } + }); + } + (mockInfrastructure.metrics.trackPerpsEvent as jest.Mock).mockClear(); + (mockInfrastructure.logger.error as jest.Mock).mockClear(); + (mockInfrastructure.debugLogger.log as jest.Mock).mockClear(); + }); + describe('getPositions', () => { + it('gets positions successfully', async () => { + const mockPositions = [ + { + symbol: 'ETH', + size: '2.5', + entryPrice: '2000', + positionValue: '5000', + unrealizedPnl: '500', + marginUsed: '2500', + leverage: { type: 'cross' as const, value: 2 }, + liquidationPrice: '1500', + maxLeverage: 100, + returnOnEquity: '10.0', + cumulativeFunding: { + allTime: '10', + sinceOpen: '5', + sinceChange: '2', + }, + takeProfitCount: 0, + stopLossCount: 0, + }, + ]; + + markControllerAsInitialized(); + controller.testSetProviders(new Map([['hyperliquid', mockProvider]])); + jest + .spyOn(mockMarketDataServiceInstance, 'getPositions') + .mockResolvedValue(mockPositions); + + const result = await controller.getPositions(); + + expect(result).toEqual(mockPositions); + expect(mockMarketDataServiceInstance.getPositions).toHaveBeenCalledWith({ + provider: mockProvider, + params: undefined, + context: expect.any(Object), + }); + }); + + it('handles getPositions error', async () => { + const errorMessage = 'Network error'; + + markControllerAsInitialized(); + controller.testSetProviders(new Map([['hyperliquid', mockProvider]])); + jest + .spyOn(mockMarketDataServiceInstance, 'getPositions') + .mockRejectedValue(new Error(errorMessage)); + + await expect(controller.getPositions()).rejects.toThrow(errorMessage); + expect(mockMarketDataServiceInstance.getPositions).toHaveBeenCalled(); + }); + }); + + describe('getAccountState', () => { + it('gets account state successfully', async () => { + const mockAccountState = { + spendableBalance: '1000', + withdrawableBalance: '1000', + marginUsed: '500', + unrealizedPnl: '100', + returnOnEquity: '20.0', + totalBalance: '1600', + }; + + markControllerAsInitialized(); + controller.testSetProviders(new Map([['hyperliquid', mockProvider]])); + jest + .spyOn(mockMarketDataServiceInstance, 'getAccountState') + .mockResolvedValue(mockAccountState); + + const result = await controller.getAccountState(); + + expect(result).toEqual(mockAccountState); + expect( + mockMarketDataServiceInstance.getAccountState, + ).toHaveBeenCalledWith({ + provider: mockProvider, + params: undefined, + context: expect.any(Object), + }); + }); + }); + + describe('placeOrder', () => { + it('places order successfully', async () => { + const orderParams = { + symbol: 'BTC', + isBuy: true, + size: '0.1', + orderType: 'market' as const, + }; + + const mockOrderResult = { + success: true, + orderId: 'order-123', + filledSize: '0.1', + averagePrice: '50000', + }; + + markControllerAsInitialized(); + controller.testSetProviders(new Map([['hyperliquid', mockProvider]])); + jest + .spyOn(mockTradingServiceInstance, 'placeOrder') + .mockResolvedValue(mockOrderResult); + + const result = await controller.placeOrder(orderParams); + + expect(result).toEqual(mockOrderResult); + expect(mockTradingServiceInstance.placeOrder).toHaveBeenCalledWith( + expect.objectContaining({ + provider: mockProvider, + params: orderParams, + context: expect.any(Object), + }), + ); + }); + + it('handles placeOrder error', async () => { + const orderParams = { + symbol: 'BTC', + isBuy: true, + size: '0.1', + orderType: 'market' as const, + }; + + const errorMessage = 'Order placement failed'; + + markControllerAsInitialized(); + controller.testSetProviders(new Map([['hyperliquid', mockProvider]])); + jest + .spyOn(mockTradingServiceInstance, 'placeOrder') + .mockRejectedValue(new Error(errorMessage)); + + await expect(controller.placeOrder(orderParams)).rejects.toThrow( + errorMessage, + ); + expect(mockTradingServiceInstance.placeOrder).toHaveBeenCalled(); + }); + }); + + describe('getMarkets', () => { + it('gets markets successfully', async () => { + const mockMarkets = [ + { + name: 'BTC', + szDecimals: 3, + maxLeverage: 50, + marginTableId: 1, + }, + { + name: 'ETH', + szDecimals: 2, + maxLeverage: 25, + marginTableId: 2, + }, + ]; + + markControllerAsInitialized(); + controller.testSetProviders(new Map([['hyperliquid', mockProvider]])); + jest + .spyOn(mockMarketDataServiceInstance, 'getMarkets') + .mockResolvedValue(mockMarkets); + + const result = await controller.getMarkets(); + + expect(result).toEqual(mockMarkets); + expect(mockMarketDataServiceInstance.getMarkets).toHaveBeenCalledWith({ + provider: mockProvider, + params: undefined, + context: expect.any(Object), + isMarketAllowed: expect.any(Function), + }); + }); + }); + + describe('cancelOrder', () => { + it('cancels order successfully', async () => { + const cancelParams = { + orderId: 'order-123', + symbol: 'BTC', + }; + + const mockCancelResult = { + success: true, + orderId: 'order-123', + }; + + markControllerAsInitialized(); + controller.testSetProviders(new Map([['hyperliquid', mockProvider]])); + jest + .spyOn(mockTradingServiceInstance, 'cancelOrder') + .mockResolvedValue(mockCancelResult); + + const result = await controller.cancelOrder(cancelParams); + + expect(result).toEqual(mockCancelResult); + expect(mockTradingServiceInstance.cancelOrder).toHaveBeenCalledWith( + expect.objectContaining({ + provider: mockProvider, + params: cancelParams, + context: expect.any(Object), + }), + ); + }); + }); + + describe('Strategy lifecycle', () => { + const twapOrder: TwapOrder = { + orderId: '987', + symbol: 'ETH', + side: 'buy', + size: '1', + executedSize: '0.4', + remainingSize: '0.6', + executedNotional: '1200', + averagePrice: '3000', + fillProgressBps: 4000, + timeProgressBps: 5000, + elapsedTimeMilliseconds: 300_000, + durationMinutes: 10, + randomize: true, + reduceOnly: false, + status: 'active', + startedAt: 1, + lastUpdated: 2, + fills: [], + }; + + const chaseOrder = { + handle: 'chase-1', + symbol: 'ETH', + side: 'buy' as const, + originalSize: '1', + remainingSize: '1', + arrivalPrice: '2999.1', + restingPrice: '2999.1', + restingOrderId: '55', + distanceChasedBps: 0, + repricings: 0, + startedAt: 1, + status: 'active' as const, + }; + + it('reads Chase lifecycle state from the active provider', async () => { + markControllerAsInitialized(); + controller.testSetProviders(new Map([['hyperliquid', mockProvider]])); + mockProvider.getChaseOrders.mockResolvedValue([chaseOrder]); + + await expect(controller.getChaseOrders()).resolves.toStrictEqual([ + chaseOrder, + ]); + }); + + it('reads TWAP lifecycle state from the active provider', async () => { + markControllerAsInitialized(); + controller.testSetProviders(new Map([['hyperliquid', mockProvider]])); + mockProvider.getTwapOrders.mockResolvedValue([twapOrder]); + + expect(await controller.getTwapOrders()).toStrictEqual([twapOrder]); + }); + + it('suspends Chase loops through the active provider', async () => { + markControllerAsInitialized(); + controller.testSetProviders(new Map([['hyperliquid', mockProvider]])); + const backgrounded = { ...chaseOrder, status: 'backgrounded' as const }; + mockProvider.suspendChaseOrders.mockResolvedValue([backgrounded]); + + await expect(controller.suspendChaseOrders()).resolves.toStrictEqual([ + backgrounded, + ]); + }); + }); + + describe('cancelOrders', () => { + it('delegates to TradingService with withStreamPause callback', async () => { + markControllerAsInitialized(); + controller.testSetProviders(new Map([['hyperliquid', mockProvider]])); + + const mockImplementation = jest.fn(async (options: any) => { + // Simulate TradingService calling the withStreamPause callback + await options.withStreamPause( + async () => ({ + success: true, + successCount: 1, + failureCount: 0, + results: [{ symbol: 'BTC', orderId: 'order-1', success: true }], + }), + ['orders'], + ); + + return { + success: true, + successCount: 1, + failureCount: 0, + results: [{ symbol: 'BTC', orderId: 'order-1', success: true }], + }; + }); + + jest + .spyOn(mockTradingServiceInstance, 'cancelOrders') + .mockImplementation(mockImplementation); + + await controller.cancelOrders({ cancelAll: true }); + + expect( + mockInfrastructure.streamManager.pauseChannel, + ).toHaveBeenCalledWith('orders'); + expect( + mockInfrastructure.streamManager.resumeChannel, + ).toHaveBeenCalledWith('orders'); + expect(mockTradingServiceInstance.cancelOrders).toHaveBeenCalledWith( + expect.objectContaining({ + provider: mockProvider, + params: { cancelAll: true }, + context: expect.any(Object), + withStreamPause: expect.any(Function), + }), + ); + }); + + it('resumes streams even when operation throws error', async () => { + markControllerAsInitialized(); + controller.testSetProviders(new Map([['hyperliquid', mockProvider]])); + + const mockImplementation = jest.fn(async (options: any) => + // Simulate TradingService calling the withStreamPause callback with an error + options.withStreamPause(async () => { + throw new Error('Network error'); + }, ['orders']), + ); + + jest + .spyOn(mockTradingServiceInstance, 'cancelOrders') + .mockImplementation(mockImplementation); + + await expect( + controller.cancelOrders({ cancelAll: true }), + ).rejects.toThrow('Network error'); + + expect( + mockInfrastructure.streamManager.pauseChannel, + ).toHaveBeenCalledWith('orders'); + expect( + mockInfrastructure.streamManager.resumeChannel, + ).toHaveBeenCalledWith('orders'); + }); + }); + + describe('closePosition', () => { + it('closes position successfully', async () => { + const closeParams = { + symbol: 'BTC', + orderType: 'market' as const, + size: '0.5', + }; + + const mockCloseResult = { + success: true, + orderId: 'close-order-123', + filledSize: '0.5', + averagePrice: '50000', + }; + + markControllerAsInitialized(); + controller.testSetProviders(new Map([['hyperliquid', mockProvider]])); + jest + .spyOn(mockTradingServiceInstance, 'closePosition') + .mockResolvedValue(mockCloseResult); + + const result = await controller.closePosition(closeParams); + + expect(result).toEqual(mockCloseResult); + expect(mockTradingServiceInstance.closePosition).toHaveBeenCalledWith( + expect.objectContaining({ + provider: mockProvider, + params: closeParams, + context: expect.any(Object), + }), + ); + }); + }); + + describe('closePositions', () => { + it('delegates to TradingService.closePositions', async () => { + markControllerAsInitialized(); + controller.testSetProviders(new Map([['hyperliquid', mockProvider]])); + + jest + .spyOn(mockTradingServiceInstance, 'closePositions') + .mockResolvedValue({ + success: true, + successCount: 1, + failureCount: 0, + results: [{ symbol: 'BTC', success: true }], + }); + + const result = await controller.closePositions({ closeAll: true }); + + expect(result.success).toBe(true); + expect(result.successCount).toBe(1); + expect(mockTradingServiceInstance.closePositions).toHaveBeenCalledWith( + expect.objectContaining({ + provider: mockProvider, + params: { closeAll: true }, + context: expect.any(Object), + }), + ); + }); + }); + + describe('validateOrder', () => { + it('validates order successfully', async () => { + const orderParams = { + symbol: 'BTC', + isBuy: true, + size: '0.1', + orderType: 'market' as const, + }; + + const mockValidationResult = { + isValid: true, + }; + + markControllerAsInitialized(); + controller.testSetProviders(new Map([['hyperliquid', mockProvider]])); + jest + .spyOn(mockMarketDataServiceInstance, 'validateOrder') + .mockResolvedValue(mockValidationResult); + + const result = await controller.validateOrder(orderParams); + + expect(result).toEqual(mockValidationResult); + expect(mockMarketDataServiceInstance.validateOrder).toHaveBeenCalledWith({ + provider: mockProvider, + params: orderParams, + context: expect.any(Object), + }); + }); + }); + + describe('getOrderFills', () => { + it('gets order fills successfully', async () => { + const mockOrderFills = [ + { + orderId: 'order-123', + symbol: 'BTC', + side: 'buy', + size: '0.1', + price: '50000', + pnl: '100', + direction: 'long', + fee: '5', + feeToken: 'USDC', + timestamp: 1640995200000, + }, + ]; + + markControllerAsInitialized(); + controller.testSetProviders(new Map([['hyperliquid', mockProvider]])); + jest + .spyOn(mockMarketDataServiceInstance, 'getOrderFills') + .mockResolvedValue(mockOrderFills); + + const result = await controller.getOrderFills(); + + expect(result).toEqual(mockOrderFills); + expect(mockMarketDataServiceInstance.getOrderFills).toHaveBeenCalledWith({ + provider: mockProvider, + params: undefined, + context: expect.any(Object), + }); + }); + }); + + describe('getOrders', () => { + it('gets orders successfully', async () => { + const mockOrders = [ + { + orderId: 'order-123', + symbol: 'BTC', + side: 'buy' as const, + orderType: 'market' as const, + size: '0.1', + originalSize: '0.1', + price: '50000', + filledSize: '0.1', + remainingSize: '0', + status: 'filled' as const, + timestamp: 1640995200000, + }, + ]; + + markControllerAsInitialized(); + controller.testSetProviders(new Map([['hyperliquid', mockProvider]])); + jest + .spyOn(mockMarketDataServiceInstance, 'getOrders') + .mockResolvedValue(mockOrders); + + const result = await controller.getOrders(); + + expect(result).toEqual(mockOrders); + expect(mockMarketDataServiceInstance.getOrders).toHaveBeenCalledWith({ + provider: mockProvider, + params: undefined, + context: expect.any(Object), + }); + }); + }); + + describe('subscribeToPrices', () => { + it('subscribes to price updates', () => { + const mockUnsubscribe = jest.fn(); + const params = { + symbols: ['BTC', 'ETH'], + callback: jest.fn(), + }; + + markControllerAsInitialized(); + controller.testSetProviders(new Map([['hyperliquid', mockProvider]])); + mockProvider.subscribeToPrices.mockReturnValue(mockUnsubscribe); + + const unsubscribe = controller.subscribeToPrices(params); + + expect(unsubscribe).toBe(mockUnsubscribe); + expect(mockProvider.subscribeToPrices).toHaveBeenCalledWith(params); + }); + }); +}); diff --git a/packages/perps-controller/tests/src/aggregation/SubscriptionMultiplexer.test.ts b/packages/perps-controller/tests/src/aggregation/SubscriptionMultiplexer.test.ts new file mode 100644 index 00000000000..327d6598a6d --- /dev/null +++ b/packages/perps-controller/tests/src/aggregation/SubscriptionMultiplexer.test.ts @@ -0,0 +1,821 @@ +import { SubscriptionMultiplexer } from '../../../src/aggregation/SubscriptionMultiplexer.js'; +/* eslint-disable */ +import type { + PerpsProvider, + PerpsLogger, + PriceUpdate, + Position, + Order, + OrderFill, + AccountState, +} from '../../../src/types/index.js'; + +// Mock logger factory +const createMockLogger = (): jest.Mocked => ({ + error: jest.fn(), +}); + +// Mock provider with test helper methods +type MockProviderWithEmit = jest.Mocked> & { + _emitPrices: (prices: PriceUpdate[]) => void; + _emitPositions: (positions: Position[]) => void; + _emitOrders: (orders: Order[]) => void; + _emitFills: (fills: OrderFill[], isSnapshot?: boolean) => void; + _emitAccount: (account: AccountState | null) => void; +}; + +// Mock provider factory +const createMockProvider = (providerId: string): MockProviderWithEmit => { + const priceCallbacks: ((prices: PriceUpdate[]) => void)[] = []; + const positionCallbacks: ((positions: Position[]) => void)[] = []; + const orderCallbacks: ((orders: Order[]) => void)[] = []; + const fillCallbacks: ((fills: OrderFill[], isSnapshot?: boolean) => void)[] = + []; + const accountCallbacks: ((account: AccountState | null) => void)[] = []; + + return { + protocolId: providerId, + subscribeToPrices: jest.fn((params) => { + priceCallbacks.push(params.callback); + return () => { + const idx = priceCallbacks.indexOf(params.callback); + if (idx > -1) { + priceCallbacks.splice(idx, 1); + } + }; + }), + subscribeToPositions: jest.fn((params) => { + positionCallbacks.push(params.callback); + return () => { + const idx = positionCallbacks.indexOf(params.callback); + if (idx > -1) { + positionCallbacks.splice(idx, 1); + } + }; + }), + subscribeToOrders: jest.fn((params) => { + orderCallbacks.push(params.callback); + return () => { + const idx = orderCallbacks.indexOf(params.callback); + if (idx > -1) { + orderCallbacks.splice(idx, 1); + } + }; + }), + subscribeToOrderFills: jest.fn((params) => { + fillCallbacks.push(params.callback); + return () => { + const idx = fillCallbacks.indexOf(params.callback); + if (idx > -1) { + fillCallbacks.splice(idx, 1); + } + }; + }), + subscribeToAccount: jest.fn((params) => { + accountCallbacks.push(params.callback); + return () => { + const idx = accountCallbacks.indexOf(params.callback); + if (idx > -1) { + accountCallbacks.splice(idx, 1); + } + }; + }), + // Helper to emit updates in tests + _emitPrices: (prices: PriceUpdate[]) => { + priceCallbacks.forEach((cb) => cb(prices)); + }, + _emitPositions: (positions: Position[]) => { + positionCallbacks.forEach((cb) => cb(positions)); + }, + _emitOrders: (orders: Order[]) => { + orderCallbacks.forEach((cb) => cb(orders)); + }, + _emitFills: (fills: OrderFill[], isSnapshot?: boolean) => { + fillCallbacks.forEach((cb) => cb(fills, isSnapshot)); + }, + _emitAccount: (account: AccountState | null) => { + accountCallbacks.forEach((cb) => cb(account)); + }, + } as MockProviderWithEmit; +}; + +describe('SubscriptionMultiplexer', () => { + let mux: SubscriptionMultiplexer; + let mockHLProvider: ReturnType; + let mockMYXProvider: ReturnType; + + beforeEach(() => { + mux = new SubscriptionMultiplexer(); + mockHLProvider = createMockProvider('hyperliquid'); + mockMYXProvider = createMockProvider('myx'); + }); + + describe('subscribeToPrices', () => { + it('subscribes to multiple providers', () => { + const callback = jest.fn(); + + mux.subscribeToPrices({ + symbols: ['BTC', 'ETH'], + providers: [ + ['hyperliquid', mockHLProvider as unknown as PerpsProvider], + ['myx', mockMYXProvider as unknown as PerpsProvider], + ], + callback, + }); + + expect(mockHLProvider.subscribeToPrices).toHaveBeenCalledTimes(1); + expect(mockMYXProvider.subscribeToPrices).toHaveBeenCalledTimes(1); + }); + + it('injects providerId into price updates', () => { + const callback = jest.fn(); + + mux.subscribeToPrices({ + symbols: ['BTC'], + providers: [ + ['hyperliquid', mockHLProvider as unknown as PerpsProvider], + ], + callback, + }); + + // Emit price from provider + mockHLProvider._emitPrices([ + { symbol: 'BTC', price: '50000', timestamp: Date.now() }, + ]); + + expect(callback).toHaveBeenCalledWith( + expect.arrayContaining([ + expect.objectContaining({ + symbol: 'BTC', + price: '50000', + providerId: 'hyperliquid', + }), + ]), + ); + }); + + it('aggregates prices from multiple providers in merge mode', () => { + const callback = jest.fn(); + + mux.subscribeToPrices({ + symbols: ['BTC'], + providers: [ + ['hyperliquid', mockHLProvider as unknown as PerpsProvider], + ['myx', mockMYXProvider as unknown as PerpsProvider], + ], + callback, + aggregationMode: 'merge', + }); + + // Emit from both providers + mockHLProvider._emitPrices([ + { symbol: 'BTC', price: '50000', timestamp: Date.now() }, + ]); + mockMYXProvider._emitPrices([ + { symbol: 'BTC', price: '50100', timestamp: Date.now() }, + ]); + + // After second emission, should have both prices + const lastCall = callback.mock.calls.at(-1)?.[0]; + expect(lastCall).toHaveLength(2); + expect(lastCall).toContainEqual( + expect.objectContaining({ providerId: 'hyperliquid', price: '50000' }), + ); + expect(lastCall).toContainEqual( + expect.objectContaining({ providerId: 'myx', price: '50100' }), + ); + }); + + it('selects best price in best_price mode', () => { + const callback = jest.fn(); + + mux.subscribeToPrices({ + symbols: ['BTC'], + providers: [ + ['hyperliquid', mockHLProvider as unknown as PerpsProvider], + ['myx', mockMYXProvider as unknown as PerpsProvider], + ], + callback, + aggregationMode: 'best_price', + }); + + // Emit from both providers with different spreads + mockHLProvider._emitPrices([ + { symbol: 'BTC', price: '50000', timestamp: Date.now(), spread: '10' }, + ]); + mockMYXProvider._emitPrices([ + { symbol: 'BTC', price: '50100', timestamp: Date.now(), spread: '5' }, + ]); + + // Should return MYX price (smaller spread) + const lastCall = callback.mock.calls.at(-1)?.[0]; + expect(lastCall).toHaveLength(1); + expect(lastCall[0]).toMatchObject({ + providerId: 'myx', + spread: '5', + }); + }); + + it('unsubscribes from all providers', () => { + const callback = jest.fn(); + + const unsubscribe = mux.subscribeToPrices({ + symbols: ['BTC'], + providers: [ + ['hyperliquid', mockHLProvider as unknown as PerpsProvider], + ['myx', mockMYXProvider as unknown as PerpsProvider], + ], + callback, + }); + + unsubscribe(); + + // Emit after unsubscribe - callback should not be called + callback.mockClear(); + mockHLProvider._emitPrices([ + { symbol: 'BTC', price: '50000', timestamp: Date.now() }, + ]); + + expect(callback).not.toHaveBeenCalled(); + }); + }); + + describe('subscribeToPositions', () => { + const createMockPosition = (symbol: string, size: string): Position => + ({ + symbol, + size, + entryPrice: '50000', + positionValue: '5000', + unrealizedPnl: '100', + marginUsed: '500', + leverage: { type: 'cross', value: 10 }, + liquidationPrice: '45000', + maxLeverage: 50, + returnOnEquity: '2%', + cumulativeFunding: { + allTime: '10', + sinceOpen: '5', + sinceChange: '2', + }, + takeProfitCount: 0, + stopLossCount: 0, + }) as Position; + + it('injects providerId into position updates', () => { + const callback = jest.fn(); + + mux.subscribeToPositions({ + providers: [ + ['hyperliquid', mockHLProvider as unknown as PerpsProvider], + ], + callback, + }); + + mockHLProvider._emitPositions([createMockPosition('BTC', '0.1')]); + + expect(callback).toHaveBeenCalledWith( + expect.arrayContaining([ + expect.objectContaining({ + symbol: 'BTC', + providerId: 'hyperliquid', + }), + ]), + ); + }); + + it('aggregates positions from multiple providers', () => { + const callback = jest.fn(); + + mux.subscribeToPositions({ + providers: [ + ['hyperliquid', mockHLProvider as unknown as PerpsProvider], + ['myx', mockMYXProvider as unknown as PerpsProvider], + ], + callback, + }); + + mockHLProvider._emitPositions([createMockPosition('BTC', '0.1')]); + mockMYXProvider._emitPositions([createMockPosition('ETH', '1.0')]); + + const lastCall = callback.mock.calls.at(-1)?.[0]; + expect(lastCall).toHaveLength(2); + expect(lastCall).toContainEqual( + expect.objectContaining({ symbol: 'BTC', providerId: 'hyperliquid' }), + ); + expect(lastCall).toContainEqual( + expect.objectContaining({ symbol: 'ETH', providerId: 'myx' }), + ); + }); + }); + + describe('subscribeToOrders', () => { + const createMockOrder = (orderId: string, symbol: string): Order => + ({ + orderId, + symbol, + side: 'buy', + orderType: 'limit', + size: '0.1', + originalSize: '0.1', + price: '50000', + filledSize: '0', + remainingSize: '0.1', + status: 'open', + timestamp: Date.now(), + }) as Order; + + it('injects providerId into order updates', () => { + const callback = jest.fn(); + + mux.subscribeToOrders({ + providers: [ + ['hyperliquid', mockHLProvider as unknown as PerpsProvider], + ], + callback, + }); + + mockHLProvider._emitOrders([createMockOrder('order-1', 'BTC')]); + + expect(callback).toHaveBeenCalledWith( + expect.arrayContaining([ + expect.objectContaining({ + orderId: 'order-1', + providerId: 'hyperliquid', + }), + ]), + ); + }); + + it('aggregates orders from multiple providers', () => { + const callback = jest.fn(); + + mux.subscribeToOrders({ + providers: [ + ['hyperliquid', mockHLProvider as unknown as PerpsProvider], + ['myx', mockMYXProvider as unknown as PerpsProvider], + ], + callback, + }); + + mockHLProvider._emitOrders([createMockOrder('hl-order', 'BTC')]); + mockMYXProvider._emitOrders([createMockOrder('myx-order', 'ETH')]); + + const lastCall = callback.mock.calls.at(-1)?.[0]; + expect(lastCall).toHaveLength(2); + expect(lastCall).toContainEqual( + expect.objectContaining({ + orderId: 'hl-order', + providerId: 'hyperliquid', + }), + ); + expect(lastCall).toContainEqual( + expect.objectContaining({ orderId: 'myx-order', providerId: 'myx' }), + ); + }); + }); + + describe('subscribeToOrderFills', () => { + const createMockFill = (orderId: string, symbol: string): OrderFill => + ({ + orderId, + symbol, + side: 'buy', + size: '0.1', + price: '50000', + pnl: '100', + direction: 'long', + fee: '0.5', + feeToken: 'USDC', + timestamp: Date.now(), + }) as OrderFill; + + it('injects providerId into fill updates', () => { + const callback = jest.fn(); + + mux.subscribeToOrderFills({ + providers: [ + ['hyperliquid', mockHLProvider as unknown as PerpsProvider], + ], + callback, + }); + + mockHLProvider._emitFills([createMockFill('fill-1', 'BTC')], false); + + expect(callback).toHaveBeenCalledWith( + expect.arrayContaining([ + expect.objectContaining({ + orderId: 'fill-1', + providerId: 'hyperliquid', + }), + ]), + false, + ); + }); + + it('passes through isSnapshot flag', () => { + const callback = jest.fn(); + + mux.subscribeToOrderFills({ + providers: [ + ['hyperliquid', mockHLProvider as unknown as PerpsProvider], + ], + callback, + }); + + mockHLProvider._emitFills([createMockFill('fill-1', 'BTC')], true); + + expect(callback).toHaveBeenCalledWith(expect.any(Array), true); + }); + }); + + describe('subscribeToAccount', () => { + const createMockAccount = (balance: string): AccountState => + ({ + spendableBalance: balance, + withdrawableBalance: balance, + totalBalance: balance, + marginUsed: '0', + unrealizedPnl: '0', + returnOnEquity: '0', + }) as AccountState; + + it('injects providerId into account updates', () => { + const callback = jest.fn(); + + mux.subscribeToAccount({ + providers: [ + ['hyperliquid', mockHLProvider as unknown as PerpsProvider], + ], + callback, + }); + + mockHLProvider._emitAccount(createMockAccount('10000')); + + expect(callback).toHaveBeenCalledWith( + expect.arrayContaining([ + expect.objectContaining({ + spendableBalance: '10000', + withdrawableBalance: '10000', + providerId: 'hyperliquid', + }), + ]), + ); + }); + + it('aggregates accounts from multiple providers', () => { + const callback = jest.fn(); + + mux.subscribeToAccount({ + providers: [ + ['hyperliquid', mockHLProvider as unknown as PerpsProvider], + ['myx', mockMYXProvider as unknown as PerpsProvider], + ], + callback, + }); + + mockHLProvider._emitAccount(createMockAccount('10000')); + mockMYXProvider._emitAccount(createMockAccount('5000')); + + const lastCall = callback.mock.calls.at(-1)?.[0]; + expect(lastCall).toHaveLength(2); + expect(lastCall).toContainEqual( + expect.objectContaining({ + spendableBalance: '10000', + withdrawableBalance: '10000', + providerId: 'hyperliquid', + }), + ); + expect(lastCall).toContainEqual( + expect.objectContaining({ + spendableBalance: '5000', + withdrawableBalance: '5000', + providerId: 'myx', + }), + ); + }); + + it('removes provider from cache and invokes callback when provider emits null', () => { + const callback = jest.fn(); + + mux.subscribeToAccount({ + providers: [ + ['hyperliquid', mockHLProvider as unknown as PerpsProvider], + ], + callback, + }); + + mockHLProvider._emitAccount(createMockAccount('10000')); + expect(callback).toHaveBeenLastCalledWith( + expect.arrayContaining([ + expect.objectContaining({ providerId: 'hyperliquid' }), + ]), + ); + + mockHLProvider._emitAccount(null); + + expect(callback).toHaveBeenLastCalledWith([]); + }); + }); + + describe('cache operations', () => { + it('caches prices', () => { + const callback = jest.fn(); + + mux.subscribeToPrices({ + symbols: ['BTC'], + providers: [ + ['hyperliquid', mockHLProvider as unknown as PerpsProvider], + ], + callback, + }); + + mockHLProvider._emitPrices([ + { symbol: 'BTC', price: '50000', timestamp: Date.now() }, + ]); + + const cached = mux.getCachedPrice('BTC', 'hyperliquid'); + expect(cached).toMatchObject({ + symbol: 'BTC', + price: '50000', + providerId: 'hyperliquid', + }); + }); + + it('returns all cached prices for a symbol', () => { + const callback = jest.fn(); + + mux.subscribeToPrices({ + symbols: ['BTC'], + providers: [ + ['hyperliquid', mockHLProvider as unknown as PerpsProvider], + ['myx', mockMYXProvider as unknown as PerpsProvider], + ], + callback, + }); + + mockHLProvider._emitPrices([ + { symbol: 'BTC', price: '50000', timestamp: Date.now() }, + ]); + mockMYXProvider._emitPrices([ + { symbol: 'BTC', price: '50100', timestamp: Date.now() }, + ]); + + const allPrices = mux.getAllCachedPricesForSymbol('BTC'); + expect(allPrices?.size).toBe(2); + expect(allPrices?.get('hyperliquid')?.price).toBe('50000'); + expect(allPrices?.get('myx')?.price).toBe('50100'); + }); + + it('clears cache', () => { + const callback = jest.fn(); + + mux.subscribeToPrices({ + symbols: ['BTC'], + providers: [ + ['hyperliquid', mockHLProvider as unknown as PerpsProvider], + ], + callback, + }); + + mockHLProvider._emitPrices([ + { symbol: 'BTC', price: '50000', timestamp: Date.now() }, + ]); + + mux.clearCache(); + + expect(mux.getCachedPrice('BTC', 'hyperliquid')).toBeUndefined(); + }); + }); + + describe('subscription cleanup on partial failure', () => { + let mockLogger: jest.Mocked; + let muxWithLogger: SubscriptionMultiplexer; + let successfulProvider: ReturnType; + let failingProvider: MockProviderWithEmit; + + beforeEach(() => { + mockLogger = createMockLogger(); + muxWithLogger = new SubscriptionMultiplexer({ logger: mockLogger }); + successfulProvider = createMockProvider('hyperliquid'); + + // Create a provider that throws on subscription + failingProvider = { + ...createMockProvider('myx'), + subscribeToPrices: jest.fn(() => { + throw new Error('Provider 2 failed'); + }), + subscribeToPositions: jest.fn(() => { + throw new Error('Provider 2 failed'); + }), + subscribeToOrders: jest.fn(() => { + throw new Error('Provider 2 failed'); + }), + subscribeToOrderFills: jest.fn(() => { + throw new Error('Provider 2 failed'); + }), + subscribeToAccount: jest.fn(() => { + throw new Error('Provider 2 failed'); + }), + } as MockProviderWithEmit; + }); + + it('cleans up successful subscriptions when subscribeToPrices fails for a later provider', () => { + // Track if the first provider's unsubscribe was called + const unsubMock = jest.fn(); + successfulProvider.subscribeToPrices = jest.fn(() => unsubMock); + + expect(() => { + muxWithLogger.subscribeToPrices({ + symbols: ['BTC'], + providers: [ + ['hyperliquid', successfulProvider as unknown as PerpsProvider], + ['myx', failingProvider as unknown as PerpsProvider], + ], + callback: jest.fn(), + }); + }).toThrow('Provider 2 failed'); + + // Verify cleanup was called for the successful subscription + expect(unsubMock).toHaveBeenCalled(); + // Verify error was logged with feature tag + expect(mockLogger.error).toHaveBeenCalledWith( + expect.any(Error), + expect.objectContaining({ + tags: { + feature: 'perps', + provider: 'myx', + method: 'subscribeToPrices', + }, + context: expect.objectContaining({ + name: 'SubscriptionMultiplexer', + data: { subscribedCount: 1 }, + }), + }), + ); + }); + + it('cleans up successful subscriptions when subscribeToPositions fails for a later provider', () => { + const unsubMock = jest.fn(); + successfulProvider.subscribeToPositions = jest.fn(() => unsubMock); + + expect(() => { + muxWithLogger.subscribeToPositions({ + providers: [ + ['hyperliquid', successfulProvider as unknown as PerpsProvider], + ['myx', failingProvider as unknown as PerpsProvider], + ], + callback: jest.fn(), + }); + }).toThrow('Provider 2 failed'); + + expect(unsubMock).toHaveBeenCalled(); + expect(mockLogger.error).toHaveBeenCalledWith( + expect.any(Error), + expect.objectContaining({ + tags: { + feature: 'perps', + provider: 'myx', + method: 'subscribeToPositions', + }, + }), + ); + }); + + it('cleans up successful subscriptions when subscribeToOrders fails for a later provider', () => { + const unsubMock = jest.fn(); + successfulProvider.subscribeToOrders = jest.fn(() => unsubMock); + + expect(() => { + muxWithLogger.subscribeToOrders({ + providers: [ + ['hyperliquid', successfulProvider as unknown as PerpsProvider], + ['myx', failingProvider as unknown as PerpsProvider], + ], + callback: jest.fn(), + }); + }).toThrow('Provider 2 failed'); + + expect(unsubMock).toHaveBeenCalled(); + expect(mockLogger.error).toHaveBeenCalledWith( + expect.any(Error), + expect.objectContaining({ + tags: { + feature: 'perps', + provider: 'myx', + method: 'subscribeToOrders', + }, + }), + ); + }); + + it('cleans up successful subscriptions when subscribeToOrderFills fails for a later provider', () => { + const unsubMock = jest.fn(); + successfulProvider.subscribeToOrderFills = jest.fn(() => unsubMock); + + expect(() => { + muxWithLogger.subscribeToOrderFills({ + providers: [ + ['hyperliquid', successfulProvider as unknown as PerpsProvider], + ['myx', failingProvider as unknown as PerpsProvider], + ], + callback: jest.fn(), + }); + }).toThrow('Provider 2 failed'); + + expect(unsubMock).toHaveBeenCalled(); + expect(mockLogger.error).toHaveBeenCalledWith( + expect.any(Error), + expect.objectContaining({ + tags: { + feature: 'perps', + provider: 'myx', + method: 'subscribeToOrderFills', + }, + }), + ); + }); + + it('cleans up successful subscriptions when subscribeToAccount fails for a later provider', () => { + const unsubMock = jest.fn(); + successfulProvider.subscribeToAccount = jest.fn(() => unsubMock); + + expect(() => { + muxWithLogger.subscribeToAccount({ + providers: [ + ['hyperliquid', successfulProvider as unknown as PerpsProvider], + ['myx', failingProvider as unknown as PerpsProvider], + ], + callback: jest.fn(), + }); + }).toThrow('Provider 2 failed'); + + expect(unsubMock).toHaveBeenCalled(); + expect(mockLogger.error).toHaveBeenCalledWith( + expect.any(Error), + expect.objectContaining({ + tags: { + feature: 'perps', + provider: 'myx', + method: 'subscribeToAccount', + }, + }), + ); + }); + + it('works without a logger (no crash when logger is undefined)', () => { + const muxNoLogger = new SubscriptionMultiplexer(); + const unsubMock = jest.fn(); + successfulProvider.subscribeToPrices = jest.fn(() => unsubMock); + + expect(() => { + muxNoLogger.subscribeToPrices({ + symbols: ['BTC'], + providers: [ + ['hyperliquid', successfulProvider as unknown as PerpsProvider], + ['myx', failingProvider as unknown as PerpsProvider], + ], + callback: jest.fn(), + }); + }).toThrow('Provider 2 failed'); + + // Cleanup still happens even without logger + expect(unsubMock).toHaveBeenCalled(); + }); + + it('cleans up multiple successful subscriptions when a later provider fails', () => { + const unsubMock1 = jest.fn(); + const unsubMock2 = jest.fn(); + const provider1 = createMockProvider('provider1'); + const provider2 = createMockProvider('provider2'); + + provider1.subscribeToPrices = jest.fn(() => unsubMock1); + provider2.subscribeToPrices = jest.fn(() => unsubMock2); + + expect(() => { + muxWithLogger.subscribeToPrices({ + symbols: ['BTC'], + providers: [ + ['hyperliquid', provider1 as unknown as PerpsProvider], + ['myx', provider2 as unknown as PerpsProvider], + ['myx', failingProvider as unknown as PerpsProvider], + ], + callback: jest.fn(), + }); + }).toThrow('Provider 2 failed'); + + // Both successful subscriptions should be cleaned up + expect(unsubMock1).toHaveBeenCalled(); + expect(unsubMock2).toHaveBeenCalled(); + // subscribedCount should be 2 since two providers succeeded before the failure + expect(mockLogger.error).toHaveBeenCalledWith( + expect.any(Error), + expect.objectContaining({ + context: expect.objectContaining({ + data: { subscribedCount: 2 }, + }), + }), + ); + }); + }); +}); diff --git a/packages/perps-controller/tests/src/constants/eventNames.test.ts b/packages/perps-controller/tests/src/constants/eventNames.test.ts new file mode 100644 index 00000000000..f788305e69b --- /dev/null +++ b/packages/perps-controller/tests/src/constants/eventNames.test.ts @@ -0,0 +1,431 @@ +import { + PERPS_EVENT_PROPERTY, + PERPS_EVENT_VALUE, +} from '../../../src/constants/eventNames.js'; +import { PerpsAnalyticsEvent } from '../../../src/types/index.js'; + +describe('PERPS_EVENT_PROPERTY', () => { + describe('PREVIOUS_LEVERAGE', () => { + it('exports PREVIOUS_LEVERAGE as previous_leverage', () => { + expect(PERPS_EVENT_PROPERTY.PREVIOUS_LEVERAGE).toBe('previous_leverage'); + }); + }); + + describe('advanced chart analytics property keys', () => { + it('exports CHART_LIBRARY key', () => { + expect(PERPS_EVENT_PROPERTY.CHART_LIBRARY).toBe('chart_library'); + }); + + it('exports ASSET_TYPE key', () => { + expect(PERPS_EVENT_PROPERTY.ASSET_TYPE).toBe('asset_type'); + }); + }); + + describe('Auto Close TP/SL RoE sign analytics keys', () => { + it('exports ROE_SIGN property key', () => { + expect(PERPS_EVENT_PROPERTY.ROE_SIGN).toBe('roe_sign'); + }); + }); + + describe('consolidated analytics contract property keys', () => { + it('exports entry point / discovery attribution keys', () => { + expect(PERPS_EVENT_PROPERTY.ENTRY_POINT).toBe('entry_point'); + expect(PERPS_EVENT_PROPERTY.DISCOVERY_SOURCE).toBe('discovery_source'); + expect(PERPS_EVENT_PROPERTY.PERP_DISCOVERY_SOURCE).toBe( + 'perp_discovery_source', + ); + }); + + it('exports UTM attribution keys', () => { + expect(PERPS_EVENT_PROPERTY.UTM_SOURCE).toBe('utm_source'); + expect(PERPS_EVENT_PROPERTY.UTM_MEDIUM).toBe('utm_medium'); + expect(PERPS_EVENT_PROPERTY.UTM_CAMPAIGN).toBe('utm_campaign'); + expect(PERPS_EVENT_PROPERTY.UTM_CONTENT).toBe('utm_content'); + expect(PERPS_EVENT_PROPERTY.UTM_TERM).toBe('utm_term'); + }); + + it('exports watchlisted, hl_fee_rate, bulk_action_id, environment_type keys', () => { + expect(PERPS_EVENT_PROPERTY.WATCHLISTED).toBe('watchlisted'); + expect(PERPS_EVENT_PROPERTY.HL_FEE_RATE).toBe('hl_fee_rate'); + expect(PERPS_EVENT_PROPERTY.BULK_ACTION_ID).toBe('bulk_action_id'); + expect(PERPS_EVENT_PROPERTY.ENVIRONMENT_TYPE).toBe('environment_type'); + }); + + it('exports order funnel / quote keys', () => { + expect(PERPS_EVENT_PROPERTY.ORDER_CONTEXT).toBe('order_context'); + expect(PERPS_EVENT_PROPERTY.ORDER_SIZE_PERCENT).toBe( + 'order_size_percent', + ); + expect(PERPS_EVENT_PROPERTY.LIMIT_PRICE_INPUT_TYPE).toBe( + 'limit_price_input_type', + ); + expect(PERPS_EVENT_PROPERTY.LIMIT_PRICE_INPUT_PRESET).toBe( + 'limit_price_input_preset', + ); + expect(PERPS_EVENT_PROPERTY.ORDER_HAS_TP).toBe('order_has_tp'); + expect(PERPS_EVENT_PROPERTY.ORDER_HAS_SL).toBe('order_has_sl'); + expect(PERPS_EVENT_PROPERTY.QUOTE_LATENCY_MS).toBe('quote_latency_ms'); + expect(PERPS_EVENT_PROPERTY.ERROR_REASON).toBe('error_reason'); + expect(PERPS_EVENT_PROPERTY.SAVED_ORDER).toBe('saved_order'); + expect(PERPS_EVENT_PROPERTY.DEFAULT_PAYMENT_TOKEN).toBe( + 'default_payment_token', + ); + expect(PERPS_EVENT_PROPERTY.DEFAULT_SIZE_AMOUNT).toBe( + 'default_size_amount', + ); + expect(PERPS_EVENT_PROPERTY.DEFAULT_LEVERAGE).toBe('default_leverage'); + expect(PERPS_EVENT_PROPERTY.DEFAULT_AUTO_CLOSE).toBe( + 'default_auto_close', + ); + expect(PERPS_EVENT_PROPERTY.ORDER_EXECUTION_LATENCY_MS).toBe( + 'order_execution_latency_ms', + ); + expect(PERPS_EVENT_PROPERTY.SCREEN_CONTEXT).toBe('screen_context'); + expect(PERPS_EVENT_PROPERTY.FROM_TOKEN).toBe('from_token'); + expect(PERPS_EVENT_PROPERTY.FROM_CHAIN).toBe('from_chain'); + expect(PERPS_EVENT_PROPERTY.TO_TOKEN).toBe('to_token'); + expect(PERPS_EVENT_PROPERTY.TO_CHAIN).toBe('to_chain'); + }); + + it('exports search keys', () => { + expect(PERPS_EVENT_PROPERTY.SEARCH_QUERY).toBe('search_query'); + expect(PERPS_EVENT_PROPERTY.RESULTS_COUNT).toBe('results_count'); + expect(PERPS_EVENT_PROPERTY.RESULT_RANK).toBe('result_rank'); + // Search intent — distinct from PERPS_MODE (Lite/Pro UI) + expect(PERPS_EVENT_PROPERTY.MODE).toBe('mode'); + expect(PERPS_EVENT_PROPERTY.CURRENT_TOKEN).toBe('current_token'); + }); + + it('exports PERPS_MODE for Lite/Pro interface mode', () => { + expect(PERPS_EVENT_PROPERTY.PERPS_MODE).toBe('perps_mode'); + }); + + it('exports sort / filter and time-on-screen keys', () => { + expect(PERPS_EVENT_PROPERTY.SORT_FIELD).toBe('sort_field'); + expect(PERPS_EVENT_PROPERTY.SORT_DIRECTION).toBe('sort_direction'); + expect(PERPS_EVENT_PROPERTY.FILTER_CATEGORY).toBe('filter_category'); + expect(PERPS_EVENT_PROPERTY.TIME_ON_SCREEN_MS).toBe('time_on_screen_ms'); + }); + }); + + describe('discovery analytics property keys', () => { + it('exports SOURCE_SECTION key', () => { + expect(PERPS_EVENT_PROPERTY.SOURCE_SECTION).toBe('source_section'); + }); + + it('exports RESULT_COUNT key', () => { + expect(PERPS_EVENT_PROPERTY.RESULT_COUNT).toBe('result_count'); + }); + + it('exports SECTION_NAME key', () => { + expect(PERPS_EVENT_PROPERTY.SECTION_NAME).toBe('section_name'); + }); + + it('exports SECTION_INDEX key', () => { + expect(PERPS_EVENT_PROPERTY.SECTION_INDEX).toBe('section_index'); + }); + + it('exports SECTIONS_DISPLAYED key', () => { + expect(PERPS_EVENT_PROPERTY.SECTIONS_DISPLAYED).toBe( + 'sections_displayed', + ); + }); + + it('exports WATCHLIST_COUNT key', () => { + expect(PERPS_EVENT_PROPERTY.WATCHLIST_COUNT).toBe('watchlist_count'); + }); + + it('exports WATCHLIST_MARKETS key', () => { + expect(PERPS_EVENT_PROPERTY.WATCHLIST_MARKETS).toBe('watchlist_markets'); + }); + }); +}); + +describe('PERPS_EVENT_VALUE.CHART_LIBRARY', () => { + it('exports LIGHTWEIGHT', () => { + expect(PERPS_EVENT_VALUE.CHART_LIBRARY.LIGHTWEIGHT).toBe('lightweight'); + }); + + it('exports ADVANCED', () => { + expect(PERPS_EVENT_VALUE.CHART_LIBRARY.ADVANCED).toBe('advanced'); + }); +}); + +describe('PERPS_EVENT_VALUE.ASSET_TYPE', () => { + it('exports SPOT', () => { + expect(PERPS_EVENT_VALUE.ASSET_TYPE.SPOT).toBe('spot'); + }); + + it('exports PERP', () => { + expect(PERPS_EVENT_VALUE.ASSET_TYPE.PERP).toBe('perp'); + }); +}); + +describe('PERPS_EVENT_VALUE.SOURCE_SECTION', () => { + describe('home section values', () => { + it('exports POSITIONS', () => { + expect(PERPS_EVENT_VALUE.SOURCE_SECTION.POSITIONS).toBe('positions'); + }); + + it('exports ORDERS', () => { + expect(PERPS_EVENT_VALUE.SOURCE_SECTION.ORDERS).toBe('orders'); + }); + + it('exports WATCHLIST', () => { + expect(PERPS_EVENT_VALUE.SOURCE_SECTION.WATCHLIST).toBe('watchlist'); + }); + + it('exports WHATS_HAPPENING', () => { + expect(PERPS_EVENT_VALUE.SOURCE_SECTION.WHATS_HAPPENING).toBe( + 'whats_happening', + ); + }); + + it('exports PRODUCTS', () => { + expect(PERPS_EVENT_VALUE.SOURCE_SECTION.PRODUCTS).toBe('products'); + }); + + it('exports TOP_GAINERS', () => { + expect(PERPS_EVENT_VALUE.SOURCE_SECTION.TOP_GAINERS).toBe('top_gainers'); + }); + + it('exports TOP_LOSERS', () => { + expect(PERPS_EVENT_VALUE.SOURCE_SECTION.TOP_LOSERS).toBe('top_losers'); + }); + + it('exports CRYPTO', () => { + expect(PERPS_EVENT_VALUE.SOURCE_SECTION.CRYPTO).toBe('crypto'); + }); + + it('exports COMMODITY', () => { + expect(PERPS_EVENT_VALUE.SOURCE_SECTION.COMMODITY).toBe('commodity'); + }); + + it('exports STOCK', () => { + expect(PERPS_EVENT_VALUE.SOURCE_SECTION.STOCK).toBe('stock'); + }); + + it('exports FOREX', () => { + expect(PERPS_EVENT_VALUE.SOURCE_SECTION.FOREX).toBe('forex'); + }); + }); + + describe('explore section values', () => { + it('exports PERPS_MOVERS', () => { + expect(PERPS_EVENT_VALUE.SOURCE_SECTION.PERPS_MOVERS).toBe( + 'perps_movers', + ); + }); + + it('exports PERPS_CRYPTO', () => { + expect(PERPS_EVENT_VALUE.SOURCE_SECTION.PERPS_CRYPTO).toBe( + 'perps_crypto', + ); + }); + + it('exports PERPS_STOCKS_COMMODITIES', () => { + expect(PERPS_EVENT_VALUE.SOURCE_SECTION.PERPS_STOCKS_COMMODITIES).toBe( + 'perps_stocks_commodities', + ); + }); + + it('exports PERPS_MARKETS', () => { + expect(PERPS_EVENT_VALUE.SOURCE_SECTION.PERPS_MARKETS).toBe( + 'perps_markets', + ); + }); + }); + + describe('market list section values', () => { + it('exports ALL_MARKETS', () => { + expect(PERPS_EVENT_VALUE.SOURCE_SECTION.ALL_MARKETS).toBe('all_markets'); + }); + + it('exports NEW', () => { + expect(PERPS_EVENT_VALUE.SOURCE_SECTION.NEW).toBe('new'); + }); + + it('exports ACTIVE_SEARCH', () => { + expect(PERPS_EVENT_VALUE.SOURCE_SECTION.ACTIVE_SEARCH).toBe( + 'active_search', + ); + }); + }); +}); + +describe('PERPS_EVENT_VALUE.SECTION_NAME', () => { + it('exports BALANCE', () => { + expect(PERPS_EVENT_VALUE.SECTION_NAME.BALANCE).toBe('balance'); + }); + + it('exports POSITIONS', () => { + expect(PERPS_EVENT_VALUE.SECTION_NAME.POSITIONS).toBe('positions'); + }); + + it('exports ORDERS', () => { + expect(PERPS_EVENT_VALUE.SECTION_NAME.ORDERS).toBe('orders'); + }); + + it('exports WATCHLIST', () => { + expect(PERPS_EVENT_VALUE.SECTION_NAME.WATCHLIST).toBe('watchlist'); + }); + + it('exports WHATS_HAPPENING', () => { + expect(PERPS_EVENT_VALUE.SECTION_NAME.WHATS_HAPPENING).toBe( + 'whats_happening', + ); + }); + + it('exports PRODUCTS', () => { + expect(PERPS_EVENT_VALUE.SECTION_NAME.PRODUCTS).toBe('products'); + }); + + it('exports TOP_MOVERS', () => { + expect(PERPS_EVENT_VALUE.SECTION_NAME.TOP_MOVERS).toBe('top_movers'); + }); + + it('exports EXPLORE_CRYPTO', () => { + expect(PERPS_EVENT_VALUE.SECTION_NAME.EXPLORE_CRYPTO).toBe( + 'explore_crypto', + ); + }); + + it('exports EXPLORE_COMMODITIES', () => { + expect(PERPS_EVENT_VALUE.SECTION_NAME.EXPLORE_COMMODITIES).toBe( + 'explore_commodities', + ); + }); + + it('exports EXPLORE_STOCKS', () => { + expect(PERPS_EVENT_VALUE.SECTION_NAME.EXPLORE_STOCKS).toBe( + 'explore_stocks', + ); + }); + + it('exports EXPLORE_FOREX', () => { + expect(PERPS_EVENT_VALUE.SECTION_NAME.EXPLORE_FOREX).toBe('explore_forex'); + }); + + it('exports RECENT_ACTIVITY', () => { + expect(PERPS_EVENT_VALUE.SECTION_NAME.RECENT_ACTIVITY).toBe( + 'recent_activity', + ); + }); +}); + +describe('PERPS_EVENT_VALUE.INTERACTION_TYPE extensions', () => { + it('exports MARKET_LIST_FILTER', () => { + expect(PERPS_EVENT_VALUE.INTERACTION_TYPE.MARKET_LIST_FILTER).toBe( + 'market_list_filter', + ); + }); +}); + +describe('PERPS_EVENT_VALUE.BUTTON_CLICKED extensions', () => { + it('exports WATCHLIST', () => { + expect(PERPS_EVENT_VALUE.BUTTON_CLICKED.WATCHLIST).toBe('watchlist'); + }); + + it('exports TOP_MOVERS', () => { + expect(PERPS_EVENT_VALUE.BUTTON_CLICKED.TOP_MOVERS).toBe('top_movers'); + }); + + it('exports WHATS_HAPPENING', () => { + expect(PERPS_EVENT_VALUE.BUTTON_CLICKED.WHATS_HAPPENING).toBe( + 'whats_happening', + ); + }); +}); + +describe('PERPS_EVENT_VALUE.BUTTON_LOCATION extensions', () => { + it('exports ASSET_DETAILS', () => { + expect(PERPS_EVENT_VALUE.BUTTON_LOCATION.ASSET_DETAILS).toBe( + 'asset_details', + ); + }); +}); + +describe('PERPS_EVENT_VALUE consolidated contract entries', () => { + it('exports new INTERACTION_TYPE values', () => { + expect(PERPS_EVENT_VALUE.INTERACTION_TYPE.SORT_APPLIED).toBe( + 'sort_applied', + ); + expect(PERPS_EVENT_VALUE.INTERACTION_TYPE.FILTER_APPLIED).toBe( + 'filter_applied', + ); + expect( + PERPS_EVENT_VALUE.INTERACTION_TYPE.CHASE_BACKGROUNDED_CONVERTED, + ).toBe('chase_backgrounded_converted'); + expect(PERPS_EVENT_VALUE.INTERACTION_TYPE.CHASE_TERMINATED).toBe( + 'chase_terminated', + ); + expect(PERPS_EVENT_VALUE.INTERACTION_TYPE.SEARCH_RESULT_TAPPED).toBe( + 'search_result_tapped', + ); + expect(PERPS_EVENT_VALUE.INTERACTION_TYPE.SEARCH_CHIP_TAPPED).toBe( + 'search_chip_tapped', + ); + expect(PERPS_EVENT_VALUE.INTERACTION_TYPE.SEARCH_SIGNAL_TILE_TAPPED).toBe( + 'search_signal_tile_tapped', + ); + expect( + PERPS_EVENT_VALUE.INTERACTION_TYPE.PAYMENT_TOKEN_SELECTOR_DISMISSED, + ).toBe('payment_token_selector_dismissed'); + expect(PERPS_EVENT_VALUE.INTERACTION_TYPE.TPSL_ROE_SIGN_TOGGLED).toBe( + 'tpsl_roe_sign_toggled', + ); + }); + + it('exports ACTION.ABANDON_ORDER', () => { + expect(PERPS_EVENT_VALUE.ACTION.ABANDON_ORDER).toBe('abandon_order'); + }); + + it('exports the Chase background notification schema value', () => { + expect(PERPS_EVENT_VALUE.NOTIFICATION_TYPE.CHASE_BACKGROUNDED).toBe( + 'chase_backgrounded', + ); + }); + + it('exports new BUTTON_CLICKED values', () => { + expect(PERPS_EVENT_VALUE.BUTTON_CLICKED.PLACE_ORDER).toBe('place_order'); + expect(PERPS_EVENT_VALUE.BUTTON_CLICKED.CLOSE).toBe('close'); + expect(PERPS_EVENT_VALUE.BUTTON_CLICKED.REDUCE_EXPOSURE).toBe( + 'reduce_exposure', + ); + }); + + it('exports new SCREEN_TYPE values and keeps add/remove margin', () => { + expect(PERPS_EVENT_VALUE.SCREEN_TYPE.SEARCH_RESULTS_SHOWN).toBe( + 'search_results_shown', + ); + expect(PERPS_EVENT_VALUE.SCREEN_TYPE.SEARCH_NO_RESULTS).toBe( + 'search_no_results', + ); + // add_margin / remove_margin already existed — verify still present + expect(PERPS_EVENT_VALUE.SCREEN_TYPE.ADD_MARGIN).toBe('add_margin'); + expect(PERPS_EVENT_VALUE.SCREEN_TYPE.REMOVE_MARGIN).toBe('remove_margin'); + }); + + it('keeps STATUS.SUBMITTED for transaction pipeline events', () => { + expect(PERPS_EVENT_VALUE.STATUS.SUBMITTED).toBe('submitted'); + }); +}); + +describe('PerpsAnalyticsEvent', () => { + it('adds exactly the five new event names and keeps the nine existing', () => { + expect(PerpsAnalyticsEvent.TransactionConsidered).toBe( + 'Perp Transaction Considered', + ); + expect(PerpsAnalyticsEvent.TradeQuoteReceived).toBe( + 'Perp Trade Quote Received', + ); + expect(PerpsAnalyticsEvent.SearchQuery).toBe('Perp Search Query'); + expect(PerpsAnalyticsEvent.SearchResultTapped).toBe( + 'Perp Search Result Tapped', + ); + expect(PerpsAnalyticsEvent.SearchAbandoned).toBe('Perp Search Abandoned'); + + // No event names invented beyond the five new + nine existing = 14 total. + expect(Object.keys(PerpsAnalyticsEvent)).toHaveLength(14); + }); +}); diff --git a/packages/perps-controller/tests/src/constants/hyperLiquidConfig.test.ts b/packages/perps-controller/tests/src/constants/hyperLiquidConfig.test.ts new file mode 100644 index 00000000000..468821780fc --- /dev/null +++ b/packages/perps-controller/tests/src/constants/hyperLiquidConfig.test.ts @@ -0,0 +1,153 @@ +import { HIP3_ASSET_MARKET_TYPES } from '../../../src/constants/hyperLiquidConfig.js'; +import { MarketCategory, MARKET_CATEGORIES } from '../../../src/types/index.js'; +import type { MarketType, MarketTypeFilter } from '../../../src/types/index.js'; + +describe('HIP3_ASSET_MARKET_TYPES', () => { + it('classifies known US stocks correctly', () => { + expect(HIP3_ASSET_MARKET_TYPES['xyz:TSLA']).toBe('stock'); + expect(HIP3_ASSET_MARKET_TYPES['xyz:NVDA']).toBe('stock'); + expect(HIP3_ASSET_MARKET_TYPES['xyz:AAPL']).toBe('stock'); + expect(HIP3_ASSET_MARKET_TYPES['xyz:GOOGL']).toBe('stock'); + expect(HIP3_ASSET_MARKET_TYPES['xyz:AMZN']).toBe('stock'); + expect(HIP3_ASSET_MARKET_TYPES['xyz:META']).toBe('stock'); + expect(HIP3_ASSET_MARKET_TYPES['xyz:MSFT']).toBe('stock'); + }); + + it('classifies newly added US stocks correctly', () => { + expect(HIP3_ASSET_MARKET_TYPES['xyz:DKNG']).toBe('stock'); + expect(HIP3_ASSET_MARKET_TYPES['xyz:BIRD']).toBe('stock'); + expect(HIP3_ASSET_MARKET_TYPES['xyz:RKLB']).toBe('stock'); + expect(HIP3_ASSET_MARKET_TYPES['xyz:MRVL']).toBe('stock'); + expect(HIP3_ASSET_MARKET_TYPES['xyz:ZM']).toBe('stock'); + expect(HIP3_ASSET_MARKET_TYPES['xyz:EBAY']).toBe('stock'); + expect(HIP3_ASSET_MARKET_TYPES['xyz:PURRDAT']).toBe('stock'); + expect(HIP3_ASSET_MARKET_TYPES['xyz:ARM']).toBe('stock'); + expect(HIP3_ASSET_MARKET_TYPES['xyz:BX']).toBe('stock'); + expect(HIP3_ASSET_MARKET_TYPES['xyz:LITE']).toBe('stock'); + expect(HIP3_ASSET_MARKET_TYPES['xyz:CBRS']).toBe('stock'); + expect(HIP3_ASSET_MARKET_TYPES['xyz:SPCX']).toBe('stock'); + }); + + it('classifies USAR as stock (USA Rare Earth)', () => { + expect(HIP3_ASSET_MARKET_TYPES['xyz:USAR']).toBe('stock'); + }); + + it('classifies Korean stocks correctly', () => { + expect(HIP3_ASSET_MARKET_TYPES['xyz:SKHX']).toBe('stock'); + expect(HIP3_ASSET_MARKET_TYPES['xyz:SMSN']).toBe('stock'); + expect(HIP3_ASSET_MARKET_TYPES['xyz:HYUNDAI']).toBe('stock'); + }); + + it('classifies pre-IPO markets correctly', () => { + expect(HIP3_ASSET_MARKET_TYPES['xyz:IPOP']).toBe('pre-ipo'); + }); + + it('classifies known indices correctly', () => { + expect(HIP3_ASSET_MARKET_TYPES['xyz:SP500']).toBe('index'); + expect(HIP3_ASSET_MARKET_TYPES['xyz:XYZ100']).toBe('index'); + expect(HIP3_ASSET_MARKET_TYPES['xyz:JP225']).toBe('index'); + expect(HIP3_ASSET_MARKET_TYPES['xyz:KR200']).toBe('index'); + expect(HIP3_ASSET_MARKET_TYPES['xyz:VIX']).toBe('index'); + }); + + it('classifies known ETFs correctly', () => { + expect(HIP3_ASSET_MARKET_TYPES['xyz:EWY']).toBe('etf'); + expect(HIP3_ASSET_MARKET_TYPES['xyz:EWJ']).toBe('etf'); + expect(HIP3_ASSET_MARKET_TYPES['xyz:EWT']).toBe('etf'); + expect(HIP3_ASSET_MARKET_TYPES['xyz:EWZ']).toBe('etf'); + expect(HIP3_ASSET_MARKET_TYPES['xyz:URNM']).toBe('etf'); + expect(HIP3_ASSET_MARKET_TYPES['xyz:DRAM']).toBe('etf'); + expect(HIP3_ASSET_MARKET_TYPES['xyz:XLE']).toBe('etf'); + }); + + it('classifies known commodities correctly', () => { + expect(HIP3_ASSET_MARKET_TYPES['xyz:GOLD']).toBe('commodity'); + expect(HIP3_ASSET_MARKET_TYPES['xyz:SILVER']).toBe('commodity'); + expect(HIP3_ASSET_MARKET_TYPES['xyz:CL']).toBe('commodity'); + expect(HIP3_ASSET_MARKET_TYPES['xyz:WTIOIL']).toBe('commodity'); + expect(HIP3_ASSET_MARKET_TYPES['xyz:COPPER']).toBe('commodity'); + expect(HIP3_ASSET_MARKET_TYPES['xyz:URANIUM']).toBe('commodity'); + expect(HIP3_ASSET_MARKET_TYPES['xyz:BRENTOIL']).toBe('commodity'); + }); + + it('classifies known forex pairs correctly', () => { + expect(HIP3_ASSET_MARKET_TYPES['xyz:EUR']).toBe('forex'); + expect(HIP3_ASSET_MARKET_TYPES['xyz:JPY']).toBe('forex'); + expect(HIP3_ASSET_MARKET_TYPES['xyz:GBP']).toBe('forex'); + expect(HIP3_ASSET_MARKET_TYPES['xyz:DXY']).toBe('forex'); + }); + + it('derives unique market categories from config', () => { + const categories: MarketType[] = [ + ...new Set(Object.values(HIP3_ASSET_MARKET_TYPES)), + ]; + expect(categories).toContain('stock'); + expect(categories).toContain('pre-ipo'); + expect(categories).toContain('index'); + expect(categories).toContain('etf'); + expect(categories).toContain('commodity'); + expect(categories).toContain('forex'); + expect(categories).not.toContain('equity'); + expect(categories).not.toContain('crypto'); + }); +}); + +describe('MarketCategory', () => { + it('has string values for all 7 data-model categories', () => { + expect(MarketCategory.CryptoCurrency).toBe('crypto'); + expect(MarketCategory.Stock).toBe('stock'); + expect(MarketCategory.PreIpo).toBe('pre-ipo'); + expect(MarketCategory.Index).toBe('index'); + expect(MarketCategory.Etf).toBe('etf'); + expect(MarketCategory.Commodity).toBe('commodity'); + expect(MarketCategory.Forex).toBe('forex'); + }); + + it('has exactly 7 members', () => { + const values = Object.values(MarketCategory); + expect(values).toHaveLength(7); + }); +}); + +describe('MARKET_CATEGORIES', () => { + it('has exactly 7 entries (one per data-model category)', () => { + expect(MARKET_CATEGORIES).toHaveLength(7); + }); + + it('does not include the all or new sentinel values', () => { + expect(MARKET_CATEGORIES).not.toContain('all'); + expect(MARKET_CATEGORIES).not.toContain('new'); + }); + + it('includes all 7 MarketTypeFilter data categories', () => { + const dataCategories: MarketTypeFilter[] = [ + 'crypto', + 'stock', + 'pre-ipo', + 'index', + 'etf', + 'commodity', + 'forex', + ]; + for (const category of dataCategories) { + expect(MARKET_CATEGORIES).toContain(category); + } + }); + + it('satisfies MarketTypeFilter[] so no unknown values exist', () => { + // Compile-time guarantee: MARKET_CATEGORIES is typed as readonly MarketTypeFilter[] + // The runtime check here mirrors that constraint. + const validValues: readonly string[] = [ + 'crypto', + 'stock', + 'pre-ipo', + 'index', + 'etf', + 'commodity', + 'forex', + ]; + for (const entry of MARKET_CATEGORIES) { + expect(validValues).toContain(entry); + } + }); +}); diff --git a/packages/perps-controller/tests/src/constants/myxConfig.test.ts b/packages/perps-controller/tests/src/constants/myxConfig.test.ts new file mode 100644 index 00000000000..d723c916134 --- /dev/null +++ b/packages/perps-controller/tests/src/constants/myxConfig.test.ts @@ -0,0 +1,142 @@ +/* eslint-disable */ +import BigNumber from 'bignumber.js'; + +import { + fromMYXPrice, + toMYXPrice, + fromMYXSize, + toMYXSize, + fromMYXCollateral, + getMYXChainId, + getMYXHttpEndpoint, + MYX_SIZE_DECIMALS, +} from '../../../src/constants/myxConfig.js'; + +describe('myxConfig', () => { + describe('fromMYXPrice', () => { + it('parses a normal float price string', () => { + expect(fromMYXPrice('1000')).toBe(1000); + }); + + it('returns 0 for "0"', () => { + expect(fromMYXPrice('0')).toBe(0); + }); + + it('returns 0 for empty string', () => { + expect(fromMYXPrice('')).toBe(0); + }); + + it('parses a realistic BTC price from MYX API', () => { + // MYX API returns normal float strings like "64854.760266796727" + expect(fromMYXPrice('64854.760266796727')).toBeCloseTo(64854.76, 2); + }); + + it('parses a sub-dollar price', () => { + // MYX token price ≈ $0.39 + expect(fromMYXPrice('0.390062307787905')).toBeCloseTo(0.39, 2); + }); + + it('returns 0 for invalid string', () => { + expect(fromMYXPrice('not-a-number')).toBe(0); + }); + }); + + describe('toMYXPrice', () => { + it('converts a number to string', () => { + expect(toMYXPrice(1000)).toBe('1000'); + }); + + it('converts a string input', () => { + expect(toMYXPrice('2500.5')).toBe('2500.5'); + }); + + it('returns "0" for invalid string', () => { + expect(toMYXPrice('invalid')).toBe('0'); + }); + }); + + describe('fromMYXSize', () => { + it('converts an 18-decimal size string to a number', () => { + const myxSize = new BigNumber(5) + .times(new BigNumber(10).pow(MYX_SIZE_DECIMALS)) + .toFixed(0); + expect(fromMYXSize(myxSize)).toBe(5); + }); + + it('returns 0 for "0"', () => { + expect(fromMYXSize('0')).toBe(0); + }); + + it('returns 0 for empty string', () => { + expect(fromMYXSize('')).toBe(0); + }); + + it('returns 0 for invalid string', () => { + expect(fromMYXSize('xyz')).toBe(0); + }); + }); + + describe('toMYXSize', () => { + it('converts a number to 18-decimal size string', () => { + const result = toMYXSize(3); + const expected = new BigNumber(3) + .times(new BigNumber(10).pow(MYX_SIZE_DECIMALS)) + .toFixed(0); + expect(result).toBe(expected); + }); + + it('converts a string input', () => { + const result = toMYXSize('0.5'); + const expected = new BigNumber('0.5') + .times(new BigNumber(10).pow(MYX_SIZE_DECIMALS)) + .toFixed(0); + expect(result).toBe(expected); + }); + + it('returns "0" for invalid string', () => { + expect(toMYXSize('bad')).toBe('0'); + }); + }); + + describe('fromMYXCollateral', () => { + it('converts an 18-decimal collateral string to a number', () => { + // 18 decimals (same as size) + const myxCollateral = new BigNumber(100) + .times(new BigNumber(10).pow(18)) + .toFixed(0); + expect(fromMYXCollateral(myxCollateral)).toBe(100); + }); + + it('returns 0 for "0"', () => { + expect(fromMYXCollateral('0')).toBe(0); + }); + + it('returns 0 for empty string', () => { + expect(fromMYXCollateral('')).toBe(0); + }); + + it('returns 0 for invalid string', () => { + expect(fromMYXCollateral('garbage')).toBe(0); + }); + }); + + describe('getMYXChainId', () => { + it('returns 59141 (Linea Sepolia) for testnet', () => { + expect(getMYXChainId('testnet')).toBe(59141); + }); + + it('returns 56 (BNB) for mainnet', () => { + expect(getMYXChainId('mainnet')).toBe(56); + }); + }); + + describe('getMYXHttpEndpoint', () => { + it('returns testnet URL for testnet', () => { + expect(getMYXHttpEndpoint('testnet')).toBe('https://api-test.myx.cash'); + }); + + it('returns prod URL for mainnet', () => { + expect(getMYXHttpEndpoint('mainnet')).toBe('https://api.myx.finance'); + }); + }); +}); diff --git a/packages/perps-controller/tests/src/e2e/advanced-orders.contract.test.ts b/packages/perps-controller/tests/src/e2e/advanced-orders.contract.test.ts new file mode 100644 index 00000000000..d888e1c8bef --- /dev/null +++ b/packages/perps-controller/tests/src/e2e/advanced-orders.contract.test.ts @@ -0,0 +1,127 @@ +/** + * CI guard for the advanced order-type contract (TAT-3511). + * + * Runs the exact same case matrix as `e2e/advanced-orders.e2e.ts` against the + * simulated exchange, so the contract proven on testnet cannot silently + * regress: every case must place, be visible in open-orders state with the + * correct trigger data, and disappear after cancel. + * + * If this test and the e2e script ever disagree, they are wrong together — + * both import the matrix from `e2e/lib/advancedOrders.ts`. + */ +import { + buildCases, + caseContext, + buildPremiseCases, + createSimulatedRunner, + runCase, + runPremiseCase, + runTypedErrorCase, +} from '../../helpers/advancedOrders.js'; +import type { CaseEvidence } from '../../helpers/advancedOrders.js'; + +// The simulated exchange has no market of its own, so the context describes the +// double: a reference price and precision the matrix derives everything from. +// A testnet run passes the venue's live values into the same shape. +const CTX = caseContext({ + symbol: 'BTC', + mid: 50_000, + szDecimals: 3, + assetId: 0, +}); + +describe('advanced order types contract (e2e matrix)', () => { + const cases = buildCases(CTX); + const evidence = new Map(); + + beforeAll(async () => { + for (const testCase of cases) { + // Each case gets a fresh exchange so leftover orders cannot mask a + // missing cancel. + const runner = createSimulatedRunner(); + evidence.set( + testCase.name, + await runCase({ testCase, runner, mode: 'simulated', ctx: CTX }), + ); + } + }); + + it('covers every advanced order type in scope', () => { + expect(cases.map((testCase) => testCase.name)).toStrictEqual([ + 'stop_market', + 'stop_limit', + 'take_profit_market', + 'take_profit_limit', + 'reduce_only', + 'partial_take_profit', + ]); + }); + + describe.each([ + 'stop_market', + 'stop_limit', + 'take_profit_market', + 'take_profit_limit', + 'reduce_only', + 'partial_take_profit', + ])('%s', (caseName) => { + it('passes every round-trip check', () => { + const result = evidence.get(caseName); + + const failures = (result?.checks ?? []) + .filter((check) => !check.pass) + .map( + (check) => + `${check.name}: expected ${JSON.stringify( + check.expected, + )}, got ${JSON.stringify(check.actual)}`, + ); + + expect(failures).toStrictEqual([]); + expect(result?.pass).toBe(true); + }); + + it('is gone from open orders after cancel', () => { + const result = evidence.get(caseName); + + expect(result?.cancelled).toBe(true); + expect(result?.openOrdersAfterCancel).toStrictEqual([]); + }); + }); + + it('fails a trigger placement without a trigger price with a typed error', () => { + const result = runTypedErrorCase(CTX); + + expect(result.actualError).toBe(result.expectedError); + expect(result.pass).toBe(true); + }); + + describe('venue premises', () => { + it('names the guard each premise justifies', () => { + // A premise with no named guard is a claim nobody depends on, and would + // quietly become dead weight in the matrix. + for (const premiseCase of buildPremiseCases(CTX)) { + expect(premiseCase.premise.length).toBeGreaterThan(0); + expect(premiseCase.justifies).toMatch(/[A-Z_]{6,}|refuses|derives/u); + } + }); + + it('records premises as skipped rather than passing them on the simulated exchange', async () => { + // The double renders our own payload back and never rejects, so it cannot + // establish what the venue does. Reporting these as passes would be the + // worst outcome: a green run that proves nothing. + for (const premiseCase of buildPremiseCases(CTX)) { + const result = await runPremiseCase({ + premiseCase, + runner: createSimulatedRunner(), + mode: 'simulated', + symbol: CTX.symbol, + }); + + expect(result.outcome).toBe('skipped'); + expect(result.pass).toBe(false); + expect(result.note).toContain('simulated'); + } + }); + }); +}); diff --git a/packages/perps-controller/tests/src/providers/AggregatedPerpsProvider.test.ts b/packages/perps-controller/tests/src/providers/AggregatedPerpsProvider.test.ts new file mode 100644 index 00000000000..f170b961315 --- /dev/null +++ b/packages/perps-controller/tests/src/providers/AggregatedPerpsProvider.test.ts @@ -0,0 +1,1566 @@ +import { CandlePeriod } from '../../../src/constants/chartConfig.js'; +import { PERPS_ERROR_CODES } from '../../../src/perpsErrorCodes.js'; +import { AggregatedPerpsProvider } from '../../../src/providers/AggregatedPerpsProvider.js'; +import type { + PerpsProvider, + PerpsProviderType, + OrderParams, + Position, + MarketInfo, + Order, + ChaseOrder, + TwapOrder, + FeeCalculationParams, +} from '../../../src/types/index.js'; +import { WebSocketConnectionState } from '../../../src/types/index.js'; +import { STRATEGY_ORDER_TYPES } from '../../../src/utils/orderTypes.js'; +/* eslint-disable */ +import { createMockInfrastructure } from '../../helpers/serviceMocks.js'; + +// Create a comprehensive mock provider +const createMockProvider = ( + providerId: PerpsProviderType, +): jest.Mocked => { + const mockProvider: jest.Mocked = { + protocolId: providerId, + + // Asset routes + getDepositRoutes: jest.fn().mockReturnValue([]), + getWithdrawalRoutes: jest.fn().mockReturnValue([]), + getOrderCapabilities: jest.fn().mockResolvedValue({ + status: 'ready', + providerId, + supportedStrategies: [], + }), + + // Read operations + getPositions: jest.fn().mockResolvedValue([]), + getAccountState: jest.fn().mockResolvedValue({ + spendableBalance: '10000', + withdrawableBalance: '10000', + totalBalance: '10000', + marginUsed: '0', + unrealizedPnl: '0', + returnOnEquity: '0', + }), + getMarkets: jest.fn().mockResolvedValue([]), + getMarketDataWithPrices: jest.fn().mockResolvedValue([]), + getOrderFills: jest.fn().mockResolvedValue([]), + getOrFetchFills: jest.fn().mockResolvedValue([]), + getOrders: jest.fn().mockResolvedValue([]), + getOpenOrders: jest.fn().mockResolvedValue([]), + getFunding: jest.fn().mockResolvedValue([]), + getHistoricalPortfolio: jest.fn().mockResolvedValue({ + accountValue1dAgo: '10000', + timestamp: Date.now(), + }), + getUserNonFundingLedgerUpdates: jest.fn().mockResolvedValue([]), + getUserHistory: jest.fn().mockResolvedValue([]), + getTwapOrders: jest.fn().mockResolvedValue([]), + getChaseOrders: jest.fn().mockResolvedValue([]), + suspendChaseOrders: jest.fn().mockResolvedValue([]), + getCurrentAccountId: jest + .fn() + .mockResolvedValue( + `eip155:1:0x${providerId.padStart(40, '0').slice(0, 40)}`, + ), + + // Write operations + placeOrder: jest + .fn() + .mockResolvedValue({ success: true, orderId: 'order-123' }), + editOrder: jest + .fn() + .mockResolvedValue({ success: true, orderId: 'order-123' }), + cancelOrder: jest.fn().mockResolvedValue({ success: true }), + cancelOrders: jest.fn().mockResolvedValue({ + success: true, + successCount: 1, + failureCount: 0, + results: [], + }), + closePosition: jest.fn().mockResolvedValue({ success: true }), + closePositions: jest.fn().mockResolvedValue({ + success: true, + successCount: 1, + failureCount: 0, + results: [], + }), + updatePositionTPSL: jest.fn().mockResolvedValue({ success: true }), + updateMargin: jest.fn().mockResolvedValue({ success: true }), + withdraw: jest.fn().mockResolvedValue({ success: true }), + + // Validation + validateDeposit: jest.fn().mockResolvedValue({ isValid: true }), + validateOrder: jest.fn().mockResolvedValue({ isValid: true }), + validateClosePosition: jest.fn().mockResolvedValue({ isValid: true }), + validateWithdrawal: jest.fn().mockResolvedValue({ isValid: true }), + + // Calculations + calculateLiquidationPrice: jest.fn().mockResolvedValue('45000'), + calculateMaintenanceMargin: jest.fn().mockResolvedValue(0.05), + getMaxLeverage: jest.fn().mockResolvedValue(50), + calculateFees: jest.fn().mockResolvedValue({ feeRate: 0.001 }), + previewPositionModify: jest.fn().mockResolvedValue({ status: 'none' }), + + // Subscriptions + subscribeToPrices: jest.fn().mockReturnValue(() => undefined), + subscribeToPositions: jest.fn().mockReturnValue(() => undefined), + subscribeToOrderFills: jest.fn().mockReturnValue(() => undefined), + subscribeToOrders: jest.fn().mockReturnValue(() => undefined), + subscribeToAccount: jest.fn().mockReturnValue(() => undefined), + subscribeToOICaps: jest.fn().mockReturnValue(() => undefined), + subscribeToCandles: jest.fn().mockReturnValue(() => undefined), + subscribeToOrderBook: jest.fn().mockReturnValue(() => undefined), + + // Configuration + setLiveDataConfig: jest.fn(), + setUserFeeDiscount: jest.fn(), + setUserFeeResolution: jest.fn(), + approveSubscriptionBuilderFee: jest.fn().mockResolvedValue(true), + + // Lifecycle + toggleTestnet: jest + .fn() + .mockResolvedValue({ success: true, isTestnet: false }), + initialize: jest.fn().mockResolvedValue({ success: true }), + isReadyToTrade: jest.fn().mockResolvedValue({ ready: true }), + disconnect: jest.fn().mockResolvedValue({ success: true }), + ping: jest.fn().mockResolvedValue(undefined), + + // Block explorer + getBlockExplorerUrl: jest.fn().mockReturnValue('https://explorer.example'), + + // HIP-3 + getAvailableDexs: jest.fn().mockResolvedValue([]), + }; + + return mockProvider; +}; + +// Helper to create mock position +const createMockPosition = (symbol: string, size: string): Position => + ({ + symbol, + size, + entryPrice: '50000', + positionValue: '5000', + unrealizedPnl: '100', + marginUsed: '500', + leverage: { type: 'cross', value: 10 }, + liquidationPrice: '45000', + maxLeverage: 50, + returnOnEquity: '2%', + cumulativeFunding: { allTime: '10', sinceOpen: '5', sinceChange: '2' }, + takeProfitCount: 0, + stopLossCount: 0, + }) as Position; + +// Helper to create mock market +const createMockMarket = (name: string): MarketInfo => + ({ + name, + szDecimals: 3, + maxLeverage: 50, + marginTableId: 0, + }) as MarketInfo; + +// Helper to create mock order +const createMockOrder = (orderId: string, symbol: string): Order => + ({ + orderId, + symbol, + side: 'buy', + orderType: 'limit', + size: '0.1', + originalSize: '0.1', + price: '50000', + filledSize: '0', + remainingSize: '0.1', + status: 'open', + timestamp: Date.now(), + }) as Order; + +describe('AggregatedPerpsProvider', () => { + let aggregatedProvider: AggregatedPerpsProvider; + let routedProvider: PerpsProvider; + let mockHLProvider: jest.Mocked; + let mockMYXProvider: jest.Mocked; + let mockInfrastructure: ReturnType; + + beforeEach(() => { + mockHLProvider = createMockProvider('hyperliquid'); + mockMYXProvider = createMockProvider('myx'); + mockInfrastructure = createMockInfrastructure(); + + aggregatedProvider = new AggregatedPerpsProvider({ + providers: new Map([ + ['hyperliquid', mockHLProvider], + ['myx', mockMYXProvider], + ]), + defaultProvider: 'hyperliquid', + infrastructure: mockInfrastructure, + }); + routedProvider = aggregatedProvider; + }); + + describe('constructor', () => { + it('initializes with provided providers', () => { + expect(aggregatedProvider.getProviderIds()).toContain('hyperliquid'); + expect(aggregatedProvider.getProviderIds()).toContain('myx'); + }); + + it('has protocolId set to "aggregated"', () => { + expect(aggregatedProvider.protocolId).toBe('aggregated'); + }); + }); + + describe('Read Operations - getPositions', () => { + it('aggregates positions from all providers', async () => { + mockHLProvider.getPositions.mockResolvedValue([ + createMockPosition('BTC', '0.1'), + ]); + mockMYXProvider.getPositions.mockResolvedValue([ + createMockPosition('ETH', '1.0'), + ]); + + const positions = await aggregatedProvider.getPositions(); + + expect(positions).toHaveLength(2); + expect(positions).toContainEqual( + expect.objectContaining({ symbol: 'BTC', providerId: 'hyperliquid' }), + ); + expect(positions).toContainEqual( + expect.objectContaining({ symbol: 'ETH', providerId: 'myx' }), + ); + }); + + it('injects providerId into each position', async () => { + mockHLProvider.getPositions.mockResolvedValue([ + createMockPosition('BTC', '0.1'), + ]); + + const positions = await aggregatedProvider.getPositions(); + + expect(positions[0].providerId).toBe('hyperliquid'); + }); + + it('handles partial failures gracefully', async () => { + mockHLProvider.getPositions.mockResolvedValue([ + createMockPosition('BTC', '0.1'), + ]); + mockMYXProvider.getPositions.mockRejectedValue( + new Error('Provider unavailable'), + ); + + const positions = await aggregatedProvider.getPositions(); + + // Should still return positions from successful provider + expect(positions).toHaveLength(1); + expect(positions[0].providerId).toBe('hyperliquid'); + }); + + it('returns empty array when all providers fail', async () => { + mockHLProvider.getPositions.mockRejectedValue(new Error('Error 1')); + mockMYXProvider.getPositions.mockRejectedValue(new Error('Error 2')); + + const positions = await aggregatedProvider.getPositions(); + + expect(positions).toEqual([]); + }); + }); + + describe('Read Operations - getMarkets', () => { + it('aggregates markets from all providers', async () => { + mockHLProvider.getMarkets.mockResolvedValue([createMockMarket('BTC')]); + mockMYXProvider.getMarkets.mockResolvedValue([createMockMarket('ETH')]); + + const markets = await aggregatedProvider.getMarkets(); + + expect(markets).toHaveLength(2); + expect(markets).toContainEqual( + expect.objectContaining({ name: 'BTC', providerId: 'hyperliquid' }), + ); + expect(markets).toContainEqual( + expect.objectContaining({ name: 'ETH', providerId: 'myx' }), + ); + }); + + it('keeps same market from different providers', async () => { + mockHLProvider.getMarkets.mockResolvedValue([createMockMarket('BTC')]); + mockMYXProvider.getMarkets.mockResolvedValue([createMockMarket('BTC')]); + + const markets = await aggregatedProvider.getMarkets(); + + // Both should be kept since they have different providerIds + expect(markets).toHaveLength(2); + }); + }); + + describe('Read Operations - getOrders', () => { + it('aggregates orders from all providers', async () => { + mockHLProvider.getOrders.mockResolvedValue([ + createMockOrder('hl-order', 'BTC'), + ]); + mockMYXProvider.getOrders.mockResolvedValue([ + createMockOrder('myx-order', 'ETH'), + ]); + + const orders = await aggregatedProvider.getOrders(); + + expect(orders).toHaveLength(2); + expect(orders).toContainEqual( + expect.objectContaining({ + orderId: 'hl-order', + providerId: 'hyperliquid', + }), + ); + expect(orders).toContainEqual( + expect.objectContaining({ orderId: 'myx-order', providerId: 'myx' }), + ); + }); + }); + + describe('Read Operations - getAccountState', () => { + it('returns account state from default provider with providerId injected', async () => { + const mockState = { + spendableBalance: '1000', + withdrawableBalance: '1000', + totalBalance: '1000', + marginUsed: '0', + unrealizedPnl: '0', + returnOnEquity: '0', + }; + mockHLProvider.getAccountState.mockResolvedValue(mockState); + + const result = await aggregatedProvider.getAccountState(); + + expect(result).toEqual({ ...mockState, providerId: 'hyperliquid' }); + }); + }); + + describe('Read Operations - getMarketDataWithPrices', () => { + it('aggregates market data from all providers', async () => { + mockHLProvider.getMarketDataWithPrices.mockResolvedValue([ + { + symbol: 'BTC', + name: 'Bitcoin', + maxLeverage: '50x', + price: '50000', + change24h: '100', + change24hPercent: '0.2', + volume: '1000000', + }, + ]); + mockMYXProvider.getMarketDataWithPrices.mockResolvedValue([ + { + symbol: 'ETH', + name: 'Ethereum', + maxLeverage: '25x', + price: '3000', + change24h: '50', + change24hPercent: '1.5', + volume: '500000', + }, + ]); + + const result = await aggregatedProvider.getMarketDataWithPrices(); + + expect(result).toHaveLength(2); + expect(result).toContainEqual( + expect.objectContaining({ symbol: 'BTC', providerId: 'hyperliquid' }), + ); + expect(result).toContainEqual( + expect.objectContaining({ symbol: 'ETH', providerId: 'myx' }), + ); + }); + }); + + describe('Read Operations - getOrderFills', () => { + it('aggregates order fills from all providers', async () => { + mockHLProvider.getOrderFills.mockResolvedValue([ + { + orderId: '1', + symbol: 'BTC', + side: 'buy', + size: '0.1', + price: '50000', + pnl: '0', + direction: 'long', + fee: '5', + feeToken: 'USDC', + timestamp: Date.now(), + }, + ]); + mockMYXProvider.getOrderFills.mockResolvedValue([ + { + orderId: '2', + symbol: 'ETH', + side: 'sell', + size: '1', + price: '3000', + pnl: '0', + direction: 'short', + fee: '3', + feeToken: 'USDC', + timestamp: Date.now(), + }, + ]); + + const result = await aggregatedProvider.getOrderFills(); + + expect(result).toHaveLength(2); + expect(result).toContainEqual( + expect.objectContaining({ orderId: '1', providerId: 'hyperliquid' }), + ); + }); + }); + + describe('Read Operations - getOpenOrders', () => { + it('aggregates open orders from all providers', async () => { + mockHLProvider.getOpenOrders.mockResolvedValue([ + createMockOrder('1', 'BTC'), + ]); + mockMYXProvider.getOpenOrders.mockResolvedValue([ + createMockOrder('2', 'ETH'), + ]); + + const result = await aggregatedProvider.getOpenOrders(); + + expect(result).toHaveLength(2); + expect(result).toContainEqual( + expect.objectContaining({ orderId: '1', providerId: 'hyperliquid' }), + ); + }); + }); + + describe('Read Operations - getFunding', () => { + it('aggregates funding data from all providers', async () => { + mockHLProvider.getFunding.mockResolvedValue([ + { symbol: 'BTC', amountUsd: '10', rate: '0.01', timestamp: Date.now() }, + ]); + mockMYXProvider.getFunding.mockResolvedValue([ + { symbol: 'ETH', amountUsd: '5', rate: '0.02', timestamp: Date.now() }, + ]); + + const result = await aggregatedProvider.getFunding(); + + expect(result).toHaveLength(2); + }); + }); + + describe('TWAP lifecycle operations', () => { + const twapOrder: TwapOrder = { + orderId: '987', + symbol: 'ETH', + side: 'buy', + size: '1', + executedSize: '0.4', + remainingSize: '0.6', + executedNotional: '1200', + averagePrice: '3000', + fillProgressBps: 4000, + timeProgressBps: 5000, + elapsedTimeMilliseconds: 300_000, + durationMinutes: 10, + randomize: true, + reduceOnly: false, + status: 'active', + startedAt: 1, + lastUpdated: 2, + fills: [], + }; + + it('aggregates TWAP records with their provider IDs', async () => { + mockHLProvider.getTwapOrders?.mockResolvedValue([twapOrder]); + + expect(await aggregatedProvider.getTwapOrders()).toContainEqual({ + ...twapOrder, + providerId: 'hyperliquid', + }); + }); + }); + + describe('Chase lifecycle operations', () => { + const chaseOrder: ChaseOrder = { + handle: 'chase-1', + symbol: 'ETH', + side: 'buy', + originalSize: '1', + remainingSize: '1', + arrivalPrice: '3000', + restingPrice: '3000', + restingOrderId: '55', + distanceChasedBps: 0, + repricings: 0, + startedAt: 1, + status: 'active', + }; + + it('aggregates Chase snapshots with their provider IDs', async () => { + mockHLProvider.getChaseOrders?.mockResolvedValue([chaseOrder]); + + await expect(aggregatedProvider.getChaseOrders()).resolves.toContainEqual( + { ...chaseOrder, providerId: 'hyperliquid' }, + ); + }); + + it('retains successful Chase snapshots when another provider fails', async () => { + mockHLProvider.getChaseOrders?.mockResolvedValue([chaseOrder]); + mockMYXProvider.getChaseOrders?.mockRejectedValue( + new Error('snapshot failed'), + ); + + await expect(aggregatedProvider.getChaseOrders()).resolves.toStrictEqual([ + { ...chaseOrder, providerId: 'hyperliquid' }, + ]); + }); + + it('suspends every provider and retains provider IDs', async () => { + const backgrounded = { + ...chaseOrder, + status: 'backgrounded' as const, + }; + mockHLProvider.suspendChaseOrders?.mockResolvedValue([backgrounded]); + + await expect( + aggregatedProvider.suspendChaseOrders(), + ).resolves.toContainEqual({ + ...backgrounded, + providerId: 'hyperliquid', + }); + }); + + it('waits for every suspension attempt before rejecting a provider failure', async () => { + const backgrounded = { + ...chaseOrder, + status: 'backgrounded' as const, + }; + let resolveHyperLiquid: (orders: ChaseOrder[]) => void = () => undefined; + const hyperLiquidSuspension = new Promise((resolve) => { + resolveHyperLiquid = resolve; + }); + let hyperLiquidCompleted = false; + mockHLProvider.suspendChaseOrders?.mockImplementation(async () => { + const orders = await hyperLiquidSuspension; + hyperLiquidCompleted = true; + return orders; + }); + const providerFailure = new Error('suspension failed'); + mockMYXProvider.suspendChaseOrders?.mockRejectedValue(providerFailure); + + const suspension = aggregatedProvider.suspendChaseOrders(); + let aggregateSettled = false; + void suspension.then( + () => { + aggregateSettled = true; + }, + () => { + aggregateSettled = true; + }, + ); + await new Promise((resolve) => setImmediate(resolve)); + + expect(aggregateSettled).toBe(false); + resolveHyperLiquid([backgrounded]); + await expect(suspension).rejects.toMatchObject({ + name: 'ChaseOrderSuspensionError', + message: 'Failed to suspend Chase orders for: myx', + suspendedOrders: [{ ...backgrounded, providerId: 'hyperliquid' }], + failures: [{ providerId: 'myx', reason: providerFailure }], + }); + expect(hyperLiquidCompleted).toBe(true); + }); + }); + + describe('Write Operations - placeOrder', () => { + it('accepts an ordinary order held as the routed parameter type', async () => { + const params: OrderParams = { + symbol: 'BTC', + isBuy: true, + size: '0.1', + orderType: 'market', + }; + + await aggregatedProvider.placeOrder(params); + + expect(mockHLProvider.placeOrder).toHaveBeenCalledWith(params); + }); + + it('routes to default provider when no providerId specified', async () => { + await aggregatedProvider.placeOrder({ + symbol: 'BTC', + isBuy: true, + size: '0.1', + orderType: 'market', + }); + + expect(mockHLProvider.placeOrder).toHaveBeenCalled(); + expect(mockMYXProvider.placeOrder).not.toHaveBeenCalled(); + }); + + it('routes to specified provider when providerId is provided', async () => { + await aggregatedProvider.placeOrder({ + symbol: 'BTC', + isBuy: true, + size: '0.1', + orderType: 'market', + providerId: 'myx', + }); + + expect(mockMYXProvider.placeOrder).toHaveBeenCalled(); + expect(mockHLProvider.placeOrder).not.toHaveBeenCalled(); + }); + + it.each(STRATEGY_ORDER_TYPES)( + 'routes a %s order to MYX when explicitly requested', + async (orderType) => { + const params = { + symbol: 'RHEA', + isBuy: true, + size: '0.1', + orderType, + providerId: 'myx', + } as const; + + await routedProvider.placeOrder(params); + + expect(mockMYXProvider.placeOrder).toHaveBeenCalledWith(params); + expect(mockHLProvider.placeOrder).not.toHaveBeenCalled(); + }, + ); + + it('injects providerId into result', async () => { + mockMYXProvider.placeOrder.mockResolvedValue({ + success: true, + orderId: 'myx-order-123', + }); + + const result = await aggregatedProvider.placeOrder({ + symbol: 'BTC', + isBuy: true, + size: '0.1', + orderType: 'market', + providerId: 'myx', + }); + + expect(result.providerId).toBe('myx'); + expect(result.orderId).toBe('myx-order-123'); + }); + + it('rejects an unregistered explicit provider for an ordinary order', async () => { + aggregatedProvider.removeProvider('myx'); + + await expect( + aggregatedProvider.placeOrder({ + symbol: 'BTC', + isBuy: true, + size: '0.1', + orderType: 'market', + providerId: 'myx', + }), + ).rejects.toThrow(PERPS_ERROR_CODES.PROVIDER_NOT_FOUND); + + expect(mockHLProvider.placeOrder).not.toHaveBeenCalled(); + expect(mockMYXProvider.placeOrder).not.toHaveBeenCalled(); + }); + + it.each(STRATEGY_ORDER_TYPES)( + 'rejects an unregistered explicit provider for a %s order', + async (orderType) => { + aggregatedProvider.removeProvider('myx'); + + await expect( + routedProvider.placeOrder({ + symbol: 'BTC', + isBuy: true, + size: '0.1', + orderType, + providerId: 'myx', + }), + ).rejects.toThrow(PERPS_ERROR_CODES.PROVIDER_NOT_FOUND); + expect(mockHLProvider.placeOrder).not.toHaveBeenCalled(); + expect(mockMYXProvider.placeOrder).not.toHaveBeenCalled(); + }, + ); + + it.each(STRATEGY_ORDER_TYPES)( + 'routes a %s order without providerId to the default provider', + async (orderType) => { + const params = { + symbol: 'BTC', + isBuy: true, + size: '0.1', + orderType, + }; + + await routedProvider.placeOrder(params); + + expect(mockHLProvider.placeOrder).toHaveBeenCalledWith(params); + expect(mockMYXProvider.placeOrder).not.toHaveBeenCalled(); + }, + ); + }); + + describe('Write Operations - cancelOrder', () => { + it('accepts an ordinary cancel held as the routed parameter type', async () => { + const params: CancelOrderParams = { + orderId: 'order-123', + symbol: 'BTC', + }; + + await aggregatedProvider.cancelOrder(params); + + expect(mockHLProvider.cancelOrder).toHaveBeenCalledWith(params); + }); + + it('routes to specified provider', async () => { + await aggregatedProvider.cancelOrder({ + orderId: 'order-123', + symbol: 'BTC', + providerId: 'myx', + }); + + expect(mockMYXProvider.cancelOrder).toHaveBeenCalledWith( + expect.objectContaining({ orderId: 'order-123' }), + ); + }); + + it.each(STRATEGY_ORDER_TYPES)( + 'routes a %s cancel to MYX when explicitly requested', + async (orderType) => { + const params = { + orderId: 'strategy-123', + symbol: 'RHEA', + orderType, + providerId: 'myx', + } as const; + + await routedProvider.cancelOrder(params); + + expect(mockMYXProvider.cancelOrder).toHaveBeenCalledWith(params); + expect(mockHLProvider.cancelOrder).not.toHaveBeenCalled(); + }, + ); + + it('injects providerId into result', async () => { + mockMYXProvider.cancelOrder.mockResolvedValue({ + success: true, + orderId: 'order-123', + }); + + const result = await aggregatedProvider.cancelOrder({ + orderId: 'order-123', + symbol: 'BTC', + providerId: 'myx', + }); + + expect(result.providerId).toBe('myx'); + }); + + it('rejects an unregistered explicit provider for an ordinary cancel', async () => { + aggregatedProvider.removeProvider('myx'); + + await expect( + aggregatedProvider.cancelOrder({ + orderId: 'order-123', + symbol: 'BTC', + orderType: 'limit', + providerId: 'myx', + }), + ).rejects.toThrow(PERPS_ERROR_CODES.PROVIDER_NOT_FOUND); + + expect(mockHLProvider.cancelOrder).not.toHaveBeenCalled(); + expect(mockMYXProvider.cancelOrder).not.toHaveBeenCalled(); + }); + + it.each(STRATEGY_ORDER_TYPES)( + 'rejects an unregistered explicit provider for a %s cancel', + async (orderType) => { + aggregatedProvider.removeProvider('myx'); + + await expect( + routedProvider.cancelOrder({ + orderId: 'strategy-123', + symbol: 'BTC', + orderType, + providerId: 'myx', + }), + ).rejects.toThrow(PERPS_ERROR_CODES.PROVIDER_NOT_FOUND); + expect(mockHLProvider.cancelOrder).not.toHaveBeenCalled(); + expect(mockMYXProvider.cancelOrder).not.toHaveBeenCalled(); + }, + ); + + it.each(STRATEGY_ORDER_TYPES)( + 'routes a %s cancel without providerId to the default provider', + async (orderType) => { + const params = { + orderId: 'strategy-123', + symbol: 'BTC', + orderType, + }; + + await routedProvider.cancelOrder(params); + + expect(mockHLProvider.cancelOrder).toHaveBeenCalledWith(params); + expect(mockMYXProvider.cancelOrder).not.toHaveBeenCalled(); + }, + ); + }); + + describe('Write Operations - closePosition', () => { + it('routes to specified provider', async () => { + await aggregatedProvider.closePosition({ + symbol: 'BTC', + providerId: 'myx', + }); + + expect(mockMYXProvider.closePosition).toHaveBeenCalled(); + }); + }); + + describe('Validation', () => { + it('validates order with specified provider', async () => { + await aggregatedProvider.validateOrder({ + symbol: 'BTC', + isBuy: true, + size: '0.1', + orderType: 'market', + providerId: 'myx', + }); + + expect(mockMYXProvider.validateOrder).toHaveBeenCalled(); + }); + + it.each(STRATEGY_ORDER_TYPES)( + 'routes %s validation to MYX when explicitly requested', + async (orderType) => { + const params = { + symbol: 'RHEA', + isBuy: true, + size: '0.1', + orderType, + providerId: 'myx', + } as const; + + await routedProvider.validateOrder(params); + + expect(mockMYXProvider.validateOrder).toHaveBeenCalledWith(params); + expect(mockHLProvider.validateOrder).not.toHaveBeenCalled(); + }, + ); + + it('rejects an unregistered provider during ordinary validation', async () => { + aggregatedProvider.removeProvider('myx'); + const params: OrderParams = { + symbol: 'BTC', + isBuy: true, + size: '0.1', + orderType: 'market', + providerId: 'myx', + }; + + await expect(aggregatedProvider.validateOrder(params)).rejects.toThrow( + PERPS_ERROR_CODES.PROVIDER_NOT_FOUND, + ); + + expect(mockHLProvider.validateOrder).not.toHaveBeenCalled(); + expect(mockMYXProvider.validateOrder).not.toHaveBeenCalled(); + }); + + it.each(STRATEGY_ORDER_TYPES)( + 'rejects an unregistered explicit provider for %s validation', + async (orderType) => { + aggregatedProvider.removeProvider('myx'); + + await expect( + routedProvider.validateOrder({ + symbol: 'BTC', + isBuy: true, + size: '0.1', + orderType, + providerId: 'myx', + }), + ).rejects.toThrow(PERPS_ERROR_CODES.PROVIDER_NOT_FOUND); + expect(mockHLProvider.validateOrder).not.toHaveBeenCalled(); + expect(mockMYXProvider.validateOrder).not.toHaveBeenCalled(); + }, + ); + + it.each(STRATEGY_ORDER_TYPES)( + 'routes %s validation without providerId to the default provider', + async (orderType) => { + const params = { + symbol: 'BTC', + isBuy: true, + size: '0.1', + orderType, + }; + + await routedProvider.validateOrder(params); + + expect(mockHLProvider.validateOrder).toHaveBeenCalledWith(params); + expect(mockMYXProvider.validateOrder).not.toHaveBeenCalled(); + }, + ); + + it('uses default provider for validateDeposit', async () => { + await aggregatedProvider.validateDeposit({ + amount: '100', + assetId: 'eip155:42161/erc20:0x1234/default', + }); + + expect(mockHLProvider.validateDeposit).toHaveBeenCalled(); + }); + + it('routes validateClosePosition to specified provider', async () => { + const params = { symbol: 'BTC', providerId: 'myx' as const }; + + await aggregatedProvider.validateClosePosition(params); + + expect(mockMYXProvider.validateClosePosition).toHaveBeenCalledWith( + params, + ); + }); + + it('routes validateWithdrawal to specified provider', async () => { + const params = { amount: '100', providerId: 'myx' as const }; + + await aggregatedProvider.validateWithdrawal(params); + + expect(mockMYXProvider.validateWithdrawal).toHaveBeenCalledWith(params); + }); + }); + + describe('Lifecycle', () => { + it('initializes default provider', async () => { + const result = await aggregatedProvider.initialize(); + + expect(mockHLProvider.initialize).toHaveBeenCalled(); + expect(result).toEqual({ success: true }); + }); + + it('disconnects all providers', async () => { + const result = await aggregatedProvider.disconnect(); + + expect(mockHLProvider.disconnect).toHaveBeenCalled(); + expect(mockMYXProvider.disconnect).toHaveBeenCalled(); + expect(result.success).toBe(true); + }); + + it('delegates isReadyToTrade to default provider', async () => { + mockHLProvider.isReadyToTrade.mockResolvedValue({ + ready: true, + walletConnected: true, + networkSupported: true, + }); + + const result = await aggregatedProvider.isReadyToTrade(); + + expect(result.ready).toBe(true); + expect(mockHLProvider.isReadyToTrade).toHaveBeenCalled(); + }); + + it('delegates toggleTestnet to default provider', async () => { + mockHLProvider.toggleTestnet.mockResolvedValue({ + success: true, + isTestnet: true, + }); + + const result = await aggregatedProvider.toggleTestnet(); + + expect(mockHLProvider.toggleTestnet).toHaveBeenCalled(); + expect(result).toEqual({ success: true, isTestnet: true }); + }); + }); + + describe('Configuration', () => { + it('applies setLiveDataConfig to all providers', () => { + aggregatedProvider.setLiveDataConfig({ priceThrottleMs: 1000 }); + + expect(mockHLProvider.setLiveDataConfig).toHaveBeenCalledWith({ + priceThrottleMs: 1000, + }); + expect(mockMYXProvider.setLiveDataConfig).toHaveBeenCalledWith({ + priceThrottleMs: 1000, + }); + }); + + it('applies setUserFeeDiscount to all providers', () => { + aggregatedProvider.setUserFeeDiscount(1000); + + expect(mockHLProvider.setUserFeeDiscount).toHaveBeenCalledWith(1000); + expect(mockMYXProvider.setUserFeeDiscount).toHaveBeenCalledWith(1000); + }); + + it('preserves the fee source for providers that support full resolutions', () => { + const resolution = { + feeBips: 0, + discountBips: 10000, + source: 'subscription' as const, + subscription: { eligible: true, reason: 'eligible' as const }, + }; + mockMYXProvider.setUserFeeResolution = undefined; + + aggregatedProvider.setUserFeeResolution(resolution); + + expect(mockHLProvider.setUserFeeResolution).toHaveBeenCalledWith( + resolution, + ); + expect(mockMYXProvider.setUserFeeDiscount).toHaveBeenCalledWith(10000); + }); + + it('delegates subscription builder approval to the default provider', async () => { + await expect( + aggregatedProvider.approveSubscriptionBuilderFee(), + ).resolves.toBe(true); + + expect( + mockHLProvider.approveSubscriptionBuilderFee, + ).toHaveBeenCalledTimes(1); + expect( + mockMYXProvider.approveSubscriptionBuilderFee, + ).not.toHaveBeenCalled(); + }); + }); + + describe('Provider Management', () => { + it('adds new provider', () => { + // Using 'myx' as an existing valid provider type for this test + // (simulating adding a duplicate or re-adding after removal) + const newProvider = createMockProvider('myx'); + aggregatedProvider.addProvider('myx', newProvider); + + expect(aggregatedProvider.hasProvider('myx')).toBe(true); + expect(aggregatedProvider.getProviderIds()).toContain('myx'); + }); + + it('removes provider', () => { + const removed = aggregatedProvider.removeProvider('myx'); + + expect(removed).toBe(true); + expect(aggregatedProvider.hasProvider('myx')).toBe(false); + }); + + it('returns false when removing non-existent provider', () => { + aggregatedProvider.removeProvider('myx'); + const removed = aggregatedProvider.removeProvider('myx'); + + expect(removed).toBe(false); + }); + }); + + describe('Asset Routes', () => { + it('delegates getDepositRoutes to default provider', () => { + aggregatedProvider.getDepositRoutes(); + + expect(mockHLProvider.getDepositRoutes).toHaveBeenCalled(); + }); + + it('delegates getWithdrawalRoutes to default provider', () => { + aggregatedProvider.getWithdrawalRoutes(); + + expect(mockHLProvider.getWithdrawalRoutes).toHaveBeenCalled(); + }); + }); + + describe('Calculations', () => { + it('delegates calculateLiquidationPrice to default provider', async () => { + await aggregatedProvider.calculateLiquidationPrice({ + entryPrice: 50000, + leverage: 10, + direction: 'long', + }); + + expect(mockHLProvider.calculateLiquidationPrice).toHaveBeenCalled(); + }); + + it('delegates getMaxLeverage to default provider', async () => { + await aggregatedProvider.getMaxLeverage('BTC'); + + expect(mockHLProvider.getMaxLeverage).toHaveBeenCalledWith('BTC'); + }); + + it('delegates calculateMaintenanceMargin to default provider', async () => { + mockHLProvider.calculateMaintenanceMargin.mockResolvedValue(0.05); + + const result = await aggregatedProvider.calculateMaintenanceMargin({ + asset: 'BTC', + positionSize: 1, + }); + + expect(result).toBe(0.05); + expect(mockHLProvider.calculateMaintenanceMargin).toHaveBeenCalled(); + }); + + it('delegates calculateFees to default provider', async () => { + mockHLProvider.calculateFees.mockResolvedValue({ feeRate: 0.001 }); + + const result = await aggregatedProvider.calculateFees({ + orderType: 'market', + symbol: 'BTC', + }); + + expect(result).toEqual({ feeRate: 0.001 }); + expect(mockHLProvider.calculateFees).toHaveBeenCalled(); + }); + + it('delegates previewPositionModify to default provider', async () => { + mockHLProvider.previewPositionModify.mockResolvedValue({ + status: 'none', + }); + + const params = { + position: createMockPosition('BTC', '1'), + direction: 'long' as const, + size: '0.1', + price: '50000', + leverage: 10, + }; + + await aggregatedProvider.previewPositionModify(params); + + expect(mockHLProvider.previewPositionModify).toHaveBeenCalledWith(params); + }); + + it('routes previewPositionModify to an explicit provider', async () => { + mockMYXProvider.previewPositionModify.mockResolvedValue({ + status: 'unsupported', + reason: 'provider', + }); + + const params = { + position: createMockPosition('RHEA', '1'), + direction: 'long' as const, + size: '0.1', + price: '1', + leverage: 5, + providerId: 'myx' as const, + }; + + await expect( + aggregatedProvider.previewPositionModify(params), + ).resolves.toStrictEqual({ + status: 'unsupported', + reason: 'provider', + }); + expect(mockMYXProvider.previewPositionModify).toHaveBeenCalledWith( + params, + ); + expect(mockHLProvider.previewPositionModify).not.toHaveBeenCalled(); + }); + + it('routes previewPositionModify from position.providerId', async () => { + mockMYXProvider.previewPositionModify.mockResolvedValue({ + status: 'unsupported', + reason: 'provider', + }); + + const params = { + position: { + ...createMockPosition('RHEA', '1'), + providerId: 'myx' as const, + }, + direction: 'long' as const, + size: '0.1', + price: '1', + leverage: 5, + }; + + await expect( + aggregatedProvider.previewPositionModify(params), + ).resolves.toStrictEqual({ + status: 'unsupported', + reason: 'provider', + }); + expect(mockMYXProvider.previewPositionModify).toHaveBeenCalledWith( + params, + ); + expect(mockHLProvider.previewPositionModify).not.toHaveBeenCalled(); + }); + + it('rejects an unregistered previewPositionModify route', async () => { + aggregatedProvider.removeProvider('myx'); + + await expect( + aggregatedProvider.previewPositionModify({ + position: createMockPosition('BTC', '1'), + direction: 'long', + size: '0.1', + price: '50000', + leverage: 10, + providerId: 'myx', + }), + ).rejects.toThrow(PERPS_ERROR_CODES.PROVIDER_NOT_FOUND); + + expect(mockHLProvider.previewPositionModify).not.toHaveBeenCalled(); + expect(mockMYXProvider.previewPositionModify).not.toHaveBeenCalled(); + }); + + it('accepts an ordinary fee request held as the routed parameter type', async () => { + const params: FeeCalculationParams = { + orderType: 'market', + symbol: 'BTC', + }; + + await aggregatedProvider.calculateFees(params); + + expect(mockHLProvider.calculateFees).toHaveBeenCalledWith(params); + }); + + it('routes calculateFees to an explicit provider', async () => { + mockMYXProvider.calculateFees.mockResolvedValue({ feeRate: 0.002 }); + + await expect( + routedProvider.calculateFees({ + orderType: 'market', + symbol: 'RHEA', + providerId: 'myx', + }), + ).resolves.toEqual({ feeRate: 0.002 }); + expect(mockMYXProvider.calculateFees).toHaveBeenCalledWith({ + orderType: 'market', + symbol: 'RHEA', + providerId: 'myx', + }); + expect(mockHLProvider.calculateFees).not.toHaveBeenCalled(); + }); + + it('rejects an unregistered ordinary fee route', async () => { + mockHLProvider.calculateFees.mockResolvedValue({ feeRate: 0.001 }); + aggregatedProvider.removeProvider('myx'); + + await expect( + routedProvider.calculateFees({ + orderType: 'market', + symbol: 'BTC', + providerId: 'myx', + }), + ).rejects.toThrow(PERPS_ERROR_CODES.PROVIDER_NOT_FOUND); + + expect(mockHLProvider.calculateFees).not.toHaveBeenCalled(); + expect(mockMYXProvider.calculateFees).not.toHaveBeenCalled(); + }); + + it.each(STRATEGY_ORDER_TYPES)( + 'routes a %s fee quote without providerId to the default provider', + async (orderType) => { + const params = { + orderType, + symbol: 'BTC', + }; + + await routedProvider.calculateFees(params); + + expect(mockHLProvider.calculateFees).toHaveBeenCalledWith(params); + expect(mockMYXProvider.calculateFees).not.toHaveBeenCalled(); + }, + ); + + it.each(STRATEGY_ORDER_TYPES)( + 'routes a %s fee quote to its explicit provider', + async (orderType) => { + mockMYXProvider.calculateFees.mockRejectedValueOnce( + new Error(PERPS_ERROR_CODES.ORDER_STRATEGY_MARKET_UNSUPPORTED), + ); + + await expect( + aggregatedProvider.calculateFees({ + orderType, + symbol: 'RHEA', + providerId: 'myx', + }), + ).rejects.toThrow(PERPS_ERROR_CODES.ORDER_STRATEGY_MARKET_UNSUPPORTED); + expect(mockMYXProvider.calculateFees).toHaveBeenCalledWith({ + orderType, + symbol: 'RHEA', + providerId: 'myx', + }); + expect(mockHLProvider.calculateFees).not.toHaveBeenCalled(); + }, + ); + + it('gets order capabilities from the default provider', async () => { + const capabilities = Object.freeze({ + status: 'ready', + providerId: 'hyperliquid', + supportedStrategies: Object.freeze(['twap', 'scale', 'chase']), + }); + mockHLProvider.getOrderCapabilities.mockResolvedValue(capabilities); + + await expect( + aggregatedProvider.getOrderCapabilities({ symbol: 'BTC' }), + ).resolves.toStrictEqual(capabilities); + expect(mockHLProvider.getOrderCapabilities).toHaveBeenCalledWith({ + symbol: 'BTC', + providerId: 'hyperliquid', + }); + }); + + it('gets order capabilities from an explicit provider route', async () => { + mockMYXProvider.getOrderCapabilities.mockResolvedValue({ + status: 'ready', + providerId: 'myx', + supportedStrategies: [], + }); + + await expect( + aggregatedProvider.getOrderCapabilities({ + symbol: 'BTC', + providerId: 'myx', + }), + ).resolves.toStrictEqual({ + status: 'ready', + providerId: 'myx', + supportedStrategies: [], + }); + expect(mockMYXProvider.getOrderCapabilities).toHaveBeenCalledWith({ + symbol: 'BTC', + providerId: 'myx', + }); + }); + + it('rejects capabilities attributed to a different provider', async () => { + mockMYXProvider.getOrderCapabilities.mockResolvedValue({ + status: 'ready', + providerId: 'hyperliquid', + supportedStrategies: [], + }); + + await expect( + aggregatedProvider.getOrderCapabilities({ + symbol: 'BTC', + providerId: 'myx', + }), + ).resolves.toStrictEqual({ + status: 'unavailable', + providerId: 'myx', + reason: 'provider_not_routable', + }); + }); + + it('attaches the route when unavailable capabilities omit provider identity', async () => { + mockMYXProvider.getOrderCapabilities.mockResolvedValue({ + status: 'unavailable', + reason: 'market_not_found', + }); + + await expect( + aggregatedProvider.getOrderCapabilities({ + symbol: 'BTC', + providerId: 'myx', + }), + ).resolves.toStrictEqual({ + status: 'unavailable', + providerId: 'myx', + reason: 'market_not_found', + }); + }); + + it('reports an explicit provider route that is not registered', async () => { + const providerWithoutMyx = new AggregatedPerpsProvider({ + providers: new Map([['hyperliquid', mockHLProvider]]), + defaultProvider: 'hyperliquid', + infrastructure: mockInfrastructure, + }); + + await expect( + providerWithoutMyx.getOrderCapabilities({ + symbol: 'BTC', + providerId: 'myx', + }), + ).resolves.toStrictEqual({ + status: 'unavailable', + providerId: 'myx', + reason: 'provider_not_found', + }); + expect(mockHLProvider.getOrderCapabilities).not.toHaveBeenCalled(); + }); + + it('reports unavailable when the routed provider omits the optional hook', async () => { + mockHLProvider.getOrderCapabilities = undefined; + + await expect( + aggregatedProvider.getOrderCapabilities({ symbol: 'BTC' }), + ).resolves.toStrictEqual({ + status: 'unavailable', + providerId: 'hyperliquid', + reason: 'not_implemented', + }); + }); + + it('reports unavailable when the routed capability read throws', async () => { + mockHLProvider.getOrderCapabilities.mockRejectedValueOnce( + new Error('offline'), + ); + + await expect( + aggregatedProvider.getOrderCapabilities({ symbol: 'BTC' }), + ).resolves.toStrictEqual({ + status: 'unavailable', + providerId: 'hyperliquid', + reason: 'provider_unavailable', + }); + expect(mockInfrastructure.debugLogger.log).toHaveBeenCalledWith( + '[AggregatedPerpsProvider] Order capabilities unavailable', + { providerId: 'hyperliquid', error: 'offline' }, + ); + }); + }); + + describe('Subscriptions', () => { + it('subscribes to prices via multiplexer', () => { + const callback = jest.fn(); + aggregatedProvider.subscribeToPrices({ + symbols: ['BTC'], + callback, + }); + + expect(mockHLProvider.subscribeToPrices).toHaveBeenCalled(); + expect(mockMYXProvider.subscribeToPrices).toHaveBeenCalled(); + }); + + it('delegates account subscription to default provider', () => { + const callback = jest.fn(); + aggregatedProvider.subscribeToAccount({ callback }); + + expect(mockHLProvider.subscribeToAccount).toHaveBeenCalled(); + }); + + it('delegates subscribeToOICaps to default provider', () => { + const callback = jest.fn(); + + aggregatedProvider.subscribeToOICaps({ callback }); + + expect(mockHLProvider.subscribeToOICaps).toHaveBeenCalled(); + }); + + it('delegates subscribeToCandles to default provider', () => { + const callback = jest.fn(); + + aggregatedProvider.subscribeToCandles({ + symbol: 'BTC', + interval: CandlePeriod.OneHour, + callback, + }); + + expect(mockHLProvider.subscribeToCandles).toHaveBeenCalled(); + }); + + it('delegates subscribeToOrderBook to default provider', () => { + const callback = jest.fn(); + + aggregatedProvider.subscribeToOrderBook({ symbol: 'BTC', callback }); + + expect(mockHLProvider.subscribeToOrderBook).toHaveBeenCalled(); + }); + }); + + describe('WebSocket', () => { + it('delegates getWebSocketConnectionState to default provider', () => { + // Arrange + ( + mockHLProvider as jest.Mocked & { + getWebSocketConnectionState: jest.Mock; + } + ).getWebSocketConnectionState = jest + .fn() + .mockReturnValue(WebSocketConnectionState.Connected); + + // Act + const result = aggregatedProvider.getWebSocketConnectionState(); + + // Assert + expect(result).toBe(WebSocketConnectionState.Connected); + }); + + it('returns Disconnected when provider lacks getWebSocketConnectionState', () => { + // Arrange — provider without the optional method + const noWsProvider = createMockProvider('no-ws'); + const testProvider = new AggregatedPerpsProvider({ + providers: new Map([['no-ws' as PerpsProviderType, noWsProvider]]), + defaultProvider: 'no-ws' as PerpsProviderType, + infrastructure: mockInfrastructure, + }); + + // Act + const result = testProvider.getWebSocketConnectionState(); + + // Assert + expect(result).toBe(WebSocketConnectionState.Disconnected); + }); + + it('delegates subscribeToConnectionState to default provider', () => { + // Arrange + const unsubscribe = jest.fn(); + ( + mockHLProvider as jest.Mocked & { + subscribeToConnectionState: jest.Mock; + } + ).subscribeToConnectionState = jest.fn().mockReturnValue(unsubscribe); + const listener = jest.fn(); + + // Act + const cleanup = aggregatedProvider.subscribeToConnectionState(listener); + + // Assert + expect(cleanup).toBe(unsubscribe); + }); + + it('calls listener with Disconnected when provider lacks subscribeToConnectionState', () => { + // Arrange + const noWsProvider = createMockProvider('no-ws'); + const testProvider = new AggregatedPerpsProvider({ + providers: new Map([['no-ws' as PerpsProviderType, noWsProvider]]), + defaultProvider: 'no-ws' as PerpsProviderType, + infrastructure: mockInfrastructure, + }); + const listener = jest.fn(); + + // Act + const cleanup = testProvider.subscribeToConnectionState(listener); + cleanup(); + + // Assert + expect(listener).toHaveBeenCalledWith( + WebSocketConnectionState.Disconnected, + 0, + ); + }); + + it('delegates reconnect to default provider', async () => { + // Arrange + ( + mockHLProvider as jest.Mocked & { + reconnect: jest.Mock; + } + ).reconnect = jest.fn().mockResolvedValue(undefined); + + // Act + await aggregatedProvider.reconnect(); + + // Assert + expect( + ( + mockHLProvider as jest.Mocked & { + reconnect: jest.Mock; + } + ).reconnect, + ).toHaveBeenCalled(); + }); + + it('does not throw when provider lacks reconnect', async () => { + // Arrange — provider without reconnect + const noWsProvider = createMockProvider('no-ws'); + const testProvider = new AggregatedPerpsProvider({ + providers: new Map([['no-ws' as PerpsProviderType, noWsProvider]]), + defaultProvider: 'no-ws' as PerpsProviderType, + infrastructure: mockInfrastructure, + }); + + // Act & Assert + await expect(testProvider.reconnect()).resolves.toBeUndefined(); + }); + }); +}); diff --git a/packages/perps-controller/tests/src/providers/HyperLiquidProvider.account-mode.test.ts b/packages/perps-controller/tests/src/providers/HyperLiquidProvider.account-mode.test.ts new file mode 100644 index 00000000000..91efa48e2ac --- /dev/null +++ b/packages/perps-controller/tests/src/providers/HyperLiquidProvider.account-mode.test.ts @@ -0,0 +1,2251 @@ +/* eslint-disable */ +jest.mock('@nktkas/hyperliquid', () => ({})); + +import type { CaipAssetId, Hex } from '@metamask/utils'; + +import { CandlePeriod } from '../../../src/constants/chartConfig.js'; +import { + BUILDER_FEE_CONFIG, + REFERRAL_CONFIG, +} from '../../../src/constants/hyperLiquidConfig.js'; +import { PERPS_TRANSACTIONS_HISTORY_CONSTANTS } from '../../../src/constants/transactionsHistoryConfig.js'; +import { PERPS_ERROR_CODES } from '../../../src/perpsErrorCodes.js'; +import { HyperLiquidProvider } from '../../../src/providers/HyperLiquidProvider.js'; +import { HyperLiquidClientService } from '../../../src/services/HyperLiquidClientService.js'; +import { HyperLiquidSubscriptionService } from '../../../src/services/HyperLiquidSubscriptionService.js'; +import { HyperLiquidWalletService } from '../../../src/services/HyperLiquidWalletService.js'; +import { TradingReadinessCache } from '../../../src/services/TradingReadinessCache.js'; +import type { + ClosePositionParams, + DepositParams, + Order, + PerpsPlatformDependencies, + LiveDataConfig, + OrderParams, +} from '../../../src/types/index.js'; +import { + validateAssetSupport, + validateBalance, + validateCoinExists, + validateDepositParams, + validateOrderParams, + validateWithdrawalParams, +} from '../../../src/utils/hyperLiquidValidation.js'; +import { createStandaloneInfoClient } from '../../../src/utils/standaloneInfoClient.js'; +import { + createMockInfrastructure, + createMockMessenger, +} from '../../helpers/serviceMocks.js'; + +jest.mock('../../../src/services/HyperLiquidClientService'); +jest.mock('../../../src/services/HyperLiquidWalletService'); +jest.mock('../../../src/services/HyperLiquidSubscriptionService'); +// Mock stream manager - will be set up in test +let mockStreamManagerInstance: any; +const mockGetStreamManagerInstance = jest.fn(() => mockStreamManagerInstance); +jest.mock( + '../../../../components/UI/Perps/providers/PerpsStreamManager', + () => ({ + getStreamManagerInstance: mockGetStreamManagerInstance, + }), + { virtual: true }, +); + +// Mock standalone info client for standalone mode tests +let mockStandaloneInfoClient: any; +jest.mock('../../../src/utils/standaloneInfoClient', () => ({ + ...jest.requireActual('../../../src/utils/standaloneInfoClient'), + createStandaloneInfoClient: jest.fn(() => mockStandaloneInfoClient), +})); + +jest.mock('../../../src/utils/hyperLiquidValidation', () => ({ + validateOrderParams: jest.fn(), + validateWithdrawalParams: jest.fn(), + validateDepositParams: jest.fn(), + validateCoinExists: jest.fn(), + validateAssetSupport: jest.fn(), + validateBalance: jest.fn(), + getSupportedPaths: jest + .fn() + .mockReturnValue([ + 'eip155:42161/erc20:0xa0b86a33e6776e681a06e0e1622c5e5e3e6a8b13/default', + 'eip155:1/erc20:0xa0b86a33e6776e681a06e0e1622c5e5e3e6a8b13/default', + ]), + getBridgeInfo: jest.fn().mockReturnValue({ + chainId: 'eip155:42161', + contractAddress: '0x1234567890123456789012345678901234567890', + }), + createErrorResult: jest.fn((error, defaultResponse) => ({ + ...defaultResponse, + success: false, + error: error instanceof Error ? error.message : String(error), + })), +})); + +// Mock adapter functions +jest.mock('../../../src/utils/hyperLiquidAdapter', () => { + const actual = jest.requireActual('../../../src/utils/hyperLiquidAdapter'); + return { + ...actual, + adaptHyperLiquidLedgerUpdateToUserHistoryItem: jest.fn((updates) => { + // Return mock history items based on input + if (!updates || !Array.isArray(updates) || updates.length === 0) { + return []; + } + return updates.map((_update: unknown) => ({ + type: 'deposit' as const, + amount: '100', + timestamp: Date.now(), + hash: '0x123', + })); + }), + }; +}); + +// Mock TradingReadinessCache - global singleton for signing operation caching +// Use jest.createMockFromModule for proper mock creation +jest.mock('../../../src/services/TradingReadinessCache'); + +const MockedHyperLiquidClientService = + HyperLiquidClientService as jest.MockedClass; +const MockedHyperLiquidWalletService = + HyperLiquidWalletService as jest.MockedClass; +const MockedHyperLiquidSubscriptionService = + HyperLiquidSubscriptionService as jest.MockedClass< + typeof HyperLiquidSubscriptionService + >; +const mockValidateOrderParams = validateOrderParams as jest.MockedFunction< + typeof validateOrderParams +>; +const mockValidateWithdrawalParams = + validateWithdrawalParams as jest.MockedFunction< + typeof validateWithdrawalParams + >; +const mockValidateDepositParams = validateDepositParams as jest.MockedFunction< + typeof validateDepositParams +>; +const mockValidateCoinExists = validateCoinExists as jest.MockedFunction< + typeof validateCoinExists +>; +const mockValidateAssetSupport = validateAssetSupport as jest.MockedFunction< + typeof validateAssetSupport +>; +const mockValidateBalance = validateBalance as jest.MockedFunction< + typeof validateBalance +>; + +// Mock factory functions - defined once, reused everywhere +// These reduce duplication and make tests more maintainable +const createMockInfoClient = (overrides: Record = {}) => ({ + clearinghouseState: jest.fn().mockResolvedValue({ + marginSummary: { + totalMarginUsed: '500', + accountValue: '10500', + }, + withdrawable: '9500', + assetPositions: [ + { + position: { + coin: 'BTC', + szi: '0.1', + entryPx: '50000', + positionValue: '5000', + unrealizedPnl: '100', + marginUsed: '500', + leverage: { type: 'cross', value: 10 }, + liquidationPx: '45000', + maxLeverage: 50, + returnOnEquity: '20', + cumFunding: { allTime: '10', sinceOpen: '5', sinceChange: '2' }, + }, + type: 'oneWay', + }, + { + position: { + coin: 'ETH', + szi: '1.5', + entryPx: '3000', + positionValue: '4500', + unrealizedPnl: '50', + marginUsed: '450', + leverage: { type: 'cross', value: 10 }, + liquidationPx: '2700', + maxLeverage: 50, + returnOnEquity: '10', + cumFunding: { allTime: '5', sinceOpen: '2', sinceChange: '1' }, + }, + type: 'oneWay', + }, + ], + crossMarginSummary: { + accountValue: '10000', + totalMarginUsed: '5000', + }, + }), + spotClearinghouseState: jest.fn().mockResolvedValue({ + balances: [{ coin: 'USDC', hold: '1000', total: '10000' }], + }), + // Mode-aware fold gate reads userAbstraction; default to unifiedAccount + // so tests that predated the gate still see spot folded into spendable/withdrawable. + userAbstraction: jest.fn().mockResolvedValue('unifiedAccount'), + // Single-signer account by default; Hyperliquid returns null when the user + // has no multi-sig signer set. + userToMultiSigSigners: jest.fn().mockResolvedValue(null), + meta: jest.fn().mockResolvedValue({ + universe: [ + { name: 'BTC', szDecimals: 3, maxLeverage: 50 }, + { name: 'ETH', szDecimals: 4, maxLeverage: 50 }, + ], + }), + metaAndAssetCtxs: jest.fn().mockResolvedValue([ + { + universe: [ + { name: 'BTC', szDecimals: 3, maxLeverage: 50 }, + { name: 'ETH', szDecimals: 4, maxLeverage: 50 }, + ], + }, + [ + { + funding: '0.0001', + openInterest: '1000', + prevDayPx: '49000', + dayNtlVlm: '1000000', + markPx: '50000', + midPx: '50000', + oraclePx: '50000', + }, + { + funding: '0.0001', + openInterest: '500', + prevDayPx: '2900', + dayNtlVlm: '500000', + markPx: '3000', + midPx: '3000', + oraclePx: '3000', + }, + ], + ]), + perpDexs: jest.fn().mockResolvedValue([null]), + allMids: jest.fn().mockResolvedValue({ BTC: '50000', ETH: '3000' }), + frontendOpenOrders: jest.fn().mockResolvedValue([]), + referral: jest.fn().mockResolvedValue({ + referrerState: { + stage: 'ready', + data: { code: 'MMCSI' }, + }, + }), + maxBuilderFee: jest.fn().mockResolvedValue(1), + userFees: jest.fn().mockResolvedValue({ + feeSchedule: { + cross: '0.00030', + add: '0.00010', + spotCross: '0.00040', + spotAdd: '0.00020', + }, + dailyUserVlm: [], + }), + userNonFundingLedgerUpdates: jest.fn().mockResolvedValue([ + { + delta: { type: 'deposit', usdc: '100' }, + time: Date.now(), + hash: '0x123abc', + }, + { + delta: { type: 'withdraw', usdc: '50' }, + time: Date.now() - 3600000, + hash: '0x456def', + }, + ]), + portfolio: jest.fn().mockResolvedValue([ + null, + [ + null, + { + accountValueHistory: [ + [Date.now() - 86400000, '10000'], // 24h ago + [Date.now() - 172800000, '9500'], // 48h ago + [Date.now() - 259200000, '9000'], // 72h ago + ], + }, + ], + ]), + spotMeta: jest.fn().mockResolvedValue({ + tokens: [ + { name: 'USDC', tokenId: '0xdef456', index: 0 }, + { name: 'USDT', tokenId: '0x789abc', index: 1 }, + ], + universe: [], + }), + historicalOrders: jest.fn().mockResolvedValue([]), + userFills: jest.fn().mockResolvedValue([]), + userFillsByTime: jest.fn().mockResolvedValue([]), + userFunding: jest.fn().mockResolvedValue([]), + ...overrides, +}); + +const createMockExchangeClient = (overrides: Record = {}) => ({ + order: jest.fn().mockResolvedValue({ + status: 'ok', + response: { data: { statuses: [{ resting: { oid: 123 } }] } }, + }), + modify: jest.fn().mockResolvedValue({ + status: 'ok', + response: { data: { statuses: [{ resting: { oid: '123' } }] } }, + }), + cancel: jest.fn().mockResolvedValue({ + status: 'ok', + response: { data: { statuses: ['success'] } }, + }), + withdraw3: jest.fn().mockResolvedValue({ + status: 'ok', + }), + updateLeverage: jest.fn().mockResolvedValue({ + status: 'ok', + }), + approveBuilderFee: jest.fn().mockResolvedValue({ + status: 'ok', + }), + setReferrer: jest.fn().mockResolvedValue({ + status: 'ok', + }), + sendAsset: jest.fn().mockResolvedValue({ + status: 'ok', + }), + agentSetAbstraction: jest.fn().mockResolvedValue({ + status: 'ok', + }), + userSetAbstraction: jest.fn().mockResolvedValue({ + status: 'ok', + }), + ...overrides, +}); + +// Create shared mock platform dependencies for provider tests +const mockPlatformDependencies: PerpsPlatformDependencies = + createMockInfrastructure(); + +const mockMessenger = createMockMessenger(); + +/** + * Helper to create HyperLiquidProvider with mock platform dependencies + * @param options + * @param options.isTestnet + * @param options.hip3Enabled + * @param options.allowlistMarkets + * @param options.blocklistMarkets + * @param options.useUnifiedAccount + */ +const createTestProvider = ( + options: { + isTestnet?: boolean; + hip3Enabled?: boolean; + allowlistMarkets?: string[]; + blocklistMarkets?: string[]; + useUnifiedAccount?: boolean; + initialAssetMapping?: [string, number][]; + } = {}, +): HyperLiquidProvider => + new HyperLiquidProvider({ + ...options, + platformDependencies: mockPlatformDependencies, + messenger: mockMessenger, + }); + +describe('HyperLiquidProvider', () => { + let provider: HyperLiquidProvider; + let mockClientService: jest.Mocked; + let mockWalletService: jest.Mocked; + let mockSubscriptionService: jest.Mocked; + + beforeEach(() => { + // Reset all mocks + jest.clearAllMocks(); + ( + mockPlatformDependencies.marketDataFormatters.formatVolume as jest.Mock + ).mockImplementation((value: number) => '$' + value.toFixed(0)); + ( + mockPlatformDependencies.marketDataFormatters.formatPerpsFiat as jest.Mock + ).mockImplementation((value: number) => '$' + value.toFixed(2)); + ( + mockPlatformDependencies.marketDataFormatters + .formatPercentage as jest.Mock + ).mockImplementation((value: number) => `${value.toFixed(2)}%`); + ( + mockPlatformDependencies.featureFlags.validateVersionGated as jest.Mock + ).mockReturnValue(undefined); + (mockPlatformDependencies.metrics.isEnabled as jest.Mock).mockReturnValue( + true, + ); + + // Reset TradingReadinessCache mock state (using imported mocked module) + const mockedCache = TradingReadinessCache as jest.Mocked< + typeof TradingReadinessCache + >; + mockedCache.get.mockReturnValue(undefined); + mockedCache.getBuilderFee.mockReturnValue(undefined); + mockedCache.getReferral.mockReturnValue(undefined); + mockedCache.isInFlight.mockReturnValue(undefined); + mockedCache.setInFlight.mockReturnValue(jest.fn()); + + // Initialize mock stream manager instance + mockStreamManagerInstance = { + clearAllChannels: jest.fn(), + }; + + // Create mocked service instances using factory functions + mockClientService = { + initialize: jest.fn(), + isInitialized: jest.fn().mockReturnValue(true), + isTestnetMode: jest.fn().mockReturnValue(false), + ensureInitialized: jest.fn(), + getExchangeClient: jest.fn().mockReturnValue(createMockExchangeClient()), + getInfoClient: jest.fn().mockReturnValue(createMockInfoClient()), + fetchHistoricalOrders: jest.fn().mockResolvedValue([]), + disconnect: jest.fn().mockResolvedValue(undefined), + toggleTestnet: jest.fn(), + setTestnetMode: jest.fn(), + getNetwork: jest.fn().mockReturnValue('mainnet'), + ensureSubscriptionClient: jest.fn().mockResolvedValue(undefined), + getSubscriptionClient: jest.fn(), + setOnReconnectCallback: jest.fn(), + setOnTerminateCallback: jest.fn(), + getConnectionState: jest.fn().mockReturnValue('connected'), + } as Partial as jest.Mocked; + + mockWalletService = { + setTestnetMode: jest.fn(), + getCurrentAccountId: jest + .fn() + .mockReturnValue( + 'eip155:42161:0x1234567890123456789012345678901234567890', + ), + createWalletAdapter: jest.fn().mockReturnValue({ + request: jest + .fn() + .mockResolvedValue(['0x1234567890123456789012345678901234567890']), + }), + getUserAddress: jest + .fn() + .mockReturnValue('0x1234567890123456789012345678901234567890'), + getUserAddressWithDefault: jest + .fn() + .mockResolvedValue('0x1234567890123456789012345678901234567890'), + isKeyringUnlocked: jest.fn().mockReturnValue(true), + isSelectedHardwareWallet: jest.fn().mockReturnValue(false), + } as Partial as jest.Mocked; + + mockSubscriptionService = { + subscribeToPrices: jest.fn().mockResolvedValue(jest.fn()), // Returns Promise + subscribeToPositions: jest.fn().mockReturnValue(jest.fn()), // Returns function directly + subscribeToOrderFills: jest.fn().mockReturnValue(jest.fn()), // Returns function directly + clearAll: jest.fn(), + isPositionsCacheInitialized: jest.fn().mockReturnValue(false), + getCachedPositions: jest.fn().mockReturnValue([]), + updateFeatureFlags: jest.fn().mockResolvedValue(undefined), + // Cache methods used by buildAssetMapping optimization + setDexMetaCache: jest.fn(), + setDexAssetCtxsCache: jest.fn(), + getDexAssetCtxsCache: jest.fn().mockReturnValue(undefined), + // Price cache used by placeOrder, editOrder, closePosition optimizations + getCachedPrice: jest.fn().mockImplementation((symbol: string) => { + const prices: Record = { BTC: '50000', ETH: '3000' }; + return prices[symbol]; + }), + getLastAllMidsSnapshot: jest.fn().mockReturnValue(null), + // Orders cache used by updatePositionTPSL and getOpenOrders + isOrdersCacheInitialized: jest.fn().mockReturnValue(false), + getCachedOrders: jest.fn().mockReturnValue([]), + // Atomic getter - returns null when cache not initialized (prevents race condition) + getOrdersCacheIfInitialized: jest.fn().mockReturnValue(null), + // Abstraction-mode resolved-mode setter (unified account migration) + setUserAbstractionMode: jest.fn(), + } as Partial as jest.Mocked; + + // Mock constructors + MockedHyperLiquidClientService.mockImplementation(() => mockClientService); + MockedHyperLiquidWalletService.mockImplementation(() => mockWalletService); + MockedHyperLiquidSubscriptionService.mockImplementation( + () => mockSubscriptionService, + ); + + // Mock validation + mockValidateOrderParams.mockReturnValue({ isValid: true }); + mockValidateWithdrawalParams.mockReturnValue({ isValid: true }); + mockValidateDepositParams.mockReturnValue({ isValid: true }); + mockValidateCoinExists.mockReturnValue({ isValid: true }); + mockValidateAssetSupport.mockReturnValue({ isValid: true }); + mockValidateBalance.mockReturnValue({ isValid: true }); + const hyperLiquidValidation = jest.requireMock( + '../../../src/utils/hyperLiquidValidation', + ); + hyperLiquidValidation.getSupportedPaths.mockReturnValue([ + 'eip155:42161/erc20:0xa0b86a33e6776e681a06e0e1622c5e5e3e6a8b13/default', + 'eip155:1/erc20:0xa0b86a33e6776e681a06e0e1622c5e5e3e6a8b13/default', + ]); + hyperLiquidValidation.getBridgeInfo.mockReturnValue({ + chainId: 'eip155:42161', + contractAddress: '0x1234567890123456789012345678901234567890', + }); + hyperLiquidValidation.createErrorResult.mockImplementation( + (error: unknown, defaultResponse: Record) => ({ + ...defaultResponse, + success: false, + error: error instanceof Error ? error.message : String(error), + }), + ); + const hyperLiquidAdapter = jest.requireMock( + '../../../src/utils/hyperLiquidAdapter', + ); + hyperLiquidAdapter.adaptHyperLiquidLedgerUpdateToUserHistoryItem.mockImplementation( + (updates: unknown[]) => { + if (!updates || !Array.isArray(updates) || updates.length === 0) { + return []; + } + return updates.map(() => ({ + type: 'deposit' as const, + amount: '100', + timestamp: Date.now(), + hash: '0x123', + })); + }, + ); + + provider = createTestProvider({ + initialAssetMapping: [ + ['BTC', 0], + ['ETH', 1], + ], + }); + }); + describe('getUserNonFundingLedgerUpdates', () => { + it('returns non-funding ledger updates', async () => { + // Arrange + const mockUpdates = [ + { + delta: { type: 'deposit', usdc: '100' }, + time: Date.now(), + hash: '0x123', + }, + { + delta: { type: 'withdraw', usdc: '50' }, + time: Date.now() - 3600000, + hash: '0x456', + }, + ]; + mockClientService.getInfoClient = jest.fn().mockReturnValue( + createMockInfoClient({ + userNonFundingLedgerUpdates: jest.fn().mockResolvedValue(mockUpdates), + }), + ); + + // Act + const result = await provider.getUserNonFundingLedgerUpdates(); + + // Assert + expect(Array.isArray(result)).toBe(true); + expect(result.length).toBe(2); + expect(mockClientService.getInfoClient).toHaveBeenCalled(); + }); + + it('returns empty array on error', async () => { + // Arrange + mockClientService.getInfoClient = jest.fn().mockReturnValue( + createMockInfoClient({ + userNonFundingLedgerUpdates: jest + .fn() + .mockRejectedValue(new Error('API Error')), + }), + ); + + // Act + const result = await provider.getUserNonFundingLedgerUpdates(); + + // Assert + expect(result).toEqual([]); + }); + }); + + // TODO: Refactor to test through public API — ES # private fields prevent direct access + describe.skip('HIP-3 Private Methods', () => { + interface ProviderWithPrivateMethods { + getUsdcTokenId(): Promise; + getBalanceForDex(params: { dex: string | null }): Promise; + findSourceDexWithBalance(params: { + targetDex: string; + requiredAmount: number; + }): Promise<{ sourceDex: string; available: number } | null>; + cachedUsdcTokenId?: string; + } + + let testableProvider: ProviderWithPrivateMethods; + + beforeEach(() => { + testableProvider = provider as unknown as ProviderWithPrivateMethods; + // Reset cache + testableProvider.cachedUsdcTokenId = undefined; + }); + + describe('getUsdcTokenId', () => { + it('returns cached token ID when available', async () => { + // Arrange + testableProvider.cachedUsdcTokenId = 'USDC:0xabc123'; + + // Act + const result = await testableProvider.getUsdcTokenId(); + + // Assert + expect(result).toBe('USDC:0xabc123'); + expect(mockClientService.getInfoClient).not.toHaveBeenCalled(); + }); + + it('fetches and caches token ID on first call', async () => { + // Arrange + const mockSpotMeta = { + tokens: [ + { name: 'USDC', tokenId: '0xdef456', index: 0 }, + { name: 'USDT', tokenId: '0x789abc', index: 1 }, + ], + universe: [], + }; + mockClientService.getInfoClient = jest.fn().mockReturnValue( + createMockInfoClient({ + spotMeta: jest.fn().mockResolvedValue(mockSpotMeta), + }), + ); + + // Act + const result = await testableProvider.getUsdcTokenId(); + + // Assert + expect(result).toBe('USDC:0xdef456'); + expect(testableProvider.cachedUsdcTokenId).toBe('USDC:0xdef456'); + expect(mockClientService.getInfoClient).toHaveBeenCalledTimes(1); + }); + + it('throws error when USDC token not found in metadata', async () => { + // Arrange + const mockSpotMeta = { + tokens: [{ name: 'USDT', tokenId: '0x789abc', index: 0 }], + universe: [], + }; + mockClientService.getInfoClient = jest.fn().mockReturnValue( + createMockInfoClient({ + spotMeta: jest.fn().mockResolvedValue(mockSpotMeta), + }), + ); + + // Act & Assert + await expect(testableProvider.getUsdcTokenId()).rejects.toThrow( + 'USDC token not found in spot metadata', + ); + }); + }); + + describe('findSourceDexWithBalance', () => { + it('finds main DEX with sufficient balance', async () => { + jest + .spyOn(testableProvider, 'getBalanceForDex') + .mockResolvedValue(1000); + const result = await testableProvider.findSourceDexWithBalance({ + targetDex: 'xyz', + requiredAmount: 500, + }); + expect(result).toEqual({ sourceDex: '', available: 1000 }); + }); + + it('returns null when insufficient balance', async () => { + jest.spyOn(testableProvider, 'getBalanceForDex').mockResolvedValue(100); + const result = await testableProvider.findSourceDexWithBalance({ + targetDex: 'xyz', + requiredAmount: 500, + }); + expect(result).toBeNull(); + }); + }); + + describe('getAllAvailableDexs', () => { + interface ProviderWithDexMethods { + getAllAvailableDexs(): Promise<(string | null)[]>; + dexDiscoveryCache: { + state: { + raw: ({ name: string; url: string } | null)[]; + validated: (string | null)[]; + timestamp: number; + } | null; + reset(): void; + }; + } + + let testableProvider: ProviderWithDexMethods; + + beforeEach(() => { + testableProvider = provider as unknown as ProviderWithDexMethods; + // Reset unified state + testableProvider.dexDiscoveryCache.reset(); + }); + + it('returns cached DEX list when cache is populated', async () => { + // Arrange + testableProvider.dexDiscoveryCache.state = { + raw: [ + null, + { name: 'dex1', url: 'https://dex1.example' }, + { name: 'dex2', url: 'https://dex2.example' }, + ], + validated: [null, 'dex1', 'dex2'], + timestamp: Date.now(), + }; + + // Act + const result = await testableProvider.getAllAvailableDexs(); + + // Assert + expect(result).toEqual([null, 'dex1', 'dex2']); + expect(mockClientService.getInfoClient).not.toHaveBeenCalled(); + }); + + it('fetches DEX list from API when cache is empty', async () => { + // Arrange + const mockDexs = [ + null, + { name: 'dex1', url: 'https://dex1.example' }, + { name: 'dex2', url: 'https://dex2.example' }, + ]; + mockClientService.getInfoClient = jest.fn().mockReturnValue( + createMockInfoClient({ + perpDexs: jest.fn().mockResolvedValue(mockDexs), + }), + ); + + // Act + const result = await testableProvider.getAllAvailableDexs(); + + // Assert + expect(result).toEqual([null, 'dex1', 'dex2']); + expect(testableProvider.dexDiscoveryCache.state?.raw).toEqual(mockDexs); + expect(mockClientService.getInfoClient).toHaveBeenCalledTimes(1); + }); + + it('returns fallback when API returns null', async () => { + // Arrange + mockClientService.getInfoClient = jest.fn().mockReturnValue( + createMockInfoClient({ + perpDexs: jest.fn().mockResolvedValue(null), + }), + ); + + // Act + const result = await testableProvider.getAllAvailableDexs(); + + // Assert + expect(result).toEqual([null]); + expect(testableProvider.dexDiscoveryCache.state).toBeNull(); + }); + + it('returns fallback when API returns non-array', async () => { + // Arrange + mockClientService.getInfoClient = jest.fn().mockReturnValue( + createMockInfoClient({ + perpDexs: jest.fn().mockResolvedValue({ invalid: 'data' }), + }), + ); + + // Act + const result = await testableProvider.getAllAvailableDexs(); + + // Assert + expect(result).toEqual([null]); + expect(testableProvider.dexDiscoveryCache.state).toBeNull(); + }); + + it('returns fallback and logs error when API throws', async () => { + // Arrange + const mockError = new Error('Network error'); + mockClientService.getInfoClient = jest.fn().mockReturnValue( + createMockInfoClient({ + perpDexs: jest.fn().mockRejectedValue(mockError), + }), + ); + (mockPlatformDependencies.logger.error as jest.Mock).mockClear(); + + // Act + const result = await testableProvider.getAllAvailableDexs(); + + // Assert + expect(result).toEqual([null]); + expect(testableProvider.dexDiscoveryCache.state).toBeNull(); + expect(mockPlatformDependencies.logger.error).toHaveBeenCalledWith( + mockError, + expect.objectContaining({ + context: expect.objectContaining({ + name: 'HyperLiquidProvider', + data: expect.objectContaining({ + method: 'getAllAvailableDexs', + }), + }), + }), + ); + }); + + it('filters out null entries from cached DEX list', async () => { + // Arrange + testableProvider.dexDiscoveryCache.state = { + raw: [ + null, + { name: 'dex1', url: 'https://dex1.example' }, + null, + { name: 'dex2', url: 'https://dex2.example' }, + ], + validated: [null, 'dex1', 'dex2'], + timestamp: Date.now(), + }; + + // Act + const result = await testableProvider.getAllAvailableDexs(); + + // Assert + expect(result).toEqual([null, 'dex1', 'dex2']); + }); + + it('returns only main DEX when cached list contains only null', async () => { + // Arrange + testableProvider.dexDiscoveryCache.state = { + raw: [null], + validated: [null], + timestamp: Date.now(), + }; + + // Act + const result = await testableProvider.getAllAvailableDexs(); + + // Assert + expect(result).toEqual([null]); + }); + }); + + describe('ensureReadyForTrading', () => { + interface ProviderWithTradingSetup { + ensureReadyForTrading(): Promise; + ensureReady(): Promise; + tradingSetupComplete: boolean; + } + + let testableProvider: ProviderWithTradingSetup; + + beforeEach(() => { + testableProvider = provider as unknown as ProviderWithTradingSetup; + testableProvider.tradingSetupComplete = false; + }); + + it('calls ensureReady first before trading setup', async () => { + // Arrange - spy on ensureReady + const ensureReadySpy = jest + .spyOn(testableProvider, 'ensureReady') + .mockResolvedValue(); + + // Act + await testableProvider.ensureReadyForTrading(); + + // Assert + expect(ensureReadySpy).toHaveBeenCalled(); + }); + + it('returns immediately when tradingSetupComplete is true', async () => { + // Arrange + testableProvider.tradingSetupComplete = true; + const ensureReadySpy = jest + .spyOn(testableProvider, 'ensureReady') + .mockResolvedValue(); + + // Act + await testableProvider.ensureReadyForTrading(); + + // Assert - should call ensureReady but skip trading setup + expect(ensureReadySpy).toHaveBeenCalled(); + // No signing operations should be called + expect( + (TradingReadinessCache as jest.Mocked) + .setInFlight, + ).not.toHaveBeenCalled(); + }); + + it('sets tradingSetupComplete to true after successful setup', async () => { + // Arrange + jest.spyOn(testableProvider, 'ensureReady').mockResolvedValue(); + // Mock all caches as already attempted to skip signing + ( + TradingReadinessCache as jest.Mocked + ).get.mockReturnValue({ + attempted: true, + enabled: true, + timestamp: Date.now(), + }); + ( + TradingReadinessCache as jest.Mocked + ).getBuilderFee.mockReturnValue({ + attempted: true, + success: true, + }); + ( + TradingReadinessCache as jest.Mocked + ).getReferral.mockReturnValue({ + attempted: true, + success: true, + }); + + // Act + await testableProvider.ensureReadyForTrading(); + + // Assert + expect(testableProvider.tradingSetupComplete).toBe(true); + }); + + it('keeps tradingSetupComplete false when keyring is locked', async () => { + // Arrange + jest.spyOn(testableProvider, 'ensureReady').mockResolvedValue(); + // Mock all caches as already attempted to skip signing + ( + TradingReadinessCache as jest.Mocked + ).get.mockReturnValue({ + attempted: true, + enabled: true, + timestamp: Date.now(), + }); + ( + TradingReadinessCache as jest.Mocked + ).getBuilderFee.mockReturnValue({ + attempted: true, + success: true, + }); + ( + TradingReadinessCache as jest.Mocked + ).getReferral.mockReturnValue({ + attempted: true, + success: true, + }); + // Keyring is locked + ( + mockWalletService as unknown as { isKeyringUnlocked: jest.Mock } + ).isKeyringUnlocked.mockReturnValue(false); + + // Act + await testableProvider.ensureReadyForTrading(); + + // Assert - tradingSetupComplete should remain false + expect(testableProvider.tradingSetupComplete).toBe(false); + }); + }); + + describe('autoTransferForHip3Order', () => { + interface ProviderWithAutoTransfer { + autoTransferForHip3Order(params: { + targetDex: string; + requiredMargin: number; + }): Promise<{ amount: number; sourceDex: string } | null>; + getBalanceForDex(params: { dex: string | null }): Promise; + findSourceDexWithBalance(params: { + targetDex: string; + requiredAmount: number; + }): Promise<{ sourceDex: string; available: number } | null>; + transferBetweenDexs(params: { + sourceDex: string; + destinationDex: string; + amount: string; + }): Promise<{ success: boolean; error?: string }>; + } + + let testableProvider: ProviderWithAutoTransfer; + + beforeEach(() => { + testableProvider = provider as unknown as ProviderWithAutoTransfer; + }); + + it('returns null when target DEX has sufficient balance', async () => { + // Arrange + jest + .spyOn(testableProvider, 'getBalanceForDex') + .mockResolvedValue(1000); + + // Act + const result = await testableProvider.autoTransferForHip3Order({ + targetDex: 'xyz', + requiredMargin: 500, + }); + + // Assert + expect(result).toBeNull(); + }); + + it('transfers from main DEX when target has insufficient balance', async () => { + // Arrange + jest.spyOn(testableProvider, 'getBalanceForDex').mockResolvedValue(100); // Target has only 100 + jest + .spyOn(testableProvider, 'findSourceDexWithBalance') + .mockResolvedValue({ sourceDex: '', available: 1000 }); + jest + .spyOn(testableProvider, 'transferBetweenDexs') + .mockResolvedValue({ success: true }); + + // Act + const result = await testableProvider.autoTransferForHip3Order({ + targetDex: 'xyz', + requiredMargin: 500, + }); + + // Assert + expect(result).toEqual({ amount: expect.any(Number), sourceDex: '' }); + expect(testableProvider.transferBetweenDexs).toHaveBeenCalledWith({ + sourceDex: '', + destinationDex: 'xyz', + amount: expect.any(String), + }); + }); + + it('throws error when no source has sufficient balance', async () => { + // Arrange + jest.spyOn(testableProvider, 'getBalanceForDex').mockResolvedValue(100); // Target has only 100 + jest + .spyOn(testableProvider, 'findSourceDexWithBalance') + .mockResolvedValue(null); // No source found + + // Act & Assert + await expect( + testableProvider.autoTransferForHip3Order({ + targetDex: 'xyz', + requiredMargin: 500, + }), + ).rejects.toThrow('Insufficient balance for HIP-3 order'); + }); + + it('throws error when transfer fails', async () => { + // Arrange + jest.spyOn(testableProvider, 'getBalanceForDex').mockResolvedValue(100); + jest + .spyOn(testableProvider, 'findSourceDexWithBalance') + .mockResolvedValue({ sourceDex: '', available: 1000 }); + jest + .spyOn(testableProvider, 'transferBetweenDexs') + .mockResolvedValue({ success: false, error: 'Transfer failed' }); + + // Act & Assert + await expect( + testableProvider.autoTransferForHip3Order({ + targetDex: 'xyz', + requiredMargin: 500, + }), + ).rejects.toThrow('Auto-transfer failed: Transfer failed'); + }); + }); + + describe('calculateHip3RequiredMargin', () => { + interface ProviderWithMarginCalc { + calculateHip3RequiredMargin(params: { + symbol: string; + dexName: string; + positionSize: number; + orderPrice: number; + leverage: number; + isBuy: boolean; + }): Promise; + getPositions(): Promise< + { symbol: string; size: string; marginUsed: string }[] + >; + } + + let testableProvider: ProviderWithMarginCalc; + + beforeEach(() => { + testableProvider = provider as unknown as ProviderWithMarginCalc; + }); + + it('calculates total margin when increasing existing long position', async () => { + // Arrange + jest.spyOn(testableProvider, 'getPositions').mockResolvedValue([ + { + symbol: 'BTC', + size: '1.0', // Existing long position + marginUsed: '5000', + }, + ]); + + // Act + const result = await testableProvider.calculateHip3RequiredMargin({ + symbol: 'BTC', + dexName: 'xyz', + positionSize: 0.5, // Adding to position + orderPrice: 50000, + leverage: 10, + isBuy: true, // Long order - increasing position + }); + + // Assert + // Total size = 1.0 + 0.5 = 1.5 + // Total notional = 1.5 * 50000 = 75000 + // Total margin = 75000 / 10 = 7500 + // With buffer (1.003) = 7522.5 + expect(result).toBeCloseTo(7522.5, 1); + }); + + it('calculates incremental margin when reversing position', async () => { + // Arrange + jest.spyOn(testableProvider, 'getPositions').mockResolvedValue([ + { + symbol: 'BTC', + size: '1.0', // Existing long position + marginUsed: '5000', + }, + ]); + + // Act + const result = await testableProvider.calculateHip3RequiredMargin({ + symbol: 'BTC', + dexName: 'xyz', + positionSize: 0.5, + orderPrice: 50000, + leverage: 10, + isBuy: false, // Short order - opposite direction + }); + + // Assert + // Only new order margin (not total) + // Notional = 0.5 * 50000 = 25000 + // Margin = 25000 / 10 = 2500 + // With buffer (1.003) = 2507.5 + expect(result).toBeCloseTo(2507.5, 1); + }); + + it('calculates margin for new position when no existing position', async () => { + // Arrange + jest.spyOn(testableProvider, 'getPositions').mockResolvedValue([]); + + // Act + const result = await testableProvider.calculateHip3RequiredMargin({ + symbol: 'ETH', + dexName: 'xyz', + positionSize: 10, + orderPrice: 3000, + leverage: 5, + isBuy: true, + }); + + // Assert + // Notional = 10 * 3000 = 30000 + // Margin = 30000 / 5 = 6000 + // With buffer (1.003) = 6018 + expect(result).toBeCloseTo(6018, 1); + }); + + it('calculates total margin when increasing existing short position', async () => { + // Arrange + jest.spyOn(testableProvider, 'getPositions').mockResolvedValue([ + { + symbol: 'ETH', + size: '-5.0', // Existing short position + marginUsed: '3000', + }, + ]); + + // Act + const result = await testableProvider.calculateHip3RequiredMargin({ + symbol: 'ETH', + dexName: 'xyz', + positionSize: 2.0, // Adding to short + orderPrice: 3000, + leverage: 5, + isBuy: false, // Short order - increasing short position + }); + + // Assert + // Total size = 5.0 + 2.0 = 7.0 + // Total notional = 7.0 * 3000 = 21000 + // Total margin = 21000 / 5 = 4200 + // With buffer (1.003) = 4212.6 + expect(result).toBeCloseTo(4212.6, 1); + }); + }); + }); + + describe('ensureUnifiedAccountEnabled', () => { + // These tests verify the unified account migration behaviour that runs + // inside #ensureReady() → #ensureUnifiedAccountEnabled(). Because the + // method is native-private (#), we trigger it via the public + // getMarketDataWithPrices() entry point, which calls #ensureReady() on + // every fresh provider instance. + + // The user address used by mockWalletService.getUserAddressWithDefault + const USER_ADDRESS = '0x1234567890123456789012345678901234567890'; + + // ───────────────────────────────────────────────── + // Early-exit paths + // ───────────────────────────────────────────────── + + it('does not call userAbstraction when useUnifiedAccount is false', async () => { + // Arrange - provider created with the feature disabled + const disabledProvider = createTestProvider({ useUnifiedAccount: false }); + const mockInfoClient = createMockInfoClient({ + userAbstraction: jest.fn().mockResolvedValue('default'), + }); + mockClientService.getInfoClient = jest + .fn() + .mockReturnValue(mockInfoClient); + + // Act + await disabledProvider.getMarketDataWithPrices(); + + // Assert - userAbstraction never queried (feature is off) + expect(mockInfoClient.userAbstraction).not.toHaveBeenCalled(); + }); + + it('does not call userAbstraction when global cache indicates already attempted', async () => { + // Arrange - cache says setup was already tried (success or failure) + ( + TradingReadinessCache as jest.Mocked + ).get.mockReturnValue({ + attempted: true, + enabled: true, + timestamp: Date.now(), + }); + const mockInfoClient = createMockInfoClient({ + userAbstraction: jest.fn().mockResolvedValue('default'), + }); + mockClientService.getInfoClient = jest + .fn() + .mockReturnValue(mockInfoClient); + + // Act + await provider.getMarketDataWithPrices(); + + // Assert - skipped because of cache + expect(mockInfoClient.userAbstraction).not.toHaveBeenCalled(); + expect( + (TradingReadinessCache as jest.Mocked) + .get, + ).toHaveBeenCalledWith('mainnet', USER_ADDRESS); + }); + + it('waits for in-flight then returns when another provider already cached the result', async () => { + // Arrange — another provider instance is mid-setup AND will land a + // cache entry by the time we resume from the await. + let resolveInFlight: () => void = () => undefined; + const inFlightPromise = new Promise((resolve) => { + resolveInFlight = resolve; + }); + ( + TradingReadinessCache as jest.Mocked + ).isInFlight.mockReturnValue(inFlightPromise); + // Outer cache check returns undefined; post-await cache check reflects + // the other instance's recorded result. + (TradingReadinessCache as jest.Mocked).get + .mockReturnValueOnce(undefined) + .mockReturnValue({ + attempted: true, + enabled: true, + timestamp: Date.now(), + }); + + const mockInfoClient = createMockInfoClient({ + userAbstraction: jest.fn().mockResolvedValue('default'), + }); + mockClientService.getInfoClient = jest + .fn() + .mockReturnValue(mockInfoClient); + + // Act + const marketDataPromise = provider.getMarketDataWithPrices(); + resolveInFlight(); + await marketDataPromise; + + // Assert — checked for in-flight, saw the cache landed, returned without + // acquiring a new lock or re-fetching userAbstraction. + expect( + (TradingReadinessCache as jest.Mocked) + .isInFlight, + ).toHaveBeenCalledWith('unifiedAccount', 'mainnet', USER_ADDRESS); + expect(mockInfoClient.userAbstraction).not.toHaveBeenCalled(); + expect( + (TradingReadinessCache as jest.Mocked) + .setInFlight, + ).not.toHaveBeenCalled(); + }); + + it('waits for in-flight then runs its own attempt when no cache was written (deferred dexAbstraction case)', async () => { + // Scenario: another provider's init-time call (allowUserSigning=false) + // hit the dexAbstraction defer branch and finished without writing the + // cache. Our caller is action-time (allowUserSigning=true via withdraw) + // and must not skip the migration just because another instance was + // mid-setup. + let resolveInFlight: () => void = () => undefined; + const inFlightPromise = new Promise((resolve) => { + resolveInFlight = resolve; + }); + ( + TradingReadinessCache as jest.Mocked + ).isInFlight.mockReturnValue(inFlightPromise); + // Cache stays empty across both checks (no entry was written by the + // other instance because it deferred). + ( + TradingReadinessCache as jest.Mocked + ).get.mockReturnValue(undefined); + + const exchangeClient = createMockExchangeClient(); + mockClientService.getExchangeClient = jest + .fn() + .mockReturnValue(exchangeClient); + mockClientService.getInfoClient = jest.fn().mockReturnValue( + createMockInfoClient({ + userAbstraction: jest.fn().mockResolvedValue('dexAbstraction'), + }), + ); + + Object.defineProperty(provider, 'getAccountState', { + value: jest.fn().mockResolvedValue({ availableBalance: '5000' }), + writable: true, + }); + + // Act — withdraw is the action-time entry that requires migration. + const withdrawPromise = provider.withdraw({ + amount: '100', + destination: '0x1234567890123456789012345678901234567890' as Hex, + assetId: + 'eip155:42161/erc20:0xa0b86a33e6776e681a06e0e1622c5e5e3e6a8b13/usdc' as CaipAssetId, + }); + resolveInFlight(); + await withdrawPromise; + + // Assert — fell through, acquired our own lock, and migrated. + expect( + (TradingReadinessCache as jest.Mocked) + .setInFlight, + ).toHaveBeenCalledWith('unifiedAccount', 'mainnet', USER_ADDRESS); + expect(exchangeClient.userSetAbstraction).toHaveBeenCalledWith({ + user: USER_ADDRESS, + abstraction: 'unifiedAccount', + }); + }); + + it('returns early when re-check cache (inside lock) shows another provider completed', async () => { + // Arrange - first get() → undefined, second get() (inside try) → cached + (TradingReadinessCache as jest.Mocked).get + .mockReturnValueOnce(undefined) // outer check + .mockReturnValueOnce({ + attempted: true, + enabled: true, + timestamp: Date.now(), + }); // inner re-check after lock acquired + + const mockCompleteInFlight = jest.fn(); + ( + TradingReadinessCache as jest.Mocked + ).setInFlight.mockReturnValue(mockCompleteInFlight); + + const mockInfoClient = createMockInfoClient({ + userAbstraction: jest.fn().mockResolvedValue('default'), + }); + mockClientService.getInfoClient = jest + .fn() + .mockReturnValue(mockInfoClient); + + // Act + await provider.getMarketDataWithPrices(); + + // Assert - lock was acquired and released, but no API call made + expect(mockInfoClient.userAbstraction).not.toHaveBeenCalled(); + expect(mockCompleteInFlight).toHaveBeenCalled(); + }); + + // ───────────────────────────────────────────────── + // Already on a compatible mode (unifiedAccount or portfolioMargin) + // ───────────────────────────────────────────────── + + it('tracks already_enabled and caches success when mode is already unifiedAccount', async () => { + // Arrange + const mockCompleteInFlight = jest.fn(); + ( + TradingReadinessCache as jest.Mocked + ).setInFlight.mockReturnValue(mockCompleteInFlight); + mockClientService.getInfoClient = jest.fn().mockReturnValue( + createMockInfoClient({ + userAbstraction: jest.fn().mockResolvedValue('unifiedAccount'), + }), + ); + + // Act + await provider.getMarketDataWithPrices(); + + // Assert - tracks the already_enabled event + expect( + mockPlatformDependencies.metrics.trackPerpsEvent, + ).toHaveBeenCalledWith( + 'Perp Account Setup', + expect.objectContaining({ + abstraction_mode: 'unifiedAccount', + status: 'already_enabled', + }), + ); + // Caches success + expect( + (TradingReadinessCache as jest.Mocked) + .set, + ).toHaveBeenCalledWith('mainnet', USER_ADDRESS, { + attempted: true, + enabled: true, + }); + // Does NOT call exchange client for unified account transition + expect(mockClientService.getExchangeClient).not.toHaveBeenCalled(); + // Releases in-flight lock + expect(mockCompleteInFlight).toHaveBeenCalled(); + }); + + it('does NOT migrate portfolioMargin users — tracks already_enabled and skips exchange call', async () => { + // portfolioMargin is a superset of unifiedAccount: it already supports + // HIP-3 auto-collateral management and is more capital-efficient. + // Downgrading these users would be harmful. + // Arrange + const mockCompleteInFlight = jest.fn(); + ( + TradingReadinessCache as jest.Mocked + ).setInFlight.mockReturnValue(mockCompleteInFlight); + mockClientService.getInfoClient = jest.fn().mockReturnValue( + createMockInfoClient({ + userAbstraction: jest.fn().mockResolvedValue('portfolioMargin'), + }), + ); + + // Act + await provider.getMarketDataWithPrices(); + + // Assert - tracked as already_enabled with the correct mode + expect( + mockPlatformDependencies.metrics.trackPerpsEvent, + ).toHaveBeenCalledWith( + 'Perp Account Setup', + expect.objectContaining({ + abstraction_mode: 'portfolioMargin', + status: 'already_enabled', + }), + ); + // Caches success — no retry needed + expect( + (TradingReadinessCache as jest.Mocked) + .set, + ).toHaveBeenCalledWith('mainnet', USER_ADDRESS, { + attempted: true, + enabled: true, + }); + // Does NOT call exchange client — user must NOT be downgraded + expect(mockClientService.getExchangeClient).not.toHaveBeenCalled(); + // Releases in-flight lock + expect(mockCompleteInFlight).toHaveBeenCalled(); + }); + + // ───────────────────────────────────────────────── + // Migration from default / disabled → unifiedAccount (silent agent path) + // ───────────────────────────────────────────────── + + it('calls agentSetAbstraction silently when mode is default', async () => { + // Arrange + const mockExchangeClient = createMockExchangeClient(); + mockClientService.getInfoClient = jest.fn().mockReturnValue( + createMockInfoClient({ + userAbstraction: jest.fn().mockResolvedValue('default'), + }), + ); + mockClientService.getExchangeClient = jest + .fn() + .mockReturnValue(mockExchangeClient); + + // Act + await provider.getMarketDataWithPrices(); + + // Assert - uses silent agent-key path (no user prompt) + expect(mockExchangeClient.agentSetAbstraction).toHaveBeenCalledWith({ + abstraction: 'u', + }); + expect(mockExchangeClient.userSetAbstraction).not.toHaveBeenCalled(); + }); + + it('calls agentSetAbstraction silently when mode is disabled', async () => { + // Arrange + const mockExchangeClient = createMockExchangeClient(); + mockClientService.getInfoClient = jest.fn().mockReturnValue( + createMockInfoClient({ + userAbstraction: jest.fn().mockResolvedValue('disabled'), + }), + ); + mockClientService.getExchangeClient = jest + .fn() + .mockReturnValue(mockExchangeClient); + + // Act + await provider.getMarketDataWithPrices(); + + // Assert + expect(mockExchangeClient.agentSetAbstraction).toHaveBeenCalledWith({ + abstraction: 'u', + }); + expect(mockExchangeClient.userSetAbstraction).not.toHaveBeenCalled(); + }); + + it('tracks migration_required then success for default → unifiedAccount', async () => { + // Arrange + mockClientService.getInfoClient = jest.fn().mockReturnValue( + createMockInfoClient({ + userAbstraction: jest.fn().mockResolvedValue('default'), + }), + ); + mockClientService.getExchangeClient = jest + .fn() + .mockReturnValue(createMockExchangeClient()); + + // Act + await provider.getMarketDataWithPrices(); + + // Assert - two analytics events emitted in order + const trackCalls = ( + mockPlatformDependencies.metrics.trackPerpsEvent as jest.Mock + ).mock.calls.filter((call) => call[0] === 'Perp Account Setup'); + + // First event: migration_required with current mode + expect(trackCalls[0]).toEqual([ + 'Perp Account Setup', + expect.objectContaining({ + abstraction_mode: 'default', + status: 'migration_required', + }), + ]); + // Second event: success with before/after modes + expect(trackCalls[1]).toEqual([ + 'Perp Account Setup', + expect.objectContaining({ + previous_abstraction_mode: 'default', + abstraction_mode: 'unifiedAccount', + status: 'success', + }), + ]); + // Cache reflects success + expect( + (TradingReadinessCache as jest.Mocked) + .set, + ).toHaveBeenCalledWith('mainnet', USER_ADDRESS, { + attempted: true, + enabled: true, + }); + }); + + it('skips migration and does not cache success for unknown abstraction modes', async () => { + // Arrange + const mockExchangeClient = createMockExchangeClient(); + mockClientService.getInfoClient = jest.fn().mockReturnValue( + createMockInfoClient({ + userAbstraction: jest.fn().mockResolvedValue('futureMode'), + }), + ); + mockClientService.getExchangeClient = jest + .fn() + .mockReturnValue(mockExchangeClient); + + // Act + await provider.getMarketDataWithPrices(); + + // Assert - fail closed for unknown modes rather than silently forcing 'u' + expect(mockExchangeClient.agentSetAbstraction).not.toHaveBeenCalled(); + expect(mockExchangeClient.userSetAbstraction).not.toHaveBeenCalled(); + expect( + (TradingReadinessCache as jest.Mocked) + .set, + ).not.toHaveBeenCalledWith('mainnet', USER_ADDRESS, { + attempted: true, + enabled: true, + }); + }); + + // ───────────────────────────────────────────────── + // Hyperliquid multi-sig accounts (TAT-3214) + // + // Hyperliquid rejects every single-signer exchange write for an account + // that was converted to multi-sig with "ApiRequestError: Multi-sig + // required". Attempting the migration surfaced that error on the Perps + // tab on every entry. + // ───────────────────────────────────────────────── + + it('skips unified account migration for Hyperliquid multi-sig accounts', async () => { + // Arrange - migratable mode, but the account has a multi-sig signer set + const mockExchangeClient = createMockExchangeClient(); + const userToMultiSigSigners = jest.fn().mockResolvedValue({ + authorizedUsers: ['0xabc0000000000000000000000000000000000001'], + threshold: 2, + }); + mockClientService.getInfoClient = jest.fn().mockReturnValue( + createMockInfoClient({ + userAbstraction: jest.fn().mockResolvedValue('default'), + userToMultiSigSigners, + }), + ); + mockClientService.getExchangeClient = jest + .fn() + .mockReturnValue(mockExchangeClient); + + // Act + await provider.getMarketDataWithPrices(); + + // Assert - the signer set was queried and no write was attempted + expect(userToMultiSigSigners).toHaveBeenCalledWith({ + user: USER_ADDRESS, + }); + expect(mockExchangeClient.agentSetAbstraction).not.toHaveBeenCalled(); + expect(mockExchangeClient.userSetAbstraction).not.toHaveBeenCalled(); + // No migration_required event — the migration is not possible at all. + expect( + mockPlatformDependencies.metrics.trackPerpsEvent, + ).not.toHaveBeenCalledWith( + 'Perp Account Setup', + expect.objectContaining({ status: 'migration_required' }), + ); + expect( + mockPlatformDependencies.metrics.trackPerpsEvent, + ).toHaveBeenCalledWith( + 'Perp Account Setup', + expect.objectContaining({ + status: 'not_applicable', + error_message: 'multi_sig_account', + }), + ); + }); + + it('caches attempted-but-not-enabled readiness for Hyperliquid multi-sig accounts', async () => { + // Arrange + const mockCompleteInFlight = jest.fn(); + ( + TradingReadinessCache as jest.Mocked + ).setInFlight.mockReturnValue(mockCompleteInFlight); + mockClientService.getInfoClient = jest.fn().mockReturnValue( + createMockInfoClient({ + userAbstraction: jest.fn().mockResolvedValue('default'), + userToMultiSigSigners: jest.fn().mockResolvedValue({ + authorizedUsers: ['0xabc0000000000000000000000000000000000001'], + threshold: 2, + }), + }), + ); + mockClientService.getExchangeClient = jest + .fn() + .mockReturnValue(createMockExchangeClient()); + + // Act + await provider.getMarketDataWithPrices(); + + // Assert - final state, so the next entry short-circuits instead of + // re-attempting a write that can never succeed. + expect( + (TradingReadinessCache as jest.Mocked) + .set, + ).toHaveBeenCalledWith('mainnet', USER_ADDRESS, { + attempted: true, + enabled: false, + }); + // Unified mode stays off, so spot must not be folded. + expect( + mockSubscriptionService.setUserAbstractionMode, + ).not.toHaveBeenCalled(); + expect(mockCompleteInFlight).toHaveBeenCalled(); + }); + + it('treats a Multi-sig required rejection as benign instead of reporting an error', async () => { + // The signer-set lookup and the write can race (the account is + // converted between the two calls), and other single-signer write paths + // can hit the same rejection. Classify it rather than surfacing it. + const mockExchangeClient = createMockExchangeClient(); + mockExchangeClient.agentSetAbstraction = jest + .fn() + .mockRejectedValue(new Error('ApiRequestError: Multi-sig required')); + mockClientService.getInfoClient = jest.fn().mockReturnValue( + createMockInfoClient({ + userAbstraction: jest.fn().mockResolvedValue('default'), + // Stale null — the account became multi-sig after the probe. + userToMultiSigSigners: jest.fn().mockResolvedValue(null), + }), + ); + mockClientService.getExchangeClient = jest + .fn() + .mockReturnValue(mockExchangeClient); + + // Act + await provider.getMarketDataWithPrices(); + + // Assert - not forwarded to the client error surface / Sentry + expect(mockPlatformDependencies.logger.error).not.toHaveBeenCalled(); + expect( + mockPlatformDependencies.metrics.trackPerpsEvent, + ).not.toHaveBeenCalledWith( + 'Perp Account Setup', + expect.objectContaining({ status: 'failed' }), + ); + expect( + mockPlatformDependencies.metrics.trackPerpsEvent, + ).toHaveBeenCalledWith( + 'Perp Account Setup', + expect.objectContaining({ + previous_abstraction_mode: 'default', + status: 'not_applicable', + error_message: 'multi_sig_account', + }), + ); + expect( + (TradingReadinessCache as jest.Mocked) + .set, + ).toHaveBeenCalledWith('mainnet', USER_ADDRESS, { + attempted: true, + enabled: false, + }); + }); + + it('still migrates single-signer accounts when the multi-sig probe fails', async () => { + // Fail open: a transient info-API failure must never block migration + // for the overwhelming majority of accounts. The catch-path classifier + // remains the safety net. + const mockExchangeClient = createMockExchangeClient(); + mockClientService.getInfoClient = jest.fn().mockReturnValue( + createMockInfoClient({ + userAbstraction: jest.fn().mockResolvedValue('default'), + userToMultiSigSigners: jest + .fn() + .mockRejectedValue(new Error('Transient HL network blip')), + }), + ); + mockClientService.getExchangeClient = jest + .fn() + .mockReturnValue(mockExchangeClient); + + // Act + await provider.getMarketDataWithPrices(); + + // Assert + expect(mockExchangeClient.agentSetAbstraction).toHaveBeenCalledWith({ + abstraction: 'u', + }); + expect( + (TradingReadinessCache as jest.Mocked) + .set, + ).toHaveBeenCalledWith('mainnet', USER_ADDRESS, { + attempted: true, + enabled: true, + }); + }); + + it('does not query the multi-sig signer set when no migration write is needed', async () => { + // Accounts already on a compatible mode never reach a write, so they + // must not pay an extra Hyperliquid round trip. + const userToMultiSigSigners = jest.fn().mockResolvedValue(null); + mockClientService.getInfoClient = jest.fn().mockReturnValue( + createMockInfoClient({ + userAbstraction: jest.fn().mockResolvedValue('unifiedAccount'), + userToMultiSigSigners, + }), + ); + + // Act + await provider.getMarketDataWithPrices(); + + // Assert + expect(userToMultiSigSigners).not.toHaveBeenCalled(); + }); + + // ───────────────────────────────────────────────── + // Signing-backed unifiedAccount migration on init + // + // Some transitions require an EIP-712 prompt, so software-wallet users + // migrate during initial setup to ensure the first trade sees unified + // collateral. Hardware wallets remain deferred to avoid repeated signing + // prompts while browsing. + // ───────────────────────────────────────────────── + + it('calls userSetAbstraction on init for software-wallet dexAbstraction users', async () => { + // Arrange + const mockExchangeClient = createMockExchangeClient(); + mockClientService.getInfoClient = jest.fn().mockReturnValue( + createMockInfoClient({ + userAbstraction: jest.fn().mockResolvedValue('dexAbstraction'), + }), + ); + mockClientService.getExchangeClient = jest + .fn() + .mockReturnValue(mockExchangeClient); + + // Act - init path + await provider.getMarketDataWithPrices(); + + // Assert - software wallets migrate during setup so first trade sees + // unified collateral folded into the size slider. + expect(mockExchangeClient.userSetAbstraction).toHaveBeenCalledWith({ + user: USER_ADDRESS, + abstraction: 'unifiedAccount', + }); + expect(mockExchangeClient.agentSetAbstraction).not.toHaveBeenCalled(); + }); + + it('tracks migration_required and writes cache for software-wallet dexAbstraction on init', async () => { + // Arrange + mockClientService.getInfoClient = jest.fn().mockReturnValue( + createMockInfoClient({ + userAbstraction: jest.fn().mockResolvedValue('dexAbstraction'), + }), + ); + mockClientService.getExchangeClient = jest + .fn() + .mockReturnValue(createMockExchangeClient()); + + // Act + await provider.getMarketDataWithPrices(); + + // Assert - analytics fire because software-wallet init performs the + // migration attempt. + const trackCalls = ( + mockPlatformDependencies.metrics.trackPerpsEvent as jest.Mock + ).mock.calls.filter((call) => call[0] === 'Perp Account Setup'); + expect(trackCalls[0]).toEqual([ + 'Perp Account Setup', + expect.objectContaining({ + abstraction_mode: 'dexAbstraction', + status: 'migration_required', + }), + ]); + expect(trackCalls[1]).toEqual([ + 'Perp Account Setup', + expect.objectContaining({ + previous_abstraction_mode: 'dexAbstraction', + abstraction_mode: 'unifiedAccount', + status: 'success', + }), + ]); + + expect( + (TradingReadinessCache as jest.Mocked) + .set, + ).toHaveBeenCalledWith('mainnet', USER_ADDRESS, { + attempted: true, + enabled: true, + }); + }); + + // ───────────────────────────────────────────────── + // setUserAbstractionMode is called on every success path with the + // resolved mode so the subscription service can fold spot correctly. + // ───────────────────────────────────────────────── + + it('records unifiedAccount mode when account is already unifiedAccount', async () => { + mockClientService.getInfoClient = jest.fn().mockReturnValue( + createMockInfoClient({ + userAbstraction: jest.fn().mockResolvedValue('unifiedAccount'), + }), + ); + + await provider.getMarketDataWithPrices(); + + expect( + mockSubscriptionService.setUserAbstractionMode, + ).toHaveBeenCalledWith(USER_ADDRESS, 'unifiedAccount'); + }); + + it('records portfolioMargin mode when account is already portfolioMargin', async () => { + mockClientService.getInfoClient = jest.fn().mockReturnValue( + createMockInfoClient({ + userAbstraction: jest.fn().mockResolvedValue('portfolioMargin'), + }), + ); + + await provider.getMarketDataWithPrices(); + + expect( + mockSubscriptionService.setUserAbstractionMode, + ).toHaveBeenCalledWith(USER_ADDRESS, 'portfolioMargin'); + }); + + it('records unifiedAccount mode after migrating from default → unifiedAccount', async () => { + mockClientService.getInfoClient = jest.fn().mockReturnValue( + createMockInfoClient({ + userAbstraction: jest.fn().mockResolvedValue('default'), + }), + ); + mockClientService.getExchangeClient = jest + .fn() + .mockReturnValue(createMockExchangeClient()); + + await provider.getMarketDataWithPrices(); + + expect( + mockSubscriptionService.setUserAbstractionMode, + ).toHaveBeenCalledWith(USER_ADDRESS, 'unifiedAccount'); + }); + + it('records unifiedAccount mode after migrating software-wallet dexAbstraction on init', async () => { + mockClientService.getInfoClient = jest.fn().mockReturnValue( + createMockInfoClient({ + userAbstraction: jest.fn().mockResolvedValue('dexAbstraction'), + }), + ); + mockClientService.getExchangeClient = jest + .fn() + .mockReturnValue(createMockExchangeClient()); + + await provider.getMarketDataWithPrices(); + + expect( + mockSubscriptionService.setUserAbstractionMode, + ).toHaveBeenCalledWith(USER_ADDRESS, 'unifiedAccount'); + }); + + it.each(['dexAbstraction', 'default', 'disabled'] as const)( + 'defers %s migration on init for hardware wallets', + async (currentMode) => { + // Arrange + mockWalletService.isSelectedHardwareWallet.mockReturnValue(true); + const mockExchangeClient = createMockExchangeClient(); + mockClientService.getInfoClient = jest.fn().mockReturnValue( + createMockInfoClient({ + userAbstraction: jest.fn().mockResolvedValue(currentMode), + }), + ); + mockClientService.getExchangeClient = jest + .fn() + .mockReturnValue(mockExchangeClient); + + // Act - init path + await provider.getMarketDataWithPrices(); + + // Assert - no browsing-time hardware prompt; action-time setup can still run. + expect(mockExchangeClient.userSetAbstraction).not.toHaveBeenCalled(); + expect(mockExchangeClient.agentSetAbstraction).not.toHaveBeenCalled(); + expect( + (TradingReadinessCache as jest.Mocked) + .set, + ).not.toHaveBeenCalled(); + expect( + mockSubscriptionService.setUserAbstractionMode, + ).not.toHaveBeenCalled(); + }, + ); + + it('does NOT call setUserAbstractionMode when migration fails', async () => { + const mockExchangeClient = createMockExchangeClient(); + mockExchangeClient.agentSetAbstraction = jest + .fn() + .mockRejectedValue(new Error('network error')); + mockClientService.getInfoClient = jest.fn().mockReturnValue( + createMockInfoClient({ + userAbstraction: jest.fn().mockResolvedValue('default'), + }), + ); + mockClientService.getExchangeClient = jest + .fn() + .mockReturnValue(mockExchangeClient); + + await provider.getMarketDataWithPrices(); + + expect( + mockSubscriptionService.setUserAbstractionMode, + ).not.toHaveBeenCalled(); + }); + + // ───────────────────────────────────────────────── + // Failure paths + // ───────────────────────────────────────────────── + + it('does NOT cache when silent agentSetAbstraction fails (default/disabled paths retry on next entry)', async () => { + // Silent agent-key migration (default/disabled) shows no UI prompt, so + // the "don't re-prompt rejected users" rationale doesn't apply. Caching + // a transient HL/network failure here would pin the user in the + // deprecated mode for the rest of the session — instead we leave the + // cache empty so the next #ensureReady or action-time call retries. + const mockError = new Error('Transient HL network blip'); + const mockExchangeClient = createMockExchangeClient(); + mockExchangeClient.agentSetAbstraction = jest + .fn() + .mockRejectedValue(mockError); + mockClientService.getInfoClient = jest.fn().mockReturnValue( + createMockInfoClient({ + userAbstraction: jest.fn().mockResolvedValue('default'), + }), + ); + mockClientService.getExchangeClient = jest + .fn() + .mockReturnValue(mockExchangeClient); + + await provider.getMarketDataWithPrices(); + + // No cache write — next entry can retry. + expect( + (TradingReadinessCache as jest.Mocked) + .set, + ).not.toHaveBeenCalled(); + // Failure analytics still emitted for observability. + expect( + mockPlatformDependencies.metrics.trackPerpsEvent, + ).toHaveBeenCalledWith( + 'Perp Account Setup', + expect.objectContaining({ + previous_abstraction_mode: 'default', + abstraction_mode: 'unifiedAccount', + status: 'failed', + error_message: expect.stringContaining('Transient HL network blip'), + }), + ); + // Sentry logger still records for debugging. + expect(mockPlatformDependencies.logger.error).toHaveBeenCalledWith( + expect.objectContaining({ + message: expect.stringContaining('Transient HL network blip'), + }), + expect.objectContaining({ + context: expect.objectContaining({ + name: 'HyperLiquidProvider', + data: expect.objectContaining({ + method: 'ensureUnifiedAccountEnabled', + }), + }), + }), + ); + }); + + it('retries migration on the next #ensureReady after a silent agent failure', async () => { + // Without resetting #ensureReadyPromise on the silent-failure path, + // a transient agentSetAbstraction blip during the first Perps section + // open would pin the user in the deprecated mode for the entire + // provider lifetime — every subsequent #ensureReady would just return + // the memoized resolved promise and skip the migration. + const userAbstractionMock = jest.fn().mockResolvedValue('default'); + mockClientService.getInfoClient = jest.fn().mockReturnValue( + createMockInfoClient({ + userAbstraction: userAbstractionMock, + }), + ); + const agentSetAbstractionMock = jest + .fn() + .mockRejectedValueOnce(new Error('Transient HL network blip')) + .mockResolvedValueOnce({ status: 'ok' }); + const exchangeClient = createMockExchangeClient(); + exchangeClient.agentSetAbstraction = agentSetAbstractionMock; + mockClientService.getExchangeClient = jest + .fn() + .mockReturnValue(exchangeClient); + + // First entry: migration fails silently, no cache write. + await provider.getMarketDataWithPrices(); + expect(userAbstractionMock).toHaveBeenCalledTimes(1); + expect(agentSetAbstractionMock).toHaveBeenCalledTimes(1); + + // Second entry: must re-run the migration because #ensureReadyPromise + // was reset on the silent-failure exit. agentSetAbstraction succeeds + // this time → cache attempted/enabled → no further retries. + await provider.getMarketDataWithPrices(); + expect(userAbstractionMock).toHaveBeenCalledTimes(2); + expect(agentSetAbstractionMock).toHaveBeenCalledTimes(2); + expect( + (TradingReadinessCache as jest.Mocked) + .set, + ).toHaveBeenCalledWith('mainnet', USER_ADDRESS, { + attempted: true, + enabled: true, + }); + }); + + it("caches failure when user-signed userSetAbstraction throws (don't re-prompt rejected users)", async () => { + // The dexAbstraction → unifiedAccount migration goes through + // userSetAbstraction which surfaces an EIP-712 signing dialog. Once + // the user has been prompted (and either rejected or signed but the + // call failed), we should not pop the dialog again this session. + const mockError = new Error('User rejected signing'); + const exchangeClient = createMockExchangeClient(); + exchangeClient.userSetAbstraction = jest + .fn() + .mockRejectedValue(mockError); + mockClientService.getExchangeClient = jest + .fn() + .mockReturnValue(exchangeClient); + mockClientService.getInfoClient = jest.fn().mockReturnValue( + createMockInfoClient({ + userAbstraction: jest.fn().mockResolvedValue('dexAbstraction'), + }), + ); + + Object.defineProperty(provider, 'getAccountState', { + value: jest.fn().mockResolvedValue({ availableBalance: '5000' }), + writable: true, + }); + + // withdraw() is an action-time caller that passes allowUserSigning=true, + // so the dexAbstraction path actually attempts userSetAbstraction. + await provider.withdraw({ + amount: '100', + destination: '0x1234567890123456789012345678901234567890' as Hex, + assetId: + 'eip155:42161/erc20:0xa0b86a33e6776e681a06e0e1622c5e5e3e6a8b13/usdc' as CaipAssetId, + }); + + expect( + (TradingReadinessCache as jest.Mocked) + .set, + ).toHaveBeenCalledWith('mainnet', USER_ADDRESS, { + attempted: true, + enabled: false, + }); + }); + + it('does NOT cache or log to Sentry when KEYRING_LOCKED is thrown', async () => { + // Arrange + const mockExchangeClient = createMockExchangeClient(); + mockExchangeClient.agentSetAbstraction = jest + .fn() + .mockRejectedValue(new Error('KEYRING_LOCKED')); + const mockCompleteInFlight = jest.fn(); + ( + TradingReadinessCache as jest.Mocked + ).setInFlight.mockReturnValue(mockCompleteInFlight); + mockClientService.getInfoClient = jest.fn().mockReturnValue( + createMockInfoClient({ + userAbstraction: jest.fn().mockResolvedValue('default'), + }), + ); + mockClientService.getExchangeClient = jest + .fn() + .mockReturnValue(mockExchangeClient); + + // Act - should resolve without throwing + await provider.getMarketDataWithPrices(); + + // Assert - cache NOT set (so it retries when keyring is unlocked) + expect( + (TradingReadinessCache as jest.Mocked) + .set, + ).not.toHaveBeenCalled(); + // Sentry NOT called + expect(mockPlatformDependencies.logger.error).not.toHaveBeenCalled(); + // In-flight lock still released + expect(mockCompleteInFlight).toHaveBeenCalled(); + }); + + it('does NOT cache or log to Sentry when a wrapped KEYRING_LOCKED error is thrown', async () => { + // Arrange + const wrappedKeyringLockedError = Object.assign( + new Error('Failed to sign typed data with viem wallet'), + { cause: new Error('KEYRING_LOCKED') }, + ); + const mockExchangeClient = createMockExchangeClient(); + mockExchangeClient.agentSetAbstraction = jest + .fn() + .mockRejectedValue(wrappedKeyringLockedError); + const mockCompleteInFlight = jest.fn(); + ( + TradingReadinessCache as jest.Mocked + ).setInFlight.mockReturnValue(mockCompleteInFlight); + mockClientService.getInfoClient = jest.fn().mockReturnValue( + createMockInfoClient({ + userAbstraction: jest.fn().mockResolvedValue('default'), + }), + ); + mockClientService.getExchangeClient = jest + .fn() + .mockReturnValue(mockExchangeClient); + + // Act + await provider.getMarketDataWithPrices(); + + // Assert + expect( + (TradingReadinessCache as jest.Mocked) + .set, + ).not.toHaveBeenCalled(); + expect(mockPlatformDependencies.logger.error).not.toHaveBeenCalled(); + expect(mockCompleteInFlight).toHaveBeenCalled(); + }); + + it('does NOT cache failure when userAbstraction read itself rejects', async () => { + // Read-only userAbstraction lookup failures (transient HL outage / + // network) must not block all future migration attempts for the rest + // of the session — no signing prompt has happened yet, so the + // "don't re-prompt the user" rationale doesn't apply. + const lookupError = new Error('HL info endpoint timeout'); + mockClientService.getInfoClient = jest.fn().mockReturnValue( + createMockInfoClient({ + userAbstraction: jest.fn().mockRejectedValue(lookupError), + }), + ); + const mockExchangeClient = createMockExchangeClient(); + mockClientService.getExchangeClient = jest + .fn() + .mockReturnValue(mockExchangeClient); + + // Act - should resolve without throwing + await provider.getMarketDataWithPrices(); + + // Assert - cache NOT written so the next call retries the lookup + expect( + (TradingReadinessCache as jest.Mocked) + .set, + ).not.toHaveBeenCalled(); + // No signing happened + expect(mockExchangeClient.userSetAbstraction).not.toHaveBeenCalled(); + expect(mockExchangeClient.agentSetAbstraction).not.toHaveBeenCalled(); + }); + + // ───────────────────────────────────────────────── + // Network key (mainnet vs testnet) + // ───────────────────────────────────────────────── + + it('uses testnet network key when client is in testnet mode', async () => { + // Arrange - testnet provider with cache already hit (so we only check the key) + mockClientService.isTestnetMode = jest.fn().mockReturnValue(true); + ( + TradingReadinessCache as jest.Mocked + ).get.mockReturnValue({ + attempted: true, + enabled: true, + timestamp: Date.now(), + }); + + const mockInfoClient = createMockInfoClient({ + userAbstraction: jest.fn().mockResolvedValue('default'), + }); + mockClientService.getInfoClient = jest + .fn() + .mockReturnValue(mockInfoClient); + + // Act + await provider.getMarketDataWithPrices(); + + // Assert - cache keyed by 'testnet' + expect( + (TradingReadinessCache as jest.Mocked) + .get, + ).toHaveBeenCalledWith('testnet', USER_ADDRESS); + }); + + // ───────────────────────────────────────────────── + // In-flight lock management + // ───────────────────────────────────────────────── + + it('sets in-flight lock with unifiedAccount key and releases it on success', async () => { + // Arrange + const mockCompleteInFlight = jest.fn(); + ( + TradingReadinessCache as jest.Mocked + ).setInFlight.mockReturnValue(mockCompleteInFlight); + mockClientService.getInfoClient = jest.fn().mockReturnValue( + createMockInfoClient({ + userAbstraction: jest.fn().mockResolvedValue('default'), + }), + ); + mockClientService.getExchangeClient = jest + .fn() + .mockReturnValue(createMockExchangeClient()); + + // Act + await provider.getMarketDataWithPrices(); + + // Assert - lock key uses 'unifiedAccount' + expect( + (TradingReadinessCache as jest.Mocked) + .setInFlight, + ).toHaveBeenCalledWith('unifiedAccount', 'mainnet', USER_ADDRESS); + expect(mockCompleteInFlight).toHaveBeenCalled(); + }); + }); +}); diff --git a/packages/perps-controller/tests/src/providers/HyperLiquidProvider.advanced-orders.test.ts b/packages/perps-controller/tests/src/providers/HyperLiquidProvider.advanced-orders.test.ts new file mode 100644 index 00000000000..e13eea4fa93 --- /dev/null +++ b/packages/perps-controller/tests/src/providers/HyperLiquidProvider.advanced-orders.test.ts @@ -0,0 +1,2147 @@ +/* eslint-disable */ +jest.mock('@nktkas/hyperliquid', () => ({})); + +import { ORDER_SLIPPAGE_CONFIG } from '../../../src/constants/perpsConfig.js'; +import { PERPS_ERROR_CODES } from '../../../src/perpsErrorCodes.js'; +import { HyperLiquidProvider } from '../../../src/providers/HyperLiquidProvider.js'; +import { HyperLiquidClientService } from '../../../src/services/HyperLiquidClientService.js'; +import { HyperLiquidSubscriptionService } from '../../../src/services/HyperLiquidSubscriptionService.js'; +import { HyperLiquidWalletService } from '../../../src/services/HyperLiquidWalletService.js'; +import { TradingReadinessCache } from '../../../src/services/TradingReadinessCache.js'; +import type { + PerpsPlatformDependencies, + OrderParams, +} from '../../../src/types/index.js'; +import { + validateAssetSupport, + validateBalance, + validateCoinExists, + validateDepositParams, + validateOrderParams, + validateWithdrawalParams, +} from '../../../src/utils/hyperLiquidValidation.js'; +import { + createMockInfrastructure, + createMockMessenger, +} from '../../helpers/serviceMocks.js'; + +jest.mock('../../../src/services/HyperLiquidClientService'); +jest.mock('../../../src/services/HyperLiquidWalletService'); +jest.mock('../../../src/services/HyperLiquidSubscriptionService'); +// Mock stream manager - will be set up in test +let mockStreamManagerInstance: any; +const mockGetStreamManagerInstance = jest.fn(() => mockStreamManagerInstance); +jest.mock( + '../../../../components/UI/Perps/providers/PerpsStreamManager', + () => ({ + getStreamManagerInstance: mockGetStreamManagerInstance, + }), + { virtual: true }, +); + +jest.mock('../../../src/utils/hyperLiquidValidation', () => ({ + validateOrderParams: jest.fn(), + validateWithdrawalParams: jest.fn(), + validateDepositParams: jest.fn(), + validateCoinExists: jest.fn(), + validateAssetSupport: jest.fn(), + validateBalance: jest.fn(), + getSupportedPaths: jest + .fn() + .mockReturnValue([ + 'eip155:42161/erc20:0xa0b86a33e6776e681a06e0e1622c5e5e3e6a8b13/default', + 'eip155:1/erc20:0xa0b86a33e6776e681a06e0e1622c5e5e3e6a8b13/default', + ]), + getBridgeInfo: jest.fn().mockReturnValue({ + chainId: 'eip155:42161', + contractAddress: '0x1234567890123456789012345678901234567890', + }), + createErrorResult: jest.fn((error, defaultResponse) => ({ + ...defaultResponse, + success: false, + error: error instanceof Error ? error.message : String(error), + })), +})); + +// Mock adapter functions +jest.mock('../../../src/utils/hyperLiquidAdapter', () => { + const actual = jest.requireActual('../../../src/utils/hyperLiquidAdapter'); + return { + ...actual, + adaptHyperLiquidLedgerUpdateToUserHistoryItem: jest.fn((updates) => { + // Return mock history items based on input + if (!updates || !Array.isArray(updates) || updates.length === 0) { + return []; + } + return updates.map((_update: unknown) => ({ + type: 'deposit' as const, + amount: '100', + timestamp: Date.now(), + hash: '0x123', + })); + }), + }; +}); + +// Mock TradingReadinessCache - global singleton for signing operation caching +// Use jest.createMockFromModule for proper mock creation +jest.mock('../../../src/services/TradingReadinessCache'); + +const MockedHyperLiquidClientService = + HyperLiquidClientService as jest.MockedClass; +const MockedHyperLiquidWalletService = + HyperLiquidWalletService as jest.MockedClass; +const MockedHyperLiquidSubscriptionService = + HyperLiquidSubscriptionService as jest.MockedClass< + typeof HyperLiquidSubscriptionService + >; +const mockValidateOrderParams = validateOrderParams as jest.MockedFunction< + typeof validateOrderParams +>; +const mockValidateWithdrawalParams = + validateWithdrawalParams as jest.MockedFunction< + typeof validateWithdrawalParams + >; +const mockValidateDepositParams = validateDepositParams as jest.MockedFunction< + typeof validateDepositParams +>; +const mockValidateCoinExists = validateCoinExists as jest.MockedFunction< + typeof validateCoinExists +>; +const mockValidateAssetSupport = validateAssetSupport as jest.MockedFunction< + typeof validateAssetSupport +>; +const mockValidateBalance = validateBalance as jest.MockedFunction< + typeof validateBalance +>; + +// Mock factory functions - defined once, reused everywhere +// These reduce duplication and make tests more maintainable +const createMockInfoClient = (overrides: Record = {}) => ({ + clearinghouseState: jest.fn().mockResolvedValue({ + marginSummary: { + totalMarginUsed: '500', + accountValue: '10500', + }, + withdrawable: '9500', + assetPositions: [ + { + position: { + coin: 'BTC', + szi: '0.1', + entryPx: '50000', + positionValue: '5000', + unrealizedPnl: '100', + marginUsed: '500', + leverage: { type: 'cross', value: 10 }, + liquidationPx: '45000', + maxLeverage: 50, + returnOnEquity: '20', + cumFunding: { allTime: '10', sinceOpen: '5', sinceChange: '2' }, + }, + type: 'oneWay', + }, + { + position: { + coin: 'ETH', + szi: '1.5', + entryPx: '3000', + positionValue: '4500', + unrealizedPnl: '50', + marginUsed: '450', + leverage: { type: 'cross', value: 10 }, + liquidationPx: '2700', + maxLeverage: 50, + returnOnEquity: '10', + cumFunding: { allTime: '5', sinceOpen: '2', sinceChange: '1' }, + }, + type: 'oneWay', + }, + ], + crossMarginSummary: { + accountValue: '10000', + totalMarginUsed: '5000', + }, + }), + spotClearinghouseState: jest.fn().mockResolvedValue({ + balances: [{ coin: 'USDC', hold: '1000', total: '10000' }], + }), + // Mode-aware fold gate reads userAbstraction; default to unifiedAccount + // so tests that predated the gate still see spot folded into spendable/withdrawable. + userAbstraction: jest.fn().mockResolvedValue('unifiedAccount'), + meta: jest.fn().mockResolvedValue({ + universe: [ + { name: 'BTC', szDecimals: 3, maxLeverage: 50 }, + { name: 'ETH', szDecimals: 4, maxLeverage: 50 }, + ], + }), + metaAndAssetCtxs: jest.fn().mockResolvedValue([ + { + universe: [ + { name: 'BTC', szDecimals: 3, maxLeverage: 50 }, + { name: 'ETH', szDecimals: 4, maxLeverage: 50 }, + ], + }, + [ + { + funding: '0.0001', + openInterest: '1000', + prevDayPx: '49000', + dayNtlVlm: '1000000', + markPx: '50000', + midPx: '50000', + oraclePx: '50000', + }, + { + funding: '0.0001', + openInterest: '500', + prevDayPx: '2900', + dayNtlVlm: '500000', + markPx: '3000', + midPx: '3000', + oraclePx: '3000', + }, + ], + ]), + perpDexs: jest.fn().mockResolvedValue([null]), + allMids: jest.fn().mockResolvedValue({ BTC: '50000', ETH: '3000' }), + // editOrder verifies the resting order's placement type before modifying it, + // so the account lists the plain limit order the edit tests target. + frontendOpenOrders: jest.fn().mockResolvedValue([ + { + coin: 'BTC', + side: 'B', + limitPx: '50000', + sz: '0.1', + origSz: '0.1', + oid: 123, + timestamp: 1_700_000_000_000, + isTrigger: false, + triggerCondition: 'N/A', + triggerPx: '0', + children: [], + isPositionTpsl: false, + reduceOnly: false, + orderType: 'Limit', + }, + ]), + referral: jest.fn().mockResolvedValue({ + referrerState: { + stage: 'ready', + data: { code: 'MMCSI' }, + }, + }), + maxBuilderFee: jest.fn().mockResolvedValue(1), + userFees: jest.fn().mockResolvedValue({ + feeSchedule: { + cross: '0.00030', + add: '0.00010', + spotCross: '0.00040', + spotAdd: '0.00020', + }, + dailyUserVlm: [], + }), + userNonFundingLedgerUpdates: jest.fn().mockResolvedValue([ + { + delta: { type: 'deposit', usdc: '100' }, + time: Date.now(), + hash: '0x123abc', + }, + { + delta: { type: 'withdraw', usdc: '50' }, + time: Date.now() - 3600000, + hash: '0x456def', + }, + ]), + portfolio: jest.fn().mockResolvedValue([ + null, + [ + null, + { + accountValueHistory: [ + [Date.now() - 86400000, '10000'], // 24h ago + [Date.now() - 172800000, '9500'], // 48h ago + [Date.now() - 259200000, '9000'], // 72h ago + ], + }, + ], + ]), + spotMeta: jest.fn().mockResolvedValue({ + tokens: [ + { name: 'USDC', tokenId: '0xdef456', index: 0 }, + { name: 'USDT', tokenId: '0x789abc', index: 1 }, + ], + universe: [], + }), + historicalOrders: jest.fn().mockResolvedValue([]), + userFills: jest.fn().mockResolvedValue([]), + userFillsByTime: jest.fn().mockResolvedValue([]), + userFunding: jest.fn().mockResolvedValue([]), + ...overrides, +}); + +const createMockExchangeClient = (overrides: Record = {}) => ({ + order: jest.fn().mockImplementation((request: { orders: unknown[] }) => + Promise.resolve({ + status: 'ok', + response: { + data: { + statuses: request.orders.map((_order, index) => ({ + resting: { oid: 123 + index }, + })), + }, + }, + }), + ), + modify: jest.fn().mockResolvedValue({ + status: 'ok', + response: { data: { statuses: [{ resting: { oid: '123' } }] } }, + }), + cancel: jest.fn().mockResolvedValue({ + status: 'ok', + response: { data: { statuses: ['success'] } }, + }), + withdraw3: jest.fn().mockResolvedValue({ + status: 'ok', + }), + updateLeverage: jest.fn().mockResolvedValue({ + status: 'ok', + }), + approveBuilderFee: jest.fn().mockResolvedValue({ + status: 'ok', + }), + setReferrer: jest.fn().mockResolvedValue({ + status: 'ok', + }), + sendAsset: jest.fn().mockResolvedValue({ + status: 'ok', + }), + agentSetAbstraction: jest.fn().mockResolvedValue({ + status: 'ok', + }), + userSetAbstraction: jest.fn().mockResolvedValue({ + status: 'ok', + }), + ...overrides, +}); + +// Create shared mock platform dependencies for provider tests +const mockPlatformDependencies: PerpsPlatformDependencies = + createMockInfrastructure(); + +const mockMessenger = createMockMessenger(); + +/** + * Helper to create HyperLiquidProvider with mock platform dependencies + * @param options + * @param options.isTestnet + * @param options.hip3Enabled + * @param options.allowlistMarkets + * @param options.blocklistMarkets + * @param options.useUnifiedAccount + */ +const createTestProvider = ( + options: { + isTestnet?: boolean; + hip3Enabled?: boolean; + allowlistMarkets?: string[]; + blocklistMarkets?: string[]; + useUnifiedAccount?: boolean; + initialAssetMapping?: [string, number][]; + } = {}, +): HyperLiquidProvider => + new HyperLiquidProvider({ + ...options, + platformDependencies: mockPlatformDependencies, + messenger: mockMessenger, + }); + +describe('HyperLiquidProvider', () => { + let provider: HyperLiquidProvider; + let mockClientService: jest.Mocked; + let mockWalletService: jest.Mocked; + let mockSubscriptionService: jest.Mocked; + + beforeEach(() => { + // Reset all mocks + jest.clearAllMocks(); + ( + mockPlatformDependencies.marketDataFormatters.formatVolume as jest.Mock + ).mockImplementation((value: number) => '$' + value.toFixed(0)); + ( + mockPlatformDependencies.marketDataFormatters.formatPerpsFiat as jest.Mock + ).mockImplementation((value: number) => '$' + value.toFixed(2)); + ( + mockPlatformDependencies.marketDataFormatters + .formatPercentage as jest.Mock + ).mockImplementation((value: number) => `${value.toFixed(2)}%`); + ( + mockPlatformDependencies.featureFlags.validateVersionGated as jest.Mock + ).mockReturnValue(undefined); + (mockPlatformDependencies.metrics.isEnabled as jest.Mock).mockReturnValue( + true, + ); + + // Reset TradingReadinessCache mock state (using imported mocked module) + const mockedCache = TradingReadinessCache as jest.Mocked< + typeof TradingReadinessCache + >; + mockedCache.get.mockReturnValue(undefined); + mockedCache.getBuilderFee.mockReturnValue(undefined); + mockedCache.getReferral.mockReturnValue(undefined); + mockedCache.isInFlight.mockReturnValue(undefined); + mockedCache.setInFlight.mockReturnValue(jest.fn()); + + // Initialize mock stream manager instance + mockStreamManagerInstance = { + clearAllChannels: jest.fn(), + }; + + // Create mocked service instances using factory functions + mockClientService = { + initialize: jest.fn(), + isInitialized: jest.fn().mockReturnValue(true), + isTestnetMode: jest.fn().mockReturnValue(false), + ensureInitialized: jest.fn(), + getExchangeClient: jest.fn().mockReturnValue(createMockExchangeClient()), + getInfoClient: jest.fn().mockReturnValue(createMockInfoClient()), + fetchHistoricalOrders: jest.fn().mockResolvedValue([]), + disconnect: jest.fn().mockResolvedValue(undefined), + toggleTestnet: jest.fn(), + setTestnetMode: jest.fn(), + getNetwork: jest.fn().mockReturnValue('mainnet'), + ensureSubscriptionClient: jest.fn().mockResolvedValue(undefined), + getSubscriptionClient: jest.fn(), + setOnReconnectCallback: jest.fn(), + setOnTerminateCallback: jest.fn(), + getConnectionState: jest.fn().mockReturnValue('connected'), + } as Partial as jest.Mocked; + + mockWalletService = { + setTestnetMode: jest.fn(), + getCurrentAccountId: jest + .fn() + .mockReturnValue( + 'eip155:42161:0x1234567890123456789012345678901234567890', + ), + createWalletAdapter: jest.fn().mockReturnValue({ + request: jest + .fn() + .mockResolvedValue(['0x1234567890123456789012345678901234567890']), + }), + getUserAddress: jest + .fn() + .mockReturnValue('0x1234567890123456789012345678901234567890'), + getUserAddressWithDefault: jest + .fn() + .mockResolvedValue('0x1234567890123456789012345678901234567890'), + isKeyringUnlocked: jest.fn().mockReturnValue(true), + isSelectedHardwareWallet: jest.fn().mockReturnValue(false), + } as Partial as jest.Mocked; + + mockSubscriptionService = { + subscribeToPrices: jest.fn().mockResolvedValue(jest.fn()), // Returns Promise + subscribeToPositions: jest.fn().mockReturnValue(jest.fn()), // Returns function directly + subscribeToOrderFills: jest.fn().mockReturnValue(jest.fn()), // Returns function directly + clearAll: jest.fn(), + isPositionsCacheInitialized: jest.fn().mockReturnValue(false), + getCachedPositions: jest.fn().mockReturnValue([]), + updateFeatureFlags: jest.fn().mockResolvedValue(undefined), + // Cache methods used by buildAssetMapping optimization + setDexMetaCache: jest.fn(), + setDexAssetCtxsCache: jest.fn(), + getDexAssetCtxsCache: jest.fn().mockReturnValue(undefined), + // Price cache used by placeOrder, editOrder, closePosition optimizations + getCachedPrice: jest.fn().mockImplementation((symbol: string) => { + const prices: Record = { BTC: '50000', ETH: '3000' }; + return prices[symbol]; + }), + getLastAllMidsSnapshot: jest.fn().mockReturnValue(null), + // Orders cache used by updatePositionTPSL and getOpenOrders + isOrdersCacheInitialized: jest.fn().mockReturnValue(false), + getCachedOrders: jest.fn().mockReturnValue([]), + // Atomic getter - returns null when cache not initialized (prevents race condition) + getOrdersCacheIfInitialized: jest.fn().mockReturnValue(null), + // Abstraction-mode resolved-mode setter (unified account migration) + setUserAbstractionMode: jest.fn(), + } as Partial as jest.Mocked; + + // Mock constructors + MockedHyperLiquidClientService.mockImplementation(() => mockClientService); + MockedHyperLiquidWalletService.mockImplementation(() => mockWalletService); + MockedHyperLiquidSubscriptionService.mockImplementation( + () => mockSubscriptionService, + ); + + // Mock validation + mockValidateOrderParams.mockReturnValue({ isValid: true }); + mockValidateWithdrawalParams.mockReturnValue({ isValid: true }); + mockValidateDepositParams.mockReturnValue({ isValid: true }); + mockValidateCoinExists.mockReturnValue({ isValid: true }); + mockValidateAssetSupport.mockReturnValue({ isValid: true }); + mockValidateBalance.mockReturnValue({ isValid: true }); + const hyperLiquidValidation = jest.requireMock( + '../../../src/utils/hyperLiquidValidation', + ); + hyperLiquidValidation.getSupportedPaths.mockReturnValue([ + 'eip155:42161/erc20:0xa0b86a33e6776e681a06e0e1622c5e5e3e6a8b13/default', + 'eip155:1/erc20:0xa0b86a33e6776e681a06e0e1622c5e5e3e6a8b13/default', + ]); + hyperLiquidValidation.getBridgeInfo.mockReturnValue({ + chainId: 'eip155:42161', + contractAddress: '0x1234567890123456789012345678901234567890', + }); + hyperLiquidValidation.createErrorResult.mockImplementation( + (error: unknown, defaultResponse: Record) => ({ + ...defaultResponse, + success: false, + error: error instanceof Error ? error.message : String(error), + }), + ); + const hyperLiquidAdapter = jest.requireMock( + '../../../src/utils/hyperLiquidAdapter', + ); + hyperLiquidAdapter.adaptHyperLiquidLedgerUpdateToUserHistoryItem.mockImplementation( + (updates: unknown[]) => { + if (!updates || !Array.isArray(updates) || updates.length === 0) { + return []; + } + return updates.map(() => ({ + type: 'deposit' as const, + amount: '100', + timestamp: Date.now(), + hash: '0x123', + })); + }, + ); + + provider = createTestProvider({ + initialAssetMapping: [ + ['BTC', 0], + ['ETH', 1], + ], + }); + }); + + describe('Advanced order placement', () => { + const TPSL_SLIPPAGE = ORDER_SLIPPAGE_CONFIG.DefaultTpslSlippageBps / 10000; + + /** + * Reads the orders payload submitted to the exchange client. + * + * @returns The submitted `order` request. + */ + const getSubmittedOrderRequest = () => + (mockClientService.getExchangeClient().order as jest.Mock).mock + .calls[0][0]; + + it('rejects an attached TP/SL size that rounds to zero before changing leverage', async () => { + // The leverage change is on-chain and not undone by a later rejection, so + // a size that disappears at szDecimals: 3 has to be caught before it. + mockValidateOrderParams.mockImplementation( + jest.requireActual('../../../src/utils/hyperLiquidValidation.js') + .validateOrderParams, + ); + + const result = await provider.placeOrder({ + symbol: 'BTC', + isBuy: true, + size: '0.1', + orderType: 'market', + currentPrice: 50000, + leverage: 5, + takeProfitPrice: '60000', + takeProfitSize: '0.0004', + }); + + expect(result.success).toBe(false); + expect(result.error).toBe(PERPS_ERROR_CODES.ORDER_TPSL_SIZE_INVALID); + expect( + mockClientService.getExchangeClient().updateLeverage, + ).not.toHaveBeenCalled(); + expect( + mockClientService.getExchangeClient().order, + ).not.toHaveBeenCalled(); + }); + + it('rejects a trigger price that rounds to zero before changing leverage', async () => { + // The SDK would reject triggerPx: '0' anyway, but only after the leverage + // change has already been written on-chain. + mockValidateOrderParams.mockImplementation( + jest.requireActual('../../../src/utils/hyperLiquidValidation.js') + .validateOrderParams, + ); + + const result = await provider.placeOrder({ + symbol: 'BTC', + isBuy: false, + size: '0.1', + orderType: 'stop_market', + triggerPrice: '0.0004', + currentPrice: 50000, + leverage: 5, + }); + + expect(result.success).toBe(false); + expect(result.error).toBe(PERPS_ERROR_CODES.ORDER_TRIGGER_PRICE_POSITIVE); + expect( + mockClientService.getExchangeClient().updateLeverage, + ).not.toHaveBeenCalled(); + expect( + mockClientService.getExchangeClient().order, + ).not.toHaveBeenCalled(); + }); + + it('places a stop market order as a market-on-trigger stop', async () => { + const orderParams: OrderParams = { + symbol: 'BTC', + isBuy: false, + size: '0.1', + orderType: 'stop_market', + triggerPrice: '45000', + currentPrice: 50000, + }; + + const result = await provider.placeOrder(orderParams); + + expect(result.success).toBe(true); + const request = getSubmittedOrderRequest(); + expect(request.grouping).toBe('na'); + expect(request.orders).toHaveLength(1); + expect(request.orders[0].t).toStrictEqual({ + trigger: { isMarket: true, triggerPx: '45000', tpsl: 'sl' }, + }); + // Market-on-trigger sells accept up to the TP/SL slippage below the trigger + expect(parseFloat(request.orders[0].p)).toBeCloseTo( + 45000 * (1 - TPSL_SLIPPAGE), + 0, + ); + }); + + it('places a stop limit order at the requested limit price', async () => { + const result = await provider.placeOrder({ + symbol: 'BTC', + isBuy: false, + size: '0.1', + orderType: 'stop_limit', + price: '44500', + triggerPrice: '45000', + currentPrice: 50000, + }); + + expect(result.success).toBe(true); + const request = getSubmittedOrderRequest(); + expect(request.orders[0].t).toStrictEqual({ + trigger: { isMarket: false, triggerPx: '45000', tpsl: 'sl' }, + }); + expect(request.orders[0].p).toBe('44500'); + }); + + it.each([ + ['GTC', 'Gtc'], + ['IOC', 'Ioc'], + ['ALO', 'Alo'], + ] as const)( + 'submits %s time in force to the exchange for a limit order', + async (timeInForce, tif) => { + const result = await provider.placeOrder({ + symbol: 'BTC', + isBuy: true, + size: '0.1', + orderType: 'limit', + price: '49000', + timeInForce, + currentPrice: 50000, + }); + + expect(result.success).toBe(true); + expect(getSubmittedOrderRequest().orders[0].t).toStrictEqual({ + limit: { tif }, + }); + }, + ); + + it.each([ + [ + 'market', + { + symbol: 'BTC', + isBuy: true, + size: '0.1', + orderType: 'market' as const, + }, + ], + [ + 'stop_limit', + { + symbol: 'BTC', + isBuy: false, + size: '0.1', + orderType: 'stop_limit' as const, + price: '44500', + triggerPrice: '45000', + }, + ], + ])( + 'rejects time in force on a %s order before any on-chain side effect', + async (_label, orderParams) => { + // Real validation, so the rejection happens at step 1 of placeOrder + // rather than while the exchange payload is being built. + mockValidateOrderParams.mockImplementation( + jest.requireActual('../../../src/utils/hyperLiquidValidation.js') + .validateOrderParams, + ); + + const result = await provider.placeOrder({ + ...orderParams, + // Leverage is what makes the ordering matter: #prepareAssetForTrading + // sends updateLeverage on-chain, and there is no rollback for it. + leverage: 10, + timeInForce: 'IOC', + currentPrice: 50000, + }); + + expect(result.success).toBe(false); + expect(result.error).toBe( + PERPS_ERROR_CODES.ORDER_TIME_IN_FORCE_NOT_SUPPORTED, + ); + expect( + mockClientService.getExchangeClient().updateLeverage, + ).not.toHaveBeenCalled(); + expect( + mockClientService.getExchangeClient().order, + ).not.toHaveBeenCalled(); + }, + ); + + it('rejects time in force on a trigger order', async () => { + const result = await provider.placeOrder({ + symbol: 'BTC', + isBuy: false, + size: '0.1', + orderType: 'stop_limit', + price: '44500', + triggerPrice: '45000', + timeInForce: 'ALO', + currentPrice: 50000, + }); + + expect(result.success).toBe(false); + expect(result.error).toBe( + PERPS_ERROR_CODES.ORDER_TIME_IN_FORCE_NOT_SUPPORTED, + ); + expect( + mockClientService.getExchangeClient().order, + ).not.toHaveBeenCalled(); + }); + + it('places a take profit market order as a market-on-trigger take profit', async () => { + const result = await provider.placeOrder({ + symbol: 'BTC', + isBuy: false, + size: '0.1', + orderType: 'take_profit_market', + triggerPrice: '60000', + currentPrice: 50000, + }); + + expect(result.success).toBe(true); + const request = getSubmittedOrderRequest(); + expect(request.orders[0].t).toStrictEqual({ + trigger: { isMarket: true, triggerPx: '60000', tpsl: 'tp' }, + }); + }); + + it('places a take profit limit order at the requested limit price', async () => { + const result = await provider.placeOrder({ + symbol: 'BTC', + isBuy: false, + size: '0.1', + orderType: 'take_profit_limit', + price: '60000', + triggerPrice: '59500', + currentPrice: 50000, + }); + + expect(result.success).toBe(true); + const request = getSubmittedOrderRequest(); + expect(request.orders[0].t).toStrictEqual({ + trigger: { isMarket: false, triggerPx: '59500', tpsl: 'tp' }, + }); + expect(request.orders[0].p).toBe('60000'); + }); + + it('submits reduce-only as a first-class placement flag', async () => { + const result = await provider.placeOrder({ + symbol: 'BTC', + isBuy: false, + size: '0.1', + orderType: 'stop_market', + triggerPrice: '45000', + reduceOnly: true, + currentPrice: 50000, + }); + + expect(result.success).toBe(true); + expect(getSubmittedOrderRequest().orders[0].r).toBe(true); + }); + + it('defaults reduce-only to false when not requested', async () => { + await provider.placeOrder({ + symbol: 'BTC', + isBuy: true, + size: '0.1', + orderType: 'stop_market', + triggerPrice: '55000', + currentPrice: 50000, + }); + + expect(getSubmittedOrderRequest().orders[0].r).toBe(false); + }); + + it('scopes attached TP/SL children to their partial sizes', async () => { + const result = await provider.placeOrder({ + symbol: 'BTC', + isBuy: true, + size: '0.1', + orderType: 'market', + currentPrice: 50000, + takeProfitPrice: '60000', + takeProfitSize: '0.04', + stopLossPrice: '45000', + stopLossSize: '0.06', + }); + + expect(result.success).toBe(true); + const request = getSubmittedOrderRequest(); + expect(request.grouping).toBe('normalTpsl'); + expect(request.orders).toHaveLength(3); + // Main order keeps the full size; children carry the partial sizes + expect(request.orders[0].s).toBe('0.1'); + expect(request.orders[1].s).toBe('0.04'); + expect(request.orders[1].t.trigger.tpsl).toBe('tp'); + expect(request.orders[2].s).toBe('0.06'); + expect(request.orders[2].t.trigger.tpsl).toBe('sl'); + // Partial TP/SL children always reduce the position + expect(request.orders[1].r).toBe(true); + expect(request.orders[2].r).toBe(true); + }); + + it('maps the provider-agnostic TP/SL linkage onto the exchange grouping', async () => { + await provider.placeOrder({ + symbol: 'BTC', + isBuy: true, + size: '0.1', + orderType: 'market', + currentPrice: 50000, + takeProfitPrice: '60000', + tpslLinkage: 'position', + }); + + expect(getSubmittedOrderRequest().grouping).toBe('positionTpsl'); + }); + + it('lets the linkage win over the deprecated grouping spelling', async () => { + await provider.placeOrder({ + symbol: 'BTC', + isBuy: true, + size: '0.1', + orderType: 'market', + currentPrice: 50000, + takeProfitPrice: '60000', + tpslLinkage: 'position', + // Deprecated spelling of the same option; validation rejects a genuine + // disagreement, so this only proves which field the mapping reads. + grouping: 'positionTpsl', + }); + + expect(getSubmittedOrderRequest().grouping).toBe('positionTpsl'); + }); + + it('still honours the deprecated grouping when no linkage is given', async () => { + await provider.placeOrder({ + symbol: 'BTC', + isBuy: true, + size: '0.1', + orderType: 'market', + currentPrice: 50000, + takeProfitPrice: '60000', + grouping: 'positionTpsl', + }); + + expect(getSubmittedOrderRequest().grouping).toBe('positionTpsl'); + }); + + it('returns a typed error when a trigger placement has no trigger price', async () => { + const result = await provider.placeOrder({ + symbol: 'BTC', + isBuy: false, + size: '0.1', + orderType: 'stop_market', + currentPrice: 50000, + }); + + expect(result.success).toBe(false); + expect(result.error).toBe(PERPS_ERROR_CODES.ORDER_TRIGGER_PRICE_REQUIRED); + expect( + mockClientService.getExchangeClient().order, + ).not.toHaveBeenCalled(); + }); + + it('returns a typed error when a trigger placement fails validation', async () => { + mockValidateOrderParams.mockReturnValue({ + isValid: false, + error: PERPS_ERROR_CODES.ORDER_TRIGGER_PRICE_NOT_SUPPORTED, + }); + + const result = await provider.placeOrder({ + symbol: 'BTC', + isBuy: true, + size: '0.1', + orderType: 'market', + triggerPrice: '45000', + currentPrice: 50000, + }); + + expect(result.success).toBe(false); + expect(result.error).toBe( + PERPS_ERROR_CODES.ORDER_TRIGGER_PRICE_NOT_SUPPORTED, + ); + }); + + it('forwards the new placement fields to validation', async () => { + await provider.placeOrder({ + symbol: 'BTC', + isBuy: false, + size: '0.1', + orderType: 'stop_limit', + price: '44500', + triggerPrice: '45000', + currentPrice: 50000, + }); + + expect(mockValidateOrderParams).toHaveBeenCalledWith( + expect.objectContaining({ + orderType: 'stop_limit', + triggerPrice: '45000', + price: '44500', + }), + ); + }); + + it('cancels a placed trigger order', async () => { + const placed = await provider.placeOrder({ + symbol: 'BTC', + isBuy: false, + size: '0.1', + orderType: 'stop_market', + triggerPrice: '45000', + currentPrice: 50000, + }); + + const cancelled = await provider.cancelOrder({ + orderId: placed.orderId as string, + symbol: 'BTC', + }); + + expect(cancelled.success).toBe(true); + expect(mockClientService.getExchangeClient().cancel).toHaveBeenCalledWith( + { + cancels: [{ a: 0, o: 123 }], + }, + ); + }); + + it.each([ + ['GTC', 'Gtc'], + ['IOC', 'Ioc'], + ['ALO', 'Alo'], + ] as const)( + 'honours %s time in force when editing a limit order', + async (timeInForce, tif) => { + const result = await provider.editOrder({ + orderId: '123', + newOrder: { + symbol: 'BTC', + isBuy: true, + size: '0.1', + orderType: 'limit', + price: '49000', + timeInForce, + }, + }); + + expect(result.success).toBe(true); + const modifyCall = ( + mockClientService.getExchangeClient().modify as jest.Mock + ).mock.calls[0][0]; + expect(modifyCall.order.t).toStrictEqual({ limit: { tif } }); + }, + ); + + // A venue `modify` REPLACES the resting order: the old oid is cancelled and + // the replacement rests under a new one, which the SDK modify response does + // not carry. Reporting the old oid back as OrderResult.orderId therefore + // names an order that no longer exists. These pin the contract: resolve the + // replacement from authoritative post-modify data when it is unambiguous, + // and otherwise omit the id rather than fabricate identity. + describe('replacement order id', () => { + const restingOrder = (overrides: Record = {}) => ({ + coin: 'BTC', + side: 'B', + limitPx: '50000', + sz: '0.1', + origSz: '0.1', + oid: 123, + timestamp: 1_700_000_000_000, + isTrigger: false, + triggerCondition: 'N/A', + triggerPx: '0', + children: [], + isPositionTpsl: false, + reduceOnly: false, + orderType: 'Limit', + ...overrides, + }); + + /** + * Point frontendOpenOrders at a pre-modify then post-modify snapshot. + * + * @param before - Orders resting before the edit. + * @param after - Orders resting after the edit. + * @returns The frontendOpenOrders mock. + */ + const withSnapshots = (before: unknown[], after: unknown[]) => { + const frontendOpenOrders = jest + .fn() + .mockResolvedValueOnce(before) + .mockResolvedValue(after); + mockClientService.getInfoClient.mockReturnValue( + createMockInfoClient({ frontendOpenOrders }) as never, + ); + return frontendOpenOrders; + }; + + const edit = async () => + provider.editOrder({ + orderId: '123', + newOrder: { + symbol: 'BTC', + isBuy: true, + size: '0.1', + orderType: 'limit', + price: '49000', + }, + }); + + it('reports the replacement order id, never the one that was replaced', async () => { + withSnapshots( + [restingOrder()], + [restingOrder({ oid: 456, limitPx: '49000' })], + ); + + const result = await edit(); + + expect(result.success).toBe(true); + expect(result.orderId).toBe('456'); + expect(result.orderId).not.toBe('123'); + }); + + it('omits the order id when the edit filled instead of resting', async () => { + // A market edit leaves nothing to resolve. Success is still true — the + // modify was accepted — but there is no resting order to name. + withSnapshots([restingOrder()], []); + + const result = await edit(); + + expect(result.success).toBe(true); + expect(result.orderId).toBeUndefined(); + }); + + it('omits the order id when the replacement is not yet visible', async () => { + // Eventual consistency: the post-modify read still shows the old order. + // Guessing here would report an id the venue has already cancelled. + withSnapshots([restingOrder()], [restingOrder()]); + + const result = await edit(); + + expect(result.success).toBe(true); + expect(result.orderId).toBeUndefined(); + }); + + it('omits the order id when more than one new order could be the replacement', async () => { + withSnapshots( + [restingOrder()], + [ + restingOrder({ oid: 456, limitPx: '49000' }), + restingOrder({ oid: 457, limitPx: '49000' }), + ], + ); + + const result = await edit(); + + expect(result.success).toBe(true); + expect(result.orderId).toBeUndefined(); + }); + + it('does not mistake an order that was already resting for the replacement', async () => { + // The lookalike shares coin, side and size, so attributes alone would + // match it. It existed before the edit, so it cannot be the replacement. + withSnapshots( + [restingOrder(), restingOrder({ oid: 789 })], + [restingOrder({ oid: 789 })], + ); + + const result = await edit(); + + expect(result.success).toBe(true); + expect(result.orderId).toBeUndefined(); + }); + + it('does not mistake a different market or side for the replacement', async () => { + withSnapshots( + [restingOrder()], + [ + restingOrder({ oid: 456, coin: 'ETH' }), + restingOrder({ oid: 457, side: 'A' }), + ], + ); + + const result = await edit(); + + expect(result.success).toBe(true); + expect(result.orderId).toBeUndefined(); + }); + + it('keeps the edit successful when the pre-edit baseline read fails', async () => { + // The cache already confirmed the order is safe to edit, so the baseline + // is wanted only to judge novelty afterwards. Losing it must not sink a + // modify that would otherwise succeed — the same soft failure the + // post-modify lookup already has. + mockSubscriptionService.getOrdersCacheIfInitialized.mockReturnValue([ + { + orderId: '123', + symbol: 'BTC', + side: 'buy', + orderType: 'limit', + size: '0.1', + originalSize: '0.1', + price: '50000', + filledSize: '0', + remainingSize: '0.1', + status: 'open', + timestamp: 1_700_000_000_000, + isTrigger: false, + reduceOnly: false, + }, + ] as never); + mockClientService.getInfoClient.mockReturnValue( + createMockInfoClient({ + frontendOpenOrders: jest + .fn() + .mockRejectedValue(new Error('network down')), + }) as never, + ); + + const result = await edit(); + + expect(result.success).toBe(true); + expect(result.orderId).toBeUndefined(); + expect(mockClientService.getExchangeClient().modify).toHaveBeenCalled(); + }); + + it('keeps the edit successful when the post-modify read fails', async () => { + // The modify was accepted; only the identity lookup failed. Turning that + // into a failed edit would misreport an order that really was changed. + const frontendOpenOrders = jest + .fn() + .mockResolvedValueOnce([restingOrder()]) + .mockRejectedValue(new Error('network down')); + mockClientService.getInfoClient.mockReturnValue( + createMockInfoClient({ frontendOpenOrders }) as never, + ); + + const result = await edit(); + + expect(result.success).toBe(true); + expect(result.orderId).toBeUndefined(); + }); + }); + + it('refuses an unverifiable edit before any trading setup runs', async () => { + // The cold-cache path fails closed, but the refusal is only free if it + // happens BEFORE ensureReadyForTrading: that prompts for signatures and + // writes builder-fee and referral approvals. Rejecting afterwards makes + // the caller pay for an edit that was never going to happen — the same + // ordering placeOrder and updatePositionTPSL already observe. + mockClientService.getInfoClient.mockReturnValue( + createMockInfoClient({ + frontendOpenOrders: jest.fn().mockResolvedValue([]), + }) as never, + ); + + const result = await provider.editOrder({ + orderId: '123', + newOrder: { + symbol: 'BTC', + isBuy: true, + size: '0.1', + orderType: 'limit', + price: '49000', + }, + }); + + expect(result.success).toBe(false); + expect(result.error).toBe( + PERPS_ERROR_CODES.ORDER_EDIT_ORDER_UNVERIFIABLE, + ); + expect( + mockClientService.getExchangeClient().approveBuilderFee, + ).not.toHaveBeenCalled(); + expect( + mockClientService.getExchangeClient().setReferrer, + ).not.toHaveBeenCalled(); + expect( + mockClientService.getExchangeClient().modify, + ).not.toHaveBeenCalled(); + }); + + it('rejects editing a resting trigger order into a plain one', async () => { + // The dangerous direction: `modify` would rebuild the protective stop as + // an immediately-resting limit order and report success. + mockSubscriptionService.getOrdersCacheIfInitialized.mockReturnValue([ + { + orderId: '123', + symbol: 'BTC', + side: 'sell', + orderType: 'market', + size: '0.1', + originalSize: '0.1', + price: '40500', + filledSize: '0', + remainingSize: '0.1', + status: 'open', + timestamp: 1_700_000_000_000, + isTrigger: true, + triggerOrderType: 'stop_market', + triggerPrice: '44000', + reduceOnly: true, + }, + ] as never); + + const result = await provider.editOrder({ + orderId: '123', + newOrder: { + symbol: 'BTC', + isBuy: false, + size: '0.1', + orderType: 'limit', + price: '45000', + }, + }); + + expect(result.success).toBe(false); + expect(result.error).toBe( + PERPS_ERROR_CODES.ORDER_EDIT_TRIGGER_UNSUPPORTED, + ); + expect( + mockClientService.getExchangeClient().modify, + ).not.toHaveBeenCalled(); + }); + + it('rejects editing a resting trigger order the cold cache cannot see', async () => { + // Cache is cold, so the placement type comes from REST instead. Without + // that lookup the edit would rebuild the protective stop as a plain order. + mockClientService.getInfoClient.mockReturnValue( + createMockInfoClient({ + frontendOpenOrders: jest.fn().mockResolvedValue([ + { + coin: 'BTC', + side: 'A', + limitPx: '40500', + sz: '0.1', + origSz: '0.1', + oid: 123, + timestamp: 1_700_000_000_000, + isTrigger: true, + triggerCondition: 'Price below 44000', + triggerPx: '44000', + children: [], + isPositionTpsl: false, + reduceOnly: true, + orderType: 'Stop Market', + }, + ]), + }) as unknown as ReturnType, + ); + + const result = await provider.editOrder({ + orderId: '123', + newOrder: { + symbol: 'BTC', + isBuy: false, + size: '0.1', + orderType: 'limit', + price: '45000', + }, + }); + + expect(result.success).toBe(false); + expect(result.error).toBe( + PERPS_ERROR_CODES.ORDER_EDIT_TRIGGER_UNSUPPORTED, + ); + expect( + mockClientService.getExchangeClient().modify, + ).not.toHaveBeenCalled(); + }); + + it('rejects an edit when the resting order cannot be verified at all', async () => { + mockClientService.getInfoClient.mockReturnValue( + createMockInfoClient({ + frontendOpenOrders: jest.fn().mockResolvedValue([]), + }) as unknown as ReturnType, + ); + + const result = await provider.editOrder({ + orderId: '123', + newOrder: { + symbol: 'BTC', + isBuy: false, + size: '0.1', + orderType: 'limit', + price: '45000', + }, + }); + + expect(result.success).toBe(false); + expect(result.error).toBe( + PERPS_ERROR_CODES.ORDER_EDIT_ORDER_UNVERIFIABLE, + ); + expect( + mockClientService.getExchangeClient().modify, + ).not.toHaveBeenCalled(); + }); + + it('rejects editing a resting order into a trigger placement', async () => { + const result = await provider.editOrder({ + orderId: '123', + newOrder: { + symbol: 'BTC', + isBuy: false, + size: '0.1', + orderType: 'stop_limit', + price: '44500', + triggerPrice: '45000', + }, + }); + + expect(result.success).toBe(false); + expect(result.error).toBe( + PERPS_ERROR_CODES.ORDER_EDIT_TRIGGER_UNSUPPORTED, + ); + expect( + mockClientService.getExchangeClient().modify, + ).not.toHaveBeenCalled(); + }); + }); + + describe('Advanced orders in open-orders state', () => { + it('exposes trigger data for open stop and take profit orders', async () => { + mockClientService.getInfoClient.mockReturnValue( + createMockInfoClient({ + clearinghouseState: jest.fn().mockResolvedValue({ + marginSummary: { totalMarginUsed: '500', accountValue: '10500' }, + crossMarginSummary: { + totalMarginUsed: '500', + accountValue: '10500', + }, + withdrawable: '9500', + assetPositions: [ + { + position: { + coin: 'BTC', + szi: '0.1', + entryPx: '50000', + positionValue: '5000', + unrealizedPnl: '100', + marginUsed: '500', + leverage: { type: 'cross', value: 10 }, + liquidationPx: '45000', + maxLeverage: 50, + returnOnEquity: '20', + cumFunding: { + allTime: '10', + sinceOpen: '5', + sinceChange: '2', + }, + }, + type: 'oneWay', + }, + ], + }), + frontendOpenOrders: jest.fn().mockResolvedValue([ + { + coin: 'BTC', + side: 'A', + limitPx: '40500', + sz: '0.04', + origSz: '0.04', + oid: 501, + timestamp: 1_700_000_000_000, + triggerCondition: 'Price below 45000', + isTrigger: true, + triggerPx: '45000', + children: [], + isPositionTpsl: false, + reduceOnly: true, + orderType: 'Stop Market', + }, + { + coin: 'BTC', + side: 'A', + limitPx: '60000', + sz: '0.06', + origSz: '0.06', + oid: 502, + timestamp: 1_700_000_000_000, + triggerCondition: 'Price above 60000', + isTrigger: true, + triggerPx: '60000', + children: [], + isPositionTpsl: false, + reduceOnly: true, + orderType: 'Take Profit Limit', + }, + ]), + }) as unknown as ReturnType, + ); + + const orders = await provider.getOpenOrders({ skipCache: true }); + + const stopOrder = orders.find((order) => order.orderId === '501'); + expect(stopOrder).toMatchObject({ + symbol: 'BTC', + side: 'sell', + triggerOrderType: 'stop_market', + triggerPrice: '45000', + reduceOnly: true, + isTrigger: true, + size: '0.04', + }); + + const takeProfitOrder = orders.find((order) => order.orderId === '502'); + expect(takeProfitOrder).toMatchObject({ + triggerOrderType: 'take_profit_limit', + triggerPrice: '60000', + reduceOnly: true, + size: '0.06', + }); + }); + + it('reports partial and full trigger orders on the position', async () => { + mockClientService.getInfoClient.mockReturnValue( + createMockInfoClient({ + clearinghouseState: jest.fn().mockResolvedValue({ + marginSummary: { totalMarginUsed: '500', accountValue: '10500' }, + crossMarginSummary: { + totalMarginUsed: '500', + accountValue: '10500', + }, + withdrawable: '9500', + assetPositions: [ + { + position: { + coin: 'BTC', + szi: '0.1', + entryPx: '50000', + positionValue: '5000', + unrealizedPnl: '100', + marginUsed: '500', + leverage: { type: 'cross', value: 10 }, + liquidationPx: '45000', + maxLeverage: 50, + returnOnEquity: '20', + cumFunding: { + allTime: '10', + sinceOpen: '5', + sinceChange: '2', + }, + }, + type: 'oneWay', + }, + ], + }), + frontendOpenOrders: jest.fn().mockResolvedValue([ + { + coin: 'BTC', + side: 'A', + limitPx: '60000', + sz: '0.04', + origSz: '0.04', + oid: 601, + timestamp: 1_700_000_000_000, + triggerCondition: 'Price above 60000', + isTrigger: true, + triggerPx: '60000', + children: [], + isPositionTpsl: true, + reduceOnly: true, + orderType: 'Take Profit Limit', + }, + { + coin: 'BTC', + side: 'A', + limitPx: '40500', + sz: '0', + origSz: '0', + oid: 602, + timestamp: 1_700_000_000_000, + triggerCondition: 'Price below 45000', + isTrigger: true, + triggerPx: '45000', + children: [], + isPositionTpsl: true, + reduceOnly: true, + orderType: 'Stop Market', + }, + ]), + }) as unknown as ReturnType, + ); + + const positions = await provider.getPositions({ skipCache: true }); + const position = positions.find((pos) => pos.symbol === 'BTC'); + + expect(position?.takeProfitOrders).toStrictEqual([ + { + orderId: '601', + direction: 'take_profit', + orderType: 'take_profit_limit', + triggerPrice: '60000', + size: '0.04', + isPartial: true, + reduceOnly: true, + }, + ]); + expect(position?.stopLossOrders).toStrictEqual([ + { + orderId: '602', + direction: 'stop', + orderType: 'stop_market', + triggerPrice: '45000', + // Position-bound stop (size 0) resolves to the whole position + size: '0.1', + isPartial: false, + reduceOnly: true, + }, + ]); + expect(position?.takeProfitCount).toBe(1); + expect(position?.stopLossCount).toBe(1); + }); + + it('excludes a pending order TP/SL child and never double-counts it', async () => { + // HyperLiquid lists a normalTpsl child both nested under its parent and as + // a top-level entry. It protects the pending order, not the position. + const takeProfitChild = { + coin: 'BTC', + side: 'A', + limitPx: '60000', + sz: '0.05', + origSz: '0.05', + oid: 802, + timestamp: 1_700_000_000_000, + triggerCondition: 'Price above 60000', + isTrigger: true, + triggerPx: '60000', + children: [], + isPositionTpsl: false, + reduceOnly: true, + orderType: 'Take Profit Limit', + }; + + mockClientService.getInfoClient.mockReturnValue( + createMockInfoClient({ + clearinghouseState: jest.fn().mockResolvedValue({ + marginSummary: { totalMarginUsed: '500', accountValue: '10500' }, + crossMarginSummary: { + totalMarginUsed: '500', + accountValue: '10500', + }, + withdrawable: '9500', + assetPositions: [ + { + position: { + coin: 'BTC', + szi: '0.1', + entryPx: '50000', + positionValue: '5000', + unrealizedPnl: '100', + marginUsed: '500', + leverage: { type: 'cross', value: 10 }, + liquidationPx: '45000', + maxLeverage: 50, + returnOnEquity: '20', + cumFunding: { + allTime: '10', + sinceOpen: '5', + sinceChange: '2', + }, + }, + type: 'oneWay', + }, + ], + }), + frontendOpenOrders: jest.fn().mockResolvedValue([ + { + // Pending entry order carrying the TP child + coin: 'BTC', + side: 'B', + limitPx: '40000', + sz: '0.05', + origSz: '0.05', + oid: 801, + timestamp: 1_700_000_000_000, + triggerCondition: 'N/A', + isTrigger: false, + triggerPx: '', + children: [takeProfitChild], + isPositionTpsl: false, + reduceOnly: false, + orderType: 'Limit', + }, + takeProfitChild, + ]), + }) as unknown as ReturnType, + ); + + const positions = await provider.getPositions({ skipCache: true }); + const position = positions.find((pos) => pos.symbol === 'BTC'); + + expect(position?.takeProfitOrders).toStrictEqual([]); + expect(position?.stopLossOrders).toStrictEqual([]); + expect(position?.takeProfitCount).toBe(0); + expect(position?.stopLossCount).toBe(0); + }); + + it('includes standalone partial triggers that are not position-bound', async () => { + mockClientService.getInfoClient.mockReturnValue( + createMockInfoClient({ + clearinghouseState: jest.fn().mockResolvedValue({ + marginSummary: { totalMarginUsed: '500', accountValue: '10500' }, + crossMarginSummary: { + totalMarginUsed: '500', + accountValue: '10500', + }, + withdrawable: '9500', + assetPositions: [ + { + position: { + coin: 'BTC', + szi: '0.1', + entryPx: '50000', + positionValue: '5000', + unrealizedPnl: '100', + marginUsed: '500', + leverage: { type: 'cross', value: 10 }, + liquidationPx: '45000', + maxLeverage: 50, + returnOnEquity: '20', + cumFunding: { + allTime: '10', + sinceOpen: '5', + sinceChange: '2', + }, + }, + type: 'oneWay', + }, + ], + }), + frontendOpenOrders: jest.fn().mockResolvedValue([ + { + coin: 'BTC', + side: 'A', + limitPx: '60000', + sz: '0.04', + origSz: '0.04', + oid: 701, + timestamp: 1_700_000_000_000, + triggerCondition: 'Price above 60000', + isTrigger: true, + triggerPx: '60000', + children: [], + // Partial TP/SL is placed with 'na' grouping, so it is a + // standalone reduce-only trigger rather than position-bound. + isPositionTpsl: false, + reduceOnly: true, + orderType: 'Take Profit Limit', + }, + ]), + }) as unknown as ReturnType, + ); + + const positions = await provider.getPositions({ skipCache: true }); + const position = positions.find((pos) => pos.symbol === 'BTC'); + + expect(position?.takeProfitOrders).toStrictEqual([ + { + orderId: '701', + direction: 'take_profit', + orderType: 'take_profit_limit', + triggerPrice: '60000', + size: '0.04', + isPartial: true, + reduceOnly: true, + }, + ]); + // A lone trigger is the position's take profit whether or not it is + // position-bound, so the scalar summary field reports its price. + expect(position?.takeProfitPrice).toBe('60000'); + expect(position?.takeProfitCount).toBe(1); + }); + + it('leaves the summary price unset when two partial take profits share the position', async () => { + const partialTakeProfit = (oid: number, triggerPx: string) => ({ + coin: 'BTC', + side: 'A', + limitPx: triggerPx, + sz: '0.04', + origSz: '0.04', + oid, + timestamp: 1_700_000_000_000, + triggerCondition: `Price above ${triggerPx}`, + isTrigger: true, + triggerPx, + children: [], + isPositionTpsl: false, + reduceOnly: true, + orderType: 'Take Profit Limit', + }); + + mockClientService.getInfoClient.mockReturnValue( + createMockInfoClient({ + clearinghouseState: jest.fn().mockResolvedValue({ + marginSummary: { totalMarginUsed: '500', accountValue: '10500' }, + crossMarginSummary: { + totalMarginUsed: '500', + accountValue: '10500', + }, + withdrawable: '9500', + assetPositions: [ + { + position: { + coin: 'BTC', + szi: '0.1', + entryPx: '50000', + positionValue: '5000', + unrealizedPnl: '100', + marginUsed: '500', + leverage: { type: 'cross', value: 10 }, + liquidationPx: '45000', + maxLeverage: 50, + returnOnEquity: '20', + cumFunding: { + allTime: '10', + sinceOpen: '5', + sinceChange: '2', + }, + }, + type: 'oneWay', + }, + ], + }), + frontendOpenOrders: jest + .fn() + .mockResolvedValue([ + partialTakeProfit(701, '60000'), + partialTakeProfit(702, '62000'), + ]), + }) as unknown as ReturnType, + ); + + const positions = await provider.getPositions({ skipCache: true }); + const position = positions.find((pos) => pos.symbol === 'BTC'); + + // No single price describes two triggers; the count is what a client shows. + expect(position?.takeProfitCount).toBe(2); + expect(position?.takeProfitPrice).toBeUndefined(); + }); + }); + + describe('updatePositionTPSL with partial sizes', () => { + const position = { + symbol: 'BTC', + size: '0.1', + entryPrice: '50000', + positionValue: '5000', + unrealizedPnl: '100', + marginUsed: '500', + leverage: { type: 'cross' as const, value: 10 }, + liquidationPrice: '45000', + maxLeverage: 50, + returnOnEquity: '20', + cumulativeFunding: { allTime: '10', sinceOpen: '5', sinceChange: '2' }, + takeProfitCount: 0, + stopLossCount: 0, + }; + + it('places partial TP/SL as standalone reduce-only triggers', async () => { + const result = await provider.updatePositionTPSL({ + symbol: 'BTC', + takeProfitPrice: '60000', + takeProfitSize: '0.04', + stopLossPrice: '45000', + stopLossSize: '0.06', + position, + }); + + expect(result.success).toBe(true); + const request = (mockClientService.getExchangeClient().order as jest.Mock) + .mock.calls[0][0]; + // A quantity cannot be expressed under positionTpsl grouping + expect(request.grouping).toBe('na'); + expect(request.orders).toHaveLength(2); + expect(request.orders[0].s).toBe('0.04'); + expect(request.orders[0].r).toBe(true); + expect(request.orders[1].s).toBe('0.06'); + expect(request.orders[1].r).toBe(true); + }); + + it('keeps whole-position TP/SL on positionTpsl grouping with size 0', async () => { + const result = await provider.updatePositionTPSL({ + symbol: 'BTC', + takeProfitPrice: '60000', + stopLossPrice: '45000', + position, + }); + + expect(result.success).toBe(true); + const request = (mockClientService.getExchangeClient().order as jest.Mock) + .mock.calls[0][0]; + expect(request.grouping).toBe('positionTpsl'); + expect(request.orders[0].s).toBe('0'); + expect(request.orders[1].s).toBe('0'); + }); + + it('mixes a partial take profit with a whole-position stop loss', async () => { + await provider.updatePositionTPSL({ + symbol: 'BTC', + takeProfitPrice: '60000', + takeProfitSize: '0.04', + stopLossPrice: '45000', + position, + }); + + const request = (mockClientService.getExchangeClient().order as jest.Mock) + .mock.calls[0][0]; + expect(request.grouping).toBe('na'); + expect(request.orders[0].s).toBe('0.04'); + // The stop loss without an explicit size covers the full position size + expect(request.orders[1].s).toBe('0.1'); + }); + + it('cancels standalone partial triggers but never another order TP/SL child', async () => { + const takeProfitChild = { + coin: 'BTC', + side: 'A', + limitPx: '65000', + sz: '0.05', + origSz: '0.05', + oid: 902, + timestamp: 1_700_000_000_000, + triggerCondition: 'Price above 65000', + isTrigger: true, + triggerPx: '65000', + children: [], + isPositionTpsl: false, + reduceOnly: true, + orderType: 'Take Profit Limit', + }; + + mockClientService.getInfoClient.mockReturnValue( + createMockInfoClient({ + frontendOpenOrders: jest.fn().mockResolvedValue([ + { + // Standalone partial TP previously placed for this position + coin: 'BTC', + side: 'A', + limitPx: '60000', + sz: '0.04', + origSz: '0.04', + oid: 901, + timestamp: 1_700_000_000_000, + triggerCondition: 'Price above 60000', + isTrigger: true, + triggerPx: '60000', + children: [], + isPositionTpsl: false, + reduceOnly: true, + orderType: 'Take Profit Limit', + }, + { + // Unrelated pending entry order with its own TP child + coin: 'BTC', + side: 'B', + limitPx: '40000', + sz: '0.05', + origSz: '0.05', + oid: 903, + timestamp: 1_700_000_000_000, + triggerCondition: 'N/A', + isTrigger: false, + triggerPx: '', + children: [takeProfitChild], + isPositionTpsl: false, + reduceOnly: false, + orderType: 'Limit', + }, + takeProfitChild, + ]), + }) as unknown as ReturnType, + ); + + await provider.updatePositionTPSL({ + symbol: 'BTC', + takeProfitPrice: '61000', + takeProfitSize: '0.04', + position, + }); + + expect(mockClientService.getExchangeClient().cancel).toHaveBeenCalledWith( + { + cancels: [{ a: 0, o: 901 }], + }, + ); + }); + + it('uses the REST order payload for partial updates even with a warm cache', async () => { + mockSubscriptionService.getOrdersCacheIfInitialized.mockReturnValue([]); + const infoClient = createMockInfoClient(); + mockClientService.getInfoClient.mockReturnValue( + infoClient as unknown as ReturnType< + typeof mockClientService.getInfoClient + >, + ); + + await provider.updatePositionTPSL({ + symbol: 'BTC', + takeProfitPrice: '60000', + takeProfitSize: '0.04', + position, + }); + + // The cache cannot express the parent/child relationship a partial update + // needs, so the REST payload is fetched even though the cache is warm. + expect(infoClient.frontendOpenOrders).toHaveBeenCalled(); + }); + + it('cancels standalone partial leftovers on a later whole-position update', async () => { + // Warm cache showing a standalone (not position-bound) reduce-only + // trigger left over from an earlier partial update. + mockSubscriptionService.getOrdersCacheIfInitialized.mockReturnValue([ + { + orderId: '901', + symbol: 'BTC', + side: 'sell', + orderType: 'limit', + size: '0.04', + originalSize: '0.04', + price: '60000', + filledSize: '0', + remainingSize: '0.04', + status: 'open', + timestamp: 1_700_000_000_000, + detailedOrderType: 'Take Profit Limit', + isTrigger: true, + reduceOnly: true, + isPositionTpsl: false, + triggerPrice: '60000', + }, + ]); + const infoClient = createMockInfoClient({ + frontendOpenOrders: jest.fn().mockResolvedValue([ + { + coin: 'BTC', + side: 'A', + limitPx: '60000', + sz: '0.04', + origSz: '0.04', + oid: 901, + timestamp: 1_700_000_000_000, + triggerCondition: 'Price above 60000', + isTrigger: true, + triggerPx: '60000', + children: [], + isPositionTpsl: false, + reduceOnly: true, + orderType: 'Take Profit Limit', + }, + ]), + }); + mockClientService.getInfoClient.mockReturnValue( + infoClient as unknown as ReturnType< + typeof mockClientService.getInfoClient + >, + ); + + const result = await provider.updatePositionTPSL({ + symbol: 'BTC', + takeProfitPrice: '61000', + stopLossPrice: '45000', + position, + }); + + expect(result.success).toBe(true); + // The leftover standalone trigger must not survive beside the new + // whole-position TP/SL orders — it would fire independently. + expect(mockClientService.getExchangeClient().cancel).toHaveBeenCalledWith( + { + cancels: [{ a: 0, o: 901 }], + }, + ); + }); + + it('returns a typed error when a partial size exceeds the position', async () => { + mockValidateOrderParams.mockImplementation( + jest.requireActual('../../../src/utils/hyperLiquidValidation.js') + .validateOrderParams, + ); + + const result = await provider.updatePositionTPSL({ + symbol: 'BTC', + takeProfitPrice: '60000', + takeProfitSize: '0.5', + position, + }); + + expect(result.success).toBe(false); + expect(result.error).toBe(PERPS_ERROR_CODES.ORDER_TPSL_SIZE_INVALID); + expect( + mockClientService.getExchangeClient().order, + ).not.toHaveBeenCalled(); + }); + + it('returns a typed error when a partial size rounds to zero', async () => { + // 0.0004 is positive and below the position, so validation passes, but it + // formats to '0' at szDecimals: 3 — which HyperLiquid reads as a + // whole-position trigger, silently closing the entire position. + mockValidateOrderParams.mockImplementation( + jest.requireActual('../../../src/utils/hyperLiquidValidation.js') + .validateOrderParams, + ); + + const result = await provider.updatePositionTPSL({ + symbol: 'BTC', + takeProfitPrice: '60000', + takeProfitSize: '0.0004', + position, + }); + + expect(result.success).toBe(false); + expect(result.error).toBe(PERPS_ERROR_CODES.ORDER_TPSL_SIZE_INVALID); + expect( + mockClientService.getExchangeClient().order, + ).not.toHaveBeenCalled(); + }); + + it.each([ + ['a size larger than the position', '0.5'], + ['a size that rounds to zero', '0.0004'], + ['a non-positive size', '0'], + ])( + 'rejects %s without running trading setup', + async (_label, takeProfitSize) => { + // Trading setup can prompt a hardware wallet and write the referral / + // builder-fee approvals on-chain. None of that should happen for an + // update that is rejected outright. + mockValidateOrderParams.mockImplementation( + jest.requireActual('../../../src/utils/hyperLiquidValidation.js') + .validateOrderParams, + ); + + const result = await provider.updatePositionTPSL({ + symbol: 'BTC', + takeProfitPrice: '60000', + takeProfitSize, + position, + }); + + expect(result.success).toBe(false); + expect(result.error).toBe(PERPS_ERROR_CODES.ORDER_TPSL_SIZE_INVALID); + expect( + mockClientService.getExchangeClient().setReferrer, + ).not.toHaveBeenCalled(); + }, + ); + + it('rejects a TP/SL price that rounds to zero before the pre-cancel sweep', async () => { + mockValidateOrderParams.mockImplementation( + jest.requireActual('../../../src/utils/hyperLiquidValidation.js') + .validateOrderParams, + ); + + const result = await provider.updatePositionTPSL({ + symbol: 'BTC', + takeProfitPrice: '0.0004', + position, + }); + + expect(result.success).toBe(false); + expect(result.error).toBe(PERPS_ERROR_CODES.ORDER_PRICE_POSITIVE); + expect( + mockClientService.getExchangeClient().cancel, + ).not.toHaveBeenCalled(); + expect( + mockClientService.getExchangeClient().setReferrer, + ).not.toHaveBeenCalled(); + }); + + it('leaves the position protected when a partial size rounds to zero', async () => { + // The position already has a whole-position TP/SL the sweep would cancel. + // Rejecting the update after that sweep would strip the protection and + // put nothing back, so the rejection has to come first. + mockValidateOrderParams.mockImplementation( + jest.requireActual('../../../src/utils/hyperLiquidValidation.js') + .validateOrderParams, + ); + // A partial update always reads the REST payload for parent/child links, + // so that is where the sweep finds the trigger it would cancel. + mockClientService.getInfoClient.mockReturnValue( + createMockInfoClient({ + frontendOpenOrders: jest.fn().mockResolvedValue([ + { + coin: 'BTC', + side: 'A', + limitPx: '58000', + sz: '0', + origSz: '0', + oid: 777, + timestamp: 1_700_000_000_000, + isTrigger: true, + triggerCondition: 'Price above 58000', + triggerPx: '58000', + children: [], + isPositionTpsl: true, + reduceOnly: true, + orderType: 'Take Profit Limit', + }, + ]), + }) as unknown as ReturnType, + ); + + const result = await provider.updatePositionTPSL({ + symbol: 'BTC', + takeProfitPrice: '60000', + takeProfitSize: '0.0004', + position, + }); + + expect(result.success).toBe(false); + expect(result.error).toBe(PERPS_ERROR_CODES.ORDER_TPSL_SIZE_INVALID); + expect( + mockClientService.getExchangeClient().cancel, + ).not.toHaveBeenCalled(); + expect( + mockClientService.getExchangeClient().order, + ).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/packages/perps-controller/tests/src/providers/HyperLiquidProvider.builder-fees.test.ts b/packages/perps-controller/tests/src/providers/HyperLiquidProvider.builder-fees.test.ts new file mode 100644 index 00000000000..5174a73f065 --- /dev/null +++ b/packages/perps-controller/tests/src/providers/HyperLiquidProvider.builder-fees.test.ts @@ -0,0 +1,1943 @@ +/* eslint-disable */ +jest.mock('@nktkas/hyperliquid', () => ({})); + +import type { CaipAssetId, Hex } from '@metamask/utils'; + +import { CandlePeriod } from '../../../src/constants/chartConfig.js'; +import { + BUILDER_FEE_CONFIG, + REFERRAL_CONFIG, +} from '../../../src/constants/hyperLiquidConfig.js'; +import { PERPS_TRANSACTIONS_HISTORY_CONSTANTS } from '../../../src/constants/transactionsHistoryConfig.js'; +import { PERPS_ERROR_CODES } from '../../../src/perpsErrorCodes.js'; +import { HyperLiquidProvider } from '../../../src/providers/HyperLiquidProvider.js'; +import { HyperLiquidClientService } from '../../../src/services/HyperLiquidClientService.js'; +import { HyperLiquidSubscriptionService } from '../../../src/services/HyperLiquidSubscriptionService.js'; +import { HyperLiquidWalletService } from '../../../src/services/HyperLiquidWalletService.js'; +import { PerpsSigningCache } from '../../../src/services/TradingReadinessCache.js'; +import type { + ClosePositionParams, + DepositParams, + Order, + PerpsPlatformDependencies, + LiveDataConfig, + OrderParams, +} from '../../../src/types/index.js'; +import { + validateAssetSupport, + validateBalance, + validateCoinExists, + validateDepositParams, + validateOrderParams, + validateWithdrawalParams, +} from '../../../src/utils/hyperLiquidValidation.js'; +import { createStandaloneInfoClient } from '../../../src/utils/standaloneInfoClient.js'; +import { + createMockInfrastructure, + createMockMessenger, +} from '../../helpers/serviceMocks.js'; + +jest.mock('../../../src/services/HyperLiquidClientService'); +jest.mock('../../../src/services/HyperLiquidWalletService'); +jest.mock('../../../src/services/HyperLiquidSubscriptionService'); +// Mock stream manager - will be set up in test +let mockStreamManagerInstance: any; +const mockGetStreamManagerInstance = jest.fn(() => mockStreamManagerInstance); +jest.mock( + '../../../../components/UI/Perps/providers/PerpsStreamManager', + () => ({ + getStreamManagerInstance: mockGetStreamManagerInstance, + }), + { virtual: true }, +); + +// Mock standalone info client for standalone mode tests +let mockStandaloneInfoClient: any; +jest.mock('../../../src/utils/standaloneInfoClient', () => ({ + ...jest.requireActual('../../../src/utils/standaloneInfoClient'), + createStandaloneInfoClient: jest.fn(() => mockStandaloneInfoClient), +})); + +jest.mock('../../../src/utils/hyperLiquidValidation', () => ({ + validateOrderParams: jest.fn(), + validateWithdrawalParams: jest.fn(), + validateDepositParams: jest.fn(), + validateCoinExists: jest.fn(), + validateAssetSupport: jest.fn(), + validateBalance: jest.fn(), + getSupportedPaths: jest + .fn() + .mockReturnValue([ + 'eip155:42161/erc20:0xa0b86a33e6776e681a06e0e1622c5e5e3e6a8b13/default', + 'eip155:1/erc20:0xa0b86a33e6776e681a06e0e1622c5e5e3e6a8b13/default', + ]), + getBridgeInfo: jest.fn().mockReturnValue({ + chainId: 'eip155:42161', + contractAddress: '0x1234567890123456789012345678901234567890', + }), + createErrorResult: jest.fn((error, defaultResponse) => ({ + ...defaultResponse, + success: false, + error: error instanceof Error ? error.message : String(error), + })), +})); + +// Mock adapter functions +jest.mock('../../../src/utils/hyperLiquidAdapter', () => { + const actual = jest.requireActual('../../../src/utils/hyperLiquidAdapter'); + return { + ...actual, + adaptHyperLiquidLedgerUpdateToUserHistoryItem: jest.fn((updates) => { + // Return mock history items based on input + if (!updates || !Array.isArray(updates) || updates.length === 0) { + return []; + } + return updates.map((_update: unknown) => ({ + type: 'deposit' as const, + amount: '100', + timestamp: Date.now(), + hash: '0x123', + })); + }), + }; +}); + +// Mock PerpsSigningCache (exported from TradingReadinessCache module) — global +// singleton for signing operation caching. Use jest.createMockFromModule for +// proper mock creation. +jest.mock('../../../src/services/TradingReadinessCache'); + +const MockedHyperLiquidClientService = + HyperLiquidClientService as jest.MockedClass; +const MockedHyperLiquidWalletService = + HyperLiquidWalletService as jest.MockedClass; +const MockedHyperLiquidSubscriptionService = + HyperLiquidSubscriptionService as jest.MockedClass< + typeof HyperLiquidSubscriptionService + >; +const mockValidateOrderParams = validateOrderParams as jest.MockedFunction< + typeof validateOrderParams +>; +const mockValidateWithdrawalParams = + validateWithdrawalParams as jest.MockedFunction< + typeof validateWithdrawalParams + >; +const mockValidateDepositParams = validateDepositParams as jest.MockedFunction< + typeof validateDepositParams +>; +const mockValidateCoinExists = validateCoinExists as jest.MockedFunction< + typeof validateCoinExists +>; +const mockValidateAssetSupport = validateAssetSupport as jest.MockedFunction< + typeof validateAssetSupport +>; +const mockValidateBalance = validateBalance as jest.MockedFunction< + typeof validateBalance +>; + +// Mock factory functions - defined once, reused everywhere +// These reduce duplication and make tests more maintainable +const createMockInfoClient = (overrides: Record = {}) => ({ + clearinghouseState: jest.fn().mockResolvedValue({ + marginSummary: { + totalMarginUsed: '500', + accountValue: '10500', + }, + withdrawable: '9500', + assetPositions: [ + { + position: { + coin: 'BTC', + szi: '0.1', + entryPx: '50000', + positionValue: '5000', + unrealizedPnl: '100', + marginUsed: '500', + leverage: { type: 'cross', value: 10 }, + liquidationPx: '45000', + maxLeverage: 50, + returnOnEquity: '20', + cumFunding: { allTime: '10', sinceOpen: '5', sinceChange: '2' }, + }, + type: 'oneWay', + }, + { + position: { + coin: 'ETH', + szi: '1.5', + entryPx: '3000', + positionValue: '4500', + unrealizedPnl: '50', + marginUsed: '450', + leverage: { type: 'cross', value: 10 }, + liquidationPx: '2700', + maxLeverage: 50, + returnOnEquity: '10', + cumFunding: { allTime: '5', sinceOpen: '2', sinceChange: '1' }, + }, + type: 'oneWay', + }, + ], + crossMarginSummary: { + accountValue: '10000', + totalMarginUsed: '5000', + }, + }), + spotClearinghouseState: jest.fn().mockResolvedValue({ + balances: [{ coin: 'USDC', hold: '1000', total: '10000' }], + }), + // Mode-aware fold gate reads userAbstraction; default to unifiedAccount + // so tests that predated the gate still see spot folded into spendable/withdrawable. + userAbstraction: jest.fn().mockResolvedValue('unifiedAccount'), + meta: jest.fn().mockResolvedValue({ + universe: [ + { name: 'BTC', szDecimals: 3, maxLeverage: 50 }, + { name: 'ETH', szDecimals: 4, maxLeverage: 50 }, + ], + }), + metaAndAssetCtxs: jest.fn().mockResolvedValue([ + { + universe: [ + { name: 'BTC', szDecimals: 3, maxLeverage: 50 }, + { name: 'ETH', szDecimals: 4, maxLeverage: 50 }, + ], + }, + [ + { + funding: '0.0001', + openInterest: '1000', + prevDayPx: '49000', + dayNtlVlm: '1000000', + markPx: '50000', + midPx: '50000', + oraclePx: '50000', + }, + { + funding: '0.0001', + openInterest: '500', + prevDayPx: '2900', + dayNtlVlm: '500000', + markPx: '3000', + midPx: '3000', + oraclePx: '3000', + }, + ], + ]), + perpDexs: jest.fn().mockResolvedValue([null]), + allMids: jest.fn().mockResolvedValue({ BTC: '50000', ETH: '3000' }), + frontendOpenOrders: jest.fn().mockResolvedValue([]), + referral: jest.fn().mockResolvedValue({ + referrerState: { + stage: 'ready', + data: { code: 'MMCSI' }, + }, + }), + maxBuilderFee: jest.fn().mockResolvedValue(1), + userFees: jest.fn().mockResolvedValue({ + feeSchedule: { + cross: '0.00030', + add: '0.00010', + spotCross: '0.00040', + spotAdd: '0.00020', + }, + dailyUserVlm: [], + }), + userNonFundingLedgerUpdates: jest.fn().mockResolvedValue([ + { + delta: { type: 'deposit', usdc: '100' }, + time: Date.now(), + hash: '0x123abc', + }, + { + delta: { type: 'withdraw', usdc: '50' }, + time: Date.now() - 3600000, + hash: '0x456def', + }, + ]), + portfolio: jest.fn().mockResolvedValue([ + null, + [ + null, + { + accountValueHistory: [ + [Date.now() - 86400000, '10000'], // 24h ago + [Date.now() - 172800000, '9500'], // 48h ago + [Date.now() - 259200000, '9000'], // 72h ago + ], + }, + ], + ]), + spotMeta: jest.fn().mockResolvedValue({ + tokens: [ + { name: 'USDC', tokenId: '0xdef456', index: 0 }, + { name: 'USDT', tokenId: '0x789abc', index: 1 }, + ], + universe: [], + }), + historicalOrders: jest.fn().mockResolvedValue([]), + userFills: jest.fn().mockResolvedValue([]), + userFillsByTime: jest.fn().mockResolvedValue([]), + userFunding: jest.fn().mockResolvedValue([]), + ...overrides, +}); + +const createMockExchangeClient = (overrides: Record = {}) => ({ + order: jest.fn().mockResolvedValue({ + status: 'ok', + response: { data: { statuses: [{ resting: { oid: 123 } }] } }, + }), + modify: jest.fn().mockResolvedValue({ + status: 'ok', + response: { data: { statuses: [{ resting: { oid: '123' } }] } }, + }), + cancel: jest.fn().mockResolvedValue({ + status: 'ok', + response: { data: { statuses: ['success'] } }, + }), + withdraw3: jest.fn().mockResolvedValue({ + status: 'ok', + }), + updateLeverage: jest.fn().mockResolvedValue({ + status: 'ok', + }), + approveBuilderFee: jest.fn().mockResolvedValue({ + status: 'ok', + }), + setReferrer: jest.fn().mockResolvedValue({ + status: 'ok', + }), + sendAsset: jest.fn().mockResolvedValue({ + status: 'ok', + }), + agentSetAbstraction: jest.fn().mockResolvedValue({ + status: 'ok', + }), + userSetAbstraction: jest.fn().mockResolvedValue({ + status: 'ok', + }), + ...overrides, +}); + +// Create shared mock platform dependencies for provider tests +const mockPlatformDependencies: PerpsPlatformDependencies = + createMockInfrastructure(); + +const mockMessenger = createMockMessenger(); + +/** + * Helper to create HyperLiquidProvider with mock platform dependencies + * @param options + * @param options.isTestnet + * @param options.hip3Enabled + * @param options.allowlistMarkets + * @param options.blocklistMarkets + * @param options.useUnifiedAccount + */ +const createTestProvider = ( + options: { + isTestnet?: boolean; + hip3Enabled?: boolean; + allowlistMarkets?: string[]; + blocklistMarkets?: string[]; + useUnifiedAccount?: boolean; + initialAssetMapping?: [string, number][]; + subscriptionBuilderAddressTestnet?: string; + subscriptionBuilderAddressMainnet?: string; + } = {}, +): HyperLiquidProvider => + new HyperLiquidProvider({ + ...options, + platformDependencies: mockPlatformDependencies, + messenger: mockMessenger, + }); + +describe('HyperLiquidProvider', () => { + let provider: HyperLiquidProvider; + let mockClientService: jest.Mocked; + let mockWalletService: jest.Mocked; + let mockSubscriptionService: jest.Mocked; + + beforeEach(() => { + // Reset all mocks + jest.clearAllMocks(); + ( + mockPlatformDependencies.marketDataFormatters.formatVolume as jest.Mock + ).mockImplementation((value: number) => '$' + value.toFixed(0)); + ( + mockPlatformDependencies.marketDataFormatters.formatPerpsFiat as jest.Mock + ).mockImplementation((value: number) => '$' + value.toFixed(2)); + ( + mockPlatformDependencies.marketDataFormatters + .formatPercentage as jest.Mock + ).mockImplementation((value: number) => `${value.toFixed(2)}%`); + ( + mockPlatformDependencies.featureFlags.validateVersionGated as jest.Mock + ).mockReturnValue(undefined); + (mockPlatformDependencies.metrics.isEnabled as jest.Mock).mockReturnValue( + true, + ); + + // Reset PerpsSigningCache mock state (using imported mocked module) + const mockedCache = PerpsSigningCache as jest.Mocked< + typeof PerpsSigningCache + >; + mockedCache.get.mockReturnValue(undefined); + mockedCache.getBuilderFee.mockReturnValue(undefined); + mockedCache.getReferral.mockReturnValue(undefined); + mockedCache.isInFlight.mockReturnValue(undefined); + mockedCache.setInFlight.mockReturnValue(jest.fn()); + + // Initialize mock stream manager instance + mockStreamManagerInstance = { + clearAllChannels: jest.fn(), + }; + + // Create mocked service instances using factory functions + mockClientService = { + initialize: jest.fn(), + isInitialized: jest.fn().mockReturnValue(true), + isTestnetMode: jest.fn().mockReturnValue(false), + ensureInitialized: jest.fn(), + getExchangeClient: jest.fn().mockReturnValue(createMockExchangeClient()), + getInfoClient: jest.fn().mockReturnValue(createMockInfoClient()), + fetchHistoricalOrders: jest.fn().mockResolvedValue([]), + disconnect: jest.fn().mockResolvedValue(undefined), + toggleTestnet: jest.fn(), + setTestnetMode: jest.fn(), + getNetwork: jest.fn().mockReturnValue('mainnet'), + ensureSubscriptionClient: jest.fn().mockResolvedValue(undefined), + getSubscriptionClient: jest.fn(), + setOnReconnectCallback: jest.fn(), + setOnTerminateCallback: jest.fn(), + getConnectionState: jest.fn().mockReturnValue('connected'), + } as Partial as jest.Mocked; + + mockWalletService = { + setTestnetMode: jest.fn(), + getCurrentAccountId: jest + .fn() + .mockReturnValue( + 'eip155:42161:0x1234567890123456789012345678901234567890', + ), + createWalletAdapter: jest.fn().mockReturnValue({ + request: jest + .fn() + .mockResolvedValue(['0x1234567890123456789012345678901234567890']), + }), + getUserAddress: jest + .fn() + .mockReturnValue('0x1234567890123456789012345678901234567890'), + getUserAddressWithDefault: jest + .fn() + .mockResolvedValue('0x1234567890123456789012345678901234567890'), + isKeyringUnlocked: jest.fn().mockReturnValue(true), + isSelectedHardwareWallet: jest.fn().mockReturnValue(false), + } as Partial as jest.Mocked; + + mockSubscriptionService = { + subscribeToPrices: jest.fn().mockResolvedValue(jest.fn()), // Returns Promise + subscribeToPositions: jest.fn().mockReturnValue(jest.fn()), // Returns function directly + subscribeToOrderFills: jest.fn().mockReturnValue(jest.fn()), // Returns function directly + clearAll: jest.fn(), + isPositionsCacheInitialized: jest.fn().mockReturnValue(false), + getCachedPositions: jest.fn().mockReturnValue([]), + updateFeatureFlags: jest.fn().mockResolvedValue(undefined), + // Cache methods used by buildAssetMapping optimization + setDexMetaCache: jest.fn(), + setDexAssetCtxsCache: jest.fn(), + getDexAssetCtxsCache: jest.fn().mockReturnValue(undefined), + // Price cache used by placeOrder, editOrder, closePosition optimizations + getCachedPrice: jest.fn().mockImplementation((symbol: string) => { + const prices: Record = { BTC: '50000', ETH: '3000' }; + return prices[symbol]; + }), + getLastAllMidsSnapshot: jest.fn().mockReturnValue(null), + // Orders cache used by updatePositionTPSL and getOpenOrders + isOrdersCacheInitialized: jest.fn().mockReturnValue(false), + getCachedOrders: jest.fn().mockReturnValue([]), + // Atomic getter - returns null when cache not initialized (prevents race condition) + getOrdersCacheIfInitialized: jest.fn().mockReturnValue(null), + // Abstraction-mode resolved-mode setter (unified account migration) + setUserAbstractionMode: jest.fn(), + } as Partial as jest.Mocked; + + // Mock constructors + MockedHyperLiquidClientService.mockImplementation(() => mockClientService); + MockedHyperLiquidWalletService.mockImplementation(() => mockWalletService); + MockedHyperLiquidSubscriptionService.mockImplementation( + () => mockSubscriptionService, + ); + + // Mock validation + mockValidateOrderParams.mockReturnValue({ isValid: true }); + mockValidateWithdrawalParams.mockReturnValue({ isValid: true }); + mockValidateDepositParams.mockReturnValue({ isValid: true }); + mockValidateCoinExists.mockReturnValue({ isValid: true }); + mockValidateAssetSupport.mockReturnValue({ isValid: true }); + mockValidateBalance.mockReturnValue({ isValid: true }); + const hyperLiquidValidation = jest.requireMock( + '../../../src/utils/hyperLiquidValidation', + ); + hyperLiquidValidation.getSupportedPaths.mockReturnValue([ + 'eip155:42161/erc20:0xa0b86a33e6776e681a06e0e1622c5e5e3e6a8b13/default', + 'eip155:1/erc20:0xa0b86a33e6776e681a06e0e1622c5e5e3e6a8b13/default', + ]); + hyperLiquidValidation.getBridgeInfo.mockReturnValue({ + chainId: 'eip155:42161', + contractAddress: '0x1234567890123456789012345678901234567890', + }); + hyperLiquidValidation.createErrorResult.mockImplementation( + (error: unknown, defaultResponse: Record) => ({ + ...defaultResponse, + success: false, + error: error instanceof Error ? error.message : String(error), + }), + ); + const hyperLiquidAdapter = jest.requireMock( + '../../../src/utils/hyperLiquidAdapter', + ); + hyperLiquidAdapter.adaptHyperLiquidLedgerUpdateToUserHistoryItem.mockImplementation( + (updates: unknown[]) => { + if (!updates || !Array.isArray(updates) || updates.length === 0) { + return []; + } + return updates.map(() => ({ + type: 'deposit' as const, + amount: '100', + timestamp: Date.now(), + hash: '0x123', + })); + }, + ); + + provider = createTestProvider({ + initialAssetMapping: [ + ['BTC', 0], + ['ETH', 1], + ], + }); + }); + describe('Builder Fee and Referral Integration', () => { + beforeEach(() => { + // Mock with maxBuilderFee: 0 to trigger approval calls + mockClientService.getInfoClient = jest.fn().mockReturnValue( + createMockInfoClient({ + maxBuilderFee: jest.fn().mockResolvedValue(0), // Not approved yet + }), + ); + + // Mock user address to be different from builder address + mockWalletService.getUserAddressWithDefault.mockResolvedValue( + '0x1234567890123456789012345678901234567890', // Different from builder + ); + + mockClientService.getExchangeClient = jest.fn().mockReturnValue({ + order: jest.fn().mockImplementation((request: { orders: unknown[] }) => + Promise.resolve({ + status: 'ok', + response: { + data: { + statuses: request.orders.map((_order, index) => ({ + resting: { oid: 123 + index }, + })), + }, + }, + }), + ), + modify: jest.fn().mockResolvedValue({ + status: 'ok', + response: { data: { statuses: [{ resting: { oid: '123' } }] } }, + }), + cancel: jest.fn().mockResolvedValue({ + status: 'ok', + response: { data: { statuses: ['success'] } }, + }), + withdraw3: jest.fn().mockResolvedValue({ + status: 'ok', + }), + updateLeverage: jest.fn().mockResolvedValue({ + status: 'ok', + }), + approveBuilderFee: jest.fn().mockResolvedValue({ + status: 'ok', + }), + setReferrer: jest.fn().mockResolvedValue({ + status: 'ok', + }), + }); + }); + + it('includes builder fee and referral setup in order placement', async () => { + // Mock builder fee not approved to trigger approval call + mockClientService.getInfoClient = jest.fn().mockReturnValue({ + maxBuilderFee: jest + .fn() + .mockResolvedValueOnce(0) // First call: not approved + .mockResolvedValueOnce(0.001), // Second call: approved after approval + referral: jest.fn().mockResolvedValue({ + referrerState: { + stage: 'ready', + data: { code: REFERRAL_CONFIG.MainnetCode }, + }, + referredBy: null, // User has no referral set + }), + clearinghouseState: jest.fn().mockResolvedValue({ + marginSummary: { + totalMarginUsed: '500', + accountValue: '10500', + }, + withdrawable: '9500', + assetPositions: [ + { + position: { + coin: 'BTC', + szi: '0.1', + entryPx: '50000', + positionValue: '5000', + unrealizedPnl: '100', + marginUsed: '500', + leverage: { type: 'cross', value: 10 }, + liquidationPx: '45000', + maxLeverage: 50, + returnOnEquity: '20', + cumFunding: { + allTime: '10', + sinceOpen: '5', + sinceChange: '2', + }, + }, + type: 'oneWay', + }, + ], + }), + spotClearinghouseState: jest.fn().mockResolvedValue({ + balances: [{ coin: 'USDC', hold: '1000', total: '10000' }], + }), + userAbstraction: jest.fn().mockResolvedValue('unifiedAccount'), + meta: jest.fn().mockResolvedValue({ + universe: [ + { name: 'BTC', szDecimals: 3, maxLeverage: 50 }, + { name: 'ETH', szDecimals: 4, maxLeverage: 50 }, + ], + }), + metaAndAssetCtxs: jest.fn().mockResolvedValue([ + { + universe: [ + { name: 'BTC', szDecimals: 3, maxLeverage: 50 }, + { name: 'ETH', szDecimals: 4, maxLeverage: 50 }, + ], + }, + [ + { + funding: '0.0001', + openInterest: '1000', + prevDayPx: '49000', + dayNtlVlm: '1000000', + markPx: '50000', + midPx: '50000', + oraclePx: '50000', + }, + { + funding: '0.0001', + openInterest: '500', + prevDayPx: '2900', + dayNtlVlm: '500000', + markPx: '3000', + midPx: '3000', + oraclePx: '3000', + }, + ], + ]), + perpDexs: jest.fn().mockResolvedValue([null]), + allMids: jest.fn().mockResolvedValue({ BTC: '50000', ETH: '3000' }), + frontendOpenOrders: jest.fn().mockResolvedValue([]), + }); + + const orderParams: OrderParams = { + symbol: 'BTC', + isBuy: true, + size: '0.1', + orderType: 'market', + currentPrice: 50000, + }; + + const result = await provider.placeOrder(orderParams); + + expect(result.success).toBe(true); + + // Builder fee approval is set once during ensureReady() initialization + // With session caching, it should be called once (during first ensureReady) + expect( + mockClientService.getExchangeClient().approveBuilderFee, + ).toHaveBeenCalledWith({ + builder: BUILDER_FEE_CONFIG.MainnetBuilder, + maxFeeRate: BUILDER_FEE_CONFIG.MaxFeeRate, + }); + + // Note: Referral setup is fire-and-forget (non-blocking), so we can't reliably + // test it synchronously. It's tested separately in dedicated referral tests. + + // Place a second order to verify caching (should NOT call builder fee approval again) + const mockExchangeClient = mockClientService.getExchangeClient(); + (mockExchangeClient.approveBuilderFee as jest.Mock).mockClear(); + + const result2 = await provider.placeOrder(orderParams); + + expect(result2.success).toBe(true); + // Session cache prevents redundant builder fee approval calls + expect(mockExchangeClient.approveBuilderFee).not.toHaveBeenCalled(); + + // Verify order was placed with builder fee + expect(mockClientService.getExchangeClient().order).toHaveBeenCalledWith( + expect.objectContaining({ + orders: expect.any(Array), + builder: { + b: expect.any(String), + f: expect.any(Number), + }, + }), + ); + }); + + it('routes an approved subscription waiver through the dedicated builder', async () => { + // Builder fee already approved: this test is about the fee value on the + // signed payload, not the approval flow. + mockClientService.getInfoClient = jest.fn().mockReturnValue( + createMockInfoClient({ + maxBuilderFee: jest.fn().mockResolvedValue(0.001), + }), + ); + const orderParams: OrderParams = { + symbol: 'BTC', + isBuy: true, + size: '0.1', + orderType: 'market', + currentPrice: 50000, + }; + const exchangeClient = mockClientService.getExchangeClient(); + + // Control: with no source undercutting it, the default builder fee is charged. + const baseline = await provider.placeOrder(orderParams); + + expect(baseline.success).toBe(true); + expect(exchangeClient.order).toHaveBeenCalledWith( + expect.objectContaining({ + builder: { + b: expect.any(String), + f: BUILDER_FEE_CONFIG.MaxFeeTenthsBps, + }, + }), + ); + + const subscriptionBuilder = '0x2222222222222222222222222222222222222222'; + provider = createTestProvider({ + subscriptionBuilderAddressMainnet: subscriptionBuilder, + }); + await expect(provider.approveSubscriptionBuilderFee()).resolves.toBe( + true, + ); + + (exchangeClient.order as jest.Mock).mockClear(); + provider.setUserFeeResolution({ + feeBips: 0, + discountBips: 10000, + source: 'subscription', + subscription: { eligible: true, reason: 'eligible' }, + }); + + const waived = await provider.placeOrder(orderParams); + + expect(waived.success).toBe(true); + expect(exchangeClient.order).toHaveBeenCalledWith( + expect.objectContaining({ + builder: { b: subscriptionBuilder, f: 0 }, + }), + ); + }); + + it('initializes clients before approving the subscription builder', async () => { + const subscriptionBuilder = '0x2222222222222222222222222222222222222222'; + mockClientService.getInfoClient = jest.fn().mockReturnValue( + createMockInfoClient({ + maxBuilderFee: jest.fn().mockResolvedValue(0.001), + }), + ); + provider = createTestProvider({ + subscriptionBuilderAddressMainnet: subscriptionBuilder, + }); + + await expect(provider.approveSubscriptionBuilderFee()).resolves.toBe( + true, + ); + + expect(mockClientService.initialize).toHaveBeenCalledTimes(1); + expect(mockClientService.getInfoClient).toHaveBeenCalled(); + }); + + it('does not reuse subscription builder approval after an account switch', async () => { + const accountA = '0x1234567890123456789012345678901234567890'; + const accountB = '0xabcdefabcdefabcdefabcdefabcdefabcdefabcd'; + const subscriptionBuilder = '0x2222222222222222222222222222222222222222'; + const exchangeClient = mockClientService.getExchangeClient(); + mockClientService.getInfoClient = jest.fn().mockReturnValue( + createMockInfoClient({ + maxBuilderFee: jest.fn().mockResolvedValue(0.001), + }), + ); + mockWalletService.getUserAddressWithDefault.mockResolvedValue(accountA); + provider = createTestProvider({ + subscriptionBuilderAddressMainnet: subscriptionBuilder, + }); + + await expect(provider.approveSubscriptionBuilderFee()).resolves.toBe( + true, + ); + + mockWalletService.getUserAddressWithDefault.mockResolvedValue(accountB); + (exchangeClient.order as jest.Mock).mockClear(); + provider.setUserFeeResolution({ + feeBips: 0, + discountBips: 10000, + source: 'subscription', + subscription: { eligible: true, reason: 'eligible' }, + }); + + const result = await provider.placeOrder({ + symbol: 'BTC', + isBuy: true, + size: '0.1', + orderType: 'market', + currentPrice: 50000, + }); + + expect(result.success).toBe(true); + expect(exchangeClient.order).toHaveBeenCalledWith( + expect.objectContaining({ + builder: { + b: BUILDER_FEE_CONFIG.MainnetBuilder, + f: BUILDER_FEE_CONFIG.MaxFeeTenthsBps, + }, + }), + ); + }); + + it('keeps subscription approval reads scoped to the initiating account', async () => { + const accountA = '0x1234567890123456789012345678901234567890'; + const accountB = '0xabcdefabcdefabcdefabcdefabcdefabcdefabcd'; + const subscriptionBuilder = '0x2222222222222222222222222222222222222222'; + let releaseInitialRead: (value: number) => void = () => undefined; + const initialRead = new Promise((resolve) => { + releaseInitialRead = resolve; + }); + let markInitialReadStarted: () => void = () => undefined; + const initialReadStarted = new Promise((resolve) => { + markInitialReadStarted = resolve; + }); + const maxBuilderFee = jest + .fn() + .mockImplementationOnce(() => { + markInitialReadStarted(); + return initialRead; + }) + .mockResolvedValueOnce(0.001); + mockClientService.getInfoClient = jest + .fn() + .mockReturnValue(createMockInfoClient({ maxBuilderFee })); + mockWalletService.getUserAddressWithDefault.mockResolvedValue(accountA); + provider = createTestProvider({ + subscriptionBuilderAddressMainnet: subscriptionBuilder, + }); + + const approval = provider.approveSubscriptionBuilderFee(); + await initialReadStarted; + mockWalletService.getUserAddressWithDefault.mockResolvedValue(accountB); + releaseInitialRead(0); + + await expect(approval).resolves.toBe(true); + expect(maxBuilderFee).toHaveBeenNthCalledWith(1, { + user: accountA, + builder: subscriptionBuilder, + }); + expect(maxBuilderFee).toHaveBeenNthCalledWith(2, { + user: accountA, + builder: subscriptionBuilder, + }); + }); + + it('fences subscription approval across disconnect and preserves reconnect dedupe', async () => { + const subscriptionBuilder = '0x2222222222222222222222222222222222222222'; + let releaseOldRead: (value: number) => void = () => undefined; + const oldRead = new Promise((resolve) => { + releaseOldRead = resolve; + }); + let markOldReadStarted: () => void = () => undefined; + const oldReadStarted = new Promise((resolve) => { + markOldReadStarted = resolve; + }); + let releaseNewRead: (value: number) => void = () => undefined; + const newRead = new Promise((resolve) => { + releaseNewRead = resolve; + }); + let markNewReadStarted: () => void = () => undefined; + const newReadStarted = new Promise((resolve) => { + markNewReadStarted = resolve; + }); + const maxBuilderFee = jest + .fn() + .mockImplementationOnce(() => { + markOldReadStarted(); + return oldRead; + }) + .mockImplementationOnce(() => { + markNewReadStarted(); + return newRead; + }) + .mockResolvedValue(0.001); + mockClientService.getInfoClient = jest + .fn() + .mockReturnValue(createMockInfoClient({ maxBuilderFee })); + provider = createTestProvider({ + subscriptionBuilderAddressMainnet: subscriptionBuilder, + }); + + const oldApproval = provider.approveSubscriptionBuilderFee(); + await oldReadStarted; + await provider.disconnect(); + + const newApproval = provider.approveSubscriptionBuilderFee(); + await newReadStarted; + releaseOldRead(0); + + await expect(oldApproval).resolves.toBe(false); + expect( + mockClientService.getExchangeClient().approveBuilderFee, + ).not.toHaveBeenCalled(); + expect(maxBuilderFee).toHaveBeenCalledTimes(2); + + const dedupedApproval = provider.approveSubscriptionBuilderFee(); + await Promise.resolve(); + expect(maxBuilderFee).toHaveBeenCalledTimes(2); + + releaseNewRead(0.001); + await expect( + Promise.all([newApproval, dedupedApproval]), + ).resolves.toStrictEqual([true, true]); + }); + + it('falls back to the standard fee when the subscription builder is not approved', async () => { + const subscriptionBuilder = '0x2222222222222222222222222222222222222222'; + const defaultBuilder = BUILDER_FEE_CONFIG.MainnetBuilder; + const exchangeClient = mockClientService.getExchangeClient(); + const maxBuilderFee = jest.fn().mockResolvedValue(0.001); + mockClientService.getInfoClient = jest.fn().mockReturnValue( + createMockInfoClient({ + maxBuilderFee, + }), + ); + provider = createTestProvider({ + subscriptionBuilderAddressMainnet: subscriptionBuilder, + }); + provider.setUserFeeResolution({ + feeBips: 0, + discountBips: 10000, + source: 'subscription', + subscription: { eligible: true, reason: 'eligible' }, + }); + + const result = await provider.placeOrder({ + symbol: 'BTC', + isBuy: true, + size: '0.1', + orderType: 'market', + currentPrice: 50000, + }); + + expect(result.success).toBe(true); + expect(exchangeClient.order).toHaveBeenCalledWith( + expect.objectContaining({ + builder: { + b: defaultBuilder, + f: BUILDER_FEE_CONFIG.MaxFeeTenthsBps, + }, + }), + ); + expect(maxBuilderFee).not.toHaveBeenCalledWith( + expect.objectContaining({ builder: subscriptionBuilder }), + ); + expect(exchangeClient.approveBuilderFee).not.toHaveBeenCalled(); + }); + + it('includes builder fee and referral setup in TP/SL updates', async () => { + // Mock builder fee not approved to trigger approval call + mockClientService.getInfoClient = jest.fn().mockReturnValue({ + maxBuilderFee: jest + .fn() + .mockResolvedValueOnce(0) // First call: not approved + .mockResolvedValueOnce(0.001), // Second call: approved after approval + referral: jest.fn().mockResolvedValue({ + referrerState: { + stage: 'ready', + data: { code: REFERRAL_CONFIG.MainnetCode }, + }, + referredBy: null, // User has no referral set + }), + clearinghouseState: jest.fn().mockResolvedValue({ + marginSummary: { + totalMarginUsed: '500', + accountValue: '10500', + }, + withdrawable: '9500', + assetPositions: [ + { + position: { + coin: 'BTC', + szi: '0.1', + entryPx: '50000', + positionValue: '5000', + unrealizedPnl: '100', + marginUsed: '500', + leverage: { type: 'cross', value: 10 }, + liquidationPx: '45000', + maxLeverage: 50, + returnOnEquity: '20', + cumFunding: { + allTime: '10', + sinceOpen: '5', + sinceChange: '2', + }, + }, + type: 'oneWay', + }, + ], + }), + spotClearinghouseState: jest.fn().mockResolvedValue({ + balances: [{ coin: 'USDC', hold: '1000', total: '10000' }], + }), + userAbstraction: jest.fn().mockResolvedValue('unifiedAccount'), + meta: jest.fn().mockResolvedValue({ + universe: [ + { name: 'BTC', szDecimals: 3, maxLeverage: 50 }, + { name: 'ETH', szDecimals: 4, maxLeverage: 50 }, + ], + }), + metaAndAssetCtxs: jest.fn().mockResolvedValue([ + { + universe: [ + { name: 'BTC', szDecimals: 3, maxLeverage: 50 }, + { name: 'ETH', szDecimals: 4, maxLeverage: 50 }, + ], + }, + [ + { + funding: '0.0001', + openInterest: '1000', + prevDayPx: '49000', + dayNtlVlm: '1000000', + markPx: '50000', + midPx: '50000', + oraclePx: '50000', + }, + { + funding: '0.0001', + openInterest: '500', + prevDayPx: '2900', + dayNtlVlm: '500000', + markPx: '3000', + midPx: '3000', + oraclePx: '3000', + }, + ], + ]), + perpDexs: jest.fn().mockResolvedValue([null]), + allMids: jest.fn().mockResolvedValue({ BTC: '50000', ETH: '3000' }), + frontendOpenOrders: jest.fn().mockResolvedValue([]), + }); + + const updateParams = { + symbol: 'BTC', + takeProfitPrice: '55000', + stopLossPrice: '45000', + }; + + const result = await provider.updatePositionTPSL(updateParams); + + // Verify builder fee approval was called + expect( + mockClientService.getExchangeClient().approveBuilderFee, + ).toHaveBeenCalledWith({ + builder: expect.any(String), + maxFeeRate: expect.stringContaining('%'), + }); + + // Verify referral code was set + expect( + mockClientService.getExchangeClient().setReferrer, + ).toHaveBeenCalledWith({ + code: expect.any(String), + }); + + // Verify order was placed with builder fee + expect(mockClientService.getExchangeClient().order).toHaveBeenCalledWith( + expect.objectContaining({ + orders: expect.any(Array), + grouping: 'positionTpsl', + builder: { + b: BUILDER_FEE_CONFIG.MainnetBuilder, + f: BUILDER_FEE_CONFIG.MaxFeeTenthsBps, + }, + }), + ); + expect(result.success).toBe(true); + }); + + it('uses HTTP for builder fee reads during a cold-start TP/SL update', async () => { + const webSocketMaxBuilderFee = jest + .fn() + .mockRejectedValue( + Object.assign( + new Error( + 'WebSocket connection closed before the request was sent', + ), + { name: 'WebSocketRequestError' }, + ), + ); + const httpMaxBuilderFee = jest + .fn() + .mockResolvedValue(BUILDER_FEE_CONFIG.MaxFeeDecimal); + const webSocketInfoClient = createMockInfoClient({ + maxBuilderFee: webSocketMaxBuilderFee, + }); + const httpInfoClient = createMockInfoClient({ + maxBuilderFee: httpMaxBuilderFee, + }); + mockClientService.getInfoClient.mockImplementation((options) => + options?.useHttp ? httpInfoClient : webSocketInfoClient, + ); + + const result = await provider.updatePositionTPSL({ + symbol: 'BTC', + takeProfitPrice: '55000', + stopLossPrice: '45000', + }); + + expect(result.success).toBe(true); + expect(mockClientService.getInfoClient).toHaveBeenCalledWith({ + useHttp: true, + }); + expect(httpMaxBuilderFee).toHaveBeenCalledWith({ + user: '0x1234567890123456789012345678901234567890', + builder: BUILDER_FEE_CONFIG.MainnetBuilder, + }); + expect(webSocketMaxBuilderFee).not.toHaveBeenCalled(); + expect(mockClientService.getExchangeClient().order).toHaveBeenCalledWith( + expect.objectContaining({ grouping: 'positionTpsl' }), + ); + }); + + it('uses a builder approval completed while acquiring the global lock', async () => { + const mockedCache = PerpsSigningCache as jest.Mocked< + typeof PerpsSigningCache + >; + mockedCache.getBuilderFee + .mockReturnValueOnce(undefined) + .mockReturnValueOnce({ attempted: true, success: true }); + + const result = await provider.updatePositionTPSL({ + symbol: 'BTC', + takeProfitPrice: '55000', + stopLossPrice: '45000', + }); + + expect(result.success).toBe(true); + expect(mockedCache.getBuilderFee).toHaveBeenCalledTimes(2); + expect(mockClientService.getExchangeClient().order).toHaveBeenCalledWith( + expect.objectContaining({ + builder: { + b: BUILDER_FEE_CONFIG.MainnetBuilder, + f: BUILDER_FEE_CONFIG.MaxFeeTenthsBps, + }, + }), + ); + expect( + mockClientService.getExchangeClient().approveBuilderFee, + ).not.toHaveBeenCalled(); + }); + + it('skips referral setup when user is the builder', async () => { + // Mock user address to be the same as builder address + mockWalletService.getUserAddressWithDefault.mockResolvedValue( + '0xe95a5e31904e005066614247d309e00d8ad753aa', // Builder address + ); + + // When user IS the builder, maxBuilderFee should already be approved + mockClientService.getInfoClient = jest.fn().mockReturnValue( + createMockInfoClient({ + maxBuilderFee: jest.fn().mockResolvedValue(1), // Already approved + }), + ); + + const orderParams: OrderParams = { + symbol: 'BTC', + isBuy: true, + size: '0.1', + orderType: 'market', + currentPrice: 50000, + }; + + const result = await provider.placeOrder(orderParams); + + expect(result.success).toBe(true); + + // Should not call setReferrer when user is the builder + expect( + mockClientService.getExchangeClient().setReferrer, + ).not.toHaveBeenCalled(); + }); + + it('handles builder fee approval failure (non-blocking)', async () => { + // Mock builder fee not approved to trigger approval call + mockClientService.getInfoClient = jest.fn().mockReturnValue( + createMockInfoClient({ + maxBuilderFee: jest.fn().mockResolvedValue(0), // Not approved - triggers approval + }), + ); + + // Mock builder fee approval to fail + mockClientService.getExchangeClient = jest.fn().mockReturnValue( + createMockExchangeClient({ + approveBuilderFee: jest + .fn() + .mockRejectedValue(new Error('Builder fee approval failed')), + }), + ); + + const orderParams: OrderParams = { + symbol: 'BTC', + isBuy: true, + size: '0.1', + orderType: 'market', + currentPrice: 50000, + }; + + const result = await provider.placeOrder(orderParams); + + // PR #25334: Builder fee approval is now non-blocking (fire-and-forget) + // to prevent repeated signing prompts for hardware wallets. + // Order should proceed even if builder fee approval fails. + expect(result.success).toBe(true); + expect(result.orderId).toBeDefined(); + }); + + it('retries builder fee approval after a previous attempt failed', async () => { + const mockedCache = PerpsSigningCache as jest.Mocked< + typeof PerpsSigningCache + >; + + // First order: builder fee approval fails + mockClientService.getInfoClient = jest.fn().mockReturnValue( + createMockInfoClient({ + maxBuilderFee: jest + .fn() + .mockResolvedValueOnce(0) // first order: check — not approved + .mockResolvedValueOnce(0) // second order: check — cached failure prevents retry + .mockResolvedValueOnce(0.001), // unused if cached failure is respected + }), + ); + + const mockApproveBuilderFee = jest + .fn() + .mockRejectedValueOnce(new Error('Network timeout')) + .mockResolvedValueOnce({ status: 'ok' }); + + mockClientService.getExchangeClient = jest.fn().mockReturnValue( + createMockExchangeClient({ + approveBuilderFee: mockApproveBuilderFee, + }), + ); + + const orderParams: OrderParams = { + symbol: 'BTC', + isBuy: true, + size: '0.1', + orderType: 'market', + currentPrice: 50000, + }; + + // First order — builder fee fails but order proceeds (non-blocking) + const result1 = await provider.placeOrder(orderParams); + expect(result1.success).toBe(true); + + // Simulate cached failure state — getBuilderFee returns { attempted: true, success: false } + mockedCache.getBuilderFee.mockReturnValue({ + attempted: true, + success: false, + }); + + // Second order — cached failure does NOT skip approval; retry so the + // builder fee eventually lands (mobile fix #30095). + const result2 = await provider.placeOrder(orderParams); + expect(result2.success).toBe(true); + + // approveBuilderFee called twice: cached failure retries instead of + // silently leaving the builder fee unapproved. + expect(mockApproveBuilderFee).toHaveBeenCalledTimes(2); + }); + + it('skips builder fee retry when previous attempt succeeded', async () => { + const mockedCache = PerpsSigningCache as jest.Mocked< + typeof PerpsSigningCache + >; + + // Simulate successful cache + mockedCache.getBuilderFee.mockReturnValue({ + attempted: true, + success: true, + }); + + mockClientService.getInfoClient = jest + .fn() + .mockReturnValue(createMockInfoClient()); + + const mockApproveBuilderFee = jest.fn(); + mockClientService.getExchangeClient = jest.fn().mockReturnValue( + createMockExchangeClient({ + approveBuilderFee: mockApproveBuilderFee, + }), + ); + + const orderParams: OrderParams = { + symbol: 'BTC', + isBuy: true, + size: '0.1', + orderType: 'market', + currentPrice: 50000, + }; + + const result = await provider.placeOrder(orderParams); + expect(result.success).toBe(true); + + // Should not retry — cache shows success + expect(mockApproveBuilderFee).not.toHaveBeenCalled(); + }); + + it('leaves builder fee cache empty when wrapped KEYRING_LOCKED is thrown', async () => { + const wrappedKeyringLockedError = Object.assign( + new Error('Failed to sign typed data with viem wallet'), + { cause: new Error('KEYRING_LOCKED') }, + ); + const mockCompleteInFlight = jest.fn(); + ( + PerpsSigningCache as jest.Mocked + ).setInFlight.mockReturnValue(mockCompleteInFlight); + mockClientService.getInfoClient = jest.fn().mockReturnValue( + createMockInfoClient({ + maxBuilderFee: jest.fn().mockResolvedValue(0), + referral: jest.fn().mockResolvedValue({ + referrerState: { + stage: 'not_ready', + data: null, + }, + }), + }), + ); + mockClientService.getExchangeClient = jest.fn().mockReturnValue( + createMockExchangeClient({ + approveBuilderFee: jest + .fn() + .mockRejectedValue(wrappedKeyringLockedError), + }), + ); + + const orderParams: OrderParams = { + symbol: 'BTC', + isBuy: true, + size: '0.1', + orderType: 'market', + currentPrice: 50000, + }; + + const result = await provider.placeOrder(orderParams); + + expect(result.success).toBe(true); + expect( + (PerpsSigningCache as jest.Mocked) + .setBuilderFee, + ).not.toHaveBeenCalled(); + expect(mockCompleteInFlight).toHaveBeenCalled(); + }); + + it('handles referral code setup failure (non-blocking)', async () => { + // Mock builder fee already approved + mockClientService.getInfoClient = jest + .fn() + .mockReturnValue(createMockInfoClient()); + + // Mock referral code setup to fail + mockClientService.getExchangeClient = jest.fn().mockReturnValue({ + approveBuilderFee: jest.fn().mockResolvedValue({ + status: 'ok', + }), + setReferrer: jest + .fn() + .mockRejectedValue(new Error('Referral code setup failed')), + order: jest.fn().mockResolvedValue({ + status: 'ok', + response: { data: { statuses: [{ resting: { oid: '123' } }] } }, + }), + updateLeverage: jest.fn().mockResolvedValue({ + status: 'ok', + }), + }); + + const orderParams: OrderParams = { + symbol: 'BTC', + isBuy: true, + size: '0.1', + orderType: 'market', + currentPrice: 50000, + }; + + const result = await provider.placeOrder(orderParams); + + // Referral setup is now non-blocking (fire-and-forget), so order should succeed + expect(result.success).toBe(true); + expect(result.orderId).toBeDefined(); + }); + + it('leaves referral cache empty when wrapped KEYRING_LOCKED is thrown', async () => { + const wrappedKeyringLockedError = Object.assign( + new Error('Failed to sign typed data with viem wallet'), + { cause: new Error('KEYRING_LOCKED') }, + ); + const mockCompleteInFlight = jest.fn(); + ( + PerpsSigningCache as jest.Mocked + ).setInFlight.mockReturnValue(mockCompleteInFlight); + mockClientService.getInfoClient = jest.fn().mockReturnValue( + createMockInfoClient({ + maxBuilderFee: jest.fn().mockResolvedValue(1), + referral: jest.fn().mockResolvedValue({ + referrerState: { + stage: 'ready', + data: { code: REFERRAL_CONFIG.MainnetCode }, + }, + referredBy: null, + }), + }), + ); + mockClientService.getExchangeClient = jest.fn().mockReturnValue( + createMockExchangeClient({ + setReferrer: jest.fn().mockRejectedValue(wrappedKeyringLockedError), + }), + ); + + const orderParams: OrderParams = { + symbol: 'BTC', + isBuy: true, + size: '0.1', + orderType: 'market', + currentPrice: 50000, + }; + + const result = await provider.placeOrder(orderParams); + await Promise.resolve(); + await Promise.resolve(); + + expect(result.success).toBe(true); + expect( + (PerpsSigningCache as jest.Mocked) + .setReferral, + ).not.toHaveBeenCalled(); + expect(mockCompleteInFlight).toHaveBeenCalled(); + }); + + it('skips referral setup when referral code is not ready', async () => { + // Mock referral code not ready + mockClientService.getInfoClient = jest.fn().mockReturnValue( + createMockInfoClient({ + referral: jest.fn().mockResolvedValue({ + referrerState: { + stage: 'not_ready', // Not ready + data: null, + }, + }), + }), + ); + + const orderParams: OrderParams = { + symbol: 'BTC', + isBuy: true, + size: '0.1', + orderType: 'market', + currentPrice: 50000, + }; + + const result = await provider.placeOrder(orderParams); + + expect(result.success).toBe(true); + + // Should not call setReferrer when referral code is not ready + expect( + mockClientService.getExchangeClient().setReferrer, + ).not.toHaveBeenCalled(); + }); + + it('skips referral setup when user already has a referral', async () => { + // Mock user already has a referral by setting referredBy.code + mockClientService.getInfoClient = jest.fn().mockReturnValue( + createMockInfoClient({ + referral: jest.fn().mockResolvedValue({ + referrerState: { + stage: 'ready', + data: { code: 'MMCSI' }, + }, + referredBy: { + code: 'EXISTING_REFERRAL', + }, + }), + }), + ); + + const orderParams: OrderParams = { + symbol: 'BTC', + isBuy: true, + size: '0.1', + orderType: 'market', + currentPrice: 50000, + }; + + const result = await provider.placeOrder(orderParams); + + expect(result.success).toBe(true); + + // Should not call setReferrer when user already has a referral + expect( + mockClientService.getExchangeClient().setReferrer, + ).not.toHaveBeenCalled(); + }); + + it('uses testnet builder address when in testnet mode', async () => { + // Arrange — flip to testnet mode + mockClientService.isTestnetMode.mockReturnValue(true); + + mockClientService.getInfoClient = jest.fn().mockReturnValue( + createMockInfoClient({ + maxBuilderFee: jest.fn().mockResolvedValue(1), // Already approved + }), + ); + + const orderParams: OrderParams = { + symbol: 'BTC', + isBuy: true, + size: '0.1', + orderType: 'market', + currentPrice: 50000, + }; + + // Act + const result = await provider.placeOrder(orderParams); + + // Assert — order placed with the testnet builder address + expect(result.success).toBe(true); + expect(mockClientService.getExchangeClient().order).toHaveBeenCalledWith( + expect.objectContaining({ + builder: { + b: BUILDER_FEE_CONFIG.TestnetBuilder, + f: expect.any(Number), + }, + }), + ); + }); + }); + + // TODO: Refactor to test through public API — ES # private fields prevent direct access + describe.skip('Builder Fee Global Cache (PR #25334)', () => { + interface ProviderWithBuilderFee { + ensureBuilderFeeApproval(): Promise; + } + + let testableProvider: ProviderWithBuilderFee; + + beforeEach(() => { + testableProvider = provider as unknown as ProviderWithBuilderFee; + mockWalletService.getUserAddressWithDefault = jest + .fn() + .mockResolvedValue('0x1234567890123456789012345678901234567890'); + }); + + it('returns early when global cache indicates already attempted', async () => { + // Arrange - simulate cached state + ( + PerpsSigningCache as jest.Mocked + ).getBuilderFee.mockReturnValue({ + attempted: true, + success: true, + }); + + // Act + await testableProvider.ensureBuilderFeeApproval(); + + // Assert - should not call API when cached + expect(mockClientService.getInfoClient).not.toHaveBeenCalled(); + }); + + it('waits for in-flight operation instead of duplicating request', async () => { + // Arrange - ensure getBuilderFee returns undefined (not cached) + ( + PerpsSigningCache as jest.Mocked + ).getBuilderFee.mockReturnValue(undefined); + + // Simulate in-flight operation from another provider + let resolveInFlight: () => void = () => undefined; + const inFlightPromise = new Promise((resolve) => { + resolveInFlight = resolve; + }); + ( + PerpsSigningCache as jest.Mocked + ).isInFlight.mockReturnValue(inFlightPromise); + + // Act + const approvalPromise = testableProvider.ensureBuilderFeeApproval(); + + // Resolve the in-flight operation + resolveInFlight(); + await approvalPromise; + + // Verify it called isInFlight to check for concurrent operations + expect( + (PerpsSigningCache as jest.Mocked).isInFlight, + ).toHaveBeenCalledWith( + 'builderFee', + 'mainnet', + '0x1234567890123456789012345678901234567890', + ); + + // Assert - should not have set its own in-flight lock + expect( + (PerpsSigningCache as jest.Mocked) + .setInFlight, + ).not.toHaveBeenCalled(); + }); + + it('caches success after successful approval', async () => { + // Arrange + const mockCompleteInFlight = jest.fn(); + ( + PerpsSigningCache as jest.Mocked + ).setInFlight.mockReturnValue(mockCompleteInFlight); + mockClientService.getInfoClient = jest.fn().mockReturnValue( + createMockInfoClient({ + maxBuilderFee: jest + .fn() + .mockResolvedValueOnce(0) // First call: not approved + .mockResolvedValueOnce(0.001), // Second call: approved after approval + }), + ); + + // Act + await testableProvider.ensureBuilderFeeApproval(); + + // Assert + expect( + (PerpsSigningCache as jest.Mocked) + .setBuilderFee, + ).toHaveBeenCalledWith( + 'mainnet', + '0x1234567890123456789012345678901234567890', + { attempted: true, success: true }, + ); + expect(mockCompleteInFlight).toHaveBeenCalled(); + }); + + it('caches failure to prevent repeated signing requests', async () => { + // Arrange + const mockCompleteInFlight = jest.fn(); + ( + PerpsSigningCache as jest.Mocked + ).setInFlight.mockReturnValue(mockCompleteInFlight); + mockClientService.getInfoClient = jest.fn().mockReturnValue( + createMockInfoClient({ + maxBuilderFee: jest.fn().mockResolvedValue(0), + }), + ); + mockClientService.getExchangeClient = jest.fn().mockReturnValue( + createMockExchangeClient({ + approveBuilderFee: jest + .fn() + .mockRejectedValue(new Error('User rejected')), + }), + ); + + // Act & Assert + await expect(testableProvider.ensureBuilderFeeApproval()).rejects.toThrow( + 'User rejected', + ); + + // Assert - failure should be cached + expect( + (PerpsSigningCache as jest.Mocked) + .setBuilderFee, + ).toHaveBeenCalledWith( + 'mainnet', + '0x1234567890123456789012345678901234567890', + { attempted: true, success: false }, + ); + expect(mockCompleteInFlight).toHaveBeenCalled(); + }); + + it('skips cache when KEYRING_LOCKED error is thrown', async () => { + // Arrange + const mockCompleteInFlight = jest.fn(); + ( + PerpsSigningCache as jest.Mocked + ).setInFlight.mockReturnValue(mockCompleteInFlight); + mockClientService.getInfoClient = jest.fn().mockReturnValue( + createMockInfoClient({ + maxBuilderFee: jest.fn().mockResolvedValue(0), + }), + ); + mockClientService.getExchangeClient = jest.fn().mockReturnValue( + createMockExchangeClient({ + approveBuilderFee: jest + .fn() + .mockRejectedValue(new Error('KEYRING_LOCKED')), + }), + ); + + // Act - should resolve without throwing + await testableProvider.ensureBuilderFeeApproval(); + + // Assert - cache should NOT be set (so it retries when unlocked) + expect( + (PerpsSigningCache as jest.Mocked) + .setBuilderFee, + ).not.toHaveBeenCalled(); + // Assert - in-flight lock should be released + expect(mockCompleteInFlight).toHaveBeenCalled(); + }); + }); + + // TODO: Refactor to test through public API — ES # private fields prevent direct access + describe.skip('Referral Global Cache (PR #25334)', () => { + interface ProviderWithReferral { + ensureReferralSet(): Promise; + } + + let testableProvider: ProviderWithReferral; + + beforeEach(() => { + testableProvider = provider as unknown as ProviderWithReferral; + mockWalletService.getUserAddressWithDefault = jest + .fn() + .mockResolvedValue('0x1234567890123456789012345678901234567890'); + }); + + it('returns early when global cache indicates already attempted', async () => { + // Arrange - simulate cached state + ( + PerpsSigningCache as jest.Mocked + ).getReferral.mockReturnValue({ + attempted: true, + success: true, + }); + + // Act + await testableProvider.ensureReferralSet(); + + // Assert - should not call API when cached + expect(mockClientService.getInfoClient).not.toHaveBeenCalled(); + }); + + it('waits for in-flight operation instead of duplicating request', async () => { + // Arrange - ensure getReferral returns undefined (not cached) + ( + PerpsSigningCache as jest.Mocked + ).getReferral.mockReturnValue(undefined); + + // Simulate in-flight operation from another provider + let resolveInFlight: () => void = () => undefined; + const inFlightPromise = new Promise((resolve) => { + resolveInFlight = resolve; + }); + ( + PerpsSigningCache as jest.Mocked + ).isInFlight.mockReturnValue(inFlightPromise); + + // Act + const referralPromise = testableProvider.ensureReferralSet(); + + // Resolve the in-flight operation + resolveInFlight(); + await referralPromise; + + // Verify it called isInFlight to check for concurrent operations + expect( + (PerpsSigningCache as jest.Mocked).isInFlight, + ).toHaveBeenCalledWith( + 'referral', + 'mainnet', + '0x1234567890123456789012345678901234567890', + ); + + // Assert - should not have set its own in-flight lock + expect( + (PerpsSigningCache as jest.Mocked) + .setInFlight, + ).not.toHaveBeenCalled(); + }); + + it('caches success after successful referral setup', async () => { + // Arrange + const mockCompleteInFlight = jest.fn(); + ( + PerpsSigningCache as jest.Mocked + ).setInFlight.mockReturnValue(mockCompleteInFlight); + mockClientService.getInfoClient = jest.fn().mockReturnValue( + createMockInfoClient({ + referral: jest.fn().mockResolvedValue({ + referrerState: { + stage: 'ready', + data: { code: 'MMCSI' }, + }, + referredBy: null, + }), + }), + ); + + // Act + await testableProvider.ensureReferralSet(); + + // Assert + expect( + (PerpsSigningCache as jest.Mocked) + .setReferral, + ).toHaveBeenCalledWith( + 'mainnet', + '0x1234567890123456789012345678901234567890', + { attempted: true, success: true }, + ); + expect(mockCompleteInFlight).toHaveBeenCalled(); + }); + + it('caches failure to prevent repeated signing requests', async () => { + // Arrange + const mockCompleteInFlight = jest.fn(); + ( + PerpsSigningCache as jest.Mocked + ).setInFlight.mockReturnValue(mockCompleteInFlight); + mockClientService.getInfoClient = jest.fn().mockReturnValue( + createMockInfoClient({ + referral: jest.fn().mockResolvedValue({ + referrerState: { + stage: 'ready', + data: { code: 'MMCSI' }, + }, + referredBy: null, + }), + }), + ); + mockClientService.getExchangeClient = jest.fn().mockReturnValue( + createMockExchangeClient({ + setReferrer: jest.fn().mockRejectedValue(new Error('User rejected')), + }), + ); + + // Act - should not throw (referral is non-blocking) + await testableProvider.ensureReferralSet(); + + // Assert - failure should be cached + expect( + (PerpsSigningCache as jest.Mocked) + .setReferral, + ).toHaveBeenCalledWith( + 'mainnet', + '0x1234567890123456789012345678901234567890', + { attempted: true, success: false }, + ); + expect(mockCompleteInFlight).toHaveBeenCalled(); + }); + + it('caches success when user already has referral on-chain', async () => { + // Arrange + const mockCompleteInFlight = jest.fn(); + ( + PerpsSigningCache as jest.Mocked + ).setInFlight.mockReturnValue(mockCompleteInFlight); + mockClientService.getInfoClient = jest.fn().mockReturnValue( + createMockInfoClient({ + referral: jest.fn().mockResolvedValue({ + referrerState: { + stage: 'ready', + data: { code: 'MMCSI' }, + }, + referredBy: { code: 'EXISTING' }, // Already has referral + }), + }), + ); + + // Act + await testableProvider.ensureReferralSet(); + + // Assert - should cache success without calling setReferrer + expect( + (PerpsSigningCache as jest.Mocked) + .setReferral, + ).toHaveBeenCalledWith( + 'mainnet', + '0x1234567890123456789012345678901234567890', + { attempted: true, success: true }, + ); + expect( + mockClientService.getExchangeClient().setReferrer, + ).not.toHaveBeenCalled(); + }); + + it('skips cache and Sentry when KEYRING_LOCKED error is thrown', async () => { + // Arrange + const mockCompleteInFlight = jest.fn(); + ( + PerpsSigningCache as jest.Mocked + ).setInFlight.mockReturnValue(mockCompleteInFlight); + mockClientService.getInfoClient = jest.fn().mockReturnValue( + createMockInfoClient({ + referral: jest.fn().mockResolvedValue({ + referrerState: { + stage: 'ready', + data: { code: 'MMCSI' }, + }, + referredBy: null, + }), + }), + ); + mockClientService.getExchangeClient = jest.fn().mockReturnValue( + createMockExchangeClient({ + setReferrer: jest.fn().mockRejectedValue(new Error('KEYRING_LOCKED')), + }), + ); + + // Act - should resolve without throwing + await testableProvider.ensureReferralSet(); + + // Assert - cache should NOT be set (so it retries when unlocked) + expect( + (PerpsSigningCache as jest.Mocked) + .setReferral, + ).not.toHaveBeenCalled(); + // Assert - ensureReferralSet's catch does NOT call logger.error for KEYRING_LOCKED. + // Note: setReferralCode() internally logs to Sentry before rethrowing, so + // logger.error is called once (from setReferralCode), but NOT a second time + // from ensureReferralSet's catch block (which is the behavior under test). + expect(mockPlatformDependencies.logger.error).toHaveBeenCalledTimes(1); + expect(mockPlatformDependencies.logger.error).toHaveBeenCalledWith( + expect.any(Error), + expect.objectContaining({ + context: expect.objectContaining({ + data: expect.objectContaining({ method: 'setReferralCode' }), + }), + }), + ); + // Assert - in-flight lock should be released + expect(mockCompleteInFlight).toHaveBeenCalled(); + }); + }); +}); diff --git a/packages/perps-controller/tests/src/providers/HyperLiquidProvider.data.test.ts b/packages/perps-controller/tests/src/providers/HyperLiquidProvider.data.test.ts new file mode 100644 index 00000000000..26f36f3cf16 --- /dev/null +++ b/packages/perps-controller/tests/src/providers/HyperLiquidProvider.data.test.ts @@ -0,0 +1,1459 @@ +/* eslint-disable */ +jest.mock('@nktkas/hyperliquid', () => ({})); + +import type { CaipAssetId, Hex } from '@metamask/utils'; + +import { CandlePeriod } from '../../../src/constants/chartConfig.js'; +import { + BUILDER_FEE_CONFIG, + REFERRAL_CONFIG, +} from '../../../src/constants/hyperLiquidConfig.js'; +import { PERPS_TRANSACTIONS_HISTORY_CONSTANTS } from '../../../src/constants/transactionsHistoryConfig.js'; +import { PERPS_ERROR_CODES } from '../../../src/perpsErrorCodes.js'; +import { HyperLiquidProvider } from '../../../src/providers/HyperLiquidProvider.js'; +import { HyperLiquidClientService } from '../../../src/services/HyperLiquidClientService.js'; +import { HyperLiquidSubscriptionService } from '../../../src/services/HyperLiquidSubscriptionService.js'; +import { HyperLiquidWalletService } from '../../../src/services/HyperLiquidWalletService.js'; +import { TradingReadinessCache } from '../../../src/services/TradingReadinessCache.js'; +import type { + ClosePositionParams, + DepositParams, + Order, + PerpsPlatformDependencies, + LiveDataConfig, + OrderParams, +} from '../../../src/types/index.js'; +import { + validateAssetSupport, + validateBalance, + validateCoinExists, + validateDepositParams, + validateOrderParams, + validateWithdrawalParams, +} from '../../../src/utils/hyperLiquidValidation.js'; +import { createStandaloneInfoClient } from '../../../src/utils/standaloneInfoClient.js'; +import { + createMockInfrastructure, + createMockMessenger, +} from '../../helpers/serviceMocks.js'; + +jest.mock('../../../src/services/HyperLiquidClientService'); +jest.mock('../../../src/services/HyperLiquidWalletService'); +jest.mock('../../../src/services/HyperLiquidSubscriptionService'); +// Mock stream manager - will be set up in test +let mockStreamManagerInstance: any; +const mockGetStreamManagerInstance = jest.fn(() => mockStreamManagerInstance); +jest.mock( + '../../../../components/UI/Perps/providers/PerpsStreamManager', + () => ({ + getStreamManagerInstance: mockGetStreamManagerInstance, + }), + { virtual: true }, +); + +// Mock standalone info client for standalone mode tests +let mockStandaloneInfoClient: any; +jest.mock('../../../src/utils/standaloneInfoClient', () => ({ + ...jest.requireActual('../../../src/utils/standaloneInfoClient'), + createStandaloneInfoClient: jest.fn(() => mockStandaloneInfoClient), +})); + +jest.mock('../../../src/utils/hyperLiquidValidation', () => ({ + validateOrderParams: jest.fn(), + validateWithdrawalParams: jest.fn(), + validateDepositParams: jest.fn(), + validateCoinExists: jest.fn(), + validateAssetSupport: jest.fn(), + validateBalance: jest.fn(), + getSupportedPaths: jest + .fn() + .mockReturnValue([ + 'eip155:42161/erc20:0xa0b86a33e6776e681a06e0e1622c5e5e3e6a8b13/default', + 'eip155:1/erc20:0xa0b86a33e6776e681a06e0e1622c5e5e3e6a8b13/default', + ]), + getBridgeInfo: jest.fn().mockReturnValue({ + chainId: 'eip155:42161', + contractAddress: '0x1234567890123456789012345678901234567890', + }), + createErrorResult: jest.fn((error, defaultResponse) => ({ + ...defaultResponse, + success: false, + error: error instanceof Error ? error.message : String(error), + })), +})); + +// Mock adapter functions +jest.mock('../../../src/utils/hyperLiquidAdapter', () => { + const actual = jest.requireActual('../../../src/utils/hyperLiquidAdapter'); + return { + ...actual, + adaptHyperLiquidLedgerUpdateToUserHistoryItem: jest.fn((updates) => { + // Return mock history items based on input + if (!updates || !Array.isArray(updates) || updates.length === 0) { + return []; + } + return updates.map((_update: unknown) => ({ + type: 'deposit' as const, + amount: '100', + timestamp: Date.now(), + hash: '0x123', + })); + }), + }; +}); + +// Mock TradingReadinessCache - global singleton for signing operation caching +// Use jest.createMockFromModule for proper mock creation +jest.mock('../../../src/services/TradingReadinessCache'); + +const MockedHyperLiquidClientService = + HyperLiquidClientService as jest.MockedClass; +const MockedHyperLiquidWalletService = + HyperLiquidWalletService as jest.MockedClass; +const MockedHyperLiquidSubscriptionService = + HyperLiquidSubscriptionService as jest.MockedClass< + typeof HyperLiquidSubscriptionService + >; +const mockValidateOrderParams = validateOrderParams as jest.MockedFunction< + typeof validateOrderParams +>; +const mockValidateWithdrawalParams = + validateWithdrawalParams as jest.MockedFunction< + typeof validateWithdrawalParams + >; +const mockValidateDepositParams = validateDepositParams as jest.MockedFunction< + typeof validateDepositParams +>; +const mockValidateCoinExists = validateCoinExists as jest.MockedFunction< + typeof validateCoinExists +>; +const mockValidateAssetSupport = validateAssetSupport as jest.MockedFunction< + typeof validateAssetSupport +>; +const mockValidateBalance = validateBalance as jest.MockedFunction< + typeof validateBalance +>; + +// Mock factory functions - defined once, reused everywhere +// These reduce duplication and make tests more maintainable +const createMockInfoClient = (overrides: Record = {}) => ({ + clearinghouseState: jest.fn().mockResolvedValue({ + marginSummary: { + totalMarginUsed: '500', + accountValue: '10500', + }, + withdrawable: '9500', + assetPositions: [ + { + position: { + coin: 'BTC', + szi: '0.1', + entryPx: '50000', + positionValue: '5000', + unrealizedPnl: '100', + marginUsed: '500', + leverage: { type: 'cross', value: 10 }, + liquidationPx: '45000', + maxLeverage: 50, + returnOnEquity: '20', + cumFunding: { allTime: '10', sinceOpen: '5', sinceChange: '2' }, + }, + type: 'oneWay', + }, + { + position: { + coin: 'ETH', + szi: '1.5', + entryPx: '3000', + positionValue: '4500', + unrealizedPnl: '50', + marginUsed: '450', + leverage: { type: 'cross', value: 10 }, + liquidationPx: '2700', + maxLeverage: 50, + returnOnEquity: '10', + cumFunding: { allTime: '5', sinceOpen: '2', sinceChange: '1' }, + }, + type: 'oneWay', + }, + ], + crossMarginSummary: { + accountValue: '10000', + totalMarginUsed: '5000', + }, + }), + spotClearinghouseState: jest.fn().mockResolvedValue({ + balances: [{ coin: 'USDC', hold: '1000', total: '10000' }], + }), + // Mode-aware fold gate reads userAbstraction; default to unifiedAccount + // so tests that predated the gate still see spot folded into spendable/withdrawable. + userAbstraction: jest.fn().mockResolvedValue('unifiedAccount'), + meta: jest.fn().mockResolvedValue({ + universe: [ + { name: 'BTC', szDecimals: 3, maxLeverage: 50 }, + { name: 'ETH', szDecimals: 4, maxLeverage: 50 }, + ], + }), + metaAndAssetCtxs: jest.fn().mockResolvedValue([ + { + universe: [ + { name: 'BTC', szDecimals: 3, maxLeverage: 50 }, + { name: 'ETH', szDecimals: 4, maxLeverage: 50 }, + ], + }, + [ + { + funding: '0.0001', + openInterest: '1000', + prevDayPx: '49000', + dayNtlVlm: '1000000', + markPx: '50000', + midPx: '50000', + oraclePx: '50000', + }, + { + funding: '0.0001', + openInterest: '500', + prevDayPx: '2900', + dayNtlVlm: '500000', + markPx: '3000', + midPx: '3000', + oraclePx: '3000', + }, + ], + ]), + perpDexs: jest.fn().mockResolvedValue([null]), + allMids: jest.fn().mockResolvedValue({ BTC: '50000', ETH: '3000' }), + frontendOpenOrders: jest.fn().mockResolvedValue([]), + referral: jest.fn().mockResolvedValue({ + referrerState: { + stage: 'ready', + data: { code: 'MMCSI' }, + }, + }), + maxBuilderFee: jest.fn().mockResolvedValue(1), + userFees: jest.fn().mockResolvedValue({ + feeSchedule: { + cross: '0.00030', + add: '0.00010', + spotCross: '0.00040', + spotAdd: '0.00020', + }, + dailyUserVlm: [], + }), + userNonFundingLedgerUpdates: jest.fn().mockResolvedValue([ + { + delta: { type: 'deposit', usdc: '100' }, + time: Date.now(), + hash: '0x123abc', + }, + { + delta: { type: 'withdraw', usdc: '50' }, + time: Date.now() - 3600000, + hash: '0x456def', + }, + ]), + portfolio: jest.fn().mockResolvedValue([ + null, + [ + null, + { + accountValueHistory: [ + [Date.now() - 86400000, '10000'], // 24h ago + [Date.now() - 172800000, '9500'], // 48h ago + [Date.now() - 259200000, '9000'], // 72h ago + ], + }, + ], + ]), + spotMeta: jest.fn().mockResolvedValue({ + tokens: [ + { name: 'USDC', tokenId: '0xdef456', index: 0 }, + { name: 'USDT', tokenId: '0x789abc', index: 1 }, + ], + universe: [], + }), + historicalOrders: jest.fn().mockResolvedValue([]), + userFills: jest.fn().mockResolvedValue([]), + userFillsByTime: jest.fn().mockResolvedValue([]), + userFunding: jest.fn().mockResolvedValue([]), + ...overrides, +}); + +const createMockExchangeClient = (overrides: Record = {}) => ({ + order: jest.fn().mockResolvedValue({ + status: 'ok', + response: { data: { statuses: [{ resting: { oid: 123 } }] } }, + }), + modify: jest.fn().mockResolvedValue({ + status: 'ok', + response: { data: { statuses: [{ resting: { oid: '123' } }] } }, + }), + cancel: jest.fn().mockResolvedValue({ + status: 'ok', + response: { data: { statuses: ['success'] } }, + }), + withdraw3: jest.fn().mockResolvedValue({ + status: 'ok', + }), + updateLeverage: jest.fn().mockResolvedValue({ + status: 'ok', + }), + approveBuilderFee: jest.fn().mockResolvedValue({ + status: 'ok', + }), + setReferrer: jest.fn().mockResolvedValue({ + status: 'ok', + }), + sendAsset: jest.fn().mockResolvedValue({ + status: 'ok', + }), + agentSetAbstraction: jest.fn().mockResolvedValue({ + status: 'ok', + }), + userSetAbstraction: jest.fn().mockResolvedValue({ + status: 'ok', + }), + ...overrides, +}); + +// Create shared mock platform dependencies for provider tests +const mockPlatformDependencies: PerpsPlatformDependencies = + createMockInfrastructure(); + +const mockMessenger = createMockMessenger(); + +/** + * Helper to create HyperLiquidProvider with mock platform dependencies + * @param options + * @param options.isTestnet + * @param options.hip3Enabled + * @param options.allowlistMarkets + * @param options.blocklistMarkets + * @param options.useUnifiedAccount + */ +const createTestProvider = ( + options: { + isTestnet?: boolean; + hip3Enabled?: boolean; + allowlistMarkets?: string[]; + blocklistMarkets?: string[]; + useUnifiedAccount?: boolean; + initialAssetMapping?: [string, number][]; + } = {}, +): HyperLiquidProvider => + new HyperLiquidProvider({ + ...options, + platformDependencies: mockPlatformDependencies, + messenger: mockMessenger, + }); + +describe('HyperLiquidProvider', () => { + let provider: HyperLiquidProvider; + let mockClientService: jest.Mocked; + let mockWalletService: jest.Mocked; + let mockSubscriptionService: jest.Mocked; + + beforeEach(() => { + // Reset all mocks + jest.clearAllMocks(); + ( + mockPlatformDependencies.marketDataFormatters.formatVolume as jest.Mock + ).mockImplementation((value: number) => '$' + value.toFixed(0)); + ( + mockPlatformDependencies.marketDataFormatters.formatPerpsFiat as jest.Mock + ).mockImplementation((value: number) => '$' + value.toFixed(2)); + ( + mockPlatformDependencies.marketDataFormatters + .formatPercentage as jest.Mock + ).mockImplementation((value: number) => `${value.toFixed(2)}%`); + ( + mockPlatformDependencies.featureFlags.validateVersionGated as jest.Mock + ).mockReturnValue(undefined); + (mockPlatformDependencies.metrics.isEnabled as jest.Mock).mockReturnValue( + true, + ); + + // Reset TradingReadinessCache mock state (using imported mocked module) + const mockedCache = TradingReadinessCache as jest.Mocked< + typeof TradingReadinessCache + >; + mockedCache.get.mockReturnValue(undefined); + mockedCache.getBuilderFee.mockReturnValue(undefined); + mockedCache.getReferral.mockReturnValue(undefined); + mockedCache.isInFlight.mockReturnValue(undefined); + mockedCache.setInFlight.mockReturnValue(jest.fn()); + + // Initialize mock stream manager instance + mockStreamManagerInstance = { + clearAllChannels: jest.fn(), + }; + + // Create mocked service instances using factory functions + mockClientService = { + initialize: jest.fn(), + isInitialized: jest.fn().mockReturnValue(true), + isTestnetMode: jest.fn().mockReturnValue(false), + ensureInitialized: jest.fn(), + getExchangeClient: jest.fn().mockReturnValue(createMockExchangeClient()), + getInfoClient: jest.fn().mockReturnValue(createMockInfoClient()), + fetchHistoricalOrders: jest.fn().mockResolvedValue([]), + disconnect: jest.fn().mockResolvedValue(undefined), + toggleTestnet: jest.fn(), + setTestnetMode: jest.fn(), + getNetwork: jest.fn().mockReturnValue('mainnet'), + ensureSubscriptionClient: jest.fn().mockResolvedValue(undefined), + getSubscriptionClient: jest.fn(), + setOnReconnectCallback: jest.fn(), + setOnTerminateCallback: jest.fn(), + getConnectionState: jest.fn().mockReturnValue('connected'), + } as Partial as jest.Mocked; + + mockWalletService = { + setTestnetMode: jest.fn(), + getCurrentAccountId: jest + .fn() + .mockReturnValue( + 'eip155:42161:0x1234567890123456789012345678901234567890', + ), + createWalletAdapter: jest.fn().mockReturnValue({ + request: jest + .fn() + .mockResolvedValue(['0x1234567890123456789012345678901234567890']), + }), + getUserAddress: jest + .fn() + .mockReturnValue('0x1234567890123456789012345678901234567890'), + getUserAddressWithDefault: jest + .fn() + .mockResolvedValue('0x1234567890123456789012345678901234567890'), + isKeyringUnlocked: jest.fn().mockReturnValue(true), + isSelectedHardwareWallet: jest.fn().mockReturnValue(false), + } as Partial as jest.Mocked; + + mockSubscriptionService = { + subscribeToPrices: jest.fn().mockResolvedValue(jest.fn()), // Returns Promise + subscribeToPositions: jest.fn().mockReturnValue(jest.fn()), // Returns function directly + subscribeToOrderFills: jest.fn().mockReturnValue(jest.fn()), // Returns function directly + clearAll: jest.fn(), + isPositionsCacheInitialized: jest.fn().mockReturnValue(false), + getCachedPositions: jest.fn().mockReturnValue([]), + updateFeatureFlags: jest.fn().mockResolvedValue(undefined), + // Cache methods used by buildAssetMapping optimization + setDexMetaCache: jest.fn(), + setDexAssetCtxsCache: jest.fn(), + getDexAssetCtxsCache: jest.fn().mockReturnValue(undefined), + // Price cache used by placeOrder, editOrder, closePosition optimizations + getCachedPrice: jest.fn().mockImplementation((symbol: string) => { + const prices: Record = { BTC: '50000', ETH: '3000' }; + return prices[symbol]; + }), + getLastAllMidsSnapshot: jest.fn().mockReturnValue(null), + // Orders cache used by updatePositionTPSL and getOpenOrders + isOrdersCacheInitialized: jest.fn().mockReturnValue(false), + getCachedOrders: jest.fn().mockReturnValue([]), + // Atomic getter - returns null when cache not initialized (prevents race condition) + getOrdersCacheIfInitialized: jest.fn().mockReturnValue(null), + // Abstraction-mode resolved-mode setter (unified account migration) + setUserAbstractionMode: jest.fn(), + } as Partial as jest.Mocked; + + // Mock constructors + MockedHyperLiquidClientService.mockImplementation(() => mockClientService); + MockedHyperLiquidWalletService.mockImplementation(() => mockWalletService); + MockedHyperLiquidSubscriptionService.mockImplementation( + () => mockSubscriptionService, + ); + + // Mock validation + mockValidateOrderParams.mockReturnValue({ isValid: true }); + mockValidateWithdrawalParams.mockReturnValue({ isValid: true }); + mockValidateDepositParams.mockReturnValue({ isValid: true }); + mockValidateCoinExists.mockReturnValue({ isValid: true }); + mockValidateAssetSupport.mockReturnValue({ isValid: true }); + mockValidateBalance.mockReturnValue({ isValid: true }); + const hyperLiquidValidation = jest.requireMock( + '../../../src/utils/hyperLiquidValidation', + ); + hyperLiquidValidation.getSupportedPaths.mockReturnValue([ + 'eip155:42161/erc20:0xa0b86a33e6776e681a06e0e1622c5e5e3e6a8b13/default', + 'eip155:1/erc20:0xa0b86a33e6776e681a06e0e1622c5e5e3e6a8b13/default', + ]); + hyperLiquidValidation.getBridgeInfo.mockReturnValue({ + chainId: 'eip155:42161', + contractAddress: '0x1234567890123456789012345678901234567890', + }); + hyperLiquidValidation.createErrorResult.mockImplementation( + (error: unknown, defaultResponse: Record) => ({ + ...defaultResponse, + success: false, + error: error instanceof Error ? error.message : String(error), + }), + ); + const hyperLiquidAdapter = jest.requireMock( + '../../../src/utils/hyperLiquidAdapter', + ); + hyperLiquidAdapter.adaptHyperLiquidLedgerUpdateToUserHistoryItem.mockImplementation( + (updates: unknown[]) => { + if (!updates || !Array.isArray(updates) || updates.length === 0) { + return []; + } + return updates.map(() => ({ + type: 'deposit' as const, + amount: '100', + timestamp: Date.now(), + hash: '0x123', + })); + }, + ); + + provider = createTestProvider({ + initialAssetMapping: [ + ['BTC', 0], + ['ETH', 1], + ], + }); + }); + describe('Data Retrieval', () => { + it('gets positions successfully', async () => { + const positions = await provider.getPositions(); + + expect(Array.isArray(positions)).toBe(true); + expect(positions.length).toBeGreaterThan(0); + expect( + mockClientService.getInfoClient().clearinghouseState, + ).toHaveBeenCalled(); + }); + + it('gets account state successfully', async () => { + const accountState = await provider.getAccountState(); + + expect(accountState).toBeDefined(); + expect(accountState.totalBalance).toBe('19500'); // 10500 (perps) + 10000 (spot.total) - 1000 (spot.hold, double-counted in accountValue) + expect( + mockClientService.getInfoClient().clearinghouseState, + ).toHaveBeenCalled(); + expect( + mockClientService.getInfoClient().spotClearinghouseState, + ).toHaveBeenCalled(); + }); + + it('does not count non-USDC-only spot balance in funded-state totals', async () => { + mockClientService.getInfoClient = jest.fn().mockReturnValue( + createMockInfoClient({ + spotClearinghouseState: jest.fn().mockResolvedValue({ + balances: [{ coin: 'DAI', hold: '1000', total: '10000' }], + }), + }), + ); + + const accountState = await provider.getAccountState(); + + expect(accountState).toBeDefined(); + expect(accountState.totalBalance).toBe('10500'); + expect( + mockClientService.getInfoClient().spotClearinghouseState, + ).toHaveBeenCalled(); + }); + + it('does not fold non-USDC spot balance in Unified Account mode', async () => { + const hip3Provider = createTestProvider({ + hip3Enabled: true, + allowlistMarkets: ['xyz:*'], + }); + const mockInfoClient = createMockInfoClient({ + perpDexs: jest + .fn() + .mockResolvedValue([null, { name: 'xyz', url: 'https://xyz.com' }]), + clearinghouseState: jest.fn().mockResolvedValue({ + marginSummary: { + totalMarginUsed: '0', + accountValue: '0', + }, + withdrawable: '0', + assetPositions: [], + crossMarginSummary: { + accountValue: '0', + totalMarginUsed: '0', + }, + }), + spotClearinghouseState: jest.fn().mockResolvedValue({ + balances: [ + { coin: 'mUSD', hold: '10', total: '100' }, + { coin: 'HYPE', hold: '0', total: '999' }, + ], + }), + userAbstraction: jest.fn().mockResolvedValue('unifiedAccount'), + }); + + mockClientService.getInfoClient = jest + .fn() + .mockReturnValue(mockInfoClient); + mockWalletService.getUserAddressWithDefault.mockResolvedValue('0x123'); + + const accountState = await hip3Provider.getAccountState(); + + expect(accountState.spendableBalance).toBe('0'); + expect(accountState.withdrawableBalance).toBe('0'); + expect(accountState.totalBalance).toBe('0'); + }); + + it('folds USDC spot balance into spendable/withdrawable in Unified Account mode', async () => { + const hip3Provider = createTestProvider({ + hip3Enabled: true, + allowlistMarkets: ['xyz:*'], + }); + const mockInfoClient = createMockInfoClient({ + perpDexs: jest + .fn() + .mockResolvedValue([null, { name: 'xyz', url: 'https://xyz.com' }]), + clearinghouseState: jest.fn().mockResolvedValue({ + marginSummary: { + totalMarginUsed: '0', + accountValue: '0', + }, + withdrawable: '0', + assetPositions: [], + crossMarginSummary: { + accountValue: '0', + totalMarginUsed: '0', + }, + }), + spotClearinghouseState: jest.fn().mockResolvedValue({ + balances: [{ coin: 'USDC', hold: '10', total: '100' }], + }), + userAbstraction: jest.fn().mockResolvedValue('unifiedAccount'), + }); + + mockClientService.getInfoClient = jest + .fn() + .mockReturnValue(mockInfoClient); + mockWalletService.getUserAddressWithDefault.mockResolvedValue('0x123'); + + const accountState = await hip3Provider.getAccountState(); + + expect(accountState.spendableBalance).toBe('90'); + expect(accountState.withdrawableBalance).toBe('90'); + expect(accountState.totalBalance).toBe('90'); + }); + + it.each(['default', 'disabled'] as const)( + 'does not fold USDC spot balance in %s account mode', + async (abstractionMode) => { + const hip3Provider = createTestProvider({ + hip3Enabled: true, + allowlistMarkets: ['xyz:*'], + }); + const mockInfoClient = createMockInfoClient({ + perpDexs: jest + .fn() + .mockResolvedValue([null, { name: 'xyz', url: 'https://xyz.com' }]), + clearinghouseState: jest.fn().mockResolvedValue({ + marginSummary: { + totalMarginUsed: '0', + accountValue: '0', + }, + withdrawable: '0', + assetPositions: [], + crossMarginSummary: { + accountValue: '0', + totalMarginUsed: '0', + }, + }), + spotClearinghouseState: jest.fn().mockResolvedValue({ + balances: [{ coin: 'USDC', hold: '10', total: '100' }], + }), + userAbstraction: jest.fn().mockResolvedValue(abstractionMode), + }); + + mockClientService.getInfoClient = jest + .fn() + .mockReturnValue(mockInfoClient); + mockWalletService.getUserAddressWithDefault.mockResolvedValue('0x123'); + + const accountState = await hip3Provider.getAccountState(); + + expect(accountState.spendableBalance).toBe('0'); + expect(accountState.withdrawableBalance).toBe('0'); + expect(accountState.totalBalance).toBe('90'); + }, + ); + + it('gets markets successfully', async () => { + const markets = await provider.getMarkets(); + + expect(Array.isArray(markets)).toBe(true); + expect(markets.length).toBeGreaterThan(0); + // buildAssetMapping (via ensureReady) uses metaAndAssetCtxs to populate cache; getMarkets uses cached meta + expect( + mockClientService.getInfoClient().metaAndAssetCtxs, + ).toHaveBeenCalled(); + }); + + it('filters out a HIP-3 DEX from market discovery when its collateral token is not USDC (TAT-3304)', async () => { + const hip3Provider = createTestProvider({ + hip3Enabled: true, + allowlistMarkets: ['xyz:*'], + }); + const xyzMeta = { + universe: [{ name: 'xyz:STOCK1', szDecimals: 2, maxLeverage: 20 }], + collateralToken: 5, + }; + const mockInfoClient = createMockInfoClient({ + perpDexs: jest + .fn() + .mockResolvedValue([null, { name: 'xyz', url: 'https://xyz.com' }]), + meta: jest.fn().mockImplementation((params?: { dex?: string }) => + params?.dex === 'xyz' + ? Promise.resolve(xyzMeta) + : Promise.resolve({ + universe: [{ name: 'BTC', szDecimals: 3, maxLeverage: 50 }], + }), + ), + metaAndAssetCtxs: jest + .fn() + .mockImplementation((params?: { dex?: string }) => + params?.dex === 'xyz' + ? Promise.resolve([xyzMeta, []]) + : Promise.resolve([ + { + universe: [{ name: 'BTC', szDecimals: 3, maxLeverage: 50 }], + }, + [], + ]), + ), + spotMeta: jest.fn().mockResolvedValue({ + tokens: [ + { name: 'USDC', tokenId: '0xdef456', index: 0 }, + { name: 'USDH', tokenId: '0xabc123', index: 5 }, + ], + universe: [], + }), + }); + + mockClientService.getInfoClient = jest + .fn() + .mockReturnValue(mockInfoClient); + + const markets = await hip3Provider.getMarkets({ dex: 'xyz' }); + + expect(markets).toEqual([]); + }); + + it('does not filter a HIP-3 DEX whose collateral token resolves to USDC', async () => { + const hip3Provider = createTestProvider({ + hip3Enabled: true, + allowlistMarkets: ['xyz:*'], + }); + const xyzMeta = { + universe: [{ name: 'xyz:STOCK1', szDecimals: 2, maxLeverage: 20 }], + collateralToken: 0, + }; + const mockInfoClient = createMockInfoClient({ + perpDexs: jest + .fn() + .mockResolvedValue([null, { name: 'xyz', url: 'https://xyz.com' }]), + meta: jest.fn().mockImplementation((params?: { dex?: string }) => + params?.dex === 'xyz' + ? Promise.resolve(xyzMeta) + : Promise.resolve({ + universe: [{ name: 'BTC', szDecimals: 3, maxLeverage: 50 }], + }), + ), + metaAndAssetCtxs: jest + .fn() + .mockImplementation((params?: { dex?: string }) => + params?.dex === 'xyz' + ? Promise.resolve([xyzMeta, []]) + : Promise.resolve([ + { + universe: [{ name: 'BTC', szDecimals: 3, maxLeverage: 50 }], + }, + [], + ]), + ), + spotMeta: jest.fn().mockResolvedValue({ + tokens: [{ name: 'USDC', tokenId: '0xdef456', index: 0 }], + universe: [], + }), + }); + + mockClientService.getInfoClient = jest + .fn() + .mockReturnValue(mockInfoClient); + + const markets = await hip3Provider.getMarkets({ dex: 'xyz' }); + + expect(markets.length).toBe(1); + expect(markets[0].name).toBe('xyz:STOCK1'); + }); + + it('filters out a HIP-3 DEX from market discovery when its collateral token index cannot be resolved against spot metadata', async () => { + const hip3Provider = createTestProvider({ + hip3Enabled: true, + allowlistMarkets: ['xyz:*'], + }); + // collateralToken index 7 has no corresponding entry in spotMeta.tokens + // below (missing/stale spot metadata) — the DEX must be gated out + // rather than treated as USDC. + const xyzMeta = { + universe: [{ name: 'xyz:STOCK1', szDecimals: 2, maxLeverage: 20 }], + collateralToken: 7, + }; + const mockInfoClient = createMockInfoClient({ + perpDexs: jest + .fn() + .mockResolvedValue([null, { name: 'xyz', url: 'https://xyz.com' }]), + meta: jest.fn().mockImplementation((params?: { dex?: string }) => + params?.dex === 'xyz' + ? Promise.resolve(xyzMeta) + : Promise.resolve({ + universe: [{ name: 'BTC', szDecimals: 3, maxLeverage: 50 }], + }), + ), + metaAndAssetCtxs: jest + .fn() + .mockImplementation((params?: { dex?: string }) => + params?.dex === 'xyz' + ? Promise.resolve([xyzMeta, []]) + : Promise.resolve([ + { + universe: [{ name: 'BTC', szDecimals: 3, maxLeverage: 50 }], + }, + [], + ]), + ), + spotMeta: jest.fn().mockResolvedValue({ + tokens: [{ name: 'USDC', tokenId: '0xdef456', index: 0 }], + universe: [], + }), + }); + + mockClientService.getInfoClient = jest + .fn() + .mockReturnValue(mockInfoClient); + + const markets = await hip3Provider.getMarkets({ dex: 'xyz' }); + + expect(markets).toEqual([]); + }); + + it('keeps HIP-3 asset contexts aligned after allowlist filtering', async () => { + const mainMeta = { + universe: [{ name: 'BTC', szDecimals: 3, maxLeverage: 50 }], + }; + const mainAssetCtx = { + funding: '0.0001', + openInterest: '1000', + prevDayPx: '49000', + dayNtlVlm: '1000000', + markPx: '50000', + midPx: '50000', + oraclePx: '50000', + }; + const ioMeta = { + collateralToken: 0, + universe: [ + { name: 'io:PREIPO', szDecimals: 3, maxLeverage: 3 }, + { name: 'io:ANTH', szDecimals: 3, maxLeverage: 3 }, + ], + }; + const ioAssetCtxs = [ + { + funding: '0.0002', + openInterest: '10', + prevDayPx: '100', + dayNtlVlm: '100', + markPx: '100', + midPx: '100', + oraclePx: '100', + }, + { + funding: '0.000000059', + openInterest: '1766.312', + prevDayPx: '1998.2', + dayNtlVlm: '10536801.2081', + markPx: '2009.3', + midPx: '2010.1', + oraclePx: '2009.3', + }, + ]; + const mockInfoClient = createMockInfoClient({ + perpDexs: jest + .fn() + .mockResolvedValue([null, { name: 'io', url: 'https://io.example' }]), + metaAndAssetCtxs: jest + .fn() + .mockImplementation((params?: { dex?: string }) => + params?.dex === 'io' + ? Promise.resolve([ioMeta, ioAssetCtxs]) + : Promise.resolve([mainMeta, [mainAssetCtx]]), + ), + allMids: jest.fn().mockImplementation((params?: { dex?: string }) => + params?.dex === 'io' + ? Promise.resolve({ + 'io:PREIPO': '100', + 'io:ANTH': '2010.1', + }) + : Promise.resolve({ BTC: '50000' }), + ), + }); + mockClientService.getInfoClient = jest + .fn() + .mockReturnValue(mockInfoClient); + const hip3Provider = createTestProvider({ + hip3Enabled: true, + allowlistMarkets: ['io:ANTH'], + }); + + const markets = await hip3Provider.getMarketDataWithPrices(); + + expect(markets).toEqual([ + expect.objectContaining({ symbol: 'BTC' }), + expect.objectContaining({ + symbol: 'io:ANTH', + volume: '$10536801', + openInterest: '$3550464', + fundingRate: 0.000000059, + }), + ]); + }); + + it('handles data retrieval errors gracefully', async () => { + ( + mockClientService.getInfoClient().clearinghouseState as jest.Mock + ).mockRejectedValueOnce(new Error('API Error')); + + const positions = await provider.getPositions(); + + expect(Array.isArray(positions)).toBe(true); + expect(positions.length).toBe(0); + }); + }); + + describe('Withdrawal Operations', () => { + it('processes withdrawal successfully', async () => { + const withdrawParams = { + amount: '1000', + destination: '0x1234567890123456789012345678901234567890' as Hex, + assetId: + 'eip155:42161/erc20:0xa0b86a33e6776e681a06e0e1622c5e5e3e6a8b13/usdc' as CaipAssetId, + }; + + const result = await provider.withdraw(withdrawParams); + + expect(result.success).toBe(true); + }); + + it('runs user-signed unified account migration before withdrawing for dexAbstraction users', async () => { + const exchangeClient = createMockExchangeClient(); + mockClientService.getExchangeClient = jest + .fn() + .mockReturnValue(exchangeClient); + mockClientService.getInfoClient = jest.fn().mockReturnValue( + createMockInfoClient({ + userAbstraction: jest.fn().mockResolvedValue('dexAbstraction'), + }), + ); + + Object.defineProperty(provider, 'getAccountState', { + value: jest.fn().mockResolvedValue({ + availableBalance: '5000', + }), + writable: true, + }); + + const withdrawParams = { + amount: '1000', + destination: '0x1234567890123456789012345678901234567890' as Hex, + assetId: + 'eip155:42161/erc20:0xa0b86a33e6776e681a06e0e1622c5e5e3e6a8b13/usdc' as CaipAssetId, + }; + + const result = await provider.withdraw(withdrawParams); + + expect(result.success).toBe(true); + expect(exchangeClient.userSetAbstraction).toHaveBeenCalledWith({ + user: '0x1234567890123456789012345678901234567890', + abstraction: 'unifiedAccount', + }); + expect(exchangeClient.withdraw3).toHaveBeenCalledWith({ + destination: '0x1234567890123456789012345678901234567890', + amount: '1000', + }); + expect(exchangeClient.approveBuilderFee).not.toHaveBeenCalled(); + expect(exchangeClient.setReferrer).not.toHaveBeenCalled(); + }); + + it('handles withdrawal errors', async () => { + mockValidateWithdrawalParams.mockReturnValueOnce({ + isValid: false, + error: 'Invalid withdrawal amount', + }); + + const withdrawParams = { + amount: '0', + destination: '0x1234567890123456789012345678901234567890' as Hex, + assetId: + 'eip155:42161/erc20:0xa0b86a33e6776e681a06e0e1622c5e5e3e6a8b13/usdc' as CaipAssetId, + }; + + const result = await provider.withdraw(withdrawParams); + + expect(result.success).toBe(false); + }); + }); + + describe('Subscription Management', () => { + it('subscribes to prices', () => { + const callback = jest.fn(); + const unsubscribe = provider.subscribeToPrices({ + symbols: ['BTC', 'ETH'], + callback, + }); + + expect(mockSubscriptionService.subscribeToPrices).toHaveBeenCalled(); + expect(typeof unsubscribe).toBe('function'); + }); + + it('subscribes to positions', () => { + const callback = jest.fn(); + const unsubscribe = provider.subscribeToPositions({ callback }); + + expect(mockSubscriptionService.subscribeToPositions).toHaveBeenCalled(); + expect(typeof unsubscribe).toBe('function'); + }); + + it('subscribes to order fills', () => { + const callback = jest.fn(); + const unsubscribe = provider.subscribeToOrderFills({ callback }); + + expect(mockSubscriptionService.subscribeToOrderFills).toHaveBeenCalled(); + expect(typeof unsubscribe).toBe('function'); + }); + + it('sets live data config', () => { + const config: Partial = { + priceThrottleMs: 1000, + positionThrottleMs: 3000, + }; + + provider.setLiveDataConfig(config); + + // Note: This test may need adjustment based on actual implementation + expect(mockSubscriptionService.clearAll).toBeDefined(); + }); + }); + + describe('Provider State Management', () => { + it('checks if ready to trade', async () => { + const result = await provider.isReadyToTrade(); + + expect(result.ready).toBe(true); + }); + + it('handles readiness check errors', async () => { + mockWalletService.getCurrentAccountId.mockImplementationOnce(() => { + throw new Error('No account selected'); + }); + + const result = await provider.isReadyToTrade(); + + expect(result.ready).toBe(false); + }); + + it('toggles testnet mode', async () => { + const result = await provider.toggleTestnet(); + + expect(result.success).toBe(true); + expect(mockClientService.disconnect).toHaveBeenCalledTimes(1); + expect(mockClientService.setTestnetMode).toHaveBeenCalled(); + expect(mockWalletService.setTestnetMode).toHaveBeenCalled(); + expect(mockClientService.initialize).toHaveBeenCalledTimes(1); + expect( + mockClientService.disconnect.mock.invocationCallOrder[0], + ).toBeLessThan( + mockClientService.setTestnetMode.mock.invocationCallOrder[0], + ); + expect( + mockClientService.setTestnetMode.mock.invocationCallOrder[0], + ).toBeLessThan(mockClientService.initialize.mock.invocationCallOrder[0]); + }); + + it('toggleTestnet succeeds even when called concurrently with initialization', async () => { + const result = await provider.toggleTestnet(); + + expect(result.success).toBe(true); + }); + + it('disconnects successfully', async () => { + const result = await provider.disconnect(); + + expect(result.success).toBe(true); + expect(mockClientService.disconnect).toHaveBeenCalled(); + }); + + it('disconnects successfully even when initialization was pending', async () => { + const result = await provider.disconnect(); + + expect(result.success).toBe(true); + expect(mockClientService.disconnect).toHaveBeenCalled(); + }); + + it('disconnects successfully even when ensureReady was pending', async () => { + const result = await provider.disconnect(); + + expect(result.success).toBe(true); + }); + + it('handles disconnect errors', async () => { + mockClientService.disconnect.mockRejectedValueOnce( + new Error('Disconnect failed'), + ); + + const result = await provider.disconnect(); + + expect(result.success).toBe(false); + expect(result.error).toContain('Disconnect failed'); + }); + + describe('ping() health check', () => { + it('successfully ping WebSocket connection with default timeout', async () => { + const mockReady = jest.fn().mockResolvedValue(undefined); + const mockSubscriptionClient = { + config_: { + transport: { + ready: mockReady, + }, + }, + }; + mockClientService.getSubscriptionClient.mockReturnValue( + mockSubscriptionClient as any, + ); + + await provider.ping(); + + expect(mockReady).toHaveBeenCalled(); + // Verify the AbortSignal was passed + expect(mockReady.mock.calls[0][0]).toBeInstanceOf(AbortSignal); + }); + + it('successfully ping WebSocket connection with custom timeout', async () => { + const mockReady = jest.fn().mockResolvedValue(undefined); + const mockSubscriptionClient = { + config_: { + transport: { + ready: mockReady, + }, + }, + }; + mockClientService.getSubscriptionClient.mockReturnValue( + mockSubscriptionClient as any, + ); + + await provider.ping(10000); + + expect(mockReady).toHaveBeenCalled(); + expect(mockReady.mock.calls[0][0]).toBeInstanceOf(AbortSignal); + }); + + it('throws error when subscription client is not initialized', async () => { + mockClientService.getSubscriptionClient.mockReturnValue(undefined); + + await expect(provider.ping()).rejects.toThrow( + 'Subscription client not initialized', + ); + }); + + it('throws CONNECTION_TIMEOUT error when timeout occurs', async () => { + const mockReady = jest + .fn() + .mockImplementation( + () => + new Promise((_, reject) => + setTimeout(() => reject(new Error('Aborted')), 100), + ), + ); + const mockSubscriptionClient = { + config_: { + transport: { + ready: mockReady, + }, + }, + }; + mockClientService.getSubscriptionClient.mockReturnValue( + mockSubscriptionClient as any, + ); + + await expect(provider.ping(50)).rejects.toThrow('CONNECTION_TIMEOUT'); + }); + + it('throws error when WebSocket connection fails', async () => { + const mockReady = jest + .fn() + .mockRejectedValue(new Error('WebSocket closed')); + const mockSubscriptionClient = { + config_: { + transport: { + ready: mockReady, + }, + }, + }; + mockClientService.getSubscriptionClient.mockReturnValue( + mockSubscriptionClient as any, + ); + + await expect(provider.ping()).rejects.toThrow('WebSocket closed'); + }); + }); + }); + + describe('Asset Mapping', () => { + it('handles asset mapping errors', async () => { + ( + mockClientService.getInfoClient().meta as jest.Mock + ).mockRejectedValueOnce(new Error('Meta fetch failed')); + + const orderParams: OrderParams = { + symbol: 'BTC', + isBuy: true, + size: '0.1', + orderType: 'market', + }; + + const result = await provider.placeOrder(orderParams); + + expect(result.success).toBe(false); + }); + }); + + describe('Error Handling', () => { + it('handles validation errors in orders', async () => { + mockValidateOrderParams.mockReturnValueOnce({ + isValid: false, + error: 'Invalid order parameters', + }); + + const orderParams: OrderParams = { + symbol: '', + isBuy: true, + size: '0', + orderType: 'market', + }; + + const result = await provider.placeOrder(orderParams); + + expect(result.success).toBe(false); + expect(result.error).toContain('Invalid order parameters'); + }); + + it('handles validation errors in withdrawals', async () => { + mockValidateWithdrawalParams.mockReturnValueOnce({ + isValid: false, + error: 'Invalid withdrawal parameters', + }); + + const withdrawParams = { + amount: '', + destination: '0x1234567890123456789012345678901234567890' as Hex, + assetId: + 'eip155:42161/erc20:0xa0b86a33e6776e681a06e0e1622c5e5e3e6a8b13/usdc' as CaipAssetId, + }; + + const result = await provider.withdraw(withdrawParams); + + expect(result.success).toBe(false); + expect(result.error).toContain('Invalid withdrawal parameters'); + }); + + it('handles unknown errors gracefully', async () => { + ( + mockClientService.getInfoClient().clearinghouseState as jest.Mock + ).mockRejectedValueOnce(new Error('Unknown error')); + + const result = await provider.getPositions(); + + expect(Array.isArray(result)).toBe(true); + expect(result.length).toBe(0); + }); + + describe('error mapping integration', () => { + it('maps HyperLiquid leverage error in placeOrder to ORDER_LEVERAGE_REDUCTION_FAILED', async () => { + // Mock placeOrder to throw the specific HyperLiquid error + mockClientService.getExchangeClient = jest.fn().mockReturnValue({ + order: jest + .fn() + .mockRejectedValue( + new Error( + 'isolated position does not have sufficient margin available to decrease leverage', + ), + ), + updateLeverage: jest.fn().mockResolvedValue({ status: 'ok' }), + approveBuilderFee: jest.fn().mockResolvedValue({ status: 'ok' }), + setReferrer: jest.fn().mockResolvedValue({ status: 'ok' }), + }); + + const orderParams: OrderParams = { + symbol: 'BTC', + isBuy: true, + size: '0.1', + orderType: 'market', + currentPrice: 50000, + leverage: 10, + }; + + const result = await provider.placeOrder(orderParams); + + expect(result.success).toBe(false); + expect(result.error).toBe('ORDER_LEVERAGE_REDUCTION_FAILED'); + }); + + it('maps case insensitive HyperLiquid error', async () => { + // Mock with uppercase version + mockClientService.getExchangeClient = jest.fn().mockReturnValue({ + order: jest + .fn() + .mockRejectedValue( + new Error( + 'ISOLATED POSITION DOES NOT HAVE SUFFICIENT MARGIN AVAILABLE TO DECREASE LEVERAGE', + ), + ), + updateLeverage: jest.fn().mockResolvedValue({ status: 'ok' }), + approveBuilderFee: jest.fn().mockResolvedValue({ status: 'ok' }), + setReferrer: jest.fn().mockResolvedValue({ status: 'ok' }), + }); + + const orderParams: OrderParams = { + symbol: 'BTC', + isBuy: true, + size: '0.1', + orderType: 'market', + currentPrice: 50000, + leverage: 10, + }; + + const result = await provider.placeOrder(orderParams); + + expect(result.success).toBe(false); + expect(result.error).toBe('ORDER_LEVERAGE_REDUCTION_FAILED'); + }); + + it('maps partial error message containing the pattern', async () => { + // Mock with longer error message containing the pattern + mockClientService.getExchangeClient = jest.fn().mockReturnValue({ + order: jest + .fn() + .mockRejectedValue( + new Error( + 'API Error: isolated position does not have sufficient margin available to decrease leverage. Please check your position.', + ), + ), + updateLeverage: jest.fn().mockResolvedValue({ status: 'ok' }), + approveBuilderFee: jest.fn().mockResolvedValue({ status: 'ok' }), + setReferrer: jest.fn().mockResolvedValue({ status: 'ok' }), + }); + + const orderParams: OrderParams = { + symbol: 'BTC', + isBuy: true, + size: '0.1', + orderType: 'market', + currentPrice: 50000, + leverage: 10, + }; + + const result = await provider.placeOrder(orderParams); + + expect(result.success).toBe(false); + expect(result.error).toBe('ORDER_LEVERAGE_REDUCTION_FAILED'); + }); + + it('preserves original error message for unmapped errors', async () => { + // Mock with an unmapped error + const originalError = new Error('Some other HyperLiquid API error'); + mockClientService.getExchangeClient = jest.fn().mockReturnValue({ + order: jest.fn().mockRejectedValue(originalError), + updateLeverage: jest.fn().mockResolvedValue({ status: 'ok' }), + approveBuilderFee: jest.fn().mockResolvedValue({ status: 'ok' }), + setReferrer: jest.fn().mockResolvedValue({ status: 'ok' }), + }); + + const orderParams: OrderParams = { + symbol: 'BTC', + isBuy: true, + size: '0.1', + orderType: 'market', + currentPrice: 50000, + leverage: 10, + }; + + const result = await provider.placeOrder(orderParams); + + expect(result.success).toBe(false); + expect(result.error).toBe('Some other HyperLiquid API error'); + }); + }); + }); + + describe('Edge Cases', () => { + it('handles missing asset info in orders', async () => { + ( + mockClientService.getInfoClient().meta as jest.Mock + ).mockResolvedValueOnce({ + universe: [], // Empty universe + }); + + const orderParams: OrderParams = { + symbol: 'UNKNOWN', + isBuy: true, + size: '0.1', + orderType: 'market', + currentPrice: 50000, // Add price so validation passes, then fails on asset lookup + }; + + const result = await provider.placeOrder(orderParams); + + expect(result.success).toBe(false); + expect(result.error).toContain('Asset UNKNOWN not found'); + }); + + it('handles missing price data', async () => { + mockSubscriptionService.getCachedPrice.mockReturnValueOnce(undefined); + ( + mockClientService.getInfoClient().allMids as jest.Mock + ).mockResolvedValueOnce({}); + + const orderParams: OrderParams = { + symbol: 'BTC', + isBuy: true, + size: '0.1', + orderType: 'market', + }; + + const result = await provider.placeOrder(orderParams); + + // allMids returns {} so #getOrFetchPrice parses price as 0, which is + // invalid. The error surfaces from #getAssetInfo before validation runs. + expect(result.success).toBe(false); + expect(result.error).toContain('Invalid price for BTC: 0'); + }); + + it('handles missing position in close operation', async () => { + ( + mockClientService.getInfoClient().clearinghouseState as jest.Mock + ).mockResolvedValueOnce({ + assetPositions: [], // No positions + }); + + const closeParams: ClosePositionParams = { + symbol: 'BTC', + orderType: 'market', + }; + + const result = await provider.closePosition(closeParams); + + expect(result.success).toBe(false); + expect(result.error).toContain('No position found for BTC'); + }); + }); +}); diff --git a/packages/perps-controller/tests/src/providers/HyperLiquidProvider.error-handling.test.ts b/packages/perps-controller/tests/src/providers/HyperLiquidProvider.error-handling.test.ts new file mode 100644 index 00000000000..88eef0f9f63 --- /dev/null +++ b/packages/perps-controller/tests/src/providers/HyperLiquidProvider.error-handling.test.ts @@ -0,0 +1,3252 @@ +/* eslint-disable */ +jest.mock('@nktkas/hyperliquid', () => ({})); + +import type { CaipAssetId, Hex } from '@metamask/utils'; + +import { CandlePeriod } from '../../../src/constants/chartConfig.js'; +import { + BUILDER_FEE_CONFIG, + REFERRAL_CONFIG, +} from '../../../src/constants/hyperLiquidConfig.js'; +import { PERPS_TRANSACTIONS_HISTORY_CONSTANTS } from '../../../src/constants/transactionsHistoryConfig.js'; +import { PERPS_ERROR_CODES } from '../../../src/perpsErrorCodes.js'; +import { HyperLiquidProvider } from '../../../src/providers/HyperLiquidProvider.js'; +import { HyperLiquidClientService } from '../../../src/services/HyperLiquidClientService.js'; +import { HyperLiquidSubscriptionService } from '../../../src/services/HyperLiquidSubscriptionService.js'; +import { HyperLiquidWalletService } from '../../../src/services/HyperLiquidWalletService.js'; +import { TradingReadinessCache } from '../../../src/services/TradingReadinessCache.js'; +import type { + ClosePositionParams, + DepositParams, + Order, + PerpsPlatformDependencies, + LiveDataConfig, + OrderParams, +} from '../../../src/types/index.js'; +import { + validateAssetSupport, + validateBalance, + validateCoinExists, + validateDepositParams, + validateOrderParams, + validateWithdrawalParams, +} from '../../../src/utils/hyperLiquidValidation.js'; +import { createStandaloneInfoClient } from '../../../src/utils/standaloneInfoClient.js'; +import { + createDeferred, + createMockInfrastructure, + createMockMessenger, +} from '../../helpers/serviceMocks.js'; + +jest.mock('../../../src/services/HyperLiquidClientService'); +jest.mock('../../../src/services/HyperLiquidWalletService'); +jest.mock('../../../src/services/HyperLiquidSubscriptionService'); +// Mock stream manager - will be set up in test +let mockStreamManagerInstance: any; +const mockGetStreamManagerInstance = jest.fn(() => mockStreamManagerInstance); +jest.mock( + '../../../../components/UI/Perps/providers/PerpsStreamManager', + () => ({ + getStreamManagerInstance: mockGetStreamManagerInstance, + }), + { virtual: true }, +); + +// Mock standalone info client for standalone mode tests +let mockStandaloneInfoClient: any; +jest.mock('../../../src/utils/standaloneInfoClient', () => ({ + ...jest.requireActual('../../../src/utils/standaloneInfoClient'), + createStandaloneInfoClient: jest.fn(() => mockStandaloneInfoClient), +})); + +jest.mock('../../../src/utils/hyperLiquidValidation', () => ({ + validateOrderParams: jest.fn(), + validateWithdrawalParams: jest.fn(), + validateDepositParams: jest.fn(), + validateCoinExists: jest.fn(), + validateAssetSupport: jest.fn(), + validateBalance: jest.fn(), + getSupportedPaths: jest + .fn() + .mockReturnValue([ + 'eip155:42161/erc20:0xa0b86a33e6776e681a06e0e1622c5e5e3e6a8b13/default', + 'eip155:1/erc20:0xa0b86a33e6776e681a06e0e1622c5e5e3e6a8b13/default', + ]), + getBridgeInfo: jest.fn().mockReturnValue({ + chainId: 'eip155:42161', + contractAddress: '0x1234567890123456789012345678901234567890', + }), + createErrorResult: jest.fn((error, defaultResponse) => ({ + ...defaultResponse, + success: false, + error: error instanceof Error ? error.message : String(error), + })), +})); + +// Mock adapter functions +jest.mock('../../../src/utils/hyperLiquidAdapter', () => { + const actual = jest.requireActual('../../../src/utils/hyperLiquidAdapter'); + return { + ...actual, + adaptHyperLiquidLedgerUpdateToUserHistoryItem: jest.fn((updates) => { + // Return mock history items based on input + if (!updates || !Array.isArray(updates) || updates.length === 0) { + return []; + } + return updates.map((_update: unknown) => ({ + type: 'deposit' as const, + amount: '100', + timestamp: Date.now(), + hash: '0x123', + })); + }), + }; +}); + +// Mock TradingReadinessCache - global singleton for signing operation caching +// Use jest.createMockFromModule for proper mock creation +jest.mock('../../../src/services/TradingReadinessCache'); + +const MockedHyperLiquidClientService = + HyperLiquidClientService as jest.MockedClass; +const MockedHyperLiquidWalletService = + HyperLiquidWalletService as jest.MockedClass; +const MockedHyperLiquidSubscriptionService = + HyperLiquidSubscriptionService as jest.MockedClass< + typeof HyperLiquidSubscriptionService + >; +const mockValidateOrderParams = validateOrderParams as jest.MockedFunction< + typeof validateOrderParams +>; +const mockValidateWithdrawalParams = + validateWithdrawalParams as jest.MockedFunction< + typeof validateWithdrawalParams + >; +const mockValidateDepositParams = validateDepositParams as jest.MockedFunction< + typeof validateDepositParams +>; +const mockValidateCoinExists = validateCoinExists as jest.MockedFunction< + typeof validateCoinExists +>; +const mockValidateAssetSupport = validateAssetSupport as jest.MockedFunction< + typeof validateAssetSupport +>; +const mockValidateBalance = validateBalance as jest.MockedFunction< + typeof validateBalance +>; + +// Mock factory functions - defined once, reused everywhere +// These reduce duplication and make tests more maintainable +const createMockInfoClient = (overrides: Record = {}) => ({ + clearinghouseState: jest.fn().mockResolvedValue({ + marginSummary: { + totalMarginUsed: '500', + accountValue: '10500', + }, + withdrawable: '9500', + assetPositions: [ + { + position: { + coin: 'BTC', + szi: '0.1', + entryPx: '50000', + positionValue: '5000', + unrealizedPnl: '100', + marginUsed: '500', + leverage: { type: 'cross', value: 10 }, + liquidationPx: '45000', + maxLeverage: 50, + returnOnEquity: '20', + cumFunding: { allTime: '10', sinceOpen: '5', sinceChange: '2' }, + }, + type: 'oneWay', + }, + { + position: { + coin: 'ETH', + szi: '1.5', + entryPx: '3000', + positionValue: '4500', + unrealizedPnl: '50', + marginUsed: '450', + leverage: { type: 'cross', value: 10 }, + liquidationPx: '2700', + maxLeverage: 50, + returnOnEquity: '10', + cumFunding: { allTime: '5', sinceOpen: '2', sinceChange: '1' }, + }, + type: 'oneWay', + }, + ], + crossMarginSummary: { + accountValue: '10000', + totalMarginUsed: '5000', + }, + }), + spotClearinghouseState: jest.fn().mockResolvedValue({ + balances: [{ coin: 'USDC', hold: '1000', total: '10000' }], + }), + // Mode-aware fold gate reads userAbstraction; default to unifiedAccount + // so tests that predated the gate still see spot folded into spendable/withdrawable. + userAbstraction: jest.fn().mockResolvedValue('unifiedAccount'), + meta: jest.fn().mockResolvedValue({ + universe: [ + { name: 'BTC', szDecimals: 3, maxLeverage: 50 }, + { name: 'ETH', szDecimals: 4, maxLeverage: 50 }, + ], + }), + metaAndAssetCtxs: jest.fn().mockResolvedValue([ + { + universe: [ + { name: 'BTC', szDecimals: 3, maxLeverage: 50 }, + { name: 'ETH', szDecimals: 4, maxLeverage: 50 }, + ], + }, + [ + { + funding: '0.0001', + openInterest: '1000', + prevDayPx: '49000', + dayNtlVlm: '1000000', + markPx: '50000', + midPx: '50000', + oraclePx: '50000', + }, + { + funding: '0.0001', + openInterest: '500', + prevDayPx: '2900', + dayNtlVlm: '500000', + markPx: '3000', + midPx: '3000', + oraclePx: '3000', + }, + ], + ]), + perpDexs: jest.fn().mockResolvedValue([null]), + allMids: jest.fn().mockResolvedValue({ BTC: '50000', ETH: '3000' }), + frontendOpenOrders: jest.fn().mockResolvedValue([]), + referral: jest.fn().mockResolvedValue({ + referrerState: { + stage: 'ready', + data: { code: 'MMCSI' }, + }, + }), + maxBuilderFee: jest.fn().mockResolvedValue(1), + userFees: jest.fn().mockResolvedValue({ + feeSchedule: { + cross: '0.00030', + add: '0.00010', + spotCross: '0.00040', + spotAdd: '0.00020', + }, + dailyUserVlm: [], + }), + userNonFundingLedgerUpdates: jest.fn().mockResolvedValue([ + { + delta: { type: 'deposit', usdc: '100' }, + time: Date.now(), + hash: '0x123abc', + }, + { + delta: { type: 'withdraw', usdc: '50' }, + time: Date.now() - 3600000, + hash: '0x456def', + }, + ]), + portfolio: jest.fn().mockResolvedValue([ + null, + [ + null, + { + accountValueHistory: [ + [Date.now() - 86400000, '10000'], // 24h ago + [Date.now() - 172800000, '9500'], // 48h ago + [Date.now() - 259200000, '9000'], // 72h ago + ], + }, + ], + ]), + spotMeta: jest.fn().mockResolvedValue({ + tokens: [ + { name: 'USDC', tokenId: '0xdef456', index: 0 }, + { name: 'USDT', tokenId: '0x789abc', index: 1 }, + ], + universe: [], + }), + historicalOrders: jest.fn().mockResolvedValue([]), + userFills: jest.fn().mockResolvedValue([]), + userFillsByTime: jest.fn().mockResolvedValue([]), + userFunding: jest.fn().mockResolvedValue([]), + ...overrides, +}); + +const createMockExchangeClient = (overrides: Record = {}) => ({ + order: jest.fn().mockImplementation((request: { orders: unknown[] }) => + Promise.resolve({ + status: 'ok', + response: { + data: { + statuses: request.orders.map((_order, index) => ({ + resting: { oid: 123 + index }, + })), + }, + }, + }), + ), + modify: jest.fn().mockResolvedValue({ + status: 'ok', + response: { data: { statuses: [{ resting: { oid: '123' } }] } }, + }), + cancel: jest.fn().mockResolvedValue({ + status: 'ok', + response: { data: { statuses: ['success'] } }, + }), + withdraw3: jest.fn().mockResolvedValue({ + status: 'ok', + }), + updateLeverage: jest.fn().mockResolvedValue({ + status: 'ok', + }), + approveBuilderFee: jest.fn().mockResolvedValue({ + status: 'ok', + }), + setReferrer: jest.fn().mockResolvedValue({ + status: 'ok', + }), + sendAsset: jest.fn().mockResolvedValue({ + status: 'ok', + }), + agentSetAbstraction: jest.fn().mockResolvedValue({ + status: 'ok', + }), + userSetAbstraction: jest.fn().mockResolvedValue({ + status: 'ok', + }), + ...overrides, +}); + +// Create shared mock platform dependencies for provider tests +const mockPlatformDependencies: PerpsPlatformDependencies = + createMockInfrastructure(); + +const mockMessenger = createMockMessenger(); + +/** + * Helper to create HyperLiquidProvider with mock platform dependencies + * @param options + * @param options.isTestnet + * @param options.hip3Enabled + * @param options.allowlistMarkets + * @param options.blocklistMarkets + * @param options.useUnifiedAccount + */ +const createTestProvider = ( + options: { + isTestnet?: boolean; + hip3Enabled?: boolean; + allowlistMarkets?: string[]; + blocklistMarkets?: string[]; + useUnifiedAccount?: boolean; + initialAssetMapping?: [string, number][]; + } = {}, +): HyperLiquidProvider => + new HyperLiquidProvider({ + ...options, + platformDependencies: mockPlatformDependencies, + messenger: mockMessenger, + }); + +describe('HyperLiquidProvider', () => { + let provider: HyperLiquidProvider; + let mockClientService: jest.Mocked; + let mockWalletService: jest.Mocked; + let mockSubscriptionService: jest.Mocked; + + const getMockUserFees = () => + jest.mocked(mockClientService.getInfoClient().userFees); + + beforeEach(() => { + // Reset all mocks + jest.clearAllMocks(); + ( + mockPlatformDependencies.marketDataFormatters.formatVolume as jest.Mock + ).mockImplementation((value: number) => '$' + value.toFixed(0)); + ( + mockPlatformDependencies.marketDataFormatters.formatPerpsFiat as jest.Mock + ).mockImplementation((value: number) => '$' + value.toFixed(2)); + ( + mockPlatformDependencies.marketDataFormatters + .formatPercentage as jest.Mock + ).mockImplementation((value: number) => `${value.toFixed(2)}%`); + ( + mockPlatformDependencies.featureFlags.validateVersionGated as jest.Mock + ).mockReturnValue(undefined); + (mockPlatformDependencies.metrics.isEnabled as jest.Mock).mockReturnValue( + true, + ); + + // Reset TradingReadinessCache mock state (using imported mocked module) + const mockedCache = TradingReadinessCache as jest.Mocked< + typeof TradingReadinessCache + >; + mockedCache.get.mockReturnValue(undefined); + mockedCache.getBuilderFee.mockReturnValue(undefined); + mockedCache.getReferral.mockReturnValue(undefined); + mockedCache.isInFlight.mockReturnValue(undefined); + mockedCache.setInFlight.mockReturnValue(jest.fn()); + + // Initialize mock stream manager instance + mockStreamManagerInstance = { + clearAllChannels: jest.fn(), + }; + + // Create mocked service instances using factory functions + mockClientService = { + initialize: jest.fn(), + isInitialized: jest.fn().mockReturnValue(true), + isTestnetMode: jest.fn().mockReturnValue(false), + ensureInitialized: jest.fn(), + getExchangeClient: jest.fn().mockReturnValue(createMockExchangeClient()), + getInfoClient: jest.fn().mockReturnValue(createMockInfoClient()), + fetchHistoricalOrders: jest.fn().mockResolvedValue([]), + disconnect: jest.fn().mockResolvedValue(undefined), + toggleTestnet: jest.fn(), + setTestnetMode: jest.fn(), + getNetwork: jest.fn().mockReturnValue('mainnet'), + ensureSubscriptionClient: jest.fn().mockResolvedValue(undefined), + getSubscriptionClient: jest.fn(), + setOnReconnectCallback: jest.fn(), + setOnTerminateCallback: jest.fn(), + getConnectionState: jest.fn().mockReturnValue('connected'), + } as Partial as jest.Mocked; + + mockWalletService = { + setTestnetMode: jest.fn(), + getCurrentAccountId: jest + .fn() + .mockReturnValue( + 'eip155:42161:0x1234567890123456789012345678901234567890', + ), + createWalletAdapter: jest.fn().mockReturnValue({ + request: jest + .fn() + .mockResolvedValue(['0x1234567890123456789012345678901234567890']), + }), + getUserAddress: jest + .fn() + .mockReturnValue('0x1234567890123456789012345678901234567890'), + getUserAddressWithDefault: jest + .fn() + .mockResolvedValue('0x1234567890123456789012345678901234567890'), + isKeyringUnlocked: jest.fn().mockReturnValue(true), + isSelectedHardwareWallet: jest.fn().mockReturnValue(false), + } as Partial as jest.Mocked; + + mockSubscriptionService = { + subscribeToPrices: jest.fn().mockResolvedValue(jest.fn()), // Returns Promise + subscribeToPositions: jest.fn().mockReturnValue(jest.fn()), // Returns function directly + subscribeToOrderFills: jest.fn().mockReturnValue(jest.fn()), // Returns function directly + clearAll: jest.fn(), + isPositionsCacheInitialized: jest.fn().mockReturnValue(false), + getCachedPositions: jest.fn().mockReturnValue([]), + updateFeatureFlags: jest.fn().mockResolvedValue(undefined), + // Cache methods used by buildAssetMapping optimization + setDexMetaCache: jest.fn(), + setDexAssetCtxsCache: jest.fn(), + getDexAssetCtxsCache: jest.fn().mockReturnValue(undefined), + // Price cache used by placeOrder, editOrder, closePosition optimizations + getCachedPrice: jest.fn().mockImplementation((symbol: string) => { + const prices: Record = { BTC: '50000', ETH: '3000' }; + return prices[symbol]; + }), + getLastAllMidsSnapshot: jest.fn().mockReturnValue(null), + // Orders cache used by updatePositionTPSL and getOpenOrders + isOrdersCacheInitialized: jest.fn().mockReturnValue(false), + getCachedOrders: jest.fn().mockReturnValue([]), + // Atomic getter - returns null when cache not initialized (prevents race condition) + getOrdersCacheIfInitialized: jest.fn().mockReturnValue(null), + // Abstraction-mode resolved-mode setter (unified account migration) + setUserAbstractionMode: jest.fn(), + getCachedAbstractionMode: jest.fn().mockReturnValue(null), + } as Partial as jest.Mocked; + + // Mock constructors + MockedHyperLiquidClientService.mockImplementation(() => mockClientService); + MockedHyperLiquidWalletService.mockImplementation(() => mockWalletService); + MockedHyperLiquidSubscriptionService.mockImplementation( + () => mockSubscriptionService, + ); + + // Mock validation + mockValidateOrderParams.mockReturnValue({ isValid: true }); + mockValidateWithdrawalParams.mockReturnValue({ isValid: true }); + mockValidateDepositParams.mockReturnValue({ isValid: true }); + mockValidateCoinExists.mockReturnValue({ isValid: true }); + mockValidateAssetSupport.mockReturnValue({ isValid: true }); + mockValidateBalance.mockReturnValue({ isValid: true }); + const hyperLiquidValidation = jest.requireMock( + '../../../src/utils/hyperLiquidValidation', + ); + hyperLiquidValidation.getSupportedPaths.mockReturnValue([ + 'eip155:42161/erc20:0xa0b86a33e6776e681a06e0e1622c5e5e3e6a8b13/default', + 'eip155:1/erc20:0xa0b86a33e6776e681a06e0e1622c5e5e3e6a8b13/default', + ]); + hyperLiquidValidation.getBridgeInfo.mockReturnValue({ + chainId: 'eip155:42161', + contractAddress: '0x1234567890123456789012345678901234567890', + }); + hyperLiquidValidation.createErrorResult.mockImplementation( + (error: unknown, defaultResponse: Record) => ({ + ...defaultResponse, + success: false, + error: error instanceof Error ? error.message : String(error), + }), + ); + const hyperLiquidAdapter = jest.requireMock( + '../../../src/utils/hyperLiquidAdapter', + ); + hyperLiquidAdapter.adaptHyperLiquidLedgerUpdateToUserHistoryItem.mockImplementation( + (updates: unknown[]) => { + if (!updates || !Array.isArray(updates) || updates.length === 0) { + return []; + } + return updates.map(() => ({ + type: 'deposit' as const, + amount: '100', + timestamp: Date.now(), + hash: '0x123', + })); + }, + ); + + provider = createTestProvider({ + initialAssetMapping: [ + ['BTC', 0], + ['ETH', 1], + ], + }); + }); + describe('Additional Error Handling and Edge Cases', () => { + describe('ensureReady and buildAssetMapping', () => { + it('retries readiness after initialization rejects', async () => { + mockClientService.initialize + .mockRejectedValueOnce(new Error('Initialization failed')) + .mockResolvedValueOnce(undefined); + const orderParams: OrderParams = { + symbol: 'BTC', + isBuy: true, + size: '0.1', + orderType: 'market', + currentPrice: 50000, + }; + + await expect(provider.placeOrder(orderParams)).resolves.toMatchObject({ + success: false, + error: 'Initialization failed', + }); + await expect(provider.placeOrder(orderParams)).resolves.toMatchObject({ + success: true, + }); + expect(mockClientService.initialize).toHaveBeenCalledTimes(2); + }); + + it('handles meta fetch failure in buildAssetMapping', async () => { + // Create a fresh provider to test buildAssetMapping + const freshProvider = createTestProvider(); + + // Mock failed meta fetch but keep other methods working + mockClientService.getInfoClient = jest.fn().mockReturnValue( + createMockInfoClient({ + meta: jest.fn().mockRejectedValue(new Error('Network timeout')), + metaAndAssetCtxs: jest + .fn() + .mockRejectedValue(new Error('Network timeout')), + }), + ); + + MockedHyperLiquidClientService.mockImplementation( + () => mockClientService, + ); + + // Try to place an order which will trigger ensureReady -> buildAssetMapping + const orderParams: OrderParams = { + symbol: 'BTC', + isBuy: true, + size: '0.1', + orderType: 'market', + currentPrice: 50000, // Add price for validation + }; + + const result = await freshProvider.placeOrder(orderParams); + + expect(result.success).toBe(false); + expect(result.error).toContain('Network timeout'); + }); + + it('handles string response from meta endpoint', async () => { + // metaAndAssetCtxs returns no valid meta so cache is not populated; buildAssetMapping leaves map empty + mockClientService.getInfoClient = jest.fn().mockReturnValue( + createMockInfoClient({ + meta: jest.fn().mockResolvedValue('invalid string response' as any), + metaAndAssetCtxs: jest.fn().mockResolvedValue([null, []]), // No valid meta -> no cache, no asset mapping + }), + ); + + mockWalletService.getUserAddressWithDefault.mockResolvedValue('0x123'); + + const updateParams = { + symbol: 'BTC', + takeProfitPrice: '55000', + }; + + const result = await provider.updatePositionTPSL(updateParams); + + expect(result.success).toBe(false); + // With no valid meta from metaAndAssetCtxs, asset mapping is empty so we fail with asset not found + expect( + result.error?.includes('Asset ID not found') || + result.error?.includes('Invalid meta response'), + ).toBe(true); + }); + + it('handles meta response without universe property', async () => { + // metaAndAssetCtxs returns no valid meta so cache is not populated; buildAssetMapping leaves map empty + mockClientService.getInfoClient = jest.fn().mockReturnValue( + createMockInfoClient({ + meta: jest.fn().mockResolvedValue({}), // Empty object without universe + metaAndAssetCtxs: jest.fn().mockResolvedValue([null, []]), // No valid meta -> no cache, no asset mapping + }), + ); + + const updateParams = { + symbol: 'BTC', + takeProfitPrice: '55000', + }; + + const result = await provider.updatePositionTPSL(updateParams); + + expect(result.success).toBe(false); + // With no valid meta from metaAndAssetCtxs, asset mapping is empty so we fail with asset not found + expect( + result.error?.includes('Asset ID not found') || + result.error?.includes('Invalid meta response'), + ).toBe(true); + }); + }); + + describe('Order placement edge cases', () => { + it('handles leverage update failure', async () => { + mockClientService.getExchangeClient = jest.fn().mockReturnValue( + createMockExchangeClient({ + updateLeverage: jest.fn().mockResolvedValue({ + status: 'error', + response: { message: 'Leverage update failed' }, + }), + }), + ); + + const orderParams: OrderParams = { + symbol: 'BTC', + isBuy: true, + size: '0.1', + orderType: 'market', + leverage: 10, + currentPrice: 50000, + }; + + const result = await provider.placeOrder(orderParams); + + expect(result.success).toBe(false); + expect(result.error).toContain('Failed to update leverage'); + }); + + // TAT-3343: wallets with no Hyperliquid account (accounts are created + // server-side on the first USDC credit) get "User or API Wallet 0x... does + // not exist." from every exchange write. The order path used to leak that + // raw string to the UI and to failed-trade analytics, leaving users with + // no idea they simply need to fund the account. + it.each([ + [ + 'updateLeverage', + () => + createMockExchangeClient({ + updateLeverage: jest + .fn() + .mockRejectedValue( + new Error( + 'User or API Wallet 0x1234567890123456789012345678901234567890 does not exist.', + ), + ), + }), + ], + [ + 'order', + () => + createMockExchangeClient({ + order: jest + .fn() + .mockRejectedValue( + new Error( + 'User or API Wallet 0x1234567890123456789012345678901234567890 does not exist.', + ), + ), + }), + ], + ])( + 'maps the Hyperliquid "wallet does not exist" rejection from %s to EXCHANGE_ACCOUNT_NOT_FOUND', + async (_action, buildExchangeClient) => { + mockClientService.getExchangeClient = jest + .fn() + .mockReturnValue(buildExchangeClient()); + + const orderParams: OrderParams = { + symbol: 'BTC', + isBuy: true, + size: '0.1', + orderType: 'market', + leverage: 10, + currentPrice: 50000, + }; + + const result = await provider.placeOrder(orderParams); + + expect(result.success).toBe(false); + expect(result.error).toBe( + PERPS_ERROR_CODES.EXCHANGE_ACCOUNT_NOT_FOUND, + ); + // Raw exchange internals must never reach the UI or analytics. + expect(result.error).not.toContain('0x1234567890'); + }, + ); + + it('does not report the "wallet does not exist" rejection to Sentry', async () => { + mockClientService.getExchangeClient = jest.fn().mockReturnValue( + createMockExchangeClient({ + order: jest + .fn() + .mockRejectedValue( + new Error( + 'User or API Wallet 0x1234567890123456789012345678901234567890 does not exist.', + ), + ), + }), + ); + + const result = await provider.placeOrder({ + symbol: 'BTC', + isBuy: true, + size: '0.1', + orderType: 'market', + currentPrice: 50000, + }); + + expect(result.success).toBe(false); + expect(mockPlatformDependencies.logger.error).not.toHaveBeenCalled(); + }); + + // HyperLiquid more often returns a non-`ok` status than a thrown + // rejection. #submitOrderWithRollback wraps that as + // `Order failed: ${JSON.stringify(result)}`, so the classifier must still + // match through the JSON wrapper. + it('maps the rejection when it arrives as a non-ok order status rather than a throw', async () => { + mockClientService.getExchangeClient = jest.fn().mockReturnValue( + createMockExchangeClient({ + order: jest.fn().mockResolvedValue({ + status: 'err', + response: + 'User or API Wallet 0x1234567890123456789012345678901234567890 does not exist.', + }), + }), + ); + + const result = await provider.placeOrder({ + symbol: 'BTC', + isBuy: true, + size: '0.1', + orderType: 'market', + currentPrice: 50000, + }); + + expect(result.success).toBe(false); + expect(result.error).toBe(PERPS_ERROR_CODES.EXCHANGE_ACCOUNT_NOT_FOUND); + expect(mockPlatformDependencies.logger.error).not.toHaveBeenCalled(); + }); + + // Boundary guard: `isHyperLiquidUserNotFoundError` is substring-based and + // now gates both error mapping and Sentry suppression on the order path. + // A message that merely contains "does not exist" must NOT be swallowed. + it('does not swallow near-miss "does not exist" order errors', async () => { + mockClientService.getExchangeClient = jest.fn().mockReturnValue( + createMockExchangeClient({ + order: jest + .fn() + .mockRejectedValue( + new Error('Asset BTC does not exist on this DEX'), + ), + }), + ); + + const result = await provider.placeOrder({ + symbol: 'BTC', + isBuy: true, + size: '0.1', + orderType: 'market', + currentPrice: 50000, + }); + + expect(result.success).toBe(false); + expect(result.error).not.toBe( + PERPS_ERROR_CODES.EXCHANGE_ACCOUNT_NOT_FOUND, + ); + expect(result.error).toContain('Asset BTC does not exist'); + expect(mockPlatformDependencies.logger.error).toHaveBeenCalled(); + }); + + it('still reports unrelated order failures to Sentry', async () => { + mockClientService.getExchangeClient = jest.fn().mockReturnValue( + createMockExchangeClient({ + order: jest + .fn() + .mockRejectedValue( + new Error('Insufficient margin to place order'), + ), + }), + ); + + const result = await provider.placeOrder({ + symbol: 'BTC', + isBuy: true, + size: '0.1', + orderType: 'market', + currentPrice: 50000, + }); + + expect(result.success).toBe(false); + expect(result.error).toContain('Insufficient margin'); + expect(mockPlatformDependencies.logger.error).toHaveBeenCalled(); + }); + + it('succeeds with market order without current price or usdAmount (uses fetched price)', async () => { + // The provider now fetches the live price before validation so callers + // that intentionally omit currentPrice (e.g. flipPosition) work correctly. + const orderParams: OrderParams = { + symbol: 'BTC', + isBuy: true, + size: '0.1', + orderType: 'market', + // No currentPrice or usdAmount: provider fetches live price (50000) + }; + + const result = await provider.placeOrder(orderParams); + + expect(result.success).toBe(true); + expect(mockClientService.getExchangeClient().order).toHaveBeenCalled(); + }); + + it('placeOrder validates against fetched price when params omit currentPrice (flipPosition path)', async () => { + // Simulate the exact OrderParams shape that TradingService.flipPosition + // builds: symbol + isBuy + size + orderType + leverage, no price fields. + const flipOrderParams: OrderParams = { + symbol: 'BTC', + isBuy: false, // flipping long → short + size: '1', // 2× the 0.5 BTC position + orderType: 'market', + leverage: 10, + // currentPrice, usdAmount, price intentionally absent + }; + + const result = await provider.placeOrder(flipOrderParams); + + // Live price (50000) is fetched from allMids → validation passes + // (0.1 BTC × $50 000 = $5 000 >> $10 minimum) → order executes. + expect(result.success).toBe(true); + expect( + mockClientService.getExchangeClient().order, + ).toHaveBeenCalledWith( + expect.objectContaining({ orders: expect.any(Array) }), + ); + }); + + it('handles order with custom slippage', async () => { + const orderParams: OrderParams = { + symbol: 'BTC', + isBuy: true, + size: '0.1', + orderType: 'market', + currentPrice: 50000, + slippage: 0.02, // 2% slippage + }; + + const result = await provider.placeOrder(orderParams); + + expect(result.success).toBe(true); + // Should use 2% slippage instead of default 1% + }); + + it('handles filled order response', async () => { + mockClientService.getExchangeClient = jest.fn().mockReturnValue( + createMockExchangeClient({ + order: jest.fn().mockResolvedValue({ + status: 'ok', + response: { + data: { + statuses: [ + { + filled: { + oid: '456', + totalSz: '0.1', + avgPx: '50100', + }, + }, + ], + }, + }, + }), + }), + ); + + const orderParams: OrderParams = { + symbol: 'BTC', + isBuy: true, + size: '0.1', + orderType: 'market', + currentPrice: 50000, + }; + + const result = await provider.placeOrder(orderParams); + + expect(result.success).toBe(true); + expect(result.orderId).toBe('456'); + expect(result.filledSize).toBe('0.1'); + expect(result.averagePrice).toBe('50100'); + }); + + it('handles order with clientOrderId', async () => { + const orderParams: OrderParams = { + symbol: 'BTC', + isBuy: true, + size: '0.1', + orderType: 'market', + currentPrice: 50000, + clientOrderId: '0x123abc', + }; + + const result = await provider.placeOrder(orderParams); + + expect(result.success).toBe(true); + }); + + it('handles order with TP/SL and custom grouping', async () => { + const orderParams: OrderParams = { + symbol: 'BTC', + isBuy: true, + size: '0.1', + orderType: 'limit', + price: '51000', + takeProfitPrice: '55000', + stopLossPrice: '48000', + grouping: 'positionTpsl', + }; + + const result = await provider.placeOrder(orderParams); + + expect(result.success).toBe(true); + }); + }); + + describe('updatePositionTPSL error scenarios', () => { + it('handles WebSocket error in getPositions', async () => { + // Set up mock BEFORE creating fresh provider (provider calls metaAndAssetCtxs on init) + MockedHyperLiquidClientService.mockImplementation( + () => mockClientService, + ); + + // Create a fresh provider to test WebSocket errors + const freshProvider = createTestProvider(); + + // Mock getPositions to simulate the WebSocket error being handled + jest + .spyOn(freshProvider, 'getPositions') + .mockImplementation(async () => { + throw new Error('WebSocket connection failed'); + }); + + const updateParams = { + symbol: 'BTC', + takeProfitPrice: '55000', + }; + + const result = await freshProvider.updatePositionTPSL(updateParams); + + expect(result.success).toBe(false); + expect(result.error).toBe('WebSocket connection failed'); + }); + + it('handles non-WebSocket error in getPositions', async () => { + // Set up mock BEFORE creating fresh provider (provider calls metaAndAssetCtxs on init) + MockedHyperLiquidClientService.mockImplementation( + () => mockClientService, + ); + + // Create a fresh provider to test non-WebSocket errors + const freshProvider = createTestProvider(); + + // Mock getPositions to simulate a generic API error + jest + .spyOn(freshProvider, 'getPositions') + .mockImplementation(async () => { + throw new Error('Generic API error'); + }); + + const updateParams = { + symbol: 'BTC', + takeProfitPrice: '55000', + }; + + const result = await freshProvider.updatePositionTPSL(updateParams); + + expect(result.success).toBe(false); + expect(result.error).toContain('Generic API error'); + }); + + it('handles canceling existing TP/SL orders', async () => { + // Create provider with BTC in the asset mapping + provider = createTestProvider({ + initialAssetMapping: [['BTC', 0]], + }); + + // Mock position exists with existing TP/SL orders + mockClientService.getInfoClient = jest.fn().mockReturnValue( + createMockInfoClient({ + frontendOpenOrders: jest.fn().mockResolvedValue([ + { + coin: 'BTC', + oid: 123, + reduceOnly: true, + isTrigger: true, + isPositionTpsl: true, + orderType: 'Take Profit', + }, + { + coin: 'BTC', + oid: 124, + reduceOnly: true, + isTrigger: true, + isPositionTpsl: true, + orderType: 'Stop Loss', + }, + ]), + }), + ); + + mockClientService.getExchangeClient = jest.fn().mockReturnValue( + createMockExchangeClient({ + cancel: jest.fn().mockResolvedValue({ + status: 'ok', + response: { data: { statuses: ['success', 'success'] } }, + }), + order: jest.fn().mockResolvedValue({ + status: 'ok', + response: { data: { statuses: [{ resting: { oid: 999 } }] } }, + }), + }), + ); + + mockWalletService.getUserAddressWithDefault.mockResolvedValue('0x123'); + + const updateParams = { + symbol: 'BTC', + takeProfitPrice: '55000', + }; + + const result = await provider.updatePositionTPSL(updateParams); + + expect(result.success).toBe(true); + expect( + mockClientService.getExchangeClient().cancel, + ).toHaveBeenCalledWith({ + cancels: [ + { a: 0, o: 123 }, + { a: 0, o: 124 }, + ], + }); + }); + + it('falls back to REST when the cache shows standalone-looking triggers, cancelling only positionTpsl orders', async () => { + provider = createTestProvider({ + initialAssetMapping: [['BTC', 0]], + }); + + // Simulate WS cache with mixed orders: normalTpsl children (isPositionTpsl: false) + // from a pending limit order AND positionTpsl orders (isPositionTpsl: true) + const cachedOrders: Order[] = [ + { + orderId: '500', + symbol: 'BTC', + side: 'buy', + orderType: 'limit', + size: '0.01', + originalSize: '0.01', + price: '50000', + filledSize: '0', + remainingSize: '0.01', + status: 'open', + timestamp: 1000, + isTrigger: false, + reduceOnly: false, + isPositionTpsl: false, + }, + { + orderId: '501', + symbol: 'BTC', + side: 'sell', + orderType: 'limit', + size: '0.01', + originalSize: '0.01', + price: '60000', + filledSize: '0', + remainingSize: '0.01', + status: 'open', + timestamp: 1001, + isTrigger: true, + reduceOnly: true, + isPositionTpsl: false, + detailedOrderType: 'Take Profit Limit', + }, + { + orderId: '502', + symbol: 'BTC', + side: 'sell', + orderType: 'market', + size: '0.01', + originalSize: '0.01', + price: '40000', + filledSize: '0', + remainingSize: '0.01', + status: 'open', + timestamp: 1002, + isTrigger: true, + reduceOnly: true, + isPositionTpsl: false, + detailedOrderType: 'Stop Market', + }, + { + orderId: '503', + symbol: 'BTC', + side: 'sell', + orderType: 'limit', + size: '0', + originalSize: '0', + price: '58000', + filledSize: '0', + remainingSize: '0', + status: 'open', + timestamp: 1003, + isTrigger: true, + reduceOnly: true, + isPositionTpsl: true, + detailedOrderType: 'Take Profit Limit', + }, + { + orderId: '504', + symbol: 'BTC', + side: 'sell', + orderType: 'market', + size: '0', + originalSize: '0', + price: '42000', + filledSize: '0', + remainingSize: '0', + status: 'open', + timestamp: 1004, + isTrigger: true, + reduceOnly: true, + isPositionTpsl: true, + detailedOrderType: 'Stop Market', + }, + ]; + + mockSubscriptionService.getOrdersCacheIfInitialized = jest + .fn() + .mockReturnValue(cachedOrders); + + // The cache cannot tell a standalone trigger apart from another order's + // normalTpsl child, so the update falls back to the REST payload, which + // carries the parent/child links. + const restTrigger = ( + oid: number, + orderType: string, + isPositionTpsl: boolean, + ) => ({ + coin: 'BTC', + oid, + orderType, + isPositionTpsl, + reduceOnly: true, + isTrigger: true, + }); + const normalTpslChildren = [ + restTrigger(501, 'Take Profit Limit', false), + restTrigger(502, 'Stop Market', false), + ]; + const restOrders = [ + { + coin: 'BTC', + oid: 500, + orderType: 'Limit', + isPositionTpsl: false, + reduceOnly: false, + isTrigger: false, + children: normalTpslChildren, + }, + ...normalTpslChildren, + restTrigger(503, 'Take Profit Limit', true), + restTrigger(504, 'Stop Market', true), + ]; + + mockClientService.getInfoClient = jest.fn().mockReturnValue( + createMockInfoClient({ + frontendOpenOrders: jest.fn().mockResolvedValue(restOrders), + }), + ); + + const mockCancel = jest.fn().mockResolvedValue({ + status: 'ok', + response: { data: { statuses: ['success', 'success'] } }, + }); + mockClientService.getExchangeClient = jest.fn().mockReturnValue( + createMockExchangeClient({ + cancel: mockCancel, + order: jest.fn().mockResolvedValue({ + status: 'ok', + response: { + data: { + statuses: [ + { resting: { oid: 999 } }, + { resting: { oid: 1000 } }, + ], + }, + }, + }), + }), + ); + + mockWalletService.getUserAddressWithDefault.mockResolvedValue('0x123'); + + const result = await provider.updatePositionTPSL({ + symbol: 'BTC', + takeProfitPrice: '60000', + stopLossPrice: '40000', + }); + + expect(result.success).toBe(true); + // Must cancel positionTpsl orders (503, 504) only — not normalTpsl children (501, 502) + expect(mockCancel).toHaveBeenCalledWith({ + cancels: [ + { a: 0, o: 503 }, + { a: 0, o: 504 }, + ], + }); + }); + }); + + describe('getAccountState error handling', () => { + it('re-throws errors instead of returning zeros', async () => { + mockClientService.getInfoClient = jest.fn().mockReturnValue( + createMockInfoClient({ + clearinghouseState: jest + .fn() + .mockRejectedValue(new Error('Account state fetch failed')), + spotClearinghouseState: jest.fn().mockResolvedValue({ + balances: [{ coin: 'USDC', hold: '1000', total: '10000' }], + }), + }), + ); + + mockWalletService.getUserAddressWithDefault.mockResolvedValue('0x123'); + + await expect(provider.getAccountState()).rejects.toThrow( + 'Failed to fetch account state (failedDexs=[main], spotError=none)', + ); + }); + + it('returns partial account state when one HIP-3 DEX fails', async () => { + const hip3Provider = createTestProvider({ + hip3Enabled: true, + allowlistMarkets: ['xyz:*'], + }); + const mockInfoClient = createMockInfoClient({ + perpDexs: jest + .fn() + .mockResolvedValue([null, { name: 'xyz', url: 'https://xyz.com' }]), + clearinghouseState: jest + .fn() + .mockImplementation((params?: { dex?: string }) => { + if (params?.dex === 'xyz') { + return Promise.reject(new Error('xyz DEX unavailable')); + } + + return Promise.resolve({ + marginSummary: { + totalMarginUsed: '500', + accountValue: '10500', + }, + withdrawable: '9500', + assetPositions: [], + crossMarginSummary: { + accountValue: '10500', + totalMarginUsed: '500', + }, + }); + }), + spotClearinghouseState: jest.fn().mockResolvedValue({ + balances: [{ coin: 'USDC', hold: '1000', total: '10000' }], + }), + }); + + mockClientService.getInfoClient = jest + .fn() + .mockReturnValue(mockInfoClient); + mockWalletService.getUserAddressWithDefault.mockResolvedValue('0x123'); + + const accountState = await hip3Provider.getAccountState(); + + expect(parseFloat(accountState.totalBalance)).toBe(19500); // perps 10500 + spot.total 10000 - spot.hold 1000 + expect(parseFloat(accountState.marginUsed)).toBe(500); + expect(mockInfoClient.clearinghouseState).toHaveBeenCalledWith({ + user: '0x123', + dex: 'xyz', + }); + }); + }); + + describe('getMarketDataWithPrices error scenarios', () => { + it('handles missing perpsMeta', async () => { + mockClientService.getInfoClient = jest.fn().mockReturnValue( + createMockInfoClient({ + meta: jest.fn().mockResolvedValue(null), + allMids: jest.fn().mockResolvedValue({ BTC: '50000' }), + predictedFundings: jest.fn().mockResolvedValue([]), + metaAndAssetCtxs: jest.fn().mockResolvedValue([null, []]), + }), + ); + + await expect(provider.getMarketDataWithPrices()).rejects.toThrow( + /Failed to fetch market data - no markets available/, + ); + }); + + it('uses HTTP InfoClient for market data fetches', async () => { + mockClientService.getInfoClient = jest.fn().mockReturnValue( + createMockInfoClient({ + meta: jest.fn().mockResolvedValue({ + universe: [{ name: 'BTC', szDecimals: 3, maxLeverage: 50 }], + }), + allMids: jest.fn().mockResolvedValue({ BTC: '50000' }), + predictedFundings: jest.fn().mockResolvedValue([]), + metaAndAssetCtxs: jest.fn().mockResolvedValue([ + { universe: [{ name: 'BTC', szDecimals: 3, maxLeverage: 50 }] }, + [ + { + funding: '0.0001', + openInterest: '1000', + prevDayPx: '49000', + dayNtlVlm: '1000000', + markPx: '50000', + midPx: '50000', + oraclePx: '50000', + }, + ], + ]), + }), + ); + + const freshProvider = createTestProvider(); + await freshProvider.getMarketDataWithPrices(); + + expect(mockClientService.getInfoClient).toHaveBeenCalledWith({ + useHttp: true, + }); + }); + + it('prefers the last WebSocket allMids snapshot over REST when available', async () => { + const mockInfoClient = createMockInfoClient({ + metaAndAssetCtxs: jest.fn().mockResolvedValue([ + { universe: [{ name: 'BTC', szDecimals: 3, maxLeverage: 50 }] }, + [ + { + funding: '0.0001', + openInterest: '1000', + prevDayPx: '49000', + dayNtlVlm: '1000000', + markPx: '50000', + midPx: '50000', + oraclePx: '50000', + }, + ], + ]), + allMids: jest.fn().mockResolvedValue({ BTC: '49999' }), + }); + + mockClientService.getInfoClient = jest + .fn() + .mockReturnValue(mockInfoClient); + mockSubscriptionService.getLastAllMidsSnapshot.mockReturnValue({ + BTC: '51000', + }); + + const freshProvider = createTestProvider(); + const result = await freshProvider.getMarketDataWithPrices(); + + expect(result[0].price).toBe('$51000.00'); + expect(mockInfoClient.allMids).not.toHaveBeenCalled(); + }); + + it('includes diagnostic context in error when all DEX fetches fail', async () => { + mockClientService.getInfoClient = jest.fn().mockReturnValue( + createMockInfoClient({ + metaAndAssetCtxs: jest + .fn() + .mockRejectedValue(new Error('WebSocket timeout')), + allMids: jest + .fn() + .mockRejectedValue(new Error('WebSocket timeout')), + }), + ); + + await expect(provider.getMarketDataWithPrices()).rejects.toThrow( + /enabledDexs=.*failed=.*wsState=/, + ); + }); + + it('handles missing allMids', async () => { + // Set up mock BEFORE creating fresh provider + mockClientService.getInfoClient = jest.fn().mockReturnValue( + createMockInfoClient({ + meta: jest.fn().mockResolvedValue({ + universe: [{ name: 'BTC', szDecimals: 3, maxLeverage: 50 }], + }), + allMids: jest.fn().mockResolvedValue(null), + predictedFundings: jest.fn().mockResolvedValue([]), + metaAndAssetCtxs: jest.fn().mockResolvedValue([ + { universe: [{ name: 'BTC', szDecimals: 3, maxLeverage: 50 }] }, + [ + { + funding: '0.0001', + openInterest: '1000', + prevDayPx: '49000', + dayNtlVlm: '1000000', + markPx: '50000', + midPx: '50000', + oraclePx: '50000', + }, + ], + ]), + }), + ); + + // Create fresh provider to avoid cached state from other tests + const freshProvider = createTestProvider(); + + // Should gracefully handle missing price data with fallback + const result = await freshProvider.getMarketDataWithPrices(); + expect(Array.isArray(result)).toBe(true); + expect(result[0].price).toBe('$---'); // Fallback when allMids is null + }); + + it('handles meta and predictedFundings calls successfully', async () => { + // Set up mock BEFORE creating fresh provider + mockClientService.getInfoClient = jest.fn().mockReturnValue( + createMockInfoClient({ + meta: jest.fn().mockResolvedValue({ + universe: [{ name: 'BTC', szDecimals: 3, maxLeverage: 50 }], + }), + allMids: jest.fn().mockResolvedValue({ BTC: '50000' }), + predictedFundings: jest.fn().mockResolvedValue([]), + metaAndAssetCtxs: jest.fn().mockResolvedValue([ + { universe: [{ name: 'BTC', szDecimals: 3, maxLeverage: 50 }] }, + [ + { + funding: '0.001', + openInterest: '1000000', + prevDayPx: '49000', + dayNtlVlm: '1000000', + markPx: '50000', + midPx: '50000', + oraclePx: '50000', + }, + ], + ]), + }), + ); + + // Create fresh provider to avoid cached state from other tests + const freshProvider = createTestProvider(); + + const result = await freshProvider.getMarketDataWithPrices(); + + // Verify successful call with proper data structure + expect(Array.isArray(result)).toBe(true); + expect(result.length).toBeGreaterThan(0); + expect(result[0]).toHaveProperty('name'); + expect(result[0]).toHaveProperty('price'); + expect(result[0]).toHaveProperty('fundingRate'); + }); + + it('returns stale cached market data after retry failure', async () => { + jest.useFakeTimers(); + + try { + mockClientService.getInfoClient = jest.fn().mockReturnValue( + createMockInfoClient({ + meta: jest.fn().mockResolvedValue({ + universe: [{ name: 'BTC', szDecimals: 3, maxLeverage: 50 }], + }), + allMids: jest.fn().mockResolvedValue({ BTC: '50000' }), + metaAndAssetCtxs: jest.fn().mockResolvedValue([ + { universe: [{ name: 'BTC', szDecimals: 3, maxLeverage: 50 }] }, + [ + { + funding: '0.001', + openInterest: '1000000', + prevDayPx: '49000', + dayNtlVlm: '1000000', + markPx: '50000', + midPx: '50000', + oraclePx: '50000', + }, + ], + ]), + }), + ); + + const freshProvider = createTestProvider(); + const freshMarketData = await freshProvider.getMarketDataWithPrices(); + + expect(freshMarketData[0].isStale).toBe(false); + + await freshProvider.disconnect(); + + mockClientService.getInfoClient = jest.fn().mockReturnValue( + createMockInfoClient({ + metaAndAssetCtxs: jest + .fn() + .mockRejectedValue(new Error('market data unavailable')), + allMids: jest + .fn() + .mockRejectedValue(new Error('market data unavailable')), + }), + ); + + const staleMarketDataPromise = + freshProvider.getMarketDataWithPrices(); + await jest.advanceTimersByTimeAsync(2000); + const staleMarketData = await staleMarketDataPromise; + + expect(staleMarketData[0].symbol).toBe(freshMarketData[0].symbol); + expect(staleMarketData[0].isStale).toBe(true); + } finally { + jest.useRealTimers(); + } + }); + + it('recovers on retry when cached meta refresh initially returns mismatched asset contexts', async () => { + jest.useFakeTimers(); + + try { + const mainMeta = { + universe: [{ name: 'BTC', szDecimals: 3, maxLeverage: 50 }], + }; + const mainAssetCtx = { + funding: '0.001', + openInterest: '1000000', + prevDayPx: '49000', + dayNtlVlm: '1000000', + markPx: '50000', + midPx: '50000', + oraclePx: '50000', + }; + let metaAndAssetCtxsCallCount = 0; + + const mockInfoClient = createMockInfoClient({ + metaAndAssetCtxs: jest.fn().mockImplementation(() => { + metaAndAssetCtxsCallCount += 1; + + if (metaAndAssetCtxsCallCount === 1) { + return Promise.resolve([mainMeta, [mainAssetCtx]]); + } + + if (metaAndAssetCtxsCallCount === 2) { + return Promise.resolve([mainMeta, []]); + } + + return Promise.resolve([mainMeta, [mainAssetCtx]]); + }), + allMids: jest.fn().mockResolvedValue({ BTC: '50000' }), + }); + + mockClientService.getInfoClient = jest + .fn() + .mockReturnValue(mockInfoClient); + mockSubscriptionService.getDexAssetCtxsCache.mockReturnValue([]); + + const freshProvider = createTestProvider(); + const resultPromise = freshProvider.getMarketDataWithPrices(); + + await jest.advanceTimersByTimeAsync(2000); + + const result = await resultPromise; + + expect(result).toEqual([ + expect.objectContaining({ + symbol: 'BTC', + price: '$50000.00', + isStale: false, + }), + ]); + expect(metaAndAssetCtxsCallCount).toBe(3); + expect(mockInfoClient.allMids).toHaveBeenCalledTimes(1); + expect(mockPlatformDependencies.debugLogger.log).toHaveBeenCalledWith( + '[getMarketDataWithPrices] Retry succeeded', + expect.objectContaining({ + marketCount: 1, + }), + ); + } finally { + jest.useRealTimers(); + } + }); + + it('excludes a cached-meta DEX when assetCtx refresh fails and no aligned ctx cache exists', async () => { + const xyzMeta = { + universe: [{ name: 'xyz:XYZ100', szDecimals: 2, maxLeverage: 20 }], + }; + const xyzAssetCtx = { + funding: '0.0002', + openInterest: '250', + prevDayPx: '40', + dayNtlVlm: '20000', + markPx: '42', + midPx: '42', + oraclePx: '42', + }; + const mainMeta = { + universe: [{ name: 'BTC', szDecimals: 3, maxLeverage: 50 }], + }; + const mainAssetCtx = { + funding: '0.001', + openInterest: '1000000', + prevDayPx: '49000', + dayNtlVlm: '1000000', + markPx: '50000', + midPx: '50000', + oraclePx: '50000', + }; + + const xyzMetaFetches = { count: 0 }; + const mockInfoClient = createMockInfoClient({ + perpDexs: jest + .fn() + .mockResolvedValue([null, { name: 'xyz', url: 'https://xyz.com' }]), + metaAndAssetCtxs: jest + .fn() + .mockImplementation((params?: { dex?: string }) => { + if (params?.dex === 'xyz') { + xyzMetaFetches.count += 1; + if (xyzMetaFetches.count === 1) { + return Promise.resolve([xyzMeta, [xyzAssetCtx]]); + } + + return Promise.reject( + new Error('xyz assetCtxs refresh unavailable'), + ); + } + + return Promise.resolve([mainMeta, [mainAssetCtx]]); + }), + allMids: jest.fn().mockImplementation((params?: { dex?: string }) => { + if (params?.dex === 'xyz') { + return Promise.resolve({ 'xyz:XYZ100': '42' }); + } + + return Promise.resolve({ BTC: '50000' }); + }), + }); + + mockClientService.getInfoClient = jest + .fn() + .mockReturnValue(mockInfoClient); + + const hip3Provider = createTestProvider({ + hip3Enabled: true, + allowlistMarkets: ['xyz:*'], + }); + + const result = await hip3Provider.getMarketDataWithPrices(); + + expect(result.map((market) => market.symbol)).toEqual(['BTC']); + expect(mockInfoClient.metaAndAssetCtxs).toHaveBeenCalledWith({ + dex: 'xyz', + }); + }); + + it('excludes a non-USDC-collateral HIP-3 DEX from fresh market data results (TAT-3304)', async () => { + const mainMeta = { + universe: [{ name: 'BTC', szDecimals: 3, maxLeverage: 50 }], + }; + const mainAssetCtx = { + funding: '0.001', + openInterest: '1000000', + prevDayPx: '49000', + dayNtlVlm: '1000000', + markPx: '50000', + midPx: '50000', + oraclePx: '50000', + }; + // collateralToken: 5 resolves to USDH (not USDC) in the spotMeta below + const xyzMeta = { + universe: [{ name: 'xyz:XYZ100', szDecimals: 2, maxLeverage: 20 }], + collateralToken: 5, + }; + const xyzAssetCtx = { + funding: '0.0002', + openInterest: '250', + prevDayPx: '40', + dayNtlVlm: '20000', + markPx: '42', + midPx: '42', + oraclePx: '42', + }; + + const mockInfoClient = createMockInfoClient({ + perpDexs: jest + .fn() + .mockResolvedValue([null, { name: 'xyz', url: 'https://xyz.com' }]), + metaAndAssetCtxs: jest + .fn() + .mockImplementation((params?: { dex?: string }) => + params?.dex === 'xyz' + ? Promise.resolve([xyzMeta, [xyzAssetCtx]]) + : Promise.resolve([mainMeta, [mainAssetCtx]]), + ), + allMids: jest.fn().mockImplementation((params?: { dex?: string }) => { + if (params?.dex === 'xyz') { + return Promise.resolve({ 'xyz:XYZ100': '42' }); + } + + return Promise.resolve({ BTC: '50000' }); + }), + spotMeta: jest.fn().mockResolvedValue({ + tokens: [ + { name: 'USDC', tokenId: '0xdef456', index: 0 }, + { name: 'USDH', tokenId: '0xabc123', index: 5 }, + ], + universe: [], + }), + }); + + mockClientService.getInfoClient = jest + .fn() + .mockReturnValue(mockInfoClient); + + const hip3Provider = createTestProvider({ + hip3Enabled: true, + allowlistMarkets: ['xyz:*'], + }); + + const result = await hip3Provider.getMarketDataWithPrices(); + + expect(result.map((market) => market.symbol)).toEqual(['BTC']); + }); + + it('does not exclude a USDC-collateral HIP-3 DEX from fresh market data results', async () => { + const mainMeta = { + universe: [{ name: 'BTC', szDecimals: 3, maxLeverage: 50 }], + }; + const mainAssetCtx = { + funding: '0.001', + openInterest: '1000000', + prevDayPx: '49000', + dayNtlVlm: '1000000', + markPx: '50000', + midPx: '50000', + oraclePx: '50000', + }; + const xyzMeta = { + universe: [{ name: 'xyz:XYZ100', szDecimals: 2, maxLeverage: 20 }], + collateralToken: 0, + }; + const xyzAssetCtx = { + funding: '0.0002', + openInterest: '250', + prevDayPx: '40', + dayNtlVlm: '20000', + markPx: '42', + midPx: '42', + oraclePx: '42', + }; + + const mockInfoClient = createMockInfoClient({ + perpDexs: jest + .fn() + .mockResolvedValue([null, { name: 'xyz', url: 'https://xyz.com' }]), + metaAndAssetCtxs: jest + .fn() + .mockImplementation((params?: { dex?: string }) => + params?.dex === 'xyz' + ? Promise.resolve([xyzMeta, [xyzAssetCtx]]) + : Promise.resolve([mainMeta, [mainAssetCtx]]), + ), + allMids: jest.fn().mockImplementation((params?: { dex?: string }) => { + if (params?.dex === 'xyz') { + return Promise.resolve({ 'xyz:XYZ100': '42' }); + } + + return Promise.resolve({ BTC: '50000' }); + }), + spotMeta: jest.fn().mockResolvedValue({ + tokens: [{ name: 'USDC', tokenId: '0xdef456', index: 0 }], + universe: [], + }), + }); + + mockClientService.getInfoClient = jest + .fn() + .mockReturnValue(mockInfoClient); + + const hip3Provider = createTestProvider({ + hip3Enabled: true, + allowlistMarkets: ['xyz:*'], + }); + + const result = await hip3Provider.getMarketDataWithPrices(); + + expect(result.map((market) => market.symbol).sort()).toEqual([ + 'BTC', + 'xyz:XYZ100', + ]); + }); + + it('excludes a non-USDC-collateral HIP-3 DEX from the stale cached market data snapshot (TAT-3304)', async () => { + jest.useFakeTimers(); + + try { + const mainMeta = { + universe: [{ name: 'BTC', szDecimals: 3, maxLeverage: 50 }], + }; + const mainAssetCtx = { + funding: '0.001', + openInterest: '1000000', + prevDayPx: '49000', + dayNtlVlm: '1000000', + markPx: '50000', + midPx: '50000', + oraclePx: '50000', + }; + // collateralToken: 5 resolves to USDH (not USDC) in the spotMeta below + const xyzMeta = { + universe: [{ name: 'xyz:XYZ100', szDecimals: 2, maxLeverage: 20 }], + collateralToken: 5, + }; + const xyzAssetCtx = { + funding: '0.0002', + openInterest: '250', + prevDayPx: '40', + dayNtlVlm: '20000', + markPx: '42', + midPx: '42', + oraclePx: '42', + }; + + mockClientService.getInfoClient = jest.fn().mockReturnValue( + createMockInfoClient({ + perpDexs: jest + .fn() + .mockResolvedValue([ + null, + { name: 'xyz', url: 'https://xyz.com' }, + ]), + metaAndAssetCtxs: jest + .fn() + .mockImplementation((params?: { dex?: string }) => + params?.dex === 'xyz' + ? Promise.resolve([xyzMeta, [xyzAssetCtx]]) + : Promise.resolve([mainMeta, [mainAssetCtx]]), + ), + allMids: jest + .fn() + .mockImplementation((params?: { dex?: string }) => { + if (params?.dex === 'xyz') { + return Promise.resolve({ 'xyz:XYZ100': '42' }); + } + + return Promise.resolve({ BTC: '50000' }); + }), + spotMeta: jest.fn().mockResolvedValue({ + tokens: [ + { name: 'USDC', tokenId: '0xdef456', index: 0 }, + { name: 'USDH', tokenId: '0xabc123', index: 5 }, + ], + universe: [], + }), + }), + ); + + const hip3Provider = createTestProvider({ + hip3Enabled: true, + allowlistMarkets: ['xyz:*'], + }); + + // Prime the cache: xyz is already excluded from the fresh snapshot, + // so #cachedMarketDataWithPrices never contains the non-USDC market. + const freshMarketData = await hip3Provider.getMarketDataWithPrices(); + expect(freshMarketData.map((market) => market.symbol)).toEqual([ + 'BTC', + ]); + + await hip3Provider.disconnect(); + + mockClientService.getInfoClient = jest.fn().mockReturnValue( + createMockInfoClient({ + perpDexs: jest + .fn() + .mockResolvedValue([ + null, + { name: 'xyz', url: 'https://xyz.com' }, + ]), + metaAndAssetCtxs: jest + .fn() + .mockRejectedValue(new Error('market data unavailable')), + allMids: jest + .fn() + .mockRejectedValue(new Error('market data unavailable')), + }), + ); + + const staleMarketDataPromise = hip3Provider.getMarketDataWithPrices(); + await jest.advanceTimersByTimeAsync(2000); + const staleMarketData = await staleMarketDataPromise; + + expect(staleMarketData.map((market) => market.symbol)).toEqual([ + 'BTC', + ]); + expect(staleMarketData[0].isStale).toBe(true); + } finally { + jest.useRealTimers(); + } + }); + }); + + describe('withdrawal edge cases', () => { + it('handles withdrawal without destination (use current user)', async () => { + mockWalletService.getUserAddressWithDefault.mockResolvedValue( + '0xdefaultaddress', + ); + + mockClientService.getExchangeClient = jest.fn().mockReturnValue({ + withdraw3: jest.fn().mockResolvedValue({ status: 'ok' }), + }); + + // Mock account state for balance validation + Object.defineProperty(provider, 'getAccountState', { + value: jest.fn().mockResolvedValue({ + spendableBalance: '5000', + withdrawableBalance: '5000', + }), + writable: true, + }); + + const withdrawParams = { + amount: '1000', + // No destination provided - should use current user address + assetId: + 'eip155:42161/erc20:0xaf88d065e77c8cC2239327C5EDb3A432268e5831/default' as CaipAssetId, + }; + + const result = await provider.withdraw(withdrawParams); + + expect(result.success).toBe(true); + expect( + mockClientService.getExchangeClient().withdraw3, + ).toHaveBeenCalledWith({ + destination: '0xdefaultaddress', + amount: '1000', + }); + }); + + it('validates withdrawal against withdrawableBalance populated by spot fold for Unified Account', async () => { + const exchangeClient = createMockExchangeClient(); + mockClientService.getExchangeClient = jest + .fn() + .mockReturnValue(exchangeClient); + + Object.defineProperty(provider, 'getAccountState', { + value: jest.fn().mockResolvedValue({ + spendableBalance: '2500', + withdrawableBalance: '2500', + totalBalance: '2500', + marginUsed: '0', + unrealizedPnl: '0', + returnOnEquity: '0', + }), + writable: true, + }); + + const withdrawParams = { + amount: '1000', + destination: '0x1234567890123456789012345678901234567890' as Hex, + assetId: + 'eip155:42161/erc20:0xaf88d065e77c8cC2239327C5EDb3A432268e5831/default' as CaipAssetId, + }; + + const result = await provider.withdraw(withdrawParams); + + expect(result.success).toBe(true); + expect(mockValidateBalance).toHaveBeenCalledWith(1000, 2500); + expect(exchangeClient.withdraw3).toHaveBeenCalledWith({ + destination: '0x1234567890123456789012345678901234567890', + amount: '1000', + }); + }); + + it('handles withdrawal API error', async () => { + mockClientService.getExchangeClient = jest.fn().mockReturnValue({ + withdraw3: jest.fn().mockResolvedValue({ + status: 'insufficient_funds', + message: 'Not enough balance', + }), + }); + + // Mock account state for balance validation + Object.defineProperty(provider, 'getAccountState', { + value: jest.fn().mockResolvedValue({ + spendableBalance: '5000', + withdrawableBalance: '5000', + }), + writable: true, + }); + + const withdrawParams = { + amount: '1000', + destination: '0x1234567890123456789012345678901234567890' as Hex, + assetId: + 'eip155:42161/erc20:0xaf88d065e77c8cC2239327C5EDb3A432268e5831/default' as CaipAssetId, + }; + + const result = await provider.withdraw(withdrawParams); + + expect(result.success).toBe(false); + expect(result.error).toContain('Withdrawal failed: insufficient_funds'); + }); + }); + + describe('liquidation price edge cases', () => { + it('handles denominator close to zero', async () => { + // Create scenario where denominator approaches zero + // For denominator = 1 - l * side to be close to 0 with long (side = 1): + // We need l very close to 1, so maintenanceLeverage very close to 1 + // With maxLeverage = 0.50005, maintenanceLeverage = 1.0001, l = 0.9999 + // denominator = 1 - 0.9999 * 1 = 0.0001 (right at the threshold) + // Need slightly larger to go below 0.0001: maxLeverage = 0.50001 → maintenanceLeverage = 1.00002 + // l = 0.99998, denominator = 0.00002 < 0.0001 ✓ triggers edge case + const params = { + entryPrice: 50000, + leverage: 1, // Use 1x leverage + direction: 'long' as const, + asset: 'BTC', + }; + + mockClientService.getInfoClient = jest.fn().mockReturnValue( + createMockInfoClient({ + meta: jest.fn().mockResolvedValue({ + universe: [{ name: 'BTC', szDecimals: 3, maxLeverage: 0.50001 }], // Very low to create denominator < 0.0001 + }), + }), + ); + + const result = await provider.calculateLiquidationPrice(params); + + // Should return entry price when denominator is too small (< 0.0001 threshold) + expect(parseFloat(result)).toBeCloseTo(50000, 0); + }); + + it('handles liquidation price calculation error', async () => { + // Mock getMaxLeverage to throw an error but still use default + const consoleSpy = jest.spyOn(console, 'log').mockImplementation(); + + mockClientService.getInfoClient = jest.fn().mockReturnValue({ + meta: jest.fn().mockRejectedValue(new Error('Network error')), + }); + + const params = { + entryPrice: 50000, + leverage: 2, + direction: 'long' as const, + asset: 'UNKNOWN_ASSET', + }; + + const result = await provider.calculateLiquidationPrice(params); + + // Should use default leverage and still calculate + expect(parseFloat(result)).toBeGreaterThan(0); + + consoleSpy.mockRestore(); + }); + + it('handles negative liquidation price', async () => { + // Create scenario that might result in negative liquidation price + const params = { + entryPrice: 100, + leverage: 2, + direction: 'long' as const, + asset: 'BTC', + }; + + mockClientService.getInfoClient = jest + .fn() + .mockReturnValue(createMockInfoClient()); + + const result = await provider.calculateLiquidationPrice(params); + + // Should never return negative price + expect(parseFloat(result)).toBeGreaterThanOrEqual(0); + }); + }); + + describe('isReadyToTrade edge cases', () => { + it('handles getCurrentAccountId throwing error', async () => { + mockWalletService.getCurrentAccountId.mockImplementation(() => { + throw new Error('No account found'); + }); + + const result = await provider.isReadyToTrade(); + + expect(result.ready).toBe(false); + expect(result.walletConnected).toBe(true); // Clients exist + expect(result.networkSupported).toBe(true); + }); + + it('handles missing exchange or info client', async () => { + mockClientService.getExchangeClient.mockReturnValue(null as any); + + const result = await provider.isReadyToTrade(); + + expect(result.ready).toBe(false); + expect(result.walletConnected).toBe(false); + }); + + it('handles general error in readiness check', async () => { + mockClientService.getExchangeClient.mockImplementation(() => { + throw new Error('Client error'); + }); + + const result = await provider.isReadyToTrade(); + + expect(result.ready).toBe(false); + expect(result.walletConnected).toBe(false); + expect(result.networkSupported).toBe(false); + expect(result.error).toContain('Client error'); + }); + }); + + describe('editOrder error scenarios', () => { + it('handles edit order API failure', async () => { + mockClientService.getExchangeClient = jest.fn().mockReturnValue({ + modify: jest.fn().mockResolvedValue({ + status: 'error', + response: { message: 'Order not found' }, + }), + }); + + const editParams = { + orderId: '999', + newOrder: { + symbol: 'BTC', + isBuy: true, + size: '0.2', + price: '52000', + orderType: 'limit', + } as OrderParams, + }; + + const result = await provider.editOrder(editParams); + + expect(result.success).toBe(false); + }); + }); + + describe('cancelOrder error scenarios', () => { + it('handles cancel order API returning non-success status', async () => { + mockClientService.getExchangeClient = jest.fn().mockReturnValue({ + cancel: jest.fn().mockResolvedValue({ + status: 'ok', + response: { data: { statuses: ['failed'] } }, + }), + }); + + const cancelParams = { + orderId: '123', + symbol: 'BTC', + }; + + const result = await provider.cancelOrder(cancelParams); + + expect(result.success).toBe(false); + expect(result.error).toBe('Order cancellation failed'); + }); + + it.each([ + ['multi-sig required', PERPS_ERROR_CODES.EXCHANGE_MULTI_SIG_REQUIRED], + ['invalid nonce', PERPS_ERROR_CODES.EXCHANGE_INVALID_NONCE], + ])( + 'maps HyperLiquid "%s" cancel rejection to %s with abstraction-mode context', + async (exchangeMessage, expectedCode) => { + mockClientService.getExchangeClient = jest.fn().mockReturnValue( + createMockExchangeClient({ + cancel: jest.fn().mockRejectedValue(new Error(exchangeMessage)), + }), + ); + mockSubscriptionService.getCachedAbstractionMode.mockReturnValue( + 'dexAbstraction', + ); + + const result = await provider.cancelOrder({ + orderId: '123', + symbol: 'BTC', + }); + + expect(result.success).toBe(false); + expect(result.error).toBe(expectedCode); + expect(mockPlatformDependencies.logger.error).toHaveBeenCalledWith( + expect.objectContaining({ message: expectedCode }), + expect.objectContaining({ + context: expect.objectContaining({ + data: expect.objectContaining({ + abstraction_mode: 'dexAbstraction', + }), + }), + }), + ); + }, + ); + + it.each([ + ['multi-sig required', PERPS_ERROR_CODES.EXCHANGE_MULTI_SIG_REQUIRED], + ['invalid nonce', PERPS_ERROR_CODES.EXCHANGE_INVALID_NONCE], + ])( + 'maps a non-thrown "%s" cancel status rejection to %s', + async (exchangeMessage, expectedCode) => { + // HyperLiquid usually rejects a cancel by resolving with a status + // object rather than throwing, so this is the common failure shape. + mockClientService.getExchangeClient = jest.fn().mockReturnValue( + createMockExchangeClient({ + cancel: jest.fn().mockResolvedValue({ + status: 'ok', + response: { + data: { statuses: [{ error: exchangeMessage }] }, + }, + }), + }), + ); + + const result = await provider.cancelOrder({ + orderId: '123', + symbol: 'BTC', + }); + + expect(result.success).toBe(false); + expect(result.orderId).toBe('123'); + expect(result.error).toBe(expectedCode); + }, + ); + + it('preserves an unmapped cancel status error string', async () => { + mockClientService.getExchangeClient = jest.fn().mockReturnValue( + createMockExchangeClient({ + cancel: jest.fn().mockResolvedValue({ + status: 'ok', + response: { + data: { + statuses: [ + { + error: + 'cancel 0: Order was never placed, already canceled, or filled. asset=4', + }, + ], + }, + }, + }), + }), + ); + + const result = await provider.cancelOrder({ + orderId: '123', + symbol: 'BTC', + }); + + expect(result.success).toBe(false); + expect(result.error).toBe( + 'cancel 0: Order was never placed, already canceled, or filled. asset=4', + ); + }); + }); + + describe('account-mode exchange error mapping', () => { + it.each([ + ['multi-sig required', PERPS_ERROR_CODES.EXCHANGE_MULTI_SIG_REQUIRED], + ['invalid nonce', PERPS_ERROR_CODES.EXCHANGE_INVALID_NONCE], + ])( + 'maps HyperLiquid "%s" order rejection to %s with abstraction-mode context', + async (exchangeMessage, expectedCode) => { + mockClientService.getExchangeClient = jest.fn().mockReturnValue( + createMockExchangeClient({ + order: jest.fn().mockRejectedValue(new Error(exchangeMessage)), + }), + ); + mockSubscriptionService.getCachedAbstractionMode.mockReturnValue( + 'unifiedAccount', + ); + + const result = await provider.placeOrder({ + symbol: 'BTC', + isBuy: true, + size: '0.1', + orderType: 'market', + currentPrice: 50000, + }); + + expect(result.success).toBe(false); + expect(result.error).toBe(expectedCode); + expect(mockPlatformDependencies.logger.error).toHaveBeenCalledWith( + expect.objectContaining({ message: expectedCode }), + expect.objectContaining({ + context: expect.objectContaining({ + data: expect.objectContaining({ + abstraction_mode: 'unifiedAccount', + }), + }), + }), + ); + }, + ); + }); + + describe('calculateFees', () => { + beforeEach(() => { + // Reset userFees mock for each test + getMockUserFees().mockClear(); + // Default to throw error (will use base rates) + mockWalletService.getUserAddressWithDefault.mockRejectedValue( + new Error('No wallet connected'), + ); + }); + + it('calculates fees for market orders', async () => { + const result = await provider.calculateFees({ + orderType: 'market', + isMaker: false, + amount: '100000', + symbol: 'BTC', + }); + + expect(result.feeRate).toBe(0.00145); // 0.045% taker + 0.1% MetaMask fee + expect(result.feeAmount).toBe(145); // 100000 * 0.00145 + }); + + it('calculates fees for limit orders as taker', async () => { + const result = await provider.calculateFees({ + orderType: 'limit', + isMaker: false, + amount: '100000', + symbol: 'BTC', + }); + + expect(result.feeRate).toBe(0.00145); // 0.045% taker + 0.1% MetaMask fee + expect(result.feeAmount).toBe(145); // Includes MetaMask fee + }); + + it('calculates fees for limit orders as maker', async () => { + const result = await provider.calculateFees({ + orderType: 'limit', + isMaker: true, + amount: '100000', + symbol: 'BTC', + }); + + expect(result.feeRate).toBe(0.00115); // 0.015% maker + 0.1% MetaMask fee + expect(result.feeAmount).toBeCloseTo(115, 10); // Includes MetaMask fee + }); + + it('handles undefined amount', async () => { + const result = await provider.calculateFees({ + orderType: 'market', + isMaker: false, + symbol: 'BTC', + }); + + expect(result.feeRate).toBe(0.00145); // Includes 0.1% MetaMask fee + expect(result.feeAmount).toBeUndefined(); + }); + + it('uses cached user-specific fee rates when available', async () => { + // Reset mock and set user address to trigger user fee fetching + getMockUserFees().mockClear(); + getMockUserFees().mockResolvedValue({ + userCrossRate: '0.00045', // 0.045% base taker rate + userAddRate: '0.00015', // 0.015% base maker rate + userSpotCrossRate: '0.00070', // 0.070% spot taker rate + userSpotAddRate: '0.00040', // 0.040% spot maker rate + activeReferralDiscount: '0.04', // 4% referral discount + activeStakingDiscount: { discount: '0.05' }, // 5% staking discount + dailyUserVlm: [], + }); + mockWalletService.getUserAddressWithDefault.mockResolvedValue('0x123'); + + // First call should fetch from API + const result1 = await provider.calculateFees({ + orderType: 'market', + isMaker: false, + amount: '100000', + symbol: 'BTC', + }); + + // Should use dynamically calculated rate: 0.045% * (1 - 0.04 - 0.05) = 0.045% * 0.91 = 0.04095% + expect(result1.feeRate).toBeCloseTo(0.0014095, 6); // Dynamic rate + 0.1% MetaMask + expect(result1.feeAmount).toBeCloseTo(140.95, 2); // 100000 * 0.0014095 + expect( + mockClientService.getInfoClient().userFees, + ).toHaveBeenCalledTimes(1); + + // Second call should use cache + const result2 = await provider.calculateFees({ + orderType: 'market', + isMaker: false, + amount: '100000', + symbol: 'BTC', + }); + + expect(result2.feeRate).toBeCloseTo(0.0014095, 6); // Includes MetaMask fee + expect(result2.feeAmount).toBeCloseTo(140.95, 2); // Includes MetaMask fee + // Should not call API again (cached) + expect( + mockClientService.getInfoClient().userFees, + ).toHaveBeenCalledTimes(1); + }); + + it('does not cache user fee rates after the selected account changes', async () => { + const userFees = getMockUserFees(); + userFees + .mockResolvedValueOnce({ + userCrossRate: '0.0002', + userAddRate: '0.0001', + userSpotCrossRate: '0.0004', + userSpotAddRate: '0.0002', + activeReferralDiscount: '0', + activeStakingDiscount: null, + }) + .mockResolvedValueOnce({ + userCrossRate: '0.0003', + userAddRate: '0.0001', + userSpotCrossRate: '0.0004', + userSpotAddRate: '0.0002', + activeReferralDiscount: '0', + activeStakingDiscount: null, + }); + mockWalletService.getUserAddressWithDefault + .mockResolvedValueOnce('0x123') + .mockResolvedValue('0x456'); + + await provider.calculateFees({ + orderType: 'market', + amount: '100000', + symbol: 'BTC', + }); + const currentFees = await provider.calculateFees({ + orderType: 'market', + amount: '100000', + symbol: 'BTC', + }); + const cachedCurrentFees = await provider.calculateFees({ + orderType: 'market', + amount: '100000', + symbol: 'BTC', + }); + + expect(currentFees.protocolFeeRate).toBe(0.0003); + expect(cachedCurrentFees.protocolFeeRate).toBe(0.0003); + expect(userFees).toHaveBeenCalledTimes(2); + }); + + it('rejects user fee results that resolve after disconnect', async () => { + const userFeesRequestStarted = createDeferred(); + const pendingUserFees = createDeferred<{ + userCrossRate: string; + userAddRate: string; + userSpotCrossRate: string; + userSpotAddRate: string; + activeReferralDiscount: string; + activeStakingDiscount: null; + }>(); + const userFees = getMockUserFees() + .mockImplementationOnce(() => { + userFeesRequestStarted.resolve(); + return pendingUserFees.promise; + }) + .mockResolvedValue({ + userCrossRate: '0.0002', + userAddRate: '0.0001', + userSpotCrossRate: '0.0004', + userSpotAddRate: '0.0002', + activeReferralDiscount: '0', + activeStakingDiscount: null, + }); + mockWalletService.getUserAddressWithDefault.mockResolvedValue('0x123'); + + const staleFees = provider.calculateFees({ + orderType: 'market', + amount: '100000', + symbol: 'BTC', + }); + await userFeesRequestStarted.promise; + const disconnectPromise = provider.disconnect(); + await Promise.resolve(); + pendingUserFees.resolve({ + userCrossRate: '0.0003', + userAddRate: '0.0001', + userSpotCrossRate: '0.0004', + userSpotAddRate: '0.0002', + activeReferralDiscount: '0', + activeStakingDiscount: null, + }); + + await expect(staleFees).rejects.toThrow( + PERPS_ERROR_CODES.PROVIDER_LIFECYCLE_STALE, + ); + await expect(disconnectPromise).resolves.toStrictEqual({ + success: true, + }); + + await expect(provider.initialize()).resolves.toMatchObject({ + success: true, + }); + await expect( + provider.calculateFees({ + orderType: 'market', + amount: '100000', + symbol: 'BTC', + }), + ).resolves.toMatchObject({ protocolFeeRate: 0.0002 }); + expect(userFees).toHaveBeenCalledTimes(2); + }); + + it('falls back to base rates on API failure', async () => { + // Reset and mock user address + getMockUserFees().mockClear(); + mockWalletService.getUserAddressWithDefault.mockResolvedValue('0x123'); + + // Mock API failure + getMockUserFees().mockRejectedValue(new Error('API Error')); + + const result = await provider.calculateFees({ + orderType: 'market', + isMaker: false, + amount: '100000', + symbol: 'BTC', + }); + + // Should use base rates on failure + expect(result.feeRate).toBe(0.00145); // Includes 0.1% MetaMask fee // Base taker rate + expect(result.feeAmount).toBe(145); // Includes MetaMask fee + }); + + it.each([ + ['', 0], + ['.5', 0.5], + ['1e2', 100], + [' 100 ', 100], + ])('quotes compatible fee amount %p', async (amount, expectedAmount) => { + const result = await provider.calculateFees({ + orderType: 'market', + isMaker: false, + amount, + symbol: 'BTC', + }); + + expect(result.feeAmount).toBeCloseTo(expectedAmount * 0.00145, 10); + }); + + it.each(['invalid', '-1', 'Infinity', '0x10'])( + 'returns a zero quote for unusable fee amount %p', + async (amount) => { + const result = await provider.calculateFees({ + orderType: 'market', + isMaker: false, + amount, + symbol: 'BTC', + }); + + expect(result).toMatchObject({ + feeAmount: 0, + protocolFeeAmount: 0, + metamaskFeeAmount: 0, + }); + }, + ); + + it('returns FeeCalculationResult with correct structure', async () => { + const result = await provider.calculateFees({ + orderType: 'market', + isMaker: false, + amount: '100000', + symbol: 'BTC', + }); + + expect(result).toHaveProperty('feeRate'); + expect(result).toHaveProperty('feeAmount'); + expect(typeof result.feeRate).toBe('number'); + expect(typeof result.feeAmount).toBe('number'); + }); + + it('is async and return a Promise', () => { + const result = provider.calculateFees({ + orderType: 'market', + isMaker: false, + symbol: 'BTC', + }); + + expect(result).toBeInstanceOf(Promise); + }); + + it('fetches user-specific fee rates when wallet is connected', async () => { + const testAddress = '0xTestAddress123'; + mockWalletService.getUserAddressWithDefault.mockResolvedValue( + testAddress, + ); + + // Mock user fees API response with base rates and discounts + getMockUserFees().mockResolvedValue({ + userCrossRate: '0.00045', // 0.045% base taker rate + userAddRate: '0.00015', // 0.015% base maker rate + userSpotCrossRate: '0.00070', // 0.070% spot taker rate + userSpotAddRate: '0.00040', // 0.040% spot maker rate + activeReferralDiscount: '0.04', // 4% referral discount + activeStakingDiscount: null, // No staking discount + }); + + const result = await provider.calculateFees({ + orderType: 'market', + isMaker: false, + amount: '100000', + symbol: 'BTC', + }); + + expect(result.feeRate).toBeCloseTo(0.001432, 6); // 0.045% * (1 - 0.04) + 0.1% MetaMask + expect(result.feeAmount).toBeCloseTo(143.2, 2); // Includes MetaMask fee + }); + + it('falls back to base rates when API returns invalid fee rates', async () => { + const testAddress = '0xTestAddress123'; + mockWalletService.getUserAddressWithDefault.mockResolvedValue( + testAddress, + ); + + // Mock user fees API response with invalid rates that will produce NaN + getMockUserFees().mockResolvedValue({ + userCrossRate: 'invalid', // Will cause parseFloat to return NaN + userAddRate: 'invalid', + activeReferralDiscount: 'invalid', + activeStakingDiscount: null, + }); + + const result = await provider.calculateFees({ + orderType: 'market', + isMaker: false, + amount: '100000', + symbol: 'BTC', + }); + + // Should fall back to base rates due to validation failure + expect(result.feeRate).toBe(0.00145); // Includes 0.1% MetaMask fee // Base taker rate + expect(result.feeAmount).toBe(145); // Includes MetaMask fee + }); + + it.each([ + ['partial rate', { userCrossRate: '0.0003junk' }], + ['non-finite rate', { userCrossRate: 'Infinity' }], + ['out-of-range rate', { userCrossRate: '1.1' }], + ['out-of-range discount', { activeReferralDiscount: '1.1' }], + ['negative discount', { activeReferralDiscount: '-0.1' }], + [ + 'out-of-range staking discount', + { activeStakingDiscount: { discount: '1.1' } }, + ], + [ + 'negative staking discount', + { activeStakingDiscount: { discount: '-0.1' } }, + ], + ])('falls back to base rates for %s', async (_label, override) => { + mockWalletService.getUserAddressWithDefault.mockResolvedValue( + '0xTestAddress123', + ); + getMockUserFees().mockResolvedValue({ + userCrossRate: '0.00030', + userAddRate: '0.00010', + userSpotCrossRate: '0.00050', + userSpotAddRate: '0.00020', + activeReferralDiscount: '0', + activeStakingDiscount: null, + ...override, + }); + + const result = await provider.calculateFees({ + orderType: 'market', + isMaker: false, + amount: '100000', + symbol: 'BTC', + }); + + expect(result.protocolFeeRate).toBe(0.00045); + expect(result.feeAmount).toBe(145); + expect(mockPlatformDependencies.debugLogger.log).toHaveBeenCalledWith( + 'Fee API Call Failed - Falling Back to Base Rates', + expect.objectContaining({ + error: 'Invalid fee rates received from API', + }), + ); + }); + + it('falls back to base rates when API returns negative fee rates', async () => { + const testAddress = '0xTestAddress123'; + mockWalletService.getUserAddressWithDefault.mockResolvedValue( + testAddress, + ); + + // Mock user fees API response with negative rates + getMockUserFees().mockResolvedValue({ + userCrossRate: '-0.0003', // Negative rate - invalid + userAddRate: '0.0001', + activeReferralDiscount: '0.00', + activeStakingDiscount: null, + }); + + const result = await provider.calculateFees({ + orderType: 'market', + isMaker: false, + amount: '100000', + symbol: 'BTC', + }); + + // Should fall back to base rates due to validation failure + expect(result.feeRate).toBe(0.00145); // Includes 0.1% MetaMask fee // Base taker rate + expect(result.feeAmount).toBe(145); // Includes MetaMask fee + }); + + it('always uses taker rate for market orders regardless of isMaker', async () => { + const testAddress = '0xTestAddress123'; + mockWalletService.getUserAddressWithDefault.mockResolvedValue( + testAddress, + ); + + getMockUserFees().mockResolvedValue({ + userCrossRate: '0.00035', // Taker rate + userAddRate: '0.00008', // Maker rate (lower) + userSpotCrossRate: '0.00070', + userSpotAddRate: '0.00040', + activeReferralDiscount: '0.04', // 4% referral discount + activeStakingDiscount: null, + }); + + // Test market order with isMaker=true (should still use taker rate) + const result = await provider.calculateFees({ + orderType: 'market', + isMaker: true, // This should be ignored for market orders + amount: '100000', + symbol: 'BTC', + }); + + // Should use taker rate even though isMaker is true + expect(result.feeRate).toBeCloseTo(0.001336, 6); // 0.035% * (1 - 0.04) + 0.1% MetaMask + expect(result.feeAmount).toBeCloseTo(133.6, 2); // Includes MetaMask fee + }); + + it('applies referral discount only when no staking discount', async () => { + const testAddress = '0xTestAddress123'; + mockWalletService.getUserAddressWithDefault.mockResolvedValue( + testAddress, + ); + + getMockUserFees().mockResolvedValue({ + userCrossRate: '0.00045', // 0.045% base taker rate + userAddRate: '0.00015', // 0.015% base maker rate + userSpotCrossRate: '0.00070', // 0.070% spot taker rate + userSpotAddRate: '0.00040', // 0.040% spot maker rate + activeReferralDiscount: '0.04', // 4% referral discount + activeStakingDiscount: null, + }); + + const result = await provider.calculateFees({ + orderType: 'market', + isMaker: false, + amount: '100000', + symbol: 'BTC', + }); + + // Should apply only referral discount: 0.045% * (1 - 0.04) = 0.0432% + expect(result.feeRate).toBeCloseTo(0.001432, 6); // 0.0432% + 0.1% MetaMask + expect(result.feeAmount).toBeCloseTo(143.2, 2); + }); + + it('applies staking discount only when no referral discount', async () => { + const testAddress = '0xTestAddress123'; + mockWalletService.getUserAddressWithDefault.mockResolvedValue( + testAddress, + ); + + getMockUserFees().mockResolvedValue({ + userCrossRate: '0.00045', // 0.045% base taker rate + userAddRate: '0.00015', // 0.015% base maker rate + userSpotCrossRate: '0.00070', // 0.070% spot taker rate + userSpotAddRate: '0.00040', // 0.040% spot maker rate + activeReferralDiscount: null, + activeStakingDiscount: { discount: '0.10' }, // 10% staking discount + }); + + const result = await provider.calculateFees({ + orderType: 'market', + isMaker: false, + amount: '100000', + symbol: 'BTC', + }); + + // Should apply only staking discount: 0.045% * (1 - 0.10) = 0.0405% + expect(result.feeRate).toBeCloseTo(0.001405, 6); // 0.0405% + 0.1% MetaMask + expect(result.feeAmount).toBeCloseTo(140.5, 2); + }); + + it('caps combined discounts at 40%', async () => { + const testAddress = '0xTestAddress123'; + mockWalletService.getUserAddressWithDefault.mockResolvedValue( + testAddress, + ); + + getMockUserFees().mockResolvedValue({ + userCrossRate: '0.00045', // 0.045% base taker rate + userAddRate: '0.00015', // 0.015% base maker rate + userSpotCrossRate: '0.00070', // 0.070% spot taker rate + userSpotAddRate: '0.00040', // 0.040% spot maker rate + activeReferralDiscount: '0.30', // 30% referral discount + activeStakingDiscount: { discount: '0.25' }, // 25% staking discount + }); + + const result = await provider.calculateFees({ + orderType: 'market', + isMaker: false, + amount: '100000', + symbol: 'BTC', + }); + + // Combined discounts would be 55%, but capped at 40% + // 0.045% * (1 - 0.40) = 0.027% + expect(result.feeRate).toBeCloseTo(0.00127, 6); // 0.027% + 0.1% MetaMask + expect(result.feeAmount).toBeCloseTo(127.0, 2); + }); + + it('handles maker rates with discounts correctly', async () => { + const testAddress = '0xTestAddress123'; + mockWalletService.getUserAddressWithDefault.mockResolvedValue( + testAddress, + ); + + getMockUserFees().mockResolvedValue({ + userCrossRate: '0.00045', // 0.045% base taker rate + userAddRate: '0.00015', // 0.015% base maker rate + userSpotCrossRate: '0.00070', // 0.070% spot taker rate + userSpotAddRate: '0.00040', // 0.040% spot maker rate + activeReferralDiscount: '0.04', // 4% referral discount + activeStakingDiscount: { discount: '0.05' }, // 5% staking discount + }); + + const result = await provider.calculateFees({ + orderType: 'limit', + isMaker: true, + amount: '100000', + symbol: 'BTC', + }); + + // Should apply discounts to maker rate: 0.015% * (1 - 0.04 - 0.05) = 0.01365% + expect(result.feeRate).toBeCloseTo(0.0011365, 6); // 0.01365% + 0.1% MetaMask + expect(result.feeAmount).toBeCloseTo(113.65, 2); + }); + + it('treats empty optional discounts as zero', async () => { + const testAddress = '0xTestAddress123'; + mockWalletService.getUserAddressWithDefault.mockResolvedValue( + testAddress, + ); + + getMockUserFees().mockResolvedValue({ + userCrossRate: '0.00030', // 0.030% base taker rate + userAddRate: '0.00010', // 0.010% base maker rate + userSpotCrossRate: '0.00070', // 0.070% spot taker rate + userSpotAddRate: '0.00040', // 0.040% spot maker rate + activeReferralDiscount: '', + activeStakingDiscount: { discount: '' }, + }); + + const result = await provider.calculateFees({ + orderType: 'market', + isMaker: false, + amount: '100000', + symbol: 'BTC', + }); + + expect(result.feeRate).toBe(0.0013); // 0.030% + 0.1% MetaMask + expect(result.feeAmount).toBe(130); + }); + + it('applies 2× fee multiplier for HIP-3 assets', async () => { + // HIP-3 asset (dex:SYMBOL format) + const result = await provider.calculateFees({ + orderType: 'market', + isMaker: false, + amount: '100000', + symbol: 'xyz:TSLA', // HIP-3 asset + }); + + // HIP-3 should have 2× base fees: 0.045% * 2 = 0.09% + 0.1% MetaMask = 0.19% + expect(result.feeRate).toBe(0.0019); // 0.09% taker + 0.1% MetaMask fee + expect(result.feeAmount).toBe(190); // 100000 * 0.0019 + }); + + it('applies 2× fee multiplier for HIP-3 maker orders', async () => { + // HIP-3 asset (dex:SYMBOL format) + const result = await provider.calculateFees({ + orderType: 'limit', + isMaker: true, + amount: '100000', + symbol: 'abc:SPX', // HIP-3 asset + }); + + // HIP-3 should have 2× base fees: 0.015% * 2 = 0.03% + 0.1% MetaMask = 0.13% + expect(result.feeRate).toBe(0.0013); // 0.03% maker + 0.1% MetaMask fee + expect(result.feeAmount).toBe(130); // 100000 * 0.0013 + }); + }); + + describe('fee discount functionality', () => { + describe('setUserFeeDiscount', () => { + it('logs discount context updates', () => { + // Arrange + const discountBips = 3000; // 30% in basis points + (mockPlatformDependencies.debugLogger.log as jest.Mock).mockClear(); + + // Act + provider.setUserFeeDiscount(discountBips); + + // Assert + expect(mockPlatformDependencies.debugLogger.log).toHaveBeenCalledWith( + 'HyperLiquid: Fee discount context updated', + { + discountBips, + discountPercentage: 30, + isActive: true, + }, + ); + }); + + it('logs when clearing discount context', () => { + // Arrange + (mockPlatformDependencies.debugLogger.log as jest.Mock).mockClear(); + + // Act + provider.setUserFeeDiscount(undefined); + + // Assert + expect(mockPlatformDependencies.debugLogger.log).toHaveBeenCalledWith( + 'HyperLiquid: Fee discount context updated', + { + discountBips: undefined, + discountPercentage: undefined, + isActive: false, + }, + ); + }); + }); + + describe('discount applied to orders', () => { + it('applies discount to builder fee in placeOrder', async () => { + // Arrange: Set 65% discount (6500 basis points) + provider.setUserFeeDiscount(6500); + + // Act + await provider.placeOrder({ + symbol: 'BTC', + isBuy: true, + size: '0.001', + orderType: 'market', + currentPrice: 50000, // Add price for validation + }); + + // Assert: Verify exchangeClient.order called with discounted fee + // 100 * (1 - 0.65) = 35 + expect( + mockClientService.getExchangeClient().order, + ).toHaveBeenCalledWith( + expect.objectContaining({ + builder: expect.objectContaining({ + f: 35, + }), + }), + ); + }); + + it('applies discount to builder fee in updatePositionTPSL', async () => { + // Arrange: Set 65% discount + provider.setUserFeeDiscount(6500); + + // Act + await provider.updatePositionTPSL({ + symbol: 'BTC', + takeProfitPrice: '50000', + }); + + // Assert: Verify discounted fee (35 instead of 100) + expect( + mockClientService.getExchangeClient().order, + ).toHaveBeenCalledWith( + expect.objectContaining({ + builder: expect.objectContaining({ + f: 35, + }), + }), + ); + }); + }); + + describe('calculateFees with fee discount', () => { + beforeEach(() => { + // Reset mocks for fee discount tests + getMockUserFees().mockClear(); + mockWalletService.getUserAddressWithDefault.mockRejectedValue( + new Error('No wallet connected'), + ); + }); + + it('applies discount to MetaMask fees when active', async () => { + // Arrange + const discountBips = 2000; // 20% discount in basis points + provider.setUserFeeDiscount(discountBips); + + // Act + const result = await provider.calculateFees({ + orderType: 'market', + isMaker: false, + amount: '100000', + symbol: 'BTC', + }); + + // Assert + // Base: 0.045% protocol + 0.1% MetaMask = 0.145% + // With 20% discount on MetaMask fee: 0.045% + (0.1% * 0.8) = 0.045% + 0.08% = 0.125% + expect(result.feeRate).toBe(0.00125); + expect(result.feeAmount).toBe(125); + }); + + it('applies discount to maker fees correctly', async () => { + // Arrange + const discountBips = 5000; // 50% discount in basis points + provider.setUserFeeDiscount(discountBips); + + // Act + const result = await provider.calculateFees({ + orderType: 'limit', + isMaker: true, + amount: '100000', + symbol: 'BTC', + }); + + // Assert + // Base: 0.015% protocol + 0.1% MetaMask = 0.115% + // With 50% discount on MetaMask fee: 0.015% + (0.1% * 0.5) = 0.015% + 0.05% = 0.065% + expect(result.feeRate).toBe(0.00065); + expect(result.feeAmount).toBe(65); + }); + + it('preserves protocol fees unchanged', async () => { + // Arrange + const discountBips = 10000; // 100% discount on MetaMask fees (in basis points) + provider.setUserFeeDiscount(discountBips); + + // Act + const result = await provider.calculateFees({ + orderType: 'market', + isMaker: false, + amount: '100000', + symbol: 'BTC', + }); + + // Assert + // Should only have protocol fees: 0.045% + // MetaMask fee should be 0 with 100% discount + expect(result.feeRate).toBe(0.00045); + expect(result.feeAmount).toBe(45); + }); + + it('works without discount - backward compatibility', async () => { + // Arrange - no discount set + // provider.setUserFeeDiscount() not called + + // Act + const result = await provider.calculateFees({ + orderType: 'market', + isMaker: false, + amount: '100000', + symbol: 'BTC', + }); + + // Assert + // Should have full fees: 0.045% + 0.1% = 0.145% + expect(result.feeRate).toBe(0.00145); + expect(result.feeAmount).toBe(145); + }); + + it('handles 0% discount edge case', async () => { + // Arrange + provider.setUserFeeDiscount(0); + + // Act + const result = await provider.calculateFees({ + orderType: 'limit', + isMaker: true, + amount: '100000', + symbol: 'BTC', + }); + + // Assert + // 0% discount means full MetaMask fee: 0.015% + 0.1% = 0.115% + expect(result.feeRate).toBe(0.00115); + expect(result.feeAmount).toBeCloseTo(115, 10); + }); + + it('rejects a non-finite fee discount result', async () => { + provider.setUserFeeDiscount(Number.POSITIVE_INFINITY); + + await expect( + provider.calculateFees({ + orderType: 'market', + amount: '100000', + symbol: 'BTC', + }), + ).rejects.toThrow('Invalid fee calculation result'); + }); + + it('combines discount with user staking discount', async () => { + // Arrange + const rewardsDiscountBips = 2000; // 20% MetaMask rewards discount in basis points + provider.setUserFeeDiscount(rewardsDiscountBips); + + // Clear fee cache to ensure fresh API call + provider.clearFeeCache(); + + // Reset and mock staking discount (override beforeEach) + mockWalletService.getUserAddressWithDefault.mockClear(); + mockWalletService.getUserAddressWithDefault.mockResolvedValue( + '0x123', + ); + getMockUserFees().mockResolvedValue({ + feeSchedule: { + fee: '0.03', // 0.03% protocol fee (better than base) + }, + activeStakingDiscount: { discount: '0.10' }, // 10% staking discount + }); + + // Act + const result = await provider.calculateFees({ + orderType: 'market', + isMaker: false, + amount: '100000', + symbol: 'BTC', + }); + + // Assert + // Note: If staking discount is not applied properly in test, it falls back to base rates + // Base protocol fee: 0.045% + MetaMask fee with rewards discount: 0.08% = 0.125% + // This test validates that the rewards discount is properly applied even when staking API is mocked + expect(result.feeRate).toBeCloseTo(0.00125, 5); + expect(result.feeAmount).toBeCloseTo(125, 0); + }); + + it('clears discount context after undefined is set', async () => { + // Arrange - first set a discount + provider.setUserFeeDiscount(2500); // 25% discount in basis points + + // Verify discount is applied + let result = await provider.calculateFees({ + orderType: 'market', + isMaker: false, + amount: '100000', + symbol: 'BTC', + }); + expect(result.feeRate).toBeCloseTo(0.0012, 5); // 0.045% + (0.1% * 0.75) + + // Act - clear discount + provider.setUserFeeDiscount(undefined); + + // Assert - should return to full fees + result = await provider.calculateFees({ + orderType: 'market', + isMaker: false, + amount: '100000', + symbol: 'BTC', + }); + expect(result.feeRate).toBe(0.00145); // Back to full fees + }); + }); + }); + + describe('getBlockExplorerUrl', () => { + it('returns mainnet explorer URL with address', () => { + const address = '0x1234567890abcdef1234567890abcdef12345678'; + const result = provider.getBlockExplorerUrl(address); + + expect(result).toBe( + `https://app.hyperliquid.xyz/explorer/address/${address}`, + ); + }); + + it('returns mainnet base explorer URL without address', () => { + const result = provider.getBlockExplorerUrl(); + + expect(result).toBe('https://app.hyperliquid.xyz/explorer'); + }); + + it('returns testnet explorer URL with address when in testnet mode', () => { + // Mock testnet mode + (mockClientService.isTestnetMode as jest.Mock).mockReturnValue(true); + + const address = '0xabcdef1234567890abcdef1234567890abcdef12'; + const result = provider.getBlockExplorerUrl(address); + + expect(result).toBe( + `https://app.hyperliquid-testnet.xyz/explorer/address/${address}`, + ); + }); + + it('returns testnet base explorer URL without address when in testnet mode', () => { + // Mock testnet mode + (mockClientService.isTestnetMode as jest.Mock).mockReturnValue(true); + + const result = provider.getBlockExplorerUrl(); + + expect(result).toBe('https://app.hyperliquid-testnet.xyz/explorer'); + }); + + it('handles empty string address', () => { + const result = provider.getBlockExplorerUrl(''); + + expect(result).toBe('https://app.hyperliquid.xyz/explorer'); + }); + }); + }); +}); diff --git a/packages/perps-controller/tests/src/providers/HyperLiquidProvider.history.test.ts b/packages/perps-controller/tests/src/providers/HyperLiquidProvider.history.test.ts new file mode 100644 index 00000000000..67f8bfe7bc9 --- /dev/null +++ b/packages/perps-controller/tests/src/providers/HyperLiquidProvider.history.test.ts @@ -0,0 +1,2060 @@ +/* eslint-disable */ +jest.mock('@nktkas/hyperliquid', () => ({})); + +import type { CaipAssetId, Hex } from '@metamask/utils'; + +import { CandlePeriod } from '../../../src/constants/chartConfig.js'; +import { + BUILDER_FEE_CONFIG, + REFERRAL_CONFIG, +} from '../../../src/constants/hyperLiquidConfig.js'; +import { PERPS_TRANSACTIONS_HISTORY_CONSTANTS } from '../../../src/constants/transactionsHistoryConfig.js'; +import { PERPS_ERROR_CODES } from '../../../src/perpsErrorCodes.js'; +import { HyperLiquidProvider } from '../../../src/providers/HyperLiquidProvider.js'; +import { HyperLiquidClientService } from '../../../src/services/HyperLiquidClientService.js'; +import { HyperLiquidSubscriptionService } from '../../../src/services/HyperLiquidSubscriptionService.js'; +import { HyperLiquidWalletService } from '../../../src/services/HyperLiquidWalletService.js'; +import { TradingReadinessCache } from '../../../src/services/TradingReadinessCache.js'; +import type { + ClosePositionParams, + DepositParams, + Order, + PerpsPlatformDependencies, + LiveDataConfig, + OrderParams, +} from '../../../src/types/index.js'; +import { + validateAssetSupport, + validateBalance, + validateCoinExists, + validateDepositParams, + validateOrderParams, + validateWithdrawalParams, +} from '../../../src/utils/hyperLiquidValidation.js'; +import { createStandaloneInfoClient } from '../../../src/utils/standaloneInfoClient.js'; +import { + createMockInfrastructure, + createMockMessenger, +} from '../../helpers/serviceMocks.js'; + +jest.mock('../../../src/services/HyperLiquidClientService'); +jest.mock('../../../src/services/HyperLiquidWalletService'); +jest.mock('../../../src/services/HyperLiquidSubscriptionService'); +// Mock stream manager - will be set up in test +let mockStreamManagerInstance: any; +const mockGetStreamManagerInstance = jest.fn(() => mockStreamManagerInstance); +jest.mock( + '../../../../components/UI/Perps/providers/PerpsStreamManager', + () => ({ + getStreamManagerInstance: mockGetStreamManagerInstance, + }), + { virtual: true }, +); + +// Mock standalone info client for standalone mode tests +let mockStandaloneInfoClient: any; +jest.mock('../../../src/utils/standaloneInfoClient', () => ({ + ...jest.requireActual('../../../src/utils/standaloneInfoClient'), + createStandaloneInfoClient: jest.fn(() => mockStandaloneInfoClient), +})); + +jest.mock('../../../src/utils/hyperLiquidValidation', () => ({ + validateOrderParams: jest.fn(), + validateWithdrawalParams: jest.fn(), + validateDepositParams: jest.fn(), + validateCoinExists: jest.fn(), + validateAssetSupport: jest.fn(), + validateBalance: jest.fn(), + getSupportedPaths: jest + .fn() + .mockReturnValue([ + 'eip155:42161/erc20:0xa0b86a33e6776e681a06e0e1622c5e5e3e6a8b13/default', + 'eip155:1/erc20:0xa0b86a33e6776e681a06e0e1622c5e5e3e6a8b13/default', + ]), + getBridgeInfo: jest.fn().mockReturnValue({ + chainId: 'eip155:42161', + contractAddress: '0x1234567890123456789012345678901234567890', + }), + createErrorResult: jest.fn((error, defaultResponse) => ({ + ...defaultResponse, + success: false, + error: error instanceof Error ? error.message : String(error), + })), +})); + +// Mock adapter functions +jest.mock('../../../src/utils/hyperLiquidAdapter', () => { + const actual = jest.requireActual('../../../src/utils/hyperLiquidAdapter'); + return { + ...actual, + adaptHyperLiquidLedgerUpdateToUserHistoryItem: jest.fn((updates) => { + // Return mock history items based on input + if (!updates || !Array.isArray(updates) || updates.length === 0) { + return []; + } + return updates.map((_update: unknown) => ({ + type: 'deposit' as const, + amount: '100', + timestamp: Date.now(), + hash: '0x123', + })); + }), + }; +}); + +// Mock TradingReadinessCache - global singleton for signing operation caching +// Use jest.createMockFromModule for proper mock creation +jest.mock('../../../src/services/TradingReadinessCache'); + +const MockedHyperLiquidClientService = + HyperLiquidClientService as jest.MockedClass; +const MockedHyperLiquidWalletService = + HyperLiquidWalletService as jest.MockedClass; +const MockedHyperLiquidSubscriptionService = + HyperLiquidSubscriptionService as jest.MockedClass< + typeof HyperLiquidSubscriptionService + >; +const mockValidateOrderParams = validateOrderParams as jest.MockedFunction< + typeof validateOrderParams +>; +const mockValidateWithdrawalParams = + validateWithdrawalParams as jest.MockedFunction< + typeof validateWithdrawalParams + >; +const mockValidateDepositParams = validateDepositParams as jest.MockedFunction< + typeof validateDepositParams +>; +const mockValidateCoinExists = validateCoinExists as jest.MockedFunction< + typeof validateCoinExists +>; +const mockValidateAssetSupport = validateAssetSupport as jest.MockedFunction< + typeof validateAssetSupport +>; +const mockValidateBalance = validateBalance as jest.MockedFunction< + typeof validateBalance +>; + +// Mock factory functions - defined once, reused everywhere +// These reduce duplication and make tests more maintainable +const createMockInfoClient = (overrides: Record = {}) => ({ + clearinghouseState: jest.fn().mockResolvedValue({ + marginSummary: { + totalMarginUsed: '500', + accountValue: '10500', + }, + withdrawable: '9500', + assetPositions: [ + { + position: { + coin: 'BTC', + szi: '0.1', + entryPx: '50000', + positionValue: '5000', + unrealizedPnl: '100', + marginUsed: '500', + leverage: { type: 'cross', value: 10 }, + liquidationPx: '45000', + maxLeverage: 50, + returnOnEquity: '20', + cumFunding: { allTime: '10', sinceOpen: '5', sinceChange: '2' }, + }, + type: 'oneWay', + }, + { + position: { + coin: 'ETH', + szi: '1.5', + entryPx: '3000', + positionValue: '4500', + unrealizedPnl: '50', + marginUsed: '450', + leverage: { type: 'cross', value: 10 }, + liquidationPx: '2700', + maxLeverage: 50, + returnOnEquity: '10', + cumFunding: { allTime: '5', sinceOpen: '2', sinceChange: '1' }, + }, + type: 'oneWay', + }, + ], + crossMarginSummary: { + accountValue: '10000', + totalMarginUsed: '5000', + }, + }), + spotClearinghouseState: jest.fn().mockResolvedValue({ + balances: [{ coin: 'USDC', hold: '1000', total: '10000' }], + }), + // Mode-aware fold gate reads userAbstraction; default to unifiedAccount + // so tests that predated the gate still see spot folded into spendable/withdrawable. + userAbstraction: jest.fn().mockResolvedValue('unifiedAccount'), + meta: jest.fn().mockResolvedValue({ + universe: [ + { name: 'BTC', szDecimals: 3, maxLeverage: 50 }, + { name: 'ETH', szDecimals: 4, maxLeverage: 50 }, + ], + }), + metaAndAssetCtxs: jest.fn().mockResolvedValue([ + { + universe: [ + { name: 'BTC', szDecimals: 3, maxLeverage: 50 }, + { name: 'ETH', szDecimals: 4, maxLeverage: 50 }, + ], + }, + [ + { + funding: '0.0001', + openInterest: '1000', + prevDayPx: '49000', + dayNtlVlm: '1000000', + markPx: '50000', + midPx: '50000', + oraclePx: '50000', + }, + { + funding: '0.0001', + openInterest: '500', + prevDayPx: '2900', + dayNtlVlm: '500000', + markPx: '3000', + midPx: '3000', + oraclePx: '3000', + }, + ], + ]), + perpDexs: jest.fn().mockResolvedValue([null]), + allMids: jest.fn().mockResolvedValue({ BTC: '50000', ETH: '3000' }), + frontendOpenOrders: jest.fn().mockResolvedValue([]), + referral: jest.fn().mockResolvedValue({ + referrerState: { + stage: 'ready', + data: { code: 'MMCSI' }, + }, + }), + maxBuilderFee: jest.fn().mockResolvedValue(1), + userFees: jest.fn().mockResolvedValue({ + feeSchedule: { + cross: '0.00030', + add: '0.00010', + spotCross: '0.00040', + spotAdd: '0.00020', + }, + dailyUserVlm: [], + }), + userNonFundingLedgerUpdates: jest.fn().mockResolvedValue([ + { + delta: { type: 'deposit', usdc: '100' }, + time: Date.now(), + hash: '0x123abc', + }, + { + delta: { type: 'withdraw', usdc: '50' }, + time: Date.now() - 3600000, + hash: '0x456def', + }, + ]), + portfolio: jest.fn().mockResolvedValue([ + null, + [ + null, + { + accountValueHistory: [ + [Date.now() - 86400000, '10000'], // 24h ago + [Date.now() - 172800000, '9500'], // 48h ago + [Date.now() - 259200000, '9000'], // 72h ago + ], + }, + ], + ]), + spotMeta: jest.fn().mockResolvedValue({ + tokens: [ + { name: 'USDC', tokenId: '0xdef456', index: 0 }, + { name: 'USDT', tokenId: '0x789abc', index: 1 }, + ], + universe: [], + }), + historicalOrders: jest.fn().mockResolvedValue([]), + userFills: jest.fn().mockResolvedValue([]), + userFillsByTime: jest.fn().mockResolvedValue([]), + userFunding: jest.fn().mockResolvedValue([]), + ...overrides, +}); + +const createMockExchangeClient = (overrides: Record = {}) => ({ + order: jest.fn().mockResolvedValue({ + status: 'ok', + response: { data: { statuses: [{ resting: { oid: 123 } }] } }, + }), + modify: jest.fn().mockResolvedValue({ + status: 'ok', + response: { data: { statuses: [{ resting: { oid: '123' } }] } }, + }), + cancel: jest.fn().mockResolvedValue({ + status: 'ok', + response: { data: { statuses: ['success'] } }, + }), + withdraw3: jest.fn().mockResolvedValue({ + status: 'ok', + }), + updateLeverage: jest.fn().mockResolvedValue({ + status: 'ok', + }), + approveBuilderFee: jest.fn().mockResolvedValue({ + status: 'ok', + }), + setReferrer: jest.fn().mockResolvedValue({ + status: 'ok', + }), + sendAsset: jest.fn().mockResolvedValue({ + status: 'ok', + }), + agentSetAbstraction: jest.fn().mockResolvedValue({ + status: 'ok', + }), + userSetAbstraction: jest.fn().mockResolvedValue({ + status: 'ok', + }), + ...overrides, +}); + +// Create shared mock platform dependencies for provider tests +const mockPlatformDependencies: PerpsPlatformDependencies = + createMockInfrastructure(); + +const mockMessenger = createMockMessenger(); + +/** + * Helper to create HyperLiquidProvider with mock platform dependencies + * @param options + * @param options.isTestnet + * @param options.hip3Enabled + * @param options.allowlistMarkets + * @param options.blocklistMarkets + * @param options.useUnifiedAccount + */ +const createTestProvider = ( + options: { + isTestnet?: boolean; + hip3Enabled?: boolean; + allowlistMarkets?: string[]; + blocklistMarkets?: string[]; + useUnifiedAccount?: boolean; + initialAssetMapping?: [string, number][]; + } = {}, +): HyperLiquidProvider => + new HyperLiquidProvider({ + ...options, + platformDependencies: mockPlatformDependencies, + messenger: mockMessenger, + }); + +describe('HyperLiquidProvider', () => { + let provider: HyperLiquidProvider; + let mockClientService: jest.Mocked; + let mockWalletService: jest.Mocked; + let mockSubscriptionService: jest.Mocked; + + beforeEach(() => { + // Reset all mocks + jest.clearAllMocks(); + ( + mockPlatformDependencies.marketDataFormatters.formatVolume as jest.Mock + ).mockImplementation((value: number) => '$' + value.toFixed(0)); + ( + mockPlatformDependencies.marketDataFormatters.formatPerpsFiat as jest.Mock + ).mockImplementation((value: number) => '$' + value.toFixed(2)); + ( + mockPlatformDependencies.marketDataFormatters + .formatPercentage as jest.Mock + ).mockImplementation((value: number) => `${value.toFixed(2)}%`); + ( + mockPlatformDependencies.featureFlags.validateVersionGated as jest.Mock + ).mockReturnValue(undefined); + (mockPlatformDependencies.metrics.isEnabled as jest.Mock).mockReturnValue( + true, + ); + + // Reset TradingReadinessCache mock state (using imported mocked module) + const mockedCache = TradingReadinessCache as jest.Mocked< + typeof TradingReadinessCache + >; + mockedCache.get.mockReturnValue(undefined); + mockedCache.getBuilderFee.mockReturnValue(undefined); + mockedCache.getReferral.mockReturnValue(undefined); + mockedCache.isInFlight.mockReturnValue(undefined); + mockedCache.setInFlight.mockReturnValue(jest.fn()); + + // Initialize mock stream manager instance + mockStreamManagerInstance = { + clearAllChannels: jest.fn(), + }; + + // Create mocked service instances using factory functions + mockClientService = { + initialize: jest.fn(), + isInitialized: jest.fn().mockReturnValue(true), + isTestnetMode: jest.fn().mockReturnValue(false), + ensureInitialized: jest.fn(), + getExchangeClient: jest.fn().mockReturnValue(createMockExchangeClient()), + getInfoClient: jest.fn().mockReturnValue(createMockInfoClient()), + fetchHistoricalOrders: jest.fn().mockResolvedValue([]), + disconnect: jest.fn().mockResolvedValue(undefined), + toggleTestnet: jest.fn(), + setTestnetMode: jest.fn(), + getNetwork: jest.fn().mockReturnValue('mainnet'), + ensureSubscriptionClient: jest.fn().mockResolvedValue(undefined), + getSubscriptionClient: jest.fn(), + setOnReconnectCallback: jest.fn(), + setOnTerminateCallback: jest.fn(), + getConnectionState: jest.fn().mockReturnValue('connected'), + } as Partial as jest.Mocked; + + mockWalletService = { + setTestnetMode: jest.fn(), + getCurrentAccountId: jest + .fn() + .mockReturnValue( + 'eip155:42161:0x1234567890123456789012345678901234567890', + ), + createWalletAdapter: jest.fn().mockReturnValue({ + request: jest + .fn() + .mockResolvedValue(['0x1234567890123456789012345678901234567890']), + }), + getUserAddress: jest + .fn() + .mockReturnValue('0x1234567890123456789012345678901234567890'), + getUserAddressWithDefault: jest + .fn() + .mockResolvedValue('0x1234567890123456789012345678901234567890'), + isKeyringUnlocked: jest.fn().mockReturnValue(true), + isSelectedHardwareWallet: jest.fn().mockReturnValue(false), + } as Partial as jest.Mocked; + + mockSubscriptionService = { + subscribeToPrices: jest.fn().mockResolvedValue(jest.fn()), // Returns Promise + subscribeToPositions: jest.fn().mockReturnValue(jest.fn()), // Returns function directly + subscribeToOrderFills: jest.fn().mockReturnValue(jest.fn()), // Returns function directly + clearAll: jest.fn(), + isPositionsCacheInitialized: jest.fn().mockReturnValue(false), + getCachedPositions: jest.fn().mockReturnValue([]), + updateFeatureFlags: jest.fn().mockResolvedValue(undefined), + // Cache methods used by buildAssetMapping optimization + setDexMetaCache: jest.fn(), + setDexAssetCtxsCache: jest.fn(), + getDexAssetCtxsCache: jest.fn().mockReturnValue(undefined), + // Price cache used by placeOrder, editOrder, closePosition optimizations + getCachedPrice: jest.fn().mockImplementation((symbol: string) => { + const prices: Record = { BTC: '50000', ETH: '3000' }; + return prices[symbol]; + }), + getLastAllMidsSnapshot: jest.fn().mockReturnValue(null), + // Orders cache used by updatePositionTPSL and getOpenOrders + isOrdersCacheInitialized: jest.fn().mockReturnValue(false), + getCachedOrders: jest.fn().mockReturnValue([]), + // Atomic getter - returns null when cache not initialized (prevents race condition) + getOrdersCacheIfInitialized: jest.fn().mockReturnValue(null), + // Abstraction-mode resolved-mode setter (unified account migration) + setUserAbstractionMode: jest.fn(), + } as Partial as jest.Mocked; + + // Mock constructors + MockedHyperLiquidClientService.mockImplementation(() => mockClientService); + MockedHyperLiquidWalletService.mockImplementation(() => mockWalletService); + MockedHyperLiquidSubscriptionService.mockImplementation( + () => mockSubscriptionService, + ); + + // Mock validation + mockValidateOrderParams.mockReturnValue({ isValid: true }); + mockValidateWithdrawalParams.mockReturnValue({ isValid: true }); + mockValidateDepositParams.mockReturnValue({ isValid: true }); + mockValidateCoinExists.mockReturnValue({ isValid: true }); + mockValidateAssetSupport.mockReturnValue({ isValid: true }); + mockValidateBalance.mockReturnValue({ isValid: true }); + const hyperLiquidValidation = jest.requireMock( + '../../../src/utils/hyperLiquidValidation', + ); + hyperLiquidValidation.getSupportedPaths.mockReturnValue([ + 'eip155:42161/erc20:0xa0b86a33e6776e681a06e0e1622c5e5e3e6a8b13/default', + 'eip155:1/erc20:0xa0b86a33e6776e681a06e0e1622c5e5e3e6a8b13/default', + ]); + hyperLiquidValidation.getBridgeInfo.mockReturnValue({ + chainId: 'eip155:42161', + contractAddress: '0x1234567890123456789012345678901234567890', + }); + hyperLiquidValidation.createErrorResult.mockImplementation( + (error: unknown, defaultResponse: Record) => ({ + ...defaultResponse, + success: false, + error: error instanceof Error ? error.message : String(error), + }), + ); + const hyperLiquidAdapter = jest.requireMock( + '../../../src/utils/hyperLiquidAdapter', + ); + hyperLiquidAdapter.adaptHyperLiquidLedgerUpdateToUserHistoryItem.mockImplementation( + (updates: unknown[]) => { + if (!updates || !Array.isArray(updates) || updates.length === 0) { + return []; + } + return updates.map(() => ({ + type: 'deposit' as const, + amount: '100', + timestamp: Date.now(), + hash: '0x123', + })); + }, + ); + + provider = createTestProvider({ + initialAssetMapping: [ + ['BTC', 0], + ['ETH', 1], + ], + }); + }); + describe('Additional Coverage Tests', () => { + it('handles getUserFills with empty response', async () => { + mockClientService.getInfoClient = jest.fn().mockReturnValue({ + userFills: jest.fn().mockResolvedValue(null), + }); + + const result = await provider.getOrderFills(); + expect(result).toEqual([]); + }); + + it('handles getOrders with empty response', async () => { + mockClientService.getInfoClient = jest.fn().mockReturnValue({ + historicalOrders: jest.fn().mockResolvedValue(null), + }); + mockClientService.fetchHistoricalOrders = jest.fn().mockResolvedValue([]); + + const result = await provider.getOrders(); + expect(result).toEqual([]); + }); + + it('preserves history when an order is missing runtime-required fields', async () => { + mockClientService.fetchHistoricalOrders = jest.fn().mockResolvedValue([ + { + order: { + oid: 123, + coin: 'BTC', + side: 'A', + sz: '0.5', + origSz: '1.0', + limitPx: '50000', + orderType: 'Limit', + reduceOnly: false, + isTrigger: false, + }, + status: 'filled', + statusTimestamp: 1640995200000, + }, + { + order: { + oid: undefined, + coin: 'ETH', + side: 'B', + sz: '0.1', + origSz: '0.1', + limitPx: '', + orderType: undefined, + reduceOnly: false, + isTrigger: false, + }, + status: 'open', + statusTimestamp: 1640995300000, + }, + ]); + + const result = await provider.getOrders(); + + expect(result).toHaveLength(2); + expect(result[0]).toMatchObject({ + orderId: '123', + orderType: 'limit', + }); + expect(result[1]).toMatchObject({ + orderId: '', + orderType: 'market', + }); + }); + + it.each([ + ['Limit', 'limit'], + ['Market', 'market'], + ['Stop Limit', 'limit'], + ['Stop Market', 'market'], + ['Take Profit Limit', 'limit'], + ['Take Profit Market', 'market'], + ['Unexpected Limit', 'market'], + ])( + 'maps the exact historical order type %s to %s', + async (orderType, expected) => { + mockClientService.fetchHistoricalOrders = jest.fn().mockResolvedValue([ + { + order: { + oid: 123, + coin: 'BTC', + side: 'B', + sz: '0.1', + origSz: '0.1', + limitPx: '50000', + orderType, + reduceOnly: false, + isTrigger: false, + }, + status: 'open', + statusTimestamp: 1640995200000, + }, + ]); + + const result = await provider.getOrders(); + + expect(result[0].orderType).toBe(expected); + }, + ); + + it('properly transform getOrders with reduceOnly and isTrigger fields', async () => { + const historicalOrdersData = [ + { + order: { + oid: 123, + coin: 'BTC', + side: 'A', + sz: '0.5', + origSz: '1.0', + limitPx: '50000', + orderType: 'Limit', + reduceOnly: false, + isTrigger: false, + }, + status: 'filled', + statusTimestamp: 1640995200000, + }, + { + order: { + oid: 124, + coin: 'ETH', + side: 'A', + sz: '0.0', + origSz: '2.0', + limitPx: '3500', + triggerPx: '3450', + orderType: 'Take Profit Limit', + reduceOnly: true, + isTrigger: true, + }, + status: 'filled', + statusTimestamp: 1640995300000, + }, + { + order: { + oid: 125, + coin: 'BTC', + side: 'B', + sz: '0.1', + origSz: '0.1', + limitPx: '45000', + triggerPx: '45500', + orderType: 'Stop Market', + reduceOnly: true, + isTrigger: true, + }, + status: 'triggered', + statusTimestamp: 1640995400000, + }, + { + order: { + oid: 126, + coin: 'ETH', + side: 'B', + sz: '0.0', + origSz: '1.0', + limitPx: '3600', + triggerPx: '', + orderType: 'Market', + reduceOnly: false, + isTrigger: false, + }, + status: 'filled', + statusTimestamp: 1640995500000, + }, + ]; + mockClientService.getInfoClient = jest.fn().mockReturnValue({ + maxBuilderFee: jest.fn().mockResolvedValue(1), + referral: jest.fn().mockResolvedValue({ + referrerState: { stage: 'ready', data: { code: 'MMCSI' } }, + referredBy: { code: 'MMCSI' }, + }), + historicalOrders: jest.fn().mockResolvedValue(historicalOrdersData), + }); + mockClientService.fetchHistoricalOrders = jest + .fn() + .mockResolvedValue(historicalOrdersData); + + const result = await provider.getOrders(); + + expect(result).toHaveLength(4); + + // Check first order - regular limit order (not closing) + expect(result[0]).toMatchObject({ + orderId: '123', + symbol: 'BTC', + side: 'sell', + orderType: 'limit', + size: '0.5', + originalSize: '1.0', + price: '50000', + status: 'filled', + detailedOrderType: 'Limit', + reduceOnly: false, + isTrigger: false, + }); + + // Check second order - Take Profit closing order + expect(result[1]).toMatchObject({ + orderId: '124', + symbol: 'ETH', + side: 'sell', + orderType: 'limit', + size: '0.0', + originalSize: '2.0', + price: '3500', + triggerPrice: '3450', + triggerOrderType: 'take_profit_limit', + filledSize: '2', + remainingSize: '0', + status: 'filled', + timestamp: 1640995300000, + lastUpdated: 1640995300000, + detailedOrderType: 'Take Profit Limit', + reduceOnly: true, + isTrigger: true, + }); + + // Check third order - Stop Market closing order + expect(result[2]).toMatchObject({ + orderId: '125', + symbol: 'BTC', + side: 'buy', + orderType: 'market', + size: '0.1', + originalSize: '0.1', + price: '45000', + triggerPrice: '45500', + triggerOrderType: 'stop_market', + filledSize: '0', + remainingSize: '0.1', + status: 'triggered', + timestamp: 1640995400000, + lastUpdated: 1640995400000, + detailedOrderType: 'Stop Market', + reduceOnly: true, + isTrigger: true, + }); + + // Check fourth order - regular market order with a slippage-cap price + expect(result[3]).toMatchObject({ + orderId: '126', + symbol: 'ETH', + side: 'buy', + orderType: 'market', + size: '0.0', + originalSize: '1.0', + price: '3600', + filledSize: '1', + remainingSize: '0', + status: 'filled', + timestamp: 1640995500000, + lastUpdated: 1640995500000, + detailedOrderType: 'Market', + reduceOnly: false, + isTrigger: false, + }); + expect(result[3].triggerPrice).toBeUndefined(); + expect(result[3].triggerOrderType).toBeUndefined(); + }); + + it('properly transform getOpenOrders with reduceOnly and isTrigger fields', async () => { + mockClientService.getInfoClient = jest.fn().mockReturnValue({ + maxBuilderFee: jest.fn().mockResolvedValue(1), + referral: jest.fn().mockResolvedValue({ + referrerState: { stage: 'ready', data: { code: 'MMCSI' } }, + referredBy: { code: 'MMCSI' }, + }), + clearinghouseState: jest.fn().mockResolvedValue({ + marginSummary: { totalMarginUsed: '500', accountValue: '10500' }, + withdrawable: '9500', + assetPositions: [ + { + position: { + coin: 'BTC', + szi: '1.0', + entryPx: '50000', + positionValue: '50000', + unrealizedPnl: '1000', + marginUsed: '5000', + leverage: { type: 'cross', value: 10 }, + liquidationPx: '45000', + maxLeverage: 50, + returnOnEquity: '20', + cumFunding: { allTime: '10', sinceOpen: '5', sinceChange: '2' }, + }, + type: 'oneWay', + }, + ], + crossMarginSummary: { + accountValue: '10000', + totalMarginUsed: '5000', + }, + }), + frontendOpenOrders: jest.fn().mockResolvedValue([ + { + coin: 'BTC', + side: 'B', + limitPx: '49000', + sz: '0.5', + oid: 201, + timestamp: 1640995500000, + origSz: '0.5', + triggerCondition: '', + isTrigger: false, + triggerPx: '', + children: [], + isPositionTpsl: false, + reduceOnly: false, + orderType: 'Limit', + tif: 'Gtc', + cloid: null, + }, + { + coin: 'BTC', + side: 'A', + limitPx: '55000', + sz: '1.0', + oid: 202, + timestamp: 1640995600000, + origSz: '1.0', + triggerCondition: '', + isTrigger: true, + triggerPx: '55000', + children: [], + isPositionTpsl: true, + reduceOnly: true, + orderType: 'Take Profit Limit', + tif: null, + cloid: null, + }, + { + coin: 'BTC', + side: 'A', + limitPx: '', + sz: '1.0', + oid: 203, + timestamp: 1640995700000, + origSz: '1.0', + triggerCondition: '', + isTrigger: true, + triggerPx: '45000', + children: [], + isPositionTpsl: true, + reduceOnly: true, + orderType: 'Stop Market', + tif: null, + cloid: null, + }, + ]), + meta: jest.fn().mockResolvedValue({ + universe: [{ name: 'BTC', szDecimals: 3, maxLeverage: 50 }], + }), + perpDexs: jest.fn().mockResolvedValue([null]), + }); + + const result = await provider.getOpenOrders({ skipCache: true }); + + expect(result).toHaveLength(3); + + // Check first order - regular limit order (opening position) + expect(result[0]).toMatchObject({ + orderId: '201', + symbol: 'BTC', + side: 'buy', + orderType: 'limit', + size: '0.5', + originalSize: '0.5', + price: '49000', + status: 'open', + detailedOrderType: 'Limit', + reduceOnly: false, + isTrigger: false, + }); + + // Check second order - Take Profit closing order + expect(result[1]).toMatchObject({ + orderId: '202', + symbol: 'BTC', + side: 'sell', + orderType: 'limit', + size: '1.0', + originalSize: '1.0', + price: '55000', + status: 'open', + detailedOrderType: 'Take Profit Limit', + reduceOnly: true, + isTrigger: true, + }); + + // Check third order - Stop Market closing order + expect(result[2]).toMatchObject({ + orderId: '203', + symbol: 'BTC', + side: 'sell', + orderType: 'market', + size: '1.0', + originalSize: '1.0', + price: '45000', + status: 'open', + detailedOrderType: 'Stop Market', + reduceOnly: true, + isTrigger: true, + }); + }); + + it('handles getFunding with empty response', async () => { + mockClientService.getInfoClient = jest.fn().mockReturnValue({ + userFunding: jest.fn().mockResolvedValue(null), + }); + + const result = await provider.getFunding(); + expect(result).toEqual([]); + }); + + it('fetches funding across multiple page windows to include latest records', async () => { + const NOW = 1735689600000; // fixed timestamp for determinism + const DAY_MS = 24 * 60 * 60 * 1000; + + const oldRecord = { + time: NOW - 40 * DAY_MS, + hash: '0x' + 'a'.repeat(64), + delta: { + type: 'funding', + coin: 'BTC', + usdc: '-1.0', + szi: '0.1', + fundingRate: '0.0001', + nSamples: null, + }, + }; + const recentRecord = { + time: NOW - 5 * DAY_MS, + hash: '0x' + 'b'.repeat(64), + delta: { + type: 'funding', + coin: 'BTC', + usdc: '-2.0', + szi: '0.1', + fundingRate: '0.0001', + nSamples: null, + }, + }; + + const userFundingMock = jest + .fn() + .mockImplementation( + (params: { startTime: number; endTime: number }) => { + const records = [oldRecord, recentRecord].filter( + (r) => r.time >= params.startTime && r.time <= params.endTime, + ); + return Promise.resolve(records); + }, + ); + + mockClientService.getInfoClient = jest.fn().mockReturnValue({ + userFunding: userFundingMock, + }); + + // Time range spans 60 days → 2 page windows of 30 days each + const result = await provider.getFunding({ + startTime: NOW - 60 * DAY_MS, + endTime: NOW, + }); + + expect(userFundingMock).toHaveBeenCalledTimes(2); + expect(result).toHaveLength(2); + // Results sorted ascending: older first, recent last + expect(result[0].amountUsd).toBe('-1.0'); + expect(result[1].amountUsd).toBe('-2.0'); + // Most recent record is present — this would fail with the old single-call approach + // when total records exceeded the 500-record API cap + expect(result[1].timestamp).toBe(recentRecord.time); + }); + + it('includes records from the most recent page window when history is long', async () => { + const NOW = 1735689600000; + const DAY_MS = 24 * 60 * 60 * 1000; + const recentTs = NOW - 2 * DAY_MS; + + const userFundingMock = jest + .fn() + .mockImplementation( + (params: { startTime: number; endTime: number }) => { + if (params.endTime >= recentTs && params.startTime <= recentTs) { + return Promise.resolve([ + { + time: recentTs, + hash: '0x' + 'f'.repeat(64), + delta: { + type: 'funding', + coin: 'ETH', + usdc: '-0.5', + szi: '1.0', + fundingRate: '0.00005', + nSamples: null, + }, + }, + ]); + } + return Promise.resolve([]); + }, + ); + + mockClientService.getInfoClient = jest.fn().mockReturnValue({ + userFunding: userFundingMock, + }); + + // Pass explicit 365-day range to trigger multi-page behavior. + // The default is now 30 days (1 call); callers must pass startTime to paginate further. + const result = await provider.getFunding({ + startTime: NOW - 365 * DAY_MS, + endTime: NOW, + }); + + // Multiple page windows must be created for a 365-day explicit range + expect(userFundingMock.mock.calls.length).toBeGreaterThan(1); + // The most recent record is present — proves pagination reaches the latest window + expect(result.some((r) => r.timestamp === recentTs)).toBe(true); + }); + + it('handles null response from one page window without losing other pages', async () => { + const NOW = 1735689600000; + const DAY_MS = 24 * 60 * 60 * 1000; + const validRecord = { + time: NOW - 10 * DAY_MS, + hash: '0x' + 'c'.repeat(64), + delta: { + type: 'funding', + coin: 'BTC', + usdc: '-3.0', + szi: '0.2', + fundingRate: '0.0002', + nSamples: null, + }, + }; + + let callCount = 0; + const userFundingMock = jest.fn().mockImplementation(() => { + callCount += 1; + // First call returns null, subsequent calls return data + return Promise.resolve(callCount === 1 ? null : [validRecord]); + }); + + mockClientService.getInfoClient = jest.fn().mockReturnValue({ + userFunding: userFundingMock, + }); + + const result = await provider.getFunding({ + startTime: NOW - 60 * DAY_MS, + endTime: NOW, + }); + + // Null page is gracefully skipped; valid records from other pages survive + expect(result.some((r) => r.amountUsd === '-3.0')).toBe(true); + }); + + it('handles validateWithdrawal returning true', async () => { + const params = { + amount: '100', + destination: '0x123' as Hex, + assetId: 'eip155:1/native' as CaipAssetId, + }; + + const result = await provider.validateWithdrawal(params); + expect(result.isValid).toBe(true); + }); + + it('handles clearFeeCache with specific user', () => { + const userAddress = '0x123'; + provider.clearFeeCache(userAddress); + // Method should complete without error + }); + + // TODO: Refactor — #isFeeCacheValid is an ES # private method, can't be accessed via type cast + it.skip('handles isFeeCacheValid with non-existent address', async () => { + // Access private method for edge case testing + interface ProviderWithPrivateMethods { + isFeeCacheValid(userAddress: string): boolean; + } + const testableProvider = + provider as unknown as ProviderWithPrivateMethods; + const result = testableProvider.isFeeCacheValid('0xnonexistent'); + expect(result).toBe(false); + }); + + it('transforms fill data with liquidation information', async () => { + // Mock fill with liquidation data + mockClientService.getInfoClient = jest.fn().mockReturnValue( + createMockInfoClient({ + userFills: jest.fn().mockResolvedValue([ + { + oid: 123, + coin: 'BTC', + side: 'B', + sz: '0.1', + px: '45000', + fee: '4.5', + feeToken: 'USDC', + time: Date.now(), + closedPnl: '-500', + dir: 'Close Long', + liquidation: { + liquidatedUser: '0x123', + markPx: '44900', + method: 'market', + }, + }, + ]), + }), + ); + + const fills = await provider.getOrderFills(); + + expect(fills[0].liquidation).toEqual({ + liquidatedUser: '0x123', + markPx: '44900', + method: 'market', + }); + }); + + it('handles fills without liquidation data', async () => { + mockClientService.getInfoClient = jest.fn().mockReturnValue( + createMockInfoClient({ + userFills: jest.fn().mockResolvedValue([ + { + oid: 124, + coin: 'ETH', + side: 'B', + sz: '1.0', + px: '3000', + fee: '3', + feeToken: 'USDC', + time: Date.now(), + closedPnl: '100', + dir: 'Open Long', + }, + ]), + }), + ); + + const fills = await provider.getOrderFills(); + expect(fills[0].liquidation).toBeUndefined(); + }); + + it('uses userFillsByTime when startTime is provided', async () => { + const mockUserFillsByTime = jest.fn().mockResolvedValue([ + { + oid: 125, + coin: 'BTC', + side: 'B', + sz: '0.5', + px: '50000', + fee: '5', + feeToken: 'USDC', + time: Date.now(), + closedPnl: '200', + dir: 'Open Long', + }, + ]); + + mockClientService.getInfoClient = jest.fn().mockReturnValue( + createMockInfoClient({ + userFillsByTime: mockUserFillsByTime, + }), + ); + + const startTime = Date.now() - 90 * 24 * 60 * 60 * 1000; // 3 months ago + const fills = await provider.getOrderFills({ startTime }); + + expect(mockUserFillsByTime).toHaveBeenCalledWith({ + user: '0x1234567890123456789012345678901234567890', + startTime, + endTime: undefined, + aggregateByTime: false, + }); + expect(fills).toHaveLength(1); + expect(fills[0].symbol).toBe('BTC'); + }); + + it('uses userFills when startTime is not provided', async () => { + const mockUserFills = jest.fn().mockResolvedValue([ + { + oid: 126, + coin: 'ETH', + side: 'A', + sz: '2.0', + px: '3500', + fee: '7', + feeToken: 'USDC', + time: Date.now(), + closedPnl: '150', + dir: 'Close Short', + }, + ]); + + mockClientService.getInfoClient = jest.fn().mockReturnValue( + createMockInfoClient({ + userFills: mockUserFills, + }), + ); + + const fills = await provider.getOrderFills({ aggregateByTime: true }); + + expect(mockUserFills).toHaveBeenCalledWith({ + user: '0x1234567890123456789012345678901234567890', + aggregateByTime: true, + }); + expect(fills).toHaveLength(1); + expect(fills[0].symbol).toBe('ETH'); + }); + + it('passes endTime to userFillsByTime when provided', async () => { + const mockUserFillsByTime = jest.fn().mockResolvedValue([]); + + mockClientService.getInfoClient = jest.fn().mockReturnValue( + createMockInfoClient({ + userFillsByTime: mockUserFillsByTime, + }), + ); + + const startTime = Date.now() - 30 * 24 * 60 * 60 * 1000; // 30 days ago + const endTime = Date.now(); + await provider.getOrderFills({ + startTime, + endTime, + aggregateByTime: true, + }); + + expect(mockUserFillsByTime).toHaveBeenCalledWith({ + user: '0x1234567890123456789012345678901234567890', + startTime, + endTime, + aggregateByTime: true, + }); + }); + }); + + describe('getOrderFills enrichment with detailedOrderType', () => { + it('enriches fills with detailedOrderType from historical orders', async () => { + const mockUserFills = jest.fn().mockResolvedValue([ + { + oid: 100, + coin: 'BTC', + side: 'A', + sz: '0.5', + px: '50000', + fee: '5', + feeToken: 'USDC', + time: Date.now(), + closedPnl: '-200', + dir: 'Close Long', + startPosition: '0.5', + }, + { + oid: 101, + coin: 'ETH', + side: 'B', + sz: '1.0', + px: '3000', + fee: '3', + feeToken: 'USDC', + time: Date.now(), + closedPnl: '100', + dir: 'Close Short', + startPosition: '-1.0', + }, + ]); + + const historicalOrdersData = [ + { + order: { + oid: 100, + coin: 'BTC', + side: 'A', + sz: '0', + origSz: '0.5', + limitPx: '50000', + orderType: 'Stop Market', + reduceOnly: true, + isTrigger: true, + }, + status: 'filled', + statusTimestamp: Date.now(), + }, + { + order: { + oid: 101, + coin: 'ETH', + side: 'B', + sz: '0', + origSz: '1.0', + limitPx: '3000', + orderType: 'Take Profit Limit', + reduceOnly: true, + isTrigger: true, + }, + status: 'filled', + statusTimestamp: Date.now(), + }, + ]; + const mockHistoricalOrders = jest + .fn() + .mockResolvedValue(historicalOrdersData); + + mockClientService.getInfoClient = jest.fn().mockReturnValue( + createMockInfoClient({ + userFills: mockUserFills, + historicalOrders: mockHistoricalOrders, + }), + ); + mockClientService.fetchHistoricalOrders = jest + .fn() + .mockResolvedValue(historicalOrdersData); + + const fills = await provider.getOrderFills(); + + expect(fills).toHaveLength(2); + expect(fills[0].detailedOrderType).toBe('Stop Market'); + expect(fills[1].detailedOrderType).toBe('Take Profit Limit'); + }); + + it('gracefully handles historicalOrders failure', async () => { + const mockUserFills = jest.fn().mockResolvedValue([ + { + oid: 200, + coin: 'BTC', + side: 'B', + sz: '0.1', + px: '60000', + fee: '6', + feeToken: 'USDC', + time: Date.now(), + closedPnl: '0', + dir: 'Open Long', + startPosition: '0', + }, + ]); + + mockClientService.getInfoClient = jest.fn().mockReturnValue( + createMockInfoClient({ + userFills: mockUserFills, + historicalOrders: jest.fn().mockRejectedValue(new Error('API error')), + }), + ); + mockClientService.fetchHistoricalOrders = jest + .fn() + .mockRejectedValue(new Error('API error')); + + const fills = await provider.getOrderFills(); + + expect(fills).toHaveLength(1); + expect(fills[0].detailedOrderType).toBeUndefined(); + }); + }); + + describe('getOpenOrders additional coverage', () => { + it('returns empty array when frontendOpenOrders throws error', async () => { + // Arrange + mockClientService.getInfoClient = jest.fn().mockReturnValue({ + frontendOpenOrders: jest.fn().mockRejectedValue(new Error('API Error')), + meta: jest.fn().mockResolvedValue({ + universe: [{ name: 'BTC', szDecimals: 3, maxLeverage: 50 }], + }), + perpDexs: jest.fn().mockResolvedValue([null]), + }); + + // Act + const result = await provider.getOpenOrders({ skipCache: true }); + + // Assert + expect(result).toEqual([]); + }); + + it('returns cached orders when cache is initialized', async () => { + // Arrange + const cachedOrders = [ + { + orderId: '101', + symbol: 'ETH', + side: 'buy' as const, + orderType: 'limit' as const, + size: '1.0', + originalSize: '1.0', + filledSize: '0', + remainingSize: '1.0', + price: '2900', + status: 'open' as const, + timestamp: Date.now(), + detailedOrderType: 'Limit', + reduceOnly: false, + isTrigger: false, + }, + ]; + // Use the atomic getter mock + mockSubscriptionService.getOrdersCacheIfInitialized = jest + .fn() + .mockReturnValue(cachedOrders); + + // Act + const result = await provider.getOpenOrders(); + + // Assert + expect(result).toEqual(cachedOrders); + expect(mockClientService.getInfoClient).not.toHaveBeenCalled(); + }); + + it('falls back to REST when atomic cache getter returns null', async () => { + // Arrange - atomic getter returns null (cache not initialized or race condition) + mockSubscriptionService.getOrdersCacheIfInitialized = jest + .fn() + .mockReturnValue(null); + + const mockFrontendOpenOrders = jest.fn().mockResolvedValue([ + { + coin: 'ETH', + side: 'B', + limitPx: '3000', + sz: '1.0', + oid: 501, + timestamp: Date.now(), + origSz: '1.0', + triggerCondition: '', + isTrigger: false, + triggerPx: '', + children: [], + isPositionTpsl: false, + reduceOnly: false, + orderType: 'Limit', + tif: 'Gtc', + cloid: null, + }, + ]); + + mockClientService.getInfoClient = jest.fn().mockReturnValue({ + maxBuilderFee: jest.fn().mockResolvedValue(1), + referral: jest.fn().mockResolvedValue({ + referrerState: { stage: 'ready', data: { code: 'MMCSI' } }, + referredBy: { code: 'MMCSI' }, + }), + frontendOpenOrders: mockFrontendOpenOrders, + clearinghouseState: jest.fn().mockResolvedValue({ + marginSummary: { totalMarginUsed: '0', accountValue: '1000' }, + withdrawable: '1000', + assetPositions: [], + crossMarginSummary: { accountValue: '1000', totalMarginUsed: '0' }, + }), + meta: jest.fn().mockResolvedValue({ + universe: [{ name: 'ETH', szDecimals: 4, maxLeverage: 25 }], + }), + perpDexs: jest.fn().mockResolvedValue([null]), + }); + + // Act + const result = await provider.getOpenOrders(); + + // Assert - should fall back to REST API + expect(mockFrontendOpenOrders).toHaveBeenCalled(); + expect(result.length).toBe(1); + expect(result[0].orderId).toBe('501'); + }); + + it('returns defensive copy of cached orders (not original array)', async () => { + // Arrange - this tests that the atomic getter returns a copy + const cachedOrders = [ + { + orderId: '101', + symbol: 'ETH', + side: 'buy' as const, + orderType: 'limit' as const, + size: '1.0', + originalSize: '1.0', + filledSize: '0', + remainingSize: '1.0', + price: '2900', + status: 'open' as const, + timestamp: Date.now(), + detailedOrderType: 'Limit', + reduceOnly: false, + isTrigger: false, + }, + ]; + // Return a new array each time (simulating the defensive copy) + mockSubscriptionService.getOrdersCacheIfInitialized = jest + .fn() + .mockImplementation(() => [...cachedOrders]); + + // Act + const result1 = await provider.getOpenOrders(); + const result2 = await provider.getOpenOrders(); + + // Assert - should be equal but not the same reference + expect(result1).toEqual(result2); + expect(result1).not.toBe(result2); // Different array instances + }); + + it('queries only main DEX when no additional DEXs enabled', async () => { + // Arrange + const mockFrontendOpenOrders = jest.fn().mockResolvedValue([ + { + coin: 'ETH', + side: 'B', + limitPx: '3000', + sz: '1.0', + oid: 301, + timestamp: Date.now(), + origSz: '1.0', + triggerCondition: '', + isTrigger: false, + triggerPx: '', + children: [], + isPositionTpsl: false, + reduceOnly: false, + orderType: 'Limit', + tif: 'Gtc', + cloid: null, + }, + ]); + mockClientService.getInfoClient = jest.fn().mockReturnValue({ + maxBuilderFee: jest.fn().mockResolvedValue(1), + referral: jest.fn().mockResolvedValue({ + referrerState: { stage: 'ready', data: { code: 'MMCSI' } }, + referredBy: { code: 'MMCSI' }, + }), + frontendOpenOrders: mockFrontendOpenOrders, + clearinghouseState: jest.fn().mockResolvedValue({ + marginSummary: { totalMarginUsed: '0', accountValue: '1000' }, + withdrawable: '1000', + assetPositions: [], + crossMarginSummary: { accountValue: '1000', totalMarginUsed: '0' }, + }), + meta: jest.fn().mockResolvedValue({ + universe: [{ name: 'ETH', szDecimals: 4, maxLeverage: 25 }], + }), + perpDexs: jest.fn().mockResolvedValue([null]), + }); + + // Act + const result = await provider.getOpenOrders({ skipCache: true }); + + // Assert + expect(result).toHaveLength(1); + expect(result[0].symbol).toBe('ETH'); + // Note: frontendOpenOrders is called twice - once for getOpenOrders and once for getPositions + expect(mockFrontendOpenOrders).toHaveBeenCalled(); + }); + + it('queries multiple DEXs when HIP-3 enabled', async () => { + // Create provider with HIP-3 enabled and allowlist including 'xyz' DEX + const hip3Provider = createTestProvider({ + hip3Enabled: true, + allowlistMarkets: ['xyz:*'], + initialAssetMapping: [ + ['BTC', 0], + ['xyz:STOCK1', 1], + ], + }); + + // Ensure cache is disabled for this test (atomic getter returns null) + mockSubscriptionService.getOrdersCacheIfInitialized = jest + .fn() + .mockReturnValue(null); + + const mockFrontendOpenOrders = jest + .fn() + .mockImplementation((params: { user: string; dex?: string }) => { + if (params.dex === 'xyz') { + return Promise.resolve([ + { + coin: 'xyz:STOCK1', + side: 'B', + limitPx: '100', + sz: '10', + oid: 401, + timestamp: Date.now(), + origSz: '10', + triggerCondition: '', + isTrigger: false, + triggerPx: '', + children: [], + isPositionTpsl: false, + reduceOnly: false, + orderType: 'Limit', + tif: 'Gtc', + cloid: null, + }, + ]); + } + // Main DEX + return Promise.resolve([ + { + coin: 'BTC', + side: 'A', + limitPx: '51000', + sz: '0.5', + oid: 402, + timestamp: Date.now(), + origSz: '0.5', + triggerCondition: '', + isTrigger: false, + triggerPx: '', + children: [], + isPositionTpsl: false, + reduceOnly: false, + orderType: 'Limit', + tif: 'Gtc', + cloid: null, + }, + ]); + }); + mockClientService.getInfoClient = jest.fn().mockReturnValue({ + maxBuilderFee: jest.fn().mockResolvedValue(1), + referral: jest.fn().mockResolvedValue({ + referrerState: { stage: 'ready', data: { code: 'MMCSI' } }, + referredBy: { code: 'MMCSI' }, + }), + frontendOpenOrders: mockFrontendOpenOrders, + clearinghouseState: jest.fn().mockResolvedValue({ + marginSummary: { totalMarginUsed: '0', accountValue: '1000' }, + withdrawable: '1000', + assetPositions: [], + crossMarginSummary: { accountValue: '1000', totalMarginUsed: '0' }, + }), + meta: jest.fn().mockResolvedValue({ + universe: [ + { name: 'BTC', szDecimals: 3, maxLeverage: 50 }, + { name: 'xyz:STOCK1', szDecimals: 2, maxLeverage: 20 }, + ], + }), + perpDexs: jest + .fn() + .mockResolvedValue([null, { name: 'xyz', url: 'https://xyz.com' }]), + }); + + // Act + const result = await hip3Provider.getOpenOrders({ skipCache: true }); + + // Assert + expect(result).toHaveLength(2); + // Verify both orders are present (order may vary due to Promise.all) + const symbols = result.map((r) => r.symbol); + expect(symbols).toContain('xyz:STOCK1'); + expect(symbols).toContain('BTC'); + // Verify both DEXs were queried + expect(mockFrontendOpenOrders).toHaveBeenCalled(); + expect( + mockFrontendOpenOrders.mock.calls.some((call) => call[0].dex === 'xyz'), + ).toBe(true); + }); + }); + + describe('getUserHistory', () => { + it('returns user history items successfully', async () => { + // Arrange + const mockLedgerUpdates = [ + { + delta: { type: 'deposit', usdc: '100' }, + time: Date.now(), + hash: '0x123', + }, + { + delta: { type: 'withdraw', usdc: '50' }, + time: Date.now() - 3600000, + hash: '0x456', + }, + ]; + mockClientService.getInfoClient = jest.fn().mockReturnValue( + createMockInfoClient({ + userNonFundingLedgerUpdates: jest + .fn() + .mockResolvedValue(mockLedgerUpdates), + }), + ); + + // Act + const result = await provider.getUserHistory(); + + // Assert + expect(Array.isArray(result)).toBe(true); + expect(result.length).toBeGreaterThan(0); + expect(mockClientService.getInfoClient).toHaveBeenCalled(); + }); + + it('returns empty array on API error', async () => { + // Arrange + mockClientService.getInfoClient = jest.fn().mockReturnValue( + createMockInfoClient({ + userNonFundingLedgerUpdates: jest + .fn() + .mockRejectedValue(new Error('API Error')), + }), + ); + + // Act + const result = await provider.getUserHistory(); + + // Assert + expect(result).toEqual([]); + }); + + it('handles custom time range parameters', async () => { + // Arrange + const startTime = Date.now() - 86400000; // 24h ago + const endTime = Date.now(); + const mockInfoClient = createMockInfoClient(); + + mockClientService.getInfoClient = jest + .fn() + .mockReturnValue(mockInfoClient); + + // Act + await provider.getUserHistory({ startTime, endTime }); + + // Assert + expect(mockInfoClient.userNonFundingLedgerUpdates).toHaveBeenCalledWith( + expect.objectContaining({ + startTime, + endTime, + }), + ); + }); + + it('uses default account when no accountId provided', async () => { + // Arrange + const mockInfoClient = createMockInfoClient(); + mockClientService.getInfoClient = jest + .fn() + .mockReturnValue(mockInfoClient); + + // Act + await provider.getUserHistory(); + + // Assert + expect(mockWalletService.getUserAddressWithDefault).toHaveBeenCalledWith( + undefined, + ); + expect(mockInfoClient.userNonFundingLedgerUpdates).toHaveBeenCalled(); + }); + }); + + describe('getHistoricalPortfolio', () => { + it('returns historical portfolio value from 24h ago', async () => { + // Arrange + const yesterday = Date.now() - 86400000; + mockClientService.getInfoClient = jest.fn().mockReturnValue( + createMockInfoClient({ + portfolio: jest.fn().mockResolvedValue([ + null, + [ + null, + { + accountValueHistory: [ + [yesterday, '10000'], + [yesterday - 86400000, '9500'], + ], + }, + ], + ]), + }), + ); + + // Act + const result = await provider.getHistoricalPortfolio(); + + // Assert + expect(result.accountValue1dAgo).toBeDefined(); + expect(result.timestamp).toBeDefined(); + }); + + it('finds closest entry before target timestamp', async () => { + // Arrange + const now = Date.now(); + const closestTime = now - 87000000; // Slightly older than 24h + + mockClientService.getInfoClient = jest.fn().mockReturnValue( + createMockInfoClient({ + portfolio: jest.fn().mockResolvedValue([ + null, + [ + null, + { + accountValueHistory: [ + [closestTime, '10000'], // This should be selected + [now - 172800000, '9500'], // Too old + ], + }, + ], + ]), + }), + ); + + // Act + const result = await provider.getHistoricalPortfolio(); + + // Assert + expect(result.accountValue1dAgo).toBe('10000'); + expect(result.timestamp).toBe(closestTime); + }); + + it('returns fallback when no historical data exists', async () => { + // Arrange + mockClientService.getInfoClient = jest.fn().mockReturnValue( + createMockInfoClient({ + portfolio: jest.fn().mockResolvedValue([ + null, + [ + null, + { + accountValueHistory: [], + }, + ], + ]), + }), + ); + + // Act + const result = await provider.getHistoricalPortfolio(); + + // Assert + expect(result.accountValue1dAgo).toBe('0'); + expect(result.timestamp).toBe(0); + }); + + it('handles empty portfolio data gracefully', async () => { + // Arrange + mockClientService.getInfoClient = jest.fn().mockReturnValue( + createMockInfoClient({ + portfolio: jest.fn().mockResolvedValue(null), + }), + ); + + // Act + const result = await provider.getHistoricalPortfolio(); + + // Assert + expect(result.accountValue1dAgo).toBe('0'); + expect(result.timestamp).toBe(0); + }); + + it('returns zero values on error', async () => { + // Arrange + mockClientService.getInfoClient = jest.fn().mockReturnValue( + createMockInfoClient({ + portfolio: jest + .fn() + .mockRejectedValue(new Error('Portfolio API error')), + }), + ); + + // Act + const result = await provider.getHistoricalPortfolio(); + + // Assert + expect(result.accountValue1dAgo).toBe('0'); + expect(result.timestamp).toBe(0); + }); + }); + + describe('getAvailableHip3Dexs', () => { + it('returns HIP-3 DEX names when equity enabled', async () => { + // Arrange - use existing provider with updated mock + const mockInfoClientWithDexs = createMockInfoClient({ + perpDexs: jest + .fn() + .mockResolvedValue([ + null, + { name: 'dex1', url: 'https://dex1.com' }, + { name: 'dex2', url: 'https://dex2.com' }, + ]), + }); + + mockClientService.getInfoClient = jest + .fn() + .mockReturnValue(mockInfoClientWithDexs); + + // Create a provider instance with equity enabled for this specific test + const testProvider = createTestProvider({ hip3Enabled: true }); + + // DEX discovery cache starts with null state on a fresh provider — no reset needed + + // Act + const result = await testProvider.getAvailableHip3Dexs(); + + // Assert + expect(Array.isArray(result)).toBe(true); + expect(mockInfoClientWithDexs.perpDexs).toHaveBeenCalled(); + }); + + it('returns empty array when equity disabled', async () => { + // Arrange + const disabledProvider = createTestProvider({ + hip3Enabled: false, + }); + + // Act + const result = await disabledProvider.getAvailableHip3Dexs(); + + // Assert + expect(result).toEqual([]); + }); + + it('returns empty array when perpDexs returns invalid data', async () => { + // Arrange + const hip3Provider = createTestProvider({ hip3Enabled: true }); + mockClientService.getInfoClient = jest.fn().mockReturnValue( + createMockInfoClient({ + perpDexs: jest.fn().mockResolvedValue(null), + }), + ); + + // Act + const result = await hip3Provider.getAvailableHip3Dexs(); + + // Assert + expect(result).toEqual([]); + }); + }); + + describe('transferBetweenDexs', () => { + beforeEach(() => { + // Add spotMeta to mock for getUsdcTokenId + mockClientService.getInfoClient = jest.fn().mockReturnValue( + createMockInfoClient({ + spotMeta: jest.fn().mockResolvedValue({ + tokens: [{ name: 'USDC', tokenId: '0xabc123', index: 0 }], + universe: [], + }), + }), + ); + }); + + it('transfers USDC between DEXs successfully', async () => { + // Arrange + const transferParams = { + sourceDex: 'dex1', + destinationDex: 'dex2', + amount: '100', + }; + + // Act + const result = await provider.transferBetweenDexs(transferParams); + + // Assert + expect(result.success).toBe(true); + expect( + mockClientService.getExchangeClient().sendAsset, + ).toHaveBeenCalledWith( + expect.objectContaining({ + sourceDex: 'dex1', + destinationDex: 'dex2', + amount: '100', + token: expect.any(String), + }), + ); + }); + + it('rejects transfer with zero amount', async () => { + // Arrange + const transferParams = { + sourceDex: 'dex1', + destinationDex: 'dex2', + amount: '0', + }; + + // Act + const result = await provider.transferBetweenDexs(transferParams); + + // Assert + expect(result.success).toBe(false); + expect(result.error).toContain('must be greater than 0'); + }); + + it('rejects transfer when source equals destination', async () => { + // Arrange + const transferParams = { + sourceDex: 'dex1', + destinationDex: 'dex1', + amount: '100', + }; + + // Act + const result = await provider.transferBetweenDexs(transferParams); + + // Assert + expect(result.success).toBe(false); + expect(result.error).toContain('must be different'); + }); + + it('handles sendAsset failure gracefully', async () => { + // Arrange + mockClientService.getExchangeClient = jest.fn().mockReturnValue( + createMockExchangeClient({ + sendAsset: jest.fn().mockResolvedValue({ + status: 'error', + message: 'Insufficient balance', + }), + }), + ); + const transferParams = { + sourceDex: 'dex1', + destinationDex: 'dex2', + amount: '100', + }; + + // Act + const result = await provider.transferBetweenDexs(transferParams); + + // Assert + expect(result.success).toBe(false); + expect(result.error).toBeDefined(); + }); + + it('calls getUsdcTokenId to get correct token', async () => { + // Arrange + const mockSpotMeta = jest.fn().mockResolvedValue({ + tokens: [{ name: 'USDC', tokenId: '0xspecific', index: 0 }], + universe: [], + }); + mockClientService.getInfoClient = jest + .fn() + .mockReturnValue(createMockInfoClient({ spotMeta: mockSpotMeta })); + const transferParams = { + sourceDex: '', + destinationDex: 'dex1', + amount: '100', + }; + + // Act + await provider.transferBetweenDexs(transferParams); + + // Assert + expect(mockSpotMeta).toHaveBeenCalled(); + expect( + mockClientService.getExchangeClient().sendAsset, + ).toHaveBeenCalledWith( + expect.objectContaining({ + token: 'USDC:0xspecific', + }), + ); + }); + }); +}); diff --git a/packages/perps-controller/tests/src/providers/HyperLiquidProvider.lifecycle.test.ts b/packages/perps-controller/tests/src/providers/HyperLiquidProvider.lifecycle.test.ts new file mode 100644 index 00000000000..5d39a676b60 --- /dev/null +++ b/packages/perps-controller/tests/src/providers/HyperLiquidProvider.lifecycle.test.ts @@ -0,0 +1,694 @@ +/* eslint-disable */ +jest.mock('@nktkas/hyperliquid', () => ({})); + +import type { CaipAssetId, Hex } from '@metamask/utils'; + +import { CandlePeriod } from '../../../src/constants/chartConfig.js'; +import { + BUILDER_FEE_CONFIG, + REFERRAL_CONFIG, +} from '../../../src/constants/hyperLiquidConfig.js'; +import { PERPS_TRANSACTIONS_HISTORY_CONSTANTS } from '../../../src/constants/transactionsHistoryConfig.js'; +import { PERPS_ERROR_CODES } from '../../../src/perpsErrorCodes.js'; +import { HyperLiquidProvider } from '../../../src/providers/HyperLiquidProvider.js'; +import { HyperLiquidClientService } from '../../../src/services/HyperLiquidClientService.js'; +import { HyperLiquidSubscriptionService } from '../../../src/services/HyperLiquidSubscriptionService.js'; +import { HyperLiquidWalletService } from '../../../src/services/HyperLiquidWalletService.js'; +import { TradingReadinessCache } from '../../../src/services/TradingReadinessCache.js'; +import type { + ClosePositionParams, + DepositParams, + Order, + PerpsPlatformDependencies, + LiveDataConfig, + OrderParams, +} from '../../../src/types/index.js'; +import { + validateAssetSupport, + validateBalance, + validateCoinExists, + validateDepositParams, + validateOrderParams, + validateWithdrawalParams, +} from '../../../src/utils/hyperLiquidValidation.js'; +import { createStandaloneInfoClient } from '../../../src/utils/standaloneInfoClient.js'; +import { + createMockInfrastructure, + createMockMessenger, +} from '../../helpers/serviceMocks.js'; + +jest.mock('../../../src/services/HyperLiquidClientService'); +jest.mock('../../../src/services/HyperLiquidWalletService'); +jest.mock('../../../src/services/HyperLiquidSubscriptionService'); +// Mock stream manager - will be set up in test +let mockStreamManagerInstance: any; +const mockGetStreamManagerInstance = jest.fn(() => mockStreamManagerInstance); +jest.mock( + '../../../../components/UI/Perps/providers/PerpsStreamManager', + () => ({ + getStreamManagerInstance: mockGetStreamManagerInstance, + }), + { virtual: true }, +); + +// Mock standalone info client for standalone mode tests +let mockStandaloneInfoClient: any; +jest.mock('../../../src/utils/standaloneInfoClient', () => ({ + ...jest.requireActual('../../../src/utils/standaloneInfoClient'), + createStandaloneInfoClient: jest.fn(() => mockStandaloneInfoClient), +})); + +jest.mock('../../../src/utils/hyperLiquidValidation', () => ({ + validateOrderParams: jest.fn(), + validateWithdrawalParams: jest.fn(), + validateDepositParams: jest.fn(), + validateCoinExists: jest.fn(), + validateAssetSupport: jest.fn(), + validateBalance: jest.fn(), + getSupportedPaths: jest + .fn() + .mockReturnValue([ + 'eip155:42161/erc20:0xa0b86a33e6776e681a06e0e1622c5e5e3e6a8b13/default', + 'eip155:1/erc20:0xa0b86a33e6776e681a06e0e1622c5e5e3e6a8b13/default', + ]), + getBridgeInfo: jest.fn().mockReturnValue({ + chainId: 'eip155:42161', + contractAddress: '0x1234567890123456789012345678901234567890', + }), + createErrorResult: jest.fn((error, defaultResponse) => ({ + ...defaultResponse, + success: false, + error: error instanceof Error ? error.message : String(error), + })), +})); + +// Mock adapter functions +jest.mock('../../../src/utils/hyperLiquidAdapter', () => { + const actual = jest.requireActual('../../../src/utils/hyperLiquidAdapter'); + return { + ...actual, + adaptHyperLiquidLedgerUpdateToUserHistoryItem: jest.fn((updates) => { + // Return mock history items based on input + if (!updates || !Array.isArray(updates) || updates.length === 0) { + return []; + } + return updates.map((_update: unknown) => ({ + type: 'deposit' as const, + amount: '100', + timestamp: Date.now(), + hash: '0x123', + })); + }), + }; +}); + +// Mock TradingReadinessCache - global singleton for signing operation caching +// Use jest.createMockFromModule for proper mock creation +jest.mock('../../../src/services/TradingReadinessCache'); + +const MockedHyperLiquidClientService = + HyperLiquidClientService as jest.MockedClass; +const MockedHyperLiquidWalletService = + HyperLiquidWalletService as jest.MockedClass; +const MockedHyperLiquidSubscriptionService = + HyperLiquidSubscriptionService as jest.MockedClass< + typeof HyperLiquidSubscriptionService + >; +const mockValidateOrderParams = validateOrderParams as jest.MockedFunction< + typeof validateOrderParams +>; +const mockValidateWithdrawalParams = + validateWithdrawalParams as jest.MockedFunction< + typeof validateWithdrawalParams + >; +const mockValidateDepositParams = validateDepositParams as jest.MockedFunction< + typeof validateDepositParams +>; +const mockValidateCoinExists = validateCoinExists as jest.MockedFunction< + typeof validateCoinExists +>; +const mockValidateAssetSupport = validateAssetSupport as jest.MockedFunction< + typeof validateAssetSupport +>; +const mockValidateBalance = validateBalance as jest.MockedFunction< + typeof validateBalance +>; + +// Mock factory functions - defined once, reused everywhere +// These reduce duplication and make tests more maintainable +const createMockInfoClient = (overrides: Record = {}) => ({ + clearinghouseState: jest.fn().mockResolvedValue({ + marginSummary: { + totalMarginUsed: '500', + accountValue: '10500', + }, + withdrawable: '9500', + assetPositions: [ + { + position: { + coin: 'BTC', + szi: '0.1', + entryPx: '50000', + positionValue: '5000', + unrealizedPnl: '100', + marginUsed: '500', + leverage: { type: 'cross', value: 10 }, + liquidationPx: '45000', + maxLeverage: 50, + returnOnEquity: '20', + cumFunding: { allTime: '10', sinceOpen: '5', sinceChange: '2' }, + }, + type: 'oneWay', + }, + { + position: { + coin: 'ETH', + szi: '1.5', + entryPx: '3000', + positionValue: '4500', + unrealizedPnl: '50', + marginUsed: '450', + leverage: { type: 'cross', value: 10 }, + liquidationPx: '2700', + maxLeverage: 50, + returnOnEquity: '10', + cumFunding: { allTime: '5', sinceOpen: '2', sinceChange: '1' }, + }, + type: 'oneWay', + }, + ], + crossMarginSummary: { + accountValue: '10000', + totalMarginUsed: '5000', + }, + }), + spotClearinghouseState: jest.fn().mockResolvedValue({ + balances: [{ coin: 'USDC', hold: '1000', total: '10000' }], + }), + // Mode-aware fold gate reads userAbstraction; default to unifiedAccount + // so tests that predated the gate still see spot folded into spendable/withdrawable. + userAbstraction: jest.fn().mockResolvedValue('unifiedAccount'), + meta: jest.fn().mockResolvedValue({ + universe: [ + { name: 'BTC', szDecimals: 3, maxLeverage: 50 }, + { name: 'ETH', szDecimals: 4, maxLeverage: 50 }, + ], + }), + metaAndAssetCtxs: jest.fn().mockResolvedValue([ + { + universe: [ + { name: 'BTC', szDecimals: 3, maxLeverage: 50 }, + { name: 'ETH', szDecimals: 4, maxLeverage: 50 }, + ], + }, + [ + { + funding: '0.0001', + openInterest: '1000', + prevDayPx: '49000', + dayNtlVlm: '1000000', + markPx: '50000', + midPx: '50000', + oraclePx: '50000', + }, + { + funding: '0.0001', + openInterest: '500', + prevDayPx: '2900', + dayNtlVlm: '500000', + markPx: '3000', + midPx: '3000', + oraclePx: '3000', + }, + ], + ]), + perpDexs: jest.fn().mockResolvedValue([null]), + allMids: jest.fn().mockResolvedValue({ BTC: '50000', ETH: '3000' }), + frontendOpenOrders: jest.fn().mockResolvedValue([]), + referral: jest.fn().mockResolvedValue({ + referrerState: { + stage: 'ready', + data: { code: 'MMCSI' }, + }, + }), + maxBuilderFee: jest.fn().mockResolvedValue(1), + userFees: jest.fn().mockResolvedValue({ + feeSchedule: { + cross: '0.00030', + add: '0.00010', + spotCross: '0.00040', + spotAdd: '0.00020', + }, + dailyUserVlm: [], + }), + userNonFundingLedgerUpdates: jest.fn().mockResolvedValue([ + { + delta: { type: 'deposit', usdc: '100' }, + time: Date.now(), + hash: '0x123abc', + }, + { + delta: { type: 'withdraw', usdc: '50' }, + time: Date.now() - 3600000, + hash: '0x456def', + }, + ]), + portfolio: jest.fn().mockResolvedValue([ + null, + [ + null, + { + accountValueHistory: [ + [Date.now() - 86400000, '10000'], // 24h ago + [Date.now() - 172800000, '9500'], // 48h ago + [Date.now() - 259200000, '9000'], // 72h ago + ], + }, + ], + ]), + spotMeta: jest.fn().mockResolvedValue({ + tokens: [ + { name: 'USDC', tokenId: '0xdef456', index: 0 }, + { name: 'USDT', tokenId: '0x789abc', index: 1 }, + ], + universe: [], + }), + historicalOrders: jest.fn().mockResolvedValue([]), + userFills: jest.fn().mockResolvedValue([]), + userFillsByTime: jest.fn().mockResolvedValue([]), + userFunding: jest.fn().mockResolvedValue([]), + ...overrides, +}); + +const createMockExchangeClient = (overrides: Record = {}) => ({ + order: jest.fn().mockResolvedValue({ + status: 'ok', + response: { data: { statuses: [{ resting: { oid: 123 } }] } }, + }), + modify: jest.fn().mockResolvedValue({ + status: 'ok', + response: { data: { statuses: [{ resting: { oid: '123' } }] } }, + }), + cancel: jest.fn().mockResolvedValue({ + status: 'ok', + response: { data: { statuses: ['success'] } }, + }), + withdraw3: jest.fn().mockResolvedValue({ + status: 'ok', + }), + updateLeverage: jest.fn().mockResolvedValue({ + status: 'ok', + }), + approveBuilderFee: jest.fn().mockResolvedValue({ + status: 'ok', + }), + setReferrer: jest.fn().mockResolvedValue({ + status: 'ok', + }), + sendAsset: jest.fn().mockResolvedValue({ + status: 'ok', + }), + agentSetAbstraction: jest.fn().mockResolvedValue({ + status: 'ok', + }), + userSetAbstraction: jest.fn().mockResolvedValue({ + status: 'ok', + }), + ...overrides, +}); + +// Create shared mock platform dependencies for provider tests +const mockPlatformDependencies: PerpsPlatformDependencies = + createMockInfrastructure(); + +const mockMessenger = createMockMessenger(); + +/** + * Helper to create HyperLiquidProvider with mock platform dependencies + * @param options + * @param options.isTestnet + * @param options.hip3Enabled + * @param options.allowlistMarkets + * @param options.blocklistMarkets + * @param options.useUnifiedAccount + */ +const createTestProvider = ( + options: { + isTestnet?: boolean; + hip3Enabled?: boolean; + allowlistMarkets?: string[]; + blocklistMarkets?: string[]; + useUnifiedAccount?: boolean; + initialAssetMapping?: [string, number][]; + } = {}, +): HyperLiquidProvider => + new HyperLiquidProvider({ + ...options, + platformDependencies: mockPlatformDependencies, + messenger: mockMessenger, + }); + +describe('HyperLiquidProvider', () => { + let provider: HyperLiquidProvider; + let mockClientService: jest.Mocked; + let mockWalletService: jest.Mocked; + let mockSubscriptionService: jest.Mocked; + + beforeEach(() => { + // Reset all mocks + jest.clearAllMocks(); + ( + mockPlatformDependencies.marketDataFormatters.formatVolume as jest.Mock + ).mockImplementation((value: number) => '$' + value.toFixed(0)); + ( + mockPlatformDependencies.marketDataFormatters.formatPerpsFiat as jest.Mock + ).mockImplementation((value: number) => '$' + value.toFixed(2)); + ( + mockPlatformDependencies.marketDataFormatters + .formatPercentage as jest.Mock + ).mockImplementation((value: number) => `${value.toFixed(2)}%`); + ( + mockPlatformDependencies.featureFlags.validateVersionGated as jest.Mock + ).mockReturnValue(undefined); + (mockPlatformDependencies.metrics.isEnabled as jest.Mock).mockReturnValue( + true, + ); + + // Reset TradingReadinessCache mock state (using imported mocked module) + const mockedCache = TradingReadinessCache as jest.Mocked< + typeof TradingReadinessCache + >; + mockedCache.get.mockReturnValue(undefined); + mockedCache.getBuilderFee.mockReturnValue(undefined); + mockedCache.getReferral.mockReturnValue(undefined); + mockedCache.isInFlight.mockReturnValue(undefined); + mockedCache.setInFlight.mockReturnValue(jest.fn()); + + // Initialize mock stream manager instance + mockStreamManagerInstance = { + clearAllChannels: jest.fn(), + }; + + // Create mocked service instances using factory functions + mockClientService = { + initialize: jest.fn(), + isInitialized: jest.fn().mockReturnValue(true), + isTestnetMode: jest.fn().mockReturnValue(false), + ensureInitialized: jest.fn(), + getExchangeClient: jest.fn().mockReturnValue(createMockExchangeClient()), + getInfoClient: jest.fn().mockReturnValue(createMockInfoClient()), + fetchHistoricalOrders: jest.fn().mockResolvedValue([]), + disconnect: jest.fn().mockResolvedValue(undefined), + toggleTestnet: jest.fn(), + setTestnetMode: jest.fn(), + getNetwork: jest.fn().mockReturnValue('mainnet'), + ensureSubscriptionClient: jest.fn().mockResolvedValue(undefined), + getSubscriptionClient: jest.fn(), + setOnReconnectCallback: jest.fn(), + setOnTerminateCallback: jest.fn(), + getConnectionState: jest.fn().mockReturnValue('connected'), + } as Partial as jest.Mocked; + + mockWalletService = { + setTestnetMode: jest.fn(), + getCurrentAccountId: jest + .fn() + .mockReturnValue( + 'eip155:42161:0x1234567890123456789012345678901234567890', + ), + createWalletAdapter: jest.fn().mockReturnValue({ + request: jest + .fn() + .mockResolvedValue(['0x1234567890123456789012345678901234567890']), + }), + getUserAddress: jest + .fn() + .mockReturnValue('0x1234567890123456789012345678901234567890'), + getUserAddressWithDefault: jest + .fn() + .mockResolvedValue('0x1234567890123456789012345678901234567890'), + isKeyringUnlocked: jest.fn().mockReturnValue(true), + isSelectedHardwareWallet: jest.fn().mockReturnValue(false), + } as Partial as jest.Mocked; + + mockSubscriptionService = { + subscribeToPrices: jest.fn().mockResolvedValue(jest.fn()), // Returns Promise + subscribeToPositions: jest.fn().mockReturnValue(jest.fn()), // Returns function directly + subscribeToOrderFills: jest.fn().mockReturnValue(jest.fn()), // Returns function directly + clearAll: jest.fn(), + isPositionsCacheInitialized: jest.fn().mockReturnValue(false), + getCachedPositions: jest.fn().mockReturnValue([]), + updateFeatureFlags: jest.fn().mockResolvedValue(undefined), + // Cache methods used by buildAssetMapping optimization + setDexMetaCache: jest.fn(), + setDexAssetCtxsCache: jest.fn(), + getDexAssetCtxsCache: jest.fn().mockReturnValue(undefined), + // Price cache used by placeOrder, editOrder, closePosition optimizations + getCachedPrice: jest.fn().mockImplementation((symbol: string) => { + const prices: Record = { BTC: '50000', ETH: '3000' }; + return prices[symbol]; + }), + getLastAllMidsSnapshot: jest.fn().mockReturnValue(null), + // Orders cache used by updatePositionTPSL and getOpenOrders + isOrdersCacheInitialized: jest.fn().mockReturnValue(false), + getCachedOrders: jest.fn().mockReturnValue([]), + // Atomic getter - returns null when cache not initialized (prevents race condition) + getOrdersCacheIfInitialized: jest.fn().mockReturnValue(null), + // Abstraction-mode resolved-mode setter (unified account migration) + setUserAbstractionMode: jest.fn(), + } as Partial as jest.Mocked; + + // Mock constructors + MockedHyperLiquidClientService.mockImplementation(() => mockClientService); + MockedHyperLiquidWalletService.mockImplementation(() => mockWalletService); + MockedHyperLiquidSubscriptionService.mockImplementation( + () => mockSubscriptionService, + ); + + // Mock validation + mockValidateOrderParams.mockReturnValue({ isValid: true }); + mockValidateWithdrawalParams.mockReturnValue({ isValid: true }); + mockValidateDepositParams.mockReturnValue({ isValid: true }); + mockValidateCoinExists.mockReturnValue({ isValid: true }); + mockValidateAssetSupport.mockReturnValue({ isValid: true }); + mockValidateBalance.mockReturnValue({ isValid: true }); + const hyperLiquidValidation = jest.requireMock( + '../../../src/utils/hyperLiquidValidation', + ); + hyperLiquidValidation.getSupportedPaths.mockReturnValue([ + 'eip155:42161/erc20:0xa0b86a33e6776e681a06e0e1622c5e5e3e6a8b13/default', + 'eip155:1/erc20:0xa0b86a33e6776e681a06e0e1622c5e5e3e6a8b13/default', + ]); + hyperLiquidValidation.getBridgeInfo.mockReturnValue({ + chainId: 'eip155:42161', + contractAddress: '0x1234567890123456789012345678901234567890', + }); + hyperLiquidValidation.createErrorResult.mockImplementation( + (error: unknown, defaultResponse: Record) => ({ + ...defaultResponse, + success: false, + error: error instanceof Error ? error.message : String(error), + }), + ); + const hyperLiquidAdapter = jest.requireMock( + '../../../src/utils/hyperLiquidAdapter', + ); + hyperLiquidAdapter.adaptHyperLiquidLedgerUpdateToUserHistoryItem.mockImplementation( + (updates: unknown[]) => { + if (!updates || !Array.isArray(updates) || updates.length === 0) { + return []; + } + return updates.map(() => ({ + type: 'deposit' as const, + amount: '100', + timestamp: Date.now(), + hash: '0x123', + })); + }, + ); + + provider = createTestProvider({ + initialAssetMapping: [ + ['BTC', 0], + ['ETH', 1], + ], + }); + }); + describe('Constructor and Initialization', () => { + it('initializes with default mainnet configuration', () => { + expect(provider).toBeDefined(); + expect(provider.protocolId).toBe('hyperliquid'); + }); + + it('initializes with testnet configuration', () => { + const testnetProvider = createTestProvider({ isTestnet: true }); + expect(testnetProvider).toBeDefined(); + expect(testnetProvider.protocolId).toBe('hyperliquid'); + }); + + it('initializes provider successfully', async () => { + const result = await provider.initialize(); + + expect(result.success).toBe(true); + expect(mockClientService.initialize).toHaveBeenCalled(); + }); + + it('handles initialization errors', async () => { + mockClientService.initialize.mockImplementationOnce(() => { + throw new Error('Init failed'); + }); + + const result = await provider.initialize(); + + expect(result.success).toBe(false); + expect(result.error).toContain('Init failed'); + }); + + it('initializes with HIP-3 disabled when hip3Enabled is false', async () => { + const disabledProvider = createTestProvider({ + hip3Enabled: false, + }); + + mockClientService.getInfoClient = jest.fn().mockReturnValue( + createMockInfoClient({ + perpDexs: jest + .fn() + .mockResolvedValue([ + null, + { name: 'dex1', url: 'https://dex1.example' }, + ]), + }), + ); + + await disabledProvider.initialize(); + + const markets = await disabledProvider.getMarkets(); + expect(Array.isArray(markets)).toBe(true); + }); + + it('falls back to main DEX when perpDexs returns invalid response', async () => { + mockClientService.getInfoClient = jest.fn().mockReturnValue( + createMockInfoClient({ + perpDexs: jest.fn().mockResolvedValue(null), + }), + ); + + await provider.initialize(); + + const markets = await provider.getMarkets(); + expect(Array.isArray(markets)).toBe(true); + }); + + it('does not throw when allowlist contains invalid patterns', () => { + expect(() => { + createTestProvider({ + hip3Enabled: true, + allowlistMarkets: ['xyz:TSLA', '"bad"pattern"', 'valid:*'], + }); + }).not.toThrow(); + }); + + it('does not throw when blocklist contains invalid patterns', () => { + expect(() => { + createTestProvider({ + hip3Enabled: true, + blocklistMarkets: ['valid:BTC', '"invalid"', 'also:valid'], + }); + }).not.toThrow(); + }); + + it('logs warning for skipped invalid patterns', () => { + createTestProvider({ + hip3Enabled: true, + allowlistMarkets: ['"bad"pattern"'], + }); + + expect(mockPlatformDependencies.logger.error).toHaveBeenCalledWith( + expect.any(Error), + expect.objectContaining({ + tags: expect.objectContaining({ provider: 'hyperliquid' }), + context: expect.objectContaining({ + name: 'HyperLiquidProvider', + data: expect.objectContaining({ + method: 'compilePatternsSafely', + pattern: '"bad"pattern"', + }), + }), + }), + ); + }); + + it('compiles valid patterns even when some are invalid', () => { + const testProvider = createTestProvider({ + hip3Enabled: true, + allowlistMarkets: ['xyz:TSLA', '"bad"pattern"', 'valid:*'], + }); + + // Provider should be functional — valid patterns compiled, bad ones skipped + expect(testProvider).toBeDefined(); + expect(testProvider.protocolId).toBe('hyperliquid'); + }); + + it('handles perpDexs array with null entries', async () => { + mockClientService.getInfoClient = jest.fn().mockReturnValue( + createMockInfoClient({ + perpDexs: jest + .fn() + .mockResolvedValue([ + null, + { name: 'dex1', url: 'https://dex1.example' }, + null, + ]), + }), + ); + + await provider.initialize(); + + const markets = await provider.getMarkets(); + expect(Array.isArray(markets)).toBe(true); + }); + }); + + describe('Route Management', () => { + it('gets deposit routes with constraints', () => { + const routes = provider.getDepositRoutes(); + expect(Array.isArray(routes)).toBe(true); + + // Check that routes have constraints + if (routes.length > 0) { + const route = routes[0]; + expect(route.constraints).toBeDefined(); + expect(route.constraints?.minAmount).toBe('1.01'); + expect(route.constraints?.estimatedMinutes).toBe(5); + expect(route.constraints?.fees).toEqual({ + fixed: 1, + token: 'USDC', + }); + } + }); + + it('gets withdrawal routes with constraints', () => { + const routes = provider.getWithdrawalRoutes(); + expect(Array.isArray(routes)).toBe(true); + + // Check that routes have constraints (same as deposit routes) + if (routes.length > 0) { + const route = routes[0]; + expect(route.constraints).toBeDefined(); + expect(route.constraints?.minAmount).toBe('1.01'); + expect(route.constraints?.estimatedMinutes).toBe(5); + expect(route.constraints?.fees).toEqual({ + fixed: 1, + token: 'USDC', + }); + } + }); + + it('filters routes by parameters', () => { + const params = { isTestnet: true }; + const routes = provider.getDepositRoutes(params); + + expect(Array.isArray(routes)).toBe(true); + }); + }); +}); diff --git a/packages/perps-controller/tests/src/providers/HyperLiquidProvider.misc.test.ts b/packages/perps-controller/tests/src/providers/HyperLiquidProvider.misc.test.ts new file mode 100644 index 00000000000..aac8bc5d479 --- /dev/null +++ b/packages/perps-controller/tests/src/providers/HyperLiquidProvider.misc.test.ts @@ -0,0 +1,726 @@ +/* eslint-disable */ +jest.mock('@nktkas/hyperliquid', () => ({})); + +import type { CaipAssetId, Hex } from '@metamask/utils'; + +import { CandlePeriod } from '../../../src/constants/chartConfig.js'; +import { + BUILDER_FEE_CONFIG, + REFERRAL_CONFIG, +} from '../../../src/constants/hyperLiquidConfig.js'; +import { PERPS_TRANSACTIONS_HISTORY_CONSTANTS } from '../../../src/constants/transactionsHistoryConfig.js'; +import { PERPS_ERROR_CODES } from '../../../src/perpsErrorCodes.js'; +import { HyperLiquidProvider } from '../../../src/providers/HyperLiquidProvider.js'; +import { HyperLiquidClientService } from '../../../src/services/HyperLiquidClientService.js'; +import { HyperLiquidSubscriptionService } from '../../../src/services/HyperLiquidSubscriptionService.js'; +import { HyperLiquidWalletService } from '../../../src/services/HyperLiquidWalletService.js'; +import { TradingReadinessCache } from '../../../src/services/TradingReadinessCache.js'; +import type { + ClosePositionParams, + DepositParams, + Order, + PerpsPlatformDependencies, + LiveDataConfig, + OrderParams, +} from '../../../src/types/index.js'; +import { + validateAssetSupport, + validateBalance, + validateCoinExists, + validateDepositParams, + validateOrderParams, + validateWithdrawalParams, +} from '../../../src/utils/hyperLiquidValidation.js'; +import { createStandaloneInfoClient } from '../../../src/utils/standaloneInfoClient.js'; +import { + createMockInfrastructure, + createMockMessenger, +} from '../../helpers/serviceMocks.js'; + +jest.mock('../../../src/services/HyperLiquidClientService'); +jest.mock('../../../src/services/HyperLiquidWalletService'); +jest.mock('../../../src/services/HyperLiquidSubscriptionService'); +// Mock stream manager - will be set up in test +let mockStreamManagerInstance: any; +const mockGetStreamManagerInstance = jest.fn(() => mockStreamManagerInstance); +jest.mock( + '../../../../components/UI/Perps/providers/PerpsStreamManager', + () => ({ + getStreamManagerInstance: mockGetStreamManagerInstance, + }), + { virtual: true }, +); + +// Mock standalone info client for standalone mode tests +let mockStandaloneInfoClient: any; +jest.mock('../../../src/utils/standaloneInfoClient', () => ({ + ...jest.requireActual('../../../src/utils/standaloneInfoClient'), + createStandaloneInfoClient: jest.fn(() => mockStandaloneInfoClient), +})); + +jest.mock('../../../src/utils/hyperLiquidValidation', () => ({ + validateOrderParams: jest.fn(), + validateWithdrawalParams: jest.fn(), + validateDepositParams: jest.fn(), + validateCoinExists: jest.fn(), + validateAssetSupport: jest.fn(), + validateBalance: jest.fn(), + getSupportedPaths: jest + .fn() + .mockReturnValue([ + 'eip155:42161/erc20:0xa0b86a33e6776e681a06e0e1622c5e5e3e6a8b13/default', + 'eip155:1/erc20:0xa0b86a33e6776e681a06e0e1622c5e5e3e6a8b13/default', + ]), + getBridgeInfo: jest.fn().mockReturnValue({ + chainId: 'eip155:42161', + contractAddress: '0x1234567890123456789012345678901234567890', + }), + createErrorResult: jest.fn((error, defaultResponse) => ({ + ...defaultResponse, + success: false, + error: error instanceof Error ? error.message : String(error), + })), +})); + +// Mock adapter functions +jest.mock('../../../src/utils/hyperLiquidAdapter', () => { + const actual = jest.requireActual('../../../src/utils/hyperLiquidAdapter'); + return { + ...actual, + adaptHyperLiquidLedgerUpdateToUserHistoryItem: jest.fn((updates) => { + // Return mock history items based on input + if (!updates || !Array.isArray(updates) || updates.length === 0) { + return []; + } + return updates.map((_update: unknown) => ({ + type: 'deposit' as const, + amount: '100', + timestamp: Date.now(), + hash: '0x123', + })); + }), + }; +}); + +// Mock TradingReadinessCache - global singleton for signing operation caching +// Use jest.createMockFromModule for proper mock creation +jest.mock('../../../src/services/TradingReadinessCache'); + +const MockedHyperLiquidClientService = + HyperLiquidClientService as jest.MockedClass; +const MockedHyperLiquidWalletService = + HyperLiquidWalletService as jest.MockedClass; +const MockedHyperLiquidSubscriptionService = + HyperLiquidSubscriptionService as jest.MockedClass< + typeof HyperLiquidSubscriptionService + >; +const mockValidateOrderParams = validateOrderParams as jest.MockedFunction< + typeof validateOrderParams +>; +const mockValidateWithdrawalParams = + validateWithdrawalParams as jest.MockedFunction< + typeof validateWithdrawalParams + >; +const mockValidateDepositParams = validateDepositParams as jest.MockedFunction< + typeof validateDepositParams +>; +const mockValidateCoinExists = validateCoinExists as jest.MockedFunction< + typeof validateCoinExists +>; +const mockValidateAssetSupport = validateAssetSupport as jest.MockedFunction< + typeof validateAssetSupport +>; +const mockValidateBalance = validateBalance as jest.MockedFunction< + typeof validateBalance +>; + +// Mock factory functions - defined once, reused everywhere +// These reduce duplication and make tests more maintainable +const createMockInfoClient = (overrides: Record = {}) => ({ + clearinghouseState: jest.fn().mockResolvedValue({ + marginSummary: { + totalMarginUsed: '500', + accountValue: '10500', + }, + withdrawable: '9500', + assetPositions: [ + { + position: { + coin: 'BTC', + szi: '0.1', + entryPx: '50000', + positionValue: '5000', + unrealizedPnl: '100', + marginUsed: '500', + leverage: { type: 'cross', value: 10 }, + liquidationPx: '45000', + maxLeverage: 50, + returnOnEquity: '20', + cumFunding: { allTime: '10', sinceOpen: '5', sinceChange: '2' }, + }, + type: 'oneWay', + }, + { + position: { + coin: 'ETH', + szi: '1.5', + entryPx: '3000', + positionValue: '4500', + unrealizedPnl: '50', + marginUsed: '450', + leverage: { type: 'cross', value: 10 }, + liquidationPx: '2700', + maxLeverage: 50, + returnOnEquity: '10', + cumFunding: { allTime: '5', sinceOpen: '2', sinceChange: '1' }, + }, + type: 'oneWay', + }, + ], + crossMarginSummary: { + accountValue: '10000', + totalMarginUsed: '5000', + }, + }), + spotClearinghouseState: jest.fn().mockResolvedValue({ + balances: [{ coin: 'USDC', hold: '1000', total: '10000' }], + }), + // Mode-aware fold gate reads userAbstraction; default to unifiedAccount + // so tests that predated the gate still see spot folded into spendable/withdrawable. + userAbstraction: jest.fn().mockResolvedValue('unifiedAccount'), + meta: jest.fn().mockResolvedValue({ + universe: [ + { name: 'BTC', szDecimals: 3, maxLeverage: 50 }, + { name: 'ETH', szDecimals: 4, maxLeverage: 50 }, + ], + }), + metaAndAssetCtxs: jest.fn().mockResolvedValue([ + { + universe: [ + { name: 'BTC', szDecimals: 3, maxLeverage: 50 }, + { name: 'ETH', szDecimals: 4, maxLeverage: 50 }, + ], + }, + [ + { + funding: '0.0001', + openInterest: '1000', + prevDayPx: '49000', + dayNtlVlm: '1000000', + markPx: '50000', + midPx: '50000', + oraclePx: '50000', + }, + { + funding: '0.0001', + openInterest: '500', + prevDayPx: '2900', + dayNtlVlm: '500000', + markPx: '3000', + midPx: '3000', + oraclePx: '3000', + }, + ], + ]), + perpDexs: jest.fn().mockResolvedValue([null]), + allMids: jest.fn().mockResolvedValue({ BTC: '50000', ETH: '3000' }), + frontendOpenOrders: jest.fn().mockResolvedValue([]), + referral: jest.fn().mockResolvedValue({ + referrerState: { + stage: 'ready', + data: { code: 'MMCSI' }, + }, + }), + maxBuilderFee: jest.fn().mockResolvedValue(1), + userFees: jest.fn().mockResolvedValue({ + feeSchedule: { + cross: '0.00030', + add: '0.00010', + spotCross: '0.00040', + spotAdd: '0.00020', + }, + dailyUserVlm: [], + }), + userNonFundingLedgerUpdates: jest.fn().mockResolvedValue([ + { + delta: { type: 'deposit', usdc: '100' }, + time: Date.now(), + hash: '0x123abc', + }, + { + delta: { type: 'withdraw', usdc: '50' }, + time: Date.now() - 3600000, + hash: '0x456def', + }, + ]), + portfolio: jest.fn().mockResolvedValue([ + null, + [ + null, + { + accountValueHistory: [ + [Date.now() - 86400000, '10000'], // 24h ago + [Date.now() - 172800000, '9500'], // 48h ago + [Date.now() - 259200000, '9000'], // 72h ago + ], + }, + ], + ]), + spotMeta: jest.fn().mockResolvedValue({ + tokens: [ + { name: 'USDC', tokenId: '0xdef456', index: 0 }, + { name: 'USDT', tokenId: '0x789abc', index: 1 }, + ], + universe: [], + }), + historicalOrders: jest.fn().mockResolvedValue([]), + userFills: jest.fn().mockResolvedValue([]), + userFillsByTime: jest.fn().mockResolvedValue([]), + userFunding: jest.fn().mockResolvedValue([]), + ...overrides, +}); + +const createMockExchangeClient = (overrides: Record = {}) => ({ + order: jest.fn().mockResolvedValue({ + status: 'ok', + response: { data: { statuses: [{ resting: { oid: 123 } }] } }, + }), + modify: jest.fn().mockResolvedValue({ + status: 'ok', + response: { data: { statuses: [{ resting: { oid: '123' } }] } }, + }), + cancel: jest.fn().mockResolvedValue({ + status: 'ok', + response: { data: { statuses: ['success'] } }, + }), + withdraw3: jest.fn().mockResolvedValue({ + status: 'ok', + }), + updateLeverage: jest.fn().mockResolvedValue({ + status: 'ok', + }), + approveBuilderFee: jest.fn().mockResolvedValue({ + status: 'ok', + }), + setReferrer: jest.fn().mockResolvedValue({ + status: 'ok', + }), + sendAsset: jest.fn().mockResolvedValue({ + status: 'ok', + }), + agentSetAbstraction: jest.fn().mockResolvedValue({ + status: 'ok', + }), + userSetAbstraction: jest.fn().mockResolvedValue({ + status: 'ok', + }), + ...overrides, +}); + +// Create shared mock platform dependencies for provider tests +const mockPlatformDependencies: PerpsPlatformDependencies = + createMockInfrastructure(); + +const mockMessenger = createMockMessenger(); + +/** + * Helper to create HyperLiquidProvider with mock platform dependencies + * @param options + * @param options.isTestnet + * @param options.hip3Enabled + * @param options.allowlistMarkets + * @param options.blocklistMarkets + * @param options.useUnifiedAccount + */ +const createTestProvider = ( + options: { + isTestnet?: boolean; + hip3Enabled?: boolean; + allowlistMarkets?: string[]; + blocklistMarkets?: string[]; + useUnifiedAccount?: boolean; + initialAssetMapping?: [string, number][]; + } = {}, +): HyperLiquidProvider => + new HyperLiquidProvider({ + ...options, + platformDependencies: mockPlatformDependencies, + messenger: mockMessenger, + }); + +describe('HyperLiquidProvider', () => { + let provider: HyperLiquidProvider; + let mockClientService: jest.Mocked; + let mockWalletService: jest.Mocked; + let mockSubscriptionService: jest.Mocked; + + beforeEach(() => { + // Reset all mocks + jest.clearAllMocks(); + ( + mockPlatformDependencies.marketDataFormatters.formatVolume as jest.Mock + ).mockImplementation((value: number) => '$' + value.toFixed(0)); + ( + mockPlatformDependencies.marketDataFormatters.formatPerpsFiat as jest.Mock + ).mockImplementation((value: number) => '$' + value.toFixed(2)); + ( + mockPlatformDependencies.marketDataFormatters + .formatPercentage as jest.Mock + ).mockImplementation((value: number) => `${value.toFixed(2)}%`); + ( + mockPlatformDependencies.featureFlags.validateVersionGated as jest.Mock + ).mockReturnValue(undefined); + (mockPlatformDependencies.metrics.isEnabled as jest.Mock).mockReturnValue( + true, + ); + + // Reset TradingReadinessCache mock state (using imported mocked module) + const mockedCache = TradingReadinessCache as jest.Mocked< + typeof TradingReadinessCache + >; + mockedCache.get.mockReturnValue(undefined); + mockedCache.getBuilderFee.mockReturnValue(undefined); + mockedCache.getReferral.mockReturnValue(undefined); + mockedCache.isInFlight.mockReturnValue(undefined); + mockedCache.setInFlight.mockReturnValue(jest.fn()); + + // Initialize mock stream manager instance + mockStreamManagerInstance = { + clearAllChannels: jest.fn(), + }; + + // Create mocked service instances using factory functions + mockClientService = { + initialize: jest.fn(), + isInitialized: jest.fn().mockReturnValue(true), + isTestnetMode: jest.fn().mockReturnValue(false), + ensureInitialized: jest.fn(), + getExchangeClient: jest.fn().mockReturnValue(createMockExchangeClient()), + getInfoClient: jest.fn().mockReturnValue(createMockInfoClient()), + fetchHistoricalOrders: jest.fn().mockResolvedValue([]), + disconnect: jest.fn().mockResolvedValue(undefined), + toggleTestnet: jest.fn(), + setTestnetMode: jest.fn(), + getNetwork: jest.fn().mockReturnValue('mainnet'), + ensureSubscriptionClient: jest.fn().mockResolvedValue(undefined), + getSubscriptionClient: jest.fn(), + setOnReconnectCallback: jest.fn(), + setOnTerminateCallback: jest.fn(), + getConnectionState: jest.fn().mockReturnValue('connected'), + } as Partial as jest.Mocked; + + mockWalletService = { + setTestnetMode: jest.fn(), + getCurrentAccountId: jest + .fn() + .mockReturnValue( + 'eip155:42161:0x1234567890123456789012345678901234567890', + ), + createWalletAdapter: jest.fn().mockReturnValue({ + request: jest + .fn() + .mockResolvedValue(['0x1234567890123456789012345678901234567890']), + }), + getUserAddress: jest + .fn() + .mockReturnValue('0x1234567890123456789012345678901234567890'), + getUserAddressWithDefault: jest + .fn() + .mockResolvedValue('0x1234567890123456789012345678901234567890'), + isKeyringUnlocked: jest.fn().mockReturnValue(true), + isSelectedHardwareWallet: jest.fn().mockReturnValue(false), + } as Partial as jest.Mocked; + + mockSubscriptionService = { + subscribeToPrices: jest.fn().mockResolvedValue(jest.fn()), // Returns Promise + subscribeToPositions: jest.fn().mockReturnValue(jest.fn()), // Returns function directly + subscribeToOrderFills: jest.fn().mockReturnValue(jest.fn()), // Returns function directly + clearAll: jest.fn(), + isPositionsCacheInitialized: jest.fn().mockReturnValue(false), + getCachedPositions: jest.fn().mockReturnValue([]), + updateFeatureFlags: jest.fn().mockResolvedValue(undefined), + // Cache methods used by buildAssetMapping optimization + setDexMetaCache: jest.fn(), + setDexAssetCtxsCache: jest.fn(), + getDexAssetCtxsCache: jest.fn().mockReturnValue(undefined), + // Price cache used by placeOrder, editOrder, closePosition optimizations + getCachedPrice: jest.fn().mockImplementation((symbol: string) => { + const prices: Record = { BTC: '50000', ETH: '3000' }; + return prices[symbol]; + }), + getLastAllMidsSnapshot: jest.fn().mockReturnValue(null), + // Orders cache used by updatePositionTPSL and getOpenOrders + isOrdersCacheInitialized: jest.fn().mockReturnValue(false), + getCachedOrders: jest.fn().mockReturnValue([]), + // Atomic getter - returns null when cache not initialized (prevents race condition) + getOrdersCacheIfInitialized: jest.fn().mockReturnValue(null), + // Abstraction-mode resolved-mode setter (unified account migration) + setUserAbstractionMode: jest.fn(), + } as Partial as jest.Mocked; + + // Mock constructors + MockedHyperLiquidClientService.mockImplementation(() => mockClientService); + MockedHyperLiquidWalletService.mockImplementation(() => mockWalletService); + MockedHyperLiquidSubscriptionService.mockImplementation( + () => mockSubscriptionService, + ); + + // Mock validation + mockValidateOrderParams.mockReturnValue({ isValid: true }); + mockValidateWithdrawalParams.mockReturnValue({ isValid: true }); + mockValidateDepositParams.mockReturnValue({ isValid: true }); + mockValidateCoinExists.mockReturnValue({ isValid: true }); + mockValidateAssetSupport.mockReturnValue({ isValid: true }); + mockValidateBalance.mockReturnValue({ isValid: true }); + const hyperLiquidValidation = jest.requireMock( + '../../../src/utils/hyperLiquidValidation', + ); + hyperLiquidValidation.getSupportedPaths.mockReturnValue([ + 'eip155:42161/erc20:0xa0b86a33e6776e681a06e0e1622c5e5e3e6a8b13/default', + 'eip155:1/erc20:0xa0b86a33e6776e681a06e0e1622c5e5e3e6a8b13/default', + ]); + hyperLiquidValidation.getBridgeInfo.mockReturnValue({ + chainId: 'eip155:42161', + contractAddress: '0x1234567890123456789012345678901234567890', + }); + hyperLiquidValidation.createErrorResult.mockImplementation( + (error: unknown, defaultResponse: Record) => ({ + ...defaultResponse, + success: false, + error: error instanceof Error ? error.message : String(error), + }), + ); + const hyperLiquidAdapter = jest.requireMock( + '../../../src/utils/hyperLiquidAdapter', + ); + hyperLiquidAdapter.adaptHyperLiquidLedgerUpdateToUserHistoryItem.mockImplementation( + (updates: unknown[]) => { + if (!updates || !Array.isArray(updates) || updates.length === 0) { + return []; + } + return updates.map(() => ({ + type: 'deposit' as const, + amount: '100', + timestamp: Date.now(), + hash: '0x123', + })); + }, + ); + + provider = createTestProvider({ + initialAssetMapping: [ + ['BTC', 0], + ['ETH', 1], + ], + }); + }); + describe('fetchHistoricalCandles', () => { + const options = { + symbol: 'BTC', + interval: CandlePeriod.OneHour, + limit: 100, + }; + + it('returns candle data from clientService', async () => { + // Arrange + const mockCandles = { + symbol: 'BTC', + interval: CandlePeriod.OneHour, + candles: [ + { + open: '50000', + close: '51000', + high: '51500', + low: '49500', + volume: '100', + time: 1000, + }, + ], + }; + mockClientService.fetchHistoricalCandles = jest + .fn() + .mockResolvedValue(mockCandles); + + // Act + const result = await provider.fetchHistoricalCandles(options); + + // Assert + expect(mockClientService.ensureInitialized).toHaveBeenCalled(); + expect(mockClientService.fetchHistoricalCandles).toHaveBeenCalledWith( + options, + ); + expect(result).toStrictEqual(mockCandles); + }); + + it('returns empty candles when clientService returns null', async () => { + // Arrange + mockClientService.fetchHistoricalCandles = jest + .fn() + .mockResolvedValue(null); + + // Act + const result = await provider.fetchHistoricalCandles(options); + + // Assert + expect(result).toStrictEqual({ + symbol: options.symbol, + interval: options.interval, + candles: [], + }); + }); + }); + + describe('getFunding', () => { + const makeFundingRecord = (time: number, coin = 'BTC') => ({ + delta: { coin, usdc: '0.001', fundingRate: '0.0001' }, + hash: `0x${time.toString(16)}`, + time, + }); + + it('returns funding records for the default 30-day window with a single API call', async () => { + // Arrange + const records = [ + makeFundingRecord(Date.now() - 2000, 'ETH'), + makeFundingRecord(Date.now() - 1000, 'BTC'), + ]; + const mockUserFunding = jest.fn().mockResolvedValue(records); + mockClientService.getInfoClient = jest + .fn() + .mockReturnValue( + createMockInfoClient({ userFunding: mockUserFunding }), + ); + + // Act + const result = await provider.getFunding(); + + // Assert — exactly one API call for the default 30-day window + expect(mockUserFunding).toHaveBeenCalledTimes(1); + expect(result).toHaveLength(2); + expect(result[0].symbol).toBe('ETH'); + expect(result[1].symbol).toBe('BTC'); + }); + + it('auto-splits window when API returns the record cap and recovers all records from sub-windows', async () => { + // Arrange — first call hits the cap (500 records); the function discards + // those and refetches the two halves. Each half is under the cap. + const apiLimit = + PERPS_TRANSACTIONS_HISTORY_CONSTANTS.FUNDING_HISTORY_API_LIMIT; + const capRecords = Array.from({ length: apiLimit }, (_, i) => + makeFundingRecord(1_700_000_000_000 + i * 1000), + ); + const leftHalfRecords = [makeFundingRecord(1_700_000_001_000)]; + const rightHalfRecords = [ + makeFundingRecord(1_700_000_002_000), + makeFundingRecord(1_700_000_003_000), + ]; + + const mockUserFunding = jest + .fn() + .mockResolvedValueOnce(capRecords) + .mockResolvedValueOnce(leftHalfRecords) + .mockResolvedValueOnce(rightHalfRecords); + + mockClientService.getInfoClient = jest + .fn() + .mockReturnValue( + createMockInfoClient({ userFunding: mockUserFunding }), + ); + + // Act — 2-day explicit window (> 1 h minimum) so splitting is allowed + const endTime = 1_700_000_100_000; + const twoDaysMs = 2 * 24 * 60 * 60 * 1000; + const result = await provider.getFunding({ + startTime: endTime - twoDaysMs, + endTime, + }); + + // Assert — 3 calls total: original window + left half + right half + expect(mockUserFunding).toHaveBeenCalledTimes(3); + // Combined result comes from the sub-windows (not the capped initial call) + expect(result).toHaveLength( + leftHalfRecords.length + rightHalfRecords.length, + ); + }); + + it('does not split when window is at or below the minimum split size', async () => { + // Arrange — even with a full 500-record response the 1-hour window must + // not recurse (prevents infinite recursion at the minimum boundary) + const apiLimit = + PERPS_TRANSACTIONS_HISTORY_CONSTANTS.FUNDING_HISTORY_API_LIMIT; + const capRecords = Array.from({ length: apiLimit }, (_, i) => + makeFundingRecord(Date.now() - i * 1000), + ); + const mockUserFunding = jest.fn().mockResolvedValue(capRecords); + mockClientService.getInfoClient = jest + .fn() + .mockReturnValue( + createMockInfoClient({ userFunding: mockUserFunding }), + ); + + // Act — 1-hour window equals minSplitWindowMs; no split should occur + const oneHourMs = 60 * 60 * 1000; + const endTime = Date.now(); + await provider.getFunding({ startTime: endTime - oneHourMs, endTime }); + + // Assert — exactly one call, no recursive splitting + expect(mockUserFunding).toHaveBeenCalledTimes(1); + }); + + it('passes explicit startTime and endTime directly to the API', async () => { + // Arrange + const mockUserFunding = jest.fn().mockResolvedValue([]); + mockClientService.getInfoClient = jest + .fn() + .mockReturnValue( + createMockInfoClient({ userFunding: mockUserFunding }), + ); + + // Act + const startTime = 1_700_000_000_000; + const endTime = 1_702_592_000_000; // startTime + 30 days + await provider.getFunding({ startTime, endTime }); + + // Assert — explicit bounds forwarded verbatim to the API + expect(mockUserFunding).toHaveBeenCalledWith( + expect.objectContaining({ startTime, endTime }), + ); + }); + }); + + describe('buildAssetMapping with perpDexs network failure', () => { + it('completes asset mapping using fallback when perpDexs throws', async () => { + // Arrange — perpDexs throws, so getValidatedDexs falls back to [null] + const freshProvider = createTestProvider({ hip3Enabled: true }); + mockClientService.getInfoClient = jest.fn().mockReturnValue( + createMockInfoClient({ + perpDexs: jest.fn().mockRejectedValue(new Error('Network timeout')), + }), + ); + MockedHyperLiquidClientService.mockImplementation( + () => mockClientService, + ); + + // Act — triggering ensureReady -> buildAssetMapping via getPositions + await freshProvider.initialize(); + const markets = await freshProvider.getMarkets(); + + // Assert — provider remains functional with main DEX only + expect(Array.isArray(markets)).toBe(true); + }); + }); + + describe('getExchangeClient escape hatch', () => { + it('delegates to the client service and resolves with the underlying ExchangeClient', async () => { + const sentinel = mockClientService.getExchangeClient(); + await expect(provider.getExchangeClient()).resolves.toBe(sentinel); + }); + + it('propagates errors thrown by the client service', async () => { + const bomb = new Error('client not initialized'); + mockClientService.getExchangeClient = jest.fn(() => { + throw bomb; + }); + await expect(provider.getExchangeClient()).rejects.toBe(bomb); + }); + }); +}); diff --git a/packages/perps-controller/tests/src/providers/HyperLiquidProvider.standalone.test.ts b/packages/perps-controller/tests/src/providers/HyperLiquidProvider.standalone.test.ts new file mode 100644 index 00000000000..566d934facd --- /dev/null +++ b/packages/perps-controller/tests/src/providers/HyperLiquidProvider.standalone.test.ts @@ -0,0 +1,1907 @@ +/* eslint-disable */ +jest.mock('@nktkas/hyperliquid', () => ({})); + +import type { CaipAssetId, Hex } from '@metamask/utils'; + +import { CandlePeriod } from '../../../src/constants/chartConfig.js'; +import { + BUILDER_FEE_CONFIG, + REFERRAL_CONFIG, +} from '../../../src/constants/hyperLiquidConfig.js'; +import { PERPS_TRANSACTIONS_HISTORY_CONSTANTS } from '../../../src/constants/transactionsHistoryConfig.js'; +import { PERPS_ERROR_CODES } from '../../../src/perpsErrorCodes.js'; +import { HyperLiquidProvider } from '../../../src/providers/HyperLiquidProvider.js'; +import { HyperLiquidClientService } from '../../../src/services/HyperLiquidClientService.js'; +import { HyperLiquidSubscriptionService } from '../../../src/services/HyperLiquidSubscriptionService.js'; +import { HyperLiquidWalletService } from '../../../src/services/HyperLiquidWalletService.js'; +import { TradingReadinessCache } from '../../../src/services/TradingReadinessCache.js'; +import type { + ClosePositionParams, + DepositParams, + Order, + PerpsPlatformDependencies, + LiveDataConfig, + OrderParams, +} from '../../../src/types/index.js'; +import { HYPERLIQUID_SCALE_CLOID_MARKER } from '../../../src/utils/hyperLiquidAdapter.js'; +import { + validateAssetSupport, + validateBalance, + validateCoinExists, + validateDepositParams, + validateOrderParams, + validateWithdrawalParams, +} from '../../../src/utils/hyperLiquidValidation.js'; +import { createStandaloneInfoClient } from '../../../src/utils/standaloneInfoClient.js'; +import { + createMockInfrastructure, + createMockMessenger, +} from '../../helpers/serviceMocks.js'; + +jest.mock('../../../src/services/HyperLiquidClientService'); +jest.mock('../../../src/services/HyperLiquidWalletService'); +jest.mock('../../../src/services/HyperLiquidSubscriptionService'); +// Mock stream manager - will be set up in test +let mockStreamManagerInstance: any; +const mockGetStreamManagerInstance = jest.fn(() => mockStreamManagerInstance); +jest.mock( + '../../../../components/UI/Perps/providers/PerpsStreamManager', + () => ({ + getStreamManagerInstance: mockGetStreamManagerInstance, + }), + { virtual: true }, +); + +// Mock standalone info client for standalone mode tests +let mockStandaloneInfoClient: any; +jest.mock('../../../src/utils/standaloneInfoClient', () => ({ + ...jest.requireActual('../../../src/utils/standaloneInfoClient'), + createStandaloneInfoClient: jest.fn(() => mockStandaloneInfoClient), +})); + +jest.mock('../../../src/utils/hyperLiquidValidation', () => ({ + validateOrderParams: jest.fn(), + validateWithdrawalParams: jest.fn(), + validateDepositParams: jest.fn(), + validateCoinExists: jest.fn(), + validateAssetSupport: jest.fn(), + validateBalance: jest.fn(), + getSupportedPaths: jest + .fn() + .mockReturnValue([ + 'eip155:42161/erc20:0xa0b86a33e6776e681a06e0e1622c5e5e3e6a8b13/default', + 'eip155:1/erc20:0xa0b86a33e6776e681a06e0e1622c5e5e3e6a8b13/default', + ]), + getBridgeInfo: jest.fn().mockReturnValue({ + chainId: 'eip155:42161', + contractAddress: '0x1234567890123456789012345678901234567890', + }), + createErrorResult: jest.fn((error, defaultResponse) => ({ + ...defaultResponse, + success: false, + error: error instanceof Error ? error.message : String(error), + })), +})); + +// Mock adapter functions +jest.mock('../../../src/utils/hyperLiquidAdapter', () => { + const actual = jest.requireActual('../../../src/utils/hyperLiquidAdapter'); + return { + ...actual, + adaptHyperLiquidLedgerUpdateToUserHistoryItem: jest.fn((updates) => { + // Return mock history items based on input + if (!updates || !Array.isArray(updates) || updates.length === 0) { + return []; + } + return updates.map((_update: unknown) => ({ + type: 'deposit' as const, + amount: '100', + timestamp: Date.now(), + hash: '0x123', + })); + }), + }; +}); + +// Mock TradingReadinessCache - global singleton for signing operation caching +// Use jest.createMockFromModule for proper mock creation +jest.mock('../../../src/services/TradingReadinessCache'); + +const MockedHyperLiquidClientService = + HyperLiquidClientService as jest.MockedClass; +const MockedHyperLiquidWalletService = + HyperLiquidWalletService as jest.MockedClass; +const MockedHyperLiquidSubscriptionService = + HyperLiquidSubscriptionService as jest.MockedClass< + typeof HyperLiquidSubscriptionService + >; +const mockValidateOrderParams = validateOrderParams as jest.MockedFunction< + typeof validateOrderParams +>; +const mockValidateWithdrawalParams = + validateWithdrawalParams as jest.MockedFunction< + typeof validateWithdrawalParams + >; +const mockValidateDepositParams = validateDepositParams as jest.MockedFunction< + typeof validateDepositParams +>; +const mockValidateCoinExists = validateCoinExists as jest.MockedFunction< + typeof validateCoinExists +>; +const mockValidateAssetSupport = validateAssetSupport as jest.MockedFunction< + typeof validateAssetSupport +>; +const mockValidateBalance = validateBalance as jest.MockedFunction< + typeof validateBalance +>; + +// Mock factory functions - defined once, reused everywhere +// These reduce duplication and make tests more maintainable +const createMockInfoClient = (overrides: Record = {}) => ({ + clearinghouseState: jest.fn().mockResolvedValue({ + marginSummary: { + totalMarginUsed: '500', + accountValue: '10500', + }, + withdrawable: '9500', + assetPositions: [ + { + position: { + coin: 'BTC', + szi: '0.1', + entryPx: '50000', + positionValue: '5000', + unrealizedPnl: '100', + marginUsed: '500', + leverage: { type: 'cross', value: 10 }, + liquidationPx: '45000', + maxLeverage: 50, + returnOnEquity: '20', + cumFunding: { allTime: '10', sinceOpen: '5', sinceChange: '2' }, + }, + type: 'oneWay', + }, + { + position: { + coin: 'ETH', + szi: '1.5', + entryPx: '3000', + positionValue: '4500', + unrealizedPnl: '50', + marginUsed: '450', + leverage: { type: 'cross', value: 10 }, + liquidationPx: '2700', + maxLeverage: 50, + returnOnEquity: '10', + cumFunding: { allTime: '5', sinceOpen: '2', sinceChange: '1' }, + }, + type: 'oneWay', + }, + ], + crossMarginSummary: { + accountValue: '10000', + totalMarginUsed: '5000', + }, + }), + spotClearinghouseState: jest.fn().mockResolvedValue({ + balances: [{ coin: 'USDC', hold: '1000', total: '10000' }], + }), + // Mode-aware fold gate reads userAbstraction; default to unifiedAccount + // so tests that predated the gate still see spot folded into spendable/withdrawable. + userAbstraction: jest.fn().mockResolvedValue('unifiedAccount'), + meta: jest.fn().mockResolvedValue({ + universe: [ + { name: 'BTC', szDecimals: 3, maxLeverage: 50 }, + { name: 'ETH', szDecimals: 4, maxLeverage: 50 }, + ], + }), + metaAndAssetCtxs: jest.fn().mockResolvedValue([ + { + universe: [ + { name: 'BTC', szDecimals: 3, maxLeverage: 50 }, + { name: 'ETH', szDecimals: 4, maxLeverage: 50 }, + ], + }, + [ + { + funding: '0.0001', + openInterest: '1000', + prevDayPx: '49000', + dayNtlVlm: '1000000', + markPx: '50000', + midPx: '50000', + oraclePx: '50000', + }, + { + funding: '0.0001', + openInterest: '500', + prevDayPx: '2900', + dayNtlVlm: '500000', + markPx: '3000', + midPx: '3000', + oraclePx: '3000', + }, + ], + ]), + perpDexs: jest.fn().mockResolvedValue([null]), + allMids: jest.fn().mockResolvedValue({ BTC: '50000', ETH: '3000' }), + frontendOpenOrders: jest.fn().mockResolvedValue([]), + referral: jest.fn().mockResolvedValue({ + referrerState: { + stage: 'ready', + data: { code: 'MMCSI' }, + }, + }), + maxBuilderFee: jest.fn().mockResolvedValue(1), + userFees: jest.fn().mockResolvedValue({ + feeSchedule: { + cross: '0.00030', + add: '0.00010', + spotCross: '0.00040', + spotAdd: '0.00020', + }, + dailyUserVlm: [], + }), + userNonFundingLedgerUpdates: jest.fn().mockResolvedValue([ + { + delta: { type: 'deposit', usdc: '100' }, + time: Date.now(), + hash: '0x123abc', + }, + { + delta: { type: 'withdraw', usdc: '50' }, + time: Date.now() - 3600000, + hash: '0x456def', + }, + ]), + portfolio: jest.fn().mockResolvedValue([ + null, + [ + null, + { + accountValueHistory: [ + [Date.now() - 86400000, '10000'], // 24h ago + [Date.now() - 172800000, '9500'], // 48h ago + [Date.now() - 259200000, '9000'], // 72h ago + ], + }, + ], + ]), + spotMeta: jest.fn().mockResolvedValue({ + tokens: [ + { name: 'USDC', tokenId: '0xdef456', index: 0 }, + { name: 'USDT', tokenId: '0x789abc', index: 1 }, + ], + universe: [], + }), + historicalOrders: jest.fn().mockResolvedValue([]), + userFills: jest.fn().mockResolvedValue([]), + userFillsByTime: jest.fn().mockResolvedValue([]), + userFunding: jest.fn().mockResolvedValue([]), + ...overrides, +}); + +const createMockExchangeClient = (overrides: Record = {}) => ({ + order: jest.fn().mockResolvedValue({ + status: 'ok', + response: { data: { statuses: [{ resting: { oid: 123 } }] } }, + }), + modify: jest.fn().mockResolvedValue({ + status: 'ok', + response: { data: { statuses: [{ resting: { oid: '123' } }] } }, + }), + cancel: jest.fn().mockResolvedValue({ + status: 'ok', + response: { data: { statuses: ['success'] } }, + }), + withdraw3: jest.fn().mockResolvedValue({ + status: 'ok', + }), + updateLeverage: jest.fn().mockResolvedValue({ + status: 'ok', + }), + approveBuilderFee: jest.fn().mockResolvedValue({ + status: 'ok', + }), + setReferrer: jest.fn().mockResolvedValue({ + status: 'ok', + }), + sendAsset: jest.fn().mockResolvedValue({ + status: 'ok', + }), + agentSetAbstraction: jest.fn().mockResolvedValue({ + status: 'ok', + }), + userSetAbstraction: jest.fn().mockResolvedValue({ + status: 'ok', + }), + ...overrides, +}); + +// Create shared mock platform dependencies for provider tests +const mockPlatformDependencies: PerpsPlatformDependencies = + createMockInfrastructure(); + +const mockMessenger = createMockMessenger(); + +/** + * Helper to create HyperLiquidProvider with mock platform dependencies + * @param options + * @param options.isTestnet + * @param options.hip3Enabled + * @param options.allowlistMarkets + * @param options.blocklistMarkets + * @param options.useUnifiedAccount + */ +const createTestProvider = ( + options: { + isTestnet?: boolean; + hip3Enabled?: boolean; + allowlistMarkets?: string[]; + blocklistMarkets?: string[]; + useUnifiedAccount?: boolean; + initialAssetMapping?: [string, number][]; + } = {}, +): HyperLiquidProvider => + new HyperLiquidProvider({ + ...options, + platformDependencies: mockPlatformDependencies, + messenger: mockMessenger, + }); + +describe('HyperLiquidProvider', () => { + let provider: HyperLiquidProvider; + let mockClientService: jest.Mocked; + let mockWalletService: jest.Mocked; + let mockSubscriptionService: jest.Mocked; + + beforeEach(() => { + // Reset all mocks + jest.clearAllMocks(); + ( + mockPlatformDependencies.marketDataFormatters.formatVolume as jest.Mock + ).mockImplementation((value: number) => '$' + value.toFixed(0)); + ( + mockPlatformDependencies.marketDataFormatters.formatPerpsFiat as jest.Mock + ).mockImplementation((value: number) => '$' + value.toFixed(2)); + ( + mockPlatformDependencies.marketDataFormatters + .formatPercentage as jest.Mock + ).mockImplementation((value: number) => `${value.toFixed(2)}%`); + ( + mockPlatformDependencies.featureFlags.validateVersionGated as jest.Mock + ).mockReturnValue(undefined); + (mockPlatformDependencies.metrics.isEnabled as jest.Mock).mockReturnValue( + true, + ); + + // Reset TradingReadinessCache mock state (using imported mocked module) + const mockedCache = TradingReadinessCache as jest.Mocked< + typeof TradingReadinessCache + >; + mockedCache.get.mockReturnValue(undefined); + mockedCache.getBuilderFee.mockReturnValue(undefined); + mockedCache.getReferral.mockReturnValue(undefined); + mockedCache.isInFlight.mockReturnValue(undefined); + mockedCache.setInFlight.mockReturnValue(jest.fn()); + + // Initialize mock stream manager instance + mockStreamManagerInstance = { + clearAllChannels: jest.fn(), + }; + + // Create mocked service instances using factory functions + mockClientService = { + initialize: jest.fn(), + isInitialized: jest.fn().mockReturnValue(true), + isTestnetMode: jest.fn().mockReturnValue(false), + ensureInitialized: jest.fn(), + getExchangeClient: jest.fn().mockReturnValue(createMockExchangeClient()), + getInfoClient: jest.fn().mockReturnValue(createMockInfoClient()), + fetchHistoricalOrders: jest.fn().mockResolvedValue([]), + disconnect: jest.fn().mockResolvedValue(undefined), + toggleTestnet: jest.fn(), + setTestnetMode: jest.fn(), + getNetwork: jest.fn().mockReturnValue('mainnet'), + ensureSubscriptionClient: jest.fn().mockResolvedValue(undefined), + getSubscriptionClient: jest.fn(), + setOnReconnectCallback: jest.fn(), + setOnTerminateCallback: jest.fn(), + getConnectionState: jest.fn().mockReturnValue('connected'), + } as Partial as jest.Mocked; + + mockWalletService = { + setTestnetMode: jest.fn(), + getCurrentAccountId: jest + .fn() + .mockReturnValue( + 'eip155:42161:0x1234567890123456789012345678901234567890', + ), + createWalletAdapter: jest.fn().mockReturnValue({ + request: jest + .fn() + .mockResolvedValue(['0x1234567890123456789012345678901234567890']), + }), + getUserAddress: jest + .fn() + .mockReturnValue('0x1234567890123456789012345678901234567890'), + getUserAddressWithDefault: jest + .fn() + .mockResolvedValue('0x1234567890123456789012345678901234567890'), + isKeyringUnlocked: jest.fn().mockReturnValue(true), + isSelectedHardwareWallet: jest.fn().mockReturnValue(false), + } as Partial as jest.Mocked; + + mockSubscriptionService = { + subscribeToPrices: jest.fn().mockResolvedValue(jest.fn()), // Returns Promise + subscribeToPositions: jest.fn().mockReturnValue(jest.fn()), // Returns function directly + subscribeToOrderFills: jest.fn().mockReturnValue(jest.fn()), // Returns function directly + clearAll: jest.fn(), + isPositionsCacheInitialized: jest.fn().mockReturnValue(false), + getCachedPositions: jest.fn().mockReturnValue([]), + updateFeatureFlags: jest.fn().mockResolvedValue(undefined), + // Cache methods used by buildAssetMapping optimization + setDexMetaCache: jest.fn(), + setDexAssetCtxsCache: jest.fn(), + getDexAssetCtxsCache: jest.fn().mockReturnValue(undefined), + // Price cache used by placeOrder, editOrder, closePosition optimizations + getCachedPrice: jest.fn().mockImplementation((symbol: string) => { + const prices: Record = { BTC: '50000', ETH: '3000' }; + return prices[symbol]; + }), + getLastAllMidsSnapshot: jest.fn().mockReturnValue(null), + // Orders cache used by updatePositionTPSL and getOpenOrders + isOrdersCacheInitialized: jest.fn().mockReturnValue(false), + getCachedOrders: jest.fn().mockReturnValue([]), + // Atomic getter - returns null when cache not initialized (prevents race condition) + getOrdersCacheIfInitialized: jest.fn().mockReturnValue(null), + // Abstraction-mode resolved-mode setter (unified account migration) + setUserAbstractionMode: jest.fn(), + } as Partial as jest.Mocked; + + // Mock constructors + MockedHyperLiquidClientService.mockImplementation(() => mockClientService); + MockedHyperLiquidWalletService.mockImplementation(() => mockWalletService); + MockedHyperLiquidSubscriptionService.mockImplementation( + () => mockSubscriptionService, + ); + + // Mock validation + mockValidateOrderParams.mockReturnValue({ isValid: true }); + mockValidateWithdrawalParams.mockReturnValue({ isValid: true }); + mockValidateDepositParams.mockReturnValue({ isValid: true }); + mockValidateCoinExists.mockReturnValue({ isValid: true }); + mockValidateAssetSupport.mockReturnValue({ isValid: true }); + mockValidateBalance.mockReturnValue({ isValid: true }); + const hyperLiquidValidation = jest.requireMock( + '../../../src/utils/hyperLiquidValidation', + ); + hyperLiquidValidation.getSupportedPaths.mockReturnValue([ + 'eip155:42161/erc20:0xa0b86a33e6776e681a06e0e1622c5e5e3e6a8b13/default', + 'eip155:1/erc20:0xa0b86a33e6776e681a06e0e1622c5e5e3e6a8b13/default', + ]); + hyperLiquidValidation.getBridgeInfo.mockReturnValue({ + chainId: 'eip155:42161', + contractAddress: '0x1234567890123456789012345678901234567890', + }); + hyperLiquidValidation.createErrorResult.mockImplementation( + (error: unknown, defaultResponse: Record) => ({ + ...defaultResponse, + success: false, + error: error instanceof Error ? error.message : String(error), + }), + ); + const hyperLiquidAdapter = jest.requireMock( + '../../../src/utils/hyperLiquidAdapter', + ); + hyperLiquidAdapter.adaptHyperLiquidLedgerUpdateToUserHistoryItem.mockImplementation( + (updates: unknown[]) => { + if (!updates || !Array.isArray(updates) || updates.length === 0) { + return []; + } + return updates.map(() => ({ + type: 'deposit' as const, + amount: '100', + timestamp: Date.now(), + hash: '0x123', + })); + }, + ); + + provider = createTestProvider({ + initialAssetMapping: [ + ['BTC', 0], + ['ETH', 1], + ], + }); + }); + describe('WebSocket connection state methods', () => { + // Import actual enum to ensure type compatibility + const { WebSocketConnectionState } = jest.requireActual( + '../../../src/services/HyperLiquidClientService', + ); + + beforeEach(() => { + // Add WebSocket methods to mock client service + mockClientService.getConnectionState = jest + .fn() + .mockReturnValue(WebSocketConnectionState.Connected); + mockClientService.subscribeToConnectionState = jest + .fn() + .mockReturnValue(jest.fn()); + mockClientService.reconnect = jest.fn().mockResolvedValue(undefined); + }); + + it('getWebSocketConnectionState delegates to clientService', () => { + // Arrange + mockClientService.getConnectionState.mockReturnValue( + WebSocketConnectionState.Connected, + ); + + // Act + const result = provider.getWebSocketConnectionState(); + + // Assert + expect(result).toBe(WebSocketConnectionState.Connected); + expect(mockClientService.getConnectionState).toHaveBeenCalled(); + }); + + it('subscribeToConnectionState delegates to clientService', () => { + // Arrange + const mockUnsubscribe = jest.fn(); + mockClientService.subscribeToConnectionState.mockReturnValue( + mockUnsubscribe, + ); + const listener = jest.fn(); + + // Act + const unsubscribe = provider.subscribeToConnectionState(listener); + + // Assert + expect(mockClientService.subscribeToConnectionState).toHaveBeenCalledWith( + listener, + ); + expect(unsubscribe).toBe(mockUnsubscribe); + }); + + it('reconnect delegates to clientService', async () => { + // Arrange + mockClientService.reconnect.mockResolvedValue(undefined); + + // Act + await provider.reconnect(); + + // Assert + expect(mockClientService.reconnect).toHaveBeenCalled(); + }); + }); + + describe('getOrFetchFills - Cache-First Pattern', () => { + const mockFills = [ + { + orderId: '123', + symbol: 'BTC', + side: 'buy' as const, + size: '0.1', + price: '50000', + fee: '5', + feeToken: 'USDC', + timestamp: Date.now(), + pnl: '100', + direction: 'Open Long', + success: true, + }, + { + orderId: '124', + symbol: 'ETH', + side: 'sell' as const, + size: '1.0', + price: '3000', + fee: '3', + feeToken: 'USDC', + timestamp: Date.now() - 1000, + pnl: '-50', + direction: 'Close Short', + success: true, + }, + ]; + + it('uses cached fills when cache is initialized', async () => { + // Arrange + mockSubscriptionService.getFillsCacheIfInitialized = jest + .fn() + .mockReturnValue(mockFills); + + // Act + const result = await provider.getOrFetchFills({}); + + // Assert + expect(result).toEqual(mockFills); + expect( + mockSubscriptionService.getFillsCacheIfInitialized, + ).toHaveBeenCalled(); + // Should NOT call REST API + expect(mockClientService.getInfoClient).not.toHaveBeenCalled(); + }); + + it('falls back to REST API when cache returns null', async () => { + // Arrange - cache not initialized + mockSubscriptionService.getFillsCacheIfInitialized = jest + .fn() + .mockReturnValue(null); + + mockClientService.getInfoClient = jest.fn().mockReturnValue({ + userFills: jest.fn().mockResolvedValue([ + { + oid: 125, + coin: 'BTC', + side: 'B', + sz: '0.5', + px: '49000', + fee: '2', + feeToken: 'USDC', + time: Date.now(), + closedPnl: '50', + dir: 'Open Long', + }, + ]), + }); + + // Act + const result = await provider.getOrFetchFills({}); + + // Assert + expect( + mockSubscriptionService.getFillsCacheIfInitialized, + ).toHaveBeenCalled(); + expect(mockClientService.getInfoClient).toHaveBeenCalled(); + expect(result.length).toBe(1); + expect(result[0].symbol).toBe('BTC'); + }); + + it('filters cached fills by startTime', async () => { + // Arrange + const now = Date.now(); + const fillsWithDifferentTimes = [ + { ...mockFills[0], timestamp: now }, + { ...mockFills[1], timestamp: now - 100000 }, // Older fill + ]; + mockSubscriptionService.getFillsCacheIfInitialized = jest + .fn() + .mockReturnValue(fillsWithDifferentTimes); + + // Act - filter to only include recent fills + const result = await provider.getOrFetchFills({ startTime: now - 50000 }); + + // Assert - should only include the more recent fill + expect(result.length).toBe(1); + expect(result[0].timestamp).toBe(now); + }); + + it('filters cached fills by symbol', async () => { + // Arrange + mockSubscriptionService.getFillsCacheIfInitialized = jest + .fn() + .mockReturnValue(mockFills); + + // Act - filter to only BTC fills + const result = await provider.getOrFetchFills({ symbol: 'BTC' }); + + // Assert - should only include BTC fill + expect(result.length).toBe(1); + expect(result[0].symbol).toBe('BTC'); + }); + + it('filters cached fills by both startTime and symbol', async () => { + // Arrange + const now = Date.now(); + const fillsWithDifferentTimesAndSymbols = [ + { ...mockFills[0], symbol: 'BTC', timestamp: now }, + { ...mockFills[0], symbol: 'BTC', timestamp: now - 100000 }, + { ...mockFills[0], symbol: 'ETH', timestamp: now }, + ]; + mockSubscriptionService.getFillsCacheIfInitialized = jest + .fn() + .mockReturnValue(fillsWithDifferentTimesAndSymbols); + + // Act - filter to recent BTC fills only + const result = await provider.getOrFetchFills({ + startTime: now - 50000, + symbol: 'BTC', + }); + + // Assert - should only include recent BTC fill + expect(result.length).toBe(1); + expect(result[0].symbol).toBe('BTC'); + expect(result[0].timestamp).toBe(now); + }); + + it('returns all fills when no filter params provided', async () => { + // Arrange + mockSubscriptionService.getFillsCacheIfInitialized = jest + .fn() + .mockReturnValue(mockFills); + + // Act - no filter params + const result = await provider.getOrFetchFills(); + + // Assert - should return all fills + expect(result).toEqual(mockFills); + }); + + it('returns empty array when cache is initialized but empty', async () => { + // Arrange - cache initialized but no fills + mockSubscriptionService.getFillsCacheIfInitialized = jest + .fn() + .mockReturnValue([]); + + // Act + const result = await provider.getOrFetchFills({}); + + // Assert + expect(result).toEqual([]); + // Should NOT call REST API since cache is initialized + expect(mockClientService.getInfoClient).not.toHaveBeenCalled(); + }); + }); + + describe('standalone mode', () => { + const mockUserAddress = '0xabcdef1234567890abcdef1234567890abcdef12'; + const mockCreateStandaloneInfoClient = + createStandaloneInfoClient as jest.MockedFunction< + typeof createStandaloneInfoClient + >; + + beforeEach(() => { + // Reset standalone client mock + mockStandaloneInfoClient = { + clearinghouseState: jest.fn(), + frontendOpenOrders: jest.fn(), + perpDexs: jest.fn().mockResolvedValue([null]), + spotClearinghouseState: jest.fn().mockResolvedValue({ balances: [] }), + // Mode-aware fold gate requires userAbstraction on standalone info + // clients as well; default to unifiedAccount for pre-existing tests. + userAbstraction: jest.fn().mockResolvedValue('unifiedAccount'), + }; + mockCreateStandaloneInfoClient.mockImplementation( + () => mockStandaloneInfoClient, + ); + }); + + describe('getUserDataSnapshot', () => { + it('reuses one clearinghouse response for positions and account state', async () => { + const clearinghouseState = { + assetPositions: [ + { + position: { + coin: 'BTC', + szi: '0.5', + entryPx: '45000', + positionValue: '22500', + unrealizedPnl: '500', + marginUsed: '2250', + leverage: { type: 'cross', value: 10 }, + liquidationPx: '40000', + maxLeverage: 50, + returnOnEquity: '22.22', + cumFunding: { allTime: '10', sinceOpen: '5', sinceChange: '2' }, + }, + type: 'oneWay', + }, + ], + marginSummary: { + totalMarginUsed: '2250', + accountValue: '25000', + }, + withdrawable: '22750', + }; + mockStandaloneInfoClient.clearinghouseState.mockResolvedValue( + clearinghouseState, + ); + mockStandaloneInfoClient.frontendOpenOrders.mockResolvedValue([ + { + coin: 'BTC', + oid: 101, + side: 'A', + limitPx: '0', + triggerPx: '55000', + sz: '0', + origSz: '0', + timestamp: Date.now(), + orderType: 'Take Profit Market', + isTrigger: true, + reduceOnly: true, + isPositionTpsl: true, + cloid: undefined, + children: [], + }, + ]); + + const result = await provider.getUserDataSnapshot({ + userAddress: mockUserAddress, + identity: { + provider: 'hyperliquid', + network: 'mainnet', + hip3ConfigVersion: 7, + dexes: ['main'], + }, + }); + + expect(mockStandaloneInfoClient.perpDexs).not.toHaveBeenCalled(); + expect( + mockStandaloneInfoClient.clearinghouseState, + ).toHaveBeenCalledTimes(1); + expect( + mockStandaloneInfoClient.frontendOpenOrders, + ).toHaveBeenCalledTimes(1); + expect( + mockStandaloneInfoClient.spotClearinghouseState, + ).toHaveBeenCalledTimes(1); + expect(mockStandaloneInfoClient.userAbstraction).toHaveBeenCalledTimes( + 1, + ); + expect(result.positions).toHaveLength(1); + expect(result.positions[0]).toEqual( + expect.objectContaining({ + takeProfitCount: 1, + stopLossCount: 0, + takeProfitPrice: '55000', + takeProfitOrders: [ + expect.objectContaining({ + orderId: '101', + size: '0.5', + triggerPrice: '55000', + }), + ], + }), + ); + expect(result.orders).toEqual([ + expect.objectContaining({ + orderId: '101', + size: '0.5', + originalSize: '0.5', + }), + ]); + expect(result.accountState.totalBalance).toBe('25000'); + expect(result.identity).toEqual({ + provider: 'hyperliquid', + network: 'mainnet', + address: mockUserAddress, + hip3ConfigVersion: 7, + dexes: ['main'], + }); + }); + + it('reports a lone partial take profit as the position take profit price', async () => { + mockStandaloneInfoClient.clearinghouseState.mockResolvedValue({ + assetPositions: [ + { + position: { + coin: 'BTC', + szi: '0.5', + entryPx: '45000', + positionValue: '22500', + unrealizedPnl: '500', + marginUsed: '2250', + leverage: { type: 'cross', value: 10 }, + liquidationPx: '40000', + maxLeverage: 50, + returnOnEquity: '22.22', + cumFunding: { allTime: '10', sinceOpen: '5', sinceChange: '2' }, + }, + type: 'oneWay', + }, + ], + marginSummary: { + totalMarginUsed: '2250', + accountValue: '25000', + }, + withdrawable: '22750', + }); + // A quantity-scoped take profit is placed with 'na' grouping, so it is + // a standalone reduce-only trigger rather than a position-bound one. + mockStandaloneInfoClient.frontendOpenOrders.mockResolvedValue([ + { + coin: 'BTC', + oid: 301, + side: 'A', + limitPx: '55000', + triggerPx: '55000', + sz: '0.2', + origSz: '0.2', + timestamp: Date.now(), + orderType: 'Take Profit Limit', + isTrigger: true, + reduceOnly: true, + isPositionTpsl: false, + cloid: undefined, + children: [], + }, + ]); + + const result = await provider.getUserDataSnapshot({ + userAddress: mockUserAddress, + identity: { + provider: 'hyperliquid', + network: 'mainnet', + hip3ConfigVersion: 0, + dexes: ['main'], + }, + }); + + expect(result.positions[0]).toEqual( + expect.objectContaining({ + takeProfitPrice: '55000', + takeProfitCount: 1, + stopLossCount: 0, + takeProfitOrders: [ + expect.objectContaining({ orderId: '301', isPartial: true }), + ], + stopLossOrders: [], + }), + ); + expect(result.positions[0].stopLossPrice).toBeUndefined(); + }); + + it('ignores child triggers from the inactive TP/SL grouping', async () => { + mockStandaloneInfoClient.clearinghouseState.mockResolvedValue({ + assetPositions: [ + { + position: { + coin: 'BTC', + szi: '0.5', + entryPx: '45000', + positionValue: '22500', + unrealizedPnl: '500', + marginUsed: '2250', + leverage: { type: 'cross', value: 10 }, + liquidationPx: '40000', + maxLeverage: 50, + returnOnEquity: '22.22', + cumFunding: { allTime: '10', sinceOpen: '5', sinceChange: '2' }, + }, + type: 'oneWay', + }, + ], + marginSummary: { + totalMarginUsed: '2250', + accountValue: '25000', + }, + withdrawable: '22750', + }); + mockStandaloneInfoClient.frontendOpenOrders.mockResolvedValue([ + { + coin: 'BTC', + oid: 201, + side: 'A', + limitPx: '0', + triggerPx: '55000', + sz: '0', + origSz: '0', + timestamp: Date.now(), + orderType: 'Take Profit Market', + isTrigger: true, + reduceOnly: true, + isPositionTpsl: true, + cloid: undefined, + children: [], + }, + { + coin: 'BTC', + oid: 202, + side: 'B', + limitPx: '44000', + triggerPx: '0', + sz: '0.5', + origSz: '0.5', + timestamp: Date.now(), + orderType: 'Limit', + isTrigger: false, + reduceOnly: false, + isPositionTpsl: false, + cloid: undefined, + children: [ + { + coin: 'BTC', + oid: 203, + side: 'A', + limitPx: '0', + triggerPx: '', + sz: '0', + origSz: '0', + timestamp: Date.now(), + orderType: 'Take Profit Market', + isTrigger: true, + reduceOnly: true, + isPositionTpsl: false, + cloid: undefined, + children: [], + }, + ], + }, + ]); + + const result = await provider.getUserDataSnapshot({ + userAddress: mockUserAddress, + identity: { + provider: 'hyperliquid', + network: 'mainnet', + hip3ConfigVersion: 0, + dexes: ['main'], + }, + }); + + expect(result.positions[0]).toEqual( + expect.objectContaining({ + takeProfitCount: 1, + takeProfitPrice: '55000', + }), + ); + }); + + it('logs privacy-safe timing for each atomic snapshot stage', async () => { + mockStandaloneInfoClient.clearinghouseState.mockResolvedValue({ + assetPositions: [], + marginSummary: { totalMarginUsed: '0', accountValue: '0' }, + withdrawable: '0', + }); + mockStandaloneInfoClient.frontendOpenOrders.mockResolvedValue([]); + + await provider.getUserDataSnapshot({ + userAddress: mockUserAddress, + identity: { + provider: 'hyperliquid', + network: 'mainnet', + hip3ConfigVersion: 0, + dexes: ['main'], + }, + }); + + const timingCalls = ( + mockPlatformDependencies.debugLogger.log as jest.Mock + ).mock.calls.filter(([marker]) => marker === '[PerpsUserSnapshot]'); + const stages = timingCalls.map(([, detail]) => detail.stage); + expect(stages).toHaveLength(5); + expect(stages).toEqual( + expect.arrayContaining([ + 'clearinghouse_state', + 'frontend_open_orders', + 'spot_clearinghouse_state', + 'user_abstraction', + 'complete', + ]), + ); + expect(JSON.stringify(timingCalls)).not.toContain(mockUserAddress); + }); + + it('accepts canonical DEX identity when a DEX sorts before main', async () => { + mockStandaloneInfoClient.clearinghouseState.mockResolvedValue({ + assetPositions: [], + marginSummary: { totalMarginUsed: '0', accountValue: '0' }, + withdrawable: '0', + }); + mockStandaloneInfoClient.frontendOpenOrders.mockResolvedValue([]); + + const result = await provider.getUserDataSnapshot({ + userAddress: mockUserAddress, + identity: { + provider: 'hyperliquid', + network: 'mainnet', + hip3ConfigVersion: 0, + dexes: ['main', 'flx'], + }, + }); + + expect(result.identity.dexes).toEqual(['main', 'flx']); + expect( + mockStandaloneInfoClient.clearinghouseState, + ).toHaveBeenCalledTimes(2); + }); + + it('rejects the entire bundle when one required request fails', async () => { + mockStandaloneInfoClient.clearinghouseState.mockResolvedValue({ + assetPositions: [], + marginSummary: { totalMarginUsed: '0', accountValue: '0' }, + withdrawable: '0', + }); + mockStandaloneInfoClient.frontendOpenOrders.mockRejectedValue( + new Error('orders unavailable'), + ); + + const request = provider.getUserDataSnapshot({ + userAddress: mockUserAddress, + identity: { + provider: 'hyperliquid', + network: 'mainnet', + hip3ConfigVersion: 0, + dexes: ['main'], + }, + }); + + await expect(request).rejects.toThrow('orders unavailable'); + }); + }); + + describe('getPositions with standalone mode', () => { + it('returns positions via standalone client when standalone mode enabled', async () => { + // Arrange + mockStandaloneInfoClient.clearinghouseState.mockResolvedValue({ + assetPositions: [ + { + position: { + coin: 'BTC', + szi: '0.5', + entryPx: '45000', + positionValue: '22500', + unrealizedPnl: '500', + marginUsed: '2250', + leverage: { type: 'cross', value: 10 }, + liquidationPx: '40000', + maxLeverage: 50, + returnOnEquity: '22.22', + cumFunding: { allTime: '10', sinceOpen: '5', sinceChange: '2' }, + }, + type: 'oneWay', + }, + ], + marginSummary: { + totalMarginUsed: '2250', + accountValue: '25000', + }, + }); + + // Act + const positions = await provider.getPositions({ + standalone: true, + userAddress: mockUserAddress, + }); + + // Assert + expect(mockCreateStandaloneInfoClient).toHaveBeenCalledWith({ + isTestnet: false, + }); + expect( + mockStandaloneInfoClient.clearinghouseState, + ).toHaveBeenCalledWith({ user: mockUserAddress }); + expect(positions).toHaveLength(1); + expect(positions[0].symbol).toBe('BTC'); + expect(positions[0].size).toBe('0.5'); + }); + + it('filters zero-size positions in standalone mode', async () => { + // Arrange - include positions with zero size + mockStandaloneInfoClient.clearinghouseState.mockResolvedValue({ + assetPositions: [ + { + position: { + coin: 'BTC', + szi: '0.5', + entryPx: '45000', + positionValue: '22500', + unrealizedPnl: '500', + marginUsed: '2250', + leverage: { type: 'cross', value: 10 }, + liquidationPx: '40000', + maxLeverage: 50, + returnOnEquity: '22.22', + cumFunding: { allTime: '10', sinceOpen: '5', sinceChange: '2' }, + }, + type: 'oneWay', + }, + { + position: { + coin: 'ETH', + szi: '0', // Zero size - should be filtered out + entryPx: '3000', + positionValue: '0', + unrealizedPnl: '0', + marginUsed: '0', + leverage: { type: 'cross', value: 10 }, + liquidationPx: '0', + maxLeverage: 50, + returnOnEquity: '0', + cumFunding: { allTime: '0', sinceOpen: '0', sinceChange: '0' }, + }, + type: 'oneWay', + }, + ], + marginSummary: { + totalMarginUsed: '2250', + accountValue: '25000', + }, + }); + + // Act + const positions = await provider.getPositions({ + standalone: true, + userAddress: mockUserAddress, + }); + + // Assert - ETH position with zero size should be filtered out + expect(positions).toHaveLength(1); + expect(positions[0].symbol).toBe('BTC'); + }); + + it('uses testnet endpoint when provider is in testnet mode', async () => { + // Arrange - override isTestnetMode to return true for this test + mockClientService.isTestnetMode.mockReturnValue(true); + mockStandaloneInfoClient.clearinghouseState.mockResolvedValue({ + assetPositions: [], + marginSummary: { totalMarginUsed: '0', accountValue: '0' }, + }); + + // Act + await provider.getPositions({ + standalone: true, + userAddress: mockUserAddress, + }); + + // Assert + expect(mockCreateStandaloneInfoClient).toHaveBeenCalledWith({ + isTestnet: true, + }); + }); + + it('returns empty array when standalone client fails', async () => { + // Arrange - getPositions catches errors and returns empty array + mockStandaloneInfoClient.clearinghouseState.mockRejectedValue( + new Error('Network error'), + ); + + // Act + const positions = await provider.getPositions({ + standalone: true, + userAddress: mockUserAddress, + }); + + // Assert - returns empty array instead of throwing (matches implementation) + expect(positions).toEqual([]); + }); + }); + + describe('getAccountState with standalone mode', () => { + it('returns account state via standalone client when standalone mode enabled', async () => { + // Arrange + mockStandaloneInfoClient.clearinghouseState.mockResolvedValue({ + assetPositions: [], + marginSummary: { + totalMarginUsed: '1000', + accountValue: '50000', + }, + withdrawable: '45000', + crossMarginSummary: { + accountValue: '50000', + totalMarginUsed: '1000', + }, + }); + + // Act + const accountState = await provider.getAccountState({ + standalone: true, + userAddress: mockUserAddress, + }); + + // Assert + expect(mockCreateStandaloneInfoClient).toHaveBeenCalledWith({ + isTestnet: false, + }); + expect( + mockStandaloneInfoClient.clearinghouseState, + ).toHaveBeenCalledWith({ user: mockUserAddress }); + expect(accountState.totalBalance).toBeDefined(); + }); + + it('uses testnet endpoint when provider is in testnet mode', async () => { + // Arrange - override isTestnetMode to return true for this test + mockClientService.isTestnetMode.mockReturnValue(true); + mockStandaloneInfoClient.clearinghouseState.mockResolvedValue({ + assetPositions: [], + marginSummary: { totalMarginUsed: '0', accountValue: '0' }, + withdrawable: '0', + crossMarginSummary: { accountValue: '0', totalMarginUsed: '0' }, + }); + + // Act + await provider.getAccountState({ + standalone: true, + userAddress: mockUserAddress, + }); + + // Assert + expect(mockCreateStandaloneInfoClient).toHaveBeenCalledWith({ + isTestnet: true, + }); + }); + + it('returns fallback account state when standalone client fails', async () => { + // Arrange + mockStandaloneInfoClient.clearinghouseState.mockRejectedValue( + new Error('API unavailable'), + ); + + // Act + const result = await provider.getAccountState({ + standalone: true, + userAddress: mockUserAddress, + }); + + // Assert — all DEX queries failed, aggregateAccountStates([]) returns fallback + expect(result).toEqual({ + spendableBalance: '--', + withdrawableBalance: '--', + totalBalance: '--', + marginUsed: '--', + unrealizedPnl: '--', + returnOnEquity: '--', + }); + }); + }); + + describe('getOpenOrders with standalone mode', () => { + it('returns orders via standalone client when standalone mode enabled', async () => { + // Arrange - mock with all required FrontendOrder fields for adaptOrderFromSDK + mockStandaloneInfoClient.frontendOpenOrders.mockResolvedValue([ + { + coin: 'BTC', + oid: 12345, + side: 'B', + limitPx: '50000', + sz: '0.1', + origSz: '0.1', + timestamp: Date.now(), + orderType: 'Limit', + isTrigger: false, + reduceOnly: false, + isPositionTpsl: false, + cloid: undefined, + children: [], + }, + ]); + + // Act + const orders = await provider.getOpenOrders({ + standalone: true, + userAddress: mockUserAddress, + }); + + // Assert + expect(mockCreateStandaloneInfoClient).toHaveBeenCalledWith({ + isTestnet: false, + }); + expect( + mockStandaloneInfoClient.frontendOpenOrders, + ).toHaveBeenCalledWith({ user: mockUserAddress }); + expect(orders).toHaveLength(1); + expect(orders[0].symbol).toBe('BTC'); + expect(orders[0].side).toBe('buy'); + }); + + it('does not register Scale cancel handles from standalone account reads', async () => { + const scaleClientOrderId = `0x${HYPERLIQUID_SCALE_CLOID_MARKER}${'0'.repeat(24)}`; + mockStandaloneInfoClient.frontendOpenOrders.mockResolvedValue([ + { + coin: 'BTC', + oid: 12345, + side: 'B', + limitPx: '50000', + sz: '0.1', + origSz: '0.1', + timestamp: Date.now(), + orderType: 'Limit', + isTrigger: false, + reduceOnly: false, + isPositionTpsl: false, + cloid: scaleClientOrderId, + children: [], + }, + ]); + + const orders = await provider.getOpenOrders({ + standalone: true, + userAddress: mockUserAddress, + }); + const strategyGroupId = orders[0]?.strategyGroupId; + if (!strategyGroupId) { + throw new Error('Expected a recovered Scale group ID'); + } + + expect( + await provider.cancelOrder({ + orderId: strategyGroupId, + symbol: 'BTC', + orderType: 'scale', + }), + ).toStrictEqual({ + success: false, + orderId: strategyGroupId, + error: PERPS_ERROR_CODES.ORDER_STRATEGY_HANDLE_UNKNOWN, + }); + }); + + it('returns empty array when standalone client fails', async () => { + // Arrange + mockStandaloneInfoClient.frontendOpenOrders.mockRejectedValue( + new Error('API unavailable'), + ); + + // Act + const orders = await provider.getOpenOrders({ + standalone: true, + userAddress: mockUserAddress, + }); + + // Assert — all DEX queries failed, flatMap([]) returns empty + expect(orders).toEqual([]); + }); + }); + + describe('multi-DEX standalone mode (HIP-3)', () => { + let hip3Provider: HyperLiquidProvider; + + beforeEach(() => { + hip3Provider = createTestProvider({ + hip3Enabled: true, + allowlistMarkets: ['xyz:*'], + }); + + // Mock perpDexs to return main DEX + HIP-3 DEX + mockStandaloneInfoClient.perpDexs.mockResolvedValue([ + null, // main DEX + { name: 'xyz' }, + ]); + }); + + it('returns positions from both main DEX and HIP-3 DEXs', async () => { + // Arrange: main DEX has BTC, xyz DEX has TSLA + mockStandaloneInfoClient.clearinghouseState + .mockResolvedValueOnce({ + assetPositions: [ + { + position: { + coin: 'BTC', + szi: '0.5', + entryPx: '45000', + positionValue: '22500', + unrealizedPnl: '500', + marginUsed: '2250', + leverage: { type: 'cross', value: 10 }, + liquidationPx: '40000', + maxLeverage: 50, + returnOnEquity: '22.22', + cumFunding: { + allTime: '10', + sinceOpen: '5', + sinceChange: '2', + }, + }, + type: 'oneWay', + }, + ], + marginSummary: { + totalMarginUsed: '2250', + accountValue: '25000', + }, + }) + .mockResolvedValueOnce({ + assetPositions: [ + { + position: { + coin: 'TSLA', + szi: '10', + entryPx: '250', + positionValue: '2500', + unrealizedPnl: '100', + marginUsed: '500', + leverage: { type: 'cross', value: 5 }, + liquidationPx: '200', + maxLeverage: 20, + returnOnEquity: '20', + cumFunding: { + allTime: '1', + sinceOpen: '0.5', + sinceChange: '0.1', + }, + }, + type: 'oneWay', + }, + ], + marginSummary: { + totalMarginUsed: '500', + accountValue: '2600', + }, + }); + + // Act + const positions = await hip3Provider.getPositions({ + standalone: true, + userAddress: mockUserAddress, + }); + + // Assert - should have positions from both DEXs + expect(positions).toHaveLength(2); + expect(positions[0].symbol).toBe('BTC'); + expect(positions[1].symbol).toBe('TSLA'); + // Main DEX called without dex param, HIP-3 called with dex param + expect( + mockStandaloneInfoClient.clearinghouseState, + ).toHaveBeenCalledWith({ user: mockUserAddress }); + expect( + mockStandaloneInfoClient.clearinghouseState, + ).toHaveBeenCalledWith({ user: mockUserAddress, dex: 'xyz' }); + }); + + it('falls back to main DEX only when perpDexs() fails', async () => { + // Arrange: perpDexs fails + mockStandaloneInfoClient.perpDexs.mockRejectedValue( + new Error('Network error'), + ); + mockStandaloneInfoClient.clearinghouseState.mockResolvedValue({ + assetPositions: [ + { + position: { + coin: 'BTC', + szi: '1', + entryPx: '45000', + positionValue: '45000', + unrealizedPnl: '0', + marginUsed: '4500', + leverage: { type: 'cross', value: 10 }, + liquidationPx: '40000', + maxLeverage: 50, + returnOnEquity: '0', + cumFunding: { + allTime: '0', + sinceOpen: '0', + sinceChange: '0', + }, + }, + type: 'oneWay', + }, + ], + marginSummary: { + totalMarginUsed: '4500', + accountValue: '45000', + }, + }); + + // Act + const positions = await hip3Provider.getPositions({ + standalone: true, + userAddress: mockUserAddress, + }); + + // Assert - should fall back to main DEX only + expect(positions).toHaveLength(1); + expect(positions[0].symbol).toBe('BTC'); + expect( + mockStandaloneInfoClient.clearinghouseState, + ).toHaveBeenCalledTimes(1); + expect( + mockStandaloneInfoClient.clearinghouseState, + ).toHaveBeenCalledWith({ user: mockUserAddress }); + + // Verify cache was NOT poisoned: a subsequent call should retry perpDexs() + mockStandaloneInfoClient.perpDexs.mockResolvedValue([ + null, + { name: 'xyz' }, + ]); + mockStandaloneInfoClient.clearinghouseState + .mockResolvedValueOnce({ + assetPositions: [ + { + position: { + coin: 'BTC', + szi: '1', + entryPx: '45000', + positionValue: '45000', + unrealizedPnl: '0', + marginUsed: '4500', + leverage: { type: 'cross', value: 10 }, + liquidationPx: '40000', + maxLeverage: 50, + returnOnEquity: '0', + cumFunding: { + allTime: '0', + sinceOpen: '0', + sinceChange: '0', + }, + }, + type: 'oneWay', + }, + ], + marginSummary: { + totalMarginUsed: '4500', + accountValue: '45000', + }, + }) + .mockResolvedValueOnce({ + assetPositions: [ + { + position: { + coin: 'TSLA', + szi: '10', + entryPx: '200', + positionValue: '2000', + unrealizedPnl: '50', + marginUsed: '200', + leverage: { type: 'cross', value: 10 }, + liquidationPx: '180', + maxLeverage: 50, + returnOnEquity: '25', + cumFunding: { + allTime: '0', + sinceOpen: '0', + sinceChange: '0', + }, + }, + type: 'oneWay', + }, + ], + marginSummary: { + totalMarginUsed: '200', + accountValue: '2000', + }, + }); + + const retryPositions = await hip3Provider.getPositions({ + standalone: true, + userAddress: mockUserAddress, + }); + + // perpDexs should have been called again (retry after transient failure) + expect(mockStandaloneInfoClient.perpDexs).toHaveBeenCalledTimes(2); + // Should now see positions from both DEXs + expect(retryPositions).toHaveLength(2); + const symbols = retryPositions.map((p) => p.symbol).sort(); + expect(symbols).toEqual(['BTC', 'TSLA']); + }); + + it('returns only main DEX positions when hip3Enabled is false', async () => { + // Arrange: use provider with HIP-3 disabled + const disabledProvider = createTestProvider({ + hip3Enabled: false, + }); + mockStandaloneInfoClient.clearinghouseState.mockResolvedValue({ + assetPositions: [ + { + position: { + coin: 'ETH', + szi: '5', + entryPx: '3000', + positionValue: '15000', + unrealizedPnl: '200', + marginUsed: '1500', + leverage: { type: 'cross', value: 10 }, + liquidationPx: '2500', + maxLeverage: 50, + returnOnEquity: '13.33', + cumFunding: { + allTime: '5', + sinceOpen: '2', + sinceChange: '1', + }, + }, + type: 'oneWay', + }, + ], + marginSummary: { + totalMarginUsed: '1500', + accountValue: '15200', + }, + }); + + // Act + const positions = await disabledProvider.getPositions({ + standalone: true, + userAddress: mockUserAddress, + }); + + // Assert - perpDexs should NOT be called + expect(mockStandaloneInfoClient.perpDexs).not.toHaveBeenCalled(); + expect(positions).toHaveLength(1); + expect(positions[0].symbol).toBe('ETH'); + }); + + it('caches validated DEXs across multiple readonly calls', async () => { + // Arrange + mockStandaloneInfoClient.clearinghouseState.mockResolvedValue({ + assetPositions: [], + marginSummary: { totalMarginUsed: '0', accountValue: '0' }, + withdrawable: '0', + }); + + // Act - call getPositions twice on same provider instance + await hip3Provider.getPositions({ + standalone: true, + userAddress: mockUserAddress, + }); + await hip3Provider.getPositions({ + standalone: true, + userAddress: mockUserAddress, + }); + + // Assert - perpDexs should only be called once (cached on second call) + expect(mockStandaloneInfoClient.perpDexs).toHaveBeenCalledTimes(1); + }); + + it('aggregates account state across multiple DEXs in standalone mode', async () => { + // Arrange: main DEX + xyz DEX both have balances + mockStandaloneInfoClient.clearinghouseState + .mockResolvedValueOnce({ + assetPositions: [], + marginSummary: { + totalMarginUsed: '1000', + accountValue: '50000', + }, + withdrawable: '45000', + }) + .mockResolvedValueOnce({ + assetPositions: [], + marginSummary: { + totalMarginUsed: '500', + accountValue: '5000', + }, + withdrawable: '4000', + }); + + // Act + const accountState = await hip3Provider.getAccountState({ + standalone: true, + userAddress: mockUserAddress, + }); + + // Assert - balances should be aggregated + expect(parseFloat(accountState.totalBalance)).toBe(55000); + expect(parseFloat(accountState.marginUsed)).toBe(1500); + }); + + it('does not poison fully-initialized cache when standalone perpDexs() fails', async () => { + // Arrange: standalone perpDexs fails (transient network error) + mockStandaloneInfoClient.perpDexs.mockRejectedValue( + new Error('Network error'), + ); + mockStandaloneInfoClient.clearinghouseState.mockResolvedValue({ + assetPositions: [], + marginSummary: { totalMarginUsed: '0', accountValue: '0' }, + }); + + // Act: standalone call falls back to main DEX only + await hip3Provider.getPositions({ + standalone: true, + userAddress: mockUserAddress, + }); + + // Now set up the fully-initialized path's perpDexs to succeed + const infoClient = mockClientService.getInfoClient(); + (infoClient.perpDexs as jest.Mock).mockResolvedValue([ + null, + { name: 'xyz' }, + ]); + + // Initialize the provider for fully-initialized path + await hip3Provider.initialize(); + + // Act: fully-initialized getPositions should discover HIP-3 DEXs + await hip3Provider.getPositions(); + + // Assert: fully-initialized path called perpDexs (cache was NOT poisoned) + expect(infoClient.perpDexs).toHaveBeenCalled(); + // clearinghouseState should be called for both main + xyz DEX + expect(infoClient.clearinghouseState).toHaveBeenCalledTimes(2); + }); + + it('does not cache invalid perpDexs response in standalone mode', async () => { + // Arrange: perpDexs returns invalid (non-array) response + mockStandaloneInfoClient.perpDexs.mockResolvedValue(null); + mockStandaloneInfoClient.clearinghouseState.mockResolvedValue({ + assetPositions: [], + marginSummary: { totalMarginUsed: '0', accountValue: '0' }, + }); + + // Act: first call gets invalid response, falls back to main DEX + await hip3Provider.getPositions({ + standalone: true, + userAddress: mockUserAddress, + }); + expect(mockStandaloneInfoClient.perpDexs).toHaveBeenCalledTimes(1); + + // Fix perpDexs to return valid response + mockStandaloneInfoClient.perpDexs.mockResolvedValue([ + null, + { name: 'xyz' }, + ]); + + // Act: second standalone call should retry perpDexs (not cached) + await hip3Provider.getPositions({ + standalone: true, + userAddress: mockUserAddress, + }); + + // Assert: perpDexs was called again on the second call + expect(mockStandaloneInfoClient.perpDexs).toHaveBeenCalledTimes(2); + }); + + it('shares cache between standalone and fully-initialized when standalone succeeds', async () => { + // Arrange: standalone perpDexs succeeds + mockStandaloneInfoClient.clearinghouseState.mockResolvedValue({ + assetPositions: [], + marginSummary: { totalMarginUsed: '0', accountValue: '0' }, + }); + + // Act: standalone call succeeds and caches the validated DEXs + await hip3Provider.getPositions({ + standalone: true, + userAddress: mockUserAddress, + }); + expect(mockStandaloneInfoClient.perpDexs).toHaveBeenCalledTimes(1); + + // Initialize the provider for fully-initialized path + await hip3Provider.initialize(); + const infoClient = mockClientService.getInfoClient(); + + // Act: fully-initialized getPositions should reuse standalone's cache + await hip3Provider.getPositions(); + + // Assert: fully-initialized path did NOT call perpDexs (reused cache) + expect(infoClient.perpDexs).not.toHaveBeenCalled(); + // clearinghouseState should be called for both main + xyz DEX (from cache) + expect(infoClient.clearinghouseState).toHaveBeenCalledTimes(2); + }); + + it('filters DEXs via testnet config when in testnet mode', async () => { + // Arrange: testnet mode with TESTNET_HIP3_CONFIG.EnabledDexs = ['xyz'] + (mockClientService.isTestnetMode as jest.Mock).mockReturnValue(true); + const testnetProvider = createTestProvider({ + hip3Enabled: true, + isTestnet: true, + }); + + // perpDexs returns main + xyz + other DEX + mockStandaloneInfoClient.perpDexs.mockResolvedValue([ + null, + { name: 'xyz' }, + { name: 'otherdex' }, + ]); + mockStandaloneInfoClient.clearinghouseState.mockResolvedValue({ + assetPositions: [], + marginSummary: { totalMarginUsed: '0', accountValue: '0' }, + }); + + // Act + await testnetProvider.getPositions({ + standalone: true, + userAddress: mockUserAddress, + }); + + // Assert: clearinghouseState called for main + xyz only (otherdex filtered out) + expect( + mockStandaloneInfoClient.clearinghouseState, + ).toHaveBeenCalledTimes(2); + // Restore + (mockClientService.isTestnetMode as jest.Mock).mockReturnValue(false); + }); + + it('updates unified state atomically when standalone perpDexs succeeds', async () => { + // Arrange + mockStandaloneInfoClient.clearinghouseState.mockResolvedValue({ + assetPositions: [], + marginSummary: { totalMarginUsed: '0', accountValue: '0' }, + }); + + // Act: first standalone call populates dexDiscoveryCache + await hip3Provider.getPositions({ + standalone: true, + userAddress: mockUserAddress, + }); + + // Assert: second call reuses cached state — perpDexs NOT called again + mockStandaloneInfoClient.perpDexs.mockClear(); + await hip3Provider.getPositions({ + standalone: true, + userAddress: mockUserAddress, + }); + expect(mockStandaloneInfoClient.perpDexs).not.toHaveBeenCalled(); + }); + }); + }); +}); diff --git a/packages/perps-controller/tests/src/providers/HyperLiquidProvider.strategy-orders.test.ts b/packages/perps-controller/tests/src/providers/HyperLiquidProvider.strategy-orders.test.ts new file mode 100644 index 00000000000..dbbff377342 --- /dev/null +++ b/packages/perps-controller/tests/src/providers/HyperLiquidProvider.strategy-orders.test.ts @@ -0,0 +1,9219 @@ +import { BUILDER_FEE_CONFIG } from '../../../src/constants/hyperLiquidConfig.js'; +import { + CHASE_ORDER_CONFIG, + CHASE_ORDER_STATUS, + HYPERLIQUID_TWAP_LIMITS, + PERFORMANCE_CONFIG, + PROVIDER_CONFIG, +} from '../../../src/constants/perpsConfig.js'; +import { PERPS_ERROR_CODES } from '../../../src/perpsErrorCodes.js'; +import { HyperLiquidProvider } from '../../../src/providers/HyperLiquidProvider.js'; +import { HyperLiquidClientService } from '../../../src/services/HyperLiquidClientService.js'; +import { HyperLiquidSubscriptionService } from '../../../src/services/HyperLiquidSubscriptionService.js'; +import { HyperLiquidWalletService } from '../../../src/services/HyperLiquidWalletService.js'; +import { TradingReadinessCache } from '../../../src/services/TradingReadinessCache.js'; +import type { + CancelOrderResult, + ChaseOrderMaxDistanceReached, + FeeCalculationParams, + Order, + PerpsPlatformDependencies, + OrderParams, + OrderResult, +} from '../../../src/types/index.js'; +import type { OrderType } from '../../../src/types/perps-types.js'; +import { + validateAssetSupport, + validateBalance, + validateCoinExists, + validateDepositParams, + validateOrderParams, + validateWithdrawalParams, +} from '../../../src/utils/hyperLiquidValidation.js'; +import { createMockPosition } from '../../helpers/providerMocks.js'; +import { + createDeferred, + createMockInfrastructure, + createMockMessenger, +} from '../../helpers/serviceMocks.js'; + +// The HyperLiquid SDK is never exercised directly: every exchange and info call +// goes through the mocked client service below. +jest.mock('@nktkas/hyperliquid', () => ({})); +jest.mock('../../../src/services/HyperLiquidClientService'); +jest.mock('../../../src/services/HyperLiquidWalletService'); +jest.mock('../../../src/services/HyperLiquidSubscriptionService'); + +jest.mock('../../../src/utils/hyperLiquidValidation', () => ({ + validateOrderParams: jest.fn(), + validateWithdrawalParams: jest.fn(), + validateDepositParams: jest.fn(), + validateCoinExists: jest.fn(), + validateAssetSupport: jest.fn(), + validateBalance: jest.fn(), + getSupportedPaths: jest + .fn() + .mockReturnValue([ + 'eip155:42161/erc20:0xa0b86a33e6776e681a06e0e1622c5e5e3e6a8b13/default', + 'eip155:1/erc20:0xa0b86a33e6776e681a06e0e1622c5e5e3e6a8b13/default', + ]), + getBridgeInfo: jest.fn().mockReturnValue({ + chainId: 'eip155:42161', + contractAddress: '0x1234567890123456789012345678901234567890', + }), + createErrorResult: jest.fn((error, defaultResponse) => ({ + ...defaultResponse, + success: false, + error: error instanceof Error ? error.message : String(error), + })), +})); + +// Mock adapter functions +jest.mock('../../../src/utils/hyperLiquidAdapter', () => { + const actual = jest.requireActual('../../../src/utils/hyperLiquidAdapter'); + return { + ...actual, + adaptHyperLiquidLedgerUpdateToUserHistoryItem: jest.fn((updates) => { + // Return mock history items based on input + if (!updates || !Array.isArray(updates) || updates.length === 0) { + return []; + } + return updates.map((_update: unknown) => ({ + type: 'deposit' as const, + amount: '100', + timestamp: Date.now(), + hash: '0x123', + })); + }), + }; +}); + +// Mock TradingReadinessCache - global singleton for signing operation caching +// Use jest.createMockFromModule for proper mock creation +jest.mock('../../../src/services/TradingReadinessCache'); + +const MockedHyperLiquidClientService = + HyperLiquidClientService as jest.MockedClass; +const MockedHyperLiquidWalletService = + HyperLiquidWalletService as jest.MockedClass; +const MockedHyperLiquidSubscriptionService = + HyperLiquidSubscriptionService as jest.MockedClass< + typeof HyperLiquidSubscriptionService + >; +const mockValidateOrderParams = validateOrderParams as jest.MockedFunction< + typeof validateOrderParams +>; +const mockValidateWithdrawalParams = + validateWithdrawalParams as jest.MockedFunction< + typeof validateWithdrawalParams + >; +const mockValidateDepositParams = validateDepositParams as jest.MockedFunction< + typeof validateDepositParams +>; +const mockValidateCoinExists = validateCoinExists as jest.MockedFunction< + typeof validateCoinExists +>; +const mockValidateAssetSupport = validateAssetSupport as jest.MockedFunction< + typeof validateAssetSupport +>; +const mockValidateBalance = validateBalance as jest.MockedFunction< + typeof validateBalance +>; + +/** Every method on the mocked info and exchange clients is a jest mock. */ +type MockClient = Record; + +type MockClearinghouseBalance = { + marginSummary: { + totalMarginUsed: string; + accountValue: string; + }; + withdrawable: string; + assetPositions: never[]; + crossMarginSummary: { + accountValue: string; + totalMarginUsed: string; + }; +}; + +/** + * Build the read-side client the provider queries. + * + * @param overrides - Methods to replace or add for a single test. + * @returns The mock info client. + */ +const createMockInfoClient = (overrides: MockClient = {}): MockClient => ({ + clearinghouseState: jest.fn().mockResolvedValue({ + marginSummary: { + totalMarginUsed: '500', + accountValue: '10500', + }, + withdrawable: '9500', + assetPositions: [ + { + position: { + coin: 'BTC', + szi: '0.1', + entryPx: '50000', + positionValue: '5000', + unrealizedPnl: '100', + marginUsed: '500', + leverage: { type: 'cross', value: 10 }, + liquidationPx: '45000', + maxLeverage: 50, + returnOnEquity: '20', + cumFunding: { allTime: '10', sinceOpen: '5', sinceChange: '2' }, + }, + type: 'oneWay', + }, + { + position: { + coin: 'ETH', + szi: '1.5', + entryPx: '3000', + positionValue: '4500', + unrealizedPnl: '50', + marginUsed: '450', + leverage: { type: 'cross', value: 10 }, + liquidationPx: '2700', + maxLeverage: 50, + returnOnEquity: '10', + cumFunding: { allTime: '5', sinceOpen: '2', sinceChange: '1' }, + }, + type: 'oneWay', + }, + ], + crossMarginSummary: { + accountValue: '10000', + totalMarginUsed: '5000', + }, + }), + spotClearinghouseState: jest.fn().mockResolvedValue({ + balances: [{ coin: 'USDC', hold: '1000', total: '10000' }], + }), + // Mode-aware fold gate reads userAbstraction; default to unifiedAccount + // so tests that predated the gate still see spot folded into spendable/withdrawable. + userAbstraction: jest.fn().mockResolvedValue('unifiedAccount'), + meta: jest.fn().mockResolvedValue({ + universe: [ + { name: 'BTC', szDecimals: 3, maxLeverage: 50 }, + { name: 'ETH', szDecimals: 4, maxLeverage: 50 }, + ], + }), + metaAndAssetCtxs: jest.fn().mockResolvedValue([ + { + universe: [ + { name: 'BTC', szDecimals: 3, maxLeverage: 50 }, + { name: 'ETH', szDecimals: 4, maxLeverage: 50 }, + ], + }, + [ + { + funding: '0.0001', + openInterest: '1000', + prevDayPx: '49000', + dayNtlVlm: '1000000', + markPx: '50000', + midPx: '50000', + oraclePx: '50000', + }, + { + funding: '0.0001', + openInterest: '500', + prevDayPx: '2900', + dayNtlVlm: '500000', + markPx: '3000', + midPx: '3000', + oraclePx: '3000', + }, + ], + ]), + perpDexs: jest.fn().mockResolvedValue([null]), + allMids: jest.fn().mockResolvedValue({ BTC: '50000', ETH: '3000' }), + // editOrder verifies the resting order's placement type before modifying it, + // so the account lists the plain limit order the edit tests target. + frontendOpenOrders: jest.fn().mockResolvedValue([ + { + coin: 'BTC', + side: 'B', + limitPx: '50000', + sz: '0.1', + origSz: '0.1', + oid: 123, + timestamp: 1_700_000_000_000, + isTrigger: false, + triggerCondition: 'N/A', + triggerPx: '0', + children: [], + isPositionTpsl: false, + reduceOnly: false, + orderType: 'Limit', + }, + ]), + referral: jest.fn().mockResolvedValue({ + referrerState: { + stage: 'ready', + data: { code: 'MMCSI' }, + }, + }), + maxBuilderFee: jest.fn().mockResolvedValue(1), + userFees: jest.fn().mockResolvedValue({ + feeSchedule: { + cross: '0.00030', + add: '0.00010', + spotCross: '0.00040', + spotAdd: '0.00020', + }, + dailyUserVlm: [], + }), + userNonFundingLedgerUpdates: jest.fn().mockResolvedValue([ + { + delta: { type: 'deposit', usdc: '100' }, + time: Date.now(), + hash: '0x123abc', + }, + { + delta: { type: 'withdraw', usdc: '50' }, + time: Date.now() - 3600000, + hash: '0x456def', + }, + ]), + portfolio: jest.fn().mockResolvedValue([ + null, + [ + null, + { + accountValueHistory: [ + [Date.now() - 86400000, '10000'], // 24h ago + [Date.now() - 172800000, '9500'], // 48h ago + [Date.now() - 259200000, '9000'], // 72h ago + ], + }, + ], + ]), + spotMeta: jest.fn().mockResolvedValue({ + tokens: [ + { name: 'USDC', tokenId: '0xdef456', index: 0 }, + { name: 'USDT', tokenId: '0x789abc', index: 1 }, + ], + universe: [], + }), + historicalOrders: jest.fn().mockResolvedValue([]), + twapHistory: jest.fn().mockResolvedValue([]), + userFills: jest.fn().mockResolvedValue([]), + userFillsByTime: jest.fn().mockResolvedValue([]), + userFunding: jest.fn().mockResolvedValue([]), + userTwapSliceFills: jest.fn().mockResolvedValue([]), + ...overrides, +}); + +/** + * Build the write-side client the provider signs through. + * + * @param overrides - Methods to replace or add for a single test. + * @returns The mock exchange client. + */ +const createMockExchangeClient = (overrides: MockClient = {}): MockClient => ({ + order: jest.fn().mockImplementation((request: { orders: unknown[] }) => + Promise.resolve({ + status: 'ok', + response: { + data: { + statuses: request.orders.map((_order, index) => ({ + resting: { oid: 123 + index }, + })), + }, + }, + }), + ), + modify: jest.fn().mockResolvedValue({ + status: 'ok', + response: { data: { statuses: [{ resting: { oid: '123' } }] } }, + }), + cancel: jest.fn().mockResolvedValue({ + status: 'ok', + response: { data: { statuses: ['success'] } }, + }), + withdraw3: jest.fn().mockResolvedValue({ + status: 'ok', + }), + updateLeverage: jest.fn().mockResolvedValue({ + status: 'ok', + }), + approveBuilderFee: jest.fn().mockResolvedValue({ + status: 'ok', + }), + setReferrer: jest.fn().mockResolvedValue({ + status: 'ok', + }), + sendAsset: jest.fn().mockResolvedValue({ + status: 'ok', + }), + agentSetAbstraction: jest.fn().mockResolvedValue({ + status: 'ok', + }), + userSetAbstraction: jest.fn().mockResolvedValue({ + status: 'ok', + }), + ...overrides, +}); + +// Create shared mock platform dependencies for provider tests +const mockPlatformDependencies: PerpsPlatformDependencies = + createMockInfrastructure(); + +let mockMessenger = createMockMessenger(); + +/** + * Build a provider backed by the mock services above. + * + * @param options - Provider construction options. + * @param options.isTestnet - Whether the provider runs against testnet. + * @param options.initialAssetMapping - Pre-seeded symbol-to-asset-ID entries. + * @param options.hip3Enabled - Whether HIP-3 routes are enabled. + * @param options.allowlistMarkets - HIP-3 market allowlist. + * @param options.blocklistMarkets - HIP-3 market blocklist. + * @param options.useUnifiedAccount - Whether HIP-3 orders use unified collateral. + * @param options.onChaseOrderMaxDistanceReached - Chase boundary callback. + * @returns The provider under test. + */ +const createTestProvider = ( + options: { + isTestnet?: boolean; + initialAssetMapping?: [string, number][]; + hip3Enabled?: boolean; + allowlistMarkets?: string[]; + blocklistMarkets?: string[]; + useUnifiedAccount?: boolean; + onChaseOrderMaxDistanceReached?: ( + event: ChaseOrderMaxDistanceReached, + ) => void; + } = {}, +): HyperLiquidProvider => + new HyperLiquidProvider({ + ...options, + platformDependencies: mockPlatformDependencies, + messenger: mockMessenger, + }); + +describe('HyperLiquidProvider - strategy order types', () => { + let provider: HyperLiquidProvider; + let mockClientService: jest.Mocked; + let mockWalletService: jest.Mocked; + let mockSubscriptionService: jest.Mocked; + + beforeEach(() => { + // Reset all mocks + jest.clearAllMocks(); + mockMessenger = createMockMessenger(); + ( + mockPlatformDependencies.marketDataFormatters.formatVolume as jest.Mock + ).mockImplementation((value: number) => `$${value.toFixed(0)}`); + ( + mockPlatformDependencies.marketDataFormatters.formatPerpsFiat as jest.Mock + ).mockImplementation((value: number) => `$${value.toFixed(2)}`); + ( + mockPlatformDependencies.marketDataFormatters + .formatPercentage as jest.Mock + ).mockImplementation((value: number) => `${value.toFixed(2)}%`); + ( + mockPlatformDependencies.featureFlags.validateVersionGated as jest.Mock + ).mockReturnValue(undefined); + (mockPlatformDependencies.metrics.isEnabled as jest.Mock).mockReturnValue( + true, + ); + + // Reset TradingReadinessCache mock state (using imported mocked module) + const mockedCache = TradingReadinessCache as jest.Mocked< + typeof TradingReadinessCache + >; + mockedCache.get.mockReturnValue(undefined); + mockedCache.getBuilderFee.mockReturnValue(undefined); + mockedCache.getReferral.mockReturnValue(undefined); + mockedCache.isInFlight.mockReturnValue(undefined); + mockedCache.setInFlight.mockReturnValue(jest.fn()); + + // Create mocked service instances using factory functions + mockClientService = { + initialize: jest.fn(), + isInitialized: jest.fn().mockReturnValue(true), + isTestnetMode: jest.fn().mockReturnValue(false), + ensureInitialized: jest.fn(), + getExchangeClient: jest.fn().mockReturnValue(createMockExchangeClient()), + getInfoClient: jest.fn().mockReturnValue(createMockInfoClient()), + fetchHistoricalOrders: jest.fn().mockResolvedValue([]), + disconnect: jest.fn().mockResolvedValue(undefined), + toggleTestnet: jest.fn(), + setTestnetMode: jest.fn(), + getNetwork: jest.fn().mockReturnValue('mainnet'), + ensureSubscriptionClient: jest.fn().mockResolvedValue(undefined), + getSubscriptionClient: jest.fn(), + setOnReconnectCallback: jest.fn(), + setOnTerminateCallback: jest.fn(), + getConnectionState: jest.fn().mockReturnValue('connected'), + } as Partial as jest.Mocked; + + mockWalletService = { + setTestnetMode: jest.fn(), + getCurrentAccountId: jest + .fn() + .mockReturnValue( + 'eip155:42161:0x1234567890123456789012345678901234567890', + ), + createWalletAdapter: jest.fn().mockReturnValue({ + request: jest + .fn() + .mockResolvedValue(['0x1234567890123456789012345678901234567890']), + }), + getUserAddress: jest + .fn() + .mockReturnValue('0x1234567890123456789012345678901234567890'), + getUserAddressWithDefault: jest + .fn() + .mockResolvedValue('0x1234567890123456789012345678901234567890'), + isKeyringUnlocked: jest.fn().mockReturnValue(true), + isSelectedHardwareWallet: jest.fn().mockReturnValue(false), + } as Partial as jest.Mocked; + + mockSubscriptionService = { + subscribeToPrices: jest.fn().mockResolvedValue(jest.fn()), // Returns Promise + subscribeToPositions: jest.fn().mockReturnValue(jest.fn()), // Returns function directly + subscribeToOrderFills: jest.fn().mockReturnValue(jest.fn()), // Returns function directly + clearAll: jest.fn(), + isPositionsCacheInitialized: jest.fn().mockReturnValue(false), + getCachedPositions: jest.fn().mockReturnValue([]), + updateFeatureFlags: jest.fn().mockResolvedValue(undefined), + // Cache methods used by buildAssetMapping optimization + setDexMetaCache: jest.fn(), + setDexAssetCtxsCache: jest.fn(), + getDexAssetCtxsCache: jest.fn().mockReturnValue(undefined), + // Price cache used by placeOrder, editOrder, closePosition optimizations + getCachedPrice: jest.fn().mockImplementation((symbol: string) => { + const prices: Record = { BTC: '50000', ETH: '3000' }; + return prices[symbol]; + }), + getLastAllMidsSnapshot: jest.fn().mockReturnValue(null), + // Orders cache used by updatePositionTPSL and getOpenOrders + isOrdersCacheInitialized: jest.fn().mockReturnValue(false), + getCachedOrders: jest.fn().mockReturnValue([]), + // Atomic getter - returns null when cache not initialized (prevents race condition) + getOrdersCacheIfInitialized: jest.fn().mockReturnValue(null), + // Abstraction-mode resolved-mode setter (unified account migration) + setUserAbstractionMode: jest.fn(), + } as Partial as jest.Mocked; + + // Mock constructors + MockedHyperLiquidClientService.mockImplementation(() => mockClientService); + MockedHyperLiquidWalletService.mockImplementation(() => mockWalletService); + MockedHyperLiquidSubscriptionService.mockImplementation( + () => mockSubscriptionService, + ); + + // Mock validation + mockValidateOrderParams.mockReturnValue({ isValid: true }); + mockValidateWithdrawalParams.mockReturnValue({ isValid: true }); + mockValidateDepositParams.mockReturnValue({ isValid: true }); + mockValidateCoinExists.mockReturnValue({ isValid: true }); + mockValidateAssetSupport.mockReturnValue({ isValid: true }); + mockValidateBalance.mockReturnValue({ isValid: true }); + const hyperLiquidValidation = jest.requireMock( + '../../../src/utils/hyperLiquidValidation', + ); + hyperLiquidValidation.getSupportedPaths.mockReturnValue([ + 'eip155:42161/erc20:0xa0b86a33e6776e681a06e0e1622c5e5e3e6a8b13/default', + 'eip155:1/erc20:0xa0b86a33e6776e681a06e0e1622c5e5e3e6a8b13/default', + ]); + hyperLiquidValidation.getBridgeInfo.mockReturnValue({ + chainId: 'eip155:42161', + contractAddress: '0x1234567890123456789012345678901234567890', + }); + hyperLiquidValidation.createErrorResult.mockImplementation( + (error: unknown, defaultResponse: Record) => ({ + ...defaultResponse, + success: false, + error: error instanceof Error ? error.message : String(error), + }), + ); + const hyperLiquidAdapter = jest.requireMock( + '../../../src/utils/hyperLiquidAdapter', + ); + hyperLiquidAdapter.adaptHyperLiquidLedgerUpdateToUserHistoryItem.mockImplementation( + (updates: unknown[]) => { + if (!updates || !Array.isArray(updates) || updates.length === 0) { + return []; + } + return updates.map(() => ({ + type: 'deposit' as const, + amount: '100', + timestamp: Date.now(), + hash: '0x123', + })); + }, + ); + + provider = createTestProvider({ + initialAssetMapping: [ + ['BTC', 0], + ['ETH', 1], + ], + }); + }); + + /** + * Swap in exchange/info clients carrying the strategy endpoints, which the + * shared factories above do not define. + * + * @param overrides - Per-client method overrides. + * @param overrides.exchange - Exchange client overrides. + * @param overrides.info - Info client overrides. + * @returns The installed mock clients. + */ + const useStrategyClients = ( + overrides: { exchange?: MockClient; info?: MockClient } = {}, + ): { exchangeClient: MockClient; infoClient: MockClient } => { + const exchangeClient = createMockExchangeClient({ + twapOrder: jest.fn().mockResolvedValue({ + status: 'ok', + response: { + type: 'twapOrder', + data: { status: { running: { twapId: 987 } } }, + }, + }), + twapCancel: jest.fn().mockResolvedValue({ + status: 'ok', + response: { type: 'twapCancel', data: { status: 'success' } }, + }), + ...overrides.exchange, + }); + const infoClient = createMockInfoClient({ + // A Chase reads liveness before re-pricing. Tests that cancel a child + // override this with the final canceled remainder when it matters. + orderStatus: jest.fn().mockResolvedValue({ + status: 'order', + order: { + status: 'open', + order: { + coin: 'ETH', + side: 'B', + limitPx: '2999', + sz: '1', + origSz: '1', + oid: 55, + timestamp: 1_700_000_000_000, + isTrigger: false, + triggerCondition: 'N/A', + triggerPx: '0', + children: [], + isPositionTpsl: false, + reduceOnly: false, + orderType: 'Limit', + }, + }, + }), + l2Book: jest.fn().mockResolvedValue({ + coin: 'ETH', + levels: [ + [{ px: '2999', sz: '10', n: 1 }], + [{ px: '3001', sz: '10', n: 1 }], + ], + }), + ...overrides.info, + }); + + mockClientService.getExchangeClient.mockReturnValue( + exchangeClient as never, + ); + mockClientService.getInfoClient.mockReturnValue(infoClient as never); + + return { exchangeClient, infoClient }; + }; + + const useHip3Capabilities = ( + infoOverrides: MockClient = {}, + providerOptions: { + hip3Enabled?: boolean; + allowlistMarkets?: string[]; + blocklistMarkets?: string[]; + } = {}, + ): { + capabilityProvider: HyperLiquidProvider; + infoClient: MockClient; + } => { + const { infoClient } = useStrategyClients({ + info: { + perpDexs: jest.fn().mockResolvedValue([null, { name: 'xyz' }]), + meta: jest.fn().mockResolvedValue({ + universe: [{ name: 'xyz:TSLA', szDecimals: 3, maxLeverage: 20 }], + collateralToken: 0, + }), + ...infoOverrides, + }, + }); + return { + capabilityProvider: createTestProvider({ + hip3Enabled: true, + ...providerOptions, + allowlistMarkets: providerOptions.allowlistMarkets ?? ['xyz:*'], + }), + infoClient, + }; + }; + + const baseOrder = { + symbol: 'ETH', + isBuy: true, + size: '1', + usdAmount: '3000', + currentPrice: 3000, + providerId: PROVIDER_CONFIG.DefaultProvider, + }; + + const activeEthTwapHistory = [ + { + time: 1_700_000_030, + twapId: 987, + state: { + coin: 'ETH', + executedNtl: '0', + executedSz: '0', + minutes: 30, + randomize: false, + reduceOnly: false, + side: 'B', + sz: '1', + timestamp: 1_700_000_000_000, + user: '0x1234567890123456789012345678901234567890', + }, + status: { status: 'activated' }, + }, + ]; + + const createClearinghouseBalance = ( + withdrawable: string, + ): MockClearinghouseBalance => ({ + marginSummary: { + totalMarginUsed: '0', + accountValue: withdrawable, + }, + withdrawable, + assetPositions: [], + crossMarginSummary: { + accountValue: withdrawable, + totalMarginUsed: '0', + }, + }); + + describe('Builder fee policy', () => { + it('applies one builder context to a default parent and TP/SL batch', async () => { + const { exchangeClient } = useStrategyClients(); + + await provider.placeOrder({ + ...baseOrder, + orderType: 'market', + takeProfitPrice: '3500', + stopLossPrice: '2500', + } satisfies OrderParams); + + expect(exchangeClient.order.mock.calls[0][0]).toMatchObject({ + orders: expect.any(Array), + builder: { + b: BUILDER_FEE_CONFIG.MainnetBuilder, + f: BUILDER_FEE_CONFIG.MaxFeeTenthsBps, + }, + }); + expect(exchangeClient.order.mock.calls[0][0].orders).toHaveLength(3); + }); + + it('applies one builder context to a position TP/SL batch', async () => { + const { exchangeClient } = useStrategyClients(); + + const result = await provider.updatePositionTPSL({ + symbol: 'ETH', + takeProfitPrice: '3500', + stopLossPrice: '2500', + position: createMockPosition({ symbol: 'ETH', size: '1.5' }), + }); + + expect(result.success).toBe(true); + expect(exchangeClient.order.mock.calls[0][0].builder).toStrictEqual({ + b: BUILDER_FEE_CONFIG.MainnetBuilder, + f: BUILDER_FEE_CONFIG.MaxFeeTenthsBps, + }); + }); + + it('keeps existing protection when builder approval fails', async () => { + const { exchangeClient } = useStrategyClients({ + exchange: { + approveBuilderFee: jest + .fn() + .mockRejectedValue(new Error('Builder approval failed')), + }, + info: { + maxBuilderFee: jest.fn().mockResolvedValue(0), + frontendOpenOrders: jest.fn().mockResolvedValue([ + { + coin: 'ETH', + oid: 456, + reduceOnly: true, + isTrigger: true, + isPositionTpsl: true, + orderType: 'Take Profit Limit', + children: [], + }, + ]), + }, + }); + + const result = await provider.updatePositionTPSL({ + symbol: 'ETH', + takeProfitPrice: '3500', + }); + + expect(result).toStrictEqual({ + success: false, + error: PERPS_ERROR_CODES.TPSL_UPDATE_FAILED, + }); + expect(exchangeClient.approveBuilderFee).toHaveBeenCalled(); + expect(exchangeClient.cancel).not.toHaveBeenCalled(); + expect(exchangeClient.order).not.toHaveBeenCalled(); + }); + + it('does not replace whole-position protection when pre-cancel is refused', async () => { + const { exchangeClient } = useStrategyClients({ + exchange: { + cancel: jest.fn().mockResolvedValue({ + status: 'ok', + response: { + data: { statuses: [{ error: 'Invalid nonce' }] }, + }, + }), + }, + info: { + frontendOpenOrders: jest.fn().mockResolvedValue([ + { + coin: 'ETH', + oid: 456, + reduceOnly: true, + isTrigger: true, + isPositionTpsl: true, + orderType: 'Take Profit Limit', + children: [], + }, + ]), + }, + }); + + const result = await provider.updatePositionTPSL({ + symbol: 'ETH', + takeProfitPrice: '3500', + }); + + expect(result).toStrictEqual({ + success: false, + error: PERPS_ERROR_CODES.TPSL_UPDATE_FAILED, + childOrderIds: ['456'], + }); + expect(exchangeClient.order).not.toHaveBeenCalled(); + }); + + it('reports lost protection when the old cancel outcome is unknown', async () => { + const frontendOpenOrders = jest + .fn() + .mockResolvedValueOnce([ + { + coin: 'ETH', + oid: 456, + reduceOnly: true, + isTrigger: true, + isPositionTpsl: true, + orderType: 'Take Profit Limit', + children: [], + }, + { + coin: 'ETH', + oid: 457, + reduceOnly: true, + isTrigger: true, + isPositionTpsl: true, + orderType: 'Stop Market', + children: [], + }, + ]) + .mockRejectedValueOnce(new Error('Open orders unavailable')); + const { exchangeClient } = useStrategyClients({ + exchange: { + cancel: jest.fn().mockResolvedValue({ + status: 'ok', + response: { data: { statuses: ['success'] } }, + }), + }, + info: { frontendOpenOrders }, + }); + + const result = await provider.updatePositionTPSL({ + symbol: 'ETH', + takeProfitPrice: '3500', + stopLossPrice: '2500', + position: createMockPosition({ symbol: 'ETH', size: '1.5' }), + }); + + expect(result).toStrictEqual({ + success: false, + error: PERPS_ERROR_CODES.TPSL_PROTECTION_LOST, + childOrderIds: ['456', '457'], + }); + expect(exchangeClient.order).not.toHaveBeenCalled(); + }); + + it('restores whole-position protection when replacement fails after pre-cancel', async () => { + const order = jest + .fn() + .mockResolvedValueOnce({ + status: 'ok', + response: { + data: { statuses: [{ error: 'Rejected replacement' }] }, + }, + }) + .mockResolvedValueOnce({ + status: 'ok', + response: { + data: { statuses: [{ resting: { oid: 789 } }] }, + }, + }); + const { exchangeClient } = useStrategyClients({ + exchange: { order }, + info: { + frontendOpenOrders: jest.fn().mockResolvedValue([ + { + coin: 'ETH', + side: 'A', + limitPx: '3400', + sz: '0', + origSz: '0', + oid: 456, + timestamp: 1_700_000_000_000, + reduceOnly: true, + isTrigger: true, + isPositionTpsl: true, + triggerCondition: 'Price above 3400', + triggerPx: '3400', + orderType: 'Take Profit Limit', + children: [], + }, + ]), + }, + }); + + const result = await provider.updatePositionTPSL({ + symbol: 'ETH', + takeProfitPrice: '3500', + }); + + expect(result).toMatchObject({ + success: false, + error: PERPS_ERROR_CODES.TPSL_UPDATE_FAILED, + }); + expect(exchangeClient.cancel).toHaveBeenCalledWith({ + cancels: [{ a: 1, o: 456 }], + }); + expect(order).toHaveBeenCalledTimes(2); + expect(order.mock.calls[1][0]).toMatchObject({ + grouping: 'positionTpsl', + orders: [ + { + a: 1, + b: false, + p: '3400', + s: '0', + r: true, + t: { + trigger: { + isMarket: false, + triggerPx: '3400', + tpsl: 'tp', + }, + }, + }, + ], + }); + expect(exchangeClient.cancel.mock.invocationCallOrder[0]).toBeLessThan( + order.mock.invocationCallOrder[0], + ); + expect(order.mock.invocationCallOrder[0]).toBeLessThan( + order.mock.invocationCallOrder[1], + ); + }); + + it.each([ + { + label: 'returns a rejected status', + restoreResult: { + status: 'ok', + response: { + data: { statuses: [{ error: 'Rejected restoration' }] }, + }, + }, + }, + { + label: 'throws', + restoreResult: new Error('Restoration unavailable'), + }, + ])( + 'reports lost protection when restoration $label', + async ({ restoreResult }) => { + const order = jest.fn().mockResolvedValueOnce({ + status: 'ok', + response: { + data: { statuses: [{ error: 'Rejected replacement' }] }, + }, + }); + if (restoreResult instanceof Error) { + order.mockRejectedValueOnce(restoreResult); + } else { + order.mockResolvedValueOnce(restoreResult); + } + useStrategyClients({ + exchange: { order }, + info: { + frontendOpenOrders: jest.fn().mockResolvedValue([ + { + coin: 'ETH', + side: 'A', + limitPx: '3400', + sz: '0', + origSz: '0', + oid: 456, + timestamp: 1_700_000_000_000, + reduceOnly: true, + isTrigger: true, + isPositionTpsl: true, + triggerCondition: 'Price above 3400', + triggerPx: '3400', + orderType: 'Take Profit Limit', + children: [], + }, + ]), + }, + }); + + const result = await provider.updatePositionTPSL({ + symbol: 'ETH', + takeProfitPrice: '3500', + }); + + expect(result).toStrictEqual({ + success: false, + error: PERPS_ERROR_CODES.TPSL_PROTECTION_LOST, + childOrderIds: [], + }); + expect(order).toHaveBeenCalledTimes(2); + }, + ); + + it('reports recreated protection IDs when restoration is incomplete', async () => { + const order = jest + .fn() + .mockResolvedValueOnce({ + status: 'ok', + response: { + data: { + statuses: [ + { error: 'Rejected take profit' }, + { error: 'Rejected stop loss' }, + ], + }, + }, + }) + .mockResolvedValueOnce({ + status: 'ok', + response: { + data: { statuses: [{ resting: { oid: 789 } }] }, + }, + }); + useStrategyClients({ + exchange: { + order, + cancel: jest.fn().mockResolvedValue({ + status: 'ok', + response: { data: { statuses: ['success', 'success'] } }, + }), + }, + info: { + frontendOpenOrders: jest.fn().mockResolvedValue([ + { + coin: 'ETH', + side: 'A', + limitPx: '2450', + sz: '0', + origSz: '0', + oid: 456, + timestamp: 1_700_000_000_000, + reduceOnly: true, + isTrigger: true, + isPositionTpsl: true, + triggerCondition: 'Price below 2500', + triggerPx: '2500', + orderType: 'Stop Market', + children: [], + }, + { + coin: 'ETH', + side: 'A', + limitPx: '3400', + sz: '0', + origSz: '0', + oid: 457, + timestamp: 1_700_000_000_000, + reduceOnly: true, + isTrigger: true, + isPositionTpsl: true, + triggerCondition: 'Price above 3400', + triggerPx: '3400', + orderType: 'Take Profit Limit', + children: [], + }, + ]), + }, + }); + + const result = await provider.updatePositionTPSL({ + symbol: 'ETH', + takeProfitPrice: '3500', + stopLossPrice: '2400', + }); + + expect(result).toStrictEqual({ + success: false, + error: PERPS_ERROR_CODES.TPSL_PROTECTION_LOST, + childOrderIds: ['789'], + }); + expect(order).toHaveBeenCalledTimes(2); + }); + + it('restores only the order confirmed cancelled when replacement fails', async () => { + const order = jest + .fn() + .mockResolvedValueOnce({ + status: 'ok', + response: { + data: { + statuses: [ + { error: 'Rejected take profit' }, + { error: 'Rejected stop loss' }, + ], + }, + }, + }) + .mockResolvedValueOnce({ + status: 'ok', + response: { + data: { statuses: [{ resting: { oid: 789 } }] }, + }, + }); + useStrategyClients({ + exchange: { + order, + cancel: jest.fn().mockResolvedValue({ + status: 'ok', + response: { + data: { + statuses: [ + 'success', + { + error: + 'Order was never placed, already canceled, or filled.', + }, + ], + }, + }, + }), + }, + info: { + frontendOpenOrders: jest.fn().mockResolvedValue([ + { + coin: 'ETH', + side: 'A', + limitPx: '2450', + sz: '0', + origSz: '0', + oid: 456, + timestamp: 1_700_000_000_000, + reduceOnly: true, + isTrigger: true, + isPositionTpsl: true, + triggerCondition: 'Price below 2500', + triggerPx: '2500', + orderType: 'Stop Market', + children: [], + }, + { + coin: 'ETH', + side: 'A', + limitPx: '3400', + sz: '0', + origSz: '0', + oid: 457, + timestamp: 1_700_000_000_000, + reduceOnly: true, + isTrigger: true, + isPositionTpsl: true, + triggerCondition: 'Price above 3400', + triggerPx: '3400', + orderType: 'Take Profit Limit', + children: [], + }, + ]), + }, + }); + + const result = await provider.updatePositionTPSL({ + symbol: 'ETH', + takeProfitPrice: '3500', + stopLossPrice: '2400', + }); + + expect(result).toMatchObject({ + success: false, + error: PERPS_ERROR_CODES.TPSL_UPDATE_FAILED, + }); + expect(order).toHaveBeenCalledTimes(2); + expect(order.mock.calls[1][0]).toMatchObject({ + grouping: 'positionTpsl', + orders: [ + { + a: 1, + b: false, + p: '2450', + s: '0', + r: true, + t: { + trigger: { + isMarket: true, + triggerPx: '2500', + tpsl: 'sl', + }, + }, + }, + ], + }); + expect(order.mock.calls[1][0].orders).toHaveLength(1); + }); + + it('restores a standalone trigger with its remaining size and builder fee', async () => { + const order = jest + .fn() + .mockResolvedValueOnce({ + status: 'ok', + response: { + data: { statuses: [{ error: 'Rejected replacement' }] }, + }, + }) + .mockResolvedValueOnce({ + status: 'ok', + response: { + data: { statuses: [{ resting: { oid: 789 } }] }, + }, + }); + const { exchangeClient } = useStrategyClients({ + exchange: { order }, + info: { + maxBuilderFee: jest + .fn() + .mockResolvedValueOnce(0) + .mockResolvedValueOnce(BUILDER_FEE_CONFIG.MaxFeeDecimal), + frontendOpenOrders: jest.fn().mockResolvedValue([ + { + coin: 'ETH', + side: 'A', + limitPx: '2450', + sz: '0.4', + origSz: '0.6', + oid: 456, + timestamp: 1_700_000_000_000, + reduceOnly: true, + isTrigger: true, + isPositionTpsl: false, + triggerCondition: 'Price below 2500', + triggerPx: '2500', + orderType: 'Stop Market', + children: [], + }, + ]), + }, + }); + + const result = await provider.updatePositionTPSL({ + symbol: 'ETH', + takeProfitPrice: '3500', + }); + + expect(result).toMatchObject({ + success: false, + error: PERPS_ERROR_CODES.TPSL_UPDATE_FAILED, + }); + expect(exchangeClient.approveBuilderFee).toHaveBeenCalledWith({ + builder: BUILDER_FEE_CONFIG.MainnetBuilder, + maxFeeRate: BUILDER_FEE_CONFIG.MaxFeeRate, + }); + expect(order.mock.calls[0][0]).toHaveProperty('builder'); + expect(order.mock.calls[1][0]).toMatchObject({ + grouping: 'na', + builder: { + b: BUILDER_FEE_CONFIG.MainnetBuilder, + f: BUILDER_FEE_CONFIG.MaxFeeTenthsBps, + }, + orders: [ + expect.objectContaining({ + p: '2450', + s: '0.4', + t: { + trigger: { + isMarket: true, + triggerPx: '2500', + tpsl: 'sl', + }, + }, + }), + ], + }); + }); + + it('accepts an old TP/SL order that is already gone before replacement', async () => { + const { exchangeClient } = useStrategyClients({ + exchange: { + cancel: jest.fn().mockResolvedValue({ + status: 'ok', + response: { + data: { + statuses: [ + { + error: + 'Order was never placed, already canceled, or filled.', + }, + ], + }, + }, + }), + }, + info: { + frontendOpenOrders: jest.fn().mockResolvedValue([ + { + coin: 'ETH', + oid: 456, + reduceOnly: true, + isTrigger: true, + isPositionTpsl: true, + orderType: 'Take Profit Limit', + children: [], + }, + ]), + }, + }); + + const result = await provider.updatePositionTPSL({ + symbol: 'ETH', + takeProfitPrice: '3500', + }); + + expect(result).toStrictEqual({ + success: true, + orderId: 'TP/SL orders placed', + }); + expect(exchangeClient.cancel.mock.invocationCallOrder[0]).toBeLessThan( + exchangeClient.order.mock.invocationCallOrder[0], + ); + }); + + it('rejects a TP/SL batch with one failed placement status', async () => { + const { exchangeClient } = useStrategyClients({ + exchange: { + order: jest.fn().mockResolvedValue({ + status: 'ok', + response: { + data: { + statuses: [ + { resting: { oid: 123 } }, + { error: 'Rejected stop loss' }, + ], + }, + }, + }), + }, + }); + + const result = await provider.updatePositionTPSL({ + symbol: 'ETH', + takeProfitPrice: '3500', + takeProfitSize: '0.4', + stopLossPrice: '2500', + stopLossSize: '0.6', + }); + + expect(result).toMatchObject({ + success: false, + error: PERPS_ERROR_CODES.TPSL_UPDATE_FAILED, + }); + expect(exchangeClient.order).toHaveBeenCalledTimes(1); + expect(exchangeClient.cancel).toHaveBeenCalledWith({ + cancels: [{ a: 1, o: 123 }], + }); + }); + + it('returns a recoverable ID when partial-placement cleanup is refused', async () => { + const { exchangeClient } = useStrategyClients({ + exchange: { + order: jest.fn().mockResolvedValue({ + status: 'ok', + response: { + data: { + statuses: [ + { resting: { oid: 123 } }, + { error: 'Rejected stop loss' }, + ], + }, + }, + }), + cancel: jest.fn().mockResolvedValue({ + status: 'ok', + response: { data: { statuses: [{ error: 'Invalid nonce' }] } }, + }), + }, + }); + + const result = await provider.updatePositionTPSL({ + symbol: 'ETH', + takeProfitPrice: '3500', + takeProfitSize: '0.4', + stopLossPrice: '2500', + stopLossSize: '0.6', + }); + + expect(result).toMatchObject({ + success: false, + error: PERPS_ERROR_CODES.TPSL_UPDATE_FAILED, + childOrderIds: ['123'], + }); + expect(exchangeClient.cancel).toHaveBeenCalledWith({ + cancels: [{ a: 1, o: 123 }], + }); + }); + + it('reports lost protection when whole-position cleanup is refused', async () => { + const cancel = jest + .fn() + .mockResolvedValueOnce({ + status: 'ok', + response: { data: { statuses: ['success', 'success'] } }, + }) + .mockResolvedValueOnce({ + status: 'ok', + response: { data: { statuses: [{ error: 'Invalid nonce' }] } }, + }); + const { exchangeClient } = useStrategyClients({ + exchange: { + cancel, + order: jest.fn().mockResolvedValue({ + status: 'ok', + response: { + data: { + statuses: [ + { resting: { oid: 123 } }, + { error: 'Rejected stop loss' }, + ], + }, + }, + }), + }, + info: { + frontendOpenOrders: jest.fn().mockResolvedValue([ + { + coin: 'ETH', + oid: 456, + reduceOnly: true, + isTrigger: true, + isPositionTpsl: true, + orderType: 'Take Profit Limit', + children: [], + }, + { + coin: 'ETH', + oid: 457, + reduceOnly: true, + isTrigger: true, + isPositionTpsl: true, + orderType: 'Stop Market', + children: [], + }, + ]), + }, + }); + + const result = await provider.updatePositionTPSL({ + symbol: 'ETH', + takeProfitPrice: '3500', + stopLossPrice: '2500', + }); + + expect(result).toStrictEqual({ + success: false, + error: PERPS_ERROR_CODES.TPSL_PROTECTION_LOST, + childOrderIds: ['123'], + }); + expect(exchangeClient.order).toHaveBeenCalledTimes(1); + expect(cancel).toHaveBeenLastCalledWith({ + cancels: [{ a: 1, o: 123 }], + }); + }); + + it('reports a filled partial trigger without trying to cancel it', async () => { + const { exchangeClient } = useStrategyClients({ + exchange: { + order: jest.fn().mockResolvedValue({ + status: 'ok', + response: { + data: { + statuses: [ + { filled: { oid: 123 } }, + { error: 'Rejected stop loss' }, + ], + }, + }, + }), + }, + }); + + const result = await provider.updatePositionTPSL({ + symbol: 'ETH', + takeProfitPrice: '3500', + takeProfitSize: '0.4', + stopLossPrice: '2500', + stopLossSize: '0.6', + }); + + expect(result).toMatchObject({ + success: false, + error: PERPS_ERROR_CODES.TPSL_UPDATE_FAILED, + childOrderIds: ['123'], + }); + expect(exchangeClient.cancel).not.toHaveBeenCalled(); + }); + + it('restores protection removed by a mixed old pre-cancel without placing the replacement', async () => { + const { exchangeClient } = useStrategyClients({ + exchange: { + cancel: jest.fn().mockResolvedValue({ + status: 'ok', + response: { + data: { + statuses: ['success', { error: 'Invalid nonce' }], + }, + }, + }), + }, + info: { + frontendOpenOrders: jest.fn().mockResolvedValue([ + { + coin: 'ETH', + side: 'A', + limitPx: '3400', + sz: '0', + origSz: '0', + oid: 456, + timestamp: 1_700_000_000_000, + reduceOnly: true, + isTrigger: true, + isPositionTpsl: true, + triggerCondition: 'Price above 3400', + triggerPx: '3400', + orderType: 'Take Profit Limit', + children: [], + }, + { + coin: 'ETH', + side: 'A', + limitPx: '2450', + sz: '0', + origSz: '0', + oid: 457, + timestamp: 1_700_000_000_000, + reduceOnly: true, + isTrigger: true, + isPositionTpsl: true, + triggerCondition: 'Price below 2500', + triggerPx: '2500', + orderType: 'Stop Market', + children: [], + }, + ]), + }, + }); + + const result = await provider.updatePositionTPSL({ + symbol: 'ETH', + takeProfitPrice: '3500', + stopLossPrice: '2500', + }); + + expect(result).toStrictEqual({ + success: false, + error: PERPS_ERROR_CODES.TPSL_UPDATE_FAILED, + childOrderIds: ['457', '123'], + }); + expect(mockPlatformDependencies.logger.error).toHaveBeenCalledWith( + expect.objectContaining({ + message: PERPS_ERROR_CODES.TPSL_UPDATE_FAILED, + }), + expect.objectContaining({ + context: expect.objectContaining({ + data: expect.objectContaining({ method: 'updatePositionTPSL' }), + }), + }), + ); + expect(exchangeClient.cancel).toHaveBeenCalledWith({ + cancels: [ + { a: 1, o: 456 }, + { a: 1, o: 457 }, + ], + }); + expect(exchangeClient.order).toHaveBeenCalledTimes(1); + expect(exchangeClient.order).toHaveBeenCalledWith( + expect.objectContaining({ + grouping: 'positionTpsl', + orders: [expect.objectContaining({ p: '3400' })], + }), + ); + }); + + it('accepts one successful placement status per TP/SL order', async () => { + const { exchangeClient } = useStrategyClients({ + exchange: { + order: jest.fn().mockResolvedValue({ + status: 'ok', + response: { + data: { + statuses: [ + { resting: { oid: 123 } }, + { resting: { oid: 124 } }, + ], + }, + }, + }), + }, + }); + + const result = await provider.updatePositionTPSL({ + symbol: 'ETH', + takeProfitPrice: '3500', + stopLossPrice: '2500', + }); + + expect(result.success).toBe(true); + expect(exchangeClient.order.mock.calls[0][0].orders).toHaveLength(2); + }); + + it('accepts waitingForTrigger for a combined TP/SL placement', async () => { + mockSubscriptionService.getOrdersCacheIfInitialized.mockReturnValue([]); + const { exchangeClient, infoClient } = useStrategyClients({ + exchange: { + order: jest.fn().mockResolvedValue({ + status: 'ok', + response: { + data: { + statuses: ['waitingForTrigger', 'waitingForTrigger'], + }, + }, + }), + }, + info: { + frontendOpenOrders: jest.fn().mockResolvedValue([]), + }, + }); + + const result = await provider.updatePositionTPSL({ + symbol: 'ETH', + takeProfitPrice: '3500', + stopLossPrice: '2500', + position: createMockPosition({ symbol: 'ETH', size: '1.5' }), + }); + + expect(result).toStrictEqual({ + success: true, + orderId: 'TP/SL orders placed', + }); + expect(exchangeClient.cancel).not.toHaveBeenCalled(); + expect(infoClient.frontendOpenOrders).not.toHaveBeenCalled(); + }); + + it('accepts mixed resting and waitingForTrigger placement statuses', async () => { + const { exchangeClient } = useStrategyClients({ + exchange: { + order: jest.fn().mockResolvedValue({ + status: 'ok', + response: { + data: { + statuses: [{ resting: { oid: 123 } }, 'waitingForTrigger'], + }, + }, + }), + }, + info: { + frontendOpenOrders: jest.fn().mockResolvedValue([]), + }, + }); + + const result = await provider.updatePositionTPSL({ + symbol: 'ETH', + takeProfitPrice: '3500', + stopLossPrice: '2500', + }); + + expect(result.success).toBe(true); + expect(exchangeClient.cancel).not.toHaveBeenCalled(); + }); + + it('replaces existing protection when the trigger waits for activation', async () => { + const frontendOpenOrders = jest.fn().mockResolvedValueOnce([ + { + coin: 'ETH', + side: 'A', + limitPx: '3400', + sz: '0', + origSz: '0', + oid: 456, + timestamp: 1_700_000_000_000, + reduceOnly: true, + isTrigger: true, + isPositionTpsl: true, + triggerCondition: 'Price above 3400', + triggerPx: '3400', + orderType: 'Take Profit Limit', + children: [], + }, + ]); + const { exchangeClient } = useStrategyClients({ + exchange: { + order: jest.fn().mockResolvedValue({ + status: 'ok', + response: { + data: { statuses: ['waitingForTrigger'] }, + }, + }), + }, + info: { frontendOpenOrders }, + }); + + const result = await provider.updatePositionTPSL({ + symbol: 'ETH', + takeProfitPrice: '3500', + position: createMockPosition({ symbol: 'ETH', size: '1.5' }), + }); + + expect(result.success).toBe(true); + expect(exchangeClient.cancel).toHaveBeenCalledWith({ + cancels: [{ a: 1, o: 456 }], + }); + }); + + it('reconciles a waiting trigger before cleaning up a mixed failure', async () => { + const frontendOpenOrders = jest + .fn() + .mockResolvedValueOnce([]) + .mockResolvedValueOnce([ + { + coin: 'ETH', + side: 'A', + limitPx: '3500', + sz: '0.4', + origSz: '0.4', + oid: 901, + timestamp: 1_700_000_000_000, + reduceOnly: true, + isTrigger: true, + isPositionTpsl: false, + triggerCondition: 'Price above 3500', + triggerPx: '3500', + orderType: 'Take Profit Limit', + children: [], + }, + ]); + const { exchangeClient } = useStrategyClients({ + exchange: { + order: jest.fn().mockResolvedValue({ + status: 'ok', + response: { + data: { + statuses: [ + 'waitingForTrigger', + { error: 'Rejected stop loss' }, + ], + }, + }, + }), + }, + info: { frontendOpenOrders }, + }); + + const result = await provider.updatePositionTPSL({ + symbol: 'ETH', + takeProfitPrice: '3500', + takeProfitSize: '0.4', + stopLossPrice: '2500', + stopLossSize: '0.6', + position: createMockPosition({ symbol: 'ETH', size: '1.5' }), + }); + + expect(result).toMatchObject({ + success: false, + error: PERPS_ERROR_CODES.TPSL_UPDATE_FAILED, + }); + expect(exchangeClient.cancel).toHaveBeenCalledWith({ + cancels: [{ a: 1, o: 901 }], + }); + }); + + it('reports lost protection when a waiting trigger cannot be reconciled after a mixed failure', async () => { + const { exchangeClient } = useStrategyClients({ + exchange: { + order: jest.fn().mockResolvedValue({ + status: 'ok', + response: { + data: { + statuses: [ + 'waitingForTrigger', + { error: 'Rejected stop loss' }, + ], + }, + }, + }), + }, + info: { + frontendOpenOrders: jest.fn().mockResolvedValue([]), + }, + }); + + const result = await provider.updatePositionTPSL({ + symbol: 'ETH', + takeProfitPrice: '3500', + takeProfitSize: '0.4', + stopLossPrice: '2500', + stopLossSize: '0.6', + position: createMockPosition({ symbol: 'ETH', size: '1.5' }), + }); + + expect(result).toStrictEqual({ + success: false, + error: PERPS_ERROR_CODES.TPSL_PROTECTION_LOST, + childOrderIds: [], + }); + expect(exchangeClient.cancel).not.toHaveBeenCalled(); + }); + + it.each([ + ['an unknown placement status', ['futureTriggerStatus']], + ['an incomplete placement response', []], + ])('restores old protection after %s', async (_label, statuses) => { + const order = jest + .fn() + .mockResolvedValueOnce({ + status: 'ok', + response: { + data: { statuses }, + }, + }) + .mockResolvedValueOnce({ + status: 'ok', + response: { + data: { statuses: [{ resting: { oid: 902 } }] }, + }, + }); + const { exchangeClient } = useStrategyClients({ + exchange: { order }, + info: { + frontendOpenOrders: jest.fn().mockResolvedValue([ + { + coin: 'ETH', + side: 'A', + limitPx: '2450', + sz: '0', + origSz: '0', + oid: 456, + timestamp: 1_700_000_000_000, + reduceOnly: true, + isTrigger: true, + isPositionTpsl: true, + triggerCondition: 'Price below 2450', + triggerPx: '2450', + orderType: 'Stop Market', + children: [], + }, + ]), + }, + }); + + const result = await provider.updatePositionTPSL({ + symbol: 'ETH', + takeProfitPrice: '3500', + position: createMockPosition({ symbol: 'ETH', size: '1.5' }), + }); + + expect(result).toStrictEqual({ + success: false, + error: PERPS_ERROR_CODES.TPSL_UPDATE_FAILED, + }); + expect(exchangeClient.cancel).toHaveBeenCalledWith({ + cancels: [{ a: 1, o: 456 }], + }); + expect(order).toHaveBeenCalledTimes(2); + expect(order.mock.calls[1][0]).toMatchObject({ + grouping: 'positionTpsl', + orders: [expect.objectContaining({ p: '2450', s: '0' })], + }); + }); + + it('cancels old protection before placing partial TP/SL', async () => { + const { exchangeClient } = useStrategyClients({ + info: { + frontendOpenOrders: jest.fn().mockResolvedValue([ + { + coin: 'ETH', + side: 'A', + limitPx: '3400', + sz: '0', + origSz: '0', + oid: 456, + timestamp: 1_700_000_000_000, + reduceOnly: true, + isTrigger: true, + isPositionTpsl: true, + triggerCondition: 'Price above 3400', + triggerPx: '3400', + orderType: 'Take Profit Limit', + children: [], + }, + ]), + }, + }); + + const result = await provider.updatePositionTPSL({ + symbol: 'ETH', + takeProfitPrice: '3500', + takeProfitSize: '0.4', + }); + + expect(result.success).toBe(true); + expect(exchangeClient.order).toHaveBeenCalledWith( + expect.objectContaining({ grouping: 'na' }), + ); + expect(exchangeClient.cancel).toHaveBeenCalledWith({ + cancels: [{ a: 1, o: 456 }], + }); + expect(exchangeClient.cancel.mock.invocationCallOrder[0]).toBeLessThan( + exchangeClient.order.mock.invocationCallOrder[0], + ); + }); + + it('restores old protection when a partial TP/SL replacement fails', async () => { + const order = jest + .fn() + .mockResolvedValueOnce({ + status: 'ok', + response: { + data: { statuses: [{ error: 'Rejected replacement' }] }, + }, + }) + .mockResolvedValueOnce({ + status: 'ok', + response: { + data: { statuses: [{ resting: { oid: 789 } }] }, + }, + }); + const { exchangeClient } = useStrategyClients({ + exchange: { order }, + info: { + frontendOpenOrders: jest.fn().mockResolvedValue([ + { + coin: 'ETH', + side: 'A', + limitPx: '3400', + sz: '0', + origSz: '0', + oid: 456, + timestamp: 1_700_000_000_000, + reduceOnly: true, + isTrigger: true, + isPositionTpsl: true, + triggerCondition: 'Price above 3400', + triggerPx: '3400', + orderType: 'Take Profit Limit', + children: [], + }, + ]), + }, + }); + + const result = await provider.updatePositionTPSL({ + symbol: 'ETH', + takeProfitPrice: '3500', + takeProfitSize: '0.4', + }); + + expect(result).toMatchObject({ + success: false, + error: PERPS_ERROR_CODES.TPSL_UPDATE_FAILED, + }); + expect(exchangeClient.cancel.mock.invocationCallOrder[0]).toBeLessThan( + order.mock.invocationCallOrder[0], + ); + expect(order).toHaveBeenCalledTimes(2); + expect(order.mock.calls[1][0]).toMatchObject({ + grouping: 'positionTpsl', + orders: [expect.objectContaining({ p: '3400', s: '0' })], + }); + }); + }); + + describe('TWAP placement', () => { + it('does not request builder-fee approval', async () => { + const { exchangeClient } = useStrategyClients({ + info: { maxBuilderFee: jest.fn().mockResolvedValue(0) }, + }); + + await provider.placeOrder({ + ...baseOrder, + orderType: 'twap', + twapDuration: 30, + }); + + expect(exchangeClient.approveBuilderFee).not.toHaveBeenCalled(); + expect(exchangeClient.twapOrder).toHaveBeenCalledWith({ + twap: expect.objectContaining({ a: 1, m: 30 }), + }); + }); + + it('approves the builder fee when a standard order follows a TWAP', async () => { + const { exchangeClient } = useStrategyClients({ + info: { maxBuilderFee: jest.fn().mockResolvedValue(0) }, + }); + + await provider.placeOrder({ + ...baseOrder, + orderType: 'twap', + twapDuration: 30, + }); + expect(exchangeClient.approveBuilderFee).not.toHaveBeenCalled(); + + await provider.placeOrder({ + ...baseOrder, + orderType: 'market', + }); + + expect(exchangeClient.approveBuilderFee).toHaveBeenCalledTimes(1); + }); + + it.each([ + [undefined, PERPS_ERROR_CODES.ORDER_TWAP_DURATION_REQUIRED], + [1.5, PERPS_ERROR_CODES.ORDER_TWAP_DURATION_INVALID], + [0, PERPS_ERROR_CODES.ORDER_TWAP_DURATION_INVALID], + [ + HYPERLIQUID_TWAP_LIMITS.MaxDurationMinutes + 1, + PERPS_ERROR_CODES.ORDER_TWAP_DURATION_INVALID, + ], + [2 ** 53, PERPS_ERROR_CODES.ORDER_TWAP_DURATION_INVALID], + ])( + 'rejects provider-level TWAP duration %p before submission', + async (twapDuration, expectedError) => { + const { exchangeClient } = useStrategyClients(); + + const result = await provider.placeOrder({ + ...baseOrder, + orderType: 'twap', + twapDuration, + } satisfies OrderParams); + + expect(result.error).toBe(expectedError); + expect(exchangeClient.twapOrder).not.toHaveBeenCalled(); + }, + ); + + it('still approves the builder fee when a standard order joins TWAP setup', async () => { + const spotMetaRequestStarted = createDeferred(); + const pendingSpotMeta = createDeferred<{ + tokens: { name: string; tokenId: string; index: number }[]; + universe: never[]; + }>(); + const { exchangeClient } = useStrategyClients({ + info: { + maxBuilderFee: jest.fn().mockResolvedValue(0), + perpDexs: jest.fn().mockResolvedValue([null]), + spotMeta: jest.fn().mockImplementation(() => { + spotMetaRequestStarted.resolve(); + return pendingSpotMeta.promise; + }), + }, + }); + provider = createTestProvider({ hip3Enabled: true }); + + const twapOrder = provider.placeOrder({ + ...baseOrder, + orderType: 'twap', + twapDuration: 30, + }); + await spotMetaRequestStarted.promise; + const standardOrder = provider.placeOrder({ + ...baseOrder, + orderType: 'market', + }); + pendingSpotMeta.resolve({ + tokens: [{ name: 'USDC', tokenId: '0xdef456', index: 0 }], + universe: [], + }); + + await Promise.all([twapOrder, standardOrder]); + expect(exchangeClient.approveBuilderFee).toHaveBeenCalledTimes(1); + }); + + it('does not restore spot metadata after disconnect', async () => { + provider = createTestProvider({ + hip3Enabled: true, + allowlistMarkets: ['xyz:*'], + }); + const spotMetaRequestStarted = createDeferred(); + const pendingSpotMeta = createDeferred<{ + tokens: { name: string; tokenId: string; index: number }[]; + universe: never[]; + }>(); + const spotMeta = jest + .fn() + .mockImplementationOnce(() => { + spotMetaRequestStarted.resolve(); + return pendingSpotMeta.promise; + }) + .mockResolvedValue({ + tokens: [{ name: 'USDC', tokenId: '0xdef456', index: 0 }], + universe: [], + }); + useStrategyClients({ info: { spotMeta } }); + + const staleOrder = provider.placeOrder({ + ...baseOrder, + orderType: 'market', + }); + await spotMetaRequestStarted.promise; + const disconnectPromise = provider.disconnect(); + pendingSpotMeta.resolve({ + tokens: [{ name: 'USDC', tokenId: '0xdef456', index: 0 }], + universe: [], + }); + + expect(await staleOrder).toMatchObject({ + success: false, + error: PERPS_ERROR_CODES.PROVIDER_LIFECYCLE_STALE, + }); + expect(await disconnectPromise).toStrictEqual({ success: true }); + + expect(await provider.initialize()).toMatchObject({ + success: true, + }); + expect( + await provider.placeOrder({ + ...baseOrder, + orderType: 'market', + }), + ).toMatchObject({ success: true }); + expect(spotMeta).toHaveBeenCalledTimes(2); + }); + + it('submits the venue TWAP action rather than an order', async () => { + const { exchangeClient } = useStrategyClients(); + + const result = await provider.placeOrder({ + ...baseOrder, + orderType: 'twap', + twapDuration: 30, + twapRandomize: true, + } satisfies OrderParams); + + expect(result.success).toBe(true); + expect(exchangeClient.twapOrder).toHaveBeenCalledWith({ + twap: { + a: 1, + b: true, + s: '1', + r: false, + m: 30, + t: true, + }, + }); + expect(exchangeClient.order).not.toHaveBeenCalled(); + }); + + it('returns the venue TWAP id as the handle', async () => { + useStrategyClients(); + + const result = await provider.placeOrder({ + ...baseOrder, + orderType: 'twap', + twapDuration: 30, + } satisfies OrderParams); + + expect(result).toMatchObject({ + success: true, + orderId: '987', + submittedSize: '1', + }); + }); + + it('defaults randomize and reduce-only to false', async () => { + const { exchangeClient } = useStrategyClients(); + + await provider.placeOrder({ + ...baseOrder, + orderType: 'twap', + twapDuration: 5, + } satisfies OrderParams); + + expect(exchangeClient.twapOrder).toHaveBeenCalledWith( + expect.objectContaining({ + twap: expect.objectContaining({ r: false, t: false }), + }), + ); + }); + + it('surfaces a venue rejection as a failed result', async () => { + useStrategyClients({ + exchange: { + twapOrder: jest.fn().mockResolvedValue({ + status: 'ok', + response: { + type: 'twapOrder', + data: { status: { error: 'Insufficient margin' } }, + }, + }), + }, + }); + + const result = await provider.placeOrder({ + ...baseOrder, + orderType: 'twap', + twapDuration: 30, + } satisfies OrderParams); + + expect(result.success).toBe(false); + expect(result.error).toContain('Insufficient margin'); + }); + + it.each(['987', Number.NaN, Number.POSITIVE_INFINITY, -1, 1.5])( + 'rejects malformed TWAP response id %p', + async (twapId) => { + useStrategyClients({ + exchange: { + twapOrder: jest.fn().mockResolvedValue({ + status: 'ok', + response: { + type: 'twapOrder', + data: { status: { running: { twapId } } }, + }, + }), + }, + }); + + const result = await provider.placeOrder({ + ...baseOrder, + orderType: 'twap', + twapDuration: 30, + } satisfies OrderParams); + + expect(result.success).toBe(false); + expect(result.orderId).toBeUndefined(); + expect(result.error).toBe('TWAP order rejected'); + }, + ); + }); + + describe('TWAP cancellation', () => { + it('uses the TWAP cancel endpoint, never the order cancel endpoint', async () => { + const { exchangeClient } = useStrategyClients({ + info: { + twapHistory: jest.fn().mockResolvedValue(activeEthTwapHistory), + }, + }); + + const result = await provider.cancelOrder({ + orderId: '987', + symbol: 'ETH', + orderType: 'twap', + providerId: 'hyperliquid', + }); + + expect(result).toStrictEqual({ success: true, orderId: '987' }); + expect(exchangeClient.twapCancel).toHaveBeenCalledWith({ a: 1, t: 987 }); + expect(exchangeClient.cancel).not.toHaveBeenCalled(); + expect(exchangeClient.approveBuilderFee).not.toHaveBeenCalled(); + }); + + it.each(['987junk', 'NaN', '-1', '1.5', '9007199254740992'])( + 'rejects malformed TWAP handle %p before signing', + async (orderId) => { + const { exchangeClient } = useStrategyClients(); + + const result = await provider.cancelOrder({ + orderId, + symbol: 'ETH', + orderType: 'twap', + providerId: 'hyperliquid', + }); + + expect(result).toStrictEqual({ + success: false, + orderId, + error: PERPS_ERROR_CODES.ORDER_STRATEGY_HANDLE_UNKNOWN, + }); + expect(exchangeClient.twapCancel).not.toHaveBeenCalled(); + expect(exchangeClient.approveBuilderFee).not.toHaveBeenCalled(); + }, + ); + + it('treats an already-finished TWAP cancel as successful', async () => { + useStrategyClients({ + exchange: { + twapCancel: jest.fn().mockResolvedValue({ + status: 'ok', + response: { + type: 'twapCancel', + data: { status: { error: 'Twap not found' } }, + }, + }), + }, + info: { + twapHistory: jest.fn().mockResolvedValue(activeEthTwapHistory), + }, + }); + + const result = await provider.cancelOrder({ + orderId: '987', + symbol: 'ETH', + orderType: 'twap', + providerId: 'hyperliquid', + }); + + expect(result).toStrictEqual({ success: true, orderId: '987' }); + }); + + it('reports a refused TWAP cancel as a failure', async () => { + useStrategyClients({ + exchange: { + twapCancel: jest.fn().mockResolvedValue({ + status: 'ok', + response: { + type: 'twapCancel', + data: { status: { error: 'Invalid nonce' } }, + }, + }), + }, + info: { + twapHistory: jest.fn().mockResolvedValue(activeEthTwapHistory), + }, + }); + + const result = await provider.cancelOrder({ + orderId: '987', + symbol: 'ETH', + orderType: 'twap', + providerId: 'hyperliquid', + }); + + expect(result.success).toBe(false); + expect(result.error).toBe(PERPS_ERROR_CODES.EXCHANGE_INVALID_NONCE); + }); + + it('rejects a handle that neither tracking nor venue history can authenticate', async () => { + const { exchangeClient } = useStrategyClients(); + + const result = await provider.cancelOrder({ + orderId: '654321', + symbol: 'ETH', + orderType: 'twap', + providerId: 'hyperliquid', + }); + + expect(result).toStrictEqual({ + success: false, + orderId: '654321', + error: PERPS_ERROR_CODES.ORDER_STRATEGY_HANDLE_UNKNOWN, + }); + expect(exchangeClient.twapCancel).not.toHaveBeenCalled(); + }); + + it('cancels an untracked TWAP when venue history is temporarily unavailable', async () => { + const { exchangeClient } = useStrategyClients({ + info: { + twapHistory: jest + .fn() + .mockRejectedValue(new Error('History unavailable')), + }, + }); + + const result = await provider.cancelOrder({ + orderId: '987', + symbol: 'ETH', + orderType: 'twap', + providerId: 'hyperliquid', + }); + + expect(result).toStrictEqual({ success: true, orderId: '987' }); + expect(exchangeClient.twapCancel).toHaveBeenCalledWith({ a: 1, t: 987 }); + }); + + it('does not authenticate an unknown TWAP through a failed history read', async () => { + const { exchangeClient } = useStrategyClients({ + exchange: { + twapCancel: jest.fn().mockResolvedValue({ + status: 'ok', + response: { + type: 'twapCancel', + data: { status: { error: 'Twap not found' } }, + }, + }), + }, + info: { + twapHistory: jest + .fn() + .mockRejectedValue(new Error('History unavailable')), + }, + }); + + const result = await provider.cancelOrder({ + orderId: '987', + symbol: 'ETH', + orderType: 'twap', + providerId: 'hyperliquid', + }); + + expect(result).toStrictEqual({ + success: false, + orderId: '987', + error: PERPS_ERROR_CODES.ORDER_STRATEGY_HANDLE_UNKNOWN, + }); + expect(exchangeClient.twapCancel).toHaveBeenCalledWith({ a: 1, t: 987 }); + }); + + it('preserves handle ownership across provider recreation', async () => { + const { exchangeClient } = useStrategyClients(); + const placed = await provider.placeOrder({ + ...baseOrder, + orderType: 'twap', + twapDuration: 30, + } satisfies OrderParams); + await provider.disconnect(); + const recreatedProvider = createTestProvider({ + initialAssetMapping: [ + ['BTC', 0], + ['ETH', 1], + ], + }); + + expect( + await recreatedProvider.cancelOrder({ + orderId: placed.orderId, + symbol: 'BTC', + orderType: 'twap', + providerId: 'hyperliquid', + }), + ).toStrictEqual({ + success: false, + orderId: placed.orderId, + error: PERPS_ERROR_CODES.ORDER_STRATEGY_HANDLE_UNKNOWN, + }); + expect(exchangeClient.twapCancel).not.toHaveBeenCalled(); + + expect( + await recreatedProvider.cancelOrder({ + orderId: placed.orderId, + symbol: 'ETH', + orderType: 'twap', + providerId: 'hyperliquid', + }), + ).toStrictEqual({ + success: true, + orderId: placed.orderId, + }); + expect(exchangeClient.twapCancel).toHaveBeenCalledWith({ a: 1, t: 987 }); + }); + + it('leaves an ordinary cancel on the order endpoint', async () => { + const { exchangeClient } = useStrategyClients(); + + await provider.cancelOrder({ orderId: '123', symbol: 'ETH' }); + + expect(exchangeClient.cancel).toHaveBeenCalledWith({ + cancels: [{ a: 1, o: 123 }], + }); + expect(exchangeClient.twapCancel).not.toHaveBeenCalled(); + expect(exchangeClient.approveBuilderFee).not.toHaveBeenCalled(); + }); + + it('retains the requested exchange ID when an ordinary cancel rejects', async () => { + useStrategyClients({ + exchange: { + cancel: jest.fn().mockRejectedValue(new Error('Cancel unavailable')), + }, + }); + + const result = await provider.cancelOrder({ + orderId: '123', + symbol: 'ETH', + }); + + expect(result).toStrictEqual({ + success: false, + orderId: '123', + error: 'Cancel unavailable', + }); + }); + + it('rejects every ordinary cancel when the batch response is truncated', async () => { + useStrategyClients({ + exchange: { + cancel: jest.fn().mockResolvedValue({ + status: 'ok', + response: { data: { statuses: ['success', 'success'] } }, + }), + }, + }); + + const result = await provider.cancelOrders([ + { orderId: '123', symbol: 'ETH' }, + { orderId: '456', symbol: 'BTC' }, + { orderId: '789', symbol: 'ETH' }, + ]); + + expect(result).toStrictEqual({ + success: false, + successCount: 0, + failureCount: 3, + results: [ + { + orderId: '123', + symbol: 'ETH', + success: false, + error: PERPS_ERROR_CODES.BATCH_CANCEL_FAILED, + }, + { + orderId: '456', + symbol: 'BTC', + success: false, + error: PERPS_ERROR_CODES.BATCH_CANCEL_FAILED, + }, + { + orderId: '789', + symbol: 'ETH', + success: false, + error: PERPS_ERROR_CODES.BATCH_CANCEL_FAILED, + }, + ], + }); + }); + }); + + describe('TWAP lifecycle', () => { + const userAddress = '0x1234567890123456789012345678901234567890'; + const startedAt = 1_700_000_000_000; + + beforeEach(() => { + jest.useFakeTimers(); + jest.setSystemTime(startedAt + 5 * 60_000); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + it('adapts active progress and slice fills from the venue', async () => { + const { infoClient } = useStrategyClients({ + info: { + twapHistory: jest.fn().mockResolvedValue([ + { + time: 1_700_000_030, + twapId: 987, + state: { + coin: 'ETH', + executedNtl: '1200', + executedSz: '0.4', + minutes: 10, + randomize: true, + reduceOnly: false, + side: 'B', + sz: '1', + timestamp: startedAt, + user: userAddress, + }, + status: { status: 'activated' }, + }, + ]), + userTwapSliceFills: jest.fn().mockResolvedValue([ + { + twapId: 987, + fill: { + coin: 'ETH', + px: '3000', + sz: '0.4', + side: 'B', + time: startedAt + 2 * 60_000, + startPosition: '0', + dir: 'Open Long', + closedPnl: '0', + hash: '0xabc', + oid: 321, + crossed: true, + fee: '1.20', + tid: 456, + feeToken: 'USDC', + twapId: 987, + }, + }, + ]), + }, + }); + + expect(await provider.getTwapOrders()).toStrictEqual([ + { + orderId: '987', + symbol: 'ETH', + side: 'buy', + size: '1', + executedSize: '0.4', + remainingSize: '0.6', + executedNotional: '1200', + averagePrice: '3000', + fillProgressBps: 4000, + timeProgressBps: 5000, + elapsedTimeMilliseconds: 300_000, + durationMinutes: 10, + randomize: true, + reduceOnly: false, + status: 'active', + startedAt, + lastUpdated: startedAt + 2 * 60_000, + fills: [ + { + fillId: '456', + orderId: '321', + side: 'buy', + price: '3000', + size: '0.4', + fee: '1.20', + feeToken: 'USDC', + timestamp: startedAt + 2 * 60_000, + transactionHash: '0xabc', + }, + ], + }, + ]); + expect(infoClient.twapHistory).toHaveBeenCalledWith({ + user: userAddress, + }); + expect(infoClient.userTwapSliceFills).toHaveBeenCalledWith({ + user: userAddress, + }); + }); + + it('deduplicates history and distinguishes an underfilled completion', async () => { + useStrategyClients({ + info: { + twapHistory: jest.fn().mockResolvedValue([ + { + time: 1_700_000_001, + twapId: 987, + state: { + coin: 'ETH', + executedNtl: '0', + executedSz: '0', + minutes: 10, + randomize: false, + reduceOnly: false, + side: 'A', + sz: '1', + timestamp: startedAt, + user: userAddress, + }, + status: { status: 'activated' }, + }, + { + time: 1_700_000_600, + twapId: 987, + state: { + coin: 'ETH', + executedNtl: '2400', + executedSz: '0.8', + minutes: 10, + randomize: false, + reduceOnly: false, + side: 'A', + sz: '1', + timestamp: startedAt, + user: userAddress, + }, + status: { status: 'finished' }, + }, + ]), + }, + }); + + const orders = await provider.getTwapOrders(); + + expect(orders).toHaveLength(1); + expect(orders[0]).toMatchObject({ + orderId: '987', + side: 'sell', + status: 'completed_underfilled', + executedSize: '0.8', + remainingSize: '0.2', + averagePrice: '3000', + fillProgressBps: 8000, + timeProgressBps: 10000, + elapsedTimeMilliseconds: 600_000, + }); + }); + + it.each([ + ['negative', '-1', '0', '10', 0, 'completed_underfilled'], + ['oversized', '11', '10', '0', 10_000, 'completed'], + ] as const)( + 'bounds %s venue execution before reporting TWAP progress', + async ( + _label, + venueExecutedSize, + executedSize, + remainingSize, + fillProgressBps, + status, + ) => { + useStrategyClients({ + info: { + twapHistory: jest.fn().mockResolvedValue([ + { + time: 1_700_000_600, + twapId: 987, + state: { + coin: 'ETH', + executedNtl: '0', + executedSz: venueExecutedSize, + minutes: 10, + randomize: false, + reduceOnly: false, + side: 'B', + sz: '10', + timestamp: startedAt, + user: userAddress, + }, + status: { status: 'finished' }, + }, + ]), + }, + }); + + expect(await provider.getTwapOrders()).toContainEqual( + expect.objectContaining({ + orderId: '987', + executedSize, + remainingSize, + fillProgressBps, + status, + }), + ); + }, + ); + + it.each([ + ['waitingForTrigger', 'active'], + ['stopped', 'canceled'], + ['futureStatus', 'active'], + ] as const)('maps the venue %s status to %s', async (status, expected) => { + useStrategyClients({ + info: { + twapHistory: jest.fn().mockResolvedValue([ + { + time: 1_700_000_030, + twapId: 987, + state: { + coin: 'ETH', + executedNtl: '0', + executedSz: '0', + minutes: 10, + randomize: false, + reduceOnly: false, + side: 'B', + sz: '1', + timestamp: startedAt, + user: userAddress, + }, + status: { status }, + }, + ]), + }, + }); + + expect(await provider.getTwapOrders()).toContainEqual( + expect.objectContaining({ orderId: '987', status: expected }), + ); + }); + + it('omits a TWAP with malformed venue decimals', async () => { + useStrategyClients({ + info: { + twapHistory: jest.fn().mockResolvedValue([ + { + time: 1_700_000_030, + twapId: 987, + state: { + coin: 'ETH', + executedNtl: 'NaN', + executedSz: '0', + minutes: 10, + randomize: false, + reduceOnly: false, + side: 'B', + sz: '1', + timestamp: startedAt, + user: userAddress, + }, + status: { status: 'activated' }, + }, + ]), + }, + }); + + expect(await provider.getTwapOrders()).toStrictEqual([]); + }); + + it('coalesces concurrent HIP-3 TWAP collateral cleanup', async () => { + let twapPlaced = false; + const clearinghouseState = jest.fn().mockImplementation(({ dex }) => { + let withdrawable = '1000'; + if (dex === 'xyz') { + withdrawable = twapPlaced ? '20' : '0'; + } + return Promise.resolve(createClearinghouseBalance(withdrawable)); + }); + useStrategyClients({ + exchange: { + twapOrder: jest.fn().mockImplementation(async () => { + twapPlaced = true; + return { + status: 'ok', + response: { + type: 'twapOrder', + data: { status: { running: { twapId: 987 } } }, + }, + }; + }), + }, + info: { + clearinghouseState, + perpDexs: jest.fn().mockResolvedValue([null, { name: 'xyz' }]), + meta: jest.fn().mockResolvedValue({ + universe: [{ name: 'xyz:TSLA', szDecimals: 3, maxLeverage: 20 }], + collateralToken: 0, + }), + allMids: jest.fn().mockResolvedValue({ 'xyz:TSLA': '3000' }), + twapHistory: jest.fn().mockResolvedValue([ + { + time: 1_700_000_600, + twapId: 987, + state: { + coin: 'xyz:TSLA', + executedNtl: '3000', + executedSz: '1', + minutes: 30, + randomize: false, + reduceOnly: false, + side: 'B', + sz: '1', + timestamp: startedAt, + user: userAddress, + }, + status: { status: 'finished' }, + }, + ]), + }, + }); + provider = createTestProvider({ + hip3Enabled: true, + allowlistMarkets: ['xyz:*'], + useUnifiedAccount: false, + initialAssetMapping: [['xyz:TSLA', 110000]], + }); + const cleanupStarted = createDeferred(); + const pendingCleanup = createDeferred<{ success: boolean }>(); + const transfer = jest + .spyOn(provider, 'transferBetweenDexs') + .mockResolvedValueOnce({ success: true }) + .mockImplementationOnce(() => { + cleanupStarted.resolve(); + return pendingCleanup.promise; + }); + + expect( + await provider.placeOrder({ + ...baseOrder, + symbol: 'xyz:TSLA', + orderType: 'twap', + twapDuration: 30, + } satisfies OrderParams), + ).toMatchObject({ success: true, orderId: '987' }); + + const reads = [provider.getTwapOrders(), provider.getTwapOrders()]; + await cleanupStarted.promise; + expect(transfer).toHaveBeenCalledTimes(2); + pendingCleanup.resolve({ success: true }); + await Promise.all(reads); + + expect(transfer).toHaveBeenCalledTimes(2); + }); + + it('retries a failed HIP-3 collateral rebalance after provider recreation', async () => { + let twapPlaced = false; + const clearinghouseState = jest.fn().mockImplementation(({ dex }) => { + let withdrawable = '1000'; + if (dex === 'xyz') { + withdrawable = twapPlaced ? '20' : '0'; + } + return Promise.resolve(createClearinghouseBalance(withdrawable)); + }); + const twapOrder = jest.fn().mockImplementation(async () => { + twapPlaced = true; + return { + status: 'ok', + response: { + type: 'twapOrder', + data: { status: { running: { twapId: 987 } } }, + }, + }; + }); + useStrategyClients({ + exchange: { twapOrder }, + info: { + clearinghouseState, + perpDexs: jest.fn().mockResolvedValue([null, { name: 'xyz' }]), + meta: jest.fn().mockResolvedValue({ + universe: [{ name: 'xyz:TSLA', szDecimals: 3, maxLeverage: 20 }], + collateralToken: 0, + }), + allMids: jest.fn().mockResolvedValue({ 'xyz:TSLA': '3000' }), + twapHistory: jest.fn().mockResolvedValue([ + { + time: 1_700_000_600, + twapId: 987, + state: { + coin: 'xyz:TSLA', + executedNtl: '3000', + executedSz: '1', + minutes: 30, + randomize: false, + reduceOnly: false, + side: 'B', + sz: '1', + timestamp: startedAt, + user: userAddress, + }, + status: { status: 'finished' }, + }, + ]), + }, + }); + provider = createTestProvider({ + hip3Enabled: true, + allowlistMarkets: ['xyz:*'], + useUnifiedAccount: false, + initialAssetMapping: [['xyz:TSLA', 110000]], + }); + const initialTransfer = jest + .spyOn(provider, 'transferBetweenDexs') + .mockResolvedValue({ success: true }); + + expect( + await provider.placeOrder({ + ...baseOrder, + symbol: 'xyz:TSLA', + orderType: 'twap', + twapDuration: 30, + } satisfies OrderParams), + ).toMatchObject({ success: true, orderId: '987' }); + expect(initialTransfer).toHaveBeenCalledTimes(1); + await provider.disconnect(); + + const recreatedProvider = createTestProvider({ + hip3Enabled: true, + allowlistMarkets: ['xyz:*'], + useUnifiedAccount: false, + initialAssetMapping: [['xyz:TSLA', 110000]], + }); + const rebalance = jest + .spyOn(recreatedProvider, 'transferBetweenDexs') + .mockResolvedValueOnce({ success: false, error: 'transfer failed' }) + .mockResolvedValue({ success: true }); + + await recreatedProvider.getTwapOrders(); + expect(rebalance).toHaveBeenCalledTimes(1); + + await recreatedProvider.getTwapOrders(); + expect(rebalance).toHaveBeenCalledTimes(2); + + await recreatedProvider.getTwapOrders(); + expect(rebalance).toHaveBeenCalledTimes(2); + }); + }); + + describe('Scale placement', () => { + const scaleStatuses = { + status: 'ok', + response: { + data: { + statuses: [ + { resting: { oid: 11 } }, + { resting: { oid: 22 } }, + { resting: { oid: 33 } }, + ], + }, + }, + }; + + it('fans out one order per rung, spread across the range', async () => { + const { exchangeClient } = useStrategyClients({ + exchange: { order: jest.fn().mockResolvedValue(scaleStatuses) }, + }); + + await provider.placeOrder({ + ...baseOrder, + orderType: 'scale', + scaleMinPrice: '2000', + scaleMaxPrice: '3000', + scaleNumOrders: 3, + } satisfies OrderParams); + + expect(exchangeClient.order).toHaveBeenCalledTimes(1); + const submitted = exchangeClient.order.mock.calls[0][0]; + expect(submitted.grouping).toBe('na'); + expect(submitted.orders).toHaveLength(3); + expect(submitted.builder).toStrictEqual({ + b: BUILDER_FEE_CONFIG.MainnetBuilder, + f: BUILDER_FEE_CONFIG.MaxFeeTenthsBps, + }); + expect( + submitted.orders.map((order: { p: string }) => order.p), + ).toStrictEqual(['2000', '2500', '3000']); + }); + + it('splits the size across the rungs so the total is preserved', async () => { + const { exchangeClient } = useStrategyClients({ + exchange: { order: jest.fn().mockResolvedValue(scaleStatuses) }, + }); + + await provider.placeOrder({ + ...baseOrder, + orderType: 'scale', + scaleMinPrice: '2000', + scaleMaxPrice: '3000', + scaleNumOrders: 3, + } satisfies OrderParams); + + const submitted = exchangeClient.order.mock.calls[0][0]; + const sizes = submitted.orders.map((order: { s: string }) => order.s); + expect(sizes).toStrictEqual(['0.3334', '0.3333', '0.3333']); + expect( + sizes.reduce( + (total: number, size: string) => total + parseFloat(size), + 0, + ), + ).toBeCloseTo(1, 8); + }); + + it('weights the rungs along the ladder when a skew is supplied', async () => { + const { exchangeClient } = useStrategyClients({ + exchange: { order: jest.fn().mockResolvedValue(scaleStatuses) }, + }); + + await provider.placeOrder({ + ...baseOrder, + orderType: 'scale', + scaleMinPrice: '2000', + scaleMaxPrice: '3000', + scaleNumOrders: 3, + scaleSkew: 2, + } satisfies OrderParams); + + const submitted = exchangeClient.order.mock.calls[0][0]; + const sizes = submitted.orders.map((order: { s: string }) => order.s); + // The largest rung is the one at scaleMaxPrice, and the ladder still adds + // up to the size that was validated. + expect(sizes).toStrictEqual(['0.2222', '0.3333', '0.4445']); + expect( + sizes.reduce( + (total: number, size: string) => total + parseFloat(size), + 0, + ), + ).toBeCloseTo(1, 8); + }); + + it('weights the bottom of the ladder for a skew below 1', async () => { + const { exchangeClient } = useStrategyClients({ + exchange: { order: jest.fn().mockResolvedValue(scaleStatuses) }, + }); + + await provider.placeOrder({ + ...baseOrder, + orderType: 'scale', + scaleMinPrice: '2000', + scaleMaxPrice: '3000', + scaleNumOrders: 3, + scaleSkew: 0.5, + } satisfies OrderParams); + + const submitted = exchangeClient.order.mock.calls[0][0]; + expect( + submitted.orders.map((order: { s: string }) => order.s), + ).toStrictEqual(['0.4445', '0.3333', '0.2222']); + }); + + it('splits evenly for a skew of exactly 1', async () => { + const { exchangeClient } = useStrategyClients({ + exchange: { order: jest.fn().mockResolvedValue(scaleStatuses) }, + }); + + await provider.placeOrder({ + ...baseOrder, + orderType: 'scale', + scaleMinPrice: '2000', + scaleMaxPrice: '3000', + scaleNumOrders: 3, + scaleSkew: 1, + } satisfies OrderParams); + + const submitted = exchangeClient.order.mock.calls[0][0]; + expect( + submitted.orders.map((order: { s: string }) => order.s), + ).toStrictEqual(['0.3334', '0.3333', '0.3333']); + }); + + it('rests every rung as a plain GTC limit order', async () => { + const { exchangeClient } = useStrategyClients({ + exchange: { order: jest.fn().mockResolvedValue(scaleStatuses) }, + }); + + await provider.placeOrder({ + ...baseOrder, + orderType: 'scale', + scaleMinPrice: '2000', + scaleMaxPrice: '3000', + scaleNumOrders: 3, + } satisfies OrderParams); + + const submitted = exchangeClient.order.mock.calls[0][0]; + submitted.orders.forEach((order: { t: unknown; b: boolean }) => { + expect(order.t).toStrictEqual({ limit: { tif: 'Gtc' } }); + expect(order.b).toBe(true); + }); + }); + + it('returns the ladder children alongside a group handle', async () => { + useStrategyClients({ + exchange: { order: jest.fn().mockResolvedValue(scaleStatuses) }, + }); + + const result = await provider.placeOrder({ + ...baseOrder, + orderType: 'scale', + scaleMinPrice: '2000', + scaleMaxPrice: '3000', + scaleNumOrders: 3, + } satisfies OrderParams); + + expect(result.success).toBe(true); + expect(result.childOrderIds).toStrictEqual(['11', '22', '33']); + expect(result.orderId).toMatch(/^scale:/u); + }); + + it('fails when the ladder rested nothing', async () => { + useStrategyClients({ + exchange: { + order: jest.fn().mockResolvedValue({ + status: 'ok', + response: { + data: { statuses: [{ error: 'Insufficient margin' }] }, + }, + }), + }, + }); + + const result = await provider.placeOrder({ + ...baseOrder, + orderType: 'scale', + scaleMinPrice: '2000', + scaleMaxPrice: '3000', + scaleNumOrders: 3, + } satisfies OrderParams); + + expect(result.success).toBe(false); + expect(result.error).toBe(PERPS_ERROR_CODES.ORDER_REJECTED); + }); + + it('reports filled rungs but keeps only resting rungs in a recovery group', async () => { + const cancel = jest + .fn() + .mockResolvedValueOnce({ + status: 'ok', + response: { + data: { statuses: [{ error: 'multi-sig required' }] }, + }, + }) + .mockResolvedValueOnce({ + status: 'ok', + response: { data: { statuses: ['success'] } }, + }); + const { exchangeClient } = useStrategyClients({ + exchange: { + order: jest.fn().mockResolvedValue({ + status: 'ok', + response: { + data: { + statuses: [ + { filled: { oid: 11 } }, + { resting: { oid: 22 } }, + { error: 'Insufficient margin' }, + ], + }, + }, + }), + cancel, + }, + }); + + const placed = await provider.placeOrder({ + ...baseOrder, + orderType: 'scale', + scaleMinPrice: '2000', + scaleMaxPrice: '3000', + scaleNumOrders: 3, + } satisfies OrderParams); + + expect(placed).toMatchObject({ + success: false, + error: PERPS_ERROR_CODES.ORDER_STRATEGY_CANCEL_INCOMPLETE, + childOrderIds: ['11', '22'], + submittedSize: '1', + }); + expect(placed.orderId).toMatch(/^scale:/u); + if (!placed.orderId) { + throw new Error('Expected a recovery group handle'); + } + + const cancelled = await provider.cancelOrder({ + orderId: placed.orderId, + symbol: 'ETH', + orderType: 'scale', + }); + + expect(cancelled.success).toBe(true); + expect(exchangeClient.cancel).toHaveBeenLastCalledWith({ + cancels: [{ a: 1, o: 22 }], + }); + }); + + it('cancels every child of the group in one batch', async () => { + const { exchangeClient } = useStrategyClients({ + exchange: { + order: jest.fn().mockResolvedValue(scaleStatuses), + cancel: jest.fn().mockResolvedValue({ + status: 'ok', + response: { data: { statuses: ['success', 'success', 'success'] } }, + }), + }, + }); + + const placed = await provider.placeOrder({ + ...baseOrder, + orderType: 'scale', + scaleMinPrice: '2000', + scaleMaxPrice: '3000', + scaleNumOrders: 3, + } satisfies OrderParams); + exchangeClient.approveBuilderFee.mockClear(); + + const result = await provider.cancelOrder({ + orderId: placed.orderId, + symbol: 'ETH', + orderType: 'scale', + }); + + expect(result.success).toBe(true); + expect(exchangeClient.cancel).toHaveBeenCalledWith({ + cancels: [ + { a: 1, o: 11 }, + { a: 1, o: 22 }, + { a: 1, o: 33 }, + ], + }); + expect(exchangeClient.approveBuilderFee).not.toHaveBeenCalled(); + }); + + it('does not restore a canceled group from a stale open-order cache', async () => { + const { exchangeClient } = useStrategyClients({ + exchange: { + order: jest.fn().mockResolvedValue(scaleStatuses), + cancel: jest.fn().mockResolvedValue({ + status: 'ok', + response: { data: { statuses: ['success', 'success', 'success'] } }, + }), + }, + }); + const placed = await provider.placeOrder({ + ...baseOrder, + orderType: 'scale', + scaleMinPrice: '2000', + scaleMaxPrice: '3000', + scaleNumOrders: 3, + } satisfies OrderParams); + if (!placed.orderId) { + throw new Error('Expected a Scale group handle'); + } + + expect( + await provider.cancelOrder({ + orderId: placed.orderId, + symbol: 'ETH', + orderType: 'scale', + }), + ).toStrictEqual({ success: true, orderId: placed.orderId }); + + const staleOrder = { + orderId: '11', + symbol: 'ETH', + side: 'buy', + orderType: 'limit', + size: '1', + originalSize: '1', + price: '2000', + filledSize: '0', + remainingSize: '1', + status: 'open', + timestamp: 1_700_000_000_000, + strategyGroupId: placed.orderId, + } satisfies Order; + mockSubscriptionService.getOrdersCacheIfInitialized.mockReturnValue([ + staleOrder, + ]); + await provider.getOpenOrders(); + + expect( + await provider.cancelOrder({ + orderId: placed.orderId, + symbol: 'ETH', + orderType: 'scale', + }), + ).toStrictEqual({ + success: false, + orderId: placed.orderId, + error: PERPS_ERROR_CODES.ORDER_STRATEGY_HANDLE_UNKNOWN, + }); + expect(exchangeClient.cancel).toHaveBeenCalledTimes(1); + }); + + it('does not shrink a live Scale group from a partial open-order snapshot', async () => { + const { exchangeClient } = useStrategyClients({ + exchange: { + order: jest.fn().mockResolvedValue(scaleStatuses), + cancel: jest.fn().mockResolvedValue({ + status: 'ok', + response: { data: { statuses: ['success', 'success', 'success'] } }, + }), + }, + }); + const placed = await provider.placeOrder({ + ...baseOrder, + orderType: 'scale', + scaleMinPrice: '2000', + scaleMaxPrice: '3000', + scaleNumOrders: 3, + } satisfies OrderParams); + if (!placed.orderId) { + throw new Error('Expected a Scale group handle'); + } + + mockSubscriptionService.getOrdersCacheIfInitialized.mockReturnValue( + ['11', '22'].map( + (orderId) => + ({ + orderId, + symbol: 'ETH', + side: 'buy', + orderType: 'limit', + size: '1', + originalSize: '1', + price: '2000', + filledSize: '0', + remainingSize: '1', + status: 'open', + timestamp: 1_700_000_000_000, + strategyGroupId: placed.orderId, + }) satisfies Order, + ), + ); + + await provider.getOpenOrders(); + await provider.cancelOrder({ + orderId: placed.orderId, + symbol: 'ETH', + orderType: 'scale', + }); + + expect(exchangeClient.cancel).toHaveBeenLastCalledWith({ + cancels: [ + { a: 1, o: 11 }, + { a: 1, o: 22 }, + { a: 1, o: 33 }, + ], + }); + }); + + it('adds later Scale rungs to a partially recovered group', async () => { + const { exchangeClient } = useStrategyClients({ + exchange: { + order: jest.fn().mockResolvedValue(scaleStatuses), + cancel: jest.fn().mockResolvedValue({ + status: 'ok', + response: { data: { statuses: ['success', 'success', 'success'] } }, + }), + }, + }); + const placed = await provider.placeOrder({ + ...baseOrder, + orderType: 'scale', + scaleMinPrice: '2000', + scaleMaxPrice: '3000', + scaleNumOrders: 3, + } satisfies OrderParams); + if (!placed.orderId) { + throw new Error('Expected a Scale group handle'); + } + const recreatedProvider = createTestProvider({ + initialAssetMapping: [ + ['BTC', 0], + ['ETH', 1], + ], + }); + const openOrders = (orderIds: string[]): Order[] => + orderIds.map( + (orderId) => + ({ + orderId, + symbol: 'ETH', + side: 'buy', + orderType: 'limit', + size: '1', + originalSize: '1', + price: '2000', + filledSize: '0', + remainingSize: '1', + status: 'open', + timestamp: 1_700_000_000_000, + strategyGroupId: placed.orderId, + }) satisfies Order, + ); + + mockSubscriptionService.getOrdersCacheIfInitialized.mockReturnValue( + openOrders(['11', '22']), + ); + await recreatedProvider.getOpenOrders(); + mockSubscriptionService.getOrdersCacheIfInitialized.mockReturnValue( + openOrders(['11', '22', '33']), + ); + await recreatedProvider.getOpenOrders(); + await recreatedProvider.cancelOrder({ + orderId: placed.orderId, + symbol: 'ETH', + orderType: 'scale', + }); + + expect(exchangeClient.cancel).toHaveBeenLastCalledWith({ + cancels: [ + { a: 1, o: 11 }, + { a: 1, o: 22 }, + { a: 1, o: 33 }, + ], + }); + }); + + it('reports an incomplete group cancel and keeps the handle for a retry', async () => { + const cancel = jest + .fn() + .mockResolvedValueOnce({ + status: 'ok', + response: { + data: { + statuses: ['success', { error: 'multi-sig required' }, 'success'], + }, + }, + }) + .mockResolvedValue({ + status: 'ok', + response: { data: { statuses: ['success'] } }, + }); + const { exchangeClient } = useStrategyClients({ + exchange: { order: jest.fn().mockResolvedValue(scaleStatuses), cancel }, + }); + + const placed = await provider.placeOrder({ + ...baseOrder, + orderType: 'scale', + scaleMinPrice: '2000', + scaleMaxPrice: '3000', + scaleNumOrders: 3, + } satisfies OrderParams); + + const first = await provider.cancelOrder({ + orderId: placed.orderId, + symbol: 'ETH', + orderType: 'scale', + }); + expect(first.success).toBe(false); + expect(first.error).toBe( + PERPS_ERROR_CODES.ORDER_STRATEGY_CANCEL_INCOMPLETE, + ); + + // The handle still resolves, and now covers only the rung left resting. + const second = await provider.cancelOrder({ + orderId: placed.orderId, + symbol: 'ETH', + orderType: 'scale', + }); + expect(second.success).toBe(true); + expect(exchangeClient.cancel).toHaveBeenLastCalledWith({ + cancels: [{ a: 1, o: 22 }], + }); + }); + + it('rejects a cancel for a group it does not hold', async () => { + useStrategyClients(); + + const result = await provider.cancelOrder({ + orderId: 'scale-does-not-exist', + symbol: 'ETH', + orderType: 'scale', + }); + + expect(result.success).toBe(false); + expect(result.error).toBe( + PERPS_ERROR_CODES.ORDER_STRATEGY_HANDLE_UNKNOWN, + ); + }); + }); + + describe('Chase placement', () => { + const chaseRested = { + status: 'ok', + response: { data: { statuses: [{ resting: { oid: 55 } }] } }, + }; + + it('rests post-only at the near touch for a buy', async () => { + const { exchangeClient } = useStrategyClients({ + exchange: { order: jest.fn().mockResolvedValue(chaseRested) }, + }); + + await provider.placeOrder({ + ...baseOrder, + orderType: 'chase', + } satisfies OrderParams); + + const submitted = exchangeClient.order.mock.calls[0][0]; + expect(submitted.orders).toHaveLength(1); + // One tick above the best bid, which is how the venue defines a chase: a + // post-only buy that improves the bid rather than joining the queue at it. + // ETH's tick at ~3000 is 0.1. + expect(submitted.orders[0].p).toBe('2999.1'); + expect(submitted.orders[0].t).toStrictEqual({ limit: { tif: 'Alo' } }); + }); + + it('refreshes the touch and retries an initial post-only rejection', async () => { + const order = jest + .fn() + .mockRejectedValueOnce( + new Error('Post only order would have immediately matched'), + ) + .mockResolvedValue(chaseRested); + const l2Book = jest + .fn() + .mockResolvedValueOnce({ + coin: 'ETH', + levels: [ + [{ px: '2999', sz: '10', n: 1 }], + [{ px: '3001', sz: '10', n: 1 }], + ], + }) + .mockResolvedValue({ + coin: 'ETH', + levels: [ + [{ px: '2998', sz: '10', n: 1 }], + [{ px: '3001', sz: '10', n: 1 }], + ], + }); + const { exchangeClient } = useStrategyClients({ + exchange: { order }, + info: { l2Book }, + }); + + const result = await provider.placeOrder({ + ...baseOrder, + orderType: 'chase', + } as OrderParams); + + expect(result.success).toBe(true); + expect(exchangeClient.order).toHaveBeenCalledTimes(2); + expect(exchangeClient.order.mock.calls[1][0].orders[0].p).toBe('2998.1'); + }); + + it('refreshes the touch and retries an initial oracle-distance rejection', async () => { + const order = jest + .fn() + .mockRejectedValueOnce(new Error('Price too far from oracle')) + .mockResolvedValue(chaseRested); + const l2Book = jest + .fn() + .mockResolvedValueOnce({ + coin: 'ETH', + levels: [ + [{ px: '2999', sz: '10', n: 1 }], + [{ px: '3001', sz: '10', n: 1 }], + ], + }) + .mockResolvedValue({ + coin: 'ETH', + levels: [ + [{ px: '2997', sz: '10', n: 1 }], + [{ px: '3001', sz: '10', n: 1 }], + ], + }); + const { exchangeClient } = useStrategyClients({ + exchange: { order }, + info: { l2Book }, + }); + + const result = await provider.placeOrder({ + ...baseOrder, + orderType: 'chase', + } as OrderParams); + + expect(result.success).toBe(true); + expect(exchangeClient.order).toHaveBeenCalledTimes(2); + expect(exchangeClient.order.mock.calls[1][0].orders[0].p).toBe('2997.1'); + }); + + it('stops after three retryable initial-placement rejections', async () => { + const order = jest + .fn() + .mockRejectedValue( + new Error('Post only order would have immediately matched'), + ); + const { exchangeClient } = useStrategyClients({ exchange: { order } }); + + const result = await provider.placeOrder({ + ...baseOrder, + orderType: 'chase', + } as OrderParams); + + expect(result.success).toBe(false); + expect(exchangeClient.order).toHaveBeenCalledTimes(3); + }); + + it('does not retry a non-retryable initial-placement rejection', async () => { + const order = jest + .fn() + .mockRejectedValue(new Error('insufficient margin')); + const { exchangeClient } = useStrategyClients({ exchange: { order } }); + + const result = await provider.placeOrder({ + ...baseOrder, + orderType: 'chase', + } as OrderParams); + + expect(result.success).toBe(false); + expect(exchangeClient.order).toHaveBeenCalledTimes(1); + }); + + it('rests at the best ask for a sell', async () => { + const { exchangeClient } = useStrategyClients({ + exchange: { order: jest.fn().mockResolvedValue(chaseRested) }, + }); + + await provider.placeOrder({ + ...baseOrder, + isBuy: false, + orderType: 'chase', + } satisfies OrderParams); + + const submitted = exchangeClient.order.mock.calls[0][0]; + // One tick below the best ask, mirroring the buy side. + expect(submitted.orders[0].p).toBe('3000.9'); + }); + + it('returns a session handle carrying the live order', async () => { + useStrategyClients({ + exchange: { order: jest.fn().mockResolvedValue(chaseRested) }, + }); + + const result = await provider.placeOrder({ + ...baseOrder, + orderType: 'chase', + } satisfies OrderParams); + + expect(result.success).toBe(true); + expect(result.orderId).toMatch(/^chase-/u); + expect(result.childOrderIds).toStrictEqual(['55']); + }); + + it('exposes the running session state needed by clients', async () => { + useStrategyClients({ + exchange: { order: jest.fn().mockResolvedValue(chaseRested) }, + }); + + const result = await provider.placeOrder({ + ...baseOrder, + orderType: 'chase', + chaseMaxDistanceBps: 100, + } as OrderParams); + + expect(await provider.getChaseOrders()).toStrictEqual([ + expect.objectContaining({ + handle: result.orderId, + symbol: 'ETH', + side: 'buy', + originalSize: '1', + remainingSize: '1', + arrivalPrice: '2999.1', + restingPrice: '2999.1', + restingOrderId: '55', + distanceChasedBps: 0, + maxDistanceBps: 100, + repricings: 0, + status: 'active', + }), + ]); + }); + + it('refreshes the snapshot remaining size after a partial fill', async () => { + const { infoClient } = useStrategyClients({ + exchange: { order: jest.fn().mockResolvedValue(chaseRested) }, + }); + const result = await provider.placeOrder({ + ...baseOrder, + orderType: 'chase', + } as OrderParams); + infoClient.orderStatus.mockResolvedValueOnce({ + status: 'order', + order: { + status: 'open', + order: { + coin: 'ETH', + side: 'B', + limitPx: '2999.1', + sz: '0.2', + origSz: '1', + oid: 55, + timestamp: 1_700_000_000_000, + isTrigger: false, + triggerCondition: 'N/A', + triggerPx: '0', + children: [], + isPositionTpsl: false, + reduceOnly: false, + orderType: 'Limit', + tif: 'Alo', + cloid: null, + }, + }, + }); + + const firstSnapshots = await provider.getChaseOrders(); + const cachedSnapshots = await provider.getChaseOrders(); + + expect(firstSnapshots).toContainEqual( + expect.objectContaining({ + handle: result.orderId, + remainingSize: '0.2', + }), + ); + expect(cachedSnapshots).toContainEqual( + expect.objectContaining({ + handle: result.orderId, + remainingSize: '0.2', + }), + ); + }); + + it('retries a temporarily unknown child before refreshing Chase state', async () => { + const { exchangeClient, infoClient } = useStrategyClients({ + exchange: { order: jest.fn().mockResolvedValue(chaseRested) }, + }); + const result = await provider.placeOrder({ + ...baseOrder, + orderType: 'chase', + } as OrderParams); + infoClient.orderStatus.mockResolvedValueOnce({ status: 'unknownOid' }); + + const snapshots = await provider.getChaseOrders(); + + expect(snapshots).toContainEqual( + expect.objectContaining({ + handle: result.orderId, + remainingSize: '1', + restingOrderId: '55', + status: 'active', + }), + ); + expect(infoClient.orderStatus).toHaveBeenCalledTimes(2); + + expect( + await provider.cancelOrder({ + orderId: result.orderId, + symbol: 'ETH', + orderType: 'chase', + }), + ).toStrictEqual({ success: true, orderId: result.orderId }); + expect(exchangeClient.cancel).toHaveBeenCalledWith({ + cancels: [{ a: 1, o: 55 }], + }); + }); + + it('marks a confirmed fill and stops the Chase timer', async () => { + jest.useFakeTimers(); + const order = jest.fn().mockResolvedValue(chaseRested); + const { exchangeClient, infoClient } = useStrategyClients({ + exchange: { order }, + }); + const result = await provider.placeOrder({ + ...baseOrder, + orderType: 'chase', + chaseIntervalMs: 1000, + } satisfies OrderParams); + infoClient.orderStatus.mockResolvedValueOnce({ + status: 'order', + order: { + status: 'filled', + order: { + coin: 'ETH', + side: 'B', + limitPx: '2999.1', + sz: '0', + origSz: '1', + oid: 55, + timestamp: 1_700_000_000_000, + isTrigger: false, + triggerCondition: 'N/A', + triggerPx: '0', + children: [], + isPositionTpsl: false, + reduceOnly: false, + orderType: 'Limit', + }, + }, + }); + + expect(await provider.getChaseOrders()).toContainEqual( + expect.objectContaining({ + handle: result.orderId, + remainingSize: '0', + restingOrderId: null, + status: 'filled', + }), + ); + + await jest.advanceTimersByTimeAsync(5000); + + expect(order).toHaveBeenCalledTimes(1); + expect(exchangeClient.cancel).not.toHaveBeenCalled(); + jest.useRealTimers(); + }); + + it('does not report an externally cancelled child as filled', async () => { + const { infoClient } = useStrategyClients({ + exchange: { order: jest.fn().mockResolvedValue(chaseRested) }, + }); + const result = await provider.placeOrder({ + ...baseOrder, + orderType: 'chase', + } satisfies OrderParams); + infoClient.orderStatus.mockResolvedValueOnce({ + status: 'order', + order: { + status: 'canceled', + order: { + coin: 'ETH', + side: 'B', + limitPx: '2999.1', + sz: '0.4', + origSz: '1', + oid: 55, + timestamp: 1_700_000_000_000, + isTrigger: false, + triggerCondition: 'N/A', + triggerPx: '0', + children: [], + isPositionTpsl: false, + reduceOnly: false, + orderType: 'Limit', + }, + }, + }); + + expect(await provider.getChaseOrders()).toContainEqual( + expect.objectContaining({ + handle: result.orderId, + remainingSize: '0.4', + restingOrderId: null, + status: CHASE_ORDER_STATUS.Canceled, + }), + ); + }); + + it('reports a canceled child with no remainder as filled', async () => { + const { infoClient } = useStrategyClients({ + exchange: { order: jest.fn().mockResolvedValue(chaseRested) }, + }); + const result = await provider.placeOrder({ + ...baseOrder, + orderType: 'chase', + } satisfies OrderParams); + infoClient.orderStatus.mockResolvedValueOnce({ + status: 'order', + order: { + status: 'canceled', + order: { + coin: 'ETH', + side: 'B', + limitPx: '2999.1', + sz: '0', + origSz: '1', + oid: 55, + timestamp: 1_700_000_000_000, + isTrigger: false, + triggerCondition: 'N/A', + triggerPx: '0', + children: [], + isPositionTpsl: false, + reduceOnly: false, + orderType: 'Limit', + }, + }, + }); + + expect(await provider.getChaseOrders()).toContainEqual( + expect.objectContaining({ + handle: result.orderId, + remainingSize: '0', + restingOrderId: null, + status: CHASE_ORDER_STATUS.Filled, + }), + ); + }); + + it('backgrounds every active session without cancelling its resting child', async () => { + const { exchangeClient } = useStrategyClients({ + exchange: { order: jest.fn().mockResolvedValue(chaseRested) }, + }); + + const result = await provider.placeOrder({ + ...baseOrder, + orderType: 'chase', + } as OrderParams); + + const backgrounded = await provider.suspendChaseOrders(); + + expect(backgrounded).toStrictEqual([ + expect.objectContaining({ + handle: result.orderId, + restingOrderId: '55', + status: 'backgrounded', + }), + ]); + expect(exchangeClient.cancel).not.toHaveBeenCalled(); + expect(await provider.getChaseOrders()).toStrictEqual(backgrounded); + }); + + it('retains HIP-3 collateral while a Chase child rests and retries cleanup', async () => { + let chasePlaced = false; + const clearinghouseState = jest.fn().mockImplementation(({ dex }) => { + let withdrawable = '1000'; + if (dex === 'xyz') { + withdrawable = chasePlaced ? '20' : '0'; + } + return Promise.resolve(createClearinghouseBalance(withdrawable)); + }); + useStrategyClients({ + exchange: { + order: jest.fn().mockImplementation(async () => { + chasePlaced = true; + return chaseRested; + }), + }, + info: { + clearinghouseState, + perpDexs: jest.fn().mockResolvedValue([null, { name: 'xyz' }]), + meta: jest.fn().mockResolvedValue({ + universe: [{ name: 'xyz:TSLA', szDecimals: 3, maxLeverage: 20 }], + collateralToken: 0, + }), + allMids: jest.fn().mockResolvedValue({ 'xyz:TSLA': '3000' }), + }, + }); + provider = createTestProvider({ + hip3Enabled: true, + allowlistMarkets: ['xyz:*'], + useUnifiedAccount: false, + initialAssetMapping: [['xyz:TSLA', 110000]], + }); + const transfer = jest + .spyOn(provider, 'transferBetweenDexs') + .mockResolvedValueOnce({ success: true }) + .mockResolvedValueOnce({ success: false, error: 'Transfer failed' }) + .mockResolvedValue({ success: true }); + + const placed = await provider.placeOrder({ + ...baseOrder, + symbol: 'xyz:TSLA', + orderType: 'chase', + } satisfies OrderParams); + + expect(placed.success).toBe(true); + expect(transfer).toHaveBeenCalledTimes(1); + + await provider.suspendChaseOrders(); + + expect(transfer).toHaveBeenCalledTimes(1); + await provider.cancelOrder({ + orderId: placed.orderId, + symbol: 'xyz:TSLA', + orderType: 'chase', + }); + expect(transfer).toHaveBeenCalledTimes(2); + await provider.getChaseOrders(); + expect(transfer).toHaveBeenCalledTimes(3); + await provider.getChaseOrders(); + expect(transfer).toHaveBeenCalledTimes(3); + }); + + it('reports a backgrounded child that later fills as filled', async () => { + jest.useFakeTimers(); + const { infoClient } = useStrategyClients({ + exchange: { order: jest.fn().mockResolvedValue(chaseRested) }, + }); + const result = await provider.placeOrder({ + ...baseOrder, + orderType: 'chase', + } satisfies OrderParams); + + await provider.suspendChaseOrders(); + infoClient.orderStatus.mockResolvedValueOnce({ + status: 'order', + order: { + status: 'filled', + order: { + coin: 'ETH', + side: 'B', + limitPx: '2999.1', + sz: '0', + origSz: '1', + oid: 55, + timestamp: 1_700_000_000_000, + isTrigger: false, + triggerCondition: 'N/A', + triggerPx: '0', + children: [], + isPositionTpsl: false, + reduceOnly: false, + orderType: 'Limit', + }, + }, + }); + await jest.advanceTimersByTimeAsync(CHASE_ORDER_CONFIG.DefaultIntervalMs); + + expect(await provider.getChaseOrders()).toContainEqual( + expect.objectContaining({ + handle: result.orderId, + remainingSize: '0', + restingOrderId: null, + status: 'filled', + }), + ); + jest.useRealTimers(); + }); + + it('fails when the book has no price on the side it must rest at', async () => { + useStrategyClients({ + exchange: { order: jest.fn().mockResolvedValue(chaseRested) }, + info: { + l2Book: jest + .fn() + .mockResolvedValue({ coin: 'ETH', levels: [[], []] }), + }, + }); + + const result = await provider.placeOrder({ + ...baseOrder, + orderType: 'chase', + } satisfies OrderParams); + + expect(result.success).toBe(false); + expect(result.error).toBe( + PERPS_ERROR_CODES.ORDER_CHASE_TOUCH_UNAVAILABLE, + ); + }); + + it('cancels the live order and stops the session', async () => { + const { exchangeClient } = useStrategyClients({ + exchange: { order: jest.fn().mockResolvedValue(chaseRested) }, + }); + + const placed = await provider.placeOrder({ + ...baseOrder, + orderType: 'chase', + } satisfies OrderParams); + + const result = await provider.cancelOrder({ + orderId: placed.orderId, + symbol: 'ETH', + orderType: 'chase', + }); + + expect(result.success).toBe(true); + expect(exchangeClient.cancel).toHaveBeenCalledWith({ + cancels: [{ a: 1, o: 55 }], + }); + + // Retrying a completed termination is idempotent. Mobile can receive a + // stale lifecycle snapshot while the first request is settling. + const second = await provider.cancelOrder({ + orderId: placed.orderId, + symbol: 'ETH', + orderType: 'chase', + }); + expect(second.success).toBe(true); + expect(exchangeClient.cancel).toHaveBeenCalledTimes(1); + }); + + it('deduplicates concurrent termination of the same Chase', async () => { + const cancel = jest.fn().mockResolvedValue({ + status: 'ok', + response: { data: { statuses: ['success'] } }, + }); + useStrategyClients({ + exchange: { order: jest.fn().mockResolvedValue(chaseRested), cancel }, + }); + const placed = await provider.placeOrder({ + ...baseOrder, + orderType: 'chase', + } as OrderParams); + + const first = provider.cancelOrder({ + orderId: placed.orderId, + symbol: 'ETH', + orderType: 'chase', + }); + const second = provider.cancelOrder({ + orderId: placed.orderId, + symbol: 'ETH', + orderType: 'chase', + }); + + const results = await Promise.all([first, second]); + + expect(results).toStrictEqual([ + { success: true, orderId: placed.orderId }, + { success: true, orderId: placed.orderId }, + ]); + expect(cancel).toHaveBeenCalledTimes(1); + }); + + it('stops the owning session when its child is cancelled directly', async () => { + useStrategyClients({ + exchange: { order: jest.fn().mockResolvedValue(chaseRested) }, + }); + + await provider.placeOrder({ + ...baseOrder, + orderType: 'chase', + } as OrderParams); + + expect( + await provider.cancelOrder({ orderId: '55', symbol: 'ETH' }), + ).toStrictEqual({ success: true, orderId: '55' }); + expect(await provider.getChaseOrders()).toStrictEqual([]); + }); + + it('returns an error result when child cancellation setup fails', async () => { + useStrategyClients({ + exchange: { order: jest.fn().mockResolvedValue(chaseRested) }, + }); + + await provider.placeOrder({ + ...baseOrder, + orderType: 'chase', + } as OrderParams); + mockWalletService.getUserAddressWithDefault.mockRejectedValueOnce( + new Error('Trading setup failed'), + ); + + const result = await provider.cancelOrder({ + orderId: '55', + symbol: 'ETH', + }); + + expect(result).toStrictEqual({ + success: false, + error: 'Trading setup failed', + orderId: '55', + }); + }); + + it('backgrounds an admitted placement while blocking newer placements', async () => { + let settleOrder: ((value: typeof chaseRested) => void) | undefined; + let notifyOrderStarted: (() => void) | undefined; + const orderStarted = new Promise((resolve) => { + notifyOrderStarted = resolve; + }); + const order = jest.fn().mockImplementation(async () => { + notifyOrderStarted?.(); + return await new Promise((resolve) => { + settleOrder = resolve; + }); + }); + useStrategyClients({ exchange: { order } }); + + const admitted = provider.placeOrder({ + ...baseOrder, + orderType: 'chase', + } as OrderParams); + await orderStarted; + const suspension = provider.suspendChaseOrders(); + const blocked = await provider.placeOrder({ + ...baseOrder, + orderType: 'chase', + } as OrderParams); + settleOrder?.(chaseRested); + + const admittedResult = await admitted; + const backgrounded = await suspension; + expect(blocked.success).toBe(false); + expect(blocked.error).toBe(PERPS_ERROR_CODES.ORDER_CHASE_ABANDONED); + expect(admittedResult.success).toBe(true); + expect(backgrounded).toStrictEqual([ + expect.objectContaining({ + handle: admittedResult.orderId, + restingOrderId: '55', + status: 'backgrounded', + }), + ]); + expect(await provider.getChaseOrders()).toStrictEqual(backgrounded); + expect(order).toHaveBeenCalledTimes(1); + }); + + it('reports an incomplete chase cancel and keeps the handle for a retry', async () => { + const cancel = jest + .fn() + .mockResolvedValueOnce({ + status: 'ok', + response: { data: { statuses: [{ error: 'multi-sig required' }] } }, + }) + .mockResolvedValue({ + status: 'ok', + response: { data: { statuses: ['success'] } }, + }); + useStrategyClients({ + exchange: { order: jest.fn().mockResolvedValue(chaseRested), cancel }, + }); + + const placed = await provider.placeOrder({ + ...baseOrder, + orderType: 'chase', + } satisfies OrderParams); + + const first = await provider.cancelOrder({ + orderId: placed.orderId, + symbol: 'ETH', + orderType: 'chase', + }); + expect(first.success).toBe(false); + expect(first.error).toBe( + PERPS_ERROR_CODES.ORDER_STRATEGY_CANCEL_INCOMPLETE, + ); + expect(await provider.getChaseOrders()).toStrictEqual([ + expect.objectContaining({ + handle: placed.orderId, + restingOrderId: '55', + status: 'termination_pending', + }), + ]); + + const second = await provider.cancelOrder({ + orderId: placed.orderId, + symbol: 'ETH', + orderType: 'chase', + }); + expect(second.success).toBe(true); + }); + + it('keeps the terminal reason when cancelling a stopped Chase fails', async () => { + jest.useFakeTimers(); + const cancel = jest.fn().mockResolvedValue({ + status: 'ok', + response: { data: { statuses: [{ error: 'multi-sig required' }] } }, + }); + useStrategyClients({ + exchange: { order: jest.fn().mockResolvedValue(chaseRested), cancel }, + }); + const placed = await provider.placeOrder({ + ...baseOrder, + orderType: 'chase', + chaseIntervalMs: 1000, + chaseMaxDurationMs: 1000, + } as OrderParams); + await jest.advanceTimersByTimeAsync(1000); + + const result = await provider.cancelOrder({ + orderId: placed.orderId, + symbol: 'ETH', + orderType: 'chase', + }); + + expect(result).toMatchObject({ + success: false, + error: PERPS_ERROR_CODES.ORDER_STRATEGY_CANCEL_INCOMPLETE, + }); + expect(await provider.getChaseOrders()).toStrictEqual([ + expect.objectContaining({ + handle: placed.orderId, + status: 'duration_reached', + }), + ]); + jest.useRealTimers(); + }); + + it('rejects a cancel for a session it does not hold', async () => { + useStrategyClients(); + + const result = await provider.cancelOrder({ + orderId: 'chase-does-not-exist', + symbol: 'ETH', + orderType: 'chase', + }); + + expect(result.success).toBe(false); + expect(result.error).toBe( + PERPS_ERROR_CODES.ORDER_STRATEGY_HANDLE_UNKNOWN, + ); + }); + }); + + describe('Placement validation', () => { + it('does not reach the exchange when shared validation rejects', async () => { + const { exchangeClient, infoClient } = useStrategyClients(); + mockValidateOrderParams.mockReturnValue({ + isValid: false, + error: PERPS_ERROR_CODES.ORDER_TWAP_DURATION_INVALID, + }); + + const result = await provider.placeOrder({ + ...baseOrder, + orderType: 'twap', + twapDuration: 30, + } satisfies OrderParams); + + expect(result.success).toBe(false); + expect(exchangeClient.order).not.toHaveBeenCalled(); + expect(exchangeClient.twapOrder).not.toHaveBeenCalled(); + expect(exchangeClient.updateLeverage).not.toHaveBeenCalled(); + expect(infoClient.l2Book).not.toHaveBeenCalled(); + }); + + it.each([ + ['twap', { orderType: 'twap', twapDuration: 30 }], + [ + 'scale', + { + orderType: 'scale', + scaleMinPrice: '2900', + scaleMaxPrice: '3100', + scaleNumOrders: 3, + }, + ], + ['chase', { orderType: 'chase' }], + ] as const)( + 'places a %s strategy on a HIP-3 market', + async (_, strategy) => { + provider = createTestProvider({ + hip3Enabled: true, + allowlistMarkets: ['xyz:*'], + initialAssetMapping: [['xyz:TSLA', 110000]], + }); + const { exchangeClient } = useStrategyClients({ + info: { + meta: jest.fn().mockResolvedValue({ + universe: [{ name: 'xyz:TSLA', szDecimals: 3, maxLeverage: 20 }], + collateralToken: 0, + }), + perpDexs: jest.fn().mockResolvedValue([null, { name: 'xyz' }]), + allMids: jest.fn().mockResolvedValue({ 'xyz:TSLA': '3000' }), + }, + }); + + const result = await provider.placeOrder({ + ...baseOrder, + ...strategy, + symbol: 'xyz:TSLA', + } satisfies OrderParams); + + expect(result.error).toBeUndefined(); + expect(result.success).toBe(true); + const isTwap = strategy.orderType === 'twap'; + expect(exchangeClient.twapOrder).toHaveBeenCalledTimes(isTwap ? 1 : 0); + expect(exchangeClient.order).toHaveBeenCalledTimes(isTwap ? 0 : 1); + + const assetIds = isTwap + ? exchangeClient.twapOrder.mock.calls.map( + ([request]) => request.twap.a, + ) + : exchangeClient.order.mock.calls.flatMap(([request]) => + request.orders.map((order) => order.a), + ); + expect(assetIds).toContain(110000); + }, + ); + }); + + describe('Existing order types are unaffected', () => { + it('still routes a market order through the order action', async () => { + const { exchangeClient } = useStrategyClients(); + + const result = await provider.placeOrder({ + ...baseOrder, + orderType: 'market', + } satisfies OrderParams); + + expect(result.success).toBe(true); + expect(exchangeClient.order).toHaveBeenCalledTimes(1); + expect(exchangeClient.twapOrder).not.toHaveBeenCalled(); + }); + + it('still routes a limit order through the order action', async () => { + const { exchangeClient } = useStrategyClients(); + + const result = await provider.placeOrder({ + ...baseOrder, + orderType: 'limit', + price: '2900', + } satisfies OrderParams); + + expect(result.success).toBe(true); + expect(exchangeClient.order).toHaveBeenCalledTimes(1); + expect(exchangeClient.twapOrder).not.toHaveBeenCalled(); + }); + }); + + describe('Chase re-pricing loop', () => { + /** + * A successful single-order response resting the given exchange ID. + * + * @param oid - Exchange order ID to report as resting. + * @returns The exchange response. + */ + const chaseRested = (oid: number): Record => ({ + status: 'ok', + response: { data: { statuses: [{ resting: { oid } }] } }, + }); + + beforeEach(() => { + jest.useFakeTimers(); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + /** + * Build an l2Book mock that walks through the supplied best bids. + * + * @param bids - Best bid for each successive read; the last repeats. + * @returns The mock. + */ + const bookWalkingBids = (bids: string[]): jest.Mock => { + let call = 0; + return jest.fn().mockImplementation(async () => { + const px = bids[Math.min(call, bids.length - 1)]; + call += 1; + return { + coin: 'ETH', + levels: [[{ px, sz: '10', n: 1 }], [{ px: '3001', sz: '10', n: 1 }]], + }; + }); + }; + + it('cancels and re-places when the touch moves', async () => { + const order = jest + .fn() + .mockResolvedValueOnce(chaseRested(55)) + .mockResolvedValue(chaseRested(66)); + const { exchangeClient } = useStrategyClients({ + exchange: { order }, + info: { l2Book: bookWalkingBids(['2999', '2998']) }, + }); + + await provider.placeOrder({ + ...baseOrder, + orderType: 'chase', + chaseIntervalMs: 1000, + } satisfies OrderParams); + + await jest.advanceTimersByTimeAsync(1000); + + expect(exchangeClient.cancel).toHaveBeenCalledWith({ + cancels: [{ a: 1, o: 55 }], + }); + expect(order).toHaveBeenCalledTimes(2); + expect(order.mock.calls[1][0].orders[0].p).toBe('2998.1'); + expect(order.mock.calls[1][0].orders[0].t).toStrictEqual({ + limit: { tif: 'Alo' }, + }); + }); + + it('does not replace a child cancelled directly during a reprice', async () => { + let settleCancel: ((value: Record) => void) | undefined; + let notifyCancelStarted: (() => void) | undefined; + const cancelStarted = new Promise((resolve) => { + notifyCancelStarted = resolve; + }); + const cancel = jest.fn().mockImplementation(async () => { + notifyCancelStarted?.(); + return await new Promise>((resolve) => { + settleCancel = resolve; + }); + }); + const order = jest + .fn() + .mockResolvedValueOnce(chaseRested(55)) + .mockResolvedValue(chaseRested(66)); + useStrategyClients({ + exchange: { order, cancel }, + info: { l2Book: bookWalkingBids(['2999', '2998']) }, + }); + + await provider.placeOrder({ + ...baseOrder, + orderType: 'chase', + chaseIntervalMs: 1000, + } as OrderParams); + const ticking = jest.advanceTimersByTimeAsync(1000); + await cancelStarted; + const directCancellation = provider.cancelOrder({ + orderId: '55', + symbol: 'ETH', + }); + settleCancel?.({ + status: 'ok', + response: { data: { statuses: ['success'] } }, + }); + + await ticking; + expect(await directCancellation).toMatchObject({ success: true }); + expect(order).toHaveBeenCalledTimes(1); + expect(cancel).toHaveBeenCalledTimes(1); + expect(await provider.getChaseOrders()).toStrictEqual([]); + }); + + it('does not replace a Chase child cancelled in a batch during a reprice', async () => { + let settleCancel: ((value: Record) => void) | undefined; + let notifyCancelStarted: (() => void) | undefined; + const cancelStarted = new Promise((resolve) => { + notifyCancelStarted = resolve; + }); + const cancel = jest.fn().mockImplementation(async () => { + notifyCancelStarted?.(); + return await new Promise>((resolve) => { + settleCancel = resolve; + }); + }); + const order = jest + .fn() + .mockResolvedValueOnce(chaseRested(55)) + .mockResolvedValue(chaseRested(66)); + useStrategyClients({ + exchange: { order, cancel }, + info: { l2Book: bookWalkingBids(['2999', '2998']) }, + }); + + await provider.placeOrder({ + ...baseOrder, + orderType: 'chase', + chaseIntervalMs: 1000, + } as OrderParams); + const ticking = jest.advanceTimersByTimeAsync(1000); + await cancelStarted; + const batchCancellation = provider.cancelOrders([ + { orderId: '55', symbol: 'ETH' }, + ]); + settleCancel?.({ + status: 'ok', + response: { data: { statuses: ['success'] } }, + }); + + await ticking; + expect(await batchCancellation).toStrictEqual({ + success: true, + successCount: 1, + failureCount: 0, + results: [{ orderId: '55', symbol: 'ETH', success: true }], + }); + expect(order).toHaveBeenCalledTimes(1); + expect(cancel).toHaveBeenCalledTimes(1); + expect(await provider.getChaseOrders()).toStrictEqual([]); + }); + + it('finishes an admitted reprice before suspending its replacement', async () => { + let settleCancel: ((value: Record) => void) | undefined; + let notifyCancelStarted: (() => void) | undefined; + const cancelStarted = new Promise((resolve) => { + notifyCancelStarted = resolve; + }); + const cancel = jest.fn().mockImplementation(async () => { + notifyCancelStarted?.(); + return await new Promise>((resolve) => { + settleCancel = resolve; + }); + }); + const order = jest + .fn() + .mockResolvedValueOnce(chaseRested(55)) + .mockResolvedValue(chaseRested(66)); + useStrategyClients({ + exchange: { order, cancel }, + info: { l2Book: bookWalkingBids(['2999', '2998']) }, + }); + + const placed = await provider.placeOrder({ + ...baseOrder, + orderType: 'chase', + chaseIntervalMs: 1000, + } as OrderParams); + const ticking = jest.advanceTimersByTimeAsync(1000); + await cancelStarted; + + const suspension = provider.suspendChaseOrders(); + settleCancel?.({ + status: 'ok', + response: { data: { statuses: ['success'] } }, + }); + + await ticking; + expect(await suspension).toStrictEqual([ + expect.objectContaining({ + handle: placed.orderId, + restingOrderId: '66', + status: 'backgrounded', + }), + ]); + expect(order).toHaveBeenCalledTimes(2); + expect(cancel).toHaveBeenCalledTimes(1); + }); + + it('leaves the order alone while the touch holds still', async () => { + const order = jest.fn().mockResolvedValue(chaseRested(55)); + const { exchangeClient } = useStrategyClients({ + exchange: { order }, + info: { l2Book: bookWalkingBids(['2999']) }, + }); + + await provider.placeOrder({ + ...baseOrder, + orderType: 'chase', + chaseIntervalMs: 1000, + } satisfies OrderParams); + + await jest.advanceTimersByTimeAsync(3000); + + expect(exchangeClient.cancel).not.toHaveBeenCalled(); + expect(order).toHaveBeenCalledTimes(1); + }); + + it('uses the throttled default interval when none is supplied', async () => { + const order = jest + .fn() + .mockResolvedValueOnce(chaseRested(55)) + .mockResolvedValue(chaseRested(66)); + const { exchangeClient } = useStrategyClients({ + exchange: { order }, + info: { l2Book: bookWalkingBids(['2999', '2998']) }, + }); + + await provider.placeOrder({ + ...baseOrder, + orderType: 'chase', + } as OrderParams); + + await jest.advanceTimersByTimeAsync( + CHASE_ORDER_CONFIG.DefaultIntervalMs - 1, + ); + expect(exchangeClient.cancel).not.toHaveBeenCalled(); + + await jest.advanceTimersByTimeAsync(1); + expect(exchangeClient.cancel).toHaveBeenCalledTimes(1); + expect(order).toHaveBeenCalledTimes(2); + }); + + it('defers re-pricing while another Chase placement is in flight', async () => { + const order = jest + .fn() + .mockResolvedValueOnce(chaseRested(55)) + .mockResolvedValueOnce(chaseRested(66)); + const { exchangeClient, infoClient } = useStrategyClients({ + exchange: { order }, + info: { l2Book: bookWalkingBids(['2999', '2998', '2998']) }, + }); + + await provider.placeOrder({ + ...baseOrder, + orderType: 'chase', + chaseIntervalMs: 1000, + } as OrderParams); + + let finishBookRead: (() => void) | undefined; + infoClient.l2Book.mockImplementationOnce( + (): Promise> => + new Promise>((resolve) => { + finishBookRead = (): void => + resolve({ + coin: 'ETH', + levels: [ + [{ px: '2998', sz: '10', n: 1 }], + [{ px: '3001', sz: '10', n: 1 }], + ], + }); + }), + ); + const secondPlacement = provider.placeOrder({ + ...baseOrder, + orderType: 'chase', + chaseIntervalMs: 1000, + } as OrderParams); + await jest.advanceTimersByTimeAsync(0); + for (let turn = 0; turn < 20; turn += 1) { + if (finishBookRead !== undefined) { + break; + } + await Promise.resolve(); + } + expect(finishBookRead).toBeDefined(); + + await jest.advanceTimersByTimeAsync(1000); + + expect(exchangeClient.cancel).not.toHaveBeenCalled(); + expect(order).toHaveBeenCalledTimes(1); + + finishBookRead?.(); + expect(await secondPlacement).toMatchObject({ success: true }); + expect(order).toHaveBeenCalledTimes(2); + }); + + it('drains an in-flight re-price before starting another Chase placement', async () => { + const order = jest + .fn() + .mockResolvedValueOnce(chaseRested(55)) + .mockResolvedValueOnce(chaseRested(66)) + .mockResolvedValueOnce(chaseRested(77)); + const { infoClient } = useStrategyClients({ + exchange: { order }, + info: { l2Book: bookWalkingBids(['2999']) }, + }); + + await provider.placeOrder({ + ...baseOrder, + orderType: 'chase', + chaseIntervalMs: 1000, + } as OrderParams); + + let finishTickBookRead: (() => void) | undefined; + infoClient.l2Book.mockImplementationOnce( + (): Promise> => + new Promise>((resolve) => { + finishTickBookRead = (): void => + resolve({ + coin: 'ETH', + levels: [ + [{ px: '2998', sz: '10', n: 1 }], + [{ px: '3001', sz: '10', n: 1 }], + ], + }); + }), + ); + await jest.advanceTimersByTimeAsync(1000); + expect(finishTickBookRead).toBeDefined(); + + const secondPlacement = provider.placeOrder({ + ...baseOrder, + orderType: 'chase', + chaseIntervalMs: 1000, + } as OrderParams); + await jest.advanceTimersByTimeAsync(0); + + expect(order).toHaveBeenCalledTimes(1); + + finishTickBookRead?.(); + expect(await secondPlacement).toMatchObject({ success: true }); + expect(order).toHaveBeenCalledTimes(3); + }); + + it('stops chasing once the order is no longer resting', async () => { + const order = jest.fn().mockResolvedValue(chaseRested(55)); + const { exchangeClient, infoClient } = useStrategyClients({ + exchange: { + order, + // A cancel the exchange refuses means the order already left the book. + cancel: jest.fn().mockResolvedValue({ + status: 'ok', + response: { + data: { statuses: [{ error: 'Order was never placed' }] }, + }, + }), + }, + info: { + l2Book: jest + .fn() + .mockResolvedValueOnce({ + coin: 'ETH', + levels: [ + [{ px: '2999', sz: '10', n: 1 }], + [{ px: '3001', sz: '10', n: 1 }], + ], + }) + .mockResolvedValue({ + coin: 'ETH', + levels: [ + [ + { px: '2999.1', sz: '10', n: 1 }, + { px: '2998', sz: '10', n: 1 }, + ], + [{ px: '3001', sz: '10', n: 1 }], + ], + }), + orderStatus: jest.fn().mockResolvedValue({ status: 'unknownOid' }), + }, + }); + + await provider.placeOrder({ + ...baseOrder, + orderType: 'chase', + chaseIntervalMs: 1000, + } satisfies OrderParams); + + await jest.advanceTimersByTimeAsync(5000); + + expect(exchangeClient.cancel).toHaveBeenCalledTimes(1); + // No replacement was rested after the failed cancel. + expect(order).toHaveBeenCalledTimes(1); + expect(infoClient.orderStatus).toHaveBeenCalledTimes(3); + expect(await provider.getChaseOrders()).toContainEqual( + expect.objectContaining({ + restingOrderId: null, + status: CHASE_ORDER_STATUS.Failed, + }), + ); + }); + + it('stops re-pricing at the repricing cap', async () => { + const order = jest.fn().mockResolvedValue(chaseRested(55)); + useStrategyClients({ + exchange: { order }, + info: { l2Book: bookWalkingBids(['2999', '2998', '2997', '2996']) }, + }); + + await provider.placeOrder({ + ...baseOrder, + orderType: 'chase', + chaseIntervalMs: 1000, + chaseMaxRepricings: 1, + } satisfies OrderParams); + + await jest.advanceTimersByTimeAsync(5000); + + // One initial placement plus exactly one re-price. + expect(order).toHaveBeenCalledTimes(2); + }); + + it('stops re-pricing once the window closes', async () => { + const order = jest.fn().mockResolvedValue(chaseRested(55)); + useStrategyClients({ + exchange: { order }, + info: { l2Book: bookWalkingBids(['2999', '2998', '2997', '2996']) }, + }); + + await provider.placeOrder({ + ...baseOrder, + orderType: 'chase', + chaseIntervalMs: 1000, + chaseMaxDurationMs: 2000, + } satisfies OrderParams); + + await jest.advanceTimersByTimeAsync(10_000); + + // Ticks at 1s and 2s; the 2s tick finds the deadline reached and stops, + // so only the first one re-prices. + expect(order).toHaveBeenCalledTimes(2); + }); + + it('rests at the configured max-distance boundary and stops chasing', async () => { + const onChaseOrderMaxDistanceReached = jest.fn(); + provider = createTestProvider({ onChaseOrderMaxDistanceReached }); + const order = jest + .fn() + .mockResolvedValueOnce(chaseRested(55)) + .mockResolvedValue(chaseRested(66)); + useStrategyClients({ + exchange: { order }, + info: { l2Book: bookWalkingBids(['2999', '3040']) }, + }); + + const placed = await provider.placeOrder({ + ...baseOrder, + orderType: 'chase', + chaseIntervalMs: 1000, + chaseMaxDistanceBps: 100, + } satisfies OrderParams); + + await jest.advanceTimersByTimeAsync(5000); + + expect(order).toHaveBeenCalledTimes(2); + expect(order.mock.calls[1][0].orders[0].p).toBe('3029'); + expect(await provider.getChaseOrders()).toStrictEqual([ + expect.objectContaining({ + handle: placed.orderId, + restingOrderId: '66', + restingPrice: '3029', + distanceChasedBps: 100, + status: 'max_distance_reached', + }), + ]); + expect(onChaseOrderMaxDistanceReached).toHaveBeenCalledWith({ + handle: placed.orderId, + symbol: 'ETH', + side: 'buy', + restingOrderId: '66', + restingPrice: '3029', + maxDistanceBps: 100, + timestamp: expect.any(Number), + providerId: 'hyperliquid', + }); + }); + + it('keeps chasing when the touch moves favorably beyond max distance', async () => { + const order = jest + .fn() + .mockResolvedValueOnce(chaseRested(55)) + .mockResolvedValue(chaseRested(66)); + useStrategyClients({ + exchange: { order }, + info: { l2Book: bookWalkingBids(['2999', '2900']) }, + }); + + await provider.placeOrder({ + ...baseOrder, + orderType: 'chase', + chaseIntervalMs: 1000, + chaseMaxDistanceBps: 100, + } as OrderParams); + + await jest.advanceTimersByTimeAsync(1000); + + expect(order).toHaveBeenCalledTimes(2); + expect(await provider.getChaseOrders()).toStrictEqual([ + expect.objectContaining({ + status: 'active', + restingPrice: '2900.1', + distanceChasedBps: 0, + }), + ]); + }); + + it('stops every running chase on disconnect', async () => { + const order = jest.fn().mockResolvedValue(chaseRested(55)); + const { exchangeClient } = useStrategyClients({ + exchange: { order }, + info: { l2Book: bookWalkingBids(['2999', '2998', '2997']) }, + }); + + await provider.placeOrder({ + ...baseOrder, + orderType: 'chase', + chaseIntervalMs: 1000, + } satisfies OrderParams); + + await provider.disconnect(); + await jest.advanceTimersByTimeAsync(5000); + + // Disconnecting stops the loop; it does not cancel what is already resting. + expect(order).toHaveBeenCalledTimes(1); + expect(exchangeClient.cancel).not.toHaveBeenCalled(); + }); + }); + + describe('Scale ladder is not atomic', () => { + // grouping 'na' evaluates each entry independently, so the venue can rest + // some rungs and reject others in the same response. + const partlyRested = { + status: 'ok', + response: { + data: { + statuses: [ + { resting: { oid: 11 } }, + { error: 'Insufficient margin' }, + { resting: { oid: 33 } }, + ], + }, + }, + }; + + it('retracts every rung when the ladder only partly rests', async () => { + const { exchangeClient } = useStrategyClients({ + exchange: { + order: jest.fn().mockResolvedValue(partlyRested), + cancel: jest.fn().mockResolvedValue({ + status: 'ok', + response: { data: { statuses: ['success', 'success'] } }, + }), + }, + }); + + const result = await provider.placeOrder({ + ...baseOrder, + orderType: 'scale', + scaleMinPrice: '2000', + scaleMaxPrice: '3000', + scaleNumOrders: 3, + } satisfies OrderParams); + + expect(result).toMatchObject({ + success: false, + error: PERPS_ERROR_CODES.ORDER_REJECTED, + }); + expect(exchangeClient.cancel).toHaveBeenCalledWith({ + cancels: [ + { a: 1, o: 11 }, + { a: 1, o: 33 }, + ], + }); + }); + + it('reports submitted exposure when a partial ladder fills a rung', async () => { + const { exchangeClient } = useStrategyClients({ + exchange: { + order: jest.fn().mockResolvedValue({ + status: 'ok', + response: { + data: { + statuses: [ + { filled: { oid: 11 } }, + { error: 'Insufficient margin' }, + { resting: { oid: 33 } }, + ], + }, + }, + }), + cancel: jest.fn().mockResolvedValue({ + status: 'ok', + response: { data: { statuses: ['success'] } }, + }), + }, + }); + + const result = await provider.placeOrder({ + ...baseOrder, + orderType: 'scale', + scaleMinPrice: '2000', + scaleMaxPrice: '3000', + scaleNumOrders: 3, + } satisfies OrderParams); + + expect(result).toMatchObject({ + success: false, + error: PERPS_ERROR_CODES.ORDER_REJECTED, + childOrderIds: ['11'], + submittedSize: '1', + }); + expect(exchangeClient.cancel).toHaveBeenCalledWith({ + cancels: [{ a: 1, o: 33 }], + }); + }); + + it('keeps an incomplete cleanup recoverable by its group handle', async () => { + const cancel = jest + .fn() + .mockResolvedValueOnce({ + status: 'ok', + response: { + data: { statuses: [{ error: 'Invalid nonce' }, 'success'] }, + }, + }) + .mockResolvedValueOnce({ + status: 'ok', + response: { data: { statuses: ['success'] } }, + }); + const { exchangeClient } = useStrategyClients({ + exchange: { + order: jest.fn().mockResolvedValue(partlyRested), + cancel, + }, + }); + + const placed = await provider.placeOrder({ + ...baseOrder, + orderType: 'scale', + scaleMinPrice: '2000', + scaleMaxPrice: '3000', + scaleNumOrders: 3, + } satisfies OrderParams); + + expect(placed).toMatchObject({ + success: false, + error: PERPS_ERROR_CODES.ORDER_STRATEGY_CANCEL_INCOMPLETE, + childOrderIds: ['11'], + submittedSize: '1', + }); + + const retried = await provider.cancelOrder({ + orderId: placed.orderId, + symbol: 'ETH', + orderType: 'scale', + }); + + expect(retried.success).toBe(true); + expect(exchangeClient.cancel).toHaveBeenNthCalledWith(1, { + cancels: [ + { a: 1, o: 11 }, + { a: 1, o: 33 }, + ], + }); + expect(exchangeClient.cancel).toHaveBeenNthCalledWith(2, { + cancels: [{ a: 1, o: 11 }], + }); + }); + + it('accepts filled rungs but exposes only resting rungs for cancellation', async () => { + const { exchangeClient } = useStrategyClients({ + exchange: { + order: jest.fn().mockResolvedValue({ + status: 'ok', + response: { + data: { + statuses: [ + { resting: { oid: 11 } }, + { filled: { oid: 22 } }, + { resting: { oid: 33 } }, + ], + }, + }, + }), + cancel: jest.fn().mockResolvedValue({ + status: 'ok', + response: { data: { statuses: ['success', 'success'] } }, + }), + }, + }); + + const placed = await provider.placeOrder({ + ...baseOrder, + orderType: 'scale', + scaleMinPrice: '2000', + scaleMaxPrice: '3000', + scaleNumOrders: 3, + } satisfies OrderParams); + + expect(placed).toMatchObject({ + success: true, + childOrderIds: ['11', '33'], + submittedSize: '1', + }); + + const cancelled = await provider.cancelOrder({ + orderId: placed.orderId, + symbol: 'ETH', + orderType: 'scale', + }); + + expect(cancelled.success).toBe(true); + expect(exchangeClient.cancel).toHaveBeenCalledWith({ + cancels: [ + { a: 1, o: 11 }, + { a: 1, o: 33 }, + ], + }); + }); + + it.each([ + ['negative', -1], + ['fractional', 22.5], + ['unsafe', Number.MAX_SAFE_INTEGER + 1], + ['non-numeric', '22'], + ])( + 'rejects a %s scale order ID and retracts valid rungs', + async (_label, oid) => { + const { exchangeClient } = useStrategyClients({ + exchange: { + order: jest.fn().mockResolvedValue({ + status: 'ok', + response: { + data: { + statuses: [ + { resting: { oid: 11 } }, + { resting: { oid } }, + { error: 'Insufficient margin' }, + ], + }, + }, + }), + }, + }); + + const placed = await provider.placeOrder({ + ...baseOrder, + orderType: 'scale', + scaleMinPrice: '2000', + scaleMaxPrice: '3000', + scaleNumOrders: 3, + } satisfies OrderParams); + + expect(placed).toMatchObject({ + success: false, + error: PERPS_ERROR_CODES.ORDER_REJECTED, + }); + expect(exchangeClient.cancel).toHaveBeenCalledWith({ + cancels: [{ a: 1, o: 11 }], + }); + }, + ); + }); + + describe('Chase cancel racing a re-pricing tick', () => { + beforeEach(() => { + jest.useFakeTimers(); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + it('rests nothing when a cancel lands between the tick cancelling and re-placing', async () => { + const order = jest + .fn() + .mockResolvedValueOnce({ + status: 'ok', + response: { data: { statuses: [{ resting: { oid: 55 } }] } }, + }) + .mockResolvedValue({ + status: 'ok', + response: { data: { statuses: [{ resting: { oid: 66 } }] } }, + }); + + let sessionId = ''; + let callerCancel: Promise | undefined; + let cancelCalls = 0; + const cancel = jest.fn().mockImplementation(async () => { + cancelCalls += 1; + // Call 1 is the tick's own cancel: the old order is gone and its + // replacement has not been rested yet. That is precisely the window the + // guard closes, so the caller's cancel is fired from inside it. + // `cancelOrder` stops the session synchronously, before its first await. + if (cancelCalls === 1) { + callerCancel = provider.cancelOrder({ + orderId: sessionId, + symbol: 'ETH', + orderType: 'chase', + }); + } + return { + status: 'ok', + response: { data: { statuses: ['success'] } }, + }; + }); + + useStrategyClients({ + exchange: { order, cancel }, + info: { + l2Book: jest + .fn() + .mockResolvedValueOnce({ + coin: 'ETH', + levels: [ + [{ px: '2999', sz: '10', n: 1 }], + [{ px: '3001', sz: '10', n: 1 }], + ], + }) + .mockResolvedValue({ + coin: 'ETH', + levels: [ + [{ px: '2998', sz: '10', n: 1 }], + [{ px: '3001', sz: '10', n: 1 }], + ], + }), + }, + }); + + const placed = await provider.placeOrder({ + ...baseOrder, + orderType: 'chase', + chaseIntervalMs: 1000, + } satisfies OrderParams); + sessionId = placed.orderId as string; + + await jest.advanceTimersByTimeAsync(1000); + await callerCancel; + + // Only the original placement. Without the guard the tick would rest a + // replacement the caller has no handle for and no idea exists. + expect(order).toHaveBeenCalledTimes(1); + }); + + it('keeps the live child reachable when the tick cancel is refused', async () => { + let sessionId = ''; + let callerCancel: Promise | undefined; + let releasePublicCancel: (() => void) | undefined; + let cancelCalls = 0; + const cancel = jest.fn().mockImplementation(async () => { + cancelCalls += 1; + if (cancelCalls === 1) { + mockWalletService.getUserAddressWithDefault.mockReturnValueOnce( + new Promise((resolve) => { + releasePublicCancel = (): void => + resolve('0x1234567890123456789012345678901234567890'); + }), + ); + callerCancel = provider.cancelOrder({ + orderId: sessionId, + symbol: 'ETH', + orderType: 'chase', + }); + await Promise.resolve(); + return { + status: 'ok', + response: { + data: { statuses: [{ error: 'multi-sig required' }] }, + }, + }; + } + + return cancelCalls === 2 + ? { + status: 'ok', + response: { + data: { statuses: [{ error: 'multi-sig required' }] }, + }, + } + : { + status: 'ok', + response: { data: { statuses: ['success'] } }, + }; + }); + + useStrategyClients({ + exchange: { + order: jest.fn().mockResolvedValue({ + status: 'ok', + response: { data: { statuses: [{ resting: { oid: 55 } }] } }, + }), + cancel, + }, + info: { + l2Book: jest + .fn() + .mockResolvedValueOnce({ + coin: 'ETH', + levels: [ + [{ px: '2999', sz: '10', n: 1 }], + [{ px: '3001', sz: '10', n: 1 }], + ], + }) + .mockResolvedValue({ + coin: 'ETH', + levels: [ + [{ px: '2998', sz: '10', n: 1 }], + [{ px: '3001', sz: '10', n: 1 }], + ], + }), + }, + }); + + const placed = await provider.placeOrder({ + ...baseOrder, + orderType: 'chase', + chaseIntervalMs: 1000, + } satisfies OrderParams); + sessionId = placed.orderId as string; + + await jest.advanceTimersByTimeAsync(1000); + releasePublicCancel?.(); + + const first = await callerCancel; + expect(first.error).toBe( + PERPS_ERROR_CODES.ORDER_STRATEGY_CANCEL_INCOMPLETE, + ); + + const retry = await provider.cancelOrder({ + orderId: placed.orderId, + symbol: 'ETH', + orderType: 'chase', + }); + expect(retry.success).toBe(true); + expect(cancel).toHaveBeenLastCalledWith({ + cancels: [{ a: 1, o: 55 }], + }); + }); + }); + + describe('Chase cancel racing the replacement placement', () => { + beforeEach(() => { + jest.useFakeTimers(); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + it('cancels the replacement when the cancel lands during its round trip', async () => { + let sessionId = ''; + let callerCancel: Promise | undefined; + let orderCalls = 0; + + const order = jest.fn().mockImplementation(async () => { + orderCalls += 1; + // Call 2 is the tick's replacement. Firing the caller's cancel from + // inside its round trip is the window this closes: `session.orderId` is + // null for the whole call, which without the fix reads as "nothing + // rests" and lets the cancel report success and drop the handle while + // this order is still on its way to the book. + if (orderCalls === 2) { + callerCancel = provider.cancelOrder({ + orderId: sessionId, + symbol: 'ETH', + orderType: 'chase', + }); + await Promise.resolve(); + } + return { + status: 'ok', + response: { + data: { + statuses: [{ resting: { oid: orderCalls === 1 ? 55 : 66 } }], + }, + }, + }; + }); + + const { exchangeClient } = useStrategyClients({ + exchange: { order }, + info: { + l2Book: jest + .fn() + .mockResolvedValueOnce({ + coin: 'ETH', + levels: [ + [{ px: '2999', sz: '10', n: 1 }], + [{ px: '3001', sz: '10', n: 1 }], + ], + }) + .mockResolvedValue({ + coin: 'ETH', + levels: [ + [{ px: '2998', sz: '10', n: 1 }], + [{ px: '3001', sz: '10', n: 1 }], + ], + }), + }, + }); + + const placed = await provider.placeOrder({ + ...baseOrder, + orderType: 'chase', + chaseIntervalMs: 1000, + } satisfies OrderParams); + sessionId = placed.orderId as string; + + await jest.advanceTimersByTimeAsync(1000); + const result = await callerCancel; + + // The replacement must not be left live on the exchange with the caller + // told the chase was cancelled: the cancel waits for it and cancels it. + expect(result?.success).toBe(true); + expect(exchangeClient.cancel).toHaveBeenCalledWith({ + cancels: [{ a: 1, o: 66 }], + }); + + // And the handle is gone, so nothing of this chase is left unreachable. + const second = await provider.cancelOrder({ + orderId: sessionId, + symbol: 'ETH', + orderType: 'chase', + }); + expect(second).toStrictEqual({ success: true, orderId: placed.orderId }); + }); + }); + + describe('Fee quoting for strategy placements', () => { + const configuredOrderTypes = [ + ['market', BUILDER_FEE_CONFIG.MaxFeeDecimal], + ['limit', BUILDER_FEE_CONFIG.MaxFeeDecimal], + ['stop_market', BUILDER_FEE_CONFIG.MaxFeeDecimal], + ['stop_limit', BUILDER_FEE_CONFIG.MaxFeeDecimal], + ['take_profit_market', BUILDER_FEE_CONFIG.MaxFeeDecimal], + ['take_profit_limit', BUILDER_FEE_CONFIG.MaxFeeDecimal], + ['twap', 0], + ['scale', BUILDER_FEE_CONFIG.MaxFeeDecimal], + ['chase', BUILDER_FEE_CONFIG.MaxFeeDecimal], + ] as const satisfies readonly (readonly [OrderType, number])[]; + + it.each(configuredOrderTypes)( + 'quotes %s with its provider-owned builder fee policy', + async (orderType, expectedMetamaskFeeRate) => { + useStrategyClients(); + + const fees = await provider.calculateFees({ + orderType, + amount: '1000', + symbol: 'ETH', + }); + + expect(fees.metamaskFeeRate).toBe(expectedMetamaskFeeRate); + }, + ); + + it.each(['future_order', 'constructor', 'toString', '__proto__'] as const)( + 'uses the safe builder fee for unknown runtime order type %s', + async (orderType) => { + useStrategyClients(); + const runtimeParams = { + orderType: 'market', + amount: '1000', + symbol: 'ETH', + } satisfies FeeCalculationParams; + Object.defineProperty(runtimeParams, 'orderType', { value: orderType }); + + const fees = await provider.calculateFees(runtimeParams); + + expect(fees.metamaskFeeRate).toBe(BUILDER_FEE_CONFIG.MaxFeeDecimal); + expect(mockPlatformDependencies.debugLogger.log).toHaveBeenCalledWith( + 'HyperLiquid: Unknown order type used the safe builder-fee policy', + { orderType }, + ); + }, + ); + + it('keeps a discounted TWAP quote at zero MetaMask builder fee', async () => { + useStrategyClients(); + provider.setUserFeeDiscount(5000); + + const fees = await provider.calculateFees({ + orderType: 'twap', + amount: '1000', + symbol: 'ETH', + }); + + expect(fees.metamaskFeeRate).toBe(0); + expect(fees.metamaskFeeAmount).toBe(0); + }); + + it('returns zero fee amounts for a zero notional quote', async () => { + useStrategyClients(); + + const fees = await provider.calculateFees({ + orderType: 'market', + amount: '0', + symbol: 'ETH', + }); + + expect(fees.feeAmount).toBe(0); + expect(fees.protocolFeeAmount).toBe(0); + expect(fees.metamaskFeeAmount).toBe(0); + }); + + it('quotes a chase at the maker rate even when isMaker is false', async () => { + useStrategyClients(); + + const chase = await provider.calculateFees({ + orderType: 'chase', + isMaker: false, + amount: '1000', + symbol: 'ETH', + }); + const market = await provider.calculateFees({ + orderType: 'market', + isMaker: false, + amount: '1000', + symbol: 'ETH', + }); + + // A chase is post-only, so it can only ever fill as a maker. + expect(chase.feeRate).toBeLessThan(market.feeRate); + expect(chase.metamaskFeeRate).toBe(BUILDER_FEE_CONFIG.MaxFeeDecimal); + }); + + it('quotes a resting scale ladder at the maker rate', async () => { + useStrategyClients(); + + const scale = await provider.calculateFees({ + orderType: 'scale', + isMaker: true, + amount: '1000', + symbol: 'ETH', + }); + const limit = await provider.calculateFees({ + orderType: 'limit', + isMaker: true, + amount: '1000', + symbol: 'ETH', + }); + + expect(scale.feeRate).toBe(limit.feeRate); + expect(scale.metamaskFeeRate).toBe(BUILDER_FEE_CONFIG.MaxFeeDecimal); + }); + + it('quotes a TWAP at the taker protocol rate without a builder fee', async () => { + useStrategyClients(); + + const twap = await provider.calculateFees({ + orderType: 'twap', + isMaker: true, + amount: '1000', + symbol: 'ETH', + }); + const market = await provider.calculateFees({ + orderType: 'market', + isMaker: false, + amount: '1000', + symbol: 'ETH', + }); + + expect(twap.protocolFeeRate).toBe(market.protocolFeeRate); + expect(twap.metamaskFeeRate).toBe(0); + expect(twap.metamaskFeeAmount).toBe(0); + expect(twap.feeRate).toBe(twap.protocolFeeRate); + expect(twap.feeAmount).toBe(twap.protocolFeeAmount); + expect(market.metamaskFeeRate).toBe(BUILDER_FEE_CONFIG.MaxFeeDecimal); + }); + }); + + describe('Order capabilities', () => { + beforeEach(() => { + jest.useFakeTimers(); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + it('advertises the strategies supported for a routed market', async () => { + useStrategyClients(); + + const capabilities = await provider.getOrderCapabilities({ + symbol: 'ETH', + }); + + expect(capabilities).toStrictEqual({ + status: 'ready', + providerId: 'hyperliquid', + supportedStrategies: ['twap', 'scale', 'chase'], + }); + expect(Object.isFrozen(capabilities)).toBe(true); + expect(Object.isFrozen(capabilities.supportedStrategies)).toBe(true); + }); + + it('resolves support before the asset mapping is populated', async () => { + useStrategyClients(); + const coldProvider = createTestProvider(); + + expect( + await coldProvider.getOrderCapabilities({ symbol: 'ETH' }), + ).toStrictEqual({ + status: 'ready', + providerId: 'hyperliquid', + supportedStrategies: ['twap', 'scale', 'chase'], + }); + }); + + it('advertises strategies for an existing HIP-3 market', async () => { + const { capabilityProvider, infoClient } = useHip3Capabilities(); + + expect( + await capabilityProvider.getOrderCapabilities({ symbol: 'xyz:TSLA' }), + ).toStrictEqual({ + status: 'ready', + providerId: 'hyperliquid', + supportedStrategies: ['twap', 'scale', 'chase'], + }); + expect(infoClient.meta).toHaveBeenCalledWith({ dex: 'xyz' }); + }); + + it.each([ + ['the HIP-3 kill switch is off', { hip3Enabled: false }], + ['the market is blocklisted', { blocklistMarkets: ['xyz:TSLA'] }], + ])('does not advertise a HIP-3 market when %s', async (_, options) => { + const { capabilityProvider } = useHip3Capabilities({}, options); + + expect( + await capabilityProvider.getOrderCapabilities({ symbol: 'xyz:TSLA' }), + ).toStrictEqual({ + status: 'unavailable', + providerId: 'hyperliquid', + reason: 'market_not_found', + }); + }); + + it('does not advertise a non-USDC-collateral HIP-3 market', async () => { + const { capabilityProvider } = useHip3Capabilities({ + meta: jest.fn().mockResolvedValue({ + universe: [{ name: 'xyz:TSLA', szDecimals: 3, maxLeverage: 20 }], + collateralToken: 1, + }), + }); + + expect( + await capabilityProvider.getOrderCapabilities({ symbol: 'xyz:TSLA' }), + ).toStrictEqual({ + status: 'unavailable', + providerId: 'hyperliquid', + reason: 'market_not_found', + }); + }); + + it('reports a missing HIP-3 market after checking its DEX metadata', async () => { + const { capabilityProvider, infoClient } = useHip3Capabilities(); + + expect( + await capabilityProvider.getOrderCapabilities({ symbol: 'xyz:FAKE' }), + ).toStrictEqual({ + status: 'unavailable', + providerId: 'hyperliquid', + reason: 'market_not_found', + }); + expect(infoClient.meta).toHaveBeenCalledWith({ dex: 'xyz' }); + }); + + it('reports a disconnected HIP-3 provider as unavailable', async () => { + const { capabilityProvider } = useHip3Capabilities(); + await capabilityProvider.disconnect(); + + expect( + await capabilityProvider.getOrderCapabilities({ symbol: 'xyz:TSLA' }), + ).toStrictEqual({ + status: 'unavailable', + providerId: 'hyperliquid', + reason: 'provider_unavailable', + }); + }); + + it('keeps delisted main-DEX discovery aligned with placement', async () => { + useStrategyClients({ + info: { + meta: jest.fn().mockResolvedValue({ + universe: [ + { + name: 'ETH', + szDecimals: 4, + maxLeverage: 50, + isDelisted: true, + }, + ], + }), + }, + }); + + expect( + await provider.getOrderCapabilities({ symbol: 'ETH' }), + ).toStrictEqual({ + status: 'ready', + providerId: 'hyperliquid', + supportedStrategies: ['twap', 'scale', 'chase'], + }); + }); + + it('reports an empty symbol as invalid', async () => { + const { infoClient } = useStrategyClients(); + + expect(await provider.getOrderCapabilities({ symbol: '' })).toStrictEqual( + { + status: 'unavailable', + providerId: 'hyperliquid', + reason: 'invalid_symbol', + }, + ); + expect(infoClient.meta).not.toHaveBeenCalled(); + }); + + it('reports a route missing its market as invalid', async () => { + const { infoClient } = useStrategyClients(); + + expect( + await provider.getOrderCapabilities({ symbol: 'BTC:' }), + ).toStrictEqual({ + status: 'unavailable', + providerId: 'hyperliquid', + reason: 'invalid_symbol', + }); + expect(infoClient.meta).not.toHaveBeenCalled(); + }); + + it('reports a route missing its DEX as invalid', async () => { + const { infoClient } = useStrategyClients(); + + expect( + await provider.getOrderCapabilities({ symbol: ':BTC' }), + ).toStrictEqual({ + status: 'unavailable', + providerId: 'hyperliquid', + reason: 'invalid_symbol', + }); + expect(infoClient.meta).not.toHaveBeenCalled(); + }); + + it.each([' ETH', 'ETH ', 'ETH BTC', 'a:b:c'])( + 'reports malformed symbol %p as invalid', + async (symbol) => { + const { infoClient } = useStrategyClients(); + + expect(await provider.getOrderCapabilities({ symbol })).toStrictEqual({ + status: 'unavailable', + providerId: 'hyperliquid', + reason: 'invalid_symbol', + }); + expect(infoClient.meta).not.toHaveBeenCalled(); + }, + ); + + it('reports an unknown main-DEX market as unavailable', async () => { + const { infoClient } = useStrategyClients(); + + expect( + await provider.getOrderCapabilities({ symbol: 'DOGE' }), + ).toStrictEqual({ + status: 'unavailable', + providerId: 'hyperliquid', + reason: 'market_not_found', + }); + expect(infoClient.meta).toHaveBeenCalledTimes(1); + }); + + it('refreshes metadata at the freshness boundary', async () => { + const { infoClient } = useStrategyClients(); + + await provider.getOrderCapabilities({ symbol: 'DOGE' }); + jest.advanceTimersByTime( + PERFORMANCE_CONFIG.OrderCapabilitiesMetaFreshnessMs - 1, + ); + await provider.getOrderCapabilities({ symbol: 'ETH' }); + + expect(infoClient.meta).toHaveBeenCalledTimes(1); + + jest.advanceTimersByTime(1); + await provider.getOrderCapabilities({ symbol: 'ETH' }); + + expect(infoClient.meta).toHaveBeenCalledTimes(2); + }); + + it('shares fresh metadata across unrelated symbols', async () => { + const { infoClient } = useStrategyClients(); + + await provider.getOrderCapabilities({ symbol: 'DOGE' }); + infoClient.meta.mockResolvedValueOnce({ + universe: [ + { name: 'BTC', szDecimals: 3, maxLeverage: 50 }, + { name: 'ETH', szDecimals: 4, maxLeverage: 50 }, + { name: 'PUMP', szDecimals: 0, maxLeverage: 20 }, + ], + }); + jest.advanceTimersByTime( + PERFORMANCE_CONFIG.OrderCapabilitiesMetaFreshnessMs, + ); + + await provider.getOrderCapabilities({ symbol: 'DOGE' }); + + expect( + await provider.getOrderCapabilities({ symbol: 'PUMP' }), + ).toStrictEqual({ + status: 'ready', + providerId: 'hyperliquid', + supportedStrategies: ['twap', 'scale', 'chase'], + }); + expect(infoClient.meta).toHaveBeenCalledTimes(2); + }); + + it('does not reuse session-long metadata for capability discovery', async () => { + const { infoClient } = useStrategyClients(); + + await provider.getMaxLeverage('ETH'); + + expect( + await provider.getOrderCapabilities({ symbol: 'ETH' }), + ).toStrictEqual({ + status: 'ready', + providerId: 'hyperliquid', + supportedStrategies: ['twap', 'scale', 'chase'], + }); + expect(infoClient.meta).toHaveBeenCalledTimes(2); + }); + + it('ages capability metadata from request completion', async () => { + const requestStarted = createDeferred(); + const pendingMeta = createDeferred<{ + universe: { name: string; szDecimals: number; maxLeverage: number }[]; + }>(); + const meta = jest + .fn() + .mockImplementationOnce(() => { + requestStarted.resolve(); + return pendingMeta.promise; + }) + .mockResolvedValueOnce({ + universe: [{ name: 'ETH', szDecimals: 4, maxLeverage: 50 }], + }); + const { infoClient } = useStrategyClients({ info: { meta } }); + + const capabilities = provider.getOrderCapabilities({ symbol: 'ETH' }); + await requestStarted.promise; + jest.advanceTimersByTime( + PERFORMANCE_CONFIG.OrderCapabilitiesMetaFreshnessMs, + ); + pendingMeta.resolve({ + universe: [{ name: 'ETH', szDecimals: 4, maxLeverage: 50 }], + }); + expect(await capabilities).toMatchObject({ status: 'ready' }); + + expect( + await provider.getOrderCapabilities({ symbol: 'ETH' }), + ).toMatchObject({ status: 'ready' }); + expect(infoClient.meta).toHaveBeenCalledTimes(1); + + jest.advanceTimersByTime( + PERFORMANCE_CONFIG.OrderCapabilitiesMetaFreshnessMs, + ); + expect( + await provider.getOrderCapabilities({ symbol: 'ETH' }), + ).toMatchObject({ status: 'ready' }); + expect(infoClient.meta).toHaveBeenCalledTimes(2); + }); + + it('isolates capability metadata from overlapping shared cache writes', async () => { + const sharedRequestStarted = createDeferred(); + const pendingSharedMeta = createDeferred<{ + universe: { name: string; szDecimals: number; maxLeverage: number }[]; + }>(); + const { infoClient } = useStrategyClients({ + info: { + meta: jest + .fn() + .mockImplementationOnce(() => { + sharedRequestStarted.resolve(); + return pendingSharedMeta.promise; + }) + .mockResolvedValueOnce({ + universe: [{ name: 'ETH', szDecimals: 4, maxLeverage: 50 }], + }), + }, + }); + + const sharedRead = provider.getMaxLeverage('PUMP'); + await sharedRequestStarted.promise; + expect( + await provider.getOrderCapabilities({ symbol: 'ETH' }), + ).toStrictEqual({ + status: 'ready', + providerId: 'hyperliquid', + supportedStrategies: ['twap', 'scale', 'chase'], + }); + + pendingSharedMeta.resolve({ + universe: [{ name: 'PUMP', szDecimals: 0, maxLeverage: 20 }], + }); + await sharedRead; + + expect( + await provider.getOrderCapabilities({ symbol: 'ETH' }), + ).toStrictEqual({ + status: 'ready', + providerId: 'hyperliquid', + supportedStrategies: ['twap', 'scale', 'chase'], + }); + expect(infoClient.meta).toHaveBeenCalledTimes(2); + }); + + it('deduplicates concurrent metadata refreshes', async () => { + const metaRequestStarted = createDeferred(); + const pendingMeta = createDeferred<{ + universe: { name: string; szDecimals: number; maxLeverage: number }[]; + }>(); + const { infoClient } = useStrategyClients({ + info: { + meta: jest.fn().mockImplementation(() => { + metaRequestStarted.resolve(); + return pendingMeta.promise; + }), + }, + }); + + const reads = [ + provider.getOrderCapabilities({ symbol: 'BTC' }), + provider.getOrderCapabilities({ symbol: 'ETH' }), + ]; + await metaRequestStarted.promise; + + expect(infoClient.meta).toHaveBeenCalledTimes(1); + pendingMeta.resolve({ + universe: [ + { name: 'BTC', szDecimals: 3, maxLeverage: 50 }, + { name: 'ETH', szDecimals: 4, maxLeverage: 50 }, + ], + }); + expect(await Promise.all(reads)).toStrictEqual([ + { + status: 'ready', + providerId: 'hyperliquid', + supportedStrategies: ['twap', 'scale', 'chase'], + }, + { + status: 'ready', + providerId: 'hyperliquid', + supportedStrategies: ['twap', 'scale', 'chase'], + }, + ]); + }); + + it('refreshes and coalesces capability metadata per HIP-3 DEX', async () => { + const pendingMeta = createDeferred<{ + universe: { name: string; szDecimals: number; maxLeverage: number }[]; + collateralToken: number; + }>(); + const meta = jest + .fn() + .mockReturnValueOnce(pendingMeta.promise) + .mockResolvedValue({ + universe: [{ name: 'xyz:TSLA', szDecimals: 3, maxLeverage: 20 }], + collateralToken: 0, + }); + const { capabilityProvider, infoClient } = useHip3Capabilities({ meta }); + + const reads = [ + capabilityProvider.getOrderCapabilities({ symbol: 'xyz:TSLA' }), + capabilityProvider.getOrderCapabilities({ symbol: 'xyz:XYZ100' }), + ]; + pendingMeta.resolve({ + universe: [{ name: 'xyz:TSLA', szDecimals: 3, maxLeverage: 20 }], + collateralToken: 0, + }); + await Promise.all(reads); + + expect(infoClient.meta).toHaveBeenCalledTimes(1); + jest.advanceTimersByTime( + PERFORMANCE_CONFIG.OrderCapabilitiesMetaFreshnessMs, + ); + await capabilityProvider.getOrderCapabilities({ symbol: 'xyz:TSLA' }); + expect(infoClient.meta).toHaveBeenCalledTimes(2); + }); + + it('refreshes metadata after reconnect', async () => { + const { infoClient } = useStrategyClients(); + + await provider.getOrderCapabilities({ symbol: 'ETH' }); + await provider.disconnect(); + expect( + await provider.getOrderCapabilities({ symbol: 'ETH' }), + ).toStrictEqual({ + status: 'unavailable', + providerId: 'hyperliquid', + reason: 'provider_unavailable', + }); + await provider.initialize(); + await provider.getOrderCapabilities({ symbol: 'ETH' }); + + expect(infoClient.meta).toHaveBeenCalledTimes(2); + }); + + it('reports capabilities unavailable during disconnect', async () => { + const { infoClient } = useStrategyClients(); + const disconnectStarted = createDeferred(); + const pendingDisconnect = createDeferred(); + mockClientService.disconnect.mockImplementationOnce(() => { + disconnectStarted.resolve(); + return pendingDisconnect.promise; + }); + + const disconnectPromise = provider.disconnect(); + await disconnectStarted.promise; + expect( + await provider.getOrderCapabilities({ symbol: 'ETH' }), + ).toStrictEqual({ + status: 'unavailable', + providerId: 'hyperliquid', + reason: 'provider_unavailable', + }); + pendingDisconnect.resolve(); + await disconnectPromise; + + expect( + await provider.getOrderCapabilities({ symbol: 'ETH' }), + ).toStrictEqual({ + status: 'unavailable', + providerId: 'hyperliquid', + reason: 'provider_unavailable', + }); + await provider.initialize(); + expect( + await provider.getOrderCapabilities({ symbol: 'ETH' }), + ).toStrictEqual({ + status: 'ready', + providerId: 'hyperliquid', + supportedStrategies: ['twap', 'scale', 'chase'], + }); + expect(infoClient.meta).toHaveBeenCalledTimes(1); + }); + + it('does not reconnect when initialization overlaps a disconnect', async () => { + const initializeStarted = createDeferred(); + const pendingInitialize = createDeferred(); + const clientDisconnectStarted = createDeferred(); + const pendingClientDisconnect = createDeferred(); + mockClientService.initialize.mockImplementationOnce(() => { + initializeStarted.resolve(); + return pendingInitialize.promise; + }); + mockClientService.disconnect.mockImplementationOnce(() => { + clientDisconnectStarted.resolve(); + return pendingClientDisconnect.promise; + }); + + const initializeResult = provider.initialize(); + await initializeStarted.promise; + const disconnectResult = provider.disconnect(); + pendingInitialize.resolve(); + + expect(await initializeResult).toStrictEqual({ + success: false, + error: PERPS_ERROR_CODES.PROVIDER_LIFECYCLE_STALE, + }); + await clientDisconnectStarted.promise; + expect( + await provider.getOrderCapabilities({ symbol: 'ETH' }), + ).toStrictEqual({ + status: 'unavailable', + providerId: 'hyperliquid', + reason: 'provider_unavailable', + }); + + pendingClientDisconnect.resolve(); + expect(await disconnectResult).toStrictEqual({ success: true }); + }); + + it('does not initialize while a disconnect is in progress', async () => { + useStrategyClients(); + const clientDisconnectStarted = createDeferred(); + const pendingClientDisconnect = createDeferred(); + mockClientService.disconnect.mockImplementationOnce(() => { + clientDisconnectStarted.resolve(); + return pendingClientDisconnect.promise; + }); + + const disconnectResult = provider.disconnect(); + await clientDisconnectStarted.promise; + + expect(await provider.initialize()).toStrictEqual({ + success: false, + error: PERPS_ERROR_CODES.PROVIDER_LIFECYCLE_STALE, + }); + expect(mockClientService.initialize).not.toHaveBeenCalled(); + + pendingClientDisconnect.resolve(); + expect(await disconnectResult).toStrictEqual({ success: true }); + expect(await provider.initialize()).toStrictEqual({ + success: true, + chainId: '42161', + }); + }); + + it('shares one teardown between overlapping disconnects', async () => { + const { infoClient } = useStrategyClients(); + const disconnectStarted = createDeferred(); + const pendingDisconnect = createDeferred(); + mockClientService.disconnect.mockImplementationOnce(() => { + disconnectStarted.resolve(); + return pendingDisconnect.promise; + }); + + const firstResult = provider.disconnect(); + const secondResult = provider.disconnect(); + + expect( + await provider.getOrderCapabilities({ symbol: 'ETH' }), + ).toStrictEqual({ + status: 'unavailable', + providerId: 'hyperliquid', + reason: 'provider_unavailable', + }); + + await disconnectStarted.promise; + expect(mockClientService.disconnect).toHaveBeenCalledTimes(1); + pendingDisconnect.resolve(); + expect(await Promise.all([firstResult, secondResult])).toStrictEqual([ + { success: true }, + { success: true }, + ]); + + expect( + await provider.getOrderCapabilities({ symbol: 'ETH' }), + ).toStrictEqual({ + status: 'unavailable', + providerId: 'hyperliquid', + reason: 'provider_unavailable', + }); + await provider.initialize(); + expect( + await provider.getOrderCapabilities({ symbol: 'ETH' }), + ).toStrictEqual({ + status: 'ready', + providerId: 'hyperliquid', + supportedStrategies: ['twap', 'scale', 'chase'], + }); + expect(infoClient.meta).toHaveBeenCalledTimes(1); + }); + + it('invalidates an in-flight refresh when client disconnect fails', async () => { + const requestStarted = createDeferred(); + const pendingMeta = createDeferred<{ + universe: { name: string; szDecimals: number; maxLeverage: number }[]; + }>(); + const { infoClient } = useStrategyClients({ + info: { + meta: jest + .fn() + .mockImplementationOnce(() => { + requestStarted.resolve(); + return pendingMeta.promise; + }) + .mockResolvedValueOnce({ + universe: [{ name: 'ETH', szDecimals: 4, maxLeverage: 50 }], + }), + }, + }); + mockClientService.disconnect.mockRejectedValueOnce( + new Error('disconnect failed'), + ); + + const staleRead = provider.getOrderCapabilities({ symbol: 'ETH' }); + await requestStarted.promise; + const disconnectResult = await provider.disconnect(); + pendingMeta.resolve({ + universe: [{ name: 'ETH', szDecimals: 4, maxLeverage: 50 }], + }); + + expect(disconnectResult).toStrictEqual({ + success: false, + error: 'disconnect failed', + }); + expect(await staleRead).toStrictEqual({ + status: 'unavailable', + providerId: 'hyperliquid', + reason: 'provider_unavailable', + }); + await provider.initialize(); + expect( + await provider.getOrderCapabilities({ symbol: 'ETH' }), + ).toStrictEqual({ + status: 'ready', + providerId: 'hyperliquid', + supportedStrategies: ['twap', 'scale', 'chase'], + }); + expect(infoClient.meta).toHaveBeenCalledTimes(2); + }); + + it('retries immediately after a metadata refresh failure', async () => { + const { infoClient } = useStrategyClients({ + info: { + meta: jest + .fn() + .mockRejectedValueOnce(new Error('offline')) + .mockResolvedValueOnce({ + universe: [{ name: 'ETH', szDecimals: 4, maxLeverage: 50 }], + }), + }, + }); + + expect( + await provider.getOrderCapabilities({ symbol: 'ETH' }), + ).toStrictEqual({ + status: 'unavailable', + providerId: 'hyperliquid', + reason: 'provider_unavailable', + }); + expect( + await provider.getOrderCapabilities({ symbol: 'ETH' }), + ).toStrictEqual({ + status: 'ready', + providerId: 'hyperliquid', + supportedStrategies: ['twap', 'scale', 'chase'], + }); + expect(infoClient.meta).toHaveBeenCalledTimes(2); + }); + + it('discards an in-flight main-DEX refresh after disconnect', async () => { + const universe = [{ name: 'ETH', szDecimals: 4, maxLeverage: 50 }]; + const requestStarted = createDeferred(); + const pendingMeta = createDeferred<{ universe: typeof universe }>(); + const meta = jest + .fn() + .mockImplementationOnce(() => { + requestStarted.resolve(); + return pendingMeta.promise; + }) + .mockResolvedValueOnce({ universe }); + const { infoClient } = useStrategyClients({ info: { meta } }); + + const capabilitiesPromise = provider.getOrderCapabilities({ + symbol: 'ETH', + }); + await requestStarted.promise; + await provider.disconnect(); + pendingMeta.resolve({ universe }); + + expect(await capabilitiesPromise).toStrictEqual({ + status: 'unavailable', + providerId: 'hyperliquid', + reason: 'provider_unavailable', + }); + await provider.initialize(); + expect( + await provider.getOrderCapabilities({ symbol: 'ETH' }), + ).toStrictEqual({ + status: 'ready', + providerId: 'hyperliquid', + supportedStrategies: ['twap', 'scale', 'chase'], + }); + expect(infoClient.meta).toHaveBeenCalledTimes(2); + }); + + it('does not cache capability metadata that resolves after disconnect', async () => { + const requestStarted = createDeferred(); + const pendingMeta = createDeferred<{ + universe: { name: string; szDecimals: number; maxLeverage: number }[]; + }>(); + const { infoClient } = useStrategyClients({ + info: { + meta: jest + .fn() + .mockImplementationOnce(() => { + requestStarted.resolve(); + return pendingMeta.promise; + }) + .mockResolvedValueOnce({ + universe: [{ name: 'ETH', szDecimals: 4, maxLeverage: 50 }], + }), + }, + }); + + const capabilitiesPromise = provider.getOrderCapabilities({ + symbol: 'ETH', + }); + await requestStarted.promise; + await provider.disconnect(); + pendingMeta.resolve({ + universe: [{ name: 'ETH', szDecimals: 4, maxLeverage: 99 }], + }); + + expect(await capabilitiesPromise).toStrictEqual({ + status: 'unavailable', + providerId: 'hyperliquid', + reason: 'provider_unavailable', + }); + expect(await provider.getMaxLeverage('ETH')).toBe(50); + expect(infoClient.meta).toHaveBeenCalledTimes(2); + }); + + it('does not cache general metadata that resolves after disconnect', async () => { + const universe = [{ name: 'ETH', szDecimals: 4, maxLeverage: 50 }]; + const requestStarted = createDeferred(); + const pendingMeta = createDeferred<{ universe: typeof universe }>(); + const { infoClient } = useStrategyClients({ + info: { + meta: jest + .fn() + .mockImplementationOnce(() => { + requestStarted.resolve(); + return pendingMeta.promise; + }) + .mockResolvedValueOnce({ universe }), + }, + }); + + const staleLeverage = provider.getMaxLeverage('ETH'); + await requestStarted.promise; + const disconnectResult = provider.disconnect(); + pendingMeta.resolve({ universe }); + + expect(await staleLeverage).toBe(50); + expect(mockPlatformDependencies.logger.error).not.toHaveBeenCalled(); + expect(await disconnectResult).toStrictEqual({ success: true }); + await provider.initialize(); + expect(await provider.getMaxLeverage('ETH')).toBe(50); + expect(infoClient.meta).toHaveBeenCalledTimes(2); + }); + + it('returns market data that finishes during disconnect without caching it', async () => { + const requestStarted = createDeferred(); + const pendingMids = createDeferred>(); + const { infoClient } = useStrategyClients({ + info: { + allMids: jest + .fn() + .mockImplementationOnce(() => { + requestStarted.resolve(); + return pendingMids.promise; + }) + .mockResolvedValue({ ETH: '3000' }), + }, + }); + + const marketDataPromise = provider.getMarketDataWithPrices(); + await requestStarted.promise; + await provider.disconnect(); + pendingMids.resolve({ ETH: '3000' }); + + expect(await marketDataPromise).toStrictEqual( + expect.arrayContaining([expect.objectContaining({ symbol: 'ETH' })]), + ); + expect(mockPlatformDependencies.logger.error).not.toHaveBeenCalled(); + + const callsAfterStaleRead = infoClient.metaAndAssetCtxs.mock.calls.length; + await provider.initialize(); + await provider.getMarketDataWithPrices(); + expect(infoClient.metaAndAssetCtxs.mock.calls.length).toBeGreaterThan( + callsAfterStaleRead, + ); + }); + + it('does not cache Perp DEX metadata that resolves after disconnect', async () => { + const requestStarted = createDeferred(); + const pendingPerpDexs = + createDeferred<(null | { name: string; deployerFeeScale: string })[]>(); + const { infoClient } = useStrategyClients({ + info: { + perpDexs: jest + .fn() + .mockImplementationOnce(() => { + requestStarted.resolve(); + return pendingPerpDexs.promise; + }) + .mockResolvedValueOnce([ + null, + { name: 'xyz', deployerFeeScale: '1' }, + ]), + meta: jest.fn().mockResolvedValue({ + universe: [{ name: 'xyz:TSLA', szDecimals: 3, maxLeverage: 20 }], + }), + }, + }); + provider = createTestProvider({ + hip3Enabled: true, + allowlistMarkets: ['xyz:*'], + }); + const params = { + orderType: 'market' as const, + amount: '1000', + symbol: 'xyz:TSLA', + }; + + const staleFees = provider.calculateFees(params); + await requestStarted.promise; + const disconnectPromise = provider.disconnect(); + pendingPerpDexs.resolve([null, { name: 'xyz', deployerFeeScale: '0.5' }]); + + await expect(staleFees).rejects.toThrow( + PERPS_ERROR_CODES.PROVIDER_LIFECYCLE_STALE, + ); + expect(await disconnectPromise).toStrictEqual({ success: true }); + await provider.initialize(); + expect((await provider.calculateFees(params)).protocolFeeRate).toBe( + 0.0009, + ); + expect(infoClient.perpDexs).toHaveBeenCalledTimes(2); + }); + + it('clears cached capability metadata when the network changes', async () => { + const meta = jest + .fn() + .mockResolvedValueOnce({ + universe: [{ name: 'ETH', szDecimals: 4, maxLeverage: 50 }], + }) + .mockResolvedValueOnce({ + universe: [{ name: 'BTC', szDecimals: 5, maxLeverage: 40 }], + }); + const { infoClient } = useStrategyClients({ info: { meta } }); + + expect( + await provider.getOrderCapabilities({ symbol: 'ETH' }), + ).toMatchObject({ status: 'ready' }); + await provider.toggleTestnet(); + + expect( + await provider.getOrderCapabilities({ symbol: 'ETH' }), + ).toStrictEqual({ + status: 'unavailable', + providerId: 'hyperliquid', + reason: 'market_not_found', + }); + expect(infoClient.meta).toHaveBeenCalledTimes(2); + }); + + it('retries client setup after initialization fails during a network toggle', async () => { + const { infoClient } = useStrategyClients(); + mockClientService.initialize + .mockRejectedValueOnce(new Error('initialization failed')) + .mockResolvedValueOnce(undefined); + + expect(await provider.toggleTestnet()).toMatchObject({ + success: false, + error: 'initialization failed', + }); + expect( + await provider.getOrderCapabilities({ symbol: 'ETH' }), + ).toMatchObject({ + status: 'ready', + providerId: 'hyperliquid', + }); + expect(mockClientService.initialize).toHaveBeenCalledTimes(2); + expect(infoClient.meta).toHaveBeenCalledTimes(1); + }); + + it('keeps the current network eligible when disconnect fails during a network toggle', async () => { + const { infoClient } = useStrategyClients(); + mockClientService.disconnect.mockRejectedValueOnce( + new Error('disconnect failed'), + ); + + expect(await provider.toggleTestnet()).toStrictEqual({ + success: false, + isTestnet: false, + error: 'disconnect failed', + }); + expect(mockClientService.setTestnetMode).not.toHaveBeenCalled(); + expect(mockWalletService.setTestnetMode).not.toHaveBeenCalled(); + expect( + await provider.getOrderCapabilities({ symbol: 'ETH' }), + ).toStrictEqual({ + status: 'ready', + providerId: 'hyperliquid', + supportedStrategies: ['twap', 'scale', 'chase'], + }); + expect(infoClient.meta).toHaveBeenCalledTimes(1); + }); + + it('does not cache capability metadata that resolves after a network change', async () => { + const requestStarted = createDeferred(); + const pendingMeta = createDeferred<{ + universe: { name: string; szDecimals: number; maxLeverage: number }[]; + }>(); + const meta = jest + .fn() + .mockImplementationOnce(() => { + requestStarted.resolve(); + return pendingMeta.promise; + }) + .mockResolvedValueOnce({ + universe: [{ name: 'BTC', szDecimals: 5, maxLeverage: 40 }], + }); + const { infoClient } = useStrategyClients({ info: { meta } }); + + const staleCapabilities = provider.getOrderCapabilities({ + symbol: 'ETH', + }); + await requestStarted.promise; + await provider.toggleTestnet(); + pendingMeta.resolve({ + universe: [{ name: 'ETH', szDecimals: 4, maxLeverage: 50 }], + }); + + expect(await staleCapabilities).toStrictEqual({ + status: 'unavailable', + providerId: 'hyperliquid', + reason: 'provider_unavailable', + }); + expect( + await provider.getOrderCapabilities({ symbol: 'BTC' }), + ).toMatchObject({ status: 'ready' }); + expect(infoClient.meta).toHaveBeenCalledTimes(2); + }); + + it('reports unavailable when market metadata cannot be loaded', async () => { + useStrategyClients({ + info: { meta: jest.fn().mockRejectedValue(new Error('offline')) }, + }); + const coldProvider = createTestProvider(); + + expect( + await coldProvider.getOrderCapabilities({ symbol: 'ETH' }), + ).toStrictEqual({ + status: 'unavailable', + providerId: 'hyperliquid', + reason: 'provider_unavailable', + }); + }); + }); + + describe('Network-scoped fee cache', () => { + const mainnetFees = { + userCrossRate: '0.00030', + userAddRate: '0.00010', + userSpotCrossRate: '0.00040', + userSpotAddRate: '0.00020', + activeReferralDiscount: '0', + dailyUserVlm: [], + }; + const testnetFees = { + ...mainnetFees, + userCrossRate: '0.00060', + userAddRate: '0.00020', + }; + + it('clears cached user fee rates when the network changes', async () => { + const userFees = jest + .fn() + .mockResolvedValueOnce(mainnetFees) + .mockResolvedValueOnce(testnetFees); + const { infoClient } = useStrategyClients({ info: { userFees } }); + const params = { + orderType: 'market' as const, + amount: '1000', + symbol: 'ETH', + }; + + expect((await provider.calculateFees(params)).protocolFeeRate).toBe( + 0.0003, + ); + await provider.toggleTestnet(); + + expect((await provider.calculateFees(params)).protocolFeeRate).toBe( + 0.0006, + ); + expect(infoClient.userFees).toHaveBeenCalledTimes(2); + }); + + it('does not cache user fee rates that resolve after a network change', async () => { + const requestStarted = createDeferred(); + const pendingUserFees = createDeferred(); + const userFees = jest + .fn() + .mockImplementationOnce(() => { + requestStarted.resolve(); + return pendingUserFees.promise; + }) + .mockResolvedValueOnce(testnetFees); + const { infoClient } = useStrategyClients({ info: { userFees } }); + const params = { + orderType: 'market' as const, + amount: '1000', + symbol: 'ETH', + }; + + const staleFees = provider.calculateFees(params); + await requestStarted.promise; + await provider.toggleTestnet(); + pendingUserFees.resolve(mainnetFees); + await expect(staleFees).rejects.toThrow( + PERPS_ERROR_CODES.PROVIDER_LIFECYCLE_STALE, + ); + + expect((await provider.calculateFees(params)).protocolFeeRate).toBe( + 0.0006, + ); + expect(infoClient.userFees).toHaveBeenCalledTimes(2); + }); + + it('does not cache Perp DEX metadata that resolves after a network change', async () => { + const requestStarted = createDeferred(); + const pendingPerpDexs = + createDeferred<(null | { name: string; deployerFeeScale: string })[]>(); + const { infoClient } = useStrategyClients({ + info: { + perpDexs: jest + .fn() + .mockImplementationOnce(() => { + requestStarted.resolve(); + return pendingPerpDexs.promise; + }) + .mockResolvedValueOnce([ + null, + { name: 'xyz', deployerFeeScale: '1' }, + ]), + meta: jest.fn().mockResolvedValue({ + universe: [{ name: 'xyz:TSLA', szDecimals: 3, maxLeverage: 20 }], + }), + }, + }); + provider = createTestProvider({ + hip3Enabled: true, + allowlistMarkets: ['xyz:*'], + }); + const params = { + orderType: 'market' as const, + amount: '1000', + symbol: 'xyz:TSLA', + }; + + const staleFees = provider.calculateFees(params); + await requestStarted.promise; + await provider.toggleTestnet(); + pendingPerpDexs.resolve([null, { name: 'xyz', deployerFeeScale: '0.5' }]); + + await expect(staleFees).rejects.toThrow( + PERPS_ERROR_CODES.PROVIDER_LIFECYCLE_STALE, + ); + expect((await provider.calculateFees(params)).protocolFeeRate).toBe( + 0.0009, + ); + expect(infoClient.perpDexs).toHaveBeenCalledTimes(2); + }); + + it('retains confirmed builder approval that finishes after disconnect', async () => { + let cachedBuilderFee: + | { attempted: boolean; success: boolean } + | undefined; + const mockedCache = jest.mocked(TradingReadinessCache); + mockedCache.getBuilderFee.mockImplementation(() => cachedBuilderFee); + mockedCache.setBuilderFee.mockImplementation( + (_network, _userAddress, status) => { + cachedBuilderFee = status; + }, + ); + const verificationStarted = createDeferred(); + const pendingVerification = createDeferred(); + const maxBuilderFee = jest + .fn() + .mockResolvedValueOnce(0) + .mockImplementationOnce(() => { + verificationStarted.resolve(); + return pendingVerification.promise; + }) + .mockResolvedValueOnce(0) + .mockResolvedValueOnce(BUILDER_FEE_CONFIG.MaxFeeDecimal); + const { exchangeClient } = useStrategyClients({ + info: { maxBuilderFee }, + }); + const params = { + ...baseOrder, + orderType: 'market', + } satisfies OrderParams; + + const stalePlacement = provider.placeOrder(params); + await verificationStarted.promise; + const disconnect = provider.disconnect(); + pendingVerification.resolve(BUILDER_FEE_CONFIG.MaxFeeDecimal); + await Promise.all([stalePlacement, disconnect]); + + expect(exchangeClient.approveBuilderFee).toHaveBeenCalledTimes(1); + + await provider.initialize(); + await provider.placeOrder(params); + + expect(exchangeClient.approveBuilderFee).toHaveBeenCalledTimes(1); + }); + + it('does not share pending builder setup across accounts', async () => { + const firstAccount = '0x1234567890123456789012345678901234567890'; + const secondAccount = '0xabcdefabcdefabcdefabcdefabcdefabcdefabcd'; + let selectedAccount = firstAccount; + mockWalletService.getUserAddressWithDefault.mockImplementation(() => + Promise.resolve(selectedAccount), + ); + + const firstApprovalStarted = createDeferred(); + const pendingFirstApproval = createDeferred(); + const maxBuilderFee = jest + .fn() + .mockImplementationOnce(() => { + firstApprovalStarted.resolve(); + return pendingFirstApproval.promise; + }) + .mockResolvedValue(BUILDER_FEE_CONFIG.MaxFeeDecimal); + useStrategyClients({ info: { maxBuilderFee } }); + const params = { + ...baseOrder, + orderType: 'market' as const, + }; + + const firstPlacement = provider.placeOrder(params); + await firstApprovalStarted.promise; + selectedAccount = secondAccount; + const secondPlacement = provider.placeOrder(params); + await new Promise((resolve) => setImmediate(resolve)); + const approvalChecksBeforeFirstResolved = maxBuilderFee.mock.calls.length; + + pendingFirstApproval.resolve(BUILDER_FEE_CONFIG.MaxFeeDecimal); + await Promise.all([firstPlacement, secondPlacement]); + + expect(approvalChecksBeforeFirstResolved).toBe(2); + expect(maxBuilderFee).toHaveBeenNthCalledWith(1, { + user: firstAccount, + builder: BUILDER_FEE_CONFIG.MainnetBuilder, + }); + expect(maxBuilderFee).toHaveBeenNthCalledWith(2, { + user: secondAccount, + builder: BUILDER_FEE_CONFIG.MainnetBuilder, + }); + }); + }); + + describe('Strategy notional minimums', () => { + // validateOrder owns the minimums it can decide without the asset's size + // grid — the TWAP total and the ordinary per-order minimum. A scale + // ladder's rungs depend on that grid, so they are checked in the placement + // path instead; see "Scale ladder is validated before anything is signed". + it("rejects a TWAP below the venue's minimum total", async () => { + useStrategyClients(); + + const result = await provider.validateOrder({ + ...baseOrder, + usdAmount: String(HYPERLIQUID_TWAP_LIMITS.MinNotionalUsd - 1), + orderType: 'twap', + twapDuration: 30, + } satisfies OrderParams); + + expect(result).toStrictEqual({ + isValid: false, + error: PERPS_ERROR_CODES.ORDER_TWAP_NOTIONAL_TOO_SMALL, + }); + }); + + it("accepts a TWAP at the venue's minimum total", async () => { + useStrategyClients(); + + const result = await provider.validateOrder({ + ...baseOrder, + usdAmount: String(HYPERLIQUID_TWAP_LIMITS.MinNotionalUsd), + orderType: 'twap', + twapDuration: 30, + } satisfies OrderParams); + + expect(result).toStrictEqual({ isValid: true }); + }); + + it('never reaches the exchange for an under-funded ladder', async () => { + const { exchangeClient } = useStrategyClients(); + + const result = await provider.placeOrder({ + ...baseOrder, + usdAmount: '50', + orderType: 'scale', + scaleMinPrice: '2000', + scaleMaxPrice: '3000', + scaleNumOrders: 20, + } satisfies OrderParams); + + expect(result.success).toBe(false); + expect(result.error).toBe( + PERPS_ERROR_CODES.ORDER_SCALE_NOTIONAL_TOO_SMALL, + ); + expect(exchangeClient.order).not.toHaveBeenCalled(); + }); + + // A ladder whose average rung clears the minimum can still carry a rung + // that does not once the skew has weighted it. + it('never reaches the exchange when a skew starves the cheapest rung', async () => { + const { exchangeClient } = useStrategyClients(); + + const result = await provider.placeOrder({ + ...baseOrder, + usdAmount: '100', + orderType: 'scale', + scaleMinPrice: '2000', + scaleMaxPrice: '3000', + scaleNumOrders: 5, + scaleSkew: 20, + } satisfies OrderParams); + + expect(result.success).toBe(false); + expect(result.error).toBe( + PERPS_ERROR_CODES.ORDER_SCALE_NOTIONAL_TOO_SMALL, + ); + expect(exchangeClient.order).not.toHaveBeenCalled(); + }); + + it('accepts the same ladder without the skew', async () => { + const { exchangeClient } = useStrategyClients({ + exchange: { + order: jest.fn().mockResolvedValue({ + status: 'ok', + response: { + data: { + statuses: [ + { resting: { oid: 11 } }, + { resting: { oid: 22 } }, + { resting: { oid: 33 } }, + { resting: { oid: 44 } }, + { resting: { oid: 55 } }, + ], + }, + }, + }), + }, + }); + + const result = await provider.placeOrder({ + ...baseOrder, + usdAmount: '100', + orderType: 'scale', + scaleMinPrice: '2000', + scaleMaxPrice: '3000', + scaleNumOrders: 5, + } satisfies OrderParams); + + expect(result.success).toBe(true); + expect(exchangeClient.order).toHaveBeenCalledTimes(1); + }); + + it('never reaches the exchange for an invalid skew', async () => { + const { exchangeClient } = useStrategyClients(); + + const result = await provider.placeOrder({ + ...baseOrder, + orderType: 'scale', + scaleMinPrice: '2000', + scaleMaxPrice: '3000', + scaleNumOrders: 3, + scaleSkew: 0, + } satisfies OrderParams); + + expect(result.success).toBe(false); + expect(result.error).toBe(PERPS_ERROR_CODES.ORDER_SCALE_RANGE_INVALID); + expect(exchangeClient.order).not.toHaveBeenCalled(); + }); + + it('leaves a chase on the ordinary per-order minimum', async () => { + useStrategyClients(); + + const result = await provider.validateOrder({ + ...baseOrder, + usdAmount: '20', + orderType: 'chase', + } satisfies OrderParams); + + expect(result).toStrictEqual({ isValid: true }); + }); + }); + + describe('Chase tick failures', () => { + beforeEach(() => { + jest.useFakeTimers(); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + const rested = (oid: number): Record => ({ + status: 'ok', + response: { data: { statuses: [{ resting: { oid } }] } }, + }); + + const bookAt = (bid: string): Record => ({ + coin: 'ETH', + levels: [[{ px: bid, sz: '10', n: 1 }], [{ px: '3001', sz: '10', n: 1 }]], + }); + + it('keeps chasing when a book read fails, leaving the order resting', async () => { + const order = jest.fn().mockResolvedValue(rested(55)); + const l2Book = jest + .fn() + .mockResolvedValueOnce(bookAt('2999')) + .mockRejectedValueOnce(new Error('network blip')) + .mockResolvedValue(bookAt('2998')); + const { exchangeClient } = useStrategyClients({ + exchange: { order }, + info: { l2Book }, + }); + + const placed = await provider.placeOrder({ + ...baseOrder, + orderType: 'chase', + chaseIntervalMs: 1000, + } satisfies OrderParams); + + // Tick 1 throws on the book read; tick 2 must still happen and re-price. + await jest.advanceTimersByTimeAsync(2000); + + expect(order).toHaveBeenCalledTimes(2); + expect(exchangeClient.cancel).toHaveBeenCalledWith({ + cancels: [{ a: 1, o: 55 }], + }); + + // The session survived the transient failure, so its handle still works. + const cancelled = await provider.cancelOrder({ + orderId: placed.orderId, + symbol: 'ETH', + orderType: 'chase', + }); + expect(cancelled.success).toBe(true); + }); + + it('ends the session cleanly when the replacement fails to rest', async () => { + // The old order is cancelled and the re-place throws: nothing is on the + // book, so the session must not keep claiming to hold a resting order. + const order = jest + .fn() + .mockResolvedValueOnce(rested(55)) + .mockRejectedValue(new Error('insufficient margin')); + const l2Book = jest + .fn() + .mockResolvedValueOnce(bookAt('2999')) + .mockResolvedValue(bookAt('2998')); + const { exchangeClient } = useStrategyClients({ + exchange: { order }, + info: { l2Book }, + }); + + const placed = await provider.placeOrder({ + ...baseOrder, + orderType: 'chase', + chaseIntervalMs: 1000, + } satisfies OrderParams); + + await jest.advanceTimersByTimeAsync(5000); + + // Two attempts: the original placement and the failed replacement. No + // further ticks ran, so the session stopped rather than looping. + expect(order).toHaveBeenCalledTimes(2); + + // Cancelling reports success and releases the handle, because there is + // genuinely nothing left resting — it does not report an incomplete + // cancel forever against the dead order id. + const first = await provider.cancelOrder({ + orderId: placed.orderId, + symbol: 'ETH', + orderType: 'chase', + }); + expect(first).toStrictEqual({ + success: true, + orderId: placed.orderId, + }); + expect(exchangeClient.cancel).toHaveBeenCalledTimes(1); + + // The handle is released, so a second cancel finds nothing. + const second = await provider.cancelOrder({ + orderId: placed.orderId, + symbol: 'ETH', + orderType: 'chase', + }); + expect(second).toStrictEqual({ success: true, orderId: placed.orderId }); + }); + }); + + describe('A cancel refused because the order already left the book', () => { + // HyperLiquid answers a cancel it cannot match with this message. It is a + // rejection of the request but a confirmation of what the caller wanted. + const alreadyGone = { + status: 'ok', + response: { + data: { + statuses: [ + { error: 'Order was never placed, already canceled, or filled.' }, + ], + }, + }, + }; + + it('completes a chase cancel whose child had already filled', async () => { + useStrategyClients({ + exchange: { + order: jest.fn().mockResolvedValue({ + status: 'ok', + response: { data: { statuses: [{ resting: { oid: 55 } }] } }, + }), + cancel: jest.fn().mockResolvedValue(alreadyGone), + }, + }); + + const placed = await provider.placeOrder({ + ...baseOrder, + orderType: 'chase', + } satisfies OrderParams); + + const result = await provider.cancelOrder({ + orderId: placed.orderId, + symbol: 'ETH', + orderType: 'chase', + }); + + // Nothing of the chase rests, so the cancel succeeded and the handle is + // released rather than pinned open on a filled order forever. + expect(result).toStrictEqual({ success: true, orderId: placed.orderId }); + + const second = await provider.cancelOrder({ + orderId: placed.orderId, + symbol: 'ETH', + orderType: 'chase', + }); + expect(second).toStrictEqual({ success: true, orderId: placed.orderId }); + }); + + it('completes a chase cancel when the SDK throws that its child is gone', async () => { + useStrategyClients({ + exchange: { + order: jest.fn().mockResolvedValue({ + status: 'ok', + response: { data: { statuses: [{ resting: { oid: 55 } }] } }, + }), + cancel: jest + .fn() + .mockRejectedValue( + new Error('Order was never placed, already canceled, or filled.'), + ), + }, + }); + const placed = await provider.placeOrder({ + ...baseOrder, + orderType: 'chase', + } as OrderParams); + + const result = await provider.cancelOrder({ + orderId: placed.orderId, + symbol: 'ETH', + orderType: 'chase', + }); + + expect(result).toStrictEqual({ success: true, orderId: placed.orderId }); + expect(await provider.getChaseOrders()).toStrictEqual([]); + }); + + it('completes a scale cancel when one rung had already filled', async () => { + useStrategyClients({ + exchange: { + order: jest.fn().mockResolvedValue({ + status: 'ok', + response: { + data: { + statuses: [ + { resting: { oid: 11 } }, + { resting: { oid: 22 } }, + { resting: { oid: 33 } }, + ], + }, + }, + }), + cancel: jest.fn().mockResolvedValue({ + status: 'ok', + response: { + data: { + statuses: [ + 'success', + { + error: + 'Order was never placed, already canceled, or filled.', + }, + 'success', + ], + }, + }, + }), + }, + }); + + const placed = await provider.placeOrder({ + ...baseOrder, + orderType: 'scale', + scaleMinPrice: '2000', + scaleMaxPrice: '3000', + scaleNumOrders: 3, + } satisfies OrderParams); + + const result = await provider.cancelOrder({ + orderId: placed.orderId, + symbol: 'ETH', + orderType: 'scale', + }); + + expect(result).toStrictEqual({ success: true, orderId: placed.orderId }); + + const second = await provider.cancelOrder({ + orderId: placed.orderId, + symbol: 'ETH', + orderType: 'scale', + }); + expect(second.error).toBe( + PERPS_ERROR_CODES.ORDER_STRATEGY_HANDLE_UNKNOWN, + ); + }); + + it.each([ + [ + 'a non-ok response', + { + status: 'err', + response: { + data: { statuses: ['success', 'success', 'success'] }, + }, + }, + ], + [ + 'a truncated response', + { + status: 'ok', + response: { data: { statuses: ['success'] } }, + }, + ], + ])('retains every scale child after %s', async (_label, failedCancel) => { + const cancel = jest + .fn() + .mockResolvedValueOnce(failedCancel) + .mockResolvedValueOnce({ + status: 'ok', + response: { + data: { statuses: ['success', 'success', 'success'] }, + }, + }); + useStrategyClients({ + exchange: { + order: jest.fn().mockResolvedValue({ + status: 'ok', + response: { + data: { + statuses: [ + { resting: { oid: 11 } }, + { resting: { oid: 22 } }, + { resting: { oid: 33 } }, + ], + }, + }, + }), + cancel, + }, + }); + + const placed = await provider.placeOrder({ + ...baseOrder, + orderType: 'scale', + scaleMinPrice: '2000', + scaleMaxPrice: '3000', + scaleNumOrders: 3, + } satisfies OrderParams); + + const first = await provider.cancelOrder({ + orderId: placed.orderId, + symbol: 'ETH', + orderType: 'scale', + }); + const second = await provider.cancelOrder({ + orderId: placed.orderId, + symbol: 'ETH', + orderType: 'scale', + }); + + expect(first.error).toBe( + PERPS_ERROR_CODES.ORDER_STRATEGY_CANCEL_INCOMPLETE, + ); + expect(second).toStrictEqual({ + success: true, + orderId: placed.orderId, + }); + expect(cancel).toHaveBeenNthCalledWith(2, { + cancels: [ + { a: 1, o: 11 }, + { a: 1, o: 22 }, + { a: 1, o: 33 }, + ], + }); + }); + + it('still reports a genuinely refused cancel as incomplete', async () => { + useStrategyClients({ + exchange: { + order: jest.fn().mockResolvedValue({ + status: 'ok', + response: { data: { statuses: [{ resting: { oid: 55 } }] } }, + }), + cancel: jest.fn().mockResolvedValue({ + status: 'ok', + response: { data: { statuses: [{ error: 'multi-sig required' }] } }, + }), + }, + }); + + const placed = await provider.placeOrder({ + ...baseOrder, + orderType: 'chase', + } satisfies OrderParams); + + const result = await provider.cancelOrder({ + orderId: placed.orderId, + symbol: 'ETH', + orderType: 'chase', + }); + + // The order is still on the book, so the handle must survive for a retry. + expect(result.error).toBe( + PERPS_ERROR_CODES.ORDER_STRATEGY_CANCEL_INCOMPLETE, + ); + }); + }); + + describe('Chase reprice when the exchange refuses the cancel', () => { + beforeEach(() => { + jest.useFakeTimers(); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + it('leaves the old order alone and retries on the next tick', async () => { + const order = jest.fn().mockResolvedValue({ + status: 'ok', + response: { data: { statuses: [{ resting: { oid: 55 } }] } }, + }); + const cancel = jest + .fn() + .mockResolvedValueOnce({ + status: 'ok', + response: { data: { statuses: [{ error: 'multi-sig required' }] } }, + }) + .mockResolvedValue({ + status: 'ok', + response: { data: { statuses: ['success'] } }, + }); + + useStrategyClients({ + exchange: { order, cancel }, + info: { + l2Book: jest + .fn() + .mockResolvedValueOnce({ + coin: 'ETH', + levels: [ + [{ px: '2999', sz: '10', n: 1 }], + [{ px: '3001', sz: '10', n: 1 }], + ], + }) + .mockResolvedValue({ + coin: 'ETH', + levels: [ + [{ px: '2998', sz: '10', n: 1 }], + [{ px: '3001', sz: '10', n: 1 }], + ], + }), + }, + }); + + await provider.placeOrder({ + ...baseOrder, + orderType: 'chase', + chaseIntervalMs: 1000, + } satisfies OrderParams); + + // Tick 1's cancel is refused, so no replacement may be placed — doing so + // would double the position while the old order still rests. + await jest.advanceTimersByTimeAsync(1000); + expect(order).toHaveBeenCalledTimes(1); + + // Tick 2 retries and succeeds, so the chase resumes rather than ending. + await jest.advanceTimersByTimeAsync(1000); + expect(order).toHaveBeenCalledTimes(2); + }); + }); + + describe('editOrder rejects strategy placements', () => { + it.each(['twap', 'scale', 'chase'] as OrderType[])( + 'refuses to modify an order into a %s', + async (orderType) => { + const { exchangeClient } = useStrategyClients(); + + const result = await provider.editOrder({ + orderId: '123', + newOrder: { ...baseOrder, orderType } satisfies OrderParams, + }); + + expect(result).toStrictEqual({ + success: false, + error: PERPS_ERROR_CODES.ORDER_EDIT_STRATEGY_UNSUPPORTED, + }); + expect(exchangeClient.modify).not.toHaveBeenCalled(); + }, + ); + }); + + describe('Scale ladder is validated before anything is signed', () => { + /** + * Place a scale order and report what the exchange client saw. + * + * @param order - Scale-specific order params. + * @returns The placement result and the mock exchange client. + */ + const placeLadder = async ( + order: Record, + ): Promise<{ result: OrderResult; exchangeClient: MockClient }> => { + const { exchangeClient } = useStrategyClients(); + + const result = await provider.placeOrder({ + ...baseOrder, + orderType: 'scale', + ...order, + } satisfies OrderParams); + + return { result, exchangeClient }; + }; + + it('rejects a range whose rungs collapse onto the same venue price', async () => { + // ETH has 4 size decimals, so prices carry two. 3000.001 and 3000.002 + // both format to 3000, which would stack the ladder at one price instead + // of spreading it. + const { result, exchangeClient } = await placeLadder({ + scaleMinPrice: '3000.001', + scaleMaxPrice: '3000.002', + scaleNumOrders: 2, + }); + + expect(result.error).toBe(PERPS_ERROR_CODES.ORDER_SCALE_RANGE_INVALID); + // No leverage change and no order: an invalid ladder must not cost a + // signing prompt or a venue side effect first. + expect(exchangeClient.updateLeverage).not.toHaveBeenCalled(); + expect(exchangeClient.order).not.toHaveBeenCalled(); + }); + + it('rejects a total that cannot give every rung a whole size unit', async () => { + // BTC has 3 size decimals, so one size unit is 0.001 BTC — about $50 + // here. A 0.019 BTC total is nineteen units to spread over twenty rungs: + // every rung is worth far more than the $10 minimum, so this is reached + // only by the grid check, not by the notional one. + const { result, exchangeClient } = await placeLadder({ + symbol: 'BTC', + currentPrice: 50000, + size: '0.019', + usdAmount: '950', + scaleMinPrice: '40000', + scaleMaxPrice: '60000', + scaleNumOrders: 20, + }); + + expect(result.error).toBe(PERPS_ERROR_CODES.ORDER_SCALE_SIZE_TOO_SMALL); + expect(exchangeClient.updateLeverage).not.toHaveBeenCalled(); + expect(exchangeClient.order).not.toHaveBeenCalled(); + }); + + it('rejects a ladder whose rungs fall below the per-order minimum', async () => { + // $50 over 20 rungs is twenty $2.50 orders; the venue rejects every one. + const { result, exchangeClient } = await placeLadder({ + usdAmount: '50', + scaleMinPrice: '2000', + scaleMaxPrice: '3000', + scaleNumOrders: 20, + }); + + expect(result.error).toBe( + PERPS_ERROR_CODES.ORDER_SCALE_NOTIONAL_TOO_SMALL, + ); + expect(exchangeClient.order).not.toHaveBeenCalled(); + }); + + it('rejects on the real grid slice, not the average one', async () => { + // 0.0039 ETH over 20 rungs averages 1.95 size units, but the grid split + // floors that to 1 unit on nineteen of them and puts the remainder on the + // first. At the 60000 rung the average slice is worth $11.70 — over the + // $10 minimum — while the slice actually submitted is worth $6. Only a + // check against the real grid sizes catches this. + const { result, exchangeClient } = await placeLadder({ + size: '0.0039', + usdAmount: '11.70', + scaleMinPrice: '60000', + scaleMaxPrice: '70000', + scaleNumOrders: 20, + }); + + expect(result.error).toBe( + PERPS_ERROR_CODES.ORDER_SCALE_NOTIONAL_TOO_SMALL, + ); + expect(exchangeClient.updateLeverage).not.toHaveBeenCalled(); + expect(exchangeClient.order).not.toHaveBeenCalled(); + }); + + it('submits exactly the ladder it validated', async () => { + const order = jest.fn().mockResolvedValue({ + status: 'ok', + response: { + data: { + statuses: [ + { resting: { oid: 11 } }, + { resting: { oid: 22 } }, + { resting: { oid: 33 } }, + ], + }, + }, + }); + const { exchangeClient } = useStrategyClients({ exchange: { order } }); + + await provider.placeOrder({ + ...baseOrder, + orderType: 'scale', + scaleMinPrice: '2000', + scaleMaxPrice: '3000', + scaleNumOrders: 3, + } satisfies OrderParams); + + const submitted = exchangeClient.order.mock.calls[0][0]; + expect( + submitted.orders.map((entry: { p: string }) => entry.p), + ).toStrictEqual(['2000', '2500', '3000']); + expect( + submitted.orders.map((entry: { s: string }) => entry.s), + ).toStrictEqual(['0.3334', '0.3333', '0.3333']); + }); + }); + + describe('Chase replacements keep the fee they were quoted at', () => { + beforeEach(() => { + jest.useFakeTimers(); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + it('reuses the placement-time builder fee after the discount is cleared', async () => { + const order = jest + .fn() + .mockResolvedValueOnce({ + status: 'ok', + response: { data: { statuses: [{ resting: { oid: 55 } }] } }, + }) + .mockResolvedValue({ + status: 'ok', + response: { data: { statuses: [{ resting: { oid: 66 } }] } }, + }); + + useStrategyClients({ + exchange: { order }, + info: { + l2Book: jest + .fn() + .mockResolvedValueOnce({ + coin: 'ETH', + levels: [ + [{ px: '2999', sz: '10', n: 1 }], + [{ px: '3001', sz: '10', n: 1 }], + ], + }) + .mockResolvedValue({ + coin: 'ETH', + levels: [ + [{ px: '2998', sz: '10', n: 1 }], + [{ px: '3001', sz: '10', n: 1 }], + ], + }), + }, + }); + + // The rewards discount is live only around the caller's placeOrder. + provider.setUserFeeDiscount(5000); + await provider.placeOrder({ + ...baseOrder, + orderType: 'chase', + chaseIntervalMs: 1000, + } satisfies OrderParams); + const quotedFee = order.mock.calls[0][0].builder.f; + + // TradingService clears it as soon as placeOrder returns, which for a + // chase is long before the re-pricing loop runs. + provider.setUserFeeDiscount(undefined); + await jest.advanceTimersByTimeAsync(1000); + + expect(order).toHaveBeenCalledTimes(2); + expect(order.mock.calls[1][0].builder.f).toBe(quotedFee); + }); + }); + + describe('Chase re-prices only what is still resting', () => { + beforeEach(() => { + jest.useFakeTimers(); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + /** + * Build an l2Book mock whose touch moves after the first read. + * + * @returns The mock. + */ + const bookThatMoves = (): jest.Mock => + jest + .fn() + .mockResolvedValueOnce({ + coin: 'ETH', + levels: [ + [{ px: '2999', sz: '10', n: 1 }], + [{ px: '3001', sz: '10', n: 1 }], + ], + }) + .mockResolvedValue({ + coin: 'ETH', + levels: [ + [{ px: '2998', sz: '10', n: 1 }], + [{ px: '3001', sz: '10', n: 1 }], + ], + }); + + it('reads the remainder after the cancel, not before it', async () => { + const order = jest.fn().mockResolvedValue({ + status: 'ok', + response: { data: { statuses: [{ resting: { oid: 55 } }] } }, + }); + const callOrder: string[] = []; + const cancel = jest.fn().mockImplementation(async () => { + callOrder.push('cancel'); + return { status: 'ok', response: { data: { statuses: ['success'] } } }; + }); + const orderStatus = jest.fn().mockImplementation(async () => { + callOrder.push('orderStatus'); + return { + status: 'order', + order: { + status: callOrder.includes('cancel') ? 'canceled' : 'open', + order: { + coin: 'ETH', + side: 'B', + limitPx: '2999', + sz: '0.4', + origSz: '1', + oid: 55, + timestamp: 1_700_000_000_000, + isTrigger: false, + triggerCondition: 'N/A', + triggerPx: '0', + children: [], + isPositionTpsl: false, + reduceOnly: false, + orderType: 'Limit', + }, + }, + }; + }); + + useStrategyClients({ + exchange: { order, cancel }, + info: { l2Book: bookThatMoves(), orderStatus }, + }); + + await provider.placeOrder({ + ...baseOrder, + orderType: 'chase', + chaseIntervalMs: 1000, + } satisfies OrderParams); + + await jest.advanceTimersByTimeAsync(1000); + + // Once the cancel has landed no further fills can reach the order, so the + // remainder it reports is final. Reading first would leave a window in + // which a fill lands and is then re-placed on top. A liveness read may + // precede the cancel — what matters is that a read follows it. + expect(callOrder.slice(callOrder.indexOf('cancel'))).toStrictEqual([ + 'cancel', + 'orderStatus', + ]); + }); + + it('replaces a partly filled order at its remaining size', async () => { + const order = jest + .fn() + .mockResolvedValueOnce({ + status: 'ok', + response: { data: { statuses: [{ resting: { oid: 55 } }] } }, + }) + .mockResolvedValue({ + status: 'ok', + response: { data: { statuses: [{ resting: { oid: 66 } }] } }, + }); + + useStrategyClients({ + exchange: { order }, + info: { + l2Book: bookThatMoves(), + // 0.6 of the 1 ETH had filled by the time the cancel landed. + orderStatus: jest + .fn() + .mockResolvedValueOnce({ + status: 'order', + order: { + status: 'open', + order: { + coin: 'ETH', + side: 'B', + limitPx: '2999', + sz: '1', + origSz: '1', + oid: 55, + timestamp: 1_700_000_000_000, + isTrigger: false, + triggerCondition: 'N/A', + triggerPx: '0', + children: [], + isPositionTpsl: false, + reduceOnly: false, + orderType: 'Limit', + }, + }, + }) + .mockResolvedValue({ + status: 'order', + order: { + status: 'canceled', + order: { + coin: 'ETH', + side: 'B', + limitPx: '2999', + sz: '0.4', + origSz: '1', + oid: 55, + timestamp: 1_700_000_000_000, + isTrigger: false, + triggerCondition: 'N/A', + triggerPx: '0', + children: [], + isPositionTpsl: false, + reduceOnly: false, + orderType: 'Limit', + }, + }, + }), + }, + }); + + await provider.placeOrder({ + ...baseOrder, + orderType: 'chase', + chaseIntervalMs: 1000, + } satisfies OrderParams); + expect(order.mock.calls[0][0].orders[0].s).toBe('1'); + + await jest.advanceTimersByTimeAsync(1000); + + // The replacement must cover only what was left. Re-placing the original + // size would buy 1.6 ETH in total against a 1 ETH request. + expect(order).toHaveBeenCalledTimes(2); + expect(order.mock.calls[1][0].orders[0].s).toBe('0.4'); + }); + + it('reports no remainder when the canceled child filled during repricing', async () => { + const order = jest.fn().mockResolvedValue({ + status: 'ok', + response: { data: { statuses: [{ resting: { oid: 55 } }] } }, + }); + useStrategyClients({ + exchange: { order }, + info: { + l2Book: bookThatMoves(), + orderStatus: jest + .fn() + .mockResolvedValueOnce({ + status: 'order', + order: { + status: 'open', + order: { + coin: 'ETH', + side: 'B', + limitPx: '2999', + sz: '1', + origSz: '1', + oid: 55, + timestamp: 1_700_000_000_000, + isTrigger: false, + triggerCondition: 'N/A', + triggerPx: '0', + children: [], + isPositionTpsl: false, + reduceOnly: false, + orderType: 'Limit', + }, + }, + }) + .mockResolvedValue({ + status: 'order', + order: { + status: 'filled', + order: { + coin: 'ETH', + side: 'B', + limitPx: '2999', + sz: '0', + origSz: '1', + oid: 55, + timestamp: 1_700_000_000_000, + isTrigger: false, + triggerCondition: 'N/A', + triggerPx: '0', + children: [], + isPositionTpsl: false, + reduceOnly: false, + orderType: 'Limit', + }, + }, + }), + }, + }); + + const placed = await provider.placeOrder({ + ...baseOrder, + orderType: 'chase', + chaseIntervalMs: 1000, + } satisfies OrderParams); + + await jest.advanceTimersByTimeAsync(1000); + + expect(await provider.getChaseOrders()).toContainEqual( + expect.objectContaining({ + handle: placed.orderId, + remainingSize: '0', + restingOrderId: null, + status: CHASE_ORDER_STATUS.Filled, + }), + ); + expect(order).toHaveBeenCalledTimes(1); + }); + + it('ends the session without cancelling when the order already filled', async () => { + const order = jest.fn().mockResolvedValue({ + status: 'ok', + response: { data: { statuses: [{ resting: { oid: 55 } }] } }, + }); + const { exchangeClient } = useStrategyClients({ + exchange: { order }, + info: { + l2Book: bookThatMoves(), + // The order had already filled in full. + orderStatus: jest.fn().mockResolvedValue({ + status: 'order', + order: { + status: 'filled', + order: { + coin: 'ETH', + side: 'B', + limitPx: '2999', + sz: '0', + origSz: '1', + oid: 55, + timestamp: 1_700_000_000_000, + isTrigger: false, + triggerCondition: 'N/A', + triggerPx: '0', + children: [], + isPositionTpsl: false, + reduceOnly: false, + orderType: 'Limit', + }, + }, + }), + }, + }); + + const placed = await provider.placeOrder({ + ...baseOrder, + orderType: 'chase', + chaseIntervalMs: 1000, + } satisfies OrderParams); + + await jest.advanceTimersByTimeAsync(5000); + + // The order's level is gone from the book, so the tick verifies it rather + // than assuming, finds it filled, and ends the session — releasing its + // slot against the concurrency cap without spending a cancel on an order + // that no longer exists. + expect(order).toHaveBeenCalledTimes(1); + expect(exchangeClient.cancel).not.toHaveBeenCalled(); + + const result = await provider.cancelOrder({ + orderId: placed.orderId, + symbol: 'ETH', + orderType: 'chase', + }); + expect(result).toStrictEqual({ success: true, orderId: placed.orderId }); + }); + + it('retries on the next tick when the resting child is briefly unknown', async () => { + const order = jest + .fn() + .mockResolvedValueOnce({ + status: 'ok', + response: { data: { statuses: [{ resting: { oid: 55 } }] } }, + }) + .mockResolvedValue({ + status: 'ok', + response: { data: { statuses: [{ resting: { oid: 66 } }] } }, + }); + const { exchangeClient, infoClient } = useStrategyClients({ + exchange: { order }, + info: { l2Book: bookThatMoves() }, + }); + infoClient.orderStatus + .mockResolvedValueOnce({ status: 'unknownOid' }) + .mockResolvedValueOnce({ status: 'unknownOid' }) + .mockResolvedValueOnce({ status: 'unknownOid' }); + + const placed = await provider.placeOrder({ + ...baseOrder, + orderType: 'chase', + chaseIntervalMs: 1000, + } satisfies OrderParams); + + await jest.advanceTimersByTimeAsync(1200); + + expect(infoClient.orderStatus).toHaveBeenCalledTimes(3); + expect(exchangeClient.cancel).not.toHaveBeenCalled(); + expect(order).toHaveBeenCalledTimes(1); + expect(await provider.getChaseOrders()).toContainEqual( + expect.objectContaining({ + restingOrderId: '55', + status: CHASE_ORDER_STATUS.Active, + }), + ); + + await jest.advanceTimersByTimeAsync(1000); + + expect(order).toHaveBeenCalledTimes(2); + expect(exchangeClient.cancel).toHaveBeenCalledWith({ + cancels: [{ a: 1, o: 55 }], + }); + + expect( + await provider.cancelOrder({ + orderId: placed.orderId, + symbol: 'ETH', + orderType: 'chase', + }), + ).toStrictEqual({ + success: true, + orderId: placed.orderId, + }); + expect(exchangeClient.cancel).toHaveBeenCalledWith({ + cancels: [{ a: 1, o: 66 }], + }); + }); + }); + + describe('Strategy notional is checked against the submitted size', () => { + it('rejects a TWAP whose size-grid rounding drops it under the venue minimum', async () => { + const { exchangeClient } = useStrategyClients(); + + // A reduce-only order may never round up — the venue rejects a close + // larger than the position — so its size is floored onto the grid. $100.10 + // of ETH at 3000 floors to 0.0333, which is $99.90: under the venue's $100 + // TWAP minimum, even though the requested notional cleared it. Only a + // check against the size actually being submitted catches this. + const result = await provider.placeOrder({ + ...baseOrder, + size: '0.0334', + usdAmount: '100.10', + reduceOnly: true, + orderType: 'twap', + twapDuration: 30, + } satisfies OrderParams); + + expect(result.success).toBe(false); + expect(result.error).toBe( + PERPS_ERROR_CODES.ORDER_TWAP_NOTIONAL_TOO_SMALL, + ); + expect(exchangeClient.twapOrder).not.toHaveBeenCalled(); + }); + }); + + describe('Chase pricing against the book', () => { + beforeEach(() => { + jest.useFakeTimers(); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + /** + * A book with the given bid levels and a fixed ask. + * + * @param bids - Bid levels, best first. + * @returns The l2Book payload. + */ + const bookWithBids = ( + bids: { px: string; sz: string }[], + ): Record => ({ + coin: 'ETH', + levels: [ + bids.map((level) => ({ ...level, n: 1 })), + [{ px: '3005', sz: '10', n: 1 }], + ], + }); + + it('joins the touch when the spread is a single tick', async () => { + const order = jest.fn().mockResolvedValue({ + status: 'ok', + response: { data: { statuses: [{ resting: { oid: 55 } }] } }, + }); + useStrategyClients({ + exchange: { order }, + info: { + l2Book: jest.fn().mockResolvedValue({ + coin: 'ETH', + levels: [ + [{ px: '2999.9', sz: '10', n: 1 }], + [{ px: '3000', sz: '10', n: 1 }], + ], + }), + }, + }); + + await provider.placeOrder({ + ...baseOrder, + orderType: 'chase', + } satisfies OrderParams); + + // Improving would cross, which a post-only order cannot do, so it joins + // the bid instead of resting a tick above it. + expect(order.mock.calls[0][0].orders[0].p).toBe('2999.9'); + }); + + it('re-prices when the external touch moves behind its own order', async () => { + const order = jest + .fn() + .mockResolvedValueOnce({ + status: 'ok', + response: { data: { statuses: [{ resting: { oid: 55 } }] } }, + }) + .mockResolvedValue({ + status: 'ok', + response: { data: { statuses: [{ resting: { oid: 66 } }] } }, + }); + + // Tick 1: the chase's own 1 ETH at 2999.1 tops the book, with a real + // external bid of 2999 beneath it. Tick 2: that external bid drops to + // 2990 and only the chase's own order is left at the top. + const l2Book = jest + .fn() + .mockResolvedValueOnce(bookWithBids([{ px: '2999', sz: '10' }])) + .mockResolvedValue( + bookWithBids([ + { px: '2999.1', sz: '1' }, + { px: '2990', sz: '10' }, + ]), + ); + + useStrategyClients({ + exchange: { order }, + info: { l2Book }, + }); + + await provider.placeOrder({ + ...baseOrder, + orderType: 'chase', + chaseIntervalMs: 1000, + } satisfies OrderParams); + expect(order.mock.calls[0][0].orders[0].p).toBe('2999.1'); + + await jest.advanceTimersByTimeAsync(1000); + + // Reading the raw book would see its own 2999.1 as the best bid, conclude + // nothing had moved, and sit there while the market walked away. Netting + // its own size out of that level exposes the real 2990 touch. + expect(order).toHaveBeenCalledTimes(2); + expect(order.mock.calls[1][0].orders[0].p).toBe('2990.1'); + }); + }); + + describe('Chase concurrency cap', () => { + it("refuses a chase beyond the venue's simultaneous limit", async () => { + const { exchangeClient } = useStrategyClients({ + exchange: { + order: jest.fn().mockResolvedValue({ + status: 'ok', + response: { data: { statuses: [{ resting: { oid: 55 } }] } }, + }), + }, + }); + + for ( + let placed = 0; + placed < CHASE_ORDER_CONFIG.MaxActiveSessions; + placed++ + ) { + const result = await provider.placeOrder({ + ...baseOrder, + orderType: 'chase', + } satisfies OrderParams); + expect(result.success).toBe(true); + } + + const overflow = await provider.placeOrder({ + ...baseOrder, + orderType: 'chase', + } satisfies OrderParams); + + expect(overflow.success).toBe(false); + expect(overflow.error).toBe(PERPS_ERROR_CODES.ORDER_CHASE_LIMIT_REACHED); + expect(exchangeClient.order).toHaveBeenCalledTimes( + CHASE_ORDER_CONFIG.MaxActiveSessions, + ); + }); + + it('refuses an overflow chase before signing or changing leverage', async () => { + const { exchangeClient } = useStrategyClients({ + exchange: { + order: jest.fn().mockResolvedValue({ + status: 'ok', + response: { data: { statuses: [{ resting: { oid: 55 } }] } }, + }), + }, + }); + + for ( + let placed = 0; + placed < CHASE_ORDER_CONFIG.MaxActiveSessions; + placed += 1 + ) { + await provider.placeOrder({ + ...baseOrder, + orderType: 'chase', + } satisfies OrderParams); + } + (exchangeClient.updateLeverage as jest.Mock).mockClear(); + + const overflow = await provider.placeOrder({ + ...baseOrder, + orderType: 'chase', + leverage: 5, + } satisfies OrderParams); + + // Refused before the shared preamble, which completes the signing setup + // and applies leverage — neither should be spent on a request that was + // always going to be turned away. + expect(overflow.error).toBe(PERPS_ERROR_CODES.ORDER_CHASE_LIMIT_REACHED); + expect(exchangeClient.updateLeverage).not.toHaveBeenCalled(); + }); + + it('frees a slot when a chase is cancelled', async () => { + useStrategyClients({ + exchange: { + order: jest.fn().mockResolvedValue({ + status: 'ok', + response: { data: { statuses: [{ resting: { oid: 55 } }] } }, + }), + }, + }); + + const placed = []; + for ( + let index = 0; + index < CHASE_ORDER_CONFIG.MaxActiveSessions; + index++ + ) { + placed.push( + await provider.placeOrder({ + ...baseOrder, + orderType: 'chase', + } satisfies OrderParams), + ); + } + + await provider.cancelOrder({ + orderId: placed[0].orderId, + symbol: 'ETH', + orderType: 'chase', + }); + + const replacement = await provider.placeOrder({ + ...baseOrder, + orderType: 'chase', + } satisfies OrderParams); + expect(replacement.success).toBe(true); + }); + }); + + describe('Chase cancel racing the post-cancel remainder read', () => { + beforeEach(() => { + jest.useFakeTimers(); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + it('rests nothing when a cancel lands during the remainder read', async () => { + const order = jest + .fn() + .mockResolvedValueOnce({ + status: 'ok', + response: { data: { statuses: [{ resting: { oid: 55 } }] } }, + }) + .mockResolvedValue({ + status: 'ok', + response: { data: { statuses: [{ resting: { oid: 66 } }] } }, + }); + + let sessionId = ''; + let callerCancel: Promise | undefined; + const orderStatus = jest.fn().mockImplementation(async () => { + // The old order is already cancelled and the replacement has not gone + // up. A caller cancelling here finds nothing to cancel, reports success + // and drops the handle — so the tick must not rest anything after it. + callerCancel = provider.cancelOrder({ + orderId: sessionId, + symbol: 'ETH', + orderType: 'chase', + }); + await Promise.resolve(); + return { + status: 'order', + order: { + status: 'canceled', + order: { + coin: 'ETH', + side: 'B', + limitPx: '2999.1', + sz: '1', + origSz: '1', + oid: 55, + timestamp: 1_700_000_000_000, + isTrigger: false, + triggerCondition: 'N/A', + triggerPx: '0', + children: [], + isPositionTpsl: false, + reduceOnly: false, + orderType: 'Limit', + }, + }, + }; + }); + + useStrategyClients({ + exchange: { order }, + info: { + orderStatus, + l2Book: jest + .fn() + .mockResolvedValueOnce({ + coin: 'ETH', + levels: [ + [{ px: '2999', sz: '10', n: 1 }], + [{ px: '3001', sz: '10', n: 1 }], + ], + }) + .mockResolvedValue({ + coin: 'ETH', + levels: [ + [{ px: '2990', sz: '10', n: 1 }], + [{ px: '3001', sz: '10', n: 1 }], + ], + }), + }, + }); + + const placed = await provider.placeOrder({ + ...baseOrder, + orderType: 'chase', + chaseIntervalMs: 1000, + } satisfies OrderParams); + sessionId = placed.orderId as string; + + await jest.advanceTimersByTimeAsync(1000); + await callerCancel; + + // Only the original placement: no unreachable replacement was left live. + expect(order).toHaveBeenCalledTimes(1); + }); + }); + + describe('Chase state when a post-cancel read fails', () => { + beforeEach(() => { + jest.useFakeTimers(); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + it('retries a transient unknown status before replacing the child', async () => { + const order = jest + .fn() + .mockResolvedValueOnce({ + status: 'ok', + response: { data: { statuses: [{ resting: { oid: 55 } }] } }, + }) + .mockResolvedValueOnce({ + status: 'ok', + response: { data: { statuses: [{ resting: { oid: 66 } }] } }, + }); + const cancelledOrderStatus = { + status: 'order', + order: { + status: 'canceled', + order: { + coin: 'ETH', + side: 'B', + limitPx: '2999.1', + sz: '1', + origSz: '1', + oid: 55, + timestamp: 1_700_000_000_000, + isTrigger: false, + triggerCondition: 'N/A', + triggerPx: '0', + children: [], + isPositionTpsl: false, + reduceOnly: false, + orderType: 'Limit', + }, + }, + }; + const orderStatus = jest + .fn() + .mockResolvedValueOnce({ + ...cancelledOrderStatus, + order: { ...cancelledOrderStatus.order, status: 'open' }, + }) + .mockResolvedValueOnce({ status: 'unknownOid' }) + .mockResolvedValueOnce(cancelledOrderStatus); + useStrategyClients({ + exchange: { order }, + info: { + orderStatus, + l2Book: jest + .fn() + .mockResolvedValueOnce({ + coin: 'ETH', + levels: [ + [{ px: '2999', sz: '10', n: 1 }], + [{ px: '3001', sz: '10', n: 1 }], + ], + }) + .mockResolvedValue({ + coin: 'ETH', + levels: [ + [{ px: '2990', sz: '10', n: 1 }], + [{ px: '3001', sz: '10', n: 1 }], + ], + }), + }, + }); + + const placed = await provider.placeOrder({ + ...baseOrder, + orderType: 'chase', + chaseIntervalMs: 1000, + } satisfies OrderParams); + + await jest.advanceTimersByTimeAsync(1100); + + expect(orderStatus).toHaveBeenCalledTimes(3); + expect(order).toHaveBeenCalledTimes(2); + expect(placed.success).toBe(true); + expect(order.mock.calls[1][0].orders[0]).toMatchObject({ s: '1' }); + }); + + it('does not retain the cancelled child as a Chase route after a fill', async () => { + const order = jest.fn().mockResolvedValue({ + status: 'ok', + response: { data: { statuses: [{ resting: { oid: 55 } }] } }, + }); + const orderStatus = ( + status: 'open' | 'filled', + size: string, + ): Record => ({ + status: 'order', + order: { + status, + order: { + coin: 'ETH', + side: 'B', + limitPx: '2999.1', + sz: size, + origSz: '1', + oid: 55, + timestamp: 1_700_000_000_000, + isTrigger: false, + triggerCondition: 'N/A', + triggerPx: '0', + children: [], + isPositionTpsl: false, + reduceOnly: false, + orderType: 'Limit', + }, + }, + }); + const { exchangeClient } = useStrategyClients({ + exchange: { order }, + info: { + orderStatus: jest + .fn() + .mockResolvedValueOnce(orderStatus('open', '1')) + .mockResolvedValueOnce(orderStatus('filled', '0')), + l2Book: jest + .fn() + .mockResolvedValueOnce({ + coin: 'ETH', + levels: [ + [{ px: '2999', sz: '10', n: 1 }], + [{ px: '3001', sz: '10', n: 1 }], + ], + }) + .mockResolvedValue({ + coin: 'ETH', + levels: [ + [{ px: '2990', sz: '10', n: 1 }], + [{ px: '3001', sz: '10', n: 1 }], + ], + }), + }, + }); + + await provider.placeOrder({ + ...baseOrder, + orderType: 'chase', + chaseIntervalMs: 1000, + } satisfies OrderParams); + await jest.advanceTimersByTimeAsync(1000); + + await provider.cancelOrder({ orderId: '55', symbol: 'ETH' }); + + expect(exchangeClient.cancel).toHaveBeenCalledTimes(2); + expect(exchangeClient.cancel).toHaveBeenLastCalledWith({ + cancels: [{ a: 1, o: 55 }], + }); + }); + + it('ends the session rather than rescheduling a chase with nothing resting', async () => { + const order = jest.fn().mockResolvedValue({ + status: 'ok', + response: { data: { statuses: [{ resting: { oid: 55 } }] } }, + }); + const { exchangeClient } = useStrategyClients({ + exchange: { order }, + info: { + // The cancel succeeds, then the remainder lookup fails. + orderStatus: jest.fn().mockRejectedValue(new Error('network blip')), + l2Book: jest + .fn() + .mockResolvedValueOnce({ + coin: 'ETH', + levels: [ + [{ px: '2999', sz: '10', n: 1 }], + [{ px: '3001', sz: '10', n: 1 }], + ], + }) + .mockResolvedValue({ + coin: 'ETH', + levels: [ + [{ px: '2990', sz: '10', n: 1 }], + [{ px: '3001', sz: '10', n: 1 }], + ], + }), + }, + }); + + const placed = await provider.placeOrder({ + ...baseOrder, + orderType: 'chase', + chaseIntervalMs: 1000, + } satisfies OrderParams); + + await jest.advanceTimersByTimeAsync(5000); + + // The order was cancelled, so nothing rests. Recovery must not keep + // ticking a session that has no live order. + expect(exchangeClient.cancel).toHaveBeenCalledTimes(1); + expect(order).toHaveBeenCalledTimes(1); + + // And the handle releases cleanly instead of reporting an incomplete + // cancel against an order that is already gone. + const result = await provider.cancelOrder({ + orderId: placed.orderId, + symbol: 'ETH', + orderType: 'chase', + }); + expect(result).toStrictEqual({ success: true, orderId: placed.orderId }); + }); + }); + + describe('Chase concurrency cap under concurrent placement', () => { + it('does not exceed the cap when placements overlap', async () => { + const order = jest.fn().mockImplementation(async () => { + // Every placement is in flight at once: the cap is checked before this + // resolves, so a check that does not reserve its slot lets them all in. + await Promise.resolve(); + return { + status: 'ok', + response: { data: { statuses: [{ resting: { oid: 55 } }] } }, + }; + }); + const { exchangeClient } = useStrategyClients({ exchange: { order } }); + + const attempts = CHASE_ORDER_CONFIG.MaxActiveSessions + 3; + const results = await Promise.all( + Array.from({ length: attempts }, async () => + provider.placeOrder({ + ...baseOrder, + orderType: 'chase', + } satisfies OrderParams), + ), + ); + + const accepted = results.filter((result) => result.success); + expect(accepted).toHaveLength(CHASE_ORDER_CONFIG.MaxActiveSessions); + expect(exchangeClient.order).toHaveBeenCalledTimes( + CHASE_ORDER_CONFIG.MaxActiveSessions, + ); + results + .filter((result) => !result.success) + .forEach((result) => { + expect(result.error).toBe( + PERPS_ERROR_CODES.ORDER_CHASE_LIMIT_REACHED, + ); + }); + }); + }); + + describe('Chase netting uses the live resting size', () => { + beforeEach(() => { + jest.useFakeTimers(); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + it('resolves ambiguous Chase children concurrently', async () => { + const pendingFirstStatus = createDeferred>(); + const openStatus = (oid: number): Record => ({ + status: 'order', + order: { + status: 'open', + order: { + coin: 'ETH', + side: 'B', + limitPx: '2999.1', + sz: '1', + origSz: '1', + oid, + timestamp: 1_700_000_000_000, + isTrigger: false, + triggerCondition: 'N/A', + triggerPx: '0', + children: [], + isPositionTpsl: false, + reduceOnly: false, + orderType: 'Limit', + }, + }, + }); + const orderStatus = jest + .fn() + .mockImplementation(({ oid }) => + oid === 55 + ? pendingFirstStatus.promise + : Promise.resolve(openStatus(oid)), + ); + const order = jest + .fn() + .mockResolvedValueOnce({ + status: 'ok', + response: { data: { statuses: [{ resting: { oid: 55 } }] } }, + }) + .mockResolvedValueOnce({ + status: 'ok', + response: { data: { statuses: [{ resting: { oid: 66 } }] } }, + }) + .mockResolvedValue({ + status: 'ok', + response: { data: { statuses: [{ resting: { oid: 77 } }] } }, + }); + const l2Book = jest + .fn() + .mockResolvedValueOnce({ + coin: 'ETH', + levels: [ + [{ px: '2999', sz: '10', n: 1 }], + [{ px: '3001', sz: '10', n: 1 }], + ], + }) + .mockResolvedValueOnce({ + coin: 'ETH', + levels: [ + [ + { px: '2999.1', sz: '2', n: 2 }, + { px: '2999', sz: '10', n: 1 }, + ], + [{ px: '3001', sz: '10', n: 1 }], + ], + }) + .mockResolvedValue({ + coin: 'ETH', + levels: [ + [ + { px: '2999.2', sz: '1', n: 1 }, + { px: '2999.1', sz: '1', n: 1 }, + { px: '2998', sz: '10', n: 1 }, + ], + [{ px: '3001', sz: '10', n: 1 }], + ], + }); + useStrategyClients({ + exchange: { order }, + info: { l2Book, orderStatus }, + }); + + const first = await provider.placeOrder({ + ...baseOrder, + orderType: 'chase', + chaseIntervalMs: 1000, + } satisfies OrderParams); + const second = await provider.placeOrder({ + ...baseOrder, + orderType: 'chase', + chaseIntervalMs: 1000, + } satisfies OrderParams); + if (!first.orderId || !second.orderId) { + throw new Error('Expected both Chase handles'); + } + + jest.advanceTimersByTime(1000); + for ( + let index = 0; + index < 10 && orderStatus.mock.calls.length < 2; + index++ + ) { + await Promise.resolve(); + } + + expect( + orderStatus.mock.calls.map(([params]) => params.oid), + ).toStrictEqual([55, 66]); + + pendingFirstStatus.resolve(openStatus(55)); + for (let index = 0; index < 10; index++) { + await Promise.resolve(); + } + }); + + it('sees external liquidity sharing its level after a partial fill', async () => { + const order = jest.fn().mockResolvedValue({ + status: 'ok', + response: { data: { statuses: [{ resting: { oid: 55 } }] } }, + }); + + // The chase placed 1 ETH at 2999.1 and 0.8 of it has since filled, so + // only 0.2 is its own. The level shows 0.5 — the other 0.3 is external, + // and it is still the best bid. + const orderStatus = jest.fn().mockResolvedValue({ + status: 'order', + order: { + status: 'open', + order: { + coin: 'ETH', + side: 'B', + limitPx: '2999.1', + sz: '0.2', + origSz: '1', + oid: 55, + timestamp: 1_700_000_000_000, + isTrigger: false, + triggerCondition: 'N/A', + triggerPx: '0', + children: [], + isPositionTpsl: false, + reduceOnly: false, + orderType: 'Limit', + }, + }, + }); + + useStrategyClients({ + exchange: { order }, + info: { + orderStatus, + l2Book: jest + .fn() + .mockResolvedValueOnce({ + coin: 'ETH', + levels: [ + [{ px: '2999', sz: '10', n: 1 }], + [{ px: '3001', sz: '10', n: 1 }], + ], + }) + .mockResolvedValue({ + coin: 'ETH', + levels: [ + [ + { px: '2999.1', sz: '0.5', n: 2 }, + { px: '2900', sz: '10', n: 1 }, + ], + [{ px: '3001', sz: '10', n: 1 }], + ], + }), + }, + }); + + await provider.placeOrder({ + ...baseOrder, + orderType: 'chase', + chaseIntervalMs: 1000, + } satisfies OrderParams); + + await jest.advanceTimersByTimeAsync(1000); + + // Netting the live 0.2 leaves 0.3 of external size at 2999.1, so that is + // still the best external bid and the chase improves on it by a tick. + // Netting the stale 1 ETH would wipe the level out entirely and quote off + // the 2900 level below — 99 dollars away from the real touch. + expect(orderStatus).toHaveBeenCalled(); + expect(order).toHaveBeenCalledTimes(2); + expect(order.mock.calls[1][0].orders[0].p).toBe('2999.2'); + }); + }); + + describe('Strategy placement racing a disconnect', () => { + it('retracts a TWAP that finishes after teardown through its captured client', async () => { + let disconnected: Promise | undefined; + const twapOrder = jest.fn().mockImplementation(async () => { + disconnected = provider.disconnect(); + mockClientService.getExchangeClient.mockImplementation(() => { + throw new Error(PERPS_ERROR_CODES.EXCHANGE_CLIENT_NOT_AVAILABLE); + }); + await Promise.resolve(); + return { + status: 'ok', + response: { + type: 'twapOrder', + data: { status: { running: { twapId: 987 } } }, + }, + }; + }); + const twapCancel = jest.fn().mockResolvedValue({ + status: 'ok', + response: { type: 'twapCancel', data: { status: 'success' } }, + }); + const { exchangeClient } = useStrategyClients({ + exchange: { twapOrder, twapCancel }, + }); + + const placed = await provider.placeOrder({ + ...baseOrder, + orderType: 'twap', + twapDuration: 30, + } satisfies OrderParams); + await disconnected; + + expect(exchangeClient.twapCancel).toHaveBeenCalledWith({ a: 1, t: 987 }); + expect(placed).toMatchObject({ + success: false, + error: PERPS_ERROR_CODES.PROVIDER_LIFECYCLE_STALE, + submittedSize: '1', + }); + expect(placed.orderId).toBeUndefined(); + }); + + it('treats an already-finished stale TWAP as retracted', async () => { + let disconnected: Promise | undefined; + const twapOrder = jest.fn().mockImplementation(async () => { + disconnected = provider.disconnect(); + await Promise.resolve(); + return { + status: 'ok', + response: { + type: 'twapOrder', + data: { status: { running: { twapId: 987 } } }, + }, + }; + }); + const twapCancel = jest.fn().mockResolvedValue({ + status: 'ok', + response: { + type: 'twapCancel', + data: { status: { error: 'Twap not found' } }, + }, + }); + useStrategyClients({ exchange: { twapOrder, twapCancel } }); + + const placed = await provider.placeOrder({ + ...baseOrder, + orderType: 'twap', + twapDuration: 30, + } satisfies OrderParams); + await disconnected; + + expect(placed).toStrictEqual({ + success: false, + error: PERPS_ERROR_CODES.PROVIDER_LIFECYCLE_STALE, + submittedSize: '1', + }); + }); + + it('retracts resting scale rungs and registers no stale group after teardown', async () => { + let disconnected: Promise | undefined; + const order = jest.fn().mockImplementation(async () => { + disconnected = provider.disconnect(); + mockClientService.getExchangeClient.mockImplementation(() => { + throw new Error(PERPS_ERROR_CODES.EXCHANGE_CLIENT_NOT_AVAILABLE); + }); + await Promise.resolve(); + return { + status: 'ok', + response: { + data: { + statuses: [ + { resting: { oid: 11 } }, + { filled: { oid: 22 } }, + { resting: { oid: 33 } }, + ], + }, + }, + }; + }); + const cancel = jest.fn().mockResolvedValue({ + status: 'ok', + response: { data: { statuses: ['success', 'success'] } }, + }); + const { exchangeClient } = useStrategyClients({ + exchange: { order, cancel }, + }); + + const placed = await provider.placeOrder({ + ...baseOrder, + orderType: 'scale', + scaleMinPrice: '2000', + scaleMaxPrice: '3000', + scaleNumOrders: 3, + } satisfies OrderParams); + await disconnected; + + expect(exchangeClient.cancel).toHaveBeenCalledWith({ + cancels: [ + { a: 1, o: 11 }, + { a: 1, o: 33 }, + ], + }); + expect(placed).toMatchObject({ + success: false, + error: PERPS_ERROR_CODES.PROVIDER_LIFECYCLE_STALE, + submittedSize: '1', + }); + expect(placed.orderId).toBeUndefined(); + expect(placed.childOrderIds).toStrictEqual(['22']); + }); + }); + + describe('Chase placement racing a disconnect', () => { + it('keeps placement blocked until every overlapping lifecycle owner exits', async () => { + let releaseDisconnect: (() => void) | undefined; + mockClientService.disconnect.mockImplementationOnce( + () => + new Promise((resolve) => { + releaseDisconnect = resolve; + }), + ); + const { exchangeClient } = useStrategyClients(); + + const disconnecting = provider.disconnect(); + await provider.suspendChaseOrders(); + const placed = await provider.placeOrder({ + ...baseOrder, + orderType: 'chase', + } as OrderParams); + releaseDisconnect?.(); + await disconnecting; + + expect(placed.success).toBe(false); + expect(placed.error).toBe(PERPS_ERROR_CODES.ORDER_CHASE_ABANDONED); + expect(exchangeClient.order).not.toHaveBeenCalled(); + }); + + it('rejects a placement admitted after disconnect starts', async () => { + const { exchangeClient } = useStrategyClients(); + + const disconnecting = provider.disconnect(); + const placed = await provider.placeOrder({ + ...baseOrder, + orderType: 'chase', + } as OrderParams); + await disconnecting; + + expect(placed.success).toBe(false); + expect(placed.error).toBe(PERPS_ERROR_CODES.ORDER_CHASE_ABANDONED); + expect(exchangeClient.order).not.toHaveBeenCalled(); + }); + + /** + * Tear the provider down from inside the mid-flight order submission. + * + * The teardown models what `HyperLiquidClientService.disconnect` does: + * it drops the service's client reference synchronously, so every later + * `getExchangeClient` throws, while the instance already handed out keeps + * working. Anything the provider wants to do about its in-flight order has + * to be done through the client it is already holding. + * + * @param cancel - The cancel the retraction attempt will get back. + * @returns The placement result and the exchange client that served it. + */ + const placeChaseTornDownMidFlight = async ( + cancel: jest.Mock, + ): Promise<{ placed: OrderResult; exchangeClient: MockClient }> => { + let disconnected: Promise | undefined; + const order = jest.fn().mockImplementation(async () => { + // The order is on its way to the venue when the provider is torn down. + disconnected = provider.disconnect(); + mockClientService.getExchangeClient.mockImplementation(() => { + throw new Error(PERPS_ERROR_CODES.EXCHANGE_CLIENT_NOT_AVAILABLE); + }); + await Promise.resolve(); + return { + status: 'ok', + response: { data: { statuses: [{ resting: { oid: 55 } }] } }, + }; + }); + const { exchangeClient } = useStrategyClients({ + exchange: { order, cancel }, + }); + + const placed = await provider.placeOrder({ + ...baseOrder, + orderType: 'chase', + chaseIntervalMs: 1000, + } satisfies OrderParams); + await disconnected; + + return { placed, exchangeClient }; + }; + + it('retracts the order it could not put a strategy behind', async () => { + const cancel = jest.fn().mockResolvedValue({ + status: 'ok', + response: { data: { statuses: ['success'] } }, + }); + const { placed, exchangeClient } = + await placeChaseTornDownMidFlight(cancel); + + // The placement is reported as a failure, so nothing downstream treats it + // as an order that exists: the caches a successful placement invalidates + // are not invalidated, and no handle names it. Leaving it resting would + // make that report false at the venue — and the exchange id is only good + // while this provider still points at the account that placed it, which a + // disconnect is the usual prelude to changing. So it is taken back and the + // failure is true on both sides. + // + // The teardown has already made `getExchangeClient` throw, so the cancel + // can only have gone through the instance captured before the submission. + // A provider that looked a client up at retraction time would never have + // sent this request at all. + expect(exchangeClient.cancel).toHaveBeenCalledWith({ + cancels: [{ a: 1, o: 55 }], + }); + expect(placed.success).toBe(false); + expect(placed.error).toBe(PERPS_ERROR_CODES.ORDER_CHASE_ABANDONED); + expect(placed.orderId).toBeUndefined(); + // Nothing rests, so there is no id worth handing back: naming one would + // send the caller to cancel an order that is already gone. + expect(placed.childOrderIds).toBeUndefined(); + }); + + it('reports the resting order when it cannot be retracted', async () => { + // The venue refuses the cancel and the order stays on the book. This is + // the only case where the caller is left holding a live order, so it is + // the only one that gets an id to reach it with. + const cancel = jest.fn().mockResolvedValue({ + status: 'ok', + response: { + data: { statuses: [{ error: 'Order could not be found' }] }, + }, + }); + const { placed, exchangeClient } = + await placeChaseTornDownMidFlight(cancel); + + expect(placed.success).toBe(false); + expect(placed.error).toBe(PERPS_ERROR_CODES.ORDER_CHASE_ABANDONED); + expect(placed.childOrderIds).toStrictEqual(['55']); + + // Reachable through the ordinary single-order cancel, which is the only + // route that can name it once no session holds it. That cancel runs on a + // provider that has since reconnected, so the service serves a client + // again — the id is only worth anything while that client still signs as + // the account the order was placed under. + mockClientService.getExchangeClient.mockReturnValue( + exchangeClient as never, + ); + await provider.initialize(); + const cancelled = await provider.cancelOrder({ + orderId: (placed.childOrderIds as string[])[0], + symbol: 'ETH', + }); + expect(cancelled.orderId).toBe('55'); + }); + + it('reports the resting order when the retraction itself fails', async () => { + // A provider mid-teardown can fail the cancel outright rather than have + // it refused. Best-effort by construction: the placement still resolves + // as an abandoned chase rather than throwing the cancel's error. + const cancel = jest.fn().mockRejectedValue(new Error('client torn down')); + const { placed } = await placeChaseTornDownMidFlight(cancel); + + expect(placed.success).toBe(false); + expect(placed.error).toBe(PERPS_ERROR_CODES.ORDER_CHASE_ABANDONED); + expect(placed.childOrderIds).toStrictEqual(['55']); + }); + + it('retains legacy HIP-3 collateral when an orphaned child still rests', async () => { + let disconnected: Promise | undefined; + let transferred = false; + const order = jest.fn().mockImplementation(async () => { + disconnected = provider.disconnect(); + mockClientService.getExchangeClient.mockImplementation(() => { + throw new Error(PERPS_ERROR_CODES.EXCHANGE_CLIENT_NOT_AVAILABLE); + }); + await Promise.resolve(); + return { + status: 'ok', + response: { data: { statuses: [{ resting: { oid: 55 } }] } }, + }; + }); + useStrategyClients({ + exchange: { + order, + cancel: jest.fn().mockResolvedValue({ + status: 'ok', + response: { + data: { statuses: [{ error: 'Order could not be found' }] }, + }, + }), + }, + info: { + clearinghouseState: jest + .fn() + .mockImplementation(({ dex }) => + Promise.resolve( + createClearinghouseBalance( + dex === 'xyz' && !transferred ? '0' : '1000', + ), + ), + ), + perpDexs: jest.fn().mockResolvedValue([null, { name: 'xyz' }]), + meta: jest.fn().mockResolvedValue({ + universe: [{ name: 'xyz:TSLA', szDecimals: 3, maxLeverage: 20 }], + collateralToken: 0, + }), + allMids: jest.fn().mockResolvedValue({ 'xyz:TSLA': '3000' }), + }, + }); + provider = createTestProvider({ + hip3Enabled: true, + allowlistMarkets: ['xyz:*'], + useUnifiedAccount: false, + initialAssetMapping: [['xyz:TSLA', 110000]], + }); + const transfer = jest + .spyOn(provider, 'transferBetweenDexs') + .mockImplementation(async () => { + transferred = true; + return { success: true }; + }); + + const placed = await provider.placeOrder({ + ...baseOrder, + symbol: 'xyz:TSLA', + orderType: 'chase', + } satisfies OrderParams); + await disconnected; + + expect(placed).toMatchObject({ + success: false, + error: PERPS_ERROR_CODES.ORDER_CHASE_ABANDONED, + childOrderIds: ['55'], + }); + expect(transfer).toHaveBeenCalledTimes(1); + }); + + it('places nothing when the teardown lands during the book read', async () => { + let disconnected: Promise | undefined; + const { exchangeClient } = useStrategyClients({ + info: { + // The book read is a round trip after the preamble's own checks, so + // a disconnect can land inside it. + l2Book: jest.fn().mockImplementation(async () => { + disconnected = provider.disconnect(); + await Promise.resolve(); + return { + coin: 'ETH', + levels: [ + [{ px: '2999', sz: '10', n: 1 }], + [{ px: '3001', sz: '10', n: 1 }], + ], + }; + }), + }, + }); + + const placed = await provider.placeOrder({ + ...baseOrder, + orderType: 'chase', + chaseIntervalMs: 1000, + } satisfies OrderParams); + await disconnected; + + // Nothing is signed: the check between the book read and the submission + // means no window between two awaits on this path ends in a fresh order + // for a provider that has already stopped. + expect(exchangeClient.order).not.toHaveBeenCalled(); + expect(exchangeClient.cancel).not.toHaveBeenCalled(); + expect(placed.success).toBe(false); + expect(placed.error).toBe(PERPS_ERROR_CODES.ORDER_CHASE_ABANDONED); + expect(placed.childOrderIds).toBeUndefined(); + }); + }); + + describe('Two chases on the same side do not leapfrog each other', () => { + beforeEach(() => { + jest.useFakeTimers(); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + it('treats another session of its own as not-external liquidity', async () => { + let placements = 0; + const order = jest.fn().mockImplementation(async () => { + placements += 1; + return { + status: 'ok', + response: { + data: { statuses: [{ resting: { oid: 54 + placements } }] }, + }, + }; + }); + + // The external bid never moves. The first two reads are the placements, + // when nothing of ours is resting yet; afterwards the book carries the + // two chases' combined 2 ETH at 2999.1 on top of the external 2999. + let bookReads = 0; + const l2Book = jest.fn().mockImplementation(async () => { + bookReads += 1; + const bids = + bookReads <= 2 + ? [{ px: '2999', sz: '10', n: 1 }] + : [ + { px: '2999.1', sz: '2', n: 2 }, + { px: '2999', sz: '10', n: 1 }, + ]; + return { + coin: 'ETH', + levels: [bids, [{ px: '3001', sz: '10', n: 1 }]], + }; + }); + + useStrategyClients({ exchange: { order }, info: { l2Book } }); + + await provider.placeOrder({ + ...baseOrder, + orderType: 'chase', + chaseIntervalMs: 1000, + } satisfies OrderParams); + await provider.placeOrder({ + ...baseOrder, + orderType: 'chase', + chaseIntervalMs: 1000, + } satisfies OrderParams); + expect(order).toHaveBeenCalledTimes(2); + + await jest.advanceTimersByTimeAsync(5000); + + // Netting only its own order, each session would read the other's 1 ETH + // as external size at 2999.1, improve to 2999.2, then improve on that — + // walking each other up an unchanged market. Netting everything this + // provider holds leaves 2999 as the real touch, the quote unchanged, and + // no re-price at all. + expect(order).toHaveBeenCalledTimes(2); + }); + }); + + describe('Chase fee rate against the user schedule', () => { + it('quotes the maker rate even when the account has its own fee tier', async () => { + const { infoClient } = useStrategyClients({ + info: { + // These are the fields the fee path actually reads; a `feeSchedule` + // shape parses to NaN and falls back to the base rates, which would + // make this test pass without ever entering the branch it is about. + userFees: jest.fn().mockResolvedValue({ + userCrossRate: '0.00030', + userAddRate: '0.00010', + userSpotCrossRate: '0.00040', + userSpotAddRate: '0.00020', + activeReferralDiscount: '0', + dailyUserVlm: [], + }), + }, + }); + + const chase = await provider.calculateFees({ + orderType: 'chase', + isMaker: false, + amount: '1000', + symbol: 'ETH', + }); + const market = await provider.calculateFees({ + orderType: 'market', + isMaker: false, + amount: '1000', + symbol: 'ETH', + }); + + // The account's own schedule was consulted, not the fallback base rates. + expect(infoClient.userFees).toHaveBeenCalled(); + // A chase is post-only whatever the caller passes, and that has to hold + // for the account's own schedule as well as the base rates. + expect(chase.feeRate).toBeLessThan(market.feeRate); + }); + }); + + describe('Chase placement racing a disconnect during preparation', () => { + it('places nothing when the teardown lands during preparation', async () => { + let disconnected: Promise | undefined; + const { exchangeClient } = useStrategyClients({ + exchange: { + // The leverage update is part of the shared preamble, before the + // chase handler is even reached. + updateLeverage: jest.fn().mockImplementation(async () => { + disconnected = provider.disconnect(); + await Promise.resolve(); + return { status: 'ok' }; + }), + order: jest.fn().mockResolvedValue({ + status: 'ok', + response: { data: { statuses: [{ resting: { oid: 55 } }] } }, + }), + }, + }); + + const placed = await provider.placeOrder({ + ...baseOrder, + orderType: 'chase', + leverage: 5, + chaseIntervalMs: 1000, + } satisfies OrderParams); + await disconnected; + + // The disconnect landed during the preamble, which is before the chase + // reads the book or submits anything — so no order is created for a + // provider that has been torn down, and there is nothing left resting to + // report. + expect(exchangeClient.order).not.toHaveBeenCalled(); + expect(placed.success).toBe(false); + expect(placed.error).toBe(PERPS_ERROR_CODES.ORDER_CHASE_ABANDONED); + expect(placed.childOrderIds).toBeUndefined(); + }); + }); +}); diff --git a/packages/perps-controller/tests/src/providers/HyperLiquidProvider.trading.test.ts b/packages/perps-controller/tests/src/providers/HyperLiquidProvider.trading.test.ts new file mode 100644 index 00000000000..9c7dd260e31 --- /dev/null +++ b/packages/perps-controller/tests/src/providers/HyperLiquidProvider.trading.test.ts @@ -0,0 +1,3656 @@ +/* eslint-disable */ +jest.mock('@nktkas/hyperliquid', () => ({})); + +import type { CaipAssetId, Hex } from '@metamask/utils'; + +import { CandlePeriod } from '../../../src/constants/chartConfig.js'; +import { + BUILDER_FEE_CONFIG, + REFERRAL_CONFIG, +} from '../../../src/constants/hyperLiquidConfig.js'; +import { PERPS_TRANSACTIONS_HISTORY_CONSTANTS } from '../../../src/constants/transactionsHistoryConfig.js'; +import { PERPS_ERROR_CODES } from '../../../src/perpsErrorCodes.js'; +import { HyperLiquidProvider } from '../../../src/providers/HyperLiquidProvider.js'; +import { HyperLiquidClientService } from '../../../src/services/HyperLiquidClientService.js'; +import { HyperLiquidSubscriptionService } from '../../../src/services/HyperLiquidSubscriptionService.js'; +import { HyperLiquidWalletService } from '../../../src/services/HyperLiquidWalletService.js'; +import { TradingReadinessCache } from '../../../src/services/TradingReadinessCache.js'; +import type { + ClosePositionParams, + DepositParams, + Order, + PerpsPlatformDependencies, + LiveDataConfig, + OrderParams, + Position, +} from '../../../src/types/index.js'; +import { + validateAssetSupport, + validateBalance, + validateCoinExists, + validateDepositParams, + validateOrderParams, + validateWithdrawalParams, +} from '../../../src/utils/hyperLiquidValidation.js'; +import { createStandaloneInfoClient } from '../../../src/utils/standaloneInfoClient.js'; +import { + createMockInfrastructure, + createMockMessenger, +} from '../../helpers/serviceMocks.js'; + +jest.mock('../../../src/services/HyperLiquidClientService'); +jest.mock('../../../src/services/HyperLiquidWalletService'); +jest.mock('../../../src/services/HyperLiquidSubscriptionService'); +// Mock stream manager - will be set up in test +let mockStreamManagerInstance: any; +const mockGetStreamManagerInstance = jest.fn(() => mockStreamManagerInstance); +jest.mock( + '../../../../components/UI/Perps/providers/PerpsStreamManager', + () => ({ + getStreamManagerInstance: mockGetStreamManagerInstance, + }), + { virtual: true }, +); + +// Mock standalone info client for standalone mode tests +let mockStandaloneInfoClient: any; +jest.mock('../../../src/utils/standaloneInfoClient', () => ({ + ...jest.requireActual('../../../src/utils/standaloneInfoClient'), + createStandaloneInfoClient: jest.fn(() => mockStandaloneInfoClient), +})); + +jest.mock('../../../src/utils/hyperLiquidValidation', () => ({ + validateOrderParams: jest.fn(), + validateWithdrawalParams: jest.fn(), + validateDepositParams: jest.fn(), + validateCoinExists: jest.fn(), + validateAssetSupport: jest.fn(), + validateBalance: jest.fn(), + getSupportedPaths: jest + .fn() + .mockReturnValue([ + 'eip155:42161/erc20:0xa0b86a33e6776e681a06e0e1622c5e5e3e6a8b13/default', + 'eip155:1/erc20:0xa0b86a33e6776e681a06e0e1622c5e5e3e6a8b13/default', + ]), + getBridgeInfo: jest.fn().mockReturnValue({ + chainId: 'eip155:42161', + contractAddress: '0x1234567890123456789012345678901234567890', + }), + createErrorResult: jest.fn((error, defaultResponse) => ({ + ...defaultResponse, + success: false, + error: error instanceof Error ? error.message : String(error), + })), +})); + +// Mock adapter functions +jest.mock('../../../src/utils/hyperLiquidAdapter', () => { + const actual = jest.requireActual('../../../src/utils/hyperLiquidAdapter'); + return { + ...actual, + adaptHyperLiquidLedgerUpdateToUserHistoryItem: jest.fn((updates) => { + // Return mock history items based on input + if (!updates || !Array.isArray(updates) || updates.length === 0) { + return []; + } + return updates.map((_update: unknown) => ({ + type: 'deposit' as const, + amount: '100', + timestamp: Date.now(), + hash: '0x123', + })); + }), + }; +}); + +// Mock TradingReadinessCache - global singleton for signing operation caching +// Use jest.createMockFromModule for proper mock creation +jest.mock('../../../src/services/TradingReadinessCache'); + +const MockedHyperLiquidClientService = + HyperLiquidClientService as jest.MockedClass; +const MockedHyperLiquidWalletService = + HyperLiquidWalletService as jest.MockedClass; +const MockedHyperLiquidSubscriptionService = + HyperLiquidSubscriptionService as jest.MockedClass< + typeof HyperLiquidSubscriptionService + >; +const mockValidateOrderParams = validateOrderParams as jest.MockedFunction< + typeof validateOrderParams +>; +const mockValidateWithdrawalParams = + validateWithdrawalParams as jest.MockedFunction< + typeof validateWithdrawalParams + >; +const mockValidateDepositParams = validateDepositParams as jest.MockedFunction< + typeof validateDepositParams +>; +const mockValidateCoinExists = validateCoinExists as jest.MockedFunction< + typeof validateCoinExists +>; +const mockValidateAssetSupport = validateAssetSupport as jest.MockedFunction< + typeof validateAssetSupport +>; +const mockValidateBalance = validateBalance as jest.MockedFunction< + typeof validateBalance +>; + +// Mock factory functions - defined once, reused everywhere +// These reduce duplication and make tests more maintainable +const createMockInfoClient = (overrides: Record = {}) => ({ + clearinghouseState: jest.fn().mockResolvedValue({ + marginSummary: { + totalMarginUsed: '500', + accountValue: '10500', + }, + withdrawable: '9500', + assetPositions: [ + { + position: { + coin: 'BTC', + szi: '0.1', + entryPx: '50000', + positionValue: '5000', + unrealizedPnl: '100', + marginUsed: '500', + leverage: { type: 'cross', value: 10 }, + liquidationPx: '45000', + maxLeverage: 50, + returnOnEquity: '20', + cumFunding: { allTime: '10', sinceOpen: '5', sinceChange: '2' }, + }, + type: 'oneWay', + }, + { + position: { + coin: 'ETH', + szi: '1.5', + entryPx: '3000', + positionValue: '4500', + unrealizedPnl: '50', + marginUsed: '450', + leverage: { type: 'cross', value: 10 }, + liquidationPx: '2700', + maxLeverage: 50, + returnOnEquity: '10', + cumFunding: { allTime: '5', sinceOpen: '2', sinceChange: '1' }, + }, + type: 'oneWay', + }, + ], + crossMarginSummary: { + accountValue: '10000', + totalMarginUsed: '5000', + }, + }), + spotClearinghouseState: jest.fn().mockResolvedValue({ + balances: [{ coin: 'USDC', hold: '1000', total: '10000' }], + }), + // Mode-aware fold gate reads userAbstraction; default to unifiedAccount + // so tests that predated the gate still see spot folded into spendable/withdrawable. + userAbstraction: jest.fn().mockResolvedValue('unifiedAccount'), + meta: jest.fn().mockResolvedValue({ + universe: [ + { name: 'BTC', szDecimals: 3, maxLeverage: 50 }, + { name: 'ETH', szDecimals: 4, maxLeverage: 50 }, + ], + }), + metaAndAssetCtxs: jest.fn().mockResolvedValue([ + { + universe: [ + { name: 'BTC', szDecimals: 3, maxLeverage: 50 }, + { name: 'ETH', szDecimals: 4, maxLeverage: 50 }, + ], + }, + [ + { + funding: '0.0001', + openInterest: '1000', + prevDayPx: '49000', + dayNtlVlm: '1000000', + markPx: '50000', + midPx: '50000', + oraclePx: '50000', + }, + { + funding: '0.0001', + openInterest: '500', + prevDayPx: '2900', + dayNtlVlm: '500000', + markPx: '3000', + midPx: '3000', + oraclePx: '3000', + }, + ], + ]), + perpDexs: jest.fn().mockResolvedValue([null]), + allMids: jest.fn().mockResolvedValue({ BTC: '50000', ETH: '3000' }), + // editOrder verifies the resting order's placement type before modifying it, + // so the account lists the plain limit order the edit tests target. + frontendOpenOrders: jest.fn().mockResolvedValue([ + { + coin: 'BTC', + side: 'B', + limitPx: '50000', + sz: '0.1', + origSz: '0.1', + oid: 123, + timestamp: 1_700_000_000_000, + isTrigger: false, + triggerCondition: 'N/A', + triggerPx: '0', + children: [], + isPositionTpsl: false, + reduceOnly: false, + orderType: 'Limit', + }, + ]), + referral: jest.fn().mockResolvedValue({ + referrerState: { + stage: 'ready', + data: { code: 'MMCSI' }, + }, + }), + maxBuilderFee: jest.fn().mockResolvedValue(1), + userFees: jest.fn().mockResolvedValue({ + feeSchedule: { + cross: '0.00030', + add: '0.00010', + spotCross: '0.00040', + spotAdd: '0.00020', + }, + dailyUserVlm: [], + }), + userNonFundingLedgerUpdates: jest.fn().mockResolvedValue([ + { + delta: { type: 'deposit', usdc: '100' }, + time: Date.now(), + hash: '0x123abc', + }, + { + delta: { type: 'withdraw', usdc: '50' }, + time: Date.now() - 3600000, + hash: '0x456def', + }, + ]), + portfolio: jest.fn().mockResolvedValue([ + null, + [ + null, + { + accountValueHistory: [ + [Date.now() - 86400000, '10000'], // 24h ago + [Date.now() - 172800000, '9500'], // 48h ago + [Date.now() - 259200000, '9000'], // 72h ago + ], + }, + ], + ]), + spotMeta: jest.fn().mockResolvedValue({ + tokens: [ + { name: 'USDC', tokenId: '0xdef456', index: 0 }, + { name: 'USDT', tokenId: '0x789abc', index: 1 }, + ], + universe: [], + }), + historicalOrders: jest.fn().mockResolvedValue([]), + userFills: jest.fn().mockResolvedValue([]), + userFillsByTime: jest.fn().mockResolvedValue([]), + userFunding: jest.fn().mockResolvedValue([]), + ...overrides, +}); + +const createMockExchangeClient = (overrides: Record = {}) => ({ + order: jest.fn().mockImplementation((request: { orders: unknown[] }) => + Promise.resolve({ + status: 'ok', + response: { + data: { + statuses: request.orders.map((_order, index) => ({ + resting: { oid: 123 + index }, + })), + }, + }, + }), + ), + modify: jest.fn().mockResolvedValue({ + status: 'ok', + response: { data: { statuses: [{ resting: { oid: '123' } }] } }, + }), + cancel: jest.fn().mockResolvedValue({ + status: 'ok', + response: { data: { statuses: ['success'] } }, + }), + withdraw3: jest.fn().mockResolvedValue({ + status: 'ok', + }), + updateLeverage: jest.fn().mockResolvedValue({ + status: 'ok', + }), + approveBuilderFee: jest.fn().mockResolvedValue({ + status: 'ok', + }), + setReferrer: jest.fn().mockResolvedValue({ + status: 'ok', + }), + sendAsset: jest.fn().mockResolvedValue({ + status: 'ok', + }), + agentSetAbstraction: jest.fn().mockResolvedValue({ + status: 'ok', + }), + userSetAbstraction: jest.fn().mockResolvedValue({ + status: 'ok', + }), + ...overrides, +}); + +// Create shared mock platform dependencies for provider tests +const mockPlatformDependencies: PerpsPlatformDependencies = + createMockInfrastructure(); + +const mockMessenger = createMockMessenger(); + +/** + * Helper to create HyperLiquidProvider with mock platform dependencies + * @param options + * @param options.isTestnet + * @param options.hip3Enabled + * @param options.allowlistMarkets + * @param options.blocklistMarkets + * @param options.useUnifiedAccount + */ +const createTestProvider = ( + options: { + isTestnet?: boolean; + hip3Enabled?: boolean; + allowlistMarkets?: string[]; + blocklistMarkets?: string[]; + useUnifiedAccount?: boolean; + initialAssetMapping?: [string, number][]; + } = {}, +): HyperLiquidProvider => + new HyperLiquidProvider({ + ...options, + platformDependencies: mockPlatformDependencies, + messenger: mockMessenger, + }); + +describe('HyperLiquidProvider', () => { + let provider: HyperLiquidProvider; + let mockClientService: jest.Mocked; + let mockWalletService: jest.Mocked; + let mockSubscriptionService: jest.Mocked; + + beforeEach(() => { + // Reset all mocks + jest.clearAllMocks(); + ( + mockPlatformDependencies.marketDataFormatters.formatVolume as jest.Mock + ).mockImplementation((value: number) => '$' + value.toFixed(0)); + ( + mockPlatformDependencies.marketDataFormatters.formatPerpsFiat as jest.Mock + ).mockImplementation((value: number) => '$' + value.toFixed(2)); + ( + mockPlatformDependencies.marketDataFormatters + .formatPercentage as jest.Mock + ).mockImplementation((value: number) => `${value.toFixed(2)}%`); + ( + mockPlatformDependencies.featureFlags.validateVersionGated as jest.Mock + ).mockReturnValue(undefined); + (mockPlatformDependencies.metrics.isEnabled as jest.Mock).mockReturnValue( + true, + ); + + // Reset TradingReadinessCache mock state (using imported mocked module) + const mockedCache = TradingReadinessCache as jest.Mocked< + typeof TradingReadinessCache + >; + mockedCache.get.mockReturnValue(undefined); + mockedCache.getBuilderFee.mockReturnValue(undefined); + mockedCache.getReferral.mockReturnValue(undefined); + mockedCache.isInFlight.mockReturnValue(undefined); + mockedCache.setInFlight.mockReturnValue(jest.fn()); + + // Initialize mock stream manager instance + mockStreamManagerInstance = { + clearAllChannels: jest.fn(), + }; + + // Create mocked service instances using factory functions + mockClientService = { + initialize: jest.fn(), + isInitialized: jest.fn().mockReturnValue(true), + isTestnetMode: jest.fn().mockReturnValue(false), + ensureInitialized: jest.fn(), + getExchangeClient: jest.fn().mockReturnValue(createMockExchangeClient()), + getInfoClient: jest.fn().mockReturnValue(createMockInfoClient()), + fetchHistoricalOrders: jest.fn().mockResolvedValue([]), + disconnect: jest.fn().mockResolvedValue(undefined), + toggleTestnet: jest.fn(), + setTestnetMode: jest.fn(), + getNetwork: jest.fn().mockReturnValue('mainnet'), + ensureSubscriptionClient: jest.fn().mockResolvedValue(undefined), + getSubscriptionClient: jest.fn(), + setOnReconnectCallback: jest.fn(), + setOnTerminateCallback: jest.fn(), + getConnectionState: jest.fn().mockReturnValue('connected'), + } as Partial as jest.Mocked; + + mockWalletService = { + setTestnetMode: jest.fn(), + getCurrentAccountId: jest + .fn() + .mockReturnValue( + 'eip155:42161:0x1234567890123456789012345678901234567890', + ), + createWalletAdapter: jest.fn().mockReturnValue({ + request: jest + .fn() + .mockResolvedValue(['0x1234567890123456789012345678901234567890']), + }), + getUserAddress: jest + .fn() + .mockReturnValue('0x1234567890123456789012345678901234567890'), + getUserAddressWithDefault: jest + .fn() + .mockResolvedValue('0x1234567890123456789012345678901234567890'), + isKeyringUnlocked: jest.fn().mockReturnValue(true), + isSelectedHardwareWallet: jest.fn().mockReturnValue(false), + } as Partial as jest.Mocked; + + mockSubscriptionService = { + subscribeToPrices: jest.fn().mockResolvedValue(jest.fn()), // Returns Promise + subscribeToPositions: jest.fn().mockReturnValue(jest.fn()), // Returns function directly + subscribeToOrderFills: jest.fn().mockReturnValue(jest.fn()), // Returns function directly + clearAll: jest.fn(), + isPositionsCacheInitialized: jest.fn().mockReturnValue(false), + getCachedPositions: jest.fn().mockReturnValue([]), + updateFeatureFlags: jest.fn().mockResolvedValue(undefined), + // Cache methods used by buildAssetMapping optimization + setDexMetaCache: jest.fn(), + setDexAssetCtxsCache: jest.fn(), + getDexAssetCtxsCache: jest.fn().mockReturnValue(undefined), + // Price cache used by placeOrder, editOrder, closePosition optimizations + getCachedPrice: jest.fn().mockImplementation((symbol: string) => { + const prices: Record = { BTC: '50000', ETH: '3000' }; + return prices[symbol]; + }), + getLastAllMidsSnapshot: jest.fn().mockReturnValue(null), + // Orders cache used by updatePositionTPSL and getOpenOrders + isOrdersCacheInitialized: jest.fn().mockReturnValue(false), + getCachedOrders: jest.fn().mockReturnValue([]), + // Atomic getter - returns null when cache not initialized (prevents race condition) + getOrdersCacheIfInitialized: jest.fn().mockReturnValue(null), + // Abstraction-mode resolved-mode setter (unified account migration) + setUserAbstractionMode: jest.fn(), + } as Partial as jest.Mocked; + + // Mock constructors + MockedHyperLiquidClientService.mockImplementation(() => mockClientService); + MockedHyperLiquidWalletService.mockImplementation(() => mockWalletService); + MockedHyperLiquidSubscriptionService.mockImplementation( + () => mockSubscriptionService, + ); + + // Mock validation + mockValidateOrderParams.mockReturnValue({ isValid: true }); + mockValidateWithdrawalParams.mockReturnValue({ isValid: true }); + mockValidateDepositParams.mockReturnValue({ isValid: true }); + mockValidateCoinExists.mockReturnValue({ isValid: true }); + mockValidateAssetSupport.mockReturnValue({ isValid: true }); + mockValidateBalance.mockReturnValue({ isValid: true }); + const hyperLiquidValidation = jest.requireMock( + '../../../src/utils/hyperLiquidValidation', + ); + hyperLiquidValidation.getSupportedPaths.mockReturnValue([ + 'eip155:42161/erc20:0xa0b86a33e6776e681a06e0e1622c5e5e3e6a8b13/default', + 'eip155:1/erc20:0xa0b86a33e6776e681a06e0e1622c5e5e3e6a8b13/default', + ]); + hyperLiquidValidation.getBridgeInfo.mockReturnValue({ + chainId: 'eip155:42161', + contractAddress: '0x1234567890123456789012345678901234567890', + }); + hyperLiquidValidation.createErrorResult.mockImplementation( + (error: unknown, defaultResponse: Record) => ({ + ...defaultResponse, + success: false, + error: error instanceof Error ? error.message : String(error), + }), + ); + const hyperLiquidAdapter = jest.requireMock( + '../../../src/utils/hyperLiquidAdapter', + ); + hyperLiquidAdapter.adaptHyperLiquidLedgerUpdateToUserHistoryItem.mockImplementation( + (updates: unknown[]) => { + if (!updates || !Array.isArray(updates) || updates.length === 0) { + return []; + } + return updates.map(() => ({ + type: 'deposit' as const, + amount: '100', + timestamp: Date.now(), + hash: '0x123', + })); + }, + ); + + provider = createTestProvider({ + initialAssetMapping: [ + ['BTC', 0], + ['ETH', 1], + ], + }); + }); + describe('Trading Operations', () => { + it('brings the SDK clients up before reading asset metadata', async () => { + // Reproduces the cold-start / post-disconnect failure: placeOrder resolves + // asset info (an InfoClient read) before it ensures trading readiness, so + // an order taken while the clients are still down used to fail with + // CLIENT_NOT_INITIALIZED instead of waiting for them. + let clientsUp = false; + const infoClient = mockClientService.getInfoClient(); + mockClientService.initialize.mockImplementation(async () => { + clientsUp = true; + }); + mockClientService.getInfoClient.mockImplementation(() => { + if (!clientsUp) { + throw new Error(PERPS_ERROR_CODES.CLIENT_NOT_INITIALIZED); + } + return infoClient; + }); + + const orderParams: OrderParams = { + symbol: 'BTC', + isBuy: true, + size: '0.1', + orderType: 'market', + currentPrice: 50000, + }; + + const result = await provider.placeOrder(orderParams); + + expect(result.success).toBe(true); + expect(mockClientService.initialize).toHaveBeenCalled(); + }); + + it('places a market order successfully', async () => { + const orderParams: OrderParams = { + symbol: 'BTC', + isBuy: true, + size: '0.1', + orderType: 'market', + currentPrice: 50000, + }; + + const result = await provider.placeOrder(orderParams); + + expect(result.success).toBe(true); + expect(result.orderId).toBe('123'); + + // Verify market orders use FrontendMarket (HyperLiquid standard for market execution) + expect(mockClientService.getExchangeClient().order).toHaveBeenCalledWith( + expect.objectContaining({ + orders: [ + expect.objectContaining({ + t: { limit: { tif: 'FrontendMarket' } }, + }), + ], + }), + ); + + // The result echoes back the final normalized size that was submitted to + // the exchange (the main order's `s`), so callers can classify partial + // fills against the real submitted size rather than the pre-normalization + // request. + const orderCall = ( + mockClientService.getExchangeClient().order as jest.Mock + ).mock.calls[0][0]; + expect(result.submittedSize).toBe(orderCall.orders[0].s); + }); + + it('places a limit order successfully', async () => { + const orderParams: OrderParams = { + symbol: 'BTC', + isBuy: true, + size: '0.1', + price: '51000', + orderType: 'limit', + }; + + const result = await provider.placeOrder(orderParams); + + expect(result.success).toBe(true); + + // Verify limit orders use Gtc (standard limit order behavior) + expect(mockClientService.getExchangeClient().order).toHaveBeenCalledWith( + expect.objectContaining({ + orders: [ + expect.objectContaining({ + t: { limit: { tif: 'Gtc' } }, + }), + ], + }), + ); + }); + + it('uses Gtc TIF for limit orders (regression test)', async () => { + const orderParams: OrderParams = { + symbol: 'BTC', + isBuy: true, + size: '0.1', + price: '51000', + orderType: 'limit', + }; + + await provider.placeOrder(orderParams); + + // Verify that the order was called with Gtc TIF for limit orders + expect(mockClientService.getExchangeClient().order).toHaveBeenCalledWith( + expect.objectContaining({ + orders: [ + expect.objectContaining({ + a: 0, // BTC asset ID + b: true, // isBuy + t: { limit: { tif: 'Gtc' } }, // Limit orders use Gtc TIF + }), + ], + }), + ); + }); + + it('tracks performance measurements when placing order', async () => { + const orderParams: OrderParams = { + symbol: 'ETH', + isBuy: true, + size: '1.0', + orderType: 'market', + leverage: 10, + currentPrice: 3000, // ETH price for USD calculation + }; + + await provider.placeOrder(orderParams); + }); + + it('calculates USD position size correctly for market orders', async () => { + const orderParams: OrderParams = { + symbol: 'BTC', + isBuy: true, + size: '0.5', // 0.5 BTC + orderType: 'market', + currentPrice: 45000, // BTC at $45,000 + }; + + await provider.placeOrder(orderParams); + }); + + it('calculates USD position size correctly for limit orders', async () => { + const orderParams: OrderParams = { + symbol: 'BTC', + isBuy: true, + size: '0.2', // 0.2 BTC + orderType: 'limit', + price: '44000', // Limit price at $44,000 + currentPrice: 45000, // Current price (not used for USD calculation in limit orders) + }; + + await provider.placeOrder(orderParams); + }); + + it('handles order placement errors', async () => { + ( + mockClientService.getExchangeClient().order as jest.Mock + ).mockResolvedValueOnce({ + status: 'error', + response: { message: 'Order failed' }, + }); + + const orderParams: OrderParams = { + symbol: 'BTC', + isBuy: true, + size: '0.1', + orderType: 'market', + }; + + const result = await provider.placeOrder(orderParams); + + expect(result.success).toBe(false); + }); + + it('edits an order successfully', async () => { + const editParams = { + orderId: '123', + newOrder: { + symbol: 'BTC', + isBuy: true, + size: '0.2', + price: '52000', + orderType: 'limit', + } as OrderParams, + }; + + const result = await provider.editOrder(editParams); + + expect(result.success).toBe(true); + + // Verify limit orders use Gtc TIF in edit operations + expect(mockClientService.getExchangeClient().modify).toHaveBeenCalledWith( + expect.objectContaining({ + order: expect.objectContaining({ + t: { limit: { tif: 'Gtc' } }, + }), + }), + ); + }); + + it('edits a market order with slippage calculation', async () => { + const editParams = { + orderId: '123', + newOrder: { + symbol: 'BTC', + isBuy: true, + size: '0.1', + orderType: 'market', + slippage: 0.02, // 2% slippage + } as OrderParams, + }; + + const result = await provider.editOrder(editParams); + + expect(result.success).toBe(true); + // Price is fetched from WebSocket cache (getCachedPrice) or REST API (allMids) as fallback + + // Verify market orders use FrontendMarket TIF in edit operations + expect(mockClientService.getExchangeClient().modify).toHaveBeenCalledWith( + expect.objectContaining({ + order: expect.objectContaining({ + t: { limit: { tif: 'FrontendMarket' } }, + }), + }), + ); + }); + + it('handles editOrder when asset is not found', async () => { + ( + mockClientService.getInfoClient().meta as jest.Mock + ).mockResolvedValueOnce({ + universe: [], // Empty universe - asset not found + }); + + const editParams = { + orderId: '123', + newOrder: { + symbol: 'UNKNOWN', + isBuy: true, + size: '0.1', + orderType: 'limit', + price: '50000', + } as OrderParams, + }; + + const result = await provider.editOrder(editParams); + + expect(result.success).toBe(false); + expect(result.error).toContain('Asset UNKNOWN not found'); + }); + + it('handles editOrder when no price is available', async () => { + // Mock both WebSocket cache and REST API to return no price + mockSubscriptionService.getCachedPrice.mockReturnValueOnce(undefined); + ( + mockClientService.getInfoClient().allMids as jest.Mock + ).mockResolvedValueOnce({}); // Empty price data + + const editParams = { + orderId: '123', + newOrder: { + symbol: 'BTC', + isBuy: true, + size: '0.1', + orderType: 'market', + } as OrderParams, + }; + + const result = await provider.editOrder(editParams); + + expect(result.success).toBe(false); + expect(result.error).toContain('Invalid price for BTC'); + }); + + it('falls back to REST API when cached price is zero', async () => { + // Mock WebSocket cache to return "0" (invalid price) + mockSubscriptionService.getCachedPrice.mockReturnValueOnce('0'); + // Mock REST API to return valid price + ( + mockClientService.getInfoClient().allMids as jest.Mock + ).mockResolvedValueOnce({ BTC: '50000' }); + + const editParams = { + orderId: '123', + newOrder: { + symbol: 'BTC', + isBuy: true, + size: '0.1', + orderType: 'market', + } as OrderParams, + }; + + const result = await provider.editOrder(editParams); + + // Should succeed because it fell back to REST API + expect(result.success).toBe(true); + // Verify REST API was called as fallback + expect(mockClientService.getInfoClient().allMids).toHaveBeenCalled(); + }); + + it('falls back to REST API when cached price is NaN', async () => { + // Mock WebSocket cache to return invalid string + mockSubscriptionService.getCachedPrice.mockReturnValueOnce('invalid'); + // Mock REST API to return valid price + ( + mockClientService.getInfoClient().allMids as jest.Mock + ).mockResolvedValueOnce({ BTC: '50000' }); + + const editParams = { + orderId: '123', + newOrder: { + symbol: 'BTC', + isBuy: true, + size: '0.1', + orderType: 'market', + } as OrderParams, + }; + + const result = await provider.editOrder(editParams); + + // Should succeed because it fell back to REST API + expect(result.success).toBe(true); + // Verify REST API was called as fallback + expect(mockClientService.getInfoClient().allMids).toHaveBeenCalled(); + }); + + it('falls back to REST API when cached price is negative', async () => { + // Mock WebSocket cache to return negative price (invalid for crypto) + mockSubscriptionService.getCachedPrice.mockReturnValueOnce('-100'); + // Mock REST API to return valid price + ( + mockClientService.getInfoClient().allMids as jest.Mock + ).mockResolvedValueOnce({ BTC: '50000' }); + + const editParams = { + orderId: '123', + newOrder: { + symbol: 'BTC', + isBuy: true, + size: '0.1', + orderType: 'market', + } as OrderParams, + }; + + const result = await provider.editOrder(editParams); + + // Should succeed because it fell back to REST API + expect(result.success).toBe(true); + // Verify REST API was called as fallback + expect(mockClientService.getInfoClient().allMids).toHaveBeenCalled(); + }); + + it('falls back to REST API when cached price is Infinity', async () => { + // Mock WebSocket cache to return Infinity (invalid price) + mockSubscriptionService.getCachedPrice.mockReturnValueOnce('Infinity'); + // Mock REST API to return valid price + ( + mockClientService.getInfoClient().allMids as jest.Mock + ).mockResolvedValueOnce({ BTC: '50000' }); + + const editParams = { + orderId: '123', + newOrder: { + symbol: 'BTC', + isBuy: true, + size: '0.1', + orderType: 'market', + } as OrderParams, + }; + + const result = await provider.editOrder(editParams); + + // Should succeed because it fell back to REST API + expect(result.success).toBe(true); + // Verify REST API was called as fallback + expect(mockClientService.getInfoClient().allMids).toHaveBeenCalled(); + }); + + it('throws error when REST price is negative', async () => { + // Mock WebSocket cache miss + mockSubscriptionService.getCachedPrice.mockReturnValueOnce(undefined); + // Mock REST API to return negative price + ( + mockClientService.getInfoClient().allMids as jest.Mock + ).mockResolvedValueOnce({ BTC: '-50000' }); + + const editParams = { + orderId: '123', + newOrder: { + symbol: 'BTC', + isBuy: true, + size: '0.1', + orderType: 'market', + } as OrderParams, + }; + + const result = await provider.editOrder(editParams); + + expect(result.success).toBe(false); + expect(result.error).toContain('Invalid price for BTC'); + }); + + it('throws error when REST price is Infinity', async () => { + // Mock WebSocket cache miss + mockSubscriptionService.getCachedPrice.mockReturnValueOnce(undefined); + // Mock REST API to return Infinity + ( + mockClientService.getInfoClient().allMids as jest.Mock + ).mockResolvedValueOnce({ BTC: 'Infinity' }); + + const editParams = { + orderId: '123', + newOrder: { + symbol: 'BTC', + isBuy: true, + size: '0.1', + orderType: 'market', + } as OrderParams, + }; + + const result = await provider.editOrder(editParams); + + expect(result.success).toBe(false); + expect(result.error).toContain('Invalid price for BTC'); + }); + + it('handles editOrder when asset ID is not found', async () => { + const editParams = { + orderId: '123', + newOrder: { + symbol: 'UNKNOWN_ASSET', + isBuy: true, + size: '0.1', + orderType: 'limit', + price: '50000', + } as OrderParams, + }; + + const result = await provider.editOrder(editParams); + + expect(result.success).toBe(false); + expect(result.error).toContain('UNKNOWN_ASSET not found'); + }); + + it('cancels an order successfully', async () => { + const cancelParams = { + orderId: '123', + symbol: 'BTC', + }; + + const result = await provider.cancelOrder(cancelParams); + + expect(result.success).toBe(true); + }); + + it('self-heals an empty prefetch asset map before validating the coin on cancel', async () => { + const { validateCoinExists: realValidateCoinExists } = jest.requireActual( + '../../../src/utils/hyperLiquidValidation', + ); + mockValidateCoinExists.mockImplementation(realValidateCoinExists); + + provider = createTestProvider(); + + const result = await provider.cancelOrder({ + orderId: '123', + symbol: 'BTC', + }); + + expect(result.success).toBe(true); + expect(mockClientService.getExchangeClient().cancel).toHaveBeenCalled(); + }); + + it('still rejects a genuinely unknown coin after cancel hydration', async () => { + const { validateCoinExists: realValidateCoinExists } = jest.requireActual( + '../../../src/utils/hyperLiquidValidation', + ); + mockValidateCoinExists.mockImplementation(realValidateCoinExists); + + provider = createTestProvider(); + + const result = await provider.cancelOrder({ + orderId: '123', + symbol: 'NOT_A_REAL_COIN', + }); + + expect(result.success).toBe(false); + expect(result.error).toBe(PERPS_ERROR_CODES.ORDER_UNKNOWN_COIN); + }); + + it('propagates unrelated cancel failures unchanged', async () => { + mockClientService.getExchangeClient = jest.fn().mockReturnValue( + createMockExchangeClient({ + cancel: jest + .fn() + .mockRejectedValue(new Error('Insufficient margin to cancel')), + }), + ); + + const result = await provider.cancelOrder({ + orderId: '123', + symbol: 'BTC', + }); + + expect(result.success).toBe(false); + expect(result.error).toContain('Insufficient margin'); + }); + + it('retries USD-based order when rejected for $10 minimum with adjusted amount', async () => { + // Create provider with PUMP in the asset mapping + provider = createTestProvider({ + initialAssetMapping: [ + ['BTC', 0], + ['ETH', 1], + ['PUMP', 2], + ], + }); + + const pumpUniverse = [ + { name: 'BTC', szDecimals: 3, maxLeverage: 50 }, + { name: 'ETH', szDecimals: 4, maxLeverage: 50 }, + { name: 'PUMP', szDecimals: 2, maxLeverage: 20 }, + ]; + mockClientService.getInfoClient = jest.fn().mockReturnValue( + createMockInfoClient({ + meta: jest.fn().mockResolvedValue({ universe: pumpUniverse }), + metaAndAssetCtxs: jest.fn().mockResolvedValue([ + { universe: pumpUniverse }, + [ + { + funding: '0.0001', + openInterest: '1000', + prevDayPx: '49000', + dayNtlVlm: '1000000', + markPx: '50000', + midPx: '50000', + oraclePx: '50000', + }, + { + funding: '0.0001', + openInterest: '500', + prevDayPx: '2900', + dayNtlVlm: '500000', + markPx: '3000', + midPx: '3000', + oraclePx: '3000', + }, + { + funding: '0.0001', + openInterest: '100', + prevDayPx: '0.003', + dayNtlVlm: '10000', + markPx: '0.003918', + midPx: '0.003918', + oraclePx: '0.003918', + }, + ], + ]), + allMids: jest + .fn() + .mockResolvedValue({ BTC: '50000', ETH: '3000', PUMP: '0.003918' }), + }), + ); + + const orderParams: OrderParams = { + symbol: 'PUMP', + isBuy: true, + size: '2553', + orderType: 'market', + usdAmount: '10.00', + currentPrice: 0.003918, + }; + + mockClientService.getExchangeClient = jest.fn().mockReturnValue({ + ...createMockExchangeClient(), + order: jest + .fn() + .mockRejectedValueOnce( + new Error('Order must have minimum value of $10'), + ) + .mockResolvedValueOnce({ + status: 'ok', + response: { data: { statuses: [{ resting: { oid: 456 } }] } }, + }), + }); + + const result = await provider.placeOrder(orderParams); + + expect(result.success).toBe(true); + expect(mockClientService.getExchangeClient().order).toHaveBeenCalledTimes( + 2, + ); + }); + + it('retries size-based order with currentPrice when rejected for $10 minimum', async () => { + // Create provider with PUMP in the asset mapping + provider = createTestProvider({ + initialAssetMapping: [ + ['BTC', 0], + ['ETH', 1], + ['PUMP', 2], + ], + }); + + const pumpUniverse = [ + { name: 'BTC', szDecimals: 3, maxLeverage: 50 }, + { name: 'ETH', szDecimals: 4, maxLeverage: 50 }, + { name: 'PUMP', szDecimals: 2, maxLeverage: 20 }, + ]; + mockClientService.getInfoClient = jest.fn().mockReturnValue( + createMockInfoClient({ + meta: jest.fn().mockResolvedValue({ universe: pumpUniverse }), + metaAndAssetCtxs: jest.fn().mockResolvedValue([ + { universe: pumpUniverse }, + [ + { + funding: '0.0001', + openInterest: '1000', + prevDayPx: '49000', + dayNtlVlm: '1000000', + markPx: '50000', + midPx: '50000', + oraclePx: '50000', + }, + { + funding: '0.0001', + openInterest: '500', + prevDayPx: '2900', + dayNtlVlm: '500000', + markPx: '3000', + midPx: '3000', + oraclePx: '3000', + }, + { + funding: '0.0001', + openInterest: '100', + prevDayPx: '0.003', + dayNtlVlm: '10000', + markPx: '0.003918', + midPx: '0.003918', + oraclePx: '0.003918', + }, + ], + ]), + allMids: jest + .fn() + .mockResolvedValue({ BTC: '50000', ETH: '3000', PUMP: '0.003918' }), + }), + ); + + const orderParams: OrderParams = { + symbol: 'PUMP', + isBuy: true, + size: '2553', + orderType: 'market', + currentPrice: 0.003918, + }; + + mockClientService.getExchangeClient = jest.fn().mockReturnValue({ + ...createMockExchangeClient(), + order: jest + .fn() + .mockRejectedValueOnce( + new Error('Order 0: Order must have minimum value'), + ) + .mockResolvedValueOnce({ + status: 'ok', + response: { data: { statuses: [{ resting: { oid: 789 } }] } }, + }), + }); + + const result = await provider.placeOrder(orderParams); + + expect(result.success).toBe(true); + const orderMock = mockClientService.getExchangeClient() + .order as jest.Mock; + expect(orderMock).toHaveBeenCalledTimes(2); + + // submittedSize must reflect the retried (second) submission — the size + // the exchange actually accepted after the $10-minimum bump — not the + // first rejected attempt, so partial-fill classification uses the real + // submitted size. + const firstSubmittedSize = orderMock.mock.calls[0][0].orders[0].s; + const secondSubmittedSize = orderMock.mock.calls[1][0].orders[0].s; + expect(secondSubmittedSize).not.toBe(firstSubmittedSize); + expect(result.submittedSize).toBe(secondSubmittedSize); + }); + + it('retries with adjusted USD when price-less order hits $10 minimum (uses fetched price from allMids)', async () => { + // Create provider with PUMP in the asset mapping + provider = createTestProvider({ + initialAssetMapping: [ + ['BTC', 0], + ['ETH', 1], + ['PUMP', 2], + ], + }); + + mockClientService.getInfoClient = jest.fn().mockReturnValue( + createMockInfoClient({ + meta: jest.fn().mockResolvedValue({ + universe: [ + { name: 'BTC', szDecimals: 3, maxLeverage: 50 }, + { name: 'ETH', szDecimals: 4, maxLeverage: 50 }, + { name: 'PUMP', szDecimals: 2, maxLeverage: 20 }, + ], + }), + allMids: jest + .fn() + .mockResolvedValue({ BTC: '50000', ETH: '3000', PUMP: '0.003918' }), + }), + ); + + const orderParams: OrderParams = { + symbol: 'PUMP', + isBuy: true, + size: '2553', + orderType: 'market', + // No currentPrice: provider fetches live price (0.003918) and uses it + // for both validation and the $10-minimum retry path. + }; + + const mockOrder = jest + .fn() + .mockRejectedValueOnce( + new Error('Order must have minimum value of $10'), + ) + .mockResolvedValueOnce({ + status: 'ok', + response: { + data: { + statuses: [ + { filled: { oid: 123, totalSz: '2553', avgPx: '0.004' } }, + ], + }, + }, + }); + + mockClientService.getExchangeClient = jest.fn().mockReturnValue({ + ...createMockExchangeClient(), + order: mockOrder, + }); + + const result = await provider.placeOrder(orderParams); + + // The live price is fetched → validation passes → order is submitted → + // first call hits $10 minimum → retry uses fetched price to compute + // adjusted usdAmount → second call succeeds. + expect(result.success).toBe(true); + expect(mockOrder).toHaveBeenCalledTimes(2); + }); + + it('closes a position successfully', async () => { + const exchangeClient = mockClientService.getExchangeClient(); + const closeParams: ClosePositionParams = { + symbol: 'BTC', + orderType: 'market', + }; + + const result = await provider.closePosition(closeParams); + + expect(result.success).toBe(true); + expect(exchangeClient.order.mock.calls[0][0].builder).toStrictEqual({ + b: BUILDER_FEE_CONFIG.MainnetBuilder, + f: BUILDER_FEE_CONFIG.MaxFeeTenthsBps, + }); + }); + + it('repairs missing HIP-3 asset IDs during closePosition after degraded discovery', async () => { + const hip3Provider = createTestProvider({ + hip3Enabled: true, + allowlistMarkets: ['xyz:*'], + useUnifiedAccount: true, + }); + const mockOrder = jest.fn().mockResolvedValue({ + status: 'ok', + response: { data: { statuses: [{ resting: { oid: 123 } }] } }, + }); + const mockInfoClient = createMockInfoClient({ + perpDexs: jest + .fn() + .mockResolvedValue([null, { name: 'xyz', url: 'https://xyz.com' }]), + metaAndAssetCtxs: jest + .fn() + .mockImplementation((params?: { dex?: string }) => { + if (params?.dex === 'xyz') { + return Promise.reject( + new Error('Transient xyz discovery failure'), + ); + } + + return Promise.resolve([ + { universe: [{ name: 'BTC', szDecimals: 3, maxLeverage: 50 }] }, + [ + { + funding: '0.0001', + openInterest: '1000', + prevDayPx: '49000', + dayNtlVlm: '1000000', + markPx: '50000', + midPx: '50000', + oraclePx: '50000', + }, + ], + ]); + }), + meta: jest.fn().mockImplementation((params?: { dex?: string }) => { + if (params?.dex === 'xyz') { + return Promise.resolve({ + universe: [ + { name: 'xyz:STOCK1', szDecimals: 2, maxLeverage: 20 }, + ], + // USDC collateral (matches default spotMeta USDC at index 0 + // below) so this asset-ID-repair test isn't gated by the + // (fail-closed) USDC collateral check. + collateralToken: 0, + }); + } + + return Promise.resolve({ + universe: [{ name: 'BTC', szDecimals: 3, maxLeverage: 50 }], + }); + }), + allMids: jest.fn().mockImplementation((params?: { dex?: string }) => { + if (params?.dex === 'xyz') { + return Promise.resolve({ 'xyz:STOCK1': '100' }); + } + + return Promise.resolve({ BTC: '50000' }); + }), + }); + + mockClientService.getInfoClient = jest + .fn() + .mockReturnValue(mockInfoClient); + mockClientService.getExchangeClient = jest + .fn() + .mockReturnValue(createMockExchangeClient({ order: mockOrder })); + + const result = await hip3Provider.closePosition({ + symbol: 'xyz:STOCK1', + orderType: 'market', + position: { + symbol: 'xyz:STOCK1', + size: '10', + entryPrice: '95', + positionValue: '1000', + unrealizedPnl: '50', + marginUsed: '100', + leverage: { type: 'isolated', value: 5 }, + liquidationPrice: '70', + maxLeverage: 20, + returnOnEquity: '10', + cumulativeFunding: { + allTime: '0', + sinceOpen: '0', + sinceChange: '0', + }, + takeProfitCount: 0, + stopLossCount: 0, + }, + }); + + expect(result.success).toBe(true); + expect(mockOrder).toHaveBeenCalledWith( + expect.objectContaining({ + orders: [expect.objectContaining({ a: 110000, r: true })], + }), + ); + }); + + it('rejects placeOrder for a HIP-3 DEX whose collateral token is not USDC (TAT-3304)', async () => { + const hip3Provider = createTestProvider({ + hip3Enabled: true, + allowlistMarkets: ['xyz:*'], + useUnifiedAccount: true, + }); + const mockOrder = jest.fn().mockResolvedValue({ + status: 'ok', + response: { data: { statuses: [{ resting: { oid: 123 } }] } }, + }); + const xyzMeta = { + universe: [{ name: 'xyz:STOCK1', szDecimals: 2, maxLeverage: 20 }], + collateralToken: 5, + }; + const xyzAssetCtxs = [ + { + funding: '0.0001', + openInterest: '1000', + prevDayPx: '95', + dayNtlVlm: '100000', + markPx: '100', + midPx: '100', + oraclePx: '100', + }, + ]; + const mockInfoClient = createMockInfoClient({ + perpDexs: jest + .fn() + .mockResolvedValue([null, { name: 'xyz', url: 'https://xyz.com' }]), + meta: jest.fn().mockImplementation((params?: { dex?: string }) => + params?.dex === 'xyz' + ? Promise.resolve(xyzMeta) + : Promise.resolve({ + universe: [{ name: 'BTC', szDecimals: 3, maxLeverage: 50 }], + }), + ), + metaAndAssetCtxs: jest + .fn() + .mockImplementation((params?: { dex?: string }) => { + if (params?.dex === 'xyz') { + return Promise.resolve([xyzMeta, xyzAssetCtxs]); + } + + return Promise.resolve([ + { universe: [{ name: 'BTC', szDecimals: 3, maxLeverage: 50 }] }, + [ + { + funding: '0.0001', + openInterest: '1000', + prevDayPx: '49000', + dayNtlVlm: '1000000', + markPx: '50000', + midPx: '50000', + oraclePx: '50000', + }, + ], + ]); + }), + spotMeta: jest.fn().mockResolvedValue({ + tokens: [ + { name: 'USDC', tokenId: '0xdef456', index: 0 }, + { name: 'USDH', tokenId: '0xabc123', index: 5 }, + ], + universe: [], + }), + allMids: jest.fn().mockImplementation((params?: { dex?: string }) => { + if (params?.dex === 'xyz') { + return Promise.resolve({ 'xyz:STOCK1': '100' }); + } + + return Promise.resolve({ BTC: '50000' }); + }), + }); + + mockClientService.getInfoClient = jest + .fn() + .mockReturnValue(mockInfoClient); + mockClientService.getExchangeClient = jest + .fn() + .mockReturnValue(createMockExchangeClient({ order: mockOrder })); + + const result = await hip3Provider.placeOrder({ + symbol: 'xyz:STOCK1', + isBuy: true, + size: '10', + orderType: 'market', + currentPrice: 100, + }); + + expect(result.success).toBe(false); + expect(result.error).toBe(PERPS_ERROR_CODES.UNSUPPORTED_COLLATERAL); + expect(mockOrder).not.toHaveBeenCalled(); + }); + + it('rejects placeOrder for a HIP-3 DEX whose collateral token index cannot be resolved against spot metadata', async () => { + const hip3Provider = createTestProvider({ + hip3Enabled: true, + allowlistMarkets: ['xyz:*'], + useUnifiedAccount: true, + }); + const mockOrder = jest.fn().mockResolvedValue({ + status: 'ok', + response: { data: { statuses: [{ resting: { oid: 123 } }] } }, + }); + // collateralToken index 7 has no corresponding entry in spotMeta.tokens + // below (missing/stale spot metadata) — the order must be rejected + // rather than treated as USDC-collateralized. + const xyzMeta = { + universe: [{ name: 'xyz:STOCK1', szDecimals: 2, maxLeverage: 20 }], + collateralToken: 7, + }; + const xyzAssetCtxs = [ + { + funding: '0.0001', + openInterest: '1000', + prevDayPx: '95', + dayNtlVlm: '100000', + markPx: '100', + midPx: '100', + oraclePx: '100', + }, + ]; + const mockInfoClient = createMockInfoClient({ + perpDexs: jest + .fn() + .mockResolvedValue([null, { name: 'xyz', url: 'https://xyz.com' }]), + meta: jest.fn().mockImplementation((params?: { dex?: string }) => + params?.dex === 'xyz' + ? Promise.resolve(xyzMeta) + : Promise.resolve({ + universe: [{ name: 'BTC', szDecimals: 3, maxLeverage: 50 }], + }), + ), + metaAndAssetCtxs: jest + .fn() + .mockImplementation((params?: { dex?: string }) => { + if (params?.dex === 'xyz') { + return Promise.resolve([xyzMeta, xyzAssetCtxs]); + } + + return Promise.resolve([ + { universe: [{ name: 'BTC', szDecimals: 3, maxLeverage: 50 }] }, + [ + { + funding: '0.0001', + openInterest: '1000', + prevDayPx: '49000', + dayNtlVlm: '1000000', + markPx: '50000', + midPx: '50000', + oraclePx: '50000', + }, + ], + ]); + }), + spotMeta: jest.fn().mockResolvedValue({ + tokens: [{ name: 'USDC', tokenId: '0xdef456', index: 0 }], + universe: [], + }), + allMids: jest.fn().mockImplementation((params?: { dex?: string }) => { + if (params?.dex === 'xyz') { + return Promise.resolve({ 'xyz:STOCK1': '100' }); + } + + return Promise.resolve({ BTC: '50000' }); + }), + }); + + mockClientService.getInfoClient = jest + .fn() + .mockReturnValue(mockInfoClient); + mockClientService.getExchangeClient = jest + .fn() + .mockReturnValue(createMockExchangeClient({ order: mockOrder })); + + const result = await hip3Provider.placeOrder({ + symbol: 'xyz:STOCK1', + isBuy: true, + size: '10', + orderType: 'market', + currentPrice: 100, + }); + + expect(result.success).toBe(false); + expect(result.error).toBe(PERPS_ERROR_CODES.UNSUPPORTED_COLLATERAL); + expect(mockOrder).not.toHaveBeenCalled(); + }); + }); + + describe('closePosition with TP/SL handling', () => { + beforeEach(() => { + // Clear debugLogger mock to capture logs for this test suite + (mockPlatformDependencies.debugLogger.log as jest.Mock).mockClear(); + }); + + it('closes position without TP/SL successfully', async () => { + // Position without TP/SL - using factory for standard BTC position + mockClientService.getInfoClient = jest + .fn() + .mockReturnValue(createMockInfoClient()); + + const closeParams: ClosePositionParams = { + symbol: 'BTC', + orderType: 'market', + }; + + const result = await provider.closePosition(closeParams); + + expect(result.success).toBe(true); + // No TP/SL logging expected since we removed this functionality + }); + + it('handles position with TP/SL successfully', async () => { + // Mock position with TP/SL + mockClientService.getInfoClient = jest.fn().mockReturnValue({ + clearinghouseState: jest.fn().mockResolvedValue({ + marginSummary: { + totalMarginUsed: '500', + accountValue: '10500', + }, + withdrawable: '9500', + assetPositions: [ + { + position: { + coin: 'BTC', + szi: '0.1', + entryPx: '50000', + positionValue: '5000', + unrealizedPnl: '100', + marginUsed: '500', + leverage: { type: 'cross', value: 10 }, + liquidationPx: '45000', + maxLeverage: 50, + returnOnEquity: '20', + cumFunding: { + allTime: '10', + sinceOpen: '5', + sinceChange: '2', + }, + }, + type: 'oneWay', + }, + ], + }), + frontendOpenOrders: jest + .fn() + .mockResolvedValueOnce([ + // First call for getPositions + { + coin: 'BTC', + oid: 1001, + reduceOnly: true, + isTrigger: true, + orderType: 'Take Profit Market', + triggerPx: '55000', + isPositionTpsl: true, + }, + { + coin: 'BTC', + oid: 1002, + reduceOnly: true, + isTrigger: true, + orderType: 'Stop Market', + triggerPx: '45000', + isPositionTpsl: true, + }, + ]) + .mockResolvedValueOnce([ + // Second call for closePosition TP/SL check + { + coin: 'BTC', + oid: 1001, + reduceOnly: true, + isTrigger: true, + orderType: 'Take Profit Market', + triggerPx: '55000', + isPositionTpsl: true, + side: 'A', + }, + { + coin: 'BTC', + oid: 1002, + reduceOnly: true, + isTrigger: true, + orderType: 'Stop Market', + triggerPx: '45000', + isPositionTpsl: true, + side: 'B', + }, + ]), + meta: jest.fn().mockResolvedValue({ + universe: [{ name: 'BTC', szDecimals: 3, maxLeverage: 50 }], + }), + metaAndAssetCtxs: jest.fn().mockResolvedValue([ + { universe: [{ name: 'BTC', szDecimals: 3, maxLeverage: 50 }] }, + [ + { + funding: '0.0001', + openInterest: '1000', + prevDayPx: '49000', + dayNtlVlm: '1000000', + markPx: '50000', + midPx: '50000', + oraclePx: '50000', + }, + ], + ]), + perpDexs: jest.fn().mockResolvedValue([null]), + allMids: jest.fn().mockResolvedValue({ BTC: '50000' }), + referral: jest.fn().mockResolvedValue({ + referrerState: { + stage: 'ready', + data: { code: 'MMCSI' }, + }, + }), + maxBuilderFee: jest.fn().mockResolvedValue(1), + }); + + const closeParams: ClosePositionParams = { + symbol: 'BTC', + orderType: 'market', + }; + + const result = await provider.closePosition(closeParams); + + expect(result.success).toBe(true); + + // TP/SL orders are automatically handled by Hyperliquid + // No additional logging needed + }); + + it('handles partial position close with TP/SL', async () => { + // Mock position with TP/SL + mockClientService.getInfoClient = jest.fn().mockReturnValue({ + clearinghouseState: jest.fn().mockResolvedValue({ + marginSummary: { + totalMarginUsed: '500', + accountValue: '10500', + }, + withdrawable: '9500', + assetPositions: [ + { + position: { + coin: 'ETH', + szi: '1.5', + entryPx: '3000', + positionValue: '4500', + unrealizedPnl: '50', + marginUsed: '450', + leverage: { type: 'cross', value: 10 }, + liquidationPx: '2700', + maxLeverage: 50, + returnOnEquity: '10', + cumFunding: { + allTime: '5', + sinceOpen: '2', + sinceChange: '1', + }, + }, + type: 'oneWay', + }, + ], + }), + frontendOpenOrders: jest + .fn() + .mockResolvedValueOnce([ + // First call for getPositions + { + coin: 'ETH', + oid: 2001, + reduceOnly: true, + isTrigger: true, + orderType: 'Take Profit Limit', + triggerPx: '3500', + limitPx: '3490', + isPositionTpsl: true, + }, + ]) + .mockResolvedValueOnce([ + // Second call for closePosition TP/SL check + { + coin: 'ETH', + oid: 2001, + reduceOnly: true, + isTrigger: true, + orderType: 'Take Profit Limit', + triggerPx: '3500', + limitPx: '3490', + isPositionTpsl: true, + side: 'A', + }, + ]), + meta: jest.fn().mockResolvedValue({ + universe: [{ name: 'ETH', szDecimals: 4, maxLeverage: 50 }], + }), + metaAndAssetCtxs: jest.fn().mockResolvedValue([ + { universe: [{ name: 'ETH', szDecimals: 4, maxLeverage: 50 }] }, + [ + { + funding: '0.0001', + openInterest: '500', + prevDayPx: '2900', + dayNtlVlm: '500000', + markPx: '3000', + midPx: '3000', + oraclePx: '3000', + }, + ], + ]), + perpDexs: jest.fn().mockResolvedValue([null]), + allMids: jest.fn().mockResolvedValue({ ETH: '3000' }), + referral: jest.fn().mockResolvedValue({ + referrerState: { + stage: 'ready', + data: { code: 'MMCSI' }, + }, + }), + maxBuilderFee: jest.fn().mockResolvedValue(1), + }); + + const closeParams: ClosePositionParams = { + symbol: 'ETH', + size: '0.5', // Partial close + orderType: 'limit', + price: '3100', + }; + + const result = await provider.closePosition(closeParams); + + expect(result.success).toBe(true); + + // Verify partial close size is used (with HyperLiquid's short property names) + expect(mockClientService.getExchangeClient().order).toHaveBeenCalledWith( + expect.objectContaining({ + orders: [ + expect.objectContaining({ + s: '0.5', // 's' is the short form for 'sz' (size) + r: true, // 'r' is the short form for 'reduceOnly' + }), + ], + }), + ); + + // TP/SL orders are automatically handled by Hyperliquid for partial closes too + }); + + it('handles position without open TP/SL orders', async () => { + // Position exists but no open TP/SL orders + mockClientService.getInfoClient = jest + .fn() + .mockReturnValue(createMockInfoClient()); + + const closeParams: ClosePositionParams = { + symbol: 'BTC', + orderType: 'market', + }; + + const result = await provider.closePosition(closeParams); + + expect(result.success).toBe(true); + + // Should not log TP/SL related messages + expect(mockPlatformDependencies.debugLogger.log).not.toHaveBeenCalledWith( + expect.stringContaining('Found open TP/SL orders'), + expect.any(Object), + ); + }); + + it('handles close position when position not found', async () => { + // Override to have NO positions (empty array) + mockClientService.getInfoClient = jest.fn().mockReturnValue( + createMockInfoClient({ + clearinghouseState: jest.fn().mockResolvedValue({ + marginSummary: { totalMarginUsed: '0', accountValue: '10000' }, + withdrawable: '10000', + assetPositions: [], // No positions + crossMarginSummary: { accountValue: '10000', totalMarginUsed: '0' }, + }), + }), + ); + + const closeParams: ClosePositionParams = { + symbol: 'BTC', + orderType: 'market', + }; + + const result = await provider.closePosition(closeParams); + + expect(result.success).toBe(false); + expect(result.error).toContain('No position found for BTC'); + }); + + it('handles short position close with TP/SL', async () => { + // Mock short position with TP/SL + mockClientService.getInfoClient = jest.fn().mockReturnValue({ + clearinghouseState: jest.fn().mockResolvedValue({ + marginSummary: { + totalMarginUsed: '500', + accountValue: '10500', + }, + withdrawable: '9500', + assetPositions: [ + { + position: { + coin: 'BTC', + szi: '-0.1', // Short position + entryPx: '50000', + positionValue: '5000', + unrealizedPnl: '-100', + marginUsed: '500', + leverage: { type: 'cross', value: 10 }, + liquidationPx: '55000', + maxLeverage: 50, + returnOnEquity: '-20', + cumFunding: { + allTime: '10', + sinceOpen: '5', + sinceChange: '2', + }, + }, + type: 'oneWay', + }, + ], + }), + frontendOpenOrders: jest + .fn() + .mockResolvedValueOnce([ + // First call for getPositions - short position TP/SL + { + coin: 'BTC', + oid: 3001, + reduceOnly: true, + isTrigger: true, + orderType: 'Take Profit Market', + triggerPx: '45000', // TP below entry for short + isPositionTpsl: true, + }, + { + coin: 'BTC', + oid: 3002, + reduceOnly: true, + isTrigger: true, + orderType: 'Stop Market', + triggerPx: '55000', // SL above entry for short + isPositionTpsl: true, + }, + ]) + .mockResolvedValueOnce([ + // Second call for closePosition TP/SL check + { + coin: 'BTC', + oid: 3001, + reduceOnly: true, + isTrigger: true, + orderType: 'Take Profit Market', + triggerPx: '45000', + isPositionTpsl: true, + side: 'B', + }, + { + coin: 'BTC', + oid: 3002, + reduceOnly: true, + isTrigger: true, + orderType: 'Stop Market', + triggerPx: '55000', + isPositionTpsl: true, + side: 'A', + }, + ]), + meta: jest.fn().mockResolvedValue({ + universe: [{ name: 'BTC', szDecimals: 3, maxLeverage: 50 }], + }), + metaAndAssetCtxs: jest.fn().mockResolvedValue([ + { universe: [{ name: 'BTC', szDecimals: 3, maxLeverage: 50 }] }, + [ + { + funding: '0.0001', + openInterest: '1000', + prevDayPx: '49000', + dayNtlVlm: '1000000', + markPx: '50000', + midPx: '50000', + oraclePx: '50000', + }, + ], + ]), + perpDexs: jest.fn().mockResolvedValue([null]), + allMids: jest.fn().mockResolvedValue({ BTC: '50000' }), + referral: jest.fn().mockResolvedValue({ + referrerState: { + stage: 'ready', + data: { code: 'MMCSI' }, + }, + }), + maxBuilderFee: jest.fn().mockResolvedValue(1), + }); + + const closeParams: ClosePositionParams = { + symbol: 'BTC', + orderType: 'market', + }; + + const result = await provider.closePosition(closeParams); + + expect(result.success).toBe(true); + + // Verify buy order is placed to close short (with HyperLiquid's short property names) + expect(mockClientService.getExchangeClient().order).toHaveBeenCalledWith( + expect.objectContaining({ + orders: [ + expect.objectContaining({ + b: true, // 'b' is the short form for 'isBuy' (Buy to close short) + s: '0.1', // 's' is the short form for 'sz' (size) + r: true, // 'r' is the short form for 'reduceOnly' + }), + ], + }), + ); + + // TP/SL orders are automatically handled by Hyperliquid for short positions too + }); + + it('handles position close even if TP/SL info is unavailable', async () => { + // Mock position exists with TP/SL in positions call + mockClientService.getInfoClient = jest.fn().mockReturnValue({ + clearinghouseState: jest.fn().mockResolvedValue({ + marginSummary: { + totalMarginUsed: '500', + accountValue: '10500', + }, + withdrawable: '9500', + assetPositions: [ + { + position: { + coin: 'BTC', + szi: '0.1', + entryPx: '50000', + positionValue: '5000', + unrealizedPnl: '100', + marginUsed: '500', + leverage: { type: 'cross', value: 10 }, + liquidationPx: '45000', + maxLeverage: 50, + returnOnEquity: '20', + cumFunding: { + allTime: '10', + sinceOpen: '5', + sinceChange: '2', + }, + }, + type: 'oneWay', + }, + ], + }), + frontendOpenOrders: jest.fn().mockResolvedValueOnce([ + // First call for getPositions with TP/SL + { + coin: 'BTC', + oid: 1001, + reduceOnly: true, + isTrigger: true, + orderType: 'Take Profit Market', + triggerPx: '55000', + isPositionTpsl: true, + }, + ]), + meta: jest.fn().mockResolvedValue({ + universe: [{ name: 'BTC', szDecimals: 3, maxLeverage: 50 }], + }), + metaAndAssetCtxs: jest.fn().mockResolvedValue([ + { universe: [{ name: 'BTC', szDecimals: 3, maxLeverage: 50 }] }, + [ + { + funding: '0.0001', + openInterest: '1000', + prevDayPx: '49000', + dayNtlVlm: '1000000', + markPx: '50000', + midPx: '50000', + oraclePx: '50000', + }, + ], + ]), + perpDexs: jest.fn().mockResolvedValue([null]), + allMids: jest.fn().mockResolvedValue({ BTC: '50000' }), + referral: jest.fn().mockResolvedValue({ + referrerState: { + stage: 'ready', + data: { code: 'MMCSI' }, + }, + }), + maxBuilderFee: jest.fn().mockResolvedValue(1), + }); + + const closeParams: ClosePositionParams = { + symbol: 'BTC', + orderType: 'market', + }; + + const result = await provider.closePosition(closeParams); + + // Should succeed - TP/SL handling is automatic by Hyperliquid + expect(result.success).toBe(true); + }); + }); + + // Regression coverage for TAT-3252: HyperLiquid rejects a reduce-only order + // with "Reduce only order would increase position" whenever the submitted + // size/side does not match the live position. + describe('closePosition reduce-only safety', () => { + /** + * Build a position snapshot of the shape clients pass to closePosition. + * @param overrides - Fields to override on the default BTC long. + * @returns A position snapshot. + */ + const createPositionSnapshot = (overrides: Partial = {}) => ({ + symbol: 'BTC', + size: '0.1', + entryPrice: '50000', + positionValue: '5000', + unrealizedPnl: '100', + marginUsed: '500', + leverage: { type: 'cross' as const, value: 10 }, + liquidationPrice: '45000', + maxLeverage: 50, + returnOnEquity: '20', + cumulativeFunding: { allTime: '10', sinceOpen: '5', sinceChange: '2' }, + takeProfitCount: 0, + stopLossCount: 0, + ...overrides, + }); + + /** + * Make the WebSocket position cache serve the given positions. + * @param positions - Positions the cache should report as live. + * @param coveredDexs - DEX identifiers the cache covers ('' is the main DEX). + */ + const primePositionsCache = ( + positions: Position[], + coveredDexs: string[] = [''], + ) => { + mockSubscriptionService.isPositionsCacheInitialized = jest + .fn() + .mockReturnValue(true); + mockSubscriptionService.getCachedPositions = jest + .fn() + .mockReturnValue(positions); + // Per-DEX slices are the store closePosition reads: a covered DEX returns + // its own positions, an uncovered one returns null + mockSubscriptionService.getCachedPositionsForDex = jest + .fn() + .mockImplementation((dexName: string) => + coveredDexs.includes(dexName) + ? positions.filter( + (pos) => + (pos.symbol.split(':')[1] ? pos.symbol.split(':')[0] : '') === + dexName, + ) + : null, + ); + }; + + /** + * Read the main order submitted to the exchange. + * @returns The submitted SDK order. + */ + const getSubmittedOrder = () => + (mockClientService.getExchangeClient().order as jest.Mock).mock + .calls[0][0].orders[0]; + + it('uses the live position size when the provided snapshot is stale', async () => { + // Snapshot says 0.1 BTC, but a concurrent TP/SL fill left only 0.04 + primePositionsCache([createPositionSnapshot({ size: '0.04' })]); + + const result = await provider.closePosition({ + symbol: 'BTC', + orderType: 'market', + position: createPositionSnapshot(), + }); + + expect(result.success).toBe(true); + expect(getSubmittedOrder()).toMatchObject({ + b: false, // sell to close a long + s: '0.04', + r: true, + }); + }); + + it('uses the live position side when the snapshot direction is stale', async () => { + // Snapshot says long, but the position has since flipped short + primePositionsCache([createPositionSnapshot({ size: '-0.05' })]); + + const result = await provider.closePosition({ + symbol: 'BTC', + orderType: 'market', + position: createPositionSnapshot({ size: '0.1' }), + }); + + expect(result.success).toBe(true); + expect(getSubmittedOrder()).toMatchObject({ + b: true, // buy to close a short + s: '0.05', + r: true, + }); + }); + + it('revalidates against the per-DEX slice, not a frozen aggregate, after a reconnect', async () => { + // After a WebSocket reconnect the initialized-DEX set is reset without + // clearing the caches, so the aggregate stays at its pre-reconnect contents + // until every expected DEX republishes while the per-DEX slices keep + // updating. Reading the aggregate here reused a stale size. + mockSubscriptionService.isPositionsCacheInitialized = jest + .fn() + .mockReturnValue(true); + // Frozen aggregate: pre-reconnect 0.1 BTC + mockSubscriptionService.getCachedPositions = jest + .fn() + .mockReturnValue([createPositionSnapshot({ size: '0.1' })]); + // Fresh per-DEX slice: a TP/SL fill left 0.03 BTC + mockSubscriptionService.getCachedPositionsForDex = jest + .fn() + .mockImplementation((dexName: string) => + dexName === '' ? [createPositionSnapshot({ size: '0.03' })] : null, + ); + + const result = await provider.closePosition({ + symbol: 'BTC', + orderType: 'market', + position: createPositionSnapshot({ size: '0.1' }), + }); + + expect(result.success).toBe(true); + expect(getSubmittedOrder()).toMatchObject({ s: '0.03', r: true }); + }); + + it('does not fail a close for a position missing only from a frozen aggregate', async () => { + // Same reconnect window, opposite direction: the position is absent from + // the stale aggregate but present in its DEX's fresh slice. Deciding + // coverage from the per-DEX map while reading the aggregate threw + // "No position found" for a position that is open. + mockSubscriptionService.isPositionsCacheInitialized = jest + .fn() + .mockReturnValue(true); + mockSubscriptionService.getCachedPositions = jest + .fn() + .mockReturnValue([]); + mockSubscriptionService.getCachedPositionsForDex = jest + .fn() + .mockImplementation((dexName: string) => + dexName === '' ? [createPositionSnapshot({ size: '0.05' })] : null, + ); + + const result = await provider.closePosition({ + symbol: 'BTC', + orderType: 'market', + position: createPositionSnapshot({ size: '0.1' }), + }); + + expect(result.success).toBe(true); + expect(getSubmittedOrder()).toMatchObject({ s: '0.05', r: true }); + }); + + it('fails fast without submitting an order or a REST lookup when the position is already closed', async () => { + // Cache is initialized and no longer holds BTC: the position is gone + // (e.g. a double-tapped close), so the reduce-only order must not be sent + primePositionsCache([createPositionSnapshot({ symbol: 'ETH' })]); + const getPositionsSpy = jest.spyOn(provider, 'getPositions'); + + const result = await provider.closePosition({ + symbol: 'BTC', + orderType: 'market', + position: createPositionSnapshot(), + }); + + expect(result.success).toBe(false); + expect(result.error).toContain('No position found for BTC'); + expect( + mockClientService.getExchangeClient().order, + ).not.toHaveBeenCalled(); + // No REST fallback: the cache is the freshest source, and a request here + // is exactly the 429 pressure the snapshot shortcut exists to avoid + expect(getPositionsSpy).not.toHaveBeenCalled(); + }); + + it('clamps a requested close size to the live position size', async () => { + primePositionsCache([createPositionSnapshot({ size: '0.1' })]); + + const result = await provider.closePosition({ + symbol: 'BTC', + orderType: 'market', + size: '0.2', // larger than the position + currentPrice: 50000, + position: createPositionSnapshot({ size: '0.1' }), + }); + + expect(result.success).toBe(true); + expect(getSubmittedOrder()).toMatchObject({ s: '0.1', r: true }); + }); + + it('rejects a non-positive close size instead of closing the whole position', async () => { + primePositionsCache([createPositionSnapshot({ size: '0.1' })]); + + const result = await provider.closePosition({ + symbol: 'BTC', + orderType: 'market', + size: '0', // a caller-side formatting slip must not liquidate 0.1 BTC + usdAmount: '2500', + currentPrice: 50000, + position: createPositionSnapshot({ size: '0.1' }), + }); + + expect(result.success).toBe(false); + expect(result.error).toBe(PERPS_ERROR_CODES.ORDER_SIZE_POSITIVE); + expect( + mockClientService.getExchangeClient().order, + ).not.toHaveBeenCalled(); + }); + + it('rejects a non-numeric close size', async () => { + primePositionsCache([createPositionSnapshot({ size: '0.1' })]); + + const result = await provider.closePosition({ + symbol: 'BTC', + orderType: 'market', + size: 'abc', + currentPrice: 50000, + position: createPositionSnapshot({ size: '0.1' }), + }); + + expect(result.success).toBe(false); + expect(result.error).toBe(PERPS_ERROR_CODES.ORDER_SIZE_POSITIVE); + expect( + mockClientService.getExchangeClient().order, + ).not.toHaveBeenCalled(); + }); + + it('treats an empty close size as a full close', async () => { + // Mobile sends `size: sizeToClose || ''` for 100% closes + primePositionsCache([createPositionSnapshot({ size: '0.1' })]); + + const result = await provider.closePosition({ + symbol: 'BTC', + orderType: 'market', + size: '', + position: createPositionSnapshot({ size: '0.1' }), + }); + + expect(result.success).toBe(true); + expect(getSubmittedOrder()).toMatchObject({ s: '0.1', r: true }); + }); + + it('rejects a full close whose price moved beyond maxSlippageBps', async () => { + // A full close submits the exact live size rather than a USD-derived one, + // but the caller's staleness guard must still apply: priceAtCalculation is + // 50000 and the live price is 40000, a 2000 bps move against a 300 bps cap + primePositionsCache([createPositionSnapshot({ size: '0.1' })]); + + const result = await provider.closePosition({ + symbol: 'BTC', + orderType: 'market', + priceAtCalculation: 50000, + maxSlippageBps: 300, + currentPrice: 40000, + position: createPositionSnapshot({ size: '0.1' }), + }); + + expect(result.success).toBe(false); + expect(result.error).toContain('Price moved too much'); + expect( + mockClientService.getExchangeClient().order, + ).not.toHaveBeenCalled(); + }); + + it('submits the exact live size for a full close inside maxSlippageBps', async () => { + // 100 bps of drift against a 300 bps cap: the guard passes and the close + // still submits exactly the live position size + primePositionsCache([createPositionSnapshot({ size: '0.1' })]); + + const result = await provider.closePosition({ + symbol: 'BTC', + orderType: 'market', + priceAtCalculation: 50000, + maxSlippageBps: 300, + currentPrice: 49500, + position: createPositionSnapshot({ size: '0.1' }), + }); + + expect(result.success).toBe(true); + expect(getSubmittedOrder()).toMatchObject({ s: '0.1', r: true }); + }); + + it('caps a partial close at the clamped size when usdAmount implies more', async () => { + // 98% close priced at 50000, submitted after a 2.5% adverse move: the USD + // amount implies 0.1005 BTC, above both the request and the position + primePositionsCache([createPositionSnapshot({ size: '0.1' })]); + + const result = await provider.closePosition({ + symbol: 'BTC', + orderType: 'market', + size: '0.098', + usdAmount: '4900', + priceAtCalculation: 50000, + currentPrice: 48750, + position: createPositionSnapshot({ size: '0.1' }), + }); + + expect(result.success).toBe(true); + expect(getSubmittedOrder()).toMatchObject({ s: '0.098', r: true }); + }); + + it('uses the caller snapshot verbatim when the WebSocket cache is not initialized', async () => { + // The 429-avoiding shortcut: no cache, no REST call, snapshot is authoritative + const getPositionsSpy = jest.spyOn(provider, 'getPositions'); + + const result = await provider.closePosition({ + symbol: 'BTC', + orderType: 'market', + position: createPositionSnapshot({ size: '0.07' }), + }); + + expect(result.success).toBe(true); + expect(getSubmittedOrder()).toMatchObject({ s: '0.07', r: true }); + expect(getPositionsSpy).not.toHaveBeenCalled(); + }); + + it('fetches live positions for a symbol whose DEX the cache does not cover', async () => { + // HIP-3 DEXs are only in the cache while their subscriptions are active, so + // a missing entry there proves nothing: fetch fresh data instead of + // blocking the close or trusting the snapshot. The REST position is 8, not + // the snapshot's 10. + const hip3Provider = createTestProvider({ + hip3Enabled: true, + allowlistMarkets: ['xyz:*'], + useUnifiedAccount: true, + }); + // Cache is initialized and covers the main DEX only + primePositionsCache([createPositionSnapshot({ size: '0.1' })], ['']); + const mockOrder = jest.fn().mockResolvedValue({ + status: 'ok', + response: { data: { statuses: [{ resting: { oid: 123 } }] } }, + }); + mockClientService.getInfoClient = jest.fn().mockReturnValue( + createMockInfoClient({ + clearinghouseState: jest + .fn() + .mockImplementation((params?: { user?: string; dex?: string }) => + Promise.resolve({ + marginSummary: { + totalMarginUsed: '100', + accountValue: '1000', + }, + withdrawable: '900', + assetPositions: + params?.dex === 'xyz' + ? [ + { + position: { + coin: 'xyz:STOCK1', + szi: '8', + entryPx: '95', + positionValue: '800', + unrealizedPnl: '40', + marginUsed: '80', + leverage: { type: 'isolated', value: 5 }, + liquidationPx: '70', + maxLeverage: 20, + returnOnEquity: '10', + cumFunding: { + allTime: '0', + sinceOpen: '0', + sinceChange: '0', + }, + }, + type: 'oneWay', + }, + ] + : [], + crossMarginSummary: { + accountValue: '1000', + totalMarginUsed: '100', + }, + }), + ), + perpDexs: jest + .fn() + .mockResolvedValue([null, { name: 'xyz', url: 'https://xyz.com' }]), + meta: jest.fn().mockImplementation((params?: { dex?: string }) => + params?.dex === 'xyz' + ? Promise.resolve({ + universe: [ + { name: 'xyz:STOCK1', szDecimals: 2, maxLeverage: 20 }, + ], + collateralToken: 0, // USDC, so the collateral gate passes + }) + : Promise.resolve({ + universe: [{ name: 'BTC', szDecimals: 3, maxLeverage: 50 }], + }), + ), + metaAndAssetCtxs: jest + .fn() + .mockImplementation((params?: { dex?: string }) => + params?.dex === 'xyz' + ? Promise.resolve([ + { + universe: [ + { name: 'xyz:STOCK1', szDecimals: 2, maxLeverage: 20 }, + ], + collateralToken: 0, + }, + [ + { + funding: '0.0001', + openInterest: '100', + prevDayPx: '95', + dayNtlVlm: '10000', + markPx: '100', + midPx: '100', + oraclePx: '100', + }, + ], + ]) + : Promise.resolve([ + { + universe: [ + { name: 'BTC', szDecimals: 3, maxLeverage: 50 }, + ], + }, + [ + { + funding: '0.0001', + openInterest: '1000', + prevDayPx: '49000', + dayNtlVlm: '1000000', + markPx: '50000', + midPx: '50000', + oraclePx: '50000', + }, + ], + ]), + ), + allMids: jest + .fn() + .mockImplementation((params?: { dex?: string }) => + params?.dex === 'xyz' + ? Promise.resolve({ 'xyz:STOCK1': '100' }) + : Promise.resolve({ BTC: '50000' }), + ), + }), + ); + mockClientService.getExchangeClient = jest + .fn() + .mockReturnValue(createMockExchangeClient({ order: mockOrder })); + + const result = await hip3Provider.closePosition({ + symbol: 'xyz:STOCK1', + orderType: 'market', + position: createPositionSnapshot({ + symbol: 'xyz:STOCK1', + size: '10', + marginUsed: '100', + }), + }); + + expect(result.success).toBe(true); + expect(mockOrder).toHaveBeenCalledWith( + expect.objectContaining({ + orders: [expect.objectContaining({ s: '8', r: true })], + }), + ); + }); + + it('uses the live position when the target DEX answers the uncovered-DEX lookup', async () => { + // A dropped main-DEX re-subscription still leaves the aggregate marked as + // initialized, so a cache miss there must not fail a closable position: the + // DEX is queried directly and its live 0.1 BTC wins over the 0.07 snapshot + primePositionsCache([], []); + + const result = await provider.closePosition({ + symbol: 'BTC', + orderType: 'market', + position: createPositionSnapshot({ size: '0.07' }), + }); + + expect(result.success).toBe(true); + expect(getSubmittedOrder()).toMatchObject({ s: '0.1', r: true }); + }); + + it('fails when the target DEX answers with zero positions', async () => { + // A successful clearinghouseState response holding no positions at all + // proves the position is closed. Reusing the snapshot here would submit + // exactly the stale reduce-only order this ticket exists to prevent. + primePositionsCache([], []); + mockClientService.getInfoClient = jest.fn().mockReturnValue( + createMockInfoClient({ + clearinghouseState: jest.fn().mockResolvedValue({ + marginSummary: { totalMarginUsed: '0', accountValue: '10000' }, + withdrawable: '10000', + assetPositions: [], + crossMarginSummary: { accountValue: '10000', totalMarginUsed: '0' }, + }), + }), + ); + + const result = await provider.closePosition({ + symbol: 'BTC', + orderType: 'market', + position: createPositionSnapshot({ size: '0.07' }), + }); + + expect(result.success).toBe(false); + expect(result.error).toContain('No position found for BTC'); + expect( + mockClientService.getExchangeClient().order, + ).not.toHaveBeenCalled(); + }); + + it('fails when the target DEX answers without the position but holds others', async () => { + // ETH came back from the same (main) DEX, which proves that DEX's query ran + // and BTC really is closed + primePositionsCache([], []); + mockClientService.getInfoClient = jest.fn().mockReturnValue( + createMockInfoClient({ + clearinghouseState: jest.fn().mockResolvedValue({ + marginSummary: { totalMarginUsed: '450', accountValue: '10450' }, + withdrawable: '10000', + assetPositions: [ + { + position: { + coin: 'ETH', + szi: '1.5', + entryPx: '3000', + positionValue: '4500', + unrealizedPnl: '50', + marginUsed: '450', + leverage: { type: 'cross', value: 10 }, + liquidationPx: '2700', + }, + type: 'oneWay', + }, + ], + crossMarginSummary: { + accountValue: '10450', + totalMarginUsed: '450', + }, + }), + }), + ); + + const result = await provider.closePosition({ + symbol: 'BTC', + orderType: 'market', + position: createPositionSnapshot({ size: '0.07' }), + }); + + expect(result.success).toBe(false); + expect(result.error).toContain('No position found for BTC'); + expect( + mockClientService.getExchangeClient().order, + ).not.toHaveBeenCalled(); + }); + + it('keeps the caller snapshot when the target DEX query fails', async () => { + // A rate-limited or erroring clearinghouseState proves nothing about the + // symbol, so the snapshot must stand rather than block a closable position + primePositionsCache([], []); + mockClientService.getInfoClient = jest.fn().mockReturnValue( + createMockInfoClient({ + clearinghouseState: jest + .fn() + .mockRejectedValue(new Error('429 Too Many Requests')), + }), + ); + + const result = await provider.closePosition({ + symbol: 'BTC', + orderType: 'market', + currentPrice: 50000, + position: createPositionSnapshot({ size: '0.07' }), + }); + + expect(result.success).toBe(true); + expect(getSubmittedOrder()).toMatchObject({ s: '0.07', r: true }); + }); + + it('keeps the snapshot when the HIP-3 target DEX query fails', async () => { + // The target DEX is queried directly now, so a failing xyz request is + // reported as unanswered no matter what the main DEX holds — blocking the + // close on that would strand the position. + primePositionsCache([], []); + const hip3Provider = createTestProvider({ + hip3Enabled: true, + allowlistMarkets: ['xyz:*'], + useUnifiedAccount: true, + }); + jest + .spyOn(hip3Provider, 'getPositions') + .mockResolvedValue([createPositionSnapshot({ symbol: 'BTC' })]); + const mockOrder = jest.fn().mockResolvedValue({ + status: 'ok', + response: { data: { statuses: [{ resting: { oid: 321 } }] } }, + }); + mockClientService.getInfoClient = jest.fn().mockReturnValue( + createMockInfoClient({ + // The xyz DEX query fails; the main DEX would answer, but that says + // nothing about xyz + clearinghouseState: jest + .fn() + .mockImplementation((params?: { dex?: string }) => + params?.dex === 'xyz' + ? Promise.reject(new Error('429 Too Many Requests')) + : Promise.resolve({ + marginSummary: { + totalMarginUsed: '500', + accountValue: '10500', + }, + withdrawable: '9500', + assetPositions: [], + crossMarginSummary: { + accountValue: '10500', + totalMarginUsed: '500', + }, + }), + ), + perpDexs: jest + .fn() + .mockResolvedValue([null, { name: 'xyz', url: 'https://xyz.com' }]), + meta: jest.fn().mockImplementation((params?: { dex?: string }) => + params?.dex === 'xyz' + ? Promise.resolve({ + universe: [ + { name: 'xyz:STOCK1', szDecimals: 2, maxLeverage: 20 }, + ], + collateralToken: 0, + }) + : Promise.resolve({ + universe: [{ name: 'BTC', szDecimals: 3, maxLeverage: 50 }], + }), + ), + metaAndAssetCtxs: jest + .fn() + .mockImplementation((params?: { dex?: string }) => + params?.dex === 'xyz' + ? Promise.resolve([ + { + universe: [ + { name: 'xyz:STOCK1', szDecimals: 2, maxLeverage: 20 }, + ], + collateralToken: 0, + }, + [ + { + funding: '0.0001', + openInterest: '100', + prevDayPx: '95', + dayNtlVlm: '10000', + markPx: '100', + midPx: '100', + oraclePx: '100', + }, + ], + ]) + : Promise.resolve([ + { + universe: [ + { name: 'BTC', szDecimals: 3, maxLeverage: 50 }, + ], + }, + [ + { + funding: '0.0001', + openInterest: '1000', + prevDayPx: '49000', + dayNtlVlm: '1000000', + markPx: '50000', + midPx: '50000', + oraclePx: '50000', + }, + ], + ]), + ), + allMids: jest + .fn() + .mockImplementation((params?: { dex?: string }) => + params?.dex === 'xyz' + ? Promise.resolve({ 'xyz:STOCK1': '100' }) + : Promise.resolve({ BTC: '50000' }), + ), + }), + ); + mockClientService.getExchangeClient = jest + .fn() + .mockReturnValue(createMockExchangeClient({ order: mockOrder })); + + const result = await hip3Provider.closePosition({ + symbol: 'xyz:STOCK1', + orderType: 'market', + position: createPositionSnapshot({ + symbol: 'xyz:STOCK1', + size: '10', + marginUsed: '100', + }), + }); + + expect(result.success).toBe(true); + expect(mockOrder).toHaveBeenCalledWith( + expect.objectContaining({ + orders: [expect.objectContaining({ s: '10', r: true })], + }), + ); + }); + + it('keeps the clamp when a partial close also carries usdAmount', async () => { + // The ticket's scenario for the flow clients actually use: a 50% market + // close sends size *and* usdAmount alongside a snapshot that a TP/SL fill + // has already invalidated. usdAmount is the source of truth in + // placeOrder, so it must not resurrect the pre-clamp size (0.05 > 0.04). + primePositionsCache([createPositionSnapshot({ size: '0.04' })]); + + const result = await provider.closePosition({ + symbol: 'BTC', + orderType: 'market', + size: '0.05', + usdAmount: '2500', + currentPrice: 50000, + position: createPositionSnapshot({ size: '0.1' }), + }); + + expect(result.success).toBe(true); + expect(getSubmittedOrder()).toMatchObject({ s: '0.04', r: true }); + }); + + it('clamps a close of the entire position requested by explicit size', async () => { + // size === position size is a 100% close, so usdAmount must not be able to + // recompute it upward either + primePositionsCache([createPositionSnapshot({ size: '0.1' })]); + + const result = await provider.closePosition({ + symbol: 'BTC', + orderType: 'market', + size: '0.1', + usdAmount: '5000.5', // would recompute to 0.10001 + currentPrice: 50000, + position: createPositionSnapshot({ size: '0.1' }), + }); + + expect(result.success).toBe(true); + expect(getSubmittedOrder()).toMatchObject({ s: '0.1', r: true }); + }); + + it('submits the exact position size for a full close instead of a USD-derived size', async () => { + // usdAmount / currentPrice rounds to 0.1 but leaves the notional below + // the requested USD, which previously added a whole size increment and + // submitted 0.101 for a 0.1 position. + primePositionsCache([createPositionSnapshot({ size: '0.1' })]); + + const result = await provider.closePosition({ + symbol: 'BTC', + orderType: 'market', + usdAmount: '5000.5', + priceAtCalculation: 50000, + currentPrice: 50000, + position: createPositionSnapshot({ size: '0.1' }), + }); + + expect(result.success).toBe(true); + expect(getSubmittedOrder()).toMatchObject({ s: '0.1', r: true }); + }); + + it('never rounds a partial close size up to meet the requested USD', async () => { + primePositionsCache([createPositionSnapshot({ size: '0.1' })]); + + const result = await provider.closePosition({ + symbol: 'BTC', + orderType: 'market', + size: '0.05', + usdAmount: '2500.4', // implies 0.050008 BTC at 50000 + currentPrice: 50000, + position: createPositionSnapshot({ size: '0.1' }), + }); + + expect(result.success).toBe(true); + expect(getSubmittedOrder()).toMatchObject({ s: '0.05', r: true }); + }); + + it('does not retry a close rejected for the $10 minimum with a larger size', async () => { + // Growing a close by 1.5% would exceed the position (full close) or the + // size the caller asked to close (partial close), so the minimum-value + // error must surface instead of being masked by a reduce-only rejection. + primePositionsCache([createPositionSnapshot({ size: '0.1' })]); + mockClientService.getExchangeClient = jest.fn().mockReturnValue({ + ...createMockExchangeClient(), + order: jest + .fn() + .mockRejectedValue(new Error('Order must have minimum value of $10')), + }); + + const result = await provider.closePosition({ + symbol: 'BTC', + orderType: 'market', + position: createPositionSnapshot({ size: '0.1' }), + }); + + expect(result.success).toBe(false); + expect(result.error).toContain('minimum value of $10'); + expect(mockClientService.getExchangeClient().order).toHaveBeenCalledTimes( + 1, + ); + }); + + it('does not retry a partial close rejected for the $10 minimum either', async () => { + // The retry only grows the order, and a partial close is capped at the size + // the caller asked to close, so a retry could not change the submitted + // size. Surface the minimum-value error rather than resubmitting. + primePositionsCache([ + createPositionSnapshot({ symbol: 'ETH', size: '1.5' }), + ]); + mockClientService.getExchangeClient = jest.fn().mockReturnValue({ + ...createMockExchangeClient(), + order: jest + .fn() + .mockRejectedValue(new Error('Order must have minimum value of $10')), + }); + + const result = await provider.closePosition({ + symbol: 'ETH', + orderType: 'market', + size: '0.0034', // ~$10 of a $4500 position + usdAmount: '10.20', + currentPrice: 3000, + position: createPositionSnapshot({ symbol: 'ETH', size: '1.5' }), + }); + + expect(result.success).toBe(false); + expect(result.error).toContain('minimum value of $10'); + expect(mockClientService.getExchangeClient().order).toHaveBeenCalledTimes( + 1, + ); + }); + + it('rejects a reduce-only close whose size floors to zero', async () => { + // One BTC size increment (0.001) is worth $50, so a $10 partial close + // cannot be expressed; surface a size error rather than submitting "0" + primePositionsCache([createPositionSnapshot({ size: '0.1' })]); + + const result = await provider.closePosition({ + symbol: 'BTC', + orderType: 'market', + size: '0.0002', + usdAmount: '10.00', + currentPrice: 50000, + position: createPositionSnapshot({ size: '0.1' }), + }); + + expect(result.success).toBe(false); + expect(result.error).toBe(PERPS_ERROR_CODES.ORDER_SIZE_POSITIVE); + expect( + mockClientService.getExchangeClient().order, + ).not.toHaveBeenCalled(); + }); + + it('keeps a grid-aligned close size intact despite floating point error', async () => { + // 0.123 * 1000 === 122.99999999999999, so a naive truncation would drop a + // whole increment and leave dust behind. + primePositionsCache([createPositionSnapshot({ size: '0.123' })]); + + const result = await provider.closePosition({ + symbol: 'BTC', + orderType: 'market', + position: createPositionSnapshot({ size: '0.123' }), + }); + + expect(result.success).toBe(true); + expect(getSubmittedOrder()).toMatchObject({ s: '0.123', r: true }); + }); + }); + + describe('Batch Operations', () => { + describe('cancelOrders', () => { + it('returns failure when no orders provided', async () => { + const result = await provider.cancelOrders([]); + + expect(result.success).toBe(false); + expect(result.successCount).toBe(0); + expect(result.failureCount).toBe(0); + expect(result.results).toEqual([]); + }); + + it('cancels multiple orders successfully', async () => { + mockClientService.getExchangeClient = jest.fn().mockReturnValue( + createMockExchangeClient({ + cancel: jest.fn().mockResolvedValue({ + status: 'ok', + response: { + data: { + statuses: ['success', 'success'], + }, + }, + }), + }), + ); + + const params = [ + { orderId: '123', symbol: 'BTC' }, + { orderId: '456', symbol: 'ETH' }, + ]; + + const result = await provider.cancelOrders(params); + + expect(result.success).toBe(true); + expect(result.successCount).toBe(2); + expect(result.failureCount).toBe(0); + expect(result.results).toHaveLength(2); + expect(result.results[0].success).toBe(true); + }); + + it('handles batch cancel errors', async () => { + mockClientService.getExchangeClient = jest.fn().mockReturnValue( + createMockExchangeClient({ + cancel: jest.fn().mockRejectedValue(new Error('API error')), + }), + ); + + const params = [{ orderId: '123', symbol: 'BTC' }]; + + const result = await provider.cancelOrders(params); + + expect(result.success).toBe(false); + expect(result.successCount).toBe(0); + expect(result.failureCount).toBe(1); + expect(result.results[0].success).toBe(false); + expect(result.results[0].error).toBe('API error'); + }); + + it('maps recognized per-status batch cancel rejections to a standardized code', async () => { + mockClientService.getExchangeClient = jest.fn().mockReturnValue( + createMockExchangeClient({ + cancel: jest.fn().mockResolvedValue({ + status: 'ok', + response: { + data: { + statuses: ['success', { error: 'multi-sig required' }], + }, + }, + }), + }), + ); + + const result = await provider.cancelOrders([ + { orderId: '123', symbol: 'BTC' }, + { orderId: '456', symbol: 'ETH' }, + ]); + + expect(result.successCount).toBe(1); + expect(result.failureCount).toBe(1); + expect(result.results[0].error).toBeUndefined(); + expect(result.results[1].error).toBe( + PERPS_ERROR_CODES.EXCHANGE_MULTI_SIG_REQUIRED, + ); + }); + + it('rejects a non-ok batch response even when its statuses say success', async () => { + mockClientService.getExchangeClient = jest.fn().mockReturnValue( + createMockExchangeClient({ + cancel: jest.fn().mockResolvedValue({ + status: 'err', + response: { data: { statuses: ['success'] } }, + }), + }), + ); + + const result = await provider.cancelOrders([ + { orderId: '123', symbol: 'BTC' }, + ]); + + expect(result).toStrictEqual({ + success: false, + successCount: 0, + failureCount: 1, + results: [ + { + orderId: '123', + symbol: 'BTC', + success: false, + error: PERPS_ERROR_CODES.BATCH_CANCEL_FAILED, + }, + ], + }); + }); + + it('maps recognized batch cancel rejections to a standardized code', async () => { + mockClientService.getExchangeClient = jest.fn().mockReturnValue( + createMockExchangeClient({ + cancel: jest + .fn() + .mockRejectedValue(new Error('multi-sig required')), + }), + ); + + const result = await provider.cancelOrders([ + { orderId: '123', symbol: 'BTC' }, + ]); + + expect(result.success).toBe(false); + expect(result.results[0].error).toBe( + PERPS_ERROR_CODES.EXCHANGE_MULTI_SIG_REQUIRED, + ); + }); + }); + + describe('closePositions', () => { + it('returns failure when no positions to close', async () => { + mockClientService.getInfoClient = jest.fn().mockReturnValue( + createMockInfoClient({ + clearinghouseState: jest.fn().mockResolvedValue({ + marginSummary: { totalMarginUsed: '0', accountValue: '10000' }, + withdrawable: '10000', + assetPositions: [], + crossMarginSummary: { + accountValue: '10000', + totalMarginUsed: '0', + }, + }), + }), + ); + + const result = await provider.closePositions({ closeAll: true }); + + expect(result.success).toBe(false); + expect(result.successCount).toBe(0); + expect(result.failureCount).toBe(0); + expect(result.results).toEqual([]); + }); + + it('closes multiple positions successfully', async () => { + mockClientService.getInfoClient = jest.fn().mockReturnValue( + createMockInfoClient({ + clearinghouseState: jest.fn().mockResolvedValue({ + marginSummary: { totalMarginUsed: '1500', accountValue: '11500' }, + withdrawable: '10000', + assetPositions: [ + { + position: { + coin: 'BTC', + szi: '1.5', + entryPx: '50000', + positionValue: '75000', + unrealizedPnl: '100', + marginUsed: '1000', + leverage: { type: 'cross', value: 10 }, + liquidationPx: '45000', + }, + type: 'oneWay', + }, + { + position: { + coin: 'ETH', + szi: '-2.0', + entryPx: '3000', + positionValue: '6000', + unrealizedPnl: '50', + marginUsed: '500', + leverage: { type: 'cross', value: 10 }, + liquidationPx: '3300', + }, + type: 'oneWay', + }, + ], + crossMarginSummary: { + accountValue: '11500', + totalMarginUsed: '1500', + }, + }), + meta: jest.fn().mockResolvedValue({ + universe: [ + { name: 'BTC', szDecimals: 3, maxLeverage: 50 }, + { name: 'ETH', szDecimals: 4, maxLeverage: 50 }, + ], + }), + allMids: jest.fn().mockResolvedValue({ + BTC: '50000', + ETH: '3000', + }), + }), + ); + + const exchangeClient = createMockExchangeClient({ + order: jest.fn().mockResolvedValue({ + response: { + data: { + statuses: [{ filled: {} }, { filled: {} }], + }, + }, + }), + }); + mockClientService.getExchangeClient = jest + .fn() + .mockReturnValue(exchangeClient); + + const result = await provider.closePositions({ closeAll: true }); + + expect(result.success).toBe(true); + expect(result.successCount).toBe(2); + expect(result.failureCount).toBe(0); + expect(result.results).toHaveLength(2); + expect(result.results[0].symbol).toBe('BTC'); + expect(result.results[1].symbol).toBe('ETH'); + expect(exchangeClient.order.mock.calls[0][0].builder).toStrictEqual({ + b: BUILDER_FEE_CONFIG.MainnetBuilder, + f: BUILDER_FEE_CONFIG.MaxFeeTenthsBps, + }); + }); + + it('rounds each reduce-only close size down to the size grid', async () => { + // 0.1005 would toFixed(3) up to 0.101 — above the position, which + // HyperLiquid rejects with "Reduce only order would increase position" + const mockOrder = jest.fn().mockResolvedValue({ + response: { data: { statuses: [{ filled: {} }] } }, + }); + mockClientService.getInfoClient = jest.fn().mockReturnValue( + createMockInfoClient({ + clearinghouseState: jest.fn().mockResolvedValue({ + marginSummary: { totalMarginUsed: '500', accountValue: '10500' }, + withdrawable: '10000', + assetPositions: [ + { + position: { + coin: 'BTC', + szi: '0.1005', + entryPx: '50000', + positionValue: '5025', + unrealizedPnl: '100', + marginUsed: '500', + leverage: { type: 'cross', value: 10 }, + liquidationPx: '45000', + }, + type: 'oneWay', + }, + ], + crossMarginSummary: { + accountValue: '10500', + totalMarginUsed: '500', + }, + }), + meta: jest.fn().mockResolvedValue({ + universe: [{ name: 'BTC', szDecimals: 3, maxLeverage: 50 }], + }), + allMids: jest.fn().mockResolvedValue({ BTC: '50000' }), + }), + ); + mockClientService.getExchangeClient = jest + .fn() + .mockReturnValue(createMockExchangeClient({ order: mockOrder })); + + const result = await provider.closePositions({ closeAll: true }); + + expect(result.success).toBe(true); + expect(mockOrder).toHaveBeenCalledWith( + expect.objectContaining({ + orders: [expect.objectContaining({ s: '0.1', r: true })], + }), + ); + }); + + it('reports a position smaller than one size increment as failed and still closes the rest', async () => { + // 0.0004 BTC floors to 0 at szDecimals 3; submitting "0" would be + // rejected. The remaining position must still close with its own status + // mapped to the right symbol, and the skipped one must surface as a + // failure so a caller cannot read "closed everything" from the result + const mockOrder = jest.fn().mockResolvedValue({ + response: { data: { statuses: [{ filled: {} }] } }, + }); + mockClientService.getInfoClient = jest.fn().mockReturnValue( + createMockInfoClient({ + clearinghouseState: jest.fn().mockResolvedValue({ + marginSummary: { totalMarginUsed: '500', accountValue: '10500' }, + withdrawable: '10000', + assetPositions: [ + { + position: { + coin: 'BTC', + szi: '0.0004', // dust + entryPx: '50000', + positionValue: '20', + unrealizedPnl: '1', + marginUsed: '2', + leverage: { type: 'cross', value: 10 }, + liquidationPx: '45000', + }, + type: 'oneWay', + }, + { + position: { + coin: 'ETH', + szi: '1.5', + entryPx: '3000', + positionValue: '4500', + unrealizedPnl: '50', + marginUsed: '450', + leverage: { type: 'cross', value: 10 }, + liquidationPx: '2700', + }, + type: 'oneWay', + }, + ], + crossMarginSummary: { + accountValue: '10500', + totalMarginUsed: '500', + }, + }), + }), + ); + mockClientService.getExchangeClient = jest + .fn() + .mockReturnValue(createMockExchangeClient({ order: mockOrder })); + + const result = await provider.closePositions({ closeAll: true }); + + expect(mockOrder).toHaveBeenCalledWith( + expect.objectContaining({ + orders: [expect.objectContaining({ s: '1.5', r: true })], + }), + ); + expect(result).toMatchObject({ + success: true, + successCount: 1, + failureCount: 1, + }); + // results keeps the requested order (BTC dust first, then ETH), so a + // consumer correlating results to positions by index stays correct + expect(result.results).toStrictEqual([ + { + symbol: 'BTC', + success: false, + error: PERPS_ERROR_CODES.ORDER_SIZE_POSITIVE, + }, + { symbol: 'ETH', success: true, error: undefined }, + ]); + }); + + it('credits a HIP-3 margin transfer only to its own order in a mixed batch', async () => { + // [BTC (main), xyz:STOCK1 (HIP-3)] where BTC fills and the HIP-3 close + // fails. A transfer list compacted to HIP-3 positions only would be read + // with BTC's status index and move xyz margin for a close that failed. + const hip3Provider = createTestProvider({ + hip3Enabled: true, + allowlistMarkets: ['xyz:*'], + // Manual transfer-back only runs outside unified accounts, which is + // the mode this alignment matters in + useUnifiedAccount: false, + initialAssetMapping: [ + ['BTC', 0], + ['xyz:STOCK1', 110000], + ], + }); + const mockOrder = jest.fn().mockResolvedValue({ + response: { + data: { + statuses: [ + { filled: {} }, + { error: 'Reduce only order rejected' }, + ], + }, + }, + }); + mockClientService.getInfoClient = jest.fn().mockReturnValue( + createMockInfoClient({ + clearinghouseState: jest + .fn() + .mockImplementation((params?: { dex?: string }) => + Promise.resolve({ + marginSummary: { + totalMarginUsed: '600', + accountValue: '10600', + }, + withdrawable: '10000', + assetPositions: + params?.dex === 'xyz' + ? [ + { + position: { + coin: 'xyz:STOCK1', + szi: '10', + entryPx: '95', + positionValue: '1000', + unrealizedPnl: '50', + marginUsed: '100', + leverage: { type: 'isolated', value: 5 }, + liquidationPx: '70', + }, + type: 'oneWay', + }, + ] + : [ + { + position: { + coin: 'BTC', + szi: '0.1', + entryPx: '50000', + positionValue: '5000', + unrealizedPnl: '100', + marginUsed: '500', + leverage: { type: 'cross', value: 10 }, + liquidationPx: '45000', + }, + type: 'oneWay', + }, + ], + crossMarginSummary: { + accountValue: '10600', + totalMarginUsed: '600', + }, + }), + ), + perpDexs: jest + .fn() + .mockResolvedValue([ + null, + { name: 'xyz', url: 'https://xyz.com' }, + ]), + meta: jest.fn().mockImplementation((params?: { dex?: string }) => + params?.dex === 'xyz' + ? Promise.resolve({ + universe: [ + { name: 'xyz:STOCK1', szDecimals: 2, maxLeverage: 20 }, + ], + collateralToken: 0, + }) + : Promise.resolve({ + universe: [{ name: 'BTC', szDecimals: 3, maxLeverage: 50 }], + }), + ), + metaAndAssetCtxs: jest + .fn() + .mockImplementation((params?: { dex?: string }) => + params?.dex === 'xyz' + ? Promise.resolve([ + { + universe: [ + { + name: 'xyz:STOCK1', + szDecimals: 2, + maxLeverage: 20, + }, + ], + collateralToken: 0, + }, + [ + { + funding: '0.0001', + openInterest: '100', + prevDayPx: '95', + dayNtlVlm: '10000', + markPx: '100', + midPx: '100', + oraclePx: '100', + }, + ], + ]) + : Promise.resolve([ + { + universe: [ + { name: 'BTC', szDecimals: 3, maxLeverage: 50 }, + ], + }, + [ + { + funding: '0.0001', + openInterest: '1000', + prevDayPx: '49000', + dayNtlVlm: '1000000', + markPx: '50000', + midPx: '50000', + oraclePx: '50000', + }, + ], + ]), + ), + allMids: jest + .fn() + .mockImplementation((params?: { dex?: string }) => + params?.dex === 'xyz' + ? Promise.resolve({ 'xyz:STOCK1': '100' }) + : Promise.resolve({ BTC: '50000' }), + ), + }), + ); + mockClientService.getExchangeClient = jest + .fn() + .mockReturnValue(createMockExchangeClient({ order: mockOrder })); + (mockPlatformDependencies.debugLogger.log as jest.Mock).mockClear(); + + const result = await hip3Provider.closePositions({ closeAll: true }); + + expect(result.successCount).toBe(1); + expect(result.failureCount).toBe(1); + // The HIP-3 close failed, so no freed-margin transfer may be initiated — + // the successful BTC order carries no transfer of its own + expect( + mockPlatformDependencies.debugLogger.log, + ).not.toHaveBeenCalledWith( + 'Position closed successfully, initiating manual auto-transfer back', + expect.anything(), + ); + }); + + it('reports every position as failed when all of them are dust', async () => { + // An empty result here would be indistinguishable from "no positions + // matched", so each skipped position carries its own failure + const mockOrder = jest.fn(); + mockClientService.getInfoClient = jest.fn().mockReturnValue( + createMockInfoClient({ + clearinghouseState: jest.fn().mockResolvedValue({ + marginSummary: { totalMarginUsed: '5', accountValue: '10005' }, + withdrawable: '10000', + assetPositions: [ + { + position: { + coin: 'BTC', + szi: '0.0004', + entryPx: '50000', + positionValue: '20', + unrealizedPnl: '1', + marginUsed: '2', + leverage: { type: 'cross', value: 10 }, + liquidationPx: '45000', + }, + type: 'oneWay', + }, + ], + crossMarginSummary: { + accountValue: '10005', + totalMarginUsed: '5', + }, + }), + }), + ); + mockClientService.getExchangeClient = jest + .fn() + .mockReturnValue(createMockExchangeClient({ order: mockOrder })); + + const result = await provider.closePositions({ closeAll: true }); + + expect(mockOrder).not.toHaveBeenCalled(); + expect(result).toMatchObject({ + success: false, + successCount: 0, + failureCount: 1, + }); + expect(result.results).toStrictEqual([ + { + symbol: 'BTC', + success: false, + error: PERPS_ERROR_CODES.ORDER_SIZE_POSITIVE, + }, + ]); + }); + + it('handles batch close errors', async () => { + mockClientService.getInfoClient = jest.fn().mockReturnValue( + createMockInfoClient({ + clearinghouseState: jest.fn().mockResolvedValue({ + marginSummary: { totalMarginUsed: '1000', accountValue: '11000' }, + withdrawable: '10000', + assetPositions: [ + { + position: { + coin: 'BTC', + szi: '1.0', + entryPx: '50000', + positionValue: '50000', + unrealizedPnl: '100', + marginUsed: '1000', + leverage: { type: 'cross', value: 10 }, + liquidationPx: '45000', + }, + type: 'oneWay', + }, + ], + crossMarginSummary: { + accountValue: '11000', + totalMarginUsed: '1000', + }, + }), + }), + ); + + mockClientService.getExchangeClient = jest.fn().mockReturnValue( + createMockExchangeClient({ + order: jest.fn().mockRejectedValue(new Error('Order failed')), + }), + ); + + const result = await provider.closePositions({ closeAll: true }); + + expect(result.success).toBe(false); + expect(result.successCount).toBe(0); + expect(result.failureCount).toBe(1); + expect(result.results[0].success).toBe(false); + expect(result.results[0].error).toBe('Order failed'); + }); + }); + }); + + describe('updatePositionTPSL', () => { + it('updates position TP/SL successfully', async () => { + const updateParams = { + symbol: 'ETH', + takeProfitPrice: '3500', + stopLossPrice: '2500', + }; + + const result = await provider.updatePositionTPSL(updateParams); + + expect(result.success).toBe(true); + expect(result.orderId).toBeDefined(); + }); + + it('handles update with only take profit price', async () => { + const updateParams = { + symbol: 'ETH', + takeProfitPrice: '3500', + }; + + const result = await provider.updatePositionTPSL(updateParams); + + expect(result.success).toBe(true); + }); + + it('handles update with only stop loss price', async () => { + const updateParams = { + symbol: 'ETH', + stopLossPrice: '2500', + }; + + const result = await provider.updatePositionTPSL(updateParams); + + expect(result.success).toBe(true); + }); + }); +}); diff --git a/packages/perps-controller/tests/src/providers/HyperLiquidProvider.validation.test.ts b/packages/perps-controller/tests/src/providers/HyperLiquidProvider.validation.test.ts new file mode 100644 index 00000000000..f99a0c027d8 --- /dev/null +++ b/packages/perps-controller/tests/src/providers/HyperLiquidProvider.validation.test.ts @@ -0,0 +1,1402 @@ +/* eslint-disable */ +jest.mock('@nktkas/hyperliquid', () => ({})); + +import type { CaipAssetId, Hex } from '@metamask/utils'; + +import { CandlePeriod } from '../../../src/constants/chartConfig.js'; +import { + BUILDER_FEE_CONFIG, + REFERRAL_CONFIG, +} from '../../../src/constants/hyperLiquidConfig.js'; +import { PERPS_TRANSACTIONS_HISTORY_CONSTANTS } from '../../../src/constants/transactionsHistoryConfig.js'; +import { PERPS_ERROR_CODES } from '../../../src/perpsErrorCodes.js'; +import { HyperLiquidProvider } from '../../../src/providers/HyperLiquidProvider.js'; +import { HyperLiquidClientService } from '../../../src/services/HyperLiquidClientService.js'; +import { HyperLiquidSubscriptionService } from '../../../src/services/HyperLiquidSubscriptionService.js'; +import { HyperLiquidWalletService } from '../../../src/services/HyperLiquidWalletService.js'; +import { TradingReadinessCache } from '../../../src/services/TradingReadinessCache.js'; +import type { + ClosePositionParams, + DepositParams, + Order, + PerpsPlatformDependencies, + LiveDataConfig, + OrderParams, +} from '../../../src/types/index.js'; +import { + validateAssetSupport, + validateBalance, + validateCoinExists, + validateDepositParams, + validateOrderParams, + validateWithdrawalParams, +} from '../../../src/utils/hyperLiquidValidation.js'; +import { createStandaloneInfoClient } from '../../../src/utils/standaloneInfoClient.js'; +import { + createMockInfrastructure, + createMockMessenger, +} from '../../helpers/serviceMocks.js'; + +jest.mock('../../../src/services/HyperLiquidClientService'); +jest.mock('../../../src/services/HyperLiquidWalletService'); +jest.mock('../../../src/services/HyperLiquidSubscriptionService'); +// Mock stream manager - will be set up in test +let mockStreamManagerInstance: any; +const mockGetStreamManagerInstance = jest.fn(() => mockStreamManagerInstance); +jest.mock( + '../../../../components/UI/Perps/providers/PerpsStreamManager', + () => ({ + getStreamManagerInstance: mockGetStreamManagerInstance, + }), + { virtual: true }, +); + +// Mock standalone info client for standalone mode tests +let mockStandaloneInfoClient: any; +jest.mock('../../../src/utils/standaloneInfoClient', () => ({ + ...jest.requireActual('../../../src/utils/standaloneInfoClient'), + createStandaloneInfoClient: jest.fn(() => mockStandaloneInfoClient), +})); + +jest.mock('../../../src/utils/hyperLiquidValidation', () => ({ + validateOrderParams: jest.fn(), + validateWithdrawalParams: jest.fn(), + validateDepositParams: jest.fn(), + validateCoinExists: jest.fn(), + validateAssetSupport: jest.fn(), + validateBalance: jest.fn(), + getSupportedPaths: jest + .fn() + .mockReturnValue([ + 'eip155:42161/erc20:0xa0b86a33e6776e681a06e0e1622c5e5e3e6a8b13/default', + 'eip155:1/erc20:0xa0b86a33e6776e681a06e0e1622c5e5e3e6a8b13/default', + ]), + getBridgeInfo: jest.fn().mockReturnValue({ + chainId: 'eip155:42161', + contractAddress: '0x1234567890123456789012345678901234567890', + }), + createErrorResult: jest.fn((error, defaultResponse) => ({ + ...defaultResponse, + success: false, + error: error instanceof Error ? error.message : String(error), + })), +})); + +// Mock adapter functions +jest.mock('../../../src/utils/hyperLiquidAdapter', () => { + const actual = jest.requireActual('../../../src/utils/hyperLiquidAdapter'); + return { + ...actual, + adaptHyperLiquidLedgerUpdateToUserHistoryItem: jest.fn((updates) => { + // Return mock history items based on input + if (!updates || !Array.isArray(updates) || updates.length === 0) { + return []; + } + return updates.map((_update: unknown) => ({ + type: 'deposit' as const, + amount: '100', + timestamp: Date.now(), + hash: '0x123', + })); + }), + }; +}); + +// Mock TradingReadinessCache - global singleton for signing operation caching +// Use jest.createMockFromModule for proper mock creation +jest.mock('../../../src/services/TradingReadinessCache'); + +const MockedHyperLiquidClientService = + HyperLiquidClientService as jest.MockedClass; +const MockedHyperLiquidWalletService = + HyperLiquidWalletService as jest.MockedClass; +const MockedHyperLiquidSubscriptionService = + HyperLiquidSubscriptionService as jest.MockedClass< + typeof HyperLiquidSubscriptionService + >; +const mockValidateOrderParams = validateOrderParams as jest.MockedFunction< + typeof validateOrderParams +>; +const mockValidateWithdrawalParams = + validateWithdrawalParams as jest.MockedFunction< + typeof validateWithdrawalParams + >; +const mockValidateDepositParams = validateDepositParams as jest.MockedFunction< + typeof validateDepositParams +>; +const mockValidateCoinExists = validateCoinExists as jest.MockedFunction< + typeof validateCoinExists +>; +const mockValidateAssetSupport = validateAssetSupport as jest.MockedFunction< + typeof validateAssetSupport +>; +const mockValidateBalance = validateBalance as jest.MockedFunction< + typeof validateBalance +>; + +// Mock factory functions - defined once, reused everywhere +// These reduce duplication and make tests more maintainable +const createMockInfoClient = (overrides: Record = {}) => ({ + clearinghouseState: jest.fn().mockResolvedValue({ + marginSummary: { + totalMarginUsed: '500', + accountValue: '10500', + }, + withdrawable: '9500', + assetPositions: [ + { + position: { + coin: 'BTC', + szi: '0.1', + entryPx: '50000', + positionValue: '5000', + unrealizedPnl: '100', + marginUsed: '500', + leverage: { type: 'cross', value: 10 }, + liquidationPx: '45000', + maxLeverage: 50, + returnOnEquity: '20', + cumFunding: { allTime: '10', sinceOpen: '5', sinceChange: '2' }, + }, + type: 'oneWay', + }, + { + position: { + coin: 'ETH', + szi: '1.5', + entryPx: '3000', + positionValue: '4500', + unrealizedPnl: '50', + marginUsed: '450', + leverage: { type: 'cross', value: 10 }, + liquidationPx: '2700', + maxLeverage: 50, + returnOnEquity: '10', + cumFunding: { allTime: '5', sinceOpen: '2', sinceChange: '1' }, + }, + type: 'oneWay', + }, + ], + crossMarginSummary: { + accountValue: '10000', + totalMarginUsed: '5000', + }, + }), + spotClearinghouseState: jest.fn().mockResolvedValue({ + balances: [{ coin: 'USDC', hold: '1000', total: '10000' }], + }), + // Mode-aware fold gate reads userAbstraction; default to unifiedAccount + // so tests that predated the gate still see spot folded into spendable/withdrawable. + userAbstraction: jest.fn().mockResolvedValue('unifiedAccount'), + meta: jest.fn().mockResolvedValue({ + universe: [ + { name: 'BTC', szDecimals: 3, maxLeverage: 50 }, + { name: 'ETH', szDecimals: 4, maxLeverage: 50 }, + ], + }), + metaAndAssetCtxs: jest.fn().mockResolvedValue([ + { + universe: [ + { name: 'BTC', szDecimals: 3, maxLeverage: 50 }, + { name: 'ETH', szDecimals: 4, maxLeverage: 50 }, + ], + }, + [ + { + funding: '0.0001', + openInterest: '1000', + prevDayPx: '49000', + dayNtlVlm: '1000000', + markPx: '50000', + midPx: '50000', + oraclePx: '50000', + }, + { + funding: '0.0001', + openInterest: '500', + prevDayPx: '2900', + dayNtlVlm: '500000', + markPx: '3000', + midPx: '3000', + oraclePx: '3000', + }, + ], + ]), + perpDexs: jest.fn().mockResolvedValue([null]), + allMids: jest.fn().mockResolvedValue({ BTC: '50000', ETH: '3000' }), + frontendOpenOrders: jest.fn().mockResolvedValue([]), + referral: jest.fn().mockResolvedValue({ + referrerState: { + stage: 'ready', + data: { code: 'MMCSI' }, + }, + }), + maxBuilderFee: jest.fn().mockResolvedValue(1), + userFees: jest.fn().mockResolvedValue({ + feeSchedule: { + cross: '0.00030', + add: '0.00010', + spotCross: '0.00040', + spotAdd: '0.00020', + }, + dailyUserVlm: [], + }), + userNonFundingLedgerUpdates: jest.fn().mockResolvedValue([ + { + delta: { type: 'deposit', usdc: '100' }, + time: Date.now(), + hash: '0x123abc', + }, + { + delta: { type: 'withdraw', usdc: '50' }, + time: Date.now() - 3600000, + hash: '0x456def', + }, + ]), + portfolio: jest.fn().mockResolvedValue([ + null, + [ + null, + { + accountValueHistory: [ + [Date.now() - 86400000, '10000'], // 24h ago + [Date.now() - 172800000, '9500'], // 48h ago + [Date.now() - 259200000, '9000'], // 72h ago + ], + }, + ], + ]), + spotMeta: jest.fn().mockResolvedValue({ + tokens: [ + { name: 'USDC', tokenId: '0xdef456', index: 0 }, + { name: 'USDT', tokenId: '0x789abc', index: 1 }, + ], + universe: [], + }), + historicalOrders: jest.fn().mockResolvedValue([]), + userFills: jest.fn().mockResolvedValue([]), + userFillsByTime: jest.fn().mockResolvedValue([]), + userFunding: jest.fn().mockResolvedValue([]), + ...overrides, +}); + +const createMockExchangeClient = (overrides: Record = {}) => ({ + order: jest.fn().mockResolvedValue({ + status: 'ok', + response: { data: { statuses: [{ resting: { oid: 123 } }] } }, + }), + modify: jest.fn().mockResolvedValue({ + status: 'ok', + response: { data: { statuses: [{ resting: { oid: '123' } }] } }, + }), + cancel: jest.fn().mockResolvedValue({ + status: 'ok', + response: { data: { statuses: ['success'] } }, + }), + withdraw3: jest.fn().mockResolvedValue({ + status: 'ok', + }), + updateLeverage: jest.fn().mockResolvedValue({ + status: 'ok', + }), + approveBuilderFee: jest.fn().mockResolvedValue({ + status: 'ok', + }), + setReferrer: jest.fn().mockResolvedValue({ + status: 'ok', + }), + sendAsset: jest.fn().mockResolvedValue({ + status: 'ok', + }), + agentSetAbstraction: jest.fn().mockResolvedValue({ + status: 'ok', + }), + userSetAbstraction: jest.fn().mockResolvedValue({ + status: 'ok', + }), + ...overrides, +}); + +// Create shared mock platform dependencies for provider tests +const mockPlatformDependencies: PerpsPlatformDependencies = + createMockInfrastructure(); + +const mockMessenger = createMockMessenger(); + +/** + * Helper to create HyperLiquidProvider with mock platform dependencies + * @param options + * @param options.isTestnet + * @param options.hip3Enabled + * @param options.allowlistMarkets + * @param options.blocklistMarkets + * @param options.useUnifiedAccount + */ +const createTestProvider = ( + options: { + isTestnet?: boolean; + hip3Enabled?: boolean; + allowlistMarkets?: string[]; + blocklistMarkets?: string[]; + useUnifiedAccount?: boolean; + initialAssetMapping?: [string, number][]; + } = {}, +): HyperLiquidProvider => + new HyperLiquidProvider({ + ...options, + platformDependencies: mockPlatformDependencies, + messenger: mockMessenger, + }); + +describe('HyperLiquidProvider', () => { + let provider: HyperLiquidProvider; + let mockClientService: jest.Mocked; + let mockWalletService: jest.Mocked; + let mockSubscriptionService: jest.Mocked; + + beforeEach(() => { + // Reset all mocks + jest.clearAllMocks(); + ( + mockPlatformDependencies.marketDataFormatters.formatVolume as jest.Mock + ).mockImplementation((value: number) => '$' + value.toFixed(0)); + ( + mockPlatformDependencies.marketDataFormatters.formatPerpsFiat as jest.Mock + ).mockImplementation((value: number) => '$' + value.toFixed(2)); + ( + mockPlatformDependencies.marketDataFormatters + .formatPercentage as jest.Mock + ).mockImplementation((value: number) => `${value.toFixed(2)}%`); + ( + mockPlatformDependencies.featureFlags.validateVersionGated as jest.Mock + ).mockReturnValue(undefined); + (mockPlatformDependencies.metrics.isEnabled as jest.Mock).mockReturnValue( + true, + ); + + // Reset TradingReadinessCache mock state (using imported mocked module) + const mockedCache = TradingReadinessCache as jest.Mocked< + typeof TradingReadinessCache + >; + mockedCache.get.mockReturnValue(undefined); + mockedCache.getBuilderFee.mockReturnValue(undefined); + mockedCache.getReferral.mockReturnValue(undefined); + mockedCache.isInFlight.mockReturnValue(undefined); + mockedCache.setInFlight.mockReturnValue(jest.fn()); + + // Initialize mock stream manager instance + mockStreamManagerInstance = { + clearAllChannels: jest.fn(), + }; + + // Create mocked service instances using factory functions + mockClientService = { + initialize: jest.fn(), + isInitialized: jest.fn().mockReturnValue(true), + isTestnetMode: jest.fn().mockReturnValue(false), + ensureInitialized: jest.fn(), + getExchangeClient: jest.fn().mockReturnValue(createMockExchangeClient()), + getInfoClient: jest.fn().mockReturnValue(createMockInfoClient()), + fetchHistoricalOrders: jest.fn().mockResolvedValue([]), + disconnect: jest.fn().mockResolvedValue(undefined), + toggleTestnet: jest.fn(), + setTestnetMode: jest.fn(), + getNetwork: jest.fn().mockReturnValue('mainnet'), + ensureSubscriptionClient: jest.fn().mockResolvedValue(undefined), + getSubscriptionClient: jest.fn(), + setOnReconnectCallback: jest.fn(), + setOnTerminateCallback: jest.fn(), + getConnectionState: jest.fn().mockReturnValue('connected'), + } as Partial as jest.Mocked; + + mockWalletService = { + setTestnetMode: jest.fn(), + getCurrentAccountId: jest + .fn() + .mockReturnValue( + 'eip155:42161:0x1234567890123456789012345678901234567890', + ), + createWalletAdapter: jest.fn().mockReturnValue({ + request: jest + .fn() + .mockResolvedValue(['0x1234567890123456789012345678901234567890']), + }), + getUserAddress: jest + .fn() + .mockReturnValue('0x1234567890123456789012345678901234567890'), + getUserAddressWithDefault: jest + .fn() + .mockResolvedValue('0x1234567890123456789012345678901234567890'), + isKeyringUnlocked: jest.fn().mockReturnValue(true), + isSelectedHardwareWallet: jest.fn().mockReturnValue(false), + } as Partial as jest.Mocked; + + mockSubscriptionService = { + subscribeToPrices: jest.fn().mockResolvedValue(jest.fn()), // Returns Promise + subscribeToPositions: jest.fn().mockReturnValue(jest.fn()), // Returns function directly + subscribeToOrderFills: jest.fn().mockReturnValue(jest.fn()), // Returns function directly + clearAll: jest.fn(), + isPositionsCacheInitialized: jest.fn().mockReturnValue(false), + getCachedPositions: jest.fn().mockReturnValue([]), + updateFeatureFlags: jest.fn().mockResolvedValue(undefined), + // Cache methods used by buildAssetMapping optimization + setDexMetaCache: jest.fn(), + setDexAssetCtxsCache: jest.fn(), + getDexAssetCtxsCache: jest.fn().mockReturnValue(undefined), + // Price cache used by placeOrder, editOrder, closePosition optimizations + getCachedPrice: jest.fn().mockImplementation((symbol: string) => { + const prices: Record = { BTC: '50000', ETH: '3000' }; + return prices[symbol]; + }), + getLastAllMidsSnapshot: jest.fn().mockReturnValue(null), + // Orders cache used by updatePositionTPSL and getOpenOrders + isOrdersCacheInitialized: jest.fn().mockReturnValue(false), + getCachedOrders: jest.fn().mockReturnValue([]), + // Atomic getter - returns null when cache not initialized (prevents race condition) + getOrdersCacheIfInitialized: jest.fn().mockReturnValue(null), + // Abstraction-mode resolved-mode setter (unified account migration) + setUserAbstractionMode: jest.fn(), + } as Partial as jest.Mocked; + + // Mock constructors + MockedHyperLiquidClientService.mockImplementation(() => mockClientService); + MockedHyperLiquidWalletService.mockImplementation(() => mockWalletService); + MockedHyperLiquidSubscriptionService.mockImplementation( + () => mockSubscriptionService, + ); + + // Mock validation + mockValidateOrderParams.mockReturnValue({ isValid: true }); + mockValidateWithdrawalParams.mockReturnValue({ isValid: true }); + mockValidateDepositParams.mockReturnValue({ isValid: true }); + mockValidateCoinExists.mockReturnValue({ isValid: true }); + mockValidateAssetSupport.mockReturnValue({ isValid: true }); + mockValidateBalance.mockReturnValue({ isValid: true }); + const hyperLiquidValidation = jest.requireMock( + '../../../src/utils/hyperLiquidValidation', + ); + hyperLiquidValidation.getSupportedPaths.mockReturnValue([ + 'eip155:42161/erc20:0xa0b86a33e6776e681a06e0e1622c5e5e3e6a8b13/default', + 'eip155:1/erc20:0xa0b86a33e6776e681a06e0e1622c5e5e3e6a8b13/default', + ]); + hyperLiquidValidation.getBridgeInfo.mockReturnValue({ + chainId: 'eip155:42161', + contractAddress: '0x1234567890123456789012345678901234567890', + }); + hyperLiquidValidation.createErrorResult.mockImplementation( + (error: unknown, defaultResponse: Record) => ({ + ...defaultResponse, + success: false, + error: error instanceof Error ? error.message : String(error), + }), + ); + const hyperLiquidAdapter = jest.requireMock( + '../../../src/utils/hyperLiquidAdapter', + ); + hyperLiquidAdapter.adaptHyperLiquidLedgerUpdateToUserHistoryItem.mockImplementation( + (updates: unknown[]) => { + if (!updates || !Array.isArray(updates) || updates.length === 0) { + return []; + } + return updates.map(() => ({ + type: 'deposit' as const, + amount: '100', + timestamp: Date.now(), + hash: '0x123', + })); + }, + ); + + provider = createTestProvider({ + initialAssetMapping: [ + ['BTC', 0], + ['ETH', 1], + ], + }); + }); + describe('validateDeposit', () => { + it('validates valid deposit parameters', async () => { + mockValidateDepositParams.mockReturnValue({ isValid: true }); + + const params: DepositParams = { + amount: '100', + assetId: + 'eip155:42161/erc20:0xaf88d065e77c8cC2239327C5EDb3A432268e5831/default', + }; + + const result = await provider.validateDeposit(params); + + expect(result.isValid).toBe(true); + expect(result.error).toBeUndefined(); + }); + + it('rejects empty amount', async () => { + mockValidateDepositParams.mockReturnValue({ + isValid: false, + error: 'Amount is required and must be greater than 0', + }); + + const params: DepositParams = { + amount: '', + assetId: + 'eip155:42161/erc20:0xaf88d065e77c8cC2239327C5EDb3A432268e5831/default', + }; + + const result = await provider.validateDeposit(params); + + expect(result.isValid).toBe(false); + expect(result.error).toBe( + 'Amount is required and must be greater than 0', + ); + }); + + it('rejects zero amount', async () => { + mockValidateDepositParams.mockReturnValue({ + isValid: false, + error: 'Amount is required and must be greater than 0', + }); + + const params: DepositParams = { + amount: '0', + assetId: + 'eip155:42161/erc20:0xaf88d065e77c8cC2239327C5EDb3A432268e5831/default', + }; + + const result = await provider.validateDeposit(params); + + expect(result.isValid).toBe(false); + expect(result.error).toBe( + 'Amount is required and must be greater than 0', + ); + }); + + it('rejects negative amount', async () => { + mockValidateDepositParams.mockReturnValue({ + isValid: false, + error: 'Amount is required and must be greater than 0', + }); + + const params: DepositParams = { + amount: '-10', + assetId: + 'eip155:42161/erc20:0xaf88d065e77c8cC2239327C5EDb3A432268e5831/default', + }; + + const result = await provider.validateDeposit(params); + + expect(result.isValid).toBe(false); + expect(result.error).toBe( + 'Amount is required and must be greater than 0', + ); + }); + + it('rejects invalid amount format', async () => { + mockValidateDepositParams.mockReturnValue({ + isValid: false, + error: 'Amount is required and must be greater than 0', + }); + + const params: DepositParams = { + amount: 'abc', + assetId: + 'eip155:42161/erc20:0xaf88d065e77c8cC2239327C5EDb3A432268e5831/default', + }; + + const result = await provider.validateDeposit(params); + + expect(result.isValid).toBe(false); + expect(result.error).toBe( + 'Amount is required and must be greater than 0', + ); + }); + + it('rejects amount below minimum for mainnet', async () => { + mockClientService.isTestnetMode.mockReturnValue(false); + mockValidateDepositParams.mockReturnValue({ + isValid: false, + error: 'Minimum deposit amount is 5 USDC', + }); + + const params: DepositParams = { + amount: '4.99', + assetId: + 'eip155:42161/erc20:0xaf88d065e77c8cC2239327C5EDb3A432268e5831/default', + }; + + const result = await provider.validateDeposit(params); + + expect(result.isValid).toBe(false); + expect(result.error).toBe('Minimum deposit amount is 5 USDC'); + }); + + it('rejects amount below minimum for testnet', async () => { + mockClientService.isTestnetMode.mockReturnValue(true); + mockValidateDepositParams.mockReturnValue({ + isValid: false, + error: 'Minimum deposit amount is 10 USDC', + }); + + const params: DepositParams = { + amount: '9.99', + assetId: + 'eip155:42161/erc20:0xaf88d065e77c8cC2239327C5EDb3A432268e5831/default', + }; + + const result = await provider.validateDeposit(params); + + expect(result.isValid).toBe(false); + expect(result.error).toBe('Minimum deposit amount is 10 USDC'); + }); + + it('accepts amount at minimum for mainnet', async () => { + mockClientService.isTestnetMode.mockReturnValue(false); + mockValidateDepositParams.mockReturnValue({ isValid: true }); + + const params: DepositParams = { + amount: '5', + assetId: + 'eip155:42161/erc20:0xaf88d065e77c8cC2239327C5EDb3A432268e5831/default', + }; + + const result = await provider.validateDeposit(params); + + expect(result.isValid).toBe(true); + expect(result.error).toBeUndefined(); + }); + + it('accepts amount at minimum for testnet', async () => { + mockClientService.isTestnetMode.mockReturnValue(true); + mockValidateDepositParams.mockReturnValue({ isValid: true }); + + const params: DepositParams = { + amount: '10', + assetId: + 'eip155:42161/erc20:0xaf88d065e77c8cC2239327C5EDb3A432268e5831/default', + }; + + const result = await provider.validateDeposit(params); + + expect(result.isValid).toBe(true); + expect(result.error).toBeUndefined(); + }); + + it('rejects empty assetId', async () => { + mockValidateDepositParams.mockReturnValue({ + isValid: false, + error: 'AssetId is required for deposit validation', + }); + + const params: DepositParams = { + amount: '100', + assetId: '' as CaipAssetId, + }; + + const result = await provider.validateDeposit(params); + + expect(result.isValid).toBe(false); + expect(result.error).toBe('AssetId is required for deposit validation'); + }); + + it('rejects unsupported assetId', async () => { + mockValidateDepositParams.mockReturnValue({ + isValid: false, + error: 'Asset not supported', + }); + + const params: DepositParams = { + amount: '100', + assetId: + 'eip155:1/erc20:0x1234567890123456789012345678901234567890/default', + }; + + const result = await provider.validateDeposit(params); + + expect(result.isValid).toBe(false); + expect(result.error).toContain('not supported'); + }); + + it('handles decimal amounts correctly', async () => { + mockValidateDepositParams.mockReturnValue({ isValid: true }); + + const params: DepositParams = { + amount: '100.123456', + assetId: + 'eip155:42161/erc20:0xaf88d065e77c8cC2239327C5EDb3A432268e5831/default', + }; + + const result = await provider.validateDeposit(params); + + expect(result.isValid).toBe(true); + expect(result.error).toBeUndefined(); + }); + + it('handles large amounts correctly', async () => { + mockValidateDepositParams.mockReturnValue({ isValid: true }); + + const params: DepositParams = { + amount: '1000000', + assetId: + 'eip155:42161/erc20:0xaf88d065e77c8cC2239327C5EDb3A432268e5831/default', + }; + + const result = await provider.validateDeposit(params); + + expect(result.isValid).toBe(true); + expect(result.error).toBeUndefined(); + }); + + it('handles scientific notation', async () => { + mockValidateDepositParams.mockReturnValue({ isValid: true }); + + const params: DepositParams = { + amount: '1e6', + assetId: + 'eip155:42161/erc20:0xaf88d065e77c8cC2239327C5EDb3A432268e5831/default', + }; + + const result = await provider.validateDeposit(params); + + expect(result.isValid).toBe(true); + expect(result.error).toBeUndefined(); + }); + }); + + describe('validateClosePosition', () => { + it('validates full close position successfully', async () => { + const params: ClosePositionParams = { + symbol: 'BTC', + orderType: 'market', + }; + + const result = await provider.validateClosePosition(params); + + expect(result.isValid).toBe(true); + expect(result.error).toBeUndefined(); + }); + + it('validates partial close position successfully', async () => { + const params: ClosePositionParams = { + symbol: 'BTC', + size: '0.5', + orderType: 'market', + currentPrice: 45000, + }; + + const result = await provider.validateClosePosition(params); + + expect(result.isValid).toBe(true); + expect(result.error).toBeUndefined(); + }); + + it('rejects close position below minimum value on mainnet', async () => { + mockClientService.isTestnetMode.mockReturnValue(false); + + const params: ClosePositionParams = { + symbol: 'BTC', + size: '0.0001', // $4.50 at $45,000 BTC, below $10 minimum + orderType: 'market', + currentPrice: 45000, + }; + + const result = await provider.validateClosePosition(params); + + expect(result.isValid).toBe(false); + expect(result.error).toBe(PERPS_ERROR_CODES.ORDER_SIZE_MIN); + }); + + it('rejects close position below minimum value on testnet', async () => { + mockClientService.isTestnetMode.mockReturnValue(true); + + const params: ClosePositionParams = { + symbol: 'BTC', + size: '0.00022', // $9.90 at $45,000 BTC, below $11 testnet minimum + orderType: 'market', + currentPrice: 45000, + }; + + const result = await provider.validateClosePosition(params); + + expect(result.isValid).toBe(false); + expect(result.error).toBe(PERPS_ERROR_CODES.ORDER_SIZE_MIN); + }); + + it('accepts close position at minimum value', async () => { + mockClientService.isTestnetMode.mockReturnValue(false); + + const params: ClosePositionParams = { + symbol: 'BTC', + size: '0.00023', // $10.35 at $45,000 BTC, above $10 minimum + orderType: 'market', + currentPrice: 45000, + }; + + const result = await provider.validateClosePosition(params); + + expect(result.isValid).toBe(true); + expect(result.error).toBeUndefined(); + }); + + it('validates limit close position with price', async () => { + const params: ClosePositionParams = { + symbol: 'BTC', + size: '1.0', + orderType: 'limit', + price: '44000', + currentPrice: 45000, + }; + + const result = await provider.validateClosePosition(params); + + expect(result.isValid).toBe(true); + expect(result.error).toBeUndefined(); + }); + + it('rejects limit close without price', async () => { + const params: ClosePositionParams = { + symbol: 'BTC', + size: '1.0', + orderType: 'limit', + currentPrice: 45000, + }; + + const result = await provider.validateClosePosition(params); + + expect(result.isValid).toBe(false); + expect(result.error).toBe(PERPS_ERROR_CODES.ORDER_LIMIT_PRICE_REQUIRED); + }); + + it('handles validation when currentPrice is not provided', async () => { + const params: ClosePositionParams = { + symbol: 'BTC', + size: '0.5', + orderType: 'market', + // currentPrice not provided + }; + + const result = await provider.validateClosePosition(params); + + // Should still validate basic params but skip minimum order value check + expect(result.isValid).toBe(true); + expect(result.error).toBeUndefined(); + }); + }); + + describe('calculateLiquidationPrice', () => { + beforeEach(() => { + // Set up mock for asset info with maxLeverage: 20 for BTC (test expectations) + mockClientService.getInfoClient = jest.fn().mockReturnValue( + createMockInfoClient({ + meta: jest.fn().mockResolvedValue({ + universe: [ + { name: 'BTC', szDecimals: 3, maxLeverage: 20 }, + { name: 'ETH', szDecimals: 4, maxLeverage: 20 }, + ], + }), + }), + ); + }); + + it('calculates liquidation price for long position correctly', async () => { + const params = { + entryPrice: 50000, + leverage: 10, + direction: 'long' as const, + asset: 'BTC', + }; + + const result = await provider.calculateLiquidationPrice(params); + + // With 10x leverage and 20x max leverage: + // maintenance margin = 1 / (2 * 20) = 0.025 + // initial margin = 1 / 10 = 0.1 + // margin available = 0.1 - 0.025 = 0.075 + // l = 1 / 40 = 0.025 + // liquidation = 50000 - (1 * 0.075 * 50000) / (1 - 0.025 * 1) + // liquidation = 50000 - 3750 / 0.975 = 50000 - 3846.15 = 46153.85 + expect(parseFloat(result)).toBeCloseTo(46153.85, 2); + }); + + it('calculates liquidation price for short position correctly', async () => { + const params = { + entryPrice: 50000, + leverage: 10, + direction: 'short' as const, + asset: 'BTC', + }; + + const result = await provider.calculateLiquidationPrice(params); + + // With 10x leverage and 20x max leverage: + // maintenance margin = 1 / (2 * 20) = 0.025 + // initial margin = 1 / 10 = 0.1 + // margin available = 0.1 - 0.025 = 0.075 + // l = 1 / 40 = 0.025 + // liquidation = 50000 - (-1 * 0.075 * 50000) / (1 - 0.025 * -1) + // liquidation = 50000 + 3750 / 1.025 = 50000 + 3658.54 = 53658.54 + expect(parseFloat(result)).toBeCloseTo(53658.54, 2); + }); + + it('throws error for leverage exceeding maintenance leverage', async () => { + mockClientService.getInfoClient = jest.fn().mockReturnValue( + createMockInfoClient({ + meta: jest.fn().mockResolvedValue({ + universe: [ + { name: 'BTC', szDecimals: 3, maxLeverage: 20 }, + { name: 'ETH', szDecimals: 4, maxLeverage: 20 }, + ], + }), + }), + ); + + const params = { + entryPrice: 50000, + leverage: 41, // Exceeds maintenance leverage (2 * 20 = 40) + direction: 'long' as const, + asset: 'BTC', + }; + + await expect(provider.calculateLiquidationPrice(params)).rejects.toThrow( + 'Invalid leverage: 41x exceeds maximum allowed leverage of 40x', + ); + }); + + it('handles invalid inputs', async () => { + const invalidCases = [ + { entryPrice: 0, leverage: 10, direction: 'long' as const }, + { entryPrice: 50000, leverage: 0, direction: 'long' as const }, + { entryPrice: NaN, leverage: 10, direction: 'long' as const }, + { entryPrice: 50000, leverage: Infinity, direction: 'long' as const }, + { entryPrice: -100, leverage: 10, direction: 'long' as const }, + ]; + + for (const params of invalidCases) { + const result = await provider.calculateLiquidationPrice(params); + expect(result).toBe('0.00'); + } + }); + + it('uses default max leverage when asset is not provided', async () => { + const params = { + entryPrice: 50000, + leverage: 4, + direction: 'long' as const, + // No asset provided, so default 3x will be used + }; + + const result = await provider.calculateLiquidationPrice(params); + + // Should use default 3x max leverage (since no asset provided) + // maintenance leverage = 2 * 3 = 6x + // l = 1 / 6 = 0.1667 + // initial margin = 1 / 4 = 0.25 + // maintenance margin = 1 / 6 = 0.1667 + // margin available = 0.25 - 0.1667 = 0.0833 + // liq price = 50000 - 1 * 0.0833 * 50000 / (1 - 0.1667 * 1) + // liq price = 50000 - 4165 / 0.8333 = 50000 - 4998 = 45002 + expect(parseFloat(result)).toBeCloseTo(45002, -1); + }); + + it('throws error when leverage exceeds default max leverage', async () => { + const params = { + entryPrice: 50000, + leverage: 10, + direction: 'long' as const, + // No asset provided, so default 3x will be used + }; + + await expect(provider.calculateLiquidationPrice(params)).rejects.toThrow( + 'Invalid leverage: 10x exceeds maximum allowed leverage of 6x', + ); + }); + }); + + describe('calculateMaintenanceMargin', () => { + it('calculates maintenance margin correctly for 40x max leverage asset', async () => { + mockClientService.getInfoClient = jest.fn().mockReturnValue( + createMockInfoClient({ + meta: jest.fn().mockResolvedValue({ + universe: [{ name: 'BTC', maxLeverage: 40, szDecimals: 5 }], + }), + }), + ); + + const result = await provider.calculateMaintenanceMargin({ + asset: 'BTC', + }); + + // Maintenance margin = 1 / (2 * 40) = 0.0125 (1.25%) + expect(result).toBe(0.0125); + }); + + it('calculates maintenance margin correctly for 3x max leverage asset', async () => { + mockClientService.getInfoClient = jest.fn().mockReturnValue({ + meta: jest.fn().mockResolvedValue({ + universe: [{ name: 'DOGE', maxLeverage: 3, szDecimals: 0 }], + }), + }); + + const result = await provider.calculateMaintenanceMargin({ + asset: 'DOGE', + }); + + // Maintenance margin = 1 / (2 * 3) = 0.1667 (16.67%) + expect(result).toBeCloseTo(0.1667, 4); + }); + + it('returns default maintenance margin when asset not found', async () => { + mockClientService.getInfoClient = jest.fn().mockReturnValue({ + meta: jest.fn().mockResolvedValue({ + universe: [], + }), + }); + + const result = await provider.calculateMaintenanceMargin({ + asset: 'UNKNOWN', + }); + + // Should use default max leverage of 3, so maintenance margin = 1/(2*3) = 0.16666... + expect(result).toBeCloseTo(0.16666666666666666); + }); + }); + + describe('previewPositionModify', () => { + const isolatedPosition = { + symbol: 'ETH', + size: '1', + entryPrice: '2000', + positionValue: '2000', + marginUsed: '400', + leverage: { type: 'isolated' as const, value: 5 }, + liquidationPrice: '1640', + maxLeverage: 25, + }; + + it('returns unsupported for cross-margin positions without fetching meta', async () => { + const result = await provider.previewPositionModify({ + position: { + ...isolatedPosition, + leverage: { type: 'cross', value: 5 }, + }, + direction: 'long', + size: '0.5', + price: '2000', + leverage: 5, + }); + + expect(result).toStrictEqual({ + status: 'unsupported', + reason: 'cross_margin', + }); + expect(mockClientService.getInfoClient).not.toHaveBeenCalled(); + }); + + it('uses cached meta margin tables for an isolated increase', async () => { + mockClientService.getInfoClient = jest.fn().mockReturnValue( + createMockInfoClient({ + meta: jest.fn().mockResolvedValue({ + universe: [ + { + name: 'ETH', + szDecimals: 4, + maxLeverage: 25, + marginTableId: 25, + }, + ], + marginTables: [], + }), + }), + ); + + const result = await provider.previewPositionModify({ + position: isolatedPosition, + direction: 'long', + size: '0.5', + price: '2000', + leverage: 10, + }); + + expect(result.status).toBe('open'); + if (result.status !== 'open') { + return; + } + expect(result.kind).toBe('increase'); + expect(result.resulting.margin).toStrictEqual({ + available: true, + value: 300, + }); + expect(result.resulting.direction).toBe('long'); + }); + + it('withholds liquidation when the asset is missing from meta', async () => { + mockClientService.getInfoClient = jest.fn().mockReturnValue( + createMockInfoClient({ + meta: jest.fn().mockResolvedValue({ + universe: [ + { + name: 'BTC', + szDecimals: 5, + maxLeverage: 40, + marginTableId: 40, + }, + ], + marginTables: [], + }), + }), + ); + + const result = await provider.previewPositionModify({ + position: isolatedPosition, + direction: 'long', + size: '0.5', + price: '2000', + leverage: 10, + }); + + expect(result.status).toBe('open'); + if (result.status !== 'open') { + return; + } + expect(result.resulting.margin).toStrictEqual({ + available: true, + value: 300, + }); + expect(result.resulting.liquidationPrice).toStrictEqual({ + available: false, + }); + }); + }); + + describe('getMaxLeverage', () => { + it('returns max leverage for an asset', async () => { + mockClientService.getInfoClient = jest.fn().mockReturnValue( + createMockInfoClient({ + meta: jest.fn().mockResolvedValue({ + universe: [{ name: 'ETH', maxLeverage: 30, szDecimals: 4 }], + }), + }), + ); + + const result = await provider.getMaxLeverage('ETH'); + + expect(result).toBe(30); + }); + + it('returns default max leverage when asset not found', async () => { + mockClientService.getInfoClient = jest.fn().mockReturnValue({ + meta: jest.fn().mockResolvedValue({ + universe: [], + }), + }); + + const result = await provider.getMaxLeverage('UNKNOWN'); + + // Should return default max leverage of 3 + expect(result).toBe(3); + }); + + it('returns default max leverage on network failure', async () => { + mockClientService.getInfoClient = jest.fn().mockReturnValue({ + meta: jest.fn().mockRejectedValue(new Error('Network error')), + }); + + const result = await provider.getMaxLeverage('BTC'); + + // Should return default max leverage of 3 on error + expect(result).toBe(3); + }); + }); + + describe('validateOrder', () => { + beforeEach(() => { + mockValidateOrderParams.mockReturnValue({ isValid: true }); + }); + + it('validates order successfully with valid params and price', async () => { + const params: OrderParams = { + symbol: 'BTC', + size: '0.1', + isBuy: true, + orderType: 'market', + currentPrice: 50000, + leverage: 10, + }; + + const result = await provider.validateOrder(params); + + expect(result.isValid).toBe(true); + expect(result.error).toBeUndefined(); + expect(mockValidateOrderParams).toHaveBeenCalledWith({ + coin: 'BTC', // validateOrderParams uses 'coin' (internal util), provider maps symbol -> coin + size: '0.1', + price: undefined, + orderType: 'market', + }); + }); + + it('fails validation when currentPrice is missing', async () => { + const params: OrderParams = { + symbol: 'BTC', + size: '0.1', + isBuy: true, + orderType: 'market', + // currentPrice missing + leverage: 10, + }; + + const result = await provider.validateOrder(params); + + expect(result.isValid).toBe(false); + expect(result.error).toBe(PERPS_ERROR_CODES.ORDER_PRICE_REQUIRED); + }); + + it('fails validation when order value is below minimum', async () => { + const params: OrderParams = { + symbol: 'BTC', + size: '0.00001', // Very small size + isBuy: true, + orderType: 'market', + currentPrice: 50000, // 0.00001 * 50000 = $0.5 (below minimum) + leverage: 10, + }; + + const result = await provider.validateOrder(params); + + expect(result.isValid).toBe(false); + expect(result.error).toContain(PERPS_ERROR_CODES.ORDER_SIZE_MIN); + }); + + it('fails validation when basic params are invalid', async () => { + mockValidateOrderParams.mockReturnValue({ + isValid: false, + error: 'Invalid coin', + }); + + const params: OrderParams = { + symbol: 'INVALID', + size: '0.1', + isBuy: true, + orderType: 'market', + currentPrice: 50000, + leverage: 10, + }; + + const result = await provider.validateOrder(params); + + expect(result.isValid).toBe(false); + expect(result.error).toBe('Invalid coin'); + }); + + it('validates limit order with price', async () => { + const params: OrderParams = { + symbol: 'ETH', + size: '1', + isBuy: true, + orderType: 'limit', + price: '3000', + currentPrice: 3050, + leverage: 5, + }; + + const result = await provider.validateOrder(params); + + expect(result.isValid).toBe(true); + expect(mockValidateOrderParams).toHaveBeenCalledWith({ + coin: 'ETH', // validateOrderParams uses 'coin' (internal util), provider maps symbol -> coin + size: '1', + price: '3000', + orderType: 'limit', + }); + }); + + it('handles validation errors gracefully', async () => { + mockValidateOrderParams.mockImplementation(() => { + throw new Error('Unexpected error'); + }); + + const params: OrderParams = { + symbol: 'BTC', + size: '0.1', + isBuy: true, + orderType: 'market', + currentPrice: 50000, + leverage: 10, + }; + + const result = await provider.validateOrder(params); + + expect(result.isValid).toBe(false); + expect(result.error).toBe('Unexpected error'); + }); + + describe('existing position leverage validation', () => { + it('allows order when leverage equals existing position leverage', async () => { + const params: OrderParams = { + symbol: 'BTC', + size: '0.1', + isBuy: true, + orderType: 'market', + currentPrice: 50000, + leverage: 10, + existingPositionLeverage: 10, + }; + + const result = await provider.validateOrder(params); + + expect(result.isValid).toBe(true); + expect(result.error).toBeUndefined(); + }); + + it('allows order when leverage exceeds existing position leverage', async () => { + const params: OrderParams = { + symbol: 'BTC', + size: '0.1', + isBuy: true, + orderType: 'market', + currentPrice: 50000, + leverage: 15, + existingPositionLeverage: 10, + }; + + const result = await provider.validateOrder(params); + + expect(result.isValid).toBe(true); + expect(result.error).toBeUndefined(); + }); + + it('rejects order when leverage below existing position leverage', async () => { + const params: OrderParams = { + symbol: 'BTC', + size: '0.1', + isBuy: true, + orderType: 'market', + currentPrice: 50000, + leverage: 5, + existingPositionLeverage: 10, + }; + + const result = await provider.validateOrder(params); + + expect(result.isValid).toBe(false); + expect(result.error).toBe( + PERPS_ERROR_CODES.ORDER_LEVERAGE_BELOW_POSITION, + ); + }); + + it('allows any leverage when no existing position', async () => { + const params: OrderParams = { + symbol: 'BTC', + size: '0.1', + isBuy: true, + orderType: 'market', + currentPrice: 50000, + leverage: 3, + }; + + const result = await provider.validateOrder(params); + + expect(result.isValid).toBe(true); + expect(result.error).toBeUndefined(); + }); + }); + }); +}); diff --git a/packages/perps-controller/tests/src/providers/MYXProvider.test.ts b/packages/perps-controller/tests/src/providers/MYXProvider.test.ts new file mode 100644 index 00000000000..d66062faa0b --- /dev/null +++ b/packages/perps-controller/tests/src/providers/MYXProvider.test.ts @@ -0,0 +1,1277 @@ +/* eslint-disable */ +import { + createMockInfrastructure, + createMockMessenger, +} from '../../helpers/serviceMocks.js'; + +jest.mock('@myx-trade/sdk', () => ({ + MyxClient: jest.fn(), + OrderStatusEnum: { Successful: 9 }, +})); + +import { CandlePeriod } from '../../../src/constants/chartConfig.js'; +import { MYXProvider } from '../../../src/providers/MYXProvider.js'; +import { MYXClientService } from '../../../src/services/MYXClientService.js'; +import { WebSocketConnectionState } from '../../../src/types/index.js'; +import type { PerpsPlatformDependencies } from '../../../src/types/index.js'; +import type { MYXPoolSymbol, MYXTicker } from '../../../src/types/myx-types.js'; +import { + adaptMarketFromMYX, + adaptMarketDataFromMYX, + adaptPriceFromMYX, + filterMYXExclusiveMarkets, + buildPoolSymbolMap, +} from '../../../src/utils/myxAdapter.js'; + +// ============================================================================ +// Mocks +// ============================================================================ + +jest.mock( + '../../../../core/AppConstants', + () => ({ + __esModule: true, + default: { ZERO_ADDRESS: '0x0000000000000000000000000000000000000000' }, + }), + { virtual: true }, +); +jest.mock('../../../src/services/MYXClientService'); +jest.mock('../../../src/services/MYXWalletService', () => ({ + MYXWalletService: jest.fn().mockImplementation(() => ({ + createEthersSigner: jest.fn().mockReturnValue({}), + createWalletClient: jest.fn().mockReturnValue({}), + getUserAddress: jest.fn().mockReturnValue('0xuser123'), + getCurrentAccountId: jest.fn().mockResolvedValue('eip155:421614:0xuser123'), + })), +})); +jest.mock('../../../src/utils/myxAdapter', () => ({ + adaptMarketFromMYX: jest.fn(), + adaptMarketDataFromMYX: jest.fn(), + adaptPriceFromMYX: jest.fn(), + adaptCandleFromMYX: jest.fn(), + adaptCandleFromMYXWebSocket: jest.fn(), + adaptPositionFromMYX: jest.fn(), + adaptOrderFromMYX: jest.fn(), + adaptAccountStateFromMYX: jest.fn(), + adaptOrderFillFromMYX: jest.fn(), + adaptFundingFromMYX: jest.fn(), + adaptUserHistoryFromMYX: jest.fn(), + filterMYXExclusiveMarkets: jest.fn(), + buildPoolSymbolMap: jest.fn(), + toMYXKlineResolution: jest.fn().mockReturnValue('1h'), +})); +// WebSocketConnectionState is now defined inline in types/index.ts (no mock needed) + +const MockedMYXClientService = MYXClientService as jest.MockedClass< + typeof MYXClientService +>; +const mockAdaptMarketFromMYX = adaptMarketFromMYX as jest.MockedFunction< + typeof adaptMarketFromMYX +>; +const mockAdaptMarketDataFromMYX = + adaptMarketDataFromMYX as jest.MockedFunction; +const mockAdaptPriceFromMYX = adaptPriceFromMYX as jest.MockedFunction< + typeof adaptPriceFromMYX +>; +const mockFilterMYXExclusiveMarkets = + filterMYXExclusiveMarkets as jest.MockedFunction< + typeof filterMYXExclusiveMarkets + >; +const mockBuildPoolSymbolMap = buildPoolSymbolMap as jest.MockedFunction< + typeof buildPoolSymbolMap +>; + +// ============================================================================ +// Test Fixtures +// ============================================================================ + +function makePool(overrides: Partial = {}): MYXPoolSymbol { + return { + chainId: 421614, + marketId: 'market-1', + poolId: '0xpool1', + baseSymbol: 'RHEA', + quoteSymbol: 'USDT', + baseTokenIcon: '', + baseToken: '0xbase', + quoteToken: '0xquote', + ...overrides, + }; +} + +function makeTicker(overrides: Partial = {}): MYXTicker { + return { + chainId: 421614, + poolId: '0xpool1', + oracleId: 1, + price: '1500.00', + change: '2.5', + high: '0', + low: '0', + volume: '1000000', + turnover: '0', + ...overrides, + }; +} + +function createProvider( + deps: jest.Mocked, + isTestnet = true, +): MYXProvider { + return new MYXProvider({ + isTestnet, + platformDependencies: deps, + }); +} + +// ============================================================================ +// Tests +// ============================================================================ + +describe('MYXProvider', () => { + let provider: MYXProvider; + let mockDeps: jest.Mocked; + let mockClientService: jest.Mocked; + + beforeEach(() => { + jest.useFakeTimers(); + jest.clearAllMocks(); + + mockDeps = createMockInfrastructure(); + + // Setup default adapter mock returns + mockFilterMYXExclusiveMarkets.mockImplementation((pools) => pools); + mockBuildPoolSymbolMap.mockReturnValue(new Map([['0xpool1', 'RHEA']])); + mockAdaptMarketFromMYX.mockReturnValue({ + name: 'RHEA', + szDecimals: 18, + maxLeverage: 100, + marginTableId: 0, + minimumOrderSize: 10, + providerId: 'myx', + }); + + provider = createProvider(mockDeps); + + // Get reference to the mocked client service instance + mockClientService = MockedMYXClientService.mock + .instances[0] as jest.Mocked; + }); + + afterEach(() => { + jest.useRealTimers(); + jest.resetAllMocks(); + }); + + // ========================================================================== + // Constructor + // ========================================================================== + + describe('constructor', () => { + it('sets protocolId to myx', () => { + expect(provider.protocolId).toBe('myx'); + }); + + it('defaults to testnet when isTestnet is undefined', () => { + const defaultProvider = new MYXProvider({ + platformDependencies: mockDeps, + }); + + expect(defaultProvider.protocolId).toBe('myx'); + // Constructor logs the isTestnet value + expect(mockDeps.debugLogger.log).toHaveBeenCalledWith( + '[MYXProvider] Constructor complete', + expect.objectContaining({ isTestnet: true }), + ); + }); + + it('initializes MYXClientService with correct config', () => { + expect(MockedMYXClientService).toHaveBeenCalledWith(mockDeps, { + isTestnet: true, + }); + }); + + it('logs constructor completion', () => { + expect(mockDeps.debugLogger.log).toHaveBeenCalledWith( + '[MYXProvider] Constructor complete', + expect.objectContaining({ + protocolId: 'myx', + isTestnet: true, + }), + ); + }); + }); + + // ========================================================================== + // Initialization & Lifecycle + // ========================================================================== + + describe('initialize', () => { + it('returns success after fetching and filtering markets', async () => { + const pools = [makePool()]; + mockClientService.getMarkets.mockResolvedValueOnce(pools); + + const result = await provider.initialize(); + + expect(result).toEqual({ success: true }); + expect(mockClientService.getMarkets).toHaveBeenCalled(); + expect(mockFilterMYXExclusiveMarkets).toHaveBeenCalledWith(pools); + expect(mockBuildPoolSymbolMap).toHaveBeenCalled(); + }); + + it('returns failure with error message on SDK failure', async () => { + mockClientService.getMarkets.mockRejectedValueOnce( + new Error('Init failed'), + ); + + const result = await provider.initialize(); + + expect(result).toEqual({ + success: false, + error: 'Init failed', + }); + expect(mockDeps.logger.error).toHaveBeenCalled(); + }); + }); + + describe('disconnect', () => { + it('returns success and calls clientService.disconnect', async () => { + const result = await provider.disconnect(); + + expect(result).toEqual({ success: true }); + expect(mockClientService.disconnect).toHaveBeenCalled(); + }); + + it('returns failure when disconnect throws', async () => { + mockClientService.disconnect.mockImplementation(() => { + throw new Error('Disconnect error'); + }); + + const result = await provider.disconnect(); + + expect(result).toEqual({ + success: false, + error: 'Disconnect error', + }); + }); + }); + + describe('ping', () => { + it('delegates to clientService.ping', async () => { + mockClientService.ping.mockResolvedValueOnce(undefined); + + await provider.ping(3000); + + expect(mockClientService.ping).toHaveBeenCalledWith(3000); + }); + + it('delegates without timeout argument', async () => { + mockClientService.ping.mockResolvedValueOnce(undefined); + + await provider.ping(); + + expect(mockClientService.ping).toHaveBeenCalledWith(undefined); + }); + }); + + describe('toggleTestnet', () => { + it('returns failure with testnet-only message', async () => { + const result = await provider.toggleTestnet(); + + expect(result).toEqual({ + success: false, + isTestnet: true, + error: 'MYX mainnet not yet available', + }); + }); + }); + + describe('isReadyToTrade', () => { + it('returns not ready with trading not supported message', async () => { + const result = await provider.isReadyToTrade(); + + expect(result).toEqual({ + ready: false, + error: 'MYX provider requires messenger for wallet operations', + walletConnected: false, + networkSupported: true, + }); + }); + }); + + // ========================================================================== + // Market Data Operations + // ========================================================================== + + describe('getMarkets', () => { + it('fetches markets, filters, and adapts them', async () => { + const pools = [ + makePool(), + makePool({ poolId: '0xpool2', baseSymbol: 'PARTI' }), + ]; + mockClientService.getMarkets.mockResolvedValueOnce(pools); + mockFilterMYXExclusiveMarkets.mockReturnValueOnce(pools); + + const result = await provider.getMarkets(); + + expect(mockClientService.getMarkets).toHaveBeenCalled(); + expect(mockFilterMYXExclusiveMarkets).toHaveBeenCalledWith(pools); + expect(mockAdaptMarketFromMYX).toHaveBeenCalledTimes(2); + expect(result).toHaveLength(2); + }); + + it('returns empty array on failure', async () => { + mockClientService.getMarkets.mockRejectedValueOnce( + new Error('Market fetch failed'), + ); + + const result = await provider.getMarkets(); + expect(result).toEqual([]); + expect(mockDeps.logger.error).toHaveBeenCalled(); + }); + }); + + describe('getMarketDataWithPrices', () => { + it('fetches markets if cache is empty then returns market data', async () => { + const pools = [makePool()]; + const tickers = [makeTicker()]; + mockClientService.getMarkets.mockResolvedValue(pools); + mockFilterMYXExclusiveMarkets.mockReturnValue(pools); + mockClientService.getTickers.mockResolvedValueOnce(tickers); + mockAdaptMarketDataFromMYX.mockReturnValue({ + symbol: 'RHEA', + name: 'Rhea Finance', + maxLeverage: '100x', + price: '$1,500.00', + change24h: '+$37.50', + change24hPercent: '+2.50%', + volume: '$1.00M', + providerId: 'myx', + }); + + const result = await provider.getMarketDataWithPrices(); + + expect(result).toHaveLength(1); + expect(result[0].symbol).toBe('RHEA'); + expect(mockAdaptMarketDataFromMYX).toHaveBeenCalledWith( + pools[0], + tickers[0], + mockDeps.marketDataFormatters, + ); + }); + + it('filters out pools with no matching ticker', async () => { + const pools = [makePool({ poolId: '0xpool1' })]; + const tickers = [makeTicker({ poolId: '0xDIFFERENT' })]; + mockClientService.getMarkets.mockResolvedValue(pools); + mockFilterMYXExclusiveMarkets.mockReturnValue(pools); + mockClientService.getTickers.mockResolvedValueOnce(tickers); + + const result = await provider.getMarketDataWithPrices(); + + expect(result).toHaveLength(0); + expect(mockAdaptMarketDataFromMYX).not.toHaveBeenCalled(); + }); + + it('returns empty array on failure', async () => { + mockClientService.getMarkets.mockRejectedValueOnce( + new Error('Data error'), + ); + + const result = await provider.getMarketDataWithPrices(); + expect(result).toEqual([]); + }); + }); + + // ========================================================================== + // Price Subscriptions + // ========================================================================== + + describe('subscribeToPrices', () => { + beforeEach(async () => { + // Pre-populate pools cache via initialize + const pools = [makePool({ poolId: '0xpool1', baseSymbol: 'RHEA' })]; + mockClientService.getMarkets.mockResolvedValueOnce(pools); + mockFilterMYXExclusiveMarkets.mockReturnValue(pools); + + await provider.initialize(); + }); + + it('starts price polling and returns unsubscribe function', () => { + const callback = jest.fn(); + + const unsubscribe = provider.subscribeToPrices({ + symbols: ['RHEA'], + callback, + }); + + expect(mockClientService.startPricePolling).toHaveBeenCalledWith( + ['0xpool1'], + expect.any(Function), + ); + expect(typeof unsubscribe).toBe('function'); + }); + + it('calls callback with empty array when no pool IDs match', () => { + const callback = jest.fn(); + + provider.subscribeToPrices({ + symbols: ['NONEXISTENT'], + callback, + }); + + jest.advanceTimersByTime(1); + expect(callback).toHaveBeenCalledWith([]); + }); + + it('transforms tickers to PriceUpdate format in polling callback', () => { + const callback = jest.fn(); + mockAdaptPriceFromMYX.mockReturnValue({ + price: '1500', + change24h: 2.5, + }); + + provider.subscribeToPrices({ + symbols: ['RHEA'], + callback, + }); + + // Get the polling callback that was passed to startPricePolling + const pollingCallback = + mockClientService.startPricePolling.mock.calls[0][1]; + + // Simulate polling callback + pollingCallback([makeTicker({ poolId: '0xpool1' })]); + + expect(callback).toHaveBeenCalledWith([ + expect.objectContaining({ + symbol: 'RHEA', + price: '1500', + providerId: 'myx', + percentChange24h: '2.50', + }), + ]); + }); + + it('unsubscribe stops price polling', () => { + const callback = jest.fn(); + + const unsubscribe = provider.subscribeToPrices({ + symbols: ['RHEA'], + callback, + }); + + unsubscribe(); + + expect(mockClientService.stopPricePolling).toHaveBeenCalled(); + }); + }); + + // ========================================================================== + // Asset Routes (Stage 1 - Stubbed) + // ========================================================================== + + describe('getDepositRoutes', () => { + it('returns empty array', () => { + expect(provider.getDepositRoutes()).toEqual([]); + }); + }); + + describe('getWithdrawalRoutes', () => { + it('returns empty array', () => { + expect(provider.getWithdrawalRoutes()).toEqual([]); + }); + }); + + // ========================================================================== + // Trading Operations (Stage 1 - All Stubbed) + // ========================================================================== + + describe('trading operations return not-supported errors', () => { + it('placeOrder returns failure', async () => { + const result = await provider.placeOrder( + {} as Parameters[0], + ); + + expect(result).toEqual({ + success: false, + error: 'MYX trading not yet supported', + }); + }); + + it('editOrder returns failure', async () => { + const result = await provider.editOrder( + {} as Parameters[0], + ); + + expect(result).toEqual({ + success: false, + error: 'MYX trading not yet supported', + }); + }); + + it('cancelOrder returns failure', async () => { + const result = await provider.cancelOrder( + {} as Parameters[0], + ); + + expect(result).toEqual({ + success: false, + error: 'MYX trading not yet supported', + }); + }); + + it('cancelOrders returns zero counts', async () => { + const result = await provider.cancelOrders( + {} as Parameters[0], + ); + + expect(result).toEqual({ + success: false, + successCount: 0, + failureCount: 0, + results: [], + }); + }); + + it('closePosition returns failure', async () => { + const result = await provider.closePosition( + {} as Parameters[0], + ); + + expect(result).toEqual({ + success: false, + error: 'MYX trading not yet supported', + }); + }); + + it('closePositions returns zero counts', async () => { + const result = await provider.closePositions( + {} as Parameters[0], + ); + + expect(result).toEqual({ + success: false, + successCount: 0, + failureCount: 0, + results: [], + }); + }); + + it('updatePositionTPSL returns failure', async () => { + const result = await provider.updatePositionTPSL( + {} as Parameters[0], + ); + + expect(result).toEqual({ + success: false, + error: 'MYX trading not yet supported', + }); + }); + + it('updateMargin returns failure', async () => { + const result = await provider.updateMargin({ + symbol: 'RHEA', + amount: '100', + }); + + expect(result).toEqual({ + success: false, + error: 'MYX trading not yet supported', + }); + }); + + it('withdraw returns failure', async () => { + const result = await provider.withdraw( + {} as Parameters[0], + ); + + expect(result).toEqual({ + success: false, + error: 'MYX trading not yet supported', + }); + }); + }); + + // ========================================================================== + // Account Operations (Stage 1 - Empty Returns) + // ========================================================================== + + describe('account operations return empty defaults', () => { + it('getPositions returns empty array', async () => { + expect(await provider.getPositions()).toEqual([]); + }); + + it('getAccountState returns zeroed state', async () => { + const result = await provider.getAccountState(); + + expect(result).toEqual({ + spendableBalance: '0', + withdrawableBalance: '0', + totalBalance: '0', + marginUsed: '0', + unrealizedPnl: '0', + returnOnEquity: '0', + }); + }); + + it('getOrders returns empty array', async () => { + expect(await provider.getOrders()).toEqual([]); + }); + + it('getOpenOrders returns empty array', async () => { + expect(await provider.getOpenOrders()).toEqual([]); + }); + + it('getOrderFills returns empty array', async () => { + expect(await provider.getOrderFills()).toEqual([]); + }); + + it('getOrFetchFills returns empty array', async () => { + expect(await provider.getOrFetchFills()).toEqual([]); + }); + + it('getFunding returns empty array', async () => { + expect(await provider.getFunding()).toEqual([]); + }); + + it('getHistoricalPortfolio returns zeroed result', async () => { + const result = await provider.getHistoricalPortfolio(); + + expect(result.accountValue1dAgo).toBe('0'); + expect(result.timestamp).toBeDefined(); + }); + + it('getUserNonFundingLedgerUpdates returns empty array', async () => { + expect(await provider.getUserNonFundingLedgerUpdates()).toEqual([]); + }); + + it('getUserHistory returns empty array', async () => { + expect(await provider.getUserHistory()).toEqual([]); + }); + }); + + // ========================================================================== + // Validation Operations (Stage 1 - All Invalid) + // ========================================================================== + + describe('validation operations return invalid', () => { + it('validateDeposit returns not valid', async () => { + const result = await provider.validateDeposit( + {} as Parameters[0], + ); + + expect(result).toEqual({ + isValid: false, + error: 'MYX trading not yet supported', + }); + }); + + it('validateOrder returns not valid', async () => { + const result = await provider.validateOrder( + {} as Parameters[0], + ); + + expect(result).toEqual({ + isValid: false, + error: 'MYX trading not yet supported', + }); + }); + + it('validateClosePosition returns not valid', async () => { + const result = await provider.validateClosePosition( + {} as Parameters[0], + ); + + expect(result).toEqual({ + isValid: false, + error: 'MYX trading not yet supported', + }); + }); + + it('validateWithdrawal returns not valid', async () => { + const result = await provider.validateWithdrawal( + {} as Parameters[0], + ); + + expect(result).toEqual({ + isValid: false, + error: 'MYX trading not yet supported', + }); + }); + }); + + // ========================================================================== + // Protocol Calculations (Stage 1 - Default Values) + // ========================================================================== + + describe('protocol calculations return defaults', () => { + it('calculateLiquidationPrice returns "0"', async () => { + expect( + await provider.calculateLiquidationPrice( + {} as Parameters[0], + ), + ).toBe('0'); + }); + + it('calculateMaintenanceMargin returns 0', async () => { + expect( + await provider.calculateMaintenanceMargin( + {} as Parameters[0], + ), + ).toBe(0); + }); + + it('getMaxLeverage returns 100', async () => { + expect(await provider.getMaxLeverage('RHEA')).toBe(100); + }); + + it('calculateFees returns default fee rates', async () => { + const result = await provider.calculateFees( + {} as Parameters[0], + ); + + expect(result).toEqual({ + feeRate: 0.0005, + protocolFeeRate: 0.0005, + }); + }); + + it('previewPositionModify returns unsupported', async () => { + expect( + await provider.previewPositionModify({ + position: { + symbol: 'RHEA', + size: '1', + entryPrice: '1', + positionValue: '1', + marginUsed: '1', + leverage: { type: 'isolated', value: 5 }, + liquidationPrice: '0.5', + maxLeverage: 20, + }, + direction: 'long', + size: '0.1', + price: '1', + leverage: 5, + }), + ).toEqual({ status: 'unsupported', reason: 'provider' }); + }); + }); + + // ========================================================================== + // Subscriptions (Stage 1 - No-op) + // ========================================================================== + + describe('subscriptions call back with empty data', () => { + it('subscribeToPositions calls back with empty array', () => { + const callback = jest.fn(); + + const unsub = provider.subscribeToPositions({ callback }); + jest.advanceTimersByTime(1); + + expect(callback).toHaveBeenCalledWith([]); + expect(typeof unsub).toBe('function'); + }); + + it('subscribeToOrderFills calls back with empty array', () => { + const callback = jest.fn(); + + const unsub = provider.subscribeToOrderFills({ callback }); + jest.advanceTimersByTime(1); + + expect(callback).toHaveBeenCalledWith([]); + expect(typeof unsub).toBe('function'); + }); + + it('subscribeToOrders calls back with empty array', () => { + const callback = jest.fn(); + + const unsub = provider.subscribeToOrders({ callback }); + jest.advanceTimersByTime(1); + + expect(callback).toHaveBeenCalledWith([]); + expect(typeof unsub).toBe('function'); + }); + + it('subscribeToAccount calls back with zeroed state', () => { + const callback = jest.fn(); + + const unsub = provider.subscribeToAccount({ callback }); + jest.advanceTimersByTime(1); + + expect(callback).toHaveBeenCalledWith({ + spendableBalance: '0', + withdrawableBalance: '0', + totalBalance: '0', + marginUsed: '0', + unrealizedPnl: '0', + returnOnEquity: '0', + }); + expect(typeof unsub).toBe('function'); + }); + + it('subscribeToOICaps calls back with empty array', () => { + const callback = jest.fn(); + + const unsub = provider.subscribeToOICaps({ callback }); + jest.advanceTimersByTime(1); + + expect(callback).toHaveBeenCalledWith([]); + expect(typeof unsub).toBe('function'); + }); + + it('subscribeToCandles calls back with empty candles', () => { + const callback = jest.fn(); + + const unsub = provider.subscribeToCandles({ + symbol: 'RHEA', + interval: CandlePeriod.OneHour, + callback, + }); + jest.advanceTimersByTime(1); + + expect(callback).toHaveBeenCalledWith({ + symbol: 'RHEA', + interval: CandlePeriod.OneHour, + candles: [], + }); + expect(typeof unsub).toBe('function'); + }); + + it('subscribeToOrderBook calls back with empty book', () => { + const callback = jest.fn(); + + const unsub = provider.subscribeToOrderBook({ + symbol: 'RHEA', + callback, + }); + jest.advanceTimersByTime(1); + + expect(callback).toHaveBeenCalledWith( + expect.objectContaining({ + bids: [], + asks: [], + spread: '0', + spreadPercentage: '0', + midPrice: '0', + maxTotal: '0', + }), + ); + expect(typeof unsub).toBe('function'); + }); + }); + + describe('setLiveDataConfig', () => { + it('does not throw (no-op)', () => { + expect(() => provider.setLiveDataConfig({})).not.toThrow(); + }); + }); + + // ========================================================================== + // Connection State (Stage 1 - REST Only) + // ========================================================================== + + describe('connection state', () => { + it('getWebSocketConnectionState returns Connected', () => { + expect(provider.getWebSocketConnectionState()).toBe( + WebSocketConnectionState.Connected, + ); + }); + + it('subscribeToConnectionState returns noop unsubscribe', () => { + const listener = jest.fn(); + const unsub = provider.subscribeToConnectionState(listener); + + expect(typeof unsub).toBe('function'); + // Listener not called since REST has no state changes + expect(listener).not.toHaveBeenCalled(); + }); + + it('reconnect is a no-op', async () => { + await expect(provider.reconnect()).resolves.toBeUndefined(); + }); + }); + + // ========================================================================== + // Block Explorer + // ========================================================================== + + describe('getBlockExplorerUrl', () => { + it('returns testnet explorer URL without address', () => { + expect(provider.getBlockExplorerUrl()).toBe( + 'https://sepolia.arbiscan.io', + ); + }); + + it('returns testnet explorer URL with address', () => { + expect(provider.getBlockExplorerUrl('0xabc')).toBe( + 'https://sepolia.arbiscan.io/address/0xabc', + ); + }); + + it('returns mainnet explorer URL without address', () => { + const mainnetProvider = createProvider(mockDeps, false); + + expect(mainnetProvider.getBlockExplorerUrl()).toBe('https://bscscan.com'); + }); + + it('returns mainnet explorer URL with address', () => { + const mainnetProvider = createProvider(mockDeps, false); + + expect(mainnetProvider.getBlockExplorerUrl('0xdef')).toBe( + 'https://bscscan.com/address/0xdef', + ); + }); + }); + + // ========================================================================== + // Authenticated Read Operations + // ========================================================================== + + /* eslint-disable @typescript-eslint/no-explicit-any */ + describe('authenticated reads', () => { + let authProvider: MYXProvider; + let authClientService: jest.Mocked; + + beforeEach(() => { + // Re-set MYXWalletService mock (cleared by outer jest.clearAllMocks) + const { MYXWalletService } = jest.requireMock( + '../../../src/services/MYXWalletService', + ) as { + MYXWalletService: jest.Mock; + }; + MYXWalletService.mockImplementation(() => ({ + createEthersSigner: jest.fn().mockReturnValue({}), + createWalletClient: jest.fn().mockReturnValue({}), + getUserAddress: jest.fn().mockReturnValue('0xuser123'), + getCurrentAccountId: jest + .fn() + .mockResolvedValue('eip155:421614:0xuser123'), + })); + + const { createMockMessenger: createMsg } = jest.requireActual( + '../../helpers/serviceMocks', + ) as { createMockMessenger: typeof createMockMessenger }; + const messenger = createMsg(); + + authProvider = new MYXProvider({ + isTestnet: true, + platformDependencies: mockDeps, + messenger: messenger as any, + }); + const instances = MockedMYXClientService.mock.instances; + authClientService = instances[ + instances.length - 1 + ] as jest.Mocked; + + // Pre-authenticate so all reads succeed + authClientService.isAuthenticatedForAddress.mockReturnValue(true); + authClientService.authenticate.mockResolvedValue(undefined); + }); + + describe('getPositions', () => { + it('returns adapted positions after authentication', async () => { + const mockRawPositions = [ + { size: '1.5', symbol: 'BTC', poolId: '0xpool1' }, + ]; + authClientService.listPositions.mockResolvedValue({ + data: mockRawPositions, + } as any); + const { adaptPositionFromMYX: mockAdapt } = jest.requireMock( + '../../../src/utils/myxAdapter', + ) as { adaptPositionFromMYX: jest.Mock }; + mockAdapt.mockReturnValue({ + symbol: 'BTC', + size: '1.5', + providerId: 'myx', + }); + + const result = await authProvider.getPositions(); + + expect(result).toHaveLength(1); + expect(result[0].symbol).toBe('BTC'); + expect(authClientService.listPositions).toHaveBeenCalledWith( + '0xuser123', + ); + }); + + it('filters out zero-size positions', async () => { + authClientService.listPositions.mockResolvedValue({ + data: [ + { size: '0', symbol: 'BTC', poolId: '0xpool1' }, + { size: '1.0', symbol: 'ETH', poolId: '0xpool2' }, + ], + } as any); + const { adaptPositionFromMYX: mockAdapt } = jest.requireMock( + '../../../src/utils/myxAdapter', + ) as { adaptPositionFromMYX: jest.Mock }; + mockAdapt.mockReturnValue({ symbol: 'ETH', size: '1.0' }); + + const result = await authProvider.getPositions(); + + expect(result).toHaveLength(1); + }); + + it('returns empty array when data is null', async () => { + authClientService.listPositions.mockResolvedValue({ + data: null, + } as any); + + const result = await authProvider.getPositions(); + + expect(result).toEqual([]); + }); + }); + + describe('getAccountState', () => { + it('returns adapted account state', async () => { + authClientService.getChainId.mockReturnValue(421614); + authClientService.getWalletQuoteTokenBalance.mockResolvedValue({ + data: '1000', + } as any); + authClientService.getAccountInfo.mockResolvedValue({ + data: { balance: '1000' }, + } as any); + + const { adaptAccountStateFromMYX: mockAdapt } = jest.requireMock( + '../../../src/utils/myxAdapter', + ) as { adaptAccountStateFromMYX: jest.Mock }; + mockAdapt.mockReturnValue({ + totalBalance: '1000', + spendableBalance: '800', + withdrawableBalance: '800', + marginUsed: '200', + unrealizedPnl: '50', + returnOnEquity: '5', + }); + + // Need pools cache for account info fetch + authClientService.getMarkets.mockResolvedValue([makePool()]); + mockFilterMYXExclusiveMarkets.mockImplementation((pools) => pools); + mockBuildPoolSymbolMap.mockReturnValue(new Map()); + await authProvider.initialize(); + + const result = await authProvider.getAccountState(); + + expect(result.totalBalance).toBe('1000'); + expect(authClientService.getWalletQuoteTokenBalance).toHaveBeenCalled(); + expect(authClientService.getAccountInfo).toHaveBeenCalled(); + }); + }); + + describe('getOrders', () => { + it('returns adapted orders', async () => { + authClientService.getOrderHistory.mockResolvedValue({ + data: [{ orderId: 'o1', orderStatus: 1 }], + } as any); + const { adaptOrderFromMYX: mockAdapt } = jest.requireMock( + '../../../src/utils/myxAdapter', + ) as { adaptOrderFromMYX: jest.Mock }; + mockAdapt.mockReturnValue({ + orderId: 'o1', + status: 'open', + symbol: 'BTC', + }); + + const result = await authProvider.getOrders(); + + expect(result).toHaveLength(1); + expect(result[0].orderId).toBe('o1'); + }); + + it('returns empty array when data is null', async () => { + authClientService.getOrderHistory.mockResolvedValue({ + data: null, + } as any); + + const result = await authProvider.getOrders(); + + expect(result).toEqual([]); + }); + }); + + describe('getOrderFills', () => { + it('returns adapted fills for successful orders', async () => { + authClientService.getOrderHistory.mockResolvedValue({ + data: [ + { orderId: 'o1', orderStatus: 9 }, // Successful (OrderStatusEnum.Successful = 9) + { orderId: 'o2', orderStatus: 0 }, // Not successful + ], + } as any); + const { adaptOrderFillFromMYX: mockAdapt } = jest.requireMock( + '../../../src/utils/myxAdapter', + ) as { adaptOrderFillFromMYX: jest.Mock }; + mockAdapt.mockReturnValue({ orderId: 'o1', symbol: 'BTC' }); + + const result = await authProvider.getOrderFills(); + + expect(result).toHaveLength(1); + }); + }); + + describe('getFunding', () => { + it('returns adapted funding data', async () => { + authClientService.getTradeFlow.mockResolvedValue({ + data: [{ amount: '100' }], + } as any); + const { adaptFundingFromMYX: mockAdapt } = jest.requireMock( + '../../../src/utils/myxAdapter', + ) as { adaptFundingFromMYX: jest.Mock }; + mockAdapt.mockReturnValue([{ amount: '100', symbol: 'BTC' }]); + + const result = await authProvider.getFunding(); + + expect(result).toHaveLength(1); + }); + + it('returns empty array when data is null', async () => { + authClientService.getTradeFlow.mockResolvedValue({ + data: null, + } as any); + + const result = await authProvider.getFunding(); + + expect(result).toEqual([]); + }); + }); + }); + + // ========================================================================== + // Fee Discount + // ========================================================================== + + describe('setUserFeeDiscount', () => { + it('does not throw (no-op)', () => { + expect(() => provider.setUserFeeDiscount(100)).not.toThrow(); + expect(() => provider.setUserFeeDiscount(undefined)).not.toThrow(); + }); + }); + + // ========================================================================== + // HIP-3 Operations (N/A for MYX) + // ========================================================================== + + describe('getAvailableDexs', () => { + it('returns empty array', async () => { + expect(await provider.getAvailableDexs()).toEqual([]); + }); + }); + + // ========================================================================== + // Authentication flow (isReadyToTrade + ensureAuthenticated) + // ========================================================================== + + describe('isReadyToTrade with messenger', () => { + let authProvider: MYXProvider; + let authClientService: jest.Mocked; + + beforeEach(() => { + // Re-set MYXWalletService mock (cleared by outer jest.clearAllMocks) + const { MYXWalletService } = jest.requireMock( + '../../../src/services/MYXWalletService', + ) as { + MYXWalletService: jest.Mock; + }; + MYXWalletService.mockImplementation(() => ({ + createEthersSigner: jest.fn().mockReturnValue({}), + createWalletClient: jest.fn().mockReturnValue({}), + getUserAddress: jest.fn().mockReturnValue('0xuser123'), + getCurrentAccountId: jest + .fn() + .mockResolvedValue('eip155:421614:0xuser123'), + })); + + const messenger = createMockMessenger(); + authProvider = new MYXProvider({ + isTestnet: true, + platformDependencies: mockDeps, + messenger: messenger as any, + }); + // The new MYXProvider creates a new MYXClientService instance; + // grab the latest one + const instances = MockedMYXClientService.mock.instances; + authClientService = instances[ + instances.length - 1 + ] as jest.Mocked; + }); + + it('returns ready when already authenticated for current address', async () => { + authClientService.isAuthenticatedForAddress.mockReturnValue(true); + authClientService.getAuthenticatedAddress.mockReturnValue('0xuser123'); + + const result = await authProvider.isReadyToTrade(); + + expect(result.ready).toBe(true); + expect(result.walletConnected).toBe(true); + expect(result.networkSupported).toBe(true); + expect(result.authenticatedAddress).toBe('0xuser123'); + }); + + it('authenticates and returns ready when not yet authenticated', async () => { + authClientService.isAuthenticatedForAddress.mockReturnValue(false); + authClientService.authenticate.mockResolvedValue(undefined); + authClientService.getAuthenticatedAddress.mockReturnValue('0xuser123'); + + const result = await authProvider.isReadyToTrade(); + + expect(result.ready).toBe(true); + expect(result.walletConnected).toBe(true); + expect(authClientService.authenticate).toHaveBeenCalledWith( + expect.anything(), // signer + expect.anything(), // walletClient + '0xuser123', // address + ); + }); + + it('returns not ready when authentication fails', async () => { + authClientService.isAuthenticatedForAddress.mockReturnValue(false); + authClientService.authenticate.mockRejectedValue( + new Error('Auth rejected by user'), + ); + + const result = await authProvider.isReadyToTrade(); + + expect(result.ready).toBe(false); + expect(result.error).toContain('Auth rejected by user'); + expect(result.walletConnected).toBe(false); + }); + + it('skips authentication when already authenticated', async () => { + // First call returns not-authenticated, triggering auth + authClientService.isAuthenticatedForAddress.mockReturnValue(false); + authClientService.authenticate.mockResolvedValue(undefined); + authClientService.getAuthenticatedAddress.mockReturnValue('0xuser123'); + + const result1 = await authProvider.isReadyToTrade(); + expect(result1.ready).toBe(true); + expect(authClientService.authenticate).toHaveBeenCalledTimes(1); + + // Second call finds already-authenticated — should skip auth + authClientService.isAuthenticatedForAddress.mockReturnValue(true); + authClientService.authenticate.mockClear(); + + const result2 = await authProvider.isReadyToTrade(); + expect(result2.ready).toBe(true); + expect(authClientService.authenticate).not.toHaveBeenCalled(); + }); + + it('re-authenticates when deduped auth was for a different address', async () => { + // First call: not authenticated, authenticate succeeds + let callCount = 0; + authClientService.isAuthenticatedForAddress.mockImplementation(() => { + callCount++; + // Not authenticated for any address on first 3 checks + // Authenticated after second authenticate call + return callCount > 3; + }); + authClientService.authenticate.mockResolvedValue(undefined); + authClientService.getAuthenticatedAddress.mockReturnValue('0xuser123'); + + const result = await authProvider.isReadyToTrade(); + + expect(result.ready).toBe(true); + }); + }); + /* eslint-enable @typescript-eslint/no-explicit-any */ +}); diff --git a/packages/perps-controller/tests/src/routing/ProviderRouter.test.ts b/packages/perps-controller/tests/src/routing/ProviderRouter.test.ts new file mode 100644 index 00000000000..f39b39b9839 --- /dev/null +++ b/packages/perps-controller/tests/src/routing/ProviderRouter.test.ts @@ -0,0 +1,180 @@ +/* eslint-disable */ +import { ProviderRouter } from '../../../src/routing/ProviderRouter.js'; + +describe('ProviderRouter', () => { + let router: ProviderRouter; + + beforeEach(() => { + router = new ProviderRouter({ defaultProvider: 'hyperliquid' }); + }); + + describe('constructor', () => { + it('sets default provider from options', () => { + const customRouter = new ProviderRouter({ defaultProvider: 'myx' }); + expect(customRouter.getDefaultProvider()).toBe('myx'); + }); + + it('sets default strategy to default_provider', () => { + expect(router.getStrategy()).toBe('default_provider'); + }); + + it('accepts custom strategy', () => { + const customRouter = new ProviderRouter({ + defaultProvider: 'hyperliquid', + strategy: 'default_provider', + }); + expect(customRouter.getStrategy()).toBe('default_provider'); + }); + }); + + describe('selectProvider', () => { + it('returns explicit providerId when provided', () => { + const result = router.selectProvider({ providerId: 'myx' }); + expect(result).toBe('myx'); + }); + + it('returns explicit providerId even when symbol is provided', () => { + router.updateProviderMarkets('hyperliquid', ['BTC', 'ETH']); + const result = router.selectProvider({ + symbol: 'BTC', + providerId: 'myx', + }); + expect(result).toBe('myx'); + }); + + it('returns default provider when no providerId is specified', () => { + const result = router.selectProvider({ symbol: 'BTC' }); + expect(result).toBe('hyperliquid'); + }); + + it('returns default provider when params are empty', () => { + const result = router.selectProvider({}); + expect(result).toBe('hyperliquid'); + }); + + it('uses specified provider when provided', () => { + const result = router.selectProvider({ providerId: 'myx' }); + expect(result).toBe('myx'); + }); + }); + + describe('getProvidersForMarket', () => { + beforeEach(() => { + router.updateProviderMarkets('hyperliquid', ['BTC', 'ETH', 'SOL']); + router.updateProviderMarkets('myx', ['BTC', 'ETH', 'ARB']); + }); + + it('returns all providers that support a market', () => { + const providers = router.getProvidersForMarket('BTC'); + expect(providers).toContain('hyperliquid'); + expect(providers).toContain('myx'); + expect(providers).toHaveLength(2); + }); + + it('returns single provider for exclusive market', () => { + const providers = router.getProvidersForMarket('SOL'); + expect(providers).toEqual(['hyperliquid']); + }); + + it('returns empty array for unknown market', () => { + const providers = router.getProvidersForMarket('UNKNOWN'); + expect(providers).toEqual([]); + }); + }); + + describe('updateProviderMarkets', () => { + it('adds markets for a provider', () => { + router.updateProviderMarkets('hyperliquid', ['BTC', 'ETH']); + + expect(router.providerSupportsMarket('hyperliquid', 'BTC')).toBe(true); + expect(router.providerSupportsMarket('hyperliquid', 'ETH')).toBe(true); + expect(router.providerSupportsMarket('hyperliquid', 'SOL')).toBe(false); + }); + + it('replaces existing markets when called again', () => { + router.updateProviderMarkets('hyperliquid', ['BTC', 'ETH']); + router.updateProviderMarkets('hyperliquid', ['SOL', 'ARB']); + + expect(router.providerSupportsMarket('hyperliquid', 'BTC')).toBe(false); + expect(router.providerSupportsMarket('hyperliquid', 'SOL')).toBe(true); + }); + + it('handles empty markets array', () => { + router.updateProviderMarkets('hyperliquid', []); + expect(router.providerSupportsMarket('hyperliquid', 'BTC')).toBe(false); + }); + }); + + describe('clearProviderMarkets', () => { + it('removes all markets for a provider', () => { + router.updateProviderMarkets('hyperliquid', ['BTC', 'ETH']); + router.clearProviderMarkets('hyperliquid'); + + expect(router.providerSupportsMarket('hyperliquid', 'BTC')).toBe(false); + expect(router.getProvidersForMarket('BTC')).toEqual([]); + }); + + it('does not affect other providers', () => { + router.updateProviderMarkets('hyperliquid', ['BTC']); + router.updateProviderMarkets('myx', ['BTC']); + router.clearProviderMarkets('hyperliquid'); + + expect(router.getProvidersForMarket('BTC')).toEqual(['myx']); + }); + }); + + describe('setDefaultProvider', () => { + it('updates the default provider', () => { + router.setDefaultProvider('myx'); + expect(router.getDefaultProvider()).toBe('myx'); + }); + + it('affects subsequent selectProvider calls', () => { + router.setDefaultProvider('myx'); + const result = router.selectProvider({ symbol: 'BTC' }); + expect(result).toBe('myx'); + }); + }); + + describe('providerSupportsMarket', () => { + it('returns true when provider supports market', () => { + router.updateProviderMarkets('hyperliquid', ['BTC', 'ETH']); + expect(router.providerSupportsMarket('hyperliquid', 'BTC')).toBe(true); + }); + + it('returns false when provider does not support market', () => { + router.updateProviderMarkets('hyperliquid', ['BTC', 'ETH']); + expect(router.providerSupportsMarket('hyperliquid', 'SOL')).toBe(false); + }); + + it('returns false for unknown provider', () => { + // @ts-expect-error Testing error handling with invalid provider type + expect(router.providerSupportsMarket('unknown', 'BTC')).toBe(false); + }); + }); + + describe('getRegisteredProviders', () => { + it('returns empty array when no providers registered', () => { + expect(router.getRegisteredProviders()).toEqual([]); + }); + + it('returns all providers with registered markets', () => { + router.updateProviderMarkets('hyperliquid', ['BTC']); + router.updateProviderMarkets('myx', ['ETH']); + + const providers = router.getRegisteredProviders(); + expect(providers).toContain('hyperliquid'); + expect(providers).toContain('myx'); + expect(providers).toHaveLength(2); + }); + + it('does not include cleared providers', () => { + router.updateProviderMarkets('hyperliquid', ['BTC']); + router.updateProviderMarkets('myx', ['ETH']); + router.clearProviderMarkets('hyperliquid'); + + const providers = router.getRegisteredProviders(); + expect(providers).toEqual(['myx']); + }); + }); +}); diff --git a/packages/perps-controller/tests/src/selectors.test.ts b/packages/perps-controller/tests/src/selectors.test.ts new file mode 100644 index 00000000000..7f1da6193b4 --- /dev/null +++ b/packages/perps-controller/tests/src/selectors.test.ts @@ -0,0 +1,837 @@ +/* eslint-disable */ +import { + MARKET_SORTING_CONFIG, + PERPS_CONSTANTS, +} from '../../src/constants/perpsConfig.js'; +import type { PerpsControllerState } from '../../src/PerpsController.js'; +import { + selectIsFirstTimeUser, + selectTradeConfiguration, + selectPendingTradeConfiguration, + selectWatchlistMarkets, + selectIsWatchlistMarket, + selectHasPlacedFirstOrder, + selectMarketFilterPreferences, + selectOrderBookGrouping, + selectRecentlyViewedMarkets, + selectProLayoutPreferences, + selectOrderBookPreferences, + selectSelectedOrderType, + selectVisibleCandleCount, + selectPerpsMode, +} from '../../src/selectors.js'; + +describe('PerpsController selectors', () => { + describe('selectIsFirstTimeUser', () => { + it('returns true when state is undefined', () => { + expect(selectIsFirstTimeUser(undefined)).toBe(true); + }); + + it('returns true when isFirstTimeUser is true', () => { + const state = { + isFirstTimeUser: { testnet: true, mainnet: true }, + } as PerpsControllerState; + expect(selectIsFirstTimeUser(state)).toBe(true); + }); + + it('returns false when isFirstTimeUser is false', () => { + const state = { + isFirstTimeUser: { testnet: false, mainnet: false }, + } as PerpsControllerState; + expect(selectIsFirstTimeUser(state)).toBe(false); + }); + + it('returns true when isFirstTimeUser is undefined in state', () => { + const state = {} as PerpsControllerState; + expect(selectIsFirstTimeUser(state)).toBe(true); + }); + }); + + describe('selectTradeConfiguration', () => { + it('returns saved config for mainnet when not testnet', () => { + const state = { + isTestnet: false, + tradeConfigurations: { + mainnet: { + BTC: { leverage: 10 }, + }, + testnet: {}, + }, + } as unknown as PerpsControllerState; + + const result = selectTradeConfiguration(state, 'BTC'); + + expect(result).toEqual({ leverage: 10 }); + }); + + it('returns saved config for testnet when testnet', () => { + const state = { + isTestnet: true, + tradeConfigurations: { + mainnet: {}, + testnet: { + ETH: { leverage: 5 }, + }, + }, + } as unknown as PerpsControllerState; + + const result = selectTradeConfiguration(state, 'ETH'); + + expect(result).toEqual({ leverage: 5 }); + }); + + it('returns undefined when no config exists for asset', () => { + const state = { + isTestnet: false, + tradeConfigurations: { + mainnet: {}, + testnet: {}, + }, + } as PerpsControllerState; + + const result = selectTradeConfiguration(state, 'BTC'); + + expect(result).toBeUndefined(); + }); + + it('returns undefined when config exists but has no leverage', () => { + const state = { + isTestnet: false, + tradeConfigurations: { + mainnet: { + BTC: {}, + }, + testnet: {}, + }, + } as unknown as PerpsControllerState; + + const result = selectTradeConfiguration(state, 'BTC'); + + expect(result).toBeUndefined(); + }); + + it('returns config for specific asset when multiple assets configured', () => { + const state = { + isTestnet: false, + tradeConfigurations: { + mainnet: { + BTC: { leverage: 10 }, + ETH: { leverage: 5 }, + SOL: { leverage: 3 }, + }, + testnet: {}, + }, + } as unknown as PerpsControllerState; + + const ethResult = selectTradeConfiguration(state, 'ETH'); + const btcResult = selectTradeConfiguration(state, 'BTC'); + + expect(ethResult).toEqual({ leverage: 5 }); + expect(btcResult).toEqual({ leverage: 10 }); + }); + }); + + describe('selectWatchlistMarkets', () => { + it('returns mainnet watchlist when not on testnet', () => { + const state = { + isTestnet: false, + watchlistMarkets: { + mainnet: ['BTC', 'ETH', 'SOL'], + testnet: ['DOGE'], + }, + } as unknown as PerpsControllerState; + + const result = selectWatchlistMarkets(state); + + expect(result).toEqual(['BTC', 'ETH', 'SOL']); + }); + + it('returns testnet watchlist when on testnet', () => { + const state = { + isTestnet: true, + watchlistMarkets: { + mainnet: ['BTC', 'ETH'], + testnet: ['DOGE', 'PEPE'], + }, + } as unknown as PerpsControllerState; + + const result = selectWatchlistMarkets(state); + + expect(result).toEqual(['DOGE', 'PEPE']); + }); + + it('returns empty array when watchlist is undefined', () => { + const state = { + isTestnet: false, + } as unknown as PerpsControllerState; + + const result = selectWatchlistMarkets(state); + + expect(result).toEqual([]); + }); + }); + + describe('selectIsWatchlistMarket', () => { + it('returns true when market is in watchlist', () => { + const state = { + isTestnet: false, + watchlistMarkets: { + mainnet: ['BTC', 'ETH', 'SOL'], + testnet: [], + }, + } as unknown as PerpsControllerState; + + const result = selectIsWatchlistMarket(state, 'ETH'); + + expect(result).toBe(true); + }); + + it('returns false when market is not in watchlist', () => { + const state = { + isTestnet: false, + watchlistMarkets: { + mainnet: ['BTC', 'ETH'], + testnet: [], + }, + } as unknown as PerpsControllerState; + + const result = selectIsWatchlistMarket(state, 'SOL'); + + expect(result).toBe(false); + }); + }); + + describe('selectHasPlacedFirstOrder', () => { + it('returns mainnet value when not on testnet', () => { + const state = { + isTestnet: false, + hasPlacedFirstOrder: { + mainnet: true, + testnet: false, + }, + } as unknown as PerpsControllerState; + + const result = selectHasPlacedFirstOrder(state); + + expect(result).toBe(true); + }); + + it('returns testnet value when on testnet', () => { + const state = { + isTestnet: true, + hasPlacedFirstOrder: { + mainnet: true, + testnet: false, + }, + } as unknown as PerpsControllerState; + + const result = selectHasPlacedFirstOrder(state); + + expect(result).toBe(false); + }); + + it('returns false when hasPlacedFirstOrder is undefined', () => { + const state = { + isTestnet: false, + } as unknown as PerpsControllerState; + + const result = selectHasPlacedFirstOrder(state); + + expect(result).toBe(false); + }); + }); + + describe('selectMarketFilterPreferences', () => { + it('returns saved filter preferences when defined', () => { + const state = { + marketFilterPreferences: { + optionId: 'priceChange', + direction: 'asc', + }, + } as unknown as PerpsControllerState; + + const result = selectMarketFilterPreferences(state); + + expect(result).toEqual({ + optionId: 'priceChange', + direction: 'asc', + }); + }); + + it('returns default preferences when preference is undefined', () => { + const state = {} as unknown as PerpsControllerState; + + const result = selectMarketFilterPreferences(state); + + expect(result).toEqual({ + optionId: MARKET_SORTING_CONFIG.DefaultSortOptionId, + direction: MARKET_SORTING_CONFIG.DefaultDirection, + }); + }); + + it('handles legacy string format (backward compatibility)', () => { + const state = { + marketFilterPreferences: 'priceChange', + } as unknown as PerpsControllerState; + + const result = selectMarketFilterPreferences(state); + + expect(result).toEqual({ + optionId: 'priceChange', + direction: MARKET_SORTING_CONFIG.DefaultDirection, + }); + }); + + it('handles legacy compound ID priceChange-desc (backward compatibility)', () => { + const state = { + marketFilterPreferences: 'priceChange-desc', + } as unknown as PerpsControllerState; + + const result = selectMarketFilterPreferences(state); + + expect(result).toEqual({ + optionId: 'priceChange', + direction: 'desc', + }); + }); + + it('handles legacy compound ID priceChange-asc (backward compatibility)', () => { + const state = { + marketFilterPreferences: 'priceChange-asc', + } as unknown as PerpsControllerState; + + const result = selectMarketFilterPreferences(state); + + expect(result).toEqual({ + optionId: 'priceChange', + direction: 'asc', + }); + }); + + it('handles other legacy simple strings (backward compatibility)', () => { + const state = { + marketFilterPreferences: 'volume', + } as unknown as PerpsControllerState; + + const result = selectMarketFilterPreferences(state); + + expect(result).toEqual({ + optionId: 'volume', + direction: MARKET_SORTING_CONFIG.DefaultDirection, + }); + }); + }); + + describe('selectPendingTradeConfiguration', () => { + beforeEach(() => { + jest.useFakeTimers(); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + it('returns pending config for mainnet when not testnet and not expired', () => { + const now = Date.now(); + const state = { + isTestnet: false, + tradeConfigurations: { + mainnet: { + BTC: { + leverage: 10, + pendingConfig: { + amount: '100', + leverage: 5, + takeProfitPrice: '50000', + stopLossPrice: '40000', + limitPrice: '45000', + orderType: 'limit', + reduceOnly: true, + direction: 'short', + timestamp: now, + }, + }, + }, + testnet: {}, + }, + } as unknown as PerpsControllerState; + + const result = selectPendingTradeConfiguration(state, 'BTC'); + + expect(result).toEqual({ + amount: '100', + leverage: 5, + takeProfitPrice: '50000', + stopLossPrice: '40000', + limitPrice: '45000', + orderType: 'limit', + reduceOnly: true, + direction: 'short', + }); + }); + + it('returns undefined for expired pending config (more than 30 seconds)', () => { + const expiredAt = Date.now() - 30_001; + const state = { + isTestnet: false, + tradeConfigurations: { + mainnet: { + BTC: { + leverage: 10, + pendingConfig: { + amount: '100', + leverage: 5, + timestamp: expiredAt, + }, + }, + }, + testnet: {}, + }, + } as unknown as PerpsControllerState; + + const result = selectPendingTradeConfiguration(state, 'BTC'); + + expect(result).toBeUndefined(); + }); + + it('returns pending config for valid config (less than 30 seconds)', () => { + const validAt = Date.now() - 29_999; + const state = { + isTestnet: false, + tradeConfigurations: { + mainnet: { + BTC: { + leverage: 10, + pendingConfig: { + amount: '100', + leverage: 5, + orderType: 'market', + timestamp: validAt, + }, + }, + }, + testnet: {}, + }, + } as unknown as PerpsControllerState; + + const result = selectPendingTradeConfiguration(state, 'BTC'); + + expect(result).toEqual({ + amount: '100', + leverage: 5, + orderType: 'market', + }); + }); + + it('returns undefined when no pending config exists', () => { + const state = { + isTestnet: false, + tradeConfigurations: { + mainnet: { + BTC: { + leverage: 10, + }, + }, + testnet: {}, + }, + } as unknown as PerpsControllerState; + + const result = selectPendingTradeConfiguration(state, 'BTC'); + + expect(result).toBeUndefined(); + }); + + it('returns pending config for testnet when on testnet', () => { + const now = Date.now(); + const state = { + isTestnet: true, + tradeConfigurations: { + mainnet: {}, + testnet: { + ETH: { + leverage: 5, + pendingConfig: { + amount: '200', + leverage: 10, + timestamp: now, + }, + }, + }, + }, + } as unknown as PerpsControllerState; + + const result = selectPendingTradeConfiguration(state, 'ETH'); + + expect(result).toEqual({ + amount: '200', + leverage: 10, + }); + }); + + it('returns undefined when config exists but has no pendingConfig', () => { + const state = { + isTestnet: false, + tradeConfigurations: { + mainnet: { + BTC: { + leverage: 10, + }, + }, + testnet: {}, + }, + } as unknown as PerpsControllerState; + + const result = selectPendingTradeConfiguration(state, 'BTC'); + + expect(result).toBeUndefined(); + }); + + it('applies the TTL on every call even when the memoized inputs are unchanged', () => { + const now = Date.now(); + const state = { + isTestnet: false, + tradeConfigurations: { + mainnet: { + BTC: { + leverage: 10, + pendingConfig: { + amount: '100', + leverage: 5, + timestamp: now, + }, + }, + }, + testnet: {}, + }, + } as unknown as PerpsControllerState; + + // First read while the draft is valid populates the memoized cache. + expect(selectPendingTradeConfiguration(state, 'BTC')).toEqual({ + amount: '100', + leverage: 5, + }); + + // Time passes beyond the TTL without any change to isTestnet, + // tradeConfigurations, or coin (same state reference). + jest.advanceTimersByTime( + PERPS_CONSTANTS.PendingTradeConfigurationTtlMs + 1, + ); + + // Re-reading with the identical inputs must still reflect expiry rather + // than returning the stale memoized draft. + expect(selectPendingTradeConfiguration(state, 'BTC')).toBeUndefined(); + }); + + it('returns a stable reference across re-selection while the draft is valid', () => { + const now = Date.now(); + const state = { + isTestnet: false, + tradeConfigurations: { + mainnet: { + BTC: { + leverage: 10, + pendingConfig: { + amount: '100', + leverage: 5, + timestamp: now, + }, + }, + }, + testnet: {}, + }, + } as unknown as PerpsControllerState; + + const first = selectPendingTradeConfiguration(state, 'BTC'); + const second = selectPendingTradeConfiguration(state, 'BTC'); + + expect(second).toBe(first); + }); + }); + + describe('selectOrderBookGrouping', () => { + it('returns mainnet order book grouping when not on testnet', () => { + const state = { + isTestnet: false, + tradeConfigurations: { + mainnet: { + BTC: { orderBookGrouping: 10 }, + }, + testnet: {}, + }, + } as unknown as PerpsControllerState; + + const result = selectOrderBookGrouping(state, 'BTC'); + + expect(result).toBe(10); + }); + + it('returns testnet order book grouping when on testnet', () => { + const state = { + isTestnet: true, + tradeConfigurations: { + mainnet: {}, + testnet: { + ETH: { orderBookGrouping: 0.01 }, + }, + }, + } as unknown as PerpsControllerState; + + const result = selectOrderBookGrouping(state, 'ETH'); + + expect(result).toBe(0.01); + }); + + it('returns undefined when no config exists for asset', () => { + const state = { + isTestnet: false, + tradeConfigurations: { + mainnet: {}, + testnet: {}, + }, + } as PerpsControllerState; + + const result = selectOrderBookGrouping(state, 'SOL'); + + expect(result).toBeUndefined(); + }); + + it('returns undefined when orderBookGrouping is not set', () => { + const state = { + isTestnet: false, + tradeConfigurations: { + mainnet: { + BTC: { leverage: 10 }, + }, + testnet: {}, + }, + } as unknown as PerpsControllerState; + + const result = selectOrderBookGrouping(state, 'BTC'); + + expect(result).toBeUndefined(); + }); + }); + + describe('selectRecentlyViewedMarkets', () => { + const now = Date.now(); + const withinTtl = now - 60 * 60 * 1000; // 1 hour ago — within 24h TTL + const expired = now - 25 * 60 * 60 * 1000; // 25 hours ago — outside 24h TTL + + beforeEach(() => { + jest.spyOn(Date, 'now').mockReturnValue(now); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + it('returns mainnet symbols when not on testnet, ordered newest-first', () => { + const state = { + isTestnet: false, + recentlyViewedMarkets: { + mainnet: [ + { symbol: 'BTC', viewedAt: withinTtl + 1000 }, + { symbol: 'ETH', viewedAt: withinTtl }, + ], + testnet: [], + }, + } as unknown as PerpsControllerState; + + expect(selectRecentlyViewedMarkets(state)).toStrictEqual(['BTC', 'ETH']); + }); + + it('returns testnet symbols when on testnet', () => { + const state = { + isTestnet: true, + recentlyViewedMarkets: { + mainnet: [{ symbol: 'BTC', viewedAt: withinTtl }], + testnet: [{ symbol: 'SOL', viewedAt: withinTtl }], + }, + } as unknown as PerpsControllerState; + + expect(selectRecentlyViewedMarkets(state)).toStrictEqual(['SOL']); + }); + + it('filters out entries older than 24 hours', () => { + const state = { + isTestnet: false, + recentlyViewedMarkets: { + mainnet: [ + { symbol: 'BTC', viewedAt: withinTtl }, + { symbol: 'ETH', viewedAt: expired }, + ], + testnet: [], + }, + } as unknown as PerpsControllerState; + + expect(selectRecentlyViewedMarkets(state)).toStrictEqual(['BTC']); + }); + + it('returns empty array when all entries are expired', () => { + const state = { + isTestnet: false, + recentlyViewedMarkets: { + mainnet: [{ symbol: 'BTC', viewedAt: expired }], + testnet: [], + }, + } as unknown as PerpsControllerState; + + expect(selectRecentlyViewedMarkets(state)).toStrictEqual([]); + }); + + it('returns empty array when recentlyViewedMarkets is undefined', () => { + const state = { isTestnet: false } as unknown as PerpsControllerState; + + expect(selectRecentlyViewedMarkets(state)).toStrictEqual([]); + }); + + it('caps results at 10 items', () => { + const entries = Array.from({ length: 15 }, (_, i) => ({ + symbol: `COIN${i}`, + viewedAt: withinTtl + i * 1000, + })).reverse(); // newest-first in storage + + const state = { + isTestnet: false, + recentlyViewedMarkets: { mainnet: entries, testnet: [] }, + } as unknown as PerpsControllerState; + + const result = selectRecentlyViewedMarkets(state); + expect(result).toHaveLength(10); + }); + }); + + describe('selectProLayoutPreferences', () => { + const defaults = { + orderBookExpanded: false, + chartExpanded: true, + orderBookPosition: 'left', + orderFormPosition: 'right', + positionsSideFilter: 'all', + positionsSortField: 'positionValue', + positionsSortDirection: 'desc', + ordersSideFilter: 'all', + ordersSortField: 'time', + ordersSortDirection: 'desc', + }; + + it('returns the pro-mode layout preferences', () => { + const proLayoutPreferences = { + orderBookExpanded: true, + chartExpanded: false, + orderBookPosition: 'right' as const, + orderFormPosition: 'left' as const, + positionsSideFilter: 'long' as const, + positionsSortField: 'unrealizedPnl' as const, + positionsSortDirection: 'asc' as const, + ordersSideFilter: 'short' as const, + ordersSortField: 'orderValue' as const, + ordersSortDirection: 'asc' as const, + }; + const state = { + proLayoutPreferences, + } as unknown as PerpsControllerState; + + expect(selectProLayoutPreferences(state)).toStrictEqual( + proLayoutPreferences, + ); + }); + + it('merges persisted fields over defaults so missing fields fall back', () => { + const state = { + proLayoutPreferences: { orderBookExpanded: true }, + } as unknown as PerpsControllerState; + + expect(selectProLayoutPreferences(state)).toStrictEqual({ + ...defaults, + orderBookExpanded: true, + }); + }); + + it('returns defaults when the state slice is missing', () => { + const state = {} as unknown as PerpsControllerState; + + expect(selectProLayoutPreferences(state)).toStrictEqual(defaults); + }); + + it('returns defaults when state is undefined', () => { + expect( + selectProLayoutPreferences( + undefined as unknown as PerpsControllerState, + ), + ).toStrictEqual(defaults); + }); + }); + + describe('selectOrderBookPreferences', () => { + it('merges persisted fields over defaults', () => { + const state = { + orderBookPreferences: { currency: 'base' }, + } as unknown as PerpsControllerState; + + expect(selectOrderBookPreferences(state)).toStrictEqual({ + currency: 'base', + metric: 'total', + }); + }); + + it('returns defaults when the state slice is missing', () => { + expect( + selectOrderBookPreferences({} as PerpsControllerState), + ).toStrictEqual({ + currency: 'usd', + metric: 'total', + }); + }); + }); + + describe('selectSelectedOrderType', () => { + it('returns the persisted market-agnostic order type', () => { + const state = { + selectedOrderType: 'limit', + } as unknown as PerpsControllerState; + + expect(selectSelectedOrderType(state)).toBe('limit'); + }); + + it('defaults to market', () => { + expect(selectSelectedOrderType({} as PerpsControllerState)).toBe( + 'market', + ); + }); + }); + + describe('selectVisibleCandleCount', () => { + it('returns the persisted count', () => { + const state = { + visibleCandleCount: 45, + } as unknown as PerpsControllerState; + + expect(selectVisibleCandleCount(state)).toBe(45); + }); + + it('defaults to 30', () => { + expect(selectVisibleCandleCount({} as PerpsControllerState)).toBe(30); + }); + }); + + describe('selectPerpsMode', () => { + it('returns the current mode', () => { + const state = { mode: 'pro' } as unknown as PerpsControllerState; + + expect(selectPerpsMode(state)).toBe('pro'); + }); + + it('returns the default mode when the state slice is missing', () => { + const state = {} as unknown as PerpsControllerState; + + expect(selectPerpsMode(state)).toBe('lite'); + }); + + it('returns the default mode when state is undefined', () => { + expect( + selectPerpsMode(undefined as unknown as PerpsControllerState), + ).toBe('lite'); + }); + }); +}); diff --git a/packages/perps-controller/tests/src/services/AccountService.test.ts b/packages/perps-controller/tests/src/services/AccountService.test.ts new file mode 100644 index 00000000000..2b3ee8a04f5 --- /dev/null +++ b/packages/perps-controller/tests/src/services/AccountService.test.ts @@ -0,0 +1,664 @@ +import type { PerpsControllerState } from '../../../src/PerpsController.js'; +import { AccountService } from '../../../src/services/AccountService.js'; +import type { ServiceContext } from '../../../src/services/ServiceContext.js'; +import { PerpsAnalyticsEvent } from '../../../src/types/index.js'; +import type { + PerpsProvider, + WithdrawParams, + WithdrawResult, + PerpsPlatformDependencies, +} from '../../../src/types/index.js'; +/* eslint-disable */ +import { createMockHyperLiquidProvider } from '../../helpers/providerMocks.js'; +import { + createMockServiceContext, + createMockInfrastructure, + createMockMessenger, +} from '../../helpers/serviceMocks.js'; + +jest.mock('uuid', () => ({ v4: () => 'mock-withdrawal-trace-id' })); +jest.mock('../../../src/constants/eventNames', () => ({ + PERPS_EVENT_PROPERTY: { + STATUS: 'status', + WITHDRAWAL_AMOUNT: 'withdrawal_amount', + COMPLETION_DURATION: 'completion_duration', + ERROR_MESSAGE: 'error_message', + }, + PERPS_EVENT_VALUE: { + STATUS: { + EXECUTED: 'executed', + FAILED: 'failed', + }, + }, +})); +jest.mock('../../../src/constants/hyperLiquidConfig', () => ({ + USDC_SYMBOL: 'USDC', +})); +jest.mock('../../../src/perpsErrorCodes', () => ({ + PERPS_ERROR_CODES: { + WITHDRAW_FAILED: 'WITHDRAW_FAILED', + }, +})); +// Note: EVM account is now retrieved via messenger.call('AccountTreeController:getAccountsFromSelectedAccountGroup') +// The mock is set up via createMockMessenger() in serviceMocks.ts + +describe('AccountService', () => { + let mockProvider: jest.Mocked; + let mockContext: ServiceContext; + let mockRefreshAccountState: jest.Mock; + let mockDeps: PerpsPlatformDependencies; + let mockMessenger: ReturnType; + let accountService: AccountService; + + const mockWithdrawParams: WithdrawParams = { + assetId: 'eip155:42161/erc20:0xTokenAddress/default', + amount: '100', + destination: '0xDestination', + }; + + beforeEach(() => { + mockProvider = + createMockHyperLiquidProvider() as unknown as jest.Mocked; + mockContext = createMockServiceContext({ + errorContext: { controller: 'AccountService', method: 'test' }, + }); + mockRefreshAccountState = jest.fn().mockResolvedValue(undefined); + + // Create mock dependencies and service instance + mockDeps = createMockInfrastructure(); + mockMessenger = createMockMessenger(); + accountService = new AccountService(mockDeps, mockMessenger); + + jest.clearAllMocks(); + + // Mock Date.now() to return a stable timestamp + jest.spyOn(Date, 'now').mockReturnValue(1234567890000); + }); + + afterEach(() => { + jest.resetAllMocks(); + }); + + describe('withdraw', () => { + it('executes successful withdrawal with tx hash', async () => { + const mockResult: WithdrawResult = { + success: true, + txHash: '0xTransactionHash', + withdrawalId: 'withdrawal-123', + }; + mockProvider.withdraw.mockResolvedValue(mockResult); + + const result = await accountService.withdraw({ + provider: mockProvider, + params: mockWithdrawParams, + context: mockContext, + refreshAccountState: mockRefreshAccountState, + }); + + expect(result).toEqual(mockResult); + expect(mockProvider.withdraw).toHaveBeenCalledWith(mockWithdrawParams); + }); + + it('starts trace with correct parameters', async () => { + mockProvider.withdraw.mockResolvedValue({ + success: true, + txHash: '0xHash', + }); + + await accountService.withdraw({ + provider: mockProvider, + params: mockWithdrawParams, + context: mockContext, + refreshAccountState: mockRefreshAccountState, + }); + + expect(mockDeps.tracer.trace).toHaveBeenCalledWith( + expect.objectContaining({ + name: 'Perps Withdraw', + id: 'mock-withdrawal-trace-id', + tags: expect.objectContaining({ + assetId: mockWithdrawParams.assetId, + provider: 'hyperliquid', + isTestnet: 'false', + }), + }), + ); + }); + + it('ends trace on successful withdrawal', async () => { + mockProvider.withdraw.mockResolvedValue({ + success: true, + txHash: '0xHash', + withdrawalId: 'withdrawal-123', + }); + + await accountService.withdraw({ + provider: mockProvider, + params: mockWithdrawParams, + context: mockContext, + refreshAccountState: mockRefreshAccountState, + }); + + expect(mockDeps.tracer.endTrace).toHaveBeenCalledWith( + expect.objectContaining({ + name: 'Perps Withdraw', + id: 'mock-withdrawal-trace-id', + data: expect.objectContaining({ + success: true, + txHash: '0xHash', + withdrawalId: 'withdrawal-123', + }), + }), + ); + }); + + it('sets withdrawal in progress state before provider call', async () => { + mockProvider.withdraw.mockResolvedValue({ + success: true, + txHash: '0xHash', + }); + + await accountService.withdraw({ + provider: mockProvider, + params: mockWithdrawParams, + context: mockContext, + refreshAccountState: mockRefreshAccountState, + }); + + expect(mockContext.stateManager?.update).toHaveBeenCalled(); + }); + + it('calculates net amount after $1 USDC fee', async () => { + mockProvider.withdraw.mockResolvedValue({ + success: true, + txHash: '0xHash', + }); + + await accountService.withdraw({ + provider: mockProvider, + params: { ...mockWithdrawParams, amount: '100' }, + context: mockContext, + refreshAccountState: mockRefreshAccountState, + }); + + const updateCall = (mockContext.stateManager?.update as jest.Mock).mock + .calls[0][0]; + const mockState: Pick< + PerpsControllerState, + | 'withdrawInProgress' + | 'withdrawalRequests' + | 'lastError' + | 'lastUpdateTimestamp' + | 'lastWithdrawResult' + > = { + withdrawInProgress: false, + withdrawalRequests: [], + lastError: null, + lastUpdateTimestamp: 0, + lastWithdrawResult: null, + }; + updateCall(mockState); + + expect(mockState.withdrawalRequests[0].amount).toBe('99'); + }); + + it('creates withdrawal request with pending status', async () => { + mockProvider.withdraw.mockResolvedValue({ + success: true, + txHash: '0xHash', + }); + + await accountService.withdraw({ + provider: mockProvider, + params: mockWithdrawParams, + context: mockContext, + refreshAccountState: mockRefreshAccountState, + }); + + const updateCall = (mockContext.stateManager?.update as jest.Mock).mock + .calls[0][0]; + const mockState = { + withdrawInProgress: false, + withdrawalRequests: [], + lastError: null, + lastUpdateTimestamp: 0, + lastWithdrawResult: null, + }; + updateCall(mockState); + + expect(mockState.withdrawalRequests[0]).toEqual( + expect.objectContaining({ + status: 'pending', + success: false, + asset: 'USDC', + destination: mockWithdrawParams.destination, + }), + ); + }); + + it('removes withdrawal request from queue when provider returns tx hash', async () => { + mockProvider.withdraw.mockResolvedValue({ + success: true, + txHash: '0xTransactionHash', + withdrawalId: 'withdrawal-123', + }); + + await accountService.withdraw({ + provider: mockProvider, + params: mockWithdrawParams, + context: mockContext, + refreshAccountState: mockRefreshAccountState, + }); + + const updateCalls = (mockContext.stateManager?.update as jest.Mock).mock + .calls; + + // Run the first updater to discover the generated withdrawal ID + const setupUpdater = updateCalls[0][0]; + const setupState = { + withdrawInProgress: false, + withdrawalRequests: [] as { id: string; status: string }[], + }; + setupUpdater(setupState); + const generatedId = setupState.withdrawalRequests[0].id; + + // Build mock state with the real ID so the success updater can find it + const successUpdateCall = updateCalls[1][0]; + const mockState = { + withdrawInProgress: true, + withdrawalRequests: [{ id: generatedId, status: 'pending' }], + lastError: null, + lastUpdateTimestamp: 0, + lastWithdrawResult: null, + lastCompletedWithdrawalTimestamp: null, + lastCompletedWithdrawalTxHashes: [] as string[], + }; + + successUpdateCall(mockState); + + expect(mockState.withdrawInProgress).toBe(false); + expect(mockState.withdrawalRequests).toHaveLength(0); + expect(mockState.lastCompletedWithdrawalTimestamp).toBeNull(); + expect(mockState.lastCompletedWithdrawalTxHashes).toEqual([ + '0xTransactionHash', + ]); + expect(mockState.lastWithdrawResult).toEqual( + expect.objectContaining({ + success: true, + txHash: '0xTransactionHash', + }), + ); + }); + + it('updates state with bridging status when no tx hash', async () => { + mockProvider.withdraw.mockResolvedValue({ + success: true, + withdrawalId: 'withdrawal-123', + }); + + await accountService.withdraw({ + provider: mockProvider, + params: mockWithdrawParams, + context: mockContext, + refreshAccountState: mockRefreshAccountState, + }); + + const updateCalls = (mockContext.stateManager?.update as jest.Mock).mock + .calls; + expect(updateCalls.length).toBeGreaterThan(1); + }); + + it('triggers account refresh after successful withdrawal', async () => { + mockProvider.withdraw.mockResolvedValue({ + success: true, + txHash: '0xHash', + }); + + await accountService.withdraw({ + provider: mockProvider, + params: mockWithdrawParams, + context: mockContext, + refreshAccountState: mockRefreshAccountState, + }); + + expect(mockRefreshAccountState).toHaveBeenCalledTimes(1); + }); + + it('tracks analytics event on successful withdrawal', async () => { + mockProvider.withdraw.mockResolvedValue({ + success: true, + txHash: '0xHash', + }); + + await accountService.withdraw({ + provider: mockProvider, + params: mockWithdrawParams, + context: mockContext, + refreshAccountState: mockRefreshAccountState, + }); + + expect(mockDeps.metrics.trackPerpsEvent).toHaveBeenCalledWith( + PerpsAnalyticsEvent.WithdrawalTransaction, + expect.objectContaining({ + status: 'executed', + }), + ); + }); + + it('handles withdrawal failure from provider', async () => { + const mockResult: WithdrawResult = { + success: false, + error: 'Insufficient balance', + }; + mockProvider.withdraw.mockResolvedValue(mockResult); + + const result = await accountService.withdraw({ + provider: mockProvider, + params: mockWithdrawParams, + context: mockContext, + refreshAccountState: mockRefreshAccountState, + }); + + expect(result).toEqual(mockResult); + expect(result.success).toBe(false); + expect(result.error).toBe('Insufficient balance'); + }); + + it('removes withdrawal request from queue on provider failure', async () => { + mockProvider.withdraw.mockResolvedValue({ + success: false, + error: 'Insufficient balance', + }); + + await accountService.withdraw({ + provider: mockProvider, + params: mockWithdrawParams, + context: mockContext, + refreshAccountState: mockRefreshAccountState, + }); + + const updateCalls = (mockContext.stateManager?.update as jest.Mock).mock + .calls; + + const setupUpdater = updateCalls[0][0]; + const setupState = { + withdrawInProgress: false, + withdrawalRequests: [] as { id: string; status: string }[], + }; + setupUpdater(setupState); + const generatedId = setupState.withdrawalRequests[0].id; + + const failureUpdateCall = updateCalls[updateCalls.length - 1][0]; + const mockState: Pick< + PerpsControllerState, + | 'withdrawInProgress' + | 'withdrawalRequests' + | 'lastError' + | 'lastUpdateTimestamp' + | 'lastWithdrawResult' + > = { + withdrawInProgress: true, + withdrawalRequests: [ + { + id: generatedId, + status: 'pending', + success: false, + amount: '100', + asset: 'USDC', + accountAddress: expect.any(String) as string, + timestamp: Date.now(), + }, + ], + lastError: null, + lastUpdateTimestamp: 0, + lastWithdrawResult: null, + }; + + failureUpdateCall(mockState); + + expect(mockState.withdrawalRequests).toHaveLength(0); + expect(mockState.withdrawInProgress).toBe(false); + expect(mockState.lastError).toBe('Insufficient balance'); + expect(mockState.lastWithdrawResult?.success).toBe(false); + }); + + it('tracks analytics event on withdrawal failure', async () => { + mockProvider.withdraw.mockResolvedValue({ + success: false, + error: 'Insufficient balance', + }); + + await accountService.withdraw({ + provider: mockProvider, + params: mockWithdrawParams, + context: mockContext, + refreshAccountState: mockRefreshAccountState, + }); + + expect(mockDeps.metrics.trackPerpsEvent).toHaveBeenCalledWith( + PerpsAnalyticsEvent.WithdrawalTransaction, + expect.objectContaining({ + status: 'failed', + }), + ); + }); + + it('does not trigger account refresh on failure', async () => { + mockProvider.withdraw.mockResolvedValue({ + success: false, + error: 'Insufficient balance', + }); + + await accountService.withdraw({ + provider: mockProvider, + params: mockWithdrawParams, + context: mockContext, + refreshAccountState: mockRefreshAccountState, + }); + + expect(mockRefreshAccountState).not.toHaveBeenCalled(); + }); + + it('handles exception during withdrawal', async () => { + const error = new Error('Network error'); + mockProvider.withdraw.mockRejectedValue(error); + + const result = await accountService.withdraw({ + provider: mockProvider, + params: mockWithdrawParams, + context: mockContext, + refreshAccountState: mockRefreshAccountState, + }); + + expect(result.success).toBe(false); + expect(result.error).toBe('Network error'); + }); + + it('logs error on exception', async () => { + const error = new Error('Network error'); + mockProvider.withdraw.mockRejectedValue(error); + + await accountService.withdraw({ + provider: mockProvider, + params: mockWithdrawParams, + context: mockContext, + refreshAccountState: mockRefreshAccountState, + }); + + expect(mockDeps.logger.error).toHaveBeenCalled(); + }); + + it('updates state with error on exception', async () => { + mockProvider.withdraw.mockRejectedValue(new Error('Network error')); + + await accountService.withdraw({ + provider: mockProvider, + params: mockWithdrawParams, + context: mockContext, + refreshAccountState: mockRefreshAccountState, + }); + + const updateCalls = (mockContext.stateManager?.update as jest.Mock).mock + .calls; + + const setupUpdater = updateCalls[0][0]; + const setupState = { + withdrawInProgress: false, + withdrawalRequests: [] as { id: string; status: string }[], + }; + setupUpdater(setupState); + const generatedId = setupState.withdrawalRequests[0].id; + + const errorUpdateCall = updateCalls[updateCalls.length - 1][0]; + const mockState = { + withdrawInProgress: true, + withdrawalRequests: [ + { id: generatedId, status: 'pending', success: false }, + ], + lastError: null, + lastUpdateTimestamp: 0, + lastWithdrawResult: null, + }; + + errorUpdateCall(mockState); + + expect(mockState.lastError).toBe('Network error'); + expect(mockState.withdrawalRequests).toHaveLength(0); + expect(mockState.withdrawInProgress).toBe(false); + }); + + it('ends trace with error data on exception', async () => { + mockProvider.withdraw.mockRejectedValue(new Error('Network error')); + + await accountService.withdraw({ + provider: mockProvider, + params: mockWithdrawParams, + context: mockContext, + refreshAccountState: mockRefreshAccountState, + }); + + expect(mockDeps.tracer.endTrace).toHaveBeenCalledWith( + expect.objectContaining({ + name: 'Perps Withdraw', + id: 'mock-withdrawal-trace-id', + data: expect.objectContaining({ + success: false, + error: 'Network error', + }), + }), + ); + }); + + it('handles refresh account state error gracefully', async () => { + mockProvider.withdraw.mockResolvedValue({ + success: true, + txHash: '0xHash', + }); + mockRefreshAccountState.mockRejectedValue(new Error('Refresh failed')); + + const result = await accountService.withdraw({ + provider: mockProvider, + params: mockWithdrawParams, + context: mockContext, + refreshAccountState: mockRefreshAccountState, + }); + + expect(result.success).toBe(true); + }); + + it('generates unique withdrawal ID for tracking', async () => { + mockProvider.withdraw.mockResolvedValue({ + success: true, + txHash: '0xHash', + }); + + await accountService.withdraw({ + provider: mockProvider, + params: mockWithdrawParams, + context: mockContext, + refreshAccountState: mockRefreshAccountState, + }); + + const updateCall = (mockContext.stateManager?.update as jest.Mock).mock + .calls[0][0]; + const mockState: Pick< + PerpsControllerState, + | 'withdrawInProgress' + | 'withdrawalRequests' + | 'lastError' + | 'lastUpdateTimestamp' + | 'lastWithdrawResult' + > = { + withdrawInProgress: false, + withdrawalRequests: [], + lastError: null, + lastUpdateTimestamp: 0, + lastWithdrawResult: null, + }; + updateCall(mockState); + + expect(mockState.withdrawalRequests[0].id).toMatch( + /^withdraw-\d+-[a-z0-9]+$/, + ); + }); + }); + + describe('validateWithdrawal', () => { + it('delegates to provider validateWithdrawal', async () => { + const mockValidation = { isValid: true }; + mockProvider.validateWithdrawal.mockResolvedValue(mockValidation); + + const result = await accountService.validateWithdrawal({ + provider: mockProvider, + params: mockWithdrawParams, + }); + + expect(result).toEqual(mockValidation); + expect(mockProvider.validateWithdrawal).toHaveBeenCalledWith( + mockWithdrawParams, + ); + }); + + it('returns invalid when provider validation fails', async () => { + const mockValidation = { + isValid: false, + error: 'Amount exceeds balance', + }; + mockProvider.validateWithdrawal.mockResolvedValue(mockValidation); + + const result = await accountService.validateWithdrawal({ + provider: mockProvider, + params: mockWithdrawParams, + }); + + expect(result.isValid).toBe(false); + expect(result.error).toBe('Amount exceeds balance'); + }); + + it('throws error on exception', async () => { + const error = new Error('Validation error'); + mockProvider.validateWithdrawal.mockRejectedValue(error); + + await expect( + accountService.validateWithdrawal({ + provider: mockProvider, + params: mockWithdrawParams, + }), + ).rejects.toThrow('Validation error'); + }); + + it('logs error on exception', async () => { + const error = new Error('Validation error'); + mockProvider.validateWithdrawal.mockRejectedValue(error); + + await expect( + accountService.validateWithdrawal({ + provider: mockProvider, + params: mockWithdrawParams, + }), + ).rejects.toThrow(); + + expect(mockDeps.logger.error).toHaveBeenCalled(); + }); + }); +}); diff --git a/packages/perps-controller/tests/src/services/AggregatedOrderBookConnection.test.ts b/packages/perps-controller/tests/src/services/AggregatedOrderBookConnection.test.ts new file mode 100644 index 00000000000..152c1f9101c --- /dev/null +++ b/packages/perps-controller/tests/src/services/AggregatedOrderBookConnection.test.ts @@ -0,0 +1,846 @@ +import * as hl from '@nktkas/hyperliquid'; + +import { HYPERLIQUID_TRANSPORT_CONFIG } from '../../../src/constants/hyperLiquidConfig.js'; +import { + AggregatedOrderBookConnection, + processAggregatedOrderBook, +} from '../../../src/services/AggregatedOrderBookConnection.js'; + +/** + * Mirrors the reconnecting socket: a `terminationSignal` plus a helper that + * reproduces permanent termination (abort the signal, then a final `close`). + */ +type MockSocket = EventTarget & { + terminationSignal: AbortSignal; + /** Simulate permanent termination with the given `ReconnectingWebSocketError` code. */ + terminate: (code?: string) => void; +}; + +type MockTransport = { + options: Record; + close: jest.Mock; + socket: MockSocket; + subscribe: jest.Mock; +}; +/** Invokes the connection's listener with the raw snapshot (wrapped as `detail`). */ +type L2Emit = (data: unknown) => void; + +type MockState = { + transports: MockTransport[]; + listeners: { + channel: string; + params: unknown; + listener: L2Emit; + /** The SDK's post-confirmation failure callback, if the caller passed one. */ + onError?: (error: Error) => void; + }[]; + unsubscribe: jest.Mock; + resolveSubscribe: boolean; + rejectSubscribe: boolean; +}; + +jest.mock('@nktkas/hyperliquid', () => { + const state: MockState = { + transports: [], + listeners: [], + unsubscribe: jest.fn().mockResolvedValue(undefined), + resolveSubscribe: true, + rejectSubscribe: false, + }; + + // Reproduces the reconnecting socket's termination model: permanent + // termination aborts `terminationSignal` (reason carries a `code`) *before* + // the final `close` event fires — never a standalone `terminate` event. + class MockSocket extends EventTarget { + readonly #abortController = new AbortController(); + + get terminationSignal(): AbortSignal { + return this.#abortController.signal; + } + + terminate(code = 'RECONNECTION_LIMIT'): void { + if (!this.#abortController.signal.aborted) { + this.#abortController.abort({ code }); + } + this.dispatchEvent(new Event('close')); + } + } + + class WebSocketTransport { + options: Record; + + socket = new MockSocket(); + + // Mirror the SDK: closing aborts the termination signal (as + // `TERMINATED_BY_USER` if not already aborted) and dispatches a final + // `close` event. + close = jest.fn(() => { + this.socket.terminate('TERMINATED_BY_USER'); + }); + + subscribe = jest.fn( + ( + channel: string, + params: unknown, + listener: (event: { detail: unknown }) => void, + options?: { onError?: (error: Error) => void }, + ) => { + // Store an emitter that mirrors the SDK's CustomEvent delivery so tests + // can push a raw snapshot via `listeners[i].listener(rawData)`, plus the + // SDK's post-confirmation `onError` callback so tests can simulate a + // rejected re-subscription via `listeners[i].onError(err)`. + state.listeners.push({ + channel, + params, + listener: (detail: unknown) => listener({ detail }), + onError: options?.onError, + }); + if (state.rejectSubscribe) { + return Promise.reject(new Error('subscribe failed')); + } + return state.resolveSubscribe + ? Promise.resolve({ unsubscribe: state.unsubscribe }) + : new Promise(() => undefined); + }, + ); + + constructor(options: Record) { + this.options = options; + state.transports.push(this as unknown as MockTransport); + } + } + + type SubscribingTransport = { + subscribe: ( + channel: string, + params: unknown, + listener: (event: { detail: unknown }) => void, + options?: { onError?: (error: Error) => void }, + ) => Promise; + }; + + // Mirrors the typed subscription client: `l2Book` validates/normalizes the + // params, prepends `type: 'l2Book'`, and delegates to `transport.subscribe`, + // unwrapping the CustomEvent so the caller's listener receives the snapshot + // directly. Forwards `options` (`onError`) unchanged. + class SubscriptionClient { + readonly #transport: SubscribingTransport; + + constructor({ transport }: { transport: SubscribingTransport }) { + this.#transport = transport; + } + + async l2Book( + params: Record, + listener: (data: unknown) => void, + options?: { onError?: (error: Error) => void }, + ): Promise { + return this.#transport.subscribe( + 'l2Book', + { type: 'l2Book', ...params }, + (event) => listener(event.detail), + options, + ); + } + } + + return { WebSocketTransport, SubscriptionClient, mockState: state }; +}); + +const { mockState } = hl as unknown as { mockState: MockState }; + +const flush = async (): Promise => { + // Drain a few microtasks so both the resolve (.then) and reject (.then skip + // → .catch) branches of the subscribe promise chain settle. + await Promise.resolve(); + await Promise.resolve(); + await Promise.resolve(); +}; + +const l2Level = ( + px: string, + sz: string, + count = 1, +): { px: string; sz: string; n: number } => ({ px, sz, n: count }); + +describe('processAggregatedOrderBook', () => { + it('builds cumulative totals, spread, and mid price from an l2Book snapshot', () => { + const result = processAggregatedOrderBook( + { + coin: 'BTC', + time: 1, + levels: [ + [l2Level('64000', '2'), l2Level('63000', '1')], + [l2Level('65000', '1'), l2Level('66000', '3')], + ], + }, + 10, + ); + + expect(result.bids).toStrictEqual([ + { + price: '64000', + size: '2', + total: '2', + notional: '128000.00', + totalNotional: '128000.00', + }, + { + price: '63000', + size: '1', + total: '3', + notional: '63000.00', + totalNotional: '191000.00', + }, + ]); + expect(result.asks[0]).toStrictEqual({ + price: '65000', + size: '1', + total: '1', + notional: '65000.00', + totalNotional: '65000.00', + }); + // spread = 65000 - 64000, mid = (65000 + 64000) / 2 + expect(result.spread).toBe('1000.00000'); + expect(result.midPrice).toBe('64500.00000'); + expect(result.maxTotal).toBe('4'); + }); + + it('trims each side to the requested level count', () => { + const result = processAggregatedOrderBook( + { + coin: 'BTC', + time: 1, + levels: [ + [l2Level('3', '1'), l2Level('2', '1'), l2Level('1', '1')], + [l2Level('4', '1'), l2Level('5', '1'), l2Level('6', '1')], + ], + }, + 2, + ); + + expect(result.bids).toHaveLength(2); + expect(result.asks).toHaveLength(2); + }); + + it('reports a zero spread when a side is empty', () => { + const result = processAggregatedOrderBook( + { coin: 'BTC', time: 1, levels: [[], []] }, + 10, + ); + expect(result.spread).toBe('0.00000'); + expect(result.spreadPercentage).toBe('0'); + expect(result.midPrice).toBe('0.00000'); + }); +}); + +describe('AggregatedOrderBookConnection', () => { + beforeEach(() => { + mockState.transports.length = 0; + mockState.listeners.length = 0; + // `resetMocks: true` (repo jest config) clears the shared mock's + // implementation before each test, so re-apply the resolved value. + mockState.unsubscribe.mockReset().mockResolvedValue(undefined); + mockState.resolveSubscribe = true; + mockState.rejectSubscribe = false; + }); + + it('creates a mainnet transport and subscribes with the requested params', () => { + const connection = new AggregatedOrderBookConnection({ + isTestnet: (): boolean => false, + }); + const callback = jest.fn(); + + connection.subscribe({ + symbol: 'BTC', + levels: 20, + nSigFigs: 2, + callback, + }); + + expect(mockState.transports).toHaveLength(1); + // The dedicated transport reuses the package's transport config so it shares + // the finite five-attempt reconnection policy (not the SDK's Infinity). + expect(mockState.transports[0].options).toStrictEqual({ + isTestnet: false, + ...HYPERLIQUID_TRANSPORT_CONFIG, + reconnect: HYPERLIQUID_TRANSPORT_CONFIG.reconnect, + }); + expect(mockState.listeners[0].channel).toBe('l2Book'); + // Runs in fast mode (5 levels) via the raw subscription payload. + expect(mockState.listeners[0].params).toStrictEqual({ + type: 'l2Book', + coin: 'BTC', + nSigFigs: 2, + mantissa: null, + fast: true, + }); + }); + + it('creates a testnet transport when isTestnet resolves true', () => { + const connection = new AggregatedOrderBookConnection({ + isTestnet: (): boolean => true, + }); + connection.subscribe({ symbol: 'ETH', nSigFigs: 2, callback: jest.fn() }); + expect(mockState.transports[0].options).toStrictEqual({ + isTestnet: true, + ...HYPERLIQUID_TRANSPORT_CONFIG, + reconnect: HYPERLIQUID_TRANSPORT_CONFIG.reconnect, + }); + }); + + it('transforms snapshots and forwards them to the callback', () => { + const connection = new AggregatedOrderBookConnection({ + isTestnet: (): boolean => false, + }); + const callback = jest.fn(); + connection.subscribe({ symbol: 'BTC', nSigFigs: 2, callback }); + + mockState.listeners[0].listener({ + coin: 'BTC', + time: 1, + levels: [[l2Level('64000', '1')], [l2Level('65000', '1')]], + }); + + expect(callback).toHaveBeenCalledTimes(1); + expect(callback.mock.calls[0][0].midPrice).toBe('64500.00000'); + }); + + it('ignores snapshots for a different coin', () => { + const connection = new AggregatedOrderBookConnection({ + isTestnet: (): boolean => false, + }); + const callback = jest.fn(); + connection.subscribe({ symbol: 'BTC', nSigFigs: 2, callback }); + + mockState.listeners[0].listener({ + coin: 'ETH', + time: 1, + levels: [[l2Level('1', '1')], [l2Level('2', '1')]], + }); + + expect(callback).not.toHaveBeenCalled(); + }); + + it('reuses the same transport for a second subscription on the same network', () => { + const connection = new AggregatedOrderBookConnection({ + isTestnet: (): boolean => false, + }); + connection.subscribe({ symbol: 'BTC', nSigFigs: 2, callback: jest.fn() }); + connection.subscribe({ symbol: 'ETH', nSigFigs: 2, callback: jest.fn() }); + + expect(mockState.transports).toHaveLength(1); + expect(mockState.transports[0].subscribe).toHaveBeenCalledTimes(2); + }); + + it('rejects a second subscription for the same asset with different params', () => { + const connection = new AggregatedOrderBookConnection({ + isTestnet: (): boolean => false, + }); + connection.subscribe({ symbol: 'BTC', nSigFigs: 2, callback: jest.fn() }); + + expect(() => + connection.subscribe({ symbol: 'BTC', nSigFigs: 5, callback: jest.fn() }), + ).toThrow( + 'AggregatedOrderBookConnection: "BTC" is already subscribed with different params', + ); + // The conflicting subscribe must not reach the transport. + expect(mockState.transports[0].subscribe).toHaveBeenCalledTimes(1); + }); + + it('allows a repeat subscription for the same asset with identical params', () => { + const connection = new AggregatedOrderBookConnection({ + isTestnet: (): boolean => false, + }); + connection.subscribe({ symbol: 'BTC', nSigFigs: 2, callback: jest.fn() }); + + expect(() => + connection.subscribe({ symbol: 'BTC', nSigFigs: 2, callback: jest.fn() }), + ).not.toThrow(); + expect(mockState.transports).toHaveLength(1); + expect(mockState.transports[0].subscribe).toHaveBeenCalledTimes(2); + }); + + it('treats a different levels value as identical (levels is client-side only)', () => { + const connection = new AggregatedOrderBookConnection({ + isTestnet: (): boolean => false, + }); + connection.subscribe({ + symbol: 'BTC', + nSigFigs: 2, + levels: 5, + callback: jest.fn(), + }); + + expect(() => + connection.subscribe({ + symbol: 'BTC', + nSigFigs: 2, + levels: 20, + callback: jest.fn(), + }), + ).not.toThrow(); + }); + + it('allows different params for the same asset once the prior subscription is removed', () => { + const connection = new AggregatedOrderBookConnection({ + isTestnet: (): boolean => false, + }); + const unsub = connection.subscribe({ + symbol: 'BTC', + nSigFigs: 2, + callback: jest.fn(), + }); + unsub(); + + expect(() => + connection.subscribe({ symbol: 'BTC', nSigFigs: 5, callback: jest.fn() }), + ).not.toThrow(); + }); + + it('closes the transport once the last subscription is removed', async () => { + const connection = new AggregatedOrderBookConnection({ + isTestnet: (): boolean => false, + }); + const unsubA = connection.subscribe({ + symbol: 'BTC', + nSigFigs: 2, + callback: jest.fn(), + }); + const unsubB = connection.subscribe({ + symbol: 'ETH', + nSigFigs: 2, + callback: jest.fn(), + }); + await flush(); + + const [transport] = mockState.transports; + + unsubA(); + expect(transport.close).not.toHaveBeenCalled(); + + unsubB(); + expect(transport.close).toHaveBeenCalledTimes(1); + // The pending SDK subscriptions are also cancelled. + expect(mockState.unsubscribe).toHaveBeenCalledTimes(2); + }); + + it('recreates the transport when the network changes between subscriptions', () => { + let testnet = false; + const connection = new AggregatedOrderBookConnection({ + isTestnet: (): boolean => testnet, + }); + connection.subscribe({ symbol: 'BTC', nSigFigs: 2, callback: jest.fn() }); + expect(mockState.transports).toHaveLength(1); + + testnet = true; + connection.subscribe({ symbol: 'BTC', nSigFigs: 2, callback: jest.fn() }); + expect(mockState.transports).toHaveLength(2); + expect(mockState.transports[0].close).toHaveBeenCalledTimes(1); + expect(mockState.transports[1].options).toStrictEqual({ + isTestnet: true, + ...HYPERLIQUID_TRANSPORT_CONFIG, + reconnect: HYPERLIQUID_TRANSPORT_CONFIG.reconnect, + }); + }); + + it('does not tear down the new transport when an old subscription unsubscribes after a network change', () => { + let testnet = false; + const connection = new AggregatedOrderBookConnection({ + isTestnet: (): boolean => testnet, + }); + const unsubOld = connection.subscribe({ + symbol: 'BTC', + nSigFigs: 2, + callback: jest.fn(), + }); + + // Network flips, so the next subscribe recreates the transport. + testnet = true; + const unsubNew = connection.subscribe({ + symbol: 'BTC', + nSigFigs: 2, + callback: jest.fn(), + }); + expect(mockState.transports).toHaveLength(2); + const [, newTransport] = mockState.transports; + + // The stale subscription's unsubscribe must not touch the live socket. + unsubOld(); + expect(newTransport.close).not.toHaveBeenCalled(); + + // The remaining live subscription still owns the socket and closes it. + unsubNew(); + expect(newTransport.close).toHaveBeenCalledTimes(1); + }); + + it('cancels a subscription that resolves after unsubscribe', () => { + const connection = new AggregatedOrderBookConnection({ + isTestnet: (): boolean => false, + }); + const callback = jest.fn(); + const unsub = connection.subscribe({ + symbol: 'BTC', + nSigFigs: 2, + callback, + }); + + // Unsubscribe before the snapshot listener is invoked. + unsub(); + + mockState.listeners[0].listener({ + coin: 'BTC', + time: 1, + levels: [[l2Level('1', '1')], [l2Level('2', '1')]], + }); + + expect(callback).not.toHaveBeenCalled(); + }); + + it('closes the active transport on close()', () => { + const connection = new AggregatedOrderBookConnection({ + isTestnet: (): boolean => false, + }); + connection.subscribe({ symbol: 'BTC', nSigFigs: 2, callback: jest.fn() }); + connection.close(); + expect(mockState.transports[0].close).toHaveBeenCalledTimes(1); + }); + + describe('connection status', () => { + it('reports connecting immediately then connected once the subscription resolves', async () => { + const connection = new AggregatedOrderBookConnection({ + isTestnet: (): boolean => false, + }); + const onStatusChange = jest.fn(); + connection.subscribe({ + symbol: 'BTC', + nSigFigs: 2, + callback: jest.fn(), + onStatusChange, + }); + + expect(onStatusChange).toHaveBeenNthCalledWith(1, 'connecting'); + await flush(); + expect(onStatusChange).toHaveBeenCalledWith('connected'); + }); + + it('reports connected on socket open and connecting on socket close', () => { + const connection = new AggregatedOrderBookConnection({ + isTestnet: (): boolean => false, + }); + const onStatusChange = jest.fn(); + connection.subscribe({ + symbol: 'BTC', + nSigFigs: 2, + callback: jest.fn(), + onStatusChange, + }); + const { socket } = mockState.transports[0]; + + socket.dispatchEvent(new Event('open')); + expect(onStatusChange).toHaveBeenCalledWith('connected'); + + socket.dispatchEvent(new Event('close')); + expect(onStatusChange).toHaveBeenLastCalledWith('connecting'); + }); + + it('reports error when the socket terminates (reconnection exhausted)', () => { + const connection = new AggregatedOrderBookConnection({ + isTestnet: (): boolean => false, + }); + const onStatusChange = jest.fn(); + connection.subscribe({ + symbol: 'BTC', + nSigFigs: 2, + callback: jest.fn(), + onStatusChange, + }); + + // Reconnection exhausted: the socket aborts its `terminationSignal` then + // dispatches a final `close`. + mockState.transports[0].socket.terminate(); + expect(onStatusChange).toHaveBeenLastCalledWith('error'); + }); + + it('reports connecting (not error) when close fires from an intentional close()', () => { + const connection = new AggregatedOrderBookConnection({ + isTestnet: (): boolean => false, + }); + const onStatusChange = jest.fn(); + connection.subscribe({ + symbol: 'BTC', + nSigFigs: 2, + callback: jest.fn(), + onStatusChange, + }); + + // A user-triggered close aborts the signal with `TERMINATED_BY_USER`; that + // must not be surfaced as the unrecoverable `error` state. + mockState.transports[0].socket.terminate('TERMINATED_BY_USER'); + expect(onStatusChange).toHaveBeenLastCalledWith('connecting'); + expect(onStatusChange).not.toHaveBeenCalledWith('error'); + }); + + it('stays in error (not connected) when the subscribe promise resolves after the socket terminates', async () => { + const connection = new AggregatedOrderBookConnection({ + isTestnet: (): boolean => false, + }); + const onStatusChange = jest.fn(); + connection.subscribe({ + symbol: 'BTC', + nSigFigs: 2, + callback: jest.fn(), + onStatusChange, + }); + + // Reconnection is exhausted before the pending subscribe promise settles. + mockState.transports[0].socket.terminate(); + expect(onStatusChange).toHaveBeenLastCalledWith('error'); + + // The pending subscribe promise now resolves; it must not flip the UI back + // to `connected` on the dead socket. + await flush(); + expect(onStatusChange).toHaveBeenLastCalledWith('error'); + expect(onStatusChange).not.toHaveBeenCalledWith('connected'); + }); + + it('errors and tears down when a confirmed subscription is rejected on resubscribe', async () => { + const connection = new AggregatedOrderBookConnection({ + isTestnet: (): boolean => false, + }); + const onStatusChange = jest.fn(); + connection.subscribe({ + symbol: 'BTC', + nSigFigs: 2, + callback: jest.fn(), + onStatusChange, + }); + + // The subscription is confirmed first. + await flush(); + expect(onStatusChange).toHaveBeenLastCalledWith('connected'); + + // After a reconnect the server rejects the re-subscription: the SDK + // invokes `onError` and removes the listener (no further events follow). + // The service must surface `error` and tear down rather than keep + // reporting `connected` with a frozen order book. + mockState.listeners[0].onError?.(new Error('resubscribe rejected')); + + expect(onStatusChange).toHaveBeenLastCalledWith('error'); + expect(mockState.transports[0].close).toHaveBeenCalledTimes(1); + }); + + it('does not report connected when the transport is replaced before an in-flight subscribe resolves', async () => { + let testnet = false; + const connection = new AggregatedOrderBookConnection({ + isTestnet: (): boolean => testnet, + }); + const onStatusChangeOld = jest.fn(); + connection.subscribe({ + symbol: 'BTC', + nSigFigs: 2, + callback: jest.fn(), + onStatusChange: onStatusChangeOld, + }); + + // Flip the network before the first subscribe settles: this recreates the + // transport, so the first (still-pending) subscription is now stale. + testnet = true; + const onStatusChangeNew = jest.fn(); + connection.subscribe({ + symbol: 'BTC', + nSigFigs: 2, + callback: jest.fn(), + onStatusChange: onStatusChangeNew, + }); + expect(mockState.transports).toHaveLength(2); + + await flush(); + + // The stale subscription must not announce `connected` on the dead socket, + // and its SDK subscription is cleaned up rather than stored. + expect(onStatusChangeOld).not.toHaveBeenCalledWith('connected'); + expect(mockState.unsubscribe).toHaveBeenCalled(); + // The live subscription on the new transport still reports connected. + expect(onStatusChangeNew).toHaveBeenLastCalledWith('connected'); + }); + + it('terminates orphaned subscriptions when the transport is rebuilt on a network flip', async () => { + let testnet = false; + const connection = new AggregatedOrderBookConnection({ + isTestnet: (): boolean => testnet, + }); + const onStatusChangeOld = jest.fn(); + const callbackOld = jest.fn(); + connection.subscribe({ + symbol: 'BTC', + nSigFigs: 2, + callback: callbackOld, + onStatusChange: onStatusChangeOld, + }); + await flush(); + expect(onStatusChangeOld).toHaveBeenLastCalledWith('connected'); + + // Flip the network and subscribe again: this rebuilds the transport, + // orphaning the first subscription that was live on the old socket. + testnet = true; + connection.subscribe({ symbol: 'ETH', nSigFigs: 2, callback: jest.fn() }); + + // The orphaned subscription is notified with `error` and its SDK + // subscription is cleaned up rather than left dangling on the dead socket. + expect(onStatusChangeOld).toHaveBeenLastCalledWith('error'); + expect(mockState.unsubscribe).toHaveBeenCalled(); + expect(mockState.transports[0].close).toHaveBeenCalledTimes(1); + + // A late snapshot on the orphaned listener must be ignored. + mockState.listeners[0].listener({ + coin: 'BTC', + time: 1, + levels: [[l2Level('1', '1')], [l2Level('2', '1')]], + }); + expect(callbackOld).not.toHaveBeenCalled(); + }); + + it('binds a reentrant subscribe from an onStatusChange handler to a fresh transport', () => { + const connection = new AggregatedOrderBookConnection({ + isTestnet: (): boolean => false, + }); + let reentered = false; + const onStatusChange = jest.fn((status: string) => { + // Re-subscribe synchronously from within the terminal `error` + // notification emitted while the transport is being torn down. + if (status === 'error' && !reentered) { + reentered = true; + connection.subscribe({ + symbol: 'ETH', + nSigFigs: 2, + callback: jest.fn(), + }); + } + }); + connection.subscribe({ + symbol: 'BTC', + nSigFigs: 2, + callback: jest.fn(), + onStatusChange, + }); + const [firstTransport] = mockState.transports; + + // Closing force-terminates BTC, whose `error` handler re-subscribes. The + // reentrant subscription must bind to a NEW transport, not the dying one. + connection.close(); + + expect(reentered).toBe(true); + expect(firstTransport.close).toHaveBeenCalledTimes(1); + expect(mockState.transports).toHaveLength(2); + const newTransport = mockState.transports[1]; + expect(newTransport).not.toBe(firstTransport); + // The reentrant subscription's transport is left live (not orphaned). + expect(newTransport.close).not.toHaveBeenCalled(); + }); + + it('does not leave the connection flagged terminated after closing an exhausted socket', () => { + const connection = new AggregatedOrderBookConnection({ + isTestnet: (): boolean => false, + }); + const onStatusChange = jest.fn(); + connection.subscribe({ + symbol: 'BTC', + nSigFigs: 2, + callback: jest.fn(), + onStatusChange, + }); + + // Reconnection is exhausted on the dedicated socket. + mockState.transports[0].socket.terminate(); + expect(onStatusChange).toHaveBeenLastCalledWith('error'); + + // Resubscribe rebuilds the transport. Closing the exhausted socket + // dispatches a final `close`; that must not re-flag the connection as + // terminated after it was cleared. + connection.subscribe({ symbol: 'BTC', nSigFigs: 2, callback: jest.fn() }); + expect(mockState.transports).toHaveLength(2); + + // A further subscribe on the same network must REUSE the healthy new + // transport rather than tearing it down as if it were dead. + connection.subscribe({ symbol: 'ETH', nSigFigs: 2, callback: jest.fn() }); + expect(mockState.transports).toHaveLength(2); + }); + + it('reports error when the subscription request rejects', async () => { + mockState.rejectSubscribe = true; + const connection = new AggregatedOrderBookConnection({ + isTestnet: (): boolean => false, + }); + const onStatusChange = jest.fn(); + connection.subscribe({ + symbol: 'BTC', + nSigFigs: 2, + callback: jest.fn(), + onStatusChange, + }); + + await flush(); + expect(onStatusChange).toHaveBeenLastCalledWith('error'); + }); + + it('releases the refcount and closes the transport when the subscription request rejects', async () => { + mockState.rejectSubscribe = true; + const connection = new AggregatedOrderBookConnection({ + isTestnet: (): boolean => false, + }); + const unsub = connection.subscribe({ + symbol: 'BTC', + nSigFigs: 2, + callback: jest.fn(), + }); + + await flush(); + + // The failed subscribe must not leave the dedicated socket open. + expect(mockState.transports[0].close).toHaveBeenCalledTimes(1); + + // A subsequent unsubscribe is a no-op and must not double-close. + unsub(); + expect(mockState.transports[0].close).toHaveBeenCalledTimes(1); + }); + + it('stops reporting status after unsubscribe', () => { + const connection = new AggregatedOrderBookConnection({ + isTestnet: (): boolean => false, + }); + const onStatusChange = jest.fn(); + const unsub = connection.subscribe({ + symbol: 'BTC', + nSigFigs: 2, + callback: jest.fn(), + onStatusChange, + }); + const { socket } = mockState.transports[0]; + unsub(); + onStatusChange.mockClear(); + + socket.terminate(); + expect(onStatusChange).not.toHaveBeenCalled(); + }); + + it('builds a fresh transport when resubscribing after a terminated socket', () => { + const connection = new AggregatedOrderBookConnection({ + isTestnet: (): boolean => false, + }); + const unsub = connection.subscribe({ + symbol: 'BTC', + nSigFigs: 2, + callback: jest.fn(), + }); + mockState.transports[0].socket.terminate(); + + // Reconnect flow: tear the dead subscription down, then resubscribe. + unsub(); + connection.subscribe({ symbol: 'BTC', nSigFigs: 2, callback: jest.fn() }); + + expect(mockState.transports).toHaveLength(2); + expect(mockState.transports[0].close).toHaveBeenCalledTimes(1); + }); + }); +}); diff --git a/packages/perps-controller/tests/src/services/DataLakeService.test.ts b/packages/perps-controller/tests/src/services/DataLakeService.test.ts new file mode 100644 index 00000000000..192d1a3bd90 --- /dev/null +++ b/packages/perps-controller/tests/src/services/DataLakeService.test.ts @@ -0,0 +1,503 @@ +import { DataLakeService } from '../../../src/services/DataLakeService.js'; +import type { ServiceContext } from '../../../src/services/ServiceContext.js'; +import type { PerpsPlatformDependencies } from '../../../src/types/index.js'; +/* eslint-disable */ +import { + createMockServiceContext, + createMockEvmAccount, + createMockInfrastructure, + createMockMessenger, +} from '../../helpers/serviceMocks.js'; + +jest.mock('uuid', () => ({ v4: () => 'mock-trace-id' })); + +global.fetch = jest.fn(); +global.setTimeout = jest.fn((fn: () => void) => { + fn(); + return 0 as unknown as NodeJS.Timeout; +}) as unknown as typeof setTimeout; + +describe('DataLakeService', () => { + let mockContext: ServiceContext; + let mockDeps: jest.Mocked; + let mockMessenger: ReturnType; + let dataLakeService: DataLakeService; + const mockEvmAccount = createMockEvmAccount(); + const mockToken = 'mock-bearer-token'; + + /** + * Sets up the default messenger mock that returns a valid account and token. + * Called in beforeEach and after any mid-test jest.clearAllMocks(). + */ + function setupDefaultMessenger() { + (mockMessenger.call as jest.Mock).mockImplementation((action: string) => { + if ( + action === 'AccountTreeController:getAccountsFromSelectedAccountGroup' + ) { + return [mockEvmAccount]; + } + if (action === 'AuthenticationController:getBearerToken') { + return Promise.resolve(mockToken); + } + return undefined; + }); + } + + beforeEach(() => { + jest.clearAllMocks(); + + mockDeps = createMockInfrastructure(); + mockMessenger = createMockMessenger(); + dataLakeService = new DataLakeService(mockDeps, mockMessenger); + + mockContext = createMockServiceContext({ + errorContext: { controller: 'DataLakeService', method: 'test' }, + tracingContext: { + provider: 'hyperliquid', + isTestnet: false, + }, + }); + + setupDefaultMessenger(); + }); + + afterEach(() => { + jest.resetAllMocks(); + }); + + describe('reportOrder', () => { + it('skips reporting for testnet', async () => { + const result = await dataLakeService.reportOrder({ + action: 'open', + symbol: 'BTC', + isTestnet: true, + context: mockContext, + }); + + expect(result).toEqual({ success: true, error: 'Skipped for testnet' }); + expect(mockDeps.debugLogger.log).toHaveBeenCalledWith( + 'DataLake API: Skipping for testnet', + expect.objectContaining({ network: 'testnet' }), + ); + expect(fetch).not.toHaveBeenCalled(); + }); + + it('reports order successfully on first attempt', async () => { + (fetch as jest.Mock).mockResolvedValue({ + ok: true, + status: 201, + text: jest.fn().mockResolvedValue(''), + }); + + const result = await dataLakeService.reportOrder({ + action: 'open', + symbol: 'BTC', + slPrice: 45000, + tpPrice: 55000, + isTestnet: false, + context: mockContext, + }); + + expect(result).toEqual({ success: true }); + expect(mockMessenger.call).toHaveBeenCalledWith( + 'AuthenticationController:getBearerToken', + ); + expect(fetch).toHaveBeenCalledWith( + expect.any(String), + expect.objectContaining({ + method: 'POST', + headers: expect.objectContaining({ + 'Content-Type': 'application/json', + Authorization: `Bearer ${mockToken}`, + }), + body: JSON.stringify({ + user_id: mockEvmAccount.address, + symbol: 'BTC', + sl_price: 45000, + tp_price: 55000, + }), + }), + ); + expect(mockDeps.tracer.trace).toHaveBeenCalledWith( + expect.objectContaining({ + name: 'Perps Data Lake Report', + tags: expect.objectContaining({ action: 'open', symbol: 'BTC' }), + }), + ); + expect(mockDeps.tracer.endTrace).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ success: true, retries: 0 }), + }), + ); + }); + + it('includes performance measurement on success', async () => { + (fetch as jest.Mock).mockResolvedValue({ + ok: true, + status: 201, + text: jest.fn().mockResolvedValue(''), + }); + + await dataLakeService.reportOrder({ + action: 'close', + symbol: 'ETH', + isTestnet: false, + context: mockContext, + }); + + expect(mockDeps.tracer.setMeasurement).toHaveBeenCalledWith( + 'perps.api.data_lake_call', + expect.any(Number), + 'millisecond', + ); + }); + + it('returns error when account is missing', async () => { + (mockMessenger.call as jest.Mock).mockImplementation((action: string) => { + if ( + action === 'AccountTreeController:getAccountsFromSelectedAccountGroup' + ) { + return []; + } + if (action === 'AuthenticationController:getBearerToken') { + return Promise.resolve(mockToken); + } + return undefined; + }); + + const result = await dataLakeService.reportOrder({ + action: 'open', + symbol: 'BTC', + isTestnet: false, + context: mockContext, + }); + + expect(result).toEqual({ + success: false, + error: 'No account or token available', + }); + expect(fetch).not.toHaveBeenCalled(); + expect(mockDeps.debugLogger.log).toHaveBeenCalledWith( + 'DataLake API: Missing requirements', + expect.objectContaining({ hasAccount: false }), + ); + }); + + it('returns error when token is missing', async () => { + (mockMessenger.call as jest.Mock).mockImplementation((action: string) => { + if ( + action === 'AccountTreeController:getAccountsFromSelectedAccountGroup' + ) { + return [mockEvmAccount]; + } + if (action === 'AuthenticationController:getBearerToken') { + return Promise.resolve(null); + } + return undefined; + }); + + const result = await dataLakeService.reportOrder({ + action: 'open', + symbol: 'BTC', + isTestnet: false, + context: mockContext, + }); + + expect(result).toEqual({ + success: false, + error: 'No account or token available', + }); + expect(fetch).not.toHaveBeenCalled(); + }); + + it('retries on network error with exponential backoff', async () => { + (fetch as jest.Mock) + .mockRejectedValueOnce(new Error('Network error')) + .mockRejectedValueOnce(new Error('Network error')) + .mockResolvedValueOnce({ + ok: true, + status: 201, + text: jest.fn().mockResolvedValue(''), + }); + + const result = await dataLakeService.reportOrder({ + action: 'open', + symbol: 'BTC', + isTestnet: false, + context: mockContext, + }); + + expect(result).toEqual({ success: false, error: 'Network error' }); + expect(mockDeps.logger.error).toHaveBeenCalled(); + expect(mockDeps.debugLogger.log).toHaveBeenCalledWith( + 'DataLake API: Scheduling retry', + expect.objectContaining({ nextAttempt: 2 }), + ); + expect(setTimeout).toHaveBeenCalled(); + }); + + it('retries up to 3 times then gives up', async () => { + (fetch as jest.Mock).mockRejectedValue(new Error('Persistent error')); + + const result = await dataLakeService.reportOrder({ + action: 'open', + symbol: 'BTC', + isTestnet: false, + context: mockContext, + retryCount: 3, + }); + + expect(result).toEqual({ + success: false, + error: 'Persistent error', + }); + expect(mockDeps.logger.error).toHaveBeenCalledWith( + expect.any(Error), + expect.objectContaining({ + context: expect.objectContaining({ + data: expect.objectContaining({ + operation: 'finalFailure', + retryCount: 3, + }), + }), + }), + ); + expect(mockDeps.tracer.endTrace).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ + success: false, + totalRetries: 3, + }), + }), + ); + }); + + it('calculates exponential backoff delays correctly', async () => { + (fetch as jest.Mock).mockRejectedValue(new Error('Network error')); + + await dataLakeService.reportOrder({ + action: 'open', + symbol: 'BTC', + isTestnet: false, + context: mockContext, + retryCount: 0, + }); + expect(setTimeout).toHaveBeenCalledWith(expect.any(Function), 1000); + + jest.clearAllMocks(); + setupDefaultMessenger(); + (fetch as jest.Mock).mockRejectedValue(new Error('Network error')); + + await dataLakeService.reportOrder({ + action: 'open', + symbol: 'BTC', + isTestnet: false, + context: mockContext, + retryCount: 1, + }); + expect(setTimeout).toHaveBeenCalledWith(expect.any(Function), 2000); + + jest.clearAllMocks(); + setupDefaultMessenger(); + (fetch as jest.Mock).mockRejectedValue(new Error('Network error')); + + await dataLakeService.reportOrder({ + action: 'open', + symbol: 'BTC', + isTestnet: false, + context: mockContext, + retryCount: 2, + }); + expect(setTimeout).toHaveBeenCalledWith(expect.any(Function), 4000); + }); + + it('handles API error responses', async () => { + (fetch as jest.Mock).mockResolvedValue({ + ok: false, + status: 500, + text: jest.fn().mockResolvedValue('Internal Server Error'), + }); + + const result = await dataLakeService.reportOrder({ + action: 'open', + symbol: 'BTC', + isTestnet: false, + context: mockContext, + }); + + expect(result.success).toBe(false); + expect(result.error).toContain('DataLake API error: 500'); + expect(mockDeps.logger.error).toHaveBeenCalled(); + }); + + it('handles API 4xx error responses', async () => { + (fetch as jest.Mock).mockResolvedValue({ + ok: false, + status: 400, + text: jest.fn().mockResolvedValue('Bad Request'), + }); + + const result = await dataLakeService.reportOrder({ + action: 'open', + symbol: 'INVALID', + isTestnet: false, + context: mockContext, + }); + + expect(result.success).toBe(false); + expect(result.error).toContain('DataLake API error: 400'); + }); + + it('logs all retry attempts correctly', async () => { + (fetch as jest.Mock).mockRejectedValue(new Error('Network error')); + + await dataLakeService.reportOrder({ + action: 'open', + symbol: 'BTC', + isTestnet: false, + context: mockContext, + }); + + expect(mockDeps.debugLogger.log).toHaveBeenCalledWith( + 'DataLake API: Starting order report', + expect.objectContaining({ attempt: 1, maxAttempts: 4 }), + ); + }); + + it('uses custom trace ID when provided', async () => { + (fetch as jest.Mock).mockResolvedValue({ + ok: true, + status: 201, + text: jest.fn().mockResolvedValue(''), + }); + + await dataLakeService.reportOrder({ + action: 'open', + symbol: 'BTC', + isTestnet: false, + context: mockContext, + _traceId: 'custom-trace-id', + }); + + expect(mockDeps.tracer.endTrace).toHaveBeenCalledWith( + expect.objectContaining({ id: 'custom-trace-id' }), + ); + }); + + it('reports close action with TP/SL prices', async () => { + (fetch as jest.Mock).mockResolvedValue({ + ok: true, + status: 201, + text: jest.fn().mockResolvedValue(''), + }); + + await dataLakeService.reportOrder({ + action: 'close', + symbol: 'BTC', + slPrice: 45000, + tpPrice: 55000, + isTestnet: false, + context: mockContext, + }); + + expect(fetch).toHaveBeenCalledWith( + expect.any(String), + expect.objectContaining({ + body: JSON.stringify({ + user_id: mockEvmAccount.address, + symbol: 'BTC', + sl_price: 45000, + tp_price: 55000, + }), + }), + ); + }); + + it('reports order without TP/SL prices when not provided', async () => { + (fetch as jest.Mock).mockResolvedValue({ + ok: true, + status: 201, + text: jest.fn().mockResolvedValue(''), + }); + + await dataLakeService.reportOrder({ + action: 'open', + symbol: 'ETH', + isTestnet: false, + context: mockContext, + }); + + expect(fetch).toHaveBeenCalledWith( + expect.any(String), + expect.objectContaining({ + body: JSON.stringify({ + user_id: mockEvmAccount.address, + symbol: 'ETH', + sl_price: undefined, + tp_price: undefined, + }), + }), + ); + }); + + it('handles response with body text', async () => { + (fetch as jest.Mock).mockResolvedValue({ + ok: true, + status: 201, + text: jest.fn().mockResolvedValue('{"orderId": "123"}'), + }); + + const result = await dataLakeService.reportOrder({ + action: 'open', + symbol: 'BTC', + isTestnet: false, + context: mockContext, + }); + + expect(result).toEqual({ success: true }); + expect(mockDeps.debugLogger.log).toHaveBeenCalledWith( + 'DataLake API: Order reported successfully', + expect.objectContaining({ responseBody: '{"orderId": "123"}' }), + ); + }); + + it('handles empty response body', async () => { + (fetch as jest.Mock).mockResolvedValue({ + ok: true, + status: 201, + text: jest.fn().mockResolvedValue(''), + }); + + const result = await dataLakeService.reportOrder({ + action: 'open', + symbol: 'BTC', + isTestnet: false, + context: mockContext, + }); + + expect(result).toEqual({ success: true }); + expect(mockDeps.debugLogger.log).toHaveBeenCalledWith( + 'DataLake API: Order reported successfully', + expect.objectContaining({ responseBody: 'empty' }), + ); + }); + + it('only starts trace on first attempt', async () => { + (fetch as jest.Mock).mockResolvedValue({ + ok: true, + status: 201, + text: jest.fn().mockResolvedValue(''), + }); + + await dataLakeService.reportOrder({ + action: 'open', + symbol: 'BTC', + isTestnet: false, + context: mockContext, + retryCount: 2, + }); + + expect(mockDeps.tracer.trace).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/packages/perps-controller/tests/src/services/DepositService.test.ts b/packages/perps-controller/tests/src/services/DepositService.test.ts new file mode 100644 index 00000000000..03d033dcd43 --- /dev/null +++ b/packages/perps-controller/tests/src/services/DepositService.test.ts @@ -0,0 +1,350 @@ +/* eslint-disable */ +import { toHex } from '@metamask/controller-utils'; +import { parseCaipAssetId } from '@metamask/utils'; + +import { DepositService } from '../../../src/services/DepositService.js'; +import type { + PerpsProvider, + PerpsPlatformDependencies, +} from '../../../src/types/index.js'; +import { generateDepositId } from '../../../src/utils/idUtils.js'; +import { generateERC20TransferData } from '../../../src/utils/transferData.js'; +import { createMockHyperLiquidProvider } from '../../helpers/providerMocks.js'; +import { + createMockEvmAccount, + createMockInfrastructure, + createMockMessenger, +} from '../../helpers/serviceMocks.js'; + +jest.mock('../../../src/utils/idUtils'); +jest.mock('@metamask/utils'); +// Mock generateERC20TransferData from portable transferData util +jest.mock('../../../src/utils/transferData'); +jest.mock('@metamask/controller-utils', () => { + const actual = jest.requireActual('@metamask/controller-utils'); + return { + ...actual, + toHex: jest.fn((value: string | number) => { + if (typeof value === 'number') { + return `0x${value.toString(16)}`; + } + if (typeof value === 'string' && !value.startsWith('0x')) { + return `0x${parseInt(value, 10).toString(16)}`; + } + return value; + }), + }; +}); + +describe('DepositService', () => { + let mockProvider: jest.Mocked; + let mockDeps: jest.Mocked; + let mockMessenger: ReturnType; + let service: DepositService; + const mockEvmAccount = createMockEvmAccount(); + const mockDepositId = 'deposit-123'; + const mockBridgeAddress = '0xBridgeContract'; + const mockTokenAddress = '0xTokenAddress'; + const mockAssetId = 'eip155:42161/erc20:0xTokenAddress/default'; + + beforeEach(() => { + mockProvider = + createMockHyperLiquidProvider() as unknown as jest.Mocked; + + mockDeps = createMockInfrastructure(); + mockMessenger = createMockMessenger(); + service = new DepositService(mockDeps, mockMessenger); + + mockProvider.getDepositRoutes.mockReturnValue([ + { + assetId: mockAssetId, + contractAddress: mockBridgeAddress, + chainId: 'eip155:42161', + }, + ]); + + // Setup mock EVM account via messenger + (mockMessenger.call as jest.Mock).mockImplementation((action: string) => { + if ( + action === 'AccountTreeController:getAccountsFromSelectedAccountGroup' + ) { + return [mockEvmAccount]; + } + return undefined; + }); + (generateDepositId as jest.Mock).mockReturnValue(mockDepositId); + // Mock generateERC20TransferData to return a valid ERC-20 transfer data + (generateERC20TransferData as jest.Mock).mockReturnValue( + '0xa9059cbb000000000000000000000000', + ); + (parseCaipAssetId as jest.Mock).mockReturnValue({ + chainId: 'eip155:42161', + assetReference: mockTokenAddress, + }); + (toHex as jest.Mock).mockImplementation((value: string | number) => { + if (typeof value === 'number') { + return `0x${value.toString(16)}`; + } + if (typeof value === 'string' && !value.startsWith('0x')) { + return `0x${parseInt(value, 10).toString(16)}`; + } + return value; + }); + + jest.clearAllMocks(); + }); + + afterEach(() => { + jest.resetAllMocks(); + }); + + describe('prepareTransaction', () => { + it('successfully prepares deposit transaction with all fields', async () => { + const result = await service.prepareTransaction({ + provider: mockProvider, + }); + + expect(result).toEqual({ + transaction: { + from: mockEvmAccount.address, + to: mockTokenAddress, + value: '0x0', + data: expect.stringMatching(/^0xa9059cbb/), // ERC-20 transfer function signature + gas: '0x186a0', + }, + assetChainId: '0xa4b1', + currentDepositId: mockDepositId, + }); + }); + + it('generates unique deposit ID for tracking', async () => { + await service.prepareTransaction({ + provider: mockProvider, + }); + + expect(generateDepositId).toHaveBeenCalledTimes(1); + }); + + it('retrieves deposit routes from provider', async () => { + await service.prepareTransaction({ + provider: mockProvider, + }); + + expect(mockProvider.getDepositRoutes).toHaveBeenCalledWith({ + isTestnet: false, + }); + }); + + it('uses first deposit route from provider', async () => { + mockProvider.getDepositRoutes.mockReturnValue([ + { + assetId: mockAssetId, + contractAddress: mockBridgeAddress, + chainId: 'eip155:42161', + }, + { + assetId: 'eip155:1/erc20:0xOtherToken/default', + contractAddress: '0xOtherBridge', + chainId: 'eip155:1', + }, + ]); + + const result = await service.prepareTransaction({ + provider: mockProvider, + }); + + // Verify transfer data is generated with ERC-20 transfer function signature + expect(result.transaction.data).toMatch(/^0xa9059cbb/); + }); + + it('generates transfer data for ERC-20 token transfer', async () => { + const result = await service.prepareTransaction({ + provider: mockProvider, + }); + + // Verify ERC-20 transfer function signature (0xa9059cbb) is at the start + expect(result.transaction.data).toMatch(/^0xa9059cbb/); + }); + + it('retrieves EVM account from messenger via accountTree action', async () => { + await service.prepareTransaction({ + provider: mockProvider, + }); + + expect(mockMessenger.call).toHaveBeenCalledWith( + 'AccountTreeController:getAccountsFromSelectedAccountGroup', + ); + }); + + it('throws error when no EVM account is found', async () => { + (mockMessenger.call as jest.Mock).mockImplementation((action: string) => { + if ( + action === 'AccountTreeController:getAccountsFromSelectedAccountGroup' + ) { + return []; + } + return undefined; + }); + + await expect( + service.prepareTransaction({ + provider: mockProvider, + }), + ).rejects.toThrow( + 'No EVM-compatible account found in selected account group', + ); + + expect(parseCaipAssetId).not.toHaveBeenCalled(); + }); + + it('parses CAIP asset ID to extract chain and token', async () => { + await service.prepareTransaction({ + provider: mockProvider, + }); + + expect(parseCaipAssetId).toHaveBeenCalledWith(mockAssetId); + }); + + it('converts chain ID to hex format', async () => { + await service.prepareTransaction({ + provider: mockProvider, + }); + + expect(toHex).toHaveBeenCalledWith('42161'); + }); + + it('sets fixed gas limit for deposit transaction', async () => { + const result = await service.prepareTransaction({ + provider: mockProvider, + }); + + expect(result.transaction.gas).toBe('0x186a0'); + }); + + it('sets transaction value to 0x0', async () => { + const result = await service.prepareTransaction({ + provider: mockProvider, + }); + + expect(result.transaction.value).toBe('0x0'); + }); + + it('uses token address as transaction recipient', async () => { + const result = await service.prepareTransaction({ + provider: mockProvider, + }); + + expect(result.transaction.to).toBe(mockTokenAddress); + }); + + it('uses account address as transaction sender', async () => { + const result = await service.prepareTransaction({ + provider: mockProvider, + }); + + expect(result.transaction.from).toBe(mockEvmAccount.address); + }); + + it('includes generated transfer data in transaction', async () => { + const result = await service.prepareTransaction({ + provider: mockProvider, + }); + + // Verify transfer data starts with ERC-20 transfer function signature + expect(result.transaction.data).toMatch(/^0xa9059cbb/); + }); + + it('returns asset chain ID in hex format', async () => { + const result = await service.prepareTransaction({ + provider: mockProvider, + }); + + expect(result.assetChainId).toBe('0xa4b1'); + }); + + it('returns current deposit ID for tracking', async () => { + const result = await service.prepareTransaction({ + provider: mockProvider, + }); + + expect(result.currentDepositId).toBe(mockDepositId); + }); + + it('handles different chain IDs correctly', async () => { + (parseCaipAssetId as jest.Mock).mockReturnValue({ + chainId: 'eip155:1', + assetReference: mockTokenAddress, + }); + + await service.prepareTransaction({ + provider: mockProvider, + }); + + expect(toHex).toHaveBeenCalledWith('1'); + }); + + it('handles different token addresses correctly', async () => { + const differentTokenAddress = '0xDifferentToken'; + (parseCaipAssetId as jest.Mock).mockReturnValue({ + chainId: 'eip155:42161', + assetReference: differentTokenAddress, + }); + + const result = await service.prepareTransaction({ + provider: mockProvider, + }); + + expect(result.transaction.to).toBe(differentTokenAddress); + }); + + it('prepares transaction for different bridge contracts', async () => { + const differentBridgeAddress = '0xDifferentBridge'; + mockProvider.getDepositRoutes.mockReturnValue([ + { + assetId: mockAssetId, + contractAddress: differentBridgeAddress, + chainId: 'eip155:42161', + }, + ]); + + const result = await service.prepareTransaction({ + provider: mockProvider, + }); + + // Verify transfer data is generated with ERC-20 transfer function signature + expect(result.transaction.data).toMatch(/^0xa9059cbb/); + }); + + it('logs debug messages during transaction preparation', async () => { + await service.prepareTransaction({ + provider: mockProvider, + }); + + expect(mockDeps.debugLogger.log).toHaveBeenCalledWith( + 'DepositService: Preparing deposit transaction', + ); + expect(mockDeps.debugLogger.log).toHaveBeenCalledWith( + 'DepositService: Deposit transaction prepared', + expect.objectContaining({ + depositId: mockDepositId, + assetChainId: '0xa4b1', + }), + ); + }); + }); + + describe('instance isolation', () => { + it('each instance uses its own deps', async () => { + const mockDeps2 = createMockInfrastructure(); + const mockMessenger2 = createMockMessenger(); + const service2 = new DepositService(mockDeps2, mockMessenger2); + + await service.prepareTransaction({ provider: mockProvider }); + await service2.prepareTransaction({ provider: mockProvider }); + + // Each instance should use its own logger + expect(mockDeps.debugLogger.log).toHaveBeenCalledTimes(2); + expect(mockDeps2.debugLogger.log).toHaveBeenCalledTimes(2); + }); + }); +}); diff --git a/packages/perps-controller/tests/src/services/EligibilityService.test.ts b/packages/perps-controller/tests/src/services/EligibilityService.test.ts new file mode 100644 index 00000000000..f44dc088d52 --- /dev/null +++ b/packages/perps-controller/tests/src/services/EligibilityService.test.ts @@ -0,0 +1,103 @@ +import { EligibilityService } from '../../../src/services/EligibilityService.js'; +import type { PerpsPlatformDependencies } from '../../../src/types/index.js'; +import { createMockInfrastructure } from '../../helpers/serviceMocks.js'; + +describe('EligibilityService', () => { + let mockDeps: jest.Mocked; + let service: EligibilityService; + + beforeEach(() => { + jest.clearAllMocks(); + mockDeps = createMockInfrastructure(); + service = new EligibilityService(mockDeps); + }); + + afterEach(() => { + jest.resetAllMocks(); + }); + + describe('checkEligibility', () => { + it('returns true when user is not in blocked regions', async () => { + const result = await service.checkEligibility({ + blockedRegions: ['US', 'CN'], + geoLocation: 'FR', + }); + + expect(result).toBe(true); + }); + + it('returns false when user is in blocked region', async () => { + const result = await service.checkEligibility({ + blockedRegions: ['US', 'CN'], + geoLocation: 'US', + }); + + expect(result).toBe(false); + }); + + it('returns false when user is in any blocked region from list', async () => { + const result = await service.checkEligibility({ + blockedRegions: ['US', 'CN', 'KP', 'IR'], + geoLocation: 'CN', + }); + + expect(result).toBe(false); + }); + + it('returns true when blocked regions list is empty', async () => { + const result = await service.checkEligibility({ + blockedRegions: [], + geoLocation: 'US', + }); + + expect(result).toBe(true); + }); + + it('returns true when location is UNKNOWN (defaults to eligible)', async () => { + const result = await service.checkEligibility({ + blockedRegions: ['US', 'CN'], + geoLocation: 'UNKNOWN', + }); + + expect(result).toBe(true); + }); + + it('handles partial region codes (e.g., US-NY)', async () => { + const result = await service.checkEligibility({ + blockedRegions: ['US'], + geoLocation: 'US-NY', + }); + + expect(result).toBe(false); + }); + + it('performs case-insensitive region matching', async () => { + const result = await service.checkEligibility({ + blockedRegions: ['US'], + geoLocation: 'us', + }); + + expect(result).toBe(false); + }); + + it('returns true on error (fail-safe)', async () => { + const brokenDeps = { + ...mockDeps, + debugLogger: { + log: () => { + throw new Error('Logging failure'); + }, + }, + } as unknown as jest.Mocked; + + const brokenService = new EligibilityService(brokenDeps); + + const result = await brokenService.checkEligibility({ + blockedRegions: ['US', 'CN'], + geoLocation: 'US', + }); + + expect(result).toBe(true); + }); + }); +}); diff --git a/packages/perps-controller/tests/src/services/FeatureFlagConfigurationService.test.ts b/packages/perps-controller/tests/src/services/FeatureFlagConfigurationService.test.ts new file mode 100644 index 00000000000..ecb50f6e7f8 --- /dev/null +++ b/packages/perps-controller/tests/src/services/FeatureFlagConfigurationService.test.ts @@ -0,0 +1,698 @@ +/* eslint-disable */ +import type { RemoteFeatureFlagControllerState } from '@metamask/remote-feature-flag-controller'; + +import { FeatureFlagConfigurationService } from '../../../src/services/FeatureFlagConfigurationService.js'; +import type { ServiceContext } from '../../../src/services/ServiceContext.js'; +import type { PerpsPlatformDependencies } from '../../../src/types/index.js'; +import { validateMarketPattern } from '../../../src/utils/marketUtils.js'; +import { + parseCommaSeparatedString, + stripQuotes, +} from '../../../src/utils/stringParseUtils.js'; +import { + createMockServiceContext, + createMockInfrastructure, +} from '../../helpers/serviceMocks.js'; + +jest.mock('../../../src/utils/stringParseUtils'); +jest.mock('../../../src/utils/marketUtils', () => ({ + ...jest.requireActual('../../../src/utils/marketUtils'), + validateMarketPattern: jest.fn(), +})); + +describe('FeatureFlagConfigurationService', () => { + let mockContext: ServiceContext; + let mockDeps: jest.Mocked; + let featureFlagConfigurationService: FeatureFlagConfigurationService; + let mockRemoteFeatureFlagState: RemoteFeatureFlagControllerState; + let mockCurrentHip3Config: { + enabled: boolean; + allowlistMarkets: string[]; + blocklistMarkets: string[]; + source: 'remote' | 'fallback'; + }; + let mockCurrentBlockedRegionList: { + list: string[]; + source: 'remote' | 'fallback'; + }; + + beforeEach(() => { + mockDeps = createMockInfrastructure(); + featureFlagConfigurationService = new FeatureFlagConfigurationService( + mockDeps, + ); + + mockCurrentHip3Config = { + enabled: false, + allowlistMarkets: [], + blocklistMarkets: [], + source: 'fallback', + }; + + mockCurrentBlockedRegionList = { + list: [], + source: 'fallback', + }; + + mockContext = createMockServiceContext({ + errorContext: { + controller: 'FeatureFlagConfigurationService', + method: 'test', + }, + getHip3Config: jest.fn(() => mockCurrentHip3Config), + setHip3Config: jest.fn((config) => { + Object.assign(mockCurrentHip3Config, config); + }), + incrementHip3ConfigVersion: jest.fn(() => 1), + getBlockedRegionList: jest.fn(() => mockCurrentBlockedRegionList), + setBlockedRegionList: jest.fn((list, source) => { + mockCurrentBlockedRegionList = { list, source }; + }), + refreshEligibility: jest.fn().mockResolvedValue(undefined), + }); + + mockRemoteFeatureFlagState = { + remoteFeatureFlags: {}, + cacheTimestamp: Date.now(), + }; + + (parseCommaSeparatedString as jest.Mock).mockImplementation((str: string) => + str + .split(',') + .map((s) => s.trim()) + .filter((s) => s.length > 0), + ); + + // stripQuotes is called after parseCommaSeparatedString - mock it to pass through values + (stripQuotes as jest.Mock).mockImplementation((s: string) => s); + + // validateMarketPattern passes by default + (validateMarketPattern as jest.Mock).mockImplementation(() => true); + + jest.clearAllMocks(); + }); + + afterEach(() => { + jest.resetAllMocks(); + }); + + describe('refreshHip3Config', () => { + it('throws error when required callbacks are missing', () => { + const contextWithoutCallbacks = createMockServiceContext({ + getHip3Config: undefined, + }); + + expect(() => { + featureFlagConfigurationService.refreshHip3Config({ + remoteFeatureFlagControllerState: mockRemoteFeatureFlagState, + context: contextWithoutCallbacks, + }); + }).toThrow('Required HIP-3 callbacks not available in ServiceContext'); + }); + + it('updates config when equity flag changes', () => { + // isVersionGatedFeatureFlag is now a real function from types - provide valid flag data + (mockDeps.featureFlags.validateVersionGated as jest.Mock).mockReturnValue( + true, + ); + mockRemoteFeatureFlagState.remoteFeatureFlags = { + perpsHip3Enabled: { enabled: true, minimumVersion: '1.0.0' }, + }; + + featureFlagConfigurationService.refreshHip3Config({ + remoteFeatureFlagControllerState: mockRemoteFeatureFlagState, + context: mockContext, + }); + + expect(mockContext.setHip3Config).toHaveBeenCalledWith( + expect.objectContaining({ + enabled: true, + source: 'remote', + }), + ); + }); + + it('increments version when equity flag changes', () => { + // isVersionGatedFeatureFlag is now a real function from types - provide valid flag data + (mockDeps.featureFlags.validateVersionGated as jest.Mock).mockReturnValue( + true, + ); + mockRemoteFeatureFlagState.remoteFeatureFlags = { + perpsHip3Enabled: { enabled: true, minimumVersion: '1.0.0' }, + }; + + featureFlagConfigurationService.refreshHip3Config({ + remoteFeatureFlagControllerState: mockRemoteFeatureFlagState, + context: mockContext, + }); + + expect(mockContext.incrementHip3ConfigVersion).toHaveBeenCalledTimes(1); + }); + + it('parses allowlist markets from comma-separated string', () => { + // No equity flag in this test (isVersionGatedFeatureFlag returns false for non-flag objects) + mockRemoteFeatureFlagState.remoteFeatureFlags = { + perpsHip3AllowlistMarkets: 'BTC,ETH,SOL', + }; + + featureFlagConfigurationService.refreshHip3Config({ + remoteFeatureFlagControllerState: mockRemoteFeatureFlagState, + context: mockContext, + }); + + expect(parseCommaSeparatedString).toHaveBeenCalledWith('BTC,ETH,SOL'); + expect(mockContext.setHip3Config).toHaveBeenCalledWith( + expect.objectContaining({ + allowlistMarkets: ['BTC', 'ETH', 'SOL'], + }), + ); + }); + + it('parses allowlist markets from array', () => { + // No equity flag in this test (isVersionGatedFeatureFlag returns false for non-flag objects) + mockRemoteFeatureFlagState.remoteFeatureFlags = { + perpsHip3AllowlistMarkets: ['BTC', 'ETH', 'SOL'], + }; + + featureFlagConfigurationService.refreshHip3Config({ + remoteFeatureFlagControllerState: mockRemoteFeatureFlagState, + context: mockContext, + }); + + expect(mockContext.setHip3Config).toHaveBeenCalledWith( + expect.objectContaining({ + allowlistMarkets: ['BTC', 'ETH', 'SOL'], + }), + ); + }); + + it('trims and filters empty allowlist markets from array', () => { + // No equity flag in this test (isVersionGatedFeatureFlag returns false for non-flag objects) + mockRemoteFeatureFlagState.remoteFeatureFlags = { + perpsHip3AllowlistMarkets: ['BTC ', ' ETH', ' ', 'SOL'], + }; + + featureFlagConfigurationService.refreshHip3Config({ + remoteFeatureFlagControllerState: mockRemoteFeatureFlagState, + context: mockContext, + }); + + expect(mockContext.setHip3Config).toHaveBeenCalledWith( + expect.objectContaining({ + allowlistMarkets: ['BTC', 'ETH', 'SOL'], + }), + ); + }); + + it('strips quotes from array values', () => { + // No equity flag in this test (isVersionGatedFeatureFlag returns false for non-flag objects) + // Mock stripQuotes to actually strip quotes for this test + (stripQuotes as jest.Mock).mockImplementation((s: string) => + s.replace(/^["']|["']$/g, ''), + ); + mockRemoteFeatureFlagState.remoteFeatureFlags = { + perpsHip3AllowlistMarkets: ['"BTC"', '"ETH"', "'SOL'"], + }; + + featureFlagConfigurationService.refreshHip3Config({ + remoteFeatureFlagControllerState: mockRemoteFeatureFlagState, + context: mockContext, + }); + + expect(stripQuotes).toHaveBeenCalledWith('"BTC"'); + expect(stripQuotes).toHaveBeenCalledWith('"ETH"'); + expect(stripQuotes).toHaveBeenCalledWith("'SOL'"); + expect(mockContext.setHip3Config).toHaveBeenCalledWith( + expect.objectContaining({ + allowlistMarkets: ['BTC', 'ETH', 'SOL'], + }), + ); + }); + + it('skips invalid allowlist markets format', () => { + // No equity flag in this test (isVersionGatedFeatureFlag returns false for non-flag objects) + mockRemoteFeatureFlagState.remoteFeatureFlags = { + perpsHip3AllowlistMarkets: 123, + }; + + featureFlagConfigurationService.refreshHip3Config({ + remoteFeatureFlagControllerState: mockRemoteFeatureFlagState, + context: mockContext, + }); + + expect(mockContext.setHip3Config).not.toHaveBeenCalled(); + expect(mockDeps.debugLogger.log).toHaveBeenCalledWith( + expect.stringContaining('validation FAILED'), + expect.anything(), + ); + }); + + it('parses blocklist markets from comma-separated string', () => { + // No equity flag in this test (isVersionGatedFeatureFlag returns false for non-flag objects) + mockRemoteFeatureFlagState.remoteFeatureFlags = { + perpsHip3BlocklistMarkets: 'MEME,DOGE', + }; + + featureFlagConfigurationService.refreshHip3Config({ + remoteFeatureFlagControllerState: mockRemoteFeatureFlagState, + context: mockContext, + }); + + expect(parseCommaSeparatedString).toHaveBeenCalledWith('MEME,DOGE'); + expect(mockContext.setHip3Config).toHaveBeenCalledWith( + expect.objectContaining({ + blocklistMarkets: ['MEME', 'DOGE'], + }), + ); + }); + + it('parses blocklist markets from array', () => { + // No equity flag in this test (isVersionGatedFeatureFlag returns false for non-flag objects) + mockRemoteFeatureFlagState.remoteFeatureFlags = { + perpsHip3BlocklistMarkets: ['MEME', 'DOGE'], + }; + + featureFlagConfigurationService.refreshHip3Config({ + remoteFeatureFlagControllerState: mockRemoteFeatureFlagState, + context: mockContext, + }); + + expect(mockContext.setHip3Config).toHaveBeenCalledWith( + expect.objectContaining({ + blocklistMarkets: ['MEME', 'DOGE'], + }), + ); + }); + + it('detects no change when config is identical', () => { + mockCurrentHip3Config.enabled = true; + mockCurrentHip3Config.allowlistMarkets = ['BTC', 'ETH']; + mockCurrentHip3Config.blocklistMarkets = ['MEME']; + + // isVersionGatedFeatureFlag is now a real function from types - provide valid flag data + (mockDeps.featureFlags.validateVersionGated as jest.Mock).mockReturnValue( + true, + ); + mockRemoteFeatureFlagState.remoteFeatureFlags = { + perpsHip3Enabled: { enabled: true, minimumVersion: '1.0.0' }, + perpsHip3AllowlistMarkets: ['BTC', 'ETH'], + perpsHip3BlocklistMarkets: ['MEME'], + }; + + featureFlagConfigurationService.refreshHip3Config({ + remoteFeatureFlagControllerState: mockRemoteFeatureFlagState, + context: mockContext, + }); + + expect(mockContext.setHip3Config).not.toHaveBeenCalled(); + expect(mockContext.incrementHip3ConfigVersion).not.toHaveBeenCalled(); + }); + + it('detects change even when markets are in different order', () => { + mockCurrentHip3Config.allowlistMarkets = ['BTC', 'ETH']; + // No equity flag in this test (isVersionGatedFeatureFlag returns false for non-flag objects) + mockRemoteFeatureFlagState.remoteFeatureFlags = { + perpsHip3AllowlistMarkets: ['ETH', 'SOL'], + }; + + featureFlagConfigurationService.refreshHip3Config({ + remoteFeatureFlagControllerState: mockRemoteFeatureFlagState, + context: mockContext, + }); + + expect(mockContext.setHip3Config).toHaveBeenCalled(); + }); + + it('logs config change details', () => { + // isVersionGatedFeatureFlag is now a real function from types - provide valid flag data + (mockDeps.featureFlags.validateVersionGated as jest.Mock).mockReturnValue( + true, + ); + mockRemoteFeatureFlagState.remoteFeatureFlags = { + perpsHip3Enabled: { enabled: true, minimumVersion: '1.0.0' }, + }; + + featureFlagConfigurationService.refreshHip3Config({ + remoteFeatureFlagControllerState: mockRemoteFeatureFlagState, + context: mockContext, + }); + + expect(mockDeps.debugLogger.log).toHaveBeenCalledWith( + expect.stringContaining('HIP-3 config changed'), + expect.objectContaining({ + equityChanged: true, + oldEquity: false, + newEquity: true, + }), + ); + }); + + it('logs version increment', () => { + // isVersionGatedFeatureFlag is now a real function from types - provide valid flag data + (mockDeps.featureFlags.validateVersionGated as jest.Mock).mockReturnValue( + true, + ); + (mockContext.incrementHip3ConfigVersion as jest.Mock).mockReturnValue(42); + mockRemoteFeatureFlagState.remoteFeatureFlags = { + perpsHip3Enabled: { enabled: true, minimumVersion: '1.0.0' }, + }; + + featureFlagConfigurationService.refreshHip3Config({ + remoteFeatureFlagControllerState: mockRemoteFeatureFlagState, + context: mockContext, + }); + + expect(mockDeps.debugLogger.log).toHaveBeenCalledWith( + expect.stringContaining('Incremented hip3ConfigVersion'), + expect.objectContaining({ newVersion: 42 }), + ); + }); + + it('handles empty string for allowlist markets', () => { + // No equity flag in this test (isVersionGatedFeatureFlag returns false for non-flag objects) + (parseCommaSeparatedString as jest.Mock).mockReturnValue([]); + mockRemoteFeatureFlagState.remoteFeatureFlags = { + perpsHip3AllowlistMarkets: '', + }; + + featureFlagConfigurationService.refreshHip3Config({ + remoteFeatureFlagControllerState: mockRemoteFeatureFlagState, + context: mockContext, + }); + + expect(mockDeps.debugLogger.log).toHaveBeenCalledWith( + expect.stringContaining('allowlistMarkets string was empty'), + expect.anything(), + ); + }); + + it('handles empty string for blocklist markets', () => { + // No equity flag in this test (isVersionGatedFeatureFlag returns false for non-flag objects) + (parseCommaSeparatedString as jest.Mock).mockReturnValue([]); + mockRemoteFeatureFlagState.remoteFeatureFlags = { + perpsHip3BlocklistMarkets: '', + }; + + featureFlagConfigurationService.refreshHip3Config({ + remoteFeatureFlagControllerState: mockRemoteFeatureFlagState, + context: mockContext, + }); + + expect(mockDeps.debugLogger.log).toHaveBeenCalledWith( + expect.stringContaining('blocklistMarkets string was empty'), + expect.anything(), + ); + }); + + it('filters out invalid patterns from string allowlist', () => { + (parseCommaSeparatedString as jest.Mock).mockReturnValue([ + 'xyz:TSLA', + '"bad"pattern"', + 'valid:*', + ]); + (stripQuotes as jest.Mock).mockImplementation((s: string) => s); + (validateMarketPattern as jest.Mock).mockImplementation( + (pattern: string) => { + if (pattern === '"bad"pattern"') { + throw new Error('Market pattern contains invalid characters'); + } + return true; + }, + ); + + mockRemoteFeatureFlagState.remoteFeatureFlags = { + perpsHip3AllowlistMarkets: 'xyz:TSLA,"bad"pattern",valid:*', + }; + + featureFlagConfigurationService.refreshHip3Config({ + remoteFeatureFlagControllerState: mockRemoteFeatureFlagState, + context: mockContext, + }); + + expect(mockContext.setHip3Config).toHaveBeenCalledWith( + expect.objectContaining({ + allowlistMarkets: ['xyz:TSLA', 'valid:*'], + }), + ); + }); + + it('filters out invalid patterns from array blocklist', () => { + (stripQuotes as jest.Mock).mockImplementation((s: string) => s); + (validateMarketPattern as jest.Mock).mockImplementation( + (pattern: string) => { + if (pattern === '"invalid"') { + throw new Error('Market pattern contains invalid characters'); + } + return true; + }, + ); + + mockRemoteFeatureFlagState.remoteFeatureFlags = { + perpsHip3BlocklistMarkets: ['valid:BTC', '"invalid"', 'also:valid'], + }; + + featureFlagConfigurationService.refreshHip3Config({ + remoteFeatureFlagControllerState: mockRemoteFeatureFlagState, + context: mockContext, + }); + + expect(mockContext.setHip3Config).toHaveBeenCalledWith( + expect.objectContaining({ + blocklistMarkets: ['valid:BTC', 'also:valid'], + }), + ); + }); + + it('preserves current config when all array patterns are invalid', () => { + mockCurrentHip3Config.allowlistMarkets = ['existing:BTC']; + (stripQuotes as jest.Mock).mockImplementation((s: string) => s); + (validateMarketPattern as jest.Mock).mockImplementation(() => { + throw new Error('Market pattern contains invalid characters'); + }); + + mockRemoteFeatureFlagState.remoteFeatureFlags = { + perpsHip3AllowlistMarkets: ['"bad1"', '"bad2"'], + }; + + featureFlagConfigurationService.refreshHip3Config({ + remoteFeatureFlagControllerState: mockRemoteFeatureFlagState, + context: mockContext, + }); + + // Should NOT overwrite existing config with empty array + expect(mockContext.setHip3Config).not.toHaveBeenCalled(); + }); + it('logs warning for dropped invalid patterns', () => { + (parseCommaSeparatedString as jest.Mock).mockReturnValue([ + '"bad"pattern"', + ]); + (stripQuotes as jest.Mock).mockImplementation((s: string) => s); + (validateMarketPattern as jest.Mock).mockImplementation(() => { + throw new Error('Market pattern contains invalid characters'); + }); + + mockRemoteFeatureFlagState.remoteFeatureFlags = { + perpsHip3AllowlistMarkets: '"bad"pattern"', + }; + + featureFlagConfigurationService.refreshHip3Config({ + remoteFeatureFlagControllerState: mockRemoteFeatureFlagState, + context: mockContext, + }); + + expect(mockDeps.logger.error).toHaveBeenCalledWith( + expect.any(Error), + expect.objectContaining({ + context: expect.objectContaining({ + data: expect.objectContaining({ pattern: '"bad"pattern"' }), + }), + }), + ); + }); + }); + + describe('refreshEligibility', () => { + it('extracts blocked regions from remote feature flag', () => { + mockRemoteFeatureFlagState.remoteFeatureFlags = { + perpsPerpTradingGeoBlockedCountriesV2: { + blockedRegions: ['US', 'CA', 'UK'], + }, + }; + + featureFlagConfigurationService.refreshEligibility({ + remoteFeatureFlagControllerState: mockRemoteFeatureFlagState, + context: mockContext, + }); + + expect(mockContext.setBlockedRegionList).toHaveBeenCalledWith( + ['US', 'CA', 'UK'], + 'remote', + ); + }); + + it('calls refreshHip3Config', () => { + mockRemoteFeatureFlagState.remoteFeatureFlags = { + perpsPerpTradingGeoBlockedCountriesV2: { + blockedRegions: ['US'], + }, + }; + + const refreshHip3ConfigSpy = jest.spyOn( + featureFlagConfigurationService, + 'refreshHip3Config', + ); + + featureFlagConfigurationService.refreshEligibility({ + remoteFeatureFlagControllerState: mockRemoteFeatureFlagState, + context: mockContext, + }); + + expect(refreshHip3ConfigSpy).toHaveBeenCalledWith({ + remoteFeatureFlagControllerState: mockRemoteFeatureFlagState, + context: mockContext, + }); + + refreshHip3ConfigSpy.mockRestore(); + }); + + it('skips setting blocked regions when not an array', () => { + mockRemoteFeatureFlagState.remoteFeatureFlags = { + perpsPerpTradingGeoBlockedCountriesV2: { + blockedRegions: 'invalid', + }, + }; + + featureFlagConfigurationService.refreshEligibility({ + remoteFeatureFlagControllerState: mockRemoteFeatureFlagState, + context: mockContext, + }); + + expect(mockContext.setBlockedRegionList).not.toHaveBeenCalled(); + }); + + it('handles missing blocked regions gracefully', () => { + mockRemoteFeatureFlagState.remoteFeatureFlags = { + perpsPerpTradingGeoBlockedCountriesV2: {}, + }; + + expect(() => { + featureFlagConfigurationService.refreshEligibility({ + remoteFeatureFlagControllerState: mockRemoteFeatureFlagState, + context: mockContext, + }); + }).not.toThrow(); + }); + }); + + describe('setBlockedRegions', () => { + it('throws error when required callbacks are missing', () => { + const contextWithoutCallbacks = createMockServiceContext({ + getBlockedRegionList: undefined, + }); + + expect(() => { + featureFlagConfigurationService.setBlockedRegions({ + list: ['US'], + source: 'remote', + context: contextWithoutCallbacks, + }); + }).toThrow( + 'Required blocked region callbacks not available in ServiceContext', + ); + }); + + it('sets blocked region list', () => { + featureFlagConfigurationService.setBlockedRegions({ + list: ['US', 'CA', 'UK'], + source: 'remote', + context: mockContext, + }); + + expect(mockContext.setBlockedRegionList).toHaveBeenCalledWith( + ['US', 'CA', 'UK'], + 'remote', + ); + }); + + it('triggers eligibility refresh after setting list', () => { + featureFlagConfigurationService.setBlockedRegions({ + list: ['US'], + source: 'remote', + context: mockContext, + }); + + expect(mockContext.refreshEligibility).toHaveBeenCalledTimes(1); + }); + + it('implements sticky remote pattern - does not downgrade from remote to fallback', () => { + mockCurrentBlockedRegionList.source = 'remote'; + + featureFlagConfigurationService.setBlockedRegions({ + list: ['US'], + source: 'fallback', + context: mockContext, + }); + + expect(mockContext.setBlockedRegionList).not.toHaveBeenCalled(); + expect(mockContext.refreshEligibility).not.toHaveBeenCalled(); + }); + + it('allows upgrade from fallback to remote', () => { + mockCurrentBlockedRegionList.source = 'fallback'; + + featureFlagConfigurationService.setBlockedRegions({ + list: ['US', 'CA'], + source: 'remote', + context: mockContext, + }); + + expect(mockContext.setBlockedRegionList).toHaveBeenCalledWith( + ['US', 'CA'], + 'remote', + ); + }); + + it('handles eligibility refresh error gracefully', () => { + (mockContext.refreshEligibility as jest.Mock).mockRejectedValue( + new Error('Refresh failed'), + ); + + expect(() => { + featureFlagConfigurationService.setBlockedRegions({ + list: ['US'], + source: 'remote', + context: mockContext, + }); + }).not.toThrow(); + }); + + it('logs error when eligibility refresh fails', async () => { + (mockContext.refreshEligibility as jest.Mock).mockRejectedValue( + new Error('Refresh failed'), + ); + + featureFlagConfigurationService.setBlockedRegions({ + list: ['US'], + source: 'remote', + context: mockContext, + }); + + await new Promise((resolve) => setTimeout(resolve, 10)); + + expect(mockDeps.logger.error).toHaveBeenCalled(); + }); + + it('handles empty blocked region list', () => { + featureFlagConfigurationService.setBlockedRegions({ + list: [], + source: 'remote', + context: mockContext, + }); + + expect(mockContext.setBlockedRegionList).toHaveBeenCalledWith( + [], + 'remote', + ); + }); + }); +}); diff --git a/packages/perps-controller/tests/src/services/HyperLiquidClientService.test.ts b/packages/perps-controller/tests/src/services/HyperLiquidClientService.test.ts new file mode 100644 index 00000000000..8e1c760756c --- /dev/null +++ b/packages/perps-controller/tests/src/services/HyperLiquidClientService.test.ts @@ -0,0 +1,2071 @@ +/* eslint-disable */ + +/* eslint-disable @typescript-eslint/no-require-imports */ +/** + * Unit tests for HyperLiquidClientService + */ + +/* eslint-disable @typescript-eslint/no-explicit-any */ + +import { CandlePeriod } from '../../../src/constants/chartConfig.js'; +import { HyperLiquidClientService } from '../../../src/services/HyperLiquidClientService.js'; +import type { ValidCandleInterval } from '../../../src/services/HyperLiquidClientService.js'; +import { resetPerpsRestCacheForTests } from '../../../src/utils/coalescePerpsRestRequest.js'; +import { createMockInfrastructure } from '../../helpers/serviceMocks.js'; + +// Mock WebSocket for Jest environment (React Native provides this globally) +(global as any).WebSocket = jest.fn(); + +// Mock HyperLiquid SDK - using 'mock' prefix for Jest compatibility +const mockExchangeClient = { initialized: true }; +const mockInfoClientWs = { + initialized: true, + transport: 'websocket', + candleSnapshot: jest.fn(), + historicalOrders: jest.fn(), +}; +const mockInfoClientHttp = { + initialized: true, + transport: 'http', + candleSnapshot: jest.fn(), +}; +const mockWsTransportReady = jest.fn().mockResolvedValue(undefined); +const mockSubscriptionClient = { + initialized: true, + config_: { + transport: { + ready: mockWsTransportReady, + }, + }, +}; +const mockSocket = { + addEventListener: jest.fn(), + removeEventListener: jest.fn(), +}; +const mockWsTransport = { + url: 'ws://mock', + close: jest.fn().mockResolvedValue(undefined), + ready: mockWsTransportReady, + socket: mockSocket, +}; +const mockHttpTransport = { + url: 'http://mock', +}; + +// Counter for InfoClient mock - using 'mock' prefix so Jest allows it +let mockInfoClientCallCount = 0; +jest.mock('@nktkas/hyperliquid', () => ({ + ExchangeClient: jest.fn(() => mockExchangeClient), + InfoClient: jest.fn(() => { + mockInfoClientCallCount++; + // First call is WebSocket (default), second is HTTP (fallback) + return mockInfoClientCallCount % 2 === 1 + ? mockInfoClientWs + : mockInfoClientHttp; + }), + SubscriptionClient: jest.fn(() => mockSubscriptionClient), + WebSocketTransport: jest.fn(() => mockWsTransport), + HttpTransport: jest.fn(() => mockHttpTransport), +})); + +// Mock configuration +jest.mock('../../../src/constants/hyperLiquidConfig', () => ({ + HYPERLIQUID_TRANSPORT_CONFIG: { + timeout: 10_000, + keepAlive: { interval: 30_000 }, + reconnect: { + maxRetries: 5, + connectionTimeout: 10_000, + }, + }, +})); + +// Mock DevLogger +jest.mock( + '../../../../core/SDKConnect/utils/DevLogger', + () => ({ + DevLogger: { + log: jest.fn(), + }, + }), + { virtual: true }, +); + +describe('HyperLiquidClientService', () => { + let service: HyperLiquidClientService; + let mockWallet: any; + let mockDeps: ReturnType; + + // Use fake timers globally to ensure all intervals/timeouts can be cleared + beforeAll(() => { + jest.useFakeTimers(); + }); + + afterAll(() => { + // Final cleanup - ensure all mocks and timers are reset + jest.useRealTimers(); + jest.restoreAllMocks(); + }); + + beforeEach(() => { + jest.clearAllMocks(); + jest.clearAllTimers(); + resetPerpsRestCacheForTests(); + mockInfoClientCallCount = 0; // Reset InfoClient call counter + + const hyperLiquid = jest.requireMock('@nktkas/hyperliquid'); + hyperLiquid.ExchangeClient.mockImplementation(() => mockExchangeClient); + hyperLiquid.InfoClient.mockImplementation(() => { + mockInfoClientCallCount++; + return mockInfoClientCallCount % 2 === 1 + ? mockInfoClientWs + : mockInfoClientHttp; + }); + hyperLiquid.SubscriptionClient.mockImplementation( + () => mockSubscriptionClient, + ); + hyperLiquid.WebSocketTransport.mockImplementation(() => mockWsTransport); + hyperLiquid.HttpTransport.mockImplementation(() => mockHttpTransport); + + // Restore default mock for transport ready + mockWsTransportReady.mockResolvedValue(undefined); + // Restore default mock for transport close + mockWsTransport.close.mockResolvedValue(undefined); + // Reset socket event listener mock + mockSocket.addEventListener.mockClear(); + + mockWallet = { + request: jest.fn().mockResolvedValue('0x123'), + }; + + mockDeps = createMockInfrastructure(); + service = new HyperLiquidClientService(mockDeps); + }); + + afterEach(async () => { + // Clean up the service to stop health check monitoring and close connections + try { + await service.disconnect(); + } catch { + // Ignore disconnect errors in cleanup + } + // Clear all pending timers to prevent open handles + jest.clearAllTimers(); + }); + + describe('Constructor and Configuration', () => { + it('initializes with mainnet by default', () => { + expect(service.isTestnetMode()).toBe(false); + expect(service.getNetwork()).toBe('mainnet'); + }); + + it('initializes with testnet when specified', () => { + const testnetService = new HyperLiquidClientService(mockDeps, { + isTestnet: true, + }); + + expect(testnetService.isTestnetMode()).toBe(true); + expect(testnetService.getNetwork()).toBe('testnet'); + }); + + it('updates testnet mode', () => { + service.setTestnetMode(true); + + expect(service.isTestnetMode()).toBe(true); + expect(service.getNetwork()).toBe('testnet'); + }); + }); + + describe('Client Initialization', () => { + it('initializes clients successfully with dual transports', async () => { + await service.initialize(mockWallet); + + expect(service.isInitialized()).toBe(true); + + const { + ExchangeClient, + InfoClient, + SubscriptionClient, + WebSocketTransport, + HttpTransport, + } = require('@nktkas/hyperliquid'); + + // Verify HTTP transport uses isTestnet flag (SDK handles endpoint selection) + expect(HttpTransport).toHaveBeenCalledWith({ + isTestnet: false, + timeout: 10_000, + }); + + // Verify WebSocket transport uses isTestnet flag (SDK handles endpoint selection) + expect(WebSocketTransport).toHaveBeenCalledWith({ + isTestnet: false, + timeout: 10_000, + keepAlive: { interval: 30_000 }, + reconnect: expect.objectContaining({ + maxRetries: 5, + connectionTimeout: 10_000, + }), + }); + + // ExchangeClient uses HTTP transport + expect(ExchangeClient).toHaveBeenCalledWith({ + wallet: mockWallet, + transport: mockHttpTransport, + }); + + // InfoClient is created twice: once with WebSocket (default), once with HTTP (fallback) + expect(InfoClient).toHaveBeenCalledTimes(2); + expect(InfoClient).toHaveBeenNthCalledWith(1, { + transport: mockWsTransport, + }); + expect(InfoClient).toHaveBeenNthCalledWith(2, { + transport: mockHttpTransport, + }); + + // SubscriptionClient uses WebSocket transport + expect(SubscriptionClient).toHaveBeenCalledWith({ + transport: mockWsTransport, + }); + }); + + it('handles initialization errors', async () => { + const { ExchangeClient } = require('@nktkas/hyperliquid'); + ExchangeClient.mockImplementationOnce(() => { + throw new Error('Client initialization failed'); + }); + + await expect(service.initialize(mockWallet)).rejects.toThrow( + 'Client initialization failed', + ); + }); + + it('initializes with testnet configuration', async () => { + const testnetService = new HyperLiquidClientService(mockDeps, { + isTestnet: true, + }); + await testnetService.initialize(mockWallet); + + const { + ExchangeClient, + WebSocketTransport, + HttpTransport, + } = require('@nktkas/hyperliquid'); + + // Verify testnet flag is passed (SDK auto-selects testnet endpoints) + expect(HttpTransport).toHaveBeenCalledWith({ + isTestnet: true, + timeout: 10_000, + }); + + expect(WebSocketTransport).toHaveBeenCalledWith({ + isTestnet: true, + timeout: 10_000, + keepAlive: { interval: 30_000 }, + reconnect: expect.objectContaining({ + maxRetries: 5, + connectionTimeout: 10_000, + }), + }); + + // ExchangeClient uses HTTP transport + expect(ExchangeClient).toHaveBeenCalledWith({ + wallet: mockWallet, + transport: mockHttpTransport, + }); + }); + }); + + describe('Client Access', () => { + beforeEach(async () => { + await service.initialize(mockWallet); + }); + + it('provides access to exchange client', () => { + const exchangeClient = service.getExchangeClient(); + + expect(exchangeClient).toBe(mockExchangeClient); + }); + + it('provides access to info client (WebSocket by default)', () => { + const infoClient = service.getInfoClient(); + + expect(infoClient).toBe(mockInfoClientWs); + expect((infoClient as any).transport).toBe('websocket'); + }); + + it('provides access to HTTP info client when useHttp option is true', () => { + const infoClient = service.getInfoClient({ useHttp: true }); + + expect(infoClient).toBe(mockInfoClientHttp); + expect((infoClient as any).transport).toBe('http'); + }); + + it('returns WebSocket info client when useHttp option is false', () => { + const infoClient = service.getInfoClient({ useHttp: false }); + + expect(infoClient).toBe(mockInfoClientWs); + expect((infoClient as any).transport).toBe('websocket'); + }); + + it('returns WebSocket info client when options is empty object', () => { + const infoClient = service.getInfoClient({}); + + expect(infoClient).toBe(mockInfoClientWs); + expect((infoClient as any).transport).toBe('websocket'); + }); + + it('provides access to subscription client', () => { + const subscriptionClient = service.getSubscriptionClient(); + + expect(subscriptionClient).toBe(mockSubscriptionClient); + }); + + it('throws when accessing uninitialized exchange client', () => { + const uninitializedService = new HyperLiquidClientService(mockDeps); + + expect(() => uninitializedService.getExchangeClient()).toThrow( + 'CLIENT_NOT_INITIALIZED', + ); + }); + + it('throws when accessing uninitialized info client', () => { + const uninitializedService = new HyperLiquidClientService(mockDeps); + + expect(() => uninitializedService.getInfoClient()).toThrow( + 'CLIENT_NOT_INITIALIZED', + ); + }); + + it('returns undefined for uninitialized subscription client', () => { + const uninitializedService = new HyperLiquidClientService(mockDeps); + + expect(uninitializedService.getSubscriptionClient()).toBeUndefined(); + }); + }); + + describe('Initialization State Management', () => { + it('reports not initialized before setup', () => { + expect(service.isInitialized()).toBe(false); + }); + + it('reports initialized after setup', async () => { + await service.initialize(mockWallet); + + expect(service.isInitialized()).toBe(true); + }); + + it('ensures initialization succeeds when clients are ready', async () => { + await service.initialize(mockWallet); + + expect(() => service.ensureInitialized()).not.toThrow(); + }); + + it('throws when ensuring initialization on uninitialized service', () => { + expect(() => service.ensureInitialized()).toThrow( + 'CLIENT_NOT_INITIALIZED', + ); + }); + + it('ensures subscription client is available', async () => { + await service.initialize(mockWallet); + + await expect( + service.ensureSubscriptionClient(mockWallet), + ).resolves.not.toThrow(); + }); + + it('reinitializes when subscription client is missing', async () => { + // Start with partial initialization to simulate missing subscription client + const uninitializedService = new HyperLiquidClientService(mockDeps); + + await uninitializedService.ensureSubscriptionClient(mockWallet); + + expect(uninitializedService.isInitialized()).toBe(true); + }); + }); + + describe('Network Management', () => { + it('toggles between mainnet and testnet', async () => { + expect(service.getNetwork()).toBe('mainnet'); + + const newNetwork = await service.toggleTestnet(mockWallet); + + expect(newNetwork).toBe('testnet'); + expect(service.getNetwork()).toBe('testnet'); + expect(service.isTestnetMode()).toBe(true); + }); + + it('toggles back from testnet to mainnet', async () => { + service.setTestnetMode(true); + + const newNetwork = await service.toggleTestnet(mockWallet); + + expect(newNetwork).toBe('mainnet'); + expect(service.getNetwork()).toBe('mainnet'); + expect(service.isTestnetMode()).toBe(false); + }); + }); + + describe('Disconnection', () => { + beforeEach(async () => { + await service.initialize(mockWallet); + }); + + it('disconnects successfully and close only WebSocket transport', async () => { + await service.disconnect(); + + // Only WebSocket transport should be closed (HTTP is stateless) + expect(mockWsTransport.close).toHaveBeenCalled(); + expect(service.getSubscriptionClient()).toBeUndefined(); + }); + + it('handles disconnect errors gracefully', async () => { + mockWsTransport.close.mockImplementationOnce(() => { + throw new Error('Disconnect failed'); + }); + + // Should not throw, error is caught and logged + await expect(service.disconnect()).resolves.not.toThrow(); + + // Verify the error was attempted to be handled + expect(mockWsTransport.close).toHaveBeenCalled(); + }); + + it('clears all client references after disconnect', async () => { + await service.disconnect(); + + expect(service.isInitialized()).toBe(false); + expect(service.getSubscriptionClient()).toBeUndefined(); + expect(() => service.getExchangeClient()).toThrow(); + expect(() => service.getInfoClient()).toThrow(); + }); + + it('handles disconnect when subscription client is already undefined', async () => { + // Manually clear subscription client to simulate partial state + Object.defineProperty(service, 'subscriptionClient', { + value: undefined, + writable: true, + }); + + await expect(service.disconnect()).resolves.not.toThrow(); + }); + }); + + describe('Error Handling', () => { + it('handles transport creation errors', async () => { + const { WebSocketTransport } = require('@nktkas/hyperliquid'); + WebSocketTransport.mockImplementationOnce(() => { + throw new Error('Transport creation failed'); + }); + + await expect(service.initialize(mockWallet)).rejects.toThrow( + 'Transport creation failed', + ); + }); + + it('maintains network state through errors', async () => { + service.setTestnetMode(true); + + try { + const { ExchangeClient } = require('@nktkas/hyperliquid'); + ExchangeClient.mockImplementationOnce(() => { + throw new Error('Initialization failed'); + }); + await service.initialize(mockWallet); + } catch { + // Expected error + } + + expect(service.isTestnetMode()).toBe(true); + expect(service.getNetwork()).toBe('testnet'); + }); + + it('handles transport ready timeout', async () => { + // Make transport.ready() reject with abort error + mockWsTransportReady.mockRejectedValueOnce(new Error('Aborted')); + + await expect(service.initialize(mockWallet)).rejects.toThrow('Aborted'); + expect(service.isInitialized()).toBe(false); + }); + }); + + describe('Logging and Debugging', () => { + it('logs initialization events', async () => { + await service.initialize(mockWallet); + + expect(mockDeps.debugLogger.log).toHaveBeenCalledWith( + 'HyperLiquid SDK clients initialized', + expect.objectContaining({ + testnet: false, + timestamp: expect.any(String), + connectionState: 'connected', + }), + ); + }); + + it('logs disconnect events', async () => { + await service.initialize(mockWallet); + + await service.disconnect(); + + expect(mockDeps.debugLogger.log).toHaveBeenCalledWith( + 'HyperLiquid: Disconnecting SDK clients', + expect.objectContaining({ + isTestnet: false, + timestamp: expect.any(String), + }), + ); + }); + + it('logs transport creation events', async () => { + await service.initialize(mockWallet); + + expect(mockDeps.debugLogger.log).toHaveBeenCalledWith( + 'HyperLiquid: Creating transports', + expect.objectContaining({ + isTestnet: false, + timestamp: expect.any(String), + }), + ); + }); + }); + + describe('fetchHistoricalCandles', () => { + beforeEach(async () => { + await service.initialize(mockWallet); + }); + + it('fetches historical candles successfully', async () => { + // Arrange + const mockResponse = [ + { t: 1700000000000, o: 50000, h: 51000, l: 49000, c: 50500, v: 100 }, + { t: 1700003600000, o: 50500, h: 51500, l: 50000, c: 51000, v: 150 }, + ]; + + mockInfoClientHttp.candleSnapshot = jest + .fn() + .mockResolvedValue(mockResponse); + + // Act + const result = await service.fetchHistoricalCandles({ + symbol: 'BTC', + interval: '1h' as ValidCandleInterval, + limit: 100, + }); + + // Assert + expect(result).toEqual({ + symbol: 'BTC', + interval: '1h', + candles: [ + { + time: 1700000000000, + open: '50000', + high: '51000', + low: '49000', + close: '50500', + volume: '100', + }, + { + time: 1700003600000, + open: '50500', + high: '51500', + low: '50000', + close: '51000', + volume: '150', + }, + ], + }); + expect(mockInfoClientHttp.candleSnapshot).toHaveBeenCalledWith({ + coin: 'BTC', // SDK uses 'coin' terminology + interval: '1h', + startTime: expect.any(Number), + endTime: expect.any(Number), + }); + }); + + it('uses the default limit and forwards an explicit abort signal', async () => { + const abortController = new AbortController(); + mockInfoClientHttp.candleSnapshot = jest.fn().mockResolvedValue([]); + + await service.fetchHistoricalCandles({ + symbol: 'BTC', + interval: '1h' as ValidCandleInterval, + signal: abortController.signal, + }); + + // signal is not forwarded through the coalesce path (no endTime), + // so candleSnapshot is called with only the request object + expect(mockInfoClientHttp.candleSnapshot).toHaveBeenCalledWith({ + coin: 'BTC', + interval: '1h', + startTime: expect.any(Number), + endTime: expect.any(Number), + }); + + const request = mockInfoClientHttp.candleSnapshot.mock.calls[0][0]; + expect(request.endTime - request.startTime).toBe(100 * 60 * 60 * 1000); + }); + + it('throws AbortError and skips the REST call when signal is already aborted', async () => { + const abortController = new AbortController(); + abortController.abort(); + mockInfoClientHttp.candleSnapshot = jest.fn().mockResolvedValue([]); + + await expect( + service.fetchHistoricalCandles({ + symbol: 'BTC', + interval: '1h' as ValidCandleInterval, + signal: abortController.signal, + }), + ).rejects.toMatchObject({ name: 'AbortError' }); + + expect(mockInfoClientHttp.candleSnapshot).not.toHaveBeenCalled(); + }); + + it('forwards AbortSignal for non-coalesced paginated fetches', async () => { + const abortController = new AbortController(); + const endTime = 1700000000000; + mockInfoClientHttp.candleSnapshot = jest.fn().mockResolvedValue([]); + + await service.fetchHistoricalCandles({ + symbol: 'BTC', + interval: '1h' as ValidCandleInterval, + limit: 10, + endTime, + signal: abortController.signal, + }); + + expect(mockInfoClientHttp.candleSnapshot).toHaveBeenCalledWith( + { + coin: 'BTC', + interval: '1h', + startTime: endTime - 10 * 60 * 60 * 1000, + endTime, + }, + abortController.signal, + ); + }); + + it('coalesces concurrent identical fetches into one REST call', async () => { + mockInfoClientHttp.candleSnapshot = jest.fn().mockResolvedValue([]); + + await Promise.all([ + service.fetchHistoricalCandles({ + symbol: 'BTC', + interval: '1h' as ValidCandleInterval, + limit: 100, + }), + service.fetchHistoricalCandles({ + symbol: 'BTC', + interval: '1h' as ValidCandleInterval, + limit: 100, + }), + ]); + + expect(mockInfoClientHttp.candleSnapshot).toHaveBeenCalledTimes(1); + }); + + it('handles empty candles response', async () => { + // Arrange + const mockResponse: any[] = []; + + mockInfoClientHttp.candleSnapshot = jest + .fn() + .mockResolvedValue(mockResponse); + + // Act + const result = await service.fetchHistoricalCandles({ + symbol: 'BTC', + interval: '1h' as ValidCandleInterval, + limit: 100, + }); + + // Assert + expect(result).toEqual({ + symbol: 'BTC', + interval: '1h', + candles: [], + }); + }); + + it('handles API errors gracefully', async () => { + // Arrange + const errorMessage = 'API request failed'; + mockInfoClientHttp.candleSnapshot = jest + .fn() + .mockRejectedValue(new Error(errorMessage)); + + // Act & Assert + await expect( + service.fetchHistoricalCandles({ + symbol: 'BTC', + interval: '1h' as ValidCandleInterval, + limit: 100, + }), + ).rejects.toThrow(errorMessage); + }); + + it('calculates correct time range for different intervals', async () => { + // Arrange + const mockResponse = { + symbol: 'ETH', + interval: '5m', + candles: [], + }; + + mockInfoClientHttp.candleSnapshot = jest + .fn() + .mockResolvedValue(mockResponse); + + // Act + await service.fetchHistoricalCandles({ + symbol: 'ETH', + interval: '5m' as ValidCandleInterval, + limit: 50, + }); + + // Assert + expect(mockInfoClientHttp.candleSnapshot).toHaveBeenCalledWith({ + coin: 'ETH', // SDK uses 'coin' terminology + interval: '5m', + startTime: expect.any(Number), + endTime: expect.any(Number), + }); + + // Verify time range calculation + const callArgs = mockInfoClientHttp.candleSnapshot.mock.calls[0][0]; + const timeDiff = callArgs.endTime - callArgs.startTime; + const expectedTimeDiff = 50 * 5 * 60 * 1000; // 50 intervals * 5 minutes * 60 seconds * 1000ms + expect(timeDiff).toBe(expectedTimeDiff); + }); + + it('handles different interval formats', async () => { + // Arrange + const testCases = [ + { interval: CandlePeriod.ThreeMinutes, expected: 180000 }, // 3 minutes = 3 * 60 * 1000 + { interval: CandlePeriod.OneHour, expected: 3600000 }, + { interval: CandlePeriod.OneDay, expected: 86400000 }, + ]; + + for (const { interval, expected } of testCases) { + const mockResponse: any[] = []; + + // Reset mock before each iteration + jest.clearAllMocks(); + mockInfoClientHttp.candleSnapshot = jest + .fn() + .mockResolvedValue(mockResponse); + + // Act + await service.fetchHistoricalCandles({ + symbol: 'BTC', + interval, + limit: 10, + }); + + // Assert + const callArgs = mockInfoClientHttp.candleSnapshot.mock.calls[0][0]; + const timeDiff = callArgs.endTime - callArgs.startTime; + expect(timeDiff).toBe(10 * expected); + } + }); + + it('uses testnet endpoint when in testnet mode', async () => { + // Arrange + const testnetService = new HyperLiquidClientService(mockDeps, { + isTestnet: true, + }); + await testnetService.initialize(mockWallet); + + const mockResponse: any[] = []; + + mockInfoClientHttp.candleSnapshot = jest + .fn() + .mockResolvedValue(mockResponse); + + // Act + await testnetService.fetchHistoricalCandles({ + symbol: 'BTC', + interval: '1h' as ValidCandleInterval, + limit: 100, + }); + + // Assert + expect(mockInfoClientHttp.candleSnapshot).toHaveBeenCalled(); + // The testnet configuration is handled in the service initialization + }); + + it('throws error when service not initialized', async () => { + // Arrange + const uninitializedService = new HyperLiquidClientService(mockDeps); + + // Act & Assert + await expect( + uninitializedService.fetchHistoricalCandles({ + symbol: 'BTC', + interval: '1h' as ValidCandleInterval, + limit: 100, + }), + ).rejects.toThrow('CLIENT_NOT_INITIALIZED'); + }); + + it('uses HTTP transport instead of WebSocket for historical fetches (TAT-2954)', async () => { + // Arrange + const mockResponse = [ + { t: 1700000000000, o: 50000, h: 51000, l: 49000, c: 50500, v: 100 }, + ]; + mockInfoClientHttp.candleSnapshot = jest + .fn() + .mockResolvedValue(mockResponse); + + // Spy on getInfoClient to verify useHttp option + const getInfoClientSpy = jest.spyOn(service, 'getInfoClient'); + + // Act + await service.fetchHistoricalCandles({ + symbol: 'BTC', + interval: '1h' as ValidCandleInterval, + limit: 100, + }); + + // Assert — HTTP transport used, not WebSocket + expect(getInfoClientSpy).toHaveBeenCalledWith({ useHttp: true }); + expect(mockInfoClientHttp.candleSnapshot).toHaveBeenCalled(); + + getInfoClientSpy.mockRestore(); + }); + }); + + describe('fetchHistoricalOrders', () => { + const userAddress = '0x1234567890123456789012345678901234567890' as const; + + beforeEach(async () => { + await service.initialize(mockWallet); + jest.clearAllMocks(); + resetPerpsRestCacheForTests(); + }); + + it('fetches historical orders and coalesces concurrent calls', async () => { + const mockOrders = [{ order: { oid: 1 } }]; + mockInfoClientWs.historicalOrders.mockResolvedValue(mockOrders); + + const [a, b] = await Promise.all([ + service.fetchHistoricalOrders(userAddress), + service.fetchHistoricalOrders(userAddress), + ]); + + expect(a).toEqual(mockOrders); + expect(b).toEqual(mockOrders); + // Coalesce ensures only one underlying REST call + expect(mockInfoClientWs.historicalOrders).toHaveBeenCalledTimes(1); + }); + + it('bypasses coalesce cache when forceRefresh is true', async () => { + const mockOrders = [{ order: { oid: 1 } }]; + mockInfoClientWs.historicalOrders.mockResolvedValue(mockOrders); + + // First call populates cache + await service.fetchHistoricalOrders(userAddress); + // Second call with forceRefresh should bypass cache + await service.fetchHistoricalOrders(userAddress, { forceRefresh: true }); + + expect(mockInfoClientWs.historicalOrders).toHaveBeenCalledTimes(2); + }); + + it('returns empty array when SDK returns null', async () => { + mockInfoClientWs.historicalOrders.mockResolvedValue(null); + + const result = await service.fetchHistoricalOrders(userAddress); + + expect(result).toEqual([]); + }); + }); + + describe('subscribeToCandles', () => { + beforeEach(async () => { + await service.initialize(mockWallet); + jest.clearAllMocks(); + }); + + it('throws error when service not initialized', () => { + // Arrange + const uninitializedService = new HyperLiquidClientService(mockDeps); + + // Act & Assert + expect(() => + uninitializedService.subscribeToCandles({ + symbol: 'BTC', + interval: '1h' as ValidCandleInterval, + callback: jest.fn(), + }), + ).toThrow('CLIENT_NOT_INITIALIZED'); + }); + + it('throws error when subscription client unavailable', async () => { + // Arrange + const serviceWithNoSubClient = new HyperLiquidClientService(mockDeps); + await serviceWithNoSubClient.initialize(mockWallet); + // Mock public getter to return undefined + jest + .spyOn(serviceWithNoSubClient, 'getSubscriptionClient') + .mockReturnValue(undefined); + + // Act & Assert + expect(() => + serviceWithNoSubClient.subscribeToCandles({ + symbol: 'BTC', + interval: '1h' as ValidCandleInterval, + callback: jest.fn(), + }), + ).toThrow('SUBSCRIPTION_CLIENT_NOT_AVAILABLE'); + }); + + it('fetches historical data and setup WebSocket subscription', async () => { + // Arrange + const mockHistoricalData = [ + { + t: 1700000000000, + o: 50000, + h: 51000, + l: 49000, + c: 50500, + v: 100, + }, + { + t: 1700003600000, + o: 50500, + h: 52000, + l: 50000, + c: 51500, + v: 120, + }, + ]; + + mockInfoClientHttp.candleSnapshot = jest + .fn() + .mockResolvedValue(mockHistoricalData); + + const mockUnsubscribe = jest.fn(); + const mockCandleSubscription = Promise.resolve({ + unsubscribe: mockUnsubscribe, + }); + (mockSubscriptionClient as any).candle = jest + .fn() + .mockReturnValue(mockCandleSubscription); + + const callback = jest.fn(); + + // Act + const unsubscribe = service.subscribeToCandles({ + symbol: 'BTC', + interval: '1h' as ValidCandleInterval, + callback, + }); + + // Wait for async operations + await jest.advanceTimersByTimeAsync(100); + + // Assert - should have fetched historical data (SDK uses 'coin' terminology) + // Signal is intentionally dropped inside the coalesced fetch path. + expect(mockInfoClientHttp.candleSnapshot).toHaveBeenCalledWith( + expect.objectContaining({ + coin: 'BTC', + interval: '1h', + }), + ); + + // Assert - callback invoked with historical data + expect(callback).toHaveBeenCalledWith( + expect.objectContaining({ + symbol: 'BTC', + interval: '1h', + candles: expect.arrayContaining([ + expect.objectContaining({ + time: 1700000000000, + open: '50000', + high: '51000', + low: '49000', + close: '50500', + volume: '100', + }), + ]), + }), + ); + + // Assert - WebSocket subscription created + expect((mockSubscriptionClient as any).candle).toHaveBeenCalled(); + + // Assert - unsubscribe function returned + expect(typeof unsubscribe).toBe('function'); + }); + + it('transforms historical candle data correctly', async () => { + // Arrange + const mockHistoricalData = [ + { + t: 1700000000000, + o: 50000.5, + h: 51000.75, + l: 49000.25, + c: 50500.5, + v: 100.123, + }, + ]; + + mockInfoClientHttp.candleSnapshot = jest + .fn() + .mockResolvedValue(mockHistoricalData); + + (mockSubscriptionClient as any).candle = jest + .fn() + .mockResolvedValue({ unsubscribe: jest.fn() }); + + const callback = jest.fn(); + + // Act + service.subscribeToCandles({ + symbol: 'BTC', + interval: '1h' as ValidCandleInterval, + callback, + }); + + await jest.advanceTimersByTimeAsync(100); + + // Assert - numbers converted to strings + expect(callback).toHaveBeenCalledWith( + expect.objectContaining({ + candles: [ + { + time: 1700000000000, + open: '50000.5', + high: '51000.75', + low: '49000.25', + close: '50500.5', + volume: '100.123', + }, + ], + }), + ); + }); + + it('handles WebSocket updates for existing candle', async () => { + // Arrange + const mockHistoricalData = [ + { + t: 1700000000000, + o: 50000, + h: 51000, + l: 49000, + c: 50500, + v: 100, + }, + ]; + + mockInfoClientHttp.candleSnapshot = jest + .fn() + .mockResolvedValue(mockHistoricalData); + + let wsCallback: any; + (mockSubscriptionClient as any).candle = jest + .fn() + .mockImplementation((_params, callback) => { + wsCallback = callback; + return Promise.resolve({ unsubscribe: jest.fn() }); + }); + + const callback = jest.fn(); + + // Act + service.subscribeToCandles({ + symbol: 'BTC', + interval: '1h' as ValidCandleInterval, + callback, + }); + + await jest.advanceTimersByTimeAsync(100); + + // Clear previous callback invocations + callback.mockClear(); + + // Simulate WebSocket update for existing candle (same timestamp) + const updatedCandle = { + t: 1700000000000, // Same timestamp + o: 50000, + h: 51500, // Updated high + l: 49000, + c: 51000, // Updated close + v: 150, // Updated volume + }; + + wsCallback(updatedCandle); + + // Assert - callback invoked with updated candle + expect(callback).toHaveBeenCalledWith( + expect.objectContaining({ + candles: [ + { + time: 1700000000000, + open: '50000', + high: '51500', + low: '49000', + close: '51000', + volume: '150', + }, + ], + }), + ); + }); + + it('handles WebSocket updates for new candle', async () => { + // Arrange + const mockHistoricalData = [ + { + t: 1700000000000, + o: 50000, + h: 51000, + l: 49000, + c: 50500, + v: 100, + }, + ]; + + mockInfoClientHttp.candleSnapshot = jest + .fn() + .mockResolvedValue(mockHistoricalData); + + let wsCallback: any; + (mockSubscriptionClient as any).candle = jest + .fn() + .mockImplementation((_params, callback) => { + wsCallback = callback; + return Promise.resolve({ unsubscribe: jest.fn() }); + }); + + const callback = jest.fn(); + + // Act + service.subscribeToCandles({ + symbol: 'BTC', + interval: '1h' as ValidCandleInterval, + callback, + }); + + await jest.advanceTimersByTimeAsync(100); + + // Clear previous callback invocations + callback.mockClear(); + + // Simulate WebSocket update for new candle (different timestamp) + const newCandle = { + t: 1700003600000, // Different timestamp + o: 50500, + h: 52000, + l: 50000, + c: 51500, + v: 120, + }; + + wsCallback(newCandle); + + // Assert - callback invoked with appended candle + expect(callback).toHaveBeenCalledWith( + expect.objectContaining({ + candles: [ + { + time: 1700000000000, + open: '50000', + high: '51000', + low: '49000', + close: '50500', + volume: '100', + }, + { + time: 1700003600000, + open: '50500', + high: '52000', + low: '50000', + close: '51500', + volume: '120', + }, + ], + }), + ); + }); + + it('creates immutable candles array for React re-renders', async () => { + // Arrange + const mockHistoricalData = [ + { + t: 1700000000000, + o: 50000, + h: 51000, + l: 49000, + c: 50500, + v: 100, + }, + ]; + + mockInfoClientHttp.candleSnapshot = jest + .fn() + .mockResolvedValue(mockHistoricalData); + + let wsCallback: any; + (mockSubscriptionClient as any).candle = jest + .fn() + .mockImplementation((_params, callback) => { + wsCallback = callback; + return Promise.resolve({ unsubscribe: jest.fn() }); + }); + + const callback = jest.fn(); + + // Act + service.subscribeToCandles({ + symbol: 'BTC', + interval: '1h' as ValidCandleInterval, + callback, + }); + + await jest.advanceTimersByTimeAsync(100); + + const firstCallCandles = callback.mock.calls[0][0].candles; + + // Simulate WebSocket update + wsCallback({ + t: 1700000000000, + o: 50000, + h: 51500, + l: 49000, + c: 51000, + v: 150, + }); + + const secondCallCandles = callback.mock.calls[1][0].candles; + + // Assert - different array references (immutable) + expect(firstCallCandles).not.toBe(secondCallCandles); + }); + + it('handles empty historical data', async () => { + // Arrange + mockInfoClientHttp.candleSnapshot = jest.fn().mockResolvedValue([]); + + (mockSubscriptionClient as any).candle = jest + .fn() + .mockResolvedValue({ unsubscribe: jest.fn() }); + + const callback = jest.fn(); + + // Act + service.subscribeToCandles({ + symbol: 'BTC', + interval: '1h' as ValidCandleInterval, + callback, + }); + + await jest.advanceTimersByTimeAsync(100); + + // Assert - callback invoked with empty candles + expect(callback).toHaveBeenCalledWith({ + symbol: 'BTC', + interval: '1h', + candles: [], + }); + }); + + it('invokes unsubscribe when cleanup function called', async () => { + // Arrange + mockInfoClientHttp.candleSnapshot = jest.fn().mockResolvedValue([]); + + const mockWsUnsubscribe = jest.fn(); + (mockSubscriptionClient as any).candle = jest + .fn() + .mockResolvedValue({ unsubscribe: mockWsUnsubscribe }); + + const callback = jest.fn(); + + // Act + const unsubscribe = service.subscribeToCandles({ + symbol: 'BTC', + interval: '1h' as ValidCandleInterval, + callback, + }); + + // Wait for subscription to complete + await jest.advanceTimersByTimeAsync(100); + + // Call unsubscribe + unsubscribe(); + + // Assert - WebSocket unsubscribe called + expect(mockWsUnsubscribe).toHaveBeenCalled(); + }); + + it('handles unsubscribe before WebSocket established', async () => { + // Arrange - delay the promise resolution to simulate slow network + let resolveSnapshot: (value: any) => void = () => { + /* noop */ + }; + const delayedPromise = new Promise((resolve) => { + resolveSnapshot = resolve; + }); + + mockInfoClientHttp.candleSnapshot = jest + .fn() + .mockReturnValue(delayedPromise); + + const mockCandleSubscription = jest.fn(); + (mockSubscriptionClient as any).candle = mockCandleSubscription; + + const callback = jest.fn(); + + // Act - subscribe and immediately unsubscribe + const unsubscribe = service.subscribeToCandles({ + symbol: 'BTC', + interval: '1h' as ValidCandleInterval, + callback, + }); + + // Call unsubscribe immediately before WebSocket establishes + expect(() => unsubscribe()).not.toThrow(); + + // Now resolve the snapshot to let the async chain continue + resolveSnapshot([]); + + // Wait for async operations to complete + await jest.advanceTimersByTimeAsync(100); + + // Assert - WebSocket subscription should not be created because + // we already unsubscribed before the async chain completed + expect(mockCandleSubscription).not.toHaveBeenCalled(); + expect(callback).not.toHaveBeenCalled(); // Callback should not be invoked after unsubscribe + }); + + it('suppresses error when cleanup aborts in-flight REST call', async () => { + // Arrange - make snapshot reject with abort error + const abortError = new Error('AbortError'); + abortError.name = 'AbortError'; + mockInfoClientHttp.candleSnapshot = jest + .fn() + .mockRejectedValue(abortError); + + const onError = jest.fn(); + const callback = jest.fn(); + + // Act - subscribe then immediately unsubscribe (triggers abort) + const unsubscribe = service.subscribeToCandles({ + symbol: 'BTC', + interval: '1h' as ValidCandleInterval, + callback, + onError, + }); + + unsubscribe(); + + // Wait for async rejection to propagate + await jest.advanceTimersByTimeAsync(100); + + // Assert - error suppressed (abort is intentional), callback not invoked + expect(onError).not.toHaveBeenCalled(); + expect(callback).not.toHaveBeenCalled(); + }); + + it('cleans up WebSocket when unsubscribed during subscription establishment', async () => { + // Arrange - fast snapshot, slow WebSocket subscription + mockInfoClientHttp.candleSnapshot = jest.fn().mockResolvedValue([]); + + let resolveWsSubscription: (value: any) => void = () => { + /* noop */ + }; + const delayedWsPromise = new Promise((resolve) => { + resolveWsSubscription = resolve; + }); + + const mockWsUnsubscribe = jest.fn(); + (mockSubscriptionClient as any).candle = jest + .fn() + .mockReturnValue(delayedWsPromise); + + const callback = jest.fn(); + + // Act - subscribe + const unsubscribe = service.subscribeToCandles({ + symbol: 'BTC', + interval: '1h' as ValidCandleInterval, + callback, + }); + + // Wait for snapshot to complete + await jest.advanceTimersByTimeAsync(50); + + // Unsubscribe while WebSocket is still being established + unsubscribe(); + + // Now resolve the WebSocket subscription + resolveWsSubscription({ unsubscribe: mockWsUnsubscribe }); + + // Wait for async cleanup to complete + await jest.advanceTimersByTimeAsync(100); + + // Assert - WebSocket should be cleaned up immediately after establishing + expect(mockWsUnsubscribe).toHaveBeenCalled(); + }); + + it('consumes a rejected unsubscribe after the subscription resolves', async () => { + mockInfoClientHttp.candleSnapshot = jest.fn().mockResolvedValue([]); + const unsubscribeError = new Error('Unsubscribe request failed'); + const mockWsUnsubscribe = jest.fn().mockRejectedValue(unsubscribeError); + const unhandledRejectionListener = jest.fn(); + process.on('unhandledRejection', unhandledRejectionListener); + (mockSubscriptionClient as any).candle = jest + .fn() + .mockResolvedValue({ unsubscribe: mockWsUnsubscribe }); + + const unsubscribe = service.subscribeToCandles({ + symbol: 'BTC', + interval: '1h' as ValidCandleInterval, + callback: jest.fn(), + }); + await jest.advanceTimersByTimeAsync(100); + mockDeps.logger.error.mockClear(); + + unsubscribe(); + await jest.advanceTimersByTimeAsync(100); + process.off('unhandledRejection', unhandledRejectionListener); + + expect(unhandledRejectionListener).not.toHaveBeenCalled(); + expect(mockDeps.logger.error).toHaveBeenCalledWith( + unsubscribeError, + expect.objectContaining({ + context: expect.objectContaining({ + name: 'websocket_unsubscription', + }), + }), + ); + }); + + it('treats an Already unsubscribed rejection as idempotent during late cleanup', async () => { + mockInfoClientHttp.candleSnapshot = jest.fn().mockResolvedValue([]); + let resolveWsSubscription: (value: any) => void = () => { + // Intentionally empty until the test captures the resolver. + }; + const delayedWsPromise = new Promise((resolve) => { + resolveWsSubscription = resolve; + }); + const mockWsUnsubscribe = jest + .fn() + .mockRejectedValue(new Error('Already unsubscribed from candle feed')); + const unhandledRejectionListener = jest.fn(); + process.on('unhandledRejection', unhandledRejectionListener); + (mockSubscriptionClient as any).candle = jest + .fn() + .mockReturnValue(delayedWsPromise); + + const unsubscribe = service.subscribeToCandles({ + symbol: 'BTC', + interval: '1h' as ValidCandleInterval, + callback: jest.fn(), + }); + await jest.advanceTimersByTimeAsync(50); + unsubscribe(); + mockDeps.logger.error.mockClear(); + + resolveWsSubscription({ unsubscribe: mockWsUnsubscribe }); + await jest.advanceTimersByTimeAsync(100); + process.off('unhandledRejection', unhandledRejectionListener); + + expect(mockWsUnsubscribe).toHaveBeenCalledTimes(1); + expect(unhandledRejectionListener).not.toHaveBeenCalled(); + expect(mockDeps.logger.error).not.toHaveBeenCalled(); + }); + }); + + describe('Reconnection and Terminate Event', () => { + afterEach(() => { + // Restore default mock implementations that may have been changed by tests + const { WebSocketTransport } = require('@nktkas/hyperliquid'); + (WebSocketTransport as jest.Mock).mockImplementation( + () => mockWsTransport, + ); + mockWsTransportReady.mockResolvedValue(undefined); + }); + + it('sets reconnection callback', () => { + const callback = jest.fn().mockResolvedValue(undefined); + + service.setOnReconnectCallback(callback); + + // Callback is stored internally, verify it can be set without error + expect(() => service.setOnReconnectCallback(callback)).not.toThrow(); + }); + + it('sets terminate callback', () => { + const callback = jest.fn(); + + service.setOnTerminateCallback(callback); + + // Callback is stored internally, verify it can be set without error + expect(() => service.setOnTerminateCallback(callback)).not.toThrow(); + }); + + it('clears terminate callback when set to null', () => { + const callback = jest.fn(); + + service.setOnTerminateCallback(callback); + service.setOnTerminateCallback(null); + + // Callback should be cleared without error + expect(() => service.setOnTerminateCallback(null)).not.toThrow(); + }); + + it('registers terminate event listener on WebSocket transport', () => { + service.initialize(mockWallet); + + // Verify that addEventListener was called with 'terminate' + expect(mockSocket.addEventListener).toHaveBeenCalledWith( + 'terminate', + expect.any(Function), + ); + }); + + it('calls terminate callback when terminate event is fired', () => { + const terminateCallback = jest.fn(); + service.initialize(mockWallet); + service.setOnTerminateCallback(terminateCallback); + + // Get the terminate event handler that was registered + const terminateHandler = mockSocket.addEventListener.mock.calls.find( + (call: [string, (...args: unknown[]) => unknown]) => + call[0] === 'terminate', + )?.[1] as (event: Event) => void; + + expect(terminateHandler).toBeDefined(); + + // Simulate terminate event with error detail + const mockEvent = { + detail: { code: 1006 }, + } as unknown as Event; + + terminateHandler(mockEvent); + + // Verify callback was called with an error + expect(terminateCallback).toHaveBeenCalledWith(expect.any(Error)); + expect(terminateCallback.mock.calls[0][0].message).toContain( + 'WebSocket terminated', + ); + }); + + it('calls terminate callback with Error instance when detail is Error', () => { + const terminateCallback = jest.fn(); + service.initialize(mockWallet); + service.setOnTerminateCallback(terminateCallback); + + // Get the terminate event handler + const terminateHandler = mockSocket.addEventListener.mock.calls.find( + (call: [string, (...args: unknown[]) => unknown]) => + call[0] === 'terminate', + )?.[1] as (event: Event) => void; + + // Simulate terminate event with Error detail + const originalError = new Error('Connection failed'); + const mockEvent = { + detail: originalError, + } as unknown as Event; + + terminateHandler(mockEvent); + + // Verify callback was called with the original error + expect(terminateCallback).toHaveBeenCalledWith(originalError); + }); + + it('updates connection state to DISCONNECTED when terminate event fires', async () => { + const { + WebSocketConnectionState, + } = require('../../../src/services/HyperLiquidClientService'); + await service.initialize(mockWallet); + + // Verify initial state is CONNECTED + expect(service.getConnectionState()).toBe( + WebSocketConnectionState.Connected, + ); + + // Get the terminate event handler + const terminateHandler = mockSocket.addEventListener.mock.calls.find( + (call: [string, (...args: unknown[]) => unknown]) => + call[0] === 'terminate', + )?.[1] as (event: Event) => void; + + // Fire terminate event + terminateHandler({ detail: { code: 1006 } } as unknown as Event); + + // Verify state changed to DISCONNECTED + expect(service.getConnectionState()).toBe( + WebSocketConnectionState.Disconnected, + ); + }); + + it('does not throw when terminate callback is not set', () => { + service.initialize(mockWallet); + + // Get the terminate event handler + const terminateHandler = mockSocket.addEventListener.mock.calls.find( + (call: [string, (...args: unknown[]) => unknown]) => + call[0] === 'terminate', + )?.[1] as (event: Event) => void; + + // Fire terminate event without setting callback + expect(() => { + terminateHandler({ detail: { code: 1006 } } as unknown as Event); + }).not.toThrow(); + }); + + it('clears terminate callback on disconnect', async () => { + const terminateCallback = jest.fn(); + service.initialize(mockWallet); + service.setOnTerminateCallback(terminateCallback); + + await service.disconnect(); + + // After disconnect, the callback should be cleared + // Initialize again to get a new terminate handler + service.initialize(mockWallet); + + // Get the new terminate event handler + const terminateHandler = mockSocket.addEventListener.mock.calls + .filter( + (call: [string, (...args: unknown[]) => unknown]) => + call[0] === 'terminate', + ) + .pop()?.[1] as (event: Event) => void; + + // Fire terminate event + terminateHandler({ detail: { code: 1006 } } as unknown as Event); + + // Callback should NOT be called since it was cleared on disconnect + expect(terminateCallback).not.toHaveBeenCalled(); + }); + }); + + describe('Reconnection Logic', () => { + afterEach(() => { + // Restore default mock implementations that may have been changed by tests + const { WebSocketTransport } = require('@nktkas/hyperliquid'); + (WebSocketTransport as jest.Mock).mockImplementation( + () => mockWsTransport, + ); + mockWsTransportReady.mockResolvedValue(undefined); + }); + + it('reconnect() triggers reconnection and maintains CONNECTED state on success', async () => { + const { + WebSocketConnectionState, + } = require('../../../src/services/HyperLiquidClientService'); + await service.initialize(mockWallet); + + // Verify initial state is CONNECTED + expect(service.getConnectionState()).toBe( + WebSocketConnectionState.Connected, + ); + + // Call reconnect + await service.reconnect(); + + // After successful reconnect, state should be CONNECTED again + expect(service.getConnectionState()).toBe( + WebSocketConnectionState.Connected, + ); + }); + + it('reconnect() calls wsTransport.close() to cleanup existing transport', async () => { + await service.initialize(mockWallet); + mockWsTransport.close.mockClear(); + + // Call reconnect + await service.reconnect(); + + // Verify close was called during reconnection + expect(mockWsTransport.close).toHaveBeenCalled(); + }); + + it('reconnect() creates new clients after reconnection', async () => { + const { InfoClient, SubscriptionClient } = require('@nktkas/hyperliquid'); + await service.initialize(mockWallet); + + const infoClientCallsBefore = (InfoClient as jest.Mock).mock.calls.length; + const subscriptionClientCallsBefore = (SubscriptionClient as jest.Mock) + .mock.calls.length; + + await service.reconnect(); + + // New clients should have been created + expect((InfoClient as jest.Mock).mock.calls.length).toBeGreaterThan( + infoClientCallsBefore, + ); + expect( + (SubscriptionClient as jest.Mock).mock.calls.length, + ).toBeGreaterThan(subscriptionClientCallsBefore); + }); + + it('reconnect() recreates exchangeClient and isInitialized() returns true', async () => { + const { ExchangeClient, InfoClient } = require('@nktkas/hyperliquid'); + await service.initialize(mockWallet); + + expect(service.isInitialized()).toBe(true); + + const exchangeCallsBefore = (ExchangeClient as jest.Mock).mock.calls + .length; + const infoCallsBefore = (InfoClient as jest.Mock).mock.calls.length; + + await service.reconnect(); + + // ExchangeClient should have been recreated with HTTP transport + expect((ExchangeClient as jest.Mock).mock.calls.length).toBeGreaterThan( + exchangeCallsBefore, + ); + // InfoClient should have additional calls (WS + HTTP fallback) + expect((InfoClient as jest.Mock).mock.calls.length).toBeGreaterThan( + infoCallsBefore, + ); + // isInitialized() must return true after reconnection + expect(service.isInitialized()).toBe(true); + }); + + it('reconnect() skips exchangeClient when wallet was never provided', async () => { + const { ExchangeClient } = require('@nktkas/hyperliquid'); + + // Create a fresh service without calling initialize() — no wallet stored + const freshService = new HyperLiquidClientService(mockDeps); + const exchangeCallsBefore = (ExchangeClient as jest.Mock).mock.calls + .length; + + await freshService.reconnect(); + + // ExchangeClient should NOT be created since no wallet params exist + expect((ExchangeClient as jest.Mock).mock.calls.length).toBe( + exchangeCallsBefore, + ); + // isInitialized() must be false — exchangeClient was never created + expect(freshService.isInitialized()).toBe(false); + }); + + it('reports uninitialized when reconnect readiness fails', async () => { + await service.initialize(mockWallet); + expect(service.isInitialized()).toBe(true); + + // Clients are constructed before the transport reports ready, so a + // rejected ready() must not leave a session that looks usable. + mockWsTransportReady.mockRejectedValueOnce(new Error('ws never opened')); + await service.reconnect(); + + expect(service.isInitialized()).toBe(false); + expect(service.getSubscriptionClient()).toBeUndefined(); + expect(() => service.getInfoClient()).toThrow('CLIENT_NOT_INITIALIZED'); + expect(service.getInfoClient({ useHttp: true })).toBe(mockInfoClientHttp); + expect(service.getExchangeClient()).toBe(mockExchangeClient); + }); + + it('reports uninitialized when reconnect readiness fails after a disconnect', async () => { + await service.initialize(mockWallet); + await service.disconnect(); + + mockWsTransportReady.mockRejectedValueOnce(new Error('ws never opened')); + await service.reconnect(); + + expect(service.isInitialized()).toBe(false); + }); + + it('does not initialize a competing subscription client during retry backoff', async () => { + const { WebSocketTransport } = require('@nktkas/hyperliquid'); + await service.initialize(mockWallet); + + mockWsTransportReady.mockRejectedValueOnce(new Error('ws never opened')); + await service.reconnect(); + const transportCalls = (WebSocketTransport as jest.Mock).mock.calls + .length; + + await service.ensureSubscriptionClient(mockWallet); + + expect((WebSocketTransport as jest.Mock).mock.calls).toHaveLength( + transportCalls, + ); + expect(service.getSubscriptionClient()).toBeUndefined(); + }); + + it('performDisconnection resets isReconnecting flag', async () => { + const { + WebSocketConnectionState, + } = require('../../../src/services/HyperLiquidClientService'); + await service.initialize(mockWallet); + + // Disconnect (which calls performDisconnection internally) + await service.disconnect(); + + expect(service.getConnectionState()).toBe( + WebSocketConnectionState.Disconnected, + ); + + // Verify we can reconnect after disconnect (isReconnecting was reset) + // Reset ready mock to succeed + mockWsTransportReady.mockResolvedValue(undefined); + + // Reset InfoClient counter since initialize creates new clients + mockInfoClientCallCount = 0; + + await service.initialize(mockWallet); + + expect(service.getConnectionState()).toBe( + WebSocketConnectionState.Connected, + ); + }); + }); + + describe('Connection State Listeners', () => { + afterEach(() => { + // Restore default mock implementations + const { WebSocketTransport } = require('@nktkas/hyperliquid'); + (WebSocketTransport as jest.Mock).mockImplementation( + () => mockWsTransport, + ); + mockWsTransportReady.mockResolvedValue(undefined); + }); + + it('subscribeToConnectionState immediately notifies with current state', async () => { + const { + WebSocketConnectionState, + } = require('../../../src/services/HyperLiquidClientService'); + await service.initialize(mockWallet); + + const listener = jest.fn(); + + service.subscribeToConnectionState(listener); + + // Should be called immediately with current state + expect(listener).toHaveBeenCalledWith( + WebSocketConnectionState.Connected, + 0, + ); + }); + + it('listener receives state changes when connection state updates', async () => { + const { + WebSocketConnectionState, + } = require('../../../src/services/HyperLiquidClientService'); + await service.initialize(mockWallet); + + const listener = jest.fn(); + service.subscribeToConnectionState(listener); + + // Clear the initial call + listener.mockClear(); + + // Trigger a state change by firing terminate event + const terminateHandler = mockSocket.addEventListener.mock.calls.find( + (call: [string, (...args: unknown[]) => unknown]) => + call[0] === 'terminate', + )?.[1] as (event: Event) => void; + + terminateHandler({ detail: { code: 1006 } } as unknown as Event); + + // Listener should be notified of DISCONNECTED state + expect(listener).toHaveBeenCalledWith( + WebSocketConnectionState.Disconnected, + 0, + ); + }); + + it('unsubscribe function removes listener', async () => { + await service.initialize(mockWallet); + + const listener = jest.fn(); + const unsubscribe = service.subscribeToConnectionState(listener); + + // Clear the initial call + listener.mockClear(); + + // Unsubscribe + unsubscribe(); + + // Trigger a state change + const terminateHandler = mockSocket.addEventListener.mock.calls.find( + (call: [string, (...args: unknown[]) => unknown]) => + call[0] === 'terminate', + )?.[1] as (event: Event) => void; + + terminateHandler({ detail: { code: 1006 } } as unknown as Event); + + // Listener should NOT be called after unsubscribe + expect(listener).not.toHaveBeenCalled(); + }); + + it('multiple listeners all receive notifications', async () => { + const { + WebSocketConnectionState, + } = require('../../../src/services/HyperLiquidClientService'); + await service.initialize(mockWallet); + + const listener1 = jest.fn(); + const listener2 = jest.fn(); + const listener3 = jest.fn(); + + service.subscribeToConnectionState(listener1); + service.subscribeToConnectionState(listener2); + service.subscribeToConnectionState(listener3); + + // All should be called with initial state + expect(listener1).toHaveBeenCalledWith( + WebSocketConnectionState.Connected, + 0, + ); + expect(listener2).toHaveBeenCalledWith( + WebSocketConnectionState.Connected, + 0, + ); + expect(listener3).toHaveBeenCalledWith( + WebSocketConnectionState.Connected, + 0, + ); + + // Clear all + listener1.mockClear(); + listener2.mockClear(); + listener3.mockClear(); + + // Trigger state change + const terminateHandler = mockSocket.addEventListener.mock.calls.find( + (call: [string, (...args: unknown[]) => unknown]) => + call[0] === 'terminate', + )?.[1] as (event: Event) => void; + + terminateHandler({ detail: { code: 1006 } } as unknown as Event); + + // All should be notified + expect(listener1).toHaveBeenCalledWith( + WebSocketConnectionState.Disconnected, + 0, + ); + expect(listener2).toHaveBeenCalledWith( + WebSocketConnectionState.Disconnected, + 0, + ); + expect(listener3).toHaveBeenCalledWith( + WebSocketConnectionState.Disconnected, + 0, + ); + }); + + it('reconnection triggers CONNECTING state notification', async () => { + const { + WebSocketConnectionState, + } = require('../../../src/services/HyperLiquidClientService'); + await service.initialize(mockWallet); + + const listener = jest.fn(); + service.subscribeToConnectionState(listener); + + // Clear initial call + listener.mockClear(); + + // Start a reconnection attempt + await service.reconnect(); + + // Listener should have been called with CONNECTING state + const connectingCall = listener.mock.calls.find( + (call: [string, number]) => + call[0] === WebSocketConnectionState.Connecting, + ); + expect(connectingCall).toBeDefined(); + }); + + it('successful reconnection notifies listeners with CONNECTED state', async () => { + const { + WebSocketConnectionState, + } = require('../../../src/services/HyperLiquidClientService'); + await service.initialize(mockWallet); + + const listener = jest.fn(); + service.subscribeToConnectionState(listener); + + // Clear initial call + listener.mockClear(); + + // Trigger reconnect + await service.reconnect(); + + // Find the CONNECTED call after reconnection + const connectedCalls = listener.mock.calls.filter( + (call: [string, number]) => + call[0] === WebSocketConnectionState.Connected, + ); + + expect(connectedCalls.length).toBeGreaterThan(0); + }); + }); + + describe('ensureTransportReady', () => { + it('resolves immediately when transport is ready', async () => { + await service.initialize(mockWallet); + + // Should resolve without error + await expect(service.ensureTransportReady()).resolves.toBeUndefined(); + }); + + it('throws error when subscription client not initialized', async () => { + // Service not initialized - subscription client is undefined + await expect(service.ensureTransportReady()).rejects.toThrow( + 'Subscription client not initialized', + ); + }); + + it('throws timeout error when transport not ready', async () => { + await service.initialize(mockWallet); + + // Reset mock to simulate a never-resolving ready() call + // The AbortController in ensureTransportReady will abort after timeout + mockWsTransportReady.mockImplementationOnce( + (signal?: AbortSignal) => + new Promise((_resolve, reject) => { + // If there's an abort signal, listen to it and reject when aborted + if (signal) { + signal.addEventListener('abort', () => { + reject(new Error('Aborted')); + }); + } + // Never resolves on its own - waits for abort + }), + ); + + // Use expect().rejects pattern with async timer advancement + // The promise and timer advancement need to happen concurrently + const promiseResult = service + .ensureTransportReady({ timeoutMs: 50 }) + .catch((e) => e); + + // Advance timers to trigger the timeout + await jest.advanceTimersByTimeAsync(100); + + // Now check the result + const error = await promiseResult; + expect(error).toBeInstanceOf(Error); + expect(error.message).toContain('WebSocket transport ready timeout'); + }); + + it('throws a proper Error (not undefined) when transport.ready() rejects with undefined', async () => { + await service.initialize(mockWallet); + + // Simulate the HyperLiquid SDK rejecting with undefined (the root cause of Sentry issues + // 5E7M, 5EF8, 5GBE, 5G91: "Unknown error (no details provided)") + mockWsTransportReady.mockImplementationOnce(() => + Promise.reject(undefined), + ); + + const error = await service.ensureTransportReady().catch((e) => e); + + // Must be a real Error instance, not undefined + expect(error).toBeInstanceOf(Error); + expect(error.message).toContain( + 'HyperLiquidClientService.ensureTransportReady', + ); + }); + }); +}); diff --git a/packages/perps-controller/tests/src/services/HyperLiquidSubscriptionService.cache.test.ts b/packages/perps-controller/tests/src/services/HyperLiquidSubscriptionService.cache.test.ts new file mode 100644 index 00000000000..588c494df1d --- /dev/null +++ b/packages/perps-controller/tests/src/services/HyperLiquidSubscriptionService.cache.test.ts @@ -0,0 +1,2595 @@ +/* eslint-disable */ +/** + * Unit tests for HyperLiquidSubscriptionService + */ + +/* eslint-disable @typescript-eslint/no-explicit-any */ + +import type { CaipAccountId, Hex } from '@metamask/utils'; + +import { ABSTRACTION_MODE_REFRESH_THROTTLE_MS } from '../../../src/constants/perpsConfig.js'; +import type { HyperLiquidClientService } from '../../../src/services/HyperLiquidClientService.js'; +import { HyperLiquidSubscriptionService } from '../../../src/services/HyperLiquidSubscriptionService.js'; +import type { HyperLiquidWalletService } from '../../../src/services/HyperLiquidWalletService.js'; +import type { + SubscribeOrderBookParams, + SubscribeOrderFillsParams, + SubscribePositionsParams, + SubscribePricesParams, +} from '../../../src/types/index.js'; +import { + adaptAccountStateFromSDK, + parseAssetName, +} from '../../../src/utils/hyperLiquidAdapter.js'; +import { createMockInfrastructure } from '../../helpers/serviceMocks.js'; + +// Mock HyperLiquid SDK types +interface MockSubscription { + unsubscribe: jest.Mock; +} + +// Mock adapter +jest.mock('../../../src/utils/hyperLiquidAdapter', () => ({ + adaptPositionFromSDK: jest.fn((assetPos: any) => ({ + symbol: 'BTC', + size: assetPos.position.szi, + entryPrice: '50000', + positionValue: '5000', + unrealizedPnl: '100', + marginUsed: '2500', + leverage: { type: 'isolated', value: 2 }, + liquidationPrice: '40000', + maxLeverage: 100, + returnOnEquity: '4.0', + cumulativeFunding: { allTime: '0', sinceOpen: '0', sinceChange: '0' }, + takeProfitCount: 0, + stopLossCount: 0, + })), + adaptOrderFromSDK: jest.fn((order: any) => ({ + orderId: order.oid.toString(), + symbol: order.coin, + side: order.side === 'B' ? 'buy' : 'sell', + orderType: 'limit', + size: order.sz, + originalSize: order.sz, + price: order.limitPx || order.triggerPx || '0', + filledSize: '0', + remainingSize: order.sz, + status: 'open', + timestamp: Date.now(), + detailedOrderType: order.orderType || 'Limit', + isTrigger: order.isTrigger ?? false, + reduceOnly: order.reduceOnly ?? false, + triggerPrice: order.triggerPx, + ...(typeof order.isPositionTpsl === 'boolean' + ? { isPositionTpsl: order.isPositionTpsl } + : {}), + })), + adaptAccountStateFromSDK: jest.fn(() => ({ + spendableBalance: '1000.00', + withdrawableBalance: '1000.00', + marginUsed: '500.00', + unrealizedPnl: '100.00', + returnOnEquity: '20.0', + totalBalance: '10100.00', + })), + parseAssetName: jest.fn((symbol: string) => ({ + symbol, + dex: null, + })), +})); + +// Mock DevLogger +jest.mock( + '../../../../core/SDKConnect/utils/DevLogger', + () => ({ + DevLogger: { + log: jest.fn(), + }, + }), + { virtual: true }, +); + +// Mock trace utilities +jest.mock( + '../../../../util/trace', + () => ({ + trace: jest.fn(), + TraceName: { + PerpsWebSocketConnected: 'Perps WebSocket Connected', + PerpsWebSocketDisconnected: 'Perps WebSocket Disconnected', + }, + TraceOperation: { + PerpsMarketData: 'perps.market_data', + }, + }), + { virtual: true }, +); + +// Mock Sentry +jest.mock( + '@sentry/react-native', + () => ({ + setMeasurement: jest.fn(), + }), + { virtual: true }, +); + +describe('HyperLiquidSubscriptionService', () => { + let service: HyperLiquidSubscriptionService; + let mockClientService: jest.Mocked; + let mockWalletService: jest.Mocked; + let mockSubscriptionClient: any; + let mockWalletAdapter: any; + let mockDeps: ReturnType; + let mockSpotClearinghouseState: jest.Mock; + + beforeEach(() => { + jest.useFakeTimers(); + jest.clearAllMocks(); + mockDeps = createMockInfrastructure(); + jest.mocked(parseAssetName).mockImplementation((symbol: string) => ({ + symbol, + dex: null, + })); + const hyperLiquidAdapter = jest.requireMock( + '../../../src/utils/hyperLiquidAdapter', + ); + hyperLiquidAdapter.adaptPositionFromSDK.mockImplementation( + (assetPos: any) => ({ + symbol: 'BTC', + size: assetPos.position.szi, + entryPrice: '50000', + positionValue: '5000', + unrealizedPnl: '100', + marginUsed: '2500', + leverage: { type: 'isolated', value: 2 }, + liquidationPrice: '40000', + maxLeverage: 100, + returnOnEquity: '4.0', + cumulativeFunding: { allTime: '0', sinceOpen: '0', sinceChange: '0' }, + takeProfitCount: 0, + stopLossCount: 0, + }), + ); + hyperLiquidAdapter.adaptOrderFromSDK.mockImplementation((order: any) => ({ + orderId: order.oid.toString(), + symbol: order.coin, + side: order.side === 'B' ? 'buy' : 'sell', + orderType: 'limit', + size: order.sz, + originalSize: order.sz, + price: order.limitPx || order.triggerPx || '0', + filledSize: '0', + remainingSize: order.sz, + status: 'open', + timestamp: Date.now(), + detailedOrderType: order.orderType || 'Limit', + isTrigger: order.isTrigger ?? false, + reduceOnly: order.reduceOnly ?? false, + triggerPrice: order.triggerPx, + ...(typeof order.isPositionTpsl === 'boolean' + ? { isPositionTpsl: order.isPositionTpsl } + : {}), + })); + hyperLiquidAdapter.adaptAccountStateFromSDK.mockImplementation(() => ({ + spendableBalance: '1000.00', + withdrawableBalance: '1000.00', + marginUsed: '500.00', + unrealizedPnl: '100.00', + returnOnEquity: '20.0', + totalBalance: '10100.00', + })); + + // Mock subscription client + const mockSubscription: MockSubscription = { + unsubscribe: jest.fn().mockResolvedValue(undefined), + }; + + mockSubscriptionClient = { + allMids: jest.fn((paramsOrCallback: any, maybeCallback?: any) => { + const callback = + typeof paramsOrCallback === 'function' + ? paramsOrCallback + : maybeCallback; + // Simulate allMids data + setTimeout(() => { + callback({ + mids: { + BTC: 50000, + ETH: 3000, + }, + }); + }, 0); + return Promise.resolve(mockSubscription); + }), + activeAssetCtx: jest.fn((params: any, callback: any) => { + // Simulate activeAssetCtx data + setTimeout(() => { + callback({ + coin: params.coin, + ctx: { + prevDayPx: '49000', + funding: '0.01', + openInterest: '1000000', // Raw token units from API + dayNtlVlm: '50000000', + oraclePx: '50100', + midPx: '50000', // Price used for openInterest USD conversion: 1M tokens * $50K = $50B + }, + }); + }, 0); + return Promise.resolve(mockSubscription); + }), + webData3: jest.fn((_params: any, callback: any) => { + // Simulate webData3 data with perpDexStates structure + // First callback immediately + setTimeout(() => { + callback({ + perpDexStates: [ + { + clearinghouseState: { + assetPositions: [ + { + position: { szi: '0.1' }, + coin: 'BTC', + }, + ], + }, + openOrders: [ + { + oid: 12345, + coin: 'BTC', + side: 'B', + sz: '0.5', + origSz: '1.0', + limitPx: '50000', + orderType: 'Limit', + timestamp: 1234567890000, + isTrigger: false, + reduceOnly: false, + }, + ], + perpsAtOpenInterestCap: [], + }, + ], + }); + }, 0); + + // Second callback with changed data to ensure updates are triggered + setTimeout(() => { + callback({ + perpDexStates: [ + { + clearinghouseState: { + assetPositions: [ + { + position: { szi: '0.2' }, // Changed position size + coin: 'BTC', + }, + ], + }, + openOrders: [ + { + oid: 12346, // Changed order ID + coin: 'BTC', + side: 'S', + sz: '0.3', + origSz: '0.5', + limitPx: '51000', + orderType: 'Limit', + timestamp: 1234567890001, + isTrigger: false, + reduceOnly: false, + }, + ], + perpsAtOpenInterestCap: [], + }, + ], + }); + }, 10); + + return Promise.resolve(mockSubscription); + }), + webData2: jest.fn((_params: any, callback: any) => { + // Simulate webData2 data with clearinghouseState (HIP-3 disabled) + setTimeout(() => { + callback({ + clearinghouseState: { + assetPositions: [ + { + position: { szi: '0.1' }, + coin: 'BTC', + }, + ], + marginSummary: { + accountValue: '10000', + totalMarginUsed: '500', + }, + withdrawable: '9500', + }, + openOrders: [ + { + oid: 12345, + coin: 'BTC', + side: 'B', + sz: '0.5', + origSz: '1.0', + limitPx: '50000', + orderType: 'Limit', + timestamp: 1234567890000, + isTrigger: false, + reduceOnly: false, + }, + ], + perpsAtOpenInterestCap: [], + }); + }, 0); + return Promise.resolve(mockSubscription); + }), + userFills: jest.fn((_params: any, callback: any) => { + // Simulate order fill data + setTimeout(() => { + callback({ + fills: [ + { + oid: 12345, + coin: 'BTC', + side: 'B', + sz: '0.1', + px: '50000', + fee: '5', + time: Date.now(), + }, + ], + }); + }, 0); + return Promise.resolve(mockSubscription); + }), + l2Book: jest.fn((_params: any, callback: any) => { + // Simulate l2Book data + setTimeout(() => { + callback({ + coin: _params.coin, + levels: { bids: [], asks: [] }, + }); + }, 0); + return Promise.resolve(mockSubscription); + }), + bbo: jest.fn((_params: any, callback: any) => { + // Simulate BBO data + setTimeout(() => { + callback({ + coin: _params.coin, + time: Date.now(), + bbo: [ + { px: '49900', sz: '1.5', n: 1 }, + { px: '50100', sz: '2.0', n: 1 }, + ], + }); + }, 0); + return Promise.resolve(mockSubscription); + }), + activeAsset: jest.fn((params: any, callback: any) => { + // Simulate activeAsset data (similar to activeAssetCtx) + setTimeout(() => { + callback({ + coin: params.coin, + data: 'test', + }); + }, 0); + return Promise.resolve(mockSubscription); + }), + clearinghouseState: jest.fn((_params: any, callback: any) => { + // Simulate clearinghouseState data for individual subscription + setTimeout(() => { + callback({ + dex: _params.dex || '', + clearinghouseState: { + assetPositions: [ + { + position: { szi: '0.1' }, + coin: 'BTC', + }, + ], + marginSummary: { + accountValue: '10000', + totalMarginUsed: '500', + }, + withdrawable: '9500', + }, + }); + }, 0); + return Promise.resolve(mockSubscription); + }), + openOrders: jest.fn((_params: any, callback: any) => { + // Simulate openOrders data for individual subscription + setTimeout(() => { + callback({ + dex: _params.dex || '', + orders: [ + { + oid: 12345, + coin: 'BTC', + side: 'B', + sz: '0.5', + origSz: '1.0', + limitPx: '50000', + orderType: 'Limit', + timestamp: 1234567890000, + isTrigger: false, + reduceOnly: false, + triggerCondition: '', + triggerPx: '', + children: [], + isPositionTpsl: false, + tif: null, + cloid: null, + }, + ], + }); + }, 0); + return Promise.resolve(mockSubscription); + }), + assetCtxs: jest.fn(() => Promise.resolve(mockSubscription)), + fastAssetCtxs: jest.fn((_callback: any) => + Promise.resolve(mockSubscription), + ), + spotState: jest.fn((_params: any, _callback: any) => + Promise.resolve(mockSubscription), + ), + }; + + mockWalletAdapter = { + request: jest.fn(), + }; + + // Mock client service + mockSpotClearinghouseState = jest.fn().mockResolvedValue({ + balances: [{ coin: 'USDC', total: '100.76531791' }], + }); + + mockClientService = { + ensureSubscriptionClient: jest.fn().mockResolvedValue(undefined), + getSubscriptionClient: jest.fn(() => mockSubscriptionClient), + getInfoClient: jest.fn(() => ({ + spotClearinghouseState: mockSpotClearinghouseState, + // Mode-aware fold gate reads userAbstraction; default to unifiedAccount + // so existing spot-fold assertions behave as before the gate was added. + userAbstraction: jest.fn().mockResolvedValue('unifiedAccount'), + })), + isTestnetMode: jest.fn(() => false), + ensureTransportReady: jest.fn().mockResolvedValue(undefined), + getConnectionState: jest.fn(() => 'connected'), + } as any; + + // Mock wallet service + mockWalletService = { + createWalletAdapter: jest.fn(() => mockWalletAdapter), + getUserAddressWithDefault: jest.fn().mockResolvedValue('0x123' as Hex), + } as any; + + service = new HyperLiquidSubscriptionService( + mockClientService, + mockWalletService, + mockDeps, + true, // hip3Enabled - test expects webData3 + ); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + describe('OI Cap Subscriptions', () => { + it('subscribes to OI cap updates successfully', async () => { + const mockCallback = jest.fn(); + + const unsubscribe = service.subscribeToOICaps({ + callback: mockCallback, + }); + + await jest.runAllTimersAsync(); + + expect(mockSubscriptionClient.webData3).toHaveBeenCalled(); + expect(typeof unsubscribe).toBe('function'); + }); + + it('immediately provides cached OI caps if available', async () => { + const mockCallback = jest.fn(); + + // Mock webData3 to provide OI caps data + mockSubscriptionClient.webData3.mockImplementation( + (_params: any, callback: any) => { + setTimeout(() => { + callback({ + perpDexStates: [ + { + clearinghouseState: { assetPositions: [] }, + openOrders: [], + perpsAtOpenInterestCap: ['BTC', 'ETH'], + }, + ], + }); + }, 0); + return Promise.resolve({ + unsubscribe: jest.fn().mockResolvedValue(undefined), + }); + }, + ); + + // First subscription to populate cache + const unsubscribe1 = service.subscribeToOICaps({ callback: jest.fn() }); + await jest.runAllTimersAsync(); + + // Second subscription should get cached data immediately + const unsubscribe2 = service.subscribeToOICaps({ + callback: mockCallback, + }); + + expect(mockCallback).toHaveBeenCalledWith(['BTC', 'ETH']); + + unsubscribe1(); + unsubscribe2(); + }); + }); + + describe('Account Subscriptions', () => { + it('subscribes to account updates successfully', async () => { + const mockCallback = jest.fn(); + + const unsubscribe = service.subscribeToAccount({ + callback: mockCallback, + }); + + await jest.runAllTimersAsync(); + + expect(mockSubscriptionClient.webData3).toHaveBeenCalled(); + expect(mockCallback).toHaveBeenCalled(); + expect(typeof unsubscribe).toBe('function'); + }); + + it('immediately provides cached account state if available', async () => { + const mockCallback = jest.fn(); + + // First subscription to populate cache + const unsubscribe1 = service.subscribeToAccount({ + callback: jest.fn(), + }); + await jest.runAllTimersAsync(); + + // Second subscription should get cached data immediately + const unsubscribe2 = service.subscribeToAccount({ + callback: mockCallback, + }); + + expect(mockCallback).toHaveBeenCalled(); + + unsubscribe1(); + unsubscribe2(); + }); + + it('reports DEX coverage for positions only once that DEX has published', async () => { + // closePosition treats a cache miss as "position closed" only for a + // covered DEX, so coverage must be false until data arrives + expect(service.getCachedPositionsForDex('')).toBeNull(); + + const unsubscribe = service.subscribeToAccount({ callback: jest.fn() }); + await jest.runAllTimersAsync(); + + expect(service.getCachedPositionsForDex('')).not.toBeNull(); + // No HIP-3 DEX published, so a miss there proves nothing + expect(service.getCachedPositionsForDex('xyz')).toBeNull(); + + unsubscribe(); + }); + }); + + describe('spotState WebSocket Subscription', () => { + it('establishes a spotState subscription on subscribeToAccount', async () => { + const unsubscribe = service.subscribeToAccount({ + callback: jest.fn(), + }); + await jest.runAllTimersAsync(); + + expect(mockSubscriptionClient.spotState).toHaveBeenCalledWith( + expect.objectContaining({ user: expect.stringMatching(/^0x/) }), + expect.any(Function), + ); + expect(mockClientService.getInfoClient).toHaveBeenCalledWith({ + useHttp: true, + }); + + unsubscribe(); + }); + + it('does not re-subscribe spotState for the same user', async () => { + const unsubscribe1 = service.subscribeToAccount({ + callback: jest.fn(), + }); + const unsubscribe2 = service.subscribeToAccount({ + callback: jest.fn(), + }); + await jest.runAllTimersAsync(); + + expect(mockSubscriptionClient.spotState).toHaveBeenCalledTimes(1); + + unsubscribe1(); + unsubscribe2(); + }); + + it('re-notifies account subscribers when a spotState push arrives', async () => { + const firstCallback = jest.fn(); + const firstUnsubscribe = service.subscribeToAccount({ + callback: firstCallback, + }); + await jest.runAllTimersAsync(); + + const notifyCallback = jest.fn(); + const unsubscribe = service.subscribeToAccount({ + callback: notifyCallback, + }); + await jest.runAllTimersAsync(); + + const callsBefore = notifyCallback.mock.calls.length; + + const spotListener = mockSubscriptionClient.spotState.mock.calls[0][1]; + spotListener({ + user: '0x123', + spotState: { + balances: [ + { + coin: 'USDC', + token: 0, + hold: '0', + total: '123.45', + entryNtl: '123.45', + }, + ], + }, + }); + + expect(notifyCallback.mock.calls.length).toBeGreaterThan(callsBefore); + + firstUnsubscribe(); + unsubscribe(); + }); + + it('preserves the abstraction REST result when WS spot push arrives first, and re-aggregates with the correct fold', async () => { + // Setup: first userAbstraction call hangs until we manually resolve it. + // This simulates a slow REST response while the WS spot subscription + // pushes a snapshot first, bumping #spotStateGeneration so the in-flight + // refresh would otherwise discard the abstraction result. + let resolveAbstraction: (mode: 'unifiedAccount') => void = jest.fn(); + const abstractionPromise = new Promise<'unifiedAccount'>((resolve) => { + resolveAbstraction = resolve; + }); + let resolveAbstractionStarted: () => void = jest.fn(); + const abstractionStarted = new Promise((resolve) => { + resolveAbstractionStarted = resolve; + }); + const userAbstractionMock = jest.fn().mockImplementationOnce(() => { + resolveAbstractionStarted(); + return abstractionPromise; + }); + + let spotListener: ((event: any) => void) | undefined; + let resolveSpotStateSubscribed: () => void = jest.fn(); + const spotStateSubscribed = new Promise((resolve) => { + resolveSpotStateSubscribed = resolve; + }); + mockSubscriptionClient.spotState.mockImplementationOnce( + (_params: any, callback: any) => { + spotListener = callback; + resolveSpotStateSubscribed(); + return Promise.resolve({ + unsubscribe: jest.fn().mockResolvedValue(undefined), + }); + }, + ); + + mockClientService.getInfoClient = jest.fn(() => ({ + spotClearinghouseState: mockSpotClearinghouseState, + userAbstraction: userAbstractionMock, + })) as never; + + const accountCallback = jest.fn(); + const unsubscribe = service.subscribeToAccount({ + callback: accountCallback, + }); + await Promise.all([abstractionStarted, spotStateSubscribed]); + expect(userAbstractionMock).toHaveBeenCalledTimes(1); + + // Simulate the WS spot push arriving before REST userAbstraction + // resolves. The WS callback bumps #spotStateGeneration so the + // in-flight refresh's spot result would be discarded by the + // generation guard. + expect(spotListener).toBeDefined(); + spotListener?.({ + user: '0x123', + spotState: { + balances: [ + { + coin: 'USDC', + token: 0, + hold: '0', + total: '123.45', + entryNtl: '123.45', + }, + ], + }, + }); + await jest.runAllTimersAsync(); + + // Resolve the REST userAbstraction. The refresh path must record the + // mode (it's user-keyed, independent of spot generation) and trigger + // a re-aggregation so the active subscriber sees folded balance — + // not wait for another subscribe/action to repair the state. + accountCallback.mockClear(); + resolveAbstraction('unifiedAccount'); + await jest.runAllTimersAsync(); + + expect(accountCallback).toHaveBeenCalled(); + const recoveredCall = accountCallback.mock.calls.at(-1)?.[0]; + // unifiedAccount → fold=true → spot USDC ($123.45) folds into + // spendable/withdrawable (default $1000 perps + $123.45 spot ≈ $1123.45). + expect(parseFloat(recoveredCall?.spendableBalance)).toBeCloseTo( + 1123.45, + 2, + ); + expect(parseFloat(recoveredCall?.withdrawableBalance)).toBeCloseTo( + 1123.45, + 2, + ); + + // A subsequent subscribe must take the fast path — the cache is now + // sealed for this user, so no redundant userAbstraction REST round-trip. + service.subscribeToAccount({ callback: jest.fn() }); + await jest.runAllTimersAsync(); + expect(userAbstractionMock).toHaveBeenCalledTimes(1); + + unsubscribe(); + }); + + it('ignores spotState events for a different user', async () => { + const unsubscribe = service.subscribeToAccount({ + callback: jest.fn(), + }); + await jest.runAllTimersAsync(); + + const observerCallback = jest.fn(); + const observerUnsubscribe = service.subscribeToAccount({ + callback: observerCallback, + }); + await jest.runAllTimersAsync(); + + const callsBefore = observerCallback.mock.calls.length; + + const spotListener = mockSubscriptionClient.spotState.mock.calls[0][1]; + spotListener({ + user: '0xdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef', + spotState: { balances: [] }, + }); + + expect(observerCallback.mock.calls.length).toBe(callsBefore); + + observerUnsubscribe(); + unsubscribe(); + }); + + it('refreshes abstraction mode per user when spotState ticks overlap account switches', async () => { + const userA = '0xaaa'; + const userB = '0xbbb'; + const accountA = 'eip155:42161:0xaaa' as CaipAccountId; + const accountB = 'eip155:42161:0xbbb' as CaipAccountId; + const spotState = { + balances: [ + { + coin: 'USDC', + token: 0, + hold: '0', + total: '100', + entryNtl: '100', + }, + ], + }; + const spotListeners = new Map void>(); + let resolveUserARefresh: (mode: 'unifiedAccount') => void = () => + undefined; + let userACalls = 0; + let userBCalls = 0; + const userAbstraction = jest.fn(({ user }: { user: string }) => { + const normalizedUser = user.toLowerCase(); + + if (normalizedUser === userA) { + userACalls += 1; + if (userACalls === 1) { + return Promise.resolve('unifiedAccount'); + } + return new Promise<'unifiedAccount'>((resolve) => { + resolveUserARefresh = resolve; + }); + } + + if (normalizedUser === userB) { + userBCalls += 1; + if (userBCalls === 1) { + return Promise.reject(new Error('transient userAbstraction error')); + } + return Promise.resolve('disabled'); + } + + return Promise.resolve('unifiedAccount'); + }); + + jest.mocked(adaptAccountStateFromSDK).mockImplementation(() => ({ + spendableBalance: '1000.00', + withdrawableBalance: '1000.00', + totalBalance: '10100.00', + marginUsed: '500.00', + unrealizedPnl: '100.00', + returnOnEquity: '20.0', + })); + mockSpotClearinghouseState.mockResolvedValue(spotState); + mockClientService.getInfoClient.mockReturnValue({ + spotClearinghouseState: mockSpotClearinghouseState, + userAbstraction, + } as any); + mockWalletService.getUserAddressWithDefault.mockImplementation( + async (accountId?: CaipAccountId) => + accountId === accountB ? (userB as Hex) : (userA as Hex), + ); + mockSubscriptionClient.spotState.mockImplementation( + (_params: any, callback: any) => { + spotListeners.set(_params.user.toLowerCase(), callback); + return Promise.resolve({ + unsubscribe: jest.fn().mockResolvedValue(undefined), + }); + }, + ); + + const unsubscribeA = service.subscribeToAccount({ + accountId: accountA, + callback: jest.fn(), + }); + await jest.runAllTimersAsync(); + + jest.advanceTimersByTime(ABSTRACTION_MODE_REFRESH_THROTTLE_MS + 1); + spotListeners.get(userA)?.({ user: userA, spotState }); + expect(userACalls).toBe(2); + + const callbackB = jest.fn(); + const unsubscribeB = service.subscribeToAccount({ + accountId: accountB, + callback: callbackB, + }); + await jest.runAllTimersAsync(); + + const bCallsBeforeTick = userBCalls; + spotListeners.get(userB)?.({ user: userB, spotState }); + await jest.runAllTimersAsync(); + + expect(bCallsBeforeTick).toBe(1); + expect(userBCalls).toBe(2); + expect(userAbstraction).toHaveBeenLastCalledWith({ user: userB }); + expect(callbackB.mock.calls.at(-1)[0].spendableBalance).toBe('1000'); + expect(callbackB.mock.calls.at(-1)[0].withdrawableBalance).toBe('1000'); + + resolveUserARefresh('unifiedAccount'); + await jest.runAllTimersAsync(); + + unsubscribeB(); + unsubscribeA(); + }); + + it('refreshes abstraction mode on the first spotState tick even inside the throttle window', async () => { + const user = '0xaaa'; + const accountId = 'eip155:42161:0xaaa' as CaipAccountId; + const callback = jest.fn(); + const spotState = { + balances: [ + { + coin: 'USDC', + token: 0, + hold: '0', + total: '100', + entryNtl: '100', + }, + ], + }; + const userAbstraction = jest + .fn() + .mockResolvedValueOnce('unifiedAccount') + .mockResolvedValueOnce('disabled'); + + jest.mocked(adaptAccountStateFromSDK).mockImplementation(() => ({ + spendableBalance: '1000.00', + withdrawableBalance: '1000.00', + totalBalance: '10100.00', + marginUsed: '500.00', + unrealizedPnl: '100.00', + returnOnEquity: '20.0', + })); + mockSpotClearinghouseState.mockResolvedValue(spotState); + mockClientService.getInfoClient.mockReturnValue({ + spotClearinghouseState: mockSpotClearinghouseState, + userAbstraction, + } as any); + mockWalletService.getUserAddressWithDefault.mockResolvedValue( + user as Hex, + ); + + service.subscribeToAccount({ accountId, callback }); + await jest.runAllTimersAsync(); + + expect(userAbstraction).toHaveBeenCalledTimes(1); + + const spotListener = mockSubscriptionClient.spotState.mock.calls[0][1]; + spotListener({ user, spotState }); + await jest.runAllTimersAsync(); + + expect(userAbstraction).toHaveBeenCalledTimes(2); + expect(userAbstraction).toHaveBeenLastCalledWith({ user }); + }); + + it('unsubscribes spotState when the last account subscriber leaves', async () => { + const unsubSpot = jest.fn().mockResolvedValue(undefined); + mockSubscriptionClient.spotState.mockResolvedValueOnce({ + unsubscribe: unsubSpot, + }); + + const unsubscribe = service.subscribeToAccount({ + callback: jest.fn(), + }); + await jest.runAllTimersAsync(); + + unsubscribe(); + await jest.runAllTimersAsync(); + + expect(unsubSpot).toHaveBeenCalled(); + }); + }); + + describe('setUserAbstractionMode', () => { + it('does not throw for an address with no prior cache entry', async () => { + expect(() => + service.setUserAbstractionMode('0x123', 'unifiedAccount'), + ).not.toThrow(); + }); + + it('lowercases the key so checksummed addresses hit the cached entry', async () => { + expect(() => + service.setUserAbstractionMode( + '0xABCDEF1234567890ABCDEF1234567890ABCDEF12', + 'unifiedAccount', + ), + ).not.toThrow(); + }); + + it('flips the fold state and notifies subscribers when the mode changes', async () => { + // Start without a resolved mode — the spot WS push and REST fetch run + // through the standard subscribeToAccount path. The default mock + // resolves userAbstraction = 'unifiedAccount' so the initial subscribe + // already records that mode and folds spot. Setting back to + // dexAbstraction should flip the fold off and re-notify. + mockClientService.getInfoClient = jest.fn(() => ({ + spotClearinghouseState: mockSpotClearinghouseState, + userAbstraction: jest.fn().mockResolvedValue('unifiedAccount'), + })) as never; + + const accountCallback = jest.fn(); + const unsubscribe = service.subscribeToAccount({ + callback: accountCallback, + }); + await jest.runAllTimersAsync(); + + expect(accountCallback).toHaveBeenCalled(); + accountCallback.mockClear(); + + // Switch the recorded mode to dexAbstraction (no fold). Account state + // hash flips because spendable/withdrawable drop the folded spot. + service.setUserAbstractionMode('0x123', 'dexAbstraction'); + await jest.runAllTimersAsync(); + + expect(accountCallback).toHaveBeenCalled(); + const lastCall = accountCallback.mock.calls.at(-1)?.[0]; + expect(lastCall?.spendableBalance).toBeDefined(); + expect(lastCall?.withdrawableBalance).toBeDefined(); + + unsubscribe(); + }); + }); + + describe('userAbstraction fetch failure handling', () => { + it('does not seal the spot cache when userAbstraction fails, so the next refresh retries', async () => { + // Without this guard, a transient userAbstraction failure leaves + // #cachedSpotStateUserAddress set, the early-return in #ensureSpotState + // takes the fast path forever, and Standard / dexAbstraction users + // keep seeing spot folded into availableToTradeBalance via the + // fail-open Unified default. + const userAbstractionMock = jest + .fn() + .mockRejectedValueOnce(new Error('transient HL outage')) + .mockResolvedValueOnce('dexAbstraction'); + + mockClientService.getInfoClient = jest.fn(() => ({ + spotClearinghouseState: mockSpotClearinghouseState, + userAbstraction: userAbstractionMock, + })) as never; + + const unsub1 = service.subscribeToAccount({ callback: jest.fn() }); + await jest.runAllTimersAsync(); + expect(userAbstractionMock).toHaveBeenCalledTimes(1); + + // Second subscribe (same user) must trigger another refresh because + // the prior failure left the cache unsealed. + const unsub2 = service.subscribeToAccount({ callback: jest.fn() }); + await jest.runAllTimersAsync(); + expect(userAbstractionMock).toHaveBeenCalledTimes(2); + + unsub1(); + unsub2(); + }); + + it('seals the cache normally once a prior abstraction mode has been resolved', async () => { + // Sanity check: when userAbstraction has already resolved successfully, + // a subsequent refresh failure must not force pointless retries. + const userAbstractionMock = jest + .fn() + .mockResolvedValueOnce('dexAbstraction') + .mockRejectedValueOnce(new Error('transient HL outage')); + + mockClientService.getInfoClient = jest.fn(() => ({ + spotClearinghouseState: mockSpotClearinghouseState, + userAbstraction: userAbstractionMock, + })) as never; + + const unsub1 = service.subscribeToAccount({ callback: jest.fn() }); + await jest.runAllTimersAsync(); + expect(userAbstractionMock).toHaveBeenCalledTimes(1); + + // Subsequent subscribe takes the early-return path; the second mock + // entry (the rejection) is never consumed. + const unsub2 = service.subscribeToAccount({ callback: jest.fn() }); + await jest.runAllTimersAsync(); + expect(userAbstractionMock).toHaveBeenCalledTimes(1); + + unsub1(); + unsub2(); + }); + }); + + describe('spot-adjusted account balance parity', () => { + it('includes spot balance exactly once in streamed totalBalance across multiple DEXs', async () => { + jest.mocked(adaptAccountStateFromSDK).mockImplementation(() => ({ + spendableBalance: '0', + withdrawableBalance: '0', + totalBalance: '0', + marginUsed: '0', + unrealizedPnl: '0', + returnOnEquity: '0', + })); + + mockSubscriptionClient.clearinghouseState.mockImplementation( + (_params: any, callback: any) => { + setTimeout(() => { + callback({ + dex: _params.dex || '', + clearinghouseState: { + assetPositions: [], + marginSummary: { + accountValue: '0', + totalMarginUsed: '0', + }, + withdrawable: '0', + }, + }); + }, 0); + return Promise.resolve({ + unsubscribe: jest.fn().mockResolvedValue(undefined), + }); + }, + ); + mockSubscriptionClient.openOrders.mockImplementation( + (_params: any, callback: any) => { + setTimeout(() => callback({ dex: _params.dex || '', orders: [] }), 0); + return Promise.resolve({ + unsubscribe: jest.fn().mockResolvedValue(undefined), + }); + }, + ); + + const hip3Service = new HyperLiquidSubscriptionService( + mockClientService, + mockWalletService, + mockDeps, + true, + ); + + await hip3Service.updateFeatureFlags(true, ['xyz'], [], []); + + const mockCallback = jest.fn(); + const unsubscribe = hip3Service.subscribeToAccount({ + callback: mockCallback, + }); + + await jest.runAllTimersAsync(); + + expect(mockCallback).toHaveBeenCalled(); + const accountState = mockCallback.mock.calls.at(-1)[0]; + // Unified-mode default: spot USDC folds into total, spendable, and + // withdrawable (all three carry the same value when perps balances + // are zero). Per-DEX subAccountBreakdown entries stay perps-only — + // the fold is applied once at the aggregation level, not per DEX. + expect(accountState.totalBalance).toBe('100.76531791'); + expect(accountState.spendableBalance).toBe('100.76531791'); + expect(accountState.withdrawableBalance).toBe('100.76531791'); + expect(accountState.subAccountBreakdown).toEqual({ + main: { + spendableBalance: '0', + withdrawableBalance: '0', + totalBalance: '0', + }, + xyz: { + spendableBalance: '0', + withdrawableBalance: '0', + totalBalance: '0', + }, + }); + expect(mockSpotClearinghouseState).toHaveBeenCalledTimes(1); + + unsubscribe(); + }); + + it('does not use non-USDC spot coins in streamed spendable/withdrawable', async () => { + jest.mocked(adaptAccountStateFromSDK).mockImplementation(() => ({ + spendableBalance: '0', + withdrawableBalance: '0', + totalBalance: '0', + marginUsed: '0', + unrealizedPnl: '0', + returnOnEquity: '0', + })); + + const infoClient = { + spotClearinghouseState: jest.fn().mockResolvedValue({ + balances: [ + { coin: 'mUSD', hold: '10', total: '100' }, + { coin: 'HYPE', hold: '0', total: '999' }, + ], + }), + userAbstraction: jest.fn().mockResolvedValue('unifiedAccount'), + }; + mockClientService.getInfoClient = jest.fn(() => infoClient) as never; + + mockSubscriptionClient.clearinghouseState.mockImplementation( + (_params: any, callback: any) => { + setTimeout(() => { + callback({ + dex: _params.dex || '', + clearinghouseState: { + assetPositions: [], + marginSummary: { + accountValue: '0', + totalMarginUsed: '0', + }, + withdrawable: '0', + }, + }); + }, 0); + return Promise.resolve({ + unsubscribe: jest.fn().mockResolvedValue(undefined), + }); + }, + ); + mockSubscriptionClient.openOrders.mockImplementation( + (_params: any, callback: any) => { + setTimeout(() => callback({ dex: _params.dex || '', orders: [] }), 0); + return Promise.resolve({ + unsubscribe: jest.fn().mockResolvedValue(undefined), + }); + }, + ); + + const hip3Service = new HyperLiquidSubscriptionService( + mockClientService, + mockWalletService, + mockDeps, + true, + ); + + await hip3Service.updateFeatureFlags(true, ['xyz'], [], []); + + const mockCallback = jest.fn(); + const unsubscribe = hip3Service.subscribeToAccount({ + callback: mockCallback, + }); + + await jest.runAllTimersAsync(); + + const accountState = mockCallback.mock.calls.at(-1)[0]; + expect(accountState.spendableBalance).toBe('0'); + expect(accountState.withdrawableBalance).toBe('0'); + expect(accountState.totalBalance).toBe('0'); + + unsubscribe(); + }); + + it('includes spot balance in single-DEX account updates without flickering', async () => { + jest.mocked(adaptAccountStateFromSDK).mockImplementation(() => ({ + spendableBalance: '50', + withdrawableBalance: '50', + totalBalance: '200', + marginUsed: '10', + unrealizedPnl: '5', + returnOnEquity: '0.05', + })); + + // HIP-3 disabled now uses the per-DEX clearinghouseState subscription + // (not the deprecated webData2 channel) for account updates. + const clearinghouseData = { + dex: '', + clearinghouseState: { + assetPositions: [], + marginSummary: { + accountValue: '200', + totalMarginUsed: '10', + }, + withdrawable: '50', + }, + }; + + let clearinghouseCallback: ((data: any) => void) | undefined; + mockSubscriptionClient.clearinghouseState.mockImplementation( + (_params: any, callback: any) => { + clearinghouseCallback = callback; + setTimeout(() => callback(clearinghouseData), 0); + return Promise.resolve({ + unsubscribe: jest.fn().mockResolvedValue(undefined), + }); + }, + ); + + const singleDexService = new HyperLiquidSubscriptionService( + mockClientService, + mockWalletService, + mockDeps, + false, + ); + + const mockCallback = jest.fn(); + const unsubscribe = singleDexService.subscribeToAccount({ + callback: mockCallback, + }); + + await jest.runAllTimersAsync(); + + expect(mockSubscriptionClient.webData2).not.toHaveBeenCalled(); + expect(mockCallback).toHaveBeenCalled(); + const firstUpdate = mockCallback.mock.calls.at(-1)[0]; + // Unified-mode default: freeSpot ($100.77) folds into spendable and + // withdrawable on top of perps-side values, and into total. + // total = perps.accountValue (200) + spot (100.77) = 300.77 + // spendable = perps.withdrawable (50) + spot (100.77) = 150.77 + // withdrawable = perps.withdrawable (50) + spot (100.77) = 150.77 + expect(firstUpdate.totalBalance).toBe('300.76531791'); + expect(firstUpdate.spendableBalance).toBe('150.76531791'); + expect(firstUpdate.withdrawableBalance).toBe('150.76531791'); + + // Simulate a second WebSocket tick — should still include spot balance, + // not revert to perps-only 200. + mockCallback.mockClear(); + expect(clearinghouseCallback).toBeDefined(); + + clearinghouseCallback!(clearinghouseData); + + await jest.runAllTimersAsync(); + + if (mockCallback.mock.calls.length > 0) { + const secondUpdate = mockCallback.mock.calls.at(-1)[0]; + expect(secondUpdate.totalBalance).toBe('300.76531791'); + } + + unsubscribe(); + }); + }); + + describe('aggregateAccountStates - returnOnEquity calculation', () => { + it('calculates positive ROE when unrealizedPnl is positive', async () => { + // Override the adapter mock + jest.mocked(adaptAccountStateFromSDK).mockImplementation(() => ({ + spendableBalance: '100', + withdrawableBalance: '100', + totalBalance: '1100', + marginUsed: '1000', + unrealizedPnl: '100', + returnOnEquity: '10.0', + })); + + const mockCallback = jest.fn(); + + // Mock webData3 + mockSubscriptionClient.webData3.mockImplementation( + (_params: any, callback: any) => { + const mockData = { + perpDexStates: [ + { + clearinghouseState: { assetPositions: [] }, + openOrders: [], + perpsAtOpenInterestCap: [], + }, + ], + }; + + setTimeout(() => callback(mockData), 10); + return { unsubscribe: jest.fn() }; + }, + ); + + const unsubscribe = service.subscribeToAccount({ + callback: mockCallback, + }); + + await jest.runAllTimersAsync(); + + expect(mockCallback).toHaveBeenCalled(); + const accountState = mockCallback.mock.calls[0][0]; + expect(accountState.marginUsed).toBe('1000'); + expect(accountState.unrealizedPnl).toBe('100'); + expect(accountState.returnOnEquity).toBe('10'); + + unsubscribe(); + }); + + it('calculates negative ROE when unrealizedPnl is negative', async () => { + // Override the adapter mock + jest.mocked(adaptAccountStateFromSDK).mockImplementation(() => ({ + spendableBalance: '0', + withdrawableBalance: '0', + totalBalance: '950', + marginUsed: '1000', + unrealizedPnl: '-50', + returnOnEquity: '-5.0', + })); + + const mockCallback = jest.fn(); + + // Mock webData3 + mockSubscriptionClient.webData3.mockImplementation( + (_params: any, callback: any) => { + const mockData = { + perpDexStates: [ + { + clearinghouseState: { assetPositions: [] }, + openOrders: [], + perpsAtOpenInterestCap: [], + }, + ], + }; + + setTimeout(() => callback(mockData), 10); + return { unsubscribe: jest.fn() }; + }, + ); + + const unsubscribe = service.subscribeToAccount({ + callback: mockCallback, + }); + + await jest.runAllTimersAsync(); + + expect(mockCallback).toHaveBeenCalled(); + const accountState = mockCallback.mock.calls[0][0]; + expect(accountState.marginUsed).toBe('1000'); + expect(accountState.unrealizedPnl).toBe('-50'); + expect(accountState.returnOnEquity).toBe('-5'); + + unsubscribe(); + }); + + it('returns zero ROE when marginUsed is zero', async () => { + // Override the adapter mock + jest.mocked(adaptAccountStateFromSDK).mockImplementation(() => ({ + spendableBalance: '1000', + withdrawableBalance: '1000', + totalBalance: '1000', + marginUsed: '0', + unrealizedPnl: '0', + returnOnEquity: '0', + })); + + const mockCallback = jest.fn(); + + // Mock webData3 + mockSubscriptionClient.webData3.mockImplementation( + (_params: any, callback: any) => { + const mockData = { + perpDexStates: [ + { + clearinghouseState: { assetPositions: [] }, + openOrders: [], + perpsAtOpenInterestCap: [], + }, + ], + }; + + setTimeout(() => callback(mockData), 10); + return { unsubscribe: jest.fn() }; + }, + ); + + const unsubscribe = service.subscribeToAccount({ + callback: mockCallback, + }); + + await jest.runAllTimersAsync(); + + expect(mockCallback).toHaveBeenCalled(); + const accountState = mockCallback.mock.calls[0][0]; + expect(accountState.marginUsed).toBe('0'); + expect(accountState.unrealizedPnl).toBe('0'); + expect(accountState.returnOnEquity).toBe('0'); + + unsubscribe(); + }); + + it('calculates correct ROE with mixed profit and loss positions', async () => { + // Override the adapter mock + jest.mocked(adaptAccountStateFromSDK).mockImplementation(() => ({ + spendableBalance: '75', + withdrawableBalance: '75', + totalBalance: '1575', + marginUsed: '1500', + unrealizedPnl: '75', + returnOnEquity: '5.0', + })); + + const mockCallback = jest.fn(); + + // Mock webData3 - simulates account with multiple positions + // marginUsed=1500, unrealizedPnl=75 → ROE=5.0% + mockSubscriptionClient.webData3.mockImplementation( + (_params: any, callback: any) => { + const mockData = { + perpDexStates: [ + { + clearinghouseState: { assetPositions: [] }, + openOrders: [], + perpsAtOpenInterestCap: [], + }, + ], + }; + + setTimeout(() => callback(mockData), 10); + return { unsubscribe: jest.fn() }; + }, + ); + + const unsubscribe = service.subscribeToAccount({ + callback: mockCallback, + }); + + await jest.runAllTimersAsync(); + + expect(mockCallback).toHaveBeenCalled(); + const accountState = mockCallback.mock.calls[0][0]; + expect(accountState.marginUsed).toBe('1500'); + expect(accountState.unrealizedPnl).toBe('75'); + expect(accountState.returnOnEquity).toBe('5'); + + unsubscribe(); + }); + + it('calculates high ROE with large percentage gains', async () => { + // Override the adapter mock + jest.mocked(adaptAccountStateFromSDK).mockImplementation(() => ({ + spendableBalance: '200', + withdrawableBalance: '200', + totalBalance: '300', + marginUsed: '100', + unrealizedPnl: '200', + returnOnEquity: '200.0', + })); + + const mockCallback = jest.fn(); + + // Mock webData3 + mockSubscriptionClient.webData3.mockImplementation( + (_params: any, callback: any) => { + const mockData = { + perpDexStates: [ + { + clearinghouseState: { assetPositions: [] }, + openOrders: [], + perpsAtOpenInterestCap: [], + }, + ], + }; + + setTimeout(() => callback(mockData), 10); + return { unsubscribe: jest.fn() }; + }, + ); + + const unsubscribe = service.subscribeToAccount({ + callback: mockCallback, + }); + + await jest.runAllTimersAsync(); + + expect(mockCallback).toHaveBeenCalled(); + const accountState = mockCallback.mock.calls[0][0]; + expect(accountState.marginUsed).toBe('100'); + expect(accountState.unrealizedPnl).toBe('200'); + expect(accountState.returnOnEquity).toBe('200'); + + unsubscribe(); + }); + + it('stores raw ROE without rounding', async () => { + // Override the adapter mock + jest.mocked(adaptAccountStateFromSDK).mockImplementation(() => ({ + spendableBalance: '100', + withdrawableBalance: '100', + totalBalance: '433', + marginUsed: '333', + unrealizedPnl: '100', + returnOnEquity: '30.0', + })); + + const mockCallback = jest.fn(); + + // Mock webData3 + mockSubscriptionClient.webData3.mockImplementation( + (_params: any, callback: any) => { + const mockData = { + perpDexStates: [ + { + clearinghouseState: { assetPositions: [] }, + openOrders: [], + perpsAtOpenInterestCap: [], + }, + ], + }; + + setTimeout(() => callback(mockData), 10); + return { unsubscribe: jest.fn() }; + }, + ); + + const unsubscribe = service.subscribeToAccount({ + callback: mockCallback, + }); + + await jest.runAllTimersAsync(); + + expect(mockCallback).toHaveBeenCalled(); + const accountState = mockCallback.mock.calls[0][0]; + expect(accountState.marginUsed).toBe('333'); + expect(accountState.unrealizedPnl).toBe('100'); + expect(accountState.returnOnEquity).toBe('30'); + + unsubscribe(); + }); + }); + + describe('restoreSubscriptions', () => { + it('restores allMids subscription when price subscribers exist', async () => { + const callback = jest.fn(); + const mockUnsubscribe = jest.fn(); + const mockSubscription = { unsubscribe: mockUnsubscribe }; + + // Subscribe to prices first + mockSubscriptionClient.allMids.mockImplementation((cb: any) => { + setTimeout(() => { + cb({ mids: { BTC: '50000' } }); + }, 10); + return Promise.resolve(mockSubscription); + }); + + const unsubscribe = await service.subscribeToPrices({ + symbols: ['BTC'], + callback, + }); + + await jest.runAllTimersAsync(); + + // Clear the subscription reference to simulate reconnection + (service as any).globalAllMidsSubscription = undefined; + (service as any).globalAllMidsPromise = undefined; + + // Restore subscriptions + await service.restoreSubscriptions(); + + // Verify allMids subscription was re-established + expect(mockSubscriptionClient.allMids).toHaveBeenCalledTimes(2); + + unsubscribe(); + }); + + it('does not restore allMids subscription when no price subscribers exist', async () => { + // No subscriptions created + + await service.restoreSubscriptions(); + + // Verify allMids was not called + expect(mockSubscriptionClient.allMids).not.toHaveBeenCalled(); + }); + + it('restores fastAssetCtxs subscription when price subscribers exist (TAT-3387)', async () => { + const callback = jest.fn(); + + const unsubscribe = await service.subscribeToPrices({ + symbols: ['BTC'], + callback, + }); + + await jest.runAllTimersAsync(); + + expect(mockSubscriptionClient.fastAssetCtxs).toHaveBeenCalledTimes(1); + + // Restore subscriptions (simulates reconnection) + await service.restoreSubscriptions(); + + // Verify fastAssetCtxs subscription was re-established alongside allMids + expect(mockSubscriptionClient.fastAssetCtxs).toHaveBeenCalledTimes(2); + + unsubscribe(); + }); + + it('does not restore fastAssetCtxs subscription when no price subscribers exist', async () => { + // No subscriptions created + + await service.restoreSubscriptions(); + + expect(mockSubscriptionClient.fastAssetCtxs).not.toHaveBeenCalled(); + }); + + // TODO: Refactor to test restoreSubscriptions through public disconnect/reconnect API + + it.skip('restores webData3 subscription when user data subscribers exist', async () => { + const positionCallback = jest.fn(); + const mockUnsubscribe = jest.fn().mockResolvedValue(undefined); + + // Simulate DEX discovery to skip the wait + await service.updateFeatureFlags(true, [''], [], []); + + mockSubscriptionClient.webData3.mockImplementation( + (_params: any, callback: any) => { + setTimeout(() => { + callback({ + perpDexStates: [ + { + clearinghouseState: { assetPositions: [] }, + openOrders: [], + perpDexStates: [], + }, + ], + }); + }, 10); + return Promise.resolve({ unsubscribe: mockUnsubscribe }); + }, + ); + + const unsubscribe = await service.subscribeToPositions({ + callback: positionCallback, + }); + + await jest.runAllTimersAsync(); + + // Clear subscription references to simulate reconnection + (service as any).webData3Subscriptions.clear(); + (service as any).webData3SubscriptionPromise = undefined; + + // Restore subscriptions + await service.restoreSubscriptions(); + + // Verify webData3 subscription was re-established + expect(mockSubscriptionClient.webData3).toHaveBeenCalledTimes(2); + + // Cleanup + unsubscribe(); + }); + + // TODO: Refactor to test through public disconnect/reconnect API + + it.skip('restores activeAsset subscriptions for all market data subscribers', async () => { + const marketDataCallback = jest.fn(); + const mockUnsubscribe = jest.fn(); + const mockSubscription = { unsubscribe: mockUnsubscribe }; + + mockSubscriptionClient.activeAssetCtx.mockImplementation( + (params: any, callback: any) => { + setTimeout(() => { + callback({ + coin: params.coin, + ctx: { + prevDayPx: '49000', + funding: '0.01', + openInterest: '1000000', + dayNtlVlm: '50000000', + oraclePx: '50100', + midPx: '50000', + }, + }); + }, 10); + return Promise.resolve(mockSubscription); + }, + ); + + // Subscribe to market data for multiple symbols + const unsubscribe1 = await service.subscribeToPrices({ + symbols: ['BTC'], + callback: marketDataCallback, + includeMarketData: true, + }); + const unsubscribe2 = await service.subscribeToPrices({ + symbols: ['ETH'], + callback: marketDataCallback, + includeMarketData: true, + }); + + await jest.runAllTimersAsync(); + + // Clear subscriptions to simulate reconnection + (service as any).globalActiveAssetSubscriptions.clear(); + + // Restore subscriptions + await service.restoreSubscriptions(); + + // Verify activeAssetCtx was called for each symbol (2 initial + 2 restored) + expect(mockSubscriptionClient.activeAssetCtx).toHaveBeenCalledTimes(4); + + unsubscribe1(); + unsubscribe2(); + }); + + // TODO: Refactor to test through public disconnect/reconnect API + + it.skip('clears BBO subscriptions during restoration', async () => { + const mockUnsubscribe = jest.fn().mockResolvedValue(undefined); + const mockSubscription = { unsubscribe: mockUnsubscribe }; + let subscriptionCallCount = 0; + + mockSubscriptionClient.bbo.mockImplementation( + (_params: any, bboCallback: any) => { + subscriptionCallCount++; + setTimeout(() => { + bboCallback({ + coin: _params.coin, + time: Date.now(), + bbo: [ + { px: '49900', sz: '1.5', n: 1 }, + { px: '50100', sz: '2.0', n: 1 }, + ], + }); + }, 10); + return Promise.resolve(mockSubscription); + }, + ); + + const unsubscribe = await service.subscribeToPrices({ + symbols: ['BTC'], + callback: jest.fn(), + includeOrderBook: true, + }); + + await jest.runAllTimersAsync(); + + // Verify initial subscription was created + expect((service as any).globalBboSubscriptions.size).toBe(1); + const initialCallCount = subscriptionCallCount; + + // Set up a different subscription reference to verify it's cleared + const oldSubscription = { unsubscribe: jest.fn() }; + (service as any).globalBboSubscriptions.set('BTC', oldSubscription); + + // Restore subscriptions + await service.restoreSubscriptions(); + + await jest.runAllTimersAsync(); + + // Verify old subscription was cleared and new one was re-established + // The map should have the new subscription, not the old one + const currentSubscription = (service as any).globalBboSubscriptions.get( + 'BTC', + ); + expect(currentSubscription).toBeDefined(); + expect(currentSubscription).not.toBe(oldSubscription); + // Verify bbo was called again to re-establish the subscription + expect(subscriptionCallCount).toBeGreaterThan(initialCallCount); + + unsubscribe(); + }); + + it('schedules retry when assetCtxs restoration fails', async () => { + jest.mocked(parseAssetName).mockImplementation((symbol: string) => ({ + symbol, + dex: symbol === 'BTC:UNISWAP' ? 'UNISWAP' : null, + })); + + mockClientService.getInfoClient = jest.fn( + () => + ({ + meta: jest.fn().mockResolvedValue({ + universe: [{ name: 'BTC:UNISWAP' }], + }), + }) as any, + ); + + mockSubscriptionClient.assetCtxs = jest + .fn() + .mockResolvedValueOnce({ + unsubscribe: jest.fn().mockResolvedValue(undefined), + }) + .mockRejectedValueOnce(new Error('Subscription failed')) + .mockResolvedValueOnce({ + unsubscribe: jest.fn().mockResolvedValue(undefined), + }); + + const unsubscribe = await service.subscribeToPrices({ + symbols: ['BTC:UNISWAP'], + callback: jest.fn(), + includeMarketData: true, + }); + + await jest.runAllTimersAsync(); + + await expect(service.restoreSubscriptions()).resolves.not.toThrow(); + expect(mockSubscriptionClient.assetCtxs).toHaveBeenCalledTimes(2); + + await jest.advanceTimersByTimeAsync(1000); + + expect(mockSubscriptionClient.assetCtxs).toHaveBeenCalledTimes(3); + expect(mockDeps.logger.error).toHaveBeenCalled(); + + unsubscribe(); + }); + + // TODO: Refactor to test through public disconnect/reconnect API + + it.skip('restores all subscription types when multiple subscriber types exist', async () => { + const priceCallback = jest.fn(); + const positionCallback = jest.fn(); + const allTypesMarketDataCallback = jest.fn(); + const mockUnsubscribe = jest.fn().mockResolvedValue(undefined); + const mockSubscription = { unsubscribe: mockUnsubscribe }; + + // Simulate DEX discovery to skip the wait + await service.updateFeatureFlags(true, [''], [], []); + + mockSubscriptionClient.allMids.mockImplementation((cb: any) => { + setTimeout(() => { + cb({ mids: { BTC: '50000' } }); + }, 10); + return Promise.resolve(mockSubscription); + }); + + mockSubscriptionClient.webData3.mockImplementation( + (_params: any, callback: any) => { + setTimeout(() => { + callback({ + perpDexStates: [ + { + clearinghouseState: { assetPositions: [] }, + openOrders: [], + perpDexStates: [], + }, + ], + }); + }, 10); + return Promise.resolve(mockSubscription); + }, + ); + + mockSubscriptionClient.activeAssetCtx.mockImplementation( + (params: any, callback: any) => { + setTimeout(() => { + callback({ + coin: params.coin, + ctx: { + prevDayPx: '49000', + funding: '0.01', + openInterest: '1000000', + dayNtlVlm: '50000000', + oraclePx: '50100', + midPx: '50000', + }, + }); + }, 10); + return Promise.resolve(mockSubscription); + }, + ); + + // Create subscriptions for all types + const unsubscribe1 = await service.subscribeToPrices({ + symbols: ['BTC'], + callback: priceCallback, + }); + const unsubscribe2 = await service.subscribeToPositions({ + callback: positionCallback, + }); + const unsubscribe3 = await service.subscribeToPrices({ + symbols: ['ETH'], + callback: allTypesMarketDataCallback, + includeMarketData: true, + }); + + await jest.runAllTimersAsync(); + + // Clear all subscription references + (service as any).globalAllMidsSubscription = undefined; + (service as any).globalAllMidsPromise = undefined; + (service as any).webData3Subscriptions.clear(); + (service as any).webData3SubscriptionPromise = undefined; + (service as any).globalActiveAssetSubscriptions.clear(); + + // Restore all subscriptions + await service.restoreSubscriptions(); + + // Verify all subscription types were restored + expect(mockSubscriptionClient.allMids).toHaveBeenCalledTimes(2); + expect(mockSubscriptionClient.webData3).toHaveBeenCalledTimes(2); + expect(mockSubscriptionClient.activeAssetCtx).toHaveBeenCalledTimes(2); + + unsubscribe1(); + unsubscribe2(); + unsubscribe3(); + }); + }); + + describe('subscribeToOrderBook (L2Book)', () => { + it('should subscribe to L2Book with correct params', async () => { + const mockCallback = jest.fn(); + const mockL2BookSubscription = { + unsubscribe: jest.fn().mockResolvedValue(undefined), + }; + + mockSubscriptionClient.l2Book.mockImplementation( + (_params: any, callback: any) => { + setTimeout(() => { + callback({ + coin: 'BTC', + levels: [ + [{ px: '49900', sz: '1.5', n: 3 }], + [{ px: '50100', sz: '2.0', n: 5 }], + ], + }); + }, 0); + return Promise.resolve(mockL2BookSubscription); + }, + ); + + const params: SubscribeOrderBookParams = { + symbol: 'BTC', + levels: 10, + nSigFigs: 5, + callback: mockCallback, + }; + + const unsubscribe = service.subscribeToOrderBook(params); + + await jest.runAllTimersAsync(); + + expect(mockSubscriptionClient.l2Book).toHaveBeenCalledWith( + { coin: 'BTC', nSigFigs: 5, mantissa: undefined, fast: undefined }, + expect.any(Function), + ); + + expect(typeof unsubscribe).toBe('function'); + }); + + it('should process L2Book data and call callback with OrderBookData', async () => { + const mockCallback = jest.fn(); + const mockL2BookSubscription = { + unsubscribe: jest.fn().mockResolvedValue(undefined), + }; + + mockSubscriptionClient.l2Book.mockImplementation( + (_params: any, callback: any) => { + setTimeout(() => { + callback({ + coin: 'BTC', + levels: [ + [ + { px: '49900', sz: '1.0', n: 2 }, + { px: '49800', sz: '2.0', n: 3 }, + ], + [ + { px: '50100', sz: '1.5', n: 4 }, + { px: '50200', sz: '2.5', n: 5 }, + ], + ], + }); + }, 0); + return Promise.resolve(mockL2BookSubscription); + }, + ); + + service.subscribeToOrderBook({ + symbol: 'BTC', + levels: 10, + callback: mockCallback, + }); + + await jest.runAllTimersAsync(); + + expect(mockCallback).toHaveBeenCalledWith( + expect.objectContaining({ + bids: expect.arrayContaining([ + expect.objectContaining({ + price: '49900', + size: '1.0', + }), + ]), + asks: expect.arrayContaining([ + expect.objectContaining({ + price: '50100', + size: '1.5', + }), + ]), + spread: expect.any(String), + spreadPercentage: expect.any(String), + midPrice: expect.any(String), + lastUpdated: expect.any(Number), + maxTotal: expect.any(String), + }), + ); + }); + + it('should unsubscribe when cleanup function is called', async () => { + const mockCallback = jest.fn(); + const mockUnsubscribe = jest.fn().mockResolvedValue(undefined); + + mockSubscriptionClient.l2Book.mockImplementation( + (_params: any, callback: any) => { + setTimeout(() => { + callback({ + coin: 'BTC', + levels: [ + [{ px: '49900', sz: '1.5', n: 3 }], + [{ px: '50100', sz: '2.0', n: 5 }], + ], + }); + }, 0); + return Promise.resolve({ unsubscribe: mockUnsubscribe }); + }, + ); + + const unsubscribe = service.subscribeToOrderBook({ + symbol: 'BTC', + callback: mockCallback, + }); + + await jest.runAllTimersAsync(); + + // Unsubscribe + unsubscribe(); + + await jest.runAllTimersAsync(); + + expect(mockUnsubscribe).toHaveBeenCalled(); + }); + + it('should call onError callback when subscription fails', async () => { + const mockCallback = jest.fn(); + const mockOnError = jest.fn(); + + mockSubscriptionClient.l2Book.mockRejectedValue( + new Error('L2Book subscription failed'), + ); + + service.subscribeToOrderBook({ + symbol: 'BTC', + callback: mockCallback, + onError: mockOnError, + }); + + await jest.runAllTimersAsync(); + + expect(mockOnError).toHaveBeenCalledWith( + expect.objectContaining({ + message: 'L2Book subscription failed', + }), + ); + }); + + it('should handle subscription client not available', async () => { + mockClientService.getSubscriptionClient.mockReturnValue(undefined); + + const mockCallback = jest.fn(); + const mockOnError = jest.fn(); + + const unsubscribe = service.subscribeToOrderBook({ + symbol: 'BTC', + callback: mockCallback, + onError: mockOnError, + }); + + await jest.runAllTimersAsync(); + + // Should call onError with appropriate message + expect(mockOnError).toHaveBeenCalledWith( + expect.objectContaining({ + message: 'Subscription client not available', + }), + ); + + // Should return a no-op unsubscribe function + expect(typeof unsubscribe).toBe('function'); + expect(mockSubscriptionClient.l2Book).not.toHaveBeenCalled(); + }); + + it('should handle missing levels gracefully', async () => { + const mockCallback = jest.fn(); + + mockSubscriptionClient.l2Book.mockImplementation( + (_params: any, callback: any) => { + setTimeout(() => { + callback({ + coin: 'BTC', + levels: undefined, + }); + }, 0); + return Promise.resolve({ + unsubscribe: jest.fn().mockResolvedValue(undefined), + }); + }, + ); + + service.subscribeToOrderBook({ + symbol: 'BTC', + callback: mockCallback, + }); + + await jest.runAllTimersAsync(); + + // Should not crash - callback should not be called for invalid data + // (the implementation checks for data?.levels being truthy) + expect(mockCallback).not.toHaveBeenCalled(); + }); + + it('should ignore data for different coins', async () => { + const mockCallback = jest.fn(); + + mockSubscriptionClient.l2Book.mockImplementation( + (_params: any, callback: any) => { + // First send data for wrong coin + setTimeout(() => { + callback({ + coin: 'ETH', + levels: [ + [{ px: '2900', sz: '10', n: 1 }], + [{ px: '3000', sz: '20', n: 1 }], + ], + }); + }, 0); + // Then send data for correct coin + setTimeout(() => { + callback({ + coin: 'BTC', + levels: [ + [{ px: '49900', sz: '1.5', n: 3 }], + [{ px: '50100', sz: '2.0', n: 5 }], + ], + }); + }, 10); + return Promise.resolve({ + unsubscribe: jest.fn().mockResolvedValue(undefined), + }); + }, + ); + + service.subscribeToOrderBook({ + symbol: 'BTC', + callback: mockCallback, + }); + + await jest.runAllTimersAsync(); + + // Should only receive data for BTC, not ETH + expect(mockCallback).toHaveBeenCalledTimes(1); + expect(mockCallback).toHaveBeenCalledWith( + expect.objectContaining({ + bids: expect.arrayContaining([ + expect.objectContaining({ price: '49900' }), + ]), + }), + ); + }); + + it('should pass mantissa parameter when provided', async () => { + const mockCallback = jest.fn(); + + mockSubscriptionClient.l2Book.mockImplementation( + (_params: any, callback: any) => { + setTimeout(() => { + callback({ + coin: 'BTC', + levels: [ + [{ px: '49900', sz: '1.5', n: 3 }], + [{ px: '50100', sz: '2.0', n: 5 }], + ], + }); + }, 0); + return Promise.resolve({ + unsubscribe: jest.fn().mockResolvedValue(undefined), + }); + }, + ); + + service.subscribeToOrderBook({ + symbol: 'BTC', + nSigFigs: 5, + mantissa: 2, + callback: mockCallback, + }); + + await jest.runAllTimersAsync(); + + expect(mockSubscriptionClient.l2Book).toHaveBeenCalledWith( + { coin: 'BTC', nSigFigs: 5, mantissa: 2 }, + expect.any(Function), + ); + }); + + it('should calculate cumulative totals correctly', async () => { + const mockCallback = jest.fn(); + + mockSubscriptionClient.l2Book.mockImplementation( + (_params: any, callback: any) => { + setTimeout(() => { + callback({ + coin: 'BTC', + levels: [ + [ + { px: '50000', sz: '1.0', n: 1 }, + { px: '49900', sz: '2.0', n: 1 }, + { px: '49800', sz: '3.0', n: 1 }, + ], + [ + { px: '50100', sz: '0.5', n: 1 }, + { px: '50200', sz: '1.5', n: 1 }, + ], + ], + }); + }, 0); + return Promise.resolve({ + unsubscribe: jest.fn().mockResolvedValue(undefined), + }); + }, + ); + + service.subscribeToOrderBook({ + symbol: 'BTC', + levels: 10, + callback: mockCallback, + }); + + await jest.runAllTimersAsync(); + + const orderBookData = mockCallback.mock.calls[0][0]; + + // Verify cumulative bid totals: 1.0, 3.0, 6.0 + expect(parseFloat(orderBookData.bids[0].total)).toBe(1); + expect(parseFloat(orderBookData.bids[1].total)).toBe(3); + expect(parseFloat(orderBookData.bids[2].total)).toBe(6); + + // Verify cumulative ask totals: 0.5, 2.0 + expect(parseFloat(orderBookData.asks[0].total)).toBe(0.5); + expect(parseFloat(orderBookData.asks[1].total)).toBe(2); + + // Verify maxTotal is the larger of bid/ask cumulative totals + expect(parseFloat(orderBookData.maxTotal)).toBe(6); + }); + + it('should limit levels based on the levels parameter', async () => { + const mockCallback = jest.fn(); + + mockSubscriptionClient.l2Book.mockImplementation( + (_params: any, callback: any) => { + setTimeout(() => { + callback({ + coin: 'BTC', + levels: [ + [ + { px: '50000', sz: '1.0', n: 1 }, + { px: '49900', sz: '2.0', n: 1 }, + { px: '49800', sz: '3.0', n: 1 }, + { px: '49700', sz: '4.0', n: 1 }, + { px: '49600', sz: '5.0', n: 1 }, + ], + [ + { px: '50100', sz: '0.5', n: 1 }, + { px: '50200', sz: '1.5', n: 1 }, + { px: '50300', sz: '2.5', n: 1 }, + { px: '50400', sz: '3.5', n: 1 }, + { px: '50500', sz: '4.5', n: 1 }, + ], + ], + }); + }, 0); + return Promise.resolve({ + unsubscribe: jest.fn().mockResolvedValue(undefined), + }); + }, + ); + + service.subscribeToOrderBook({ + symbol: 'BTC', + levels: 3, + callback: mockCallback, + }); + + await jest.runAllTimersAsync(); + + const orderBookData = mockCallback.mock.calls[0][0]; + + // Should only have 3 levels on each side + expect(orderBookData.bids.length).toBe(3); + expect(orderBookData.asks.length).toBe(3); + }); + + it('forwards fast: true to the SDK l2Book call', async () => { + const mockCallback = jest.fn(); + mockSubscriptionClient.l2Book.mockImplementation( + (_params: any, callback: any) => { + setTimeout(() => { + callback({ + coin: 'BTC', + levels: [ + [{ px: '49900', sz: '1.0', n: 1 }], + [{ px: '50100', sz: '1.0', n: 1 }], + ], + }); + }, 0); + return Promise.resolve({ + unsubscribe: jest.fn().mockResolvedValue(undefined), + }); + }, + ); + + service.subscribeToOrderBook({ + symbol: 'BTC', + fast: true, + callback: mockCallback, + }); + + await jest.runAllTimersAsync(); + + expect(mockSubscriptionClient.l2Book).toHaveBeenCalledWith( + expect.objectContaining({ coin: 'BTC', fast: true }), + expect.any(Function), + ); + }); + + it('does not send fast flag when fast is omitted', async () => { + const mockCallback = jest.fn(); + mockSubscriptionClient.l2Book.mockImplementation( + (_params: any, callback: any) => { + setTimeout(() => { + callback({ + coin: 'BTC', + levels: [ + [{ px: '49900', sz: '1.0', n: 1 }], + [{ px: '50100', sz: '1.0', n: 1 }], + ], + }); + }, 0); + return Promise.resolve({ + unsubscribe: jest.fn().mockResolvedValue(undefined), + }); + }, + ); + + service.subscribeToOrderBook({ + symbol: 'BTC', + callback: mockCallback, + }); + + await jest.runAllTimersAsync(); + + const calledWith = mockSubscriptionClient.l2Book.mock.calls[0][0]; + expect(calledWith.fast).toBeUndefined(); + }); + }); + + describe('Subscription Race Guards (#28141)', () => { + it('unsubscribes stale assetCtxs when a newer pending promise exists', async () => { + jest.mocked(parseAssetName).mockImplementation((symbol: string) => ({ + symbol, + dex: symbol === 'BTC:UNISWAP' ? 'UNISWAP' : null, + })); + service.setDexMetaCache('UNISWAP', { + universe: [{ name: 'BTC:UNISWAP' }], + } as any); + + let resolveFirst: (sub: MockSubscription) => void = () => undefined; + const firstMockSub: MockSubscription = { + unsubscribe: jest.fn().mockResolvedValue(undefined), + }; + const secondMockSub: MockSubscription = { + unsubscribe: jest.fn().mockResolvedValue(undefined), + }; + mockSubscriptionClient.allMids.mockResolvedValue({ + unsubscribe: jest.fn().mockResolvedValue(undefined), + }); + + let callCount = 0; + mockSubscriptionClient.assetCtxs.mockImplementation(() => { + callCount += 1; + if (callCount === 1) { + return new Promise((resolve) => { + resolveFirst = resolve; + }); + } + return Promise.resolve(secondMockSub); + }); + + const unsubscribe = await service.subscribeToPrices({ + symbols: ['BTC:UNISWAP'], + callback: jest.fn(), + includeMarketData: true, + }); + await jest.runAllTimersAsync(); + + unsubscribe(); + + await service.subscribeToPrices({ + symbols: ['BTC:UNISWAP'], + callback: jest.fn(), + includeMarketData: true, + }); + await jest.runAllTimersAsync(); + + resolveFirst(firstMockSub); + await jest.runAllTimersAsync(); + + expect(firstMockSub.unsubscribe).toHaveBeenCalled(); + expect(secondMockSub.unsubscribe).not.toHaveBeenCalled(); + }); + + it('unsubscribes stale BBO when a newer pending promise exists', async () => { + let resolveFirst: (sub: MockSubscription) => void = () => undefined; + const firstMockSub: MockSubscription = { + unsubscribe: jest.fn().mockResolvedValue(undefined), + }; + const secondMockSub: MockSubscription = { + unsubscribe: jest.fn().mockResolvedValue(undefined), + }; + + let callCount = 0; + mockSubscriptionClient.bbo.mockImplementation(() => { + callCount += 1; + if (callCount === 1) { + return new Promise((resolve) => { + resolveFirst = resolve; + }); + } + + return Promise.resolve(secondMockSub); + }); + + const unsubscribe1 = await service.subscribeToPrices({ + symbols: ['BTC'], + callback: jest.fn(), + includeOrderBook: true, + }); + await jest.runAllTimersAsync(); + + unsubscribe1(); + + await service.subscribeToPrices({ + symbols: ['BTC'], + callback: jest.fn(), + includeOrderBook: true, + }); + await jest.runAllTimersAsync(); + + resolveFirst(firstMockSub); + await jest.runAllTimersAsync(); + + expect(firstMockSub.unsubscribe).toHaveBeenCalled(); + expect(secondMockSub.unsubscribe).not.toHaveBeenCalled(); + }); + + it('unsubscribes stale activeAssetCtx when a newer pending promise exists', async () => { + // Arrange: make first activeAssetCtx return a deferred promise + let resolveFirst: (sub: MockSubscription) => void = () => undefined; + const firstMockSub: MockSubscription = { + unsubscribe: jest.fn().mockResolvedValue(undefined), + }; + const secondMockSub: MockSubscription = { + unsubscribe: jest.fn().mockResolvedValue(undefined), + }; + + let callCount = 0; + mockSubscriptionClient.activeAssetCtx.mockImplementation(() => { + callCount += 1; + if (callCount === 1) { + // First call: deferred promise (simulates slow network) + return new Promise((resolve) => { + resolveFirst = resolve; + }); + } + // Second call: resolves immediately + return Promise.resolve(secondMockSub); + }); + + // Act: first subscription + const unsubscribe1 = await service.subscribeToPrices({ + symbols: ['BTC'], + callback: jest.fn(), + includeMarketData: true, + }); + await jest.runAllTimersAsync(); + + // Cleanup first subscription (decrements count, clears pending) + unsubscribe1(); + + // Second subscription for same symbol (creates new pending promise) + await service.subscribeToPrices({ + symbols: ['BTC'], + callback: jest.fn(), + includeMarketData: true, + }); + await jest.runAllTimersAsync(); + + // Now resolve the first (stale) promise + resolveFirst(firstMockSub); + await jest.runAllTimersAsync(); + + // Assert: stale subscription was unsubscribed + expect(firstMockSub.unsubscribe).toHaveBeenCalled(); + // Fresh subscription was NOT unsubscribed + expect(secondMockSub.unsubscribe).not.toHaveBeenCalled(); + }); + + it('handles activeAssetCtx subscription error gracefully', async () => { + // Arrange: make activeAssetCtx reject + mockSubscriptionClient.activeAssetCtx.mockRejectedValue( + new Error('WebSocket connection failed'), + ); + + // Act: subscribe with market data — should not throw + const unsubscribe = await service.subscribeToPrices({ + symbols: ['BTC'], + callback: jest.fn(), + includeMarketData: true, + }); + await jest.runAllTimersAsync(); + + // Assert: error was logged, service still functional + expect(mockDeps.logger.error).toHaveBeenCalled(); + expect(typeof unsubscribe).toBe('function'); + }); + + it('logs method name (not class name) for transient SDK errors', async () => { + // Arrange: make activeAssetCtx reject with a WebSocketRequestError + const transientError = new Error( + 'Unknown error while making a WebSocket request', + ); + transientError.name = 'WebSocketRequestError'; + mockSubscriptionClient.activeAssetCtx.mockRejectedValue(transientError); + + // Act + await service.subscribeToPrices({ + symbols: ['BTC'], + callback: jest.fn(), + includeMarketData: true, + }); + await jest.runAllTimersAsync(); + + // Assert: debugLogger received method context, not the class name + expect(mockDeps.debugLogger.log).toHaveBeenCalledWith( + expect.stringContaining('ensureActiveAssetSubscription'), + ); + expect(mockDeps.debugLogger.log).not.toHaveBeenCalledWith( + expect.stringContaining('HyperLiquidSubscriptionService:'), + ); + // Sentry logger should NOT have been called with the transient error + expect(mockDeps.logger.error).not.toHaveBeenCalledWith( + transientError, + expect.anything(), + ); + }); + }); +}); diff --git a/packages/perps-controller/tests/src/services/HyperLiquidSubscriptionService.lifecycle.test.ts b/packages/perps-controller/tests/src/services/HyperLiquidSubscriptionService.lifecycle.test.ts new file mode 100644 index 00000000000..6f024c2d21b --- /dev/null +++ b/packages/perps-controller/tests/src/services/HyperLiquidSubscriptionService.lifecycle.test.ts @@ -0,0 +1,932 @@ +/* eslint-disable */ +/** + * Unit tests for HyperLiquidSubscriptionService + */ + +/* eslint-disable @typescript-eslint/no-explicit-any */ + +import type { CaipAccountId, Hex } from '@metamask/utils'; + +import { ABSTRACTION_MODE_REFRESH_THROTTLE_MS } from '../../../src/constants/perpsConfig.js'; +import type { HyperLiquidClientService } from '../../../src/services/HyperLiquidClientService.js'; +import { HyperLiquidSubscriptionService } from '../../../src/services/HyperLiquidSubscriptionService.js'; +import type { HyperLiquidWalletService } from '../../../src/services/HyperLiquidWalletService.js'; +import type { + SubscribeOrderBookParams, + SubscribeOrderFillsParams, + SubscribePositionsParams, + SubscribePricesParams, +} from '../../../src/types/index.js'; +import { + adaptAccountStateFromSDK, + parseAssetName, +} from '../../../src/utils/hyperLiquidAdapter.js'; +import { createMockInfrastructure } from '../../helpers/serviceMocks.js'; + +// Mock HyperLiquid SDK types +interface MockSubscription { + unsubscribe: jest.Mock; +} + +// Mock adapter +jest.mock('../../../src/utils/hyperLiquidAdapter', () => ({ + adaptPositionFromSDK: jest.fn((assetPos: any) => ({ + symbol: 'BTC', + size: assetPos.position.szi, + entryPrice: '50000', + positionValue: '5000', + unrealizedPnl: '100', + marginUsed: '2500', + leverage: { type: 'isolated', value: 2 }, + liquidationPrice: '40000', + maxLeverage: 100, + returnOnEquity: '4.0', + cumulativeFunding: { allTime: '0', sinceOpen: '0', sinceChange: '0' }, + takeProfitCount: 0, + stopLossCount: 0, + })), + adaptOrderFromSDK: jest.fn((order: any) => ({ + orderId: order.oid.toString(), + symbol: order.coin, + side: order.side === 'B' ? 'buy' : 'sell', + orderType: 'limit', + size: order.sz, + originalSize: order.sz, + price: order.limitPx || order.triggerPx || '0', + filledSize: '0', + remainingSize: order.sz, + status: 'open', + timestamp: Date.now(), + detailedOrderType: order.orderType || 'Limit', + isTrigger: order.isTrigger ?? false, + reduceOnly: order.reduceOnly ?? false, + triggerPrice: order.triggerPx, + ...(typeof order.isPositionTpsl === 'boolean' + ? { isPositionTpsl: order.isPositionTpsl } + : {}), + })), + adaptAccountStateFromSDK: jest.fn(() => ({ + spendableBalance: '1000.00', + withdrawableBalance: '1000.00', + marginUsed: '500.00', + unrealizedPnl: '100.00', + returnOnEquity: '20.0', + totalBalance: '10100.00', + })), + parseAssetName: jest.fn((symbol: string) => ({ + symbol, + dex: null, + })), +})); + +// Mock DevLogger +jest.mock( + '../../../../core/SDKConnect/utils/DevLogger', + () => ({ + DevLogger: { + log: jest.fn(), + }, + }), + { virtual: true }, +); + +// Mock trace utilities +jest.mock( + '../../../../util/trace', + () => ({ + trace: jest.fn(), + TraceName: { + PerpsWebSocketConnected: 'Perps WebSocket Connected', + PerpsWebSocketDisconnected: 'Perps WebSocket Disconnected', + }, + TraceOperation: { + PerpsMarketData: 'perps.market_data', + }, + }), + { virtual: true }, +); + +// Mock Sentry +jest.mock( + '@sentry/react-native', + () => ({ + setMeasurement: jest.fn(), + }), + { virtual: true }, +); + +describe('HyperLiquidSubscriptionService', () => { + let service: HyperLiquidSubscriptionService; + let mockClientService: jest.Mocked; + let mockWalletService: jest.Mocked; + let mockSubscriptionClient: any; + let mockWalletAdapter: any; + let mockDeps: ReturnType; + let mockSpotClearinghouseState: jest.Mock; + + beforeEach(() => { + jest.useFakeTimers(); + jest.clearAllMocks(); + mockDeps = createMockInfrastructure(); + jest.mocked(parseAssetName).mockImplementation((symbol: string) => ({ + symbol, + dex: null, + })); + const hyperLiquidAdapter = jest.requireMock( + '../../../src/utils/hyperLiquidAdapter', + ); + hyperLiquidAdapter.adaptPositionFromSDK.mockImplementation( + (assetPos: any) => ({ + symbol: 'BTC', + size: assetPos.position.szi, + entryPrice: '50000', + positionValue: '5000', + unrealizedPnl: '100', + marginUsed: '2500', + leverage: { type: 'isolated', value: 2 }, + liquidationPrice: '40000', + maxLeverage: 100, + returnOnEquity: '4.0', + cumulativeFunding: { allTime: '0', sinceOpen: '0', sinceChange: '0' }, + takeProfitCount: 0, + stopLossCount: 0, + }), + ); + hyperLiquidAdapter.adaptOrderFromSDK.mockImplementation((order: any) => ({ + orderId: order.oid.toString(), + symbol: order.coin, + side: order.side === 'B' ? 'buy' : 'sell', + orderType: 'limit', + size: order.sz, + originalSize: order.sz, + price: order.limitPx || order.triggerPx || '0', + filledSize: '0', + remainingSize: order.sz, + status: 'open', + timestamp: Date.now(), + detailedOrderType: order.orderType || 'Limit', + isTrigger: order.isTrigger ?? false, + reduceOnly: order.reduceOnly ?? false, + triggerPrice: order.triggerPx, + ...(typeof order.isPositionTpsl === 'boolean' + ? { isPositionTpsl: order.isPositionTpsl } + : {}), + })); + hyperLiquidAdapter.adaptAccountStateFromSDK.mockImplementation(() => ({ + spendableBalance: '1000.00', + withdrawableBalance: '1000.00', + marginUsed: '500.00', + unrealizedPnl: '100.00', + returnOnEquity: '20.0', + totalBalance: '10100.00', + })); + + // Mock subscription client + const mockSubscription: MockSubscription = { + unsubscribe: jest.fn().mockResolvedValue(undefined), + }; + + mockSubscriptionClient = { + allMids: jest.fn((paramsOrCallback: any, maybeCallback?: any) => { + const callback = + typeof paramsOrCallback === 'function' + ? paramsOrCallback + : maybeCallback; + // Simulate allMids data + setTimeout(() => { + callback({ + mids: { + BTC: 50000, + ETH: 3000, + }, + }); + }, 0); + return Promise.resolve(mockSubscription); + }), + activeAssetCtx: jest.fn((params: any, callback: any) => { + // Simulate activeAssetCtx data + setTimeout(() => { + callback({ + coin: params.coin, + ctx: { + prevDayPx: '49000', + funding: '0.01', + openInterest: '1000000', // Raw token units from API + dayNtlVlm: '50000000', + oraclePx: '50100', + midPx: '50000', // Price used for openInterest USD conversion: 1M tokens * $50K = $50B + }, + }); + }, 0); + return Promise.resolve(mockSubscription); + }), + webData3: jest.fn((_params: any, callback: any) => { + // Simulate webData3 data with perpDexStates structure + // First callback immediately + setTimeout(() => { + callback({ + perpDexStates: [ + { + clearinghouseState: { + assetPositions: [ + { + position: { szi: '0.1' }, + coin: 'BTC', + }, + ], + }, + openOrders: [ + { + oid: 12345, + coin: 'BTC', + side: 'B', + sz: '0.5', + origSz: '1.0', + limitPx: '50000', + orderType: 'Limit', + timestamp: 1234567890000, + isTrigger: false, + reduceOnly: false, + }, + ], + perpsAtOpenInterestCap: [], + }, + ], + }); + }, 0); + + // Second callback with changed data to ensure updates are triggered + setTimeout(() => { + callback({ + perpDexStates: [ + { + clearinghouseState: { + assetPositions: [ + { + position: { szi: '0.2' }, // Changed position size + coin: 'BTC', + }, + ], + }, + openOrders: [ + { + oid: 12346, // Changed order ID + coin: 'BTC', + side: 'S', + sz: '0.3', + origSz: '0.5', + limitPx: '51000', + orderType: 'Limit', + timestamp: 1234567890001, + isTrigger: false, + reduceOnly: false, + }, + ], + perpsAtOpenInterestCap: [], + }, + ], + }); + }, 10); + + return Promise.resolve(mockSubscription); + }), + webData2: jest.fn((_params: any, callback: any) => { + // Simulate webData2 data with clearinghouseState (HIP-3 disabled) + setTimeout(() => { + callback({ + clearinghouseState: { + assetPositions: [ + { + position: { szi: '0.1' }, + coin: 'BTC', + }, + ], + marginSummary: { + accountValue: '10000', + totalMarginUsed: '500', + }, + withdrawable: '9500', + }, + openOrders: [ + { + oid: 12345, + coin: 'BTC', + side: 'B', + sz: '0.5', + origSz: '1.0', + limitPx: '50000', + orderType: 'Limit', + timestamp: 1234567890000, + isTrigger: false, + reduceOnly: false, + }, + ], + perpsAtOpenInterestCap: [], + }); + }, 0); + return Promise.resolve(mockSubscription); + }), + userFills: jest.fn((_params: any, callback: any) => { + // Simulate order fill data + setTimeout(() => { + callback({ + fills: [ + { + oid: 12345, + coin: 'BTC', + side: 'B', + sz: '0.1', + px: '50000', + fee: '5', + time: Date.now(), + }, + ], + }); + }, 0); + return Promise.resolve(mockSubscription); + }), + l2Book: jest.fn((_params: any, callback: any) => { + // Simulate l2Book data + setTimeout(() => { + callback({ + coin: _params.coin, + levels: { bids: [], asks: [] }, + }); + }, 0); + return Promise.resolve(mockSubscription); + }), + bbo: jest.fn((_params: any, callback: any) => { + // Simulate BBO data + setTimeout(() => { + callback({ + coin: _params.coin, + time: Date.now(), + bbo: [ + { px: '49900', sz: '1.5', n: 1 }, + { px: '50100', sz: '2.0', n: 1 }, + ], + }); + }, 0); + return Promise.resolve(mockSubscription); + }), + activeAsset: jest.fn((params: any, callback: any) => { + // Simulate activeAsset data (similar to activeAssetCtx) + setTimeout(() => { + callback({ + coin: params.coin, + data: 'test', + }); + }, 0); + return Promise.resolve(mockSubscription); + }), + clearinghouseState: jest.fn((_params: any, callback: any) => { + // Simulate clearinghouseState data for individual subscription + setTimeout(() => { + callback({ + dex: _params.dex || '', + clearinghouseState: { + assetPositions: [ + { + position: { szi: '0.1' }, + coin: 'BTC', + }, + ], + marginSummary: { + accountValue: '10000', + totalMarginUsed: '500', + }, + withdrawable: '9500', + }, + }); + }, 0); + return Promise.resolve(mockSubscription); + }), + openOrders: jest.fn((_params: any, callback: any) => { + // Simulate openOrders data for individual subscription + setTimeout(() => { + callback({ + dex: _params.dex || '', + orders: [ + { + oid: 12345, + coin: 'BTC', + side: 'B', + sz: '0.5', + origSz: '1.0', + limitPx: '50000', + orderType: 'Limit', + timestamp: 1234567890000, + isTrigger: false, + reduceOnly: false, + triggerCondition: '', + triggerPx: '', + children: [], + isPositionTpsl: false, + tif: null, + cloid: null, + }, + ], + }); + }, 0); + return Promise.resolve(mockSubscription); + }), + assetCtxs: jest.fn(() => Promise.resolve(mockSubscription)), + fastAssetCtxs: jest.fn((_callback: any) => + Promise.resolve(mockSubscription), + ), + spotState: jest.fn((_params: any, _callback: any) => + Promise.resolve(mockSubscription), + ), + }; + + mockWalletAdapter = { + request: jest.fn(), + }; + + // Mock client service + mockSpotClearinghouseState = jest.fn().mockResolvedValue({ + balances: [{ coin: 'USDC', total: '100.76531791' }], + }); + + mockClientService = { + ensureSubscriptionClient: jest.fn().mockResolvedValue(undefined), + getSubscriptionClient: jest.fn(() => mockSubscriptionClient), + getInfoClient: jest.fn(() => ({ + spotClearinghouseState: mockSpotClearinghouseState, + // Mode-aware fold gate reads userAbstraction; default to unifiedAccount + // so existing spot-fold assertions behave as before the gate was added. + userAbstraction: jest.fn().mockResolvedValue('unifiedAccount'), + })), + isTestnetMode: jest.fn(() => false), + ensureTransportReady: jest.fn().mockResolvedValue(undefined), + getConnectionState: jest.fn(() => 'connected'), + } as any; + + // Mock wallet service + mockWalletService = { + createWalletAdapter: jest.fn(() => mockWalletAdapter), + getUserAddressWithDefault: jest.fn().mockResolvedValue('0x123' as Hex), + } as any; + + service = new HyperLiquidSubscriptionService( + mockClientService, + mockWalletService, + mockDeps, + true, // hip3Enabled - test expects webData3 + ); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + describe('Subscription Lifecycle', () => { + it('uses validated provider DEX discovery without waiting for the timeout', async () => { + const discoverEnabledDexs = jest.fn().mockResolvedValue(['xyz']); + const discoveryService = new HyperLiquidSubscriptionService( + mockClientService, + mockWalletService, + mockDeps, + true, + [], + [], + [], + undefined, + discoverEnabledDexs, + ); + + const unsubscribe = discoveryService.subscribeToPositions({ + callback: jest.fn(), + }); + + await jest.runAllTimersAsync(); + + expect(discoverEnabledDexs).toHaveBeenCalledTimes(1); + expect(mockSubscriptionClient.clearinghouseState).toHaveBeenCalledWith( + { user: '0x123', dex: 'xyz' }, + expect.any(Function), + ); + expect(mockDeps.debugLogger.log).not.toHaveBeenCalledWith( + 'DEX discovery wait timed out, proceeding with main DEX only', + ); + + unsubscribe(); + }); + + it('should unsubscribe from position updates successfully', async () => { + const mockCallback = jest.fn(); + const mockSubscription = { + unsubscribe: jest.fn().mockResolvedValue(undefined), + }; + + mockSubscriptionClient.webData3.mockResolvedValue(mockSubscription); + + const unsubscribe = service.subscribeToPositions({ + callback: mockCallback, + }); + + // Wait for subscription to be established + await jest.runAllTimersAsync(); + + // Unsubscribe + unsubscribe(); + + // Wait for unsubscribe to complete + await jest.runAllTimersAsync(); + + expect(mockSubscription.unsubscribe).toHaveBeenCalled(); + }); + + it('should unsubscribe from order fill updates successfully', async () => { + const mockCallback = jest.fn(); + const mockSubscription = { + unsubscribe: jest.fn().mockResolvedValue(undefined), + }; + + mockSubscriptionClient.userFills.mockResolvedValue(mockSubscription); + + const unsubscribe = service.subscribeToOrderFills({ + callback: mockCallback, + }); + + // Wait for subscription to be established + await jest.runAllTimersAsync(); + + // Unsubscribe + unsubscribe(); + + // Wait for unsubscribe to complete + await jest.runAllTimersAsync(); + + expect(mockSubscription.unsubscribe).toHaveBeenCalled(); + }); + + it('should handle unsubscribe errors gracefully', async () => { + const mockCallback = jest.fn(); + const mockSubscription = { + unsubscribe: jest + .fn() + .mockRejectedValue(new Error('Unsubscribe failed')), + }; + + mockSubscriptionClient.webData3.mockResolvedValue(mockSubscription); + + const unsubscribe = service.subscribeToPositions({ + callback: mockCallback, + }); + + // Wait for subscription to be established + await jest.runAllTimersAsync(); + + // Unsubscribe should not throw + expect(() => unsubscribe()).not.toThrow(); + }); + }); + + describe('Cache Management', () => { + it('should create price updates with 24h change calculation', async () => { + const mockCallback = jest.fn(); + + // First subscription to populate cache + const unsubscribe = await service.subscribeToPrices({ + symbols: ['BTC'], + callback: mockCallback, + includeMarketData: true, // Enable market data to get percentChange24h + }); + + // Wait for cache to populate + await jest.runAllTimersAsync(); + + expect(mockCallback).toHaveBeenCalledWith([ + expect.objectContaining({ + symbol: 'BTC', + price: expect.any(String), + timestamp: expect.any(Number), + percentChange24h: expect.any(String), + }), + ]); + + unsubscribe(); + }); + + it('should maintain separate caches for market data', async () => { + const mockCallback = jest.fn(); + + // Mock activeAssetCtx with market data + mockSubscriptionClient.activeAssetCtx.mockImplementation( + (params: any, callback: any) => { + setTimeout(() => { + callback({ + coin: params.coin, + ctx: { + prevDayPx: '49000', + funding: '0.01', + openInterest: '1000000', + dayNtlVlm: '50000000', + oraclePx: '50100', + }, + }); + }, 0); + return Promise.resolve({ + unsubscribe: jest.fn().mockResolvedValue(undefined), + }); + }, + ); + + const unsubscribe = await service.subscribeToPrices({ + symbols: ['BTC'], + callback: mockCallback, + }); + + // Wait for cache updates + await jest.runAllTimersAsync(); + + // Verify market data is processed + expect(mockCallback).toHaveBeenCalled(); + + unsubscribe(); + }); + }); + + describe('Cleanup and Error Handling', () => { + it('should clear all subscriptions and cache', async () => { + service.clearAll(); + + // Verify cache is cleared by trying to subscribe + const mockCallback = jest.fn(); + await service.subscribeToPrices({ + symbols: ['BTC'], + callback: mockCallback, + }); + + // Should not have cached data + expect(mockCallback).not.toHaveBeenCalled(); + }); + + it('should handle subscription errors gracefully', async () => { + mockSubscriptionClient.allMids.mockRejectedValue( + new Error('Subscription failed'), + ); + + const mockCallback = jest.fn(); + const unsubscribe = await service.subscribeToPrices({ + symbols: ['BTC'], + callback: mockCallback, + }); + + // Should not throw + expect(typeof unsubscribe).toBe('function'); + }); + + it('retries the fastAssetCtxs subscription on a transient SDK error and succeeds (TAT-3387)', async () => { + const mockUnsubscribe = jest.fn().mockResolvedValue(undefined); + const transientError = new Error( + 'Unknown error while making a WebSocket request', + ); + transientError.name = 'WebSocketRequestError'; + + // First attempt fails with a transient error; second attempt succeeds + mockSubscriptionClient.fastAssetCtxs + .mockRejectedValueOnce(transientError) + .mockResolvedValueOnce({ unsubscribe: mockUnsubscribe }); + + const mockCallback = jest.fn(); + const unsubscribe = await service.subscribeToPrices({ + symbols: ['BTC'], + callback: mockCallback, + }); + + // Let the first attempt fail and the 500ms backoff elapse + await jest.advanceTimersByTimeAsync(500); + await jest.runAllTimersAsync(); + + expect(mockSubscriptionClient.fastAssetCtxs).toHaveBeenCalledTimes(2); + + unsubscribe(); + }); + + it('does not retry the fastAssetCtxs subscription on a non-transient error', async () => { + mockSubscriptionClient.fastAssetCtxs.mockRejectedValue( + new Error('Non-transient failure'), + ); + + const mockCallback = jest.fn(); + const unsubscribe = await service.subscribeToPrices({ + symbols: ['BTC'], + callback: mockCallback, + }); + + await jest.runAllTimersAsync(); + + // Non-transient errors should not be retried (single attempt only) + expect(mockSubscriptionClient.fastAssetCtxs).toHaveBeenCalledTimes(1); + expect(typeof unsubscribe).toBe('function'); + }); + + it('should handle missing subscription client in position subscription', async () => { + mockClientService.getSubscriptionClient.mockReturnValue(undefined); + + const mockCallback = jest.fn(); + const unsubscribe = service.subscribeToPositions({ + callback: mockCallback, + }); + + // Wait for async operations + await jest.runAllTimersAsync(); + + expect(typeof unsubscribe).toBe('function'); + expect(mockSubscriptionClient.webData3).not.toHaveBeenCalled(); + }); + + it('should handle missing subscription client in order fill subscription', () => { + mockClientService.getSubscriptionClient.mockReturnValue(undefined); + + const mockCallback = jest.fn(); + const unsubscribe = service.subscribeToOrderFills({ + callback: mockCallback, + }); + + expect(typeof unsubscribe).toBe('function'); + expect( + mockWalletService.getUserAddressWithDefault, + ).not.toHaveBeenCalled(); + }); + }); + + describe('Data Transformation', () => { + it('should handle both perps and spot context types', async () => { + const mockCallback = jest.fn(); + + // Mock spot context (without perps-specific fields) + mockSubscriptionClient.activeAssetCtx.mockImplementation( + (params: any, callback: any) => { + setTimeout(() => { + callback({ + coin: params.coin, + ctx: { + prevDayPx: '49000', + dayNtlVlm: '50000000', + // No funding, openInterest, oraclePx (spot context) + }, + }); + }, 0); + return Promise.resolve({ + unsubscribe: jest.fn().mockResolvedValue(undefined), + }); + }, + ); + + const unsubscribe = await service.subscribeToPrices({ + symbols: ['BTC'], + callback: mockCallback, + }); + + // Wait for processing + await jest.runAllTimersAsync(); + + expect(mockCallback).toHaveBeenCalled(); + + unsubscribe(); + }); + + it('should handle missing position data gracefully', async () => { + const mockCallback = jest.fn(); + + // HIP-3 mode uses individual subscriptions + // Mock clearinghouseState with no position data + mockSubscriptionClient.clearinghouseState.mockImplementation( + (_params: any, callback: any) => { + setTimeout(() => { + callback({ + dex: _params.dex || '', + clearinghouseState: { + assetPositions: [], // Empty array instead of undefined + marginSummary: { accountValue: '10000', totalMarginUsed: '0' }, + withdrawable: '10000', + }, + }); + }, 0); + return Promise.resolve({ + unsubscribe: jest.fn().mockResolvedValue(undefined), + }); + }, + ); + + mockSubscriptionClient.openOrders.mockImplementation( + (_params: any, callback: any) => { + setTimeout(() => { + callback({ + dex: _params.dex || '', + orders: [], // Empty orders + }); + }, 5); + return Promise.resolve({ + unsubscribe: jest.fn().mockResolvedValue(undefined), + }); + }, + ); + + const unsubscribe = service.subscribeToPositions({ + callback: mockCallback, + }); + + // Wait for processing + await jest.runAllTimersAsync(); + + // Should call callback with empty positions to fix loading state + // This ensures the UI can transition from loading to empty state for new users without cached positions + expect(mockCallback).toHaveBeenCalledWith([]); + + unsubscribe(); + }); + }); + + describe('Market Data Subscription Control', () => { + it('should not include market data when includeMarketData is false', async () => { + const mockCallback = jest.fn(); + + // Subscribe without market data + const unsubscribe = await service.subscribeToPrices({ + symbols: ['BTC'], + callback: mockCallback, + includeMarketData: false, + }); + + // Ensure activeAssetCtx is NOT called + expect(mockSubscriptionClient.activeAssetCtx).not.toHaveBeenCalled(); + + // Wait for allMids data + await jest.runAllTimersAsync(); + + // Check that market data fields are undefined + expect(mockCallback).toHaveBeenCalledWith([ + expect.objectContaining({ + symbol: 'BTC', + price: expect.any(String), + timestamp: expect.any(Number), + funding: undefined, + openInterest: undefined, + volume24h: undefined, + }), + ]); + + unsubscribe(); + }); + + it('should include market data when includeMarketData is true', async () => { + const mockCallback = jest.fn(); + const mockSubscription = { + unsubscribe: jest.fn().mockResolvedValue(undefined), + }; + + // Mock activeAssetCtx with market data + mockSubscriptionClient.activeAssetCtx.mockImplementation( + (params: any, callback: any) => { + setTimeout(() => { + callback({ + coin: params.coin, + ctx: { + prevDayPx: 45000, + funding: 0.0001, + openInterest: 1000000, // Raw token units from API + dayNtlVlm: 5000000, + oraclePx: 50100, + midPx: 50000, // Price used for openInterest USD conversion: 1M tokens * $50K = $50B + }, + }); + }, 10); + return Promise.resolve(mockSubscription); + }, + ); + + // Subscribe with market data + const unsubscribe = await service.subscribeToPrices({ + symbols: ['BTC'], + callback: mockCallback, + includeMarketData: true, + }); + + // Ensure activeAssetCtx is called + expect(mockSubscriptionClient.activeAssetCtx).toHaveBeenCalledWith( + { coin: 'BTC' }, + expect.any(Function), + ); + + // Wait for data + await jest.runAllTimersAsync(); + + // Check that market data fields are included + expect(mockCallback).toHaveBeenCalledWith([ + expect.objectContaining({ + symbol: 'BTC', + price: expect.any(String), + timestamp: expect.any(Number), + funding: 0.0001, + openInterest: 50000000000, // 1M tokens * $50K price = $50B + volume24h: 5000000, + }), + ]); + + unsubscribe(); + }); + }); +}); diff --git a/packages/perps-controller/tests/src/services/HyperLiquidSubscriptionService.market-data.test.ts b/packages/perps-controller/tests/src/services/HyperLiquidSubscriptionService.market-data.test.ts new file mode 100644 index 00000000000..e1d3f447891 --- /dev/null +++ b/packages/perps-controller/tests/src/services/HyperLiquidSubscriptionService.market-data.test.ts @@ -0,0 +1,3645 @@ +/* eslint-disable */ +/** + * Unit tests for HyperLiquidSubscriptionService + */ + +/* eslint-disable @typescript-eslint/no-explicit-any */ + +import type { CaipAccountId, Hex } from '@metamask/utils'; + +import { ABSTRACTION_MODE_REFRESH_THROTTLE_MS } from '../../../src/constants/perpsConfig.js'; +import type { HyperLiquidClientService } from '../../../src/services/HyperLiquidClientService.js'; +import { HyperLiquidSubscriptionService } from '../../../src/services/HyperLiquidSubscriptionService.js'; +import type { HyperLiquidWalletService } from '../../../src/services/HyperLiquidWalletService.js'; +import type { + PriceUpdate, + SubscribeOrderBookParams, + SubscribeOrderFillsParams, + SubscribePositionsParams, + SubscribePricesParams, +} from '../../../src/types/index.js'; +import { + adaptAccountStateFromSDK, + parseAssetName, +} from '../../../src/utils/hyperLiquidAdapter.js'; +import { createMockInfrastructure } from '../../helpers/serviceMocks.js'; + +// Mock HyperLiquid SDK types +interface MockSubscription { + unsubscribe: jest.Mock; +} + +// Mock adapter +jest.mock('../../../src/utils/hyperLiquidAdapter', () => { + // The placement type decides whether an order reaches the trigger arrays, so + // the stub has to name it exactly as the real adapter does. Omitting it left + // every array here empty regardless of the code under test, which the legacy + // count fallback then masked. + const { adaptTriggerOrderTypeFromSDK } = jest.requireActual( + '../../../src/utils/hyperLiquidAdapter', + ); + + return { + adaptPositionFromSDK: jest.fn((assetPos: any) => ({ + symbol: 'BTC', + size: assetPos.position.szi, + entryPrice: '50000', + positionValue: '5000', + unrealizedPnl: '100', + marginUsed: '2500', + leverage: { type: 'isolated', value: 2 }, + liquidationPrice: '40000', + maxLeverage: 100, + returnOnEquity: '4.0', + cumulativeFunding: { allTime: '0', sinceOpen: '0', sinceChange: '0' }, + takeProfitCount: 0, + stopLossCount: 0, + })), + adaptOrderFromSDK: jest.fn((order: any) => ({ + orderId: order.oid.toString(), + symbol: order.coin, + side: order.side === 'B' ? 'buy' : 'sell', + orderType: 'limit', + size: order.sz, + originalSize: order.sz, + price: order.limitPx || order.triggerPx || '0', + filledSize: '0', + remainingSize: order.sz, + status: 'open', + timestamp: Date.now(), + detailedOrderType: order.orderType || 'Limit', + isTrigger: order.isTrigger ?? false, + reduceOnly: order.reduceOnly ?? false, + triggerPrice: order.triggerPx, + triggerOrderType: adaptTriggerOrderTypeFromSDK(order.orderType), + ...(typeof order.isPositionTpsl === 'boolean' + ? { isPositionTpsl: order.isPositionTpsl } + : {}), + })), + adaptAccountStateFromSDK: jest.fn(() => ({ + spendableBalance: '1000.00', + withdrawableBalance: '1000.00', + marginUsed: '500.00', + unrealizedPnl: '100.00', + returnOnEquity: '20.0', + totalBalance: '10100.00', + })), + parseAssetName: jest.fn((symbol: string) => ({ + symbol, + dex: null, + })), + }; +}); + +// Mock DevLogger +jest.mock( + '../../../../core/SDKConnect/utils/DevLogger', + () => ({ + DevLogger: { + log: jest.fn(), + }, + }), + { virtual: true }, +); + +// Mock trace utilities +jest.mock( + '../../../../util/trace', + () => ({ + trace: jest.fn(), + TraceName: { + PerpsWebSocketConnected: 'Perps WebSocket Connected', + PerpsWebSocketDisconnected: 'Perps WebSocket Disconnected', + }, + TraceOperation: { + PerpsMarketData: 'perps.market_data', + }, + }), + { virtual: true }, +); + +// Mock Sentry +jest.mock( + '@sentry/react-native', + () => ({ + setMeasurement: jest.fn(), + }), + { virtual: true }, +); + +describe('HyperLiquidSubscriptionService', () => { + let service: HyperLiquidSubscriptionService; + let mockClientService: jest.Mocked; + let mockWalletService: jest.Mocked; + let mockSubscriptionClient: any; + let mockWalletAdapter: any; + let mockDeps: ReturnType; + let mockSpotClearinghouseState: jest.Mock; + + beforeEach(() => { + jest.useFakeTimers(); + jest.clearAllMocks(); + mockDeps = createMockInfrastructure(); + jest.mocked(parseAssetName).mockImplementation((symbol: string) => ({ + symbol, + dex: null, + })); + const hyperLiquidAdapter = jest.requireMock( + '../../../src/utils/hyperLiquidAdapter', + ); + hyperLiquidAdapter.adaptPositionFromSDK.mockImplementation( + (assetPos: any) => ({ + symbol: 'BTC', + size: assetPos.position.szi, + entryPrice: '50000', + positionValue: '5000', + unrealizedPnl: '100', + marginUsed: '2500', + leverage: { type: 'isolated', value: 2 }, + liquidationPrice: '40000', + maxLeverage: 100, + returnOnEquity: '4.0', + cumulativeFunding: { allTime: '0', sinceOpen: '0', sinceChange: '0' }, + takeProfitCount: 0, + stopLossCount: 0, + }), + ); + hyperLiquidAdapter.adaptOrderFromSDK.mockImplementation((order: any) => ({ + orderId: order.oid.toString(), + symbol: order.coin, + side: order.side === 'B' ? 'buy' : 'sell', + orderType: 'limit', + size: order.sz, + originalSize: order.sz, + price: order.limitPx || order.triggerPx || '0', + filledSize: '0', + remainingSize: order.sz, + status: 'open', + timestamp: Date.now(), + detailedOrderType: order.orderType || 'Limit', + isTrigger: order.isTrigger ?? false, + reduceOnly: order.reduceOnly ?? false, + triggerPrice: order.triggerPx, + ...(typeof order.isPositionTpsl === 'boolean' + ? { isPositionTpsl: order.isPositionTpsl } + : {}), + })); + hyperLiquidAdapter.adaptAccountStateFromSDK.mockImplementation(() => ({ + spendableBalance: '1000.00', + withdrawableBalance: '1000.00', + marginUsed: '500.00', + unrealizedPnl: '100.00', + returnOnEquity: '20.0', + totalBalance: '10100.00', + })); + + // Mock subscription client + const mockSubscription: MockSubscription = { + unsubscribe: jest.fn().mockResolvedValue(undefined), + }; + + mockSubscriptionClient = { + allMids: jest.fn((paramsOrCallback: any, maybeCallback?: any) => { + const callback = + typeof paramsOrCallback === 'function' + ? paramsOrCallback + : maybeCallback; + // Simulate allMids data + setTimeout(() => { + callback({ + mids: { + BTC: 50000, + ETH: 3000, + }, + }); + }, 0); + return Promise.resolve(mockSubscription); + }), + activeAssetCtx: jest.fn((params: any, callback: any) => { + // Simulate activeAssetCtx data + setTimeout(() => { + callback({ + coin: params.coin, + ctx: { + prevDayPx: '49000', + funding: '0.01', + openInterest: '1000000', // Raw token units from API + dayNtlVlm: '50000000', + oraclePx: '50100', + midPx: '50000', // Price used for openInterest USD conversion: 1M tokens * $50K = $50B + }, + }); + }, 0); + return Promise.resolve(mockSubscription); + }), + webData3: jest.fn((_params: any, callback: any) => { + // Simulate webData3 data with perpDexStates structure + // First callback immediately + setTimeout(() => { + callback({ + perpDexStates: [ + { + clearinghouseState: { + assetPositions: [ + { + position: { szi: '0.1' }, + coin: 'BTC', + }, + ], + }, + openOrders: [ + { + oid: 12345, + coin: 'BTC', + side: 'B', + sz: '0.5', + origSz: '1.0', + limitPx: '50000', + orderType: 'Limit', + timestamp: 1234567890000, + isTrigger: false, + reduceOnly: false, + }, + ], + perpsAtOpenInterestCap: [], + }, + ], + }); + }, 0); + + // Second callback with changed data to ensure updates are triggered + setTimeout(() => { + callback({ + perpDexStates: [ + { + clearinghouseState: { + assetPositions: [ + { + position: { szi: '0.2' }, // Changed position size + coin: 'BTC', + }, + ], + }, + openOrders: [ + { + oid: 12346, // Changed order ID + coin: 'BTC', + side: 'S', + sz: '0.3', + origSz: '0.5', + limitPx: '51000', + orderType: 'Limit', + timestamp: 1234567890001, + isTrigger: false, + reduceOnly: false, + }, + ], + perpsAtOpenInterestCap: [], + }, + ], + }); + }, 10); + + return Promise.resolve(mockSubscription); + }), + webData2: jest.fn((_params: any, callback: any) => { + // Simulate webData2 data with clearinghouseState (HIP-3 disabled) + setTimeout(() => { + callback({ + clearinghouseState: { + assetPositions: [ + { + position: { szi: '0.1' }, + coin: 'BTC', + }, + ], + marginSummary: { + accountValue: '10000', + totalMarginUsed: '500', + }, + withdrawable: '9500', + }, + openOrders: [ + { + oid: 12345, + coin: 'BTC', + side: 'B', + sz: '0.5', + origSz: '1.0', + limitPx: '50000', + orderType: 'Limit', + timestamp: 1234567890000, + isTrigger: false, + reduceOnly: false, + }, + ], + perpsAtOpenInterestCap: [], + }); + }, 0); + return Promise.resolve(mockSubscription); + }), + userFills: jest.fn((_params: any, callback: any) => { + // Simulate order fill data + setTimeout(() => { + callback({ + fills: [ + { + oid: 12345, + coin: 'BTC', + side: 'B', + sz: '0.1', + px: '50000', + fee: '5', + time: Date.now(), + }, + ], + }); + }, 0); + return Promise.resolve(mockSubscription); + }), + l2Book: jest.fn((_params: any, callback: any) => { + // Simulate l2Book data + setTimeout(() => { + callback({ + coin: _params.coin, + levels: { bids: [], asks: [] }, + }); + }, 0); + return Promise.resolve(mockSubscription); + }), + bbo: jest.fn((_params: any, callback: any) => { + // Simulate BBO data + setTimeout(() => { + callback({ + coin: _params.coin, + time: Date.now(), + bbo: [ + { px: '49900', sz: '1.5', n: 1 }, + { px: '50100', sz: '2.0', n: 1 }, + ], + }); + }, 0); + return Promise.resolve(mockSubscription); + }), + activeAsset: jest.fn((params: any, callback: any) => { + // Simulate activeAsset data (similar to activeAssetCtx) + setTimeout(() => { + callback({ + coin: params.coin, + data: 'test', + }); + }, 0); + return Promise.resolve(mockSubscription); + }), + clearinghouseState: jest.fn((_params: any, callback: any) => { + // Simulate clearinghouseState data for individual subscription + setTimeout(() => { + callback({ + dex: _params.dex || '', + clearinghouseState: { + assetPositions: [ + { + position: { szi: '0.1' }, + coin: 'BTC', + }, + ], + marginSummary: { + accountValue: '10000', + totalMarginUsed: '500', + }, + withdrawable: '9500', + }, + }); + }, 0); + return Promise.resolve(mockSubscription); + }), + openOrders: jest.fn((_params: any, callback: any) => { + // Simulate openOrders data for individual subscription + setTimeout(() => { + callback({ + dex: _params.dex || '', + orders: [ + { + oid: 12345, + coin: 'BTC', + side: 'B', + sz: '0.5', + origSz: '1.0', + limitPx: '50000', + orderType: 'Limit', + timestamp: 1234567890000, + isTrigger: false, + reduceOnly: false, + triggerCondition: '', + triggerPx: '', + children: [], + isPositionTpsl: false, + tif: null, + cloid: null, + }, + ], + }); + }, 0); + return Promise.resolve(mockSubscription); + }), + assetCtxs: jest.fn(() => Promise.resolve(mockSubscription)), + fastAssetCtxs: jest.fn((_callback: any) => + Promise.resolve(mockSubscription), + ), + spotState: jest.fn((_params: any, _callback: any) => + Promise.resolve(mockSubscription), + ), + }; + + mockWalletAdapter = { + request: jest.fn(), + }; + + // Mock client service + mockSpotClearinghouseState = jest.fn().mockResolvedValue({ + balances: [{ coin: 'USDC', total: '100.76531791' }], + }); + + mockClientService = { + ensureSubscriptionClient: jest.fn().mockResolvedValue(undefined), + getSubscriptionClient: jest.fn(() => mockSubscriptionClient), + getInfoClient: jest.fn(() => ({ + spotClearinghouseState: mockSpotClearinghouseState, + // Mode-aware fold gate reads userAbstraction; default to unifiedAccount + // so existing spot-fold assertions behave as before the gate was added. + userAbstraction: jest.fn().mockResolvedValue('unifiedAccount'), + })), + isTestnetMode: jest.fn(() => false), + ensureTransportReady: jest.fn().mockResolvedValue(undefined), + getConnectionState: jest.fn(() => 'connected'), + } as any; + + // Mock wallet service + mockWalletService = { + createWalletAdapter: jest.fn(() => mockWalletAdapter), + getUserAddressWithDefault: jest.fn().mockResolvedValue('0x123' as Hex), + } as any; + + service = new HyperLiquidSubscriptionService( + mockClientService, + mockWalletService, + mockDeps, + true, // hip3Enabled - test expects webData3 + ); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + describe('BBO (Order Book) Subscriptions', () => { + it('should subscribe to BBO when includeOrderBook is true', async () => { + const mockCallback = jest.fn(); + const mockBboSubscription = { + unsubscribe: jest.fn().mockResolvedValue(undefined), + }; + + mockSubscriptionClient.bbo.mockImplementation( + (_params: any, callback: any) => { + // Simulate BBO data + setTimeout(() => { + callback({ + coin: 'BTC', + time: Date.now(), + bbo: [ + { px: '49900', sz: '1.5', n: 1 }, // Bid + { px: '50100', sz: '2.0', n: 1 }, // Ask + ], + }); + }, 0); + return Promise.resolve(mockBboSubscription); + }, + ); + + const unsubscribe = await service.subscribeToPrices({ + symbols: ['BTC'], + callback: mockCallback, + includeOrderBook: true, + }); + + // Wait for subscription and data processing + await jest.runAllTimersAsync(); + + // Verify BBO subscription was created + expect(mockSubscriptionClient.bbo).toHaveBeenCalledWith( + { coin: 'BTC' }, + expect.any(Function), + ); + + // Verify callback received bid/ask data + expect(mockCallback).toHaveBeenCalled(); + const lastCall = + mockCallback.mock.calls[mockCallback.mock.calls.length - 1][0]; + expect(lastCall).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + symbol: 'BTC', + bestBid: '49900', + bestAsk: '50100', + }), + ]), + ); + + unsubscribe(); + }); + + it('should not subscribe to BBO when includeOrderBook is false', async () => { + const mockCallback = jest.fn(); + + const unsubscribe = await service.subscribeToPrices({ + symbols: ['BTC'], + callback: mockCallback, + includeOrderBook: false, + }); + + // Wait for any potential subscriptions + await jest.runAllTimersAsync(); + + // Verify BBO subscription was NOT created + expect(mockSubscriptionClient.bbo).not.toHaveBeenCalled(); + + unsubscribe(); + }); + + it('should handle multiple BBO subscriptions with reference counting', async () => { + const mockCallback1 = jest.fn(); + const mockCallback2 = jest.fn(); + const mockUnsubscribe = jest.fn().mockResolvedValue(undefined); + mockSubscriptionClient.bbo.mockResolvedValue({ + unsubscribe: mockUnsubscribe, + }); + + // First subscription + const unsubscribe1 = await service.subscribeToPrices({ + symbols: ['BTC'], + callback: mockCallback1, + includeOrderBook: true, + }); + + await jest.runAllTimersAsync(); + + // Second subscription to same symbol + const unsubscribe2 = await service.subscribeToPrices({ + symbols: ['BTC'], + callback: mockCallback2, + includeOrderBook: true, + }); + + await jest.runAllTimersAsync(); + + // Should only create one L2 book subscription + expect(mockSubscriptionClient.bbo).toHaveBeenCalledTimes(1); + + // Unsubscribe first + unsubscribe1(); + await jest.runAllTimersAsync(); + + // BBO subscription should still be active + expect(mockSubscriptionClient.bbo).toHaveBeenCalledTimes(1); + expect(mockUnsubscribe).not.toHaveBeenCalled(); + + // Unsubscribe second + unsubscribe2(); + await jest.runAllTimersAsync(); + expect(mockUnsubscribe).toHaveBeenCalledTimes(1); + }); + + it('should handle BBO data with missing levels gracefully', async () => { + const mockCallback = jest.fn(); + + mockSubscriptionClient.bbo.mockImplementation( + (_params: any, callback: any) => { + // Simulate BBO data with missing levels + setTimeout(() => { + callback({ + coin: 'BTC', + time: Date.now(), + bbo: [undefined, undefined], + }); + }, 0); + return Promise.resolve({ + unsubscribe: jest.fn().mockResolvedValue(undefined), + }); + }, + ); + + const unsubscribe = await service.subscribeToPrices({ + symbols: ['BTC'], + callback: mockCallback, + includeOrderBook: true, + }); + + // Wait for subscription and data processing + await jest.runAllTimersAsync(); + + // Should still receive price updates, but without bid/ask + expect(mockCallback).toHaveBeenCalled(); + const { calls } = mockCallback.mock; + const lastCall = calls[calls.length - 1][0]; + + // Check that bestBid and bestAsk are either undefined or '0' + if (lastCall?.[0]) { + expect( + lastCall[0].bestBid === undefined || lastCall[0].bestBid === '0', + ).toBeTruthy(); + expect( + lastCall[0].bestAsk === undefined || lastCall[0].bestAsk === '0', + ).toBeTruthy(); + } + + unsubscribe(); + }); + + it('should handle BBO subscription errors', async () => { + const mockCallback = jest.fn(); + + mockSubscriptionClient.bbo.mockRejectedValue( + new Error('BBO subscription failed'), + ); + + const unsubscribe = await service.subscribeToPrices({ + symbols: ['BTC'], + callback: mockCallback, + includeOrderBook: true, + }); + + // Wait for subscription attempt + await jest.runAllTimersAsync(); + + // Error should be handled internally + // Just verify the subscription still works + expect(mockCallback).toHaveBeenCalled(); + + unsubscribe(); + }); + + it('should calculate spread from bid/ask prices', async () => { + const mockCallback = jest.fn(); + + mockSubscriptionClient.bbo.mockImplementation( + (_params: any, callback: any) => { + setTimeout(() => { + callback({ + coin: 'BTC', + time: Date.now(), + bbo: [ + { px: '49900', sz: '1.5', n: 1 }, // Bid + { px: '50100', sz: '2.0', n: 1 }, // Ask + ], + }); + }, 0); + return Promise.resolve({ + unsubscribe: jest.fn().mockResolvedValue(undefined), + }); + }, + ); + + const unsubscribe = await service.subscribeToPrices({ + symbols: ['BTC'], + callback: mockCallback, + includeOrderBook: true, + }); + + await jest.runAllTimersAsync(); + + expect(mockCallback).toHaveBeenCalled(); + const { calls } = mockCallback.mock; + const lastCall = calls[calls.length - 1][0]; + expect(lastCall).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + symbol: 'BTC', + bestBid: '49900', + bestAsk: '50100', + spread: '200.00000', // 50100 - 49900 + }), + ]), + ); + + unsubscribe(); + }); + }); + + describe('TP/SL Order Processing', () => { + it('should process Take Profit orders correctly', async () => { + const mockCallback = jest.fn(); + + // HIP-3 mode uses individual subscriptions (clearinghouseState + openOrders) + // Mock clearinghouseState with position data + mockSubscriptionClient.clearinghouseState.mockImplementation( + (_params: any, callback: any) => { + setTimeout(() => { + callback({ + dex: _params.dex || '', + clearinghouseState: { + assetPositions: [ + { + position: { szi: '1.0', coin: 'BTC' }, + coin: 'BTC', + }, + ], + marginSummary: { + accountValue: '10000', + totalMarginUsed: '500', + }, + withdrawable: '9500', + }, + }); + }, 0); + return Promise.resolve({ + unsubscribe: jest.fn().mockResolvedValue(undefined), + }); + }, + ); + + // Mock openOrders with TP/SL trigger orders + mockSubscriptionClient.openOrders.mockImplementation( + (_params: any, callback: any) => { + setTimeout(() => { + callback({ + dex: _params.dex || '', + orders: [ + { + oid: 123, + coin: 'BTC', + side: 'S', // Sell order (opposite of long position) + sz: '1.0', + triggerPx: '55000', // Take profit trigger price + orderType: 'Take Profit', + reduceOnly: true, + isPositionTpsl: true, + limitPx: '55000', + origSz: '1.0', + timestamp: Date.now(), + isTrigger: true, + triggerCondition: '', + children: [], + tif: null, + cloid: null, + }, + ], + }); + }, 5); // Slight delay to ensure clearinghouseState fires first + return Promise.resolve({ + unsubscribe: jest.fn().mockResolvedValue(undefined), + }); + }, + ); + + const unsubscribe = service.subscribeToPositions({ + callback: mockCallback, + }); + + await jest.runAllTimersAsync(); + + // Should receive position with takeProfitPrice set + expect(mockCallback).toHaveBeenCalledWith([ + expect.objectContaining({ + symbol: 'BTC', + takeProfitPrice: '55000', + takeProfitCount: 1, + stopLossCount: 0, + }), + ]); + + unsubscribe(); + }); + + it('reports a lone partial take profit as the position take profit price', async () => { + const mockCallback = jest.fn(); + + mockSubscriptionClient.clearinghouseState.mockImplementation( + (_params: any, callback: any) => { + setTimeout(() => { + callback({ + dex: _params.dex || '', + clearinghouseState: { + assetPositions: [ + { + position: { szi: '1.0', coin: 'BTC' }, + coin: 'BTC', + }, + ], + marginSummary: { + accountValue: '10000', + totalMarginUsed: '500', + }, + withdrawable: '9500', + }, + }); + }, 0); + return Promise.resolve({ + unsubscribe: jest.fn().mockResolvedValue(undefined), + }); + }, + ); + + // A quantity-scoped take profit is placed with 'na' grouping, so it is a + // standalone reduce-only trigger and never reaches the position-bound scan. + mockSubscriptionClient.openOrders.mockImplementation( + (_params: any, callback: any) => { + setTimeout(() => { + callback({ + dex: _params.dex || '', + orders: [ + { + oid: 321, + coin: 'BTC', + side: 'S', + sz: '0.4', + triggerPx: '55000', + orderType: 'Take Profit Limit', + reduceOnly: true, + isPositionTpsl: false, + limitPx: '55000', + origSz: '0.4', + timestamp: Date.now(), + isTrigger: true, + triggerCondition: '', + children: [], + tif: null, + cloid: null, + }, + ], + }); + }, 5); + return Promise.resolve({ + unsubscribe: jest.fn().mockResolvedValue(undefined), + }); + }, + ); + + const unsubscribe = service.subscribeToPositions({ + callback: mockCallback, + }); + + await jest.runAllTimersAsync(); + + expect(mockCallback).toHaveBeenCalledWith([ + expect.objectContaining({ + symbol: 'BTC', + takeProfitPrice: '55000', + takeProfitCount: 1, + stopLossCount: 0, + takeProfitOrders: [ + expect.objectContaining({ orderId: '321', isPartial: true }), + ], + }), + ]); + + unsubscribe(); + }); + + it('should process Stop Loss orders correctly', async () => { + const mockCallback = jest.fn(); + + mockSubscriptionClient.clearinghouseState.mockImplementation( + (_params: any, callback: any) => { + setTimeout(() => { + callback({ + dex: _params.dex || '', + clearinghouseState: { + assetPositions: [ + { + position: { szi: '1.0', coin: 'BTC' }, + coin: 'BTC', + }, + ], + marginSummary: { + accountValue: '10000', + totalMarginUsed: '500', + }, + withdrawable: '9500', + }, + }); + }, 0); + return Promise.resolve({ + unsubscribe: jest.fn().mockResolvedValue(undefined), + }); + }, + ); + + mockSubscriptionClient.openOrders.mockImplementation( + (_params: any, callback: any) => { + setTimeout(() => { + callback({ + dex: _params.dex || '', + orders: [ + { + oid: 124, + coin: 'BTC', + side: 'S', + sz: '1.0', + triggerPx: '45000', // Stop loss trigger price + orderType: 'Stop', + reduceOnly: true, + isPositionTpsl: true, + limitPx: '45000', + origSz: '1.0', + timestamp: Date.now(), + isTrigger: true, + triggerCondition: '', + children: [], + tif: null, + cloid: null, + }, + ], + }); + }, 5); + return Promise.resolve({ + unsubscribe: jest.fn().mockResolvedValue(undefined), + }); + }, + ); + + const unsubscribe = service.subscribeToPositions({ + callback: mockCallback, + }); + + await jest.runAllTimersAsync(); + + // Should receive position with stopLossPrice set + expect(mockCallback).toHaveBeenCalledWith([ + expect.objectContaining({ + symbol: 'BTC', + stopLossPrice: '45000', + takeProfitCount: 0, + stopLossCount: 1, + }), + ]); + + unsubscribe(); + }); + + it('should handle multiple TP/SL orders for same position', async () => { + const mockCallback = jest.fn(); + + mockSubscriptionClient.clearinghouseState.mockImplementation( + (_params: any, callback: any) => { + setTimeout(() => { + callback({ + dex: _params.dex || '', + clearinghouseState: { + assetPositions: [ + { + position: { szi: '2.0', coin: 'BTC' }, + coin: 'BTC', + }, + ], + marginSummary: { + accountValue: '10000', + totalMarginUsed: '500', + }, + withdrawable: '9500', + }, + }); + }, 0); + return Promise.resolve({ + unsubscribe: jest.fn().mockResolvedValue(undefined), + }); + }, + ); + + mockSubscriptionClient.openOrders.mockImplementation( + (_params: any, callback: any) => { + setTimeout(() => { + callback({ + dex: _params.dex || '', + orders: [ + { + oid: 125, + coin: 'BTC', + side: 'S', + sz: '1.0', + triggerPx: '55000', + orderType: 'Take Profit', + reduceOnly: true, + isPositionTpsl: true, + limitPx: '55000', + origSz: '1.0', + timestamp: Date.now(), + isTrigger: true, + triggerCondition: '', + children: [], + tif: null, + cloid: null, + }, + { + oid: 126, + coin: 'BTC', + side: 'S', + sz: '1.0', + triggerPx: '56000', + orderType: 'Take Profit', + reduceOnly: true, + isPositionTpsl: true, + limitPx: '56000', + origSz: '1.0', + timestamp: Date.now(), + isTrigger: true, + triggerCondition: '', + children: [], + tif: null, + cloid: null, + }, + { + oid: 127, + coin: 'BTC', + side: 'S', + sz: '0.5', + triggerPx: '45000', + orderType: 'Stop', + reduceOnly: true, + isPositionTpsl: true, + limitPx: '45000', + origSz: '0.5', + timestamp: Date.now(), + isTrigger: true, + triggerCondition: '', + children: [], + tif: null, + cloid: null, + }, + ], + }); + }, 5); + return Promise.resolve({ + unsubscribe: jest.fn().mockResolvedValue(undefined), + }); + }, + ); + + const unsubscribe = service.subscribeToPositions({ + callback: mockCallback, + }); + + await jest.runAllTimersAsync(); + + // Should receive position with correct counts but only last TP/SL prices + expect(mockCallback).toHaveBeenCalledWith([ + expect.objectContaining({ + symbol: 'BTC', + takeProfitCount: 2, + stopLossCount: 1, + // Should have the last processed prices + takeProfitPrice: expect.any(String), + stopLossPrice: '45000', + }), + ]); + + unsubscribe(); + }); + + it('should fallback to price-based TP/SL detection when orderType is ambiguous', async () => { + const mockCallback = jest.fn(); + + // Mock the adapter to include entryPrice + const mockAdapter = jest.requireMock( + '../../../src/utils/hyperLiquidAdapter', + ); + mockAdapter.adaptPositionFromSDK.mockImplementationOnce(() => ({ + symbol: 'BTC', + size: '1.0', + entryPrice: '50000', + positionValue: '50000', + unrealizedPnl: '5000', + marginUsed: '25000', + leverage: { type: 'cross', value: 2 }, + liquidationPrice: '40000', + maxLeverage: 100, + returnOnEquity: '10.0', + cumulativeFunding: { allTime: '0', sinceOpen: '0', sinceChange: '0' }, + takeProfitCount: 0, + stopLossCount: 0, + })); + + mockSubscriptionClient.clearinghouseState.mockImplementation( + (_params: any, callback: any) => { + setTimeout(() => { + callback({ + dex: _params.dex || '', + clearinghouseState: { + assetPositions: [ + { + position: { + szi: '1.0', + coin: 'BTC', + entryPrice: '50000', + }, + coin: 'BTC', + }, + ], + marginSummary: { + accountValue: '10000', + totalMarginUsed: '500', + }, + withdrawable: '9500', + }, + }); + }, 0); + return Promise.resolve({ + unsubscribe: jest.fn().mockResolvedValue(undefined), + }); + }, + ); + + mockSubscriptionClient.openOrders.mockImplementation( + (_params: any, callback: any) => { + setTimeout(() => { + callback({ + dex: _params.dex || '', + orders: [ + { + oid: 128, + coin: 'BTC', + side: 'S', + sz: '1.0', + triggerPx: '55000', // Above entry price = Take Profit for long + orderType: 'Trigger', // Ambiguous order type + reduceOnly: true, + isPositionTpsl: true, + limitPx: '55000', + origSz: '1.0', + timestamp: Date.now(), + isTrigger: true, + triggerCondition: '', + children: [], + tif: null, + cloid: null, + }, + { + oid: 129, + coin: 'BTC', + side: 'S', + sz: '1.0', + triggerPx: '45000', // Below entry price = Stop Loss for long + orderType: 'Trigger', // Ambiguous order type + reduceOnly: true, + isPositionTpsl: true, + limitPx: '45000', + origSz: '1.0', + timestamp: Date.now(), + isTrigger: true, + triggerCondition: '', + children: [], + tif: null, + cloid: null, + }, + ], + }); + }, 5); + return Promise.resolve({ + unsubscribe: jest.fn().mockResolvedValue(undefined), + }); + }, + ); + + const unsubscribe = service.subscribeToPositions({ + callback: mockCallback, + }); + + await jest.runAllTimersAsync(); + + // An ambiguous 'Trigger' names neither direction nor execution. The + // direction is recovered from the trigger price against the entry, so the + // order still reaches its array and the count that derives from it — with + // the execution mode left unstated rather than guessed. Counts and arrays + // are asserted together: a count disagreeing with its own array is a + // state no subscriber can render. + expect(mockCallback).toHaveBeenCalledWith([ + expect.objectContaining({ + symbol: 'BTC', + takeProfitPrice: '55000', // Above entry price + stopLossPrice: '45000', // Below entry price + takeProfitCount: 1, + stopLossCount: 1, + takeProfitOrders: [ + expect.objectContaining({ + orderId: '128', + direction: 'take_profit', + orderType: undefined, + triggerPrice: '55000', + }), + ], + stopLossOrders: [ + expect.objectContaining({ + orderId: '129', + direction: 'stop', + orderType: undefined, + triggerPrice: '45000', + }), + ], + }), + ]); + + unsubscribe(); + }); + + it('should handle short position TP/SL logic correctly', async () => { + const mockCallback = jest.fn(); + + // Mock the adapter for short position + const mockAdapter = jest.requireMock( + '../../../src/utils/hyperLiquidAdapter', + ); + mockAdapter.adaptPositionFromSDK.mockImplementationOnce(() => ({ + symbol: 'BTC', + size: '-1.0', // Short position + entryPrice: '50000', + positionValue: '50000', + unrealizedPnl: '5000', + marginUsed: '25000', + leverage: { type: 'cross', value: 2 }, + liquidationPrice: '60000', + maxLeverage: 100, + returnOnEquity: '10.0', + cumulativeFunding: { allTime: '0', sinceOpen: '0', sinceChange: '0' }, + takeProfitCount: 0, + stopLossCount: 0, + })); + + mockSubscriptionClient.clearinghouseState.mockImplementation( + (_params: any, callback: any) => { + setTimeout(() => { + callback({ + dex: _params.dex || '', + clearinghouseState: { + assetPositions: [ + { + position: { + szi: '-1.0', // Short position (negative size) + coin: 'BTC', + entryPrice: '50000', + }, + coin: 'BTC', + }, + ], + marginSummary: { + accountValue: '10000', + totalMarginUsed: '500', + }, + withdrawable: '9500', + }, + }); + }, 0); + return Promise.resolve({ + unsubscribe: jest.fn().mockResolvedValue(undefined), + }); + }, + ); + + mockSubscriptionClient.openOrders.mockImplementation( + (_params: any, callback: any) => { + setTimeout(() => { + callback({ + dex: _params.dex || '', + orders: [ + { + oid: 130, + coin: 'BTC', + side: 'B', // Buy order (opposite of short position) + sz: '1.0', + triggerPx: '45000', // Below entry price = Take Profit for short + orderType: 'Trigger', + reduceOnly: true, + isPositionTpsl: true, + limitPx: '45000', + origSz: '1.0', + timestamp: Date.now(), + isTrigger: true, + triggerCondition: '', + children: [], + tif: null, + cloid: null, + }, + { + oid: 131, + coin: 'BTC', + side: 'B', + sz: '1.0', + triggerPx: '55000', // Above entry price = Stop Loss for short + orderType: 'Trigger', + reduceOnly: true, + isPositionTpsl: true, + limitPx: '55000', + origSz: '1.0', + timestamp: Date.now(), + isTrigger: true, + triggerCondition: '', + children: [], + tif: null, + cloid: null, + }, + ], + }); + }, 5); + return Promise.resolve({ + unsubscribe: jest.fn().mockResolvedValue(undefined), + }); + }, + ); + + const unsubscribe = service.subscribeToPositions({ + callback: mockCallback, + }); + + await jest.runAllTimersAsync(); + + // For short positions: TP when trigger < entry, SL when trigger > entry + // With the fix, ambiguous 'Trigger' orders are now counted correctly using price-based fallback + expect(mockCallback).toHaveBeenCalledWith([ + expect.objectContaining({ + symbol: 'BTC', + takeProfitPrice: '45000', // Below entry price for short + stopLossPrice: '55000', // Above entry price for short + takeProfitCount: 1, // Ambiguous orders now counted via price-based fallback + stopLossCount: 1, // Ambiguous orders now counted via price-based fallback + }), + ]); + + unsubscribe(); + }); + + it('should include TP/SL orders in the orders list', async () => { + const mockCallback = jest.fn(); + + // Create service with enabledDexs to skip DEX discovery wait + const hip3Service = new HyperLiquidSubscriptionService( + mockClientService, + mockWalletService, + mockDeps, + true, // hip3Enabled + [], // enabledDexs - empty but we'll call updateFeatureFlags + ); + + // Simulate DEX discovery by calling updateFeatureFlags + await hip3Service.updateFeatureFlags(true, [''], [], []); + + mockSubscriptionClient.clearinghouseState.mockImplementation( + (_params: any, callback: any) => { + setTimeout(() => { + callback({ + dex: _params.dex || '', + clearinghouseState: { + assetPositions: [ + { + position: { szi: '1.0', coin: 'BTC' }, + coin: 'BTC', + }, + ], + marginSummary: { + accountValue: '10000', + totalMarginUsed: '500', + }, + withdrawable: '9500', + }, + }); + }, 0); + return Promise.resolve({ + unsubscribe: jest.fn().mockResolvedValue(undefined), + }); + }, + ); + + mockSubscriptionClient.openOrders.mockImplementation( + (_params: any, callback: any) => { + setTimeout(() => { + callback({ + dex: _params.dex || '', + orders: [ + { + oid: 132, + coin: 'BTC', + side: 'S', + sz: '1.0', + triggerPx: '55000', + orderType: 'Take Profit', + reduceOnly: true, + isPositionTpsl: true, + limitPx: '55000', + origSz: '1.0', + timestamp: Date.now(), + isTrigger: true, + triggerCondition: '', + children: [], + tif: null, + cloid: null, + }, + { + oid: 133, + coin: 'BTC', + side: 'B', + sz: '0.5', + limitPx: '49000', + orderType: 'Limit', + reduceOnly: false, + isPositionTpsl: false, + origSz: '0.5', + timestamp: Date.now(), + isTrigger: false, + triggerCondition: '', + triggerPx: '', + children: [], + tif: null, + cloid: null, + }, + ], + }); + }, 5); + return Promise.resolve({ + unsubscribe: jest.fn().mockResolvedValue(undefined), + }); + }, + ); + + const unsubscribe = hip3Service.subscribeToOrders({ + callback: mockCallback, + }); + + await jest.runAllTimersAsync(); + + // Should include both TP/SL and regular orders + expect(mockCallback).toHaveBeenCalledWith([ + expect.objectContaining({ + orderId: '132', + symbol: 'BTC', + detailedOrderType: 'Take Profit', + }), + expect.objectContaining({ + orderId: '133', + symbol: 'BTC', + detailedOrderType: 'Limit', + }), + ]); + + unsubscribe(); + }); + + it('should handle positions without matching TP/SL orders', async () => { + const mockCallback = jest.fn(); + + // Mock the adapter to return both positions + const mockAdapter = jest.requireMock( + '../../../src/utils/hyperLiquidAdapter', + ); + mockAdapter.adaptPositionFromSDK + .mockImplementationOnce((_assetPos: any) => ({ + symbol: 'BTC', + size: '1.0', + entryPrice: '50000', + positionValue: '50000', + unrealizedPnl: '5000', + marginUsed: '25000', + leverage: { type: 'cross', value: 2 }, + liquidationPrice: '40000', + maxLeverage: 100, + returnOnEquity: '10.0', + cumulativeFunding: { allTime: '0', sinceOpen: '0', sinceChange: '0' }, + takeProfitCount: 0, + stopLossCount: 0, + })) + .mockImplementationOnce(() => ({ + symbol: 'ETH', + size: '2.0', + entryPrice: '3000', + positionValue: '6000', + unrealizedPnl: '1000', + marginUsed: '3000', + leverage: { type: 'isolated', value: 2 }, + liquidationPrice: '2500', + maxLeverage: 50, + returnOnEquity: '16.7', + cumulativeFunding: { allTime: '0', sinceOpen: '0', sinceChange: '0' }, + takeProfitCount: 0, + stopLossCount: 0, + })); + + mockSubscriptionClient.clearinghouseState.mockImplementation( + (_params: any, callback: any) => { + setTimeout(() => { + callback({ + dex: _params.dex || '', + clearinghouseState: { + assetPositions: [ + { + position: { szi: '1.0', coin: 'BTC' }, + coin: 'BTC', + }, + { + position: { szi: '2.0', coin: 'ETH' }, + coin: 'ETH', + }, + ], + marginSummary: { + accountValue: '10000', + totalMarginUsed: '500', + }, + withdrawable: '9500', + }, + }); + }, 0); + return Promise.resolve({ + unsubscribe: jest.fn().mockResolvedValue(undefined), + }); + }, + ); + + mockSubscriptionClient.openOrders.mockImplementation( + (_params: any, callback: any) => { + setTimeout(() => { + callback({ + dex: _params.dex || '', + orders: [ + { + oid: 134, + coin: 'BTC', // Only BTC has TP/SL orders + side: 'S', + sz: '1.0', + triggerPx: '55000', + orderType: 'Take Profit', + reduceOnly: true, + isPositionTpsl: true, + limitPx: '55000', + origSz: '1.0', + timestamp: Date.now(), + isTrigger: true, + triggerCondition: '', + children: [], + tif: null, + cloid: null, + }, + ], + }); + }, 5); + return Promise.resolve({ + unsubscribe: jest.fn().mockResolvedValue(undefined), + }); + }, + ); + + const unsubscribe = service.subscribeToPositions({ + callback: mockCallback, + }); + + await jest.runAllTimersAsync(); + + // Should handle positions with and without TP/SL + expect(mockCallback).toHaveBeenCalledWith([ + expect.objectContaining({ + symbol: 'BTC', + takeProfitPrice: '55000', + takeProfitCount: 1, + stopLossCount: 0, + }), + expect.objectContaining({ + symbol: 'ETH', + takeProfitPrice: undefined, + stopLossPrice: undefined, + takeProfitCount: 0, + stopLossCount: 0, + }), + ]); + + unsubscribe(); + }); + + it('keeps streamed TP/SL counts consistent with the trigger arrays for standalone partial triggers', async () => { + const mockCallback = jest.fn(); + + // The suite's adapter mock omits triggerOrderType, which the trigger + // arrays are built from; extend it the way the real adapter fills it. + const mockAdapter = jest.requireMock( + '../../../src/utils/hyperLiquidAdapter', + ); + const baseAdaptOrder = + mockAdapter.adaptOrderFromSDK.getMockImplementation(); + mockAdapter.adaptOrderFromSDK.mockImplementation((order: any) => ({ + ...baseAdaptOrder(order), + triggerOrderType: order.orderType.includes('Limit') + ? 'take_profit_limit' + : 'take_profit_market', + })); + + mockSubscriptionClient.clearinghouseState.mockImplementation( + (_params: any, callback: any) => { + setTimeout(() => { + callback({ + dex: _params.dex || '', + clearinghouseState: { + assetPositions: [ + { + position: { szi: '1.0', coin: 'BTC' }, + coin: 'BTC', + }, + ], + marginSummary: { + accountValue: '10000', + totalMarginUsed: '500', + }, + withdrawable: '9500', + }, + }); + }, 0); + return Promise.resolve({ + unsubscribe: jest.fn().mockResolvedValue(undefined), + }); + }, + ); + + mockSubscriptionClient.openOrders.mockImplementation( + (_params: any, callback: any) => { + setTimeout(() => { + callback({ + dex: _params.dex || '', + orders: [ + { + // Whole-position TP + oid: 134, + coin: 'BTC', + side: 'S', + sz: '1.0', + triggerPx: '55000', + orderType: 'Take Profit', + reduceOnly: true, + isPositionTpsl: true, + limitPx: '55000', + origSz: '1.0', + timestamp: Date.now(), + isTrigger: true, + triggerCondition: '', + children: [], + tif: null, + cloid: null, + }, + { + // Standalone partial TP: not position-bound, owned by no + // other order + oid: 135, + coin: 'BTC', + side: 'S', + sz: '0.4', + triggerPx: '58000', + orderType: 'Take Profit Limit', + reduceOnly: true, + isPositionTpsl: false, + limitPx: '58000', + origSz: '0.4', + timestamp: Date.now(), + isTrigger: true, + triggerCondition: '', + children: [], + tif: null, + cloid: null, + }, + ], + }); + }, 5); + return Promise.resolve({ + unsubscribe: jest.fn().mockResolvedValue(undefined), + }); + }, + ); + + const unsubscribe = service.subscribeToPositions({ + callback: mockCallback, + }); + + await jest.runAllTimersAsync(); + + const lastCall = + mockCallback.mock.calls[mockCallback.mock.calls.length - 1]; + const btcPosition = lastCall[0].find( + (pos: { symbol: string }) => pos.symbol === 'BTC', + ); + + // Counts must agree with the arrays: both triggers are the position's, + // even though only one is position-bound. + expect(btcPosition.takeProfitOrders).toHaveLength(2); + expect(btcPosition.takeProfitCount).toBe(2); + expect(btcPosition.stopLossOrders).toHaveLength(0); + expect(btcPosition.stopLossCount).toBe(0); + + unsubscribe(); + }); + + it('should re-extract TP/SL from cached orders when clearinghouseState updates', async () => { + // Arrange + const mockCallback = jest.fn(); + let clearinghouseStateCallback: (data: any) => void = () => undefined; + + // Setup adapter to return positions with symbol matching the orders + const mockAdapter = jest.requireMock( + '../../../src/utils/hyperLiquidAdapter', + ); + mockAdapter.adaptPositionFromSDK.mockImplementation((assetPos: any) => ({ + symbol: assetPos.position.coin || assetPos.coin, + size: assetPos.position.szi, + entryPrice: '50000', + positionValue: '50000', + unrealizedPnl: '5000', + marginUsed: '25000', + leverage: { type: 'cross', value: 2 }, + liquidationPrice: '40000', + maxLeverage: 100, + returnOnEquity: '10.0', + cumulativeFunding: { allTime: '0', sinceOpen: '0', sinceChange: '0' }, + takeProfitCount: 0, + stopLossCount: 0, + })); + + mockSubscriptionClient.clearinghouseState.mockImplementation( + (_params: any, callback: any) => { + // Store callback for later invocation + clearinghouseStateCallback = callback; + // Fire first update immediately (before orders are cached) + setTimeout(() => { + callback({ + dex: _params.dex || '', + clearinghouseState: { + assetPositions: [ + { + position: { szi: '1.0', coin: 'BTC' }, + coin: 'BTC', + }, + ], + marginSummary: { + accountValue: '10000', + totalMarginUsed: '500', + }, + withdrawable: '9500', + }, + }); + }, 0); + return Promise.resolve({ + unsubscribe: jest.fn().mockResolvedValue(undefined), + }); + }, + ); + + // openOrders fires at 10ms to cache trigger orders + mockSubscriptionClient.openOrders.mockImplementation( + (_params: any, callback: any) => { + setTimeout(() => { + callback({ + dex: _params.dex || '', + orders: [ + { + oid: 200, + coin: 'BTC', + side: 'S', + sz: '1.0', + triggerPx: '60000', + orderType: 'Take Profit', + reduceOnly: true, + isPositionTpsl: true, + limitPx: '60000', + origSz: '1.0', + timestamp: Date.now(), + isTrigger: true, + triggerCondition: '', + children: [], + tif: null, + cloid: null, + }, + { + oid: 201, + coin: 'BTC', + side: 'S', + sz: '1.0', + triggerPx: '40000', + orderType: 'Stop Market', + reduceOnly: true, + isPositionTpsl: true, + limitPx: '40000', + origSz: '1.0', + timestamp: Date.now(), + isTrigger: true, + triggerCondition: '', + children: [], + tif: null, + cloid: null, + }, + ], + }); + }, 10); + return Promise.resolve({ + unsubscribe: jest.fn().mockResolvedValue(undefined), + }); + }, + ); + + // Act - subscribe to positions + const unsubscribe = service.subscribeToPositions({ + callback: mockCallback, + }); + + // Wait for openOrders to fire and cache orders + await jest.runAllTimersAsync(); + + // Simulate a subsequent clearinghouseState update (which will use cached orders) + clearinghouseStateCallback({ + dex: '', + clearinghouseState: { + assetPositions: [ + { + position: { szi: '1.5', coin: 'BTC' }, + coin: 'BTC', + }, + ], + marginSummary: { + accountValue: '11000', + totalMarginUsed: '600', + }, + withdrawable: '10400', + }, + }); + + await jest.runAllTimersAsync(); + + // Assert - callback should have been called with TP/SL re-extracted from cached orders + const lastCall = + mockCallback.mock.calls[mockCallback.mock.calls.length - 1]; + expect(lastCall[0]).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + symbol: 'BTC', + takeProfitPrice: '60000', + stopLossPrice: '40000', + takeProfitCount: 1, + stopLossCount: 1, + }), + ]), + ); + + unsubscribe(); + }); + + it('preserves TP/SL data from cached orders with ambiguous Trigger type on clearinghouseState updates', async () => { + const mockCallback = jest.fn(); + + // Mock the adapter for long position - returns position with size from input + const mockAdapter = jest.requireMock( + '../../../src/utils/hyperLiquidAdapter', + ); + mockAdapter.adaptPositionFromSDK.mockImplementation( + (assetPos: { position: { szi: string } }) => ({ + symbol: 'BTC', + size: assetPos.position.szi, // Use actual size from input + entryPrice: '50000', + positionValue: '50000', + unrealizedPnl: '5000', + marginUsed: '25000', + leverage: { type: 'cross', value: 2 }, + liquidationPrice: '40000', + maxLeverage: 100, + returnOnEquity: '10.0', + cumulativeFunding: { allTime: '0', sinceOpen: '0', sinceChange: '0' }, + takeProfitCount: 0, + stopLossCount: 0, + }), + ); + + // Track callback invocations for clearinghouseState + const callbackRef: { current: ((data: any) => void) | null } = { + current: null, + }; + mockSubscriptionClient.clearinghouseState.mockImplementation( + (_params: any, callback: any) => { + callbackRef.current = callback; + setTimeout(() => { + callback({ + dex: _params.dex || '', + clearinghouseState: { + assetPositions: [ + { + position: { + szi: '1.0', + coin: 'BTC', + entryPrice: '50000', + }, + coin: 'BTC', + }, + ], + marginSummary: { + accountValue: '10000', + totalMarginUsed: '500', + }, + withdrawable: '9500', + }, + }); + }, 0); + return Promise.resolve({ + unsubscribe: jest.fn().mockResolvedValue(undefined), + }); + }, + ); + + // Orders with ambiguous 'Trigger' type (no 'Take Profit' or 'Stop' in orderType) + // These should be classified by price: above entry = TP, below entry = SL + mockSubscriptionClient.openOrders.mockImplementation( + (_params: any, callback: any) => { + setTimeout(() => { + callback({ + dex: _params.dex || '', + orders: [ + { + oid: 200, + coin: 'BTC', + side: 'S', + sz: '1.0', + triggerPx: '55000', // Above entry price = Take Profit for long + orderType: 'Trigger', // Ambiguous order type + reduceOnly: true, + isPositionTpsl: true, + limitPx: '55000', + origSz: '1.0', + timestamp: Date.now(), + isTrigger: true, + triggerCondition: '', + children: [], + tif: null, + cloid: null, + }, + { + oid: 201, + coin: 'BTC', + side: 'S', + sz: '1.0', + triggerPx: '45000', // Below entry price = Stop Loss for long + orderType: 'Trigger', // Ambiguous order type + reduceOnly: true, + isPositionTpsl: true, + limitPx: '45000', + origSz: '1.0', + timestamp: Date.now(), + isTrigger: true, + triggerCondition: '', + children: [], + tif: null, + cloid: null, + }, + ], + }); + }, 5); // openOrders arrives after clearinghouseState + return Promise.resolve({ + unsubscribe: jest.fn().mockResolvedValue(undefined), + }); + }, + ); + + const unsubscribe = service.subscribeToPositions({ + callback: mockCallback, + }); + + // Wait for initial subscription setup and callbacks + await jest.runAllTimersAsync(); + + // Verify initial TP/SL extraction worked + expect(mockCallback).toHaveBeenCalledWith( + expect.arrayContaining([ + expect.objectContaining({ + symbol: 'BTC', + takeProfitPrice: '55000', + stopLossPrice: '45000', + }), + ]), + ); + + // Clear mock to track subsequent calls + mockCallback.mockClear(); + + // Simulate a subsequent clearinghouseState update (e.g., position size change) + // This triggers re-extraction of TP/SL from CACHED orders + // Note: We change szi slightly to ensure positionsHash changes and callback is triggered + expect(callbackRef.current).not.toBeNull(); + if (callbackRef.current) { + callbackRef.current({ + dex: '', + clearinghouseState: { + assetPositions: [ + { + position: { + szi: '1.1', // Changed from 1.0 - ensures positionsHash differs and callback fires + coin: 'BTC', + entryPrice: '50000', + }, + coin: 'BTC', + }, + ], + marginSummary: { + accountValue: '10500', // Changed - simulates PnL update + totalMarginUsed: '500', + }, + withdrawable: '10000', + }, + }); + } + + await jest.runAllTimersAsync(); + + // TP/SL should still be present after re-extraction from cached orders + // This is the bug fix: cached orders with 'Trigger' type should use price-based fallback + expect(mockCallback).toHaveBeenCalledWith( + expect.arrayContaining([ + expect.objectContaining({ + symbol: 'BTC', + takeProfitPrice: '55000', // Should persist + stopLossPrice: '45000', // Should persist + }), + ]), + ); + + unsubscribe(); + }); + }); + + describe('Race condition prevention', () => { + it('should prevent duplicate allMids subscriptions when multiple subscribeToPrices calls happen simultaneously', async () => { + const callbacks = [jest.fn(), jest.fn(), jest.fn()]; + const unsubscribes: (() => void)[] = []; + + // Call subscribeToPrices multiple times simultaneously + const subscribePromises = callbacks.map(async (callback) => { + const unsubscribe = await service.subscribeToPrices({ + symbols: ['BTC'], + callback, + }); + unsubscribes.push(unsubscribe); + }); + + // Wait for all subscriptions to complete + await Promise.all(subscribePromises); + + // Advance timers for async callbacks + await jest.runAllTimersAsync(); + + // Should only create one allMids subscription despite multiple simultaneous calls + expect(mockSubscriptionClient.allMids).toHaveBeenCalledTimes(1); + + // All callbacks should still work + await jest.runAllTimersAsync(); + callbacks.forEach((callback) => { + expect(callback).toHaveBeenCalled(); + }); + + // Cleanup + unsubscribes.forEach((unsubscribe) => unsubscribe()); + }); + + it('should retry allMids subscription if initial attempt fails', async () => { + const callback = jest.fn(); + const mockUnsubscribeFn = jest.fn(); + const mockSubscriptionObj = { + unsubscribe: mockUnsubscribeFn, + }; + + // Make first attempt fail + mockSubscriptionClient.allMids.mockImplementationOnce(() => + Promise.reject(new Error('Connection failed')), + ); + + // Second attempt succeeds + mockSubscriptionClient.allMids.mockImplementationOnce((cb: any) => { + setTimeout(() => { + cb({ + mids: { + BTC: '50000', + }, + }); + }, 10); + return Promise.resolve(mockSubscriptionObj); + }); + + // First subscription attempt + const unsubscribe1 = await service.subscribeToPrices({ + symbols: ['BTC'], + callback, + }); + + // Wait for first attempt to fail + await jest.runAllTimersAsync(); + + // Second subscription attempt should retry + const unsubscribe2 = await service.subscribeToPrices({ + symbols: ['ETH'], + callback, + }); + + // Wait for second attempt to succeed + await jest.runAllTimersAsync(); + + // Should have tried twice total + expect(mockSubscriptionClient.allMids).toHaveBeenCalledTimes(2); + + // Cleanup + unsubscribe1(); + unsubscribe2(); + }); + }); + + it('should not repeatedly notify subscribers with empty positions', async () => { + const mockCallback = jest.fn(); + + // HIP-3 mode uses individual subscriptions + // Mock clearinghouseState to send multiple empty updates + mockSubscriptionClient.clearinghouseState.mockImplementation( + (_params: any, callback: any) => { + // Send first update + setTimeout(() => { + callback({ + dex: _params.dex || '', + clearinghouseState: { + assetPositions: [], + marginSummary: { accountValue: '10000', totalMarginUsed: '0' }, + withdrawable: '10000', + }, + }); + }, 0); + + // Send second update (still empty) + setTimeout(() => { + callback({ + dex: _params.dex || '', + clearinghouseState: { + assetPositions: [], + marginSummary: { accountValue: '10000', totalMarginUsed: '0' }, + withdrawable: '10000', + }, + }); + }, 20); + + return Promise.resolve({ + unsubscribe: jest.fn().mockResolvedValue(undefined), + }); + }, + ); + + mockSubscriptionClient.openOrders.mockImplementation( + (_params: any, callback: any) => { + setTimeout(() => { + callback({ + dex: _params.dex || '', + orders: [], + }); + }, 5); + return Promise.resolve({ + unsubscribe: jest.fn().mockResolvedValue(undefined), + }); + }, + ); + + const unsubscribe = service.subscribeToPositions({ + callback: mockCallback, + }); + + // Wait for both updates to process + await jest.runAllTimersAsync(); + + // Should only be called once with empty positions (initial notification) + expect(mockCallback).toHaveBeenCalledTimes(1); + expect(mockCallback).toHaveBeenCalledWith([]); + + unsubscribe(); + }); + + it('should notify price subscribers on first update even with zero prices', async () => { + const mockCallback = jest.fn(); + + // Mock allMids with zero prices + mockSubscriptionClient.allMids.mockImplementation((callback: any) => { + // Send first update + setTimeout(() => { + callback({ + mids: { + BTC: '0', + ETH: '0', + }, + }); + }, 0); + return Promise.resolve({ + unsubscribe: jest.fn().mockResolvedValue(undefined), + }); + }); + + const unsubscribe = await service.subscribeToPrices({ + symbols: ['BTC', 'ETH'], + callback: mockCallback, + }); + + // Wait for processing + await jest.runAllTimersAsync(); + + // Should call callback with zero prices to enable UI state + expect(mockCallback).toHaveBeenCalledWith([ + expect.objectContaining({ + symbol: 'BTC', + price: '0', + }), + expect.objectContaining({ + symbol: 'ETH', + price: '0', + }), + ]); + + unsubscribe(); + }); + + describe('HIP-3 Feature Flags and Multi-DEX Support', () => { + it('initializes service with HIP-3 DEXs enabled', () => { + const hip3Service = new HyperLiquidSubscriptionService( + mockClientService, + mockWalletService, + mockDeps, + true, // hip3Enabled + ['dex1', 'dex2'], // enabledDexs + ); + + expect(hip3Service).toBeDefined(); + }); + + it('returns only main DEX when equity is disabled', () => { + const subscriptionService = new HyperLiquidSubscriptionService( + mockClientService, + mockWalletService, + mockDeps, + false, // hip3Enabled + [], + ); + + expect(subscriptionService).toBeDefined(); + }); + + it('updates feature flags and establishes new DEX subscriptions', async () => { + // Start with market data subscribers to trigger assetCtxs subscriptions + const mockCallback = jest.fn(); + const mockInfoClient = { + meta: jest.fn().mockResolvedValue({ + universe: [{ name: 'BTC' }, { name: 'ETH' }], + }), + }; + mockClientService.getInfoClient = jest.fn(() => mockInfoClient as any); + + const assetCtxsSubscription = { + unsubscribe: jest.fn().mockResolvedValue(undefined), + }; + mockSubscriptionClient.assetCtxs = jest + .fn() + .mockResolvedValue(assetCtxsSubscription); + + // Subscribe to prices with market data to create market data subscribers + await service.subscribeToPrices({ + symbols: ['BTC'], + callback: mockCallback, + includeMarketData: true, + }); + + await jest.runAllTimersAsync(); + + // Now update feature flags to enable new DEXs + await service.updateFeatureFlags(true, ['newdex1', 'newdex2'], [], []); + + expect(mockInfoClient.meta).toHaveBeenCalledWith({ dex: 'newdex1' }); + expect(mockInfoClient.meta).toHaveBeenCalledWith({ dex: 'newdex2' }); + }); + + it('handles errors when establishing assetCtxs subscriptions for new DEXs', async () => { + const mockCallback = jest.fn(); + + // Mock successful meta call but failing assetCtxs subscription + const mockInfoClient = { + meta: jest.fn().mockResolvedValue({ + universe: [{ name: 'BTC' }], + }), + }; + mockClientService.getInfoClient = jest.fn(() => mockInfoClient as any); + + // Make assetCtxs subscription fail + mockSubscriptionClient.assetCtxs = jest + .fn() + .mockRejectedValue(new Error('AssetCtxs subscription failed')); + + // Subscribe to prices with market data to create market data subscribers + await service.subscribeToPrices({ + symbols: ['BTC'], + callback: mockCallback, + includeMarketData: true, + }); + + await jest.runAllTimersAsync(); + + // Update feature flags - should handle error gracefully without throwing + await service.updateFeatureFlags(true, ['failingdex'], [], []); + + // Wait for async error handling + await jest.runAllTimersAsync(); + + // Verify updateFeatureFlags completed without throwing + expect(mockInfoClient.meta).toHaveBeenCalledWith({ dex: 'failingdex' }); + }); + + it('handles errors when establishing clearinghouseState subscriptions for new DEXs', async () => { + const mockPositionCallback = jest.fn(); + mockSubscriptionClient.clearinghouseState = jest + .fn() + .mockRejectedValue(new Error('Subscription failed')); + + // Subscribe to positions first + service.subscribeToPositions({ + callback: mockPositionCallback, + }); + + await jest.runAllTimersAsync(); + + // Update feature flags - should handle error gracefully + await expect( + service.updateFeatureFlags(true, ['failingdex2'], [], []), + ).resolves.not.toThrow(); + }); + + it('handles getUserAddress errors during feature flag updates', async () => { + const mockPositionCallback = jest.fn(); + mockWalletService.getUserAddressWithDefault.mockRejectedValue( + new Error('Wallet error'), + ); + + // Subscribe to positions first + service.subscribeToPositions({ + callback: mockPositionCallback, + }); + + await jest.runAllTimersAsync(); + + // Update feature flags - should handle wallet error gracefully + await expect( + service.updateFeatureFlags(true, ['newdex'], [], []), + ).resolves.not.toThrow(); + + // Reset mock for other tests + mockWalletService.getUserAddressWithDefault.mockResolvedValue( + '0x123' as Hex, + ); + }); + + it('does not establish subscriptions when no new DEXs are added', async () => { + const mockCallback = jest.fn(); + await service.subscribeToPrices({ + symbols: ['BTC'], + callback: mockCallback, + includeMarketData: true, + }); + await jest.runAllTimersAsync(); + + const initialCallCount = mockSubscriptionClient.assetCtxs + ? (mockSubscriptionClient.assetCtxs as jest.Mock).mock.calls.length + : 0; + + // Update with same DEXs (no new ones) + await service.updateFeatureFlags(false, [], [], []); + + // Should not create new subscriptions + const finalCallCount = mockSubscriptionClient.assetCtxs + ? (mockSubscriptionClient.assetCtxs as jest.Mock).mock.calls.length + : 0; + expect(finalCallCount).toBe(initialCallCount); + }); + + it('cleans up failed assetCtxs subscriptions so later HIP-3 resubscribes reconnect cleanly', async () => { + const mockCallback = jest.fn(); + jest.mocked(parseAssetName).mockImplementation((symbol: string) => ({ + symbol, + dex: symbol === 'BTC:UNISWAP' ? 'UNISWAP' : null, + })); + + mockClientService.getInfoClient = jest.fn( + () => + ({ + meta: jest.fn().mockResolvedValue({ + universe: [{ name: 'BTC:UNISWAP' }], + }), + }) as any, + ); + + mockSubscriptionClient.assetCtxs = jest + .fn() + .mockRejectedValueOnce(new Error('Subscription failed')) + .mockResolvedValueOnce({ + unsubscribe: jest.fn().mockResolvedValue(undefined), + }) + .mockResolvedValueOnce({ + unsubscribe: jest.fn().mockResolvedValue(undefined), + }); + + const failedUnsubscribe = await service.subscribeToPrices({ + symbols: ['BTC:UNISWAP'], + callback: mockCallback, + includeMarketData: true, + }); + await jest.runAllTimersAsync(); + failedUnsubscribe(); + await jest.runAllTimersAsync(); + + const recoveredUnsubscribe = await service.subscribeToPrices({ + symbols: ['BTC:UNISWAP'], + callback: mockCallback, + includeMarketData: true, + }); + await jest.runAllTimersAsync(); + recoveredUnsubscribe(); + await jest.runAllTimersAsync(); + + const finalUnsubscribe = await service.subscribeToPrices({ + symbols: ['BTC:UNISWAP'], + callback: mockCallback, + includeMarketData: true, + }); + await jest.runAllTimersAsync(); + + expect(mockSubscriptionClient.assetCtxs).toHaveBeenCalledTimes(3); + finalUnsubscribe(); + }); + }); + + describe('Market Data Cache Initialization', () => { + it('uses setDexMetaCache to pre-populate meta cache instead of API call', async () => { + // Test that setDexMetaCache can be used to pre-populate the cache + // This is how Provider shares cached meta with SubscriptionService + const mockMeta = { + universe: [ + { name: 'BTC', szDecimals: 3, maxLeverage: 50 }, + { name: 'ETH', szDecimals: 4, maxLeverage: 50 }, + { name: 'SOL', szDecimals: 2, maxLeverage: 20 }, + ], + }; + + // Pre-populate cache via setDexMetaCache (simulating what Provider does) + service.setDexMetaCache('', mockMeta); + + const mockCallback = jest.fn(); + const mockInfoClient = { + // These should NOT be called since cache is populated + meta: jest.fn().mockResolvedValue(mockMeta), + metaAndAssetCtxs: jest.fn().mockResolvedValue([mockMeta, []]), + }; + + mockClientService.getInfoClient = jest.fn(() => mockInfoClient as any); + + const unsubscribe = await service.subscribeToPrices({ + symbols: ['BTC', 'ETH', 'SOL'], + callback: mockCallback, + includeMarketData: true, + }); + + await jest.runAllTimersAsync(); + + // Verify that metaAndAssetCtxs was NOT called (cache was used) + // Note: meta() may still be called by createAssetCtxsSubscription fallback if cache miss, + // but with proper cache population, it should hit the cache + expect(mockInfoClient.metaAndAssetCtxs).not.toHaveBeenCalled(); + + unsubscribe(); + }); + + it('handles errors when caching initial market data', async () => { + const mockCallback = jest.fn(); + const mockInfoClient = { + meta: jest.fn().mockRejectedValue(new Error('Meta fetch failed')), + metaAndAssetCtxs: jest + .fn() + .mockRejectedValue(new Error('AssetCtxs fetch failed')), + }; + + mockClientService.getInfoClient = jest.fn(() => mockInfoClient as any); + + // Should not throw even if initial cache fails + const unsubscribe = await service.subscribeToPrices({ + symbols: ['BTC'], + callback: mockCallback, + includeMarketData: true, + }); + + await jest.runAllTimersAsync(); + + // Subscription should still work despite cache error + expect(unsubscribe).toBeDefined(); + expect(typeof unsubscribe).toBe('function'); + + unsubscribe(); + }); + + it('skips caching when includeMarketData is false', async () => { + const mockCallback = jest.fn(); + const mockInfoClient = { + meta: jest.fn(), + metaAndAssetCtxs: jest.fn(), + }; + + mockClientService.getInfoClient = jest.fn(() => mockInfoClient as any); + + const unsubscribe = await service.subscribeToPrices({ + symbols: ['BTC'], + callback: mockCallback, + includeMarketData: false, + }); + + await jest.runAllTimersAsync(); + + // assetCtxs subscription is always established (lightweight, 1 per DEX) + // so meta may be called for the assetCtxs mapping, but metaAndAssetCtxs should not + expect(mockInfoClient.metaAndAssetCtxs).not.toHaveBeenCalled(); + + unsubscribe(); + }); + + it('handles partial market data in cache', async () => { + const mockCallback = jest.fn(); + const mockInfoClient = { + meta: jest.fn().mockResolvedValue({ + universe: [{ name: 'BTC' }, { name: 'ETH' }], + }), + metaAndAssetCtxs: jest.fn().mockResolvedValue([ + {}, + [ + { + funding: '0.0001', + prevDayPx: '49000', + }, + null, // Missing asset context for ETH + ], + ]), + }; + + mockClientService.getInfoClient = jest.fn(() => mockInfoClient as any); + + const unsubscribe = await service.subscribeToPrices({ + symbols: ['BTC', 'ETH'], + callback: mockCallback, + includeMarketData: true, + }); + + await jest.runAllTimersAsync(); + + // Should handle partial data gracefully + expect(mockCallback).toHaveBeenCalled(); + + unsubscribe(); + }); + }); + + describe('Multi-DEX Error Handling', () => { + it('handles webData3 subscription errors gracefully', async () => { + const mockCallback = jest.fn(); + mockSubscriptionClient.webData3 = jest + .fn() + .mockRejectedValue(new Error('WebData3 subscription failed')); + + const unsubscribe = service.subscribeToPositions({ + callback: mockCallback, + }); + + await jest.runAllTimersAsync(); + + // Should return unsubscribe function despite error + expect(typeof unsubscribe).toBe('function'); + expect(() => unsubscribe()).not.toThrow(); + }); + + it('handles clearinghouseState subscription errors for HIP-3 DEXs', async () => { + const mockCallback = jest.fn(); + mockSubscriptionClient.clearinghouseState = jest + .fn() + .mockRejectedValue(new Error('ClearinghouseState subscription failed')); + + // Create service with HIP-3 enabled + const hip3Service = new HyperLiquidSubscriptionService( + mockClientService, + mockWalletService, + mockDeps, + true, + ['failingdex'], + ); + + const unsubscribe = hip3Service.subscribeToPositions({ + callback: mockCallback, + }); + + await jest.runAllTimersAsync(); + + // Should handle error gracefully + expect(typeof unsubscribe).toBe('function'); + expect(() => unsubscribe()).not.toThrow(); + }); + + it('handles unsubscribe errors for HIP-3 clearinghouseState', async () => { + const mockCallback = jest.fn(); + const mockInfoClient = { + frontendOpenOrders: jest.fn().mockResolvedValue([]), + }; + mockClientService.getInfoClient = jest.fn(() => mockInfoClient as any); + + const clearinghouseStateSubscription = { + unsubscribe: jest + .fn() + .mockRejectedValue(new Error('Unsubscribe failed')), + }; + + mockSubscriptionClient.clearinghouseState = jest.fn( + (_params: any, callback: any) => { + setTimeout(() => { + callback({ + user: '0x123', + clearinghouseState: { + assetPositions: [], + }, + }); + }, 0); + return Promise.resolve(clearinghouseStateSubscription); + }, + ); + + // Create service with HIP-3 enabled + const hip3Service = new HyperLiquidSubscriptionService( + mockClientService, + mockWalletService, + mockDeps, + true, + ['testdex'], + ); + + const unsubscribe = hip3Service.subscribeToPositions({ + callback: mockCallback, + }); + + await jest.runAllTimersAsync(); + + // Unsubscribe should not throw even if underlying unsubscribe fails + expect(() => unsubscribe()).not.toThrow(); + }); + }); + + describe('Cache Initialization Checks', () => { + it('returns false for OI caps cache before initialization', () => { + const result = service.isOICapsCacheInitialized(); + + expect(result).toBe(false); + }); + + it('returns false for orders cache before initialization', () => { + const result = service.isOrdersCacheInitialized(); + + expect(result).toBe(false); + }); + + it('returns false for positions cache before initialization', () => { + const result = service.isPositionsCacheInitialized(); + + expect(result).toBe(false); + }); + + it('returns null for cached positions before initialization', () => { + const result = service.getCachedPositions(); + + expect(result).toBeNull(); + }); + + it('reports no DEX coverage for positions before initialization', () => { + // A close of a symbol on an uncovered DEX must not read a cache miss as + // "position closed" + expect(service.getCachedPositionsForDex('')).toBeNull(); + expect(service.getCachedPositionsForDex('xyz')).toBeNull(); + }); + + it('returns null for cached orders before initialization', () => { + const result = service.getCachedOrders(); + + expect(result).toBeNull(); + }); + + it('getOrdersCacheIfInitialized returns null when cache not initialized', () => { + const result = service.getOrdersCacheIfInitialized(); + + expect(result).toBeNull(); + }); + + it('getOrdersCacheIfInitialized returns empty array when initialized but no orders', async () => { + // First subscribe to trigger initialization + const callback = jest.fn(); + service.subscribeToOrders({ callback }); + + // Manually set the cache as initialized with empty data + // We need to simulate WebSocket message to trigger initialization + // For unit test, we verify the method exists and returns correct type + const result = service.getOrdersCacheIfInitialized(); + + // Before any WebSocket data, should return null + expect(result).toBeNull(); + }); + + it('getOrdersCacheIfInitialized returns defensive copy of orders', async () => { + // This test verifies the atomic getter returns a copy, not the original + // We test indirectly by verifying the method signature and behavior + const result1 = service.getOrdersCacheIfInitialized(); + const result2 = service.getOrdersCacheIfInitialized(); + + // Both should be null before initialization + expect(result1).toBeNull(); + expect(result2).toBeNull(); + }); + + it('returns null for cached fills before initialization', () => { + const result = service.getCachedFills(); + + expect(result).toBeNull(); + }); + + it('getLastAllMidsSnapshot returns a defensive copy and null for unknown dexes', async () => { + const unsubscribe = await service.subscribeToPrices({ + symbols: ['BTC'], + callback: jest.fn(), + }); + + await jest.runAllTimersAsync(); + + const snapshot = service.getLastAllMidsSnapshot(); + expect(snapshot).toEqual( + expect.objectContaining({ + BTC: 50000, + ETH: 3000, + }), + ); + + if (!snapshot) { + throw new Error('Expected allMids snapshot to be populated'); + } + + delete snapshot.BTC; + + expect(service.getLastAllMidsSnapshot()).toEqual( + expect.objectContaining({ + BTC: 50000, + ETH: 3000, + }), + ); + expect(service.getLastAllMidsSnapshot('missing-dex')).toBeNull(); + + unsubscribe(); + }); + + it('getFillsCacheIfInitialized returns null when cache not initialized', () => { + const result = service.getFillsCacheIfInitialized(); + + expect(result).toBeNull(); + }); + + it('getFillsCacheIfInitialized returns defensive copy of fills', () => { + // This test verifies the atomic getter returns a copy, not the original + // We test indirectly by verifying the method signature and behavior + const result1 = service.getFillsCacheIfInitialized(); + const result2 = service.getFillsCacheIfInitialized(); + + // Both should be null before initialization + expect(result1).toBeNull(); + expect(result2).toBeNull(); + }); + }); + + describe('activeAssetCtx price preference (per-subscriber projection)', () => { + it('focused subscriber (includeMarketData: true) sees activeAssetCtx midPx; list subscriber sees allMids', async () => { + let allMidsCallback: ((data: any) => void) | undefined; + let activeAssetCallback: ((data: any) => void) | undefined; + + mockSubscriptionClient.allMids.mockImplementation( + (paramsOrCallback: any, maybeCallback?: any) => { + allMidsCallback = + typeof paramsOrCallback === 'function' + ? paramsOrCallback + : maybeCallback; + return Promise.resolve({ + unsubscribe: jest.fn().mockResolvedValue(undefined), + }); + }, + ); + + mockSubscriptionClient.activeAssetCtx.mockImplementation( + (params: any, callback: any) => { + activeAssetCallback = callback; + return Promise.resolve({ + unsubscribe: jest.fn().mockResolvedValue(undefined), + }); + }, + ); + + const focusedCallback = jest.fn(); + const listCallback = jest.fn(); + + const unsubFocused = await service.subscribeToPrices({ + symbols: ['BTC'], + callback: focusedCallback, + includeMarketData: true, + }); + + const unsubList = await service.subscribeToPrices({ + symbols: ['BTC'], + callback: listCallback, + includeMarketData: false, + }); + + await jest.runAllTimersAsync(); + + // allMids fires first with 50000 + allMidsCallback?.({ mids: { BTC: '50000' } }); + await jest.runAllTimersAsync(); + + // activeAssetCtx fires with a fresher 50500 + activeAssetCallback?.({ + coin: 'BTC', + ctx: { + prevDayPx: '49000', + funding: '0.01', + openInterest: '1000000', + dayNtlVlm: '50000000', + oraclePx: '50100', + midPx: '50500', + }, + }); + await jest.runAllTimersAsync(); + + const focusedLast = + focusedCallback.mock.calls[focusedCallback.mock.calls.length - 1][0]; + expect(focusedLast).toEqual( + expect.arrayContaining([ + expect.objectContaining({ symbol: 'BTC', price: '50500' }), + ]), + ); + + const listLast = + listCallback.mock.calls[listCallback.mock.calls.length - 1][0]; + expect(listLast).toEqual( + expect.arrayContaining([ + expect.objectContaining({ symbol: 'BTC', price: '50000' }), + ]), + ); + + unsubFocused(); + unsubList(); + }); + + it('focused subscriber gets fast price even before allMids baseline arrives', async () => { + let activeAssetCallback: ((data: any) => void) | undefined; + + mockSubscriptionClient.activeAssetCtx.mockImplementation( + (params: any, callback: any) => { + activeAssetCallback = callback; + return Promise.resolve({ + unsubscribe: jest.fn().mockResolvedValue(undefined), + }); + }, + ); + + // allMids never fires in this test + mockSubscriptionClient.allMids.mockImplementation( + (_paramsOrCallback: any, _maybeCallback?: any) => { + return Promise.resolve({ + unsubscribe: jest.fn().mockResolvedValue(undefined), + }); + }, + ); + + const focusedCallback = jest.fn(); + const unsubFocused = await service.subscribeToPrices({ + symbols: ['BTC'], + callback: focusedCallback, + includeMarketData: true, + }); + + await jest.runAllTimersAsync(); + + activeAssetCallback?.({ + coin: 'BTC', + ctx: { + prevDayPx: '49000', + funding: '0.01', + openInterest: '1000000', + dayNtlVlm: '50000000', + oraclePx: '50100', + midPx: '50500', + }, + }); + + await jest.runAllTimersAsync(); + + const lastCall = + focusedCallback.mock.calls[focusedCallback.mock.calls.length - 1][0]; + expect(lastCall).toEqual( + expect.arrayContaining([ + expect.objectContaining({ symbol: 'BTC', price: '50500' }), + ]), + ); + + unsubFocused(); + }); + + it('does not emit a price when activeAssetCtx has no midPx/markPx and no allMids baseline exists', async () => { + let activeAssetCallback: ((data: any) => void) | undefined; + + mockSubscriptionClient.activeAssetCtx.mockImplementation( + (params: any, callback: any) => { + activeAssetCallback = callback; + return Promise.resolve({ + unsubscribe: jest.fn().mockResolvedValue(undefined), + }); + }, + ); + + // allMids never fires + mockSubscriptionClient.allMids.mockImplementation( + (_paramsOrCallback: any, _maybeCallback?: any) => { + return Promise.resolve({ + unsubscribe: jest.fn().mockResolvedValue(undefined), + }); + }, + ); + + const mockCallback = jest.fn(); + const unsubscribe = await service.subscribeToPrices({ + symbols: ['BTC'], + callback: mockCallback, + includeMarketData: true, + }); + + await jest.runAllTimersAsync(); + mockCallback.mockClear(); + + // activeAssetCtx fires without a midPx or markPx + activeAssetCallback?.({ + coin: 'BTC', + ctx: { + prevDayPx: '49000', + funding: '0.01', + openInterest: '1000000', + dayNtlVlm: '50000000', + oraclePx: '50100', + // no midPx, no markPx + }, + }); + + await jest.runAllTimersAsync(); + + // The callback should not have been called at all: without midPx/markPx + // there is no fast-stream price to project, and no allMids baseline + // exists yet, so #notifyAllPriceSubscribers has nothing to send. + expect(mockCallback).not.toHaveBeenCalled(); + + unsubscribe(); + }); + + it('list subscriber always uses allMids price (never sees activeAssetCtx fast price)', async () => { + let allMidsCallback: ((data: any) => void) | undefined; + let activeAssetCallback: ((data: any) => void) | undefined; + + mockSubscriptionClient.allMids.mockImplementation( + (paramsOrCallback: any, maybeCallback?: any) => { + allMidsCallback = + typeof paramsOrCallback === 'function' + ? paramsOrCallback + : maybeCallback; + return Promise.resolve({ + unsubscribe: jest.fn().mockResolvedValue(undefined), + }); + }, + ); + + mockSubscriptionClient.activeAssetCtx.mockImplementation( + (params: any, callback: any) => { + activeAssetCallback = callback; + return Promise.resolve({ + unsubscribe: jest.fn().mockResolvedValue(undefined), + }); + }, + ); + + const listCallback = jest.fn(); + + // Subscribe with a focused subscriber first so activeAssetCtx is established + const unsubFocused = await service.subscribeToPrices({ + symbols: ['BTC'], + callback: jest.fn(), + includeMarketData: true, + }); + + const unsubList = await service.subscribeToPrices({ + symbols: ['BTC'], + callback: listCallback, + includeMarketData: false, + }); + + await jest.runAllTimersAsync(); + + allMidsCallback?.({ mids: { BTC: '50000' } }); + await jest.runAllTimersAsync(); + + listCallback.mockClear(); + + // Fast stream ticks with a higher price + activeAssetCallback?.({ + coin: 'BTC', + ctx: { + prevDayPx: '49000', + funding: '0.01', + openInterest: '1000000', + dayNtlVlm: '50000000', + oraclePx: '50100', + midPx: '50500', + }, + }); + + await jest.runAllTimersAsync(); + + // List subscriber should still see 50000 (allMids), never 50500 + const listLast = + listCallback.mock.calls[listCallback.mock.calls.length - 1][0]; + expect(listLast).toEqual( + expect.arrayContaining([ + expect.objectContaining({ symbol: 'BTC', price: '50000' }), + ]), + ); + + unsubFocused(); + unsubList(); + }); + + it('focused subscriber falls back to allMids when activeAssetCtx price is stale (beyond TTL)', async () => { + let allMidsCallback: ((data: any) => void) | undefined; + let activeAssetCallback: ((data: any) => void) | undefined; + + mockSubscriptionClient.allMids.mockImplementation( + (paramsOrCallback: any, maybeCallback?: any) => { + allMidsCallback = + typeof paramsOrCallback === 'function' + ? paramsOrCallback + : maybeCallback; + return Promise.resolve({ + unsubscribe: jest.fn().mockResolvedValue(undefined), + }); + }, + ); + + mockSubscriptionClient.activeAssetCtx.mockImplementation( + (params: any, callback: any) => { + activeAssetCallback = callback; + return Promise.resolve({ + unsubscribe: jest.fn().mockResolvedValue(undefined), + }); + }, + ); + + const mockCallback = jest.fn(); + const unsubscribe = await service.subscribeToPrices({ + symbols: ['BTC'], + callback: mockCallback, + includeMarketData: true, + }); + + await jest.runAllTimersAsync(); + + allMidsCallback?.({ mids: { BTC: '50000' } }); + await jest.runAllTimersAsync(); + + activeAssetCallback?.({ + coin: 'BTC', + ctx: { + prevDayPx: '49000', + funding: '0.01', + openInterest: '1000000', + dayNtlVlm: '50000000', + oraclePx: '50100', + midPx: '50500', + }, + }); + + // Move the system clock past the 10 s TTL so the staleness check in + // #getFreshActiveAssetCtxPrice returns undefined. Use setSystemTime + // (not advanceTimersByTime) to avoid firing service-internal timers + // that could alter subscription state before the second allMids fires. + jest.setSystemTime(Date.now() + 11_000); + + mockCallback.mockClear(); + + // allMids fires with a NEW price (must differ from the cached '50000' so the + // allMids handler's price-deduplication guard doesn't swallow the update). + allMidsCallback?.({ mids: { BTC: '51000' } }); + + await jest.runAllTimersAsync(); + + const lastCall = + mockCallback.mock.calls[mockCallback.mock.calls.length - 1][0]; + expect(lastCall).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + symbol: 'BTC', + price: '51000', // allMids wins after TTL – fast-stream '50500' is stale + }), + ]), + ); + + unsubscribe(); + }); + + it('list subscriber (includeMarketData: false) never triggers activeAssetCtx subscription', async () => { + mockSubscriptionClient.allMids.mockImplementation( + (paramsOrCallback: any, maybeCallback?: any) => { + const callback = + typeof paramsOrCallback === 'function' + ? paramsOrCallback + : maybeCallback; + setTimeout(() => { + callback({ mids: { BTC: '50000' } }); + }, 0); + return Promise.resolve({ + unsubscribe: jest.fn().mockResolvedValue(undefined), + }); + }, + ); + + const unsubscribe = await service.subscribeToPrices({ + symbols: ['BTC'], + callback: jest.fn(), + includeMarketData: false, + }); + + await jest.runAllTimersAsync(); + + expect(mockSubscriptionClient.activeAssetCtx).not.toHaveBeenCalled(); + + unsubscribe(); + }); + + it('projection preserves derived fields (funding, openInterest, markPrice, isTradable, percentChange24h) from the allMids baseline', async () => { + let allMidsCallback: ((data: any) => void) | undefined; + let activeAssetCallback: ((data: any) => void) | undefined; + + mockSubscriptionClient.allMids.mockImplementation( + (paramsOrCallback: any, maybeCallback?: any) => { + allMidsCallback = + typeof paramsOrCallback === 'function' + ? paramsOrCallback + : maybeCallback; + return Promise.resolve({ + unsubscribe: jest.fn().mockResolvedValue(undefined), + }); + }, + ); + + mockSubscriptionClient.activeAssetCtx.mockImplementation( + (params: any, callback: any) => { + activeAssetCallback = callback; + return Promise.resolve({ + unsubscribe: jest.fn().mockResolvedValue(undefined), + }); + }, + ); + + const focusedCallback = jest.fn(); + const unsubscribe = await service.subscribeToPrices({ + symbols: ['BTC'], + callback: focusedCallback, + includeMarketData: true, + }); + + await jest.runAllTimersAsync(); + + // allMids establishes the baseline price + allMidsCallback?.({ mids: { BTC: '50000' } }); + await jest.runAllTimersAsync(); + + // activeAssetCtx fires with a fast price and rich market data + activeAssetCallback?.({ + coin: 'BTC', + ctx: { + prevDayPx: '48000', + funding: '0.0001', + openInterest: '2000000', + dayNtlVlm: '100000000', + oraclePx: '50050', + markPx: '50025', + midPx: '50500', + }, + }); + await jest.runAllTimersAsync(); + + const lastUpdate: PriceUpdate = + focusedCallback.mock.calls[focusedCallback.mock.calls.length - 1][0][0]; + + // Fast-stream price is projected + expect(lastUpdate.price).toBe('50500'); + + // Derived fields from the allMids baseline (enriched by activeAssetCtx) are preserved + expect(lastUpdate.funding).toBeDefined(); + expect(lastUpdate.openInterest).toBeDefined(); + expect(lastUpdate.volume24h).toBeDefined(); + expect(lastUpdate.markPrice).toBeDefined(); + expect(lastUpdate.percentChange24h).toBeDefined(); + // isTradable defaults to true when the price is within oracle deviation limits + expect(lastUpdate.isTradable).toBe(true); + + unsubscribe(); + }); + + it('keeps projecting the fast price after an assetCtxs batch update (does not clobber the fast-stream cache)', async () => { + let allMidsCallback: ((data: any) => void) | undefined; + let activeAssetCallback: ((data: any) => void) | undefined; + let assetCtxsCallback: ((data: any) => void) | undefined; + + // Pre-populate meta so #createAssetCtxsSubscription maps ctxs -> symbols + // from cache and the assetCtxs handler fires for 'BTC'. + service.setDexMetaCache('', { universe: [{ name: 'BTC' }] } as any); + + mockSubscriptionClient.allMids.mockImplementation( + (paramsOrCallback: any, maybeCallback?: any) => { + allMidsCallback = + typeof paramsOrCallback === 'function' + ? paramsOrCallback + : maybeCallback; + return Promise.resolve({ + unsubscribe: jest.fn().mockResolvedValue(undefined), + }); + }, + ); + + mockSubscriptionClient.activeAssetCtx.mockImplementation( + (params: any, callback: any) => { + activeAssetCallback = callback; + return Promise.resolve({ + unsubscribe: jest.fn().mockResolvedValue(undefined), + }); + }, + ); + + mockSubscriptionClient.assetCtxs.mockImplementation( + (_params: any, callback: any) => { + assetCtxsCallback = callback; + return Promise.resolve({ + unsubscribe: jest.fn().mockResolvedValue(undefined), + }); + }, + ); + + const focusedCallback = jest.fn(); + const unsubscribe = await service.subscribeToPrices({ + symbols: ['BTC'], + callback: focusedCallback, + includeMarketData: true, + }); + + await jest.runAllTimersAsync(); + + // allMids establishes the baseline, then activeAssetCtx provides the fast price + allMidsCallback?.({ mids: { BTC: '50000' } }); + await jest.runAllTimersAsync(); + + activeAssetCallback?.({ + coin: 'BTC', + ctx: { + prevDayPx: '49000', + funding: '0.01', + openInterest: '1000000', + dayNtlVlm: '50000000', + oraclePx: '50100', + midPx: '50500', + }, + }); + await jest.runAllTimersAsync(); + + focusedCallback.mockClear(); + + // assetCtxs batch update fires for BTC with a DIFFERENT price. Before the + // fix this rebuilt the #marketDataCache entry without the fast-stream + // fields, so #getFreshActiveAssetCtxPrice returned undefined and the + // focused subscriber fell back to the assetCtxs/allMids baseline (50200). + assetCtxsCallback?.({ + ctxs: [ + { + prevDayPx: '49000', + funding: '0.01', + openInterest: '1000000', + dayNtlVlm: '50000000', + oraclePx: '50100', + midPx: '50200', + }, + ], + }); + await jest.runAllTimersAsync(); + + // The focused subscriber must still see the fast-stream price (50500), + // not the slower batch baseline (50200). + const lastCall = + focusedCallback.mock.calls[focusedCallback.mock.calls.length - 1][0]; + expect(lastCall).toEqual( + expect.arrayContaining([ + expect.objectContaining({ symbol: 'BTC', price: '50500' }), + ]), + ); + + unsubscribe(); + }); + + it('does not let a slower assetCtxs batch update overwrite a price already covered by fastAssetCtxs', async () => { + let fastAssetCtxsCallback: ((data: any) => void) | undefined; + let assetCtxsCallback: ((data: any) => void) | undefined; + + service.setDexMetaCache('', { universe: [{ name: 'BTC' }] } as any); + + mockSubscriptionClient.fastAssetCtxs.mockImplementation( + (callback: any) => { + fastAssetCtxsCallback = callback; + return Promise.resolve({ + unsubscribe: jest.fn().mockResolvedValue(undefined), + }); + }, + ); + + mockSubscriptionClient.assetCtxs.mockImplementation( + (_params: any, callback: any) => { + assetCtxsCallback = callback; + return Promise.resolve({ + unsubscribe: jest.fn().mockResolvedValue(undefined), + }); + }, + ); + + const listCallback = jest.fn(); + const unsubscribe = await service.subscribeToPrices({ + symbols: ['BTC'], + callback: listCallback, + }); + + await jest.runAllTimersAsync(); + + // fastAssetCtxs establishes the authoritative price for BTC + fastAssetCtxsCallback?.({ BTC: { midPx: '52000' } }); + await jest.runAllTimersAsync(); + + listCallback.mockClear(); + + // A slower assetCtxs batch tick fires for BTC with a different price. + // It should not overwrite the fresher fastAssetCtxs price. + assetCtxsCallback?.({ + ctxs: [ + { + prevDayPx: '49000', + funding: '0.01', + openInterest: '1000000', + dayNtlVlm: '50000000', + oraclePx: '50100', + midPx: '50200', + }, + ], + }); + await jest.runAllTimersAsync(); + + expect(listCallback).not.toHaveBeenCalledWith([ + expect.objectContaining({ symbol: 'BTC', price: '50200' }), + ]); + + unsubscribe(); + }); + + it('lets assetCtxs set the price for a coin whose only fastAssetCtxs message had no usable price', async () => { + let fastAssetCtxsCallback: ((data: any) => void) | undefined; + let assetCtxsCallback: ((data: any) => void) | undefined; + + service.setDexMetaCache('', { universe: [{ name: 'BTC' }] } as any); + + mockSubscriptionClient.fastAssetCtxs.mockImplementation( + (callback: any) => { + fastAssetCtxsCallback = callback; + return Promise.resolve({ + unsubscribe: jest.fn().mockResolvedValue(undefined), + }); + }, + ); + + mockSubscriptionClient.assetCtxs.mockImplementation( + (_params: any, callback: any) => { + assetCtxsCallback = callback; + return Promise.resolve({ + unsubscribe: jest.fn().mockResolvedValue(undefined), + }); + }, + ); + + const listCallback = jest.fn(); + const unsubscribe = await service.subscribeToPrices({ + symbols: ['BTC'], + callback: listCallback, + }); + + await jest.runAllTimersAsync(); + + // fastAssetCtxs reports BTC with no usable price (both midPx and markPx + // absent). This must not claim fastAssetCtxs ownership of BTC, since + // there is no fast price to back it — otherwise assetCtxs, the only + // remaining price source for BTC, would be suppressed with nothing to + // fall back on. + fastAssetCtxsCallback?.({ BTC: {} }); + await jest.runAllTimersAsync(); + + listCallback.mockClear(); + + assetCtxsCallback?.({ + ctxs: [ + { + prevDayPx: '49000', + funding: '0.01', + openInterest: '1000000', + dayNtlVlm: '50000000', + oraclePx: '50100', + midPx: '50200', + }, + ], + }); + await jest.runAllTimersAsync(); + + expect(listCallback).toHaveBeenCalledWith([ + expect.objectContaining({ symbol: 'BTC', price: '50200' }), + ]); + + unsubscribe(); + }); + + it('lets assetCtxs update the price for a symbol not covered by fastAssetCtxs (e.g. a HIP-3 dex:symbol asset)', async () => { + let assetCtxsCallback: ((data: any) => void) | undefined; + + jest.mocked(parseAssetName).mockImplementation((symbol: string) => ({ + symbol, + dex: symbol === 'xyz:STOCK1' ? 'xyz' : null, + })); + + service.setDexMetaCache('xyz', { + universe: [{ name: 'xyz:STOCK1' }], + } as any); + + mockSubscriptionClient.assetCtxs.mockImplementation( + (_params: any, callback: any) => { + assetCtxsCallback = callback; + return Promise.resolve({ + unsubscribe: jest.fn().mockResolvedValue(undefined), + }); + }, + ); + + const listCallback = jest.fn(); + const unsubscribe = await service.subscribeToPrices({ + symbols: ['xyz:STOCK1'], + callback: listCallback, + }); + + await jest.runAllTimersAsync(); + + listCallback.mockClear(); + + assetCtxsCallback?.({ + ctxs: [ + { + prevDayPx: '9', + funding: '0.01', + openInterest: '1000', + dayNtlVlm: '5000', + oraclePx: '10', + midPx: '10.5', + }, + ], + }); + await jest.runAllTimersAsync(); + + expect(listCallback).toHaveBeenCalledWith([ + expect.objectContaining({ symbol: 'xyz:STOCK1', price: '10.5' }), + ]); + + unsubscribe(); + }); + }); + + describe('Market tradability (isTradable)', () => { + const getLastBtcUpdate = (mockCallback: jest.Mock) => { + const { calls } = mockCallback.mock; + const lastCall = calls[calls.length - 1][0]; + return lastCall.find((update: PriceUpdate) => update.symbol === 'BTC'); + }; + + it('marks a market tradable when the mid price is close to the oracle price', async () => { + // Default mock: mid (allMids) BTC = 50000, oraclePx = 50100 -> ~0.2% deviation + const mockCallback = jest.fn(); + + const unsubscribe = await service.subscribeToPrices({ + symbols: ['BTC'], + callback: mockCallback, + includeMarketData: true, + }); + + await jest.runAllTimersAsync(); + + expect(getLastBtcUpdate(mockCallback)).toEqual( + expect.objectContaining({ symbol: 'BTC', isTradable: true }), + ); + + unsubscribe(); + }); + + it('marks a market untradable when the mid price deviates more than 95% from the oracle price', async () => { + // mid (allMids) BTC = 50000, oraclePx = 100 -> deviation far beyond the 95% limit + mockSubscriptionClient.activeAssetCtx = jest.fn( + (params: any, callback: any) => { + setTimeout(() => { + callback({ + coin: params.coin, + ctx: { + prevDayPx: '49000', + funding: '0.01', + openInterest: '1000000', + dayNtlVlm: '50000000', + oraclePx: '100', + midPx: '50000', + }, + }); + }, 0); + return Promise.resolve({ + unsubscribe: jest.fn().mockResolvedValue(undefined), + }); + }, + ); + + const mockCallback = jest.fn(); + + const unsubscribe = await service.subscribeToPrices({ + symbols: ['BTC'], + callback: mockCallback, + includeMarketData: true, + }); + + await jest.runAllTimersAsync(); + + expect(getLastBtcUpdate(mockCallback)).toEqual( + expect.objectContaining({ symbol: 'BTC', isTradable: false }), + ); + + unsubscribe(); + }); + + it('honors an injected price deviation limit', async () => { + // mid (allMids) BTC = 50000, oraclePx = 40000 -> 25% deviation: tradable under the + // default 0.95 limit, but untradable under an injected 0.1 (10%) limit. + mockSubscriptionClient.activeAssetCtx = jest.fn( + (params: any, callback: any) => { + setTimeout(() => { + callback({ + coin: params.coin, + ctx: { + prevDayPx: '49000', + funding: '0.01', + openInterest: '1000000', + dayNtlVlm: '50000000', + oraclePx: '40000', + midPx: '50000', + }, + }); + }, 0); + return Promise.resolve({ + unsubscribe: jest.fn().mockResolvedValue(undefined), + }); + }, + ); + + const customService = new HyperLiquidSubscriptionService( + mockClientService, + mockWalletService, + mockDeps, + true, // hip3Enabled + [], // enabledDexs + [], // allowlistMarkets + [], // blocklistMarkets + 0.1, // priceDeviationLimit (10%) + ); + + const mockCallback = jest.fn(); + + const unsubscribe = await customService.subscribeToPrices({ + symbols: ['BTC'], + callback: mockCallback, + includeMarketData: true, + }); + + await jest.runAllTimersAsync(); + + expect(getLastBtcUpdate(mockCallback)).toEqual( + expect.objectContaining({ symbol: 'BTC', isTradable: false }), + ); + + unsubscribe(); + customService.clearAll(); + }); + }); +}); diff --git a/packages/perps-controller/tests/src/services/HyperLiquidSubscriptionService.streams.test.ts b/packages/perps-controller/tests/src/services/HyperLiquidSubscriptionService.streams.test.ts new file mode 100644 index 00000000000..8e4c0eaa886 --- /dev/null +++ b/packages/perps-controller/tests/src/services/HyperLiquidSubscriptionService.streams.test.ts @@ -0,0 +1,1566 @@ +/* eslint-disable */ +/** + * Unit tests for HyperLiquidSubscriptionService + */ + +/* eslint-disable @typescript-eslint/no-explicit-any */ + +import type { CaipAccountId, Hex } from '@metamask/utils'; + +import { ABSTRACTION_MODE_REFRESH_THROTTLE_MS } from '../../../src/constants/perpsConfig.js'; +import type { HyperLiquidClientService } from '../../../src/services/HyperLiquidClientService.js'; +import { HyperLiquidSubscriptionService } from '../../../src/services/HyperLiquidSubscriptionService.js'; +import type { HyperLiquidWalletService } from '../../../src/services/HyperLiquidWalletService.js'; +import type { + SubscribeOrderBookParams, + SubscribeOrderFillsParams, + SubscribePositionsParams, + SubscribePricesParams, +} from '../../../src/types/index.js'; +import { + adaptAccountStateFromSDK, + parseAssetName, +} from '../../../src/utils/hyperLiquidAdapter.js'; +import { createMockInfrastructure } from '../../helpers/serviceMocks.js'; + +// Mock HyperLiquid SDK types +interface MockSubscription { + unsubscribe: jest.Mock; +} + +// Mock adapter +jest.mock('../../../src/utils/hyperLiquidAdapter', () => ({ + adaptPositionFromSDK: jest.fn((assetPos: any) => ({ + symbol: 'BTC', + size: assetPos.position.szi, + entryPrice: '50000', + positionValue: '5000', + unrealizedPnl: '100', + marginUsed: '2500', + leverage: { type: 'isolated', value: 2 }, + liquidationPrice: '40000', + maxLeverage: 100, + returnOnEquity: '4.0', + cumulativeFunding: { allTime: '0', sinceOpen: '0', sinceChange: '0' }, + takeProfitCount: 0, + stopLossCount: 0, + })), + adaptOrderFromSDK: jest.fn((order: any) => ({ + orderId: order.oid.toString(), + symbol: order.coin, + side: order.side === 'B' ? 'buy' : 'sell', + orderType: 'limit', + size: order.sz, + originalSize: order.sz, + price: order.limitPx || order.triggerPx || '0', + filledSize: '0', + remainingSize: order.sz, + status: 'open', + timestamp: Date.now(), + detailedOrderType: order.orderType || 'Limit', + isTrigger: order.isTrigger ?? false, + reduceOnly: order.reduceOnly ?? false, + triggerPrice: order.triggerPx, + ...(typeof order.isPositionTpsl === 'boolean' + ? { isPositionTpsl: order.isPositionTpsl } + : {}), + })), + adaptAccountStateFromSDK: jest.fn(() => ({ + spendableBalance: '1000.00', + withdrawableBalance: '1000.00', + marginUsed: '500.00', + unrealizedPnl: '100.00', + returnOnEquity: '20.0', + totalBalance: '10100.00', + })), + parseAssetName: jest.fn((symbol: string) => ({ + symbol, + dex: null, + })), +})); + +// Mock DevLogger +jest.mock( + '../../../../core/SDKConnect/utils/DevLogger', + () => ({ + DevLogger: { + log: jest.fn(), + }, + }), + { virtual: true }, +); + +// Mock trace utilities +jest.mock( + '../../../../util/trace', + () => ({ + trace: jest.fn(), + TraceName: { + PerpsWebSocketConnected: 'Perps WebSocket Connected', + PerpsWebSocketDisconnected: 'Perps WebSocket Disconnected', + }, + TraceOperation: { + PerpsMarketData: 'perps.market_data', + }, + }), + { virtual: true }, +); + +// Mock Sentry +jest.mock( + '@sentry/react-native', + () => ({ + setMeasurement: jest.fn(), + }), + { virtual: true }, +); + +describe('HyperLiquidSubscriptionService', () => { + let service: HyperLiquidSubscriptionService; + let mockClientService: jest.Mocked; + let mockWalletService: jest.Mocked; + let mockSubscriptionClient: any; + let mockWalletAdapter: any; + let mockDeps: ReturnType; + let mockSpotClearinghouseState: jest.Mock; + + beforeEach(() => { + jest.useFakeTimers(); + jest.clearAllMocks(); + mockDeps = createMockInfrastructure(); + jest.mocked(parseAssetName).mockImplementation((symbol: string) => ({ + symbol, + dex: null, + })); + const hyperLiquidAdapter = jest.requireMock( + '../../../src/utils/hyperLiquidAdapter', + ); + hyperLiquidAdapter.adaptPositionFromSDK.mockImplementation( + (assetPos: any) => ({ + symbol: 'BTC', + size: assetPos.position.szi, + entryPrice: '50000', + positionValue: '5000', + unrealizedPnl: '100', + marginUsed: '2500', + leverage: { type: 'isolated', value: 2 }, + liquidationPrice: '40000', + maxLeverage: 100, + returnOnEquity: '4.0', + cumulativeFunding: { allTime: '0', sinceOpen: '0', sinceChange: '0' }, + takeProfitCount: 0, + stopLossCount: 0, + }), + ); + hyperLiquidAdapter.adaptOrderFromSDK.mockImplementation((order: any) => ({ + orderId: order.oid.toString(), + symbol: order.coin, + side: order.side === 'B' ? 'buy' : 'sell', + orderType: 'limit', + size: order.sz, + originalSize: order.sz, + price: order.limitPx || order.triggerPx || '0', + filledSize: '0', + remainingSize: order.sz, + status: 'open', + timestamp: Date.now(), + detailedOrderType: order.orderType || 'Limit', + isTrigger: order.isTrigger ?? false, + reduceOnly: order.reduceOnly ?? false, + triggerPrice: order.triggerPx, + ...(typeof order.isPositionTpsl === 'boolean' + ? { isPositionTpsl: order.isPositionTpsl } + : {}), + })); + hyperLiquidAdapter.adaptAccountStateFromSDK.mockImplementation(() => ({ + spendableBalance: '1000.00', + withdrawableBalance: '1000.00', + marginUsed: '500.00', + unrealizedPnl: '100.00', + returnOnEquity: '20.0', + totalBalance: '10100.00', + })); + + // Mock subscription client + const mockSubscription: MockSubscription = { + unsubscribe: jest.fn().mockResolvedValue(undefined), + }; + + mockSubscriptionClient = { + allMids: jest.fn((paramsOrCallback: any, maybeCallback?: any) => { + const callback = + typeof paramsOrCallback === 'function' + ? paramsOrCallback + : maybeCallback; + // Simulate allMids data + setTimeout(() => { + callback({ + mids: { + BTC: 50000, + ETH: 3000, + }, + }); + }, 0); + return Promise.resolve(mockSubscription); + }), + activeAssetCtx: jest.fn((params: any, callback: any) => { + // Simulate activeAssetCtx data + setTimeout(() => { + callback({ + coin: params.coin, + ctx: { + prevDayPx: '49000', + funding: '0.01', + openInterest: '1000000', // Raw token units from API + dayNtlVlm: '50000000', + oraclePx: '50100', + midPx: '50000', // Price used for openInterest USD conversion: 1M tokens * $50K = $50B + }, + }); + }, 0); + return Promise.resolve(mockSubscription); + }), + webData3: jest.fn((_params: any, callback: any) => { + // Simulate webData3 data with perpDexStates structure + // First callback immediately + setTimeout(() => { + callback({ + perpDexStates: [ + { + clearinghouseState: { + assetPositions: [ + { + position: { szi: '0.1' }, + coin: 'BTC', + }, + ], + }, + openOrders: [ + { + oid: 12345, + coin: 'BTC', + side: 'B', + sz: '0.5', + origSz: '1.0', + limitPx: '50000', + orderType: 'Limit', + timestamp: 1234567890000, + isTrigger: false, + reduceOnly: false, + }, + ], + perpsAtOpenInterestCap: [], + }, + ], + }); + }, 0); + + // Second callback with changed data to ensure updates are triggered + setTimeout(() => { + callback({ + perpDexStates: [ + { + clearinghouseState: { + assetPositions: [ + { + position: { szi: '0.2' }, // Changed position size + coin: 'BTC', + }, + ], + }, + openOrders: [ + { + oid: 12346, // Changed order ID + coin: 'BTC', + side: 'S', + sz: '0.3', + origSz: '0.5', + limitPx: '51000', + orderType: 'Limit', + timestamp: 1234567890001, + isTrigger: false, + reduceOnly: false, + }, + ], + perpsAtOpenInterestCap: [], + }, + ], + }); + }, 10); + + return Promise.resolve(mockSubscription); + }), + webData2: jest.fn((_params: any, callback: any) => { + // Simulate webData2 data with clearinghouseState (HIP-3 disabled) + setTimeout(() => { + callback({ + clearinghouseState: { + assetPositions: [ + { + position: { szi: '0.1' }, + coin: 'BTC', + }, + ], + marginSummary: { + accountValue: '10000', + totalMarginUsed: '500', + }, + withdrawable: '9500', + }, + openOrders: [ + { + oid: 12345, + coin: 'BTC', + side: 'B', + sz: '0.5', + origSz: '1.0', + limitPx: '50000', + orderType: 'Limit', + timestamp: 1234567890000, + isTrigger: false, + reduceOnly: false, + }, + ], + perpsAtOpenInterestCap: [], + }); + }, 0); + return Promise.resolve(mockSubscription); + }), + userFills: jest.fn((_params: any, callback: any) => { + // Simulate order fill data + setTimeout(() => { + callback({ + fills: [ + { + oid: 12345, + coin: 'BTC', + side: 'B', + sz: '0.1', + px: '50000', + fee: '5', + time: Date.now(), + }, + ], + }); + }, 0); + return Promise.resolve(mockSubscription); + }), + l2Book: jest.fn((_params: any, callback: any) => { + // Simulate l2Book data + setTimeout(() => { + callback({ + coin: _params.coin, + levels: { bids: [], asks: [] }, + }); + }, 0); + return Promise.resolve(mockSubscription); + }), + bbo: jest.fn((_params: any, callback: any) => { + // Simulate BBO data + setTimeout(() => { + callback({ + coin: _params.coin, + time: Date.now(), + bbo: [ + { px: '49900', sz: '1.5', n: 1 }, + { px: '50100', sz: '2.0', n: 1 }, + ], + }); + }, 0); + return Promise.resolve(mockSubscription); + }), + activeAsset: jest.fn((params: any, callback: any) => { + // Simulate activeAsset data (similar to activeAssetCtx) + setTimeout(() => { + callback({ + coin: params.coin, + data: 'test', + }); + }, 0); + return Promise.resolve(mockSubscription); + }), + clearinghouseState: jest.fn((_params: any, callback: any) => { + // Simulate clearinghouseState data for individual subscription + setTimeout(() => { + callback({ + dex: _params.dex || '', + clearinghouseState: { + assetPositions: [ + { + position: { szi: '0.1' }, + coin: 'BTC', + }, + ], + marginSummary: { + accountValue: '10000', + totalMarginUsed: '500', + }, + withdrawable: '9500', + }, + }); + }, 0); + return Promise.resolve(mockSubscription); + }), + openOrders: jest.fn((_params: any, callback: any) => { + // Simulate openOrders data for individual subscription + setTimeout(() => { + callback({ + dex: _params.dex || '', + orders: [ + { + oid: 12345, + coin: 'BTC', + side: 'B', + sz: '0.5', + origSz: '1.0', + limitPx: '50000', + orderType: 'Limit', + timestamp: 1234567890000, + isTrigger: false, + reduceOnly: false, + triggerCondition: '', + triggerPx: '', + children: [], + isPositionTpsl: false, + tif: null, + cloid: null, + }, + ], + }); + }, 0); + return Promise.resolve(mockSubscription); + }), + assetCtxs: jest.fn(() => Promise.resolve(mockSubscription)), + fastAssetCtxs: jest.fn((_callback: any) => + Promise.resolve(mockSubscription), + ), + spotState: jest.fn((_params: any, _callback: any) => + Promise.resolve(mockSubscription), + ), + }; + + mockWalletAdapter = { + request: jest.fn(), + }; + + // Mock client service + mockSpotClearinghouseState = jest.fn().mockResolvedValue({ + balances: [{ coin: 'USDC', total: '100.76531791' }], + }); + + mockClientService = { + ensureSubscriptionClient: jest.fn().mockResolvedValue(undefined), + getSubscriptionClient: jest.fn(() => mockSubscriptionClient), + getInfoClient: jest.fn(() => ({ + spotClearinghouseState: mockSpotClearinghouseState, + // Mode-aware fold gate reads userAbstraction; default to unifiedAccount + // so existing spot-fold assertions behave as before the gate was added. + userAbstraction: jest.fn().mockResolvedValue('unifiedAccount'), + })), + isTestnetMode: jest.fn(() => false), + ensureTransportReady: jest.fn().mockResolvedValue(undefined), + getConnectionState: jest.fn(() => 'connected'), + } as any; + + // Mock wallet service + mockWalletService = { + createWalletAdapter: jest.fn(() => mockWalletAdapter), + getUserAddressWithDefault: jest.fn().mockResolvedValue('0x123' as Hex), + } as any; + + service = new HyperLiquidSubscriptionService( + mockClientService, + mockWalletService, + mockDeps, + true, // hip3Enabled - test expects webData3 + ); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + describe('Price Subscriptions', () => { + it('should subscribe to price updates successfully', async () => { + const mockCallback = jest.fn(); + const params: SubscribePricesParams = { + symbols: ['BTC', 'ETH'], + callback: mockCallback, + includeMarketData: true, // Enable market data to test activeAssetCtx subscription + }; + + const unsubscribe = await service.subscribeToPrices(params); + + expect(mockSubscriptionClient.allMids).toHaveBeenCalled(); + expect(mockSubscriptionClient.activeAssetCtx).toHaveBeenCalledWith( + { coin: 'BTC' }, + expect.any(Function), + ); + expect(mockSubscriptionClient.activeAssetCtx).toHaveBeenCalledWith( + { coin: 'ETH' }, + expect.any(Function), + ); + + // Advance timers to trigger async callbacks + await jest.runAllTimersAsync(); + + expect(mockCallback).toHaveBeenCalled(); + expect(typeof unsubscribe).toBe('function'); + }); + + it('should handle subscription client not available', async () => { + mockClientService.getSubscriptionClient.mockReturnValue(undefined); + + const mockCallback = jest.fn(); + const params: SubscribePricesParams = { + symbols: ['BTC'], + callback: mockCallback, + }; + + const unsubscribe = await service.subscribeToPrices(params); + + expect(typeof unsubscribe).toBe('function'); + expect(mockSubscriptionClient.allMids).not.toHaveBeenCalled(); + }); + + it('should send cached price data immediately', async () => { + const mockCallback = jest.fn(); + + // First subscription to populate cache + const firstUnsubscribe = await service.subscribeToPrices({ + symbols: ['BTC'], + callback: jest.fn(), + }); + + // Advance timers for cache to populate + await jest.runAllTimersAsync(); + + // Second subscription should get cached data immediately + const secondUnsubscribe = await service.subscribeToPrices({ + symbols: ['BTC'], + callback: mockCallback, + }); + + expect(mockCallback).toHaveBeenCalled(); + + firstUnsubscribe(); + secondUnsubscribe(); + }); + + it('should cleanup subscriptions with reference counting', async () => { + const mockCallback1 = jest.fn(); + const mockCallback2 = jest.fn(); + + // Test that subscribing without market data does not call activeAssetCtx + const unsubscribe1 = await service.subscribeToPrices({ + symbols: ['ETH'], + callback: mockCallback1, + includeMarketData: false, + }); + + const unsubscribe2 = await service.subscribeToPrices({ + symbols: ['ETH'], + callback: mockCallback2, + includeMarketData: false, + }); + + // Should not call activeAssetCtx when includeMarketData is false + expect(mockSubscriptionClient.activeAssetCtx).not.toHaveBeenCalledWith( + { coin: 'ETH' }, + expect.any(Function), + ); + + // Cleanup + unsubscribe1(); + unsubscribe2(); + + // Verify cleanup functions exist + expect(typeof unsubscribe1).toBe('function'); + expect(typeof unsubscribe2).toBe('function'); + }); + + it('does not notify a list subscriber for symbol A when only symbol B activeAssetCtx fires', async () => { + const listCallback = jest.fn(); + const focusedCallback = jest.fn(); + + // List subscriber watching BTC (no market data -> no activeAssetCtx subscription) + const unsubscribeList = await service.subscribeToPrices({ + symbols: ['BTC'], + callback: listCallback, + includeMarketData: false, + }); + + // Focused subscriber watching ETH (market data -> activeAssetCtx subscription) + const unsubscribeFocused = await service.subscribeToPrices({ + symbols: ['ETH'], + callback: focusedCallback, + includeMarketData: true, + }); + + // Let initial allMids + activeAssetCtx ticks settle + await jest.runAllTimersAsync(); + + listCallback.mockClear(); + focusedCallback.mockClear(); + + // Fire a fresh activeAssetCtx tick for ETH only (simulates the fast-stream + // price cadence for a focused symbol while BTC's allMids baseline is untouched) + const ethCall = mockSubscriptionClient.activeAssetCtx.mock.calls.find( + ([params]: [{ coin: string }]) => params.coin === 'ETH', + ); + expect(ethCall).toBeDefined(); + const ethCallback = ethCall[1]; + + ethCallback({ + coin: 'ETH', + ctx: { + prevDayPx: '2900', + funding: '0.02', + openInterest: '2000000', + dayNtlVlm: '60000000', + oraclePx: '3010', + midPx: '3010', + }, + }); + + expect(focusedCallback).toHaveBeenCalled(); + expect(listCallback).not.toHaveBeenCalled(); + + unsubscribeList(); + unsubscribeFocused(); + }); + + it('only notifies subscribers of symbols whose allMids price actually changed', async () => { + const btcCallback = jest.fn(); + const ethCallback = jest.fn(); + + const unsubscribeBtc = await service.subscribeToPrices({ + symbols: ['BTC'], + callback: btcCallback, + }); + const unsubscribeEth = await service.subscribeToPrices({ + symbols: ['ETH'], + callback: ethCallback, + }); + + // Let the initial allMids snapshot settle + await jest.runAllTimersAsync(); + + btcCallback.mockClear(); + ethCallback.mockClear(); + + // Re-invoke the allMids handler directly with only BTC's price changed + const allMidsCallback = mockSubscriptionClient.allMids.mock.calls[0][0]; + allMidsCallback({ + mids: { + BTC: 51000, // changed + ETH: 3000, // unchanged from initial snapshot + }, + }); + + expect(btcCallback).toHaveBeenCalled(); + expect(ethCallback).not.toHaveBeenCalled(); + + unsubscribeBtc(); + unsubscribeEth(); + }); + + it('establishes a global fastAssetCtxs subscription when subscribing to prices', async () => { + const mockCallback = jest.fn(); + + const unsubscribe = await service.subscribeToPrices({ + symbols: ['BTC'], + callback: mockCallback, + }); + + await jest.runAllTimersAsync(); + + expect(mockSubscriptionClient.fastAssetCtxs).toHaveBeenCalledTimes(1); + expect(mockSubscriptionClient.fastAssetCtxs).toHaveBeenCalledWith( + expect.any(Function), + ); + + unsubscribe(); + }); + + it('only creates a single global fastAssetCtxs subscription for multiple subscribers', async () => { + const unsubscribeBtc = await service.subscribeToPrices({ + symbols: ['BTC'], + callback: jest.fn(), + }); + const unsubscribeEth = await service.subscribeToPrices({ + symbols: ['ETH'], + callback: jest.fn(), + }); + + await jest.runAllTimersAsync(); + + expect(mockSubscriptionClient.fastAssetCtxs).toHaveBeenCalledTimes(1); + + unsubscribeBtc(); + unsubscribeEth(); + }); + + it('applies a fastAssetCtxs snapshot to cached price data and notifies subscribers', async () => { + const btcCallback = jest.fn(); + + const unsubscribe = await service.subscribeToPrices({ + symbols: ['BTC'], + callback: btcCallback, + }); + + await jest.runAllTimersAsync(); + btcCallback.mockClear(); + + const fastAssetCtxsCallback = + mockSubscriptionClient.fastAssetCtxs.mock.calls[0][0]; + + // First message after subscribing is a full snapshot keyed by coin + fastAssetCtxsCallback({ + BTC: { midPx: '52000' }, + SOL: { midPx: '150' }, // no subscriber for SOL; should be ignored + }); + + expect(btcCallback).toHaveBeenCalledWith([ + expect.objectContaining({ symbol: 'BTC', price: '52000' }), + ]); + + unsubscribe(); + }); + + it('only notifies subscribers of symbols present in a fastAssetCtxs diff message', async () => { + const btcCallback = jest.fn(); + const ethCallback = jest.fn(); + + const unsubscribeBtc = await service.subscribeToPrices({ + symbols: ['BTC'], + callback: btcCallback, + }); + const unsubscribeEth = await service.subscribeToPrices({ + symbols: ['ETH'], + callback: ethCallback, + }); + + await jest.runAllTimersAsync(); + + const fastAssetCtxsCallback = + mockSubscriptionClient.fastAssetCtxs.mock.calls[0][0]; + + // Snapshot establishes a baseline for both symbols + fastAssetCtxsCallback({ + BTC: { midPx: '52000' }, + ETH: { midPx: '3000' }, + }); + + btcCallback.mockClear(); + ethCallback.mockClear(); + + // Later messages are diffs containing only the coins that changed + fastAssetCtxsCallback({ + BTC: { midPx: '52500' }, + }); + + expect(btcCallback).toHaveBeenCalledWith([ + expect.objectContaining({ symbol: 'BTC', price: '52500' }), + ]); + expect(ethCallback).not.toHaveBeenCalled(); + + unsubscribeBtc(); + unsubscribeEth(); + }); + + it('falls back to markPx when midPx is absent in a fastAssetCtxs update', async () => { + const btcCallback = jest.fn(); + + const unsubscribe = await service.subscribeToPrices({ + symbols: ['BTC'], + callback: btcCallback, + }); + + await jest.runAllTimersAsync(); + btcCallback.mockClear(); + + const fastAssetCtxsCallback = + mockSubscriptionClient.fastAssetCtxs.mock.calls[0][0]; + + fastAssetCtxsCallback({ + BTC: { markPx: '53000' }, + }); + + expect(btcCallback).toHaveBeenCalledWith([ + expect.objectContaining({ symbol: 'BTC', price: '53000' }), + ]); + + unsubscribe(); + }); + + it('skips a coin with a null midPx and no markPx in a fastAssetCtxs update without throwing', async () => { + const btcCallback = jest.fn(); + const ethCallback = jest.fn(); + + const unsubscribeBtc = await service.subscribeToPrices({ + symbols: ['BTC'], + callback: btcCallback, + }); + const unsubscribeEth = await service.subscribeToPrices({ + symbols: ['ETH'], + callback: ethCallback, + }); + + await jest.runAllTimersAsync(); + btcCallback.mockClear(); + ethCallback.mockClear(); + + const fastAssetCtxsCallback = + mockSubscriptionClient.fastAssetCtxs.mock.calls[0][0]; + + // BTC has a null midPx (and no markPx), which the SDK types allow at + // runtime; ETH is a valid update (with a price change from the allMids + // baseline of 3000) in the same payload. + expect(() => + fastAssetCtxsCallback({ + BTC: { midPx: null }, + ETH: { midPx: '3100' }, + }), + ).not.toThrow(); + + expect(btcCallback).not.toHaveBeenCalled(); + expect(ethCallback).toHaveBeenCalledWith([ + expect.objectContaining({ symbol: 'ETH', price: '3100' }), + ]); + + unsubscribeBtc(); + unsubscribeEth(); + }); + + it('does not notify for a coin with no subscriber in a fastAssetCtxs snapshot', async () => { + const btcCallback = jest.fn(); + + const unsubscribe = await service.subscribeToPrices({ + symbols: ['BTC'], + callback: btcCallback, + }); + + await jest.runAllTimersAsync(); + btcCallback.mockClear(); + + const fastAssetCtxsCallback = + mockSubscriptionClient.fastAssetCtxs.mock.calls[0][0]; + + // SOL has a valid price but no subscriber; only BTC (subscribed) + // should trigger a notification. + fastAssetCtxsCallback({ + BTC: { midPx: '52000' }, + SOL: { midPx: '150' }, + }); + + expect(btcCallback).toHaveBeenCalledTimes(1); + expect(btcCallback).toHaveBeenCalledWith([ + expect.objectContaining({ symbol: 'BTC', price: '52000' }), + ]); + + unsubscribe(); + }); + + it('caches a fastAssetCtxs price for a coin with no subscriber so a later subscriber gets it immediately', async () => { + const btcCallback = jest.fn(); + + const unsubscribeBtc = await service.subscribeToPrices({ + symbols: ['BTC'], + callback: btcCallback, + }); + + await jest.runAllTimersAsync(); + + const fastAssetCtxsCallback = + mockSubscriptionClient.fastAssetCtxs.mock.calls[0][0]; + + // SOL has no subscriber yet, but its valid price must still be cached + // so a later subscriber gets an immediate baseline instead of waiting + // for the next snapshot/diff that happens to include SOL. + fastAssetCtxsCallback({ + BTC: { midPx: '52000' }, + SOL: { midPx: '150' }, + }); + + const solCallback = jest.fn(); + const unsubscribeSol = await service.subscribeToPrices({ + symbols: ['SOL'], + callback: solCallback, + }); + + await jest.runAllTimersAsync(); + + expect(solCallback).toHaveBeenCalledWith([ + expect.objectContaining({ symbol: 'SOL', price: '150' }), + ]); + + unsubscribeBtc(); + unsubscribeSol(); + }); + }); + + describe('Position Subscriptions', () => { + it('should subscribe to position updates successfully', async () => { + const mockCallback = jest.fn(); + const params: SubscribePositionsParams = { + accountId: 'eip155:42161:0x123' as CaipAccountId, + callback: mockCallback, + }; + + const unsubscribe = service.subscribeToPositions(params); + + // Wait for async operations (individual subscription setup for HIP-3 mode) + // Need to flush both timers and microtask queue since subscription uses fire-and-forget promises + await jest.runAllTimersAsync(); + // Flush microtask queue to allow promise chains to complete + await Promise.resolve(); + await jest.runAllTimersAsync(); + + expect(mockWalletService.getUserAddressWithDefault).toHaveBeenCalledWith( + params.accountId, + ); + + // HIP-3 mode uses individual subscriptions (clearinghouseState + openOrders) + // and webData3 only for OI caps + expect(mockSubscriptionClient.clearinghouseState).toHaveBeenCalledWith( + { user: '0x123', dex: undefined }, + expect.any(Function), + ); + expect(mockSubscriptionClient.openOrders).toHaveBeenCalledWith( + { user: '0x123', dex: undefined }, + expect.any(Function), + ); + expect(mockSubscriptionClient.webData3).toHaveBeenCalledWith( + { user: '0x123' }, + expect.any(Function), + ); + expect(mockCallback).toHaveBeenCalled(); + expect(typeof unsubscribe).toBe('function'); + }); + + it('should handle wallet service errors', async () => { + mockWalletService.getUserAddressWithDefault.mockRejectedValue( + new Error('Wallet error'), + ); + + const mockCallback = jest.fn(); + const params: SubscribePositionsParams = { + accountId: 'eip155:42161:0x123' as CaipAccountId, + callback: mockCallback, + }; + + const unsubscribe = service.subscribeToPositions(params); + + // Wait for async operations + await jest.runAllTimersAsync(); + + // Should not call any subscriptions when wallet service fails + expect(mockSubscriptionClient.clearinghouseState).not.toHaveBeenCalled(); + expect(mockSubscriptionClient.openOrders).not.toHaveBeenCalled(); + expect(mockSubscriptionClient.webData3).not.toHaveBeenCalled(); + expect(typeof unsubscribe).toBe('function'); + }); + + it('should handle subscription client not available', async () => { + mockClientService.getSubscriptionClient.mockReturnValue(undefined); + + const mockCallback = jest.fn(); + const params: SubscribePositionsParams = { + callback: mockCallback, + }; + + const unsubscribe = service.subscribeToPositions(params); + + // Wait for async operations + await jest.runAllTimersAsync(); + + expect(typeof unsubscribe).toBe('function'); + // Should not call any subscriptions when client not available + expect(mockSubscriptionClient.clearinghouseState).not.toHaveBeenCalled(); + expect(mockSubscriptionClient.openOrders).not.toHaveBeenCalled(); + expect(mockSubscriptionClient.webData3).not.toHaveBeenCalled(); + }); + + it('should filter out zero-size positions', async () => { + const mockCallback = jest.fn(); + + // Mock clearinghouseState with mixed positions (HIP-3 mode uses individual subscriptions) + mockSubscriptionClient.clearinghouseState.mockImplementation( + (_params: any, callback: any) => { + setTimeout(() => { + callback({ + dex: _params.dex || '', + clearinghouseState: { + assetPositions: [ + { position: { szi: '0.1' }, coin: 'BTC' }, // Should be included + { position: { szi: '0' }, coin: 'ETH' }, // Should be filtered out + ], + marginSummary: { + accountValue: '10000', + totalMarginUsed: '500', + }, + withdrawable: '9500', + }, + }); + }, 0); + return Promise.resolve({ + unsubscribe: jest.fn().mockResolvedValue(undefined), + }); + }, + ); + + const unsubscribe = service.subscribeToPositions({ + callback: mockCallback, + }); + + // Wait for async operations + await jest.runAllTimersAsync(); + + expect(mockCallback).toHaveBeenCalledWith( + expect.arrayContaining([expect.objectContaining({ size: '0.1' })]), + ); + + unsubscribe(); + }); + }); + + describe('Order Fill Subscriptions', () => { + it('should subscribe to order fill updates successfully', async () => { + const mockCallback = jest.fn(); + const params: SubscribeOrderFillsParams = { + accountId: 'eip155:42161:0x123' as CaipAccountId, + callback: mockCallback, + }; + + const unsubscribe = service.subscribeToOrderFills(params); + + expect(mockWalletService.getUserAddressWithDefault).toHaveBeenCalledWith( + params.accountId, + ); + + // Wait for async operations + await jest.runAllTimersAsync(); + + expect(mockSubscriptionClient.userFills).toHaveBeenCalledWith( + { user: '0x123' }, + expect.any(Function), + ); + expect(mockCallback).toHaveBeenCalled(); + expect(typeof unsubscribe).toBe('function'); + }); + + it('should transform order fill data correctly', async () => { + const mockCallback = jest.fn(); + + const unsubscribe = service.subscribeToOrderFills({ + callback: mockCallback, + }); + + // Wait for async operations + await jest.runAllTimersAsync(); + + expect(mockCallback).toHaveBeenCalledWith( + [ + expect.objectContaining({ + orderId: '12345', + symbol: 'BTC', + side: 'B', + size: '0.1', + price: '50000', + fee: '5', + timestamp: expect.any(Number), + }), + ], + undefined, // isSnapshot is undefined for mock data without it + ); + + unsubscribe(); + }); + + it('should handle wallet service errors in order fills', async () => { + mockWalletService.getUserAddressWithDefault.mockRejectedValue( + new Error('Wallet error'), + ); + + const mockCallback = jest.fn(); + const unsubscribe = service.subscribeToOrderFills({ + callback: mockCallback, + }); + + // Wait for async operations + await jest.runAllTimersAsync(); + + expect(mockSubscriptionClient.userFills).not.toHaveBeenCalled(); + expect(typeof unsubscribe).toBe('function'); + }); + + it('should handle order fills with liquidation data', async () => { + const mockCallback = jest.fn(); + + // Update mock data to include liquidation + mockSubscriptionClient.userFills.mockImplementation( + (_params: any, callback: any) => { + setTimeout(() => { + callback({ + fills: [ + { + oid: BigInt(12345), + coin: 'BTC', + side: 'A', + sz: '0.1', + px: '45000', + fee: '5', + time: Date.now(), + closedPnl: '-500', + dir: 'Close Long', + feeToken: 'USDC', + liquidation: { + liquidatedUser: '0x123', + markPx: '44900', + method: 'market', + }, + }, + ], + }); + }, 0); + return Promise.resolve({ + unsubscribe: jest.fn().mockResolvedValue(undefined), + }); + }, + ); + + const unsubscribe = service.subscribeToOrderFills({ + callback: mockCallback, + }); + + await jest.runAllTimersAsync(); + + expect(mockCallback).toHaveBeenCalledWith( + [ + expect.objectContaining({ + orderId: '12345', + symbol: 'BTC', + liquidation: { + liquidatedUser: '0x123', + markPx: '44900', + method: 'market', + }, + }), + ], + undefined, // isSnapshot is undefined for mock data without it + ); + + unsubscribe(); + }); + + it('enriches WS fills with detailedOrderType from cached orders', async () => { + // Arrange — subscribe to orders first so #cachedOrders gets populated + const orderCallback = jest.fn(); + service.subscribeToOrders({ callback: orderCallback }); + await jest.runAllTimersAsync(); + + // Now subscribe to fills — the callback should enrich with cached order types + const fillCallback = jest.fn(); + mockSubscriptionClient.userFills.mockImplementation( + (_params: any, callback: any) => { + setTimeout(() => { + callback({ + fills: [ + { + oid: BigInt(12345), + coin: 'BTC', + side: 'B', + sz: '0.1', + px: '50000', + fee: '5', + time: Date.now(), + closedPnl: '0', + dir: 'Open Long', + feeToken: 'USDC', + startPosition: '0', + }, + ], + }); + }, 0); + return Promise.resolve({ + unsubscribe: jest.fn().mockResolvedValue(undefined), + }); + }, + ); + + // Act + const unsubscribe = service.subscribeToOrderFills({ + callback: fillCallback, + }); + await jest.runAllTimersAsync(); + + // Assert — fill received with orderId mapped and detailedOrderType enriched + expect(fillCallback).toHaveBeenCalledWith( + [ + expect.objectContaining({ + orderId: '12345', + symbol: 'BTC', + detailedOrderType: 'Limit', + }), + ], + undefined, + ); + + unsubscribe(); + }); + + it('should pass isSnapshot flag to callback', async () => { + const mockCallback = jest.fn(); + + // Update mock data to include isSnapshot: true (snapshot message) + mockSubscriptionClient.userFills.mockImplementation( + (_params: any, callback: any) => { + setTimeout(() => { + callback({ + fills: [ + { + oid: BigInt(12345), + coin: 'BTC', + side: 'B', + sz: '0.1', + px: '50000', + fee: '5', + time: Date.now(), + }, + ], + isSnapshot: true, // This is a snapshot message + }); + }, 0); + return Promise.resolve({ + unsubscribe: jest.fn().mockResolvedValue(undefined), + }); + }, + ); + + const unsubscribe = service.subscribeToOrderFills({ + callback: mockCallback, + }); + + await jest.runAllTimersAsync(); + + expect(mockCallback).toHaveBeenCalledWith( + expect.any(Array), + true, // isSnapshot should be passed through + ); + + unsubscribe(); + }); + }); + + describe('Shared WebData3 Subscription', () => { + it('should share webData3 subscription between positions and orders', async () => { + const positionCallback = jest.fn(); + const orderCallback = jest.fn(); + + // Mock getUserAddressWithDefault to return immediately + mockWalletService.getUserAddressWithDefault.mockResolvedValue( + '0x123' as Hex, + ); + + // Subscribe to positions first + const unsubscribePositions = service.subscribeToPositions({ + callback: positionCallback, + }); + + // Wait for subscription to be established and initial callback + // This will trigger the first webData3 callback which caches both positions and orders + await jest.runAllTimersAsync(); + + // Verify position callback was called + expect(positionCallback).toHaveBeenCalled(); + + // Subscribe to orders - should reuse same webData3 subscription + // and immediately get cached data + const unsubscribeOrders = service.subscribeToOrders({ + callback: orderCallback, + }); + + // Orders should get cached data immediately (synchronously) + // or after the second webData3 update with changed data + await jest.runAllTimersAsync(); + + // Should only call webData3 once for shared subscription + expect(mockSubscriptionClient.webData3).toHaveBeenCalledTimes(1); + + // Both callbacks should be called with their respective data + expect(positionCallback).toHaveBeenCalled(); + expect(orderCallback).toHaveBeenCalled(); + + // Cleanup + unsubscribePositions(); + unsubscribeOrders(); + }); + + it('should maintain subscription when one subscriber unsubscribes', async () => { + const positionCallback1 = jest.fn(); + const positionCallback2 = jest.fn(); + + // Subscribe two position callbacks + const unsubscribe1 = service.subscribeToPositions({ + callback: positionCallback1, + }); + + const unsubscribe2 = service.subscribeToPositions({ + callback: positionCallback2, + }); + + await jest.runAllTimersAsync(); + + // Unsubscribe first callback + unsubscribe1(); + + // Second callback should still receive updates + mockSubscriptionClient.webData3.mock.calls[0][1]({ + perpDexStates: [ + { + clearinghouseState: { + assetPositions: [ + { + position: { coin: 'BTC', szi: '1.0' }, + }, + ], + }, + openOrders: [], + perpsAtOpenInterestCap: [], + }, + ], + }); + + expect(positionCallback2).toHaveBeenCalled(); + + unsubscribe2(); + }); + + it('should cache positions and orders data', async () => { + const positionCallback = jest.fn(); + + // Setup webData3 mock to call callback with data + mockSubscriptionClient.webData3.mockImplementation( + (_addr: any, callback: any) => { + setTimeout(() => { + callback({ + perpDexStates: [ + { + clearinghouseState: { + assetPositions: [ + { + position: { szi: '1.0' }, + coin: 'BTC', + }, + ], + }, + openOrders: [ + { + oid: 123, + coin: 'BTC', + side: 'B', + sz: '0.5', + origSz: '0.5', + limitPx: '50000', + orderType: 'Limit', + timestamp: Date.now(), + isTrigger: false, + reduceOnly: false, + }, + ], + perpsAtOpenInterestCap: [], + }, + ], + }); + }, 0); + return Promise.resolve({ + unsubscribe: jest.fn().mockResolvedValue(undefined), + }); + }, + ); + + const unsubscribe = service.subscribeToPositions({ + callback: positionCallback, + }); + + await jest.runAllTimersAsync(); + + // Should receive cached data on new subscription + const newCallback = jest.fn(); + const unsubscribe2 = service.subscribeToPositions({ + callback: newCallback, + }); + + // New subscriber should get cached data immediately + expect(newCallback).toHaveBeenCalledWith( + expect.arrayContaining([expect.objectContaining({ symbol: 'BTC' })]), + ); + + unsubscribe(); + unsubscribe2(); + }); + + it('uses per-DEX subscriptions (not webData2) when HIP-3 is disabled', async () => { + // Arrange + const positionCallback = jest.fn(); + const orderCallback = jest.fn(); + const accountCallback = jest.fn(); + const oiCapCallback = jest.fn(); + + // Create service with HIP-3 disabled + const serviceWithoutHip3 = new HyperLiquidSubscriptionService( + mockClientService, + mockWalletService, + mockDeps, + false, // hip3Enabled = false + [], // enabledDexs + ); + + mockWalletService.getUserAddressWithDefault.mockResolvedValue( + '0x123' as Hex, + ); + + // Positions + account come from the main-DEX clearinghouseState subscription + mockSubscriptionClient.clearinghouseState.mockImplementation( + (_params: any, callback: any) => { + setTimeout(() => { + callback({ + dex: '', + clearinghouseState: { + assetPositions: [ + { + position: { + coin: 'BTC', + szi: '1.5', + }, + }, + ], + marginSummary: { + accountValue: '100000', + totalMarginUsed: '7500', + }, + withdrawable: '92500', + }, + }); + }, 0); + return Promise.resolve({ + unsubscribe: jest.fn().mockResolvedValue(undefined), + }); + }, + ); + + // Orders come from the main-DEX openOrders subscription + mockSubscriptionClient.openOrders.mockImplementation( + (_params: any, callback: any) => { + setTimeout(() => { + callback({ + dex: '', + orders: [ + { + oid: 456, + coin: 'ETH', + side: 'A', + sz: '2.0', + origSz: '2.0', + limitPx: '3000', + orderType: 'Limit', + timestamp: 1234567890000, + isTrigger: false, + reduceOnly: false, + }, + ], + }); + }, 0); + return Promise.resolve({ + unsubscribe: jest.fn().mockResolvedValue(undefined), + }); + }, + ); + + // OI caps still come from webData3 (acceptable latency) + mockSubscriptionClient.webData3.mockImplementation( + (_params: any, callback: any) => { + setTimeout(() => { + callback({ + perpDexStates: [ + { + clearinghouseState: { assetPositions: [] }, + openOrders: [], + perpsAtOpenInterestCap: ['BTC', 'DOGE'], + }, + ], + }); + }, 0); + return Promise.resolve({ + unsubscribe: jest.fn().mockResolvedValue(undefined), + }); + }, + ); + + // Act + const unsubscribePositions = serviceWithoutHip3.subscribeToPositions({ + callback: positionCallback, + }); + const unsubscribeOrders = serviceWithoutHip3.subscribeToOrders({ + callback: orderCallback, + }); + const unsubscribeAccount = serviceWithoutHip3.subscribeToAccount({ + callback: accountCallback, + }); + const unsubscribeOICaps = serviceWithoutHip3.subscribeToOICaps({ + callback: oiCapCallback, + }); + + await jest.runAllTimersAsync(); + + // Assert: webData2 is never used; positions/orders/account come from the + // per-DEX subscriptions and OI caps from webData3. + expect(mockSubscriptionClient.webData2).not.toHaveBeenCalled(); + expect(mockSubscriptionClient.clearinghouseState).toHaveBeenCalledWith( + expect.objectContaining({ user: '0x123' }), + expect.any(Function), + ); + expect(mockSubscriptionClient.openOrders).toHaveBeenCalledWith( + expect.objectContaining({ user: '0x123' }), + expect.any(Function), + ); + expect(mockSubscriptionClient.webData3).toHaveBeenCalledWith( + { user: '0x123' }, + expect.any(Function), + ); + + expect(positionCallback).toHaveBeenCalledWith( + expect.arrayContaining([ + expect.objectContaining({ + symbol: 'BTC', + size: '1.5', + }), + ]), + ); + + expect(orderCallback).toHaveBeenCalledWith( + expect.arrayContaining([ + expect.objectContaining({ + orderId: '456', + symbol: 'ETH', + }), + ]), + ); + + expect(accountCallback).toHaveBeenCalledWith( + expect.objectContaining({ + totalBalance: expect.any(String), + marginUsed: expect.any(String), + }), + ); + + expect(oiCapCallback).toHaveBeenCalledWith(['BTC', 'DOGE']); + + // Cleanup + unsubscribePositions(); + unsubscribeOrders(); + unsubscribeAccount(); + unsubscribeOICaps(); + }); + }); +}); diff --git a/packages/perps-controller/tests/src/services/HyperLiquidWalletService.test.ts b/packages/perps-controller/tests/src/services/HyperLiquidWalletService.test.ts new file mode 100644 index 00000000000..72fb99734bc --- /dev/null +++ b/packages/perps-controller/tests/src/services/HyperLiquidWalletService.test.ts @@ -0,0 +1,570 @@ +/* eslint-disable */ +/** + * Unit tests for HyperLiquidWalletService + */ + +// Mock keyring-api to avoid import issues with definePattern +jest.mock('@metamask/keyring-api', () => ({ + isEvmAccountType: jest.fn((accountType: string) => + accountType?.startsWith('eip155:'), + ), +})); + +// Mock MetaMask utils +jest.mock('@metamask/utils', () => ({ + hasProperty: jest.fn((object: object, property: string) => + Object.prototype.hasOwnProperty.call(object, property), + ), + parseCaipAccountId: jest.fn((accountId: string) => { + const parts = accountId.split(':'); + return { + chainNamespace: parts[0], + chainReference: parts[1], + address: parts[2], + }; + }), + isValidHexAddress: jest.fn((address: string) => + /^0x[0-9a-fA-F]{40}$/.test(address), + ), +})); + +// Mock config +jest.mock('../../../src/constants/hyperLiquidConfig', () => ({ + getChainId: jest.fn((isTestnet: boolean) => (isTestnet ? '421614' : '42161')), +})); + +// Mock DevLogger +jest.mock( + '../../../../core/SDKConnect/utils/DevLogger', + () => ({ + DevLogger: { + log: jest.fn(), + }, + }), + { virtual: true }, +); + +import type { CaipAccountId } from '@metamask/utils'; + +import { HyperLiquidWalletService } from '../../../src/services/HyperLiquidWalletService.js'; +import { + createMockInfrastructure, + createMockEvmAccount, + createMockMessenger, +} from '../../helpers/serviceMocks.js'; + +describe('HyperLiquidWalletService', () => { + let service: HyperLiquidWalletService; + let mockDeps: ReturnType; + let mockMessenger: ReturnType; + const mockEvmAccount = createMockEvmAccount(); + + beforeEach(() => { + jest.clearAllMocks(); + const keyringApi = jest.requireMock('@metamask/keyring-api'); + keyringApi.isEvmAccountType.mockImplementation((accountType: string) => + accountType?.startsWith('eip155:'), + ); + const utils = jest.requireMock('@metamask/utils'); + utils.hasProperty.mockImplementation((object: object, property: string) => + Object.prototype.hasOwnProperty.call(object, property), + ); + utils.parseCaipAccountId.mockImplementation((accountId: string) => { + const parts = accountId.split(':'); + return { + chainNamespace: parts[0], + chainReference: parts[1], + address: parts[2], + }; + }); + utils.isValidHexAddress.mockImplementation((address: string) => + /^0x[0-9a-fA-F]{40}$/.test(address), + ); + const hyperLiquidConfig = jest.requireMock( + '../../../src/constants/hyperLiquidConfig', + ); + hyperLiquidConfig.getChainId.mockImplementation((isTestnet: boolean) => + isTestnet ? '421614' : '42161', + ); + mockDeps = createMockInfrastructure(); + mockMessenger = createMockMessenger(); + service = new HyperLiquidWalletService(mockDeps, mockMessenger); + }); + + describe('Constructor and Configuration', () => { + it('should initialize with mainnet by default', () => { + expect(service.isTestnetMode()).toBe(false); + }); + + it('should initialize with testnet when specified', () => { + const testnetService = new HyperLiquidWalletService( + mockDeps, + mockMessenger, + { isTestnet: true }, + ); + + expect(testnetService.isTestnetMode()).toBe(true); + }); + + it('should update testnet mode', () => { + service.setTestnetMode(true); + + expect(service.isTestnetMode()).toBe(true); + }); + }); + + describe('Wallet Adapter Creation', () => { + let walletAdapter: { + signTypedData: (params: { + domain: { + name: string; + version: string; + chainId: number; + verifyingContract: `0x${string}`; + }; + types: { + [key: string]: { name: string; type: string }[]; + }; + primaryType: string; + message: Record; + }) => Promise<`0x${string}`>; + getChainId?: () => Promise; + }; + + beforeEach(() => { + walletAdapter = service.createWalletAdapter(); + }); + + it('should create wallet adapter with signTypedData method', () => { + expect(walletAdapter).toHaveProperty('signTypedData'); + expect(typeof walletAdapter.signTypedData).toBe('function'); + }); + + it('should have getChainId method', () => { + expect(walletAdapter).toHaveProperty('getChainId'); + expect(typeof walletAdapter.getChainId).toBe('function'); + }); + + describe('getChainId method', () => { + it('should return mainnet chain ID', async () => { + expect(walletAdapter.getChainId).toBeDefined(); + const chainId = await walletAdapter.getChainId?.(); + + expect(chainId).toBe(42161); + }); + + it('should return testnet chain ID when in testnet mode', async () => { + const testnetService = new HyperLiquidWalletService( + mockDeps, + mockMessenger, + { isTestnet: true }, + ); + const testnetAdapter = testnetService.createWalletAdapter(); + + expect(testnetAdapter.getChainId).toBeDefined(); + const chainId = await testnetAdapter.getChainId?.(); + + expect(chainId).toBe(421614); + }); + }); + + describe('signTypedData method', () => { + const mockTypedDataParams = { + domain: { + name: 'HyperLiquid', + version: '1', + chainId: 42161, + verifyingContract: + '0x0000000000000000000000000000000000000000' as `0x${string}`, + }, + types: { + Order: [ + { name: 'asset', type: 'uint32' }, + { name: 'isBuy', type: 'bool' }, + { name: 'limitPx', type: 'uint64' }, + { name: 'sz', type: 'uint64' }, + { name: 'reduceOnly', type: 'bool' }, + { name: 'timestamp', type: 'uint64' }, + ], + }, + primaryType: 'Order', + message: { + asset: 0, + isBuy: true, + limitPx: '30000', + sz: '1', + reduceOnly: false, + timestamp: Date.now(), + }, + }; + + it('should sign typed data successfully', async () => { + const result = await walletAdapter.signTypedData(mockTypedDataParams); + + expect(result).toBe('0xSignatureResult'); + expect(mockDeps.debugLogger.log).toHaveBeenCalledWith( + 'HyperLiquidWalletService: Signing typed data', + { + address: mockEvmAccount.address, + primaryType: 'Order', + domain: mockTypedDataParams.domain, + }, + ); + expect(mockMessenger.call).toHaveBeenCalledWith( + 'KeyringController:signTypedMessage', + { + from: mockEvmAccount.address, + data: { + domain: mockTypedDataParams.domain, + types: mockTypedDataParams.types, + primaryType: mockTypedDataParams.primaryType, + message: mockTypedDataParams.message, + }, + }, + 'V4', + ); + }); + + it('should throw error when no account selected', async () => { + // Mock accountTree to return empty array (no account selected) + (mockMessenger.call as jest.Mock).mockImplementation( + (action: string) => { + if ( + action === + 'AccountTreeController:getAccountsFromSelectedAccountGroup' + ) { + return []; + } + if (action === 'KeyringController:getState') { + return { isUnlocked: true }; + } + if (action === 'KeyringController:signTypedMessage') { + return Promise.resolve('0xSignatureResult'); + } + return undefined; + }, + ); + + // Creating wallet adapter should throw when no account + expect(() => service.createWalletAdapter()).toThrow( + 'NO_ACCOUNT_SELECTED', + ); + }); + + it('should handle keyring controller errors', async () => { + (mockMessenger.call as jest.Mock).mockImplementation( + (action: string) => { + if ( + action === + 'AccountTreeController:getAccountsFromSelectedAccountGroup' + ) { + return [mockEvmAccount]; + } + if (action === 'KeyringController:getState') { + return { isUnlocked: true }; + } + if (action === 'KeyringController:signTypedMessage') { + return Promise.reject(new Error('Signing failed')); + } + return undefined; + }, + ); + + // Need to recreate the adapter after changing the mock + const freshAdapter = service.createWalletAdapter(); + + await expect( + freshAdapter.signTypedData(mockTypedDataParams), + ).rejects.toThrow('Signing failed'); + }); + }); + }); + + describe('Account Management', () => { + it('should get current account ID for mainnet', async () => { + const accountId = await service.getCurrentAccountId(); + + expect(accountId).toBe(`eip155:42161:${mockEvmAccount.address}`); + }); + + it('should get current account ID for testnet', async () => { + service.setTestnetMode(true); + + const accountId = await service.getCurrentAccountId(); + + expect(accountId).toBe(`eip155:421614:${mockEvmAccount.address}`); + }); + + it('should throw error when getting account ID with no selected account', async () => { + (mockMessenger.call as jest.Mock).mockImplementation((action: string) => { + if ( + action === 'AccountTreeController:getAccountsFromSelectedAccountGroup' + ) { + return []; + } + return undefined; + }); + + await expect(service.getCurrentAccountId()).rejects.toThrow( + 'NO_ACCOUNT_SELECTED', + ); + }); + + it('should parse user address from account ID', () => { + const accountId = + 'eip155:42161:0x1234567890123456789012345678901234567890' as CaipAccountId; + + const address = service.getUserAddress(accountId); + + expect(address).toBe('0x1234567890123456789012345678901234567890'); + }); + + it('should throw error for invalid address format', () => { + const { isValidHexAddress } = jest.requireMock('@metamask/utils'); + isValidHexAddress.mockReturnValueOnce(false); + + const accountId = 'eip155:42161:invalid-address' as CaipAccountId; + + expect(() => service.getUserAddress(accountId)).toThrow( + 'INVALID_ADDRESS_FORMAT', + ); + }); + + it('should get user address with provided account ID', async () => { + const accountId = + 'eip155:42161:0x9999999999999999999999999999999999999999' as CaipAccountId; + + const address = await service.getUserAddressWithDefault(accountId); + + expect(address).toBe('0x9999999999999999999999999999999999999999'); + }); + + it('should get user address with default fallback', async () => { + const address = await service.getUserAddressWithDefault(); + + expect(address).toBe(mockEvmAccount.address); + }); + + it('returns false for software wallet', () => { + expect(service.isSelectedHardwareWallet()).toBe(false); + }); + + it.each([ + 'Ledger Hardware', + 'Trezor Hardware', + 'OneKey Hardware', + 'Lattice Hardware', + 'QR Hardware Wallet Device', + ])('returns true for %s wallet', (keyringType) => { + (mockMessenger.call as jest.Mock).mockImplementation((action: string) => { + if ( + action === 'AccountTreeController:getAccountsFromSelectedAccountGroup' + ) { + return [ + { + ...mockEvmAccount, + metadata: { + ...mockEvmAccount.metadata, + keyring: { type: keyringType }, + }, + }, + ]; + } + return undefined; + }); + + expect(service.isSelectedHardwareWallet()).toBe(true); + }); + }); + + describe('Network Management', () => { + it('should update testnet mode correctly', () => { + expect(service.isTestnetMode()).toBe(false); + + service.setTestnetMode(true); + expect(service.isTestnetMode()).toBe(true); + + service.setTestnetMode(false); + expect(service.isTestnetMode()).toBe(false); + }); + + it('should affect chain ID in account ID generation', async () => { + // Test mainnet + service.setTestnetMode(false); + const mainnetAccountId = await service.getCurrentAccountId(); + expect(mainnetAccountId).toContain('eip155:42161:'); + + // Test testnet + service.setTestnetMode(true); + const testnetAccountId = await service.getCurrentAccountId(); + expect(testnetAccountId).toContain('eip155:421614:'); + }); + }); + + describe('Error Handling', () => { + it('should handle accountTree errors gracefully', async () => { + (mockMessenger.call as jest.Mock).mockImplementation((action: string) => { + if ( + action === 'AccountTreeController:getAccountsFromSelectedAccountGroup' + ) { + throw new Error('Store error'); + } + return undefined; + }); + + await expect(service.getCurrentAccountId()).rejects.toThrow( + 'NO_ACCOUNT_SELECTED', + ); + }); + + it('should handle malformed CAIP account IDs', () => { + const { parseCaipAccountId } = jest.requireMock('@metamask/utils'); + parseCaipAccountId.mockImplementationOnce(() => { + throw new Error('Invalid CAIP account ID'); + }); + + const accountId = 'invalid-caip-id' as CaipAccountId; + + expect(() => service.getUserAddress(accountId)).toThrow( + 'Invalid CAIP account ID', + ); + }); + + it('should throw KEYRING_LOCKED when keyring is locked', async () => { + const walletAdapter = service.createWalletAdapter(); + (mockMessenger.call as jest.Mock).mockImplementation((action: string) => { + if ( + action === 'AccountTreeController:getAccountsFromSelectedAccountGroup' + ) { + return [mockEvmAccount]; + } + if (action === 'KeyringController:getState') { + return { isUnlocked: false }; + } + return undefined; + }); + + const mockTypedData = { + domain: { + name: 'Test', + version: '1', + chainId: 42161, + verifyingContract: + '0x0000000000000000000000000000000000000000' as `0x${string}`, + }, + types: { + Test: [{ name: 'value', type: 'string' }], + }, + primaryType: 'Test', + message: { value: 'test' }, + }; + + await expect(walletAdapter.signTypedData(mockTypedData)).rejects.toThrow( + 'KEYRING_LOCKED', + ); + expect(mockMessenger.call).not.toHaveBeenCalledWith( + 'KeyringController:signTypedMessage', + expect.anything(), + expect.anything(), + ); + }); + + it('should return keyring unlocked status via isKeyringUnlocked()', () => { + expect(service.isKeyringUnlocked()).toBe(true); + + (mockMessenger.call as jest.Mock).mockImplementation((action: string) => { + if (action === 'KeyringController:getState') { + return { isUnlocked: false }; + } + return undefined; + }); + + expect(service.isKeyringUnlocked()).toBe(false); + }); + + it('should handle keyring controller initialization errors', async () => { + const walletAdapter = service.createWalletAdapter(); + (mockMessenger.call as jest.Mock).mockImplementation((action: string) => { + if ( + action === 'AccountTreeController:getAccountsFromSelectedAccountGroup' + ) { + return [mockEvmAccount]; + } + if (action === 'KeyringController:getState') { + return { isUnlocked: true }; + } + if (action === 'KeyringController:signTypedMessage') { + return Promise.reject(new Error('Keyring not initialized')); + } + return undefined; + }); + + const mockTypedData = { + domain: { + name: 'Test', + version: '1', + chainId: 42161, + verifyingContract: + '0x0000000000000000000000000000000000000000' as `0x${string}`, + }, + types: { + Test: [{ name: 'value', type: 'string' }], + }, + primaryType: 'Test', + message: { value: 'test' }, + }; + + await expect(walletAdapter.signTypedData(mockTypedData)).rejects.toThrow( + 'Keyring not initialized', + ); + }); + }); + + describe('Integration Scenarios', () => { + it('should handle full wallet adapter workflow', async () => { + const walletAdapter = service.createWalletAdapter(); + + // Get chain ID + expect(walletAdapter.getChainId).toBeDefined(); + const chainId = await ( + walletAdapter.getChainId as () => Promise + )(); + expect(chainId).toBe(42161); + + // Sign typed data + const mockTypedData = { + domain: { + name: 'Test', + version: '1', + chainId, + verifyingContract: + '0x0000000000000000000000000000000000000000' as `0x${string}`, + }, + types: { + Test: [{ name: 'value', type: 'string' }], + }, + primaryType: 'Test', + message: { value: 'test' }, + }; + + const signature = await walletAdapter.signTypedData(mockTypedData); + expect(signature).toBe('0xSignatureResult'); + }); + + it('should maintain consistency between wallet adapter and service methods', async () => { + const walletAdapter = service.createWalletAdapter(); + + // Get chain ID through wallet adapter + expect(walletAdapter.getChainId).toBeDefined(); + const chainId = await walletAdapter.getChainId?.(); + + // Get account through service method + const accountId = await service.getCurrentAccountId(); + const serviceAddress = service.getUserAddress(accountId); + + // Chain ID should match + expect(accountId).toContain(`eip155:${chainId}:`); + expect(accountId).toContain(serviceAddress); + }); + }); +}); diff --git a/packages/perps-controller/tests/src/services/MYXClientService.test.ts b/packages/perps-controller/tests/src/services/MYXClientService.test.ts new file mode 100644 index 00000000000..d8b5807fab8 --- /dev/null +++ b/packages/perps-controller/tests/src/services/MYXClientService.test.ts @@ -0,0 +1,1079 @@ +/* eslint-disable */ +import type { PerpsPlatformDependencies } from '@metamask/perps-controller'; + +import { MYX_PRICE_POLLING_INTERVAL_MS } from '../../../src/constants/myxConfig.js'; +import { MYXClientService } from '../../../src/services/MYXClientService.js'; +import type { MYXPoolSymbol, MYXTicker } from '../../../src/types/myx-types.js'; +import { createMockInfrastructure } from '../../helpers/serviceMocks.js'; + +// ============================================================================ +// Mock @myx-trade/sdk +// Uses the same pattern as HyperLiquidClientService.test.ts: +// 'mock'-prefixed variables at module level are hoisted by Jest's babel plugin +// and can be referenced inside jest.mock() factories. +// ============================================================================ + +const mockGetPoolSymbolAll = jest.fn().mockResolvedValue([]); +const mockGetTickerList = jest.fn().mockResolvedValue([]); +const mockWsConnect = jest.fn(); +const mockWsDisconnect = jest.fn(); +const mockListPositions = jest.fn(); +const mockGetOrders = jest.fn(); +const mockGetOrderHistory = jest.fn(); +const mockGetPositionHistory = jest.fn(); +const mockGetAccountInfo = jest.fn(); +const mockGetWalletQuoteTokenBalance = jest.fn(); +const mockGetTradeFlow = jest.fn(); +const mockGetKlineList = jest.fn(); +const mockGetMarketDetail = jest.fn(); +const mockSubscribeKline = jest.fn(); +const mockUnsubscribeKline = jest.fn(); +const mockAuth = jest.fn(); + +jest.mock('@myx-trade/sdk', () => ({ + MyxClient: jest.fn(() => ({ + markets: { + getPoolSymbolAll: mockGetPoolSymbolAll, + getTickerList: mockGetTickerList, + getKlineList: mockGetKlineList, + getMarketDetail: mockGetMarketDetail, + }, + subscription: { + connect: mockWsConnect, + disconnect: mockWsDisconnect, + subscribeKline: mockSubscribeKline, + unsubscribeKline: mockUnsubscribeKline, + }, + position: { + listPositions: mockListPositions, + getPositionHistory: mockGetPositionHistory, + }, + order: { + getOrders: mockGetOrders, + getOrderHistory: mockGetOrderHistory, + }, + account: { + getAccountInfo: mockGetAccountInfo, + getWalletQuoteTokenBalance: mockGetWalletQuoteTokenBalance, + getTradeFlow: mockGetTradeFlow, + }, + auth: mockAuth, + })), +})); + +// ============================================================================ +// Test Fixtures +// ============================================================================ + +function makePool(overrides: Partial = {}): MYXPoolSymbol { + return { + chainId: 59141, + marketId: 'market-1', + poolId: '0xpool1', + baseSymbol: 'RHEA', + quoteSymbol: 'USDT', + baseTokenIcon: '', + baseToken: '0xbase', + quoteToken: '0xquote', + ...overrides, + }; +} + +function makeTicker(overrides: Partial = {}): MYXTicker { + return { + chainId: 59141, + poolId: '0xpool1', + oracleId: 1, + price: '1500.00', + change: '2.5', + high: '0', + low: '0', + volume: '1000000', + turnover: '0', + ...overrides, + }; +} + +// ============================================================================ +// Tests +// ============================================================================ + +describe('MYXClientService', () => { + let service: MYXClientService; + let mockDeps: jest.Mocked; + + beforeEach(() => { + jest.useFakeTimers(); + // clearAllMocks resets call counts/results but preserves implementations. + // Do NOT use resetAllMocks — it strips mockImplementation from MyxClient, + // causing all subsequent `new MyxClient()` calls to return empty objects. + jest.clearAllMocks(); + const { MyxClient } = jest.requireMock('@myx-trade/sdk'); + MyxClient.mockImplementation(() => ({ + markets: { + getPoolSymbolAll: mockGetPoolSymbolAll, + getTickerList: mockGetTickerList, + getKlineList: mockGetKlineList, + getMarketDetail: mockGetMarketDetail, + }, + subscription: { + connect: mockWsConnect, + disconnect: mockWsDisconnect, + subscribeKline: mockSubscribeKline, + unsubscribeKline: mockUnsubscribeKline, + }, + position: { + listPositions: mockListPositions, + getPositionHistory: mockGetPositionHistory, + }, + order: { + getOrders: mockGetOrders, + getOrderHistory: mockGetOrderHistory, + }, + account: { + getAccountInfo: mockGetAccountInfo, + getWalletQuoteTokenBalance: mockGetWalletQuoteTokenBalance, + getTradeFlow: mockGetTradeFlow, + }, + auth: mockAuth, + })); + + mockDeps = createMockInfrastructure(); + service = new MYXClientService(mockDeps, { isTestnet: true }); + }); + + afterEach(() => { + service.disconnect(); + jest.useRealTimers(); + }); + + // ========================================================================== + // Constructor + // ========================================================================== + + describe('constructor', () => { + it('initializes with testnet configuration', () => { + const isTestnet = service.getIsTestnet(); + + expect(isTestnet).toBe(true); + }); + + it('initializes with mainnet configuration', () => { + const mainnetService = new MYXClientService(mockDeps, { + isTestnet: false, + }); + + expect(mainnetService.getIsTestnet()).toBe(false); + mainnetService.disconnect(); + }); + + it('logs initialization details', () => { + expect(mockDeps.debugLogger.log).toHaveBeenCalledWith( + '[MYXClientService] Initialized with SDK', + expect.objectContaining({ + isTestnet: true, + chainId: 59141, + }), + ); + }); + }); + + // ========================================================================== + // getMarkets + // ========================================================================== + + describe('getMarkets', () => { + it('fetches markets from SDK and caches them', async () => { + const pools = [ + makePool(), + makePool({ poolId: '0xpool2', baseSymbol: 'PARTI' }), + ]; + mockGetPoolSymbolAll.mockResolvedValueOnce(pools); + + const result = await service.getMarkets(); + + expect(result).toEqual(pools); + expect(mockGetPoolSymbolAll).toHaveBeenCalledTimes(1); + }); + + it('returns cached markets within TTL', async () => { + const pools = [makePool()]; + mockGetPoolSymbolAll.mockResolvedValueOnce(pools); + + await service.getMarkets(); + const cachedResult = await service.getMarkets(); + + expect(cachedResult).toEqual(pools); + expect(mockGetPoolSymbolAll).toHaveBeenCalledTimes(1); + }); + + it('refetches markets after cache TTL expires', async () => { + const pools = [makePool()]; + const updatedPools = [makePool(), makePool({ poolId: '0xpool2' })]; + mockGetPoolSymbolAll.mockResolvedValueOnce(pools); + mockGetPoolSymbolAll.mockResolvedValueOnce(updatedPools); + + await service.getMarkets(); + + // Advance past cache TTL (5 minutes) + jest.advanceTimersByTime(5 * 60 * 1000 + 1); + + const result = await service.getMarkets(); + + expect(result).toEqual(updatedPools); + expect(mockGetPoolSymbolAll).toHaveBeenCalledTimes(2); + }); + + it('returns stale cache on error when cache exists', async () => { + const pools = [makePool()]; + mockGetPoolSymbolAll.mockResolvedValueOnce(pools); + + await service.getMarkets(); + + // Expire cache + jest.advanceTimersByTime(5 * 60 * 1000 + 1); + + mockGetPoolSymbolAll.mockRejectedValueOnce(new Error('API down')); + + const result = await service.getMarkets(); + + expect(result).toEqual(pools); + expect(mockDeps.logger.error).toHaveBeenCalled(); + }); + + it('throws error when fetch fails with no cache', async () => { + mockGetPoolSymbolAll.mockRejectedValueOnce(new Error('Network error')); + + await expect(service.getMarkets()).rejects.toThrow('Network error'); + expect(mockDeps.logger.error).toHaveBeenCalled(); + }); + + it('handles null response from SDK', async () => { + mockGetPoolSymbolAll.mockResolvedValueOnce(null); + + const result = await service.getMarkets(); + + expect(result).toEqual([]); + }); + + it('logs fetching and results', async () => { + const pools = [makePool()]; + mockGetPoolSymbolAll.mockResolvedValueOnce(pools); + + await service.getMarkets(); + + expect(mockDeps.debugLogger.log).toHaveBeenCalledWith( + '[MYXClientService] Fetching markets via SDK', + ); + expect(mockDeps.debugLogger.log).toHaveBeenCalledWith( + '[MYXClientService] Markets fetched', + { count: 1 }, + ); + }); + }); + + // ========================================================================== + // getTickers + // ========================================================================== + + describe('getTickers', () => { + it('returns empty array for empty poolIds', async () => { + const result = await service.getTickers([]); + + expect(result).toEqual([]); + expect(mockGetTickerList).not.toHaveBeenCalled(); + }); + + it('fetches tickers for given pool IDs', async () => { + const tickers = [makeTicker()]; + mockGetTickerList.mockResolvedValueOnce(tickers); + + const result = await service.getTickers(['0xpool1']); + + expect(result).toEqual(tickers); + expect(mockGetTickerList).toHaveBeenCalledWith({ + chainId: 59141, + poolIds: ['0xpool1'], + }); + }); + + it('handles null response from SDK', async () => { + mockGetTickerList.mockResolvedValueOnce(null); + + const result = await service.getTickers(['0xpool1']); + + expect(result).toEqual([]); + }); + + it('throws error on SDK failure', async () => { + mockGetTickerList.mockRejectedValueOnce(new Error('Ticker fetch failed')); + + await expect(service.getTickers(['0xpool1'])).rejects.toThrow( + 'Ticker fetch failed', + ); + expect(mockDeps.logger.error).toHaveBeenCalled(); + }); + }); + + // ========================================================================== + // getAllTickers + // ========================================================================== + + describe('getAllTickers', () => { + it('fetches markets then tickers for all pool IDs', async () => { + const pools = [ + makePool({ poolId: '0xpool1' }), + makePool({ poolId: '0xpool2', baseSymbol: 'PARTI' }), + ]; + const tickers = [ + makeTicker({ poolId: '0xpool1' }), + makeTicker({ poolId: '0xpool2' }), + ]; + mockGetPoolSymbolAll.mockResolvedValueOnce(pools); + mockGetTickerList.mockResolvedValueOnce(tickers); + + const result = await service.getAllTickers(); + + expect(result).toEqual(tickers); + expect(mockGetTickerList).toHaveBeenCalledWith({ + chainId: 59141, + poolIds: ['0xpool1', '0xpool2'], + }); + }); + + it('returns empty array when no markets exist', async () => { + mockGetPoolSymbolAll.mockResolvedValueOnce([]); + + const result = await service.getAllTickers(); + + expect(result).toEqual([]); + expect(mockGetTickerList).not.toHaveBeenCalled(); + }); + + it('throws error on failure', async () => { + mockGetPoolSymbolAll.mockRejectedValueOnce(new Error('Markets failed')); + + await expect(service.getAllTickers()).rejects.toThrow('Markets failed'); + expect(mockDeps.logger.error).toHaveBeenCalled(); + }); + }); + + // ========================================================================== + // Price Polling + // ========================================================================== + + describe('startPricePolling', () => { + it('fetches tickers immediately and invokes callback', async () => { + const tickers = [makeTicker()]; + mockGetTickerList.mockResolvedValueOnce(tickers); + const callback = jest.fn(); + + service.startPricePolling(['0xpool1'], callback); + + // Allow the immediate poll to complete + await jest.advanceTimersByTimeAsync(0); + + expect(callback).toHaveBeenCalledWith(tickers); + }); + + it('schedules subsequent poll after interval', async () => { + const tickers1 = [makeTicker({ price: '1000' })]; + const tickers2 = [makeTicker({ price: '2000' })]; + mockGetTickerList.mockResolvedValueOnce(tickers1); + mockGetTickerList.mockResolvedValueOnce(tickers2); + const callback = jest.fn(); + + service.startPricePolling(['0xpool1'], callback); + + // Complete first poll + await jest.advanceTimersByTimeAsync(0); + expect(callback).toHaveBeenCalledTimes(1); + + // Advance to next polling interval + await jest.advanceTimersByTimeAsync(MYX_PRICE_POLLING_INTERVAL_MS); + + expect(callback).toHaveBeenCalledTimes(2); + expect(callback).toHaveBeenLastCalledWith(tickers2); + }); + + it('stops previous polling when starting new one', async () => { + const callback1 = jest.fn(); + const callback2 = jest.fn(); + mockGetTickerList.mockResolvedValue([makeTicker()]); + + service.startPricePolling(['0xpool1'], callback1); + service.startPricePolling(['0xpool2'], callback2); + + await jest.advanceTimersByTimeAsync(0); + + // Only the second callback receives updates after re-start + expect(callback2).toHaveBeenCalled(); + }); + + it('continues polling even when a poll fails', async () => { + const tickers = [makeTicker()]; + mockGetTickerList.mockRejectedValueOnce(new Error('Temporary failure')); + mockGetTickerList.mockResolvedValueOnce(tickers); + const callback = jest.fn(); + + service.startPricePolling(['0xpool1'], callback); + + // First poll fails + await jest.advanceTimersByTimeAsync(0); + expect(callback).not.toHaveBeenCalled(); + + // Next poll succeeds + await jest.advanceTimersByTimeAsync(MYX_PRICE_POLLING_INTERVAL_MS); + expect(callback).toHaveBeenCalledWith(tickers); + }); + + it('does not invoke callback if polling stopped during fetch', async () => { + const tickers = [makeTicker()]; + mockGetTickerList.mockImplementation(async () => { + // Simulate stopping polling during the async fetch + service.stopPricePolling(); + return tickers; + }); + const callback = jest.fn(); + + service.startPricePolling(['0xpool1'], callback); + await jest.advanceTimersByTimeAsync(0); + + expect(callback).not.toHaveBeenCalled(); + }); + }); + + describe('stopPricePolling', () => { + it('clears active polling', async () => { + mockGetTickerList.mockResolvedValue([makeTicker()]); + const callback = jest.fn(); + + service.startPricePolling(['0xpool1'], callback); + await jest.advanceTimersByTimeAsync(0); + + callback.mockClear(); + service.stopPricePolling(); + + // Advance past multiple polling intervals + await jest.advanceTimersByTimeAsync(MYX_PRICE_POLLING_INTERVAL_MS * 3); + + expect(callback).not.toHaveBeenCalled(); + }); + + it('is safe to call when no polling is active', () => { + expect(() => service.stopPricePolling()).not.toThrow(); + }); + + it('logs when stopping', () => { + service.stopPricePolling(); + + expect(mockDeps.debugLogger.log).toHaveBeenCalledWith( + '[MYXClientService] Stopped price polling', + ); + }); + }); + + // ========================================================================== + // ping + // ========================================================================== + + describe('ping', () => { + it('resolves when SDK call succeeds', async () => { + mockGetTickerList.mockResolvedValueOnce([]); + + await expect(service.ping()).resolves.toBeUndefined(); + }); + + it('throws on SDK failure', async () => { + mockGetTickerList.mockRejectedValueOnce(new Error('Connection refused')); + + await expect(service.ping()).rejects.toThrow('Connection refused'); + }); + + it('throws on timeout', async () => { + mockGetTickerList.mockImplementation( + () => + new Promise(() => { + // Never resolves + }), + ); + + const pingPromise = service.ping(100); + + // Advance timer past the timeout + jest.advanceTimersByTime(150); + + await expect(pingPromise).rejects.toThrow('MYX ping timeout'); + }); + + it('uses default 5000ms timeout', async () => { + mockGetTickerList.mockImplementation( + () => + new Promise(() => { + // Never resolves + }), + ); + + const pingPromise = service.ping(); + jest.advanceTimersByTime(5001); + + await expect(pingPromise).rejects.toThrow('MYX ping timeout'); + }); + + it('clears timeout on successful ping', async () => { + mockGetTickerList.mockResolvedValueOnce([]); + const clearTimeoutSpy = jest.spyOn(global, 'clearTimeout'); + + await service.ping(); + + expect(clearTimeoutSpy).toHaveBeenCalled(); + clearTimeoutSpy.mockRestore(); + }); + }); + + // ========================================================================== + // disconnect + // ========================================================================== + + describe('disconnect', () => { + it('stops polling and clears cache', async () => { + const pools = [makePool()]; + mockGetPoolSymbolAll.mockResolvedValueOnce(pools); + await service.getMarkets(); + + service.disconnect(); + + // After disconnect, next getMarkets call requires a new fetch + mockGetPoolSymbolAll.mockResolvedValueOnce([ + makePool({ poolId: '0xnew' }), + ]); + const result = await service.getMarkets(); + + expect(result).toEqual([makePool({ poolId: '0xnew' })]); + }); + + it('logs disconnection', () => { + service.disconnect(); + + expect(mockDeps.debugLogger.log).toHaveBeenCalledWith( + '[MYXClientService] Disconnected', + ); + }); + }); + + // ========================================================================== + // getIsTestnet + // ========================================================================== + + describe('getIsTestnet', () => { + it('returns true for testnet configuration', () => { + expect(service.getIsTestnet()).toBe(true); + }); + + it('returns false for mainnet configuration', () => { + const mainnetService = new MYXClientService(mockDeps, { + isTestnet: false, + }); + + expect(mainnetService.getIsTestnet()).toBe(false); + mainnetService.disconnect(); + }); + }); + + // ========================================================================== + // Error Context + // ========================================================================== + + describe('error context', () => { + it('includes testnet tag in error context for testnet service', async () => { + mockGetPoolSymbolAll.mockRejectedValueOnce(new Error('fail')); + + try { + await service.getMarkets(); + } catch { + // expected + } + + expect(mockDeps.logger.error).toHaveBeenCalledWith( + expect.any(Error), + expect.objectContaining({ + tags: expect.objectContaining({ + network: 'testnet', + service: 'MYXClientService', + }), + }), + ); + }); + + it('includes mainnet tag in error context for mainnet service', async () => { + const mainnetService = new MYXClientService(mockDeps, { + isTestnet: false, + }); + mockGetPoolSymbolAll.mockRejectedValueOnce(new Error('fail')); + + try { + await mainnetService.getMarkets(); + } catch { + // expected + } + + expect(mockDeps.logger.error).toHaveBeenCalledWith( + expect.any(Error), + expect.objectContaining({ + tags: expect.objectContaining({ + network: 'mainnet', + }), + }), + ); + + mainnetService.disconnect(); + }); + }); + + // ========================================================================== + // Authenticated Read Operations + // ========================================================================== + + describe('listPositions', () => { + it('delegates to SDK and returns result', async () => { + const mockResult = { code: 9200, data: [{ poolId: '0x1', size: '100' }] }; + mockListPositions.mockResolvedValueOnce(mockResult); + + const result = await service.listPositions('0xuser'); + + expect(result).toEqual(mockResult); + expect(mockListPositions).toHaveBeenCalledWith('0xuser'); + }); + + it('wraps and rethrows errors', async () => { + mockListPositions.mockRejectedValueOnce(new Error('API error')); + + await expect(service.listPositions('0xuser')).rejects.toThrow( + 'API error', + ); + expect(mockDeps.logger.error).toHaveBeenCalled(); + }); + }); + + describe('getOrders', () => { + it('delegates to SDK and returns result', async () => { + const mockResult = { code: 9200, data: [] }; + mockGetOrders.mockResolvedValueOnce(mockResult); + + const result = await service.getOrders('0xuser'); + + expect(result).toEqual(mockResult); + expect(mockGetOrders).toHaveBeenCalledWith('0xuser'); + }); + + it('wraps and rethrows errors', async () => { + mockGetOrders.mockRejectedValueOnce(new Error('Order error')); + + await expect(service.getOrders('0xuser')).rejects.toThrow('Order error'); + expect(mockDeps.logger.error).toHaveBeenCalled(); + }); + }); + + describe('getOrderHistory', () => { + it('delegates to SDK with params and address', async () => { + const params = { limit: 50, chainId: 59141 }; + const mockResult = { code: 9200, data: [] }; + mockGetOrderHistory.mockResolvedValueOnce(mockResult); + + const result = await service.getOrderHistory( + params as Parameters[0], + '0xuser', + ); + + expect(result).toEqual(mockResult); + expect(mockGetOrderHistory).toHaveBeenCalledWith(params, '0xuser'); + }); + + it('wraps and rethrows errors', async () => { + mockGetOrderHistory.mockRejectedValueOnce(new Error('History error')); + + await expect( + service.getOrderHistory( + { limit: 50 } as Parameters[0], + '0xuser', + ), + ).rejects.toThrow('History error'); + }); + }); + + describe('getPositionHistory', () => { + it('delegates to SDK with params and address', async () => { + const params = { limit: 50 }; + const mockResult = { code: 9200, data: [] }; + mockGetPositionHistory.mockResolvedValueOnce(mockResult); + + const result = await service.getPositionHistory( + params as Parameters[0], + '0xuser', + ); + + expect(result).toEqual(mockResult); + expect(mockGetPositionHistory).toHaveBeenCalledWith(params, '0xuser'); + }); + + it('wraps and rethrows errors', async () => { + mockGetPositionHistory.mockRejectedValueOnce(new Error('Pos history')); + + await expect( + service.getPositionHistory( + { limit: 50 } as Parameters[0], + '0xuser', + ), + ).rejects.toThrow('Pos history'); + }); + }); + + describe('getAccountInfo', () => { + it('delegates to SDK with chainId, address, poolId', async () => { + const mockResult = { code: 9200, data: { totalCollateral: '1000' } }; + mockGetAccountInfo.mockResolvedValueOnce(mockResult); + + const result = await service.getAccountInfo(59141, '0xuser', '0xpool1'); + + expect(result).toEqual(mockResult); + expect(mockGetAccountInfo).toHaveBeenCalledWith( + 59141, + '0xuser', + '0xpool1', + ); + }); + + it('wraps and rethrows errors', async () => { + mockGetAccountInfo.mockRejectedValueOnce(new Error('Account error')); + + await expect( + service.getAccountInfo(59141, '0xuser', '0xpool1'), + ).rejects.toThrow('Account error'); + }); + }); + + describe('getWalletQuoteTokenBalance', () => { + it('delegates to SDK', async () => { + const mockResult = { code: 9200, data: '500000000' }; + mockGetWalletQuoteTokenBalance.mockResolvedValueOnce(mockResult); + + const result = await service.getWalletQuoteTokenBalance(59141, '0xuser'); + + expect(result).toEqual(mockResult); + expect(mockGetWalletQuoteTokenBalance).toHaveBeenCalledWith( + 59141, + '0xuser', + ); + }); + + it('wraps and rethrows errors', async () => { + mockGetWalletQuoteTokenBalance.mockRejectedValueOnce( + new Error('Balance error'), + ); + + await expect( + service.getWalletQuoteTokenBalance(59141, '0xuser'), + ).rejects.toThrow('Balance error'); + }); + }); + + describe('getTradeFlow', () => { + it('delegates to SDK with params and address', async () => { + const params = { limit: 50 }; + const mockResult = { code: 9200, data: [] }; + mockGetTradeFlow.mockResolvedValueOnce(mockResult); + + const result = await service.getTradeFlow( + params as Parameters[0], + '0xuser', + ); + + expect(result).toEqual(mockResult); + expect(mockGetTradeFlow).toHaveBeenCalledWith(params, '0xuser'); + }); + + it('wraps and rethrows errors', async () => { + mockGetTradeFlow.mockRejectedValueOnce(new Error('Flow error')); + + await expect( + service.getTradeFlow( + { limit: 50 } as Parameters[0], + '0xuser', + ), + ).rejects.toThrow('Flow error'); + }); + }); + + // ========================================================================== + // Kline (Candle) Data + // ========================================================================== + + describe('getKlineData', () => { + it('fetches kline data from SDK', async () => { + const klineData = [ + { + time: 1700000000, + open: '50000', + close: '51000', + high: '52000', + low: '49000', + }, + ]; + mockGetKlineList.mockResolvedValueOnce(klineData); + + const result = await service.getKlineData({ + poolId: '0xpool1', + interval: '1h' as Parameters< + typeof service.getKlineData + >[0]['interval'], + limit: 100, + }); + + expect(result).toEqual(klineData); + expect(mockGetKlineList).toHaveBeenCalledWith( + expect.objectContaining({ + poolId: '0xpool1', + chainId: 59141, + interval: '1h', + limit: 100, + }), + ); + }); + + it('returns empty array when SDK returns null', async () => { + mockGetKlineList.mockResolvedValueOnce(null); + + const result = await service.getKlineData({ + poolId: '0xpool1', + interval: '1h' as Parameters< + typeof service.getKlineData + >[0]['interval'], + limit: 100, + }); + + expect(result).toEqual([]); + }); + + it('wraps and rethrows errors', async () => { + mockGetKlineList.mockRejectedValueOnce(new Error('Kline error')); + + await expect( + service.getKlineData({ + poolId: '0xpool1', + interval: '1h' as Parameters< + typeof service.getKlineData + >[0]['interval'], + limit: 100, + }), + ).rejects.toThrow('Kline error'); + }); + }); + + // ========================================================================== + // Global ID + // ========================================================================== + + describe('getGlobalId', () => { + it('fetches globalId from market detail and caches it', async () => { + mockGetMarketDetail.mockResolvedValueOnce({ globalId: 42 }); + + const result = await service.getGlobalId('0xpool1'); + + expect(result).toBe(42); + expect(mockGetMarketDetail).toHaveBeenCalledWith({ + chainId: 59141, + poolId: '0xpool1', + }); + }); + + it('returns cached globalId on subsequent calls', async () => { + mockGetMarketDetail.mockResolvedValueOnce({ globalId: 42 }); + + await service.getGlobalId('0xpool1'); + const result = await service.getGlobalId('0xpool1'); + + expect(result).toBe(42); + expect(mockGetMarketDetail).toHaveBeenCalledTimes(1); + }); + + it('wraps and rethrows errors', async () => { + mockGetMarketDetail.mockRejectedValueOnce(new Error('Detail error')); + + await expect(service.getGlobalId('0xpool1')).rejects.toThrow( + 'Detail error', + ); + }); + }); + + // ========================================================================== + // Kline WebSocket Subscriptions + // ========================================================================== + + describe('subscribeToKline', () => { + it('delegates to SDK subscription', () => { + const callback = jest.fn(); + + service.subscribeToKline( + 42, + '1h' as Parameters[1], + callback, + ); + + expect(mockSubscribeKline).toHaveBeenCalledWith(42, '1h', callback); + }); + }); + + describe('unsubscribeFromKline', () => { + it('delegates to SDK unsubscription', () => { + const callback = jest.fn(); + + service.unsubscribeFromKline( + 42, + '1h' as Parameters[1], + callback, + ); + + expect(mockUnsubscribeKline).toHaveBeenCalledWith(42, '1h', callback); + }); + }); + + // ========================================================================== + // Simple Getters + // ========================================================================== + + describe('getChainId', () => { + it('returns testnet chain ID', () => { + expect(service.getChainId()).toBe(59141); + }); + + it('returns mainnet chain ID', () => { + const mainnetService = new MYXClientService(mockDeps, { + isTestnet: false, + }); + + expect(mainnetService.getChainId()).toBe(56); + mainnetService.disconnect(); + }); + }); + + describe('getNetwork', () => { + it('returns testnet for testnet service', () => { + expect(service.getNetwork()).toBe('testnet'); + }); + + it('returns mainnet for mainnet service', () => { + const mainnetService = new MYXClientService(mockDeps, { + isTestnet: false, + }); + + expect(mainnetService.getNetwork()).toBe('mainnet'); + mainnetService.disconnect(); + }); + }); + + describe('isAuthenticated', () => { + it('returns false before authentication', () => { + expect(service.isAuthenticated()).toBe(false); + }); + + it('returns true after successful authentication', async () => { + // authenticate() calls myxClient.auth() synchronously, then sets #authenticated + await service.authenticate({}, {}, '0xuser'); + + expect(service.isAuthenticated()).toBe(true); + }); + }); + + describe('isAuthenticatedForAddress', () => { + it('returns false before authentication', () => { + expect(service.isAuthenticatedForAddress('0xuser')).toBe(false); + }); + + it('returns true for the authenticated address', async () => { + await service.authenticate({}, {}, '0xuser'); + + expect(service.isAuthenticatedForAddress('0xuser')).toBe(true); + }); + + it('returns true regardless of address casing', async () => { + await service.authenticate({}, {}, '0xUser'); + + expect(service.isAuthenticatedForAddress('0xuser')).toBe(true); + expect(service.isAuthenticatedForAddress('0xUSER')).toBe(true); + }); + + it('returns false for a different address', async () => { + await service.authenticate({}, {}, '0xuser'); + + expect(service.isAuthenticatedForAddress('0xother')).toBe(false); + }); + + it('returns false after disconnect', async () => { + await service.authenticate({}, {}, '0xuser'); + service.disconnect(); + + expect(service.isAuthenticatedForAddress('0xuser')).toBe(false); + }); + }); + + // ========================================================================== + // authenticate + // ========================================================================== + + describe('authenticate', () => { + it('calls SDK auth with signer, getAccessToken, and walletClient', async () => { + const signer = { signMessage: jest.fn() }; + const walletClient = {}; + + await service.authenticate(signer, walletClient, '0xuser'); + + expect(mockAuth).toHaveBeenCalledWith( + expect.objectContaining({ + signer, + walletClient, + getAccessToken: expect.any(Function), + }), + ); + }); + + it('skips if already authenticated', async () => { + await service.authenticate({}, {}, '0xuser'); + mockAuth.mockClear(); + + await service.authenticate({}, {}, '0xuser'); + + expect(mockAuth).not.toHaveBeenCalled(); + }); + + it('deduplicates concurrent auth calls', async () => { + // Slow auth: resolve after a tick + let resolveAuth: () => void = () => undefined; + mockAuth.mockImplementationOnce( + () => + new Promise((resolve) => { + resolveAuth = resolve; + }), + ); + + const p1 = service.authenticate({}, {}, '0xuser'); + const p2 = service.authenticate({}, {}, '0xuser'); + + resolveAuth(); + await Promise.all([p1, p2]); + + // Only one SDK auth call despite two authenticate() calls + expect(mockAuth).toHaveBeenCalledTimes(1); + }); + + it('wraps and rethrows SDK auth errors', async () => { + mockAuth.mockImplementationOnce(() => { + throw new Error('Auth failed'); + }); + + await expect(service.authenticate({}, {}, '0xuser')).rejects.toThrow( + 'Auth failed', + ); + expect(mockDeps.logger.error).toHaveBeenCalled(); + }); + }); +}); diff --git a/packages/perps-controller/tests/src/services/MYXWalletService.test.ts b/packages/perps-controller/tests/src/services/MYXWalletService.test.ts new file mode 100644 index 00000000000..2ebadbb6bf0 --- /dev/null +++ b/packages/perps-controller/tests/src/services/MYXWalletService.test.ts @@ -0,0 +1,484 @@ +/* eslint-disable */ +/** + * Unit tests for MYXWalletService + */ + +// Mock keyring-api to avoid import issues with definePattern +jest.mock('@metamask/keyring-api', () => ({ + isEvmAccountType: jest.fn((accountType: string) => + accountType?.startsWith('eip155:'), + ), +})); + +// Mock keyring-controller to avoid superstruct/abi-utils chain errors +jest.mock('@metamask/keyring-controller', () => ({ + SignTypedDataVersion: { V4: 'V4' }, +})); + +// Mock MetaMask utils +jest.mock('@metamask/utils', () => ({ + parseCaipAccountId: jest.fn((accountId: string) => { + const parts = accountId.split(':'); + return { + chainNamespace: parts[0], + chainReference: parts[1], + address: parts[2], + }; + }), + isValidHexAddress: jest.fn((address: string) => + /^0x[0-9a-fA-F]{40}$/.test(address), + ), +})); + +// Mock MYX config +jest.mock('../../../src/constants/myxConfig', () => ({ + getMYXChainId: jest.fn((network: string) => + network === 'testnet' ? 421614 : 56, + ), + MYX_TESTNET_CHAIN_ID: '421614', + MYX_MAINNET_CHAIN_ID: '56', +})); + +// Mock DevLogger +jest.mock( + '../../../../core/SDKConnect/utils/DevLogger', + () => ({ + DevLogger: { + log: jest.fn(), + }, + }), + { virtual: true }, +); + +import type { CaipAccountId } from '@metamask/utils'; + +import { MYXWalletService } from '../../../src/services/MYXWalletService.js'; +import { + createMockInfrastructure, + createMockEvmAccount, + createMockMessenger, +} from '../../helpers/serviceMocks.js'; + +describe('MYXWalletService', () => { + let service: MYXWalletService; + let mockDeps: ReturnType; + let mockMessenger: ReturnType; + const mockEvmAccount = createMockEvmAccount(); + + beforeEach(() => { + jest.clearAllMocks(); + const keyringApi = jest.requireMock('@metamask/keyring-api'); + keyringApi.isEvmAccountType.mockImplementation((accountType: string) => + accountType?.startsWith('eip155:'), + ); + const utils = jest.requireMock('@metamask/utils'); + utils.parseCaipAccountId.mockImplementation((accountId: string) => { + const parts = accountId.split(':'); + return { + chainNamespace: parts[0], + chainReference: parts[1], + address: parts[2], + }; + }); + utils.isValidHexAddress.mockImplementation((address: string) => + /^0x[0-9a-fA-F]{40}$/.test(address), + ); + const myxConfig = jest.requireMock('../../../src/constants/myxConfig'); + myxConfig.getMYXChainId.mockImplementation((network: string) => + network === 'testnet' ? 421614 : 56, + ); + mockDeps = createMockInfrastructure(); + mockMessenger = createMockMessenger(); + service = new MYXWalletService(mockDeps, mockMessenger); + }); + + describe('Constructor and Configuration', () => { + it('initializes with mainnet by default', () => { + expect(service.isTestnetMode()).toBe(false); + }); + + it('initializes with testnet when specified', () => { + const testnetService = new MYXWalletService(mockDeps, mockMessenger, { + isTestnet: true, + }); + + expect(testnetService.isTestnetMode()).toBe(true); + }); + + it('setTestnetMode / isTestnetMode toggles correctly', () => { + service.setTestnetMode(true); + expect(service.isTestnetMode()).toBe(true); + + service.setTestnetMode(false); + expect(service.isTestnetMode()).toBe(false); + }); + + it('isKeyringUnlocked returns keyring state', () => { + expect(service.isKeyringUnlocked()).toBe(true); + + (mockMessenger.call as jest.Mock).mockImplementation((action: string) => { + if (action === 'KeyringController:getState') { + return { isUnlocked: false }; + } + return undefined; + }); + + expect(service.isKeyringUnlocked()).toBe(false); + }); + }); + + describe('createEthersSigner', () => { + it('throws NO_ACCOUNT_SELECTED when no account', () => { + (mockMessenger.call as jest.Mock).mockImplementation((action: string) => { + if ( + action === 'AccountTreeController:getAccountsFromSelectedAccountGroup' + ) { + return []; + } + return undefined; + }); + + expect(() => service.createEthersSigner()).toThrow('NO_ACCOUNT_SELECTED'); + }); + + it('getAddress() returns current account address', async () => { + const signer = service.createEthersSigner(); + const address = await signer.getAddress(); + + expect(address).toBe(mockEvmAccount.address); + }); + + it('getAddress() throws when account disappears', async () => { + const signer = service.createEthersSigner(); + + (mockMessenger.call as jest.Mock).mockImplementation((action: string) => { + if ( + action === 'AccountTreeController:getAccountsFromSelectedAccountGroup' + ) { + return []; + } + return undefined; + }); + + await expect(signer.getAddress()).rejects.toThrow('NO_ACCOUNT_SELECTED'); + }); + + it('signTypedData() calls messenger with correct params and returns signature', async () => { + const signer = service.createEthersSigner(); + const domain = { name: 'MYX', version: '1', chainId: 56 }; + const types = { + Order: [ + { name: 'asset', type: 'uint32' }, + { name: 'isBuy', type: 'bool' }, + ], + }; + const value = { asset: 0, isBuy: true }; + + const result = await signer.signTypedData(domain, types, value); + + expect(result).toBe('0xSignatureResult'); + expect(mockMessenger.call).toHaveBeenCalledWith( + 'KeyringController:signTypedMessage', + { + from: mockEvmAccount.address, + data: { + domain, + types, + primaryType: 'Order', + message: value, + }, + }, + 'V4', + ); + }); + + it('signTypedData() derives primaryType (non-EIP712Domain key)', async () => { + const signer = service.createEthersSigner(); + const domain = { name: 'MYX' }; + const types = { + EIP712Domain: [{ name: 'name', type: 'string' }], + Transfer: [{ name: 'amount', type: 'uint256' }], + }; + const value = { amount: '1000' }; + + await signer.signTypedData(domain, types, value); + + expect(mockMessenger.call).toHaveBeenCalledWith( + 'KeyringController:signTypedMessage', + expect.objectContaining({ + data: expect.objectContaining({ primaryType: 'Transfer' }), + }), + 'V4', + ); + }); + + it('signTypedData() falls back to EIP712Domain when types only has that key', async () => { + const signer = service.createEthersSigner(); + const domain = { name: 'MYX' }; + const types = { + EIP712Domain: [{ name: 'name', type: 'string' }], + }; + const value = {}; + + await signer.signTypedData(domain, types, value); + + expect(mockMessenger.call).toHaveBeenCalledWith( + 'KeyringController:signTypedMessage', + expect.objectContaining({ + data: expect.objectContaining({ primaryType: 'EIP712Domain' }), + }), + 'V4', + ); + }); + + it('signTypedData() throws NO_ACCOUNT_SELECTED when account disappears', async () => { + const signer = service.createEthersSigner(); + + (mockMessenger.call as jest.Mock).mockImplementation((action: string) => { + if ( + action === 'AccountTreeController:getAccountsFromSelectedAccountGroup' + ) { + return []; + } + if (action === 'KeyringController:getState') { + return { isUnlocked: true }; + } + return undefined; + }); + + await expect( + signer.signTypedData({ name: 'MYX' }, { Test: [] }, {}), + ).rejects.toThrow('NO_ACCOUNT_SELECTED'); + }); + + it('signTypedData() throws KEYRING_LOCKED when keyring locked', async () => { + const signer = service.createEthersSigner(); + + (mockMessenger.call as jest.Mock).mockImplementation((action: string) => { + if ( + action === 'AccountTreeController:getAccountsFromSelectedAccountGroup' + ) { + return [mockEvmAccount]; + } + if (action === 'KeyringController:getState') { + return { isUnlocked: false }; + } + return undefined; + }); + + await expect( + signer.signTypedData({ name: 'MYX' }, { Test: [] }, {}), + ).rejects.toThrow('KEYRING_LOCKED'); + }); + + it('provider is null', () => { + const signer = service.createEthersSigner(); + + expect(signer.provider).toBeNull(); + }); + }); + + describe('createWalletClient', () => { + it('throws NO_ACCOUNT_SELECTED when no account', () => { + (mockMessenger.call as jest.Mock).mockImplementation((action: string) => { + if ( + action === 'AccountTreeController:getAccountsFromSelectedAccountGroup' + ) { + return []; + } + return undefined; + }); + + expect(() => service.createWalletClient()).toThrow('NO_ACCOUNT_SELECTED'); + }); + + it('returns correct account address and chain ID for mainnet (56)', () => { + const client = service.createWalletClient(); + + expect(client.account.address).toBe(mockEvmAccount.address); + expect(client.chain.id).toBe(56); + }); + + it('returns correct chain ID for testnet (421614)', () => { + const testnetService = new MYXWalletService(mockDeps, mockMessenger, { + isTestnet: true, + }); + const client = testnetService.createWalletClient(); + + expect(client.chain.id).toBe(421614); + }); + + it('signTypedData() calls messenger and returns signature', async () => { + const client = service.createWalletClient(); + const args = { + domain: { name: 'MYX', chainId: 56 }, + types: { Order: [{ name: 'asset', type: 'uint32' }] }, + primaryType: 'Order', + message: { asset: 1 }, + }; + + const result = await client.signTypedData(args); + + expect(result).toBe('0xSignatureResult'); + expect(mockMessenger.call).toHaveBeenCalledWith( + 'KeyringController:signTypedMessage', + { + from: mockEvmAccount.address, + data: { + domain: args.domain, + types: args.types, + primaryType: args.primaryType, + message: args.message, + }, + }, + 'V4', + ); + }); + + it('signTypedData() throws NO_ACCOUNT_SELECTED when account disappears', async () => { + const client = service.createWalletClient(); + + (mockMessenger.call as jest.Mock).mockImplementation((action: string) => { + if ( + action === 'AccountTreeController:getAccountsFromSelectedAccountGroup' + ) { + return []; + } + if (action === 'KeyringController:getState') { + return { isUnlocked: true }; + } + return undefined; + }); + + await expect( + client.signTypedData({ + domain: {}, + types: {}, + primaryType: 'Test', + message: {}, + }), + ).rejects.toThrow('NO_ACCOUNT_SELECTED'); + }); + + it('signTypedData() throws KEYRING_LOCKED when keyring locked', async () => { + const client = service.createWalletClient(); + + (mockMessenger.call as jest.Mock).mockImplementation((action: string) => { + if ( + action === 'AccountTreeController:getAccountsFromSelectedAccountGroup' + ) { + return [mockEvmAccount]; + } + if (action === 'KeyringController:getState') { + return { isUnlocked: false }; + } + return undefined; + }); + + await expect( + client.signTypedData({ + domain: {}, + types: {}, + primaryType: 'Test', + message: {}, + }), + ).rejects.toThrow('KEYRING_LOCKED'); + }); + }); + + describe('getUserAddress', () => { + it('returns address as Hex', () => { + const address = service.getUserAddress(); + + expect(address).toBe(mockEvmAccount.address); + }); + + it('throws NO_ACCOUNT_SELECTED when no account', () => { + (mockMessenger.call as jest.Mock).mockImplementation((action: string) => { + if ( + action === 'AccountTreeController:getAccountsFromSelectedAccountGroup' + ) { + return []; + } + return undefined; + }); + + expect(() => service.getUserAddress()).toThrow('NO_ACCOUNT_SELECTED'); + }); + + it('throws INVALID_ADDRESS_FORMAT when isValidHexAddress returns false', () => { + const { isValidHexAddress } = jest.requireMock('@metamask/utils'); + isValidHexAddress.mockReturnValueOnce(false); + + expect(() => service.getUserAddress()).toThrow('INVALID_ADDRESS_FORMAT'); + }); + }); + + describe('getCurrentAccountId', () => { + it('returns CAIP ID with mainnet chain (eip155:56:address)', async () => { + const accountId = await service.getCurrentAccountId(); + + expect(accountId).toBe(`eip155:56:${mockEvmAccount.address}`); + }); + + it('returns CAIP ID with testnet chain (eip155:421614:address)', async () => { + service.setTestnetMode(true); + + const accountId = await service.getCurrentAccountId(); + + expect(accountId).toBe(`eip155:421614:${mockEvmAccount.address}`); + }); + + it('throws NO_ACCOUNT_SELECTED when no account', async () => { + (mockMessenger.call as jest.Mock).mockImplementation((action: string) => { + if ( + action === 'AccountTreeController:getAccountsFromSelectedAccountGroup' + ) { + return []; + } + return undefined; + }); + + await expect(service.getCurrentAccountId()).rejects.toThrow( + 'NO_ACCOUNT_SELECTED', + ); + }); + }); + + describe('getUserAddressFromAccountId / getUserAddressWithDefault', () => { + it('parses address from CAIP account ID', () => { + const accountId = + 'eip155:56:0x1234567890123456789012345678901234567890' as CaipAccountId; + + const address = service.getUserAddressFromAccountId(accountId); + + expect(address).toBe('0x1234567890123456789012345678901234567890'); + }); + + it('throws INVALID_ADDRESS_FORMAT for invalid address', () => { + const { isValidHexAddress } = jest.requireMock('@metamask/utils'); + isValidHexAddress.mockReturnValueOnce(false); + + const accountId = 'eip155:56:invalid-address' as CaipAccountId; + + expect(() => service.getUserAddressFromAccountId(accountId)).toThrow( + 'INVALID_ADDRESS_FORMAT', + ); + }); + + it('getUserAddressWithDefault uses provided accountId', async () => { + const accountId = + 'eip155:56:0x9999999999999999999999999999999999999999' as CaipAccountId; + + const address = await service.getUserAddressWithDefault(accountId); + + expect(address).toBe('0x9999999999999999999999999999999999999999'); + }); + + it('getUserAddressWithDefault falls back to getCurrentAccountId', async () => { + const address = await service.getUserAddressWithDefault(); + + expect(address).toBe(mockEvmAccount.address); + }); + }); +}); diff --git a/packages/perps-controller/tests/src/services/MarketDataService.test.ts b/packages/perps-controller/tests/src/services/MarketDataService.test.ts new file mode 100644 index 00000000000..3cd3fa5e8e9 --- /dev/null +++ b/packages/perps-controller/tests/src/services/MarketDataService.test.ts @@ -0,0 +1,1817 @@ +import type { CandlePeriod } from '../../../src/constants/chartConfig.js'; +import { MarketDataService } from '../../../src/services/MarketDataService.js'; +import type { ServiceContext } from '../../../src/services/ServiceContext.js'; +import type { + PerpsProvider, + Position, + AccountState, + Order, + OrderFill, + Funding, + MarketInfo, + FeeCalculationResult, + FeeCalculationParams, + AssetRoute, + PerpsPlatformDependencies, + PerpsMarketData, + PerpsTerminalMarketService, + TerminalAssetMetadata, +} from '../../../src/types/index.js'; +import type { CandleData } from '../../../src/types/perps-types.js'; +import { resetPerpsRestCacheForTests } from '../../../src/utils/coalescePerpsRestRequest.js'; +/* eslint-disable */ +import { + createMockHyperLiquidProvider, + createMockPosition, + createMockOrder, +} from '../../helpers/providerMocks.js'; +import { + createMockServiceContext, + createMockInfrastructure, +} from '../../helpers/serviceMocks.js'; + +jest.mock('uuid', () => ({ v4: () => 'mock-trace-id' })); + +describe('MarketDataService', () => { + let mockProvider: jest.Mocked; + let mockContext: ServiceContext; + let mockDeps: jest.Mocked; + let marketDataService: MarketDataService; + + beforeEach(() => { + mockProvider = + createMockHyperLiquidProvider() as unknown as jest.Mocked; + mockDeps = createMockInfrastructure(); + marketDataService = new MarketDataService(mockDeps); + mockContext = createMockServiceContext({ + errorContext: { controller: 'MarketDataService', method: 'test' }, + }); + jest.clearAllMocks(); + // REST coalesce cache is module-scoped and persists across tests; reset + // so each test starts from a clean slate. + resetPerpsRestCacheForTests(); + }); + + afterEach(() => { + jest.resetAllMocks(); + }); + + describe('getPositions', () => { + it('fetches and returns positions successfully', async () => { + const mockPositions: Position[] = [createMockPosition()]; + mockProvider.getPositions.mockResolvedValue(mockPositions); + + const result = await marketDataService.getPositions({ + provider: mockProvider, + context: mockContext, + }); + + expect(result).toEqual(mockPositions); + expect(mockDeps.tracer.trace).toHaveBeenCalledWith( + expect.objectContaining({ name: 'Perps Get Positions' }), + ); + expect(mockDeps.tracer.endTrace).toHaveBeenCalledWith( + expect.objectContaining({ + name: 'Perps Get Positions', + data: { success: true }, + }), + ); + expect(mockContext.stateManager?.update).toHaveBeenCalled(); + }); + + it('updates state with lastUpdateTimestamp on success', async () => { + const mockPositions: Position[] = [createMockPosition()]; + mockProvider.getPositions.mockResolvedValue(mockPositions); + + await marketDataService.getPositions({ + provider: mockProvider, + context: mockContext, + }); + + expect(mockContext.stateManager?.update).toHaveBeenCalledWith( + expect.any(Function), + ); + }); + + it('handles errors and updates state', async () => { + const mockError = new Error('Network error'); + mockProvider.getPositions.mockRejectedValue(mockError); + + await expect( + marketDataService.getPositions({ + provider: mockProvider, + context: mockContext, + }), + ).rejects.toThrow('Network error'); + + expect(mockDeps.tracer.endTrace).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ success: false }), + }), + ); + expect(mockContext.stateManager?.update).toHaveBeenCalled(); + }); + + it('works without stateManager', async () => { + const mockPositions: Position[] = [createMockPosition()]; + mockProvider.getPositions.mockResolvedValue(mockPositions); + const contextWithoutState = createMockServiceContext({ + errorContext: { controller: 'MarketDataService', method: 'test' }, + stateManager: undefined, + }); + + const result = await marketDataService.getPositions({ + provider: mockProvider, + context: contextWithoutState, + }); + + expect(result).toEqual(mockPositions); + }); + + it('passes params to provider', async () => { + const mockPositions: Position[] = []; + mockProvider.getPositions.mockResolvedValue(mockPositions); + const params = { skipCache: true }; + + await marketDataService.getPositions({ + provider: mockProvider, + params, + context: mockContext, + }); + + expect(mockProvider.getPositions).toHaveBeenCalledWith(params); + }); + + it('handles provider exception during getPositions', async () => { + const error = new Error('Network timeout'); + mockProvider.getPositions.mockRejectedValue(error); + + await expect( + marketDataService.getPositions({ + provider: mockProvider, + context: mockContext, + }), + ).rejects.toThrow('Network timeout'); + + expect(mockContext.stateManager?.update).toHaveBeenCalled(); + }); + }); + + describe('getOrderFills', () => { + it('fetches and returns order fills successfully', async () => { + const mockOrderFills: OrderFill[] = [ + { + orderId: 'fill-1', + symbol: 'BTC', + side: 'buy', + price: '50000', + size: '0.1', + pnl: '100', + direction: 'long', + fee: '5', + feeToken: 'USDC', + timestamp: Date.now(), + }, + ]; + mockProvider.getOrderFills.mockResolvedValue(mockOrderFills); + + const result = await marketDataService.getOrderFills({ + provider: mockProvider, + context: mockContext, + }); + + expect(result).toEqual(mockOrderFills); + expect(mockDeps.tracer.trace).toHaveBeenCalledWith( + expect.objectContaining({ name: 'Perps Order Fills Fetch' }), + ); + expect(mockDeps.tracer.endTrace).toHaveBeenCalledWith( + expect.objectContaining({ data: { success: true } }), + ); + }); + + it('handles errors and logs them', async () => { + const mockError = new Error('API error'); + mockProvider.getOrderFills.mockRejectedValue(mockError); + + await expect( + marketDataService.getOrderFills({ + provider: mockProvider, + context: mockContext, + }), + ).rejects.toThrow('API error'); + + expect(mockDeps.logger.error).toHaveBeenCalledWith( + mockError, + expect.objectContaining({ + tags: expect.objectContaining({ feature: 'perps' }), + }), + ); + }); + + it('passes params to provider', async () => { + mockProvider.getOrderFills.mockResolvedValue([]); + const params = { startTime: Date.now() - 86400000, limit: 50 }; + + await marketDataService.getOrderFills({ + provider: mockProvider, + params, + context: mockContext, + }); + + expect(mockProvider.getOrderFills).toHaveBeenCalledWith(params, { + forceRefresh: undefined, + }); + }); + }); + + describe('getOrders', () => { + it('fetches and returns orders successfully', async () => { + const mockOrders: Order[] = [createMockOrder()]; + mockProvider.getOrders.mockResolvedValue(mockOrders); + + const result = await marketDataService.getOrders({ + provider: mockProvider, + context: mockContext, + }); + + expect(result).toEqual(mockOrders); + expect(mockDeps.tracer.trace).toHaveBeenCalledWith( + expect.objectContaining({ name: 'Perps Orders Fetch' }), + ); + expect(mockDeps.tracer.endTrace).toHaveBeenCalledWith( + expect.objectContaining({ data: { success: true } }), + ); + }); + + it('handles errors and logs them', async () => { + const mockError = new Error('Failed to fetch orders'); + mockProvider.getOrders.mockRejectedValue(mockError); + + await expect( + marketDataService.getOrders({ + provider: mockProvider, + context: mockContext, + }), + ).rejects.toThrow('Failed to fetch orders'); + + expect(mockDeps.logger.error).toHaveBeenCalled(); + expect(mockDeps.tracer.endTrace).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ success: false }), + }), + ); + }); + }); + + describe('getOpenOrders', () => { + it('fetches open orders successfully', async () => { + const mockOrders: Order[] = [createMockOrder({ status: 'open' })]; + mockProvider.getOpenOrders.mockResolvedValue(mockOrders); + + const result = await marketDataService.getOpenOrders({ + provider: mockProvider, + context: mockContext, + }); + + expect(result).toEqual(mockOrders); + expect(mockDeps.tracer.trace).toHaveBeenCalled(); + expect(mockDeps.tracer.setMeasurement).toHaveBeenCalled(); + }); + + it('handles errors in open orders fetch', async () => { + const mockError = new Error('Connection timeout'); + mockProvider.getOpenOrders.mockRejectedValue(mockError); + + await expect( + marketDataService.getOpenOrders({ + provider: mockProvider, + context: mockContext, + }), + ).rejects.toThrow('Connection timeout'); + + expect(mockDeps.logger.error).toHaveBeenCalled(); + }); + }); + + describe('getFunding', () => { + it('fetches funding rates successfully', async () => { + const mockFunding: Funding[] = [ + { + symbol: 'BTC', + amountUsd: '10', + rate: '0.0001', + timestamp: Date.now(), + }, + ]; + mockProvider.getFunding.mockResolvedValue(mockFunding); + + const result = await marketDataService.getFunding({ + provider: mockProvider, + context: mockContext, + }); + + expect(result).toEqual(mockFunding); + expect(mockDeps.tracer.trace).toHaveBeenCalledWith( + expect.objectContaining({ name: 'Perps Funding Fetch' }), + ); + }); + + it('handles funding fetch errors', async () => { + const mockError = new Error('Funding data unavailable'); + mockProvider.getFunding.mockRejectedValue(mockError); + + await expect( + marketDataService.getFunding({ + provider: mockProvider, + context: mockContext, + }), + ).rejects.toThrow('Funding data unavailable'); + + expect(mockDeps.logger.error).toHaveBeenCalled(); + }); + }); + + describe('REST request coalesce', () => { + beforeEach(() => { + jest.spyOn(Date, 'now').mockReturnValue(1_700_000_000_000); + }); + + it('dedupes concurrent getOrderFills calls with identical params', async () => { + let resolveFetch: (value: OrderFill[]) => void = () => undefined; + mockProvider.getOrderFills.mockImplementation( + () => + new Promise((resolve) => { + resolveFetch = resolve; + }), + ); + + const a = marketDataService.getOrderFills({ + provider: mockProvider, + params: { aggregateByTime: false }, + context: mockContext, + }); + const b = marketDataService.getOrderFills({ + provider: mockProvider, + params: { aggregateByTime: false }, + context: mockContext, + }); + + // Flush microtasks so both calls reach coalescePerpsRestRequest and the + // provider mock captures the real resolver. Without this, resolveFetch + // still points at the initial stub and both pending fetches hang. + await Promise.resolve(); + await Promise.resolve(); + resolveFetch([]); + await Promise.all([a, b]); + + expect(mockProvider.getOrderFills).toHaveBeenCalledTimes(1); + }); + + it('returns cached result for second getOrders call within TTL', async () => { + mockProvider.getOrders.mockResolvedValue([]); + + await marketDataService.getOrders({ + provider: mockProvider, + context: mockContext, + }); + await marketDataService.getOrders({ + provider: mockProvider, + context: mockContext, + }); + + expect(mockProvider.getOrders).toHaveBeenCalledTimes(1); + }); + + it('bypasses cache when forceRefresh is true on getFunding', async () => { + mockProvider.getFunding.mockResolvedValue([]); + + await marketDataService.getFunding({ + provider: mockProvider, + context: mockContext, + }); + await marketDataService.getFunding({ + provider: mockProvider, + context: mockContext, + forceRefresh: true, + }); + + expect(mockProvider.getFunding).toHaveBeenCalledTimes(2); + }); + + it('bypasses coalesce for paginated getOrderFills (limit/endTime)', async () => { + mockProvider.getOrderFills.mockResolvedValue([]); + + await marketDataService.getOrderFills({ + provider: mockProvider, + params: { limit: 50 }, + context: mockContext, + }); + await marketDataService.getOrderFills({ + provider: mockProvider, + params: { limit: 50 }, + context: mockContext, + }); + + expect(mockProvider.getOrderFills).toHaveBeenCalledTimes(2); + }); + + it('buckets getOrderFills by startTime day so 90d and all-history callers do not collide', async () => { + mockProvider.getOrderFills + .mockResolvedValueOnce([]) + .mockResolvedValueOnce([]); + + // Caller A — unbounded (no startTime) + await marketDataService.getOrderFills({ + provider: mockProvider, + params: { aggregateByTime: false }, + context: mockContext, + }); + // Caller B — 90-day window (startTime set) + await marketDataService.getOrderFills({ + provider: mockProvider, + params: { + aggregateByTime: false, + startTime: Date.now() - 90 * 86_400_000, + }, + context: mockContext, + }); + + expect(mockProvider.getOrderFills).toHaveBeenCalledTimes(2); + }); + + it('keys cache by resolved account so account switch does not serve stale data', async () => { + mockProvider.getOrders.mockResolvedValue([]); + + // Caller A — resolved account 0x...001 + (mockProvider.getCurrentAccountId as jest.Mock).mockResolvedValueOnce( + 'eip155:1:0x0000000000000000000000000000000000000001', + ); + await marketDataService.getOrders({ + provider: mockProvider, + context: mockContext, + }); + + // Caller B — resolved account 0x...002 (user switched accounts) + (mockProvider.getCurrentAccountId as jest.Mock).mockResolvedValueOnce( + 'eip155:1:0x0000000000000000000000000000000000000002', + ); + await marketDataService.getOrders({ + provider: mockProvider, + context: mockContext, + }); + + // Two distinct accounts must each trigger a real fetch rather than + // sharing a single cached payload via a 'default' sentinel key. + expect(mockProvider.getOrders).toHaveBeenCalledTimes(2); + }); + }); + + describe('getAccountState', () => { + it('fetches account state and updates state', async () => { + const mockAccountState: AccountState = { + spendableBalance: '10000', + withdrawableBalance: '10000', + totalBalance: '15000', + marginUsed: '5000', + unrealizedPnl: '1000', + returnOnEquity: '0.2', + }; + mockProvider.getAccountState.mockResolvedValue(mockAccountState); + + const result = await marketDataService.getAccountState({ + provider: mockProvider, + context: mockContext, + }); + + expect(result).toEqual(mockAccountState); + expect(mockContext.stateManager?.update).toHaveBeenCalled(); + expect(mockDeps.tracer.trace).toHaveBeenCalledWith( + expect.objectContaining({ name: 'Perps Get Account State' }), + ); + }); + + it('throws error when account state is null', async () => { + mockProvider.getAccountState.mockResolvedValue(null as never); + + await expect( + marketDataService.getAccountState({ + provider: mockProvider, + context: mockContext, + }), + ).rejects.toThrow( + 'Failed to get account state: received null/undefined response', + ); + + expect(mockDeps.logger.error).toHaveBeenCalled(); + }); + + it('handles errors and updates error state', async () => { + const mockError = new Error('Account fetch failed'); + mockProvider.getAccountState.mockRejectedValue(mockError); + + await expect( + marketDataService.getAccountState({ + provider: mockProvider, + context: mockContext, + }), + ).rejects.toThrow('Account fetch failed'); + + expect(mockContext.stateManager?.update).toHaveBeenCalled(); + expect(mockDeps.tracer.endTrace).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ + success: false, + error: 'Account fetch failed', + }), + }), + ); + }); + + it('passes source param in trace tags', async () => { + const mockAccountState: AccountState = { + spendableBalance: '10000', + withdrawableBalance: '10000', + totalBalance: '15000', + marginUsed: '5000', + unrealizedPnl: '1000', + returnOnEquity: '0.2', + }; + mockProvider.getAccountState.mockResolvedValue(mockAccountState); + + await marketDataService.getAccountState({ + provider: mockProvider, + params: { source: 'user-action' }, + context: mockContext, + }); + + expect(mockDeps.tracer.trace).toHaveBeenCalledWith( + expect.objectContaining({ + tags: expect.objectContaining({ source: 'user-action' }), + }), + ); + }); + }); + + describe('getHistoricalPortfolio', () => { + it('fetches historical portfolio data successfully', async () => { + const mockResult = { + accountValue1dAgo: '9500', + timestamp: Date.now(), + }; + mockProvider.getHistoricalPortfolio.mockResolvedValue(mockResult); + + const result = await marketDataService.getHistoricalPortfolio({ + provider: mockProvider, + context: mockContext, + }); + + expect(result).toEqual(mockResult); + expect(mockDeps.tracer.trace).toHaveBeenCalledWith( + expect.objectContaining({ name: 'Perps Get Historical Portfolio' }), + ); + }); + + it('throws error when provider does not support historical portfolio', async () => { + const providerWithoutMethod = { + ...mockProvider, + getHistoricalPortfolio: undefined, + }; + + await expect( + marketDataService.getHistoricalPortfolio({ + provider: providerWithoutMethod as never, + context: mockContext, + }), + ).rejects.toThrow('Historical portfolio not supported by provider'); + }); + + it('handles errors and updates error state', async () => { + const mockError = new Error('Portfolio data error'); + mockProvider.getHistoricalPortfolio.mockRejectedValue(mockError); + + await expect( + marketDataService.getHistoricalPortfolio({ + provider: mockProvider, + context: mockContext, + }), + ).rejects.toThrow('Portfolio data error'); + + expect(mockContext.stateManager?.update).toHaveBeenCalled(); + expect(mockDeps.logger.error).toHaveBeenCalled(); + }); + }); + + describe('getMarkets', () => { + it('fetches markets successfully', async () => { + const mockMarkets: MarketInfo[] = [ + { name: 'BTC', szDecimals: 5, maxLeverage: 20, marginTableId: 1 }, + { name: 'ETH', szDecimals: 4, maxLeverage: 15, marginTableId: 2 }, + ]; + mockProvider.getMarkets.mockResolvedValue(mockMarkets); + + const result = await marketDataService.getMarkets({ + provider: mockProvider, + context: mockContext, + }); + + expect(result).toEqual(mockMarkets); + expect(mockContext.stateManager?.update).toHaveBeenCalled(); + expect(mockDeps.tracer.trace).toHaveBeenCalledWith( + expect.objectContaining({ name: 'Perps Get Markets' }), + ); + }); + + it('includes symbol count in trace tags when symbols provided', async () => { + mockProvider.getMarkets.mockResolvedValue([]); + + await marketDataService.getMarkets({ + provider: mockProvider, + params: { symbols: ['BTC', 'ETH', 'SOL'] }, + context: mockContext, + }); + + expect(mockDeps.tracer.trace).toHaveBeenCalledWith( + expect.objectContaining({ + tags: expect.objectContaining({ symbolCount: '3' }), + }), + ); + }); + + it('handles market fetch errors and updates state', async () => { + const mockError = new Error('Markets unavailable'); + mockProvider.getMarkets.mockRejectedValue(mockError); + + await expect( + marketDataService.getMarkets({ + provider: mockProvider, + context: mockContext, + }), + ).rejects.toThrow('Markets unavailable'); + + expect(mockContext.stateManager?.update).toHaveBeenCalled(); + expect(mockDeps.logger.error).toHaveBeenCalled(); + }); + }); + + describe('getAvailableDexs', () => { + it('fetches available DEXs when supported', async () => { + const mockDexs = ['hyperliquid', 'vertex']; + const providerWithDexs = { + ...mockProvider, + getAvailableDexs: jest.fn().mockResolvedValue(mockDexs), + }; + + const result = await marketDataService.getAvailableDexs({ + provider: providerWithDexs as never, + context: mockContext, + }); + + expect(result).toEqual(mockDexs); + }); + + it('throws error when provider does not support HIP-3 DEXs', async () => { + const providerWithoutDexs = { + ...mockProvider, + getAvailableDexs: undefined, + }; + + await expect( + marketDataService.getAvailableDexs({ + provider: providerWithoutDexs as never, + context: mockContext, + }), + ).rejects.toThrow('Provider does not support HIP-3 DEXs'); + + expect(mockDeps.logger.error).toHaveBeenCalled(); + }); + }); + + describe('calculateLiquidationPrice', () => { + it('calculates liquidation price successfully', async () => { + const params = { + entryPrice: 50000, + leverage: 10, + direction: 'long' as const, + positionSize: 0.5, + }; + mockProvider.calculateLiquidationPrice.mockResolvedValue('45000'); + + const result = await marketDataService.calculateLiquidationPrice({ + provider: mockProvider, + params, + context: mockContext, + }); + + expect(result).toBe('45000'); + expect(mockProvider.calculateLiquidationPrice).toHaveBeenCalledWith( + params, + ); + }); + + it('handles calculation errors', async () => { + const params = { + entryPrice: 50000, + leverage: 10, + direction: 'long' as const, + positionSize: 0.5, + }; + const mockError = new Error('Calculation failed'); + mockProvider.calculateLiquidationPrice.mockRejectedValue(mockError); + + await expect( + marketDataService.calculateLiquidationPrice({ + provider: mockProvider, + params, + context: mockContext, + }), + ).rejects.toThrow('Calculation failed'); + + expect(mockDeps.logger.error).toHaveBeenCalled(); + }); + }); + + describe('previewPositionModify', () => { + it('delegates to the provider', async () => { + const params = { + position: createMockPosition({ + leverage: { type: 'isolated' as const, value: 5 }, + }), + direction: 'long' as const, + size: '0.1', + price: '50000', + leverage: 10, + }; + mockProvider.previewPositionModify.mockResolvedValue({ + status: 'none', + }); + + const result = await marketDataService.previewPositionModify({ + provider: mockProvider, + params, + context: mockContext, + }); + + expect(result).toEqual({ status: 'none' }); + expect(mockProvider.previewPositionModify).toHaveBeenCalledWith(params); + }); + }); + + describe('calculateMaintenanceMargin', () => { + it('calculates maintenance margin successfully', async () => { + const params = { + asset: 'BTC', + positionSize: 0.5, + }; + mockProvider.calculateMaintenanceMargin.mockResolvedValue(500); + + const result = await marketDataService.calculateMaintenanceMargin({ + provider: mockProvider, + params, + context: mockContext, + }); + + expect(result).toBe(500); + }); + + it('handles maintenance margin errors', async () => { + const params = { + asset: 'BTC', + positionSize: 0.5, + }; + const mockError = new Error('Margin calculation error'); + mockProvider.calculateMaintenanceMargin.mockRejectedValue(mockError); + + await expect( + marketDataService.calculateMaintenanceMargin({ + provider: mockProvider, + params, + context: mockContext, + }), + ).rejects.toThrow('Margin calculation error'); + + expect(mockDeps.logger.error).toHaveBeenCalled(); + }); + }); + + describe('getMaxLeverage', () => { + it('fetches max leverage for asset', async () => { + mockProvider.getMaxLeverage.mockResolvedValue(20); + + const result = await marketDataService.getMaxLeverage({ + provider: mockProvider, + asset: 'BTC', + context: mockContext, + }); + + expect(result).toBe(20); + expect(mockProvider.getMaxLeverage).toHaveBeenCalledWith('BTC'); + }); + + it('handles max leverage errors', async () => { + const mockError = new Error('Asset not found'); + mockProvider.getMaxLeverage.mockRejectedValue(mockError); + + await expect( + marketDataService.getMaxLeverage({ + provider: mockProvider, + asset: 'INVALID', + context: mockContext, + }), + ).rejects.toThrow('Asset not found'); + + expect(mockDeps.logger.error).toHaveBeenCalled(); + }); + }); + + describe('calculateFees', () => { + it('calculates fees successfully', async () => { + const params: FeeCalculationParams = { + orderType: 'market', + symbol: 'BTC', + amount: '0.1', + isMaker: false, + }; + const mockFees: FeeCalculationResult = { + feeRate: 0.0005, + feeAmount: 2.5, + protocolFeeRate: 0.0003, + protocolFeeAmount: 1.5, + metamaskFeeRate: 0.0002, + }; + mockProvider.calculateFees.mockResolvedValue(mockFees); + + const result = await marketDataService.calculateFees({ + provider: mockProvider, + params, + context: mockContext, + }); + + expect(result).toEqual(mockFees); + }); + + it('surfaces subscription eligibility and remainingNotionalUsd on the fee preview', async () => { + const params: FeeCalculationParams = { + orderType: 'market', + symbol: 'BTC', + amount: '1000', + isMaker: false, + }; + const mockFees: FeeCalculationResult = { + feeRate: 0.0015, + feeAmount: 1.5, + protocolFeeRate: 0.00045, + metamaskFeeRate: 0.001, + }; + mockProvider.calculateFees.mockResolvedValue(mockFees); + + const result = await marketDataService.calculateFees({ + provider: mockProvider, + params, + context: { + ...mockContext, + subscriptionFeeWaiver: { + eligible: true, + reason: 'eligible', + remainingNotionalUsd: 2500, + }, + }, + }); + + expect(result).toStrictEqual({ + ...mockFees, + subscription: { + eligible: true, + reason: 'eligible', + remainingNotionalUsd: 2500, + }, + }); + }); + + it('reads the fee preview waiver status without any side effects', async () => { + const params: FeeCalculationParams = { + orderType: 'market', + symbol: 'BTC', + amount: '1000', + isMaker: false, + }; + const mockFees: FeeCalculationResult = { + feeRate: 0.0015, + feeAmount: 1.5, + protocolFeeRate: 0.00045, + metamaskFeeRate: 0.001, + }; + mockProvider.calculateFees.mockResolvedValue(mockFees); + const waiver = { + eligible: true, + reason: 'eligible' as const, + remainingNotionalUsd: 2500, + }; + + const result = await marketDataService.calculateFees({ + provider: mockProvider, + params, + context: { ...mockContext, subscriptionFeeWaiver: waiver }, + }); + + // The quoted rates are untouched, the cap is not mutated, and the + // provider is asked exactly once for the same params. + expect(result.feeRate).toBe(mockFees.feeRate); + expect(result.metamaskFeeRate).toBe(mockFees.metamaskFeeRate); + expect(result.protocolFeeRate).toBe(mockFees.protocolFeeRate); + expect(waiver).toStrictEqual({ + eligible: true, + reason: 'eligible', + remainingNotionalUsd: 2500, + }); + expect(mockProvider.calculateFees).toHaveBeenCalledTimes(1); + expect(mockProvider.calculateFees).toHaveBeenCalledWith(params); + }); + + it('omits the subscription preview when no waiver status is provided', async () => { + const params: FeeCalculationParams = { + orderType: 'market', + symbol: 'BTC', + amount: '1000', + isMaker: false, + }; + const mockFees: FeeCalculationResult = { feeRate: 0.0015 }; + mockProvider.calculateFees.mockResolvedValue(mockFees); + + const result = await marketDataService.calculateFees({ + provider: mockProvider, + params, + context: mockContext, + }); + + expect(result).toStrictEqual(mockFees); + }); + + it('handles fee calculation errors', async () => { + const params: FeeCalculationParams = { + orderType: 'limit', + symbol: 'BTC', + amount: '0.1', + isMaker: true, + }; + const mockError = new Error('Fee calculation failed'); + mockProvider.calculateFees.mockRejectedValue(mockError); + + await expect( + marketDataService.calculateFees({ + provider: mockProvider, + params, + context: mockContext, + }), + ).rejects.toThrow('Fee calculation failed'); + + expect(mockDeps.logger.error).toHaveBeenCalled(); + }); + }); + + describe('validateOrder', () => { + it('validates order successfully', async () => { + const params = { + symbol: 'BTC', + isBuy: true, + size: '0.1', + orderType: 'market' as const, + }; + const mockResult = { isValid: true }; + mockProvider.validateOrder.mockResolvedValue(mockResult); + + const result = await marketDataService.validateOrder({ + provider: mockProvider, + params, + context: mockContext, + }); + + expect(result).toEqual(mockResult); + }); + + it('returns validation error when order invalid', async () => { + const params = { + symbol: 'BTC', + isBuy: true, + size: '0.001', + orderType: 'market' as const, + }; + const mockResult = { isValid: false, error: 'Size too small' }; + mockProvider.validateOrder.mockResolvedValue(mockResult); + + const result = await marketDataService.validateOrder({ + provider: mockProvider, + params, + context: mockContext, + }); + + expect(result).toEqual(mockResult); + }); + + it('handles validation errors', async () => { + const params = { + symbol: 'BTC', + isBuy: true, + size: '0.1', + orderType: 'market' as const, + }; + const mockError = new Error('Validation service unavailable'); + mockProvider.validateOrder.mockRejectedValue(mockError); + + await expect( + marketDataService.validateOrder({ + provider: mockProvider, + params, + context: mockContext, + }), + ).rejects.toThrow('Validation service unavailable'); + + expect(mockDeps.logger.error).toHaveBeenCalled(); + }); + }); + + describe('validateClosePosition', () => { + it('validates close position request', async () => { + const params = { + symbol: 'BTC', + size: '0.5', + }; + const mockResult = { isValid: true }; + mockProvider.validateClosePosition.mockResolvedValue(mockResult); + + const result = await marketDataService.validateClosePosition({ + provider: mockProvider, + params, + context: mockContext, + }); + + expect(result).toEqual(mockResult); + }); + + it('returns error when close position invalid', async () => { + const params = { + symbol: 'BTC', + size: '10', + }; + const mockResult = { isValid: false, error: 'Position size mismatch' }; + mockProvider.validateClosePosition.mockResolvedValue(mockResult); + + const result = await marketDataService.validateClosePosition({ + provider: mockProvider, + params, + context: mockContext, + }); + + expect(result).toEqual(mockResult); + }); + }); + + describe('getWithdrawalRoutes', () => { + it('fetches withdrawal routes successfully', () => { + const mockRoutes: AssetRoute[] = [ + { + assetId: + 'eip155:42161/erc20:0xaf88d065e77c8cC2239327C5EDb3A432268e5831/default', + chainId: 'eip155:42161', + contractAddress: '0xBridgeAddress', + constraints: { minAmount: '10' }, + }, + ]; + mockProvider.getWithdrawalRoutes.mockReturnValue(mockRoutes); + + const result = marketDataService.getWithdrawalRoutes({ + provider: mockProvider, + }); + + expect(result).toEqual(mockRoutes); + }); + + it('returns empty array on error', () => { + mockProvider.getWithdrawalRoutes.mockImplementation(() => { + throw new Error('Routes unavailable'); + }); + + const result = marketDataService.getWithdrawalRoutes({ + provider: mockProvider, + }); + + // Silent fail - withdrawal routes are not critical + expect(result).toEqual([]); + }); + }); + + describe('getBlockExplorerUrl', () => { + it('returns block explorer URL without address', () => { + mockProvider.getBlockExplorerUrl.mockReturnValue( + 'https://explorer.example.com', + ); + + const result = marketDataService.getBlockExplorerUrl({ + provider: mockProvider, + }); + + expect(result).toBe('https://explorer.example.com'); + }); + + it('returns block explorer URL with address', () => { + const address = '0x1234'; + mockProvider.getBlockExplorerUrl.mockReturnValue( + `https://explorer.example.com/address/${address}`, + ); + + const result = marketDataService.getBlockExplorerUrl({ + provider: mockProvider, + address, + }); + + expect(result).toBe(`https://explorer.example.com/address/${address}`); + expect(mockProvider.getBlockExplorerUrl).toHaveBeenCalledWith(address); + }); + }); + + describe('fetchHistoricalCandles', () => { + const mockCandleData: CandleData = { + symbol: 'BTC', + interval: '1h' as CandlePeriod, + candles: [ + { + time: 1700000000, + open: '50000', + high: '51000', + low: '49500', + close: '50500', + volume: '1000', + }, + ], + }; + + it('fetches historical candles successfully', async () => { + mockProvider.fetchHistoricalCandles = jest + .fn() + .mockResolvedValue(mockCandleData); + + const result = await marketDataService.fetchHistoricalCandles({ + provider: mockProvider, + symbol: 'BTC', + interval: '1h' as CandlePeriod, + limit: 100, + context: mockContext, + }); + + expect(result).toEqual(mockCandleData); + expect(mockProvider.fetchHistoricalCandles).toHaveBeenCalledWith({ + symbol: 'BTC', + interval: '1h', + limit: 100, + endTime: undefined, + }); + expect(mockDeps.tracer.trace).toHaveBeenCalledWith( + expect.objectContaining({ name: 'Perps Fetch Historical Candles' }), + ); + expect(mockDeps.tracer.endTrace).toHaveBeenCalledWith( + expect.objectContaining({ + name: 'Perps Fetch Historical Candles', + data: { success: true }, + }), + ); + }); + + it('throws error when provider lacks clientService support', async () => { + const providerWithoutClient = { ...mockProvider }; + + await expect( + marketDataService.fetchHistoricalCandles({ + provider: providerWithoutClient, + symbol: 'BTC', + interval: '1h' as CandlePeriod, + context: mockContext, + }), + ).rejects.toThrow('Historical candles not supported by provider'); + + expect(mockDeps.logger.error).toHaveBeenCalled(); + expect(mockDeps.tracer.endTrace).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ success: false }), + }), + ); + }); + + it('updates error state on failure', async () => { + const mockError = new Error('Network timeout'); + mockProvider.fetchHistoricalCandles = jest + .fn() + .mockRejectedValue(mockError); + + await expect( + marketDataService.fetchHistoricalCandles({ + provider: mockProvider, + symbol: 'BTC', + interval: '1h' as CandlePeriod, + context: mockContext, + }), + ).rejects.toThrow('Network timeout'); + + expect(mockContext.stateManager?.update).toHaveBeenCalled(); + }); + + it('skips Sentry logging for abort errors', async () => { + const abortError = new Error('The operation was aborted'); + abortError.name = 'AbortError'; + mockProvider.fetchHistoricalCandles = jest + .fn() + .mockRejectedValue(abortError); + + await expect( + marketDataService.fetchHistoricalCandles({ + provider: mockProvider, + symbol: 'BTC', + interval: '1h' as CandlePeriod, + context: mockContext, + }), + ).rejects.toThrow(); + + expect(mockDeps.logger.error).not.toHaveBeenCalled(); + expect(mockContext.stateManager?.update).not.toHaveBeenCalled(); + }); + + it('logs to Sentry for real fetch failures', async () => { + const networkError = new Error('Network timeout'); + mockProvider.fetchHistoricalCandles = jest + .fn() + .mockRejectedValue(networkError); + + await expect( + marketDataService.fetchHistoricalCandles({ + provider: mockProvider, + symbol: 'BTC', + interval: '1h' as CandlePeriod, + context: mockContext, + }), + ).rejects.toThrow('Network timeout'); + + expect(mockDeps.logger.error).toHaveBeenCalled(); + }); + }); + + describe('Terminal API integration', () => { + let mockTerminalService: jest.Mocked; + let serviceWithTerminal: MarketDataService; + + const terminalMarkets: MarketInfo[] = [ + { name: 'BTC', szDecimals: 5, maxLeverage: 50, marginTableId: 0 }, + { name: 'ETH', szDecimals: 4, maxLeverage: 25, marginTableId: 1 }, + ]; + + const terminalMetadata = new Map([ + [ + 'BTC', + { + name: 'Bitcoin', + description: 'The original cryptocurrency and largest by market cap.', + keywords: ['crypto', 'layer-1'], + tags: ['top-10'], + categories: ['crypto'], + marketType: 'crypto', + }, + ], + [ + 'ETH', + { + name: 'Ethereum', + keywords: ['defi'], + }, + ], + ]); + + beforeEach(() => { + mockDeps.terminalApi = { + ...mockDeps.terminalApi, + globalSnapshotUrl: 'https://terminal.test/v2/perpetuals', + }; + mockTerminalService = { + fetchMarkets: jest.fn(), + fetchGlobalSnapshot: jest.fn(), + clearCache: jest.fn(), + logError: jest.fn(), + }; + + serviceWithTerminal = new MarketDataService({ + ...mockDeps, + terminalMarketService: mockTerminalService, + }); + }); + + describe('getMarkets with useTerminalApi', () => { + it('uses terminal API when flag is enabled and returns data', async () => { + mockTerminalService.fetchMarkets.mockResolvedValue({ + markets: terminalMarkets, + metadata: terminalMetadata, + }); + + const result = await serviceWithTerminal.getMarkets({ + provider: mockProvider, + params: { useTerminalApi: true }, + context: mockContext, + }); + + expect(result).toEqual(terminalMarkets); + expect(mockTerminalService.fetchMarkets).toHaveBeenCalled(); + expect(mockProvider.getMarkets).not.toHaveBeenCalled(); + }); + + it('falls back to provider when terminal API fails', async () => { + const providerMarkets: MarketInfo[] = [ + { name: 'BTC', szDecimals: 5, maxLeverage: 50, marginTableId: 0 }, + ]; + mockTerminalService.fetchMarkets.mockRejectedValue( + new Error('Terminal API down'), + ); + mockProvider.getMarkets.mockResolvedValue(providerMarkets); + + const result = await serviceWithTerminal.getMarkets({ + provider: mockProvider, + params: { useTerminalApi: true }, + context: mockContext, + }); + + expect(result).toEqual(providerMarkets); + expect(mockTerminalService.logError).toHaveBeenCalledWith( + expect.any(Error), + 'getMarkets', + ); + }); + + it('falls back to provider when terminal API returns empty', async () => { + const providerMarkets: MarketInfo[] = [ + { name: 'BTC', szDecimals: 5, maxLeverage: 50, marginTableId: 0 }, + ]; + mockTerminalService.fetchMarkets.mockResolvedValue({ + markets: [], + metadata: new Map(), + }); + mockProvider.getMarkets.mockResolvedValue(providerMarkets); + + const result = await serviceWithTerminal.getMarkets({ + provider: mockProvider, + params: { useTerminalApi: true }, + context: mockContext, + }); + + expect(result).toEqual(providerMarkets); + }); + + it('uses provider when useTerminalApi is false', async () => { + const providerMarkets: MarketInfo[] = [ + { name: 'BTC', szDecimals: 5, maxLeverage: 50, marginTableId: 0 }, + ]; + mockProvider.getMarkets.mockResolvedValue(providerMarkets); + + const result = await serviceWithTerminal.getMarkets({ + provider: mockProvider, + params: { useTerminalApi: false }, + context: mockContext, + }); + + expect(result).toEqual(providerMarkets); + expect(mockTerminalService.fetchMarkets).not.toHaveBeenCalled(); + }); + + it('falls back to provider when symbol filter yields no terminal matches', async () => { + mockTerminalService.fetchMarkets.mockResolvedValue({ + markets: terminalMarkets, + metadata: terminalMetadata, + }); + const providerMarkets: MarketInfo[] = [ + { name: 'DOGE', szDecimals: 2, maxLeverage: 10, marginTableId: 5 }, + ]; + mockProvider.getMarkets.mockResolvedValue(providerMarkets); + + const result = await serviceWithTerminal.getMarkets({ + provider: mockProvider, + params: { useTerminalApi: true, symbols: ['DOGE'] }, + context: mockContext, + }); + + expect(result).toEqual(providerMarkets); + expect(mockTerminalService.fetchMarkets).toHaveBeenCalled(); + expect(mockProvider.getMarkets).toHaveBeenCalled(); + }); + + it('falls back to provider when dex filter yields no terminal matches', async () => { + mockTerminalService.fetchMarkets.mockResolvedValue({ + markets: terminalMarkets, + metadata: terminalMetadata, + }); + const providerMarkets: MarketInfo[] = [ + { + name: 'xyz:GOLD', + szDecimals: 2, + maxLeverage: 5, + marginTableId: 10, + }, + ]; + mockProvider.getMarkets.mockResolvedValue(providerMarkets); + + const result = await serviceWithTerminal.getMarkets({ + provider: mockProvider, + params: { useTerminalApi: true, dex: 'xyz' }, + context: mockContext, + }); + + expect(result).toEqual(providerMarkets); + expect(mockTerminalService.fetchMarkets).toHaveBeenCalled(); + expect(mockProvider.getMarkets).toHaveBeenCalled(); + }); + + it('returns empty for broad unconstrained query when terminal has data', async () => { + mockTerminalService.fetchMarkets.mockResolvedValue({ + markets: terminalMarkets, + metadata: terminalMetadata, + }); + + const result = await serviceWithTerminal.getMarkets({ + provider: mockProvider, + params: { useTerminalApi: true }, + context: mockContext, + }); + + expect(result).toEqual(terminalMarkets); + expect(mockProvider.getMarkets).not.toHaveBeenCalled(); + }); + }); + + describe('getMarketDataWithPrices with useTerminalApi', () => { + const providerMarketData: PerpsMarketData[] = [ + { + symbol: 'BTC', + name: 'BTC', + maxLeverage: '50x', + price: '$50000.00', + change24h: '+$500.00', + change24hPercent: '+1.00%', + volume: '$1000000', + }, + { + symbol: 'ETH', + name: 'ETH', + maxLeverage: '25x', + price: '$3000.00', + change24h: '+$30.00', + change24hPercent: '+1.00%', + volume: '$500000', + }, + ]; + + const createGlobalSnapshotContext = ({ + enabledDexes = ['main'], + isCurrent = () => true, + isMarketAllowed = () => true, + }: { + enabledDexes?: string[]; + isCurrent?: () => boolean; + isMarketAllowed?: (symbol: string) => boolean; + } = {}): ServiceContext => ({ + ...mockContext, + globalSnapshot: { + request: { + provider: 'hyperliquid', + network: 'mainnet', + enabledDexes, + }, + isCurrent, + isMarketAllowed, + }, + }); + + it('adopts a configured atomic snapshot independently of the legacy flag', async () => { + const snapshotMarkets: PerpsMarketData[] = [ + { + symbol: 'BTC', + name: 'Bitcoin', + maxLeverage: '50x', + price: '$50001.00', + change24h: '+$125.00', + change24hPercent: '0.25%', + volume: '$1000000', + }, + ]; + mockTerminalService.fetchGlobalSnapshot?.mockResolvedValue({ + markets: snapshotMarkets, + expiresAt: Date.now() + 30_000, + }); + const isCurrent = jest.fn(() => true); + + const result = await serviceWithTerminal.getMarketDataWithPrices({ + provider: mockProvider, + params: { useTerminalApi: false }, + context: createGlobalSnapshotContext({ isCurrent }), + }); + + expect(result).toStrictEqual(snapshotMarkets); + expect(mockTerminalService.fetchGlobalSnapshot).toHaveBeenCalledTimes( + 1, + ); + expect(mockProvider.getMarketDataWithPrices).not.toHaveBeenCalled(); + expect(isCurrent).toHaveBeenCalledTimes(2); + }); + + it('falls back when a snapshot expires while being fetched', async () => { + mockTerminalService.fetchGlobalSnapshot?.mockResolvedValue({ + markets: providerMarketData, + expiresAt: Date.now() - 1, + }); + mockProvider.getMarketDataWithPrices.mockResolvedValue( + providerMarketData, + ); + + const result = await serviceWithTerminal.getMarketDataWithPrices({ + provider: mockProvider, + context: createGlobalSnapshotContext(), + }); + + expect(result).toStrictEqual(providerMarketData); + expect(mockProvider.getMarketDataWithPrices).toHaveBeenCalledTimes(1); + expect(mockTerminalService.logError).toHaveBeenCalledWith( + expect.objectContaining({ + message: 'Terminal global snapshot expired', + }), + 'getMarketDataWithPrices.globalSnapshot', + ); + }); + + it.each([ + ['timeout', new Error('snapshot timeout')], + ['malformed', new Error('snapshot malformed')], + ['stale', new Error('snapshot stale')], + ['context mismatch', new Error('snapshot identity mismatch')], + ])( + 'falls back to the provider exactly once on %s', + async (_name, error) => { + mockTerminalService.fetchGlobalSnapshot?.mockRejectedValue(error); + mockProvider.getMarketDataWithPrices.mockResolvedValue( + providerMarketData, + ); + + const result = await serviceWithTerminal.getMarketDataWithPrices({ + provider: mockProvider, + params: { useTerminalApi: true }, + context: createGlobalSnapshotContext(), + }); + + expect(result).toStrictEqual(providerMarketData); + expect(mockProvider.getMarketDataWithPrices).toHaveBeenCalledTimes(1); + expect(mockTerminalService.fetchMarkets).not.toHaveBeenCalled(); + }, + ); + + it('rejects a snapshot context race without calling the captured provider', async () => { + mockTerminalService.fetchGlobalSnapshot?.mockResolvedValue({ + markets: providerMarketData, + expiresAt: Date.now() + 30_000, + }); + mockProvider.getMarketDataWithPrices.mockResolvedValue( + providerMarketData, + ); + const isCurrent = jest + .fn() + .mockReturnValueOnce(true) + .mockReturnValueOnce(false); + + await expect( + serviceWithTerminal.getMarketDataWithPrices({ + provider: mockProvider, + context: createGlobalSnapshotContext({ isCurrent }), + }), + ).rejects.toThrow('snapshot context changed'); + + expect(mockProvider.getMarketDataWithPrices).not.toHaveBeenCalled(); + expect(mockTerminalService.fetchMarkets).not.toHaveBeenCalled(); + }); + + it('rejects when a failed snapshot fetch also races with a context change', async () => { + mockTerminalService.fetchGlobalSnapshot?.mockRejectedValue( + new Error('snapshot network failure'), + ); + const isCurrent = jest + .fn() + .mockReturnValueOnce(true) + .mockReturnValueOnce(false); + + await expect( + serviceWithTerminal.getMarketDataWithPrices({ + provider: mockProvider, + context: createGlobalSnapshotContext({ isCurrent }), + }), + ).rejects.toThrow('snapshot context changed'); + expect(mockProvider.getMarketDataWithPrices).not.toHaveBeenCalled(); + expect(mockTerminalService.logError).not.toHaveBeenCalled(); + }); + + it('applies the existing symbol and list filters before adopting a snapshot', async () => { + const snapshotMarkets = [ + providerMarketData[0] as PerpsMarketData, + { + ...(providerMarketData[1] as PerpsMarketData), + symbol: 'xyz:TSLA', + marketSource: 'xyz', + marketType: 'stock' as const, + isHip3: true, + }, + ]; + mockTerminalService.fetchGlobalSnapshot?.mockResolvedValue({ + markets: snapshotMarkets, + expiresAt: Date.now() + 30_000, + }); + + const result = await serviceWithTerminal.getMarketDataWithPrices({ + provider: mockProvider, + params: { + useTerminalApi: true, + categories: ['all'], + excludeSymbols: ['ETH'], + limit: 1, + }, + context: createGlobalSnapshotContext({ + enabledDexes: ['main', 'xyz'], + isMarketAllowed: (symbol) => symbol === 'BTC', + }), + }); + + expect(result).toStrictEqual([providerMarketData[0]]); + expect(mockProvider.getMarketDataWithPrices).not.toHaveBeenCalled(); + }); + + it('propagates provider failure without retry after snapshot rejection', async () => { + mockTerminalService.fetchGlobalSnapshot?.mockRejectedValue( + new Error('snapshot rejected'), + ); + mockProvider.getMarketDataWithPrices.mockRejectedValue( + new Error('provider failed'), + ); + + await expect( + serviceWithTerminal.getMarketDataWithPrices({ + provider: mockProvider, + params: { useTerminalApi: true }, + context: createGlobalSnapshotContext(), + }), + ).rejects.toThrow('provider failed'); + expect(mockProvider.getMarketDataWithPrices).toHaveBeenCalledTimes(1); + }); + + it('rejects a provider fallback result when snapshot context changes during provider await', async () => { + mockTerminalService.fetchGlobalSnapshot?.mockRejectedValue( + new Error('snapshot rejected'), + ); + let resolveProvider: ((markets: PerpsMarketData[]) => void) | undefined; + mockProvider.getMarketDataWithPrices.mockImplementation( + () => + new Promise((resolve) => { + resolveProvider = resolve; + }), + ); + const isCurrent = jest + .fn() + .mockReturnValueOnce(true) + .mockReturnValueOnce(true) + .mockReturnValueOnce(false); + + const pending = serviceWithTerminal.getMarketDataWithPrices({ + provider: mockProvider, + context: createGlobalSnapshotContext({ isCurrent }), + }); + await Promise.resolve(); + resolveProvider?.(providerMarketData); + + await expect(pending).rejects.toThrow('snapshot context changed'); + expect(mockProvider.getMarketDataWithPrices).toHaveBeenCalledTimes(1); + }); + + it('enriches provider data with terminal metadata when flag is enabled', async () => { + mockTerminalService.fetchMarkets.mockResolvedValue({ + markets: terminalMarkets, + metadata: terminalMetadata, + }); + mockProvider.getMarketDataWithPrices.mockResolvedValue( + providerMarketData, + ); + + const result = await serviceWithTerminal.getMarketDataWithPrices({ + provider: mockProvider, + params: { useTerminalApi: true }, + context: mockContext, + }); + + expect(result[0]?.name).toBe('Bitcoin'); + expect(result[0]?.description).toBe( + 'The original cryptocurrency and largest by market cap.', + ); + expect(result[0]?.keywords).toEqual(['crypto', 'layer-1']); + expect(result[0]?.tags).toEqual(['top-10']); + expect(result[0]?.categories).toEqual(['crypto']); + expect(result[0]?.marketType).toBe('crypto'); + expect(result[1]?.name).toBe('Ethereum'); + expect(result[1]?.keywords).toEqual(['defi']); + expect(result[1]?.description).toBeUndefined(); + }); + + it('preserves provider name when terminal metadata omits name', async () => { + const metadataWithoutName = new Map([ + ['BTC', { keywords: ['crypto'] }], + ['ETH', { name: 'Ethereum' }], + ]); + mockTerminalService.fetchMarkets.mockResolvedValue({ + markets: terminalMarkets, + metadata: metadataWithoutName, + }); + mockProvider.getMarketDataWithPrices.mockResolvedValue([ + { + symbol: 'BTC', + name: 'Bitcoin', + maxLeverage: '50x', + price: '$50000.00', + change24h: '+$500.00', + change24hPercent: '+1.00%', + volume: '$1000000', + }, + { + symbol: 'ETH', + name: 'ETH', + maxLeverage: '25x', + price: '$3000.00', + change24h: '+$30.00', + change24hPercent: '+1.00%', + volume: '$500000', + }, + ]); + + const result = await serviceWithTerminal.getMarketDataWithPrices({ + provider: mockProvider, + params: { useTerminalApi: true }, + context: mockContext, + }); + + expect(result[0]?.name).toBe('Bitcoin'); + expect(result[0]?.keywords).toEqual(['crypto']); + expect(result[1]?.name).toBe('Ethereum'); + }); + + it('returns provider data unchanged when terminal API fails', async () => { + mockTerminalService.fetchMarkets.mockRejectedValue( + new Error('Network error'), + ); + mockProvider.getMarketDataWithPrices.mockResolvedValue( + providerMarketData, + ); + + const result = await serviceWithTerminal.getMarketDataWithPrices({ + provider: mockProvider, + params: { useTerminalApi: true }, + context: mockContext, + }); + + expect(result[0]?.name).toBe('BTC'); + expect(result[0]?.keywords).toBeUndefined(); + expect(mockTerminalService.logError).toHaveBeenCalled(); + }); + + it('returns provider data unchanged when flag is disabled', async () => { + mockProvider.getMarketDataWithPrices.mockResolvedValue( + providerMarketData, + ); + + const result = await serviceWithTerminal.getMarketDataWithPrices({ + provider: mockProvider, + params: { useTerminalApi: false }, + context: mockContext, + }); + + expect(result[0]?.name).toBe('BTC'); + expect(mockTerminalService.fetchMarkets).not.toHaveBeenCalled(); + }); + + it('merges listedAt from terminal metadata onto market data', async () => { + const listedAtMs = 1_700_000_000_000; + const metadataWithListedAt = new Map([ + ['BTC', { name: 'Bitcoin', listedAt: listedAtMs }], + ['ETH', { name: 'Ethereum' }], + ]); + mockTerminalService.fetchMarkets.mockResolvedValue({ + markets: terminalMarkets, + metadata: metadataWithListedAt, + }); + mockProvider.getMarketDataWithPrices.mockResolvedValue( + providerMarketData, + ); + + const result = await serviceWithTerminal.getMarketDataWithPrices({ + provider: mockProvider, + params: { useTerminalApi: true }, + context: mockContext, + }); + + expect(result[0]?.listedAt).toBe(listedAtMs); + expect(result[1]?.listedAt).toBeUndefined(); + }); + }); + }); +}); diff --git a/packages/perps-controller/tests/src/services/RewardsIntegrationService.test.ts b/packages/perps-controller/tests/src/services/RewardsIntegrationService.test.ts new file mode 100644 index 00000000000..3e9ae3b1f17 --- /dev/null +++ b/packages/perps-controller/tests/src/services/RewardsIntegrationService.test.ts @@ -0,0 +1,757 @@ +import { RewardsIntegrationService } from '../../../src/services/RewardsIntegrationService.js'; +import type { PerpsPlatformDependencies } from '../../../src/types/index.js'; +/* eslint-disable */ +import { + createMockEvmAccount, + createMockInfrastructure, + createMockMessenger, +} from '../../helpers/serviceMocks.js'; + +describe('RewardsIntegrationService', () => { + let mockDeps: jest.Mocked; + let mockMessenger: ReturnType; + let service: RewardsIntegrationService; + const mockEvmAccount = createMockEvmAccount(); + + /** + * Helper to set up mockMessenger.call with standard defaults, + * plus optional overrides for specific actions. + */ + const setupMessengerDefaults = (overrides: Record = {}) => { + (mockMessenger.call as jest.Mock).mockImplementation( + (action: string, ...args: unknown[]) => { + if (action in overrides) { + const val = overrides[action]; + return typeof val === 'function' + ? (val as (...a: unknown[]) => unknown)(...args) + : val; + } + if ( + action === 'AccountTreeController:getAccountsFromSelectedAccountGroup' + ) { + return [mockEvmAccount]; + } + if (action === 'NetworkController:getState') { + return { selectedNetworkClientId: 'mainnet' }; + } + if (action === 'NetworkController:getNetworkClientById') { + return { configuration: { chainId: '0x1' } }; + } + return undefined; + }, + ); + }; + + beforeEach(() => { + mockDeps = createMockInfrastructure(); + mockMessenger = createMockMessenger(); + service = new RewardsIntegrationService(mockDeps, mockMessenger); + + jest.clearAllMocks(); + }); + + afterEach(() => { + jest.resetAllMocks(); + }); + + describe('calculateUserFeeDiscount', () => { + it('calculates fee discount successfully with valid discount', async () => { + const mockDiscountBips = 6500; // 65% + + setupMessengerDefaults(); + ( + mockDeps.rewards.getPerpsDiscountForAccount as jest.Mock + ).mockResolvedValue(mockDiscountBips); + + const result = await service.calculateUserFeeDiscount(); + + expect(result).toBe(6500); + expect(mockDeps.rewards.getPerpsDiscountForAccount).toHaveBeenCalledWith( + expect.stringMatching(/^eip155:1:0x/), + 10, + ); + expect(mockDeps.debugLogger.log).toHaveBeenCalledWith( + 'RewardsIntegrationService: Fee discount calculated', + expect.objectContaining({ + discountBips: 6500, + discountPercentage: 65, + }), + ); + }); + + it('returns 0 when no discount available', async () => { + setupMessengerDefaults(); + ( + mockDeps.rewards.getPerpsDiscountForAccount as jest.Mock + ).mockResolvedValue(0); + + const result = await service.calculateUserFeeDiscount(); + + expect(result).toBe(0); + }); + + it('returns undefined when rewards subscription state has not hydrated yet', async () => { + setupMessengerDefaults(); + ( + mockDeps.rewards.getPerpsDiscountForAccount as jest.Mock + ).mockResolvedValue(null); + + const result = await service.calculateUserFeeDiscount(); + + expect(result).toBeUndefined(); + expect(mockDeps.debugLogger.log).toHaveBeenCalledWith( + 'RewardsIntegrationService: Fee discount unavailable (subscription state not hydrated)', + expect.objectContaining({ + caipAccountId: expect.any(String), + }), + ); + }); + + it('returns undefined when no EVM account found', async () => { + setupMessengerDefaults({ + 'AccountTreeController:getAccountsFromSelectedAccountGroup': [], + }); + + const result = await service.calculateUserFeeDiscount(); + + expect(result).toBeUndefined(); + expect(mockDeps.debugLogger.log).toHaveBeenCalledWith( + 'RewardsIntegrationService: No EVM account found for fee discount', + ); + expect( + mockDeps.rewards.getPerpsDiscountForAccount, + ).not.toHaveBeenCalled(); + }); + + it('returns undefined when chain ID not found', async () => { + setupMessengerDefaults({ + 'NetworkController:getNetworkClientById': () => { + throw new Error('Network client not found'); + }, + }); + + const result = await service.calculateUserFeeDiscount(); + + expect(result).toBeUndefined(); + expect( + mockDeps.rewards.getPerpsDiscountForAccount, + ).not.toHaveBeenCalled(); + }); + + it('returns undefined when getFeeDiscount throws error', async () => { + const mockError = new Error('Rewards API error'); + + setupMessengerDefaults(); + ( + mockDeps.rewards.getPerpsDiscountForAccount as jest.Mock + ).mockRejectedValue(mockError); + + const result = await service.calculateUserFeeDiscount(); + + expect(result).toBeUndefined(); + expect(mockDeps.logger.error).toHaveBeenCalledWith( + mockError, + expect.objectContaining({ + context: expect.objectContaining({ + name: 'RewardsIntegrationService.calculateUserFeeDiscount', + }), + }), + ); + }); + + it('returns undefined when NetworkController throws error', async () => { + const mockError = new Error('Network error'); + + setupMessengerDefaults({ + 'NetworkController:getState': () => { + throw mockError; + }, + }); + + const result = await service.calculateUserFeeDiscount(); + + expect(result).toBeUndefined(); + expect(mockDeps.logger.error).toHaveBeenCalled(); + }); + + it('handles different chain IDs correctly', async () => { + const chains = [ + { chainId: '0x1', name: 'Mainnet' }, + { chainId: '0x89', name: 'Polygon' }, + { chainId: '0xa4b1', name: 'Arbitrum' }, + ]; + + for (const chain of chains) { + jest.clearAllMocks(); + mockDeps = createMockInfrastructure(); + mockMessenger = createMockMessenger(); + service = new RewardsIntegrationService(mockDeps, mockMessenger); + + (mockMessenger.call as jest.Mock).mockImplementation( + (action: string) => { + if ( + action === + 'AccountTreeController:getAccountsFromSelectedAccountGroup' + ) { + return [mockEvmAccount]; + } + if (action === 'NetworkController:getState') { + return { selectedNetworkClientId: chain.name.toLowerCase() }; + } + if (action === 'NetworkController:getNetworkClientById') { + return { configuration: { chainId: chain.chainId } }; + } + return undefined; + }, + ); + ( + mockDeps.rewards.getPerpsDiscountForAccount as jest.Mock + ).mockResolvedValue(5000); + + const result = await service.calculateUserFeeDiscount(); + + expect(result).toBe(5000); + } + }); + + it('calculates discount percentage correctly in logs', async () => { + const testCases = [ + { bips: 6500, percentage: 65 }, + { bips: 5000, percentage: 50 }, + { bips: 2500, percentage: 25 }, + { bips: 1000, percentage: 10 }, + { bips: 0, percentage: 0 }, + ]; + + for (const testCase of testCases) { + jest.clearAllMocks(); + + setupMessengerDefaults(); + ( + mockDeps.rewards.getPerpsDiscountForAccount as jest.Mock + ).mockResolvedValue(testCase.bips); + + await service.calculateUserFeeDiscount(); + + expect(mockDeps.debugLogger.log).toHaveBeenCalledWith( + 'RewardsIntegrationService: Fee discount calculated', + expect.objectContaining({ + discountBips: testCase.bips, + discountPercentage: testCase.percentage, + }), + ); + } + }); + }); + + describe('unified fee resolver', () => { + // 10 bips = BUILDER_FEE_CONFIG.MaxFeeDecimal (0.001) * BASIS_POINTS_DIVISOR + const DEFAULT_FEE_BIPS = 10; + const FRESH_MS = 60_000; + const MAX_STALE_MS = 10 * 60 * 1000; + const NOW = 1_700_000_000_000; + + /** + * Build a benefits payload that passes the eligibility gate by default. + * + * @param waiverOverrides - Fields to override on `perpsFeeWaiver`. + * @param overrides - Fields to override on the benefits payload itself. + * @returns A benefits payload. + */ + const createBenefits = ( + waiverOverrides: Record = {}, + overrides: Record = {}, + ) => + ({ + status: 'active', + perpsFeeWaiver: { + entitled: true, + usage: 'available', + remainingNotionalUsd: 5000, + ...waiverOverrides, + }, + ...overrides, + }) as never; + + /** + * Wire a subscription benefits source onto the mocked dependencies. + * + * @param getPerpsBenefits - The mocked benefits reader. + * @returns The same mock, for convenience. + */ + const wireSubscription = (getPerpsBenefits: jest.Mock) => { + (mockDeps as { subscription?: unknown }).subscription = { + getPerpsBenefits, + }; + return getPerpsBenefits; + }; + + beforeEach(() => { + jest.useFakeTimers(); + jest.setSystemTime(NOW); + setupMessengerDefaults(); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + it('returns the lowest fee bips across the default, rewards and subscription sources', async () => { + // Rewards unresolved and no subscription source: nothing beats the + // default fee, and the discount stays undefined (not "no discount"). + ( + mockDeps.rewards.getPerpsDiscountForAccount as jest.Mock + ).mockResolvedValue(null); + expect(await service.resolveFee()).toMatchObject({ + feeBips: DEFAULT_FEE_BIPS, + discountBips: undefined, + source: 'default', + }); + + // A resolved 0% rewards discount still wins the tie over `default`, so a + // known "no discount" answer stays distinguishable from an unknown one. + ( + mockDeps.rewards.getPerpsDiscountForAccount as jest.Mock + ).mockResolvedValue(0); + expect(await service.resolveFee()).toMatchObject({ + feeBips: DEFAULT_FEE_BIPS, + discountBips: 0, + source: 'rewards', + }); + + // A 65% VIP/season discount undercuts the default fee. + ( + mockDeps.rewards.getPerpsDiscountForAccount as jest.Mock + ).mockResolvedValue(6500); + expect(await service.resolveFee()).toMatchObject({ + feeBips: 3.5, + discountBips: 6500, + source: 'rewards', + }); + + // Subscription undercuts everything once the cached gate passes. + const getPerpsBenefits = wireSubscription( + jest.fn().mockResolvedValue(createBenefits()), + ); + await service.refreshSubscriptionBenefits(); + expect(getPerpsBenefits).toHaveBeenCalledTimes(1); + expect(await service.resolveFee()).toMatchObject({ + feeBips: 0, + discountBips: 10000, + source: 'subscription', + }); + + // ...including when the rewards source has not hydrated at all. + ( + mockDeps.rewards.getPerpsDiscountForAccount as jest.Mock + ).mockResolvedValue(null); + expect(await service.resolveFee()).toMatchObject({ + feeBips: 0, + discountBips: 10000, + source: 'subscription', + }); + }); + + it('resolves the subscription source to a 0 bips fee only when the eligibility gate passes', async () => { + const cases = [ + { benefits: createBenefits(), eligible: true, reason: 'eligible' }, + { + benefits: createBenefits({}, { status: 'canceled' }), + eligible: false, + reason: 'inactive', + }, + { + benefits: createBenefits({ entitled: false }), + eligible: false, + reason: 'not-entitled', + }, + { + benefits: createBenefits({ usage: undefined }), + eligible: false, + reason: 'not-entitled', + }, + { + benefits: createBenefits({ usage: 'exhausted' }), + eligible: false, + reason: 'exhausted', + }, + { + benefits: createBenefits({ exhausted: true }), + eligible: false, + reason: 'exhausted', + }, + // `null` is "no subscription to report", not "subscription inactive". + { benefits: null, eligible: false, reason: 'no-subscription' }, + ]; + + ( + mockDeps.rewards.getPerpsDiscountForAccount as jest.Mock + ).mockResolvedValue(0); + + for (const testCase of cases) { + mockDeps = createMockInfrastructure(); + mockMessenger = createMockMessenger(); + setupMessengerDefaults(); + ( + mockDeps.rewards.getPerpsDiscountForAccount as jest.Mock + ).mockResolvedValue(0); + wireSubscription(jest.fn().mockResolvedValue(testCase.benefits)); + service = new RewardsIntegrationService(mockDeps, mockMessenger); + await service.refreshSubscriptionBenefits(); + + const resolution = await service.resolveFee(); + + expect(resolution.subscription).toStrictEqual( + expect.objectContaining({ + eligible: testCase.eligible, + reason: testCase.reason, + }), + ); + expect(resolution.source).toBe( + testCase.eligible ? 'subscription' : 'rewards', + ); + expect(resolution.feeBips).toBe( + testCase.eligible ? 0 : DEFAULT_FEE_BIPS, + ); + } + }); + + it('does not start a benefits network read on the fee resolution path', async () => { + const getPerpsBenefits = wireSubscription( + jest.fn().mockResolvedValue(createBenefits()), + ); + ( + mockDeps.rewards.getPerpsDiscountForAccount as jest.Mock + ).mockResolvedValue(2500); + + const resolution = await service.resolveFee(); + + expect(resolution.source).toBe('rewards'); + expect(resolution.discountBips).toBe(2500); + expect(resolution.subscription).toStrictEqual({ + eligible: false, + reason: 'not-hydrated', + }); + expect(getPerpsBenefits).not.toHaveBeenCalled(); + }); + + it('serves a stale snapshot without refreshing on the cache-read path', async () => { + const getPerpsBenefits = wireSubscription( + jest.fn().mockResolvedValue(createBenefits()), + ); + await service.refreshSubscriptionBenefits(); + expect(getPerpsBenefits).toHaveBeenCalledTimes(1); + + // Inside the freshness window: served from cache, no revalidation. + jest.setSystemTime(NOW + FRESH_MS - 1); + expect(service.getSubscriptionFeeWaiverStatus()).toStrictEqual({ + eligible: true, + reason: 'eligible', + remainingNotionalUsd: 5000, + }); + expect(getPerpsBenefits).toHaveBeenCalledTimes(1); + + // Past it: the stale snapshot is still served without a request. + jest.setSystemTime(NOW + FRESH_MS + 1); + expect(service.getSubscriptionFeeWaiverStatus()).toStrictEqual({ + eligible: true, + reason: 'eligible', + remainingNotionalUsd: 5000, + }); + expect(getPerpsBenefits).toHaveBeenCalledTimes(1); + + // Preview/lifecycle hydration owns the refresh explicitly. + await service.refreshSubscriptionBenefits(); + expect(getPerpsBenefits).toHaveBeenCalledTimes(2); + }); + + it('falls back to the next-lowest source when the cached benefits snapshot is hard-stale', async () => { + const getPerpsBenefits = wireSubscription( + jest.fn().mockResolvedValue(createBenefits()), + ); + ( + mockDeps.rewards.getPerpsDiscountForAccount as jest.Mock + ).mockResolvedValue(6500); + await service.refreshSubscriptionBenefits(); + + // Beyond the ceiling the snapshot can no longer be trusted to grant the + // waiver, even though it says the cap is available. + jest.setSystemTime(NOW + MAX_STALE_MS + 1); + getPerpsBenefits.mockImplementation( + async () => new Promise(() => undefined), + ); + + const resolution = await service.resolveFee(); + + expect(resolution.subscription).toStrictEqual({ + eligible: false, + reason: 'stale', + }); + expect(resolution.source).toBe('rewards'); + expect(resolution.feeBips).toBe(3.5); + }); + + it('falls back to the next-lowest source when the benefits read is unreachable', async () => { + wireSubscription( + jest.fn().mockRejectedValue(new Error('benefits endpoint unreachable')), + ); + ( + mockDeps.rewards.getPerpsDiscountForAccount as jest.Mock + ).mockResolvedValue(6500); + + // The refresh swallows the failure rather than rejecting into callers. + await expect( + service.refreshSubscriptionBenefits(), + ).resolves.toBeUndefined(); + + const resolution = await service.resolveFee(); + + expect(resolution.subscription).toStrictEqual({ + eligible: false, + reason: 'not-hydrated', + }); + expect(resolution.source).toBe('rewards'); + expect(resolution.discountBips).toBe(6500); + expect(mockDeps.logger.error).toHaveBeenCalled(); + }); + + it('honors exhausted=true from the backend on the next cache refresh', async () => { + const getPerpsBenefits = wireSubscription( + jest.fn().mockResolvedValue(createBenefits()), + ); + ( + mockDeps.rewards.getPerpsDiscountForAccount as jest.Mock + ).mockResolvedValue(0); + await service.refreshSubscriptionBenefits(); + expect(await service.resolveFee()).toMatchObject({ + source: 'subscription', + feeBips: 0, + }); + + // The backend crosses the cap. No client-side release is needed: the + // next refresh simply stops passing the gate. + getPerpsBenefits.mockResolvedValue( + createBenefits({ exhausted: true, remainingNotionalUsd: 0 }), + ); + jest.setSystemTime(NOW + FRESH_MS + 1); + await service.refreshSubscriptionBenefits(); + + const resolution = await service.resolveFee(); + + expect(resolution.subscription).toStrictEqual({ + eligible: false, + reason: 'exhausted', + remainingNotionalUsd: 0, + }); + expect(resolution.source).toBe('rewards'); + expect(resolution.feeBips).toBe(DEFAULT_FEE_BIPS); + expect(resolution.discountBips).toBe(0); + }); + + it('reports no subscription source when the dependency is not wired', async () => { + ( + mockDeps.rewards.getPerpsDiscountForAccount as jest.Mock + ).mockResolvedValue(0); + + const resolution = await service.resolveFee(); + + expect(resolution.subscription).toStrictEqual({ + eligible: false, + reason: 'no-source', + }); + expect(resolution.source).toBe('rewards'); + }); + + it('deduplicates concurrent benefits refreshes', async () => { + const getPerpsBenefits = wireSubscription( + jest.fn().mockResolvedValue(createBenefits()), + ); + + await Promise.all([ + service.refreshSubscriptionBenefits(), + service.refreshSubscriptionBenefits(), + service.refreshSubscriptionBenefits(), + ]); + + expect(getPerpsBenefits).toHaveBeenCalledTimes(1); + }); + + it('keeps pure cache reads off the network after a failed refresh', async () => { + // A failing read never advances the snapshot timestamp, so without an + // attempt-based throttle every caller would start a new request. + const getPerpsBenefits = wireSubscription( + jest.fn().mockRejectedValue(new Error('benefits endpoint down')), + ); + + await service.refreshSubscriptionBenefits(); + expect(getPerpsBenefits).toHaveBeenCalledTimes(1); + + // Ten fee previews inside the freshness window: still one request. + for (let i = 0; i < 10; i++) { + expect(service.getSubscriptionFeeWaiverStatus()).toStrictEqual({ + eligible: false, + reason: 'not-hydrated', + }); + } + expect(getPerpsBenefits).toHaveBeenCalledTimes(1); + + // Past the window, cache reads still cannot retry on their own. + jest.setSystemTime(NOW + FRESH_MS + 1); + service.getSubscriptionFeeWaiverStatus(); + service.getSubscriptionFeeWaiverStatus(); + expect(getPerpsBenefits).toHaveBeenCalledTimes(1); + + await service.refreshSubscriptionBenefits(); + expect(getPerpsBenefits).toHaveBeenCalledTimes(2); + }); + + it('invalidates the cached benefits snapshot on demand', async () => { + const getPerpsBenefits = wireSubscription( + jest.fn().mockResolvedValue(createBenefits()), + ); + await service.refreshSubscriptionBenefits(); + expect(service.getSubscriptionFeeWaiverStatus().eligible).toBe(true); + + // Sign-out / profile switch: the snapshot must stop answering for the + // previous profile immediately, not at the next freshness boundary. + service.invalidateSubscriptionBenefits(); + + expect(service.getSubscriptionFeeWaiverStatus()).toStrictEqual({ + eligible: false, + reason: 'not-hydrated', + }); + expect(getPerpsBenefits).toHaveBeenCalledTimes(1); + await service.refreshSubscriptionBenefits(); + expect(getPerpsBenefits).toHaveBeenCalledTimes(2); + }); + + it('discards an in-flight benefits read that resolves after invalidation', async () => { + // Profile A's read is still in flight when the client signs out. Without + // an epoch fence it would repopulate the cache — and mark it fresh — + // granting profile A's waiver to profile B. + let releaseProfileA: (value: unknown) => void = () => undefined; + const getPerpsBenefits = wireSubscription( + jest.fn( + async () => + new Promise((resolve) => { + releaseProfileA = resolve; + }), + ), + ); + + const inFlight = service.refreshSubscriptionBenefits(); + service.invalidateSubscriptionBenefits(); + releaseProfileA(createBenefits()); + await inFlight; + + expect(service.getSubscriptionFeeWaiverStatus()).toStrictEqual({ + eligible: false, + reason: 'not-hydrated', + }); + expect(getPerpsBenefits).toHaveBeenCalledTimes(1); + }); + + it('starts a fresh read when the next caller arrives while a fenced read is still in flight', async () => { + // Profile A's read is still in flight at sign-out, so the epoch fence can + // only discard it. Deduping profile B onto it would leave the cache + // unhydrated instead of fetching for the new identity. + const releases: ((value: unknown) => void)[] = []; + const getPerpsBenefits = wireSubscription( + jest.fn( + async () => + new Promise((resolve) => { + releases.push(resolve); + }), + ), + ); + + const profileARead = service.refreshSubscriptionBenefits(); + service.invalidateSubscriptionBenefits(); + + // Status remains a pure read after invalidation. + expect(service.getSubscriptionFeeWaiverStatus()).toStrictEqual({ + eligible: false, + reason: 'not-hydrated', + }); + expect(getPerpsBenefits).toHaveBeenCalledTimes(1); + + // Preview/lifecycle hydration starts profile B's independent read. + const profileBRead = service.refreshSubscriptionBenefits(); + expect(getPerpsBenefits).toHaveBeenCalledTimes(2); + releases.forEach((release) => release(createBenefits())); + await Promise.all([profileARead, profileBRead]); + + expect(getPerpsBenefits).toHaveBeenCalledTimes(2); + expect(service.getSubscriptionFeeWaiverStatus().eligible).toBe(true); + }); + + it('uses a background refresh that lands during the rewards round trip', async () => { + const getPerpsBenefits = wireSubscription( + jest.fn().mockResolvedValue(createBenefits()), + ); + // The rewards read resolves only after the benefits refresh has landed, + // which is exactly the window a pre-await snapshot would miss. + ( + mockDeps.rewards.getPerpsDiscountForAccount as jest.Mock + ).mockImplementation(async () => { + await service.refreshSubscriptionBenefits(); + return 6500; + }); + + const resolution = await service.resolveFee(); + + expect(getPerpsBenefits).toHaveBeenCalled(); + expect(resolution.subscription.eligible).toBe(true); + expect(resolution.source).toBe('subscription'); + expect(resolution.feeBips).toBe(0); + }); + + it('keeps calculateUserFeeDiscount returning the resolved discount bips', async () => { + wireSubscription(jest.fn().mockResolvedValue(createBenefits())); + ( + mockDeps.rewards.getPerpsDiscountForAccount as jest.Mock + ).mockResolvedValue(6500); + await service.refreshSubscriptionBenefits(); + + expect(await service.calculateUserFeeDiscount()).toBe(10000); + }); + }); + + describe('instance isolation', () => { + it('each instance uses its own deps', async () => { + const mockDeps2 = createMockInfrastructure(); + const mockMessenger2 = createMockMessenger(); + const service2 = new RewardsIntegrationService(mockDeps2, mockMessenger2); + + // First service - no EVM account + (mockMessenger.call as jest.Mock).mockImplementation((action: string) => { + if ( + action === 'AccountTreeController:getAccountsFromSelectedAccountGroup' + ) { + return []; + } + return undefined; + }); + await service.calculateUserFeeDiscount(); + + // Second service - no EVM account + (mockMessenger2.call as jest.Mock).mockImplementation( + (action: string) => { + if ( + action === + 'AccountTreeController:getAccountsFromSelectedAccountGroup' + ) { + return []; + } + return undefined; + }, + ); + await service2.calculateUserFeeDiscount(); + + // Each instance should use its own logger: one "no account" log plus the + // resolver's outcome log. + expect(mockDeps.debugLogger.log).toHaveBeenCalledTimes(2); + expect(mockDeps2.debugLogger.log).toHaveBeenCalledTimes(2); + }); + }); +}); diff --git a/packages/perps-controller/tests/src/services/TerminalMarketService.test.ts b/packages/perps-controller/tests/src/services/TerminalMarketService.test.ts new file mode 100644 index 00000000000..a0341d04a24 --- /dev/null +++ b/packages/perps-controller/tests/src/services/TerminalMarketService.test.ts @@ -0,0 +1,1119 @@ +import { TERMINAL_API_CONFIG } from '../../../src/constants/perpsConfig.js'; +import { TerminalMarketService } from '../../../src/services/TerminalMarketService.js'; +import type { PerpsPlatformDependencies } from '../../../src/types/index.js'; +import { createMockInfrastructure } from '../../helpers/serviceMocks.js'; + +const SNAPSHOT_NOW = 1_700_000_030_000; +const HUGE_FINITE_DECIMAL = `1${'0'.repeat(308)}`; + +const createSnapshotMarket = ( + overrides: Record = {}, +): Record => ({ + symbol: 'BTC', + provider: 'hyperliquid', + dex: 'main', + name: 'Bitcoin', + description: 'Original cryptocurrency', + iconUrl: 'https://example.com/btc.png', + szDecimals: 5, + maxLeverage: 50, + markPrice: '50000', + price: '50000', + midPrice: '50001', + oraclePrice: '49999', + change24h: '125', + changePercent24h: 0.25, + funding: '0.0001', + volume24h: '1000000', + openInterest: '1000000', + category: 'crypto', + keywords: ['bitcoin'], + tags: ['top-10'], + listedAt: 1_600_000_000_000, + trend: [ + [SNAPSHOT_NOW - 3_600_000, '49000'], + [SNAPSHOT_NOW - 1_000, '50000'], + ], + ...overrides, +}); + +const createGlobalSnapshot = ( + overrides: Record = {}, +): Record => ({ + schemaVersion: 2, + provider: 'hyperliquid', + network: 'mainnet', + enabledDexes: ['main'], + fingerprint: + 'sha256:21c2aec213ce0cf6c0d8624570abfe1a07dd68f7ee2f4e07e9fe2785d3d0212c', + generatedAt: SNAPSHOT_NOW - 1_000, + receivedAt: SNAPSHOT_NOW - 2_000, + maxAgeMs: 60_000, + complete: true, + perDexErrors: [], + markets: [createSnapshotMarket()], + ...overrides, +}); + +const okJsonResponse = (body: unknown): Response => + ({ + ok: true, + status: 200, + statusText: 'OK', + json: () => Promise.resolve(body), + text: () => Promise.resolve(JSON.stringify(body)), + }) as Response; + +describe('TerminalMarketService', () => { + let mockDeps: jest.Mocked; + let service: TerminalMarketService; + + const mockApiResponse = [ + { + symbol: 'BTC', + name: 'Bitcoin', + description: 'The original cryptocurrency and largest by market cap.', + szDecimals: 5, + maxLeverage: 50, + marginTableId: 0, + keywords: ['crypto', 'layer-1'], + tags: ['top-10'], + categories: ['crypto'], + marketType: 'crypto', + }, + { + symbol: 'ETH', + name: 'Ethereum', + szDecimals: 4, + maxLeverage: 25, + marginTableId: 1, + keywords: ['defi', 'layer-1'], + }, + { + symbol: 'xyz:TSLA', + name: 'Tesla', + szDecimals: 2, + maxLeverage: 5, + marginTableId: 2, + onlyIsolated: true, + marketType: 'stock', + tags: ['us-equities'], + categories: ['stock'], + }, + ]; + + beforeEach(() => { + mockDeps = createMockInfrastructure(); + service = new TerminalMarketService(mockDeps); + jest.clearAllMocks(); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + describe('fetchMarkets', () => { + it('fetches and maps markets successfully', async () => { + jest.spyOn(globalThis, 'fetch').mockResolvedValue({ + ok: true, + status: 200, + statusText: 'OK', + json: () => Promise.resolve(mockApiResponse), + } as Response); + + const { markets, metadata } = await service.fetchMarkets(); + + expect(globalThis.fetch).toHaveBeenCalledWith( + 'https://terminal.test-api.cx.metamask.io/v1/perpetuals', + expect.objectContaining({ + method: 'GET', + signal: expect.any(AbortSignal), + }), + ); + + expect(markets).toHaveLength(3); + expect(markets[0]).toStrictEqual({ + name: 'BTC', + szDecimals: 5, + maxLeverage: 50, + marginTableId: 0, + }); + expect(markets[2]).toStrictEqual({ + name: 'xyz:TSLA', + szDecimals: 2, + maxLeverage: 5, + marginTableId: 2, + onlyIsolated: true, + }); + + expect(metadata.size).toBe(3); + expect(metadata.get('BTC')).toStrictEqual({ + name: 'Bitcoin', + description: 'The original cryptocurrency and largest by market cap.', + keywords: ['crypto', 'layer-1'], + tags: ['top-10'], + categories: ['crypto'], + marketType: 'crypto', + }); + expect(metadata.get('ETH')).toStrictEqual({ + name: 'Ethereum', + keywords: ['defi', 'layer-1'], + }); + expect(metadata.get('xyz:TSLA')).toStrictEqual({ + name: 'Tesla', + marketType: 'stock', + tags: ['us-equities'], + categories: ['stock'], + }); + }); + + it('uses the full marketDataUrl without path concatenation', async () => { + mockDeps.terminalApi = { + marketDataUrl: 'https://terminal.api.cx.metamask.io/v1/perpetuals', + }; + + jest.spyOn(globalThis, 'fetch').mockResolvedValue({ + ok: true, + status: 200, + statusText: 'OK', + json: () => Promise.resolve([]), + } as Response); + + await service.fetchMarkets(); + + expect(globalThis.fetch).toHaveBeenCalledWith( + 'https://terminal.api.cx.metamask.io/v1/perpetuals', + expect.any(Object), + ); + }); + + it('throws on non-2xx response', async () => { + jest.spyOn(globalThis, 'fetch').mockResolvedValue({ + ok: false, + status: 500, + statusText: 'Internal Server Error', + json: () => Promise.resolve({}), + } as Response); + + await expect(service.fetchMarkets()).rejects.toThrow( + 'Terminal API returned 500: Internal Server Error', + ); + }); + + it('throws on non-array response body', async () => { + jest.spyOn(globalThis, 'fetch').mockResolvedValue({ + ok: true, + status: 200, + statusText: 'OK', + json: () => Promise.resolve({ data: [] }), + } as Response); + + await expect(service.fetchMarkets()).rejects.toThrow( + 'Terminal API returned non-array body: object', + ); + }); + + it('throws on network error', async () => { + jest + .spyOn(globalThis, 'fetch') + .mockRejectedValue(new Error('Network request failed')); + + await expect(service.fetchMarkets()).rejects.toThrow( + 'Network request failed', + ); + }); + + it('aborts fetch when timeout elapses', async () => { + jest.useFakeTimers(); + + jest.spyOn(globalThis, 'fetch').mockImplementation( + (_url, init) => + new Promise((_resolve, reject) => { + (init?.signal as AbortSignal)?.addEventListener('abort', () => { + const { reason } = init?.signal as AbortSignal; + reject( + reason instanceof Error ? reason : new Error(String(reason)), + ); + }); + }), + ); + + const promise = service.fetchMarkets(); + + jest.advanceTimersByTime(TERMINAL_API_CONFIG.FetchTimeoutMs); + + await expect(promise).rejects.toThrow('Terminal API fetch timed out'); + + jest.useRealTimers(); + }); + + it('returns empty arrays for empty API response', async () => { + jest.spyOn(globalThis, 'fetch').mockResolvedValue({ + ok: true, + status: 200, + statusText: 'OK', + json: () => Promise.resolve([]), + } as Response); + + const { markets, metadata } = await service.fetchMarkets(); + + expect(markets).toHaveLength(0); + expect(metadata.size).toBe(0); + }); + + it('filters out items with missing or empty symbol', async () => { + jest.spyOn(globalThis, 'fetch').mockResolvedValue({ + ok: true, + status: 200, + statusText: 'OK', + json: () => + Promise.resolve([ + { symbol: '', name: 'Empty' }, + { symbol: 'VALID', name: 'Valid' }, + ]), + } as Response); + + const { markets, metadata } = await service.fetchMarkets(); + + expect(markets).toHaveLength(1); + expect(markets[0]?.name).toBe('VALID'); + expect(metadata.size).toBe(1); + expect(metadata.has('VALID')).toBe(true); + }); + + it('filters out items that fail schema validation and logs errors', async () => { + jest.spyOn(globalThis, 'fetch').mockResolvedValue({ + ok: true, + status: 200, + statusText: 'OK', + json: () => + Promise.resolve([ + { symbol: 123 }, + { name: 'NoSymbol' }, + 'not-an-object', + { symbol: 'VALID', name: 'Valid' }, + ]), + } as Response); + + const { markets, metadata } = await service.fetchMarkets(); + + expect(markets).toHaveLength(1); + expect(markets[0]?.name).toBe('VALID'); + expect(metadata.size).toBe(1); + expect(mockDeps.logger.error).toHaveBeenCalledTimes(3); + expect(mockDeps.logger.error).toHaveBeenCalledWith( + expect.objectContaining({ + message: 'Terminal API item failed schema validation', + }), + expect.objectContaining({ + tags: { feature: 'perps', source: 'terminal-api' }, + context: expect.objectContaining({ + name: 'TerminalMarketService.validateItems', + }), + }), + ); + }); + + it('accepts items with extra properties returned by the backend', async () => { + jest.spyOn(globalThis, 'fetch').mockResolvedValue({ + ok: true, + status: 200, + statusText: 'OK', + json: () => + Promise.resolve([ + { + symbol: 'BTC', + name: 'Bitcoin', + szDecimals: 5, + maxLeverage: 50, + marginTableId: 0, + // Extra properties not in the schema + price: 67000.5, + iconUrl: 'https://example.com/btc.png', + trend: 'bullish', + volume24h: 1234567890, + sparklineData: [65000, 66000, 67000], + }, + ]), + } as Response); + + const { markets, metadata } = await service.fetchMarkets(); + + expect(markets).toHaveLength(1); + expect(markets[0]).toStrictEqual({ + name: 'BTC', + szDecimals: 5, + maxLeverage: 50, + marginTableId: 0, + }); + expect(metadata.get('BTC')?.name).toBe('Bitcoin'); + expect(mockDeps.logger.error).not.toHaveBeenCalled(); + }); + + it('accepts only known MarketCategory values as marketType', async () => { + jest.spyOn(globalThis, 'fetch').mockResolvedValue({ + ok: true, + status: 200, + statusText: 'OK', + json: () => + Promise.resolve([ + { symbol: 'BTC', name: 'Bitcoin', marketType: 'crypto' }, + { symbol: 'TSLA', name: 'Tesla', marketType: 'stock' }, + { symbol: 'MEME', name: 'MemeCoin', marketType: 'meme' }, + { symbol: 'FOO', name: 'Foo', marketType: '' }, + ]), + } as Response); + + const { metadata } = await service.fetchMarkets(); + + expect(metadata.get('BTC')?.marketType).toBe('crypto'); + expect(metadata.get('TSLA')?.marketType).toBe('stock'); + expect(metadata.get('MEME')?.marketType).toBeUndefined(); + expect(metadata.get('FOO')?.marketType).toBeUndefined(); + }); + + it('uses defaults for missing numeric fields', async () => { + jest.spyOn(globalThis, 'fetch').mockResolvedValue({ + ok: true, + status: 200, + statusText: 'OK', + json: () => Promise.resolve([{ symbol: 'FOO' }]), + } as Response); + + const { markets } = await service.fetchMarkets(); + + expect(markets[0]).toStrictEqual({ + name: 'FOO', + szDecimals: 0, + maxLeverage: 1, + marginTableId: 0, + }); + }); + + it('omits name from metadata when Terminal does not supply one', async () => { + jest.spyOn(globalThis, 'fetch').mockResolvedValue({ + ok: true, + status: 200, + statusText: 'OK', + json: () => Promise.resolve([{ symbol: 'UNKNOWN' }]), + } as Response); + + const { metadata } = await service.fetchMarkets(); + + expect(metadata.get('UNKNOWN')?.name).toBeUndefined(); + }); + + it('captures description when Terminal supplies one', async () => { + jest.spyOn(globalThis, 'fetch').mockResolvedValue({ + ok: true, + status: 200, + statusText: 'OK', + json: () => + Promise.resolve([ + { + symbol: 'ETH', + name: 'Ethereum', + description: 'The leading smart contract platform.', + }, + ]), + } as Response); + + const { metadata } = await service.fetchMarkets(); + + expect(metadata.get('ETH')?.description).toBe( + 'The leading smart contract platform.', + ); + }); + + it('omits description when Terminal supplies null or empty', async () => { + jest.spyOn(globalThis, 'fetch').mockResolvedValue({ + ok: true, + status: 200, + statusText: 'OK', + json: () => + Promise.resolve([ + { symbol: 'ORBS', name: 'Orbs', description: null }, + { symbol: 'FOO', name: 'Foo', description: '' }, + { symbol: 'BAR', name: 'Bar' }, + ]), + } as Response); + + const { metadata } = await service.fetchMarkets(); + + expect(metadata.get('ORBS')?.description).toBeUndefined(); + expect(metadata.get('FOO')?.description).toBeUndefined(); + expect(metadata.get('BAR')?.description).toBeUndefined(); + }); + + describe('listedAt handling', () => { + it('passes through a numeric listedAt as-is', async () => { + const epochMs = 1_700_000_000_000; + jest.spyOn(globalThis, 'fetch').mockResolvedValue({ + ok: true, + status: 200, + statusText: 'OK', + json: () => + Promise.resolve([ + { symbol: 'BTC', name: 'Bitcoin', listedAt: epochMs }, + ]), + } as Response); + + const { metadata } = await service.fetchMarkets(); + + expect(metadata.get('BTC')?.listedAt).toBe(epochMs); + }); + + it('parses an ISO string listedAt to epoch ms', async () => { + const isoString = '2023-11-14T22:13:20.000Z'; + const expectedMs = Date.parse(isoString); + jest.spyOn(globalThis, 'fetch').mockResolvedValue({ + ok: true, + status: 200, + statusText: 'OK', + json: () => + Promise.resolve([ + { symbol: 'ETH', name: 'Ethereum', listedAt: isoString }, + ]), + } as Response); + + const { metadata } = await service.fetchMarkets(); + + expect(metadata.get('ETH')?.listedAt).toBe(expectedMs); + }); + + it('omits listedAt when the string is not a valid date', async () => { + jest.spyOn(globalThis, 'fetch').mockResolvedValue({ + ok: true, + status: 200, + statusText: 'OK', + json: () => + Promise.resolve([ + { symbol: 'DOGE', name: 'Dogecoin', listedAt: 'not-a-date' }, + ]), + } as Response); + + const { metadata } = await service.fetchMarkets(); + + expect(metadata.get('DOGE')?.listedAt).toBeUndefined(); + }); + + it('omits listedAt when the value is null', async () => { + jest.spyOn(globalThis, 'fetch').mockResolvedValue({ + ok: true, + status: 200, + statusText: 'OK', + json: () => Promise.resolve([{ symbol: 'SOL', listedAt: null }]), + } as Response); + + const { metadata } = await service.fetchMarkets(); + + expect(metadata.get('SOL')?.listedAt).toBeUndefined(); + }); + + it('omits listedAt when the field is absent', async () => { + jest.spyOn(globalThis, 'fetch').mockResolvedValue({ + ok: true, + status: 200, + statusText: 'OK', + json: () => Promise.resolve([{ symbol: 'AVAX' }]), + } as Response); + + const { metadata } = await service.fetchMarkets(); + + expect(metadata.get('AVAX')?.listedAt).toBeUndefined(); + }); + }); + }); + + describe('fetchGlobalSnapshot', () => { + beforeEach(() => { + jest.spyOn(Date, 'now').mockReturnValue(SNAPSHOT_NOW); + mockDeps.terminalApi = { + ...mockDeps.terminalApi, + globalSnapshotUrl: + 'https://terminal.test-api.cx.metamask.io/v2/perpetuals', + }; + }); + + it('strictly validates and maps a fresh v2 snapshot', async () => { + jest + .spyOn(globalThis, 'fetch') + .mockResolvedValue(okJsonResponse(createGlobalSnapshot())); + + const result = await service.fetchGlobalSnapshot({ + provider: 'hyperliquid', + network: 'mainnet', + enabledDexes: ['main'], + }); + + expect(result).toStrictEqual({ + markets: [ + { + symbol: 'BTC', + name: 'Bitcoin', + description: 'Original cryptocurrency', + maxLeverage: '50x', + price: '$50000.00', + change24h: '+$125.00', + change24hPercent: '0.25%', + volume: '$1000000', + openInterest: '$1000000', + fundingRate: 0.0001, + marketSource: undefined, + marketType: 'crypto', + isHip3: false, + isNewMarket: false, + keywords: ['bitcoin'], + tags: ['top-10'], + categories: ['crypto'], + listedAt: 1_600_000_000_000, + trend: [ + [SNAPSHOT_NOW - 3_600_000, '49000'], + [SNAPSHOT_NOW - 1_000, '50000'], + ], + dataSource: 'terminal-global-snapshot-mark', + sourceExpiresAt: SNAPSHOT_NOW + 28_000, + }, + ], + expiresAt: SNAPSHOT_NOW + 28_000, + }); + expect(globalThis.fetch).toHaveBeenCalledWith( + 'https://terminal.test-api.cx.metamask.io/v2/perpetuals?provider=hyperliquid&network=mainnet&dexes=main', + expect.objectContaining({ method: 'GET' }), + ); + }); + + it('keeps main first when canonicalizing requested DEXes', async () => { + jest.spyOn(globalThis, 'fetch').mockResolvedValue({ + ok: false, + status: 503, + statusText: 'Service Unavailable', + } as Response); + + await expect( + service.fetchGlobalSnapshot({ + provider: 'hyperliquid', + network: 'mainnet', + enabledDexes: ['flx', 'main'], + }), + ).rejects.toThrow('Terminal global snapshot returned 503'); + + expect(globalThis.fetch).toHaveBeenCalledWith( + 'https://terminal.test-api.cx.metamask.io/v2/perpetuals?provider=hyperliquid&network=mainnet&dexes=main%2Cflx', + expect.objectContaining({ method: 'GET' }), + ); + }); + + it.each([ + ['unknown top-level key', createGlobalSnapshot({ extra: true })], + [ + 'unknown market key', + createGlobalSnapshot({ + markets: [createSnapshotMarket({ extra: true })], + }), + ], + [ + 'incoherent mark-based percent', + createGlobalSnapshot({ + markets: [createSnapshotMarket({ changePercent24h: 9 })], + }), + ], + [ + 'incoherent deprecated price alias', + createGlobalSnapshot({ + markets: [createSnapshotMarket({ price: '50001' })], + }), + ], + [ + 'overflowing mark/change subtraction', + createGlobalSnapshot({ + markets: [ + createSnapshotMarket({ + markPrice: HUGE_FINITE_DECIMAL, + change24h: `-${HUGE_FINITE_DECIMAL}`, + changePercent24h: 0, + }), + ], + }), + ], + [ + 'unordered trend timestamps', + createGlobalSnapshot({ + markets: [ + createSnapshotMarket({ + trend: [ + [SNAPSHOT_NOW - 1_000, '50000'], + [SNAPSHOT_NOW - 2_000, '49999'], + ], + }), + ], + }), + ], + [ + 'future trend timestamp', + createGlobalSnapshot({ + markets: [ + createSnapshotMarket({ + trend: [[SNAPSHOT_NOW + 1, '50000']], + }), + ], + }), + ], + [ + 'seconds-based listedAt timestamp', + createGlobalSnapshot({ + markets: [createSnapshotMarket({ listedAt: 1_700_000_000 })], + }), + ], + ])('rejects %s', async (_name, snapshot) => { + jest + .spyOn(globalThis, 'fetch') + .mockResolvedValue(okJsonResponse(snapshot)); + + await expect( + service.fetchGlobalSnapshot({ + provider: 'hyperliquid', + network: 'mainnet', + enabledDexes: ['main'], + }), + ).rejects.toThrow('Terminal global snapshot'); + }); + + it.each([ + ['empty', []], + ['single-point', [[SNAPSHOT_NOW - 1_000, '50000']]], + [ + 'irregular or stale', + [ + [SNAPSHOT_NOW - 10_800_000, '49000'], + [SNAPSHOT_NOW - 1_000, '50000'], + ], + ], + ])('accepts %s optional trend data', async (_name, trend) => { + jest.spyOn(globalThis, 'fetch').mockResolvedValue( + okJsonResponse( + createGlobalSnapshot({ + markets: [createSnapshotMarket({ trend })], + }), + ), + ); + + const result = await service.fetchGlobalSnapshot({ + provider: 'hyperliquid', + network: 'mainnet', + enabledDexes: ['main'], + }); + + expect(result).toMatchObject({ markets: [{ trend }] }); + }); + + it('rejects a response larger than the snapshot payload limit', async () => { + jest.spyOn(globalThis, 'fetch').mockResolvedValue({ + ok: true, + status: 200, + statusText: 'OK', + text: () => Promise.resolve('x'.repeat(1_048_577)), + } as Response); + + await expect( + service.fetchGlobalSnapshot({ + provider: 'hyperliquid', + network: 'mainnet', + enabledDexes: ['main'], + }), + ).rejects.toThrow('payload exceeds'); + }); + + it('rejects an oversized Content-Length before allocating response text', async () => { + const text = jest.fn().mockRejectedValue(new Error('must not read')); + jest.spyOn(globalThis, 'fetch').mockResolvedValue({ + ok: true, + status: 200, + statusText: 'OK', + headers: { + get: jest.fn().mockReturnValue('1048577'), + } as unknown as Headers, + text, + } as Response); + + await expect( + service.fetchGlobalSnapshot({ + provider: 'hyperliquid', + network: 'mainnet', + enabledDexes: ['main'], + }), + ).rejects.toThrow('payload exceeds'); + expect(text).not.toHaveBeenCalled(); + }); + + it('aborts when the response body stalls', async () => { + jest.useFakeTimers(); + jest.spyOn(globalThis, 'fetch').mockImplementation((_url, init) => { + const signal = init?.signal as AbortSignal; + return Promise.resolve({ + ok: true, + status: 200, + statusText: 'OK', + headers: { get: () => null } as unknown as Headers, + text: () => + new Promise((_resolve, reject) => { + signal.addEventListener('abort', () => { + reject( + signal.reason instanceof Error + ? signal.reason + : new Error(String(signal.reason)), + ); + }); + }), + } as Response); + }); + + const pending = service.fetchGlobalSnapshot({ + provider: 'hyperliquid', + network: 'mainnet', + enabledDexes: ['main'], + }); + await Promise.resolve(); + + jest.advanceTimersByTime(TERMINAL_API_CONFIG.FetchTimeoutMs); + + await expect(pending).rejects.toThrow( + 'Terminal global snapshot timed out', + ); + jest.useRealTimers(); + }); + + it.each([ + ['version', { schemaVersion: 1 }], + ['provider', { provider: 'other' }], + ['network', { network: 'testnet' }], + ['DEX set', { enabledDexes: ['main', 'xyz'] }], + ['fingerprint', { fingerprint: 'sha256:wrong' }], + ['empty markets', { markets: [] }], + ['incomplete', { complete: false }], + ['per-DEX error', { perDexErrors: [{ dex: 'main', error: 'TIMEOUT' }] }], + ['future generatedAt', { generatedAt: SNAPSHOT_NOW + 5_001 }], + ['future receivedAt', { receivedAt: SNAPSHOT_NOW + 5_001 }], + [ + 'stale source age', + { + generatedAt: SNAPSHOT_NOW - 31_000, + receivedAt: SNAPSHOT_NOW - 31_000, + maxAgeMs: 60_000, + }, + ], + ])('rejects a snapshot with invalid %s', async (_name, overrides) => { + jest + .spyOn(globalThis, 'fetch') + .mockResolvedValue(okJsonResponse(createGlobalSnapshot(overrides))); + + await expect( + service.fetchGlobalSnapshot({ + provider: 'hyperliquid', + network: 'mainnet', + enabledDexes: ['main'], + }), + ).rejects.toThrow('Terminal global snapshot'); + }); + + it('accepts timestamps within the producer clock-skew allowance', async () => { + jest.spyOn(globalThis, 'fetch').mockResolvedValue( + okJsonResponse( + createGlobalSnapshot({ + generatedAt: SNAPSHOT_NOW + 5_000, + receivedAt: SNAPSHOT_NOW + 5_000, + }), + ), + ); + + const result = await service.fetchGlobalSnapshot({ + provider: 'hyperliquid', + network: 'mainnet', + enabledDexes: ['main'], + }); + + expect(result.markets).toStrictEqual(expect.any(Array)); + }); + + it.each([ + ['oracle price', { oraclePrice: '0' }], + ['mid price', { midPrice: '0' }], + ])('rejects a non-positive %s', async (_name, marketOverrides) => { + jest.spyOn(globalThis, 'fetch').mockResolvedValue( + okJsonResponse( + createGlobalSnapshot({ + markets: [createSnapshotMarket(marketOverrides)], + }), + ), + ); + + await expect( + service.fetchGlobalSnapshot({ + provider: 'hyperliquid', + network: 'mainnet', + enabledDexes: ['main'], + }), + ).rejects.toThrow('reference price'); + }); + + it.each([ + ['duplicate market', [createSnapshotMarket(), createSnapshotMarket()]], + [ + 'missing requested DEX', + [ + createSnapshotMarket(), + createSnapshotMarket({ + symbol: 'BTC2', + }), + ], + ], + ['invalid open interest', [createSnapshotMarket({ openInterest: '-1' })]], + ['empty non-null name', [createSnapshotMarket({ name: '' })]], + ])('rejects %s', async (_name, markets) => { + const needsXyz = _name === 'missing requested DEX'; + jest.spyOn(globalThis, 'fetch').mockResolvedValue( + okJsonResponse( + createGlobalSnapshot({ + ...(needsXyz && { + enabledDexes: ['main', 'xyz'], + fingerprint: + 'sha256:2680c000d74e6b46aaddfc5f944442d235961fcdf1d9063af15989285be39bb7', + }), + markets, + }), + ), + ); + + await expect( + service.fetchGlobalSnapshot({ + provider: 'hyperliquid', + network: 'mainnet', + enabledDexes: needsXyz ? ['main', 'xyz'] : ['main'], + }), + ).rejects.toThrow('Terminal global snapshot'); + }); + + it('coalesces same-key requests and isolates different identities', async () => { + const fetchSpy = jest + .spyOn(globalThis, 'fetch') + .mockResolvedValueOnce(okJsonResponse(createGlobalSnapshot())) + .mockResolvedValueOnce( + okJsonResponse( + createGlobalSnapshot({ + network: 'testnet', + fingerprint: + 'sha256:0077720707e8b99ea78df074cdaa58522d331b47f7dcd9bd7cff6f706ffd44db', + }), + ), + ); + const request = { + provider: 'hyperliquid' as const, + network: 'mainnet' as const, + enabledDexes: ['main'], + }; + + await Promise.all([ + service.fetchGlobalSnapshot(request), + service.fetchGlobalSnapshot(request), + ]); + await service.fetchGlobalSnapshot({ + ...request, + network: 'testnet', + }); + + expect(fetchSpy).toHaveBeenCalledTimes(2); + }); + + it('bounds cache TTL by source age and the 30-second consumer cap', async () => { + const fetchSpy = jest + .spyOn(globalThis, 'fetch') + .mockResolvedValue(okJsonResponse(createGlobalSnapshot())); + const request = { + provider: 'hyperliquid' as const, + network: 'mainnet' as const, + enabledDexes: ['main'], + }; + + await service.fetchGlobalSnapshot(request); + jest.spyOn(Date, 'now').mockReturnValue(SNAPSHOT_NOW + 27_999); + await service.fetchGlobalSnapshot(request); + expect(fetchSpy).toHaveBeenCalledTimes(1); + + jest.spyOn(Date, 'now').mockReturnValue(SNAPSHOT_NOW + 28_000); + await expect(service.fetchGlobalSnapshot(request)).rejects.toThrow( + 'stale', + ); + expect(fetchSpy).toHaveBeenCalledTimes(2); + }); + + it('does not cache rejected data', async () => { + const fetchSpy = jest + .spyOn(globalThis, 'fetch') + .mockResolvedValue( + okJsonResponse(createGlobalSnapshot({ fingerprint: 'invalid' })), + ); + const request = { + provider: 'hyperliquid' as const, + network: 'mainnet' as const, + enabledDexes: ['main'], + }; + + await expect(service.fetchGlobalSnapshot(request)).rejects.toThrow( + 'Terminal global snapshot', + ); + await expect(service.fetchGlobalSnapshot(request)).rejects.toThrow( + 'Terminal global snapshot', + ); + + expect(fetchSpy).toHaveBeenCalledTimes(2); + }); + + it('keeps the legacy cache separate and clears both accepted caches', async () => { + const fetchSpy = jest + .spyOn(globalThis, 'fetch') + .mockResolvedValueOnce(okJsonResponse(createGlobalSnapshot())) + .mockResolvedValueOnce(okJsonResponse(mockApiResponse)) + .mockResolvedValueOnce(okJsonResponse(createGlobalSnapshot())); + const request = { + provider: 'hyperliquid' as const, + network: 'mainnet' as const, + enabledDexes: ['main'], + }; + + await service.fetchGlobalSnapshot(request); + await service.fetchMarkets(); + service.clearCache(); + await service.fetchGlobalSnapshot(request); + + expect(fetchSpy).toHaveBeenCalledTimes(3); + }); + + it('does not reuse or recache an in-flight response after clearCache', async () => { + let resolveFirst: ((response: Response) => void) | undefined; + let resolveSecond: ((response: Response) => void) | undefined; + const fetchSpy = jest + .spyOn(globalThis, 'fetch') + .mockImplementationOnce( + () => + new Promise((resolve) => { + resolveFirst = resolve; + }), + ) + .mockImplementationOnce( + () => + new Promise((resolve) => { + resolveSecond = resolve; + }), + ); + const request = { + provider: 'hyperliquid' as const, + network: 'mainnet' as const, + enabledDexes: ['main'], + }; + + const oldRequest = service.fetchGlobalSnapshot(request); + service.clearCache(); + const newRequest = service.fetchGlobalSnapshot(request); + resolveFirst?.(okJsonResponse(createGlobalSnapshot())); + await oldRequest; + resolveSecond?.(okJsonResponse(createGlobalSnapshot())); + const fresh = await newRequest; + const cached = await service.fetchGlobalSnapshot(request); + + expect(fetchSpy).toHaveBeenCalledTimes(2); + expect(cached).toStrictEqual(fresh); + expect(cached).not.toBe(fresh); + }); + + it('does not expose mutable references from the validated cache', async () => { + const fetchSpy = jest + .spyOn(globalThis, 'fetch') + .mockResolvedValue(okJsonResponse(createGlobalSnapshot())); + const request = { + provider: 'hyperliquid' as const, + network: 'mainnet' as const, + enabledDexes: ['main'], + }; + + const first = await service.fetchGlobalSnapshot(request); + first.markets[0].trend?.push([SNAPSHOT_NOW, '1']); + first.markets.splice(0); + const second = await service.fetchGlobalSnapshot(request); + + expect(fetchSpy).toHaveBeenCalledTimes(1); + expect(second.markets).toHaveLength(1); + expect(second.markets[0].trend).toHaveLength(2); + }); + }); + + describe('cache behavior', () => { + it('returns cached data on second call within TTL', async () => { + const fetchSpy = jest.spyOn(globalThis, 'fetch').mockResolvedValue({ + ok: true, + status: 200, + statusText: 'OK', + json: () => Promise.resolve(mockApiResponse), + } as Response); + + const first = await service.fetchMarkets(); + const second = await service.fetchMarkets(); + + expect(fetchSpy).toHaveBeenCalledTimes(1); + expect(second.markets).toBe(first.markets); + expect(second.metadata).toBe(first.metadata); + }); + + it('fetches again after cache is cleared', async () => { + const fetchSpy = jest.spyOn(globalThis, 'fetch').mockResolvedValue({ + ok: true, + status: 200, + statusText: 'OK', + json: () => Promise.resolve(mockApiResponse), + } as Response); + + await service.fetchMarkets(); + service.clearCache(); + await service.fetchMarkets(); + + expect(fetchSpy).toHaveBeenCalledTimes(2); + }); + + it('fetches again after TTL expires', async () => { + const fetchSpy = jest.spyOn(globalThis, 'fetch').mockResolvedValue({ + ok: true, + status: 200, + statusText: 'OK', + json: () => Promise.resolve(mockApiResponse), + } as Response); + + await service.fetchMarkets(); + + // Advance time past TTL (5 minutes) + jest.spyOn(Date, 'now').mockReturnValue(Date.now() + 6 * 60 * 1000); + + await service.fetchMarkets(); + + expect(fetchSpy).toHaveBeenCalledTimes(2); + }); + }); + + describe('logError', () => { + it('logs error to Sentry via deps.logger', () => { + const error = new Error('fetch failed'); + service.logError(error, 'getMarkets'); + + expect(mockDeps.logger.error).toHaveBeenCalledWith( + expect.objectContaining({ message: 'fetch failed' }), + expect.objectContaining({ + tags: { feature: 'perps', source: 'terminal-api' }, + context: { + name: 'TerminalMarketService.getMarkets', + data: { + url: 'https://terminal.test-api.cx.metamask.io/v1/perpetuals', + }, + }, + }), + ); + }); + }); +}); diff --git a/packages/perps-controller/tests/src/services/TradingReadinessCache.test.ts b/packages/perps-controller/tests/src/services/TradingReadinessCache.test.ts new file mode 100644 index 00000000000..77469d60034 --- /dev/null +++ b/packages/perps-controller/tests/src/services/TradingReadinessCache.test.ts @@ -0,0 +1,776 @@ +/* eslint-disable */ +import { + TradingReadinessCache, + PerpsSigningCache, +} from '../../../src/services/TradingReadinessCache.js'; + +describe('TradingReadinessCache / PerpsSigningCache', () => { + // Both exports reference the same singleton instance + describe('Singleton Pattern', () => { + it('TradingReadinessCache and PerpsSigningCache are the same instance', () => { + expect(TradingReadinessCache).toBe(PerpsSigningCache); + }); + }); + + beforeEach(() => { + // Clear all cache entries before each test + TradingReadinessCache.clearAll(); + }); + + describe('DEX Abstraction (Legacy API)', () => { + const network = 'mainnet' as const; + const userAddress = '0x1234567890123456789012345678901234567890'; + + describe('get()', () => { + it('returns undefined when no entry exists', () => { + const result = TradingReadinessCache.get(network, userAddress); + expect(result).toBeUndefined(); + }); + + it('returns entry with correct structure when set', () => { + TradingReadinessCache.set(network, userAddress, { + attempted: true, + enabled: true, + }); + + const result = TradingReadinessCache.get(network, userAddress); + expect(result).toEqual({ + attempted: true, + enabled: true, + timestamp: expect.any(Number), + }); + }); + + it('normalizes address to lowercase for cache key', () => { + const mixedCaseAddress = '0xAbCdEf1234567890123456789012345678901234'; + TradingReadinessCache.set(network, mixedCaseAddress, { + attempted: true, + enabled: false, + }); + + // Should be accessible with lowercase address + const result = TradingReadinessCache.get( + network, + mixedCaseAddress.toLowerCase(), + ); + expect(result).toBeDefined(); + expect(result?.attempted).toBe(true); + }); + }); + + describe('set()', () => { + it('creates new entry when none exists', () => { + expect(TradingReadinessCache.size()).toBe(0); + + TradingReadinessCache.set(network, userAddress, { + attempted: true, + enabled: true, + }); + + expect(TradingReadinessCache.size()).toBe(1); + }); + + it('updates timestamp on each set', () => { + // Use fake timers to control timestamp + jest.useFakeTimers(); + const startTime = Date.now(); + + TradingReadinessCache.set(network, userAddress, { + attempted: true, + enabled: false, + }); + const firstTimestamp = TradingReadinessCache.get( + network, + userAddress, + )?.timestamp; + + // Advance time by 100ms + jest.advanceTimersByTime(100); + + TradingReadinessCache.set(network, userAddress, { + attempted: true, + enabled: true, + }); + const secondTimestamp = TradingReadinessCache.get( + network, + userAddress, + )?.timestamp; + + expect(firstTimestamp).toBeGreaterThanOrEqual(startTime); + expect(secondTimestamp).toBeGreaterThan(firstTimestamp as number); + + jest.useRealTimers(); + }); + + it('differentiates between mainnet and testnet', () => { + TradingReadinessCache.set('mainnet', userAddress, { + attempted: true, + enabled: true, + }); + TradingReadinessCache.set('testnet', userAddress, { + attempted: true, + enabled: false, + }); + + expect(TradingReadinessCache.get('mainnet', userAddress)?.enabled).toBe( + true, + ); + expect(TradingReadinessCache.get('testnet', userAddress)?.enabled).toBe( + false, + ); + }); + }); + }); + + describe('Builder Fee API', () => { + const network = 'testnet' as const; + const userAddress = '0xBuilderFeeUser123456789012345678901234'; + + describe('getBuilderFee()', () => { + it('returns undefined when no entry exists', () => { + const result = PerpsSigningCache.getBuilderFee(network, userAddress); + expect(result).toBeUndefined(); + }); + + it('returns builder fee state when set', () => { + PerpsSigningCache.setBuilderFee(network, userAddress, { + attempted: true, + success: true, + }); + + const result = PerpsSigningCache.getBuilderFee(network, userAddress); + expect(result).toEqual({ + attempted: true, + success: true, + }); + }); + }); + + describe('setBuilderFee()', () => { + it('creates entry if it does not exist', () => { + PerpsSigningCache.setBuilderFee(network, userAddress, { + attempted: true, + success: false, + }); + + const result = PerpsSigningCache.getBuilderFee(network, userAddress); + expect(result?.attempted).toBe(true); + expect(result?.success).toBe(false); + }); + + it('updates existing entry without affecting other fields', () => { + // Set DEX abstraction first + TradingReadinessCache.set(network, userAddress, { + attempted: true, + enabled: true, + }); + + // Set builder fee + PerpsSigningCache.setBuilderFee(network, userAddress, { + attempted: true, + success: true, + }); + + // Both should be preserved + const dexResult = TradingReadinessCache.get(network, userAddress); + const builderResult = PerpsSigningCache.getBuilderFee( + network, + userAddress, + ); + + expect(dexResult?.enabled).toBe(true); + expect(builderResult?.success).toBe(true); + }); + }); + }); + + describe('Referral API', () => { + const network = 'mainnet' as const; + const userAddress = '0xReferralUser1234567890123456789012345'; + + describe('getReferral()', () => { + it('returns undefined when no entry exists', () => { + const result = PerpsSigningCache.getReferral(network, userAddress); + expect(result).toBeUndefined(); + }); + + it('returns referral state when set', () => { + PerpsSigningCache.setReferral(network, userAddress, { + attempted: true, + success: false, + }); + + const result = PerpsSigningCache.getReferral(network, userAddress); + expect(result).toEqual({ + attempted: true, + success: false, + }); + }); + }); + + describe('setReferral()', () => { + it('creates entry if it does not exist', () => { + PerpsSigningCache.setReferral(network, userAddress, { + attempted: true, + success: true, + }); + + const result = PerpsSigningCache.getReferral(network, userAddress); + expect(result?.attempted).toBe(true); + expect(result?.success).toBe(true); + }); + }); + }); + + describe('In-Flight Lock Methods', () => { + const network = 'mainnet' as const; + const userAddress = '0xInFlightUser12345678901234567890123456'; + + describe('isInFlight()', () => { + it('returns undefined when no in-flight operation', () => { + const result = PerpsSigningCache.isInFlight( + 'unifiedAccount', + network, + userAddress, + ); + expect(result).toBeUndefined(); + }); + + it('returns promise when operation is in-flight', () => { + PerpsSigningCache.setInFlight('unifiedAccount', network, userAddress); + + const result = PerpsSigningCache.isInFlight( + 'unifiedAccount', + network, + userAddress, + ); + expect(result).toBeInstanceOf(Promise); + }); + + it('differentiates by operation type', () => { + // Use unique addresses to avoid state pollution from other tests + const uniqueAddress = '0xUniqueAddressForDifferentiationTest123'; + + // Set only builderFee in-flight + const completeBuilder = PerpsSigningCache.setInFlight( + 'builderFee', + network, + uniqueAddress, + ); + + // Different operation type should not be in-flight + expect( + PerpsSigningCache.isInFlight( + 'unifiedAccount', + network, + uniqueAddress, + ), + ).toBeUndefined(); + expect( + PerpsSigningCache.isInFlight('builderFee', network, uniqueAddress), + ).toBeInstanceOf(Promise); + + // Clean up + completeBuilder(); + }); + + it('normalizes address to lowercase', () => { + const mixedCaseAddress = '0xMixedCaseUser123456789012345678901234'; + PerpsSigningCache.setInFlight( + 'referral', + network, + mixedCaseAddress.toUpperCase(), + ); + + const result = PerpsSigningCache.isInFlight( + 'referral', + network, + mixedCaseAddress.toLowerCase(), + ); + expect(result).toBeInstanceOf(Promise); + }); + }); + + describe('setInFlight()', () => { + it('returns a completion function', () => { + const complete = PerpsSigningCache.setInFlight( + 'unifiedAccount', + network, + userAddress, + ); + expect(typeof complete).toBe('function'); + }); + + it('calling completion function removes in-flight status', () => { + const complete = PerpsSigningCache.setInFlight( + 'unifiedAccount', + network, + userAddress, + ); + + // Should be in-flight + expect( + PerpsSigningCache.isInFlight('unifiedAccount', network, userAddress), + ).toBeDefined(); + + // Complete the operation + complete(); + + // Should no longer be in-flight + expect( + PerpsSigningCache.isInFlight('unifiedAccount', network, userAddress), + ).toBeUndefined(); + }); + + it('calling completion function resolves waiting promises', async () => { + const complete = PerpsSigningCache.setInFlight( + 'builderFee', + network, + userAddress, + ); + + const waitingPromise = PerpsSigningCache.isInFlight( + 'builderFee', + network, + userAddress, + ); + + // Start waiting + let resolved = false; + const waitPromise = waitingPromise?.then(() => { + resolved = true; + }); + + // Should not be resolved yet + expect(resolved).toBe(false); + + // Complete the operation + complete(); + + // Wait for resolution + await waitPromise; + expect(resolved).toBe(true); + }); + + it('handles multiple concurrent waiters', async () => { + const complete = PerpsSigningCache.setInFlight( + 'referral', + network, + userAddress, + ); + + const waitingPromise = PerpsSigningCache.isInFlight( + 'referral', + network, + userAddress, + ); + + // Multiple waiters + const results: boolean[] = []; + const waiter1 = waitingPromise?.then(() => results.push(true)); + const waiter2 = waitingPromise?.then(() => results.push(true)); + const waiter3 = waitingPromise?.then(() => results.push(true)); + + // Complete and wait + complete(); + await Promise.all([waiter1, waiter2, waiter3]); + + expect(results).toHaveLength(3); + expect(results.every((r) => r === true)).toBe(true); + }); + }); + }); + + describe('General Methods', () => { + const mainnetAddress = '0xMainnetUser1234567890123456789012345'; + const testnetAddress = '0xTestnetUser1234567890123456789012345'; + + describe('clearUnifiedAccount()', () => { + it('clears only DEX abstraction state, preserving other states', () => { + // Setup all three operation states + TradingReadinessCache.set('mainnet', mainnetAddress, { + attempted: true, + enabled: true, + }); + PerpsSigningCache.setBuilderFee('mainnet', mainnetAddress, { + attempted: true, + success: true, + }); + PerpsSigningCache.setReferral('mainnet', mainnetAddress, { + attempted: true, + success: true, + }); + + // Clear only DEX abstraction + TradingReadinessCache.clearUnifiedAccount('mainnet', mainnetAddress); + + // DEX abstraction should be reset + const dexResult = TradingReadinessCache.get('mainnet', mainnetAddress); + expect(dexResult?.attempted).toBe(false); + expect(dexResult?.enabled).toBe(false); + + // Builder fee and referral should be preserved + expect( + PerpsSigningCache.getBuilderFee('mainnet', mainnetAddress)?.success, + ).toBe(true); + expect( + PerpsSigningCache.getReferral('mainnet', mainnetAddress)?.success, + ).toBe(true); + + // Entry should still exist + expect(TradingReadinessCache.size()).toBe(1); + }); + + it('does nothing when entry does not exist', () => { + TradingReadinessCache.clearUnifiedAccount('mainnet', mainnetAddress); + expect(TradingReadinessCache.size()).toBe(0); + }); + }); + + describe('clearBuilderFee()', () => { + it('clears only builder fee state, preserving other states', () => { + // Setup all three operation states + TradingReadinessCache.set('mainnet', mainnetAddress, { + attempted: true, + enabled: true, + }); + PerpsSigningCache.setBuilderFee('mainnet', mainnetAddress, { + attempted: true, + success: true, + }); + PerpsSigningCache.setReferral('mainnet', mainnetAddress, { + attempted: true, + success: true, + }); + + // Clear only builder fee + TradingReadinessCache.clearBuilderFee('mainnet', mainnetAddress); + + // Builder fee should be reset + const builderResult = PerpsSigningCache.getBuilderFee( + 'mainnet', + mainnetAddress, + ); + expect(builderResult?.attempted).toBe(false); + expect(builderResult?.success).toBe(false); + + // DEX abstraction and referral should be preserved + expect( + TradingReadinessCache.get('mainnet', mainnetAddress)?.enabled, + ).toBe(true); + expect( + PerpsSigningCache.getReferral('mainnet', mainnetAddress)?.success, + ).toBe(true); + }); + + it('does nothing when entry does not exist', () => { + TradingReadinessCache.clearBuilderFee('mainnet', mainnetAddress); + expect(TradingReadinessCache.size()).toBe(0); + }); + }); + + describe('clearReferral()', () => { + it('clears only referral state, preserving other states', () => { + // Setup all three operation states + TradingReadinessCache.set('mainnet', mainnetAddress, { + attempted: true, + enabled: true, + }); + PerpsSigningCache.setBuilderFee('mainnet', mainnetAddress, { + attempted: true, + success: true, + }); + PerpsSigningCache.setReferral('mainnet', mainnetAddress, { + attempted: true, + success: true, + }); + + // Clear only referral + TradingReadinessCache.clearReferral('mainnet', mainnetAddress); + + // Referral should be reset + const referralResult = PerpsSigningCache.getReferral( + 'mainnet', + mainnetAddress, + ); + expect(referralResult?.attempted).toBe(false); + expect(referralResult?.success).toBe(false); + + // DEX abstraction and builder fee should be preserved + expect( + TradingReadinessCache.get('mainnet', mainnetAddress)?.enabled, + ).toBe(true); + expect( + PerpsSigningCache.getBuilderFee('mainnet', mainnetAddress)?.success, + ).toBe(true); + }); + + it('does nothing when entry does not exist', () => { + TradingReadinessCache.clearReferral('mainnet', mainnetAddress); + expect(TradingReadinessCache.size()).toBe(0); + }); + }); + + describe('clear()', () => { + it('removes entire cache entry (all signing states)', () => { + TradingReadinessCache.set('mainnet', mainnetAddress, { + attempted: true, + enabled: true, + }); + PerpsSigningCache.setBuilderFee('mainnet', mainnetAddress, { + attempted: true, + success: true, + }); + TradingReadinessCache.set('testnet', testnetAddress, { + attempted: true, + enabled: false, + }); + + expect(TradingReadinessCache.size()).toBe(2); + + TradingReadinessCache.clear('mainnet', mainnetAddress); + + expect(TradingReadinessCache.size()).toBe(1); + // Entire entry including builder fee should be gone + expect( + TradingReadinessCache.get('mainnet', mainnetAddress), + ).toBeUndefined(); + expect( + PerpsSigningCache.getBuilderFee('mainnet', mainnetAddress), + ).toBeUndefined(); + expect( + TradingReadinessCache.get('testnet', testnetAddress), + ).toBeDefined(); + }); + + it('does nothing when entry does not exist', () => { + TradingReadinessCache.set('mainnet', mainnetAddress, { + attempted: true, + enabled: true, + }); + + expect(TradingReadinessCache.size()).toBe(1); + + // Clear non-existent entry + TradingReadinessCache.clear('testnet', testnetAddress); + + expect(TradingReadinessCache.size()).toBe(1); + }); + }); + + describe('clearAll()', () => { + it('removes all cache entries', () => { + TradingReadinessCache.set('mainnet', mainnetAddress, { + attempted: true, + enabled: true, + }); + TradingReadinessCache.set('testnet', testnetAddress, { + attempted: true, + enabled: false, + }); + PerpsSigningCache.setBuilderFee('mainnet', mainnetAddress, { + attempted: true, + success: true, + }); + + expect(TradingReadinessCache.size()).toBe(2); + + TradingReadinessCache.clearAll(); + + expect(TradingReadinessCache.size()).toBe(0); + }); + }); + + describe('getAll()', () => { + it('returns a copy of all cache entries', () => { + TradingReadinessCache.set('mainnet', mainnetAddress, { + attempted: true, + enabled: true, + }); + + const allEntries = TradingReadinessCache.getAll(); + + expect(allEntries).toBeInstanceOf(Map); + expect(allEntries.size).toBe(1); + + // Verify it's a copy (modifying returned map doesn't affect cache) + allEntries.clear(); + expect(TradingReadinessCache.size()).toBe(1); + }); + }); + + describe('size()', () => { + it('returns correct count of entries', () => { + expect(TradingReadinessCache.size()).toBe(0); + + TradingReadinessCache.set('mainnet', mainnetAddress, { + attempted: true, + enabled: true, + }); + expect(TradingReadinessCache.size()).toBe(1); + + TradingReadinessCache.set('testnet', mainnetAddress, { + attempted: true, + enabled: false, + }); + expect(TradingReadinessCache.size()).toBe(2); + }); + }); + + describe('debugState()', () => { + it('returns empty string for empty cache', () => { + const state = TradingReadinessCache.debugState(); + expect(state).toBe('(empty)'); + }); + + it('returns formatted string with all entries', () => { + TradingReadinessCache.set('mainnet', mainnetAddress, { + attempted: true, + enabled: true, + }); + PerpsSigningCache.setBuilderFee('mainnet', mainnetAddress, { + attempted: true, + success: false, + }); + PerpsSigningCache.setReferral('mainnet', mainnetAddress, { + attempted: false, + success: false, + }); + + const state = TradingReadinessCache.debugState(); + + expect(state).toContain('mainnet:'); + expect(state).toContain('unified=true/true'); + expect(state).toContain('builder=true/false'); + expect(state).toContain('referral=false/false'); + }); + }); + }); + + describe('Integration Scenarios', () => { + const userAddress = '0xIntegrationUser12345678901234567890123'; + + it('tracks all three signing operations for same user/network', () => { + const network = 'mainnet' as const; + + // Set all three operations + TradingReadinessCache.set(network, userAddress, { + attempted: true, + enabled: true, + }); + PerpsSigningCache.setBuilderFee(network, userAddress, { + attempted: true, + success: true, + }); + PerpsSigningCache.setReferral(network, userAddress, { + attempted: true, + success: false, + }); + + // All should be retrievable + expect(TradingReadinessCache.get(network, userAddress)?.enabled).toBe( + true, + ); + expect( + PerpsSigningCache.getBuilderFee(network, userAddress)?.success, + ).toBe(true); + expect(PerpsSigningCache.getReferral(network, userAddress)?.success).toBe( + false, + ); + + // Single entry in cache (all operations share same entry) + expect(TradingReadinessCache.size()).toBe(1); + }); + + it('handles concurrent in-flight operations of different types', async () => { + const network = 'mainnet' as const; + + // Start all three operations + const completeDex = PerpsSigningCache.setInFlight( + 'unifiedAccount', + network, + userAddress, + ); + const completeBuilder = PerpsSigningCache.setInFlight( + 'builderFee', + network, + userAddress, + ); + const completeReferral = PerpsSigningCache.setInFlight( + 'referral', + network, + userAddress, + ); + + // All should be in-flight + expect( + PerpsSigningCache.isInFlight('unifiedAccount', network, userAddress), + ).toBeDefined(); + expect( + PerpsSigningCache.isInFlight('builderFee', network, userAddress), + ).toBeDefined(); + expect( + PerpsSigningCache.isInFlight('referral', network, userAddress), + ).toBeDefined(); + + // Complete them in different order + completeBuilder(); + expect( + PerpsSigningCache.isInFlight('builderFee', network, userAddress), + ).toBeUndefined(); + expect( + PerpsSigningCache.isInFlight('unifiedAccount', network, userAddress), + ).toBeDefined(); + + completeDex(); + completeReferral(); + + // All should be cleared + expect( + PerpsSigningCache.isInFlight('unifiedAccount', network, userAddress), + ).toBeUndefined(); + expect( + PerpsSigningCache.isInFlight('referral', network, userAddress), + ).toBeUndefined(); + }); + + it('isolates cache between different users on same network', () => { + const network = 'mainnet' as const; + const user1 = '0xUser1000000000000000000000000000000001'; + const user2 = '0xUser2000000000000000000000000000000002'; + + TradingReadinessCache.set(network, user1, { + attempted: true, + enabled: true, + }); + TradingReadinessCache.set(network, user2, { + attempted: true, + enabled: false, + }); + + expect(TradingReadinessCache.get(network, user1)?.enabled).toBe(true); + expect(TradingReadinessCache.get(network, user2)?.enabled).toBe(false); + }); + + it('isolates cache between networks for same user', () => { + TradingReadinessCache.set('mainnet', userAddress, { + attempted: true, + enabled: true, + }); + TradingReadinessCache.set('testnet', userAddress, { + attempted: true, + enabled: false, + }); + + expect(TradingReadinessCache.get('mainnet', userAddress)?.enabled).toBe( + true, + ); + expect(TradingReadinessCache.get('testnet', userAddress)?.enabled).toBe( + false, + ); + + // Two separate entries + expect(TradingReadinessCache.size()).toBe(2); + }); + }); +}); diff --git a/packages/perps-controller/tests/src/services/TradingService.placeOrder.timeout.test.ts b/packages/perps-controller/tests/src/services/TradingService.placeOrder.timeout.test.ts new file mode 100644 index 00000000000..c8ba09b555f --- /dev/null +++ b/packages/perps-controller/tests/src/services/TradingService.placeOrder.timeout.test.ts @@ -0,0 +1,201 @@ +/* eslint-disable */ +import { PERPS_CONSTANTS } from '../../../src/constants/perpsConfig.js'; +import { TradingService } from '../../../src/services/TradingService.js'; +import type { + OrderParams, + OrderResult, + PerpsProvider, + PerpsPlatformDependencies, +} from '../../../src/types/index.js'; +import { createMockHyperLiquidProvider } from '../../helpers/providerMocks.js'; +import { + createMockInfrastructure, + createMockServiceContext, + createMockPerpsControllerState, +} from '../../helpers/serviceMocks.js'; + +jest.mock('uuid', () => ({ v4: () => 'mock-trace-id' })); + +describe('TradingService.placeOrder — order submission timeout', () => { + let tradingService: TradingService; + let mockDeps: jest.Mocked; + let mockProvider: jest.Mocked; + let mockRewardsService: { + calculateUserFeeDiscount: jest.Mock; + resolveFee: jest.Mock; + }; + let mockContext: ReturnType; + let mockReportOrderToDataLake: jest.Mock; + + const baseOrderParams: OrderParams = { + symbol: 'BTC', + isBuy: true, + size: '0.1', + orderType: 'market', + }; + + beforeEach(() => { + jest.useFakeTimers(); + mockDeps = createMockInfrastructure(); + tradingService = new TradingService(mockDeps); + mockRewardsService = { + calculateUserFeeDiscount: jest.fn().mockResolvedValue(undefined), + resolveFee: jest.fn().mockResolvedValue({ + feeBips: 10, + discountBips: undefined, + source: 'default', + subscription: { eligible: false, reason: 'no-source' }, + }), + }; + tradingService.setControllerDependencies({ + rewardsIntegrationService: mockRewardsService as never, + }); + mockProvider = + createMockHyperLiquidProvider() as unknown as jest.Mocked; + mockContext = createMockServiceContext({ + errorContext: { controller: 'TradingService', method: 'test' }, + stateManager: { + update: jest.fn(), + getState: jest.fn(() => createMockPerpsControllerState()), + }, + }); + mockReportOrderToDataLake = jest.fn().mockResolvedValue({ success: true }); + }); + + afterEach(() => { + jest.useRealTimers(); + jest.clearAllMocks(); + }); + + it('emits no threshold breadcrumb and leaves reason undefined when provider resolves before threshold', async () => { + const mockResult: OrderResult = { + success: true, + orderId: 'order-123', + filledSize: '0.1', + averagePrice: '50000', + }; + mockProvider.placeOrder.mockResolvedValue(mockResult); + + await tradingService.placeOrder({ + provider: mockProvider, + params: baseOrderParams, + context: mockContext, + reportOrderToDataLake: mockReportOrderToDataLake, + }); + + expect(mockDeps.tracer.addBreadcrumb).not.toHaveBeenCalledWith( + expect.objectContaining({ + message: 'Order submission exceeded threshold (still pending)', + }), + ); + + const endTraceArgs = (mockDeps.tracer.endTrace as jest.Mock).mock + .calls[0][0]; + expect(endTraceArgs.data?.reason).toBeUndefined(); + expect(jest.getTimerCount()).toBe(0); + }); + + it('emits breadcrumb exactly once and sets reason: late_success when provider resolves after threshold', async () => { + let resolveOrder!: (result: OrderResult) => void; + const slowOrder = new Promise((resolve) => { + resolveOrder = resolve; + }); + mockProvider.placeOrder.mockReturnValue(slowOrder); + + const placeOrderPromise = tradingService.placeOrder({ + provider: mockProvider, + params: baseOrderParams, + context: mockContext, + reportOrderToDataLake: mockReportOrderToDataLake, + }); + // Advance past the threshold, allowing microtasks (fee discount await) to run first + await jest.advanceTimersByTimeAsync( + PERPS_CONSTANTS.PlaceOrderTimeoutMs + 1, + ); + + expect(mockDeps.tracer.addBreadcrumb).toHaveBeenCalledWith( + expect.objectContaining({ + message: 'Order submission exceeded threshold (still pending)', + level: 'warning', + category: 'perps', + data: expect.objectContaining({ + thresholdMs: PERPS_CONSTANTS.PlaceOrderTimeoutMs, + }), + }), + ); + // Exactly one threshold breadcrumb (plus the 'Order execution started' breadcrumb = 2 total) + const thresholdCalls = ( + mockDeps.tracer.addBreadcrumb as jest.Mock + ).mock.calls.filter( + ([args]: [{ message: string }]) => + args.message === 'Order submission exceeded threshold (still pending)', + ); + expect(thresholdCalls).toHaveLength(1); + + // Resolve the provider after the threshold fired + resolveOrder({ + success: true, + orderId: 'order-456', + filledSize: '0.1', + averagePrice: '50000', + }); + await placeOrderPromise; + + const endTraceArgs = (mockDeps.tracer.endTrace as jest.Mock).mock + .calls[0][0]; + expect(endTraceArgs.data?.reason).toBe('late_success'); + expect(jest.getTimerCount()).toBe(0); + }); + + it('sets reason: late_error and rethrows the original error when provider rejects after threshold', async () => { + let rejectOrder!: (error: Error) => void; + const slowOrder = new Promise((_, reject) => { + rejectOrder = reject; + }); + mockProvider.placeOrder.mockReturnValue(slowOrder); + + const placeOrderPromise = tradingService.placeOrder({ + provider: mockProvider, + params: baseOrderParams, + context: mockContext, + reportOrderToDataLake: mockReportOrderToDataLake, + }); + const rejection = expect(placeOrderPromise).rejects.toThrow( + 'Provider connection timed out', + ); + + await jest.advanceTimersByTimeAsync( + PERPS_CONSTANTS.PlaceOrderTimeoutMs + 1, + ); + + const originalError = new Error('Provider connection timed out'); + rejectOrder(originalError); + + await rejection; + + const endTraceArgs = (mockDeps.tracer.endTrace as jest.Mock).mock + .calls[0][0]; + expect(endTraceArgs.data?.reason).toBe('late_error'); + expect(endTraceArgs.data?.success).toBe(false); + expect(jest.getTimerCount()).toBe(0); + }); + + it('leaves no pending timers when the provider rejects before the threshold', async () => { + mockProvider.placeOrder.mockRejectedValue(new Error('immediate failure')); + + await expect( + tradingService.placeOrder({ + provider: mockProvider, + params: baseOrderParams, + context: mockContext, + reportOrderToDataLake: mockReportOrderToDataLake, + }), + ).rejects.toThrow('immediate failure'); + + expect(jest.getTimerCount()).toBe(0); + + const endTraceArgs = (mockDeps.tracer.endTrace as jest.Mock).mock + .calls[0][0]; + expect(endTraceArgs.data?.reason).toBe('error'); + }); +}); diff --git a/packages/perps-controller/tests/src/services/TradingService.test.ts b/packages/perps-controller/tests/src/services/TradingService.test.ts new file mode 100644 index 00000000000..cee0872a447 --- /dev/null +++ b/packages/perps-controller/tests/src/services/TradingService.test.ts @@ -0,0 +1,3521 @@ +import { PERPS_EVENT_VALUE } from '../../../src/constants/eventNames.js'; +import type { ServiceContext } from '../../../src/services/ServiceContext.js'; +import { TradingService } from '../../../src/services/TradingService.js'; +import { PerpsAnalyticsEvent } from '../../../src/types/index.js'; +import type { + PerpsProvider, + OrderParams, + OrderResult, + EditOrderParams, + CancelOrderParams, + CancelOrdersParams, + ClosePositionParams, + ClosePositionsParams, + Position, + Order, + UpdatePositionTPSLParams, + PerpsPlatformDependencies, + PerpsFeeResolution, +} from '../../../src/types/index.js'; +/* eslint-disable */ +import { createMockHyperLiquidProvider } from '../../helpers/providerMocks.js'; +import { + createMockServiceContext, + createMockPerpsControllerState, + createMockInfrastructure, +} from '../../helpers/serviceMocks.js'; + +jest.mock('uuid', () => ({ v4: () => 'mock-trace-id' })); + +describe('TradingService', () => { + let mockProvider: jest.Mocked; + let mockContext: ServiceContext; + let mockDeps: jest.Mocked; + let tradingService: TradingService; + let mockReportOrderToDataLake: jest.Mock; + let mockWithStreamPause: jest.Mock; + let mockGetPositions: jest.Mock; + let mockGetOpenOrders: jest.Mock; + let mockSaveTradeConfiguration: jest.Mock; + let mockRewardsIntegrationService: { + calculateUserFeeDiscount: jest.Mock; + resolveFee: jest.Mock; + }; + + const defaultFeeResolution: PerpsFeeResolution = { + feeBips: 10, + discountBips: undefined, + source: 'default', + subscription: { eligible: false, reason: 'no-source' }, + }; + + const createContextWithRewards = (): ServiceContext => + createMockServiceContext({ + errorContext: { controller: 'TradingService', method: 'test' }, + stateManager: { + update: jest.fn(), + getState: jest.fn(() => createMockPerpsControllerState()), + }, + }); + + beforeEach(() => { + mockDeps = createMockInfrastructure(); + tradingService = new TradingService(mockDeps); + mockRewardsIntegrationService = { + calculateUserFeeDiscount: jest.fn().mockResolvedValue(undefined), + resolveFee: jest.fn(async () => { + const discountBips = + await mockRewardsIntegrationService.calculateUserFeeDiscount(); + return discountBips === undefined + ? defaultFeeResolution + : { + ...defaultFeeResolution, + discountBips, + source: 'rewards' as const, + }; + }), + }; + // Set controller dependencies for fee discount calculation + tradingService.setControllerDependencies({ + rewardsIntegrationService: mockRewardsIntegrationService as never, + }); + mockProvider = + createMockHyperLiquidProvider() as unknown as jest.Mocked; + mockSaveTradeConfiguration = jest.fn(); + mockContext = createMockServiceContext({ + errorContext: { controller: 'TradingService', method: 'test' }, + stateManager: { + update: jest.fn(), + getState: jest.fn(() => createMockPerpsControllerState()), + }, + saveTradeConfiguration: mockSaveTradeConfiguration, + }); + mockReportOrderToDataLake = jest.fn().mockResolvedValue(undefined); + mockWithStreamPause = jest.fn(async (callback) => await callback()); + mockGetPositions = jest.fn().mockResolvedValue([]); + mockGetOpenOrders = jest.fn().mockResolvedValue([]); + + jest.clearAllMocks(); + }); + + afterEach(() => { + jest.resetAllMocks(); + }); + + describe('placeOrder', () => { + it('preserves the subscription source through order construction', async () => { + mockProvider.setUserFeeResolution = jest.fn(); + const subscriptionResolution: PerpsFeeResolution = { + feeBips: 0, + discountBips: 10000, + source: 'subscription', + subscription: { + eligible: true, + reason: 'eligible', + remainingNotionalUsd: 1500, + }, + }; + const orderParams: OrderParams = { + symbol: 'BTC', + isBuy: true, + size: '0.1', + orderType: 'market', + }; + mockRewardsIntegrationService.resolveFee.mockResolvedValue( + subscriptionResolution, + ); + mockProvider.placeOrder.mockResolvedValue({ success: true }); + + await tradingService.placeOrder({ + provider: mockProvider, + params: orderParams, + context: mockContext, + reportOrderToDataLake: mockReportOrderToDataLake, + }); + + expect(mockProvider.setUserFeeResolution).toHaveBeenCalledWith( + subscriptionResolution, + ); + expect(mockProvider.setUserFeeResolution).toHaveBeenLastCalledWith( + undefined, + ); + }); + + it('isolates fee resolutions between concurrent orders', async () => { + const subscriptionResolution: PerpsFeeResolution = { + feeBips: 0, + discountBips: 10000, + source: 'subscription', + subscription: { eligible: true, reason: 'eligible' }, + }; + const rewardsResolution: PerpsFeeResolution = { + feeBips: 5, + discountBips: 5000, + source: 'rewards', + subscription: { eligible: false, reason: 'not-entitled' }, + }; + mockRewardsIntegrationService.resolveFee + .mockResolvedValueOnce(subscriptionResolution) + .mockResolvedValueOnce(rewardsResolution); + + let activeResolution: PerpsFeeResolution | undefined; + mockProvider.setUserFeeResolution = jest.fn((resolution) => { + activeResolution = resolution; + }); + let releaseFirst: () => void = () => undefined; + const firstPending = new Promise((resolve) => { + releaseFirst = resolve; + }); + let markFirstStarted: () => void = () => undefined; + const firstStarted = new Promise((resolve) => { + markFirstStarted = resolve; + }); + const observed: Array = []; + mockProvider.placeOrder.mockImplementation(async (params) => { + observed.push(activeResolution); + if (params.symbol === 'BTC') { + markFirstStarted(); + await firstPending; + } + return { success: true }; + }); + + const first = tradingService.placeOrder({ + provider: mockProvider, + params: { + symbol: 'BTC', + isBuy: true, + size: '0.1', + orderType: 'market', + }, + context: mockContext, + reportOrderToDataLake: mockReportOrderToDataLake, + }); + const second = tradingService.placeOrder({ + provider: mockProvider, + params: { + symbol: 'ETH', + isBuy: true, + size: '1', + orderType: 'market', + }, + context: mockContext, + reportOrderToDataLake: mockReportOrderToDataLake, + }); + + await firstStarted; + expect(mockProvider.placeOrder).toHaveBeenCalledTimes(1); + releaseFirst(); + await Promise.all([first, second]); + + expect(observed).toStrictEqual([ + subscriptionResolution, + rewardsResolution, + ]); + }); + + it('places order successfully without fee discount', async () => { + const orderParams: OrderParams = { + symbol: 'BTC', + isBuy: true, + size: '0.1', + orderType: 'market', + }; + const mockOrderResult: OrderResult = { + success: true, + orderId: 'order-123', + filledSize: '0.1', + averagePrice: '50000', + }; + + mockProvider.placeOrder.mockResolvedValue(mockOrderResult); + mockRewardsIntegrationService.calculateUserFeeDiscount.mockResolvedValue( + undefined, + ); + + const result = await tradingService.placeOrder({ + provider: mockProvider, + params: orderParams, + context: mockContext, + reportOrderToDataLake: mockReportOrderToDataLake, + }); + + expect(result).toEqual(mockOrderResult); + expect(mockProvider.placeOrder).toHaveBeenCalledWith(orderParams); + expect(mockProvider.setUserFeeDiscount).toHaveBeenCalledWith(undefined); + }); + + it('places order successfully with fee discount applied and cleared', async () => { + const orderParams: OrderParams = { + symbol: 'BTC', + isBuy: true, + size: '0.1', + orderType: 'market', + leverage: 10, + }; + const mockOrderResult: OrderResult = { + success: true, + orderId: 'order-123', + filledSize: '0.1', + averagePrice: '50000', + }; + const contextWithRewards = createContextWithRewards(); + + mockProvider.placeOrder.mockResolvedValue(mockOrderResult); + mockRewardsIntegrationService.calculateUserFeeDiscount.mockResolvedValue( + 6500, + ); + + const result = await tradingService.placeOrder({ + provider: mockProvider, + params: orderParams, + context: contextWithRewards, + reportOrderToDataLake: mockReportOrderToDataLake, + }); + + expect(result).toEqual(mockOrderResult); + expect(mockProvider.setUserFeeDiscount).toHaveBeenCalledWith(6500); + expect(mockProvider.placeOrder).toHaveBeenCalledWith(orderParams); + expect(mockProvider.setUserFeeDiscount).toHaveBeenLastCalledWith( + undefined, + ); + }); + + it('clears fee discount when order placement fails', async () => { + const orderParams: OrderParams = { + symbol: 'BTC', + isBuy: true, + size: '0.1', + orderType: 'market', + }; + const contextWithRewards = createContextWithRewards(); + + mockProvider.placeOrder.mockRejectedValue( + new Error('Order placement failed'), + ); + mockRewardsIntegrationService.calculateUserFeeDiscount.mockResolvedValue( + 6500, + ); + + await expect( + tradingService.placeOrder({ + provider: mockProvider, + params: orderParams, + context: contextWithRewards, + reportOrderToDataLake: mockReportOrderToDataLake, + }), + ).rejects.toThrow('Order placement failed'); + + expect(mockProvider.setUserFeeDiscount).toHaveBeenCalledWith(6500); + expect(mockProvider.setUserFeeDiscount).toHaveBeenLastCalledWith( + undefined, + ); + }); + + it('adds and removes order from pending state optimistically', async () => { + const orderParams: OrderParams = { + symbol: 'BTC', + isBuy: true, + size: '0.1', + orderType: 'market', + }; + const mockOrderResult: OrderResult = { + success: true, + orderId: 'order-123', + }; + + mockProvider.placeOrder.mockResolvedValue(mockOrderResult); + mockRewardsIntegrationService.calculateUserFeeDiscount.mockResolvedValue( + undefined, + ); + + await tradingService.placeOrder({ + provider: mockProvider, + params: orderParams, + context: mockContext, + reportOrderToDataLake: mockReportOrderToDataLake, + }); + + expect(mockContext.stateManager?.update).toHaveBeenCalled(); + }); + + it('saves trade configuration when leverage is provided', async () => { + const orderParams: OrderParams = { + symbol: 'BTC', + isBuy: true, + size: '0.1', + orderType: 'market', + leverage: 10, + }; + const mockOrderResult: OrderResult = { + success: true, + orderId: 'order-123', + }; + + mockProvider.placeOrder.mockResolvedValue(mockOrderResult); + mockRewardsIntegrationService.calculateUserFeeDiscount.mockResolvedValue( + undefined, + ); + + await tradingService.placeOrder({ + provider: mockProvider, + params: orderParams, + context: mockContext, + reportOrderToDataLake: mockReportOrderToDataLake, + }); + + expect(mockSaveTradeConfiguration).toHaveBeenCalledWith('BTC', 10); + }); + + it('tracks analytics event when order succeeds', async () => { + const orderParams: OrderParams = { + symbol: 'BTC', + isBuy: true, + size: '0.1', + orderType: 'market', + leverage: 10, + trackingData: { + totalFee: 5, + marketPrice: 50000, + marginUsed: 5000, + metamaskFee: 5, + metamaskFeeRate: 0.001, + feeDiscountPercentage: 0.65, + estimatedPoints: 100, + }, + }; + const mockOrderResult: OrderResult = { + success: true, + orderId: 'order-123', + filledSize: '0.1', + averagePrice: '50000', + }; + + mockProvider.placeOrder.mockResolvedValue(mockOrderResult); + mockRewardsIntegrationService.calculateUserFeeDiscount.mockResolvedValue( + undefined, + ); + + await tradingService.placeOrder({ + provider: mockProvider, + params: orderParams, + context: mockContext, + reportOrderToDataLake: mockReportOrderToDataLake, + }); + + expect(mockDeps.metrics.trackPerpsEvent).toHaveBeenCalledWith( + PerpsAnalyticsEvent.TradeTransaction, + expect.objectContaining({ + status: 'executed', + }), + ); + }); + + it('includes trade_with_token and mm_pay fields when trackingData has tradeWithToken and pay token/network', async () => { + const orderParams: OrderParams = { + symbol: 'BTC', + isBuy: true, + size: '0.1', + orderType: 'market', + leverage: 10, + trackingData: { + totalFee: 0, + marketPrice: 50000, + tradeWithToken: true, + mmPayTokenSelected: 'USDC', + mmPayNetworkSelected: 'ethereum', + }, + }; + const mockOrderResult: OrderResult = { + success: true, + orderId: 'order-123', + filledSize: '0.1', + averagePrice: '50000', + }; + + mockProvider.placeOrder.mockResolvedValue(mockOrderResult); + mockRewardsIntegrationService.calculateUserFeeDiscount.mockResolvedValue( + undefined, + ); + + await tradingService.placeOrder({ + provider: mockProvider, + params: orderParams, + context: mockContext, + reportOrderToDataLake: mockReportOrderToDataLake, + }); + + expect(mockDeps.metrics.trackPerpsEvent).toHaveBeenCalledWith( + PerpsAnalyticsEvent.TradeTransaction, + expect.objectContaining({ + status: 'executed', + trade_with_token: true, + mm_pay_token_selected: 'USDC', + mm_pay_network_selected: 'ethereum', + }), + ); + }); + + it('includes chart_library when trackingData has chartLibrary', async () => { + const orderParams: OrderParams = { + symbol: 'BTC', + isBuy: true, + size: '0.1', + orderType: 'market', + leverage: 10, + trackingData: { + totalFee: 0, + marketPrice: 50000, + chartLibrary: PERPS_EVENT_VALUE.CHART_LIBRARY.ADVANCED, + }, + }; + const mockOrderResult: OrderResult = { + success: true, + orderId: 'order-123', + filledSize: '0.1', + averagePrice: '50000', + }; + + mockProvider.placeOrder.mockResolvedValue(mockOrderResult); + mockRewardsIntegrationService.calculateUserFeeDiscount.mockResolvedValue( + undefined, + ); + + await tradingService.placeOrder({ + provider: mockProvider, + params: orderParams, + context: mockContext, + reportOrderToDataLake: mockReportOrderToDataLake, + }); + + expect(mockDeps.metrics.trackPerpsEvent).toHaveBeenCalledWith( + PerpsAnalyticsEvent.TradeTransaction, + expect.objectContaining({ + status: 'executed', + chart_library: 'advanced', + }), + ); + }); + + it('includes mm_pay_token_selected "Perps Balance" when user uses perps balance', async () => { + const orderParams: OrderParams = { + symbol: 'BTC', + isBuy: true, + size: '0.1', + orderType: 'market', + leverage: 10, + trackingData: { + totalFee: 0, + marketPrice: 50000, + tradeWithToken: false, + }, + }; + const mockOrderResult: OrderResult = { + success: true, + orderId: 'order-123', + filledSize: '0.1', + averagePrice: '50000', + }; + + mockProvider.placeOrder.mockResolvedValue(mockOrderResult); + mockRewardsIntegrationService.calculateUserFeeDiscount.mockResolvedValue( + undefined, + ); + + await tradingService.placeOrder({ + provider: mockProvider, + params: orderParams, + context: mockContext, + reportOrderToDataLake: mockReportOrderToDataLake, + }); + + expect(mockDeps.metrics.trackPerpsEvent).toHaveBeenCalledWith( + PerpsAnalyticsEvent.TradeTransaction, + expect.objectContaining({ + status: 'executed', + trade_with_token: false, + mm_pay_token_selected: PERPS_EVENT_VALUE.MM_PAY_TOKEN.PERPS_BALANCE, + }), + ); + }); + + it('tracks analytics event when order fails', async () => { + const orderParams: OrderParams = { + symbol: 'BTC', + isBuy: true, + size: '0.1', + orderType: 'market', + }; + const mockOrderResult: OrderResult = { + success: false, + error: 'Insufficient margin', + }; + + mockProvider.placeOrder.mockResolvedValue(mockOrderResult); + mockRewardsIntegrationService.calculateUserFeeDiscount.mockResolvedValue( + undefined, + ); + + await tradingService.placeOrder({ + provider: mockProvider, + params: orderParams, + context: mockContext, + reportOrderToDataLake: mockReportOrderToDataLake, + }); + + expect(mockDeps.metrics.trackPerpsEvent).toHaveBeenCalledWith( + PerpsAnalyticsEvent.TradeTransaction, + expect.objectContaining({ + status: 'failed', + }), + ); + }); + + it('reports order to data lake on success (fire-and-forget)', async () => { + const orderParams: OrderParams = { + symbol: 'BTC', + isBuy: true, + size: '0.1', + orderType: 'market', + takeProfitPrice: '55000', + stopLossPrice: '45000', + }; + const mockOrderResult: OrderResult = { + success: true, + orderId: 'order-123', + }; + + mockProvider.placeOrder.mockResolvedValue(mockOrderResult); + mockRewardsIntegrationService.calculateUserFeeDiscount.mockResolvedValue( + undefined, + ); + + await tradingService.placeOrder({ + provider: mockProvider, + params: orderParams, + context: mockContext, + reportOrderToDataLake: mockReportOrderToDataLake, + }); + + expect(mockReportOrderToDataLake).toHaveBeenCalledWith({ + action: 'open', + symbol: 'BTC', + slPrice: 45000, + tpPrice: 55000, + }); + }); + + it('does not throw when data lake reporting fails', async () => { + const orderParams: OrderParams = { + symbol: 'BTC', + isBuy: true, + size: '0.1', + orderType: 'market', + }; + const mockOrderResult: OrderResult = { + success: true, + orderId: 'order-123', + }; + + mockProvider.placeOrder.mockResolvedValue(mockOrderResult); + mockRewardsIntegrationService.calculateUserFeeDiscount.mockResolvedValue( + undefined, + ); + mockReportOrderToDataLake.mockRejectedValue(new Error('Data lake error')); + + await expect( + tradingService.placeOrder({ + provider: mockProvider, + params: orderParams, + context: mockContext, + reportOrderToDataLake: mockReportOrderToDataLake, + }), + ).resolves.toBeDefined(); + }); + + it('creates trace for order placement', async () => { + const orderParams: OrderParams = { + symbol: 'BTC', + isBuy: true, + size: '0.1', + orderType: 'market', + }; + const mockOrderResult: OrderResult = { + success: true, + orderId: 'order-123', + }; + + mockProvider.placeOrder.mockResolvedValue(mockOrderResult); + mockRewardsIntegrationService.calculateUserFeeDiscount.mockResolvedValue( + undefined, + ); + + await tradingService.placeOrder({ + provider: mockProvider, + params: orderParams, + context: mockContext, + reportOrderToDataLake: mockReportOrderToDataLake, + }); + + expect(mockDeps.tracer.trace).toHaveBeenCalledWith( + expect.objectContaining({ + name: expect.any(String), + id: 'mock-trace-id', + tags: expect.objectContaining({ + payment_token: 'perps_balance', + }), + }), + ); + expect(mockDeps.tracer.endTrace).toHaveBeenCalled(); + }); + + it('adds payment_token tag for order trace (perps_balance when not tradeWithToken)', async () => { + const orderParams: OrderParams = { + symbol: 'BTC', + isBuy: true, + size: '0.1', + orderType: 'market', + }; + mockProvider.placeOrder.mockResolvedValue({ + success: true, + orderId: 'order-123', + filledSize: '0.1', + averagePrice: '50000', + }); + mockRewardsIntegrationService.calculateUserFeeDiscount.mockResolvedValue( + undefined, + ); + + await tradingService.placeOrder({ + provider: mockProvider, + params: orderParams, + context: mockContext, + reportOrderToDataLake: mockReportOrderToDataLake, + }); + + expect(mockDeps.tracer.trace).toHaveBeenCalledWith( + expect.objectContaining({ + tags: expect.objectContaining({ + payment_token: 'perps_balance', + }), + }), + ); + }); + + it('adds payment_token tag for order trace (token symbol when tradeWithToken)', async () => { + const orderParams: OrderParams = { + symbol: 'BTC', + isBuy: true, + size: '0.1', + orderType: 'market', + trackingData: { + totalFee: 0, + marketPrice: 50000, + tradeWithToken: true, + mmPayTokenSelected: 'ETH', + mmPayNetworkSelected: 'arbitrum', + }, + }; + mockProvider.placeOrder.mockResolvedValue({ + success: true, + orderId: 'order-123', + filledSize: '0.1', + averagePrice: '50000', + }); + mockRewardsIntegrationService.calculateUserFeeDiscount.mockResolvedValue( + undefined, + ); + + await tradingService.placeOrder({ + provider: mockProvider, + params: orderParams, + context: mockContext, + reportOrderToDataLake: mockReportOrderToDataLake, + }); + + expect(mockDeps.tracer.trace).toHaveBeenCalledWith( + expect.objectContaining({ + tags: expect.objectContaining({ + payment_token: 'ETH', + }), + }), + ); + }); + + it('handles order placement failure', async () => { + const orderParams: OrderParams = { + symbol: 'BTC', + isBuy: true, + size: '0.1', + orderType: 'market', + }; + mockProvider.placeOrder.mockResolvedValue({ + success: false, + error: 'Insufficient margin', + }); + mockRewardsIntegrationService.calculateUserFeeDiscount.mockResolvedValue( + undefined, + ); + + const result = await tradingService.placeOrder({ + provider: mockProvider, + params: orderParams, + context: mockContext, + reportOrderToDataLake: mockReportOrderToDataLake, + }); + + expect(result.success).toBe(false); + expect(result.error).toBe('Insufficient margin'); + expect(mockDeps.metrics.trackPerpsEvent).toHaveBeenCalled(); + }); + + it('handles provider exception during order placement', async () => { + const orderParams: OrderParams = { + symbol: 'BTC', + isBuy: true, + size: '0.1', + orderType: 'market', + }; + const error = new Error('Network timeout'); + mockProvider.placeOrder.mockRejectedValue(error); + mockRewardsIntegrationService.calculateUserFeeDiscount.mockResolvedValue( + undefined, + ); + + await expect( + tradingService.placeOrder({ + provider: mockProvider, + params: orderParams, + context: mockContext, + reportOrderToDataLake: mockReportOrderToDataLake, + }), + ).rejects.toThrow('Network timeout'); + + expect(mockDeps.logger.error).toHaveBeenCalledWith( + error, + expect.any(Object), + ); + expect(mockDeps.metrics.trackPerpsEvent).toHaveBeenCalled(); + }); + + it('handles data lake reporting failure', async () => { + const orderParams: OrderParams = { + symbol: 'BTC', + isBuy: true, + size: '0.1', + orderType: 'market', + }; + const mockOrderResult: OrderResult = { + success: true, + orderId: 'order-123', + filledSize: '0.1', + averagePrice: '50000', + }; + mockProvider.placeOrder.mockResolvedValue(mockOrderResult); + mockReportOrderToDataLake.mockRejectedValue( + new Error('Data lake unavailable'), + ); + mockRewardsIntegrationService.calculateUserFeeDiscount.mockResolvedValue( + undefined, + ); + + const result = await tradingService.placeOrder({ + provider: mockProvider, + params: orderParams, + context: mockContext, + reportOrderToDataLake: mockReportOrderToDataLake, + }); + + expect(result.success).toBe(true); + await new Promise((resolve) => setTimeout(resolve, 10)); + expect(mockDeps.logger.error).toHaveBeenCalledWith( + expect.objectContaining({ message: 'Data lake unavailable' }), + expect.any(Object), + ); + }); + }); + + describe('editOrder', () => { + it('edits order successfully without fee discount', async () => { + const editParams: EditOrderParams = { + orderId: 'order-123', + newOrder: { + symbol: 'BTC', + isBuy: true, + size: '0.2', + orderType: 'limit', + price: '51000', + }, + }; + const mockOrderResult: OrderResult = { + success: true, + orderId: 'order-123', + }; + + mockProvider.editOrder.mockResolvedValue(mockOrderResult); + mockRewardsIntegrationService.calculateUserFeeDiscount.mockResolvedValue( + undefined, + ); + + const result = await tradingService.editOrder({ + provider: mockProvider, + params: editParams, + context: mockContext, + }); + + expect(result).toEqual(mockOrderResult); + expect(mockProvider.editOrder).toHaveBeenCalledWith(editParams); + expect(mockProvider.setUserFeeDiscount).toHaveBeenCalledWith(undefined); + }); + + it('edits order successfully with fee discount applied and cleared', async () => { + const editParams: EditOrderParams = { + orderId: 'order-123', + newOrder: { + symbol: 'BTC', + isBuy: true, + size: '0.2', + orderType: 'limit', + price: '51000', + }, + }; + const mockOrderResult: OrderResult = { + success: true, + orderId: 'order-123', + }; + + mockProvider.editOrder.mockResolvedValue(mockOrderResult); + const contextWithRewards = createContextWithRewards(); + mockRewardsIntegrationService.calculateUserFeeDiscount.mockResolvedValue( + 6500, + ); + + const result = await tradingService.editOrder({ + provider: mockProvider, + params: editParams, + context: contextWithRewards, + }); + + expect(result).toEqual(mockOrderResult); + expect(mockProvider.setUserFeeDiscount).toHaveBeenCalledWith(6500); + expect(mockProvider.editOrder).toHaveBeenCalledWith(editParams); + expect(mockProvider.setUserFeeDiscount).toHaveBeenLastCalledWith( + undefined, + ); + }); + + it('tracks analytics event when edit succeeds', async () => { + const editParams: EditOrderParams = { + orderId: 'order-123', + newOrder: { + symbol: 'BTC', + isBuy: true, + size: '0.2', + orderType: 'limit', + price: '51000', + }, + }; + const mockOrderResult: OrderResult = { + success: true, + orderId: 'order-123', + }; + + mockProvider.editOrder.mockResolvedValue(mockOrderResult); + mockRewardsIntegrationService.calculateUserFeeDiscount.mockResolvedValue( + undefined, + ); + + await tradingService.editOrder({ + provider: mockProvider, + params: editParams, + context: mockContext, + }); + + expect(mockDeps.metrics.trackPerpsEvent).toHaveBeenCalledWith( + PerpsAnalyticsEvent.TradeTransaction, + expect.objectContaining({ + status: 'executed', + }), + ); + }); + + it('tracks analytics event when edit fails', async () => { + const editParams: EditOrderParams = { + orderId: 'order-123', + newOrder: { + symbol: 'BTC', + isBuy: true, + size: '0.2', + orderType: 'limit', + price: '51000', + }, + }; + const mockOrderResult: OrderResult = { + success: false, + error: 'Order not found', + }; + + mockProvider.editOrder.mockResolvedValue(mockOrderResult); + mockRewardsIntegrationService.calculateUserFeeDiscount.mockResolvedValue( + undefined, + ); + + await tradingService.editOrder({ + provider: mockProvider, + params: editParams, + context: mockContext, + }); + + expect(mockDeps.metrics.trackPerpsEvent).toHaveBeenCalledWith( + PerpsAnalyticsEvent.TradeTransaction, + expect.objectContaining({ + status: 'failed', + }), + ); + }); + + it('clears fee discount when edit throws exception', async () => { + const editParams: EditOrderParams = { + orderId: 'order-123', + newOrder: { + symbol: 'BTC', + isBuy: true, + size: '0.2', + orderType: 'limit', + price: '51000', + }, + }; + + mockProvider.editOrder.mockRejectedValue(new Error('Edit failed')); + const contextWithRewards = createContextWithRewards(); + mockRewardsIntegrationService.calculateUserFeeDiscount.mockResolvedValue( + 6500, + ); + + await expect( + tradingService.editOrder({ + provider: mockProvider, + params: editParams, + context: contextWithRewards, + }), + ).rejects.toThrow('Edit failed'); + + expect(mockProvider.setUserFeeDiscount).toHaveBeenCalledWith(6500); + expect(mockProvider.setUserFeeDiscount).toHaveBeenLastCalledWith( + undefined, + ); + }); + + it('handles order edit failure', async () => { + const editParams: EditOrderParams = { + orderId: 'order-123', + newOrder: { + symbol: 'BTC', + isBuy: true, + size: '0.2', + orderType: 'market', + }, + }; + mockProvider.editOrder.mockResolvedValue({ + success: false, + error: 'Order not found', + }); + + const result = await tradingService.editOrder({ + provider: mockProvider, + params: editParams, + context: mockContext, + }); + + expect(result.success).toBe(false); + expect(result.error).toBe('Order not found'); + expect(mockDeps.metrics.trackPerpsEvent).toHaveBeenCalled(); + }); + + it('handles provider exception during order edit', async () => { + const editParams: EditOrderParams = { + orderId: 'order-123', + newOrder: { + symbol: 'BTC', + isBuy: true, + size: '0.2', + orderType: 'market', + }, + }; + const error = new Error('Network timeout'); + mockProvider.editOrder.mockRejectedValue(error); + + await expect( + tradingService.editOrder({ + provider: mockProvider, + params: editParams, + context: mockContext, + }), + ).rejects.toThrow('Network timeout'); + + expect(mockDeps.logger.error).toHaveBeenCalledWith( + error, + expect.any(Object), + ); + expect(mockDeps.metrics.trackPerpsEvent).toHaveBeenCalled(); + }); + }); + + describe('cancelOrder', () => { + it('cancels order successfully', async () => { + const cancelParams: CancelOrderParams = { + orderId: 'order-123', + symbol: 'BTC', + }; + const mockResult = { + success: true, + orderId: 'order-123', + }; + + mockProvider.cancelOrder.mockResolvedValue(mockResult); + + const result = await tradingService.cancelOrder({ + provider: mockProvider, + params: cancelParams, + context: mockContext, + }); + + expect(result).toEqual(mockResult); + expect(mockProvider.cancelOrder).toHaveBeenCalledWith(cancelParams); + }); + + it('tracks analytics event when cancellation succeeds', async () => { + const cancelParams: CancelOrderParams = { + orderId: 'order-123', + symbol: 'BTC', + }; + const mockResult = { + success: true, + orderId: 'order-123', + }; + + mockProvider.cancelOrder.mockResolvedValue(mockResult); + + await tradingService.cancelOrder({ + provider: mockProvider, + params: cancelParams, + context: mockContext, + }); + + expect(mockDeps.metrics.trackPerpsEvent).toHaveBeenCalledWith( + PerpsAnalyticsEvent.OrderCancelTransaction, + expect.objectContaining({ + status: 'executed', + }), + ); + }); + + it('tracks analytics event when cancellation fails', async () => { + const cancelParams: CancelOrderParams = { + orderId: 'order-123', + symbol: 'BTC', + }; + const mockResult = { + success: false, + error: 'Order not found', + }; + + mockProvider.cancelOrder.mockResolvedValue(mockResult); + + await tradingService.cancelOrder({ + provider: mockProvider, + params: cancelParams, + context: mockContext, + }); + + expect(mockDeps.metrics.trackPerpsEvent).toHaveBeenCalledWith( + PerpsAnalyticsEvent.OrderCancelTransaction, + expect.objectContaining({ + status: 'failed', + }), + ); + }); + + it('logs error when cancellation throws exception', async () => { + const cancelParams: CancelOrderParams = { + orderId: 'order-123', + symbol: 'BTC', + }; + + mockProvider.cancelOrder.mockRejectedValue(new Error('Cancel failed')); + + await expect( + tradingService.cancelOrder({ + provider: mockProvider, + params: cancelParams, + context: mockContext, + }), + ).rejects.toThrow('Cancel failed'); + + expect(mockDeps.logger.error).toHaveBeenCalled(); + }); + + it('handles order cancel failure', async () => { + const cancelParams: CancelOrderParams = { + orderId: 'order-123', + symbol: 'BTC', + }; + mockProvider.cancelOrder.mockResolvedValue({ + success: false, + error: 'Order already filled', + }); + + const result = await tradingService.cancelOrder({ + provider: mockProvider, + params: cancelParams, + context: mockContext, + }); + + expect(result.success).toBe(false); + expect(result.error).toBe('Order already filled'); + expect(mockDeps.metrics.trackPerpsEvent).toHaveBeenCalled(); + }); + + it('logs error when provider returns a failure result without throwing', async () => { + const cancelParams: CancelOrderParams = { + orderId: 'order-123', + symbol: 'BTC', + }; + mockProvider.cancelOrder.mockResolvedValue({ + success: false, + error: 'Order already filled', + }); + + const result = await tradingService.cancelOrder({ + provider: mockProvider, + params: cancelParams, + context: mockContext, + }); + + expect(result.success).toBe(false); + expect(mockDeps.logger.error).toHaveBeenCalledWith( + expect.objectContaining({ message: 'Order already filled' }), + expect.objectContaining({ + controller: 'TradingService', + method: 'cancelOrder', + symbol: 'BTC', + }), + ); + }); + + it('handles provider exception during order cancel', async () => { + const cancelParams: CancelOrderParams = { + orderId: 'order-123', + symbol: 'BTC', + }; + const error = new Error('Network error'); + mockProvider.cancelOrder.mockRejectedValue(error); + + await expect( + tradingService.cancelOrder({ + provider: mockProvider, + params: cancelParams, + context: mockContext, + }), + ).rejects.toThrow('Network error'); + + expect(mockDeps.logger.error).toHaveBeenCalledWith( + error, + expect.any(Object), + ); + expect(mockDeps.metrics.trackPerpsEvent).toHaveBeenCalled(); + }); + }); + + describe('cancelOrders', () => { + const mockOrders: Order[] = [ + { + orderId: 'order-1', + symbol: 'BTC', + side: 'buy', + orderType: 'limit', + price: '50000', + size: '0.1', + originalSize: '0.1', + filledSize: '0', + remainingSize: '0.1', + status: 'open', + timestamp: 1234567890, + }, + { + orderId: 'order-2', + symbol: 'ETH', + side: 'sell', + orderType: 'market', + detailedOrderType: 'Stop Market', + isTrigger: true, + reduceOnly: true, + price: '3000', + size: '1.0', + originalSize: '1.0', + filledSize: '0', + remainingSize: '1.0', + status: 'open', + timestamp: 1234567891, + }, + { + orderId: 'order-3', + symbol: 'BTC', + side: 'buy', + orderType: 'limit', + detailedOrderType: 'Take Profit Limit', + isTrigger: true, + reduceOnly: true, + price: '55000', + size: '0.1', + originalSize: '0.1', + filledSize: '0', + remainingSize: '0.1', + status: 'open', + timestamp: 1234567892, + }, + ]; + + it('cancels all orders excluding TP/SL when cancelAll is true', async () => { + const params: CancelOrdersParams = { + cancelAll: true, + }; + + mockGetOpenOrders.mockResolvedValue(mockOrders); + (mockProvider.cancelOrders as jest.Mock).mockResolvedValue({ + success: true, + results: [{ success: true, orderId: 'order-1' }], + }); + + const result = await tradingService.cancelOrders({ + provider: mockProvider, + params, + context: { ...mockContext, getOpenOrders: mockGetOpenOrders }, + withStreamPause: mockWithStreamPause, + }); + + expect(result.success).toBe(true); + expect(mockProvider.cancelOrders).toHaveBeenCalledWith([ + { symbol: 'BTC', orderId: 'order-1' }, + ]); + }); + + it('allows canceling TP/SL orders when specified by orderId', async () => { + const params: CancelOrdersParams = { + orderIds: ['order-2', 'order-3'], + }; + + mockGetOpenOrders.mockResolvedValue(mockOrders); + (mockProvider.cancelOrders as jest.Mock).mockResolvedValue({ + success: true, + results: [ + { success: true, orderId: 'order-2' }, + { success: true, orderId: 'order-3' }, + ], + }); + + const result = await tradingService.cancelOrders({ + provider: mockProvider, + params, + context: { ...mockContext, getOpenOrders: mockGetOpenOrders }, + withStreamPause: mockWithStreamPause, + }); + + expect(result.success).toBe(true); + expect(result.results).toHaveLength(2); + }); + + it('cancels orders for specific coins when provided', async () => { + const params: CancelOrdersParams = { + symbols: ['BTC'], + }; + + mockGetOpenOrders.mockResolvedValue(mockOrders); + (mockProvider.cancelOrders as jest.Mock).mockResolvedValue({ + success: true, + results: [{ success: true, orderId: 'order-1' }], + }); + + const result = await tradingService.cancelOrders({ + provider: mockProvider, + params, + context: { ...mockContext, getOpenOrders: mockGetOpenOrders }, + withStreamPause: mockWithStreamPause, + }); + + expect(result.success).toBe(true); + expect(mockProvider.cancelOrders).toHaveBeenCalledWith([ + { symbol: 'BTC', orderId: 'order-1' }, + { symbol: 'BTC', orderId: 'order-3' }, + ]); + }); + + it('returns empty results when no orders match filters', async () => { + const params: CancelOrdersParams = { + symbols: ['SOL'], + }; + + mockGetOpenOrders.mockResolvedValue(mockOrders); + + const result = await tradingService.cancelOrders({ + provider: mockProvider, + params, + context: { ...mockContext, getOpenOrders: mockGetOpenOrders }, + withStreamPause: mockWithStreamPause, + }); + + expect(result.success).toBe(false); + expect(result.results).toEqual([]); + expect(mockProvider.cancelOrders).not.toHaveBeenCalled(); + }); + + it('handles partial failures gracefully', async () => { + const params: CancelOrdersParams = { + cancelAll: true, + }; + + mockGetOpenOrders.mockResolvedValue(mockOrders); + (mockProvider.cancelOrders as jest.Mock).mockResolvedValue({ + success: false, + results: [ + { success: true, orderId: 'order-1' }, + { success: false, orderId: 'order-2', error: 'Order not found' }, + ], + }); + + const result = await tradingService.cancelOrders({ + provider: mockProvider, + params, + context: { ...mockContext, getOpenOrders: mockGetOpenOrders }, + withStreamPause: mockWithStreamPause, + }); + + expect(result.success).toBe(false); + expect(result.results).toHaveLength(2); + }); + + it('pauses and resumes streams during batch cancellation', async () => { + const params: CancelOrdersParams = { + cancelAll: true, + }; + + mockGetOpenOrders.mockResolvedValue(mockOrders); + (mockProvider.cancelOrders as jest.Mock).mockResolvedValue({ + success: true, + results: [{ success: true, orderId: 'order-1' }], + }); + + await tradingService.cancelOrders({ + provider: mockProvider, + params, + context: { ...mockContext, getOpenOrders: mockGetOpenOrders }, + withStreamPause: mockWithStreamPause, + }); + + expect(mockWithStreamPause).toHaveBeenCalled(); + }); + + it('resumes streams even when operation throws error', async () => { + const params: CancelOrdersParams = { + cancelAll: true, + }; + + mockGetOpenOrders.mockResolvedValue(mockOrders); + mockWithStreamPause.mockImplementation( + async (callback) => await callback(), + ); + (mockProvider.cancelOrders as jest.Mock).mockRejectedValue( + new Error('Cancel failed'), + ); + + await expect( + tradingService.cancelOrders({ + provider: mockProvider, + params, + context: { ...mockContext, getOpenOrders: mockGetOpenOrders }, + withStreamPause: mockWithStreamPause, + }), + ).rejects.toThrow('Cancel failed'); + + expect(mockWithStreamPause).toHaveBeenCalled(); + }); + + it('uses fallback when provider does not support batch cancellation', async () => { + const params: CancelOrdersParams = { + orderIds: ['order-1', 'order-2'], + }; + + mockGetOpenOrders.mockResolvedValue(mockOrders); + delete mockProvider.cancelOrders; + mockProvider.cancelOrder.mockResolvedValue({ success: true }); + + const result = await tradingService.cancelOrders({ + provider: mockProvider, + params, + context: { ...mockContext, getOpenOrders: mockGetOpenOrders }, + withStreamPause: mockWithStreamPause, + }); + + expect(result.results).toHaveLength(2); + expect(mockProvider.cancelOrder).toHaveBeenCalledTimes(2); + }); + + it('logs batch error when provider.cancelOrders returns partial/full failure', async () => { + const params: CancelOrdersParams = { cancelAll: true }; + mockGetOpenOrders.mockResolvedValue(mockOrders); + mockWithStreamPause.mockImplementation( + async (callback) => await callback(), + ); + (mockProvider.cancelOrders as jest.Mock).mockResolvedValue({ + success: false, + successCount: 0, + failureCount: 2, + results: [ + { + orderId: 'order-1', + symbol: 'BTC', + success: false, + error: 'rate limit', + }, + { + orderId: 'order-2', + symbol: 'ETH', + success: false, + error: 'not found', + }, + ], + }); + + const result = await tradingService.cancelOrders({ + provider: mockProvider, + params, + context: { ...mockContext, getOpenOrders: mockGetOpenOrders }, + withStreamPause: mockWithStreamPause, + }); + + expect(result.success).toBe(false); + expect(mockDeps.logger.error).toHaveBeenCalledWith( + expect.objectContaining({ + message: expect.stringContaining( + 'cancelOrders batch failure: 2/2 failed', + ), + }), + expect.objectContaining({ + controller: 'TradingService', + method: 'cancelOrders', + }), + ); + }); + + it('does NOT log batch error when using fallback path (provider.cancelOrders undefined)', async () => { + const params: CancelOrdersParams = { cancelAll: true }; + mockGetOpenOrders.mockResolvedValue(mockOrders); + mockWithStreamPause.mockImplementation( + async (callback) => await callback(), + ); + delete mockProvider.cancelOrders; + mockProvider.cancelOrder.mockResolvedValue({ success: true }); + + await tradingService.cancelOrders({ + provider: mockProvider, + params, + context: { ...mockContext, getOpenOrders: mockGetOpenOrders }, + withStreamPause: mockWithStreamPause, + }); + + // Batch-level log must not fire; individual leaf logs cover per-order failures + const batchErrorCalls = ( + mockDeps.logger.error as jest.Mock + ).mock.calls.filter( + ([err]: [Error]) => + err instanceof Error && + err.message.includes('cancelOrders batch failure'), + ); + expect(batchErrorCalls).toHaveLength(0); + }); + }); + + describe('closePosition', () => { + const mockPosition: Position = { + symbol: 'BTC', + size: '0.5', + entryPrice: '50000', + liquidationPrice: '45000', + leverage: { type: 'cross', value: 10 }, + marginUsed: '2500', + maxLeverage: 20, + positionValue: '25000', + returnOnEquity: '0.2', + unrealizedPnl: '5000', + cumulativeFunding: { allTime: '0', sinceOpen: '0', sinceChange: '0' }, + takeProfitCount: 0, + stopLossCount: 0, + }; + + it('closes position successfully without fee discount', async () => { + const params: ClosePositionParams = { + symbol: 'BTC', + }; + const mockResult: OrderResult = { + success: true, + orderId: 'close-123', + filledSize: '0.5', + averagePrice: '55000', + }; + + mockGetPositions.mockResolvedValue([mockPosition]); + mockProvider.closePosition.mockResolvedValue(mockResult); + mockRewardsIntegrationService.calculateUserFeeDiscount.mockResolvedValue( + undefined, + ); + + const result = await tradingService.closePosition({ + provider: mockProvider, + params, + context: { ...mockContext, getPositions: mockGetPositions }, + reportOrderToDataLake: mockReportOrderToDataLake, + }); + + expect(result).toEqual(mockResult); + expect(mockProvider.closePosition).toHaveBeenCalledWith(params); + expect(mockProvider.setUserFeeDiscount).toHaveBeenCalledWith(undefined); + }); + + it('closes position successfully with fee discount applied and cleared', async () => { + const params: ClosePositionParams = { + symbol: 'BTC', + }; + const mockResult: OrderResult = { + success: true, + orderId: 'close-123', + }; + + mockGetPositions.mockResolvedValue([mockPosition]); + mockProvider.closePosition.mockResolvedValue(mockResult); + const contextWithRewards = createContextWithRewards(); + mockRewardsIntegrationService.calculateUserFeeDiscount.mockResolvedValue( + 6500, + ); + + const result = await tradingService.closePosition({ + provider: mockProvider, + params, + context: { ...contextWithRewards, getPositions: mockGetPositions }, + reportOrderToDataLake: mockReportOrderToDataLake, + }); + + expect(result).toEqual(mockResult); + expect(mockProvider.setUserFeeDiscount).toHaveBeenCalledWith(6500); + expect(mockProvider.closePosition).toHaveBeenCalledWith(params); + expect(mockProvider.setUserFeeDiscount).toHaveBeenLastCalledWith( + undefined, + ); + }); + + it('tracks analytics with PNL calculation', async () => { + const params: ClosePositionParams = { + symbol: 'BTC', + }; + const mockResult: OrderResult = { + success: true, + orderId: 'close-123', + filledSize: '0.5', + averagePrice: '55000', + }; + + mockGetPositions.mockResolvedValue([mockPosition]); + mockProvider.closePosition.mockResolvedValue(mockResult); + mockRewardsIntegrationService.calculateUserFeeDiscount.mockResolvedValue( + undefined, + ); + + await tradingService.closePosition({ + provider: mockProvider, + params, + context: { ...mockContext, getPositions: mockGetPositions }, + reportOrderToDataLake: mockReportOrderToDataLake, + }); + + expect(mockDeps.metrics.trackPerpsEvent).toHaveBeenCalledWith( + PerpsAnalyticsEvent.PositionCloseTransaction, + expect.objectContaining({ + status: 'executed', + }), + ); + }); + + it('reports order to data lake on successful close', async () => { + const params: ClosePositionParams = { + symbol: 'BTC', + }; + const mockResult: OrderResult = { + success: true, + orderId: 'close-123', + }; + + mockGetPositions.mockResolvedValue([mockPosition]); + mockProvider.closePosition.mockResolvedValue(mockResult); + mockRewardsIntegrationService.calculateUserFeeDiscount.mockResolvedValue( + undefined, + ); + + await tradingService.closePosition({ + provider: mockProvider, + params, + context: { ...mockContext, getPositions: mockGetPositions }, + reportOrderToDataLake: mockReportOrderToDataLake, + }); + + expect(mockReportOrderToDataLake).toHaveBeenCalledWith({ + action: 'close', + symbol: 'BTC', + }); + }); + + it('detects direction from position size', async () => { + const shortPosition: Position = { + ...mockPosition, + size: '-0.5', + }; + const params: ClosePositionParams = { + symbol: 'BTC', + }; + const mockResult: OrderResult = { + success: true, + orderId: 'close-123', + }; + + mockGetPositions.mockResolvedValue([shortPosition]); + mockProvider.closePosition.mockResolvedValue(mockResult); + mockRewardsIntegrationService.calculateUserFeeDiscount.mockResolvedValue( + undefined, + ); + + await tradingService.closePosition({ + provider: mockProvider, + params, + context: { ...mockContext, getPositions: mockGetPositions }, + reportOrderToDataLake: mockReportOrderToDataLake, + }); + + expect(mockDeps.metrics.trackPerpsEvent).toHaveBeenCalledWith( + PerpsAnalyticsEvent.PositionCloseTransaction, + expect.objectContaining({ + direction: expect.any(String), + }), + ); + }); + + it('tracks analytics on position close failure', async () => { + const params: ClosePositionParams = { + symbol: 'BTC', + }; + const mockFailureResult: OrderResult = { + success: false, + error: 'Insufficient liquidity', + }; + + mockGetPositions.mockResolvedValue([mockPosition]); + mockProvider.closePosition.mockResolvedValue(mockFailureResult); + mockRewardsIntegrationService.calculateUserFeeDiscount.mockResolvedValue( + undefined, + ); + + const result = await tradingService.closePosition({ + provider: mockProvider, + params, + context: { ...mockContext, getPositions: mockGetPositions }, + reportOrderToDataLake: mockReportOrderToDataLake, + }); + + expect(result).toEqual(mockFailureResult); + expect(mockDeps.metrics.trackPerpsEvent).toHaveBeenCalledWith( + PerpsAnalyticsEvent.PositionCloseTransaction, + expect.objectContaining({ + status: 'failed', + error_message: 'Insufficient liquidity', + }), + ); + }); + + it('logs error when provider returns a failure result without throwing', async () => { + const params: ClosePositionParams = { symbol: 'BTC' }; + const mockFailureResult: OrderResult = { + success: false, + error: 'Insufficient liquidity', + }; + + mockGetPositions.mockResolvedValue([mockPosition]); + mockProvider.closePosition.mockResolvedValue(mockFailureResult); + mockRewardsIntegrationService.calculateUserFeeDiscount.mockResolvedValue( + undefined, + ); + + const result = await tradingService.closePosition({ + provider: mockProvider, + params, + context: { ...mockContext, getPositions: mockGetPositions }, + reportOrderToDataLake: mockReportOrderToDataLake, + }); + + expect(result).toEqual(mockFailureResult); + expect(mockDeps.logger.error).toHaveBeenCalledWith( + expect.objectContaining({ message: 'Insufficient liquidity' }), + expect.objectContaining({ + controller: 'TradingService', + method: 'closePosition', + symbol: 'BTC', + }), + ); + }); + }); + + describe('closePositions', () => { + const mockPositions: Position[] = [ + { + symbol: 'BTC', + size: '0.5', + entryPrice: '50000', + liquidationPrice: '45000', + leverage: { type: 'cross', value: 10 }, + marginUsed: '2500', + maxLeverage: 20, + positionValue: '25000', + returnOnEquity: '0.2', + unrealizedPnl: '5000', + cumulativeFunding: { allTime: '0', sinceOpen: '0', sinceChange: '0' }, + takeProfitCount: 0, + stopLossCount: 0, + }, + { + symbol: 'ETH', + size: '5.0', + entryPrice: '3000', + liquidationPrice: '2700', + leverage: { type: 'cross', value: 10 }, + marginUsed: '1500', + maxLeverage: 20, + positionValue: '15000', + returnOnEquity: '0.1', + unrealizedPnl: '1500', + cumulativeFunding: { allTime: '0', sinceOpen: '0', sinceChange: '0' }, + takeProfitCount: 0, + stopLossCount: 0, + }, + ]; + + it('closes all positions when closeAll is true', async () => { + const params: ClosePositionsParams = { + closeAll: true, + }; + + mockGetPositions.mockResolvedValue(mockPositions); + (mockProvider.closePositions as jest.Mock).mockResolvedValue({ + success: true, + results: [ + { success: true, orderId: 'close-1', symbol: 'BTC' }, + { success: true, orderId: 'close-2', symbol: 'ETH' }, + ], + }); + mockRewardsIntegrationService.calculateUserFeeDiscount.mockResolvedValue( + undefined, + ); + + const result = await tradingService.closePositions({ + provider: mockProvider, + params, + context: { ...mockContext, getPositions: mockGetPositions }, + }); + + expect(result.success).toBe(true); + expect(result.results).toHaveLength(2); + }); + + it('closes specific coins when provided', async () => { + const params: ClosePositionsParams = { + symbols: ['BTC'], + }; + + mockGetPositions.mockResolvedValue(mockPositions); + (mockProvider.closePositions as jest.Mock).mockResolvedValue({ + success: true, + results: [{ success: true, orderId: 'close-1', symbol: 'BTC' }], + }); + mockRewardsIntegrationService.calculateUserFeeDiscount.mockResolvedValue( + undefined, + ); + + const result = await tradingService.closePositions({ + provider: mockProvider, + params, + context: { ...mockContext, getPositions: mockGetPositions }, + }); + + expect(result.success).toBe(true); + expect(result.results).toHaveLength(1); + }); + + it('returns empty results when no positions match', async () => { + const params: ClosePositionsParams = { + symbols: ['SOL'], + }; + + mockGetPositions.mockResolvedValue(mockPositions); + (mockProvider.closePositions as jest.Mock).mockResolvedValue({ + success: false, + successCount: 0, + failureCount: 0, + results: [], + }); + + const result = await tradingService.closePositions({ + provider: mockProvider, + params, + context: { ...mockContext, getPositions: mockGetPositions }, + }); + + expect(result.success).toBe(false); + expect(result.results).toEqual([]); + }); + + it('handles partial failures gracefully', async () => { + const params: ClosePositionsParams = { + closeAll: true, + }; + + mockGetPositions.mockResolvedValue(mockPositions); + (mockProvider.closePositions as jest.Mock).mockResolvedValue({ + success: false, + results: [ + { success: true, orderId: 'close-1', symbol: 'BTC' }, + { success: false, symbol: 'ETH', error: 'Insufficient liquidity' }, + ], + }); + mockRewardsIntegrationService.calculateUserFeeDiscount.mockResolvedValue( + undefined, + ); + + const result = await tradingService.closePositions({ + provider: mockProvider, + params, + context: { ...mockContext, getPositions: mockGetPositions }, + }); + + expect(result.success).toBe(false); + expect(result.results).toHaveLength(2); + }); + + it('uses fallback when provider does not support batch closing', async () => { + const params: ClosePositionsParams = { + symbols: ['BTC'], + }; + + mockGetPositions.mockResolvedValue(mockPositions); + delete mockProvider.closePositions; + mockProvider.closePosition.mockResolvedValue({ + success: true, + orderId: 'close-1', + }); + mockRewardsIntegrationService.calculateUserFeeDiscount.mockResolvedValue( + undefined, + ); + + const result = await tradingService.closePositions({ + provider: mockProvider, + params, + context: { ...mockContext, getPositions: mockGetPositions }, + }); + + expect(result.results).toHaveLength(1); + expect(mockProvider.closePosition).toHaveBeenCalledTimes(1); + }); + + it('logs batch error when provider.closePositions returns partial/full failure', async () => { + const params: ClosePositionsParams = { closeAll: true }; + (mockProvider.closePositions as jest.Mock).mockResolvedValue({ + success: false, + successCount: 0, + failureCount: 2, + results: [ + { symbol: 'BTC', success: false, error: 'insufficient liquidity' }, + { symbol: 'ETH', success: false, error: 'min size' }, + ], + }); + mockRewardsIntegrationService.calculateUserFeeDiscount.mockResolvedValue( + undefined, + ); + + const result = await tradingService.closePositions({ + provider: mockProvider, + params, + context: { ...mockContext, getPositions: mockGetPositions }, + }); + + expect(result.success).toBe(false); + expect(mockDeps.logger.error).toHaveBeenCalledWith( + expect.objectContaining({ + message: expect.stringContaining( + 'closePositions batch failure: 2/2 failed', + ), + }), + expect.objectContaining({ + controller: 'TradingService', + method: 'closePositions', + }), + ); + }); + + it('does NOT log batch error when using fallback path (provider.closePositions undefined)', async () => { + const params: ClosePositionsParams = { symbols: ['BTC'] }; + mockGetPositions.mockResolvedValue(mockPositions); + delete mockProvider.closePositions; + mockProvider.closePosition.mockResolvedValue({ + success: true, + orderId: 'close-1', + }); + mockRewardsIntegrationService.calculateUserFeeDiscount.mockResolvedValue( + undefined, + ); + + await tradingService.closePositions({ + provider: mockProvider, + params, + context: { ...mockContext, getPositions: mockGetPositions }, + }); + + // Batch-level log must not fire; individual leaf logs cover per-position failures + const batchErrorCalls = ( + mockDeps.logger.error as jest.Mock + ).mock.calls.filter( + ([err]: [Error]) => + err instanceof Error && + err.message.includes('closePositions batch failure'), + ); + expect(batchErrorCalls).toHaveLength(0); + }); + }); + + describe('updatePositionTPSL', () => { + const mockPosition: Position = { + symbol: 'BTC', + size: '0.5', + entryPrice: '50000', + liquidationPrice: '45000', + leverage: { type: 'cross', value: 10 }, + marginUsed: '2500', + maxLeverage: 20, + positionValue: '25000', + returnOnEquity: '0.2', + unrealizedPnl: '5000', + cumulativeFunding: { allTime: '0', sinceOpen: '0', sinceChange: '0' }, + takeProfitCount: 0, + stopLossCount: 0, + }; + + it('updates TP/SL successfully without fee discount', async () => { + const params: UpdatePositionTPSLParams = { + symbol: 'BTC', + takeProfitPrice: '55000', + stopLossPrice: '45000', + }; + const mockResult: OrderResult = { + success: true, + }; + + mockGetPositions.mockResolvedValue([mockPosition]); + mockProvider.updatePositionTPSL.mockResolvedValue(mockResult); + mockRewardsIntegrationService.calculateUserFeeDiscount.mockResolvedValue( + undefined, + ); + + const result = await tradingService.updatePositionTPSL({ + provider: mockProvider, + params, + context: { ...mockContext, getPositions: mockGetPositions }, + }); + + expect(result).toEqual(mockResult); + expect(mockProvider.updatePositionTPSL).toHaveBeenCalledWith(params); + expect(mockProvider.setUserFeeDiscount).toHaveBeenCalledWith(undefined); + }); + + it('updates TP/SL successfully with fee discount applied and cleared', async () => { + const params: UpdatePositionTPSLParams = { + symbol: 'BTC', + takeProfitPrice: '55000', + }; + const mockResult: OrderResult = { + success: true, + }; + + mockGetPositions.mockResolvedValue([mockPosition]); + mockProvider.updatePositionTPSL.mockResolvedValue(mockResult); + const contextWithRewards = createContextWithRewards(); + mockRewardsIntegrationService.calculateUserFeeDiscount.mockResolvedValue( + 6500, + ); + + const result = await tradingService.updatePositionTPSL({ + provider: mockProvider, + params, + context: { ...contextWithRewards, getPositions: mockGetPositions }, + }); + + expect(result).toEqual(mockResult); + expect(mockProvider.setUserFeeDiscount).toHaveBeenCalledWith(6500); + expect(mockProvider.updatePositionTPSL).toHaveBeenCalledWith(params); + expect(mockProvider.setUserFeeDiscount).toHaveBeenLastCalledWith( + undefined, + ); + }); + + it('tracks analytics event when update succeeds', async () => { + const params: UpdatePositionTPSLParams = { + symbol: 'BTC', + takeProfitPrice: '55000', + stopLossPrice: '45000', + }; + const mockResult: OrderResult = { + success: true, + }; + + mockGetPositions.mockResolvedValue([mockPosition]); + mockProvider.updatePositionTPSL.mockResolvedValue(mockResult); + mockRewardsIntegrationService.calculateUserFeeDiscount.mockResolvedValue( + undefined, + ); + + await tradingService.updatePositionTPSL({ + provider: mockProvider, + params, + context: { ...mockContext, getPositions: mockGetPositions }, + }); + + expect(mockDeps.metrics.trackPerpsEvent).toHaveBeenCalledWith( + PerpsAnalyticsEvent.RiskManagement, + expect.objectContaining({ + status: 'executed', + }), + ); + }); + + it('tracks analytics event when update fails', async () => { + const params: UpdatePositionTPSLParams = { + symbol: 'BTC', + takeProfitPrice: '55000', + }; + const mockResult: OrderResult = { + success: false, + error: 'Invalid price', + }; + + mockGetPositions.mockResolvedValue([mockPosition]); + mockProvider.updatePositionTPSL.mockResolvedValue(mockResult); + mockRewardsIntegrationService.calculateUserFeeDiscount.mockResolvedValue( + undefined, + ); + + await tradingService.updatePositionTPSL({ + provider: mockProvider, + params, + context: { ...mockContext, getPositions: mockGetPositions }, + }); + + expect(mockDeps.metrics.trackPerpsEvent).toHaveBeenCalledWith( + PerpsAnalyticsEvent.RiskManagement, + expect.objectContaining({ + status: 'failed', + }), + ); + }); + + it('includes direction and size in analytics', async () => { + const params: UpdatePositionTPSLParams = { + symbol: 'BTC', + stopLossPrice: '45000', + trackingData: { + direction: 'long', + positionSize: 0.5, + source: 'tp_sl_view', + }, + }; + const mockResult: OrderResult = { + success: true, + }; + + mockGetPositions.mockResolvedValue([mockPosition]); + mockProvider.updatePositionTPSL.mockResolvedValue(mockResult); + mockRewardsIntegrationService.calculateUserFeeDiscount.mockResolvedValue( + undefined, + ); + + await tradingService.updatePositionTPSL({ + provider: mockProvider, + params, + context: { ...mockContext, getPositions: mockGetPositions }, + }); + + expect(mockDeps.metrics.trackPerpsEvent).toHaveBeenCalledWith( + PerpsAnalyticsEvent.RiskManagement, + expect.objectContaining({ + direction: expect.any(String), + position_size: expect.any(Number), + }), + ); + }); + + it('clears fee discount when update throws exception', async () => { + const params: UpdatePositionTPSLParams = { + symbol: 'BTC', + takeProfitPrice: '55000', + }; + + mockGetPositions.mockResolvedValue([mockPosition]); + mockProvider.updatePositionTPSL.mockRejectedValue( + new Error('Update failed'), + ); + const contextWithRewards = createContextWithRewards(); + mockRewardsIntegrationService.calculateUserFeeDiscount.mockResolvedValue( + 6500, + ); + + await expect( + tradingService.updatePositionTPSL({ + provider: mockProvider, + params, + context: { ...contextWithRewards, getPositions: mockGetPositions }, + }), + ).rejects.toThrow('Update failed'); + + expect(mockProvider.setUserFeeDiscount).toHaveBeenCalledWith(6500); + expect(mockProvider.setUserFeeDiscount).toHaveBeenLastCalledWith( + undefined, + ); + }); + + it('logs error with message and context when provider throws', async () => { + const params: UpdatePositionTPSLParams = { + symbol: 'BTC', + takeProfitPrice: '55000', + stopLossPrice: '45000', + }; + + mockGetPositions.mockResolvedValue([mockPosition]); + mockProvider.updatePositionTPSL.mockRejectedValue( + new Error('TPSL provider failure'), + ); + mockRewardsIntegrationService.calculateUserFeeDiscount.mockResolvedValue( + undefined, + ); + + await expect( + tradingService.updatePositionTPSL({ + provider: mockProvider, + params, + context: { ...mockContext, getPositions: mockGetPositions }, + }), + ).rejects.toThrow('TPSL provider failure'); + + expect(mockDeps.logger.error).toHaveBeenCalledWith( + expect.objectContaining({ message: 'TPSL provider failure' }), + expect.objectContaining({ + controller: 'TradingService', + method: 'updatePositionTPSL', + symbol: 'BTC', + }), + ); + }); + }); + + describe('updateMargin', () => { + it('updates margin successfully when adding margin', async () => { + const mockResult = { success: true }; + mockProvider.updateMargin = jest.fn().mockResolvedValue(mockResult); + + const result = await tradingService.updateMargin({ + provider: mockProvider, + symbol: 'BTC', + amount: '100', + context: mockContext, + }); + + expect(result).toEqual(mockResult); + expect(mockProvider.updateMargin).toHaveBeenCalledWith({ + symbol: 'BTC', + amount: '100', + }); + }); + + it('updates margin successfully when removing margin', async () => { + const mockResult = { success: true }; + mockProvider.updateMargin = jest.fn().mockResolvedValue(mockResult); + + const result = await tradingService.updateMargin({ + provider: mockProvider, + symbol: 'BTC', + amount: '-50', + context: mockContext, + }); + + expect(result).toEqual(mockResult); + expect(mockProvider.updateMargin).toHaveBeenCalledWith({ + symbol: 'BTC', + amount: '-50', + }); + }); + + it('throws error when provider does not support margin adjustment', async () => { + mockProvider.updateMargin = undefined as never; + + await expect( + tradingService.updateMargin({ + provider: mockProvider, + symbol: 'BTC', + amount: '100', + context: mockContext, + }), + ).rejects.toThrow('Provider does not support margin adjustment'); + }); + + it('returns error when margin update fails', async () => { + const mockResult = { success: false, error: 'Insufficient balance' }; + mockProvider.updateMargin = jest.fn().mockResolvedValue(mockResult); + + const result = await tradingService.updateMargin({ + provider: mockProvider, + symbol: 'BTC', + amount: '100', + context: mockContext, + }); + + expect(result.success).toBe(false); + expect(result.error).toBe('Insufficient balance'); + }); + + it('tracks analytics on success', async () => { + const mockResult = { success: true }; + mockProvider.updateMargin = jest.fn().mockResolvedValue(mockResult); + + await tradingService.updateMargin({ + provider: mockProvider, + symbol: 'BTC', + amount: '100', + context: mockContext, + }); + + expect(mockDeps.metrics.trackPerpsEvent).toHaveBeenCalledWith( + PerpsAnalyticsEvent.RiskManagement, + expect.objectContaining({ + status: 'executed', + }), + ); + }); + + it('tracks analytics on failure with error message', async () => { + mockProvider.updateMargin = jest + .fn() + .mockRejectedValue(new Error('Network error')); + + await expect( + tradingService.updateMargin({ + provider: mockProvider, + symbol: 'BTC', + amount: '100', + context: mockContext, + }), + ).rejects.toThrow('Network error'); + + expect(mockDeps.metrics.trackPerpsEvent).toHaveBeenCalledWith( + PerpsAnalyticsEvent.RiskManagement, + expect.objectContaining({ + status: 'failed', + }), + ); + }); + + it('emits a failed Risk Management event on a non-throwing { success: false } result', async () => { + const mockResult = { success: false, error: 'Insufficient balance' }; + mockProvider.updateMargin = jest.fn().mockResolvedValue(mockResult); + + const result = await tradingService.updateMargin({ + provider: mockProvider, + symbol: 'BTC', + amount: '100', + context: mockContext, + }); + + expect(result.success).toBe(false); + + const riskCalls = ( + mockDeps.metrics.trackPerpsEvent as jest.Mock + ).mock.calls.filter( + ([event]) => event === PerpsAnalyticsEvent.RiskManagement, + ); + // Exactly one terminal Risk Management event, and it is the failure with + // the error message carried from the provider result. + expect(riskCalls).toHaveLength(1); + expect(riskCalls[0][1]).toEqual( + expect.objectContaining({ + status: 'failed', + asset: 'BTC', + action: 'add_margin', + error_message: 'Insufficient balance', + }), + ); + }); + + it('emits the failed Risk Management event exactly once on a thrown error', async () => { + mockProvider.updateMargin = jest + .fn() + .mockRejectedValue(new Error('Network error')); + + await expect( + tradingService.updateMargin({ + provider: mockProvider, + symbol: 'BTC', + amount: '100', + context: mockContext, + }), + ).rejects.toThrow('Network error'); + + const riskCalls = ( + mockDeps.metrics.trackPerpsEvent as jest.Mock + ).mock.calls.filter( + ([event]) => event === PerpsAnalyticsEvent.RiskManagement, + ); + expect(riskCalls).toHaveLength(1); + expect(riskCalls[0][1]).toEqual( + expect.objectContaining({ status: 'failed' }), + ); + }); + + it('emits the Risk Management event exactly once on success', async () => { + const mockResult = { success: true }; + mockProvider.updateMargin = jest.fn().mockResolvedValue(mockResult); + + await tradingService.updateMargin({ + provider: mockProvider, + symbol: 'BTC', + amount: '100', + context: mockContext, + }); + + const riskCalls = ( + mockDeps.metrics.trackPerpsEvent as jest.Mock + ).mock.calls.filter( + ([event]) => event === PerpsAnalyticsEvent.RiskManagement, + ); + expect(riskCalls).toHaveLength(1); + expect(riskCalls[0][1]).toEqual( + expect.objectContaining({ status: 'executed' }), + ); + }); + + it('updates state on success', async () => { + const mockResult = { success: true }; + mockProvider.updateMargin = jest.fn().mockResolvedValue(mockResult); + + await tradingService.updateMargin({ + provider: mockProvider, + symbol: 'BTC', + amount: '100', + context: mockContext, + }); + + expect(mockContext.stateManager?.update).toHaveBeenCalled(); + }); + + it('creates trace for margin update', async () => { + const mockResult = { success: true }; + mockProvider.updateMargin = jest.fn().mockResolvedValue(mockResult); + + await tradingService.updateMargin({ + provider: mockProvider, + symbol: 'BTC', + amount: '100', + context: mockContext, + }); + + expect(mockDeps.tracer.trace).toHaveBeenCalledWith( + expect.objectContaining({ + name: 'Perps Update Margin', + id: 'mock-trace-id', + }), + ); + expect(mockDeps.tracer.endTrace).toHaveBeenCalled(); + }); + }); + + describe('flipPosition', () => { + const mockPosition: Position = { + symbol: 'BTC', + size: '0.5', + entryPrice: '50000', + liquidationPrice: '45000', + leverage: { type: 'cross', value: 10 }, + marginUsed: '2500', + maxLeverage: 20, + positionValue: '25000', + returnOnEquity: '0.2', + unrealizedPnl: '5000', + cumulativeFunding: { allTime: '0', sinceOpen: '0', sinceChange: '0' }, + takeProfitCount: 0, + stopLossCount: 0, + }; + + it('preserves the subscription source for flip orders', async () => { + const resolution: PerpsFeeResolution = { + feeBips: 0, + discountBips: 10000, + source: 'subscription', + subscription: { eligible: true, reason: 'eligible' }, + }; + mockProvider.setUserFeeResolution = jest.fn(); + mockRewardsIntegrationService.resolveFee.mockResolvedValue(resolution); + mockProvider.placeOrder.mockResolvedValue({ success: true }); + + await tradingService.flipPosition({ + provider: mockProvider, + position: mockPosition, + context: mockContext, + }); + + expect(mockProvider.setUserFeeResolution).toHaveBeenCalledWith( + resolution, + ); + expect(mockProvider.setUserFeeResolution).toHaveBeenLastCalledWith( + undefined, + ); + }); + + it('places order with 2x position size to flip position', async () => { + const mockResult: OrderResult = { + success: true, + orderId: 'flip-123', + filledSize: '1.0', + averagePrice: '50000', + }; + mockProvider.placeOrder.mockResolvedValue(mockResult); + + await tradingService.flipPosition({ + provider: mockProvider, + position: mockPosition, + context: mockContext, + }); + + // Verify order placed with 2x position size (0.5 * 2 = 1.0) + expect(mockProvider.placeOrder).toHaveBeenCalledWith( + expect.objectContaining({ + symbol: 'BTC', + size: '1', + }), + ); + }); + + it('flips long position to short (isBuy=false)', async () => { + const mockResult: OrderResult = { success: true, orderId: 'flip-123' }; + mockProvider.placeOrder.mockResolvedValue(mockResult); + + // Long position (positive size) + await tradingService.flipPosition({ + provider: mockProvider, + position: { ...mockPosition, size: '0.5' }, + context: mockContext, + }); + + expect(mockProvider.placeOrder).toHaveBeenCalledWith( + expect.objectContaining({ + isBuy: false, + }), + ); + }); + + it('flips short position to long (isBuy=true)', async () => { + const mockResult: OrderResult = { success: true, orderId: 'flip-123' }; + mockProvider.placeOrder.mockResolvedValue(mockResult); + + // Short position (negative size) + await tradingService.flipPosition({ + provider: mockProvider, + position: { ...mockPosition, size: '-0.5' }, + context: mockContext, + }); + + expect(mockProvider.placeOrder).toHaveBeenCalledWith( + expect.objectContaining({ + isBuy: true, + }), + ); + }); + + it('does not pass entry price as currentPrice to the provider', async () => { + mockProvider.placeOrder.mockResolvedValue({ + success: true, + orderId: 'flip-balance-fixed', + filledSize: '1.0', + averagePrice: '50000', + }); + + const result = await tradingService.flipPosition({ + provider: mockProvider, + position: mockPosition, + context: mockContext, + }); + + expect(result.success).toBe(true); + expect(mockProvider.placeOrder).toHaveBeenCalledWith({ + symbol: 'BTC', + isBuy: false, + size: '1', + orderType: 'market', + leverage: 10, + }); + expect(mockProvider.placeOrder).not.toHaveBeenCalledWith( + expect.objectContaining({ + currentPrice: expect.any(Number), + }), + ); + }); + + it('returns error when order placement fails', async () => { + const mockResult: OrderResult = { + success: false, + error: 'Order rejected', + }; + mockProvider.placeOrder.mockResolvedValue(mockResult); + + const result = await tradingService.flipPosition({ + provider: mockProvider, + position: mockPosition, + context: mockContext, + }); + + expect(result.success).toBe(false); + expect(result.error).toBe('Order rejected'); + }); + + it('tracks analytics on success', async () => { + const mockResult: OrderResult = { + success: true, + orderId: 'flip-123', + averagePrice: '60000', + }; + mockProvider.placeOrder.mockResolvedValue(mockResult); + + await tradingService.flipPosition({ + provider: mockProvider, + position: mockPosition, + context: mockContext, + }); + + expect(mockDeps.metrics.trackPerpsEvent).toHaveBeenCalledWith( + PerpsAnalyticsEvent.TradeTransaction, + expect.objectContaining({ + status: 'executed', + order_value: 30000, + }), + ); + }); + + it('tracks analytics on failure', async () => { + mockProvider.placeOrder.mockRejectedValue(new Error('Network error')); + + await expect( + tradingService.flipPosition({ + provider: mockProvider, + position: mockPosition, + context: mockContext, + }), + ).rejects.toThrow('Network error'); + + expect(mockDeps.metrics.trackPerpsEvent).toHaveBeenCalledWith( + PerpsAnalyticsEvent.TradeTransaction, + expect.objectContaining({ + status: 'failed', + }), + ); + }); + + it('propagates attribution properties on failure', async () => { + mockProvider.placeOrder.mockRejectedValue(new Error('Network error')); + + await expect( + tradingService.flipPosition({ + provider: mockProvider, + position: mockPosition, + trackingData: { + totalFee: 1, + marketPrice: 50000, + entryPoint: 'position_view', + discoverySource: 'banner', + hlFeeRate: 0.00045, + }, + context: mockContext, + }), + ).rejects.toThrow('Network error'); + + expect(mockDeps.metrics.trackPerpsEvent).toHaveBeenCalledWith( + PerpsAnalyticsEvent.TradeTransaction, + expect.objectContaining({ + status: 'failed', + entry_point: 'position_view', + discovery_source: 'banner', + hl_fee_rate: 0.00045, + }), + ); + }); + + it('updates state on success', async () => { + const mockResult: OrderResult = { success: true, orderId: 'flip-123' }; + mockProvider.placeOrder.mockResolvedValue(mockResult); + + await tradingService.flipPosition({ + provider: mockProvider, + position: mockPosition, + context: mockContext, + }); + + expect(mockContext.stateManager?.update).toHaveBeenCalled(); + }); + + it('creates trace for flip position', async () => { + const mockResult: OrderResult = { success: true, orderId: 'flip-123' }; + mockProvider.placeOrder.mockResolvedValue(mockResult); + + await tradingService.flipPosition({ + provider: mockProvider, + position: mockPosition, + context: mockContext, + }); + + expect(mockDeps.tracer.trace).toHaveBeenCalledWith( + expect.objectContaining({ + name: 'Perps Flip Position', + id: 'mock-trace-id', + }), + ); + expect(mockDeps.tracer.endTrace).toHaveBeenCalled(); + }); + + it('uses correct order params including leverage', async () => { + const mockResult: OrderResult = { success: true, orderId: 'flip-123' }; + mockProvider.placeOrder.mockResolvedValue(mockResult); + + await tradingService.flipPosition({ + provider: mockProvider, + position: mockPosition, + context: mockContext, + }); + + expect(mockProvider.placeOrder).toHaveBeenCalledWith({ + symbol: 'BTC', + isBuy: false, + size: '1', + orderType: 'market', + leverage: 10, + }); + }); + }); + + describe('consolidated analytics pipeline', () => { + const mockClosePosition: Position = { + symbol: 'BTC', + size: '0.5', + entryPrice: '50000', + liquidationPrice: '45000', + leverage: { type: 'cross', value: 10 }, + marginUsed: '2500', + maxLeverage: 20, + positionValue: '25000', + returnOnEquity: '0.2', + unrealizedPnl: '5000', + cumulativeFunding: { allTime: '0', sinceOpen: '0', sinceChange: '0' }, + takeProfitCount: 0, + stopLossCount: 0, + }; + + const findCall = (event: PerpsAnalyticsEvent, status: string) => + (mockDeps.metrics.trackPerpsEvent as jest.Mock).mock.calls.find( + ([calledEvent, props]) => + calledEvent === event && props.status === status, + ); + + describe('status=submitted before provider round-trip', () => { + it('emits a submitted trade event before placeOrder', async () => { + mockProvider.placeOrder.mockResolvedValue({ + success: true, + orderId: 'order-1', + }); + + await tradingService.placeOrder({ + provider: mockProvider, + params: { + symbol: 'BTC', + isBuy: true, + size: '0.1', + orderType: 'market', + }, + context: mockContext, + reportOrderToDataLake: mockReportOrderToDataLake, + }); + + expect(mockDeps.metrics.trackPerpsEvent).toHaveBeenCalledWith( + PerpsAnalyticsEvent.TradeTransaction, + expect.objectContaining({ status: 'submitted', asset: 'BTC' }), + ); + }); + + it('emits a submitted close event before closePosition', async () => { + mockGetPositions.mockResolvedValue([mockClosePosition]); + mockProvider.closePosition.mockResolvedValue({ + success: true, + orderId: 'close-1', + }); + + await tradingService.closePosition({ + provider: mockProvider, + params: { symbol: 'BTC' }, + context: { ...mockContext, getPositions: mockGetPositions }, + reportOrderToDataLake: mockReportOrderToDataLake, + }); + + expect(mockDeps.metrics.trackPerpsEvent).toHaveBeenCalledWith( + PerpsAnalyticsEvent.PositionCloseTransaction, + expect.objectContaining({ status: 'submitted', asset: 'BTC' }), + ); + }); + + it('emits a submitted cancel event before cancelOrder', async () => { + mockProvider.cancelOrder.mockResolvedValue({ success: true }); + + await tradingService.cancelOrder({ + provider: mockProvider, + params: { symbol: 'BTC', orderId: 'order-1' }, + context: mockContext, + }); + + expect(mockDeps.metrics.trackPerpsEvent).toHaveBeenCalledWith( + PerpsAnalyticsEvent.OrderCancelTransaction, + expect.objectContaining({ status: 'submitted', asset: 'BTC' }), + ); + }); + + it('emits a submitted risk-management event before updatePositionTPSL', async () => { + mockProvider.updatePositionTPSL.mockResolvedValue({ success: true }); + + await tradingService.updatePositionTPSL({ + provider: mockProvider, + params: { + symbol: 'BTC', + takeProfitPrice: '60000', + trackingData: { + direction: 'long', + source: 'tp_sl_view', + positionSize: 0.5, + }, + }, + context: mockContext, + }); + + expect(mockDeps.metrics.trackPerpsEvent).toHaveBeenCalledWith( + PerpsAnalyticsEvent.RiskManagement, + expect.objectContaining({ status: 'submitted', asset: 'BTC' }), + ); + }); + + it('emits a submitted trade event before flipPosition', async () => { + mockProvider.placeOrder.mockResolvedValue({ + success: true, + orderId: 'flip-1', + }); + + await tradingService.flipPosition({ + provider: mockProvider, + position: mockClosePosition, + context: mockContext, + }); + + expect(mockDeps.metrics.trackPerpsEvent).toHaveBeenCalledWith( + PerpsAnalyticsEvent.TradeTransaction, + expect.objectContaining({ status: 'submitted', asset: 'BTC' }), + ); + }); + + it('emits a terminal failed event when the flip is rejected without throwing', async () => { + mockProvider.placeOrder.mockResolvedValue({ + success: false, + error: 'insufficient margin', + }); + + await tradingService.flipPosition({ + provider: mockProvider, + position: mockClosePosition, + context: mockContext, + }); + + expect( + findCall(PerpsAnalyticsEvent.TradeTransaction, 'failed')?.[1], + ).toEqual( + expect.objectContaining({ + status: 'failed', + asset: 'BTC', + error_message: 'insufficient margin', + }), + ); + }); + }); + + it('emits a terminal close event when no local position is found', async () => { + mockGetPositions.mockResolvedValue([]); + mockProvider.closePosition.mockResolvedValue({ + success: true, + orderId: 'close-1', + }); + + await tradingService.closePosition({ + provider: mockProvider, + params: { symbol: 'BTC' }, + context: { ...mockContext, getPositions: mockGetPositions }, + reportOrderToDataLake: mockReportOrderToDataLake, + }); + + // Submitted event still fires, and a matching terminal event is emitted + // even though no local position metrics are available. + expect(mockDeps.metrics.trackPerpsEvent).toHaveBeenCalledWith( + PerpsAnalyticsEvent.PositionCloseTransaction, + expect.objectContaining({ status: 'submitted', asset: 'BTC' }), + ); + expect( + findCall(PerpsAnalyticsEvent.PositionCloseTransaction, 'executed')?.[1], + ).toEqual(expect.objectContaining({ asset: 'BTC', status: 'executed' })); + }); + + it('populates metamask_fee on flip success from trackingData', async () => { + mockProvider.placeOrder.mockResolvedValue({ + success: true, + orderId: 'flip-1', + averagePrice: '50000', + }); + + await tradingService.flipPosition({ + provider: mockProvider, + position: mockClosePosition, + trackingData: { totalFee: 1, marketPrice: 50000, metamaskFee: 2.5 }, + context: mockContext, + }); + + expect( + findCall(PerpsAnalyticsEvent.TradeTransaction, 'executed')?.[1], + ).toEqual(expect.objectContaining({ metamask_fee: 2.5 })); + }); + + it('adds effective leverage (positionUSD / marginUSD, 1 dp) to the close event properties', async () => { + // positionValue / marginUsed = 25000 / 2727 = 9.167… -> 9.2, which is the + // effective leverage rather than the configured leverage.value of 10. + mockGetPositions.mockResolvedValue([ + { ...mockClosePosition, positionValue: '25000', marginUsed: '2727' }, + ]); + mockProvider.closePosition.mockResolvedValue({ + success: true, + orderId: 'close-1', + filledSize: '0.5', + averagePrice: '55000', + }); + + await tradingService.closePosition({ + provider: mockProvider, + params: { symbol: 'BTC' }, + context: { ...mockContext, getPositions: mockGetPositions }, + reportOrderToDataLake: mockReportOrderToDataLake, + }); + + expect( + findCall(PerpsAnalyticsEvent.PositionCloseTransaction, 'executed')?.[1], + ).toEqual(expect.objectContaining({ leverage: 9.2 })); + }); + + it('populates effective leverage even when configured leverage is missing (TP/SL close)', async () => { + // TP/SL closes may carry no configured leverage.value; the effective + // leverage is still derived from positionValue / marginUsed = 20000 / 4000 = 5. + mockGetPositions.mockResolvedValue([ + { + ...mockClosePosition, + positionValue: '20000', + marginUsed: '4000', + leverage: undefined as unknown as Position['leverage'], + }, + ]); + mockProvider.closePosition.mockResolvedValue({ + success: true, + orderId: 'close-2', + filledSize: '0.5', + averagePrice: '55000', + }); + + await tradingService.closePosition({ + provider: mockProvider, + params: { symbol: 'BTC' }, + context: { ...mockContext, getPositions: mockGetPositions }, + reportOrderToDataLake: mockReportOrderToDataLake, + }); + + expect( + findCall(PerpsAnalyticsEvent.PositionCloseTransaction, 'executed')?.[1], + ).toEqual(expect.objectContaining({ leverage: 5 })); + }); + + it('omits leverage (never NaN) when marginUsed is zero or non-finite', async () => { + // Guard against divide-by-zero / NaN: marginUsed '0' must not produce a + // leverage property at all (rather than Infinity / NaN). + mockGetPositions.mockResolvedValue([ + { ...mockClosePosition, positionValue: '25000', marginUsed: '0' }, + ]); + mockProvider.closePosition.mockResolvedValue({ + success: true, + orderId: 'close-3', + filledSize: '0.5', + averagePrice: '55000', + }); + + await tradingService.closePosition({ + provider: mockProvider, + params: { symbol: 'BTC' }, + context: { ...mockContext, getPositions: mockGetPositions }, + reportOrderToDataLake: mockReportOrderToDataLake, + }); + + const closeProps = findCall( + PerpsAnalyticsEvent.PositionCloseTransaction, + 'executed', + )?.[1]; + expect(closeProps).not.toHaveProperty('leverage'); + expect(closeProps?.leverage).toBeUndefined(); + }); + + describe('hl_fee_rate on trade + close', () => { + it('includes hl_fee_rate when present in trackingData', async () => { + mockProvider.placeOrder.mockResolvedValue({ + success: true, + orderId: 'order-1', + filledSize: '0.1', + averagePrice: '50000', + }); + + await tradingService.placeOrder({ + provider: mockProvider, + params: { + symbol: 'BTC', + isBuy: true, + size: '0.1', + orderType: 'market', + trackingData: { + totalFee: 1, + marketPrice: 50000, + hlFeeRate: 0.00045, + }, + }, + context: mockContext, + reportOrderToDataLake: mockReportOrderToDataLake, + }); + + expect( + findCall(PerpsAnalyticsEvent.TradeTransaction, 'executed')?.[1], + ).toEqual(expect.objectContaining({ hl_fee_rate: 0.00045 })); + }); + + it('omits hl_fee_rate when unavailable', async () => { + mockProvider.placeOrder.mockResolvedValue({ + success: true, + orderId: 'order-1', + filledSize: '0.1', + averagePrice: '50000', + }); + + await tradingService.placeOrder({ + provider: mockProvider, + params: { + symbol: 'BTC', + isBuy: true, + size: '0.1', + orderType: 'market', + trackingData: { totalFee: 1, marketPrice: 50000 }, + }, + context: mockContext, + reportOrderToDataLake: mockReportOrderToDataLake, + }); + + expect( + findCall(PerpsAnalyticsEvent.TradeTransaction, 'executed')?.[1], + ).not.toHaveProperty('hl_fee_rate'); + }); + }); + + describe('partial fill on open trade', () => { + it('emits an additional partially_filled trade event with order_size, amount_filled, and remaining_amount from the submitted size', async () => { + mockProvider.placeOrder.mockResolvedValue({ + success: true, + orderId: 'order-1', + filledSize: '4', + submittedSize: '10', + averagePrice: '50000', + }); + + await tradingService.placeOrder({ + provider: mockProvider, + params: { + symbol: 'BTC', + isBuy: true, + size: '10', + orderType: 'market', + }, + context: mockContext, + reportOrderToDataLake: mockReportOrderToDataLake, + }); + + // Schema mirrors the close path: order_size = submitted size, the fill is + // reported via amount_filled, and remaining_amount = submitted - filled. + expect( + findCall( + PerpsAnalyticsEvent.TradeTransaction, + 'partially_filled', + )?.[1], + ).toEqual( + expect.objectContaining({ + order_size: 10, + amount_filled: 4, + remaining_amount: 6, + }), + ); + // The terminal executed event still fires alongside it. + expect( + findCall(PerpsAnalyticsEvent.TradeTransaction, 'executed'), + ).toBeDefined(); + }); + + it('does not emit a partially_filled event on a complete fill of the normalized submitted size', async () => { + // The provider rounds the requested size (params.size = 10) down to the + // normalized size it actually submits (9.99) and that fills completely. + // Classifying against params.size would spuriously flag this as partial; + // classifying against submittedSize must not. + mockProvider.placeOrder.mockResolvedValue({ + success: true, + orderId: 'order-1', + filledSize: '9.99', + submittedSize: '9.99', + averagePrice: '50000', + }); + + await tradingService.placeOrder({ + provider: mockProvider, + params: { + symbol: 'BTC', + isBuy: true, + size: '10', + orderType: 'market', + }, + context: mockContext, + reportOrderToDataLake: mockReportOrderToDataLake, + }); + + expect( + findCall(PerpsAnalyticsEvent.TradeTransaction, 'partially_filled'), + ).toBeUndefined(); + expect( + findCall(PerpsAnalyticsEvent.TradeTransaction, 'executed'), + ).toBeDefined(); + }); + + it('does not classify a partial fill when the provider omits submittedSize', async () => { + // Without a submitted size we cannot know the real baseline, so we emit + // no partially_filled event rather than guessing from params.size. + mockProvider.placeOrder.mockResolvedValue({ + success: true, + orderId: 'order-1', + filledSize: '4', + averagePrice: '50000', + }); + + await tradingService.placeOrder({ + provider: mockProvider, + params: { + symbol: 'BTC', + isBuy: true, + size: '10', + orderType: 'market', + }, + context: mockContext, + reportOrderToDataLake: mockReportOrderToDataLake, + }); + + expect( + findCall(PerpsAnalyticsEvent.TradeTransaction, 'partially_filled'), + ).toBeUndefined(); + expect( + findCall(PerpsAnalyticsEvent.TradeTransaction, 'executed'), + ).toBeDefined(); + }); + + it('does not leak amount_filled/remaining_amount onto the executed trade event', async () => { + mockProvider.placeOrder.mockResolvedValue({ + success: true, + orderId: 'order-1', + filledSize: '4', + submittedSize: '10', + averagePrice: '50000', + }); + + await tradingService.placeOrder({ + provider: mockProvider, + params: { + symbol: 'BTC', + isBuy: true, + size: '10', + orderType: 'market', + }, + context: mockContext, + reportOrderToDataLake: mockReportOrderToDataLake, + }); + + const executed = findCall( + PerpsAnalyticsEvent.TradeTransaction, + 'executed', + )?.[1]; + expect(executed).not.toHaveProperty('amount_filled'); + expect(executed).not.toHaveProperty('remaining_amount'); + }); + + it('computes remaining_amount with exact decimal math (no binary-float dust)', async () => { + // 10 - 9.7 evaluates to 0.30000000000000071 in binary floating point; + // the decimal (BigNumber) subtraction must report exactly 0.3. + mockProvider.placeOrder.mockResolvedValue({ + success: true, + orderId: 'order-1', + filledSize: '9.7', + submittedSize: '10', + averagePrice: '50000', + }); + + await tradingService.placeOrder({ + provider: mockProvider, + params: { + symbol: 'BTC', + isBuy: true, + size: '10', + orderType: 'market', + }, + context: mockContext, + reportOrderToDataLake: mockReportOrderToDataLake, + }); + + expect( + findCall( + PerpsAnalyticsEvent.TradeTransaction, + 'partially_filled', + )?.[1], + ).toEqual( + expect.objectContaining({ + order_size: 10, + amount_filled: 9.7, + remaining_amount: 0.3, + }), + ); + }); + + it('classifies a partial fill when parseFloat would collapse the two sizes to equal', async () => { + // parseFloat('0.10000000000000001') === parseFloat('0.1') === 0.1, so a + // Number-based comparison would treat this as a full fill and skip the + // event; decimal comparison sees filled < submitted and classifies it. + mockProvider.placeOrder.mockResolvedValue({ + success: true, + orderId: 'order-1', + filledSize: '0.1', + submittedSize: '0.10000000000000001', + averagePrice: '50000', + }); + + await tradingService.placeOrder({ + provider: mockProvider, + params: { + symbol: 'BTC', + isBuy: true, + size: '0.10000000000000001', + orderType: 'market', + }, + context: mockContext, + reportOrderToDataLake: mockReportOrderToDataLake, + }); + + expect( + findCall(PerpsAnalyticsEvent.TradeTransaction, 'partially_filled'), + ).toBeDefined(); + }); + + it('does not classify a partial fill when filledSize is not a finite number', async () => { + mockProvider.placeOrder.mockResolvedValue({ + success: true, + orderId: 'order-1', + filledSize: 'not-a-number', + submittedSize: '10', + averagePrice: '50000', + }); + + await tradingService.placeOrder({ + provider: mockProvider, + params: { + symbol: 'BTC', + isBuy: true, + size: '10', + orderType: 'market', + }, + context: mockContext, + reportOrderToDataLake: mockReportOrderToDataLake, + }); + + expect( + findCall(PerpsAnalyticsEvent.TradeTransaction, 'partially_filled'), + ).toBeUndefined(); + }); + + it('does not classify a partial fill or emit any NaN size when submittedSize is not finite', async () => { + mockProvider.placeOrder.mockResolvedValue({ + success: true, + orderId: 'order-1', + filledSize: '4', + submittedSize: 'not-a-number', + averagePrice: '50000', + }); + + await tradingService.placeOrder({ + provider: mockProvider, + params: { + symbol: 'BTC', + isBuy: true, + size: '10', + orderType: 'market', + }, + context: mockContext, + reportOrderToDataLake: mockReportOrderToDataLake, + }); + + expect( + findCall(PerpsAnalyticsEvent.TradeTransaction, 'partially_filled'), + ).toBeUndefined(); + // No emitted trade event carries a NaN size property. + const tradeCalls = ( + mockDeps.metrics.trackPerpsEvent as jest.Mock + ).mock.calls.filter( + ([event]) => event === PerpsAnalyticsEvent.TradeTransaction, + ); + for (const [, props] of tradeCalls) { + expect(Number.isNaN(props.order_size)).toBe(false); + expect(Number.isNaN(props.amount_filled)).toBe(false); + expect(Number.isNaN(props.remaining_amount)).toBe(false); + } + }); + + it('does not emit a partially_filled event for a failed result even when it carries sizes', async () => { + mockProvider.placeOrder.mockResolvedValue({ + success: false, + error: 'boom', + filledSize: '4', + submittedSize: '10', + }); + + await tradingService.placeOrder({ + provider: mockProvider, + params: { + symbol: 'BTC', + isBuy: true, + size: '10', + orderType: 'market', + }, + context: mockContext, + reportOrderToDataLake: mockReportOrderToDataLake, + }); + + // Classification is gated on success — a failed order never emits a + // partial event, even if the provider echoed filled/submitted sizes. + expect( + findCall(PerpsAnalyticsEvent.TradeTransaction, 'partially_filled'), + ).toBeUndefined(); + expect( + findCall(PerpsAnalyticsEvent.TradeTransaction, 'failed'), + ).toBeDefined(); + }); + }); + + describe('number_positions_closed on batch close', () => { + it('carries the successful-close count on the batch close summary event', async () => { + mockGetPositions.mockResolvedValue([mockClosePosition]); + (mockProvider.closePositions as jest.Mock).mockResolvedValue({ + success: true, + successCount: 2, + failureCount: 1, + results: [ + { success: true, orderId: 'close-1', symbol: 'BTC' }, + { success: true, orderId: 'close-2', symbol: 'ETH' }, + { success: false, symbol: 'SOL', error: 'Insufficient liquidity' }, + ], + }); + + await tradingService.closePositions({ + provider: mockProvider, + params: { closeAll: true }, + context: { ...mockContext, getPositions: mockGetPositions }, + }); + + expect(mockDeps.metrics.trackPerpsEvent).toHaveBeenCalledWith( + PerpsAnalyticsEvent.PositionCloseTransaction, + expect.objectContaining({ number_positions_closed: 2 }), + ); + }); + + it('does not add number_positions_closed to a single-position close event', async () => { + mockGetPositions.mockResolvedValue([mockClosePosition]); + mockProvider.closePosition.mockResolvedValue({ + success: true, + orderId: 'close-1', + filledSize: '0.5', + averagePrice: '55000', + }); + + await tradingService.closePosition({ + provider: mockProvider, + params: { symbol: 'BTC' }, + context: { ...mockContext, getPositions: mockGetPositions }, + reportOrderToDataLake: mockReportOrderToDataLake, + }); + + expect( + findCall( + PerpsAnalyticsEvent.PositionCloseTransaction, + 'executed', + )?.[1], + ).not.toHaveProperty('number_positions_closed'); + }); + }); + + describe('bulk_action_id on batch close/cancel', () => { + it('attaches bulk_action_id to the batch close summary event', async () => { + mockGetPositions.mockResolvedValue([mockClosePosition]); + (mockProvider.closePositions as jest.Mock).mockResolvedValue({ + success: true, + successCount: 1, + failureCount: 0, + results: [{ success: true, orderId: 'close-1', symbol: 'BTC' }], + }); + + await tradingService.closePositions({ + provider: mockProvider, + params: { closeAll: true }, + context: { ...mockContext, getPositions: mockGetPositions }, + }); + + expect(mockDeps.metrics.trackPerpsEvent).toHaveBeenCalledWith( + PerpsAnalyticsEvent.PositionCloseTransaction, + expect.objectContaining({ bulk_action_id: 'mock-trace-id' }), + ); + }); + + it('attaches bulk_action_id to per-item close events in the fallback path', async () => { + ( + mockProvider as unknown as { closePositions?: unknown } + ).closePositions = undefined; + mockGetPositions.mockResolvedValue([mockClosePosition]); + mockProvider.closePosition.mockResolvedValue({ + success: true, + orderId: 'close-1', + filledSize: '0.5', + averagePrice: '55000', + }); + + await tradingService.closePositions({ + provider: mockProvider, + params: { closeAll: true }, + context: { ...mockContext, getPositions: mockGetPositions }, + }); + + expect( + findCall( + PerpsAnalyticsEvent.PositionCloseTransaction, + 'executed', + )?.[1], + ).toEqual(expect.objectContaining({ bulk_action_id: 'mock-trace-id' })); + }); + + it('attaches bulk_action_id to the batch cancel summary event', async () => { + mockGetOpenOrders.mockResolvedValue([ + { + orderId: 'order-1', + symbol: 'BTC', + side: 'buy', + orderType: 'limit', + price: '50000', + size: '0.1', + originalSize: '0.1', + filledSize: '0', + remainingSize: '0.1', + status: 'open', + timestamp: 1, + } as Order, + ]); + (mockProvider.cancelOrders as jest.Mock).mockResolvedValue({ + success: true, + successCount: 1, + failureCount: 0, + results: [{ success: true, orderId: 'order-1', symbol: 'BTC' }], + }); + + await tradingService.cancelOrders({ + provider: mockProvider, + params: { cancelAll: true }, + context: { ...mockContext, getOpenOrders: mockGetOpenOrders }, + withStreamPause: mockWithStreamPause, + }); + + expect(mockDeps.metrics.trackPerpsEvent).toHaveBeenCalledWith( + PerpsAnalyticsEvent.OrderCancelTransaction, + expect.objectContaining({ bulk_action_id: 'mock-trace-id' }), + ); + }); + }); + + it('propagates entry_point/discovery_source/perp_discovery_source on trade events', async () => { + mockProvider.placeOrder.mockResolvedValue({ + success: true, + orderId: 'order-1', + filledSize: '0.1', + averagePrice: '50000', + }); + + await tradingService.placeOrder({ + provider: mockProvider, + params: { + symbol: 'BTC', + isBuy: true, + size: '0.1', + orderType: 'market', + trackingData: { + totalFee: 1, + marketPrice: 50000, + entryPoint: 'perps_home', + discoverySource: 'watchlist', + perpDiscoverySource: 'top_movers', + }, + }, + context: mockContext, + reportOrderToDataLake: mockReportOrderToDataLake, + }); + + expect( + findCall(PerpsAnalyticsEvent.TradeTransaction, 'executed')?.[1], + ).toEqual( + expect.objectContaining({ + entry_point: 'perps_home', + discovery_source: 'watchlist', + perp_discovery_source: 'top_movers', + }), + ); + }); + }); +}); diff --git a/packages/perps-controller/tests/src/types/hyperliquid-types.test.ts b/packages/perps-controller/tests/src/types/hyperliquid-types.test.ts new file mode 100644 index 00000000000..f1c7762a70d --- /dev/null +++ b/packages/perps-controller/tests/src/types/hyperliquid-types.test.ts @@ -0,0 +1,34 @@ +import { hyperLiquidModeFoldsSpot } from '../../../src/types/hyperliquid-types.js'; + +describe('hyperLiquidModeFoldsSpot', () => { + it('folds for unifiedAccount', () => { + expect(hyperLiquidModeFoldsSpot('unifiedAccount')).toBe(true); + }); + + it('folds for portfolioMargin', () => { + expect(hyperLiquidModeFoldsSpot('portfolioMargin')).toBe(true); + }); + + it('does not fold for dexAbstraction', () => { + expect(hyperLiquidModeFoldsSpot('dexAbstraction')).toBe(false); + }); + + it('does not fold for default', () => { + expect(hyperLiquidModeFoldsSpot('default')).toBe(false); + }); + + it('does not fold for disabled', () => { + expect(hyperLiquidModeFoldsSpot('disabled')).toBe(false); + }); + + it('fail-closes (no fold) when mode is null', () => { + // Critical: must not over-report withdrawable funds for Standard / + // dexAbstraction users when the abstraction mode hasn't been resolved + // yet (e.g. WS spot push arrives before REST userAbstraction completes). + expect(hyperLiquidModeFoldsSpot(null)).toBe(false); + }); + + it('fail-closes (no fold) when mode is undefined', () => { + expect(hyperLiquidModeFoldsSpot(undefined)).toBe(false); + }); +}); diff --git a/packages/perps-controller/tests/src/utils/accountUtils.test.ts b/packages/perps-controller/tests/src/utils/accountUtils.test.ts new file mode 100644 index 00000000000..0875c1ae73e --- /dev/null +++ b/packages/perps-controller/tests/src/utils/accountUtils.test.ts @@ -0,0 +1,439 @@ +/* eslint-disable */ +import { PERPS_CONSTANTS } from '../../../src/constants/perpsConfig.js'; +import type { AccountState } from '../../../src/types/index.js'; +import { + addSpotBalanceToAccountState, + aggregateAccountStates, + calculateWeightedReturnOnEquity, + getSpotBalance, +} from '../../../src/utils/accountUtils.js'; + +describe('aggregateAccountStates', () => { + const fallback: AccountState = { + spendableBalance: PERPS_CONSTANTS.FallbackDataDisplay, + withdrawableBalance: PERPS_CONSTANTS.FallbackDataDisplay, + totalBalance: PERPS_CONSTANTS.FallbackDataDisplay, + marginUsed: PERPS_CONSTANTS.FallbackDataDisplay, + unrealizedPnl: PERPS_CONSTANTS.FallbackDataDisplay, + returnOnEquity: PERPS_CONSTANTS.FallbackDataDisplay, + }; + + it('returns fallback when given an empty array', () => { + expect(aggregateAccountStates([])).toEqual(fallback); + }); + + it('returns the single state unchanged when given one element', () => { + const single: AccountState = { + spendableBalance: '100', + withdrawableBalance: '100', + totalBalance: '200', + marginUsed: '50', + unrealizedPnl: '10', + returnOnEquity: '20', + }; + expect(aggregateAccountStates([single])).toEqual(single); + }); + + it('sums numeric fields from two states and recalculates ROE', () => { + const stateA: AccountState = { + spendableBalance: '100', + withdrawableBalance: '100', + totalBalance: '200', + marginUsed: '50', + unrealizedPnl: '10', + returnOnEquity: '20', + }; + const stateB: AccountState = { + spendableBalance: '50', + withdrawableBalance: '50', + totalBalance: '150', + marginUsed: '30', + unrealizedPnl: '6', + returnOnEquity: '20', + }; + + const result = aggregateAccountStates([stateA, stateB]); + + expect(parseFloat(result.spendableBalance)).toBe(150); + expect(parseFloat(result.withdrawableBalance)).toBe(150); + expect(parseFloat(result.totalBalance)).toBe(350); + expect(parseFloat(result.marginUsed)).toBe(80); + expect(parseFloat(result.unrealizedPnl)).toBe(16); + // ROE = (16 / 80) * 100 = 20 + expect(parseFloat(result.returnOnEquity)).toBe(20); + }); + + it('sums numeric fields from three states', () => { + const states: AccountState[] = [ + { + spendableBalance: '100', + withdrawableBalance: '100', + totalBalance: '200', + marginUsed: '50', + unrealizedPnl: '10', + returnOnEquity: '20', + }, + { + spendableBalance: '200', + withdrawableBalance: '200', + totalBalance: '300', + marginUsed: '100', + unrealizedPnl: '30', + returnOnEquity: '30', + }, + { + spendableBalance: '50', + withdrawableBalance: '50', + totalBalance: '100', + marginUsed: '50', + unrealizedPnl: '5', + returnOnEquity: '10', + }, + ]; + + const result = aggregateAccountStates(states); + + expect(parseFloat(result.spendableBalance)).toBe(350); + expect(parseFloat(result.withdrawableBalance)).toBe(350); + expect(parseFloat(result.totalBalance)).toBe(600); + expect(parseFloat(result.marginUsed)).toBe(200); + expect(parseFloat(result.unrealizedPnl)).toBe(45); + // ROE = (45 / 200) * 100 = 22.5 + expect(parseFloat(result.returnOnEquity)).toBe(22.5); + }); + + it('does not mutate the input state object', () => { + const single: AccountState = { + spendableBalance: '100', + withdrawableBalance: '100', + totalBalance: '200', + marginUsed: '50', + unrealizedPnl: '10', + returnOnEquity: '99', + }; + const result = aggregateAccountStates([single]); + // result gets recalculated ROE = (10/50)*100 = 20 + expect(result.returnOnEquity).toBe('20'); + // original must be untouched + expect(single.returnOnEquity).toBe('99'); + }); + + it('sets ROE to 0 when total marginUsed is 0', () => { + const state: AccountState = { + spendableBalance: '100', + withdrawableBalance: '100', + totalBalance: '100', + marginUsed: '0', + unrealizedPnl: '0', + returnOnEquity: '0', + }; + const result = aggregateAccountStates([state]); + expect(result.returnOnEquity).toBe('0'); + }); + + it('handles negative unrealizedPnl correctly', () => { + const stateA: AccountState = { + spendableBalance: '80', + withdrawableBalance: '80', + totalBalance: '180', + marginUsed: '100', + unrealizedPnl: '-20', + returnOnEquity: '-20', + }; + const stateB: AccountState = { + spendableBalance: '40', + withdrawableBalance: '40', + totalBalance: '90', + marginUsed: '50', + unrealizedPnl: '-10', + returnOnEquity: '-20', + }; + + const result = aggregateAccountStates([stateA, stateB]); + + expect(parseFloat(result.marginUsed)).toBe(150); + expect(parseFloat(result.unrealizedPnl)).toBe(-30); + // ROE = (-30 / 150) * 100 = -20 + expect(parseFloat(result.returnOnEquity)).toBe(-20); + }); + + it('handles decimal values correctly', () => { + const stateA: AccountState = { + spendableBalance: '100.50', + withdrawableBalance: '100.50', + totalBalance: '200.75', + marginUsed: '50.25', + unrealizedPnl: '10.10', + returnOnEquity: '20.1', + }; + const stateB: AccountState = { + spendableBalance: '50.50', + withdrawableBalance: '50.50', + totalBalance: '150.25', + marginUsed: '30.75', + unrealizedPnl: '6.90', + returnOnEquity: '22.4', + }; + + const result = aggregateAccountStates([stateA, stateB]); + + expect(parseFloat(result.spendableBalance)).toBeCloseTo(151, 0); + expect(parseFloat(result.withdrawableBalance)).toBeCloseTo(151, 0); + expect(parseFloat(result.totalBalance)).toBeCloseTo(351, 0); + expect(parseFloat(result.marginUsed)).toBeCloseTo(81, 0); + expect(parseFloat(result.unrealizedPnl)).toBeCloseTo(17, 0); + }); +}); + +describe('spot balance helpers', () => { + it('returns zero spot balance when no spot state is provided', () => { + expect(getSpotBalance()).toBe(0); + }); + + it('bumps totalBalance, spendableBalance, and withdrawableBalance by spot USDC without mutating the input', () => { + const accountState: AccountState = { + spendableBalance: '0', + withdrawableBalance: '0', + totalBalance: '100', + marginUsed: '0', + unrealizedPnl: '0', + returnOnEquity: '0', + }; + + const result = addSpotBalanceToAccountState( + accountState, + { + balances: [ + { coin: 'USDC', total: '25.5' }, + { coin: 'HYPE', total: '0.5' }, + ], + } as never, + { foldIntoCollateral: true }, + ); + + // Only USDC contributes — non-stablecoin spot assets are not convertible + // to perps collateral and must not inflate balances. + expect(result.totalBalance).toBe('125.5'); + expect(result.spendableBalance).toBe('25.5'); + expect(result.withdrawableBalance).toBe('25.5'); + expect(accountState.totalBalance).toBe('100'); + }); + + it('ignores non-collateral spot balances entirely', () => { + const accountState: AccountState = { + spendableBalance: '0', + withdrawableBalance: '0', + totalBalance: '50', + marginUsed: '0', + unrealizedPnl: '0', + returnOnEquity: '0', + }; + + const result = addSpotBalanceToAccountState(accountState, { + balances: [ + { coin: 'HYPE', total: '1000' }, + { coin: 'PURR', total: '5000' }, + ], + } as never); + + expect(result).toBe(accountState); + }); + + it('excludes non-USDC-only spot balance from funded-state totals', () => { + const accountState: AccountState = { + spendableBalance: '0', + withdrawableBalance: '0', + totalBalance: '0', + marginUsed: '0', + unrealizedPnl: '0', + returnOnEquity: '0', + }; + + const result = addSpotBalanceToAccountState(accountState, { + balances: [ + { coin: 'DAI', total: '75.25' }, + { coin: 'HYPE', total: '999' }, + ], + } as never); + + expect(result).toBe(accountState); + }); + + it('adds only the USDC portion when USDC and a non-USDC token are both present', () => { + const accountState: AccountState = { + spendableBalance: '0', + withdrawableBalance: '0', + totalBalance: '10', + marginUsed: '0', + unrealizedPnl: '0', + returnOnEquity: '0', + }; + + const result = addSpotBalanceToAccountState( + accountState, + { + balances: [ + { coin: 'USDC', total: '20' }, + { coin: 'DAI', total: '30' }, + { coin: 'HYPE', total: '9999' }, + ], + } as never, + { foldIntoCollateral: true }, + ); + + expect(result.totalBalance).toBe('30'); + expect(result.spendableBalance).toBe('20'); + expect(result.withdrawableBalance).toBe('20'); + }); + + it('does not fold USDC spot collateral into spendable/withdrawable for Standard modes', () => { + const accountState: AccountState = { + spendableBalance: '7', + withdrawableBalance: '7', + totalBalance: '10', + marginUsed: '0', + unrealizedPnl: '0', + returnOnEquity: '0', + }; + + const result = addSpotBalanceToAccountState( + accountState, + { + balances: [{ coin: 'USDC', total: '25', hold: '5' }], + } as never, + { foldIntoCollateral: false }, + ); + + expect(result.totalBalance).toBe('30'); + expect(result.spendableBalance).toBe('7'); + expect(result.withdrawableBalance).toBe('7'); + }); + + it('keeps spot USDC separate from withdrawable even when withdrawable=0 in Standard mode', () => { + // Standard / DEX-abstraction users with $0 perps withdrawable but free + // spot USDC must NOT see spot fold into withdrawable — withdraw3 only + // draws from the perps ledger in those modes. Folding would surface a + // withdrawable amount the API can't actually fulfill. + const accountState: AccountState = { + spendableBalance: '0', + withdrawableBalance: '0', + totalBalance: '0', + marginUsed: '0', + unrealizedPnl: '0', + returnOnEquity: '0', + }; + + const result = addSpotBalanceToAccountState( + accountState, + { + balances: [{ coin: 'USDC', total: '2500', hold: '0' }], + } as never, + { foldIntoCollateral: false }, + ); + + expect(result.spendableBalance).toBe('0'); + expect(result.withdrawableBalance).toBe('0'); + expect(result.totalBalance).toBe('2500'); + }); + + it('subtracts spot hold from total and only folds free spot into spendable/withdrawable', () => { + const accountState: AccountState = { + spendableBalance: '10', + withdrawableBalance: '10', + totalBalance: '100', + marginUsed: '0', + unrealizedPnl: '0', + returnOnEquity: '0', + }; + + const result = addSpotBalanceToAccountState( + accountState, + { + balances: [{ coin: 'USDC', total: '40', hold: '15' }], + } as never, + { foldIntoCollateral: true }, + ); + + // totalBalance += spotTotal - spotHold = 100 + 40 - 15 = 125 + expect(parseFloat(result.totalBalance)).toBe(125); + // spendable/withdrawable += freeSpot = 10 + (40 - 15) = 35 + expect(parseFloat(result.spendableBalance)).toBe(35); + expect(parseFloat(result.withdrawableBalance)).toBe(35); + }); + + it('returns the input untouched when no collateral spot balance is present', () => { + const accountState: AccountState = { + spendableBalance: '1', + withdrawableBalance: '1', + totalBalance: '2', + marginUsed: '3', + unrealizedPnl: '4', + returnOnEquity: '5', + }; + + const result = addSpotBalanceToAccountState(accountState, { + balances: [], + } as never); + + expect(result).toBe(accountState); + }); + + it('does NOT fold spot into spendable/withdrawable when foldIntoCollateral is false (e.g. HL Standard mode)', () => { + const accountState: AccountState = { + spendableBalance: '5', + withdrawableBalance: '5', + totalBalance: '5', + marginUsed: '0', + unrealizedPnl: '0', + returnOnEquity: '0', + }; + + const result = addSpotBalanceToAccountState( + accountState, + { balances: [{ coin: 'USDC', total: '30' }] } as never, + { foldIntoCollateral: false }, + ); + + // Total still reflects combined wealth (display). + expect(parseFloat(result.totalBalance)).toBe(35); + // Spendable/withdrawable must remain perps-only — spot isn't auto-collateral + // on Standard mode, so surfacing a folded value would mislead the validation + // hook into approving submissions HL will reject. + expect(result.spendableBalance).toBe('5'); + expect(result.withdrawableBalance).toBe('5'); + }); + + it('folds spot into spendable/withdrawable when foldIntoCollateral is explicitly true', () => { + const accountState: AccountState = { + spendableBalance: '5', + withdrawableBalance: '5', + totalBalance: '5', + marginUsed: '0', + unrealizedPnl: '0', + returnOnEquity: '0', + }; + + const result = addSpotBalanceToAccountState( + accountState, + { balances: [{ coin: 'USDC', total: '30' }] } as never, + { foldIntoCollateral: true }, + ); + + expect(parseFloat(result.totalBalance)).toBe(35); + expect(parseFloat(result.spendableBalance)).toBe(35); + expect(parseFloat(result.withdrawableBalance)).toBe(35); + }); +}); + +describe('calculateWeightedReturnOnEquity', () => { + it('returns 0 for empty array', () => { + expect(calculateWeightedReturnOnEquity([])).toBe('0'); + }); + + it('returns the single account ROE for one account', () => { + const result = calculateWeightedReturnOnEquity([ + { unrealizedPnl: '10', returnOnEquity: '20' }, + ]); + expect(parseFloat(result)).toBeCloseTo(20, 5); + }); +}); diff --git a/packages/perps-controller/tests/src/utils/capabilitySymbols.test.ts b/packages/perps-controller/tests/src/utils/capabilitySymbols.test.ts new file mode 100644 index 00000000000..cbd9e294e25 --- /dev/null +++ b/packages/perps-controller/tests/src/utils/capabilitySymbols.test.ts @@ -0,0 +1,35 @@ +import { isValidCapabilitySymbol } from '../../../src/utils/capabilitySymbols.js'; + +describe('isValidCapabilitySymbol', () => { + it.each([ + ['ETH', false], + ['ETH', true], + ['xyz:TSLA', true], + ] as const)( + 'accepts %p when provider routes are %p', + (symbol, allowRoute) => { + expect( + isValidCapabilitySymbol(symbol, { allowProviderRoute: allowRoute }), + ).toBe(true); + }, + ); + + it.each([ + ['', false], + ['', true], + [' ETH', false], + ['ETH ', true], + ['ETH USD', false], + ['xyz:TSLA', false], + [':TSLA', true], + ['xyz:', true], + ['xyz:desk:TSLA', true], + ] as const)( + 'rejects %p when provider routes are %p', + (symbol, allowRoute) => { + expect( + isValidCapabilitySymbol(symbol, { allowProviderRoute: allowRoute }), + ).toBe(false); + }, + ); +}); diff --git a/packages/perps-controller/tests/src/utils/coalescePerpsRestRequest.test.ts b/packages/perps-controller/tests/src/utils/coalescePerpsRestRequest.test.ts new file mode 100644 index 00000000000..e21b1da3e11 --- /dev/null +++ b/packages/perps-controller/tests/src/utils/coalescePerpsRestRequest.test.ts @@ -0,0 +1,180 @@ +import { + coalescePerpsRestRequest, + resetPerpsRestCacheForTests, +} from '../../../src/utils/coalescePerpsRestRequest.js'; + +describe('coalescePerpsRestRequest', () => { + beforeEach(() => { + resetPerpsRestCacheForTests(); + jest.useFakeTimers(); + jest.setSystemTime(0); + }); + + afterEach(() => { + jest.useRealTimers(); + resetPerpsRestCacheForTests(); + }); + + it('returns the fetcher result on first call', async () => { + const fetcher = jest.fn().mockResolvedValue('v1'); + + const result = await coalescePerpsRestRequest('k', fetcher); + + expect(result).toBe('v1'); + expect(fetcher).toHaveBeenCalledTimes(1); + }); + + it('shares the in-flight promise across concurrent callers', async () => { + let resolveFetch!: (v: string) => void; + const fetcher = jest.fn( + () => + new Promise((resolve) => { + resolveFetch = resolve; + }), + ); + + const p1 = coalescePerpsRestRequest('k', fetcher); + const p2 = coalescePerpsRestRequest('k', fetcher); + const p3 = coalescePerpsRestRequest('k', fetcher); + + resolveFetch('shared'); + const [r1, r2, r3] = await Promise.all([p1, p2, p3]); + + expect(r1).toBe('shared'); + expect(r2).toBe('shared'); + expect(r3).toBe('shared'); + expect(fetcher).toHaveBeenCalledTimes(1); + }); + + it('serves cached value within the TTL window', async () => { + const fetcher = jest.fn().mockResolvedValue('cached'); + + const first = await coalescePerpsRestRequest('k', fetcher, { + ttlMs: 1000, + }); + jest.setSystemTime(500); + const second = await coalescePerpsRestRequest('k', fetcher, { + ttlMs: 1000, + }); + + expect(first).toBe('cached'); + expect(second).toBe('cached'); + expect(fetcher).toHaveBeenCalledTimes(1); + }); + + it('refetches after the TTL expires', async () => { + const fetcher = jest + .fn() + .mockResolvedValueOnce('first') + .mockResolvedValueOnce('second'); + + const a = await coalescePerpsRestRequest('k', fetcher, { ttlMs: 1000 }); + jest.setSystemTime(1001); + const b = await coalescePerpsRestRequest('k', fetcher, { ttlMs: 1000 }); + + expect(a).toBe('first'); + expect(b).toBe('second'); + expect(fetcher).toHaveBeenCalledTimes(2); + }); + + it('scopes cache by key', async () => { + const fetcherA = jest.fn().mockResolvedValue('A'); + const fetcherB = jest.fn().mockResolvedValue('B'); + + const rA = await coalescePerpsRestRequest('a', fetcherA); + const rB = await coalescePerpsRestRequest('b', fetcherB); + + expect(rA).toBe('A'); + expect(rB).toBe('B'); + expect(fetcherA).toHaveBeenCalledTimes(1); + expect(fetcherB).toHaveBeenCalledTimes(1); + }); + + it('bypasses cache when forceRefresh is true', async () => { + const fetcher = jest + .fn() + .mockResolvedValueOnce('stale') + .mockResolvedValueOnce('fresh'); + + await coalescePerpsRestRequest('k', fetcher); + const forced = await coalescePerpsRestRequest('k', fetcher, { + forceRefresh: true, + }); + + expect(forced).toBe('fresh'); + expect(fetcher).toHaveBeenCalledTimes(2); + }); + + it('does not let a stale in-flight resolution clobber a forceRefresh result', async () => { + let resolveStale!: (v: string) => void; + const staleFetcher = jest.fn( + () => + new Promise((resolve) => { + resolveStale = resolve; + }), + ); + const freshFetcher = jest.fn().mockResolvedValue('fresh'); + + const stalePromise = coalescePerpsRestRequest('k', staleFetcher); + const freshPromise = coalescePerpsRestRequest('k', freshFetcher, { + forceRefresh: true, + }); + const freshResult = await freshPromise; + + resolveStale('stale'); + await stalePromise; + + // Next cached read must return the fresh value, not the late stale one. + const cachedFetcher = jest.fn(); + const cachedResult = await coalescePerpsRestRequest('k', cachedFetcher); + + expect(freshResult).toBe('fresh'); + expect(cachedResult).toBe('fresh'); + expect(cachedFetcher).not.toHaveBeenCalled(); + }); + + it('does not cache the value when the fetcher rejects', async () => { + const rejectingFetcher = jest.fn().mockRejectedValue(new Error('boom')); + const retryFetcher = jest.fn().mockResolvedValue('ok'); + + await expect( + coalescePerpsRestRequest('k', rejectingFetcher), + ).rejects.toThrow('boom'); + const retry = await coalescePerpsRestRequest('k', retryFetcher); + + expect(retry).toBe('ok'); + expect(retryFetcher).toHaveBeenCalledTimes(1); + }); + + it('evicts expired entries on TTL-miss', async () => { + const fetcher = jest + .fn() + .mockResolvedValueOnce('first') + .mockResolvedValueOnce('second'); + + // Populate under key A and let it expire. + await coalescePerpsRestRequest('a', fetcher, { ttlMs: 1000 }); + jest.setSystemTime(1001); + // Next call under key A must evict the stale entry before running. + const refreshed = await coalescePerpsRestRequest('a', fetcher, { + ttlMs: 1000, + }); + + expect(refreshed).toBe('second'); + expect(fetcher).toHaveBeenCalledTimes(2); + }); + + it('resetPerpsRestCacheForTests clears cache and in-flight entries', async () => { + const fetcher = jest + .fn() + .mockResolvedValueOnce('first') + .mockResolvedValueOnce('second'); + + await coalescePerpsRestRequest('k', fetcher); + resetPerpsRestCacheForTests(); + const after = await coalescePerpsRestRequest('k', fetcher); + + expect(after).toBe('second'); + expect(fetcher).toHaveBeenCalledTimes(2); + }); +}); diff --git a/packages/perps-controller/tests/src/utils/errorUtils.test.ts b/packages/perps-controller/tests/src/utils/errorUtils.test.ts new file mode 100644 index 00000000000..b82b5052df3 --- /dev/null +++ b/packages/perps-controller/tests/src/utils/errorUtils.test.ts @@ -0,0 +1,165 @@ +import { + isAbortError, + ensureError, + isHyperLiquidMultiSigRequiredError, + isHyperLiquidUserNotFoundError, + isKeyringLockedError, +} from '../../../src/utils/errorUtils.js'; + +describe('errorUtils', () => { + describe('isAbortError', () => { + it('returns true for Error with name AbortError', () => { + const error = new Error('The operation was aborted'); + error.name = 'AbortError'; + + expect(isAbortError(error)).toBe(true); + }); + + it('returns true for Error with "signal is aborted" message', () => { + const error = new Error('AbortError: signal is aborted without reason'); + + expect(isAbortError(error)).toBe(true); + }); + + it('returns true for Error with "The operation was aborted" message', () => { + const error = new Error('The operation was aborted'); + + expect(isAbortError(error)).toBe(true); + }); + + it('returns false for regular Error', () => { + const error = new Error('Network timeout'); + + expect(isAbortError(error)).toBe(false); + }); + + it('returns false for non-Error values', () => { + expect(isAbortError('some string')).toBe(false); + expect(isAbortError(null)).toBe(false); + expect(isAbortError(undefined)).toBe(false); + expect(isAbortError(42)).toBe(false); + }); + + it('returns false for DOMException with non-abort name', () => { + const error = new Error('Something failed'); + error.name = 'TypeError'; + + expect(isAbortError(error)).toBe(false); + }); + }); + + describe('ensureError', () => { + it('returns Error instance unchanged', () => { + const error = new Error('test'); + + expect(ensureError(error)).toBe(error); + }); + + it('wraps string in Error', () => { + const result = ensureError('string error'); + + expect(result).toBeInstanceOf(Error); + expect(result.message).toBe('string error'); + }); + + it('wraps undefined with context', () => { + const result = ensureError(undefined, 'TestContext'); + + expect(result).toBeInstanceOf(Error); + expect(result.message).toContain('Unknown error'); + expect(result.message).toContain('TestContext'); + }); + + it('wraps null with context', () => { + const result = ensureError(null, 'TestContext'); + + expect(result).toBeInstanceOf(Error); + expect(result.message).toContain('Unknown error'); + }); + }); + + describe('isKeyringLockedError', () => { + it('returns true for direct KEYRING_LOCKED errors', () => { + const error = new Error('KEYRING_LOCKED'); + + expect(isKeyringLockedError(error)).toBe(true); + }); + + it('returns true for wrapped KEYRING_LOCKED causes', () => { + const error = Object.assign( + new Error('Failed to sign typed data with viem wallet'), + { cause: new Error('KEYRING_LOCKED') }, + ); + + expect(isKeyringLockedError(error)).toBe(true); + }); + + it('returns false for non-keyring errors with cyclic causes', () => { + const error = new Error('Network error') as Error & { cause?: unknown }; + error.cause = error; + + expect(isKeyringLockedError(error)).toBe(false); + }); + }); + + describe('isHyperLiquidUserNotFoundError', () => { + it('returns true for the Hyperliquid "wallet does not exist" rejection', () => { + const error = new Error( + 'User or API Wallet 0x340ed4af8642491fe02fa28403cad1a53268e510 does not exist.', + ); + + expect(isHyperLiquidUserNotFoundError(error)).toBe(true); + }); + + it('returns true for non-Error rejections carrying the same message', () => { + expect( + isHyperLiquidUserNotFoundError( + 'user or API wallet 0xabc does not exist', + ), + ).toBe(true); + }); + + it('returns false for unrelated "does not exist" errors', () => { + expect( + isHyperLiquidUserNotFoundError(new Error('Asset BTC does not exist')), + ).toBe(false); + }); + }); + + describe('isHyperLiquidMultiSigRequiredError', () => { + it('returns true for both Hyperliquid multi-sig required spellings', () => { + // Hyperliquid is not consistent about the hyphen across endpoints, so + // both spellings must classify as the same benign condition. + expect( + isHyperLiquidMultiSigRequiredError( + new Error('ApiRequestError: Multi-sig required'), + ), + ).toBe(true); + expect( + isHyperLiquidMultiSigRequiredError( + new Error('ApiRequestError: Multisig required'), + ), + ).toBe(true); + }); + + it('returns true for non-Error rejections carrying the same message', () => { + expect(isHyperLiquidMultiSigRequiredError('multi-sig required')).toBe( + true, + ); + }); + + it('returns false for unrelated Hyperliquid and network errors', () => { + expect( + isHyperLiquidMultiSigRequiredError(new Error('Network error')), + ).toBe(false); + expect( + isHyperLiquidMultiSigRequiredError( + new Error( + 'User or API Wallet 0x340ed4af8642491fe02fa28403cad1a53268e510 does not exist.', + ), + ), + ).toBe(false); + expect(isHyperLiquidMultiSigRequiredError(undefined)).toBe(false); + }); + }); +}); diff --git a/packages/perps-controller/tests/src/utils/hyperLiquidAbstraction.test.ts b/packages/perps-controller/tests/src/utils/hyperLiquidAbstraction.test.ts new file mode 100644 index 00000000000..7ed3364ed91 --- /dev/null +++ b/packages/perps-controller/tests/src/utils/hyperLiquidAbstraction.test.ts @@ -0,0 +1,24 @@ +import { shouldDeferUnifiedAccountSetup } from '../../../src/utils/hyperLiquidAbstraction.js'; + +describe('shouldDeferUnifiedAccountSetup', () => { + it.each(['dexAbstraction', 'default', 'disabled'] as const)( + 'defers %s setup when signing is not allowed', + (currentMode) => { + expect(shouldDeferUnifiedAccountSetup(currentMode, false)).toBe(true); + }, + ); + + it.each(['dexAbstraction', 'default', 'disabled'] as const)( + 'allows %s setup when signing is allowed', + (currentMode) => { + expect(shouldDeferUnifiedAccountSetup(currentMode, true)).toBe(false); + }, + ); + + it.each(['unifiedAccount', 'portfolioMargin', undefined] as const)( + 'does not defer %s because no migration is required', + (currentMode) => { + expect(shouldDeferUnifiedAccountSetup(currentMode, false)).toBe(false); + }, + ); +}); diff --git a/packages/perps-controller/tests/src/utils/hyperLiquidAdapter.advanced-orders.test.ts b/packages/perps-controller/tests/src/utils/hyperLiquidAdapter.advanced-orders.test.ts new file mode 100644 index 00000000000..9b3ed993cfd --- /dev/null +++ b/packages/perps-controller/tests/src/utils/hyperLiquidAdapter.advanced-orders.test.ts @@ -0,0 +1,335 @@ +import { ORDER_SLIPPAGE_CONFIG } from '../../../src/constants/perpsConfig.js'; +import { PERPS_ERROR_CODES } from '../../../src/perpsErrorCodes.js'; +import type { FrontendOrder } from '../../../src/types/hyperliquid-types.js'; +import type { OrderParams } from '../../../src/types/index.js'; +import type { + TpslLinkage, + TriggerOrderType, +} from '../../../src/types/perps-types.js'; +import { + adaptOrderFromSDK, + adaptOrderToSDK, + adaptPositionTriggerOrderFromSDK, + adaptTpslLinkageToGrouping, + adaptTriggerOrderTypeFromSDK, +} from '../../../src/utils/hyperLiquidAdapter.js'; + +/** + * Builds a minimal valid `FrontendOrder` fixture, overridable per test. + * + * @param overrides - Fields to override on the base fixture. + * @returns A `FrontendOrder` fixture. + */ +function buildFrontendOrder( + overrides: Partial = {}, +): FrontendOrder { + return { + coin: 'BTC', + side: 'B', + limitPx: '50000', + sz: '0.1', + oid: 12345, + timestamp: 1_700_000_000_000, + origSz: '0.1', + triggerCondition: 'N/A', + isTrigger: false, + triggerPx: '', + children: [], + isPositionTpsl: false, + reduceOnly: false, + orderType: 'Limit', + ...overrides, + } as FrontendOrder; +} + +const TPSL_SLIPPAGE = ORDER_SLIPPAGE_CONFIG.DefaultTpslSlippageBps / 10_000; + +const buildOrderParams = ( + overrides: Partial = {}, +): OrderParams => ({ + symbol: 'BTC', + isBuy: true, + size: '0.1', + orderType: 'market', + ...overrides, +}); + +describe('hyperLiquidAdapter - advanced order types', () => { + describe('adaptTriggerOrderTypeFromSDK', () => { + it.each([ + ['Stop Market', 'stop_market'], + ['Stop Limit', 'stop_limit'], + ['Take Profit Market', 'take_profit_market'], + ['Take Profit Limit', 'take_profit_limit'], + ] as [string, TriggerOrderType][])( + 'maps %s to %s', + (detailedOrderType, expected) => { + expect(adaptTriggerOrderTypeFromSDK(detailedOrderType)).toBe(expected); + }, + ); + + it.each([undefined, '', 'Limit', 'Market', 'Trigger'])( + 'returns undefined for %s', + (detailedOrderType) => { + expect(adaptTriggerOrderTypeFromSDK(detailedOrderType)).toBeUndefined(); + }, + ); + }); + + describe('adaptOrderFromSDK', () => { + it('round-trips a stop market order with its trigger data', () => { + const result = adaptOrderFromSDK( + buildFrontendOrder({ + orderType: 'Stop Market', + isTrigger: true, + triggerPx: '45000', + limitPx: '40500', + reduceOnly: true, + }), + ); + + expect(result.triggerOrderType).toBe('stop_market'); + expect(result.triggerPrice).toBe('45000'); + expect(result.reduceOnly).toBe(true); + expect(result.isTrigger).toBe(true); + }); + + it('round-trips a take profit limit order', () => { + const result = adaptOrderFromSDK( + buildFrontendOrder({ + orderType: 'Take Profit Limit', + isTrigger: true, + triggerPx: '60000', + limitPx: '60000', + sz: '0.04', + origSz: '0.04', + }), + ); + + expect(result.triggerOrderType).toBe('take_profit_limit'); + expect(result.triggerPrice).toBe('60000'); + // Partial quantity is carried by the order size itself + expect(result.size).toBe('0.04'); + }); + + it('leaves triggerOrderType unset for plain orders', () => { + const result = adaptOrderFromSDK(buildFrontendOrder()); + + expect(result.triggerOrderType).toBeUndefined(); + }); + + it('tolerates runtime orders without an id or detailed type', () => { + const malformedOrder = buildFrontendOrder({ + oid: undefined, + orderType: undefined, + limitPx: '', + } as unknown as Partial); + + const result = adaptOrderFromSDK(malformedOrder); + + expect(result).toMatchObject({ + orderId: '', + orderType: 'market', + }); + expect(result.detailedOrderType).toBeUndefined(); + expect(result.triggerOrderType).toBeUndefined(); + }); + }); + + describe('adaptOrderToSDK', () => { + const symbolToAssetId = new Map([['BTC', 0]]); + + it.each([ + ['GTC', 'Gtc'], + ['IOC', 'Ioc'], + ['ALO', 'Alo'], + ] as const)( + 'maps %s time in force onto a limit order', + (timeInForce, tif) => { + const result = adaptOrderToSDK( + buildOrderParams({ orderType: 'limit', timeInForce }), + symbolToAssetId, + ); + + expect(result.t).toStrictEqual({ limit: { tif } }); + }, + ); + + it.each([ + ['stop_market', { isMarket: true, tpsl: 'sl' }], + ['stop_limit', { isMarket: false, tpsl: 'sl' }], + ['take_profit_market', { isMarket: true, tpsl: 'tp' }], + ['take_profit_limit', { isMarket: false, tpsl: 'tp' }], + ] as [TriggerOrderType, { isMarket: boolean; tpsl: string }][])( + 'maps %s onto the SDK trigger shape', + (orderType, expected) => { + const result = adaptOrderToSDK( + buildOrderParams({ orderType, triggerPrice: '45000' }), + symbolToAssetId, + ); + + expect(result.t).toStrictEqual({ + trigger: { + isMarket: expected.isMarket, + triggerPx: '45000', + tpsl: expected.tpsl, + }, + }); + }, + ); + + it('throws a typed error when a trigger placement has no trigger price', () => { + expect(() => + adaptOrderToSDK( + buildOrderParams({ orderType: 'stop_limit', price: '44000' }), + symbolToAssetId, + ), + ).toThrow(PERPS_ERROR_CODES.ORDER_TRIGGER_PRICE_REQUIRED); + }); + + it.each([ + // A market-on-trigger order carries no limit price, but `p` is the + // slippage cap the SDK still requires — sending '0' fails validation. + ['stop_market', true, 45000 * (1 + TPSL_SLIPPAGE)], + ['stop_market', false, 45000 * (1 - TPSL_SLIPPAGE)], + ['take_profit_market', true, 45000 * (1 + TPSL_SLIPPAGE)], + ['take_profit_market', false, 45000 * (1 - TPSL_SLIPPAGE)], + ] as [TriggerOrderType, boolean, number][])( + 'caps %s (isBuy: %s) at the trigger price adjusted for slippage', + (orderType, isBuy, expectedCap) => { + const result = adaptOrderToSDK( + buildOrderParams({ orderType, isBuy, triggerPrice: '45000' }), + symbolToAssetId, + ); + + expect(parseFloat(String(result.p))).toBeCloseTo(expectedCap, 6); + }, + ); + + it('caps a market-on-trigger order at the requested slippage tolerance', () => { + const result = adaptOrderToSDK( + buildOrderParams({ + orderType: 'stop_market', + isBuy: false, + triggerPrice: '45000', + maxSlippageBps: 50, + }), + symbolToAssetId, + ); + + expect(parseFloat(String(result.p))).toBeCloseTo(45000 * (1 - 0.005), 6); + }); + + it('honours the deprecated decimal slippage when no bps tolerance is set', () => { + const result = adaptOrderToSDK( + buildOrderParams({ + orderType: 'stop_market', + isBuy: false, + triggerPrice: '45000', + slippage: 0.005, + }), + symbolToAssetId, + ); + + expect(parseFloat(String(result.p))).toBeCloseTo(45000 * (1 - 0.005), 6); + }); + + it('keeps an explicit price on a market-on-trigger order', () => { + const result = adaptOrderToSDK( + buildOrderParams({ + orderType: 'stop_market', + triggerPrice: '45000', + price: '44000', + }), + symbolToAssetId, + ); + + expect(result.p).toBe('44000'); + }); + + it('keeps the existing mapping for market and limit orders', () => { + expect( + adaptOrderToSDK(buildOrderParams(), symbolToAssetId).t, + ).toStrictEqual({ limit: { tif: 'FrontendMarket' } }); + expect( + adaptOrderToSDK( + buildOrderParams({ orderType: 'limit', price: '49000' }), + symbolToAssetId, + ).t, + ).toStrictEqual({ limit: { tif: 'Gtc' } }); + }); + }); + + describe('adaptPositionTriggerOrderFromSDK', () => { + it('reports a partial trigger order against the position size', () => { + const result = adaptPositionTriggerOrderFromSDK({ + rawOrder: buildFrontendOrder({ + oid: 999, + orderType: 'Take Profit Limit', + triggerPx: '60000', + sz: '0.4', + reduceOnly: true, + }), + positionSize: '1', + }); + + expect(result).toStrictEqual({ + orderId: '999', + direction: 'take_profit', + orderType: 'take_profit_limit', + triggerPrice: '60000', + size: '0.4', + isPartial: true, + reduceOnly: true, + }); + }); + + it('resolves a position-bound trigger (size 0) to the position size', () => { + const result = adaptPositionTriggerOrderFromSDK({ + rawOrder: buildFrontendOrder({ + orderType: 'Stop Market', + triggerPx: '45000', + sz: '0', + reduceOnly: true, + }), + positionSize: '-2', + }); + + expect(result?.size).toBe('2'); + expect(result?.isPartial).toBe(false); + }); + + it('falls back to limitPx when HyperLiquid omits the trigger price', () => { + const result = adaptPositionTriggerOrderFromSDK({ + rawOrder: buildFrontendOrder({ + orderType: 'Stop Limit', + triggerPx: '', + limitPx: '44000', + sz: '1', + }), + positionSize: '1', + }); + + expect(result?.triggerPrice).toBe('44000'); + }); + + it('returns undefined for non-trigger orders', () => { + expect( + adaptPositionTriggerOrderFromSDK({ + rawOrder: buildFrontendOrder(), + positionSize: '1', + }), + ).toBeUndefined(); + }); + }); + + describe('adaptTpslLinkageToGrouping', () => { + it.each([ + ['none', 'na'], + ['order', 'normalTpsl'], + ['position', 'positionTpsl'], + ] as [TpslLinkage, string][])('maps %s to %s', (linkage, expected) => { + expect(adaptTpslLinkageToGrouping(linkage)).toBe(expected); + }); + }); +}); diff --git a/packages/perps-controller/tests/src/utils/hyperLiquidAdapter.test.ts b/packages/perps-controller/tests/src/utils/hyperLiquidAdapter.test.ts new file mode 100644 index 00000000000..04ebe449fb5 --- /dev/null +++ b/packages/perps-controller/tests/src/utils/hyperLiquidAdapter.test.ts @@ -0,0 +1,105 @@ +import type { FrontendOrder } from '../../../src/types/hyperliquid-types.js'; +import { adaptOrderFromSDK } from '../../../src/utils/hyperLiquidAdapter.js'; + +/** + * Builds a minimal valid `FrontendOrder` fixture, overridable per test. + * + * @param overrides - Fields to override on the base fixture. + * @returns A `FrontendOrder` fixture. + */ +function buildFrontendOrder( + overrides: Partial = {}, +): FrontendOrder { + return { + coin: 'BTC', + side: 'B', + limitPx: '50000', + sz: '0.1', + oid: 12345, + timestamp: Date.now(), + origSz: '0.1', + triggerCondition: 'N/A', + isTrigger: false, + triggerPx: '', + children: [], + isPositionTpsl: false, + reduceOnly: false, + orderType: 'Limit', + ...overrides, + } as FrontendOrder; +} + +describe('adaptOrderFromSDK', () => { + it('converts a child take-profit order with limitPx instead of triggerPx', () => { + const frontendOrder = buildFrontendOrder({ + coin: 'ADA', + oid: 66666, + children: [ + buildFrontendOrder({ + oid: 66667, + orderType: 'Take Profit Limit', + isTrigger: true, + // HyperLiquid represents "no trigger price" as an empty string, + // with the actual price carried in limitPx instead. + triggerPx: '', + limitPx: '0.6', + }), + ], + }); + + const result = adaptOrderFromSDK(frontendOrder); + + expect(result.takeProfitPrice).toBe('0.6'); + expect(result.takeProfitOrderId).toBe('66667'); + }); + + it('converts a child stop-loss order with limitPx instead of triggerPx', () => { + const frontendOrder = buildFrontendOrder({ + coin: 'ADA', + oid: 66666, + children: [ + buildFrontendOrder({ + oid: 66668, + orderType: 'Stop Limit', + isTrigger: true, + triggerPx: '', + limitPx: '0.4', + }), + ], + }); + + const result = adaptOrderFromSDK(frontendOrder); + + expect(result.stopLossPrice).toBe('0.4'); + expect(result.stopLossOrderId).toBe('66668'); + }); + + it('prefers triggerPx over limitPx when triggerPx is a non-empty value', () => { + const frontendOrder = buildFrontendOrder({ + coin: 'ADA', + oid: 66666, + children: [ + buildFrontendOrder({ + oid: 66667, + orderType: 'Take Profit Limit', + isTrigger: true, + triggerPx: '0.75', + limitPx: '0.6', + }), + ], + }); + + const result = adaptOrderFromSDK(frontendOrder); + + expect(result.takeProfitPrice).toBe('0.75'); + }); + + it('does not set take-profit/stop-loss fields when there are no children', () => { + const frontendOrder = buildFrontendOrder({ children: [] }); + + const result = adaptOrderFromSDK(frontendOrder); + + expect(result.takeProfitPrice).toBeUndefined(); + expect(result.stopLossPrice).toBeUndefined(); + }); +}); diff --git a/packages/perps-controller/tests/src/utils/hyperLiquidOrderBookProcessor.test.ts b/packages/perps-controller/tests/src/utils/hyperLiquidOrderBookProcessor.test.ts new file mode 100644 index 00000000000..6cc80e32a0a --- /dev/null +++ b/packages/perps-controller/tests/src/utils/hyperLiquidOrderBookProcessor.test.ts @@ -0,0 +1,651 @@ +/* eslint-disable */ +/** + * Unit tests for HyperLiquid Order Book Processor + */ + +import type { BboWsEvent, L2BookResponse } from '@nktkas/hyperliquid'; + +import type { PriceUpdate } from '../../../src/types/index.js'; +import { + processBboData, + processL2BookData, +} from '../../../src/utils/hyperLiquidOrderBookProcessor.js'; +import type { + OrderBookCacheEntry, + ProcessBboDataParams, + ProcessL2BookDataParams, +} from '../../../src/utils/hyperLiquidOrderBookProcessor.js'; + +describe('hyperLiquidOrderBookProcessor', () => { + let mockOrderBookCache: Map; + let mockCachedPriceData: Map; + let mockCreatePriceUpdate: jest.Mock; + let mockNotifySubscribers: jest.Mock; + + beforeEach(() => { + jest.clearAllMocks(); + mockOrderBookCache = new Map(); + mockCachedPriceData = new Map(); + mockCreatePriceUpdate = jest.fn((symbol: string, price: string) => ({ + symbol, + price, + timestamp: Date.now(), + isTradable: true, + })); + mockNotifySubscribers = jest.fn(); + }); + + describe('processL2BookData', () => { + it('processes valid L2 book data with bid and ask', () => { + const symbol = 'BTC'; + const data: L2BookResponse = { + coin: 'BTC', + time: Date.now(), + levels: [ + [{ px: '49900', sz: '1.5', n: 3 }], // Bid level + [{ px: '50100', sz: '2.0', n: 5 }], // Ask level + ], + }; + + mockCachedPriceData.set('BTC', { + symbol: 'BTC', + price: '50000', + timestamp: Date.now(), + }); + + const params: ProcessL2BookDataParams = { + symbol, + data, + orderBookCache: mockOrderBookCache, + cachedPriceData: mockCachedPriceData, + createPriceUpdate: mockCreatePriceUpdate, + notifySubscribers: mockNotifySubscribers, + }; + + processL2BookData(params); + + const cacheEntry = mockOrderBookCache.get('BTC'); + expect(cacheEntry).toBeDefined(); + expect(cacheEntry?.bestBid).toBe('49900'); + expect(cacheEntry?.bestAsk).toBe('50100'); + expect(cacheEntry?.spread).toBe('200.00000'); + expect(cacheEntry?.lastUpdated).toBeGreaterThan(0); + expect(mockCreatePriceUpdate).toHaveBeenCalledWith('BTC', '50000'); + expect(mockNotifySubscribers).toHaveBeenCalledTimes(1); + }); + + it('returns early when symbol does not match', () => { + const data: L2BookResponse = { + coin: 'ETH', + time: Date.now(), + levels: [ + [{ px: '3000', sz: '1.0', n: 2 }], + [{ px: '3100', sz: '1.0', n: 2 }], + ], + }; + + const params: ProcessL2BookDataParams = { + symbol: 'BTC', + data, + orderBookCache: mockOrderBookCache, + cachedPriceData: mockCachedPriceData, + createPriceUpdate: mockCreatePriceUpdate, + notifySubscribers: mockNotifySubscribers, + }; + + processL2BookData(params); + + expect(mockOrderBookCache.size).toBe(0); + expect(mockCreatePriceUpdate).not.toHaveBeenCalled(); + expect(mockNotifySubscribers).not.toHaveBeenCalled(); + }); + + it('returns early when levels data is missing', () => { + const data = { + coin: 'BTC', + time: Date.now(), + levels: undefined, + } as unknown as L2BookResponse; + + const params: ProcessL2BookDataParams = { + symbol: 'BTC', + data, + orderBookCache: mockOrderBookCache, + cachedPriceData: mockCachedPriceData, + createPriceUpdate: mockCreatePriceUpdate, + notifySubscribers: mockNotifySubscribers, + }; + + processL2BookData(params); + + expect(mockOrderBookCache.size).toBe(0); + expect(mockCreatePriceUpdate).not.toHaveBeenCalled(); + expect(mockNotifySubscribers).not.toHaveBeenCalled(); + }); + + it('returns early when both bid and ask are missing', () => { + const data: L2BookResponse = { + coin: 'BTC', + time: Date.now(), + levels: [[], []], + }; + + const params: ProcessL2BookDataParams = { + symbol: 'BTC', + data, + orderBookCache: mockOrderBookCache, + cachedPriceData: mockCachedPriceData, + createPriceUpdate: mockCreatePriceUpdate, + notifySubscribers: mockNotifySubscribers, + }; + + processL2BookData(params); + + expect(mockOrderBookCache.size).toBe(0); + expect(mockCreatePriceUpdate).not.toHaveBeenCalled(); + expect(mockNotifySubscribers).not.toHaveBeenCalled(); + }); + + it('processes data with only bid present', () => { + const data: L2BookResponse = { + coin: 'BTC', + time: Date.now(), + levels: [[{ px: '49900', sz: '1.5', n: 3 }], []], + }; + + mockCachedPriceData.set('BTC', { + symbol: 'BTC', + price: '50000', + timestamp: Date.now(), + }); + + const params: ProcessL2BookDataParams = { + symbol: 'BTC', + data, + orderBookCache: mockOrderBookCache, + cachedPriceData: mockCachedPriceData, + createPriceUpdate: mockCreatePriceUpdate, + notifySubscribers: mockNotifySubscribers, + }; + + processL2BookData(params); + + const cacheEntry = mockOrderBookCache.get('BTC'); + expect(cacheEntry).toBeDefined(); + expect(cacheEntry?.bestBid).toBe('49900'); + expect(cacheEntry?.bestAsk).toBeUndefined(); + expect(cacheEntry?.spread).toBeUndefined(); + expect(mockNotifySubscribers).toHaveBeenCalledTimes(1); + }); + + it('processes data with only ask present', () => { + const data: L2BookResponse = { + coin: 'BTC', + time: Date.now(), + levels: [[], [{ px: '50100', sz: '2.0', n: 5 }]], + }; + + mockCachedPriceData.set('BTC', { + symbol: 'BTC', + price: '50000', + timestamp: Date.now(), + }); + + const params: ProcessL2BookDataParams = { + symbol: 'BTC', + data, + orderBookCache: mockOrderBookCache, + cachedPriceData: mockCachedPriceData, + createPriceUpdate: mockCreatePriceUpdate, + notifySubscribers: mockNotifySubscribers, + }; + + processL2BookData(params); + + const cacheEntry = mockOrderBookCache.get('BTC'); + expect(cacheEntry).toBeDefined(); + expect(cacheEntry?.bestBid).toBeUndefined(); + expect(cacheEntry?.bestAsk).toBe('50100'); + expect(cacheEntry?.spread).toBeUndefined(); + expect(mockNotifySubscribers).toHaveBeenCalledTimes(1); + }); + + it('returns early when no cached price data exists for symbol', () => { + const data: L2BookResponse = { + coin: 'BTC', + time: Date.now(), + levels: [ + [{ px: '49900', sz: '1.5', n: 3 }], + [{ px: '50100', sz: '2.0', n: 5 }], + ], + }; + + const params: ProcessL2BookDataParams = { + symbol: 'BTC', + data, + orderBookCache: mockOrderBookCache, + cachedPriceData: mockCachedPriceData, + createPriceUpdate: mockCreatePriceUpdate, + notifySubscribers: mockNotifySubscribers, + }; + + processL2BookData(params); + + expect(mockOrderBookCache.get('BTC')).toBeDefined(); + expect(mockCreatePriceUpdate).not.toHaveBeenCalled(); + expect(mockNotifySubscribers).not.toHaveBeenCalled(); + }); + + it('handles null cachedPriceData gracefully', () => { + const data: L2BookResponse = { + coin: 'BTC', + time: Date.now(), + levels: [ + [{ px: '49900', sz: '1.5', n: 3 }], + [{ px: '50100', sz: '2.0', n: 5 }], + ], + }; + + const params: ProcessL2BookDataParams = { + symbol: 'BTC', + data, + orderBookCache: mockOrderBookCache, + cachedPriceData: null, + createPriceUpdate: mockCreatePriceUpdate, + notifySubscribers: mockNotifySubscribers, + }; + + processL2BookData(params); + + expect(mockOrderBookCache.get('BTC')).toBeDefined(); + expect(mockCreatePriceUpdate).not.toHaveBeenCalled(); + expect(mockNotifySubscribers).not.toHaveBeenCalled(); + }); + + it('calculates spread correctly with valid bid and ask', () => { + const data: L2BookResponse = { + coin: 'BTC', + time: Date.now(), + levels: [ + [{ px: '50000', sz: '1.0', n: 1 }], + [{ px: '50005', sz: '1.0', n: 1 }], + ], + }; + + mockCachedPriceData.set('BTC', { + symbol: 'BTC', + price: '50000', + timestamp: Date.now(), + }); + + const params: ProcessL2BookDataParams = { + symbol: 'BTC', + data, + orderBookCache: mockOrderBookCache, + cachedPriceData: mockCachedPriceData, + createPriceUpdate: mockCreatePriceUpdate, + notifySubscribers: mockNotifySubscribers, + }; + + processL2BookData(params); + + const cacheEntry = mockOrderBookCache.get('BTC'); + expect(cacheEntry?.spread).toBe('5.00000'); + }); + + it('sets spread to undefined when only bid is present', () => { + const data: L2BookResponse = { + coin: 'ETH', + time: Date.now(), + levels: [[{ px: '3000', sz: '1.0', n: 1 }], []], + }; + + mockCachedPriceData.set('ETH', { + symbol: 'ETH', + price: '3000', + timestamp: Date.now(), + }); + + const params: ProcessL2BookDataParams = { + symbol: 'ETH', + data, + orderBookCache: mockOrderBookCache, + cachedPriceData: mockCachedPriceData, + createPriceUpdate: mockCreatePriceUpdate, + notifySubscribers: mockNotifySubscribers, + }; + + processL2BookData(params); + + const cacheEntry = mockOrderBookCache.get('ETH'); + expect(cacheEntry?.spread).toBeUndefined(); + }); + + it('updates cached price data with newly created price update', () => { + const existingPrice: PriceUpdate = { + symbol: 'BTC', + price: '50000', + timestamp: Date.now() - 1000, + isTradable: true, + }; + + const newPrice: PriceUpdate = { + symbol: 'BTC', + price: '50000', + timestamp: Date.now(), + isTradable: true, + }; + + mockCachedPriceData.set('BTC', existingPrice); + mockCreatePriceUpdate.mockReturnValue(newPrice); + + const data: L2BookResponse = { + coin: 'BTC', + time: Date.now(), + levels: [ + [{ px: '49900', sz: '1.5', n: 3 }], + [{ px: '50100', sz: '2.0', n: 5 }], + ], + }; + + const params: ProcessL2BookDataParams = { + symbol: 'BTC', + data, + orderBookCache: mockOrderBookCache, + cachedPriceData: mockCachedPriceData, + createPriceUpdate: mockCreatePriceUpdate, + notifySubscribers: mockNotifySubscribers, + }; + + processL2BookData(params); + + expect(mockCachedPriceData.get('BTC')).toEqual(newPrice); + expect(mockCachedPriceData.get('BTC')?.timestamp).toBeGreaterThan( + existingPrice.timestamp, + ); + }); + + it('updates order book cache with timestamp', () => { + const beforeTimestamp = Date.now(); + + const data: L2BookResponse = { + coin: 'SOL', + time: Date.now(), + levels: [ + [{ px: '100', sz: '10', n: 1 }], + [{ px: '101', sz: '10', n: 1 }], + ], + }; + + mockCachedPriceData.set('SOL', { + symbol: 'SOL', + price: '100', + timestamp: Date.now(), + }); + + const params: ProcessL2BookDataParams = { + symbol: 'SOL', + data, + orderBookCache: mockOrderBookCache, + cachedPriceData: mockCachedPriceData, + createPriceUpdate: mockCreatePriceUpdate, + notifySubscribers: mockNotifySubscribers, + }; + + processL2BookData(params); + + const cacheEntry = mockOrderBookCache.get('SOL'); + expect(cacheEntry?.lastUpdated).toBeGreaterThanOrEqual(beforeTimestamp); + expect(cacheEntry?.lastUpdated).toBeLessThanOrEqual(Date.now()); + }); + + it('calls notifySubscribers only when price data is updated', () => { + const data: L2BookResponse = { + coin: 'BTC', + time: Date.now(), + levels: [ + [{ px: '49900', sz: '1.5', n: 3 }], + [{ px: '50100', sz: '2.0', n: 5 }], + ], + }; + + mockCachedPriceData.set('BTC', { + symbol: 'BTC', + price: '50000', + timestamp: Date.now(), + }); + + const params: ProcessL2BookDataParams = { + symbol: 'BTC', + data, + orderBookCache: mockOrderBookCache, + cachedPriceData: mockCachedPriceData, + createPriceUpdate: mockCreatePriceUpdate, + notifySubscribers: mockNotifySubscribers, + }; + + processL2BookData(params); + + expect(mockNotifySubscribers).toHaveBeenCalledTimes(1); + }); + + it('does not call notifySubscribers when cached price data is null', () => { + const data: L2BookResponse = { + coin: 'BTC', + time: Date.now(), + levels: [ + [{ px: '49900', sz: '1.5', n: 3 }], + [{ px: '50100', sz: '2.0', n: 5 }], + ], + }; + + const params: ProcessL2BookDataParams = { + symbol: 'BTC', + data, + orderBookCache: mockOrderBookCache, + cachedPriceData: null, + createPriceUpdate: mockCreatePriceUpdate, + notifySubscribers: mockNotifySubscribers, + }; + + processL2BookData(params); + + expect(mockNotifySubscribers).not.toHaveBeenCalled(); + }); + + it('processes multiple symbols independently', () => { + mockCachedPriceData.set('BTC', { + symbol: 'BTC', + price: '50000', + timestamp: Date.now(), + }); + mockCachedPriceData.set('ETH', { + symbol: 'ETH', + price: '3000', + timestamp: Date.now(), + }); + + const btcData: L2BookResponse = { + coin: 'BTC', + time: Date.now(), + levels: [ + [{ px: '49900', sz: '1.5', n: 3 }], + [{ px: '50100', sz: '2.0', n: 5 }], + ], + }; + + const ethData: L2BookResponse = { + coin: 'ETH', + time: Date.now(), + levels: [ + [{ px: '2990', sz: '5.0', n: 2 }], + [{ px: '3010', sz: '5.0', n: 2 }], + ], + }; + + processL2BookData({ + symbol: 'BTC', + data: btcData, + orderBookCache: mockOrderBookCache, + cachedPriceData: mockCachedPriceData, + createPriceUpdate: mockCreatePriceUpdate, + notifySubscribers: mockNotifySubscribers, + }); + + processL2BookData({ + symbol: 'ETH', + data: ethData, + orderBookCache: mockOrderBookCache, + cachedPriceData: mockCachedPriceData, + createPriceUpdate: mockCreatePriceUpdate, + notifySubscribers: mockNotifySubscribers, + }); + + const btcCache = mockOrderBookCache.get('BTC'); + const ethCache = mockOrderBookCache.get('ETH'); + + expect(btcCache?.bestBid).toBe('49900'); + expect(btcCache?.bestAsk).toBe('50100'); + expect(ethCache?.bestBid).toBe('2990'); + expect(ethCache?.bestAsk).toBe('3010'); + expect(mockNotifySubscribers).toHaveBeenCalledTimes(2); + }); + }); + + describe('processBboData', () => { + it('processes valid BBO data with bid and ask', () => { + const symbol = 'BTC'; + const data: BboWsEvent = { + coin: 'BTC', + time: Date.now(), + bbo: [ + { px: '49900', sz: '1.5', n: 3 }, // Bid + { px: '50100', sz: '2.0', n: 5 }, // Ask + ], + }; + + mockCachedPriceData.set('BTC', { + symbol: 'BTC', + price: '50000', + timestamp: Date.now(), + }); + + const params: ProcessBboDataParams = { + symbol, + data, + orderBookCache: mockOrderBookCache, + cachedPriceData: mockCachedPriceData, + createPriceUpdate: mockCreatePriceUpdate, + notifySubscribers: mockNotifySubscribers, + }; + + processBboData(params); + + const cacheEntry = mockOrderBookCache.get('BTC'); + expect(cacheEntry).toBeDefined(); + expect(cacheEntry?.bestBid).toBe('49900'); + expect(cacheEntry?.bestAsk).toBe('50100'); + expect(cacheEntry?.spread).toBe('200.00000'); + expect(cacheEntry?.lastUpdated).toBeGreaterThan(0); + expect(mockCreatePriceUpdate).toHaveBeenCalledWith('BTC', '50000'); + expect(mockNotifySubscribers).toHaveBeenCalledTimes(1); + }); + + it('returns early when coin does not match symbol', () => { + const data: BboWsEvent = { + coin: 'ETH', + time: Date.now(), + bbo: [ + { px: '2990', sz: '5.0', n: 2 }, + { px: '3010', sz: '5.0', n: 2 }, + ], + }; + + const params: ProcessBboDataParams = { + symbol: 'BTC', + data, + orderBookCache: mockOrderBookCache, + cachedPriceData: mockCachedPriceData, + createPriceUpdate: mockCreatePriceUpdate, + notifySubscribers: mockNotifySubscribers, + }; + + processBboData(params); + + expect(mockOrderBookCache.size).toBe(0); + expect(mockCreatePriceUpdate).not.toHaveBeenCalled(); + expect(mockNotifySubscribers).not.toHaveBeenCalled(); + }); + + it('returns early when both bid and ask are missing', () => { + const data = { + coin: 'BTC', + time: Date.now(), + bbo: [undefined, undefined], + } as unknown as BboWsEvent; + + const params: ProcessBboDataParams = { + symbol: 'BTC', + data, + orderBookCache: mockOrderBookCache, + cachedPriceData: mockCachedPriceData, + createPriceUpdate: mockCreatePriceUpdate, + notifySubscribers: mockNotifySubscribers, + }; + + processBboData(params); + + expect(mockOrderBookCache.size).toBe(0); + expect(mockCreatePriceUpdate).not.toHaveBeenCalled(); + expect(mockNotifySubscribers).not.toHaveBeenCalled(); + }); + + it('returns early when bbo is a truthy non-array value', () => { + const data = { + coin: 'BTC', + time: Date.now(), + bbo: {}, + } as unknown as BboWsEvent; + + const params: ProcessBboDataParams = { + symbol: 'BTC', + data, + orderBookCache: mockOrderBookCache, + cachedPriceData: mockCachedPriceData, + createPriceUpdate: mockCreatePriceUpdate, + notifySubscribers: mockNotifySubscribers, + }; + + expect(() => processBboData(params)).not.toThrow(); + expect(mockOrderBookCache.size).toBe(0); + expect(mockCreatePriceUpdate).not.toHaveBeenCalled(); + expect(mockNotifySubscribers).not.toHaveBeenCalled(); + }); + + it('updates order book cache but does not notify when no cached price exists', () => { + const data: BboWsEvent = { + coin: 'BTC', + time: Date.now(), + bbo: [ + { px: '49900', sz: '1.5', n: 3 }, + { px: '50100', sz: '2.0', n: 5 }, + ], + }; + + const params: ProcessBboDataParams = { + symbol: 'BTC', + data, + orderBookCache: mockOrderBookCache, + cachedPriceData: mockCachedPriceData, + createPriceUpdate: mockCreatePriceUpdate, + notifySubscribers: mockNotifySubscribers, + }; + + processBboData(params); + + expect(mockOrderBookCache.get('BTC')).toBeDefined(); + expect(mockCreatePriceUpdate).not.toHaveBeenCalled(); + expect(mockNotifySubscribers).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/packages/perps-controller/tests/src/utils/hyperLiquidPositionPreview.test.ts b/packages/perps-controller/tests/src/utils/hyperLiquidPositionPreview.test.ts new file mode 100644 index 00000000000..ff832636b81 --- /dev/null +++ b/packages/perps-controller/tests/src/utils/hyperLiquidPositionPreview.test.ts @@ -0,0 +1,857 @@ +import type { + PositionModifyPreviewSource, + PositionPreviewValue, +} from '../../../src/types/index.js'; +import { + buildMaintenanceSchedule, + estimateIsolatedLiquidationPrice, + estimateIsolatedLiquidationPriceAtTier, + previewHyperLiquidIsolatedPositionModify, + resolveHyperLiquidMarginTiers, +} from '../../../src/utils/hyperLiquidPositionPreview.js'; + +const isolatedPosition = ( + overrides: Partial = {}, +): PositionModifyPreviewSource => ({ + symbol: 'ETH', + size: '1', + entryPrice: '2000', + positionValue: '2000', + marginUsed: '400', + leverage: { type: 'isolated', value: 5 }, + liquidationPrice: '1640', + maxLeverage: 25, + ...overrides, +}); + +const singleTier25x = [{ lowerBound: 0, maxLeverage: 25 }]; + +const availablePreviewValue = (preview: PositionPreviewValue): number => { + expect(preview.available).toBe(true); + if (!preview.available) { + throw new Error('Expected an available preview value'); + } + return preview.value; +}; + +/** Testnet ETH maintenance tiers. */ +const testnetEthTiers = [ + { lowerBound: 0, maxLeverage: 25 }, + { lowerBound: 20_000, maxLeverage: 10 }, + { lowerBound: 50_000, maxLeverage: 5 }, + { lowerBound: 200_000, maxLeverage: 3 }, +]; + +describe('resolveHyperLiquidMarginTiers', () => { + it('treats table ids below 50 as a single tier', () => { + expect( + resolveHyperLiquidMarginTiers({ + marginTableId: 25, + maxLeverage: 25, + marginTables: [], + }), + ).toStrictEqual([{ lowerBound: 0, maxLeverage: 25 }]); + }); + + it('returns the matching multi-tier table', () => { + expect( + resolveHyperLiquidMarginTiers({ + marginTableId: 50, + maxLeverage: 25, + marginTables: [ + [ + 50, + { + marginTiers: [ + { lowerBound: '0', maxLeverage: 25 }, + { lowerBound: '20000', maxLeverage: 10 }, + ], + }, + ], + ], + }), + ).toStrictEqual([ + { lowerBound: 0, maxLeverage: 25 }, + { lowerBound: 20_000, maxLeverage: 10 }, + ]); + }); + + it('returns null when the margin-table id is unknown', () => { + expect( + resolveHyperLiquidMarginTiers({ + maxLeverage: 25, + marginTables: [], + }), + ).toBeNull(); + }); + + it('returns null when a multi-tier table is required but missing', () => { + expect( + resolveHyperLiquidMarginTiers({ + marginTableId: 50, + maxLeverage: 25, + marginTables: [], + }), + ).toBeNull(); + }); +}); + +describe('buildMaintenanceSchedule', () => { + it('applies the HyperLiquid maintenance deduction at each tier', () => { + const schedule = buildMaintenanceSchedule(testnetEthTiers); + + expect(schedule[0]).toMatchObject({ + lowerBound: 0, + upperBound: 20_000, + maintenanceMarginRate: 1 / 50, + maintenanceDeduction: 0, + }); + expect(schedule[1].maintenanceMarginRate).toBeCloseTo(1 / 20); + expect(schedule[1].maintenanceDeduction).toBeCloseTo( + 20_000 * (1 / 20 - 1 / 50), + ); + }); +}); + +describe('estimateIsolatedLiquidationPrice', () => { + it('matches the single-tier closed form for a long', () => { + const liq = estimateIsolatedLiquidationPrice({ + isLong: true, + markPrice: 2000, + margin: 400, + positionSize: 1, + maintenanceMarginRate: 1 / 50, + }); + + expect(liq).toBeCloseTo((2000 - 400) / (1 - 1 / 50)); + }); + + it('matches the single-tier closed form for a short', () => { + const liq = estimateIsolatedLiquidationPrice({ + isLong: false, + markPrice: 2000, + margin: 400, + positionSize: 1, + maintenanceMarginRate: 1 / 50, + }); + + expect(liq).toBeCloseTo((2000 + 400) / (1 + 1 / 50)); + }); +}); + +describe('previewHyperLiquidIsolatedPositionModify', () => { + it('returns unsupported for cross-margin positions', () => { + const result = previewHyperLiquidIsolatedPositionModify({ + position: isolatedPosition({ + leverage: { type: 'cross', value: 5 }, + }), + direction: 'long', + size: '0.5', + price: '2000', + leverage: 5, + marginTiers: singleTier25x, + }); + + expect(result).toStrictEqual({ + status: 'unsupported', + reason: 'cross_margin', + }); + }); + + it('returns none when there is no order size', () => { + const result = previewHyperLiquidIsolatedPositionModify({ + position: isolatedPosition(), + direction: 'long', + size: '0', + price: '2000', + leverage: 5, + marginTiers: singleTier25x, + }); + + expect(result.status).toBe('none'); + }); + + it('projects an isolated increase at the current leverage', () => { + const result = previewHyperLiquidIsolatedPositionModify({ + position: isolatedPosition(), + direction: 'long', + size: '0.5', + price: '2000', + leverage: 5, + marginTiers: singleTier25x, + }); + + expect(result.status).toBe('open'); + if (result.status !== 'open') { + return; + } + expect(result.kind).toBe('increase'); + expect(result.resulting.direction).toBe('long'); + expect(result.resulting.size).toBeCloseTo(1.5); + expect(result.resulting.entryPrice).toBeCloseTo(2000); + expect(result.resulting.margin).toStrictEqual({ + available: true, + value: 600, + }); + expect(result.resulting.leverage).toBeCloseTo(5); + }); + + it('deducts fees from isolated margin on an increase', () => { + const result = previewHyperLiquidIsolatedPositionModify({ + position: isolatedPosition(), + direction: 'long', + size: '0.5', + price: '2000', + leverage: 5, + feeAmountUsd: 2, + marginTiers: singleTier25x, + }); + + expect(result.status).toBe('open'); + if (result.status !== 'open') { + return; + } + expect(result.resulting.margin).toStrictEqual({ + available: true, + value: 598, + }); + }); + + it('reallocates the existing isolated position when order leverage differs', () => { + const result = previewHyperLiquidIsolatedPositionModify({ + position: isolatedPosition(), + direction: 'long', + size: '0.5', + price: '2000', + leverage: 10, + marginTiers: singleTier25x, + }); + + expect(result.status).toBe('open'); + if (result.status !== 'open') { + return; + } + expect(result.kind).toBe('increase'); + // Existing 5x $400 is reset to $200 at 10x, then $100 is added for the order. + expect(result.resulting.margin).toStrictEqual({ + available: true, + value: 300, + }); + expect(result.resulting.leverage).toBeCloseTo(10); + const liquidationPrice = availablePreviewValue( + result.resulting.liquidationPrice, + ); + const overstatedMarginLiq = estimateIsolatedLiquidationPrice({ + isLong: true, + markPrice: 2000, + margin: 500, + positionSize: 1.5, + maintenanceMarginRate: 1 / 50, + }); + expect(overstatedMarginLiq).not.toBeNull(); + expect(liquidationPrice).toBeGreaterThan(overstatedMarginLiq ?? 0); + }); + + it('reports mark-based leverage when entry differs from mark after a leverage change', () => { + const result = previewHyperLiquidIsolatedPositionModify({ + position: isolatedPosition({ + entryPrice: '2000', + positionValue: '2500', + marginUsed: '500', + }), + direction: 'long', + size: '0.5', + price: '2500', + leverage: 10, + marginTiers: singleTier25x, + }); + + expect(result.status).toBe('open'); + if (result.status !== 'open') { + return; + } + expect(result.resulting.margin).toStrictEqual({ + available: true, + value: 375, + }); + expect(result.resulting.leverage).toBeCloseTo(10); + expect(result.resulting.entryPrice).toBeCloseTo(2166.6666667); + // Mark-based liq: (2500 - 375/1.5) / (1 - 1/50) = 2295.918... + // Entry-based liq would be ~1955.78 and is wrong for TP/SL. + expect( + availablePreviewValue(result.resulting.liquidationPrice), + ).toBeCloseTo(2295.9183673469); + }); + + it('projects a partial decrease using the remaining position direction', () => { + const result = previewHyperLiquidIsolatedPositionModify({ + position: isolatedPosition(), + direction: 'short', + size: '0.4', + price: '2000', + leverage: 5, + reduceOnly: true, + marginTiers: singleTier25x, + }); + + expect(result.status).toBe('open'); + if (result.status !== 'open') { + return; + } + expect(result.kind).toBe('decrease'); + expect(result.resulting.direction).toBe('long'); + expect(result.resulting.size).toBeCloseTo(0.6); + expect(result.resulting.entryPrice).toBeCloseTo(2000); + expect(result.resulting.margin).toStrictEqual({ + available: true, + value: 240, + }); + }); + + it('marks a partial decrease at the expected fill, not live mark', () => { + const result = previewHyperLiquidIsolatedPositionModify({ + position: isolatedPosition(), + direction: 'short', + size: '0.4', + price: '1800', + leverage: 5, + reduceOnly: true, + marginTiers: singleTier25x, + }); + + expect(result.status).toBe('open'); + if (result.status !== 'open') { + return; + } + expect(result.kind).toBe('decrease'); + expect(result.resulting.size).toBeCloseTo(0.6); + expect(result.resulting.margin).toStrictEqual({ + available: true, + value: 240, + }); + // Remaining 0.6 marked at 1800 → notional 1080 / margin 240 = 4.5x. + expect(result.resulting.leverage).toBeCloseTo(4.5); + // (1800 - 240/0.6) / (1 - 1/50) = 1428.571... + expect( + availablePreviewValue(result.resulting.liquidationPrice), + ).toBeCloseTo(1428.5714285714); + }); + + it('reallocates before a partial decrease when leverage changes', () => { + const result = previewHyperLiquidIsolatedPositionModify({ + position: isolatedPosition(), + direction: 'short', + size: '0.4', + price: '2000', + leverage: 10, + marginTiers: singleTier25x, + }); + + expect(result.status).toBe('open'); + if (result.status !== 'open') { + return; + } + expect(result.resulting.margin).toStrictEqual({ + available: true, + value: 120, + }); + expect(result.resulting.direction).toBe('long'); + }); + + it('projects a flip leftover at the selected leverage and order direction', () => { + const result = previewHyperLiquidIsolatedPositionModify({ + position: isolatedPosition(), + direction: 'short', + size: '1.5', + price: '2000', + leverage: 10, + marginTiers: singleTier25x, + }); + + expect(result.status).toBe('open'); + if (result.status !== 'open') { + return; + } + expect(result.kind).toBe('flip'); + expect(result.resulting.direction).toBe('short'); + expect(result.resulting.size).toBeCloseTo(0.5); + expect(result.resulting.entryPrice).toBeCloseTo(2000); + expect(result.resulting.margin).toStrictEqual({ + available: true, + value: 100, + }); + }); + + it('returns full_close without a remaining size', () => { + const result = previewHyperLiquidIsolatedPositionModify({ + position: isolatedPosition(), + direction: 'short', + size: '1', + price: '2000', + leverage: 5, + reduceOnly: true, + marginTiers: singleTier25x, + }); + + expect(result).toStrictEqual({ + status: 'full_close', + current: { + margin: { available: true, value: 400 }, + liquidationPrice: { available: true, value: 1640 }, + }, + resultingDirection: 'long', + }); + }); + + it('treats a reduce-only overshoot as a full close rather than a flip', () => { + const result = previewHyperLiquidIsolatedPositionModify({ + position: isolatedPosition(), + direction: 'short', + size: '2', + price: '2000', + leverage: 5, + reduceOnly: true, + marginTiers: singleTier25x, + }); + + expect(result.status).toBe('full_close'); + }); + + it('keeps margin available when the live liquidation price is missing', () => { + const result = previewHyperLiquidIsolatedPositionModify({ + position: isolatedPosition({ liquidationPrice: null }), + direction: 'long', + size: '0.5', + price: '2000', + leverage: 5, + marginTiers: singleTier25x, + }); + + expect(result.status).toBe('open'); + if (result.status !== 'open') { + return; + } + expect(result.current.liquidationPrice).toStrictEqual({ + available: false, + }); + expect(result.current.margin).toStrictEqual({ + available: true, + value: 400, + }); + expect(result.resulting.margin.available).toBe(true); + expect(result.resulting.liquidationPrice.available).toBe(true); + }); + + it('withholds liquidation and keeps margin when tier data is missing', () => { + const result = previewHyperLiquidIsolatedPositionModify({ + position: isolatedPosition(), + direction: 'long', + size: '0.5', + price: '2000', + leverage: 5, + marginTiers: null, + }); + + expect(result.status).toBe('open'); + if (result.status !== 'open') { + return; + } + expect(result.resulting.margin).toStrictEqual({ + available: true, + value: 600, + }); + expect(result.resulting.liquidationPrice).toStrictEqual({ + available: false, + }); + }); + + it('uses the maintenance tier at liquidation notional, including the deduction', () => { + const result = previewHyperLiquidIsolatedPositionModify({ + position: isolatedPosition({ + size: '20', + entryPrice: '2500', + positionValue: '50000', + marginUsed: '5000', + leverage: { type: 'isolated', value: 10 }, + liquidationPrice: '2200', + }), + direction: 'long', + size: '0.0001', + price: '2500', + leverage: 10, + marginTiers: testnetEthTiers, + }); + + expect(result.status).toBe('open'); + if (result.status !== 'open') { + return; + } + + const expected = estimateIsolatedLiquidationPriceAtTier({ + isLong: true, + markPrice: 2500, + margin: result.resulting.margin.available + ? result.resulting.margin.value + : 0, + positionSize: result.resulting.size, + marginTiers: testnetEthTiers, + }); + const singleTier = estimateIsolatedLiquidationPrice({ + isLong: true, + markPrice: 2500, + margin: result.resulting.margin.available + ? result.resulting.margin.value + : 0, + positionSize: result.resulting.size, + maintenanceMarginRate: 1 / 50, + }); + + expect(result.resulting.liquidationPrice.available).toBe(true); + const liquidationPrice = availablePreviewValue( + result.resulting.liquidationPrice, + ); + expect(expected).not.toBeNull(); + expect(singleTier).not.toBeNull(); + expect(liquidationPrice).toBeCloseTo(expected ?? 0); + expect(liquidationPrice).toBeGreaterThan(singleTier ?? 0); + }); + + it('averages entry and posts order margin at a limit price away from entry', () => { + const result = previewHyperLiquidIsolatedPositionModify({ + position: isolatedPosition(), + direction: 'long', + size: '1', + price: '1800', + leverage: 5, + marginTiers: singleTier25x, + }); + + expect(result.status).toBe('open'); + if (result.status !== 'open') { + return; + } + expect(result.kind).toBe('increase'); + expect(result.resulting.size).toBeCloseTo(2); + expect(result.resulting.entryPrice).toBeCloseTo(1900); + // Existing $400 at 5x plus 1 * 1800 / 5 = $360. + expect(result.resulting.margin).toStrictEqual({ + available: true, + value: 760, + }); + // After fill the whole position is marked at 1800, not live mark plus fill. + expect(result.resulting.leverage).toBeCloseTo(3600 / 760); + }); + + it('does not project an increase or flip when the fill price is missing', () => { + const increase = previewHyperLiquidIsolatedPositionModify({ + position: isolatedPosition(), + direction: 'long', + size: '0.5', + price: '0', + leverage: 5, + marginTiers: singleTier25x, + }); + const flip = previewHyperLiquidIsolatedPositionModify({ + position: isolatedPosition(), + direction: 'short', + size: '1.5', + price: '', + leverage: 5, + marginTiers: singleTier25x, + }); + + expect(increase.status).toBe('none'); + expect(flip.status).toBe('none'); + }); + + it('still projects a reduce when the fill price is missing', () => { + const result = previewHyperLiquidIsolatedPositionModify({ + position: isolatedPosition(), + direction: 'short', + size: '0.4', + price: '0', + leverage: 5, + marginTiers: singleTier25x, + }); + + expect(result.status).toBe('open'); + if (result.status !== 'open') { + return; + } + expect(result.kind).toBe('decrease'); + expect(result.resulting.direction).toBe('long'); + }); + + it('does not treat a same-direction reduce-only order as a decrease', () => { + const result = previewHyperLiquidIsolatedPositionModify({ + position: isolatedPosition(), + direction: 'long', + size: '0.4', + price: '2000', + leverage: 5, + reduceOnly: true, + marginTiers: singleTier25x, + }); + + expect(result.status).toBe('none'); + }); + + it('keeps extra isolated margin when leverage is unchanged', () => { + const result = previewHyperLiquidIsolatedPositionModify({ + position: isolatedPosition({ marginUsed: '800' }), + direction: 'long', + size: '0.5', + price: '2000', + leverage: 5, + marginTiers: singleTier25x, + }); + + expect(result.status).toBe('open'); + if (result.status !== 'open') { + return; + } + expect(result.resulting.margin).toStrictEqual({ + available: true, + value: 1000, + }); + }); + + it('strips extra isolated margin when leverage increases', () => { + const result = previewHyperLiquidIsolatedPositionModify({ + position: isolatedPosition({ marginUsed: '800' }), + direction: 'long', + size: '0.5', + price: '2000', + leverage: 10, + marginTiers: singleTier25x, + }); + + expect(result.status).toBe('open'); + if (result.status !== 'open') { + return; + } + expect(result.resulting.margin).toStrictEqual({ + available: true, + value: 300, + }); + }); + + it('adds isolated margin when selected leverage is lower than the position', () => { + const result = previewHyperLiquidIsolatedPositionModify({ + position: isolatedPosition({ + leverage: { type: 'isolated', value: 10 }, + marginUsed: '200', + }), + direction: 'long', + size: '0.5', + price: '2000', + leverage: 5, + marginTiers: singleTier25x, + }); + + expect(result.status).toBe('open'); + if (result.status !== 'open') { + return; + } + // Existing $2000 / 5 = $400, plus 0.5 * 2000 / 5 = $200. + expect(result.resulting.margin).toStrictEqual({ + available: true, + value: 600, + }); + expect(result.resulting.leverage).toBeCloseTo(5); + }); + + it('projects a short increase, keeping liquidation above entry', () => { + const result = previewHyperLiquidIsolatedPositionModify({ + position: isolatedPosition({ + size: '-1', + liquidationPrice: '2360', + }), + direction: 'short', + size: '0.5', + price: '2000', + leverage: 5, + marginTiers: singleTier25x, + }); + + expect(result.status).toBe('open'); + if (result.status !== 'open') { + return; + } + expect(result.kind).toBe('increase'); + expect(result.resulting.direction).toBe('short'); + expect(result.resulting.size).toBeCloseTo(1.5); + expect(result.resulting.margin).toStrictEqual({ + available: true, + value: 600, + }); + expect( + availablePreviewValue(result.resulting.liquidationPrice), + ).toBeGreaterThan(2000); + }); + + it('reallocates a short when increasing at higher leverage', () => { + const result = previewHyperLiquidIsolatedPositionModify({ + position: isolatedPosition({ + size: '-1', + liquidationPrice: '2360', + }), + direction: 'short', + size: '0.5', + price: '2000', + leverage: 10, + marginTiers: singleTier25x, + }); + + expect(result.status).toBe('open'); + if (result.status !== 'open') { + return; + } + expect(result.resulting.margin).toStrictEqual({ + available: true, + value: 300, + }); + expect(result.resulting.direction).toBe('short'); + }); + + it('averages a short increase at a limit above entry', () => { + const result = previewHyperLiquidIsolatedPositionModify({ + position: isolatedPosition({ + size: '-1', + liquidationPrice: '2360', + }), + direction: 'short', + size: '1', + price: '2200', + leverage: 5, + marginTiers: singleTier25x, + }); + + expect(result.status).toBe('open'); + if (result.status !== 'open') { + return; + } + expect(result.resulting.entryPrice).toBeCloseTo(2100); + expect(result.resulting.margin).toStrictEqual({ + available: true, + value: 840, + }); + }); + + it('projects a partial cover of a short using the remaining short direction', () => { + const result = previewHyperLiquidIsolatedPositionModify({ + position: isolatedPosition({ + size: '-1', + liquidationPrice: '2360', + }), + direction: 'long', + size: '0.4', + price: '2000', + leverage: 5, + reduceOnly: true, + marginTiers: singleTier25x, + }); + + expect(result.status).toBe('open'); + if (result.status !== 'open') { + return; + } + expect(result.kind).toBe('decrease'); + expect(result.resulting.direction).toBe('short'); + expect(result.resulting.size).toBeCloseTo(0.6); + expect(result.resulting.margin).toStrictEqual({ + available: true, + value: 240, + }); + }); + + it('flips a short leftover into a long at the fill price', () => { + const result = previewHyperLiquidIsolatedPositionModify({ + position: isolatedPosition({ + size: '-1', + liquidationPrice: '2360', + }), + direction: 'long', + size: '1.5', + price: '1900', + leverage: 10, + marginTiers: singleTier25x, + }); + + expect(result.status).toBe('open'); + if (result.status !== 'open') { + return; + } + expect(result.kind).toBe('flip'); + expect(result.resulting.direction).toBe('long'); + expect(result.resulting.size).toBeCloseTo(0.5); + expect(result.resulting.entryPrice).toBeCloseTo(1900); + expect(result.resulting.margin).toStrictEqual({ + available: true, + value: 95, + }); + expect( + availablePreviewValue(result.resulting.liquidationPrice), + ).toBeLessThan(1900); + }); + + it('fully closes a short without a remaining size', () => { + const result = previewHyperLiquidIsolatedPositionModify({ + position: isolatedPosition({ + size: '-1', + liquidationPrice: '2360', + }), + direction: 'long', + size: '1', + price: '2000', + leverage: 5, + reduceOnly: true, + marginTiers: singleTier25x, + }); + + expect(result).toMatchObject({ + status: 'full_close', + resultingDirection: 'short', + }); + }); + + it('flips a long leftover into a short at a limit away from entry', () => { + const result = previewHyperLiquidIsolatedPositionModify({ + position: isolatedPosition(), + direction: 'short', + size: '1.5', + price: '1800', + leverage: 10, + marginTiers: singleTier25x, + }); + + expect(result.status).toBe('open'); + if (result.status !== 'open') { + return; + } + expect(result.kind).toBe('flip'); + expect(result.resulting.direction).toBe('short'); + expect(result.resulting.size).toBeCloseTo(0.5); + expect(result.resulting.entryPrice).toBeCloseTo(1800); + expect(result.resulting.margin).toStrictEqual({ + available: true, + value: 90, + }); + expect( + availablePreviewValue(result.resulting.liquidationPrice), + ).toBeGreaterThan(1800); + }); + + it('returns none for a negative order size', () => { + const result = previewHyperLiquidIsolatedPositionModify({ + position: isolatedPosition(), + direction: 'long', + size: '-0.5', + price: '2000', + leverage: 5, + marginTiers: singleTier25x, + }); + + expect(result.status).toBe('none'); + }); +}); diff --git a/packages/perps-controller/tests/src/utils/hyperLiquidValidation.advanced-orders.test.ts b/packages/perps-controller/tests/src/utils/hyperLiquidValidation.advanced-orders.test.ts new file mode 100644 index 00000000000..6a21bf5318b --- /dev/null +++ b/packages/perps-controller/tests/src/utils/hyperLiquidValidation.advanced-orders.test.ts @@ -0,0 +1,484 @@ +import { HYPERLIQUID_ORDER_LIMITS } from '../../../src/constants/perpsConfig.js'; +import { PERPS_ERROR_CODES } from '../../../src/perpsErrorCodes.js'; +import type { OrderType, TpslLinkage } from '../../../src/types/perps-types.js'; +import { + getMaxOrderValue, + validateOrderParams, +} from '../../../src/utils/hyperLiquidValidation.js'; +import { isTriggerOrderType } from '../../../src/utils/orderTypes.js'; + +describe('hyperLiquidValidation - advanced order types', () => { + describe('validateOrderParams', () => { + it.each([ + 'stop_market', + 'stop_limit', + 'take_profit_market', + 'take_profit_limit', + ] as OrderType[])('requires a trigger price for %s', (orderType) => { + expect( + validateOrderParams({ + coin: 'BTC', + size: '0.1', + price: '50000', + orderType, + }), + ).toStrictEqual({ + isValid: false, + error: PERPS_ERROR_CODES.ORDER_TRIGGER_PRICE_REQUIRED, + }); + }); + + it.each(['0', '-1', 'abc'])( + 'rejects a non-positive trigger price (%s)', + (triggerPrice) => { + expect( + validateOrderParams({ + coin: 'BTC', + size: '0.1', + orderType: 'stop_market', + triggerPrice, + }), + ).toStrictEqual({ + isValid: false, + error: PERPS_ERROR_CODES.ORDER_TRIGGER_PRICE_POSITIVE, + }); + }, + ); + + it.each(['stop_limit', 'take_profit_limit'] as OrderType[])( + 'requires a limit price for %s', + (orderType) => { + expect( + validateOrderParams({ + coin: 'BTC', + size: '0.1', + orderType, + triggerPrice: '45000', + }), + ).toStrictEqual({ + isValid: false, + error: PERPS_ERROR_CODES.ORDER_LIMIT_PRICE_REQUIRED, + }); + }, + ); + + it.each(['stop_market', 'take_profit_market'] as OrderType[])( + 'accepts %s without a limit price', + (orderType) => { + expect( + validateOrderParams({ + coin: 'BTC', + size: '0.1', + orderType, + triggerPrice: '45000', + }), + ).toStrictEqual({ isValid: true }); + }, + ); + + it.each(['market', 'limit'] as OrderType[])( + 'rejects a trigger price on %s instead of ignoring it', + (orderType) => { + expect( + validateOrderParams({ + coin: 'BTC', + size: '0.1', + price: '50000', + orderType, + triggerPrice: '45000', + }), + ).toStrictEqual({ + isValid: false, + error: PERPS_ERROR_CODES.ORDER_TRIGGER_PRICE_NOT_SUPPORTED, + }); + }, + ); + + it('rejects a falsy-but-present trigger price on a market order', () => { + expect( + validateOrderParams({ + coin: 'BTC', + size: '0.1', + orderType: 'market', + triggerPrice: '', + }), + ).toStrictEqual({ + isValid: false, + error: PERPS_ERROR_CODES.ORDER_TRIGGER_PRICE_NOT_SUPPORTED, + }); + }); + + it('rejects attached TP/SL on a trigger placement', () => { + expect( + validateOrderParams({ + coin: 'BTC', + size: '0.1', + orderType: 'stop_market', + triggerPrice: '45000', + takeProfitPrice: '60000', + }), + ).toStrictEqual({ + isValid: false, + error: PERPS_ERROR_CODES.ORDER_TRIGGER_TPSL_UNSUPPORTED, + }); + + expect( + validateOrderParams({ + coin: 'BTC', + size: '0.1', + orderType: 'take_profit_market', + triggerPrice: '60000', + stopLossPrice: '45000', + }), + ).toStrictEqual({ + isValid: false, + error: PERPS_ERROR_CODES.ORDER_TRIGGER_TPSL_UNSUPPORTED, + }); + }); + + it('keeps market and limit orders valid without trigger fields', () => { + expect( + validateOrderParams({ + coin: 'BTC', + size: '0.1', + orderType: 'market', + }), + ).toStrictEqual({ isValid: true }); + + expect( + validateOrderParams({ + coin: 'BTC', + size: '0.1', + price: '50000', + orderType: 'limit', + }), + ).toStrictEqual({ isValid: true }); + }); + + describe('partial TP/SL sizes', () => { + it('accepts a partial size smaller than the order size', () => { + expect( + validateOrderParams({ + coin: 'BTC', + size: '1', + orderType: 'market', + takeProfitPrice: '60000', + takeProfitSize: '0.4', + stopLossPrice: '45000', + stopLossSize: '0.6', + }), + ).toStrictEqual({ isValid: true }); + }); + + it('accepts a partial size equal to the order size', () => { + expect( + validateOrderParams({ + coin: 'BTC', + size: '1', + orderType: 'market', + takeProfitPrice: '60000', + takeProfitSize: '1', + }), + ).toStrictEqual({ isValid: true }); + }); + + it('rejects a take profit size without a take profit price', () => { + expect( + validateOrderParams({ + coin: 'BTC', + size: '1', + orderType: 'market', + takeProfitSize: '0.4', + }), + ).toStrictEqual({ + isValid: false, + error: PERPS_ERROR_CODES.ORDER_TPSL_SIZE_INVALID, + }); + }); + + it('rejects a stop loss size without a stop loss price', () => { + expect( + validateOrderParams({ + coin: 'BTC', + size: '1', + orderType: 'market', + stopLossSize: '0.4', + }), + ).toStrictEqual({ + isValid: false, + error: PERPS_ERROR_CODES.ORDER_TPSL_SIZE_INVALID, + }); + }); + + it.each(['0', '-0.1', 'abc'])( + 'rejects a non-positive partial size (%s)', + (takeProfitSize) => { + expect( + validateOrderParams({ + coin: 'BTC', + size: '1', + orderType: 'market', + takeProfitPrice: '60000', + takeProfitSize, + }), + ).toStrictEqual({ + isValid: false, + error: PERPS_ERROR_CODES.ORDER_TPSL_SIZE_INVALID, + }); + }, + ); + + it('rejects a partial size larger than the order size', () => { + expect( + validateOrderParams({ + coin: 'BTC', + size: '1', + orderType: 'market', + stopLossPrice: '45000', + stopLossSize: '1.5', + }), + ).toStrictEqual({ + isValid: false, + error: PERPS_ERROR_CODES.ORDER_TPSL_SIZE_INVALID, + }); + }); + + it('compares against the absolute order size for short orders', () => { + expect( + validateOrderParams({ + coin: 'BTC', + size: '-1', + orderType: 'market', + stopLossPrice: '55000', + stopLossSize: '0.5', + }), + ).toStrictEqual({ isValid: true }); + }); + + it('skips the upper bound when no order size is known', () => { + expect( + validateOrderParams({ + coin: 'BTC', + orderType: 'market', + takeProfitPrice: '60000', + takeProfitSize: '99', + }), + ).toStrictEqual({ isValid: true }); + }); + }); + }); + + describe('TP/SL linkage', () => { + it('accepts the provider-agnostic linkage on its own', () => { + expect( + validateOrderParams({ + coin: 'BTC', + size: '1', + orderType: 'market', + takeProfitPrice: '60000', + tpslLinkage: 'order', + }), + ).toStrictEqual({ isValid: true }); + }); + + it('accepts the deprecated grouping on its own', () => { + expect( + validateOrderParams({ + coin: 'BTC', + size: '1', + orderType: 'market', + takeProfitPrice: '60000', + grouping: 'normalTpsl', + }), + ).toStrictEqual({ isValid: true }); + }); + + it.each([ + { tpslLinkage: 'position' as TpslLinkage }, + { grouping: 'positionTpsl' as const }, + ])( + 'rejects position linkage on an order that carries its own TP/SL (%o)', + (linkage) => { + // A positionTpsl batch may only contain trigger orders, but the parent + // being placed is an ordinary market/limit order — HyperLiquid rejects + // the whole batch, so the combination is refused up front. + expect( + validateOrderParams({ + coin: 'BTC', + size: '1', + orderType: 'market', + takeProfitPrice: '60000', + ...linkage, + }), + ).toStrictEqual({ + isValid: false, + error: PERPS_ERROR_CODES.ORDER_TPSL_POSITION_LINKAGE_UNSUPPORTED, + }); + }, + ); + + it.each([ + { tpslLinkage: 'none' as TpslLinkage }, + { grouping: 'na' as const }, + ])( + 'rejects an order that carries its own TP/SL with no linkage (%o)', + (linkage) => { + // 'na' grouping submits the TP/SL as standalone triggers tied to + // neither the parent order nor the resulting position, so they outlive + // an unfilled parent as orphan reduce-only triggers. + expect( + validateOrderParams({ + coin: 'BTC', + size: '1', + orderType: 'market', + takeProfitPrice: '60000', + ...linkage, + }), + ).toStrictEqual({ + isValid: false, + error: PERPS_ERROR_CODES.ORDER_TPSL_LINKAGE_REQUIRED, + }); + }, + ); + + it('accepts no linkage when no TP/SL is attached to the order', () => { + expect( + validateOrderParams({ + coin: 'BTC', + size: '1', + orderType: 'market', + tpslLinkage: 'none', + }), + ).toStrictEqual({ isValid: true }); + }); + + it.each([ + { tpslLinkage: 'position' as TpslLinkage }, + { grouping: 'positionTpsl' as const }, + { + tpslLinkage: 'position' as TpslLinkage, + grouping: 'positionTpsl' as const, + }, + ])('rejects position linkage with nothing to link (%o)', (linkage) => { + // Without an attached TP/SL the batch is just the ordinary parent order + // carrying `positionTpsl` grouping, which HyperLiquid rejects for the + // same reason as the attached case: every order in that batch must be a + // trigger. There is no shape of `placeOrder` request the linkage works + // on, so it is refused outright. + expect( + validateOrderParams({ + coin: 'BTC', + size: '1', + orderType: 'market', + ...linkage, + }), + ).toStrictEqual({ + isValid: false, + error: PERPS_ERROR_CODES.ORDER_TPSL_POSITION_LINKAGE_UNSUPPORTED, + }); + }); + + // 'position'/'positionTpsl' is covered by the rejection cases below: the + // spellings agree, but the linkage itself is unsupported on this path. + it.each([ + ['none', 'na'], + ['order', 'normalTpsl'], + ] as [TpslLinkage, 'na' | 'normalTpsl' | 'positionTpsl'][])( + 'accepts %s alongside the equivalent grouping %s', + (tpslLinkage, grouping) => { + expect( + validateOrderParams({ + coin: 'BTC', + size: '1', + orderType: 'market', + tpslLinkage, + grouping, + }), + ).toStrictEqual({ isValid: true }); + }, + ); + + it('rejects a linkage that disagrees with the deprecated grouping', () => { + expect( + validateOrderParams({ + coin: 'BTC', + size: '1', + orderType: 'market', + tpslLinkage: 'position', + grouping: 'normalTpsl', + }), + ).toStrictEqual({ + isValid: false, + error: PERPS_ERROR_CODES.ORDER_TPSL_LINKAGE_CONFLICT, + }); + }); + }); + + describe('time in force', () => { + it('accepts a time in force on a plain limit order', () => { + expect( + validateOrderParams({ + coin: 'BTC', + size: '1', + price: '50000', + orderType: 'limit', + timeInForce: 'ALO', + }), + ).toStrictEqual({ isValid: true }); + }); + + it.each([ + 'market', + 'stop_market', + 'stop_limit', + 'take_profit_market', + 'take_profit_limit', + ] as OrderType[])('rejects a time in force on %s', (orderType) => { + expect( + validateOrderParams({ + coin: 'BTC', + size: '1', + price: '50000', + orderType, + triggerPrice: isTriggerOrderType(orderType) ? '45000' : undefined, + timeInForce: 'IOC', + }), + ).toStrictEqual({ + isValid: false, + error: PERPS_ERROR_CODES.ORDER_TIME_IN_FORCE_NOT_SUPPORTED, + }); + }); + + it('leaves orders without a time in force untouched', () => { + expect( + validateOrderParams({ + coin: 'BTC', + size: '1', + orderType: 'market', + }), + ).toStrictEqual({ isValid: true }); + }); + }); + + describe('getMaxOrderValue', () => { + it('applies the limit multiplier to limit-executing trigger types', () => { + const marketLimit = getMaxOrderValue(50, 'market'); + + expect(getMaxOrderValue(50, 'stop_limit')).toBe( + marketLimit * HYPERLIQUID_ORDER_LIMITS.LimitOrderMultiplier, + ); + expect(getMaxOrderValue(50, 'take_profit_limit')).toBe( + getMaxOrderValue(50, 'limit'), + ); + }); + + it('treats market-executing trigger types as market orders', () => { + expect(getMaxOrderValue(50, 'stop_market')).toBe( + getMaxOrderValue(50, 'market'), + ); + expect(getMaxOrderValue(50, 'take_profit_market')).toBe( + getMaxOrderValue(50, 'market'), + ); + }); + }); +}); diff --git a/packages/perps-controller/tests/src/utils/hyperLiquidValidation.strategy-orders.test.ts b/packages/perps-controller/tests/src/utils/hyperLiquidValidation.strategy-orders.test.ts new file mode 100644 index 00000000000..13731a6ec98 --- /dev/null +++ b/packages/perps-controller/tests/src/utils/hyperLiquidValidation.strategy-orders.test.ts @@ -0,0 +1,520 @@ +import { PERPS_EVENT_VALUE } from '../../../src/constants/eventNames.js'; +import { + CHASE_ORDER_CONFIG, + HYPERLIQUID_ORDER_LIMITS, + HYPERLIQUID_TWAP_LIMITS, +} from '../../../src/constants/perpsConfig.js'; +import { PERPS_ERROR_CODES } from '../../../src/perpsErrorCodes.js'; +import type { + OrderType, + StrategyOrderType, +} from '../../../src/types/perps-types.js'; +import { + getMaxOrderValue, + validateOrderParams, +} from '../../../src/utils/hyperLiquidValidation.js'; +import { + getTriggerExecution, + isStrategyOrderType, + isTriggerOrderType, + STRATEGY_ORDER_TYPES, +} from '../../../src/utils/orderTypes.js'; + +/** The smallest params that make each strategy valid on its own. */ +const VALID_STRATEGY_PARAMS: Record< + StrategyOrderType, + Record +> = { + twap: { twapDuration: 30 }, + scale: { + scaleMinPrice: '2000', + scaleMaxPrice: '3000', + scaleNumOrders: 3, + }, + chase: {}, +}; + +describe('hyperLiquidValidation - strategy order types', () => { + describe('order type classification', () => { + it.each(STRATEGY_ORDER_TYPES)( + 'classifies %s as a strategy', + (orderType) => { + expect(isStrategyOrderType(orderType)).toBe(true); + }, + ); + + it.each([ + 'market', + 'limit', + 'stop_market', + 'stop_limit', + 'take_profit_market', + 'take_profit_limit', + ] as OrderType[])('does not classify %s as a strategy', (orderType) => { + expect(isStrategyOrderType(orderType)).toBe(false); + }); + + // TriggerOrderType used to be Exclude, which + // would have swallowed the strategy types and demanded a trigger price from + // them. + it.each(STRATEGY_ORDER_TYPES)( + 'does not classify %s as a trigger placement', + (orderType) => { + expect(isTriggerOrderType(orderType)).toBe(false); + }, + ); + + it.each([0.5, 9999.5])( + 'accepts a fractional %s bps max distance below the upper boundary', + (chaseMaxDistanceBps) => { + expect( + validateOrderParams({ + coin: 'ETH', + size: '1', + orderType: 'chase', + chaseMaxDistanceBps, + }), + ).toStrictEqual({ isValid: true }); + }, + ); + }); + + describe('validateOrderParams - accepted strategies', () => { + it.each(STRATEGY_ORDER_TYPES)('accepts a well-formed %s', (orderType) => { + expect( + validateOrderParams({ + coin: 'ETH', + size: '1', + orderType, + ...VALID_STRATEGY_PARAMS[orderType], + }), + ).toStrictEqual({ isValid: true }); + }); + + it('accepts a randomized TWAP', () => { + expect( + validateOrderParams({ + coin: 'ETH', + size: '1', + orderType: 'twap', + twapDuration: HYPERLIQUID_TWAP_LIMITS.MinDurationMinutes, + twapRandomize: true, + }), + ).toStrictEqual({ isValid: true }); + }); + + it('accepts an explicitly configured chase', () => { + expect( + validateOrderParams({ + coin: 'ETH', + size: '1', + orderType: 'chase', + chaseIntervalMs: CHASE_ORDER_CONFIG.MinIntervalMs, + chaseMaxDurationMs: CHASE_ORDER_CONFIG.MinIntervalMs * 10, + chaseMaxRepricings: 5, + }), + ).toStrictEqual({ isValid: true }); + }); + }); + + describe('validateOrderParams - TWAP', () => { + it('requires a duration', () => { + expect( + validateOrderParams({ coin: 'ETH', size: '1', orderType: 'twap' }), + ).toStrictEqual({ + isValid: false, + error: PERPS_ERROR_CODES.ORDER_TWAP_DURATION_REQUIRED, + }); + }); + + it.each([ + ['zero', 0], + ['negative', -5], + [ + 'below the venue minimum', + HYPERLIQUID_TWAP_LIMITS.MinDurationMinutes - 1, + ], + [ + 'above the venue maximum', + HYPERLIQUID_TWAP_LIMITS.MaxDurationMinutes + 1, + ], + ['fractional', 10.5], + ['not a number', NaN], + ])('rejects a %s duration', (_label, twapDuration) => { + expect( + validateOrderParams({ + coin: 'ETH', + size: '1', + orderType: 'twap', + twapDuration, + }), + ).toStrictEqual({ + isValid: false, + error: PERPS_ERROR_CODES.ORDER_TWAP_DURATION_INVALID, + }); + }); + + it.each([ + HYPERLIQUID_TWAP_LIMITS.MinDurationMinutes, + HYPERLIQUID_TWAP_LIMITS.MaxDurationMinutes, + ])('accepts the boundary duration %s', (twapDuration) => { + expect( + validateOrderParams({ + coin: 'ETH', + size: '1', + orderType: 'twap', + twapDuration, + }), + ).toStrictEqual({ isValid: true }); + }); + }); + + describe('validateOrderParams - scale', () => { + it.each([ + ['no bounds', {}], + ['only a lower bound', { scaleMinPrice: '2000' }], + ['only an upper bound', { scaleMaxPrice: '3000' }], + ])('requires both ladder bounds (%s)', (_label, bounds) => { + expect( + validateOrderParams({ + coin: 'ETH', + size: '1', + orderType: 'scale', + scaleNumOrders: 3, + ...bounds, + }), + ).toStrictEqual({ + isValid: false, + error: PERPS_ERROR_CODES.ORDER_SCALE_RANGE_REQUIRED, + }); + }); + + it('rejects an inverted range', () => { + expect( + validateOrderParams({ + coin: 'ETH', + size: '1', + orderType: 'scale', + scaleMinPrice: '3000', + scaleMaxPrice: '2000', + scaleNumOrders: 3, + }), + ).toStrictEqual({ + isValid: false, + error: PERPS_ERROR_CODES.ORDER_SCALE_RANGE_INVALID, + }); + }); + + it.each([ + ['a degenerate range', { scaleMinPrice: '2000', scaleMaxPrice: '2000' }], + [ + 'a non-positive lower bound', + { scaleMinPrice: '0', scaleMaxPrice: '1' }, + ], + ['an unparseable bound', { scaleMinPrice: 'abc', scaleMaxPrice: '3000' }], + ])('rejects %s', (_label, bounds) => { + expect( + validateOrderParams({ + coin: 'ETH', + size: '1', + orderType: 'scale', + scaleNumOrders: 3, + ...bounds, + }), + ).toStrictEqual({ + isValid: false, + error: PERPS_ERROR_CODES.ORDER_SCALE_RANGE_INVALID, + }); + }); + + it.each([ + ['missing', undefined], + ['a single rung', 1], + ['zero', 0], + ['fractional', 2.5], + ['above the supported ladder size', 21], + ])('rejects %s order counts', (_label, scaleNumOrders) => { + expect( + validateOrderParams({ + coin: 'ETH', + size: '1', + orderType: 'scale', + scaleMinPrice: '2000', + scaleMaxPrice: '3000', + scaleNumOrders, + }), + ).toStrictEqual({ + isValid: false, + error: PERPS_ERROR_CODES.ORDER_SCALE_COUNT_INVALID, + }); + }); + + it.each([ + ['zero', 0], + ['negative', -1], + ['NaN', NaN], + ['Infinity', Infinity], + ['-Infinity', -Infinity], + ])('rejects a %s skew before anything is signed', (_label, scaleSkew) => { + expect( + validateOrderParams({ + coin: 'ETH', + size: '1', + orderType: 'scale', + ...VALID_STRATEGY_PARAMS.scale, + scaleSkew, + }), + ).toStrictEqual({ + isValid: false, + error: PERPS_ERROR_CODES.ORDER_SCALE_RANGE_INVALID, + }); + }); + + it('accepts an omitted skew', () => { + expect( + validateOrderParams({ + coin: 'ETH', + size: '1', + orderType: 'scale', + ...VALID_STRATEGY_PARAMS.scale, + }), + ).toStrictEqual({ isValid: true }); + }); + + // The client coerces its input to two decimals; nothing here re-rounds it. + it.each([ + ['above 1', 2.35], + ['below 1', 0.25], + ['exactly 1', 1], + ['far above 1', 100], + ['far below 1', 0.01], + ])('accepts a skew %s', (_label, scaleSkew) => { + expect( + validateOrderParams({ + coin: 'ETH', + size: '1', + orderType: 'scale', + ...VALID_STRATEGY_PARAMS.scale, + scaleSkew, + }), + ).toStrictEqual({ isValid: true }); + }); + }); + + describe('validateOrderParams - chase', () => { + it('rejects a poll interval below the floor', () => { + expect( + validateOrderParams({ + coin: 'ETH', + size: '1', + orderType: 'chase', + chaseIntervalMs: CHASE_ORDER_CONFIG.MinIntervalMs - 1, + }), + ).toStrictEqual({ + isValid: false, + error: PERPS_ERROR_CODES.ORDER_CHASE_INTERVAL_INVALID, + }); + }); + + it('rejects a window shorter than one poll', () => { + expect( + validateOrderParams({ + coin: 'ETH', + size: '1', + orderType: 'chase', + chaseIntervalMs: 5000, + chaseMaxDurationMs: 4999, + }), + ).toStrictEqual({ + isValid: false, + error: PERPS_ERROR_CODES.ORDER_CHASE_DURATION_INVALID, + }); + }); + + it.each([0, -1, 1.5])( + 'rejects a %s repricing cap', + (chaseMaxRepricings) => { + expect( + validateOrderParams({ + coin: 'ETH', + size: '1', + orderType: 'chase', + chaseMaxRepricings, + }), + ).toStrictEqual({ + isValid: false, + error: PERPS_ERROR_CODES.ORDER_CHASE_DURATION_INVALID, + }); + }, + ); + + it.each([0, -1, 10_000, 10_001, Number.NaN, Number.POSITIVE_INFINITY])( + 'rejects an invalid %s bps max distance', + (chaseMaxDistanceBps) => { + expect( + validateOrderParams({ + coin: 'ETH', + size: '1', + orderType: 'chase', + chaseMaxDistanceBps, + }), + ).toStrictEqual({ + isValid: false, + error: PERPS_ERROR_CODES.ORDER_CHASE_MAX_DISTANCE_INVALID, + }); + }, + ); + }); + + describe('validateOrderParams - fields that do not belong', () => { + it.each([ + ['twapDuration', { twapDuration: 30 }], + ['twapRandomize', { twapRandomize: true }], + ['scaleMinPrice', { scaleMinPrice: '2000' }], + ['scaleNumOrders', { scaleNumOrders: 3 }], + ['scaleSkew', { scaleSkew: 2 }], + ['chaseIntervalMs', { chaseIntervalMs: 3000 }], + ['chaseMaxDistanceBps', { chaseMaxDistanceBps: 100 }], + ])('rejects %s on a market order', (_label, strategyField) => { + expect( + validateOrderParams({ + coin: 'ETH', + size: '1', + orderType: 'market', + ...strategyField, + }), + ).toStrictEqual({ + isValid: false, + error: PERPS_ERROR_CODES.ORDER_STRATEGY_PARAMS_NOT_SUPPORTED, + }); + }); + + it("rejects another strategy's fields", () => { + expect( + validateOrderParams({ + coin: 'ETH', + size: '1', + orderType: 'twap', + twapDuration: 30, + scaleNumOrders: 3, + }), + ).toStrictEqual({ + isValid: false, + error: PERPS_ERROR_CODES.ORDER_STRATEGY_PARAMS_NOT_SUPPORTED, + }); + }); + + it.each([ + ['a limit price', { price: '2500' }], + ['a trigger price', { triggerPrice: '2500' }], + ['a time in force', { timeInForce: 'GTC' as const }], + ['an attached take profit', { takeProfitPrice: '3500' }], + ['an attached stop loss', { stopLossPrice: '1500' }], + ['a partial take profit size', { takeProfitSize: '0.5' }], + ['a partial stop loss size', { stopLossSize: '0.5' }], + // A strategy is many orders over time, or none on the book at all; one + // client id cannot name any of them, so it is refused rather than dropped. + ['a client order id', { clientOrderId: '0xabc' }], + ])('rejects %s on a strategy placement', (_label, field) => { + expect( + validateOrderParams({ + coin: 'ETH', + size: '1', + orderType: 'twap', + twapDuration: 30, + ...field, + }), + ).toStrictEqual({ + isValid: false, + error: PERPS_ERROR_CODES.ORDER_STRATEGY_FIELD_UNSUPPORTED, + }); + }); + }); + + describe('validateOrderParams - existing order types are unaffected', () => { + it('still accepts a plain limit order', () => { + expect( + validateOrderParams({ + coin: 'ETH', + size: '1', + orderType: 'limit', + price: '2500', + timeInForce: 'ALO', + }), + ).toStrictEqual({ isValid: true }); + }); + + it('still accepts a trigger placement', () => { + expect( + validateOrderParams({ + coin: 'ETH', + size: '1', + orderType: 'stop_market', + triggerPrice: '2000', + }), + ).toStrictEqual({ isValid: true }); + }); + + it('still rejects a market order carrying a trigger price', () => { + expect( + validateOrderParams({ + coin: 'ETH', + size: '1', + orderType: 'market', + triggerPrice: '2000', + }), + ).toStrictEqual({ + isValid: false, + error: PERPS_ERROR_CODES.ORDER_TRIGGER_PRICE_NOT_SUPPORTED, + }); + }); + + it('still requires a coin before anything else', () => { + expect( + validateOrderParams({ orderType: 'twap', twapDuration: 30 }), + ).toStrictEqual({ + isValid: false, + error: PERPS_ERROR_CODES.ORDER_COIN_REQUIRED, + }); + }); + }); + + describe('execution classification', () => { + // A scale ladder rests GTC limits and a chase rests an ALO post-only limit, + // so both execute as limit orders even though neither carries an + // OrderParams.price. A TWAP's suborders cross the book. + it.each([ + ['scale', 'limit'], + ['chase', 'limit'], + ['twap', 'market'], + ] as [OrderType, 'limit' | 'market'][])( + 'classifies %s execution as %s', + (orderType, execution) => { + expect(getTriggerExecution(orderType)).toBe(execution); + }, + ); + + it.each(['scale', 'chase'] as OrderType[])( + 'gives %s the limit-order max value, not the tighter market cap', + (orderType) => { + const marketCap = getMaxOrderValue(50, 'market'); + + expect(getMaxOrderValue(50, orderType)).toBe( + marketCap * HYPERLIQUID_ORDER_LIMITS.LimitOrderMultiplier, + ); + }, + ); + + it('leaves twap on the market-order max value', () => { + expect(getMaxOrderValue(50, 'twap')).toBe(getMaxOrderValue(50, 'market')); + }); + }); + + describe('analytics order_type values', () => { + // TradingService emits `order_type` verbatim, so a placement type missing + // from this enum shows up in dashboards as an unmapped value. + it.each(STRATEGY_ORDER_TYPES)('enumerates %s', (orderType) => { + expect(Object.values(PERPS_EVENT_VALUE.ORDER_TYPE)).toContain(orderType); + }); + }); +}); diff --git a/packages/perps-controller/tests/src/utils/marketDataTransform.test.ts b/packages/perps-controller/tests/src/utils/marketDataTransform.test.ts new file mode 100644 index 00000000000..4677f7916cc --- /dev/null +++ b/packages/perps-controller/tests/src/utils/marketDataTransform.test.ts @@ -0,0 +1,216 @@ +import { + HYPERLIQUID_ASSET_NAMES, + HYPERLIQUID_CONFIG, + getHyperLiquidAssetName, +} from '../../../src/constants/hyperLiquidConfig.js'; +import type { + AllMidsResponse, + PerpsAssetCtx, + PerpsUniverse, +} from '../../../src/types/hyperliquid-types.js'; +import type { MarketDataFormatters } from '../../../src/types/index.js'; +import { + isMarketTradable, + transformMarketData, +} from '../../../src/utils/marketDataTransform.js'; + +// Mock formatters matching the MarketDataFormatters interface +const mockFormatters: MarketDataFormatters = { + formatVolume: (value: number) => `$${value.toFixed(0)}`, + formatPerpsFiat: (value: number) => `$${value.toFixed(2)}`, + formatPercentage: (percent: number) => `${percent.toFixed(2)}%`, + priceRangesUniversal: [], +}; + +/** + * Build a minimal HyperLiquid universe entry. Only the fields read by + * transformMarketData are meaningful; the rest satisfy the SDK type. + * + * @param name - Asset symbol (bare for crypto, `dex:SYMBOL` for HIP-3). + * @returns A PerpsUniverse fixture. + */ +function makeUniverseEntry(name: string): PerpsUniverse { + return { name, szDecimals: 2, maxLeverage: 10, marginTableId: 1 }; +} + +describe('marketDataTransform', () => { + describe('isMarketTradable', () => { + it('is tradable when mid and oracle prices are equal', () => { + expect(isMarketTradable({ midPrice: 50000, oraclePrice: 50000 })).toBe( + true, + ); + }); + + it('is tradable for small deviations well within the limit', () => { + // 0.2% deviation + expect(isMarketTradable({ midPrice: 50100, oraclePrice: 50000 })).toBe( + true, + ); + }); + + it('is tradable exactly at the deviation limit (inclusive boundary)', () => { + // 95% above the oracle price -> deviation === limit + expect( + isMarketTradable({ midPrice: 50000 * 1.95, oraclePrice: 50000 }), + ).toBe(true); + }); + + it('is untradable when the mid price is more than 95% above the oracle price', () => { + // 96% above the oracle price -> deviation > limit + expect( + isMarketTradable({ midPrice: 50000 * 1.96, oraclePrice: 50000 }), + ).toBe(false); + }); + + it('is untradable when the mid price is more than 95% below the oracle price', () => { + // Mid price near zero relative to oracle -> ~100% deviation + expect(isMarketTradable({ midPrice: 1, oraclePrice: 50000 })).toBe(false); + }); + + it('respects a custom deviation limit', () => { + // 20% deviation with a 10% limit -> untradable + expect( + isMarketTradable({ + midPrice: 120, + oraclePrice: 100, + deviationLimit: 0.1, + }), + ).toBe(false); + // Same deviation with a 50% limit -> tradable + expect( + isMarketTradable({ + midPrice: 120, + oraclePrice: 100, + deviationLimit: 0.5, + }), + ).toBe(true); + }); + + it('uses the HyperLiquid 0.95 default when no limit is provided', () => { + expect(HYPERLIQUID_CONFIG.OraclePriceDeviationLimit).toBe(0.95); + // Just over 95% -> untradable with the default limit + expect(isMarketTradable({ midPrice: 1.96, oraclePrice: 1 })).toBe(false); + }); + + it.each([ + ['mid price undefined', { midPrice: undefined, oraclePrice: 50000 }], + ['oracle price undefined', { midPrice: 50000, oraclePrice: undefined }], + ['mid price NaN', { midPrice: NaN, oraclePrice: 50000 }], + ['oracle price NaN', { midPrice: 50000, oraclePrice: NaN }], + ['mid price zero', { midPrice: 0, oraclePrice: 50000 }], + ['oracle price zero', { midPrice: 50000, oraclePrice: 0 }], + ['oracle price negative', { midPrice: 50000, oraclePrice: -1 }], + ])('defaults to tradable when %s', (_label, params) => { + expect(isMarketTradable(params)).toBe(true); + }); + }); +}); + +describe('getHyperLiquidAssetName', () => { + it('returns the human-readable name for a mapped main-DEX crypto symbol', () => { + expect(getHyperLiquidAssetName('BTC')).toBe('Bitcoin'); + expect(getHyperLiquidAssetName('ETH')).toBe('Ethereum'); + }); + + it('returns the human-readable name for a mapped HIP-3 symbol', () => { + expect(getHyperLiquidAssetName('xyz:TSLA')).toBe('Tesla'); + expect(getHyperLiquidAssetName('xyz:GOLD')).toBe('Gold'); + }); + + it('falls back to the ticker symbol for an unmapped asset', () => { + expect(getHyperLiquidAssetName('FOO')).toBe('FOO'); + expect(getHyperLiquidAssetName('unknown:BAR')).toBe('unknown:BAR'); + }); + + it('uses an injected name map when provided', () => { + const names = { BTC: 'Bitcoin Override', NEW: 'Brand New' }; + expect(getHyperLiquidAssetName('BTC', names)).toBe('Bitcoin Override'); + expect(getHyperLiquidAssetName('NEW', names)).toBe('Brand New'); + // Falls back to symbol when missing from the injected map. + expect(getHyperLiquidAssetName('ETH', names)).toBe('ETH'); + }); + + it('maps every bundled symbol to a non-empty name', () => { + for (const [symbol, name] of Object.entries(HYPERLIQUID_ASSET_NAMES)) { + expect(typeof symbol).toBe('string'); + expect(name.length).toBeGreaterThan(0); + } + }); +}); + +describe('transformMarketData - human-readable names', () => { + it('populates name from the bundled map for crypto and HIP-3 markets', () => { + const universe: PerpsUniverse[] = [ + makeUniverseEntry('BTC'), + makeUniverseEntry('xyz:AAPL'), + ]; + const allMids: AllMidsResponse = { BTC: '50000', 'xyz:AAPL': '200' }; + + const result = transformMarketData( + { universe, assetCtxs: [], allMids }, + mockFormatters, + ); + + expect(result[0]).toMatchObject({ symbol: 'BTC', name: 'Bitcoin' }); + expect(result[1]).toMatchObject({ symbol: 'xyz:AAPL', name: 'Apple' }); + }); + + it('falls back to the symbol when an asset is not mapped', () => { + const universe: PerpsUniverse[] = [makeUniverseEntry('zzz:UNKNOWN')]; + const allMids: AllMidsResponse = { 'zzz:UNKNOWN': '1' }; + + const result = transformMarketData( + { universe, assetCtxs: [], allMids }, + mockFormatters, + ); + + expect(result[0]).toMatchObject({ + symbol: 'zzz:UNKNOWN', + name: 'zzz:UNKNOWN', + }); + }); + + it('respects an injected assetNames map over the bundled defaults', () => { + const universe: PerpsUniverse[] = [makeUniverseEntry('BTC')]; + const allMids: AllMidsResponse = { BTC: '50000' }; + + const result = transformMarketData( + { universe, assetCtxs: [], allMids }, + mockFormatters, + undefined, + { BTC: 'Custom Bitcoin' }, + ); + + expect(result[0].name).toBe('Custom Bitcoin'); + }); + + it('still reads asset context data alongside the resolved name', () => { + const universe: PerpsUniverse[] = [makeUniverseEntry('BTC')]; + const allMids: AllMidsResponse = { BTC: '50000' }; + const assetCtxs = [ + { + funding: '0.0001', + openInterest: '1000', + prevDayPx: '49000', + dayNtlVlm: '1000000', + markPx: '50000', + midPx: '50000', + oraclePx: '50000', + premium: '0', + impactPxs: ['49990', '50010'], + dayBaseVlm: '20', + }, + ] as unknown as PerpsAssetCtx[]; + + const result = transformMarketData( + { universe, assetCtxs, allMids }, + mockFormatters, + ); + + expect(result[0].name).toBe('Bitcoin'); + expect(result[0].volume).toBe('$1000000'); + }); +}); + +// Terminal metadata enrichment is handled by MarketDataService.#enrichWithTerminalMetadata +// and tested in MarketDataService.test.ts — not by transformMarketData. diff --git a/packages/perps-controller/tests/src/utils/marketSearch.test.ts b/packages/perps-controller/tests/src/utils/marketSearch.test.ts new file mode 100644 index 00000000000..88487dac1f3 --- /dev/null +++ b/packages/perps-controller/tests/src/utils/marketSearch.test.ts @@ -0,0 +1,178 @@ +import type { PerpsMarketData } from '../../../src/types/index.js'; +import { + MarketMatchRank, + getMarketMatchRank, + rankMarketsByQuery, +} from '../../../src/utils/marketSearch.js'; + +/** + * Build a minimal market fixture. Only `symbol`, `name`, and optional + * `keywords` drive search; the remaining fields satisfy the PerpsMarketData type. + * + * @param symbol - Ticker symbol (bare for crypto, `dex:SYMBOL` for HIP-3). + * @param name - Human-readable name. + * @param keywords - Optional keyword array from Terminal API metadata. + * @returns A PerpsMarketData fixture. + */ +function makeMarket( + symbol: string, + name: string, + keywords?: string[], +): PerpsMarketData { + return { + symbol, + name, + maxLeverage: '10x', + price: '$1.00', + change24h: '$0.00', + change24hPercent: '0.00%', + volume: '$0', + ...(keywords !== undefined && { keywords }), + }; +} + +describe('getMarketMatchRank', () => { + const btc = makeMarket('BTC', 'Bitcoin'); + + it('ranks an exact symbol or name match as Exact', () => { + expect(getMarketMatchRank(btc, 'BTC')).toBe(MarketMatchRank.Exact); + expect(getMarketMatchRank(btc, 'Bitcoin')).toBe(MarketMatchRank.Exact); + }); + + it('ranks a leading match as Prefix', () => { + expect(getMarketMatchRank(btc, 'bit')).toBe(MarketMatchRank.Prefix); + expect(getMarketMatchRank(btc, 'bt')).toBe(MarketMatchRank.Prefix); + }); + + it('ranks an interior match as Substring', () => { + expect(getMarketMatchRank(btc, 'itco')).toBe(MarketMatchRank.Substring); + }); + + it('is case-insensitive and trims the query', () => { + expect(getMarketMatchRank(btc, ' BITCOIN ')).toBe(MarketMatchRank.Exact); + }); + + it('returns null when nothing matches', () => { + expect(getMarketMatchRank(btc, 'ethereum')).toBeNull(); + }); + + it('returns null for an empty or whitespace query', () => { + expect(getMarketMatchRank(btc, '')).toBeNull(); + expect(getMarketMatchRank(btc, ' ')).toBeNull(); + }); + + it('matches HIP-3 markets by name and by symbol substring', () => { + const tsla = makeMarket('xyz:TSLA', 'Tesla'); + // Full name -> Exact. + expect(getMarketMatchRank(tsla, 'tesla')).toBe(MarketMatchRank.Exact); + // Leading fragment of the name -> Prefix. + expect(getMarketMatchRank(tsla, 'tes')).toBe(MarketMatchRank.Prefix); + // "tsla" only appears inside the dex-prefixed symbol -> Substring. + expect(getMarketMatchRank(tsla, 'tsla')).toBe(MarketMatchRank.Substring); + }); +}); + +describe('rankMarketsByQuery', () => { + it('returns the markets unchanged for an empty or whitespace query', () => { + const markets = [ + makeMarket('BTC', 'Bitcoin'), + makeMarket('ETH', 'Ethereum'), + ]; + expect(rankMarketsByQuery(markets, '')).toBe(markets); + expect(rankMarketsByQuery(markets, ' ')).toBe(markets); + }); + + it('drops non-matching markets', () => { + const markets = [ + makeMarket('BTC', 'Bitcoin'), + makeMarket('ETH', 'Ethereum'), + ]; + const result = rankMarketsByQuery(markets, 'bitcoin'); + expect(result).toHaveLength(1); + expect(result[0].symbol).toBe('BTC'); + }); + + it('orders results exact, then prefix, then substring', () => { + const markets = [ + makeMarket('WETH', 'Wrapped Ether'), // substring of "weth" + makeMarket('ETHFI', 'Ether.fi'), // prefix of "ethfi" + makeMarket('ETH', 'Ethereum'), // exact symbol + ]; + const result = rankMarketsByQuery(markets, 'eth').map( + (market) => market.symbol, + ); + expect(result).toStrictEqual(['ETH', 'ETHFI', 'WETH']); + }); + + it('keeps input order for markets sharing the same rank (stable)', () => { + const markets = [ + makeMarket('BTC', 'Bitcoin'), // name prefix "bit" + makeMarket('BCH', 'Bitcoin Cash'), // name prefix "bit" + ]; + const result = rankMarketsByQuery(markets, 'bit').map( + (market) => market.symbol, + ); + expect(result).toStrictEqual(['BTC', 'BCH']); + }); + + it('finds markets by human-readable name (the TAT-2413 case)', () => { + const markets = [ + makeMarket('BTC', 'Bitcoin'), + makeMarket('xyz:AAPL', 'Apple'), + makeMarket('xyz:GOLD', 'Gold'), + ]; + expect( + rankMarketsByQuery(markets, 'apple').map((market) => market.symbol), + ).toStrictEqual(['xyz:AAPL']); + expect( + rankMarketsByQuery(markets, 'gold').map((market) => market.symbol), + ).toStrictEqual(['xyz:GOLD']); + }); +}); + +describe('keyword matching (Terminal API metadata)', () => { + it('matches against keywords for exact, prefix, and substring', () => { + const market = makeMarket('BTC', 'Bitcoin', ['layer-1', 'pow', 'defi']); + + expect(getMarketMatchRank(market, 'defi')).toBe(MarketMatchRank.Exact); + expect(getMarketMatchRank(market, 'layer')).toBe(MarketMatchRank.Prefix); + expect(getMarketMatchRank(market, 'ayer')).toBe(MarketMatchRank.Substring); + }); + + it('keyword match does not override a better symbol or name match', () => { + const market = makeMarket('BTC', 'Bitcoin', ['crypto']); + expect(getMarketMatchRank(market, 'btc')).toBe(MarketMatchRank.Exact); + }); + + it('returns keyword rank when symbol and name do not match', () => { + const market = makeMarket('BTC', 'Bitcoin', ['digital-gold']); + expect(getMarketMatchRank(market, 'digital-gold')).toBe( + MarketMatchRank.Exact, + ); + }); + + it('returns null when keywords also do not match', () => { + const market = makeMarket('BTC', 'Bitcoin', ['crypto', 'layer-1']); + expect(getMarketMatchRank(market, 'forex')).toBeNull(); + }); + + it('handles markets without keywords gracefully', () => { + const market = makeMarket('ETH', 'Ethereum'); + expect(getMarketMatchRank(market, 'defi')).toBeNull(); + }); + + it('rankMarketsByQuery includes keyword-matched markets', () => { + const markets = [ + makeMarket('BTC', 'Bitcoin', ['digital-gold']), + makeMarket('xyz:GOLD', 'Gold'), + makeMarket('ETH', 'Ethereum'), + ]; + + const result = rankMarketsByQuery(markets, 'gold').map( + (market) => market.symbol, + ); + expect(result).toContain('BTC'); + expect(result).toContain('xyz:GOLD'); + expect(result).not.toContain('ETH'); + }); +}); diff --git a/packages/perps-controller/tests/src/utils/marketUtils.test.ts b/packages/perps-controller/tests/src/utils/marketUtils.test.ts new file mode 100644 index 00000000000..a881c05c4c0 --- /dev/null +++ b/packages/perps-controller/tests/src/utils/marketUtils.test.ts @@ -0,0 +1,171 @@ +import type { PerpsMarketData } from '../../../src/types/index.js'; +import { + getMarketTypeFilter, + isHip3Market, + matchesCategory, +} from '../../../src/utils/marketUtils.js'; + +const market = (overrides: Partial): PerpsMarketData => + ({ + name: 'BTC', + symbol: 'BTC', + price: '50000', + volume: '$1M', + openInterest: '$1M', + change24hPercent: '+1.00%', + fundingRate: 0, + ...overrides, + }) as PerpsMarketData; + +describe('marketUtils category classification', () => { + describe('isHip3Market', () => { + it('is true when isHip3 is set', () => { + expect(isHip3Market(market({ isHip3: true }))).toBe(true); + }); + + it('is true when only marketSource is set', () => { + expect( + isHip3Market(market({ isHip3: undefined, marketSource: 'xyz' })), + ).toBe(true); + }); + + it('is false for a main-DEX market', () => { + expect( + isHip3Market(market({ isHip3: false, marketSource: undefined })), + ).toBe(false); + }); + }); + + describe('matchesCategory', () => { + it("matches every market for 'all'", () => { + expect(matchesCategory(market({ marketType: 'etf' }), 'all')).toBe(true); + }); + + it("matches only new markets for 'new'", () => { + expect(matchesCategory(market({ isNewMarket: true }), 'new')).toBe(true); + expect(matchesCategory(market({ isNewMarket: false }), 'new')).toBe( + false, + ); + }); + + it("matches non-HIP3 markets for 'crypto'", () => { + expect(matchesCategory(market({ isHip3: false }), 'crypto')).toBe(true); + }); + + it("matches HIP-3 markets explicitly typed crypto for 'crypto'", () => { + expect( + matchesCategory( + market({ isHip3: true, marketType: 'crypto' }), + 'crypto', + ), + ).toBe(true); + }); + + it("excludes other HIP-3 markets from 'crypto'", () => { + expect( + matchesCategory(market({ isHip3: true, marketType: 'etf' }), 'crypto'), + ).toBe(false); + }); + + it("treats a marketSource-only partial market as 'new', not 'crypto'", () => { + const partial = market({ + marketType: undefined, + isHip3: undefined, + isNewMarket: undefined, + marketSource: 'xyz', + }); + expect(matchesCategory(partial, 'crypto')).toBe(false); + expect(matchesCategory(partial, 'new')).toBe(true); + }); + + it.each([ + ['stock', 'stock'], + ['pre-ipo', 'pre-ipo'], + ['index', 'index'], + ['etf', 'etf'], + ['commodity', 'commodity'], + ['forex', 'forex'], + ] as const)( + 'matches marketType %s for the aligned filter %s', + (marketType, filter) => { + expect(matchesCategory(market({ marketType }), filter)).toBe(true); + }, + ); + }); + + describe('getMarketTypeFilter', () => { + // HIP-3 markets carry a marketType; main-DEX crypto does not. + it.each([ + ['stock', 'stock'], + ['pre-ipo', 'pre-ipo'], + ['index', 'index'], + ['etf', 'etf'], + ['commodity', 'commodity'], + ['forex', 'forex'], + ] as const)( + 'resolves HIP-3 marketType %s to the %s filter', + (marketType, expected) => { + expect(getMarketTypeFilter(market({ marketType, isHip3: true }))).toBe( + expected, + ); + }, + ); + + it('resolves an explicit crypto marketType to crypto', () => { + expect(getMarketTypeFilter(market({ marketType: 'crypto' }))).toBe( + 'crypto', + ); + }); + + it('resolves a main-DEX market without a marketType to crypto', () => { + expect( + getMarketTypeFilter(market({ marketType: undefined, isHip3: false })), + ).toBe('crypto'); + }); + + it('resolves uncategorized HIP-3 markets to the new bucket', () => { + expect( + getMarketTypeFilter(market({ marketType: undefined, isHip3: true })), + ).toBe('new'); + }); + + it('treats a marketSource DEX id as HIP-3 (new, not crypto) when isHip3 is unset', () => { + expect( + getMarketTypeFilter( + market({ + marketType: undefined, + isHip3: undefined, + marketSource: 'xyz', + }), + ), + ).toBe('new'); + }); + + it('never returns the all sentinel', () => { + const samples = [ + market({ marketType: 'stock', isHip3: true }), + market({ marketType: 'commodity', isHip3: true }), + market({ marketType: undefined, isHip3: false }), + market({ marketType: undefined, isHip3: true }), + ]; + samples.forEach((sample) => + expect(getMarketTypeFilter(sample)).not.toBe('all'), + ); + }); + + // The resolved bucket must agree with matchesCategory. + it.each([ + market({ marketType: 'stock', isHip3: true }), + market({ marketType: 'pre-ipo', isHip3: true }), + market({ marketType: 'index', isHip3: true }), + market({ marketType: 'etf', isHip3: true }), + market({ marketType: 'commodity', isHip3: true }), + market({ marketType: 'forex', isHip3: true }), + market({ marketType: undefined, isHip3: false }), + market({ marketType: undefined, isHip3: true }), + market({ marketType: undefined, marketSource: 'xyz' }), + ])('is consistent with matchesCategory for %o', (sample) => { + expect(matchesCategory(sample, getMarketTypeFilter(sample))).toBe(true); + }); + }); +}); diff --git a/packages/perps-controller/tests/src/utils/myxAdapter.test.ts b/packages/perps-controller/tests/src/utils/myxAdapter.test.ts new file mode 100644 index 00000000000..544a5f4090d --- /dev/null +++ b/packages/perps-controller/tests/src/utils/myxAdapter.test.ts @@ -0,0 +1,905 @@ +/* eslint-disable */ +import BigNumber from 'bignumber.js'; + +jest.mock('@myx-trade/sdk', () => ({ + Direction: { LONG: 0, SHORT: 1 }, + OrderType: {}, + OperationType: {}, + TriggerType: {}, + OrderStatus: {}, + TimeInForce: {}, + DirectionEnum: { Long: 0, Short: 1 }, + OrderTypeEnum: { Market: 0, Limit: 1, Stop: 2, Conditional: 3 }, + OperationEnum: { Increase: 0, Decrease: 1 }, + OrderStatusEnum: { Cancelled: 1, Expired: 2, Successful: 9 }, + ExecTypeEnum: { + Market: 1, + Limit: 2, + TP: 3, + SL: 4, + ADL: 5, + ADLTrigger: 6, + Liquidation: 7, + EarlyClose: 8, + MarketClose: 9, + }, + TradeFlowTypeEnum: { + Increase: 0, + Decrease: 1, + AddMargin: 2, + RemoveMargin: 3, + CancelOrder: 4, + ADL: 5, + Liquidation: 6, + MarketClose: 7, + EarlyClose: 8, + AddTPSL: 9, + SecurityDeposit: 10, + TransferToWallet: 11, + MarginAccountDeposit: 12, + ReferralReward: 13, + ReferralRewardClaim: 14, + }, +})); + +import { + MYX_PRICE_DECIMALS, + MYX_SIZE_DECIMALS, + MYX_PRICE_DECIMALS as PRICE_DEC, + MYX_COLLATERAL_DECIMALS, +} from '../../../src/constants/myxConfig.js'; +import type { MarketDataFormatters } from '../../../src/types/index.js'; +import type { + MYXPoolSymbol, + MYXTicker, + MYXPositionType, + MYXHistoryOrderItem, + MYXTradeFlowItem, + MYXKlineData, + MYXKlineWsData, +} from '../../../src/types/myx-types.js'; +import { + MYXDirection, + MYXDirectionEnum, + MYXOperationEnum, + MYXOrderStatusEnum, + MYXOrderTypeEnum, + MYXExecTypeEnum, + MYXTradeFlowTypeEnum, +} from '../../../src/types/myx-types.js'; +import { + adaptMarketFromMYX, + adaptPriceFromMYX, + adaptMarketDataFromMYX, + filterMYXExclusiveMarkets, + isOverlappingMarket, + buildPoolSymbolMap, + buildSymbolPoolsMap, + extractSymbolFromPoolId, + adaptPositionFromMYX, + adaptOrderFromMYX, + adaptAccountStateFromMYX, + adaptOrderFillFromMYX, + adaptFundingFromMYX, + adaptUserHistoryFromMYX, + adaptCandleFromMYX, + adaptCandleFromMYXWebSocket, + toMYXKlineResolution, + assertMYXSuccess, +} from '../../../src/utils/myxAdapter.js'; + +// Mock formatters matching the MarketDataFormatters interface +const mockFormatters: MarketDataFormatters = { + formatVolume: (v: number) => `$${v.toFixed(0)}`, + formatPerpsFiat: (v: number) => `$${v.toFixed(2)}`, + formatPercentage: (p: number) => `${p.toFixed(2)}%`, + priceRangesUniversal: [], +}; + +// Helper: create a minimal MYXPoolSymbol fixture +function makePool(overrides: Partial = {}): MYXPoolSymbol { + return { + chainId: 56, + marketId: 'market-1', + poolId: '0xpool1', + baseSymbol: 'RHEA', + quoteSymbol: 'USDT', + baseTokenIcon: '', + baseToken: '0xbase', + quoteToken: '0xquote', + ...overrides, + }; +} + +// Helper: create a minimal MYXTicker fixture +function makeTicker(overrides: Partial = {}): MYXTicker { + return { + chainId: 56, + poolId: '0xpool1', + oracleId: 1, + price: new BigNumber(1500) + .times(new BigNumber(10).pow(MYX_PRICE_DECIMALS)) + .toFixed(0), + change: '2.5', + high: '0', + low: '0', + volume: '1000000', + turnover: '0', + ...overrides, + }; +} + +describe('myxAdapter', () => { + describe('adaptMarketFromMYX', () => { + it('returns correct MarketInfo from a pool with baseSymbol', () => { + const pool = makePool({ baseSymbol: 'PARTI' }); + const market = adaptMarketFromMYX(pool); + + expect(market.name).toBe('PARTI'); + expect(market.szDecimals).toBe(18); + expect(market.maxLeverage).toBe(100); + expect(market.providerId).toBe('myx'); + expect(market.marginTableId).toBe(0); + expect(market.minimumOrderSize).toBe(10); + }); + + it('falls back to poolId when baseSymbol is missing', () => { + const pool = makePool({ baseSymbol: '', poolId: '0xfallback' }); + const market = adaptMarketFromMYX(pool); + + expect(market.name).toBe('0xfallback'); + }); + }); + + describe('adaptPriceFromMYX', () => { + it('returns correct price and change24h from valid ticker', () => { + const ticker = makeTicker({ change: '3.14' }); + const result = adaptPriceFromMYX(ticker); + + expect(Number.parseFloat(result.price)).toBe(1500); + expect(result.change24h).toBe(3.14); + }); + + it('defaults change24h to 0 when change is falsy', () => { + const ticker = makeTicker({ change: '' }); + const result = adaptPriceFromMYX(ticker); + + expect(result.change24h).toBe(0); + }); + + it('returns "0" price for zero-value ticker', () => { + const ticker = makeTicker({ price: '0' }); + const result = adaptPriceFromMYX(ticker); + + expect(result.price).toBe('0'); + }); + }); + + describe('adaptMarketDataFromMYX', () => { + it('returns full data when ticker is provided', () => { + const pool = makePool({ baseSymbol: 'RHEA' }); + const ticker = makeTicker(); + const data = adaptMarketDataFromMYX(pool, ticker, mockFormatters); + + expect(data.symbol).toBe('RHEA'); + expect(data.providerId).toBe('myx'); + expect(data.maxLeverage).toBe('100x'); + // Price should be formatted (non-zero) + expect(data.price).toBeDefined(); + expect(data.volume).toBeDefined(); + }); + + it('returns zeroed prices when ticker is omitted', () => { + const pool = makePool({ baseSymbol: 'SKYAI' }); + const data = adaptMarketDataFromMYX(pool, undefined, mockFormatters); + + expect(data.symbol).toBe('SKYAI'); + expect(data.providerId).toBe('myx'); + expect(data.maxLeverage).toBe('100x'); + }); + }); + + describe('filterMYXExclusiveMarkets', () => { + it('filters out overlapping markets (BTC, ETH, BNB, PUMP, WLFI)', () => { + const pools = [ + makePool({ baseSymbol: 'BTC' }), + makePool({ baseSymbol: 'ETH' }), + makePool({ baseSymbol: 'BNB' }), + makePool({ baseSymbol: 'PUMP' }), + makePool({ baseSymbol: 'WLFI' }), + makePool({ baseSymbol: 'RHEA' }), + makePool({ baseSymbol: 'PARTI' }), + ]; + + const result = filterMYXExclusiveMarkets(pools); + const symbols = result.map((p) => p.baseSymbol); + + expect(symbols).toEqual(['RHEA', 'PARTI']); + }); + + it('returns all pools when none overlap', () => { + const pools = [ + makePool({ baseSymbol: 'RHEA' }), + makePool({ baseSymbol: 'SKYAI' }), + ]; + + expect(filterMYXExclusiveMarkets(pools)).toHaveLength(2); + }); + }); + + describe('isOverlappingMarket', () => { + it('returns true for BTC', () => { + expect(isOverlappingMarket('BTC')).toBe(true); + }); + + it('returns true for ETH', () => { + expect(isOverlappingMarket('ETH')).toBe(true); + }); + + it('returns false for MYX-exclusive symbol', () => { + expect(isOverlappingMarket('RHEA')).toBe(false); + }); + }); + + describe('buildPoolSymbolMap', () => { + it('builds a map from poolId to symbol', () => { + const pools = [ + makePool({ poolId: '0xA', baseSymbol: 'RHEA' }), + makePool({ poolId: '0xB', baseSymbol: 'PARTI' }), + ]; + + const map = buildPoolSymbolMap(pools); + + expect(map.get('0xA')).toBe('RHEA'); + expect(map.get('0xB')).toBe('PARTI'); + expect(map.size).toBe(2); + }); + + it('returns an empty map for empty input', () => { + expect(buildPoolSymbolMap([]).size).toBe(0); + }); + }); + + describe('buildSymbolPoolsMap', () => { + it('groups poolIds by symbol', () => { + const pools = [ + makePool({ poolId: '0xA', baseSymbol: 'RHEA' }), + makePool({ poolId: '0xB', baseSymbol: 'RHEA' }), + makePool({ poolId: '0xC', baseSymbol: 'PARTI' }), + ]; + + const map = buildSymbolPoolsMap(pools); + + expect(map.get('RHEA')).toEqual(['0xA', '0xB']); + expect(map.get('PARTI')).toEqual(['0xC']); + }); + + it('returns empty map for empty input', () => { + expect(buildSymbolPoolsMap([]).size).toBe(0); + }); + }); + + describe('extractSymbolFromPoolId', () => { + it('returns poolId as fallback', () => { + expect(extractSymbolFromPoolId('0xSomePool')).toBe('0xSomePool'); + }); + }); + + // ============================================================================ + // Position Adapter + // ============================================================================ + + describe('adaptPositionFromMYX', () => { + function makePosition( + overrides: Partial = {}, + ): MYXPositionType { + return { + poolId: '0xpool1', + positionId: 'pos-1', + direction: MYXDirection.LONG, + entryPrice: new BigNumber(50000) + .times(new BigNumber(10).pow(PRICE_DEC)) + .toFixed(0), + fundingRateIndex: '0', + size: new BigNumber(1) + .times(new BigNumber(10).pow(MYX_SIZE_DECIMALS)) + .toFixed(0), + riskTier: 0, + collateralAmount: new BigNumber(5000) + .times(new BigNumber(10).pow(MYX_COLLATERAL_DECIMALS)) + .toFixed(0), + txTime: 1700000000, + ...overrides, + }; + } + + const poolSymbolMap = new Map([['0xpool1', 'BTC']]); + + it('adapts a long position with correct symbol, size, and leverage', () => { + const result = adaptPositionFromMYX(makePosition(), poolSymbolMap); + + expect(result.symbol).toBe('BTC'); + expect(Number(result.size)).toBeGreaterThan(0); // Long = positive + expect(Number(result.entryPrice)).toBe(50000); + expect(result.leverage.type).toBe('isolated'); + expect(result.leverage.value).toBe(10); // 50000 * 1 / 5000 = 10x + expect(result.providerId).toBe('myx'); + }); + + it('adapts a short position with negative size', () => { + const result = adaptPositionFromMYX( + makePosition({ direction: MYXDirection.SHORT }), + poolSymbolMap, + ); + + expect(Number(result.size)).toBeLessThan(0); + }); + + it('falls back to poolId when symbol not in map', () => { + const emptyMap = new Map(); + const result = adaptPositionFromMYX(makePosition(), emptyMap); + + expect(result.symbol).toBe('0xpool1'); + }); + + it('uses leverage 1 when collateral is zero', () => { + const result = adaptPositionFromMYX( + makePosition({ collateralAmount: '0' }), + poolSymbolMap, + ); + + expect(result.leverage.value).toBe(1); + }); + }); + + // ============================================================================ + // Order Adapter + // ============================================================================ + + describe('adaptOrderFromMYX', () => { + function makeHistoryOrder( + overrides: Partial = {}, + ): MYXHistoryOrderItem { + return { + chainId: 56, + poolId: '0xpool1', + orderId: 42, + txTime: 1700000000, + txHash: 0xabc as unknown as number, + orderType: MYXOrderTypeEnum.Market, + operation: MYXOperationEnum.Increase, + triggerType: 0 as MYXHistoryOrderItem['triggerType'], + direction: MYXDirectionEnum.Long, + size: new BigNumber(2) + .times(new BigNumber(10).pow(MYX_SIZE_DECIMALS)) + .toFixed(0), + filledSize: new BigNumber(2) + .times(new BigNumber(10).pow(MYX_SIZE_DECIMALS)) + .toFixed(0), + filledAmount: '0', + price: new BigNumber(60000) + .times(new BigNumber(10).pow(PRICE_DEC)) + .toFixed(0), + lastPrice: '0', + orderStatus: MYXOrderStatusEnum.Successful, + execType: MYXExecTypeEnum.Market, + slippagePct: 0, + executionFeeToken: '0x0' as MYXHistoryOrderItem['executionFeeToken'], + executionFeeAmount: '0', + tradingFee: '0', + fundingFee: '0', + realizedPnl: '0', + baseSymbol: 'BTC', + quoteSymbol: 'USDT', + userLeverage: 10, + ...overrides, + }; + } + + const poolSymbolMap = new Map([['0xpool1', 'BTC']]); + + it('maps a filled long market order correctly', () => { + const result = adaptOrderFromMYX(makeHistoryOrder(), poolSymbolMap); + + expect(result.orderId).toBe('42'); + expect(result.symbol).toBe('BTC'); + expect(result.side).toBe('buy'); + expect(result.orderType).toBe('market'); + expect(result.status).toBe('filled'); + expect(result.isTrigger).toBe(false); + expect(result.providerId).toBe('myx'); + }); + + it('maps a short limit order as sell', () => { + const result = adaptOrderFromMYX( + makeHistoryOrder({ + direction: MYXDirectionEnum.Short, + orderType: MYXOrderTypeEnum.Limit, + orderStatus: MYXOrderStatusEnum.Cancelled, + }), + poolSymbolMap, + ); + + expect(result.side).toBe('sell'); + expect(result.orderType).toBe('limit'); + expect(result.status).toBe('canceled'); + }); + + it('maps expired status to canceled', () => { + const result = adaptOrderFromMYX( + makeHistoryOrder({ orderStatus: MYXOrderStatusEnum.Expired }), + poolSymbolMap, + ); + + expect(result.status).toBe('canceled'); + }); + + it('maps unknown status to open', () => { + const result = adaptOrderFromMYX( + makeHistoryOrder({ orderStatus: 99 as MYXOrderStatusEnum }), + poolSymbolMap, + ); + + expect(result.status).toBe('open'); + }); + + it('detects TP trigger order', () => { + const result = adaptOrderFromMYX( + makeHistoryOrder({ execType: MYXExecTypeEnum.TP }), + poolSymbolMap, + ); + + expect(result.isTrigger).toBe(true); + expect(result.detailedOrderType).toBe('Take Profit'); + }); + + it('detects SL trigger order', () => { + const result = adaptOrderFromMYX( + makeHistoryOrder({ execType: MYXExecTypeEnum.SL }), + poolSymbolMap, + ); + + expect(result.isTrigger).toBe(true); + expect(result.detailedOrderType).toBe('Stop Loss'); + }); + + it('detects liquidation order', () => { + const result = adaptOrderFromMYX( + makeHistoryOrder({ execType: MYXExecTypeEnum.Liquidation }), + poolSymbolMap, + ); + + expect(result.detailedOrderType).toBe('Liquidation'); + }); + + it('sets reduceOnly for decrease operations', () => { + const result = adaptOrderFromMYX( + makeHistoryOrder({ operation: MYXOperationEnum.Decrease }), + poolSymbolMap, + ); + + expect(result.reduceOnly).toBe(true); + }); + + it('falls back to poolSymbolMap then poolId for symbol', () => { + const result = adaptOrderFromMYX( + makeHistoryOrder({ baseSymbol: undefined as unknown as string }), + poolSymbolMap, + ); + expect(result.symbol).toBe('BTC'); + + const emptyMap = new Map(); + const result2 = adaptOrderFromMYX( + makeHistoryOrder({ baseSymbol: undefined as unknown as string }), + emptyMap, + ); + expect(result2.symbol).toBe('0xpool1'); + }); + }); + + // ============================================================================ + // Account State Adapter + // ============================================================================ + + describe('adaptAccountStateFromMYX', () => { + it('computes balances from account info and wallet balance', () => { + const accountInfo = { + totalCollateral: new BigNumber(1000) + .times(new BigNumber(10).pow(MYX_COLLATERAL_DECIMALS)) + .toFixed(0), + unrealizedPnl: new BigNumber(50) + .times(new BigNumber(10).pow(MYX_COLLATERAL_DECIMALS)) + .toFixed(0), + }; + const walletBalance = new BigNumber(500) + .times(new BigNumber(10).pow(MYX_COLLATERAL_DECIMALS)) + .toFixed(0); + + const result = adaptAccountStateFromMYX(accountInfo, walletBalance); + + expect(Number(result.marginUsed)).toBe(1000); + expect(Number(result.unrealizedPnl)).toBe(50); + expect(Number(result.spendableBalance)).toBe(500); + // totalBalance = balance + marginUsed + unrealizedPnl = 500 + 1000 + 50 + expect(Number(result.totalBalance)).toBe(1550); + }); + + it('returns zeros when accountInfo is undefined', () => { + const result = adaptAccountStateFromMYX(undefined); + + expect(Number(result.marginUsed)).toBe(0); + expect(Number(result.unrealizedPnl)).toBe(0); + expect(Number(result.totalBalance)).toBe(0); + expect(Number(result.spendableBalance)).toBe(0); + }); + + it('returns zeros when walletBalance is undefined', () => { + const result = adaptAccountStateFromMYX(undefined, undefined); + + expect(Number(result.spendableBalance)).toBe(0); + }); + }); + + // ============================================================================ + // Order Fill Adapter + // ============================================================================ + + describe('adaptOrderFillFromMYX', () => { + function makeHistoryOrder( + overrides: Partial = {}, + ): MYXHistoryOrderItem { + return { + chainId: 56, + poolId: '0xpool1', + orderId: 99, + txTime: 1700000000, + txHash: 0xdef as unknown as number, + orderType: MYXOrderTypeEnum.Market, + operation: MYXOperationEnum.Increase, + triggerType: 0 as MYXHistoryOrderItem['triggerType'], + direction: MYXDirectionEnum.Long, + size: new BigNumber(3) + .times(new BigNumber(10).pow(MYX_SIZE_DECIMALS)) + .toFixed(0), + filledSize: new BigNumber(3) + .times(new BigNumber(10).pow(MYX_SIZE_DECIMALS)) + .toFixed(0), + filledAmount: '0', + price: new BigNumber(45000) + .times(new BigNumber(10).pow(PRICE_DEC)) + .toFixed(0), + lastPrice: new BigNumber(45100) + .times(new BigNumber(10).pow(PRICE_DEC)) + .toFixed(0), + orderStatus: MYXOrderStatusEnum.Successful, + execType: MYXExecTypeEnum.Market, + slippagePct: 0, + executionFeeToken: '0x0' as MYXHistoryOrderItem['executionFeeToken'], + executionFeeAmount: '0', + tradingFee: new BigNumber(5) + .times(new BigNumber(10).pow(MYX_COLLATERAL_DECIMALS)) + .toFixed(0), + fundingFee: '0', + realizedPnl: new BigNumber(100) + .times(new BigNumber(10).pow(MYX_COLLATERAL_DECIMALS)) + .toFixed(0), + baseSymbol: 'BTC', + quoteSymbol: 'USDT', + userLeverage: 10, + ...overrides, + }; + } + + const poolSymbolMap = new Map([['0xpool1', 'BTC']]); + + it('adapts a filled order to OrderFill with correct fields', () => { + const result = adaptOrderFillFromMYX(makeHistoryOrder(), poolSymbolMap); + + expect(result.orderId).toBe('99'); + expect(result.symbol).toBe('BTC'); + expect(result.side).toBe('buy'); + expect(Number(result.size)).toBe(3); + expect(Number(result.price)).toBe(45100); // Uses lastPrice + expect(Number(result.fee)).toBe(5); + expect(Number(result.pnl)).toBe(100); + expect(result.feeToken).toBe('USDT'); + expect(result.success).toBe(true); + expect(result.orderType).toBe('regular'); + expect(result.providerId).toBe('myx'); + }); + + it('uses size as fallback when filledSize is empty', () => { + const result = adaptOrderFillFromMYX( + makeHistoryOrder({ filledSize: '' }), + poolSymbolMap, + ); + + expect(Number(result.size)).toBe(3); // Falls back to size + }); + + it('uses price as fallback when lastPrice is empty', () => { + const result = adaptOrderFillFromMYX( + makeHistoryOrder({ lastPrice: '' }), + poolSymbolMap, + ); + + expect(Number(result.price)).toBe(45000); // Falls back to price + }); + + it('maps TP exec type to take_profit', () => { + const result = adaptOrderFillFromMYX( + makeHistoryOrder({ execType: MYXExecTypeEnum.TP }), + poolSymbolMap, + ); + + expect(result.orderType).toBe('take_profit'); + }); + + it('maps SL exec type to stop_loss', () => { + const result = adaptOrderFillFromMYX( + makeHistoryOrder({ execType: MYXExecTypeEnum.SL }), + poolSymbolMap, + ); + + expect(result.orderType).toBe('stop_loss'); + }); + + it('maps Liquidation exec type', () => { + const result = adaptOrderFillFromMYX( + makeHistoryOrder({ execType: MYXExecTypeEnum.Liquidation }), + poolSymbolMap, + ); + + expect(result.orderType).toBe('liquidation'); + }); + + it('marks unsuccessful orders', () => { + const result = adaptOrderFillFromMYX( + makeHistoryOrder({ orderStatus: MYXOrderStatusEnum.Cancelled }), + poolSymbolMap, + ); + + expect(result.success).toBe(false); + }); + }); + + // ============================================================================ + // Funding Adapter + // ============================================================================ + + describe('adaptFundingFromMYX', () => { + function makeFlowItem( + overrides: Partial = {}, + ): MYXTradeFlowItem { + return { + chainId: 56, + orderId: 1, + user: '0xuser' as MYXTradeFlowItem['user'], + poolId: '0xpool1', + fundingFee: new BigNumber(10) + .times(new BigNumber(10).pow(MYX_COLLATERAL_DECIMALS)) + .toFixed(0), + tradingFee: '0', + charge: '0', + collateralAmount: '0', + collateralBase: '0', + txHash: '0xhash', + txTime: 1700000000, + type: MYXTradeFlowTypeEnum.Increase, + accountType: 1 as MYXTradeFlowItem['accountType'], + executionFee: '0', + seamlessFee: '0', + seamlessFeeSymbol: '', + basePnl: '0', + quotePnl: '0', + referrerRebate: '0', + referralRebate: '0', + rebateClaimedAmount: '0', + ...overrides, + }; + } + + const poolSymbolMap = new Map([['0xpool1', 'BTC']]); + + it('adapts flows with non-zero funding fees', () => { + const result = adaptFundingFromMYX([makeFlowItem()], poolSymbolMap); + + expect(result).toHaveLength(1); + expect(result[0].symbol).toBe('BTC'); + expect(Number(result[0].amountUsd)).toBe(10); + expect(result[0].transactionHash).toBe('0xhash'); + }); + + it('filters out flows with zero or empty funding fees', () => { + const flows = [ + makeFlowItem({ fundingFee: '0' }), + makeFlowItem({ fundingFee: '' }), + ]; + + const result = adaptFundingFromMYX(flows, poolSymbolMap); + + expect(result).toHaveLength(0); + }); + + it('falls back to poolId when symbol not in map', () => { + const emptyMap = new Map(); + const result = adaptFundingFromMYX([makeFlowItem()], emptyMap); + + expect(result[0].symbol).toBe('0xpool1'); + }); + }); + + // ============================================================================ + // User History Adapter + // ============================================================================ + + describe('adaptUserHistoryFromMYX', () => { + function makeFlowItem( + overrides: Partial = {}, + ): MYXTradeFlowItem { + return { + chainId: 56, + orderId: 1, + user: '0xuser' as MYXTradeFlowItem['user'], + poolId: '0xpool1', + fundingFee: '0', + tradingFee: '0', + charge: '0', + collateralAmount: new BigNumber(200) + .times(new BigNumber(10).pow(MYX_COLLATERAL_DECIMALS)) + .toFixed(0), + collateralBase: '0', + txHash: '0xhash', + txTime: 1700000000, + type: MYXTradeFlowTypeEnum.MarginAccountDeposit, + accountType: 1 as MYXTradeFlowItem['accountType'], + executionFee: '0', + seamlessFee: '0', + seamlessFeeSymbol: '', + basePnl: '0', + quotePnl: '0', + referrerRebate: '0', + referralRebate: '0', + rebateClaimedAmount: '0', + ...overrides, + }; + } + + it('adapts deposit flows', () => { + const result = adaptUserHistoryFromMYX([makeFlowItem()]); + + expect(result).toHaveLength(1); + expect(result[0].type).toBe('deposit'); + expect(Number(result[0].amount)).toBe(200); + expect(result[0].asset).toBe('USDT'); + expect(result[0].status).toBe('completed'); + }); + + it('adapts withdrawal flows', () => { + const result = adaptUserHistoryFromMYX([ + makeFlowItem({ type: MYXTradeFlowTypeEnum.TransferToWallet }), + ]); + + expect(result).toHaveLength(1); + expect(result[0].type).toBe('withdrawal'); + }); + + it('filters out non-deposit/withdrawal flow types', () => { + const result = adaptUserHistoryFromMYX([ + makeFlowItem({ type: MYXTradeFlowTypeEnum.Increase }), + makeFlowItem({ type: MYXTradeFlowTypeEnum.Decrease }), + makeFlowItem({ type: MYXTradeFlowTypeEnum.Liquidation }), + ]); + + expect(result).toHaveLength(0); + }); + }); + + // ============================================================================ + // Candle Adapters + // ============================================================================ + + describe('adaptCandleFromMYX', () => { + it('maps REST kline fields to CandleStick', () => { + const kline: MYXKlineData = { + time: 1700000000, + open: '50000', + close: '51000', + high: '52000', + low: '49000', + }; + + const result = adaptCandleFromMYX(kline); + + expect(result.time).toBe(1700000000); + expect(result.open).toBe('50000'); + expect(result.close).toBe('51000'); + expect(result.high).toBe('52000'); + expect(result.low).toBe('49000'); + expect(result.volume).toBe('0'); + }); + }); + + describe('adaptCandleFromMYXWebSocket', () => { + it('maps WS single-letter fields to CandleStick', () => { + const wsData: MYXKlineWsData = { + E: 1700000000, + T: '100', + t: 1700000000, + o: '50000', + h: '52000', + l: '49000', + c: '51000', + v: '1500', + }; + + const result = adaptCandleFromMYXWebSocket(wsData); + + expect(result.time).toBe(1700000000); + expect(result.open).toBe('50000'); + expect(result.high).toBe('52000'); + expect(result.low).toBe('49000'); + expect(result.close).toBe('51000'); + expect(result.volume).toBe('1500'); + }); + }); + + // ============================================================================ + // Resolution Mapper + // ============================================================================ + + describe('toMYXKlineResolution', () => { + it.each([ + ['1m', '1m'], + ['5m', '5m'], + ['15m', '15m'], + ['1h', '1h'], + ['4h', '4h'], + ['1d', '1d'], + ['1w', '1w'], + ['1M', '1M'], + ] as const)('maps %s to %s', (input, expected) => { + expect(toMYXKlineResolution(input)).toBe(expected); + }); + + it.each([ + ['3m', '5m'], + ['2h', '4h'], + ['8h', '4h'], + ['12h', '1d'], + ['3d', '1w'], + ] as const)('maps unsupported %s to nearest %s', (input, expected) => { + expect(toMYXKlineResolution(input)).toBe(expected); + }); + + it('defaults unknown periods to 1h', () => { + expect(toMYXKlineResolution('99x')).toBe('1h'); + }); + }); + + // ============================================================================ + // Response Validation + // ============================================================================ + + describe('assertMYXSuccess', () => { + it('does not throw for code 9200', () => { + expect(() => assertMYXSuccess({ code: 9200 }, 'test')).not.toThrow(); + }); + + it('does not throw for code 0', () => { + expect(() => assertMYXSuccess({ code: 0 }, 'test')).not.toThrow(); + }); + + it('throws for non-success code', () => { + expect(() => + assertMYXSuccess({ code: 500, message: 'Server Error' }, 'fetch'), + ).toThrow('MYX fetch failed: code=500 message=Server Error'); + }); + + it('includes "unknown" when message is null', () => { + expect(() => + assertMYXSuccess({ code: 400, message: null }, 'auth'), + ).toThrow('MYX auth failed: code=400 message=unknown'); + }); + }); +}); diff --git a/packages/perps-controller/tests/src/utils/orderCalculations.advanced-orders.test.ts b/packages/perps-controller/tests/src/utils/orderCalculations.advanced-orders.test.ts new file mode 100644 index 00000000000..27710738ee3 --- /dev/null +++ b/packages/perps-controller/tests/src/utils/orderCalculations.advanced-orders.test.ts @@ -0,0 +1,380 @@ +import { ORDER_SLIPPAGE_CONFIG } from '../../../src/constants/perpsConfig.js'; +import { PERPS_ERROR_CODES } from '../../../src/perpsErrorCodes.js'; +import type { OrderType } from '../../../src/types/perps-types.js'; +import { + buildOrdersArray, + calculateOrderPriceAndSize, +} from '../../../src/utils/orderCalculations.js'; + +const SZ_DECIMALS = 3; + +const baseBuildParams = { + assetId: 0, + isBuy: true, + formattedPrice: '50000', + formattedSize: '0.1', + reduceOnly: false, + orderType: 'market' as OrderType, + szDecimals: SZ_DECIMALS, +}; + +describe('orderCalculations - advanced order types', () => { + describe('calculateOrderPriceAndSize', () => { + it('prices a stop limit order at the limit price', () => { + const result = calculateOrderPriceAndSize({ + orderType: 'stop_limit', + isBuy: false, + finalPositionSize: 0.1, + currentPrice: 50000, + limitPrice: '44000', + triggerPrice: '45000', + szDecimals: SZ_DECIMALS, + }); + + expect(result.orderPrice).toBe(44000); + expect(result.formattedSize).toBe('0.1'); + }); + + it('caps a stop market sell at the trigger price minus TP/SL slippage', () => { + const slippage = ORDER_SLIPPAGE_CONFIG.DefaultTpslSlippageBps / 10_000; + + const result = calculateOrderPriceAndSize({ + orderType: 'stop_market', + isBuy: false, + finalPositionSize: 0.1, + currentPrice: 50000, + triggerPrice: '45000', + szDecimals: SZ_DECIMALS, + }); + + expect(result.orderPrice).toBeCloseTo(45000 * (1 - slippage), 6); + }); + + it('caps a take profit market buy at the trigger price plus TP/SL slippage', () => { + const slippage = ORDER_SLIPPAGE_CONFIG.DefaultTpslSlippageBps / 10_000; + + const result = calculateOrderPriceAndSize({ + orderType: 'take_profit_market', + isBuy: true, + finalPositionSize: 0.1, + currentPrice: 50000, + triggerPrice: '55000', + szDecimals: SZ_DECIMALS, + }); + + expect(result.orderPrice).toBeCloseTo(55000 * (1 + slippage), 6); + }); + + it('honours the caller slippage tolerance on a market-executing trigger', () => { + const result = calculateOrderPriceAndSize({ + orderType: 'stop_market', + isBuy: false, + finalPositionSize: 0.1, + currentPrice: 50000, + triggerPrice: '45000', + maxSlippageBps: 100, + szDecimals: SZ_DECIMALS, + }); + + // 100 bps below the trigger, not the 10% TP/SL default + expect(result.orderPrice).toBeCloseTo(45000 * (1 - 0.01), 6); + }); + + it('falls back to the TP/SL slippage default when none is supplied', () => { + const slippage = ORDER_SLIPPAGE_CONFIG.DefaultTpslSlippageBps / 10_000; + + const result = calculateOrderPriceAndSize({ + orderType: 'take_profit_market', + isBuy: true, + finalPositionSize: 0.1, + currentPrice: 50000, + triggerPrice: '55000', + szDecimals: SZ_DECIMALS, + }); + + expect(result.orderPrice).toBeCloseTo(55000 * (1 + slippage), 6); + }); + + it('ignores the slippage setting for limit-executing triggers', () => { + const result = calculateOrderPriceAndSize({ + orderType: 'stop_limit', + isBuy: false, + finalPositionSize: 0.1, + currentPrice: 50000, + limitPrice: '44500', + triggerPrice: '45000', + maxSlippageBps: 100, + szDecimals: SZ_DECIMALS, + }); + + expect(result.orderPrice).toBe(44500); + }); + + it('throws a typed error when a trigger placement has no trigger price', () => { + expect(() => + calculateOrderPriceAndSize({ + orderType: 'stop_market', + isBuy: false, + finalPositionSize: 0.1, + currentPrice: 50000, + szDecimals: SZ_DECIMALS, + }), + ).toThrow(PERPS_ERROR_CODES.ORDER_TRIGGER_PRICE_REQUIRED); + }); + + it('throws a typed error when the trigger price is not positive', () => { + expect(() => + calculateOrderPriceAndSize({ + orderType: 'take_profit_limit', + isBuy: false, + finalPositionSize: 0.1, + currentPrice: 50000, + limitPrice: '55000', + triggerPrice: '0', + szDecimals: SZ_DECIMALS, + }), + ).toThrow(PERPS_ERROR_CODES.ORDER_TRIGGER_PRICE_POSITIVE); + }); + + it('throws a typed error when a limit-executing trigger has no limit price', () => { + expect(() => + calculateOrderPriceAndSize({ + orderType: 'stop_limit', + isBuy: false, + finalPositionSize: 0.1, + currentPrice: 50000, + triggerPrice: '45000', + szDecimals: SZ_DECIMALS, + }), + ).toThrow(PERPS_ERROR_CODES.ORDER_LIMIT_PRICE_REQUIRED); + }); + + it('leaves plain market and limit pricing untouched', () => { + const market = calculateOrderPriceAndSize({ + orderType: 'market', + isBuy: true, + finalPositionSize: 0.1, + currentPrice: 50000, + maxSlippageBps: 100, + szDecimals: SZ_DECIMALS, + }); + expect(market.orderPrice).toBeCloseTo(50000 * 1.01, 6); + + const limit = calculateOrderPriceAndSize({ + orderType: 'limit', + isBuy: true, + finalPositionSize: 0.1, + currentPrice: 50000, + limitPrice: '49000', + szDecimals: SZ_DECIMALS, + }); + expect(limit.orderPrice).toBe(49000); + }); + }); + + describe('buildOrdersArray', () => { + it.each([ + ['stop_market', { isMarket: true, tpsl: 'sl' }], + ['stop_limit', { isMarket: false, tpsl: 'sl' }], + ['take_profit_market', { isMarket: true, tpsl: 'tp' }], + ['take_profit_limit', { isMarket: false, tpsl: 'tp' }], + ] as [OrderType, { isMarket: boolean; tpsl: string }][])( + 'maps %s onto the SDK trigger shape', + (orderType, expected) => { + const { orders, grouping } = buildOrdersArray({ + ...baseBuildParams, + orderType, + triggerPrice: '45000', + }); + + expect(orders).toHaveLength(1); + expect(orders[0].t).toStrictEqual({ + trigger: { + isMarket: expected.isMarket, + triggerPx: '45000', + tpsl: expected.tpsl, + }, + }); + // A standalone trigger order has no attached children + expect(grouping).toBe('na'); + }, + ); + + it('passes the reduce-only flag through for trigger placements', () => { + const { orders } = buildOrdersArray({ + ...baseBuildParams, + orderType: 'stop_market', + triggerPrice: '45000', + reduceOnly: true, + }); + + expect(orders[0].r).toBe(true); + }); + + it('formats the trigger price to the asset precision', () => { + const { orders } = buildOrdersArray({ + ...baseBuildParams, + orderType: 'stop_limit', + triggerPrice: '45.123456789', + }); + + expect(orders[0].t).toStrictEqual({ + trigger: { + isMarket: false, + triggerPx: '45.123', + tpsl: 'sl', + }, + }); + }); + + it('throws a typed error when a trigger placement has no trigger price', () => { + expect(() => + buildOrdersArray({ + ...baseBuildParams, + orderType: 'take_profit_limit', + }), + ).toThrow(PERPS_ERROR_CODES.ORDER_TRIGGER_PRICE_REQUIRED); + }); + + it.each(['takeProfitSize', 'stopLossSize'] as const)( + 'throws a typed error when %s rounds to zero at the asset precision', + (sizeField) => { + // 0.0004 is positive but below szDecimals: 3, so it formats to '0'. + // A zero-sized trigger reads as whole-position on HyperLiquid, which + // would turn a partial TP/SL into a full close. + expect(() => + buildOrdersArray({ + ...baseBuildParams, + takeProfitPrice: '60000', + stopLossPrice: '40000', + [sizeField]: '0.0004', + }), + ).toThrow(PERPS_ERROR_CODES.ORDER_TPSL_SIZE_INVALID); + }, + ); + + it('keeps a partial TP/SL size that survives the asset precision', () => { + const { orders } = buildOrdersArray({ + ...baseBuildParams, + takeProfitPrice: '60000', + takeProfitSize: '0.004', + }); + + expect(orders[1].s).toBe('0.004'); + }); + + it('throws a typed error when the trigger price rounds to zero at the asset precision', () => { + // 0.0004 is positive, but a szDecimals: 3 asset prices to 3 decimals, so + // it formats to '0' — a triggerPx the SDK rejects outright. + expect(() => + buildOrdersArray({ + ...baseBuildParams, + orderType: 'stop_market', + triggerPrice: '0.0004', + }), + ).toThrow(PERPS_ERROR_CODES.ORDER_TRIGGER_PRICE_POSITIVE); + }); + + it.each(['takeProfitPrice', 'stopLossPrice'] as const)( + 'throws a typed error when %s rounds to zero at the asset precision', + (priceField) => { + expect(() => + buildOrdersArray({ + ...baseBuildParams, + [priceField]: '0.0004', + }), + ).toThrow(PERPS_ERROR_CODES.ORDER_PRICE_POSITIVE); + }, + ); + + it('keeps a trigger price that survives the asset precision', () => { + const { orders } = buildOrdersArray({ + ...baseBuildParams, + orderType: 'stop_market', + triggerPrice: '0.004', + }); + + expect(orders[0].t).toStrictEqual({ + trigger: { isMarket: true, triggerPx: '0.004', tpsl: 'sl' }, + }); + }); + + it('maps each public limit time-in-force value to the SDK', () => { + expect( + buildOrdersArray({ ...baseBuildParams, orderType: 'market' }).orders[0] + .t, + ).toStrictEqual({ limit: { tif: 'FrontendMarket' } }); + expect( + buildOrdersArray({ + ...baseBuildParams, + orderType: 'limit', + timeInForce: 'GTC', + }).orders[0].t, + ).toStrictEqual({ limit: { tif: 'Gtc' } }); + expect( + buildOrdersArray({ + ...baseBuildParams, + orderType: 'limit', + timeInForce: 'IOC', + }).orders[0].t, + ).toStrictEqual({ limit: { tif: 'Ioc' } }); + expect( + buildOrdersArray({ + ...baseBuildParams, + orderType: 'limit', + timeInForce: 'ALO', + }).orders[0].t, + ).toStrictEqual({ limit: { tif: 'Alo' } }); + }); + + it('rejects time in force where the SDK order shape cannot carry it', () => { + expect(() => + buildOrdersArray({ + ...baseBuildParams, + orderType: 'stop_limit', + triggerPrice: '45000', + timeInForce: 'ALO', + }), + ).toThrow(PERPS_ERROR_CODES.ORDER_TIME_IN_FORCE_NOT_SUPPORTED); + }); + + it('scopes attached TP/SL orders to their partial sizes', () => { + const { orders, grouping } = buildOrdersArray({ + ...baseBuildParams, + formattedSize: '1', + takeProfitPrice: '60000', + takeProfitSize: '0.4', + stopLossPrice: '45000', + stopLossSize: '0.6', + }); + + expect(grouping).toBe('normalTpsl'); + expect(orders).toHaveLength(3); + expect(orders[1].s).toBe('0.4'); + expect(orders[2].s).toBe('0.6'); + }); + + it('defaults attached TP/SL orders to the full order size', () => { + const { orders } = buildOrdersArray({ + ...baseBuildParams, + formattedSize: '1', + takeProfitPrice: '60000', + stopLossPrice: '45000', + }); + + expect(orders[1].s).toBe('1'); + expect(orders[2].s).toBe('1'); + }); + + it('formats partial TP/SL sizes to the asset precision', () => { + const { orders } = buildOrdersArray({ + ...baseBuildParams, + formattedSize: '1', + takeProfitPrice: '60000', + takeProfitSize: '0.123456', + }); + + expect(orders[1].s).toBe('0.123'); + }); + }); +}); diff --git a/packages/perps-controller/tests/src/utils/orderCalculations.scale-ladder.test.ts b/packages/perps-controller/tests/src/utils/orderCalculations.scale-ladder.test.ts new file mode 100644 index 00000000000..6442c97770f --- /dev/null +++ b/packages/perps-controller/tests/src/utils/orderCalculations.scale-ladder.test.ts @@ -0,0 +1,292 @@ +import { PERPS_ERROR_CODES } from '../../../src/perpsErrorCodes.js'; +import { + computeChaseQuotePrice, + computeScalePriceLadder, + getPriceTick, + splitScaleSizes, +} from '../../../src/utils/orderCalculations.js'; + +describe('orderCalculations - scale ladder', () => { + describe('computeScalePriceLadder', () => { + it('spreads the rungs evenly and lands on both bounds', () => { + expect( + computeScalePriceLadder({ minPrice: 2000, maxPrice: 3000, count: 3 }), + ).toStrictEqual([2000, 2500, 3000]); + }); + + it('places exactly the requested number of rungs', () => { + expect( + computeScalePriceLadder({ minPrice: 100, maxPrice: 200, count: 5 }), + ).toStrictEqual([100, 125, 150, 175, 200]); + }); + + it('lands exactly on maxPrice where accumulation would drift', () => { + const ladder = computeScalePriceLadder({ + minPrice: 0.1, + maxPrice: 0.4, + count: 4, + }); + + expect(ladder[0]).toBe(0.1); + expect(ladder[ladder.length - 1]).toBe(0.4); + }); + + it.each([ + ['a single rung', 1], + ['zero rungs', 0], + ['a fractional count', 3.5], + ['more rungs than supported', 21], + ])('rejects %s', (_label, count) => { + expect(() => + computeScalePriceLadder({ minPrice: 2000, maxPrice: 3000, count }), + ).toThrow(PERPS_ERROR_CODES.ORDER_SCALE_COUNT_INVALID); + }); + + it.each([ + ['an inverted range', 3000, 2000], + ['a degenerate range', 2000, 2000], + ['a non-positive lower bound', 0, 2000], + ['a non-finite bound', 2000, NaN], + ])('rejects %s', (_label, minPrice, maxPrice) => { + expect(() => + computeScalePriceLadder({ minPrice, maxPrice, count: 3 }), + ).toThrow(PERPS_ERROR_CODES.ORDER_SCALE_RANGE_INVALID); + }); + }); + + describe('splitScaleSizes', () => { + it('splits a size that divides evenly', () => { + expect( + splitScaleSizes({ totalSize: 0.6, count: 3, szDecimals: 4 }), + ).toStrictEqual(['0.2', '0.2', '0.2']); + }); + + it('gives the indivisible remainder to the first rung so the total is exact', () => { + const sizes = splitScaleSizes({ + totalSize: 1, + count: 3, + szDecimals: 2, + }); + + expect(sizes).toStrictEqual(['0.34', '0.33', '0.33']); + expect( + sizes.reduce((total, size) => total + parseFloat(size), 0), + ).toBeCloseTo(1, 10); + }); + + it('formats slices the way every other submitted size is formatted', () => { + expect( + splitScaleSizes({ totalSize: 3, count: 3, szDecimals: 0 }), + ).toStrictEqual(['1', '1', '1']); + }); + + it('rejects a total too small to give every rung a slice', () => { + expect(() => + splitScaleSizes({ totalSize: 0.02, count: 3, szDecimals: 2 }), + ).toThrow(PERPS_ERROR_CODES.ORDER_SCALE_SIZE_TOO_SMALL); + }); + }); + + describe('splitScaleSizes - skew', () => { + // The ticket's worked example, taken all the way onto the size grid: total + // 100 over 5 rungs at skew 2 gives weights 1, 1.25, 1.5, 1.75, 2 and ideal + // slices 13.33, 16.67, 20, 23.33, 26.67. The two largest discarded + // fractions are rungs 1 and 4, so they take the two leftover units. + it('ramps the weights linearly and lands the leftover on the largest fractions', () => { + expect( + splitScaleSizes({ + totalSize: 100, + count: 5, + szDecimals: 0, + skew: 2, + }), + ).toStrictEqual(['13', '17', '20', '23', '27']); + }); + + it('weights the top of the ladder when the skew is above 1', () => { + const sizes = splitScaleSizes({ + totalSize: 1, + count: 5, + szDecimals: 2, + skew: 2, + }).map((size) => parseFloat(size)); + + // The ladder runs scaleMinPrice -> scaleMaxPrice, so the last rung is the + // one at scaleMaxPrice. + expect(Math.max(...sizes)).toBe(sizes[sizes.length - 1]); + expect(sizes).toStrictEqual([0.13, 0.17, 0.2, 0.23, 0.27]); + }); + + it('weights the bottom of the ladder when the skew is below 1', () => { + const sizes = splitScaleSizes({ + totalSize: 1, + count: 5, + szDecimals: 2, + skew: 0.5, + }).map((size) => parseFloat(size)); + + expect(Math.max(...sizes)).toBe(sizes[0]); + expect(sizes).toStrictEqual([0.27, 0.23, 0.2, 0.17, 0.13]); + }); + + // A short ladder is still built low price to high price: the skew weights + // the range, not the direction of the trade. + it('does not flip for a sell', () => { + expect( + splitScaleSizes({ totalSize: 100, count: 5, szDecimals: 0, skew: 2 }), + ).toStrictEqual(['13', '17', '20', '23', '27']); + }); + + it('breaks a tie in the discarded fraction by the lower index', () => { + // Weights 1 and 3 over 10 units give 2.5 and 7.5 — one leftover unit and + // two equal fractions. + expect( + splitScaleSizes({ totalSize: 10, count: 2, szDecimals: 0, skew: 3 }), + ).toStrictEqual(['3', '7']); + }); + + it.each([ + ['above 1', 2], + ['below 1', 0.5], + ['far above 1', 100], + ['far below 1', 0.01], + ])( + 'sums to the requested total in grid units with a skew %s', + (_label, skew) => { + const sizes = splitScaleSizes({ + totalSize: 1, + count: 7, + szDecimals: 3, + skew, + }); + + const units = sizes.reduce( + (total, size) => total + Math.round(parseFloat(size) * 1000), + 0, + ); + expect(units).toBe(1000); + }, + ); + + it('still fills every rung under a very high skew', () => { + // Weights 1, 50.5, 100 over 100 units: the first rung's ideal slice is + // 0.66 of a unit, and the leftover unit is what keeps it non-zero. + expect( + splitScaleSizes({ totalSize: 1, count: 3, szDecimals: 2, skew: 100 }), + ).toStrictEqual(['0.01', '0.33', '0.66']); + }); + + it('rejects a skew that starves a rung of every unit', () => { + // Same weights, but three units to go round: the first rung's ideal slice + // is 0.02 and there is no leftover left to round it up with. + expect(() => + splitScaleSizes({ + totalSize: 0.03, + count: 3, + szDecimals: 2, + skew: 100, + }), + ).toThrow(PERPS_ERROR_CODES.ORDER_SCALE_SIZE_TOO_SMALL); + }); + + it.each([ + ['zero', 0], + ['negative', -2], + ['NaN', NaN], + ['Infinity', Infinity], + ['-Infinity', -Infinity], + ])('rejects %s', (_label, skew) => { + expect(() => + splitScaleSizes({ totalSize: 1, count: 3, szDecimals: 2, skew }), + ).toThrow(PERPS_ERROR_CODES.ORDER_SCALE_RANGE_INVALID); + }); + + it('splits exactly as an omitted skew does when the skew is 1', () => { + const even = splitScaleSizes({ totalSize: 1, count: 3, szDecimals: 2 }); + + expect(even).toStrictEqual(['0.34', '0.33', '0.33']); + expect( + splitScaleSizes({ totalSize: 1, count: 3, szDecimals: 2, skew: 1 }), + ).toStrictEqual(even); + }); + }); + + describe('splitScaleSizes - rung count', () => { + // Exported on its own, so it cannot rely on computeScalePriceLadder having + // vetted the count first: zero would return an empty split, and a + // fractional count slices that do not sum to the total. + it.each([ + ['zero', 0], + ['negative', -3], + ['a single rung', 1], + ['fractional', 2.5], + ['above the supported ladder size', 21], + ])('rejects %s', (_label, count) => { + expect(() => + splitScaleSizes({ totalSize: 1, count, szDecimals: 4 }), + ).toThrow(PERPS_ERROR_CODES.ORDER_SCALE_COUNT_INVALID); + }); + }); + + describe('computeChaseQuotePrice', () => { + // The venue's definition: one tick above the best bid for a buy, one tick + // below the best ask for a sell, or the touch itself when the spread is a + // single tick. ETH-like precision (szDecimals 4) gives a 0.1 tick at ~3000. + it('rests one tick above the best bid for a buy', () => { + expect( + computeChaseQuotePrice({ + bestBid: 2999, + bestAsk: 3001, + isBuy: true, + szDecimals: 4, + }), + ).toBe('2999.1'); + }); + + it('rests one tick below the best ask for a sell', () => { + expect( + computeChaseQuotePrice({ + bestBid: 2999, + bestAsk: 3001, + isBuy: false, + szDecimals: 4, + }), + ).toBe('3000.9'); + }); + + it.each([true, false])( + 'joins the touch on a single-tick spread (isBuy=%s)', + (isBuy) => { + expect( + computeChaseQuotePrice({ + bestBid: 2999.9, + bestAsk: 3000, + isBuy, + szDecimals: 4, + }), + ).toBe(isBuy ? '2999.9' : '3000'); + }, + ); + + it('widens the tick with the price, as the venue does', () => { + // At 50000 the five-significant-figure cap makes the tick 1, not 0.01. + expect( + computeChaseQuotePrice({ + bestBid: 50000, + bestAsk: 50100, + isBuy: true, + szDecimals: 3, + }), + ).toBe('50001'); + }); + }); + + describe('getPriceTick', () => { + it.each([ + ['decimal-bound at a low price', 12, 4, 0.01], + ['significant-figure-bound at a high price', 50000, 3, 1], + ])('is %s', (_label, price, szDecimals, expected) => { + expect(getPriceTick({ price, szDecimals })).toBeCloseTo(expected, 10); + }); + }); +}); diff --git a/packages/perps-controller/tests/src/utils/orderCalculations.test.ts b/packages/perps-controller/tests/src/utils/orderCalculations.test.ts new file mode 100644 index 00000000000..814e488adee --- /dev/null +++ b/packages/perps-controller/tests/src/utils/orderCalculations.test.ts @@ -0,0 +1,615 @@ +import { PERPS_ERROR_CODES } from '../../../src/perpsErrorCodes.js'; +import { + calculateFinalPositionSize, + floorToSizeDecimals, + getMaxAllowedAmount, +} from '../../../src/utils/orderCalculations.js'; + +/** + * Margin HyperLiquid reserves for a resting order, which is based on the price + * the order is submitted at, not the market price the size was derived from. + * + * @param options - The order details. + * @param options.usdAmount - Order notional in USD, priced at the market price. + * @param options.assetPrice - Current market (mid) price of the asset. + * @param options.assetSzDecimals - Size decimals for the asset. + * @param options.leverage - Leverage the order is placed with. + * @param options.orderPrice - Price the order is submitted at. + * @returns The margin the exchange reserves for the resulting order. + */ +function exchangeRequiredMargin({ + usdAmount, + assetPrice, + assetSzDecimals, + leverage, + orderPrice, +}: { + usdAmount: number; + assetPrice: number; + assetSzDecimals: number; + leverage: number; + orderPrice: number; +}): number { + const { finalPositionSize } = calculateFinalPositionSize({ + usdAmount: usdAmount.toString(), + currentPrice: assetPrice, + szDecimals: assetSzDecimals, + leverage, + }); + + return (finalPositionSize * orderPrice) / leverage; +} + +describe('getMaxAllowedAmount', () => { + it('returns 0 when inputs are missing', () => { + expect( + getMaxAllowedAmount({ + spendableBalance: 0, + assetPrice: 100000, + assetSzDecimals: 5, + leverage: 3, + }), + ).toBe(0); + + expect( + getMaxAllowedAmount({ + spendableBalance: 100, + assetPrice: 0, + assetSzDecimals: 5, + leverage: 3, + }), + ).toBe(0); + }); + + // Regression: TAT-3344 - "order 0: insufficient margin to place order". + // A limit order resting above the market price has margin reserved at that + // submitted price, so the max must be sized off it and not off the mid. + it('keeps a max limit order resting above the market price within the balance', () => { + const spendableBalance = 1000; + const assetPrice = 100000; + const assetSzDecimals = 5; + const leverage = 5; + const limitPrice = assetPrice * 1.05; + + const maxAmount = getMaxAllowedAmount({ + spendableBalance, + assetPrice, + assetSzDecimals, + leverage, + orderType: 'limit', + limitPrice, + }); + + expect(maxAmount).toBeGreaterThan(0); + expect( + exchangeRequiredMargin({ + usdAmount: maxAmount, + assetPrice, + assetSzDecimals, + leverage, + orderPrice: limitPrice, + }), + ).toBeLessThanOrEqual(spendableBalance); + }); + + it('scales the reduction with how far above the market price the order rests', () => { + const params = { + spendableBalance: 1000, + assetPrice: 100000, + assetSzDecimals: 5, + leverage: 5, + orderType: 'limit' as const, + }; + + const near = getMaxAllowedAmount({ + ...params, + limitPrice: params.assetPrice * 1.02, + }); + const far = getMaxAllowedAmount({ + ...params, + limitPrice: params.assetPrice * 1.2, + }); + + expect(far).toBeLessThan(near); + expect( + exchangeRequiredMargin({ + usdAmount: far, + assetPrice: params.assetPrice, + assetSzDecimals: params.assetSzDecimals, + leverage: params.leverage, + orderPrice: params.assetPrice * 1.2, + }), + ).toBeLessThanOrEqual(params.spendableBalance); + }); + + it('does not reduce the max for orders that are not priced above the market', () => { + const params = { + spendableBalance: 1000, + assetPrice: 100000, + assetSzDecimals: 5, + leverage: 5, + }; + const uncapped = getMaxAllowedAmount(params); + + // A resting buy sits below the market price, so it reserves less margin. + expect( + getMaxAllowedAmount({ + ...params, + orderType: 'limit', + limitPrice: params.assetPrice * 0.9, + }), + ).toBe(uncapped); + + // A marketable order is charged at the fill price, not the padded price it + // is submitted with, so it is unaffected too. + expect(getMaxAllowedAmount({ ...params, orderType: 'market' })).toBe( + uncapped, + ); + + expect(uncapped).toBeLessThanOrEqual( + params.spendableBalance * params.leverage, + ); + }); + + it('steps down by one size increment when rounding pushes margin over the balance', () => { + // szDecimals 0 means the size increment is a whole unit, so the rounded-up + // size can exceed the balance without the step-down. + const spendableBalance = 100; + const assetPrice = 30; + const assetSzDecimals = 0; + const leverage = 3; + + const maxAmount = getMaxAllowedAmount({ + spendableBalance, + assetPrice, + assetSzDecimals, + leverage, + }); + + expect( + exchangeRequiredMargin({ + usdAmount: maxAmount, + assetPrice, + assetSzDecimals, + leverage, + orderPrice: assetPrice, + }), + ).toBeLessThanOrEqual(spendableBalance); + }); +}); + +describe('floorToSizeDecimals', () => { + it('never returns a size larger than its input', () => { + // The invariant the helper exists to enforce: a reduce-only size may be + // reduced, never increased. Covers grid-aligned values, values just below + // and just above a grid point, dust, and magnitudes where the tolerance + // exceeds half a grid increment. + const cases: [number, number][] = [ + [0.1229999995, 3], + [3000000000.000006, 5], + [0.123, 3], + [0.12356, 3], + [1048580.0694, 5], + [0.0004, 3], + [0.1, 3], + [1.5, 4], + [0.099999999999, 3], + [123456789.123456, 5], + [1e-8, 5], + [0, 3], + ]; + + for (const [size, szDecimals] of cases) { + expect(floorToSizeDecimals(size, szDecimals)).toBeLessThanOrEqual(size); + } + }); + + it('truncates a value that sits just below a grid point instead of snapping up', () => { + // 0.1229999995 * 1000 = 122.9999995, within the old tolerance of 123, so the + // previous implementation returned 0.123 — larger than the input + expect(floorToSizeDecimals(0.1229999995, 3)).toBe(0.122); + }); + + it('truncates at magnitudes where the tolerance exceeds half a grid increment', () => { + // 3000000000.000006 * 1e5 scales past 1e14, where a magnitude-scaled + // tolerance is wider than one increment and would round to nearest + expect(floorToSizeDecimals(3000000000.000006, 5)).toBeLessThanOrEqual( + 3000000000.000006, + ); + expect(floorToSizeDecimals(3000000000.000006, 5)).toBe(3000000000); + }); + + it('still recovers a grid-aligned value whose scaled form lands just below the grid point', () => { + // 0.123 * 1000 === 122.99999999999999 and 1048580.0694 * 1e5 is 1.5e-5 off + // its grid point: both are exactly representable, so neither may be shaved + expect(floorToSizeDecimals(0.123, 3)).toBe(0.123); + expect(floorToSizeDecimals(1048580.0694, 5)).toBe(1048580.0694); + }); + + it('holds the invariant for inputs a fraction of an ulp below a grid point', () => { + // size * multiplier rounds to exactly the grid integer for these, so + // flooring the scaled value returns the grid point the input sits below + expect(floorToSizeDecimals(0.8999999999999999, 1)).toBeLessThanOrEqual( + 0.8999999999999999, + ); + expect(floorToSizeDecimals(0.11699999999999999, 3)).toBeLessThanOrEqual( + 0.11699999999999999, + ); + }); + + it('holds the invariant across a sweep of one-ulp-below-grid inputs', () => { + // The case the earlier boundary/property tests never sampled: grid points + // approached from below by 1-3 ulp, across every supported precision + for (let szDecimals = 0; szDecimals <= 6; szDecimals += 1) { + const multiplier = Math.pow(10, szDecimals); + for (let gridPoint = 1; gridPoint <= 500; gridPoint += 1) { + let size = gridPoint / multiplier; + for (let ulp = 0; ulp < 3; ulp += 1) { + size -= Math.abs(size) * Number.EPSILON; + expect(floorToSizeDecimals(size, szDecimals)).toBeLessThanOrEqual( + size, + ); + } + } + } + }); + + it('terminates and holds the invariant where the scaled size reaches 2^53', () => { + // Past 2^53 `units -= 1` is a no-op, so a step-down loop cannot converge. + // Each case must return (the test timing out is the failure mode) and must + // still not exceed its input. + const cases: [number, number][] = [ + [998999999.9999998, 8], + [902999999999.9995, 4], + [8390000000000000, 6], + [1e18, 7], + [Number.MAX_SAFE_INTEGER, 0], + [Number.MAX_VALUE, 2], + [Infinity, 3], + ]; + + for (const [size, szDecimals] of cases) { + const result = floorToSizeDecimals(size, szDecimals); + + expect(Number.isNaN(result)).toBe(false); + expect(result).toBeLessThanOrEqual(size); + } + }); + + it('holds the invariant for negative sizes', () => { + // For a negative input the tolerance snap rounds towards zero, i.e. upward, + // so the step-down has to run for negatives too + expect(floorToSizeDecimals(-1.0000000001, 0)).toBeLessThanOrEqual( + -1.0000000001, + ); + expect(floorToSizeDecimals(-0.1220000001, 3)).toBeLessThanOrEqual( + -0.1220000001, + ); + expect(floorToSizeDecimals(-1.5, 4)).toBe(-1.5); + expect(floorToSizeDecimals(-0.123, 3)).toBe(-0.123); + }); + + it('holds the invariant across a sweep of negative one-tick-below-grid inputs', () => { + for (let szDecimals = 0; szDecimals <= 5; szDecimals += 1) { + const multiplier = Math.pow(10, szDecimals); + for (let gridPoint = 1; gridPoint <= 400; gridPoint += 1) { + let size = -(gridPoint / multiplier); + for (let tick = 0; tick < 3; tick += 1) { + size -= Math.abs(size) * Number.EPSILON; + expect(floorToSizeDecimals(size, szDecimals)).toBeLessThanOrEqual( + size, + ); + } + } + } + }); + + it('holds the invariant for negative sizes below one grid increment', () => { + // Below the tolerance the snap produces -0, which `units !== 0` alone cannot + // step down because -0 === 0. This region sits under one grid increment, so + // the grid-point sweeps never reach it. + expect(floorToSizeDecimals(-1e-9, 0)).toBeLessThanOrEqual(-1e-9); + expect(floorToSizeDecimals(-5e-7, 0)).toBeLessThanOrEqual(-5e-7); + expect(floorToSizeDecimals(-1e-12, 5)).toBeLessThanOrEqual(-1e-12); + expect(floorToSizeDecimals(-1e-8, 2)).toBeLessThanOrEqual(-1e-8); + // A negative zero is already its own floor and must not be stepped down + expect(floorToSizeDecimals(-0, 3)).toBe(-0); + // A positive sub-tolerance size still floors to zero + expect(floorToSizeDecimals(1e-9, 3)).toBe(0); + }); + + it('holds the invariant across a sweep of sub-increment negative magnitudes', () => { + // Magnitudes from 1e-14 up to 1e-3 across every supported precision: spans + // sub-tolerance (snap-to--0), sub-increment, and above-increment regions + for (let szDecimals = 0; szDecimals <= 5; szDecimals += 1) { + for (const magnitude of [ + 1e-14, 1e-12, 1e-10, 1e-9, 5e-7, 1e-6, 1e-5, 1e-3, + ]) { + for (let step = 1; step <= 40; step += 1) { + const size = -(magnitude * step); + expect(floorToSizeDecimals(size, szDecimals)).toBeLessThanOrEqual( + size, + ); + } + } + } + }); + + it('terminates and holds the invariant for negative sizes past 2^53', () => { + const cases: [number, number][] = [ + [-998999999.9999998, 8], + [-8390000000000000, 6], + [-Number.MAX_VALUE, 2], + [-Infinity, 3], + ]; + + for (const [size, szDecimals] of cases) { + const result = floorToSizeDecimals(size, szDecimals); + + expect(Number.isNaN(result)).toBe(false); + expect(result).toBeLessThanOrEqual(size); + } + }); + + it('holds the invariant across a swept range of sizes and precisions', () => { + // Property sweep: every result must be <= its input and on the size grid + for (const szDecimals of [0, 1, 3, 5]) { + const multiplier = Math.pow(10, szDecimals); + for (let step = 0; step < 400; step += 1) { + const size = step * 0.00731 + step / 997; + const result = floorToSizeDecimals(size, szDecimals); + + expect(result).toBeLessThanOrEqual(size); + expect( + Math.abs(result * multiplier - Math.round(result * multiplier)), + ).toBeLessThan(1e-6); + } + } + }); +}); + +describe('calculateFinalPositionSize', () => { + describe('USD amount as source of truth', () => { + it('rounds to the size grid and tops up to meet the requested USD', () => { + // 5000.5 / 50000 = 0.10001 → rounds to 0.1, whose notional (5000) falls + // short of the request, so one increment is added + const { finalPositionSize } = calculateFinalPositionSize({ + usdAmount: '5000.5', + currentPrice: 50000, + szDecimals: 3, + }); + + expect(finalPositionSize).toBeCloseTo(0.101, 10); + }); + + it('rounds down and skips the top-up for reduce-only orders', () => { + // The top-up would submit more than the position holds, which HyperLiquid + // rejects with "Reduce only order would increase position" + const { finalPositionSize } = calculateFinalPositionSize({ + usdAmount: '5000.5', + currentPrice: 50000, + szDecimals: 3, + reduceOnly: true, + }); + + expect(finalPositionSize).toBeCloseTo(0.1, 10); + }); + + it('rounds a reduce-only size down rather than to the nearest increment', () => { + // 4599 / 50000 = 0.09198, which would round up to 0.092 + const { finalPositionSize } = calculateFinalPositionSize({ + usdAmount: '4599', + currentPrice: 50000, + szDecimals: 3, + reduceOnly: true, + }); + + expect(finalPositionSize).toBeCloseTo(0.091, 10); + }); + + it('throws when a reduce-only size floors to zero', () => { + // One increment (0.001 BTC) is worth $50, so $15 cannot be expressed + expect(() => + calculateFinalPositionSize({ + usdAmount: '15', + currentPrice: 50000, + szDecimals: 3, + reduceOnly: true, + }), + ).toThrow(PERPS_ERROR_CODES.ORDER_SIZE_POSITIVE); + }); + + it('throws instead of capping a reduce-only size at a non-positive size', () => { + // The cap would make this a zero-size order, which the exchange rejects + expect(() => + calculateFinalPositionSize({ + size: '0', + usdAmount: '100', + currentPrice: 50000, + szDecimals: 3, + reduceOnly: true, + }), + ).toThrow(PERPS_ERROR_CODES.ORDER_SIZE_POSITIVE); + }); + + it('throws instead of capping a reduce-only size at a negative size', () => { + expect(() => + calculateFinalPositionSize({ + size: '-1', + usdAmount: '100', + currentPrice: 50000, + szDecimals: 3, + reduceOnly: true, + }), + ).toThrow(PERPS_ERROR_CODES.ORDER_SIZE_POSITIVE); + }); + + it('throws instead of capping a reduce-only size at a non-numeric size', () => { + expect(() => + calculateFinalPositionSize({ + size: 'abc', + usdAmount: '100', + currentPrice: 50000, + szDecimals: 3, + reduceOnly: true, + }), + ).toThrow(PERPS_ERROR_CODES.ORDER_SIZE_POSITIVE); + }); + + it('leaves a non-positive size alone for orders that are not reduce-only', () => { + // Opens still treat usdAmount as the only source of truth + const { finalPositionSize } = calculateFinalPositionSize({ + size: '0', + usdAmount: '100', + currentPrice: 50000, + szDecimals: 3, + }); + + expect(finalPositionSize).toBeCloseTo(0.002, 10); + }); + + it('ignores the provided size when a USD amount is given', () => { + const { finalPositionSize } = calculateFinalPositionSize({ + size: '0.04', + usdAmount: '2500', + currentPrice: 50000, + szDecimals: 5, + }); + + // For an opening order the USD amount wins outright + expect(finalPositionSize).toBeCloseTo(0.05, 10); + }); + + it('caps a reduce-only size at the provided size', () => { + // The caller already clamped 0.04 to the live position, so the USD amount + // (worth 0.05 at this price) must not resurrect the larger size + const { finalPositionSize } = calculateFinalPositionSize({ + size: '0.04', + usdAmount: '2500', + currentPrice: 50000, + szDecimals: 5, + reduceOnly: true, + }); + + expect(finalPositionSize).toBeCloseTo(0.04, 10); + }); + + it('keeps the USD-derived reduce-only size when it is below the provided size', () => { + // An adverse price move shrinks the USD-derived size; the cap must not + // raise it back up to the requested size + const { finalPositionSize } = calculateFinalPositionSize({ + size: '0.05', + usdAmount: '2000', + currentPrice: 50000, + szDecimals: 5, + reduceOnly: true, + }); + + expect(finalPositionSize).toBeCloseTo(0.04, 10); + }); + + it('throws when the price moved beyond the allowed slippage', () => { + expect(() => + calculateFinalPositionSize({ + usdAmount: '5000', + currentPrice: 45000, + priceAtCalculation: 50000, + maxSlippageBps: 300, + szDecimals: 3, + }), + ).toThrow('Price moved too much'); + }); + }); + + describe('legacy size path', () => { + it('uses the provided size verbatim', () => { + const { finalPositionSize } = calculateFinalPositionSize({ + size: '0.12345', + currentPrice: 50000, + szDecimals: 3, + }); + + expect(finalPositionSize).toBeCloseTo(0.12345, 10); + }); + + it('floors a reduce-only size onto the size grid', () => { + // formatHyperLiquidSize would otherwise toFixed(3) this up to 0.124 + const { finalPositionSize } = calculateFinalPositionSize({ + size: '0.12356', + currentPrice: 50000, + szDecimals: 3, + reduceOnly: true, + }); + + expect(finalPositionSize).toBeCloseTo(0.123, 10); + }); + + it('keeps a grid-aligned reduce-only size intact despite float error', () => { + // 0.123 * 1000 === 122.99999999999999, so a plain truncation would drop a + // whole increment and leave dust in the position + const { finalPositionSize } = calculateFinalPositionSize({ + size: '0.123', + currentPrice: 50000, + szDecimals: 3, + reduceOnly: true, + }); + + expect(finalPositionSize).toBeCloseTo(0.123, 10); + }); + + it('keeps a grid-aligned reduce-only size intact at magnitudes where a fixed tolerance fails', () => { + // 1048580.0694 * 1e5 lands 1.5e-5 off its grid point — past a fixed 1e-6 + // tolerance, which would floor it to 1048580.06939 and lose an increment + const { finalPositionSize } = calculateFinalPositionSize({ + size: '1048580.0694', + currentPrice: 1, + szDecimals: 5, + reduceOnly: true, + }); + + expect(finalPositionSize).toBe(1048580.0694); + }); + + it('throws when a reduce-only size floors to zero', () => { + expect(() => + calculateFinalPositionSize({ + size: '0.0004', + currentPrice: 50000, + szDecimals: 3, + reduceOnly: true, + }), + ).toThrow(PERPS_ERROR_CODES.ORDER_SIZE_POSITIVE); + }); + + it('throws for a non-positive reduce-only size, matching the USD branch', () => { + // formatHyperLiquidSize would otherwise render '0.000' / '-1.000' + expect(() => + calculateFinalPositionSize({ + size: '0', + currentPrice: 50000, + szDecimals: 3, + reduceOnly: true, + }), + ).toThrow(PERPS_ERROR_CODES.ORDER_SIZE_POSITIVE); + + expect(() => + calculateFinalPositionSize({ + size: '-1', + currentPrice: 50000, + szDecimals: 3, + reduceOnly: true, + }), + ).toThrow(PERPS_ERROR_CODES.ORDER_SIZE_POSITIVE); + }); + + it('leaves a zero size alone when the order is not reduce-only', () => { + const { finalPositionSize } = calculateFinalPositionSize({ + size: '0', + currentPrice: 50000, + szDecimals: 3, + }); + + expect(finalPositionSize).toBe(0); + }); + }); +}); diff --git a/packages/perps-controller/tests/src/utils/orderTypes.test.ts b/packages/perps-controller/tests/src/utils/orderTypes.test.ts new file mode 100644 index 00000000000..97b4a585170 --- /dev/null +++ b/packages/perps-controller/tests/src/utils/orderTypes.test.ts @@ -0,0 +1,414 @@ +import type { Order, PositionTriggerOrder } from '../../../src/types/index.js'; +import type { + OrderType, + TriggerOrderType, +} from '../../../src/types/perps-types.js'; +import { + TRIGGER_ORDER_TYPES, + hashTriggerOrders, + toSDKTimeInForce, + buildPositionTriggerOrderFromOrder, + buildTriggerOrderType, + getTriggerDirection, + getTriggerExecution, + isLimitExecutionOrderType, + isTriggerOrderType, + resolvePositionTriggerSummaryPrice, +} from '../../../src/utils/orderTypes.js'; + +const createOrder = (overrides: Partial = {}): Order => ({ + orderId: '111', + symbol: 'BTC', + side: 'sell', + orderType: 'market', + size: '0.5', + originalSize: '0.5', + price: '50000', + filledSize: '0', + remainingSize: '0.5', + status: 'open', + timestamp: 1_700_000_000_000, + ...overrides, +}); + +describe('orderTypes', () => { + describe('TRIGGER_ORDER_TYPES', () => { + it('lists every trigger placement type', () => { + expect(TRIGGER_ORDER_TYPES).toStrictEqual([ + 'stop_market', + 'stop_limit', + 'take_profit_market', + 'take_profit_limit', + ]); + }); + }); + + describe('isTriggerOrderType', () => { + it.each(TRIGGER_ORDER_TYPES)('returns true for %s', (orderType) => { + expect(isTriggerOrderType(orderType)).toBe(true); + }); + + it.each(['market', 'limit'] as OrderType[])( + 'returns false for %s', + (orderType) => { + expect(isTriggerOrderType(orderType)).toBe(false); + }, + ); + }); + + describe('isLimitExecutionOrderType', () => { + it.each([ + ['limit', true], + ['stop_limit', true], + ['take_profit_limit', true], + ['market', false], + ['stop_market', false], + ['take_profit_market', false], + ] as [OrderType, boolean][])('maps %s to %s', (orderType, expected) => { + expect(isLimitExecutionOrderType(orderType)).toBe(expected); + }); + }); + + describe('getTriggerExecution', () => { + it.each([ + ['stop_market', 'market'], + ['stop_limit', 'limit'], + ['take_profit_market', 'market'], + ['take_profit_limit', 'limit'], + ['market', 'market'], + ['limit', 'limit'], + ] as [OrderType, string][])('maps %s to %s', (orderType, expected) => { + expect(getTriggerExecution(orderType)).toBe(expected); + }); + }); + + describe('getTriggerDirection', () => { + it.each([ + ['stop_market', 'stop'], + ['stop_limit', 'stop'], + ['take_profit_market', 'take_profit'], + ['take_profit_limit', 'take_profit'], + ] as [TriggerOrderType, string][])( + 'maps %s to %s', + (orderType, expected) => { + expect(getTriggerDirection(orderType)).toBe(expected); + }, + ); + }); + + describe('buildTriggerOrderType', () => { + it.each([ + [{ direction: 'stop', execution: 'market' }, 'stop_market'], + [{ direction: 'stop', execution: 'limit' }, 'stop_limit'], + [{ direction: 'take_profit', execution: 'market' }, 'take_profit_market'], + [{ direction: 'take_profit', execution: 'limit' }, 'take_profit_limit'], + ] as [Parameters[0], TriggerOrderType][])( + 'builds %o into %s', + (params, expected) => { + expect(buildTriggerOrderType(params)).toBe(expected); + }, + ); + }); + + describe('hashTriggerOrders', () => { + const trigger = { + orderId: '1', + direction: 'stop' as const, + orderType: 'stop_market' as const, + triggerPrice: '45000', + size: '0.5', + isPartial: false, + reduceOnly: true, + }; + + it('treats empty and absent the same', () => { + expect(hashTriggerOrders([])).toBe('0'); + expect(hashTriggerOrders(undefined)).toBe('0'); + }); + + it('changes the hash when a trigger is added', () => { + // Streamed positions only re-emit when this string moves. + expect(hashTriggerOrders([trigger])).not.toBe('0'); + }); + + it.each([ + ['repriced', [{ ...trigger, triggerPrice: '46000' }]], + ['resized', [{ ...trigger, size: '0.25' }]], + ['partial', [{ ...trigger, isPartial: true }]], + ['replaced', [{ ...trigger, orderId: '2' }]], + // A trigger modified in place keeps its order ID, price, and size, so + // execution semantics are the only thing that moves. + [ + 'switched from market to limit execution', + [{ ...trigger, orderType: 'stop_limit' as const }], + ], + ])('changes the hash when a trigger is %s', (_label, orders) => { + expect(hashTriggerOrders(orders)).not.toBe(hashTriggerOrders([trigger])); + }); + + it('is stable for unchanged input', () => { + expect(hashTriggerOrders([trigger])).toBe(hashTriggerOrders([trigger])); + }); + }); + + describe('toSDKTimeInForce', () => { + it.each([ + ['GTC', 'Gtc'], + ['IOC', 'Ioc'], + ['ALO', 'Alo'], + [undefined, 'Gtc'], + ] as [('GTC' | 'IOC' | 'ALO') | undefined, string][])( + 'maps %s to %s', + (timeInForce, expected) => { + expect(toSDKTimeInForce(timeInForce)).toBe(expected); + }, + ); + }); + + describe('buildPositionTriggerOrderFromOrder', () => { + it('returns undefined for a non-trigger order', () => { + expect( + buildPositionTriggerOrderFromOrder({ + order: createOrder(), + positionSize: '1', + }), + ).toBeUndefined(); + }); + + it('returns undefined when the placement type is unknown', () => { + expect( + buildPositionTriggerOrderFromOrder({ + order: createOrder({ isTrigger: true }), + positionSize: '1', + }), + ).toBeUndefined(); + }); + + it('marks a quantity-scoped trigger as partial', () => { + const result = buildPositionTriggerOrderFromOrder({ + order: createOrder({ + orderId: '222', + isTrigger: true, + triggerOrderType: 'take_profit_limit', + triggerPrice: '60000', + size: '0.4', + reduceOnly: true, + }), + positionSize: '1', + }); + + expect(result).toStrictEqual({ + orderId: '222', + direction: 'take_profit', + orderType: 'take_profit_limit', + triggerPrice: '60000', + size: '0.4', + isPartial: true, + reduceOnly: true, + }); + }); + + it('resolves a whole-position trigger (size 0) against the position size', () => { + const result = buildPositionTriggerOrderFromOrder({ + order: createOrder({ + isTrigger: true, + triggerOrderType: 'stop_market', + triggerPrice: '40000', + size: '0', + reduceOnly: true, + }), + // Short position: the absolute size is what the trigger closes + positionSize: '-1.5', + }); + + expect(result?.size).toBe('1.5'); + expect(result?.isPartial).toBe(false); + }); + + it.each([ + ['grown', '2', '2'], + ['shrunk', '0.5', '0.5'], + ])( + 're-resolves a position-bound trigger against a position that has %s', + (_label, positionSize, expectedSize) => { + // adaptOrderFromSDK already collapsed the exchange's size 0 against the + // position as it stood then, so the size carried here is stale. The + // position-bound flag, not the number, says what the trigger covers. + const result = buildPositionTriggerOrderFromOrder({ + order: createOrder({ + isTrigger: true, + triggerOrderType: 'take_profit_market', + triggerPrice: '60000', + size: '1', + reduceOnly: true, + isPositionTpsl: true, + }), + positionSize, + }); + + expect(result?.size).toBe(expectedSize); + // A position-bound TP/SL always covers the whole position, however it + // has been resized. + expect(result?.isPartial).toBe(false); + }, + ); + + it.each([ + ['long', '1', '60000', 'take_profit'], + ['long', '1', '40000', 'stop'], + ['short', '-1', '40000', 'take_profit'], + ['short', '-1', '60000', 'stop'], + ])( + 'classifies an unnamed trigger on a %s position at %s by price', + (_side, positionSize, triggerPrice, expectedDirection) => { + // HyperLiquid's bare 'Trigger' names no direction and no execution. + // The direction is still recoverable from the trigger price against + // the entry, and that is what decides which array the order joins. + const result = buildPositionTriggerOrderFromOrder({ + order: createOrder({ + isTrigger: true, + triggerPrice, + size: '1', + reduceOnly: true, + }), + positionSize, + entryPrice: '50000', + }); + + expect(result?.direction).toBe(expectedDirection); + // The execution mode is genuinely unknown, so it is left unstated + // rather than guessed. + expect(result?.orderType).toBeUndefined(); + }, + ); + + it.each([ + ['long', '1', 'stop'], + ['short', '-1', 'stop'], + ])( + 'classifies a %s trigger sitting exactly at entry as a stop', + (_side, positionSize, expectedDirection) => { + // The legacy price fallback treats trigger == entry as a stop on both + // sides. Classifying it as a take profit here would put the order in + // takeProfitOrders while the scalar stopLossPrice still called it a + // stop — the two disagreeing about the same order. + const result = buildPositionTriggerOrderFromOrder({ + order: createOrder({ + isTrigger: true, + triggerPrice: '50000', + size: '1', + reduceOnly: true, + }), + positionSize, + entryPrice: '50000', + }); + + expect(result?.direction).toBe(expectedDirection); + }, + ); + + it('states the direction alongside a named placement type', () => { + const result = buildPositionTriggerOrderFromOrder({ + order: createOrder({ + isTrigger: true, + triggerOrderType: 'take_profit_limit', + triggerPrice: '60000', + size: '1', + }), + positionSize: '1', + }); + + expect(result?.direction).toBe('take_profit'); + expect(result?.orderType).toBe('take_profit_limit'); + }); + + it('returns undefined for an unnamed trigger with no position to classify against', () => { + expect( + buildPositionTriggerOrderFromOrder({ + order: createOrder({ isTrigger: true, triggerPrice: '60000' }), + positionSize: '1', + }), + ).toBeUndefined(); + }); + + it('falls back to the order price when no trigger price is present', () => { + const result = buildPositionTriggerOrderFromOrder({ + order: createOrder({ + isTrigger: true, + triggerOrderType: 'stop_limit', + price: '45000', + size: '1', + }), + positionSize: '1', + }); + + expect(result?.triggerPrice).toBe('45000'); + expect(result?.isPartial).toBe(false); + expect(result?.reduceOnly).toBe(false); + }); + }); + + describe('resolvePositionTriggerSummaryPrice', () => { + const createTriggerOrder = ( + overrides: Partial = {}, + ): PositionTriggerOrder => ({ + orderId: '901', + direction: 'take_profit', + orderType: 'take_profit_limit', + triggerPrice: '60000', + size: '0.04', + isPartial: true, + reduceOnly: true, + ...overrides, + }); + + it('reports the price of a lone trigger order, partial or not', () => { + expect( + resolvePositionTriggerSummaryPrice({ + triggerOrders: [createTriggerOrder()], + }), + ).toBe('60000'); + }); + + it('prefers the lone trigger order over a differing scanned price', () => { + expect( + resolvePositionTriggerSummaryPrice({ + triggerOrders: [createTriggerOrder({ triggerPrice: '61000' })], + scannedPrice: '60000', + }), + ).toBe('61000'); + }); + + it('keeps the scanned price when several trigger orders share a direction', () => { + expect( + resolvePositionTriggerSummaryPrice({ + triggerOrders: [ + createTriggerOrder({ orderId: '901', triggerPrice: '60000' }), + createTriggerOrder({ orderId: '902', triggerPrice: '61000' }), + ], + scannedPrice: '59000', + }), + ).toBe('59000'); + }); + + it('reports nothing when several trigger orders share a direction and none was scanned', () => { + expect( + resolvePositionTriggerSummaryPrice({ + triggerOrders: [ + createTriggerOrder({ orderId: '901', triggerPrice: '60000' }), + createTriggerOrder({ orderId: '902', triggerPrice: '61000' }), + ], + }), + ).toBeUndefined(); + }); + + it('keeps the scanned price when there is no trigger order, which is how a pending order TP/SL still reports', () => { + expect( + resolvePositionTriggerSummaryPrice({ + triggerOrders: [], + scannedPrice: '60000', + }), + ).toBe('60000'); + }); + }); +}); diff --git a/packages/perps-controller/tests/src/utils/perpsConnectionAttemptContext.test.ts b/packages/perps-controller/tests/src/utils/perpsConnectionAttemptContext.test.ts new file mode 100644 index 00000000000..b1ea6c8b520 --- /dev/null +++ b/packages/perps-controller/tests/src/utils/perpsConnectionAttemptContext.test.ts @@ -0,0 +1,64 @@ +/* eslint-disable */ +import { + getPerpsConnectionAttemptContext, + withPerpsConnectionAttemptContext, +} from '../../../src/utils/perpsConnectionAttemptContext.js'; + +describe('perpsConnectionAttemptContext', () => { + it('returns null when no context is active', () => { + expect(getPerpsConnectionAttemptContext()).toBeNull(); + }); + + it('sets context during callback execution and restores it after', async () => { + const context = { source: 'test_source', suppressError: true }; + + await withPerpsConnectionAttemptContext(context, async () => { + expect(getPerpsConnectionAttemptContext()).toEqual(context); + return 'result'; + }); + + expect(getPerpsConnectionAttemptContext()).toBeNull(); + }); + + it('returns the callback result', async () => { + const context = { source: 'test', suppressError: false }; + + const result = await withPerpsConnectionAttemptContext( + context, + async () => 42, + ); + + expect(result).toBe(42); + }); + + it('restores previous context after nested calls', async () => { + const outer = { source: 'outer', suppressError: false }; + const inner = { source: 'inner', suppressError: true }; + + await withPerpsConnectionAttemptContext(outer, async () => { + expect(getPerpsConnectionAttemptContext()).toEqual(outer); + + await withPerpsConnectionAttemptContext(inner, async () => { + expect(getPerpsConnectionAttemptContext()).toEqual(inner); + }); + + // Outer context restored after inner completes + expect(getPerpsConnectionAttemptContext()).toEqual(outer); + }); + + expect(getPerpsConnectionAttemptContext()).toBeNull(); + }); + + it('restores context even when callback throws', async () => { + const context = { source: 'failing', suppressError: false }; + + await expect( + withPerpsConnectionAttemptContext(context, async () => { + throw new Error('callback error'); + }), + ).rejects.toThrow('callback error'); + + // Context should be restored to null despite the error + expect(getPerpsConnectionAttemptContext()).toBeNull(); + }); +}); diff --git a/packages/perps-controller/tests/src/utils/perpsFormatters.test.ts b/packages/perps-controller/tests/src/utils/perpsFormatters.test.ts new file mode 100644 index 00000000000..c13013f2796 --- /dev/null +++ b/packages/perps-controller/tests/src/utils/perpsFormatters.test.ts @@ -0,0 +1,313 @@ +/* eslint-disable */ +import { + formatFundingRate, + formatPercentage, + formatPerpsFiat, + formatPnl, + formatPositionSize, + formatWithSignificantDigits, + PRICE_RANGES_MINIMAL_VIEW, + PRICE_RANGES_UNIVERSAL, + PRICE_THRESHOLD, +} from '../../../src/utils/perpsFormatters.js'; + +describe('PRICE_THRESHOLD', () => { + it('exports expected boundary values', () => { + expect(PRICE_THRESHOLD.VERY_HIGH).toBe(100_000); + expect(PRICE_THRESHOLD.HIGH).toBe(10_000); + expect(PRICE_THRESHOLD.LARGE).toBe(1_000); + expect(PRICE_THRESHOLD.MEDIUM).toBe(100); + expect(PRICE_THRESHOLD.MEDIUM_LOW).toBe(10); + expect(PRICE_THRESHOLD.LOW).toBe(0.01); + expect(PRICE_THRESHOLD.VERY_SMALL).toBe(0.000001); + }); +}); + +describe('PRICE_RANGES_MINIMAL_VIEW', () => { + it('has two ranges', () => { + expect(PRICE_RANGES_MINIMAL_VIEW).toHaveLength(2); + }); + + it('large range condition matches values >= 1000', () => { + expect(PRICE_RANGES_MINIMAL_VIEW[0].condition(1000)).toBe(true); + expect(PRICE_RANGES_MINIMAL_VIEW[0].condition(999)).toBe(false); + }); + + it('fallback range always matches', () => { + expect(PRICE_RANGES_MINIMAL_VIEW[1].condition(0)).toBe(true); + expect(PRICE_RANGES_MINIMAL_VIEW[1].condition(-999)).toBe(true); + }); +}); + +describe('PRICE_RANGES_UNIVERSAL', () => { + it('has seven ranges', () => { + expect(PRICE_RANGES_UNIVERSAL).toHaveLength(7); + }); + + it('very-high range condition matches values above 100000', () => { + expect(PRICE_RANGES_UNIVERSAL[0].condition(100001)).toBe(true); + expect(PRICE_RANGES_UNIVERSAL[0].condition(100000)).toBe(false); + }); + + it('very-small fallback always matches', () => { + expect(PRICE_RANGES_UNIVERSAL[6].condition(0)).toBe(true); + }); +}); + +describe('formatWithSignificantDigits', () => { + it('returns zero with no decimals for input 0', () => { + expect(formatWithSignificantDigits(0, 4)).toEqual({ + value: 0, + decimals: 0, + }); + }); + + it('respects minDecimals for zero', () => { + expect(formatWithSignificantDigits(0, 4, 2)).toEqual({ + value: 0, + decimals: 2, + }); + }); + + it('formats integer >= 1 with correct decimals for 5 sig figs', () => { + // 123.456 has 3 integer digits → 5-3 = 2 decimals + const result = formatWithSignificantDigits(123.456, 5); + expect(result.decimals).toBe(2); + expect(result.value).toBeCloseTo(123.46, 2); + }); + + it('formats large number with 0 decimals when significantDigits exceeded by integer digits', () => { + // 123456 has 6 integer digits → 5-6 = negative → clamped to 0 + const result = formatWithSignificantDigits(123456, 5); + expect(result.decimals).toBe(0); + }); + + it('formats decimal < 1 to correct sig figs', () => { + // 0.003456 to 4 sig figs → 0.003456 + const result = formatWithSignificantDigits(0.003456, 4); + expect(result.value).toBeCloseTo(0.003456, 6); + }); + + it('respects maxDecimals override', () => { + // 12.34567 with 5 sig figs would need 4 decimals, capped at 2 + const result = formatWithSignificantDigits(12.34567, 5, undefined, 2); + expect(result.decimals).toBe(2); + }); + + it('preserves negative sign for values < 1', () => { + const result = formatWithSignificantDigits(-0.001234, 3); + expect(result.value).toBeLessThan(0); + }); +}); + +describe('formatPerpsFiat', () => { + it('returns fallback for NaN', () => { + expect(formatPerpsFiat('not-a-number')).toBe('$---'); + }); + + it('formats a basic value with default minimal view ranges', () => { + // PRICE_RANGES_MINIMAL_VIEW: 2 decimals, fiat-style stripping + expect(formatPerpsFiat(1234.56)).toBe('$1,234.56'); + }); + + it('strips .00 with fiat-style stripping (default ranges)', () => { + expect(formatPerpsFiat(1250)).toBe('$1,250'); + expect(formatPerpsFiat(100)).toBe('$100'); + }); + + it('preserves meaningful decimals with fiat-style stripping', () => { + // $13.40 → stays $13.40 (not $13.4) + expect(formatPerpsFiat(13.4)).toBe('$13.40'); + }); + + it('formats with PRICE_RANGES_UNIVERSAL', () => { + // $12,345.67 → $12,346 (0 decimals, 5 sig figs for high range) + const result = formatPerpsFiat(12345.67, { + ranges: PRICE_RANGES_UNIVERSAL, + }); + expect(result).toBe('$12,346'); + }); + + it('formats small value with universal ranges', () => { + // $1.3445 → ~$1.3445 (5 sig figs, max 6 decimals) + const result = formatPerpsFiat(1.3445, { ranges: PRICE_RANGES_UNIVERSAL }); + expect(result).toBe('$1.3445'); + }); + + it('formats very small value with universal ranges', () => { + // < $0.01 → 4 sig figs + const result = formatPerpsFiat(0.004236, { + ranges: PRICE_RANGES_UNIVERSAL, + }); + expect(result).toBe('$0.004236'); + }); + + it('respects explicit stripTrailingZeros: false', () => { + const result = formatPerpsFiat(1250, { stripTrailingZeros: false }); + expect(result).toBe('$1,250.00'); + }); + + it('handles numeric string input', () => { + expect(formatPerpsFiat('500.50')).toBe('$500.50'); + }); + + it('accepts custom currency', () => { + const result = formatPerpsFiat(100, { + currency: 'EUR', + stripTrailingZeros: false, + }); + expect(result).toContain('100'); + }); +}); + +describe('formatPositionSize', () => { + it('returns "0" for zero', () => { + expect(formatPositionSize(0)).toBe('0'); + }); + + it('returns "0" for NaN', () => { + expect(formatPositionSize('invalid')).toBe('0'); + }); + + it('uses szDecimals when provided', () => { + expect(formatPositionSize(0.00009, 5)).toBe('0.00009'); + expect(formatPositionSize(44, 1)).toBe('44'); + expect(formatPositionSize(1.5, 5)).toBe('1.5'); + }); + + it('strips trailing zeros with szDecimals', () => { + expect(formatPositionSize(44.0, 2)).toBe('44'); + }); + + it('preserves integer trailing zeros when szDecimals=0 (whole-unit assets)', () => { + expect(formatPositionSize(100, 0)).toBe('100'); + expect(formatPositionSize(20, 0)).toBe('20'); + expect(formatPositionSize(1000, 0)).toBe('1000'); + expect(formatPositionSize(1, 0)).toBe('1'); + expect(formatPositionSize(1.7, 0)).toBe('2'); + }); + + it('uses magnitude logic for very small values (< 0.01) without szDecimals', () => { + const result = formatPositionSize(0.00009); + expect(result).toBe('0.00009'); + }); + + it('uses magnitude logic for small values (< 1) without szDecimals', () => { + expect(formatPositionSize(0.0024)).toBe('0.0024'); + }); + + it('uses 2 decimals for values >= 1 without szDecimals', () => { + expect(formatPositionSize(44)).toBe('44'); + expect(formatPositionSize(44.5)).toBe('44.5'); + }); + + it('handles string input', () => { + expect(formatPositionSize('1.23')).toBe('1.23'); + }); +}); + +describe('formatPnl', () => { + it('formats positive PnL with + prefix', () => { + expect(formatPnl(1234.56)).toBe('+$1,234.56'); + }); + + it('formats negative PnL with - prefix', () => { + expect(formatPnl(-500)).toBe('-$500.00'); + }); + + it('formats zero as positive', () => { + expect(formatPnl(0)).toBe('+$0.00'); + }); + + it('returns zero display for NaN', () => { + expect(formatPnl('invalid')).toBe('$0.00'); + }); + + it('handles string input', () => { + expect(formatPnl('250.75')).toBe('+$250.75'); + }); + + it('formats 2 decimal places always', () => { + expect(formatPnl(100)).toBe('+$100.00'); + expect(formatPnl(-0.01)).toBe('-$0.01'); + }); +}); + +describe('formatPercentage', () => { + it('formats positive percentage with + prefix', () => { + expect(formatPercentage(5.25)).toBe('+5.25%'); + }); + + it('formats negative percentage with - prefix', () => { + expect(formatPercentage(-2.75)).toBe('-2.75%'); + }); + + it('formats zero as positive', () => { + expect(formatPercentage(0)).toBe('+0.00%'); + }); + + it('returns "0.00%" for NaN', () => { + expect(formatPercentage('not-a-number')).toBe('0.00%'); + }); + + it('respects custom decimals', () => { + expect(formatPercentage(5.1234, 4)).toBe('+5.1234%'); + expect(formatPercentage(5.1234, 0)).toBe('+5%'); + }); + + it('handles string input', () => { + expect(formatPercentage('10.5')).toBe('+10.50%'); + }); +}); + +describe('formatFundingRate', () => { + it('returns zero display for undefined', () => { + expect(formatFundingRate(undefined)).toBe('0.0000%'); + }); + + it('returns zero display for null', () => { + expect(formatFundingRate(null)).toBe('0.0000%'); + }); + + it('formats positive funding rate as percentage', () => { + // 0.0005 * 100 = 0.05 → "0.0500%" + expect(formatFundingRate(0.0005)).toBe('0.0500%'); + }); + + it('formats negative funding rate as percentage', () => { + // -0.0001 * 100 = -0.01 → "-0.0100%" + expect(formatFundingRate(-0.0001)).toBe('-0.0100%'); + }); + + it('returns zero display for effectively-zero value', () => { + expect(formatFundingRate(0)).toBe('0.0000%'); + }); + + it('shows a threshold for positive rates below display precision', () => { + const value = 0.000000059; + + const result = formatFundingRate(value); + + expect(result).toBe('<0.0001%'); + }); + + it('shows a threshold for negative rates below display precision', () => { + const value = -0.000000059; + + const result = formatFundingRate(value); + + expect(result).toBe('-<0.0001%'); + }); + + it('returns empty string for undefined when showZero is false', () => { + expect(formatFundingRate(undefined, { showZero: false })).toBe(''); + }); + + it('formats zero value normally when showZero is false (showZero only affects undefined/null)', () => { + expect(formatFundingRate(0, { showZero: false })).toBe('0.0000%'); + }); + + it('still formats non-zero when showZero is false', () => { + expect(formatFundingRate(0.001, { showZero: false })).toBe('0.1000%'); + }); +}); diff --git a/packages/perps-controller/tests/src/utils/rewardsUtils.test.ts b/packages/perps-controller/tests/src/utils/rewardsUtils.test.ts new file mode 100644 index 00000000000..f98d313d04b --- /dev/null +++ b/packages/perps-controller/tests/src/utils/rewardsUtils.test.ts @@ -0,0 +1,103 @@ +import type { PerpsLogger } from '../../../src/types/index.js'; +/* eslint-disable */ +import { + formatAccountToCaipAccountId, + isCaipAccountId, + handleRewardsError, +} from '../../../src/utils/rewardsUtils.js'; + +describe('rewardsUtils', () => { + describe('formatAccountToCaipAccountId', () => { + it('formats hex chain ID and address to CAIP-10', () => { + const result = formatAccountToCaipAccountId( + '0xAb5801a7D398351b8bE11C439e05C5B3259aeC9B', + '0xa4b1', + ); + expect(result).toMatch(/^eip155:42161:0x/); + }); + + it('formats decimal chain ID and address to CAIP-10', () => { + const result = formatAccountToCaipAccountId( + '0xAb5801a7D398351b8bE11C439e05C5B3259aeC9B', + '42161', + ); + expect(result).toMatch(/^eip155:42161:0x/); + }); + + it('returns null for invalid chain ID (NaN) and logs error', () => { + const mockLogger: PerpsLogger = { + error: jest.fn(), + }; + + const result = formatAccountToCaipAccountId( + '0xAb5801a7D398351b8bE11C439e05C5B3259aeC9B', + 'abc', + mockLogger, + ); + + expect(result).toBeNull(); + expect(mockLogger.error).toHaveBeenCalledWith( + expect.objectContaining({ + message: expect.stringContaining('Invalid chain ID: abc'), + }), + expect.any(Object), + ); + }); + + it('returns null for empty chain ID and logs error', () => { + const mockLogger: PerpsLogger = { + error: jest.fn(), + }; + + const result = formatAccountToCaipAccountId( + '0xAb5801a7D398351b8bE11C439e05C5B3259aeC9B', + '', + mockLogger, + ); + + expect(result).toBeNull(); + expect(mockLogger.error).toHaveBeenCalled(); + }); + + it('returns null without logger for invalid chain ID', () => { + const result = formatAccountToCaipAccountId( + '0xAb5801a7D398351b8bE11C439e05C5B3259aeC9B', + 'not-a-number', + ); + + expect(result).toBeNull(); + }); + }); + + describe('isCaipAccountId', () => { + it('returns true for valid CAIP-10 account ID', () => { + expect(isCaipAccountId('eip155:1:0xABC')).toBe(true); + }); + + it('returns false for non-string value', () => { + expect(isCaipAccountId(123)).toBe(false); + expect(isCaipAccountId(null)).toBe(false); + }); + + it('returns false for non-eip155 namespace', () => { + expect(isCaipAccountId('solana:1:abc')).toBe(false); + }); + + it('returns false for string with fewer than 3 parts', () => { + expect(isCaipAccountId('eip155:1')).toBe(false); + }); + }); + + describe('handleRewardsError', () => { + it('returns user-friendly error message', () => { + const result = handleRewardsError(new Error('test')); + expect(result).toBe('Rewards operation failed'); + }); + + it('logs error when logger is provided', () => { + const mockLogger: PerpsLogger = { error: jest.fn() }; + handleRewardsError(new Error('test'), mockLogger, { key: 'value' }); + expect(mockLogger.error).toHaveBeenCalled(); + }); + }); +}); diff --git a/packages/perps-controller/tests/src/utils/significantFigures.test.ts b/packages/perps-controller/tests/src/utils/significantFigures.test.ts new file mode 100644 index 00000000000..b9bac0cb973 --- /dev/null +++ b/packages/perps-controller/tests/src/utils/significantFigures.test.ts @@ -0,0 +1,48 @@ +import { + countSignificantFigures, + hasExceededSignificantFigures, + roundToSignificantFigures, +} from '../../../src/utils/significantFigures.js'; + +describe('significantFigures utilities', () => { + describe('countSignificantFigures', () => { + it.each([ + ['', 0], + ['0', 0], + ['not-a-number', 0], + ['$1,230.4500', 6], + ['0.001234', 6], + ['1000', 1], + ['-12.340', 4], + ])('counts %s as %s', (input, expected) => { + expect(countSignificantFigures(input)).toBe(expected); + }); + }); + + describe('hasExceededSignificantFigures', () => { + it('returns false for empty, invalid, and integer values', () => { + expect(hasExceededSignificantFigures('')).toBe(false); + expect(hasExceededSignificantFigures('abc')).toBe(false); + expect(hasExceededSignificantFigures('123456789')).toBe(false); + }); + + it('detects decimal values above the configured limit', () => { + expect(hasExceededSignificantFigures('123.456', 5)).toBe(true); + expect(hasExceededSignificantFigures('123.45', 5)).toBe(false); + }); + }); + + describe('roundToSignificantFigures', () => { + it('returns the original string for empty, invalid, and zero values', () => { + expect(roundToSignificantFigures('')).toBe(''); + expect(roundToSignificantFigures('abc')).toBe('abc'); + expect(roundToSignificantFigures('0')).toBe('0'); + }); + + it('rounds decimal values to the allowed significant figures', () => { + expect(roundToSignificantFigures('123.4567', 5)).toBe('123.46'); + expect(roundToSignificantFigures('123.4', 5)).toBe('123.4'); + expect(roundToSignificantFigures('12345.67', 3)).toBe('12346'); + }); + }); +}); diff --git a/packages/perps-controller/tests/src/utils/sortMarkets.test.ts b/packages/perps-controller/tests/src/utils/sortMarkets.test.ts new file mode 100644 index 00000000000..9931607ddc4 --- /dev/null +++ b/packages/perps-controller/tests/src/utils/sortMarkets.test.ts @@ -0,0 +1,87 @@ +import { MARKET_SORTING_CONFIG } from '../../../src/constants/perpsConfig.js'; +import type { PerpsMarketData } from '../../../src/types/index.js'; +import { parseVolume, sortMarkets } from '../../../src/utils/sortMarkets.js'; + +const market = (overrides: Partial): PerpsMarketData => + ({ + name: 'BTC', + symbol: 'BTC', + price: '50000', + volume: '$1M', + openInterest: '$1M', + change24hPercent: '+1.00%', + fundingRate: 0, + ...overrides, + }) as PerpsMarketData; + +describe('sortMarkets utilities', () => { + describe('parseVolume', () => { + it.each([ + [undefined, -1], + ['-', -1], + ['$<1', 0.5], + ['$1.5K', 1_500], + ['$2.25M', 2_250_000], + ['$3.5B', 3_500_000_000], + ['$4T', 4_000_000_000_000], + ['$1,234.56', 1234.56], + ['not-a-number', -1], + ])('parses %s as %s', (input, expected) => { + expect(parseVolume(input)).toBe(expected); + }); + }); + + it('sorts by volume descending by default without mutating input', () => { + const markets = [ + market({ name: 'low', volume: '$1M' }), + market({ name: 'high', volume: '$2M' }), + ]; + + const result = sortMarkets({ + markets, + sortBy: MARKET_SORTING_CONFIG.SortFields.Volume, + }); + + expect(result.map(({ name }) => name)).toStrictEqual(['high', 'low']); + expect(markets.map(({ name }) => name)).toStrictEqual(['low', 'high']); + }); + + it('sorts price change, funding rate, and open interest ascending', () => { + const markets = [ + market({ + name: 'a', + change24hPercent: '+10.00%', + fundingRate: 0.02, + openInterest: '$3M', + }), + market({ + name: 'b', + change24hPercent: '-5.00%', + fundingRate: -0.01, + openInterest: '$1M', + }), + ]; + + expect( + sortMarkets({ + markets, + sortBy: MARKET_SORTING_CONFIG.SortFields.PriceChange, + direction: 'asc', + }).map(({ name }) => name), + ).toStrictEqual(['b', 'a']); + expect( + sortMarkets({ + markets, + sortBy: MARKET_SORTING_CONFIG.SortFields.FundingRate, + direction: 'asc', + }).map(({ name }) => name), + ).toStrictEqual(['b', 'a']); + expect( + sortMarkets({ + markets, + sortBy: MARKET_SORTING_CONFIG.SortFields.OpenInterest, + direction: 'asc', + }).map(({ name }) => name), + ).toStrictEqual(['b', 'a']); + }); +}); diff --git a/packages/perps-controller/tests/src/utils/standaloneInfoClient.test.ts b/packages/perps-controller/tests/src/utils/standaloneInfoClient.test.ts new file mode 100644 index 00000000000..ca89e964390 --- /dev/null +++ b/packages/perps-controller/tests/src/utils/standaloneInfoClient.test.ts @@ -0,0 +1,326 @@ +/* eslint-disable */ +import { HttpTransport, InfoClient } from '@nktkas/hyperliquid'; + +import type { ClearinghouseStateResponse } from '../../../src/types/hyperliquid-types.js'; +import { + createStandaloneInfoClient, + queryStandaloneClearinghouseStates, + queryStandaloneOpenOrders, +} from '../../../src/utils/standaloneInfoClient.js'; + +// Mock instances — must use 'mock' prefix for Jest hoisting +const mockHttpTransportInstance = { url: 'http://mock' }; +const mockInfoClientInstance = { + clearinghouseState: jest.fn(), + frontendOpenOrders: jest.fn(), +}; + +jest.mock('@nktkas/hyperliquid', () => ({ + HttpTransport: jest.fn(() => mockHttpTransportInstance), + InfoClient: jest.fn(() => mockInfoClientInstance), +})); + +// After jest.mock hoisting, these imports are the mocked constructors +const MockedHttpTransport = HttpTransport as unknown as jest.Mock; +const MockedInfoClient = InfoClient as unknown as jest.Mock; + +/** + * Factory for mock ClearinghouseStateResponse. + * Returns a minimal valid shape; callers can spread overrides. + * @param overrides + */ +const createMockClearinghouseResponse = ( + overrides: Partial = {}, +): ClearinghouseStateResponse => + ({ + marginSummary: { + totalMarginUsed: '0', + accountValue: '1000', + }, + withdrawable: '1000', + assetPositions: [], + ...overrides, + }) as ClearinghouseStateResponse; + +describe('standaloneInfoClient', () => { + beforeEach(() => { + jest.clearAllMocks(); + MockedHttpTransport.mockImplementation(() => mockHttpTransportInstance); + MockedInfoClient.mockImplementation(() => mockInfoClientInstance); + }); + + // ---------------------------------------------------------------- + // createStandaloneInfoClient + // ---------------------------------------------------------------- + describe('createStandaloneInfoClient', () => { + it('creates HttpTransport with mainnet config and default timeout', () => { + createStandaloneInfoClient({ isTestnet: false }); + + expect(MockedHttpTransport).toHaveBeenCalledWith({ + isTestnet: false, + timeout: 10_000, + }); + }); + + it('creates HttpTransport with testnet config', () => { + createStandaloneInfoClient({ isTestnet: true }); + + expect(MockedHttpTransport).toHaveBeenCalledWith({ + isTestnet: true, + timeout: 10_000, + }); + }); + + it('creates HttpTransport with custom timeout', () => { + createStandaloneInfoClient({ isTestnet: false, timeout: 5000 }); + + expect(MockedHttpTransport).toHaveBeenCalledWith({ + isTestnet: false, + timeout: 5000, + }); + }); + + it('passes HttpTransport instance to InfoClient', () => { + createStandaloneInfoClient({ isTestnet: false }); + + expect(MockedInfoClient).toHaveBeenCalledWith({ + transport: mockHttpTransportInstance, + }); + }); + + it('returns the InfoClient instance', () => { + const result = createStandaloneInfoClient({ isTestnet: false }); + + expect(result).toBe(mockInfoClientInstance); + }); + }); + + // ---------------------------------------------------------------- + // queryStandaloneClearinghouseStates + // ---------------------------------------------------------------- + describe('queryStandaloneClearinghouseStates', () => { + const userAddress = '0xABCDEF1234567890abcdef1234567890ABCDEF12'; + + let mockInfoClient: jest.Mocked>; + + beforeEach(() => { + mockInfoClient = { + clearinghouseState: jest.fn(), + }; + }); + + it('calls clearinghouseState for each DEX in the list', async () => { + const dexs: (string | null)[] = [null, 'xyz', 'abc']; + mockInfoClient.clearinghouseState.mockResolvedValue( + createMockClearinghouseResponse(), + ); + + await queryStandaloneClearinghouseStates( + mockInfoClient as unknown as InfoClient, + userAddress, + dexs, + ); + + expect(mockInfoClient.clearinghouseState).toHaveBeenCalledTimes(3); + }); + + it('passes user address without dex param for null DEX entries', async () => { + mockInfoClient.clearinghouseState.mockResolvedValue( + createMockClearinghouseResponse(), + ); + + await queryStandaloneClearinghouseStates( + mockInfoClient as unknown as InfoClient, + userAddress, + [null], + ); + + expect(mockInfoClient.clearinghouseState).toHaveBeenCalledWith({ + user: userAddress, + }); + }); + + it('passes user address with dex param for non-null DEX entries', async () => { + mockInfoClient.clearinghouseState.mockResolvedValue( + createMockClearinghouseResponse(), + ); + + await queryStandaloneClearinghouseStates( + mockInfoClient as unknown as InfoClient, + userAddress, + ['xyz'], + ); + + expect(mockInfoClient.clearinghouseState).toHaveBeenCalledWith({ + user: userAddress, + dex: 'xyz', + }); + }); + + it('returns all clearinghouseState responses in order', async () => { + const responseA = createMockClearinghouseResponse({ + withdrawable: '100', + }); + const responseB = createMockClearinghouseResponse({ + withdrawable: '200', + }); + const responseC = createMockClearinghouseResponse({ + withdrawable: '300', + }); + + mockInfoClient.clearinghouseState + .mockResolvedValueOnce(responseA) + .mockResolvedValueOnce(responseB) + .mockResolvedValueOnce(responseC); + + const results = await queryStandaloneClearinghouseStates( + mockInfoClient as unknown as InfoClient, + userAddress, + [null, 'xyz', 'abc'], + ); + + expect(results).toEqual([responseA, responseB, responseC]); + }); + + it('returns single response for main-DEX-only list', async () => { + const response = createMockClearinghouseResponse(); + mockInfoClient.clearinghouseState.mockResolvedValue(response); + + const results = await queryStandaloneClearinghouseStates( + mockInfoClient as unknown as InfoClient, + userAddress, + [null], + ); + + expect(mockInfoClient.clearinghouseState).toHaveBeenCalledTimes(1); + expect(results).toEqual([response]); + }); + + it('returns empty array for empty DEX list', async () => { + const results = await queryStandaloneClearinghouseStates( + mockInfoClient as unknown as InfoClient, + userAddress, + [], + ); + + expect(mockInfoClient.clearinghouseState).not.toHaveBeenCalled(); + expect(results).toEqual([]); + }); + + it('returns successful results when some DEX queries fail', async () => { + const responseA = createMockClearinghouseResponse({ + withdrawable: '100', + }); + const responseC = createMockClearinghouseResponse({ + withdrawable: '300', + }); + + mockInfoClient.clearinghouseState + .mockResolvedValueOnce(responseA) + .mockRejectedValueOnce(new Error('HIP-3 DEX timeout')) + .mockResolvedValueOnce(responseC); + + const results = await queryStandaloneClearinghouseStates( + mockInfoClient as unknown as InfoClient, + userAddress, + [null, 'failing-dex', 'healthy-dex'], + ); + + expect(results).toEqual([responseA, responseC]); + }); + + it('returns empty array when all DEX queries fail', async () => { + mockInfoClient.clearinghouseState.mockRejectedValue( + new Error('Network timeout'), + ); + + const results = await queryStandaloneClearinghouseStates( + mockInfoClient as unknown as InfoClient, + userAddress, + [null, 'dex-a'], + ); + + expect(results).toEqual([]); + }); + }); + + // ---------------------------------------------------------------- + // queryStandaloneOpenOrders + // ---------------------------------------------------------------- + describe('queryStandaloneOpenOrders', () => { + const userAddress = '0xABCDEF1234567890abcdef1234567890ABCDEF12'; + + let mockInfoClient: { + frontendOpenOrders: jest.Mock; + }; + + beforeEach(() => { + mockInfoClient = { + frontendOpenOrders: jest.fn(), + }; + }); + + it('returns orders from all DEXs combined', async () => { + const ordersA = [{ oid: 1, coin: 'BTC', side: 'A' }]; + const ordersB = [{ oid: 2, coin: 'ETH', side: 'B' }]; + + mockInfoClient.frontendOpenOrders + .mockResolvedValueOnce(ordersA) + .mockResolvedValueOnce(ordersB); + + const results = await queryStandaloneOpenOrders( + mockInfoClient as unknown as InfoClient, + userAddress, + [null, 'xyz'], + ); + + expect(results).toEqual([ordersA, ordersB]); + expect(mockInfoClient.frontendOpenOrders).toHaveBeenCalledTimes(2); + }); + + it('returns only fulfilled results when some DEX queries fail', async () => { + const ordersA = [{ oid: 1, coin: 'BTC', side: 'A' }]; + + mockInfoClient.frontendOpenOrders + .mockResolvedValueOnce(ordersA) + .mockRejectedValueOnce(new Error('DEX timeout')); + + const results = await queryStandaloneOpenOrders( + mockInfoClient as unknown as InfoClient, + userAddress, + [null, 'failing-dex'], + ); + + expect(results).toEqual([ordersA]); + }); + + it('omits dex param when DEX is null', async () => { + mockInfoClient.frontendOpenOrders.mockResolvedValue([]); + + await queryStandaloneOpenOrders( + mockInfoClient as unknown as InfoClient, + userAddress, + [null], + ); + + expect(mockInfoClient.frontendOpenOrders).toHaveBeenCalledWith({ + user: userAddress, + }); + }); + + it('passes dex param for non-null DEX', async () => { + mockInfoClient.frontendOpenOrders.mockResolvedValue([]); + + await queryStandaloneOpenOrders( + mockInfoClient as unknown as InfoClient, + userAddress, + ['xyz'], + ); + + expect(mockInfoClient.frontendOpenOrders).toHaveBeenCalledWith({ + user: userAddress, + dex: 'xyz', + }); + }); + }); +}); diff --git a/packages/perps-controller/tests/src/utils/stringParseUtils.test.ts b/packages/perps-controller/tests/src/utils/stringParseUtils.test.ts new file mode 100644 index 00000000000..4dc38b01d98 --- /dev/null +++ b/packages/perps-controller/tests/src/utils/stringParseUtils.test.ts @@ -0,0 +1,105 @@ +/* eslint-disable */ +import { + parseBoundedNonNegativeDecimal, + stripQuotes, + parseCommaSeparatedString, +} from '../../../src/utils/stringParseUtils.js'; + +describe('stripQuotes', () => { + it('removes single layer of double quotes', () => { + expect(stripQuotes('"hello"')).toBe('hello'); + }); + + it('removes single layer of single quotes', () => { + expect(stripQuotes("'hello'")).toBe('hello'); + }); + + it('removes nested quotes (single wrapping double)', () => { + // Simulates LaunchDarkly returning '"xyz:TSLA"' (single quotes wrapping double quotes) + expect(stripQuotes(`'"xyz:TSLA"'`)).toBe('xyz:TSLA'); + }); + + it('removes multiple layers of double quotes', () => { + expect(stripQuotes('""xyz""')).toBe('xyz'); + }); + + it('removes mixed nested quotes (double wrapping single)', () => { + expect(stripQuotes(`"'xyz'"`)).toBe('xyz'); + }); + + it('returns string unchanged when no wrapping quotes', () => { + expect(stripQuotes('hello')).toBe('hello'); + }); + + it('returns empty string unchanged', () => { + expect(stripQuotes('')).toBe(''); + }); + + it('does not remove mismatched quotes', () => { + expect(stripQuotes(`"hello'`)).toBe(`"hello'`); + }); + + it('does not remove quotes in the middle', () => { + expect(stripQuotes('hel"lo')).toBe('hel"lo'); + }); + + it('handles deeply nested single quotes', () => { + expect(stripQuotes(`'''xyz'''`)).toBe('xyz'); + }); + + it('handles real LaunchDarkly pattern with nested quotes', () => { + // The actual problematic value: single-quote wrapped double-quoted string + expect(stripQuotes(`'"xyz:TSLA"'`)).toBe('xyz:TSLA'); + }); +}); + +describe('parseCommaSeparatedString', () => { + it('parses comma-separated values', () => { + expect(parseCommaSeparatedString('BTC,ETH,SOL')).toEqual([ + 'BTC', + 'ETH', + 'SOL', + ]); + }); + + it('trims whitespace', () => { + expect(parseCommaSeparatedString(' BTC , ETH , SOL ')).toEqual([ + 'BTC', + 'ETH', + 'SOL', + ]); + }); + + it('filters empty values', () => { + expect(parseCommaSeparatedString('BTC,,SOL')).toEqual(['BTC', 'SOL']); + }); + + it('returns empty array for empty string', () => { + expect(parseCommaSeparatedString('')).toEqual([]); + }); +}); + +describe('bounded decimal parsing', () => { + it.each([ + ['0', 0], + ['0.5', 0.5], + ['100', 100], + ])('parses non-negative decimal %p', (value, expected) => { + expect(parseBoundedNonNegativeDecimal(value)).toBe(expected); + }); + + it.each(['', '-1', '.5', '1.', '1e5', ' 1', '1 ', '0x10', 'Infinity'])( + 'rejects malformed decimal %p', + (value) => { + expect(parseBoundedNonNegativeDecimal(value)).toBeNull(); + }, + ); + + it.each([1, null, undefined])('rejects non-string value %p', (value) => { + expect(parseBoundedNonNegativeDecimal(value)).toBeNull(); + }); + + it('rejects values above the supplied bound', () => { + expect(parseBoundedNonNegativeDecimal('1.1', 1)).toBeNull(); + }); +}); diff --git a/packages/perps-controller/tests/src/utils/transferData.test.ts b/packages/perps-controller/tests/src/utils/transferData.test.ts new file mode 100644 index 00000000000..7a4bb2074f4 --- /dev/null +++ b/packages/perps-controller/tests/src/utils/transferData.test.ts @@ -0,0 +1,26 @@ +import { generateERC20TransferData } from '../../../src/utils/transferData.js'; + +describe('generateERC20TransferData', () => { + it('encodes ERC-20 transfer calldata', () => { + expect( + generateERC20TransferData( + '0x000000000000000000000000000000000000dEaD', + '0x64', + ), + ).toBe( + '0xa9059cbb000000000000000000000000000000000000000000000000000000000000dead0000000000000000000000000000000000000000000000000000000000000064', + ); + }); + + it('requires both recipient and amount', () => { + expect(() => generateERC20TransferData('', '0x64')).toThrow( + "'toAddress' and 'amount' must be defined", + ); + expect(() => + generateERC20TransferData( + '0x000000000000000000000000000000000000dEaD', + '', + ), + ).toThrow("'toAddress' and 'amount' must be defined"); + }); +}); diff --git a/packages/perps-controller/tsconfig.build.json b/packages/perps-controller/tsconfig.build.json new file mode 100644 index 00000000000..26372414a07 --- /dev/null +++ b/packages/perps-controller/tsconfig.build.json @@ -0,0 +1,46 @@ +{ + "extends": "../../tsconfig.packages.build.json", + "compilerOptions": { + "baseUrl": "./", + "module": "ESNext", + "moduleResolution": "Bundler", + "outDir": "./dist", + "rootDir": "./src" + }, + "references": [ + { + "path": "../account-tree-controller/tsconfig.build.json" + }, + { + "path": "../authenticated-user-storage/tsconfig.build.json" + }, + { + "path": "../base-controller/tsconfig.build.json" + }, + { + "path": "../controller-utils/tsconfig.build.json" + }, + { + "path": "../geolocation-controller/tsconfig.build.json" + }, + { + "path": "../keyring-controller/tsconfig.build.json" + }, + { + "path": "../messenger/tsconfig.build.json" + }, + { + "path": "../network-controller/tsconfig.build.json" + }, + { + "path": "../profile-sync-controller/tsconfig.build.json" + }, + { + "path": "../remote-feature-flag-controller/tsconfig.build.json" + }, + { + "path": "../transaction-controller/tsconfig.build.json" + } + ], + "include": ["../../types", "./src"] +} diff --git a/packages/perps-controller/tsconfig.json b/packages/perps-controller/tsconfig.json new file mode 100644 index 00000000000..085e52868a2 --- /dev/null +++ b/packages/perps-controller/tsconfig.json @@ -0,0 +1,44 @@ +{ + "extends": "../../tsconfig.packages.json", + "compilerOptions": { + "baseUrl": "./", + "module": "ESNext", + "moduleResolution": "Bundler" + }, + "references": [ + { + "path": "../account-tree-controller" + }, + { + "path": "../authenticated-user-storage" + }, + { + "path": "../base-controller" + }, + { + "path": "../controller-utils" + }, + { + "path": "../geolocation-controller" + }, + { + "path": "../keyring-controller" + }, + { + "path": "../messenger" + }, + { + "path": "../network-controller" + }, + { + "path": "../profile-sync-controller" + }, + { + "path": "../remote-feature-flag-controller" + }, + { + "path": "../transaction-controller" + } + ], + "include": ["../../types", "./src", "./tests"] +} diff --git a/packages/perps-controller/typedoc.json b/packages/perps-controller/typedoc.json new file mode 100644 index 00000000000..c9da015dbf8 --- /dev/null +++ b/packages/perps-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/phishing-controller/CHANGELOG.md b/packages/phishing-controller/CHANGELOG.md index 2aac6cab848..0967d9afc04 100644 --- a/packages/phishing-controller/CHANGELOG.md +++ b/packages/phishing-controller/CHANGELOG.md @@ -1,4 +1,5 @@ # 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/), @@ -6,13 +7,540 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Changed + +- Optimize C2 domain blocklist lookups by switching internal storage from `Array` to `Set`, reducing per-lookup complexity from O(n) to O(1) ([#6388](https://github.com/MetaMask/core/pull/6388)) +- Bump `@metamask/transaction-controller` from `^69.5.2` to `^69.6.1` ([#9960](https://github.com/MetaMask/core/pull/9960), [#9969](https://github.com/MetaMask/core/pull/9969)) + +## [17.4.0] + +### Added + +- Add `isAddressScanSupportedChainId` so clients can check whether `scanAddress` will call the Security Alerts API for a chain ID, without duplicating `DEFAULT_CHAIN_ID_TO_NAME` and `ADDRESS_SCAN_SUPPORTED_CHAINS` ([#9946](https://github.com/MetaMask/core/pull/9946)) + +### Changed + +- Bump `@metamask/transaction-controller` from `^69.4.0` to `^69.5.2` ([#9780](https://github.com/MetaMask/core/pull/9780), [#9798](https://github.com/MetaMask/core/pull/9798), [#9823](https://github.com/MetaMask/core/pull/9823)) + +## [17.3.1] + +### Changed + +- Bump `@metamask/transaction-controller` from `^69.0.0` to `^69.4.0` ([#9568](https://github.com/MetaMask/core/pull/9568), [#9589](https://github.com/MetaMask/core/pull/9589), [#9593](https://github.com/MetaMask/core/pull/9593), [#9693](https://github.com/MetaMask/core/pull/9693), [#9735](https://github.com/MetaMask/core/pull/9735)) + +### Fixed + +- Address poisoning known recipients now use the actual token recipient decoded from calldata for confirmed ERC-20/ERC-721/ERC-1155 token transfers, instead of the token contract address from `txParams.to` ([#9699](https://github.com/MetaMask/core/pull/9699)) + +## [17.3.0] + +### Added + +- Extend `DEFAULT_CHAIN_ID_TO_NAME` with 19 additional chains: `xlayer`, `megaeth`, `tempo`, `tempo-testnet`, `kaia`, `robinhood`, `arc`, `plasma`, `mantle`, `katana`, `plume`, `kite-ai`, `monad-testnet`, `starknet`, `starknet-sepolia`, `stellar`, `bitcoin`, `sui`, `tron` ([#9506](https://github.com/MetaMask/core/pull/9506)) +- Add `TOKEN_SCAN_SUPPORTED_CHAINS` constant, `TokenScanSupportedChain` type, and `isTokenScanSupportedChain` type guard to gate `bulkScanTokens` per chain ([#9506](https://github.com/MetaMask/core/pull/9506)) +- Add `ADDRESS_SCAN_SUPPORTED_CHAINS` constant, `AddressScanSupportedChain` type, and `isAddressScanSupportedChain` type guard to gate `scanAddress` per chain ([#9506](https://github.com/MetaMask/core/pull/9506)) + +### Changed + +- `bulkScanTokens` now returns `{}` without calling the security-alerts API when the resolved chain is not in `TOKEN_SCAN_SUPPORTED_CHAINS` ([#9506](https://github.com/MetaMask/core/pull/9506)) +- `scanAddress` now returns `{ result_type: 'ErrorResult', label: '' }` without calling the security-alerts API when the resolved chain is not in `ADDRESS_SCAN_SUPPORTED_CHAINS` ([#9506](https://github.com/MetaMask/core/pull/9506)) + +## [17.2.1] + +### Changed + +- Bump `@metamask/controller-utils` from `^12.1.0` to `^12.3.0` ([#9058](https://github.com/MetaMask/core/pull/9058), [#9083](https://github.com/MetaMask/core/pull/9083), [#9218](https://github.com/MetaMask/core/pull/9218)) +- Bump `@metamask/transaction-controller` from `^65.4.0` to `^69.0.0` ([#8848](https://github.com/MetaMask/core/pull/8848), [#8999](https://github.com/MetaMask/core/pull/8999), [#9021](https://github.com/MetaMask/core/pull/9021), [#9027](https://github.com/MetaMask/core/pull/9027), [#9066](https://github.com/MetaMask/core/pull/9066), [#9089](https://github.com/MetaMask/core/pull/9089), [#9177](https://github.com/MetaMask/core/pull/9177), [#9203](https://github.com/MetaMask/core/pull/9203), [#9218](https://github.com/MetaMask/core/pull/9218), [#9253](https://github.com/MetaMask/core/pull/9253), [#9337](https://github.com/MetaMask/core/pull/9337), [#9349](https://github.com/MetaMask/core/pull/9349), [#9421](https://github.com/MetaMask/core/pull/9421), [#9456](https://github.com/MetaMask/core/pull/9456), [#9470](https://github.com/MetaMask/core/pull/9470)) +- Bump `@metamask/messenger` from `^1.2.0` to `^2.0.0` ([#9392](https://github.com/MetaMask/core/pull/9392)) + +## [17.2.0] + +### Added + +- Add `findSimilarAddresses` utility and `PhishingController:checkAddressPoisoning` messenger action to detect address poisoning attempts against known recipients ([#8171](https://github.com/MetaMask/core/pull/8171)) + - The controller now hydrates and maintains a set of known recipient addresses from confirmed transactions (`TransactionController`) and the address book (`AddressBookController`) + - Exposes match metadata including prefix/suffix match lengths, poisoning score, and diff indices +- Add `@metamask/address-book-controller` as a dependency ([#8171](https://github.com/MetaMask/core/pull/8171)) +- Support path-based phishing lists (`blocklistPaths`, `whitelistPaths`) and path-aware URL scanning for shared gateways (for example IPFS gateways and `sites.google.com`) via `getPhishingDetectionScanUrlParam`, `isPhishingDetectionPathBasedHostname`, and `PHISHING_DETECTION_PATH_BASED_ROOT_DOMAINS` ([#8662](https://github.com/MetaMask/core/pull/8662)) + +### Changed + +- Bump `@metamask/controller-utils` from `^12.0.0` to `^12.1.0` ([#8774](https://github.com/MetaMask/core/pull/8774)) +- Bump `@metamask/transaction-controller` from `^65.3.0` to `^65.4.0` ([#8796](https://github.com/MetaMask/core/pull/8796)) + +## [17.1.2] + +### Changed + +- Bump `@metamask/messenger` from `^1.0.0` to `^1.2.0` ([#8364](https://github.com/MetaMask/core/pull/8364), [#8373](https://github.com/MetaMask/core/pull/8373), [#8632](https://github.com/MetaMask/core/pull/8632)) +- Bump `@metamask/transaction-controller` from `^64.0.0` to `^65.3.0` ([#8432](https://github.com/MetaMask/core/pull/8432), [#8447](https://github.com/MetaMask/core/pull/8447), [#8482](https://github.com/MetaMask/core/pull/8482), [#8585](https://github.com/MetaMask/core/pull/8585), [#8613](https://github.com/MetaMask/core/pull/8613), [#8691](https://github.com/MetaMask/core/pull/8691), [#8722](https://github.com/MetaMask/core/pull/8722), [#8755](https://github.com/MetaMask/core/pull/8755)) +- Bump `@metamask/base-controller` from `^9.0.1` to `^9.1.0` ([#8457](https://github.com/MetaMask/core/pull/8457)) +- Bump `@metamask/controller-utils` from `^11.20.0` to `^12.0.0` ([#8755](https://github.com/MetaMask/core/pull/8755)) + +## [17.1.1] + +### Changed + +- Bump `@metamask/transaction-controller` from `^63.3.1` to `^64.0.0` ([#8359](https://github.com/MetaMask/core/pull/8359)) +- Bump `@metamask/controller-utils` from `^11.19.0` to `^11.20.0` ([#8344](https://github.com/MetaMask/core/pull/8344)) + +## [17.1.0] + +### Added + +- Add `getApprovals` method and messenger action to fetch token approvals with security enrichments from the security alerts API ([#8074](https://github.com/MetaMask/core/pull/8074)) +- Export approval-related types: `ApprovalsResponse`, `Approval`, `Allowance`, `ApprovalAsset`, `Exposure`, `Spender`, `ApprovalFeature`, `ApprovalResultType`, `ApprovalFeatureType` ([#8074](https://github.com/MetaMask/core/pull/8074)) +- Expose missing public `PhishingController` methods through its messenger ([#8269](https://github.com/MetaMask/core/pull/8269)) + - The following actions are now available: + - `PhishingController:bypass` + - `PhishingController:isBlockedRequest` + - `PhishingController:scanUrl` + - Corresponding action types (e.g. `PhishingControllerBypassAction`) are available as well. + +### Changed + +- `PhishingController` no longer advances `c2DomainBlocklistLastFetched` when the C2 domain blocklist fetch fails, allowing the blocklist to be retried on the next update cycle ([#8250](https://github.com/MetaMask/core/pull/8250)) +- Reduce default cache TTL for `DEFAULT_URL_SCAN_CACHE_TTL`, `DEFAULT_TOKEN_SCAN_CACHE_TTL`, and `DEFAULT_ADDRESS_SCAN_CACHE_TTL` from 15 minutes to 1 minute ([#8254](https://github.com/MetaMask/core/pull/8254)) +- Bump `@metamask/base-controller` from `^9.0.0` to `^9.0.1` ([#8317](https://github.com/MetaMask/core/pull/8317)) +- Bump `@metamask/messenger` from `^0.3.0` to `^1.0.0` ([#8317](https://github.com/MetaMask/core/pull/8317)) +- Bump `@metamask/transaction-controller` from `^63.0.0` to `^63.3.1` ([#8272](https://github.com/MetaMask/core/pull/8272), [#8301](https://github.com/MetaMask/core/pull/8301), [#8313](https://github.com/MetaMask/core/pull/8313), [#8317](https://github.com/MetaMask/core/pull/8317)) + +### Deprecated + +- Deprecate `test` method in favor of `testOrigin` ([#8269](https://github.com/MetaMask/core/pull/8269)) + - The `test` method is now renamed to `testOrigin` to better reflect its purpose of testing a domain origin for phishing. + - The old `test` method is still present but is now marked as deprecated and will be removed in a future release. +- Deprecate action types in favor of `PhishingController...Action` types ([#8269](https://github.com/MetaMask/core/pull/8269)) + - The following action types have been renamed: + - `TestOrigin` is now `PhishingControllerTestOriginAction`. + - `MaybeUpdateState` is now `PhishingControllerMaybeUpdateStateAction`. + - The old types are still exported but are now marked as deprecated and will + be removed in a future release. + +## [17.0.0] + +### Changed + +- Bump `@metamask/transaction-controller` from `^62.17.0` to `^63.0.0` ([#7996](https://github.com/MetaMask/core/pull/7996), [#8005](https://github.com/MetaMask/core/pull/8005), [#8031](https://github.com/MetaMask/core/pull/8031), [#8104](https://github.com/MetaMask/core/pull/8104), [#8140](https://github.com/MetaMask/core/pull/8140), [#8217](https://github.com/MetaMask/core/pull/8217), [#8225](https://github.com/MetaMask/core/pull/8225)) +- Bump `@metamask/controller-utils` from `^11.18.0` to `^11.19.0` ([#7995](https://github.com/MetaMask/core/pull/7995)) + +### Removed + +- **BREAKING:** Remove unused public methods `setStalelistRefreshInterval`, `setHotlistRefreshInterval`, `setC2DomainBlocklistRefreshInterval`, `setUrlScanCacheTTL`, `setUrlScanCacheMaxSize`, and `clearUrlScanCache` from `PhishingController` ([#8212](https://github.com/MetaMask/core/pull/8212)) + +## [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 + +- Add support for Monad network (`0x8f`) in token scanning ([#7237](https://github.com/MetaMask/core/pull/7237)) +- Add support for HyperEVM network (`0x3e7`) in token scanning ([#7873](https://github.com/MetaMask/core/pull/7873)) + +### Changed + +- Bump `@metamask/transaction-controller` from `^62.1.0` to `^62.16.0` ([#7220](https://github.com/MetaMask/core/pull/7220), [#7236](https://github.com/MetaMask/core/pull/7236), [#7257](https://github.com/MetaMask/core/pull/7257), [#7289](https://github.com/MetaMask/core/pull/7289), [#7325](https://github.com/MetaMask/core/pull/7325), [#7430](https://github.com/MetaMask/core/pull/7430), [#7494](https://github.com/MetaMask/core/pull/7494), [#7596](https://github.com/MetaMask/core/pull/7596), [#7602](https://github.com/MetaMask/core/pull/7602), [#7604](https://github.com/MetaMask/core/pull/7604), [#7642](https://github.com/MetaMask/core/pull/7642), [#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/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)) + +## [16.1.0] + +### Added + +- Export `TokenScanCacheData` and `TokenScanResultType` to allow consumers to have a type to reference if grabbing values directly from the controller's state ([#7208](https://github.com/MetaMask/core/pull/7208)) + +### Changed + +- Move peer dependencies for controller and service packages to direct dependencies ([#7209](https://github.com/MetaMask/core/pull/7209)) + - The dependencies moved are: + - `@metamask/transaction-controller` (^62.1.0) + - In clients, it is now possible for multiple versions of these packages to exist in the dependency tree. + - For example, this scenario would be valid: a client relies on `@metamask/controller-a` 1.0.0 and `@metamask/controller-b` 1.0.0, and `@metamask/controller-b` depends on `@metamask/controller-a` 1.1.0. + - Note, however, that the versions specified in the client's `package.json` always "win", and you are expected to keep them up to date so as not to break controller and service intercommunication. + +## [16.0.0] + +### Added + +- Add address scanning to detect malicious addresses ([#7118](https://github.com/MetaMask/core/pull/7118)) + - Add `scanAddress` method to scan addresses for security alerts + - Add `AddressScanResult` type + - Add `addressScanCache` to `PhishingControllerState` + - Add action registration for `scanAddress` method as `PhishingControllerScanAddressAction` + +### Changed + +- **BREAKING:** Bump `@metamask/transaction-controller` from `^61.0.0` to `^62.0.0` ([#7202](https://github.com/MetaMask/core/pull/7202)) +- Bump `@metamask/controller-utils` from `^11.15.0` to `^11.16.0` ([#7202](https://github.com/MetaMask/core/pull/7202)) + +## [15.0.1] + +### Fixed + +- Fixed the Transaction Controller listener to correctly pick up state changes for mobile ([#7139](https://github.com/MetaMask/core/pull/7139)) + +## [15.0.0] + +### Changed + +- **BREAKING:** Use new `Messenger` from `@metamask/messenger` ([#6535](https://github.com/MetaMask/core/pull/6535)) + - Previously, `PhishingController` accepted a `RestrictedMessenger` instance from `@metamask/base-controller`. +- **BREAKING:** Metadata property `anonymous` renamed to `includeInDebugSnapshot` ([#6535](https://github.com/MetaMask/core/pull/6535)) +- **BREAKING:** Bump `@metamask/transaction-controller` from `^60.0.0` to `^61.0.0` ([#6962](https://github.com/MetaMask/core/pull/6962)) +- Bump `@metamask/base-controller` from `^8.4.2` to `^9.0.0` ([#6962](https://github.com/MetaMask/core/pull/6962)) + +## [14.1.3] + +### Changed + +- Bump `@metamask/base-controller` from `^8.4.1` to `^8.4.2` ([#6917](https://github.com/MetaMask/core/pull/6917)) +- Bump `@metamask/transaction-controller` from `^60.7.0` to `^60.8.0` ([#6883](https://github.com/MetaMask/core/pull/6883)) + +## [14.1.2] + +### Changed + +- Update hotlist endpoint from v1 to v2 for blocklistPaths support ([#6815](https://github.com/MetaMask/core/pull/6815)) + - Changed `METAMASK_HOTLIST_DIFF_FILE` from `/v1/diffsSince` to `/v2/diffsSince` + - Removed query parameter approach for blocklistPaths in favor of v2 endpoint + +## [14.1.1] + +### Changed + +- Bump `@metamask/base-controller` from `^8.4.0` to `^8.4.1` ([#6807](https://github.com/MetaMask/core/pull/6807)) +- Bump `@metamask/controller-utils` from `^11.14.0` to `^11.14.1` ([#6807](https://github.com/MetaMask/core/pull/6807)) + +### Fixed + +- Fetches the hotlist endpoint with a query param for blocklistPaths ([#6808](https://github.com/MetaMask/core/pull/6808)) + +## [14.1.0] + +### Added + +- Add path-based blocking [#6416](https://github.com/MetaMask/core/pull/6416) + - Add `blocklistPaths` to `PhishingDetectorList` + - Add `blocklistPaths` to `PhishingDetectorConfiguration` + - Add `whitelistPaths` to `PhishingControllerState` + - Adds a type called PathTrie + +### Fixed + +- Fixed phishing detector initialization failure when domain lists contain invalid values (numbers, null, undefined) by filtering them out ([#6767](https://github.com/MetaMask/core/pull/6767)) + +## [14.0.0] + +### Added + +- Add bulk token scanning functionality to detect malicious tokens ([#6483](https://github.com/MetaMask/core/pull/6483)) + - Add `bulkScanTokens` method to scan multiple tokens for malicious activity + - Add `BulkTokenScanRequest` and `BulkTokenScanResponse` types + - Add `tokenScanCache` to `PhishingControllerState` + - Add proper action registration for `bulkScanTokens` method as `PhishingControllerBulkScanTokensAction` + - Support for multiple chains including Ethereum, Polygon, BSC, Arbitrum, Avalanche, Base, Optimism, ect... +- Add token screening from transaction simulation data ([#6617](https://github.com/MetaMask/core/pull/6617)) + - Add `#onTransactionControllerStateChange` method to handle transaction state changes + - Add `#scanTokensFromSimulation` method to extract and scan tokens from transaction simulation data + - Add `start` and `stop` methods to manage Transaction Controller state change subscription +- Add two new controller state metadata properties: `includeInStateLogs` and `usedInUi` ([#6587](https://github.com/MetaMask/core/pull/6587)) + +### Changed + +- Bump `@metamask/base-controller` from `^8.0.1` to `^8.4.0` ([#6284](https://github.com/MetaMask/core/pull/6284), [#6355](https://github.com/MetaMask/core/pull/6355), [#6465](https://github.com/MetaMask/core/pull/6465), [#6632](https://github.com/MetaMask/core/pull/6632)) +- Bump `@metamask/controller-utils` from `^11.11.0` to `^11.14.0` ([#6303](https://github.com/MetaMask/core/pull/6303), [#6620](https://github.com/MetaMask/core/pull/6620), [#6629](https://github.com/MetaMask/core/pull/6629)) + +- Bump `@noble/hashes` from `^1.4.0` to `^1.8.0` ([#6101](https://github.com/MetaMask/core/pull/6101)) + +## [13.1.0] + +### Added + +- Add proper action registration for `bulkScanUrls` method as `PhishingControllerBulkScanUrlsAction` ([#6105](https://github.com/MetaMask/core/pull/6105)) +- Export `PhishingControllerBulkScanUrlsAction` type for external use ([#6105](https://github.com/MetaMask/core/pull/6105)) + +## [13.0.0] + +### Added + +- Exports `UrlScanCacheEntry` ([#6095](https://github.com/MetaMask/core/pull/6095)) + +### Changed + +- **BREAKING**`scanUrl` hits the v2 endpoint now. Returns `hostname` instead of `domainName` now. ([#5981](https://github.com/MetaMask/core/pull/5981)) +- Bump `@metamask/controller-utils` from `^11.10.0` to `^11.11.0` ([#6069](https://github.com/MetaMask/core/pull/6069)) + +## [12.6.0] + +### Added + +- Added `Verified` to `RecommendedAction` for `scanUrl` ([#5964](https://github.com/MetaMask/core/pull/5964)) + +### Changed + +- Bump `@metamask/base-controller` from ^8.0.0 to ^8.0.1 ([#5722](https://github.com/MetaMask/core/pull/5722)) +- Bump `@metamask/controller-utils` to `^11.9.0` ([#5935](https://github.com/MetaMask/core/pull/5935), [#5765](https://github.com/MetaMask/core/pull/5765), [#5812](https://github.com/MetaMask/core/pull/5812)) + +## [12.5.0] + +### Added + +- Add URL scan cache functionality to improve performance ([#5625](https://github.com/MetaMask/core/pull/5625)) + - Added `UrlScanCache` class for caching phishing detection scan results + - Added methods to `PhishingController`: `setUrlScanCacheTTL`, `setUrlScanCacheMaxSize`, `clearUrlScanCache` + - Added URL scan cache state to `PhishingControllerState` + - Added configuration options: `urlScanCacheTTL` and `urlScanCacheMaxSize` +- Add `bulkScanUrls` method to `PhishingController` for scanning multiple URLs for phishing in bulk ([#5682](https://github.com/MetaMask/core/pull/5682)) +- Add `BulkPhishingDetectionScanResponse` type for bulk URL scan results ([#5682](https://github.com/MetaMask/core/pull/5682)) +- Add `PHISHING_DETECTION_BULK_SCAN_ENDPOINT` constant ([#5682](https://github.com/MetaMask/core/pull/5682)) + +### Changed + +- Enhance `bulkScanUrls` method to leverage URL scan cache for improved performance ([#5688](https://github.com/MetaMask/core/pull/5688)) + - URLs are now checked against the cache before making API requests + - Only uncached URLs are sent to the phishing detection API + - API results are automatically stored in the cache for future use +- Bump `@metamask/controller-utils` to `^11.7.0` ([#5583](https://github.com/MetaMask/core/pull/5583)) + +## [12.4.1] + +### Fixed + +- Fixed an edge case in `PhishingController` where empty phishing lists could trigger API requests with invalid `-Infinity` timestamps ([#5385](https://github.com/MetaMask/core/pull/5385)) +- Fixed `RecommendedAction` not being exported correctly ([#5456](https://github.com/MetaMask/core/pull/5456)) + +## [12.4.0] + +### Added + +- Add `scanURL` to `PhishingController` ([#5319](https://github.com/MetaMask/core/pull/5319)) +- Add `PhishingDetectionScanResult` ([#5319](https://github.com/MetaMask/core/pull/5319)) +- Add `RecommendedAction` to `PhishingDetectionScanResult` ([#5319](https://github.com/MetaMask/core/pull/5319)) +- Add `getHostnameFromWebUrl` to only get hostnames on web URLs. ([#5319](https://github.com/MetaMask/core/pull/5319)) + +### Fixed + +- Fixed `getHostnameFromUrl` to return null when the URL's hostname only contains '.' ([#5319](https://github.com/MetaMask/core/pull/5319)) + +## [12.3.2] + +### Changed + +- Bump `@metamask/base-controller` from `^7.0.2` to `^8.0.0`,, ([#5079](https://github.com/MetaMask/core/pull/5079), [#5135](https://github.com/MetaMask/core/pull/5135), [#5305](https://github.com/MetaMask/core/pull/5305)) +- Bump `@metamask/controller-utils` from `^11.4.4` to `^11.5.0`, ([#5135](https://github.com/MetaMask/core/pull/5135), [#5272](https://github.com/MetaMask/core/pull/5272)) + +## [12.3.1] + +### Changed + +- Bump `@metamask/base-controller` from `^7.0.1` to `^7.0.2` ([#4862](https://github.com/MetaMask/core/pull/4862)) +- Bump `@metamask/controller-utils` from `^11.4.0` to `^11.4.4` ([#4862](https://github.com/MetaMask/core/pull/4862), [#4870](https://github.com/MetaMask/core/pull/4870), [#4915](https://github.com/MetaMask/core/pull/4915), [#5012](https://github.com/MetaMask/core/pull/5012)) + +### Fixed + +- Correct ESM-compatible build so that imports of the following packages that re-export other modules via `export *` are no longer corrupted: ([#5011](https://github.com/MetaMask/core/pull/5011)) + - `punycode/punycode.js` + +## [12.3.0] + +### Fixed + +- Fixed extension performance issues ([#4853](https://github.com/MetaMask/core/pull/4853)) + +## [12.2.0] + +### Changed + +- Changed the c2 blocklist fetch interval from 15 minutes to 5 minutes ([#4850](https://github.com/MetaMask/core/pull/4850)) + +## [12.1.0] + +### Fixed + +- Update the phishing detector validation to drop invalid configs from detector ([#4820](https://github.com/MetaMask/core/pull/4820)) + +## [12.0.3] + +### Fixed + +- Produce and export ESM-compatible TypeScript type declaration files in addition to CommonJS-compatible declaration files ([#4648](https://github.com/MetaMask/core/pull/4648)) + - Previously, this package shipped with only one variant of type declaration + files, and these files were only CommonJS-compatible, and the `exports` + field in `package.json` linked to these files. This is an anti-pattern and + was rightfully flagged by the + ["Are the Types Wrong?"](https://arethetypeswrong.github.io/) tool as + ["masquerading as CJS"](https://github.com/arethetypeswrong/arethetypeswrong.github.io/blob/main/docs/problems/FalseCJS.md). + All of the ATTW checks now pass. +- Remove chunk files. ([#4648](https://github.com/MetaMask/core/pull/4648)) + - Previously, the build tool we used to generate JavaScript files extracted + common code to "chunk" files. While this was intended to make this package + more tree-shakeable, it also made debugging more difficult for our + development teams. These chunk files are no longer present. + +## [12.0.2] + +### Fixed + +- Export `PhishingDetectorResultType` enum type ([#4674](https://github.com/MetaMask/core/pull/4674)) +- Export `PhishingDetector` type ([#4553](https://github.com/MetaMask/core/pull/4553)) + +## [12.0.1] + +### Added + +- Add `getHostnameFromUrl` utility function to standardize hostname extraction from URLs ([#4645](https://github.com/MetaMask/core/pull/4645)) + +### Fixed + +- Update `test`, `isBlockedRequest`, and `bypass` methods to use the hostname for allowlist checks instead of the full origin ([#4645](https://github.com/MetaMask/core/pull/4645)) + - The previous approach of using the full origin had limitations in dealing with subdomains or variations in the URL structure, which could lead to inconsistent results or false negatives. + +## [12.0.0] + +### Added + +- Add allowlist functionality to the C2 domain detection system ([#4464](https://github.com/MetaMask/core/pull/4464)) +- Add `PhishingController` functionality for blocking client-side C2 requests by managing a hashed C2 request blocklist ([#4526](https://github.com/MetaMask/core/pull/4526)) + - Add `requestBlocklist` type to `ListTypes`. + - Add `isBlockedRequest` method to `PhishingController`. + - Add `isMaliciousRequestDomain` method to `PhishingDetector`. + - Add handling of `requestBlocklist` in `PhishingDetector` configuration. + - Add logic to update and check `requestBlocklist` when updating a stale list. + - Add `sha256Hash` function to generate SHA-256 hash of a domain. +- Define and export new types: `PhishingControllerGetStateAction`, `PhishingControllerStateChangeEvent`, `PhishingControllerEvents` ([#4633](https://github.com/MetaMask/core/pull/4633)) + +### Changed + +- **BREAKING:** Add `@noble/hashes` `^1.4.0` as dependency ([#4526](https://github.com/MetaMask/core/pull/4526)) +- **BREAKING:**: Add `ethereum-cryptography` `^2.1.2` as dependency ([#4526](https://github.com/MetaMask/core/pull/4526)) +- **BREAKING:** `PhishingControllerMessenger` must allow internal events defined in the `PhishingControllerEvents` type ([#4633](https://github.com/MetaMask/core/pull/4633)) +- Widen `PhishingControllerActions` to include the `PhishingController:getState` action ([#4633](https://github.com/MetaMask/core/pull/4633)) +- Bump `@metamask/base-controller` from `^6.0.2` to `^6.0.3` ([#4625](https://github.com/MetaMask/core/pull/4625)) +- Bump `@metamask/controller-utils` from `^11.0.2` to `^11.1.0` ([#4639](https://github.com/MetaMask/core/pull/4639)) + +## [11.0.0] + +### Changed + +- Bump `typescript` from `~5.0.4` to `~5.2.2` ([#4584](https://github.com/MetaMask/core/pull/4584), [#4576](https://github.com/MetaMask/core/pull/4576)) + +### Removed + +- **BREAKING:** Remove the Phishfort list from the PhishingController ([#4621](https://github.com/MetaMask/core/pull/4621)) + +## [10.1.1] + +### Changed + +- Bump TypeScript version to `~5.0.4` and set `moduleResolution` option to `Node16` ([#3645](https://github.com/MetaMask/core/pull/3645)) +- Bump `@metamask/base-controller` from `^6.0.1` to `^6.0.2` ([#4544](https://github.com/MetaMask/core/pull/4544)) +- Bump `@metamask/controller-utils` from `^11.0.1` to `^11.0.2` ([#4544](https://github.com/MetaMask/core/pull/4544)) + +## [10.1.0] + +### Added + +- Port `PhishingDetector` from `eth-phishing-detector`; add TypeScript types ([#4137](https://github.com/MetaMask/core/pull/4137)) +- Add support for IPFS CID blocking to `PhishingDetector` ([#4465](https://github.com/MetaMask/core/pull/4465)) + +### Changed + +- Bump `@metamask/base-controller` to `^6.0.1` ([#4517](https://github.com/MetaMask/core/pull/4517)) +- Bump `@metamask/controller-utils` to `^11.0.1` ([#4517](https://github.com/MetaMask/core/pull/4517)) + +## [10.0.0] + +### Changed + +- **BREAKING:** Bump minimum Node version to 18.18 ([#3611](https://github.com/MetaMask/core/pull/3611)) +- Bump `@metamask/base-controller` to `^6.0.0` ([#4352](https://github.com/MetaMask/core/pull/4352)) +- Bump `@metamask/controller-utils` to `^11.0.0` ([#4352](https://github.com/MetaMask/core/pull/4352)) + +## [9.0.4] + +### Changed + +- Bump `@metamask/controller-utils` to `^10.0.0` ([#4342](https://github.com/MetaMask/core/pull/4342)) + +## [9.0.3] + +### Changed + +- Update phishing detection API endpoint from `*.metafi.codefi.network` to `*.api.cx.metamask.io` ([#4301](https://github.com/MetaMask/core/pull/4301)) + +## [9.0.2] + +### Changed + +- Changed Stalelist and hotlist update intervals ([#4202](https://github.com/MetaMask/core/pull/4202)) + - Updated the Stalelist update interval to 30 days and the hotlist update interval to 5 mins +- Bump `@metamask/controller-utils` version to `~9.1.0` ([#4153](https://github.com/MetaMask/core/pull/4153)) +- Bump TypeScript version to `~4.9.5` ([#4084](https://github.com/MetaMask/core/pull/4084)) +- Bump `@metamask/base-controller` to `^5.0.2` + +## [9.0.1] + +### Fixed + +- Fix `types` field in `package.json` ([#4047](https://github.com/MetaMask/core/pull/4047)) + +## [9.0.0] + +### Added + +- **BREAKING**: Add ESM build ([#3998](https://github.com/MetaMask/core/pull/3998)) + - It's no longer possible to import files from `./dist` directly. + +### Changed + +- **BREAKING:** Bump `@metamask/base-controller` to `^5.0.0` ([#4039](https://github.com/MetaMask/core/pull/4039)) + - This version has a number of breaking changes. See the changelog for more. +- Bump `@metamask/controller-utils` to `^9.0.0` ([#4039](https://github.com/MetaMask/core/pull/4039)) + +## [8.0.2] + +### Changed + +- Bump `@metamask/base-controller` to `^4.1.1` ([#3760](https://github.com/MetaMask/core/pull/3760), [#3821](https://github.com/MetaMask/core/pull/3821)) +- Bump `@metamask/controller-utils` to `^8.0.2` ([#3821](https://github.com/MetaMask/core/pull/3821)) + +## [8.0.1] + +### Changed + +- Bump `@metamask/base-controller` to `^4.0.1` ([#3695](https://github.com/MetaMask/core/pull/3695)) +- Bump `@metamask/controller-utils` to `^8.0.1` ([#3695](https://github.com/MetaMask/core/pull/3695), [#3678](https://github.com/MetaMask/core/pull/3678), [#3667](https://github.com/MetaMask/core/pull/3667), [#3580](https://github.com/MetaMask/core/pull/3580)) + +## [8.0.0] + +### Changed + +- **BREAKING:** Bump `@metamask/base-controller` to ^4.0.0 ([#2063](https://github.com/MetaMask/core/pull/2063)) + - This is breaking because the type of the `messenger` has backward-incompatible changes. See the changelog for this package for more. +- Bump `@metamask/controller-utils` to ^6.0.0 ([#2063](https://github.com/MetaMask/core/pull/2063)) + ## [7.0.1] + ### Changed + - Bump dependency on `@metamask/base-controller` to ^3.2.3 ([#1747](https://github.com/MetaMask/core/pull/1747)) - Bump dependency on `@metamask/controller-utils` to ^5.0.2 ([#1747](https://github.com/MetaMask/core/pull/1747)) ## [7.0.0] + ### Changed + - **BREAKING:** Migrate `PhishingController` to BaseControllerV2 ([#1705](https://github.com/MetaMask/core/pull/1705)) - `PhishingController` now expects a `messenger` option (and corresponding type `PhishingControllerMessenger` is now available) - The constructor takes a single argument, an options bag, instead of three arguments @@ -20,25 +548,35 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Update TypeScript to v4.8.x ([#1718](https://github.com/MetaMask/core/pull/1718)) ## [6.0.2] + ### Changed + - Bump dependency on `@metamask/controller-utils` to ^5.0.0 ## [6.0.1] + ### Changed + - Bump dependency on `@metamask/base-controller` to ^3.2.1 - Bump dependency on `@metamask/controller-utils` to ^4.3.2 ## [6.0.0] + ### Changed + - **BREAKING:** Remove fallback phishing configuration ([#1527](https://github.com/MetaMask/core/pull/1527)) - The default configuration is now blank. A custom initial configuration can still be specified via the constructor to preserve the old behavior. ## [5.0.0] + ### Changed + - **BREAKING:** Bump to Node 16 ([#1262](https://github.com/MetaMask/core/pull/1262)) ## [4.0.0] + ### Changed + - **BREAKING:** Switch to new phishing configuration API that returns a diff since the last update ([#1123](https://github.com/MetaMask/core/pull/1123)) - The "hotlist" has been replaced by a service that returns any configuration changes since the last update. This should reduce network traffic even further. - The endpoints used are now `https://phishing-detection.metafi.codefi.network/v1/stalelist` and `https://phishing-detection.metafi.codefi.network/v1/diffsSince/:lastUpdated` @@ -47,12 +585,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - The PhishFort config is deduplicated server-side, so it should have zero overlap with the MetaMask configuration (which helps reduce memory/disk usage) ## [3.0.0] + ### Removed + - **BREAKING:** Remove `isomorphic-fetch` ([#1106](https://github.com/MetaMask/controllers/pull/1106)) - Consumers must now import `isomorphic-fetch` or another polyfill themselves if they are running in an environment without `fetch` ## [2.0.0] + ### Changed + - **BREAKING:** Refactor to Cost-Optimized Phishing List Data Architecture. ([#1080](https://github.com/MetaMask/core/pull/1080)) - Rather than periodically downloading two separate configurations (MetaMask and Phishfort), we now download a combined "stalelist" and "hotlist". The stalelist is downloaded every 4 days, and the hotlist is downloaded every 30 minutes. The hotlist only includes data from the last 8 days, which should dramatically reduce the required network traffic for phishing config updates. - When a site is blocked, we no longer know which list is responsible due to the combined format. We will need to come up with another way to attribute blocks to a specific list; this controller will no longer be responsible for that. @@ -71,25 +613,34 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - The `updatePhishingLists` method has been replaced by `updateStalelist` and `updateHotlist` ## [1.1.2] + ### Fixed + - Improve performance of phishing list update ([#1086](https://github.com/MetaMask/core/pull/1086)) - We now use a `Set` + `has` method instead of the array `includes` method for detecting overlap between phishing lists after an update. ## [1.1.1] + ### Changed + - Rename this repository to `core` ([#1031](https://github.com/MetaMask/controllers/pull/1031)) - Update `@metamask/controller-utils` package ([#1041](https://github.com/MetaMask/controllers/pull/1041)) ## [1.1.0] + ### Added + - Add method to conditionally update the phishing lists ([#986](https://github.com/MetaMask/core/pull/986)) ### Changed + - Relax dependencies on `@metamask/base-controller` and `@metamask/controller-utils` (use `^` instead of `~`) ([#998](https://github.com/MetaMask/core/pull/998)) - Expose `lastFetched` in PhishingController state ([#986](https://github.com/MetaMask/core/pull/986)) ## [1.0.0] + ### Added + - Initial release - As a result of converting our shared controllers repo into a monorepo ([#831](https://github.com/MetaMask/core/pull/831)), we've created this package from select parts of [`@metamask/controllers` v33.0.0](https://github.com/MetaMask/core/tree/v33.0.0), namely: - `src/third-party/PhishingController.ts` @@ -97,7 +648,54 @@ 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@7.0.1...HEAD +[Unreleased]: https://github.com/MetaMask/core/compare/@metamask/phishing-controller@17.4.0...HEAD +[17.4.0]: https://github.com/MetaMask/core/compare/@metamask/phishing-controller@17.3.1...@metamask/phishing-controller@17.4.0 +[17.3.1]: https://github.com/MetaMask/core/compare/@metamask/phishing-controller@17.3.0...@metamask/phishing-controller@17.3.1 +[17.3.0]: https://github.com/MetaMask/core/compare/@metamask/phishing-controller@17.2.1...@metamask/phishing-controller@17.3.0 +[17.2.1]: https://github.com/MetaMask/core/compare/@metamask/phishing-controller@17.2.0...@metamask/phishing-controller@17.2.1 +[17.2.0]: https://github.com/MetaMask/core/compare/@metamask/phishing-controller@17.1.2...@metamask/phishing-controller@17.2.0 +[17.1.2]: https://github.com/MetaMask/core/compare/@metamask/phishing-controller@17.1.1...@metamask/phishing-controller@17.1.2 +[17.1.1]: https://github.com/MetaMask/core/compare/@metamask/phishing-controller@17.1.0...@metamask/phishing-controller@17.1.1 +[17.1.0]: https://github.com/MetaMask/core/compare/@metamask/phishing-controller@17.0.0...@metamask/phishing-controller@17.1.0 +[17.0.0]: https://github.com/MetaMask/core/compare/@metamask/phishing-controller@16.3.0...@metamask/phishing-controller@17.0.0 +[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 +[15.0.1]: https://github.com/MetaMask/core/compare/@metamask/phishing-controller@15.0.0...@metamask/phishing-controller@15.0.1 +[15.0.0]: https://github.com/MetaMask/core/compare/@metamask/phishing-controller@14.1.3...@metamask/phishing-controller@15.0.0 +[14.1.3]: https://github.com/MetaMask/core/compare/@metamask/phishing-controller@14.1.2...@metamask/phishing-controller@14.1.3 +[14.1.2]: https://github.com/MetaMask/core/compare/@metamask/phishing-controller@14.1.1...@metamask/phishing-controller@14.1.2 +[14.1.1]: https://github.com/MetaMask/core/compare/@metamask/phishing-controller@14.1.0...@metamask/phishing-controller@14.1.1 +[14.1.0]: https://github.com/MetaMask/core/compare/@metamask/phishing-controller@14.0.0...@metamask/phishing-controller@14.1.0 +[14.0.0]: https://github.com/MetaMask/core/compare/@metamask/phishing-controller@13.1.0...@metamask/phishing-controller@14.0.0 +[13.1.0]: https://github.com/MetaMask/core/compare/@metamask/phishing-controller@13.0.0...@metamask/phishing-controller@13.1.0 +[13.0.0]: https://github.com/MetaMask/core/compare/@metamask/phishing-controller@12.6.0...@metamask/phishing-controller@13.0.0 +[12.6.0]: https://github.com/MetaMask/core/compare/@metamask/phishing-controller@12.5.0...@metamask/phishing-controller@12.6.0 +[12.5.0]: https://github.com/MetaMask/core/compare/@metamask/phishing-controller@12.4.1...@metamask/phishing-controller@12.5.0 +[12.4.1]: https://github.com/MetaMask/core/compare/@metamask/phishing-controller@12.4.0...@metamask/phishing-controller@12.4.1 +[12.4.0]: https://github.com/MetaMask/core/compare/@metamask/phishing-controller@12.3.2...@metamask/phishing-controller@12.4.0 +[12.3.2]: https://github.com/MetaMask/core/compare/@metamask/phishing-controller@12.3.1...@metamask/phishing-controller@12.3.2 +[12.3.1]: https://github.com/MetaMask/core/compare/@metamask/phishing-controller@12.3.0...@metamask/phishing-controller@12.3.1 +[12.3.0]: https://github.com/MetaMask/core/compare/@metamask/phishing-controller@12.2.0...@metamask/phishing-controller@12.3.0 +[12.2.0]: https://github.com/MetaMask/core/compare/@metamask/phishing-controller@12.1.0...@metamask/phishing-controller@12.2.0 +[12.1.0]: https://github.com/MetaMask/core/compare/@metamask/phishing-controller@12.0.3...@metamask/phishing-controller@12.1.0 +[12.0.3]: https://github.com/MetaMask/core/compare/@metamask/phishing-controller@12.0.2...@metamask/phishing-controller@12.0.3 +[12.0.2]: https://github.com/MetaMask/core/compare/@metamask/phishing-controller@12.0.1...@metamask/phishing-controller@12.0.2 +[12.0.1]: https://github.com/MetaMask/core/compare/@metamask/phishing-controller@12.0.0...@metamask/phishing-controller@12.0.1 +[12.0.0]: https://github.com/MetaMask/core/compare/@metamask/phishing-controller@11.0.0...@metamask/phishing-controller@12.0.0 +[11.0.0]: https://github.com/MetaMask/core/compare/@metamask/phishing-controller@10.1.1...@metamask/phishing-controller@11.0.0 +[10.1.1]: https://github.com/MetaMask/core/compare/@metamask/phishing-controller@10.1.0...@metamask/phishing-controller@10.1.1 +[10.1.0]: https://github.com/MetaMask/core/compare/@metamask/phishing-controller@10.0.0...@metamask/phishing-controller@10.1.0 +[10.0.0]: https://github.com/MetaMask/core/compare/@metamask/phishing-controller@9.0.4...@metamask/phishing-controller@10.0.0 +[9.0.4]: https://github.com/MetaMask/core/compare/@metamask/phishing-controller@9.0.3...@metamask/phishing-controller@9.0.4 +[9.0.3]: https://github.com/MetaMask/core/compare/@metamask/phishing-controller@9.0.2...@metamask/phishing-controller@9.0.3 +[9.0.2]: https://github.com/MetaMask/core/compare/@metamask/phishing-controller@9.0.1...@metamask/phishing-controller@9.0.2 +[9.0.1]: https://github.com/MetaMask/core/compare/@metamask/phishing-controller@9.0.0...@metamask/phishing-controller@9.0.1 +[9.0.0]: https://github.com/MetaMask/core/compare/@metamask/phishing-controller@8.0.2...@metamask/phishing-controller@9.0.0 +[8.0.2]: https://github.com/MetaMask/core/compare/@metamask/phishing-controller@8.0.1...@metamask/phishing-controller@8.0.2 +[8.0.1]: https://github.com/MetaMask/core/compare/@metamask/phishing-controller@8.0.0...@metamask/phishing-controller@8.0.1 +[8.0.0]: https://github.com/MetaMask/core/compare/@metamask/phishing-controller@7.0.1...@metamask/phishing-controller@8.0.0 [7.0.1]: https://github.com/MetaMask/core/compare/@metamask/phishing-controller@7.0.0...@metamask/phishing-controller@7.0.1 [7.0.0]: https://github.com/MetaMask/core/compare/@metamask/phishing-controller@6.0.2...@metamask/phishing-controller@7.0.0 [6.0.2]: https://github.com/MetaMask/core/compare/@metamask/phishing-controller@6.0.1...@metamask/phishing-controller@6.0.2 diff --git a/packages/phishing-controller/LICENSE b/packages/phishing-controller/LICENSE index ddfbecf9020..bbed2e24b91 100644 --- a/packages/phishing-controller/LICENSE +++ b/packages/phishing-controller/LICENSE @@ -18,3 +18,4 @@ 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/phishing-controller/jest.config.js b/packages/phishing-controller/jest.config.js index ca084133399..422b97aa459 100644 --- a/packages/phishing-controller/jest.config.js +++ b/packages/phishing-controller/jest.config.js @@ -17,7 +17,7 @@ module.exports = merge(baseConfig, { // An object that configures minimum threshold enforcement for coverage results coverageThreshold: { global: { - branches: 100, + branches: 98.95, functions: 100, lines: 100, statements: 100, diff --git a/packages/phishing-controller/package.json b/packages/phishing-controller/package.json index ac7a0f79c77..cc124b85ee6 100644 --- a/packages/phishing-controller/package.json +++ b/packages/phishing-controller/package.json @@ -1,57 +1,85 @@ { "name": "@metamask/phishing-controller", - "version": "7.0.1", + "version": "17.4.0", "description": "Maintains a periodically updated list of approved and unapproved website origins", "keywords": [ - "MetaMask", - "Ethereum" + "Ethereum", + "MetaMask" ], "homepage": "https://github.com/MetaMask/core/tree/main/packages/phishing-controller#readme", "bugs": { "url": "https://github.com/MetaMask/core/issues" }, + "license": "MIT", "repository": { "type": "git", "url": "https://github.com/MetaMask/core.git" }, - "license": "MIT", - "main": "./dist/index.js", - "types": "./dist/index.d.ts", "files": [ "dist/" ], + "sideEffects": false, + "main": "./dist/index.cjs", + "types": "./dist/index.d.cts", + "exports": { + ".": { + "import": { + "types": "./dist/index.d.mts", + "default": "./dist/index.mjs" + }, + "require": { + "types": "./dist/index.d.cts", + "default": "./dist/index.cjs" + } + }, + "./package.json": "./package.json" + }, + "publishConfig": { + "access": "public", + "registry": "https://registry.npmjs.org/" + }, "scripts": { + "build": "ts-bridge --project tsconfig.build.json --verbose --clean --no-references", + "build:all": "ts-bridge --project tsconfig.build.json --verbose --clean", "build:docs": "typedoc", + "changelog:update": "../../scripts/update-changelog.sh @metamask/phishing-controller", "changelog:validate": "../../scripts/validate-changelog.sh @metamask/phishing-controller", - "publish:preview": "yarn npm publish --tag preview", - "test": "jest", - "test:clean": "jest --clearCache", - "test:watch": "jest --watch" + "lint:tsconfigs": "tsx ../../scripts/lint-tsconfigs/lint-tsconfigs.mts", + "lint:tsconfigs:fix": "tsx ../../scripts/lint-tsconfigs/lint-tsconfigs.mts --fix", + "messenger-action-types:check": "tsx ../../packages/messenger-cli/src/cli.ts --formatter oxfmt --esm --check", + "messenger-action-types:generate": "tsx ../../packages/messenger-cli/src/cli.ts --formatter oxfmt --esm --generate", + "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": "^3.2.3", - "@metamask/controller-utils": "^5.0.2", + "@metamask/address-book-controller": "^7.1.2", + "@metamask/base-controller": "^9.1.0", + "@metamask/controller-utils": "^12.3.0", + "@metamask/messenger": "^2.0.0", + "@metamask/transaction-controller": "^69.6.1", + "@noble/hashes": "^1.8.0", "@types/punycode": "^2.1.0", - "eth-phishing-detect": "^1.2.0", + "ethereum-cryptography": "^2.1.2", + "fastest-levenshtein": "^1.0.16", "punycode": "^2.1.1" }, "devDependencies": { - "@metamask/auto-changelog": "^3.1.0", - "@types/jest": "^27.4.1", + "@metamask/auto-changelog": "^6.1.0", + "@ts-bridge/cli": "^0.6.4", + "@types/jest": "^30.0.0", "deepmerge": "^4.2.2", - "jest": "^27.5.1", + "jest": "^30.4.2", "nock": "^13.3.1", - "sinon": "^9.2.4", - "ts-jest": "^27.1.4", - "typedoc": "^0.24.8", + "ts-jest": "^29.4.11", + "tsx": "^4.20.5", + "typedoc": "^0.25.13", "typedoc-plugin-missing-exports": "^2.0.0", - "typescript": "~4.8.4" + "typescript": "~5.3.3" }, "engines": { - "node": ">=16.0.0" - }, - "publishConfig": { - "access": "public", - "registry": "https://registry.npmjs.org/" + "node": "^18.18 || >=20" } } diff --git a/packages/phishing-controller/src/BulkTokenScan.test.ts b/packages/phishing-controller/src/BulkTokenScan.test.ts new file mode 100644 index 00000000000..7f2ab12fc82 --- /dev/null +++ b/packages/phishing-controller/src/BulkTokenScan.test.ts @@ -0,0 +1,786 @@ +import { safelyExecuteWithTimeout } from '@metamask/controller-utils'; +import { Messenger, MOCK_ANY_NAMESPACE } from '@metamask/messenger'; +import type { + MessengerActions, + MessengerEvents, + MockAnyNamespace, +} from '@metamask/messenger'; +import nock, { cleanAll } from 'nock'; + +import { + PhishingController, + SECURITY_ALERTS_BASE_URL, + TOKEN_BULK_SCANNING_ENDPOINT, +} from './PhishingController.js'; +import type { + PhishingControllerMessenger, + PhishingControllerOptions, +} from './PhishingController.js'; +import { TokenScanResultType } from './types.js'; +import type { BulkTokenScanRequest, TokenScanApiResponse } from './types.js'; + +jest.mock('@metamask/controller-utils', () => ({ + ...jest.requireActual('@metamask/controller-utils'), + safelyExecuteWithTimeout: jest.fn(), +})); + +const mockSafelyExecuteWithTimeout = + safelyExecuteWithTimeout as jest.MockedFunction< + typeof safelyExecuteWithTimeout + >; + +const controllerName = 'PhishingController'; + +type AllPhishingControllerActions = + MessengerActions; + +type AllPhishingControllerEvents = MessengerEvents; + +type RootMessenger = Messenger< + MockAnyNamespace, + AllPhishingControllerActions, + AllPhishingControllerEvents, + RootMessenger +>; + +/** + * Creates and returns a root messenger for testing + * + * @returns A messenger instance + */ +function getRootMessenger(): RootMessenger { + return new Messenger({ + namespace: MOCK_ANY_NAMESPACE, + }); +} + +/** + * Constructs a messenger with transaction events enabled. + * + * @returns A restricted messenger that can listen to TransactionController events. + */ +function getMessengerWithTransactionEvents() { + const rootMessenger = getRootMessenger(); + + const messenger = new Messenger< + typeof controllerName, + AllPhishingControllerActions, + AllPhishingControllerEvents, + RootMessenger + >({ + namespace: controllerName, + parent: rootMessenger, + }); + + rootMessenger.delegate({ + actions: [], + events: ['TransactionController:stateChange'], + messenger, + }); + + return { + messenger, + }; +} + +/** + * Construct a Phishing Controller with the given options if any. + * + * @param options - The Phishing Controller options. + * @returns The constructed Phishing Controller. + */ +function getPhishingController(options?: Partial) { + return new PhishingController({ + messenger: getMessengerWithTransactionEvents().messenger, + ...options, + }); +} + +describe('PhishingController - Bulk Token Scanning', () => { + let controller: PhishingController; + let consoleErrorSpy: jest.SpyInstance; + let consoleWarnSpy: jest.SpyInstance; + + beforeEach(() => { + controller = getPhishingController(); + consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(); + consoleWarnSpy = jest.spyOn(console, 'warn').mockImplementation(); + + // Reset the mock to its default behavior (pass through to real implementation) + mockSafelyExecuteWithTimeout.mockImplementation( + (fn, throwOnTimeout, timeout) => { + return jest + .requireActual('@metamask/controller-utils') + .safelyExecuteWithTimeout(fn, throwOnTimeout, timeout); + }, + ); + }); + + afterEach(() => { + cleanAll(); + consoleErrorSpy.mockRestore(); + consoleWarnSpy.mockRestore(); + }); + + describe('bulkScanTokens', () => { + describe('input validation', () => { + it('should return empty object when tokens array is empty', async () => { + const request: BulkTokenScanRequest = { + chainId: '0x1', + tokens: [], + }; + + const result = await controller.bulkScanTokens(request); + + expect(result).toStrictEqual({}); + }); + + it('should return empty object when tokens is null/undefined', async () => { + const request: BulkTokenScanRequest = { + chainId: '0x1', + // @ts-expect-error Testing invalid input + tokens: null, + }; + + const result = await controller.bulkScanTokens(request); + + expect(result).toStrictEqual({}); + }); + + it('should return empty object and log warning when too many tokens provided', async () => { + const tokens = Array.from( + { length: 101 }, + (_, i) => `0x${i.toString().padStart(40, '0')}`, + ); + const request: BulkTokenScanRequest = { + chainId: '0x1', + tokens, + }; + + const result = await controller.bulkScanTokens(request); + + expect(result).toStrictEqual({}); + expect(consoleWarnSpy).toHaveBeenCalledWith( + 'Maximum of 100 tokens allowed per request', + ); + }); + + it('should return empty object and log warning for unknown chain ID', async () => { + const request: BulkTokenScanRequest = { + chainId: '0x999', + tokens: ['0x1234567890123456789012345678901234567890'], + }; + + const result = await controller.bulkScanTokens(request); + + expect(result).toStrictEqual({}); + expect(consoleWarnSpy).toHaveBeenCalledWith( + 'Unsupported chain ID: 0x999', + ); + }); + + it('should return empty object and log warning for a known chain that is not supported by token scanning', async () => { + const scope = nock(SECURITY_ALERTS_BASE_URL) + .post(TOKEN_BULK_SCANNING_ENDPOINT) + .reply(200, { results: {} }); + + const request: BulkTokenScanRequest = { + chainId: '0x64', + tokens: ['0x1234567890123456789012345678901234567890'], + }; + + const result = await controller.bulkScanTokens(request); + + expect(result).toStrictEqual({}); + expect(scope.isDone()).toBe(false); + expect(consoleWarnSpy).toHaveBeenCalledWith( + 'Unsupported chain ID: 0x64', + ); + cleanAll(); + }); + + it('should handle case insensitive chainId', async () => { + const mockApiResponse: TokenScanApiResponse = { + results: { + '0x1234567890123456789012345678901234567890': { + result_type: TokenScanResultType.Benign, + }, + }, + }; + + const scope = nock(SECURITY_ALERTS_BASE_URL) + .post(TOKEN_BULK_SCANNING_ENDPOINT) + .reply(200, mockApiResponse); + + const request: BulkTokenScanRequest = { + chainId: '0X1', // Mixed case + tokens: ['0x1234567890123456789012345678901234567890'], + }; + + const result = await controller.bulkScanTokens(request); + + expect(scope.isDone()).toBe(true); + expect(result).toStrictEqual({ + '0x1234567890123456789012345678901234567890': { + result_type: TokenScanResultType.Benign, + chain: '0x1', // Should be normalized to lowercase + address: '0x1234567890123456789012345678901234567890', + }, + }); + }); + }); + + describe('successful API responses', () => { + it('should return scan results for valid tokens', async () => { + const tokens = [ + '0x1234567890123456789012345678901234567890', + '0xABCDEF1234567890123456789012345678901234', + ]; + const mockApiResponse: TokenScanApiResponse = { + results: { + '0x1234567890123456789012345678901234567890': { + result_type: TokenScanResultType.Benign, + }, + '0xabcdef1234567890123456789012345678901234': { + result_type: TokenScanResultType.Malicious, + chain: 'ethereum', + address: '0xabcdef1234567890123456789012345678901234', + }, + }, + }; + + const scope = nock(SECURITY_ALERTS_BASE_URL) + .post(TOKEN_BULK_SCANNING_ENDPOINT, { + chain: 'ethereum', + tokens: [ + '0x1234567890123456789012345678901234567890', + '0xabcdef1234567890123456789012345678901234', + ], + }) + .reply(200, mockApiResponse); + + const request: BulkTokenScanRequest = { + chainId: '0x1', + tokens, + }; + + const result = await controller.bulkScanTokens(request); + + expect(scope.isDone()).toBe(true); + expect(result).toStrictEqual({ + '0x1234567890123456789012345678901234567890': { + result_type: TokenScanResultType.Benign, + chain: '0x1', + address: '0x1234567890123456789012345678901234567890', + }, + '0xabcdef1234567890123456789012345678901234': { + result_type: TokenScanResultType.Malicious, + chain: 'ethereum', + address: '0xabcdef1234567890123456789012345678901234', + }, + }); + }); + + it('should handle partial API responses (some tokens missing)', async () => { + const tokens = [ + '0x1234567890123456789012345678901234567890', + '0xABCDEF1234567890123456789012345678901234', + ]; + const mockApiResponse: TokenScanApiResponse = { + results: { + '0x1234567890123456789012345678901234567890': { + result_type: TokenScanResultType.Benign, + }, + // Missing second token in response + }, + }; + + const scope = nock(SECURITY_ALERTS_BASE_URL) + .post(TOKEN_BULK_SCANNING_ENDPOINT) + .reply(200, mockApiResponse); + + const request: BulkTokenScanRequest = { + chainId: '0x1', + tokens, + }; + + const result = await controller.bulkScanTokens(request); + + expect(scope.isDone()).toBe(true); + expect(result).toStrictEqual({ + '0x1234567890123456789012345678901234567890': { + result_type: TokenScanResultType.Benign, + chain: '0x1', + address: '0x1234567890123456789012345678901234567890', + }, + // Second token should be omitted + }); + }); + + it('should handle API response with no results field', async () => { + const tokens = ['0x1234567890123456789012345678901234567890']; + const mockApiResponse = {}; // No results field + + const scope = nock(SECURITY_ALERTS_BASE_URL) + .post(TOKEN_BULK_SCANNING_ENDPOINT) + .reply(200, mockApiResponse); + + const request: BulkTokenScanRequest = { + chainId: '0x1', + tokens, + }; + + const result = await controller.bulkScanTokens(request); + + expect(scope.isDone()).toBe(true); + expect(result).toStrictEqual({}); + }); + + it('should handle API response with results containing tokens without result_type', async () => { + const tokens = ['0x1234567890123456789012345678901234567890']; + const mockApiResponse: TokenScanApiResponse = { + results: { + '0x1234567890123456789012345678901234567890': { + // @ts-expect-error Testing invalid response + result_type: undefined, + }, + }, + }; + + const scope = nock(SECURITY_ALERTS_BASE_URL) + .post(TOKEN_BULK_SCANNING_ENDPOINT) + .reply(200, mockApiResponse); + + const request: BulkTokenScanRequest = { + chainId: '0x1', + tokens, + }; + + const result = await controller.bulkScanTokens(request); + + expect(scope.isDone()).toBe(true); + expect(result).toStrictEqual({}); + }); + }); + + describe('API error responses', () => { + it.each([ + [400, 'Bad Request'], + [401, 'Unauthorized'], + [403, 'Forbidden'], + [404, 'Not Found'], + [500, 'Internal Server Error'], + [502, 'Bad Gateway'], + [503, 'Service Unavailable'], + [504, 'Gateway Timeout'], + ])( + 'should handle %i HTTP error and return empty results', + async (statusCode, statusText) => { + const tokens = ['0x1234567890123456789012345678901234567890']; + + const scope = nock(SECURITY_ALERTS_BASE_URL) + .post(TOKEN_BULK_SCANNING_ENDPOINT) + .reply(statusCode, statusText); + + const request: BulkTokenScanRequest = { + chainId: '0x1', + tokens, + }; + + const result = await controller.bulkScanTokens(request); + + expect(scope.isDone()).toBe(true); + expect(result).toStrictEqual({}); + expect(consoleWarnSpy).toHaveBeenCalledWith( + `Token bulk screening API error: ${statusCode} ${statusText}`, + ); + }, + ); + + it('should handle network errors and return empty results', async () => { + const tokens = ['0x1234567890123456789012345678901234567890']; + + const scope = nock(SECURITY_ALERTS_BASE_URL) + .post(TOKEN_BULK_SCANNING_ENDPOINT) + .replyWithError('Network error'); + + const request: BulkTokenScanRequest = { + chainId: '0x1', + tokens, + }; + + const result = await controller.bulkScanTokens(request); + + expect(scope.isDone()).toBe(true); + expect(result).toStrictEqual({}); + + // Check that console.error was called (may be called multiple times due to timeout) + expect(consoleErrorSpy).toHaveBeenCalled(); + expect(consoleErrorSpy.mock.calls.length).toBeGreaterThanOrEqual(1); + }); + + it('should handle API timeout and return empty results', async () => { + const tokens = ['0x1234567890123456789012345678901234567890']; + + // Mock safelyExecuteWithTimeout to return null (simulating a timeout) + mockSafelyExecuteWithTimeout.mockResolvedValueOnce(null); + + const request: BulkTokenScanRequest = { + chainId: '0x1', + tokens, + }; + + const result = await controller.bulkScanTokens(request); + + expect(result).toStrictEqual({}); + expect(consoleErrorSpy).toHaveBeenCalledWith( + 'Error scanning tokens: timeout of 8000ms exceeded', + ); + }); + }); + + describe('caching behavior', () => { + it('should return cached results without making API calls', async () => { + const tokens = ['0x1234567890123456789012345678901234567890']; + const mockApiResponse: TokenScanApiResponse = { + results: { + '0x1234567890123456789012345678901234567890': { + 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 request: BulkTokenScanRequest = { + chainId: '0x1', + tokens, + }; + + // First call + const result1 = await controller.bulkScanTokens(request); + expect(scope1.isDone()).toBe(true); + + // Second call should use cache (no additional API call) + const result2 = await controller.bulkScanTokens(request); + + expect(result1).toStrictEqual(result2); + expect(result2).toStrictEqual({ + '0x1234567890123456789012345678901234567890': { + result_type: TokenScanResultType.Benign, + chain: '0x1', + address: '0x1234567890123456789012345678901234567890', + }, + }); + }); + + it('should handle mixed cached and non-cached tokens', async () => { + const cachedToken = '0x1234567890123456789012345678901234567890'; + const newToken = '0xABCDEF1234567890123456789012345678901234'; + + // First, cache one token + const scope1 = nock(SECURITY_ALERTS_BASE_URL) + .post(TOKEN_BULK_SCANNING_ENDPOINT) + .reply(200, { + results: { + [cachedToken]: { + result_type: TokenScanResultType.Benign, + }, + }, + }); + + await controller.bulkScanTokens({ + chainId: '0x1', + tokens: [cachedToken], + }); + + expect(scope1.isDone()).toBe(true); + + // Now request both cached and new token + const scope2 = nock(SECURITY_ALERTS_BASE_URL) + .post(TOKEN_BULK_SCANNING_ENDPOINT, { + chain: 'ethereum', + tokens: [newToken.toLowerCase()], // Should only request the new token + }) + .reply(200, { + results: { + [newToken.toLowerCase()]: { + result_type: TokenScanResultType.Malicious, + }, + }, + }); + + const result = await controller.bulkScanTokens({ + chainId: '0x1', + tokens: [cachedToken, newToken], + }); + + expect(scope2.isDone()).toBe(true); + expect(result).toStrictEqual({ + [cachedToken]: { + result_type: TokenScanResultType.Benign, + chain: '0x1', + address: cachedToken, + }, + [newToken.toLowerCase()]: { + result_type: TokenScanResultType.Malicious, + chain: '0x1', + address: newToken.toLowerCase(), + }, + }); + }); + + it('should handle case insensitive token addresses for caching', async () => { + const tokenMixedCase = '0x1234567890123456789012345678901234567890'; + const tokenLowerCase = tokenMixedCase.toLowerCase(); + const tokenUpperCase = tokenMixedCase.toUpperCase(); + + // First call with mixed case + const scope1 = nock(SECURITY_ALERTS_BASE_URL) + .post(TOKEN_BULK_SCANNING_ENDPOINT) + .reply(200, { + results: { + [tokenLowerCase]: { + result_type: TokenScanResultType.Benign, + }, + }, + }); + + const result1 = await controller.bulkScanTokens({ + chainId: '0x1', + tokens: [tokenMixedCase], + }); + + expect(scope1.isDone()).toBe(true); + + // Second call with uppercase should use cache + const result2 = await controller.bulkScanTokens({ + chainId: '0x1', + tokens: [tokenUpperCase], + }); + + expect(result1).toStrictEqual(result2); + expect(result2[tokenLowerCase]).toBeDefined(); + }); + }); + + describe('different chains', () => { + it('should work with Polygon chain', async () => { + const tokens = ['0x1234567890123456789012345678901234567890']; + const mockApiResponse: TokenScanApiResponse = { + results: { + '0x1234567890123456789012345678901234567890': { + result_type: TokenScanResultType.Warning, + }, + }, + }; + + const scope = nock(SECURITY_ALERTS_BASE_URL) + .post(TOKEN_BULK_SCANNING_ENDPOINT, { + chain: 'polygon', + tokens, + }) + .reply(200, mockApiResponse); + + const request: BulkTokenScanRequest = { + chainId: '0x89', // Polygon + tokens, + }; + + const result = await controller.bulkScanTokens(request); + + expect(scope.isDone()).toBe(true); + expect(result).toStrictEqual({ + '0x1234567890123456789012345678901234567890': { + result_type: TokenScanResultType.Warning, + chain: '0x89', + address: '0x1234567890123456789012345678901234567890', + }, + }); + }); + + it('should work with BSC chain', async () => { + const tokens = ['0x1234567890123456789012345678901234567890']; + const mockApiResponse: TokenScanApiResponse = { + results: { + '0x1234567890123456789012345678901234567890': { + result_type: TokenScanResultType.Spam, + }, + }, + }; + + const scope = nock(SECURITY_ALERTS_BASE_URL) + .post(TOKEN_BULK_SCANNING_ENDPOINT, { + chain: 'bsc', + tokens, + }) + .reply(200, mockApiResponse); + + const request: BulkTokenScanRequest = { + chainId: '0x38', // BSC + tokens, + }; + + const result = await controller.bulkScanTokens(request); + + expect(scope.isDone()).toBe(true); + expect(result).toStrictEqual({ + '0x1234567890123456789012345678901234567890': { + result_type: TokenScanResultType.Spam, + chain: '0x38', + address: '0x1234567890123456789012345678901234567890', + }, + }); + }); + }); + + 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( + { length: 100 }, + (_, i) => `0x${i.toString().padStart(40, '0')}`, + ); + + const mockResults: Record< + string, + { result_type: TokenScanResultType } + > = {}; + tokens.forEach((token) => { + mockResults[token] = { result_type: TokenScanResultType.Benign }; + }); + + const mockApiResponse: TokenScanApiResponse = { + results: mockResults, + }; + + const scope = nock(SECURITY_ALERTS_BASE_URL) + .post(TOKEN_BULK_SCANNING_ENDPOINT, { + chain: 'ethereum', + tokens, + }) + .reply(200, mockApiResponse); + + const request: BulkTokenScanRequest = { + chainId: '0x1', + tokens, + }; + + const result = await controller.bulkScanTokens(request); + + expect(scope.isDone()).toBe(true); + expect(Object.keys(result)).toHaveLength(100); + expect(consoleWarnSpy).not.toHaveBeenCalled(); + }); + }); + }); +}); diff --git a/packages/phishing-controller/src/CacheManager.test.ts b/packages/phishing-controller/src/CacheManager.test.ts new file mode 100644 index 00000000000..5bbf8c92cb5 --- /dev/null +++ b/packages/phishing-controller/src/CacheManager.test.ts @@ -0,0 +1,200 @@ +import { CacheManager } from './CacheManager.js'; +import * as utils from './utils.js'; + +describe('CacheManager', () => { + let updateStateSpy: jest.Mock; + let cache: CacheManager<{ value: string }>; + + beforeEach(() => { + 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, + updateState: updateStateSpy, + }); + }); + + afterEach(() => { + jest.useRealTimers(); + jest.restoreAllMocks(); + }); + + describe('constructor', () => { + it('should initialize with empty cache when no initialCache provided', () => { + const emptyCache = new CacheManager<{ value: string }>({ + // eslint-disable-next-line no-empty-function + updateState: () => {}, + }); + expect(emptyCache.get('test-key')).toBeUndefined(); + }); + + it('should initialize with provided initialCache data', () => { + const now = Math.floor(Date.now() / 1000); + const initialCache = { + 'test-key': { + data: { value: 'test-value' }, + timestamp: now, + }, + }; + + const cacheWithInitialData = new CacheManager<{ value: string }>({ + initialCache, + // eslint-disable-next-line no-empty-function + updateState: () => {}, + }); + + expect(cacheWithInitialData.get('test-key')).toStrictEqual({ + value: 'test-value', + }); + }); + }); + + describe('get', () => { + it('should return undefined for non-existent keys', () => { + expect(cache.get('non-existent')).toBeUndefined(); + }); + + it('should return data for existing keys', () => { + cache.set('key1', { value: 'value1' }); + expect(cache.get('key1')).toStrictEqual({ value: 'value1' }); + }); + + it('should return undefined for expired entries', () => { + cache.set('key1', { value: 'value1' }); + + // Fast forward time past TTL + jest.advanceTimersByTime(301 * 1000); + + expect(cache.get('key1')).toBeUndefined(); + }); + }); + + describe('set', () => { + it('should add new entries', () => { + cache.set('key1', { value: 'value1' }); + expect(cache.get('key1')).toStrictEqual({ value: 'value1' }); + }); + + it('should update existing entries', () => { + cache.set('key1', { value: 'value1' }); + cache.set('key1', { value: 'updated-value' }); + expect(cache.get('key1')).toStrictEqual({ value: 'updated-value' }); + }); + + it('should call updateState when adding entries', () => { + cache.set('key1', { value: 'value1' }); + expect(updateStateSpy).toHaveBeenCalledTimes(1); + }); + + it('should evict oldest entries when cache exceeds max size', () => { + cache.set('key1', { value: 'value1' }); + cache.set('key2', { value: 'value2' }); + cache.set('key3', { value: 'value3' }); + cache.set('key4', { value: 'value4' }); // This should evict key1 + + expect(cache.get('key1')).toBeUndefined(); + expect(cache.get('key2')).toStrictEqual({ value: 'value2' }); + expect(cache.get('key3')).toStrictEqual({ value: 'value3' }); + expect(cache.get('key4')).toStrictEqual({ value: 'value4' }); + }); + }); + + describe('delete', () => { + it('should remove entries', () => { + cache.set('key1', { value: 'value1' }); + expect(cache.delete('key1')).toBe(true); + expect(cache.get('key1')).toBeUndefined(); + }); + + it('should return false when deleting non-existent keys', () => { + expect(cache.delete('non-existent')).toBe(false); + }); + + it('should call updateState when deleting entries', () => { + cache.set('key1', { value: 'value1' }); + updateStateSpy.mockClear(); + cache.delete('key1'); + expect(updateStateSpy).toHaveBeenCalledTimes(1); + }); + }); + + describe('clear', () => { + it('should remove all entries', () => { + cache.set('key1', { value: 'value1' }); + cache.set('key2', { value: 'value2' }); + cache.clear(); + expect(cache.get('key1')).toBeUndefined(); + expect(cache.get('key2')).toBeUndefined(); + }); + + it('should call updateState', () => { + cache.set('key1', { value: 'value1' }); + updateStateSpy.mockClear(); + cache.clear(); + expect(updateStateSpy).toHaveBeenCalledTimes(1); + }); + }); + + describe('setTTL', () => { + it('should update the TTL', () => { + cache.setTTL(600); + expect(cache.getTTL()).toBe(600); + }); + }); + + describe('setMaxSize', () => { + it('should update the max size', () => { + cache.setMaxSize(5); + expect(cache.getMaxSize()).toBe(5); + }); + + it('should evict entries if new size is smaller than current cache size', () => { + cache.set('key1', { value: 'value1' }); + cache.set('key2', { value: 'value2' }); + cache.set('key3', { value: 'value3' }); + cache.setMaxSize(2); // This should evict key1 + + expect(cache.get('key1')).toBeUndefined(); + expect(cache.get('key2')).toStrictEqual({ value: 'value2' }); + expect(cache.get('key3')).toStrictEqual({ value: 'value3' }); + }); + }); + + describe('getSize', () => { + it('should return the current cache size', () => { + expect(cache.getSize()).toBe(0); + cache.set('key1', { value: 'value1' }); + expect(cache.getSize()).toBe(1); + cache.set('key2', { value: 'value2' }); + expect(cache.getSize()).toBe(2); + cache.delete('key1'); + expect(cache.getSize()).toBe(1); + }); + }); + + describe('keys', () => { + it('should return all cache keys', () => { + cache.set('key1', { value: 'value1' }); + cache.set('key2', { value: 'value2' }); + expect(cache.keys()).toStrictEqual(['key1', 'key2']); + }); + }); + + describe('getAllEntries', () => { + it('should return all cache entries', () => { + const now = Math.floor(Date.now() / 1000); + cache.set('key1', { value: 'value1' }); + cache.set('key2', { value: 'value2' }); + const entries = cache.getAllEntries(); + expect(Object.keys(entries)).toStrictEqual(['key1', 'key2']); + expect(entries.key1.data).toStrictEqual({ value: 'value1' }); + expect(entries.key2.data).toStrictEqual({ value: 'value2' }); + expect(entries.key1.timestamp).toBeGreaterThanOrEqual(now); + expect(entries.key2.timestamp).toBeGreaterThanOrEqual(now); + }); + }); +}); diff --git a/packages/phishing-controller/src/CacheManager.ts b/packages/phishing-controller/src/CacheManager.ts new file mode 100644 index 00000000000..9dd4b256353 --- /dev/null +++ b/packages/phishing-controller/src/CacheManager.ts @@ -0,0 +1,210 @@ +import { fetchTimeNow } from './utils.js'; + +/** + * Generic cache entry type that wraps the data with a timestamp + */ +export type CacheEntry = { + data: T; + timestamp: number; +}; + +/** + * Configuration options for CacheManager + */ +export type CacheManagerOptions = { + cacheTTL?: number; + maxCacheSize?: number; + initialCache?: Record>; + updateState: (cache: Record>) => void; +}; + +/** + * Generic cache manager with TTL and size limit support + * + * @template T - The type of data to cache + */ +export class CacheManager { + #cacheTTL: number; + + #maxCacheSize: number; + + readonly #cache: Map>; + + readonly #updateState: (cache: Record>) => void; + + /** + * Constructor for CacheManager + * + * @param options - Cache configuration options + * @param options.cacheTTL - Time to live in seconds for cached entries + * @param options.maxCacheSize - Maximum number of entries in the cache + * @param options.initialCache - Initial cache state + * @param options.updateState - Function to update the state when cache changes + */ + constructor({ + cacheTTL = 300, // 5 minutes default + maxCacheSize = 100, + initialCache = {}, + updateState, + }: CacheManagerOptions) { + this.#cacheTTL = cacheTTL; + this.#maxCacheSize = maxCacheSize; + this.#cache = new Map(Object.entries(initialCache)); + this.#updateState = updateState; + this.#evictEntries(); + } + + /** + * Set the time-to-live for cached entries + * + * @param ttl - The TTL in seconds + */ + setTTL(ttl: number): void { + this.#cacheTTL = ttl; + } + + /** + * Get the current TTL setting + * + * @returns The TTL in seconds + */ + getTTL(): number { + return this.#cacheTTL; + } + + /** + * Set the maximum cache size + * + * @param maxSize - The maximum cache size + */ + setMaxSize(maxSize: number): void { + this.#maxCacheSize = maxSize; + this.#evictEntries(); + } + + /** + * Get the current maximum cache size + * + * @returns The maximum cache size + */ + getMaxSize(): number { + return this.#maxCacheSize; + } + + /** + * Get the current cache size + * + * @returns The current number of entries in the cache + */ + getSize(): number { + return this.#cache.size; + } + + /** + * Clear the cache + */ + clear(): void { + this.#cache.clear(); + this.#persistCache(); + } + + /** + * Get a cached result if it exists and is not expired + * + * @param key - The cache key + * @returns The cached data or undefined if not found or expired + */ + get(key: string): T | undefined { + const cacheEntry = this.#cache.get(key); + if (!cacheEntry) { + return undefined; + } + + // Check if the entry is expired + const now = fetchTimeNow(); + if (now - cacheEntry.timestamp > this.#cacheTTL) { + // Entry expired, remove it from cache + this.#cache.delete(key); + this.#persistCache(); + return undefined; + } + + return cacheEntry.data; + } + + /** + * Add an entry to the cache, evicting oldest entries if necessary + * + * @param key - The cache key + * @param data - The data to cache + */ + set(key: string, data: T): void { + this.#cache.set(key, { + data, + timestamp: fetchTimeNow(), + }); + + this.#evictEntries(); + this.#persistCache(); + } + + /** + * Delete a specific entry from the cache + * + * @param key - The cache key + * @returns True if an entry was deleted + */ + delete(key: string): boolean { + const result = this.#cache.delete(key); + if (result) { + this.#persistCache(); + } + return result; + } + + /** + * Get all keys in the cache + * + * @returns Array of cache keys + */ + keys(): string[] { + return Array.from(this.#cache.keys()); + } + + /** + * Get all entries in the cache (including expired ones) + * Useful for debugging or persistence + * + * @returns Record of all cache entries + */ + getAllEntries(): Record> { + return Object.fromEntries(this.#cache); + } + + /** + * Persist the current cache state + */ + #persistCache(): void { + this.#updateState(Object.fromEntries(this.#cache)); + } + + /** + * Evict oldest entries if cache exceeds max size + */ + #evictEntries(): void { + if (this.#cache.size <= this.#maxCacheSize) { + return; + } + + const entriesToRemove = this.#cache.size - this.#maxCacheSize; + let count = 0; + // Delete the oldest entries (Map maintains insertion order) + for (const key of this.#cache.keys()) { + if (count >= entriesToRemove) { + break; + } + this.#cache.delete(key); + count += 1; + } + } +} diff --git a/packages/phishing-controller/src/PathTrie.test.ts b/packages/phishing-controller/src/PathTrie.test.ts new file mode 100644 index 00000000000..55d3f6567a8 --- /dev/null +++ b/packages/phishing-controller/src/PathTrie.test.ts @@ -0,0 +1,404 @@ +import { + convertListToTrie, + deepCopyPathTrie, + deleteFromTrie, + insertToTrie, + isTerminal, + matchedPathPrefix, +} from './PathTrie.js'; +import type { PathTrie } from './PathTrie.js'; + +const emptyPathTrie: PathTrie = {}; + +describe('PathTrie', () => { + describe('isTerminal', () => { + it.each([ + [{}, true], + [{ child: {} }, false], + [{ path1: {}, path2: {} }, false], + [undefined, false], + [null, false], + ])('returns %s for %s', (input, expected) => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + expect(isTerminal(input as any)).toBe(expected); + }); + + it('handles nested empty objects correctly', () => { + const nestedEmptyNode = { + child: {}, + }; + expect(isTerminal(nestedEmptyNode)).toBe(false); // Has properties + expect(isTerminal(nestedEmptyNode.child)).toBe(true); // Child is empty + }); + }); + + describe('insertToTrie', () => { + let pathTrie: PathTrie; + + beforeEach(() => { + pathTrie = {}; + }); + + it('inserts a URL to the path trie', () => { + insertToTrie('example.com/path1/path2', pathTrie); + + expect(pathTrie).toStrictEqual({ + 'example.com': { + path1: { + path2: {}, + }, + }, + }); + }); + + it('inserts sibling path', () => { + insertToTrie('example.com/path1', pathTrie); + insertToTrie('example.com/path2', pathTrie); + + expect(pathTrie).toStrictEqual({ + 'example.com': { + path1: {}, + path2: {}, + }, + }); + }); + + it('multiple inserts', () => { + insertToTrie('example.com/path1/path2/path31', pathTrie); + insertToTrie('example.com/path1/path2/path32', pathTrie); + insertToTrie('example.com/path1/path2/path33/path4', pathTrie); + insertToTrie('example.com/path2', pathTrie); + + expect(pathTrie).toStrictEqual({ + 'example.com': { + path1: { + path2: { + path31: {}, + path32: {}, + path33: { + path4: {}, + }, + }, + }, + path2: {}, + }, + }); + }); + + it('idempotent', () => { + insertToTrie('example.com/path1/path2', pathTrie); + insertToTrie('example.com/path1/path2', pathTrie); + + expect(pathTrie).toStrictEqual({ + 'example.com': { + path1: { + path2: {}, + }, + }, + }); + }); + + it('prunes descendants when adding ancestor', () => { + insertToTrie('example.com/path1/path2/path3', pathTrie); + expect(pathTrie).toStrictEqual({ + 'example.com': { + path1: { + path2: { + path3: {}, + }, + }, + }, + }); + + insertToTrie('example.com/path1', pathTrie); + expect(pathTrie).toStrictEqual({ + 'example.com': { + path1: {}, + }, + }); + }); + + it('does not insert path1/path2 if path1 exists', () => { + insertToTrie('example.com/path1', pathTrie); + insertToTrie('example.com/path1/path2', pathTrie); + + expect(pathTrie).toStrictEqual({ + 'example.com': { + path1: {}, + }, + }); + }); + + it('does not insert if no path is provided', () => { + insertToTrie('example.com', pathTrie); + + expect(pathTrie).toStrictEqual(emptyPathTrie); + }); + + it('treats trailing slash as equivalent', () => { + insertToTrie('example.com/path', pathTrie); + insertToTrie('example.com/path/', pathTrie); + expect(pathTrie).toStrictEqual({ + 'example.com': { path: {} }, + }); + }); + + it('accepts URLs with a scheme', () => { + insertToTrie('https://example.com/path', pathTrie); + expect(pathTrie).toStrictEqual({ 'example.com': { path: {} } }); + }); + }); + + describe('deleteFromTrie', () => { + let pathTrie: PathTrie; + + beforeEach(() => { + pathTrie = { + 'example.com': { + path11: { + path2: {}, + }, + path12: {}, + }, + }; + }); + + it('deletes a path', () => { + deleteFromTrie('example.com/path11/path2', pathTrie); + expect(pathTrie).toStrictEqual({ + 'example.com': { + path12: {}, + }, + }); + }); + + it('deletes all paths', () => { + deleteFromTrie('example.com/path11/path2', pathTrie); + deleteFromTrie('example.com/path12', pathTrie); + expect(pathTrie).toStrictEqual(emptyPathTrie); + }); + + it('deletes descendants if the path is not terminal', () => { + deleteFromTrie('example.com/path11', pathTrie); + expect(pathTrie).toStrictEqual({ + 'example.com': { + path12: {}, + }, + }); + }); + + it('idempotent', () => { + deleteFromTrie('example.com/path11/path2', pathTrie); + deleteFromTrie('example.com/path11/path2', pathTrie); + expect(pathTrie).toStrictEqual({ + 'example.com': { + path12: {}, + }, + }); + }); + + it('does nothing if the path does not exist within the trie', () => { + deleteFromTrie('example.com/nonexistent', pathTrie); + expect(pathTrie).toStrictEqual(pathTrie); + }); + + it('does nothing if the hostname does not exist', () => { + deleteFromTrie('nonexistent.com/path11/path2', pathTrie); + expect(pathTrie).toStrictEqual(pathTrie); + }); + + it('does nothing if no path is provided', () => { + deleteFromTrie('example.com', pathTrie); + expect(pathTrie).toStrictEqual(pathTrie); + }); + + it('deletes with a scheme', () => { + deleteFromTrie('https://example.com/path11/path2', pathTrie); + expect(pathTrie).toStrictEqual({ + 'example.com': { + path12: {}, + }, + }); + }); + }); + + describe('matchedPathPrefix', () => { + let pathTrie: PathTrie; + + beforeEach(() => { + pathTrie = { + 'example.com': { + path11: { + path2: {}, + }, + }, + }; + }); + + it.each([ + { + path: 'example.com/path11/path2', + expected: 'example.com/path11/path2', + }, + { path: 'example.com/path11', expected: null }, + { + path: 'example.com/path11/path3', + expected: null, + }, + { path: 'example.com', expected: null }, + { + path: 'nonexistent.com/path11/path2', + expected: null, + }, + { + path: 'https://example.com/path11/path2/path3', + expected: 'example.com/path11/path2', + }, + ])('$path returns $expected', ({ path, expected }) => { + expect(matchedPathPrefix(path, pathTrie)).toBe(expected); + }); + }); + + describe('deepCopyPathTrie', () => { + it('creates a deep copy of a simple trie', () => { + const original: PathTrie = { + 'example.com': { + path1: {}, + path2: {}, + }, + }; + + const copy = deepCopyPathTrie(original); + + expect(copy).toStrictEqual(original); + expect(copy).not.toBe(original); + expect(copy['example.com']).not.toBe(original['example.com']); + }); + + it('creates a deep copy of a complex nested trie', () => { + const original: PathTrie = { + 'example.com': { + path1: { + subpath1: { + deeppath: {}, + }, + subpath2: {}, + }, + path2: {}, + }, + 'another.com': { + different: { + nested: {}, + }, + }, + }; + + const copy = deepCopyPathTrie(original); + + expect(copy).toStrictEqual(original); + expect(copy).not.toBe(original); + expect(copy['example.com']).not.toBe(original['example.com']); + expect(copy['example.com'].path1).not.toBe(original['example.com'].path1); + expect(copy['example.com'].path1.subpath1).not.toBe( + original['example.com'].path1.subpath1, + ); + expect(copy['another.com']).not.toBe(original['another.com']); + }); + + it('handles empty trie', () => { + const original: PathTrie = {}; + const copy = deepCopyPathTrie(original); + + expect(copy).toStrictEqual({}); + expect(copy).not.toBe(original); + }); + + it('handles undefined input gracefully', () => { + const copy = deepCopyPathTrie(undefined); + expect(copy).toStrictEqual({}); + }); + + it('handles null input gracefully', () => { + const copy = deepCopyPathTrie(null); + expect(copy).toStrictEqual({}); + }); + }); +}); + +describe('convertListToTrie', () => { + it('converts array of URLs with paths to PathTrie structure', () => { + const paths = [ + 'example.com/path1', + 'example.com/path2/subpath', + 'another.com/different/path', + ]; + + const result = convertListToTrie(paths); + + expect(result).toStrictEqual({ + 'example.com': { + path1: {}, + path2: { + subpath: {}, + }, + }, + 'another.com': { + different: { + path: {}, + }, + }, + }); + }); + + it('handles empty array', () => { + const result = convertListToTrie([]); + expect(result).toStrictEqual({}); + }); + + it('handles undefined input gracefully', () => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const result = convertListToTrie(undefined as any); + expect(result).toStrictEqual({}); + }); + + it('handles non-array input gracefully', () => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const result = convertListToTrie('not-an-array' as any); + expect(result).toStrictEqual({}); + }); + + it('filters out invalid URLs', () => { + const paths = [ + 'valid.com/path', + '', // empty string + 'invalid-url-without-domain', + ]; + + const result = convertListToTrie(paths); + + expect(result).toStrictEqual({ + 'valid.com': { + path: {}, + }, + }); + }); + + it('handles multiple paths on same domain correctly', () => { + const paths = [ + 'example.com/path1', + 'example.com/path2/subpath', + 'example.com/path1/deeper', + ]; + + const result = convertListToTrie(paths); + + expect(result).toStrictEqual({ + 'example.com': { + path1: {}, + path2: { + subpath: {}, + }, + }, + }); + }); +}); diff --git a/packages/phishing-controller/src/PathTrie.ts b/packages/phishing-controller/src/PathTrie.ts new file mode 100644 index 00000000000..8d435206c68 --- /dev/null +++ b/packages/phishing-controller/src/PathTrie.ts @@ -0,0 +1,190 @@ +import { getHostnameAndPathComponents } from './utils.js'; + +export type PathNode = { + [key: string]: PathNode; +}; + +export type PathTrie = Record; + +export const isTerminal = (node: PathNode | undefined): boolean => { + if (!node || typeof node !== 'object') { + return false; + } + return Object.keys(node).length === 0; +}; + +/** + * Insert a URL into the trie. + * + * @param url - The URL to insert into the trie. + * @param pathTrie - The trie to insert the URL into. + */ +export const insertToTrie = (url: string, pathTrie: PathTrie) => { + const { hostname, pathComponents } = getHostnameAndPathComponents(url); + + if (pathComponents.length === 0 || !hostname) { + return; + } + + const lowerHostname = hostname.toLowerCase(); + if (!pathTrie[lowerHostname]) { + pathTrie[lowerHostname] = {} as PathNode; + } + + let curr: PathNode = pathTrie[lowerHostname]; + for (let i = 0; i < pathComponents.length; i++) { + const pathComponent = pathComponents[i]; + const isLast = i === pathComponents.length - 1; + const exists = curr[pathComponent] !== undefined; + + if (exists) { + if (!isLast && isTerminal(curr[pathComponent])) { + return; + } + + if (isLast) { + // Prune descendants if the current path component is not terminal + if (!isTerminal(curr[pathComponent])) { + curr[pathComponent] = {}; + } + return; + } + curr = curr[pathComponent]; + continue; + } + + if (isLast) { + curr[pathComponent] = {}; + return; + } + const next: PathNode = {}; + curr[pathComponent] = next; + curr = next; + } +}; + +/** + * Delete a URL from the trie. + * + * @param url - The URL to delete from the trie. + * @param pathTrie - The trie to delete the URL from. + */ +export const deleteFromTrie = (url: string, pathTrie: PathTrie) => { + const { hostname, pathComponents } = getHostnameAndPathComponents(url); + + const lowerHostname = hostname.toLowerCase(); + if (pathComponents.length === 0 || !pathTrie[lowerHostname]) { + return; + } + + const pathToNode: { node: PathNode; key: string }[] = [ + { node: pathTrie, key: lowerHostname }, + ]; + let curr: PathNode = pathTrie[lowerHostname]; + for (const pathComponent of pathComponents) { + if (!curr[pathComponent]) { + return; + } + + pathToNode.push({ node: curr, key: pathComponent }); + curr = curr[pathComponent]; + } + + const lastEntry = pathToNode[pathToNode.length - 1]; + delete lastEntry.node[lastEntry.key]; + for (let i = pathToNode.length - 2; i >= 0; i--) { + const { node, key } = pathToNode[i]; + if (isTerminal(node[key])) { + delete node[key]; + } else { + break; + } + } +}; + +/** + * Get the concatenated hostname and path components all the way down to the + * terminal node in the trie that is prefixed in the passed URL. It will only + * return a string if the terminal node in the trie is contained in the passed + * URL. + * + * @param url - The URL to check. + * @param pathTrie - The trie to check the URL in. + * @returns The matched path prefix, or null if no match is found. + */ +export const matchedPathPrefix = ( + url: string, + pathTrie: PathTrie, +): string | null => { + const { hostname, pathComponents } = getHostnameAndPathComponents(url); + + const lowerHostname = hostname.toLowerCase(); + if (pathComponents.length === 0 || !hostname || !pathTrie[lowerHostname]) { + return null; + } + + let matchedPath = `${hostname}/`; + let curr: PathNode = pathTrie[lowerHostname]; + for (const pathComponent of pathComponents) { + if (!curr[pathComponent]) { + return null; + } + curr = curr[pathComponent]; + // If we've reached a terminal node, then we can return the matched path. + if (isTerminal(curr)) { + matchedPath += pathComponent; + return matchedPath; + } + matchedPath += `${pathComponent}/`; + } + return null; +}; + +/** + * Converts a list ofpaths into a PathTrie structure. This assumes that the + * entries are only hostname+pathname format. + * + * @param paths - Array of hostname+pathname + * @returns PathTrie structure for efficient path checking + */ +export const convertListToTrie = (paths: string[] = []): PathTrie => { + const pathTrie: PathTrie = {}; + if (!paths || !Array.isArray(paths)) { + return pathTrie; + } + for (const path of paths) { + insertToTrie(path, pathTrie); + } + return pathTrie; +}; + +/** + * Creates a deep copy of a PathNode structure. + * + * @param original - The original PathNode to copy. + * @returns A deep copy of the PathNode. + */ +const deepCopyPathNode = (original: PathNode): PathNode => { + const copy: PathNode = {}; + + for (const [key, childNode] of Object.entries(original)) { + copy[key] = deepCopyPathNode(childNode); + } + + return copy; +}; + +/** + * Creates a deep copy of a PathTrie structure. + * + * @param original - The original PathTrie to copy. + * @returns A deep copy of the PathTrie. + */ +export const deepCopyPathTrie = ( + original: PathTrie | undefined | null, +): PathTrie => { + if (!original) { + return {}; + } + return deepCopyPathNode(original) as PathTrie; +}; diff --git a/packages/phishing-controller/src/PhishingController-method-action-types.ts b/packages/phishing-controller/src/PhishingController-method-action-types.ts new file mode 100644 index 00000000000..cfd36b561ed --- /dev/null +++ b/packages/phishing-controller/src/PhishingController-method-action-types.ts @@ -0,0 +1,144 @@ +/** + * This file is auto generated. + * Do not edit manually. + */ + +import type { PhishingController } from './PhishingController.js'; + +/** + * Finds known recipient addresses that look like an address poisoning match. + * + * @param candidate - The recipient address being checked. + * @returns Similar known recipient matches sorted by score. + */ +export type PhishingControllerCheckAddressPoisoningAction = { + type: `PhishingController:checkAddressPoisoning`; + handler: PhishingController['checkAddressPoisoning']; +}; + +/** + * Conditionally update the phishing configuration. + * + * If the stalelist configuration is out of date, this function will call `updateStalelist` + * to update the configuration. This will automatically grab the hotlist, + * so it isn't necessary to continue on to download the hotlist and the c2 domain blocklist. + * + */ +export type PhishingControllerMaybeUpdateStateAction = { + type: `PhishingController:maybeUpdateState`; + handler: PhishingController['maybeUpdateState']; +}; + +/** + * Determines if a given origin is unapproved. + * + * It is strongly recommended that you call {@link maybeUpdateState} before calling this, + * to check whether the phishing configuration is up-to-date. It will be updated if necessary + * by calling {@link updateStalelist} or {@link updateHotlist}. + * + * @param origin - Domain origin of a website. + * @returns Whether the origin is an unapproved origin. + */ +export type PhishingControllerTestOriginAction = { + type: `PhishingController:testOrigin`; + handler: PhishingController['testOrigin']; +}; + +/** + * Checks if a request URL's domain is blocked against the request blocklist. + * + * This method is used to determine if a specific request URL is associated with a malicious + * command and control (C2) domain. The URL's hostname is hashed and checked against a configured + * blocklist of known malicious domains. + * + * @param origin - The full request URL to be checked. + * @returns An object indicating whether the URL's domain is blocked and relevant metadata. + */ +export type PhishingControllerIsBlockedRequestAction = { + type: `PhishingController:isBlockedRequest`; + handler: PhishingController['isBlockedRequest']; +}; + +/** + * Temporarily marks a given origin as approved. + * + * @param origin - The origin to mark as approved. + */ +export type PhishingControllerBypassAction = { + type: `PhishingController:bypass`; + handler: PhishingController['bypass']; +}; + +/** + * Scan a URL for phishing. For most hosts only the hostname is sent to the API; for known + * shared gateways the pathname is included (see `PHISHING_DETECTION_PATH_BASED_ROOT_DOMAINS`). + * Only supports web URLs (`http:` / `https:`). + * + * @param url - The URL to scan. + * @returns The phishing detection scan result. + */ +export type PhishingControllerScanUrlAction = { + type: `PhishingController:scanUrl`; + handler: PhishingController['scanUrl']; +}; + +/** + * Scan multiple URLs for phishing in bulk. It will only scan the hostnames of the URLs. + * It also only supports web URLs. + * + * @param urls - The URLs to scan. + * @returns A mapping of URLs to their phishing detection scan results and errors. + */ +export type PhishingControllerBulkScanUrlsAction = { + type: `PhishingController:bulkScanUrls`; + handler: PhishingController['bulkScanUrls']; +}; + +/** + * Scan an address for security alerts. + * + * @param chainId - The chain ID in hex format (e.g., '0x1' for Ethereum). + * @param address - The address to scan. + * @returns The address scan result. + */ +export type PhishingControllerScanAddressAction = { + type: `PhishingController:scanAddress`; + handler: PhishingController['scanAddress']; +}; + +/** + * Scan multiple tokens for malicious activity in bulk. + * + * @param request - The bulk scan request containing chainId and tokens. + * @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 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. + */ +export type PhishingControllerBulkScanTokensAction = { + type: `PhishingController:bulkScanTokens`; + handler: PhishingController['bulkScanTokens']; +}; + +export type PhishingControllerGetApprovalsAction = { + type: `PhishingController:getApprovals`; + handler: PhishingController['getApprovals']; +}; + +/** + * Union of all PhishingController action types. + */ +export type PhishingControllerMethodActions = + | PhishingControllerCheckAddressPoisoningAction + | PhishingControllerMaybeUpdateStateAction + | PhishingControllerTestOriginAction + | PhishingControllerIsBlockedRequestAction + | PhishingControllerBypassAction + | PhishingControllerScanUrlAction + | PhishingControllerBulkScanUrlsAction + | PhishingControllerScanAddressAction + | PhishingControllerBulkScanTokensAction + | PhishingControllerGetApprovalsAction; diff --git a/packages/phishing-controller/src/PhishingController.test.ts b/packages/phishing-controller/src/PhishingController.test.ts index be3888ad208..d7437aa8a7c 100644 --- a/packages/phishing-controller/src/PhishingController.test.ts +++ b/packages/phishing-controller/src/PhishingController.test.ts @@ -1,7 +1,18 @@ -import { ControllerMessenger } from '@metamask/base-controller'; +import type { AddressBookControllerState } from '@metamask/address-book-controller'; +import { deriveStateFromMetadata } from '@metamask/base-controller'; +import { Messenger, MOCK_ANY_NAMESPACE } from '@metamask/messenger'; +import type { + MessengerActions, + MessengerEvents, + MockAnyNamespace, +} from '@metamask/messenger'; +import { + TransactionStatus, + TransactionType, +} from '@metamask/transaction-controller'; +import type { TransactionControllerState } from '@metamask/transaction-controller'; import { strict as assert } from 'assert'; -import nock from 'nock'; -import * as sinon from 'sinon'; +import nock, { cleanAll, isDone, pendingMocks } from 'nock'; import { ListNames, @@ -9,74 +20,292 @@ import { METAMASK_STALELIST_FILE, PhishingController, PHISHING_CONFIG_BASE_URL, - type PhishingControllerActions, - type PhishingControllerOptions, -} from './PhishingController'; + CLIENT_SIDE_DETECION_BASE_URL, + C2_DOMAIN_BLOCKLIST_ENDPOINT, + PHISHING_DETECTION_BASE_URL, + PHISHING_DETECTION_SCAN_ENDPOINT, + PHISHING_DETECTION_BULK_SCAN_ENDPOINT, + SECURITY_ALERTS_BASE_URL, + ADDRESS_SCAN_ENDPOINT, + APPROVALS_ENDPOINT, +} from './PhishingController.js'; +import type { + PhishingControllerOptions, + BulkPhishingDetectionScanResponse, + PhishingControllerMessenger, +} from './PhishingController.js'; +import { + createMockStateChangePayload, + createMockTransaction, + formatHostnameToUrl, + TEST_ADDRESSES, +} from './tests/utils.js'; +import type { + PhishingDetectionScanResult, + AddressScanResult, +} from './types.js'; +import { + PhishingDetectorResultType, + RecommendedAction, + AddressScanResultType, + ApprovalResultType, + ApprovalFeatureType, +} from './types.js'; +import { getHostnameFromUrl } from './utils.js'; const controllerName = 'PhishingController'; +type AllPhishingControllerActions = + MessengerActions; + +type AllPhishingControllerEvents = MessengerEvents; + +type RootMessenger = Messenger< + MockAnyNamespace, + AllPhishingControllerActions, + AllPhishingControllerEvents, + RootMessenger +>; + +type SetupMessengerOptions = { + transactionControllerState?: TransactionControllerState; + addressBookControllerState?: AddressBookControllerState; +}; + +function getDefaultTransactionControllerState(): TransactionControllerState { + return { + transactions: [], + transactionBatches: [], + methodData: {}, + lastFetchedBlockNumbers: {}, + submitHistory: [], + }; +} + +function getDefaultAddressBookControllerState(): AddressBookControllerState { + return { + addressBook: {}, + }; +} + +/** + * Creates and returns a root messenger for testing + * + * @returns A messenger instance + */ +function getRootMessenger(): RootMessenger { + return new Messenger({ + namespace: MOCK_ANY_NAMESPACE, + }); +} + /** - * Constructs a restricted controller messenger. + * Constructs a messenger for use in PhishingController tests. * - * @returns A restricted controller messenger. + * @param options - Options for the test messenger. + * @param options.transactionControllerState - Initial transaction controller state. + * @param options.addressBookControllerState - Initial address book controller state. + * @returns A messenger and the root messenger. */ -function getRestrictedMessenger() { - const controllerMessenger = new ControllerMessenger< - PhishingControllerActions, - never - >(); +function setupMessenger(options: SetupMessengerOptions = {}): { + messenger: PhishingControllerMessenger; + rootMessenger: RootMessenger; + setTransactionControllerState: (state: TransactionControllerState) => void; + setAddressBookControllerState: (state: AddressBookControllerState) => void; +} { + const { + transactionControllerState: + initialTransactionControllerState = getDefaultTransactionControllerState(), + addressBookControllerState: + initialAddressBookControllerState = getDefaultAddressBookControllerState(), + } = options; + const rootMessenger = getRootMessenger(); + let transactionControllerState = initialTransactionControllerState; + let addressBookControllerState = initialAddressBookControllerState; - const messenger = controllerMessenger.getRestricted< + const messenger = new Messenger< typeof controllerName, - never, - never + AllPhishingControllerActions, + AllPhishingControllerEvents, + RootMessenger >({ - name: 'PhishingController', + namespace: controllerName, + parent: rootMessenger, + }); + + rootMessenger.delegate({ + actions: [ + 'AddressBookController:getState', + 'TransactionController:getState', + ], + events: [ + // eslint-disable-next-line no-restricted-syntax + 'AddressBookController:stateChange', + // eslint-disable-next-line no-restricted-syntax + 'TransactionController:stateChange', + ], + messenger, }); - return messenger; + rootMessenger.registerActionHandler( + 'TransactionController:getState', + () => transactionControllerState, + ); + rootMessenger.registerActionHandler( + 'AddressBookController:getState', + () => addressBookControllerState, + ); + + return { + messenger, + rootMessenger, + setTransactionControllerState: ( + state: TransactionControllerState, + ): void => { + transactionControllerState = state; + }, + setAddressBookControllerState: ( + state: AddressBookControllerState, + ): void => { + addressBookControllerState = state; + }, + }; } /** - * Contruct a Phishing Controller with the given options if any. + * Construct a Phishing Controller with the given options if any. + * * @param options - The Phishing Controller options. - * @returns The contstructed Phishing Controller. + * @returns The constructed Phishing Controller. */ -function getPhishingController(options?: Partial) { - return new PhishingController({ - messenger: getRestrictedMessenger(), +function getPhishingController(options?: Partial): { + controller: PhishingController; + rootMessenger: RootMessenger; +} { + const { messenger, rootMessenger } = setupMessenger(); + const controller = new PhishingController({ + messenger, ...options, }); + return { controller, rootMessenger }; } describe('PhishingController', () => { afterEach(() => { - sinon.restore(); + jest.useRealTimers(); + cleanAll(); }); it('should have no default phishing lists', () => { - const controller = getPhishingController(); + const { controller } = getPhishingController(); expect(controller.state.phishingLists).toStrictEqual([]); }); it('should default to an empty whitelist', () => { - const controller = getPhishingController(); + const { controller } = getPhishingController(); expect(controller.state.whitelist).toStrictEqual([]); }); + it('should return false if the hostname is in the whitelist', async () => { + const whitelistedHostname = 'example.com'; + + const { rootMessenger } = getPhishingController(); + rootMessenger.call( + 'PhishingController:bypass', + formatHostnameToUrl(whitelistedHostname), + ); + const result = rootMessenger.call( + 'PhishingController:testOrigin', + whitelistedHostname, + ); + + expect(result).toMatchObject({ + result: false, + type: PhishingDetectorResultType.All, + }); + }); + it('should return false if the URL is in the whitelist', async () => { + const whitelistedHostname = 'example.com'; + + const { rootMessenger } = getPhishingController(); + rootMessenger.call( + 'PhishingController:bypass', + formatHostnameToUrl(whitelistedHostname), + ); + const result = rootMessenger.call( + 'PhishingController:testOrigin', + `https://${whitelistedHostname}/path`, + ); + + expect(result).toMatchObject({ + result: false, + type: PhishingDetectorResultType.All, + }); + }); + + it('returns false if the URL is in the whitelistPaths', async () => { + const whitelistedURL = 'https://example.com/path'; + + const { rootMessenger } = getPhishingController(); + rootMessenger.call('PhishingController:bypass', whitelistedURL); + const result = rootMessenger.call( + 'PhishingController:testOrigin', + whitelistedURL, + ); + expect(result).toMatchObject({ + result: false, + type: PhishingDetectorResultType.All, + }); + }); + + it('should return false if the URL is in the allowlist', async () => { + const allowlistedHostname = 'example.com'; + + nock(PHISHING_CONFIG_BASE_URL) + .get(METAMASK_STALELIST_FILE) + .reply(200, { + data: { + allowlist: [allowlistedHostname], + blocklist: [], + blocklistPaths: [], + fuzzylist: [], + tolerance: 0, + version: 0, + lastUpdated: 1, + }, + }) + .get(`${METAMASK_HOTLIST_DIFF_FILE}/${1}`) + .reply(200, { data: [] }); + + nock(CLIENT_SIDE_DETECION_BASE_URL) + .get(C2_DOMAIN_BLOCKLIST_ENDPOINT) + .reply(200, { + recentlyAdded: [], + recentlyRemoved: [], + lastFetchedAt: 1, + }); + + const { controller, rootMessenger } = getPhishingController(); + await controller.updateStalelist(); + const result = rootMessenger.call( + 'PhishingController:testOrigin', + `https://${allowlistedHostname}/path`, + ); + + expect(result).toMatchObject({ + result: false, + type: PhishingDetectorResultType.Allowlist, + }); + }); it('does not call update stalelist or hotlist upon construction', async () => { const nockScope = nock(PHISHING_CONFIG_BASE_URL) .get(METAMASK_STALELIST_FILE) .reply(200, { data: { - eth_phishing_detect_config: { - blocklist: [], - fuzzylist: [], - allowlist: [], - }, - phishfort_hotlist: { - blocklist: [], - }, + blocklist: [], + blocklistPaths: [], + fuzzylist: [], + allowlist: [], tolerance: 0, version: 0, }, @@ -90,7 +319,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. @@ -110,10 +339,25 @@ describe('PhishingController', () => { ], }); - const controller = getPhishingController({ + const { controller } = getPhishingController({ hotlistRefreshInterval: 10, + state: { + phishingLists: [ + { + allowlist: [], + blocklist: [], + c2DomainBlocklist: [], + blocklistPaths: {}, + fuzzylist: [], + tolerance: 0, + lastUpdated: 1, + name: ListNames.MetaMask, + version: 0, + }, + ], + }, }); - clock.tick(1000 * 10); + jest.advanceTimersByTime(1000 * 10); const pendingUpdate = controller.updateHotlist(); expect(controller.isHotlistOutOfDate()).toBe(true); @@ -132,14 +376,10 @@ describe('PhishingController', () => { .get(METAMASK_STALELIST_FILE) .reply(200, { data: { - eth_phishing_detect_config: { - blocklist: ['this-should-not-be-in-default-blocklist.com'], - fuzzylist: [], - allowlist: ['this-should-not-be-in-default-allowlist.com'], - }, - phishfort_hotlist: { - blocklist: [], - }, + blocklist: ['this-should-not-be-in-default-blocklist.com'], + blocklistPaths: [], + fuzzylist: [], + allowlist: ['this-should-not-be-in-default-allowlist.com'], tolerance: 0, version: 0, lastUpdated: 1, @@ -161,85 +401,107 @@ describe('PhishingController', () => { }, ], }); + + nock(CLIENT_SIDE_DETECION_BASE_URL) + .get(C2_DOMAIN_BLOCKLIST_ENDPOINT) + .reply(200, { + recentlyAdded: [], + recentlyRemoved: [], + lastFetchedAt: 1, + }); }); it('should not have stalelist be out of date immediately after maybeUpdateState is called', async () => { - const clock = sinon.useFakeTimers(); - const controller = getPhishingController({ + jest.useFakeTimers({ doNotFake: ['nextTick', 'queueMicrotask'], now: 0 }); + const { controller, rootMessenger } = getPhishingController({ stalelistRefreshInterval: 10, }); - clock.tick(1000 * 10); + jest.advanceTimersByTime(1000 * 10); expect(controller.isStalelistOutOfDate()).toBe(true); - await controller.maybeUpdateState(); + await rootMessenger.call('PhishingController:maybeUpdateState'); expect(controller.isStalelistOutOfDate()).toBe(false); expect(nockScope.isDone()).toBe(true); }); it('should not be out of date after maybeUpdateStalelist is called but before refresh interval has passed', async () => { - const clock = sinon.useFakeTimers(); - const controller = getPhishingController({ + jest.useFakeTimers({ doNotFake: ['nextTick', 'queueMicrotask'], now: 0 }); + const { controller, rootMessenger } = getPhishingController({ stalelistRefreshInterval: 10, }); - clock.tick(1000 * 10); + jest.advanceTimersByTime(1000 * 10); expect(controller.isStalelistOutOfDate()).toBe(true); - await controller.maybeUpdateState(); - clock.tick(1000 * 5); + await rootMessenger.call('PhishingController:maybeUpdateState'); + 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(); - const controller = getPhishingController({ + jest.useFakeTimers({ doNotFake: ['nextTick', 'queueMicrotask'], now: 0 }); + const { controller, rootMessenger } = getPhishingController({ stalelistRefreshInterval: 10, }); - clock.tick(1000 * 10); + jest.advanceTimersByTime(1000 * 10); // do not wait - const maybeUpdatePhisingListPromise = controller.maybeUpdateState(); + const maybeUpdatePhisingListPromise = rootMessenger.call( + 'PhishingController: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(); - const controller = getPhishingController({ + jest.useFakeTimers({ doNotFake: ['nextTick', 'queueMicrotask'], now: 0 }); + const { controller, rootMessenger } = getPhishingController({ stalelistRefreshInterval: 10, }); expect(controller.isStalelistOutOfDate()).toBe(false); - await controller.maybeUpdateState(); + await rootMessenger.call('PhishingController:maybeUpdateState'); expect( - controller.test('this-should-not-be-in-default-blocklist.com'), + rootMessenger.call( + 'PhishingController:testOrigin', + formatHostnameToUrl('this-should-not-be-in-default-blocklist.com'), + ), ).toMatchObject({ result: false, - type: 'all', + type: PhishingDetectorResultType.All, }); expect( - controller.test('this-should-not-be-in-default-allowlist.com'), + rootMessenger.call( + 'PhishingController:testOrigin', + formatHostnameToUrl('this-should-not-be-in-default-allowlist.com'), + ), ).toMatchObject({ result: false, - type: 'all', + type: PhishingDetectorResultType.All, }); - clock.tick(1000 * 10); - await controller.maybeUpdateState(); + jest.advanceTimersByTime(1000 * 10); + await rootMessenger.call('PhishingController:maybeUpdateState'); expect( - controller.test('this-should-not-be-in-default-blocklist.com'), + rootMessenger.call( + 'PhishingController:testOrigin', + formatHostnameToUrl('this-should-not-be-in-default-blocklist.com'), + ), ).toMatchObject({ result: true, - type: 'blocklist', + type: PhishingDetectorResultType.Blocklist, }); expect( - controller.test('this-should-not-be-in-default-allowlist.com'), + rootMessenger.call( + 'PhishingController:testOrigin', + formatHostnameToUrl('this-should-not-be-in-default-allowlist.com'), + ), ).toMatchObject({ result: false, - type: 'allowlist', + type: PhishingDetectorResultType.Allowlist, }); expect(nockScope.isDone()).toBe(true); @@ -263,22 +525,124 @@ describe('PhishingController', () => { }, ], }); - const clock = sinon.useFakeTimers(50); - const controller = getPhishingController({ + jest.useFakeTimers({ + doNotFake: ['nextTick', 'queueMicrotask'], + now: 50, + }); + const { controller, rootMessenger } = getPhishingController({ hotlistRefreshInterval: 10, stalelistRefreshInterval: 50, }); - clock.tick(1000 * 10); + jest.advanceTimersByTime(1000 * 10); expect(controller.isHotlistOutOfDate()).toBe(true); - await controller.maybeUpdateState(); + await rootMessenger.call('PhishingController:maybeUpdateState'); expect(controller.isHotlistOutOfDate()).toBe(false); }); + + it('should not have c2DomainBlocklist be out of date immediately after maybeUpdateState is called', async () => { + nockScope = nock(CLIENT_SIDE_DETECION_BASE_URL) + .get(`${C2_DOMAIN_BLOCKLIST_ENDPOINT}?timestamp=0`) + .reply(200, { + recentlyAdded: [], + recentlyRemoved: [], + lastFetchedAt: 1, + }); + jest.useFakeTimers({ doNotFake: ['nextTick', 'queueMicrotask'], now: 0 }); + const { controller, rootMessenger } = getPhishingController({ + c2DomainBlocklistRefreshInterval: 10, + }); + jest.advanceTimersByTime(1000 * 10); + expect(controller.isC2DomainBlocklistOutOfDate()).toBe(true); + await rootMessenger.call('PhishingController:maybeUpdateState'); + expect(controller.isC2DomainBlocklistOutOfDate()).toBe(false); + }); + + it('replaces existing phishing lists with completely new list from phishing detection API', async () => { + const { messenger, rootMessenger } = setupMessenger(); + const controller = new PhishingController({ + messenger, + stalelistRefreshInterval: 10, + state: { + phishingLists: [ + { + allowlist: ['initial-safe-site.com'], + blocklist: ['new-phishing-site.com'], + blocklistPaths: {}, + c2DomainBlocklist: [], + fuzzylist: ['new-fuzzy-site.com'], + tolerance: 2, + version: 1, + lastUpdated: 1, + name: ListNames.MetaMask, + }, + ], + whitelist: [], + whitelistPaths: {}, + hotlistLastFetched: 0, + stalelistLastFetched: 0, + c2DomainBlocklistLastFetched: 0, + urlScanCache: {}, + }, + }); + + cleanAll(); + nock(PHISHING_CONFIG_BASE_URL) + .get(METAMASK_STALELIST_FILE) + .reply(200, { + data: { + blocklist: [], + blocklistPaths: ['example.com/path'], + fuzzylist: ['new-fuzzy-site.com'], + allowlist: ['new-safe-site.com'], + tolerance: 2, + version: 2, + lastUpdated: 2, + }, + }) + .get(`${METAMASK_HOTLIST_DIFF_FILE}/${2}`) + .reply(200, { + data: [], + }); + nock(CLIENT_SIDE_DETECION_BASE_URL) + .get(C2_DOMAIN_BLOCKLIST_ENDPOINT) + .reply(200, { + recentlyAdded: [], + recentlyRemoved: [], + lastFetchedAt: 2, + }); + + // Force the stalelist to be out of date and trigger update + jest.useFakeTimers({ doNotFake: ['nextTick', 'queueMicrotask'], now: 0 }); + jest.advanceTimersByTime(1000 * 10); + + await rootMessenger.call('PhishingController:maybeUpdateState'); + + expect(controller.state.phishingLists).toStrictEqual([ + { + allowlist: ['new-safe-site.com'], + blocklist: [], + blocklistPaths: { + 'example.com': { + path: {}, + }, + }, + c2DomainBlocklist: [], + fuzzylist: ['new-fuzzy-site.com'], + tolerance: 2, + version: 2, + lastUpdated: 2, + name: ListNames.MetaMask, + }, + ]); + + jest.useRealTimers(); + }); }); describe('isStalelistOutOfDate', () => { it('should not be out of date upon construction', () => { - sinon.useFakeTimers(); - const controller = getPhishingController({ + jest.useFakeTimers({ doNotFake: ['nextTick', 'queueMicrotask'], now: 0 }); + const { controller } = getPhishingController({ stalelistRefreshInterval: 10, }); @@ -286,31 +650,31 @@ describe('PhishingController', () => { }); it('should not be out of date after some of the refresh interval has passed', () => { - const clock = sinon.useFakeTimers(); - const controller = getPhishingController({ + 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(); - const controller = getPhishingController({ + 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(); - const controller = getPhishingController({ + 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); @@ -320,8 +684,8 @@ describe('PhishingController', () => { }); it('should not be out of date if the phishing lists were just updated', async () => { - sinon.useFakeTimers(); - const controller = getPhishingController({ + jest.useFakeTimers({ doNotFake: ['nextTick', 'queueMicrotask'], now: 0 }); + const { controller } = getPhishingController({ stalelistRefreshInterval: 10, }); await controller.updateStalelist(); @@ -330,23 +694,23 @@ describe('PhishingController', () => { }); it('should not be out of date if the phishing lists were recently updated', async () => { - const clock = sinon.useFakeTimers(); - const controller = getPhishingController({ + 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(); - const controller = getPhishingController({ + 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); }); @@ -354,8 +718,8 @@ describe('PhishingController', () => { describe('isHotlistOutOfDate', () => { it('should not be out of date upon construction', () => { - sinon.useFakeTimers(); - const controller = getPhishingController({ + jest.useFakeTimers({ doNotFake: ['nextTick', 'queueMicrotask'], now: 0 }); + const { controller } = getPhishingController({ hotlistRefreshInterval: 10, }); @@ -363,31 +727,46 @@ describe('PhishingController', () => { }); it('should not be out of date after some of the refresh interval has passed', () => { - const clock = sinon.useFakeTimers(); - const controller = getPhishingController({ + 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(); - const controller = getPhishingController({ + 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(); - const controller = getPhishingController({ + jest.useFakeTimers({ doNotFake: ['nextTick', 'queueMicrotask'], now: 0 }); + const { controller } = getPhishingController({ hotlistRefreshInterval: 10, + state: { + phishingLists: [ + { + allowlist: [], + blocklist: [], + c2DomainBlocklist: [], + blocklistPaths: {}, + fuzzylist: [], + tolerance: 0, + lastUpdated: 1, + name: ListNames.MetaMask, + version: 0, + }, + ], + }, }); - clock.tick(1000 * 10); + jest.advanceTimersByTime(1000 * 10); const pendingUpdate = controller.updateHotlist(); expect(controller.isHotlistOutOfDate()).toBe(true); @@ -397,8 +776,8 @@ describe('PhishingController', () => { }); it('should not be out of date if the phishing lists were just updated', async () => { - sinon.useFakeTimers(); - const controller = getPhishingController({ + jest.useFakeTimers({ doNotFake: ['nextTick', 'queueMicrotask'], now: 0 }); + const { controller } = getPhishingController({ hotlistRefreshInterval: 10, }); await controller.updateHotlist(); @@ -407,44 +786,103 @@ describe('PhishingController', () => { }); it('should not be out of date if the phishing lists were recently updated', async () => { - const clock = sinon.useFakeTimers(); - const controller = getPhishingController({ + 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(); - const controller = getPhishingController({ + 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); }); }); - it('should be able to change the stalelistRefreshInterval', async () => { - sinon.useFakeTimers(); - const controller = getPhishingController({ stalelistRefreshInterval: 10 }); - controller.setStalelistRefreshInterval(0); + describe('isC2DomainBlocklistOutOfDate', () => { + it('should not be out of date upon construction', () => { + jest.useFakeTimers({ doNotFake: ['nextTick', 'queueMicrotask'], now: 0 }); + const { controller } = getPhishingController({ + c2DomainBlocklistRefreshInterval: 10, + }); + + expect(controller.isC2DomainBlocklistOutOfDate()).toBe(false); + }); - expect(controller.isStalelistOutOfDate()).toBe(true); - }); + it('should not be out of date after some of the refresh interval has passed', () => { + jest.useFakeTimers({ doNotFake: ['nextTick', 'queueMicrotask'], now: 0 }); + const { controller } = getPhishingController({ + c2DomainBlocklistRefreshInterval: 10, + }); + jest.advanceTimersByTime(1000 * 5); - it('should be able to change the hotlistRefreshInterval', async () => { - sinon.useFakeTimers(); - const controller = getPhishingController({ - hotlistRefreshInterval: 10, + expect(controller.isC2DomainBlocklistOutOfDate()).toBe(false); }); - controller.setHotlistRefreshInterval(0); - expect(controller.isHotlistOutOfDate()).toBe(true); + it('should be out of date after the refresh interval has passed', () => { + jest.useFakeTimers({ doNotFake: ['nextTick', 'queueMicrotask'], now: 0 }); + const { controller } = getPhishingController({ + c2DomainBlocklistRefreshInterval: 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 () => { + jest.useFakeTimers({ doNotFake: ['nextTick', 'queueMicrotask'], now: 0 }); + const { controller } = getPhishingController({ + c2DomainBlocklistRefreshInterval: 10, + }); + jest.advanceTimersByTime(1000 * 10); + const pendingUpdate = controller.updateC2DomainBlocklist(); + + expect(controller.isC2DomainBlocklistOutOfDate()).toBe(true); + + // Cleanup pending operations + await pendingUpdate; + }); + + it('should not be out of date if the C2 domain blocklist was just updated', async () => { + jest.useFakeTimers({ doNotFake: ['nextTick', 'queueMicrotask'], now: 0 }); + const { controller } = getPhishingController({ + c2DomainBlocklistRefreshInterval: 10, + }); + await controller.updateC2DomainBlocklist(); + + expect(controller.isC2DomainBlocklistOutOfDate()).toBe(false); + }); + + it('should not be out of date if the C2 domain blocklist was recently updated', async () => { + jest.useFakeTimers({ doNotFake: ['nextTick', 'queueMicrotask'], now: 0 }); + const { controller } = getPhishingController({ + c2DomainBlocklistRefreshInterval: 10, + }); + await controller.updateC2DomainBlocklist(); + 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 () => { + jest.useFakeTimers({ doNotFake: ['nextTick', 'queueMicrotask'], now: 0 }); + const { controller } = getPhishingController({ + c2DomainBlocklistRefreshInterval: 10, + }); + await controller.updateC2DomainBlocklist(); + jest.advanceTimersByTime(1000 * 10); + + expect(controller.isC2DomainBlocklistOutOfDate()).toBe(true); + }); }); it('should return negative result for safe domain from MetaMask config', async () => { @@ -452,14 +890,10 @@ describe('PhishingController', () => { .get(METAMASK_STALELIST_FILE) .reply(200, { data: { - eth_phishing_detect_config: { - allowlist: ['metamask.io'], - blocklist: [], - fuzzylist: [], - }, - phishfort_hotlist: { - blocklist: [], - }, + allowlist: ['metamask.io'], + blocklist: [], + blocklistPaths: [], + fuzzylist: [], tolerance: 0, version: 0, lastUpdated: 1, @@ -467,11 +901,25 @@ describe('PhishingController', () => { }) .get(`${METAMASK_HOTLIST_DIFF_FILE}/${1}`) .reply(200, { data: [] }); - const controller = getPhishingController(); + + nock(CLIENT_SIDE_DETECION_BASE_URL) + .get(C2_DOMAIN_BLOCKLIST_ENDPOINT) + .reply(200, { + recentlyAdded: [], + recentlyRemoved: [], + lastFetchedAt: 1, + }); + + const { controller, rootMessenger } = getPhishingController(); await controller.updateStalelist(); - expect(controller.test('metamask.io')).toMatchObject({ + expect( + rootMessenger.call( + 'PhishingController:testOrigin', + formatHostnameToUrl('metamask.io'), + ), + ).toMatchObject({ result: false, - type: 'allowlist', + type: PhishingDetectorResultType.Allowlist, name: ListNames.MetaMask, }); }); @@ -481,14 +929,10 @@ describe('PhishingController', () => { .get(METAMASK_STALELIST_FILE) .reply(200, { data: { - eth_phishing_detect_config: { - allowlist: [], - blocklist: [], - fuzzylist: [], - }, - phishfort_hotlist: { - blocklist: [], - }, + allowlist: [], + blocklist: [], + blocklistPaths: [], + fuzzylist: [], tolerance: 0, version: 0, lastUpdated: 1, @@ -497,11 +941,16 @@ describe('PhishingController', () => { .get(`${METAMASK_HOTLIST_DIFF_FILE}/${1}`) .reply(200, { data: [] }); - const controller = getPhishingController(); + const { controller, rootMessenger } = getPhishingController(); await controller.updateStalelist(); - expect(controller.test('i❤.ws')).toMatchObject({ + expect( + rootMessenger.call( + 'PhishingController:testOrigin', + formatHostnameToUrl('i❤.ws'), + ), + ).toMatchObject({ result: false, - type: 'all', + type: PhishingDetectorResultType.All, }); }); @@ -510,14 +959,10 @@ describe('PhishingController', () => { .get(METAMASK_STALELIST_FILE) .reply(200, { data: { - eth_phishing_detect_config: { - allowlist: [], - blocklist: [], - fuzzylist: [], - }, - phishfort_hotlist: { - blocklist: [], - }, + allowlist: [], + blocklist: [], + blocklistPaths: [], + fuzzylist: [], tolerance: 0, version: 0, lastUpdated: 1, @@ -526,11 +971,16 @@ describe('PhishingController', () => { .get(`${METAMASK_HOTLIST_DIFF_FILE}/${1}`) .reply(200, { data: [] }); - const controller = getPhishingController(); + const { controller, rootMessenger } = getPhishingController(); await controller.updateStalelist(); - expect(controller.test('xn--i-7iq.ws')).toMatchObject({ + expect( + rootMessenger.call( + 'PhishingController:testOrigin', + formatHostnameToUrl('xn--i-7iq.ws'), + ), + ).toMatchObject({ result: false, - type: 'all', + type: PhishingDetectorResultType.All, }); }); @@ -539,14 +989,10 @@ describe('PhishingController', () => { .get(METAMASK_STALELIST_FILE) .reply(200, { data: { - eth_phishing_detect_config: { - allowlist: [], - blocklist: ['etnerscan.io'], - fuzzylist: [], - }, - phishfort_hotlist: { - blocklist: [], - }, + allowlist: [], + blocklist: ['etnerscan.io'], + blocklistPaths: [], + fuzzylist: [], tolerance: 0, version: 0, lastUpdated: 1, @@ -555,11 +1001,24 @@ describe('PhishingController', () => { .get(`${METAMASK_HOTLIST_DIFF_FILE}/${1}`) .reply(200, { data: [] }); - const controller = getPhishingController(); + nock(CLIENT_SIDE_DETECION_BASE_URL) + .get(C2_DOMAIN_BLOCKLIST_ENDPOINT) + .reply(200, { + recentlyAdded: [], + recentlyRemoved: [], + lastFetchedAt: 1, + }); + + const { controller, rootMessenger } = getPhishingController(); await controller.updateStalelist(); - expect(controller.test('etnerscan.io')).toMatchObject({ + expect( + rootMessenger.call( + 'PhishingController:testOrigin', + formatHostnameToUrl('etnerscan.io'), + ), + ).toMatchObject({ result: true, - type: 'blocklist', + type: PhishingDetectorResultType.Blocklist, name: ListNames.MetaMask, }); }); @@ -569,14 +1028,10 @@ describe('PhishingController', () => { .get(METAMASK_STALELIST_FILE) .reply(200, { data: { - eth_phishing_detect_config: { - blocklist: ['xn--myetherallet-4k5fwn.com'], - allowlist: [], - fuzzylist: [], - }, - phishfort_hotlist: { - blocklist: [], - }, + blocklist: ['xn--myetherallet-4k5fwn.com'], + blocklistPaths: [], + allowlist: [], + fuzzylist: [], tolerance: 0, version: 0, lastUpdated: 1, @@ -584,11 +1039,25 @@ describe('PhishingController', () => { }) .get(`${METAMASK_HOTLIST_DIFF_FILE}/${1}`) .reply(200, { data: [] }); - const controller = getPhishingController(); + + nock(CLIENT_SIDE_DETECION_BASE_URL) + .get(C2_DOMAIN_BLOCKLIST_ENDPOINT) + .reply(200, { + recentlyAdded: [], + recentlyRemoved: [], + lastFetchedAt: 1, + }); + + const { controller, rootMessenger } = getPhishingController(); await controller.updateStalelist(); - expect(controller.test('myetherẉalletṭ.com')).toMatchObject({ + expect( + rootMessenger.call( + 'PhishingController:testOrigin', + formatHostnameToUrl('myetherẉalletṭ.com'), + ), + ).toMatchObject({ result: true, - type: 'blocklist', + type: PhishingDetectorResultType.Blocklist, name: ListNames.MetaMask, }); }); @@ -598,14 +1067,10 @@ describe('PhishingController', () => { .get(METAMASK_STALELIST_FILE) .reply(200, { data: { - eth_phishing_detect_config: { - allowlist: [], - blocklist: ['xn--myetherallet-4k5fwn.com'], - fuzzylist: [], - }, - phishfort_hotlist: { - blocklist: [], - }, + allowlist: [], + blocklist: ['xn--myetherallet-4k5fwn.com'], + blocklistPaths: [], + fuzzylist: [], tolerance: 0, version: 0, lastUpdated: 1, @@ -614,11 +1079,24 @@ describe('PhishingController', () => { .get(`${METAMASK_HOTLIST_DIFF_FILE}/${1}`) .reply(200, { data: [] }); - const controller = getPhishingController(); + nock(CLIENT_SIDE_DETECION_BASE_URL) + .get(C2_DOMAIN_BLOCKLIST_ENDPOINT) + .reply(200, { + recentlyAdded: [], + recentlyRemoved: [], + lastFetchedAt: 1, + }); + + const { controller, rootMessenger } = getPhishingController(); await controller.updateStalelist(); - expect(controller.test('xn--myetherallet-4k5fwn.com')).toMatchObject({ + expect( + rootMessenger.call( + 'PhishingController:testOrigin', + formatHostnameToUrl('xn--myetherallet-4k5fwn.com'), + ), + ).toMatchObject({ result: true, - type: 'blocklist', + type: PhishingDetectorResultType.Blocklist, name: ListNames.MetaMask, }); }); @@ -628,14 +1106,10 @@ describe('PhishingController', () => { .get(METAMASK_STALELIST_FILE) .reply(200, { data: { - eth_phishing_detect_config: { - allowlist: [], - blocklist: [], - fuzzylist: [], - }, - phishfort_hotlist: { - blocklist: [], - }, + allowlist: [], + blocklist: [], + blocklistPaths: [], + fuzzylist: [], tolerance: 0, version: 0, lastUpdated: 1, @@ -652,13 +1126,24 @@ describe('PhishingController', () => { ], }); - const controller = getPhishingController(); + nock(CLIENT_SIDE_DETECION_BASE_URL) + .get(C2_DOMAIN_BLOCKLIST_ENDPOINT) + .reply(200, { + recentlyAdded: [], + recentlyRemoved: [], + lastFetchedAt: 1, + }); + + const { controller, rootMessenger } = getPhishingController(); await controller.updateStalelist(); expect( - controller.test('e4d600ab9141b7a9859511c77e63b9b3.com'), + rootMessenger.call( + 'PhishingController:testOrigin', + formatHostnameToUrl('e4d600ab9141b7a9859511c77e63b9b3.com'), + ), ).toMatchObject({ result: true, - type: 'blocklist', + type: PhishingDetectorResultType.Blocklist, name: ListNames.MetaMask, }); }); @@ -668,14 +1153,10 @@ describe('PhishingController', () => { .get(METAMASK_STALELIST_FILE) .reply(200, { data: { - eth_phishing_detect_config: { - allowlist: [], - blocklist: [], - fuzzylist: [], - }, - phishfort_hotlist: { - blocklist: [], - }, + allowlist: [], + blocklist: [], + blocklistPaths: [], + fuzzylist: [], tolerance: 0, version: 0, lastUpdated: 1, @@ -684,13 +1165,16 @@ describe('PhishingController', () => { .get(`${METAMASK_HOTLIST_DIFF_FILE}/${1}`) .reply(500); - const controller = getPhishingController(); + const { controller, rootMessenger } = getPhishingController(); await controller.updateStalelist(); expect( - controller.test('e4d600ab9141b7a9859511c77e63b9b3.com'), + rootMessenger.call( + 'PhishingController:testOrigin', + formatHostnameToUrl('e4d600ab9141b7a9859511c77e63b9b3.com'), + ), ).toMatchObject({ result: false, - type: 'all', + type: PhishingDetectorResultType.All, }); }); @@ -699,14 +1183,10 @@ describe('PhishingController', () => { .get(METAMASK_STALELIST_FILE) .reply(200, { data: { - eth_phishing_detect_config: { - allowlist: ['opensea.io'], - blocklist: [], - fuzzylist: [], - }, - phishfort_hotlist: { - blocklist: [], - }, + allowlist: ['opensea.io'], + blocklist: [], + blocklistPaths: [], + fuzzylist: [], tolerance: 0, version: 0, lastUpdated: 1, @@ -714,11 +1194,25 @@ describe('PhishingController', () => { }) .get(`${METAMASK_HOTLIST_DIFF_FILE}/${1}`) .reply(200, { data: [] }); - const controller = getPhishingController(); + + nock(CLIENT_SIDE_DETECION_BASE_URL) + .get(C2_DOMAIN_BLOCKLIST_ENDPOINT) + .reply(200, { + recentlyAdded: [], + recentlyRemoved: [], + lastFetchedAt: 1, + }); + + const { controller, rootMessenger } = getPhishingController(); await controller.updateStalelist(); - expect(controller.test('opensea.io')).toMatchObject({ + expect( + rootMessenger.call( + 'PhishingController:testOrigin', + formatHostnameToUrl('opensea.io'), + ), + ).toMatchObject({ result: false, - type: 'allowlist', + type: PhishingDetectorResultType.Allowlist, name: ListNames.MetaMask, }); }); @@ -728,14 +1222,10 @@ describe('PhishingController', () => { .get(METAMASK_STALELIST_FILE) .reply(200, { data: { - eth_phishing_detect_config: { - allowlist: ['opensea.io'], - blocklist: [], - fuzzylist: ['opensea.io'], - }, - phishfort_hotlist: { - blocklist: [], - }, + allowlist: ['opensea.io'], + blocklist: [], + blocklistPaths: [], + fuzzylist: ['opensea.io'], tolerance: 2, version: 0, lastUpdated: 1, @@ -743,11 +1233,25 @@ describe('PhishingController', () => { }) .get(`${METAMASK_HOTLIST_DIFF_FILE}/${1}`) .reply(200, { data: [] }); - const controller = getPhishingController(); + + nock(CLIENT_SIDE_DETECION_BASE_URL) + .get(C2_DOMAIN_BLOCKLIST_ENDPOINT) + .reply(200, { + recentlyAdded: [], + recentlyRemoved: [], + lastFetchedAt: 1, + }); + + const { controller, rootMessenger } = getPhishingController(); await controller.updateStalelist(); - expect(controller.test('ohpensea.io')).toMatchObject({ + expect( + rootMessenger.call( + 'PhishingController:testOrigin', + formatHostnameToUrl('ohpensea.io'), + ), + ).toMatchObject({ result: true, - type: 'fuzzy', + type: PhishingDetectorResultType.Fuzzy, name: ListNames.MetaMask, }); }); @@ -757,14 +1261,10 @@ describe('PhishingController', () => { .get(METAMASK_STALELIST_FILE) .reply(200, { data: { - eth_phishing_detect_config: { - allowlist: ['opensea.io'], - blocklist: [], - fuzzylist: ['opensea.io'], - }, - phishfort_hotlist: { - blocklist: [], - }, + allowlist: ['opensea.io'], + blocklist: [], + blocklistPaths: [], + fuzzylist: ['opensea.io'], tolerance: 0, version: 0, lastUpdated: 1, @@ -772,13 +1272,16 @@ describe('PhishingController', () => { }) .get(`${METAMASK_HOTLIST_DIFF_FILE}/${1}`) .reply(200, { data: [] }); - const controller = getPhishingController(); + const { controller, rootMessenger } = getPhishingController(); await controller.updateStalelist(); expect( - controller.test('this-is-the-official-website-of-opensea.io'), + rootMessenger.call( + 'PhishingController:testOrigin', + formatHostnameToUrl('this-is-the-official-website-of-opensea.io'), + ), ).toMatchObject({ result: false, - type: 'all', + type: PhishingDetectorResultType.All, }); }); @@ -787,14 +1290,10 @@ describe('PhishingController', () => { .get(METAMASK_STALELIST_FILE) .reply(200, { data: { - eth_phishing_detect_config: { - allowlist: [], - blocklist: ['electrum.mx'], - fuzzylist: [], - }, - phishfort_hotlist: { - blocklist: [], - }, + allowlist: [], + blocklist: ['electrum.mx'], + blocklistPaths: [], + fuzzylist: [], tolerance: 2, version: 0, lastUpdated: 1, @@ -802,18 +1301,38 @@ describe('PhishingController', () => { }) .get(`${METAMASK_HOTLIST_DIFF_FILE}/${1}`) .reply(200, { data: [] }); - const controller = getPhishingController(); + + nock(CLIENT_SIDE_DETECION_BASE_URL) + .get(C2_DOMAIN_BLOCKLIST_ENDPOINT) + .reply(200, { + recentlyAdded: [], + recentlyRemoved: [], + lastFetchedAt: 1, + }); + + const { controller, rootMessenger } = getPhishingController(); await controller.updateStalelist(); const unsafeDomain = 'electrum.mx'; assert.equal( - controller.test(unsafeDomain).result, + rootMessenger.call( + 'PhishingController:testOrigin', + formatHostnameToUrl(unsafeDomain), + ).result, true, 'Example unsafe domain seems to be safe', ); - controller.bypass(unsafeDomain); - expect(controller.test(unsafeDomain)).toMatchObject({ + rootMessenger.call( + 'PhishingController:bypass', + formatHostnameToUrl(unsafeDomain), + ); + expect( + rootMessenger.call( + 'PhishingController:testOrigin', + formatHostnameToUrl(unsafeDomain), + ), + ).toMatchObject({ result: false, - type: 'all', + type: PhishingDetectorResultType.All, }); }); @@ -822,14 +1341,10 @@ describe('PhishingController', () => { .get(METAMASK_STALELIST_FILE) .reply(200, { data: { - eth_phishing_detect_config: { - allowlist: [], - blocklist: ['electrum.mx'], - fuzzylist: [], - }, - phishfort_hotlist: { - blocklist: [], - }, + allowlist: [], + blocklist: ['electrum.mx'], + blocklistPaths: [], + fuzzylist: [], tolerance: 0, version: 0, lastUpdated: 1, @@ -837,19 +1352,42 @@ describe('PhishingController', () => { }) .get(`${METAMASK_HOTLIST_DIFF_FILE}/${1}`) .reply(200, { data: [] }); - const controller = getPhishingController(); + + nock(CLIENT_SIDE_DETECION_BASE_URL) + .get(C2_DOMAIN_BLOCKLIST_ENDPOINT) + .reply(200, { + recentlyAdded: [], + recentlyRemoved: [], + lastFetchedAt: 1, + }); + + const { controller, rootMessenger } = getPhishingController(); await controller.updateStalelist(); const unsafeDomain = 'electrum.mx'; assert.equal( - controller.test(unsafeDomain).result, + rootMessenger.call( + 'PhishingController:testOrigin', + formatHostnameToUrl(unsafeDomain), + ).result, true, 'Example unsafe domain seems to be safe', ); - controller.bypass(unsafeDomain); - controller.bypass(unsafeDomain); - expect(controller.test(unsafeDomain)).toMatchObject({ + rootMessenger.call( + 'PhishingController:bypass', + formatHostnameToUrl(unsafeDomain), + ); + rootMessenger.call( + 'PhishingController:bypass', + formatHostnameToUrl(unsafeDomain), + ); + expect( + rootMessenger.call( + 'PhishingController:testOrigin', + formatHostnameToUrl(unsafeDomain), + ), + ).toMatchObject({ result: false, - type: 'all', + type: PhishingDetectorResultType.All, }); }); @@ -858,14 +1396,10 @@ describe('PhishingController', () => { .get(METAMASK_STALELIST_FILE) .reply(200, { data: { - eth_phishing_detect_config: { - allowlist: [], - blocklist: ['xn--myetherallet-4k5fwn.com'], - fuzzylist: [], - }, - phishfort_hotlist: { - blocklist: [], - }, + allowlist: [], + blocklist: ['xn--myetherallet-4k5fwn.com'], + blocklistPaths: [], + fuzzylist: [], tolerance: 0, version: 0, lastUpdated: 1, @@ -873,18 +1407,38 @@ describe('PhishingController', () => { }) .get(`${METAMASK_HOTLIST_DIFF_FILE}/${1}`) .reply(200, { data: [] }); - const controller = getPhishingController(); + + nock(CLIENT_SIDE_DETECION_BASE_URL) + .get(C2_DOMAIN_BLOCKLIST_ENDPOINT) + .reply(200, { + recentlyAdded: [], + recentlyRemoved: [], + lastFetchedAt: 1, + }); + + const { controller, rootMessenger } = getPhishingController(); await controller.updateStalelist(); const unsafeDomain = 'myetherẉalletṭ.com'; assert.equal( - controller.test(unsafeDomain).result, + rootMessenger.call( + 'PhishingController:testOrigin', + formatHostnameToUrl(unsafeDomain), + ).result, true, 'Example unsafe domain seems to be safe', ); - controller.bypass(unsafeDomain); - expect(controller.test(unsafeDomain)).toMatchObject({ + rootMessenger.call( + 'PhishingController:bypass', + formatHostnameToUrl(unsafeDomain), + ); + expect( + rootMessenger.call( + 'PhishingController:testOrigin', + formatHostnameToUrl(unsafeDomain), + ), + ).toMatchObject({ result: false, - type: 'all', + type: PhishingDetectorResultType.All, }); }); @@ -893,14 +1447,10 @@ describe('PhishingController', () => { .get(METAMASK_STALELIST_FILE) .reply(200, { data: { - eth_phishing_detect_config: { - allowlist: [], - blocklist: ['xn--myetherallet-4k5fwn.com'], - fuzzylist: [], - }, - phishfort_hotlist: { - blocklist: [], - }, + allowlist: [], + blocklist: ['xn--myetherallet-4k5fwn.com'], + blocklistPaths: [], + fuzzylist: [], tolerance: 0, version: 0, lastUpdated: 1, @@ -908,41 +1458,164 @@ describe('PhishingController', () => { }) .get(`${METAMASK_HOTLIST_DIFF_FILE}/${1}`) .reply(200, { data: [] }); - const controller = getPhishingController(); + + nock(CLIENT_SIDE_DETECION_BASE_URL) + .get(C2_DOMAIN_BLOCKLIST_ENDPOINT) + .reply(200, { + recentlyAdded: [], + recentlyRemoved: [], + lastFetchedAt: 1, + }); + + const { controller, rootMessenger } = getPhishingController(); await controller.updateStalelist(); const unsafeDomain = 'xn--myetherallet-4k5fwn.com'; assert.equal( - controller.test(unsafeDomain).result, + rootMessenger.call( + 'PhishingController:testOrigin', + formatHostnameToUrl(unsafeDomain), + ).result, true, 'Example unsafe domain seems to be safe', ); - controller.bypass(unsafeDomain); - expect(controller.test(unsafeDomain)).toMatchObject({ + rootMessenger.call( + 'PhishingController:bypass', + formatHostnameToUrl(unsafeDomain), + ); + expect( + rootMessenger.call( + 'PhishingController:testOrigin', + formatHostnameToUrl(unsafeDomain), + ), + ).toMatchObject({ + result: false, + type: PhishingDetectorResultType.All, + }); + }); + + it('returns positive result for unsafe hostname+pathname from MetaMask config', async () => { + nock(PHISHING_CONFIG_BASE_URL) + .get(METAMASK_STALELIST_FILE) + .reply(200, { + data: { + allowlist: [], + blocklist: [], + blocklistPaths: ['example.com/path'], + fuzzylist: [], + tolerance: 0, + version: 0, + lastUpdated: 1, + }, + }) + .get(`${METAMASK_HOTLIST_DIFF_FILE}/${1}`) + .reply(200, { data: [] }); + + nock(CLIENT_SIDE_DETECION_BASE_URL) + .get(C2_DOMAIN_BLOCKLIST_ENDPOINT) + .reply(200, { + recentlyAdded: [], + recentlyRemoved: [], + lastFetchedAt: 1, + }); + + const { controller, rootMessenger } = getPhishingController(); + await controller.updateStalelist(); + expect( + rootMessenger.call( + 'PhishingController:testOrigin', + 'https://example.com/path', + ), + ).toMatchObject({ + result: true, + type: PhishingDetectorResultType.Blocklist, + }); + }); + + it('returns negative result if the hostname+pathname is in the whitelistPaths', async () => { + const { rootMessenger } = getPhishingController({ + state: { + phishingLists: [ + { + allowlist: [], + blocklist: [], + c2DomainBlocklist: [], + blocklistPaths: { + 'example.com': { + path: {}, + }, + }, + fuzzylist: [], + tolerance: 0, + version: 0, + lastUpdated: 0, + name: ListNames.MetaMask, + }, + ], + }, + }); + rootMessenger.call('PhishingController:bypass', 'https://example.com/path'); + expect( + rootMessenger.call( + 'PhishingController:testOrigin', + 'https://example.com/path', + ), + ).toMatchObject({ result: false, - type: 'all', + type: PhishingDetectorResultType.All, + }); + }); + + it('returns positive result even if the hostname+pathname contains percent encoding', async () => { + const { rootMessenger } = getPhishingController({ + state: { + phishingLists: [ + { + allowlist: [], + blocklist: [], + blocklistPaths: { + 'example.com': { + path: {}, + }, + }, + c2DomainBlocklist: [], + fuzzylist: [], + tolerance: 0, + version: 0, + lastUpdated: 0, + name: ListNames.MetaMask, + }, + ], + }, + }); + + expect( + rootMessenger.call( + 'PhishingController:testOrigin', + 'https://example.com/%70%61%74%68', + ), + ).toMatchObject({ + result: true, + type: PhishingDetectorResultType.Blocklist, }); }); describe('updateStalelist', () => { it('should update lists with addition to hotlist', async () => { - sinon.useFakeTimers(2); - const exampleBlockedUrl = 'https://example-blocked-website.com'; + jest.useFakeTimers({ doNotFake: ['nextTick', 'queueMicrotask'], now: 2 }); + const exampleBlockedUrl = 'example-blocked-website.com'; + const exampleRequestBlockedHash = + '0415f1f12f07ddc4ef7e229da747c6c53a6a6474fbaf295a35d984ec0ece9455'; const exampleBlockedUrlOne = 'https://another-example-blocked-website.com'; nock(PHISHING_CONFIG_BASE_URL) .get(METAMASK_STALELIST_FILE) .reply(200, { data: { - eth_phishing_detect_config: { - allowlist: [], - blocklist: [exampleBlockedUrl], - fuzzylist: [], - }, - phishfort_hotlist: { - blocklist: [], - }, - tolerance: 0, allowlist: [], + blocklist: [exampleBlockedUrl], + blocklistPaths: [], + fuzzylist: [], + tolerance: 0, version: 0, lastUpdated: 1, }, @@ -958,47 +1631,46 @@ describe('PhishingController', () => { ], }); - const controller = getPhishingController(); + nock(CLIENT_SIDE_DETECION_BASE_URL) + .get(C2_DOMAIN_BLOCKLIST_ENDPOINT) + .reply(200, { + recentlyAdded: [exampleRequestBlockedHash], + recentlyRemoved: [], + lastFetchedAt: 1, + }); + + const { controller } = getPhishingController(); await controller.updateStalelist(); expect(controller.state.phishingLists).toStrictEqual([ { allowlist: [], blocklist: [exampleBlockedUrl, exampleBlockedUrlOne], + c2DomainBlocklist: [exampleRequestBlockedHash], + blocklistPaths: {}, fuzzylist: [], tolerance: 0, lastUpdated: 2, name: ListNames.MetaMask, version: 0, }, - { - allowlist: [], - blocklist: [], - fuzzylist: [], - tolerance: 0, - lastUpdated: 1, - name: ListNames.Phishfort, - version: 0, - }, ]); }); 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'; const exampleBlockedUrlTwo = 'another-example-blocked-website.com'; nock(PHISHING_CONFIG_BASE_URL) .get(METAMASK_STALELIST_FILE) .reply(200, { data: { - eth_phishing_detect_config: { - allowlist: [], - blocklist: [exampleBlockedUrl], - fuzzylist: [], - }, - phishfort_hotlist: { - blocklist: [], - }, + allowlist: [], + blocklist: [exampleBlockedUrl], + blocklistPaths: [], + fuzzylist: [], tolerance: 0, version: 0, lastUpdated: 1, @@ -1021,27 +1693,74 @@ describe('PhishingController', () => { ], }); - const controller = getPhishingController(); + nock(CLIENT_SIDE_DETECION_BASE_URL) + .get(C2_DOMAIN_BLOCKLIST_ENDPOINT) + .reply(200, { + recentlyAdded: [exampleRequestBlockedHash], + recentlyRemoved: [], + lastFetchedAt: 1, + }); + + const { controller } = getPhishingController(); await controller.updateStalelist(); expect(controller.state.phishingLists).toStrictEqual([ { allowlist: [], blocklist: [exampleBlockedUrlTwo], + c2DomainBlocklist: [exampleRequestBlockedHash], + blocklistPaths: {}, fuzzylist: [], tolerance: 0, version: 0, lastUpdated: 2, name: ListNames.MetaMask, }, + ]); + }); + + it('should correctly process blocklist entries with paths into blocklistPaths', async () => { + nock(PHISHING_CONFIG_BASE_URL) + .get(METAMASK_STALELIST_FILE) + .reply(200, { + data: { + allowlist: [], + blocklist: ['example.com'], + blocklistPaths: ['malicious.com/phishing'], + fuzzylist: [], + tolerance: 0, + version: 0, + lastUpdated: 1, + }, + }) + .get(`${METAMASK_HOTLIST_DIFF_FILE}/${1}`) + .reply(200, { data: [] }); + + nock(CLIENT_SIDE_DETECION_BASE_URL) + .get(C2_DOMAIN_BLOCKLIST_ENDPOINT) + .reply(200, { + recentlyAdded: [], + recentlyRemoved: [], + lastFetchedAt: 1, + }); + + const { controller } = getPhishingController(); + await controller.updateStalelist(); + expect(controller.state.phishingLists).toStrictEqual([ { - blocklist: [], allowlist: [], + blocklist: ['example.com'], + c2DomainBlocklist: [], + blocklistPaths: { + 'malicious.com': { + phishing: {}, + }, + }, fuzzylist: [], tolerance: 0, version: 0, lastUpdated: 1, - name: ListNames.Phishfort, + name: ListNames.MetaMask, }, ]); }); @@ -1053,12 +1772,14 @@ describe('PhishingController', () => { .get(`${METAMASK_HOTLIST_DIFF_FILE}/${1}`) .reply(304); - const controller = getPhishingController({ + const { controller } = getPhishingController({ state: { phishingLists: [ { allowlist: [], blocklist: [], + c2DomainBlocklist: [], + blocklistPaths: {}, fuzzylist: [], tolerance: 3, version: 1, @@ -1074,6 +1795,8 @@ describe('PhishingController', () => { { allowlist: [], blocklist: [], + c2DomainBlocklist: [], + blocklistPaths: {}, fuzzylist: [], tolerance: 3, version: 1, @@ -1090,12 +1813,18 @@ describe('PhishingController', () => { .get(`${METAMASK_HOTLIST_DIFF_FILE}/${1}`) .reply(500); - const controller = getPhishingController({ + nock(CLIENT_SIDE_DETECION_BASE_URL) + .get(C2_DOMAIN_BLOCKLIST_ENDPOINT) + .reply(500); + + const { controller } = getPhishingController({ state: { phishingLists: [ { allowlist: [], blocklist: [], + c2DomainBlocklist: [], + blocklistPaths: {}, fuzzylist: [], tolerance: 3, version: 1, @@ -1111,6 +1840,8 @@ describe('PhishingController', () => { { allowlist: [], blocklist: [], + c2DomainBlocklist: [], + blocklistPaths: {}, fuzzylist: [], tolerance: 3, version: 1, @@ -1127,27 +1858,29 @@ describe('PhishingController', () => { .get(`${METAMASK_HOTLIST_DIFF_FILE}/${1}`) .replyWithError('network error'); - const controller = getPhishingController(); + nock(CLIENT_SIDE_DETECION_BASE_URL) + .get(C2_DOMAIN_BLOCKLIST_ENDPOINT) + .replyWithError('network error'); + + const { controller } = getPhishingController(); expect(await controller.updateStalelist()).toBeUndefined(); }); 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) .reply(200, { data: { - eth_phishing_detect_config: { - allowlist: [], - blocklist: [], - fuzzylist: [], - }, - phishfort_hotlist: { - blocklist: [], - }, + allowlist: [], + blocklist: [], + fuzzylist: [], tolerance: 0, version: 0, lastUpdated: 1, @@ -1157,11 +1890,11 @@ describe('PhishingController', () => { .delay(100) .reply(200, { data: [] }); - const controller = getPhishingController(); + const { controller } = getPhishingController(); const firstPromise = controller.updateStalelist(); const secondPromise = controller.updateStalelist(); - clock.tick(1000 * 100); + jest.advanceTimersByTime(1000 * 100); await firstPromise; await secondPromise; @@ -1172,20 +1905,18 @@ 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) .reply(200, { data: { - eth_phishing_detect_config: { - allowlist: [], - blocklist: [], - fuzzylist: [], - }, - phishfort_hotlist: { - blocklist: [], - }, + allowlist: [], + blocklist: [], + fuzzylist: [], tolerance: 0, version: 0, lastUpdated: 1, @@ -1195,10 +1926,10 @@ describe('PhishingController', () => { .delay(100) .reply(200, { data: [] }); - const controller = getPhishingController(); + const { controller } = getPhishingController(); const firstPromise = controller.updateStalelist(); const secondPromise = controller.updateStalelist(); - clock.tick(1000 * 99); + jest.advanceTimersByTime(1000 * 99); await expect(secondPromise).toNeverResolve(); @@ -1223,12 +1954,14 @@ describe('PhishingController', () => { ], }); - const controller = getPhishingController({ + const { controller } = getPhishingController({ state: { phishingLists: [ { allowlist: [], blocklist: [], + c2DomainBlocklist: [], + blocklistPaths: {}, fuzzylist: [], tolerance: 3, version: 1, @@ -1244,6 +1977,8 @@ describe('PhishingController', () => { { allowlist: [], blocklist: [testBlockedDomain], + c2DomainBlocklist: [], + blocklistPaths: {}, fuzzylist: [], tolerance: 3, name: ListNames.MetaMask, @@ -1252,17 +1987,20 @@ describe('PhishingController', () => { }, ]); }); - it('should not update phishing lists if hotlist fetch returns 400', async () => { + + it('should not update phishing lists if hotlist fetch returns 404', async () => { nock(PHISHING_CONFIG_BASE_URL) .get(`${METAMASK_HOTLIST_DIFF_FILE}/${0}`) .reply(404); - const controller = getPhishingController({ + const { controller } = getPhishingController({ state: { phishingLists: [ { allowlist: [], blocklist: [], + c2DomainBlocklist: [], + blocklistPaths: {}, fuzzylist: [], tolerance: 3, version: 1, @@ -1281,5 +2019,3228 @@ describe('PhishingController', () => { }, ]); }); + + it('should not make API calls to update hotlist when phishingLists array is empty', async () => { + const testBlockedDomain = 'some-test-blocked-url.com'; + const hotlistNock = nock(PHISHING_CONFIG_BASE_URL) + .get(`${METAMASK_HOTLIST_DIFF_FILE}/${0}`) + .reply(200, { + data: [ + { + targetList: 'eth_phishing_detect_config.blocklist', + url: testBlockedDomain, + timestamp: 1, + }, + ], + }); + + const { controller } = getPhishingController({ + state: { + phishingLists: [], + }, + }); + await controller.updateHotlist(); + + expect(hotlistNock.isDone()).toBe(false); + }); + + it('should handle empty hotlist and request blocklist responses gracefully', async () => { + nock(PHISHING_CONFIG_BASE_URL) + .get(`${METAMASK_HOTLIST_DIFF_FILE}/0`) + .reply(200, { data: [] }); + + nock(CLIENT_SIDE_DETECION_BASE_URL) + .get(`${C2_DOMAIN_BLOCKLIST_ENDPOINT}?timestamp=0`) + .reply(200, { + recentlyAdded: [], + recentlyRemoved: [], + lastFetchedAt: 1, + }); + + const { controller } = getPhishingController({ + state: { + phishingLists: [ + { + allowlist: [], + blocklist: [], + c2DomainBlocklist: [], + blocklistPaths: {}, + fuzzylist: [], + tolerance: 3, + version: 1, + name: ListNames.MetaMask, + lastUpdated: 0, + }, + ], + }, + }); + await controller.updateHotlist(); + await controller.updateC2DomainBlocklist(); + + expect(controller.state.phishingLists).toStrictEqual([ + { + allowlist: [], + blocklist: [], + c2DomainBlocklist: [], + blocklistPaths: {}, + fuzzylist: [], + tolerance: 3, + version: 1, + name: ListNames.MetaMask, + lastUpdated: 0, + }, + ]); + }); + + it('should handle errors during hotlist fetching gracefully', async () => { + const exampleRequestBlockedHash = + '0415f1f12f07ddc4ef7e229da747c6c53a6a6474fbaf295a35d984ec0ece9455'; + + nock(PHISHING_CONFIG_BASE_URL) + .get(`${METAMASK_HOTLIST_DIFF_FILE}/0`) + .replyWithError('network error'); + + nock(CLIENT_SIDE_DETECION_BASE_URL) + .get(`${C2_DOMAIN_BLOCKLIST_ENDPOINT}?timestamp=0`) + .reply(200, { + recentlyAdded: [exampleRequestBlockedHash], + recentlyRemoved: [], + lastFetchedAt: 1, + }); + + const { controller } = getPhishingController({ + state: { + phishingLists: [ + { + allowlist: [], + blocklist: [], + c2DomainBlocklist: [exampleRequestBlockedHash], + blocklistPaths: {}, + fuzzylist: [], + tolerance: 3, + version: 1, + name: ListNames.MetaMask, + lastUpdated: 1, + }, + ], + }, + }); + + await controller.updateHotlist(); + await controller.updateC2DomainBlocklist(); + + expect(controller.state.phishingLists).toStrictEqual([ + { + allowlist: [], + blocklist: [], + c2DomainBlocklist: [exampleRequestBlockedHash], + blocklistPaths: {}, + fuzzylist: [], + tolerance: 3, + name: ListNames.MetaMask, + version: 1, + lastUpdated: 1, + }, + ]); + }); + it('should handle missing hotlist data and non-empty domain blocklist gracefully', async () => { + const exampleRequestBlockedHash = + '0415f1f12f07ddc4ef7e229da747c6c53a6a6474fbaf295a35d984ec0ece9455'; + + nock(PHISHING_CONFIG_BASE_URL) + .get(`${METAMASK_HOTLIST_DIFF_FILE}/0`) + .reply(500); + + nock(CLIENT_SIDE_DETECION_BASE_URL) + .get(`${C2_DOMAIN_BLOCKLIST_ENDPOINT}?timestamp=0`) + .reply(200, { + recentlyAdded: [exampleRequestBlockedHash], + recentlyRemoved: [], + lastFetchedAt: 1, + }); + + const { controller } = getPhishingController({ + state: { + phishingLists: [ + { + allowlist: [], + blocklist: [], + c2DomainBlocklist: [], + blocklistPaths: {}, + fuzzylist: [], + tolerance: 3, + version: 1, + name: ListNames.MetaMask, + lastUpdated: 0, + }, + ], + }, + }); + + await controller.updateHotlist(); + await controller.updateC2DomainBlocklist(); + + expect(controller.state.phishingLists).toStrictEqual([ + { + allowlist: [], + blocklist: [], + c2DomainBlocklist: [exampleRequestBlockedHash], + blocklistPaths: {}, + fuzzylist: [], + tolerance: 3, + name: ListNames.MetaMask, + version: 1, + lastUpdated: 0, + }, + ]); + }); + }); + + describe('updateC2DomainBlocklist', () => { + it('should update the C2 domain blocklist if the fetch returns 200', async () => { + const exampleRequestBlockedHash = + '0415f1f12f07ddc4ef7e229da747c6c53a6a6474fbaf295a35d984ec0ece9455'; + + // Mocking the request to the C2 domain blocklist endpoint + nock(CLIENT_SIDE_DETECION_BASE_URL) + .get(`${C2_DOMAIN_BLOCKLIST_ENDPOINT}?timestamp=0`) + .reply(200, { + recentlyAdded: [exampleRequestBlockedHash], + recentlyRemoved: [], + lastFetchedAt: 1, + }); + + const { controller } = getPhishingController({ + state: { + phishingLists: [ + { + allowlist: [], + blocklist: [], + c2DomainBlocklist: [], + blocklistPaths: {}, + fuzzylist: [], + tolerance: 3, + version: 1, + name: ListNames.MetaMask, + lastUpdated: 0, + }, + ], + c2DomainBlocklistLastFetched: 0, + }, + }); + + await controller.updateC2DomainBlocklist(); + + expect(controller.state.phishingLists).toStrictEqual([ + { + allowlist: [], + blocklist: [], + c2DomainBlocklist: [exampleRequestBlockedHash], + blocklistPaths: {}, + fuzzylist: [], + tolerance: 3, + version: 1, + name: ListNames.MetaMask, + lastUpdated: 0, + }, + ]); + expect(controller.state.c2DomainBlocklistLastFetched).toBeGreaterThan(0); + }); + + it('should not update the C2 domain blocklist if the fetch returns 404', async () => { + nock(CLIENT_SIDE_DETECION_BASE_URL) + .get(`${C2_DOMAIN_BLOCKLIST_ENDPOINT}?timestamp=0`) + .reply(404); + + const { controller } = getPhishingController({ + state: { + phishingLists: [ + { + allowlist: [], + blocklist: [], + c2DomainBlocklist: [], + blocklistPaths: {}, + fuzzylist: [], + tolerance: 3, + version: 1, + name: ListNames.MetaMask, + lastUpdated: 0, + }, + ], + c2DomainBlocklistLastFetched: 0, + }, + }); + + await controller.updateC2DomainBlocklist(); + + expect(controller.state.phishingLists).toStrictEqual([ + { + allowlist: [], + blocklist: [], + c2DomainBlocklist: [], + blocklistPaths: {}, + fuzzylist: [], + tolerance: 3, + version: 1, + name: ListNames.MetaMask, + lastUpdated: 0, + }, + ]); + expect(controller.state.c2DomainBlocklistLastFetched).toBe(0); + }); + + it('should update request blocklist with additions and removals', async () => { + const exampleRequestBlockedHash = + '0415f1f12f07ddc4ef7e229da747c6c53a6a6474fbaf295a35d984ec0ece9455'; + const exampleRequestBlockedHashTwo = 'd3bkcslj57l47pamplifyapp'; + + // Mock the request blocklist response with additions and removals + nock(CLIENT_SIDE_DETECION_BASE_URL) + .get(`${C2_DOMAIN_BLOCKLIST_ENDPOINT}?timestamp=0`) + .reply(200, { + recentlyAdded: [exampleRequestBlockedHash], + recentlyRemoved: [exampleRequestBlockedHashTwo], + lastFetchedAt: 1, + }); + + // Initialize the controller with an existing state + const { controller } = getPhishingController({ + state: { + phishingLists: [ + { + allowlist: [], + blocklist: [], + c2DomainBlocklist: [exampleRequestBlockedHashTwo], + blocklistPaths: {}, + fuzzylist: [], + tolerance: 3, + version: 1, + name: ListNames.MetaMask, + lastUpdated: 0, + }, + ], + }, + }); + + await controller.updateC2DomainBlocklist(); + + // Check the updated state + expect(controller.state.phishingLists).toStrictEqual([ + { + allowlist: [], + blocklist: [], + c2DomainBlocklist: [exampleRequestBlockedHash], + blocklistPaths: {}, + fuzzylist: [], + tolerance: 3, + name: ListNames.MetaMask, + version: 1, + lastUpdated: 0, + }, + ]); + }); + + it('should handle an update that is already in progress', async () => { + const exampleRequestBlockedHash = + '0415f1f12f07ddc4ef7e229da747c6c53a6a6474fbaf295a35d984ec0ece9455'; + + nock(CLIENT_SIDE_DETECION_BASE_URL) + .get(`${C2_DOMAIN_BLOCKLIST_ENDPOINT}?timestamp=0`) + .reply(200, { + recentlyAdded: [exampleRequestBlockedHash], + recentlyRemoved: [], + lastFetchedAt: 1, + }); + + const { controller } = getPhishingController({ + state: { + phishingLists: [ + { + allowlist: [], + blocklist: [], + c2DomainBlocklist: [], + blocklistPaths: {}, + fuzzylist: [], + tolerance: 3, + version: 1, + name: ListNames.MetaMask, + lastUpdated: 0, + }, + ], + c2DomainBlocklistLastFetched: 0, + }, + }); + + const firstUpdatePromise = controller.updateC2DomainBlocklist(); + const secondUpdatePromise = controller.updateC2DomainBlocklist(); + + await firstUpdatePromise; + await secondUpdatePromise; + + expect(controller.state.phishingLists).toStrictEqual([ + { + allowlist: [], + blocklist: [], + c2DomainBlocklist: [exampleRequestBlockedHash], + blocklistPaths: {}, + fuzzylist: [], + tolerance: 3, + version: 1, + name: ListNames.MetaMask, + lastUpdated: 0, + }, + ]); + expect(controller.state.c2DomainBlocklistLastFetched).toBeGreaterThan(0); + }); + + it('should handle empty recentlyAdded and recentlyRemoved in the response', async () => { + nock(CLIENT_SIDE_DETECION_BASE_URL) + .get(`${C2_DOMAIN_BLOCKLIST_ENDPOINT}?timestamp=0`) + .reply(200, { + recentlyAdded: [], + recentlyRemoved: [], + lastFetchedAt: 1, + }); + + const { controller } = getPhishingController({ + state: { + phishingLists: [ + { + allowlist: [], + blocklist: [], + c2DomainBlocklist: [], + blocklistPaths: {}, + fuzzylist: [], + tolerance: 3, + version: 1, + name: ListNames.MetaMask, + lastUpdated: 0, + }, + ], + c2DomainBlocklistLastFetched: 0, + }, + }); + + await controller.updateC2DomainBlocklist(); + + expect(controller.state.phishingLists).toStrictEqual([ + { + allowlist: [], + blocklist: [], + c2DomainBlocklist: [], + blocklistPaths: {}, + fuzzylist: [], + tolerance: 3, + version: 1, + name: ListNames.MetaMask, + lastUpdated: 0, + }, + ]); + expect(controller.state.c2DomainBlocklistLastFetched).toBeGreaterThan(0); + }); + + it('should handle errors during C2 domain blocklist fetching gracefully', async () => { + nock(CLIENT_SIDE_DETECION_BASE_URL) + .get(`${C2_DOMAIN_BLOCKLIST_ENDPOINT}?timestamp=0`) + .replyWithError('network error'); + + const { controller } = getPhishingController({ + state: { + phishingLists: [ + { + allowlist: [], + blocklist: [], + c2DomainBlocklist: [], + blocklistPaths: {}, + fuzzylist: [], + tolerance: 3, + version: 1, + name: ListNames.MetaMask, + lastUpdated: 0, + }, + ], + c2DomainBlocklistLastFetched: 0, + }, + }); + + await controller.updateC2DomainBlocklist(); + + expect(controller.state.phishingLists).toStrictEqual([ + { + allowlist: [], + blocklist: [], + c2DomainBlocklist: [], + blocklistPaths: {}, + fuzzylist: [], + tolerance: 3, + version: 1, + name: ListNames.MetaMask, + lastUpdated: 0, + }, + ]); + expect(controller.state.c2DomainBlocklistLastFetched).toBe(0); + }); + }); + + describe('PhishingController - isBlockedRequest', () => { + afterEach(() => { + cleanAll(); + }); + + it('should return false if c2DomainBlocklist is not defined or empty', async () => { + nock(PHISHING_CONFIG_BASE_URL) + .get(METAMASK_STALELIST_FILE) + .reply(200, { + data: { + allowlist: [], + blocklist: [], + fuzzylist: [], + tolerance: 0, + version: 0, + lastUpdated: 1, + }, + }) + .get(`${METAMASK_HOTLIST_DIFF_FILE}/${1}`) + .reply(200, { data: [] }); + + nock(CLIENT_SIDE_DETECION_BASE_URL) + .get(C2_DOMAIN_BLOCKLIST_ENDPOINT) + .reply(200, { + recentlyAdded: [], + recentlyRemoved: [], + lastFetchedAt: 1, + }); + + const { controller, rootMessenger } = getPhishingController(); + await controller.updateStalelist(); + const result = rootMessenger.call( + 'PhishingController:isBlockedRequest', + 'https://example.com', + ); + expect(result).toMatchObject({ + result: false, + type: PhishingDetectorResultType.C2DomainBlocklist, + }); + }); + + it('should return true if URL is in the c2DomainBlocklist', async () => { + const exampleRequestBlockedHash = + '0415f1f12f07ddc4ef7e229da747c6c53a6a6474fbaf295a35d984ec0ece9455'; + nock(PHISHING_CONFIG_BASE_URL) + .get(METAMASK_STALELIST_FILE) + .reply(200, { + data: { + allowlist: [], + blocklist: [], + fuzzylist: [], + tolerance: 0, + version: 0, + lastUpdated: 1, + }, + }) + .get(`${METAMASK_HOTLIST_DIFF_FILE}/${1}`) + .reply(200, { data: [] }); + + nock(CLIENT_SIDE_DETECION_BASE_URL) + .get(C2_DOMAIN_BLOCKLIST_ENDPOINT) + .reply(200, { + recentlyAdded: [exampleRequestBlockedHash], + recentlyRemoved: [], + lastFetchedAt: 1, + }); + + const { controller, rootMessenger } = getPhishingController(); + await controller.updateStalelist(); + const result = rootMessenger.call( + 'PhishingController:isBlockedRequest', + 'https://develop.d3bkcslj57l47p.amplifyapp.com', + ); + expect(result).toMatchObject({ + name: ListNames.MetaMask, + result: true, + type: PhishingDetectorResultType.C2DomainBlocklist, + }); + }); + + it('should return false if URL is not in the c2DomainBlocklist', async () => { + nock(PHISHING_CONFIG_BASE_URL) + .get(METAMASK_STALELIST_FILE) + .reply(200, { + data: { + allowlist: [], + blocklist: [], + fuzzylist: [], + tolerance: 0, + version: 0, + lastUpdated: 1, + }, + }) + .get(`${METAMASK_HOTLIST_DIFF_FILE}/${1}`) + .reply(200, { data: [] }); + + nock(CLIENT_SIDE_DETECION_BASE_URL) + .get(C2_DOMAIN_BLOCKLIST_ENDPOINT) + .reply(200, { + recentlyAdded: [], + recentlyRemoved: [], + lastFetchedAt: 1, + }); + + const { controller, rootMessenger } = getPhishingController(); + await controller.updateStalelist(); + const result = rootMessenger.call( + 'PhishingController:isBlockedRequest', + 'https://example.com', + ); + expect(result).toMatchObject({ + result: false, + type: PhishingDetectorResultType.C2DomainBlocklist, + }); + }); + + it('should return false if URL is invalid', async () => { + nock(PHISHING_CONFIG_BASE_URL) + .get(METAMASK_STALELIST_FILE) + .reply(200, { + data: { + allowlist: [], + blocklist: [], + fuzzylist: [], + tolerance: 0, + version: 0, + lastUpdated: 1, + }, + }) + .get(`${METAMASK_HOTLIST_DIFF_FILE}/${1}`) + .reply(200, { data: [] }); + + nock(CLIENT_SIDE_DETECION_BASE_URL) + .get(C2_DOMAIN_BLOCKLIST_ENDPOINT) + .reply(200, { + recentlyAdded: [], + recentlyRemoved: [], + lastFetchedAt: 1, + }); + + const { controller, rootMessenger } = getPhishingController(); + await controller.updateStalelist(); + const result = rootMessenger.call( + 'PhishingController:isBlockedRequest', + '#$@(%&@#$(%', + ); + expect(result).toMatchObject({ + result: false, + type: PhishingDetectorResultType.C2DomainBlocklist, + }); + }); + }); + it('isBlockedRequest - should return false if the URL is in the whitelist', async () => { + const whitelistedHostname = 'example.com'; + + const { rootMessenger } = getPhishingController(); + rootMessenger.call( + 'PhishingController:bypass', + formatHostnameToUrl(whitelistedHostname), + ); + const result = rootMessenger.call( + 'PhishingController:isBlockedRequest', + `https://${whitelistedHostname}/path`, + ); + + expect(result).toMatchObject({ + result: false, + type: PhishingDetectorResultType.All, + }); + }); + it('isBlockedRequest - should return false if the URL is in the allowlist', async () => { + const allowlistedDomain = 'example.com'; + + nock(PHISHING_CONFIG_BASE_URL) + .get(METAMASK_STALELIST_FILE) + .reply(200, { + data: { + allowlist: [allowlistedDomain], + blocklist: [], + blocklistPaths: [], + fuzzylist: [], + tolerance: 0, + version: 0, + lastUpdated: 1, + }, + }) + .get(`${METAMASK_HOTLIST_DIFF_FILE}/${1}`) + .reply(200, { data: [] }); + + nock(CLIENT_SIDE_DETECION_BASE_URL) + .get(C2_DOMAIN_BLOCKLIST_ENDPOINT) + .reply(200, { + recentlyAdded: [], + recentlyRemoved: [], + lastFetchedAt: 1, + }); + + const { controller, rootMessenger } = getPhishingController(); + await controller.updateStalelist(); + const result = rootMessenger.call( + 'PhishingController:isBlockedRequest', + `https://${allowlistedDomain}/path`, + ); + + expect(result).toMatchObject({ + result: false, + type: PhishingDetectorResultType.Allowlist, + }); + }); + describe('bypass', () => { + let controller: PhishingController; + let rootMessenger: RootMessenger; + + beforeEach(() => { + const { controller: createdController, rootMessenger: createdMessenger } = + getPhishingController({ + state: { + phishingLists: [ + { + allowlist: [], + blocklist: [], + c2DomainBlocklist: [], + blocklistPaths: { + 'example.com': { + path: {}, + }, + 'sub.example.com': { + path1: { + path2: {}, + }, + }, + }, + fuzzylist: [], + tolerance: 0, + version: 0, + lastUpdated: 0, + name: ListNames.MetaMask, + }, + ], + whitelistPaths: {}, + }, + }); + + controller = createdController; + rootMessenger = createdMessenger; + }); + + describe('whitelist', () => { + it('should do nothing if the origin is already in the whitelist', () => { + const origin = 'https://example.com'; + const hostname = getHostnameFromUrl(origin); + + // Call the bypass function + rootMessenger.call('PhishingController:bypass', origin); + rootMessenger.call('PhishingController:bypass', origin); + + // Verify that the whitelist has not changed + expect(controller.state.whitelist).toContain(hostname); + expect(controller.state.whitelist).toHaveLength(1); // No duplicates added + expect(Object.keys(controller.state.whitelistPaths)).toHaveLength(0); + }); + + it('should add the origin to the whitelist if not already present', () => { + const origin = 'https://newsite.com'; + const hostname = getHostnameFromUrl(origin); + + // Call the bypass function + rootMessenger.call('PhishingController:bypass', origin); + + // Verify that the whitelist now includes the new origin + expect(controller.state.whitelist).toContain(hostname); + expect(controller.state.whitelist).toHaveLength(1); + expect(Object.keys(controller.state.whitelistPaths)).toHaveLength(0); + }); + + it('should add punycode origins to the whitelist if not already present', () => { + const punycodeOrigin = 'xn--fsq.com'; // Example punycode domain + + // Call the bypass function + rootMessenger.call('PhishingController:bypass', punycodeOrigin); + + // Verify that the whitelist now includes the punycode origin + expect(controller.state.whitelist).toContain(punycodeOrigin); + expect(controller.state.whitelist).toHaveLength(1); + expect(Object.keys(controller.state.whitelistPaths)).toHaveLength(0); + }); + }); + + describe('whitelistPaths', () => { + it('adds the matched path prefix within blocklistPaths to the whitelistPaths', () => { + const origin = 'https://sub.example.com/path1/path2/path3'; + rootMessenger.call('PhishingController:bypass', origin); + + expect(controller.state.whitelistPaths).toStrictEqual({ + 'sub.example.com': { + path1: { + path2: {}, + }, + }, + }); + expect(controller.state.whitelist).toHaveLength(0); + }); + + it('does not add if a matched path prefix is not present', () => { + const origin = 'https://sub.example.com/path1/path3'; + rootMessenger.call('PhishingController:bypass', origin); + + expect(controller.state.whitelistPaths).toStrictEqual({}); + expect(controller.state.whitelist).toStrictEqual(['sub.example.com']); + }); + + it('idempotent', () => { + const origin = 'https://example.com/path'; + rootMessenger.call('PhishingController:bypass', origin); + rootMessenger.call('PhishingController:bypass', origin); + + expect(controller.state.whitelistPaths).toStrictEqual({ + 'example.com': { + path: {}, + }, + }); + expect(controller.state.whitelist).toHaveLength(0); + }); + + it('if the pathname contains percent encoding, it is added decoded', () => { + const origin = 'https://example.com/%70%61%74%68'; + rootMessenger.call('PhishingController:bypass', origin); + + expect(controller.state.whitelistPaths).toStrictEqual({ + 'example.com': { + path: {}, + }, + }); + }); + }); + }); + + describe('scanUrl', () => { + let rootMessenger: RootMessenger; + + const testUrl: string = 'https://example.com'; + const mockResponse: PhishingDetectionScanResult = { + hostname: 'example.com', + recommendedAction: RecommendedAction.None, + }; + + beforeEach(() => { + const { rootMessenger: createdMessenger } = getPhishingController(); + + rootMessenger = createdMessenger; + + jest.useFakeTimers({ doNotFake: ['nextTick', 'queueMicrotask'], now: 0 }); + }); + + it('should return the scan result', async () => { + const scope = nock(PHISHING_DETECTION_BASE_URL) + .get(`/${PHISHING_DETECTION_SCAN_ENDPOINT}`) + .query({ url: 'example.com' }) + .reply(200, mockResponse); + + const response = await rootMessenger.call( + 'PhishingController:scanUrl', + testUrl, + ); + expect(response).toMatchObject(mockResponse); + expect(scope.isDone()).toBe(true); + }); + + it.each([ + [400, 'Bad Request'], + [401, 'Unauthorized'], + [403, 'Forbidden'], + [404, 'Not Found'], + [500, 'Internal Server Error'], + [502, 'Bad Gateway'], + [503, 'Service Unavailable'], + [504, 'Gateway Timeout'], + ])( + 'should return a PhishingDetectionScanResult with a fetchError on %i status code', + async (statusCode, statusText) => { + const scope = nock(PHISHING_DETECTION_BASE_URL) + .get(`/${PHISHING_DETECTION_SCAN_ENDPOINT}`) + .query({ url: 'example.com' }) + .reply(statusCode); + + const response = await rootMessenger.call( + 'PhishingController:scanUrl', + testUrl, + ); + expect(response).toMatchObject({ + hostname: '', + recommendedAction: RecommendedAction.None, + fetchError: `${statusCode} ${statusText}`, + }); + expect(scope.isDone()).toBe(true); + }, + ); + + it('should return a PhishingDetectionScanResult with a fetchError on timeout', async () => { + const scope = nock(PHISHING_DETECTION_BASE_URL) + .get(`/${PHISHING_DETECTION_SCAN_ENDPOINT}`) + .query({ url: 'example.com' }) + .delayConnection(10000) + .reply(200, {}); + + const promise = rootMessenger.call('PhishingController:scanUrl', testUrl); + jest.advanceTimersByTime(8000); + const response = await promise; + expect(response).toMatchObject({ + hostname: '', + recommendedAction: RecommendedAction.None, + fetchError: 'timeout of 8000ms exceeded', + }); + expect(scope.isDone()).toBe(false); + }); + + it('should only send hostname when URL contains query parameters', async () => { + const urlWithQuery = + 'https://example.com/path?param1=value1¶m2=value2'; + const expectedHostname = 'example.com'; + + const scope = nock(PHISHING_DETECTION_BASE_URL) + .get(`/${PHISHING_DETECTION_SCAN_ENDPOINT}`) + .query({ url: expectedHostname }) + .reply(200, mockResponse); + + const response = await rootMessenger.call( + 'PhishingController:scanUrl', + urlWithQuery, + ); + expect(response).toMatchObject(mockResponse); + expect(scope.isDone()).toBe(true); + }); + + it('should only send hostname when URL contains hash fragments', async () => { + const urlWithHash = 'https://example.com/page#section1'; + const expectedHostname = 'example.com'; + + const scope = nock(PHISHING_DETECTION_BASE_URL) + .get(`/${PHISHING_DETECTION_SCAN_ENDPOINT}`) + .query({ url: expectedHostname }) + .reply(200, mockResponse); + + const response = await rootMessenger.call( + 'PhishingController:scanUrl', + urlWithHash, + ); + expect(response).toMatchObject(mockResponse); + expect(scope.isDone()).toBe(true); + }); + + it('should only send hostname for complex URLs with multiple parameters', async () => { + const complexUrl = + 'https://sub.example.com:8080/path/to/page?q=search&utm_source=test#top'; + const expectedHostname = 'sub.example.com'; + + const subdomainResponse = { + ...mockResponse, + hostname: 'sub.example.com', + }; + + const scope = nock(PHISHING_DETECTION_BASE_URL) + .get(`/${PHISHING_DETECTION_SCAN_ENDPOINT}`) + .query({ url: expectedHostname }) + .reply(200, subdomainResponse); + + const response = await rootMessenger.call( + 'PhishingController:scanUrl', + complexUrl, + ); + expect(response).toMatchObject(subdomainResponse); + expect(scope.isDone()).toBe(true); + }); + + it('should return a PhishingDetectionScanResult with a fetchError on invalid URLs', async () => { + const invalidUrls = [ + 'not-a-url', + 'http://', + 'https://', + 'example', + 'http://.', + 'http://..', + 'http://../', + 'http://?', + 'http://??', + 'http://??/', + 'http://#', + 'http://##', + 'http://##/', + 'chrome://extensions', + 'file://some_file.pdf', + 'about:blank', + ]; + + for (const invalidUrl of invalidUrls) { + const response = await rootMessenger.call( + 'PhishingController:scanUrl', + invalidUrl, + ); + expect(response).toMatchObject({ + hostname: '', + recommendedAction: RecommendedAction.None, + fetchError: 'url is not a valid web URL', + }); + } + }); + + it('should handle URLs with authentication parameters correctly', async () => { + const urlWithAuth = 'https://user:pass@example.com/secure'; + const expectedHostname = 'example.com'; + + const scope = nock(PHISHING_DETECTION_BASE_URL) + .get(`/${PHISHING_DETECTION_SCAN_ENDPOINT}`) + .query({ url: expectedHostname }) + .reply(200, mockResponse); + + const response = await rootMessenger.call( + 'PhishingController:scanUrl', + urlWithAuth, + ); + expect(response).toMatchObject(mockResponse); + expect(scope.isDone()).toBe(true); + }); + + it('should send hostname and path for path-based gateways and cache per path', async () => { + const urlA = 'https://ipfs.io/ipfs/QmAAA'; + const urlB = 'https://ipfs.io/ipfs/QmBBB'; + + const scopeA = nock(PHISHING_DETECTION_BASE_URL) + .get(`/${PHISHING_DETECTION_SCAN_ENDPOINT}`) + .query({ url: 'ipfs.io/ipfs/QmAAA' }) + .reply(200, { + recommendedAction: RecommendedAction.Warn, + }); + + const scopeB = nock(PHISHING_DETECTION_BASE_URL) + .get(`/${PHISHING_DETECTION_SCAN_ENDPOINT}`) + .query({ url: 'ipfs.io/ipfs/QmBBB' }) + .reply(200, { + recommendedAction: RecommendedAction.Block, + }); + + const fetchSpy = jest.spyOn(global, 'fetch'); + + const resultA1 = await rootMessenger.call( + 'PhishingController:scanUrl', + urlA, + ); + const resultB = await rootMessenger.call( + 'PhishingController:scanUrl', + urlB, + ); + const resultA2 = await rootMessenger.call( + 'PhishingController:scanUrl', + urlA, + ); + + expect(resultA1).toMatchObject({ + hostname: 'ipfs.io', + recommendedAction: RecommendedAction.Warn, + }); + expect(resultB).toMatchObject({ + hostname: 'ipfs.io', + recommendedAction: RecommendedAction.Block, + }); + expect(resultA2).toStrictEqual(resultA1); + + expect(scopeA.isDone()).toBe(true); + expect(scopeB.isDone()).toBe(true); + expect(fetchSpy).toHaveBeenCalledTimes(2); + + fetchSpy.mockRestore(); + }); + }); + + describe('bulkScanUrls', () => { + let rootMessenger: RootMessenger; + + const testUrls: string[] = [ + 'https://example1.com', + 'https://example2.com', + 'https://example3.com', + ]; + const mockResponse: BulkPhishingDetectionScanResponse = { + results: { + 'https://example1.com': { + hostname: 'example1.com', + recommendedAction: RecommendedAction.None, + }, + 'https://example2.com': { + hostname: 'example2.com', + recommendedAction: RecommendedAction.Block, + }, + 'https://example3.com': { + hostname: 'example3.com', + recommendedAction: RecommendedAction.None, + }, + }, + errors: {}, + }; + + beforeEach(() => { + const { rootMessenger: createdMessenger } = getPhishingController(); + + rootMessenger = createdMessenger; + + jest.useFakeTimers({ doNotFake: ['nextTick', 'queueMicrotask'], now: 0 }); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + it('should return the scan results for multiple URLs', async () => { + const scope = nock(PHISHING_DETECTION_BASE_URL) + .post(`/${PHISHING_DETECTION_BULK_SCAN_ENDPOINT}`, { + urls: testUrls, + }) + .reply(200, mockResponse); + + const response = await rootMessenger.call( + 'PhishingController:bulkScanUrls', + testUrls, + ); + expect(response).toStrictEqual(mockResponse); + expect(scope.isDone()).toBe(true); + }); + + it('should handle empty URL arrays', async () => { + const response = await rootMessenger.call( + 'PhishingController:bulkScanUrls', + [], + ); + expect(response).toStrictEqual({ + results: {}, + errors: {}, + }); + }); + + it('should enforce maximum URL limit', async () => { + const tooManyUrls = Array(251).fill('https://example.com'); + const response = await rootMessenger.call( + 'PhishingController:bulkScanUrls', + tooManyUrls, + ); + expect(response).toStrictEqual({ + results: {}, + errors: { + too_many_urls: ['Maximum of 250 URLs allowed per request'], + }, + }); + }); + + it('should validate URL length', async () => { + const longUrl = `https://example.com/${'a'.repeat(2048)}`; + const response = await rootMessenger.call( + 'PhishingController:bulkScanUrls', + [longUrl], + ); + expect(response).toStrictEqual({ + results: {}, + errors: { + [longUrl]: ['URL length must not exceed 2048 characters'], + }, + }); + }); + + it.each([ + [400, 'Bad Request'], + [401, 'Unauthorized'], + [403, 'Forbidden'], + [404, 'Not Found'], + [500, 'Internal Server Error'], + [502, 'Bad Gateway'], + [503, 'Service Unavailable'], + [504, 'Gateway Timeout'], + ])( + 'should return an error response on %i status code', + async (statusCode, statusText) => { + const scope = nock(PHISHING_DETECTION_BASE_URL) + .post(`/${PHISHING_DETECTION_BULK_SCAN_ENDPOINT}`, { + urls: testUrls, + }) + .reply(statusCode); + + const response = await rootMessenger.call( + 'PhishingController:bulkScanUrls', + testUrls, + ); + expect(response).toStrictEqual({ + results: {}, + errors: { + api_error: [`${statusCode} ${statusText}`], + }, + }); + expect(scope.isDone()).toBe(true); + }, + ); + + it('should handle timeouts correctly', async () => { + const scope = nock(PHISHING_DETECTION_BASE_URL) + .post(`/${PHISHING_DETECTION_BULK_SCAN_ENDPOINT}`, { + urls: testUrls, + }) + .delayConnection(20000) + .reply(200, {}); + + const promise = rootMessenger.call( + 'PhishingController:bulkScanUrls', + testUrls, + ); + jest.advanceTimersByTime(15000); + const response = await promise; + expect(response).toStrictEqual({ + results: {}, + errors: { + network_error: ['timeout of 15000ms exceeded'], + }, + }); + expect(scope.isDone()).toBe(false); + }); + + it('should process URLs in batches when more than 50 URLs are provided', async () => { + const batchSize = 50; + const totalUrls = 120; + const manyUrls = Array(totalUrls) + .fill(0) + .map((_, i) => `https://example${i}.com`); + + // Expected batches + const batch1 = manyUrls.slice(0, batchSize); + const batch2 = manyUrls.slice(batchSize, 2 * batchSize); + const batch3 = manyUrls.slice(2 * batchSize); + + // Mock responses for each batch + const mockBatch1Response: BulkPhishingDetectionScanResponse = { + results: batch1.reduce>( + (acc, url) => { + acc[url] = { + hostname: url.replace('https://', ''), + recommendedAction: RecommendedAction.None, + }; + return acc; + }, + {}, + ), + errors: {}, + }; + + const mockBatch2Response: BulkPhishingDetectionScanResponse = { + results: batch2.reduce>( + (acc, url) => { + acc[url] = { + hostname: url.replace('https://', ''), + recommendedAction: RecommendedAction.None, + }; + return acc; + }, + {}, + ), + errors: {}, + }; + + const mockBatch3Response: BulkPhishingDetectionScanResponse = { + results: batch3.reduce>( + (acc, url) => { + acc[url] = { + hostname: url.replace('https://', ''), + recommendedAction: RecommendedAction.None, + }; + return acc; + }, + {}, + ), + errors: {}, + }; + + // Setup nock to handle all three batch requests + const scope1 = nock(PHISHING_DETECTION_BASE_URL) + .post(`/${PHISHING_DETECTION_BULK_SCAN_ENDPOINT}`, { + urls: batch1, + }) + .reply(200, mockBatch1Response); + + const scope2 = nock(PHISHING_DETECTION_BASE_URL) + .post(`/${PHISHING_DETECTION_BULK_SCAN_ENDPOINT}`, { + urls: batch2, + }) + .reply(200, mockBatch2Response); + + const scope3 = nock(PHISHING_DETECTION_BASE_URL) + .post(`/${PHISHING_DETECTION_BULK_SCAN_ENDPOINT}`, { + urls: batch3, + }) + .reply(200, mockBatch3Response); + + const response = await rootMessenger.call( + 'PhishingController:bulkScanUrls', + manyUrls, + ); + + // Verify all scopes were called + expect(scope1.isDone()).toBe(true); + expect(scope2.isDone()).toBe(true); + expect(scope3.isDone()).toBe(true); + + // Check all results were merged correctly + const combinedResults = { + ...mockBatch1Response.results, + ...mockBatch2Response.results, + ...mockBatch3Response.results, + }; + + expect(Object.keys(response.results)).toHaveLength(totalUrls); + expect(response.results).toStrictEqual(combinedResults); + }); + + it('should handle mixed results with both successful scans and errors', async () => { + const mixedResponse: BulkPhishingDetectionScanResponse = { + results: { + 'https://example1.com': { + hostname: 'example1.com', + recommendedAction: RecommendedAction.None, + }, + }, + errors: { + 'https://example2.com': ['Failed to process URL'], + 'https://example3.com': ['Domain not found'], + }, + }; + + const scope = nock(PHISHING_DETECTION_BASE_URL) + .post(`/${PHISHING_DETECTION_BULK_SCAN_ENDPOINT}`, { + urls: testUrls, + }) + .reply(200, mixedResponse); + + const response = await rootMessenger.call( + 'PhishingController:bulkScanUrls', + testUrls, + ); + expect(response).toStrictEqual(mixedResponse); + expect(scope.isDone()).toBe(true); + }); + + it('should have error merging issues when multiple batches return errors with the same key', async () => { + // Create enough URLs to need two batches (over 50) + const batchSize = 50; + const totalUrls = 100; + const manyUrls = Array(totalUrls) + .fill(0) + .map((_, i) => `https://example${i}.com`); + + // The URLs will be split into two batches + const batch1 = manyUrls.slice(0, batchSize); + const batch2 = manyUrls.slice(batchSize); + + // Setup nock to handle both batch requests with different error responses + const scope1 = nock(PHISHING_DETECTION_BASE_URL) + .post(`/${PHISHING_DETECTION_BULK_SCAN_ENDPOINT}`, { + urls: batch1, + }) + .reply(404, { error: 'Not Found' }); + + const scope2 = nock(PHISHING_DETECTION_BASE_URL) + .post(`/${PHISHING_DETECTION_BULK_SCAN_ENDPOINT}`, { + urls: batch2, + }) + .reply(500, { error: 'Internal Server Error' }); + + const response = await rootMessenger.call( + 'PhishingController:bulkScanUrls', + manyUrls, + ); + + expect(scope1.isDone()).toBe(true); + expect(scope2.isDone()).toBe(true); + + // With the fixed implementation, we should now preserve all errors + expect(response.errors).toHaveProperty('api_error'); + expect(response.errors.api_error).toHaveLength(2); + expect(response.errors.api_error).toContain('404 Not Found'); + expect(response.errors.api_error).toContain('500 Internal Server Error'); + }); + + it('should use cached results for previously scanned URLs and only fetch uncached URLs', async () => { + const cachedUrl = 'https://cached-example.com'; + const uncachedUrl = 'https://uncached-example.com'; + const mixedUrls = [cachedUrl, uncachedUrl]; + + // Set up the cache with a pre-existing result + const cachedResult: PhishingDetectionScanResult = { + hostname: 'cached-example.com', + recommendedAction: RecommendedAction.None, + }; + + // First cache a result via scanUrl + nock(PHISHING_DETECTION_BASE_URL) + .get( + `/${PHISHING_DETECTION_SCAN_ENDPOINT}?url=${encodeURIComponent( + 'cached-example.com', + )}`, + ) + .reply(200, { + recommendedAction: RecommendedAction.None, + }); + + await rootMessenger.call('PhishingController:scanUrl', cachedUrl); + + // Now set up the mock for the bulk API call with only the uncached URL + const expectedPostBody = { + urls: [uncachedUrl], + }; + + const bulkApiResponse: BulkPhishingDetectionScanResponse = { + results: { + [uncachedUrl]: { + hostname: 'uncached-example.com', + recommendedAction: RecommendedAction.Warn, + }, + }, + errors: {}, + }; + + const scope = nock(PHISHING_DETECTION_BASE_URL) + .post(`/${PHISHING_DETECTION_BULK_SCAN_ENDPOINT}`, expectedPostBody) + .reply(200, bulkApiResponse); + + // Call bulkScanUrls with both URLs + const response = await rootMessenger.call( + 'PhishingController:bulkScanUrls', + mixedUrls, + ); + + // Verify that only the uncached URL was requested from the API + expect(scope.isDone()).toBe(true); + + // Verify the combined results include both the cached and newly fetched results + expect(response.results).toStrictEqual({ + [cachedUrl]: cachedResult, + [uncachedUrl]: bulkApiResponse.results[uncachedUrl], + }); + + // Verify the newly fetched result is now in the cache + const newlyCachedResult = await rootMessenger.call( + 'PhishingController:scanUrl', + uncachedUrl, + ); + expect(newlyCachedResult).toStrictEqual( + bulkApiResponse.results[uncachedUrl], + ); + + // Should not make a new API call for the second scanUrl call + // eslint-disable-next-line import-x/no-named-as-default-member + expect(nock.pendingMocks()).toHaveLength(0); + }); + it('should handle invalid URLs properly when mixed with valid URLs and cache results correctly', async () => { + const validUrl = 'https://valid-example.com'; + const invalidUrl = 'not-a-url'; + const mixedUrls = [validUrl, invalidUrl]; + + const bulkApiResponse: BulkPhishingDetectionScanResponse = { + results: { + [validUrl]: { + hostname: 'valid-example.com', + recommendedAction: RecommendedAction.None, + }, + }, + errors: {}, + }; + + const scope = nock(PHISHING_DETECTION_BASE_URL) + .post(`/${PHISHING_DETECTION_BULK_SCAN_ENDPOINT}`, { + urls: [validUrl], + }) + .reply(200, bulkApiResponse); + + // Call bulkScanUrls with both URLs + const response = await rootMessenger.call( + 'PhishingController:bulkScanUrls', + mixedUrls, + ); + + // Verify that only the valid URL was requested from the API + expect(scope.isDone()).toBe(true); + + // Verify the results include the valid URL result and an error for the invalid URL + expect(response.results[validUrl]).toStrictEqual( + bulkApiResponse.results[validUrl], + ); + expect(response.errors[invalidUrl]).toContain( + 'url is not a valid web URL', + ); + + // Verify the valid result is now in the cache + const cachedResult = await rootMessenger.call( + 'PhishingController:scanUrl', + validUrl, + ); + expect(cachedResult).toStrictEqual(bulkApiResponse.results[validUrl]); + + // Should not make a new API call for the cached URL + // eslint-disable-next-line import-x/no-named-as-default-member + expect(nock.pendingMocks()).toHaveLength(0); + }); + + it('should use cache for all URLs if all are already cached', async () => { + // First cache the results individually + const cachedUrls = ['https://domain1.com', 'https://domain2.com']; + const cachedResults = [ + { + hostname: 'domain1.com', + recommendedAction: RecommendedAction.None, + }, + { + hostname: 'domain2.com', + recommendedAction: RecommendedAction.Block, + }, + ]; + + // Set up nock for individual caching + nock(PHISHING_DETECTION_BASE_URL) + .get( + `/${PHISHING_DETECTION_SCAN_ENDPOINT}?url=${encodeURIComponent( + 'domain1.com', + )}`, + ) + .reply(200, { + recommendedAction: RecommendedAction.None, + }); + + nock(PHISHING_DETECTION_BASE_URL) + .get( + `/${PHISHING_DETECTION_SCAN_ENDPOINT}?url=${encodeURIComponent( + 'domain2.com', + )}`, + ) + .reply(200, { + recommendedAction: RecommendedAction.Block, + }); + + // Cache the results + await rootMessenger.call('PhishingController:scanUrl', cachedUrls[0]); + await rootMessenger.call('PhishingController:scanUrl', cachedUrls[1]); + + // No API call should be made for bulkScanUrls + const response = await rootMessenger.call( + 'PhishingController:bulkScanUrls', + cachedUrls, + ); + + // Verify we got the results from cache + expect(response.results[cachedUrls[0]]).toStrictEqual(cachedResults[0]); + expect(response.results[cachedUrls[1]]).toStrictEqual(cachedResults[1]); + + // Verify no API calls were made + // eslint-disable-next-line import-x/no-named-as-default-member + expect(nock.pendingMocks()).toHaveLength(0); + }); + }); + + describe('scanAddress', () => { + let rootMessenger: RootMessenger; + + const testChainId = '0x1'; + const testAddress = '0x1234567890123456789012345678901234567890'; + const mockResponse: AddressScanResult = { + result_type: AddressScanResultType.Benign, + label: '', + }; + + beforeEach(() => { + const { rootMessenger: createdMessenger } = getPhishingController(); + + rootMessenger = createdMessenger; + + jest.useFakeTimers({ doNotFake: ['nextTick', 'queueMicrotask'], now: 0 }); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + it('will return the scan result for a valid address', async () => { + const scope = nock(SECURITY_ALERTS_BASE_URL) + .post(ADDRESS_SCAN_ENDPOINT, { + chain: 'ethereum', + address: testAddress.toLowerCase(), + }) + .reply(200, mockResponse); + + const response = await rootMessenger.call( + 'PhishingController:scanAddress', + testChainId, + testAddress, + ); + expect(response).toMatchObject(mockResponse); + expect(scope.isDone()).toBe(true); + }); + + it.each([ + [400, 'Bad Request'], + [401, 'Unauthorized'], + [403, 'Forbidden'], + [404, 'Not Found'], + [500, 'Internal Server Error'], + [502, 'Bad Gateway'], + [503, 'Service Unavailable'], + [504, 'Gateway Timeout'], + ])( + 'will return an AddressScanResult with an ErrorResult on %i status code', + async (statusCode) => { + const scope = nock(SECURITY_ALERTS_BASE_URL) + .post(ADDRESS_SCAN_ENDPOINT, { + chain: 'ethereum', + address: testAddress.toLowerCase(), + }) + .reply(statusCode); + + const response = await rootMessenger.call( + 'PhishingController:scanAddress', + testChainId, + testAddress, + ); + expect(response).toMatchObject({ + result_type: AddressScanResultType.ErrorResult, + label: '', + }); + expect(scope.isDone()).toBe(true); + }, + ); + + it('will return an AddressScanResult with an ErrorResult on timeout', async () => { + const scope = nock(SECURITY_ALERTS_BASE_URL) + .post(ADDRESS_SCAN_ENDPOINT, { + chain: 'ethereum', + address: testAddress.toLowerCase(), + }) + .delayConnection(10000) + .reply(200, {}); + + const promise = rootMessenger.call( + 'PhishingController:scanAddress', + testChainId, + testAddress, + ); + jest.advanceTimersByTime(5000); + const response = await promise; + expect(response).toMatchObject({ + result_type: AddressScanResultType.ErrorResult, + label: '', + }); + expect(scope.isDone()).toBe(false); + }); + + it('will return an AddressScanResult with an ErrorResult when address is missing', async () => { + const response = await rootMessenger.call( + 'PhishingController:scanAddress', + testChainId, + '', + ); + expect(response).toMatchObject({ + result_type: AddressScanResultType.ErrorResult, + label: '', + }); + }); + + it('will return an AddressScanResult with an ErrorResult when chain ID is unknown', async () => { + const unknownChainId = '0x999999'; + const response = await rootMessenger.call( + 'PhishingController:scanAddress', + unknownChainId, + testAddress, + ); + expect(response).toMatchObject({ + result_type: AddressScanResultType.ErrorResult, + label: '', + }); + }); + + it('will return an AddressScanResult with an ErrorResult for a known chain that is not supported by address scanning', async () => { + const scope = nock(SECURITY_ALERTS_BASE_URL) + .post(ADDRESS_SCAN_ENDPOINT) + .reply(200, mockResponse); + + const response = await rootMessenger.call( + 'PhishingController:scanAddress', + 'solana', + testAddress, + ); + + expect(response).toMatchObject({ + result_type: AddressScanResultType.ErrorResult, + label: '', + }); + expect(scope.isDone()).toBe(false); + cleanAll(); + }); + + it('will normalize address to lowercase', async () => { + const mixedCaseAddress = '0xAbCdEf1234567890123456789012345678901234'; + const scope = nock(SECURITY_ALERTS_BASE_URL) + .post(ADDRESS_SCAN_ENDPOINT, { + chain: 'ethereum', + address: mixedCaseAddress.toLowerCase(), + }) + .reply(200, mockResponse); + + const response = await rootMessenger.call( + 'PhishingController:scanAddress', + testChainId, + mixedCaseAddress, + ); + expect(response).toMatchObject(mockResponse); + expect(scope.isDone()).toBe(true); + }); + + it('will normalize chain ID to lowercase', async () => { + const mixedCaseChainId = '0xA'; + const scope = nock(SECURITY_ALERTS_BASE_URL) + .post(ADDRESS_SCAN_ENDPOINT, { + chain: 'optimism', + address: testAddress.toLowerCase(), + }) + .reply(200, mockResponse); + + const response = await rootMessenger.call( + 'PhishingController:scanAddress', + mixedCaseChainId, + testAddress, + ); + expect(response).toMatchObject(mockResponse); + expect(scope.isDone()).toBe(true); + }); + + it('will cache scan results and return them on subsequent calls', async () => { + const fetchSpy = jest.spyOn(global, 'fetch'); + + const scope = nock(SECURITY_ALERTS_BASE_URL) + .post(ADDRESS_SCAN_ENDPOINT, { + chain: 'ethereum', + address: testAddress.toLowerCase(), + }) + .reply(200, mockResponse); + + const result1 = await rootMessenger.call( + 'PhishingController:scanAddress', + testChainId, + testAddress, + ); + expect(result1).toMatchObject(mockResponse); + + const result2 = await rootMessenger.call( + 'PhishingController:scanAddress', + testChainId, + testAddress, + ); + expect(result2).toMatchObject(mockResponse); + + expect(fetchSpy).toHaveBeenCalledTimes(1); + expect(scope.isDone()).toBe(true); + + fetchSpy.mockRestore(); + }); + + it('will cache addresses per chain ID', async () => { + const chainId1 = '0x1'; + const chainId2 = '0x89'; + + const mockResponse1: AddressScanResult = { + result_type: AddressScanResultType.Benign, + label: 'ethereum result', + }; + + const mockResponse2: AddressScanResult = { + result_type: AddressScanResultType.Warning, + label: 'polygon result', + }; + + const scope1 = nock(SECURITY_ALERTS_BASE_URL) + .post(ADDRESS_SCAN_ENDPOINT, { + chain: 'ethereum', + address: testAddress.toLowerCase(), + }) + .reply(200, mockResponse1); + + const scope2 = nock(SECURITY_ALERTS_BASE_URL) + .post(ADDRESS_SCAN_ENDPOINT, { + chain: 'polygon', + address: testAddress.toLowerCase(), + }) + .reply(200, mockResponse2); + + const result1 = await rootMessenger.call( + 'PhishingController:scanAddress', + chainId1, + testAddress, + ); + const result2 = await rootMessenger.call( + 'PhishingController:scanAddress', + chainId2, + testAddress, + ); + + expect(result1).toMatchObject(mockResponse1); + expect(result2).toMatchObject(mockResponse2); + expect(scope1.isDone()).toBe(true); + expect(scope2.isDone()).toBe(true); + + const cachedResult1 = await rootMessenger.call( + 'PhishingController:scanAddress', + chainId1, + testAddress, + ); + const cachedResult2 = await rootMessenger.call( + 'PhishingController:scanAddress', + chainId2, + testAddress, + ); + + expect(cachedResult1).toMatchObject(mockResponse1); + expect(cachedResult2).toMatchObject(mockResponse2); + }); + }); + + describe('getApprovals', () => { + let rootMessenger: RootMessenger; + + const testChainId = '0x1'; + const testAddress = '0x1234567890123456789012345678901234567890'; + const mockApproval = { + allowance: { value: '1000000', usd_price: '1000.00' }, + asset: { + type: 'ERC20', + address: '0xtoken', + symbol: 'TKN', + name: 'Token', + decimals: 18, + logo_url: 'https://example.com/token.png', + }, + exposure: { usd_price: '100.00', value: '100.00', raw_value: '0x64' }, + spender: { + address: '0xspender', + label: 'Uniswap', + features: [ + { + type: ApprovalFeatureType.Benign, + feature_id: 'VERIFIED_CONTRACT', + description: 'This contract is verified', + }, + ], + }, + verdict: ApprovalResultType.Benign, + }; + const mockResponse = { approvals: [mockApproval] }; + + beforeEach(() => { + const { rootMessenger: createdMessenger } = getPhishingController(); + rootMessenger = createdMessenger; + jest.useFakeTimers({ doNotFake: ['nextTick', 'queueMicrotask'], now: 0 }); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + it('will return approvals for a valid address and chain', async () => { + const scope = nock(SECURITY_ALERTS_BASE_URL) + .post(APPROVALS_ENDPOINT, { + chain: 'ethereum', + address: testAddress.toLowerCase(), + }) + .reply(200, mockResponse); + + const response = await rootMessenger.call( + 'PhishingController:getApprovals', + testChainId, + testAddress, + ); + expect(response).toStrictEqual(mockResponse); + expect(scope.isDone()).toBe(true); + }); + + it('will return empty approvals when address is missing', async () => { + const response = await rootMessenger.call( + 'PhishingController:getApprovals', + testChainId, + '', + ); + expect(response).toStrictEqual({ approvals: [] }); + }); + + it('will return empty approvals when chainId is missing', async () => { + const response = await rootMessenger.call( + 'PhishingController:getApprovals', + '', + testAddress, + ); + expect(response).toStrictEqual({ approvals: [] }); + }); + + it('will return empty approvals for unknown chain ID', async () => { + const response = await rootMessenger.call( + 'PhishingController:getApprovals', + '0x999999', + testAddress, + ); + expect(response).toStrictEqual({ approvals: [] }); + }); + + it('will return empty approvals for chains not supported by the approvals API', async () => { + const response = await rootMessenger.call( + 'PhishingController:getApprovals', + '0x82750', + testAddress, + ); + expect(response).toStrictEqual({ approvals: [] }); + }); + + it.each([ + [400, 'Bad Request'], + [500, 'Internal Server Error'], + ])('will return empty approvals on %i HTTP error', async (statusCode) => { + const scope = nock(SECURITY_ALERTS_BASE_URL) + .post(APPROVALS_ENDPOINT, { + chain: 'ethereum', + address: testAddress.toLowerCase(), + }) + .reply(statusCode); + + const response = await rootMessenger.call( + 'PhishingController:getApprovals', + testChainId, + testAddress, + ); + expect(response).toStrictEqual({ approvals: [] }); + expect(scope.isDone()).toBe(true); + }); + + it('will return empty approvals on timeout', async () => { + const scope = nock(SECURITY_ALERTS_BASE_URL) + .post(APPROVALS_ENDPOINT, { + chain: 'ethereum', + address: testAddress.toLowerCase(), + }) + .delayConnection(10000) + .reply(200, mockResponse); + + const promise = rootMessenger.call( + 'PhishingController:getApprovals', + testChainId, + testAddress, + ); + jest.advanceTimersByTime(5000); + const response = await promise; + expect(response).toStrictEqual({ approvals: [] }); + expect(scope.isDone()).toBe(false); + }); + + it('will normalize address to lowercase before API call', async () => { + const mixedCaseAddress = '0xAbCdEf1234567890123456789012345678901234'; + const scope = nock(SECURITY_ALERTS_BASE_URL) + .post(APPROVALS_ENDPOINT, { + chain: 'ethereum', + address: mixedCaseAddress.toLowerCase(), + }) + .reply(200, mockResponse); + + const response = await rootMessenger.call( + 'PhishingController:getApprovals', + testChainId, + mixedCaseAddress, + ); + expect(response).toStrictEqual(mockResponse); + expect(scope.isDone()).toBe(true); + }); + + it('will normalize chainId and resolve to chain name', async () => { + const mixedCaseChainId = '0xA'; + const scope = nock(SECURITY_ALERTS_BASE_URL) + .post(APPROVALS_ENDPOINT, { + chain: 'optimism', + address: testAddress.toLowerCase(), + }) + .reply(200, mockResponse); + + const response = await rootMessenger.call( + 'PhishingController:getApprovals', + mixedCaseChainId, + testAddress, + ); + expect(response).toStrictEqual(mockResponse); + expect(scope.isDone()).toBe(true); + }); + }); +}); + +describe('URL Scan Cache', () => { + beforeEach(() => { + jest.useFakeTimers({ doNotFake: ['nextTick', 'queueMicrotask'], now: 0 }); + }); + afterEach(() => { + jest.useRealTimers(); + cleanAll(); + }); + + it('should cache scan results and return them on subsequent calls', async () => { + const testDomain = 'example.com'; + + // Spy on the fetch function to track calls + const fetchSpy = jest.spyOn(global, 'fetch'); + + nock(PHISHING_DETECTION_BASE_URL) + .get( + `/${PHISHING_DETECTION_SCAN_ENDPOINT}?url=${encodeURIComponent( + testDomain, + )}`, + ) + .reply(200, { + recommendedAction: RecommendedAction.None, + }); + + const { rootMessenger } = getPhishingController(); + + const result1 = await rootMessenger.call( + 'PhishingController:scanUrl', + `https://${testDomain}`, + ); + expect(result1).toStrictEqual({ + hostname: testDomain, + recommendedAction: RecommendedAction.None, + }); + + const result2 = await rootMessenger.call( + 'PhishingController:scanUrl', + `https://${testDomain}`, + ); + expect(result2).toStrictEqual({ + hostname: testDomain, + recommendedAction: RecommendedAction.None, + }); + + // Verify that fetch was called exactly once + expect(fetchSpy).toHaveBeenCalledTimes(1); + + fetchSpy.mockRestore(); + }); + + it('should expire cache entries after TTL', async () => { + const testDomain = 'example.com'; + const cacheTTL = 300; // 5 minutes + + nock(PHISHING_DETECTION_BASE_URL) + .get( + `/${PHISHING_DETECTION_SCAN_ENDPOINT}?url=${encodeURIComponent( + testDomain, + )}`, + ) + .reply(200, { + recommendedAction: RecommendedAction.None, + }) + .get( + `/${PHISHING_DETECTION_SCAN_ENDPOINT}?url=${encodeURIComponent( + testDomain, + )}`, + ) + .reply(200, { + recommendedAction: RecommendedAction.None, + }); + + const { rootMessenger } = getPhishingController({ + urlScanCacheTTL: cacheTTL, + }); + + await rootMessenger.call( + 'PhishingController:scanUrl', + `https://${testDomain}`, + ); + + // Before TTL expires, should use cache + jest.advanceTimersByTime((cacheTTL - 10) * 1000); + await rootMessenger.call( + 'PhishingController:scanUrl', + `https://${testDomain}`, + ); + expect(pendingMocks()).toHaveLength(1); // One mock remaining + + // After TTL expires, should fetch again + jest.advanceTimersByTime(11 * 1000); + await rootMessenger.call( + 'PhishingController:scanUrl', + `https://${testDomain}`, + ); + expect(pendingMocks()).toHaveLength(0); // All mocks used + }); + + it('should evict oldest entries when cache exceeds max size', async () => { + const maxCacheSize = 2; + const domains = ['domain1.com', 'domain2.com', 'domain3.com']; + + // Setup nock to respond to all three domains + domains.forEach((domain) => { + nock(PHISHING_DETECTION_BASE_URL) + .get( + `/${PHISHING_DETECTION_SCAN_ENDPOINT}?url=${encodeURIComponent( + domain, + )}`, + ) + .reply(200, { + recommendedAction: RecommendedAction.None, + }); + }); + + // Setup a second request for the first domain + nock(PHISHING_DETECTION_BASE_URL) + .get( + `/${PHISHING_DETECTION_SCAN_ENDPOINT}?url=${encodeURIComponent( + domains[0], + )}`, + ) + .reply(200, { + recommendedAction: RecommendedAction.Warn, + }); + + const { rootMessenger } = getPhishingController({ + urlScanCacheMaxSize: maxCacheSize, + }); + + // Fill the cache + await rootMessenger.call( + 'PhishingController:scanUrl', + `https://${domains[0]}`, + ); + jest.advanceTimersByTime(1000); // Ensure different timestamps + await rootMessenger.call( + 'PhishingController:scanUrl', + `https://${domains[1]}`, + ); + + // This should evict the oldest entry (domain1) + jest.advanceTimersByTime(1000); + await rootMessenger.call( + 'PhishingController:scanUrl', + `https://${domains[2]}`, + ); + + // Now domain1 should not be in cache and require a new fetch + await rootMessenger.call( + 'PhishingController:scanUrl', + `https://${domains[0]}`, + ); + + // All mocks should be used + expect(isDone()).toBe(true); + }); + + it('should handle fetch errors and not cache them', async () => { + const testDomain = 'example.com'; + + nock(PHISHING_DETECTION_BASE_URL) + .get( + `/${PHISHING_DETECTION_SCAN_ENDPOINT}?url=${encodeURIComponent( + testDomain, + )}`, + ) + .reply(500, { error: 'Internal Server Error' }) + .get( + `/${PHISHING_DETECTION_SCAN_ENDPOINT}?url=${encodeURIComponent( + testDomain, + )}`, + ) + .reply(200, { + recommendedAction: RecommendedAction.None, + }); + + const { rootMessenger } = getPhishingController(); + + // First call should result in an error response + const result1 = await rootMessenger.call( + 'PhishingController:scanUrl', + `https://${testDomain}`, + ); + expect(result1.fetchError).toBeDefined(); + + // Second call should try again (not use cache since errors aren't cached) + const result2 = await rootMessenger.call( + 'PhishingController:scanUrl', + `https://${testDomain}`, + ); + expect(result2.fetchError).toBeUndefined(); + expect(result2.recommendedAction).toBe(RecommendedAction.None); + + // All mocks should be used + expect(isDone()).toBe(true); + }); + + it('should handle timeout errors and not cache them', async () => { + const testDomain = 'example.com'; + + // First mock a timeout/error response + nock(PHISHING_DETECTION_BASE_URL) + .get( + `/${PHISHING_DETECTION_SCAN_ENDPOINT}?url=${encodeURIComponent( + testDomain, + )}`, + ) + .replyWithError('connection timeout') + .get( + `/${PHISHING_DETECTION_SCAN_ENDPOINT}?url=${encodeURIComponent( + testDomain, + )}`, + ) + .reply(200, { + recommendedAction: RecommendedAction.None, + }); + + const { rootMessenger } = getPhishingController(); + + // First call should result in an error + const result1 = await rootMessenger.call( + 'PhishingController:scanUrl', + `https://${testDomain}`, + ); + expect(result1.fetchError).toBeDefined(); + + // Second call should succeed (not use cache since errors aren't cached) + const result2 = await rootMessenger.call( + 'PhishingController:scanUrl', + `https://${testDomain}`, + ); + expect(result2.fetchError).toBeUndefined(); + expect(result2.recommendedAction).toBe(RecommendedAction.None); + + // All mocks should be used + expect(isDone()).toBe(true); + }); + + it('should handle invalid URLs and not cache them', async () => { + const invalidUrl = 'not-a-valid-url'; + + const { rootMessenger } = getPhishingController(); + + // First call should return an error for invalid URL + const result1 = await rootMessenger.call( + 'PhishingController:scanUrl', + invalidUrl, + ); + expect(result1.fetchError).toBeDefined(); + + // Second call should also return an error (not from cache) + const result2 = await rootMessenger.call( + 'PhishingController:scanUrl', + invalidUrl, + ); + expect(result2.fetchError).toBeDefined(); + }); + + describe('metadata', () => { + it('includes expected state in debug snapshots', () => { + const { controller } = getPhishingController(); + + expect( + deriveStateFromMetadata( + controller.state, + controller.metadata, + 'includeInDebugSnapshot', + ), + ).toMatchInlineSnapshot(`{}`); + }); + + it('includes expected state in state logs', () => { + const { controller } = getPhishingController(); + + expect( + deriveStateFromMetadata( + controller.state, + controller.metadata, + 'includeInStateLogs', + ), + ).toMatchInlineSnapshot(` + { + "c2DomainBlocklistLastFetched": 0, + "hotlistLastFetched": 0, + "stalelistLastFetched": 0, + } + `); + }); + + it('persists expected state', () => { + const { controller } = getPhishingController(); + + expect( + deriveStateFromMetadata( + controller.state, + controller.metadata, + 'persist', + ), + ).toMatchInlineSnapshot(` + { + "addressScanCache": {}, + "c2DomainBlocklistLastFetched": 0, + "hotlistLastFetched": 0, + "phishingLists": [], + "stalelistLastFetched": 0, + "tokenScanCache": {}, + "urlScanCache": {}, + "whitelist": [], + "whitelistPaths": {}, + } + `); + }); + + it('includes expected state in UI', () => { + const { controller } = getPhishingController(); + + expect( + deriveStateFromMetadata( + controller.state, + controller.metadata, + 'usedInUi', + ), + ).toMatchInlineSnapshot(` + { + "addressScanCache": {}, + "tokenScanCache": {}, + "urlScanCache": {}, + } + `); + }); + }); +}); + +describe('Transaction Controller State Change Integration', () => { + let controller: PhishingController; + let globalMessenger: RootMessenger; + let bulkScanTokensSpy: jest.SpyInstance; + + beforeEach(() => { + const { messenger, rootMessenger } = setupMessenger(); + + globalMessenger = rootMessenger; + + controller = new PhishingController({ + messenger, + }); + + bulkScanTokensSpy = jest + .spyOn(controller, 'bulkScanTokens') + .mockResolvedValue({}); + }); + + afterEach(() => { + bulkScanTokensSpy.mockRestore(); + }); + + it('triggers bulk token scanning when transaction with token balance changes is added', async () => { + const mockTransaction = createMockTransaction('test-tx-1', [ + TEST_ADDRESSES.USDC, + TEST_ADDRESSES.MOCK_TOKEN_1, + ]); + const stateChangePayload = createMockStateChangePayload([mockTransaction]); + + globalMessenger.publish( + 'TransactionController:stateChange', + stateChangePayload, + [ + { + op: 'add' as const, + path: ['transactions', 0], + value: mockTransaction, + }, + ], + ); + + await new Promise((resolve) => process.nextTick(resolve)); + + expect(bulkScanTokensSpy).toHaveBeenCalledWith({ + chainId: mockTransaction.chainId.toLowerCase(), + tokens: [ + TEST_ADDRESSES.USDC.toLowerCase(), + TEST_ADDRESSES.MOCK_TOKEN_1.toLowerCase(), + ], + }); + }); + + it('triggers bulk token scanning when patch path includes simulationData', async () => { + const mockTransaction = createMockTransaction('test-tx-1', [ + TEST_ADDRESSES.USDC, + TEST_ADDRESSES.MOCK_TOKEN_1, + ]); + const stateChangePayload = createMockStateChangePayload([mockTransaction]); + + globalMessenger.publish( + 'TransactionController:stateChange', + stateChangePayload, + [ + { + op: 'add' as const, + path: ['transactions', 0, 'simulationData'], + value: mockTransaction.simulationData, + }, + ], + ); + await new Promise((resolve) => process.nextTick(resolve)); + + expect(bulkScanTokensSpy).toHaveBeenCalledWith({ + chainId: mockTransaction.chainId.toLowerCase(), + tokens: [ + TEST_ADDRESSES.USDC.toLowerCase(), + TEST_ADDRESSES.MOCK_TOKEN_1.toLowerCase(), + ], + }); + }); + + it('skips processing when patch operation is remove', async () => { + const mockTransaction = createMockTransaction('test-tx-1', [ + TEST_ADDRESSES.USDC, + ]); + + const stateChangePayload = createMockStateChangePayload([]); + + globalMessenger.publish( + 'TransactionController:stateChange', + stateChangePayload, + [ + { + op: 'remove' as const, + path: ['transactions', 0], + value: mockTransaction, + }, + ], + ); + + await new Promise((resolve) => process.nextTick(resolve)); + + expect(bulkScanTokensSpy).not.toHaveBeenCalled(); + }); + + it('does not trigger bulk token scanning when transaction has no token balance changes', async () => { + const mockTransaction = createMockTransaction('test-tx-1', []); + + const stateChangePayload = createMockStateChangePayload([mockTransaction]); + + globalMessenger.publish( + 'TransactionController:stateChange', + stateChangePayload, + [ + { + op: 'add' as const, + path: ['transactions', 0], + value: mockTransaction, + }, + ], + ); + + await new Promise((resolve) => process.nextTick(resolve)); + + expect(bulkScanTokensSpy).not.toHaveBeenCalled(); + }); + + it('does not trigger bulk token scanning when using default tokenAddresses parameter', async () => { + const mockTransaction = createMockTransaction('test-tx-2'); + + const stateChangePayload = createMockStateChangePayload([mockTransaction]); + + globalMessenger.publish( + 'TransactionController:stateChange', + stateChangePayload, + [ + { + op: 'add' as const, + path: ['transactions', 0], + value: mockTransaction, + }, + ], + ); + + await new Promise((resolve) => process.nextTick(resolve)); + + expect(bulkScanTokensSpy).not.toHaveBeenCalled(); + }); + + it('handles errors in transaction state change processing', async () => { + const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(); + + const stateChangePayload = createMockStateChangePayload([]); + + globalMessenger.publish( + 'TransactionController:stateChange', + stateChangePayload, + [ + { + op: 'add' as const, + path: ['transactions', 0], + value: null, + }, + ], + ); + + await new Promise((resolve) => process.nextTick(resolve)); + + expect(consoleErrorSpy).toHaveBeenCalledWith( + 'Error processing transaction state change:', + expect.any(Error), + ); + + consoleErrorSpy.mockRestore(); + }); + + it('handles errors in bulk token scanning', async () => { + const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(); + + bulkScanTokensSpy.mockRejectedValue(new Error('Scanning failed')); + + const mockTransaction = createMockTransaction('test-tx-1', [ + TEST_ADDRESSES.USDC, + ]); + + const stateChangePayload = createMockStateChangePayload([mockTransaction]); + + globalMessenger.publish( + 'TransactionController:stateChange', + stateChangePayload, + [ + { + op: 'add' as const, + path: ['transactions', 0], + value: mockTransaction, + }, + ], + ); + + await new Promise((resolve) => process.nextTick(resolve)); + + expect(consoleErrorSpy).toHaveBeenCalledWith( + 'Error scanning tokens for chain 0x1:', + expect.any(Error), + ); + + consoleErrorSpy.mockRestore(); + }); + + it('continues bulk token scanning if known recipient updates fail', async () => { + const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(); + + const mockTransaction = createMockTransaction('test-tx-1', [ + TEST_ADDRESSES.USDC, + TEST_ADDRESSES.MOCK_TOKEN_1, + ]); + + globalMessenger.publish( + 'TransactionController:stateChange', + { + ...createMockStateChangePayload([mockTransaction]), + transactions: undefined, + } as unknown as TransactionControllerState, + [ + { + op: 'replace' as const, + path: ['transactions'], + value: undefined, + }, + { + op: 'add' as const, + path: ['transactions', 0], + value: mockTransaction, + }, + ], + ); + + await new Promise((resolve) => process.nextTick(resolve)); + + expect(consoleErrorSpy).toHaveBeenCalledWith( + 'Error updating known recipients from transaction state:', + expect.any(Error), + ); + expect(bulkScanTokensSpy).toHaveBeenCalledWith({ + chainId: mockTransaction.chainId.toLowerCase(), + tokens: [ + TEST_ADDRESSES.USDC.toLowerCase(), + TEST_ADDRESSES.MOCK_TOKEN_1.toLowerCase(), + ], + }); + + consoleErrorSpy.mockRestore(); + }); +}); + +describe('Address poisoning detection', () => { + const ADDRESS_BOOK_RECIPIENT = + '0x1234bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb5678' as `0x${string}`; + const CONFIRMED_TX_RECIPIENT = + '0x1234cccccccccccccccccccccccccccccccc9abc' as `0x${string}`; + const CANDIDATE_ADDRESS = + '0x1234aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa5678' as `0x${string}`; + const TX_CANDIDATE_ADDRESS = + '0x1234aaaacccccccccccccccccccccccccccc9abc' as `0x${string}`; + + it('hydrates known recipients from confirmed transactions and address book state', () => { + const confirmedTransaction = createMockTransaction('confirmed-tx', [], { + status: TransactionStatus.confirmed, + txParams: { + from: TEST_ADDRESSES.FROM_ADDRESS, + to: CONFIRMED_TX_RECIPIENT, + value: '0x0' as `0x${string}`, + }, + }); + + const { messenger } = setupMessenger({ + transactionControllerState: { + ...getDefaultTransactionControllerState(), + transactions: [confirmedTransaction], + }, + addressBookControllerState: { + addressBook: { + '0x1': { + [ADDRESS_BOOK_RECIPIENT]: { + address: ADDRESS_BOOK_RECIPIENT, + name: 'Known recipient', + chainId: '0x1', + memo: '', + isEns: false, + }, + }, + }, + }, + }); + + const controller = new PhishingController({ + messenger, + }); + + expect(controller.checkAddressPoisoning(CANDIDATE_ADDRESS)).toMatchObject([ + { + knownAddress: ADDRESS_BOOK_RECIPIENT, + prefixMatchLength: 4, + suffixMatchLength: 4, + poisoningScore: 8, + }, + ]); + + expect( + controller.checkAddressPoisoning(TX_CANDIDATE_ADDRESS), + ).toMatchObject([ + { + knownAddress: CONFIRMED_TX_RECIPIENT, + prefixMatchLength: 4, + suffixMatchLength: 32, + poisoningScore: 36, + }, + ]); + }); + + it('uses the decoded token recipient instead of the token contract for confirmed token transfers', () => { + const TOKEN_CONTRACT = + '0xdddd111111111111111111111111111111119999' as `0x${string}`; + const CONTRACT_CANDIDATE_ADDRESS = + '0xddddaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa9999' as `0x${string}`; + // transfer(address _to, uint256 _value) sending tokens to CONFIRMED_TX_RECIPIENT + const transferData = + `0xa9059cbb000000000000000000000000${CONFIRMED_TX_RECIPIENT.slice( + 2, + )}0000000000000000000000000000000000000000000000000000000000000064` as `0x${string}`; + + const tokenTransferTransaction = createMockTransaction( + 'token-transfer-tx', + [], + { + status: TransactionStatus.confirmed, + type: TransactionType.tokenMethodTransfer, + txParams: { + from: TEST_ADDRESSES.FROM_ADDRESS, + to: TOKEN_CONTRACT, + value: '0x0' as `0x${string}`, + data: transferData, + }, + }, + ); + + const { messenger } = setupMessenger({ + transactionControllerState: { + ...getDefaultTransactionControllerState(), + transactions: [tokenTransferTransaction], + }, + }); + + const controller = new PhishingController({ + messenger, + }); + + expect( + controller.checkAddressPoisoning(TX_CANDIDATE_ADDRESS), + ).toMatchObject([ + { + knownAddress: CONFIRMED_TX_RECIPIENT, + prefixMatchLength: 4, + suffixMatchLength: 32, + poisoningScore: 36, + }, + ]); + + expect( + controller.checkAddressPoisoning(CONTRACT_CANDIDATE_ADDRESS), + ).toStrictEqual([]); + }); + + it('ignores non-confirmed transactions when hydrating known recipients', () => { + const { messenger } = setupMessenger({ + transactionControllerState: { + ...getDefaultTransactionControllerState(), + transactions: [ + createMockTransaction('unapproved-tx', [], { + status: TransactionStatus.unapproved, + txParams: { + from: TEST_ADDRESSES.FROM_ADDRESS, + to: ADDRESS_BOOK_RECIPIENT, + value: '0x0' as `0x${string}`, + }, + }), + ], + }, + }); + + const controller = new PhishingController({ + messenger, + }); + + expect(controller.checkAddressPoisoning(CANDIDATE_ADDRESS)).toStrictEqual( + [], + ); + }); + + it('updates known recipients when address book state changes', async () => { + const { messenger, rootMessenger } = setupMessenger(); + + const controller = new PhishingController({ + messenger, + }); + + expect(controller.checkAddressPoisoning(CANDIDATE_ADDRESS)).toStrictEqual( + [], + ); + + rootMessenger.publish('AddressBookController:stateChange', { + addressBook: { + '0x1': { + [ADDRESS_BOOK_RECIPIENT]: { + address: ADDRESS_BOOK_RECIPIENT, + name: 'Known recipient', + chainId: '0x1', + memo: '', + isEns: false, + }, + }, + }, + }); + + await new Promise((resolve) => process.nextTick(resolve)); + + expect(controller.checkAddressPoisoning(CANDIDATE_ADDRESS)).toMatchObject([ + { + knownAddress: ADDRESS_BOOK_RECIPIENT, + prefixMatchLength: 4, + suffixMatchLength: 4, + poisoningScore: 8, + }, + ]); + }); + + it('updates known recipients when confirmed transactions change', async () => { + const { messenger, rootMessenger } = setupMessenger(); + + const controller = new PhishingController({ + messenger, + }); + + const confirmedTransaction = createMockTransaction('confirmed-tx', [], { + status: TransactionStatus.confirmed, + txParams: { + from: TEST_ADDRESSES.FROM_ADDRESS, + to: ADDRESS_BOOK_RECIPIENT, + value: '0x0' as `0x${string}`, + }, + }); + + rootMessenger.publish( + 'TransactionController:stateChange', + createMockStateChangePayload([confirmedTransaction]), + [ + { + op: 'add' as const, + path: ['transactions', 0], + value: confirmedTransaction, + }, + ], + ); + + await new Promise((resolve) => process.nextTick(resolve)); + + expect(controller.checkAddressPoisoning(CANDIDATE_ADDRESS)).toMatchObject([ + { + knownAddress: ADDRESS_BOOK_RECIPIENT, + prefixMatchLength: 4, + suffixMatchLength: 4, + poisoningScore: 8, + }, + ]); + }); + + it('updates transaction recipients when a confirmed transaction recipient changes', async () => { + const originalTransaction = createMockTransaction('confirmed-tx', [], { + status: TransactionStatus.confirmed, + txParams: { + from: TEST_ADDRESSES.FROM_ADDRESS, + to: ADDRESS_BOOK_RECIPIENT, + value: '0x0' as `0x${string}`, + }, + }); + const updatedTransaction = createMockTransaction('confirmed-tx', [], { + status: TransactionStatus.confirmed, + txParams: { + from: TEST_ADDRESSES.FROM_ADDRESS, + to: CONFIRMED_TX_RECIPIENT, + value: '0x0' as `0x${string}`, + }, + }); + const { messenger, rootMessenger } = setupMessenger({ + transactionControllerState: { + ...getDefaultTransactionControllerState(), + transactions: [originalTransaction], + }, + }); + + const controller = new PhishingController({ + messenger, + }); + + expect(controller.checkAddressPoisoning(CANDIDATE_ADDRESS)).toHaveLength(1); + + rootMessenger.publish( + 'TransactionController:stateChange', + createMockStateChangePayload([updatedTransaction]), + [ + { + op: 'replace' as const, + path: ['transactions', 0], + value: updatedTransaction, + }, + ], + ); + + await new Promise((resolve) => process.nextTick(resolve)); + + expect(controller.checkAddressPoisoning(CANDIDATE_ADDRESS)).toStrictEqual( + [], + ); + expect( + controller.checkAddressPoisoning(TX_CANDIDATE_ADDRESS), + ).toMatchObject([ + { + knownAddress: CONFIRMED_TX_RECIPIENT, + prefixMatchLength: 4, + suffixMatchLength: 32, + poisoningScore: 36, + }, + ]); + }); + + it('keeps duplicate transaction recipients when one matching transaction recipient changes', async () => { + const firstTransaction = createMockTransaction('confirmed-tx-1', [], { + status: TransactionStatus.confirmed, + txParams: { + from: TEST_ADDRESSES.FROM_ADDRESS, + to: ADDRESS_BOOK_RECIPIENT, + value: '0x0' as `0x${string}`, + }, + }); + const secondTransaction = createMockTransaction('confirmed-tx-2', [], { + status: TransactionStatus.confirmed, + txParams: { + from: TEST_ADDRESSES.FROM_ADDRESS, + to: ADDRESS_BOOK_RECIPIENT, + value: '0x0' as `0x${string}`, + }, + }); + const updatedFirstTransaction = createMockTransaction( + 'confirmed-tx-1', + [], + { + status: TransactionStatus.confirmed, + txParams: { + from: TEST_ADDRESSES.FROM_ADDRESS, + to: CONFIRMED_TX_RECIPIENT, + value: '0x0' as `0x${string}`, + }, + }, + ); + const { messenger, rootMessenger } = setupMessenger({ + transactionControllerState: { + ...getDefaultTransactionControllerState(), + transactions: [firstTransaction, secondTransaction], + }, + }); + + const controller = new PhishingController({ + messenger, + }); + + expect(controller.checkAddressPoisoning(CANDIDATE_ADDRESS)).toHaveLength(1); + + rootMessenger.publish( + 'TransactionController:stateChange', + createMockStateChangePayload([ + updatedFirstTransaction, + secondTransaction, + ]), + [ + { + op: 'replace' as const, + path: ['transactions', 0], + value: updatedFirstTransaction, + }, + ], + ); + + await new Promise((resolve) => process.nextTick(resolve)); + + expect(controller.checkAddressPoisoning(CANDIDATE_ADDRESS)).toMatchObject([ + { + knownAddress: ADDRESS_BOOK_RECIPIENT, + prefixMatchLength: 4, + suffixMatchLength: 4, + poisoningScore: 8, + }, + ]); + expect( + controller.checkAddressPoisoning(TX_CANDIDATE_ADDRESS), + ).toMatchObject([ + { + knownAddress: CONFIRMED_TX_RECIPIENT, + prefixMatchLength: 4, + suffixMatchLength: 32, + poisoningScore: 36, + }, + ]); + }); + + it('ignores transaction state changes that do not include transaction patches', async () => { + const { messenger, rootMessenger } = setupMessenger(); + + const controller = new PhishingController({ + messenger, + }); + + rootMessenger.publish( + 'TransactionController:stateChange', + createMockStateChangePayload([]), + [ + { + op: 'replace' as const, + path: ['methodData'], + value: {}, + }, + ], + ); + + await new Promise((resolve) => process.nextTick(resolve)); + + expect(controller.checkAddressPoisoning(CANDIDATE_ADDRESS)).toStrictEqual( + [], + ); + }); + + it('rebuilds known recipients when the transaction collection changes', async () => { + const { messenger, rootMessenger } = setupMessenger(); + + const controller = new PhishingController({ + messenger, + }); + + const confirmedTransaction = createMockTransaction('confirmed-tx', [], { + status: TransactionStatus.confirmed, + txParams: { + from: TEST_ADDRESSES.FROM_ADDRESS, + to: ADDRESS_BOOK_RECIPIENT, + value: '0x0' as `0x${string}`, + }, + }); + + rootMessenger.publish( + 'TransactionController:stateChange', + createMockStateChangePayload([confirmedTransaction]), + [ + { + op: 'replace' as const, + path: ['transactions'], + value: [confirmedTransaction], + }, + ], + ); + + await new Promise((resolve) => process.nextTick(resolve)); + + expect(controller.checkAddressPoisoning(CANDIDATE_ADDRESS)).toHaveLength(1); + }); + + it('rebuilds known recipients when a transaction patch is not indexed by array position', async () => { + const { messenger, rootMessenger } = setupMessenger(); + + const controller = new PhishingController({ + messenger, + }); + + const confirmedTransaction = createMockTransaction('confirmed-tx', [], { + status: TransactionStatus.confirmed, + txParams: { + from: TEST_ADDRESSES.FROM_ADDRESS, + to: ADDRESS_BOOK_RECIPIENT, + value: '0x0' as `0x${string}`, + }, + }); + + rootMessenger.publish( + 'TransactionController:stateChange', + createMockStateChangePayload([confirmedTransaction]), + [ + { + op: 'replace' as const, + path: ['transactions', 'confirmed-tx'], + value: confirmedTransaction, + }, + ], + ); + + await new Promise((resolve) => process.nextTick(resolve)); + + expect(controller.checkAddressPoisoning(CANDIDATE_ADDRESS)).toHaveLength(1); + }); + + it('rebuilds known recipients when a remove patch does not include the removed transaction', async () => { + const confirmedTransaction = createMockTransaction('confirmed-tx', [], { + status: TransactionStatus.confirmed, + txParams: { + from: TEST_ADDRESSES.FROM_ADDRESS, + to: ADDRESS_BOOK_RECIPIENT, + value: '0x0' as `0x${string}`, + }, + }); + const { messenger, rootMessenger } = setupMessenger({ + transactionControllerState: { + ...getDefaultTransactionControllerState(), + transactions: [confirmedTransaction], + }, + }); + + const controller = new PhishingController({ + messenger, + }); + + expect(controller.checkAddressPoisoning(CANDIDATE_ADDRESS)).toHaveLength(1); + + rootMessenger.publish( + 'TransactionController:stateChange', + createMockStateChangePayload([]), + [ + { + op: 'remove' as const, + path: ['transactions', 0], + }, + ], + ); + + await new Promise((resolve) => process.nextTick(resolve)); + + expect(controller.checkAddressPoisoning(CANDIDATE_ADDRESS)).toStrictEqual( + [], + ); + }); + + it('rebuilds known recipients when the transaction array length changes', async () => { + const confirmedTransaction = createMockTransaction('confirmed-tx', [], { + status: TransactionStatus.confirmed, + txParams: { + from: TEST_ADDRESSES.FROM_ADDRESS, + to: ADDRESS_BOOK_RECIPIENT, + value: '0x0' as `0x${string}`, + }, + }); + const { messenger, rootMessenger } = setupMessenger({ + transactionControllerState: { + ...getDefaultTransactionControllerState(), + transactions: [confirmedTransaction], + }, + }); + + const controller = new PhishingController({ + messenger, + }); + + expect(controller.checkAddressPoisoning(CANDIDATE_ADDRESS)).toHaveLength(1); + + rootMessenger.publish( + 'TransactionController:stateChange', + createMockStateChangePayload([]), + [ + { + op: 'replace' as const, + path: ['transactions', 'length'], + value: 0, + }, + ], + ); + + await new Promise((resolve) => process.nextTick(resolve)); + + expect(controller.checkAddressPoisoning(CANDIDATE_ADDRESS)).toStrictEqual( + [], + ); + }); + + it('rebuilds duplicate transaction recipients when transactions are removed', async () => { + const firstTransaction = createMockTransaction('confirmed-tx-1', [], { + status: TransactionStatus.confirmed, + txParams: { + from: TEST_ADDRESSES.FROM_ADDRESS, + to: ADDRESS_BOOK_RECIPIENT, + value: '0x0' as `0x${string}`, + }, + }); + const secondTransaction = createMockTransaction('confirmed-tx-2', [], { + status: TransactionStatus.confirmed, + txParams: { + from: TEST_ADDRESSES.FROM_ADDRESS, + to: ADDRESS_BOOK_RECIPIENT, + value: '0x0' as `0x${string}`, + }, + }); + const { messenger, rootMessenger } = setupMessenger({ + transactionControllerState: { + ...getDefaultTransactionControllerState(), + transactions: [firstTransaction, secondTransaction], + }, + }); + + const controller = new PhishingController({ + messenger, + }); + + expect(controller.checkAddressPoisoning(CANDIDATE_ADDRESS)).toHaveLength(1); + + rootMessenger.publish( + 'TransactionController:stateChange', + createMockStateChangePayload([secondTransaction]), + [ + { + op: 'remove' as const, + path: ['transactions', 0], + }, + ], + ); + + await new Promise((resolve) => process.nextTick(resolve)); + + expect(controller.checkAddressPoisoning(CANDIDATE_ADDRESS)).toMatchObject([ + { + knownAddress: ADDRESS_BOOK_RECIPIENT, + prefixMatchLength: 4, + suffixMatchLength: 4, + poisoningScore: 8, + }, + ]); + }); + + it('logs when transaction state hydration fails', () => { + const { messenger, rootMessenger } = setupMessenger(); + const error = new Error('Transaction state unavailable'); + const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(); + + rootMessenger.unregisterActionHandler('TransactionController:getState'); + rootMessenger.registerActionHandler( + 'TransactionController:getState', + () => { + throw error; + }, + ); + + // eslint-disable-next-line no-new -- controller hydrates known recipients on construction + new PhishingController({ + messenger, + }); + + expect(consoleErrorSpy).toHaveBeenCalledWith( + 'Unable to hydrate known recipients from TransactionController state; address poisoning checks will not include existing confirmed transactions.', + error, + ); + + consoleErrorSpy.mockRestore(); + }); + + it('logs when address book state hydration fails', () => { + const { messenger, rootMessenger } = setupMessenger(); + const error = new Error('Address book state unavailable'); + const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(); + + rootMessenger.unregisterActionHandler('AddressBookController:getState'); + rootMessenger.registerActionHandler( + 'AddressBookController:getState', + () => { + throw error; + }, + ); + + // eslint-disable-next-line no-new -- controller hydrates known recipients on construction + new PhishingController({ + messenger, + }); + + expect(consoleErrorSpy).toHaveBeenCalledWith( + 'Unable to hydrate known recipients from AddressBookController state; address poisoning checks will not include existing address book entries.', + error, + ); + + consoleErrorSpy.mockRestore(); + }); + + it('exposes checkAddressPoisoning through the controller messenger', async () => { + const { messenger, rootMessenger } = setupMessenger({ + addressBookControllerState: { + addressBook: { + '0x1': { + [ADDRESS_BOOK_RECIPIENT]: { + address: ADDRESS_BOOK_RECIPIENT, + name: 'Known recipient', + chainId: '0x1', + memo: '', + isEns: false, + }, + }, + }, + }, + }); + + // eslint-disable-next-line no-new -- controller registers messenger handlers as a side effect + new PhishingController({ + messenger, + }); + + expect( + rootMessenger.call( + 'PhishingController:checkAddressPoisoning', + CANDIDATE_ADDRESS, + ), + ).toMatchObject([ + { + knownAddress: ADDRESS_BOOK_RECIPIENT, + prefixMatchLength: 4, + suffixMatchLength: 4, + poisoningScore: 8, + }, + ]); }); }); diff --git a/packages/phishing-controller/src/PhishingController.ts b/packages/phishing-controller/src/PhishingController.ts index 2c95e86587d..b4b6365cec1 100644 --- a/packages/phishing-controller/src/PhishingController.ts +++ b/packages/phishing-controller/src/PhishingController.ts @@ -1,36 +1,135 @@ -import type { RestrictedControllerMessenger } from '@metamask/base-controller'; -import { BaseControllerV2 as BaseController } from '@metamask/base-controller'; -import { safelyExecute } from '@metamask/controller-utils'; -import PhishingDetector from 'eth-phishing-detect/src/detector'; -import { toASCII } from 'punycode/'; +import type { + AddressBookControllerGetStateAction, + AddressBookControllerState, + AddressBookControllerStateChangeEvent, +} from '@metamask/address-book-controller'; +import { BaseController } from '@metamask/base-controller'; +import type { + StateMetadata, + ControllerGetStateAction, + ControllerStateChangeEvent, +} from '@metamask/base-controller'; +import { + isValidHexAddress, + safelyExecute, + safelyExecuteWithTimeout, +} from '@metamask/controller-utils'; +import type { Messenger } from '@metamask/messenger'; +import type { + TransactionControllerGetStateAction, + TransactionControllerState, + TransactionControllerStateChangeEvent, + TransactionMeta, +} from '@metamask/transaction-controller'; +import { + getEffectiveRecipient, + TransactionStatus, +} from '@metamask/transaction-controller'; +import type { Patch } from 'immer'; +import { toASCII } from 'punycode/punycode.js'; -import { applyDiffs, fetchTimeNow } from './utils'; +import { findSimilarAddresses } from './address-poisoning.js'; +import { CacheManager } from './CacheManager.js'; +import type { CacheEntry } from './CacheManager.js'; +import { + convertListToTrie, + insertToTrie, + matchedPathPrefix, +} from './PathTrie.js'; +import type { PathTrie } from './PathTrie.js'; +import type { + PhishingControllerMaybeUpdateStateAction, + PhishingControllerMethodActions, + PhishingControllerTestOriginAction, +} from './PhishingController-method-action-types.js'; +import { PhishingDetector } from './PhishingDetector.js'; +import { + PhishingDetectorResultType, + RecommendedAction, + AddressScanResultType, +} from './types.js'; +import type { + PhishingDetectorResult, + PhishingDetectionScanResult, + TokenScanCacheData, + BulkTokenScanResponse, + BulkTokenScanRequest, + TokenScanApiResponse, + AddressScanCacheData, + AddressScanResult, + SimilarAddressMatch, + ApprovalsResponse, +} from './types.js'; +import { + applyDiffs, + fetchTimeNow, + getHostnameFromUrl, + roundToNearestMinute, + getHostnameFromWebUrl, + getPhishingDetectionScanUrlParam, + buildCacheKey, + splitCacheHits, + resolveChainName, + getPathnameFromUrl, + getAddressScanSupportedChain, + isApprovalSupportedChain, + isTokenScanSupportedChain, +} from './utils.js'; export const PHISHING_CONFIG_BASE_URL = - 'https://phishing-detection.metafi.codefi.network'; - + 'https://phishing-detection.api.cx.metamask.io'; export const METAMASK_STALELIST_FILE = '/v1/stalelist'; +export const METAMASK_HOTLIST_DIFF_FILE = '/v2/diffsSince'; + +export const CLIENT_SIDE_DETECION_BASE_URL = + 'https://client-side-detection.api.cx.metamask.io'; +export const C2_DOMAIN_BLOCKLIST_ENDPOINT = '/v1/request-blocklist'; + +export const PHISHING_DETECTION_BASE_URL = + 'https://dapp-scanning.api.cx.metamask.io'; +export const PHISHING_DETECTION_SCAN_ENDPOINT = 'v2/scan'; +export const PHISHING_DETECTION_BULK_SCAN_ENDPOINT = 'bulk-scan'; + +export const SECURITY_ALERTS_BASE_URL = + 'https://security-alerts.api.cx.metamask.io'; +export const TOKEN_BULK_SCANNING_ENDPOINT = '/token/scan-bulk'; +export const ADDRESS_SCAN_ENDPOINT = '/address/evm/scan'; +export const APPROVALS_ENDPOINT = '/address/evm/approvals'; -export const METAMASK_HOTLIST_DIFF_FILE = '/v1/diffsSince'; +// Cache configuration defaults +export const DEFAULT_URL_SCAN_CACHE_TTL = 1 * 60; // 1 minute in seconds +export const DEFAULT_URL_SCAN_CACHE_MAX_SIZE = 250; +export const DEFAULT_TOKEN_SCAN_CACHE_TTL = 1 * 60; // 1 minute in seconds +export const DEFAULT_TOKEN_SCAN_CACHE_MAX_SIZE = 1000; +export const DEFAULT_ADDRESS_SCAN_CACHE_TTL = 1 * 60; // 1 minute in seconds +export const DEFAULT_ADDRESS_SCAN_CACHE_MAX_SIZE = 1000; -export const HOTLIST_REFRESH_INTERVAL = 30 * 60; // 30 mins in seconds -export const STALELIST_REFRESH_INTERVAL = 4 * 24 * 60 * 60; // 4 days in seconds +export const C2_DOMAIN_BLOCKLIST_REFRESH_INTERVAL = 5 * 60; // 5 mins in seconds +export const HOTLIST_REFRESH_INTERVAL = 5 * 60; // 5 mins in seconds +export const STALELIST_REFRESH_INTERVAL = 30 * 24 * 60 * 60; // 30 days in seconds export const METAMASK_STALELIST_URL = `${PHISHING_CONFIG_BASE_URL}${METAMASK_STALELIST_FILE}`; export const METAMASK_HOTLIST_DIFF_URL = `${PHISHING_CONFIG_BASE_URL}${METAMASK_HOTLIST_DIFF_FILE}`; +export const C2_DOMAIN_BLOCKLIST_URL = `${CLIENT_SIDE_DETECION_BASE_URL}${C2_DOMAIN_BLOCKLIST_ENDPOINT}`; /** * @type ListTypes * * Type outlining the types of lists provided by aggregating different source lists */ -export type ListTypes = 'fuzzylist' | 'blocklist' | 'allowlist'; +export type ListTypes = + | 'fuzzylist' + | 'blocklist' + | 'blocklistPaths' + | 'allowlist' + | 'c2DomainBlocklist'; /** * @type EthPhishingResponse * * Configuration response from the eth-phishing-detect package * consisting of approved and unapproved website origins + * * @property blacklist - List of unapproved origins * @property fuzzylist - List of fuzzy-matched unapproved origins * @property tolerance - Fuzzy match tolerance level @@ -46,18 +145,36 @@ export type EthPhishingResponse = { }; /** - * @type PhishingStalelist + * @type C2DomainBlocklistResponse * - * type defining expected type of the stalelist.json file. - * @property eth_phishing_detect_config - Stale list sourced from eth-phishing-detect's config.json. - * @property phishfort_hotlist - Stale list sourced from phishfort's hotlist.json. Only includes blocklist. Deduplicated entries from eth_phishing_detect_config. - * @property tolerance - Fuzzy match tolerance level - * @property lastUpdated - Timestamp of last update. - * @property version - Stalelist data structure iteration. + * Response for blocklist update requests + * + * @property recentlyAdded - List of c2 domains recently added to the blocklist + * @property recentlyRemoved - List of c2 domains recently removed from the blocklist + * @property lastFetchedAt - Timestamp of the last fetch request + */ +export type C2DomainBlocklistResponse = { + recentlyAdded: string[]; + recentlyRemoved: string[]; + lastFetchedAt: string; +}; + +/** + * PhishingStalelist defines the expected type of the stalelist from the API. + * + * allowlist - List of approved origins. + * blocklist - List of unapproved origins (hostname-only entries). + * blocklistPaths - Trie of unapproved origins with paths (hostname + path entries). + * fuzzylist - List of fuzzy-matched unapproved origins. + * tolerance - Fuzzy match tolerance level + * lastUpdated - Timestamp of last update. + * version - Stalelist data structure iteration. */ export type PhishingStalelist = { - eth_phishing_detect_config: Record; - phishfort_hotlist: Record; + allowlist: string[]; + blocklist: string[]; + blocklistPaths: string[]; + fuzzylist: string[]; tolerance: number; version: number; lastUpdated: number; @@ -67,8 +184,11 @@ export type PhishingStalelist = { * @type PhishingListState * * type defining the persisted list state. This is the persisted state that is updated frequently with `this.maybeUpdateState()`. + * * @property allowlist - List of approved origins (legacy naming "whitelist") * @property blocklist - List of unapproved origins (legacy naming "blacklist") + * @property blocklistPaths - Trie of unapproved origins with paths (hostname + path, no query params). + * @property c2DomainBlocklist - List of hashed hostnames that C2 requests are blocked against. * @property fuzzylist - List of fuzzy-matched unapproved origins * @property tolerance - Fuzzy match tolerance level * @property lastUpdated - Timestamp of last update. @@ -78,6 +198,8 @@ export type PhishingStalelist = { export type PhishingListState = { allowlist: string[]; blocklist: string[]; + blocklistPaths: PathTrie; + c2DomainBlocklist: string[]; fuzzylist: string[]; tolerance: number; version: number; @@ -85,28 +207,11 @@ export type PhishingListState = { name: ListNames; }; -/** - * @type EthPhishingDetectResult - * - * type that describes the result of the `test` method. - * @property name - Name of the config on which a match was found. - * @property version - Version of the config on which a match was found. - * @property result - Whether a domain was detected as a phishing domain. True means an unsafe domain. - * @property match - The matching fuzzylist origin when a fuzzylist match is found. Returned as undefined for non-fuzzy true results. - * @property type - The field of the config on which a match was found. - */ -export type EthPhishingDetectResult = { - name?: string; - version?: string; - result: boolean; - match?: string; // Returned as undefined for non-fuzzy true results. - type: 'all' | 'fuzzy' | 'blocklist' | 'allowlist'; -}; - /** * @type HotlistDiff * * type defining the expected type of the diffs in hotlist.json file. + * * @property url - Url of the diff entry. * @property timestamp - Timestamp at which the diff was identified. * @property targetList - The list name where the diff was identified. @@ -127,6 +232,7 @@ export type DataResultWrapper = { * @type Hotlist * * Type defining expected hotlist.json file. + * * @property url - Url of the diff entry. * @property timestamp - Timestamp at which the diff was identified. * @property targetList - The list name where the diff was identified. @@ -139,7 +245,6 @@ export type Hotlist = HotlistDiff[]; * These are the keys denoting lists consumed by the upstream data provider. */ export enum ListKeys { - PhishfortHotlist = 'phishfort_hotlist', EthPhishingDetectConfig = 'eth_phishing_detect_config', } @@ -148,7 +253,6 @@ export enum ListKeys { */ export enum ListNames { MetaMask = 'MetaMask', - Phishfort = 'Phishfort', } /** @@ -156,7 +260,6 @@ export enum ListNames { * to list key sourced from upstream data provider. */ const phishingListNameKeyMap = { - [ListNames.Phishfort]: ListKeys.PhishfortHotlist, [ListNames.MetaMask]: ListKeys.EthPhishingDetectConfig, }; @@ -166,28 +269,83 @@ const phishingListNameKeyMap = { */ export const phishingListKeyNameMap = { [ListKeys.EthPhishingDetectConfig]: ListNames.MetaMask, - [ListKeys.PhishfortHotlist]: ListNames.Phishfort, }; const controllerName = 'PhishingController'; -const metadata = { - phishingLists: { persist: true, anonymous: false }, - whitelist: { persist: true, anonymous: false }, - hotlistLastFetched: { persist: true, anonymous: false }, - stalelistLastFetched: { persist: true, anonymous: false }, +const metadata: StateMetadata = { + phishingLists: { + includeInStateLogs: false, + persist: true, + includeInDebugSnapshot: false, + usedInUi: false, + }, + whitelist: { + includeInStateLogs: false, + persist: true, + includeInDebugSnapshot: false, + usedInUi: false, + }, + whitelistPaths: { + includeInStateLogs: false, + persist: true, + includeInDebugSnapshot: false, + usedInUi: false, + }, + hotlistLastFetched: { + includeInStateLogs: true, + persist: true, + includeInDebugSnapshot: false, + usedInUi: false, + }, + stalelistLastFetched: { + includeInStateLogs: true, + persist: true, + includeInDebugSnapshot: false, + usedInUi: false, + }, + c2DomainBlocklistLastFetched: { + includeInStateLogs: true, + persist: true, + includeInDebugSnapshot: false, + usedInUi: false, + }, + urlScanCache: { + includeInStateLogs: false, + persist: true, + includeInDebugSnapshot: false, + usedInUi: true, + }, + tokenScanCache: { + includeInStateLogs: false, + persist: true, + includeInDebugSnapshot: false, + usedInUi: true, + }, + addressScanCache: { + includeInStateLogs: false, + persist: true, + includeInDebugSnapshot: false, + usedInUi: true, + }, }; /** * Get a default empty state for the controller. + * * @returns The default empty state. */ const getDefaultState = (): PhishingControllerState => { return { phishingLists: [], whitelist: [], + whitelistPaths: {}, hotlistLastFetched: 0, stalelistLastFetched: 0, + c2DomainBlocklistLastFetched: 0, + urlScanCache: {}, + tokenScanCache: {}, + addressScanCache: {}, }; }; @@ -195,50 +353,128 @@ const getDefaultState = (): PhishingControllerState => { * @type PhishingControllerState * * Phishing controller state - * @property phishing - eth-phishing-detect configuration - * @property whitelist - array of temporarily-approved origins + * phishingLists - array of phishing lists + * whitelist - origins that bypass the phishing detector + * whitelistPaths - origins with paths that bypass the phishing detector + * hotlistLastFetched - timestamp of the last hotlist fetch + * stalelistLastFetched - timestamp of the last stalelist fetch + * c2DomainBlocklistLastFetched - timestamp of the last c2 domain blocklist fetch + * urlScanCache - cache of URL scan results + * tokenScanCache - cache of token scan results + * addressScanCache - cache of address scan results */ export type PhishingControllerState = { phishingLists: PhishingListState[]; whitelist: string[]; + whitelistPaths: PathTrie; hotlistLastFetched: number; stalelistLastFetched: number; + c2DomainBlocklistLastFetched: number; + urlScanCache: Record>; + tokenScanCache: Record>; + addressScanCache: Record>; }; /** - * @type PhishingControllerOptions + * PhishingControllerOptions * * Phishing controller options - * @property stalelistRefreshInterval - Polling interval used to fetch stale list. - * @property hotlistRefreshInterval - Polling interval used to fetch hotlist diff list. + * stalelistRefreshInterval - Polling interval used to fetch stale list. + * hotlistRefreshInterval - Polling interval used to fetch hotlist diff list. + * c2DomainBlocklistRefreshInterval - Polling interval used to fetch c2 domain blocklist. + * urlScanCacheTTL - Time to live in seconds for cached scan results. + * urlScanCacheMaxSize - Maximum number of entries in the scan cache. + * tokenScanCacheTTL - Time to live in seconds for cached token scan results. + * tokenScanCacheMaxSize - Maximum number of entries in the token scan cache. + * addressScanCacheTTL - Time to live in seconds for cached address scan results. + * addressScanCacheMaxSize - Maximum number of entries in the address scan cache. */ export type PhishingControllerOptions = { stalelistRefreshInterval?: number; hotlistRefreshInterval?: number; + c2DomainBlocklistRefreshInterval?: number; + urlScanCacheTTL?: number; + urlScanCacheMaxSize?: number; + tokenScanCacheTTL?: number; + tokenScanCacheMaxSize?: number; + addressScanCacheTTL?: number; + addressScanCacheMaxSize?: number; messenger: PhishingControllerMessenger; state?: Partial; }; -export type MaybeUpdateState = { - type: `${typeof controllerName}:maybeUpdateState`; - handler: PhishingController['maybeUpdateState']; -}; +const MESSENGER_EXPOSED_METHODS = [ + 'maybeUpdateState', + 'testOrigin', + 'isBlockedRequest', + 'bypass', + 'scanUrl', + 'bulkScanUrls', + 'bulkScanTokens', + 'scanAddress', + 'getApprovals', + 'checkAddressPoisoning', +] as const; -export type TestOrigin = { - type: `${typeof controllerName}:testOrigin`; - handler: PhishingController['test']; -}; +/** + * @deprecated Use `PhishingControllerTestOriginAction` instead. + */ +export type TestOrigin = PhishingControllerTestOriginAction; + +/** + * @deprecated Use `PhishingControllerMaybeUpdateStateAction` instead. + */ +export type MaybeUpdateState = PhishingControllerMaybeUpdateStateAction; + +export type PhishingControllerGetStateAction = ControllerGetStateAction< + typeof controllerName, + PhishingControllerState +>; + +export type PhishingControllerActions = + | PhishingControllerGetStateAction + | PhishingControllerMethodActions; + +export type PhishingControllerStateChangeEvent = ControllerStateChangeEvent< + typeof controllerName, + PhishingControllerState +>; + +export type PhishingControllerEvents = PhishingControllerStateChangeEvent; + +/** + * The external actions available to the PhishingController. + */ +type AllowedActions = + | AddressBookControllerGetStateAction + | TransactionControllerGetStateAction; -export type PhishingControllerActions = MaybeUpdateState | TestOrigin; +/** + * The external events available to the PhishingController. + */ +export type AllowedEvents = + | AddressBookControllerStateChangeEvent + | TransactionControllerStateChangeEvent; -export type PhishingControllerMessenger = RestrictedControllerMessenger< +export type PhishingControllerMessenger = Messenger< typeof controllerName, - PhishingControllerActions, - never, - never, - never + PhishingControllerActions | AllowedActions, + PhishingControllerEvents | AllowedEvents >; +/** + * BulkPhishingDetectionScanResponse + * + * Response for bulk phishing detection scan requests + * results - Record of domain names and their corresponding phishing detection scan results + * + * errors - Record of domain names and their corresponding errors + */ +export type BulkPhishingDetectionScanResponse = { + results: Record; + errors: Record; +}; + /** * Controller that manages community-maintained lists of approved and unapproved website origins. */ @@ -247,28 +483,73 @@ export class PhishingController extends BaseController< PhishingControllerState, PhishingControllerMessenger > { + // TODO: Replace `any` with type + // eslint-disable-next-line @typescript-eslint/no-explicit-any #detector: any; - #stalelistRefreshInterval: number; + readonly #stalelistRefreshInterval: number; + + readonly #hotlistRefreshInterval: number; + + readonly #c2DomainBlocklistRefreshInterval: number; + + readonly #urlScanCache: CacheManager; + + readonly #tokenScanCache: CacheManager; + + readonly #addressScanCache: CacheManager; - #hotlistRefreshInterval: number; + readonly #knownRecipients: Set; + + readonly #transactionRecipients: Set; + + readonly #transactionRecipientsByTransactionId: Map>; + + readonly #transactionRecipientCounts: Map; + + readonly #addressBookRecipients: Set; #inProgressHotlistUpdate?: Promise; #inProgressStalelistUpdate?: Promise; + #isProgressC2DomainBlocklistUpdate?: Promise; + + readonly #transactionControllerStateChangeHandler: ( + state: TransactionControllerState, + patches: Patch[], + ) => void; + + readonly #addressBookControllerStateChangeHandler: ( + state: AddressBookControllerState, + ) => void; + /** * Construct a Phishing Controller. * * @param config - Initial options used to configure this controller. * @param config.stalelistRefreshInterval - Polling interval used to fetch stale list. * @param config.hotlistRefreshInterval - Polling interval used to fetch hotlist diff list. + * @param config.c2DomainBlocklistRefreshInterval - Polling interval used to fetch c2 domain blocklist. + * @param config.urlScanCacheTTL - Time to live in seconds for cached scan results. + * @param config.urlScanCacheMaxSize - Maximum number of entries in the scan cache. + * @param config.tokenScanCacheTTL - Time to live in seconds for cached token scan results. + * @param config.tokenScanCacheMaxSize - Maximum number of entries in the token scan cache. + * @param config.addressScanCacheTTL - Time to live in seconds for cached address scan results. + * @param config.addressScanCacheMaxSize - Maximum number of entries in the address scan cache. * @param config.messenger - The controller restricted messenger. * @param config.state - Initial state to set on this controller. */ constructor({ stalelistRefreshInterval = STALELIST_REFRESH_INTERVAL, hotlistRefreshInterval = HOTLIST_REFRESH_INTERVAL, + c2DomainBlocklistRefreshInterval = C2_DOMAIN_BLOCKLIST_REFRESH_INTERVAL, + urlScanCacheTTL = DEFAULT_URL_SCAN_CACHE_TTL, + urlScanCacheMaxSize = DEFAULT_URL_SCAN_CACHE_MAX_SIZE, + tokenScanCacheTTL = DEFAULT_TOKEN_SCAN_CACHE_TTL, + tokenScanCacheMaxSize = DEFAULT_TOKEN_SCAN_CACHE_MAX_SIZE, + addressScanCacheTTL = DEFAULT_ADDRESS_SCAN_CACHE_TTL, + addressScanCacheMaxSize = DEFAULT_ADDRESS_SCAN_CACHE_MAX_SIZE, messenger, state = {}, }: PhishingControllerOptions) { @@ -284,54 +565,442 @@ export class PhishingController extends BaseController< this.#stalelistRefreshInterval = stalelistRefreshInterval; this.#hotlistRefreshInterval = hotlistRefreshInterval; - this.#registerMessageHandlers(); + this.#c2DomainBlocklistRefreshInterval = c2DomainBlocklistRefreshInterval; + this.#knownRecipients = new Set(); + this.#transactionRecipients = new Set(); + this.#transactionRecipientsByTransactionId = new Map(); + this.#transactionRecipientCounts = new Map(); + this.#addressBookRecipients = new Set(); + this.#transactionControllerStateChangeHandler = + this.#onTransactionControllerStateChange.bind(this); + this.#addressBookControllerStateChangeHandler = + this.#onAddressBookControllerStateChange.bind(this); + this.#urlScanCache = new CacheManager({ + cacheTTL: urlScanCacheTTL, + maxCacheSize: urlScanCacheMaxSize, + initialCache: this.state.urlScanCache, + updateState: (cache) => { + this.update((draftState) => { + draftState.urlScanCache = cache; + }); + }, + }); + this.#tokenScanCache = new CacheManager({ + cacheTTL: tokenScanCacheTTL, + maxCacheSize: tokenScanCacheMaxSize, + initialCache: this.state.tokenScanCache, + updateState: (cache) => { + this.update((draftState) => { + draftState.tokenScanCache = cache; + }); + }, + }); + this.#addressScanCache = new CacheManager({ + cacheTTL: addressScanCacheTTL, + maxCacheSize: addressScanCacheMaxSize, + initialCache: this.state.addressScanCache, + updateState: (cache) => { + this.update((draftState) => { + draftState.addressScanCache = cache; + }); + }, + }); + + this.messenger.registerMethodActionHandlers( + this, + MESSENGER_EXPOSED_METHODS, + ); this.updatePhishingDetector(); + this.#hydrateKnownRecipients(); + this.#subscribeToAddressBookControllerStateChange(); + this.#subscribeToTransactionControllerStateChange(); + } + + #subscribeToAddressBookControllerStateChange(): void { + this.messenger.subscribe( + // eslint-disable-next-line no-restricted-syntax + 'AddressBookController:stateChange', + this.#addressBookControllerStateChangeHandler, + ); + } + + #subscribeToTransactionControllerStateChange(): void { + this.messenger.subscribe( + // eslint-disable-next-line no-restricted-syntax + 'TransactionController:stateChange', + this.#transactionControllerStateChangeHandler, + ); } /** - * Constructor helper for registering this controller's messaging system - * actions. + * Checks if a patch represents a transaction-level change or nested transaction property change + * + * @param patch - Immer patch to check + * @returns True if patch affects a transaction or its nested properties */ - #registerMessageHandlers(): void { - this.messagingSystem.registerActionHandler( - `${controllerName}:maybeUpdateState` as const, - this.maybeUpdateState.bind(this), + #isTransactionPatch(patch: Patch): boolean { + const { path } = patch; + return ( + path.length === 2 && + path[0] === 'transactions' && + typeof path[1] === 'number' ); + } - this.messagingSystem.registerActionHandler( - `${controllerName}:testOrigin` as const, - this.test.bind(this), + /** + * Checks if a patch represents a simulation data change + * + * @param patch - Immer patch to check + * @returns True if patch represents a simulation data change + */ + #isSimulationDataPatch(patch: Patch): boolean { + const { path } = patch; + return ( + path.length === 3 && + path[0] === 'transactions' && + typeof path[1] === 'number' && + path[2] === 'simulationData' ); } /** - * Updates this.detector with an instance of PhishingDetector using the current state. + * Handle transaction controller state changes using Immer patches + * Extracts token addresses from simulation data and groups them by chain for bulk scanning + * + * @param _state - The current transaction controller state + * @param _state.transactions - Array of transaction metadata + * @param patches - Array of Immer patches only for transaction-level changes */ - updatePhishingDetector() { - this.#detector = new PhishingDetector(this.state.phishingLists); + #onTransactionControllerStateChange( + _state: TransactionControllerState, + patches: Patch[], + ): void { + try { + try { + this.#updateKnownRecipientsFromTransactionPatches(_state, patches); + } catch (error) { + console.error( + 'Error updating known recipients from transaction state:', + error, + ); + } + + const tokensByChain = new Map>(); + + for (const patch of patches) { + if (patch.op === 'remove') { + continue; + } + + // Handle transaction-level patches (includes simulation data updates) + if (this.#isTransactionPatch(patch)) { + const transaction = patch.value as TransactionMeta; + this.#getTokensFromTransaction(transaction, tokensByChain); + } else if (this.#isSimulationDataPatch(patch)) { + const transactionIndex = patch.path[1] as number; + const transaction = _state.transactions?.[transactionIndex]; + this.#getTokensFromTransaction(transaction, tokensByChain); + } + } + + this.#scanTokensByChain(tokensByChain); + } catch (error) { + console.error('Error processing transaction state change:', error); + } + } + + #onAddressBookControllerStateChange(state: AddressBookControllerState): void { + this.#setKnownRecipientsFromAddressBookState(state); } /** - * Set the interval at which the stale phishing list will be refetched. - * Fetching will only occur on the next call to test/bypass. - * For immediate update to the phishing list, call {@link updateStalelist} directly. + * Collect token addresses from a transaction and group them by chain * - * @param interval - the new interval, in ms. + * @param transaction - Transaction metadata to extract tokens from + * @param tokensByChain - Map to collect tokens grouped by chainId */ - setStalelistRefreshInterval(interval: number) { - this.#stalelistRefreshInterval = interval; + #getTokensFromTransaction( + transaction: TransactionMeta, + tokensByChain: Map>, + ): void { + // extract token addresses from simulation data + const tokenAddresses = transaction.simulationData?.tokenBalanceChanges?.map( + (tokenChange) => tokenChange.address.toLowerCase(), + ); + + // add token addresses to the map by chainId + if (tokenAddresses && tokenAddresses.length > 0 && transaction.chainId) { + const chainId = transaction.chainId.toLowerCase(); + + if (!tokensByChain.has(chainId)) { + tokensByChain.set(chainId, new Set()); + } + + const chainTokens = tokensByChain.get(chainId); + if (chainTokens) { + for (const address of tokenAddresses) { + chainTokens.add(address); + } + } + } } /** - * Set the interval at which the hot list will be refetched. - * Fetching will only occur on the next call to test/bypass. - * For immediate update to the phishing list, call {@link updateHotlist} directly. + * Scan tokens grouped by chain ID * - * @param interval - the new interval, in ms. + * @param tokensByChain - Map of chainId to token addresses */ - setHotlistRefreshInterval(interval: number) { - this.#hotlistRefreshInterval = interval; + #scanTokensByChain(tokensByChain: Map>): void { + for (const [chainId, tokenSet] of tokensByChain) { + if (tokenSet.size > 0) { + const tokens = Array.from(tokenSet); + this.bulkScanTokens({ + chainId, + tokens, + }).catch((error) => + console.error(`Error scanning tokens for chain ${chainId}:`, error), + ); + } + } + } + + #hydrateKnownRecipients(): void { + this.#hydrateKnownRecipientsFromTransactionState(); + this.#hydrateKnownRecipientsFromAddressBookState(); + } + + #hydrateKnownRecipientsFromTransactionState(): void { + try { + const state = this.messenger.call('TransactionController:getState'); + this.#setKnownRecipientsFromTransactionState(state); + } catch (error) { + console.error( + 'Unable to hydrate known recipients from TransactionController state; address poisoning checks will not include existing confirmed transactions.', + error, + ); + } + } + + #hydrateKnownRecipientsFromAddressBookState(): void { + try { + const state = this.messenger.call('AddressBookController:getState'); + this.#setKnownRecipientsFromAddressBookState(state); + } catch (error) { + console.error( + 'Unable to hydrate known recipients from AddressBookController state; address poisoning checks will not include existing address book entries.', + error, + ); + } + } + + #setKnownRecipientsFromTransactionState( + state: TransactionControllerState, + ): void { + this.#transactionRecipients.clear(); + this.#transactionRecipientsByTransactionId.clear(); + this.#transactionRecipientCounts.clear(); + + for (const transaction of state.transactions) { + this.#addTransactionRecipients(transaction); + } + + this.#rebuildKnownRecipients(); + } + + #updateKnownRecipientsFromTransactionPatches( + state: TransactionControllerState, + patches: Patch[], + ): void { + let recipientsChanged = false; + + for (const patch of patches) { + if (patch.path[0] !== 'transactions') { + continue; + } + + if (patch.path.length === 1) { + this.#setKnownRecipientsFromTransactionState(state); + return; + } + + const transactionIndex = patch.path[1]; + + if (transactionIndex === 'length') { + this.#setKnownRecipientsFromTransactionState(state); + return; + } + + if (patch.op === 'remove') { + this.#setKnownRecipientsFromTransactionState(state); + return; + } + + if (typeof transactionIndex !== 'number') { + this.#setKnownRecipientsFromTransactionState(state); + return; + } + + const transaction = + this.#getTransactionFromPatchValue(patch.value) ?? + state.transactions[transactionIndex]; + + if (!transaction) { + continue; + } + + recipientsChanged = + this.#updateTransactionRecipients(transaction) || recipientsChanged; + } + + if (recipientsChanged) { + this.#rebuildKnownRecipients(); + } + } + + #getTransactionFromPatchValue(value: unknown): TransactionMeta | undefined { + const transaction = value as Partial; + + if ( + value && + typeof value === 'object' && + typeof transaction.id === 'string' && + transaction.txParams !== undefined + ) { + return value as TransactionMeta; + } + + return undefined; + } + + #updateTransactionRecipients(transaction: TransactionMeta): boolean { + const recipientsRemoved = this.#removeTransactionRecipients(transaction.id); + const recipientsAdded = this.#addTransactionRecipients(transaction); + + return recipientsRemoved || recipientsAdded; + } + + #addTransactionRecipients(transaction: TransactionMeta): boolean { + const recipients = this.#getRecipientAddressesFromTransaction(transaction); + + if (recipients.length === 0) { + return false; + } + + this.#transactionRecipientsByTransactionId.set( + transaction.id, + new Set(recipients), + ); + + for (const address of recipients) { + const count = this.#transactionRecipientCounts.get(address) ?? 0; + this.#transactionRecipientCounts.set(address, count + 1); + this.#transactionRecipients.add(address); + } + + return true; + } + + #removeTransactionRecipients(transactionId: string): boolean { + const recipients = + this.#transactionRecipientsByTransactionId.get(transactionId); + + if (!recipients) { + return false; + } + + this.#transactionRecipientsByTransactionId.delete(transactionId); + + for (const address of recipients) { + const count = this.#transactionRecipientCounts.get(address) as number; + + if (count <= 1) { + this.#transactionRecipientCounts.delete(address); + this.#transactionRecipients.delete(address); + } else { + this.#transactionRecipientCounts.set(address, count - 1); + } + } + + return true; + } + + #setKnownRecipientsFromAddressBookState( + state: AddressBookControllerState, + ): void { + this.#addressBookRecipients.clear(); + for (const address of this.#getAddressBookRecipients(state)) { + this.#addressBookRecipients.add(address); + } + this.#rebuildKnownRecipients(); + } + + #rebuildKnownRecipients(): void { + this.#knownRecipients.clear(); + + for (const address of this.#transactionRecipients) { + this.#knownRecipients.add(address); + } + + for (const address of this.#addressBookRecipients) { + this.#knownRecipients.add(address); + } + } + + #getAddressBookRecipients(state: AddressBookControllerState): Set { + return new Set( + Object.values(state.addressBook) + .flatMap((entriesByAddress) => Object.values(entriesByAddress)) + .map((entry) => entry.address.toLowerCase()), + ); + } + + #getRecipientAddressesFromTransaction( + transaction: TransactionMeta, + ): string[] { + if (transaction.status !== TransactionStatus.confirmed) { + return []; + } + + const transactionRecipient = this.#normalizeAddress( + getEffectiveRecipient(transaction), + ); + const swapAndSendRecipient = this.#normalizeAddress( + transaction.swapAndSendRecipient, + ); + + return Array.from( + new Set( + [transactionRecipient, swapAndSendRecipient].filter( + (address): address is string => Boolean(address), + ), + ), + ); + } + + #normalizeAddress(address?: string | null): string | null { + if (!address || !isValidHexAddress(address, { allowNonPrefixed: false })) { + return null; + } + + return address.toLowerCase(); + } + + /** + * Updates this.detector with an instance of PhishingDetector using the current state. + */ + updatePhishingDetector(): void { + this.#detector = new PhishingDetector(this.state.phishingLists); + } + + /** + * Finds known recipient addresses that look like an address poisoning match. + * + * @param candidate - The recipient address being checked. + * @returns Similar known recipient matches sorted by score. + */ + checkAddressPoisoning(candidate: string): SimilarAddressMatch[] { + return findSimilarAddresses(candidate, Array.from(this.#knownRecipients)); } /** @@ -358,12 +1027,24 @@ export class PhishingController extends BaseController< ); } + /** + * Determine if an update to the C2 domain blocklist is needed. + * + * @returns Whether an update is needed + */ + isC2DomainBlocklistOutOfDate() { + return ( + fetchTimeNow() - this.state.c2DomainBlocklistLastFetched >= + this.#c2DomainBlocklistRefreshInterval + ); + } + /** * Conditionally update the phishing configuration. * * If the stalelist configuration is out of date, this function will call `updateStalelist` * to update the configuration. This will automatically grab the hotlist, - * so it isn't necessary to continue on to download the hotlist. + * so it isn't necessary to continue on to download the hotlist and the c2 domain blocklist. * */ async maybeUpdateState() { @@ -376,6 +1057,10 @@ export class PhishingController extends BaseController< if (hotlistOutOfDate) { await this.updateHotlist(); } + const c2DomainBlocklistOutOfDate = this.isC2DomainBlocklistOutOfDate(); + if (c2DomainBlocklistOutOfDate) { + await this.updateC2DomainBlocklist(); + } } /** @@ -388,14 +1073,53 @@ export class PhishingController extends BaseController< * @param origin - Domain origin of a website. * @returns Whether the origin is an unapproved origin. */ - test(origin: string): EthPhishingDetectResult { + testOrigin(origin: string): PhishingDetectorResult { const punycodeOrigin = toASCII(origin); - if (this.state.whitelist.includes(punycodeOrigin)) { - return { result: false, type: 'all' }; // Same as whitelisted match returned by detector.check(...). + const hostname = getHostnameFromUrl(punycodeOrigin); + const hostnameWithPaths = hostname + getPathnameFromUrl(origin); + + if (matchedPathPrefix(hostnameWithPaths, this.state.whitelistPaths)) { + return { result: false, type: PhishingDetectorResultType.All }; + } + + if (this.state.whitelist.includes(hostname || punycodeOrigin)) { + return { result: false, type: PhishingDetectorResultType.All }; // Same as whitelisted match returned by detector.check(...). } return this.#detector.check(punycodeOrigin); } + /** + * Determines if a given origin is unapproved. + * + * It is strongly recommended that you call {@link maybeUpdateState} before calling this, + * to check whether the phishing configuration is up-to-date. It will be updated if necessary + * by calling {@link updateStalelist} or {@link updateHotlist}. + * + * @param origin - Domain origin of a website. + * @returns Whether the origin is an unapproved origin. + * @deprecated Use {@link testOrigin} instead. This method is exposed for backward compatibility and will be removed in a future release. + */ + test = this.testOrigin.bind(this); + + /** + * Checks if a request URL's domain is blocked against the request blocklist. + * + * This method is used to determine if a specific request URL is associated with a malicious + * command and control (C2) domain. The URL's hostname is hashed and checked against a configured + * blocklist of known malicious domains. + * + * @param origin - The full request URL to be checked. + * @returns An object indicating whether the URL's domain is blocked and relevant metadata. + */ + isBlockedRequest(origin: string): PhishingDetectorResult { + const punycodeOrigin = toASCII(origin); + const hostname = getHostnameFromUrl(punycodeOrigin); + if (this.state.whitelist.includes(hostname || punycodeOrigin)) { + return { result: false, type: PhishingDetectorResultType.All }; // Same as whitelisted match returned by detector.check(...). + } + return this.#detector.isMaliciousC2Domain(punycodeOrigin); + } + /** * Temporarily marks a given origin as approved. * @@ -403,15 +1127,50 @@ export class PhishingController extends BaseController< */ bypass(origin: string) { const punycodeOrigin = toASCII(origin); - const { whitelist } = this.state; - if (whitelist.includes(punycodeOrigin)) { + const hostname = getHostnameFromUrl(punycodeOrigin); + const hostnameWithPaths = hostname + getPathnameFromUrl(origin); + const { whitelist, whitelistPaths } = this.state; + const whitelistPath = matchedPathPrefix(hostnameWithPaths, whitelistPaths); + + if (whitelist.includes(hostname || punycodeOrigin) || whitelistPath) { + return; + } + + // If the origin was blocked by a path, then we only want to add it to the whitelistPaths since + // other paths with the same hostname may not be blocked. + const blockingPath = this.#detector.blockingPath(origin); + if (blockingPath) { + this.update((draftState) => { + insertToTrie(blockingPath, draftState.whitelistPaths); + }); return; } + this.update((draftState) => { - draftState.whitelist.push(punycodeOrigin); + draftState.whitelist.push(hostname || punycodeOrigin); }); } + /** + * Update the C2 domain blocklist. + * + * If an update is in progress, no additional update will be made. Instead this will wait until + * the in-progress update has finished. + */ + async updateC2DomainBlocklist() { + if (this.#isProgressC2DomainBlocklistUpdate) { + await this.#isProgressC2DomainBlocklistUpdate; + return; + } + + try { + this.#isProgressC2DomainBlocklistUpdate = this.#updateC2DomainBlocklist(); + await this.#isProgressC2DomainBlocklistUpdate; + } finally { + this.#isProgressC2DomainBlocklistUpdate = undefined; + } + } + /** * Update the hotlist. * @@ -452,6 +1211,558 @@ export class PhishingController extends BaseController< } } + /** + * Scan a URL for phishing. For most hosts only the hostname is sent to the API; for known + * shared gateways the pathname is included (see `PHISHING_DETECTION_PATH_BASED_ROOT_DOMAINS`). + * Only supports web URLs (`http:` / `https:`). + * + * @param url - The URL to scan. + * @returns The phishing detection scan result. + */ + async scanUrl(url: string): Promise { + const [scanUrlParam, scanParamOk] = getPhishingDetectionScanUrlParam(url); + if (!scanParamOk) { + return { + hostname: '', + recommendedAction: RecommendedAction.None, + fetchError: 'url is not a valid web URL', + }; + } + + const [hostname] = getHostnameFromWebUrl(url); + + const cachedResult = this.#urlScanCache.get(scanUrlParam); + if (cachedResult) { + return cachedResult; + } + + const apiResponse = await safelyExecuteWithTimeout( + async () => { + const res = await fetch( + `${PHISHING_DETECTION_BASE_URL}/${PHISHING_DETECTION_SCAN_ENDPOINT}?url=${encodeURIComponent(scanUrlParam)}`, + { + method: 'GET', + headers: { + Accept: 'application/json', + }, + }, + ); + if (!res.ok) { + return { + error: `${res.status} ${res.statusText}`, + }; + } + const data = await res.json(); + return data; + }, + true, + 8000, + ); + + // Need to do it this way because safelyExecuteWithTimeout returns undefined for both timeouts and errors. + if (!apiResponse) { + return { + hostname: '', + recommendedAction: RecommendedAction.None, + fetchError: 'timeout of 8000ms exceeded', + }; + } else if ((apiResponse as { error?: string }).error) { + return { + hostname: '', + recommendedAction: RecommendedAction.None, + fetchError: (apiResponse as { error: string }).error, + }; + } + + const scanResult = apiResponse as PhishingDetectionScanResult; + const result = { + hostname, + recommendedAction: scanResult.recommendedAction, + }; + + this.#urlScanCache.set(scanUrlParam, result); + + return result; + } + + /** + * Scan multiple URLs for phishing in bulk. It will only scan the hostnames of the URLs. + * It also only supports web URLs. + * + * @param urls - The URLs to scan. + * @returns A mapping of URLs to their phishing detection scan results and errors. + */ + async bulkScanUrls( + urls: string[], + ): Promise { + if (!urls || urls.length === 0) { + return { + results: {}, + errors: {}, + }; + } + + // we are arbitrarily limiting the number of URLs to 250 + const MAX_TOTAL_URLS = 250; + if (urls.length > MAX_TOTAL_URLS) { + return { + results: {}, + errors: { + too_many_urls: [ + `Maximum of ${MAX_TOTAL_URLS} URLs allowed per request`, + ], + }, + }; + } + + const MAX_URL_LENGTH = 2048; + const combinedResponse: BulkPhishingDetectionScanResponse = { + results: {}, + errors: {}, + }; + + // Extract hostnames from URLs and check for validity and length constraints + const urlsToHostnames: Record = {}; + const urlsToFetch: string[] = []; + + for (const url of urls) { + if (url.length > MAX_URL_LENGTH) { + combinedResponse.errors[url] = [ + `URL length must not exceed ${MAX_URL_LENGTH} characters`, + ]; + continue; + } + + const [hostname, ok] = getHostnameFromWebUrl(url); + if (!ok) { + combinedResponse.errors[url] = ['url is not a valid web URL']; + continue; + } + + // Check if result is already in cache + const cachedResult = this.#urlScanCache.get(hostname); + if (cachedResult) { + // Use cached result + combinedResponse.results[url] = cachedResult; + } else { + // Add to list of URLs to fetch + urlsToHostnames[url] = hostname; + urlsToFetch.push(url); + } + } + + // If there are URLs to fetch, process them in batches + if (urlsToFetch.length > 0) { + // The API has a limit of 50 URLs per request, so we batch the requests + const MAX_URLS_PER_BATCH = 50; + const batches: string[][] = []; + for (let i = 0; i < urlsToFetch.length; i += MAX_URLS_PER_BATCH) { + batches.push(urlsToFetch.slice(i, i + MAX_URLS_PER_BATCH)); + } + + // Process each batch in parallel + const batchResults = await Promise.all( + batches.map((batchUrls) => this.#processBatch(batchUrls)), + ); + + // Merge results and errors from all batches + batchResults.forEach((batchResponse) => { + // Add results to cache and combine with response + Object.entries(batchResponse.results).forEach(([url, result]) => { + const hostname = urlsToHostnames[url]; + if (hostname) { + this.#urlScanCache.set(hostname, result); + } + combinedResponse.results[url] = result; + }); + + // Combine errors + Object.entries(batchResponse.errors).forEach(([key, messages]) => { + combinedResponse.errors[key] = [ + ...(combinedResponse.errors[key] || []), + ...messages, + ]; + }); + }); + } + + return combinedResponse; + } + + /** + * Fetch bulk token scan results from the security alerts API. + * + * @param chain - The chain name. + * @param tokens - Array of token addresses to scan. + * @returns The API response or null if there was an error. + */ + readonly #fetchTokenScanBulkResults = async ( + chain: string, + tokens: string[], + ): Promise => { + const timeout = 8000; // 8 seconds + const apiResponse = await safelyExecuteWithTimeout( + async () => { + const response = await fetch( + `${SECURITY_ALERTS_BASE_URL}${TOKEN_BULK_SCANNING_ENDPOINT}`, + { + method: 'POST', + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + chain, + tokens, + }), + }, + ); + + if (!response.ok) { + return { + error: `${response.status} ${response.statusText}`, + status: response.status, + statusText: response.statusText, + }; + } + + const data = await response.json(); + return data; + }, + true, + timeout, + ); + + if (!apiResponse) { + console.error(`Error scanning tokens: timeout of ${timeout}ms exceeded`); + return null; + } + + if ((apiResponse as { error?: string }).error) { + const { status, statusText } = apiResponse as { + status: number; + statusText: string; + }; + + console.warn(`Token bulk screening API error: ${status} ${statusText}`); + return null; + } + + return apiResponse as TokenScanApiResponse; + }; + + /** + * Scan an address for security alerts. + * + * @param chainId - The chain ID in hex format (e.g., '0x1' for Ethereum). + * @param address - The address to scan. + * @returns The address scan result. + */ + async scanAddress( + chainId: string, + address: string, + ): Promise { + if (!address || !chainId) { + return { + result_type: AddressScanResultType.ErrorResult, + label: '', + }; + } + + const normalizedChainId = chainId.toLowerCase(); + const normalizedAddress = address.toLowerCase(); + const chain = getAddressScanSupportedChain(normalizedChainId); + + if (!chain) { + return { + result_type: AddressScanResultType.ErrorResult, + label: '', + }; + } + + const cacheKey = buildCacheKey(normalizedChainId, normalizedAddress); + const cachedResult = this.#addressScanCache.get(cacheKey); + if (cachedResult) { + return { + result_type: cachedResult.result_type, + label: cachedResult.label, + }; + } + + const apiResponse = await safelyExecuteWithTimeout( + async () => { + const res = await fetch( + `${SECURITY_ALERTS_BASE_URL}${ADDRESS_SCAN_ENDPOINT}`, + { + method: 'POST', + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + chain, + address: normalizedAddress, + }), + }, + ); + if (!res.ok) { + return { + error: `${res.status} ${res.statusText}`, + }; + } + const data: AddressScanResult = await res.json(); + return data; + }, + true, + 5000, + ); + + if (!apiResponse) { + return { + result_type: AddressScanResultType.ErrorResult, + label: '', + }; + } else if ((apiResponse as { error?: string }).error) { + return { + result_type: AddressScanResultType.ErrorResult, + label: '', + }; + } + + const scanResult = apiResponse as AddressScanResult; + const result: AddressScanCacheData = { + result_type: scanResult.result_type, + label: scanResult.label, + }; + + this.#addressScanCache.set(cacheKey, result); + + return { + result_type: scanResult.result_type, + label: scanResult.label, + }; + } + + /** + * Get token approvals for an EVM address with security enrichments. + * + * @param chainId - The chain ID in hex format (e.g., '0x1' for Ethereum). + * @param address - The address to get approvals for. + * @returns The approvals response containing approval data, or empty approvals on error. + */ + getApprovals = async ( + chainId: string, + address: string, + ): Promise => { + if (!address || !chainId) { + return { approvals: [] }; + } + + const normalizedChainId = chainId.toLowerCase(); + const normalizedAddress = address.toLowerCase(); + const chain = resolveChainName(normalizedChainId); + + if (!chain || !isApprovalSupportedChain(chain)) { + return { approvals: [] }; + } + + const apiResponse = await safelyExecuteWithTimeout( + async () => { + const res = await fetch( + `${SECURITY_ALERTS_BASE_URL}${APPROVALS_ENDPOINT}`, + { + method: 'POST', + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + chain, + address: normalizedAddress, + }), + }, + ); + if (!res.ok) { + return { error: `${res.status} ${res.statusText}` }; + } + const data: ApprovalsResponse = await res.json(); + return data; + }, + true, + 5000, + ); + + if (!apiResponse) { + return { approvals: [] }; + } + + if ( + (apiResponse as { error?: string }).error || + !Array.isArray((apiResponse as Partial).approvals) + ) { + return { approvals: [] }; + } + + return apiResponse as ApprovalsResponse; + }; + + /** + * Scan multiple tokens for malicious activity in bulk. + * + * @param request - The bulk scan request containing chainId and tokens. + * @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 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. + */ + async bulkScanTokens( + request: BulkTokenScanRequest, + ): Promise { + const { chainId, tokens } = request; + + if (!tokens || tokens.length === 0) { + return {}; + } + + const MAX_TOKENS_PER_REQUEST = 100; + if (tokens.length > MAX_TOKENS_PER_REQUEST) { + console.warn( + `Maximum of ${MAX_TOKENS_PER_REQUEST} tokens allowed per request`, + ); + return {}; + } + + const normalizedChainId = chainId.toLowerCase(); + const chain = resolveChainName(normalizedChainId); + + if (!chain || !isTokenScanSupportedChain(chain)) { + console.warn(`Unsupported chain ID: ${chainId}`); + 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 }; + + // If there are tokens to fetch, call the bulk token scan API + if (tokensToFetch.length > 0) { + const apiResponse = await this.#fetchTokenScanBulkResults( + chain, + tokensToFetch, + ); + if (apiResponse?.results) { + // Process API results and update cache + for (const tokenAddress of tokensToFetch) { + const normalizedAddress = caseSensitive + ? tokenAddress + : tokenAddress.toLowerCase(); + const tokenResult = apiResponse.results[normalizedAddress]; + + if (tokenResult?.result_type) { + const result = { + result_type: tokenResult.result_type, + chain: tokenResult.chain || normalizedChainId, + address: tokenResult.address || normalizedAddress, + }; + + // Update cache + const cacheKey = buildCacheKey( + normalizedChainId, + normalizedAddress, + caseSensitive, + ); + this.#tokenScanCache.set(cacheKey, { + result_type: tokenResult.result_type, + }); + + results[normalizedAddress] = result; + } + } + } + } + + return results; + } + + /** + * Process a batch of URLs (up to 50) for phishing detection. + * + * @param urls - A batch of URLs to scan. + * @returns The scan results and errors for this batch. + */ + readonly #processBatch = async ( + urls: string[], + ): Promise => { + const apiResponse = await safelyExecuteWithTimeout( + async () => { + const res = await fetch( + `${PHISHING_DETECTION_BASE_URL}/${PHISHING_DETECTION_BULK_SCAN_ENDPOINT}`, + { + method: 'POST', + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ urls }), + }, + ); + + if (!res.ok) { + return { + error: `${res.status} ${res.statusText}`, + status: res.status, + statusText: res.statusText, + }; + } + + const data = await res.json(); + return data; + }, + true, + 15000, + ); + + // Handle timeout or network errors + if (!apiResponse) { + return { + results: {}, + errors: { + network_error: ['timeout of 15000ms exceeded'], + }, + }; + } + + // Handle HTTP error responses + if ((apiResponse as { error?: string }).error) { + const { status, statusText } = apiResponse as { + status: number; + statusText: string; + }; + + return { + results: {}, + errors: { + api_error: [`${status} ${statusText}`], + }, + }; + } + + return apiResponse as BulkPhishingDetectionScanResponse; + }; + /** * Update the stalelist configuration. * @@ -459,13 +1770,21 @@ export class PhishingController extends BaseController< * this function that prevents redundant configuration updates. */ async #updateStalelist() { - let stalelistResponse; - let hotlistDiffsResponse; + let stalelistResponse: DataResultWrapper | null = null; + let hotlistDiffsResponse: DataResultWrapper | null = null; + let c2DomainBlocklistResponse: C2DomainBlocklistResponse | null = null; try { - stalelistResponse = await this.#queryConfig< + const stalelistPromise = this.#queryConfig< DataResultWrapper - >(METAMASK_STALELIST_URL).then((d) => d); + >(METAMASK_STALELIST_URL); + + const c2DomainBlocklistPromise = + this.#queryConfig(C2_DOMAIN_BLOCKLIST_URL); + [stalelistResponse, c2DomainBlocklistResponse] = await Promise.all([ + stalelistPromise, + c2DomainBlocklistPromise, + ]); // Fetching hotlist diffs relies on having a lastUpdated timestamp to do `GET /v1/diffsSince/:timestamp`, // so it doesn't make sense to call if there is not a timestamp to begin with. if (stalelistResponse?.data && stalelistResponse.data.lastUpdated > 0) { @@ -480,6 +1799,9 @@ export class PhishingController extends BaseController< this.update((draftState) => { draftState.stalelistLastFetched = timeNow; draftState.hotlistLastFetched = timeNow; + if (c2DomainBlocklistResponse) { + draftState.c2DomainBlocklistLastFetched = timeNow; + } }); } @@ -487,27 +1809,20 @@ export class PhishingController extends BaseController< return; } - const { phishfort_hotlist, eth_phishing_detect_config, ...partialState } = - stalelistResponse.data; - - const phishfortListState: PhishingListState = { - ...phishfort_hotlist, - ...partialState, - fuzzylist: [], // Phishfort hotlist doesn't contain a fuzzylist - allowlist: [], // Phishfort hotlist doesn't contain an allowlist - name: phishingListKeyNameMap.phishfort_hotlist, - }; const metamaskListState: PhishingListState = { - ...eth_phishing_detect_config, - ...partialState, + allowlist: stalelistResponse.data.allowlist, + fuzzylist: stalelistResponse.data.fuzzylist, + tolerance: stalelistResponse.data.tolerance, + version: stalelistResponse.data.version, + lastUpdated: stalelistResponse.data.lastUpdated, + blocklist: stalelistResponse.data.blocklist, + blocklistPaths: convertListToTrie(stalelistResponse.data.blocklistPaths), + c2DomainBlocklist: c2DomainBlocklistResponse + ? c2DomainBlocklistResponse.recentlyAdded + : [], name: phishingListKeyNameMap.eth_phishing_detect_config, }; - // Correctly shaping eth-phishing-detect state by applying hotlist diffs to the stalelist. - const newPhishfortListState: PhishingListState = applyDiffs( - phishfortListState, - hotlistDiffsResponse.data, - ListKeys.PhishfortHotlist, - ); + const newMetaMaskListState: PhishingListState = applyDiffs( metamaskListState, hotlistDiffsResponse.data, @@ -515,7 +1830,7 @@ export class PhishingController extends BaseController< ); this.update((draftState) => { - draftState.phishingLists = [newMetaMaskListState, newPhishfortListState]; + draftState.phishingLists = [newMetaMaskListState]; }); this.updatePhishingDetector(); } @@ -527,12 +1842,17 @@ export class PhishingController extends BaseController< * this function that prevents redundant configuration updates. */ async #updateHotlist() { - const lastDiffTimestamp = Math.max( - ...this.state.phishingLists.map(({ lastUpdated }) => lastUpdated), - ); let hotlistResponse: DataResultWrapper | null; try { + if (this.state.phishingLists.length === 0) { + return; + } + + const lastDiffTimestamp = Math.max( + ...this.state.phishingLists.map(({ lastUpdated }) => lastUpdated), + ); + hotlistResponse = await this.#queryConfig>( `${METAMASK_HOTLIST_DIFF_URL}/${lastDiffTimestamp}`, ); @@ -548,13 +1868,60 @@ export class PhishingController extends BaseController< return; } const hotlist = hotlistResponse.data; - const newPhishingLists = this.state.phishingLists.map((phishingList) => - applyDiffs( + const newPhishingLists = this.state.phishingLists.map((phishingList) => { + const updatedList = applyDiffs( phishingList, hotlist, phishingListNameKeyMap[phishingList.name], - ), - ); + [], + [], + ); + + return updatedList; + }); + + this.update((draftState) => { + draftState.phishingLists = newPhishingLists; + }); + this.updatePhishingDetector(); + } + + /** + * Update the C2 domain blocklist. + * + * This should only be called from the `updateC2DomainBlocklist` function, which is a wrapper around + * this function that prevents redundant configuration updates. + */ + async #updateC2DomainBlocklist() { + const c2DomainBlocklistResponse = + await this.#queryConfig( + `${C2_DOMAIN_BLOCKLIST_URL}?timestamp=${roundToNearestMinute( + this.state.c2DomainBlocklistLastFetched, + )}`, + ); + + if (!c2DomainBlocklistResponse) { + return; + } + + this.update((draftState) => { + draftState.c2DomainBlocklistLastFetched = fetchTimeNow(); + }); + + const recentlyAddedC2Domains = c2DomainBlocklistResponse.recentlyAdded; + const recentlyRemovedC2Domains = c2DomainBlocklistResponse.recentlyRemoved; + + const newPhishingLists = this.state.phishingLists.map((phishingList) => { + const updatedList = applyDiffs( + phishingList, + [], + phishingListNameKeyMap[phishingList.name], + recentlyAddedC2Domains, + recentlyRemovedC2Domains, + ); + + return updatedList; + }); this.update((draftState) => { draftState.phishingLists = newPhishingLists; @@ -583,3 +1950,5 @@ export class PhishingController extends BaseController< } export default PhishingController; + +export type { PhishingDetectorResult }; diff --git a/packages/phishing-controller/src/PhishingDetector.test.ts b/packages/phishing-controller/src/PhishingDetector.test.ts new file mode 100644 index 00000000000..8736b47c8fe --- /dev/null +++ b/packages/phishing-controller/src/PhishingDetector.test.ts @@ -0,0 +1,2092 @@ +import { PhishingDetector } from './PhishingDetector.js'; +import type { PhishingDetectorOptions } from './PhishingDetector.js'; +import { formatHostnameToUrl } from './tests/utils.js'; +import { PhishingDetectorResultType } from './types.js'; +import { sha256Hash } from './utils.js'; + +describe('PhishingDetector', () => { + describe('constructor', () => { + describe('with a recommended config', () => { + it('constructs a phishing detector when allowlist is missing', async () => { + await withPhishingDetector( + [ + { + blocklist: [], + fuzzylist: [], + name: 'first', + tolerance: 2, + version: 1, + }, + ], + ({ detector }) => { + expect(detector).toBeDefined(); + }, + ); + }); + + it('constructs a phishing detector when blocklist is missing', async () => { + await withPhishingDetector( + [ + { + allowlist: [], + fuzzylist: [], + name: 'first', + tolerance: 2, + version: 1, + }, + ], + ({ detector }) => { + expect(detector).toBeDefined(); + }, + ); + }); + + it('constructs a phishing detector when fuzzylist and tolerance are missing', async () => { + await withPhishingDetector( + [ + { + allowlist: [], + blocklist: [], + name: 'first', + version: 1, + }, + ], + ({ detector }) => { + expect(detector).toBeDefined(); + }, + ); + }); + + it('constructs a phishing detector when tolerance is missing', async () => { + await withPhishingDetector( + [ + { + allowlist: [], + blocklist: [], + fuzzylist: [], + name: 'first', + version: 1, + }, + ], + ({ detector }) => { + expect(detector).toBeDefined(); + }, + ); + }); + + it.each([ + undefined, + null, + true, + false, + 0, + 1, + 1.1, + '', + () => { + return { name: 'test', version: 1 }; + }, + {}, + ])('logs an error when config name is %p', async (mockInvalidName) => { + // Mock console.error to track error logs without cluttering test output + const consoleErrorMock = jest.spyOn(console, 'error'); + + let detector; + + await withPhishingDetector( + [ + { + allowlist: [], + blocklist: ['blocked-by-first.com'], + fuzzylist: [], + // @ts-expect-error Testing invalid input + name: mockInvalidName, + tolerance: 2, + version: 1, + }, + ], + async ({ detector: d }) => { + detector = d; + }, + ); + + // Ensure the detector is still defined + expect(detector).toBeDefined(); + + // Check that console.error was called (you can further specify the error if needed) + expect(console.error).toHaveBeenCalled(); + + // Restore the original console.error implementation + consoleErrorMock.mockRestore(); + }); + + it('drops the invalid config and retains the valid config', async () => { + const consoleErrorMock = jest.spyOn(console, 'error'); + + let detector: PhishingDetector | undefined; + + await withPhishingDetector( + [ + // Invalid config + { + allowlist: [], + blocklist: ['blocked-by-first.com'], + fuzzylist: [], + name: undefined, + tolerance: 2, + version: 1, + }, + // Valid config + { + allowlist: [], + blocklist: ['blocked-by-second.com'], + fuzzylist: [], + name: 'MetaMask', + tolerance: 2, + version: 1, + }, + ], + async ({ detector: d }) => { + detector = d; + }, + ); + + expect(detector).toBeDefined(); + + const result = detector?.check('https://blocked-by-second.com'); + + expect(result).toBeDefined(); + expect(result?.type).toBe('blocklist'); + + const resultInvalid = detector?.check('https://blocked-by-first.com'); + + expect(resultInvalid).toBeDefined(); + expect(resultInvalid?.type).toBe('all'); + + expect(console.error).toHaveBeenCalled(); + + consoleErrorMock.mockRestore(); + }); + + it('logs an error when tolerance is provided without fuzzylist', async () => { + const consoleErrorMock = jest.spyOn(console, 'error'); + + let detector; + + await withPhishingDetector( + [ + // @ts-expect-error testing invalid input + { + allowlist: [], + blocklist: [], + name: 'first', + tolerance: 2, + version: 1, + }, + ], + async ({ detector: d }) => { + detector = d; + }, + ); + + expect(detector).toBeDefined(); + + expect(console.error).toHaveBeenCalledWith(expect.any(Error)); + + consoleErrorMock.mockRestore(); + }); + + it.each([ + undefined, + null, + true, + false, + '', + () => { + return { name: 'test', version: 1 }; + }, + {}, + ])( + 'logs an error when config version is %p', + async (mockInvalidVersion) => { + // Mock console.error to track error logs without cluttering test output + const consoleErrorMock = jest.spyOn(console, 'error'); + + let detector; + + await withPhishingDetector( + [ + { + allowlist: [], + blocklist: ['blocked-by-first.com'], + fuzzylist: [], + name: 'first', + tolerance: 2, + // @ts-expect-error Testing invalid input + version: mockInvalidVersion, + }, + ], + async ({ detector: d }) => { + detector = d; + }, + ); + + // Ensure the detector is still defined + expect(detector).toBeDefined(); + + // Check that console.error was called with an error + expect(console.error).toHaveBeenCalledWith(expect.any(Error)); + + // Restore the original console.error implementation + consoleErrorMock.mockRestore(); + }, + ); + }); + + describe('with legacy config', () => { + it('constructs a phishing detector when whitelist is missing', async () => { + await withPhishingDetector( + { + blacklist: [], + fuzzylist: [], + tolerance: 2, + }, + ({ detector }) => { + expect(detector).toBeDefined(); + }, + ); + }); + + it('constructs a phishing detector when blacklist is missing', async () => { + await withPhishingDetector( + { + fuzzylist: [], + tolerance: 2, + whitelist: [], + }, + ({ detector }) => { + expect(detector).toBeDefined(); + }, + ); + }); + + it('constructs a phishing detector when fuzzylist and tolerance are missing', async () => { + await withPhishingDetector( + { + whitelist: [], + blacklist: [], + }, + ({ detector }) => { + expect(detector).toBeDefined(); + }, + ); + }); + + it('constructs a phishing detector when tolerance is missing', async () => { + await withPhishingDetector( + { + blacklist: [], + fuzzylist: [], + whitelist: [], + }, + ({ detector }) => { + expect(detector).toBeDefined(); + }, + ); + }); + }); + }); + + describe('check', () => { + describe('with recommended config', () => { + it('allows a domain when no config is provided', async () => { + await withPhishingDetector([], async ({ detector }) => { + const { result, type } = detector.check( + formatHostnameToUrl('default.com'), + ); + + expect(result).toBe(false); + expect(type).toBe(PhishingDetectorResultType.All); + }); + }); + + it('allows a domain when empty lists are provided', async () => { + await withPhishingDetector( + [ + { + allowlist: [], + blocklist: [], + fuzzylist: [], + name: 'first', + tolerance: 2, + version: 1, + }, + { + allowlist: [], + blocklist: [], + fuzzylist: [], + name: 'second', + tolerance: 2, + version: 1, + }, + ], + async ({ detector }) => { + const { result, type } = detector.check( + formatHostnameToUrl('default.com'), + ); + + expect(result).toBe(false); + expect(type).toBe(PhishingDetectorResultType.All); + }, + ); + }); + + it('blocks a domain when it is in the blocklist of the first config', async () => { + await withPhishingDetector( + [ + { + allowlist: [], + blocklist: ['blocked-by-first.com'], + fuzzylist: [], + name: 'first', + tolerance: 2, + version: 1, + }, + { + allowlist: [], + blocklist: [], + fuzzylist: [], + name: 'second', + tolerance: 2, + version: 1, + }, + ], + async ({ detector }) => { + const { result, type, name } = detector.check( + formatHostnameToUrl('blocked-by-first.com'), + ); + + expect(result).toBe(true); + expect(type).toBe('blocklist'); + expect(name).toBe('first'); + }, + ); + }); + + it('blocks a domain when it is in the blocklist of the second config', async () => { + await withPhishingDetector( + [ + { + allowlist: [], + blocklist: [], + fuzzylist: [], + name: 'first', + tolerance: 2, + version: 1, + }, + { + allowlist: [], + blocklist: ['blocked-by-second.com'], + fuzzylist: [], + name: 'second', + tolerance: 2, + version: 1, + }, + ], + async ({ detector }) => { + const { result, type, name } = detector.check( + formatHostnameToUrl('blocked-by-second.com'), + ); + + expect(result).toBe(true); + expect(type).toBe('blocklist'); + expect(name).toBe('second'); + }, + ); + }); + + it('prefers the first config when a domain is in both blocklists', async () => { + await withPhishingDetector( + [ + { + allowlist: [], + blocklist: ['blocked-by-both.com'], + fuzzylist: [], + name: 'first', + tolerance: 2, + version: 1, + }, + { + allowlist: [], + blocklist: ['blocked-by-both.com'], + fuzzylist: [], + name: 'second', + tolerance: 2, + version: 1, + }, + ], + async ({ detector }) => { + const { result, type, name } = detector.check( + formatHostnameToUrl('blocked-by-both.com'), + ); + + expect(result).toBe(true); + expect(type).toBe('blocklist'); + expect(name).toBe('first'); + }, + ); + }); + + it('blocks a domain when it is in the fuzzylist of the first config', async () => { + await withPhishingDetector( + [ + { + allowlist: [], + blocklist: [], + fuzzylist: ['fuzzy-first.com'], + name: 'first', + tolerance: 2, + version: 1, + }, + { + allowlist: [], + blocklist: [], + fuzzylist: [], + name: 'second', + tolerance: 2, + version: 1, + }, + ], + async ({ detector }) => { + const { result, type, name } = detector.check( + formatHostnameToUrl('fuzzy-first.com'), + ); + + expect(result).toBe(true); + expect(type).toBe('fuzzy'); + expect(name).toBe('first'); + }, + ); + }); + + it('blocks a domain that is similar enough (within a tolerance) to a domain in the fuzzylist of the first config', async () => { + await withPhishingDetector( + [ + { + allowlist: [], + blocklist: [], + fuzzylist: ['fuzzy-first.com'], + name: 'first', + tolerance: 2, + version: 1, + }, + { + allowlist: [], + blocklist: [], + fuzzylist: [], + name: 'second', + tolerance: 2, + version: 1, + }, + ], + async ({ detector }) => { + const { result, type, name } = detector.check( + formatHostnameToUrl('fuzzy-firstab.com'), + ); + + expect(result).toBe(true); + expect(type).toBe('fuzzy'); + expect(name).toBe('first'); + }, + ); + }); + + it('allows a domain that is not similar enough to a domain in the fuzzylist of the first config', async () => { + await withPhishingDetector( + [ + { + allowlist: [], + blocklist: [], + fuzzylist: ['fuzzy-first.com'], + name: 'first', + tolerance: 2, + version: 1, + }, + { + allowlist: [], + blocklist: [], + fuzzylist: [], + name: 'second', + tolerance: 2, + version: 1, + }, + ], + async ({ detector }) => { + const { result, type } = detector.check( + formatHostnameToUrl('fuzzy-firstabc.com'), + ); + + expect(result).toBe(false); + expect(type).toBe(PhishingDetectorResultType.All); + }, + ); + }); + + it('blocks a domain when it is in the fuzzylist of the second config', async () => { + await withPhishingDetector( + [ + { + allowlist: [], + blocklist: [], + fuzzylist: [], + name: 'first', + tolerance: 2, + version: 1, + }, + { + allowlist: [], + blocklist: [], + fuzzylist: ['fuzzy-second.com'], + name: 'second', + tolerance: 2, + version: 1, + }, + ], + async ({ detector }) => { + const { result, type, name } = detector.check( + formatHostnameToUrl('fuzzy-second.com'), + ); + + expect(result).toBe(true); + expect(type).toBe('fuzzy'); + expect(name).toBe('second'); + }, + ); + }); + + it('blocks a domain that is similar enough (within a tolerance) to a domain in the fuzzylist of the second config', async () => { + await withPhishingDetector( + [ + { + allowlist: [], + blocklist: [], + fuzzylist: [], + name: 'first', + tolerance: 2, + version: 1, + }, + { + allowlist: [], + blocklist: [], + fuzzylist: ['fuzzy-second.com'], + name: 'second', + tolerance: 2, + version: 1, + }, + ], + async ({ detector }) => { + const { result, type, name } = detector.check( + formatHostnameToUrl('fuzzy-secondab.com'), + ); + + expect(result).toBe(true); + expect(type).toBe('fuzzy'); + expect(name).toBe('second'); + }, + ); + }); + + it('allows a domain that is not similar enough to a domain in the fuzzylist of the second config', async () => { + await withPhishingDetector( + [ + { + allowlist: [], + blocklist: [], + fuzzylist: [], + name: 'first', + tolerance: 2, + version: 1, + }, + { + allowlist: [], + blocklist: [], + fuzzylist: ['fuzzy-second.com'], + name: 'second', + tolerance: 2, + version: 1, + }, + ], + async ({ detector }) => { + const { result, type } = detector.check( + formatHostnameToUrl('fuzzy-secondabc.com'), + ); + + expect(result).toBe(false); + expect(type).toBe(PhishingDetectorResultType.All); + }, + ); + }); + + it('prefers the first config when a domain is in both fuzzylists', async () => { + await withPhishingDetector( + [ + { + allowlist: [], + blocklist: [], + fuzzylist: ['fuzzy-both.com'], + name: 'first', + tolerance: 2, + version: 1, + }, + { + allowlist: [], + blocklist: [], + fuzzylist: ['fuzzy-both.com'], + name: 'second', + tolerance: 2, + version: 1, + }, + ], + async ({ detector }) => { + const { result, type, name } = detector.check( + formatHostnameToUrl('fuzzy-both.com'), + ); + + expect(result).toBe(true); + expect(type).toBe('fuzzy'); + expect(name).toBe('first'); + }, + ); + }); + + it('blocks a domain when it is in the first blocklist, even if it is also matched by the second fuzzylist', async () => { + await withPhishingDetector( + [ + { + allowlist: [], + blocklist: ['blocked-by-first.com'], + fuzzylist: [], + name: 'first', + tolerance: 2, + version: 1, + }, + { + allowlist: [], + blocklist: [], + fuzzylist: ['fuzzy-second.com'], + name: 'second', + tolerance: 2, + version: 1, + }, + ], + async ({ detector }) => { + const { result, type, name } = detector.check( + formatHostnameToUrl('blocked-by-first.com'), + ); + + expect(result).toBe(true); + expect(type).toBe('blocklist'); + expect(name).toBe('first'); + }, + ); + }); + + it('blocks a domain when it is matched by the first fuzzylist, even if it is also in the second blocklist', async () => { + await withPhishingDetector( + [ + { + allowlist: [], + blocklist: [], + fuzzylist: ['fuzzy-first.com'], + name: 'first', + tolerance: 2, + version: 1, + }, + { + allowlist: [], + blocklist: ['blocked-by-second.com'], + fuzzylist: [], + name: 'second', + tolerance: 2, + version: 1, + }, + ], + async ({ detector }) => { + const { result, type, name } = detector.check( + formatHostnameToUrl('fuzzy-first.com'), + ); + + expect(result).toBe(true); + expect(type).toBe('fuzzy'); + expect(name).toBe('first'); + }, + ); + }); + + it('allows a domain when it is in the first allowlist (and not blocked by the second blocklist)', async () => { + await withPhishingDetector( + [ + { + allowlist: ['allowed-by-first.com'], + blocklist: [], + fuzzylist: [], + name: 'first', + tolerance: 2, + version: 1, + }, + { + allowlist: [], + blocklist: [], + fuzzylist: [], + name: 'second', + tolerance: 2, + version: 1, + }, + ], + async ({ detector }) => { + const { result, type, name } = detector.check( + formatHostnameToUrl('allowed-by-first.com'), + ); + + expect(result).toBe(false); + expect(type).toBe('allowlist'); + expect(name).toBe('first'); + }, + ); + }); + + it('fails when the URL is invalid', async () => { + await withPhishingDetector( + [ + { + allowlist: ['allowed-by-first.com'], + blocklist: [], + fuzzylist: [], + name: 'first', + tolerance: 2, + version: 1, + }, + { + allowlist: [], + blocklist: [], + fuzzylist: [], + name: 'second', + tolerance: 2, + version: 1, + }, + ], + async ({ detector }) => { + const invalidUrl = 'not-a-valid-url'; + + const { result, type, name } = detector.check(invalidUrl); + + expect(result).toBe(false); + expect(type).toBe(PhishingDetectorResultType.All); + expect(name).toBeUndefined(); + }, + ); + }); + + it('allows a domain when it is in the second allowlist (and not blocked by the first blocklist)', async () => { + await withPhishingDetector( + [ + { + allowlist: [], + blocklist: [], + fuzzylist: [], + name: 'first', + tolerance: 2, + version: 1, + }, + { + allowlist: ['allowed-by-second.com'], + blocklist: [], + fuzzylist: [], + name: 'second', + tolerance: 2, + version: 1, + }, + ], + async ({ detector }) => { + const { result, type, name } = detector.check( + formatHostnameToUrl('allowed-by-second.com'), + ); + + expect(result).toBe(false); + expect(type).toBe('allowlist'); + expect(name).toBe('second'); + }, + ); + }); + + it('allows a domain when it is in the first allowlist and the first blocklist (and not blocked by the second blocklist)', async () => { + await withPhishingDetector( + [ + { + allowlist: ['allowed-and-blocked-first.com'], + blocklist: ['allowed-and-blocked-first.com'], + fuzzylist: [], + name: 'first', + tolerance: 2, + version: 1, + }, + { + allowlist: [], + blocklist: [], + fuzzylist: [], + name: 'second', + tolerance: 2, + version: 1, + }, + ], + async ({ detector }) => { + const { result, type, name } = detector.check( + formatHostnameToUrl('allowed-and-blocked-first.com'), + ); + + expect(result).toBe(false); + expect(type).toBe('allowlist'); + expect(name).toBe('first'); + }, + ); + }); + + it('allows a domain when it is in the first allowlist and the first fuzzylist (and not blocked by the second blocklist)', async () => { + await withPhishingDetector( + [ + { + allowlist: ['allowed-and-fuzzy-first.com'], + blocklist: [], + fuzzylist: ['allowed-and-fuzzy-first.com'], + name: 'first', + tolerance: 2, + version: 1, + }, + { + allowlist: [], + blocklist: [], + fuzzylist: [], + name: 'second', + tolerance: 2, + version: 1, + }, + ], + async ({ detector }) => { + const { result, type, name } = detector.check( + formatHostnameToUrl('allowed-and-fuzzy-first.com'), + ); + + expect(result).toBe(false); + expect(type).toBe('allowlist'); + expect(name).toBe('first'); + }, + ); + }); + + it('allows a domain when it is in the second allowlist and the second blocklist (and not blocked by the first blocklist)', async () => { + await withPhishingDetector( + [ + { + allowlist: [], + blocklist: [], + fuzzylist: [], + name: 'first', + tolerance: 2, + version: 1, + }, + { + allowlist: ['allowed-and-blocked-second.com'], + blocklist: ['allowed-and-blocked-second.com'], + fuzzylist: [], + name: 'second', + tolerance: 2, + version: 1, + }, + ], + async ({ detector }) => { + const { result, type, name } = detector.check( + formatHostnameToUrl('allowed-and-blocked-second.com'), + ); + + expect(result).toBe(false); + expect(type).toBe('allowlist'); + expect(name).toBe('second'); + }, + ); + }); + + it('allows a domain when it is in the second allowlist and the second fuzzylist (and not blocked by the first blocklist)', async () => { + await withPhishingDetector( + [ + { + allowlist: [], + blocklist: [], + fuzzylist: [], + name: 'first', + tolerance: 2, + version: 1, + }, + { + allowlist: ['allowed-and-fuzzy-second.com'], + blocklist: [], + fuzzylist: ['allowed-and-fuzzy-second.com'], + name: 'second', + tolerance: 2, + version: 1, + }, + ], + async ({ detector }) => { + const { result, type, name } = detector.check( + formatHostnameToUrl('allowed-and-fuzzy-second.com'), + ); + + expect(result).toBe(false); + expect(type).toBe('allowlist'); + expect(name).toBe('second'); + }, + ); + }); + + it('allows a domain when it is in the first and second allowlist', async () => { + await withPhishingDetector( + [ + { + allowlist: ['allowed-by-both.com'], + blocklist: [], + fuzzylist: [], + name: 'first', + tolerance: 2, + version: 1, + }, + { + allowlist: ['allowed-by-both.com'], + blocklist: [], + fuzzylist: [], + name: 'second', + tolerance: 2, + version: 1, + }, + ], + async ({ detector }) => { + const { result, type, name } = detector.check( + formatHostnameToUrl('allowed-by-both.com'), + ); + + expect(result).toBe(false); + expect(type).toBe('allowlist'); + expect(name).toBe('first'); + }, + ); + }); + + it('allows a domain when it is in the first fuzzylist and the second allowlist', async () => { + await withPhishingDetector( + [ + { + allowlist: [], + blocklist: [], + fuzzylist: ['fuzzy-first-allowed-second.com'], + name: 'first', + tolerance: 2, + version: 1, + }, + { + allowlist: ['fuzzy-first-allowed-second.com'], + blocklist: [], + fuzzylist: [], + name: 'second', + tolerance: 2, + version: 1, + }, + ], + async ({ detector }) => { + const { result, type, name } = detector.check( + formatHostnameToUrl('fuzzy-first-allowed-second.com'), + ); + + expect(result).toBe(false); + expect(type).toBe('allowlist'); + expect(name).toBe('second'); + }, + ); + }); + + it('allows a domain when it is in the first allowlist and the second fuzzylist', async () => { + await withPhishingDetector( + [ + { + allowlist: ['allowed-first-fuzzy-second.com'], + blocklist: [], + fuzzylist: [], + name: 'first', + tolerance: 2, + version: 1, + }, + { + allowlist: [], + blocklist: [], + fuzzylist: ['allowed-first-fuzzy-second.com'], + name: 'second', + tolerance: 2, + version: 1, + }, + ], + async ({ detector }) => { + const { result, type, name } = detector.check( + formatHostnameToUrl('allowed-first-fuzzy-second.com'), + ); + + expect(result).toBe(false); + expect(type).toBe('allowlist'); + expect(name).toBe('first'); + }, + ); + }); + + it('blocks a blocklisted domain when it ends with a dot', async () => { + await withPhishingDetector( + [ + { + allowlist: [], + blocklist: ['blocked.com'], + fuzzylist: [], + name: 'first', + tolerance: 2, + version: 1, + }, + ], + async ({ detector }) => { + const { result, type } = detector.check( + formatHostnameToUrl('blocked.com.'), + ); + + expect(result).toBe(true); + expect(type).toBe('blocklist'); + }, + ); + }); + + it('blocks ipfs cid across various formats (cids located in subdomains and paths)', async () => { + // CID should not blocked + await withPhishingDetector( + [ + { + allowlist: [], + blocklist: [ + 'QmUDBVyGwqKdSayk7kDKUaj9J41Ft1DWizcKUx5UmgMgGy', + 'bafybeifx7yeb55armcsxwwitkymga5xf53dxiarykms3ygqic223w5sk3m', + 'example.com', + ], + fuzzylist: [], + name: 'first', + tolerance: 2, + version: 1, + }, + ], + async ({ detector }) => { + const { result, type } = detector.check( + formatHostnameToUrl( + 'cf-ipfs.com/ipfs/bafybeiaysi4s6lnjev27ln5icwm6tueaw2vdykrtjkwiphwekaywqhcjze', + ), + ); + + expect(result).toBe(false); + expect(type).toBe(PhishingDetectorResultType.All); + }, + ); + + // Gateways differ on where the CID is... sometimes in the path, sometimes in a magic subdomain + const expectedToBeBlocked = [ + 'ipfs.io/ipfs/bafybeifx7yeb55armcsxwwitkymga5xf53dxiarykms3ygqic223w5sk3m#x-ipfs-companion-no-redirect', + 'gateway.pinata.cloud/ipfs/bafybeifx7yeb55armcsxwwitkymga5xf53dxiarykms3ygqic223w5sk3m#x-ipfs-companion-no-redirect', + 'cloudflare-ipfs.com/ipfs/bafybeifx7yeb55armcsxwwitkymga5xf53dxiarykms3ygqic223w5sk3m#x-ipfs-companion-no-redirect', + 'ipfs.eth.aragon.network/ipfs/bafybeifx7yeb55armcsxwwitkymga5xf53dxiarykms3ygqic223w5sk3m#x-ipfs-companion-no-redirect', + 'bafybeifx7yeb55armcsxwwitkymga5xf53dxiarykms3ygqic223w5sk3m.ipfs.dweb.link/#x-ipfs-companion-no-redirect', + 'bafybeifx7yeb55armcsxwwitkymga5xf53dxiarykms3ygqic223w5sk3m.ipfs.cf-ipfs.com/#x-ipfs-companion-no-redirect', + 'example.com', + 'example.com/foo/bar', + ]; + + // CID should be blocked + for (const entry of expectedToBeBlocked) { + await withPhishingDetector( + [ + { + allowlist: [], + blocklist: [ + 'QmUDBVyGwqKdSayk7kDKUaj9J41Ft1DWizcKUx5UmgMgGy', + 'bafybeifx7yeb55armcsxwwitkymga5xf53dxiarykms3ygqic223w5sk3m', + 'example.com', + ], + fuzzylist: [], + name: 'first', + tolerance: 2, + version: 1, + }, + ], + async ({ detector }) => { + const { result, type } = detector.check( + formatHostnameToUrl(entry), + ); + + expect(result).toBe(true); + expect(type).toBe('blocklist'); + }, + ); + } + }); + + it('returns a result without a version when a config lacks a version and the blocklist contains an ipfs cid', async () => { + await withPhishingDetector( + [ + { + allowlist: [], + blocklist: [ + 'bafybeifx7yeb55armcsxwwitkymga5xf53dxiarykms3ygqic223w5sk3m', + ], + fuzzylist: [], + name: 'first', + tolerance: 2, + }, + ], + async ({ detector }) => { + const { result, version } = detector.check( + formatHostnameToUrl( + 'ipfs.io/ipfs/bafybeifx7yeb55armcsxwwitkymga5xf53dxiarykms3ygqic223w5sk3m#x-ipfs-companion-no-redirect', + ), + ); + + expect(result).toBe(true); + expect(version).toBeUndefined(); + }, + ); + }); + + describe('blocklistPaths', () => { + it('returns true if exact path is blocked', async () => { + await withPhishingDetector( + [ + { + allowlist: [], + blocklist: [], + blocklistPaths: { + 'example.com': { + path: {}, + }, + }, + }, + ], + async ({ detector }) => { + const out = detector.check('https://example.com/path'); + expect(out.result).toBe(true); + }, + ); + }); + }); + }); + + describe('with legacy config', () => { + it('changes the type to whitelist when the result is allowlist', async () => { + await withPhishingDetector( + { + blacklist: [], + fuzzylist: [], + tolerance: 2, + whitelist: ['allowed.com'], + }, + async ({ detector }) => { + const { type, result } = detector.check( + formatHostnameToUrl('allowed.com'), + ); + + expect(type).toBe('whitelist'); + expect(result).toBe(false); + }, + ); + }); + + it('changes the type to blacklist when the result is blocklist', async () => { + await withPhishingDetector( + { + blacklist: ['blocked.com'], + fuzzylist: [], + tolerance: 2, + whitelist: [], + }, + async ({ detector }) => { + const { type, result } = detector.check( + formatHostnameToUrl('blocked.com'), + ); + + expect(type).toBe('blacklist'); + expect(result).toBe(true); + }, + ); + }); + + it('uses the type `fuzzy` when the result is in fuzzylist', async () => { + await withPhishingDetector( + { + blacklist: [], + fuzzylist: ['fuzzy.com'], + tolerance: 2, + whitelist: [], + }, + async ({ detector }) => { + const { type, result } = detector.check( + formatHostnameToUrl('fupzy.com'), + ); + + expect(type).toBe('fuzzy'); + expect(result).toBe(true); + }, + ); + }); + }); + + describe('path-based blocking', () => { + const blocklistPathsOpts = { + 'sub.example.com': { + path1: { + path2: {}, + }, + }, + }; + + it('blocks on the exact path', async () => { + await withPhishingDetector( + [ + { + allowlist: [], + blocklist: [], + blocklistPaths: blocklistPathsOpts, + }, + ], + async ({ detector }) => { + const result = detector.check( + 'https://sub.example.com/path1/path2', + ); + expect(result).toStrictEqual({ + match: 'sub.example.com/path1/path2', + name: undefined, + result: true, + type: PhishingDetectorResultType.Blocklist, + version: undefined, + }); + }, + ); + }); + + it('does not block if not terminal path', async () => { + await withPhishingDetector( + [ + { + allowlist: [], + blocklist: [], + blocklistPaths: blocklistPathsOpts, + }, + ], + async ({ detector }) => { + const result = detector.check('https://sub.example.com/path1'); + expect(result.result).toBe(false); + }, + ); + }); + + it('blocks if the terminal path is present in the URL', async () => { + await withPhishingDetector( + [ + { + allowlist: [], + blocklist: [], + blocklistPaths: blocklistPathsOpts, + }, + ], + async ({ detector }) => { + const result = detector.check( + 'https://sub.example.com/path1/path2/path3', + ); + expect(result.result).toBe(true); + }, + ); + }); + + it('blocks a domain with path when version is defined', async () => { + await withPhishingDetector( + [ + { + allowlist: [], + blocklist: [], + fuzzylist: [], + blocklistPaths: { + 'example.com': { + path: {}, + }, + }, + name: 'test-config', + version: 1, + tolerance: 0, + }, + ], + async ({ detector }) => { + const result = detector.check('https://example.com/path'); + expect(result).toStrictEqual({ + match: 'example.com/path', + name: 'test-config', + result: true, + type: PhishingDetectorResultType.Blocklist, + version: '1', + }); + }, + ); + }); + + it('blocks a domain with path when version is undefined', async () => { + await withPhishingDetector( + [ + { + allowlist: [], + blocklist: [], + fuzzylist: [], + blocklistPaths: { + 'malicious.com': { + phishing: {}, + }, + }, + // version is undefined + tolerance: 0, + }, + ], + async ({ detector }) => { + const result = detector.check('https://malicious.com/phishing'); + expect(result).toStrictEqual({ + match: 'malicious.com/phishing', + name: undefined, + result: true, + type: PhishingDetectorResultType.Blocklist, + version: undefined, + }); + }, + ); + }); + }); + }); + + describe('blockingPath', () => { + const blocklistPathsOpts = { + 'example.com': { + path1: { + path2: {}, + }, + }, + }; + + it('returns the matching terminal path if URL has an exact match in blocklistPaths', async () => { + await withPhishingDetector( + [ + { + blocklist: [], + fuzzylist: [], + blocklistPaths: blocklistPathsOpts, + version: 1, + tolerance: 2, + name: 'test-config', + }, + ], + async ({ detector }) => { + const result = detector.blockingPath( + 'https://example.com/path1/path2', + ); + expect(result).toBe('example.com/path1/path2'); + }, + ); + }); + + it('returns null if the URL path ends at an ancestor path', async () => { + await withPhishingDetector( + [ + { + blocklist: [], + fuzzylist: [], + blocklistPaths: blocklistPathsOpts, + version: 1, + tolerance: 2, + name: 'test-config', + }, + ], + async ({ detector }) => { + const result = detector.blockingPath('https://example.com/path1'); + expect(result).toBeNull(); + }, + ); + }); + + it('returns the matching terminal path if the URL path contains a terminal path', async () => { + await withPhishingDetector( + [ + { + blocklist: [], + fuzzylist: [], + blocklistPaths: blocklistPathsOpts, + version: 1, + tolerance: 2, + name: 'test-config', + }, + ], + async ({ detector }) => { + const result = detector.blockingPath( + 'https://example.com/path1/path2/path3', + ); + expect(result).toBe('example.com/path1/path2'); + }, + ); + }); + + it('returns null if blocklistPaths is empty', async () => { + await withPhishingDetector( + [ + { + blocklist: [], + fuzzylist: [], + blocklistPaths: {}, + name: 'test-config', + version: 1, + tolerance: 2, + }, + ], + async ({ detector }) => { + const result = detector.blockingPath('https://example.com/path'); + expect(result).toBeNull(); + }, + ); + }); + + it('returns null if blocklistPaths is not defined', async () => { + await withPhishingDetector( + [ + { + blocklist: [], + fuzzylist: [], + name: 'test-config', + version: 1, + tolerance: 2, + }, + ], + async ({ detector }) => { + const result = detector.blockingPath('https://example.com/path'); + expect(result).toBeNull(); + }, + ); + }); + + it('returns the matching terminal path if URL matches a blocked path with version undefined', async () => { + await withPhishingDetector( + [ + { + blocklist: [], + fuzzylist: [], + blocklistPaths: { + 'malicious.com': { + phishing: {}, + }, + }, + name: 'test-config', + // version is undefined + tolerance: 2, + }, + ], + async ({ detector }) => { + const result = detector.blockingPath( + 'https://malicious.com/phishing', + ); + expect(result).toBe('malicious.com/phishing'); + }, + ); + }); + }); + + describe('isMaliciousC2Domain', () => { + it('should return false if c2DomainBlocklist is not defined or empty', async () => { + await withPhishingDetector( + [ + { + blocklist: [], + fuzzylist: [], + c2DomainBlocklist: [], + name: 'test-config', + version: 1, + tolerance: 2, + }, + ], + async ({ detector }) => { + const result = detector.isMaliciousC2Domain('https://example.com'); + expect(result).toStrictEqual({ + result: false, + type: PhishingDetectorResultType.C2DomainBlocklist, + }); + }, + ); + }); + + it('check the hash against c2DomainBlocklist, returning the correct result', async () => { + await withPhishingDetector( + [ + { + blocklist: [], + fuzzylist: [], + c2DomainBlocklist: [ + 'a379a6f6eeafb9a55e378c118034e2751e682fab9f2d30ab13d2125586ce1947', + ], + name: 'test-config', + version: 1, + tolerance: 2, + }, + ], + async ({ detector }) => { + const result = detector.isMaliciousC2Domain('https://example.com'); + expect(result).toStrictEqual({ + name: 'test-config', + result: true, + type: PhishingDetectorResultType.C2DomainBlocklist, + version: '1', + }); + }, + ); + }); + + it('check the hash against c2DomainBlocklist, returning the correct result without a version', async () => { + await withPhishingDetector( + [ + { + blocklist: [], + fuzzylist: [], + c2DomainBlocklist: [ + 'a379a6f6eeafb9a55e378c118034e2751e682fab9f2d30ab13d2125586ce1947', + ], + name: 'test-config', + tolerance: 2, + }, + ], + async ({ detector }) => { + const result = detector.isMaliciousC2Domain('https://example.com'); + expect(result).toStrictEqual({ + name: 'test-config', + result: true, + type: PhishingDetectorResultType.C2DomainBlocklist, + version: undefined, + }); + }, + ); + }); + + it('check the hash against c2DomainBlocklist, returning the correct result without a version with sub domains', async () => { + await withPhishingDetector( + [ + { + blocklist: [], + fuzzylist: [], + c2DomainBlocklist: [ + 'a379a6f6eeafb9a55e378c118034e2751e682fab9f2d30ab13d2125586ce1947', // example.com + ], + name: 'test-config', + tolerance: 2, + }, + ], + async ({ detector }) => { + const result = detector.isMaliciousC2Domain( + 'https://sub.sub.evil.example.com', + ); + expect(result).toStrictEqual({ + name: 'test-config', + result: true, + type: PhishingDetectorResultType.C2DomainBlocklist, + version: undefined, + }); + }, + ); + }); + + it('should return false if URL is invalid', async () => { + await withPhishingDetector( + [ + { + blocklist: [], + fuzzylist: [], + c2DomainBlocklist: [ + 'a379a6f6eeafb9a55e378c118034e2751e682fab9f2d30ab13d2125586ce1947', + ], + name: 'test-config', + version: 1, + tolerance: 2, + }, + ], + async ({ detector }) => { + const result = detector.isMaliciousC2Domain('#$@(%&@#$(%'); + expect(result).toStrictEqual({ + result: false, + type: PhishingDetectorResultType.C2DomainBlocklist, + }); + }, + ); + }); + + it('should return true if URL is in the c2DomainBlocklist', async () => { + await withPhishingDetector( + [ + { + blocklist: [], + fuzzylist: [], + c2DomainBlocklist: [ + 'a379a6f6eeafb9a55e378c118034e2751e682fab9f2d30ab13d2125586ce1947', + ], + name: 'test-config', + version: 1, + tolerance: 2, + }, + ], + async ({ detector }) => { + const result = detector.isMaliciousC2Domain('https://example.com'); + expect(result).toStrictEqual({ + name: 'test-config', + result: true, + type: PhishingDetectorResultType.C2DomainBlocklist, + version: '1', + }); + }, + ); + }); + + it('should return false if URL is not in the c2DomainBlocklist', async () => { + await withPhishingDetector( + [ + { + blocklist: [], + fuzzylist: [], + c2DomainBlocklist: ['hash-other.com'], + name: 'test-config', + version: 1, + tolerance: 2, + }, + ], + async ({ detector }) => { + const result = detector.isMaliciousC2Domain('https://example.com'); + expect(result).toStrictEqual({ + result: false, + type: PhishingDetectorResultType.C2DomainBlocklist, + }); + }, + ); + }); + + it('should check all configs and return the result from the first matching config', async () => { + await withPhishingDetector( + [ + { + blocklist: [], + fuzzylist: [], + c2DomainBlocklist: [ + 'a379a6f6eeafb9a55e378c118034e2751e682fab9f2d30ab13d2125586ce1947', + ], + name: 'first-config', + version: 1, + tolerance: 2, + }, + { + blocklist: [], + fuzzylist: [], + c2DomainBlocklist: [], + name: 'second-config', + version: 2, + tolerance: 2, + }, + ], + async ({ detector }) => { + const result = detector.isMaliciousC2Domain('https://example.com'); + expect(result).toStrictEqual({ + name: 'first-config', + result: true, + type: PhishingDetectorResultType.C2DomainBlocklist, + version: '1', + }); + }, + ); + }); + + it('should return allowlist if URL is in the allowlist with something on the blocklist', async () => { + await withPhishingDetector( + [ + { + allowlist: ['example.com'], + blocklist: [], + fuzzylist: [], + c2DomainBlocklist: [ + 'a379a6f6eeafb9a55e378c118034e2751e682fab9f2d30ab13d2125586ce1947', + ], + name: 'first-config', + version: 1, + tolerance: 2, + }, + ], + async ({ detector }) => { + const result = detector.isMaliciousC2Domain('https://example.com'); + expect(result).toStrictEqual({ + match: 'example.com', + name: 'first-config', + result: false, + type: PhishingDetectorResultType.Allowlist, + version: '1', + }); + }, + ); + }); + + it('should return allowlist if URL is in the allowlist', async () => { + await withPhishingDetector( + [ + { + allowlist: ['example.com'], + blocklist: [], + fuzzylist: [], + c2DomainBlocklist: [], + name: 'first-config', + version: 1, + tolerance: 2, + }, + ], + async ({ detector }) => { + const result = detector.isMaliciousC2Domain('https://example.com'); + expect(result).toStrictEqual({ + match: 'example.com', + name: 'first-config', + result: false, + type: PhishingDetectorResultType.Allowlist, + version: '1', + }); + }, + ); + }); + + it('should return allowlist if URL is in the allowlist without a version', async () => { + await withPhishingDetector( + [ + { + allowlist: ['example.com'], + blocklist: [], + fuzzylist: [], + c2DomainBlocklist: [], + name: 'first-config', + tolerance: 2, + }, + ], + async ({ detector }) => { + const result = detector.isMaliciousC2Domain('https://example.com'); + expect(result).toStrictEqual({ + match: 'example.com', + name: 'first-config', + result: false, + type: PhishingDetectorResultType.Allowlist, + version: undefined, + }); + }, + ); + }); + + it('should correctly normalize the hostname by removing the trailing dot', async () => { + await withPhishingDetector( + [ + { + allowlist: ['example.com'], + blocklist: [], + fuzzylist: [], + c2DomainBlocklist: [], + name: 'first-config', + tolerance: 2, + }, + ], + async ({ detector }) => { + const result = detector.isMaliciousC2Domain( + 'https://example.com.', // URL with trailing dot + ); + expect(result).toStrictEqual({ + match: 'example.com', // Expectation is that the trailing dot is removed + name: 'first-config', + result: false, + type: PhishingDetectorResultType.Allowlist, + version: undefined, + }); + }, + ); + }); + + it('should return false with type "c2DomainBlocklist" if no configs have a valid c2DomainBlocklist', async () => { + await withPhishingDetector( + [ + { + blocklist: [], + fuzzylist: [], + name: 'first-config', + version: 1, + tolerance: 2, + }, + { + blocklist: [], + fuzzylist: [], + name: 'second-config', + version: 2, + tolerance: 2, + }, + ], + async ({ detector }) => { + const result = detector.isMaliciousC2Domain('https://example.com'); + expect(result).toStrictEqual({ + result: false, + type: PhishingDetectorResultType.C2DomainBlocklist, + }); + }, + ); + }); + }); + + it('should block a specific subdomain but not the parent domain', async () => { + const blockedSubdomain = '123.pages.dev'; + const blockedSubdomainHash = sha256Hash(blockedSubdomain.toLowerCase()); + + await withPhishingDetector( + [ + { + blocklist: [], + fuzzylist: [], + c2DomainBlocklist: [blockedSubdomainHash], + name: 'subdomain-only-config', + version: 1, + tolerance: 2, + }, + ], + async ({ detector }) => { + const blockedUrl = 'https://123.pages.dev'; + const unblockedUrl = 'https://pages.dev'; + + // Expect the specific subdomain to be blocked + const blockedResult = detector.isMaliciousC2Domain(blockedUrl); + expect(blockedResult).toStrictEqual({ + name: 'subdomain-only-config', + result: true, + type: PhishingDetectorResultType.C2DomainBlocklist, + version: '1', + }); + + // Expect the parent domain to not be blocked + const unblockedResult = detector.isMaliciousC2Domain(unblockedUrl); + expect(unblockedResult).toStrictEqual({ + result: false, + type: PhishingDetectorResultType.C2DomainBlocklist, + }); + }, + ); + }); + + it('should block the parent domain and its subdomains', async () => { + const blockedDomain = 'malicious.xyz'; + const blockedDomainHash = sha256Hash(blockedDomain.toLowerCase()); + + await withPhishingDetector( + [ + { + blocklist: [], + fuzzylist: [], + c2DomainBlocklist: [blockedDomainHash], + name: 'parent-domain-config', + version: 1, + tolerance: 2, + }, + ], + async ({ detector }) => { + const blockedParentUrl = 'https://malicious.xyz'; + const blockedSubdomainUrl = 'https://123.malicious.xyz'; + + // Expect the parent domain to be blocked + const blockedParentResult = + detector.isMaliciousC2Domain(blockedParentUrl); + expect(blockedParentResult).toStrictEqual({ + name: 'parent-domain-config', + result: true, + type: PhishingDetectorResultType.C2DomainBlocklist, + version: '1', + }); + + // Expect the subdomain to also be blocked because the parent domain is blocked + const blockedSubdomainResult = + detector.isMaliciousC2Domain(blockedSubdomainUrl); + expect(blockedSubdomainResult).toStrictEqual({ + name: 'parent-domain-config', + result: true, + type: PhishingDetectorResultType.C2DomainBlocklist, + version: '1', + }); + }, + ); + }); + + it('should not block unrelated subdomains if parent domain is not blocked', async () => { + const blockedSubdomain = '123.pages.dev'; + const blockedSubdomainHash = sha256Hash(blockedSubdomain.toLowerCase()); + + await withPhishingDetector( + [ + { + blocklist: [], + fuzzylist: [], + c2DomainBlocklist: [blockedSubdomainHash], + name: 'unrelated-subdomain-config', + version: 1, + tolerance: 2, + }, + ], + async ({ detector }) => { + const blockedUrl = 'https://123.pages.dev'; + const unrelatedSubdomainUrl = 'https://456.pages.dev'; + const parentDomainUrl = 'https://pages.dev'; + + // Expect the specific subdomain to be blocked + const blockedResult = detector.isMaliciousC2Domain(blockedUrl); + expect(blockedResult).toStrictEqual({ + name: 'unrelated-subdomain-config', + result: true, + type: PhishingDetectorResultType.C2DomainBlocklist, + version: '1', + }); + + // Expect unrelated subdomain to not be blocked + const unrelatedResult = detector.isMaliciousC2Domain( + unrelatedSubdomainUrl, + ); + expect(unrelatedResult).toStrictEqual({ + result: false, + type: PhishingDetectorResultType.C2DomainBlocklist, + }); + + // Expect the parent domain to not be blocked + const parentResult = detector.isMaliciousC2Domain(parentDomainUrl); + expect(parentResult).toStrictEqual({ + result: false, + type: PhishingDetectorResultType.C2DomainBlocklist, + }); + }, + ); + }); + + it('should prioritize allowlist over blocklist even if subdomain is blocked', async () => { + const blockedSubdomain = 'blocked.example.com'; + const blockedSubdomainHash = sha256Hash(blockedSubdomain.toLowerCase()); + + await withPhishingDetector( + [ + { + allowlist: ['example.com'], + blocklist: [], + fuzzylist: [], + c2DomainBlocklist: [blockedSubdomainHash], + name: 'allowlist-over-blocklist-config', + version: 1, + tolerance: 2, + }, + ], + async ({ detector }) => { + const blockedSubdomainUrl = 'https://blocked.example.com'; + const allowlistedUrl = 'https://example.com'; + + // Expect the subdomain to be allowed because the parent domain is in the allowlist + const subdomainResult = + detector.isMaliciousC2Domain(blockedSubdomainUrl); + expect(subdomainResult).toStrictEqual({ + match: 'example.com', + name: 'allowlist-over-blocklist-config', + result: false, + type: PhishingDetectorResultType.Allowlist, + version: '1', + }); + + // Ensure the parent domain is also allowed + const allowlistedResult = detector.isMaliciousC2Domain(allowlistedUrl); + expect(allowlistedResult).toStrictEqual({ + match: 'example.com', + name: 'allowlist-over-blocklist-config', + result: false, + type: PhishingDetectorResultType.Allowlist, + version: '1', + }); + }, + ); + }); + + it('should handle URLs with multiple subdomains correctly', async () => { + const hostname = 'a.b.c.example.com'; + const domainName = 'example.com'; + const hostnameHash = sha256Hash(hostname.toLowerCase()); + const domainNameHash = sha256Hash(domainName.toLowerCase()); + + await withPhishingDetector( + [ + { + blocklist: [], + fuzzylist: [], + c2DomainBlocklist: [hostnameHash, domainNameHash], + name: 'multi-subdomain-config', + version: 1, + tolerance: 2, + }, + ], + async ({ detector }) => { + const deepSubdomainUrl = 'https://a.b.c.example.com'; + const parentDomainUrl = 'https://example.com'; + + // Expect the subdomain to be blocked because its specific hostname is on the blocklist + const deepSubdomainResult = + detector.isMaliciousC2Domain(deepSubdomainUrl); + expect(deepSubdomainResult).toStrictEqual({ + name: 'multi-subdomain-config', + result: true, + type: PhishingDetectorResultType.C2DomainBlocklist, + version: '1', + }); + + // Expect the parent domain to be blocked because it's on the blocklist + const parentDomainResult = + detector.isMaliciousC2Domain(parentDomainUrl); + expect(parentDomainResult).toStrictEqual({ + name: 'multi-subdomain-config', + result: true, + type: PhishingDetectorResultType.C2DomainBlocklist, + version: '1', + }); + }, + ); + }); +}); + +type WithPhishingDetectorCallback = ({ + detector, +}: { + detector: PhishingDetector; +}) => Promise | ReturnValue; + +type WithPhishingDetectorArgs = [ + PhishingDetectorOptions, + WithPhishingDetectorCallback, +]; + +/** + * Build a phishing detector and run a callback with it. + * + * @param args - The phishing detector options and callback. + * @returns The return value of the callback. + */ +async function withPhishingDetector( + ...args: WithPhishingDetectorArgs +): Promise { + const [options, fn] = args; + const detector = new PhishingDetector(options); + return await fn({ + detector, + }); +} diff --git a/packages/phishing-controller/src/PhishingDetector.ts b/packages/phishing-controller/src/PhishingDetector.ts new file mode 100644 index 00000000000..85ba02f2b6d --- /dev/null +++ b/packages/phishing-controller/src/PhishingDetector.ts @@ -0,0 +1,355 @@ +import { distance } from 'fastest-levenshtein'; + +import { matchedPathPrefix } from './PathTrie.js'; +import type { PathTrie } from './PathTrie.js'; +import { PhishingDetectorResultType } from './types.js'; +import type { PhishingDetectorResult } from './types.js'; +import { + domainPartsToDomain, + domainPartsToFuzzyForm, + domainToParts, + generateParentDomains, + getDefaultPhishingDetectorConfig, + getHostnameFromUrl, + matchPartsAgainstList, + processConfigs, + sha256Hash, +} from './utils.js'; + +export type LegacyPhishingDetectorList = { + whitelist?: string[]; + blacklist?: string[]; + c2DomainBlocklist?: string[]; +} & FuzzyTolerance; + +export type PhishingDetectorList = { + allowlist?: string[]; + blocklist?: string[]; + blocklistPaths?: PathTrie; + c2DomainBlocklist?: string[]; + name?: string; + version?: string | number; + tolerance?: number; +} & FuzzyTolerance; + +export type FuzzyTolerance = + | { + tolerance?: number; + fuzzylist: string[]; + } + | { + tolerance?: never; + fuzzylist?: never; + }; + +export type PhishingDetectorOptions = + | LegacyPhishingDetectorList + | PhishingDetectorList[]; + +export type PhishingDetectorConfiguration = { + name?: string; + version?: number | string; + allowlist: string[][]; + blocklist: string[][]; + c2DomainBlocklist?: string[]; + blocklistPaths?: PathTrie; + fuzzylist: string[][]; + tolerance: number; +}; + +type InternalPhishingDetectorConfiguration = Omit< + PhishingDetectorConfiguration, + 'c2DomainBlocklist' +> & { + c2DomainBlocklist?: Set; +}; + +export class PhishingDetector { + readonly #configs: InternalPhishingDetectorConfiguration[]; + + readonly #legacyConfig: boolean; + + /** + * Construct a phishing detector, which can check whether origins are known + * to be malicious or similar to common phishing targets. + * + * A list of configurations is accepted. Each origin checked is processed + * using each configuration in sequence, so the order defines which + * configurations take precedence. + * + * @param opts - Phishing detection options + */ + constructor(opts: PhishingDetectorOptions) { + // recommended configuration + if (Array.isArray(opts)) { + this.#configs = processConfigs(opts).map((config) => ({ + ...config, + c2DomainBlocklist: new Set(config.c2DomainBlocklist), + })); + this.#legacyConfig = false; + // legacy configuration + } else { + this.#configs = [ + { + ...getDefaultPhishingDetectorConfig({ + allowlist: opts.whitelist, + blocklist: opts.blacklist, + fuzzylist: opts.fuzzylist, + tolerance: opts.tolerance, + }), + c2DomainBlocklist: new Set(), + }, + ]; + this.#legacyConfig = true; + } + } + + /** + * Check if a url is known to be malicious or similar to a common phishing + * target. This will check the hostname and IPFS CID that is sometimes + * located in the path. + * + * @param url - The url to check. + * @returns The result of the check. + */ + check(url: string): PhishingDetectorResult { + const result = this.#check(url); + + if (this.#legacyConfig) { + let legacyType = result.type; + if (legacyType === PhishingDetectorResultType.Allowlist) { + legacyType = PhishingDetectorResultType.Whitelist; + } else if (legacyType === PhishingDetectorResultType.Blocklist) { + legacyType = PhishingDetectorResultType.Blacklist; + } + return { + match: result.match, + result: result.result, + type: legacyType, + }; + } + return result; + } + + #check(url: string): PhishingDetectorResult { + const ipfsCidMatch = url.match(ipfsCidRegex()); + + // Check for IPFS CID related blocklist entries + if (ipfsCidMatch !== null) { + // there is a cID string somewhere + // Determine if any of the entries are ipfs cids + // Depending on the gateway, the CID is in the path OR a subdomain, so we do a regex match on it all + const cID = ipfsCidMatch[0]; + for (const { blocklist, name, version } of this.#configs) { + const blocklistMatch = blocklist + .filter((entries) => entries.length === 1) + .find((entries) => { + return entries[0] === cID; + }); + if (blocklistMatch) { + return { + name, + match: cID, + result: true, + type: PhishingDetectorResultType.Blocklist, + version: version === undefined ? version : String(version), + }; + } + } + } + + let domain; + try { + domain = new URL(url).hostname; + } catch { + return { + result: false, + type: PhishingDetectorResultType.All, + }; + } + + const fqdn = domain.endsWith('.') ? domain.slice(0, -1) : domain; + + const source = domainToParts(fqdn); + + for (const { blocklistPaths, name, version } of this.#configs) { + if (!blocklistPaths || Object.keys(blocklistPaths).length === 0) { + continue; + } + const pathMatch = matchedPathPrefix(url, blocklistPaths); + if (pathMatch) { + return { + match: pathMatch, + name, + result: true, + type: PhishingDetectorResultType.Blocklist, + version: version === undefined ? version : String(version), + }; + } + } + + for (const { allowlist, name, version } of this.#configs) { + // if source matches allowlist hostname (or subdomain thereof), PASS + const allowlistMatch = matchPartsAgainstList(source, allowlist); + if (allowlistMatch) { + const match = domainPartsToDomain(allowlistMatch); + return { + match, + name, + result: false, + type: PhishingDetectorResultType.Allowlist, + version: version === undefined ? version : String(version), + }; + } + } + + for (const { + blocklist, + fuzzylist, + name, + tolerance, + version, + } of this.#configs) { + // if source matches blocklist hostname (or subdomain thereof), FAIL + const blocklistMatch = matchPartsAgainstList(source, blocklist); + if (blocklistMatch) { + const match = domainPartsToDomain(blocklistMatch); + return { + match, + name, + result: true, + type: PhishingDetectorResultType.Blocklist, + version: version === undefined ? version : String(version), + }; + } + + if (tolerance > 0) { + // check if near-match of whitelist domain, FAIL + let fuzzyForm = domainPartsToFuzzyForm(source); + // strip www + fuzzyForm = fuzzyForm.replace(/^www\./u, ''); + // check against fuzzylist + const levenshteinMatched = fuzzylist.find((targetParts) => { + const fuzzyTarget = domainPartsToFuzzyForm(targetParts); + const dist = distance(fuzzyForm, fuzzyTarget); + return dist <= tolerance; + }); + if (levenshteinMatched) { + const match = domainPartsToDomain(levenshteinMatched); + return { + name, + match, + result: true, + type: PhishingDetectorResultType.Fuzzy, + version: version === undefined ? version : String(version), + }; + } + } + } + + // matched nothing, PASS + return { result: false, type: PhishingDetectorResultType.All }; + } + + /** + * Gets the specific terminal path from blocklistPaths that is blocking a URL. + * + * @param url - The URL to check. + * @returns The terminal path that is blocking the URL, or null if not blocked. + */ + blockingPath(url: string): string | null { + for (const { blocklistPaths } of this.#configs) { + if (!blocklistPaths || Object.keys(blocklistPaths).length === 0) { + continue; + } + const matchedPath = matchedPathPrefix(url, blocklistPaths); + if (matchedPath) { + return matchedPath; + } + } + + return null; + } + + /** + * Checks if a URL is blocked against the hashed request blocklist. + * This is done by hashing the URL's hostname and checking it against the hashed request blocklist. + * + * @param urlString - The URL to check. + * @returns An object indicating if the URL is blocked and relevant metadata. + */ + isMaliciousC2Domain(urlString: string): PhishingDetectorResult { + const hostname = getHostnameFromUrl(urlString); + if (!hostname) { + return { + result: false, + type: PhishingDetectorResultType.C2DomainBlocklist, + }; + } + + const fqdn = hostname.endsWith('.') ? hostname.slice(0, -1) : hostname; + const sourceParts = domainToParts(fqdn); + + for (const { allowlist, name, version } of this.#configs) { + // if source matches allowlist hostname (or subdomain thereof), PASS + const allowlistMatch = matchPartsAgainstList(sourceParts, allowlist); + if (allowlistMatch) { + const match = domainPartsToDomain(allowlistMatch); + return { + match, + name, + result: false, + type: PhishingDetectorResultType.Allowlist, + version: version === undefined ? version : String(version), + }; + } + } + + const hostnameHash = sha256Hash(hostname.toLowerCase()); + const domainsToCheck = generateParentDomains(sourceParts.reverse(), 5); + + for (const { c2DomainBlocklist, name, version } of this.#configs) { + if (!c2DomainBlocklist || c2DomainBlocklist.size === 0) { + continue; + } + + if (c2DomainBlocklist.has(hostnameHash)) { + return { + name, + result: true, + type: PhishingDetectorResultType.C2DomainBlocklist, + version: version === undefined ? version : String(version), + }; + } + + for (const domain of domainsToCheck) { + const domainHash = sha256Hash(domain); + if (c2DomainBlocklist.has(domainHash)) { + return { + name, + result: true, + type: PhishingDetectorResultType.C2DomainBlocklist, + version: version === undefined ? version : String(version), + }; + } + } + } + // did not match, PASS + return { + result: false, + type: PhishingDetectorResultType.C2DomainBlocklist, + }; + } +} + +/** + * Runs a regex match to determine if a string is a IPFS CID + * + * @returns Regex string for IPFS CID + */ +function ipfsCidRegex() { + // regex from https://stackoverflow.com/a/67176726 + const reg = + 'Qm[1-9A-HJ-NP-Za-km-z]{44,}|b[A-Za-z2-7]{58,}|B[A-Z2-7]{58,}|z[1-9A-HJ-NP-Za-km-z]{48,}|F[0-9A-F]{50,}'; + return new RegExp(reg, 'u'); +} diff --git a/packages/phishing-controller/src/address-poisoning.test.ts b/packages/phishing-controller/src/address-poisoning.test.ts new file mode 100644 index 00000000000..cd89b4b2b40 --- /dev/null +++ b/packages/phishing-controller/src/address-poisoning.test.ts @@ -0,0 +1,125 @@ +import { findSimilarAddresses } from './address-poisoning.js'; + +function getNumberRange(start: number, end: number): number[] { + return Array.from({ length: end - start + 1 }, (_, index) => start + index); +} + +describe('findSimilarAddresses', () => { + const CLASSIC_CANDIDATE = '0x1234aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa5678'; + const CLASSIC_KNOWN = '0x1234bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb5678'; + + it('returns no matches when there are no known addresses', () => { + expect(findSimilarAddresses(CLASSIC_CANDIDATE, [])).toStrictEqual([]); + }); + + it('returns a classic poisoning match with prefix, suffix, score, and diff indices', () => { + expect( + findSimilarAddresses(CLASSIC_CANDIDATE, [CLASSIC_KNOWN]), + ).toStrictEqual([ + { + knownAddress: CLASSIC_KNOWN, + prefixMatchLength: 4, + suffixMatchLength: 4, + poisoningScore: 8, + diffIndices: getNumberRange(6, 37), + }, + ]); + }); + + it('excludes exact matches', () => { + expect( + findSimilarAddresses('0x1234567890abcdef1234567890abcdef12345678', [ + '0x1234567890abcdef1234567890abcdef12345678', + ]), + ).toStrictEqual([]); + }); + + it('matches case-insensitively', () => { + expect( + findSimilarAddresses('0x1234AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA5678', [ + CLASSIC_KNOWN, + ]), + ).toStrictEqual([ + { + knownAddress: CLASSIC_KNOWN, + prefixMatchLength: 4, + suffixMatchLength: 4, + poisoningScore: 8, + diffIndices: getNumberRange(6, 37), + }, + ]); + }); + + it('skips partial matches below the default threshold', () => { + expect( + findSimilarAddresses('0x123aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa567', [ + '0x123bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb567', + ]), + ).toStrictEqual([]); + }); + + it('supports custom thresholds', () => { + expect( + findSimilarAddresses( + '0x123aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa567', + ['0x123bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb567'], + { + prefixLen: 3, + suffixLen: 3, + }, + ), + ).toStrictEqual([ + { + knownAddress: '0x123bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb567', + prefixMatchLength: 3, + suffixMatchLength: 3, + poisoningScore: 6, + diffIndices: getNumberRange(5, 38), + }, + ]); + }); + + it('sorts multiple matches by poisoning score descending', () => { + expect( + findSimilarAddresses('0x12345aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa5678', [ + '0x1234cccccccccccccccccccccccccccccccc5678', + '0x12345eeeeeeeeeeeeeeeeeeeeeeeeeeeeeee5678', + ]), + ).toStrictEqual([ + { + knownAddress: '0x12345eeeeeeeeeeeeeeeeeeeeeeeeeeeeeee5678', + prefixMatchLength: 5, + suffixMatchLength: 4, + poisoningScore: 9, + diffIndices: getNumberRange(7, 37), + }, + { + knownAddress: '0x1234cccccccccccccccccccccccccccccccc5678', + prefixMatchLength: 4, + suffixMatchLength: 4, + poisoningScore: 8, + diffIndices: getNumberRange(6, 37), + }, + ]); + }); + + it('ignores non-hex candidate addresses', () => { + expect( + findSimilarAddresses('not-an-address', [CLASSIC_KNOWN]), + ).toStrictEqual([]); + }); + + it('ignores non-hex known addresses', () => { + expect( + findSimilarAddresses(CLASSIC_CANDIDATE, ['not-an-address']), + ).toStrictEqual([]); + }); + + it('ignores differently-sized known addresses', () => { + expect( + findSimilarAddresses(CLASSIC_CANDIDATE, [ + '0x1234bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb56789', + ]), + ).toStrictEqual([]); + }); +}); diff --git a/packages/phishing-controller/src/address-poisoning.ts b/packages/phishing-controller/src/address-poisoning.ts new file mode 100644 index 00000000000..63bbdbef205 --- /dev/null +++ b/packages/phishing-controller/src/address-poisoning.ts @@ -0,0 +1,102 @@ +import { isValidHexAddress } from '@metamask/controller-utils'; + +import type { SimilarAddressMatch, SimilarityOptions } from './types.js'; + +const DEFAULT_PREFIX_LEN = 4; +const DEFAULT_SUFFIX_LEN = 4; + +function normalizeAddress(address: string): string | null { + if (!isValidHexAddress(address, { allowNonPrefixed: false })) { + return null; + } + + return address.toLowerCase(); +} + +function getPrefixMatchLength(candidate: string, knownAddress: string): number { + let index = 0; + + while (index < candidate.length && candidate[index] === knownAddress[index]) { + index += 1; + } + + return index; +} + +function getSuffixMatchLength(candidate: string, knownAddress: string): number { + let index = 0; + + while ( + index < candidate.length && + candidate[candidate.length - 1 - index] === + knownAddress[knownAddress.length - 1 - index] + ) { + index += 1; + } + + return index; +} + +function getDiffIndices(candidate: string, knownAddress: string): number[] { + const diffIndices: number[] = []; + + for (let index = 0; index < candidate.length; index += 1) { + if (candidate[index] !== knownAddress[index]) { + diffIndices.push(index + 2); + } + } + + return diffIndices; +} + +export function findSimilarAddresses( + candidate: string, + knownAddresses: string[], + options: SimilarityOptions = {}, +): SimilarAddressMatch[] { + const normalizedCandidate = normalizeAddress(candidate); + + if (!normalizedCandidate) { + return []; + } + + const prefixLen = options.prefixLen ?? DEFAULT_PREFIX_LEN; + const suffixLen = options.suffixLen ?? DEFAULT_SUFFIX_LEN; + const candidateBody = normalizedCandidate.slice(2); + + return knownAddresses + .map((knownAddress) => { + const normalizedKnownAddress = normalizeAddress(knownAddress); + + if ( + normalizedKnownAddress?.length !== normalizedCandidate.length || + normalizedKnownAddress === normalizedCandidate + ) { + return null; + } + + const knownAddressBody = normalizedKnownAddress.slice(2); + const prefixMatchLength = getPrefixMatchLength( + candidateBody, + knownAddressBody, + ); + const suffixMatchLength = getSuffixMatchLength( + candidateBody, + knownAddressBody, + ); + + if (prefixMatchLength < prefixLen || suffixMatchLength < suffixLen) { + return null; + } + + return { + knownAddress, + prefixMatchLength, + suffixMatchLength, + poisoningScore: prefixMatchLength + suffixMatchLength, + diffIndices: getDiffIndices(candidateBody, knownAddressBody), + }; + }) + .filter((match): match is SimilarAddressMatch => Boolean(match)) + .sort((left, right) => right.poisoningScore - left.poisoningScore); +} diff --git a/packages/phishing-controller/src/index.ts b/packages/phishing-controller/src/index.ts index 703c8fe9e33..7292c6e2f86 100644 --- a/packages/phishing-controller/src/index.ts +++ b/packages/phishing-controller/src/index.ts @@ -1 +1,53 @@ -export * from './PhishingController'; +export * from './PhishingController.js'; +export { findSimilarAddresses } from './address-poisoning.js'; +export type { + LegacyPhishingDetectorList, + PhishingDetectorList, + FuzzyTolerance, + PhishingDetectorOptions, + PhishingDetectorConfiguration, +} from './PhishingDetector.js'; +export { PhishingDetector } from './PhishingDetector.js'; +export type { + PhishingDetectionScanResult, + AddressScanResult, + BulkTokenScanResponse, + SimilarAddressMatch, + SimilarityOptions, + ApprovalsResponse, + Approval, + Allowance, + ApprovalAsset, + Exposure, + Spender, + ApprovalFeature, +} from './types.js'; +export type { TokenScanCacheData } from './types.js'; +export { TokenScanResultType } from './types.js'; +export { + PhishingDetectorResultType, + RecommendedAction, + AddressScanResultType, + ApprovalResultType, + ApprovalFeatureType, +} from './types.js'; +export type { CacheEntry } from './CacheManager.js'; +export { + PHISHING_DETECTION_PATH_BASED_ROOT_DOMAINS, + getPhishingDetectionScanUrlParam, + isAddressScanSupportedChainId, + isPhishingDetectionPathBasedHostname, +} from './utils.js'; + +export type { + PhishingControllerMaybeUpdateStateAction, + PhishingControllerTestOriginAction, + PhishingControllerIsBlockedRequestAction, + PhishingControllerBypassAction, + PhishingControllerScanUrlAction, + PhishingControllerBulkScanUrlsAction, + PhishingControllerBulkScanTokensAction, + PhishingControllerScanAddressAction, + PhishingControllerGetApprovalsAction, + PhishingControllerCheckAddressPoisoningAction, +} from './PhishingController-method-action-types.js'; diff --git a/packages/phishing-controller/src/tests/utils.ts b/packages/phishing-controller/src/tests/utils.ts new file mode 100644 index 00000000000..c1b6f3833ff --- /dev/null +++ b/packages/phishing-controller/src/tests/utils.ts @@ -0,0 +1,122 @@ +import type { TransactionMeta } from '@metamask/transaction-controller'; +import { + TransactionStatus, + TransactionType, + SimulationTokenStandard, +} from '@metamask/transaction-controller'; + +/** + * Formats a hostname into a URL so we can parse it correctly + * and pass full URLs into the PhishingDetector class. Previously + * only hostnames were supported, but now only full URLs are + * supported since we want to block IPFS CIDs. + * + * @param hostname - the hostname of the URL. + * @returns the href property of a URL object. + */ +export const formatHostnameToUrl = (hostname: string): string => { + let url = ''; + try { + url = new URL(hostname).href; + } catch { + url = new URL(['https://', hostname].join('')).href; + } + return url; +}; + +/** + * Test addresses for consistent use in tests + */ +export const TEST_ADDRESSES = { + MOCK_TOKEN_1: '0x1234567890123456789012345678901234567890' as `0x${string}`, + USDC: '0xA0B86991c6218B36C1D19D4A2E9EB0CE3606EB48' as `0x${string}`, + FROM_ADDRESS: '0x0987654321098765432109876543210987654321' as `0x${string}`, + TO_ADDRESS: '0x1234567890123456789012345678901234567890' as `0x${string}`, +}; + +/** + * Creates a mock token balance change object + * + * @param address - The address of the token + * @param options - The options for the token balance change + * @param options.difference - The difference in the token balance + * @param options.previousBalance - The previous balance of the token + * @param options.newBalance - The new balance of the token + * @param options.isDecrease - Whether the token balance is decreasing + * @param options.standard - The standard of the token + * @returns The mock token balance change object + */ +export const createMockTokenBalanceChange = ( + address: `0x${string}`, + options: { + difference?: `0x${string}`; + previousBalance?: `0x${string}`; + newBalance?: `0x${string}`; + isDecrease?: boolean; + standard?: SimulationTokenStandard; + } = {}, +) => ({ + address, + standard: options.standard ?? SimulationTokenStandard.erc20, + difference: options.difference ?? ('0xde0b6b3a7640000' as `0x${string}`), + previousBalance: options.previousBalance ?? ('0x0' as `0x${string}`), + newBalance: options.newBalance ?? ('0xde0b6b3a7640000' as `0x${string}`), + isDecrease: options.isDecrease ?? false, +}); + +/** + * Creates a mock transaction with token balance changes + * + * @param id - The transaction ID + * @param tokenAddresses - Array of token addresses to include in balance changes + * @param overrides - Partial transaction metadata to override defaults + * @returns The mock transaction metadata object + */ +export const createMockTransaction = ( + id: string, + tokenAddresses: `0x${string}`[] = [], + overrides: Partial = {}, +): TransactionMeta => { + const simulationData = + tokenAddresses.length > 0 + ? { + tokenBalanceChanges: tokenAddresses.map((address) => + createMockTokenBalanceChange(address), + ), + } + : overrides.simulationData; + + return { + txParams: { + from: TEST_ADDRESSES.FROM_ADDRESS, + to: TEST_ADDRESSES.TO_ADDRESS, + value: '0x0' as `0x${string}`, + }, + chainId: '0x1' as `0x${string}`, + id, + networkClientId: 'mainnet', + status: TransactionStatus.unapproved, + time: Date.now(), + type: TransactionType.contractInteraction, + origin: 'https://metamask.io', + submittedTime: Date.now(), + simulationData, + ...overrides, + }; +}; + +/** + * Creates a mock state change payload for TransactionController + * + * @param transactions - The transactions to include in the state change payload. + * @returns A mock state change payload. + */ +export const createMockStateChangePayload = ( + transactions: TransactionMeta[], +) => ({ + transactions, + transactionBatches: [], + methodData: {}, + lastFetchedBlockNumbers: {}, + submitHistory: [], +}); diff --git a/packages/phishing-controller/src/types.ts b/packages/phishing-controller/src/types.ts new file mode 100644 index 00000000000..44a9f677d02 --- /dev/null +++ b/packages/phishing-controller/src/types.ts @@ -0,0 +1,492 @@ +/* eslint-disable @typescript-eslint/naming-convention */ +/** + * Represents the result of checking a domain. + */ +export type PhishingDetectorResult = { + /** + * The name of the configuration object in which the domain was found within + * an allowlist, blocklist, or fuzzylist. + */ + name?: string; + /** + * The version associated with the configuration object in which the domain + * was found within an allowlist, blocklist, or fuzzylist. + */ + version?: string; + /** + * Whether the domain is regarded as allowed (true) or not (false). + */ + result: boolean; + /** + * A normalized version of the domain, which is only constructed if the domain + * is found within a list. + */ + match?: string; + /** + * Which type of list in which the domain was found. + * + * - "allowlist" means that the domain was found in the allowlist. + * - "blocklist" means that the domain was found in the blocklist. + * - "fuzzy" means that the domain was found in the fuzzylist. + * - "blacklist" means that the domain was found in a blacklist of a legacy + * configuration object. + * - "whitelist" means that the domain was found in a whitelist of a legacy + * configuration object. + * - "all" means that the domain was not found in any list. + */ + type: PhishingDetectorResultType; +}; + +/** + * The type of list in which the domain was found. + */ +export enum PhishingDetectorResultType { + /* + * "all" means that the domain was not found in any list. + */ + All = 'all', + /* + * "fuzzy" means that the domain was found in the fuzzylist. + */ + Fuzzy = 'fuzzy', + /* + * "blocklist" means that the domain was found in the blocklist. + */ + Blocklist = 'blocklist', + /* + * "allowlist" means that the domain was found in the allowlist. + */ + Allowlist = 'allowlist', + /* + * "blacklist" means that the domain was found in a blacklist of a legacy + * configuration object. + */ + Blacklist = 'blacklist', + /* + * "whitelist" means that the domain was found in a whitelist of a legacy + * configuration object. + */ + Whitelist = 'whitelist', + /* + * "c2DomainBlocklist" means that the domain was found in the C2 domain blocklist. + */ + C2DomainBlocklist = 'c2DomainBlocklist', +} + +/** + * PhishingDetectionScanResult represents the result of a phishing detection scan. + */ +export type PhishingDetectionScanResult = { + /** + * The hostname that was scanned. + */ + hostname: string; + /** + * Indicates the warning level based on risk factors. + * + * - "NONE" means it is most likely safe. + * - "WARN" means there is some risk. + * - "BLOCK" means it is highly likely to be malicious. + * - "VERIFIED" means it has been associated as an official domain of a + * company or organization and/or a top Web3 domain. + */ + recommendedAction: RecommendedAction; + /** + * An optional error message that exists if: + * - The link requested is not a valid web URL. + * - Failed to fetch the result from the phishing detector. + * + * Consumers can use the existence of this field to retry. + */ + fetchError?: string; +}; + +/** + * Indicates the warning level based on risk factors + */ +export enum RecommendedAction { + /** + * None means it is most likely safe + */ + None = 'NONE', + /** + * Warn means there is some risk + */ + Warn = 'WARN', + /** + * Block means it is highly likely to be malicious + */ + Block = 'BLOCK', + /** + * Verified means it has been associated as an official domain of a + * company or organization and/or a top Web3 domain. + */ + Verified = 'VERIFIED', +} + +/** + * Request for bulk token scan + */ +export type BulkTokenScanRequest = { + chainId: string; + tokens: string[]; +}; + +/** + * Result type of a token scan + */ +export enum TokenScanResultType { + Benign = 'Benign', + Warning = 'Warning', + Malicious = 'Malicious', + Spam = 'Spam', +} + +/** + * Result of a token scan + */ +export type TokenScanResult = { + result_type: TokenScanResultType; + chain: string; + address: string; +}; + +/** + * Response for bulk token scan requests + */ +export type BulkTokenScanResponse = Record; + +/** + * Token data stored in cache (excludes chain and address which are in the key) + * For now, we only cache the result type, but we could add more data if needed in the future + */ +export type TokenScanCacheData = Omit; + +/** + * API response from the bulk token scanning endpoint + */ +export type TokenScanApiResponse = { + results: Record< + string, + { + result_type: TokenScanResultType; + chain?: string; + address?: string; + } + >; +}; + +export const DEFAULT_CHAIN_ID_TO_NAME = { + '0x1': 'ethereum', + '0x89': 'polygon', + '0x38': 'bsc', + '0xa4b1': 'arbitrum', + '0xa86a': 'avalanche', + '0x2105': 'base', + '0xa': 'optimism', + '0x76adf1': 'zora', + '0xe708': 'linea', + '0x27bc86aa': 'degen', + '0x144': 'zksync', + '0x82750': 'scroll', + '0x13e31': 'blast', + '0x74c': 'soneium', + '0x79a': 'soneium-minato', + '0x14a34': 'base-sepolia', + '0xab5': 'abstract', + '0x849ea': 'zero-network', + '0x138de': 'berachain', + '0x82': 'unichain', + '0x7e4': 'ronin', + '0x127': 'hedera', + '0x12c': 'zksync-sepolia', + '0xaa36a7': 'ethereum-sepolia', + '0xa869': 'avalanche-fuji', + '0x343b': 'immutable-zkevm', + '0x34a1': 'immutable-zkevm-testnet', + '0x64': 'gnosis', + '0x1e0': 'worldchain', + '0x8173': 'apechain', + '0x138c5': 'berachain-bartio', + '0xdef1': 'ink', + '0xba5ed': 'ink-sepolia', + '0x2b74': 'abstract-testnet', + '0x531': 'sei', + '0x2eb': 'flow-evm', + '0x8f': 'monad', + '0x3e7': 'hyperevm', + '0xc4': 'xlayer', + '0x10e6': 'megaeth', + '0x1079': 'tempo', + '0xa5bf': 'tempo-testnet', + '0x2019': 'kaia', + '0x1237': 'robinhood', + '0x13b2': 'arc', + '0x2611': 'plasma', + '0x1388': 'mantle', + '0xb67d2': 'katana', + '0x18232': 'plume', + '0x93e': 'kite-ai', + '0x279f': 'monad-testnet', + solana: 'solana', + starknet: 'starknet', + 'starknet-sepolia': 'starknet-sepolia', + stellar: 'stellar', + bitcoin: 'bitcoin', + sui: 'sui', + tron: 'tron', +} as const; + +export type ChainIdToNameMap = typeof DEFAULT_CHAIN_ID_TO_NAME; + +/** + * Result type of an address scan + */ +export enum AddressScanResultType { + /** + * Address is benign/safe + */ + Benign = 'Benign', + /** + * Address has warning indicators + */ + Warning = 'Warning', + /** + * Address is malicious + */ + Malicious = 'Malicious', + /** + * Error occurred during scan + */ + ErrorResult = 'ErrorResult', +} + +/** + * Result of an address security scan + */ +export type AddressScanResult = { + /** + * The result type indicating the security assessment + */ + result_type: AddressScanResultType; + /** + * Additional label or description for the result + */ + label: string; +}; + +/** + * Address data stored in cache (minimal data needed) + */ +export type AddressScanCacheData = { + result_type: AddressScanResultType; + label: string; +}; + +/** + * Similar address match metadata for address poisoning detection. + */ +export type SimilarAddressMatch = { + /** + * The known recipient address that resembles the candidate address. + */ + knownAddress: string; + /** + * Number of matching characters at the start of the address body. + */ + prefixMatchLength: number; + /** + * Number of matching characters at the end of the address body. + */ + suffixMatchLength: number; + /** + * Combined similarity score used to rank matches. + */ + poisoningScore: number; + /** + * Character positions where the candidate and known addresses differ. + * Indices are based on the full hex string, including the `0x` prefix. + */ + diffIndices: number[]; +}; + +/** + * Thresholds for address poisoning similarity detection. + */ +export type SimilarityOptions = { + /** + * Minimum required prefix match length. + */ + prefixLen?: number; + /** + * Minimum required suffix match length. + */ + suffixLen?: number; +}; + +export const APPROVAL_SUPPORTED_CHAINS = [ + 'ethereum', + 'polygon', + 'bsc', + 'avalanche', + 'arbitrum', + 'base', + 'linea', + 'optimism', +] as const; + +export type ApprovalSupportedChain = (typeof APPROVAL_SUPPORTED_CHAINS)[number]; + +export const TOKEN_SCAN_SUPPORTED_CHAINS = [ + 'arbitrum', + 'avalanche', + 'base', + 'bsc', + 'ethereum', + 'optimism', + 'polygon', + 'zora', + 'solana', + 'starknet', + 'starknet-sepolia', + 'stellar', + 'linea', + 'degen', + 'zksync', + 'scroll', + 'blast', + 'soneium-minato', + 'base-sepolia', + 'bitcoin', + 'abstract', + 'soneium', + 'ink', + 'berachain', + 'unichain', + 'ronin', + 'sui', + 'hedera', + 'hyperevm', + 'xlayer', + 'monad', + 'megaeth', + 'tempo', + 'sei', + 'kaia', + 'tron', + 'robinhood', +] as const; + +export type TokenScanSupportedChain = + (typeof TOKEN_SCAN_SUPPORTED_CHAINS)[number]; + +export const ADDRESS_SCAN_SUPPORTED_CHAINS = [ + 'arbitrum', + 'avalanche', + 'base', + 'base-sepolia', + 'bsc', + 'ethereum', + 'optimism', + 'polygon', + 'zksync', + 'zksync-sepolia', + 'zora', + 'linea', + 'blast', + 'scroll', + 'ethereum-sepolia', + 'degen', + 'avalanche-fuji', + 'gnosis', + 'worldchain', + 'soneium-minato', + 'ronin', + 'apechain', + 'berachain', + 'berachain-bartio', + 'ink', + 'ink-sepolia', + 'abstract', + 'abstract-testnet', + 'soneium', + 'unichain', + 'sei', + 'flow-evm', + 'hyperevm', + 'megaeth', + 'katana', + 'plume', + 'xlayer', + 'monad', + 'monad-testnet', + 'tempo', + 'tempo-testnet', + 'kite-ai', + 'kaia', + 'plasma', + 'mantle', + 'robinhood', + 'arc', +] as const; + +export type AddressScanSupportedChain = + (typeof ADDRESS_SCAN_SUPPORTED_CHAINS)[number]; + +export enum ApprovalResultType { + Malicious = 'Malicious', + Warning = 'Warning', + Benign = 'Benign', + ErrorResult = 'Error', +} + +export enum ApprovalFeatureType { + Malicious = 'Malicious', + Warning = 'Warning', + Benign = 'Benign', + Info = 'Info', +} + +export type ApprovalFeature = { + feature_id: string; + type: ApprovalFeatureType; + description: string; +}; + +export type Allowance = { + value?: string; + usd_price?: string; +}; + +export type ApprovalAsset = { + address: string; + symbol: string; + name: string; + decimals: number; + logo_url?: string; + type?: string; +}; + +export type Exposure = { + usd_price?: string; + value: string; + raw_value: string; +}; + +export type Spender = { + address: string; + label?: string; + features?: ApprovalFeature[]; +}; + +export type Approval = { + allowance: Allowance; + asset: ApprovalAsset; + exposure: Exposure; + spender: Spender; + verdict: ApprovalResultType; +}; + +export type ApprovalsResponse = { + approvals: Approval[]; +}; diff --git a/packages/phishing-controller/src/utils.test.ts b/packages/phishing-controller/src/utils.test.ts index b75646c575e..873968e6284 100644 --- a/packages/phishing-controller/src/utils.test.ts +++ b/packages/phishing-controller/src/utils.test.ts @@ -1,19 +1,66 @@ -import sinon from 'sinon'; - -import { ListKeys, ListNames } from './PhishingController'; -import { applyDiffs, fetchTimeNow } from './utils'; +import { ListKeys, ListNames } from './PhishingController.js'; +import type { PhishingListState } from './PhishingController.js'; +import type { TokenScanResultType } from './types.js'; +import { + applyDiffs, + buildCacheKey, + domainToParts, + fetchTimeNow, + generateParentDomains, + getAddressScanSupportedChain, + getHostnameAndPathComponents, + getHostnameFromUrl, + getHostnameFromWebUrl, + getPhishingDetectionScanUrlParam, + isAddressScanSupportedChain, + isAddressScanSupportedChainId, + isPhishingDetectionPathBasedHostname, + isTokenScanSupportedChain, + matchPartsAgainstList, + processConfigs, + processDomainList, + resolveChainName, + roundToNearestMinute, + sha256Hash, + splitCacheHits, + validateConfig, +} from './utils.js'; const exampleBlockedUrl = 'https://example-blocked-website.com'; const exampleBlockedUrlOne = 'https://another-example-blocked-website.com'; const exampleBlockedUrlTwo = 'https://final-example-blocked-website.com'; +const examplec2DomainBlocklistHashOne = + '0415f1f12f07ddc4ef7e229da747c6c53a6a6474fbaf295a35d984ec0ece9455'; const exampleBlocklist = [exampleBlockedUrl, exampleBlockedUrlOne]; - +const examplec2DomainBlocklist = [examplec2DomainBlocklistHashOne]; const exampleAllowUrl = 'https://example-allowlist-item.com'; const exampleFuzzyUrl = 'https://example-fuzzylist-item.com'; const exampleAllowlist = [exampleAllowUrl]; const exampleFuzzylist = [exampleFuzzyUrl]; const exampleListState = { blocklist: exampleBlocklist, + c2DomainBlocklist: examplec2DomainBlocklist, + blocklistPaths: { + 'url1.com': {}, + 'url2.com': { + path2: {}, + }, + 'url3.com': { + path2: { + path3: {}, + }, + }, + 'url4.com': { + path21: { + path31: { + path41: {}, + path42: {}, + }, + path32: {}, + }, + path22: {}, + }, + }, fuzzylist: exampleFuzzylist, tolerance: 2, allowlist: exampleAllowlist, @@ -36,15 +83,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, @@ -72,7 +130,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, @@ -84,7 +145,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, @@ -106,22 +170,28 @@ 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, [exampleAddDiff], - ListKeys.PhishfortHotlist, + ListKeys.EthPhishingDetectConfig, ); expect(result).toStrictEqual({ ...testExistingState, - name: ListNames.Phishfort, + name: ListNames.MetaMask, }); }); 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, @@ -129,14 +199,1297 @@ describe('applyDiffs', () => { const result = applyDiffs( testExistingState, [ - { ...exampleAddDiff, timestamp: 1674773009 }, + { ...exampleAddDiff, timestamp: 1674773005 }, { ...exampleRemoveDiff, timestamp: 1674773004 }, ], - ListKeys.PhishfortHotlist, + ListKeys.EthPhishingDetectConfig, ); expect(result).toStrictEqual({ ...testExistingState, - name: ListNames.Phishfort, + name: ListNames.MetaMask, + }); + }); + // New tests for handling C2 domain blocklist + it('should add hashes to the current C2 domain blocklist', () => { + exampleListState.c2DomainBlocklist = ['hash1', 'hash2']; + const result = applyDiffs( + exampleListState, + [], + ListKeys.EthPhishingDetectConfig, + ['hash3', 'hash4'], + [], + ); + expect(result.c2DomainBlocklist).toStrictEqual([ + ...exampleListState.c2DomainBlocklist, + 'hash3', + 'hash4', + ]); + }); + + it('should remove hashes from the current C2 domain blocklist', () => { + exampleListState.c2DomainBlocklist = ['hash1', 'hash2']; + const result = applyDiffs( + exampleListState, + [], + ListKeys.EthPhishingDetectConfig, + [], + ['hash2'], + ); + expect(result.c2DomainBlocklist).toStrictEqual(['hash1']); + }); + + it('should handle adding and removing hashes simultaneously in C2 domain blocklist', () => { + exampleListState.c2DomainBlocklist = ['hash1', 'hash2']; + const result = applyDiffs( + exampleListState, + [], + ListKeys.EthPhishingDetectConfig, + ['hash3'], + ['hash2'], + ); + expect(result.c2DomainBlocklist).toStrictEqual(['hash1', 'hash3']); + }); + + it('should not add duplicates in C2 domain blocklist', () => { + exampleListState.c2DomainBlocklist = ['hash1', 'hash2']; + const result = applyDiffs( + exampleListState, + [], + ListKeys.EthPhishingDetectConfig, + ['hash2', 'hash3'], + [], + ); + expect(result.c2DomainBlocklist).toStrictEqual(['hash1', 'hash2', 'hash3']); + }); + + it('should handle empty recently added and removed lists for C2 domain blocklist', () => { + exampleListState.c2DomainBlocklist = ['hash1', 'hash2']; + const result = applyDiffs( + exampleListState, + [], + ListKeys.EthPhishingDetectConfig, + [], + [], + ); + expect(result.c2DomainBlocklist).toStrictEqual(['hash1', 'hash2']); + }); + + it('should handle removing a non-existent hash in C2 domain blocklist', () => { + exampleListState.c2DomainBlocklist = ['hash1', 'hash2']; + const result = applyDiffs( + exampleListState, + [], + ListKeys.EthPhishingDetectConfig, + [], + ['hash3'], + ); + expect(result.c2DomainBlocklist).toStrictEqual(['hash1', 'hash2']); + }); + + describe('blocklistPaths handling', () => { + const newAddDiff = (url: string) => ({ + targetList: 'eth_phishing_detect_config.blocklistPaths' as const, + url, + timestamp: 1000000000, + }); + + const newRemoveDiff = (url: string) => ({ + targetList: 'eth_phishing_detect_config.blocklistPaths' as const, + url, + timestamp: 1000000001, + isRemoval: true, + }); + + describe('adding URLs to blocklistPaths', () => { + let listState: PhishingListState; + + beforeEach(() => { + listState = { + ...exampleListState, + blocklistPaths: {}, + }; + }); + + it('adds a URL to the path trie', () => { + const result = applyDiffs( + listState, + [newAddDiff('example.com/path1/path2')], + ListKeys.EthPhishingDetectConfig, + ); + expect(result.blocklistPaths).toStrictEqual({ + 'example.com': { + path1: { + path2: {}, + }, + }, + }); + }); + + it('adds sibling paths', () => { + const firstResult = applyDiffs( + listState, + [newAddDiff('example.com/path1')], + ListKeys.EthPhishingDetectConfig, + ); + const result = applyDiffs( + firstResult, + [{ ...newAddDiff('example.com/path2'), timestamp: 1000000001 }], + ListKeys.EthPhishingDetectConfig, + ); + expect(result.blocklistPaths).toStrictEqual({ + 'example.com': { + path1: {}, + path2: {}, + }, + }); + }); + + it('is idempotent', () => { + applyDiffs( + listState, + [newAddDiff('example.com/path1/path2')], + ListKeys.EthPhishingDetectConfig, + ); + const result = applyDiffs( + listState, + [newAddDiff('example.com/path1/path2')], + ListKeys.EthPhishingDetectConfig, + ); + expect(result.blocklistPaths).toStrictEqual({ + 'example.com': { + path1: { + path2: {}, + }, + }, + }); + }); + + it('prunes descendants when adding ancestor', () => { + applyDiffs( + listState, + [newAddDiff('example.com/path1/path2/path3')], + ListKeys.EthPhishingDetectConfig, + ); + const result = applyDiffs( + listState, + [newAddDiff('example.com/path1')], + ListKeys.EthPhishingDetectConfig, + ); + expect(result.blocklistPaths).toStrictEqual({ + 'example.com': { + path1: {}, + }, + }); + }); + + it('does not insert deeper path if ancestor exists', () => { + const firstResult = applyDiffs( + listState, + [newAddDiff('example.com/path1')], + ListKeys.EthPhishingDetectConfig, + ); + const result = applyDiffs( + firstResult, + [newAddDiff('example.com/path1/path2')], + ListKeys.EthPhishingDetectConfig, + ); + expect(result.blocklistPaths).toStrictEqual({ + 'example.com': { + path1: {}, + }, + }); + }); + + it('does not insert if no path is provided', () => { + const result = applyDiffs( + listState, + [newAddDiff('example.com')], + ListKeys.EthPhishingDetectConfig, + ); + expect(result.blocklistPaths).toStrictEqual({}); + }); + }); + + describe('removing URLs from blocklistPaths', () => { + let listState: PhishingListState; + + beforeEach(() => { + listState = { + ...exampleListState, + blocklistPaths: { + 'example.com': { + path11: { + path2: {}, + }, + path12: {}, + }, + }, + }; + }); + + it('deletes a path', () => { + const result = applyDiffs( + listState, + [newRemoveDiff('example.com/path11/path2')], + ListKeys.EthPhishingDetectConfig, + ); + expect(result.blocklistPaths).toStrictEqual({ + 'example.com': { + path12: {}, + }, + }); + }); + + it('deletes all paths', () => { + const firstResult = applyDiffs( + listState, + [newRemoveDiff('example.com/path11/path2')], + ListKeys.EthPhishingDetectConfig, + ); + const result = applyDiffs( + firstResult, + [{ ...newRemoveDiff('example.com/path12'), timestamp: 1000000002 }], + ListKeys.EthPhishingDetectConfig, + ); + expect(result.blocklistPaths).toStrictEqual({}); + }); + + it('deletes descendants if the path is not terminal', () => { + const result = applyDiffs( + listState, + [newRemoveDiff('example.com/path11')], + ListKeys.EthPhishingDetectConfig, + ); + expect(result.blocklistPaths).toStrictEqual({ + 'example.com': { + path12: {}, + }, + }); + }); + + it('is idempotent', () => { + applyDiffs( + listState, + [newRemoveDiff('example.com/path11/path2')], + ListKeys.EthPhishingDetectConfig, + ); + const result = applyDiffs( + listState, + [newRemoveDiff('example.com/path11/path2')], + ListKeys.EthPhishingDetectConfig, + ); + expect(result.blocklistPaths).toStrictEqual({ + 'example.com': { + path12: {}, + }, + }); + }); + + it('does nothing if path does not exist', () => { + const result = applyDiffs( + listState, + [newRemoveDiff('example.com/nonexistent')], + ListKeys.EthPhishingDetectConfig, + ); + expect(result.blocklistPaths).toStrictEqual(listState.blocklistPaths); + }); + + it('does nothing if hostname does not exist', () => { + const result = applyDiffs( + listState, + [newRemoveDiff('nonexistent.com/path11/path2')], + ListKeys.EthPhishingDetectConfig, + ); + expect(result.blocklistPaths).toStrictEqual(listState.blocklistPaths); + }); + + it('does nothing if no path is provided', () => { + const result = applyDiffs( + listState, + [newRemoveDiff('example.com')], + ListKeys.EthPhishingDetectConfig, + ); + expect(result.blocklistPaths).toStrictEqual(listState.blocklistPaths); + }); + }); + }); +}); + +describe('validateConfig', () => { + it('correctly validates a valid config', () => { + expect(() => + validateConfig({ + allowlist: ['example.com'], + blocklist: ['sub.example.com'], + fuzzylist: ['fuzzy.example.com'], + tolerance: 2, + }), + ).not.toThrow(); + }); + + it('throws an error if the config is not an object', () => { + expect(() => validateConfig(null)).toThrow('Invalid config'); + }); + + it('throws an error if the config contains a tolerance without a fuzzylist', () => { + expect(() => validateConfig({ tolerance: 2 })).toThrow( + 'Fuzzylist tolerance provided without fuzzylist', + ); + }); + + it('throws an error if the config contains an invalid name', () => { + expect(() => validateConfig({ name: 123 })).toThrow( + "Invalid config parameter: 'name'", + ); + }); + + it('throws an error if the config contains an invalid version', () => { + expect(() => validateConfig({ version: { foo: 'bar' } })).toThrow( + "Invalid config parameter: 'version'", + ); + }); +}); + +describe('domainToParts', () => { + it('correctly converts a domain string to an array of parts', () => { + const domain = 'example.com'; + const result = domainToParts(domain); + expect(result).toStrictEqual(['com', 'example']); + }); + + it('correctly converts a domain string with subdomains to an array of parts', () => { + const domain = 'sub.example.com'; + const result = domainToParts(domain); + expect(result).toStrictEqual(['com', 'example', 'sub']); + }); +}); + +describe('processConfigs', () => { + let consoleErrorMock: jest.SpyInstance; + + beforeEach(() => { + consoleErrorMock = jest.spyOn(console, 'error'); + }); + + afterEach(() => { + consoleErrorMock.mockRestore(); + }); + + it('correctly processes a list of valid configs', () => { + const configs = [ + { + allowlist: ['example.com'], + blocklist: ['sub.example.com'], + blocklistPaths: { + 'malicious.com': { + path: {}, + }, + }, + fuzzylist: ['fuzzy.example.com'], + tolerance: 2, + version: 1, + name: 'MetaMask', + }, + ]; + + const result = processConfigs(configs); + + expect(result).toHaveLength(1); + expect(result[0].blocklist).toStrictEqual( + Array.of(['com', 'example', 'sub']), + ); + expect(result[0].blocklistPaths).toStrictEqual({ + 'malicious.com': { + path: {}, + }, }); + expect(result[0].name).toBe('MetaMask'); + + expect(console.error).not.toHaveBeenCalled(); + }); + + it('filters out invalid configs and logs errors', () => { + const configs = [ + { + allowlist: ['example.com'], + blocklist: ['sub.example.com'], + fuzzylist: [], + tolerance: 2, + version: 1, + name: 'MetaMask', + }, + { + allowlist: [], + version: 1, + name: undefined, + }, + ]; + + const result = processConfigs(configs); + + expect(result).toHaveLength(1); + expect(result[0].name).toBe('MetaMask'); + + expect(console.error).toHaveBeenCalledTimes(1); + }); + + it('returns an empty array when called with no arguments', () => { + const result = processConfigs(); + expect(result).toStrictEqual([]); + }); + + it('filters out invalid configs and logs errors with multiple configs', () => { + const configs = [ + { + allowlist: ['example.com'], + blocklist: ['sub.example.com'], + fuzzylist: [], + tolerance: 2, + version: 1, + name: 'MetaMask', + }, + { + allowlist: [], + version: 1, + name: undefined, + }, + { + allowlist: ['example.com'], + blocklist: ['sub.example.com'], + fuzzylist: [], + tolerance: 2, + version: 1, + name: 'name', + }, + { + allowlist: [], + version: 1, + name: '', + }, + ]; + + const result = processConfigs(configs); + + expect(result).toHaveLength(2); + expect(result[0].name).toBe('MetaMask'); + expect(result[1].name).toBe('name'); + + expect(console.error).toHaveBeenCalledTimes(2); + }); + + it('returns an empty array when all configs are invalid', () => { + const configs = [ + { + allowlist: [], + version: 1, + name: undefined, + }, + { + blocklist: [], + fuzzylist: [], + tolerance: 2, + version: null, + name: '', + }, + ]; + + // @ts-expect-error testing invalid input + const result = processConfigs(configs); + + expect(result).toStrictEqual([]); + + expect(console.error).toHaveBeenCalledTimes(2); + }); + + it('logs errors for invalid tolerance or version types', () => { + const configs = [ + { + allowlist: ['example.com'], + blocklist: ['sub.example.com'], + tolerance: 'invalid', + version: 1, + }, + { + allowlist: ['example.com'], + blocklist: ['sub.example.com'], + tolerance: 2, + version: {}, + }, + ]; + + // @ts-expect-error testing invalid input + const result = processConfigs(configs); + + expect(result).toStrictEqual([]); + + expect(console.error).toHaveBeenCalledTimes(2); + }); +}); + +describe('processDomainList', () => { + let consoleWarnMock: jest.SpyInstance; + + beforeEach(() => { + consoleWarnMock = jest.spyOn(console, 'warn').mockImplementation(); + }); + + afterEach(() => { + consoleWarnMock.mockRestore(); + }); + + it('correctly converts a list of domains to an array of parts', () => { + const domainList = ['example.com', 'sub.example.com']; + + const result = processDomainList(domainList); + + expect(result).toStrictEqual([ + ['com', 'example'], + ['com', 'example', 'sub'], + ]); + }); + + it('filters out invalid values and logs warnings', () => { + const domainList = [ + 'example.com', + 123, + 'valid.com', + null, + undefined, + -2342394, + ]; + + const result = processDomainList(domainList as unknown as string[]); + + expect(result).toStrictEqual([ + ['com', 'example'], + ['com', 'valid'], + ]); + + expect(consoleWarnMock).toHaveBeenCalledTimes(4); + expect(consoleWarnMock).toHaveBeenCalledWith( + 'Invalid domain value in list: 123', + ); + expect(consoleWarnMock).toHaveBeenCalledWith( + 'Invalid domain value in list: null', + ); + expect(consoleWarnMock).toHaveBeenCalledWith( + 'Invalid domain value in list: undefined', + ); + expect(consoleWarnMock).toHaveBeenCalledWith( + 'Invalid domain value in list: -2342394', + ); + }); + + it('returns empty array when all values are invalid', () => { + const domainList = [123, null, {}]; + + const result = processDomainList(domainList as unknown as string[]); + + expect(result).toStrictEqual([]); + expect(consoleWarnMock).toHaveBeenCalledTimes(3); + }); +}); + +describe('matchPartsAgainstList', () => { + it('matches a domain against a list of parts', () => { + const domainParts = ['com', 'example']; + const list = [ + ['com', 'example', 'sub'], + ['com', 'example'], + ]; + + const result = matchPartsAgainstList(domainParts, list); + + expect(result).toStrictEqual(['com', 'example']); + }); + + it('returns undefined if there is no match', () => { + const domainParts = ['com', 'examplea']; + const list = [['com', 'exampleb']]; + + const result = matchPartsAgainstList(domainParts, list); + + expect(result).toBeUndefined(); + }); +}); + +describe('sha256Hash', () => { + it('should generate the correct SHA-256 hash for a given domain', async () => { + const hostname = 'develop.d3bkcslj57l47p.amplifyapp.com'; + const expectedHash = + '0415f1f12f07ddc4ef7e229da747c6c53a6a6474fbaf295a35d984ec0ece9455'; + const hash = sha256Hash(hostname); + expect(hash).toBe(expectedHash); + }); + + it('should generate the correct SHA-256 hash for a domain with uppercase letters', async () => { + const hostname = 'develop.d3bkcslj57l47p.Amplifyapp.com'; + const expectedHash = + '0415f1f12f07ddc4ef7e229da747c6c53a6a6474fbaf295a35d984ec0ece9455'; + const hash = sha256Hash(hostname); + expect(hash).toBe(expectedHash); + }); +}); + +describe('roundToNearestMinute', () => { + it('should round down to the nearest minute for a typical Unix timestamp with seconds', () => { + const timestamp = 1622548192; // Represents some time with extra seconds + const expected = 1622548140; // Expected result after rounding down to the nearest minute + expect(roundToNearestMinute(timestamp)).toBe(expected); + }); + + it('should return the same timestamp if it is already rounded to the nearest minute', () => { + const timestamp = 1622548140; // Represents a time already at the exact minute + const expected = 1622548140; + expect(roundToNearestMinute(timestamp)).toBe(expected); + }); + + it('should handle Unix timestamp 0 correctly', () => { + const timestamp = 0; // Edge case: the start of Unix time + const expected = 0; + expect(roundToNearestMinute(timestamp)).toBe(expected); + }); + + it('should correctly round down for timestamps very close to the next minute', () => { + const timestamp = 1622548199; // One second before the next minute + const expected = 1622548140; // Should still round down to the previous minute + expect(roundToNearestMinute(timestamp)).toBe(expected); + }); + + it('should handle very large Unix timestamps correctly', () => { + const timestamp = 1893456000; // A far future Unix timestamp + const expected = 1893456000; // Expected result after rounding down (already rounded) + expect(roundToNearestMinute(timestamp)).toBe(expected); + }); + + it('should handle very small Unix timestamps (close to zero)', () => { + const timestamp = 59; // 59 seconds past the Unix epoch + const expected = 0; // Should round down to the start of Unix time + expect(roundToNearestMinute(timestamp)).toBe(expected); + }); + + it('should handle timestamps exactly at the boundary of a minute', () => { + const timestamp = 1622548200; // Exact boundary of a minute + const expected = 1622548200; // Should return the same timestamp + expect(roundToNearestMinute(timestamp)).toBe(expected); + }); + + it('should handle negative Unix timestamps (dates before 1970)', () => { + const timestamp = -1622548192; // Represents a time before Unix epoch + const expected = -1622548200; // Expected result after rounding down to the nearest minute + expect(roundToNearestMinute(timestamp)).toBe(expected); + }); +}); + +describe('getHostnameFromURL', () => { + it('should extract the hostname from a valid URL', () => { + const url = 'https://www.example.com/path?query=string'; + const expectedHostname = 'www.example.com'; + expect(getHostnameFromUrl(url)).toBe(expectedHostname); + }); + + it('should handle URLs with subdomains correctly', () => { + const url = 'https://subdomain.example.com/path'; + const expectedHostname = 'subdomain.example.com'; + expect(getHostnameFromUrl(url)).toBe(expectedHostname); + }); + + it('should return null for an invalid URL', () => { + let url = 'invalid-url'; + expect(getHostnameFromUrl(url)).toBeNull(); + + url = 'http://.'; + expect(getHostnameFromUrl(url)).toBeNull(); + + url = 'http://..'; + expect(getHostnameFromUrl(url)).toBeNull(); + + url = 'about:blank'; + expect(getHostnameFromUrl(url)).toBeNull(); + }); + + it('should return null for a hostname', () => { + const url = 'www.example.com'; + expect(getHostnameFromUrl(url)).toBeNull(); + }); + + it('should return null for an empty input', () => { + const url = ''; + expect(getHostnameFromUrl(url)).toBeNull(); + }); + + it('should handle URLs with unusual ports correctly', () => { + const url = 'http://localhost:3000'; + const expectedHostname = 'localhost'; + expect(getHostnameFromUrl(url)).toBe(expectedHostname); + }); + + it('should handle URLs with IP addresses', () => { + const url = 'http://192.168.1.1'; + const expectedHostname = '192.168.1.1'; + expect(getHostnameFromUrl(url)).toBe(expectedHostname); + }); + + it('should handle URLs with protocols other than HTTP/HTTPS', () => { + const url = 'ftp://example.com/resource'; + const expectedHostname = 'example.com'; + expect(getHostnameFromUrl(url)).toBe(expectedHostname); + }); + + it('should return null for a URL missing a protocol', () => { + const url = 'www.example.com'; + expect(getHostnameFromUrl(url)).toBeNull(); + }); + + it('should return the correct hostname for URLs with complex query strings', () => { + const url = 'https://www.example.com/path?query=string&another=param'; + const expectedHostname = 'www.example.com'; + expect(getHostnameFromUrl(url)).toBe(expectedHostname); + }); + + it('should handle URLs with fragments correctly', () => { + const url = 'https://www.example.com/path#section'; + const expectedHostname = 'www.example.com'; + expect(getHostnameFromUrl(url)).toBe(expectedHostname); + }); +}); + +describe('getHostnameFromWebUrl', () => { + // each testcase is [input, expectedHostname, expectedValid] + const testCases = [ + ['https://www.example.com/path?query=string', 'www.example.com', true], + ['https://subdomain.example.com/path', 'subdomain.example.com', true], + ['invalid-url', '', false], + ['http://.', '', false], + ['http://..', '', false], + ['about:blank', '', false], + ['www.example.com', '', false], + ['', '', false], + ['http://localhost:3000', 'localhost', true], + ['http://192.168.1.1', '192.168.1.1', true], + ['ftp://example.com/resource', '', false], + ['www.example.com', '', false], + [ + 'https://www.example.com/path?query=string&another=param', + 'www.example.com', + true, + ], + ['https://www.example.com/path#section', 'www.example.com', true], + ] as const; + + it.each(testCases)( + 'for URL %s should return [%s, %s]', + (input, expectedHostname, expectedValid) => { + const [hostname, isValid] = getHostnameFromWebUrl(input); + expect(hostname).toBe(expectedHostname); + expect(isValid).toBe(expectedValid); + }, + ); +}); + +describe('isPhishingDetectionPathBasedHostname', () => { + it('returns true for registered roots and subdomains', () => { + expect(isPhishingDetectionPathBasedHostname('ipfs.io')).toBe(true); + expect(isPhishingDetectionPathBasedHostname('gateway.ipfs.io')).toBe(true); + expect(isPhishingDetectionPathBasedHostname('dweb.link')).toBe(true); + expect(isPhishingDetectionPathBasedHostname('sites.google.com')).toBe(true); + }); + + it('is case-insensitive', () => { + expect(isPhishingDetectionPathBasedHostname('IPFS.IO')).toBe(true); + expect(isPhishingDetectionPathBasedHostname('Gateway.IPFS.IO')).toBe(true); + }); + + it('returns false for unrelated hosts', () => { + expect(isPhishingDetectionPathBasedHostname('example.com')).toBe(false); + expect(isPhishingDetectionPathBasedHostname('evil-ipfs.io')).toBe(false); + }); +}); + +describe('getPhishingDetectionScanUrlParam', () => { + it('returns hostname only for non-gateway hosts', () => { + expect( + getPhishingDetectionScanUrlParam('https://example.com/path?q=1#h'), + ).toStrictEqual(['example.com', true]); + }); + + it('returns hostname plus path for path-based gateway hosts', () => { + expect( + getPhishingDetectionScanUrlParam( + 'https://ipfs.io/ipfs/QmAAA/foo?x=1#frag', + ), + ).toStrictEqual(['ipfs.io/ipfs/QmAAA/foo', true]); + }); + + it('does not append path when pathname is /', () => { + expect( + getPhishingDetectionScanUrlParam('https://dweb.link/'), + ).toStrictEqual(['dweb.link', true]); + }); + + it('returns ok false for invalid web URLs', () => { + expect(getPhishingDetectionScanUrlParam('not-a-url')).toStrictEqual([ + '', + false, + ]); + }); +}); + +/** + * Extracts the domain name (e.g., example.com) from a given hostname. + * + * @param hostname - The full hostname to extract the domain from. + * @returns The extracted domain name. + */ +const extractDomainName = (hostname: string): string => { + const parts = domainToParts(hostname.toLowerCase()); + if (parts.length < 2) { + return hostname; + } + const domainParts = parts.slice(0, 2).reverse(); + return domainParts.join('.'); +}; + +describe('extractDomainName', () => { + it('should extract the primary domain from a standard hostname', () => { + const hostname = 'www.example.com'; + const expected = 'example.com'; + const result = extractDomainName(hostname); + expect(result).toBe(expected); + }); + + it('should extract the primary domain from a hostname with multiple subdomains', () => { + const hostname = 'a.b.c.example.com'; + const expected = 'example.com'; + const result = extractDomainName(hostname); + expect(result).toBe(expected); + }); + + it('should return single-segment hostnames as-is', () => { + const hostname = 'localhost'; + const expected = 'localhost'; + const result = extractDomainName(hostname); + expect(result).toBe(expected); + }); + + it('should extract the last two segments from a hostname with a multi-level TLD', () => { + const hostname = 'sub.example.co.uk'; + const expected = 'co.uk'; + const result = extractDomainName(hostname); + expect(result).toBe(expected); + }); + + it('should handle hostnames with uppercase letters correctly', () => { + const hostname = 'ExAmPlE.CoM'; + const expected = 'example.com'; + const result = extractDomainName(hostname); + expect(result).toBe(expected); + }); + + it('should return an empty string when given an empty hostname', () => { + const hostname = ''; + const expected = ''; + const result = extractDomainName(hostname); + expect(result).toBe(expected); + }); +}); + +describe('generateParentDomains', () => { + it('should return an empty array when sourceParts is empty', () => { + expect(generateParentDomains([], 5)).toStrictEqual([]); + }); + + it('should handle single-segment hostname correctly', () => { + const sourceParts = ['uk']; + const expected = ['uk']; + expect(generateParentDomains(sourceParts)).toStrictEqual(expected); + }); + + it('should handle two-segment hostname correctly', () => { + const sourceParts = ['co', 'uk']; + const expected = ['co.uk']; + expect(generateParentDomains(sourceParts)).toStrictEqual(expected); + }); + + it('should handle three-segment hostname correctly', () => { + const sourceParts = ['domain', 'co', 'uk']; + const expected = ['co.uk', 'domain.co.uk']; + expect(generateParentDomains(sourceParts)).toStrictEqual(expected); + }); + + it('should handle four-segment hostname within limit', () => { + const sourceParts = ['evil', 'domain', 'co', 'uk']; + const expected = ['co.uk', 'domain.co.uk', 'evil.domain.co.uk']; + expect(generateParentDomains(sourceParts)).toStrictEqual(expected); + }); + + it('should handle five-segment hostname within limit', () => { + const sourceParts = ['fifth', 'evil', 'domain', 'co', 'uk']; + const expected = [ + 'co.uk', + 'domain.co.uk', + 'evil.domain.co.uk', + 'fifth.evil.domain.co.uk', + ]; + expect(generateParentDomains(sourceParts, 5)).toStrictEqual(expected); + }); + + it('should handle hostnames exceeding the limit', () => { + const sourceParts = ['a', 'b', 'c', 'd', 'e', 'f', 'g']; + const limit = 5; + const expected = ['f.g', 'e.f.g', 'd.e.f.g', 'c.d.e.f.g', 'b.c.d.e.f.g']; + expect(generateParentDomains(sourceParts, limit)).toStrictEqual(expected); + }); + + it('should lowercase all domain parts', () => { + const sourceParts = ['Evil', 'Domain', 'Co', 'Uk']; + const expected = ['co.uk', 'domain.co.uk', 'evil.domain.co.uk']; + expect(generateParentDomains(sourceParts)).toStrictEqual(expected); + }); + + it('should handle hostnames with empty labels correctly', () => { + const sourceParts = ['a', '', 'b', 'example', 'com']; + // Assuming that empty strings are already filtered out before calling the function + // Thus, sourceParts should be ['a', 'b', 'example', 'com'] + const filteredSourceParts = sourceParts.filter(Boolean); + const expected = ['example.com', 'b.example.com', 'a.b.example.com']; + expect(generateParentDomains(filteredSourceParts)).toStrictEqual(expected); + }); + + it('should handle numeric labels correctly', () => { + const sourceParts = ['123', 'example', 'com']; + const expected = ['example.com', '123.example.com']; + expect(generateParentDomains(sourceParts)).toStrictEqual(expected); + }); + + it('should handle special characters in labels correctly', () => { + const sourceParts = ['sub-domain', 'example', 'com']; + const expected = ['example.com', 'sub-domain.example.com']; + expect(generateParentDomains(sourceParts)).toStrictEqual(expected); + }); + + it('should handle mixed case and empty labels correctly', () => { + const sourceParts = ['A', '', 'B', 'Example', 'Com']; + // After filtering: ['A', 'B', 'Example', 'Com'] + const filteredSourceParts = sourceParts.filter(Boolean); + const expected = ['example.com', 'b.example.com', 'a.b.example.com']; + expect(generateParentDomains(filteredSourceParts)).toStrictEqual(expected); + }); + + it('should handle trailing empty labels correctly', () => { + const sourceParts = ['a', 'b', 'c', '']; + // After filtering: ['a', 'b', 'c'] + const filteredSourceParts = sourceParts.filter(Boolean); + const expected = ['b.c', 'a.b.c']; + expect(generateParentDomains(filteredSourceParts)).toStrictEqual(expected); + }); +}); + +describe('buildCacheKey', () => { + it('should create cache key with lowercase chainId and address', () => { + const chainId = '0x1'; + const address = '0x1234ABCD'; + const result = buildCacheKey(chainId, address); + expect(result).toBe('0x1:0x1234abcd'); + }); + + it('should handle already lowercase inputs', () => { + const chainId = '0xa'; + const address = '0xdeadbeef'; + const result = buildCacheKey(chainId, address); + expect(result).toBe('0xa:0xdeadbeef'); + }); + + it('should handle mixed case inputs', () => { + const chainId = '0X89'; + const address = '0XaBcDeF123456'; + 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', () => { + it('should resolve known chain IDs to chain names', () => { + expect(resolveChainName('0x1')).toBe('ethereum'); + expect(resolveChainName('0x89')).toBe('polygon'); + expect(resolveChainName('0xa')).toBe('optimism'); + }); + + it('should handle case insensitive chain IDs', () => { + expect(resolveChainName('0X1')).toBe('ethereum'); + expect(resolveChainName('0X89')).toBe('polygon'); + 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(); + expect(resolveChainName('')).toBeNull(); + }); +}); + +describe('isTokenScanSupportedChain', () => { + it('returns true for chains supported by token scanning', () => { + expect(isTokenScanSupportedChain('ethereum')).toBe(true); + expect(isTokenScanSupportedChain('polygon')).toBe(true); + expect(isTokenScanSupportedChain('solana')).toBe(true); + expect(isTokenScanSupportedChain('bitcoin')).toBe(true); + expect(isTokenScanSupportedChain('kaia')).toBe(true); + }); + + it('returns false for chains not supported by token scanning', () => { + expect(isTokenScanSupportedChain('gnosis')).toBe(false); + expect(isTokenScanSupportedChain('worldchain')).toBe(false); + expect(isTokenScanSupportedChain('flow-evm')).toBe(false); + }); + + it('returns false for unknown chains', () => { + expect(isTokenScanSupportedChain('unknown-chain')).toBe(false); + expect(isTokenScanSupportedChain('')).toBe(false); + }); +}); + +describe('isAddressScanSupportedChain', () => { + it('returns true for chains supported by address scanning', () => { + expect(isAddressScanSupportedChain('ethereum')).toBe(true); + expect(isAddressScanSupportedChain('polygon')).toBe(true); + expect(isAddressScanSupportedChain('gnosis')).toBe(true); + expect(isAddressScanSupportedChain('flow-evm')).toBe(true); + expect(isAddressScanSupportedChain('mantle')).toBe(true); + }); + + it('returns false for chains not supported by address scanning', () => { + expect(isAddressScanSupportedChain('solana')).toBe(false); + expect(isAddressScanSupportedChain('bitcoin')).toBe(false); + expect(isAddressScanSupportedChain('hedera')).toBe(false); + expect(isAddressScanSupportedChain('stellar')).toBe(false); + }); + + it('returns false for unknown chains', () => { + expect(isAddressScanSupportedChain('unknown-chain')).toBe(false); + expect(isAddressScanSupportedChain('')).toBe(false); + }); +}); + +describe('getAddressScanSupportedChain', () => { + it('returns the chain name for an address-scan supported hex chain ID', () => { + expect(getAddressScanSupportedChain('0x1')).toBe('ethereum'); + expect(getAddressScanSupportedChain('0x1237')).toBe('robinhood'); + }); + + it('matches chain IDs case-insensitively', () => { + expect(getAddressScanSupportedChain('0X1')).toBe('ethereum'); + }); + + it('returns null for a mapped chain that is not address-scan supported', () => { + expect(getAddressScanSupportedChain('0x343b')).toBeNull(); + }); + + it('returns null for an unmapped chain ID', () => { + expect(getAddressScanSupportedChain('0xa4ec')).toBeNull(); + expect(getAddressScanSupportedChain('0xdeadbeef')).toBeNull(); + }); + + it('returns null for non-EVM chain names that are not address-scan supported', () => { + expect(getAddressScanSupportedChain('solana')).toBeNull(); + }); +}); + +describe('isAddressScanSupportedChainId', () => { + it('returns true for address-scan supported hex chain IDs', () => { + expect(isAddressScanSupportedChainId('0x1')).toBe(true); + expect(isAddressScanSupportedChainId('0x1237')).toBe(true); + }); + + it('matches chain IDs case-insensitively', () => { + expect(isAddressScanSupportedChainId('0X1')).toBe(true); + }); + + it('returns false for a mapped chain that is not address-scan supported', () => { + expect(isAddressScanSupportedChainId('0x343b')).toBe(false); + }); + + it('returns false for an unmapped chain ID', () => { + expect(isAddressScanSupportedChainId('0xa4ec')).toBe(false); + expect(isAddressScanSupportedChainId('0xdeadbeef')).toBe(false); + }); + + it('returns false for non-EVM chain names that are not address-scan supported', () => { + expect(isAddressScanSupportedChainId('solana')).toBe(false); + }); +}); + +describe('splitCacheHits', () => { + const mockCache = { + get: jest.fn(), + }; + + beforeEach(() => { + mockCache.get.mockClear(); + }); + + it('should split tokens correctly when some are cached', () => { + const chainId = '0x1'; + const tokens = ['0xTOKEN1', '0xTOKEN2', '0xTOKEN3']; + + // Mock cache to return data for token1 only + const mockResponses = new Map([ + ['0x1:0xtoken1', { result_type: 'Benign' as TokenScanResultType }], + ]); + mockCache.get.mockImplementation((key: string) => mockResponses.get(key)); + + const result = splitCacheHits(mockCache, chainId, tokens); + + expect(result.cachedResults).toStrictEqual({ + '0xtoken1': { + result_type: 'Benign', + chain: '0x1', + address: '0xtoken1', + }, + }); + expect(result.tokensToFetch).toStrictEqual(['0xtoken2', '0xtoken3']); + }); + + it('should handle all tokens being cached', () => { + const chainId = '0x89'; + const tokens = ['0xTOKEN1', '0xTOKEN2']; + + mockCache.get.mockReturnValue({ + result_type: 'Warning' as TokenScanResultType, + }); + + const result = splitCacheHits(mockCache, chainId, tokens); + + expect(result.cachedResults).toStrictEqual({ + '0xtoken1': { + result_type: 'Warning', + chain: '0x89', + address: '0xtoken1', + }, + '0xtoken2': { + result_type: 'Warning', + chain: '0x89', + address: '0xtoken2', + }, + }); + expect(result.tokensToFetch).toStrictEqual([]); + }); + + it('should handle no tokens being cached', () => { + const chainId = '0xa'; + const tokens = ['0xTOKEN1', '0xTOKEN2']; + + mockCache.get.mockReturnValue(undefined); + + const result = splitCacheHits(mockCache, chainId, tokens); + + expect(result.cachedResults).toStrictEqual({}); + expect(result.tokensToFetch).toStrictEqual(['0xtoken1', '0xtoken2']); + }); + + it('should handle empty token list', () => { + const chainId = '0x1'; + const tokens: string[] = []; + + const result = splitCacheHits(mockCache, chainId, tokens); + + expect(result.cachedResults).toStrictEqual({}); + expect(result.tokensToFetch).toStrictEqual([]); + expect(mockCache.get).not.toHaveBeenCalled(); + }); + + it('should normalize addresses to lowercase', () => { + const chainId = '0X1'; + const tokens = ['0XTOKEN1']; + + mockCache.get.mockReturnValue({ + result_type: 'Malicious' as TokenScanResultType, + }); + + const result = splitCacheHits(mockCache, chainId, tokens); + + expect(mockCache.get).toHaveBeenCalledWith('0x1:0xtoken1'); + 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', () => { + it.each([ + [ + 'https://example.com/path1/path2', + { hostname: 'example.com', pathComponents: ['path1', 'path2'] }, + ], + [ + 'example.com/path1/path2', + { hostname: 'example.com', pathComponents: ['path1', 'path2'] }, + ], + ['example.com', { hostname: 'example.com', pathComponents: [] }], + [ + 'EXAMPLE.COM/Path1/PATH2', + { hostname: 'example.com', pathComponents: ['Path1', 'PATH2'] }, + ], + ['', { hostname: '', pathComponents: [] }], + [ + 'example.sub.com/path1/path2', + { hostname: 'example.sub.com', pathComponents: ['path1', 'path2'] }, + ], + [ + 'example.com/%70%61%74%68', + { hostname: 'example.com', pathComponents: ['path'] }, + ], + ])('parses %s correctly', (input, expected) => { + const result = getHostnameAndPathComponents(input); + expect(result).toStrictEqual(expected); }); }); diff --git a/packages/phishing-controller/src/utils.ts b/packages/phishing-controller/src/utils.ts index 914b5da9f3b..5cd354b9795 100644 --- a/packages/phishing-controller/src/utils.ts +++ b/packages/phishing-controller/src/utils.ts @@ -1,9 +1,29 @@ +import { bytesToHex } from '@noble/hashes/utils'; +import { sha256 } from 'ethereum-cryptography/sha256'; + +import { deleteFromTrie, insertToTrie, deepCopyPathTrie } from './PathTrie.js'; +import type { Hotlist, PhishingListState } from './PhishingController.js'; +import { ListKeys, phishingListKeyNameMap } from './PhishingController.js'; +import type { + PhishingDetectorList, + PhishingDetectorConfiguration, +} from './PhishingDetector.js'; +import { + ADDRESS_SCAN_SUPPORTED_CHAINS, + APPROVAL_SUPPORTED_CHAINS, + DEFAULT_CHAIN_ID_TO_NAME, + TOKEN_SCAN_SUPPORTED_CHAINS, +} from './types.js'; import type { - Hotlist, - ListKeys, - PhishingListState, -} from './PhishingController'; -import { phishingListKeyNameMap } from './PhishingController'; + AddressScanSupportedChain, + ApprovalSupportedChain, + TokenScanCacheData, + TokenScanResult, + TokenScanSupportedChain, +} from './types.js'; + +const DEFAULT_TOLERANCE = 3; + /** * Fetches current epoch time in seconds. * @@ -11,6 +31,16 @@ import { phishingListKeyNameMap } from './PhishingController'; */ export const fetchTimeNow = (): number => Math.round(Date.now() / 1000); +/** + * Rounds a Unix timestamp down to the nearest minute. + * + * @param unixTimestamp - The Unix timestamp to be rounded. + * @returns The rounded Unix timestamp. + */ +export function roundToNearestMinute(unixTimestamp: number): number { + return Math.floor(unixTimestamp / 60) * 60; +} + /** * Split a string into two pieces, using the first period as the delimiter. * @@ -27,18 +57,43 @@ const splitStringByPeriod = ( ]; }; +export const getHostnameAndPathComponents = ( + url: string, +): { hostname: string; pathComponents: string[] } => { + const urlWithProtocol = url.startsWith('http') ? url : `https://${url}`; + try { + const { hostname, pathname } = new URL(urlWithProtocol); + return { + hostname: hostname.toLowerCase(), + pathComponents: pathname + .split('/') + .filter(Boolean) + .map((component) => decodeURIComponent(component)), + }; + } catch { + return { + hostname: '', + pathComponents: [], + }; + } +}; + /** * Determines which diffs are applicable to the listState, then applies those diffs. * * @param listState - the stalelist or the existing liststate that diffs will be applied to. * @param hotlistDiffs - the diffs to apply to the listState if valid. * @param listKey - the key associated with the input/output phishing list state. + * @param recentlyAddedC2Domains - list of hashed C2 domains to add to the local c2 domain blocklist + * @param recentlyRemovedC2Domains - list of hashed C2 domains to remove from the local c2 domain blocklist * @returns the new list state */ export const applyDiffs = ( listState: PhishingListState, hotlistDiffs: Hotlist, listKey: ListKeys, + recentlyAddedC2Domains: string[] = [], + recentlyRemovedC2Domains: string[] = [], ): PhishingListState => { // filter to remove diffs that were added before the lastUpdate time. // filter to remove diffs that aren't applicable to the specified list (by listKey). @@ -58,26 +113,515 @@ export const applyDiffs = ( allowlist: new Set(listState.allowlist), blocklist: new Set(listState.blocklist), fuzzylist: new Set(listState.fuzzylist), + c2DomainBlocklist: new Set(listState.c2DomainBlocklist), }; + + // deep copy of blocklistPaths to avoid mutating the original + const newBlocklistPaths = deepCopyPathTrie(listState.blocklistPaths); + for (const { isRemoval, targetList, url, timestamp } of diffsToApply) { const targetListType = splitStringByPeriod(targetList)[1]; if (timestamp > latestDiffTimestamp) { latestDiffTimestamp = timestamp; } + if (isRemoval) { - listSets[targetListType].delete(url); + if (targetListType === 'blocklistPaths') { + deleteFromTrie(url, newBlocklistPaths); + } else { + listSets[targetListType].delete(url); + } + continue; + } + + if (targetListType === 'blocklistPaths') { + insertToTrie(url, newBlocklistPaths); } else { listSets[targetListType].add(url); } } + if (listKey === ListKeys.EthPhishingDetectConfig) { + for (const hash of recentlyAddedC2Domains) { + listSets.c2DomainBlocklist.add(hash); + } + for (const hash of recentlyRemovedC2Domains) { + listSets.c2DomainBlocklist.delete(hash); + } + } + return { + c2DomainBlocklist: Array.from(listSets.c2DomainBlocklist), allowlist: Array.from(listSets.allowlist), blocklist: Array.from(listSets.blocklist), fuzzylist: Array.from(listSets.fuzzylist), + blocklistPaths: newBlocklistPaths, version: listState.version, name: phishingListKeyNameMap[listKey], tolerance: listState.tolerance, lastUpdated: latestDiffTimestamp, }; }; + +/** + * Validates the configuration object for the phishing detector. + * + * @param config - the configuration object to validate. + * @throws an error if the configuration is invalid. + */ +export function validateConfig( + config: unknown, +): asserts config is PhishingListState { + if (config === null || typeof config !== 'object') { + throw new Error('Invalid config'); + } + + if ('tolerance' in config && !('fuzzylist' in config)) { + throw new Error('Fuzzylist tolerance provided without fuzzylist'); + } + + if ( + 'name' in config && + (typeof config.name !== 'string' || config.name === '') + ) { + throw new Error("Invalid config parameter: 'name'"); + } + + if ( + 'version' in config && + (!['number', 'string'].includes(typeof config.version) || + config.version === '') + ) { + throw new Error("Invalid config parameter: 'version'"); + } +} + +/** + * Converts a domain string to a list of domain parts. + * + * @param domain - the domain string to convert. + * @returns the list of domain parts. + */ +export const domainToParts = (domain: string) => { + return domain.split('.').reverse(); +}; + +/** + * Converts a list of domain strings to a list of domain parts. + * + * @param list - the list of domain strings to convert. + * @returns the list of domain parts. + */ +export const processDomainList = (list: string[]): string[][] => { + return list.reduce((acc, domain) => { + if (typeof domain !== 'string') { + console.warn(`Invalid domain value in list: ${JSON.stringify(domain)}`); + return acc; + } + acc.push(domainToParts(domain)); + return acc; + }, []); +}; + +/** + * Gets the default phishing detector configuration. + * + * @param override - the optional override for the configuration. + * @param override.allowlist - the optional allowlist to override. + * @param override.blocklist - the optional blocklist to override. + * @param override.fuzzylist - the optional fuzzylist to override. + * @param override.tolerance - the optional tolerance to override. + * @param override.c2DomainBlocklist - the optional c2DomainBlocklist to override. + * @returns the default phishing detector configuration. + */ +export const getDefaultPhishingDetectorConfig = ({ + allowlist = [], + blocklist = [], + c2DomainBlocklist = [], + fuzzylist = [], + tolerance = DEFAULT_TOLERANCE, +}: { + allowlist?: string[]; + blocklist?: string[]; + c2DomainBlocklist?: string[]; + fuzzylist?: string[]; + tolerance?: number; +}): PhishingDetectorConfiguration => ({ + allowlist: processDomainList(allowlist), + // We can assume that blocklist is already separated into hostname-only entries + // and hostname+path entries so we do not need to separate it again. + blocklist: processDomainList(blocklist), + c2DomainBlocklist, + fuzzylist: processDomainList(fuzzylist), + tolerance, +}); + +/** + * Processes the configurations for the phishing detector, filtering out any invalid configs. + * + * @param configs - The configurations to process. + * @returns An array of processed and valid configurations. + */ +export const processConfigs = ( + configs: PhishingDetectorList[] = [], +): PhishingDetectorConfiguration[] => { + return configs + .filter((config) => { + try { + validateConfig(config); + return true; + } catch (error) { + console.error(error); + return false; + } + }) + .map((config) => ({ + ...config, + ...getDefaultPhishingDetectorConfig(config), + })); +}; + +/** + * Converts a list of domain parts to a domain string. + * + * @param domainParts - the list of domain parts. + * @returns the domain string. + */ +export const domainPartsToDomain = (domainParts: string[]) => { + return domainParts.slice().reverse().join('.'); +}; + +/** + * Converts a list of domain parts to a fuzzy form. + * + * @param domainParts - the list of domain parts. + * @returns the fuzzy form of the domain. + */ +export const domainPartsToFuzzyForm = (domainParts: string[]) => { + return domainParts.slice(1).reverse().join('.'); +}; + +/** + * Matches the target parts, ignoring extra subdomains on source. + * + * @param source - the source domain parts. + * @param list - the list of domain parts to match against. + * @returns the parts for the first found matching entry. + */ +export const matchPartsAgainstList = (source: string[], list: string[][]) => { + return list.find((target) => { + // target domain has more parts than source, fail + if (target.length > source.length) { + return false; + } + // source matches target or (is deeper subdomain) + return target.every((part, index) => source[index] === part); + }); +}; + +/** + * Generate the SHA-256 hash of a hostname. + * + * @param hostname - The hostname to hash. + * @returns The SHA-256 hash of the hostname. + */ +export const sha256Hash = (hostname: string): string => { + const hashBuffer = sha256(new TextEncoder().encode(hostname.toLowerCase())); + return bytesToHex(hashBuffer); +}; + +/** + * Extracts the hostname from a URL. + * + * @param url - The URL to extract the hostname from. + * @returns The hostname extracted from the URL, or null if the URL is invalid. + */ +export const getHostnameFromUrl = (url: string): string | null => { + let hostname; + try { + hostname = new URL(url).hostname; + // above will not throw if 'http://.' is passed. in fact, any string with a dot will pass. + if (!hostname || hostname.split('.').join('') === '') { + return null; + } + } catch { + return null; + } + return hostname; +}; + +/** + * getHostnameFromWebUrl returns the hostname from a web URL. + * It returns the hostname and a boolean indicating if the hostname is valid. + * + * @param url - The web URL to extract the hostname from. + * @returns A tuple containing the extracted hostname and a boolean indicating if the hostname is valid. + * @example + * getHostnameFromWebUrl('https://example.com') // Returns: ['example.com', true] + * getHostnameFromWebUrl('example.com') // Returns: ['', false] + * getHostnameFromWebUrl('https://') // Returns: ['', false] + * getHostnameFromWebUrl('') // Returns: ['', false] + */ +export const getHostnameFromWebUrl = (url: string): [string, boolean] => { + if ( + !url.toLowerCase().startsWith('http://') && + !url.toLowerCase().startsWith('https://') + ) { + return ['', false]; + } + + const hostname = getHostnameFromUrl(url); + return [hostname || '', Boolean(hostname)]; +}; + +/** + * Hosts where PDS single-URL scans include the URL path (shared gateways / hosts where many sites + * share one origin). For all other hosts, only the hostname is sent. + */ +export const PHISHING_DETECTION_PATH_BASED_ROOT_DOMAINS = [ + 'ipfs.io', + 'dweb.link', + 'cf-ipfs.com', + 'cloudflare-ipfs.com', + 'irys.xyz', + 'sites.google.com', +] as const; + +/** + * @param hostname - Lowercase normalization is applied for matching registered roots and subdomains. + * @returns Whether {@link getPhishingDetectionScanUrlParam} appends pathname for this hostname. + */ +export function isPhishingDetectionPathBasedHostname( + hostname: string, +): boolean { + const normalizedHost = hostname.toLowerCase(); + return PHISHING_DETECTION_PATH_BASED_ROOT_DOMAINS.some( + (root) => normalizedHost === root || normalizedHost.endsWith(`.${root}`), + ); +} + +/** + * Builds the `url` query parameter for {@link PhishingController.scanUrl}. For hosts in + * {@link PHISHING_DETECTION_PATH_BASED_ROOT_DOMAINS} (and their subdomains), the value is hostname + * plus pathname, without protocol, query, or fragment. For all other hosts, only hostname is used. + * + * @param url - A web URL string (must use `http:` or `https:` — same rules as {@link getHostnameFromWebUrl}). + * @returns A tuple of `[scanUrlParam, ok]` where `ok` is false when the URL is not a valid web URL. + */ +export const getPhishingDetectionScanUrlParam = ( + url: string, +): [scanUrlParam: string, ok: boolean] => { + const [hostname, ok] = getHostnameFromWebUrl(url); + if (!ok) { + return ['', false]; + } + + if (!isPhishingDetectionPathBasedHostname(hostname)) { + return [hostname, true]; + } + + // `getHostnameFromWebUrl` already required a successful `new URL(url)` parse. + const { pathname } = new URL(url); + const pathSuffix = pathname === '/' ? '' : pathname; + const scanUrlParam = pathSuffix ? `${hostname}${pathSuffix}` : hostname; + + return [scanUrlParam, true]; +}; + +export const getPathnameFromUrl = (url: string): string => { + try { + const { pathname } = new URL(url); + return pathname; + } catch { + return ''; + } +}; + +/** + * Generates all possible parent domains up to a specified limit. + * + * @param sourceParts - The list of domain parts in normal order (e.g., ['evil', 'domain', 'co', 'uk']). + * @param limit - The maximum number of parent domains to generate (default is 5). + * @returns An array of parent domains starting from the base TLD to the most specific subdomain. + * @example + * generateParentDomains(['evil', 'domain', 'co', 'uk'], 5) + * // Returns: ['co.uk', 'domain.co.uk', 'evil.domain.co.uk'] + * + * generateParentDomains(['uk'], 5) + * // Returns: ['uk'] + * + * generateParentDomains(['sub', 'example', 'com'], 5) + * // Returns: ['example.com', 'sub.example.com'] + */ +export const generateParentDomains = ( + sourceParts: string[], + limit = 5, +): string[] => { + const domains: string[] = []; + + if (sourceParts.length === 0) { + return domains; + } + + if (sourceParts.length === 1) { + // Single-segment hostname (e.g., 'uk') + domains.push(sourceParts[0].toLowerCase()); + } else { + // Start with the base domain or TLD (last two labels, e.g., 'co.uk' or 'example.com') + const baseDomain = sourceParts.slice(-2).join('.'); + domains.push(baseDomain.toLowerCase()); + + // Iteratively add one subdomain level at a time, up to the specified limit + for ( + let i = sourceParts.length - 3; + i >= 0 && domains.length < limit; + i-- + ) { + const domain = sourceParts.slice(i).join('.'); + domains.push(domain.toLowerCase()); + } + } + + return domains; +}; + +/** + * Builds a cache key for a token scan result. + * + * @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, + caseSensitive = false, +) => { + const normalizedAddress = caseSensitive ? address : address.toLowerCase(); + return `${chainId.toLowerCase()}:${normalizedAddress}`; +}; + +/** + * Determines whether a chain name is supported for token approval scanning. + * + * @param chain - The chain name to check. + * @returns `true` if the chain is supported, `false` otherwise. + */ +export const isApprovalSupportedChain = ( + chain: string, +): chain is ApprovalSupportedChain => + (APPROVAL_SUPPORTED_CHAINS as readonly string[]).includes(chain); + +/** + * Determines whether a chain name is supported for bulk token scanning. + * + * @param chain - The chain name to check. + * @returns `true` if the chain is supported, `false` otherwise. + */ +export const isTokenScanSupportedChain = ( + chain: string, +): chain is TokenScanSupportedChain => + (TOKEN_SCAN_SUPPORTED_CHAINS as readonly string[]).includes(chain); + +/** + * Determines whether a chain name is supported for address scanning. + * + * @param chain - The chain name to check. + * @returns `true` if the chain is supported, `false` otherwise. + */ +export const isAddressScanSupportedChain = ( + chain: string, +): chain is AddressScanSupportedChain => + (ADDRESS_SCAN_SUPPORTED_CHAINS as readonly string[]).includes(chain); + +/** + * Resolves the chain name from a chain ID. + * + * @param chainId - The chain ID. + * @param mapping - The mapping of chain IDs to chain names. + * @returns The chain name. + */ +export const resolveChainName = ( + chainId: string, + mapping = DEFAULT_CHAIN_ID_TO_NAME, +): string | null => { + return mapping[chainId.toLowerCase() as keyof typeof mapping] ?? null; +}; + +/** + * Resolves a chain ID to a Blockaid address-scan chain name, or `null` if + * `scanAddress` would not call the Security Alerts API for this chain. + * + * @param chainId - Hex chain ID for EVM chains (e.g. `'0x1'`) or a chain + * name for non-EVM chains (e.g. `'solana'`). + * @returns The address-scan chain name, or `null` if unsupported. + */ +export const getAddressScanSupportedChain = ( + chainId: string, +): AddressScanSupportedChain | null => { + const chain = resolveChainName(chainId); + if (!chain || !isAddressScanSupportedChain(chain)) { + return null; + } + return chain; +}; + +/** + * Determines whether `scanAddress` will call the Security Alerts API for + * this chain, rather than immediately returning `ErrorResult`. + * + * Matches the gate inside `scanAddress`: the chain ID must resolve via + * {@link resolveChainName}, and that name must be in + * `ADDRESS_SCAN_SUPPORTED_CHAINS`. + * + * @param chainId - Hex chain ID for EVM chains (e.g. `'0x1'`) or a chain + * name for non-EVM chains (e.g. `'solana'`). + * @returns `true` if an address scan would hit the API. + */ +export const isAddressScanSupportedChainId = (chainId: string): boolean => + getAddressScanSupportedChain(chainId) !== null; + +/** + * Split tokens into cached results and tokens that need to be fetched. + * + * @param cache - Cache-like object with get method. + * @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[]; +} => { + const cachedResults: Record = {}; + const tokensToFetch: string[] = []; + + 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[normalizedAddress] = { + result_type: hit.result_type, + chain: chainId, + address: normalizedAddress, + }; + } else { + tokensToFetch.push(normalizedAddress); + } + } + + return { cachedResults, tokensToFetch }; +}; diff --git a/packages/phishing-controller/tsconfig.build.json b/packages/phishing-controller/tsconfig.build.json index bbfe057a207..3f312d587cd 100644 --- a/packages/phishing-controller/tsconfig.build.json +++ b/packages/phishing-controller/tsconfig.build.json @@ -6,8 +6,21 @@ "rootDir": "./src" }, "references": [ - { "path": "../base-controller/tsconfig.build.json" }, - { "path": "../controller-utils/tsconfig.build.json" } + { + "path": "../base-controller/tsconfig.build.json" + }, + { + "path": "../controller-utils/tsconfig.build.json" + }, + { + "path": "../transaction-controller/tsconfig.build.json" + }, + { + "path": "../messenger/tsconfig.build.json" + }, + { + "path": "../address-book-controller/tsconfig.build.json" + } ], "include": ["../../types", "./src"] } diff --git a/packages/phishing-controller/tsconfig.json b/packages/phishing-controller/tsconfig.json index 7ee9852347a..63ae98b5725 100644 --- a/packages/phishing-controller/tsconfig.json +++ b/packages/phishing-controller/tsconfig.json @@ -4,8 +4,21 @@ "baseUrl": "./" }, "references": [ - { "path": "../base-controller" }, - { "path": "../controller-utils" } + { + "path": "../base-controller" + }, + { + "path": "../controller-utils" + }, + { + "path": "../transaction-controller" + }, + { + "path": "../messenger" + }, + { + "path": "../address-book-controller" + } ], - "include": ["../../types", "./src"] + "include": ["../../types", "./src", "./tests"] } diff --git a/packages/platform-api-docs/CHANGELOG.md b/packages/platform-api-docs/CHANGELOG.md new file mode 100644 index 00000000000..dc6fb9d7a20 --- /dev/null +++ b/packages/platform-api-docs/CHANGELOG.md @@ -0,0 +1,14 @@ +# Changelog + +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [Unreleased] + +### Added + +- Initial release of the platform-api-docs package ([#8012](https://github.com/MetaMask/core/pull/8012), [#9913](https://github.com/MetaMask/core/pull/9913)) + +[Unreleased]: https://github.com/MetaMask/core/ diff --git a/packages/platform-api-docs/LICENSE b/packages/platform-api-docs/LICENSE new file mode 100644 index 00000000000..8cb538233e8 --- /dev/null +++ b/packages/platform-api-docs/LICENSE @@ -0,0 +1,6 @@ +This project is licensed under either of + + * MIT license ([LICENSE.MIT](LICENSE.MIT)) + * Apache License, Version 2.0 ([LICENSE.APACHE2](LICENSE.APACHE2)) + +at your option. \ No newline at end of file diff --git a/packages/platform-api-docs/LICENSE.APACHE2 b/packages/platform-api-docs/LICENSE.APACHE2 new file mode 100644 index 00000000000..cd780528412 --- /dev/null +++ b/packages/platform-api-docs/LICENSE.APACHE2 @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright 2025 MetaMask + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. \ No newline at end of file diff --git a/packages/platform-api-docs/LICENSE.MIT b/packages/platform-api-docs/LICENSE.MIT new file mode 100644 index 00000000000..c259cd7ebcf --- /dev/null +++ b/packages/platform-api-docs/LICENSE.MIT @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2025 MetaMask + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packages/platform-api-docs/README.md b/packages/platform-api-docs/README.md new file mode 100644 index 00000000000..47fbbeada2a --- /dev/null +++ b/packages/platform-api-docs/README.md @@ -0,0 +1,79 @@ +# `@metamask/platform-api-docs` + +Produces documentation for the platform API, the set of actions and events available in clients through the message bus. + +When run within a project (such as `metamask-extension` or `metamask-mobile`), this tool looks for messenger action and event types declared within TypeScript source and declaration files within MetaMask NPM packages. It extracts all of the JSDoc from these actions and events, then outputs them into a searchable Docusaurus site. + +## Installation + +1. Add this package as a development dependency: + + `yarn add @metamask/platform-api-docs` + + or + + `npm install @metamask/platform-api-docs` + +2. Add a script to your project's `package.json`. For example: + ```json + { + "scripts": { + "docs:platform-api:build": "platform-api-docs --build --project-label MyProject" + } + } + ``` + +## Usage + +``` +platform-api-docs [project-path] [options] + +Arguments: + project-path Path to the project to scan (default: current directory) + +Options: + --build Generate docs and build static site + --serve Generate docs, build, and serve static site + --dev Generate docs and start dev server with hot reload + --strategy How to find actions and events: "scan" (default) or + "root-messenger" (see below) + --scan-dir Extra source directory to scan (repeatable; --strategy scan only) + --root-actions Type aliasing the union of every action, as "#" + (required with --strategy root-messenger) + --root-events Type aliasing the union of every event, as "#" + (required with --strategy root-messenger) + --output Output directory (default: /.platform-api-docs) + --project-label